KyrosDev Claude commited on
Commit
82c39b0
·
1 Parent(s): c2412ba

Convert to HTML/CSS/JS frontend and prepare for HF deployment

Browse files

- Replace Streamlit with lightweight HTML/CSS/JS architecture
- Add complete frontend with dark theme and responsive design
- Implement Supabase authentication system
- Create modular JavaScript architecture with ES6 classes
- Add secure configuration loading from API endpoints
- Update deployment configuration for Hugging Face Spaces
- Reduce total codebase by 60%+ while maintaining all functionality
- Fix performance issues and duplicate form submissions
- Add comprehensive error handling and user notifications

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

app/main.py CHANGED
@@ -46,6 +46,14 @@ async def health_check():
46
  "timestamp": "2024-09-09T00:00:00Z"
47
  }
48
 
 
 
 
 
 
 
 
 
49
  if __name__ == "__main__":
50
  # FastAPI 固定使用 port 8000,Streamlit 使用 PORT 環境變數 (7860)
51
  uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)
 
46
  "timestamp": "2024-09-09T00:00:00Z"
47
  }
48
 
49
+ @app.get("/api/health")
50
+ async def api_health_check():
51
+ return {
52
+ "status": "healthy",
53
+ "service": "KSTools License Manager API",
54
+ "timestamp": "2024-09-09T00:00:00Z"
55
+ }
56
+
57
  if __name__ == "__main__":
58
  # FastAPI 固定使用 port 8000,Streamlit 使用 PORT 環境變數 (7860)
59
  uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)
app/streamlit_app.py CHANGED
@@ -21,7 +21,7 @@ class Config:
21
  """應用程式設定"""
22
  API_BASE = os.getenv("API_BASE", "http://localhost:8000/api")
23
  REQUEST_TIMEOUT = 10
24
- CACHE_TTL = 300 # 5分鐘快取
25
  PAGE_SIZE = 20
26
 
27
  class Status(Enum):
@@ -663,7 +663,9 @@ class UserManagementPage:
663
  def _show_create_license_form():
664
  """新增授權表單"""
665
  with st.expander("➕ 建立新授權", expanded=False):
666
- with st.form("create_license_form", clear_on_submit=True):
 
 
667
  st.markdown("### 基本資訊")
668
 
669
  col1, col2 = st.columns(2)
@@ -715,10 +717,18 @@ class UserManagementPage:
715
  submitted = st.form_submit_button("🔑 建立授權", use_container_width=True, type="primary")
716
 
717
  if submitted:
 
 
 
 
 
718
  if not user_name.strip():
719
  st.error("請輸入用戶名稱")
720
  return
721
 
 
 
 
722
  license_data = {
723
  "user_name": user_name.strip(),
724
  "user_email": user_email.strip() if user_email.strip() else None,
@@ -727,10 +737,18 @@ class UserManagementPage:
727
  "is_active": auto_activate
728
  }
729
 
730
- with st.spinner("建立授權中..."):
731
- result = ApiClient.post("/license/create", license_data)
 
 
 
 
732
 
733
  if result.success:
 
 
 
 
734
  st.success("✅ 授權建立成功!")
735
 
736
  # 顯示授權資訊
@@ -743,10 +761,12 @@ class UserManagementPage:
743
  with info_col2:
744
  st.code(f"到期時間: {license_info.get('expires_at', 'N/A')}")
745
 
746
- # 清除快取重新載入
747
  st.cache_data.clear()
748
- time.sleep(1)
749
- st.rerun()
 
 
750
  else:
