This commit is contained in:
beabigegg
2025-08-27 18:03:54 +08:00
commit b9557250a4
31 changed files with 2353 additions and 0 deletions

40
.gitignore vendored Normal file
View File

@@ -0,0 +1,40 @@
# --- 敏感資訊 (Sensitive Information) ---
# 忽略包含所有密鑰和資料庫連線資訊的環境變數檔案。
# --- 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/
.env

133
README.md Normal file
View File

@@ -0,0 +1,133 @@
# TEMP Spec System - 暫時規範管理系統 (ONLYOFFICE-Edition)
這是一個使用 Flask 開發的 Web 應用程式,旨在管理、追蹤和存檔暫時性的工程規範。此版本已整合 ONLYOFFICE Document Server提供強大的所見即所得WYSIWYG線上文件編輯體驗。
## 核心功能
- **使用者權限管理**: 內建三種角色 (`viewer`, `editor`, `admin`),各角色擁有不同操作權限。
- **規範生命週期**: 支援暫時規範的建立、啟用、展延、終止與刪除。
- **線上文件編輯**: 整合 ONLYOFFICE Document Server支援多人協作與專業級的線上 Word 文件編輯。
- **範本自動填入**: 建立規範時,可將表單資料自動填入 Word 範本中。
- **檔案管理**: 支援上傳簽核後的文件,並與對應的規範進行關聯。
- **歷史紀錄**: 詳細記錄每一份規範的所有變更歷史,方便追蹤與稽核。
---
## 環境要求
在部署此應用程式之前,請確保您的系統已安裝以下軟體:
1. **Python**: 建議使用 `Python 3.10` 或更高版本。
2. **MySQL**: 需要一個 MySQL 資料庫來儲存所有應用程式資料。
3. **Docker**: **[重要]** 本專案依賴 ONLYOFFICE Document Server推薦使用 Docker 進行部署和管理。請確保您的伺服器已安裝並運行 Docker。
4. **Git**: 用於從版本控制系統下載程式碼。
---
## 安裝與設定步驟
請依照以下步驟來設定您的開發或生產環境:
### 1. 下載程式碼
```bash
git clone <your-repository-url>
cd TEMP_spec_system_V2
```
### 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. 設定環境變數
複製範例檔案,並填入您的實際設定:
```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
DATABASE_URL="mysql+pymysql://user:password@host:port/dbname"
# --- ONLYOFFICE 設定 ---
# 您 ONLYOFFICE Document Server 的公開存取位址
ONLYOFFICE_URL="http://localhost:8080/"
# 用於保護 ONLYOFFICE 通訊的 JWT 密鑰 (請務必修改為一個新的隨機長字串)
ONLYOFFICE_JWT_SECRET="your-onlyoffice-jwt-secret-string"
```
**注意**: 請先在您的 MySQL 中手動建立一個資料庫。
### 5. 啟動 ONLYOFFICE Document Server
使用 Docker 啟動 ONLYOFFICE Document Server並啟用 JWT 驗證。
```bash
docker run -i -t -d -p 8080:80 --restart=always \
-e JWT_ENABLED=true \
-e JWT_SECRET="your-onlyoffice-jwt-secret-string" \
onlyoffice/documentserver
```
**[重要]**:指令中的 `JWT_SECRET` 值,必須和您在 `.env` 檔案中設定的 `ONLYOFFICE_JWT_SECRET` **完全一致**
### 6. 初始化資料庫
執行初始化腳本來建立所有需要的資料表,並產生一個預設的管理員帳號。
```bash
python init_db.py
```
腳本會提示您確認操作。輸入 `yes` 後,它會建立資料表並在終端機中顯示預設 `admin` 帳號的隨機密碼。**請務必記下此密碼**。
---
## 執行應用程式
### 開發模式
**請確保您的 ONLYOFFICE Docker 容器已在運行中**,然後在另一個終端機視窗執行 `app.py`
```bash
python app.py
```
應用程式預設會在 `http://127.0.0.1:5000` 上執行。
### 生產環境
在生產環境中,建議使用生產級的 WSGI 伺服器,例如 `Gunicorn` (Linux) 或 `Waitress` (Windows)。
**使用 Waitress (Windows) 的範例:**
```bash
pip install waitress
waitress-serve --host=0.0.0.0 --port=8000 app:app
```

105
USER_MANUAL.md Normal file
View File

