KyrosDev commited on
Commit
eb493c3
·
1 Parent(s): 588c4fc

新增貼文發布管理功能與測試部署流程

Browse files
CLAUDE.md CHANGED
@@ -82,7 +82,106 @@ python -m http.server 8000
82
  ```
83
 
84
  ### 部署平台
85
- - Hugging Face Spaces: https://huggingface.co/spaces/KyrosDev/kstools-license-manager
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
 
87
  ## Mermaid 圖表顏色規範
88
 
 
82
  ```
83
 
84
  ### 部署平台
85
+ - **正式環境**: https://huggingface.co/spaces/KyrosDev/kstools-license-manager
86
+ - **測試環境**: https://huggingface.co/spaces/KyrosDev/kstools-license-manager-test
87
+
88
+ ### Git 分支策略
89
+
90
+ ```
91
+ main ─────●─────────────●─────────────●───── 正式版本(穩定)
92
+ ↑ ↑ ↑
93
+ develop ──●──●──●──●───●───●──●──●───●───●───── 開發分支(測試)
94
+ ```
95
+
96
+ | 分支 | 用途 | 部署目標 |
97
+ |------|------|----------|
98
+ | `main` | 正式穩定版本 | HuggingFace 正式環境 + GitHub |
99
+ | `develop` | 開發測試 | HuggingFace 測試環境 |
100
+
101
+ ### Git Remote 設定
102
+
103
+ ```bash
104
+ # 查看現有 remote
105
+ git remote -v
106
+
107
+ # 預期設定:
108
+ # github -> GitHub(main + develop 分支)
109
+ # origin -> HuggingFace 正式環境(只推 main)
110
+ # test -> HuggingFace 測試環境(推 develop:main)
111
+
112
+ # 新增測試環境 remote(如果尚未設定)
113
+ git remote add test https://KyrosDev:<HF_TOKEN>@huggingface.co/spaces/KyrosDev/kstools-license-manager-test
114
+ ```
115
+
116
+ ### 開發工作流程
117
+
118
+ **Step 1: 在 develop 分支開發**
119
+ ```bash
120
+ # 確保在 develop 分支
121
+ git checkout develop
122
+
123
+ # 開發、修改程式碼...
124
+
125
+ # 提交變更
126
+ git add .
127
+ git commit -m "新增功能描述"
128
+
129
+ # 推送到 GitHub(養成習慣)
130
+ git push github develop
131
+ ```
132
+
133
+ **Step 2: 部署到測試環境驗證**
134
+ ```bash
135
+ # 推送 develop 到測試平台(HF 從 main 分支 build)
136
+ git push test develop:main
137
+ ```
138
+
139
+ **Step 3: 在測試環境驗證**
140
+ - 訪問 https://huggingface.co/spaces/KyrosDev/kstools-license-manager-test
141
+ - 測試所有功能是否正常
142
+ - 確認 API、UI、資料庫連線正常
143
+
144
+ **Step 4: 測試通過,合併到 main**
145
+ ```bash
146
+ # 切換到 main 分支
147
+ git checkout main
148
+
149
+ # 合併 develop
150
+ git merge develop
151
+
152
+ # 推送到正式環境和 GitHub
153
+ git push origin main
154
+ git push github main
155
+ ```
156
+
157
+ **Step 5: 回到 develop 繼續開發**
158
+ ```bash
159
+ git checkout develop
160
+ ```
161
+
162
+ ### 快速指令參考
163
+
164
+ ```bash
165
+ # 日常開發(在 develop 分支)
166
+ git push github develop # 推送到 GitHub
167
+
168
+ # 部署測試
169
+ git push test develop:main # 推送到測試環境
170
+
171
+ # 發布正式版本(測試通過後)
172
+ git checkout main
173
+ git merge develop
174
+ git push origin main && git push github main
175
+ git checkout develop
176
+ ```
177
+
178
+ ### 注意事項
179
+
180
+ 1. **日常開發**: 在 `develop` 分支工作,頻繁推送到 GitHub
181
+ 2. **測試部署**: `develop` → 測試平台驗證
182
+ 3. **正式發布**: 測試通過後才合併到 `main` 並部署
183
+ 4. **環境變數**: 測試環境和正式環境使用相同的 Supabase,資料會互相影響
184
+ 5. **如需隔離測試**: 可在測試 Space 設定不同的 Supabase 專案連線
185
 
186
  ## Mermaid 圖表顏色規範
187
 
