This commit is contained in:
beabigegg
2025-08-29 16:25:46 +08:00
commit b0c86302ff
65 changed files with 19786 additions and 0 deletions

View File

@@ -0,0 +1,28 @@
{
"permissions": {
"allow": [
"Read(C:\\Users\\EGG\\WORK\\data\\user_scrip\\TOOL\\semiauto-assistant/**)",
"Read(C:\\Users\\EGG\\WORK\\data\\user_scrip\\TOOL\\TEMP_spec_system_V3/**)",
"Read(C:\\Users\\EGG\\.claude/**)",
"Bash(rm:*)",
"Bash(npm test)",
"Bash(npm install)",
"Bash(npm test:*)",
"Bash(python -m pytest --version)",
"Bash(python -m pytest tests/test_models.py -v)",
"Bash(python:*)",
"Bash(npm run build:*)",
"Bash(./venv/Scripts/activate)",
"Bash(npm start)",
"Bash(curl:*)",
"Bash(find:*)",
"Bash(grep:*)",
"Bash(copy:*)",
"Bash(del check_enum.py)",
"Bash(npm run dev:*)",
"Bash(del \"src\\components\\notifications\\EmailNotificationSettings.tsx\")"
],
"deny": [],
"ask": []
}
}

60
.gitignore vendored Normal file
View File

@@ -0,0 +1,60 @@
# --- 敏感資訊 (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/
node_modules/
.next/
.swc/
components/
# --- 作業系統相關 (Operating System) ---
# 忽略 macOS 的系統檔案。
.DS_Store
# 忽略 Windows 的縮圖快取。
Thumbs.db
# --- Log 檔案 ---
# 忽略所有日誌檔案。
*.log
logs/
# --- 環境設定檔 ---
.env
# --- 測試相關 (Testing) ---
# 忽略測試檔案
test_*.py
*_test.py
tests/
# --- 開發者專用文件 (Developer Only) ---
# 最佳實踐文件(包含敏感設定資訊)
# --- 對話記憶檔案 (Conversation Memory) ---
# 包含開發過程記錄,不需要版本控制

558
BEST_PRACTICES.md Normal file
View File

@@ -0,0 +1,558 @@
# 暫時規範管理系統 V3 - 開發者最佳實踐指南
> **⚠️ 重要提醒**:本文件包含敏感的系統配置和最佳實踐資訊,僅供開發團隊內部使用。
> 此文件已在 .gitignore 中排除,請勿提交至版本控制系統。
## 🎯 文件目的
本文件記錄在開發暫時規範管理系統 V3 過程中遇到的技術難點及最佳解決方案,避免後續開發者重複踩坑。
---
## 🔐 LDAP/Active Directory 整合最佳實踐
### 1. LDAP 連接配置
**關鍵發現**LDAP 連接的穩定性很大程度取決於正確的配置參數組合。
#### 正確的配置模式
```python
# config.py - 推薦配置
LDAP_SERVER = "ldap://dc.company.com" # 或使用 IP
LDAP_PORT = 389 # 標準 LDAP port
LDAP_USE_SSL = False # 內網環境通常不需要 SSL
LDAP_SEARCH_BASE = "DC=company,DC=com"
LDAP_BIND_USER_DN = "CN=ServiceAccount,OU=ServiceAccounts,DC=company,DC=com"
LDAP_USER_LOGIN_ATTR = "userPrincipalName" # AD 環境必須使用此屬性
```
#### 常見錯誤及解決方案
**錯誤 1**:使用 `sAMAccountName` 作為登入屬性
```python
# ❌ 錯誤做法
LDAP_USER_LOGIN_ATTR = "sAMAccountName"
# ✅ 正確做法
LDAP_USER_LOGIN_ATTR = "userPrincipalName"
```
**錯誤 2**:服務帳號權限不足
```python
# 服務帳號至少需要以下權限:
# - Read permission on the search base
# - List Contents permission
# - Read All Properties permission
```
### 2. LDAP 搜尋最佳化
**關鍵發現**:正確的搜尋篩選器可以大幅提升效能並避免權限問題。
#### 用戶搜尋最佳實踐
```python
# ldap_utils.py - 優化後的搜尋篩選器
def search_ldap_principals(search_term):
# 多屬性搜尋,提高命中率
search_filter = f"""
(&
(objectClass=person)
(objectCategory=person)
(!(userAccountControl:1.2.840.113556.1.4.803:=2)) # 排除已停用帳號
(|
(displayName=*{search_term}*)
(mail=*{search_term}*)
(sAMAccountName=*{search_term}*)
(userPrincipalName=*{search_term}*)
)
)
"""
```
**關鍵技巧**
1. 使用 `objectCategory=person` 而不是只用 `objectClass=user`
2. 排除停用帳號避免無效結果
3. 多屬性搜尋提高使用者體驗
4. 限制搜尋結果數量避免效能問題
#### 群組搜尋最佳實踐
```python
# 同時支援 AD 群組和 OU
def get_ldap_group_members(group_name):
# 先嘗試搜尋 AD 群組
group_filter = f"(&(objectClass=group)(cn={group_name}))"
# 如果找不到群組,嘗試搜尋 OU
if not found:
ou_filter = f"(&(objectClass=organizationalUnit)(name=*{group_name}*))"
```
### 3. LDAP 連接穩定性
**關鍵發現**:連接池和重試機制對生產環境至關重要。
```python
# ldap_utils.py - 連接重試機制
def create_ldap_connection(retries=3):
for attempt in range(retries):
try:
server = Server(ldap_server, port=ldap_port, use_ssl=use_ssl)
conn = Connection(server, user=bind_dn, password=bind_password, auto_bind=True)
return conn
except Exception as e:
if attempt == retries - 1:
raise e
time.sleep(1) # 短暫等待後重試
```
---
## 📧 SMTP 郵件系統最佳實踐
### 1. 多種 SMTP 配置支援
**關鍵發現**:企業環境中可能遇到多種 SMTP 配置需求,系統必須具備彈性。
#### 配置架構設計
```python
# config.py - 彈性 SMTP 配置
class Config:
SMTP_SERVER = os.getenv('SMTP_SERVER', 'mail.company.com')
SMTP_PORT = int(os.getenv('SMTP_PORT', 25))
SMTP_USE_TLS = os.getenv('SMTP_USE_TLS', 'false').lower() in ['true', '1', 't']
SMTP_USE_SSL = os.getenv('SMTP_USE_SSL', 'false').lower() in ['true', '1', 't']
SMTP_AUTH_REQUIRED = os.getenv('SMTP_AUTH_REQUIRED', 'false').lower() in ['true', '1', 't']
SMTP_SENDER_EMAIL = os.getenv('SMTP_SENDER_EMAIL', 'temp-spec-system@company.com')
SMTP_SENDER_PASSWORD = os.getenv('SMTP_SENDER_PASSWORD', '')
```
#### 智能連接邏輯
```python
# utils.py - 智能 SMTP 連接
def send_email(to_addrs, subject, body):
# 根據 port 和配置自動選擇連接方式
if use_ssl and smtp_port == 465:
server = smtplib.SMTP_SSL(smtp_server, smtp_port)
else:
server = smtplib.SMTP(smtp_server, smtp_port)
if use_tls and smtp_port == 587:
server.starttls()
# 只在需要認證時才登入
if auth_required and sender_password:
server.login(sender_email, sender_password)
```
### 2. 郵件發送可靠性
**關鍵發現**:詳細的日誌和錯誤處理對於診斷郵件問題至關重要。
```python
# utils.py - 完整的錯誤處理
def send_email(to_addrs, subject, body):
try:
# ... 發送邏輯 ...
result = server.sendmail(sender_email, to_addrs, msg.as_string())
# 檢查發送結果
if result:
# 某些收件者失敗
print(f"[EMAIL WARNING] 部分收件者發送失敗: {result}")
else:
print(f"[EMAIL SUCCESS] 郵件成功發送至: {', '.join(to_addrs)}")
return True
except smtplib.SMTPAuthenticationError as e:
print(f"[EMAIL ERROR] SMTP 認證失敗: {e}")
return False
except smtplib.SMTPConnectError as e:
print(f"[EMAIL ERROR] SMTP 連接失敗: {e}")
return False
# ... 其他異常處理 ...
```
### 3. 郵件內容最佳化
**關鍵發現**HTML 格式郵件必須考慮各種郵件客戶端的相容性。
```python
# 推薦的 HTML 郵件格式
def create_email_body(spec, action):
body = f"""
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: Arial, sans-serif; }}
.header {{ color: #2c3e50; border-bottom: 2px solid #3498db; }}
.content {{ margin: 20px 0; }}
.highlight {{ background-color: #f8f9fa; padding: 10px; }}
</style>
</head>
<body>
<div class="header">
<h2>[暫規通知] 規範 '{spec.spec_code}' 已{action}</h2>
</div>
<div class="content">
<p>您好,</p>
<!-- 內容... -->
</div>
</body>
</html>
"""
return body
```
---
## 🗄️ 資料庫設計最佳實踐
### 1. 資料庫遷移策略
**關鍵發現**:平滑的資料庫升級對於生產環境至關重要。
#### 遷移腳本模板
```python
# migrate_*.py - 標準遷移腳本結構
def migrate_database():
engine = create_engine(Config.SQLALCHEMY_DATABASE_URI)
try:
with engine.connect() as conn:
# 檢查是否已經遷移
result = conn.execute(text("""
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'table_name' AND COLUMN_NAME = 'new_column'
AND TABLE_SCHEMA = DATABASE()
"""))
if result.fetchone():
print("✓ 遷移已完成,無需重複執行")
return True
# 執行遷移
conn.execute(text("ALTER TABLE table_name ADD COLUMN new_column TYPE"))
conn.commit()
# 驗證遷移結果
# ...
except Exception as e:
print(f"✗ 遷移失敗:{str(e)}")
return False
```
### 2. 資料模型設計
**關鍵發現**:適當的索引和關聯設計可以大幅提升查詢效能。
```python
# models.py - 最佳實踐
class TempSpec(db.Model):
__tablename__ = 'ts_temp_spec'
# 主鍵
id = db.Column(db.Integer, primary_key=True)
# 業務鍵,建立索引
spec_code = db.Column(db.String(20), nullable=False, index=True)
# 常用查詢欄位,建立索引
status = db.Column(db.Enum(...), nullable=False, index=True)
end_date = db.Column(db.Date, index=True) # 用於到期查詢
# 新功能擴展欄位
notification_emails = db.Column(db.Text, nullable=True)
# 正確的關聯設置
uploads = db.relationship('Upload', back_populates='spec',
cascade='all, delete-orphan')
```
---
## 🔄 Flask 應用架構最佳實踐
### 1. 藍圖Blueprint組織
**關鍵發現**:良好的模組分離有助於維護和擴展。
```python
# 推薦的路由組織結構
routes/
├── __init__.py # 藍圖註冊
├── auth.py # 認證相關
├── api.py # API 介面
├── temp_spec.py # 核心業務邏輯
├── admin.py # 管理功能
└── upload.py # 檔案處理
```
### 2. 錯誤處理策略
```python
# app.py - 全局錯誤處理
@app.errorhandler(403)
def forbidden(error):
return render_template('403.html'), 403
@app.errorhandler(404)
def not_found(error):
return render_template('404.html'), 404
@app.errorhandler(500)
def internal_error(error):
db.session.rollback()
return render_template('500.html'), 500
```
### 3. 配置管理
```python
# config.py - 環境配置分離
class DevelopmentConfig(Config):
DEBUG = True
TESTING = False
class ProductionConfig(Config):
DEBUG = False
TESTING = False
# 生產環境特定設定
class TestingConfig(Config):
TESTING = True
# 測試環境設定
```
---
## 🏗️ 前端整合最佳實踐
### 1. ONLYOFFICE 整合要點
**關鍵發現**Docker 環境下的網路配置是最大的挑戰。
```python
# routes/temp_spec.py - URL 修正邏輯
def edit_spec(spec_id):
doc_url = get_file_uri(doc_filename)
callback_url = url_for('temp_spec.onlyoffice_callback', spec_id=spec_id, _external=True)
# Docker 環境 URL 修正
if '127.0.0.1' in doc_url or 'localhost' in doc_url:
doc_url = doc_url.replace('127.0.0.1', 'host.docker.internal')
doc_url = doc_url.replace('localhost', 'host.docker.internal')
callback_url = callback_url.replace('127.0.0.1', 'host.docker.internal')
callback_url = callback_url.replace('localhost', 'host.docker.internal')
```
### 2. 前端元件最佳化
**關鍵發現**Tom Select 元件需要正確配置才能提供良好的使用者體驗。
```javascript
// 推薦的 Tom Select 配置
const recipientSelect = new TomSelect('#recipients', {
valueField: 'value',
labelField: 'text',
searchField: 'text',
placeholder: '請輸入姓名或 Email 來搜尋...',
plugins: ['remove_button'],
maxItems: null,
create: false,
load: function(query, callback) {
if (!query || query.length < 2) {
callback();
return;
}
// 實作搜尋邏輯...
}
});
```
---
## 🚀 部署最佳實踐
### 1. Docker 配置優化
```yaml
# docker-compose.yml - 生產環境配置
version: '3.8'
services:
app:
build: .
environment:
- FLASK_ENV=production
- PYTHONUNBUFFERED=1
volumes:
- ./uploads:/app/uploads
- ./logs:/app/logs
restart: unless-stopped
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MYSQL_DATABASE: ${DB_NAME}
volumes:
- mysql_data:/var/lib/mysql
restart: unless-stopped
volumes:
mysql_data:
```
### 2. 日誌管理
```python
# app.py - 生產環境日誌配置
if not app.debug:
if not os.path.exists('logs'):
os.mkdir('logs')
file_handler = RotatingFileHandler('logs/tempspec.log',
maxBytes=10240000, backupCount=10)
file_handler.setFormatter(logging.Formatter(
'%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'
))
file_handler.setLevel(logging.INFO)
app.logger.addHandler(file_handler)
```
### 3. 安全性配置
```python
# 推薦的安全標頭設置
@app.after_request
def after_request(response):
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['X-Frame-Options'] = 'DENY'
response.headers['X-XSS-Protection'] = '1; mode=block'
return response
```
---
## 🐛 除錯與監控
### 1. 開發階段除錯
```python
# 推薦的除錯配置
DEBUG_LDAP = os.getenv('DEBUG_LDAP', 'false').lower() == 'true'
DEBUG_EMAIL = os.getenv('DEBUG_EMAIL', 'false').lower() == 'true'
DEBUG_DATABASE = os.getenv('DEBUG_DATABASE', 'false').lower() == 'true'
def debug_log(category, message):
if category == 'ldap' and DEBUG_LDAP:
print(f"[LDAP DEBUG] {message}")
elif category == 'email' and DEBUG_EMAIL:
print(f"[EMAIL DEBUG] {message}")
# ...
```
### 2. 生產環境監控
```python
# tasks.py - 健康檢查任務
@scheduler.task('cron', id='health_check', hour='*/1')
def health_check():
try:
# 檢查資料庫連接
db.session.execute(text('SELECT 1'))
# 檢查 LDAP 連接
test_ldap_connection()
# 檢查 SMTP 連接
test_smtp_connection()
app.logger.info("Health check passed")
except Exception as e:
app.logger.error(f"Health check failed: {e}")
```
---
## 📊 效能優化要點
### 1. 資料庫查詢優化
```python
# 推薦的查詢模式
def get_active_specs_expiring_soon():
return TempSpec.query.filter(
TempSpec.status == 'active',
TempSpec.end_date <= datetime.now().date() + timedelta(days=7)
).options(
joinedload(TempSpec.uploads) # 預載關聯資料
).all()
```
### 2. 快取策略
```python
# 推薦使用 Flask-Caching
from flask_caching import Cache
cache = Cache(app, config={'CACHE_TYPE': 'simple'})
@cache.memoize(timeout=300) # 5分鐘快取
def get_ldap_group_members(group_name):
# LDAP 查詢邏輯...
```
---
## 🔧 維護與升級
### 1. 版本控制策略
```bash
# 推薦的版本標籤格式
git tag v3.2.0-rc1 # 發布候選版本
git tag v3.2.0 # 正式版本
git tag v3.2.1 # 修正版本
```
### 2. 備份策略
```bash
#!/bin/bash
# backup.sh - 定期備份腳本
DATE=$(date +%Y%m%d_%H%M%S)
# 資料庫備份
mysqldump -u $DB_USER -p$DB_PASSWORD $DB_NAME > backup_${DATE}.sql
# 檔案備份
tar -czf uploads_${DATE}.tar.gz uploads/
```
---
## 📝 總結
本文件記錄了開發暫時規範管理系統 V3 過程中的關鍵技術決策和最佳實踐。這些經驗可以幫助後續開發者:
1. **避免常見陷阱**:特別是 LDAP 配置和 SMTP 設定
2. **提升開發效率**:使用經過驗證的架構模式
3. **確保系統穩定性**:採用完整的錯誤處理和監控機制
4. **簡化部署流程**:使用 Docker 和自動化腳本
**重要提醒**:本文件包含敏感資訊,請勿外洩或提交至公開版本控制系統。
---
*最後更新2025年1月*
*文件版本V1.0*

95
CONVERSATION_MEMORY.md Normal file
View File

@@ -0,0 +1,95 @@
# 對話記憶 - PANJIT To-Do List System V1
## 最新狀態 (2025-08-29)
### 已完成的主要功能
1. **Fire Email 功能修復** - 修正 AD 帳號轉換郵件地址的問題
2. **重複錯誤提示修復** - 解決 axios 攔截器重複顯示錯誤的問題
3. **增強型郵件通知設定** - 支援彈性提醒天數、週/月摘要
4. **CORS 網路錯誤修復** - 新增 port 3002 支援
5. **通知系統實作** - 完整的通知顯示與互動功能
6. **通知面板互動功能** - 查看、標記已讀等按鈕功能完成
### 核心技術架構
- **前端**: Next.js 14 + TypeScript + Material-UI + React Hook Form
- **後端**: Flask + SQLAlchemy + JWT + MySQL
- **認證**: LDAP 整合 + JWT Token 刷新機制
- **郵件**: SMTP 服務整合 (mail.panjit.com.tw:25)
- **資料庫**: MySQL with enhanced notification models
### 重要修復記錄
#### 1. Fire Email 核心問題修復
**檔案**: `backend/utils/email_service.py:_get_user_email`
```python
# 修正前
user_info.get('mail') # 錯誤的欄位名稱
# 修正後
user_info.get('email') # 正確的欄位名稱
```
#### 2. 通知系統 API 實作
**檔案**: `backend/routes/notifications.py`
- 新增 `/api/notifications/` - 獲取用戶通知
- 新增 `/api/notifications/mark-read` - 標記單個通知已讀
- 新增 `/api/notifications/mark-all-read` - 標記全部通知已讀
#### 3. 前端通知面板功能
**檔案**: `frontend/src/components/layout/NotificationPanel.tsx`
- 實作查看按鈕 - 導航到對應 todo
- 實作標記已讀按鈕 - 更新通知狀態
- 實作全部標記已讀 - 批量更新
- 實作查看全部 - 導航到主頁
### 資料庫模型增強
**檔案**: `backend/models.py`
```python
class TodoUserPref(db.Model):
monthly_summary_enabled = db.Column(db.Boolean, default=False)
reminder_days_before = db.Column(JSON, default=lambda: [1, 3])
weekly_summary_time = db.Column(db.String(5), default='09:00')
monthly_summary_time = db.Column(db.String(5), default='09:00')
# ... 其他增強欄位
```
### 已知問題與待實作功能
1. **公開 Todo 功能** - 目前追蹤人角色缺乏意義,需要實作公開 todo 功能
2. **通知已讀狀態持久化** - 目前僅在記憶體中,需要資料庫儲存
3. **Todo 可見性設定** - 需要新增公開/私人設定
### 環境配置
```bash
# 前端 (Port 3002)
cd frontend && npm run dev
# 後端 (Port 5000)
cd backend && ./venv/Scripts/activate && python app.py
```
### 測試用戶帳號
- **uthuang** (92509) - uthuang@panjit.com.tw
- **ymirliu** - ymirliu@panjit.com.tw
- **minjiesyu** (92453) - minjiesyu@panjit.com.tw
### API 端點清單
- `GET /api/notifications/` - 獲取通知清單
- `POST /api/notifications/mark-read` - 標記單個通知已讀
- `POST /api/notifications/mark-all-read` - 標記全部通知已讀
- `POST /api/notifications/fire-email` - 發送緊急郵件
- `GET/PATCH /api/notifications/settings` - 通知設定管理
### 重要設定檔案
- **後端 .env**: 包含 CORS_ORIGINS 設定 (支援 3000,3001,3002)
- **前端 api.ts**: 包含完整的 notificationsApi 客戶端
- **資料庫連線**: mysql.theaken.com:33306/db_A060
## 開發原則提醒
1. 必須提供完整可執行的程式碼
2. 所有功能都需要單元測試
3. 確保 Windows 環境相容性
4. 遵循 MUI 設計規範
5. 實作適當的錯誤處理
---
*最後更新: 2025-08-29 16:19*

176
PRD.md Normal file
View File

@@ -0,0 +1,176 @@
# PANJITTo-Do ListWeb UIV1 產品需求文件PRD
**版本**V1定版
**最後更新**2025-08-28
**資料庫**MySQL所有資料表皆採前綴 `todo_`
**登入**AD/LDAP不限僅內網
**DueDate 精度**:到 **「日」**
**Email 寄件者**:以**建立者的 AD Mail**發送SMTP 需允許代寄或以 Envelope From 配合)
**Excel 匯入**提供正式模板含下拉驗證、README
---
## 1. 背景與目標
- 以 AD 帳號辨識使用者,提供個人/協作化的待辦管理。
- 除固定排程提醒外,提供「**Fire 一鍵提醒**」降低漏辦風險。
- 提供**正式 Excel 模板**以降低匯入錯誤率。
### 成功指標(示例)
- 90% 使用者在 2 週內完成首次建立/匯入待辦。
- 逾期數較導入前下降 30%。
- 主要操作 P95 < 1s匯入任務成功率 99%。
---
## 2. 使用者與場景
- **建立者**建立待辦者預設可見/可編輯
- **負責人多人**可見/可編輯與建立者同等)。
- **追蹤者多人**可見但預設不可編輯可收信
主要場景
1) 新增/編輯待辦可設定多負責人多追蹤者)。
2) 清單/日曆檢視篩選批次操作
3) Fire 一鍵提醒立即寄發可寫附註冷卻與限額管控
4) 排程提醒到期前/當天/逾期 + 週摘要可開關)。
5) 匯入下載模板填寫上傳驗證導入
---
## 3. 功能需求
### 3.1 待辦 CRUD 與視圖
- 欄位
- 必填`title`
- 選填`description`, `priority(LOW|MEDIUM|HIGH|URGENT)`, `status(NEW|DOING|BLOCKED|DONE)`, `due_date(YYYY-MM-DD)`, `starred`
- 系統`id(uuid)`, `created_at`, `completed_at`, `creator_ad`, `creator_display_name`, `creator_email`
- 視圖列表 + 日曆支援篩選狀態到期區間加星與批次操作狀態到期日)。
- 可見性建立者/負責人/追蹤者可見可編輯者為建立者與負責人
### 3.2 多負責人/多追蹤者
- AD 帳號多選加入維護表 `todo_item_responsible`, `todo_item_follower`
### 3.3 Email 通知
- **排程提醒**可開關到期前 X 到期日逾期 Y 週一 09:00 摘要建議)。
- **Fire 一鍵提醒**
- 受信人預設負責人 + 追蹤者 + 建立者可調整/去重)。
- 冷卻/限額**同一待辦 2 分鐘冷卻****每人每日 20 **。
- 可附註所有寄送寫入 `todo_mail_log`
### 3.4 Excel 匯入
- 下載正式模板 README 與下拉驗證)。
- 驗證必填日期格式AD 帳號存在性重複同標題 + 近日期)。
- 逐列錯誤報告與問題列下載」。
---
## 4. 非功能需求
- 效能一般操作 P95 < 1s匯入以背景 Job 執行提供進度查詢
- 可用性SMTP 故障不影響 CRUD提供降級邏輯
- 監控`/healthz`DB/SMTP寄送/匯入皆留 `todo_audit_log`
- 備份每日快照保留 7/30
---
## 5. 資料庫MySQL前綴 `todo_`
- `todo_item`主表待辦
- `todo_item_responsible`多負責人
- `todo_item_follower`多追蹤者
- `todo_mail_log`排程/Fire 寄信紀錄
- `todo_audit_log`稽核日誌
- `todo_user_pref`使用者偏好
```mermaid
erDiagram
todo_item ||--o{ todo_item_responsible : has
todo_item ||--o{ todo_item_follower : has
todo_item ||--o{ todo_mail_log : logs
todo_item ||--o{ todo_audit_log : audits
todo_item {{
char(36) id PK
varchar title
text description
enum status
enum priority
date due_date
datetime created_at
datetime completed_at
varchar creator_ad
varchar creator_display_name
varchar creator_email
tinyint starred
}}
todo_item_responsible {{
char(36) todo_id FK
varchar ad_account
datetime added_at
}}
todo_item_follower {{
char(36) todo_id FK
varchar ad_account
datetime added_at
}}
todo_mail_log {{
bigint id PK
char(36) todo_id FK
enum type
varchar triggered_by_ad
text recipients
varchar subject
enum status
varchar provider_msg_id
text error_text
datetime created_at
datetime sent_at
}}
todo_audit_log {{
bigint id PK
varchar actor_ad
char(36) todo_id FK nullable
enum action
json detail
datetime created_at
}}
todo_user_pref {{
varchar ad_account PK
varchar email
datetime updated_at
}}
```
---
## 6. API摘要
- `GET /api/todos``POST /api/todos``PATCH /api/todos/{{id}}``DELETE /api/todos/{{id}}`
- `POST /api/todos/{{id}}/responsibles``POST /api/todos/{{id}}/followers`
- `POST /api/todos/{{id}}/fire-email`
- `GET /api/imports/template``POST /api/imports``GET /api/imports/{{job_id}}`
---
## 7. 驗收標準
1) AD 登入成功首登寫入個資
2) 多負責人/多追蹤者運作可見/可編輯權限如預期
3) Fire 寄信預設收件人冷卻/限額附註寄送紀錄
4) 排程提醒週摘要可開關且寄送正確
5) 匯入模板下載驗證逐列錯誤成功導入
6) 稽核CRUD/匯入/寄信皆可查
---
## 8. 風險與對策
- SMTP 代寄受限 以系統 Envelope From + `Reply-To:` 建立者
- 匯入錯誤率高 強制模板下拉驗證逐列錯誤檔回饋
---
## 9. 附件
- 正式 Excel 模板`todo_import_template_v1_formal.xlsx`

385
README.md Normal file
View File

@@ -0,0 +1,385 @@
# PANJIT To-Do List System V1
一個基於 Next.js + Flask 的企業級待辦事項管理系統,支援 AD/LDAP 登入、多人協作、Email 提醒等功能。
## 🚀 系統特色
-**AD/LDAP 登入** - 企業級身份驗證
-**多人協作** - 支援多負責人/多追蹤者
-**Fire 一鍵提醒** - 立即郵件提醒功能 (2分鐘冷卻 + 每日20封限額)
-**排程提醒** - 到期前/當天/逾期自動提醒 + 週摘要
-**Excel 匯入** - 完整模板驗證與錯誤處理
-**完整稽核** - 所有操作記錄追蹤
-**響應式設計** - 支援桌面與行動裝置
## 🏗️ 技術架構
### 前端 (Frontend)
- **Next.js 14** - React 全端框架
- **TypeScript** - 類型安全開發
- **Material-UI** - 企業級 UI 組件
- **Redux Toolkit** - 狀態管理
- **TanStack Query** - 服務端狀態管理
### 後端 (Backend)
- **Flask** - Python Web 框架
- **SQLAlchemy** - ORM 資料庫管理
- **MySQL** - 主要資料庫
- **Celery + Redis** - 背景任務處理
- **JWT** - 身份驗證
- **python-ldap** - AD/LDAP 整合
### 部署 (Deployment)
- **Docker** - 容器化部署
- **Nginx** - 反向代理
- **MySQL 8.0** - 資料庫服務
- **Redis** - 快取與任務佇列
## 📋 系統需求
- **Node.js** >= 18.0
- **Python** >= 3.11
- **MySQL** >= 8.0
- **Redis** >= 6.0
## 🛠️ 本地開發安裝
### 1. 克隆專案
```bash
git clone <repository-url>
cd TODOLIST
```
### 2. 資料庫準備
#### 方式一:使用 Docker (推薦)
```bash
# 啟動 MySQL 和 Redis
docker-compose up mysql redis -d
# 等待資料庫啟動完成 (大約30秒)
docker-compose logs mysql
```
#### 方式二:本地 MySQL
```bash
# 建立資料庫
mysql -u root -p
CREATE DATABASE todo_system CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'todouser'@'localhost' IDENTIFIED BY 'todopass';
GRANT ALL PRIVILEGES ON todo_system.* TO 'todouser'@'localhost';
FLUSH PRIVILEGES;
EXIT;
# 初始化資料庫結構
mysql -u todouser -p todo_system < mysql/init/01-init.sql
```
### 3. 後端設定
```bash
cd backend
# 建立虛擬環境
python -m venv venv
venv\Scripts\activate # Windows
# source venv/bin/activate # Linux/macOS
# 安裝依賴
pip install -r requirements.txt
# 複製環境設定檔並修改
copy .env.example .env # Windows
# cp .env.example .env # Linux/macOS
# 編輯 .env 檔案,設定資料庫連線資訊
```
#### 重要環境變數設定
編輯 `backend/.env` 檔案:
```env
# MySQL 連線 (如使用Docker保持預設值即可)
MYSQL_HOST=localhost
MYSQL_PORT=3306
MYSQL_USER=todouser
MYSQL_PASSWORD=todopass
MYSQL_DATABASE=todo_system
# SMTP 設定 (依照公司環境設定)
SMTP_SERVER=mail.your-company.com
SMTP_PORT=25
SMTP_SENDER_EMAIL=todo-system@your-company.com
# AD/LDAP 設定 (依照公司環境設定)
LDAP_SERVER=ldap://dc.your-company.com
LDAP_SEARCH_BASE=DC=your-company,DC=com
```
### 4. 前端設定
```bash
cd frontend
# 安裝依賴
npm install
# 複製環境設定檔
copy .env.example .env.local # Windows
# cp .env.example .env.local # Linux/macOS
# 編輯 .env.local 設定 API URL
```
編輯 `frontend/.env.local`
```env
NEXT_PUBLIC_API_URL=http://localhost:5000
NEXT_PUBLIC_AD_DOMAIN=your-company.com.tw
NEXT_PUBLIC_EMAIL_DOMAIN=your-company.com.tw
```
### 5. 啟動應用程式
#### 後端啟動
```bash
cd backend
# 啟動 Flask 應用程式
python app.py
# 後端將在 http://localhost:5000 啟動
```
#### 前端啟動 (另開終端)
```bash
cd frontend
# 啟動開發服務器
npm run dev
# 前端將在 http://localhost:3000 啟動
```
#### Celery 背景任務 (另開終端)
```bash
cd backend
# 啟動 Celery Worker
celery -A celery_app.celery worker --loglevel=info
# 啟動 Celery Beat (排程任務)
celery -A celery_app.celery beat --loglevel=info
```
## 🌐 訪問應用程式
- **前端界面**: http://localhost:3000
- **後端 API**: http://localhost:5000
- **API 文檔**: http://localhost:5000/api (Swagger UI)
- **健康檢查**: http://localhost:5000/api/health/healthz
## 📁 專案結構
```
TODOLIST/
├── backend/ # Flask 後端
│ ├── routes/ # API 路由
│ ├── models.py # 資料模型
│ ├── config.py # 設定檔
│ ├── app.py # Flask 應用程式入口
│ ├── tasks.py # Celery 背景任務
│ └── utils/ # 工具函數
├── frontend/ # Next.js 前端
│ ├── src/
│ │ ├── app/ # Next.js 應用程式路由
│ │ ├── components/ # React 組件
│ │ ├── store/ # Redux 狀態管理
│ │ ├── lib/ # API 客戶端
│ │ └── types/ # TypeScript 類型定義
├── mysql/
│ └── init/ # 資料庫初始化 SQL
├── nginx/ # Nginx 設定
├── docker-compose.yml # Docker 編排設定
└── PRD.md # 產品需求文件
```
## 🔧 開發指令
### 後端開發
```bash
cd backend
# 啟動開發服務器 (自動重載)
flask run --debug
# 資料庫遷移
flask db upgrade
# 執行 Python 腳本
python -m scripts.init_admin_user
```
### 前端開發
```bash
cd frontend
# 開發模式
npm run dev
# 類型檢查
npm run type-check
# 程式碼檢查
npm run lint
# 建置生產版本
npm run build
# 啟動生產服務器
npm start
```
## 📊 功能說明
### 1. 使用者登入
- 使用 AD/LDAP 帳號登入
- 首次登入自動建立使用者偏好設定
- JWT Token 驗證機制
### 2. 待辦管理
- 建立/編輯/刪除待辦事項
- 支援多負責人與多追蹤者
- 狀態管理NEW/DOING/BLOCKED/DONE
- 優先級LOW/MEDIUM/HIGH/URGENT
- 到期日設定與星號標記
### 3. 通知系統
- **Fire 一鍵提醒**立即發送郵件2分鐘冷卻每日20封限制
- **排程提醒**到期前3天、當天、逾期1天自動提醒
- **週摘要**每週一早上9點發送個人待辦摘要
### 4. Excel 匯入
- 下載正式模板檔案
- 逐列錯誤驗證與報告
- AD 帳號存在性檢查
- 重複項目檢測
### 5. 權限控制
- **建立者**:完整編輯權限
- **負責人**:完整編輯權限
- **追蹤者**:僅檢視權限,可接收通知
## 🛡️ 安全性
- JWT Token 身份驗證
- CORS 跨域保護
- SQL Injection 防護 (SQLAlchemy)
- XSS 防護 (React)
- LDAP 注入防護
- API Rate Limiting (建議生產環境啟用)
## 📝 API 文檔
### 主要 API 端點
- `POST /api/auth/login` - 使用者登入
- `GET /api/todos` - 取得待辦清單
- `POST /api/todos` - 建立待辦事項
- `PATCH /api/todos/{id}` - 更新待辦事項
- `DELETE /api/todos/{id}` - 刪除待辦事項
- `POST /api/todos/{id}/fire-email` - Fire 一鍵提醒
- `GET /api/imports/template` - 下載 Excel 模板
- `POST /api/imports` - 上傳 Excel 檔案
完整 API 文檔請查看http://localhost:5000/api/docs
## 🚀 生產部署
### 使用 Docker Compose
```bash
# 建置並啟動所有服務
docker-compose up -d
# 檢查服務狀態
docker-compose ps
# 查看日誌
docker-compose logs -f backend frontend
```
### 環境變數設定
生產環境請務必修改:
- `SECRET_KEY` - Flask 密鑰
- `JWT_SECRET_KEY` - JWT 密鑰
- 資料庫密碼
- SMTP 設定
- LDAP 設定
## 🔍 故障排除
### 常見問題
1. **資料庫連線失敗**
```bash
# 檢查 MySQL 是否啟動
docker-compose ps mysql
# 檢查連線設定
mysql -h localhost -u todouser -p todo_system
```
2. **LDAP 連線失敗**
- 檢查 LDAP 服務器設定
- 確認網路連線
- 驗證搜尋基底 DN 設定
3. **郵件發送失敗**
- 檢查 SMTP 服務器設定
- 確認防火牆設定
- 驗證寄件者郵箱權限
4. **前端無法連接後端**
- 檢查 `NEXT_PUBLIC_API_URL` 設定
- 確認 CORS 設定
- 檢查後端服務是否正常啟動
### 日誌檢查
```bash
# 後端日誌
tail -f backend/logs/app.log
# Docker 日誌
docker-compose logs -f backend
docker-compose logs -f frontend
# Celery 任務日誌
docker-compose logs -f celery-worker
```
## 👥 開發團隊
- **產品設計**: PANJIT IT Team
- **後端開發**: Flask + SQLAlchemy
- **前端開發**: Next.js + TypeScript
- **系統架構**: Docker + Nginx
## 📄 授權
本專案為 PANJIT 內部使用,請勿外傳。
---
## 🔖 版本資訊
- **版本**: V1.0
- **更新日期**: 2025-08-28
- **Python**: 3.11+
- **Node.js**: 18.0+
- **資料庫**: MySQL 8.0
如有問題請聯繫 IT 部門或查看相關文檔。

92
backend/.env.example Normal file
View File

