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

[暫規通知] 規範 '{spec.spec_code}' 已{action}

-
-
-

您好,

- -
- - - """ - 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 內容可以在這裡添加 */} - - - - {/* 儲存按鈕 */} - - - - - - - - + {/* 儲存按鈕 */} + + + + {/* 成功通知 */} { 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)', - }, - }} - /> - - ))} - - -