app/api/post_routes.py ADDED
@@ -0,0 +1,481 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ KSTools 貼文管理 API 路由
3
+ 處理社群貼文發布、編輯、郵件發送等功能
4
+ """
5
+
6
+ from fastapi import APIRouter, HTTPException, Depends, Request
7
+ from typing import Optional, List
8
+ from datetime import datetime
9
+ import logging
10
+ from pydantic import BaseModel
11
+
12
+ from ..models.supabase_clients import supabase_clients
13
+ from ..services.auth_service import AuthService
14
+
15
+ logger = logging.getLogger(__name__)
16
+ router = APIRouter(tags=["posts"])
17
+
18
+ # ==================== 資料模型 ====================
19
+
20
+ class PostCreate(BaseModel):
21
+ """建立貼文請求"""
22
+ version: str
23
+ product_type: str # 'revit' 或 'autocad'
24
+ subject: str
25
+ content: str
26
+ version_id: Optional[str] = None
27
+
28
+ class PostUpdate(BaseModel):
29
+ """更新貼文請求"""
30
+ subject: Optional[str] = None
31
+ content: Optional[str] = None
32
+
33
+ class PostPublish(BaseModel):
34
+ """發布貼文請求"""
35
+ send_email: bool = False # 是否同時發送郵件
36
+
37
+ # ==================== 輔助函數 ====================
38
+
39
+ async def verify_admin(request: Request):
40
+ """驗證管理員權限"""
41
+ auth_service = AuthService()
42
+ token = request.headers.get('Authorization', '').replace('Bearer ', '')
43
+
44
+ if not token:
45
+ raise HTTPException(status_code=401, detail="No authorization token")
46
+
47
+ user = auth_service.verify_token(token)
48
+ if not user:
49
+ raise HTTPException(status_code=401, detail="Invalid token")
50
+
51
+ return user
52
+
53
+ def get_download_link(version: str, product_type: str) -> str:
54
+ """取得版本的下載連結"""
55
+ try:
56
+ client = supabase_clients.get_version_client()
57
+ if not client:
58
+ return ""
59
+
60
+ table_name = 'versions_autocad' if product_type == 'autocad' else 'versions'
61
+ result = client.table(table_name).select('download_url').eq('version', version).execute()
62
+
63
+ if result.data and result.data[0].get('download_url'):
64
+ return result.data[0]['download_url']
65
+ return ""
66
+ except Exception as e:
67
+ logger.error(f"Get download link error: {e}")
68
+ return ""
69
+
70
+ def replace_download_link_variable(content: str, download_link: str) -> str:
71
+ """替換內容中的 {download_link} 變數"""
72
+ if not download_link:
73
+ return content
74
+ return content.replace('{download_link}', download_link)
75
+
76
+ # ==================== 管理端點 (需要認證) ====================
77
+
78
+ @router.get("/admin/posts")
79
+ async def get_posts(
80
+ product_type: Optional[str] = None,
81
+ status: Optional[str] = None,
82
+ limit: int = 50,
83
+ current_user = Depends(verify_admin)
84
+ ):
85
+ """
86
+ 取得貼文列表
87
+ 支援產品類型和狀態篩選
88
+ """
89
+ try:
90
+ client = supabase_clients.get_version_client()
91
+ if not client:
92
+ raise HTTPException(status_code=503, detail="Database unavailable")
93
+
94
+ query = client.table('posts').select('*').eq('is_deleted', False)
95
+
96
+ if product_type and product_type in ['revit', 'autocad']:
97
+ query = query.eq('product_type', product_type)
98
+
99
+ if status and status in ['draft', 'published', 'sent']:
100
+ query = query.eq('status', status)
101
+
102
+ result = query.order('created_at', desc=True).limit(limit).execute()
103
+
104
+ return {
105
+ "posts": result.data,
106
+ "total": len(result.data)
107
+ }
108
+
109
+ except Exception as e:
110
+ logger.error(f"Get posts error: {e}")
111
+ raise HTTPException(status_code=500, detail=str(e))
112
+
113
+ @router.post("/admin/posts")
114
+ async def create_post(
115
+ post_data: PostCreate,
116
+ current_user = Depends(verify_admin)
117
+ ):
118
+ """
119
+ 建立新貼文
120
+ 通常在版本發布時自動調用
121
+ """
122
+ try:
123
+ client = supabase_clients.get_version_client()
124
+ if not client:
125
+ raise HTTPException(status_code=503, detail="Database unavailable")
126
+
127
+ # 驗證產品類型
128
+ if post_data.product_type not in ['revit', 'autocad']:
129
+ raise HTTPException(status_code=400, detail="Invalid product_type. Must be 'revit' or 'autocad'")
130
+
131
+ # 檢查是否已存在相同版本的貼文
132
+ existing = client.table('posts').select('id').eq('version', post_data.version).eq('product_type', post_data.product_type).eq('is_deleted', False).execute()
133
+ if existing.data:
134
+ raise HTTPException(status_code=400, detail=f"Post for {post_data.product_type} v{post_data.version} already exists")
135
+
136
+ # 取得下載連結並替換變數
137
+ download_link = get_download_link(post_data.version, post_data.product_type)
138
+ content_with_link = replace_download_link_variable(post_data.content, download_link)
139
+
140
+ # 建立貼文記錄
141
+ new_post = {
142
+ 'version': post_data.version,
143
+ 'product_type': post_data.product_type,
144
+ 'subject': post_data.subject,
145
+ 'content': content_with_link,
146
+ 'status': 'draft',
147
+ 'is_deleted': False
148
+ }
149
+
150
+ if post_data.version_id:
151
+ new_post['version_id'] = post_data.version_id
152
+
153
+ result = client.table('posts').insert(new_post).execute()
154
+
155
+ if not result.data:
156
+ raise HTTPException(status_code=500, detail="Failed to create post")
157
+
158
+ logger.info(f"Post created for {post_data.product_type} v{post_data.version}")
159
+
160
+ return {
161
+ "success": True,
162
+ "message": f"Post created for {post_data.product_type} v{post_data.version}",
163
+ "post": result.data[0]
164
+ }
165
+
166
+ except HTTPException:
167
+ raise
168
+ except Exception as e:
169
+ logger.error(f"Create post error: {e}")
170
+ raise HTTPException(status_code=500, detail=str(e))
171
+
172
+ @router.get("/admin/posts/{post_id}")
173
+ async def get_post(
174
+ post_id: str,
175
+ current_user = Depends(verify_admin)
176
+ ):
177
+ """
178
+ 取得單一貼文詳情
179
+ """
180
+ try:
181
+ client = supabase_clients.get_version_client()
182
+ if not client:
183
+ raise HTTPException(status_code=503, detail="Database unavailable")
184
+
185
+ result = client.table('posts').select('*').eq('id', post_id).eq('is_deleted', False).execute()
186
+
187
+ if not result.data:
188
+ raise HTTPException(status_code=404, detail="Post not found")
189
+
190
+ return {
191
+ "post": result.data[0]
192
+ }
193
+
194
+ except HTTPException:
195
+ raise
196
+ except Exception as e:
197
+ logger.error(f"Get post error: {e}")
198
+ raise HTTPException(status_code=500, detail=str(e))
199
+
200
+ @router.put("/admin/posts/{post_id}")
201
+ async def update_post(
202
+ post_id: str,
203
+ post_data: PostUpdate,
204
+ current_user = Depends(verify_admin)
205
+ ):
206
+ """
207
+ 更新貼文內容
208
+ 只能編輯草稿狀態的貼文
209
+ """
210
+ try:
211
+ client = supabase_clients.get_version_client()
212
+ if not client:
213
+ raise HTTPException(status_code=503, detail="Database unavailable")
214
+
215
+ # 檢查貼文是否存在
216
+ existing = client.table('posts').select('*').eq('id', post_id).eq('is_deleted', False).execute()
217
+ if not existing.data:
218
+ raise HTTPException(status_code=404, detail="Post not found")
219
+
220
+ post = existing.data[0]
221
+
222
+ # 只能編輯草稿狀態的貼文
223
+ if post['status'] != 'draft':
224
+ raise HTTPException(status_code=400, detail="Can only edit draft posts")
225
+
226
+ # 準備更新資料
227
+ update_data = {}
228
+ if post_data.subject is not None:
229
+ update_data['subject'] = post_data.subject
230
+ if post_data.content is not None:
231
+ # 替換下載連結變數
232
+ download_link = get_download_link(post['version'], post['product_type'])
233
+ update_data['content'] = replace_download_link_variable(post_data.content, download_link)
234
+
235
+ if not update_data:
236
+ raise HTTPException(status_code=400, detail="No update data provided")
237
+
238
+ result = client.table('posts').update(update_data).eq('id', post_id).execute()
239
+
240
+ if not result.data:
241
+ raise HTTPException(status_code=500, detail="Failed to update post")
242
+
243
+ logger.info(f"Post {post_id} updated")
244
+
245
+ return {
246
+ "success": True,
247
+ "message": "Post updated successfully",
248
+ "post": result.data[0]
249
+ }
250
+
251
+ except HTTPException:
252
+ raise
253
+ except Exception as e:
254
+ logger.error(f"Update post error: {e}")
255
+ raise HTTPException(status_code=500, detail=str(e))
256
+
257
+ @router.post("/admin/posts/{post_id}/publish")
258
+ async def publish_post(
259
+ post_id: str,
260
+ publish_data: PostPublish,
261
+ current_user = Depends(verify_admin)
262
+ ):
263
+ """
264
+ 發布貼文
265
+ 可選擇是否同時發送郵件通知
266
+ """
267
+ try:
268
+ client = supabase_clients.get_version_client()
269
+ if not client:
270
+ raise HTTPException(status_code=503, detail="Database unavailable")
271
+
272
+ # 檢查貼文是否存在
273
+ existing = client.table('posts').select('*').eq('id', post_id).eq('is_deleted', False).execute()
274
+ if not existing.data:
275
+ raise HTTPException(status_code=404, detail="Post not found")
276
+
277
+ post = existing.data[0]
278
+
279
+ # 草稿或已發布狀態都可以發布/重新發布
280
+ if post['status'] == 'sent':
281
+ raise HTTPException(status_code=400, detail="Post already sent, cannot modify")
282
+
283
+ # 更新狀態為已發布
284
+ update_data = {'status': 'published'}
285
+
286
+ email_result = None
287
+ if publish_data.send_email:
288
+ # 發送郵件
289
+ from ..services.email_service import EmailService
290
+ email_service = EmailService()
291
+
292
+ try:
293
+ recipient_count = await email_service.send_version_announcement(
294
+ subject=post['subject'],
295
+ content=post['content'],
296
+ product_type=post['product_type']
297
+ )
298
+
299
+ update_data['status'] = 'sent'
300
+ update_data['sent_at'] = datetime.utcnow().isoformat()
301
+ update_data['sent_by'] = current_user.get('email', 'Unknown')
302
+ update_data['recipient_count'] = recipient_count
303
+
304
+ email_result = {
305
+ "sent": True,
306
+ "recipient_count": recipient_count
307
+ }
308
+
309
+ logger.info(f"Email sent for post {post_id} to {recipient_count} recipients")
310
+
311
+ except Exception as email_error:
312
+ logger.error(f"Email send error: {email_error}")
313
+ email_result = {
314
+ "sent": False,
315
+ "error": str(email_error)
316
+ }
317
+
318
+ result = client.table('posts').update(update_data).eq('id', post_id).execute()
319
+
320
+ if not result.data:
321
+ raise HTTPException(status_code=500, detail="Failed to publish post")
322
+
323
+ response = {
324
+ "success": True,
325
+ "message": "Post published successfully",
326
+ "post": result.data[0]
327
+ }
328
+
329
+ if email_result:
330
+ response["email"] = email_result
331
+
332
+ return response
333
+
334
+ except HTTPException:
335
+ raise
336
+ except Exception as e:
337
+ logger.error(f"Publish post error: {e}")
338
+ raise HTTPException(status_code=500, detail=str(e))
339
+
340
+ @router.post("/admin/posts/{post_id}/send-email")
341
+ async def send_post_email(
342
+ post_id: str,
343
+ current_user = Depends(verify_admin)
344
+ ):
345
+ """
346
+ 單獨發送郵件(針對已發布的貼文)
347
+ """
348
+ try:
349
+ client = supabase_clients.get_version_client()
350
+ if not client:
351
+ raise HTTPException(status_code=503, detail="Database unavailable")
352
+
353
+ # 檢查貼文是否存在
354
+ existing = client.table('posts').select('*').eq('id', post_id).eq('is_deleted', False).execute()
355
+ if not existing.data:
356
+ raise HTTPException(status_code=404, detail="Post not found")
357
+
358
+ post = existing.data[0]
359
+
360
+ # 只有已發布的貼文可以發送郵件
361
+ if post['status'] == 'draft':
362
+ raise HTTPException(status_code=400, detail="Please publish the post first")
363
+
364
+ if post['status'] == 'sent':
365
+ raise HTTPException(status_code=400, detail="Email already sent for this post")
366
+
367
+ # 發送郵件
368
+ from ..services.email_service import EmailService
369
+ email_service = EmailService()
370
+
371
+ recipient_count = await email_service.send_version_announcement(
372
+ subject=post['subject'],
373
+ content=post['content'],
374
+ product_type=post['product_type']
375
+ )
376
+
377
+ # 更新貼文狀態
378
+ update_data = {
379
+ 'status': 'sent',
380
+ 'sent_at': datetime.utcnow().isoformat(),
381
+ 'sent_by': current_user.get('email', 'Unknown'),
382
+ 'recipient_count': recipient_count
383
+ }
384
+
385
+ result = client.table('posts').update(update_data).eq('id', post_id).execute()
386
+
387
+ logger.info(f"Email sent for post {post_id} to {recipient_count} recipients")
388
+
389
+ return {
390
+ "success": True,
391
+ "message": f"Email sent to {recipient_count} recipients",
392
+ "recipient_count": recipient_count,
393
+ "post": result.data[0] if result.data else None
394
+ }
395
+
396
+ except HTTPException:
397
+ raise
398
+ except Exception as e:
399
+ logger.error(f"Send email error: {e}")
400
+ raise HTTPException(status_code=500, detail=str(e))
401
+
402
+ @router.delete("/admin/posts/{post_id}")
403
+ async def delete_post(
404
+ post_id: str,
405
+ current_user = Depends(verify_admin)
406
+ ):
407
+ """
408
+ 刪除貼文(軟刪除)
409
+ """
410
+ try:
411
+ client = supabase_clients.get_version_client()
412
+ if not client:
413
+ raise HTTPException(status_code=503, detail="Database unavailable")
414
+
415
+ # 檢查貼文是否存在
416
+ existing = client.table('posts').select('*').eq('id', post_id).eq('is_deleted', False).execute()
417
+ if not existing.data:
418
+ raise HTTPException(status_code=404, detail="Post not found")
419
+
420
+ # 軟刪除
421
+ result = client.table('posts').update({'is_deleted': True}).eq('id', post_id).execute()
422
+
423
+ if not result.data:
424
+ raise HTTPException(status_code=500, detail="Failed to delete post")
425
+
426
+ logger.info(f"Post {post_id} deleted")
427
+
428
+ return {
429
+ "success": True,
430
+ "message": "Post deleted successfully"
431
+ }
432
+
433
+ except HTTPException:
434
+ raise
435
+ except Exception as e:
436
+ logger.error(f"Delete post error: {e}")
437
+ raise HTTPException(status_code=500, detail=str(e))
438
+
439
+ @router.get("/admin/posts/stats")
440
+ async def get_post_stats(current_user = Depends(verify_admin)):
441
+ """
442
+ 取得貼文統計資料
443
+ """
444
+ try:
445
+ client = supabase_clients.get_version_client()
446
+ if not client:
447
+ raise HTTPException(status_code=503, detail="Database unavailable")
448
+
449
+ # 取得所有未刪除的貼文
450
+ result = client.table('posts').select('*').eq('is_deleted', False).execute()
451
+ posts = result.data
452
+
453
+ # 統計各狀態數量
454
+ draft_count = len([p for p in posts if p['status'] == 'draft'])
455
+ published_count = len([p for p in posts if p['status'] == 'published'])
456
+ sent_count = len([p for p in posts if p['status'] == 'sent'])
457
+
458
+ # 統計各產品類型
459
+ revit_count = len([p for p in posts if p['product_type'] == 'revit'])
460
+ autocad_count = len([p for p in posts if p['product_type'] == 'autocad'])
461
+
462
+ # 總發送郵件數量
463
+ total_emails_sent = sum([p.get('recipient_count', 0) or 0 for p in posts if p['status'] == 'sent'])
464
+
465
+ return {
466
+ "total_posts": len(posts),
467
+ "by_status": {
468
+ "draft": draft_count,
469
+ "published": published_count,
470
+ "sent": sent_count
471
+ },
472
+ "by_product": {
473
+ "revit": revit_count,
474
+ "autocad": autocad_count
475
+ },
476
+ "total_emails_sent": total_emails_sent
477
+ }
478
+
479
+ except Exception as e:
480
+ logger.error(f"Get post stats error: {e}")
481
+ raise HTTPException(status_code=500, detail=str(e))
app/api/version_routes.py CHANGED
@@ -40,6 +40,11 @@ class ReleaseVersionRequest(BaseModel):
40
  min_revit_version: Optional[str] = None
41
  download_url: Optional[str] = None # 可選,如果不上傳檔案則提供外部連結
42
 
 
 
 
 
 
43
  # ==================== 公開端點 (不需認證) ====================
44
 
45
  @router.post("/check-version")
@@ -189,6 +194,8 @@ async def release_version(
189
  min_revit_version: Optional[str] = Form(None),
190
  download_url: Optional[str] = Form(None),
191
  file: Optional[UploadFile] = File(None),
 
 
192
  current_user = Depends(verify_admin)
193
  ):
194
  """