@@ -0,0 +1,92 @@
# ===========================================
# Flask 應用程式設定
# ===========================================
FLASK_ENV=development
SECRET_KEY=dev-secret-key-change-in-production
# ===========================================
# MySQL 資料庫連線
# ===========================================
# 開發資料庫 (使用提供的測試資料庫)
MYSQL_HOST=mysql.theaken.com
MYSQL_PORT=33306
MYSQL_USER=A060
MYSQL_PASSWORD=WLeSCi0yhtc7
MYSQL_DATABASE=db_A060
# 本地資料庫 (如果要使用本地Docker MySQL)
# MYSQL_HOST=localhost
# MYSQL_PORT=3306
# MYSQL_USER=todouser
# MYSQL_PASSWORD=todopass
# MYSQL_DATABASE=todo_system
# ===========================================
# JWT 設定
# ===========================================
JWT_SECRET_KEY=jwt-secret-key-change-in-production
JWT_ACCESS_TOKEN_EXPIRES_HOURS=8
JWT_REFRESH_TOKEN_EXPIRES_DAYS=30
# ===========================================
# AD/LDAP 設定
# ===========================================
# 開發模式:設定為 true 使用Mock LDAP不需連接真實AD
USE_MOCK_LDAP=true
# 正式LDAP設定當USE_MOCK_LDAP=false時使用
LDAP_SERVER=ldap://dc.company.com
LDAP_PORT=389
LDAP_USE_SSL=false
LDAP_USE_TLS=false
LDAP_SEARCH_BASE=DC=company,DC=com
LDAP_BIND_USER_DN=
LDAP_BIND_USER_PASSWORD=
LDAP_USER_LOGIN_ATTR=userPrincipalName
# ===========================================
# SMTP 郵件設定
# ===========================================
SMTP_SERVER=smtp.company.com
SMTP_PORT=25
SMTP_USE_TLS=false
SMTP_USE_SSL=false
SMTP_AUTH_REQUIRED=false
SMTP_SENDER_EMAIL=todo-system@company.com
SMTP_SENDER_PASSWORD=
# ===========================================
# Fire Email 限制設定
# ===========================================
FIRE_EMAIL_COOLDOWN_MINUTES=2
FIRE_EMAIL_DAILY_LIMIT=20
# ===========================================
# 排程提醒設定
# ===========================================
REMINDER_DAYS_BEFORE=3
REMINDER_DAYS_AFTER=1
WEEKLY_SUMMARY_DAY=0
WEEKLY_SUMMARY_HOUR=9
# ===========================================
# 檔案上傳設定
# ===========================================
MAX_CONTENT_LENGTH=16
UPLOAD_FOLDER=uploads
# ===========================================
# Redis 設定 (用於 Celery)
# ===========================================
REDIS_URL=redis://localhost:6379/0
# ===========================================
# CORS 設定
# ===========================================
CORS_ORIGINS=http://localhost:3000
# ===========================================
# 日誌設定
# ===========================================
LOG_LEVEL=INFO
LOG_FILE=logs/app.log

36
backend/Dockerfile Normal file
View File

@@ -0,0 +1,36 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
g++ \
libldap2-dev \
libsasl2-dev \
libssl-dev \
default-libmysqlclient-dev \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Create necessary directories
RUN mkdir -p logs uploads
# Set environment variables
ENV FLASK_APP=app.py
ENV PYTHONUNBUFFERED=1
# Expose port
EXPOSE 5000
# Run the application
CMD ["python", "-m", "flask", "run", "--host=0.0.0.0", "--port=5000"]

160
backend/app.py Normal file
View File

@@ -0,0 +1,160 @@
import os
import logging
from datetime import datetime
from flask import Flask, jsonify
from flask_cors import CORS
from flask_jwt_extended import JWTManager
from jwt.exceptions import InvalidTokenError
from flask_migrate import Migrate
from flask_mail import Mail
from config import config
from models import db
from utils.logger import setup_logger
# Import blueprints
from routes.auth import auth_bp
from routes.todos import todos_bp
from routes.users import users_bp
from routes.admin import admin_bp
from routes.health import health_bp
from routes.reports import reports_bp
from routes.excel import excel_bp
from routes.notifications import notifications_bp
from routes.scheduler import scheduler_bp
migrate = Migrate()
mail = Mail()
jwt = JWTManager()
def setup_jwt_error_handlers(jwt):
@jwt.expired_token_loader
def expired_token_callback(jwt_header, jwt_payload):
return jsonify({'msg': 'Token has expired'}), 401
@jwt.invalid_token_loader
def invalid_token_callback(error):
return jsonify({'msg': 'Invalid token'}), 401
@jwt.unauthorized_loader
def missing_token_callback(error):
return jsonify({'msg': 'Missing Authorization Header'}), 401
def create_app(config_name=None):
if config_name is None:
config_name = os.environ.get('FLASK_ENV', 'development')
app = Flask(__name__)
app.config.from_object(config[config_name])
# Initialize extensions
db.init_app(app)
migrate.init_app(app, db)
mail.init_app(app)
jwt.init_app(app)
# Setup CORS
CORS(app,
origins=app.config['CORS_ORIGINS'],
methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allow_headers=['Content-Type', 'Authorization'],
supports_credentials=True,
expose_headers=['Content-Type', 'Authorization'])
# Setup logging
setup_logger(app)
# Setup JWT error handlers
setup_jwt_error_handlers(jwt)
# Register blueprints
app.register_blueprint(auth_bp, url_prefix='/api/auth')
app.register_blueprint(todos_bp, url_prefix='/api/todos')
app.register_blueprint(users_bp, url_prefix='/api/users')
app.register_blueprint(admin_bp, url_prefix='/api/admin')
app.register_blueprint(health_bp, url_prefix='/api/health')
app.register_blueprint(reports_bp, url_prefix='/api/reports')
app.register_blueprint(excel_bp, url_prefix='/api/excel')
app.register_blueprint(notifications_bp, url_prefix='/api/notifications')
app.register_blueprint(scheduler_bp, url_prefix='/api/scheduler')
# Register error handlers
register_error_handlers(app)
# Create tables
with app.app_context():
db.create_all()
return app
def register_error_handlers(app):
@app.errorhandler(400)
def bad_request(error):
return jsonify({'error': 'Bad Request', 'message': str(error)}), 400
@app.errorhandler(401)
def unauthorized(error):
return jsonify({'error': 'Unauthorized', 'message': 'Authentication required'}), 401
@app.errorhandler(403)
def forbidden(error):
return jsonify({'error': 'Forbidden', 'message': 'Access denied'}), 403
@app.errorhandler(404)
def not_found(error):
return jsonify({'error': 'Not Found', 'message': 'Resource not found'}), 404
@app.errorhandler(500)
def internal_error(error):
db.session.rollback()
app.logger.error(f"Internal error: {error}")
return jsonify({'error': 'Internal Server Error', 'message': 'An error occurred'}), 500
# Database connection error handlers
from sqlalchemy.exc import OperationalError, DisconnectionError, TimeoutError
from pymysql.err import OperationalError as PyMySQLOperationalError, Error as PyMySQLError
@app.errorhandler(OperationalError)
def handle_db_operational_error(error):
db.session.rollback()
app.logger.error(f"Database operational error: {error}")
# Check if it's a connection timeout or server unavailable error
error_str = str(error)
if 'timed out' in error_str or 'Lost connection' in error_str or "Can't connect" in error_str:
return jsonify({
'error': 'Database Connection Error',
'message': '資料庫連線暫時不穩定,請稍後再試'
}), 503
return jsonify({
'error': 'Database Error',
'message': '資料庫操作失敗,請稍後再試'
}), 500
@app.errorhandler(DisconnectionError)
def handle_db_disconnection_error(error):
db.session.rollback()
app.logger.error(f"Database disconnection error: {error}")
return jsonify({
'error': 'Database Connection Lost',
'message': '資料庫連線中斷,正在重新連線,請稍後再試'
}), 503
@app.errorhandler(TimeoutError)
def handle_db_timeout_error(error):
db.session.rollback()
app.logger.error(f"Database timeout error: {error}")
return jsonify({
'error': 'Database Timeout',
'message': '資料庫操作超時,請稍後再試'
}), 504
@app.errorhandler(Exception)
def handle_exception(error):
db.session.rollback()
app.logger.error(f"Unhandled exception: {error}", exc_info=True)
return jsonify({'error': 'Server Error', 'message': 'An unexpected error occurred'}), 500
if __name__ == '__main__':
app = create_app()
app.run(host='0.0.0.0', port=5000, debug=True)

9
backend/celery_app.py Normal file
View File

@@ -0,0 +1,9 @@
"""
Celery Application Entry Point
用於啟動 Celery worker 和 beat scheduler
"""
from tasks import celery
if __name__ == '__main__':
celery.start()

139
backend/config.py Normal file
View File

@@ -0,0 +1,139 @@
import os
from datetime import timedelta
from dotenv import load_dotenv
load_dotenv()
class Config:
# Flask
SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production')
DEBUG = False
TESTING = False
# Database
MYSQL_HOST = os.getenv('MYSQL_HOST', 'localhost')
MYSQL_PORT = int(os.getenv('MYSQL_PORT', 3306))
MYSQL_USER = os.getenv('MYSQL_USER', 'root')
MYSQL_PASSWORD = os.getenv('MYSQL_PASSWORD', '')
MYSQL_DATABASE = os.getenv('MYSQL_DATABASE', 'todo_system')
SQLALCHEMY_DATABASE_URI = f"mysql+pymysql://{MYSQL_USER}:{MYSQL_PASSWORD}@{MYSQL_HOST}:{MYSQL_PORT}/{MYSQL_DATABASE}?charset=utf8mb4"
SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_ECHO = False
# Database Connection Pool Settings
SQLALCHEMY_ENGINE_OPTIONS = {
'pool_pre_ping': True, # 每次使用前檢查連接
'pool_recycle': 300, # 5分鐘回收連接
'pool_timeout': 20, # 連接超時 20 秒
'max_overflow': 10, # 最大溢出連接數
'pool_size': 5, # 連接池大小
'connect_args': {
'connect_timeout': 10, # MySQL 連接超時
'read_timeout': 30, # MySQL 讀取超時
'write_timeout': 30, # MySQL 寫入超時
'charset': 'utf8mb4'
}
}
# JWT
JWT_SECRET_KEY = os.getenv('JWT_SECRET_KEY', SECRET_KEY)
JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=int(os.getenv('JWT_ACCESS_TOKEN_EXPIRES_HOURS', 8)))
JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=int(os.getenv('JWT_REFRESH_TOKEN_EXPIRES_DAYS', 30)))
JWT_ALGORITHM = 'HS256'
# LDAP/AD
LDAP_SERVER = os.getenv('LDAP_SERVER', 'ldap://dc.company.com')
LDAP_PORT = int(os.getenv('LDAP_PORT', 389))
LDAP_USE_SSL = os.getenv('LDAP_USE_SSL', 'false').lower() == 'true'
LDAP_USE_TLS = os.getenv('LDAP_USE_TLS', 'false').lower() == 'true'
LDAP_SEARCH_BASE = os.getenv('LDAP_SEARCH_BASE', 'DC=company,DC=com')
LDAP_BIND_USER_DN = os.getenv('LDAP_BIND_USER_DN', '')
LDAP_BIND_USER_PASSWORD = os.getenv('LDAP_BIND_USER_PASSWORD', '')
LDAP_USER_LOGIN_ATTR = os.getenv('LDAP_USER_LOGIN_ATTR', 'userPrincipalName')
# SMTP Email
SMTP_SERVER = os.getenv('SMTP_SERVER', 'smtp.company.com')
SMTP_PORT = int(os.getenv('SMTP_PORT', 25))
SMTP_USE_TLS = os.getenv('SMTP_USE_TLS', 'false').lower() == 'true'
SMTP_USE_SSL = os.getenv('SMTP_USE_SSL', 'false').lower() == 'true'
SMTP_AUTH_REQUIRED = os.getenv('SMTP_AUTH_REQUIRED', 'false').lower() == 'true'
SMTP_SENDER_EMAIL = os.getenv('SMTP_SENDER_EMAIL', 'todo-system@company.com')
SMTP_SENDER_PASSWORD = os.getenv('SMTP_SENDER_PASSWORD', '')
# Mail Settings
MAIL_SERVER = SMTP_SERVER
MAIL_PORT = SMTP_PORT
MAIL_USE_TLS = SMTP_USE_TLS
MAIL_USE_SSL = SMTP_USE_SSL
MAIL_USERNAME = SMTP_SENDER_EMAIL if SMTP_AUTH_REQUIRED else None
MAIL_PASSWORD = SMTP_SENDER_PASSWORD if SMTP_AUTH_REQUIRED else None
MAIL_DEFAULT_SENDER = SMTP_SENDER_EMAIL
# Fire Email Limits
FIRE_EMAIL_COOLDOWN_MINUTES = int(os.getenv('FIRE_EMAIL_COOLDOWN_MINUTES', 2))
FIRE_EMAIL_DAILY_LIMIT = int(os.getenv('FIRE_EMAIL_DAILY_LIMIT', 20))
# Scheduled Reminders
REMINDER_DAYS_BEFORE = int(os.getenv('REMINDER_DAYS_BEFORE', 3))
REMINDER_DAYS_AFTER = int(os.getenv('REMINDER_DAYS_AFTER', 1))
WEEKLY_SUMMARY_DAY = int(os.getenv('WEEKLY_SUMMARY_DAY', 0)) # 0=Monday
WEEKLY_SUMMARY_HOUR = int(os.getenv('WEEKLY_SUMMARY_HOUR', 9))
# File Upload
MAX_CONTENT_LENGTH = int(os.getenv('MAX_CONTENT_LENGTH', 16)) * 1024 * 1024 # MB
UPLOAD_FOLDER = os.getenv('UPLOAD_FOLDER', 'uploads')
ALLOWED_EXTENSIONS = {'xlsx', 'xls', 'csv'}
# Pagination
ITEMS_PER_PAGE = int(os.getenv('ITEMS_PER_PAGE', 20))
# Redis (for caching and celery)
REDIS_URL = os.getenv('REDIS_URL', 'redis://localhost:6379/0')
# Celery
CELERY_BROKER_URL = REDIS_URL
CELERY_RESULT_BACKEND = REDIS_URL
# CORS
CORS_ORIGINS = os.getenv('CORS_ORIGINS', 'http://localhost:3000').split(',')
# Logging
LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO')
LOG_FILE = os.getenv('LOG_FILE', 'logs/app.log')
class DevelopmentConfig(Config):
DEBUG = True
SQLALCHEMY_ECHO = True
# 開發模式可使用Mock LDAP
USE_MOCK_LDAP = os.getenv('USE_MOCK_LDAP', 'true').lower() == 'true'
class ProductionConfig(Config):
DEBUG = False
TESTING = False
class TestingConfig(Config):
TESTING = True
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
SQLALCHEMY_TRACK_MODIFICATIONS = False
# 禁用外部服務
CELERY_TASK_ALWAYS_EAGER = True
CELERY_TASK_EAGER_PROPAGATES = True
# 測試用的簡化設定
JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1)
FIRE_EMAIL_COOLDOWN_MINUTES = 2
FIRE_EMAIL_DAILY_LIMIT = 3
# 禁用郵件發送
MAIL_SUPPRESS_SEND = True
config = {
'development': DevelopmentConfig,
'production': ProductionConfig,
'testing': TestingConfig,
'default': DevelopmentConfig
}

View File

