CH-01 / app_new.py
Neil6387
Fix Taiwan time and reminder handling
aa1416c
Raw
History Blame Contribute Delete
151 kB
import datetime as dt
import json
import os
import re
import tempfile
import uuid
import warnings
import zipfile
from pathlib import Path
from urllib.parse import quote_plus
from zoneinfo import ZoneInfo
import gradio as gr
import pandas as pd
import requests
warnings.filterwarnings("ignore", message="cmap value too big/small:*")
try:
from fpdf import FPDF
HAS_FPDF = True
except Exception:
FPDF = None
HAS_FPDF = False
try:
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
HAS_MATPLOTLIB = True
except Exception:
HAS_MATPLOTLIB = False
mdates = None
plt = None
def default_data_file():
configured = os.environ.get("DATA_FILE")
if configured:
return Path(configured)
persistent_dir = Path("/data")
if persistent_dir.exists():
return persistent_dir / "neil_log_cases.json"
return Path("cases.json")
DATA_FILE = default_data_file()
APP_TITLE = "Neil日誌"
APP_SHORT_NAME = "Neil日誌"
APP_TIMEZONE = os.environ.get("APP_TIMEZONE", "Asia/Taipei")
LOCAL_TZ = ZoneInfo(APP_TIMEZONE)
ALL_CATEGORIES = "全部案場"
CASE_CATEGORIES = ["投標評估", "規劃設計", "施工管理", "會議追蹤", "文件審查", "現場勘查"]
CASE_STAGES = ["前期評估", "投標準備", "設計規劃", "施工中", "驗收結案", "暫緩"]
REMINDER_TYPES = ["會議", "投標期限", "文件送審", "現場勘查", "請款/估驗", "追蹤待辦", "其他"]
REMINDER_MINUTES = [0, 10, 30, 60, 180, 1440, 2880, 10080]
TASK_STATUSES = ["未開始", "進行中", "待確認", "已完成", "暫緩"]
TASK_PRIORITIES = ["一般", "重要", "緊急"]
RISK_LEVELS = ["低", "中", "高"]
RISK_STATUSES = ["觀察中", "處理中", "已解除"]
EXPENSE_TYPES = ["設計", "材料", "工班", "設備", "行政", "其他"]
GUIDE_TEXT = {
"workbench": "先選案場,再更新階段、預算與下一步。每次更新都會反映到首頁、報表與案場摘要。",
"task": "待辦要寫清楚「誰、何時、完成什麼」。風險要寫清楚影響、處置方式與負責人。",
"cases": "新增案場時先填名稱、地點、客戶、階段與類別。後續所有會議、文件、提醒都會掛回案場。",
"meeting": "錄音或上傳音檔後產生逐字稿與摘要;沒有音檔時可貼逐字稿,一樣能整理成決議與待辦。",
"files": "照片用來記錄現場狀態,文件用來做 OCR 與摘要。上傳後會自動歸到案場歷史。",
"budget": "支出要填日期、類型、金額與備註;附件要選類型,方便日後查合約、報價、圖說。",
"calendar": "提醒用來管理投標期限、會議、送審、請款與現場勘查,可匯出到手機和平板行事曆。",
"history": "用案場或關鍵字回查照片、文件與 OCR 內容,適合找舊資料與追蹤依據。",
"reports": "用於產出案場總結、詳細資料、甘特圖與 PDF/Excel 報表。",
}
PM_NOTE_TEMPLATE = "下一步:\n1. 本週完成:\n2. 需追蹤事項:\n3. 風險/卡點:\n4. 負責人與期限:"
MEETING_FOCUS_TEMPLATE = "本次會議請確認:\n1. 已決議事項\n2. 未決事項\n3. 負責人與期限\n4. 風險與需要協調事項"
PHOTO_NOTE_TEMPLATE = "拍攝位置:\n現場狀態:\n需改善事項:\n負責人/期限:"
DOC_NOTE_TEMPLATE = "文件類型:\n日期/版次:\n金額/數量:\n需簽核或追蹤事項:"
RISK_TEMPLATE = "可能影響:\n處置策略:\n需要協調:\n預計解除條件:"
EXPENSE_NOTE_TEMPLATE = "用途:\n對應項目:\n付款狀態:\n憑證/附件:"
HF_API_URL = "https://api-inference.huggingface.co/models"
HF_TOKEN = os.environ.get("HUGGINGFACEHUB_API_TOKEN") or os.environ.get("HF_TOKEN")
HEADERS = {"Authorization": f"Bearer {HF_TOKEN}"} if HF_TOKEN else {}
TRANSCRIBE_MODEL = os.environ.get("TRANSCRIBE_MODEL", "openai/whisper-large-v3")
IMAGE_CAPTION_MODEL = os.environ.get("IMAGE_CAPTION_MODEL", "nlpconnect/vit-gpt2-image-captioning")
OCR_MODEL = os.environ.get("OCR_MODEL", "microsoft/trocr-base-printed")
TEXT_MODEL = os.environ.get("TEXT_MODEL", "")
LOGIN_USER = os.environ.get("APP_LOGIN_USER", "Neil67")
LOGIN_PASSWORD = os.environ.get("APP_LOGIN_PASSWORD", "")
APP_ACCESS_KEY = os.environ.get("APP_ACCESS_KEY", "")
def now_local():
return dt.datetime.now(LOCAL_TZ).replace(tzinfo=None, microsecond=0)
def today_local():
return now_local().date()
def now_iso():
return now_local().isoformat()
def parse_iso_local(value):
parsed = dt.datetime.fromisoformat(str(value or ""))
if parsed.tzinfo:
parsed = parsed.astimezone(LOCAL_TZ).replace(tzinfo=None)
return parsed
def bundled_data_file():
return Path("cases.json")
def ensure_data_file():
if DATA_FILE.parent != Path("."):
DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
if not DATA_FILE.exists():
source = bundled_data_file()
if DATA_FILE != source and source.exists():
content = source.read_text(encoding="utf-8").strip()
DATA_FILE.write_text(content or "[]", encoding="utf-8")
else:
DATA_FILE.write_text("[]", encoding="utf-8")
def load_cases():
ensure_data_file()
try:
data = json.loads(DATA_FILE.read_text(encoding="utf-8"))
except json.JSONDecodeError:
backup = DATA_FILE.with_suffix(f".broken-{now_local():%Y%m%d%H%M%S}.json")
DATA_FILE.replace(backup)
DATA_FILE.write_text("[]", encoding="utf-8")
return []
return [normalize_case(case) for case in data if isinstance(case, dict)]
def save_cases(cases):
normalized = [normalize_case(case) for case in cases]
DATA_FILE.write_text(json.dumps(normalized, ensure_ascii=False, indent=2), encoding="utf-8")
def storage_mode():
path_text = str(DATA_FILE).replace("\\", "/")
if path_text.startswith("/data/"):
return "永久儲存"
return "暫存儲存"
def data_storage_status():
try:
count = len(load_cases())
return f"目前資料位置:`{DATA_FILE}`|模式:**{storage_mode()}**|案場:**{count}** 件"
except Exception as exc:
return f"資料讀取異常:{exc}"
def export_data_backup():
cases = load_cases()
path = Path(tempfile.gettempdir()) / f"NeilLog_backup_{now_local():%Y%m%d_%H%M%S}.json"
path.write_text(json.dumps(cases, ensure_ascii=False, indent=2), encoding="utf-8")
return str(path)
def restore_data_backup(file_path):
if not file_path:
return (*refresh_all("請先選擇要還原的 Neil日誌備份 JSON。"), data_storage_status())
path = Path(str(file_path))
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception as exc:
return (*refresh_all(f"備份檔讀取失敗:{exc}"), data_storage_status())
if not isinstance(data, list):
return (*refresh_all("備份格式不正確,請上傳 Neil日誌匯出的 JSON 檔。"), data_storage_status())
save_cases([case for case in data if isinstance(case, dict)])
return (*refresh_all(f"已還原備份:{len(data)} 筆案場資料。"), data_storage_status())
def normalize_case(case):
case = dict(case)
case.setdefault("id", str(uuid.uuid4()))
case.setdefault("site_name", "")
case.setdefault("location", "")
case.setdefault("client", "")
case.setdefault("stage", CASE_STAGES[0])
case.setdefault("category", CASE_CATEGORIES[0])
case.setdefault("tags", [])
case.setdefault("bid_amount", "")
case.setdefault("manager_notes", "")
case.setdefault("created_at", now_iso())
case.setdefault("updated_at", case.get("created_at", now_iso()))
case.setdefault("meetings", [])
case.setdefault("photo_history", [])
case.setdefault("ocr_documents", [])
case.setdefault("attachments", [])
case.setdefault("reminders", [])
case.setdefault("tasks", [])
case.setdefault("risks", [])
case.setdefault("expenses", [])
if isinstance(case["tags"], str):
case["tags"] = [tag.strip() for tag in case["tags"].split(",") if tag.strip()]
return case
def to_number(value):
cleaned = "".join(ch for ch in str(value or "") if ch.isdigit() or ch in ".-")
try:
return float(cleaned) if cleaned else 0.0
except ValueError:
return 0.0
def money_text(value):
number = to_number(value)
if not number:
return "0"
return f"{number:,.0f}"
def normalize_date_text(value, field_name="日期", allow_empty=True):
if isinstance(value, dt.datetime):
if value.tzinfo:
value = value.astimezone(LOCAL_TZ).replace(tzinfo=None)
return value.date().isoformat()
value = str(value or "").strip()
if not value:
if allow_empty:
return ""
raise ValueError(f"請輸入{field_name}。")
try:
parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
if parsed.tzinfo:
parsed = parsed.astimezone(LOCAL_TZ).replace(tzinfo=None)
return parsed.date().isoformat()
except ValueError:
pass
for fmt in ("%Y-%m-%d", "%Y/%m/%d"):
try:
return dt.datetime.strptime(value, fmt).date().isoformat()
except ValueError:
continue
raise ValueError(f"{field_name}格式請用 YYYY-MM-DD。")
def normalize_task_datetime(value, field_name="時間", allow_empty=True):
if isinstance(value, dt.datetime):
if value.tzinfo:
value = value.astimezone(LOCAL_TZ).replace(tzinfo=None)
return value.strftime("%Y-%m-%d %H:%M")
value = str(value or "").strip()
if not value:
if allow_empty:
return ""
raise ValueError(f"請輸入{field_name}。")
text = value.replace(" ", " ").replace("/", "-")
text = re.sub(r"\s+", " ", text).strip()
iso_text = text.replace("Z", "+00:00")
try:
parsed = dt.datetime.fromisoformat(iso_text)
if parsed.tzinfo:
parsed = parsed.astimezone(LOCAL_TZ).replace(tzinfo=None)
return parsed.strftime("%Y-%m-%d %H:%M")
except ValueError:
pass
zh_match = re.search(
r"(?P<date>\d{4}-\d{1,2}-\d{1,2})\s*(?P<period>上午|早上|中午|下午|晚上)?\s*(?P<hour>\d{1,2})(?:點|時|:)(?P<minute>\d{1,2}|半)?",
text,
)
if zh_match:
date_part = zh_match.group("date")
hour = int(zh_match.group("hour"))
minute_text = zh_match.group("minute")
minute = 30 if minute_text == "半" else int(minute_text or 0)
period = zh_match.group("period") or ""
if period in {"下午", "晚上"} and hour < 12:
hour += 12
if period == "中午" and hour < 12:
hour += 12
parsed_date = dt.datetime.strptime(date_part, "%Y-%m-%d")
return parsed_date.replace(hour=hour, minute=minute).strftime("%Y-%m-%d %H:%M")
text = text.replace("點半", ":30").replace("點", ":00").replace("時", ":00")
formats = [
"%Y-%m-%d %H:%M",
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%d",
]
for fmt in formats:
try:
parsed = dt.datetime.strptime(text, fmt)
if fmt == "%Y-%m-%d":
return parsed.date().isoformat()
return parsed.strftime("%Y-%m-%d %H:%M")
except ValueError:
continue
raise ValueError(f"{field_name}格式請用 2026-06-29 09:00,或 2026/06/29 上午9點。")
def task_datetime_to_iso(value):
value = str(value or "").strip()
if not value:
return ""
for fmt in ("%Y-%m-%d %H:%M", "%Y-%m-%d"):
try:
parsed = dt.datetime.strptime(value, fmt)
if fmt == "%Y-%m-%d":
parsed = parsed.replace(hour=9, minute=0)
return parsed.isoformat()
except ValueError:
continue
return ""
def parse_minutes(value, default=0):
if value in (None, ""):
return default
if isinstance(value, (int, float)):
return int(value)
match = re.search(r"\d+", str(value))
return int(match.group(0)) if match else default
def current_categories():
categories = list(CASE_CATEGORIES)
for case in load_cases():
category = case.get("category")
if category and category not in categories:
categories.append(category)
return categories
def parse_tags(tags):
if isinstance(tags, list):
return [str(tag).strip() for tag in tags if str(tag).strip()]
return [tag.strip() for tag in str(tags or "").replace(",", ",").split(",") if tag.strip()]
def site_label(case):
name = case.get("site_name") or "未命名案場"
location = case.get("location") or "未填地點"
if not location or location == name:
return name
return f"{name}{location})"
def google_maps_url(location):
location = str(location or "").strip()
if not location:
return ""
return f"https://www.google.com/maps/search/?api=1&query={quote_plus(location)}"
def google_maps_markdown(location):
url = google_maps_url(location)
if not url:
return "尚未輸入地點。"
return f"[開啟 Google Maps]({url})"
def location_map_preview(location):
url = google_maps_url(location)
if not url:
return "輸入地點後會自動產生 Google Maps 連結。"
return f"Google Maps:[{str(location).strip()}]({url})"
def list_site_options():
return [site_label(case) for case in load_cases()]
def get_case_by_label(label):
if not label:
return None
label = str(label).strip()
case_id = label.split("|")[-1].strip()
for case in load_cases():
if case.get("id") == case_id or site_label(case) == label:
return case
return None
def case_rows(category=ALL_CATEGORIES):
rows = []
for case in load_cases():
if category and category != ALL_CATEGORIES and case.get("category") != category:
continue
rows.append(
{
"案場": case.get("site_name", ""),
"地點": case.get("location", ""),
"Google Maps": google_maps_url(case.get("location", "")),
"類別": case.get("category", ""),
"客戶": case.get("client", ""),
"階段": case.get("stage", ""),
"金額/預算": case.get("bid_amount", ""),
"標籤": ", ".join(case.get("tags", [])),
"會議": len(case.get("meetings", [])),
"照片": len(case.get("photo_history", [])),
"文件": len(case.get("ocr_documents", [])) + len(case.get("attachments", [])),
"提醒": len(case.get("reminders", [])),
"未完成待辦": sum(1 for item in case.get("tasks", []) if item.get("status") != "已完成"),
"高風險": sum(1 for item in case.get("risks", []) if item.get("level") == "高" and item.get("status") != "已解除"),
"更新時間": case.get("updated_at", ""),
}
)
return pd.DataFrame(rows)
def category_overview(category=ALL_CATEGORIES):
cases = load_cases()
if category and category != ALL_CATEGORIES:
cases = [case for case in cases if case.get("category") == category]
if not cases:
return "### 案場總覽\n目前沒有符合條件的案場。"
grouped = {}
for case in cases:
grouped.setdefault(case.get("category") or "未分類", []).append(case)
lines = ["### 案場總覽"]
for group, items in sorted(grouped.items()):
lines.append(f"#### {group}{len(items)} 件)")
for case in items:
lines.append(
f"- **{case.get('site_name', '')}**|{case.get('location', '')}{google_maps_markdown(case.get('location', ''))}|"
f"{case.get('stage', '')}{case.get('client', '') or '未填客戶'}"
)
return "\n".join(lines)
def add_case(site_name, location, client, stage, category, new_category, tags, bid_amount, manager_notes):
if not str(site_name or "").strip():
return refresh_all("請先輸入案場名稱。")
chosen_category = str(new_category or "").strip() or category or CASE_CATEGORIES[0]
cases = load_cases()
new_case = create_case_record(site_name, location, client, stage, chosen_category, tags, bid_amount, manager_notes)
cases.append(new_case)
save_cases(cases)
return refresh_all_select_case(site_label(new_case), f"已新增並選取案場:{site_name}")
def create_case_record(site_name, location="", client="", stage=None, category=None, tags="", bid_amount="", manager_notes=""):
now = now_iso()
return {
"id": str(uuid.uuid4()),
"site_name": str(site_name).strip(),
"location": str(location or "").strip(),
"client": str(client or "").strip(),
"stage": stage or CASE_STAGES[0],
"category": category or CASE_CATEGORIES[0],
"tags": parse_tags(tags),
"bid_amount": str(bid_amount or "").strip(),
"manager_notes": str(manager_notes or "").strip(),
"created_at": now,
"updated_at": now,
"meetings": [],
"photo_history": [],
"ocr_documents": [],
"attachments": [],
"reminders": [],
"tasks": [],
"risks": [],
"expenses": [],
}
def refresh_all_select_case(selected_label, message="已更新。"):
sites = list_site_options()
categories = current_categories()
return (
case_rows(),
gr.update(choices=sites, value=selected_label),
gr.update(choices=sites, value=selected_label),
gr.update(choices=sites, value=selected_label),
gr.update(choices=sites, value=selected_label),
gr.update(choices=sites, value=selected_label),
gr.update(choices=sites, value=selected_label),
gr.update(choices=sites, value=selected_label),
gr.update(choices=sites, value=selected_label),
gr.update(choices=sites, value=selected_label),
gr.update(choices=sites, value=selected_label),
gr.update(choices=sites, value=selected_label),
gr.update(choices=sites, value=selected_label),
gr.update(choices=sites, value=selected_label),
gr.update(choices=sites, value=selected_label),
gr.update(choices=sites, value=selected_label),
gr.update(choices=sites, value=selected_label),
gr.update(choices=categories),
gr.update(choices=[ALL_CATEGORIES] + categories, value=ALL_CATEGORIES),
category_overview(),
message,
)
def quick_add_case_for_task(site_name, location):
site_name = str(site_name or "").strip()
location = str(location or "").strip()
if not site_name:
return refresh_all("請先輸入案場名稱,建立後就能直接新增待辦。")
for case in load_cases():
same_name = case.get("site_name") == site_name
same_location = not location or case.get("location", "") == location
if same_name and same_location:
label = site_label(case)
return refresh_all_select_case(label, f"已選取既有案場:{label}")
cases = load_cases()
new_case = create_case_record(site_name, location)
cases.append(new_case)
save_cases(cases)
label = site_label(new_case)
return refresh_all_select_case(label, f"已新增並選取案場:{label}")
def refresh_all(message="已更新。"):
sites = list_site_options()
categories = current_categories()
return (
case_rows(),
gr.update(choices=sites),
gr.update(choices=sites),
gr.update(choices=sites),
gr.update(choices=sites),
gr.update(choices=sites),
gr.update(choices=sites),
gr.update(choices=sites),
gr.update(choices=sites),
gr.update(choices=sites),
gr.update(choices=sites),
gr.update(choices=sites),
gr.update(choices=sites),
gr.update(choices=sites),
gr.update(choices=sites),
gr.update(choices=sites),
gr.update(choices=sites),
gr.update(choices=categories),
gr.update(choices=[ALL_CATEGORIES] + categories, value=ALL_CATEGORIES),
category_overview(),
message,
)
def filter_cases(category):
return case_rows(category), category_overview(category)
def template_text(kind):
templates = {
"pm_note": PM_NOTE_TEMPLATE,
"meeting": MEETING_FOCUS_TEMPLATE,
"photo": PHOTO_NOTE_TEMPLATE,
"document": DOC_NOTE_TEMPLATE,
"risk": RISK_TEMPLATE,
"expense": EXPENSE_NOTE_TEMPLATE,
}
return templates.get(kind, "")
def case_workbench(site):
case = get_case_by_label(site)
if not case:
return "請先選擇案場。"
checks = []
if not case.get("client"):
checks.append("補上客戶/窗口")
if not case.get("bid_amount"):
checks.append("補上預算或合約金額")
if not case.get("reminders"):
checks.append("建立下一個提醒")
if not case.get("tasks"):
checks.append("建立至少一項待辦")
if not case.get("manager_notes"):
checks.append("補上 PM 下一步")
upcoming = []
now = now_local()
for item in case.get("reminders", []):
try:
start = parse_iso_local(item.get("start_at", ""))
except ValueError:
continue
if start >= now:
upcoming.append((start, item))
upcoming.sort(key=lambda pair: pair[0])
lines = [
f"### {case.get('site_name', '未命名案場')}",
f"- 地點:{case.get('location', '') or '未填'}",
f"- 地圖:{google_maps_markdown(case.get('location', ''))}",
f"- 階段:{case.get('stage', '')}",
f"- 類別:{case.get('category', '')}",
f"- 客戶:{case.get('client', '') or '未填'}",
f"- 預算/金額:{case.get('bid_amount', '') or '未填'}",
f"- 會議:{len(case.get('meetings', []))} 筆",
f"- 文件:{len(case.get('ocr_documents', [])) + len(case.get('attachments', []))} 筆",
f"- 照片:{len(case.get('photo_history', []))} 筆",
f"- 提醒:{len(case.get('reminders', []))} 筆",
f"- 未完成待辦:{sum(1 for item in case.get('tasks', []) if item.get('status') != '已完成')} 項",
f"- 未解除風險:{sum(1 for item in case.get('risks', []) if item.get('status') != '已解除')} 項",
f"- 已登錄支出:{money_text(sum(to_number(item.get('amount')) for item in case.get('expenses', [])))}",
"",
"#### 專家檢核",
"- " + "、".join(checks) if checks else "- 基本資料完整,請持續追蹤期限、風險與成本。",
"",
"#### 下一步提醒",
]
if upcoming:
for start, item in upcoming[:5]:
lines.append(f"- {start:%Y-%m-%d %H:%M}{item.get('type', '')}{item.get('title', '')}")
else:
lines.append("- 尚無未來提醒,建議建立下一個追蹤節點。")
lines.append("\n#### 優先待辦")
open_tasks = [item for item in case.get("tasks", []) if item.get("status") != "已完成"]
if open_tasks:
for item in open_tasks[:5]:
agreed = item.get("agreed_at") or "未填約定"
due = item.get("due_at") or "未填期限"
lines.append(f"- {item.get('priority', '')}|約定:{agreed}|期限:{due}{item.get('owner', '') or '未填負責人'}{item.get('title', '')}")
else:
lines.append("- 尚無未完成待辦。")
lines.append("\n#### 風險控管")
open_risks = [item for item in case.get("risks", []) if item.get("status") != "已解除"]
if open_risks:
for item in open_risks[:5]:
lines.append(f"- {item.get('level', '')}{item.get('status', '')}{item.get('title', '')}")
else:
lines.append("- 尚無未解除風險。")
lines.extend(["", "#### PM 備註", case.get("manager_notes", "") or "尚無備註。"])
return "\n".join(lines)
def load_case_for_workbench(site):
case = get_case_by_label(site)
if not case:
return "", "", "", "", "請先選擇案場。"
return (
case.get("stage", CASE_STAGES[0]),
case.get("category", CASE_CATEGORIES[0]),
case.get("bid_amount", ""),
case.get("manager_notes", ""),
case_workbench(site),
)
def update_case_from_workbench(site, stage, category, bid_amount, manager_notes):
case = get_case_by_label(site)
if not case:
return case_rows(), build_hero_html(), "請先選擇案場。", "請先選擇案場。"
cases = load_cases()
for stored in cases:
if stored["id"] == case["id"]:
stored["stage"] = stage or stored.get("stage", "")
stored["category"] = category or stored.get("category", "")
stored["bid_amount"] = str(bid_amount or "").strip()
stored["manager_notes"] = str(manager_notes or "").strip()
stored["updated_at"] = now_iso()
break
save_cases(cases)
return case_rows(), build_hero_html(), case_workbench(site), "案場狀態已更新。"
def task_rows(site=None, only_open=False):
rows = []
selected_case_id = None
if site:
case = get_case_by_label(site)
selected_case_id = case.get("id") if case else None
for case in load_cases():
if selected_case_id and case.get("id") != selected_case_id:
continue
for item in case.get("tasks", []):
if only_open and item.get("status") == "已完成":
continue
rows.append(
{
"案場": case.get("site_name", ""),
"狀態": item.get("status", ""),
"優先": item.get("priority", ""),
"約定時間": item.get("agreed_at", ""),
"完成期限": item.get("due_at", ""),
"負責人": item.get("owner", ""),
"待辦": item.get("title", ""),
"備註": item.get("notes", ""),
"更新時間": item.get("updated_at", item.get("created_at", "")),
}
)
rows.sort(key=lambda row: (row["狀態"] == "已完成", row["完成期限"] or "9999-12-31", row["約定時間"] or "9999-12-31", row["優先"]))
return pd.DataFrame(rows)
def task_options(site=None, only_open=True):
options = []
selected_case_id = None
if site:
case = get_case_by_label(site)
selected_case_id = case.get("id") if case else None
for case in load_cases():
if selected_case_id and case.get("id") != selected_case_id:
continue
for item in case.get("tasks", []):
if only_open and item.get("status") == "已完成":
continue
label = f"{case.get('site_name', '')}{item.get('priority', '')}{item.get('due_at') or item.get('agreed_at') or '未填時間'}{item.get('title', '')}"
options.append((label, item.get("id", "")))
return options
def add_task(site, title, owner, agreed_at, due_at, priority, status, sync_calendar, notes):
case = get_case_by_label(site)
if not case:
return task_rows(), build_hero_html(), case_rows(), upcoming_reminders(30), gr.update(choices=[]), "請先選擇案場;若還沒有案場,請先在上方快速新增案場。"
if not str(title or "").strip():
return task_rows(site), build_hero_html(), case_rows(), upcoming_reminders(30), gr.update(choices=task_options(site), value=None), "請輸入待辦事項。"
try:
agreed_at = normalize_task_datetime(agreed_at, "約定時間", allow_empty=True)
due_at = normalize_task_datetime(due_at, "完成期限", allow_empty=True)
except ValueError as exc:
return task_rows(site), build_hero_html(), case_rows(), upcoming_reminders(30), gr.update(choices=task_options(site), value=None), str(exc)
cases = load_cases()
selected_label = site
reminder_count = 0
for stored in cases:
if stored["id"] == case["id"]:
stored.setdefault("tasks", []).append(
{
"id": str(uuid.uuid4()),
"title": str(title).strip(),
"owner": str(owner or "").strip(),
"agreed_at": str(agreed_at or "").strip(),
"due_at": str(due_at or "").strip(),
"priority": priority or TASK_PRIORITIES[0],
"status": status or TASK_STATUSES[0],
"notes": str(notes or "").strip(),
"created_at": now_iso(),
"updated_at": now_iso(),
}
)
if sync_calendar:
reminders = stored.setdefault("reminders", [])
for label, time_value in (("約定時間", agreed_at), ("完成期限", due_at)):
start_iso = task_datetime_to_iso(time_value)
if not start_iso:
continue
start_dt = dt.datetime.fromisoformat(start_iso)
reminders.append(
{
"id": str(uuid.uuid4()),
"title": f"{label}{str(title).strip()}",
"type": "追蹤待辦",
"start_at": start_iso,
"end_at": (start_dt + dt.timedelta(minutes=30)).isoformat(),
"remind_before_minutes": 30,
"owner": str(owner or "").strip(),
"notes": str(notes or "").strip(),
"created_at": now_iso(),
}
)
reminder_count += 1
stored["updated_at"] = now_iso()
selected_label = site_label(stored)
break
save_cases(cases)
message = f"已新增待辦:{title}"
if reminder_count:
message += f";已同步建立 {reminder_count} 筆行事曆提醒。"
return task_rows(selected_label), build_hero_html(), case_rows(), upcoming_reminders(30), gr.update(choices=task_options(selected_label), value=None), message
def complete_task(site, task_id):
if not task_id:
return task_rows(site), build_hero_html(), case_rows(), gr.update(choices=task_options(site), value=None), "請先選擇要完成的待辦。"
selected_label = site
updated_title = ""
cases = load_cases()
for stored in cases:
for item in stored.get("tasks", []):
if item.get("id") == task_id:
item["status"] = "已完成"
item["updated_at"] = now_iso()
stored["updated_at"] = now_iso()
selected_label = site_label(stored)
updated_title = item.get("title", "")
break
if updated_title:
break
if not updated_title:
return task_rows(site), build_hero_html(), case_rows(), gr.update(choices=task_options(site), value=None), "找不到這筆待辦,請重新整理清單。"
save_cases(cases)
return task_rows(selected_label), build_hero_html(), case_rows(), gr.update(choices=task_options(selected_label), value=None), f"已完成待辦:{updated_title}"
def risk_rows(site=None):
rows = []
selected_case_id = None
if site:
case = get_case_by_label(site)
selected_case_id = case.get("id") if case else None
for case in load_cases():
if selected_case_id and case.get("id") != selected_case_id:
continue
for item in case.get("risks", []):
rows.append(
{
"案場": case.get("site_name", ""),
"等級": item.get("level", ""),
"狀態": item.get("status", ""),
"風險": item.get("title", ""),
"處置": item.get("mitigation", ""),
"負責人": item.get("owner", ""),
"更新時間": item.get("updated_at", item.get("created_at", "")),
}
)
level_rank = {"高": 0, "中": 1, "低": 2}
rows.sort(key=lambda row: (level_rank.get(row["等級"], 9), row["狀態"] == "已解除"))
return pd.DataFrame(rows)
def risk_options(site=None, only_open=True):
options = []
selected_case_id = None
if site:
case = get_case_by_label(site)
selected_case_id = case.get("id") if case else None
for case in load_cases():
if selected_case_id and case.get("id") != selected_case_id:
continue
for item in case.get("risks", []):
if only_open and item.get("status") == "已解除":
continue
label = f"{case.get('site_name', '')}{item.get('level', '')}{item.get('status', '')}{item.get('title', '')}"
options.append((label, item.get("id", "")))
return options
def add_risk(site, title, level, status, owner, mitigation):
case = get_case_by_label(site)
if not case:
return risk_rows(), build_hero_html(), case_rows(), gr.update(choices=[]), "請先選擇案場。"
if not str(title or "").strip():
return risk_rows(site), build_hero_html(), case_rows(), gr.update(choices=risk_options(site), value=None), "請輸入風險項目。"
cases = load_cases()
selected_label = site
for stored in cases:
if stored["id"] == case["id"]:
stored.setdefault("risks", []).append(
{
"id": str(uuid.uuid4()),
"title": str(title).strip(),
"level": level or RISK_LEVELS[1],
"status": status or RISK_STATUSES[0],
"owner": str(owner or "").strip(),
"mitigation": str(mitigation or "").strip(),
"created_at": now_iso(),
"updated_at": now_iso(),
}
)
stored["updated_at"] = now_iso()
selected_label = site_label(stored)
break
save_cases(cases)
return risk_rows(selected_label), build_hero_html(), case_rows(), gr.update(choices=risk_options(selected_label), value=None), f"已新增風險:{title}"
def resolve_risk(site, risk_id):
if not risk_id:
return risk_rows(site), build_hero_html(), case_rows(), gr.update(choices=risk_options(site), value=None), "請先選擇要解除的風險。"
selected_label = site
updated_title = ""
cases = load_cases()
for stored in cases:
for item in stored.get("risks", []):
if item.get("id") == risk_id:
item["status"] = "已解除"
item["updated_at"] = now_iso()
stored["updated_at"] = now_iso()
selected_label = site_label(stored)
updated_title = item.get("title", "")
break
if updated_title:
break
if not updated_title:
return risk_rows(site), build_hero_html(), case_rows(), gr.update(choices=risk_options(site), value=None), "找不到這筆風險,請重新整理清單。"
save_cases(cases)
return risk_rows(selected_label), build_hero_html(), case_rows(), gr.update(choices=risk_options(selected_label), value=None), f"已解除風險:{updated_title}"
def expense_rows(site=None):
rows = []
selected_case_id = None
if site:
case = get_case_by_label(site)
selected_case_id = case.get("id") if case else None
for case in load_cases():
if selected_case_id and case.get("id") != selected_case_id:
continue
for item in case.get("expenses", []):
rows.append(
{
"案場": case.get("site_name", ""),
"日期": item.get("date", ""),
"類型": item.get("type", ""),
"項目": item.get("title", ""),
"金額": money_text(item.get("amount", "")),
"備註": item.get("notes", ""),
}
)
rows.sort(key=lambda row: row["日期"], reverse=True)
return pd.DataFrame(rows)
def budget_summary(site=None):
cases = load_cases()
if site:
selected = get_case_by_label(site)
cases = [case for case in cases if selected and case.get("id") == selected.get("id")]
budget = sum(to_number(case.get("bid_amount")) for case in cases)
spent = sum(to_number(item.get("amount")) for case in cases for item in case.get("expenses", []))
remaining = budget - spent
ratio = round(spent / budget * 100) if budget > 0 else 0
return (
"### 預算彙整\n"
f"- 預算/合約金額:{money_text(budget)}\n"
f"- 已登錄支出:{money_text(spent)}\n"
f"- 剩餘金額:{money_text(remaining)}\n"
f"- 使用比例:{ratio}%"
)
def add_expense(site, title, expense_type, amount, expense_date, notes):
case = get_case_by_label(site)
if not case:
return expense_rows(), budget_summary(), build_hero_html(), case_rows(), "請先選擇案場。"
if not str(title or "").strip():
return expense_rows(site), budget_summary(site), build_hero_html(), case_rows(), "請輸入支出項目。"
amount_value = to_number(amount)
if amount_value <= 0:
return expense_rows(site), budget_summary(site), build_hero_html(), case_rows(), "請輸入大於 0 的金額。"
try:
expense_date = normalize_date_text(expense_date, "日期", allow_empty=False)
except ValueError as exc:
return expense_rows(site), budget_summary(site), build_hero_html(), case_rows(), str(exc)
cases = load_cases()
selected_label = site
for stored in cases:
if stored["id"] == case["id"]:
stored.setdefault("expenses", []).append(
{
"id": str(uuid.uuid4()),
"title": str(title).strip(),
"type": expense_type or EXPENSE_TYPES[-1],
"amount": str(amount_value),
"date": expense_date,
"notes": str(notes or "").strip(),
"created_at": now_iso(),
}
)
stored["updated_at"] = now_iso()
selected_label = site_label(stored)
break
save_cases(cases)
return expense_rows(selected_label), budget_summary(selected_label), build_hero_html(), case_rows(), f"已新增支出:{title}"
def attachment_rows(site=None):
rows = []
selected_case_id = None
if site:
case = get_case_by_label(site)
selected_case_id = case.get("id") if case else None
for case in load_cases():
if selected_case_id and case.get("id") != selected_case_id:
continue
for item in case.get("attachments", []):
rows.append(
{
"案場": case.get("site_name", ""),
"時間": item.get("timestamp", ""),
"類型": item.get("type", ""),
"名稱": item.get("name", ""),
"備註": item.get("notes", ""),
"路徑": item.get("path", ""),
}
)
rows.sort(key=lambda row: row["時間"], reverse=True)
return pd.DataFrame(rows)
def add_attachment(site, files, attachment_type, notes):
case = get_case_by_label(site)
if not case:
return attachment_rows(), build_hero_html(), case_rows(), "請先選擇案場。"
if not files:
return attachment_rows(site), build_hero_html(), case_rows(), "請先上傳檔案。"
if isinstance(files, str):
files = [files]
cases = load_cases()
added = 0
selected_label = site
for stored in cases:
if stored["id"] == case["id"]:
for file_path in files:
path = str(file_path)
stored.setdefault("attachments", []).append(
{
"id": str(uuid.uuid4()),
"timestamp": now_iso(),
"type": attachment_type or "附件",
"name": Path(path).name,
"path": path,
"notes": str(notes or "").strip(),
}
)
added += 1
stored["updated_at"] = now_iso()
selected_label = site_label(stored)
break
save_cases(cases)
return attachment_rows(selected_label), build_hero_html(), case_rows(), f"已加入 {added} 個附件。"
def parse_reminder_datetime(value):
if isinstance(value, dt.datetime):
if value.tzinfo:
value = value.astimezone(LOCAL_TZ).replace(tzinfo=None)
return value.replace(tzinfo=None)
value = str(value or "").strip()
if not value:
raise ValueError("請輸入提醒日期時間。")
try:
parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
if parsed.tzinfo:
parsed = parsed.astimezone(LOCAL_TZ).replace(tzinfo=None)
return parsed
except ValueError:
pass
formats = ["%Y-%m-%d %H:%M", "%Y/%m/%d %H:%M", "%Y-%m-%dT%H:%M", "%Y-%m-%d"]
for fmt in formats:
try:
parsed = dt.datetime.strptime(value, fmt)
if fmt == "%Y-%m-%d":
parsed = parsed.replace(hour=9, minute=0)
return parsed
except ValueError:
continue
raise ValueError("日期格式請用 YYYY-MM-DD HH:MM,例如 2026-06-30 09:00。")
def reminder_status(event_time):
now = now_local()
if event_time < now:
return "已過期"
if event_time.date() == now.date():
return "今日"
if event_time <= now + dt.timedelta(days=7):
return "7 天內"
return "待辦"
def add_reminder(site, title, reminder_type, start_time, duration_minutes, remind_before, owner, notes):
case = get_case_by_label(site)
if not case:
return pd.DataFrame([]), "請先選擇案場。", build_hero_html(), case_rows()
if not str(title or "").strip():
return reminder_rows(site), "請輸入提醒標題。", build_hero_html(), case_rows()
try:
event_time = parse_reminder_datetime(start_time)
except ValueError as exc:
return reminder_rows(site), str(exc), build_hero_html(), case_rows()
duration = max(parse_minutes(duration_minutes, 60), 1)
remind_before = max(parse_minutes(remind_before, 0), 0)
reminder = {
"id": str(uuid.uuid4()),
"title": str(title).strip(),
"type": reminder_type or "其他",
"start_at": event_time.isoformat(),
"end_at": (event_time + dt.timedelta(minutes=duration)).isoformat(),
"remind_before_minutes": remind_before,
"owner": str(owner or "").strip(),
"notes": str(notes or "").strip(),
"created_at": now_iso(),
}
cases = load_cases()
for stored in cases:
if stored["id"] == case["id"]:
stored.setdefault("reminders", []).append(reminder)
stored["updated_at"] = now_iso()
break
save_cases(cases)
return reminder_rows(site), f"已加入提醒:{title}", build_hero_html(), case_rows()
def reminder_rows(site=None, horizon_days=None):
rows = []
selected_case_id = None
if site:
case = get_case_by_label(site)
selected_case_id = case.get("id") if case else None
now = now_local()
horizon = now + dt.timedelta(days=int(horizon_days)) if horizon_days else None
for case in load_cases():
if selected_case_id and case.get("id") != selected_case_id:
continue
for item in case.get("reminders", []):
try:
start = parse_iso_local(item.get("start_at", ""))
except ValueError:
continue
if horizon and (start < now or start > horizon):
continue
rows.append(
{
"狀態": reminder_status(start),
"時間": start.strftime("%Y-%m-%d %H:%M"),
"案場": case.get("site_name", ""),
"類型": item.get("type", ""),
"標題": item.get("title", ""),
"負責人": item.get("owner", ""),
"提前提醒": f"{item.get('remind_before_minutes', 0)} 分鐘",
"備註": item.get("notes", ""),
}
)
rows.sort(key=lambda row: row["時間"])
return pd.DataFrame(rows)
def upcoming_reminders(days):
return reminder_rows(horizon_days=days)
def ics_escape(value):
return str(value or "").replace("\\", "\\\\").replace("\n", "\\n").replace(",", "\\,").replace(";", "\\;")
def ics_datetime(value):
if isinstance(value, str):
value = parse_iso_local(value)
return value.strftime("%Y%m%dT%H%M%S")
def fold_ics_line(line):
chunks = []
while len(line.encode("utf-8")) > 73:
cut = 73
while len(line[:cut].encode("utf-8")) > 73:
cut -= 1
chunks.append(line[:cut])
line = " " + line[cut:]
chunks.append(line)
return "\r\n".join(chunks)
def export_calendar_ics(site=None):
cases = load_cases()
selected_case = get_case_by_label(site) if site else None
if selected_case:
cases = [case for case in cases if case.get("id") == selected_case.get("id")]
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//NeilLog//Project Reminders//ZH-TW",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
"X-WR-CALNAME:Neil日誌 專案提醒",
]
event_count = 0
generated = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
for case in cases:
for item in case.get("reminders", []):
try:
start = parse_iso_local(item.get("start_at", ""))
end = parse_iso_local(item.get("end_at", "")) if item.get("end_at") else start + dt.timedelta(hours=1)
except ValueError:
continue
event_count += 1
uid = f"{item.get('id', uuid.uuid4())}@ch-01"
summary = f"{case.get('site_name', '')}{item.get('title', '')}"
description = (
f"案場:{case.get('site_name', '')}\\n"
f"地點:{case.get('location', '')}\\n"
f"類型:{item.get('type', '')}\\n"
f"負責人:{item.get('owner', '')}\\n"
f"備註:{item.get('notes', '')}"
)
lines.extend(
[
"BEGIN:VEVENT",
f"UID:{ics_escape(uid)}",
f"DTSTAMP:{generated}",
f"DTSTART:{ics_datetime(start)}",
f"DTEND:{ics_datetime(end)}",
f"SUMMARY:{ics_escape(summary)}",
f"LOCATION:{ics_escape(case.get('location', ''))}",
f"DESCRIPTION:{ics_escape(description)}",
"BEGIN:VALARM",
"ACTION:DISPLAY",
f"DESCRIPTION:{ics_escape(summary)}",
f"TRIGGER:-PT{int(item.get('remind_before_minutes', 0))}M",
"END:VALARM",
"END:VEVENT",
]
)
lines.append("END:VCALENDAR")
if event_count == 0:
raise gr.Error("目前沒有可匯出的提醒。")
path = Path(tempfile.gettempdir()) / f"NeilLog_project_calendar_{now_local():%Y%m%d_%H%M%S}.ics"
content = "\r\n".join(fold_ics_line(line) for line in lines) + "\r\n"
path.write_bytes(content.encode("utf-8"))
return str(path)
def call_hf_json(model, payload=None, file_path=None, timeout=90):
if not HF_TOKEN:
raise RuntimeError("尚未設定 HUGGINGFACEHUB_API_TOKEN 或 HF_TOKEN。")
if not model:
raise RuntimeError("尚未設定可用的模型名稱。")
url = f"{HF_API_URL}/{model}"
if file_path:
with open(file_path, "rb") as file:
response = requests.post(url, headers=HEADERS, data=file.read(), timeout=timeout)
else:
response = requests.post(url, headers=HEADERS, json=payload or {}, timeout=timeout)
response.raise_for_status()
return response.json()
def extract_generated_text(data):
if isinstance(data, list) and data:
return data[0].get("generated_text") or data[0].get("summary_text") or str(data[0])
if isinstance(data, dict):
return data.get("generated_text") or data.get("text") or str(data)
return str(data)
def ai_mode_text():
if HF_TOKEN and TEXT_MODEL:
return "完整 AI 模式:已啟用模型摘要與外部辨識"
if HF_TOKEN:
return "AI 轉錄模式:錄音/圖片/文件辨識可用,摘要使用本機規則"
return "免 Token 模式:手動逐字稿、摘要、待辦、提醒與風險標註可用"
def split_meeting_sentences(text):
parts = re.split(r"[\n。;;]+", str(text or ""))
return [part.strip(" -•\t") for part in parts if part.strip(" -•\t")]
def extract_datetime_from_text(text):
text = str(text or "")
patterns = [
r"\d{4}[/-]\d{1,2}[/-]\d{1,2}\s*(?:上午|早上|中午|下午|晚上)?\s*\d{0,2}(?:點|時|:\d{1,2})?",
r"\d{1,2}[/-]\d{1,2}\s*(?:上午|早上|中午|下午|晚上)?\s*\d{0,2}(?:點|時|:\d{1,2})?",
]
for pattern in patterns:
match = re.search(pattern, text)
if not match:
continue
value = match.group(0).strip()
if not re.match(r"\d{4}", value):
value = f"{now_local().year}/{value}"
try:
return normalize_task_datetime(value, "會議日期", allow_empty=False)
except ValueError:
continue
return ""
def local_meeting_minutes(case, title, participants, focus, transcript):
sentences = split_meeting_sentences(transcript)
summary_items = sentences[:5] or ["尚未提供足夠文字,請補充逐字稿或會議重點。"]
decision_items = [s for s in sentences if any(k in s for k in ("決議", "確認", "同意", "完成", "送審", "報價"))][:6]
risk_items = [s for s in sentences if any(k in s for k in ("風險", "延誤", "逾期", "問題", "未回", "缺"))][:6]
action_items = [s for s in sentences if any(k in s for k in ("請", "待辦", "追蹤", "確認", "完成", "提交", "回覆", "送審", "報價"))][:8]
lines = [
f"## {title}",
f"- 案場:{case.get('site_name')} / {case.get('location')}",
f"- 參與人員:{participants or '未填'}",
f"- 會議重點:{focus or '未填'}",
"",
"### 會議摘要",
*(f"- {item}" for item in summary_items),
"",
"### 重要事項 / 決議",
*(f"- {item}" for item in (decision_items or ["尚未偵測到明確決議。"])),
"",
"### 待辦清單",
*(f"- {item}" for item in (action_items or ["尚未偵測到明確待辦。"])),
"",
"### 風險與需追蹤事項",
*(f"- {item}" for item in (risk_items or ["尚未偵測到明確風險。"])),
"",
"### 下一步建議",
"- 請確認每個待辦的負責人與期限。",
"- 有日期的事項會自動建立提醒。",
]
return "\n".join(lines)
def infer_owner_from_text(text, participants):
match = re.search(r"(?:負責人|窗口|owner|Owner)\s*[::]\s*([^,,。\n]+)", str(text or ""))
if match:
return match.group(1).strip()
first = str(participants or "").replace("、", ",").replace(",", ",").split(",")[0].strip()
return first
def build_meeting_intelligence(case, title, participants, focus, transcript, minutes):
text = "\n".join([str(focus or ""), str(transcript or ""), str(minutes or "")])
sentences = split_meeting_sentences(text)
important_keywords = ("決議", "重要", "必須", "需要", "請", "待辦", "追蹤", "確認", "完成", "提交", "回覆", "期限", "風險", "報價", "送審", "會議", "現勘", "important", "confirm", "submit", "reply", "deadline", "follow")
action_keywords = ("請", "待辦", "追蹤", "確認", "完成", "提交", "回覆", "送審", "報價", "please", "todo", "follow", "confirm", "complete", "submit", "reply", "quote")
risk_keywords = ("風險", "延誤", "逾期", "卡住", "未回", "缺", "問題", "異常", "追加", "risk", "delay", "late", "issue", "missing", "not returned")
important = []
actions = []
risks = []
seen = set()
for sentence in sentences:
compact = sentence[:120]
if compact in seen:
continue
seen.add(compact)
lowered = sentence.lower()
if any(keyword in lowered for keyword in important_keywords):
important.append(compact)
if any(keyword in lowered for keyword in action_keywords):
actions.append(
{
"title": compact,
"owner": infer_owner_from_text(sentence, participants),
"due_at": extract_datetime_from_text(sentence),
}
)
if any(keyword in lowered for keyword in risk_keywords):
risks.append(compact)
if not important:
important = [sentence[:120] for sentence in sentences[:3]]
actions = actions[:6]
risks = risks[:5]
lines = [
"\n### 自動標註重點",
*(f"- {item}" for item in important[:8]),
"\n### 自動待辦與提醒",
]
if actions:
for item in actions:
due = item.get("due_at") or "未偵測日期"
owner = item.get("owner") or "未填負責人"
lines.append(f"- {item['title']}|負責人:{owner}|日期:{due}")
else:
lines.append("- 尚未偵測到明確待辦,請在待辦頁補充。")
lines.append("\n### 自動風險提示")
lines.extend([f"- {item}" for item in risks] or ["- 尚未偵測到明確風險。"])
return "\n".join(lines), actions, risks
def apply_meeting_intelligence(stored, actions, risks):
task_count = 0
reminder_count = 0
risk_count = 0
for item in actions:
title = item.get("title", "").strip()
if not title:
continue
task_id = str(uuid.uuid4())
stored.setdefault("tasks", []).append(
{
"id": task_id,
"title": title,
"owner": item.get("owner", ""),
"agreed_at": item.get("due_at", ""),
"due_at": item.get("due_at", ""),
"priority": "重要" if item.get("due_at") else "一般",
"status": "未開始",
"notes": "由會議轉錄自動建立",
"created_at": now_iso(),
"updated_at": now_iso(),
}
)
task_count += 1
start_iso = task_datetime_to_iso(item.get("due_at"))
if start_iso:
start_dt = dt.datetime.fromisoformat(start_iso)
stored.setdefault("reminders", []).append(
{
"id": str(uuid.uuid4()),
"title": f"會議待辦:{title[:40]}",
"type": "追蹤待辦",
"start_at": start_iso,
"end_at": (start_dt + dt.timedelta(minutes=30)).isoformat(),
"remind_before_minutes": 30,
"owner": item.get("owner", ""),
"notes": "由會議轉錄自動建立",
"created_at": now_iso(),
}
)
reminder_count += 1
for item in risks[:3]:
stored.setdefault("risks", []).append(
{
"id": str(uuid.uuid4()),
"title": item,
"level": "中",
"status": "觀察中",
"owner": "",
"mitigation": "由會議轉錄偵測,請確認處置方式。",
"created_at": now_iso(),
"updated_at": now_iso(),
}
)
risk_count += 1
return task_count, reminder_count, risk_count
def summarize_with_model(prompt, fallback):
if not TEXT_MODEL:
return fallback
try:
data = call_hf_json(TEXT_MODEL, {"inputs": prompt, "parameters": {"max_new_tokens": 700, "temperature": 0.2}})
return extract_generated_text(data)
except Exception as exc:
return f"{fallback}\n\n模型摘要暫時不可用:{exc}"
def transcribe_audio(audio_path):
if not audio_path:
return "", "未提供音檔,將使用手動逐字稿。"
if not HF_TOKEN:
return "", "免 Token 模式:錄音自動轉文字需要 HF Token;請貼上逐字稿,系統仍會自動摘要、建立待辦與提醒。"
try:
data = call_hf_json(TRANSCRIBE_MODEL, file_path=audio_path, timeout=180)
text = data.get("text", "") if isinstance(data, dict) else extract_generated_text(data)
text = str(text or "").strip()
if not text:
return "", "轉譯完成,但沒有取得文字內容。"
return text, "語音轉文字完成。"
except Exception as exc:
return "", f"語音轉文字暫時不可用:{exc}"
def meeting_rows(site=None):
rows = []
selected_case_id = None
if site:
case = get_case_by_label(site)
selected_case_id = case.get("id") if case else None
for case in load_cases():
if selected_case_id and case.get("id") != selected_case_id:
continue
for item in case.get("meetings", []):
rows.append(
{
"時間": item.get("recorded_at", ""),
"案場": case.get("site_name", ""),
"主題": item.get("title", ""),
"參與人員": item.get("participants", ""),
"重點": item.get("focus", ""),
"摘要": str(item.get("minutes", ""))[:160],
}
)
rows.sort(key=lambda row: row["時間"], reverse=True)
return pd.DataFrame(rows)
def add_meeting(site, title, participants, audio, focus, manual_transcript):
case = get_case_by_label(site)
if not case:
return "請先選擇案場。", "", "", meeting_rows(), build_hero_html(), case_rows(), upcoming_reminders(30), task_rows(only_open=True), gr.update(choices=task_options(), value=None), risk_rows(), gr.update(choices=risk_options(), value=None)
if not title:
title = f"會議紀錄 {now_local():%Y-%m-%d}"
transcript = str(manual_transcript or "").strip()
status_parts = []
if transcript:
status_parts.append("已使用手動逐字稿。")
elif audio:
transcript, transcribe_status = transcribe_audio(audio)
status_parts.append(transcribe_status)
else:
status_parts.append("請錄音、上傳音檔,或貼上逐字稿。")
if not transcript:
return " ".join(status_parts), "", "", meeting_rows(site), build_hero_html(), case_rows(), upcoming_reminders(30), task_rows(only_open=True), gr.update(choices=task_options(), value=None), risk_rows(site), gr.update(choices=risk_options(site), value=None)
fallback = local_meeting_minutes(case, title, participants, focus, transcript)
prompt = (
"請將以下專案會議逐字稿整理成繁體中文會議紀錄,像 Notion AI 會議摘要一樣清楚。格式請包含:\n"
"1. 會議重點摘要\n2. 重要事項\n3. 決議事項\n4. 待辦清單(負責人、日期/期限、狀態)\n5. 風險與需追蹤事項\n6. 下一步建議\n"
f"案場:{case.get('site_name')} / {case.get('location')}\n"
f"階段:{case.get('stage')}\n"
f"參與人員:{participants}\n"
f"補充重點:{focus}\n"
f"逐字稿:{transcript}"
)
minutes = summarize_with_model(prompt, fallback)
intelligence, actions, risks = build_meeting_intelligence(case, title, participants, focus, transcript, minutes)
minutes = f"{minutes}\n{intelligence}"
cases = load_cases()
task_count = reminder_count = risk_count = 0
selected_label = site
for stored in cases:
if stored["id"] == case["id"]:
task_count, reminder_count, risk_count = apply_meeting_intelligence(stored, actions, risks)
stored.setdefault("meetings", []).append(
{
"id": str(uuid.uuid4()),
"title": title,
"participants": participants or "",
"recorded_at": now_iso(),
"audio_path": audio or "",
"transcript": transcript,
"minutes": minutes,
"focus": focus or "",
"auto_tasks": task_count,
"auto_reminders": reminder_count,
"auto_risks": risk_count,
}
)
stored["updated_at"] = now_iso()
selected_label = site_label(stored)
break
save_cases(cases)
status_parts.append(f"已產生摘要並存入案場;自動建立 {task_count} 項待辦、{reminder_count} 筆提醒、{risk_count} 筆風險提示。")
return (
" ".join(status_parts),
transcript,
minutes,
meeting_rows(selected_label),
build_hero_html(),
case_rows(),
upcoming_reminders(30),
task_rows(selected_label),
gr.update(choices=task_options(selected_label), value=None),
risk_rows(selected_label),
gr.update(choices=risk_options(selected_label), value=None),
)
def case_summary(case):
return (
f"### {case.get('site_name', '未命名案場')}\n"
f"- 地點:{case.get('location', '')}\n"
f"- Google Maps:{google_maps_markdown(case.get('location', ''))}\n"
f"- 客戶:{case.get('client', '')}\n"
f"- 類別:{case.get('category', '')}\n"
f"- 階段:{case.get('stage', '')}\n"
f"- 金額/預算:{case.get('bid_amount', '')}\n"
f"- 標籤:{', '.join(case.get('tags', [])) or '無'}\n"
f"- 會議:{len(case.get('meetings', []))} 筆\n"
f"- 照片:{len(case.get('photo_history', []))} 筆\n"
f"- 文件:{len(case.get('ocr_documents', [])) + len(case.get('attachments', []))} 筆\n"
f"- 提醒:{len(case.get('reminders', []))} 筆\n"
f"- 未完成待辦:{sum(1 for item in case.get('tasks', []) if item.get('status') != '已完成')} 項\n"
f"- 未解除風險:{sum(1 for item in case.get('risks', []) if item.get('status') != '已解除')} 項\n"
f"- 已登錄支出:{money_text(sum(to_number(item.get('amount')) for item in case.get('expenses', [])))}\n"
f"- 管理備註:{case.get('manager_notes', '') or '未填'}"
)
def get_dashboard(site):
case = get_case_by_label(site)
if not case:
return "請先選擇案場。"
latest_meeting = case.get("meetings", [])[-1].get("minutes", "尚無會議紀錄。") if case.get("meetings") else "尚無會議紀錄。"
return (
f"{case_summary(case)}\n\n"
"### PM 檢核\n"
"- 確認下一個里程碑、負責人與期限是否清楚。\n"
"- 檢查投標/預算文件是否已歸檔。\n"
"- 對未更新超過 7 天的案場安排追蹤。\n\n"
f"### 最新會議\n{latest_meeting}"
)
def get_case_detail(site):
case = get_case_by_label(site)
if not case:
return "請先選擇案場。"
lines = [case_summary(case), "", "### 附件"]
attachments = case.get("attachments", [])
if not attachments:
lines.append("尚無附件。")
for item in attachments[:20]:
lines.append(f"- {item.get('name', Path(item.get('path', '')).name)}")
if len(attachments) > 20:
lines.append(f"- 另有 {len(attachments) - 20} 筆附件未顯示")
lines.append("\n### 會議紀錄")
for meeting in case.get("meetings", [])[-5:]:
lines.append(f"- {meeting.get('recorded_at', '')}{meeting.get('title', '')}{meeting.get('participants', '')}")
if not case.get("meetings"):
lines.append("尚無會議紀錄。")
lines.append("\n### 行事曆提醒")
for reminder in case.get("reminders", [])[-8:]:
lines.append(f"- {reminder.get('start_at', '')[:16].replace('T', ' ')}{reminder.get('type', '')}{reminder.get('title', '')}")
if not case.get("reminders"):
lines.append("尚無提醒。")
lines.append("\n### 待辦任務")
for task in case.get("tasks", [])[-10:]:
agreed = task.get("agreed_at") or "未填約定"
due = task.get("due_at") or "未填期限"
lines.append(f"- {task.get('status', '')}{task.get('priority', '')}|約定:{agreed}|期限:{due}{task.get('owner', '')}{task.get('title', '')}")
if not case.get("tasks"):
lines.append("尚無待辦。")
lines.append("\n### 風險控管")
for risk in case.get("risks", [])[-10:]:
lines.append(f"- {risk.get('level', '')}{risk.get('status', '')}{risk.get('title', '')}{risk.get('mitigation', '')}")
if not case.get("risks"):
lines.append("尚無風險紀錄。")
lines.append("\n### 預算支出")
for expense in case.get("expenses", [])[-10:]:
lines.append(f"- {expense.get('date', '')}{expense.get('type', '')}{expense.get('title', '')}{money_text(expense.get('amount', ''))}")
if not case.get("expenses"):
lines.append("尚無支出紀錄。")
return "\n".join(lines)
def scan_photo(site, image_path, notes):
case = get_case_by_label(site)
if not case:
return "請先選擇案場。", "", pd.DataFrame([]), [], build_hero_html(), case_rows()
if not image_path:
return "請先上傳照片。", "", photo_history(site), gallery(site), build_hero_html(), case_rows()
if not HF_TOKEN:
caption = f"免 Token 模式:照片已儲存,請以備註作為現場紀錄。檔名:{Path(image_path).name}"
else:
try:
caption_data = call_hf_json(IMAGE_CAPTION_MODEL, file_path=image_path)
caption = extract_generated_text(caption_data)
except Exception as exc:
caption = f"照片辨識暫時不可用,已改用備註整理:{exc}"
fallback = (
f"照片已加入 {case.get('site_name')}。\n"
f"- 系統描述:{caption}\n"
f"- 現場備註:{notes or '未填'}\n"
"- 建議:補充拍攝位置、缺失項目、改善期限。"
)
prompt = (
"請根據案場照片描述與現場備註,整理繁體中文現場紀錄、可能風險與追蹤事項。\n"
f"案場:{case.get('site_name')}\n照片描述:{caption}\n備註:{notes}"
)
summary = summarize_with_model(prompt, fallback)
save_photo_record(case["id"], image_path, caption, summary, notes)
return caption, summary, photo_history(site), gallery(site), build_hero_html(), case_rows()
def save_photo_record(case_id, image_path, caption, summary, notes):
cases = load_cases()
for case in cases:
if case["id"] == case_id:
case.setdefault("photo_history", []).append(
{
"id": str(uuid.uuid4()),
"timestamp": now_iso(),
"image_path": image_path,
"caption": caption,
"summary": summary,
"notes": notes or "",
}
)
case["updated_at"] = now_iso()
break
save_cases(cases)
def scan_document(site, image_path, notes):
case = get_case_by_label(site)
if not case:
return "請先選擇案場。", "", "", pd.DataFrame([]), build_hero_html(), case_rows()
if not image_path:
return "請先上傳文件影像。", "", "", ocr_history(site), build_hero_html(), case_rows()
if not HF_TOKEN:
ocr_text = str(notes or "").strip() or f"免 Token 模式:文件已儲存,請在文件備註貼上文字或摘要。檔名:{Path(image_path).name}"
else:
try:
ocr_data = call_hf_json(OCR_MODEL, file_path=image_path)
ocr_text = extract_generated_text(ocr_data)
except Exception as exc:
ocr_text = f"文件辨識暫時不可用,已改用備註整理:{exc}"
fallback = (
f"文件已加入 {case.get('site_name')}。\n"
f"- 辨識文字:{ocr_text[:300]}\n"
f"- 備註:{notes or '未填'}\n"
"- 建議:確認文件類型、日期、金額與簽核狀態。"
)
prompt = (
"請將以下文件辨識文字整理成專案文件摘要,標出日期、金額、待辦與風險。\n"
f"案場:{case.get('site_name')}\n辨識文字:{ocr_text}\n備註:{notes}"
)
summary = summarize_with_model(prompt, fallback)
save_ocr_record(case["id"], image_path, ocr_text, summary, notes)
return "已完成文件紀錄。", ocr_text, summary, ocr_history(site), build_hero_html(), case_rows()
def save_ocr_record(case_id, image_path, ocr_text, summary, notes):
cases = load_cases()
for case in cases:
if case["id"] == case_id:
case.setdefault("ocr_documents", []).append(
{
"id": str(uuid.uuid4()),
"timestamp": now_iso(),
"image_path": image_path,
"ocr_text": ocr_text,
"summary": summary,
"notes": notes or "",
}
)
case["updated_at"] = now_iso()
break
save_cases(cases)
def photo_history(site):
case = get_case_by_label(site)
rows = []
for item in (case or {}).get("photo_history", []):
rows.append({"時間": item.get("timestamp", ""), "照片": item.get("image_path", ""), "描述": item.get("caption", ""), "摘要": item.get("summary", "")})
return pd.DataFrame(rows)
def ocr_history(site):
case = get_case_by_label(site)
rows = []
for item in (case or {}).get("ocr_documents", []):
text = item.get("ocr_text", "")
rows.append({"時間": item.get("timestamp", ""), "文件": item.get("image_path", ""), "辨識文字": text[:120], "摘要": item.get("summary", "")})
return pd.DataFrame(rows)
def gallery(site):
case = get_case_by_label(site)
return [[item.get("image_path"), item.get("caption", "")] for item in (case or {}).get("photo_history", [])]
def search_ocr(query):
query = str(query or "").strip().lower()
if not query:
return pd.DataFrame([])
rows = []
for case in load_cases():
for item in case.get("ocr_documents", []):
haystack = " ".join([item.get("ocr_text", ""), item.get("summary", ""), item.get("notes", "")]).lower()
if query in haystack:
rows.append(
{
"案場": case.get("site_name", ""),
"時間": item.get("timestamp", ""),
"文件": item.get("image_path", ""),
"摘要": item.get("summary", ""),
}
)
return pd.DataFrame(rows)
def generate_gantt(site):
if not HAS_MATPLOTLIB:
return None
case = get_case_by_label(site)
if not case:
return None
font_path = find_font()
font_props = None
if font_path:
from matplotlib import font_manager
font_props = font_manager.FontProperties(fname=font_path)
created = parse_iso_local(case.get("created_at", now_iso()))
tasks = []
if case.get("meetings"):
for meeting in case["meetings"]:
start = parse_iso_local(meeting.get("recorded_at", now_iso()))
tasks.append((meeting.get("title", "會議"), start, start + dt.timedelta(days=1)))
else:
tasks = [
("前期評估", created, created + dt.timedelta(days=7)),
("投標/預算", created + dt.timedelta(days=7), created + dt.timedelta(days=21)),
("設計與文件", created + dt.timedelta(days=21), created + dt.timedelta(days=45)),
("施工追蹤", created + dt.timedelta(days=45), created + dt.timedelta(days=75)),
("驗收結案", created + dt.timedelta(days=75), created + dt.timedelta(days=90)),
]
fig, ax = plt.subplots(figsize=(10, max(4, len(tasks) * 0.65)))
for idx, (name, start, end) in enumerate(tasks):
ax.barh(idx, mdates.date2num(end) - mdates.date2num(start), left=mdates.date2num(start), height=0.5)
ax.set_yticks(range(len(tasks)))
ax.set_yticklabels([task[0] for task in tasks], fontproperties=font_props)
ax.xaxis_date()
ax.xaxis.set_major_formatter(mdates.DateFormatter("%m-%d"))
ax.set_title(case.get("site_name", "案場進度"), fontproperties=font_props)
ax.grid(axis="x", alpha=0.25)
fig.tight_layout()
path = Path(tempfile.gettempdir()) / f"gantt_{case['id']}.png"
fig.savefig(path, dpi=160, bbox_inches="tight")
plt.close(fig)
return str(path)
def safe_filename(value):
cleaned = "".join(ch for ch in str(value) if ch.isalnum() or ch in (" ", "-", "_")).strip()
return cleaned or "case"
def find_font():
candidates = [
"/usr/share/fonts/truetype/noto/NotoSansCJKtc-Regular.otf",
"/usr/share/fonts/opentype/noto/NotoSansCJKtc-Regular.otf",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.otf",
"C:/Windows/Fonts/kaiu.ttf",
"C:/Windows/Fonts/NotoSansTC-VF.ttf",
"C:/Windows/Fonts/NotoSansHK-VF.ttf",
"C:/Windows/Fonts/msjh.ttc",
"C:/Windows/Fonts/mingliu.ttc",
]
return next((path for path in candidates if Path(path).exists() and Path(path).suffix.lower() in {".ttf", ".otf"}), None)
def pdf_safe_text(text, has_unicode_font):
text = str(text)
if has_unicode_font:
return text
return text.encode("latin-1", errors="replace").decode("latin-1")
def export_report(site):
case = get_case_by_label(site)
if not case:
raise gr.Error("請先選擇案場。")
stamp = now_local().strftime("%Y%m%d_%H%M%S")
base = safe_filename(case.get("site_name", "case"))
pdf_path = Path(tempfile.gettempdir()) / f"{base}_{stamp}.pdf"
excel_path = Path(tempfile.gettempdir()) / f"{base}_{stamp}.xlsx"
pdf_output = None
if HAS_FPDF:
pdf = FPDF()
pdf.set_auto_page_break(auto=True, margin=15)
pdf.add_page()
font = find_font()
if font:
with warnings.catch_warnings():
warnings.simplefilter("ignore")
pdf.add_font("CJK", "", font, uni=True)
pdf.set_font("CJK", size=14)
else:
pdf.set_font("Arial", size=12)
has_unicode_font = bool(font)
pdf.multi_cell(0, 8, pdf_safe_text(f"{APP_TITLE}\n\n{case_summary(case).replace('### ', '')}", has_unicode_font))
pdf.ln(4)
pdf.multi_cell(0, 8, pdf_safe_text("近期會議", has_unicode_font))
for meeting in case.get("meetings", []):
pdf.multi_cell(0, 7, pdf_safe_text(f"- {meeting.get('recorded_at', '')} {meeting.get('title', '')}\n{meeting.get('minutes', '')[:500]}", has_unicode_font))
pdf.ln(4)
pdf.multi_cell(0, 8, pdf_safe_text("待辦任務", has_unicode_font))
for task in case.get("tasks", []):
pdf.multi_cell(0, 7, pdf_safe_text(f"- {task.get('status', '')} {task.get('priority', '')} 約定:{task.get('agreed_at', '')} 期限:{task.get('due_at', '')} {task.get('title', '')}", has_unicode_font))
pdf.ln(4)
pdf.multi_cell(0, 8, pdf_safe_text("風險控管", has_unicode_font))
for risk in case.get("risks", []):
pdf.multi_cell(0, 7, pdf_safe_text(f"- {risk.get('level', '')} {risk.get('status', '')} {risk.get('title', '')}{risk.get('mitigation', '')}", has_unicode_font))
pdf.output(str(pdf_path))
pdf_output = str(pdf_path)
sheets = {
"案場": pd.DataFrame([case]).drop(columns=["meetings", "photo_history", "ocr_documents", "attachments", "reminders", "tasks", "risks", "expenses"], errors="ignore"),
"會議": pd.DataFrame(case.get("meetings", [])),
"照片": pd.DataFrame(case.get("photo_history", [])),
"文件": pd.DataFrame(case.get("ocr_documents", [])),
"附件": pd.DataFrame(case.get("attachments", [])),
"提醒": pd.DataFrame(case.get("reminders", [])),
"待辦": pd.DataFrame(case.get("tasks", [])),
"風險": pd.DataFrame(case.get("risks", [])),
"支出": pd.DataFrame(case.get("expenses", [])),
}
try:
with pd.ExcelWriter(excel_path) as writer:
for sheet_name, frame in sheets.items():
frame.to_excel(writer, index=False, sheet_name=sheet_name)
data_output = str(excel_path)
except Exception:
zip_path = Path(tempfile.gettempdir()) / f"{base}_{stamp}_csv.zip"
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as archive:
for sheet_name, frame in sheets.items():
archive.writestr(f"{sheet_name}.csv", frame.to_csv(index=False).encode("utf-8-sig"))
data_output = str(zip_path)
return pdf_output, data_output
def verify_login(user, password, key):
key_ok = not APP_ACCESS_KEY or key == APP_ACCESS_KEY
password_ok = not LOGIN_PASSWORD or password == LOGIN_PASSWORD
if user == LOGIN_USER and password_ok and key_ok:
return "登入成功。", gr.update(visible=False), gr.update(visible=True)
return "登入失敗,請確認帳號、密碼或存取金鑰。", gr.update(visible=True), gr.update(visible=False)
def format_now_zh():
now = now_local()
return f"{now.year}{now.month}{now.day}{now.hour:02d}:{now.minute:02d}:{now.second:02d}(台灣時間)"
def today_progress_report(cases):
today = today_local()
due_today = []
overdue = []
task_due_today = []
task_overdue = []
updated_today = []
meetings_today = []
documents_total = 0
photos_total = 0
open_tasks = 0
high_risks = 0
budget_total = 0
expense_total = 0
for case in cases:
documents_total += len(case.get("ocr_documents", [])) + len(case.get("attachments", []))
photos_total += len(case.get("photo_history", []))
budget_total += to_number(case.get("bid_amount"))
expense_total += sum(to_number(item.get("amount")) for item in case.get("expenses", []))
open_tasks += sum(1 for item in case.get("tasks", []) if item.get("status") != "已完成")
high_risks += sum(1 for item in case.get("risks", []) if item.get("level") == "高" and item.get("status") != "已解除")
try:
if case.get("updated_at") and parse_iso_local(case["updated_at"]).date() == today:
updated_today.append(case.get("site_name", "未命名案場"))
except ValueError:
pass
for meeting in case.get("meetings", []):
try:
if parse_iso_local(meeting.get("recorded_at", "")).date() == today:
meetings_today.append(f"{case.get('site_name', '')}{meeting.get('title', '會議')}")
except ValueError:
pass
for item in case.get("reminders", []):
try:
start = parse_iso_local(item.get("start_at", ""))
except ValueError:
continue
label = f"{case.get('site_name', '')}{item.get('title', '')}"
if start.date() == today:
due_today.append(label)
elif start < now_local():
overdue.append(label)
for item in case.get("tasks", []):
if item.get("status") == "已完成" or not item.get("due_at"):
continue
try:
due = dt.datetime.strptime(item.get("due_at", "")[:10], "%Y-%m-%d").date()
except ValueError:
continue
label = f"{case.get('site_name', '')}{item.get('title', '')}"
if due == today:
task_due_today.append(label)
elif due < today:
task_overdue.append(label)
if not cases:
summary = "目前尚無案場資料。請先新增案場,系統會自動把案場、提醒、會議、文件彙整到這裡。"
elif due_today or task_due_today:
summary = f"今日有 {len(due_today)} 項提醒、{len(task_due_today)} 項待辦需要處理,請優先確認負責人與期限。"
elif overdue or task_overdue:
summary = f"目前有 {len(overdue)} 項逾期提醒、{len(task_overdue)} 項逾期待辦,建議先完成追蹤。"
elif high_risks:
summary = f"目前有 {high_risks} 項高風險案場,請確認處置策略與負責人。"
else:
summary = "今日沒有到期提醒,請維持案場資料更新並檢查下週節點。"
return {
"summary": summary,
"due_today": due_today,
"overdue": overdue,
"task_due_today": task_due_today,
"task_overdue": task_overdue,
"updated_today": updated_today,
"meetings_today": meetings_today,
"documents_total": documents_total,
"photos_total": photos_total,
"open_tasks": open_tasks,
"high_risks": high_risks,
"budget_total": budget_total,
"expense_total": expense_total,
}
def build_action_cards(case_count, reminder_count, document_count, photo_count, open_tasks, high_risks):
cards = [
("案場優先", f"{case_count} 件案場", "整理階段、客戶與預算資訊", "teal"),
("今日提醒", f"{reminder_count} 項提醒", "同步手機與平板行事曆", "amber"),
("待辦任務", f"{open_tasks} 項未完成", "確認期限、優先級與負責人", "violet"),
("風險控管", f"{high_risks} 項高風險", "追蹤處置策略與解除狀態", "rose"),
]
return "".join(
f"""
<div class="action-card {tone}">
<div class="card-arrow">→</div>
<strong>{title}</strong>
<span>{value}</span>
<p>{desc}</p>
</div>
"""
for title, value, desc, tone in cards
)
def system_health_report():
checks = []
issues = []
try:
load_cases()
checks.append(f"資料檔正常:{storage_mode()}")
checks.append(f"紀錄時間:{APP_TIMEZONE}")
except Exception as exc:
issues.append(f"資料檔異常:{exc}")
checks.append(ai_mode_text())
if HAS_MATPLOTLIB:
checks.append("甘特圖套件正常")
else:
issues.append("甘特圖套件未啟用")
if HAS_FPDF:
checks.append("PDF 套件正常")
else:
checks.append("PDF 套件未啟用,報表會自動改用資料檔")
status = "系統健康" if not issues else f"自動偵錯:{len(issues)} 項注意"
detail_parts = checks[:3] + issues
detail = ";".join(detail_parts)
return status, detail
def build_hero_html():
cases = load_cases()
case_count = len(cases)
completion = 0
if cases:
filled = 0
total = case_count * 4
for case in cases:
filled += bool(case.get("site_name"))
filled += bool(case.get("location"))
filled += bool(case.get("stage"))
filled += bool(case.get("category"))
completion = round(filled / total * 100)
report = today_progress_report(cases)
due_count = len(report["due_today"])
overdue_count = len(report["overdue"])
task_due_count = len(report["task_due_today"])
budget_ratio = round(report["expense_total"] / report["budget_total"] * 100) if report["budget_total"] > 0 else 0
health_text, health_detail = system_health_report()
return f"""
<section class="command-dashboard">
<header class="command-header">
<div>
<span>Neil您好</span>
<strong>{format_now_zh()}</strong>
</div>
<div class="command-status"><i></i>{health_text}</div>
</header>
<div class="health-line">{health_detail}</div>
<div class="command-summary">
<div class="summary-copy">
<b>今日進度報告與彙整</b>
<p>{report["summary"]}</p>
</div>
<div class="summary-number">
<span>今日關注</span>
<strong>{due_count + task_due_count}</strong>
</div>
</div>
<div class="command-kpis">
<div><span>案場</span><strong>{case_count}</strong></div>
<div><span>今日提醒</span><strong>{due_count}</strong></div>
<div><span>今日待辦</span><strong>{task_due_count}</strong></div>
<div><span>逾期追蹤</span><strong>{overdue_count + len(report['task_overdue'])}</strong></div>
<div><span>未完成待辦</span><strong>{report["open_tasks"]}</strong></div>
<div><span>高風險</span><strong>{report["high_risks"]}</strong></div>
<div><span>資料完成</span><strong>{completion}%</strong></div>
<div><span>預算使用</span><strong>{budget_ratio}%</strong></div>
</div>
</section>
"""
CSS = """
:root {
--navy-950: #040b16;
--navy-900: #071327;
--navy-800: #0b2342;
--navy-700: #0f3767;
--cyan-400: #55d7ff;
--cyan-500: #25a9e8;
--gold-400: #eba020;
--gold-200: #ffe7ad;
--cream: #fff3d8;
--ink: #f8fbff;
--muted: #8ea0b8;
--glass: rgba(17, 43, 77, 0.74);
--glass-2: rgba(255, 255, 255, 0.10);
--line: rgba(142, 160, 184, 0.26);
}
html, body, .gradio-container, .main, gradio-app {
background:
radial-gradient(circle at 8% 0%, rgba(85, 215, 255, 0.18), transparent 30%),
radial-gradient(circle at 88% 8%, rgba(235, 160, 32, 0.13), transparent 28%),
linear-gradient(135deg, #10233b 0%, #101420 48%, #1b2238 100%) !important;
color: var(--ink) !important;
}
.gradio-container {
max-width: 1440px !important;
padding: 10px 18px 32px !important;
}
.app-window {
margin-top: 22px;
border-radius: 28px 28px 10px 10px;
overflow: hidden;
border: 1px solid rgba(105, 130, 178, 0.38);
background: linear-gradient(145deg, rgba(9,19,39,0.96), rgba(9,28,55,0.88));
box-shadow: 0 34px 90px rgba(0,0,0,0.45), inset 0 1px 0 rgba(255,255,255,0.10);
}
.window-bar {
height: 58px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 28px;
background: #050c18;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.window-dots { display: flex; gap: 10px; }
.dot { width: 11px; height: 11px; border-radius: 999px; display: block; }
.dot.red { background: #ff6b65; }
.dot.yellow { background: #ffc861; }
.dot.green { background: #66e2bf; }
.brand-mark {
color: rgba(248,251,255,0.42);
font-size: 12px;
font-weight: 800;
letter-spacing: 4px;
}
.visual-frame {
min-height: 184px;
display: grid;
grid-template-columns: 72px 1fr;
background:
linear-gradient(90deg, rgba(52,119,184,0.16), transparent 28%),
linear-gradient(135deg, #0d2b50 0%, #061327 58%, #05101f 100%);
}
.side-rail {
border-right: 1px solid rgba(255,255,255,0.07);
background: linear-gradient(180deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02));
display: flex;
flex-direction: column;
align-items: center;
padding-top: 28px;
gap: 22px;
}
.rail-logo {
width: 42px;
height: 42px;
border-radius: 10px;
background: linear-gradient(135deg, #71e0ff, #28a9f0);
color: #061327;
display: flex;
align-items: center;
justify-content: center;
font-weight: 900;
}
.rail-line { width: 28px; height: 6px; border-radius: 99px; background: rgba(142,160,184,0.42); }
.hero-board {
padding: 34px 38px 36px;
}
.hero-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 20px;
}
.hero-title {
margin: 0;
color: #f8fbff;
font-size: 24px;
font-weight: 900;
}
.live-pill {
padding: 8px 16px;
border-radius: 999px;
background: rgba(39, 223, 183, 0.15);
color: #65f2ce;
font-size: 12px;
font-weight: 900;
letter-spacing: 1px;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(3, minmax(150px, 1fr));
gap: 14px;
margin-top: 24px;
}
.metric-card {
min-height: 96px;
border-radius: 14px;
padding: 18px;
border: 1px solid rgba(142,160,184,0.18);
background: linear-gradient(145deg, rgba(255,255,255,0.10), rgba(255,255,255,0.04));
}
.metric-card span { color: #8ea0b8; font-size: 12px; font-weight: 800; }
.metric-card strong { display: block; color: #f8fbff; font-size: 38px; margin-top: 8px; line-height: 1; }
.bar-card {
margin-top: 26px;
border-radius: 14px;
background: rgba(8, 43, 82, 0.66);
padding: 28px 20px 20px;
}
.bar-chart {
height: 96px;
display: flex;
align-items: end;
gap: 12px;
}
.bar-chart i {
flex: 1;
min-width: 36px;
border-radius: 7px 7px 0 0;
background: linear-gradient(180deg, #63dcff 0%, #1974c1 100%);
box-shadow: 0 -8px 18px rgba(85,215,255,0.16);
}
.pm-shell {
gap: 18px !important;
padding: 18px 0 0 !important;
}
.pm-title, .pm-panel, .block, .form, .panel, .wrap {
border-radius: 14px !important;
}
.pm-title, .pm-panel {
border: 1px solid rgba(105, 130, 178, 0.26) !important;
background:
linear-gradient(145deg, rgba(255,255,255,0.11), rgba(255,255,255,0.04)),
rgba(8, 22, 43, 0.76) !important;
box-shadow: 0 22px 54px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255,255,255,0.12) !important;
backdrop-filter: blur(18px) saturate(130%) !important;
-webkit-backdrop-filter: blur(18px) saturate(130%) !important;
}
.pm-title { padding: 20px 24px !important; display: none !important; }
.pm-panel { padding: 18px !important; }
.pm-title h1, h1, h2, h3, label, .label-wrap span, p, li {
color: var(--ink) !important;
letter-spacing: 0 !important;
}
.pm-title h1 { color: var(--gold-200) !important; margin-bottom: 2px !important; }
.pm-title p { color: #dceaff !important; margin-top: 0 !important; }
.block, .form, .panel, .wrap {
background: rgba(255, 255, 255, 0.07) !important;
border-color: rgba(142, 160, 184, 0.18) !important;
box-shadow: none !important;
}
input, textarea, select, .dropdown, .secondary-wrap {
background: rgba(246, 250, 255, 0.94) !important;
color: var(--navy-950) !important;
border-color: rgba(105, 130, 178, 0.28) !important;
border-radius: 12px !important;
}
button, .gr-button {
border-radius: 12px !important;
border: 1px solid rgba(85, 215, 255, 0.28) !important;
background: linear-gradient(180deg, #63dcff 0%, #2d91d6 48%, #155ca4 100%) !important;
color: #061327 !important;
font-weight: 800 !important;
box-shadow: 0 14px 28px rgba(0, 0, 0, 0.24), inset 0 1px 0 rgba(255,255,255,0.42) !important;
}
button:hover, .gr-button:hover { filter: brightness(1.08) !important; transform: translateY(-1px); }
.tab-nav, .tabs, .tabitem { background: transparent !important; border-color: rgba(142,160,184,0.18) !important; }
.tab-nav button, [role="tab"] {
background: transparent !important;
color: #eff6ff !important;
border: 0 !important;
box-shadow: none !important;
border-radius: 0 !important;
}
.tab-nav button.selected, [role="tab"][aria-selected="true"] {
color: var(--gold-200) !important;
background: transparent !important;
border-bottom: 3px solid var(--gold-400) !important;
}
table {
background: rgba(255, 255, 255, 0.95) !important;
color: #071327 !important;
}
th {
background: #071327 !important;
color: #55d7ff !important;
}
td { color: #071327 !important; }
.prose, .markdown, .gradio-markdown { color: var(--ink) !important; }
.erp-shell {
min-height: 680px;
display: grid;
grid-template-columns: 248px 1fr;
overflow: hidden;
border-radius: 28px;
background: #f8f0e1;
border: 1px solid rgba(255,255,255,0.22);
box-shadow: 0 34px 90px rgba(0, 0, 0, 0.34);
}
.erp-sidebar {
padding: 28px 22px;
background:
linear-gradient(180deg, rgba(21, 60, 97, 0.20), transparent 34%),
linear-gradient(180deg, #2a4760 0%, #28304f 45%, #2d223e 100%);
color: #fff;
display: flex;
flex-direction: column;
gap: 22px;
}
.studio-mark {
width: 72px;
height: 72px;
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 36px;
font-weight: 900;
color: #172138;
background: radial-gradient(circle at 30% 20%, #9be8ff 0%, #3baeea 44%, #18395f 100%);
box-shadow: 0 18px 40px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.45);
}
.brand-block span {
display: block;
color: #ffd978;
font-weight: 900;
letter-spacing: 3px;
font-size: 13px;
}
.brand-block strong {
display: block;
margin-top: 8px;
color: #fff;
font-size: 26px;
line-height: 1.05;
}
.brand-block small {
display: block;
margin-top: 10px;
color: rgba(255,255,255,0.58);
font-size: 14px;
font-weight: 800;
}
.side-menu {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 12px;
}
.side-menu .menu-title {
color: rgba(255,255,255,0.42);
font-weight: 900;
font-size: 14px;
}
.side-menu b {
display: block;
padding: 13px 14px;
border-radius: 12px;
color: rgba(255,255,255,0.86);
background: rgba(255,255,255,0.08);
font-size: 15px;
}
.side-menu b:first-of-type {
color: #fff;
background: rgba(255,255,255,0.14);
border: 1px solid rgba(255,255,255,0.12);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.15);
}
.online-card {
margin-top: auto;
border-radius: 16px;
padding: 14px;
background: rgba(255,255,255,0.12);
border: 1px solid rgba(255,255,255,0.12);
}
.online-card strong {
display: block;
color: #fff;
font-size: 18px;
}
.online-card span {
display: block;
color: rgba(255,255,255,0.62);
margin-top: 6px;
}
.erp-main {
padding: 34px 40px 36px;
background:
radial-gradient(circle at 100% 8%, rgba(255, 214, 132, 0.32), transparent 22%),
linear-gradient(180deg, #fff7e7 0%, #f5ead9 100%);
color: #2f2330;
}
.topline {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 22px;
border-bottom: 1px solid rgba(73, 54, 72, 0.12);
}
.topline .hello {
display: block;
color: #e45b7e;
font-weight: 900;
letter-spacing: 4px;
font-size: 13px;
}
.topline strong {
display: block;
margin-top: 8px;
color: #2f2330;
font-size: 26px;
}
.health {
display: flex;
align-items: center;
gap: 10px;
color: rgba(47,35,48,0.72);
font-weight: 900;
}
.health i {
width: 10px;
height: 10px;
border-radius: 99px;
background: #58d0a6;
box-shadow: 0 0 0 8px rgba(88,208,166,0.16);
}
.pulse-card {
min-height: 310px;
margin-top: 24px;
border-radius: 28px;
padding: 42px 46px;
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(280px, 0.85fr);
gap: 28px;
overflow: hidden;
background:
radial-gradient(circle at 100% 0%, rgba(85, 215, 255, 0.18), transparent 30%),
radial-gradient(circle at 0% 100%, rgba(255, 216, 120, 0.16), transparent 32%),
linear-gradient(135deg, #233a5a 0%, #17304f 48%, #33233e 100%);
color: #fff;
box-shadow: 0 26px 70px rgba(80, 55, 45, 0.26);
}
.pulse-copy span {
color: #ffd36d !important;
font-weight: 900;
letter-spacing: 4px;
font-size: 14px;
}
.pulse-copy h1 {
margin: 16px 0 18px !important;
color: #fff !important;
font-size: clamp(32px, 4.2vw, 54px);
line-height: 1.12;
max-width: 720px;
}
.pulse-copy h1 em {
color: #69d8ff;
font-style: normal;
}
.pulse-copy p {
color: rgba(255,255,255,0.86) !important;
font-size: 18px;
line-height: 1.7;
max-width: 620px;
}
.hero-tags {
display: flex;
gap: 10px;
margin-top: 28px;
flex-wrap: wrap;
}
.hero-tags b {
display: inline-flex;
align-items: center;
min-height: 34px;
padding: 8px 13px;
border-radius: 999px;
color: #f8fbff;
background: rgba(255,255,255,0.12);
border: 1px solid rgba(255,255,255,0.16);
font-size: 13px;
font-weight: 900;
}
.focus-panel {
align-self: stretch;
border-radius: 24px;
padding: 26px;
background: rgba(255,255,255,0.13);
border: 1px solid rgba(255,255,255,0.20);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.18);
backdrop-filter: blur(14px);
}
.focus-head {
display: flex;
align-items: end;
justify-content: space-between;
gap: 18px;
padding-bottom: 22px;
border-bottom: 1px solid rgba(255,255,255,0.16);
}
.focus-head span {
color: rgba(255,255,255,0.76);
font-weight: 900;
}
.focus-head strong {
color: #ffd36d;
font-size: 76px;
line-height: 0.86;
}
.focus-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-top: 22px;
}
.focus-list div {
border-radius: 16px;
padding: 16px;
background: rgba(255,255,255,0.12);
border: 1px solid rgba(255,255,255,0.12);
}
.focus-list span {
display: block;
color: rgba(255,255,255,0.72);
font-size: 13px;
font-weight: 900;
}
.focus-list strong {
display: block;
margin-top: 8px;
color: #fff;
font-size: 30px;
}
.report-strip {
margin-top: 20px;
padding: 16px;
border-radius: 18px;
background: rgba(255,255,255,0.84);
border: 1px solid rgba(42,71,96,0.12);
box-shadow: 0 14px 30px rgba(48, 43, 56, 0.08);
}
.report-strip ul {
list-style: none;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
padding: 0;
margin: 0;
}
.report-strip li {
color: #2e3a4d !important;
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
font-weight: 900;
min-height: 48px;
padding: 0 12px;
border-radius: 12px;
background: rgba(246, 249, 252, 0.84);
}
.report-strip strong { color: #1468a8; font-size: 18px; }
.action-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
margin-top: 20px;
}
.action-card {
min-height: 132px;
border-radius: 18px;
padding: 20px;
color: #263247;
overflow: hidden;
position: relative;
border: 1px solid rgba(42,71,96,0.12);
box-shadow: 0 14px 30px rgba(48, 43, 56, 0.10);
}
.action-card strong {
display: block;
margin-top: 12px;
font-size: 20px;
color: #17243a;
}
.action-card span, .action-card p {
display: block;
color: rgba(38,50,71,0.72) !important;
margin: 7px 0 0;
}
.card-arrow {
width: 34px;
height: 34px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
background: #1268a9;
font-size: 22px;
}
.action-card.teal { background: linear-gradient(145deg, #eefbf8, #d5f3ee); }
.action-card.amber { background: linear-gradient(145deg, #fff7df, #f8e5ad); }
.action-card.violet { background: linear-gradient(145deg, #f0f0ff, #dadcfb); }
.action-card.rose { background: linear-gradient(145deg, #fff0f4, #f8d9e3); }
.data-strip {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
margin-top: 20px;
}
.data-strip div {
border-radius: 16px;
padding: 18px;
background: rgba(255,255,255,0.86);
border: 1px solid rgba(42,71,96,0.12);
box-shadow: 0 14px 30px rgba(48, 43, 56, 0.08);
}
.data-strip span {
display: block;
color: rgba(38,50,71,0.62);
font-weight: 900;
}
.data-strip strong {
display: block;
margin-top: 8px;
color: #153d64;
font-size: 32px;
}
.pm-shell {
background: #f5ead9;
padding: 24px 0 0 !important;
}
.pm-panel {
background: rgba(255,255,255,0.88) !important;
color: #2f2330 !important;
border: 1px solid rgba(42,71,96,0.12) !important;
box-shadow: 0 16px 36px rgba(48,43,56,0.09) !important;
}
.pm-panel label, .pm-panel p, .pm-panel li, .pm-panel h1, .pm-panel h2, .pm-panel h3 {
color: #182235 !important;
}
.pm-shell, .pm-shell .block, .pm-shell .form, .pm-shell .panel, .pm-shell .wrap {
color: #182235 !important;
}
.pm-shell h1, .pm-shell h2, .pm-shell h3, .pm-shell h4,
.pm-shell p, .pm-shell li, .pm-shell label,
.pm-shell .label-wrap span, .pm-shell .prose,
.pm-shell .markdown, .pm-shell .gradio-markdown {
color: #182235 !important;
}
.pm-shell label, .pm-shell .label-wrap span {
color: #4a5568 !important;
font-weight: 800 !important;
}
.pm-shell input, .pm-shell textarea, .pm-shell select,
.pm-shell .dropdown, .pm-shell .secondary-wrap {
background: #f7fafc !important;
color: #111827 !important;
border: 1px solid rgba(42,71,96,0.18) !important;
}
.pm-shell input::placeholder, .pm-shell textarea::placeholder {
color: #718096 !important;
opacity: 1 !important;
}
.pm-shell [role="tablist"], .pm-shell .tab-nav {
background: #f4ead8 !important;
border-bottom: 1px solid rgba(42,71,96,0.12) !important;
}
.pm-shell [role="tab"], .pm-shell .tab-nav button {
color: #34445c !important;
font-weight: 900 !important;
opacity: 1 !important;
}
.pm-shell [role="tab"][aria-selected="true"], .pm-shell .tab-nav button.selected {
color: #0f5f9f !important;
border-bottom: 4px solid #ff7a1a !important;
}
.pm-shell button, .pm-shell .gr-button {
color: #061327 !important;
background: linear-gradient(180deg, #5fdcff 0%, #2496d8 48%, #1267ad 100%) !important;
border-color: rgba(18, 103, 173, 0.42) !important;
text-shadow: none !important;
}
.pm-shell button.primary, .pm-shell .gr-button.primary {
color: #04111f !important;
font-weight: 900 !important;
}
.pm-shell .pm-panel {
box-shadow: 0 18px 44px rgba(31, 41, 55, 0.12) !important;
}
.pm-shell .block, .pm-shell .form, .pm-shell .panel, .pm-shell .wrap {
background: rgba(255,255,255,0.62) !important;
border-color: rgba(42,71,96,0.12) !important;
}
.pm-shell table, .pm-shell th, .pm-shell td {
color: #111827 !important;
}
.pm-shell {
gap: 12px !important;
padding-top: 14px !important;
}
.pm-shell .pm-panel {
padding: 16px !important;
border-radius: 16px !important;
}
.pm-shell .block, .pm-shell .form, .pm-shell .panel, .pm-shell .wrap {
padding: 0 !important;
margin: 0 0 12px 0 !important;
background: transparent !important;
border: 0 !important;
box-shadow: none !important;
}
.pm-shell .form {
gap: 12px !important;
}
.pm-shell label, .pm-shell .label-wrap {
margin-bottom: 6px !important;
min-height: 0 !important;
}
.pm-shell label, .pm-shell .label-wrap span {
font-size: 14px !important;
line-height: 1.35 !important;
}
.pm-shell input, .pm-shell textarea, .pm-shell select,
.pm-shell .dropdown, .pm-shell .secondary-wrap {
min-height: 42px !important;
height: auto !important;
border-radius: 12px !important;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.72) !important;
}
.pm-shell .dropdown > div,
.pm-shell .secondary-wrap > div,
.pm-shell [data-testid="dropdown"] > div {
background: transparent !important;
border: 0 !important;
box-shadow: none !important;
min-height: 40px !important;
}
.pm-shell .dropdown input,
.pm-shell .secondary-wrap input {
min-height: 38px !important;
padding: 6px 10px !important;
}
.pm-shell textarea {
min-height: 92px !important;
}
.pm-shell .wrap > div,
.pm-shell .block > div,
.pm-shell .form > div {
border-radius: 12px !important;
}
.pm-shell button, .pm-shell .gr-button {
min-height: 44px !important;
padding: 10px 16px !important;
border-radius: 12px !important;
box-shadow: 0 10px 22px rgba(18, 103, 173, 0.20) !important;
}
.pm-shell .row {
gap: 16px !important;
}
.pm-shell .gap {
gap: 12px !important;
}
.command-dashboard {
margin: 12px 0 0;
padding: 18px;
border-radius: 18px;
background: linear-gradient(135deg, rgba(255,255,255,0.96), rgba(248, 252, 255, 0.90));
border: 1px solid rgba(30, 64, 105, 0.14);
box-shadow: 0 18px 44px rgba(15, 23, 42, 0.16);
color: #111827;
}
.command-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding-bottom: 14px;
border-bottom: 1px solid rgba(30, 64, 105, 0.12);
}
.command-header span {
display: block;
color: #0f5f9f;
font-weight: 900;
font-size: 14px;
}
.command-header strong {
display: block;
margin-top: 4px;
color: #111827;
font-size: 24px;
}
.command-status {
display: inline-flex;
align-items: center;
gap: 9px;
padding: 8px 12px;
border-radius: 999px;
color: #1f2937;
background: #ecfdf5;
border: 1px solid #bbf7d0;
font-weight: 900;
}
.command-status i {
width: 9px;
height: 9px;
border-radius: 999px;
background: #10b981;
}
.command-summary {
display: grid;
grid-template-columns: minmax(0, 1fr) 150px;
gap: 16px;
align-items: stretch;
padding: 16px 0;
}
.summary-copy {
padding: 16px;
border-radius: 14px;
background: #f8fafc;
border: 1px solid rgba(30, 64, 105, 0.10);
}
.summary-copy b {
display: block;
color: #111827;
font-size: 18px;
}
.summary-copy p {
margin: 8px 0 0 !important;
color: #374151 !important;
line-height: 1.6;
}
.summary-number {
border-radius: 14px;
background: linear-gradient(135deg, #0f5f9f, #123b63);
color: #fff;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.summary-number span {
color: rgba(255,255,255,0.78);
font-weight: 900;
}
.summary-number strong {
color: #fff;
font-size: 52px;
line-height: 1;
}
.command-kpis {
display: grid;
grid-template-columns: repeat(4, minmax(120px, 1fr));
gap: 10px;
}
.command-kpis div {
min-height: 78px;
padding: 14px;
border-radius: 12px;
background: #ffffff;
border: 1px solid rgba(30, 64, 105, 0.10);
}
.command-kpis span {
display: block;
color: #64748b;
font-weight: 900;
font-size: 13px;
}
.command-kpis strong {
display: block;
margin-top: 8px;
color: #0f172a;
font-size: 26px;
}
.gradio-container {
max-width: 1360px !important;
}
.pm-shell {
background: #f3eadc !important;
}
.pm-shell [role="tabpanel"] {
padding-top: 18px !important;
}
.pm-shell [role="tablist"], .pm-shell .tab-nav {
position: sticky;
top: 0;
z-index: 10;
display: flex !important;
gap: 4px !important;
overflow-x: auto !important;
padding: 8px 10px !important;
background: rgba(255, 248, 236, 0.96) !important;
backdrop-filter: blur(14px);
}
.pm-shell [role="tab"], .pm-shell .tab-nav button {
min-height: 38px !important;
padding: 8px 12px !important;
border-radius: 10px !important;
border-bottom: 0 !important;
color: #334155 !important;
white-space: nowrap !important;
}
.pm-shell [role="tab"][aria-selected="true"], .pm-shell .tab-nav button.selected {
color: #ffffff !important;
background: #0f5f9f !important;
border-bottom: 0 !important;
}
.pm-shell .pm-panel {
background: rgba(255,255,255,0.94) !important;
border-radius: 14px !important;
border-color: rgba(30, 64, 105, 0.12) !important;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.08) !important;
}
.pm-shell button, .pm-shell .gr-button {
background: #0f5f9f !important;
border: 1px solid #0f5f9f !important;
color: #fff !important;
box-shadow: none !important;
}
.pm-shell button.secondary, .pm-shell .gr-button.secondary {
background: #e2e8f0 !important;
border-color: #cbd5e1 !important;
color: #1e293b !important;
}
.pm-shell input, .pm-shell textarea, .pm-shell select,
.pm-shell .dropdown, .pm-shell .secondary-wrap {
background: #ffffff !important;
border-color: #cbd5e1 !important;
}
.pm-shell table {
border-radius: 12px !important;
overflow: hidden !important;
}
.pm-shell th {
background: #f1f5f9 !important;
color: #0f172a !important;
}
.pm-shell td {
border-color: #e2e8f0 !important;
}
.gradio-container, html, body, .main, gradio-app {
background:
radial-gradient(circle at 12% 0%, rgba(91, 196, 255, 0.18), transparent 30%),
radial-gradient(circle at 92% 8%, rgba(255, 208, 112, 0.24), transparent 28%),
linear-gradient(135deg, #f7fbff 0%, #fff8ea 48%, #eef8f5 100%) !important;
color: #102033 !important;
}
.command-dashboard {
background:
linear-gradient(135deg, rgba(255,255,255,0.98), rgba(244, 252, 255, 0.94)) !important;
border-color: rgba(20, 118, 180, 0.16) !important;
box-shadow: 0 16px 38px rgba(56, 95, 130, 0.14) !important;
}
.command-header span {
color: #0f8abf !important;
}
.summary-copy {
background: #ffffff !important;
}
.summary-number {
background: linear-gradient(135deg, #2eb7df, #2184c2) !important;
}
.command-kpis div {
background: linear-gradient(180deg, #ffffff, #f7fbff) !important;
border-color: rgba(20, 118, 180, 0.13) !important;
}
.command-kpis div:nth-child(2),
.command-kpis div:nth-child(5) {
background: linear-gradient(180deg, #fffdf7, #fff3cf) !important;
}
.command-kpis div:nth-child(3),
.command-kpis div:nth-child(6) {
background: linear-gradient(180deg, #f8fffd, #dcf7ef) !important;
}
.command-kpis div:nth-child(4),
.command-kpis div:nth-child(8) {
background: linear-gradient(180deg, #fbfdff, #e4f4ff) !important;
}
.command-kpis strong {
color: #135f8f !important;
}
.pm-shell {
background: linear-gradient(180deg, #fff7e8, #f4fbff) !important;
}
.pm-shell [role="tablist"], .pm-shell .tab-nav {
background: rgba(255, 252, 244, 0.98) !important;
}
.pm-shell [role="tab"][aria-selected="true"], .pm-shell .tab-nav button.selected {
background: linear-gradient(135deg, #25a9e8, #1386c8) !important;
}
.pm-shell .pm-panel {
background: rgba(255,255,255,0.96) !important;
}
.pm-shell button, .pm-shell .gr-button {
background: linear-gradient(135deg, #35c4ee, #1687cf) !important;
border-color: rgba(22, 135, 207, 0.50) !important;
min-height: 46px !important;
border-radius: 12px !important;
font-weight: 950 !important;
white-space: normal !important;
line-height: 1.25 !important;
}
.pm-shell button:not(.primary),
.pm-shell .gr-button:not(.primary) {
background: #edf2f7 !important;
border-color: #cbd5e1 !important;
color: #1e293b !important;
}
.pm-shell button.primary,
.pm-shell .gr-button.primary {
background: linear-gradient(135deg, #14b8d5, #0b76bd) !important;
color: #ffffff !important;
}
.erp-shell {
background: #fff8eb !important;
}
.erp-sidebar {
background:
radial-gradient(circle at 20% 0%, rgba(92, 207, 255, 0.34), transparent 34%),
linear-gradient(180deg, #f9fcff 0%, #eaf7ff 48%, #fff3d9 100%) !important;
color: #18324a !important;
border-right: 1px solid rgba(20, 118, 180, 0.14) !important;
}
.brand-block strong, .brand-block small, .side-menu b, .online-card strong, .online-card span {
color: #18324a !important;
}
.brand-block span, .side-menu .menu-title {
color: #0f8abf !important;
}
.side-menu b {
background: rgba(255,255,255,0.76) !important;
border: 1px solid rgba(20, 118, 180, 0.10) !important;
}
.side-menu b:first-of-type {
background: linear-gradient(135deg, #25a9e8, #1386c8) !important;
color: #ffffff !important;
}
.online-card {
background: rgba(255,255,255,0.78) !important;
border-color: rgba(20, 118, 180, 0.12) !important;
}
.erp-main {
background:
radial-gradient(circle at 100% 0%, rgba(255, 211, 109, 0.25), transparent 26%),
linear-gradient(180deg, #fffaf0 0%, #f5fbff 100%) !important;
}
.pulse-card {
background:
radial-gradient(circle at 95% 0%, rgba(255, 215, 116, 0.25), transparent 34%),
linear-gradient(135deg, #f2fbff 0%, #e7f6ff 48%, #fff3d8 100%) !important;
color: #18324a !important;
}
.pulse-copy h1, .pulse-copy p, .pulse-copy span,
.focus-head span, .focus-list span, .focus-list strong {
color: #18324a !important;
}
.pulse-copy h1 em, .focus-head strong {
color: #0f8abf !important;
}
.focus-panel, .focus-list div, .hero-tags b {
background: rgba(255,255,255,0.72) !important;
border-color: rgba(20, 118, 180, 0.14) !important;
color: #18324a !important;
}
.meeting-panel {
align-self: stretch;
}
.meeting-panel textarea {
line-height: 1.65 !important;
}
.meeting-steps {
margin: 0 0 14px 0 !important;
padding: 14px 16px !important;
border-radius: 14px !important;
background: #ffffff !important;
border: 1px solid rgba(20, 118, 180, 0.16) !important;
color: #18324a !important;
font-weight: 900 !important;
}
.meeting-steps p {
margin: 4px 0 !important;
color: #18324a !important;
}
.meeting-action button,
.meeting-action .gr-button {
min-height: 58px !important;
font-size: 20px !important;
font-weight: 950 !important;
border-radius: 14px !important;
background: linear-gradient(135deg, #16b4df, #0f76bd) !important;
}
.meeting-clear button,
.meeting-clear .gr-button {
min-height: 46px !important;
background: #e2e8f0 !important;
border-color: #cbd5e1 !important;
color: #1e293b !important;
font-weight: 900 !important;
}
.meeting-audio, .meeting-audio .wrap, .meeting-audio .block {
background: #f8fcff !important;
border: 1px solid rgba(20, 118, 180, 0.14) !important;
border-radius: 14px !important;
min-height: 138px !important;
overflow: visible !important;
}
.meeting-audio button,
.meeting-audio .gr-button {
min-height: 42px !important;
min-width: 52px !important;
padding: 8px 14px !important;
border-radius: 10px !important;
background: #ffffff !important;
color: #0f172a !important;
border: 1px solid #cbd5e1 !important;
box-shadow: none !important;
}
.meeting-audio button svg,
.meeting-audio .gr-button svg {
color: #0f172a !important;
fill: currentColor !important;
stroke: currentColor !important;
opacity: 1 !important;
display: inline-block !important;
}
.meeting-audio button:empty,
.meeting-audio .gr-button:empty {
display: none !important;
}
.meeting-audio audio {
width: 100% !important;
}
.pm-upload, .pm-upload .wrap, .pm-upload .block {
background: #f8fcff !important;
border: 1px solid rgba(20, 118, 180, 0.14) !important;
border-radius: 14px !important;
overflow: visible !important;
}
.pm-upload button,
.pm-upload .gr-button {
min-height: 42px !important;
min-width: 64px !important;
padding: 8px 14px !important;
border-radius: 10px !important;
background: #ffffff !important;
color: #0f172a !important;
border: 1px solid #cbd5e1 !important;
box-shadow: none !important;
}
.pm-upload button svg,
.pm-upload .gr-button svg {
color: #0f172a !important;
fill: currentColor !important;
stroke: currentColor !important;
opacity: 1 !important;
display: inline-block !important;
}
.pm-upload button:empty,
.pm-upload .gr-button:empty {
display: none !important;
}
.pm-upload [aria-label="Clear"],
.pm-upload [aria-label="Remove"],
.pm-upload [title="Clear"],
.pm-upload [title="Remove"] {
background: #fee2e2 !important;
border-color: #fecaca !important;
color: #991b1b !important;
}
.pm-guide {
margin: 0 0 12px 0 !important;
padding: 10px 14px !important;
border-radius: 12px !important;
background: linear-gradient(135deg, #f0fbff, #fff9e8) !important;
border: 1px solid rgba(20, 118, 180, 0.14) !important;
color: #27445f !important;
font-weight: 700 !important;
}
.pm-guide p {
margin: 0 !important;
color: #27445f !important;
}
.health-line {
margin: 6px 0 14px;
padding: 9px 12px;
border-radius: 10px;
background: #ffffff;
border: 1px solid rgba(20, 118, 180, 0.14);
color: #1e3a56;
font-weight: 800;
}
.pm-shell label,
.pm-shell .label-wrap span,
.pm-shell .block-info,
.pm-shell .prose,
.pm-shell .markdown,
.pm-shell .gradio-markdown,
.pm-shell .gradio-markdown *,
.pm-shell textarea,
.pm-shell input,
.pm-shell select,
.pm-shell [role="textbox"],
.pm-shell [role="combobox"],
.pm-shell [data-testid="textbox"],
.pm-shell [data-testid="dropdown"],
.pm-shell table,
.pm-shell th,
.pm-shell td {
color: #0f172a !important;
opacity: 1 !important;
text-shadow: none !important;
}
.pm-shell input::placeholder,
.pm-shell textarea::placeholder {
color: #64748b !important;
opacity: 1 !important;
}
.pm-shell [role="tab"] {
color: #1f2937 !important;
font-weight: 900 !important;
}
.pm-shell [role="tab"][aria-selected="true"] {
color: #ffffff !important;
}
.pm-shell .wrap,
.pm-shell .block,
.pm-shell .form,
.pm-shell .input-container {
color: #0f172a !important;
}
@media (max-width: 900px) {
.gradio-container { padding: 8px !important; }
.command-dashboard { padding: 14px; border-radius: 14px; }
.command-header { align-items: flex-start; flex-direction: column; }
.command-header strong { font-size: 20px; }
.command-summary { grid-template-columns: 1fr; }
.summary-number { min-height: 110px; }
.command-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.erp-shell { grid-template-columns: 1fr; }
.erp-sidebar {
min-height: auto;
padding: 18px;
display: grid;
grid-template-columns: 64px 1fr;
align-items: center;
}
.studio-mark { width: 56px; height: 56px; font-size: 28px; border-radius: 16px; }
.brand-block strong { font-size: 22px; }
.side-menu, .online-card { display: none; }
.erp-main { padding: 22px 16px 24px; }
.topline {
align-items: flex-start;
gap: 16px;
flex-direction: column;
}
.topline strong { font-size: 22px; }
.pulse-card {
grid-template-columns: 1fr;
padding: 26px 20px;
min-height: 0;
}
.pulse-copy h1 { font-size: 32px; }
.pulse-copy p { font-size: 16px; }
.focus-head strong { font-size: 58px; }
.focus-list, .report-strip ul, .action-grid, .data-strip { grid-template-columns: 1fr; }
.pm-title, .pm-panel { padding: 14px !important; }
}
@media (min-width: 901px) and (max-width: 1180px) {
.command-kpis { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.erp-shell { grid-template-columns: 220px 1fr; }
.erp-sidebar { padding: 24px 18px; }
.brand-block strong { font-size: 23px; }
.erp-main { padding: 30px 28px; }
.pulse-card { grid-template-columns: 1fr; }
.focus-list { grid-template-columns: repeat(4, 1fr); }
.action-grid { grid-template-columns: repeat(2, 1fr); }
}
"""
PWA_HEAD = f"""
<meta name="application-name" content="{APP_SHORT_NAME}">
<meta name="apple-mobile-web-app-title" content="{APP_SHORT_NAME}">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#25a9e8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
"""
with gr.Blocks(title=APP_TITLE) as demo:
gr.HTML(f"<style>{CSS}</style>")
dashboard_home = gr.HTML(build_hero_html())
with gr.Column(elem_classes="pm-shell"):
with gr.Column(elem_classes="pm-title"):
gr.Markdown(f"# {APP_TITLE}\n案場、待辦、會議轉錄、提醒與文件報表集中管理。")
with gr.Column(elem_classes="pm-panel", visible=False) as login_area:
gr.Markdown("### 登入")
login_user = gr.Textbox(label="帳號", value=LOGIN_USER, lines=1)
login_pass = gr.Textbox(label="密碼", type="password", lines=1)
login_key = gr.Textbox(label="存取金鑰", type="password", lines=1)
login_button = gr.Button("登入", variant="primary")
login_message = gr.Textbox(label="狀態", interactive=False)
with gr.Column(visible=True) as main_area:
with gr.Tab("專案工作台"):
gr.Markdown("### 案場工作台")
gr.Markdown(GUIDE_TEXT["workbench"], elem_classes="pm-guide")
with gr.Row():
with gr.Column(scale=1, elem_classes="pm-panel"):
workbench_site = gr.Dropdown(label="選擇案場", choices=list_site_options())
load_workbench_btn = gr.Button("查看這個案場狀態", variant="primary")
workbench_stage = gr.Dropdown(label="目前階段", choices=CASE_STAGES, value=CASE_STAGES[0])
workbench_category = gr.Dropdown(label="專案類別", choices=current_categories(), value=current_categories()[0])
workbench_budget = gr.Textbox(label="預算/金額")
workbench_notes = gr.Textbox(label="PM 備註與下一步", lines=5)
pm_note_template_btn = gr.Button("帶入 PM 備註範本")
save_workbench_btn = gr.Button("儲存案場狀態與下一步", variant="primary")
gr.ClearButton([workbench_budget, workbench_notes], value="清空工作台輸入")
workbench_message = gr.Textbox(label="更新狀態", interactive=False)
with gr.Column(scale=2, elem_classes="pm-panel"):
workbench_summary = gr.Markdown("### 請選擇案場\n載入後會顯示案場摘要、提醒、會議、文件與 PM 備註。")
with gr.Tab("待辦與風險"):
gr.Markdown("### 待辦與風險控管")
gr.Markdown(GUIDE_TEXT["task"], elem_classes="pm-guide")
with gr.Row():
with gr.Column(scale=1, elem_classes="pm-panel"):
gr.Markdown("#### 快速建立案場")
with gr.Row():
quick_task_site_name = gr.Textbox(label="案場名稱", placeholder="例如:光寶A棟")
quick_task_site_location = gr.Textbox(label="地點", placeholder="可空白")
quick_task_map_preview = gr.Markdown(location_map_preview(""))
quick_task_site_btn = gr.Button("建立這個案場並開始新增待辦", variant="primary")
gr.ClearButton([quick_task_site_name, quick_task_site_location], value="清空快速案場")
task_site = gr.Dropdown(label="待辦案場", choices=list_site_options())
task_title = gr.Textbox(label="待辦事項", placeholder="例如:確認施工圖、追蹤報價、補齊送審文件")
task_owner = gr.Textbox(label="負責人")
task_agreed = gr.DateTime(label="約定時間", include_time=True, type="string", timezone="Asia/Taipei")
task_due = gr.DateTime(label="完成期限", include_time=True, type="string", timezone="Asia/Taipei")
task_priority = gr.Dropdown(label="優先級", choices=TASK_PRIORITIES, value=TASK_PRIORITIES[0])
task_status = gr.Dropdown(label="狀態", choices=TASK_STATUSES, value=TASK_STATUSES[0])
task_sync_calendar = gr.Checkbox(label="同步建立行事曆提醒", value=True)
task_notes = gr.Textbox(label="待辦備註", lines=3)
add_task_btn = gr.Button("建立待辦並同步提醒", variant="primary")
gr.ClearButton([task_title, task_owner, task_agreed, task_due, task_notes], value="清空待辦表單")
task_message = gr.Textbox(label="待辦狀態", interactive=False)
with gr.Column(scale=2, elem_classes="pm-panel"):
task_table = gr.Dataframe(value=task_rows(only_open=True), label="未完成待辦", interactive=False, wrap=True)
task_picker = gr.Dropdown(label="選擇要完成的待辦", choices=task_options())
with gr.Row():
task_selected_btn = gr.Button("此案場待辦")
task_all_btn = gr.Button("全部未完成")
complete_task_btn = gr.Button("把選到的待辦標記完成", variant="primary")
with gr.Row():
with gr.Column(scale=1, elem_classes="pm-panel"):
risk_site = gr.Dropdown(label="風險案場", choices=list_site_options())
risk_title = gr.Textbox(label="風險項目", placeholder="例如:工期延誤、報價未回、圖說待確認")
risk_level = gr.Dropdown(label="風險等級", choices=RISK_LEVELS, value=RISK_LEVELS[1])
risk_status = gr.Dropdown(label="處理狀態", choices=RISK_STATUSES, value=RISK_STATUSES[0])
risk_owner = gr.Textbox(label="負責人")
risk_mitigation = gr.Textbox(label="處置方式", lines=3)
risk_template_btn = gr.Button("帶入風險處置範本")
add_risk_btn = gr.Button("新增風險並追蹤處置", variant="primary")
gr.ClearButton([risk_title, risk_owner, risk_mitigation], value="清空風險表單")
risk_message = gr.Textbox(label="風險狀態", interactive=False)
with gr.Column(scale=2, elem_classes="pm-panel"):
risk_table = gr.Dataframe(value=risk_rows(), label="風險清單", interactive=False, wrap=True)
risk_picker = gr.Dropdown(label="選擇要解除的風險", choices=risk_options())
with gr.Row():
risk_selected_btn = gr.Button("此案場風險")
risk_all_btn = gr.Button("全部風險")
resolve_risk_btn = gr.Button("把選到的風險標記解除", variant="primary")
with gr.Tab("案場總覽"):
gr.Markdown("### 案場總覽")
gr.Markdown(GUIDE_TEXT["cases"], elem_classes="pm-guide")
with gr.Row():
with gr.Column(scale=1, elem_classes="pm-panel"):
site_name = gr.Textbox(label="案場名稱")
location = gr.Textbox(label="地點")
location_map = gr.Markdown(location_map_preview(""))
client = gr.Textbox(label="客戶")
stage = gr.Dropdown(label="階段", choices=CASE_STAGES, value=CASE_STAGES[0])
category = gr.Dropdown(label="類別", choices=current_categories(), value=current_categories()[0])
new_category = gr.Textbox(label="新增類別")
tags = gr.Textbox(label="標籤", placeholder="例如:投標, 設計, 急件")
bid_amount = gr.Textbox(label="金額/預算")
manager_notes = gr.Textbox(label="管理備註", lines=4)
add_site_btn = gr.Button("建立案場並加入清單", variant="primary")
gr.ClearButton([site_name, location, client, new_category, tags, bid_amount, manager_notes], value="清空案場表單")
site_message = gr.Textbox(label="狀態", interactive=False)
with gr.Column(scale=2, elem_classes="pm-panel"):
category_filter = gr.Dropdown(label="類別篩選", choices=[ALL_CATEGORIES] + current_categories(), value=ALL_CATEGORIES)
case_table = gr.Dataframe(value=case_rows(), interactive=False, wrap=True)
overview = gr.Markdown(category_overview())
storage_status = gr.Markdown(data_storage_status())
with gr.Row():
export_data_btn = gr.Button("下載 Neil日誌資料備份", variant="primary")
restore_data_file = gr.File(label="選擇備份 JSON 還原", file_count="single")
restore_data_btn = gr.Button("還原備份到系統")
data_backup_file = gr.File(label="資料備份檔")
with gr.Tab("會議紀錄"):
gr.Markdown("### 會議轉錄與自動摘要")
gr.Markdown(GUIDE_TEXT["meeting"], elem_classes="pm-guide")
gr.Markdown(
"1. 先選案場與填會議主題。\n"
"2. 錄音、上傳音檔,或直接貼逐字稿。\n"
"3. 按「開始整理會議」,系統會產生摘要、重要事項、待辦、提醒與風險提示。",
elem_classes="meeting-steps",
)
with gr.Row():
with gr.Column(scale=1, elem_classes="pm-panel meeting-panel"):
meeting_site = gr.Dropdown(label="1. 選擇案場", choices=list_site_options())
meeting_title = gr.Textbox(label="2. 會議主題", placeholder="例如:週會、現場協調、報價確認")
participants = gr.Textbox(label="3. 參與人員", placeholder="例如:Neil、設計師、現場主任")
focus = gr.Textbox(label="4. 這次要追蹤的重點", lines=3, placeholder="例如:決議、風險、期限、誰要做什麼")
meeting_template_btn = gr.Button("套用會議重點範本")
audio_input = gr.Audio(sources=["microphone", "upload"], type="filepath", label="5A. 錄音或上傳音檔", elem_classes="meeting-audio")
manual_transcript = gr.Textbox(label="5B. 沒有音檔就貼逐字稿", lines=6, placeholder="可直接貼上會議文字,系統一樣會整理摘要、待辦與提醒。")
meeting_btn = gr.Button("6. 開始整理會議", variant="primary", elem_classes="meeting-action")
gr.ClearButton([meeting_title, participants, audio_input, focus, manual_transcript], value="清空重填", elem_classes="meeting-clear")
meeting_status = gr.Textbox(label="處理狀態", interactive=False)
with gr.Column(scale=1, elem_classes="pm-panel meeting-panel"):
minutes_output = gr.Textbox(label="整理完成後看這裡:摘要、重要事項、待辦與提醒", lines=14, interactive=False)
transcript_output = gr.Textbox(label="轉錄文字 / 逐字稿", lines=10, interactive=False)
with gr.Row():
with gr.Column(elem_classes="pm-panel"):
meeting_history_site = gr.Dropdown(label="查看案場會議", choices=list_site_options())
refresh_meeting_history = gr.Button("更新會議清單")
meeting_history_table = gr.Dataframe(value=meeting_rows(), label="會議紀錄清單", interactive=False, wrap=True)
with gr.Tab("照片與文件"):
gr.Markdown("### 照片與文件")
gr.Markdown(GUIDE_TEXT["files"], elem_classes="pm-guide")
with gr.Row():
with gr.Column(elem_classes="pm-panel"):
photo_site = gr.Dropdown(label="照片案場", choices=list_site_options())
gr.Markdown("先上傳照片,再填備註,最後按下方藍色按鈕存入案場。", elem_classes="pm-guide")
photo_input = gr.Image(type="filepath", label="1. 上傳現場照片", elem_classes="pm-upload")
photo_notes = gr.Textbox(label="2. 照片備註", lines=3)
photo_template_btn = gr.Button("帶入照片紀錄範本")
photo_btn = gr.Button("3. 分析照片並存入案場", variant="primary")
gr.ClearButton([photo_input, photo_notes], value="清空照片表單")
photo_caption = gr.Textbox(label="照片描述", lines=3, interactive=False)
photo_summary = gr.Textbox(label="現場摘要", lines=8, interactive=False)
with gr.Column(elem_classes="pm-panel"):
doc_site = gr.Dropdown(label="文件案場", choices=list_site_options())
gr.Markdown("先上傳文件照片,再填備註,最後按下方藍色按鈕辨識與摘要。", elem_classes="pm-guide")
doc_image = gr.Image(type="filepath", label="1. 上傳文件照片", elem_classes="pm-upload")
doc_notes = gr.Textbox(label="2. 文件備註", lines=3)
doc_template_btn = gr.Button("帶入文件備註範本")
ocr_btn = gr.Button("3. 辨識文件並產生摘要", variant="primary")
gr.ClearButton([doc_image, doc_notes], value="清空文件表單")
ocr_status = gr.Textbox(label="辨識狀態", interactive=False)
ocr_text = gr.Textbox(label="辨識文字", lines=6, interactive=False)
ocr_summary = gr.Textbox(label="文件摘要", lines=8, interactive=False)
with gr.Tab("預算與附件"):
gr.Markdown("### 預算與附件")
gr.Markdown(GUIDE_TEXT["budget"], elem_classes="pm-guide")
with gr.Row():
with gr.Column(scale=1, elem_classes="pm-panel"):
budget_site = gr.Dropdown(label="預算案場", choices=list_site_options())
expense_title = gr.Textbox(label="支出項目", placeholder="例如:材料訂金、設計費、現場追加")
expense_type = gr.Dropdown(label="支出類型", choices=EXPENSE_TYPES, value=EXPENSE_TYPES[0])
expense_amount = gr.Textbox(label="金額")
expense_date = gr.DateTime(label="日期", include_time=False, type="string", timezone="Asia/Taipei", value=str(today_local()))
expense_notes = gr.Textbox(label="支出備註", lines=3)
expense_template_btn = gr.Button("帶入支出備註範本")
add_expense_btn = gr.Button("新增支出並更新預算", variant="primary")
gr.ClearButton([expense_title, expense_amount, expense_date, expense_notes], value="清空支出表單")
expense_message = gr.Textbox(label="預算狀態", interactive=False)
with gr.Column(scale=2, elem_classes="pm-panel"):
budget_overview = gr.Markdown(budget_summary())
expense_table = gr.Dataframe(value=expense_rows(), label="支出明細", interactive=False, wrap=True)
with gr.Row():
expense_selected_btn = gr.Button("此案場支出")
expense_all_btn = gr.Button("全部支出")
with gr.Row():
with gr.Column(scale=1, elem_classes="pm-panel"):
attachment_site = gr.Dropdown(label="附件案場", choices=list_site_options())
attachment_file = gr.File(label="上傳附件", file_count="multiple")
attachment_type = gr.Dropdown(label="附件類型", choices=["合約", "報價", "圖說", "照片", "會議", "其他"], value="其他")
attachment_notes = gr.Textbox(label="附件備註", lines=3)
add_attachment_btn = gr.Button("上傳附件並掛到案場", variant="primary")
gr.ClearButton([attachment_file, attachment_notes], value="清空附件表單")
attachment_message = gr.Textbox(label="附件狀態", interactive=False)
with gr.Column(scale=2, elem_classes="pm-panel"):
attachment_table = gr.Dataframe(value=attachment_rows(), label="附件清單", interactive=False, wrap=True)
with gr.Row():
attachment_selected_btn = gr.Button("此案場附件")
attachment_all_btn = gr.Button("全部附件")
with gr.Tab("行事曆提醒"):
gr.Markdown("### 行事曆提醒")
gr.Markdown(GUIDE_TEXT["calendar"], elem_classes="pm-guide")
with gr.Row():
with gr.Column(scale=1, elem_classes="pm-panel"):
calendar_site = gr.Dropdown(label="案場", choices=list_site_options())
reminder_title = gr.Textbox(label="提醒標題", placeholder="例如:投標截止、現場會勘、文件送審")
reminder_type = gr.Dropdown(label="提醒類型", choices=REMINDER_TYPES, value=REMINDER_TYPES[0])
reminder_time = gr.DateTime(label="日期時間", include_time=True, type="string", timezone="Asia/Taipei")
reminder_duration = gr.Number(label="預估時間(分鐘)", value=60, precision=0, minimum=15, maximum=1440)
reminder_before = gr.Dropdown(
label="提前提醒",
choices=[(f"{minutes} 分鐘", minutes) for minutes in REMINDER_MINUTES],
value=60,
)
reminder_owner = gr.Textbox(label="負責人")
reminder_notes = gr.Textbox(label="備註", lines=3)
add_reminder_btn = gr.Button("建立提醒並可匯到手機", variant="primary")
gr.ClearButton([reminder_title, reminder_time, reminder_owner, reminder_notes], value="清空提醒表單")
reminder_message = gr.Textbox(label="狀態", interactive=False)
with gr.Column(scale=2, elem_classes="pm-panel"):
reminder_table = gr.Dataframe(value=upcoming_reminders(30), label="未來 30 天提醒", interactive=False, wrap=True)
with gr.Row():
upcoming_7_btn = gr.Button("7 天內")
upcoming_30_btn = gr.Button("30 天內")
selected_reminders_btn = gr.Button("此案場提醒")
with gr.Row():
export_selected_calendar = gr.Button("下載此案場手機行事曆", variant="primary")
export_all_calendar = gr.Button("下載全部手機行事曆", variant="primary")
calendar_file = gr.File(label="手機/平板行事曆檔")
with gr.Tab("歷史與搜尋"):
gr.Markdown("### 歷史與搜尋")
gr.Markdown(GUIDE_TEXT["history"], elem_classes="pm-guide")
with gr.Row():
with gr.Column(elem_classes="pm-panel"):
history_site = gr.Dropdown(label="案場", choices=list_site_options())
refresh_history = gr.Button("更新歷史")
photo_history_table = gr.Dataframe(label="照片歷史", interactive=False, wrap=True)
ocr_history_table = gr.Dataframe(label="文件歷史", interactive=False, wrap=True)
with gr.Column(elem_classes="pm-panel"):
gallery_site = gr.Dropdown(label="照片牆案場", choices=list_site_options())
refresh_gallery = gr.Button("更新照片牆")
photo_gallery = gr.Gallery(label="照片牆")
ocr_search = gr.Textbox(label="搜尋文件文字")
search_button = gr.Button("搜尋")
search_results = gr.Dataframe(label="搜尋結果", interactive=False, wrap=True)
with gr.Tab("儀表板與報表"):
gr.Markdown("### 儀表板與報表")
gr.Markdown(GUIDE_TEXT["reports"], elem_classes="pm-guide")
with gr.Row():
with gr.Column(elem_classes="pm-panel"):
dashboard_site = gr.Dropdown(label="儀表板案場", choices=list_site_options())
dashboard_btn = gr.Button("更新儀表板", variant="primary")
dashboard = gr.Markdown()
with gr.Column(elem_classes="pm-panel"):
detail_site = gr.Dropdown(label="詳細資料案場", choices=list_site_options())
detail_btn = gr.Button("查看詳細資料")
detail = gr.Markdown()
with gr.Row():
with gr.Column(elem_classes="pm-panel"):
gantt_site = gr.Dropdown(label="甘特圖案場", choices=list_site_options())
gantt_btn = gr.Button("產生甘特圖")
gantt_image = gr.Image(label="甘特圖", interactive=False)
with gr.Column(elem_classes="pm-panel"):
export_site = gr.Dropdown(label="報表案場", choices=list_site_options())
export_btn = gr.Button("下載 PDF / 試算表報表", variant="primary")
pdf_report = gr.File(label="報表文件")
excel_report = gr.File(label="試算表")
refresh_btn = gr.Button("重新整理案場清單")
login_button.click(verify_login, inputs=[login_user, login_pass, login_key], outputs=[login_message, login_area, main_area])
pm_note_template_btn.click(lambda: template_text("pm_note"), outputs=[workbench_notes])
risk_template_btn.click(lambda: template_text("risk"), outputs=[risk_mitigation])
meeting_template_btn.click(lambda: template_text("meeting"), outputs=[focus])
photo_template_btn.click(lambda: template_text("photo"), outputs=[photo_notes])
doc_template_btn.click(lambda: template_text("document"), outputs=[doc_notes])
expense_template_btn.click(lambda: template_text("expense"), outputs=[expense_notes])
location.change(location_map_preview, inputs=[location], outputs=[location_map])
quick_task_site_location.change(location_map_preview, inputs=[quick_task_site_location], outputs=[quick_task_map_preview])
meeting_btn.click(
add_meeting,
inputs=[meeting_site, meeting_title, participants, audio_input, focus, manual_transcript],
outputs=[meeting_status, transcript_output, minutes_output, meeting_history_table, dashboard_home, case_table, reminder_table, task_table, task_picker, risk_table, risk_picker],
)
refresh_meeting_history.click(meeting_rows, inputs=[meeting_history_site], outputs=[meeting_history_table])
photo_btn.click(
scan_photo,
inputs=[photo_site, photo_input, photo_notes],
outputs=[photo_caption, photo_summary, photo_history_table, photo_gallery, dashboard_home, case_table],
)
ocr_btn.click(
scan_document,
inputs=[doc_site, doc_image, doc_notes],
outputs=[ocr_status, ocr_text, ocr_summary, ocr_history_table, dashboard_home, case_table],
)
add_reminder_btn.click(
add_reminder,
inputs=[calendar_site, reminder_title, reminder_type, reminder_time, reminder_duration, reminder_before, reminder_owner, reminder_notes],
outputs=[reminder_table, reminder_message, dashboard_home, case_table],
)
load_workbench_btn.click(
load_case_for_workbench,
inputs=[workbench_site],
outputs=[workbench_stage, workbench_category, workbench_budget, workbench_notes, workbench_summary],
)
save_workbench_btn.click(
update_case_from_workbench,
inputs=[workbench_site, workbench_stage, workbench_category, workbench_budget, workbench_notes],
outputs=[case_table, dashboard_home, workbench_summary, workbench_message],
)
add_task_btn.click(
add_task,
inputs=[task_site, task_title, task_owner, task_agreed, task_due, task_priority, task_status, task_sync_calendar, task_notes],
outputs=[task_table, dashboard_home, case_table, reminder_table, task_picker, task_message],
)
task_selected_btn.click(lambda site: (task_rows(site), gr.update(choices=task_options(site), value=None)), inputs=[task_site], outputs=[task_table, task_picker])
task_all_btn.click(lambda: (task_rows(only_open=True), gr.update(choices=task_options(), value=None)), outputs=[task_table, task_picker])
complete_task_btn.click(
complete_task,
inputs=[task_site, task_picker],
outputs=[task_table, dashboard_home, case_table, task_picker, task_message],
)
add_risk_btn.click(
add_risk,
inputs=[risk_site, risk_title, risk_level, risk_status, risk_owner, risk_mitigation],
outputs=[risk_table, dashboard_home, case_table, risk_picker, risk_message],
)
risk_selected_btn.click(lambda site: (risk_rows(site), gr.update(choices=risk_options(site), value=None)), inputs=[risk_site], outputs=[risk_table, risk_picker])
risk_all_btn.click(lambda: (risk_rows(), gr.update(choices=risk_options(), value=None)), outputs=[risk_table, risk_picker])
resolve_risk_btn.click(
resolve_risk,
inputs=[risk_site, risk_picker],
outputs=[risk_table, dashboard_home, case_table, risk_picker, risk_message],
)
add_expense_btn.click(
add_expense,
inputs=[budget_site, expense_title, expense_type, expense_amount, expense_date, expense_notes],
outputs=[expense_table, budget_overview, dashboard_home, case_table, expense_message],
)
expense_selected_btn.click(lambda site: (expense_rows(site), budget_summary(site)), inputs=[budget_site], outputs=[expense_table, budget_overview])
expense_all_btn.click(lambda: (expense_rows(), budget_summary()), outputs=[expense_table, budget_overview])
add_attachment_btn.click(
add_attachment,
inputs=[attachment_site, attachment_file, attachment_type, attachment_notes],
outputs=[attachment_table, dashboard_home, case_table, attachment_message],
)
attachment_selected_btn.click(attachment_rows, inputs=[attachment_site], outputs=[attachment_table])
attachment_all_btn.click(attachment_rows, outputs=[attachment_table])
upcoming_7_btn.click(lambda: upcoming_reminders(7), outputs=[reminder_table])
upcoming_30_btn.click(lambda: upcoming_reminders(30), outputs=[reminder_table])
selected_reminders_btn.click(reminder_rows, inputs=[calendar_site], outputs=[reminder_table])
export_selected_calendar.click(export_calendar_ics, inputs=[calendar_site], outputs=[calendar_file])
export_all_calendar.click(lambda: export_calendar_ics(), outputs=[calendar_file])
refresh_history.click(lambda site: (photo_history(site), ocr_history(site)), inputs=[history_site], outputs=[photo_history_table, ocr_history_table])
refresh_gallery.click(gallery, inputs=[gallery_site], outputs=[photo_gallery])
search_button.click(search_ocr, inputs=[ocr_search], outputs=[search_results])
dashboard_btn.click(get_dashboard, inputs=[dashboard_site], outputs=[dashboard])
detail_btn.click(get_case_detail, inputs=[detail_site], outputs=[detail])
gantt_btn.click(generate_gantt, inputs=[gantt_site], outputs=[gantt_image])
export_btn.click(export_report, inputs=[export_site], outputs=[pdf_report, excel_report])
category_filter.change(filter_cases, inputs=[category_filter], outputs=[case_table, overview])
export_data_btn.click(export_data_backup, outputs=[data_backup_file])
dropdown_outputs = [
workbench_site,
meeting_site,
meeting_history_site,
dashboard_site,
photo_site,
doc_site,
history_site,
gallery_site,
export_site,
detail_site,
gantt_site,
calendar_site,
task_site,
risk_site,
budget_site,
attachment_site,
category,
category_filter,
]
add_site_btn.click(
add_case,
inputs=[site_name, location, client, stage, category, new_category, tags, bid_amount, manager_notes],
outputs=[case_table, *dropdown_outputs, overview, site_message],
)
quick_task_site_btn.click(
quick_add_case_for_task,
inputs=[quick_task_site_name, quick_task_site_location],
outputs=[case_table, *dropdown_outputs, overview, task_message],
)
restore_data_btn.click(
restore_data_backup,
inputs=[restore_data_file],
outputs=[case_table, *dropdown_outputs, overview, site_message, storage_status],
)
refresh_btn.click(lambda: refresh_all("案場清單已重新整理。"), outputs=[case_table, *dropdown_outputs, overview, site_message])
auto_refresh = gr.Timer(10)
auto_refresh.tick(
lambda: (
build_hero_html(),
case_rows(),
category_overview(),
upcoming_reminders(30),
task_rows(only_open=True),
gr.update(choices=task_options(), value=None),
risk_rows(),
gr.update(choices=risk_options(), value=None),
expense_rows(),
budget_summary(),
attachment_rows(),
meeting_rows(),
data_storage_status(),
),
outputs=[dashboard_home, case_table, overview, reminder_table, task_table, task_picker, risk_table, risk_picker, expense_table, budget_overview, attachment_table, meeting_history_table, storage_status],
show_progress="hidden",
)
if __name__ == "__main__":
demo.launch(server_name="0.0.0.0", server_port=7860, css=CSS, pwa=True, favicon_path="neil_log_icon.svg", head=PWA_HEAD)