@@ -293,11 +300,40 @@ async def release_version(
293
  # GitBook 同步已移除 - 請手動更新 GitBook 文檔
294
  # 更新格式請參考 CLAUDE.md 中的 GitBook 更新格式規範
295
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  return {
297
  "success": True,
298
  "message": f"{product_type.capitalize()} version {version} released successfully",
299
  "product_type": product_type,
300
- "version": result.data[0] if result.data else version_data
 
301
  }
302
 
303
  except Exception as e:
 
40
  min_revit_version: Optional[str] = None
41
  download_url: Optional[str] = None # 可選,如果不上傳檔案則提供外部連結
42
 
43
+ class PostContent(BaseModel):
44
+ """貼文內容"""
45
+ subject: str
46
+ content: str
47
+
48
  # ==================== 公開端點 (不需認證) ====================
49
 
50
  @router.post("/check-version")
 
194
  min_revit_version: Optional[str] = Form(None),
195
  download_url: Optional[str] = Form(None),
196
  file: Optional[UploadFile] = File(None),
197
+ post_subject: Optional[str] = Form(None), # 貼文主旨
198
+ post_content: Optional[str] = Form(None), # 貼文內容
199
  current_user = Depends(verify_admin)
200
  ):
201
  """
 
300
  # GitBook 同步已移除 - 請手動更新 GitBook 文檔
301
  # 更新格式請參考 CLAUDE.md 中的 GitBook 更新格式規範
302
 
303
+ # 自動建立貼文草稿(如果提供了貼文內容)
304
+ post_created = False
305
+ if post_subject and post_content:
306
+ try:
307
+ # 替換下載連結變數
308
+ post_content_with_link = post_content.replace('{download_link}', final_download_url or '')
309
+
310
+ post_data = {
311
+ 'version': version,
312
+ 'product_type': product_type,
313
+ 'subject': post_subject,
314
+ 'content': post_content_with_link,
315
+ 'status': 'draft',
316
+ 'is_deleted': False
317
+ }
318
+
319
+ if result.data and result.data[0].get('id'):
320
+ post_data['version_id'] = result.data[0]['id']
321
+
322
+ post_result = client.table('posts').insert(post_data).execute()
323
+ if post_result.data:
324
+ post_created = True
325
+ logger.info(f"Auto-created post for {product_type} v{version}")
326
+
327
+ except Exception as post_error:
328
+ logger.warning(f"Failed to auto-create post: {post_error}")
329
+ # 不因為貼文建立失敗而中斷版本發布
330
+
331
  return {
332
  "success": True,
333
  "message": f"{product_type.capitalize()} version {version} released successfully",
334
  "product_type": product_type,
335
+ "version": result.data[0] if result.data else version_data,
336
+ "post_created": post_created
337
  }
338
 
339
  except Exception as e:
app/main.py CHANGED
@@ -10,6 +10,7 @@ import uvicorn
10
  import os
11
 
12
  from app.api import version_routes
 
13
 
14
  app = FastAPI(
15
  title="KSTools License & Version Manager API",
@@ -32,6 +33,7 @@ app.add_middleware(
32
  app.include_router(license.router, prefix="/api", tags=["license"])
33
  app.include_router(hardware.router, prefix="/api", tags=["hardware"])
34
  app.include_router(version_routes.router, prefix="/api", tags=["versions"])
 
35
  app.include_router(config_endpoint.router, prefix="/api", tags=["config"])
36
 
37
  @app.get("/")
 
10
  import os
11
 
12
  from app.api import version_routes
13
+ from app.api import post_routes
14
 
15
  app = FastAPI(
16
  title="KSTools License & Version Manager API",
 
33
  app.include_router(license.router, prefix="/api", tags=["license"])
34
  app.include_router(hardware.router, prefix="/api", tags=["hardware"])
35
  app.include_router(version_routes.router, prefix="/api", tags=["versions"])
36
+ app.include_router(post_routes.router, prefix="/api", tags=["posts"])
37
  app.include_router(config_endpoint.router, prefix="/api", tags=["config"])
38
 
39
  @app.get("/")
app/services/email_service.py ADDED
@@ -0,0 +1,337 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ KSTools 郵件服務
3
+ 使用 Gmail SMTP 發送版本更新通知郵件
4
+ """
5
+
6
+ import os
7
+ import smtplib
8
+ import asyncio
9
+ import logging
10
+ import re
11
+ from email.mime.text import MIMEText
12
+ from email.mime.multipart import MIMEMultipart
13
+ from typing import List, Optional
14
+ from datetime import datetime
15
+
16
+ from ..models.supabase_clients import supabase_clients
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class EmailService:
22
+ """
23
+ 郵件服務類別
24
+ 使用 Gmail SMTP 發送郵件
25
+ """
26
+
27
+ def __init__(self):
28
+ """初始化郵件服務"""
29
+ self.smtp_host = os.environ.get('SMTP_HOST', 'smtp.gmail.com')
30
+ self.smtp_port = int(os.environ.get('SMTP_PORT', 587))
31
+ self.smtp_user = os.environ.get('SMTP_USER', '')
32
+ self.smtp_pass = os.environ.get('SMTP_PASS', '')
33
+ self.from_email = os.environ.get('FROM_EMAIL', self.smtp_user)
34
+ self.from_name = os.environ.get('FROM_NAME', 'KSTools')
35
+
36
+ # 批次發送設定
37
+ self.batch_size = 50 # 每批最多發送 50 封
38
+ self.batch_delay = 30 # 批次間隔 30 秒
39
+
40
+ if not self.smtp_user or not self.smtp_pass:
41
+ logger.warning("SMTP credentials not configured")
42
+
43
+ def is_configured(self) -> bool:
44
+ """檢查郵件服務是否已設定"""
45
+ return bool(self.smtp_user and self.smtp_pass)
46
+
47
+ async def get_active_user_emails(self, product_type: Optional[str] = None) -> List[str]:
48
+ """
49
+ 取得所有啟用用戶的郵箱
50
+ 從 licenses 表查詢
51
+
52
+ Args:
53
+ product_type: 產品類型(目前授權是共用的,此參數保留供未來使用)
54
+
55
+ Returns:
56
+ 有效郵箱列表
57
+ """
58
+ try:
59
+ client = supabase_clients.get_license_client()
60
+ if not client:
61
+ logger.error("License client unavailable")
62
+ return []
63
+
64
+ # 查詢所有啟用的授權
65
+ result = client.table('licenses').select('email').eq('status', 'active').execute()
66
+
67
+ if not result.data:
68
+ return []
69
+
70
+ # 提取並驗證郵箱
71
+ emails = []
72
+ email_pattern = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
73
+
74
+ for record in result.data:
75
+ email = record.get('email', '').strip().lower()
76
+ if email and email_pattern.match(email):
77
+ if email not in emails: # 去重
78
+ emails.append(email)
79
+
80
+ logger.info(f"Found {len(emails)} active user emails")
81
+ return emails
82
+
83
+ except Exception as e:
84
+ logger.error(f"Get user emails error: {e}")
85
+ return []
86
+
87
+ def _format_email_html(self, content: str, product_type: str) -> str:
88
+ """
89
+ 格式化郵件 HTML 內容
90
+
91
+ Args:
92
+ content: 貼文內容
93
+ product_type: 產品類型
94
+
95
+ Returns:
96
+ 格式化的 HTML 內容
97
+ """
98
+ # 將換行轉換為 <br>
99
+ html_content = content.replace('\n', '<br>')
100
+
101
+ # 產品名稱
102
+ product_name = 'KSTools Revit' if product_type == 'revit' else 'KSTools AutoCAD'
103
+
104
+ # HTML 模板
105
+ html_template = f"""
106
+ <!DOCTYPE html>
107
+ <html>
108
+ <head>
109
+ <meta charset="UTF-8">
110
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
111
+ <style>
112
+ body {{
113
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
114
+ line-height: 1.6;
115
+ color: #333;
116
+ max-width: 600px;
117
+ margin: 0 auto;
118
+ padding: 20px;
119
+ background-color: #f5f5f5;
120
+ }}
121
+ .container {{
122
+ background-color: #ffffff;
123
+ border-radius: 8px;
124
+ padding: 30px;
125
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
126
+ }}
127
+ .header {{
128
+ text-align: center;
129
+ margin-bottom: 30px;
130
+ padding-bottom: 20px;
131
+ border-bottom: 2px solid #e0e0e0;
132
+ }}
133
+ .header h1 {{
134
+ color: #2563eb;
135
+ margin: 0;
136
+ font-size: 24px;
137
+ }}
138
+ .content {{
139
+ padding: 20px 0;
140
+ }}
141
+ .footer {{
142
+ margin-top: 30px;
143
+ padding-top: 20px;
144
+ border-top: 1px solid #e0e0e0;
145
+ text-align: center;
146
+ color: #666;
147
+ font-size: 12px;
148
+ }}
149
+ a {{
150
+ color: #2563eb;
151
+ text-decoration: none;
152
+ }}
153
+ a:hover {{
154
+ text-decoration: underline;
155
+ }}
156
+ </style>
157
+ </head>
158
+ <body>
159
+ <div class="container">
160
+ <div class="header">
161
+ <h1>{product_name} 版本更新</h1>
162
+ </div>
163
+ <div class="content">
164
+ {html_content}
165
+ </div>
166
+ <div class="footer">
167
+ <p>此郵件由 KSTools 系統自動發送</p>
168
+ <p>如有任何問題,請聯繫技術支援</p>
169
+ </div>
170
+ </div>
171
+ </body>
172
+ </html>
173
+ """
174
+ return html_template
175
+
176
+ async def _send_single_email(self, server: smtplib.SMTP, recipient: str, subject: str, html_content: str) -> bool:
177
+ """
178
+ 發送單封郵件
179
+
180
+ Args:
181
+ server: SMTP 伺服器連線
182
+ recipient: 收件人
183
+ subject: 郵件主旨
184
+ html_content: HTML 內容
185
+
186
+ Returns:
187
+ 是否發送成功
188
+ """
189
+ try:
190
+ msg = MIMEMultipart('alternative')
191
+ msg['Subject'] = subject
192
+ msg['From'] = f"{self.from_name} <{self.from_email}>"
193
+ msg['To'] = recipient
194
+
195
+ html_part = MIMEText(html_content, 'html', 'utf-8')
196
+ msg.attach(html_part)
197
+
198
+ server.send_message(msg)
199
+ return True
200
+
201
+ except Exception as e:
202
+ logger.error(f"Failed to send email to {recipient}: {e}")
203
+ return False
204
+
205
+ async def send_version_announcement(
206
+ self,
207
+ subject: str,
208
+ content: str,
209
+ product_type: str
210
+ ) -> int:
211
+ """
212
+ 發送版本更新通知郵件
213
+
214
+ Args:
215
+ subject: 郵件主旨
216
+ content: 郵件內容
217
+ product_type: 產品類型
218
+
219
+ Returns:
220
+ 成功發送的郵件數量
221
+ """
222
+ if not self.is_configured():
223
+ raise ValueError("SMTP not configured. Please set SMTP_USER and SMTP_PASS environment variables.")
224
+
225
+ # 取得收件人列表
226
+ recipients = await self.get_active_user_emails(product_type)
227
+
228
+ if not recipients:
229
+ logger.warning("No recipients found for email notification")
230
+ return 0
231
+
232
+ logger.info(f"Sending version announcement to {len(recipients)} recipients")
233
+
234
+ # 格式化 HTML 內容
235
+ html_content = self._format_email_html(content, product_type)
236
+
237
+ sent_count = 0
238
+ total_batches = (len(recipients) + self.batch_size - 1) // self.batch_size
239
+
240
+ # 分批發送
241
+ for batch_num, i in enumerate(range(0, len(recipients), self.batch_size), 1):
242
+ batch = recipients[i:i + self.batch_size]
243
+
244
+ try:
245
+ # 建立 SMTP 連線
246
+ with smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=30) as server:
247
+ server.starttls()
248
+ server.login(self.smtp_user, self.smtp_pass)
249
+
250
+ # 對每個收件人發送個別郵件
251
+ for recipient in batch:
252
+ success = await self._send_single_email(server, recipient, subject, html_content)
253
+ if success:
254
+ sent_count += 1
255
+
256
+ logger.info(f"Batch {batch_num}/{total_batches}: Sent {len(batch)} emails")
257
+
258
+ # 批次間暫停,避免觸發 Gmail 限制
259
+ if batch_num < total_batches:
260
+ await asyncio.sleep(self.batch_delay)
261
+
262
+ except smtplib.SMTPAuthenticationError as e:
263
+ logger.error(f"SMTP authentication failed: {e}")
264
+ raise ValueError("SMTP authentication failed. Please check your credentials.")
265
+
266
+ except Exception as e:
267
+ logger.error(f"SMTP error in batch {batch_num}: {e}")
268
+ # 繼續處理下一批
269
+ continue
270
+
271
+ logger.info(f"Email sending completed: {sent_count}/{len(recipients)} sent successfully")
272
+ return sent_count
273
+
274
+ async def send_test_email(self, recipient: str) -> bool:
275
+ """
276
+ 發送測試郵件
277
+
278
+ Args:
279
+ recipient: 測試收件人
280
+
281
+ Returns:
282
+ 是否發送成功
283
+ """
284
+ if not self.is_configured():
285
+ raise ValueError("SMTP not configured")
286
+
287
+ try:
288
+ subject = "KSTools 郵件服務測試"
289
+ content = """
290
+ 這是一封測試郵件。
291
+
292
+ 如果您收到此郵件,表示 KSTools 郵件服務已正確設定。
293
+
294
+ 此郵件由系統自動發送,請勿回覆。
295
+ """
296
+ html_content = self._format_email_html(content, 'revit')
297
+
298
+ with smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=30) as server:
299
+ server.starttls()
300
+ server.login(self.smtp_user, self.smtp_pass)
301
+
302
+ success = await self._send_single_email(server, recipient, subject, html_content)
303
+
304
+ if success:
305
+ logger.info(f"Test email sent to {recipient}")
306
+ return success
307
+
308
+ except Exception as e:
309
+ logger.error(f"Test email failed: {e}")
310
+ raise
311
+
312
+ def health_check(self) -> dict:
313
+ """
314
+ 郵件服務健康檢查
315
+ """
316
+ return {
317
+ 'service': 'EmailService',
318
+ 'configured': self.is_configured(),
319
+ 'smtp_host': self.smtp_host,
320
+ 'smtp_port': self.smtp_port,
321
+ 'from_email': self.from_email,
322
+ 'from_name': self.from_name,
323
+ 'batch_size': self.batch_size,
324
+ 'timestamp': datetime.now().isoformat()
325
+ }
326
+
327
+
328
+ # 建立全域實例
329
+ _email_service_instance = None
330
+
331
+
332
+ def get_email_service() -> EmailService:
333
+ """取得 EmailService 單例實例"""
334
+ global _email_service_instance
335
+ if _email_service_instance is None:
336
+ _email_service_instance = EmailService()
337
+ return _email_service_instance
frontend/css/posts.css ADDED
@@ -0,0 +1,479 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Posts Management Page Styles
3
+ * 貼文發布管理頁面樣式
4
+ */
5
+
6
+ /* ==================== 篩選區 ==================== */
7
+
8
+ .filters-container {
9
+ display: flex;
10
+ flex-wrap: wrap;
11
+ gap: 24px;
12
+ }
13
+
14
+ .filter-group {
15
+ display: flex;
16
+ flex-direction: column;
17
+ gap: 8px;
18
+ }
19
+
20
+ .filter-label {
21
+ font-size: 12px;
22
+ font-weight: 500;
23
+ color: var(--text-secondary);
24
+ text-transform: uppercase;
25
+ letter-spacing: 0.5px;
26
+ }
27
+
28
+ .filter-buttons {
29
+ display: flex;
30
+ gap: 8px;
31
+ flex-wrap: wrap;
32
+ }
33
+
34
+ .filter-btn {
35
+ padding: 6px 12px;
36
+ border: 1px solid var(--border-color);
37
+ border-radius: 6px;
38
+ background: var(--bg-secondary);
39
+ color: var(--text-secondary);
40
+ font-size: 13px;
41
+ cursor: pointer;
42
+ transition: all 0.2s ease;
43
+ }
44
+
45
+ .filter-btn:hover {
46
+ border-color: var(--accent-blue);
47
+ color: var(--text-primary);
48
+ }
49
+
50
+ .filter-btn.active {
51
+ background: var(--accent-blue);
52
+ border-color: var(--accent-blue);
53
+ color: #fff;
54
+ }
55
+
56
+ /* ==================== 產品標籤 ==================== */
57
+
58
+ .product-badge {
59
+ display: inline-flex;
60
+ align-items: center;
61
+ padding: 2px 8px;
62
+ border-radius: 4px;
63
+ font-size: 11px;
64
+ font-weight: 600;
65
+ text-transform: uppercase;
66
+ letter-spacing: 0.5px;
67
+ }
68
+
69
+ .product-badge.revit {
70
+ background: rgba(59, 130, 246, 0.15);
71
+ color: #3b82f6;
72
+ }
73
+
74
+ .product-badge.autocad {
75
+ background: rgba(239, 68, 68, 0.15);
76
+ color: #ef4444;
77
+ }
78
+
79
+ /* ==================== 狀態標籤 ==================== */
80
+
81
+ .status-badge {
82
+ display: inline-flex;
83
+ align-items: center;
84
+ gap: 4px;
85
+ padding: 2px 8px;
86
+ border-radius: 4px;
87
+ font-size: 11px;
88
+ font-weight: 500;
89
+ }
90
+
91
+ .status-badge.draft {
92
+ background: rgba(156, 163, 175, 0.15);
93
+ color: #9ca3af;
94
+ }
95
+
96
+ .status-badge.published {
97
+ background: rgba(34, 197, 94, 0.15);
98
+ color: #22c55e;
99
+ }
100
+
101
+ .status-badge.sent {
102
+ background: rgba(59, 130, 246, 0.15);
103
+ color: #3b82f6;
104
+ }
105
+
106
+ /* ==================== 統計卡片 ==================== */
107
+
108
+ .stats-row {
109
+ display: grid;
110
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
111
+ gap: 16px;
112
+ }
113
+
114
+ .stat-card {
115
+ display: flex;
116
+ align-items: center;
117
+ gap: 12px;
118
+ padding: 16px;
119
+ background: var(--bg-secondary);
120
+ border: 1px solid var(--border-color);
121
+ border-radius: 8px;
122
+ }
123
+
124
+ .stat-icon {
125
+ display: flex;
126
+ align-items: center;
127
+ justify-content: center;
128
+ width: 40px;
129
+ height: 40px;
130
+ border-radius: 8px;
131
+ background: rgba(88, 166, 255, 0.1);
132
+ color: var(--accent-blue);
133
+ font-size: 16px;
134
+ }
135
+
136
+ .stat-icon.draft {
137
+ background: rgba(156, 163, 175, 0.1);
138
+ color: #9ca3af;
139
+ }
140
+
141
+ .stat-icon.published {
142
+ background: rgba(34, 197, 94, 0.1);
143
+ color: #22c55e;
144
+ }
145
+
146
+ .stat-icon.sent {
147
+ background: rgba(59, 130, 246, 0.1);
148
+ color: #3b82f6;
149
+ }
150
+
151
+ .stat-info {
152
+ display: flex;
153
+ flex-direction: column;
154
+ }
155
+
156
+ .stat-value {
157
+ font-size: 24px;
158
+ font-weight: 600;
159
+ color: var(--text-primary);
160
+ line-height: 1;
161
+ }
162
+
163
+ .stat-label {
164
+ font-size: 12px;
165
+ color: var(--text-secondary);
166
+ margin-top: 4px;
167
+ }
168
+
169
+ /* ==================== 貼文網格 ==================== */
170
+
171
+ .posts-grid {
172
+ display: grid;
173
+ grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
174
+ gap: 20px;
175
+ }
176
+
177
+ .post-card {
178
+ display: flex;
179
+ flex-direction: column;
180
+ background: var(--bg-secondary);
181
+ border: 1px solid var(--border-color);
182
+ border-radius: 12px;
183
+ overflow: hidden;
184
+ transition: all 0.2s ease;
185
+ }
186
+
187
+ .post-card:hover {
188
+ border-color: var(--accent-blue);
189
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
190
+ }
191
+
192
+ .post-card-header {
193
+ display: flex;
194
+ justify-content: space-between;
195
+ align-items: center;
196
+ padding: 12px 16px;
197
+ background: var(--bg-tertiary);
198
+ border-bottom: 1px solid var(--border-color);
199
+ }
200
+
201
+ .post-badges {
202
+ display: flex;
203
+ gap: 8px;
204
+ }
205
+
206
+ .post-version {
207
+ font-size: 12px;
208
+ font-weight: 600;
209
+ color: var(--text-secondary);
210
+ padding: 2px 8px;
211
+ background: var(--bg-secondary);
212
+ border-radius: 4px;
213
+ }
214
+
215
+ .post-card-body {
216
+ flex: 1;
217
+ padding: 16px;
218
+ }
219
+
220
+ .post-subject {
221
+ font-size: 15px;
222
+ font-weight: 600;
223
+ color: var(--text-primary);
224
+ margin: 0 0 8px 0;
225
+ line-height: 1.4;
226
+ }
227
+
228
+ .post-content-preview {
229
+ font-size: 13px;
230
+ color: var(--text-secondary);
231
+ margin: 0;
232
+ line-height: 1.5;
233
+ display: -webkit-box;
234
+ -webkit-line-clamp: 3;
235
+ -webkit-box-orient: vertical;
236
+ overflow: hidden;
237
+ }
238
+
239
+ .post-card-footer {
240
+ display: flex;
241
+ justify-content: space-between;
242
+ align-items: center;
243
+ padding: 12px 16px;
244
+ border-top: 1px solid var(--border-color);
245
+ background: var(--bg-tertiary);
246
+ }
247
+
248
+ .post-date {
249
+ font-size: 12px;
250
+ color: var(--text-secondary);
251
+ }
252
+
253
+ .post-date i {
254
+ margin-right: 4px;
255
+ }
256
+
257
+ .post-actions {
258
+ display: flex;
259
+ gap: 8px;
260
+ align-items: center;
261
+ }
262
+
263
+ .sent-info {
264
+ font-size: 11px;
265
+ color: var(--text-secondary);
266
+ margin-right: 8px;
267
+ }
268
+
269
+ /* ==================== 空狀態 ==================== */
270
+
271
+ .empty-state {
272
+ grid-column: 1 / -1;
273
+ display: flex;
274
+ flex-direction: column;
275
+ align-items: center;
276
+ justify-content: center;
277
+ padding: 60px 20px;
278
+ text-align: center;
279
+ }
280
+
281
+ .empty-state i {
282
+ font-size: 48px;
283
+ color: var(--text-secondary);
284
+ opacity: 0.5;
285
+ margin-bottom: 16px;
286
+ }
287
+
288
+ .empty-state h3 {
289
+ font-size: 18px;
290
+ font-weight: 600;
291
+ color: var(--text-primary);
292
+ margin: 0 0 8px 0;
293
+ }
294
+
295
+ .empty-state p {
296
+ font-size: 14px;
297
+ color: var(--text-secondary);
298
+ margin: 0 0 16px 0;
299
+ }
300
+
301
+ /* ==================== Modal ==================== */
302
+
303
+ .modal-overlay {
304
+ position: fixed;
305
+ top: 0;
306
+ left: 0;
307
+ right: 0;
308
+ bottom: 0;
309
+ background: rgba(0, 0, 0, 0.7);
310
+ display: flex;
311
+ align-items: center;
312
+ justify-content: center;
313
+ z-index: 1000;
314
+ padding: 20px;
315
+ }
316
+
317
+ .modal-dialog {
318
+ width: 100%;
319
+ max-width: 600px;
320
+ background: var(--bg-primary);
321
+ border-radius: 12px;
322
+ border: 1px solid var(--border-color);
323
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
324
+ max-height: 90vh;
325
+ display: flex;
326
+ flex-direction: column;
327
+ }
328
+
329
+ .modal-dialog.modal-lg {
330
+ max-width: 800px;
331
+ }
332
+
333
+ .modal-header {
334
+ display: flex;
335
+ justify-content: space-between;
336
+ align-items: center;
337
+ padding: 16px 20px;
338
+ border-bottom: 1px solid var(--border-color);
339
+ }
340
+
341
+ .modal-title {
342
+ font-size: 18px;
343
+ font-weight: 600;
344
+ color: var(--text-primary);
345
+ margin: 0;
346
+ }
347
+
348
+ .modal-close {
349
+ width: 32px;
350
+ height: 32px;
351
+ display: flex;
352
+ align-items: center;
353
+ justify-content: center;
354
+ background: transparent;
355
+ border: none;
356
+ border-radius: 6px;
357
+ color: var(--text-secondary);
358
+ cursor: pointer;
359
+ transition: all 0.2s ease;
360
+ }
361
+
362
+ .modal-close:hover {
363
+ background: var(--bg-secondary);
364
+ color: var(--text-primary);
365
+ }
366
+
367
+ .modal-body {
368
+ flex: 1;
369
+ padding: 20px;
370
+ overflow-y: auto;
371
+ }
372
+
373
+ .modal-footer {
374
+ display: flex;
375
+ justify-content: flex-end;
376
+ gap: 12px;
377
+ padding: 16px 20px;
378
+ border-top: 1px solid var(--border-color);
379
+ }
380
+
381
+ /* ==================== 表單樣式 ==================== */
382
+
383
+ .form-input,
384
+ .form-textarea {
385
+ width: 100%;
386
+ padding: 10px 14px;
387
+ background: var(--bg-secondary);
388
+ border: 1px solid var(--border-color);
389
+ border-radius: 8px;
390
+ color: var(--text-primary);
391
+ font-size: 14px;
392
+ transition: all 0.2s ease;
393
+ }
394
+
395
+ .form-input:focus,
396
+ .form-textarea:focus {
397
+ outline: none;
398
+ border-color: var(--accent-blue);
399
+ box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.1);
400
+ }
401
+
402
+ .form-textarea {
403
+ resize: vertical;
404
+ min-height: 200px;
405
+ font-family: inherit;
406
+ line-height: 1.5;
407
+ }
408
+
409
+ .form-hint {
410
+ display: block;
411
+ margin-top: 6px;
412
+ font-size: 12px;
413
+ color: var(--text-secondary);
414
+ }
415
+
416
+ /* ==================== 按鈕樣式 ==================== */
417
+
418
+ .btn-sm {
419
+ padding: 6px 10px;
420
+ font-size: 12px;
421
+ }
422
+
423
+ .btn-danger {
424
+ background: rgba(248, 81, 73, 0.1);
425
+ color: var(--accent-red);
426
+ border: 1px solid transparent;
427
+ }
428
+
429
+ .btn-danger:hover {
430
+ background: var(--accent-red);
431
+ color: #fff;
432
+ }
433
+
434
+ /* ==================== 響應式設計 ==================== */
435
+
436
+ @media (max-width: 768px) {
437
+ .filters-container {
438
+ flex-direction: column;
439
+ gap: 16px;
440
+ }
441
+
442
+ .filter-buttons {
443
+ flex-wrap: wrap;
444
+ }
445
+
446
+ .posts-grid {
447
+ grid-template-columns: 1fr;
448
+ }
449
+
450
+ .stats-row {
451
+ grid-template-columns: repeat(2, 1fr);
452
+ }
453
+
454
+ .modal-dialog {
455
+ max-width: 100%;
456
+ margin: 10px;
457
+ }
458
+
459
+ .post-card-footer {
460
+ flex-direction: column;
461
+ gap: 12px;
462
+ align-items: flex-start;
463
+ }
464
+
465
+ .post-actions {
466
+ width: 100%;
467
+ justify-content: flex-end;
468
+ }
469
+ }
470
+
471
+ @media (max-width: 480px) {
472
+ .stats-row {
473
+ grid-template-columns: 1fr;
474
+ }
475
+
476
+ .post-actions .btn {
477
+ flex: 1;
478
+ }
479
+ }
frontend/index.html CHANGED
@@ -6,6 +6,7 @@
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
  <!-- Hugging Face 推薦的現代載入方式 -->