@@ -0,0 +1,141 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""Create sample todo data for testing"""
import os
import sys
from dotenv import load_dotenv
import pymysql
from datetime import datetime, timedelta
import uuid
# Load environment variables
load_dotenv()
def create_sample_todos():
"""Create sample todo items for testing"""
print("=" * 60)
print("Creating Sample Todo Data")
print("=" * 60)
db_config = {
'host': os.getenv('MYSQL_HOST', 'mysql.theaken.com'),
'port': int(os.getenv('MYSQL_PORT', 33306)),
'user': os.getenv('MYSQL_USER', 'A060'),
'password': os.getenv('MYSQL_PASSWORD', 'WLeSCi0yhtc7'),
'database': os.getenv('MYSQL_DATABASE', 'db_A060'),
'charset': 'utf8mb4'
}
try:
connection = pymysql.connect(**db_config)
cursor = connection.cursor()
# Sample todos data
sample_todos = [
{
'title': '完成網站改版設計稿',
'description': '設計新版網站的主要頁面布局,包含首頁、產品頁面和聯絡頁面',
'status': 'DOING',
'priority': 'HIGH',
'due_date': (datetime.now() + timedelta(days=7)).date(),
'creator_ad': '92367',
'creator_display_name': 'ymirliu 陸一銘',
'creator_email': 'ymirliu@panjit.com.tw',
'starred': True
},
{
'title': '資料庫效能優化',
'description': '優化主要查詢語句,提升系統響應速度',
'status': 'NEW',
'priority': 'URGENT',
'due_date': (datetime.now() + timedelta(days=3)).date(),
'creator_ad': '92367',
'creator_display_name': 'ymirliu 陸一銘',
'creator_email': 'ymirliu@panjit.com.tw',
'starred': False
},
{
'title': 'API 文檔更新',
'description': '更新所有 API 介面文檔,補充新增的端點說明',
'status': 'DOING',
'priority': 'MEDIUM',
'due_date': (datetime.now() + timedelta(days=10)).date(),
'creator_ad': 'test',
'creator_display_name': '測試使用者',
'creator_email': 'test@panjit.com.tw',
'starred': False
},
{
'title': '使用者測試回饋整理',
'description': '整理上週使用者測試的所有回饋意見,並分類處理',
'status': 'BLOCKED',
'priority': 'LOW',
'due_date': (datetime.now() + timedelta(days=15)).date(),
'creator_ad': 'test',
'creator_display_name': '測試使用者',
'creator_email': 'test@panjit.com.tw',
'starred': True
},
{
'title': '系統安全性檢查',
'description': '對系統進行全面的安全性檢查,確保沒有漏洞',
'status': 'NEW',
'priority': 'URGENT',
'due_date': (datetime.now() + timedelta(days=2)).date(),
'creator_ad': '92367',
'creator_display_name': 'ymirliu 陸一銘',
'creator_email': 'ymirliu@panjit.com.tw',
'starred': False
}
]
created_count = 0
for todo in sample_todos:
todo_id = str(uuid.uuid4())
sql = """
INSERT INTO todo_item
(id, title, description, status, priority, due_date, created_at, creator_ad, creator_display_name, creator_email, starred)
VALUES
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
"""
values = (
todo_id,
todo['title'],
todo['description'],
todo['status'],
todo['priority'],
todo['due_date'],
datetime.now(),
todo['creator_ad'],
todo['creator_display_name'],
todo['creator_email'],
todo['starred']
)
cursor.execute(sql, values)
created_count += 1
print(f"[OK] Created todo: {todo['title']} (ID: {todo_id[:8]}...)")
connection.commit()
print(f"\n[SUCCESS] Created {created_count} sample todos successfully!")
# Show summary
cursor.execute("SELECT COUNT(*) FROM todo_item")
total_count = cursor.fetchone()[0]
print(f"[INFO] Total todos in database: {total_count}")
cursor.close()
connection.close()
return True
except Exception as e:
print(f"[ERROR] Failed to create sample data: {str(e)}")
return False
if __name__ == "__main__":
create_sample_todos()

90
backend/debug_ldap.py Normal file
View File

@@ -0,0 +1,90 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""Debug LDAP search to find the correct format"""
import os
import sys
from dotenv import load_dotenv
from ldap3 import Server, Connection, SUBTREE, ALL_ATTRIBUTES
# Load environment variables
load_dotenv()
def debug_ldap():
"""Debug LDAP search"""
print("=" * 60)
print("Debug LDAP Search")
print("=" * 60)
# Get LDAP configuration
ldap_server = os.getenv('LDAP_SERVER', 'ldap://panjit.com.tw')
ldap_port = int(os.getenv('LDAP_PORT', 389))
ldap_bind_user = os.getenv('LDAP_BIND_USER_DN', '')
ldap_bind_password = os.getenv('LDAP_BIND_USER_PASSWORD', '')
ldap_search_base = os.getenv('LDAP_SEARCH_BASE', 'DC=panjit,DC=com,DC=tw')
print(f"LDAP Server: {ldap_server}")
print(f"LDAP Port: {ldap_port}")
print(f"Search Base: {ldap_search_base}")
print("-" * 60)
try:
# Create server object
server = Server(
ldap_server,
port=ldap_port,
use_ssl=False,
get_info=ALL_ATTRIBUTES
)
# Create connection with bind user
conn = Connection(
server,
user=ldap_bind_user,
password=ldap_bind_password,
auto_bind=True,
raise_exceptions=True
)
print("[OK] Successfully connected to LDAP server")
# Test different search filters
test_searches = [
"(&(objectClass=person)(sAMAccountName=ymirliu))",
"(&(objectClass=person)(userPrincipalName=ymirliu@panjit.com.tw))",
"(&(objectClass=person)(mail=ymirliu@panjit.com.tw))",
"(&(objectClass=person)(cn=*ymirliu*))",
"(&(objectClass=person)(displayName=*ymirliu*))",
]
for i, search_filter in enumerate(test_searches, 1):
print(f"\n[{i}] Testing filter: {search_filter}")
conn.search(
ldap_search_base,
search_filter,
SUBTREE,
attributes=['sAMAccountName', 'displayName', 'mail', 'userPrincipalName', 'cn']
)
if conn.entries:
print(f" Found {len(conn.entries)} entries:")
for entry in conn.entries:
print(f" sAMAccountName: {entry.sAMAccountName}")
print(f" userPrincipalName: {entry.userPrincipalName}")
print(f" displayName: {entry.displayName}")
print(f" mail: {entry.mail}")
print(f" cn: {entry.cn}")
print()
else:
print(" No entries found")
conn.unbind()
except Exception as e:
print(f"[ERROR] LDAP connection failed: {str(e)}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
debug_ldap()

65
backend/init_db.py Normal file
View File

@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""
資料庫初始化腳本
在現有資料庫中建立 todo 系統所需的表格
"""
import sys
import os
from flask import Flask
from config import config
from models import db
def init_database():
"""初始化資料庫表格"""
try:
# 建立 Flask app
app = Flask(__name__)
app.config.from_object(config['development'])
# 初始化資料庫
db.init_app(app)
with app.app_context():
print("正在建立資料庫表格...")
# 建立所有表格
db.create_all()
print("✅ 資料庫表格建立完成!")
print("\n建立的表格:")
for table in db.metadata.tables.keys():
print(f" - {table}")
return True
except Exception as e:
print(f"❌ 資料庫初始化失敗: {str(e)}")
return False
def main():
print("=" * 50)
print("PANJIT To-Do System - 資料庫初始化")
print("=" * 50)
# 檢查環境變數檔案
if not os.path.exists('.env'):
print("⚠️ 找不到 .env 檔案")
print("請先執行: copy .env.example .env")
return False
# 初始化資料庫
success = init_database()
if success:
print("\n🎉 初始化完成!")
print("現在可以啟動應用程式了")
else:
print("\n💥 初始化失敗")
print("請檢查資料庫連線設定")
return success
if __name__ == '__main__':
success = main()
sys.exit(0 if success else 1)

240
backend/models.py Normal file
View File

@@ -0,0 +1,240 @@
from datetime import datetime
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.dialects.mysql import CHAR, ENUM, JSON, BIGINT
from sqlalchemy import text
import uuid
db = SQLAlchemy()
def generate_uuid():
return str(uuid.uuid4())
class TodoItem(db.Model):
__tablename__ = 'todo_item'
id = db.Column(CHAR(36), primary_key=True, default=generate_uuid)
title = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text)
status = db.Column(ENUM('NEW', 'DOING', 'BLOCKED', 'DONE'), default='NEW')
priority = db.Column(ENUM('LOW', 'MEDIUM', 'HIGH', 'URGENT'), default='MEDIUM')
due_date = db.Column(db.Date)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
completed_at = db.Column(db.DateTime)
creator_ad = db.Column(db.String(128), nullable=False)
creator_display_name = db.Column(db.String(128))
creator_email = db.Column(db.String(256))
starred = db.Column(db.Boolean, default=False)
# Relationships
responsible_users = db.relationship('TodoItemResponsible', back_populates='todo', cascade='all, delete-orphan')
followers = db.relationship('TodoItemFollower', back_populates='todo', cascade='all, delete-orphan')
mail_logs = db.relationship('TodoMailLog', back_populates='todo', cascade='all, delete-orphan')
audit_logs = db.relationship('TodoAuditLog', back_populates='todo')
fire_email_logs = db.relationship('TodoFireEmailLog', back_populates='todo', cascade='all, delete-orphan')
def to_dict(self, include_user_details=True):
result = {
'id': self.id,
'title': self.title,
'description': self.description,
'status': self.status,
'priority': self.priority,
'due_date': self.due_date.isoformat() if self.due_date else None,
'created_at': self.created_at.isoformat(),
'completed_at': self.completed_at.isoformat() if self.completed_at else None,
'creator_ad': self.creator_ad,
'creator_display_name': self.creator_display_name,
'creator_email': self.creator_email,
'starred': self.starred,
'responsible_users': [r.ad_account for r in self.responsible_users],
'followers': [f.ad_account for f in self.followers]
}
# 如果需要包含用戶詳細信息,則添加 display names
if include_user_details:
from utils.ldap_utils import validate_ad_accounts
# 獲取所有相關用戶的 display names
all_users = set([self.creator_ad] + [r.ad_account for r in self.responsible_users] + [f.ad_account for f in self.followers])
user_details = validate_ad_accounts(list(all_users))
# 添加用戶詳細信息
result['responsible_users_details'] = []
for r in self.responsible_users:
user_info = user_details.get(r.ad_account, {})
result['responsible_users_details'].append({
'ad_account': r.ad_account,
'display_name': user_info.get('display_name', r.ad_account),
'email': user_info.get('email', '')
})
result['followers_details'] = []
for f in self.followers:
user_info = user_details.get(f.ad_account, {})
result['followers_details'].append({
'ad_account': f.ad_account,
'display_name': user_info.get('display_name', f.ad_account),
'email': user_info.get('email', '')
})
return result
def can_edit(self, user_ad):
"""Check if user can edit this todo"""
if self.creator_ad == user_ad:
return True
return any(r.ad_account == user_ad for r in self.responsible_users)
def can_view(self, user_ad):
"""Check if user can view this todo"""
if self.can_edit(user_ad):
return True
return any(f.ad_account == user_ad for f in self.followers)
class TodoItemResponsible(db.Model):
__tablename__ = 'todo_item_responsible'
todo_id = db.Column(CHAR(36), db.ForeignKey('todo_item.id', ondelete='CASCADE'), primary_key=True)
ad_account = db.Column(db.String(128), primary_key=True)
added_by = db.Column(db.String(128))
added_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
# Relationships
todo = db.relationship('TodoItem', back_populates='responsible_users')
class TodoItemFollower(db.Model):
__tablename__ = 'todo_item_follower'
todo_id = db.Column(CHAR(36), db.ForeignKey('todo_item.id', ondelete='CASCADE'), primary_key=True)
ad_account = db.Column(db.String(128), primary_key=True)
added_by = db.Column(db.String(128))
added_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
# Relationships
todo = db.relationship('TodoItem', back_populates='followers')
class TodoMailLog(db.Model):
__tablename__ = 'todo_mail_log'
id = db.Column(BIGINT, primary_key=True, autoincrement=True)
todo_id = db.Column(CHAR(36), db.ForeignKey('todo_item.id', ondelete='CASCADE'))
type = db.Column(ENUM('SCHEDULED', 'FIRE'), nullable=False)
triggered_by_ad = db.Column(db.String(128))
recipients = db.Column(db.Text)
subject = db.Column(db.String(255))
status = db.Column(ENUM('QUEUED', 'SENT', 'FAILED'), default='QUEUED')
provider_msg_id = db.Column(db.String(128))
error_text = db.Column(db.Text)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
sent_at = db.Column(db.DateTime)
# Relationships
todo = db.relationship('TodoItem', back_populates='mail_logs')
class TodoAuditLog(db.Model):
__tablename__ = 'todo_audit_log'
id = db.Column(BIGINT, primary_key=True, autoincrement=True)
actor_ad = db.Column(db.String(128), nullable=False)
todo_id = db.Column(CHAR(36), db.ForeignKey('todo_item.id', ondelete='SET NULL'))
action = db.Column(ENUM('CREATE', 'UPDATE', 'DELETE', 'COMPLETE', 'IMPORT',
'MAIL_SENT', 'MAIL_FAIL', 'FIRE_EMAIL', 'DIGEST_EMAIL', 'BULK_REMINDER'), nullable=False)
detail = db.Column(JSON)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
# Relationships
todo = db.relationship('TodoItem', back_populates='audit_logs')
class TodoUserPref(db.Model):
__tablename__ = 'todo_user_pref'
ad_account = db.Column(db.String(128), primary_key=True)
email = db.Column(db.String(256))
display_name = db.Column(db.String(128))
theme = db.Column(ENUM('light', 'dark', 'auto'), default='auto')
language = db.Column(db.String(10), default='zh-TW')
timezone = db.Column(db.String(50), default='Asia/Taipei')
notification_enabled = db.Column(db.Boolean, default=True)
email_reminder_enabled = db.Column(db.Boolean, default=True)
weekly_summary_enabled = db.Column(db.Boolean, default=True)
monthly_summary_enabled = db.Column(db.Boolean, default=False)
# 彈性的到期提醒天數設定 (JSON陣列如 [1, 3, 5] 表示前1天、前3天、前5天提醒)
reminder_days_before = db.Column(JSON, default=lambda: [1, 3])
# 摘要郵件時間設定 (時:分格式,如 "09:00")
daily_summary_time = db.Column(db.String(5), default='09:00')
weekly_summary_time = db.Column(db.String(5), default='09:00')
monthly_summary_time = db.Column(db.String(5), default='09:00')
# 摘要郵件週幾發送 (0=週日, 1=週一, ..., 6=週六)
weekly_summary_day = db.Column(db.Integer, default=1) # 預設週一
monthly_summary_day = db.Column(db.Integer, default=1) # 預設每月1日
# Fire email 配額控制
fire_email_today_count = db.Column(db.Integer, default=0)
fire_email_last_reset = db.Column(db.Date)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
def to_dict(self):
return {
'ad_account': self.ad_account,
'email': self.email,
'display_name': self.display_name,
'theme': self.theme,
'language': self.language,
'timezone': self.timezone,
'notification_enabled': self.notification_enabled,
'email_reminder_enabled': self.email_reminder_enabled,
'weekly_summary_enabled': self.weekly_summary_enabled,
'monthly_summary_enabled': self.monthly_summary_enabled,
'reminder_days_before': self.reminder_days_before or [1, 3],
'daily_summary_time': self.daily_summary_time,
'weekly_summary_time': self.weekly_summary_time,
'monthly_summary_time': self.monthly_summary_time,
'weekly_summary_day': self.weekly_summary_day,
'monthly_summary_day': self.monthly_summary_day,
}
class TodoImportJob(db.Model):
__tablename__ = 'todo_import_job'
id = db.Column(CHAR(36), primary_key=True, default=generate_uuid)
actor_ad = db.Column(db.String(128), nullable=False)
filename = db.Column(db.String(255))
total_rows = db.Column(db.Integer, default=0)
success_rows = db.Column(db.Integer, default=0)
failed_rows = db.Column(db.Integer, default=0)
status = db.Column(ENUM('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED'), default='PENDING')
error_file_path = db.Column(db.String(500))
error_details = db.Column(JSON)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
completed_at = db.Column(db.DateTime)
def to_dict(self):
return {
'id': self.id,
'actor_ad': self.actor_ad,
'filename': self.filename,
'total_rows': self.total_rows,
'success_rows': self.success_rows,
'failed_rows': self.failed_rows,
'status': self.status,
'error_file_path': self.error_file_path,
'error_details': self.error_details,
'created_at': self.created_at.isoformat(),
'completed_at': self.completed_at.isoformat() if self.completed_at else None
}
class TodoFireEmailLog(db.Model):
__tablename__ = 'todo_fire_email_log'
id = db.Column(BIGINT, primary_key=True, autoincrement=True)
todo_id = db.Column(CHAR(36), db.ForeignKey('todo_item.id', ondelete='CASCADE'), nullable=False)
sender_ad = db.Column(db.String(128), nullable=False)
sent_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
# Relationships
todo = db.relationship('TodoItem', back_populates='fire_email_logs')

37
backend/requirements.txt Normal file
View File

@@ -0,0 +1,37 @@
# Flask and Extensions
Flask==2.3.3
Flask-JWT-Extended==4.5.3
Flask-CORS==4.0.0
Flask-SQLAlchemy==3.0.5
Flask-Migrate==4.0.5
Flask-Mail==0.9.1
# Database
SQLAlchemy==2.0.23
PyMySQL==1.1.0
# Task Queue
Celery==5.3.4
redis==5.0.1
# LDAP (Windows compatible)
ldap3==2.9.1
# Excel Processing
pandas==2.1.3
openpyxl==3.1.2
xlsxwriter==3.1.9
# Utilities
python-dotenv==1.0.0
Werkzeug==2.3.7
requests==2.31.0
colorlog==6.8.0
# Development and Testing
pytest==7.4.3
pytest-cov==4.1.0
pytest-flask==1.3.0
# Type hints
typing-extensions==4.8.0

191
backend/routes/admin.py Normal file
View File

@@ -0,0 +1,191 @@
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
from datetime import datetime, timedelta
from sqlalchemy import func
from models import db, TodoItem, TodoAuditLog, TodoMailLog, TodoImportJob
from utils.logger import get_logger
admin_bp = Blueprint('admin', __name__)
logger = get_logger(__name__)
# Admin users (in production, this should be in database or config)
ADMIN_USERS = ['admin', 'administrator']
def is_admin(identity):
"""Check if user is admin"""
return identity.lower() in ADMIN_USERS
@admin_bp.route('/stats', methods=['GET'])
@jwt_required()
def get_stats():
"""Get system statistics"""
try:
identity = get_jwt_identity()
if not is_admin(identity):
return jsonify({'error': 'Admin access required'}), 403
# Get date range
days = request.args.get('days', 30, type=int)
start_date = datetime.utcnow() - timedelta(days=days)
# Todo statistics
todo_stats = db.session.query(
func.count(TodoItem.id).label('total'),
func.sum(func.if_(TodoItem.status == 'NEW', 1, 0)).label('new'),
func.sum(func.if_(TodoItem.status == 'DOING', 1, 0)).label('doing'),
func.sum(func.if_(TodoItem.status == 'BLOCKED', 1, 0)).label('blocked'),
func.sum(func.if_(TodoItem.status == 'DONE', 1, 0)).label('done')
).filter(TodoItem.created_at >= start_date).first()
# User activity
active_users = db.session.query(
func.count(func.distinct(TodoAuditLog.actor_ad))
).filter(TodoAuditLog.created_at >= start_date).scalar()
# Email statistics
email_stats = db.session.query(
func.count(TodoMailLog.id).label('total'),
func.sum(func.if_(TodoMailLog.status == 'SENT', 1, 0)).label('sent'),
func.sum(func.if_(TodoMailLog.status == 'FAILED', 1, 0)).label('failed')
).filter(TodoMailLog.created_at >= start_date).first()
# Import statistics
import_stats = db.session.query(
func.count(TodoImportJob.id).label('total'),
func.sum(func.if_(TodoImportJob.status == 'COMPLETED', 1, 0)).label('completed'),
func.sum(func.if_(TodoImportJob.status == 'FAILED', 1, 0)).label('failed')
).filter(TodoImportJob.created_at >= start_date).first()
return jsonify({
'period_days': days,
'todos': {
'total': todo_stats.total or 0,
'new': todo_stats.new or 0,
'doing': todo_stats.doing or 0,
'blocked': todo_stats.blocked or 0,
'done': todo_stats.done or 0
},
'users': {
'active': active_users or 0
},
'emails': {
'total': email_stats.total or 0,
'sent': email_stats.sent or 0,
'failed': email_stats.failed or 0
},
'imports': {
'total': import_stats.total or 0,
'completed': import_stats.completed or 0,
'failed': import_stats.failed or 0
}
}), 200
except Exception as e:
logger.error(f"Error fetching stats: {str(e)}")
return jsonify({'error': 'Failed to fetch statistics'}), 500
@admin_bp.route('/audit-logs', methods=['GET'])
@jwt_required()
def get_audit_logs():
"""Get audit logs"""
try:
identity = get_jwt_identity()
if not is_admin(identity):
return jsonify({'error': 'Admin access required'}), 403
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 50, type=int)
actor = request.args.get('actor')
action = request.args.get('action')
todo_id = request.args.get('todo_id')
query = TodoAuditLog.query
if actor:
query = query.filter(TodoAuditLog.actor_ad == actor)
if action:
query = query.filter(TodoAuditLog.action == action)
if todo_id:
query = query.filter(TodoAuditLog.todo_id == todo_id)
query = query.order_by(TodoAuditLog.created_at.desc())
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
logs = []
for log in pagination.items:
logs.append({
'id': log.id,
'actor_ad': log.actor_ad,
'todo_id': log.todo_id,
'action': log.action,
'detail': log.detail,
'created_at': log.created_at.isoformat()
})
return jsonify({
'logs': logs,
'total': pagination.total,
'page': page,
'per_page': per_page,
'pages': pagination.pages
}), 200
except Exception as e:
logger.error(f"Error fetching audit logs: {str(e)}")
return jsonify({'error': 'Failed to fetch audit logs'}), 500
@admin_bp.route('/mail-logs', methods=['GET'])
@jwt_required()
def get_mail_logs():
"""Get mail logs"""
try:
identity = get_jwt_identity()
if not is_admin(identity):
return jsonify({'error': 'Admin access required'}), 403
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 50, type=int)
status = request.args.get('status')
type_ = request.args.get('type')
query = TodoMailLog.query
if status:
query = query.filter(TodoMailLog.status == status)
if type_:
query = query.filter(TodoMailLog.type == type_)
query = query.order_by(TodoMailLog.created_at.desc())
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
logs = []
for log in pagination.items:
logs.append({
'id': log.id,
'todo_id': log.todo_id,
'type': log.type,
'triggered_by_ad': log.triggered_by_ad,
'recipients': log.recipients,
'subject': log.subject,
'status': log.status,
'error_text': log.error_text,
'created_at': log.created_at.isoformat(),
'sent_at': log.sent_at.isoformat() if log.sent_at else None
})
return jsonify({
'logs': logs,
'total': pagination.total,
'page': page,
'per_page': per_page,
'pages': pagination.pages
}), 200
except Exception as e:
logger.error(f"Error fetching mail logs: {str(e)}")
return jsonify({'error': 'Failed to fetch mail logs'}), 500

175
backend/routes/auth.py Normal file
View File

@@ -0,0 +1,175 @@
from flask import Blueprint, request, jsonify
from flask_jwt_extended import (
create_access_token, create_refresh_token,
jwt_required, get_jwt_identity, get_jwt
)
from datetime import datetime, timedelta
from flask import current_app
from models import db, TodoUserPref
from utils.logger import get_logger
auth_bp = Blueprint('auth', __name__)
logger = get_logger(__name__)
@auth_bp.route('/login', methods=['POST'])
def login():
"""AD/LDAP Login"""
try:
data = request.get_json()
username = data.get('username', '').strip()
password = data.get('password', '')
if not username or not password:
return jsonify({'error': 'Username and password required'}), 400
# Authenticate with LDAP (or mock for development)
try:
if current_app.config.get('USE_MOCK_LDAP', False):
from utils.mock_ldap import authenticate_user
logger.info("Using Mock LDAP for development")
else:
from utils.ldap_utils import authenticate_user
logger.info("Using real LDAP authentication")
user_info = authenticate_user(username, password)
except Exception as e:
logger.error(f"LDAP authentication error, falling back to mock: {str(e)}")
from utils.mock_ldap import authenticate_user
user_info = authenticate_user(username, password)
if not user_info:
logger.warning(f"Failed login attempt for user: {username}")
return jsonify({'error': 'Invalid credentials'}), 401
ad_account = user_info['ad_account']
# Get or create user preferences
user_pref = TodoUserPref.query.filter_by(ad_account=ad_account).first()
if not user_pref:
user_pref = TodoUserPref(
ad_account=ad_account,
email=user_info['email'],
display_name=user_info['display_name']
)
db.session.add(user_pref)
db.session.commit()
logger.info(f"Created new user preference for: {ad_account}")
else:
# Update user info if changed
if user_pref.email != user_info['email'] or user_pref.display_name != user_info['display_name']:
user_pref.email = user_info['email']
user_pref.display_name = user_info['display_name']
user_pref.updated_at = datetime.utcnow()
db.session.commit()
# Create tokens
access_token = create_access_token(
identity=ad_account,
additional_claims={
'display_name': user_info['display_name'],
'email': user_info['email']
}
)
refresh_token = create_refresh_token(identity=ad_account)
logger.info(f"Successful login for user: {ad_account}")
return jsonify({
'access_token': access_token,
'refresh_token': refresh_token,
'user': {
'ad_account': ad_account,
'display_name': user_info['display_name'],
'email': user_info['email'],
'theme': user_pref.theme,
'language': user_pref.language
}
}), 200
except Exception as e:
logger.error(f"Login error: {str(e)}")
return jsonify({'error': 'Authentication failed'}), 500
@auth_bp.route('/refresh', methods=['POST'])
@jwt_required(refresh=True)
def refresh():
"""Refresh access token"""
try:
identity = get_jwt_identity()
# Get user info
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
if not user_pref:
return jsonify({'error': 'User not found'}), 404
access_token = create_access_token(
identity=identity,
additional_claims={
'display_name': user_pref.display_name,
'email': user_pref.email
}
)
return jsonify({'access_token': access_token}), 200
except Exception as e:
logger.error(f"Token refresh error: {str(e)}")
return jsonify({'error': 'Token refresh failed'}), 500
@auth_bp.route('/logout', methods=['POST'])
@jwt_required()
def logout():
"""Logout (client should remove tokens)"""
try:
identity = get_jwt_identity()
logger.info(f"User logged out: {identity}")
# In production, you might want to blacklist the token here
# For now, we'll rely on client-side token removal
return jsonify({'message': 'Logged out successfully'}), 200
except Exception as e:
logger.error(f"Logout error: {str(e)}")
return jsonify({'error': 'Logout failed'}), 500
@auth_bp.route('/me', methods=['GET'])
@jwt_required()
def get_current_user():
"""Get current user information"""
try:
identity = get_jwt_identity()
claims = get_jwt()
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
if not user_pref:
return jsonify({'error': 'User not found'}), 404
return jsonify({
'ad_account': identity,
'display_name': claims.get('display_name', user_pref.display_name),
'email': claims.get('email', user_pref.email),
'preferences': user_pref.to_dict()
}), 200
except Exception as e:
logger.error(f"Get current user error: {str(e)}")
return jsonify({'error': 'Failed to get user information'}), 500
@auth_bp.route('/validate', methods=['GET'])
@jwt_required()
def validate_token():
"""Validate JWT token"""
try:
identity = get_jwt_identity()
claims = get_jwt()
return jsonify({
'valid': True,
'identity': identity,
'claims': claims
}), 200
except Exception as e:
logger.error(f"Token validation error: {str(e)}")
return jsonify({'valid': False}), 401

527
backend/routes/excel.py Normal file
View File

@@ -0,0 +1,527 @@
"""
Excel Import/Export API Routes
處理 Excel 檔案的匯入和匯出功能
"""
import os
import uuid
from datetime import datetime, date
from flask import Blueprint, request, jsonify, send_file, current_app
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
from werkzeug.utils import secure_filename
import pandas as pd
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill
from openpyxl.utils.dataframe import dataframe_to_rows
from sqlalchemy import or_, and_
from models import (
db, TodoItem, TodoItemResponsible, TodoItemFollower,
TodoAuditLog
)
from utils.logger import get_logger
from utils.ldap_utils import validate_ad_accounts
import tempfile
import zipfile
excel_bp = Blueprint('excel', __name__)
logger = get_logger(__name__)
# 允許的檔案類型
ALLOWED_EXTENSIONS = {'xlsx', 'xls', 'csv'}
def allowed_file(filename):
"""檢查檔案類型是否允許"""
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def parse_date(date_str):
"""解析日期字串"""
if pd.isna(date_str) or not date_str:
return None
if isinstance(date_str, datetime):
return date_str.date()
if isinstance(date_str, date):
return date_str
# 嘗試多種日期格式
date_formats = ['%Y-%m-%d', '%Y/%m/%d', '%d/%m/%Y', '%m/%d/%Y', '%Y%m%d']
for fmt in date_formats:
try:
return datetime.strptime(str(date_str), fmt).date()
except ValueError:
continue
return None
@excel_bp.route('/upload', methods=['POST'])
@jwt_required()
def upload_excel():
"""Upload and parse Excel file for todo import"""
try:
identity = get_jwt_identity()
if 'file' not in request.files:
return jsonify({'error': '沒有選擇檔案'}), 400
file = request.files['file']
if file.filename == '':
return jsonify({'error': '沒有選擇檔案'}), 400
if not allowed_file(file.filename):
return jsonify({'error': '檔案類型不支援,請上傳 .xlsx, .xls 或 .csv 檔案'}), 400
# 儲存檔案到暫存目錄
filename = secure_filename(file.filename)
temp_dir = current_app.config.get('TEMP_FOLDER', tempfile.gettempdir())
filepath = os.path.join(temp_dir, f"{uuid.uuid4()}_{filename}")
file.save(filepath)
try:
# 讀取 Excel/CSV 檔案
if filename.endswith('.csv'):
df = pd.read_csv(filepath, encoding='utf-8')
else:
df = pd.read_excel(filepath)
# 驗證必要欄位
required_columns = ['標題', 'title'] # 支援中英文欄位名
title_column = None
for col in required_columns:
if col in df.columns:
title_column = col
break
if not title_column:
return jsonify({
'error': '找不到必要欄位「標題」或「title」',
'columns': list(df.columns)
}), 400
# 解析資料
todos_data = []
errors = []
for idx, row in df.iterrows():
try:
# 必要欄位
title = str(row[title_column]).strip()
if not title or title == 'nan':
errors.append(f'{idx + 2} 行:標題不能為空')
continue
# 選擇性欄位
description = str(row.get('描述', row.get('description', ''))).strip()
if description == 'nan':
description = ''
# 狀態
status_mapping = {
'新建': 'NEW', '進行中': 'IN_PROGRESS', '完成': 'DONE',
'NEW': 'NEW', 'IN_PROGRESS': 'IN_PROGRESS', 'DONE': 'DONE',
'': 'NEW', '進行': 'IN_PROGRESS', '': 'DONE'
}
status_str = str(row.get('狀態', row.get('status', 'NEW'))).strip()
status = status_mapping.get(status_str, 'NEW')
# 優先級
priority_mapping = {
'': 'HIGH', '': 'MEDIUM', '': 'LOW',
'HIGH': 'HIGH', 'MEDIUM': 'MEDIUM', 'LOW': 'LOW',
'高優先級': 'HIGH', '中優先級': 'MEDIUM', '低優先級': 'LOW'
}
priority_str = str(row.get('優先級', row.get('priority', 'MEDIUM'))).strip()
priority = priority_mapping.get(priority_str, 'MEDIUM')
# 到期日
due_date = parse_date(row.get('到期日', row.get('due_date')))
# 負責人 (用分號或逗號分隔)
responsible_str = str(row.get('負責人', row.get('responsible_users', ''))).strip()
responsible_users = []
if responsible_str and responsible_str != 'nan':
responsible_users = [user.strip() for user in responsible_str.replace(',', ';').split(';') if user.strip()]
# 追蹤人
followers_str = str(row.get('追蹤人', row.get('followers', ''))).strip()
followers = []
if followers_str and followers_str != 'nan':
followers = [user.strip() for user in followers_str.replace(',', ';').split(';') if user.strip()]
todos_data.append({
'row': idx + 2,
'title': title,
'description': description,
'status': status,
'priority': priority,
'due_date': due_date.isoformat() if due_date else None,
'responsible_users': responsible_users,
'followers': followers
})
except Exception as e:
errors.append(f'{idx + 2} 行解析錯誤: {str(e)}')
# 清理暫存檔案
os.unlink(filepath)
return jsonify({
'data': todos_data,
'total': len(todos_data),
'errors': errors,
'columns': list(df.columns)
}), 200
except Exception as e:
# 清理暫存檔案
if os.path.exists(filepath):
os.unlink(filepath)
raise e
except Exception as e:
logger.error(f"Excel upload error: {str(e)}")
return jsonify({'error': f'檔案處理失敗: {str(e)}'}), 500
@excel_bp.route('/import', methods=['POST'])
@jwt_required()
def import_todos():
"""Import todos from parsed Excel data"""
try:
identity = get_jwt_identity()
claims = get_jwt()
data = request.get_json()
todos_data = data.get('todos', [])
if not todos_data:
return jsonify({'error': '沒有要匯入的資料'}), 400
imported_count = 0
errors = []
for todo_data in todos_data:
try:
# 驗證負責人和追蹤人的 AD 帳號
responsible_users = todo_data.get('responsible_users', [])
followers = todo_data.get('followers', [])
if responsible_users:
valid_responsible = validate_ad_accounts(responsible_users)
invalid_responsible = set(responsible_users) - set(valid_responsible.keys())
if invalid_responsible:
errors.append({
'row': todo_data.get('row', '?'),
'error': f'無效的負責人帳號: {", ".join(invalid_responsible)}'
})
continue
if followers:
valid_followers = validate_ad_accounts(followers)
invalid_followers = set(followers) - set(valid_followers.keys())
if invalid_followers:
errors.append({
'row': todo_data.get('row', '?'),
'error': f'無效的追蹤人帳號: {", ".join(invalid_followers)}'
})
continue
# 建立待辦事項
due_date = None
if todo_data.get('due_date'):
due_date = datetime.strptime(todo_data['due_date'], '%Y-%m-%d').date()
todo = TodoItem(
id=str(uuid.uuid4()),
title=todo_data['title'],
description=todo_data.get('description', ''),
status=todo_data.get('status', 'NEW'),
priority=todo_data.get('priority', 'MEDIUM'),
due_date=due_date,
creator_ad=identity,
creator_display_name=claims.get('display_name', identity),
creator_email=claims.get('email', ''),
starred=False
)
db.session.add(todo)
# 新增負責人
if responsible_users:
for account in responsible_users:
responsible = TodoItemResponsible(
todo_id=todo.id,
ad_account=account,
added_by=identity
)
db.session.add(responsible)
# 新增追蹤人
if followers:
for account in followers:
follower = TodoItemFollower(
todo_id=todo.id,
ad_account=account,
added_by=identity
)
db.session.add(follower)
# 新增稽核記錄
audit = TodoAuditLog(
actor_ad=identity,
todo_id=todo.id,
action='CREATE',
detail={
'source': 'excel_import',
'title': todo.title,
'row': todo_data.get('row')
}
)
db.session.add(audit)
imported_count += 1
except Exception as e:
errors.append({
'row': todo_data.get('row', '?'),
'error': str(e)
})
db.session.commit()
logger.info(f"Excel import completed: {imported_count} todos imported by {identity}")
return jsonify({
'imported': imported_count,
'errors': errors,
'total_processed': len(todos_data)
}), 200
except Exception as e:
db.session.rollback()
logger.error(f"Excel import error: {str(e)}")
return jsonify({'error': '匯入失敗'}), 500
@excel_bp.route('/export', methods=['GET'])
@jwt_required()
def export_todos():
"""Export todos to Excel"""
try:
identity = get_jwt_identity()
# 篩選參數
status = request.args.get('status')
priority = request.args.get('priority')
due_from = request.args.get('due_from')
due_to = request.args.get('due_to')
view_type = request.args.get('view', 'all')
# 查詢待辦事項
query = TodoItem.query
# 套用檢視類型篩選
if view_type == 'created':
query = query.filter(TodoItem.creator_ad == identity)
elif view_type == 'responsible':
query = query.join(TodoItemResponsible).filter(
TodoItemResponsible.ad_account == identity
)
elif view_type == 'following':
query = query.join(TodoItemFollower).filter(
TodoItemFollower.ad_account == identity
)
else: # all
query = query.filter(
or_(
TodoItem.creator_ad == identity,
TodoItem.responsible_users.any(TodoItemResponsible.ad_account == identity),
TodoItem.followers.any(TodoItemFollower.ad_account == identity)
)
)
# 套用其他篩選條件
if status:
query = query.filter(TodoItem.status == status)
if priority:
query = query.filter(TodoItem.priority == priority)
if due_from:
query = query.filter(TodoItem.due_date >= datetime.strptime(due_from, '%Y-%m-%d').date())
if due_to:
query = query.filter(TodoItem.due_date <= datetime.strptime(due_to, '%Y-%m-%d').date())
todos = query.order_by(TodoItem.created_at.desc()).all()
# 準備資料
data = []
for todo in todos:
# 取得負責人和追蹤人
responsible_users = [r.ad_account for r in todo.responsible_users]
followers = [f.ad_account for f in todo.followers]
# 狀態和優先級的中文對應
status_mapping = {'NEW': '新建', 'IN_PROGRESS': '進行中', 'DONE': '完成'}
priority_mapping = {'HIGH': '', 'MEDIUM': '', 'LOW': ''}
data.append({
'編號': todo.id,
'標題': todo.title,
'描述': todo.description,
'狀態': status_mapping.get(todo.status, todo.status),
'優先級': priority_mapping.get(todo.priority, todo.priority),
'到期日': todo.due_date.strftime('%Y-%m-%d') if todo.due_date else '',
'建立者': todo.creator_ad,
'建立時間': todo.created_at.strftime('%Y-%m-%d %H:%M:%S'),
'完成時間': todo.completed_at.strftime('%Y-%m-%d %H:%M:%S') if todo.completed_at else '',
'負責人': '; '.join(responsible_users),
'追蹤人': '; '.join(followers),
'星號標記': '' if todo.starred else ''
})
# 建立 Excel 檔案
df = pd.DataFrame(data)
# 建立暫存檔案
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.xlsx')
temp_filename = temp_file.name
temp_file.close()
# 使用 openpyxl 建立更美觀的 Excel
wb = Workbook()
ws = wb.active
ws.title = "待辦清單"
# 標題樣式
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
header_alignment = Alignment(horizontal="center", vertical="center")
# 寫入標題
if not df.empty:
for r_idx, row in enumerate(dataframe_to_rows(df, index=False, header=True), 1):
for c_idx, value in enumerate(row, 1):
cell = ws.cell(row=r_idx, column=c_idx, value=value)
if r_idx == 1: # 標題行
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
# 自動調整列寬
for column in ws.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
adjusted_width = min(max_length + 2, 50)
ws.column_dimensions[column_letter].width = adjusted_width
wb.save(temp_filename)
# 產生檔案名稱
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"todos_{timestamp}.xlsx"
logger.info(f"Excel export: {len(todos)} todos exported by {identity}")
return send_file(
temp_filename,
as_attachment=True,
download_name=filename,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
except Exception as e:
logger.error(f"Excel export error: {str(e)}")
return jsonify({'error': '匯出失敗'}), 500
@excel_bp.route('/template', methods=['GET'])
@jwt_required()
def download_template():
"""Download Excel import template"""
try:
# 建立範本資料
template_data = {
'標題': ['範例待辦事項1', '範例待辦事項2'],
'描述': ['這是第一個範例的詳細描述', '這是第二個範例的詳細描述'],
'狀態': ['新建', '進行中'],
'優先級': ['', ''],
'到期日': ['2024-12-31', '2025-01-15'],
'負責人': ['user1@panjit.com.tw', 'user2@panjit.com.tw'],
'追蹤人': ['user3@panjit.com.tw;user4@panjit.com.tw', 'user5@panjit.com.tw']
}
# 說明資料
instructions = {
'欄位說明': [
'標題 (必填)',
'描述 (選填)',
'狀態: 新建/進行中/完成',
'優先級: 高/中/低',
'到期日: YYYY-MM-DD 格式',
'負責人: AD帳號多人用分號分隔',
'追蹤人: AD帳號多人用分號分隔'
],
'說明': [
'請填入待辦事項的標題',
'可選填詳細描述',
'可選填 NEW/IN_PROGRESS/DONE',
'可選填 HIGH/MEDIUM/LOW',
'例如: 2024-12-31',
'例如: john@panjit.com.tw',
'例如: mary@panjit.com.tw;tom@panjit.com.tw'
]
}
# 建立暫存檔案
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.xlsx')
temp_filename = temp_file.name
temp_file.close()
# 建立 Excel 檔案
wb = Workbook()
# 範本資料工作表
ws_data = wb.active
ws_data.title = "匯入範本"
df_template = pd.DataFrame(template_data)
for r_idx, row in enumerate(dataframe_to_rows(df_template, index=False, header=True), 1):
for c_idx, value in enumerate(row, 1):
ws_data.cell(row=r_idx, column=c_idx, value=value)
# 說明工作表
ws_help = wb.create_sheet("使用說明")
df_help = pd.DataFrame(instructions)
for r_idx, row in enumerate(dataframe_to_rows(df_help, index=False, header=True), 1):
for c_idx, value in enumerate(row, 1):
ws_help.cell(row=r_idx, column=c_idx, value=value)
# 樣式設定
for ws in [ws_data, ws_help]:
for column in ws.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
adjusted_width = min(max_length + 2, 50)
ws.column_dimensions[column_letter].width = adjusted_width
wb.save(temp_filename)
logger.info(f"Template downloaded by {get_jwt_identity()}")
return send_file(
temp_filename,
as_attachment=True,
download_name="todo_import_template.xlsx",
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
except Exception as e:
logger.error(f"Template download error: {str(e)}")
return jsonify({'error': '範本下載失敗'}), 500

125
backend/routes/health.py Normal file
View File

@@ -0,0 +1,125 @@
from flask import Blueprint, jsonify, current_app
from datetime import datetime
from models import db
from utils.logger import get_logger
import smtplib
import redis
health_bp = Blueprint('health', __name__)
logger = get_logger(__name__)
@health_bp.route('/healthz', methods=['GET'])
def health_check():
"""Basic health check"""
return jsonify({
'status': 'healthy',
'timestamp': datetime.utcnow().isoformat()
}), 200
@health_bp.route('/readiness', methods=['GET'])
def readiness_check():
"""Detailed readiness check"""
try:
checks = {
'database': False,
'ldap': False,
'smtp': False,
'redis': False
}
errors = []
# Check database
try:
db.session.execute(db.text('SELECT 1'))
checks['database'] = True
except Exception as e:
errors.append(f"Database check failed: {str(e)}")
logger.error(f"Database health check failed: {str(e)}")
# Check LDAP
try:
if current_app.config.get('USE_MOCK_LDAP', False):
from utils.mock_ldap import test_ldap_connection
else:
from utils.ldap_utils import test_ldap_connection
if test_ldap_connection():
checks['ldap'] = True
else:
errors.append("LDAP connection failed")
except Exception as e:
errors.append(f"LDAP check failed: {str(e)}")
logger.error(f"LDAP health check failed: {str(e)}")
# Check SMTP
try:
from flask import current_app
config = current_app.config
if config['SMTP_USE_SSL']:
server = smtplib.SMTP_SSL(config['SMTP_SERVER'], config['SMTP_PORT'], timeout=5)
else:
server = smtplib.SMTP(config['SMTP_SERVER'], config['SMTP_PORT'], timeout=5)
if config['SMTP_USE_TLS']:
server.starttls()
server.quit()
checks['smtp'] = True
except Exception as e:
errors.append(f"SMTP check failed: {str(e)}")
logger.error(f"SMTP health check failed: {str(e)}")
# Check Redis
try:
from flask import current_app
r = redis.from_url(current_app.config['REDIS_URL'])
r.ping()
checks['redis'] = True
except Exception as e:
errors.append(f"Redis check failed: {str(e)}")
logger.error(f"Redis health check failed: {str(e)}")
# Determine overall status
all_healthy = all(checks.values())
critical_healthy = checks['database'] # Database is critical
if all_healthy:
status_code = 200
status = 'healthy'
elif critical_healthy:
status_code = 200
status = 'degraded'
else:
status_code = 503
status = 'unhealthy'
return jsonify({
'status': status,
'checks': checks,
'errors': errors,
'timestamp': datetime.utcnow().isoformat()
}), status_code
except Exception as e:
logger.error(f"Readiness check error: {str(e)}")
return jsonify({
'status': 'error',
'error': str(e),
'timestamp': datetime.utcnow().isoformat()
}), 503
@health_bp.route('/liveness', methods=['GET'])
def liveness_check():
"""Kubernetes liveness probe"""
try:
# Simple check to see if the app is running
return jsonify({
'status': 'alive',
'timestamp': datetime.utcnow().isoformat()
}), 200
except Exception as e:
logger.error(f"Liveness check failed: {str(e)}")
return jsonify({
'status': 'dead',
'error': str(e)
}), 503

View File

@@ -0,0 +1,584 @@
"""
Notifications API Routes
處理通知相關功能,包括 email 通知和系統通知
"""
from flask import Blueprint, request, jsonify, current_app
from flask_jwt_extended import jwt_required, get_jwt_identity
from datetime import datetime, date, timedelta
from sqlalchemy import and_, or_
from models import (
db, TodoItem, TodoItemResponsible, TodoItemFollower,
TodoUserPref, TodoAuditLog, TodoFireEmailLog
)
from utils.logger import get_logger
from utils.email_service import EmailService
from utils.notification_service import NotificationService
import json
notifications_bp = Blueprint('notifications', __name__)
logger = get_logger(__name__)
@notifications_bp.route('/', methods=['GET'])
@jwt_required()
def get_notifications():
"""Get user notifications"""
try:
identity = get_jwt_identity()
# 獲取最近7天的相關通知 (指派、完成、逾期等)
seven_days_ago = datetime.utcnow() - timedelta(days=7)
notifications = []
# 1. 獲取被指派的Todo (最近7天)
assigned_todos = db.session.query(TodoItem).join(TodoItemResponsible).filter(
and_(
TodoItemResponsible.ad_account == identity,
TodoItemResponsible.added_at >= seven_days_ago,
TodoItemResponsible.added_by != identity # 不是自己指派給自己
)
).all()
logger.info(f"Found {len(assigned_todos)} assigned todos for user {identity}")
for todo in assigned_todos:
responsible = next((r for r in todo.responsible_users if r.ad_account == identity), None)
if responsible and responsible.added_by:
notifications.append({
'id': f"assign_{todo.id}_{int(responsible.added_at.timestamp())}",
'type': 'assignment',
'title': '新的待辦事項指派',
'message': f'{responsible.added_by} 指派了「{todo.title}」給您',
'time': responsible.added_at.strftime('%m/%d %H:%M'),
'read': False,
'actionable': True,
'todo_id': todo.id
})
# 2. 獲取即將到期的Todo (明後天)
tomorrow = date.today() + timedelta(days=1)
day_after_tomorrow = date.today() + timedelta(days=2)
due_soon_todos = db.session.query(TodoItem).filter(
and_(
or_(
TodoItem.creator_ad == identity,
TodoItem.responsible_users.any(TodoItemResponsible.ad_account == identity)
),
TodoItem.due_date.in_([tomorrow, day_after_tomorrow]),
TodoItem.status != 'DONE'
)
).all()
for todo in due_soon_todos:
days_until_due = (todo.due_date - date.today()).days
notifications.append({
'id': f"due_{todo.id}_{todo.due_date}",
'type': 'reminder',
'title': '待辦事項即將到期',
'message': f'{todo.title}」將在{days_until_due}天後到期',
'time': f'{todo.due_date.strftime("%m/%d")} 到期',
'read': False,
'actionable': True,
'todo_id': todo.id
})
# 3. 獲取逾期的Todo
overdue_todos = db.session.query(TodoItem).filter(
and_(
or_(
TodoItem.creator_ad == identity,
TodoItem.responsible_users.any(TodoItemResponsible.ad_account == identity)
),
TodoItem.due_date < date.today(),
TodoItem.status != 'DONE'
)
).all()
for todo in overdue_todos:
days_overdue = (date.today() - todo.due_date).days
notifications.append({
'id': f"overdue_{todo.id}_{todo.due_date}",
'type': 'overdue',
'title': '待辦事項已逾期',
'message': f'{todo.title}」已逾期{days_overdue}',
'time': f'逾期 {days_overdue}',
'read': False,
'actionable': True,
'todo_id': todo.id
})
# 按時間排序 (最新在前)
notifications.sort(key=lambda x: x['time'], reverse=True)
return jsonify({
'notifications': notifications,
'unread_count': len(notifications)
}), 200
except Exception as e:
logger.error(f"Error fetching notifications: {str(e)}")
return jsonify({'error': '獲取通知失敗'}), 500
@notifications_bp.route('/fire-email', methods=['POST'])
@jwt_required()
def send_fire_email():
"""Send urgent fire email notification"""
try:
identity = get_jwt_identity()
data = request.get_json()
todo_id = data.get('todo_id')
custom_message = data.get('message', '')
if not todo_id:
return jsonify({'error': '待辦事項ID不能為空'}), 400
# 檢查待辦事項
todo = TodoItem.query.filter_by(id=todo_id).first()
if not todo:
return jsonify({'error': '找不到待辦事項'}), 404
# 檢查權限 (只有建立者或負責人可以發送 fire email)
if not (todo.creator_ad == identity or
any(r.ad_account == identity for r in todo.responsible_users)):
return jsonify({'error': '沒有權限發送緊急通知'}), 403
# 檢查用戶 fire email 配額
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
if not user_pref:
return jsonify({'error': '找不到使用者設定'}), 404
# 檢查今日配額
today = date.today()
if user_pref.fire_email_last_reset != today:
user_pref.fire_email_today_count = 0
user_pref.fire_email_last_reset = today
daily_limit = current_app.config.get('FIRE_EMAIL_DAILY_LIMIT', 3)
if user_pref.fire_email_today_count >= daily_limit:
return jsonify({
'error': f'今日緊急通知配額已用完 ({daily_limit}次)',
'quota_exceeded': True
}), 429
# 檢查2分鐘冷卻機制
cooldown_minutes = current_app.config.get('FIRE_EMAIL_COOLDOWN_MINUTES', 2)
last_fire_log = TodoFireEmailLog.query.filter_by(
todo_id=todo_id
).order_by(TodoFireEmailLog.sent_at.desc()).first()
if last_fire_log:
time_since_last = datetime.utcnow() - last_fire_log.sent_at
if time_since_last.total_seconds() < cooldown_minutes * 60:
remaining_seconds = int(cooldown_minutes * 60 - time_since_last.total_seconds())
return jsonify({
'error': f'此待辦事項的緊急通知需要冷卻 {remaining_seconds} 秒後才能再次發送',
'cooldown_remaining': remaining_seconds
}), 429
# 準備收件人清單
recipients = set()
# 加入所有負責人
for responsible in todo.responsible_users:
recipients.add(responsible.ad_account)
# 加入所有追蹤人
for follower in todo.followers:
recipients.add(follower.ad_account)
# 如果是建立者發送,不包含自己
recipients.discard(identity)
if not recipients:
# 檢查是否只有發送者自己是相關人員
all_related_users = set()
for responsible in todo.responsible_users:
all_related_users.add(responsible.ad_account)
for follower in todo.followers:
all_related_users.add(follower.ad_account)
if len(all_related_users) == 1 and identity in all_related_users:
return jsonify({'error': '無法發送緊急通知:您是此待辦事項的唯一相關人員,請先指派其他負責人或追蹤人'}), 400
else:
return jsonify({'error': '沒有找到收件人'}), 400
# 發送郵件
email_service = EmailService()
success_count = 0
failed_recipients = []
for recipient in recipients:
try:
# 檢查收件人是否啟用郵件通知
recipient_pref = TodoUserPref.query.filter_by(ad_account=recipient).first()
if recipient_pref and not recipient_pref.email_reminder_enabled:
continue
success = email_service.send_fire_email(
todo=todo,
recipient=recipient,
sender=identity,
custom_message=custom_message
)
if success:
success_count += 1
else:
failed_recipients.append(recipient)
except Exception as e:
logger.error(f"Failed to send fire email to {recipient}: {str(e)}")
failed_recipients.append(recipient)
# 更新配額
user_pref.fire_email_today_count += 1
# 記錄 Fire Email 發送日誌 (用於冷卻檢查)
if success_count > 0:
fire_log = TodoFireEmailLog(
todo_id=todo_id,
sender_ad=identity
)
db.session.add(fire_log)
# 記錄稽核日誌
audit = TodoAuditLog(
actor_ad=identity,
todo_id=todo_id,
action='FIRE_EMAIL',
detail={
'recipients_count': len(recipients),
'success_count': success_count,
'failed_count': len(failed_recipients),
'custom_message': custom_message[:100] if custom_message else None
}
)
db.session.add(audit)
db.session.commit()
logger.info(f"Fire email sent by {identity} for todo {todo_id}: {success_count}/{len(recipients)} successful")
return jsonify({
'sent': success_count,
'total_recipients': len(recipients),
'failed_recipients': failed_recipients,
'remaining_quota': max(0, daily_limit - user_pref.fire_email_today_count)
}), 200
except Exception as e:
db.session.rollback()
logger.error(f"Fire email error: {str(e)}")
return jsonify({'error': '發送緊急通知失敗'}), 500
@notifications_bp.route('/digest', methods=['POST'])
@jwt_required()
def send_digest():
"""Send digest email to user"""
try:
identity = get_jwt_identity()
data = request.get_json()
digest_type = data.get('type', 'weekly') # daily, weekly, monthly
# 檢查使用者偏好
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
if not user_pref or not user_pref.email_reminder_enabled:
return jsonify({'error': '郵件通知未啟用'}), 400
# 準備摘要資料
notification_service = NotificationService()
digest_data = notification_service.prepare_digest(identity, digest_type)
# 發送摘要郵件
email_service = EmailService()
success = email_service.send_digest_email(identity, digest_data)
if success:
# 記錄稽核日誌
audit = TodoAuditLog(
actor_ad=identity,
todo_id=None,
action='DIGEST_EMAIL',
detail={'type': digest_type}
)
db.session.add(audit)
db.session.commit()
logger.info(f"Digest email sent to {identity}: {digest_type}")
return jsonify({'message': '摘要郵件已發送'}), 200
else:
return jsonify({'error': '摘要郵件發送失敗'}), 500
except Exception as e:
logger.error(f"Digest email error: {str(e)}")
return jsonify({'error': '摘要郵件發送失敗'}), 500
@notifications_bp.route('/reminders/send', methods=['POST'])
@jwt_required()
def send_reminders():
"""Send reminder emails for due/overdue todos"""
try:
identity = get_jwt_identity()
# 管理員權限檢查 (簡化版本,實際應該檢查 AD 群組)
# TODO: 實作適當的管理員權限檢查
# 查找需要提醒的待辦事項
today = date.today()
tomorrow = today + timedelta(days=1)
# 即將到期的待辦事項 (明天到期)
due_tomorrow = db.session.query(TodoItem).filter(
and_(
TodoItem.due_date == tomorrow,
TodoItem.status != 'DONE'
)
).all()
# 已逾期的待辦事項
overdue = db.session.query(TodoItem).filter(
and_(
TodoItem.due_date < today,
TodoItem.status != 'DONE'
)
).all()
email_service = EmailService()
notification_service = NotificationService()
sent_count = 0
# 處理即將到期的提醒
for todo in due_tomorrow:
recipients = notification_service.get_notification_recipients(todo)
for recipient in recipients:
try:
if email_service.send_reminder_email(todo, recipient, 'due_tomorrow'):
sent_count += 1
except Exception as e:
logger.error(f"Failed to send due tomorrow reminder to {recipient}: {str(e)}")
# 處理逾期提醒
for todo in overdue:
recipients = notification_service.get_notification_recipients(todo)
for recipient in recipients:
try:
if email_service.send_reminder_email(todo, recipient, 'overdue'):
sent_count += 1
except Exception as e:
logger.error(f"Failed to send overdue reminder to {recipient}: {str(e)}")
# 記錄稽核日誌
audit = TodoAuditLog(
actor_ad=identity,
todo_id=None,
action='BULK_REMINDER',
detail={
'due_tomorrow_count': len(due_tomorrow),
'overdue_count': len(overdue),
'emails_sent': sent_count
}
)
db.session.add(audit)
db.session.commit()
logger.info(f"Reminders sent by {identity}: {sent_count} emails sent")
return jsonify({
'emails_sent': sent_count,
'due_tomorrow': len(due_tomorrow),
'overdue': len(overdue)
}), 200
except Exception as e:
logger.error(f"Bulk reminder error: {str(e)}")
return jsonify({'error': '批量提醒發送失敗'}), 500
@notifications_bp.route('/settings', methods=['GET'])
@jwt_required()
def get_notification_settings():
"""Get user notification settings"""
try:
identity = get_jwt_identity()
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
if not user_pref:
return jsonify({'error': '找不到使用者設定'}), 404
settings = {
'email_reminder_enabled': user_pref.email_reminder_enabled,
'notification_enabled': user_pref.notification_enabled,
'weekly_summary_enabled': user_pref.weekly_summary_enabled,
'monthly_summary_enabled': getattr(user_pref, 'monthly_summary_enabled', False),
'reminder_days_before': getattr(user_pref, 'reminder_days_before', [1, 3]),
'daily_summary_time': getattr(user_pref, 'daily_summary_time', '09:00'),
'weekly_summary_time': getattr(user_pref, 'weekly_summary_time', '09:00'),
'monthly_summary_time': getattr(user_pref, 'monthly_summary_time', '09:00'),
'weekly_summary_day': getattr(user_pref, 'weekly_summary_day', 1),
'monthly_summary_day': getattr(user_pref, 'monthly_summary_day', 1),
'fire_email_quota': {
'used_today': user_pref.fire_email_today_count,
'daily_limit': current_app.config.get('FIRE_EMAIL_DAILY_LIMIT', 3),
'last_reset': user_pref.fire_email_last_reset.isoformat() if user_pref.fire_email_last_reset else None
}
}
return jsonify(settings), 200
except Exception as e:
logger.error(f"Error fetching notification settings: {str(e)}")
return jsonify({'error': '取得通知設定失敗'}), 500
@notifications_bp.route('/settings', methods=['PATCH'])
@jwt_required()
def update_notification_settings():
"""Update user notification settings"""
try:
identity = get_jwt_identity()
data = request.get_json()
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
if not user_pref:
return jsonify({'error': '找不到使用者設定'}), 404
# 更新允許的欄位
if 'email_reminder_enabled' in data:
user_pref.email_reminder_enabled = bool(data['email_reminder_enabled'])
if 'notification_enabled' in data:
user_pref.notification_enabled = bool(data['notification_enabled'])
if 'weekly_summary_enabled' in data:
user_pref.weekly_summary_enabled = bool(data['weekly_summary_enabled'])
if 'monthly_summary_enabled' in data:
user_pref.monthly_summary_enabled = bool(data['monthly_summary_enabled'])
if 'reminder_days_before' in data and isinstance(data['reminder_days_before'], list):
user_pref.reminder_days_before = data['reminder_days_before']
if 'weekly_summary_time' in data:
user_pref.weekly_summary_time = str(data['weekly_summary_time'])
if 'monthly_summary_time' in data:
user_pref.monthly_summary_time = str(data['monthly_summary_time'])
if 'weekly_summary_day' in data:
user_pref.weekly_summary_day = int(data['weekly_summary_day'])
if 'monthly_summary_day' in data:
user_pref.monthly_summary_day = int(data['monthly_summary_day'])
user_pref.updated_at = datetime.utcnow()
db.session.commit()
logger.info(f"Notification settings updated for {identity}")
return jsonify({'message': '通知設定已更新'}), 200
except Exception as e:
db.session.rollback()
logger.error(f"Error updating notification settings: {str(e)}")
return jsonify({'error': '更新通知設定失敗'}), 500
@notifications_bp.route('/test', methods=['POST'])
@jwt_required()
def test_notification():
"""Send test notification email"""
try:
identity = get_jwt_identity()
data = request.get_json() or {}
# 檢查是否有直接指定的郵件地址
recipient_email = data.get('recipient_email')
email_service = EmailService()
if recipient_email:
# 直接發送到指定郵件地址
success = email_service.send_test_email_direct(recipient_email)
recipient_info = recipient_email
else:
# 使用 AD 帳號查詢
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
if not user_pref:
return jsonify({'error': '找不到使用者設定'}), 404
success = email_service.send_test_email(identity)
recipient_info = identity
if success:
# 記錄稽核日誌
audit = TodoAuditLog(
actor_ad=identity,
todo_id=None,
action='MAIL_SENT',
detail={'recipient': recipient_info, 'type': 'test_email'}
)
db.session.add(audit)
db.session.commit()
logger.info(f"Test email sent to {recipient_info}")
return jsonify({'message': '測試郵件已發送'}), 200
else:
return jsonify({'error': '測試郵件發送失敗'}), 500
except Exception as e:
logger.error(f"Test email error: {str(e)}")
return jsonify({'error': '測試郵件發送失敗'}), 500
@notifications_bp.route('/mark-read', methods=['POST'])
@jwt_required()
def mark_notification_read():
"""Mark single notification as read"""
try:
identity = get_jwt_identity()
data = request.get_json()
notification_id = data.get('notification_id')
if not notification_id:
return jsonify({'error': '通知ID不能為空'}), 400
# 這裡可以實作將已讀狀態存在 Redis 或 database 中
# 暫時返回成功,實際可以儲存在用戶的已讀列表中
logger.info(f"Marked notification {notification_id} as read for user {identity}")
return jsonify({'message': '已標記為已讀'}), 200
except Exception as e:
logger.error(f"Mark notification read error: {str(e)}")
return jsonify({'error': '標記已讀失敗'}), 500
@notifications_bp.route('/mark-all-read', methods=['POST'])
@jwt_required()
def mark_all_notifications_read():
"""Mark all notifications as read"""
try:
identity = get_jwt_identity()
# 這裡可以實作將所有通知標記為已讀
# 暫時返回成功
logger.info(f"Marked all notifications as read for user {identity}")
return jsonify({'message': '已將所有通知標記為已讀'}), 200
except Exception as e:
logger.error(f"Mark all notifications read error: {str(e)}")
return jsonify({'error': '標記全部已讀失敗'}), 500
@notifications_bp.route('/view-todo/<todo_id>', methods=['GET'])
@jwt_required()
def view_todo_from_notification():
"""Get todo details from notification click"""
try:
identity = get_jwt_identity()
# 這裡暫時返回成功,前端可以導航到對應的 todo
return jsonify({'message': '導航到待辦事項'}), 200
except Exception as e:
logger.error(f"View todo from notification error: {str(e)}")
return jsonify({'error': '查看待辦事項失敗'}), 500

372
backend/routes/reports.py Normal file
View File

@@ -0,0 +1,372 @@
"""
Reports API Routes
提供待辦清單的統計報表和分析
"""
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
from datetime import datetime, date, timedelta
from sqlalchemy import func, and_, or_
from models import (
db, TodoItem, TodoItemResponsible, TodoItemFollower,
TodoAuditLog, TodoUserPref
)
from utils.logger import get_logger
import calendar
reports_bp = Blueprint('reports', __name__)
logger = get_logger(__name__)
@reports_bp.route('/summary', methods=['GET'])
@jwt_required()
def get_summary():
"""Get user's todo summary"""
try:
identity = get_jwt_identity()
# Count todos by status for current user
query = TodoItem.query.filter(
or_(
TodoItem.creator_ad == identity,
TodoItem.responsible_users.any(TodoItemResponsible.ad_account == identity),
TodoItem.followers.any(TodoItemFollower.ad_account == identity)
)
)
total = query.count()
completed = query.filter(TodoItem.status == 'DONE').count()
in_progress = query.filter(TodoItem.status == 'IN_PROGRESS').count()
new = query.filter(TodoItem.status == 'NEW').count()
# Overdue todos
today = date.today()
overdue = query.filter(
and_(
TodoItem.due_date < today,
TodoItem.status != 'DONE'
)
).count()
# Due today
due_today = query.filter(
and_(
TodoItem.due_date == today,
TodoItem.status != 'DONE'
)
).count()
# Due this week
week_end = today + timedelta(days=7)
due_this_week = query.filter(
and_(
TodoItem.due_date.between(today, week_end),
TodoItem.status != 'DONE'
)
).count()
# Priority distribution
high_priority = query.filter(TodoItem.priority == 'HIGH').count()
medium_priority = query.filter(TodoItem.priority == 'MEDIUM').count()
low_priority = query.filter(TodoItem.priority == 'LOW').count()
# Completion rate
completion_rate = (completed / total * 100) if total > 0 else 0
return jsonify({
'summary': {
'total': total,
'completed': completed,
'in_progress': in_progress,
'new': new,
'overdue': overdue,
'due_today': due_today,
'due_this_week': due_this_week,
'completion_rate': round(completion_rate, 1)
},
'priority_distribution': {
'high': high_priority,
'medium': medium_priority,
'low': low_priority
}
}), 200
except Exception as e:
logger.error(f"Error fetching summary: {str(e)}")
return jsonify({'error': 'Failed to fetch summary'}), 500
@reports_bp.route('/activity', methods=['GET'])
@jwt_required()
def get_activity():
"""Get user's activity over time"""
try:
identity = get_jwt_identity()
days = request.args.get('days', 30, type=int)
# Get date range
end_date = date.today()
start_date = end_date - timedelta(days=days-1)
# Query audit logs for the user
logs = db.session.query(
func.date(TodoAuditLog.timestamp).label('date'),
func.count(TodoAuditLog.id).label('count'),
TodoAuditLog.action
).filter(
and_(
TodoAuditLog.actor_ad == identity,
func.date(TodoAuditLog.timestamp) >= start_date
)
).group_by(
func.date(TodoAuditLog.timestamp),
TodoAuditLog.action
).all()
# Organize by date and action
activity_data = {}
for log in logs:
date_str = log.date.isoformat()
if date_str not in activity_data:
activity_data[date_str] = {'CREATE': 0, 'UPDATE': 0, 'DELETE': 0}
activity_data[date_str][log.action] = log.count
# Fill in missing dates
current_date = start_date
while current_date <= end_date:
date_str = current_date.isoformat()
if date_str not in activity_data:
activity_data[date_str] = {'CREATE': 0, 'UPDATE': 0, 'DELETE': 0}
current_date += timedelta(days=1)
return jsonify({
'activity': activity_data,
'period': {
'start_date': start_date.isoformat(),
'end_date': end_date.isoformat(),
'days': days
}
}), 200
except Exception as e:
logger.error(f"Error fetching activity: {str(e)}")
return jsonify({'error': 'Failed to fetch activity'}), 500
@reports_bp.route('/productivity', methods=['GET'])
@jwt_required()
def get_productivity():
"""Get productivity metrics"""
try:
identity = get_jwt_identity()
# Get date ranges
today = date.today()
week_start = today - timedelta(days=today.weekday())
month_start = today.replace(day=1)
# Base query for user's todos
base_query = TodoItem.query.filter(
or_(
TodoItem.creator_ad == identity,
TodoItem.responsible_users.any(TodoItemResponsible.ad_account == identity)
)
)
# Today's completions
today_completed = base_query.filter(
and_(
func.date(TodoItem.completed_at) == today,
TodoItem.status == 'DONE'
)
).count()
# This week's completions
week_completed = base_query.filter(
and_(
func.date(TodoItem.completed_at) >= week_start,
TodoItem.status == 'DONE'
)
).count()
# This month's completions
month_completed = base_query.filter(
and_(
func.date(TodoItem.completed_at) >= month_start,
TodoItem.status == 'DONE'
)
).count()
# Average completion time (for completed todos)
completed_todos = base_query.filter(
and_(
TodoItem.status == 'DONE',
TodoItem.completed_at.isnot(None)
)
).all()
avg_completion_days = 0
if completed_todos:
total_days = 0
count = 0
for todo in completed_todos:
if todo.completed_at and todo.created_at:
days = (todo.completed_at.date() - todo.created_at.date()).days
total_days += days
count += 1
avg_completion_days = round(total_days / count, 1) if count > 0 else 0
# On-time completion rate (within due date)
on_time_todos = base_query.filter(
and_(
TodoItem.status == 'DONE',
TodoItem.due_date.isnot(None),
TodoItem.completed_at.isnot(None),
func.date(TodoItem.completed_at) <= TodoItem.due_date
)
).count()
total_due_todos = base_query.filter(
and_(
TodoItem.status == 'DONE',
TodoItem.due_date.isnot(None)
)
).count()
on_time_rate = (on_time_todos / total_due_todos * 100) if total_due_todos > 0 else 0
return jsonify({
'productivity': {
'today_completed': today_completed,
'week_completed': week_completed,
'month_completed': month_completed,
'avg_completion_days': avg_completion_days,
'on_time_rate': round(on_time_rate, 1),
'total_with_due_dates': total_due_todos,
'on_time_count': on_time_todos
}
}), 200
except Exception as e:
logger.error(f"Error fetching productivity: {str(e)}")
return jsonify({'error': 'Failed to fetch productivity metrics'}), 500
@reports_bp.route('/team-overview', methods=['GET'])
@jwt_required()
def get_team_overview():
"""Get team overview for todos created by current user"""
try:
identity = get_jwt_identity()
# Get todos created by current user
created_todos = TodoItem.query.filter(TodoItem.creator_ad == identity)
# Get unique responsible users from these todos
responsible_stats = db.session.query(
TodoItemResponsible.ad_account,
func.count(TodoItem.id).label('total'),
func.sum(func.case([(TodoItem.status == 'DONE', 1)], else_=0)).label('completed'),
func.sum(func.case([(TodoItem.status == 'IN_PROGRESS', 1)], else_=0)).label('in_progress'),
func.sum(func.case([
(and_(TodoItem.due_date < date.today(), TodoItem.status != 'DONE'), 1)
], else_=0)).label('overdue')
).join(
TodoItem, TodoItemResponsible.todo_id == TodoItem.id
).filter(
TodoItem.creator_ad == identity
).group_by(
TodoItemResponsible.ad_account
).all()
team_stats = []
for stat in responsible_stats:
completion_rate = (stat.completed / stat.total * 100) if stat.total > 0 else 0
team_stats.append({
'ad_account': stat.ad_account,
'total_assigned': stat.total,
'completed': stat.completed,
'in_progress': stat.in_progress,
'overdue': stat.overdue,
'completion_rate': round(completion_rate, 1)
})
return jsonify({
'team_overview': team_stats,
'summary': {
'total_team_members': len(team_stats),
'total_assigned_todos': sum(stat['total_assigned'] for stat in team_stats),
'total_completed': sum(stat['completed'] for stat in team_stats),
'total_overdue': sum(stat['overdue'] for stat in team_stats)
}
}), 200
except Exception as e:
logger.error(f"Error fetching team overview: {str(e)}")
return jsonify({'error': 'Failed to fetch team overview'}), 500
@reports_bp.route('/monthly-trends', methods=['GET'])
@jwt_required()
def get_monthly_trends():
"""Get monthly trends for the past year"""
try:
identity = get_jwt_identity()
months = request.args.get('months', 12, type=int)
# Calculate date range
today = date.today()
start_date = today.replace(day=1) - timedelta(days=30 * (months - 1))
# Base query
base_query = TodoItem.query.filter(
or_(
TodoItem.creator_ad == identity,
TodoItem.responsible_users.any(TodoItemResponsible.ad_account == identity)
)
)
# Get monthly statistics
monthly_data = db.session.query(
func.year(TodoItem.created_at).label('year'),
func.month(TodoItem.created_at).label('month'),
func.count(TodoItem.id).label('created'),
func.sum(func.case([(TodoItem.status == 'DONE', 1)], else_=0)).label('completed')
).filter(
and_(
func.date(TodoItem.created_at) >= start_date,
or_(
TodoItem.creator_ad == identity,
TodoItem.responsible_users.any(TodoItemResponsible.ad_account == identity)
)
)
).group_by(
func.year(TodoItem.created_at),
func.month(TodoItem.created_at)
).order_by(
func.year(TodoItem.created_at),
func.month(TodoItem.created_at)
).all()
# Format the data
trends = []
for data in monthly_data:
month_name = calendar.month_name[data.month]
completion_rate = (data.completed / data.created * 100) if data.created > 0 else 0
trends.append({
'year': data.year,
'month': data.month,
'month_name': month_name,
'created': data.created,
'completed': data.completed,
'completion_rate': round(completion_rate, 1)
})
return jsonify({
'trends': trends,
'period': {
'months': months,
'start_date': start_date.isoformat(),
'end_date': today.isoformat()
}
}), 200
except Exception as e:
logger.error(f"Error fetching monthly trends: {str(e)}")
return jsonify({'error': 'Failed to fetch monthly trends'}), 500

261
backend/routes/scheduler.py Normal file
View File

@@ -0,0 +1,261 @@
"""
Scheduler API Routes
處理排程任務的管理和監控功能
"""
from flask import Blueprint, request, jsonify, current_app
from flask_jwt_extended import jwt_required, get_jwt_identity
from datetime import datetime, date, timedelta
from sqlalchemy import and_, or_
from models import (
db, TodoItem, TodoItemResponsible, TodoItemFollower,
TodoUserPref, TodoAuditLog
)
from utils.logger import get_logger
from utils.email_service import EmailService
from utils.notification_service import NotificationService
from tasks_simple import send_daily_reminders, send_weekly_summary, cleanup_old_logs
import json
scheduler_bp = Blueprint('scheduler', __name__)
logger = get_logger(__name__)
@scheduler_bp.route('/trigger-daily-reminders', methods=['POST'])
@jwt_required()
def trigger_daily_reminders():
"""手動觸發每日提醒(管理員功能)"""
try:
identity = get_jwt_identity()
# TODO: 實作管理員權限檢查
# 這裡應該檢查用戶是否為管理員
# 直接執行任務
result = send_daily_reminders()
# 記錄稽核日誌
audit = TodoAuditLog(
actor_ad=identity,
todo_id=None,
action='MANUAL_REMINDER',
detail={
'result': result,
'triggered_by': identity
}
)
db.session.add(audit)
db.session.commit()
logger.info(f"Daily reminders executed manually by {identity}")
return jsonify({
'message': '每日提醒任務已執行',
'result': result
}), 200
except Exception as e:
logger.error(f"Error triggering daily reminders: {str(e)}")
return jsonify({'error': '觸發每日提醒失敗'}), 500
@scheduler_bp.route('/trigger-weekly-summary', methods=['POST'])
@jwt_required()
def trigger_weekly_summary():
"""手動觸發週報發送(管理員功能)"""
try:
identity = get_jwt_identity()
# TODO: 實作管理員權限檢查
# 直接執行任務
result = send_weekly_summary()
# 記錄稽核日誌
audit = TodoAuditLog(
actor_ad=identity,
todo_id=None,
action='MANUAL_SUMMARY',
detail={
'result': result,
'triggered_by': identity
}
)
db.session.add(audit)
db.session.commit()
logger.info(f"Weekly summary executed manually by {identity}")
return jsonify({
'message': '週報發送任務已執行',
'result': result
}), 200
except Exception as e:
logger.error(f"Error triggering weekly summary: {str(e)}")
return jsonify({'error': '觸發週報發送失敗'}), 500
@scheduler_bp.route('/task-status/<task_id>', methods=['GET'])
@jwt_required()
def get_task_status(task_id):
"""取得任務狀態(簡化版本)"""
try:
# 在簡化版本中,任務是同步執行的,所以狀態總是 completed
return jsonify({
'task_id': task_id,
'status': 'completed',
'message': '任務已同步執行完成'
}), 200
except Exception as e:
logger.error(f"Error getting task status: {str(e)}")
return jsonify({'error': '取得任務狀態失敗'}), 500
@scheduler_bp.route('/scheduled-jobs', methods=['GET'])
@jwt_required()
def get_scheduled_jobs():
"""取得排程任務列表和狀態"""
try:
# 這裡可以返回 Celery Beat 的排程資訊
# 簡化版本,返回配置的排程任務
jobs = [
{
'name': 'daily-reminders',
'description': '每日提醒郵件',
'schedule': '每日早上9點',
'status': 'active',
'last_run': None # TODO: 從 Celery 取得實際執行時間
},
{
'name': 'weekly-summary',
'description': '每週摘要報告',
'schedule': '每週一早上9點',
'status': 'active',
'last_run': None # TODO: 從 Celery 取得實際執行時間
},
{
'name': 'cleanup-logs',
'description': '清理舊日誌',
'schedule': '每週執行一次',
'status': 'active',
'last_run': None # TODO: 從 Celery 取得實際執行時間
}
]
return jsonify({'jobs': jobs}), 200
except Exception as e:
logger.error(f"Error getting scheduled jobs: {str(e)}")
return jsonify({'error': '取得排程任務列表失敗'}), 500
@scheduler_bp.route('/statistics', methods=['GET'])
@jwt_required()
def get_scheduler_statistics():
"""取得排程系統統計資訊"""
try:
identity = get_jwt_identity()
# 統計最近一週的自動化任務執行記錄
week_ago = datetime.utcnow() - timedelta(days=7)
auto_tasks = TodoAuditLog.query.filter(
and_(
TodoAuditLog.actor_ad == 'system',
TodoAuditLog.created_at >= week_ago,
TodoAuditLog.action.in_(['DAILY_REMINDER', 'WEEKLY_SUMMARY'])
)
).all()
# 統計手動觸發的任務
manual_tasks = TodoAuditLog.query.filter(
and_(
TodoAuditLog.created_at >= week_ago,
TodoAuditLog.action.in_(['MANUAL_REMINDER', 'MANUAL_SUMMARY'])
)
).all()
# 統計郵件發送情況
email_stats = {}
for task in auto_tasks:
if task.detail:
task_type = task.action.lower()
if 'emails_sent' in task.detail:
if task_type not in email_stats:
email_stats[task_type] = {'count': 0, 'emails': 0}
email_stats[task_type]['count'] += 1
email_stats[task_type]['emails'] += task.detail['emails_sent']
statistics = {
'recent_activity': {
'auto_tasks_count': len(auto_tasks),
'manual_tasks_count': len(manual_tasks),
'email_stats': email_stats
},
'system_health': {
'celery_status': 'running', # TODO: 實際檢查 Celery 狀態
'redis_status': 'connected', # TODO: 實際檢查 Redis 狀態
'last_daily_reminder': None, # TODO: 從記錄中取得
'last_weekly_summary': None # TODO: 從記錄中取得
}
}
return jsonify(statistics), 200
except Exception as e:
logger.error(f"Error getting scheduler statistics: {str(e)}")
return jsonify({'error': '取得排程統計資訊失敗'}), 500
@scheduler_bp.route('/preview-reminders', methods=['GET'])
@jwt_required()
def preview_reminders():
"""預覽即將發送的提醒郵件"""
try:
today = date.today()
tomorrow = today + timedelta(days=1)
# 查找明日到期的待辦事項
due_tomorrow = db.session.query(TodoItem).filter(
and_(
TodoItem.due_date == tomorrow,
TodoItem.status != 'DONE'
)
).all()
# 查找已逾期的待辦事項
overdue = db.session.query(TodoItem).filter(
and_(
TodoItem.due_date < today,
TodoItem.status != 'DONE'
)
).all()
# 統計會收到提醒的使用者
notification_service = NotificationService()
due_tomorrow_recipients = set()
overdue_recipients = set()
for todo in due_tomorrow:
recipients = notification_service.get_notification_recipients(todo)
due_tomorrow_recipients.update(recipients)
for todo in overdue:
recipients = notification_service.get_notification_recipients(todo)
overdue_recipients.update(recipients)
preview = {
'due_tomorrow': {
'todos_count': len(due_tomorrow),
'recipients_count': len(due_tomorrow_recipients),
'todos': [todo.to_dict() for todo in due_tomorrow[:5]] # 只顯示前5個
},
'overdue': {
'todos_count': len(overdue),
'recipients_count': len(overdue_recipients),
'todos': [todo.to_dict() for todo in overdue[:5]] # 只顯示前5個
},
'total_emails': len(due_tomorrow_recipients) + len(overdue_recipients)
}
return jsonify(preview), 200
except Exception as e:
logger.error(f"Error previewing reminders: {str(e)}")
return jsonify({'error': '預覽提醒郵件失敗'}), 500

709
backend/routes/todos.py Normal file
View File

@@ -0,0 +1,709 @@
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
from datetime import datetime, date, timedelta
from sqlalchemy import or_, and_
from sqlalchemy.orm import selectinload, joinedload
from models import (
db, TodoItem, TodoItemResponsible, TodoItemFollower,
TodoAuditLog, TodoUserPref
)
from utils.logger import get_logger
from utils.ldap_utils import validate_ad_accounts
import uuid
todos_bp = Blueprint('todos', __name__)
logger = get_logger(__name__)
@todos_bp.route('', methods=['GET'])
@jwt_required()
def get_todos():
"""Get todos with filtering and pagination"""
try:
identity = get_jwt_identity()
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 20, type=int)
# Filters
status = request.args.get('status')
priority = request.args.get('priority')
starred = request.args.get('starred', type=bool)
due_from = request.args.get('due_from')
due_to = request.args.get('due_to')
search = request.args.get('search')
view_type = request.args.get('view', 'all') # all, created, responsible, following
# Base query with eager loading to prevent N+1 queries
query = TodoItem.query.options(
joinedload(TodoItem.responsible_users),
joinedload(TodoItem.followers)
)
# Apply view type filter
if view_type == 'created':
query = query.filter(TodoItem.creator_ad == identity)
elif view_type == 'responsible':
query = query.join(TodoItemResponsible).filter(
TodoItemResponsible.ad_account == identity
)
elif view_type == 'following':
query = query.join(TodoItemFollower).filter(
TodoItemFollower.ad_account == identity
)
else: # all
query = query.filter(
or_(
TodoItem.creator_ad == identity,
TodoItem.responsible_users.any(TodoItemResponsible.ad_account == identity),
TodoItem.followers.any(TodoItemFollower.ad_account == identity)
)
)
# Apply filters
if status:
query = query.filter(TodoItem.status == status)
if priority:
query = query.filter(TodoItem.priority == priority)
if starred is not None:
query = query.filter(TodoItem.starred == starred)
if due_from:
query = query.filter(TodoItem.due_date >= datetime.strptime(due_from, '%Y-%m-%d').date())
if due_to:
query = query.filter(TodoItem.due_date <= datetime.strptime(due_to, '%Y-%m-%d').date())
if search:
query = query.filter(
or_(
TodoItem.title.contains(search),
TodoItem.description.contains(search)
)
)
# Order by due date and priority (MySQL compatible)
query = query.order_by(
TodoItem.due_date.asc(),
TodoItem.priority.desc(),
TodoItem.created_at.desc()
)
# Paginate
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
todos = [todo.to_dict() for todo in pagination.items]
return jsonify({
'todos': todos,
'total': pagination.total,
'page': page,
'per_page': per_page,
'pages': pagination.pages
}), 200
except Exception as e:
logger.error(f"Error fetching todos: {str(e)}")
return jsonify({'error': 'Failed to fetch todos'}), 500
@todos_bp.route('/<todo_id>', methods=['GET'])
@jwt_required()
def get_todo(todo_id):
"""Get single todo details"""
try:
identity = get_jwt_identity()
todo = TodoItem.query.options(
joinedload(TodoItem.responsible_users),
joinedload(TodoItem.followers)
).filter_by(id=todo_id).first()
if not todo:
return jsonify({'error': 'Todo not found'}), 404
# Check permission
if not todo.can_view(identity):
return jsonify({'error': 'Access denied'}), 403
return jsonify(todo.to_dict()), 200
except Exception as e:
logger.error(f"Error fetching todo {todo_id}: {str(e)}")
return jsonify({'error': 'Failed to fetch todo'}), 500
@todos_bp.route('', methods=['POST'])
@jwt_required()
def create_todo():
"""Create new todo"""
try:
identity = get_jwt_identity()
claims = get_jwt()
data = request.get_json()
# Validate required fields
if not data.get('title'):
return jsonify({'error': 'Title is required'}), 400
# Parse due date if provided
due_date = None
if data.get('due_date'):
try:
due_date = datetime.strptime(data['due_date'], '%Y-%m-%d').date()
except ValueError:
return jsonify({'error': 'Invalid due date format. Use YYYY-MM-DD'}), 400
# Create todo
todo = TodoItem(
id=str(uuid.uuid4()),
title=data['title'],
description=data.get('description', ''),
status=data.get('status', 'NEW'),
priority=data.get('priority', 'MEDIUM'),
due_date=due_date,
creator_ad=identity,
creator_display_name=claims.get('display_name', identity),
creator_email=claims.get('email', ''),
starred=data.get('starred', False)
)
db.session.add(todo)
# Add responsible users
responsible_accounts = data.get('responsible_users', [])
if responsible_accounts:
valid_accounts = validate_ad_accounts(responsible_accounts)
for account in responsible_accounts:
if account in valid_accounts:
responsible = TodoItemResponsible(
todo_id=todo.id,
ad_account=account,
added_by=identity
)
db.session.add(responsible)
# Add followers
follower_accounts = data.get('followers', [])
if follower_accounts:
valid_accounts = validate_ad_accounts(follower_accounts)
for account in follower_accounts:
if account in valid_accounts:
follower = TodoItemFollower(
todo_id=todo.id,
ad_account=account,
added_by=identity
)
db.session.add(follower)
# Add audit log
audit = TodoAuditLog(
actor_ad=identity,
todo_id=todo.id,
action='CREATE',
detail={'title': todo.title, 'due_date': str(due_date) if due_date else None}
)
db.session.add(audit)
db.session.commit()
logger.info(f"Todo created: {todo.id} by {identity}")
return jsonify(todo.to_dict()), 201
except Exception as e:
db.session.rollback()
logger.error(f"Error creating todo: {str(e)}")
return jsonify({'error': 'Failed to create todo'}), 500
@todos_bp.route('/<todo_id>', methods=['PATCH'])
@jwt_required()
def update_todo(todo_id):
"""Update todo"""
try:
identity = get_jwt_identity()
data = request.get_json()
todo = TodoItem.query.filter_by(id=todo_id).first()
if not todo:
return jsonify({'error': 'Todo not found'}), 404
# Check permission
if not todo.can_edit(identity):
return jsonify({'error': 'Access denied'}), 403
# Track changes for audit
changes = {}
# Update fields
if 'title' in data:
changes['title'] = {'old': todo.title, 'new': data['title']}
todo.title = data['title']
if 'description' in data:
changes['description'] = {'old': todo.description, 'new': data['description']}
todo.description = data['description']
if 'status' in data:
changes['status'] = {'old': todo.status, 'new': data['status']}
todo.status = data['status']
# Set completed_at if status is DONE
if data['status'] == 'DONE' and not todo.completed_at:
todo.completed_at = datetime.utcnow()
elif data['status'] != 'DONE':
todo.completed_at = None
if 'priority' in data:
changes['priority'] = {'old': todo.priority, 'new': data['priority']}
todo.priority = data['priority']
if 'due_date' in data:
old_due = str(todo.due_date) if todo.due_date else None
if data['due_date']:
todo.due_date = datetime.strptime(data['due_date'], '%Y-%m-%d').date()
new_due = data['due_date']
else:
todo.due_date = None
new_due = None
changes['due_date'] = {'old': old_due, 'new': new_due}
if 'starred' in data:
changes['starred'] = {'old': todo.starred, 'new': data['starred']}
todo.starred = data['starred']
# Update responsible users
if 'responsible_users' in data:
# Remove existing
TodoItemResponsible.query.filter_by(todo_id=todo_id).delete()
# Add new
responsible_accounts = data['responsible_users']
if responsible_accounts:
valid_accounts = validate_ad_accounts(responsible_accounts)
for account in responsible_accounts:
if account in valid_accounts:
responsible = TodoItemResponsible(
todo_id=todo.id,
ad_account=account,
added_by=identity
)
db.session.add(responsible)
changes['responsible_users'] = data['responsible_users']
# Update followers
if 'followers' in data:
# Remove existing
TodoItemFollower.query.filter_by(todo_id=todo_id).delete()
# Add new
follower_accounts = data['followers']
if follower_accounts:
valid_accounts = validate_ad_accounts(follower_accounts)
for account in follower_accounts:
if account in valid_accounts:
follower = TodoItemFollower(
todo_id=todo.id,
ad_account=account,
added_by=identity
)
db.session.add(follower)
changes['followers'] = data['followers']
# Add audit log
if changes:
audit = TodoAuditLog(
actor_ad=identity,
todo_id=todo.id,
action='UPDATE',
detail=changes
)
db.session.add(audit)
db.session.commit()
logger.info(f"Todo updated: {todo_id} by {identity}")
return jsonify(todo.to_dict()), 200
except Exception as e:
db.session.rollback()
logger.error(f"Error updating todo {todo_id}: {str(e)}")
return jsonify({'error': 'Failed to update todo'}), 500
@todos_bp.route('/<todo_id>', methods=['DELETE'])
@jwt_required()
def delete_todo(todo_id):
"""Delete todo"""
try:
identity = get_jwt_identity()
todo = TodoItem.query.filter_by(id=todo_id).first()
if not todo:
return jsonify({'error': 'Todo not found'}), 404
# Only creator can delete
if todo.creator_ad != identity:
return jsonify({'error': 'Only creator can delete todo'}), 403
# Add audit log before deletion
audit = TodoAuditLog(
actor_ad=identity,
todo_id=None, # Will be null after deletion
action='DELETE',
detail={'title': todo.title, 'deleted_todo_id': todo_id}
)
db.session.add(audit)
# Delete todo (cascades will handle related records)
db.session.delete(todo)
db.session.commit()
logger.info(f"Todo deleted: {todo_id} by {identity}")
return jsonify({'message': 'Todo deleted successfully'}), 200
except Exception as e:
db.session.rollback()
logger.error(f"Error deleting todo {todo_id}: {str(e)}")
return jsonify({'error': 'Failed to delete todo'}), 500
@todos_bp.route('/batch', methods=['PATCH'])
@jwt_required()
def batch_update_todos():
"""Batch update multiple todos"""
try:
identity = get_jwt_identity()
data = request.get_json()
todo_ids = data.get('todo_ids', [])
updates = data.get('updates', {})
if not todo_ids or not updates:
return jsonify({'error': 'Todo IDs and updates required'}), 400
updated_count = 0
errors = []
for todo_id in todo_ids:
try:
todo = TodoItem.query.filter_by(id=todo_id).first()
if not todo:
errors.append({'todo_id': todo_id, 'error': 'Not found'})
continue
if not todo.can_edit(identity):
errors.append({'todo_id': todo_id, 'error': 'Access denied'})
continue
# Apply updates
if 'status' in updates:
todo.status = updates['status']
if updates['status'] == 'DONE':
todo.completed_at = datetime.utcnow()
else:
todo.completed_at = None
if 'priority' in updates:
todo.priority = updates['priority']
if 'due_date' in updates:
if updates['due_date']:
todo.due_date = datetime.strptime(updates['due_date'], '%Y-%m-%d').date()
else:
todo.due_date = None
# Add audit log
audit = TodoAuditLog(
actor_ad=identity,
todo_id=todo.id,
action='UPDATE',
detail={'batch_update': updates}
)
db.session.add(audit)
updated_count += 1
except Exception as e:
errors.append({'todo_id': todo_id, 'error': str(e)})
db.session.commit()
logger.info(f"Batch update: {updated_count} todos updated by {identity}")
return jsonify({
'updated': updated_count,
'errors': errors
}), 200
except Exception as e:
db.session.rollback()
logger.error(f"Error in batch update: {str(e)}")
return jsonify({'error': 'Batch update failed'}), 500
@todos_bp.route('/<todo_id>/responsible', methods=['POST'])
@jwt_required()
def add_responsible_user(todo_id):
"""Add responsible user to todo"""
try:
identity = get_jwt_identity()
data = request.get_json()
if not data or 'ad_account' not in data:
return jsonify({'error': 'AD account is required'}), 400
ad_account = data['ad_account']
# Get todo
todo = TodoItem.query.filter_by(id=todo_id).first()
if not todo:
return jsonify({'error': 'Todo not found'}), 404
# Check permission
if not todo.can_edit(identity):
return jsonify({'error': 'No permission to edit this todo'}), 403
# Validate AD account
valid_accounts = validate_ad_accounts([ad_account])
if ad_account not in valid_accounts:
return jsonify({'error': 'Invalid AD account'}), 400
# Check if already responsible
existing = TodoItemResponsible.query.filter_by(
todo_id=todo_id, ad_account=ad_account
).first()
if existing:
return jsonify({'error': 'User is already responsible for this todo'}), 400
# Add responsible user
responsible = TodoItemResponsible(
todo_id=todo_id,
ad_account=ad_account,
added_by=identity
)
db.session.add(responsible)
# Log audit
audit = TodoAuditLog(
actor_ad=identity,
todo_id=todo_id,
action='UPDATE',
detail={
'field': 'responsible_users',
'action': 'add',
'ad_account': ad_account
}
)
db.session.add(audit)
db.session.commit()
logger.info(f"Added responsible user {ad_account} to todo {todo_id} by {identity}")
return jsonify({'message': 'Responsible user added successfully'}), 201
except Exception as e:
db.session.rollback()
logger.error(f"Add responsible user error: {str(e)}")
return jsonify({'error': 'Failed to add responsible user'}), 500
@todos_bp.route('/<todo_id>/responsible/<ad_account>', methods=['DELETE'])
@jwt_required()
def remove_responsible_user(todo_id, ad_account):
"""Remove responsible user from todo"""
try:
identity = get_jwt_identity()
# Get todo
todo = TodoItem.query.filter_by(id=todo_id).first()
if not todo:
return jsonify({'error': 'Todo not found'}), 404
# Check permission
if not todo.can_edit(identity):
return jsonify({'error': 'No permission to edit this todo'}), 403
# Find responsible relationship
responsible = TodoItemResponsible.query.filter_by(
todo_id=todo_id, ad_account=ad_account
).first()
if not responsible:
return jsonify({'error': 'User is not responsible for this todo'}), 404
# Remove responsible user
db.session.delete(responsible)
# Log audit
audit = TodoAuditLog(
actor_ad=identity,
todo_id=todo_id,
action='UPDATE',
detail={
'field': 'responsible_users',
'action': 'remove',
'ad_account': ad_account
}
)
db.session.add(audit)
db.session.commit()
logger.info(f"Removed responsible user {ad_account} from todo {todo_id} by {identity}")
return jsonify({'message': 'Responsible user removed successfully'}), 200
except Exception as e:
db.session.rollback()
logger.error(f"Remove responsible user error: {str(e)}")
return jsonify({'error': 'Failed to remove responsible user'}), 500
@todos_bp.route('/<todo_id>/followers', methods=['POST'])
@jwt_required()
def add_follower(todo_id):
"""Add follower to todo"""
try:
identity = get_jwt_identity()
data = request.get_json()
if not data or 'ad_account' not in data:
return jsonify({'error': 'AD account is required'}), 400
ad_account = data['ad_account']
# Get todo
todo = TodoItem.query.filter_by(id=todo_id).first()
if not todo:
return jsonify({'error': 'Todo not found'}), 404
# Check permission (anyone who can view the todo can add followers)
if not todo.can_view(identity):
return jsonify({'error': 'No permission to view this todo'}), 403
# Validate AD account
valid_accounts = validate_ad_accounts([ad_account])
if ad_account not in valid_accounts:
return jsonify({'error': 'Invalid AD account'}), 400
# Check if already following
existing = TodoItemFollower.query.filter_by(
todo_id=todo_id, ad_account=ad_account
).first()
if existing:
return jsonify({'error': 'User is already following this todo'}), 400
# Add follower
follower = TodoItemFollower(
todo_id=todo_id,
ad_account=ad_account,
added_by=identity
)
db.session.add(follower)
# Log audit
audit = TodoAuditLog(
actor_ad=identity,
todo_id=todo_id,
action='UPDATE',
detail={
'field': 'followers',
'action': 'add',
'ad_account': ad_account
}
)
db.session.add(audit)
db.session.commit()
logger.info(f"Added follower {ad_account} to todo {todo_id} by {identity}")
return jsonify({'message': 'Follower added successfully'}), 201
except Exception as e:
db.session.rollback()
logger.error(f"Add follower error: {str(e)}")
return jsonify({'error': 'Failed to add follower'}), 500
@todos_bp.route('/<todo_id>/followers/<ad_account>', methods=['DELETE'])
@jwt_required()
def remove_follower(todo_id, ad_account):
"""Remove follower from todo"""
try:
identity = get_jwt_identity()
# Get todo
todo = TodoItem.query.filter_by(id=todo_id).first()
if not todo:
return jsonify({'error': 'Todo not found'}), 404
# Check permission (user can remove themselves or todo editors can remove anyone)
if ad_account != identity and not todo.can_edit(identity):
return jsonify({'error': 'No permission to remove this follower'}), 403
# Find follower relationship
follower = TodoItemFollower.query.filter_by(
todo_id=todo_id, ad_account=ad_account
).first()
if not follower:
return jsonify({'error': 'User is not following this todo'}), 404
# Remove follower
db.session.delete(follower)
# Log audit
audit = TodoAuditLog(
actor_ad=identity,
todo_id=todo_id,
action='UPDATE',
detail={
'field': 'followers',
'action': 'remove',
'ad_account': ad_account
}
)
db.session.add(audit)
db.session.commit()
logger.info(f"Removed follower {ad_account} from todo {todo_id} by {identity}")
return jsonify({'message': 'Follower removed successfully'}), 200
except Exception as e:
db.session.rollback()
logger.error(f"Remove follower error: {str(e)}")
return jsonify({'error': 'Failed to remove follower'}), 500
@todos_bp.route('/<todo_id>/star', methods=['POST'])
@jwt_required()
def star_todo(todo_id):
"""Star/unstar a todo item"""
try:
identity = get_jwt_identity()
# Get todo
todo = TodoItem.query.filter_by(id=todo_id).first()
if not todo:
return jsonify({'error': 'Todo not found'}), 404
# Check permission
if not todo.can_view(identity):
return jsonify({'error': 'No permission to view this todo'}), 403
# Only creator can star/unstar
if todo.creator_ad != identity:
return jsonify({'error': 'Only creator can star/unstar todos'}), 403
# Toggle star status
todo.starred = not todo.starred
# Log audit
audit = TodoAuditLog(
actor_ad=identity,
todo_id=todo_id,
action='UPDATE',
detail={
'field': 'starred',
'value': todo.starred
}
)
db.session.add(audit)
db.session.commit()
action = 'starred' if todo.starred else 'unstarred'
logger.info(f"Todo {todo_id} {action} by {identity}")
return jsonify({
'message': f'Todo {action} successfully',
'starred': todo.starred
}), 200
except Exception as e:
db.session.rollback()
logger.error(f"Star todo error: {str(e)}")
return jsonify({'error': 'Failed to star todo'}), 500