@@ -0,0 +1,105 @@
# 系統操作說明書 (User Manual)
歡迎使用新版「暫時規範管理系統」。本說明書將引導您如何操作整合了 ONLYOFFICE 線上編輯器的新系統。
## 1. 系統簡介
本系統旨在提供一個集中化平台,用於管理、追蹤和存檔所有暫時性的工程規範。它涵蓋了從草擬、線上編輯、簽核、生效到最終歸檔的完整生命週期。
---
## 2. 登入與主畫面
### 2.1 登入
請使用管理員提供的帳號和密碼,在首頁進行登入。
### 2.2 主畫面 (暫時規範總表)
登入後的主畫面會列出所有暫時規範,功能包含:
- **建立新規範按鈕**: (僅 Editor/Admin 可見) 點擊此處開始建立一份新的規範。
- **搜尋與篩選區**: 可根據「編號」、「主題」或「狀態」快速找到目標。
- **規範列表**: 顯示每份規範的關鍵資訊。
- **操作按鈕**: 根據您的「角色」和規範的「狀態」提供不同的操作選項。
---
## 3. 核心操作流程
### 3.1 流程總覽
新版系統的標準工作流程如下:
1. **Editor** 填寫初始資料表單,建立一份新的暫時規範草稿。
2. 系統將初始資料**自動填入** Word 範本,並在瀏覽器中開啟 **ONLYOFFICE 線上編輯器**
3. **Editor** 在線上完成文件的詳細內容撰寫,文件會自動儲存。
4. 完成後,可下載最終的 Word 文件 (`.docx`),進行線下簽核流程。
5. 簽核完成後,將文件儲存為 **PDF (`.pdf`) 格式**
6. **Admin** 登入系統,上傳簽核完成的 PDF 檔案,正式**啟用**該規範。
### 3.2 建立新的暫時規範 (Editor / Admin)
1. 在主畫面點擊 **[+ 建立新規範]** 按鈕。
2. 在「建立新的暫時規範」頁面填寫所有初始資料如主題、站別、Package 等。
3. 點擊 **[建立並開始編輯]** 按鈕。
4. 系統將會自動重新導向至 ONLYOFFICE 編輯器頁面,您會看到一個已預先填好初始資料的 Word 文件。
5. 在線上編輯器中完成所有內容的修改與撰寫。您的所有變更都會**自動儲存**。完成後可直接關閉分頁或返回總表。
### 3.3 啟用暫時規範 (僅限 Admin)
當一份規範的線下簽核流程完成後,管理員需執行以下操作使其生效:
1. 在總表中找到狀態為「**待生效**」的目標規範。
2. 點擊該規範右側的 **啟用圖示 (✅)**
3. 在「啟用暫時規範」頁面,點擊 **[選擇檔案]**,並上傳**已簽核完成的 PDF 檔案**。
4. 點擊 **[啟用規範]** 按鈕。
5. 完成後,該規範的狀態會變為「**已生效**」。
### 3.4 管理已生效的規範 (Editor / Admin)
對於「**已生效**」的規範,您可以進行展延或提早終止:
- **展延**:
1. 點擊 **展延圖示 (📅+**)。
2. 選擇新的結束日期,並**必須上傳新的佐證文件 (PDF 格式)**。
3. 點擊儲存,規範的效期將會延長。
- **終止**:
1. 點擊 **終止圖示 (❌)**
2. 填寫提早終止的原因。
3. 提交後,規範狀態將變為「**已終止**」。
### 3.5 搜尋、篩選與下載
- **搜尋/篩選**: 在主畫面的搜尋框和下拉選單中操作。
- **下載**:
- **待生效規範**:
- Editor 和 Admin 可下載仍在編輯中的 **Word** 原始檔。
- **已生效/已終止/已過期規範**:
- 所有角色都可以下載由 Admin 上傳的**最終簽核版 PDF**。
### 3.6 檢視歷史紀錄
點擊規範最右側的 **歷史紀錄圖示 (🕒)**,可查看該規範的所有變更紀錄。
---
## 4. 使用者管理 (僅限 Admin)
管理員可點擊導覽列的 **[帳號管理]** 來新增、編輯或刪除使用者帳號。
---
## 5. 常見問題 (FAQ)
**Q: 為什麼我看不到「建立規範」或「啟用」的按鈕?**
A: 您的帳號權限不足。建立規範需要 `Editor``Admin` 權限;啟用規範僅限 `Admin`。如有需要,請聯繫系統管理員。
**Q: 我忘記密碼了怎麼辦?**
A: 請聯繫系統管理員,請他/她為您重設密碼。
**Q: 為什麼編輯器頁面顯示 "Download failed" 或無法儲存?**
A: 這通常是開發環境的網路設定問題。請確認您主機的防火牆是否允許來自 Docker 的連線,或聯繫系統管理員檢查網路設定。
**Q: 我可以上傳 Word 檔案來啟用規範嗎?**
A: 不行。為了確保文件的最終性和不可修改性,系統規定必須上傳已簽核的 **PDF 檔案**來啟用規範。

54
app.py Normal file
View File

@@ -0,0 +1,54 @@
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():
# 檢查使用者是否已經通過驗證 (已登入)
if current_user.is_authenticated:
# 如果已登入,直接導向到暫規總表
return redirect(url_for('temp_spec.spec_list'))
else:
# 如果未登入,才導向到登入頁面
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)

31
config.py Normal file
View File

@@ -0,0 +1,31 @@
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
ONLYOFFICE_URL = os.getenv('ONLYOFFICE_URL')
ONLYOFFICE_JWT_SECRET = os.getenv('ONLYOFFICE_JWT_SECRET')
# LDAP Configuration
LDAP_SERVER = os.getenv('LDAP_SERVER')
LDAP_PORT = int(os.getenv('LDAP_PORT', 389))
LDAP_USE_SSL = os.getenv('LDAP_USE_SSL', 'false').lower() in ['true', '1', 't']
LDAP_BIND_USER_DN = os.getenv('LDAP_BIND_USER_DN')
LDAP_BIND_USER_PASSWORD = os.getenv('LDAP_BIND_USER_PASSWORD')
LDAP_SEARCH_BASE = os.getenv('LDAP_SEARCH_BASE') # e.g., 'ou=users,dc=panjit,dc=com,dc=tw'
LDAP_USER_LOGIN_ATTR = os.getenv('LDAP_USER_LOGIN_ATTR', 'userPrincipalName') # AD attribute for user login (e.g., user@panjit.com.tw)
# SMTP Configuration
SMTP_SERVER = os.getenv('SMTP_SERVER', 'mail.panjit.com.tw')
SMTP_PORT = int(os.getenv('SMTP_PORT', 25))
SMTP_USE_TLS = os.getenv('SMTP_USE_TLS', 'false').lower() in ['true', '1', 't']
SMTP_SENDER_EMAIL = os.getenv('SMTP_SENDER_EMAIL')
SMTP_SENDER_PASSWORD = os.getenv('SMTP_SENDER_PASSWORD')