@@ -107,6 +108,12 @@
107
  <span>授權記錄</span>
108
  </a>
109
  </li>
 
 
 
 
 
 
110
  <li class="nav-item" data-page="settings">
111
  <a href="#" class="nav-link">
112
  <i class="fas fa-cog"></i>
@@ -180,6 +187,11 @@
180
  <!-- Logs content will be loaded here -->
181
  </div>
182
 
 
 
 
 
 
183
  <!-- Settings Page -->
184
  <div class="page-content" id="settingsPage">
185
  <!-- Settings content will be loaded here -->
@@ -203,6 +215,7 @@
203
  <script src="js/pages/users.js"></script>
204
  <script src="js/pages/logs.js"></script>
205
  <script src="js/pages/settings.js"></script>
 
206
  <script src="js/app.js"></script>
207
  <script>
208
  // 手機選單事件由 app.js 統一處理
 
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 rel="stylesheet" href="css/posts.css">
10
  <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
11
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
12
  <!-- Hugging Face 推薦的現代載入方式 -->
 
108
  <span>授權記錄</span>
109
  </a>
110
  </li>
111
+ <li class="nav-item" data-page="posts">
112
+ <a href="#" class="nav-link">
113
+ <i class="fas fa-bullhorn"></i>
114
+ <span>貼文發布</span>
115
+ </a>
116
+ </li>
117
  <li class="nav-item" data-page="settings">