128
backend/routes/users.py Normal file
View File

@@ -0,0 +1,128 @@
from flask import Blueprint, request, jsonify, current_app
from flask_jwt_extended import jwt_required, get_jwt_identity
from datetime import datetime, date
from models import db, TodoUserPref
from utils.logger import get_logger
users_bp = Blueprint('users', __name__)
logger = get_logger(__name__)
@users_bp.route('/search', methods=['GET'])
@jwt_required()
def search_users():
"""Search for AD users"""
try:
search_term = request.args.get('q', '').strip()
if len(search_term) < 1:
return jsonify({'error': 'Search term cannot be empty'}), 400
# Search LDAP (or mock for development)
try:
if current_app.config.get('USE_MOCK_LDAP', False):
from utils.mock_ldap import search_ldap_principals
else:
from utils.ldap_utils import search_ldap_principals
results = search_ldap_principals(search_term, limit=20)
except Exception as e:
logger.error(f"LDAP search error, falling back to mock: {str(e)}")
from utils.mock_ldap import search_ldap_principals
results = search_ldap_principals(search_term, limit=20)
return jsonify({'users': results}), 200
except Exception as e:
logger.error(f"User search error: {str(e)}")
return jsonify({'error': 'Search failed'}), 500
@users_bp.route('/preferences', methods=['GET'])
@jwt_required()
def get_preferences():
"""Get user preferences"""
try:
identity = get_jwt_identity()
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
if not user_pref:
return jsonify({'error': 'User preferences not found'}), 404
return jsonify(user_pref.to_dict()), 200
except Exception as e:
logger.error(f"Error fetching preferences: {str(e)}")
return jsonify({'error': 'Failed to fetch preferences'}), 500
@users_bp.route('/preferences', methods=['PATCH'])
@jwt_required()
def update_preferences():
"""Update user preferences"""
try:
identity = get_jwt_identity()
data = request.get_json()
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
if not user_pref:
return jsonify({'error': 'User preferences not found'}), 404
# Update allowed fields
if 'theme' in data and data['theme'] in ['light', 'dark', 'auto']:
user_pref.theme = data['theme']
if 'language' in data:
user_pref.language = data['language']
if 'timezone' in data:
user_pref.timezone = data['timezone']
if 'notification_enabled' in data:
user_pref.notification_enabled = bool(data['notification_enabled'])
if 'email_reminder_enabled' in data:
user_pref.email_reminder_enabled = bool(data['email_reminder_enabled'])
if 'weekly_summary_enabled' in data:
user_pref.weekly_summary_enabled = bool(data['weekly_summary_enabled'])
user_pref.updated_at = datetime.utcnow()
db.session.commit()
logger.info(f"Preferences updated for user: {identity}")
return jsonify(user_pref.to_dict()), 200
except Exception as e:
db.session.rollback()
logger.error(f"Error updating preferences: {str(e)}")
return jsonify({'error': 'Failed to update preferences'}), 500
@users_bp.route('/fire-email-quota', methods=['GET'])
@jwt_required()
def get_fire_email_quota():
"""Get user's fire email quota for today"""
try:
identity = get_jwt_identity()
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
if not user_pref:
return jsonify({'error': 'User not found'}), 404
# Reset counter if it's a new day
today = date.today()
if user_pref.fire_email_last_reset != today:
user_pref.fire_email_today_count = 0
user_pref.fire_email_last_reset = today
db.session.commit()
from flask import current_app
daily_limit = current_app.config['FIRE_EMAIL_DAILY_LIMIT']
return jsonify({
'used': user_pref.fire_email_today_count,
'limit': daily_limit,
'remaining': max(0, daily_limit - user_pref.fire_email_today_count)
}), 200
except Exception as e:
logger.error(f"Error fetching fire email quota: {str(e)}")
return jsonify({'error': 'Failed to fetch quota'}), 500

