2026-05-07 16:31:56 +08:00

349 lines
14 KiB
Python

from __future__ import annotations
import json
from datetime import date
from html import escape
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
from .config import Config, read_config
from .db import Database
from .feishu_auth import (
authorize_url,
create_session_cookie,
create_state,
exchange_code_for_user_access_token,
fetch_user_info,
parse_session_cookie,
)
from .report_service import ReportService
from .robot_service import create_reminder_payload, create_summary_payload, send_webhook
def today_string() -> str:
return date.today().isoformat()
def page(title: str, body: str, scripts: str = "") -> bytes:
html = f"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{escape(title)}</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
{body}
{scripts}
</body>
</html>"""
return html.encode("utf-8")
def submit_page(current_date: str, session: dict[str, str] | None = None) -> bytes:
numbered_template = "1. \n2. \n3. \n4. "
if session:
identity_html = f"""
<div class="identity-box">
<span>当前填写人</span>
<strong>{escape(session.get("name", session["feishu_user_id"]))}</strong>
</div>
<input id="feishu-user-id" type="hidden" name="feishu_user_id" value="{escape(session["feishu_user_id"])}">
"""
history_hint = "系统已自动识别你的飞书身份,可以查看自己最近提交的记录。"
else:
identity_html = '<label>员工 ID<input id="feishu-user-id" name="feishu_user_id" required placeholder="例如 u_alice"></label>'
history_hint = "输入员工 ID 后可以查看自己最近提交的记录。"
return page(
"每日报告",
f"""
<main class="shell narrow">
<h1>每日报告</h1>
<form id="report-form" class="panel">
{identity_html}
<label>日期<input name="report_date" type="date" required value="{escape(current_date)}"></label>
<label>今日完成<textarea name="today_done" required rows="5">{numbered_template}</textarea></label>
<label>明日计划<textarea name="tomorrow_plan" required rows="4">{numbered_template}</textarea></label>
<label>遇到的问题<textarea name="blockers" rows="3"></textarea></label>
<label>需要协助<textarea name="help_needed" rows="3"></textarea></label>
<button type="submit">提交日报</button>
<p id="form-message" class="message"></p>
</form>
<section class="panel history-panel">
<div class="history-head">
<div>
<h2>我的历史日报</h2>
<p>{history_hint}</p>
</div>
<button id="load-history" type="button">查看历史</button>
</div>
<div id="history-list" class="history-list">暂无历史记录。</div>
</section>
</main>
<script>
function renderHistory(data) {{
const container = document.querySelector("#history-list");
if (!data.reports.length) {{
container.innerHTML = "<p>还没有历史日报。</p>";
return;
}}
container.innerHTML = data.reports.map((report) => `
<article class="history-card">
<h3>${{report.report_date}}</h3>
<div><strong>今日完成</strong><pre>${{report.today_done}}</pre></div>
<div><strong>明日计划</strong><pre>${{report.tomorrow_plan}}</pre></div>
<div><strong>问题</strong><pre>${{report.blockers || ""}}</pre></div>
<div><strong>协助</strong><pre>${{report.help_needed || ""}}</pre></div>
</article>
`).join("");
}}
document.querySelector("#load-history").addEventListener("click", async () => {{
const userId = document.querySelector("#feishu-user-id").value.trim();
const container = document.querySelector("#history-list");
if (!userId) {{
container.textContent = "请先填写员工 ID。";
return;
}}
const response = await fetch(`/api/reports/history?feishu_user_id=${{encodeURIComponent(userId)}}`);
if (response.ok) {{
renderHistory(await response.json());
}} else {{
const result = await response.json();
container.textContent = result.error || "历史记录加载失败。";
}}
}});
document.querySelector("#report-form").addEventListener("submit", async (event) => {{
event.preventDefault();
const data = Object.fromEntries(new FormData(event.currentTarget).entries());
const response = await fetch("/api/reports", {{
method: "POST",
headers: {{ "content-type": "application/json" }},
body: JSON.stringify(data)
}});
const message = document.querySelector("#form-message");
if (response.ok) {{
message.textContent = "提交成功。";
message.className = "message success";
document.querySelector("#load-history").click();
}} else {{
const result = await response.json();
message.textContent = result.error || "提交失败。";
message.className = "message error";
}}
}});
</script>""",
)
def manager_page(current_date: str) -> bytes:
return page(
"日报浏览",
f"""
<main class="shell">
<header class="topbar">
<div>
<h1>日报浏览</h1>
<p>按日期、员工和关键词浏览所有日报记录。</p>
</div>
<button id="copy-summary">复制今日汇总</button>
</header>
<section class="filters">
<input id="date-filter" type="date" value="{escape(current_date)}">
<input id="employee-filter" placeholder="筛选员工">
<input id="keyword-filter" placeholder="搜索日报内容">
<label class="check"><input id="blocker-filter" type="checkbox"> 只看问题/协助</label>
<a id="export-link" href="/api/reports/export?date={escape(current_date)}">导出 CSV</a>
</section>
<section id="stats" class="stats"></section>
<section id="reports" class="report-list"></section>
<section>
<h2>未提交人员</h2>
<div id="missing" class="missing"></div>
</section>
</main>""",
'<script src="/static/manager.js"></script>',
)
class DailyReportHandler(BaseHTTPRequestHandler):
report_service: ReportService
config: Config
static_dir: Path
def log_message(self, format: str, *args: object) -> None:
return
def _send(self, status: int, body: bytes, content_type: str = "text/html; charset=utf-8") -> None:
self.send_response(status)
self.send_header("content-type", content_type)
self.send_header("content-length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _json(self, status: int, data: dict) -> None:
self._send(status, json.dumps(data, ensure_ascii=False).encode("utf-8"), "application/json; charset=utf-8")
def _read_json(self) -> dict:
length = int(self.headers.get("content-length", "0"))
if length == 0:
return {}
return json.loads(self.rfile.read(length).decode("utf-8"))
def _cookie(self, name: str) -> str | None:
for item in self.headers.get("cookie", "").split(";"):
if "=" not in item:
continue
key, value = item.strip().split("=", 1)
if key == name:
return value
return None
def _session(self) -> dict[str, str] | None:
return parse_session_cookie(self._cookie("daily_report_session"), self.config.session_secret)
def _oauth_enabled(self) -> bool:
return bool(self.config.feishu_app_id and self.config.feishu_app_secret)
def do_GET(self) -> None:
parsed = urlparse(self.path)
query = parse_qs(parsed.query)
if parsed.path == "/":
self.send_response(302)
self.send_header("location", "/manager")
self.end_headers()
return
if parsed.path == "/submit":
session = self._session()
if self._oauth_enabled() and not session:
self.send_response(302)
self.send_header("location", "/auth/feishu/start")
self.end_headers()
return
self._send(200, submit_page(today_string(), session))
return
if parsed.path == "/auth/feishu/start":
state = create_state()
redirect_uri = f"{self.config.base_url}/auth/feishu/callback"
self.send_response(302)
self.send_header("set-cookie", f"daily_report_oauth_state={state}; Path=/; HttpOnly; SameSite=Lax")
self.send_header("location", authorize_url(self.config.feishu_app_id, redirect_uri, state))
self.end_headers()
return
if parsed.path == "/auth/feishu/callback":
state = query.get("state", [""])[0]
code = query.get("code", [""])[0]
if not code or not state or state != self._cookie("daily_report_oauth_state"):
self._json(400, {"error": "invalid feishu oauth callback"})
return
redirect_uri = f"{self.config.base_url}/auth/feishu/callback"
token = exchange_code_for_user_access_token(
self.config.feishu_app_id,
self.config.feishu_app_secret,
code,
redirect_uri,
)
employee = fetch_user_info(token)
self.report_service.database.upsert_employee(employee)
cookie = create_session_cookie(
{"feishu_user_id": employee["feishu_user_id"], "name": employee["name"]},
self.config.session_secret,
)
self.send_response(302)
self.send_header("set-cookie", f"daily_report_session={cookie}; Path=/; HttpOnly; SameSite=Lax")
self.send_header("set-cookie", "daily_report_oauth_state=; Path=/; Max-Age=0")
self.send_header("location", "/submit")
self.end_headers()
return
if parsed.path == "/manager":
self._send(200, manager_page(query.get("date", [today_string()])[0]))
return
if parsed.path == "/api/reports":
self._json(200, self.report_service.list_reports_for_date(query.get("date", [today_string()])[0]))
return
if parsed.path == "/api/reports/history":
user_id = query.get("feishu_user_id", [""])[0]
try:
self._json(200, self.report_service.list_employee_history(user_id))
except ValueError as error:
self._json(400, {"error": str(error)})
return
if parsed.path == "/api/reports/export":
report_date = query.get("date", [today_string()])[0]
csv_text = self.report_service.export_reports_csv(report_date)
self.send_response(200)
self.send_header("content-type", "text/csv; charset=utf-8")
self.send_header("content-disposition", f'attachment; filename="daily-reports-{report_date}.csv"')
self.end_headers()
self.wfile.write(csv_text.encode("utf-8-sig"))
return
if parsed.path.startswith("/static/"):
self._serve_static(parsed.path.removeprefix("/static/"))
return
self._json(404, {"error": "not found"})
def do_POST(self) -> None:
parsed = urlparse(self.path)
try:
if parsed.path == "/api/reports":
self._json(200, {"report": self.report_service.upsert_report(self._read_json())})
return
if parsed.path == "/api/robot/send-reminder":
payload = create_reminder_payload(f"{self.config.base_url}/submit")
self._json(200, {"ok": True, "result": send_webhook(self.config.feishu_webhook_url, payload)})
return
if parsed.path == "/api/robot/send-summary":
body = self._read_json()
report_date = body.get("date") or today_string()
summary = self.report_service.list_reports_for_date(report_date)
payload = create_summary_payload(f"{self.config.base_url}/manager?date={report_date}", summary)
self._json(200, {"ok": True, "result": send_webhook(self.config.feishu_webhook_url, payload)})
return
self._json(404, {"error": "not found"})
except (ValueError, json.JSONDecodeError) as error:
self._json(400, {"error": str(error)})
except Exception as error:
self._json(500, {"error": str(error)})
def _serve_static(self, name: str) -> None:
safe_name = name.replace("\\", "/").split("/")[-1]
file_path = self.static_dir / safe_name
if not file_path.exists():
self._json(404, {"error": "not found"})
return
content_type = "application/javascript; charset=utf-8" if file_path.suffix == ".js" else "text/css; charset=utf-8"
self._send(200, file_path.read_bytes(), content_type)
def create_server(config: Config, database: Database | None = None) -> ThreadingHTTPServer:
db = database or Database(config.database_path, config.employee_seed_path)
service = ReportService(db)
class Handler(DailyReportHandler):
report_service = service
static_dir = config.root_dir / "daily_report" / "static"
Handler.config = config
server = ThreadingHTTPServer(("", config.port), Handler)
server.database = db # type: ignore[attr-defined]
return server
def main() -> None:
config = read_config()
server = create_server(config)
print(f"Daily report app listening on {config.base_url}")
try:
server.serve_forever()
finally:
server.server_close()
server.database.close() # type: ignore[attr-defined]
if __name__ == "__main__":
main()