diff --git a/.gitignore b/.gitignore index d6643c6..545866f 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,7 @@ htmlcov/ frontend/node_modules/ frontend/dist/ .gstack/ +.codex/ +.agents/ +.ruff_cache/ +.mypy_cache/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c132ab2 --- /dev/null +++ b/Makefile @@ -0,0 +1,27 @@ +.PHONY: install dev test test-backend test-frontend lint build clean + +PYTHON ?= $(if $(wildcard .venv/bin/python),.venv/bin/python,python3) + +install: + $(PYTHON) -m pip install -e ".[test]" + npm --prefix frontend install + +dev: + $(PYTHON) -m uvicorn app.main:create_app --factory --app-dir backend --reload --host 0.0.0.0 --port 8000 + +test: test-backend test-frontend + +test-backend: + $(PYTHON) -m pytest -q + +test-frontend: + npm --prefix frontend test -- --run + +lint: + npm --prefix frontend exec vue-tsc -- -b + +build: + npm --prefix frontend run build + +clean: + rm -rf .pytest_cache .ruff_cache .mypy_cache htmlcov .coverage frontend/dist diff --git a/backend/app/agendas/outcome_tags.py b/backend/app/agendas/outcome_tags.py index a269bb2..e5fea2f 100644 --- a/backend/app/agendas/outcome_tags.py +++ b/backend/app/agendas/outcome_tags.py @@ -29,6 +29,7 @@ class TaggedOutcome: ("decision", "@决策:"), ("action", "@行动:"), ("question", "@开放问题:"), + ("question", "@问题:"), ) @@ -47,7 +48,7 @@ def parse_action_metadata( if not match: break token = match.group(1).strip() - text = text[match.end() :] + token_lower = token.lower() if token.startswith("@"): owner = token[1:].strip() @@ -55,15 +56,19 @@ def parse_action_metadata( try: due_date_val = date.fromisoformat(token) except ValueError: - pass - elif token.lower() in ("todo", "open"): + break + elif token_lower in ("todo", "open"): status = ActionStatus.open - elif token.lower() in ("in_progress", "doing", "progress"): + elif token_lower in ("in_progress", "doing", "progress"): status = ActionStatus.in_progress - elif token.lower() in ("done", "completed", "finish", "finished"): + elif token_lower in ("done", "completed", "finish", "finished"): status = ActionStatus.done - elif token.lower() in ("canceled", "cancelled"): + elif token_lower in ("canceled", "cancelled"): status = ActionStatus.canceled + else: + break + + text = text[match.end() :] return text.strip(), status, owner, due_date_val @@ -111,8 +116,14 @@ def rewrite_action_in_notes( lines = notes_markdown.splitlines(keepends=True) action_count = 0 + in_fence = False for idx, raw_line in enumerate(lines): line = raw_line.strip() + if line.startswith("```") or line.startswith("~~~"): + in_fence = not in_fence + continue + if in_fence: + continue if line.startswith("@行动:"): if action_count == target_index: leading_ws_len = len(raw_line) - len(raw_line.lstrip()) @@ -132,8 +143,14 @@ def parse_outcome_tags(markdown: str) -> list[TaggedOutcome]: "question": 0, } result: list[TaggedOutcome] = [] + in_fence = False for line_number, raw_line in enumerate(markdown.splitlines(), start=1): line = raw_line.strip() + if line.startswith("```") or line.startswith("~~~"): + in_fence = not in_fence + continue + if in_fence: + continue matched = next( ((kind, prefix) for kind, prefix in _TAGS if line.startswith(prefix)), None, diff --git a/backend/app/agendas/service.py b/backend/app/agendas/service.py index 4c92244..04c4917 100644 --- a/backend/app/agendas/service.py +++ b/backend/app/agendas/service.py @@ -21,7 +21,13 @@ ) from app.auth.models import User, UserStatus from app.collaboration.activity import ActivityRecorder -from app.domain.enums import ActionStatus, AgendaStatus, MeetingStatus, OpenQuestionStatus +from app.domain.enums import ( + ActionStatus, + AgendaStatus, + DecisionStatus, + MeetingStatus, + OpenQuestionStatus, +) from app.domain.versioning import StaleCheck, require_version, resolve_stale from app.errors import AppError from app.meetings.models import Meeting @@ -76,20 +82,31 @@ def get(self, item_id: str) -> AgendaItem: def _resolve_owner_identifier(self, identifier: str | None) -> str | None: if not identifier: return None - user = self.session.scalar( - select(User).where( + user = self.session.scalars( + select(User) + .where( + User.status == UserStatus.ACTIVE, (User.username == identifier) | (User.display_name == identifier) - | (User.id == identifier) + | (User.id == identifier), ) - ) + .order_by(User.created_at.asc(), User.id.asc()) + ).first() return user.id if user else None + @staticmethod + def _row_content(kind: str, row: Any) -> str: + if kind == "decision": + return row.title + if kind == "action": + return row.content + return row.question_markdown + def _reconcile_derived_outcomes( self, item: AgendaItem, tags: list[TaggedOutcome], actor: User ) -> None: meeting = item.meeting - rows = { + rows: dict[str, list[Any]] = { "decision": list( self.session.scalars( select(Decision).where(Decision.source_agenda_item_id == item.id) @@ -108,17 +125,99 @@ def _reconcile_derived_outcomes( ) ), } - existing = { - kind: {row.source_tag_key: row for row in values} - for kind, values in rows.items() - } - requested = { - kind: {tag.source_tag_key for tag in tags if tag.kind == kind} - for kind in ("decision", "action", "question") - } + matched_rows: dict[str, Any] = {} + needs_key_flush = False + + for kind in ("decision", "action", "question"): + kind_tags = [tag for tag in tags if tag.kind == kind] + kind_rows = rows[kind] + by_key = { + row.source_tag_key: row + for row in kind_rows + if row.source_tag_key is not None + } + used_row_ids: set[str] = set() + + # 1a. Exact content match at the same positional source_tag_key + for tag in kind_tags: + same_key_row = by_key.get(tag.source_tag_key) + if ( + same_key_row is not None + and same_key_row.id not in used_row_ids + and self._row_content(kind, same_key_row) == tag.content + ): + matched_rows[tag.source_tag_key] = same_key_row + used_row_ids.add(same_key_row.id) + + # 1b. Exact content match at any position + for tag in kind_tags: + if tag.source_tag_key in matched_rows: + continue + for row in kind_rows: + if ( + row.id not in used_row_ids + and self._row_content(kind, row) == tag.content + ): + matched_rows[tag.source_tag_key] = row + used_row_ids.add(row.id) + break + + # 2. Fallback to positional source_tag_key or remaining non-final rows + for tag in kind_tags: + if tag.source_tag_key in matched_rows: + continue + same_key_row = by_key.get(tag.source_tag_key) + if ( + same_key_row is not None + and same_key_row.id not in used_row_ids + and not ( + kind == "decision" + and same_key_row.status == DecisionStatus.final + ) + ): + matched_rows[tag.source_tag_key] = same_key_row + used_row_ids.add(same_key_row.id) + continue + for row in kind_rows: + if row.id in used_row_ids: + continue + if kind == "decision" and row.status == DecisionStatus.final: + continue + matched_rows[tag.source_tag_key] = row + used_row_ids.add(row.id) + break + + # 3. Handle unmatched rows (never auto-delete finalized decisions) + for row in kind_rows: + if row.id in used_row_ids: + continue + if kind == "decision" and row.status == DecisionStatus.final: + if row.source_tag_key is not None: + row.source_tag_key = None + row.version += 1 + needs_key_flush = True + else: + self.session.delete(row) + needs_key_flush = True + + # Temporarily clear source_tag_key on rows whose key is shifting to avoid SQLite UNIQUE collisions + shifting: list[tuple[Any, str]] = [] for tag in tags: - row = existing[tag.kind].get(tag.source_tag_key) + row = matched_rows.get(tag.source_tag_key) + if row is not None and row.source_tag_key != tag.source_tag_key: + row.source_tag_key = None + shifting.append((row, tag.source_tag_key)) + needs_key_flush = True + + if needs_key_flush: + self.session.flush() + + for row, new_key in shifting: + row.source_tag_key = new_key + + for tag in tags: + row = matched_rows.get(tag.source_tag_key) if row is None: if tag.kind == "decision": self.session.add( @@ -164,7 +263,7 @@ def _reconcile_derived_outcomes( ) continue - changed = False + changed = any(r is row for r, _ in shifting) for field, value in ( ("project_id", meeting.project_id), ("meeting_id", meeting.id), @@ -184,12 +283,15 @@ def _reconcile_derived_outcomes( changed = True if tag.kind == "action": - if tag.owner_name: - resolved_owner = self._resolve_owner_identifier(tag.owner_name) - if row.owner_user_id != resolved_owner: - row.owner_user_id = resolved_owner - changed = True - if tag.due_date is not None and row.due_date != tag.due_date: + resolved_owner = ( + self._resolve_owner_identifier(tag.owner_name) + if tag.owner_name + else None + ) + if row.owner_user_id != resolved_owner: + row.owner_user_id = resolved_owner + changed = True + if row.due_date != tag.due_date: row.due_date = tag.due_date changed = True if tag.status != row.status: @@ -203,11 +305,6 @@ def _reconcile_derived_outcomes( if changed: row.version += 1 - for kind, values in rows.items(): - for row in values: - if row.source_tag_key not in requested[kind]: - self.session.delete(row) - def list( self, meeting_id: str, *, actor: User | None = None ) -> list[AgendaItem]: diff --git a/backend/app/calendar/router.py b/backend/app/calendar/router.py index aa0d3a3..1fd3bdb 100644 --- a/backend/app/calendar/router.py +++ b/backend/app/calendar/router.py @@ -1,4 +1,4 @@ -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from typing import Any from fastapi import APIRouter, Depends, Request, Response @@ -13,6 +13,7 @@ from app.errors import AppError from app.meetings.models import Meeting, MeetingParticipant from app.outcomes.models import ActionItem +from app.time_utils import as_utc router = APIRouter(prefix="/api/calendar", tags=["calendar"]) CALENDAR_SALT = "meetflow-calendar-feed-v1" @@ -23,6 +24,22 @@ def get_serializer(request: Request) -> URLSafeSerializer: return URLSafeSerializer(secret, salt=CALENDAR_SALT) +def _escape_ics_text(value: str) -> str: + return ( + value.replace("\\", "\\\\") + .replace(";", "\\;") + .replace(",", "\\,") + .replace("\r\n", "\\n") + .replace("\r", "\\n") + .replace("\n", "\\n") + ) + + +def _utc_stamp(value: datetime | None) -> str: + target = as_utc(value) if value is not None else datetime.now(timezone.utc) + return target.strftime("%Y%m%dT%H%M%SZ") + + def generate_ics_feed(user: User, actions: list[ActionItem], meetings: list[Meeting]) -> str: now_stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") lines = [ @@ -31,24 +48,30 @@ def generate_ics_feed(user: User, actions: list[ActionItem], meetings: list[Meet "PRODID:-//MeetFlow//Calendar//CN", "CALSCALE:GREGORIAN", "METHOD:PUBLISH", - f"X-WR-CALNAME:MeetFlow - {user.display_name}的待办日程", + f"X-WR-CALNAME:MeetFlow - {_escape_ics_text(user.display_name)}的待办日程", "X-WR-TIMEZONE:UTC", ] for action in actions: if not action.due_date: continue - due_str = action.due_date.strftime("%Y%m%d") - created_stamp = (action.created_at or datetime.now(timezone.utc)).strftime("%Y%m%dT%H%M%SZ") + due_start_str = action.due_date.strftime("%Y%m%d") + due_end_str = (action.due_date + timedelta(days=1)).strftime("%Y%m%d") + created_stamp = _utc_stamp(action.created_at) + summary = _escape_ics_text(f"[待办] {action.content}") + description = ( + f"状态: {_escape_ics_text(action.status.value)}\\n" + f"优先级: {_escape_ics_text(action.priority.value)}" + ) lines.extend([ "BEGIN:VEVENT", f"UID:action-{action.id}@meetflow", f"DTSTAMP:{now_stamp}", f"CREATED:{created_stamp}", - f"DTSTART;VALUE=DATE:{due_str}", - f"DTEND;VALUE=DATE:{due_str}", - f"SUMMARY:[待办] {action.content}", - f"DESCRIPTION:状态: {action.status.value}\\n优先级: {action.priority.value}", + f"DTSTART;VALUE=DATE:{due_start_str}", + f"DTEND;VALUE=DATE:{due_end_str}", + f"SUMMARY:{summary}", + f"DESCRIPTION:{description}", "STATUS:CONFIRMED", "END:VEVENT", ]) @@ -56,14 +79,15 @@ def generate_ics_feed(user: User, actions: list[ActionItem], meetings: list[Meet for meeting in meetings: if not meeting.scheduled_start: continue - start_stamp = meeting.scheduled_start.strftime("%Y%m%dT%H%M%SZ") + start_stamp = _utc_stamp(meeting.scheduled_start) end_stamp = ( - meeting.scheduled_end.strftime("%Y%m%dT%H%M%SZ") + _utc_stamp(meeting.scheduled_end) if meeting.scheduled_end else start_stamp ) - created_stamp = (meeting.created_at or datetime.now(timezone.utc)).strftime("%Y%m%dT%H%M%SZ") - purpose = (meeting.purpose_markdown or "").replace("\n", "\\n") + created_stamp = _utc_stamp(meeting.created_at) + summary = _escape_ics_text(f"[会议] {meeting.title}") + purpose = _escape_ics_text(meeting.purpose_markdown or "") lines.extend([ "BEGIN:VEVENT", f"UID:meeting-{meeting.id}@meetflow", @@ -71,7 +95,7 @@ def generate_ics_feed(user: User, actions: list[ActionItem], meetings: list[Meet f"CREATED:{created_stamp}", f"DTSTART:{start_stamp}", f"DTEND:{end_stamp}", - f"SUMMARY:[会议] {meeting.title}", + f"SUMMARY:{summary}", f"DESCRIPTION:会议议题与目的:\\n{purpose}", "STATUS:CONFIRMED", "END:VEVENT", diff --git a/backend/app/database.py b/backend/app/database.py index b47b28c..a315e53 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -9,6 +9,7 @@ from sqlalchemy.orm import DeclarativeBase, Session +import asyncio import functools import inspect import time @@ -38,7 +39,7 @@ async def async_wrapper(*args, **kwargs): ) if is_lock and retries < max_retries: retries += 1 - time.sleep(backoff) + await asyncio.sleep(backoff) backoff *= backoff_factor continue raise diff --git a/backend/app/meetings/router.py b/backend/app/meetings/router.py index bb9cd1c..7006d72 100644 --- a/backend/app/meetings/router.py +++ b/backend/app/meetings/router.py @@ -1,11 +1,13 @@ -from typing import Any +from typing import Any, Literal from fastapi import APIRouter, Depends, Query from fastapi.responses import JSONResponse +from pydantic import AliasChoices, BaseModel, ConfigDict, Field from sqlalchemy.orm import Session from app.auth.dependencies import current_user from app.auth.models import User +from app.collaboration.activity import ActivityRecorder from app.database import get_session from app.http import utc_response from app.meetings.schemas import ( @@ -20,6 +22,14 @@ from app.meetings.projectors import serialize_amendment, serialize_snapshot from app.meetings.service import MeetingService from app.projects.access import WorkspaceAccess +from app.time_utils import utcnow +from app.webhooks.adapter import ( + dispatch_webhook, + format_dingtalk_card, + format_feishu_card, + format_generic_card, + redact_webhook_url, +) router = APIRouter(tags=["meetings"]) @@ -251,21 +261,14 @@ def add_amendment( ) -from typing import Literal -from pydantic import BaseModel -from app.collaboration.activity import ActivityRecorder -from app.time_utils import utcnow -from app.webhooks.adapter import ( - dispatch_webhook, - format_dingtalk_card, - format_feishu_card, - format_generic_card, -) - - class MeetingWebhookNotifyRequest(BaseModel): + model_config = ConfigDict(populate_by_name=True) + webhook_url: str - webhook_type: Literal["feishu", "dingtalk", "generic"] = "feishu" + webhook_type: Literal["feishu", "dingtalk", "generic"] = Field( + default="feishu", + validation_alias=AliasChoices("webhook_type", "platform"), + ) secret: str | None = None @@ -276,7 +279,7 @@ async def notify_meeting_webhook( user: User = Depends(current_user), session: Session = Depends(get_session), ) -> dict[str, Any]: - meeting = WorkspaceAccess(session).require_meeting_view(meeting_id, user) + meeting = WorkspaceAccess(session).require_meeting_contribute(meeting_id, user) service = MeetingService(session) detail = service.meeting_detail(meeting_id, user) project_name = meeting.project.name if meeting.project else "未指定项目" @@ -286,8 +289,14 @@ async def notify_meeting_webhook( else utcnow().strftime("%Y-%m-%d %H:%M:%S UTC") ) summary_text = detail.get("summary_markdown") or detail.get("purpose_markdown") or "" - decisions = [d["title"] for d in detail.get("decisions", [])] - actions = [a["content"] for a in detail.get("actions", [])] + all_decisions = detail.get("meeting_decisions", []) + [ + d for item in detail.get("agenda_items", []) for d in item.get("decisions", []) + ] + all_actions = detail.get("meeting_actions", []) + [ + a for item in detail.get("agenda_items", []) for a in item.get("actions", []) + ] + decisions = [d["title"] for d in all_decisions] + actions = [a["content"] for a in all_actions] if payload.webhook_type == "feishu": card = format_feishu_card( @@ -330,7 +339,10 @@ async def notify_meeting_webhook( event_type="meeting.webhook_sent", subject_type="meeting", subject_id=meeting.id, - payload={"webhook_type": payload.webhook_type, "webhook_url": payload.webhook_url}, + payload={ + "webhook_type": payload.webhook_type, + "webhook_url": redact_webhook_url(payload.webhook_url), + }, ) session.commit() return utc_response({"status": "ok", "response": res, "webhook_type": payload.webhook_type}) diff --git a/backend/app/outcomes/service.py b/backend/app/outcomes/service.py index 3ce17d2..9f2a131 100644 --- a/backend/app/outcomes/service.py +++ b/backend/app/outcomes/service.py @@ -10,7 +10,11 @@ from sqlalchemy.orm.exc import StaleDataError from app.agendas.models import AgendaItem -from app.agendas.outcome_tags import format_action_tag, rewrite_action_in_notes +from app.agendas.outcome_tags import ( + format_action_tag, + parse_outcome_tags, + rewrite_action_in_notes, +) from app.time_utils import as_utc, utcnow from app.validation import fetch_users, require_active from app.auth.models import User, UserStatus @@ -467,7 +471,7 @@ def supersede_decision( def _action(self, action_id: str) -> ActionItem: action = self.session.get(ActionItem, action_id) if action is None: - raise AppError(404, "action_not_found", "不存在") + raise AppError(404, "action_not_found", "行动项不存在") return action def create_action( @@ -476,7 +480,7 @@ def create_action( require_active(actor) self._require_project_contribution(project_id, actor) if payload.project_id != project_id: - raise AppError(422, "source_mismatch", "项目与路径项目不匹配") + raise AppError(422, "source_mismatch", "行动项项目与路径项目不匹配") _, meeting, _ = self.require_source_chain( project_id, payload.meeting_id, payload.agenda_item_id ) @@ -530,7 +534,19 @@ def update_action( elif previous_status == ActionStatus.done: action.completed_at = None if changes: - if action.source_agenda_item_id and action.source_tag_key: + meeting = ( + self.session.get(Meeting, action.meeting_id) + if action.meeting_id + else None + ) + meeting_is_mutable = ( + meeting is not None and meeting.status in MUTABLE_MEETING_STATUSES + ) + if ( + meeting_is_mutable + and action.source_agenda_item_id + and action.source_tag_key + ): agenda_item = self.session.get(AgendaItem, action.source_agenda_item_id) if agenda_item and agenda_item.notes_markdown: owner_name = None @@ -552,7 +568,11 @@ def update_action( if updated_notes != agenda_item.notes_markdown: agenda_item.notes_markdown = updated_notes agenda_item.version += 1 - meeting_versions = self._touch_meetings(actor, action.meeting_id) + meeting_versions = ( + self._touch_meetings(actor, action.meeting_id) + if meeting_is_mutable + else {} + ) action.version += 1 event_type = "action.updated" if "status" in changes: @@ -591,7 +611,7 @@ def update_action( def _question(self, question_id: str) -> OpenQuestion: question = self.session.get(OpenQuestion, question_id) if question is None: - raise AppError(404, "question_not_found", "不存在") + raise AppError(404, "question_not_found", "开放问题不存在") return question def create_question( @@ -672,9 +692,9 @@ def schedule_question( question.status == OpenQuestionStatus.scheduled or question.scheduled_meeting_id ): - raise AppError(409, "question_already_scheduled", "已排入会议") + raise AppError(409, "question_already_scheduled", "开放问题已排入会议") if question.status != OpenQuestionStatus.open: - raise AppError(409, "question_not_open", "当前不可排期") + raise AppError(409, "question_not_open", "开放问题当前不可排期") meeting = self.session.get(Meeting, payload.meeting_id) if meeting is None: raise AppError(404, "source_not_found", "目标会议不存在") @@ -682,13 +702,13 @@ def schedule_question( require_version(payload.expected_meeting_version, meeting.version) self._mutable_meeting(meeting) if meeting.project_id != question.project_id: - raise AppError(422, "source_mismatch", "目标会议与不属于同一项目") + raise AppError(422, "source_mismatch", "目标会议与开放问题不属于同一项目") origin = question.meeting earliest = utcnow() if origin is not None: earliest = max(earliest, as_utc(origin.scheduled_start)) if as_utc(meeting.scheduled_start) <= earliest: - raise AppError(422, "meeting_not_future", "只能排入之后的会议") + raise AppError(422, "meeting_not_future", "开放问题只能排入之后的会议") position = ( self.session.scalar( select(func.count()) @@ -732,7 +752,7 @@ def resolve_question( self._require_project_contribution(question.project_id, actor) require_version(payload.expected_version, question.version) if question.status in {OpenQuestionStatus.resolved, OpenQuestionStatus.dropped}: - raise AppError(409, "question_already_closed", "已关闭") + raise AppError(409, "question_already_closed", "开放问题已关闭") if payload.decision_id: decision = self._decision(payload.decision_id) self._require_project_contribution(decision.project_id, actor) @@ -889,13 +909,13 @@ def convert_agenda_to_question( ) ) if existing is not None: - raise AppError(409, "agenda_already_converted", "议题已转为") + raise AppError(409, "agenda_already_converted", "议题已转为开放问题") require_version(payload.expected_source_version, source.version) source_meeting = source.meeting require_version(payload.expected_source_meeting_version, source_meeting.version) self._mutable_meeting(source_meeting) if source.status != AgendaStatus.skipped: - raise AppError(409, "agenda_not_skipped", "只有跳过的议题可转为") + raise AppError(409, "agenda_not_skipped", "只有跳过的议题可转为开放问题") question = OpenQuestion( id=str(uuid.uuid4()), project_id=source_meeting.project_id, @@ -930,12 +950,12 @@ def convert_agenda_to_question( ) if duplicate is not None: raise AppError( - 409, "agenda_already_converted", "议题已转为" + 409, "agenda_already_converted", "议题已转为开放问题" ) from exc raise AppError( 409, "outcome_integrity_conflict", - "写入违反数据完整性约束", + "开放问题写入违反数据完整性约束", ) from exc except StaleDataError as exc: resolve_stale( @@ -996,6 +1016,7 @@ def copy_agenda_to_meeting( ) or 0 ) + tags = parse_outcome_tags(source.notes_markdown or "") item = AgendaItem( id=str(uuid.uuid4()), meeting_id=target.id, @@ -1012,6 +1033,11 @@ def copy_agenda_to_meeting( updated_by=actor.id, ) self.session.add(item) + self.session.flush() + if tags: + from app.agendas.service import AgendaService + + AgendaService(self.session)._reconcile_derived_outcomes(item, tags, actor) source.version += 1 source.updated_by = actor.id source_meeting_id = source_meeting.id diff --git a/backend/app/webhooks/adapter.py b/backend/app/webhooks/adapter.py index 5920238..7444bb5 100644 --- a/backend/app/webhooks/adapter.py +++ b/backend/app/webhooks/adapter.py @@ -3,9 +3,12 @@ import base64 import hashlib import hmac +import ipaddress +import os +import socket import time from typing import Any, Literal -from urllib.parse import quote_plus +from urllib.parse import quote_plus, urlsplit, urlunsplit import httpx @@ -13,6 +16,104 @@ WebhookType = Literal["feishu", "dingtalk", "generic"] +_BLOCKED_NETWORKS = ( + ipaddress.ip_network("127.0.0.0/8"), + ipaddress.ip_network("169.254.0.0/16"), + ipaddress.ip_network("10.0.0.0/8"), + ipaddress.ip_network("172.16.0.0/12"), + ipaddress.ip_network("192.168.0.0/16"), + ipaddress.ip_network("0.0.0.0/8"), + ipaddress.ip_network("::1/128"), + ipaddress.ip_network("fc00::/7"), + ipaddress.ip_network("fe80::/10"), +) + + +def _is_blocked_ip(ip_obj: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: + if isinstance(ip_obj, ipaddress.IPv6Address) and ip_obj.ipv4_mapped is not None: + ip_obj = ip_obj.ipv4_mapped + return ( + any(ip_obj in net for net in _BLOCKED_NETWORKS) + or ip_obj.is_loopback + or ip_obj.is_private + or ip_obj.is_link_local + or ip_obj.is_unspecified + or ip_obj.is_multicast + ) + + +def validate_webhook_url(webhook_url: str, *, allow_private: bool = False) -> None: + raw = (webhook_url or "").strip() + parsed = urlsplit(raw) + scheme = parsed.scheme.lower() + hostname = (parsed.hostname or "").strip().lower() + if scheme not in ("http", "https") or not hostname: + raise AppError( + 422, + "invalid_webhook_url", + "Webhook 地址必须使用 http:// 或 https:// 协议", + ) + + if allow_private or os.getenv("MEETFLOW_WEBHOOK_ALLOW_PRIVATE") == "1": + return + + if hostname in {"localhost", "localhost.localdomain"} or hostname.endswith( + (".local", ".internal") + ): + raise AppError( + 422, + "invalid_webhook_url", + "禁止将 Webhook 发送至本地或内网地址", + ) + + try: + literal_ip = ipaddress.ip_address(hostname) + except ValueError: + literal_ip = None + + if literal_ip is not None: + if _is_blocked_ip(literal_ip): + raise AppError( + 422, + "invalid_webhook_url", + "禁止将 Webhook 发送至本地或内网地址", + ) + return + + port = parsed.port or (443 if scheme == "https" else 80) + try: + addr_infos = socket.getaddrinfo(hostname, port, type=socket.SOCK_STREAM) + except socket.gaierror: + return + + for info in addr_infos: + raw_ip = str(info[4][0]).split("%", 1)[0] + try: + resolved_ip = ipaddress.ip_address(raw_ip) + except ValueError: + continue + if _is_blocked_ip(resolved_ip): + raise AppError( + 422, + "invalid_webhook_url", + "禁止将 Webhook 发送至本地或内网地址", + ) + + +def redact_webhook_url(webhook_url: str) -> str: + parsed = urlsplit((webhook_url or "").strip()) + if not parsed.scheme or not parsed.netloc: + return "***" + path_parts = [part for part in parsed.path.split("/") if part] + if len(path_parts) > 1: + redacted_path = "/" + "/".join([*path_parts[:-1], "***"]) + elif path_parts: + redacted_path = "/***" + else: + redacted_path = "" + redacted_query = "***" if parsed.query else "" + return urlunsplit((parsed.scheme, parsed.netloc, redacted_path, redacted_query, "")) + def format_feishu_card( meeting_title: str, @@ -136,10 +237,13 @@ async def dispatch_webhook( webhook_type: WebhookType, payload: dict[str, Any], secret: str | None = None, + *, + allow_private: bool = False, ) -> dict[str, Any]: - url = webhook_url + validate_webhook_url(webhook_url, allow_private=allow_private) + url = webhook_url.strip() headers = {"Content-Type": "application/json"} - body = payload + body = dict(payload) if webhook_type == "dingtalk" and secret: timestamp = str(round(time.time() * 1000)) @@ -147,7 +251,7 @@ async def dispatch_webhook( string_to_sign = f"{timestamp}\n{secret}".encode("utf-8") hmac_code = hmac.new(secret_enc, string_to_sign, digestmod=hashlib.sha256).digest() sign = quote_plus(base64.b64encode(hmac_code).decode("utf-8")) - url = f"{webhook_url}×tamp={timestamp}&sign={sign}" if "?" in webhook_url else f"{webhook_url}?timestamp={timestamp}&sign={sign}" + url = f"{url}×tamp={timestamp}&sign={sign}" if "?" in url else f"{url}?timestamp={timestamp}&sign={sign}" elif webhook_type == "feishu" and secret: timestamp = str(int(time.time())) diff --git a/backend/tests/api/test_calendar_and_webhooks.py b/backend/tests/api/test_calendar_and_webhooks.py index b14753b..49970e8 100644 --- a/backend/tests/api/test_calendar_and_webhooks.py +++ b/backend/tests/api/test_calendar_and_webhooks.py @@ -1,10 +1,17 @@ -from datetime import date, datetime, timezone +from datetime import date, datetime, timedelta, timezone +import httpx import pytest +from sqlalchemy import select from unittest.mock import AsyncMock, patch +from app.agendas.schemas import AgendaEdit, AgendaWrite +from app.agendas.service import AgendaService +from app.auth.models import User +from app.calendar.router import generate_ics_feed +from app.collaboration.models import ActivityEvent from app.domain.enums import ActionStatus from app.meetings.models import Meeting -from app.outcomes.models import ActionItem +from app.outcomes.models import ActionItem, Decision def test_calendar_feed_url_and_ics_export(authenticated_client, meeting_id): @@ -16,13 +23,17 @@ def test_calendar_feed_url_and_ics_export(authenticated_client, meeting_id): feed_url = data["feed_url"] assert feed_url.endswith(".ics") - # 2. Add an action with due_date + # 2. Add an action with due_date and special characters in content + tz_plus_8 = timezone(timedelta(hours=8)) with authenticated_client.app.state.database.session() as session: meeting = session.get(Meeting, meeting_id) + user = session.get(User, meeting.created_by) + meeting.title = "评审, 复盘; 阶段\\总结\n第二行" + meeting.purpose_markdown = "第一行;\n第二行, 含反斜杠\\结束" action = ActionItem( project_id=meeting.project_id, meeting_id=meeting.id, - content="准备季度财务总结", + content="准备季度财务总结; 核对, 归档", due_date=date(2026, 10, 20), status=ActionStatus.open, owner_user_id=meeting.created_by, @@ -31,6 +42,13 @@ def test_calendar_feed_url_and_ics_export(authenticated_client, meeting_id): session.add(action) session.commit() + # Verify timezone-aware non-UTC datetime conversion in generate_ics_feed + meeting.scheduled_start = datetime(2026, 10, 20, 18, 0, tzinfo=tz_plus_8) + meeting.scheduled_end = datetime(2026, 10, 20, 19, 0, tzinfo=tz_plus_8) + direct_ics = generate_ics_feed(user, [action], [meeting]) + assert "DTSTART:20261020T100000Z" in direct_ics + assert "DTEND:20261020T110000Z" in direct_ics + # 3. Access the feed without cookies (external calendar client) ics_res = authenticated_client.get(f"/api/calendar/{token}.ics", cookies={}) assert ics_res.status_code == 200 @@ -38,8 +56,11 @@ def test_calendar_feed_url_and_ics_export(authenticated_client, meeting_id): text = ics_res.text assert "BEGIN:VCALENDAR" in text assert "BEGIN:VEVENT" in text - assert "[待办] 准备季度财务总结" in text - assert "VALUE=DATE:20261020" in text + assert "SUMMARY:[待办] 准备季度财务总结\\; 核对\\, 归档" in text + assert "DTSTART;VALUE=DATE:20261020" in text + assert "DTEND;VALUE=DATE:20261021" in text + assert "SUMMARY:[会议] 评审\\, 复盘\\; 阶段\\\\总结\\n第二行" in text + assert "DESCRIPTION:会议议题与目的:\\n第一行\\;\\n第二行\\, 含反斜杠\\\\结束" in text assert "END:VCALENDAR" in text # 4. Invalid token test @@ -47,20 +68,47 @@ def test_calendar_feed_url_and_ics_export(authenticated_client, meeting_id): assert bad_res.status_code == 404 -import httpx - @pytest.mark.anyio async def test_meeting_webhook_notification_feishu_and_dingtalk(authenticated_client, meeting_id): + with authenticated_client.app.state.database.session() as session: + meeting = session.get(Meeting, meeting_id) + admin = session.get(User, meeting.created_by) + agenda_service = AgendaService(session) + item = agenda_service.create( + meeting.id, + AgendaWrite(title="Webhook 统计议题", agenda_type="decision"), + admin, + expected_meeting_version=meeting.version, + ) + agenda_service.update( + item.id, + AgendaEdit( + expected_version=item.version, + notes_markdown="@决策: 议题级决议\n@行动: 议题级行动", + ), + admin, + ) + session.add( + Decision( + project_id=meeting.project_id, + meeting_id=meeting.id, + title="会议级决议", + decision_markdown="会议级决议内容", + created_by=admin.id, + ) + ) + session.commit() + mock_post = AsyncMock() mock_post.return_value = httpx.Response(200, json={"StatusCode": 0, "StatusMessage": "success"}) - # Test Feishu webhook notify with patch("httpx.AsyncClient.post", mock_post): + # Test Feishu webhook notify using `platform` alias (sent by frontend modal) res = authenticated_client.post( f"/api/meetings/{meeting_id}/webhook-notify", json={ - "webhook_url": "https://open.feishu.cn/open-apis/bot/v2/hook/mock", - "webhook_type": "feishu", + "webhook_url": "https://open.feishu.cn/open-apis/bot/v2/hook/secret-hook-token-123", + "platform": "feishu", }, ) assert res.status_code == 200 @@ -68,8 +116,30 @@ async def test_meeting_webhook_notification_feishu_and_dingtalk(authenticated_cl assert res.json()["webhook_type"] == "feishu" assert mock_post.called call_args, call_kwargs = mock_post.call_args - assert call_args[0] == "https://open.feishu.cn/open-apis/bot/v2/hook/mock" - assert call_kwargs["json"]["msg_type"] == "interactive" + assert call_args[0] == "https://open.feishu.cn/open-apis/bot/v2/hook/secret-hook-token-123" + card_json = call_kwargs["json"] + assert card_json["msg_type"] == "interactive" + card_str = str(card_json) + # Should include both meeting-level (1) and agenda-level (1) decisions = 2, and agenda-level action = 1 + assert "决议清单 (2)" in card_str + assert "会议级决议" in card_str + assert "议题级决议" in card_str + assert "待办事项 (1)" in card_str + assert "议题级行动" in card_str + + # Verify ActivityEvent redacts the secret webhook token + with authenticated_client.app.state.database.session() as session: + event = session.scalar( + select(ActivityEvent) + .where( + ActivityEvent.meeting_id == meeting_id, + ActivityEvent.event_type == "meeting.webhook_sent", + ) + .order_by(ActivityEvent.id.desc()) + ) + assert event is not None + assert "secret-hook-token-123" not in event.payload_json["webhook_url"] + assert "***" in event.payload_json["webhook_url"] # Test DingTalk webhook notify mock_post.reset_mock() @@ -91,3 +161,28 @@ async def test_meeting_webhook_notification_feishu_and_dingtalk(authenticated_cl assert "timestamp=" in call_args2[0] assert "sign=" in call_args2[0] assert call_kwargs2["json"]["msgtype"] == "markdown" + + +@pytest.mark.parametrize( + "unsafe_url", + [ + "http://127.0.0.1:8000/hook", + "http://169.254.169.254/latest/meta-data/", + "http://10.0.0.1/internal", + "http://192.168.1.1/hook", + "http://localhost:8080/hook", + "ftp://open.feishu.cn/hook", + ], +) +def test_meeting_webhook_ssrf_protection_blocks_private_and_invalid_urls( + authenticated_client, meeting_id, unsafe_url +): + res = authenticated_client.post( + f"/api/meetings/{meeting_id}/webhook-notify", + json={ + "webhook_url": unsafe_url, + "platform": "generic", + }, + ) + assert res.status_code == 422 + assert res.json()["error"]["code"] == "invalid_webhook_url" diff --git a/backend/tests/domain/test_bidirectional_actions.py b/backend/tests/domain/test_bidirectional_actions.py index 22a2aae..f8b878b 100644 --- a/backend/tests/domain/test_bidirectional_actions.py +++ b/backend/tests/domain/test_bidirectional_actions.py @@ -166,3 +166,157 @@ def test_bidirectional_action_sync_end_to_end(client, agenda_context): # 3. Verify that the agenda item notes_markdown AST has been rewritten! refreshed_agenda = session.get(AgendaItem, saved_agenda.id) assert f"@行动:[done][@{admin.username}][2026-10-15] 实施双向状态机" in refreshed_agenda.notes_markdown + + +def test_parse_outcome_tags_fenced_code_blocks_brackets_and_question_aliases(): + md = """ +# 议题记录 +```markdown +@决策: 代码块内的示例决策不应提取 +@行动:[done] 代码块内的示例行动不应提取 +@问题: 代码块内的示例问题不应提取 +``` +~~~ +@开放问题: 波浪号代码块内也不应提取 +~~~ +@行动:[todo][@alice][P0] 修复核心缺陷 +@问题: 简写问题标签是否生效? +@开放问题: 完整开放问题标签是否生效? +""" + tags = parse_outcome_tags(md) + assert len(tags) == 3 + assert tags[0].kind == "action" + assert tags[0].owner_name == "alice" + assert tags[0].content == "[P0] 修复核心缺陷" + assert tags[1].kind == "question" + assert tags[1].content == "简写问题标签是否生效?" + assert tags[2].kind == "question" + assert tags[2].content == "完整开放问题标签是否生效?" + + # Verify rewrite_action_in_notes also skips fenced code blocks + rewritten = rewrite_action_in_notes( + md, + "action:0", + format_action_tag(content="[P0] 修复核心缺陷", status=ActionStatus.done, owner="alice"), + ) + assert "@行动:[done] 代码块内的示例行动不应提取" in rewritten + assert "@行动:[done][@alice] [P0] 修复核心缺陷" in rewritten + + +def test_reconcile_outcomes_preserves_final_decision_clears_metadata_and_handles_duplicate_names( + client, agenda_context +): + _, _, meeting_id = agenda_context + with client.app.state.database.session() as session: + admin = session.scalar(select(User).where(User.username == "admin")) + member = session.scalar(select(User).where(User.username == "test-member")) + member_id = member.id + # Add another active user with duplicate display_name and an inactive user + dup_member = User( + username="dup-member", + display_name="Test Member", + password_hash="unused", + role=UserRole.MEMBER, + status=UserStatus.ACTIVE, + ) + inactive_user = User( + username="inactive-owner", + display_name="Inactive Owner", + password_hash="unused", + role=UserRole.MEMBER, + status=UserStatus.DISABLED, + ) + session.add_all([dup_member, inactive_user]) + session.commit() + dup_member_id = dup_member.id + admin = session.scalar(select(User).where(User.username == "admin")) + + agenda_service = AgendaService(session) + outcome_service = OutcomeService(session) + meeting = session.get(Meeting, meeting_id) + + agenda = agenda_service.create( + meeting.id, + AgendaWrite(title="Reconciliation Edge Cases", agenda_type="decision"), + admin, + expected_meeting_version=meeting.version, + ) + + # 1. Create initial decision and action with owner + due_date + agenda = agenda_service.update( + agenda.id, + AgendaEdit( + expected_version=agenda.version, + notes_markdown=( + "@决策: 核心架构定稿\n" + "@行动:[todo][@Test Member][2026-10-20] 编写接口文档\n" + "@行动:[todo][@inactive-owner] 已禁用用户任务\n" + ), + ), + admin, + ) + + from app.outcomes.models import Decision + from app.outcomes.schemas import DecisionFinalizeWrite + + decisions = [ + d + for d in outcome_service.list_decisions(meeting.project_id) + if d.agenda_item_id == agenda.id + ] + assert len(decisions) == 1 + final_dec = outcome_service.finalize_decision( + decisions[0].id, + DecisionFinalizeWrite(expected_version=decisions[0].version), + admin, + ) + assert final_dec.status.value == "final" + + actions = [ + a + for a in outcome_service.list_actions(meeting.project_id) + if a.agenda_item_id == agenda.id + ] + doc_action = next(a for a in actions if a.content == "编写接口文档") + assert doc_action.owner_user_id in {member_id, dup_member_id} + assert doc_action.due_date == date(2026, 10, 20) + + inactive_action = next(a for a in actions if a.content == "已禁用用户任务") + assert inactive_action.owner_user_id is None + + # 2. Insert a new decision BEFORE the finalized decision and remove owner/due_date from action + agenda = session.get(AgendaItem, agenda.id) + agenda = agenda_service.update( + agenda.id, + AgendaEdit( + expected_version=agenda.version, + notes_markdown=( + "@决策: 前置新增决议\n" + "@决策: 核心架构定稿\n" + "@行动:[in_progress] 编写接口文档\n" + ), + ), + admin, + ) + + session.refresh(final_dec) + assert final_dec.status.value == "final" + assert final_dec.title == "核心架构定稿" + assert final_dec.source_tag_key == "decision:1" + + session.refresh(doc_action) + assert doc_action.status == ActionStatus.in_progress + assert doc_action.owner_user_id is None + assert doc_action.due_date is None + + # 3. Even if all @决策 tags are removed from notes, finalized decision must not be deleted + agenda = agenda_service.update( + agenda.id, + AgendaEdit( + expected_version=agenda.version, + notes_markdown="@行动:[done] 编写接口文档\n", + ), + admin, + ) + assert session.get(Decision, final_dec.id) is not None + assert session.get(Decision, final_dec.id).status.value == "final" diff --git a/backend/tests/domain/test_outcomes.py b/backend/tests/domain/test_outcomes.py index 2d1176e..8d80b9f 100644 --- a/backend/tests/domain/test_outcomes.py +++ b/backend/tests/domain/test_outcomes.py @@ -604,3 +604,125 @@ def test_concurrent_migration_rolls_back_and_leaves_session_reusable( == target_id ) assert second.scalar(select(func.count(OutcomeMigrationRecord.id))) == 1 + + +def test_completed_meeting_allows_action_status_update_without_rewriting_locked_notes( + client, outcome_context +): + admin_id, _, _, project_id, meeting_id, _, source_id, _ = outcome_context + with client.app.state.database.session() as session: + actor = session.get(User, admin_id) + agendas = AgendaService(session) + outcomes = OutcomeService(session) + source = session.get(AgendaItem, source_id) + original_notes = "@行动:[todo] 会后跟进线上监控" + source = agendas.update( + source.id, + AgendaEdit(expected_version=source.version, notes_markdown=original_notes), + actor, + ) + derived_action = session.scalar( + select(ActionItem).where(ActionItem.source_agenda_item_id == source.id) + ) + assert derived_action is not None + + # Lock the meeting as completed + meeting = session.get(Meeting, meeting_id) + meeting.status = "completed" + meeting.version += 1 + session.commit() + locked_meeting_version = meeting.version + locked_agenda_version = source.version + + # Toggling action status after meeting completion must succeed without mutating locked meeting/notes + updated = outcomes.update_action( + derived_action.id, + ActionEdit(expected_version=derived_action.version, status="done"), + actor, + ) + assert updated.status.value == "done" + assert updated.completed_at is not None + + session.refresh(meeting) + session.refresh(source) + assert meeting.version == locked_meeting_version + assert source.version == locked_agenda_version + assert source.notes_markdown == original_notes + + +def test_copy_agenda_to_meeting_extracts_outcome_tags(client, outcome_context): + from app.outcomes.schemas import AgendaCopyWrite + + admin_id, _, _, _, meeting_id, future_id, source_id, _ = outcome_context + with client.app.state.database.session() as session: + actor = session.get(User, admin_id) + agendas = AgendaService(session) + outcomes = OutcomeService(session) + source = session.get(AgendaItem, source_id) + source = agendas.update( + source.id, + AgendaEdit( + expected_version=source.version, + notes_markdown=( + "@决策: 沿用缓存架构\n" + "@行动:[todo] 补充压测报告\n" + "@问题: 峰值流量如何限流?\n" + ), + ), + actor, + ) + source = agendas.skip( + source.id, AgendaCommand(expected_version=source.version), actor + ) + source_meeting = session.get(Meeting, meeting_id) + future_meeting = session.get(Meeting, future_id) + + copied = outcomes.copy_agenda_to_meeting( + source.id, + AgendaCopyWrite( + target_meeting_id=future_meeting.id, + expected_source_version=source.version, + expected_source_meeting_version=source_meeting.version, + expected_target_meeting_version=future_meeting.version, + ), + actor, + ) + copied_decisions = session.scalars( + select(Decision).where(Decision.source_agenda_item_id == copied.id) + ).all() + copied_actions = session.scalars( + select(ActionItem).where(ActionItem.source_agenda_item_id == copied.id) + ).all() + copied_questions = session.scalars( + select(OpenQuestion).where(OpenQuestion.source_agenda_item_id == copied.id) + ).all() + + assert len(copied_decisions) == 1 + assert copied_decisions[0].title == "沿用缓存架构" + assert len(copied_actions) == 1 + assert copied_actions[0].content == "补充压测报告" + assert len(copied_questions) == 1 + assert copied_questions[0].question_markdown == "峰值流量如何限流?" + + +@pytest.mark.anyio +async def test_retry_on_db_lock_async_uses_asyncio_sleep(): + from unittest.mock import AsyncMock, patch + from sqlalchemy.exc import OperationalError + from app.database import retry_on_db_lock + + attempts = 0 + + @retry_on_db_lock(max_retries=2, initial_backoff=0.01) + async def flaky_op(): + nonlocal attempts + attempts += 1 + if attempts < 2: + raise OperationalError("stmt", {}, Exception("database is locked")) + return "recovered" + + with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep: + result = await flaky_op() + assert result == "recovered" + assert attempts == 2 + mock_sleep.assert_awaited_once() diff --git a/backend/tests/plugins/test_actions.py b/backend/tests/plugins/test_actions.py index c121e30..94de1ee 100644 --- a/backend/tests/plugins/test_actions.py +++ b/backend/tests/plugins/test_actions.py @@ -39,6 +39,12 @@ class Client: def __init__(self, **_kwargs): pass + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return None + async def post(self, _url, **kwargs): captured.update(kwargs) return Response() @@ -83,6 +89,12 @@ class Client: def __init__(self, **_kwargs): pass + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return None + async def post(self, url, **kwargs): captured["url"] = url captured.update(kwargs) @@ -131,6 +143,12 @@ class Client: def __init__(self, **_kwargs): pass + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return None + async def post(self, _url, **_kwargs): return Response() @@ -171,6 +189,12 @@ class Client: def __init__(self, **_kwargs): pass + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return None + async def post(self, _url, **kwargs): captured.update(kwargs) return Response() @@ -223,6 +247,12 @@ class Client: def __init__(self, **_kwargs): pass + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return None + async def post(self, _url, **_kwargs): return Response() @@ -244,6 +274,51 @@ async def post(self, _url, **_kwargs): assert result == {"markdown": expected_markdown, "model": "test-model"} +def test_ai_work_assistant_handles_null_message_content( + ai_work_assistant_backend, monkeypatch +): + closed = False + + class Response: + def raise_for_status(self): + return None + + def json(self): + return {"choices": [{"message": {"content": None}}]} + + class Client: + def __init__(self, **_kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + nonlocal closed + closed = True + return None + + async def post(self, _url, **_kwargs): + return Response() + + monkeypatch.setattr(ai_work_assistant_backend.httpx, "AsyncClient", Client) + + result = asyncio.run( + ai_work_assistant_backend.meeting_summary( + {"title": "空响应测试"}, + {"current_markdown": ""}, + { + "base_url": "https://example.test/v1", + "api_key": "test-key", + "model": "test-model", + "timeout_seconds": 10, + }, + ) + ) + assert closed is True + assert result == {"markdown": "", "model": "test-model"} + + def test_ai_work_assistant_declares_bounded_editor_input(ai_work_assistant_backend): class Registry: def __init__(self): diff --git a/frontend/src/components/AgendaDetail.vue b/frontend/src/components/AgendaDetail.vue index 9589227..1970347 100644 --- a/frontend/src/components/AgendaDetail.vue +++ b/frontend/src/components/AgendaDetail.vue @@ -112,20 +112,29 @@ const completedWindow = computed(() => { return parts.join(' · ') }) const liveElapsed = computed(() => { - if (props.item.status !== 'in_progress' || !props.meeting.started_at) return '' - const elapsedSeconds = Math.max(0, Math.floor((now.value - parseUtcTimestamp(props.meeting.started_at).getTime()) / 1000)) + const startedAt = props.item.started_at ?? props.meeting.started_at + if (props.item.status !== 'in_progress' || !startedAt) return '' + const elapsedSeconds = Math.max(0, Math.floor((now.value - parseUtcTimestamp(startedAt).getTime()) / 1000)) const hours = Math.floor(elapsedSeconds / 3600) const minutes = Math.floor((elapsedSeconds % 3600) / 60) const seconds = elapsedSeconds % 60 return [hours, minutes, seconds].map((part) => String(part).padStart(2, '0')).join(':') }) +const currentItemId = ref(props.item.id) watch(() => props.item, (item) => { + const switchedItem = item.id !== currentItemId.value + currentItemId.value = item.id const next = draftFor(item) - Object.assign(draft, next) - accepted.value = next - currentVersion.value = item.version - attachmentItems.value = null + if (switchedItem || !dirty.value) { + Object.assign(draft, next) + accepted.value = next + currentVersion.value = item.version + } + if (switchedItem) { + attachmentItems.value = null + conflict.value = null + } }, { deep: true }) let clockHandle: number | undefined @@ -191,7 +200,19 @@ async function persistIfDirty(expectedVersion = currentVersion.value): Promise(`/api/meetings/${props.meeting.id}`) + const latestItem = latestMeeting.agenda_items?.find((row) => row.id === props.item.id) + if (latestItem) { + serverNotes = latestItem.notes_markdown + actualVersion = latestItem.version + } + } catch { + // Fallback to conflict details and current props if fetching latest meeting fails. + } + conflict.value = { version: actualVersion, server: serverNotes } } else error.value = errorMessage(caught, '议题保存失败') throw caught } finally { @@ -199,6 +220,18 @@ async function persistIfDirty(expectedVersion = currentVersion.value): Promise { return persistIfDirty() } @@ -508,7 +541,7 @@ const noteActionTags = computed(() => {
- + diff --git a/frontend/src/components/FocusMode.vue b/frontend/src/components/FocusMode.vue index fff5201..f57ce84 100644 --- a/frontend/src/components/FocusMode.vue +++ b/frontend/src/components/FocusMode.vue @@ -348,13 +348,13 @@ const noteActionTags = computed(() => {

- 💡 支持在手记中直接输入 @决策: 结论、@行动:[待办][@成员][日期] 事项、@问题: 待确认 自动双向流转产出。 + 💡 支持在手记中直接输入 @决策: 结论、@行动:[待办][@成员][日期] 事项、@开放问题: 待确认 自动双向流转产出。

快捷标签: - +
diff --git a/frontend/src/components/MeetingCommentsPanel.vue b/frontend/src/components/MeetingCommentsPanel.vue index f506df2..8d7c178 100644 --- a/frontend/src/components/MeetingCommentsPanel.vue +++ b/frontend/src/components/MeetingCommentsPanel.vue @@ -1,6 +1,6 @@