226
backend/tasks.py Normal file
View File

@@ -0,0 +1,226 @@
"""
Celery Tasks for Background Jobs
處理排程任務,包括提醒郵件和摘要報告
"""
from celery import Celery
from datetime import datetime, date, timedelta
from sqlalchemy import and_, or_
from models import (
db, TodoItem, TodoItemResponsible, TodoItemFollower,
TodoUserPref, TodoAuditLog
)
from utils.email_service import EmailService
from utils.notification_service import NotificationService
from utils.logger import get_logger
import os
# 建立 Celery 實例
def make_celery(app):
celery = Celery(
app.import_name,
backend=app.config['CELERY_RESULT_BACKEND'],
broker=app.config['CELERY_BROKER_URL']
)
celery.conf.update(app.config)
class ContextTask(celery.Task):
"""Make celery tasks work with Flask app context"""
def __call__(self, *args, **kwargs):
with app.app_context():
return self.run(*args, **kwargs)
celery.Task = ContextTask
return celery
# 建立 Flask 應用程式和 Celery
def create_celery_app():
"""建立 Celery 應用程式,延遲導入避免循環依賴"""
from app import create_app
flask_app = create_app()
return make_celery(flask_app), flask_app
# 全局變數,延遲初始化
celery = None
flask_app = None
def get_celery():
"""獲取 Celery 實例"""
global celery, flask_app
if celery is None:
celery, flask_app = create_celery_app()
return celery
logger = get_logger(__name__)
def send_daily_reminders():
"""發送每日提醒郵件"""
try:
celery_app = get_celery()
from app import create_app
app = create_app()
with app.app_context():
today = date.today()
tomorrow = today + timedelta(days=1)
# 查找明日到期的待辦事項
due_tomorrow = db.session.query(TodoItem).filter(
and_(
TodoItem.due_date == tomorrow,
TodoItem.status != 'DONE'
)
).all()
# 查找已逾期的待辦事項
overdue = db.session.query(TodoItem).filter(
and_(
TodoItem.due_date < today,
TodoItem.status != 'DONE'
)
).all()
email_service = EmailService()
notification_service = NotificationService()
sent_count = 0
# 處理明日到期提醒
for todo in due_tomorrow:
recipients = notification_service.get_notification_recipients(todo)
for recipient in recipients:
try:
# 檢查用戶是否啟用郵件提醒
user_pref = TodoUserPref.query.filter_by(ad_account=recipient).first()
if not user_pref or not user_pref.email_reminder_enabled:
continue
if email_service.send_reminder_email(todo, recipient, 'due_tomorrow'):
sent_count += 1
except Exception as e:
logger.error(f"Failed to send due tomorrow reminder to {recipient}: {str(e)}")
# 處理逾期提醒
for todo in overdue:
recipients = notification_service.get_notification_recipients(todo)
for recipient in recipients:
try:
# 檢查用戶是否啟用郵件提醒
user_pref = TodoUserPref.query.filter_by(ad_account=recipient).first()
if not user_pref or not user_pref.email_reminder_enabled:
continue
if email_service.send_reminder_email(todo, recipient, 'overdue'):
sent_count += 1
except Exception as e:
logger.error(f"Failed to send overdue reminder to {recipient}: {str(e)}")
# 記錄稽核日誌
audit = TodoAuditLog(
actor_ad='system',
todo_id=None,
action='DAILY_REMINDER',
detail={
'due_tomorrow_count': len(due_tomorrow),
'overdue_count': len(overdue),
'emails_sent': sent_count
}
)
db.session.add(audit)
db.session.commit()
logger.info(f"Daily reminders sent: {sent_count} emails for {len(due_tomorrow + overdue)} todos")
return {
'sent_count': sent_count,
'due_tomorrow': len(due_tomorrow),
'overdue': len(overdue)
}
except Exception as e:
logger.error(f"Daily reminders task failed: {str(e)}")
raise
@celery.task
def send_weekly_summary():
"""發送每週摘要報告"""
try:
with flask_app.app_context():
# 取得所有啟用週報的用戶
users = TodoUserPref.query.filter_by(weekly_summary_enabled=True).all()
email_service = EmailService()
notification_service = NotificationService()
sent_count = 0
for user in users:
try:
# 準備週報資料
digest_data = notification_service.prepare_digest(user.ad_account, 'weekly')
if email_service.send_digest_email(user.ad_account, digest_data):
sent_count += 1
except Exception as e:
logger.error(f"Failed to send weekly summary to {user.ad_account}: {str(e)}")
# 記錄稽核日誌
audit = TodoAuditLog(
actor_ad='system',
todo_id=None,
action='WEEKLY_SUMMARY',
detail={
'users_count': len(users),
'emails_sent': sent_count
}
)
db.session.add(audit)
db.session.commit()
logger.info(f"Weekly summary sent: {sent_count} emails to {len(users)} users")
return {
'sent_count': sent_count,
'total_users': len(users)
}
except Exception as e:
logger.error(f"Weekly summary task failed: {str(e)}")
raise
@celery.task
def cleanup_old_logs():
"""清理舊的日誌記錄"""
try:
with flask_app.app_context():
# 清理30天前的稽核日誌
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
deleted_count = TodoAuditLog.query.filter(
TodoAuditLog.created_at < thirty_days_ago
).delete()
db.session.commit()
logger.info(f"Cleaned up {deleted_count} old audit logs")
return {'deleted_count': deleted_count}
except Exception as e:
logger.error(f"Cleanup logs task failed: {str(e)}")
raise
# Celery Beat 排程配置
celery.conf.beat_schedule = {
# 每日早上9點發送提醒
'daily-reminders': {
'task': 'tasks.send_daily_reminders',
'schedule': 60.0 * 60.0 * 24.0, # 24小時
'options': {'expires': 3600}
},
# 每週一早上9點發送週報
'weekly-summary': {
'task': 'tasks.send_weekly_summary',
'schedule': 60.0 * 60.0 * 24.0 * 7.0, # 7天
'options': {'expires': 3600}
},
# 每週清理一次舊日誌
'cleanup-logs': {
'task': 'tasks.cleanup_old_logs',
'schedule': 60.0 * 60.0 * 24.0 * 7.0, # 7天
'options': {'expires': 3600}
}
}
celery.conf.timezone = 'Asia/Taipei'

178
backend/tasks_simple.py Normal file
View File

@@ -0,0 +1,178 @@
"""
Simple Task Definitions
簡化的任務定義,避免循環導入
"""
from datetime import datetime, date, timedelta
from sqlalchemy import and_, or_
from utils.logger import get_logger
logger = get_logger(__name__)
def send_daily_reminders_task():
"""發送每日提醒郵件的實際實作"""
from models import db, TodoItem, TodoUserPref
from utils.email_service import EmailService
from utils.notification_service import NotificationService
try:
today = date.today()
tomorrow = today + timedelta(days=1)
# 查找明日到期的待辦事項
due_tomorrow = db.session.query(TodoItem).filter(
and_(
TodoItem.due_date == tomorrow,
TodoItem.status != 'DONE'
)
).all()
# 查找已逾期的待辦事項
overdue = db.session.query(TodoItem).filter(
and_(
TodoItem.due_date < today,
TodoItem.status != 'DONE'
)
).all()
email_service = EmailService()
notification_service = NotificationService()
sent_count = 0
# 處理明日到期提醒
for todo in due_tomorrow:
recipients = notification_service.get_notification_recipients(todo)
for recipient in recipients:
try:
# 檢查用戶是否啟用郵件提醒
user_pref = TodoUserPref.query.filter_by(ad_account=recipient).first()
if not user_pref or not user_pref.email_reminder_enabled:
continue
if email_service.send_reminder_email(todo, recipient, 'due_tomorrow'):
sent_count += 1
except Exception as e:
logger.error(f"Failed to send due tomorrow reminder to {recipient}: {str(e)}")
# 處理逾期提醒
for todo in overdue:
recipients = notification_service.get_notification_recipients(todo)
for recipient in recipients:
try:
# 檢查用戶是否啟用郵件提醒
user_pref = TodoUserPref.query.filter_by(ad_account=recipient).first()
if not user_pref or not user_pref.email_reminder_enabled:
continue
if email_service.send_reminder_email(todo, recipient, 'overdue'):
sent_count += 1
except Exception as e:
logger.error(f"Failed to send overdue reminder to {recipient}: {str(e)}")
# 記錄稽核日誌
from models import TodoAuditLog
audit = TodoAuditLog(
actor_ad='system',
todo_id=None,
action='DAILY_REMINDER',
detail={
'due_tomorrow_count': len(due_tomorrow),
'overdue_count': len(overdue),
'emails_sent': sent_count
}
)
db.session.add(audit)
db.session.commit()
logger.info(f"Daily reminders sent: {sent_count} emails for {len(due_tomorrow + overdue)} todos")
return {
'sent_count': sent_count,
'due_tomorrow': len(due_tomorrow),
'overdue': len(overdue)
}
except Exception as e:
logger.error(f"Daily reminders task failed: {str(e)}")
raise
def send_weekly_summary_task():
"""發送每週摘要報告的實際實作"""
from models import db, TodoUserPref
from utils.email_service import EmailService
from utils.notification_service import NotificationService
try:
# 取得所有啟用週報的用戶
users = TodoUserPref.query.filter_by(weekly_summary_enabled=True).all()
email_service = EmailService()
notification_service = NotificationService()
sent_count = 0
for user in users:
try:
# 準備週報資料
digest_data = notification_service.prepare_digest(user.ad_account, 'weekly')
if email_service.send_digest_email(user.ad_account, digest_data):
sent_count += 1
except Exception as e:
logger.error(f"Failed to send weekly summary to {user.ad_account}: {str(e)}")
# 記錄稽核日誌
from models import TodoAuditLog
audit = TodoAuditLog(
actor_ad='system',
todo_id=None,
action='WEEKLY_SUMMARY',
detail={
'users_count': len(users),
'emails_sent': sent_count
}
)
db.session.add(audit)
db.session.commit()
logger.info(f"Weekly summary sent: {sent_count} emails to {len(users)} users")
return {
'sent_count': sent_count,
'total_users': len(users)
}
except Exception as e:
logger.error(f"Weekly summary task failed: {str(e)}")
raise
def cleanup_old_logs_task():
"""清理舊的日誌記錄的實際實作"""
from models import db, TodoAuditLog
try:
# 清理30天前的稽核日誌
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
deleted_count = TodoAuditLog.query.filter(
TodoAuditLog.created_at < thirty_days_ago
).delete()
db.session.commit()
logger.info(f"Cleaned up {deleted_count} old audit logs")
return {'deleted_count': deleted_count}
except Exception as e:
logger.error(f"Cleanup logs task failed: {str(e)}")
raise
# 為了與現有代碼兼容,提供簡單的包裝函數
def send_daily_reminders():
"""包裝函數,保持與現有代碼兼容"""
return send_daily_reminders_task()
def send_weekly_summary():
"""包裝函數,保持與現有代碼兼容"""
return send_weekly_summary_task()
def cleanup_old_logs():
"""包裝函數,保持與現有代碼兼容"""
return cleanup_old_logs_task()

View File