72
init_db.py Normal file
View 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("❌ 操作已取消。")

118
ldap_utils.py Normal file
View File

@@ -0,0 +1,118 @@
from ldap3 import Server, Connection, ALL, Tls
import ssl
from flask import current_app
def authenticate_ldap_user(username, password):
"""
Authenticates a user against the LDAP server using their credentials.
Returns a dictionary with user info upon success, otherwise None.
"""
# Ensure username is in UPN format (e.g., user@domain.com)
# Assuming the domain part is derivable or static.
# This logic might need adjustment based on how users log in.
if '@' not in username:
# This assumes a fixed domain, which should be configured or improved
domain = current_app.config['LDAP_SEARCH_BASE'].split('dc=')[1].replace(',', '.')
user_upn = f"{username}@{domain}"
else:
user_upn = username
ldap_server = current_app.config['LDAP_SERVER']
ldap_port = current_app.config['LDAP_PORT']
use_ssl = current_app.config['LDAP_USE_SSL']
server_options = {'host': ldap_server, 'port': ldap_port, 'use_ssl': use_ssl}
if use_ssl:
tls_config = Tls(validate=ssl.CERT_NONE, version=ssl.PROTOCOL_TLSv1_2)
server_options['tls'] = tls_config
server = Server(**server_options, get_info=ALL)
try:
# Attempt to bind with the user's credentials to authenticate
conn = Connection(server, user=user_upn, password=password, auto_bind=True)
if conn.bound:
# Authentication successful. Now, get user details.
search_base = current_app.config['LDAP_SEARCH_BASE']
login_attr = current_app.config['LDAP_USER_LOGIN_ATTR']
search_filter = f'({login_attr}={user_upn})'
conn.search(search_base, search_filter, attributes=['dn', 'mail', 'displayName', 'sAMAccountName'])
if conn.entries:
entry = conn.entries[0]
user_info = {
'dn': entry.entry_dn,
'email': str(entry.mail) if 'mail' in entry else None,
'display_name': str(entry.displayName) if 'displayName' in entry else None,
'username': str(entry.sAMAccountName) if 'sAMAccountName' in entry else username
}
conn.unbind()
return user_info
else:
# This case is unlikely if bind succeeded, but handle it just in case
conn.unbind()
return None
else:
# Authentication failed
return None
except Exception as e:
# Log the exception in a real application
print(f"LDAP authentication error: {e}")
return None
def get_ldap_group_members(group_name):
"""
Retrieves a list of email addresses for members of a given LDAP group.
Uses the application's bind credentials for searching.
"""
ldap_server = current_app.config['LDAP_SERVER']
ldap_port = current_app.config['LDAP_PORT']
use_ssl = current_app.config['LDAP_USE_SSL']
bind_dn = current_app.config['LDAP_BIND_USER_DN']
bind_password = current_app.config['LDAP_BIND_USER_PASSWORD']
search_base = current_app.config['LDAP_SEARCH_BASE']
server_options = {'host': ldap_server, 'port': ldap_port, 'use_ssl': use_ssl}
if use_ssl:
tls_config = Tls(validate=ssl.CERT_NONE, version=ssl.PROTOCOL_TLSv1_2)
server_options['tls'] = tls_config
server = Server(**server_options, get_info=ALL)
try:
# Bind with the service account
conn = Connection(server, user=bind_dn, password=bind_password, auto_bind=True)
if conn.bound:
# First, find the group to get its member list
group_search_filter = f'(&(objectClass=group)(cn={group_name}))'
conn.search(search_base, group_search_filter, attributes=['member'])
if not conn.entries:
print(f"LDAP group '{group_name}' not found.")
conn.unbind()
return []
members_dn = conn.entries[0].member.values
emails = []
# For each member DN, fetch their email
for member_dn in members_dn:
member_filter = f'(objectDN={member_dn})'
conn.search(member_dn, '(objectClass=*)', attributes=['mail'])
if conn.entries and 'mail' in conn.entries[0]:
emails.append(str(conn.entries[0].mail))
conn.unbind()
return emails
else:
print("Failed to bind to LDAP with service account.")
return []
except Exception as e:
print(f"LDAP group search error: {e}")
return []

59
models.py Normal file
View File

