commit ebe9d5684aab6c8a1170d9f71e94c6233794cdcd Author: Codex Date: Thu May 7 16:31:56 2026 +0800 feat: add Feishu daily report app diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2447472 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +PORT=8787 +BASE_URL=http://localhost:8787 +DATABASE_PATH=data/daily-report.sqlite +EMPLOYEE_SEED_PATH=data/employees.json +WORKDAY_CALENDAR_PATH=data/workday-calendar.json +FEISHU_WEBHOOK_URL= +FEISHU_WEBHOOK_SECRET= +FEISHU_APP_ID= +FEISHU_APP_SECRET= +SESSION_SECRET= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e7a12ce --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.env +data/*.sqlite +data/*.sqlite-shm +data/*.sqlite-wal +__pycache__/ +*.pyc +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..8eacd66 --- /dev/null +++ b/README.md @@ -0,0 +1,145 @@ +# 飞书每日工作汇报系统 + +这是一个轻量的日报提交和浏览工具。员工通过飞书机器人提醒进入填写页提交日报,管理者在浏览页查看所有记录和未提交名单。 + +## 本地启动 + +本项目只使用 Python 标准库,不需要安装第三方依赖。 + +```bash +python -m daily_report.web +``` + +默认地址: + +- 提交页:http://localhost:8787/submit +- 管理页:http://localhost:8787/manager + +## 配置 + +复制 `.env.example` 为 `.env`,或直接设置环境变量: + +- `PORT`:服务端口,默认 `8787` +- `BASE_URL`:飞书消息里使用的外部访问地址 +- `DATABASE_PATH`:SQLite 数据库路径 +- `EMPLOYEE_SEED_PATH`:员工名单 JSON 路径 +- `WORKDAY_CALENDAR_PATH`:国家法定工作日历 JSON 路径 +- `FEISHU_WEBHOOK_URL`:飞书自定义机器人 Webhook +- `FEISHU_WEBHOOK_SECRET`:预留字段,后续用于签名校验 +- `FEISHU_APP_ID`:飞书企业自建应用的 App ID,用于自动识别填写人 +- `FEISHU_APP_SECRET`:飞书企业自建应用的 App Secret +- `SESSION_SECRET`:本系统登录 Cookie 签名密钥,可以填一串自己生成的随机字符 + +## 员工名单 + +第一版通过 `data/employees.json` 维护员工名单。每个员工需要: + +- `feishu_user_id` +- `name` +- `department` +- `manager` +- `active` + +修改员工名单后,重启服务会自动同步到 SQLite。 + +## 飞书机器人 + +在飞书群添加自定义机器人后,将 Webhook 地址配置到 `FEISHU_WEBHOOK_URL`。 + +手动发送提醒: + +```bash +curl -X POST http://localhost:8787/api/robot/send-reminder +``` + +手动发送汇总: + +```bash +curl -X POST http://localhost:8787/api/robot/send-summary -H "content-type: application/json" -d "{\"date\":\"2026-05-07\"}" +``` + +## 飞书自动识别填写人 + +要让员工打开填写页时自动识别身份,需要使用飞书企业自建应用。 + +在 `.env` 中填写: + +```dotenv +FEISHU_APP_ID=你的 App ID +FEISHU_APP_SECRET=你的 App Secret +SESSION_SECRET=任意一串较长随机字符 +``` + +然后在飞书开放平台应用后台配置网页应用重定向地址: + +```text +http://你的可访问地址:8787/auth/feishu/callback +``` + +这个地址里的域名和端口要与 `BASE_URL` 一致。配置后重启本系统,员工打开 `/submit` 会先跳转到飞书授权,授权完成后自动回到填写页,并自动带出当前填写人。 + +如果暂时不填 `FEISHU_APP_ID` 和 `FEISHU_APP_SECRET`,系统会退回手动填写员工 ID 的模式。 + +## Windows 任务计划 + +本项目提供 Windows 任务计划脚本。任务每天触发,但发送前会读取 `data/workday-calendar.json`,只在国家法定工作日发送: + +- 工作日 18:00:日报填写提醒 +- 工作日 19:00:日报提交汇总 + +这意味着: + +- 周一到周五如果是法定假期,不发送。 +- 周六、周日如果是调休补班,会发送。 + +任务计划会直接运行 Python 模块发送飞书消息,不要求 Web 服务正在运行。但 `BASE_URL` 必须是员工能打开的地址。 + +安装任务: + +```powershell +Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass +.\scripts\install-windows-tasks.ps1 +``` + +也可以直接双击: + +```text +install-tasks.bat +``` + +如果你的 Python 不在默认路径,可以指定: + +```powershell +.\scripts\install-windows-tasks.ps1 -PythonPath "C:\Path\To\python.exe" +``` + +手动测试提醒: + +```powershell +python -m daily_report.scheduled reminder +``` + +手动测试汇总: + +```powershell +python -m daily_report.scheduled summary +``` + +卸载任务: + +```powershell +.\scripts\uninstall-windows-tasks.ps1 +``` + +也可以直接双击: + +```text +uninstall-tasks.bat +``` + +## 验证 + +```bash +python -m unittest discover -s tests +python -m py_compile daily_report/config.py daily_report/db.py daily_report/report_service.py daily_report/robot_service.py daily_report/scheduled.py daily_report/web.py daily_report/workday.py +``` diff --git a/daily_report/__init__.py b/daily_report/__init__.py new file mode 100644 index 0000000..f5f2529 --- /dev/null +++ b/daily_report/__init__.py @@ -0,0 +1,2 @@ +"""Feishu daily report application.""" + diff --git a/daily_report/config.py b/daily_report/config.py new file mode 100644 index 0000000..1bfa7cc --- /dev/null +++ b/daily_report/config.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class Config: + root_dir: Path + port: int + base_url: str + database_path: Path + employee_seed_path: Path + workday_calendar_path: Path + feishu_webhook_url: str + feishu_webhook_secret: str + feishu_app_id: str + feishu_app_secret: str + session_secret: str + + +def _read_dotenv(path: Path) -> dict[str, str]: + if not path.exists(): + return {} + + values: dict[str, str] = {} + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + values[key.strip()] = value.strip().strip('"').strip("'") + return values + + +def read_config(env: dict[str, str] | None = None) -> Config: + root_dir = Path(__file__).resolve().parent.parent + dotenv_values = _read_dotenv(root_dir / ".env") + values = {**dotenv_values, **dict(env if env is not None else os.environ)} + port = int(values.get("PORT", "8787")) + if port <= 0: + raise ValueError("PORT must be a positive integer") + + return Config( + root_dir=root_dir, + port=port, + base_url=values.get("BASE_URL", f"http://localhost:{port}"), + database_path=(root_dir / values.get("DATABASE_PATH", "data/daily-report.sqlite")).resolve(), + employee_seed_path=(root_dir / values.get("EMPLOYEE_SEED_PATH", "data/employees.json")).resolve(), + workday_calendar_path=(root_dir / values.get("WORKDAY_CALENDAR_PATH", "data/workday-calendar.json")).resolve(), + feishu_webhook_url=values.get("FEISHU_WEBHOOK_URL", ""), + feishu_webhook_secret=values.get("FEISHU_WEBHOOK_SECRET", ""), + feishu_app_id=values.get("FEISHU_APP_ID", ""), + feishu_app_secret=values.get("FEISHU_APP_SECRET", ""), + session_secret=values.get("SESSION_SECRET") or values.get("FEISHU_APP_SECRET") or "dev-session-secret", + ) diff --git a/daily_report/db.py b/daily_report/db.py new file mode 100644 index 0000000..85a19ab --- /dev/null +++ b/daily_report/db.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +import json +import sqlite3 +import threading +from pathlib import Path +from typing import Any + + +class Database: + def __init__(self, database_path: Path, employee_seed_path: Path): + database_path.parent.mkdir(parents=True, exist_ok=True) + self.connection = sqlite3.connect(database_path, check_same_thread=False) + self.connection.row_factory = sqlite3.Row + self._lock = threading.RLock() + self._migrate() + self.load_employees(employee_seed_path) + + def close(self) -> None: + with self._lock: + self.connection.close() + + def _migrate(self) -> None: + with self._lock: + self.connection.executescript( + """ + CREATE TABLE IF NOT EXISTS employees ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + feishu_user_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + department TEXT NOT NULL DEFAULT '', + manager TEXT NOT NULL DEFAULT '', + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS daily_reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + feishu_user_id TEXT NOT NULL, + employee_name TEXT NOT NULL, + report_date TEXT NOT NULL, + today_done TEXT NOT NULL, + tomorrow_plan TEXT NOT NULL, + blockers TEXT NOT NULL DEFAULT '', + help_needed TEXT NOT NULL DEFAULT '', + submitted_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(feishu_user_id, report_date) + ); + """ + ) + self.connection.commit() + + def load_employees(self, employee_seed_path: Path) -> None: + if not employee_seed_path.exists(): + return + + employees = json.loads(employee_seed_path.read_text(encoding="utf-8")) + self.upsert_employees(employees) + + def upsert_employees(self, employees: list[dict[str, Any]]) -> None: + with self._lock, self.connection: + for employee in employees: + self.connection.execute( + """ + INSERT INTO employees (feishu_user_id, name, department, manager, active, updated_at) + VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(feishu_user_id) DO UPDATE SET + name = excluded.name, + department = excluded.department, + manager = excluded.manager, + active = excluded.active, + updated_at = CURRENT_TIMESTAMP + """, + ( + employee["feishu_user_id"], + employee["name"], + employee.get("department", ""), + employee.get("manager", ""), + 0 if employee.get("active") is False else 1, + ), + ) + + def upsert_employee(self, employee: dict[str, Any]) -> None: + self.upsert_employees([employee]) + + def list_active_employees(self) -> list[dict[str, Any]]: + with self._lock: + rows = self.connection.execute( + """ + SELECT feishu_user_id, name, department, manager, active + FROM employees + WHERE active = 1 + ORDER BY department, name + """ + ).fetchall() + return [dict(row) for row in rows] + + def find_employee(self, feishu_user_id: str) -> dict[str, Any] | None: + with self._lock: + row = self.connection.execute( + """ + SELECT feishu_user_id, name, department, manager, active + FROM employees + WHERE feishu_user_id = ? + """, + (feishu_user_id,), + ).fetchone() + return dict(row) if row else None + + def upsert_report(self, report: dict[str, Any]) -> None: + with self._lock, self.connection: + self.connection.execute( + """ + INSERT INTO daily_reports ( + feishu_user_id, employee_name, report_date, today_done, tomorrow_plan, + blockers, help_needed, submitted_at, updated_at + ) + VALUES ( + :feishu_user_id, :employee_name, :report_date, :today_done, :tomorrow_plan, + :blockers, :help_needed, :submitted_at, :updated_at + ) + ON CONFLICT(feishu_user_id, report_date) DO UPDATE SET + employee_name = excluded.employee_name, + today_done = excluded.today_done, + tomorrow_plan = excluded.tomorrow_plan, + blockers = excluded.blockers, + help_needed = excluded.help_needed, + updated_at = excluded.updated_at + """, + report, + ) + + def list_reports_for_date(self, report_date: str) -> list[dict[str, Any]]: + with self._lock: + rows = self.connection.execute( + """ + SELECT id, feishu_user_id, employee_name, report_date, today_done, + tomorrow_plan, blockers, help_needed, submitted_at, updated_at + FROM daily_reports + WHERE report_date = ? + ORDER BY employee_name + """, + (report_date,), + ).fetchall() + return [dict(row) for row in rows] + + def list_reports_for_employee(self, feishu_user_id: str, limit: int = 10) -> list[dict[str, Any]]: + with self._lock: + rows = self.connection.execute( + """ + SELECT id, feishu_user_id, employee_name, report_date, today_done, + tomorrow_plan, blockers, help_needed, submitted_at, updated_at + FROM daily_reports + WHERE feishu_user_id = ? + ORDER BY report_date DESC, updated_at DESC + LIMIT ? + """, + (feishu_user_id, limit), + ).fetchall() + return [dict(row) for row in rows] diff --git a/daily_report/feishu_auth.py b/daily_report/feishu_auth.py new file mode 100644 index 0000000..150e7ab --- /dev/null +++ b/daily_report/feishu_auth.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import secrets +import urllib.parse +import urllib.request +from typing import Any + + +AUTHORIZE_URL = "https://open.feishu.cn/open-apis/authen/v1/authorize" +TOKEN_URL = "https://open.feishu.cn/open-apis/authen/v2/oauth/token" +USER_INFO_URL = "https://open.feishu.cn/open-apis/authen/v1/user_info" + + +def _b64encode(data: bytes) -> str: + return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=") + + +def _b64decode(text: str) -> bytes: + padding = "=" * (-len(text) % 4) + return base64.urlsafe_b64decode(text + padding) + + +def create_session_cookie(session: dict[str, str], secret: str) -> str: + payload = _b64encode(json.dumps(session, ensure_ascii=False, separators=(",", ":")).encode("utf-8")) + signature = hmac.new(secret.encode("utf-8"), payload.encode("ascii"), hashlib.sha256).hexdigest() + return f"{payload}.{signature}" + + +def parse_session_cookie(cookie_value: str | None, secret: str) -> dict[str, str] | None: + if not cookie_value or "." not in cookie_value: + return None + + payload, signature = cookie_value.rsplit(".", 1) + expected = hmac.new(secret.encode("utf-8"), payload.encode("ascii"), hashlib.sha256).hexdigest() + if not hmac.compare_digest(signature, expected): + return None + + try: + data = json.loads(_b64decode(payload).decode("utf-8")) + except (ValueError, json.JSONDecodeError): + return None + if not isinstance(data, dict) or not data.get("feishu_user_id"): + return None + return {str(key): str(value) for key, value in data.items()} + + +def create_state() -> str: + return secrets.token_urlsafe(24) + + +def authorize_url(app_id: str, redirect_uri: str, state: str) -> str: + query = urllib.parse.urlencode( + { + "app_id": app_id, + "redirect_uri": redirect_uri, + "state": state, + } + ) + return f"{AUTHORIZE_URL}?{query}" + + +def exchange_code_for_user_access_token(app_id: str, app_secret: str, code: str, redirect_uri: str) -> str: + payload = json.dumps( + { + "grant_type": "authorization_code", + "client_id": app_id, + "client_secret": app_secret, + "code": code, + "redirect_uri": redirect_uri, + } + ).encode("utf-8") + request = urllib.request.Request( + TOKEN_URL, + data=payload, + headers={"content-type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(request, timeout=10) as response: + result = json.loads(response.read().decode("utf-8")) + + token = result.get("access_token") or result.get("data", {}).get("access_token") + if not token: + raise RuntimeError(f"Feishu token response missing access_token: {result}") + return str(token) + + +def fetch_user_info(user_access_token: str) -> dict[str, Any]: + request = urllib.request.Request( + USER_INFO_URL, + headers={"authorization": f"Bearer {user_access_token}"}, + method="GET", + ) + with urllib.request.urlopen(request, timeout=10) as response: + result = json.loads(response.read().decode("utf-8")) + + data = result.get("data", result) + user_id = data.get("user_id") or data.get("open_id") or data.get("union_id") + name = data.get("name") or data.get("en_name") or data.get("email") or user_id + if not user_id: + raise RuntimeError(f"Feishu user_info response missing user id: {result}") + + return { + "feishu_user_id": str(user_id), + "name": str(name), + "department": "", + "manager": "", + "active": True, + } diff --git a/daily_report/report_service.py b/daily_report/report_service.py new file mode 100644 index 0000000..612748e --- /dev/null +++ b/daily_report/report_service.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import csv +import io +from datetime import datetime, timezone +from typing import Any, Callable + +from .db import Database + + +Clock = Callable[[], datetime] + + +def _required_text(value: Any, field_name: str) -> str: + text = str(value or "").strip() + if not text: + raise ValueError(f"{field_name} is required") + return text + + +def _optional_text(value: Any) -> str: + return str(value or "").strip() + + +class ReportService: + def __init__(self, database: Database, clock: Clock | None = None): + self.database = database + self.clock = clock or (lambda: datetime.now(timezone.utc)) + + def upsert_report(self, data: dict[str, Any]) -> dict[str, Any]: + feishu_user_id = _required_text(data.get("feishu_user_id"), "feishu_user_id") + report_date = _required_text(data.get("report_date"), "report_date") + today_done = _required_text(data.get("today_done"), "today_done") + tomorrow_plan = _required_text(data.get("tomorrow_plan"), "tomorrow_plan") + employee = self.database.find_employee(feishu_user_id) + if not employee or employee.get("active") != 1: + raise ValueError("employee is not active") + + now = self.clock().isoformat() + self.database.upsert_report( + { + "feishu_user_id": feishu_user_id, + "employee_name": employee["name"], + "report_date": report_date, + "today_done": today_done, + "tomorrow_plan": tomorrow_plan, + "blockers": _optional_text(data.get("blockers")), + "help_needed": _optional_text(data.get("help_needed")), + "submitted_at": now, + "updated_at": now, + } + ) + return next( + report + for report in self.list_reports_for_date(report_date)["reports"] + if report["feishu_user_id"] == feishu_user_id + ) + + def list_reports_for_date(self, report_date: str) -> dict[str, Any]: + date = _required_text(report_date, "report_date") + employees = self.database.list_active_employees() + reports = self.database.list_reports_for_date(date) + submitted_ids = {report["feishu_user_id"] for report in reports} + missing = [employee for employee in employees if employee["feishu_user_id"] not in submitted_ids] + return { + "date": date, + "expectedCount": len(employees), + "submittedCount": len(reports), + "missingCount": len(missing), + "reports": reports, + "missing": missing, + } + + def export_reports_csv(self, report_date: str) -> str: + result = self.list_reports_for_date(report_date) + output = io.StringIO() + fieldnames = [ + "employee_name", + "report_date", + "today_done", + "tomorrow_plan", + "blockers", + "help_needed", + "submitted_at", + ] + writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore") + writer.writeheader() + writer.writerows(result["reports"]) + return output.getvalue() + + def list_employee_history(self, feishu_user_id: str, limit: int = 10) -> dict[str, Any]: + user_id = _required_text(feishu_user_id, "feishu_user_id") + employee = self.database.find_employee(user_id) + if not employee or employee.get("active") != 1: + raise ValueError("employee is not active") + + return { + "employee": employee, + "reports": self.database.list_reports_for_employee(user_id, limit), + } diff --git a/daily_report/robot_service.py b/daily_report/robot_service.py new file mode 100644 index 0000000..ad8677b --- /dev/null +++ b/daily_report/robot_service.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import json +import urllib.error +import urllib.request +from typing import Any + + +def _button(text: str, url: str) -> dict[str, Any]: + return { + "tag": "button", + "text": {"tag": "plain_text", "content": text}, + "type": "primary", + "url": url, + } + + +def create_reminder_payload(submit_url: str, title: str = "每日工作汇报提醒") -> dict[str, Any]: + return { + "msg_type": "interactive", + "card": { + "header": { + "title": {"tag": "plain_text", "content": title}, + "template": "blue", + }, + "elements": [ + {"tag": "div", "text": {"tag": "lark_md", "content": "请提交今日日报。"}}, + {"tag": "action", "actions": [_button("填写日报", submit_url)]}, + ], + }, + } + + +def create_summary_payload(manager_url: str, summary: dict[str, Any]) -> dict[str, Any]: + missing_names = "、".join(employee["name"] for employee in summary["missing"]) or "无" + return { + "msg_type": "interactive", + "card": { + "header": { + "title": {"tag": "plain_text", "content": f"{summary['date']} 日报提交汇总"}, + "template": "orange" if summary["missingCount"] > 0 else "green", + }, + "elements": [ + { + "tag": "div", + "text": { + "tag": "lark_md", + "content": f"已提交:{summary['submittedCount']}/{summary['expectedCount']}\n未提交:{missing_names}", + }, + }, + {"tag": "action", "actions": [_button("查看全部日报", manager_url)]}, + ], + }, + } + + +def send_webhook(webhook_url: str, payload: dict[str, Any]) -> dict[str, Any]: + if not webhook_url: + raise ValueError("FEISHU_WEBHOOK_URL is required") + + request = urllib.request.Request( + webhook_url, + data=json.dumps(payload).encode("utf-8"), + headers={"content-type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=10) as response: + body = response.read().decode("utf-8") + return json.loads(body) if body else {"ok": True} + except urllib.error.HTTPError as error: + raise RuntimeError(f"Feishu webhook failed with status {error.code}") from error diff --git a/daily_report/scheduled.py b/daily_report/scheduled.py new file mode 100644 index 0000000..783dd33 --- /dev/null +++ b/daily_report/scheduled.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import argparse +from datetime import date + +from .config import read_config +from .db import Database +from .report_service import ReportService +from .robot_service import create_reminder_payload, create_summary_payload, send_webhook +from .workday import is_workday + + +def send_reminder() -> dict: + config = read_config() + if not is_workday(date.today(), config.workday_calendar_path): + return {"skipped": True, "reason": "not a workday"} + payload = create_reminder_payload(f"{config.base_url}/submit") + return send_webhook(config.feishu_webhook_url, payload) + + +def send_summary(report_date: str | None = None) -> dict: + config = read_config() + selected_date = date.fromisoformat(report_date) if report_date else date.today() + if not is_workday(selected_date, config.workday_calendar_path): + return {"skipped": True, "reason": "not a workday", "date": selected_date.isoformat()} + + database = Database(config.database_path, config.employee_seed_path) + try: + service = ReportService(database) + selected_date_text = selected_date.isoformat() + summary = service.list_reports_for_date(selected_date_text) + payload = create_summary_payload(f"{config.base_url}/manager?date={selected_date_text}", summary) + return send_webhook(config.feishu_webhook_url, payload) + finally: + database.close() + + +def main() -> None: + parser = argparse.ArgumentParser(description="发送飞书日报定时消息") + parser.add_argument("action", choices=["reminder", "summary"], help="reminder 发送提醒,summary 发送汇总") + parser.add_argument("--date", help="汇总日期,格式 YYYY-MM-DD;不传则使用当天") + args = parser.parse_args() + + if args.action == "reminder": + result = send_reminder() + else: + result = send_summary(args.date) + print(result) + + +if __name__ == "__main__": + main() diff --git a/daily_report/static/manager.js b/daily_report/static/manager.js new file mode 100644 index 0000000..a1ce236 --- /dev/null +++ b/daily_report/static/manager.js @@ -0,0 +1,67 @@ +const state = { data: null }; + +function text(value) { + return String(value || ""); +} + +function render() { + const employeeQuery = document.querySelector("#employee-filter").value.trim().toLowerCase(); + const keywordQuery = document.querySelector("#keyword-filter").value.trim().toLowerCase(); + const onlyBlockers = document.querySelector("#blocker-filter").checked; + const data = state.data; + + document.querySelector("#stats").innerHTML = ` + 应提交:${data.expectedCount} + 已提交:${data.submittedCount} + 未提交:${data.missingCount} + `; + + const reports = data.reports.filter((report) => { + const employeeMatch = report.employee_name.toLowerCase().includes(employeeQuery); + const searchable = [report.today_done, report.tomorrow_plan, report.blockers, report.help_needed].join(" ").toLowerCase(); + const keywordMatch = searchable.includes(keywordQuery); + const blockerMatch = !onlyBlockers || report.blockers || report.help_needed; + return employeeMatch && keywordMatch && blockerMatch; + }); + + document.querySelector("#reports").innerHTML = reports.length ? reports.map((report) => ` +
+