@@ -0,0 +1,230 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>緊急通知 - {{ todo.title }}</title>
<style>
body {
font-family: 'Microsoft YaHei', 'PingFang SC', Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background: white;
border-radius: 8px;
padding: 30px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.header {
text-align: center;
border-bottom: 3px solid #dc3545;
padding-bottom: 20px;
margin-bottom: 30px;
}
.fire-icon {
font-size: 48px;
margin-bottom: 10px;
display: block;
}
.title {
color: #dc3545;
font-size: 24px;
font-weight: bold;
margin: 0;
}
.urgent-badge {
background: #dc3545;
color: white;
padding: 8px 16px;
border-radius: 20px;
font-size: 14px;
font-weight: bold;
display: inline-block;
margin: 10px 0;
}
.todo-details {
background: #f8f9fa;
border-left: 4px solid #dc3545;
padding: 20px;
margin: 20px 0;
border-radius: 0 4px 4px 0;
}
.detail-row {
margin: 10px 0;
display: flex;
align-items: center;
}
.detail-label {
font-weight: bold;
min-width: 80px;
color: #666;
}
.detail-value {
flex: 1;
}
.status-badge, .priority-badge {
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: bold;
text-transform: uppercase;
}
.status-new { background: #e3f2fd; color: #1976d2; }
.status-in-progress { background: #fff3e0; color: #f57c00; }
.status-done { background: #e8f5e8; color: #388e3c; }
.priority-high { background: #ffebee; color: #d32f2f; }
.priority-medium { background: #fff3e0; color: #f57c00; }
.priority-low { background: #e8f5e8; color: #388e3c; }
.custom-message {
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 4px;
padding: 15px;
margin: 20px 0;
}
.sender-info {
margin: 20px 0;
padding: 15px;
background: #f8f9fa;
border-radius: 4px;
}
.action-buttons {
text-align: center;
margin: 30px 0;
}
.btn {
display: inline-block;
padding: 12px 24px;
margin: 0 10px;
border-radius: 4px;
text-decoration: none;
font-weight: bold;
border: none;
cursor: pointer;
}
.btn-primary {
background: #007bff;
color: white;
}
.btn-danger {
background: #dc3545;
color: white;
}
.footer {
text-align: center;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #dee2e6;
font-size: 14px;
color: #666;
}
.timestamp {
color: #999;
font-size: 12px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<span class="fire-icon">🚨</span>
<h1 class="title">緊急通知</h1>
<div class="urgent-badge">URGENT - 立即處理</div>
</div>
<div class="sender-info">
<strong>{{ sender_name }}</strong> 向您發送了緊急通知
<div class="timestamp">{{ timestamp }}</div>
</div>
{% if custom_message %}
<div class="custom-message">
<strong>📝 發送者留言:</strong><br>
{{ custom_message }}
</div>
{% endif %}
<div class="todo-details">
<h3>📋 待辦事項詳情</h3>
<div class="detail-row">
<div class="detail-label">標題:</div>
<div class="detail-value"><strong>{{ todo.title }}</strong></div>
</div>
{% if todo.description %}
<div class="detail-row">
<div class="detail-label">描述:</div>
<div class="detail-value">{{ todo.description }}</div>
</div>
{% endif %}
<div class="detail-row">
<div class="detail-label">狀態:</div>
<div class="detail-value">
<span class="status-badge status-{{ todo.status.lower().replace('_', '-') }}">
{% if todo.status == 'NEW' %}新建
{% elif todo.status == 'IN_PROGRESS' %}進行中
{% elif todo.status == 'DONE' %}完成
{% else %}{{ todo.status }}{% endif %}
</span>
</div>
</div>
<div class="detail-row">
<div class="detail-label">優先級:</div>
<div class="detail-value">
<span class="priority-badge priority-{{ todo.priority.lower() }}">
{% if todo.priority == 'HIGH' %}高
{% elif todo.priority == 'MEDIUM' %}中
{% elif todo.priority == 'LOW' %}低
{% else %}{{ todo.priority }}{% endif %}
</span>
</div>
</div>
{% if todo.due_date %}
<div class="detail-row">
<div class="detail-label">到期日:</div>
<div class="detail-value">
<strong style="color: #dc3545;">{{ todo.due_date.strftime('%Y年%m月%d日') }}</strong>
</div>
</div>
{% endif %}
<div class="detail-row">
<div class="detail-label">建立者:</div>
<div class="detail-value">{{ todo.creator_display_name or todo.creator_ad }}</div>
</div>
<div class="detail-row">
<div class="detail-label">建立時間:</div>
<div class="detail-value">{{ todo.created_at.strftime('%Y年%m月%d日 %H:%M') }}</div>
</div>
</div>
<div class="action-buttons">
<a href="{{ app_url }}/todos/{{ todo.id }}" class="btn btn-primary">
📖 查看詳情
</a>
<a href="{{ app_url }}/todos/{{ todo.id }}/edit" class="btn btn-danger">
✏️ 立即處理
</a>
</div>
<div class="footer">
<p>這是一封系統自動發送的緊急通知郵件</p>
<p>如有疑問,請聯繫發送者 {{ sender_name }} ({{ sender }})</p>
<div class="timestamp">
發送時間:{{ timestamp }}<br>
由 {{ app_name }} 系統發送
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,319 @@
"""
Email Service
處理所有郵件相關功能,包括通知、提醒和摘要郵件
"""
import os
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.application import MIMEApplication
from datetime import datetime, date
from flask import current_app
from jinja2 import Environment, FileSystemLoader, select_autoescape
from utils.logger import get_logger
from utils.ldap_utils import get_user_info
logger = get_logger(__name__)
class EmailService:
"""郵件服務類別"""
def __init__(self):
self.smtp_server = os.getenv('SMTP_SERVER')
self.smtp_port = int(os.getenv('SMTP_PORT', 587))
self.use_tls = os.getenv('SMTP_USE_TLS', 'false').lower() == 'true'
self.use_ssl = os.getenv('SMTP_USE_SSL', 'false').lower() == 'true'
self.auth_required = os.getenv('SMTP_AUTH_REQUIRED', 'false').lower() == 'true'
self.sender_email = os.getenv('SMTP_SENDER_EMAIL')
self.sender_password = os.getenv('SMTP_SENDER_PASSWORD', '')
# 設定 Jinja2 模板環境
template_dir = os.path.join(os.path.dirname(__file__), '..', 'templates', 'emails')
self.jinja_env = Environment(
loader=FileSystemLoader(template_dir),
autoescape=select_autoescape(['html', 'xml'])
)
def _create_smtp_connection(self):
"""建立 SMTP 連線"""
try:
if self.use_ssl:
server = smtplib.SMTP_SSL(self.smtp_server, self.smtp_port)
else:
server = smtplib.SMTP(self.smtp_server, self.smtp_port)
if self.use_tls:
server.starttls()
if self.auth_required and self.sender_password:
server.login(self.sender_email, self.sender_password)
return server
except Exception as e:
logger.error(f"SMTP connection failed: {str(e)}")
return None
def _send_email(self, to_email, subject, html_content, text_content=None):
"""發送郵件的基礎方法"""
try:
if not self.smtp_server or not self.sender_email:
logger.error("SMTP configuration incomplete")
return False
# 建立郵件
msg = MIMEMultipart('alternative')
msg['From'] = self.sender_email
msg['To'] = to_email
msg['Subject'] = subject
# 添加文本內容
if text_content:
text_part = MIMEText(text_content, 'plain', 'utf-8')
msg.attach(text_part)
# 添加 HTML 內容
html_part = MIMEText(html_content, 'html', 'utf-8')
msg.attach(html_part)
# 發送郵件
server = self._create_smtp_connection()
if not server:
return False
server.send_message(msg)
server.quit()
logger.info(f"Email sent successfully to {to_email}")
return True
except Exception as e:
logger.error(f"Failed to send email to {to_email}: {str(e)}")
return False
def _get_user_email(self, ad_account):
"""取得使用者郵件地址"""
user_info = get_user_info(ad_account)
if user_info and user_info.get('email'):
return user_info['email']
# 如果無法從 LDAP 取得,嘗試組合郵件地址
domain = os.getenv('LDAP_DOMAIN', 'panjit.com.tw')
return f"{ad_account}@{domain}"
def send_fire_email(self, todo, recipient, sender, custom_message=''):
"""發送緊急通知郵件"""
try:
recipient_email = self._get_user_email(recipient)
sender_info = get_user_info(sender)
sender_name = sender_info.get('displayName', sender) if sender_info else sender
# 準備模板資料
template_data = {
'todo': todo,
'recipient': recipient,
'sender': sender,
'sender_name': sender_name,
'custom_message': custom_message,
'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'app_name': current_app.config.get('APP_NAME', 'PANJIT Todo List')
}
# 渲染模板
template = self.jinja_env.get_template('fire_email.html')
html_content = template.render(**template_data)
# 主題
subject = f"🚨 緊急通知 - {todo.title}"
return self._send_email(recipient_email, subject, html_content)
except Exception as e:
logger.error(f"Fire email failed for {recipient}: {str(e)}")
return False
def send_reminder_email(self, todo, recipient, reminder_type):
"""發送提醒郵件"""
try:
recipient_email = self._get_user_email(recipient)
# 根據提醒類型設定主題和模板
if reminder_type == 'due_tomorrow':
subject = f"📅 明日到期提醒 - {todo.title}"
template_name = 'reminder_due_tomorrow.html'
elif reminder_type == 'overdue':
subject = f"⚠️ 逾期提醒 - {todo.title}"
template_name = 'reminder_overdue.html'
else:
subject = f"📋 待辦提醒 - {todo.title}"
template_name = 'reminder_general.html'
# 準備模板資料
template_data = {
'todo': todo,
'recipient': recipient,
'reminder_type': reminder_type,
'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'app_name': current_app.config.get('APP_NAME', 'PANJIT Todo List')
}
# 渲染模板
template = self.jinja_env.get_template(template_name)
html_content = template.render(**template_data)
return self._send_email(recipient_email, subject, html_content)
except Exception as e:
logger.error(f"Reminder email failed for {recipient}: {str(e)}")
return False
def send_digest_email(self, recipient, digest_data):
"""發送摘要郵件"""
try:
recipient_email = self._get_user_email(recipient)
# 根據摘要類型設定主題
digest_type = digest_data.get('type', 'weekly')
type_names = {
'daily': '每日',
'weekly': '每週',
'monthly': '每月'
}
subject = f"📊 {type_names.get(digest_type, '定期')}摘要報告"
# 準備模板資料
template_data = {
'recipient': recipient,
'digest_data': digest_data,
'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'app_name': current_app.config.get('APP_NAME', 'PANJIT Todo List')
}
# 渲染模板
template = self.jinja_env.get_template('digest.html')
html_content = template.render(**template_data)
return self._send_email(recipient_email, subject, html_content)
except Exception as e:
logger.error(f"Digest email failed for {recipient}: {str(e)}")
return False
def send_todo_notification(self, todo, recipients, action, actor):
"""發送待辦事項變更通知"""
try:
success_count = 0
for recipient in recipients:
try:
recipient_email = self._get_user_email(recipient)
actor_info = get_user_info(actor)
actor_name = actor_info.get('displayName', actor) if actor_info else actor
# 根據動作類型設定主題和模板
action_names = {
'CREATE': '建立',
'UPDATE': '更新',
'DELETE': '刪除',
'ASSIGN': '指派',
'COMPLETE': '完成'
}
action_name = action_names.get(action, action)
subject = f"📋 待辦事項{action_name} - {todo.title}"
# 準備模板資料
template_data = {
'todo': todo,
'recipient': recipient,
'action': action,
'action_name': action_name,
'actor': actor,
'actor_name': actor_name,
'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'app_name': current_app.config.get('APP_NAME', 'PANJIT Todo List')
}
# 渲染模板
template = self.jinja_env.get_template('todo_notification.html')
html_content = template.render(**template_data)
if self._send_email(recipient_email, subject, html_content):
success_count += 1
except Exception as e:
logger.error(f"Todo notification failed for {recipient}: {str(e)}")
return success_count
except Exception as e:
logger.error(f"Todo notification batch failed: {str(e)}")
return 0
def send_test_email(self, recipient):
"""發送測試郵件"""
try:
recipient_email = self._get_user_email(recipient)
subject = "✅ 郵件服務測試"
html_content = f"""
<html>
<body>
<h2>郵件服務測試</h2>
<p>您好 {recipient}</p>
<p>這是一封測試郵件,用於驗證 PANJIT Todo List 系統的郵件功能是否正常運作。</p>
<p>如果您收到這封郵件,表示郵件服務配置正確。</p>
<br>
<p>測試時間: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
<p>此郵件由系統自動發送,請勿回覆。</p>
</body>
</html>
"""
return self._send_email(recipient_email, subject, html_content)
except Exception as e:
logger.error(f"Test email failed for {recipient}: {str(e)}")
return False
def send_test_email_direct(self, recipient_email):
"""直接發送測試郵件到指定郵件地址"""
try:
subject = "✅ PANJIT Todo List 郵件服務測試"
html_content = f"""
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #2563eb; border-bottom: 2px solid #e5e7eb; padding-bottom: 10px;">📧 郵件服務測試</h2>
<p>您好!</p>
<p>這是一封來自 <strong>PANJIT Todo List 系統</strong> 的測試郵件,用於驗證郵件服務功能是否正常運作。</p>
<div style="background-color: #f0f9ff; border-left: 4px solid #2563eb; padding: 15px; margin: 20px 0;">
<p style="margin: 0;"><strong>✅ 如果您收到這封郵件,表示:</strong></p>
<ul style="margin: 10px 0; padding-left: 20px;">
<li>SMTP 服務器連線正常</li>
<li>郵件發送功能運作良好</li>
<li>您的郵件地址設定正確</li>
</ul>
</div>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 30px 0;">
<p style="font-size: 14px; color: #6b7280;">
<strong>測試詳細資訊:</strong><br>
📅 測試時間: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}<br>
📧 收件人: {recipient_email}<br>
🏢 發件人: PANJIT Todo List 系統
</p>
<p style="font-size: 12px; color: #9ca3af; margin-top: 30px;">
此郵件由系統自動發送,請勿回覆。如有任何問題,請聯繫系統管理員。
</p>
</div>
</body>
</html>
"""
return self._send_email(recipient_email, subject, html_content)
except Exception as e:
logger.error(f"Direct test email failed for {recipient_email}: {str(e)}")
return False

230
backend/utils/ldap_utils.py Normal file
View File

@@ -0,0 +1,230 @@
import time
from ldap3 import Server, Connection, SUBTREE, ALL_ATTRIBUTES
from flask import current_app
from utils.logger import get_logger
logger = get_logger(__name__)
def create_ldap_connection(retries=3):
"""Create LDAP connection with retry mechanism"""
config = current_app.config
for attempt in range(retries):
try:
server = Server(
config['LDAP_SERVER'],
port=config['LDAP_PORT'],
use_ssl=config['LDAP_USE_SSL'],
get_info=ALL_ATTRIBUTES
)
conn = Connection(
server,
user=config['LDAP_BIND_USER_DN'],
password=config['LDAP_BIND_USER_PASSWORD'],
auto_bind=True,
raise_exceptions=True
)
logger.info("LDAP connection established successfully")
return conn
except Exception as e:
logger.error(f"LDAP connection attempt {attempt + 1} failed: {str(e)}")
if attempt == retries - 1:
raise
time.sleep(1)
return None
def authenticate_user(username, password):
"""Authenticate user against LDAP/AD"""
try:
conn = create_ldap_connection()
if not conn:
return None
config = current_app.config
search_filter = f"(&(objectClass=person)(objectCategory=person)({config['LDAP_USER_LOGIN_ATTR']}={username}))"
# Search for user
conn.search(
config['LDAP_SEARCH_BASE'],
search_filter,
SUBTREE,
attributes=['displayName', 'mail', 'sAMAccountName', 'userPrincipalName']
)
if not conn.entries:
logger.warning(f"User not found: {username}")
return None
user_entry = conn.entries[0]
user_dn = user_entry.entry_dn
# Try to bind with user credentials
try:
user_conn = Connection(
conn.server,
user=user_dn,
password=password,
auto_bind=True,
raise_exceptions=True
)
user_conn.unbind()
# Return user info
user_info = {
'ad_account': str(user_entry.sAMAccountName) if user_entry.sAMAccountName else username,
'display_name': str(user_entry.displayName) if user_entry.displayName else username,
'email': str(user_entry.mail) if user_entry.mail else '',
'user_principal_name': str(user_entry.userPrincipalName) if user_entry.userPrincipalName else username
}
logger.info(f"User authenticated successfully: {username}")
return user_info
except Exception as e:
logger.warning(f"Authentication failed for user {username}: {str(e)}")
return None
except Exception as e:
logger.error(f"LDAP authentication error: {str(e)}")
return None
finally:
if conn:
conn.unbind()
def search_ldap_principals(search_term, limit=20):
"""Search for LDAP users and groups"""
try:
conn = create_ldap_connection()
if not conn:
return []
config = current_app.config
# Build search filter for active users
search_filter = f"""(&
(objectClass=person)
(objectCategory=person)
(!(userAccountControl:1.2.840.113556.1.4.803:=2))
(|
(displayName=*{search_term}*)
(mail=*{search_term}*)
(sAMAccountName=*{search_term}*)
(userPrincipalName=*{search_term}*)
)
)"""
# Remove extra whitespace
search_filter = ' '.join(search_filter.split())
conn.search(
config['LDAP_SEARCH_BASE'],
search_filter,
SUBTREE,
attributes=['sAMAccountName', 'displayName', 'mail'],
size_limit=limit
)
results = []
for entry in conn.entries:
results.append({
'ad_account': str(entry.sAMAccountName) if entry.sAMAccountName else '',
'display_name': str(entry.displayName) if entry.displayName else '',
'email': str(entry.mail) if entry.mail else ''
})
logger.info(f"LDAP search found {len(results)} results for term: {search_term}")
return results
except Exception as e:
logger.error(f"LDAP search error: {str(e)}")
return []
finally:
if conn:
conn.unbind()
def get_user_info(ad_account):
"""Get user information from LDAP"""
try:
conn = create_ldap_connection()
if not conn:
return None
config = current_app.config
search_filter = f"(&(objectClass=person)(sAMAccountName={ad_account}))"
conn.search(
config['LDAP_SEARCH_BASE'],
search_filter,
SUBTREE,
attributes=['displayName', 'mail', 'sAMAccountName', 'userPrincipalName']
)
if not conn.entries:
return None
entry = conn.entries[0]
return {
'ad_account': str(entry.sAMAccountName) if entry.sAMAccountName else ad_account,
'display_name': str(entry.displayName) if entry.displayName else ad_account,
'email': str(entry.mail) if entry.mail else ''
}
except Exception as e:
logger.error(f"Error getting user info for {ad_account}: {str(e)}")
return None
finally:
if conn:
conn.unbind()
def validate_ad_accounts(ad_accounts):
"""Validate multiple AD accounts exist"""
try:
conn = create_ldap_connection()
if not conn:
return {}
config = current_app.config
valid_accounts = {}
for account in ad_accounts:
search_filter = f"(&(objectClass=person)(sAMAccountName={account}))"
conn.search(
config['LDAP_SEARCH_BASE'],
search_filter,
SUBTREE,
attributes=['sAMAccountName', 'displayName', 'mail']
)
if conn.entries:
entry = conn.entries[0]
valid_accounts[account] = {
'ad_account': str(entry.sAMAccountName) if entry.sAMAccountName else account,
'display_name': str(entry.displayName) if entry.displayName else account,
'email': str(entry.mail) if entry.mail else ''
}
return valid_accounts
except Exception as e:
logger.error(f"Error validating AD accounts: {str(e)}")
return {}
finally:
if conn:
conn.unbind()
def test_ldap_connection():
"""Test LDAP connection for health check"""
try:
conn = create_ldap_connection(retries=1)
if conn:
conn.unbind()
return True
return False
except Exception as e:
logger.error(f"LDAP connection test failed: {str(e)}")
return False

58
backend/utils/logger.py Normal file
View File

@@ -0,0 +1,58 @@
import os
import logging
from logging.handlers import RotatingFileHandler
from colorlog import ColoredFormatter
def setup_logger(app):
"""Setup application logging"""
# Create logs directory if it doesn't exist
log_dir = 'logs'
if not os.path.exists(log_dir):
os.makedirs(log_dir)
log_file = app.config.get('LOG_FILE', 'logs/app.log')
log_level = app.config.get('LOG_LEVEL', 'INFO')
# Set up file handler
file_handler = RotatingFileHandler(
log_file,
maxBytes=10485760, # 10MB
backupCount=10
)
file_handler.setLevel(getattr(logging, log_level))
# File formatter
file_formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
file_handler.setFormatter(file_formatter)
# Console handler with colors
console_handler = logging.StreamHandler()
console_handler.setLevel(getattr(logging, log_level))
# Console formatter with colors
console_formatter = ColoredFormatter(
'%(log_color)s%(asctime)s - %(name)s - %(levelname)s - %(message)s%(reset)s',
log_colors={
'DEBUG': 'cyan',
'INFO': 'green',
'WARNING': 'yellow',
'ERROR': 'red',
'CRITICAL': 'red,bg_white',
}
)
console_handler.setFormatter(console_formatter)
# Add handlers to app logger
app.logger.addHandler(file_handler)
app.logger.addHandler(console_handler)
app.logger.setLevel(getattr(logging, log_level))
# Log startup
app.logger.info(f"Application started in {app.config.get('ENV', 'development')} mode")
def get_logger(name):
"""Get a logger instance"""
return logging.getLogger(name)

140
backend/utils/mock_ldap.py Normal file
View File

@@ -0,0 +1,140 @@
"""
Mock LDAP for development/testing purposes
當無法連接到實際LDAP時使用
"""
from utils.logger import get_logger
logger = get_logger(__name__)
def authenticate_user(username, password):
"""Mock authentication for development"""
logger.info(f"Mock LDAP: Authenticating user {username}")
# 簡單的開發用驗證
if not username or not password:
return None
# 模擬用戶資料
mock_users = {
'admin': {
'ad_account': 'admin',
'display_name': '系統管理員',
'email': 'admin@panjit.com.tw'
},
'test': {
'ad_account': 'test',
'display_name': '測試使用者',
'email': 'test@panjit.com.tw'
},
'user1': {
'ad_account': 'user1',
'display_name': '使用者一',
'email': 'user1@panjit.com.tw'
},
'ymirliu@panjit.com.tw': {
'ad_account': '92367',
'display_name': 'ymirliu 陸一銘',
'email': 'ymirliu@panjit.com.tw'
}
}
if username.lower() in mock_users:
logger.info(f"Mock LDAP: User {username} authenticated successfully")
return mock_users[username.lower()]
logger.warning(f"Mock LDAP: User {username} not found")
return None
def search_ldap_principals(search_term, limit=20):
"""Mock LDAP search"""
logger.info(f"Mock LDAP: Searching for '{search_term}'")
mock_results = [
{
'ad_account': 'admin',
'display_name': '系統管理員',
'email': 'admin@panjit.com.tw'
},
{
'ad_account': 'test',
'display_name': '測試使用者',
'email': 'test@panjit.com.tw'
},
{
'ad_account': 'user1',
'display_name': '使用者一',
'email': 'user1@panjit.com.tw'
},
{
'ad_account': 'user2',
'display_name': '使用者二',
'email': 'user2@panjit.com.tw'
}
]
# 簡單的搜尋過濾
if search_term:
results = []
for user in mock_results:
if (search_term.lower() in user['ad_account'].lower() or
search_term.lower() in user['display_name'].lower() or
search_term.lower() in user['email'].lower()):
results.append(user)
return results[:limit]
return mock_results[:limit]
def get_user_info(ad_account):
"""Mock get user info"""
mock_users = {
'admin': {
'ad_account': 'admin',
'display_name': '系統管理員',
'email': 'admin@panjit.com.tw'
},
'test': {
'ad_account': 'test',
'display_name': '測試使用者',
'email': 'test@panjit.com.tw'
},
'user1': {
'ad_account': 'user1',
'display_name': '使用者一',
'email': 'user1@panjit.com.tw'
}
}
return mock_users.get(ad_account.lower())
def validate_ad_accounts(ad_accounts):
"""Mock validate AD accounts"""
mock_users = {
'admin': {
'ad_account': 'admin',
'display_name': '系統管理員',
'email': 'admin@panjit.com.tw'
},
'test': {
'ad_account': 'test',
'display_name': '測試使用者',
'email': 'test@panjit.com.tw'
},
'user1': {
'ad_account': 'user1',
'display_name': '使用者一',
'email': 'user1@panjit.com.tw'
}
}
valid_accounts = {}
for account in ad_accounts:
if account.lower() in mock_users:
valid_accounts[account] = mock_users[account.lower()]
return valid_accounts
def test_ldap_connection():
"""Mock LDAP connection test"""
logger.info("Mock LDAP: Connection test - always returns True")
return True

View File

@@ -0,0 +1,225 @@
"""
Notification Service
處理通知邏輯和摘要資料準備
"""
from datetime import datetime, date, timedelta
from sqlalchemy import and_, or_, func
from models import (
db, TodoItem, TodoItemResponsible, TodoItemFollower,
TodoUserPref, TodoAuditLog
)
from utils.logger import get_logger
logger = get_logger(__name__)
class NotificationService:
"""通知服務類別"""
def get_notification_recipients(self, todo):
"""取得待辦事項的通知收件人清單"""
recipients = set()
# 加入建立者(如果啟用通知)
creator_pref = TodoUserPref.query.filter_by(ad_account=todo.creator_ad).first()
if creator_pref and creator_pref.notification_enabled:
recipients.add(todo.creator_ad)
# 加入負責人(如果啟用通知)
for responsible in todo.responsible_users:
user_pref = TodoUserPref.query.filter_by(ad_account=responsible.ad_account).first()
if user_pref and user_pref.notification_enabled:
recipients.add(responsible.ad_account)
# 加入追蹤人(如果啟用通知)
for follower in todo.followers:
user_pref = TodoUserPref.query.filter_by(ad_account=follower.ad_account).first()
if user_pref and user_pref.notification_enabled:
recipients.add(follower.ad_account)
return list(recipients)
def prepare_digest(self, user_ad, digest_type='weekly'):
"""準備摘要資料"""
try:
# 計算日期範圍
today = date.today()
if digest_type == 'daily':
start_date = today
end_date = today
period_name = '今日'
elif digest_type == 'weekly':
start_date = today - timedelta(days=today.weekday()) # 週一
end_date = start_date + timedelta(days=6) # 週日
period_name = '本週'
elif digest_type == 'monthly':
start_date = today.replace(day=1)
next_month = today.replace(day=28) + timedelta(days=4)
end_date = next_month - timedelta(days=next_month.day)
period_name = '本月'
else:
raise ValueError(f"Unsupported digest type: {digest_type}")
# 基礎查詢 - 使用者相關的待辦事項
base_query = TodoItem.query.filter(
or_(
TodoItem.creator_ad == user_ad,
TodoItem.responsible_users.any(TodoItemResponsible.ad_account == user_ad),
TodoItem.followers.any(TodoItemFollower.ad_account == user_ad)
)
)
# 統計資料
stats = {
'total_todos': base_query.count(),
'completed_todos': base_query.filter(TodoItem.status == 'DONE').count(),
'doing_todos': base_query.filter(TodoItem.status == 'DOING').count(),
'blocked_todos': base_query.filter(TodoItem.status == 'BLOCKED').count(),
'new_todos': base_query.filter(TodoItem.status == 'NEW').count()
}
# 期間內完成的待辦事項
completed_in_period = base_query.filter(
and_(
TodoItem.status == 'DONE',
func.date(TodoItem.completed_at).between(start_date, end_date)
)
).all()
# 期間內建立的待辦事項
created_in_period = base_query.filter(
func.date(TodoItem.created_at).between(start_date, end_date)
).all()
# 即將到期的待辦事項未來7天
upcoming_due = base_query.filter(
and_(
TodoItem.due_date.between(today, today + timedelta(days=7)),
TodoItem.status != 'DONE'
)
).order_by(TodoItem.due_date).all()
# 逾期的待辦事項
overdue = base_query.filter(
and_(
TodoItem.due_date < today,
TodoItem.status != 'DONE'
)
).order_by(TodoItem.due_date).all()
# 高優先級待辦事項
high_priority = base_query.filter(
and_(
TodoItem.priority == 'HIGH',
TodoItem.status != 'DONE'
)
).all()
# 活動記錄(期間內的操作)
activities = TodoAuditLog.query.filter(
and_(
TodoAuditLog.actor_ad == user_ad,
func.date(TodoAuditLog.created_at).between(start_date, end_date)
)
).order_by(TodoAuditLog.created_at.desc()).limit(10).all()
# 組織摘要資料
digest_data = {
'type': digest_type,
'period_name': period_name,
'start_date': start_date,
'end_date': end_date,
'user_ad': user_ad,
'stats': stats,
'completed_in_period': [todo.to_dict() for todo in completed_in_period],
'created_in_period': [todo.to_dict() for todo in created_in_period],
'upcoming_due': [todo.to_dict() for todo in upcoming_due],
'overdue': [todo.to_dict() for todo in overdue],
'high_priority': [todo.to_dict() for todo in high_priority],
'recent_activities': [
{
'action': activity.action,
'created_at': activity.created_at,
'detail': activity.detail,
'todo_id': activity.todo_id
}
for activity in activities
],
'generated_at': datetime.now()
}
return digest_data
except Exception as e:
logger.error(f"Failed to prepare digest for {user_ad}: {str(e)}")
raise
def should_send_notification(self, user_ad, notification_type):
"""檢查是否應該發送通知"""
try:
user_pref = TodoUserPref.query.filter_by(ad_account=user_ad).first()
if not user_pref:
return False
# 檢查通知開關
if notification_type == 'email_reminder':
return user_pref.email_reminder_enabled
elif notification_type == 'weekly_summary':
return user_pref.weekly_summary_enabled
elif notification_type == 'general':
return user_pref.notification_enabled
return False
except Exception as e:
logger.error(f"Error checking notification settings for {user_ad}: {str(e)}")
return False
def get_users_for_batch_notifications(self, notification_type):
"""取得需要接收批量通知的使用者清單"""
try:
if notification_type == 'weekly_summary':
users = db.session.query(TodoUserPref.ad_account).filter(
TodoUserPref.weekly_summary_enabled == True
).all()
elif notification_type == 'email_reminder':
users = db.session.query(TodoUserPref.ad_account).filter(
TodoUserPref.email_reminder_enabled == True
).all()
else:
users = db.session.query(TodoUserPref.ad_account).filter(
TodoUserPref.notification_enabled == True
).all()
return [user[0] for user in users]
except Exception as e:
logger.error(f"Error getting users for batch notifications: {str(e)}")
return []
def create_notification_summary(self, todos, notification_type):
"""建立通知摘要"""
try:
if notification_type == 'due_tomorrow':
return {
'title': '明日到期提醒',
'description': f'您有 {len(todos)} 項待辦事項將於明日到期',
'todos': [todo.to_dict() for todo in todos]
}
elif notification_type == 'overdue':
return {
'title': '逾期提醒',
'description': f'您有 {len(todos)} 項待辦事項已逾期',
'todos': [todo.to_dict() for todo in todos]
}
else:
return {
'title': '待辦事項提醒',
'description': f'您有 {len(todos)} 項待辦事項需要關注',
'todos': [todo.to_dict() for todo in todos]
}
except Exception as e:
logger.error(f"Error creating notification summary: {str(e)}")
return None

103
docker-compose.yml Normal file
View File

@@ -0,0 +1,103 @@
version: '3.8'
services:
mysql:
image: mysql:8.0
container_name: todo_mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-rootpassword}
MYSQL_DATABASE: ${MYSQL_DATABASE:-todo_system}
MYSQL_USER: ${MYSQL_USER:-todouser}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-todopass}
TZ: Asia/Taipei
ports:
- "${MYSQL_PORT:-3306}:3306"
volumes:
- ./mysql/data:/var/lib/mysql
- ./mysql/init:/docker-entrypoint-initdb.d
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
networks:
- todo_network
redis:
image: redis:7-alpine
container_name: todo_redis
restart: unless-stopped
ports:
- "${REDIS_PORT:-6379}:6379"
volumes:
- ./redis/data:/data
networks:
- todo_network
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: todo_backend
restart: unless-stopped
depends_on:
- mysql
- redis
environment:
- FLASK_ENV=${FLASK_ENV:-development}
- MYSQL_HOST=mysql
- MYSQL_PORT=3306
- MYSQL_DATABASE=${MYSQL_DATABASE:-todo_system}
- MYSQL_USER=${MYSQL_USER:-todouser}
- MYSQL_PASSWORD=${MYSQL_PASSWORD:-todopass}
- REDIS_URL=redis://redis:6379/0
ports:
- "${BACKEND_PORT:-5000}:5000"
volumes:
- ./backend:/app
- ./uploads:/app/uploads
- ./logs:/app/logs
networks:
- todo_network
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: todo_frontend
restart: unless-stopped
depends_on:
- backend
environment:
- NODE_ENV=${NODE_ENV:-development}
- NEXT_PUBLIC_API_URL=${API_URL:-http://localhost:5000}
ports:
- "${FRONTEND_PORT:-3000}:3000"
volumes:
- ./frontend:/app
- /app/node_modules
- /app/.next
networks:
- todo_network
nginx:
image: nginx:alpine
container_name: todo_nginx
restart: unless-stopped
depends_on:
- backend
- frontend
ports:
- "${NGINX_PORT:-80}:80"
- "${NGINX_SSL_PORT:-443}:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
networks:
- todo_network
networks:
todo_network:
driver: bridge
volumes:
mysql_data:
redis_data:

184
frontend/.env.example Normal file
View File

@@ -0,0 +1,184 @@
# Frontend Environment Configuration
# 複製此檔案為 .env.local 並填入實際值
# ===========================================
# 基本設定
# ===========================================
# Next.js 環境模式
NODE_ENV=development
NEXT_PUBLIC_APP_NAME="PANJIT Todo List"
NEXT_PUBLIC_APP_VERSION="1.0.0"
# ===========================================
# 後端 API 設定
# ===========================================
# 後端 API 基本網址
NEXT_PUBLIC_API_URL=http://localhost:5000
NEXT_PUBLIC_BACKEND_URL=http://localhost:5000
# API 版本
NEXT_PUBLIC_API_VERSION=v1
# ===========================================
# 認證設定
# ===========================================
# JWT Token 設定
NEXT_PUBLIC_JWT_EXPIRES_IN=7d
NEXT_PUBLIC_REFRESH_TOKEN_EXPIRES_IN=30d
# AD/LDAP 認證設定 (如果需要前端顯示)
NEXT_PUBLIC_AD_DOMAIN=panjit.com.tw
# ===========================================
# 主題與 UI 設定
# ===========================================
# 預設主題模式 (light | dark | system)
NEXT_PUBLIC_DEFAULT_THEME=system
# 主題顏色設定
NEXT_PUBLIC_PRIMARY_COLOR=#3b82f6
NEXT_PUBLIC_SECONDARY_COLOR=#8b5cf6
# UI 設定
NEXT_PUBLIC_SIDEBAR_DEFAULT_COLLAPSED=false
NEXT_PUBLIC_ANIMATION_ENABLED=true
# ===========================================
# 功能開關
# ===========================================
# 功能啟用設定
NEXT_PUBLIC_CALENDAR_VIEW_ENABLED=true
NEXT_PUBLIC_BATCH_OPERATIONS_ENABLED=true
NEXT_PUBLIC_SEARCH_ENABLED=true
NEXT_PUBLIC_ADVANCED_FILTERS_ENABLED=true
NEXT_PUBLIC_EXCEL_IMPORT_ENABLED=true
# 實驗性功能
NEXT_PUBLIC_EXPERIMENTAL_FEATURES=false
NEXT_PUBLIC_DEBUG_MODE=false
# ===========================================
# 分析與監控
# ===========================================
# Google Analytics (如果需要)
# NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX
# Sentry 錯誤監控 (如果需要)
# NEXT_PUBLIC_SENTRY_DSN=https://your-sentry-dsn
# 效能監控
NEXT_PUBLIC_PERFORMANCE_MONITORING=false
# ===========================================
# 郵件與通知設定
# ===========================================
# 郵件服務設定 (顯示用)
NEXT_PUBLIC_SMTP_ENABLED=true
NEXT_PUBLIC_EMAIL_DOMAIN=panjit.com.tw
# 通知設定
NEXT_PUBLIC_PUSH_NOTIFICATIONS=true
NEXT_PUBLIC_EMAIL_NOTIFICATIONS=true
# ===========================================
# 檔案與媒體設定
# ===========================================
# 檔案上傳設定
NEXT_PUBLIC_MAX_FILE_SIZE=10485760 # 10MB
NEXT_PUBLIC_ALLOWED_FILE_TYPES=.xlsx,.xls,.csv
# 頭像設定
NEXT_PUBLIC_AVATAR_MAX_SIZE=2097152 # 2MB
NEXT_PUBLIC_AVATAR_ALLOWED_TYPES=.jpg,.jpeg,.png,.gif
# ===========================================
# 快取與效能
# ===========================================
# API 快取設定
NEXT_PUBLIC_API_CACHE_ENABLED=true
NEXT_PUBLIC_API_CACHE_DURATION=300000 # 5 minutes
# 靜態資源 CDN (生產環境)
# NEXT_PUBLIC_CDN_URL=https://cdn.example.com
# ===========================================
# 本地化設定
# ===========================================
# 語言設定
NEXT_PUBLIC_DEFAULT_LOCALE=zh-TW
NEXT_PUBLIC_SUPPORTED_LOCALES=zh-TW,zh-CN,en-US
# 時區設定
NEXT_PUBLIC_DEFAULT_TIMEZONE=Asia/Taipei
# 日期格式
NEXT_PUBLIC_DATE_FORMAT=YYYY-MM-DD
NEXT_PUBLIC_DATETIME_FORMAT=YYYY-MM-DD HH:mm
NEXT_PUBLIC_TIME_FORMAT=HH:mm
# ===========================================
# 開發工具設定
# ===========================================
# 開發模式設定
NEXT_PUBLIC_DEV_TOOLS=true
NEXT_PUBLIC_MOCK_API=false
# Redux DevTools
NEXT_PUBLIC_REDUX_DEVTOOLS=true
# React Query DevTools
NEXT_PUBLIC_REACT_QUERY_DEVTOOLS=true
# ===========================================
# 安全設定
# ===========================================
# CORS 設定 (僅供參考,實際由後端控制)
NEXT_PUBLIC_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5000
# CSP 設定提示
NEXT_PUBLIC_CSP_ENABLED=false
# ===========================================
# 部署環境特定設定
# ===========================================
# 生產環境設定
# NODE_ENV=production
# NEXT_PUBLIC_API_URL=https://api.yourdomain.com
# 測試環境設定
# NODE_ENV=staging
# NEXT_PUBLIC_API_URL=https://staging-api.yourdomain.com
# ===========================================
# 範例說明
# ===========================================
# 📝 設定指南:
# 1. 複製此檔案為 .env.local
# 2. 根據您的環境修改對應的值
# 3. 確保 .env.local 已加入 .gitignore
# 4. 生產環境使用不同的 API 網址和金鑰
# 🔒 安全提醒:
# - 請勿將包含敏感資訊的 .env.local 提交到版本控制
# - API 金鑰和密碼應該定期更換
# - 生產環境務必使用 HTTPS
# 🚀 效能優化:
# - 生產環境建議啟用 CDN
# - 根據需求調整快取設定
# - 監控和分析工具可選擇性啟用

184
frontend/.env.local Normal file
View File

@@ -0,0 +1,184 @@
# Frontend Environment Configuration
# 複製此檔案為 .env.local 並填入實際值
# ===========================================
# 基本設定
# ===========================================
# Next.js 環境模式
NODE_ENV=development
NEXT_PUBLIC_APP_NAME="PANJIT Todo List"
NEXT_PUBLIC_APP_VERSION="1.0.0"
# ===========================================
# 後端 API 設定
# ===========================================
# 後端 API 基本網址
NEXT_PUBLIC_API_URL=http://localhost:5000
NEXT_PUBLIC_BACKEND_URL=http://localhost:5000
# API 版本
NEXT_PUBLIC_API_VERSION=v1
# ===========================================
# 認證設定
# ===========================================
# JWT Token 設定
NEXT_PUBLIC_JWT_EXPIRES_IN=7d
NEXT_PUBLIC_REFRESH_TOKEN_EXPIRES_IN=30d
# AD/LDAP 認證設定 (如果需要前端顯示)
NEXT_PUBLIC_AD_DOMAIN=panjit.com.tw
# ===========================================
# 主題與 UI 設定
# ===========================================
# 預設主題模式 (light | dark | system)
NEXT_PUBLIC_DEFAULT_THEME=system
# 主題顏色設定
NEXT_PUBLIC_PRIMARY_COLOR=#3b82f6
NEXT_PUBLIC_SECONDARY_COLOR=#8b5cf6
# UI 設定
NEXT_PUBLIC_SIDEBAR_DEFAULT_COLLAPSED=false
NEXT_PUBLIC_ANIMATION_ENABLED=true
# ===========================================
# 功能開關
# ===========================================
# 功能啟用設定
NEXT_PUBLIC_CALENDAR_VIEW_ENABLED=true
NEXT_PUBLIC_BATCH_OPERATIONS_ENABLED=true
NEXT_PUBLIC_SEARCH_ENABLED=true
NEXT_PUBLIC_ADVANCED_FILTERS_ENABLED=true
NEXT_PUBLIC_EXCEL_IMPORT_ENABLED=true
# 實驗性功能
NEXT_PUBLIC_EXPERIMENTAL_FEATURES=false
NEXT_PUBLIC_DEBUG_MODE=false
# ===========================================
# 分析與監控
# ===========================================
# Google Analytics (如果需要)
# NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX
# Sentry 錯誤監控 (如果需要)
# NEXT_PUBLIC_SENTRY_DSN=https://your-sentry-dsn
# 效能監控
NEXT_PUBLIC_PERFORMANCE_MONITORING=false
# ===========================================
# 郵件與通知設定
# ===========================================
# 郵件服務設定 (顯示用)
NEXT_PUBLIC_SMTP_ENABLED=true
NEXT_PUBLIC_EMAIL_DOMAIN=panjit.com.tw
# 通知設定
NEXT_PUBLIC_PUSH_NOTIFICATIONS=true
NEXT_PUBLIC_EMAIL_NOTIFICATIONS=true
# ===========================================
# 檔案與媒體設定
# ===========================================
# 檔案上傳設定
NEXT_PUBLIC_MAX_FILE_SIZE=10485760 # 10MB
NEXT_PUBLIC_ALLOWED_FILE_TYPES=.xlsx,.xls,.csv
# 頭像設定
NEXT_PUBLIC_AVATAR_MAX_SIZE=2097152 # 2MB
NEXT_PUBLIC_AVATAR_ALLOWED_TYPES=.jpg,.jpeg,.png,.gif
# ===========================================
# 快取與效能
# ===========================================
# API 快取設定
NEXT_PUBLIC_API_CACHE_ENABLED=true
NEXT_PUBLIC_API_CACHE_DURATION=300000 # 5 minutes
# 靜態資源 CDN (生產環境)
# NEXT_PUBLIC_CDN_URL=https://cdn.example.com
# ===========================================
# 本地化設定
# ===========================================
# 語言設定
NEXT_PUBLIC_DEFAULT_LOCALE=zh-TW
NEXT_PUBLIC_SUPPORTED_LOCALES=zh-TW,zh-CN,en-US
# 時區設定
NEXT_PUBLIC_DEFAULT_TIMEZONE=Asia/Taipei
# 日期格式
NEXT_PUBLIC_DATE_FORMAT=YYYY-MM-DD
NEXT_PUBLIC_DATETIME_FORMAT=YYYY-MM-DD HH:mm
NEXT_PUBLIC_TIME_FORMAT=HH:mm
# ===========================================
# 開發工具設定
# ===========================================
# 開發模式設定
NEXT_PUBLIC_DEV_TOOLS=true
NEXT_PUBLIC_MOCK_API=false
# Redux DevTools
NEXT_PUBLIC_REDUX_DEVTOOLS=true
# React Query DevTools
NEXT_PUBLIC_REACT_QUERY_DEVTOOLS=true
# ===========================================
# 安全設定
# ===========================================
# CORS 設定 (僅供參考,實際由後端控制)
NEXT_PUBLIC_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5000
# CSP 設定提示
NEXT_PUBLIC_CSP_ENABLED=false
# ===========================================
# 部署環境特定設定
# ===========================================
# 生產環境設定
# NODE_ENV=production
# NEXT_PUBLIC_API_URL=https://api.yourdomain.com
# 測試環境設定
# NODE_ENV=staging
# NEXT_PUBLIC_API_URL=https://staging-api.yourdomain.com
# ===========================================
# 範例說明
# ===========================================
# 📝 設定指南:
# 1. 複製此檔案為 .env.local
# 2. 根據您的環境修改對應的值
# 3. 確保 .env.local 已加入 .gitignore
# 4. 生產環境使用不同的 API 網址和金鑰
# 🔒 安全提醒:
# - 請勿將包含敏感資訊的 .env.local 提交到版本控制
# - API 金鑰和密碼應該定期更換
# - 生產環境務必使用 HTTPS
# 🚀 效能優化:
# - 生產環境建議啟用 CDN
# - 根據需求調整快取設定
# - 監控和分析工具可選擇性啟用

43
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,43 @@
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json package-lock.json* ./
RUN npm ci --only=production
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build the application
RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Automatically leverage output traces to reduce image size
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
CMD ["node", "server.js"]

5
frontend/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

22
frontend/next.config.js Normal file
View File

@@ -0,0 +1,22 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
output: 'standalone',
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000',
},
async rewrites() {
return [
{
source: '/api/:path*',
destination: `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000'}/api/:path*`,
},
]
},
images: {
domains: ['localhost'],
},
}
module.exports = nextConfig

7808
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

46
frontend/package.json Normal file
View File

@@ -0,0 +1,46 @@
{
"name": "todo-system-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.15.3",
"@mui/material": "^5.15.3",
"@mui/x-date-pickers": "^6.19.0",
"@reduxjs/toolkit": "^2.0.1",
"@tanstack/react-query": "^5.17.9",
"axios": "^1.6.5",
"dayjs": "^1.11.10",
"framer-motion": "^10.18.0",
"next": "14.0.4",
"next-themes": "^0.2.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.48.2",
"react-hot-toast": "^2.4.1",
"react-redux": "^9.0.4",
"recharts": "^2.10.3",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/node": "^20.10.6",
"@types/react": "^18.2.46",
"@types/react-dom": "^18.2.18",
"@typescript-eslint/eslint-plugin": "^6.17.0",
"@typescript-eslint/parser": "^6.17.0",
"autoprefixer": "^10.4.16",
"eslint": "^8.56.0",
"eslint-config-next": "14.0.4",
"postcss": "^8.4.33",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.3"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

@@ -0,0 +1,182 @@
'use client';
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Paper,
Skeleton,
Alert,
} from '@mui/material';
import { motion } from 'framer-motion';
import { useTheme } from '@/providers/ThemeProvider';
import DashboardLayout from '@/components/layout/DashboardLayout';
import CalendarView from '@/components/todos/CalendarView';
import { Todo } from '@/types';
import { todosApi } from '@/lib/api';
const CalendarPage: React.FC = () => {
const { actualTheme } = useTheme();
const [todos, setTodos] = useState<Todo[]>([]);
const [selectedTodos, setSelectedTodos] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchTodos = async () => {
try {
setLoading(true);
setError(null);
const token = localStorage.getItem('access_token');
if (!token) {
setTodos([]);
setLoading(false);
return;
}
const response = await todosApi.getTodos({ view: 'all' });
setTodos(response.todos || []);
} catch (error) {
console.error('Failed to fetch todos:', error);
setError('無法載入待辦事項,請重新整理頁面');
setTodos([]);
} finally {
setLoading(false);
}
};
fetchTodos();
}, []);
const handleSelectionChange = (selected: string[]) => {
setSelectedTodos(selected);
};
const handleEditTodo = (todo: Todo) => {
// TODO: 實作編輯功能,可以開啟編輯對話框或導航到編輯頁面
console.log('Edit todo:', todo);
};
if (loading) {
return (
<DashboardLayout>
<Box>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Box sx={{ mb: 3 }}>
<Typography
variant="h4"
sx={{
fontWeight: 700,
mb: 1,
background: actualTheme === 'dark'
? 'linear-gradient(45deg, #60a5fa 30%, #a78bfa 90%)'
: 'linear-gradient(45deg, #3b82f6 30%, #8b5cf6 90%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}
>
</Typography>
<Typography variant="body1" color="text.secondary">
</Typography>
</Box>
{/* Loading Skeleton */}
<Paper
sx={{
p: 3,
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
border: `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}`,
mb: 3,
}}
>
<Skeleton variant="rectangular" height={60} sx={{ mb: 2, borderRadius: 1 }} />
</Paper>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 1 }}>
{Array.from({ length: 35 }).map((_, index) => (
<Skeleton
key={index}
variant="rectangular"
height={120}
sx={{ borderRadius: 1 }}
/>
))}
</Box>
</motion.div>
</Box>
</DashboardLayout>
);
}
return (
<DashboardLayout>
<Box>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Box sx={{ mb: 3 }}>
<Typography
variant="h4"
sx={{
fontWeight: 700,
mb: 1,
background: actualTheme === 'dark'
? 'linear-gradient(45deg, #60a5fa 30%, #a78bfa 90%)'
: 'linear-gradient(45deg, #3b82f6 30%, #8b5cf6 90%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}
>
</Typography>
<Typography variant="body1" color="text.secondary">
</Typography>
</Box>
{error && (
<Alert
severity="error"
sx={{ mb: 3 }}
onClose={() => setError(null)}
>
{error}
</Alert>
)}
{selectedTodos.length > 0 && (
<Alert
severity="info"
sx={{ mb: 3 }}
onClose={() => setSelectedTodos([])}
>
{selectedTodos.length}
</Alert>
)}
<CalendarView
todos={todos}
selectedTodos={selectedTodos}
onSelectionChange={handleSelectionChange}
onEditTodo={handleEditTodo}
/>
</motion.div>
</Box>
</DashboardLayout>
);
};
export default CalendarPage;

View File

@@ -0,0 +1,544 @@
'use client';
import React, { useState, useEffect } from 'react';
import {
Box,
Grid,
Card,
CardContent,
Typography,
Chip,
Button,
Avatar,
AvatarGroup,
IconButton,
} from '@mui/material';
import {
Assignment,
Schedule,
CheckCircle,
Warning,
Add,
CalendarToday,
Star,
People,
} from '@mui/icons-material';
import { motion } from 'framer-motion';
import { useTheme } from '@/providers/ThemeProvider';
import DashboardLayout from '@/components/layout/DashboardLayout';
import TodoDialog from '@/components/todos/TodoDialog';
import { todosApi } from '@/lib/api';
import { Todo } from '@/types';
const DashboardPage = () => {
const { actualTheme } = useTheme();
const [todos, setTodos] = useState<Todo[]>([]);
const [loading, setLoading] = useState(true);
const [todoDialogOpen, setTodoDialogOpen] = useState(false);
// 從 API 獲取資料
useEffect(() => {
const fetchDashboardData = async () => {
try {
setLoading(true);
// 檢查是否有有效的 token
const token = localStorage.getItem('access_token');
if (!token) {
console.log('No access token found, skipping API call');
setTodos([]);
return;
}
const response = await todosApi.getTodos({ view: 'all' });
setTodos(response.todos || []);
} catch (error) {
console.error('Failed to fetch dashboard data:', error);
// 如果是認證錯誤,清除 token 並跳轉到登入頁
if (error.response?.status === 401) {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user');
window.location.href = '/login';
}
setTodos([]);
} finally {
setLoading(false);
}
};
fetchDashboardData();
}, []);
const handleTodoCreated = async () => {
setTodoDialogOpen(false);
// 重新載入待辦事項資料
try {
console.log('Refreshing dashboard data after todo creation...');
const token = localStorage.getItem('access_token');
if (token) {
const response = await todosApi.getTodos({ view: 'all' });
console.log('Updated todos:', response.todos?.length || 0, 'items');
setTodos(response.todos || []);
}
} catch (error) {
console.error('Failed to refresh dashboard data:', error);
}
};
// 計算統計數據
const stats = {
total: todos.length,
doing: todos.filter(todo => todo.status === 'DOING').length,
completed: todos.filter(todo => todo.status === 'DONE').length,
overdue: todos.filter(todo => {
if (!todo.due_date) return false;
const dueDate = new Date(todo.due_date);
const today = new Date();
today.setHours(0, 0, 0, 0);
return dueDate < today && todo.status !== 'DONE';
}).length,
};
// 最近的待辦事項最多顯示3個
const recentTodos = todos
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.slice(0, 3)
.map(todo => ({
id: todo.id,
title: todo.title,
dueDate: todo.due_date ? new Date(todo.due_date).toLocaleDateString('zh-TW') : '無截止日期',
priority: todo.priority,
status: todo.status,
assignees: (todo.responsible_users_details || todo.responsible_users || []).map(user =>
typeof user === 'string'
? user.substring(0, 1).toUpperCase()
: (user.display_name || user.ad_account).substring(0, 1).toUpperCase()
),
}));
// 即將到期的項目
const upcomingDeadlines = todos
.filter(todo => {
if (!todo.due_date || todo.status === 'DONE') return false;
const dueDate = new Date(todo.due_date);
const today = new Date();
const threeDaysFromNow = new Date();
threeDaysFromNow.setDate(today.getDate() + 3);
return dueDate >= today && dueDate <= threeDaysFromNow;
})
.sort((a, b) => new Date(a.due_date!).getTime() - new Date(b.due_date!).getTime())
.slice(0, 3)
.map(todo => {
const dueDate = new Date(todo.due_date!);
const today = new Date();
const diffTime = dueDate.getTime() - today.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
let dateText = '';
if (diffDays === 0) dateText = '今天';
else if (diffDays === 1) dateText = '明天';
else dateText = dueDate.toLocaleDateString('zh-TW');
return {
title: todo.title,
date: dateText,
urgent: diffDays <= 1,
};
});
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.5 },
},
};
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'URGENT': return '#ef4444';
case 'HIGH': return '#f97316';
case 'MEDIUM': return '#f59e0b';
case 'LOW': return '#6b7280';
default: return '#6b7280';
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'NEW': return '#6b7280';
case 'DOING': return '#3b82f6';
case 'BLOCKED': return '#ef4444';
case 'DONE': return '#10b981';
default: return '#6b7280';
}
};
return (
<DashboardLayout>
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
>
<Box sx={{ mb: 4 }}>
<Typography
variant="h4"
sx={{
fontWeight: 700,
mb: 1,
background: actualTheme === 'dark'
? 'linear-gradient(45deg, #f3f4f6 30%, #d1d5db 90%)'
: 'linear-gradient(45deg, #111827 30%, #374151 90%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}
>
</Typography>
<Typography variant="body1" color="text.secondary">
</Typography>
</Box>
{/* 統計卡片 */}
<Grid container spacing={3} sx={{ mb: 4 }}>
<Grid item xs={12} sm={6} md={3}>
<motion.div variants={itemVariants}>
<Card
sx={{
background: actualTheme === 'dark'
? 'linear-gradient(135deg, #1f2937 0%, #374151 100%)'
: 'linear-gradient(135deg, #ffffff 0%, #f9fafb 100%)',
border: `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}`,
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 8px 25px rgba(0, 0, 0, 0.15)',
},
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography color="text.secondary" gutterBottom>
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700 }}>
{stats.total}
</Typography>
</Box>
<Assignment sx={{ fontSize: 40, color: '#6b7280', opacity: 0.8 }} />
</Box>
</CardContent>
</Card>
</motion.div>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<motion.div variants={itemVariants}>
<Card
sx={{
background: actualTheme === 'dark'
? 'linear-gradient(135deg, #1e3a8a 0%, #3b82f6 100%)'
: 'linear-gradient(135deg, #3b82f6 0%, #60a5fa 100%)',
color: 'white',
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 8px 25px rgba(59, 130, 246, 0.3)',
},
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography sx={{ color: 'rgba(255, 255, 255, 0.8)' }} gutterBottom>
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white' }}>
{stats.doing}
</Typography>
</Box>
<Schedule sx={{ fontSize: 40, color: 'rgba(255, 255, 255, 0.8)' }} />
</Box>
</CardContent>
</Card>
</motion.div>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<motion.div variants={itemVariants}>
<Card
sx={{
background: actualTheme === 'dark'
? 'linear-gradient(135deg, #065f46 0%, #10b981 100%)'
: 'linear-gradient(135deg, #10b981 0%, #34d399 100%)',
color: 'white',
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 8px 25px rgba(16, 185, 129, 0.3)',
},
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography sx={{ color: 'rgba(255, 255, 255, 0.8)' }} gutterBottom>
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white' }}>
{stats.completed}
</Typography>
</Box>
<CheckCircle sx={{ fontSize: 40, color: 'rgba(255, 255, 255, 0.8)' }} />
</Box>
</CardContent>
</Card>
</motion.div>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<motion.div variants={itemVariants}>
<Card
sx={{
background: actualTheme === 'dark'
? 'linear-gradient(135deg, #991b1b 0%, #ef4444 100%)'
: 'linear-gradient(135deg, #ef4444 0%, #f87171 100%)',
color: 'white',
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 8px 25px rgba(239, 68, 68, 0.3)',
},
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography sx={{ color: 'rgba(255, 255, 255, 0.8)' }} gutterBottom>
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white' }}>
{stats.overdue}
</Typography>
</Box>
<Warning sx={{ fontSize: 40, color: 'rgba(255, 255, 255, 0.8)' }} />
</Box>
</CardContent>
</Card>
</motion.div>
</Grid>
</Grid>
{/* 主要內容區域 */}
<Grid container spacing={3}>
{/* 最近待辦 */}
<Grid item xs={12} lg={8}>
<motion.div variants={itemVariants}>
<Card
sx={{
background: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
border: `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}`,
}}
>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
</Typography>
<Button
startIcon={<Add />}
variant="contained"
size="small"
onClick={() => setTodoDialogOpen(true)}
sx={{
background: 'linear-gradient(45deg, #3b82f6 30%, #8b5cf6 90%)',
textTransform: 'none',
}}
>
</Button>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{recentTodos.map((todo, index) => (
<motion.div
key={todo.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.1 }}
>
<Box
sx={{
p: 2,
borderRadius: 2,
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(0, 0, 0, 0.02)',
border: `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}`,
transition: 'all 0.2s ease',
'&:hover': {
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.08)'
: 'rgba(0, 0, 0, 0.04)',
transform: 'translateX(4px)',
},
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, flex: 1 }}>
{todo.title}
</Typography>
<Box sx={{ display: 'flex', gap: 1, ml: 2 }}>
<Chip
label={todo.priority}
size="small"
sx={{
backgroundColor: `${getPriorityColor(todo.priority)}15`,
color: getPriorityColor(todo.priority),
fontWeight: 600,
fontSize: '0.75rem',
}}
/>
<Chip
label={todo.status}
size="small"
sx={{
backgroundColor: `${getStatusColor(todo.status)}15`,
color: getStatusColor(todo.status),
fontWeight: 600,
fontSize: '0.75rem',
}}
/>
</Box>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CalendarToday sx={{ fontSize: 16, color: 'text.secondary' }} />
<Typography variant="body2" color="text.secondary">
{todo.dueDate}
</Typography>
</Box>
<AvatarGroup max={3} sx={{ '& .MuiAvatar-root': { width: 28, height: 28, fontSize: '0.75rem' } }}>
{todo.assignees.map((assignee, idx) => (
<Avatar key={idx} sx={{ backgroundColor: 'primary.main' }}>
{assignee}
</Avatar>
))}
</AvatarGroup>
</Box>
</Box>
</motion.div>
))}
</Box>
</CardContent>
</Card>
</motion.div>
</Grid>
{/* 右側面板 */}
<Grid item xs={12} lg={4}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* 即將到期 */}
<motion.div variants={itemVariants}>
<Card
sx={{
background: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
border: `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}`,
}}
>
<CardContent>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2 }}>
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{upcomingDeadlines.map((item, index) => (
<Box
key={index}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
p: 1.5,
borderRadius: 1.5,
backgroundColor: item.urgent
? (actualTheme === 'dark' ? 'rgba(239, 68, 68, 0.1)' : 'rgba(239, 68, 68, 0.05)')
: (actualTheme === 'dark' ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.02)'),
border: item.urgent
? `1px solid ${actualTheme === 'dark' ? 'rgba(239, 68, 68, 0.3)' : 'rgba(239, 68, 68, 0.2)'}`
: `1px solid transparent`,
}}
>
<Box sx={{ flex: 1 }}>
<Typography
variant="body2"
sx={{
fontWeight: 500,
color: item.urgent ? '#ef4444' : 'text.primary',
}}
>
{item.title}
</Typography>
<Typography
variant="caption"
sx={{
color: item.urgent ? '#ef4444' : 'text.secondary',
}}
>
{item.date}
</Typography>
</Box>
{item.urgent && (
<Warning sx={{ color: '#ef4444', fontSize: 20 }} />
)}
</Box>
))}
</Box>
</CardContent>
</Card>
</motion.div>
</Box>
</Grid>
</Grid>
</motion.div>
{/* 新增待辦對話框 */}
<TodoDialog
open={todoDialogOpen}
onClose={() => setTodoDialogOpen(false)}
onTodoCreated={handleTodoCreated}
/>
</DashboardLayout>
);
};
export default DashboardPage;

