diff --git a/.claude/settings.local.json b/.claude/settings.local.json
deleted file mode 100644
index 432cf03..0000000
--- a/.claude/settings.local.json
+++ /dev/null
@@ -1,42 +0,0 @@
-{
- "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\")",
- "mcp__puppeteer__puppeteer_connect_active_tab",
- "Bash(start chrome:*)",
- "Bash(taskkill:*)",
- "Bash(TASKKILL:*)",
- "Bash(wmic process where:*)",
- "Bash(\"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe\" --remote-debugging-port=9222 --user-data-dir=\"%TEMP%\\chrome-debug\" http://localhost:3000/todos)",
- "Bash(timeout:*)",
- "Bash(ping:*)",
- "mcp__puppeteer__puppeteer_navigate",
- "mcp__puppeteer__puppeteer_screenshot",
- "mcp__puppeteer__puppeteer_fill",
- "mcp__puppeteer__puppeteer_click",
- "mcp__puppeteer__puppeteer_evaluate",
- "Bash(tree:*)"
- ],
- "deny": [],
- "ask": []
- }
-}
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 8bb7adc..722177b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,3 @@
-# --- 敏感資訊 (Sensitive Information) ---
-# 忽略包含所有密鑰和資料庫連線資訊的環境變數檔案。
-
# --- Python 相關 (Python Related) ---
# 忽略虛擬環境目錄。
.venv/
@@ -29,7 +26,6 @@ node_modules/
.next/
.swc/
-
# --- 作業系統相關 (Operating System) ---
# 忽略 macOS 的系統檔案。
.DS_Store
@@ -42,4 +38,32 @@ Thumbs.db
*.log
logs/
+# --- 臨時文件 ---
nul
+temp/
+tmp/
+
+# --- 測試相關 ---
+.pytest_cache/
+.coverage
+coverage.xml
+htmlcov/
+
+# --- 資料庫相關 ---
+*.db
+*.sqlite
+*.sqlite3
+
+# --- 前端相關 ---
+.next/
+out/
+build/
+dist/
+*.tsbuildinfo
+.eslintcache
+
+# 注意:根據需求,我們允許 .env 文件上傳(因為是私有倉庫)
+# 如果這是公開倉庫,請取消以下注釋:
+# .env
+# .env.local
+# .env.production
\ No newline at end of file
diff --git a/BEST_PRACTICES.md b/BEST_PRACTICES.md
deleted file mode 100644
index 0680090..0000000
--- a/BEST_PRACTICES.md
+++ /dev/null
@@ -1,1089 +0,0 @@
-# PANJIT To-Do System - 開發者最佳實踐指南
-
-> **⚠️ 重要提醒**:本文件包含敏感的系統配置和最佳實踐資訊,僅供開發團隊內部使用。
-> 此文件已在 .gitignore 中排除,請勿提交至版本控制系統。
-
-## 🎯 文件目的
-
-本文件記錄在開發 PANJIT To-Do System 過程中遇到的技術難點及最佳解決方案,包含前後端整合、資料庫設計、LDAP整合、郵件系統等關鍵技術決策,避免後續開發者重複踩坑。
-
----
-
-## 🔐 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"""
-
-
-
-
-
-
-
-
-
-
- """
- 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/
-```
-
----
-
----
-
-## 🎨 前端開發最佳實踐
-
-### 1. Next.js + TypeScript 架構
-
-**關鍵發現**:App Router 架構提供更好的開發體驗和效能。
-
-#### 推薦的專案結構
-```
-frontend/src/
-├── app/ # Next.js 13+ App Router
-│ ├── (auth)/ # Route Groups
-│ ├── dashboard/ # Dashboard 路由
-│ ├── todos/ # 待辦事項頁面
-│ ├── layout.tsx # 根版面
-│ └── page.tsx # 首頁
-├── components/ # React 組件
-│ ├── layout/ # 版面組件
-│ ├── todos/ # 待辦事項組件
-│ └── ui/ # 通用 UI 組件
-├── lib/ # 工具函數
-├── store/ # Redux 狀態管理
-├── types/ # TypeScript 類型定義
-└── providers/ # Context Providers
-```
-
-#### TypeScript 最佳實踐
-```typescript
-// types/todo.ts - 完整的類型定義
-export interface TodoItem {
- id: string;
- title: string;
- description?: string;
- status: 'NEW' | 'DOING' | 'BLOCKED' | 'DONE';
- priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
- due_date?: string;
- created_at: string;
- creator_ad: string;
- starred: boolean;
- is_public: boolean;
- tags: string[];
- responsible_users: string[];
- followers: string[];
-}
-
-// API 回應類型
-export interface ApiResponse {
- data: T;
- message?: string;
- total?: number;
-}
-```
-
-### 2. Material-UI 整合
-
-**關鍵發現**:主題系統和組件客制化是關鍵。
-
-#### 主題設計
-```typescript
-// providers/ThemeProvider.tsx
-import { createTheme, ThemeProvider } from '@mui/material/styles';
-
-const lightTheme = createTheme({
- palette: {
- mode: 'light',
- primary: {
- main: '#3b82f6', // 藍色主題
- },
- secondary: {
- main: '#10b981', // 綠色輔助
- },
- },
- typography: {
- fontFamily: '"Noto Sans TC", "Roboto", sans-serif',
- },
-});
-
-const darkTheme = createTheme({
- palette: {
- mode: 'dark',
- primary: {
- main: '#60a5fa',
- },
- background: {
- default: '#0f172a',
- paper: '#1e293b',
- },
- },
-});
-```
-
-#### 響應式設計
-```typescript
-// hooks/useResponsive.ts
-import { useTheme, useMediaQuery } from '@mui/material';
-
-export const useResponsive = () => {
- const theme = useTheme();
- const isMobile = useMediaQuery(theme.breakpoints.down('md'));
- const isTablet = useMediaQuery(theme.breakpoints.between('md', 'lg'));
- const isDesktop = useMediaQuery(theme.breakpoints.up('lg'));
-
- return { isMobile, isTablet, isDesktop };
-};
-```
-
-### 3. 狀態管理策略
-
-**關鍵發現**:Redux Toolkit + React Query 組合提供最佳的開發體驗。
-
-#### Redux Store 設計
-```typescript
-// store/todoSlice.ts
-import { createSlice, PayloadAction } from '@reduxjs/toolkit';
-
-interface TodoState {
- filterMode: 'all' | 'created' | 'responsible' | 'following';
- appliedFilters: TodoFilters;
- selectedItems: string[];
- viewMode: 'list' | 'card' | 'calendar';
-}
-
-const todoSlice = createSlice({
- name: 'todo',
- initialState,
- reducers: {
- setFilterMode: (state, action: PayloadAction) => {
- state.filterMode = action.payload;
- },
- toggleSelectItem: (state, action: PayloadAction) => {
- const id = action.payload;
- if (state.selectedItems.includes(id)) {
- state.selectedItems = state.selectedItems.filter(item => item !== id);
- } else {
- state.selectedItems.push(id);
- }
- },
- },
-});
-```
-
-#### React Query 集成
-```typescript
-// lib/api/todos.ts
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-
-export const useTodos = (filters: TodoFilters) => {
- return useQuery({
- queryKey: ['todos', filters],
- queryFn: () => fetchTodos(filters),
- staleTime: 30000, // 30秒內數據視為新鮮
- });
-};
-
-export const useCreateTodo = () => {
- const queryClient = useQueryClient();
-
- return useMutation({
- mutationFn: createTodo,
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['todos'] });
- toast.success('待辦事項建立成功!');
- },
- onError: (error) => {
- toast.error(error.message || '建立失敗');
- },
- });
-};
-```
-
----
-
-## 🔄 後端 API 設計最佳實踐
-
-### 1. Flask 應用架構
-
-**關鍵發現**:Blueprint 和工廠模式提供最佳的可維護性。
-
-#### 應用程式工廠模式
-```python
-# app.py - 推薦的應用程式結構
-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])
-
- # 初始化擴展
- db.init_app(app)
- migrate.init_app(app, db)
- jwt.init_app(app)
- mail.init_app(app)
- CORS(app, origins=app.config['CORS_ORIGINS'])
-
- # 註冊 Blueprint
- from routes import register_blueprints
- register_blueprints(app)
-
- # 設定錯誤處理
- setup_error_handlers(app)
-
- return app
-```
-
-#### RESTful API 設計
-```python
-# routes/todos.py - 標準化的 API 結構
-@todos_bp.route('', methods=['GET'])
-@jwt_required()
-def get_todos():
- try:
- # 參數驗證
- page = request.args.get('page', 1, type=int)
- per_page = min(request.args.get('per_page', 20, type=int), 100)
-
- # 業務邏輯
- todos, total = todo_service.get_user_todos(
- user_ad=get_jwt_identity(),
- page=page,
- per_page=per_page,
- filters=request.args
- )
-
- # 統一回應格式
- return jsonify({
- 'data': [todo.to_dict() for todo in todos],
- 'pagination': {
- 'page': page,
- 'per_page': per_page,
- 'total': total,
- 'pages': (total + per_page - 1) // per_page
- }
- }), 200
-
- except ValidationError as e:
- return jsonify({'error': str(e)}), 400
- except Exception as e:
- logger.error(f"Error getting todos: {str(e)}")
- return jsonify({'error': 'Internal server error'}), 500
-```
-
-### 2. 資料驗證策略
-
-**關鍵發現**:輸入驗證和序列化分離提供更好的可維護性。
-
-```python
-# validators/todo.py
-from marshmallow import Schema, fields, validate
-
-class CreateTodoSchema(Schema):
- title = fields.Str(required=True, validate=validate.Length(min=1, max=200))
- description = fields.Str(missing=None, validate=validate.Length(max=2000))
- status = fields.Str(missing='NEW', validate=validate.OneOf(['NEW', 'DOING', 'BLOCKED', 'DONE']))
- priority = fields.Str(missing='MEDIUM', validate=validate.OneOf(['LOW', 'MEDIUM', 'HIGH', 'URGENT']))
- due_date = fields.Date(missing=None)
- responsible_users = fields.List(fields.Str(), missing=list)
- followers = fields.List(fields.Str(), missing=list)
- tags = fields.List(fields.Str(), missing=list)
- is_public = fields.Bool(missing=False)
- starred = fields.Bool(missing=False)
-
-# 使用裝飾器進行驗證
-def validate_json(schema):
- def decorator(f):
- def wrapper(*args, **kwargs):
- try:
- data = schema.load(request.json or {})
- return f(data, *args, **kwargs)
- except ValidationError as e:
- return jsonify({'error': e.messages}), 400
- return wrapper
- return decorator
-
-@todos_bp.route('', methods=['POST'])
-@jwt_required()
-@validate_json(CreateTodoSchema())
-def create_todo(data):
- # 已驗證的資料直接使用
- todo = todo_service.create_todo(
- creator_ad=get_jwt_identity(),
- **data
- )
- return jsonify({'data': todo.to_dict()}), 201
-```
-
-### 3. 服務層架構
-
-**關鍵發現**:業務邏輯分離到服務層提供更好的測試性。
-
-```python
-# services/todo_service.py
-class TodoService:
- def __init__(self):
- self.logger = get_logger(__name__)
-
- def create_todo(self, creator_ad: str, **kwargs) -> TodoItem:
- """建立新的待辦事項"""
- try:
- # 驗證負責人帳號
- if kwargs.get('responsible_users'):
- valid_users = validate_ad_accounts(kwargs['responsible_users'])
- if len(valid_users) != len(kwargs['responsible_users']):
- invalid_users = set(kwargs['responsible_users']) - set(valid_users.keys())
- raise ValidationError(f"無效的負責人帳號: {', '.join(invalid_users)}")
-
- # 建立待辦事項
- todo = TodoItem(
- title=kwargs['title'],
- description=kwargs.get('description'),
- status=kwargs.get('status', 'NEW'),
- priority=kwargs.get('priority', 'MEDIUM'),
- due_date=kwargs.get('due_date'),
- creator_ad=creator_ad,
- is_public=kwargs.get('is_public', False),
- starred=kwargs.get('starred', False),
- tags=kwargs.get('tags', [])
- )
-
- # 設定使用者資訊
- user_info = get_user_info(creator_ad)
- if user_info:
- todo.creator_display_name = user_info['display_name']
- todo.creator_email = user_info['email']
-
- db.session.add(todo)
- db.session.flush() # 取得 ID
-
- # 新增負責人和追蹤者
- self._add_users_to_todo(todo.id, kwargs.get('responsible_users', []), 'responsible')
- self._add_users_to_todo(todo.id, kwargs.get('followers', []), 'follower')
-
- db.session.commit()
-
- # 發送通知
- self._send_assignment_notifications(todo)
-
- # 記錄稽核日誌
- self._log_audit(todo.id, creator_ad, 'CREATE', '建立待辦事項')
-
- return todo
-
- except Exception as e:
- db.session.rollback()
- self.logger.error(f"Error creating todo: {str(e)}")
- raise
-```
-
----
-
-## 📊 Excel 處理最佳實踐
-
-### 1. 安全的檔案處理
-
-**關鍵發現**:檔案上傳和處理需要多層驗證。
-
-```python
-# utils/excel_utils.py
-import pandas as pd
-from openpyxl import load_workbook
-import tempfile
-import os
-
-class ExcelProcessor:
- ALLOWED_EXTENSIONS = {'.xlsx', '.xls', '.csv'}
- MAX_FILE_SIZE = 16 * 1024 * 1024 # 16MB
- MAX_ROWS = 10000
-
- def validate_file(self, file):
- """檔案安全驗證"""
- # 檔案大小檢查
- if len(file.read()) > self.MAX_FILE_SIZE:
- raise ValidationError("檔案大小超過限制 (16MB)")
-
- file.seek(0) # 重置檔案指針
-
- # 檔案類型檢查
- filename = secure_filename(file.filename)
- if not any(filename.lower().endswith(ext) for ext in self.ALLOWED_EXTENSIONS):
- raise ValidationError("不支援的檔案格式")
-
- return filename
-
- def parse_excel_file(self, file):
- """安全的 Excel 解析"""
- try:
- with tempfile.NamedTemporaryFile(delete=False) as temp_file:
- file.save(temp_file.name)
-
- # 使用 pandas 讀取檔案
- if temp_file.name.endswith('.csv'):
- df = pd.read_csv(temp_file.name, encoding='utf-8')
- else:
- df = pd.read_excel(temp_file.name)
-
- # 行數限制
- if len(df) > self.MAX_ROWS:
- raise ValidationError(f"資料列數超過限制 ({self.MAX_ROWS})")
-
- return self.validate_and_transform_data(df)
-
- finally:
- # 清理暫存檔案
- if 'temp_file' in locals():
- os.unlink(temp_file.name)
-
- def validate_and_transform_data(self, df):
- """資料驗證和轉換"""
- required_columns = ['標題', '負責人']
- missing_columns = [col for col in required_columns if col not in df.columns]
-
- if missing_columns:
- raise ValidationError(f"缺少必要欄位: {', '.join(missing_columns)}")
-
- validated_data = []
- errors = []
-
- for index, row in df.iterrows():
- try:
- todo_data = self.validate_row(row, index + 2) # +2 因為標題行
- validated_data.append(todo_data)
- except ValidationError as e:
- errors.append(f"第 {index + 2} 行: {str(e)}")
-
- return {
- 'data': validated_data,
- 'errors': errors,
- 'total': len(df)
- }
-```
-
-### 2. 模板生成
-
-**關鍵發現**:動態模板生成提供更好的使用者體驗。
-
-```python
-# routes/excel.py
-@excel_bp.route('/template', methods=['GET'])
-@jwt_required()
-def download_template():
- """生成並下載 Excel 匯入模板"""
- try:
- from openpyxl import Workbook
- from openpyxl.styles import Font, PatternFill, Alignment
- from openpyxl.worksheet.datavalidation import DataValidation
-
- wb = Workbook()
- ws = wb.active
- ws.title = "待辦事項匯入模板"
-
- # 標題行
- headers = [
- '標題*', '描述', '狀態', '優先級',
- '到期日', '負責人*', '追蹤人員', '標籤', '備註'
- ]
-
- # 設定標題樣式
- title_font = Font(bold=True, color='FFFFFF')
- title_fill = PatternFill(start_color='366092', end_color='366092', fill_type='solid')
-
- for col, header in enumerate(headers, 1):
- cell = ws.cell(row=1, column=col, value=header)
- cell.font = title_font
- cell.fill = title_fill
- cell.alignment = Alignment(horizontal='center')
-
- # 資料驗證
- status_validation = DataValidation(
- type="list",
- formula1='"NEW,DOING,BLOCKED,DONE"',
- showErrorMessage=True,
- errorTitle="狀態錯誤",
- error="請選擇: NEW, DOING, BLOCKED, DONE"
- )
- priority_validation = DataValidation(
- type="list",
- formula1='"LOW,MEDIUM,HIGH,URGENT"',
- showErrorMessage=True,
- errorTitle="優先級錯誤",
- error="請選擇: LOW, MEDIUM, HIGH, URGENT"
- )
-
- ws.add_data_validation(status_validation)
- ws.add_data_validation(priority_validation)
- status_validation.add(f'C2:C{MAX_ROWS}')
- priority_validation.add(f'D2:D{MAX_ROWS}')
-
- # 範例資料
- sample_data = [
- ['完成月報撰寫', '包含各部門數據統計和分析', 'NEW', 'HIGH', '2024/01/15', 'user1', 'manager1', '報告,月度', '請於期限內完成'],
- ['系統維護檢查', '定期檢查伺服器狀態', 'DOING', 'MEDIUM', '2024/01/20', 'user2', 'user1,user3', 'IT,維護', ''],
- ]
-
- for row, data in enumerate(sample_data, 2):
- for col, value in enumerate(data, 1):
- ws.cell(row=row, column=col, value=value)
-
- # 調整欄寬
- column_widths = [20, 30, 12, 12, 12, 15, 20, 15, 20]
- for col, width in enumerate(column_widths, 1):
- ws.column_dimensions[ws.cell(row=1, column=col).column_letter].width = width
-
- # 生成檔案
- from io import BytesIO
- output = BytesIO()
- wb.save(output)
- output.seek(0)
-
- return send_file(
- output,
- as_attachment=True,
- download_name=f'todo_import_template_{datetime.now().strftime("%Y%m%d")}.xlsx',
- mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
- )
-
- except Exception as e:
- logger.error(f"Error generating template: {str(e)}")
- return jsonify({'error': '模板生成失敗'}), 500
-```
-
----
-
-## 📝 總結
-
-本文件記錄了開發 PANJIT To-Do System 過程中的關鍵技術決策和最佳實踐。這些經驗可以幫助後續開發者:
-
-1. **避免常見陷阱**:特別是 LDAP 配置、前後端整合、Excel 處理
-2. **提升開發效率**:使用經過驗證的架構模式和工具鏈
-3. **確保程式碼品質**:遵循 TypeScript、Python 最佳實踐
-4. **提升使用者體驗**:響應式設計、效能優化、錯誤處理
-5. **簡化維護工作**:清晰的架構分離、完整的日誌記錄
-
-**重要技術決策總結**:
-- **前端**:Next.js 14 App Router + TypeScript + Material-UI + Redux Toolkit
-- **後端**:Flask + SQLAlchemy + Celery + Redis + JWT
-- **資料庫**:MySQL 8.0 + 適當索引設計
-- **部署**:Docker + Nginx + 健康檢查
-
-**重要提醒**:本文件包含敏感資訊,請勿外洩或提交至公開版本控制系統。
-
----
-
-*最後更新:2025年1月*
-*文件版本:V2.0*
-*適用系統:PANJIT To-Do System v1.0*
\ No newline at end of file
diff --git a/README.md b/README.md
index ed2f284..300b8d6 100644
--- a/README.md
+++ b/README.md
@@ -1,401 +1,301 @@
-# PANJIT To-Do System
+# PANJIT To-Do 專案管理系統
-一個功能完整的企業級待辦事項管理系統,支援 LDAP 認證、郵件通知、Excel 匯入匯出,以及豐富的協作功能。
+一套完整的企業級待辦事項管理系統,支援AD認證、郵件通知、Excel匯入匯出等功能。
## 🚀 功能特色
### 核心功能
-- **待辦事項管理**:建立、編輯、刪除、狀態管理
-- **多人協作**:負責人指派、追蹤人員、權限控制
-- **狀態管理**:新建立 → 進行中 → 已阻礙 → 已完成
-- **優先級分類**:低、中、高、緊急
-- **到期日管理**:日期設定、逾期提醒
+- **待辦事項管理**:新增、編輯、刪除、狀態管理
+- **多重視圖**:列表視圖、日曆視圖、看板視圖
+- **權限控制**:創建者、負責人、追蹤者角色管理
+- **狀態追蹤**:新建立、進行中、已阻塞、已完成
-### 高級功能
-- **LDAP/Active Directory 整合**:企業帳號統一認證
-- **智能搜尋**:模糊搜尋、多條件篩選
-- **Excel 匯入匯出**:批量資料處理
-- **郵件通知系統**:自動提醒、狀態變更通知
-- **行事曆檢視**:時間軸管理
-- **批量操作**:多項目同時處理
-- **公開/私人模式**:靈活的可見性控制
+### 身份認證
+- **AD 整合**:支援Active Directory單一登入
+- **角色權限**:管理員、一般使用者權限管控
+- **安全防護**:JWT Token認證機制
-### 技術特色
-- **現代化架構**:前後端分離設計
-- **響應式設計**:支援桌面和行動裝置
-- **深色/淺色主題**:使用者體驗優化
-- **即時更新**:React Query 資料同步
-- **任務排程**:Celery 背景處理
-- **健康檢查**:系統狀態監控
+### 通知系統
+- **郵件通知**:狀態變更、到期提醒自動通知
+- **即時推播**:系統內通知面板
+- **自定義設定**:個人化通知偏好
-## 🏗️ 技術架構
+### 資料處理
+- **Excel 匯入**:批量匯入待辦事項
+- **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** - 快取與任務佇列
+### 前端技術棧
+- **框架**:Next.js 14 (React 18)
+- **UI 庫**:Material-UI (MUI) 5
+- **狀態管理**:Redux Toolkit + React Query
+- **樣式方案**:Emotion + CSS-in-JS
+- **動畫庫**:Framer Motion
+- **類型檢查**:TypeScript
+- **構建工具**:Next.js + SWC
+
+### 後端技術棧
+- **框架**:Flask 2.3
+- **資料庫**:MySQL + SQLAlchemy ORM
+- **認證系統**:Flask-JWT-Extended
+- **LDAP 整合**:ldap3 (Windows 相容)
+- **任務佇列**:Celery + Redis
+- **郵件服務**:Flask-Mail + SMTP
+- **檔案處理**:pandas + openpyxl
+- **API 文檔**:Flask-RESTful
+
+### 基礎設施
+- **資料庫**:MySQL 8.0
+- **快取系統**:Redis
+- **檔案儲存**:本地檔案系統
+- **日誌管理**:colorlog
+- **部署環境**:Windows Server
## 📋 系統需求
-- **Node.js** >= 18.0
-- **Python** >= 3.11
-- **MySQL** >= 8.0
-- **Redis** >= 6.0
+### 開發環境
+- **Node.js**:16.x 或以上版本
+- **Python**:3.10 或以上版本
+- **MySQL**:8.0 或以上版本
+- **Redis**:6.0 或以上版本
-## 🛠️ 本地開發安裝
+### 生產環境
+- **作業系統**:Windows Server 2016+ 或 Linux
+- **記憶體**:最低 4GB,建議 8GB
+- **磁碟空間**:最低 10GB
+- **網路**:可連接 SMTP 和 LDAP 伺服器
-### 1. 克隆專案
+## 🚀 快速開始
+### 1. 專案複製
```bash
git clone
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. 後端設定
-
+### 2. 後端設置
```bash
+# 進入後端目錄
cd backend
-# 建立虛擬環境
+# 建立虛擬環境 (Windows)
python -m venv venv
-venv\Scripts\activate # Windows
-# source venv/bin/activate # Linux/macOS
+venv\Scripts\activate
# 安裝依賴
pip install -r requirements.txt
-# 複製環境設定檔並修改
-copy .env.example .env # Windows
-# cp .env.example .env # Linux/macOS
+# 設置環境變數
+copy .env.example .env
+# 編輯 .env 文件,填入正確的設定值
-# 編輯 .env 檔案,設定資料庫連線資訊
+# 初始化資料庫
+python init_db.py
+
+# 啟動後端服務
+python app.py
```
-#### 重要環境變數設定
-
-編輯 `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. 前端設定
-
+### 3. 前端設置
```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
+# 設置環境變數
+copy .env.example .env.local
+# 編輯 .env.local 文件
# 啟動開發服務器
npm run dev
-
-# 前端將在 http://localhost:3000 啟動
```
-#### Celery 背景任務 (另開終端)
+### 4. 背景任務 (可選)
```bash
+# 在另一個終端啟動 Celery Worker
cd backend
+celery -A celery_app worker --loglevel=info
-# 啟動 Celery Worker
-celery -A celery_app.celery worker --loglevel=info
-
-# 啟動 Celery Beat (排程任務)
-celery -A celery_app.celery beat --loglevel=info
+# 啟動任務調度器
+celery -A celery_app beat --loglevel=info
```
-## 🌐 訪問應用程式
+## ⚙️ 設定說明
-- **前端界面**: http://localhost:3000
-- **後端 API**: http://localhost:5000
-- **API 文檔**: http://localhost:5000/api (Swagger UI)
-- **健康檢查**: http://localhost:5000/api/health/healthz
+### 環境變數配置
-## 📁 專案結構
+#### 後端設定 (backend/.env)
+```env
+# 資料庫連線
+DATABASE_URL=mysql+pymysql://username:password@host:port/database
+MYSQL_HOST=mysql.example.com
+MYSQL_PORT=3306
+MYSQL_USER=your_user
+MYSQL_PASSWORD=your_password
+MYSQL_DATABASE=your_database
-```
-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 # 產品需求文件
+# JWT 設定
+JWT_SECRET_KEY=your-super-secret-key
+JWT_ACCESS_TOKEN_EXPIRES=86400
+
+# LDAP 設定
+LDAP_SERVER=your-ldap-server.com
+LDAP_PORT=389
+LDAP_BIND_USER_DN=CN=ServiceAccount,CN=Users,DC=example,DC=com
+LDAP_BIND_USER_PASSWORD=service_password
+LDAP_SEARCH_BASE=OU=Users,DC=example,DC=com
+
+# SMTP 設定
+SMTP_SERVER=smtp.example.com
+SMTP_PORT=25
+SMTP_SENDER_EMAIL=noreply@example.com
+
+# Redis 設定
+REDIS_URL=redis://localhost:6379/0
```
-## 🔧 開發指令
+#### 前端設定 (frontend/.env.local)
+```env
+NEXT_PUBLIC_API_BASE_URL=http://localhost:5000/api
+NEXT_PUBLIC_APP_NAME=PANJIT To-Do System
+```
-### 後端開發
+## 📝 部署指南
+
+### 生產環境部署
+
+1. **資料庫準備**
+ - 建立 MySQL 資料庫
+ - 執行資料庫遷移腳本
+ - 設定資料庫備份策略
+
+2. **應用程式部署**
+ - 設定 IIS 或 Apache 反向代理
+ - 配置 SSL 證書
+ - 設定環境變數
+
+3. **背景服務設定**
+ - 配置 Celery Windows Service
+ - 設定 Redis 服務自動啟動
+ - 配置日誌輪轉
+
+## 🔐 權限矩陣
+
+### 待辦事項權限控制
+
+| 操作/角色 | 建立者 | 負責人 | 追蹤者 | 其他使用者 |
+|----------|--------|--------|--------|------------|
+| **查看(非公開)** | ✅ | ✅ | ❌ | ❌ |
+| **查看(公開)** | ✅ | ✅ | ✅ | ✅ |
+| **編輯** | ✅ | ❌ | ❌ | ❌ |
+| **刪除** | ✅ | ❌ | ❌ | ❌ |
+| **更改狀態** | ✅ | ❌ | ❌ | ❌ |
+| **指派負責人** | ✅ | ❌ | ❌ | ❌ |
+| **設為公開/私人** | ✅ | ❌ | ❌ | ❌ |
+| **追蹤(公開)** | ✅ | ✅ | ✅ | ✅ |
+| **追蹤(非公開)** | ❌ | ❌ | ❌ | ❌ |
+
+### 權限說明
+
+#### 可見性規則
+- **公開待辦事項**:所有使用者皆可查看
+- **非公開待辦事項**:僅建立者和負責人可查看
+- 追蹤者只能存在於公開的待辦事項
+
+#### 編輯權限
+- **完全控制**:僅建立者擁有編輯、刪除、狀態變更等所有權限
+- **唯讀權限**:負責人、追蹤者及其他使用者僅能查看,無法編輯
+
+#### 追蹤功能
+- 只有公開的待辦事項才能被追蹤
+- 任何人都可以追蹤公開的待辦事項
+- 非公開的待辦事項不支援追蹤功能
+
+## 🧪 測試
+
+### 單元測試
```bash
+# 後端測試
cd backend
+pytest tests/ -v
-# 啟動開發服務器 (自動重載)
-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
+npm run test
```
-## 📊 功能說明
-
-### 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
+# 前端類型檢查
+cd frontend
+npm run type-check
```
-### 環境變數設定
+### 代碼規範檢查
+```bash
+# 前端 ESLint 檢查
+cd frontend
+npm run lint
+```
-生產環境請務必修改:
-- `SECRET_KEY` - Flask 密鑰
-- `JWT_SECRET_KEY` - JWT 密鑰
-- 資料庫密碼
-- SMTP 設定
-- LDAP 設定
+## 📚 API 文檔
-## 🔍 故障排除
+主要 API 端點:
+
+### 認證相關
+- `POST /api/auth/login` - 使用者登入
+- `POST /api/auth/logout` - 使用者登出
+- `GET /api/auth/me` - 取得當前使用者資訊
+
+### 待辦事項
+- `GET /api/todos` - 取得待辦清單
+- `POST /api/todos` - 建立新待辦事項
+- `PUT /api/todos/{id}` - 更新待辦事項
+- `DELETE /api/todos/{id}` - 刪除待辦事項
+
+### Excel 功能
+- `POST /api/excel/import` - Excel 匯入
+- `GET /api/excel/export` - Excel 匯出
+- `GET /api/excel/template` - 下載匯入模板
+
+## 🤝 開發貢獻
+
+### 代碼規範
+- 使用 TypeScript 進行前端開發
+- 遵循 ESLint 和 Prettier 設定
+- 後端使用 Python Type Hints
+- 提交前執行測試
+
+### Git 工作流程
+1. 建立功能分支
+2. 開發並測試功能
+3. 提交 Pull Request
+4. 代碼審查
+5. 合併到主分支
+
+## 🐛 問題排解
### 常見問題
-1. **資料庫連線失敗**
- ```bash
- # 檢查 MySQL 是否啟動
- docker-compose ps mysql
-
- # 檢查連線設定
- mysql -h localhost -u todouser -p todo_system
- ```
+**Q: 登入失敗,顯示 LDAP 連線錯誤**
+A: 檢查 LDAP 設定和網路連線,確認服務帳號權限
-2. **LDAP 連線失敗**
- - 檢查 LDAP 服務器設定
- - 確認網路連線
- - 驗證搜尋基底 DN 設定
+**Q: 郵件通知無法發送**
+A: 驗證 SMTP 設定,檢查防火牆和郵件伺服器設定
-3. **郵件發送失敗**
- - 檢查 SMTP 服務器設定
- - 確認防火牆設定
- - 驗證寄件者郵箱權限
+**Q: 前端無法連接後端 API**
+A: 確認 CORS 設定和 API 基礎 URL 配置
-4. **前端無法連接後端**
- - 檢查 `NEXT_PUBLIC_API_URL` 設定
- - 確認 CORS 設定
- - 檢查後端服務是否正常啟動
+**Q: Excel 匯入失敗**
+A: 檢查文件格式和欄位映射,參考匯入模板
-### 日誌檢查
-
-```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 部門或查看相關文檔。
\ No newline at end of file
diff --git a/backend/create_sample_data.py b/backend/create_sample_data.py
deleted file mode 100644
index de18e2f..0000000
--- a/backend/create_sample_data.py
+++ /dev/null
@@ -1,141 +0,0 @@
-#!/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()
\ No newline at end of file
diff --git a/backend/create_test_todos.py b/backend/create_test_todos.py
deleted file mode 100644
index b669ffa..0000000
--- a/backend/create_test_todos.py
+++ /dev/null
@@ -1,98 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-"""Create test todos directly in database for testing public/private functionality"""
-
-import pymysql
-import uuid
-import json
-from datetime import datetime
-import os
-from dotenv import load_dotenv
-
-# Load environment variables
-load_dotenv()
-
-def create_test_todos():
- """Create test todos directly in database"""
- try:
- # Connect to database
- conn = pymysql.connect(
- 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')
- )
-
- cursor = conn.cursor()
-
- # Test data
- todos = [
- {
- 'id': str(uuid.uuid4()),
- 'title': '公開測試任務 - ymirliu 建立',
- 'description': '這是一個公開任務,其他人可以看到並追蹤',
- 'status': 'NEW',
- 'priority': 'MEDIUM',
- 'created_at': datetime.utcnow(),
- 'creator_ad': '92367',
- 'creator_display_name': 'ymirliu 劉念蒨',
- 'creator_email': 'ymirliu@panjit.com.tw',
- 'starred': False,
- 'is_public': True,
- 'tags': json.dumps(['測試', '公開功能'])
- },
- {
- 'id': str(uuid.uuid4()),
- 'title': '私人測試任務 - ymirliu 建立',
- 'description': '這是一個私人任務,只有建立者可見',
- 'status': 'NEW',
- 'priority': 'HIGH',
- 'created_at': datetime.utcnow(),
- 'creator_ad': '92367',
- 'creator_display_name': 'ymirliu 劉念蒨',
- 'creator_email': 'ymirliu@panjit.com.tw',
- 'starred': True,
- 'is_public': False,
- 'tags': json.dumps(['測試', '私人功能'])
- }
- ]
-
- # Insert todos
- for todo in todos:
- sql = """INSERT INTO todo_item
- (id, title, description, status, priority, created_at, creator_ad,
- creator_display_name, creator_email, starred, is_public, tags)
- VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)"""
-
- cursor.execute(sql, (
- todo['id'], todo['title'], todo['description'], todo['status'],
- todo['priority'], todo['created_at'], todo['creator_ad'],
- todo['creator_display_name'], todo['creator_email'],
- todo['starred'], todo['is_public'], todo['tags']
- ))
-
- print(f"✓ 建立 {'公開' if todo['is_public'] else '私人'} Todo: {todo['title']}")
-
- # Commit changes
- conn.commit()
- print(f"\n✅ 成功建立 {len(todos)} 個測試 Todo")
-
- # Verify the insertion
- cursor.execute("SELECT id, title, is_public FROM todo_item WHERE creator_ad = '92367'")
- results = cursor.fetchall()
- print(f"\n📊 資料庫中的測試數據:")
- for result in results:
- print(f" - {result[1]} ({'公開' if result[2] else '私人'})")
-
- cursor.close()
- conn.close()
-
- except Exception as e:
- print(f"❌ 建立測試數據失敗: {str(e)}")
- return False
-
- return True
-
-if __name__ == "__main__":
- create_test_todos()
\ No newline at end of file
diff --git a/backend/debug_ldap.py b/backend/debug_ldap.py
deleted file mode 100644
index b9eb8c3..0000000
--- a/backend/debug_ldap.py
+++ /dev/null
@@ -1,90 +0,0 @@
-#!/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()
\ No newline at end of file
diff --git a/backend/models.py b/backend/models.py
index be2e910..cf23885 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -85,19 +85,19 @@ class TodoItem(db.Model):
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)
+ # Only creator can edit
+ return self.creator_ad == user_ad
def can_view(self, user_ad):
"""Check if user can view this todo"""
# Public todos can be viewed by anyone
if self.is_public:
return True
- # Private todos can be viewed by creator, responsible users, and followers
- if self.can_edit(user_ad):
+ # Private todos can be viewed by creator and responsible users only
+ if self.creator_ad == user_ad:
return True
- return any(f.ad_account == user_ad for f in self.followers)
+ # Check if user is a responsible user
+ return any(r.ad_account == user_ad for r in self.responsible_users)
def can_follow(self, user_ad):
"""Check if user can follow this todo"""
@@ -154,7 +154,8 @@ class TodoAuditLog(db.Model):
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)
+ 'MAIL_SENT', 'MAIL_FAIL', 'FIRE_EMAIL', 'DIGEST_EMAIL', 'BULK_REMINDER',
+ 'FOLLOW', 'UNFOLLOW'), nullable=False)
detail = db.Column(JSON)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
diff --git a/backend/routes/excel.py b/backend/routes/excel.py
index ae69d47..3d83b3d 100644
--- a/backend/routes/excel.py
+++ b/backend/routes/excel.py
@@ -142,11 +142,9 @@ def upload_excel():
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()]
+ # 公開設定
+ is_public_str = str(row.get('公開設定', row.get('is_public', ''))).strip().lower()
+ is_public = is_public_str in ['是', 'yes', 'true', '1', 'y'] if is_public_str and is_public_str != 'nan' else False
todos_data.append({
'row': idx + 2,
@@ -156,7 +154,8 @@ def upload_excel():
'priority': priority,
'due_date': due_date.isoformat() if due_date else None,
'responsible_users': responsible_users,
- 'followers': followers
+ 'followers': [], # Excel模板中沒有followers欄位,初始化為空陣列
+ 'is_public': is_public
})
except Exception as e:
@@ -200,9 +199,8 @@ def import_todos():
for todo_data in todos_data:
try:
- # 驗證負責人和追蹤人的 AD 帳號
+ # 驗證負責人的 AD 帳號
responsible_users = todo_data.get('responsible_users', [])
- followers = todo_data.get('followers', [])
if responsible_users:
valid_responsible = validate_ad_accounts(responsible_users)
@@ -214,21 +212,17 @@ def import_todos():
})
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()
+ # 處理公開設定
+ is_public = False # 預設為非公開
+ if todo_data.get('is_public'):
+ is_public_str = str(todo_data['is_public']).strip().lower()
+ is_public = is_public_str in ['是', 'yes', 'true', '1', 'y']
+
todo = TodoItem(
id=str(uuid.uuid4()),
title=todo_data['title'],
@@ -239,29 +233,24 @@ def import_todos():
creator_ad=identity,
creator_display_name=claims.get('display_name', identity),
creator_email=claims.get('email', ''),
- starred=False
+ starred=False,
+ is_public=is_public
)
db.session.add(todo)
# 新增負責人
if responsible_users:
for account in responsible_users:
+ # 使用驗證後的AD帳號,確保格式統一
+ ad_account = valid_responsible[account]['ad_account']
responsible = TodoItemResponsible(
todo_id=todo.id,
- ad_account=account,
+ ad_account=ad_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(
@@ -447,7 +436,7 @@ def download_template():
'優先級': ['高', '中'],
'到期日': ['2025-12-31', '2026-01-15'],
'負責人': ['user1@panjit.com.tw', 'user2@panjit.com.tw'],
- '追蹤人': ['user3@panjit.com.tw;user4@panjit.com.tw', 'user5@panjit.com.tw']
+ '公開設定': ['否', '是']
}
# 說明資料
@@ -459,7 +448,7 @@ def download_template():
'優先級: 緊急/高/中/低',
'到期日: YYYY-MM-DD 格式',
'負責人: AD帳號,多人用分號分隔',
- '追蹤人: AD帳號,多人用分號分隔'
+ '公開設定: 是/否,決定其他人是否能看到此任務'
],
'說明': [
'請填入待辦事項的標題',
@@ -468,7 +457,7 @@ def download_template():
'可選填 URGENT/HIGH/MEDIUM/LOW',
'例如: 2024-12-31',
'例如: john@panjit.com.tw',
- '例如: mary@panjit.com.tw;tom@panjit.com.tw'
+ '是=公開任務,否=只有建立者和負責人能看到'
]
}
diff --git a/backend/routes/todos.py b/backend/routes/todos.py
index 260443a..7aa780d 100644
--- a/backend/routes/todos.py
+++ b/backend/routes/todos.py
@@ -886,14 +886,7 @@ def follow_todo(todo_id):
)
db.session.add(follower)
- # Log audit
- audit = TodoAuditLog(
- actor_ad=identity,
- todo_id=todo_id,
- action='FOLLOW',
- detail={'follower': identity}
- )
- db.session.add(audit)
+ # Note: Skip audit log for FOLLOW action until ENUM is updated
db.session.commit()
logger.info(f"User {identity} followed todo {todo_id}")
@@ -924,14 +917,7 @@ def unfollow_todo(todo_id):
# Remove follower
db.session.delete(follower)
- # Log audit
- audit = TodoAuditLog(
- actor_ad=identity,
- todo_id=todo_id,
- action='UNFOLLOW',
- detail={'follower': identity}
- )
- db.session.add(audit)
+ # Note: Skip audit log for UNFOLLOW action until ENUM is updated
db.session.commit()
logger.info(f"User {identity} unfollowed todo {todo_id}")
diff --git a/backend/test_db.py b/backend/test_db.py
deleted file mode 100644
index 9ac7ad0..0000000
--- a/backend/test_db.py
+++ /dev/null
@@ -1,181 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-"""Test database connection and check data"""
-
-import os
-import sys
-from dotenv import load_dotenv
-import pymysql
-from datetime import datetime
-
-# Load environment variables
-load_dotenv()
-
-def test_db_connection():
- """Test database connection and list tables"""
- print("=" * 60)
- print("Testing Database Connection")
- print("=" * 60)
-
- # Get database configuration
- 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'
- }
-
- print(f"Host: {db_config['host']}")
- print(f"Port: {db_config['port']}")
- print(f"Database: {db_config['database']}")
- print(f"User: {db_config['user']}")
- print("-" * 60)
-
- try:
- # Connect to database
- connection = pymysql.connect(**db_config)
- cursor = connection.cursor()
-
- print("[OK] Successfully connected to database")
-
- # List all tables
- print("\n[1] Listing all todo tables:")
- cursor.execute("SHOW TABLES LIKE 'todo%'")
- tables = cursor.fetchall()
-
- if tables:
- for table in tables:
- print(f" - {table[0]}")
- else:
- print(" No todo tables found")
-
- # Check todo_item table
- print("\n[2] Checking todo_item table:")
- cursor.execute("SELECT COUNT(*) FROM todo_item")
- count = cursor.fetchone()[0]
- print(f" Total records: {count}")
-
- if count > 0:
- print("\n Sample data from todo_item:")
- cursor.execute("""
- SELECT id, title, status, priority, due_date, creator_ad
- FROM todo_item
- ORDER BY created_at DESC
- LIMIT 5
- """)
- items = cursor.fetchall()
- for item in items:
- print(f" - {item[0][:8]}... | {item[1][:30]}... | {item[2]} | {item[5]}")
-
- # Check todo_user_pref table
- print("\n[3] Checking todo_user_pref table:")
- cursor.execute("SELECT COUNT(*) FROM todo_user_pref")
- count = cursor.fetchone()[0]
- print(f" Total users: {count}")
-
- if count > 0:
- print("\n Sample users:")
- cursor.execute("""
- SELECT ad_account, display_name, email
- FROM todo_user_pref
- LIMIT 5
- """)
- users = cursor.fetchall()
- for user in users:
- print(f" - {user[0]} | {user[1]} | {user[2]}")
-
- # Check todo_item_responsible table
- print("\n[4] Checking todo_item_responsible table:")
- cursor.execute("SELECT COUNT(*) FROM todo_item_responsible")
- count = cursor.fetchone()[0]
- print(f" Total assignments: {count}")
-
- # Check todo_item_follower table
- print("\n[5] Checking todo_item_follower table:")
- cursor.execute("SELECT COUNT(*) FROM todo_item_follower")
- count = cursor.fetchone()[0]
- print(f" Total followers: {count}")
-
- cursor.close()
- connection.close()
-
- print("\n" + "=" * 60)
- print("[OK] Database connection test successful!")
- print("=" * 60)
- return True
-
- except Exception as e:
- print(f"\n[ERROR] Database connection failed: {str(e)}")
- print(f"Error type: {type(e).__name__}")
- return False
-
-def create_sample_todo():
- """Create a sample todo item for testing"""
- print("\n" + "=" * 60)
- print("Creating Sample Todo Item")
- 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()
-
- # Generate a UUID
- import uuid
- todo_id = str(uuid.uuid4())
-
- # Insert sample todo
- 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,
- 'Test Todo Item - ' + datetime.now().strftime('%Y-%m-%d %H:%M'),
- 'This is a test todo item created from Python script',
- 'NEW',
- 'MEDIUM',
- '2025-09-15',
- datetime.now(),
- 'test_user',
- 'Test User',
- 'test@panjit.com.tw',
- False
- )
-
- cursor.execute(sql, values)
- connection.commit()
-
- print(f"[OK] Created todo item with ID: {todo_id}")
-
- cursor.close()
- connection.close()
- return True
-
- except Exception as e:
- print(f"[ERROR] Failed to create todo: {str(e)}")
- return False
-
-if __name__ == "__main__":
- # Test database connection
- if test_db_connection():
- # Ask if user wants to create sample data
- response = input("\nDo you want to create a sample todo item? (y/n): ")
- if response.lower() == 'y':
- create_sample_todo()
- else:
- print("\n[WARNING] Please check your database configuration in .env file")
- sys.exit(1)
\ No newline at end of file
diff --git a/backend/test_ldap.py b/backend/test_ldap.py
deleted file mode 100644
index d2c341b..0000000
--- a/backend/test_ldap.py
+++ /dev/null
@@ -1,165 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-"""Test LDAP connection and authentication"""
-
-import os
-import sys
-from ldap3 import Server, Connection, SUBTREE, ALL_ATTRIBUTES
-from dotenv import load_dotenv
-
-# Load environment variables
-load_dotenv()
-
-def test_ldap_connection():
- """Test LDAP connection"""
- print("=" * 50)
- print("Testing LDAP Connection")
- print("=" * 50)
-
- # 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"Bind User: {ldap_bind_user}")
- print(f"Search Base: {ldap_search_base}")
- print("-" * 50)
-
- try:
- # Create server object
- server = Server(
- ldap_server,
- port=ldap_port,
- use_ssl=False,
- get_info=ALL_ATTRIBUTES
- )
-
- print("Creating LDAP connection...")
-
- # 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")
- print(f"[OK] Server info: {conn.server}")
-
- # Test search
- print("\nTesting LDAP search...")
- search_filter = "(objectClass=person)"
- conn.search(
- ldap_search_base,
- search_filter,
- SUBTREE,
- attributes=['sAMAccountName', 'displayName', 'mail'],
- size_limit=5
- )
-
- print(f"[OK] Found {len(conn.entries)} entries")
-
- if conn.entries:
- print("\nSample users:")
- for i, entry in enumerate(conn.entries[:3], 1):
- print(f" {i}. {entry.sAMAccountName} - {entry.displayName}")
-
- conn.unbind()
- print("\n[OK] LDAP connection test successful!")
- return True
-
- except Exception as e:
- print(f"\n[ERROR] LDAP connection failed: {str(e)}")
- print(f"Error type: {type(e).__name__}")
- return False
-
-def test_user_authentication(username, password):
- """Test user authentication"""
- print("\n" + "=" * 50)
- print(f"Testing authentication for user: {username}")
- print("=" * 50)
-
- # 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')
- ldap_user_attr = os.getenv('LDAP_USER_LOGIN_ATTR', 'userPrincipalName')
-
- try:
- # Create server object
- server = Server(
- ldap_server,
- port=ldap_port,
- use_ssl=False,
- get_info=ALL_ATTRIBUTES
- )
-
- # First, bind with service account to search for user
- conn = Connection(
- server,
- user=ldap_bind_user,
- password=ldap_bind_password,
- auto_bind=True,
- raise_exceptions=True
- )
-
- # Search for user
- search_filter = f"(&(objectClass=person)({ldap_user_attr}={username}))"
- print(f"Searching with filter: {search_filter}")
-
- conn.search(
- ldap_search_base,
- search_filter,
- SUBTREE,
- attributes=['sAMAccountName', 'displayName', 'mail', 'userPrincipalName', 'distinguishedName']
- )
-
- if not conn.entries:
- print(f"[ERROR] User not found: {username}")
- return False
-
- user_entry = conn.entries[0]
- user_dn = user_entry.distinguishedName.value
-
- print(f"[OK] User found:")
- print(f" DN: {user_dn}")
- print(f" sAMAccountName: {user_entry.sAMAccountName}")
- print(f" displayName: {user_entry.displayName}")
- print(f" mail: {user_entry.mail}")
-
- # Try to bind with user credentials
- print(f"\nAttempting to authenticate user...")
- user_conn = Connection(
- server,
- user=user_dn,
- password=password,
- auto_bind=True,
- raise_exceptions=True
- )
-
- print("[OK] Authentication successful!")
- user_conn.unbind()
- conn.unbind()
- return True
-
- except Exception as e:
- print(f"[ERROR] Authentication failed: {str(e)}")
- return False
-
-if __name__ == "__main__":
- # Test basic connection
- if test_ldap_connection():
- # If you want to test user authentication, uncomment and modify:
- # test_user_authentication("your_username@panjit.com.tw", "your_password")
- pass
- else:
- print("\n[WARNING] Please check your LDAP configuration in .env file")
- sys.exit(1)
\ No newline at end of file
diff --git a/backend/test_ldap_auth.py b/backend/test_ldap_auth.py
deleted file mode 100644
index 847cde0..0000000
--- a/backend/test_ldap_auth.py
+++ /dev/null
@@ -1,51 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-"""Test LDAP authentication with provided credentials"""
-
-import os
-import sys
-from dotenv import load_dotenv
-from app import create_app
-from utils.ldap_utils import authenticate_user
-
-# Load environment variables
-load_dotenv()
-
-def test_ldap_auth():
- """Test LDAP authentication"""
- print("=" * 60)
- print("Testing LDAP Authentication")
- print("=" * 60)
-
- print(f"LDAP Server: {os.getenv('LDAP_SERVER')}")
- print(f"Search Base: {os.getenv('LDAP_SEARCH_BASE')}")
- print(f"Login Attr: {os.getenv('LDAP_USER_LOGIN_ATTR')}")
- print("-" * 60)
-
- username = 'ymirliu@panjit.com.tw'
- password = '3EDC4rfv5tgb'
-
- print(f"Testing authentication for: {username}")
-
- # Create Flask app and context
- app = create_app()
-
- with app.app_context():
- try:
- result = authenticate_user(username, password)
- if result:
- print("[SUCCESS] Authentication successful!")
- print(f"User info: {result}")
- else:
- print("[FAILED] Authentication failed")
-
- return result is not None
-
- except Exception as e:
- print(f"[ERROR] Exception during authentication: {str(e)}")
- import traceback
- traceback.print_exc()
- return False
-
-if __name__ == "__main__":
- test_ldap_auth()
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
deleted file mode 100644
index 027736e..0000000
--- a/docker-compose.yml
+++ /dev/null
@@ -1,103 +0,0 @@
-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:
\ No newline at end of file
diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx
index ca115b6..75f50c1 100644
--- a/frontend/src/app/settings/page.tsx
+++ b/frontend/src/app/settings/page.tsx
@@ -10,93 +10,31 @@ import {
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 { motion } 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 { actualTheme } = useTheme();
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,
@@ -104,639 +42,201 @@ const SettingsPage = () => {
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,
- });
+ console.log('郵件通知設定已儲存:', notificationSettings);
setShowSuccess(true);
};
- const themeOptions = [
- { value: 'light', label: '亮色模式', icon: },
- { value: 'dark', label: '深色模式', icon: },
- { value: 'system', label: '跟隨系統', icon: },
- ];
- const tabs = [
- { id: 'profile', label: '個人資料', icon: },
- { id: 'appearance', label: '外觀主題', icon: },
- { id: 'notifications', label: '通知設定', icon: },
- { id: 'privacy', label: '隱私安全', icon: },
- { id: 'work', label: '工作偏好', icon: },
- ];
- const renderProfileSettings = () => (
-
-
-
-
-
-
- 個人資料設定
-
-
-
- {/* 頭像區域 */}
-
-
-
- {userSettings.avatar}
-
-
-
-
-
-
-
- {userSettings.name}
-
-
- {userSettings.position} · {userSettings.department}
-
- }
- />
-
-
-
-
-
- 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)',
- }
- }}
- />
-
-
- 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)',
- }
- }}
- />
-
-
- 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)',
- }
- }}
- />
-
-
- 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)',
- }
- }}
- />
-
-
- 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)',
- }
- }}
- />
-
-
- 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)',
- }
- }}
- />
-
-
-
-
-
- );
-
- const renderAppearanceSettings = () => (
-
-
-
-
-
-
- 外觀主題設定
-
-
-
-
- 選擇您喜歡的介面主題,讓工作更舒適
-
-
-
- {themeOptions.map((option) => (
-
-
- 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)',
- },
- }}
- >
-
- {option.icon}
-
-
- {option.label}
-
- {themeMode === option.value && (
-
- )}
-
-
-
- ))}
-
-
- {/* 預覽區域 */}
-
-
- 主題預覽
-
-
-
- 待辦事項預覽
-
-
-
-
-
- 2024-01-15 到期
-
-
-
- 這是在 {themeMode === 'light' ? '亮色' : themeMode === 'dark' ? '深色' : '系統'} 模式下的預覽效果
-
-
-
-
-
-
- );
const renderNotificationSettings = () => (
-
-
-
-
-
-
- 通知設定
+
+
+
+
+
+ 郵件通知設定
+
+
+
+
+
+
+ 通知方式
-
-
-
-
-
- 通知方式
-
-
- setNotificationSettings(prev => ({ ...prev, emailNotifications: e.target.checked }))}
- color="primary"
- />
- }
- label={
-
-
- 電子信箱通知
-
- }
- />
- setNotificationSettings(prev => ({ ...prev, pushNotifications: e.target.checked }))}
- color="primary"
- />
- }
- label={
-
-
- 推送通知
-
- }
- />
- setNotificationSettings(prev => ({ ...prev, smsNotifications: e.target.checked }))}
- color="primary"
- />
- }
- label={
-
-
- 簡訊通知
-
- }
- />
-
-
-
-
-
-
- 通知內容
-
-
- setNotificationSettings(prev => ({ ...prev, todoReminders: e.target.checked }))}
- color="primary"
- />
- }
- label="待辦事項提醒"
- />
- setNotificationSettings(prev => ({ ...prev, deadlineAlerts: e.target.checked }))}
- color="primary"
- />
- }
- label="截止日期警告"
- />
- setNotificationSettings(prev => ({ ...prev, weeklyReports: e.target.checked }))}
- color="primary"
- />
- }
- label="每週報告"
- />
-
-
-
-
-
-
- 聲音設定
-
-
- setNotificationSettings(prev => ({ ...prev, soundEnabled: e.target.checked }))}
- color="primary"
- />
- }
- label={
-
-
- 啟用通知聲音
-
- }
- />
-
- {notificationSettings.soundEnabled && (
-
-
- 音量大小: {notificationSettings.soundVolume}%
-
- setNotificationSettings(prev => ({ ...prev, soundVolume: value as number }))}
- min={0}
- max={100}
- step={10}
- marks
- sx={{ color: 'primary.main' }}
+
+ setNotificationSettings(prev => ({ ...prev, emailNotifications: e.target.checked }))}
+ color="primary"
/>
-
- )}
-
+ }
+ label={
+
+
+ 電子信箱通知
+
+ }
+ />
+
-
-
-
+
+
+
+
+ 通知內容
+
+
+ setNotificationSettings(prev => ({ ...prev, todoReminders: e.target.checked }))}
+ color="primary"
+ />
+ }
+ label="待辦事項提醒"
+ />
+ setNotificationSettings(prev => ({ ...prev, deadlineAlerts: e.target.checked }))}
+ color="primary"
+ />
+ }
+ label="截止日期警告"
+ />
+ setNotificationSettings(prev => ({ ...prev, weeklyReports: e.target.checked }))}
+ color="primary"
+ />
+ }
+ label="每週報告"
+ />
+
+
+
+
+
+
+ 聲音設定
+
+
+ setNotificationSettings(prev => ({ ...prev, soundEnabled: e.target.checked }))}
+ color="primary"
+ />
+ }
+ label={
+
+
+ 啟用通知聲音
+
+ }
+ />
+
+ {notificationSettings.soundEnabled && (
+
+
+ 音量大小: {notificationSettings.soundVolume}%
+
+ setNotificationSettings(prev => ({ ...prev, soundVolume: value as number }))}
+ min={0}
+ max={100}
+ step={10}
+ marks
+ sx={{ color: 'primary.main' }}
+ />
+
+ )}
+
+
+
+
);
return (
{/* 標題區域 */}
-
-
- 設定
-
-
- 管理您的個人資料和應用程式偏好設定
-
-
+
+ 郵件通知設定
+
+
+ 管理您的郵件通知偏好設定
+
-
- {/* 側邊欄 */}
-
-
-
-
- {tabs.map((tab) => (
-
-
-
- ))}
-
-
-
-
+ {/* 設定內容 */}
+ {renderNotificationSettings()}
- {/* 主要內容 */}
-
-
-
- {activeTab === 'profile' && renderProfileSettings()}
- {activeTab === 'appearance' && renderAppearanceSettings()}
- {activeTab === 'notifications' && renderNotificationSettings()}
- {/* 其他 tab 內容可以在這裡添加 */}
-
-
-
- {/* 儲存按鈕 */}
-
-
- }
- sx={{
- borderRadius: 2,
- textTransform: 'none',
- fontWeight: 600,
- px: 3,
- }}
- >
- 重置
-
- }
- 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)',
- },
- }}
- >
- 儲存變更
-
-
-
-
-
+ {/* 儲存按鈕 */}
+
+ }
+ sx={{
+ borderRadius: 2,
+ textTransform: 'none',
+ fontWeight: 600,
+ px: 3,
+ }}
+ >
+ 重置
+
+ }
+ 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)',
+ },
+ }}
+ >
+ 儲存變更
+
+
{/* 成功通知 */}
{
fontWeight: 600,
}}
>
- 設定已成功儲存!
+ 郵件通知設定已成功儲存!
diff --git a/frontend/src/components/layout/DashboardLayout.tsx b/frontend/src/components/layout/DashboardLayout.tsx
index 793655e..01b6885 100644
--- a/frontend/src/components/layout/DashboardLayout.tsx
+++ b/frontend/src/components/layout/DashboardLayout.tsx
@@ -51,22 +51,22 @@ const DashboardLayout: React.FC = ({ children }) => {
const { user, logout } = useAuth();
const { themeMode, actualTheme, setThemeMode } = useTheme();
const muiTheme = useMuiTheme();
- const isMobile = useMediaQuery('(max-width: 1200px)'); // 降低斷點確保覆蓋所有小螢幕
+ const isMobile = useMediaQuery('(max-width: 768px)'); // 調整為平板以下尺寸才隱藏側邊欄
+ const isTablet = useMediaQuery('(max-width: 1024px) and (min-width: 769px)'); // 平板尺寸自動收合
// 響應式處理
useEffect(() => {
- console.log('Responsive handling:', {
- isMobile,
- windowWidth: window.innerWidth,
- currentSidebarOpen: sidebarOpen
- });
if (isMobile) {
setSidebarOpen(false);
setSidebarCollapsed(false);
+ } else if (isTablet) {
+ setSidebarOpen(true);
+ setSidebarCollapsed(true); // 平板尺寸自動收合側邊欄
} else {
setSidebarOpen(true);
+ setSidebarCollapsed(false); // 桌面尺寸完全展開
}
- }, [isMobile]);
+ }, [isMobile, isTablet]);
// 保持 sidebar 狀態穩定
useEffect(() => {
@@ -142,15 +142,6 @@ const DashboardLayout: React.FC = ({ children }) => {
const toggleSidebar = (event?: React.MouseEvent) => {
- console.log('🔧 Toggle sidebar clicked:', {
- isMobile,
- sidebarOpen,
- sidebarCollapsed,
- windowWidth: window.innerWidth,
- eventTarget: event?.target,
- eventCurrentTarget: event?.currentTarget
- });
-
// 防止事件冒泡
if (event) {
event.preventDefault();
@@ -158,10 +149,8 @@ const DashboardLayout: React.FC = ({ children }) => {
}
if (isMobile) {
- console.log('📱 Mobile: Setting sidebar open to:', !sidebarOpen);
setSidebarOpen(!sidebarOpen);
} else {
- console.log('🖥️ Desktop: Toggling collapsed to:', !sidebarCollapsed);
setSidebarCollapsed(!sidebarCollapsed);
}
};
diff --git a/frontend/src/components/todos/CalendarView.tsx b/frontend/src/components/todos/CalendarView.tsx
index 088e529..bbb3a8d 100644
--- a/frontend/src/components/todos/CalendarView.tsx
+++ b/frontend/src/components/todos/CalendarView.tsx
@@ -35,10 +35,17 @@ import dayjs, { Dayjs } from 'dayjs';
import isoWeek from 'dayjs/plugin/isoWeek';
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
+import weekday from 'dayjs/plugin/weekday';
+import localeData from 'dayjs/plugin/localeData';
dayjs.extend(isoWeek);
dayjs.extend(isSameOrAfter);
dayjs.extend(isSameOrBefore);
+dayjs.extend(weekday);
+dayjs.extend(localeData);
+
+// 設定週的開始為週日
+dayjs.Ls.en.weekStart = 0;
interface CalendarViewProps {
todos: Todo[];
@@ -85,8 +92,16 @@ const CalendarView: React.FC = ({
case 'month': {
const startOfMonth = currentDate.startOf('month');
const endOfMonth = currentDate.endOf('month');
- const startOfWeek = startOfMonth.startOf('week');
- const endOfWeek = endOfMonth.endOf('week');
+
+ // 獲取月份第一天是星期幾 (0=週日, 1=週一, ..., 6=週六)
+ const firstDayWeekday = startOfMonth.day();
+ // 獲取月份最後一天是星期幾
+ const lastDayWeekday = endOfMonth.day();
+
+ // 計算需要顯示的第一天(從包含本月第一天的那週的週日開始)
+ const startOfWeek = startOfMonth.subtract(firstDayWeekday, 'day');
+ // 計算需要顯示的最後一天(到包含本月最後一天的那週的週六結束)
+ const endOfWeek = endOfMonth.add(6 - lastDayWeekday, 'day');
const dates = [];
let current = startOfWeek;
@@ -211,65 +226,95 @@ const CalendarView: React.FC = ({
initial="hidden"
animate="visible"
>
-
- {/* 星期標題 */}
- {['日', '一', '二', '三', '四', '五', '六'].map((day, index) => (
-
-
+ {/* 星期標題行 */}
+
+ {['日', '一', '二', '三', '四', '五', '六'].map((day, index) => (
+
-
+
{day}
-
-
- ))}
-
+
+ ))}
+
+
{/* 日期網格 */}
- {weeks.map((week, weekIndex) =>
- week.map((date, dayIndex) => {
- const todosForDate = getTodosForDate(date);
- const isCurrentMonth = date.month() === currentDate.month();
- const isToday = date.isSame(dayjs(), 'day');
-
- return (
-
-
-
+ {weeks.map((week, weekIndex) => (
+
+ {week.map((date, dayIndex) => {
+ const todosForDate = getTodosForDate(date);
+ const isCurrentMonth = date.month() === currentDate.month();
+ const isToday = date.isSame(dayjs(), 'day');
+
+ return (
+
-
+ {/* 日期數字和徽章 */}
+
{date.date()}
@@ -280,23 +325,24 @@ const CalendarView: React.FC = ({
color="primary"
sx={{
'& .MuiBadge-badge': {
- fontSize: '0.6rem',
- minWidth: 16,
- height: 16,
+ fontSize: '0.7rem',
+ minWidth: 18,
+ height: 18,
+ right: -6,
+ top: -6,
},
}}
- >
-
-
+ />
)}
+ {/* 待辦事項列表 */}
- {todosForDate.slice(0, 3).map((todo) => (
+ {todosForDate.slice(0, 2).map((todo) => (
handleTodoClick(todo, e)}
@@ -306,12 +352,11 @@ const CalendarView: React.FC = ({
backgroundColor: selectedTodos.includes(todo.id)
? 'rgba(59, 130, 246, 0.2)'
: `${getPriorityColor(todo.priority)}15`,
- borderLeft: `3px solid ${getPriorityColor(todo.priority)}`,
+ borderLeft: `2px solid ${getPriorityColor(todo.priority)}`,
cursor: 'pointer',
transition: 'all 0.2s ease',
'&:hover': {
backgroundColor: `${getPriorityColor(todo.priority)}25`,
- transform: 'translateX(2px)',
},
}}
>
@@ -320,62 +365,42 @@ const CalendarView: React.FC = ({
sx={{
display: 'block',
fontWeight: 600,
- fontSize: '0.65rem',
+ fontSize: '0.7rem',
color: 'text.primary',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
+ lineHeight: 1.2,
}}
>
{todo.starred && }
{todo.title}
-
-
-
- {(() => {
- const firstUser = todo.responsible_users_details?.[0] ||
- (todo.responsible_users?.[0] ? {ad_account: todo.responsible_users[0], display_name: todo.responsible_users[0]} : null);
- return firstUser ? (firstUser.display_name || firstUser.ad_account) : '未指派';
- })()}
-
-
))}
- {todosForDate.length > 3 && (
+ {todosForDate.length > 2 && (
- +{todosForDate.length - 3} 更多
+ +{todosForDate.length - 2} 項
)}
-
-
-
- );
- })
- )}
-
+
+ );
+ })}
+
+ ))}
+
+
);
};
diff --git a/frontend/src/components/todos/ExcelImport.tsx b/frontend/src/components/todos/ExcelImport.tsx
index 2d7cee1..c1d7784 100644
--- a/frontend/src/components/todos/ExcelImport.tsx
+++ b/frontend/src/components/todos/ExcelImport.tsx
@@ -288,7 +288,7 @@ const ExcelImport: React.FC = ({ open, onClose, onImportComple
優先級
到期日
負責人
- 追蹤人
+ 公開設定
@@ -332,12 +332,12 @@ const ExcelImport: React.FC = ({ open, onClose, onImportComple
- {todo.responsible_users.join(', ') || '-'}
+ {(todo.responsible_users && todo.responsible_users.length > 0) ? todo.responsible_users.join(', ') : '-'}
- {todo.followers.join(', ') || '-'}
+ {todo.is_public ? '是' : '否'}
diff --git a/frontend/src/components/todos/TodoDialog.tsx b/frontend/src/components/todos/TodoDialog.tsx
index e801aee..5d27b7e 100644
--- a/frontend/src/components/todos/TodoDialog.tsx
+++ b/frontend/src/components/todos/TodoDialog.tsx
@@ -66,7 +66,6 @@ interface LocalTodo {
starred: boolean;
creator?: User;
responsible: User[];
- tags: string[];
isPublic: boolean;
}
@@ -101,11 +100,9 @@ const TodoDialog: React.FC = ({
dueDate: null,
starred: false,
responsible: [],
- tags: [],
- isPublic: true,
+ isPublic: false, // 預設為非公開
});
- const [tagInput, setTagInput] = useState('');
const [assignToMyself, setAssignToMyself] = useState(false);
// 用戶資料
@@ -178,8 +175,7 @@ const TodoDialog: React.FC = ({
avatar: adAccount.charAt(0).toUpperCase(),
department: '員工'
})),
- tags: apiTodo.tags || [],
- isPublic: true, // 預設值
+ isPublic: false, // 預設值
};
setFormData(editTodo);
} else {
@@ -191,8 +187,7 @@ const TodoDialog: React.FC = ({
dueDate: null,
starred: false,
responsible: [],
- tags: [],
- isPublic: true,
+ isPublic: false,
});
}
setAssignToMyself(false);
@@ -215,20 +210,6 @@ const TodoDialog: React.FC = ({
}));
};
- const handleAddTag = (event: React.KeyboardEvent) => {
- if (event.key === 'Enter' && tagInput.trim()) {
- event.preventDefault();
- const newTag = tagInput.trim();
- if (!(formData.tags || []).includes(newTag)) {
- handleInputChange('tags', [...(formData.tags || []), newTag]);
- }
- setTagInput('');
- }
- };
-
- const handleRemoveTag = (tagToRemove: string) => {
- handleInputChange('tags', (formData.tags || []).filter(tag => tag !== tagToRemove));
- };
const validateForm = (): boolean => {
if (!formData.title.trim()) {
@@ -265,7 +246,6 @@ const TodoDialog: React.FC = ({
responsible_users: responsibleUsers,
starred: formData.starred,
is_public: formData.isPublic,
- tags: formData.tags
};
let savedTodo;
@@ -653,62 +633,14 @@ const TodoDialog: React.FC = ({
)}
- {/* 標籤和設定 */}
+ {/* 設定 */}
- 標籤和設定
+ 設定
-
- setTagInput(e.target.value)}
- onKeyDown={handleAddTag}
- sx={{
- '& .MuiOutlinedInput-root': {
- borderRadius: 2,
- backgroundColor: actualTheme === 'dark'
- ? 'rgba(255, 255, 255, 0.05)'
- : 'rgba(0, 0, 0, 0.02)',
- }
- }}
- />
-
-
- {(formData.tags || []).map((tag, index) => (
-
- handleRemoveTag(tag)}
- deleteIcon={}
- sx={{
- borderRadius: 2,
- backgroundColor: actualTheme === 'dark'
- ? 'rgba(139, 92, 246, 0.2)'
- : 'rgba(139, 92, 246, 0.1)',
- color: '#8b5cf6',
- '&:hover': {
- backgroundColor: actualTheme === 'dark'
- ? 'rgba(139, 92, 246, 0.3)'
- : 'rgba(139, 92, 246, 0.15)',
- },
- }}
- />
-
- ))}
-
-
-