diff --git a/daily_report/db.py b/daily_report/db.py index 6791eee..18c2c4d 100644 --- a/daily_report/db.py +++ b/daily_report/db.py @@ -41,6 +41,7 @@ class Database: feishu_user_id TEXT NOT NULL, employee_name TEXT NOT NULL, report_date TEXT NOT NULL, + report_status TEXT NOT NULL DEFAULT 'normal', today_done TEXT NOT NULL, tomorrow_plan TEXT NOT NULL, blockers TEXT NOT NULL DEFAULT '', @@ -56,6 +57,13 @@ class Database: except sqlite3.OperationalError as error: if "duplicate column name" not in str(error).lower(): raise + try: + self.connection.execute( + "ALTER TABLE daily_reports ADD COLUMN report_status TEXT NOT NULL DEFAULT 'normal'" + ) + except sqlite3.OperationalError as error: + if "duplicate column name" not in str(error).lower(): + raise self.connection.commit() def load_employees(self, employee_seed_path: Path) -> None: @@ -129,15 +137,16 @@ class Database: self.connection.execute( """ INSERT INTO daily_reports ( - feishu_user_id, employee_name, report_date, today_done, tomorrow_plan, + feishu_user_id, employee_name, report_date, report_status, today_done, tomorrow_plan, blockers, help_needed, submitted_at, updated_at ) VALUES ( - :feishu_user_id, :employee_name, :report_date, :today_done, :tomorrow_plan, + :feishu_user_id, :employee_name, :report_date, :report_status, :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, + report_status = excluded.report_status, today_done = excluded.today_done, tomorrow_plan = excluded.tomorrow_plan, blockers = excluded.blockers, @@ -151,7 +160,7 @@ class Database: with self._lock: rows = self.connection.execute( """ - SELECT id, feishu_user_id, employee_name, report_date, today_done, + SELECT id, feishu_user_id, employee_name, report_date, report_status, today_done, tomorrow_plan, blockers, help_needed, submitted_at, updated_at FROM daily_reports WHERE report_date = ? @@ -165,7 +174,7 @@ class Database: with self._lock: rows = self.connection.execute( """ - SELECT id, feishu_user_id, employee_name, report_date, today_done, + SELECT id, feishu_user_id, employee_name, report_date, report_status, today_done, tomorrow_plan, blockers, help_needed, submitted_at, updated_at FROM daily_reports WHERE feishu_user_id = ? @@ -175,3 +184,18 @@ class Database: (feishu_user_id, limit), ).fetchall() return [dict(row) for row in rows] + + def find_previous_report(self, feishu_user_id: str, before_date: str) -> dict[str, Any] | None: + with self._lock: + row = self.connection.execute( + """ + SELECT id, feishu_user_id, employee_name, report_date, report_status, today_done, + tomorrow_plan, blockers, help_needed, submitted_at, updated_at + FROM daily_reports + WHERE feishu_user_id = ? AND report_date < ? + ORDER BY report_date DESC, updated_at DESC + LIMIT 1 + """, + (feishu_user_id, before_date), + ).fetchone() + return dict(row) if row else None diff --git a/daily_report/report_service.py b/daily_report/report_service.py index 0cfca8a..a95501d 100644 --- a/daily_report/report_service.py +++ b/daily_report/report_service.py @@ -22,6 +22,14 @@ def _optional_text(value: Any) -> str: return str(value or "").strip() +def _report_status(value: Any) -> str: + status = str(value or "normal").strip() + allowed = {"normal", "risk", "need_help"} + if status not in allowed: + raise ValueError("report_status is invalid") + return status + + class ReportService: def __init__(self, database: Database, clock: Clock | None = None): self.database = database @@ -42,6 +50,7 @@ class ReportService: "feishu_user_id": feishu_user_id, "employee_name": employee["name"], "report_date": report_date, + "report_status": _report_status(data.get("report_status")), "today_done": today_done, "tomorrow_plan": tomorrow_plan, "blockers": _optional_text(data.get("blockers")), @@ -81,6 +90,7 @@ class ReportService: "tomorrow_plan", "blockers", "help_needed", + "report_status", "submitted_at", ] writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore") @@ -99,6 +109,17 @@ class ReportService: "reports": self.database.list_reports_for_employee(user_id, limit), } + def previous_report_reference(self, feishu_user_id: str, before_date: str) -> dict[str, Any]: + user_id = _required_text(feishu_user_id, "feishu_user_id") + date = _required_text(before_date, "report_date") + employee = self.database.find_employee(user_id) + if not employee or employee.get("active") != 1: + raise ValueError("employee is not active") + return { + "employee": employee, + "report": self.database.find_previous_report(user_id, date), + } + def is_admin(self, feishu_user_id: str) -> bool: user_id = _required_text(feishu_user_id, "feishu_user_id") employee = self.database.find_employee(user_id) diff --git a/daily_report/static/manager.js b/daily_report/static/manager.js index a1ce236..fcbe1a5 100644 --- a/daily_report/static/manager.js +++ b/daily_report/static/manager.js @@ -1,9 +1,25 @@ const state = { data: null }; +const statusLabels = { + normal: "正常", + risk: "有风险", + need_help: "需要支持" +}; + function text(value) { return String(value || ""); } +function escapeHtml(value) { + return text(value).replace(/[&<>"']/g, (char) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'" + }[char])); +} + function render() { const employeeQuery = document.querySelector("#employee-filter").value.trim().toLowerCase(); const keywordQuery = document.querySelector("#keyword-filter").value.trim().toLowerCase(); @@ -18,22 +34,33 @@ function render() { 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 searchable = [ + report.today_done, + report.tomorrow_plan, + report.blockers, + report.help_needed, + statusLabels[report.report_status] + ].join(" ").toLowerCase(); const keywordMatch = searchable.includes(keywordQuery); - const blockerMatch = !onlyBlockers || report.blockers || report.help_needed; + const blockerMatch = !onlyBlockers || report.blockers || report.help_needed || report.report_status === "need_help"; 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) || "无"}
+
+

${escapeHtml(report.employee_name)}

+ + ${statusLabels[report.report_status] || "正常"} +
-

提交时间:${report.updated_at}

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

提交时间:${escapeHtml(report.updated_at)}

`).join("") : "

当前筛选下没有日报。

"; diff --git a/daily_report/static/styles.css b/daily_report/static/styles.css index b95a10f..6b80c51 100644 --- a/daily_report/static/styles.css +++ b/daily_report/static/styles.css @@ -32,6 +32,66 @@ button { border: 0; cursor: pointer; } +.identity-box { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 12px; + border: 1px solid #dbeafe; + border-radius: 6px; + background: #eff6ff; +} +.status-options { + display: flex; + flex-wrap: wrap; + gap: 12px; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 12px; +} +.status-options legend { font-weight: 700; } +.status-options label { + display: flex; + align-items: center; + gap: 6px; + font-weight: 400; +} +.item-group { + display: grid; + gap: 8px; +} +.item-group h2 { margin: 0; } +.item-group [data-list] { + display: grid; + gap: 8px; +} +.previous-plan { + display: grid; + gap: 10px; + margin-top: 18px; + background: #f8fafc; +} +.previous-plan[hidden] { display: none; } +.previous-plan h2, .previous-plan p { margin: 0; } +.previous-plan button { justify-self: start; } +.report-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; +} +.status-badge { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 4px 10px; + border-radius: 999px; + font-size: 13px; + font-weight: 700; +} +.status-normal { background: #dcfce7; color: #166534; } +.status-risk { background: #fef3c7; color: #92400e; } +.status-need_help { background: #fee2e2; color: #991b1b; } .filters { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); @@ -68,6 +128,6 @@ pre { .message.success { color: #047857; } .message.error { color: #b91c1c; } @media (max-width: 760px) { - .topbar, .stats, .history-head { display: grid; } + .topbar, .stats, .history-head, .report-head { display: grid; } .filters, .report-grid { grid-template-columns: 1fr; } } diff --git a/daily_report/web.py b/daily_report/web.py index ddd1ce7..45e3336 100644 --- a/daily_report/web.py +++ b/daily_report/web.py @@ -43,7 +43,6 @@ def page(title: str, body: str, scripts: str = "") -> bytes: def submit_page(current_date: str, session: dict[str, str] | None = None) -> bytes: - numbered_template = "1. \n2. \n3. \n4. " if session: identity_html = f"""
@@ -52,38 +51,102 @@ def submit_page(current_date: str, session: dict[str, str] | None = None) -> byt
""" - history_hint = "系统已自动识别你的飞书身份,可以查看自己最近提交的记录。" + history_hint = "系统已自动识别你的飞书身份,这里会显示你最近提交过的日报。" else: identity_html = '' - history_hint = "输入员工 ID 后可以查看自己最近提交的记录。" + history_hint = "输入员工 ID 后,可以查看自己最近提交过的日报。" return page( "每日报告", f"""
-

每日报告

+

每日工作汇报

{identity_html} - - - - - + +
+ 今日状态 + + + +
+
+

今日完成

+
+ + + + +
+
+
+

明日计划

+
+ + + + +
+
+ +

+

我的历史日报

{history_hint}

- +
暂无历史记录。
""", ) @@ -177,7 +275,7 @@ def forbidden_page() -> bytes:

无权限

-

你没有查看日报汇总的权限。请联系管理员开通。

+

你没有查看日报汇总的权限,请联系管理员开通。

返回日报填写页

""", @@ -300,6 +398,14 @@ class DailyReportHandler(BaseHTTPRequestHandler): except ValueError as error: self._json(400, {"error": str(error)}) return + if parsed.path == "/api/reports/previous": + user_id = query.get("feishu_user_id", [""])[0] + report_date = query.get("date", [today_string()])[0] + try: + self._json(200, self.report_service.previous_report_reference(user_id, report_date)) + except ValueError as error: + self._json(400, {"error": str(error)}) + return if parsed.path == "/api/reports/export": if not self._require_admin(): return diff --git a/tests/test_report_service.py b/tests/test_report_service.py index f3da3e1..3c11523 100644 --- a/tests/test_report_service.py +++ b/tests/test_report_service.py @@ -42,7 +42,7 @@ class ReportServiceTest(unittest.TestCase): "feishu_user_id": "u_1", "report_date": "2026-05-07", "today_done": "", - "tomorrow_plan": "完成管理页", + "tomorrow_plan": "完善管理页", } ) finally: @@ -56,29 +56,32 @@ class ReportServiceTest(unittest.TestCase): { "feishu_user_id": "u_1", "report_date": "2026-05-07", - "today_done": "完成原型初稿", - "tomorrow_plan": "和主管评审", + "report_status": "risk", + "today_done": "1. 完成原型初稿", + "tomorrow_plan": "1. 和主管评审", } ) service.upsert_report( { "feishu_user_id": "u_1", "report_date": "2026-05-07", - "today_done": "完成原型初稿并调整文案", - "tomorrow_plan": "和主管评审", + "report_status": "need_help", + "today_done": "1. 完成原型初稿\n2. 调整文案", + "tomorrow_plan": "1. 和主管评审", "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]["report_status"], "need_help") + self.assertEqual(result["reports"][0]["today_done"], "1. 完成原型初稿\n2. 调整文案") 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: + def test_returns_missing_active_employees_csv_and_previous_reference(self) -> None: db = make_database( [ {"feishu_user_id": "u_1", "name": "Lin", "department": "Design", "active": True}, @@ -87,12 +90,20 @@ class ReportServiceTest(unittest.TestCase): ) 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-06", + "today_done": "1. 完成上线检查清单", + "tomorrow_plan": "1. 准备发布", + } + ) service.upsert_report( { "feishu_user_id": "u_1", "report_date": "2026-05-07", - "today_done": "完成上线检查清单", - "tomorrow_plan": "准备发布", + "today_done": "1. 准备发布", + "tomorrow_plan": "1. 复盘问题", } ) result = service.list_reports_for_date("2026-05-07") @@ -100,8 +111,12 @@ class ReportServiceTest(unittest.TestCase): 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) + self.assertIn("employee_name,report_date,today_done,tomorrow_plan,blockers,help_needed,report_status,submitted_at", csv_text) + self.assertIn("Lin,2026-05-07,1. 准备发布,1. 复盘问题,,,normal,", csv_text) + + reference = service.previous_report_reference("u_1", "2026-05-07") + self.assertEqual(reference["report"]["report_date"], "2026-05-06") + self.assertEqual(reference["report"]["tomorrow_plan"], "1. 准备发布") finally: db.close() diff --git a/tests/test_web.py b/tests/test_web.py index 6b844b6..1eaf2f1 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -4,6 +4,7 @@ import json import tempfile import threading import unittest +import urllib.error import urllib.request from pathlib import Path @@ -52,19 +53,23 @@ def get_with_cookie(url: str, cookie: str) -> tuple[int, str]: return response.status, response.read().decode("utf-8") +def admin_cookie(user_id: str = "u_1", name: str = "Lin") -> str: + return "daily_report_session=" + create_session_cookie( + {"feishu_user_id": user_id, "name": name}, "session-secret" + ) + + 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) + self.assertIn("今日状态", submit) + self.assertIn('data-list="today_done"', submit) self.assertIn("我的历史日报", submit) - admin_cookie = "daily_report_session=" + create_session_cookie( - {"feishu_user_id": "u_1", "name": "Lin"}, "session-secret" - ) db.upsert_employee( { "feishu_user_id": "u_1", @@ -75,7 +80,7 @@ class WebTest(unittest.TestCase): "role": "admin", } ) - status, manager = get_with_cookie(f"{base_url}/manager", admin_cookie) + status, manager = get_with_cookie(f"{base_url}/manager", admin_cookie()) self.assertEqual(status, 200) self.assertIn("日报浏览", manager) finally: @@ -83,24 +88,42 @@ class WebTest(unittest.TestCase): server.server_close() db.close() - def test_accepts_report_submission_and_returns_summary(self) -> None: + def test_accepts_report_submission_and_returns_summary_and_previous_reference(self) -> None: server, db, base_url = make_server() try: - request = urllib.request.Request( + first_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": "完善界面", + "report_date": "2026-05-06", + "today_done": "1. 完成 API", + "tomorrow_plan": "1. 完善界面", }, ensure_ascii=False, ).encode("utf-8"), headers={"content-type": "application/json"}, method="POST", ) - with urllib.request.urlopen(request, timeout=5) as response: + with urllib.request.urlopen(first_request, timeout=5) as response: + self.assertEqual(response.status, 200) + + second_request = urllib.request.Request( + f"{base_url}/api/reports", + data=json.dumps( + { + "feishu_user_id": "u_1", + "report_date": "2026-05-07", + "report_status": "need_help", + "today_done": "1. 完善界面", + "tomorrow_plan": "1. 上线测试", + }, + ensure_ascii=False, + ).encode("utf-8"), + headers={"content-type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(second_request, timeout=5) as response: self.assertEqual(response.status, 200) db.upsert_employee( @@ -113,20 +136,24 @@ class WebTest(unittest.TestCase): "role": "admin", } ) - admin_cookie = "daily_report_session=" + create_session_cookie( - {"feishu_user_id": "u_1", "name": "Lin"}, "session-secret" - ) - status, body = get_with_cookie(f"{base_url}/api/reports?date=2026-05-07", admin_cookie) + status, body = get_with_cookie(f"{base_url}/api/reports?date=2026-05-07", admin_cookie()) summary = json.loads(body) self.assertEqual(status, 200) self.assertEqual(summary["submittedCount"], 1) - self.assertEqual(summary["reports"][0]["today_done"], "完成 API") + self.assertEqual(summary["reports"][0]["today_done"], "1. 完善界面") + self.assertEqual(summary["reports"][0]["report_status"], "need_help") 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"], "完善界面") + self.assertEqual(history["reports"][0]["tomorrow_plan"], "1. 上线测试") + + status, reference_body = get(f"{base_url}/api/reports/previous?feishu_user_id=u_1&date=2026-05-07") + reference = json.loads(reference_body) + self.assertEqual(status, 200) + self.assertEqual(reference["report"]["report_date"], "2026-05-06") + self.assertEqual(reference["report"]["tomorrow_plan"], "1. 完善界面") finally: server.shutdown() server.server_close() @@ -135,18 +162,8 @@ class WebTest(unittest.TestCase): 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) + status, body = get_with_cookie(f"{base_url}/submit", admin_cookie()) + self.assertEqual(status, 200) self.assertIn("Lin", body) self.assertIn('type="hidden" name="feishu_user_id" value="u_1"', body) self.assertNotIn("员工 ID