349 lines
14 KiB
Python
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()
|