118
  <a href="#" class="nav-link">
119
  <i class="fas fa-cog"></i>
 
187
  <!-- Logs content will be loaded here -->
188
  </div>
189
 
190
+ <!-- Posts Page -->
191
+ <div class="page-content" id="postsPage">
192
+ <!-- Posts content will be loaded here -->
193
+ </div>
194
+
195
  <!-- Settings Page -->
196
  <div class="page-content" id="settingsPage">
197
  <!-- Settings content will be loaded here -->
 
215
  <script src="js/pages/users.js"></script>
216
  <script src="js/pages/logs.js"></script>
217
  <script src="js/pages/settings.js"></script>
218
+ <script src="js/pages/posts.js"></script>
219
  <script src="js/app.js"></script>
220
  <script>
221
  // 手機選單事件由 app.js 統一處理
frontend/js/api.js CHANGED
@@ -349,6 +349,67 @@ class ApiClient {
349
  async getSystemInfo() {
350
  return this.request('/api/system/info');
351
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
  }
353
 
354
  // Create global instance
 
349
  async getSystemInfo() {
350
  return this.request('/api/system/info');
351
  }
352
+
353
+ // ==================== Posts API ====================
354
+
355
+ // 取得貼文列表
356
+ async getPosts(productType = null, status = null) {
357
+ let url = '/api/admin/posts';
358
+ const params = [];
359
+
360
+ if (productType && productType !== 'all') {
361
+ params.push(`product_type=${productType}`);
362
+ }
363
+ if (status && status !== 'all') {
364
+ params.push(`status=${status}`);
365
+ }
366
+
367
+ if (params.length > 0) {
368
+ url += '?' + params.join('&');
369
+ }
370
+
371
+ return this.request(url);
372
+ }
373
+
374
+ // 取得單一貼文
375
+ async getPost(postId) {
376
+ return this.request(`/api/admin/posts/${postId}`);
377
+ }
378
+
379
+ // 更新貼文
380
+ async updatePost(postId, data) {
381
+ return this.request(`/api/admin/posts/${postId}`, {
382
+ method: 'PUT',
383
+ body: data
384
+ });
385
+ }
386
+
387
+ // 發布貼文
388
+ async publishPost(postId, sendEmail = false) {
389
+ return this.request(`/api/admin/posts/${postId}/publish`, {
390
+ method: 'POST',
391
+ body: { send_email: sendEmail }
392
+ });
393
+ }
394
+
395
+ // 發送郵件
396
+ async sendPostEmail(postId) {
397
+ return this.request(`/api/admin/posts/${postId}/send-email`, {
398
+ method: 'POST'
399
+ });
400
+ }
401
+
402
+ // 刪除貼文
403
+ async deletePost(postId) {
404
+ return this.request(`/api/admin/posts/${postId}`, {
405
+ method: 'DELETE'
406
+ });
407
+ }
408
+
409
+ // 取得貼文統計
410
+ async getPostStats() {
411
+ return this.request('/api/admin/posts/stats');
412
+ }
413
  }
