This commit is contained in:
beabigegg
2025-09-01 16:42:41 +08:00
parent 22a231d78c
commit 00061adeb7
23 changed files with 858 additions and 3584 deletions

View File

@@ -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": []
}
}

32
.gitignore vendored
View File

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

File diff suppressed because it is too large Load Diff

556
README.md
View File

@@ -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 <repository-url>
cd TODOLIST
```
### 2. 資料庫準備
#### 方式一:使用 Docker (推薦)
```bash
# 啟動 MySQL 和 Redis
docker-compose up mysql redis -d
# 等待資料庫啟動完成 (大約30秒)
docker-compose logs mysql
```
#### 方式二:本地 MySQL
```bash
# 建立資料庫
mysql -u root -p
CREATE DATABASE todo_system CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'todouser'@'localhost' IDENTIFIED BY 'todopass';
GRANT ALL PRIVILEGES ON todo_system.* TO 'todouser'@'localhost';
FLUSH PRIVILEGES;
EXIT;
# 初始化資料庫結構
mysql -u todouser -p todo_system < mysql/init/01-init.sql
```
### 3. 後端設定
### 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 部門或查看相關文檔。

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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)

View File

@@ -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'
'是=公開任務,否=只有建立者和負責人能看到'
]
}

View File

@@ -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}")

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()

View File

@@ -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:

View File

@@ -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: <LightMode /> },
{ value: 'dark', label: '深色模式', icon: <DarkMode /> },
{ value: 'system', label: '跟隨系統', icon: <SettingsBrightness /> },
];
const tabs = [
{ id: 'profile', label: '個人資料', icon: <Person /> },
{ id: 'appearance', label: '外觀主題', icon: <Palette /> },
{ id: 'notifications', label: '通知設定', icon: <Notifications /> },
{ id: 'privacy', label: '隱私安全', icon: <Security /> },
{ id: 'work', label: '工作偏好', icon: <Schedule /> },
];
const renderProfileSettings = () => (
<motion.div variants={itemVariants}>
<Card sx={{
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
border: `1px solid ${actualTheme === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}`,
}}>
<CardContent sx={{ p: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Person sx={{ color: 'primary.main', fontSize: 24 }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
</Typography>
</Box>
{/* 頭像區域 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 4 }}>
<Box sx={{ position: 'relative' }}>
<Avatar
sx={{
width: 80,
height: 80,
fontSize: '2rem',
fontWeight: 700,
background: 'linear-gradient(45deg, #3b82f6 30%, #8b5cf6 90%)',
}}
>
{userSettings.avatar}
</Avatar>
<IconButton
sx={{
position: 'absolute',
bottom: -5,
right: -5,
backgroundColor: 'primary.main',
color: 'white',
width: 32,
height: 32,
'&:hover': {
backgroundColor: 'primary.dark',
},
}}
>
<PhotoCamera sx={{ fontSize: 16 }} />
</IconButton>
</Box>
<Box>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 0.5 }}>
{userSettings.name}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{userSettings.position} · {userSettings.department}
</Typography>
<Chip
label="已驗證"
color="success"
size="small"
icon={<Shield sx={{ fontSize: 14 }} />}
/>
</Box>
</Box>
<Grid container spacing={3}>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="姓名"
value={userSettings.name}
onChange={(e) => setUserSettings(prev => ({ ...prev, name: e.target.value }))}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(0, 0, 0, 0.02)',
}
}}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="電子信箱"
value={userSettings.email}
onChange={(e) => setUserSettings(prev => ({ ...prev, email: e.target.value }))}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(0, 0, 0, 0.02)',
}
}}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="部門"
value={userSettings.department}
onChange={(e) => setUserSettings(prev => ({ ...prev, department: e.target.value }))}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(0, 0, 0, 0.02)',
}
}}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="職位"
value={userSettings.position}
onChange={(e) => setUserSettings(prev => ({ ...prev, position: e.target.value }))}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(0, 0, 0, 0.02)',
}
}}
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="電話號碼"
value={userSettings.phone}
onChange={(e) => setUserSettings(prev => ({ ...prev, phone: e.target.value }))}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(0, 0, 0, 0.02)',
}
}}
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
multiline
rows={3}
label="個人簡介"
placeholder="簡單介紹一下自己..."
value={userSettings.bio}
onChange={(e) => setUserSettings(prev => ({ ...prev, bio: e.target.value }))}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
backgroundColor: actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(0, 0, 0, 0.02)',
}
}}
/>
</Grid>
</Grid>
</CardContent>
</Card>
</motion.div>
);
const renderAppearanceSettings = () => (
<motion.div variants={itemVariants}>
<Card sx={{
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
border: `1px solid ${actualTheme === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}`,
}}>
<CardContent sx={{ p: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Palette sx={{ color: 'primary.main', fontSize: 24 }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
</Typography>
</Box>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
</Typography>
<Grid container spacing={3}>
{themeOptions.map((option) => (
<Grid item xs={12} sm={4} key={option.value}>
<motion.div
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Paper
onClick={() => setThemeMode(option.value as 'light' | 'dark' | 'auto')}
sx={{
p: 3,
cursor: 'pointer',
textAlign: 'center',
backgroundColor: themeMode === option.value
? (actualTheme === 'dark' ? 'rgba(59, 130, 246, 0.2)' : 'rgba(59, 130, 246, 0.1)')
: (actualTheme === 'dark' ? '#374151' : '#f9fafb'),
border: themeMode === option.value
? `2px solid ${actualTheme === 'dark' ? '#60a5fa' : '#3b82f6'}`
: `1px solid ${actualTheme === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}`,
borderRadius: 3,
transition: 'all 0.3s ease',
'&:hover': {
backgroundColor: actualTheme === 'dark'
? 'rgba(59, 130, 246, 0.1)'
: 'rgba(59, 130, 246, 0.05)',
transform: 'translateY(-2px)',
},
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'center',
mb: 2,
color: themeMode === option.value ? 'primary.main' : 'text.secondary',
'& svg': {
fontSize: 40,
},
}}
>
{option.icon}
</Box>
<Typography
variant="h6"
sx={{
fontWeight: 600,
color: themeMode === option.value ? 'primary.main' : 'text.primary',
mb: 1,
}}
>
{option.label}
</Typography>
{themeMode === option.value && (
<Chip
label="已選擇"
color="primary"
size="small"
sx={{ fontWeight: 600 }}
/>
)}
</Paper>
</motion.div>
</Grid>
))}
</Grid>
{/* 預覽區域 */}
<Box sx={{ mt: 4 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
</Typography>
<Paper
sx={{
p: 3,
backgroundColor: actualTheme === 'dark' ? '#374151' : '#f8fafc',
border: `1px solid ${actualTheme === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}`,
borderRadius: 2,
}}
>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 600 }}>
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<Chip label="進行中" color="primary" size="small" />
<Chip label="高優先級" color="error" size="small" />
<Typography variant="body2" color="text.secondary">
2024-01-15
</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
{themeMode === 'light' ? '亮色' : themeMode === 'dark' ? '深色' : '系統'}
</Typography>
</Paper>
</Box>
</CardContent>
</Card>
</motion.div>
);
const renderNotificationSettings = () => (
<motion.div variants={itemVariants}>
<Card sx={{
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
border: `1px solid ${actualTheme === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}`,
}}>
<CardContent sx={{ p: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Notifications sx={{ color: 'primary.main', fontSize: 24 }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
<Card sx={{
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
border: `1px solid ${actualTheme === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}`,
}}>
<CardContent sx={{ p: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Notifications sx={{ color: 'primary.main', fontSize: 24 }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
</Typography>
</Box>
<Grid container spacing={3}>
<Grid item xs={12}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2, color: 'text.secondary' }}>
</Typography>
</Box>
<Grid container spacing={3}>
<Grid item xs={12}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2, color: 'text.secondary' }}>
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<FormControlLabel
control={
<Switch
checked={notificationSettings.emailNotifications}
onChange={(e) => setNotificationSettings(prev => ({ ...prev, emailNotifications: e.target.checked }))}
color="primary"
/>
}
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Email sx={{ fontSize: 18, color: 'text.secondary' }} />
</Box>
}
/>
<FormControlLabel
control={
<Switch
checked={notificationSettings.pushNotifications}
onChange={(e) => setNotificationSettings(prev => ({ ...prev, pushNotifications: e.target.checked }))}
color="primary"
/>
}
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Notifications sx={{ fontSize: 18, color: 'text.secondary' }} />
</Box>
}
/>
<FormControlLabel
control={
<Switch
checked={notificationSettings.smsNotifications}
onChange={(e) => setNotificationSettings(prev => ({ ...prev, smsNotifications: e.target.checked }))}
color="primary"
/>
}
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Sms sx={{ fontSize: 18, color: 'text.secondary' }} />
</Box>
}
/>
</Box>
</Grid>
<Grid item xs={12}>
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2, color: 'text.secondary' }}>
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<FormControlLabel
control={
<Switch
checked={notificationSettings.todoReminders}
onChange={(e) => setNotificationSettings(prev => ({ ...prev, todoReminders: e.target.checked }))}
color="primary"
/>
}
label="待辦事項提醒"
/>
<FormControlLabel
control={
<Switch
checked={notificationSettings.deadlineAlerts}
onChange={(e) => setNotificationSettings(prev => ({ ...prev, deadlineAlerts: e.target.checked }))}
color="primary"
/>
}
label="截止日期警告"
/>
<FormControlLabel
control={
<Switch
checked={notificationSettings.weeklyReports}
onChange={(e) => setNotificationSettings(prev => ({ ...prev, weeklyReports: e.target.checked }))}
color="primary"
/>
}
label="每週報告"
/>
</Box>
</Grid>
<Grid item xs={12}>
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2, color: 'text.secondary' }}>
</Typography>
<Box sx={{ mb: 2 }}>
<FormControlLabel
control={
<Switch
checked={notificationSettings.soundEnabled}
onChange={(e) => setNotificationSettings(prev => ({ ...prev, soundEnabled: e.target.checked }))}
color="primary"
/>
}
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<VolumeUp sx={{ fontSize: 18, color: 'text.secondary' }} />
</Box>
}
/>
</Box>
{notificationSettings.soundEnabled && (
<Box sx={{ px: 2, mb: 2 }}>
<Typography variant="caption" color="text.secondary" gutterBottom>
: {notificationSettings.soundVolume}%
</Typography>
<Slider
value={notificationSettings.soundVolume}
onChange={(_, value) => setNotificationSettings(prev => ({ ...prev, soundVolume: value as number }))}
min={0}
max={100}
step={10}
marks
sx={{ color: 'primary.main' }}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<FormControlLabel
control={
<Switch
checked={notificationSettings.emailNotifications}
onChange={(e) => setNotificationSettings(prev => ({ ...prev, emailNotifications: e.target.checked }))}
color="primary"
/>
</Box>
)}
</Grid>
}
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Email sx={{ fontSize: 18, color: 'text.secondary' }} />
</Box>
}
/>
</Box>
</Grid>
</CardContent>
</Card>
</motion.div>
<Grid item xs={12}>
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2, color: 'text.secondary' }}>
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<FormControlLabel
control={
<Switch
checked={notificationSettings.todoReminders}
onChange={(e) => setNotificationSettings(prev => ({ ...prev, todoReminders: e.target.checked }))}
color="primary"
/>
}
label="待辦事項提醒"
/>
<FormControlLabel
control={
<Switch
checked={notificationSettings.deadlineAlerts}
onChange={(e) => setNotificationSettings(prev => ({ ...prev, deadlineAlerts: e.target.checked }))}
color="primary"
/>
}
label="截止日期警告"
/>
<FormControlLabel
control={
<Switch
checked={notificationSettings.weeklyReports}
onChange={(e) => setNotificationSettings(prev => ({ ...prev, weeklyReports: e.target.checked }))}
color="primary"
/>
}
label="每週報告"
/>
</Box>
</Grid>
<Grid item xs={12}>
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2, color: 'text.secondary' }}>
</Typography>
<Box sx={{ mb: 2 }}>
<FormControlLabel
control={
<Switch
checked={notificationSettings.soundEnabled}
onChange={(e) => setNotificationSettings(prev => ({ ...prev, soundEnabled: e.target.checked }))}
color="primary"
/>
}
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<VolumeUp sx={{ fontSize: 18, color: 'text.secondary' }} />
</Box>
}
/>
</Box>
{notificationSettings.soundEnabled && (
<Box sx={{ px: 2, mb: 2 }}>
<Typography variant="caption" color="text.secondary" gutterBottom>
: {notificationSettings.soundVolume}%
</Typography>
<Slider
value={notificationSettings.soundVolume}
onChange={(_, value) => setNotificationSettings(prev => ({ ...prev, soundVolume: value as number }))}
min={0}
max={100}
step={10}
marks
sx={{ color: 'primary.main' }}
/>
</Box>
)}
</Grid>
</Grid>
</CardContent>
</Card>
);
return (
<DashboardLayout>
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
{/* 標題區域 */}
<Box sx={{ mb: 3 }}>
<motion.div variants={itemVariants}>
<Typography
variant="h4"
sx={{
fontWeight: 700,
mb: 0.5,
background: actualTheme === 'dark'
? 'linear-gradient(45deg, #f3f4f6 30%, #d1d5db 90%)'
: 'linear-gradient(45deg, #111827 30%, #374151 90%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}
>
</Typography>
<Typography variant="body1" color="text.secondary">
</Typography>
</motion.div>
<Typography
variant="h4"
sx={{
fontWeight: 700,
mb: 0.5,
background: actualTheme === 'dark'
? 'linear-gradient(45deg, #f3f4f6 30%, #d1d5db 90%)'
: 'linear-gradient(45deg, #111827 30%, #374151 90%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}
>
</Typography>
<Typography variant="body1" color="text.secondary">
</Typography>
</Box>
<Grid container spacing={3}>
{/* 側邊欄 */}
<Grid item xs={12} md={3}>
<motion.div variants={itemVariants}>
<Card sx={{
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
border: `1px solid ${actualTheme === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}`,
}}>
<Box sx={{ p: 2 }}>
{tabs.map((tab) => (
<motion.div
key={tab.id}
whileHover={{ x: 4 }}
whileTap={{ scale: 0.98 }}
>
<Button
fullWidth
onClick={() => setActiveTab(tab.id)}
startIcon={tab.icon}
sx={{
justifyContent: 'flex-start',
textTransform: 'none',
fontWeight: activeTab === tab.id ? 600 : 400,
color: activeTab === tab.id ? 'primary.main' : 'text.primary',
backgroundColor: activeTab === tab.id
? (actualTheme === 'dark' ? 'rgba(59, 130, 246, 0.1)' : 'rgba(59, 130, 246, 0.1)')
: 'transparent',
borderRadius: 2,
mb: 1,
py: 1.5,
px: 2,
'&:hover': {
backgroundColor: actualTheme === 'dark'
? 'rgba(59, 130, 246, 0.1)'
: 'rgba(59, 130, 246, 0.05)',
},
}}
>
{tab.label}
</Button>
</motion.div>
))}
</Box>
</Card>
</motion.div>
</Grid>
{/* 設定內容 */}
{renderNotificationSettings()}
{/* 主要內容 */}
<Grid item xs={12} md={9}>
<AnimatePresence mode="wait">
<motion.div
key={activeTab}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
>
{activeTab === 'profile' && renderProfileSettings()}
{activeTab === 'appearance' && renderAppearanceSettings()}
{activeTab === 'notifications' && renderNotificationSettings()}
{/* 其他 tab 內容可以在這裡添加 */}
</motion.div>
</AnimatePresence>
{/* 儲存按鈕 */}
<motion.div variants={itemVariants} style={{ marginTop: 24 }}>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2 }}>
<Button
variant="outlined"
startIcon={<Refresh />}
sx={{
borderRadius: 2,
textTransform: 'none',
fontWeight: 600,
px: 3,
}}
>
</Button>
<Button
variant="contained"
startIcon={<Save />}
onClick={handleSave}
sx={{
background: 'linear-gradient(45deg, #3b82f6 30%, #8b5cf6 90%)',
borderRadius: 2,
textTransform: 'none',
fontWeight: 600,
px: 3,
'&:hover': {
background: 'linear-gradient(45deg, #2563eb 30%, #7c3aed 90%)',
boxShadow: '0 4px 12px rgba(59, 130, 246, 0.3)',
},
}}
>
</Button>
</Box>
</motion.div>
</Grid>
</Grid>
{/* 儲存按鈕 */}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, mt: 3 }}>
<Button
variant="outlined"
startIcon={<Refresh />}
sx={{
borderRadius: 2,
textTransform: 'none',
fontWeight: 600,
px: 3,
}}
>
</Button>
<Button
variant="contained"
startIcon={<Save />}
onClick={handleSave}
sx={{
background: 'linear-gradient(45deg, #3b82f6 30%, #8b5cf6 90%)',
borderRadius: 2,
textTransform: 'none',
fontWeight: 600,
px: 3,
'&:hover': {
background: 'linear-gradient(45deg, #2563eb 30%, #7c3aed 90%)',
boxShadow: '0 4px 12px rgba(59, 130, 246, 0.3)',
},
}}
>
</Button>
</Box>
{/* 成功通知 */}
<Snackbar
@@ -753,7 +253,7 @@ const SettingsPage = () => {
fontWeight: 600,
}}
>
</Alert>
</Snackbar>
</motion.div>

View File

@@ -51,22 +51,22 @@ const DashboardLayout: React.FC<DashboardLayoutProps> = ({ 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<DashboardLayoutProps> = ({ 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<DashboardLayoutProps> = ({ children }) => {
}
if (isMobile) {
console.log('📱 Mobile: Setting sidebar open to:', !sidebarOpen);
setSidebarOpen(!sidebarOpen);
} else {
console.log('🖥️ Desktop: Toggling collapsed to:', !sidebarCollapsed);
setSidebarCollapsed(!sidebarCollapsed);
}
};

View File

@@ -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<CalendarViewProps> = ({
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<CalendarViewProps> = ({
initial="hidden"
animate="visible"
>
<Grid container spacing={1}>
{/* 星期標題 */}
{['日', '一', '二', '三', '四', '五', '六'].map((day, index) => (
<Grid item xs key={index}>
<Paper
<Box
sx={{
backgroundColor: actualTheme === 'dark' ? '#1f2937' : '#ffffff',
border: `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}`,
borderRadius: 2,
overflow: 'hidden'
}}
>
{/* 星期標題行 */}
<Box sx={{
display: 'grid',
gridTemplateColumns: 'repeat(7, 1fr)',
backgroundColor: actualTheme === 'dark' ? '#374151' : '#f3f4f6',
borderBottom: `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}`,
}}>
{['日', '一', '二', '三', '四', '五', '六'].map((day, index) => (
<Box
key={index}
sx={{
p: 1,
p: 2,
textAlign: 'center',
backgroundColor: actualTheme === 'dark' ? '#374151' : '#f3f4f6',
border: 'none',
borderRight: index < 6 ? `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}` : 'none',
}}
>
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.secondary' }}>
{day}
</Typography>
</Paper>
</Grid>
))}
</Box>
))}
</Box>
{/* 日期網格 */}
{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 (
<Grid item xs key={`${weekIndex}-${dayIndex}`}>
<motion.div variants={itemVariants}>
<Card
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
{weeks.map((week, weekIndex) => (
<Box
key={weekIndex}
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(7, 1fr)',
borderBottom: weekIndex < weeks.length - 1 ? `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}` : 'none',
}}
>
{week.map((date, dayIndex) => {
const todosForDate = getTodosForDate(date);
const isCurrentMonth = date.month() === currentDate.month();
const isToday = date.isSame(dayjs(), 'day');
return (
<Box
key={`${weekIndex}-${dayIndex}`}
sx={{
minHeight: 120,
p: 1,
backgroundColor: actualTheme === 'dark'
? (isCurrentMonth ? '#1f2937' : '#111827')
: (isCurrentMonth ? '#ffffff' : '#f9fafb'),
border: isToday
? `2px solid ${actualTheme === 'dark' ? '#60a5fa' : '#3b82f6'}`
: `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}`,
p: 1.5,
borderRight: dayIndex < 6 ? `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}` : 'none',
backgroundColor: isToday
? (actualTheme === 'dark' ? 'rgba(59, 130, 246, 0.1)' : 'rgba(59, 130, 246, 0.05)')
: (isCurrentMonth
? 'transparent'
: (actualTheme === 'dark' ? 'rgba(0, 0, 0, 0.2)' : 'rgba(0, 0, 0, 0.02)')),
cursor: 'pointer',
transition: 'all 0.2s ease',
position: 'relative',
'&:hover': {
backgroundColor: actualTheme === 'dark'
? 'rgba(59, 130, 246, 0.1)'
: 'rgba(59, 130, 246, 0.05)',
transform: 'translateY(-1px)',
},
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
{/* 日期數字和徽章 */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography
variant="caption"
variant="body2"
sx={{
fontWeight: isToday ? 700 : 500,
fontWeight: isToday ? 700 : (isCurrentMonth ? 500 : 400),
color: isCurrentMonth
? (isToday ? 'primary.main' : 'text.primary')
: 'text.disabled',
fontSize: '0.875rem',
}}
>
{date.date()}
@@ -280,23 +325,24 @@ const CalendarView: React.FC<CalendarViewProps> = ({
color="primary"
sx={{
'& .MuiBadge-badge': {
fontSize: '0.6rem',
minWidth: 16,
height: 16,
fontSize: '0.7rem',
minWidth: 18,
height: 18,
right: -6,
top: -6,
},
}}
>
<Circle sx={{ fontSize: 8, color: 'primary.main' }} />
</Badge>
/>
)}
</Box>
{/* 待辦事項列表 */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{todosForDate.slice(0, 3).map((todo) => (
{todosForDate.slice(0, 2).map((todo) => (
<motion.div
key={todo.id}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
>
<Box
onClick={(e) => handleTodoClick(todo, e)}
@@ -306,12 +352,11 @@ const CalendarView: React.FC<CalendarViewProps> = ({
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<CalendarViewProps> = ({
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 && <Star sx={{ fontSize: 10, color: '#fbbf24', mr: 0.25 }} />}
{todo.title}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, mt: 0.25 }}>
<Circle
sx={{
fontSize: 6,
color: getStatusColor(todo.status),
}}
/>
<Typography
variant="caption"
sx={{
fontSize: '0.6rem',
color: 'text.secondary',
}}
>
{(() => {
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) : '未指派';
})()}
</Typography>
</Box>
</Box>
</motion.div>
))}
{todosForDate.length > 3 && (
{todosForDate.length > 2 && (
<Typography
variant="caption"
sx={{
fontSize: '0.6rem',
fontSize: '0.65rem',
color: 'text.secondary',
textAlign: 'center',
mt: 0.5,
fontStyle: 'italic',
}}
>
+{todosForDate.length - 3}
+{todosForDate.length - 2}
</Typography>
)}
</Box>
</Card>
</motion.div>
</Grid>
);
})
)}
</Grid>
</Box>
);
})}
</Box>
))}
</Box>
</Box>
</motion.div>
);
};

View File

@@ -288,7 +288,7 @@ const ExcelImport: React.FC<ExcelImportProps> = ({ open, onClose, onImportComple
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
</TableRow>
</TableHead>
<TableBody>
@@ -332,12 +332,12 @@ const ExcelImport: React.FC<ExcelImportProps> = ({ open, onClose, onImportComple
</TableCell>
<TableCell>
<Typography variant="body2">
{todo.responsible_users.join(', ') || '-'}
{(todo.responsible_users && todo.responsible_users.length > 0) ? todo.responsible_users.join(', ') : '-'}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2">
{todo.followers.join(', ') || '-'}
{todo.is_public ? '是' : ''}
</Typography>
</TableCell>
</TableRow>

View File

@@ -66,7 +66,6 @@ interface LocalTodo {
starred: boolean;
creator?: User;
responsible: User[];
tags: string[];
isPublic: boolean;
}
@@ -101,11 +100,9 @@ const TodoDialog: React.FC<TodoDialogProps> = ({
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<TodoDialogProps> = ({
avatar: adAccount.charAt(0).toUpperCase(),
department: '員工'
})),
tags: apiTodo.tags || [],
isPublic: true, // 預設值
isPublic: false, // 預設值
};
setFormData(editTodo);
} else {
@@ -191,8 +187,7 @@ const TodoDialog: React.FC<TodoDialogProps> = ({
dueDate: null,
starred: false,
responsible: [],
tags: [],
isPublic: true,
isPublic: false,
});
}
setAssignToMyself(false);
@@ -215,20 +210,6 @@ const TodoDialog: React.FC<TodoDialogProps> = ({
}));
};
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<TodoDialogProps> = ({
responsible_users: responsibleUsers,
starred: formData.starred,
is_public: formData.isPublic,
tags: formData.tags
};
let savedTodo;
@@ -653,62 +633,14 @@ const TodoDialog: React.FC<TodoDialogProps> = ({
</Grid>
)}
{/* 標籤和設定 */}
{/* 設定 */}
<Grid item xs={12}>
<Divider sx={{ my: 2 }} />
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2 }}>
</Typography>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="新增標籤"
placeholder="輸入標籤並按 Enter..."
value={tagInput}
onChange={(e) => 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)',
}
}}
/>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 2 }}>
{(formData.tags || []).map((tag, index) => (
<motion.div
key={tag}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: index * 0.1 }}
>
<Chip
label={tag}
onDelete={() => handleRemoveTag(tag)}
deleteIcon={<Delete sx={{ fontSize: 16 }} />}
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)',
},
}}
/>
</motion.div>
))}
</Box>
</Grid>
<Grid item xs={12}>
<FormControlLabel
control={

View File

@@ -20,7 +20,6 @@ export interface Todo {
creator_email?: string;
starred: boolean;
is_public: boolean;
tags: string[];
responsible_users: string[];
followers: string[];
responsible_users_details?: UserDetail[];
@@ -35,7 +34,6 @@ export interface TodoCreate {
due_date?: string;
starred?: boolean;
is_public?: boolean;
tags?: string[];
responsible_users?: string[];
followers?: string[];
}
@@ -52,7 +50,6 @@ export interface TodoFilter {
due_to?: string;
search?: string;
view?: 'all' | 'created' | 'responsible' | 'following' | 'public';
tags?: string[];
}
// User Types

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

View File

@@ -1,469 +1,360 @@
# PANJIT To-Do System 使用者手冊
# PANJIT To-Do 系統 - 使用者手冊
## 📖 目錄
1. [系統簡介](#系統簡介)
2. [登入系統](#登入系統)
3. [主要功能](#主要功能)
4. [待辦事項管理](#待辦事項管理)
5. [協作功能](#協作功能)
6. [通知設定](#通知設定)
7. [Excel 匯入匯出](#excel-匯入匯出)
8. [進階功能](#進階功能)
1. [系統登入](#系統登入)
2. [主要介面說明](#主要介面說明)
3. [待辦事項管理](#待辦事項管理)
4. [篩選與搜尋](#篩選與搜尋)
5. [日曆視圖](#日曆視圖)
6. [Excel 功能](#excel-功能)
7. [通知設定](#通知設定)
8. [系統設定](#系統設定)
9. [常見問題](#常見問題)
---
## 系統簡介
## 🔐 系統登入
PANJIT To-Do System 是一個專為企業環境設計的待辦事項管理系統,提供:
### 登入步驟
1. 開啟瀏覽器,輸入系統網址
2. 在登入頁面輸入您的 **AD 帳號****密碼**
3. 點擊「登入」按鈕
- **統一登入**:使用公司 AD/LDAP 帳號直接登入
- **多人協作**:支援團隊合作、責任分工
- **智能通知**:自動化郵件提醒,不錯過任何重要任務
- **數據管理**Excel 批量匯入匯出,高效處理大量資料
- **行動支援**:響應式設計,桌面和行動裝置完美適配
### 登入注意事項
- 請使用公司 Active Directory 帳號
- 如果忘記密碼,請聯繫 IT 部門重設
- 系統支援記住登入狀態功能
---
## 登入系統
## 🏠 主要介面說明
### 🔐 第一次登入
### 導航側邊欄
系統左側提供完整的功能導航:
1. **開啟系統**
- 瀏覽器開啟:`http://your-company-domain.com`
- 系統支援Chrome、Firefox、Safari、Edge
#### 主要功能
- **儀表板**:查看系統總覽和統計資料
- **待辦清單**:管理所有待辦事項
- **公開任務**:查看公開的待辦事項
- **日曆視圖**:以日曆形式查看任務
2. **輸入帳號密碼**
- 使用者名稱:`帳號@panjit.com.tw``帳號`
- 密碼:與 Windows 登入相同
3. **首次登入設定**
- 系統會自動從 AD 取得您的姓名和 Email
- 建立個人偏好設定
- 設定預設通知選項
#### 視圖篩選
- **已加星**:查看標記星號的重要事項
- **我建立的**:顯示由您建立的待辦事項
- **指派給我**:顯示指派給您的待辦事項
- **我追蹤的**:顯示您正在追蹤的待辦事項
### ⚙️ 登入選項
#### 狀態分類
- **新建立**:剛建立尚未開始的任務
- **進行中**:正在進行的任務
- **已阻塞**:遇到問題暫時停止的任務
- **已完成**:已經完成的任務
- **記住我**勾選後下次自動登入7天內有效
- **語言切換**:支援繁體中文、英文
- **主題模式**淺色/深色主題切換
### 頂部工具列
- **選單按鈕**:在小螢幕上收合/展開側邊欄
- **主題切換**:切換亮色/暗色/自動模式
- **通知鈴鐺**:查看系統通知
- **使用者頭像**:存取個人設定和登出
---
## 主要功能
## ✅ 待辦事項管理
### 📱 系統介面
### 建立新的待辦事項
1. 點擊「+ 新增待辦事項」按鈕
2. 填寫以下資訊:
- **標題**:簡明的任務描述(必填)
- **內容**:詳細的任務說明
- **截止日期**:任務完成期限
- **優先級**:高、中、低
- **負責人**:可指派給其他同事
- **追蹤者**:需要關注此任務的人員
- **標籤**:用於分類管理
3. 點擊「建立」按鈕
#### 頂部導航欄
- **系統標題**:顯示目前頁面
- **搜尋列**:快速搜尋待辦事項
- **通知鈴鐺**:系統通知提醒
- **使用者頭像**:個人設定選單
### 編輯待辦事項
1. 在待辦清單中點擊要編輯的項目
2. 修改所需的資訊
3. 點擊「儲存」按鈕確認變更
#### 左側選單
- **儀表板**:系統概覽和統計
- **待辦清單**主要工作區域
- **行事曆**:時間軸檢視
- **設定**:個人化設定
### 狀態管理
- **拖放操作**:直接拖拽任務到不同狀態欄
- **下拉選單**點擊狀態下拉選單切換
- **快速按鈕**:使用工具列上的快速狀態按鈕
#### 主要工作區
- **工具列**:篩選、排序、批量操作
- **清單檢視**:條列式顯示
- **卡片檢視**:圖像化顯示
- **行事曆檢視**:時間軸顯示
### 重要功能
- **加星標記**:點擊星號圖示標記重要任務
- **複製任務**:快速建立相似任務
- **刪除任務**:刪除不需要的任務(僅限建立者)
---
## 待辦事項管理
## 🔍 篩選與搜尋
### 建立新待辦事項
### 搜尋功能
- 在頂部搜尋框輸入關鍵字
- 支援搜尋標題、內容、負責人、建立者
- 即時搜尋,輸入即顯示結果
1. **點選「新增待辦」按鈕**
- 位於右上角藍色按鈕
- 快捷鍵:`Ctrl + N`
### 篩選選項
點擊「篩選」按鈕可設定以下條件:
- **狀態篩選**:選擇特定狀態的任務
- **優先級篩選**:篩選不同優先級
- **日期範圍**:設定建立日期或截止日期範圍
- **負責人篩選**:查看特定人員的任務
- **標籤篩選**:依標籤分類查看
2. **填寫基本資訊**
```
標題*:簡潔描述任務內容
描述:詳細說明和注意事項
狀態:新建立 (預設)
優先級:中 (預設)
到期日:選擇完成期限
```
3. **指派人員**
- **負責人**:執行任務的人員
- 支援多人負責
- 可搜尋公司同事姓名或帳號
- 自動驗證帳號存在性
- **追蹤人員**:需要了解進度的人員
- 主管或相關部門同事
- 會收到狀態變更通知
4. **進階設定**
- **標籤**:分類標記(可自訂)
- **星號標記**:重要項目標示
- **公開/私人**:可見性設定
### ✏️ 編輯待辦事項
1. **開啟編輯模式**
- 點選事項標題直接編輯
- 或點選「編輯」按鈕
2. **權限說明**
- **建立者**:完整編輯權限
- **負責人**:可修改狀態、描述
- **追蹤人員**:僅可查看
3. **狀態變更**
- **新建立** → **進行中**:開始執行
- **進行中** → **已阻礙**:遇到困難需協助
- **已阻礙** → **進行中**:問題解決繼續執行
- **進行中** → **已完成**:任務完成
### 🔍 搜尋和篩選
#### 快速搜尋
- **關鍵字搜尋**:標題、描述、人員姓名
- **模糊比對**:不需輸入完整字詞
- **即時搜尋**:輸入即時顯示結果
#### 進階篩選
1. **點選篩選按鈕**(漏斗圖示)
2. **設定篩選條件**
- 狀態:單選或多選
- 優先級:依重要程度篩選
- 指派人員:查看特定人員的任務
- 日期範圍:到期日區間
- 標籤:依分類查看
3. **常用篩選**
- 我負責的:顯示自己的任務
- 我追蹤的:顯示關注的項目
- 逾期項目:已過期未完成
- 本週到期:七天內到期
#### 排序選項
- **到期日**:依時間緊急程度
- **優先級**:依重要程度
- **建立時間**:依新舊程度
- **狀態**:依進度分組
- **標題**:字母順序
### 排序選項
- **建立時間**:依建立時間排序
- **截止日期**:依到期日排序
- **優先級**:依重要性排序
- **狀態**:依任務狀態排序
---
## 協作功能
## 📅 日曆視圖
### 👥 團隊協作
### 檢視模式
- **月檢視**:以月份為單位顯示任務
- **週檢視**:以週為單位的詳細檢視
- **日檢視**:單日的詳細任務排程
#### 角色說明
- **建立者**:任務發起人,擁有完整權限
- **負責人**:實際執行者,可編輯任務內容
- **追蹤人員**:關注者,可查看進度並接收通知
### 日曆操作
- **拖放任務**:直接拖拽任務到其他日期
- **快速建立**:點擊日期快速建立任務
- **任務詳情**:點擊任務查看完整資訊
#### 協作流程
1. **任務指派**
```
建立者 → 指派負責人 → 設定追蹤人員 → 發送通知
```
2. **進度更新**
```
負責人更新狀態 → 系統記錄變更 → 自動通知相關人員
```
3. **問題處理**
```
遇到阻礙 → 標記為「已阻礙」→ 通知建立者/追蹤人員 → 協助解決
```
### 🔔 即時通知
#### Fire 一鍵提醒
- **功能說明**:立即發送郵件提醒
- **使用時機**:急件處理、重要提醒
- **限制機制**
- 2分鐘冷卻時間
- 每日20封限額
- 防止過度騷擾
#### 自動化通知
- **到期前提醒**3天前自動發送
- **當天提醒**:到期當日早上通知
- **逾期提醒**逾期1天後提醒
- **狀態變更**:任務狀態改變時通知
#### 週摘要報告
- **發送時間**每週一早上9點
- **內容包含**
- 本週待完成任務
- 逾期項目統計
- 新指派任務
- 完成項目回顧
### 顏色編碼
- **紅色**:逾期任務
- **橙色**即將到期3天內
- **藍色**:一般任務
- **綠色**:已完成任務
- **灰色**:已阻塞任務
---
## 通知設定
## 📊 Excel 功能
### 📧 郵件通知設定
### 匯入待辦事項
1. 點擊「Excel 匯入」按鈕
2. 下載匯入模板(首次使用建議)
3. 填寫 Excel 檔案:
- **標題**:任務標題(必填)
- **內容**:任務詳細說明
- **截止日期**:格式為 YYYY-MM-DD
- **優先級**HIGH/MEDIUM/LOW
- **負責人**AD帳號或郵件地址
- **狀態**NEW/DOING/BLOCKED/DONE
4. 上傳並預覽匯入資料
5. 確認無誤後執行匯入
1. **開啟設定頁面**
- 點選右上角個人頭像
- 選擇「通知設定」
### 匯出待辦事項
1. 在待辦清單頁面點擊「Excel 匯出」
2. 選擇匯出範圍:
- **全部任務**
- **目前篩選結果**
- **選中的任務**
3. 選擇匯出格式和欄位
4. 點擊「匯出」下載檔案
2. **通知類型設定**
- ✅ 新任務指派通知
- ✅ 任務狀態變更通知
- ✅ 到期提醒通知
- ✅ Fire 一鍵提醒
- ✅ 週摘要報告
3. **時間設定**
- **提醒天數**到期前幾天提醒1-7天
- **工作時間**僅工作時間發送9:00-18:00
- **週末通知**:是否包含週末
4. **郵件格式**
- **HTML 格式**:豐富內容顯示
- **純文字格式**:相容性更好
- **摘要模式**:減少郵件數量
### 🔕 勿擾模式
- **暫停所有通知**:臨時關閉通知
- **例外設定**:緊急通知例外
- **自動恢復**:設定恢復時間
### Excel 模板說明
- **必填欄位**:標題
- **日期格式**YYYY-MM-DD HH:MM
- **狀態值**NEW, DOING, BLOCKED, DONE
- **優先級值**HIGH, MEDIUM, LOW
- **使用者格式**AD帳號或完整郵件地址
---
## Excel 匯入匯出
## 🔐 權限說明與控制
### 📥 匯入待辦事項
### 權限矩陣
#### Step 1下載模板
1. **點選「Excel 匯入」按鈕**
2. **下載標準模板**
- 包含所有必要欄位
- 內建格式驗證
- 範例資料參考
系統的待辦事項權限控制如下:
#### Step 2填寫資料
```excel
標題* | 描述 | 狀態 | 優先級 | 到期日 | 負責人* | 追蹤人員
完成報告撰寫 | 月報內容 | NEW | HIGH | 2024/1/15 | user1 | user2,user3
系統維護 | 定期檢查 | DOING | MEDIUM | 2024/1/20 | user2 | user1
```
| 操作權限 | 建立者 | 負責人 | 追蹤者 | 其他使用者 |
|---------|--------|--------|--------|------------|
| **查看非公開任務** | ✅ 可以 | ✅ 可以 | ❌ 不可 | ❌ 不可 |
| **查看公開任務** | ✅ 可以 | ✅ 可以 | ✅ 可以 | ✅ 可以 |
| **編輯任務內容** | ✅ 可以 | ❌ 不可 | ❌ 不可 | ❌ 不可 |
| **刪除任務** | ✅ 可以 | ❌ 不可 | ❌ 不可 | ❌ 不可 |
| **更改任務狀態** | ✅ 可以 | ❌ 不可 | ❌ 不可 | ❌ 不可 |
| **指派負責人** | ✅ 可以 | ❌ 不可 | ❌ 不可 | ❌ 不可 |
| **設定公開/私人** | ✅ 可以 | ❌ 不可 | ❌ 不可 | ❌ 不可 |
| **追蹤公開任務** | ✅ 可以 | ✅ 可以 | ✅ 可以 | ✅ 可以 |
| **追蹤非公開任務** | ❌ 不可 | ❌ 不可 | ❌ 不可 | ❌ 不可 |
**欄位說明:**
- `*` 為必填欄位
- 狀態NEW, DOING, BLOCKED, DONE
- 優先級LOW, MEDIUM, HIGH, URGENT
- 日期格式YYYY/MM/DD
- 人員:使用帳號,多人用逗號分隔
### 角色說明
#### Step 3上傳檔案
1. **選擇檔案**:支援 .xlsx, .xls, .csv
2. **系統驗證**
- 格式檢查
- 欄位驗證
- 人員帳號確認
- 重複項目檢查
#### 建立者 (Creator)
- 對待辦事項擁有**完全控制權**
- 可以執行所有操作:編輯、刪除、狀態變更、指派等
- 是唯一可以將任務設為公開或私人的角色
3. **預覽確認**
- 顯示將匯入的資料
- 標示錯誤項目
- 提供修正建議
#### 負責人 (Responsible User)
- 被指派執行任務的人員
- **只能查看**任務內容,無法編輯
- 可以看到非公開的任務(因為被指派)
- 無法變更任務狀態或內容
4. **執行匯入**
- 成功項目批量建立
- 失敗項目詳細報告
- 自動發送通知
#### 追蹤者 (Follower)
- 關注公開任務進展的人員
- **只能存在於公開任務**
- 僅有查看權限,無法編輯
- 非公開任務不支援追蹤功能
### 📤 匯出待辦事項
#### 其他使用者
- 只能查看和追蹤**公開任務**
- 無法查看非公開任務
- 無任何編輯權限
#### 匯出選項
1. **全部匯出**:所有可存取的待辦事項
2. **篩選匯出**:根據目前篩選條件
3. **選取匯出**:勾選特定項目
4. **範圍匯出**:指定日期範圍
### 重要提醒
#### 檔案格式
- **Excel 格式**:完整資料和格式
- **CSV 格式**:純資料,相容性好
- **PDF 報表**:列印友好格式
#### 匯出內容
- 基本資訊:標題、描述、狀態、優先級
- 時間資訊:建立時間、到期日、完成時間
- 人員資訊:建立者、負責人、追蹤人員
- 進階資訊:標籤、備註、操作記錄
1. **非公開任務**預設只有建立者和被指派的負責人能看到
2. **公開任務**所有系統使用者都能查看和追蹤
3. **編輯權限**僅限建立者,確保任務內容的一致性
4. **追蹤功能**僅適用於公開任務,私人任務不開放追蹤
---
## 進階功能
## 🔔 通知設定
### 📊 統計報表
### 通知類型
系統提供以下通知:
- **任務指派通知**:被指派新任務時
- **狀態變更通知**:任務狀態改變時
- **到期提醒**:任務即將到期時
- **逾期通知**:任務已逾期時
- **評論通知**:任務有新評論時
#### 個人統計
- **任務完成率**:本週/本月完成統計
- **平均處理時間**:從建立到完成的時間
- **優先級分佈**:高/中/低優先級比例
- **逾期分析**:逾期項目和原因分析
### 通知設定
1. 點擊右上角頭像 → 「通知設定」
2. 選擇接收通知的類型:
- **即時通知**:系統內彈出通知
- **郵件通知**:發送到註冊郵箱
- **提醒時間**:設定提前提醒時間
3. 儲存設定
#### 團隊統計
- **部門效率**:各部門完成情況
- **工作量分析**:人員任務負荷
- **協作頻率**:跨部門合作統計
- **趨勢分析**:月度/季度趨勢
#### 系統統計
- **使用者活躍度**:登入和操作頻率
- **功能使用率**:各功能使用統計
- **效能監控**:系統回應時間
- **錯誤率統計**:系統穩定性指標
### 📅 行事曆整合
#### 檢視模式
- **月檢視**:整月任務概覽
- **週檢視**:週計劃詳細檢視
- **日檢視**:單日任務清單
- **時間線**:甘特圖式顯示
#### 互動功能
- **拖拽調整**:直接拖動改變日期
- **快速編輯**:點選事項快速修改
- **顏色標示**:優先級和狀態色彩
- **提醒設定**:行事曆提醒整合
### 🏷️ 標籤管理
#### 標籤功能
- **自訂標籤**:建立個人化分類
- **顏色標示**:視覺化識別
- **快速篩選**:點選標籤快速篩選
- **統計分析**:標籤使用統計
#### 常用標籤建議
- 專案類:`#Project-A`, `#年度計劃`
- 部門類:`#IT`, `#HR`, `#Sales`
- 性質類:`#會議`, `#報告`, `#檢查`
- 狀態類:`#待確認`, `#進行中`, `#暫停`
### 🔍 進階搜尋
#### 搜尋語法
```
標題搜尋title:報告
描述搜尋desc:系統
人員搜尋user:張三
標籤搜尋tag:專案
狀態搜尋status:DOING
日期搜尋due:2024-01-15
```
#### 組合搜尋
```
複合條件title:報告 AND user:張三
排除條件title:會議 NOT status:DONE
日期範圍due:2024-01-01..2024-01-31
```
### 通知管理
- **通知面板**:點擊通知鈴鐺查看所有通知
- **標記已讀**:點擊通知標記為已讀
- **清除通知**:清除所有已讀通知
---
## 常見問題
## ⚙️ 系統設定
### ❓ 登入相關
### 個人設定
1. 點擊右上角頭像 → 「個人設定」
2. 可調整:
- **顯示語言**:系統介面語言
- **時區設定**:本地時區
- **每頁顯示數量**:列表每頁項目數
- **預設視圖**:登入後預設頁面
**Q: 忘記密碼怎麼辦?**
A: 系統使用公司 AD 帳號,請聯繫 IT 部門重設 Windows 密碼。
### 主題設定
- **亮色模式**:白色背景主題
- **暗色模式**:深色背景主題
- **自動切換**:跟隨系統設定
**Q: 帳號被鎖定無法登入?**
A: 通常是密碼輸入錯誤多次,請聯繫 IT 部門解鎖帳號。
### 隱私設定
- **個人資料可見性**:設定其他使用者能看到的資訊
- **任務預設權限**:新建任務的預設可見範圍
**Q: 可以在家裡使用嗎?**
A: 需要連接公司 VPN 後才能存取內部系統。
---
###功能使用
## ❓ 常見問題
**Q: 為什麼找不到某個同事?**
### 登入相關
**Q: 忘記密碼怎麼辦?**
A: 請聯繫 IT 部門重設 AD 密碼,系統使用公司 Active Directory 認證。
**Q: 為什麼無法登入?**
A: 請確認:
1. 輸入正確的姓名或帳號
2. 該同事帳號未被停用
3. 有權限查看該同事資訊
- AD 帳號和密碼正確
- 帳號未被停用
- 網路連線正常
**Q: 郵件通知沒有收到?**
### 使用功能
**Q: 無法建立待辦事項**
A: 請檢查:
1. 垃圾信件匣
2. 通知設定是否開啟
3. 郵箱容量是否已滿
4. 公司郵件伺服器是否正常
- 標題欄位是否已填寫
- 網路連線是否正常
- 是否有足夠的權限
**Q: Excel 匯入失敗?**
**Q: 找不到之前建立的任務**
A: 請嘗試:
- 清除所有篩選條件
- 使用搜尋功能
- 檢查不同狀態分類
**Q: Excel 匯入失敗**
A: 常見原因:
1. 檔案格式不正確
2. 必填欄位為空
3. 日期格式錯誤
4. 人員帳號不存在
- 檔案格式不正確(請使用 .xlsx
- 必填欄位未填寫
- 日期格式錯誤
- 使用者帳號不存在
### ❓ 效能相關
### 通知問題
**Q: 收不到郵件通知**
A: 請檢查:
- 郵件地址是否正確
- 垃圾郵件資料夾
- 通知設定是否開啟
**Q: 系統載入很慢**
**Q: 通知太多怎麼辦**
A: 可以在通知設定中:
- 關閉不需要的通知類型
- 調整提醒時間
- 設定免打擾時段
### 效能問題
**Q: 系統載入很慢**
A: 建議:
1. 使用較新版本的瀏覽器
2. 清除瀏覽器快取
3. 檢查網路連線品質
4. 關閉不必要的分頁
- 清除瀏覽器快取
- 使用較新版本的瀏覽器
- 檢查網路連線品質
**Q: 行動裝置使用不順?**
A: 建議
1. 使用手機瀏覽器而非APP
2. 確保網路訊號穩定
3. 定期清理瀏覽器快取
### ❓ 資料安全
**Q: 資料會不會外洩?**
A: 系統安全措施:
1. 資料存放於公司內部伺服器
2. 使用 HTTPS 加密傳輸
3. AD 統一身份驗證
4. 定期備份和監控
**Q: 刪除的資料可以復原嗎?**
A:
1. 系統有30天的回收站功能
2. 重要資料有定期備份
3. 可聯繫管理員協助復原
**Q: 手機上使用體驗不佳**
A: 系統提供響應式設計
- 支援手機瀏覽器存取
- 建議使用 Chrome 或 Safari
- 確保瀏覽器為最新版本
---
## 🆘 技術支援
### 問題回報
回報問題時請提供:
1. 問題發生時間
2. 操作步驟描述
3. 錯誤訊息截圖
4. 使用的瀏覽器和版本
5. 作業系統資訊
### 聯繫方式
- **IT服務台**:分機 XXX
- **Email**it-support@panjit.com.tw
- **內部聊天群**IT技術支援群組
### 支援時間
- **工作日**09:00 - 18:00
- **緊急問題**24小時 (電話支援)
- **系統維護時間**:每週日 02:00 - 04:00
### 提報問題時請提供
1. **問題描述**:詳細說明遇到的狀況
2. **操作步驟**:問題發生前的操作流程
3. **錯誤訊息**:如有錯誤訊息請完整記錄
4. **環境資訊**:使用的瀏覽器和作業系統
5. **螢幕截圖**:有助於快速診斷問題
### 建議與回饋
歡迎提供系統改善建議:
- 功能需求
- 介面優化建議
- 使用體驗改善
---
**使用者手冊版本**1.0
**最後更新**2025年1月
**適用系統版本**PANJIT To-Do System v1.0
## 📋 快速參考
### 鍵盤快捷鍵
- `Ctrl + N`:建立新待辦事項
- `Ctrl + F`:開啟搜尋
- `Ctrl + /`:顯示快捷鍵說明
- `Esc`:關閉對話框
### 狀態代碼
- **NEW**:新建立
- **DOING**:進行中
- **BLOCKED**:已阻塞
- **DONE**:已完成
### 優先級
- **HIGH**:高優先級(紅色)
- **MEDIUM**:中優先級(橙色)
- **LOW**:低優先級(綠色)
如有任何問題或建議,歡迎隨時聯繫 IT 部門。