@@ -0,0 +1,59 @@
from flask_sqlalchemy import SQLAlchemy
from flask_login import UserMixin
from datetime import datetime
db = SQLAlchemy()
class User(db.Model, UserMixin):
# 修改 table name
__tablename__ = 'ts_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):
# 新增並設定 table name
__tablename__ = 'ts_temp_spec'
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):
# 新增並設定 table name
__tablename__ = 'ts_upload'
id = db.Column(db.Integer, primary_key=True)
# 注意:這裡的 ForeignKey 也要更新為新的 table name
temp_spec_id = db.Column(db.Integer, db.ForeignKey('ts_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):
# 修改 table name
__tablename__ = 'ts_spec_history'
id = db.Column(db.Integer, primary_key=True)
# 注意:這裡的 ForeignKey 也要更新為新的 table name
spec_id = db.Column(db.Integer, db.ForeignKey('ts_temp_spec.id', ondelete='CASCADE'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('ts_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')

14
requirements.txt Normal file
View File

@@ -0,0 +1,14 @@
flask
flask-login
flask-sqlalchemy
pymysql
werkzeug
docx2pdf
python-docx
docxtpl
beautifulsoup4
lxml
python-dotenv
mistune
PyJWT
ldap3

0
routes/__init__.py Normal file
View File

76
routes/admin.py Normal file
View 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'))

54
routes/auth.py Normal file
View File

@@ -0,0 +1,54 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash
from flask_login import login_user, logout_user, login_required, current_user
from werkzeug.security import check_password_hash
from ldap_utils import authenticate_ldap_user
from models import User, db
from datetime import datetime
from werkzeug.security import check_password_hash
from ldap_utils import authenticate_ldap_user, generate_password_hash
auth_bp = Blueprint('auth', __name__)
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('temp_spec.spec_list'))
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
# Step 1: Authenticate against LDAP
user_info = authenticate_ldap_user(username, password)
if user_info:
# Step 2: User authenticated successfully, find or create local user
local_user = User.query.filter_by(username=user_info['username']).first()
if not local_user:
# Create a new user in the local database
local_user = User(
username=user_info['username'],
# password_hash is no longer needed for login, can be empty or random
password_hash='ldap_authenticated',
role='viewer' # Default role for new users
)
db.session.add(local_user)
# Update last_login time
local_user.last_login = datetime.now()
db.session.commit()
# Step 3: Log in the user with Flask-Login
login_user(local_user)
return redirect(url_for('temp_spec.spec_list'))
else:
flash('帳號或密碼錯誤,請重新輸入', 'danger')
return render_template('login.html')
@auth_bp.route('/logout')
@login_required
def logout():
logout_user()
return redirect(url_for('auth.login'))

426
routes/temp_spec.py Normal file
View File

@@ -0,0 +1,426 @@
# -*- coding: utf-8 -*-
from flask import Blueprint, render_template, request, redirect, url_for, flash, send_file, current_app, jsonify, abort
from flask_login import login_required, current_user
from datetime import datetime, timedelta
from models import TempSpec, db, Upload, SpecHistory
from utils import editor_or_admin_required, add_history_log, admin_required, send_email
from ldap_utils import get_ldap_group_members
import os
import shutil
import jwt
import requests
from werkzeug.utils import secure_filename
from docxtpl import DocxTemplate
temp_spec_bp = Blueprint('temp_spec', __name__)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 移除 @login_required
@temp_spec_bp.before_request
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}"
def get_file_uri(filename):
"""產生 Flask 應用程式可以存取到該檔案的 URL"""
return url_for('static', filename=f"generated/{filename}", _external=True)
@temp_spec_bp.route('/create', methods=['GET', 'POST'])
@editor_or_admin_required
def create_temp_spec():
if request.method == 'POST':
spec_code = _generate_next_spec_code()
form_data = request.form
now = datetime.now()
# 1. 在資料庫中建立紀錄
spec = TempSpec(
spec_code=spec_code,
title=form_data['theme'],
applicant=form_data['applicant'],
status='pending_approval',
created_at=now,
start_date=now.date(),
end_date=(now + timedelta(days=30)).date()
)
db.session.add(spec)
db.session.flush()
# 2. 準備要填入 Word 範本的 context
context = {
'serial_number': spec_code,
'theme': form_data.get('theme', ''),
'package': form_data.get('package', ''),
'lot_number': form_data.get('lot_number', ''),
'equipment_type': form_data.get('equipment_type', ''),
'applicant': form_data.get('applicant', ''),
'applicant_phone': form_data.get('applicant_phone', ''),
'start_date': now.strftime('%Y-%m-%d'),
'end_date': (now + timedelta(days=30)).strftime('%Y-%m-%d'),
}
# 3. 處理勾選框邏輯
selected_stations = request.form.getlist('station')
station_keys = ['probing', 'dicing', 'diebond', 'wirebond', 'solder', 'molding',
'degate', 'deflash', 'plating', 'trimform', 'marking', 'tmtt', 'other']
for key in station_keys:
context[f's_{key}'] = '' if key in selected_stations else ''
selected_tccs_level = form_data.get('tccs_level')
level_keys = ['l1', 'l2', 'l3', 'l4']
for key in level_keys:
context[f't_{key}'] = '' if key == selected_tccs_level else ''
selected_tccs_4m = form_data.get('tccs_4m')
m_keys = ['man', 'machine', 'material', 'method', 'env']
for key in m_keys:
context[f't_{key}'] = '' if key == selected_tccs_4m else ''
# 4. 渲染 Word 範本
generated_folder = os.path.join(current_app.static_folder, 'generated')
os.makedirs(generated_folder, exist_ok=True)
template_path = os.path.join(BASE_DIR, 'template_with_placeholders.docx')
new_file_path = os.path.join(generated_folder, f"{spec_code}.docx")
if not os.path.exists(template_path):
flash('找不到 Word 範本檔案 (template_with_placeholders.docx)!', 'danger')
db.session.rollback()
return redirect(url_for('temp_spec.spec_list'))
doc = DocxTemplate(template_path)
doc.render(context)
doc.save(new_file_path)
# 5. 提交資料庫並重新導向
add_history_log(spec.id, '建立', f"建立暫時規範草稿: {spec.spec_code}")
db.session.commit()
return redirect(url_for('temp_spec.edit_spec', spec_id=spec.id))
# GET 請求:顯示建立表單
return render_template('create_temp_spec_form.html')
@temp_spec_bp.route('/edit/<int:spec_id>')
@editor_or_admin_required
def edit_spec(spec_id):
spec = TempSpec.query.get_or_404(spec_id)
doc_filename = f"{spec.spec_code}.docx"
doc_physical_path = os.path.join(current_app.static_folder, 'generated', doc_filename)
if not os.path.exists(doc_physical_path):
flash(f'找不到文件檔案: {doc_filename}', 'danger')
return redirect(url_for('temp_spec.spec_list'))
# --- START: 修正文件下載與回呼的 URL ---
# 1. 產生標準的文件 URL 和回呼 URL
doc_url = get_file_uri(doc_filename)
callback_url = url_for('temp_spec.onlyoffice_callback', spec_id=spec_id, _external=True)
# 2. 如果是在開發環境,將 URL 中的 localhost 替換為 Docker 可存取的地址
if '127.0.0.1' in doc_url or 'localhost' in doc_url:
# 同時修正 doc_url 和 callback_url
doc_url = doc_url.replace('127.0.0.1', 'host.docker.internal').replace('localhost', 'host.docker.internal')
callback_url = callback_url.replace('127.0.0.1', 'host.docker.internal').replace('localhost', 'host.docker.internal')
# --- END: 修正文件下載與回呼的 URL ---
oo_secret = current_app.config['ONLYOFFICE_JWT_SECRET']
payload = {
"document": {
"fileType": "docx",
"key": f"{spec.id}_{int(os.path.getmtime(doc_physical_path))}",
"title": doc_filename,
"url": doc_url # <-- 使用修正後的 doc_url
},
"documentType": "word",
"editorConfig": {
"callbackUrl": callback_url, # <-- 使用修正後的回呼 URL
"user": { "id": str(current_user.id), "name": current_user.username },
"customization": { "autosave": True, "forcesave": True }
}
}
token = jwt.encode(payload, oo_secret, algorithm='HS256')
config = payload.copy()
config['token'] = token
return render_template(
'onlyoffice_editor.html',
spec=spec,
config=config,
onlyoffice_url=current_app.config['ONLYOFFICE_URL']
)
# 這個路由不需要登入驗證,因為是 ONLYOFFICE Server 在呼叫它
@temp_spec_bp.route('/onlyoffice-callback/<int:spec_id>', methods=['POST'])
def onlyoffice_callback(spec_id):
data = request.json
if data.get('status') == 2:
try:
response = requests.get(data['url'], timeout=10)
response.raise_for_status()
spec = TempSpec.query.get_or_404(spec_id)
doc_filename = f"{spec.spec_code}.docx"
file_path = os.path.join(current_app.static_folder, 'generated', doc_filename)
with open(file_path, 'wb') as f:
f.write(response.content)
except Exception as e:
current_app.logger.error(f"ONLYOFFICE callback error for spec {spec_id}: {e}")
return jsonify({"error": 1, "message": str(e)})
return jsonify({"error": 0})
# --- 其他既有路由 ---
@temp_spec_bp.route('/list')
@login_required # 補上登入驗證
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
# --- START: 新增的程式碼 ---
# 取得今天的日期,並傳給模板
from datetime import date
today = date.today()
# --- END: 新增的程式碼 ---
return render_template(
'spec_list.html',
specs=specs,
pagination=pagination,
query=query,
status=status_filter,
today=today # <-- 將 today 傳遞到模板
)
@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')
# --- Start of Email Notification Example ---
# Get recipient list from a predefined LDAP group
# NOTE: 'TempSpec_Approvers' is an example group name. Replace with the actual group name.
recipients = get_ldap_group_members('TempSpec_Approvers')
if recipients:
subject = f"[暫規通知] 規範 '{spec.spec_code}' 已正式生效"
# Using f-strings and triple quotes for a readable HTML body
body = f"""
<html>
<body>
<p>您好,</p>
<p>暫時規範 <b>{spec.spec_code} - {spec.title}</b> 已由管理員啟用,並正式生效。</p>
<p>詳細資訊請登入系統查看。</p>
<p>生效日期: {spec.start_date.strftime('%Y-%m-%d')}<br>
結束日期: {spec.end_date.strftime('%Y-%m-%d')}</p>
<p>此為系統自動發送的通知郵件,請勿直接回覆。</p>
</body>
</html>
"""
send_email(recipients, subject, body)
else:
# Log a warning if no recipients were found, but don't block the main process
current_app.logger.warning(f"Could not find recipients in LDAP group 'TempSpec_Approvers' for spec {spec.id}.")
# --- End of Email Notification Example ---
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_word/<int:spec_id>')
@login_required
def download_initial_word(spec_id):
spec = TempSpec.query.get_or_404(spec_id)
if current_user.role not in ['editor', 'admin']:
flash('權限不足,無法下載 Word 檔案。', 'danger')
abort(403)
generated_folder = os.path.join(current_app.static_folder, 'generated')
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>')
@login_required # 補上登入驗證
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 uploaded_file or uploaded_file.filename == '':
flash('您必須上傳新的佐證檔案才能展延。', 'danger')
return redirect(url_for('temp_spec.extend_spec', spec_id=spec.id))
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'
filename = secure_filename(f"{spec.spec_code}_extension_{spec.extension_count}_{datetime.now().strftime('%Y%m%d')}.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)
details = f"展延結束日期至 {spec.end_date.strftime('%Y-%m-%d')}"
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>')
@login_required # 補上登入驗證
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(current_app.static_folder, 'generated')
files_to_delete.append(os.path.join(generated_folder, f"{spec.spec_code}.docx"))
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))
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
View 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})