414
 
415
  // Create global instance
frontend/js/app.js CHANGED
@@ -12,13 +12,15 @@ class App {
12
  dashboard: window.dashboardPage,
13
  users: window.usersPage,
14
  logs: window.logsPage,
 
15
  settings: window.settingsPage
16
  };
17
-
18
  this.pageConfig = {
19
  dashboard: { title: '系統儀表板' },
20
  users: { title: '用戶管理' },
21
  logs: { title: '授權記錄' },
 
22
  settings: { title: '系統設定' }
23
  };
24
  }
 
12
  dashboard: window.dashboardPage,
13
  users: window.usersPage,
14
  logs: window.logsPage,
15
+ posts: window.postsPage,
16
  settings: window.settingsPage
17
  };
18
+
19
  this.pageConfig = {
20
  dashboard: { title: '系統儀表板' },
21
  users: { title: '用戶管理' },
22
  logs: { title: '授權記錄' },
23
+ posts: { title: '貼文發布' },
24
  settings: { title: '系統設定' }
25
  };
26
  }
frontend/js/pages/posts.js ADDED
@@ -0,0 +1,499 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Posts Management Page for KSTools License Manager
3
+ * 貼文發布管理頁面
4
+ */
5
+
6
+ class PostsPage {
7
+ constructor() {
8
+ this.posts = [];
9
+ this.filters = {
10
+ productType: 'all',
11
+ status: 'all'
12
+ };
13
+ this.currentEditPost = null;
14
+ }
15
+
16
+ async render(container) {
17
+ container.innerHTML = `
18
+ <div class="posts-page">
19
+ <div class="page-header mb-4">
20
+ <div class="d-flex justify-between align-center">
21
+ <div>
22
+ <h2>貼文發布</h2>
23
+ <p class="text-secondary">管理版本更新貼文與郵件通知</p>
24
+ </div>
25
+ <button class="btn btn-primary" onclick="postsPage.refreshPosts()">
26
+ <i class="fas fa-sync-alt"></i>
27
+ 重新整理
28
+ </button>
29
+ </div>
30
+ </div>
31
+
32
+ <!-- 篩選區 -->
33
+ <div class="card mb-4">
34
+ <div class="card-body">
35
+ <div class="filters-container">
36
+ <div class="filter-group">
37
+ <label class="filter-label">產品類型</label>
38
+ <div class="filter-buttons" id="productTypeFilter">
39
+ <button class="filter-btn active" data-value="all">全部</button>
40
+ <button class="filter-btn" data-value="revit">
41
+ <span class="product-badge revit">Revit</span>
42
+ </button>
43
+ <button class="filter-btn" data-value="autocad">
44
+ <span class="product-badge autocad">AutoCAD</span>
45
+ </button>
46
+ </div>
47
+ </div>
48
+ <div class="filter-group">
49
+ <label class="filter-label">狀態</label>
50
+ <div class="filter-buttons" id="statusFilter">
51
+ <button class="filter-btn active" data-value="all">全部</button>
52
+ <button class="filter-btn" data-value="draft">
53
+ <span class="status-badge draft">草稿</span>
54
+ </button>
55
+ <button class="filter-btn" data-value="published">
56
+ <span class="status-badge published">已發布</span>
57
+ </button>
58
+ <button class="filter-btn" data-value="sent">
59
+ <span class="status-badge sent">已發送</span>
60
+ </button>
61
+ </div>
62
+ </div>
63
+ </div>
64
+ </div>
65
+ </div>
66
+
67
+ <!-- 貼文統計 -->
68
+ <div class="stats-row mb-4" id="postsStats">
69
+ <div class="stat-card">
70
+ <div class="stat-icon"><i class="fas fa-file-alt"></i></div>
71
+ <div class="stat-info">
72
+ <span class="stat-value" id="statTotal">-</span>
73
+ <span class="stat-label">總貼文數</span>
74
+ </div>
75
+ </div>
76
+ <div class="stat-card">
77
+ <div class="stat-icon draft"><i class="fas fa-edit"></i></div>
78
+ <div class="stat-info">
79
+ <span class="stat-value" id="statDraft">-</span>
80
+ <span class="stat-label">草稿</span>
81
+ </div>
82
+ </div>
83
+ <div class="stat-card">
84
+ <div class="stat-icon published"><i class="fas fa-check"></i></div>
85
+ <div class="stat-info">
86
+ <span class="stat-value" id="statPublished">-</span>
87
+ <span class="stat-label">已發布</span>
88
+ </div>
89
+ </div>
90
+ <div class="stat-card">
91
+ <div class="stat-icon sent"><i class="fas fa-envelope"></i></div>
92
+ <div class="stat-info">
93
+ <span class="stat-value" id="statSent">-</span>
94
+ <span class="stat-label">已發送郵件</span>
95
+ </div>
96
+ </div>
97
+ </div>
98
+
99
+ <!-- 貼文列表 -->
100
+ <div class="posts-grid" id="postsGrid">
101
+ <div class="text-center mt-5">
102
+ <div class="spinner mb-3"></div>
103
+ <p>載入貼文中...</p>
104
+ </div>
105
+ </div>
106
+ </div>
107
+
108
+ <!-- 編輯 Modal -->
109
+ <div class="modal-overlay" id="editPostModal" style="display: none;">
110
+ <div class="modal-dialog modal-lg">
111
+ <div class="modal-header">
112
+ <h3 class="modal-title">編輯貼文</h3>
113
+ <button class="modal-close" onclick="postsPage.closeEditModal()">
114
+ <i class="fas fa-times"></i>
115
+ </button>
116
+ </div>
117
+ <div class="modal-body">
118
+ <form id="editPostForm">
119
+ <div class="form-group">
120
+ <label class="form-label">主旨</label>
121
+ <input type="text" id="editSubject" class="form-input" placeholder="輸入郵件主旨..." required>
122
+ </div>
123
+ <div class="form-group">
124
+ <label class="form-label">內容</label>
125
+ <textarea id="editContent" class="form-textarea" rows="12" placeholder="輸入貼文內容..." required></textarea>
126
+ <small class="form-hint">可使用 {download_link} 變數,系統會自動替換為下載連結</small>
127
+ </div>
128
+ </form>
129
+ </div>
130
+ <div class="modal-footer">
131
+ <button class="btn btn-secondary" onclick="postsPage.closeEditModal()">取消</button>
132
+ <button class="btn btn-primary" onclick="postsPage.savePost()">
133
+ <i class="fas fa-save"></i> 儲存
134
+ </button>
135
+ </div>
136
+ </div>
137
+ </div>
138
+ `;
139
+
140
+ this.setupEventHandlers();
141
+ await this.loadPosts();
142
+ }
143
+
144
+ setupEventHandlers() {
145
+ // 產品類型篩選
146
+ const productTypeFilter = document.getElementById('productTypeFilter');
147
+ if (productTypeFilter) {
148
+ productTypeFilter.addEventListener('click', (e) => {
149
+ const btn = e.target.closest('.filter-btn');
150
+ if (btn) {
151
+ productTypeFilter.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
152
+ btn.classList.add('active');
153
+ this.filters.productType = btn.dataset.value;
154
+ this.displayPosts();
155
+ }
156
+ });
157
+ }
158
+
159
+ // 狀態篩選
160
+ const statusFilter = document.getElementById('statusFilter');
161
+ if (statusFilter) {
162
+ statusFilter.addEventListener('click', (e) => {
163
+ const btn = e.target.closest('.filter-btn');
164
+ if (btn) {
165
+ statusFilter.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
166
+ btn.classList.add('active');
167
+ this.filters.status = btn.dataset.value;
168
+ this.displayPosts();
169
+ }
170
+ });
171
+ }
172
+
173
+ // Modal 背景點擊關閉
174
+ const modal = document.getElementById('editPostModal');
175
+ if (modal) {
176
+ modal.addEventListener('click', (e) => {
177
+ if (e.target === modal) {
178
+ this.closeEditModal();
179
+ }
180
+ });
181
+ }
182
+ }
183
+
184
+ async loadPosts() {
185
+ try {
186
+ const result = await api.getPosts();
187
+ this.posts = result.posts || [];
188
+ this.updateStats();
189
+ this.displayPosts();
190
+ } catch (error) {
191
+ console.error('Load posts error:', error);
192
+ const grid = document.getElementById('postsGrid');
193
+ if (grid) {
194
+ grid.innerHTML = `
195
+ <div class="empty-state">
196
+ <i class="fas fa-exclamation-circle"></i>
197
+ <h3>載入失敗</h3>
198
+ <p>${error.message}</p>
199
+ <button class="btn btn-primary" onclick="postsPage.refreshPosts()">重試</button>
200
+ </div>
201
+ `;
202
+ }
203
+ }
204
+ }
205
+
206
+ updateStats() {
207
+ const total = this.posts.length;
208
+ const draft = this.posts.filter(p => p.status === 'draft').length;
209
+ const published = this.posts.filter(p => p.status === 'published').length;
210
+ const sent = this.posts.filter(p => p.status === 'sent').length;
211
+
212
+ document.getElementById('statTotal').textContent = total;
213
+ document.getElementById('statDraft').textContent = draft;
214
+ document.getElementById('statPublished').textContent = published;
215
+ document.getElementById('statSent').textContent = sent;
216
+ }
217
+
218
+ displayPosts() {
219
+ const grid = document.getElementById('postsGrid');
220
+ if (!grid) return;
221
+
222
+ // 篩選貼文
223
+ let filteredPosts = this.posts;
224
+
225
+ if (this.filters.productType !== 'all') {
226
+ filteredPosts = filteredPosts.filter(p => p.product_type === this.filters.productType);
227
+ }
228
+
229
+ if (this.filters.status !== 'all') {
230
+ filteredPosts = filteredPosts.filter(p => p.status === this.filters.status);
231
+ }
232
+
233
+ if (filteredPosts.length === 0) {
234
+ grid.innerHTML = `
235
+ <div class="empty-state">
236
+ <i class="fas fa-inbox"></i>
237
+ <h3>沒有貼文</h3>
238
+ <p>當發布新版本時,系統會自動建立貼文草稿</p>
239
+ </div>
240
+ `;
241
+ return;
242
+ }
243
+
244
+ grid.innerHTML = filteredPosts.map(post => this.createPostCard(post)).join('');
245
+ }
246
+
247
+ createPostCard(post) {
248
+ const productBadge = post.product_type === 'revit'
249
+ ? '<span class="product-badge revit">Revit</span>'
250
+ : '<span class="product-badge autocad">AutoCAD</span>';
251
+
252
+ const statusBadge = this.getStatusBadge(post.status);
253
+ const contentPreview = this.truncateText(post.content, 150);
254
+ const createdDate = new Date(post.created_at).toLocaleDateString('zh-TW');
255
+
256
+ // 根據狀態顯示不同的操作按鈕
257
+ let actionButtons = '';
258
+
259
+ if (post.status === 'draft') {
260
+ actionButtons = `
261
+ <button class="btn btn-sm btn-secondary" onclick="postsPage.editPost('${post.id}')" title="編輯">
262
+ <i class="fas fa-edit"></i>
263
+ </button>
264
+ <button class="btn btn-sm btn-primary" onclick="postsPage.publishPost('${post.id}')" title="發布">
265
+ <i class="fas fa-paper-plane"></i>
266
+ </button>
267
+ <button class="btn btn-sm btn-danger" onclick="postsPage.deletePost('${post.id}')" title="刪除">
268
+ <i class="fas fa-trash"></i>
269
+ </button>
270
+ `;
271
+ } else if (post.status === 'published') {
272
+ actionButtons = `
273
+ <button class="btn btn-sm btn-primary" onclick="postsPage.sendEmail('${post.id}')" title="發送郵件">
274
+ <i class="fas fa-envelope"></i> 發送郵件
275
+ </button>
276
+ <button class="btn btn-sm btn-danger" onclick="postsPage.deletePost('${post.id}')" title="刪除">
277
+ <i class="fas fa-trash"></i>
278
+ </button>
279
+ `;
280
+ } else if (post.status === 'sent') {
281
+ const sentInfo = post.sent_at
282
+ ? `<small class="sent-info">已發送 ${post.recipient_count || 0} 封郵件</small>`
283
+ : '';
284
+ actionButtons = `
285
+ ${sentInfo}
286
+ <button class="btn btn-sm btn-secondary" onclick="postsPage.viewPost('${post.id}')" title="查看">
287
+ <i class="fas fa-eye"></i>
288
+ </button>
289
+ `;
290
+ }
291
+
292
+ return `
293
+ <div class="post-card" data-id="${post.id}">
294
+ <div class="post-card-header">
295
+ <div class="post-badges">
296
+ ${productBadge}
297
+ ${statusBadge}
298
+ </div>
299
+ <span class="post-version">v${post.version}</span>
300
+ </div>
301
+ <div class="post-card-body">
302
+ <h4 class="post-subject">${this.escapeHtml(post.subject)}</h4>
303
+ <p class="post-content-preview">${this.escapeHtml(contentPreview)}</p>
304
+ </div>
305
+ <div class="post-card-footer">
306
+ <span class="post-date">
307
+ <i class="fas fa-calendar"></i> ${createdDate}
308
+ </span>
309
+ <div class="post-actions">
310
+ ${actionButtons}
311
+ </div>
312
+ </div>
313
+ </div>
314
+ `;
315
+ }
316
+
317
+ getStatusBadge(status) {
318
+ const badges = {
319
+ draft: '<span class="status-badge draft"><i class="fas fa-edit"></i> 草稿</span>',
320
+ published: '<span class="status-badge published"><i class="fas fa-check"></i> 已發布</span>',
321
+ sent: '<span class="status-badge sent"><i class="fas fa-envelope"></i> 已發送</span>'
322
+ };
323
+ return badges[status] || '';
324
+ }
325
+
326
+ truncateText(text, maxLength) {
327
+ if (!text) return '';
328
+ if (text.length <= maxLength) return text;
329
+ return text.substring(0, maxLength) + '...';
330
+ }
331
+
332
+ escapeHtml(text) {
333
+ if (!text) return '';
334
+ const div = document.createElement('div');
335
+ div.textContent = text;
336
+ return div.innerHTML;
337
+ }
338
+
339
+ // 編輯貼文
340
+ async editPost(postId) {
341
+ const post = this.posts.find(p => p.id === postId);
342
+ if (!post) return;
343
+
344
+ if (post.status !== 'draft') {
345
+ Utils.showError('無法編輯', '只能編輯草稿狀態的貼文');
346
+ return;
347
+ }
348
+
349
+ this.currentEditPost = post;
350
+ document.getElementById('editSubject').value = post.subject;
351
+ document.getElementById('editContent').value = post.content;
352
+ document.getElementById('editPostModal').style.display = 'flex';
353
+ }
354
+
355
+ closeEditModal() {
356
+ document.getElementById('editPostModal').style.display = 'none';
357
+ this.currentEditPost = null;
358
+ }
359
+
360
+ async savePost() {
361
+ if (!this.currentEditPost) return;
362
+
363
+ const subject = document.getElementById('editSubject').value.trim();
364
+ const content = document.getElementById('editContent').value.trim();
365
+
366
+ if (!subject || !content) {
367
+ Utils.showError('驗證錯誤', '請填寫主旨和內容');
368
+ return;
369
+ }
370
+
371
+ try {
372
+ await api.updatePost(this.currentEditPost.id, { subject, content });
373
+ Utils.showSuccess('貼文已更新');
374
+ this.closeEditModal();
375
+ await this.loadPosts();
376
+ } catch (error) {
377
+ Utils.handleError(error, '更新貼文時');
378
+ }
379
+ }
380
+
381
+ // 發布貼文
382
+ async publishPost(postId) {
383
+ const post = this.posts.find(p => p.id === postId);
384
+ if (!post) return;
385
+
386
+ const confirmed = confirm(`確定要發布「${post.subject}」嗎?\n\n發布後將無法再編輯內容。`);
387
+ if (!confirmed) return;
388
+
389
+ try {
390
+ await api.publishPost(postId, false);
391
+ Utils.showSuccess('貼文已發布');
392
+ await this.loadPosts();
393
+ } catch (error) {
394
+ Utils.handleError(error, '發布貼文時');
395
+ }
396
+ }
397
+
398
+ // 發送郵件
399
+ async sendEmail(postId) {
400
+ const post = this.posts.find(p => p.id === postId);
401
+ if (!post) return;
402
+
403
+ const confirmed = confirm(
404
+ `確定要發送郵件通知嗎?\n\n` +
405
+ `主旨:${post.subject}\n` +
406
+ `產品:${post.product_type === 'revit' ? 'Revit' : 'AutoCAD'}\n\n` +
407
+ `郵件將發送給所有啟用用戶。`
408
+ );
409
+ if (!confirmed) return;
410
+
411
+ try {
412
+ const result = await api.sendPostEmail(postId);
413
+ Utils.showSuccess(`郵件已發送給 ${result.recipient_count || 0} 位用戶`);
414
+ await this.loadPosts();
415
+ } catch (error) {
416
+ Utils.handleError(error, '發送郵件時');
417
+ }
418
+ }
419
+
420
+ // 查看貼文(已發送的)
421
+ viewPost(postId) {
422
+ const post = this.posts.find(p => p.id === postId);
423
+ if (!post) return;
424
+
425
+ // 顯示唯讀 modal
426
+ this.currentEditPost = null;
427
+ document.getElementById('editSubject').value = post.subject;
428
+ document.getElementById('editSubject').disabled = true;
429
+ document.getElementById('editContent').value = post.content;
430
+ document.getElementById('editContent').disabled = true;
431
+
432
+ // 修改 modal 標題和按鈕
433
+ const modal = document.getElementById('editPostModal');
434
+ modal.querySelector('.modal-title').textContent = '查看貼文';
435
+ modal.querySelector('.modal-footer').innerHTML = `
436
+ <button class="btn btn-secondary" onclick="postsPage.closeViewModal()">關閉</button>
437
+ `;
438
+ modal.style.display = 'flex';
439
+ }
440
+
441
+ closeViewModal() {
442
+ const modal = document.getElementById('editPostModal');
443
+ modal.style.display = 'none';
444
+
445
+ // 恢復編輯功能
446
+ document.getElementById('editSubject').disabled = false;
447
+ document.getElementById('editContent').disabled = false;
448
+ modal.querySelector('.modal-title').textContent = '編輯貼文';
449
+ modal.querySelector('.modal-footer').innerHTML = `
450
+ <button class="btn btn-secondary" onclick="postsPage.closeEditModal()">取消</button>
451
+ <button class="btn btn-primary" onclick="postsPage.savePost()">
452
+ <i class="fas fa-save"></i> 儲存
453
+ </button>
454
+ `;
455
+ }
456
+
457
+ // 刪除貼文
458
+ async deletePost(postId) {
459
+ const post = this.posts.find(p => p.id === postId);
460
+ if (!post) return;
461
+
462
+ const confirmed = confirm(`確定要刪除「${post.subject}」嗎?\n\n此操作無法復原。`);
463
+ if (!confirmed) return;
464
+
465
+ try {
466
+ await api.deletePost(postId);
467
+ Utils.showSuccess('貼文已刪除');
468
+ await this.loadPosts();
469
+ } catch (error) {
470
+ Utils.handleError(error, '刪除貼文時');
471
+ }
472
+ }
473
+
474
+ async refreshPosts() {
475
+ const grid = document.getElementById('postsGrid');
476
+ if (grid) {
477
+ grid.innerHTML = `
478
+ <div class="text-center mt-5">
479
+ <div class="spinner mb-3"></div>
480
+ <p>載入貼文中...</p>
481
+ </div>
482
+ `;
483
+ }
484
+ await this.loadPosts();
485
+ Utils.showSuccess('已重新整理');
486
+ }
487
+
488
+ async refresh() {
489
+ await this.loadPosts();
490
+ }
491
+
492
+ destroy() {
493
+ this.posts = [];
494
+ this.currentEditPost = null;
495
+ }
496
+ }
497
+
498
+ // Create global instance
499
+ window.postsPage = new PostsPage();