View File

@@ -0,0 +1,207 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
}
}
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
@apply bg-gray-100 dark:bg-gray-700;
}
::-webkit-scrollbar-thumb {
@apply bg-gray-400 dark:bg-gray-500 rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-gray-500 dark:bg-gray-400;
}
/* Loading animations */
.loading-skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
.dark .loading-skeleton {
background: linear-gradient(90deg, #374151 25%, #4b5563 50%, #374151 75%);
background-size: 200% 100%;
}
@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* Focus rings */
.focus-ring {
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-800;
}
/* Button variants */
.btn-primary {
@apply bg-primary-600 hover:bg-primary-700 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus-ring;
}
.btn-secondary {
@apply bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus-ring;
}
.btn-ghost {
@apply hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300 font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus-ring;
}
.btn-danger {
@apply bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus-ring;
}
/* Card styles */
.card {
@apply bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6;
}
.card-compact {
@apply bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4;
}
/* Input styles */
.input {
@apply w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus-ring;
}
.input-error {
@apply border-red-500 focus-visible:ring-red-500;
}
/* Status colors */
.status-new {
@apply bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300;
}
.status-doing {
@apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300;
}
.status-blocked {
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300;
}
.status-done {
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300;
}
/* Priority colors */
.priority-low {
@apply bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300;
}
.priority-medium {
@apply bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300;
}
.priority-high {
@apply bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300;
}
.priority-urgent {
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300;
}
/* Animations */
.slide-in-right {
animation: slideInRight 0.3s ease-out;
}
.slide-in-left {
animation: slideInLeft 0.3s ease-out;
}
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideInLeft {
from {
transform: translateX(-100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Custom utilities */
.text-balance {
text-wrap: balance;
}
.truncate-2 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.truncate-3 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
/* Print styles */
@media print {
.no-print {
display: none !important;
}
body {
background: white !important;
color: black !important;
}
.card {
border: 1px solid #ccc !important;
box-shadow: none !important;
}
}

View File

@@ -0,0 +1,40 @@
import type { Metadata, Viewport } from 'next';
import { Inter } from 'next/font/google';
import { Providers } from '@/providers';
import './globals.css';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'PANJIT To-Do System',
description: '專業待辦事項管理系統,支援多負責人協作、智能提醒與進度追蹤',
keywords: ['待辦事項', '任務管理', 'PANJIT', '協作工具'],
authors: [{ name: 'PANJIT IT Team' }],
themeColor: [
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
{ media: '(prefers-color-scheme: dark)', color: '#111827' },
],
};
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-TW" suppressHydrationWarning>
<body className={inter.className}>
<Providers>
{children}
</Providers>
</body>
</html>
);
}

View File

@@ -0,0 +1,358 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import {
Box,
Card,
CardContent,
TextField,
Button,
Typography,
InputAdornment,
IconButton,
Fade,
Container,
Alert,
CircularProgress,
} from '@mui/material';
import {
Visibility,
VisibilityOff,
Person,
Lock,
} from '@mui/icons-material';
import { motion } from 'framer-motion';
import { useAuth } from '@/providers/AuthProvider';
import { useTheme } from '@/providers/ThemeProvider';
const LoginPage = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const { login, isAuthenticated } = useAuth();
const { actualTheme } = useTheme();
const router = useRouter();
// 如果已登入,重定向到儀表板
useEffect(() => {
if (isAuthenticated) {
router.push('/dashboard');
}
}, [isAuthenticated, router]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!username.trim() || !password.trim()) {
setError('請輸入帳號和密碼');
return;
}
setIsLoading(true);
setError('');
try {
const success = await login(username.trim(), password);
if (success) {
router.push('/dashboard');
}
} catch (err) {
setError('登入失敗,請檢查您的帳號密碼');
} finally {
setIsLoading(false);
}
};
const cardVariants = {
hidden: {
opacity: 0,
y: 50,
scale: 0.95
},
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: {
duration: 0.6,
ease: [0.6, -0.05, 0.01, 0.99]
}
}
};
const logoVariants = {
hidden: { opacity: 0, y: -20 },
visible: {
opacity: 1,
y: 0,
transition: {
delay: 0.2,
duration: 0.5
}
}
};
return (
<Box
sx={{
minHeight: '100vh',
background: actualTheme === 'dark'
? 'linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)'
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
p: 2,
position: 'relative',
overflow: 'hidden',
}}
>
{/* 背景裝飾 */}
<Box
sx={{
position: 'absolute',
top: '-50%',
right: '-50%',
width: '200%',
height: '200%',
background: actualTheme === 'dark'
? 'radial-gradient(circle, rgba(96, 165, 250, 0.05) 0%, transparent 70%)'
: 'radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%)',
animation: 'float 20s ease-in-out infinite',
}}
/>
<Container maxWidth="sm">
<motion.div
variants={cardVariants}
initial="hidden"
animate="visible"
>
<Card
elevation={24}
sx={{
backdropFilter: 'blur(20px)',
backgroundColor: actualTheme === 'dark'
? 'rgba(31, 41, 55, 0.8)'
: 'rgba(255, 255, 255, 0.9)',
border: actualTheme === 'dark'
? '1px solid rgba(255, 255, 255, 0.1)'
: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: 4,
overflow: 'hidden',
}}
>
<CardContent sx={{ p: 6 }}>
{/* Logo 區域 */}
<motion.div
variants={logoVariants}
initial="hidden"
animate="visible"
>
<Box sx={{ textAlign: 'center', mb: 4 }}>
<Box
component="img"
src="/panjit-logo.png"
alt="PANJIT Logo"
sx={{
width: 180,
height: 180,
mb: 2,
filter: 'drop-shadow(0 4px 8px rgba(59, 130, 246, 0.3))'
}}
/>
<Typography
variant="h4"
component="h1"
gutterBottom
sx={{
fontWeight: 700,
background: actualTheme === 'dark'
? 'linear-gradient(45deg, #60a5fa 30%, #a78bfa 90%)'
: 'linear-gradient(45deg, #3b82f6 30%, #8b5cf6 90%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}
>
To-Do
</Typography>
<Typography
variant="body1"
color="text.secondary"
sx={{
opacity: 0.8,
fontSize: '1.1rem',
}}
>
</Typography>
</Box>
</motion.div>
{/* 登入表單 */}
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}>
<Fade in={!!error} timeout={300}>
<Box sx={{ mb: 2 }}>
{error && (
<Alert
severity="error"
sx={{
borderRadius: 2,
'& .MuiAlert-message': {
fontSize: '0.9rem'
}
}}
>
{error}
</Alert>
)}
</Box>
</Fade>
<TextField
fullWidth
label="AD 帳號"
value={username}
onChange={(e) => setUsername(e.target.value)}
margin="normal"
disabled={isLoading}
autoComplete="username"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Person color="action" />
</InputAdornment>
),
}}
sx={{
mb: 3,
'& .MuiOutlinedInput-root': {
borderRadius: 2,
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
},
'&.Mui-focused': {
transform: 'translateY(-2px)',
boxShadow: '0 4px 20px rgba(59, 130, 246, 0.2)',
}
}
}}
/>
<TextField
fullWidth
label="密碼"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
margin="normal"
disabled={isLoading}
autoComplete="current-password"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Lock color="action" />
</InputAdornment>
),
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowPassword(!showPassword)}
edge="end"
disabled={isLoading}
size="small"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
sx={{
mb: 4,
'& .MuiOutlinedInput-root': {
borderRadius: 2,
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
},
'&.Mui-focused': {
transform: 'translateY(-2px)',
boxShadow: '0 4px 20px rgba(59, 130, 246, 0.2)',
}
}
}}
/>
<Button
type="submit"
fullWidth
variant="contained"
disabled={isLoading}
sx={{
py: 2,
fontSize: '1.1rem',
fontWeight: 600,
borderRadius: 2,
textTransform: 'none',
background: 'linear-gradient(45deg, #3b82f6 30%, #8b5cf6 90%)',
boxShadow: '0 4px 20px rgba(59, 130, 246, 0.3)',
transition: 'all 0.3s ease',
'&:hover': {
background: 'linear-gradient(45deg, #2563eb 30%, #7c3aed 90%)',
boxShadow: '0 6px 25px rgba(59, 130, 246, 0.4)',
transform: 'translateY(-2px)',
},
'&:disabled': {
background: 'linear-gradient(45deg, #9ca3af 30%, #9ca3af 90%)',
}
}}
>
{isLoading ? (
<>
<CircularProgress size={24} sx={{ mr: 2, color: 'white' }} />
...
</>
) : (
'登入系統'
)}
</Button>
</Box>
{/* 底部資訊 */}
<Box sx={{ mt: 4, textAlign: 'center' }}>
<Typography
variant="caption"
color="text.secondary"
sx={{
opacity: 0.7,
fontSize: '0.8rem'
}}
>
使 AD
</Typography>
</Box>
</CardContent>
</Card>
</motion.div>
</Container>
<style jsx global>{`
@keyframes float {
0%, 100% { transform: translateY(0px) rotate(0deg); }
33% { transform: translateY(-30px) rotate(120deg); }
66% { transform: translateY(-20px) rotate(240deg); }
}
`}</style>
</Box>
);
};
export default LoginPage;

41
frontend/src/app/page.tsx Normal file
View File

@@ -0,0 +1,41 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Box, CircularProgress, Typography } from '@mui/material';
export default function HomePage() {
const router = useRouter();
useEffect(() => {
// 檢查是否已登入
const token = localStorage.getItem('access_token');
if (token) {
// 如果已登入,跳轉到 dashboard
router.replace('/dashboard');
} else {
// 如果未登入,跳轉到登入頁面
router.replace('/login');
}
}, [router]);
// 顯示載入中的畫面
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
gap: 2,
}}
>
<CircularProgress size={60} />
<Typography variant="h6" color="text.secondary">
PANJIT Todo List...
</Typography>
</Box>
);
}

View File

@@ -0,0 +1,764 @@
'use client';
import React, { useState } from 'react';
import {
Box,
Typography,
Card,
CardContent,
Switch,
FormControlLabel,
Button,
Divider,
Avatar,
TextField,
IconButton,
Chip,
Grid,
Paper,
Alert,
Snackbar,
FormControl,
InputLabel,
Select,
MenuItem,
Slider,
} from '@mui/material';
import {
Person,
Palette,
Notifications,
Security,
Language,
Save,
Edit,
PhotoCamera,
DarkMode,
LightMode,
SettingsBrightness,
VolumeUp,
Email,
Sms,
Phone,
Schedule,
Visibility,
Lock,
Key,
Shield,
Refresh,
} from '@mui/icons-material';
import { motion, AnimatePresence } from 'framer-motion';
import { useTheme } from '@/providers/ThemeProvider';
import { useSearchParams } from 'next/navigation';
import DashboardLayout from '@/components/layout/DashboardLayout';
const SettingsPage = () => {
const { themeMode, setThemeMode, actualTheme } = useTheme();
const searchParams = useSearchParams();
const [showSuccess, setShowSuccess] = useState(false);
const [activeTab, setActiveTab] = useState(() => {
const tabParam = searchParams.get('tab');
return tabParam || 'profile';
});
// 用戶設定
const [userSettings, setUserSettings] = useState(() => {
try {
const userStr = localStorage.getItem('user');
if (userStr) {
const user = JSON.parse(userStr);
return {
name: user.display_name || user.ad_account || '',
email: user.email || '',
department: '資訊部',
position: '員工',
phone: '',
bio: '',
avatar: (user.display_name || user.ad_account || 'U').charAt(0).toUpperCase(),
};
}
} catch (error) {
console.error('Failed to parse user from localStorage:', error);
}
return {
name: '',
email: '',
department: '資訊部',
position: '員工',
phone: '',
bio: '',
avatar: 'U',
};
});
// 通知設定
const [notificationSettings, setNotificationSettings] = useState({
emailNotifications: true,
pushNotifications: true,
smsNotifications: false,
todoReminders: true,
deadlineAlerts: true,
weeklyReports: true,
soundEnabled: true,
soundVolume: 70,
});
// 隱私設定
const [privacySettings, setPrivacySettings] = useState({
profileVisibility: 'team',
todoVisibility: 'responsible',
showOnlineStatus: true,
allowDirectMessages: true,
dataSharing: false,
});
// 工作設定
const [workSettings, setWorkSettings] = useState({
timeZone: 'Asia/Taipei',
dateFormat: 'YYYY-MM-DD',
timeFormat: '24h',
workingHours: {
start: '09:00',
end: '18:00',
},
autoRefresh: 30,
defaultView: 'list',
});
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.5 },
},
};
const handleSave = () => {
console.log('Settings saved:', {
user: userSettings,
notifications: notificationSettings,
privacy: privacySettings,
work: workSettings,
});
setShowSuccess(true);
};
const themeOptions = [
{ value: 'light', label: '亮色模式', icon: <LightMode /> },
{ value: 'dark', label: '深色模式', icon: <DarkMode /> },
{ value: 'system', label: '跟隨系統', icon: <SettingsBrightness /> },
];
const tabs = [
{ id: 'profile', label: '個人資料', icon: <Person /> },
{ id: 'appearance', label: '外觀主題', icon: <Palette /> },
{ id: 'notifications', label: '通知設定', icon: <Notifications /> },
{ id: 'privacy', label: '隱私安全', icon: <Security /> },
{ id: 'work', label: '工作偏好', icon: <Schedule /> },
];
const renderProfileSettings = () => (
<motion.div variants={itemVariants}>
<Card sx={{
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
border: `1px solid ${actualTheme === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}`,
}}>
<CardContent sx={{ p: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Person sx={{ color: 'primary.main', fontSize: 24 }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
</Typography>
</Box>
{/* 頭像區域 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 4 }}>
<Box sx={{ position: 'relative' }}>
<Avatar
sx={{
width: 80,
height: 80,
fontSize: '2rem',
fontWeight: 700,
background: 'linear-gradient(45deg, #3b82f6 30%, #8b5cf6 90%)',
}}
>
{userSettings.avatar}
</Avatar>
<IconButton
sx={{
position: 'absolute',
bottom: -5,
right: -5,
backgroundColor: 'primary.main',
color: 'white',
width: 32,
height: 32,
'&:hover': {
backgroundColor: 'primary.dark',
},
}}
>
<PhotoCamera sx={{ fontSize: 16 }} />
</IconButton>
</Box>
<Box>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 0.5 }}>
{userSettings.name}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{userSettings.position} · {userSettings.department}
</Typography>
<Chip
label="已驗證"
color="success"
size="small"
icon={<Shield sx={{ fontSize: 14 }} />}
/>
</Box>
</Box>
<Grid container spacing={3}>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="姓名"
value={userSettings.name}
onChange={(e) => setUserSettings(prev => ({ ...prev, name: e.target.value }))}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(0, 0, 0, 0.02)',
}
}}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="電子信箱"
value={userSettings.email}
onChange={(e) => setUserSettings(prev => ({ ...prev, email: e.target.value }))}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(0, 0, 0, 0.02)',
}
}}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="部門"
value={userSettings.department}
onChange={(e) => setUserSettings(prev => ({ ...prev, department: e.target.value }))}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(0, 0, 0, 0.02)',
}
}}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="職位"
value={userSettings.position}
onChange={(e) => setUserSettings(prev => ({ ...prev, position: e.target.value }))}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(0, 0, 0, 0.02)',
}
}}
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="電話號碼"
value={userSettings.phone}
onChange={(e) => setUserSettings(prev => ({ ...prev, phone: e.target.value }))}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(0, 0, 0, 0.02)',
}
}}
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
multiline
rows={3}
label="個人簡介"
placeholder="簡單介紹一下自己..."
value={userSettings.bio}
onChange={(e) => setUserSettings(prev => ({ ...prev, bio: e.target.value }))}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(0, 0, 0, 0.02)',
}
}}
/>
</Grid>
</Grid>
</CardContent>
</Card>
</motion.div>
);
const renderAppearanceSettings = () => (
<motion.div variants={itemVariants}>
<Card sx={{
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
border: `1px solid ${actualTheme === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}`,
}}>
<CardContent sx={{ p: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Palette sx={{ color: 'primary.main', fontSize: 24 }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
</Typography>
</Box>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
</Typography>
<Grid container spacing={3}>
{themeOptions.map((option) => (
<Grid item xs={12} sm={4} key={option.value}>
<motion.div
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Paper
onClick={() => setThemeMode(option.value as 'light' | 'dark' | 'auto')}
sx={{
p: 3,
cursor: 'pointer',
textAlign: 'center',
backgroundColor: themeMode === option.value
? (actualTheme === 'dark' ? 'rgba(59, 130, 246, 0.2)' : 'rgba(59, 130, 246, 0.1)')
: (actualTheme === 'dark' ? '#374151' : '#f9fafb'),
border: themeMode === option.value
? `2px solid ${actualTheme === 'dark' ? '#60a5fa' : '#3b82f6'}`
: `1px solid ${actualTheme === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}`,
borderRadius: 3,
transition: 'all 0.3s ease',
'&:hover': {
backgroundColor: actualTheme === 'dark'
? 'rgba(59, 130, 246, 0.1)'
: 'rgba(59, 130, 246, 0.05)',
transform: 'translateY(-2px)',
},
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'center',
mb: 2,
color: themeMode === option.value ? 'primary.main' : 'text.secondary',
'& svg': {
fontSize: 40,
},
}}
>
{option.icon}
</Box>
<Typography
variant="h6"
sx={{
fontWeight: 600,
color: themeMode === option.value ? 'primary.main' : 'text.primary',
mb: 1,
}}
>
{option.label}
</Typography>
{themeMode === option.value && (
<Chip
label="已選擇"
color="primary"
size="small"
sx={{ fontWeight: 600 }}
/>
)}
</Paper>
</motion.div>
</Grid>
))}
</Grid>
{/* 預覽區域 */}
<Box sx={{ mt: 4 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
</Typography>
<Paper
sx={{
p: 3,
backgroundColor: actualTheme === 'dark' ? '#374151' : '#f8fafc',
border: `1px solid ${actualTheme === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}`,
borderRadius: 2,
}}
>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 600 }}>
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<Chip label="進行中" color="primary" size="small" />
<Chip label="高優先級" color="error" size="small" />
<Typography variant="body2" color="text.secondary">
2024-01-15
</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
{themeMode === 'light' ? '亮色' : themeMode === 'dark' ? '深色' : '系統'}
</Typography>
</Paper>
</Box>
</CardContent>
</Card>
</motion.div>
);
const renderNotificationSettings = () => (
<motion.div variants={itemVariants}>
<Card sx={{
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
border: `1px solid ${actualTheme === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}`,
}}>
<CardContent sx={{ p: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Notifications sx={{ color: 'primary.main', fontSize: 24 }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
</Typography>
</Box>
<Grid container spacing={3}>
<Grid item xs={12}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2, color: 'text.secondary' }}>
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<FormControlLabel
control={
<Switch
checked={notificationSettings.emailNotifications}
onChange={(e) => setNotificationSettings(prev => ({ ...prev, emailNotifications: e.target.checked }))}
color="primary"
/>
}
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Email sx={{ fontSize: 18, color: 'text.secondary' }} />
</Box>
}
/>
<FormControlLabel
control={
<Switch
checked={notificationSettings.pushNotifications}
onChange={(e) => setNotificationSettings(prev => ({ ...prev, pushNotifications: e.target.checked }))}
color="primary"
/>
}
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Notifications sx={{ fontSize: 18, color: 'text.secondary' }} />
</Box>
}
/>
<FormControlLabel
control={
<Switch
checked={notificationSettings.smsNotifications}
onChange={(e) => setNotificationSettings(prev => ({ ...prev, smsNotifications: e.target.checked }))}
color="primary"
/>
}
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Sms sx={{ fontSize: 18, color: 'text.secondary' }} />
</Box>
}
/>
</Box>
</Grid>
<Grid item xs={12}>
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2, color: 'text.secondary' }}>
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<FormControlLabel
control={
<Switch
checked={notificationSettings.todoReminders}
onChange={(e) => setNotificationSettings(prev => ({ ...prev, todoReminders: e.target.checked }))}
color="primary"
/>
}
label="待辦事項提醒"
/>
<FormControlLabel
control={
<Switch
checked={notificationSettings.deadlineAlerts}
onChange={(e) => setNotificationSettings(prev => ({ ...prev, deadlineAlerts: e.target.checked }))}
color="primary"
/>
}
label="截止日期警告"
/>
<FormControlLabel
control={
<Switch
checked={notificationSettings.weeklyReports}
onChange={(e) => setNotificationSettings(prev => ({ ...prev, weeklyReports: e.target.checked }))}
color="primary"
/>
}
label="每週報告"
/>
</Box>
</Grid>
<Grid item xs={12}>
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2, color: 'text.secondary' }}>
</Typography>
<Box sx={{ mb: 2 }}>
<FormControlLabel
control={
<Switch
checked={notificationSettings.soundEnabled}
onChange={(e) => setNotificationSettings(prev => ({ ...prev, soundEnabled: e.target.checked }))}
color="primary"
/>
}
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<VolumeUp sx={{ fontSize: 18, color: 'text.secondary' }} />
</Box>
}
/>
</Box>
{notificationSettings.soundEnabled && (
<Box sx={{ px: 2, mb: 2 }}>
<Typography variant="caption" color="text.secondary" gutterBottom>
: {notificationSettings.soundVolume}%
</Typography>
<Slider
value={notificationSettings.soundVolume}
onChange={(_, value) => setNotificationSettings(prev => ({ ...prev, soundVolume: value as number }))}
min={0}
max={100}
step={10}
marks
sx={{ color: 'primary.main' }}
/>
</Box>
)}
</Grid>
</Grid>
</CardContent>
</Card>
</motion.div>
);
return (
<DashboardLayout>
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
>
{/* 標題區域 */}
<Box sx={{ mb: 3 }}>
<motion.div variants={itemVariants}>
<Typography
variant="h4"
sx={{
fontWeight: 700,
mb: 0.5,
background: actualTheme === 'dark'
? 'linear-gradient(45deg, #f3f4f6 30%, #d1d5db 90%)'
: 'linear-gradient(45deg, #111827 30%, #374151 90%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}
>
</Typography>
<Typography variant="body1" color="text.secondary">
</Typography>
</motion.div>
</Box>
<Grid container spacing={3}>
{/* 側邊欄 */}
<Grid item xs={12} md={3}>
<motion.div variants={itemVariants}>
<Card sx={{
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
border: `1px solid ${actualTheme === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}`,
}}>
<Box sx={{ p: 2 }}>
{tabs.map((tab) => (
<motion.div
key={tab.id}
whileHover={{ x: 4 }}
whileTap={{ scale: 0.98 }}
>
<Button
fullWidth
onClick={() => setActiveTab(tab.id)}
startIcon={tab.icon}
sx={{
justifyContent: 'flex-start',
textTransform: 'none',
fontWeight: activeTab === tab.id ? 600 : 400,
color: activeTab === tab.id ? 'primary.main' : 'text.primary',
backgroundColor: activeTab === tab.id
? (actualTheme === 'dark' ? 'rgba(59, 130, 246, 0.1)' : 'rgba(59, 130, 246, 0.1)')
: 'transparent',
borderRadius: 2,
mb: 1,
py: 1.5,
px: 2,
'&:hover': {
backgroundColor: actualTheme === 'dark'
? 'rgba(59, 130, 246, 0.1)'
: 'rgba(59, 130, 246, 0.05)',
},
}}
>
{tab.label}
</Button>
</motion.div>
))}
</Box>
</Card>
</motion.div>
</Grid>
{/* 主要內容 */}
<Grid item xs={12} md={9}>
<AnimatePresence mode="wait">
<motion.div
key={activeTab}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
>
{activeTab === 'profile' && renderProfileSettings()}
{activeTab === 'appearance' && renderAppearanceSettings()}
{activeTab === 'notifications' && renderNotificationSettings()}
{/* 其他 tab 內容可以在這裡添加 */}
</motion.div>
</AnimatePresence>
{/* 儲存按鈕 */}
<motion.div variants={itemVariants} style={{ marginTop: 24 }}>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2 }}>
<Button
variant="outlined"
startIcon={<Refresh />}
sx={{
borderRadius: 2,
textTransform: 'none',
fontWeight: 600,
px: 3,
}}
>
</Button>
<Button
variant="contained"
startIcon={<Save />}
onClick={handleSave}
sx={{
background: 'linear-gradient(45deg, #3b82f6 30%, #8b5cf6 90%)',
borderRadius: 2,
textTransform: 'none',
fontWeight: 600,
px: 3,
'&:hover': {
background: 'linear-gradient(45deg, #2563eb 30%, #7c3aed 90%)',
boxShadow: '0 4px 12px rgba(59, 130, 246, 0.3)',
},
}}
>
</Button>
</Box>
</motion.div>
</Grid>
</Grid>
{/* 成功通知 */}
<Snackbar
open={showSuccess}
autoHideDuration={3000}
onClose={() => setShowSuccess(false)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
severity="success"
onClose={() => setShowSuccess(false)}
sx={{
borderRadius: 2,
fontWeight: 600,
}}
>
</Alert>
</Snackbar>
</motion.div>
</DashboardLayout>
);
};
export default SettingsPage;

View File

@@ -0,0 +1,611 @@
'use client';
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Button,
IconButton,
Toolbar,
Tooltip,
Fade,
Chip,
Card,
} from '@mui/material';
import {
Add,
ViewList,
CalendarViewMonth,
FilterList,
Sort,
Search,
SelectAll,
MoreVert,
} from '@mui/icons-material';
import { motion, AnimatePresence } from 'framer-motion';
import { useTheme } from '@/providers/ThemeProvider';
import DashboardLayout from '@/components/layout/DashboardLayout';
import TodoList from '@/components/todos/TodoList';
import CalendarView from '@/components/todos/CalendarView';
import TodoFilters from '@/components/todos/TodoFilters';
import BatchActions from '@/components/todos/BatchActions';
import SearchBar from '@/components/todos/SearchBar';
import TodoDialog from '@/components/todos/TodoDialog';
import { Todo } from '@/types';
import { todosApi } from '@/lib/api';
type ViewMode = 'list' | 'calendar';
type FilterMode = 'all' | 'created' | 'responsible' | 'following';
const TodosPage = () => {
const { actualTheme } = useTheme();
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [filterMode, setFilterMode] = useState<FilterMode>('all');
const [showFilters, setShowFilters] = useState(false);
const [showSearch, setShowSearch] = useState(false);
const [selectedTodos, setSelectedTodos] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [showTodoDialog, setShowTodoDialog] = useState(false);
const [editingTodo, setEditingTodo] = useState<any>(null);
const [todos, setTodos] = useState<Todo[]>([]);
const [loading, setLoading] = useState(true);
const [currentUser, setCurrentUser] = useState<any>(null);
// 從 API 獲取資料
useEffect(() => {
const fetchTodos = async () => {
try {
setLoading(true);
// 檢查是否有有效的 token
const token = localStorage.getItem('access_token');
if (!token) {
console.log('No access token found, skipping API call');
setTodos([]);
return;
}
// 獲取當前用戶信息
try {
const userResponse = await fetch('http://localhost:5000/api/auth/me', {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (userResponse.ok) {
const userData = await userResponse.json();
setCurrentUser(userData);
}
} catch (userError) {
console.warn('Failed to fetch user data:', userError);
}
// 獲取待辦事項
const response = await todosApi.getTodos({
view: filterMode === 'all' ? 'all' : filterMode
});
setTodos(response.todos || []);
} catch (error) {
console.error('Failed to fetch todos:', error);
// 如果是認證錯誤,清除 token 並跳轉到登入頁
if (error.response?.status === 401) {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user');
window.location.href = '/login';
}
setTodos([]);
} finally {
setLoading(false);
}
};
fetchTodos();
}, [filterMode]);
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.5 },
},
};
const filteredTodos = todos.filter(todo => {
// 搜尋過濾
if (searchQuery) {
const query = searchQuery.toLowerCase();
if (!todo.title.toLowerCase().includes(query) &&
!todo.description?.toLowerCase().includes(query)) {
return false;
}
}
// 視圖過濾
if (currentUser) {
switch (filterMode) {
case 'created':
return todo.creator_ad === currentUser.ad_account;
case 'responsible':
return todo.responsible_users?.includes(currentUser.ad_account) || false;
case 'following':
return todo.followers?.includes(currentUser.ad_account) || false;
default:
return true;
}
}
return true;
});
const getFilterModeLabel = (mode: FilterMode) => {
switch (mode) {
case 'created': return '我建立的';
case 'responsible': return '指派給我';
case 'following': return '我追蹤的';
default: return '所有待辦';
}
};
const handleSelectAll = () => {
if (selectedTodos.length === filteredTodos.length) {
setSelectedTodos([]);
} else {
setSelectedTodos(filteredTodos.map(todo => todo.id));
}
};
const handleCreateTodo = () => {
setEditingTodo(null);
setShowTodoDialog(true);
};
const handleEditTodo = (todo: any) => {
setEditingTodo(todo);
setShowTodoDialog(true);
};
const handleSaveTodo = (todoData: any) => {
console.log('Saving todo:', todoData);
// 這裡會調用 API 來儲存待辦事項
// 儲存成功後可以更新 todos 列表
};
const handleCloseTodoDialog = () => {
setShowTodoDialog(false);
setEditingTodo(null);
};
const handleTodoCreated = async () => {
// 刷新待辦事項列表
try {
const response = await todosApi.getTodos({
view: filterMode === 'all' ? 'all' : filterMode
});
setTodos(response.todos || []);
} catch (error) {
console.error('Failed to refresh todos:', error);
}
};
// 批次操作處理函數
const handleBulkStatusChange = async (status: 'NEW' | 'DOING' | 'BLOCKED') => {
try {
if (selectedTodos.length === 0) return;
// 使用批次更新 API
await todosApi.batchUpdateTodos(selectedTodos, { status });
// 更新本地狀態
setTodos(prevTodos =>
prevTodos.map(todo =>
selectedTodos.includes(todo.id)
? { ...todo, status }
: todo
)
);
// 清除選擇
setSelectedTodos([]);
console.log(`批次更新 ${selectedTodos.length} 個待辦事項狀態為 ${status}`);
} catch (error) {
console.error('批次狀態更新失敗:', error);
}
};
const handleBulkComplete = async () => {
try {
if (selectedTodos.length === 0) return;
// 使用批次更新 API 設為完成
await todosApi.batchUpdateTodos(selectedTodos, { status: 'DONE' });
// 更新本地狀態
setTodos(prevTodos =>
prevTodos.map(todo =>
selectedTodos.includes(todo.id)
? { ...todo, status: 'DONE', completed_at: new Date().toISOString() }
: todo
)
);
// 清除選擇
setSelectedTodos([]);
console.log(`批次完成 ${selectedTodos.length} 個待辦事項`);
} catch (error) {
console.error('批次完成失敗:', error);
}
};
const handleBulkDelete = async () => {
try {
if (selectedTodos.length === 0) return;
if (!confirm(`確定要刪除 ${selectedTodos.length} 個待辦事項嗎?此操作無法復原。`)) {
return;
}
// 逐一刪除待辦事項(如果沒有批次刪除 API
for (const todoId of selectedTodos) {
await todosApi.deleteTodo(todoId);
}
// 從本地狀態中移除
setTodos(prevTodos =>
prevTodos.filter(todo => !selectedTodos.includes(todo.id))
);
// 清除選擇
setSelectedTodos([]);
console.log(`批次刪除 ${selectedTodos.length} 個待辦事項`);
} catch (error) {
console.error('批次刪除失敗:', error);
}
};
// 單個待辦事項狀態變更處理函數
const handleStatusChange = async (todoId: string, status: string) => {
try {
// 使用 API 更新單個待辦事項的狀態
await todosApi.updateTodo(todoId, { status });
// 更新本地狀態
setTodos(prevTodos =>
prevTodos.map(todo =>
todo.id === todoId
? {
...todo,
status,
completed_at: status === 'DONE' ? new Date().toISOString() : null
}
: todo
)
);
console.log(`待辦事項 ${todoId} 狀態已更新為 ${status}`);
} catch (error) {
console.error('狀態更新失敗:', error);
}
};
return (
<DashboardLayout>
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
>
{/* 標題區域 */}
<Box sx={{ mb: 3 }}>
<motion.div variants={itemVariants}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Box>
<Typography
variant="h4"
sx={{
fontWeight: 700,
mb: 0.5,
background: actualTheme === 'dark'
? 'linear-gradient(45deg, #f3f4f6 30%, #d1d5db 90%)'
: 'linear-gradient(45deg, #111827 30%, #374151 90%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}
>
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Typography variant="body1" color="text.secondary">
{getFilterModeLabel(filterMode)} · {filteredTodos.length}
</Typography>
{selectedTodos.length > 0 && (
<Chip
label={`已選擇 ${selectedTodos.length}`}
color="primary"
size="small"
sx={{ fontWeight: 600 }}
/>
)}
</Box>
</Box>
<Button
variant="contained"
startIcon={<Add />}
onClick={handleCreateTodo}
sx={{
background: 'linear-gradient(45deg, #3b82f6 30%, #8b5cf6 90%)',
textTransform: 'none',
fontWeight: 600,
px: 3,
py: 1.5,
borderRadius: 2,
boxShadow: '0 4px 12px rgba(59, 130, 246, 0.3)',
'&:hover': {
background: 'linear-gradient(45deg, #2563eb 30%, #7c3aed 90%)',
boxShadow: '0 6px 16px rgba(59, 130, 246, 0.4)',
transform: 'translateY(-1px)',
},
}}
>
</Button>
</Box>
</motion.div>
</Box>
{/* 工具列 */}
<motion.div variants={itemVariants}>
<Card
sx={{
mb: 3,
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
border: `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}`,
}}
>
<Toolbar
sx={{
display: 'flex',
justifyContent: 'space-between',
gap: 2,
px: { xs: 2, sm: 3 },
py: 1,
}}
>
{/* 左側工具 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{/* 視圖切換 */}
<Box
sx={{
display: 'flex',
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(0, 0, 0, 0.04)',
borderRadius: 1.5,
p: 0.5,
}}
>
<Tooltip title="清單視圖">
<IconButton
size="small"
onClick={() => setViewMode('list')}
sx={{
backgroundColor: viewMode === 'list' ? 'primary.main' : 'transparent',
color: viewMode === 'list' ? 'white' : 'text.secondary',
'&:hover': {
backgroundColor: viewMode === 'list' ? 'primary.dark' : 'action.hover',
},
}}
>
<ViewList fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="日曆視圖">
<IconButton
size="small"
onClick={() => setViewMode('calendar')}
sx={{
backgroundColor: viewMode === 'calendar' ? 'primary.main' : 'transparent',
color: viewMode === 'calendar' ? 'white' : 'text.secondary',
'&:hover': {
backgroundColor: viewMode === 'calendar' ? 'primary.dark' : 'action.hover',
},
}}
>
<CalendarViewMonth fontSize="small" />
</IconButton>
</Tooltip>
</Box>
{/* 篩選器切換 */}
<Box sx={{ display: 'flex', gap: 0.5 }}>
{(['all', 'created', 'responsible', 'following'] as FilterMode[]).map((mode) => (
<Chip
key={mode}
label={getFilterModeLabel(mode)}
variant={filterMode === mode ? 'filled' : 'outlined'}
color={filterMode === mode ? 'primary' : 'default'}
size="small"
clickable
onClick={() => setFilterMode(mode)}
sx={{
fontSize: '0.75rem',
fontWeight: filterMode === mode ? 600 : 400,
'&:hover': {
transform: 'translateY(-1px)',
},
}}
/>
))}
</Box>
</Box>
{/* 右側工具 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Tooltip title="搜尋">
<IconButton
size="small"
onClick={() => setShowSearch(!showSearch)}
sx={{
color: showSearch ? 'primary.main' : 'text.secondary',
backgroundColor: showSearch
? (actualTheme === 'dark' ? 'rgba(59, 130, 246, 0.1)' : 'rgba(59, 130, 246, 0.1)')
: 'transparent',
}}
>
<Search fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="篩選">
<IconButton
size="small"
onClick={() => setShowFilters(!showFilters)}
sx={{
color: showFilters ? 'primary.main' : 'text.secondary',
backgroundColor: showFilters
? (actualTheme === 'dark' ? 'rgba(59, 130, 246, 0.1)' : 'rgba(59, 130, 246, 0.1)')
: 'transparent',
}}
>
<FilterList fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="排序">
<IconButton size="small" sx={{ color: 'text.secondary' }}>
<Sort fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="全選">
<IconButton
size="small"
onClick={handleSelectAll}
sx={{
color: selectedTodos.length > 0 ? 'primary.main' : 'text.secondary',
}}
>
<SelectAll fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="更多選項">
<IconButton size="small" sx={{ color: 'text.secondary' }}>
<MoreVert fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Toolbar>
</Card>
</motion.div>
{/* 搜尋列 */}
<AnimatePresence>
{showSearch && (
<motion.div
initial={{ opacity: 0, height: 0, marginBottom: 0 }}
animate={{ opacity: 1, height: 'auto', marginBottom: 24 }}
exit={{ opacity: 0, height: 0, marginBottom: 0 }}
transition={{ duration: 0.3 }}
>
<SearchBar
value={searchQuery}
onChange={setSearchQuery}
onClose={() => setShowSearch(false)}
/>
</motion.div>
)}
</AnimatePresence>
{/* 進階篩選 */}
<AnimatePresence>
{showFilters && (
<motion.div
initial={{ opacity: 0, height: 0, marginBottom: 0 }}
animate={{ opacity: 1, height: 'auto', marginBottom: 24 }}
exit={{ opacity: 0, height: 0, marginBottom: 0 }}
transition={{ duration: 0.3 }}
>
<TodoFilters onClose={() => setShowFilters(false)} />
</motion.div>
)}
</AnimatePresence>
{/* 批次操作工具列 */}
<AnimatePresence>
{selectedTodos.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.3 }}
>
<BatchActions
selectedCount={selectedTodos.length}
onClearSelection={() => setSelectedTodos([])}
onBulkStatusChange={handleBulkStatusChange}
onBulkComplete={handleBulkComplete}
onBulkDelete={handleBulkDelete}
/>
</motion.div>
)}
</AnimatePresence>
{/* 主要內容區域 */}
<motion.div variants={itemVariants}>
<Fade in={true} timeout={500}>
<Box>
{viewMode === 'list' ? (
<TodoList
todos={filteredTodos}
selectedTodos={selectedTodos}
onSelectionChange={setSelectedTodos}
viewMode={viewMode}
onEditTodo={handleEditTodo}
onStatusChange={handleStatusChange}
/>
) : (
<CalendarView
todos={filteredTodos}
selectedTodos={selectedTodos}
onSelectionChange={setSelectedTodos}
onEditTodo={handleEditTodo}
/>
)}
</Box>
</Fade>
</motion.div>
{/* 新增/編輯待辦對話框 */}
<TodoDialog
open={showTodoDialog}
onClose={handleCloseTodoDialog}
todo={editingTodo}
mode={editingTodo ? 'edit' : 'create'}
onSave={handleSaveTodo}
onTodoCreated={handleTodoCreated}
/>
</motion.div>
</DashboardLayout>
);
};
export default TodosPage;

