feat: add Feishu daily report app

This commit is contained in:
Codex 2026-05-07 16:31:56 +08:00
commit ebe9d5684a
33 changed files with 2242 additions and 0 deletions

10
.env.example Normal file
View File

@ -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=

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
.env
data/*.sqlite
data/*.sqlite-shm
data/*.sqlite-wal
__pycache__/
*.pyc
*.log

145
README.md Normal file
View File

@ -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
```

2
daily_report/__init__.py Normal file
View File

@ -0,0 +1,2 @@
"""Feishu daily report application."""

57
daily_report/config.py Normal file
View File

@ -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",
)

162
daily_report/db.py Normal file
View File

@ -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]

112
daily_report/feishu_auth.py Normal file
View File

@ -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,
}

View File

@ -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),
}

View File

@ -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

52
daily_report/scheduled.py Normal file
View File

@ -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()

View File

@ -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 = `
<strong>应提交${data.expectedCount}</strong>
<strong>已提交${data.submittedCount}</strong>
<strong>未提交${data.missingCount}</strong>
`;
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) => `
<article class="report-card">
<h3>${report.employee_name}</h3>
<div class="report-grid">
<div><div class="field-title">今日完成</div><div>${text(report.today_done)}</div></div>
<div><div class="field-title">明日计划</div><div>${text(report.tomorrow_plan)}</div></div>
<div><div class="field-title">遇到的问题</div><div>${text(report.blockers) || ""}</div></div>
<div><div class="field-title">需要协助</div><div>${text(report.help_needed) || ""}</div></div>
</div>
<p>提交时间${report.updated_at}</p>
</article>
`).join("") : "<p>当前筛选下没有日报。</p>";
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();

View File

@ -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; }
}

348
daily_report/web.py Normal file
View File

@ -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"""<!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()

21
daily_report/workday.py Normal file
View File

@ -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

16
data/employees.json Normal file
View File

@ -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
}
]

View File

@ -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"
]
}

View File

@ -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。

View File

@ -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 和多维表格同步。

20
install-tasks.bat Normal file
View File

@ -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

1
requirements.txt Normal file
View File

@ -0,0 +1 @@
# This project uses only the Python standard library.

View File

@ -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"

View File

@ -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"
}
}

26
send-reminder.bat Normal file
View File

@ -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

26
send-summary.bat Normal file
View File

@ -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

20
start-server.bat Normal file
View File

@ -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

42
tests/test_config.py Normal file
View File

@ -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()

19
tests/test_feishu_auth.py Normal file
View File

@ -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()

View File

@ -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()

View File

@ -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()

58
tests/test_scheduled.py Normal file
View File

@ -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()

127
tests/test_web.py Normal file
View File

@ -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<input", body)
finally:
server.shutdown()
server.server_close()
db.close()
if __name__ == "__main__":
unittest.main()

33
tests/test_workday.py Normal file
View File

@ -0,0 +1,33 @@
from __future__ import annotations
import json
import tempfile
import unittest
from datetime import date
from pathlib import Path
from daily_report.workday import is_workday
class WorkdayTest(unittest.TestCase):
def test_uses_holidays_and_makeup_workdays(self) -> 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()

17
uninstall-tasks.bat Normal file
View File

@ -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