203
static/css/style.css Normal file
View File

@@ -0,0 +1,203 @@
/* --- 全域與背景設定 --- */
body {
background-color: #0d1117;
background-image: linear-gradient(180deg, #161b22 0%, #0d1117 100%);
background-attachment: fixed;
color: #c9d1d9;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
/* --- 強制設定通用元素的文字顏色 --- */
p, label, th, td, .form-label, .form-check-label, .card-body {
color: #c9d1d9;
}
/* --- 導覽列 --- */
.navbar {
background-color: rgba(13, 17, 23, 0.8);
backdrop-filter: blur(10px);
border-bottom: 1px solid #30363d;
}
/* --- 標題 --- */
h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 {
color: #f0f6fc;
}
/* --- 卡片與容器 --- */
.card {
background-color: #161b22;
border: 1px solid #30363d;
border-radius: 0.5rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
}
.card-header, .card-footer {
background-color: rgba(22, 27, 34, 0.7);
border-bottom: 1px solid #30363d;
color: #f0f6fc;
}
/* --- 按鈕 --- */
.btn-primary {
background-color: #5865f2; border-color: #5865f2; color: #ffffff;
transition: all 0.2s ease-in-out;
}
.btn-primary:hover, .btn-primary:focus {
background-color: #4752c4; border-color: #4752c4;
box-shadow: 0 0 0 0.25rem rgba(88, 101, 242, 0.5);
}
.btn-success { background-color: #2ea043; border-color: #2ea043; color: #fff; }
.btn-success:hover { background-color: #268839; border-color: #268839; color: #fff;}
.btn-danger { background-color: #da3633; border-color: #da3633; color: #fff;}
.btn-danger:hover { background-color: #b92d2b; border-color: #b92d2b; color: #fff;}
.btn-warning { background-color: #f0ad4e; border-color: #f0ad4e; color: #0d1117; }
.btn-warning:hover { background-color: #e39b37; border-color: #e39b37; color: #0d1117;}
.btn-info { background-color: #0dcaf0; border-color: #0dcaf0; color: #0d1117; }
.btn-info:hover { background-color: #0baccc; border-color: #0baccc; color: #0d1117;}
/* --- 表單輸入框 --- */
.form-control, .form-select {
background-color: #0d1117; color: #c9d1d9;
border: 1px solid #30363d; border-radius: 0.375rem;
}
.form-control:focus, .form-select:focus {
background-color: #0d1117; color: #c9d1d9;
border-color: #5865f2;
box-shadow: 0 0 0 0.25rem rgba(88, 101, 242, 0.25);
}
.form-control::placeholder { color: #8b949e; }
.form-control[readonly] { background-color: #161b22; opacity: 0.7; }
.input-group-text {
background-color: #161b22;
border: 1px solid #30363d;
color: #c9d1d9;
}
/* --- 表格 --- */
.table {
--bs-table-color: #c9d1d9;
--bs-table-bg: #161b22;
--bs-table-border-color: #30363d;
--bs-table-striped-color: #c9d1d9;
--bs-table-striped-bg: #21262d;
--bs-table-hover-color: #f0f6fc;
--bs-table-hover-bg: #30363d;
border-color: var(--bs-table-border-color);
}
.table > thead {
color: #f0f6fc;
}
/* --- 分頁 --- */
.pagination {
--bs-pagination-color: #58a6ff;
--bs-pagination-bg: #0d1117; /* 最深的背景色,使其與容器分離 */
--bs-pagination-border-color: #30363d;
--bs-pagination-hover-color: #80b6ff;
--bs-pagination-hover-bg: #21262d; /* 懸停時變亮 */
--bs-pagination-hover-border-color: #4d555e;
--bs-pagination-focus-color: #80b6ff;
--bs-pagination-focus-bg: #21262d;
--bs-pagination-focus-box-shadow: 0 0 0 0.25rem rgba(88, 101, 242, 0.25);
--bs-pagination-active-color: #fff;
--bs-pagination-active-bg: #5865f2;
--bs-pagination-active-border-color: #5865f2;
--bs-pagination-disabled-color: #8b949e;
--bs-pagination-disabled-bg: #161b22; /* 禁用的背景色,使其看起來凹陷 */
--bs-pagination-disabled-border-color: #30363d;
}
/* --- 提示訊息 (Alerts) --- */
.alert { border-width: 1px; border-style: solid; }
.alert-danger { background-color: rgba(218, 54, 51, 0.15); border-color: #da3633; color: #ff8986; }
.alert-success { background-color: rgba(46, 160, 67, 0.15); border-color: #2ea043; color: #7ce38f; }
.alert-info { background-color: rgba(13, 202, 240, 0.15); border-color: #0dcaf0; color: #6be2fa; }
.alert-warning { background-color: rgba(240, 173, 78, 0.15); border-color: #f0ad4e; color: #f0ad4e; }
/* --- 狀態標籤 (Badges) --- */
.badge { --bs-badge-font-size: 0.8em; --bs-badge-padding-y: 0.4em; --bs-badge-padding-x: 0.7em; }
.bg-success { background-color: #2ea043 !important; }
.bg-info { background-color: #0dcaf0 !important; color: #0d1117 !important; }
.bg-warning { background-color: #f0ad4e !important; color: #0d1117 !important; }
.bg-secondary { background-color: #8b949e !important; }
/* --- 連結 --- */
a { color: #58a6ff; }
a:hover { color: #80b6ff; }
/* 頁面切換的淡入效果 */
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
main.container { animation: fadeIn 0.5s ease-in-out; }
/* --- 通知 (Toast) --- */
.toast {
background-color: #21262d; /* 使用比卡片稍亮的深色背景 */
border: 1px solid #30363d;
color: #c9d1d9;
}
.toast-header {
background-color: #161b22; /* 使用與卡片相同的深色背景 */
color: #f0f6fc; /* 標題使用較亮的白色文字 */
border-bottom: 1px solid #30363d;
}
.toast-body {
color: #c9d1d9; /* 內文使用標準的灰色文字 */
}
/* 讓關閉按鈕在深色背景下可見 */
.btn-close {
filter: invert(1) grayscale(100%) brightness(200%);
}
/* --- 列表群組 (List Group for History Page) --- */
.list-group-flush .list-group-item {
background-color: transparent; /* 在 card 中使用透明背景 */
border-color: #30363d;
}
.list-group-item {
background-color: #161b22;
border-color: #30363d;
}
/* 確保列表內的文字顏色正確 */
.list-group-item,
.list-group-item p,
.list-group-item small {
color: #c9d1d9; /* 標準灰色文字 */
}
/* 讓使用者名稱等重要文字更亮 */
.list-group-item h5 strong {
color: #f0f6fc;
}
/* --- 剩餘天數標籤 (Days Remaining Badge) --- */
.days-badge {
padding: 0.3em 0.6em;
border-radius: 0.375rem;
font-weight: 500;
font-size: 0.85em;
color: #0d1117; /* 預設使用深色文字 */
}
.days-safe {
background-color: #2ea043; /* 綠色 */
color: #ffffff; /* 搭配淺色文字 */
}
.days-warning {
background-color: #f0ad4e; /* 黃色 */
}
.days-critical {
background-color: #da3633; /* 紅色 */
color: #ffffff; /* 搭配淺色文字 */
}
.days-expired {
background-color: #8b949e; /* 灰色 */
color: #ffffff;
}

Binary file not shown.

12
templates/403.html Normal file
View 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
View 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 %}

View 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
View 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">
<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>

View File

@@ -0,0 +1,83 @@
{% extends "base.html" %}
{% block title %}建立新的暫時規範{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-10">
<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-4 mb-3">
<label for="theme" class="form-label">主題/目的</label>
<input type="text" class="form-control" id="theme" name="theme" required>
</div>
<div class="col-md-4 mb-3">
<label for="applicant" class="form-label">申請者</label>
<input type="text" class="form-control" id="applicant" name="applicant" value="{{ current_user.username }}" readonly>
</div>
<div class="col-md-4 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="probing"><label>點測</label></div>
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="dicing"><label>切割</label></div>
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="diebond"><label>晶粒黏著</label></div>
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="wirebond"><label>銲線黏著</label></div>
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="solder"><label>錫膏焊接</label></div>
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="molding"><label>成型</label></div>
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="degate"><label>去膠</label></div>
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="deflash"><label>吹砂</label></div>
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="plating"><label>電鍍</label></div>
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="trimform"><label>切彎腳</label></div>
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="marking"><label>印字</label></div>
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="tmtt"><label>測試</label></div>
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="other"><label>其他</label></div>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">TCCS Level</label>
<div class="input-group">
<select class="form-select" name="tccs_level">
<option selected value="">請選擇 Level...</option>
<option value="l1">Level 1</option>
<option value="l2">Level 2</option>
<option value="l3">Level 3</option>
<option value="l4">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="man"><label></label></div>
<div class="form-check form-check-inline mb-0"><input class="form-check-input" type="radio" name="tccs_4m" value="machine"><label></label></div>
<div class="form-check form-check-inline mb-0"><input class="form-check-input" type="radio" name="tccs_4m" value="material"><label></label></div>
<div class="form-check form-check-inline mb-0"><input class="form-check-input" type="radio" name="tccs_4m" value="method"><label></label></div>
<div class="form-check form-check-inline mb-0"><input class="form-check-input" type="radio" name="tccs_4m" value="env"><label></label></div>
</div>
</div>
</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="d-flex justify-content-end">
<a href="{{ url_for('temp_spec.spec_list') }}" class="btn btn-secondary me-2">取消</a>
<button type="submit" class="btn btn-primary">建立並開始編輯</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,35 @@
{% 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>
<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" accept=".pdf" required>
<div class="form-text">請上傳展延申請的相關佐證文件 (PDF 格式)。</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 %}

43
templates/login.html Normal file
View File

@@ -0,0 +1,43 @@
{% 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 class="text-center mt-3">
<p class="mb-0">還沒有帳號嗎?</p>
<a href="{{ url_for('auth.register') }}" class="btn btn-outline-success">立即註冊</a>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block title %}編輯規範 - {{ spec.spec_code }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h2 class="mb-0">正在編輯: {{ spec.spec_code }}</h2>
<p class="lead text-muted">主題: {{ spec.title }}</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 p-0" style="height: 85vh;">
<div id="placeholder"></div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script type="text/javascript" src="{{ onlyoffice_url }}web-apps/apps/api/documents/api.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// 從後端接收的設定
const config = {{ config|tojson|safe }};
// 建立 DocEditor 物件
const docEditor = new DocsAPI.DocEditor("placeholder", config);
// 您可以在這裡加入更多事件處理,例如:
// config.events = {
// 'onAppReady': function() { console.log('Editor is ready'); },
// 'onDocumentStateChange': function(event) { console.log('Document state changed'); },
// };
});
</script>
{% endblock %}

47
templates/register.html Normal file
View File

@@ -0,0 +1,47 @@
{% 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="mb-3">
<label for="confirm_password" class="form-label">確認密碼</label>
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-success">註冊</button>
</div>
</form>
<div class="text-center mt-3">
<a href="{{ url_for('auth.login') }}">已經有帳號了?返回登入</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View 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 %}

153
templates/spec_list.html Normal file
View File

@@ -0,0 +1,153 @@
{% 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>
<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">
<div class="table-responsive">
<table class="table table-hover table-striped align-middle">
<thead>
<tr>
<th>編號</th>
<th>主題</th>
<th>申請者</th>
<th>建立日期</th>
<th>結束日期</th>
<th class="text-center">剩餘天數</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 class="text-center">
{% if spec.status in ['active', 'expired'] %}
{% set remaining_days = (spec.end_date - today).days %}
{% if remaining_days < 0 %}
{% set color_class = 'days-expired' %}
{% elif remaining_days <= 3 %}
{% set color_class = 'days-critical' %}
{% elif remaining_days <= 7 %}
{% set color_class = 'days-warning' %}
{% else %}
{% set color_class = 'days-safe' %}
{% endif %}
<span class="days-badge {{ color_class }}">
{{ remaining_days if remaining_days >= 0 else '已過期' }}
</span>
{% else %}
<span>-</span>
{% endif %}
</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">
{% if spec.status == 'pending_approval' and current_user.role in ['editor', 'admin'] %}
<a href="{{ url_for('temp_spec.edit_spec', spec_id=spec.id) }}" class="btn btn-sm btn-warning" title="編輯"><i class="bi bi-pencil-fill"></i></a>
{% endif %}
{% if current_user.role == 'admin' and 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 %}
{% if current_user.role in ['editor', 'admin'] and 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 %}
{% 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' %}
{% 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 %}
<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>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="card-footer">
<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 %}

View 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 %}

View 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 table-striped table-hover align-middle">
<thead>
<tr>
<th>ID</th>
<th>使用者名稱</th>
<th>權限</th>
<th>上次登入</th>
<th colspan="2">操作</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" class="d-inline">
<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('確定要刪除這位使用者嗎?');" class="d-inline">
<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 %}

197
utils.py Normal file
View File

@@ -0,0 +1,197 @@
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)
# 填入 contextNone 改為空字串
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)
import smtplib
from email.mime.text import MIMEText
from email.header import Header
from flask import current_app
def send_email(to_addrs, subject, body):
"""
Sends an email using the SMTP settings from the config.
"""
try:
smtp_server = current_app.config['SMTP_SERVER']
smtp_port = current_app.config['SMTP_PORT']
use_tls = current_app.config['SMTP_USE_TLS']
sender_email = current_app.config['SMTP_SENDER_EMAIL']
sender_password = current_app.config['SMTP_SENDER_PASSWORD']
msg = MIMEText(body, 'html', 'utf-8')
msg['Subject'] = Header(subject, 'utf-8')
msg['From'] = sender_email
msg['To'] = ', '.join(to_addrs)
server = smtplib.SMTP(smtp_server, smtp_port)
if use_tls:
server.starttls()
if sender_password:
server.login(sender_email, sender_password)
server.sendmail(sender_email, to_addrs, msg.as_string())
server.quit()
print(f"Email sent to {', '.join(to_addrs)}")
return True
except Exception as e:
print(f"Failed to send email: {e}")
return False

45
代辦.txt Normal file
View File

@@ -0,0 +1,45 @@
### **交付與後續步驟**
程式碼已準備就緒,但要使其正常運作,您需要完成以下**關鍵步驟**
**1. 填寫環境變數 (`.env` 檔案):**
請根據您從 IT 部門獲取的資訊,在專案根目錄的 `.env` 檔案中新增或修改以下變數。這一步至關重要。
```ini
# --- LDAP Settings ---
LDAP_SERVER=dc1.panjit.com.tw
LDAP_PORT=389
LDAP_USE_SSL=false
# 服務帳號 (用於查詢群組)
LDAP_BIND_USER_DN="CN=ServiceAccount,OU=Services,DC=panjit,DC=com,DC=tw"
LDAP_BIND_USER_PASSWORD="service_account_password"
# 使用者和群組的搜尋基礎
LDAP_SEARCH_BASE="OU=Users,DC=panjit,DC=com,DC=tw"
LDAP_USER_LOGIN_ATTR=userPrincipalName
# --- SMTP Settings ---
SMTP_SERVER=mail.panjit.com.tw
SMTP_PORT=25
SMTP_USE_TLS=false
# 發信人帳號
SMTP_SENDER_EMAIL=app-noreply@panjit.com.tw
SMTP_SENDER_PASSWORD="email_password_if_needed"
```
**2. 安裝新的 Python 套件:**
在您的開發環境中執行以下指令,以安裝 `ldap3`
```bash
pip install -r requirements.txt
```
**3. 自訂郵件通知:**
* 我添加的郵件通知僅作為一個**範例**。您需要:
* 將範例中的 `'TempSpec_Approvers'` 替換為貴公司**實際的郵件群組名稱**。
* 將此通知邏輯複製並修改,應用到其他需要發送通知的地方,例如 `terminate_spec` (結束通知) 和 `extend_spec` (展延通知)。
* 根據需要建立一個排程任務 (例如,使用 APScheduler 或 Windows 工作排程器),定期檢查即將到期的暫規並發送提醒郵件。
**4. 測試:**
完成上述配置後,請啟動應用程式並使用您的 AD 帳號進行登入測試。同時,觸發一個暫規生效的流程,檢查郵件是否能成功發送。
本次的架構轉移工作已完成。如果您在配置或測試過程中遇到任何問題,或需要對通知邏輯進行進一步的調整,請隨時提出。