${report.employee_name}

+
+
今日完成
${text(report.today_done)}
+
明日计划
${text(report.tomorrow_plan)}
+
遇到的问题
${text(report.blockers) || "无"}
+
需要协助
${text(report.help_needed) || "无"}
+
+

提交时间:${report.updated_at}

+
+ `).join("") : "

当前筛选下没有日报。

"; + + document.querySelector("#missing").textContent = data.missing.length + ? data.missing.map((employee) => employee.name).join("、") + : "无"; +} + +async function loadReports() { + const date = document.querySelector("#date-filter").value; + document.querySelector("#export-link").href = `/api/reports/export?date=${date}`; + const response = await fetch(`/api/reports?date=${date}`); + state.data = await response.json(); + render(); +} + +document.querySelector("#date-filter").addEventListener("change", loadReports); +document.querySelector("#employee-filter").addEventListener("input", render); +document.querySelector("#keyword-filter").addEventListener("input", render); +document.querySelector("#blocker-filter").addEventListener("change", render); +document.querySelector("#copy-summary").addEventListener("click", async () => { + const data = state.data; + const lines = [ + `${data.date} 日报汇总`, + `已提交:${data.submittedCount}/${data.expectedCount}`, + `未提交:${data.missing.length ? data.missing.map((employee) => employee.name).join("、") : "无"}` + ]; + await navigator.clipboard.writeText(lines.join("\n")); +}); + +loadReports(); diff --git a/daily_report/static/styles.css b/daily_report/static/styles.css new file mode 100644 index 0000000..b95a10f --- /dev/null +++ b/daily_report/static/styles.css @@ -0,0 +1,73 @@ +* { box-sizing: border-box; } +body { + margin: 0; + font-family: Arial, "Microsoft YaHei", sans-serif; + background: #f6f7f9; + color: #1f2937; +} +.shell { width: min(1120px, calc(100% - 32px)); margin: 32px auto; } +.narrow { width: min(720px, calc(100% - 32px)); } +.topbar { display: flex; justify-content: space-between; gap: 16px; align-items: center; } +h1 { margin: 0 0 8px; font-size: 28px; } +h2 { font-size: 18px; } +p { color: #5b6472; } +.panel, .filters, .stats, .report-card, .missing { + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 16px; +} +form { display: grid; gap: 14px; } +label { display: grid; gap: 6px; font-weight: 600; } +input, textarea, button { + font: inherit; + border-radius: 6px; + border: 1px solid #cfd5df; + padding: 10px 12px; +} +textarea { resize: vertical; } +button { + background: #2563eb; + color: #fff; + border: 0; + cursor: pointer; +} +.filters { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; + align-items: center; + margin: 20px 0; +} +.check { display: flex; align-items: center; gap: 8px; font-weight: 400; } +.stats { display: flex; gap: 20px; margin-bottom: 16px; } +.report-list { display: grid; gap: 12px; } +.report-card h3 { margin: 0 0 10px; } +.report-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } +.field-title { font-weight: 700; margin-bottom: 4px; } +.history-panel { margin-top: 18px; } +.history-head { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: center; +} +.history-list { display: grid; gap: 12px; } +.history-card { + border-top: 1px solid #e5e7eb; + padding-top: 12px; +} +.history-card h3 { margin: 0 0 10px; } +pre { + white-space: pre-wrap; + word-break: break-word; + margin: 6px 0 12px; + font: inherit; + color: #374151; +} +.message.success { color: #047857; } +.message.error { color: #b91c1c; } +@media (max-width: 760px) { + .topbar, .stats, .history-head { display: grid; } + .filters, .report-grid { grid-template-columns: 1fr; } +} diff --git a/daily_report/web.py b/daily_report/web.py new file mode 100644 index 0000000..55a910c --- /dev/null +++ b/daily_report/web.py @@ -0,0 +1,348 @@ +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""" + + + + + {escape(title)} + + + +{body} +{scripts} + +""" + 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""" +
+ 当前填写人 + {escape(session.get("name", session["feishu_user_id"]))} +
+ +""" + history_hint = "系统已自动识别你的飞书身份,可以查看自己最近提交的记录。" + else: + identity_html = '' + history_hint = "输入员工 ID 后可以查看自己最近提交的记录。" + + return page( + "每日报告", + f""" +
+

每日报告

+
+ {identity_html} + + + + + + +

+
+
+
+
+

我的历史日报

+

{history_hint}

+
+ +
+
暂无历史记录。
+
+
+""", + ) + + +def manager_page(current_date: str) -> bytes: + return page( + "日报浏览", + f""" +
+
+
+

日报浏览

+

按日期、员工和关键词浏览所有日报记录。

+
+ +
+
+ + + + + 导出 CSV +
+
+
+
+

未提交人员

+
+
+
""", + '', + ) + + +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() diff --git a/daily_report/workday.py b/daily_report/workday.py new file mode 100644 index 0000000..23725ee --- /dev/null +++ b/daily_report/workday.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import json +from datetime import date +from pathlib import Path + + +def is_workday(selected_date: date, calendar_path: Path) -> bool: + if not calendar_path.exists(): + return selected_date.weekday() < 5 + + calendar = json.loads(calendar_path.read_text(encoding="utf-8")) + day = selected_date.isoformat() + holidays = set(calendar.get("holidays", [])) + makeup_workdays = set(calendar.get("makeup_workdays", [])) + + if day in makeup_workdays: + return True + if day in holidays: + return False + return selected_date.weekday() < 5 diff --git a/data/employees.json b/data/employees.json new file mode 100644 index 0000000..7ac30b8 --- /dev/null +++ b/data/employees.json @@ -0,0 +1,16 @@ +[ + { + "feishu_user_id": "u_alice", + "name": "Alice", + "department": "Operations", + "manager": "Manager", + "active": true + }, + { + "feishu_user_id": "u_bob", + "name": "Bob", + "department": "Operations", + "manager": "Manager", + "active": true + } +] diff --git a/data/workday-calendar.json b/data/workday-calendar.json new file mode 100644 index 0000000..fc480a3 --- /dev/null +++ b/data/workday-calendar.json @@ -0,0 +1,47 @@ +{ + "year": 2026, + "source": "https://www.gov.cn/yaowen/liebiao/202511/content_7047099.htm", + "holidays": [ + "2026-01-01", + "2026-01-02", + "2026-01-03", + "2026-02-15", + "2026-02-16", + "2026-02-17", + "2026-02-18", + "2026-02-19", + "2026-02-20", + "2026-02-21", + "2026-02-22", + "2026-02-23", + "2026-04-04", + "2026-04-05", + "2026-04-06", + "2026-05-01", + "2026-05-02", + "2026-05-03", + "2026-05-04", + "2026-05-05", + "2026-06-19", + "2026-06-20", + "2026-06-21", + "2026-09-25", + "2026-09-26", + "2026-09-27", + "2026-10-01", + "2026-10-02", + "2026-10-03", + "2026-10-04", + "2026-10-05", + "2026-10-06", + "2026-10-07" + ], + "makeup_workdays": [ + "2026-01-04", + "2026-02-14", + "2026-02-28", + "2026-05-09", + "2026-09-20", + "2026-10-10" + ] +} diff --git a/docs/superpowers/plans/2026-05-07-feishu-daily-report.md b/docs/superpowers/plans/2026-05-07-feishu-daily-report.md new file mode 100644 index 0000000..b72c014 --- /dev/null +++ b/docs/superpowers/plans/2026-05-07-feishu-daily-report.md @@ -0,0 +1,139 @@ +# 飞书每日工作汇报系统实施计划 + +> **给执行代理的要求:** 实施本计划时必须使用 `superpowers:subagent-driven-development`(推荐)或 `superpowers:executing-plans`。所有步骤使用复选框语法,便于逐项跟踪。 + +**目标:** 构建第一版飞书日报 Web 应用,让员工提交每日报告,让管理者在一个页面浏览所有日报记录。 + +**架构:** 使用 Python 标准库实现一个零第三方依赖的小型 Web 应用。后端使用 `http.server` 提供页面和 API,使用 `sqlite3` 保存员工与日报数据,使用 `urllib.request` 调用飞书机器人 Webhook。前端使用原生 HTML/CSS/JavaScript。 + +**技术栈:** Python 标准库、`http.server`、`sqlite3`、`unittest`、原生 HTML/CSS/JavaScript、飞书自定义机器人 Webhook。 + +--- + +## 文件结构 + +- `requirements.txt`:说明项目只使用 Python 标准库。 +- `.env.example`:环境变量示例。 +- `.gitignore`:忽略本地数据库、环境变量和缓存文件。 +- `data/employees.json`:第一版员工名单种子文件。 +- `daily_report/config.py`:读取环境变量和 `.env` 配置。 +- `daily_report/db.py`:SQLite 建表、员工导入和基础查询。 +- `daily_report/report_service.py`:日报校验、创建/更新、列表、未提交人员和 CSV 导出。 +- `daily_report/robot_service.py`:飞书机器人提醒/汇总消息体和 Webhook 发送。 +- `daily_report/web.py`:HTTP 页面路由、API 路由和静态文件服务。 +- `daily_report/static/styles.css`:提交页和管理页样式。 +- `daily_report/static/manager.js`:管理页筛选、复制汇总和数据加载逻辑。 +- `tests/test_report_service.py`:日报业务测试。 +- `tests/test_robot_service.py`:机器人消息体测试。 +- `tests/test_web.py`:页面和 API 冒烟测试。 +- `README.md`:中文使用说明。 + +## 任务 1:项目骨架 + +- [x] 创建 Python 包 `daily_report`。 +- [x] 创建 `requirements.txt`,说明无需第三方依赖。 +- [x] 创建 `.gitignore`。 +- [x] 创建 `.env.example`。 +- [x] 创建 `data/employees.json` 示例员工名单。 +- [x] 实现 `daily_report/config.py`,读取端口、基础 URL、数据库路径、员工名单路径和飞书 Webhook 配置。 + +验证: + +```bash +python -m py_compile daily_report/config.py +``` + +## 任务 2:数据层和日报服务 + +- [x] 实现 `daily_report/db.py`。 +- [x] 创建 `employees` 表。 +- [x] 创建 `daily_reports` 表。 +- [x] 导入 `data/employees.json` 中的有效员工。 +- [x] 实现同一员工同一天唯一日报。 +- [x] 实现 `daily_report/report_service.py`。 +- [x] 校验必填字段:员工 ID、日期、今日完成、明日计划。 +- [x] 重复提交时更新当天已有日报。 +- [x] 计算已提交、未提交和应提交人数。 +- [x] 导出 CSV。 +- [x] 编写 `tests/test_report_service.py`。 + +验证: + +```bash +python -m unittest tests.test_report_service +``` + +## 任务 3:飞书机器人服务 + +- [x] 实现 `daily_report/robot_service.py`。 +- [x] 生成“填写日报”提醒消息体。 +- [x] 生成“日报提交汇总”消息体。 +- [x] 支持调用飞书自定义机器人 Webhook。 +- [x] 编写 `tests/test_robot_service.py`。 + +验证: + +```bash +python -m unittest tests.test_robot_service +``` + +## 任务 4:页面和 API + +- [x] 实现 `daily_report/web.py`。 +- [x] 提供 `/submit` 员工日报提交页。 +- [x] 提供 `/manager` 管理者日报浏览页。 +- [x] 提供 `POST /api/reports` 创建或更新日报。 +- [x] 提供 `GET /api/reports?date=YYYY-MM-DD` 查询某日汇总。 +- [x] 提供 `GET /api/reports/export?date=YYYY-MM-DD` 导出 CSV。 +- [x] 提供 `POST /api/robot/send-reminder` 发送飞书提醒。 +- [x] 提供 `POST /api/robot/send-summary` 发送飞书汇总。 +- [x] 实现 `daily_report/static/styles.css`。 +- [x] 实现 `daily_report/static/manager.js`。 +- [x] 编写 `tests/test_web.py`。 + +验证: + +```bash +python -m unittest tests.test_web +``` + +## 任务 5:文档和手动验证 + +- [x] 编写中文 `README.md`。 +- [x] 更新设计文档中的实际技术方案。 +- [x] 启动本地服务。 +- [x] 验证管理页可以访问。 +- [x] 验证日报提交 API 可以写入数据。 +- [x] 验证日报汇总 API 可以读取数据。 + +启动: + +```bash +python -m daily_report.web +``` + +访问: + +- 提交页:http://localhost:8787/submit +- 管理页:http://localhost:8787/manager + +## 最终验证 + +```bash +python -m unittest discover -s tests +python -m py_compile daily_report/config.py daily_report/db.py daily_report/report_service.py daily_report/robot_service.py daily_report/web.py +``` + +当前验证结果: + +- `python -m unittest discover -s tests`:8 个测试通过。 +- `python -m py_compile ...`:通过。 +- `http://localhost:8787/manager`:返回 200。 + +## 后续增强 + +- 接入飞书 OAuth,自动识别员工身份。 +- 增加管理者登录和权限控制。 +- 增加飞书多维表格同步。 +- 增加定时任务,让提醒和汇总自动发送。 +- 增加员工名单管理页面,减少手工编辑 JSON。 diff --git a/docs/superpowers/specs/2026-05-07-feishu-daily-report-design.md b/docs/superpowers/specs/2026-05-07-feishu-daily-report-design.md new file mode 100644 index 0000000..b514dd3 --- /dev/null +++ b/docs/superpowers/specs/2026-05-07-feishu-daily-report-design.md @@ -0,0 +1,212 @@ +# 飞书每日工作汇报系统设计 + +## 目标 + +建设一个轻量、实用的飞书每日工作汇报系统。员工每天通过飞书机器人提醒进入填写页面提交日报,管理者可以在一个内部浏览页面查看所有人的日报记录。 + +第一版优先保证快速上线、流程顺畅、后续容易扩展。核心能力包括:每日提醒、员工填写、数据保存、管理者统一浏览、未提交统计和每日汇总。 + +## 使用角色 + +- 员工:每天提交一份工作日报。 +- 管理者:查看日报、检查未提交人员、复制或导出汇总。 +- 管理员:维护员工名单和飞书机器人配置。 + +## 推荐方案 + +采用“飞书机器人 + 小型 Web 应用”的方案。 + +- 飞书机器人每天在指定群发送日报提醒。 +- 提醒消息里包含“填写日报”的按钮或链接。 +- 员工打开填写页面并提交日报。 +- 管理者打开浏览页面,在一个页面里查看所有记录。 +- 日报数据由应用保存,后续可以按需同步到飞书多维表格。 + +这个方案比只使用多维表格更适合阅读和管理,也比完整飞书自建应用更容易启动。 + +## 日报字段 + +第一版采用标准四段式日报模板: + +- 员工姓名和飞书用户 ID +- 汇报日期 +- 今日完成 +- 明日计划 +- 遇到的问题或阻塞 +- 需要协助的事项 +- 提交时间 +- 更新时间 +- 提交状态 + +必填字段: + +- 员工身份 +- 汇报日期 +- 今日完成 +- 明日计划 + +选填字段: + +- 遇到的问题或阻塞 +- 需要协助的事项 + +如果员工当天重复提交,系统更新当天已有记录,不额外创建重复日报。 + +## 飞书流程 + +1. 到达配置的提醒时间,例如 18:00,机器人在工作汇报群发送日报提醒。 +2. 员工点击提醒消息中的“填写日报”。 +3. 系统打开日报填写页面,并默认选择当天日期。 +4. 员工填写四段式日报并提交。 +5. 后端校验并保存日报。 +6. 页面显示提交成功状态。 +7. 到达配置的汇总时间,例如 19:00,机器人在群里发送完成情况汇总: + - 应提交人数 + - 已提交人数 + - 未提交人员名单 + - 管理者浏览页面链接 + +第一版可以先使用带签名的链接或简单的员工选择方式识别员工。后续版本再接入飞书 OAuth,自动识别当前登录用户。 + +## 管理者浏览页面 + +管理者浏览页是日常查看日报的主工作台,打开后默认展示当天日报。 + +筛选控件: + +- 日期选择 +- 员工筛选 +- 部门筛选,如果已配置部门 +- 关键词搜索 +- 只看未提交 +- 只看有问题或需要协助的日报 + +主要内容: + +- 当前日期的提交统计 +- 按员工分组展示已提交日报 +- 未提交人员名单 +- 每条日报展示今日完成、明日计划、问题、协助需求和提交时间 + +管理操作: + +- 复制今日汇总 +- 导出 CSV +- 打开原始数据表 + +页面应优先保证阅读体验,而不是做成纯表格。管理者应该能在一个页面里快速扫完当天所有日报。 + +## 数据模型 + +### employees + +- id +- feishu_user_id +- name +- department +- manager +- active +- created_at +- updated_at + +### daily_reports + +- id +- feishu_user_id +- employee_name +- report_date +- today_done +- tomorrow_plan +- blockers +- help_needed +- submitted_at +- updated_at + +唯一约束: + +- 同一个 `feishu_user_id` 在同一个 `report_date` 只能有一条日报 + +## API 设计 + +### 员工接口 + +- `GET /submit` + - 展示日报填写页面。 + +- `POST /api/reports` + - 创建或更新某员工某日期的日报。 + +### 管理者接口 + +- `GET /manager` + - 展示日报浏览页面。 + +- `GET /api/reports?date=YYYY-MM-DD` + - 返回指定日期的已提交日报和未提交人员。 + +- `GET /api/reports/export?date=YYYY-MM-DD` + - 导出指定日期的日报 CSV。 + +### 机器人接口 + +- `POST /api/robot/send-reminder` + - 向飞书群发送日报提醒。 + +- `POST /api/robot/send-summary` + - 向飞书群发送日报完成情况汇总。 + +这些接口第一版可以由本地定时任务或手动脚本调用。 + +## 技术方案 + +第一版实际技术栈: + +- 后端:Python 标准库 `http.server` +- 前端:原生 HTML/CSS/JavaScript +- 数据库:SQLite +- 飞书集成:飞书自定义机器人 Webhook,用于发送提醒和汇总 + +Python 标准库方案不依赖 npm 或第三方包,适合当前本地环境快速运行。SQLite 足够支撑第一版,并且部署简单。如果后续多人并发或权限要求增强,可以迁移到 PostgreSQL,用户流程不需要变化。 + +## 错误处理 + +- 必填字段缺失时,在表单内展示校验错误。 +- 当天重复提交时,更新已有日报。 +- 机器人发送失败时记录日志,并支持手动重试。 +- 管理页在指定日期没有日报时展示空状态。 +- 无法识别员工身份时,提交页要求员工从有效员工名单中选择姓名。 + +## 安全设计 + +第一版: + +- 使用难以猜测的提交链接,或仅在公司内部网络/内部成员范围内访问。 +- 管理页面只在内部使用。 +- 飞书 Webhook 密钥不写入源代码。 + +后续版本: + +- 接入飞书 OAuth 自动识别员工身份。 +- 增加管理者权限控制。 +- 增加日报编辑审计日志。 + +## 测试范围 + +核心检查项: + +- 可以提交一份完整日报。 +- 缺少必填字段时拒绝提交。 +- 同一天重复提交会更新原记录。 +- 管理页能展示某日期的已提交和未提交人员。 +- 管理页能按员工和关键词筛选。 +- 可以导出 CSV。 +- 可以生成提醒消息和汇总消息。 + +## 里程碑 + +1. 搭建本地数据模型和日报提交流程。 +2. 搭建管理者浏览页面。 +3. 通过种子文件或简单配置维护员工名单。 +4. 接入飞书机器人提醒和汇总发送。 +5. 增加 CSV 导出和复制汇总功能。 +6. 可选:接入飞书 OAuth 和多维表格同步。 diff --git a/install-tasks.bat b/install-tasks.bat new file mode 100644 index 0000000..171f2b3 --- /dev/null +++ b/install-tasks.bat @@ -0,0 +1,20 @@ +@echo off +setlocal +cd /d "%~dp0" +set "LOG=%~dp0install-tasks.log" + +echo Installing Windows scheduled tasks... +echo Installing Windows scheduled tasks... > "%LOG%" +echo Project: %CD% >> "%LOG%" +echo. >> "%LOG%" + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\install-windows-tasks.ps1" >> "%LOG%" 2>&1 +type "%LOG%" + +echo. +echo If there is no error above, tasks are installed: +echo - FeishuDailyReport-Reminder runs daily at 18:00 and skips holidays +echo - FeishuDailyReport-Summary runs daily at 19:00 and skips holidays +echo. +echo If there is an error, send Codex a screenshot. +pause diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a4f2751 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +# This project uses only the Python standard library. diff --git a/scripts/install-windows-tasks.ps1 b/scripts/install-windows-tasks.ps1 new file mode 100644 index 0000000..54a1666 --- /dev/null +++ b/scripts/install-windows-tasks.ps1 @@ -0,0 +1,48 @@ +param( + [string]$PythonPath = "C:\Users\13636\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe", + [string]$TaskPrefix = "FeishuDailyReport", + [string]$ReminderTime = "18:00", + [string]$SummaryTime = "19:00" +) + +$ErrorActionPreference = "Stop" +$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + +if (-not (Test-Path $PythonPath)) { + throw "Python not found: $PythonPath" +} + +$reminderAction = New-ScheduledTaskAction ` + -Execute $PythonPath ` + -Argument "-m daily_report.scheduled reminder" ` + -WorkingDirectory $ProjectRoot + +$summaryAction = New-ScheduledTaskAction ` + -Execute $PythonPath ` + -Argument "-m daily_report.scheduled summary" ` + -WorkingDirectory $ProjectRoot + +$reminderTrigger = New-ScheduledTaskTrigger -Daily -At $ReminderTime +$summaryTrigger = New-ScheduledTaskTrigger -Daily -At $SummaryTime +$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries + +Register-ScheduledTask ` + -TaskName "$TaskPrefix-Reminder" ` + -Action $reminderAction ` + -Trigger $reminderTrigger ` + -Settings $settings ` + -Description "Runs daily at $ReminderTime; script sends only on configured workdays" ` + -Force | Out-Null + +Register-ScheduledTask ` + -TaskName "$TaskPrefix-Summary" ` + -Action $summaryAction ` + -Trigger $summaryTrigger ` + -Settings $settings ` + -Description "Runs daily at $SummaryTime; script sends only on configured workdays" ` + -Force | Out-Null + +Write-Host "Windows scheduled tasks installed:" +Write-Host " - $TaskPrefix-Reminder: runs daily at $ReminderTime and skips non-workdays" +Write-Host " - $TaskPrefix-Summary: runs daily at $SummaryTime and skips non-workdays" +Write-Host "Project root: $ProjectRoot" diff --git a/scripts/uninstall-windows-tasks.ps1 b/scripts/uninstall-windows-tasks.ps1 new file mode 100644 index 0000000..8776340 --- /dev/null +++ b/scripts/uninstall-windows-tasks.ps1 @@ -0,0 +1,15 @@ +param( + [string]$TaskPrefix = "FeishuDailyReport" +) + +$ErrorActionPreference = "Stop" + +foreach ($name in @("$TaskPrefix-Reminder", "$TaskPrefix-Summary")) { + $task = Get-ScheduledTask -TaskName $name -ErrorAction SilentlyContinue + if ($task) { + Unregister-ScheduledTask -TaskName $name -Confirm:$false + Write-Host "Removed task: $name" + } else { + Write-Host "Task not found: $name" + } +} diff --git a/send-reminder.bat b/send-reminder.bat new file mode 100644 index 0000000..1e863e1 --- /dev/null +++ b/send-reminder.bat @@ -0,0 +1,26 @@ +@echo off +setlocal +cd /d "%~dp0" +set "PY=C:\Users\13636\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe" +set "LOG=%~dp0send-reminder.log" + +echo Sending Feishu reminder... +echo Sending Feishu reminder... > "%LOG%" +echo Project: %CD% >> "%LOG%" +echo Python: %PY% >> "%LOG%" +echo. >> "%LOG%" + +if not exist "%PY%" ( + echo Python not found: %PY% + echo Python not found: %PY% >> "%LOG%" + goto END +) + +"%PY%" -m daily_report.scheduled reminder >> "%LOG%" 2>&1 +type "%LOG%" + +:END +echo. +echo Log file: %LOG% +echo Please check the Feishu group. If there is an error, send Codex a screenshot. +pause diff --git a/send-summary.bat b/send-summary.bat new file mode 100644 index 0000000..d6e2725 --- /dev/null +++ b/send-summary.bat @@ -0,0 +1,26 @@ +@echo off +setlocal +cd /d "%~dp0" +set "PY=C:\Users\13636\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe" +set "LOG=%~dp0send-summary.log" + +echo Sending Feishu summary... +echo Sending Feishu summary... > "%LOG%" +echo Project: %CD% >> "%LOG%" +echo Python: %PY% >> "%LOG%" +echo. >> "%LOG%" + +if not exist "%PY%" ( + echo Python not found: %PY% + echo Python not found: %PY% >> "%LOG%" + goto END +) + +"%PY%" -m daily_report.scheduled summary >> "%LOG%" 2>&1 +type "%LOG%" + +:END +echo. +echo Log file: %LOG% +echo Please check the Feishu group. If there is an error, send Codex a screenshot. +pause diff --git a/start-server.bat b/start-server.bat new file mode 100644 index 0000000..e7b5fcb --- /dev/null +++ b/start-server.bat @@ -0,0 +1,20 @@ +@echo off +setlocal +cd /d "%~dp0" +set "PY=C:\Users\13636\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe" + +echo Starting Feishu daily report server... +echo Project: %CD% +echo. + +if not exist "%PY%" ( + echo Python not found: %PY% + echo Send Codex a screenshot. + pause + exit /b 1 +) + +"%PY%" -m daily_report.web +echo. +echo Server stopped. +pause diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..01888aa --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from daily_report import config + + +class ConfigTest(unittest.TestCase): + def test_reads_dotenv_and_allows_environment_override(self) -> None: + with tempfile.TemporaryDirectory(prefix="daily-report-config-") as temp: + root = Path(temp) + (root / ".env").write_text( + "\n".join( + [ + "PORT=9000", + "BASE_URL=http://example.test", + "FEISHU_WEBHOOK_URL=https://open.feishu.cn/webhook", + "FEISHU_APP_ID=cli_xxx", + "FEISHU_APP_SECRET=secret", + ] + ), + encoding="utf-8", + ) + fake_file = root / "daily_report" / "config.py" + fake_file.parent.mkdir() + fake_file.write_text("", encoding="utf-8") + + with patch.object(config, "__file__", str(fake_file)): + result = config.read_config({"PORT": "9001"}) + + self.assertEqual(result.port, 9001) + self.assertEqual(result.base_url, "http://example.test") + self.assertEqual(result.feishu_webhook_url, "https://open.feishu.cn/webhook") + self.assertEqual(result.feishu_app_id, "cli_xxx") + self.assertEqual(result.feishu_app_secret, "secret") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_feishu_auth.py b/tests/test_feishu_auth.py new file mode 100644 index 0000000..bda586a --- /dev/null +++ b/tests/test_feishu_auth.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import unittest + +from daily_report.feishu_auth import create_session_cookie, parse_session_cookie + + +class FeishuAuthTest(unittest.TestCase): + def test_session_cookie_round_trip_and_rejects_tampering(self) -> None: + cookie = create_session_cookie({"feishu_user_id": "ou_1", "name": "张三"}, "secret") + session = parse_session_cookie(cookie, "secret") + + self.assertEqual(session["feishu_user_id"], "ou_1") + self.assertEqual(session["name"], "张三") + self.assertIsNone(parse_session_cookie(cookie + "x", "secret")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_report_service.py b/tests/test_report_service.py new file mode 100644 index 0000000..f3da3e1 --- /dev/null +++ b/tests/test_report_service.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import json +import tempfile +import unittest +from datetime import datetime, timezone +from pathlib import Path + +from daily_report.db import Database +from daily_report.report_service import ReportService + + +def make_database(employees: list[dict]) -> Database: + temp_dir = Path(tempfile.mkdtemp(prefix="daily-report-")) + seed_path = temp_dir / "employees.json" + seed_path.write_text(json.dumps(employees), encoding="utf-8") + return Database(temp_dir / "test.sqlite", seed_path) + + +class ReportServiceTest(unittest.TestCase): + def test_creates_schema_and_loads_active_employees(self) -> None: + db = make_database( + [ + {"feishu_user_id": "u_1", "name": "Lin", "department": "Design", "manager": "Mo", "active": True}, + {"feishu_user_id": "u_2", "name": "Chen", "department": "Sales", "manager": "Mo", "active": False}, + ] + ) + try: + employees = db.list_active_employees() + self.assertEqual(len(employees), 1) + self.assertEqual(employees[0]["name"], "Lin") + finally: + db.close() + + def test_validates_required_report_fields(self) -> None: + db = make_database([{"feishu_user_id": "u_1", "name": "Lin", "active": True}]) + service = ReportService(db) + try: + with self.assertRaisesRegex(ValueError, "today_done is required"): + service.upsert_report( + { + "feishu_user_id": "u_1", + "report_date": "2026-05-07", + "today_done": "", + "tomorrow_plan": "完成管理页", + } + ) + finally: + db.close() + + def test_upserts_one_report_per_employee_and_date(self) -> None: + db = make_database([{"feishu_user_id": "u_1", "name": "Lin", "department": "Design", "active": True}]) + service = ReportService(db, clock=lambda: datetime(2026, 5, 7, 10, tzinfo=timezone.utc)) + try: + service.upsert_report( + { + "feishu_user_id": "u_1", + "report_date": "2026-05-07", + "today_done": "完成原型初稿", + "tomorrow_plan": "和主管评审", + } + ) + service.upsert_report( + { + "feishu_user_id": "u_1", + "report_date": "2026-05-07", + "today_done": "完成原型初稿并调整文案", + "tomorrow_plan": "和主管评审", + "blockers": "需要确认 logo", + "help_needed": "确认视觉方向", + } + ) + result = service.list_reports_for_date("2026-05-07") + self.assertEqual(len(result["reports"]), 1) + self.assertEqual(result["reports"][0]["today_done"], "完成原型初稿并调整文案") + self.assertEqual(result["reports"][0]["blockers"], "需要确认 logo") + self.assertEqual(len(result["missing"]), 0) + finally: + db.close() + + def test_returns_missing_active_employees_and_csv_export(self) -> None: + db = make_database( + [ + {"feishu_user_id": "u_1", "name": "Lin", "department": "Design", "active": True}, + {"feishu_user_id": "u_2", "name": "Chen", "department": "Sales", "active": True}, + ] + ) + service = ReportService(db, clock=lambda: datetime(2026, 5, 7, 10, tzinfo=timezone.utc)) + try: + service.upsert_report( + { + "feishu_user_id": "u_1", + "report_date": "2026-05-07", + "today_done": "完成上线检查清单", + "tomorrow_plan": "准备发布", + } + ) + result = service.list_reports_for_date("2026-05-07") + self.assertEqual(result["submittedCount"], 1) + self.assertEqual(result["missing"][0]["name"], "Chen") + + csv_text = service.export_reports_csv("2026-05-07") + self.assertIn("employee_name,report_date,today_done,tomorrow_plan,blockers,help_needed,submitted_at", csv_text) + self.assertIn("Lin,2026-05-07,完成上线检查清单,准备发布", csv_text) + finally: + db.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_robot_service.py b/tests/test_robot_service.py new file mode 100644 index 0000000..379d02b --- /dev/null +++ b/tests/test_robot_service.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import json +import unittest + +from daily_report.robot_service import create_reminder_payload, create_summary_payload + + +class RobotServiceTest(unittest.TestCase): + def test_creates_reminder_payload_with_submit_link(self) -> None: + payload = create_reminder_payload("http://localhost:8787/submit", "每日工作汇报提醒") + text = json.dumps(payload, ensure_ascii=False) + + self.assertEqual(payload["msg_type"], "interactive") + self.assertIn("每日工作汇报提醒", text) + self.assertIn("http://localhost:8787/submit", text) + + def test_creates_summary_payload_with_missing_employees(self) -> None: + payload = create_summary_payload( + "http://localhost:8787/manager", + { + "date": "2026-05-07", + "expectedCount": 2, + "submittedCount": 1, + "missingCount": 1, + "missing": [{"name": "Chen"}], + }, + ) + text = json.dumps(payload, ensure_ascii=False) + + self.assertIn("2026-05-07", text) + self.assertIn("1/2", text) + self.assertIn("Chen", text) + self.assertIn("http://localhost:8787/manager", text) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_scheduled.py b/tests/test_scheduled.py new file mode 100644 index 0000000..a733139 --- /dev/null +++ b/tests/test_scheduled.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from daily_report import scheduled + + +class ScheduledTest(unittest.TestCase): + def test_send_summary_builds_today_summary_payload(self) -> None: + with tempfile.TemporaryDirectory(prefix="daily-report-scheduled-") as temp: + root = Path(temp) + seed_path = root / "employees.json" + seed_path.write_text( + json.dumps( + [ + {"feishu_user_id": "u_1", "name": "Lin", "active": True}, + {"feishu_user_id": "u_2", "name": "Chen", "active": True}, + ], + ensure_ascii=False, + ), + encoding="utf-8", + ) + fake_file = root / "daily_report" / "config.py" + fake_file.parent.mkdir() + fake_file.write_text("", encoding="utf-8") + env = { + "DATABASE_PATH": "test.sqlite", + "EMPLOYEE_SEED_PATH": "employees.json", + "BASE_URL": "http://localhost:8787", + "FEISHU_WEBHOOK_URL": "https://example.test/webhook", + } + + captured = {} + + def fake_send(webhook_url: str, payload: dict) -> dict: + captured["webhook_url"] = webhook_url + captured["payload"] = payload + return {"ok": True} + + with patch("daily_report.config.__file__", str(fake_file)), patch.dict("os.environ", env, clear=True), patch( + "daily_report.scheduled.send_webhook", fake_send + ): + result = scheduled.send_summary("2026-05-07") + + self.assertEqual(result, {"ok": True}) + self.assertEqual(captured["webhook_url"], "https://example.test/webhook") + text = json.dumps(captured["payload"], ensure_ascii=False) + self.assertIn("2026-05-07 日报提交汇总", text) + self.assertIn("0/2", text) + self.assertIn("Chen、Lin", text) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_web.py b/tests/test_web.py new file mode 100644 index 0000000..db390fd --- /dev/null +++ b/tests/test_web.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import json +import tempfile +import threading +import unittest +import urllib.request +from pathlib import Path + +from daily_report.config import Config +from daily_report.db import Database +from daily_report.feishu_auth import create_session_cookie +from daily_report.web import create_server + + +def make_server() -> tuple: + temp_dir = Path(tempfile.mkdtemp(prefix="daily-report-web-")) + seed_path = temp_dir / "employees.json" + seed_path.write_text( + json.dumps([{"feishu_user_id": "u_1", "name": "Lin", "department": "Design", "active": True}]), + encoding="utf-8", + ) + config = Config( + root_dir=Path(__file__).resolve().parent.parent, + port=0, + base_url="http://localhost:8787", + database_path=temp_dir / "test.sqlite", + employee_seed_path=seed_path, + workday_calendar_path=temp_dir / "workday-calendar.json", + feishu_webhook_url="", + feishu_webhook_secret="", + feishu_app_id="", + feishu_app_secret="", + session_secret="session-secret", + ) + db = Database(config.database_path, config.employee_seed_path) + server = create_server(config, db) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server, db, f"http://127.0.0.1:{server.server_address[1]}" + + +def get(url: str) -> tuple[int, str]: + with urllib.request.urlopen(url, timeout=5) as response: + return response.status, response.read().decode("utf-8") + + +class WebTest(unittest.TestCase): + def test_serves_submit_and_manager_pages(self) -> None: + server, db, base_url = make_server() + try: + status, submit = get(f"{base_url}/submit") + self.assertEqual(status, 200) + self.assertIn("每日报告", submit) + self.assertIn("1. \n2. \n3. \n4.", submit) + self.assertIn("我的历史日报", submit) + + status, manager = get(f"{base_url}/manager") + self.assertEqual(status, 200) + self.assertIn("日报浏览", manager) + finally: + server.shutdown() + server.server_close() + db.close() + + def test_accepts_report_submission_and_returns_summary(self) -> None: + server, db, base_url = make_server() + try: + request = urllib.request.Request( + f"{base_url}/api/reports", + data=json.dumps( + { + "feishu_user_id": "u_1", + "report_date": "2026-05-07", + "today_done": "完成 API", + "tomorrow_plan": "完善界面", + }, + ensure_ascii=False, + ).encode("utf-8"), + headers={"content-type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(request, timeout=5) as response: + self.assertEqual(response.status, 200) + + status, body = get(f"{base_url}/api/reports?date=2026-05-07") + summary = json.loads(body) + self.assertEqual(status, 200) + self.assertEqual(summary["submittedCount"], 1) + self.assertEqual(summary["reports"][0]["today_done"], "完成 API") + + status, history_body = get(f"{base_url}/api/reports/history?feishu_user_id=u_1") + history = json.loads(history_body) + self.assertEqual(status, 200) + self.assertEqual(history["employee"]["name"], "Lin") + self.assertEqual(history["reports"][0]["tomorrow_plan"], "完善界面") + finally: + server.shutdown() + server.server_close() + db.close() + + def test_submit_page_uses_logged_in_feishu_identity(self) -> None: + server, db, base_url = make_server() + try: + opener = urllib.request.build_opener() + opener.addheaders = [ + ( + "Cookie", + "daily_report_session=" + + create_session_cookie({"feishu_user_id": "u_1", "name": "Lin"}, "session-secret"), + ) + ] + with opener.open(f"{base_url}/submit", timeout=5) as response: + body = response.read().decode("utf-8") + + self.assertEqual(response.status, 200) + self.assertIn("Lin", body) + self.assertIn('type="hidden" name="feishu_user_id" value="u_1"', body) + self.assertNotIn("员工 ID None: + with tempfile.TemporaryDirectory(prefix="daily-report-workday-") as temp: + calendar_path = Path(temp) / "calendar.json" + calendar_path.write_text( + json.dumps( + { + "holidays": ["2026-05-01"], + "makeup_workdays": ["2026-05-09"], + } + ), + encoding="utf-8", + ) + + self.assertFalse(is_workday(date(2026, 5, 1), calendar_path)) + self.assertTrue(is_workday(date(2026, 5, 9), calendar_path)) + self.assertFalse(is_workday(date(2026, 5, 10), calendar_path)) + self.assertTrue(is_workday(date(2026, 5, 11), calendar_path)) + + +if __name__ == "__main__": + unittest.main() diff --git a/uninstall-tasks.bat b/uninstall-tasks.bat new file mode 100644 index 0000000..347f8b6 --- /dev/null +++ b/uninstall-tasks.bat @@ -0,0 +1,17 @@ +@echo off +setlocal +cd /d "%~dp0" +set "LOG=%~dp0uninstall-tasks.log" + +echo Uninstalling Windows scheduled tasks... +echo Uninstalling Windows scheduled tasks... > "%LOG%" +echo Project: %CD% >> "%LOG%" +echo. >> "%LOG%" + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\uninstall-windows-tasks.ps1" >> "%LOG%" 2>&1 +type "%LOG%" + +echo. +echo If there is no error above, tasks are removed. +echo If there is an error, send Codex a screenshot. +pause