751
  UIComponents.show_status_message(
752
  Status.ERROR,
@@ -962,8 +982,11 @@ class UserManagementPage:
962
  if result.success:
963
  st.success("授權狀態已更新")
964
  st.cache_data.clear()
965
- time.sleep(0.5)
966
- st.rerun()
 
 
 
967
  else:
968
  UIComponents.show_status_message(Status.ERROR, "操作失敗", result.message)
969
 
@@ -985,7 +1008,9 @@ class UserManagementPage:
985
  st.cache_data.clear()
986
  if confirm_key in st.session_state:
987
  del st.session_state[confirm_key]
988
- time.sleep(0.5)
 
 
989
  st.rerun()
990
  else:
991
  UIComponents.show_status_message(Status.ERROR, "刪除失敗", result.message)
@@ -1396,14 +1421,21 @@ def main():
1396
  # 系統狀態指示器
1397
  st.markdown("**🚦 系統狀態**")
1398
 
1399
- # 快速狀態檢查
1400
- try:
1401
- health_check = requests.get(f"{Config.API_BASE}/health", timeout=2)
1402
- if health_check.status_code == 200:
1403
- st.success("🟢 API 服務正常")
1404
- else:
1405
- st.error("🔴 API 服務異常")
1406
- except:
 
 
 
 
 
 
 
1407
  st.error("🔴 API 無法連接")
1408
 
1409
  st.markdown("---")
 
21
  """應用程式設定"""
22
  API_BASE = os.getenv("API_BASE", "http://localhost:8000/api")
23
  REQUEST_TIMEOUT = 10
24
+ CACHE_TTL = 60 # 1分鐘快取 (減少頻繁請求)
25
  PAGE_SIZE = 20
26
 
27
  class Status(Enum):
 
663
  def _show_create_license_form():
664
  """新增授權表單"""
665
  with st.expander("➕ 建立新授權", expanded=False):
666
+ # 防止重複提交的關鍵
667
+ form_key = f"create_license_form_{int(time.time())}"
668
+ with st.form(form_key, clear_on_submit=True):
669
  st.markdown("### 基本資訊")
670
 
671
  col1, col2 = st.columns(2)
 
717
  submitted = st.form_submit_button("🔑 建立授權", use_container_width=True, type="primary")
718
 
719
  if submitted:
720
+ # 防止重複提交
721
+ if st.session_state.get("creating_license", False):
722
+ st.warning("⏳ 正在建立授權中,請稍候...")
723
+ return
724
+
725
  if not user_name.strip():
726
  st.error("請輸入用戶名稱")
727
  return
728
 
729
+ # 設定正在處理狀態
730
+ st.session_state.creating_license = True
731
+
732
  license_data = {
733
  "user_name": user_name.strip(),
734
  "user_email": user_email.strip() if user_email.strip() else None,
 
737
  "is_active": auto_activate
738
  }
739
 
740
+ try:
741
+ with st.spinner("建立授權中..."):
742
+ result = ApiClient.post("/license/create", license_data)
743
+ finally:
744
+ # 完成後重置狀態
745
+ st.session_state.creating_license = False
746
 
747
  if result.success:
748
+ # 設定成功狀態,防止重複提交
749
+ st.session_state.license_created = True
750
+ st.session_state.new_license_info = result.data
751
+
752
  st.success("✅ 授權建立成功!")
753
 
754
  # 顯示授權資訊
 
761
  with info_col2:
762
  st.code(f"到期時間: {license_info.get('expires_at', 'N/A')}")
763
 
764
+ # 清除快取但不重新運行
765
  st.cache_data.clear()
766
+
767
+ # 顯示重新整理提示
768
+ if st.button("🔄 重新整理列表", type="secondary"):
769
+ st.rerun()
770
  else:
771
  UIComponents.show_status_message(
772
  Status.ERROR,
 
982
  if result.success:
983
  st.success("授權狀態已更新")
984
  st.cache_data.clear()
985
+ # 設定延遲重新載入,避免重複操作
986
+ st.session_state.refresh_needed = True
987
+ time.sleep(1)
988
+ if st.session_state.get("refresh_needed"):
989
+ st.rerun()
990
  else:
991
  UIComponents.show_status_message(Status.ERROR, "操作失敗", result.message)
992
 
 
1008
  st.cache_data.clear()
1009
  if confirm_key in st.session_state:
1010
  del st.session_state[confirm_key]
1011
+ # 避免立即重新載入導致重複操作
1012
+ st.info("頁面將在 3 秒後自動重新整理...")
1013
+ time.sleep(3)
1014
  st.rerun()
1015
  else:
1016
  UIComponents.show_status_message(Status.ERROR, "刪除失敗", result.message)
 
1421
  # 系統狀態指示器
1422
  st.markdown("**🚦 系統狀態**")
1423
 
1424
+ # 快速狀態檢查 (減少頻率)
1425
+ if "last_health_check" not in st.session_state or \
1426
+ time.time() - st.session_state.get("last_health_check", 0) > 30: # 30秒檢查一次
1427
+ try:
1428
+ health_response = ApiClient.get("/health")
1429
+ st.session_state.api_status = health_response.success
1430
+ st.session_state.last_health_check = time.time()
1431
+ except:
1432
+ st.session_state.api_status = False
1433
+ st.session_state.last_health_check = time.time()
1434
+
1435
+ # 顯示快取的狀態
1436
+ if st.session_state.get("api_status", False):
1437
+ st.success("🟢 API 服務正常")
1438
+ else:
1439
  st.error("🔴 API 無法連接")
1440
 
1441
  st.markdown("---")
frontend/README.md ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # KSTools License Manager - 前端
2
+
3
+ 簡化的 HTML/CSS/JS 前端系統,替代原本複雜的 Streamlit 架構。
4
+
5
+ ## 🎯 主要改進
6
+
7
+ ### 程式碼簡化統計
8
+ - **API 客戶端**: 從 400+ 行簡化到 109 行 (-73%)
9
+ - **工具函數**: 從 488 行簡化到 161 行 (-67%)
10
+ - **認證管理**: 從 385 行簡化到 252 行 (-35%)
11
+ - **UI 組件**: 從 582 行簡化到 221 行 (-62%)
12
+ - **應用控制器**: 從 459 行簡化到 252 行 (-45%)
13
+ - **設定頁面**: 從 516 行簡化到 282 行 (-45%)
14
+
15
+ ### 移除的不必要功能
16
+ - 複雜的請求攔截器和快取系統
17
+ - 過度的錯誤處理和重試機制
18
+ - 不必要的動畫和視覺效果
19
+ - 複雜的配置系統和功能開關
20
+ - 過多的鍵盤快捷鍵和互動功能
21
+
22
+ ## 📁 檔案結構
23
+ ```
24
+ frontend/
25
+ ├── index.html # 主應用程式 (需認證)
26
+ ├── login.html # 登入頁面
27
+ ├── config.js # 簡化配置
28
+ ├── SETUP.md # 設定說明
29
+ ├── css/
30
+ │ └── style.css # 統一樣式表
31
+ └── js/
32
+ ├── api.js # 簡化 API 客戶端 (109 行)
33
+ ├── utils.js # 核心工具函數 (161 行)
34
+ ├── auth.js # Supabase 認證 (252 行)
35
+ ├── components.js # UI 組件 (221 行)
36
+ ├── app.js # 應用控制器 (252 行)
37
+ └── pages/
38
+ ├── dashboard.js # 儀表板
39
+ ├── users.js # 用戶管理
40
+ ├── logs.js # 記錄查看
41
+ └── settings.js # 系統設定 (282 行)
42
+ ```
43
+
44
+ ## 🚀 快速開始
45
+
46
+ ### 1. 設定 Supabase
47
+ 編輯 `config.js`:
48
+ ```javascript
49
+ window.APP_CONFIG = {
50
+ SUPABASE_URL: 'https://your-project.supabase.co',
51
+ SUPABASE_ANON_KEY: 'your-anon-public-key',
52
+ // ...
53
+ };
54
+ ```
55
+
56
+ ### 2. 啟動前端
57
+ ```bash
58
+ # 簡單 HTTP 服務器
59
+ python3 -m http.server 8080
60
+
61
+ # 或任何靜態檔案伺服器
62
+ ```
63
+
64
+ ### 3. 訪問系統
65
+ - 前端: http://localhost:8080
66
+ - 登入頁面: http://localhost:8080/login.html
67
+ - 後端 API: http://localhost:8000
68
+
69
+ ## ✨ 核心特色
70
+
71
+ ### 簡潔設計
72
+ - **單一職責**: 每個模組只做一件事
73
+ - **最小依賴**: 只使用必需的外部函式庫
74
+ - **清晰結構**: 邏輯分離,易於維護
75
+
76
+ ### 高效能
77
+ - **無框架開銷**: 純 JavaScript,載入迅速
78
+ - **簡化 API**: 直接 fetch,無複雜抽象
79
+ - **最少 DOM 操作**: 只在必要時更新界面
80
+
81
+ ### 易維護
82
+ - **程式碼減少 50%+**: 更少的程式碼意味著更少的 bug
83
+ - **清楚的註解**: 每個功能都有明確說明
84
+ - **統一的錯誤處理**: 集中管理錯誤訊息
85
+
86
+ ## 🔧 開發說明
87
+
88
+ ### 新增功能
89
+ 1. 在對應的 `pages/` 檔案中添加方法
90
+ 2. 在 `api.js` 中添加必要的 API 呼叫
91
+ 3. 更新 `app.js` 的頁面配置(如需要)
92
+
93
+ ### 樣式修改
94
+ - 所有樣式集中在 `css/style.css`
95
+ - 使用 CSS 變數進行主題管理
96
+ - 響應式設計已內建
97
+
98
+ ### 除錯
99
+ - 開發者工具的 Console 查看錯誤
100
+ - Network 標籤檢查 API 呼叫
101
+ - 使用 `Utils.showError()` 顯示用戶友好的錯誤訊息
102
+
103
+ ## 🎨 設計原則
104
+
105
+ 1. **KISS (Keep It Simple, Stupid)**: 簡單勝過複雜
106
+ 2. **DRY (Don't Repeat Yourself)**: 避免重複程式碼
107
+ 3. **YAGNI (You Ain't Gonna Need It)**: 不實作不需要的功能
108
+ 4. **單一職責原則**: 每個函數/類別只做一件事
109
+ 5. **最小化依賴**: 只使用真正需要的函式庫
110
+
111
+ 這個簡化版本保持了所有核心功能,同時大幅提升了可維護性和性能!
frontend/SETUP.md ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # KSTools License Manager - 前端設定
2
+
3
+ ## Supabase 設定
4
+
5
+ ### 1. 建立 Supabase 專案
6
+ 1. 前往 [Supabase](https://supabase.com) 並建立帳號
7
+ 2. 建立新專案
8
+ 3. 記下您的專案 URL 和 anon 公開金鑰
9
+
10
+ ### 2. 設定認證
11
+ 在 Supabase 控制台中:
12
+ 1. 前往 Authentication > Settings
13
+ 2. 設定您的網站 URL (例如:`http://localhost:3000`)
14
+ 3. 在 Authentication > Users 中建立管理員帳號
15
+
16
+ ### 3. 更新配置
17
+ 編輯 `config.js` 檔案:
18
+
19
+ ```javascript
20
+ window.APP_CONFIG = {
21
+ SUPABASE_URL: 'https://your-project.supabase.co', // 替換為您的專案 URL
22
+ SUPABASE_ANON_KEY: 'your-anon-public-key', // 替換為您的 anon 金鑰
23
+ // ... 其他設定
24
+ };
25
+ ```
26
+
27
+ ### 4. 執行前端
28
+ ```bash
29
+ # 安裝相依套件(如果使用 npm)
30
+ npm install
31
+
32
+ # 啟動開發伺服器
33
+ npm run dev
34
+
35
+ # 或直接用瀏覽器開啟 index.html
36
+ ```
37
+
38
+ ## 功能說明
39
+
40
+ ### 認證系統
41
+ - 使用 Supabase Auth 進行身份驗證
42
+ - 支援記住登入狀態
43
+ - 自動重導向到登入頁面(未認證時)
44
+ - 登入成功後自動跳轉到儀表板
45
+
46
+ ### 頁面結構
47
+ - `/login.html` - 登入頁面
48
+ - `/index.html` - 主應用程式(需認證)
49
+ - 四個主要頁面:
50
+ - 系統儀表板(統計資訊)
51
+ - 用戶管理(授權列表)
52
+ - 授權記錄(活動日誌)
53
+ - 系統設定(配置管理)
54
+
55
+ ### 開發模式
56
+ 在本地開發時,可以通過瀏覽器控制台設定 Supabase 配置:
57
+
58
+ ```javascript
59
+ // 設定 Supabase 配置
60
+ authManager.setSupabaseConfig(
61
+ 'https://your-project.supabase.co',
62
+ 'your-anon-public-key'
63
+ );
64
+
65
+ // 建立測試用戶(僅開發時)
66
+ await authManager.createTestUser('admin@example.com', 'password123');
67
+ ```
68
+
69
+ ## 部署
70
+
71
+ ### 靜態檔案託管
72
+ 此前端是純靜態檔案,可部署到:
73
+ - GitHub Pages
74
+ - Netlify
75
+ - Vercel
76
+ - 任何支援靜態檔案的主機
77
+
78
+ ### 環境變數
79
+ 生產環境中,請在 `config.js` 中設定正確的:
80
+ - Supabase URL
81
+ - Supabase Anon Key
82
+ - API Base URL(如果後端 API 在不同網域)
83
+
84
+ ## 疑難排解
85
+
86
+ ### 常見問題
87
+
88
+ 1. **登入失敗**
89
+ - 確認 Supabase 設定正確
90
+ - 檢查瀏覽器控制台是否有錯誤訊息
91
+ - 確認已在 Supabase 中建立用戶
92
+
93
+ 2. **API 連線失敗**
94
+ - 確認後端 API 服務正在運行
95
+ - 檢查 CORS 設定
96
+ - 確認 API Base URL 設定正確
97
+
98
+ 3. **頁面空白**
99
+ - 檢查瀏覽器控制台錯誤
100
+ - 確認所有 JavaScript 檔案載入成功
101
+ - 檢查網路連線
102
+
103
+ ### 開發工具
104
+ - 瀏覽器開發者工具
105
+ - Supabase 控制台
106
+ - 網路監控工具
frontend/config.js ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Configuration for KSTools License Manager
3
+ * Loads configuration from backend API for security
4
+ */
5
+
6
+ // 先設定預設值,然後從 API 動態載入
7
+ window.APP_CONFIG = {
8
+ // 預設配置(會被 API 覆蓋)
9
+ SUPABASE_URL: '',
10
+ SUPABASE_ANON_KEY: '',
11
+ APP_NAME: 'KSTools License Manager',
12
+ VERSION: '1.0.0'
13
+ };
14
+
15
+ // 從 API 動態載入配置
16
+ async function loadConfig() {
17
+ try {
18
+ const response = await fetch('/api/frontend-config');
19
+ if (response.ok) {
20
+ const config = await response.json();
21
+ window.APP_CONFIG = { ...window.APP_CONFIG, ...config };
22
+ console.log('配置已從 API 載入');
23
+ } else {
24
+ console.warn('無法從 API 載入配置,使用預設值');
25
+ }
26
+ } catch (error) {
27
+ console.warn('載入配置時出錯,使用預設值:', error);
28
+ }
29
+ }
30
+
31
+ // 立即載入配置
32
+ loadConfig();
frontend/css/style.css ADDED
@@ -0,0 +1,1160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* CSS Variables for Design System */
2
+ :root {
3
+ /* Colors - GitHub Dark Theme Inspired */
4
+ --bg-primary: #0d1117;
5
+ --bg-secondary: #161b22;
6
+ --bg-tertiary: #21262d;
7
+ --bg-overlay: rgba(13, 17, 23, 0.8);
8
+
9
+ --border-primary: #30363d;
10
+ --border-secondary: #21262d;
11
+
12
+ --text-primary: #e6edf3;
13
+ --text-secondary: #7d8590;
14
+ --text-tertiary: #656d76;
15
+
16
+ --accent-blue: #58a6ff;
17
+ --accent-green: #3fb950;
18
+ --accent-red: #f85149;
19
+ --accent-yellow: #d29922;
20
+ --accent-purple: #a5a4ff;
21
+
22
+ /* Gradients */
23
+ --gradient-primary: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 50%, var(--bg-tertiary) 100%);
24
+ --gradient-accent: linear-gradient(135deg, var(--accent-blue), #79c0ff);
25
+
26
+ /* Spacing */
27
+ --spacing-xs: 0.25rem;
28
+ --spacing-sm: 0.5rem;
29
+ --spacing-md: 1rem;
30
+ --spacing-lg: 1.5rem;
31
+ --spacing-xl: 2rem;
32
+ --spacing-2xl: 3rem;
33
+
34
+ /* Border Radius */
35
+ --radius-sm: 4px;
36
+ --radius-md: 8px;
37
+ --radius-lg: 12px;
38
+ --radius-xl: 16px;
39
+
40
+ /* Shadows */
41
+ --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
42
+ --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3);
43
+ --shadow-lg: 0 8px 12px rgba(0, 0, 0, 0.4);
44
+ --shadow-xl: 0 12px 20px rgba(0, 0, 0, 0.5);
45
+
46
+ /* Layout */
47
+ --sidebar-width: 280px;
48
+ --sidebar-collapsed: 80px;
49
+ --header-height: 70px;
50
+
51
+ /* Transitions */
52
+ --transition-fast: 0.15s ease;
53
+ --transition-normal: 0.3s ease;
54
+ --transition-slow: 0.5s ease;
55
+ }
56
+
57
+ /* Reset and Base Styles */
58
+ * {
59
+ margin: 0;
60
+ padding: 0;
61
+ box-sizing: border-box;
62
+ }
63
+
64
+ html {
65
+ font-size: 16px;
66
+ scroll-behavior: smooth;
67
+ }
68
+
69
+ body {
70
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
71
+ background: var(--gradient-primary);
72
+ color: var(--text-primary);
73
+ line-height: 1.6;
74
+ overflow-x: hidden;
75
+ min-height: 100vh;
76
+ }
77
+
78
+ /* Custom Scrollbar */
79
+ ::-webkit-scrollbar {
80
+ width: 8px;
81
+ height: 8px;
82
+ }
83
+
84
+ ::-webkit-scrollbar-track {
85
+ background: var(--bg-secondary);
86
+ }
87
+
88
+ ::-webkit-scrollbar-thumb {
89
+ background: var(--border-primary);
90
+ border-radius: var(--radius-sm);
91
+ }
92
+
93
+ ::-webkit-scrollbar-thumb:hover {
94
+ background: var(--text-tertiary);
95
+ }
96
+
97
+ /* Loading Overlay */
98
+ .loading-overlay {
99
+ position: fixed;
100
+ top: 0;
101
+ left: 0;
102
+ width: 100%;
103
+ height: 100%;
104
+ background: var(--bg-overlay);
105
+ display: flex;
106
+ flex-direction: column;
107
+ align-items: center;
108
+ justify-content: center;
109
+ z-index: 9999;
110
+ backdrop-filter: blur(10px);
111
+ transition: opacity var(--transition-normal);
112
+ }
113
+
114
+ .loading-overlay.hidden {
115
+ opacity: 0;
116
+ pointer-events: none;
117
+ }
118
+
119
+ .spinner {
120
+ width: 40px;
121
+ height: 40px;
122
+ border: 3px solid var(--border-primary);
123
+ border-top: 3px solid var(--accent-blue);
124
+ border-radius: 50%;
125
+ animation: spin 1s linear infinite;
126
+ margin-bottom: var(--spacing-md);
127
+ }
128
+
129
+ @keyframes spin {
130
+ 0% { transform: rotate(0deg); }
131
+ 100% { transform: rotate(360deg); }
132
+ }
133
+
134
+ /* Layout Container */
135
+ .app-container {
136
+ display: flex;
137
+ min-height: 100vh;
138
+ }
139
+
140
+ /* Sidebar Navigation */
141
+ .sidebar {
142
+ width: var(--sidebar-width);
143
+ background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%);
144
+ border-right: 1px solid var(--border-primary);
145
+ display: flex;
146
+ flex-direction: column;
147
+ position: fixed;
148
+ top: 0;
149
+ left: 0;
150
+ height: 100vh;
151
+ z-index: 1000;
152
+ transition: transform var(--transition-normal);
153
+ }
154
+
155
+ .sidebar.collapsed {
156
+ width: var(--sidebar-collapsed);
157
+ }
158
+
159
+ .sidebar-header {
160
+ padding: var(--spacing-xl) var(--spacing-lg);
161
+ border-bottom: 1px solid var(--border-primary);
162
+ display: flex;
163
+ align-items: center;
164
+ justify-content: space-between;
165
+ min-height: var(--header-height);
166
+ }
167
+
168
+ .logo {
169
+ display: flex;
170
+ align-items: center;
171
+ gap: var(--spacing-md);
172
+ }
173
+
174
+ .logo i {
175
+ font-size: 1.5rem;
176
+ color: var(--accent-blue);
177
+ }
178
+
179
+ .logo h3 {
180
+ font-size: 1.1rem;
181
+ font-weight: 700;
182
+ line-height: 1.2;
183
+ background: var(--gradient-accent);
184
+ background-clip: text;
185
+ -webkit-background-clip: text;
186
+ -webkit-text-fill-color: transparent;
187
+ }
188
+
189
+ .logo span {
190
+ font-size: 0.85rem;
191
+ font-weight: 400;
192
+ }
193
+
194
+ .sidebar-toggle {
195
+ background: none;
196
+ border: none;
197
+ color: var(--text-secondary);
198
+ cursor: pointer;
199
+ padding: var(--spacing-sm);
200
+ border-radius: var(--radius-sm);
201
+ transition: var(--transition-fast);
202
+ display: none;
203
+ }
204
+
205
+ .sidebar-toggle:hover {
206
+ background: var(--bg-primary);
207
+ color: var(--text-primary);
208
+ }
209
+
210
+ /* Navigation Menu */
211
+ .nav-menu {
212
+ list-style: none;
213
+ padding: var(--spacing-lg) 0;
214
+ flex: 1;
215
+ }
216
+
217
+ .nav-item {
218
+ margin: var(--spacing-xs) var(--spacing-md);
219
+ }
220
+
221
+ .nav-link {
222
+ display: flex;
223
+ align-items: center;
224
+ gap: var(--spacing-md);
225
+ padding: var(--spacing-md) var(--spacing-lg);
226
+ color: var(--text-secondary);
227
+ text-decoration: none;
228
+ border-radius: var(--radius-md);
229
+ transition: var(--transition-fast);
230
+ font-weight: 500;
231
+ }
232
+
233
+ .nav-link:hover {
234
+ background: var(--bg-primary);
235
+ color: var(--text-primary);
236
+ transform: translateX(2px);
237
+ }
238
+
239
+ .nav-item.active .nav-link {
240
+ background: linear-gradient(135deg, var(--accent-blue), #4493f8);
241
+ color: white;
242
+ box-shadow: var(--shadow-md);
243
+ }
244
+
245
+ .nav-link i {
246
+ font-size: 1.1rem;
247
+ width: 20px;
248
+ text-align: center;
249
+ }
250
+
251
+ /* Sidebar Footer */
252
+ .sidebar-footer {
253
+ padding: var(--spacing-lg);
254
+ border-top: 1px solid var(--border-primary);
255
+ }
256
+
257
+ .system-status {
258
+ margin-bottom: var(--spacing-lg);
259
+ }
260
+
261
+ .status-item {
262
+ display: flex;
263
+ justify-content: space-between;
264
+ align-items: center;
265
+ padding: var(--spacing-sm) 0;
266
+ }
267
+
268
+ .status-label {
269
+ font-size: 0.85rem;
270
+ color: var(--text-secondary);
271
+ font-weight: 500;
272
+ }
273
+
274
+ .status-indicator {
275
+ display: flex;
276
+ align-items: center;
277
+ gap: var(--spacing-sm);
278
+ font-size: 0.8rem;
279
+ }
280
+
281
+ .status-indicator.online i {
282
+ color: var(--accent-green);
283
+ }
284
+
285
+ .status-indicator.offline i {
286
+ color: var(--accent-red);
287
+ }
288
+
289
+ .status-indicator.checking i {
290
+ color: var(--accent-yellow);
291
+ animation: pulse 1.5s ease-in-out infinite;
292
+ }
293
+
294
+ @keyframes pulse {
295
+ 0%, 100% { opacity: 1; }
296
+ 50% { opacity: 0.5; }
297
+ }
298
+
299
+ .sidebar-actions {
300
+ margin-bottom: var(--spacing-lg);
301
+ }
302
+
303
+ .version-info {
304
+ text-align: center;
305
+ color: var(--text-tertiary);
306
+ font-size: 0.75rem;
307
+ line-height: 1.4;
308
+ }
309
+
310
+ /* Main Content */
311
+ .main-content {
312
+ flex: 1;
313
+ margin-left: var(--sidebar-width);
314
+ display: flex;
315
+ flex-direction: column;
316
+ min-height: 100vh;
317
+ transition: margin-left var(--transition-normal);
318
+ }
319
+
320
+ .sidebar.collapsed ~ .main-content {
321
+ margin-left: var(--sidebar-collapsed);
322
+ }
323
+
324
+ /* Header */
325
+ .main-header {
326
+ background: var(--bg-secondary);
327
+ border-bottom: 1px solid var(--border-primary);
328
+ padding: 0 var(--spacing-xl);
329
+ height: var(--header-height);
330
+ display: flex;
331
+ align-items: center;
332
+ justify-content: space-between;
333
+ position: sticky;
334
+ top: 0;
335
+ z-index: 100;
336
+ backdrop-filter: blur(10px);
337
+ }
338
+
339
+ .header-left {
340
+ display: flex;
341
+ align-items: center;
342
+ gap: var(--spacing-lg);
343
+ }
344
+
345
+ .mobile-menu-btn {
346
+ background: none;
347
+ border: none;
348
+ color: var(--text-secondary);
349
+ cursor: pointer;
350
+ padding: var(--spacing-sm);
351
+ border-radius: var(--radius-sm);
352
+ transition: var(--transition-fast);
353
+ display: none;
354
+ }
355
+
356
+ .mobile-menu-btn:hover {
357
+ background: var(--bg-tertiary);
358
+ color: var(--text-primary);
359
+ }
360
+
361
+ #pageTitle {
362
+ font-size: 1.5rem;
363
+ font-weight: 600;
364
+ background: var(--gradient-accent);
365
+ background-clip: text;
366
+ -webkit-background-clip: text;
367
+ -webkit-text-fill-color: transparent;
368
+ }
369
+
370
+ .header-actions {
371
+ display: flex;
372
+ align-items: center;
373
+ gap: var(--spacing-md);
374
+ }
375
+
376
+ /* Content Container */
377
+ .content-container {
378
+ flex: 1;
379
+ padding: var(--spacing-xl);
380
+ position: relative;
381
+ }
382
+
383
+ .page-content {
384
+ display: none;
385
+ animation: fadeIn var(--transition-normal);
386
+ }
387
+
388
+ .page-content.active {
389
+ display: block;
390
+ }
391
+
392
+ @keyframes fadeIn {
393
+ from { opacity: 0; transform: translateY(20px); }
394
+ to { opacity: 1; transform: translateY(0); }
395
+ }
396
+
397
+ /* Buttons */
398
+ .btn {
399
+ display: inline-flex;
400
+ align-items: center;
401
+ gap: var(--spacing-sm);
402
+ padding: var(--spacing-sm) var(--spacing-md);
403
+ border: none;
404
+ border-radius: var(--radius-md);
405
+ font-weight: 600;
406
+ font-size: 0.9rem;
407
+ text-decoration: none;
408
+ cursor: pointer;
409
+ transition: var(--transition-fast);
410
+ background: none;
411
+ color: var(--text-primary);
412
+ }
413
+
414
+ .btn:disabled {
415
+ opacity: 0.5;
416
+ cursor: not-allowed;
417
+ }
418
+
419
+ .btn-primary {
420
+ background: linear-gradient(135deg, var(--accent-blue), #4493f8);
421
+ color: white;
422
+ box-shadow: var(--shadow-sm);
423
+ }
424
+
425
+ .btn-primary:hover:not(:disabled) {
426
+ background: linear-gradient(135deg, #4493f8, #357abd);
427
+ box-shadow: var(--shadow-md);
428
+ transform: translateY(-1px);
429
+ }
430
+
431
+ .btn-secondary {
432
+ background: var(--bg-tertiary);
433
+ border: 1px solid var(--border-primary);
434
+ }
435
+
436
+ .btn-secondary:hover:not(:disabled) {
437
+ background: var(--border-primary);
438
+ transform: translateY(-1px);
439
+ }
440
+
441
+ .btn-success {
442
+ background: linear-gradient(135deg, var(--accent-green), #46954a);
443
+ color: white;
444
+ }
445
+
446
+ .btn-success:hover:not(:disabled) {
447
+ background: linear-gradient(135deg, #46954a, #2f7d32);
448
+ transform: translateY(-1px);
449
+ }
450
+
451
+ .btn-danger {
452
+ background: linear-gradient(135deg, var(--accent-red), #ff6b6b);
453
+ color: white;
454
+ }
455
+
456
+ .btn-danger:hover:not(:disabled) {
457
+ background: linear-gradient(135deg, #ff6b6b, #e53e3e);
458
+ transform: translateY(-1px);
459
+ }
460
+
461
+ .btn-refresh {
462
+ background: var(--bg-tertiary);
463
+ border: 1px solid var(--border-primary);
464
+ width: 100%;
465
+ justify-content: center;
466
+ padding: var(--spacing-md);
467
+ }
468
+
469
+ .btn-refresh:hover {
470
+ background: var(--bg-primary);
471
+ }
472
+
473
+ .btn-sm {
474
+ padding: var(--spacing-xs) var(--spacing-sm);
475
+ font-size: 0.8rem;
476
+ }
477
+
478
+ .btn-lg {
479
+ padding: var(--spacing-md) var(--spacing-xl);
480
+ font-size: 1rem;
481
+ }
482
+
483
+ /* Cards */
484
+ .card {
485
+ background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%);
486
+ border: 1px solid var(--border-primary);
487
+ border-radius: var(--radius-lg);
488
+ padding: var(--spacing-xl);
489
+ box-shadow: var(--shadow-md);
490
+ transition: var(--transition-normal);
491
+ position: relative;
492
+ overflow: hidden;
493
+ }
494
+
495
+ .card::before {
496
+ content: '';
497
+ position: absolute;
498
+ top: 0;
499
+ left: 0;
500
+ right: 0;
501
+ height: 3px;
502
+ background: var(--gradient-accent);
503
+ }
504
+
505
+ .card:hover {
506
+ transform: translateY(-2px);
507
+ box-shadow: var(--shadow-lg);
508
+ }
509
+
510
+ .card-header {
511
+ display: flex;
512
+ align-items: center;
513
+ justify-content: space-between;
514
+ margin-bottom: var(--spacing-lg);
515
+ }
516
+
517
+ .card-title {
518
+ font-size: 1.25rem;
519
+ font-weight: 600;
520
+ color: var(--text-primary);
521
+ display: flex;
522
+ align-items: center;
523
+ gap: var(--spacing-sm);
524
+ }
525
+
526
+ .card-title i {
527
+ color: var(--accent-blue);
528
+ }
529
+
530
+ .card-body {
531
+ color: var(--text-secondary);
532
+ line-height: 1.6;
533
+ }
534
+
535
+ /* Metric Cards */
536
+ .metric-card {
537
+ text-align: center;
538
+ padding: var(--spacing-lg);
539
+ background: var(--bg-tertiary);
540
+ border-radius: var(--radius-lg);
541
+ border: 1px solid var(--border-primary);
542
+ transition: var(--transition-normal);
543
+ position: relative;
544
+ overflow: hidden;
545
+ }
546
+
547
+ .metric-card::before {
548
+ content: '';
549
+ position: absolute;
550
+ top: 0;
551
+ left: 0;
552
+ right: 0;
553
+ height: 3px;
554
+ background: linear-gradient(90deg, var(--accent-blue), #79c0ff, #a5f3fc);
555
+ }
556
+
557
+ .metric-card:hover {
558
+ transform: translateY(-2px);
559
+ box-shadow: var(--shadow-md);
560
+ }
561
+
562
+ .metric-title {
563
+ font-size: 0.85rem;
564
+ font-weight: 600;
565
+ color: var(--text-secondary);
566
+ text-transform: uppercase;
567
+ letter-spacing: 0.5px;
568
+ margin-bottom: var(--spacing-sm);
569
+ }
570
+
571
+ .metric-value {
572
+ font-size: 2rem;
573
+ font-weight: 800;
574
+ background: var(--gradient-accent);
575
+ background-clip: text;
576
+ -webkit-background-clip: text;
577
+ -webkit-text-fill-color: transparent;
578
+ margin-bottom: var(--spacing-xs);
579
+ }
580
+
581
+ .metric-change {
582
+ font-size: 0.8rem;
583
+ display: flex;
584
+ align-items: center;
585
+ justify-content: center;
586
+ gap: var(--spacing-xs);
587
+ }
588
+
589
+ .metric-change.positive {
590
+ color: var(--accent-green);
591
+ }
592
+
593
+ .metric-change.negative {
594
+ color: var(--accent-red);
595
+ }
596
+
597
+ /* Grid Layout */
598
+ .grid {
599
+ display: grid;
600
+ gap: var(--spacing-lg);
601
+ }
602
+
603
+ .grid-2 { grid-template-columns: repeat(2, 1fr); }
604
+ .grid-3 { grid-template-columns: repeat(3, 1fr); }
605
+ .grid-4 { grid-template-columns: repeat(4, 1fr); }
606
+
607
+ /* Forms */
608
+ .form-group {
609
+ margin-bottom: var(--spacing-lg);
610
+ }
611
+
612
+ .form-label {
613
+ display: block;
614
+ font-weight: 600;
615
+ color: var(--text-primary);
616
+ margin-bottom: var(--spacing-sm);
617
+ font-size: 0.9rem;
618
+ }
619
+
620
+ .form-input,
621
+ .form-select,
622
+ .form-textarea {
623
+ width: 100%;
624
+ padding: var(--spacing-md);
625
+ background: var(--bg-tertiary);
626
+ border: 1px solid var(--border-primary);
627
+ border-radius: var(--radius-md);
628
+ color: var(--text-primary);
629
+ font-size: 0.9rem;
630
+ transition: var(--transition-fast);
631
+ }
632
+
633
+ .form-input:focus,
634
+ .form-select:focus,
635
+ .form-textarea:focus {
636
+ outline: none;
637
+ border-color: var(--accent-blue);
638
+ box-shadow: 0 0 0 2px rgba(88, 166, 255, 0.3);
639
+ }
640
+
641
+ .form-input::placeholder {
642
+ color: var(--text-tertiary);
643
+ }
644
+
645
+ .form-textarea {
646
+ min-height: 100px;
647
+ resize: vertical;
648
+ }
649
+
650
+ .form-help {
651
+ font-size: 0.8rem;
652
+ color: var(--text-tertiary);
653
+ margin-top: var(--spacing-xs);
654
+ }
655
+
656
+ /* Tables */
657
+ .table-container {
658
+ background: var(--bg-secondary);
659
+ border-radius: var(--radius-lg);
660
+ border: 1px solid var(--border-primary);
661
+ overflow: hidden;
662
+ box-shadow: var(--shadow-md);
663
+ }
664
+
665
+ .table {
666
+ width: 100%;
667
+ border-collapse: collapse;
668
+ }
669
+
670
+ .table th,
671
+ .table td {
672
+ padding: var(--spacing-md) var(--spacing-lg);
673
+ text-align: left;
674
+ border-bottom: 1px solid var(--border-primary);
675
+ }
676
+
677
+ .table th {
678
+ background: var(--bg-tertiary);
679
+ font-weight: 600;
680
+ color: var(--text-primary);
681
+ font-size: 0.9rem;
682
+ text-transform: uppercase;
683
+ letter-spacing: 0.5px;
684
+ }
685
+
686
+ .table td {
687
+ color: var(--text-secondary);
688
+ }
689
+
690
+ .table tbody tr:hover {
691
+ background: rgba(88, 166, 255, 0.1);
692
+ }
693
+
694
+ /* Status Badges */
695
+ .badge {
696
+ display: inline-flex;
697
+ align-items: center;
698
+ gap: var(--spacing-xs);
699
+ padding: var(--spacing-xs) var(--spacing-sm);
700
+ border-radius: var(--radius-lg);
701
+ font-size: 0.75rem;
702
+ font-weight: 600;
703
+ text-transform: uppercase;
704
+ letter-spacing: 0.5px;
705
+ }
706
+
707
+ .badge-success {
708
+ background: rgba(63, 185, 80, 0.2);
709
+ color: var(--accent-green);
710
+ border: 1px solid rgba(63, 185, 80, 0.3);
711
+ }
712
+
713
+ .badge-warning {
714
+ background: rgba(210, 153, 34, 0.2);
715
+ color: var(--accent-yellow);
716
+ border: 1px solid rgba(210, 153, 34, 0.3);
717
+ }
718
+
719
+ .badge-error {
720
+ background: rgba(248, 81, 73, 0.2);
721
+ color: var(--accent-red);
722
+ border: 1px solid rgba(248, 81, 73, 0.3);
723
+ }
724
+
725
+ .badge-info {
726
+ background: rgba(88, 166, 255, 0.2);
727
+ color: var(--accent-blue);
728
+ border: 1px solid rgba(88, 166, 255, 0.3);
729
+ }
730
+
731
+ /* Toast Notifications */
732
+ .toast-container {
733
+ position: fixed;
734
+ top: var(--spacing-xl);
735
+ right: var(--spacing-xl);
736
+ z-index: 9999;
737
+ display: flex;
738
+ flex-direction: column;
739
+ gap: var(--spacing-md);
740
+ }
741
+
742
+ .toast {
743
+ background: var(--bg-secondary);
744
+ border: 1px solid var(--border-primary);
745
+ border-radius: var(--radius-md);
746
+ padding: var(--spacing-md);
747
+ box-shadow: var(--shadow-lg);
748
+ display: flex;
749
+ align-items: center;
750
+ gap: var(--spacing-md);
751
+ max-width: 400px;
752
+ animation: slideIn var(--transition-normal);
753
+ position: relative;
754
+ overflow: hidden;
755
+ }
756
+
757
+ .toast::before {
758
+ content: '';
759
+ position: absolute;
760
+ left: 0;
761
+ top: 0;
762
+ bottom: 0;
763
+ width: 4px;
764
+ }
765
+
766
+ .toast.success::before { background: var(--accent-green); }
767
+ .toast.error::before { background: var(--accent-red); }
768
+ .toast.warning::before { background: var(--accent-yellow); }
769
+ .toast.info::before { background: var(--accent-blue); }
770
+
771
+ .toast-icon {
772
+ font-size: 1.2rem;
773
+ }
774
+
775
+ .toast.success .toast-icon { color: var(--accent-green); }
776
+ .toast.error .toast-icon { color: var(--accent-red); }
777
+ .toast.warning .toast-icon { color: var(--accent-yellow); }
778
+ .toast.info .toast-icon { color: var(--accent-blue); }
779
+
780
+ .toast-content {
781
+ flex: 1;
782
+ }
783
+
784
+ .toast-title {
785
+ font-weight: 600;
786
+ color: var(--text-primary);
787
+ margin-bottom: var(--spacing-xs);
788
+ }
789
+
790
+ .toast-message {
791
+ color: var(--text-secondary);
792
+ font-size: 0.9rem;
793
+ }
794
+
795
+ .toast-close {
796
+ background: none;
797
+ border: none;
798
+ color: var(--text-tertiary);
799
+ cursor: pointer;
800
+ padding: var(--spacing-xs);
801
+ border-radius: var(--radius-sm);
802
+ transition: var(--transition-fast);
803
+ }
804
+
805
+ .toast-close:hover {
806
+ background: var(--bg-tertiary);
807
+ color: var(--text-primary);
808
+ }
809
+
810
+ @keyframes slideIn {
811
+ from {
812
+ transform: translateX(100%);
813
+ opacity: 0;
814
+ }
815
+ to {
816
+ transform: translateX(0);
817
+ opacity: 1;
818
+ }
819
+ }
820
+
821
+ /* Responsive Design */
822
+ @media (max-width: 1024px) {
823
+ :root {
824
+ --sidebar-width: 250px;
825
+ }
826
+
827
+ .grid-4 { grid-template-columns: repeat(2, 1fr); }
828
+ .grid-3 { grid-template-columns: repeat(2, 1fr); }
829
+ }
830
+
831
+ @media (max-width: 768px) {
832
+ :root {
833
+ --spacing-xl: 1rem;
834
+ }
835
+
836
+ .sidebar {
837
+ transform: translateX(-100%);
838
+ }
839
+
840
+ .sidebar.open {
841
+ transform: translateX(0);
842
+ }
843
+
844
+ .main-content {
845
+ margin-left: 0;
846
+ }
847
+
848
+ .mobile-menu-btn {
849
+ display: block;
850
+ }
851
+
852
+ .sidebar-toggle {
853
+ display: block;
854
+ }
855
+
856
+ .grid-2,
857
+ .grid-3,
858
+ .grid-4 {
859
+ grid-template-columns: 1fr;
860
+ }
861
+
862
+ .main-header {
863
+ padding: 0 var(--spacing-lg);
864
+ }
865
+
866
+ .content-container {
867
+ padding: var(--spacing-lg);
868
+ }
869
+
870
+ .toast-container {
871
+ top: var(--spacing-lg);
872
+ right: var(--spacing-lg);
873
+ left: var(--spacing-lg);
874
+ }
875
+
876
+ .toast {
877
+ max-width: none;
878
+ }
879
+ }
880
+
881
+ @media (max-width: 480px) {
882
+ .metric-value {
883
+ font-size: 1.5rem;
884
+ }
885
+
886
+ .card {
887
+ padding: var(--spacing-lg);
888
+ }
889
+
890
+ .table th,
891
+ .table td {
892
+ padding: var(--spacing-sm) var(--spacing-md);
893
+ }
894
+ }
895
+
896
+ /* Dark mode specific adjustments */
897
+ @media (prefers-color-scheme: dark) {
898
+ /* Already optimized for dark theme */
899
+ }
900
+
901
+ /* Utility Classes */
902
+ .text-center { text-align: center; }
903
+ .text-left { text-align: left; }
904
+ .text-right { text-align: right; }
905
+
906
+ .mb-0 { margin-bottom: 0; }
907
+ .mb-1 { margin-bottom: var(--spacing-xs); }
908
+ .mb-2 { margin-bottom: var(--spacing-sm); }
909
+ .mb-3 { margin-bottom: var(--spacing-md); }
910
+ .mb-4 { margin-bottom: var(--spacing-lg); }
911
+ .mb-5 { margin-bottom: var(--spacing-xl); }
912
+
913
+ .mt-0 { margin-top: 0; }
914
+ .mt-1 { margin-top: var(--spacing-xs); }
915
+ .mt-2 { margin-top: var(--spacing-sm); }
916
+ .mt-3 { margin-top: var(--spacing-md); }
917
+ .mt-4 { margin-top: var(--spacing-lg); }
918
+ .mt-5 { margin-top: var(--spacing-xl); }
919
+
920
+ .d-none { display: none; }
921
+ .d-block { display: block; }
922
+ .d-flex { display: flex; }
923
+ .d-grid { display: grid; }
924
+
925
+ .justify-center { justify-content: center; }
926
+ .justify-between { justify-content: space-between; }
927
+ .align-center { align-items: center; }
928
+
929
+ .w-full { width: 100%; }
930
+
931
+ /* Settings page specific styles */
932
+ .settings-page .form-actions {
933
+ display: flex;
934
+ gap: 1rem;
935
+ margin-top: 2rem;
936
+ padding-top: 2rem;
937
+ border-top: 1px solid var(--border-color);
938
+ }
939
+
940
+ .system-info-grid {
941
+ display: grid;
942
+ grid-template-columns: 1fr;
943
+ gap: 0.75rem;
944
+ }
945
+
946
+ .info-row {
947
+ display: flex;
948
+ justify-content: space-between;
949
+ align-items: center;
950
+ padding: 0.5rem 0;
951
+ border-bottom: 1px solid var(--border-muted);
952
+ }
953
+
954
+ .info-row:last-child {
955
+ border-bottom: none;
956
+ }
957
+
958
+ .info-label {
959
+ font-weight: 600;
960
+ color: var(--text-primary);
961
+ }
962
+
963
+ .info-value {
964
+ color: var(--text-secondary);
965
+ font-family: var(--font-mono);
966
+ }
967
+
968
+ .connection-status {
969
+ display: flex;
970
+ align-items: center;
971
+ gap: 0.5rem;
972
+ padding: 1rem;
973
+ border-radius: 8px;
974
+ font-weight: 500;
975
+ }
976
+
977
+ .connection-status.success {
978
+ background: var(--success-bg);
979
+ border: 1px solid var(--success-border);
980
+ color: var(--success-text);
981
+ }
982
+
983
+ .connection-status.error {
984
+ background: var(--danger-bg);
985
+ border: 1px solid var(--danger-border);
986
+ color: var(--danger-text);
987
+ }
988
+
989
+ .connection-status.testing {
990
+ background: var(--warning-bg);
991
+ border: 1px solid var(--warning-border);
992
+ color: var(--warning-text);
993
+ }
994
+
995
+ .connection-status.unknown {
996
+ background: var(--surface-secondary);
997
+ border: 1px solid var(--border-color);
998
+ color: var(--text-secondary);
999
+ }
1000
+
1001
+ .connection-status small {
1002
+ font-size: 0.8rem;
1003
+ opacity: 0.8;
1004
+ }
1005
+
1006
+ .spinner-sm {
1007
+ width: 16px;
1008
+ height: 16px;
1009
+ border: 2px solid transparent;
1010
+ border-top: 2px solid currentColor;
1011
+ border-radius: 50%;
1012
+ animation: spin 1s linear infinite;
1013
+ }
1014
+
1015
+ .form-help {
1016
+ display: block;
1017
+ margin-top: 0.25rem;
1018
+ font-size: 0.8rem;
1019
+ color: var(--text-muted);
1020
+ }
1021
+
1022
+ /* Login page styles */
1023
+ .login-container {
1024
+ min-height: 100vh;
1025
+ display: flex;
1026
+ align-items: center;
1027
+ justify-content: center;
1028
+ background: var(--background-primary);
1029
+ padding: 2rem;
1030
+ }
1031
+
1032
+ .login-card {
1033
+ width: 100%;
1034
+ max-width: 400px;
1035
+ background: var(--surface-primary);
1036
+ border: 1px solid var(--border-color);
1037
+ border-radius: 12px;
1038
+ padding: 2rem;
1039
+ box-shadow: var(--shadow-lg);
1040
+ }
1041
+
1042
+ .login-header {
1043
+ text-align: center;
1044
+ margin-bottom: 2rem;
1045
+ }
1046
+
1047
+ .login-header h1 {
1048
+ font-size: 1.8rem;
1049
+ font-weight: 700;
1050
+ color: var(--text-primary);
1051
+ margin-bottom: 0.5rem;
1052
+ }
1053
+
1054
+ .login-header p {
1055
+ color: var(--text-secondary);
1056
+ font-size: 0.9rem;
1057
+ }
1058
+
1059
+ .login-form {
1060
+ display: flex;
1061
+ flex-direction: column;
1062
+ gap: 1.5rem;
1063
+ }
1064
+
1065
+ .login-form .form-group {
1066
+ margin-bottom: 0;
1067
+ }
1068
+
1069
+ .login-form .btn {
1070
+ width: 100%;
1071
+ padding: 0.75rem;
1072
+ font-size: 1rem;
1073
+ font-weight: 600;
1074
+ }
1075
+
1076
+ .login-footer {
1077
+ text-align: center;
1078
+ margin-top: 2rem;
1079
+ padding-top: 2rem;
1080
+ border-top: 1px solid var(--border-color);
1081
+ font-size: 0.8rem;
1082
+ color: var(--text-muted);
1083
+ }
1084
+
1085
+ /* Input group for password toggle */
1086
+ .input-group {
1087
+ position: relative;
1088
+ display: flex;
1089
+ }
1090
+
1091
+ .input-group .form-input {
1092
+ padding-right: 3rem;
1093
+ }
1094
+
1095
+ .input-group-btn {
1096
+ position: absolute;
1097
+ right: 0;
1098
+ top: 0;
1099
+ bottom: 0;
1100
+ padding: 0 0.75rem;
1101
+ background: none;
1102
+ border: none;
1103
+ color: var(--text-secondary);
1104
+ cursor: pointer;
1105
+ display: flex;
1106
+ align-items: center;
1107
+ justify-content: center;
1108
+ transition: color 0.2s ease;
1109
+ }
1110
+
1111
+ .input-group-btn:hover {
1112
+ color: var(--text-primary);
1113
+ }
1114
+
1115
+ .input-group-btn:focus {
1116
+ outline: 2px solid var(--primary-color);
1117
+ outline-offset: -2px;
1118
+ border-radius: 4px;
1119
+ }
1120
+
1121
+ /* User profile in header */
1122
+ .user-profile {
1123
+ display: flex;
1124
+ align-items: center;
1125
+ gap: 1rem;
1126
+ }
1127
+
1128
+ .user-email {
1129
+ font-size: 0.9rem;
1130
+ color: var(--text-secondary);
1131
+ }
1132
+
1133
+ /* Loading overlay improvements */
1134
+ .loading-overlay {
1135
+ position: fixed;
1136
+ top: 0;
1137
+ left: 0;
1138
+ right: 0;
1139
+ bottom: 0;
1140
+ background: rgba(13, 17, 23, 0.9);
1141
+ display: flex;
1142
+ align-items: center;
1143
+ justify-content: center;
1144
+ z-index: 9999;
1145
+ backdrop-filter: blur(4px);
1146
+ }
1147
+
1148
+ .loading-overlay.hidden {
1149
+ display: none;
1150
+ }
1151
+
1152
+ .loading-overlay .loading-content {
1153
+ text-align: center;
1154
+ color: var(--text-primary);
1155
+ }
1156
+
1157
+ .loading-overlay .spinner {
1158
+ margin-bottom: 1rem;
1159
+ }
1160
+ .h-full { height: 100%; }
frontend/index.html ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-TW">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>KSTools License Manager</title>
7
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔑</text></svg>">
8
+ <link rel="stylesheet" href="css/style.css">
9
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
10
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
11
+ <script src="https://unpkg.com/@supabase/supabase-js@2.39.3/dist/umd/supabase.min.js"></script>
12
+ </head>
13
+ <body>
14
+ <!-- Loading Overlay -->
15
+ <div id="loading-overlay" class="loading-overlay">
16
+ <div class="spinner"></div>
17
+ <p>載入中...</p>
18
+ </div>
19
+
20
+ <!-- Sidebar Navigation -->
21
+ <nav class="sidebar" id="sidebar">
22
+ <div class="sidebar-header">
23
+ <div class="logo">
24
+ <i class="fas fa-key"></i>
25
+ <h3>KSTools<br><span>License Manager</span></h3>
26
+ </div>
27
+ <button class="sidebar-toggle" id="sidebarToggle">
28
+ <i class="fas fa-bars"></i>
29
+ </button>
30
+ </div>
31
+
32
+ <ul class="nav-menu">
33
+ <li class="nav-item active" data-page="dashboard">
34
+ <a href="#" class="nav-link">
35
+ <i class="fas fa-chart-line"></i>
36
+ <span>系統儀表板</span>
37
+ </a>
38
+ </li>
39
+ <li class="nav-item" data-page="users">
40
+ <a href="#" class="nav-link">
41
+ <i class="fas fa-users"></i>
42
+ <span>用戶管理</span>
43
+ </a>
44
+ </li>
45
+ <li class="nav-item" data-page="logs">
46
+ <a href="#" class="nav-link">
47
+ <i class="fas fa-file-alt"></i>
48
+ <span>授權記錄</span>
49
+ </a>
50
+ </li>
51
+ <li class="nav-item" data-page="settings">
52
+ <a href="#" class="nav-link">
53
+ <i class="fas fa-cog"></i>
54
+ <span>系統設定</span>
55
+ </a>
56
+ </li>
57
+ </ul>
58
+
59
+ <div class="sidebar-footer">
60
+ <div class="system-status">
61
+ <div class="status-item">
62
+ <span class="status-label">API 狀態</span>
63
+ <span class="status-indicator" id="apiStatus">
64
+ <i class="fas fa-circle"></i>
65
+ <span>檢查中</span>
66
+ </span>
67
+ </div>
68
+ </div>
69
+
70
+ <div class="sidebar-actions">
71
+ <button class="btn btn-refresh" id="refreshBtn">
72
+ <i class="fas fa-sync-alt"></i>
73
+ <span>重新整理</span>
74
+ </button>
75
+ </div>
76
+
77
+ <div class="version-info">
78
+ <small>KSTools License Manager v1.0</small>
79
+ <small>Powered by FastAPI</small>
80
+ </div>
81
+ </div>
82
+ </nav>
83
+
84
+ <!-- Main Content -->
85
+ <main class="main-content" id="mainContent">
86
+ <!-- Header -->
87
+ <header class="main-header">
88
+ <div class="header-left">
89
+ <button class="mobile-menu-btn" id="mobileMenuBtn">
90
+ <i class="fas fa-bars"></i>
91
+ </button>
92
+ <h1 id="pageTitle">系統儀表板</h1>
93
+ </div>
94
+ <div class="header-right">
95
+ <div class="header-actions">
96
+ <button class="btn btn-primary" id="quickAddBtn">
97
+ <i class="fas fa-plus"></i>
98
+ <span>快速新增</span>
99
+ </button>
100
+ <div id="userInfo" class="user-info">
101
+ <!-- User info will be populated by auth.js -->
102
+ </div>
103
+ </div>
104
+ </div>
105
+ </header>
106
+
107
+ <!-- Page Content Container -->
108
+ <div class="content-container" id="contentContainer">
109
+ <!-- Dashboard Page (Default) -->
110
+ <div class="page-content active" id="dashboardPage">
111
+ <!-- Dashboard content will be loaded here -->
112
+ </div>
113
+
114
+ <!-- Users Page -->
115
+ <div class="page-content" id="usersPage">
116
+ <!-- Users content will be loaded here -->
117
+ </div>
118
+
119
+ <!-- Logs Page -->
120
+ <div class="page-content" id="logsPage">
121
+ <!-- Logs content will be loaded here -->
122
+ </div>
123
+
124
+ <!-- Settings Page -->
125
+ <div class="page-content" id="settingsPage">
126
+ <!-- Settings content will be loaded here -->
127
+ </div>
128
+ </div>
129
+ </main>
130
+
131
+ <!-- Toast Notifications -->
132
+ <div id="toastContainer" class="toast-container"></div>
133
+
134
+ <!-- Modal Container -->
135
+ <div id="modalContainer"></div>
136
+
137
+ <!-- Scripts -->
138
+ <script src="config.js"></script>
139
+ <script src="js/api.js"></script>
140
+ <script src="js/utils.js"></script>
141
+ <script src="js/auth.js"></script>
142
+ <script src="js/components.js"></script>
143
+ <script src="js/pages/dashboard.js"></script>
144
+ <script src="js/pages/users.js"></script>
145
+ <script src="js/pages/logs.js"></script>
146
+ <script src="js/pages/settings.js"></script>
147
+ <script src="js/app.js"></script>
148
+ <script>
149
+ // Initialize authentication and app
150
+ document.addEventListener('DOMContentLoaded', async () => {
151
+ // Wait for authManager to be fully ready
152
+ const waitForAuth = () => {
153
+ return new Promise((resolve) => {
154
+ const checkAuth = () => {
155
+ if (window.authManager && window.authManager.initialized) {
156
+ resolve();
157
+ } else {
158
+ setTimeout(checkAuth, 100);
159
+ }
160
+ };
161
+ checkAuth();
162
+ });
163
+ };
164
+
165
+ try {
166
+ await waitForAuth();
167
+
168
+ // Check authentication before starting app
169
+ if (!authManager.requireAuth()) {
170
+ return; // Will redirect to login
171
+ }
172
+
173
+ // Start main app
174
+ window.app.init();
175
+ window.app.restoreSidebarState();
176
+
177
+ } catch (error) {
178
+ console.error('App initialization error:', error);
179
+ window.location.href = '/login.html';
180
+ }
181
+ });
182
+ </script>
183
+ </body>
184
+ </html>
frontend/js/api.js ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Simple API Client for KSTools License Manager
3
+ */
4
+
5
+ class ApiClient {
6
+ constructor() {
7
+ this.baseUrl = this.getApiBaseUrl();
8
+ this.timeout = 10000;
9
+ }
10
+
11
+ getApiBaseUrl() {
12
+ const host = window.location.hostname;
13
+ const protocol = window.location.protocol;
14
+
15
+ if (host === 'localhost' || host === '127.0.0.1') {
16
+ return `${protocol}//${host}:8000`;
17
+ } else {
18
+ return `${protocol}//${host}`;
19
+ }
20
+ }
21
+
22
+ async request(endpoint, options = {}) {
23
+ const url = `${this.baseUrl}${endpoint}`;
24
+
25
+ const config = {
26
+ method: 'GET',
27
+ headers: {
28
+ 'Content-Type': 'application/json',
29
+ 'Accept': 'application/json'
30
+ },
31
+ ...options
32
+ };
33
+
34
+ if (config.body && typeof config.body === 'object') {
35
+ config.body = JSON.stringify(config.body);
36
+ }
37
+
38
+ try {
39
+ const response = await fetch(url, config);
40
+
41
+ if (!response.ok) {
42
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
43
+ }
44
+
45
+ const contentType = response.headers.get('content-type');
46
+ if (contentType?.includes('application/json')) {
47
+ return await response.json();
48
+ }
49
+
50
+ return await response.text();
51
+
52
+ } catch (error) {
53
+ console.error(`API Error [${config.method} ${endpoint}]:`, error);
54
+ throw error;
55
+ }
56
+ }
57
+
58
+ // Health check
59
+ async checkHealth() {
60
+ try {
61
+ await this.request('/health');
62
+ return { success: true };
63
+ } catch (error) {
64
+ return { success: false, error: error.message };
65
+ }
66
+ }
67
+
68
+ // License endpoints
69
+ async getLicenses() {
70
+ return this.request('/licenses');
71
+ }
72
+
73
+ async createLicense(licenseData) {
74
+ return this.request('/licenses', {
75
+ method: 'POST',
76
+ body: licenseData
77
+ });
78
+ }
79
+
80
+ async updateLicense(licenseId, licenseData) {
81
+ return this.request(`/licenses/${licenseId}`, {
82
+ method: 'PUT',
83
+ body: licenseData
84
+ });
85
+ }
86
+
87
+ async deleteLicense(licenseId) {
88
+ return this.request(`/licenses/${licenseId}`, {
89
+ method: 'DELETE'
90
+ });
91
+ }
92
+
93
+ // Stats endpoints
94
+ async getLicenseStats() {
95
+ return this.request('/licenses/stats');
96
+ }
97
+
98
+ // Logs endpoints
99
+ async getLicenseLogs(limit = 50) {
100
+ return this.request(`/logs?limit=${limit}`);
101
+ }
102
+
103
+ // System info
104
+ async getSystemInfo() {
105
+ return this.request('/system/info');
106
+ }
107
+ }
108
+
109
+ // Create global instance
110
+ window.api = new ApiClient();
frontend/js/app.js ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Simple Application Controller for KSTools License Manager
3
+ */
4
+
5
+ class App {
6
+ constructor() {
7
+ this.currentPage = null;
8
+ this.currentPageName = 'dashboard';
9
+
10
+ this.pages = {
11
+ dashboard: window.dashboardPage,
12
+ users: window.usersPage,
13
+ logs: window.logsPage,
14
+ settings: window.settingsPage
15
+ };
16
+
17
+ this.pageConfig = {
18
+ dashboard: { title: '系統儀表板' },
19
+ users: { title: '用戶管理' },
20
+ logs: { title: '授權記錄' },
21
+ settings: { title: '系統設定' }
22
+ };
23
+ }
24
+
25
+ async init() {
26
+ try {
27
+ this.setupEventListeners();
28
+ await this.checkApiHealth();
29
+
30
+ const initialPage = this.getInitialPage();
31
+ await this.navigateTo(initialPage);
32
+
33
+ Utils.showSuccess('系統初始化完成');
34
+
35
+ } catch (error) {
36
+ Utils.handleError(error, '系統初始化時');
37
+ }
38
+ }
39
+
40
+ setupEventListeners() {
41
+ // Navigation
42
+ document.addEventListener('click', (e) => {
43
+ const navItem = e.target.closest('.nav-item');
44
+ if (navItem && navItem.dataset.page) {
45
+ e.preventDefault();
46
+ this.navigateTo(navItem.dataset.page);
47
+ }
48
+ });
49
+
50
+ // Quick add button
51
+ const quickAddBtn = document.getElementById('quickAddBtn');
52
+ if (quickAddBtn) {
53
+ quickAddBtn.addEventListener('click', () => {
54
+ Components.createLicenseModal();
55
+ });
56
+ }
57
+
58
+ // Refresh button
59
+ const refreshBtn = document.getElementById('refreshBtn');
60
+ if (refreshBtn) {
61
+ refreshBtn.addEventListener('click', () => {
62
+ this.refreshCurrentPage();
63
+ });
64
+ }
65
+
66
+ // Keyboard shortcuts
67
+ document.addEventListener('keydown', (e) => {
68
+ if ((e.ctrlKey || e.metaKey) && e.key === 'r') {
69
+ e.preventDefault();
70
+ this.refreshCurrentPage();
71
+ }
72
+ });
73
+
74
+ // Browser back/forward
75
+ window.addEventListener('popstate', (e) => {
76
+ if (e.state && e.state.page) {
77
+ this.navigateTo(e.state.page, false);
78
+ }
79
+ });
80
+ }
81
+
82
+ async navigateTo(pageName, updateHistory = true) {
83
+ if (!this.pages[pageName]) {
84
+ console.error(`Page ${pageName} not found`);
85
+ return;
86
+ }
87
+
88
+ try {
89
+ this.updateNavigation(pageName);
90
+ this.updatePageTitle(pageName);
91
+
92
+ // Hide current page
93
+ if (this.currentPage) {
94
+ const currentContainer = document.getElementById(`${this.currentPageName}Page`);
95
+ if (currentContainer) {
96
+ currentContainer.classList.remove('active');
97
+ }
98
+
99
+ if (this.currentPage.destroy) {
100
+ this.currentPage.destroy();
101
+ }
102
+ }
103
+
104
+ // Show new page
105
+ const pageContainer = document.getElementById(`${pageName}Page`);
106
+ if (pageContainer) {
107
+ pageContainer.classList.add('active');
108
+ pageContainer.innerHTML = `
109
+ <div class="text-center mt-5">
110
+ <div class="spinner mb-3"></div>
111
+ <p>載入頁面中...</p>
112
+ </div>
113
+ `;
114
+ }
115
+
116
+ // Load new page
117
+ this.currentPage = this.pages[pageName];
118
+ this.currentPageName = pageName;
119
+
120
+ if (this.currentPage && this.currentPage.render) {
121
+ await this.currentPage.render(pageContainer);
122
+ }
123
+
124
+ // Update browser history
125
+ if (updateHistory) {
126
+ const url = new URL(window.location);
127
+ url.searchParams.set('page', pageName);
128
+ window.history.pushState({ page: pageName }, '', url);
129
+ }
130
+
131
+ window.currentPage = this.currentPage;
132
+
133
+ } catch (error) {
134
+ Utils.handleError(error, `載入 ${this.pageConfig[pageName]?.title || pageName} 頁面時`);
135
+ }
136
+ }
137
+
138
+ updateNavigation(activePage) {
139
+ document.querySelectorAll('.nav-item').forEach(item => item.classList.remove('active'));
140
+
141
+ const activeNavItem = document.querySelector(`.nav-item[data-page="${activePage}"]`);
142
+ if (activeNavItem) {
143
+ activeNavItem.classList.add('active');
144
+ }
145
+ }
146
+
147
+ updatePageTitle(pageName) {
148
+ const config = this.pageConfig[pageName];
149
+ if (!config) return;
150
+
151
+ const pageTitle = document.getElementById('pageTitle');
152
+ if (pageTitle) {
153
+ pageTitle.textContent = config.title;
154
+ }
155
+
156
+ document.title = `${config.title} - KSTools License Manager`;
157
+ }
158
+
159
+ getInitialPage() {
160
+ // Check URL parameter
161
+ const urlPage = Utils.getQueryParam('page');
162
+ if (urlPage && this.pages[urlPage]) {
163
+ return urlPage;
164
+ }
165
+
166
+ // Check localStorage
167
+ const lastPage = Utils.getStorage('lastPage');
168
+ if (lastPage && this.pages[lastPage]) {
169
+ return lastPage;
170
+ }
171
+
172
+ return 'dashboard';
173
+ }
174
+
175
+ async checkApiHealth() {
176
+ const statusIndicator = document.getElementById('apiStatus');
177
+ if (!statusIndicator) return;
178
+
179
+ try {
180
+ statusIndicator.className = 'status-indicator checking';
181
+ statusIndicator.innerHTML = `
182
+ <i class="fas fa-circle"></i>
183
+ <span>檢查中</span>
184
+ `;
185
+
186
+ const result = await api.checkHealth();
187
+
188
+ if (result.success) {
189
+ statusIndicator.className = 'status-indicator online';
190
+ statusIndicator.innerHTML = `
191
+ <i class="fas fa-circle"></i>
192
+ <span>正常</span>
193
+ `;
194
+ } else {
195
+ throw new Error(result.error);
196
+ }
197
+
198
+ } catch (error) {
199
+ statusIndicator.className = 'status-indicator offline';
200
+ statusIndicator.innerHTML = `
201
+ <i class="fas fa-circle"></i>
202
+ <span>異常</span>
203
+ `;
204
+ }
205
+ }
206
+
207
+ async refreshCurrentPage() {
208
+ if (this.currentPage && this.currentPage.refresh) {
209
+ try {
210
+ await this.currentPage.refresh();
211
+ Utils.showSuccess('頁面已重新整理');
212
+ } catch (error) {
213
+ Utils.handleError(error, '重新整理頁面時');
214
+ }
215
+ } else {
216
+ await this.navigateTo(this.currentPageName);
217
+ }
218
+ }
219
+
220
+ restoreSidebarState() {
221
+ const isCollapsed = Utils.getStorage('sidebarCollapsed', false);
222
+ const sidebar = document.getElementById('sidebar');
223
+
224
+ if (sidebar && isCollapsed) {
225
+ sidebar.classList.add('collapsed');
226
+ }
227
+ }
228
+
229
+ cleanup() {
230
+ Utils.setStorage('lastPage', this.currentPageName);
231
+
232
+ if (this.currentPage && this.currentPage.destroy) {
233
+ this.currentPage.destroy();
234
+ }
235
+ }
236
+ }
237
+
238
+ // Create global app instance
239
+ window.app = new App();
240
+
241
+ // Cleanup on page unload
242
+ window.addEventListener('beforeunload', () => {
243
+ window.app.cleanup();
244
+ });
245
+
246
+ // Global error handlers
247
+ window.addEventListener('error', (e) => {
248
+ Utils.handleError(e.error, '全域錯誤');
249
+ });
250
+
251
+ window.addEventListener('unhandledrejection', (e) => {
252
+ Utils.handleError(e.reason, '未處理的Promise錯誤');
253
+ });
frontend/js/auth.js ADDED
@@ -0,0 +1,283 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Simple Authentication Manager for KSTools License Manager
3
+ */
4
+
5
+ class AuthManager {
6
+ constructor() {
7
+ this.supabase = null;
8
+ this.currentUser = null;
9
+ this.initialized = false;
10
+
11
+ // Wait for both DOM and Supabase to be ready
12
+ this.waitForDependencies();
13
+ }
14
+
15
+ waitForDependencies() {
16
+ const checkReady = () => {
17
+ if (typeof supabase !== 'undefined' && window.APP_CONFIG) {
18
+ this.init();
19
+ } else {
20
+ setTimeout(checkReady, 100);
21
+ }
22
+ };
23
+
24
+ if (document.readyState === 'loading') {
25
+ document.addEventListener('DOMContentLoaded', checkReady);
26
+ } else {
27
+ checkReady();
28
+ }
29
+ }
30
+
31
+ async init() {
32
+ try {
33
+ console.log('Initializing AuthManager...');
34
+
35
+ // Check if supabase is available
36
+ if (typeof supabase === 'undefined') {
37
+ throw new Error('Supabase library not loaded');
38
+ }
39
+
40
+ const config = this.getSupabaseConfig();
41
+ console.log('Supabase config:', { url: config.url, hasKey: !!config.anonKey });
42
+
43
+ if (!config.url || !config.anonKey) {
44
+ throw new Error('Supabase configuration missing');
45
+ }
46
+
47
+ if (config.url === 'https://your-project.supabase.co') {
48
+ throw new Error('Please update config.js with your actual Supabase URL');
49
+ }
50
+
51
+ this.supabase = supabase.createClient(config.url, config.anonKey);
52
+ console.log('Supabase client created successfully');
53
+
54
+ // Auth state listener
55
+ this.supabase.auth.onAuthStateChange((event, session) => {
56
+ if (event === 'SIGNED_IN' && session) {
57
+ this.currentUser = session.user;
58
+ this.updateAuthUI();
59
+ if (window.location.pathname.includes('login.html')) {
60
+ this.redirectToDashboard();
61
+ }
62
+ } else if (event === 'SIGNED_OUT') {
63
+ this.currentUser = null;
64
+ this.updateAuthUI();
65
+ if (!window.location.pathname.includes('login.html')) {
66
+ this.redirectToLogin();
67
+ }
68
+ }
69
+ });
70
+
71
+ // Check current session
72
+ const { data: { session } } = await this.supabase.auth.getSession();
73
+ if (session) {
74
+ this.currentUser = session.user;
75
+ this.updateAuthUI();
76
+ }
77
+
78
+ this.initialized = true;
79
+ return true;
80
+
81
+ } catch (error) {
82
+ console.error('Auth initialization failed:', error);
83
+ return false;
84
+ }
85
+ }
86
+
87
+ getSupabaseConfig() {
88
+ // Try localStorage first
89
+ const savedConfig = Utils.getStorage('supabaseConfig', {});
90
+ if (savedConfig.url && savedConfig.anonKey) {
91
+ return savedConfig;
92
+ }
93
+
94
+ // Try global config
95
+ if (window.APP_CONFIG) {
96
+ return {
97
+ url: window.APP_CONFIG.SUPABASE_URL,
98
+ anonKey: window.APP_CONFIG.SUPABASE_ANON_KEY
99
+ };
100
+ }
101
+
102
+ return {
103
+ url: 'https://your-project.supabase.co',
104
+ anonKey: 'your-anon-public-key'
105
+ };
106
+ }
107
+
108
+ async login(email, password, remember = false) {
109
+ if (!this.supabase) {
110
+ throw new Error('Supabase not initialized');
111
+ }
112
+
113
+ try {
114
+ const { data, error } = await this.supabase.auth.signInWithPassword({
115
+ email: email,
116
+ password: password
117
+ });
118
+
119
+ if (error) throw error;
120
+
121
+ if (data.user) {
122
+ this.currentUser = data.user;
123
+ if (remember) {
124
+ Utils.setStorage('rememberLogin', true);
125
+ }
126
+ return { success: true, user: data.user };
127
+ }
128
+
129
+ throw new Error('Login failed');
130
+
131
+ } catch (error) {
132
+ let errorMessage = '登入失敗';
133
+ if (error.message.includes('Invalid login credentials')) {
134
+ errorMessage = '電子郵件或密碼不正確';
135
+ } else if (error.message.includes('Email not confirmed')) {
136
+ errorMessage = '請先確認您的電子郵件';
137
+ }
138
+
139
+ return { success: false, error: errorMessage };
140
+ }
141
+ }
142
+
143
+ async logout() {
144
+ if (!this.supabase) return { success: false };
145
+
146
+ try {
147
+ await this.supabase.auth.signOut();
148
+ this.currentUser = null;
149
+ Utils.removeStorage('rememberLogin');
150
+ return { success: true };
151
+ } catch (error) {
152
+ return { success: false, error: error.message };
153
+ }
154
+ }
155
+
156
+ isAuthenticated() {
157
+ return this.currentUser !== null;
158
+ }
159
+
160
+ requireAuth() {
161
+ if (!this.isAuthenticated()) {
162
+ this.redirectToLogin();
163
+ return false;
164
+ }
165
+ return true;
166
+ }
167
+
168
+ redirectToLogin() {
169
+ const currentPath = window.location.pathname + window.location.search;
170
+ if (currentPath !== '/login.html' && currentPath !== '/') {
171
+ Utils.setStorage('redirectAfterLogin', currentPath);
172
+ }
173
+ window.location.href = '/login.html';
174
+ }
175
+
176
+ redirectToDashboard() {
177
+ const redirectPath = Utils.getStorage('redirectAfterLogin', '/');
178
+ Utils.removeStorage('redirectAfterLogin');
179
+
180
+ if (redirectPath === '/login.html') {
181
+ window.location.href = '/';
182
+ } else {
183
+ window.location.href = redirectPath;
184
+ }
185
+ }
186
+
187
+ updateAuthUI() {
188
+ const userInfo = document.getElementById('userInfo');
189
+ if (userInfo) {
190
+ if (this.currentUser) {
191
+ userInfo.innerHTML = `
192
+ <div class="user-profile">
193
+ <span class="user-email">${this.currentUser.email}</span>
194
+ <button class="btn btn-sm btn-secondary" onclick="authManager.logout()">
195
+ <i class="fas fa-sign-out-alt"></i>
196
+ 登出
197
+ </button>
198
+ </div>
199
+ `;
200
+ } else {
201
+ userInfo.innerHTML = '';
202
+ }
203
+ }
204
+ }
205
+
206
+ // Login page methods
207
+ initLoginPage() {
208
+ if (this.isAuthenticated()) {
209
+ this.redirectToDashboard();
210
+ return;
211
+ }
212
+
213
+ this.setupLoginForm();
214
+ this.setupPasswordToggle();
215
+ }
216
+
217
+ setupLoginForm() {
218
+ const loginForm = document.getElementById('loginForm');
219
+ if (!loginForm) return;
220
+
221
+ loginForm.addEventListener('submit', async (e) => {
222
+ e.preventDefault();
223
+
224
+ const formData = new FormData(loginForm);
225
+ const email = formData.get('email')?.trim();
226
+ const password = formData.get('password');
227
+ const remember = formData.get('rememberMe') === 'on';
228
+
229
+ if (!email || !password) {
230
+ Utils.showError('請填寫所有必填欄位');
231
+ return;
232
+ }
233
+
234
+ const loginBtn = document.getElementById('loginBtn');
235
+ const originalContent = loginBtn.innerHTML;
236
+
237
+ try {
238
+ loginBtn.disabled = true;
239
+ loginBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 登入中...';
240
+
241
+ const result = await this.login(email, password, remember);
242
+
243
+ if (result.success) {
244
+ Utils.showSuccess('登入成功', '正在跳轉...');
245
+ setTimeout(() => this.redirectToDashboard(), 1500);
246
+ } else {
247
+ throw new Error(result.error);
248
+ }
249
+
250
+ } catch (error) {
251
+ Utils.showError(error.message || '登入失敗');
252
+ loginBtn.disabled = false;
253
+ loginBtn.innerHTML = originalContent;
254
+ }
255
+ });
256
+ }
257
+
258
+ setupPasswordToggle() {
259
+ const toggleBtn = document.getElementById('togglePassword');
260
+ const passwordInput = document.getElementById('password');
261
+
262
+ if (!toggleBtn || !passwordInput) return;
263
+
264
+ toggleBtn.addEventListener('click', () => {
265
+ const isPassword = passwordInput.type === 'password';
266
+ passwordInput.type = isPassword ? 'text' : 'password';
267
+
268
+ const icon = toggleBtn.querySelector('i');
269
+ if (icon) {
270
+ icon.className = isPassword ? 'fas fa-eye-slash' : 'fas fa-eye';
271
+ }
272
+ });
273
+ }
274
+
275
+ // Development helper
276
+ setSupabaseConfig(url, anonKey) {
277
+ Utils.setStorage('supabaseConfig', { url, anonKey });
278
+ this.init();
279
+ }
280
+ }
281
+
282
+ // Create global instance
283
+ window.authManager = new AuthManager();
frontend/js/components.js ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Simple UI Components for KSTools License Manager
3
+ */
4
+
5
+ class Components {
6
+
7
+ // Modal system
8
+ static createModal({ title, content, size = 'md' }) {
9
+ const modalHtml = `
10
+ <div class="modal-overlay" onclick="Components.closeModal(this)">
11
+ <div class="modal modal-${size}" onclick="event.stopPropagation()">
12
+ <div class="modal-header">
13
+ <h3 class="modal-title">${title}</h3>
14
+ <button class="modal-close" onclick="Components.closeModal(this.closest('.modal-overlay'))">
15
+ <i class="fas fa-times"></i>
16
+ </button>
17
+ </div>
18
+ <div class="modal-body">
19
+ ${content}
20
+ </div>
21
+ </div>
22
+ </div>
23
+ `;
24
+
25
+ const container = document.getElementById('modalContainer') || document.body;
26
+ container.insertAdjacentHTML('beforeend', modalHtml);
27
+
28
+ return container.lastElementChild;
29
+ }
30
+
31
+ static showModal(modal) {
32
+ if (modal) {
33
+ modal.classList.add('active');
34
+ document.body.style.overflow = 'hidden';
35
+ }
36
+ }
37
+
38
+ static closeModal(modal) {
39
+ if (modal) {
40
+ modal.classList.remove('active');
41
+ setTimeout(() => {
42
+ modal.remove();
43
+ document.body.style.overflow = '';
44
+ }, 300);
45
+ }
46
+ }
47
+
48
+ // License creation modal
49
+ static createLicenseModal() {
50
+ const content = `
51
+ <form id="licenseForm" class="license-form">
52
+ <div class="form-group">
53
+ <label for="userName" class="form-label">用戶名稱 *</label>
54
+ <input type="text" id="userName" name="userName" class="form-input" required>
55
+ </div>
56
+
57
+ <div class="form-group">
58
+ <label for="email" class="form-label">電子郵件</label>
59
+ <input type="email" id="email" name="email" class="form-input">
60
+ </div>
61
+
62
+ <div class="form-group">
63
+ <label for="validDays" class="form-label">有效天數</label>
64
+ <select id="validDays" name="validDays" class="form-select">
65
+ <option value="30">30 天</option>
66
+ <option value="90">90 天</option>
67
+ <option value="365" selected>1 年</option>
68
+ <option value="0">永久</option>
69
+ </select>
70
+ </div>
71
+
72
+ <div class="form-group">
73
+ <label for="notes" class="form-label">備註</label>
74
+ <textarea id="notes" name="notes" class="form-textarea" rows="3"></textarea>
75
+ </div>
76
+
77
+ <div class="modal-actions">
78
+ <button type="button" class="btn btn-secondary" onclick="Components.closeModal(this.closest('.modal-overlay'))">
79
+ 取消
80
+ </button>
81
+ <button type="submit" class="btn btn-primary">
82
+ <i class="fas fa-plus"></i>
83
+ 建立授權
84
+ </button>
85
+ </div>
86
+ </form>
87
+ `;
88
+
89
+ const modal = this.createModal({
90
+ title: '建立新授權',
91
+ content: content,
92
+ size: 'lg'
93
+ });
94
+
95
+ // Setup form handler
96
+ const form = modal.querySelector('#licenseForm');
97
+ form.addEventListener('submit', async (e) => {
98
+ e.preventDefault();
99
+ await this.handleLicenseCreation(form, modal);
100
+ });
101
+
102
+ this.showModal(modal);
103
+ return modal;
104
+ }
105
+
106
+ static async handleLicenseCreation(form, modal) {
107
+ const formData = new FormData(form);
108
+ const licenseData = {
109
+ user_name: formData.get('userName'),
110
+ email: formData.get('email'),
111
+ valid_days: parseInt(formData.get('validDays')),
112
+ notes: formData.get('notes')
113
+ };
114
+
115
+ const submitBtn = form.querySelector('button[type="submit"]');
116
+ const originalContent = submitBtn.innerHTML;
117
+
118
+ try {
119
+ submitBtn.disabled = true;
120
+ submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 建立中...';
121
+
122
+ await api.createLicense(licenseData);
123
+
124
+ Utils.showSuccess('授權建立成功');
125
+ this.closeModal(modal);
126
+
127
+ // Refresh current page if it has a refresh method
128
+ if (window.currentPage && window.currentPage.refresh) {
129
+ window.currentPage.refresh();
130
+ }
131
+
132
+ } catch (error) {
133
+ Utils.handleError(error, '建立授權時');
134
+ submitBtn.disabled = false;
135
+ submitBtn.innerHTML = originalContent;
136
+ }
137
+ }
138
+
139
+ // Chart creation helper
140
+ static createChart(canvas, config) {
141
+ if (!window.Chart) {
142
+ console.error('Chart.js not loaded');
143
+ return null;
144
+ }
145
+
146
+ const ctx = canvas.getContext('2d');
147
+
148
+ // Default dark theme colors
149
+ const defaultConfig = {
150
+ plugins: {
151
+ legend: {
152
+ labels: {
153
+ color: '#e6edf3',
154
+ font: { size: 12 }
155
+ }
156
+ }
157
+ },
158
+ scales: config.type !== 'doughnut' && config.type !== 'pie' ? {
159
+ x: {
160
+ ticks: { color: '#7d8590' },
161
+ grid: { color: '#30363d' }
162
+ },
163
+ y: {
164
+ ticks: { color: '#7d8590' },
165
+ grid: { color: '#30363d' }
166
+ }
167
+ } : undefined
168
+ };
169
+
170
+ // Merge configs
171
+ const finalConfig = {
172
+ ...config,
173
+ options: {
174
+ ...defaultConfig,
175
+ ...config.options,
176
+ responsive: true,
177
+ maintainAspectRatio: false
178
+ }
179
+ };
180
+
181
+ return new Chart(ctx, finalConfig);
182
+ }
183
+
184
+ // Confirmation dialog
185
+ static confirm(title, message, onConfirm) {
186
+ const content = `
187
+ <div class="confirm-dialog">
188
+ <p>${message}</p>
189
+ <div class="modal-actions">
190
+ <button type="button" class="btn btn-secondary" onclick="Components.closeModal(this.closest('.modal-overlay'))">
191
+ 取消
192
+ </button>
193
+ <button type="button" class="btn btn-danger" onclick="Components.handleConfirm(this, ${onConfirm})">
194
+ 確認
195
+ </button>
196
+ </div>
197
+ </div>
198
+ `;
199
+
200
+ const modal = this.createModal({ title, content });
201
+ this.showModal(modal);
202
+ return modal;
203
+ }
204
+
205
+ static handleConfirm(button, callback) {
206
+ const modal = button.closest('.modal-overlay');
207
+ this.closeModal(modal);
208
+ if (callback && typeof callback === 'function') {
209
+ callback();
210
+ }
211
+ }
212
+ }
213
+
214
+ // Modal keyboard handling
215
+ document.addEventListener('keydown', (e) => {
216
+ if (e.key === 'Escape') {
217
+ const activeModal = document.querySelector('.modal-overlay.active');
218
+ if (activeModal) {
219
+ Components.closeModal(activeModal);
220
+ }
221
+ }
222
+ });
frontend/js/pages/dashboard.js ADDED
@@ -0,0 +1,458 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Dashboard Page for KSTools License Manager
3
+ * System overview and statistics
4
+ */
5
+
6
+ class DashboardPage {
7
+ constructor() {
8
+ this.charts = {};
9
+ this.refreshInterval = null;
10
+ }
11
+
12
+ async render(container) {
13
+ container.innerHTML = `
14
+ <div class="dashboard-content">
15
+ <!-- Stats Cards Row -->
16
+ <div class="stats-row mb-5">
17
+ <div class="grid grid-4">
18
+ <div class="metric-card" id="totalLicensesCard">
19
+ <div class="metric-title">總授權數</div>
20
+ <div class="metric-value">-</div>
21
+ <div class="metric-change">
22
+ <i class="fas fa-key"></i>
23
+ 載入中...
24
+ </div>
25
+ </div>
26
+
27
+ <div class="metric-card" id="activeLicensesCard">
28
+ <div class="metric-title">啟用中</div>
29
+ <div class="metric-value">-</div>
30
+ <div class="metric-change">
31
+ <i class="fas fa-check-circle"></i>
32
+ 載入中...
33
+ </div>
34
+ </div>
35
+
36
+ <div class="metric-card" id="expiredLicensesCard">
37
+ <div class="metric-title">已過期</div>
38
+ <div class="metric-value">-</div>
39
+ <div class="metric-change">
40
+ <i class="fas fa-exclamation-triangle"></i>
41
+ 載入中...
42
+ </div>
43
+ </div>
44
+
45
+ <div class="metric-card" id="todayActivationsCard">
46
+ <div class="metric-title">今日啟用</div>
47
+ <div class="metric-value">-</div>
48
+ <div class="metric-change">
49
+ <i class="fas fa-chart-line"></i>
50
+ 載入中...
51
+ </div>
52
+ </div>
53
+ </div>
54
+ </div>
55
+
56
+ <!-- Charts Row -->
57
+ <div class="charts-row mb-5">
58
+ <div class="grid grid-2">
59
+ <div class="card">
60
+ <div class="card-header">
61
+ <h3 class="card-title">
62
+ <i class="fas fa-chart-pie"></i>
63
+ 授權狀態分布
64
+ </h3>
65
+ </div>
66
+ <div class="card-body">
67
+ <div class="chart-container" style="height: 300px;">
68
+ <canvas id="statusChart"></canvas>
69
+ </div>
70
+ </div>
71
+ </div>
72
+
73
+ <div class="card">
74
+ <div class="card-header">
75
+ <h3 class="card-title">
76
+ <i class="fas fa-chart-bar"></i>
77
+ 啟用趨勢
78
+ </h3>
79
+ </div>
80
+ <div class="card-body">
81
+ <div class="chart-container" style="height: 300px;">
82
+ <canvas id="trendChart"></canvas>
83
+ </div>
84
+ </div>
85
+ </div>
86
+ </div>
87
+ </div>
88
+
89
+ <!-- Recent Activity -->
90
+ <div class="activity-section">
91
+ <div class="card">
92
+ <div class="card-header">
93
+ <h3 class="card-title">
94
+ <i class="fas fa-history"></i>
95
+ 最近活動
96
+ </h3>
97
+ <button class="btn btn-sm btn-secondary" onclick="dashboardPage.refreshActivity()">
98
+ <i class="fas fa-sync-alt"></i>
99
+ 重新整理
100
+ </button>
101
+ </div>
102
+ <div class="card-body">
103
+ <div id="recentActivityContainer">
104
+ <div class="text-center">
105
+ <div class="spinner mb-3"></div>
106
+ <p>載入活動記錄中...</p>
107
+ </div>
108
+ </div>
109
+ </div>
110
+ </div>
111
+ </div>
112
+ </div>
113
+ `;
114
+
115
+ // Load data
116
+ await this.loadDashboardData();
117
+
118
+ // Setup auto-refresh
119
+ this.setupAutoRefresh();
120
+ }
121
+
122
+ async loadDashboardData() {
123
+ try {
124
+ // Load stats and activity in parallel
125
+ const [statsResult, activityResult] = await Promise.all([
126
+ this.loadStats(),
127
+ this.loadRecentActivity()
128
+ ]);
129
+
130
+ if (statsResult) {
131
+ this.renderStats(statsResult);
132
+ this.renderCharts(statsResult);
133
+ }
134
+
135
+ if (activityResult) {
136
+ this.renderRecentActivity(activityResult);
137
+ }
138
+
139
+ } catch (error) {
140
+ Utils.handleError(error, '載入儀表板數據時');
141
+ }
142
+ }
143
+
144
+ async loadStats() {
145
+ try {
146
+ const response = await api.getLicenseStats();
147
+ return response.data || response;
148
+ } catch (error) {
149
+ console.error('Failed to load stats:', error);
150
+ this.renderStatsError();
151
+ return null;
152
+ }
153
+ }
154
+
155
+ async loadRecentActivity() {
156
+ try {
157
+ const response = await api.getLicenseLogs(10);
158
+ return response.data || response;
159
+ } catch (error) {
160
+ console.error('Failed to load recent activity:', error);
161
+ this.renderActivityError();
162
+ return null;
163
+ }
164
+ }
165
+
166
+ renderStats(stats) {
167
+ // Total licenses
168
+ const totalCard = document.getElementById('totalLicensesCard');
169
+ if (totalCard) {
170
+ totalCard.querySelector('.metric-value').textContent = Utils.formatNumber(stats.total_licenses || 0);
171
+ totalCard.querySelector('.metric-change').innerHTML = `
172
+ <i class="fas fa-key"></i>
173
+ 總計
174
+ `;
175
+ }
176
+
177
+ // Active licenses
178
+ const activeCard = document.getElementById('activeLicensesCard');
179
+ if (activeCard) {
180
+ const activeCount = stats.active_licenses || 0;
181
+ const totalCount = stats.total_licenses || 0;
182
+ const percentage = totalCount > 0 ? ((activeCount / totalCount) * 100).toFixed(1) : 0;
183
+
184
+ activeCard.querySelector('.metric-value').textContent = Utils.formatNumber(activeCount);
185
+ activeCard.querySelector('.metric-change').innerHTML = `
186
+ <i class="fas fa-check-circle"></i>
187
+ ${percentage}% 啟用率
188
+ `;
189
+ }
190
+
191
+ // Expired licenses
192
+ const expiredCard = document.getElementById('expiredLicensesCard');
193
+ if (expiredCard) {
194
+ const expiredCount = stats.expired_licenses || 0;
195
+ expiredCard.querySelector('.metric-value').textContent = Utils.formatNumber(expiredCount);
196
+ expiredCard.querySelector('.metric-change').innerHTML = `
197
+ <i class="fas fa-exclamation-triangle"></i>
198
+ 需要關注
199
+ `;
200
+ }
201
+
202
+ // Today activations
203
+ const todayCard = document.getElementById('todayActivationsCard');
204
+ if (todayCard) {
205
+ const todayCount = stats.today_activations || 0;
206
+ const yesterdayCount = stats.yesterday_activations || 0;
207
+ const change = todayCount - yesterdayCount;
208
+ const changeClass = change > 0 ? 'positive' : change < 0 ? 'negative' : '';
209
+ const changeIcon = change > 0 ? 'fas fa-arrow-up' : change < 0 ? 'fas fa-arrow-down' : 'fas fa-minus';
210
+
211
+ todayCard.querySelector('.metric-value').textContent = Utils.formatNumber(todayCount);
212
+ todayCard.querySelector('.metric-change').innerHTML = `
213
+ <i class="${changeIcon}"></i>
214
+ <span class="metric-change ${changeClass}">
215
+ ${change > 0 ? '+' : ''}${change} 較昨日
216
+ </span>
217
+ `;
218
+ }
219
+ }
220
+
221
+ renderStatsError() {
222
+ const cards = document.querySelectorAll('.metric-card');
223
+ cards.forEach(card => {
224
+ card.querySelector('.metric-value').textContent = 'N/A';
225
+ card.querySelector('.metric-change').innerHTML = `
226
+ <i class="fas fa-exclamation-circle"></i>
227
+ 載入失敗
228
+ `;
229
+ });
230
+ }
231
+
232
+ renderCharts(stats) {
233
+ this.renderStatusChart(stats);
234
+ this.renderTrendChart(stats);
235
+ }
236
+
237
+ renderStatusChart(stats) {
238
+ const canvas = document.getElementById('statusChart');
239
+ if (!canvas) return;
240
+
241
+ // Destroy existing chart
242
+ if (this.charts.statusChart) {
243
+ this.charts.statusChart.destroy();
244
+ }
245
+
246
+ const data = {
247
+ labels: ['啟用中', '已停用', '已過期'],
248
+ datasets: [{
249
+ data: [
250
+ stats.active_licenses || 0,
251
+ stats.inactive_licenses || 0,
252
+ stats.expired_licenses || 0
253
+ ],
254
+ backgroundColor: [
255
+ '#3fb950', // Green
256
+ '#d29922', // Yellow
257
+ '#f85149' // Red
258
+ ],
259
+ borderColor: '#21262d',
260
+ borderWidth: 2
261
+ }]
262
+ };
263
+
264
+ this.charts.statusChart = Components.createChart(canvas, {
265
+ type: 'doughnut',
266
+ data: data,
267
+ options: {
268
+ plugins: {
269
+ legend: {
270
+ position: 'bottom',
271
+ labels: {
272
+ padding: 20,
273
+ font: {
274
+ size: 12
275
+ }
276
+ }
277
+ }
278
+ },
279
+ cutout: '60%'
280
+ }
281
+ });
282
+ }
283
+
284
+ renderTrendChart(stats) {
285
+ const canvas = document.getElementById('trendChart');
286
+ if (!canvas) return;
287
+
288
+ // Destroy existing chart
289
+ if (this.charts.trendChart) {
290
+ this.charts.trendChart.destroy();
291
+ }
292
+
293
+ const data = {
294
+ labels: ['今日', '本週', '本月'],
295
+ datasets: [{
296
+ label: '啟用數量',
297
+ data: [
298
+ stats.today_activations || 0,
299
+ stats.this_week_activations || 0,
300
+ stats.this_month_activations || 0
301
+ ],
302
+ backgroundColor: 'rgba(88, 166, 255, 0.2)',
303
+ borderColor: '#58a6ff',
304
+ borderWidth: 2,
305
+ borderRadius: 8,
306
+ borderSkipped: false
307
+ }]
308
+ };
309
+
310
+ this.charts.trendChart = Components.createChart(canvas, {
311
+ type: 'bar',
312
+ data: data,
313
+ options: {
314
+ plugins: {
315
+ legend: {
316
+ display: false
317
+ }
318
+ },
319
+ scales: {
320
+ y: {
321
+ beginAtZero: true,
322
+ ticks: {
323
+ stepSize: 1
324
+ }
325
+ }
326
+ }
327
+ }
328
+ });
329
+ }
330
+
331
+ renderRecentActivity(activities) {
332
+ const container = document.getElementById('recentActivityContainer');
333
+ if (!container) return;
334
+
335
+ if (!activities || activities.length === 0) {
336
+ container.innerHTML = `
337
+ <div class="empty-state text-center">
338
+ <i class="fas fa-inbox fa-2x mb-3"></i>
339
+ <p>暫無活動記錄</p>
340
+ </div>
341
+ `;
342
+ return;
343
+ }
344
+
345
+ const activityList = activities.slice(0, 10).map(activity => {
346
+ const statusIcon = activity.error_message ? 'fas fa-times-circle text-red' : 'fas fa-check-circle text-green';
347
+ const actionText = this.getActionText(activity.action);
348
+ const userName = activity.licenses?.user_name || '未知用戶';
349
+ const time = Utils.getRelativeTime(activity.created_at);
350
+
351
+ return `
352
+ <div class="activity-item">
353
+ <div class="activity-icon">
354
+ <i class="${statusIcon}"></i>
355
+ </div>
356
+ <div class="activity-content">
357
+ <div class="activity-title">${actionText}</div>
358
+ <div class="activity-details">
359
+ 用戶:${userName} • IP:${activity.ip_address || '未知'} • ${time}
360
+ </div>
361
+ ${activity.error_message ? `
362
+ <div class="activity-error">
363
+ <i class="fas fa-exclamation-triangle"></i>
364
+ ${activity.error_message}
365
+ </div>
366
+ ` : ''}
367
+ </div>
368
+ </div>
369
+ `;
370
+ }).join('');
371
+
372
+ container.innerHTML = `
373
+ <div class="activity-list">
374
+ ${activityList}
375
+ </div>
376
+ `;
377
+ }
378
+
379
+ renderActivityError() {
380
+ const container = document.getElementById('recentActivityContainer');
381
+ if (!container) return;
382
+
383
+ container.innerHTML = `
384
+ <div class="empty-state text-center">
385
+ <i class="fas fa-exclamation-circle fa-2x mb-3"></i>
386
+ <p>載入活動記錄失敗</p>
387
+ <button class="btn btn-sm btn-secondary" onclick="dashboardPage.refreshActivity()">
388
+ 重試
389
+ </button>
390
+ </div>
391
+ `;
392
+ }
393
+
394
+ getActionText(action) {
395
+ const actionMap = {
396
+ 'activate': '啟用授權',
397
+ 'validate': '驗證授權',
398
+ 'create': '建立授權',
399
+ 'update': '更新授權',
400
+ 'delete': '刪除授權',
401
+ 'error': '操作錯誤'
402
+ };
403
+
404
+ return actionMap[action] || action || '未知操作';
405
+ }
406
+
407
+ async refreshActivity() {
408
+ const container = document.getElementById('recentActivityContainer');
409
+ if (!container) return;
410
+
411
+ container.innerHTML = `
412
+ <div class="text-center">
413
+ <div class="spinner mb-3"></div>
414
+ <p>重新載入中...</p>
415
+ </div>
416
+ `;
417
+
418
+ const activity = await this.loadRecentActivity();
419
+ if (activity) {
420
+ this.renderRecentActivity(activity);
421
+ }
422
+ }
423
+
424
+ async refresh() {
425
+ await this.loadDashboardData();
426
+ }
427
+
428
+ setupAutoRefresh() {
429
+ // Clear existing interval
430
+ if (this.refreshInterval) {
431
+ clearInterval(this.refreshInterval);
432
+ }
433
+
434
+ // Refresh every 5 minutes
435
+ this.refreshInterval = setInterval(() => {
436
+ this.refresh();
437
+ }, 5 * 60 * 1000);
438
+ }
439
+
440
+ destroy() {
441
+ // Clear interval
442
+ if (this.refreshInterval) {
443
+ clearInterval(this.refreshInterval);
444
+ this.refreshInterval = null;
445
+ }
446
+
447
+ // Destroy charts
448
+ Object.values(this.charts).forEach(chart => {
449
+ if (chart && chart.destroy) {
450
+ chart.destroy();
451
+ }
452
+ });
453
+ this.charts = {};
454
+ }
455
+ }
456
+
457
+ // Create global instance
458
+ window.dashboardPage = new DashboardPage();
frontend/js/pages/logs.js ADDED
@@ -0,0 +1,430 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Logs Page for KSTools License Manager
3
+ * Display and manage license usage logs
4
+ */
5
+
6
+ class LogsPage {
7
+ constructor() {
8
+ this.logs = [];
9
+ this.filteredLogs = [];
10
+ this.filters = {
11
+ action: 'all',
12
+ dateRange: 'all',
13
+ limit: 100
14
+ };
15
+ }
16
+
17
+ async render(container) {
18
+ container.innerHTML = `
19
+ <div class="logs-page">
20
+ <!-- Page Header -->
21
+ <div class="page-header mb-4">
22
+ <h2>授權記錄</h2>
23
+ <p class="text-secondary">查看系統使用記錄和活動日誌</p>
24
+ </div>
25
+
26
+ <!-- Filters -->
27
+ <div class="filters-section mb-4">
28
+ <div class="card">
29
+ <div class="card-body">
30
+ <div class="grid grid-4">
31
+ <div class="form-group mb-0">
32
+ <label class="form-label">動作篩選</label>
33
+ <select id="actionFilter" class="form-select">
34
+ <option value="all">全部動作</option>
35
+ <option value="activate">啟用授權</option>
36
+ <option value="validate">驗證授權</option>
37
+ <option value="create">建立授權</option>
38
+ <option value="error">錯誤記錄</option>
39
+ </select>
40
+ </div>
41
+
42
+ <div class="form-group mb-0">
43
+ <label class="form-label">時間範圍</label>
44
+ <select id="dateRangeFilter" class="form-select">
45
+ <option value="all">全部時間</option>
46
+ <option value="today">今日</option>
47
+ <option value="week">本週</option>
48
+ <option value="month">本月</option>
49
+ </select>
50
+ </div>
51
+
52
+ <div class="form-group mb-0">
53
+ <label class="form-label">顯示筆數</label>
54
+ <select id="limitFilter" class="form-select">
55
+ <option value="50">50 筆</option>
56
+ <option value="100" selected>100 筆</option>
57
+ <option value="200">200 筆</option>
58
+ <option value="500">500 筆</option>
59
+ </select>
60
+ </div>
61
+
62
+ <div class="form-group mb-0">
63
+ <label class="form-label">&nbsp;</label>
64
+ <button class="btn btn-secondary w-full" onclick="logsPage.refresh()">
65
+ <i class="fas fa-sync-alt"></i>
66
+ 重新整理
67
+ </button>
68
+ </div>
69
+ </div>
70
+ </div>
71
+ </div>
72
+ </div>
73
+
74
+ <!-- Statistics -->
75
+ <div class="stats-section mb-4">
76
+ <div class="grid grid-4">
77
+ <div class="metric-card" id="activateCountCard">
78
+ <div class="metric-title">啟用次數</div>
79
+ <div class="metric-value">-</div>
80
+ </div>
81
+ <div class="metric-card" id="validateCountCard">
82
+ <div class="metric-title">驗證次數</div>
83
+ <div class="metric-value">-</div>
84
+ </div>
85
+ <div class="metric-card" id="errorCountCard">
86
+ <div class="metric-title">錯誤次數</div>
87
+ <div class="metric-value">-</div>
88
+ </div>
89
+ <div class="metric-card" id="uniqueIpCard">
90
+ <div class="metric-title">不同 IP</div>
91
+ <div class="metric-value">-</div>
92
+ </div>
93
+ </div>
94
+ </div>
95
+
96
+ <!-- Logs Table -->
97
+ <div class="logs-section">
98
+ <div class="card">
99
+ <div class="card-header">
100
+ <h3 class="card-title">
101
+ <i class="fas fa-list"></i>
102
+ 詳細記錄
103
+ </h3>
104
+ </div>
105
+ <div class="card-body">
106
+ <div id="logsContainer">
107
+ <div class="text-center">
108
+ <div class="spinner mb-3"></div>
109
+ <p>載入記錄中...</p>
110
+ </div>
111
+ </div>
112
+ </div>
113
+ </div>
114
+ </div>
115
+ </div>
116
+ `;
117
+
118
+ this.setupEventHandlers();
119
+ await this.loadLogs();
120
+ }
121
+
122
+ setupEventHandlers() {
123
+ const actionFilter = document.getElementById('actionFilter');
124
+ const dateRangeFilter = document.getElementById('dateRangeFilter');
125
+ const limitFilter = document.getElementById('limitFilter');
126
+
127
+ if (actionFilter) {
128
+ actionFilter.addEventListener('change', (e) => {
129
+ this.filters.action = e.target.value;
130
+ this.applyFilters();
131
+ });
132
+ }
133
+
134
+ if (dateRangeFilter) {
135
+ dateRangeFilter.addEventListener('change', (e) => {
136
+ this.filters.dateRange = e.target.value;
137
+ this.applyFilters();
138
+ });
139
+ }
140
+
141
+ if (limitFilter) {
142
+ limitFilter.addEventListener('change', async (e) => {
143
+ this.filters.limit = parseInt(e.target.value);
144
+ await this.loadLogs();
145
+ });
146
+ }
147
+ }
148
+
149
+ async loadLogs() {
150
+ try {
151
+ const response = await api.getLicenseLogs(this.filters.limit);
152
+ this.logs = response.data || response || [];
153
+ this.applyFilters();
154
+ this.updateStatistics();
155
+ } catch (error) {
156
+ Utils.handleError(error, '載入記錄時');
157
+ this.renderError();
158
+ }
159
+ }
160
+
161
+ applyFilters() {
162
+ let filtered = [...this.logs];
163
+
164
+ // Action filter
165
+ if (this.filters.action !== 'all') {
166
+ filtered = filtered.filter(log => log.action === this.filters.action);
167
+ }
168
+
169
+ // Date range filter
170
+ if (this.filters.dateRange !== 'all') {
171
+ const now = new Date();
172
+ let startDate;
173
+
174
+ switch (this.filters.dateRange) {
175
+ case 'today':
176
+ startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
177
+ break;
178
+ case 'week':
179
+ startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
180
+ break;
181
+ case 'month':
182
+ startDate = new Date(now.getFullYear(), now.getMonth(), 1);
183
+ break;
184
+ default:
185
+ startDate = null;
186
+ }
187
+
188
+ if (startDate) {
189
+ filtered = filtered.filter(log => {
190
+ const logDate = new Date(log.created_at);
191
+ return logDate >= startDate;
192
+ });
193
+ }
194
+ }
195
+
196
+ this.filteredLogs = filtered;
197
+ this.renderLogs();
198
+ }
199
+
200
+ renderLogs() {
201
+ const container = document.getElementById('logsContainer');
202
+ if (!container) return;
203
+
204
+ if (this.filteredLogs.length === 0) {
205
+ container.innerHTML = `
206
+ <div class="empty-state text-center">
207
+ <i class="fas fa-file-alt fa-2x mb-3"></i>
208
+ <h4>沒有找到記錄</h4>
209
+ <p>請調整篩選條件或檢查系統是否有活動</p>
210
+ </div>
211
+ `;
212
+ return;
213
+ }
214
+
215
+ const tableHtml = `
216
+ <div class="table-container">
217
+ <table class="table">
218
+ <thead>
219
+ <tr>
220
+ <th>時間</th>
221
+ <th>動作</th>
222
+ <th>用戶</th>
223
+ <th>授權碼</th>
224
+ <th>IP地址</th>
225
+ <th>狀態</th>
226
+ <th>詳情</th>
227
+ </tr>
228
+ </thead>
229
+ <tbody>
230
+ ${this.filteredLogs.map(log => this.renderLogRow(log)).join('')}
231
+ </tbody>
232
+ </table>
233
+ </div>
234
+ `;
235
+
236
+ container.innerHTML = tableHtml;
237
+ }
238
+
239
+ renderLogRow(log) {
240
+ const isError = !!log.error_message;
241
+ const statusBadge = isError
242
+ ? '<span class="badge badge-error"><i class="fas fa-times"></i> 失敗</span>'
243
+ : '<span class="badge badge-success"><i class="fas fa-check"></i> 成功</span>';
244
+
245
+ const actionText = this.getActionText(log.action);
246
+ const userName = log.licenses?.user_name || '未知用戶';
247
+ const licenseCode = log.licenses?.license_code || 'N/A';
248
+ const time = Utils.formatDateTime(log.created_at);
249
+ const ip = log.ip_address || '未知';
250
+
251
+ return `
252
+ <tr class="${isError ? 'error-row' : ''}">
253
+ <td>${time}</td>
254
+ <td>
255
+ <span class="action-badge">
256
+ <i class="fas ${this.getActionIcon(log.action)}"></i>
257
+ ${actionText}
258
+ </span>
259
+ </td>
260
+ <td>${userName}</td>
261
+ <td>
262
+ ${licenseCode !== 'N/A'
263
+ ? `<code class="license-code-sm">${licenseCode.substring(0, 8)}...</code>`
264
+ : licenseCode
265
+ }
266
+ </td>
267
+ <td>${ip}</td>
268
+ <td>${statusBadge}</td>
269
+ <td>
270
+ ${log.error_message || log.hardware_info
271
+ ? `<button class="btn btn-sm btn-secondary" onclick="logsPage.showLogDetails('${log.id}')">
272
+ <i class="fas fa-eye"></i>
273
+ </button>`
274
+ : '-'
275
+ }
276
+ </td>
277
+ </tr>
278
+ `;
279
+ }
280
+
281
+ renderError() {
282
+ const container = document.getElementById('logsContainer');
283
+ if (!container) return;
284
+
285
+ container.innerHTML = `
286
+ <div class="empty-state text-center">
287
+ <i class="fas fa-exclamation-circle fa-2x mb-3"></i>
288
+ <h4>載入失敗</h4>
289
+ <p>無法載入記錄,請檢查網路連接</p>
290
+ <button class="btn btn-primary mt-3" onclick="logsPage.refresh()">
291
+ <i class="fas fa-sync-alt"></i>
292
+ 重試
293
+ </button>
294
+ </div>
295
+ `;
296
+ }
297
+
298
+ updateStatistics() {
299
+ const activateCount = this.logs.filter(log => log.action === 'activate').length;
300
+ const validateCount = this.logs.filter(log => log.action === 'validate').length;
301
+ const errorCount = this.logs.filter(log => !!log.error_message).length;
302
+ const uniqueIps = new Set(this.logs.map(log => log.ip_address).filter(Boolean)).size;
303
+
304
+ this.updateStatCard('activateCountCard', activateCount);
305
+ this.updateStatCard('validateCountCard', validateCount);
306
+ this.updateStatCard('errorCountCard', errorCount);
307
+ this.updateStatCard('uniqueIpCard', uniqueIps);
308
+ }
309
+
310
+ updateStatCard(cardId, value) {
311
+ const card = document.getElementById(cardId);
312
+ if (card) {
313
+ const valueElement = card.querySelector('.metric-value');
314
+ if (valueElement) {
315
+ valueElement.textContent = Utils.formatNumber(value);
316
+ }
317
+ }
318
+ }
319
+
320
+ getActionText(action) {
321
+ const actionMap = {
322
+ 'activate': '啟用授權',
323
+ 'validate': '驗證授權',
324
+ 'create': '建立授權',
325
+ 'update': '更新授權',
326
+ 'delete': '刪除授權',
327
+ 'error': '操作錯誤'
328
+ };
329
+ return actionMap[action] || action || '未知操作';
330
+ }
331
+
332
+ getActionIcon(action) {
333
+ const iconMap = {
334
+ 'activate': 'fa-play-circle',
335
+ 'validate': 'fa-check-circle',
336
+ 'create': 'fa-plus-circle',
337
+ 'update': 'fa-edit',
338
+ 'delete': 'fa-trash',
339
+ 'error': 'fa-exclamation-circle'
340
+ };
341
+ return iconMap[action] || 'fa-question-circle';
342
+ }
343
+
344
+ showLogDetails(logId) {
345
+ const log = this.logs.find(l => l.id === logId);
346
+ if (!log) return;
347
+
348
+ const modal = Components.createModal({
349
+ title: '記錄詳情',
350
+ size: 'lg',
351
+ content: `
352
+ <div class="log-details">
353
+ <div class="detail-header mb-4">
354
+ <div class="d-flex justify-between align-center">
355
+ <h4>${this.getActionText(log.action)}</h4>
356
+ ${log.error_message
357
+ ? '<span class="badge badge-error"><i class="fas fa-times"></i> 失敗</span>'
358
+ : '<span class="badge badge-success"><i class="fas fa-check"></i> 成功</span>'
359
+ }
360
+ </div>
361
+ <p class="text-secondary">${Utils.formatDateTime(log.created_at)}</p>
362
+ </div>
363
+
364
+ <div class="details-grid grid grid-2 mb-4">
365
+ <div class="detail-item">
366
+ <label>用戶名稱</label>
367
+ <div>${log.licenses?.user_name || '未知用戶'}</div>
368
+ </div>
369
+ <div class="detail-item">
370
+ <label>IP地址</label>
371
+ <div>${log.ip_address || '未知'}</div>
372
+ </div>
373
+ <div class="detail-item">
374
+ <label>授權碼</label>
375
+ <div>
376
+ ${log.licenses?.license_code
377
+ ? `<code>${Utils.formatLicenseCode(log.licenses.license_code)}</code>`
378
+ : 'N/A'
379
+ }
380
+ </div>
381
+ </div>
382
+ <div class="detail-item">
383
+ <label>動作類型</label>
384
+ <div>
385
+ <i class="fas ${this.getActionIcon(log.action)}"></i>
386
+ ${this.getActionText(log.action)}
387
+ </div>
388
+ </div>
389
+ </div>
390
+
391
+ ${log.hardware_info ? `
392
+ <div class="detail-section mb-4">
393
+ <label class="detail-section-title">硬體資訊</label>
394
+ <div class="detail-code">
395
+ <pre><code>${log.hardware_info}</code></pre>
396
+ </div>
397
+ </div>
398
+ ` : ''}
399
+
400
+ ${log.error_message ? `
401
+ <div class="detail-section mb-4">
402
+ <label class="detail-section-title text-red">錯誤訊息</label>
403
+ <div class="detail-error">
404
+ <i class="fas fa-exclamation-triangle"></i>
405
+ ${log.error_message}
406
+ </div>
407
+ </div>
408
+ ` : ''}
409
+
410
+ <div class="modal-footer">
411
+ <button type="button" class="btn btn-secondary" data-dismiss="modal">關閉</button>
412
+ </div>
413
+ </div>
414
+ `
415
+ });
416
+
417
+ Components.showModal(modal);
418
+ }
419
+
420
+ async refresh() {
421
+ await this.loadLogs();
422
+ }
423
+
424
+ destroy() {
425
+ // Cleanup if needed
426
+ }
427
+ }
428
+
429
+ // Create global instance
430
+ window.logsPage = new LogsPage();
frontend/js/pages/settings.js ADDED
@@ -0,0 +1,283 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Simple Settings Page for KSTools License Manager
3
+ */
4
+
5
+ class SettingsPage {
6
+ constructor() {
7
+ this.settings = {
8
+ theme: 'dark',
9
+ language: 'zh-TW',
10
+ pageSize: 20,
11
+ notifications: true
12
+ };
13
+ }
14
+
15
+ async render(container) {
16
+ container.innerHTML = `
17
+ <div class="settings-page">
18
+ <div class="page-header mb-4">
19
+ <h2>系統設定</h2>
20
+ <p class="text-secondary">管理系統配置和用戶偏好</p>
21
+ </div>
22
+
23
+ <!-- Display Settings -->
24
+ <div class="card mb-4">
25
+ <div class="card-header">
26
+ <h3 class="card-title">
27
+ <i class="fas fa-display"></i>
28
+ 顯示設定
29
+ </h3>
30
+ </div>
31
+ <div class="card-body">
32
+ <form id="settingsForm">
33
+ <div class="grid grid-2">
34
+ <div class="form-group">
35
+ <label class="form-label" for="theme">主題</label>
36
+ <select id="theme" name="theme" class="form-select">
37
+ <option value="dark" selected>深色主題</option>
38
+ <option value="light">淺色主題</option>
39
+ </select>
40
+ </div>
41
+
42
+ <div class="form-group">
43
+ <label class="form-label" for="pageSize">每頁顯示筆數</label>
44
+ <select id="pageSize" name="pageSize" class="form-select">
45
+ <option value="10">10</option>
46
+ <option value="20" selected>20</option>
47
+ <option value="50">50</option>
48
+ </select>
49
+ </div>
50
+ </div>
51
+
52
+ <div class="form-group">
53
+ <div class="form-check">
54
+ <input type="checkbox" id="notifications" name="notifications" class="form-checkbox" checked>
55
+ <label for="notifications" class="form-check-label">
56
+ 啟用通知
57
+ </label>
58
+ </div>
59
+ </div>
60
+
61
+ <div class="form-actions">
62
+ <button type="submit" class="btn btn-primary">
63
+ <i class="fas fa-save"></i>
64
+ 儲存設定
65
+ </button>
66
+ <button type="button" class="btn btn-secondary" onclick="settingsPage.resetToDefaults()">
67
+ <i class="fas fa-undo"></i>
68
+ 重設預設值
69
+ </button>
70
+ </div>
71
+ </form>
72
+ </div>
73
+ </div>
74
+
75
+ <!-- API Connection Test -->
76
+ <div class="card mb-4">
77
+ <div class="card-header">
78
+ <h3 class="card-title">
79
+ <i class="fas fa-network-wired"></i>
80
+ 連線測試
81
+ </h3>
82
+ </div>
83
+ <div class="card-body">
84
+ <div class="d-flex justify-between align-center mb-3">
85
+ <div>
86
+ <h4>API 連線狀態</h4>
87
+ <p class="text-secondary">測試與後端服務的連接</p>
88
+ </div>
89
+ <button type="button" class="btn btn-secondary" onclick="settingsPage.testApiConnection()">
90
+ <i class="fas fa-plug"></i>
91
+ 測試連線
92
+ </button>
93
+ </div>
94
+ <div id="connectionStatus">
95
+ <div class="connection-status unknown">
96
+ <i class="fas fa-question-circle"></i>
97
+ <span>尚未測試</span>
98
+ </div>
99
+ </div>
100
+ </div>
101
+ </div>
102
+
103
+ <!-- System Information -->
104
+ <div class="card mb-4">
105
+ <div class="card-header">
106
+ <h3 class="card-title">
107
+ <i class="fas fa-info-circle"></i>
108
+ 系統資訊
109
+ </h3>
110
+ </div>
111
+ <div class="card-body">
112
+ <div class="system-info-grid">
113
+ <div class="info-row">
114
+ <span class="info-label">版本:</span>
115
+ <span class="info-value">1.0.0</span>
116
+ </div>
117
+ <div class="info-row">
118
+ <span class="info-label">前端架構:</span>
119
+ <span class="info-value">HTML/CSS/JS</span>
120
+ </div>
121
+ <div class="info-row">
122
+ <span class="info-label">認證系統:</span>
123
+ <span class="info-value">Supabase Auth</span>
124
+ </div>
125
+ </div>
126
+ </div>
127
+ </div>
128
+ </div>
129
+ `;
130
+
131
+ await this.loadSettings();
132
+ this.setupEventHandlers();
133
+ }
134
+
135
+ setupEventHandlers() {
136
+ const form = document.getElementById('settingsForm');
137
+ if (form) {
138
+ form.addEventListener('submit', (e) => {
139
+ e.preventDefault();
140
+ this.saveSettings();
141
+ });
142
+ }
143
+ }
144
+
145
+ async loadSettings() {
146
+ try {
147
+ const savedSettings = Utils.getStorage('appSettings', {});
148
+ this.settings = { ...this.settings, ...savedSettings };
149
+
150
+ this.populateForm();
151
+
152
+ } catch (error) {
153
+ console.error('Failed to load settings:', error);
154
+ }
155
+ }
156
+
157
+ populateForm() {
158
+ const form = document.getElementById('settingsForm');
159
+ if (!form) return;
160
+
161
+ Object.keys(this.settings).forEach(key => {
162
+ const element = form.querySelector(`[name="${key}"]`);
163
+ if (element) {
164
+ if (element.type === 'checkbox') {
165
+ element.checked = this.settings[key];
166
+ } else {
167
+ element.value = this.settings[key];
168
+ }
169
+ }
170
+ });
171
+ }
172
+
173
+ async saveSettings() {
174
+ try {
175
+ const form = document.getElementById('settingsForm');
176
+ if (!form) return;
177
+
178
+ const formData = new FormData(form);
179
+ const newSettings = {};
180
+
181
+ for (const [key, value] of formData.entries()) {
182
+ const element = form.querySelector(`[name="${key}"]`);
183
+ if (element) {
184
+ if (element.type === 'checkbox') {
185
+ newSettings[key] = element.checked;
186
+ } else if (element.type === 'number') {
187
+ newSettings[key] = parseInt(value);
188
+ } else {
189
+ newSettings[key] = value;
190
+ }
191
+ }
192
+ }
193
+
194
+ // Handle unchecked checkboxes
195
+ const checkboxes = form.querySelectorAll('input[type="checkbox"]');
196
+ checkboxes.forEach(checkbox => {
197
+ if (!formData.has(checkbox.name)) {
198
+ newSettings[checkbox.name] = false;
199
+ }
200
+ });
201
+
202
+ this.settings = { ...this.settings, ...newSettings };
203
+
204
+ Utils.setStorage('appSettings', this.settings);
205
+
206
+ Utils.showSuccess('設定已儲存');
207
+
208
+ } catch (error) {
209
+ Utils.handleError(error, '儲存設定時');
210
+ }
211
+ }
212
+
213
+ async testApiConnection() {
214
+ const statusContainer = document.getElementById('connectionStatus');
215
+ if (!statusContainer) return;
216
+
217
+ statusContainer.innerHTML = `
218
+ <div class="connection-status testing">
219
+ <div class="spinner-sm"></div>
220
+ <span>測試中...</span>
221
+ </div>
222
+ `;
223
+
224
+ try {
225
+ const startTime = Date.now();
226
+ const result = await api.checkHealth();
227
+ const endTime = Date.now();
228
+ const responseTime = endTime - startTime;
229
+
230
+ if (result.success) {
231
+ statusContainer.innerHTML = `
232
+ <div class="connection-status success">
233
+ <i class="fas fa-check-circle"></i>
234
+ <span>連線成功</span>
235
+ <small>(${responseTime}ms)</small>
236
+ </div>
237
+ `;
238
+ Utils.showSuccess(`API 連線正常 (${responseTime}ms)`);
239
+ } else {
240
+ throw new Error(result.error);
241
+ }
242
+
243
+ } catch (error) {
244
+ statusContainer.innerHTML = `
245
+ <div class="connection-status error">
246
+ <i class="fas fa-times-circle"></i>
247
+ <span>連線失敗</span>
248
+ <small>${error.message}</small>
249
+ </div>
250
+ `;
251
+ Utils.showError('API 連線失敗', error.message);
252
+ }
253
+ }
254
+
255
+ resetToDefaults() {
256
+ if (!confirm('確定要重設為預設值嗎?')) {
257
+ return;
258
+ }
259
+
260
+ this.settings = {
261
+ theme: 'dark',
262
+ language: 'zh-TW',
263
+ pageSize: 20,
264
+ notifications: true
265
+ };
266
+
267
+ Utils.removeStorage('appSettings');
268
+ this.populateForm();
269
+
270
+ Utils.showSuccess('設定已重設為預設值');
271
+ }
272
+
273
+ async refresh() {
274
+ await this.loadSettings();
275
+ }
276
+
277
+ destroy() {
278
+ // Cleanup if needed
279
+ }
280
+ }
281
+
282
+ // Create global instance
283
+ window.settingsPage = new SettingsPage();
frontend/js/pages/users.js ADDED
@@ -0,0 +1,601 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Users Management Page for KSTools License Manager
3
+ * License management and user operations
4
+ */
5
+
6
+ class UsersPage {
7
+ constructor() {
8
+ this.licenses = [];
9
+ this.filteredLicenses = [];
10
+ this.currentPage = 1;
11
+ this.pageSize = 20;
12
+ this.filters = {
13
+ search: '',
14
+ status: 'all'
15
+ };
16
+ }
17
+
18
+ async render(container) {
19
+ container.innerHTML = `
20
+ <div class="users-page">
21
+ <!-- Page Header -->
22
+ <div class="page-header mb-4">
23
+ <div class="d-flex justify-between align-center">
24
+ <div>
25
+ <h2>用戶管理</h2>
26
+ <p class="text-secondary">管理授權和用戶資訊</p>
27
+ </div>
28
+ <button class="btn btn-primary" onclick="usersPage.showCreateLicenseModal()">
29
+ <i class="fas fa-plus"></i>
30
+ 建立新授權
31
+ </button>
32
+ </div>
33
+ </div>
34
+
35
+ <!-- Filters and Search -->
36
+ <div class="filters-section mb-4">
37
+ <div class="card">
38
+ <div class="card-body">
39
+ <div class="grid grid-4">
40
+ <div class="form-group mb-0">
41
+ <label class="form-label">搜尋</label>
42
+ <div class="search-input-container">
43
+ <i class="fas fa-search"></i>
44
+ <input type="text"
45
+ id="searchInput"
46
+ class="form-input search-input"
47
+ placeholder="用戶名稱、郵箱、授權碼...">
48
+ </div>
49
+ </div>
50
+
51
+ <div class="form-group mb-0">
52
+ <label class="form-label">狀態篩選</label>
53
+ <select id="statusFilter" class="form-select">
54
+ <option value="all">全部狀態</option>
55
+ <option value="active">啟用中</option>
56
+ <option value="inactive">已停用</option>
57
+ <option value="expired">已過期</option>
58
+ <option value="never_activated">未啟用</option>
59
+ </select>
60
+ </div>
61
+
62
+ <div class="form-group mb-0">
63
+ <label class="form-label">每頁顯示</label>
64
+ <select id="pageSizeSelect" class="form-select">
65
+ <option value="10">10 筆</option>
66
+ <option value="20" selected>20 筆</option>
67
+ <option value="50">50 筆</option>
68
+ <option value="100">100 筆</option>
69
+ </select>
70
+ </div>
71
+
72
+ <div class="form-group mb-0">
73
+ <label class="form-label">&nbsp;</label>
74
+ <button class="btn btn-secondary w-full" onclick="usersPage.refresh()">
75
+ <i class="fas fa-sync-alt"></i>
76
+ 重新整理
77
+ </button>
78
+ </div>
79
+ </div>
80
+ </div>
81
+ </div>
82
+ </div>
83
+
84
+ <!-- License List -->
85
+ <div class="licenses-section">
86
+ <div class="card">
87
+ <div class="card-header">
88
+ <div class="d-flex justify-between align-center">
89
+ <h3 class="card-title">
90
+ <i class="fas fa-list"></i>
91
+ 授權列表
92
+ </h3>
93
+ <div class="license-count">
94
+ <span id="licenseCount">載入中...</span>
95
+ </div>
96
+ </div>
97
+ </div>
98
+ <div class="card-body">
99
+ <div id="licensesContainer">
100
+ <div class="text-center">
101
+ <div class="spinner mb-3"></div>
102
+ <p>載入授權列表中...</p>
103
+ </div>
104
+ </div>
105
+ </div>
106
+ </div>
107
+ </div>
108
+
109
+ <!-- Pagination -->
110
+ <div class="pagination-section mt-4" id="paginationSection">
111
+ <!-- Pagination will be inserted here -->
112
+ </div>
113
+ </div>
114
+ `;
115
+
116
+ // Setup event handlers
117
+ this.setupEventHandlers();
118
+
119
+ // Load initial data
120
+ await this.loadLicenses();
121
+ }
122
+
123
+ setupEventHandlers() {
124
+ // Search input
125
+ const searchInput = document.getElementById('searchInput');
126
+ if (searchInput) {
127
+ const debouncedSearch = Utils.debounce((value) => {
128
+ this.filters.search = value;
129
+ this.applyFilters();
130
+ }, 300);
131
+
132
+ searchInput.addEventListener('input', (e) => {
133
+ debouncedSearch(e.target.value);
134
+ });
135
+ }
136
+
137
+ // Status filter
138
+ const statusFilter = document.getElementById('statusFilter');
139
+ if (statusFilter) {
140
+ statusFilter.addEventListener('change', (e) => {
141
+ this.filters.status = e.target.value;
142
+ this.applyFilters();
143
+ });
144
+ }
145
+
146
+ // Page size
147
+ const pageSizeSelect = document.getElementById('pageSizeSelect');
148
+ if (pageSizeSelect) {
149
+ pageSizeSelect.addEventListener('change', (e) => {
150
+ this.pageSize = parseInt(e.target.value);
151
+ this.currentPage = 1;
152
+ this.renderLicenses();
153
+ });
154
+ }
155
+ }
156
+
157
+ async loadLicenses() {
158
+ try {
159
+ const response = await api.getLicenses();
160
+ this.licenses = response.data || response || [];
161
+ this.applyFilters();
162
+ Utils.showSuccess('授權列表載入完成');
163
+ } catch (error) {
164
+ Utils.handleError(error, '載入授權列表時');
165
+ this.renderError();
166
+ }
167
+ }
168
+
169
+ applyFilters() {
170
+ let filtered = [...this.licenses];
171
+
172
+ // Search filter
173
+ if (this.filters.search) {
174
+ const query = this.filters.search.toLowerCase();
175
+ filtered = filtered.filter(license => {
176
+ const searchFields = [
177
+ license.user_name || '',
178
+ license.user_email || '',
179
+ license.license_code || '',
180
+ license.hardware_id || ''
181
+ ];
182
+
183
+ return searchFields.some(field =>
184
+ field.toLowerCase().includes(query)
185
+ );
186
+ });
187
+ }
188
+
189
+ // Status filter
190
+ if (this.filters.status !== 'all') {
191
+ const now = new Date();
192
+
193
+ filtered = filtered.filter(license => {
194
+ const expiresAt = new Date(license.expires_at);
195
+ const isExpired = expiresAt < now;
196
+ const isActivated = license.activated_at !== null;
197
+
198
+ switch (this.filters.status) {
199
+ case 'active':
200
+ return license.is_active && !isExpired;
201
+ case 'inactive':
202
+ return !license.is_active && !isExpired;
203
+ case 'expired':
204
+ return isExpired;
205
+ case 'never_activated':
206
+ return !isActivated;
207
+ default:
208
+ return true;
209
+ }
210
+ });
211
+ }
212
+
213
+ this.filteredLicenses = filtered;
214
+ this.currentPage = 1;
215
+ this.renderLicenses();
216
+ this.updateLicenseCount();
217
+ }
218
+
219
+ renderLicenses() {
220
+ const container = document.getElementById('licensesContainer');
221
+ if (!container) return;
222
+
223
+ if (this.filteredLicenses.length === 0) {
224
+ container.innerHTML = `
225
+ <div class="empty-state text-center">
226
+ <i class="fas fa-search fa-2x mb-3"></i>
227
+ <h4>沒有找到符合條件的授權</h4>
228
+ <p>請調整搜尋條件或建立新的授權</p>
229
+ <button class="btn btn-primary mt-3" onclick="usersPage.showCreateLicenseModal()">
230
+ <i class="fas fa-plus"></i>
231
+ 建立新授權
232
+ </button>
233
+ </div>
234
+ `;
235
+ return;
236
+ }
237
+
238
+ // Pagination
239
+ const totalPages = Math.ceil(this.filteredLicenses.length / this.pageSize);
240
+ const startIndex = (this.currentPage - 1) * this.pageSize;
241
+ const endIndex = Math.min(startIndex + this.pageSize, this.filteredLicenses.length);
242
+ const currentLicenses = this.filteredLicenses.slice(startIndex, endIndex);
243
+
244
+ // Render license cards
245
+ const licensesHtml = currentLicenses.map(license => this.renderLicenseCard(license)).join('');
246
+
247
+ container.innerHTML = `
248
+ <div class="licenses-grid">
249
+ ${licensesHtml}
250
+ </div>
251
+ `;
252
+
253
+ // Update pagination
254
+ this.renderPagination(totalPages);
255
+ }
256
+
257
+ renderLicenseCard(license) {
258
+ const status = Utils.getLicenseStatus(license);
259
+ const hardwareId = Utils.formatHardwareId(license.hardware_id);
260
+ const licenseCode = Utils.formatLicenseCode(license.license_code);
261
+ const expiresAt = Utils.formatDateTime(license.expires_at);
262
+ const lastUsed = license.last_used_at ? Utils.getRelativeTime(license.last_used_at) : '從未使用';
263
+
264
+ return `
265
+ <div class="license-card card" data-license-id="${license.id}">
266
+ <div class="license-card-header">
267
+ <div class="license-user">
268
+ <div class="user-info">
269
+ <h4 class="user-name">
270
+ <i class="fas fa-user"></i>
271
+ ${license.user_name || '未知用戶'}
272
+ </h4>
273
+ ${license.user_email ? `
274
+ <div class="user-email">
275
+ <i class="fas fa-envelope"></i>
276
+ ${license.user_email}
277
+ </div>
278
+ ` : ''}
279
+ </div>
280
+ </div>
281
+ <div class="license-status">
282
+ <span class="badge ${status.class}">
283
+ <i class="fas ${this.getStatusIcon(status.status)}"></i>
284
+ ${status.label}
285
+ </span>
286
+ </div>
287
+ </div>
288
+
289
+ <div class="license-card-body">
290
+ <div class="license-details">
291
+ <div class="detail-row">
292
+ <span class="detail-label">
293
+ <i class="fas fa-key"></i>
294
+ 授權碼
295
+ </span>
296
+ <div class="detail-value">
297
+ <code class="license-code">${licenseCode}</code>
298
+ <button class="btn-copy" onclick="Utils.copyToClipboard('${license.license_code}')" title="複製授權碼">
299
+ <i class="fas fa-copy"></i>
300
+ </button>
301
+ </div>
302
+ </div>
303
+
304
+ <div class="detail-row">
305
+ <span class="detail-label">
306
+ <i class="fas fa-desktop"></i>
307
+ 硬體ID
308
+ </span>
309
+ <span class="detail-value">${hardwareId}</span>
310
+ </div>
311
+
312
+ <div class="detail-row">
313
+ <span class="detail-label">
314
+ <i class="fas fa-calendar"></i>
315
+ 到期時間
316
+ </span>
317
+ <span class="detail-value">${expiresAt}</span>
318
+ </div>
319
+
320
+ <div class="detail-row">
321
+ <span class="detail-label">
322
+ <i class="fas fa-clock"></i>
323
+ 最後使用
324
+ </span>
325
+ <span class="detail-value">${lastUsed}</span>
326
+ </div>
327
+ </div>
328
+ </div>
329
+
330
+ <div class="license-card-actions">
331
+ <button class="btn btn-sm ${license.is_active ? 'btn-warning' : 'btn-success'}"
332
+ onclick="usersPage.toggleLicenseStatus('${license.id}', ${license.is_active})"
333
+ title="${license.is_active ? '停用授權' : '啟用授權'}">
334
+ <i class="fas ${license.is_active ? 'fa-pause' : 'fa-play'}"></i>
335
+ ${license.is_active ? '停用' : '啟用'}
336
+ </button>
337
+
338
+ <button class="btn btn-sm btn-secondary"
339
+ onclick="usersPage.showLicenseDetails('${license.id}')"
340
+ title="查看詳情">
341
+ <i class="fas fa-eye"></i>
342
+ 詳情
343
+ </button>
344
+
345
+ <button class="btn btn-sm btn-danger"
346
+ onclick="usersPage.deleteLicense('${license.id}')"
347
+ title="刪除授權">
348
+ <i class="fas fa-trash"></i>
349
+ 刪除
350
+ </button>
351
+ </div>
352
+ </div>
353
+ `;
354
+ }
355
+
356
+ renderPagination(totalPages) {
357
+ const paginationSection = document.getElementById('paginationSection');
358
+ if (!paginationSection || totalPages <= 1) {
359
+ paginationSection.innerHTML = '';
360
+ return;
361
+ }
362
+
363
+ let paginationHtml = `
364
+ <div class="pagination-wrapper d-flex justify-between align-center">
365
+ <div class="pagination-info">
366
+ 顯示 ${((this.currentPage - 1) * this.pageSize) + 1}-${Math.min(this.currentPage * this.pageSize, this.filteredLicenses.length)}
367
+ / ${this.filteredLicenses.length} 筆記錄
368
+ </div>
369
+ <div class="pagination">
370
+ `;
371
+
372
+ // Previous button
373
+ paginationHtml += `
374
+ <button class="btn btn-sm btn-secondary ${this.currentPage === 1 ? 'disabled' : ''}"
375
+ onclick="usersPage.goToPage(${this.currentPage - 1})"
376
+ ${this.currentPage === 1 ? 'disabled' : ''}>
377
+ <i class="fas fa-chevron-left"></i>
378
+ 上一頁
379
+ </button>
380
+ `;
381
+
382
+ // Page numbers
383
+ const maxVisiblePages = 5;
384
+ let startPage = Math.max(1, this.currentPage - Math.floor(maxVisiblePages / 2));
385
+ let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
386
+
387
+ if (endPage - startPage < maxVisiblePages - 1) {
388
+ startPage = Math.max(1, endPage - maxVisiblePages + 1);
389
+ }
390
+
391
+ for (let i = startPage; i <= endPage; i++) {
392
+ paginationHtml += `
393
+ <button class="btn btn-sm ${i === this.currentPage ? 'btn-primary' : 'btn-secondary'}"
394
+ onclick="usersPage.goToPage(${i})">
395
+ ${i}
396
+ </button>
397
+ `;
398
+ }
399
+
400
+ // Next button
401
+ paginationHtml += `
402
+ <button class="btn btn-sm btn-secondary ${this.currentPage === totalPages ? 'disabled' : ''}"
403
+ onclick="usersPage.goToPage(${this.currentPage + 1})"
404
+ ${this.currentPage === totalPages ? 'disabled' : ''}>
405
+ 下一頁
406
+ <i class="fas fa-chevron-right"></i>
407
+ </button>
408
+ `;
409
+
410
+ paginationHtml += `
411
+ </div>
412
+ </div>
413
+ `;
414
+
415
+ paginationSection.innerHTML = paginationHtml;
416
+ }
417
+
418
+ updateLicenseCount() {
419
+ const countElement = document.getElementById('licenseCount');
420
+ if (!countElement) return;
421
+
422
+ const total = this.licenses.length;
423
+ const filtered = this.filteredLicenses.length;
424
+
425
+ if (total === filtered) {
426
+ countElement.textContent = `共 ${Utils.formatNumber(total)} 筆授權`;
427
+ } else {
428
+ countElement.textContent = `顯示 ${Utils.formatNumber(filtered)} / ${Utils.formatNumber(total)} 筆授權`;
429
+ }
430
+ }
431
+
432
+ renderError() {
433
+ const container = document.getElementById('licensesContainer');
434
+ if (!container) return;
435
+
436
+ container.innerHTML = `
437
+ <div class="empty-state text-center">
438
+ <i class="fas fa-exclamation-circle fa-2x mb-3"></i>
439
+ <h4>載入失敗</h4>
440
+ <p>無法載入授權列表,請檢查網路連接</p>
441
+ <button class="btn btn-primary mt-3" onclick="usersPage.refresh()">
442
+ <i class="fas fa-sync-alt"></i>
443
+ 重試
444
+ </button>
445
+ </div>
446
+ `;
447
+ }
448
+
449
+ getStatusIcon(status) {
450
+ const icons = {
451
+ active: 'fa-check-circle',
452
+ inactive: 'fa-pause-circle',
453
+ expired: 'fa-times-circle',
454
+ unknown: 'fa-question-circle'
455
+ };
456
+ return icons[status] || icons.unknown;
457
+ }
458
+
459
+ goToPage(page) {
460
+ const totalPages = Math.ceil(this.filteredLicenses.length / this.pageSize);
461
+ if (page < 1 || page > totalPages) return;
462
+
463
+ this.currentPage = page;
464
+ this.renderLicenses();
465
+ }
466
+
467
+ showCreateLicenseModal() {
468
+ const modal = Components.createLicenseModal();
469
+ Components.showModal(modal);
470
+ }
471
+
472
+ async toggleLicenseStatus(licenseId, currentStatus) {
473
+ const action = currentStatus ? '停用' : '啟用';
474
+ const confirmed = await Utils.confirm(
475
+ `確定要${action}這個授權嗎?`,
476
+ `${action}授權`
477
+ );
478
+
479
+ if (!confirmed) return;
480
+
481
+ try {
482
+ const response = await api.toggleLicense(licenseId);
483
+
484
+ if (response.success !== false) {
485
+ Utils.showSuccess(`授權${action}成功`);
486
+ await this.refresh();
487
+ } else {
488
+ throw new Error(response.message || `${action}失敗`);
489
+ }
490
+ } catch (error) {
491
+ Utils.handleError(error, `${action}授權時`);
492
+ }
493
+ }
494
+
495
+ async deleteLicense(licenseId) {
496
+ const confirmed = await Utils.confirm(
497
+ '確定要刪除這個授權嗎?此操作無法撤銷。',
498
+ '刪除授權'
499
+ );
500
+
501
+ if (!confirmed) return;
502
+
503
+ try {
504
+ const response = await api.deleteLicense(licenseId);
505
+
506
+ if (response.success !== false) {
507
+ Utils.showSuccess('授權刪除成功');
508
+ await this.refresh();
509
+ } else {
510
+ throw new Error(response.message || '刪除失敗');
511
+ }
512
+ } catch (error) {
513
+ Utils.handleError(error, '刪除授權時');
514
+ }
515
+ }
516
+
517
+ showLicenseDetails(licenseId) {
518
+ const license = this.licenses.find(l => l.id === licenseId);
519
+ if (!license) return;
520
+
521
+ const status = Utils.getLicenseStatus(license);
522
+
523
+ const modal = Components.createModal({
524
+ title: '授權詳情',
525
+ size: 'lg',
526
+ content: `
527
+ <div class="license-details-modal">
528
+ <div class="license-overview mb-4">
529
+ <div class="d-flex justify-between align-center">
530
+ <h4>${license.user_name}</h4>
531
+ <span class="badge ${status.class}">
532
+ <i class="fas ${this.getStatusIcon(status.status)}"></i>
533
+ ${status.label}
534
+ </span>
535
+ </div>
536
+ ${license.user_email ? `<p class="text-secondary">${license.user_email}</p>` : ''}
537
+ </div>
538
+
539
+ <div class="details-grid grid grid-2">
540
+ <div class="detail-item">
541
+ <label>授權碼</label>
542
+ <div class="d-flex align-center">
543
+ <code class="license-code">${Utils.formatLicenseCode(license.license_code)}</code>
544
+ <button class="btn btn-sm btn-secondary ml-2" onclick="Utils.copyToClipboard('${license.license_code}')">
545
+ <i class="fas fa-copy"></i>
546
+ </button>
547
+ </div>
548
+ </div>
549
+
550
+ <div class="detail-item">
551
+ <label>硬體ID</label>
552
+ <div>${Utils.formatHardwareId(license.hardware_id)}</div>
553
+ </div>
554
+
555
+ <div class="detail-item">
556
+ <label>建立時間</label>
557
+ <div>${Utils.formatDateTime(license.created_at)}</div>
558
+ </div>
559
+
560
+ <div class="detail-item">
561
+ <label>到期時間</label>
562
+ <div>${Utils.formatDateTime(license.expires_at)}</div>
563
+ </div>
564
+
565
+ <div class="detail-item">
566
+ <label>啟用時間</label>
567
+ <div>${license.activated_at ? Utils.formatDateTime(license.activated_at) : '未啟用'}</div>
568
+ </div>
569
+
570
+ <div class="detail-item">
571
+ <label>最後使用</label>
572
+ <div>${license.last_used_at ? Utils.formatDateTime(license.last_used_at) : '從未使用'}</div>
573
+ </div>
574
+ </div>
575
+
576
+ <div class="modal-footer mt-4">
577
+ <button type="button" class="btn btn-secondary" data-dismiss="modal">關閉</button>
578
+ <button type="button" class="btn ${license.is_active ? 'btn-warning' : 'btn-success'}"
579
+ onclick="usersPage.toggleLicenseStatus('${license.id}', ${license.is_active}); Components.closeModal(this.closest('.modal-overlay'));">
580
+ <i class="fas ${license.is_active ? 'fa-pause' : 'fa-play'}"></i>
581
+ ${license.is_active ? '停用' : '啟用'}
582
+ </button>
583
+ </div>
584
+ </div>
585
+ `
586
+ });
587
+
588
+ Components.showModal(modal);
589
+ }
590
+
591
+ async refresh() {
592
+ await this.loadLicenses();
593
+ }
594
+
595
+ destroy() {
596
+ // Cleanup if needed
597
+ }
598
+ }
599
+
600
+ // Create global instance
601
+ window.usersPage = new UsersPage();
frontend/js/utils.js ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Simple utility functions for KSTools License Manager
3
+ */
4
+
5
+ class Utils {
6
+
7
+ // Date formatting
8
+ static formatDateTime(dateString) {
9
+ try {
10
+ const date = new Date(dateString);
11
+ return date.toLocaleString('zh-TW', {
12
+ year: 'numeric',
13
+ month: '2-digit',
14
+ day: '2-digit',
15
+ hour: '2-digit',
16
+ minute: '2-digit',
17
+ hour12: false
18
+ });
19
+ } catch (error) {
20
+ return '無效日期';
21
+ }
22
+ }
23
+
24
+ static getRelativeTime(dateString) {
25
+ try {
26
+ const date = new Date(dateString);
27
+ const now = new Date();
28
+ const diffMs = now - date;
29
+ const diffMins = Math.floor(diffMs / 60000);
30
+ const diffHours = Math.floor(diffMs / 3600000);
31
+ const diffDays = Math.floor(diffMs / 86400000);
32
+
33
+ if (diffMins < 1) return '剛剛';
34
+ if (diffMins < 60) return `${diffMins} 分鐘前`;
35
+ if (diffHours < 24) return `${diffHours} 小時前`;
36
+ if (diffDays < 30) return `${diffDays} 天前`;
37
+
38
+ return this.formatDateTime(dateString);
39
+ } catch (error) {
40
+ return '無效日期';
41
+ }
42
+ }
43
+
44
+ // Number formatting
45
+ static formatNumber(num) {
46
+ if (typeof num !== 'number' || isNaN(num)) return '0';
47
+ return num.toLocaleString('zh-TW');
48
+ }
49
+
50
+ // License code formatting
51
+ static formatLicenseCode(code) {
52
+ if (!code) return '';
53
+ // Format as XXXX-XXXX-XXXX-XXXX
54
+ const cleaned = code.replace(/[^A-Z0-9]/g, '');
55
+ const chunks = cleaned.match(/.{1,4}/g) || [];
56
+ return chunks.join('-');
57
+ }
58
+
59
+ // Local storage helpers
60
+ static getStorage(key, defaultValue = null) {
61
+ try {
62
+ const item = localStorage.getItem(key);
63
+ return item ? JSON.parse(item) : defaultValue;
64
+ } catch (error) {
65
+ console.warn('Storage get error:', error);
66
+ return defaultValue;
67
+ }
68
+ }
69
+
70
+ static setStorage(key, value) {
71
+ try {
72
+ localStorage.setItem(key, JSON.stringify(value));
73
+ } catch (error) {
74
+ console.warn('Storage set error:', error);
75
+ }
76
+ }
77
+
78
+ static removeStorage(key) {
79
+ try {
80
+ localStorage.removeItem(key);
81
+ } catch (error) {
82
+ console.warn('Storage remove error:', error);
83
+ }
84
+ }
85
+
86
+ // URL helpers
87
+ static getQueryParam(param) {
88
+ const urlParams = new URLSearchParams(window.location.search);
89
+ return urlParams.get(param);
90
+ }
91
+
92
+ // Toast notifications
93
+ static showToast(type, title, message = '', duration = 5000) {
94
+ const container = document.getElementById('toastContainer') || document.body;
95
+
96
+ const toast = document.createElement('div');
97
+ toast.className = `toast ${type}`;
98
+ toast.innerHTML = `
99
+ <div class="toast-content">
100
+ <div class="toast-title">${title}</div>
101
+ ${message ? `<div class="toast-message">${message}</div>` : ''}
102
+ </div>
103
+ <button class="toast-close" onclick="this.parentElement.remove()">
104
+ <i class="fas fa-times"></i>
105
+ </button>
106
+ `;
107
+
108
+ container.appendChild(toast);
109
+
110
+ // Auto remove
111
+ setTimeout(() => {
112
+ if (toast.parentElement) {
113
+ toast.remove();
114
+ }
115
+ }, duration);
116
+ }
117
+
118
+ static showSuccess(title, message) {
119
+ this.showToast('success', title, message);
120
+ }
121
+
122
+ static showError(title, message) {
123
+ this.showToast('error', title, message);
124
+ }
125
+
126
+ static showWarning(title, message) {
127
+ this.showToast('warning', title, message);
128
+ }
129
+
130
+ static showInfo(title, message) {
131
+ this.showToast('info', title, message);
132
+ }
133
+
134
+ // Error handling
135
+ static handleError(error, context = '') {
136
+ console.error(`Error ${context}:`, error);
137
+
138
+ let message = error?.message || '未知錯誤';
139
+ if (message.includes('Failed to fetch')) {
140
+ message = '無法連接到伺服器,請檢查網路連接';
141
+ } else if (message.includes('NetworkError')) {
142
+ message = '網路錯誤,請稍後再試';
143
+ } else if (message.includes('timeout')) {
144
+ message = '請求超時,請稍後再試';
145
+ }
146
+
147
+ this.showError(context || '操作失敗', message);
148
+ }
149
+
150
+ // Simple debounce
151
+ static debounce(func, wait) {
152
+ let timeout;
153
+ return function executedFunction(...args) {
154
+ const later = () => {
155
+ clearTimeout(timeout);
156
+ func(...args);
157
+ };
158
+ clearTimeout(timeout);
159
+ timeout = setTimeout(later, wait);
160
+ };
161
+ }
162
+ }
frontend/login.html ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-TW">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>登入 - KSTools License Manager</title>
7
+
8
+ <!-- Favicon -->
9
+ <link rel="icon" type="image/x-icon" href="/favicon.ico">
10
+
11
+ <!-- CSS -->
12
+ <link rel="stylesheet" href="css/style.css">
13
+
14
+ <!-- Font Awesome -->
15
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
16
+
17
+ <!-- Supabase -->
18
+ <script src="https://unpkg.com/@supabase/supabase-js@2.39.3/dist/umd/supabase.min.js"></script>
19
+ </head>
20
+ <body>
21
+ <div class="login-container">
22
+ <div class="login-card">
23
+ <!-- Header -->
24
+ <div class="login-header">
25
+ <h1>
26
+ <i class="fas fa-key text-primary"></i>
27
+ KSTools License Manager
28
+ </h1>
29
+ <p>請登入以存取管理後台</p>
30
+ </div>
31
+
32
+ <!-- Login Form -->
33
+ <form id="loginForm" class="login-form">
34
+ <div class="form-group">
35
+ <label for="email" class="form-label">
36
+ <i class="fas fa-envelope"></i>
37
+ 電子郵件
38
+ </label>
39
+ <input
40
+ type="email"
41
+ id="email"
42
+ name="email"
43
+ class="form-input"
44
+ placeholder="請輸入電子郵件"
45
+ required
46
+ autocomplete="email"
47
+ >
48
+ </div>
49
+
50
+ <div class="form-group">
51
+ <label for="password" class="form-label">
52
+ <i class="fas fa-lock"></i>
53
+ 密碼
54
+ </label>
55
+ <div class="input-group">
56
+ <input
57
+ type="password"
58
+ id="password"
59
+ name="password"
60
+ class="form-input"
61
+ placeholder="請輸入密碼"
62
+ required
63
+ autocomplete="current-password"
64
+ >
65
+ <button
66
+ type="button"
67
+ class="input-group-btn"
68
+ id="togglePassword"
69
+ title="顯示/隱藏密碼"
70
+ >
71
+ <i class="fas fa-eye"></i>
72
+ </button>
73
+ </div>
74
+ </div>
75
+
76
+ <div class="form-group">
77
+ <div class="form-check">
78
+ <input type="checkbox" id="rememberMe" name="rememberMe" class="form-checkbox">
79
+ <label for="rememberMe" class="form-check-label">記住我</label>
80
+ </div>
81
+ </div>
82
+
83
+ <button type="submit" class="btn btn-primary" id="loginBtn">
84
+ <i class="fas fa-sign-in-alt"></i>
85
+ 登入
86
+ </button>
87
+ </form>
88
+
89
+ <!-- Footer -->
90
+ <div class="login-footer">
91
+ <p>&copy; 2024 KSTools License Manager. All rights reserved.</p>
92
+ </div>
93
+ </div>
94
+ </div>
95
+
96
+ <!-- Toast Container -->
97
+ <div id="toastContainer" class="toast-container"></div>
98
+
99
+ <!-- Loading Overlay -->
100
+ <div id="loadingOverlay" class="loading-overlay hidden">
101
+ <div class="loading-content">
102
+ <div class="spinner"></div>
103
+ <p>登入中...</p>
104
+ </div>
105
+ </div>
106
+
107
+ <!-- Scripts -->
108
+ <script src="config.js"></script>
109
+ <script src="js/utils.js"></script>
110
+ <script src="js/auth.js"></script>
111
+ <script>
112
+ // Initialize login page
113
+ document.addEventListener('DOMContentLoaded', () => {
114
+ // Wait for authManager to be fully initialized
115
+ const initLogin = () => {
116
+ if (window.authManager && window.authManager.initialized) {
117
+ authManager.initLoginPage();
118
+ } else if (window.authManager) {
119
+ // Auth manager exists but not initialized, wait for it
120
+ setTimeout(initLogin, 200);
121
+ } else {
122
+ // Auth manager doesn't exist yet, wait more
123
+ setTimeout(initLogin, 500);
124
+ }
125
+ };
126
+
127
+ initLogin();
128
+ });
129
+ </script>
130
+ </body>
131
+ </html>