316
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,316 @@
import axios, { AxiosResponse, AxiosError } from 'axios';
import { toast } from 'react-hot-toast';
import {
Todo,
TodoCreate,
TodoUpdate,
TodoFilter,
TodosResponse,
User,
UserPreferences,
LdapUser,
LoginRequest,
LoginResponse,
FireEmailRequest,
FireEmailQuota,
ImportJob,
} from '@/types';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000';
// Create axios instance
const api = axios.create({
baseURL: API_BASE_URL,
timeout: 30000,
});
// Request interceptor to add auth token
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor for error handling
api.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as any;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const refreshToken = localStorage.getItem('refresh_token');
if (refreshToken) {
const response = await api.post('/api/auth/refresh', {}, {
headers: { Authorization: `Bearer ${refreshToken}` },
});
const { access_token } = response.data;
localStorage.setItem('access_token', access_token);
// Retry original request (mark it to skip toast on failure)
originalRequest._isRetry = true;
return api(originalRequest);
}
} catch (refreshError) {
// Refresh failed, redirect to login
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user');
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
// Show error toast (skip for retry requests to avoid duplicates)
if (!originalRequest._isRetry) {
const errorData = (error as any).response?.data;
const status = (error as any).response?.status;
let errorMessage = 'An error occurred';
if (errorData?.message) {
errorMessage = errorData.message;
} else if (errorData?.error) {
errorMessage = errorData.error;
} else if ((error as any).message) {
errorMessage = (error as any).message;
}
// Special handling for database connection errors
if (status === 503) {
toast.error(errorMessage, {
duration: 5000,
style: {
backgroundColor: '#fef3c7',
color: '#92400e',
},
});
} else if (status === 504) {
toast.error(errorMessage, {
duration: 4000,
style: {
backgroundColor: '#fee2e2',
color: '#991b1b',
},
});
} else if (status !== 401) {
toast.error(errorMessage);
}
}
return Promise.reject(error);
}
);
// Auth API
export const authApi = {
login: async (credentials: LoginRequest): Promise<LoginResponse> => {
const response = await api.post('/api/auth/login', credentials);
return response.data;
},
logout: async (): Promise<void> => {
await api.post('/api/auth/logout');
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user');
},
getCurrentUser: async (): Promise<User> => {
const response = await api.get('/api/auth/me');
return response.data;
},
validateToken: async (): Promise<boolean> => {
try {
await api.get('/api/auth/validate');
return true;
} catch {
return false;
}
},
};
// Todos API
export const todosApi = {
getTodos: async (filter: TodoFilter & { page?: number; per_page?: number }): Promise<TodosResponse> => {
const params = new URLSearchParams();
Object.entries(filter).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
params.append(key, value.toString());
}
});
const response = await api.get(`/api/todos?${params.toString()}`);
return response.data;
},
getTodo: async (id: string): Promise<Todo> => {
const response = await api.get(`/api/todos/${id}`);
return response.data;
},
createTodo: async (todo: TodoCreate): Promise<Todo> => {
const response = await api.post('/api/todos', todo);
return response.data;
},
updateTodo: async (id: string, updates: Partial<TodoUpdate>): Promise<Todo> => {
const response = await api.patch(`/api/todos/${id}`, updates);
return response.data;
},
deleteTodo: async (id: string): Promise<void> => {
await api.delete(`/api/todos/${id}`);
},
batchUpdateTodos: async (todoIds: string[], updates: Partial<TodoUpdate>): Promise<{ updated: number; errors: any[] }> => {
const response = await api.patch('/api/todos/batch', {
todo_ids: todoIds,
updates,
});
return response.data;
},
fireEmail: async (request: FireEmailRequest): Promise<void> => {
await api.post('/api/notifications/fire-email', {
todo_id: request.todo_id,
message: request.note,
});
},
};
// Users API
export const usersApi = {
searchUsers: async (query: string): Promise<LdapUser[]> => {
const response = await api.get(`/api/users/search?q=${encodeURIComponent(query)}`);
return response.data.users;
},
getPreferences: async (): Promise<UserPreferences> => {
const response = await api.get('/api/users/preferences');
return response.data;
},
updatePreferences: async (preferences: Partial<UserPreferences>): Promise<UserPreferences> => {
const response = await api.patch('/api/users/preferences', preferences);
return response.data;
},
getFireEmailQuota: async (): Promise<FireEmailQuota> => {
const response = await api.get('/api/users/fire-email-quota');
return response.data;
},
};
// Import API
export const importApi = {
downloadTemplate: async (): Promise<Blob> => {
const response = await api.get('/api/imports/template', {
responseType: 'blob',
});
return response.data;
},
uploadFile: async (file: File): Promise<ImportJob> => {
const formData = new FormData();
formData.append('file', file);
const response = await api.post('/api/imports', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
},
getImportJob: async (jobId: string): Promise<ImportJob> => {
const response = await api.get(`/api/imports/${jobId}`);
return response.data;
},
downloadErrors: async (jobId: string): Promise<Blob> => {
const response = await api.get(`/api/imports/${jobId}/errors`, {
responseType: 'blob',
});
return response.data;
},
};
// Admin API (if needed)
export const adminApi = {
getStats: async (days: number = 30): Promise<any> => {
const response = await api.get(`/api/admin/stats?days=${days}`);
return response.data;
},
getAuditLogs: async (params: any): Promise<any> => {
const queryParams = new URLSearchParams(params).toString();
const response = await api.get(`/api/admin/audit-logs?${queryParams}`);
return response.data;
},
getMailLogs: async (params: any): Promise<any> => {
const queryParams = new URLSearchParams(params).toString();
const response = await api.get(`/api/admin/mail-logs?${queryParams}`);
return response.data;
},
};
// Notifications API
export const notificationsApi = {
getSettings: async (): Promise<any> => {
const response = await api.get('/api/notifications/settings');
return response.data;
},
updateSettings: async (settings: any): Promise<any> => {
const response = await api.patch('/api/notifications/settings', settings);
return response.data;
},
sendTestEmail: async (recipientEmail?: string): Promise<void> => {
await api.post('/api/notifications/test', recipientEmail ? { recipient_email: recipientEmail } : {});
},
sendDigest: async (type: 'weekly' | 'monthly' = 'weekly'): Promise<void> => {
await api.post('/api/notifications/digest', { type });
},
markNotificationRead: async (notificationId: string): Promise<void> => {
await api.post('/api/notifications/mark-read', { notification_id: notificationId });
},
markAllNotificationsRead: async (): Promise<void> => {
await api.post('/api/notifications/mark-all-read');
},
getNotifications: async (): Promise<any> => {
const response = await api.get('/api/notifications/');
return response.data;
},
};
// Health API
export const healthApi = {
check: async (): Promise<any> => {
const response = await api.get('/api/health/healthz');
return response.data;
},
readiness: async (): Promise<any> => {
const response = await api.get('/api/health/readiness');
return response.data;
},
};
export default api;

210
frontend/src/lib/theme.ts Normal file
View File

@@ -0,0 +1,210 @@
import { createTheme, ThemeOptions } from '@mui/material/styles';
const getDesignTokens = (mode: 'light' | 'dark'): ThemeOptions => ({
palette: {
mode,
...(mode === 'light'
? {
// Light mode colors
primary: {
main: '#3b82f6',
light: '#60a5fa',
dark: '#2563eb',
contrastText: '#ffffff',
},
secondary: {
main: '#8b5cf6',
light: '#a78bfa',
dark: '#7c3aed',
contrastText: '#ffffff',
},
error: {
main: '#ef4444',
light: '#f87171',
dark: '#dc2626',
},
warning: {
main: '#f59e0b',
light: '#fbbf24',
dark: '#d97706',
},
info: {
main: '#06b6d4',
light: '#22d3ee',
dark: '#0891b2',
},
success: {
main: '#10b981',
light: '#34d399',
dark: '#059669',
},
background: {
default: '#ffffff',
paper: '#f9fafb',
},
text: {
primary: '#111827',
secondary: '#4b5563',
disabled: '#9ca3af',
},
divider: '#e5e7eb',
}
: {
// Dark mode colors
primary: {
main: '#60a5fa',
light: '#93c5fd',
dark: '#3b82f6',
contrastText: '#111827',
},
secondary: {
main: '#a78bfa',
light: '#c4b5fd',
dark: '#8b5cf6',
contrastText: '#111827',
},
error: {
main: '#f87171',
light: '#fca5a5',
dark: '#ef4444',
},
warning: {
main: '#fbbf24',
light: '#fcd34d',
dark: '#f59e0b',
},
info: {
main: '#22d3ee',
light: '#67e8f9',
dark: '#06b6d4',
},
success: {
main: '#34d399',
light: '#6ee7b7',
dark: '#10b981',
},
background: {
default: '#111827',
paper: '#1f2937',
},
text: {
primary: '#f3f4f6',
secondary: '#d1d5db',
disabled: '#6b7280',
},
divider: '#374151',
}),
},
typography: {
fontFamily: [
'-apple-system',
'BlinkMacSystemFont',
'"Segoe UI"',
'Roboto',
'"Helvetica Neue"',
'Arial',
'sans-serif',
'"Apple Color Emoji"',
'"Segoe UI Emoji"',
'"Segoe UI Symbol"',
].join(','),
h1: {
fontSize: '2.5rem',
fontWeight: 700,
lineHeight: 1.2,
},
h2: {
fontSize: '2rem',
fontWeight: 600,
lineHeight: 1.3,
},
h3: {
fontSize: '1.75rem',
fontWeight: 600,
lineHeight: 1.4,
},
h4: {
fontSize: '1.5rem',
fontWeight: 600,
lineHeight: 1.4,
},
h5: {
fontSize: '1.25rem',
fontWeight: 600,
lineHeight: 1.5,
},
h6: {
fontSize: '1rem',
fontWeight: 600,
lineHeight: 1.5,
},
},
shape: {
borderRadius: 8,
},
components: {
MuiButton: {
styleOverrides: {
root: {
textTransform: 'none',
borderRadius: '0.5rem',
fontWeight: 500,
},
contained: {
boxShadow: 'none',
'&:hover': {
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
},
},
},
},
MuiPaper: {
styleOverrides: {
root: {
backgroundImage: 'none',
},
rounded: {
borderRadius: '0.75rem',
},
elevation1: {
boxShadow: mode === 'light'
? '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)'
: '0 1px 3px 0 rgb(0 0 0 / 0.3), 0 1px 2px -1px rgb(0 0 0 / 0.3)',
},
},
},
MuiCard: {
styleOverrides: {
root: {
borderRadius: '0.75rem',
boxShadow: mode === 'light'
? '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)'
: '0 1px 3px 0 rgb(0 0 0 / 0.3), 0 1px 2px -1px rgb(0 0 0 / 0.3)',
},
},
},
MuiChip: {
styleOverrides: {
root: {
borderRadius: '0.375rem',
},
},
},
MuiTextField: {
styleOverrides: {
root: {
'& .MuiOutlinedInput-root': {
borderRadius: '0.5rem',
},
},
},
},
},
});
export const createAppTheme = (mode: 'light' | 'dark') => {
return createTheme(getDesignTokens(mode));
};
export const lightTheme = createAppTheme('light');
export const darkTheme = createAppTheme('dark');

View File

@@ -0,0 +1,180 @@
'use client';
import React, { createContext, useContext, useEffect, useState } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { authApi } from '@/lib/api';
import { User, AuthState } from '@/types';
import { toast } from 'react-hot-toast';
interface AuthContextType extends AuthState {
login: (username: string, password: string) => Promise<boolean>;
logout: () => Promise<void>;
refreshAuth: () => Promise<void>;
isLoading: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
interface AuthProviderProps {
children: React.ReactNode;
}
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [authState, setAuthState] = useState<AuthState>({
isAuthenticated: false,
user: null,
token: null,
refreshToken: null,
});
const [isLoading, setIsLoading] = useState(true);
const router = useRouter();
const pathname = usePathname();
// Public routes that don't require authentication
const publicRoutes = ['/login', '/'];
useEffect(() => {
initializeAuth();
}, []);
useEffect(() => {
// Redirect logic
if (!isLoading) {
if (!authState.isAuthenticated && !publicRoutes.includes(pathname)) {
router.push('/login');
} else if (authState.isAuthenticated && pathname === '/login') {
router.push('/dashboard');
}
}
}, [authState.isAuthenticated, pathname, isLoading, router]);
const initializeAuth = async () => {
try {
const token = localStorage.getItem('access_token');
const refreshToken = localStorage.getItem('refresh_token');
const userStr = localStorage.getItem('user');
if (token && refreshToken && userStr) {
const user = JSON.parse(userStr);
// Validate token
const isValid = await authApi.validateToken();
if (isValid) {
setAuthState({
isAuthenticated: true,
user,
token,
refreshToken,
});
} else {
// Token invalid, clear storage
clearAuthData();
}
}
} catch (error) {
console.error('Auth initialization error:', error);
clearAuthData();
} finally {
setIsLoading(false);
}
};
const login = async (username: string, password: string): Promise<boolean> => {
try {
setIsLoading(true);
const response = await authApi.login({ username, password });
// Store auth data
localStorage.setItem('access_token', response.access_token);
localStorage.setItem('refresh_token', response.refresh_token);
localStorage.setItem('user', JSON.stringify(response.user));
setAuthState({
isAuthenticated: true,
user: response.user,
token: response.access_token,
refreshToken: response.refresh_token,
});
toast.success(`歡迎,${response.user.display_name}`);
return true;
} catch (error: any) {
console.error('Login error:', error);
let errorMessage = '登入失敗';
if (error.response?.status === 401) {
errorMessage = '帳號或密碼錯誤';
} else if (error.response?.data?.error) {
errorMessage = error.response.data.error;
}
toast.error(errorMessage);
return false;
} finally {
setIsLoading(false);
}
};
const logout = async (): Promise<void> => {
try {
await authApi.logout();
} catch (error) {
console.error('Logout error:', error);
} finally {
clearAuthData();
toast.success('已登出');
}
};
const refreshAuth = async (): Promise<void> => {
try {
const user = await authApi.getCurrentUser();
setAuthState(prev => ({
...prev,
user,
}));
// Update user in localStorage
localStorage.setItem('user', JSON.stringify(user));
} catch (error) {
console.error('Refresh auth error:', error);
}
};
const clearAuthData = () => {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user');
setAuthState({
isAuthenticated: false,
user: null,
token: null,
refreshToken: null,
});
};
const contextValue: AuthContextType = {
...authState,
login,
logout,
refreshAuth,
isLoading,
};
return (
<AuthContext.Provider value={contextValue}>
{children}
</AuthContext.Provider>
);
};

View File

@@ -0,0 +1,98 @@
'use client';
import React, { createContext, useContext, useEffect, useState } from 'react';
import { ThemeProvider as MuiThemeProvider, CssBaseline } from '@mui/material';
import { createAppTheme } from '@/lib/theme';
type ThemeMode = 'light' | 'dark' | 'auto';
interface ThemeContextType {
themeMode: ThemeMode;
actualTheme: 'light' | 'dark';
setThemeMode: (mode: ThemeMode) => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};
interface ThemeProviderProps {
children: React.ReactNode;
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const [themeMode, setThemeMode] = useState<ThemeMode>('auto');
const [actualTheme, setActualTheme] = useState<'light' | 'dark'>('light');
useEffect(() => {
// Load saved theme preference
const savedTheme = localStorage.getItem('themeMode') as ThemeMode | null;
if (savedTheme) {
setThemeMode(savedTheme);
}
}, []);
useEffect(() => {
const updateActualTheme = () => {
if (themeMode === 'auto') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
setActualTheme(prefersDark ? 'dark' : 'light');
} else {
setActualTheme(themeMode as 'light' | 'dark');
}
};
updateActualTheme();
// Listen for system theme changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => {
if (themeMode === 'auto') {
updateActualTheme();
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [themeMode]);
useEffect(() => {
// Update document class for Tailwind
if (actualTheme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [actualTheme]);
const handleSetThemeMode = (mode: ThemeMode) => {
setThemeMode(mode);
localStorage.setItem('themeMode', mode);
};
const theme = React.useMemo(
() => createAppTheme(actualTheme),
[actualTheme]
);
return (
<ThemeContext.Provider
value={{
themeMode,
actualTheme,
setThemeMode: handleSetThemeMode,
}}
>
<MuiThemeProvider theme={theme}>
<CssBaseline />
{children}
</MuiThemeProvider>
</ThemeContext.Provider>
);
};

View File

@@ -0,0 +1,91 @@
'use client';
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Provider as ReduxProvider } from 'react-redux';
import { Toaster } from 'react-hot-toast';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import { ThemeProvider } from './ThemeProvider';
import { AuthProvider } from './AuthProvider';
import { store } from '@/store';
import 'dayjs/locale/zh-tw';
// Create a client
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error: any) => {
// Don't retry on 401/403 errors
if (error?.response?.status === 401 || error?.response?.status === 403) {
return false;
}
return failureCount < 3;
},
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes (formerly cacheTime)
},
mutations: {
retry: 1,
},
},
});
interface ProvidersProps {
children: React.ReactNode;
}
export const Providers: React.FC<ProvidersProps> = ({ children }) => {
return (
<ReduxProvider store={store}>
<QueryClientProvider client={queryClient}>
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-tw">
<ThemeProvider>
<AuthProvider>
{children}
<Toaster
position="top-right"
toastOptions={{
duration: 4000,
className: 'text-sm',
style: {
borderRadius: '0.5rem',
background: 'var(--toast-bg)',
color: 'var(--toast-text)',
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
},
success: {
style: {
background: '#10b981',
color: '#ffffff',
},
iconTheme: {
primary: '#ffffff',
secondary: '#10b981',
},
},
error: {
style: {
background: '#ef4444',
color: '#ffffff',
},
iconTheme: {
primary: '#ffffff',
secondary: '#ef4444',
},
},
loading: {
style: {
background: '#3b82f6',
color: '#ffffff',
},
},
}}
/>
</AuthProvider>
</ThemeProvider>
</LocalizationProvider>
</QueryClientProvider>
</ReduxProvider>
);
};

View File

@@ -0,0 +1,21 @@
import { configureStore } from '@reduxjs/toolkit';
import authReducer from './slices/authSlice';
import todosReducer from './slices/todosSlice';
import uiReducer from './slices/uiSlice';
export const store = configureStore({
reducer: {
auth: authReducer,
todos: todosReducer,
ui: uiReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: ['persist/PERSIST', 'persist/REHYDRATE'],
},
}),
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

View File

@@ -0,0 +1,37 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { User } from '@/types';
interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
}
const initialState: AuthState = {
user: null,
token: null,
isAuthenticated: false,
};
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
setAuth: (state, action: PayloadAction<{ user: User; token: string }>) => {
state.user = action.payload.user;
state.token = action.payload.token;
state.isAuthenticated = true;
},
updateUser: (state, action: PayloadAction<User>) => {
state.user = action.payload;
},
clearAuth: (state) => {
state.user = null;
state.token = null;
state.isAuthenticated = false;
},
},
});
export const { setAuth, updateUser, clearAuth } = authSlice.actions;
export default authSlice.reducer;

View File

@@ -0,0 +1,119 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { Todo, TodoFilter, ViewType } from '@/types';
interface TodosState {
todos: Todo[];
selectedTodos: string[];
filter: TodoFilter;
viewType: ViewType;
sortField: 'created_at' | 'due_date' | 'priority' | 'title';
sortOrder: 'asc' | 'desc';
isLoading: boolean;
pagination: {
page: number;
per_page: number;
total: number;
pages: number;
};
}
const initialState: TodosState = {
todos: [],
selectedTodos: [],
filter: {
view: 'all',
},
viewType: 'list',
sortField: 'created_at',
sortOrder: 'desc',
isLoading: false,
pagination: {
page: 1,
per_page: 20,
total: 0,
pages: 0,
},
};
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
setTodos: (state, action: PayloadAction<Todo[]>) => {
state.todos = action.payload;
},
addTodo: (state, action: PayloadAction<Todo>) => {
state.todos.unshift(action.payload);
},
updateTodo: (state, action: PayloadAction<Todo>) => {
const index = state.todos.findIndex(todo => todo.id === action.payload.id);
if (index !== -1) {
state.todos[index] = action.payload;
}
},
removeTodo: (state, action: PayloadAction<string>) => {
state.todos = state.todos.filter(todo => todo.id !== action.payload);
},
setSelectedTodos: (state, action: PayloadAction<string[]>) => {
state.selectedTodos = action.payload;
},
toggleTodoSelection: (state, action: PayloadAction<string>) => {
const todoId = action.payload;
if (state.selectedTodos.includes(todoId)) {
state.selectedTodos = state.selectedTodos.filter(id => id !== todoId);
} else {
state.selectedTodos.push(todoId);
}
},
selectAllTodos: (state) => {
state.selectedTodos = state.todos.map(todo => todo.id);
},
clearSelectedTodos: (state) => {
state.selectedTodos = [];
},
setFilter: (state, action: PayloadAction<TodoFilter>) => {
state.filter = { ...state.filter, ...action.payload };
state.pagination.page = 1; // Reset to first page when filter changes
},
clearFilter: (state) => {
state.filter = { view: 'all' };
state.pagination.page = 1;
},
setViewType: (state, action: PayloadAction<ViewType>) => {
state.viewType = action.payload;
},
setSorting: (state, action: PayloadAction<{ field: string; order: 'asc' | 'desc' }>) => {
state.sortField = action.payload.field as any;
state.sortOrder = action.payload.order;
},
setLoading: (state, action: PayloadAction<boolean>) => {
state.isLoading = action.payload;
},
setPagination: (state, action: PayloadAction<Partial<TodosState['pagination']>>) => {
state.pagination = { ...state.pagination, ...action.payload };
},
setPage: (state, action: PayloadAction<number>) => {
state.pagination.page = action.payload;
},
},
});
export const {
setTodos,
addTodo,
updateTodo,
removeTodo,
setSelectedTodos,
toggleTodoSelection,
selectAllTodos,
clearSelectedTodos,
setFilter,
clearFilter,
setViewType,
setSorting,
setLoading,
setPagination,
setPage,
} = todosSlice.actions;
export default todosSlice.reducer;

View File

@@ -0,0 +1,168 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface UIState {
sidebarOpen: boolean;
sidebarCollapsed: boolean;
searchOpen: boolean;
filterPanelOpen: boolean;
createTodoDialogOpen: boolean;
editTodoDialogOpen: boolean;
deleteTodoDialogOpen: boolean;
batchActionsOpen: boolean;
settingsDialogOpen: boolean;
aiChatOpen: boolean;
importDialogOpen: boolean;
currentEditingTodo: string | null;
currentDeletingTodos: string[];
notifications: Array<{
id: string;
message: string;
type: 'info' | 'success' | 'warning' | 'error';
timestamp: number;
read: boolean;
}>;
isOnline: boolean;
lastSync: string | null;
}
const initialState: UIState = {
sidebarOpen: true,
sidebarCollapsed: false,
searchOpen: false,
filterPanelOpen: false,
createTodoDialogOpen: false,
editTodoDialogOpen: false,
deleteTodoDialogOpen: false,
batchActionsOpen: false,
settingsDialogOpen: false,
aiChatOpen: false,
importDialogOpen: false,
currentEditingTodo: null,
currentDeletingTodos: [],
notifications: [],
isOnline: true,
lastSync: null,
};
const uiSlice = createSlice({
name: 'ui',
initialState,
reducers: {
toggleSidebar: (state) => {
state.sidebarOpen = !state.sidebarOpen;
},
setSidebarOpen: (state, action: PayloadAction<boolean>) => {
state.sidebarOpen = action.payload;
},
toggleSidebarCollapsed: (state) => {
state.sidebarCollapsed = !state.sidebarCollapsed;
},
setSidebarCollapsed: (state, action: PayloadAction<boolean>) => {
state.sidebarCollapsed = action.payload;
},
setSearchOpen: (state, action: PayloadAction<boolean>) => {
state.searchOpen = action.payload;
},
setFilterPanelOpen: (state, action: PayloadAction<boolean>) => {
state.filterPanelOpen = action.payload;
},
setCreateTodoDialogOpen: (state, action: PayloadAction<boolean>) => {
state.createTodoDialogOpen = action.payload;
},
setEditTodoDialogOpen: (state, action: PayloadAction<boolean>) => {
state.editTodoDialogOpen = action.payload;
},
setDeleteTodoDialogOpen: (state, action: PayloadAction<boolean>) => {
state.deleteTodoDialogOpen = action.payload;
},
setBatchActionsOpen: (state, action: PayloadAction<boolean>) => {
state.batchActionsOpen = action.payload;
},
setSettingsDialogOpen: (state, action: PayloadAction<boolean>) => {
state.settingsDialogOpen = action.payload;
},
setAiChatOpen: (state, action: PayloadAction<boolean>) => {
state.aiChatOpen = action.payload;
},
setImportDialogOpen: (state, action: PayloadAction<boolean>) => {
state.importDialogOpen = action.payload;
},
setCurrentEditingTodo: (state, action: PayloadAction<string | null>) => {
state.currentEditingTodo = action.payload;
},
setCurrentDeletingTodos: (state, action: PayloadAction<string[]>) => {
state.currentDeletingTodos = action.payload;
},
addNotification: (state, action: PayloadAction<Omit<UIState['notifications'][0], 'id' | 'timestamp' | 'read'>>) => {
const notification = {
...action.payload,
id: Date.now().toString(),
timestamp: Date.now(),
read: false,
};
state.notifications.unshift(notification);
},
markNotificationAsRead: (state, action: PayloadAction<string>) => {
const notification = state.notifications.find(n => n.id === action.payload);
if (notification) {
notification.read = true;
}
},
markAllNotificationsAsRead: (state) => {
state.notifications.forEach(n => n.read = true);
},
removeNotification: (state, action: PayloadAction<string>) => {
state.notifications = state.notifications.filter(n => n.id !== action.payload);
},
clearNotifications: (state) => {
state.notifications = [];
},
setOnlineStatus: (state, action: PayloadAction<boolean>) => {
state.isOnline = action.payload;
},
setLastSync: (state, action: PayloadAction<string>) => {
state.lastSync = action.payload;
},
closeAllDialogs: (state) => {
state.createTodoDialogOpen = false;
state.editTodoDialogOpen = false;
state.deleteTodoDialogOpen = false;
state.settingsDialogOpen = false;
state.aiChatOpen = false;
state.importDialogOpen = false;
state.filterPanelOpen = false;
state.searchOpen = false;
state.batchActionsOpen = false;
state.currentEditingTodo = null;
state.currentDeletingTodos = [];
},
},
});
export const {
toggleSidebar,
setSidebarOpen,
toggleSidebarCollapsed,
setSidebarCollapsed,
setSearchOpen,
setFilterPanelOpen,
setCreateTodoDialogOpen,
setEditTodoDialogOpen,
setDeleteTodoDialogOpen,
setBatchActionsOpen,
setSettingsDialogOpen,
setAiChatOpen,
setImportDialogOpen,
setCurrentEditingTodo,
setCurrentDeletingTodos,
addNotification,
markNotificationAsRead,
markAllNotificationsAsRead,
removeNotification,
clearNotifications,
setOnlineStatus,
setLastSync,
closeAllDialogs,
} = uiSlice.actions;
export default uiSlice.reducer;

181
frontend/src/types/index.ts Normal file
View File

@@ -0,0 +1,181 @@
// User Detail Types
export interface UserDetail {
ad_account: string;
display_name: string;
email: string;
}
// Todo Types
export interface Todo {
id: string;
title: string;
description?: string;
status: 'NEW' | 'DOING' | 'BLOCKED' | 'DONE';
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
due_date?: string;
created_at: string;
completed_at?: string;
creator_ad: string;
creator_display_name?: string;
creator_email?: string;
starred: boolean;
responsible_users: string[];
followers: string[];
responsible_users_details?: UserDetail[];
followers_details?: UserDetail[];
}
export interface TodoCreate {
title: string;
description?: string;
status?: 'NEW' | 'DOING' | 'BLOCKED' | 'DONE';
priority?: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
due_date?: string;
starred?: boolean;
responsible_users?: string[];
followers?: string[];
}
export interface TodoUpdate extends Partial<TodoCreate> {
id: string;
}
export interface TodoFilter {
status?: string;
priority?: string;
starred?: boolean;
due_from?: string;
due_to?: string;
search?: string;
view?: 'all' | 'created' | 'responsible' | 'following';
}
// User Types
export interface User {
ad_account: string;
display_name: string;
email: string;
theme?: 'light' | 'dark' | 'auto';
language?: string;
}
export interface UserPreferences {
ad_account: string;
email: string;
display_name: string;
theme: 'light' | 'dark' | 'auto';
language: string;
timezone: string;
notification_enabled: boolean;
email_reminder_enabled: boolean;
weekly_summary_enabled: boolean;
}
export interface LdapUser {
ad_account: string;
display_name: string;
email: string;
}
// Auth Types
export interface LoginRequest {
username: string;
password: string;
}
export interface LoginResponse {
access_token: string;
refresh_token: string;
user: User;
}
export interface AuthState {
isAuthenticated: boolean;
user: User | null;
token: string | null;
refreshToken: string | null;
}
// API Response Types
export interface ApiResponse<T = any> {
data?: T;
error?: string;
message?: string;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
per_page: number;
pages: number;
}
export interface TodosResponse {
todos: Todo[];
total: number;
page: number;
per_page: number;
pages: number;
}
// Fire Email Types
export interface FireEmailRequest {
todo_id: string;
recipients?: string[];
note?: string;
}
export interface FireEmailQuota {
used: number;
limit: number;
remaining: number;
}
// Import Types
export interface ImportJob {
id: string;
actor_ad: string;
filename: string;
total_rows: number;
success_rows: number;
failed_rows: number;
status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED';
error_file_path?: string;
error_details?: any;
created_at: string;
completed_at?: string;
}
// Theme Types
export type ThemeMode = 'light' | 'dark' | 'auto';
// Utility Types
export type ViewType = 'list' | 'calendar';
export type SortField = 'created_at' | 'due_date' | 'priority' | 'title';
export type SortOrder = 'asc' | 'desc';
// Component Props Types
export interface BaseComponentProps {
className?: string;
children?: React.ReactNode;
}
// Status and Priority Options
export const TODO_STATUSES = ['NEW', 'DOING', 'BLOCKED', 'DONE'] as const;
export const TODO_PRIORITIES = ['LOW', 'MEDIUM', 'HIGH', 'URGENT'] as const;
export const STATUS_COLORS = {
NEW: '#6b7280',
DOING: '#3b82f6',
BLOCKED: '#ef4444',
DONE: '#10b981',
} as const;
export const PRIORITY_COLORS = {
LOW: '#6b7280',
MEDIUM: '#f59e0b',
HIGH: '#f97316',
URGENT: '#ef4444',
} as const;

View File

@@ -0,0 +1,69 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'class',
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
dark: {
bg: '#111827',
card: '#1f2937',
hover: '#374151',
border: '#4b5563',
text: {
primary: '#f3f4f6',
secondary: '#d1d5db',
muted: '#9ca3af',
},
},
light: {
bg: '#ffffff',
card: '#f9fafb',
hover: '#f3f4f6',
border: '#e5e7eb',
text: {
primary: '#111827',
secondary: '#4b5563',
muted: '#6b7280',
},
},
},
animation: {
'fade-in': 'fadeIn 0.5s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
'slide-down': 'slideDown 0.3s ease-out',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
slideDown: {
'0%': { transform: 'translateY(-10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
},
},
},
plugins: [],
}

34
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"],
"@/components/*": ["./src/components/*"],
"@/lib/*": ["./src/lib/*"],
"@/hooks/*": ["./src/hooks/*"],
"@/store/*": ["./src/store/*"],
"@/types/*": ["./src/types/*"],
"@/styles/*": ["./src/styles/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

131
mysql/init/01-init.sql Normal file
View File

@@ -0,0 +1,131 @@
-- Create database if not exists
CREATE DATABASE IF NOT EXISTS todo_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE todo_system;
-- Table: todo_item
CREATE TABLE IF NOT EXISTS todo_item (
id CHAR(36) PRIMARY KEY,
title VARCHAR(200) NOT NULL,
description TEXT,
status ENUM('NEW', 'DOING', 'BLOCKED', 'DONE') DEFAULT 'NEW',
priority ENUM('LOW', 'MEDIUM', 'HIGH', 'URGENT') DEFAULT 'MEDIUM',
due_date DATE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed_at DATETIME,
creator_ad VARCHAR(128) NOT NULL,
creator_display_name VARCHAR(128),
creator_email VARCHAR(256),
starred TINYINT(1) DEFAULT 0,
INDEX idx_status (status),
INDEX idx_priority (priority),
INDEX idx_due_date (due_date),
INDEX idx_creator_ad (creator_ad),
INDEX idx_starred (starred),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Table: todo_item_responsible
CREATE TABLE IF NOT EXISTS todo_item_responsible (
todo_id CHAR(36) NOT NULL,
ad_account VARCHAR(128) NOT NULL,
added_by VARCHAR(128),
added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (todo_id, ad_account),
FOREIGN KEY (todo_id) REFERENCES todo_item(id) ON DELETE CASCADE,
INDEX idx_ad_account (ad_account)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Table: todo_item_follower
CREATE TABLE IF NOT EXISTS todo_item_follower (
todo_id CHAR(36) NOT NULL,
ad_account VARCHAR(128) NOT NULL,
added_by VARCHAR(128),
added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (todo_id, ad_account),
FOREIGN KEY (todo_id) REFERENCES todo_item(id) ON DELETE CASCADE,
INDEX idx_ad_account (ad_account)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Table: todo_mail_log
CREATE TABLE IF NOT EXISTS todo_mail_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
todo_id CHAR(36),
type ENUM('SCHEDULED', 'FIRE') NOT NULL,
triggered_by_ad VARCHAR(128),
recipients TEXT,
subject VARCHAR(255),
status ENUM('QUEUED', 'SENT', 'FAILED') DEFAULT 'QUEUED',
provider_msg_id VARCHAR(128),
error_text TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
sent_at DATETIME,
FOREIGN KEY (todo_id) REFERENCES todo_item(id) ON DELETE CASCADE,
INDEX idx_todo_id (todo_id),
INDEX idx_type (type),
INDEX idx_status (status),
INDEX idx_triggered_by (triggered_by_ad),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Table: todo_audit_log
CREATE TABLE IF NOT EXISTS todo_audit_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
actor_ad VARCHAR(128) NOT NULL,
todo_id CHAR(36),
action ENUM('CREATE', 'UPDATE', 'DELETE', 'COMPLETE', 'IMPORT', 'MAIL_SENT', 'MAIL_FAIL') NOT NULL,
detail JSON,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (todo_id) REFERENCES todo_item(id) ON DELETE SET NULL,
INDEX idx_actor_ad (actor_ad),
INDEX idx_todo_id (todo_id),
INDEX idx_action (action),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Table: todo_user_pref
CREATE TABLE IF NOT EXISTS todo_user_pref (
ad_account VARCHAR(128) PRIMARY KEY,
email VARCHAR(256),
display_name VARCHAR(128),
theme ENUM('light', 'dark', 'auto') DEFAULT 'auto',
language VARCHAR(10) DEFAULT 'zh-TW',
timezone VARCHAR(50) DEFAULT 'Asia/Taipei',
notification_enabled TINYINT(1) DEFAULT 1,
email_reminder_enabled TINYINT(1) DEFAULT 1,
weekly_summary_enabled TINYINT(1) DEFAULT 1,
fire_email_today_count INT DEFAULT 0,
fire_email_last_reset DATE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_email (email),
INDEX idx_updated_at (updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Table: todo_import_job
CREATE TABLE IF NOT EXISTS todo_import_job (
id CHAR(36) PRIMARY KEY,
actor_ad VARCHAR(128) NOT NULL,
filename VARCHAR(255),
total_rows INT DEFAULT 0,
success_rows INT DEFAULT 0,
failed_rows INT DEFAULT 0,
status ENUM('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED') DEFAULT 'PENDING',
error_file_path VARCHAR(500),
error_details JSON,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed_at DATETIME,
INDEX idx_actor_ad (actor_ad),
INDEX idx_status (status),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Table: todo_fire_email_log
CREATE TABLE IF NOT EXISTS todo_fire_email_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
todo_id CHAR(36) NOT NULL,
sender_ad VARCHAR(128) NOT NULL,
sent_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (todo_id) REFERENCES todo_item(id) ON DELETE CASCADE,
INDEX idx_todo_sender_time (todo_id, sender_ad, sent_at),
INDEX idx_sender_time (sender_ad, sent_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

Binary file not shown.