执行全项目配置隐患与模型命名扫描清单
This commit is contained in:
parent
054d056182
commit
331e9109e2
|
|
@ -42,7 +42,10 @@ backend/
|
|||
│ └── versions/
|
||||
│ ├── 20260310_add_feishu_outbox_reply_hash.py # 飞书 outbox 回执哈希幂等字段
|
||||
│ ├── 20260311_add_pg_trgm_text_indexes.py # pg_trgm 扩展与文本检索索引
|
||||
│ └── 20260311_add_kb_chunk_ivfflat_index.py # knowledge_base_chunks 向量 IVFFlat 索引
|
||||
│ ├── 20260311_add_kb_chunk_ivfflat_index.py # knowledge_base_chunks 向量 IVFFlat 索引
|
||||
│ └── 20260311_add_conversation_sessions_table.py # 飞书多轮会话上下文持久化表
|
||||
├── scripts/ # 后端运维/数据修复脚本
|
||||
│ └── backfill_orphan_signal_links.py # 回填孤立 signal 的 project_signal_links
|
||||
├── requirements.txt # Python 依赖
|
||||
├── Dockerfile
|
||||
└── tests/
|
||||
|
|
@ -152,3 +155,4 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
|||
- `agents.defaults.timeoutSeconds` 在 OpenClaw 网关行为未验证,当前以 `LLM_REQUEST_TIMEOUT` 环境变量为准
|
||||
- World Model 静态层已接入 Redis 缓存(key: `wm:static:{tenant_id}:{project_id}`,TTL 600s)
|
||||
- 写操作后主动 invalidate,动态层不缓存
|
||||
- AI PMO 意图识别已启用 LLM 主路径(`AI_PMO_INTENT_LLM_ENABLED`,默认开启);当模型超时/不可用/结构化解析失败时自动降级到规则链路,不中断主流程
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@
|
|||
"enabled": true,
|
||||
"priority": 300,
|
||||
"tier": "low",
|
||||
"provider": "openai",
|
||||
"model": "zai/glm-4-flash",
|
||||
"provider": "glm",
|
||||
"model": "GLM-4.7",
|
||||
"match_any": [
|
||||
"feishu_level_classification",
|
||||
"feishu_signal_extraction",
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
"priority": 220,
|
||||
"tier": "high",
|
||||
"provider": "openai",
|
||||
"model": "minimax/MiniMax-M2.5",
|
||||
"model": "MiniMax-M2.5",
|
||||
"match_any": [
|
||||
"adaptive_rag_answer",
|
||||
"feishu_project_prefill",
|
||||
|
|
@ -49,7 +49,7 @@
|
|||
"priority": 200,
|
||||
"tier": "high",
|
||||
"provider": "openai",
|
||||
"model": "minimax/MiniMax-M2.5",
|
||||
"model": "MiniMax-M2.5",
|
||||
"match_any": [
|
||||
"analyze_food_calories",
|
||||
"feishu_new_project_brief",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
"""add conversation sessions table
|
||||
|
||||
Revision ID: add_conversation_sessions_table
|
||||
Revises: add_kb_chunk_ivfflat_index
|
||||
Create Date: 2026-03-11
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "add_conversation_sessions_table"
|
||||
down_revision = "add_kb_chunk_ivfflat_index"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(idx.get("name") == index_name for idx in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def _create_index_if_missing(bind, table_name: str, index_name: str, columns: list[str], *, unique: bool = False) -> None:
|
||||
if not _index_exists(bind, table_name, index_name):
|
||||
op.create_index(index_name, table_name, columns, unique=unique)
|
||||
|
||||
|
||||
def _drop_index_if_exists(bind, table_name: str, index_name: str) -> None:
|
||||
if _index_exists(bind, table_name, index_name):
|
||||
op.drop_index(index_name, table_name=table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "conversation_sessions"):
|
||||
op.create_table(
|
||||
"conversation_sessions",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
|
||||
sa.Column("sender", sa.String(length=128), nullable=False),
|
||||
sa.Column("current_project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=True),
|
||||
sa.Column("last_intent", sa.String(length=64), nullable=True),
|
||||
sa.Column("last_message_text", sa.String(length=200), nullable=True),
|
||||
sa.Column("last_active_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.UniqueConstraint("tenant_id", "sender", name="uq_conversation_sessions_tenant_sender"),
|
||||
)
|
||||
|
||||
_create_index_if_missing(bind, "conversation_sessions", "ix_conversation_sessions_tenant_id", ["tenant_id"])
|
||||
_create_index_if_missing(bind, "conversation_sessions", "ix_conversation_sessions_sender", ["sender"])
|
||||
_create_index_if_missing(bind, "conversation_sessions", "ix_conversation_sessions_current_project_id", ["current_project_id"])
|
||||
_create_index_if_missing(bind, "conversation_sessions", "ix_conversation_sessions_last_intent", ["last_intent"])
|
||||
_create_index_if_missing(bind, "conversation_sessions", "ix_conversation_sessions_last_active_at", ["last_active_at"])
|
||||
_create_index_if_missing(
|
||||
bind,
|
||||
"conversation_sessions",
|
||||
"ix_conversation_sessions_tenant_sender_last_active",
|
||||
["tenant_id", "sender", "last_active_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "conversation_sessions"):
|
||||
return
|
||||
|
||||
_drop_index_if_exists(bind, "conversation_sessions", "ix_conversation_sessions_tenant_sender_last_active")
|
||||
_drop_index_if_exists(bind, "conversation_sessions", "ix_conversation_sessions_last_active_at")
|
||||
_drop_index_if_exists(bind, "conversation_sessions", "ix_conversation_sessions_last_intent")
|
||||
_drop_index_if_exists(bind, "conversation_sessions", "ix_conversation_sessions_current_project_id")
|
||||
_drop_index_if_exists(bind, "conversation_sessions", "ix_conversation_sessions_sender")
|
||||
_drop_index_if_exists(bind, "conversation_sessions", "ix_conversation_sessions_tenant_id")
|
||||
op.drop_table("conversation_sessions")
|
||||
|
|
@ -500,6 +500,35 @@ PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION = """
|
|||
"""
|
||||
|
||||
|
||||
PROMPT_AI_PMO_INTENT_CLASSIFICATION = """
|
||||
首字符必须是 {{,末字符必须是 }}。
|
||||
你是 PMFlow 的 AI PMO 意图识别模块。你只做分类,不做执行。
|
||||
|
||||
输入:
|
||||
- message_text: {message_text}
|
||||
- session_last_intent: {session_last_intent}
|
||||
- session_project_id: {session_project_id}
|
||||
- world_model_project_id: {world_model_project_id}
|
||||
- candidate_intents_json: {candidate_intents_json}
|
||||
|
||||
输出 JSON:
|
||||
{{
|
||||
"intent": "signal_source.onboard|signal.create|query.read_only|project.create|followup.confirm|followup.continue|ignore",
|
||||
"confidence": 0.0,
|
||||
"project_id": 0,
|
||||
"reasoning": "一句话简述判定原因"
|
||||
}}
|
||||
|
||||
规则:
|
||||
1. intent 必须从 candidate_intents_json 内选择,不能自造。
|
||||
2. 若消息是短确认语气,优先结合 session_last_intent 判定。
|
||||
3. project_id 无法确定时返回 null,不要猜测无依据项目。
|
||||
4. 噪音/寒暄/无关消息返回 ignore。
|
||||
5. 禁止输出 thinking、analysis、解释句、Markdown、代码块。
|
||||
6. 只输出 JSON,不要额外文本。
|
||||
"""
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Full Adaptive RAG
|
||||
# ============================================================================
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ from .decision_record import DecisionRecord
|
|||
from .plan_bundle import PlanBundle
|
||||
from .reply_receipt import ReplyReceipt
|
||||
from .portfolio_snapshot import PortfolioSnapshot
|
||||
from .conversation_session import ConversationSession
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
|
|
@ -126,4 +127,5 @@ __all__ = [
|
|||
"PlanBundle",
|
||||
"ReplyReceipt",
|
||||
"PortfolioSnapshot",
|
||||
"ConversationSession",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
"""飞书/消息对话会话状态。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import BigInteger, ForeignKey, Index, Integer, TIMESTAMP, UniqueConstraint, VARCHAR, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import BaseNoTenant
|
||||
|
||||
|
||||
class ConversationSession(BaseNoTenant):
|
||||
__tablename__ = "conversation_sessions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "sender", name="uq_conversation_sessions_tenant_sender"),
|
||||
Index("ix_conversation_sessions_tenant_sender_last_active", "tenant_id", "sender", "last_active_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(BigInteger, nullable=False, default=1, index=True)
|
||||
sender: Mapped[str] = mapped_column(VARCHAR(128), nullable=False, index=True)
|
||||
current_project_id: Mapped[Optional[int]] = mapped_column(
|
||||
BigInteger,
|
||||
ForeignKey("projects.id"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
last_intent: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True, index=True)
|
||||
last_message_text: Mapped[Optional[str]] = mapped_column(VARCHAR(200), nullable=True)
|
||||
last_active_at: Mapped[datetime] = mapped_column(TIMESTAMP, nullable=False, default=func.now(), index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, nullable=False, default=func.now(), server_default=func.now())
|
||||
|
|
@ -49,6 +49,7 @@ from .recipe import (
|
|||
RecipeImageUploadRequest,
|
||||
RecipeAnalyzeCaloriesResponse,
|
||||
)
|
||||
from .conversation_session import ConversationSessionBase, ConversationSessionRead
|
||||
from . import ai as ai_schemas
|
||||
|
||||
__all__ = [
|
||||
|
|
@ -99,4 +100,6 @@ __all__ = [
|
|||
"RecipeResponse",
|
||||
"RecipeImageUploadRequest",
|
||||
"RecipeAnalyzeCaloriesResponse",
|
||||
"ConversationSessionBase",
|
||||
"ConversationSessionRead",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
"""会话状态 Schema。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ConversationSessionBase(BaseModel):
|
||||
tenant_id: int
|
||||
sender: str = Field(min_length=1, max_length=128)
|
||||
current_project_id: Optional[int] = None
|
||||
last_intent: Optional[str] = Field(default=None, max_length=64)
|
||||
last_message_text: Optional[str] = Field(default=None, max_length=200)
|
||||
last_active_at: datetime
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ConversationSessionRead(ConversationSessionBase):
|
||||
id: int
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
|
@ -5,6 +5,7 @@ from __future__ import annotations
|
|||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
|
@ -15,11 +16,13 @@ from sqlalchemy import and_, func, or_, select
|
|||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..config.ai_prompts import PROMPT_AI_PMO_INTENT_CLASSIFICATION
|
||||
from ..core.logging import get_logger
|
||||
from ..database import AsyncSessionLocal
|
||||
from ..models import (
|
||||
AIProcessingLog,
|
||||
Budget,
|
||||
ConversationSession,
|
||||
DecisionRecord,
|
||||
IngestEvent,
|
||||
Notification,
|
||||
|
|
@ -61,6 +64,7 @@ from .context_cache import ContextCacheService
|
|||
from .notification_service import NotificationService
|
||||
from .openclaw_commander_service import OpenClawCommanderService
|
||||
from .ai_pmo_domain_workflow import SignalSourceDomainWorkflow
|
||||
from .llm import llm_service
|
||||
from .signal_source_orchestration_service import SignalSourceOrchestrationService
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
|
@ -143,6 +147,37 @@ POLICY_GATE_RULES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|||
("charter_rewrite", ("改写charter", "success criteria", "成功标准改", "范围重大变更")),
|
||||
("critical_path_change", ("关键路径", "里程碑改动", "影响多个项目", "跨项目依赖调整")),
|
||||
)
|
||||
BOOL_TRUE_VALUES = {"1", "true", "yes", "y", "on", "是"}
|
||||
SESSION_PROJECT_INHERIT_WINDOW_SECONDS = 30 * 60
|
||||
SESSION_SHORT_CONFIRM_WINDOW_SECONDS = 5 * 60
|
||||
SHORT_CONFIRM_MAX_MESSAGE_LENGTH = 10
|
||||
SHORT_CONFIRM_HINTS = (
|
||||
"是",
|
||||
"好",
|
||||
"对",
|
||||
"确认",
|
||||
"新增",
|
||||
"加",
|
||||
"嗯",
|
||||
"yes",
|
||||
"ok",
|
||||
"好的",
|
||||
"是的",
|
||||
"行",
|
||||
"可以",
|
||||
"没问题",
|
||||
)
|
||||
INTENT_LLM_TIMEOUT_SECONDS = 8.0
|
||||
INTENT_LLM_FUNCTION_NAME = "classify_intent_ai_pmo_kernel"
|
||||
INTENT_CANDIDATES = (
|
||||
INTENT_SIGNAL_SOURCE_ONBOARD,
|
||||
INTENT_SIGNAL_CREATE,
|
||||
INTENT_QUERY,
|
||||
INTENT_PROJECT_CREATE,
|
||||
INTENT_FOLLOWUP_CONFIRM,
|
||||
INTENT_FOLLOWUP_CONTINUE,
|
||||
INTENT_IGNORE,
|
||||
)
|
||||
|
||||
WORLD_MODEL_STATIC_CACHE_TTL_SECONDS = 10 * 60
|
||||
WORLD_MODEL_STATIC_CACHE_KEY_PREFIX = "wm:static"
|
||||
|
|
@ -152,6 +187,13 @@ def _utc_now_naive() -> datetime:
|
|||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool = False) -> bool:
|
||||
raw = os.getenv(name)
|
||||
if raw is None:
|
||||
return default
|
||||
return str(raw).strip().lower() in BOOL_TRUE_VALUES
|
||||
|
||||
|
||||
def _safe_int(value: Any) -> Optional[int]:
|
||||
try:
|
||||
if value is None or value == "":
|
||||
|
|
@ -1004,6 +1046,7 @@ class AIPMOKernelService:
|
|||
source_message_id=synthetic_message_id,
|
||||
owner_user_id=_safe_int(getattr(operator, "id", None)),
|
||||
)
|
||||
session = await self._get_or_create_session(tenant_id=self.tenant_id, sender=sender)
|
||||
|
||||
try:
|
||||
world_model_query_tables: set[str] = set()
|
||||
|
|
@ -1011,6 +1054,7 @@ class AIPMOKernelService:
|
|||
message_text=clean_text,
|
||||
project_hint_id=project_hint_id,
|
||||
query_tables=world_model_query_tables,
|
||||
session=session,
|
||||
)
|
||||
world_model = await self.build_world_model(
|
||||
tenant_id=self.tenant_id,
|
||||
|
|
@ -1021,8 +1065,16 @@ class AIPMOKernelService:
|
|||
)
|
||||
self._schedule_portfolio_snapshot_persist(world_model=world_model)
|
||||
work_item.state = STATE_MODELED
|
||||
intent, intent_context_source = await self._resolve_intent(
|
||||
message_text=clean_text,
|
||||
session=session,
|
||||
world_model=world_model,
|
||||
)
|
||||
if intent_context_source:
|
||||
world_model["context_source"] = intent_context_source
|
||||
work_item.context_snapshot = world_model
|
||||
intent = self.intent_resolver.resolve(
|
||||
resolved_project_for_session = _safe_int(intent.project_id) or _safe_int(world_model.get("resolved_project_id"))
|
||||
fallback_intent = self.intent_resolver.resolve(
|
||||
text=clean_text,
|
||||
project_id=_safe_int(world_model.get("resolved_project_id")),
|
||||
)
|
||||
|
|
@ -1041,6 +1093,13 @@ class AIPMOKernelService:
|
|||
processing_status="success",
|
||||
duration_ms=int((time.perf_counter() - started) * 1000),
|
||||
)
|
||||
await self._update_session(
|
||||
tenant_id=self.tenant_id,
|
||||
sender=sender,
|
||||
current_project_id=resolved_project_for_session,
|
||||
last_intent=intent.name,
|
||||
last_message_text=clean_text[:200],
|
||||
)
|
||||
await self.db.commit()
|
||||
return {
|
||||
"handled": True,
|
||||
|
|
@ -1138,6 +1197,13 @@ class AIPMOKernelService:
|
|||
processing_status="success",
|
||||
duration_ms=int((time.perf_counter() - started) * 1000),
|
||||
)
|
||||
await self._update_session(
|
||||
tenant_id=self.tenant_id,
|
||||
sender=sender,
|
||||
current_project_id=_safe_int(work_item.project_id),
|
||||
last_intent=intent.name,
|
||||
last_message_text=clean_text[:200],
|
||||
)
|
||||
await self.db.commit()
|
||||
return self._build_capture_result(
|
||||
work_item=work_item,
|
||||
|
|
@ -1235,6 +1301,15 @@ class AIPMOKernelService:
|
|||
processing_status="success" if work_item.state in {STATE_SUCCEEDED, STATE_WAITING_MANUAL} else "failed",
|
||||
duration_ms=int((time.perf_counter() - started) * 1000),
|
||||
)
|
||||
final_project_id = _safe_int(work_item.project_id) or resolved_project_for_session
|
||||
final_intent_name = str(work_item.intent or fallback_intent.name or "").strip() or intent.name
|
||||
await self._update_session(
|
||||
tenant_id=self.tenant_id,
|
||||
sender=sender,
|
||||
current_project_id=final_project_id,
|
||||
last_intent=final_intent_name,
|
||||
last_message_text=clean_text[:200],
|
||||
)
|
||||
await self.db.commit()
|
||||
return self._build_capture_result(
|
||||
work_item=work_item,
|
||||
|
|
@ -1558,12 +1633,14 @@ class AIPMOKernelService:
|
|||
message_text: str,
|
||||
sender: str,
|
||||
project_hint_id: Optional[int],
|
||||
session: Optional[ConversationSession] = None,
|
||||
) -> WorldModel:
|
||||
query_tables: set[str] = set()
|
||||
project = await self._resolve_project(
|
||||
message_text=message_text,
|
||||
project_hint_id=project_hint_id,
|
||||
query_tables=query_tables,
|
||||
session=session,
|
||||
)
|
||||
return await self.build_world_model(
|
||||
tenant_id=self.tenant_id,
|
||||
|
|
@ -1584,6 +1661,7 @@ class AIPMOKernelService:
|
|||
message_text: str,
|
||||
project_hint_id: Optional[int],
|
||||
query_tables: Optional[set[str]] = None,
|
||||
session: Optional[ConversationSession] = None,
|
||||
) -> Optional[Project]:
|
||||
if project_hint_id is not None:
|
||||
self._record_world_model_query_table(query_tables, "projects")
|
||||
|
|
@ -1625,8 +1703,313 @@ class AIPMOKernelService:
|
|||
normalized_candidate = re.sub(r"\s+", "", str(candidate or "")).lower()
|
||||
if normalized_candidate and normalized_candidate in normalized_message:
|
||||
return project
|
||||
inherited_project_id = self._extract_inherited_project_id(session=session)
|
||||
if inherited_project_id is not None:
|
||||
self._record_world_model_query_table(query_tables, "projects")
|
||||
return (
|
||||
await self.db.execute(
|
||||
select(Project).where(
|
||||
and_(
|
||||
Project.tenant_id == self.tenant_id,
|
||||
Project.id == int(inherited_project_id),
|
||||
)
|
||||
).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _within_recent_window(last_active_at: Optional[datetime], *, window_seconds: int) -> bool:
|
||||
if not isinstance(last_active_at, datetime):
|
||||
return False
|
||||
now = _utc_now_naive()
|
||||
delta = now - last_active_at
|
||||
return 0 <= delta.total_seconds() <= float(window_seconds)
|
||||
|
||||
def _extract_inherited_project_id(self, *, session: Optional[ConversationSession]) -> Optional[int]:
|
||||
if session is None:
|
||||
return None
|
||||
session_project_id = _safe_int(getattr(session, "current_project_id", None))
|
||||
if session_project_id is None or int(session_project_id) <= 0:
|
||||
return None
|
||||
if not self._within_recent_window(
|
||||
getattr(session, "last_active_at", None),
|
||||
window_seconds=SESSION_PROJECT_INHERIT_WINDOW_SECONDS,
|
||||
):
|
||||
return None
|
||||
return session_project_id
|
||||
|
||||
async def _get_or_create_session(self, *, tenant_id: int, sender: str) -> ConversationSession:
|
||||
sender_key = str(sender or "").strip()[:128] or "unknown"
|
||||
existing = (
|
||||
await self.db.execute(
|
||||
select(ConversationSession).where(
|
||||
and_(
|
||||
ConversationSession.tenant_id == int(tenant_id),
|
||||
ConversationSession.sender == sender_key,
|
||||
)
|
||||
).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
created = ConversationSession(
|
||||
tenant_id=int(tenant_id),
|
||||
sender=sender_key,
|
||||
current_project_id=None,
|
||||
last_intent=None,
|
||||
last_message_text=None,
|
||||
last_active_at=_utc_now_naive(),
|
||||
)
|
||||
nested_tx = await self.db.begin_nested()
|
||||
try:
|
||||
self.db.add(created)
|
||||
await self.db.flush()
|
||||
await nested_tx.commit()
|
||||
return created
|
||||
except IntegrityError:
|
||||
await nested_tx.rollback()
|
||||
existing = (
|
||||
await self.db.execute(
|
||||
select(ConversationSession).where(
|
||||
and_(
|
||||
ConversationSession.tenant_id == int(tenant_id),
|
||||
ConversationSession.sender == sender_key,
|
||||
)
|
||||
).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return existing
|
||||
raise
|
||||
|
||||
async def _update_session(
|
||||
self,
|
||||
*,
|
||||
tenant_id: int,
|
||||
sender: str,
|
||||
current_project_id: Optional[int],
|
||||
last_intent: Optional[str],
|
||||
last_message_text: Optional[str],
|
||||
) -> None:
|
||||
row = await self._get_or_create_session(tenant_id=tenant_id, sender=sender)
|
||||
if current_project_id is not None:
|
||||
row.current_project_id = int(current_project_id)
|
||||
row.last_intent = (str(last_intent or "").strip()[:64] or None)
|
||||
trimmed_message = str(last_message_text or "").strip()
|
||||
row.last_message_text = trimmed_message[:200] if trimmed_message else None
|
||||
row.last_active_at = _utc_now_naive()
|
||||
await self.db.flush()
|
||||
|
||||
@staticmethod
|
||||
def _intent_llm_enabled() -> bool:
|
||||
return _env_bool("AI_PMO_INTENT_LLM_ENABLED", True)
|
||||
|
||||
@classmethod
|
||||
def _is_short_confirmation_message(
|
||||
cls,
|
||||
*,
|
||||
message_text: str,
|
||||
session: Optional[ConversationSession],
|
||||
) -> bool:
|
||||
if session is None:
|
||||
return False
|
||||
if not cls._within_recent_window(
|
||||
getattr(session, "last_active_at", None),
|
||||
window_seconds=SESSION_SHORT_CONFIRM_WINDOW_SECONDS,
|
||||
):
|
||||
return False
|
||||
last_intent = str(getattr(session, "last_intent", "") or "").strip()
|
||||
if not last_intent:
|
||||
return False
|
||||
normalized = re.sub(r"\s+", "", str(message_text or "")).lower()
|
||||
if not normalized:
|
||||
return False
|
||||
if len(normalized) > SHORT_CONFIRM_MAX_MESSAGE_LENGTH:
|
||||
return False
|
||||
return any(token in normalized for token in SHORT_CONFIRM_HINTS)
|
||||
|
||||
@staticmethod
|
||||
def _build_canonical_intent(
|
||||
*,
|
||||
intent_name: str,
|
||||
message_text: str,
|
||||
project_id: Optional[int],
|
||||
confidence: float,
|
||||
metadata: Optional[dict[str, Any]] = None,
|
||||
) -> CanonicalIntent:
|
||||
pretty = re.sub(r"\s+", " ", str(message_text or "")).strip()
|
||||
extras = dict(metadata or {})
|
||||
safe_confidence = max(0.0, min(1.0, float(confidence or 0.0)))
|
||||
if intent_name == INTENT_SIGNAL_SOURCE_ONBOARD:
|
||||
extras.setdefault("source_request", True)
|
||||
return CanonicalIntent(
|
||||
INTENT_SIGNAL_SOURCE_ONBOARD,
|
||||
safe_confidence,
|
||||
project_id,
|
||||
IntentResolver._build_signal_title(pretty, default="新增信号源接入请求"),
|
||||
pretty[:300],
|
||||
extras,
|
||||
)
|
||||
if intent_name == INTENT_SIGNAL_CREATE:
|
||||
return CanonicalIntent(
|
||||
INTENT_SIGNAL_CREATE,
|
||||
safe_confidence,
|
||||
project_id,
|
||||
IntentResolver._build_signal_title(pretty, default="新信号"),
|
||||
pretty[:300],
|
||||
extras,
|
||||
)
|
||||
if intent_name == INTENT_PROJECT_CREATE:
|
||||
return CanonicalIntent(
|
||||
INTENT_PROJECT_CREATE,
|
||||
safe_confidence,
|
||||
project_id,
|
||||
IntentResolver._build_signal_title(pretty, default="新项目创建请求"),
|
||||
pretty[:300],
|
||||
extras,
|
||||
)
|
||||
if intent_name == INTENT_QUERY:
|
||||
return CanonicalIntent(INTENT_QUERY, safe_confidence, project_id, "", pretty[:300], extras)
|
||||
if intent_name == INTENT_FOLLOWUP_CONFIRM:
|
||||
return CanonicalIntent(INTENT_FOLLOWUP_CONFIRM, safe_confidence, project_id, "", "", extras)
|
||||
if intent_name == INTENT_FOLLOWUP_CONTINUE:
|
||||
return CanonicalIntent(INTENT_FOLLOWUP_CONTINUE, safe_confidence, project_id, "", "", extras)
|
||||
return CanonicalIntent(INTENT_IGNORE, safe_confidence, project_id, "", "", extras or {"reason": "unsupported_intent"})
|
||||
|
||||
def _build_short_confirm_intent(
|
||||
self,
|
||||
*,
|
||||
message_text: str,
|
||||
session: ConversationSession,
|
||||
world_model: dict[str, Any],
|
||||
) -> CanonicalIntent:
|
||||
inherited_name = str(getattr(session, "last_intent", "") or "").strip()
|
||||
if inherited_name not in INTENT_CANDIDATES:
|
||||
inherited_name = INTENT_QUERY
|
||||
inherited_project_id = _safe_int(getattr(session, "current_project_id", None)) or _safe_int(world_model.get("resolved_project_id"))
|
||||
if inherited_project_id is not None and int(inherited_project_id) <= 0:
|
||||
inherited_project_id = None
|
||||
inherited_text = str(getattr(session, "last_message_text", "") or "").strip() or message_text
|
||||
return self._build_canonical_intent(
|
||||
intent_name=inherited_name,
|
||||
message_text=inherited_text,
|
||||
project_id=inherited_project_id,
|
||||
confidence=0.86,
|
||||
metadata={
|
||||
"context_source": "session_inherited",
|
||||
"inherited_from_intent": str(getattr(session, "last_intent", "") or "").strip(),
|
||||
},
|
||||
)
|
||||
|
||||
async def _resolve_intent_with_llm(
|
||||
self,
|
||||
*,
|
||||
message_text: str,
|
||||
session: Optional[ConversationSession],
|
||||
world_model: dict[str, Any],
|
||||
) -> Optional[CanonicalIntent]:
|
||||
prompt = PROMPT_AI_PMO_INTENT_CLASSIFICATION.format(
|
||||
message_text=str(message_text or "")[:1200],
|
||||
session_last_intent=str(getattr(session, "last_intent", "") or "").strip() if session is not None else "",
|
||||
session_project_id=_safe_int(getattr(session, "current_project_id", None)) if session is not None else None,
|
||||
world_model_project_id=_safe_int(world_model.get("resolved_project_id")),
|
||||
candidate_intents_json=json.dumps(list(INTENT_CANDIDATES), ensure_ascii=False),
|
||||
).strip()
|
||||
try:
|
||||
extraction = await asyncio.wait_for(
|
||||
llm_service.generate_structured_object(
|
||||
prompt,
|
||||
db=self.db,
|
||||
tenant_id=self.tenant_id,
|
||||
function_name=INTENT_LLM_FUNCTION_NAME,
|
||||
use_prompt_profile=False,
|
||||
temperature=0.0,
|
||||
max_tokens=260,
|
||||
response_mode="fast",
|
||||
),
|
||||
timeout=INTENT_LLM_TIMEOUT_SECONDS,
|
||||
)
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
"ai_pmo_intent_llm_timeout",
|
||||
tenant_id=self.tenant_id,
|
||||
timeout_seconds=INTENT_LLM_TIMEOUT_SECONDS,
|
||||
)
|
||||
return None
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"ai_pmo_intent_llm_failed",
|
||||
tenant_id=self.tenant_id,
|
||||
error=str(exc),
|
||||
)
|
||||
return None
|
||||
|
||||
payload = extraction.payload if extraction and isinstance(extraction.payload, dict) else None
|
||||
if payload is None:
|
||||
logger.warning(
|
||||
"ai_pmo_intent_llm_empty_payload",
|
||||
tenant_id=self.tenant_id,
|
||||
error_code=str(getattr(extraction, "error_code", "") or ""),
|
||||
provider=str(getattr(extraction, "provider", "") or ""),
|
||||
model=str(getattr(extraction, "model", "") or ""),
|
||||
)
|
||||
return None
|
||||
|
||||
intent_name = str(payload.get("intent") or "").strip()
|
||||
if intent_name not in INTENT_CANDIDATES:
|
||||
return None
|
||||
confidence = float(payload.get("confidence") or 0.0)
|
||||
project_id = _safe_int(payload.get("project_id"))
|
||||
if project_id is not None and int(project_id) <= 0:
|
||||
project_id = None
|
||||
if project_id is None:
|
||||
project_id = _safe_int(world_model.get("resolved_project_id"))
|
||||
reasoning = str(payload.get("reasoning") or "").strip()
|
||||
return self._build_canonical_intent(
|
||||
intent_name=intent_name,
|
||||
message_text=message_text,
|
||||
project_id=project_id,
|
||||
confidence=confidence,
|
||||
metadata={
|
||||
"context_source": "llm",
|
||||
"reasoning": reasoning[:300],
|
||||
"llm_function": INTENT_LLM_FUNCTION_NAME,
|
||||
},
|
||||
)
|
||||
|
||||
async def _resolve_intent(
|
||||
self,
|
||||
*,
|
||||
message_text: str,
|
||||
session: Optional[ConversationSession],
|
||||
world_model: dict[str, Any],
|
||||
) -> tuple[CanonicalIntent, str]:
|
||||
project_id = _safe_int(world_model.get("resolved_project_id"))
|
||||
if self._is_short_confirmation_message(message_text=message_text, session=session):
|
||||
if session is None:
|
||||
return self.intent_resolver.resolve(text=message_text, project_id=project_id), "rule_fallback"
|
||||
return (
|
||||
self._build_short_confirm_intent(
|
||||
message_text=message_text,
|
||||
session=session,
|
||||
world_model=world_model,
|
||||
),
|
||||
"session_inherited",
|
||||
)
|
||||
|
||||
if self._intent_llm_enabled():
|
||||
llm_intent = await self._resolve_intent_with_llm(
|
||||
message_text=message_text,
|
||||
session=session,
|
||||
world_model=world_model,
|
||||
)
|
||||
if llm_intent is not None:
|
||||
return llm_intent, "llm"
|
||||
|
||||
return self.intent_resolver.resolve(text=message_text, project_id=project_id), "rule_fallback"
|
||||
|
||||
async def _build_portfolio_summary(
|
||||
self,
|
||||
*,
|
||||
|
|
|
|||
|
|
@ -215,6 +215,17 @@ STRUCTURED_OBJECT_SCHEMAS: dict[str, dict[str, Any]] = {
|
|||
"next_actions": "list",
|
||||
},
|
||||
},
|
||||
"classify_intent_ai_pmo_kernel": {
|
||||
"required_keys": ("intent", "confidence", "reasoning"),
|
||||
"type_checks": {
|
||||
"intent": "str",
|
||||
"confidence": "number",
|
||||
"reasoning": "str",
|
||||
},
|
||||
"optional_type_checks": {
|
||||
"project_id": ("number_or_none",),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1934,6 +1945,8 @@ class LLMService:
|
|||
return isinstance(value, list)
|
||||
if expected == "number":
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
if expected == "number_or_none":
|
||||
return value is None or (isinstance(value, (int, float)) and not isinstance(value, bool))
|
||||
if expected == "dict_or_none":
|
||||
return value is None or isinstance(value, dict)
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ from .prompt_profile_service import prompt_profile_service
|
|||
from .llm import llm_service
|
||||
|
||||
SOURCE_ONBOARDING_ACTION_RE = re.compile(
|
||||
r"(新增|增加|添加|接入|配置|注册|建立).{0,8}(信号源|来源|监控源|channel|source|webhook|飞书|github|url|rss)",
|
||||
r"(新增|增加|添加|接入|配置|注册|建立|加个|加一个|加).{0,8}(信号源|来源|监控源|channel|source|webhook|飞书|github|url|rss)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
SOURCE_KIND_HINTS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
|
|
@ -66,9 +66,12 @@ class SignalSourceOrchestrationService:
|
|||
@classmethod
|
||||
def looks_like_signal_source_request(cls, text: str) -> bool:
|
||||
normalized = re.sub(r"\s+", "", str(text or ""))
|
||||
if len(normalized) < 6:
|
||||
if len(normalized) < 3:
|
||||
return False
|
||||
if "信号源" in normalized and any(token in normalized for token in ("新增", "增加", "添加", "接入", "配置", "注册", "建立")):
|
||||
if "信号源" in normalized and any(
|
||||
token in normalized
|
||||
for token in ("新增", "增加", "添加", "接入", "配置", "注册", "建立", "加个", "加一个", "加")
|
||||
):
|
||||
return True
|
||||
return bool(SOURCE_ONBOARDING_ACTION_RE.search(normalized))
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
#!/usr/bin/env python3
|
||||
"""回填 signals.matched_project_id 对应的 project_signal_links。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
|
||||
from sqlalchemy import and_, select
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models import ProjectSignalLink, Signal
|
||||
|
||||
|
||||
async def _query_orphans(*, tenant_id: int, signal_id: int | None) -> list[tuple[int, int, int, str]]:
|
||||
async with AsyncSessionLocal() as db:
|
||||
stmt = (
|
||||
select(
|
||||
Signal.id,
|
||||
Signal.tenant_id,
|
||||
Signal.matched_project_id,
|
||||
Signal.title,
|
||||
)
|
||||
.outerjoin(
|
||||
ProjectSignalLink,
|
||||
and_(
|
||||
ProjectSignalLink.tenant_id == Signal.tenant_id,
|
||||
ProjectSignalLink.signal_id == Signal.id,
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
Signal.tenant_id == int(tenant_id),
|
||||
Signal.matched_project_id.is_not(None),
|
||||
ProjectSignalLink.id.is_(None),
|
||||
)
|
||||
)
|
||||
.order_by(Signal.id.asc())
|
||||
)
|
||||
if signal_id is not None:
|
||||
stmt = stmt.where(Signal.id == int(signal_id))
|
||||
rows = (await db.execute(stmt)).all()
|
||||
return [
|
||||
(
|
||||
int(row[0]),
|
||||
int(row[1]),
|
||||
int(row[2]),
|
||||
str(row[3] or ""),
|
||||
)
|
||||
for row in rows
|
||||
if row[0] is not None and row[1] is not None and row[2] is not None
|
||||
]
|
||||
|
||||
|
||||
async def _apply_backfill(*, tenant_id: int, signal_id: int | None) -> int:
|
||||
rows = await _query_orphans(tenant_id=tenant_id, signal_id=signal_id)
|
||||
if not rows:
|
||||
return 0
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
for sid, tid, pid, _ in rows:
|
||||
db.add(
|
||||
ProjectSignalLink(
|
||||
tenant_id=tid,
|
||||
signal_id=sid,
|
||||
project_id=pid,
|
||||
link_type="matched_project",
|
||||
relevance_score=1.0,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return len(rows)
|
||||
|
||||
|
||||
async def _main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Backfill orphan project_signal_links from signals.matched_project_id")
|
||||
parser.add_argument("--tenant-id", type=int, default=1, help="目标租户 ID,默认 1")
|
||||
parser.add_argument("--signal-id", type=int, default=None, help="仅修复指定 signal_id")
|
||||
parser.add_argument("--apply", action="store_true", help="执行写入;不传时仅预览")
|
||||
args = parser.parse_args()
|
||||
|
||||
rows = await _query_orphans(tenant_id=args.tenant_id, signal_id=args.signal_id)
|
||||
print(f"[preview] tenant_id={args.tenant_id} orphan_count={len(rows)}")
|
||||
for sid, _, pid, title in rows[:20]:
|
||||
print(f"- signal_id={sid} -> project_id={pid} title={title[:80]}")
|
||||
if len(rows) > 20:
|
||||
print(f"... ({len(rows) - 20} more)")
|
||||
|
||||
if not args.apply:
|
||||
print("[dry-run] 未执行写入。加 --apply 以回填。")
|
||||
return
|
||||
|
||||
inserted = await _apply_backfill(tenant_id=args.tenant_id, signal_id=args.signal_id)
|
||||
remaining = await _query_orphans(tenant_id=args.tenant_id, signal_id=args.signal_id)
|
||||
print(f"[apply] inserted={inserted} remaining_orphans={len(remaining)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(_main())
|
||||
|
|
@ -7,7 +7,21 @@ from unittest.mock import AsyncMock, patch
|
|||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.models import Base, Budget, PortfolioSnapshot, Project, ProjectDomain, Task, TruthNode, User, UserProject, UserSkillBinding, UserWorkload, WorkItem
|
||||
from app.models import (
|
||||
Base,
|
||||
Budget,
|
||||
ConversationSession,
|
||||
PortfolioSnapshot,
|
||||
Project,
|
||||
ProjectDomain,
|
||||
Task,
|
||||
TruthNode,
|
||||
User,
|
||||
UserProject,
|
||||
UserSkillBinding,
|
||||
UserWorkload,
|
||||
WorkItem,
|
||||
)
|
||||
from app.services.ai_pmo_cadence_service import AIPMOCadenceService
|
||||
from app.services.ai_pmo_kernel_service import (
|
||||
AIPMOKernelService,
|
||||
|
|
@ -15,11 +29,14 @@ from app.services.ai_pmo_kernel_service import (
|
|||
IntentResolver,
|
||||
)
|
||||
from app.services.ai_pmo_domain_workflow import SignalSourceDomainWorkflow
|
||||
from app.services.llm import StructuredExtractionResult
|
||||
from app.services.signal_source_orchestration_service import SignalSourceOrchestrationService
|
||||
|
||||
|
||||
class AIPMOKernelServiceTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self) -> None:
|
||||
self._old_intent_llm_enabled = os.environ.get("AI_PMO_INTENT_LLM_ENABLED")
|
||||
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = "0"
|
||||
tmp = NamedTemporaryFile(prefix="pmflow-ai-pmo-test-", suffix=".db", delete=False)
|
||||
tmp.close()
|
||||
self.db_path = tmp.name
|
||||
|
|
@ -149,6 +166,10 @@ class AIPMOKernelServiceTest(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
async def asyncTearDown(self) -> None:
|
||||
await self.engine.dispose()
|
||||
if self._old_intent_llm_enabled is None:
|
||||
os.environ.pop("AI_PMO_INTENT_LLM_ENABLED", None)
|
||||
else:
|
||||
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = self._old_intent_llm_enabled
|
||||
if getattr(self, "db_path", None):
|
||||
try:
|
||||
os.remove(self.db_path)
|
||||
|
|
@ -254,6 +275,187 @@ class AIPMOKernelServiceTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(second["status"], "ignored")
|
||||
self.assertFalse(second["direct_reply_required"])
|
||||
|
||||
async def test_short_confirmation_inherits_last_intent_and_project(self) -> None:
|
||||
async with self.session_factory() as db:
|
||||
service = AIPMOKernelService(db, tenant_id=1)
|
||||
await service.process_ingress(
|
||||
source_system="feishu",
|
||||
source_message_id="ctx-source-1",
|
||||
message_text="给项目ID: 1 新增一个 GitHub 信号源,监控 openai/codex。",
|
||||
sender="老板",
|
||||
channel_ref="oc_ctx",
|
||||
source_sender_open_id="ou_boss",
|
||||
project_hint_id=1,
|
||||
)
|
||||
second = await service.process_ingress(
|
||||
source_system="feishu",
|
||||
source_message_id="ctx-source-2",
|
||||
message_text="是的新增吧",
|
||||
sender="老板",
|
||||
channel_ref="oc_ctx",
|
||||
source_sender_open_id="ou_boss",
|
||||
)
|
||||
second_item = (
|
||||
await db.execute(
|
||||
select(WorkItem).where(
|
||||
and_(
|
||||
WorkItem.tenant_id == 1,
|
||||
WorkItem.source_message_id == "ctx-source-2",
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
session_row = (
|
||||
await db.execute(
|
||||
select(ConversationSession).where(
|
||||
and_(
|
||||
ConversationSession.tenant_id == 1,
|
||||
ConversationSession.sender == "老板",
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
self.assertEqual(second["status"], "captured")
|
||||
self.assertEqual(second_item.intent, INTENT_SIGNAL_SOURCE_ONBOARD)
|
||||
self.assertEqual(int(second_item.project_id or 0), 1)
|
||||
self.assertEqual((second_item.context_snapshot or {}).get("context_source"), "session_inherited")
|
||||
self.assertEqual(session_row.last_intent, INTENT_SIGNAL_SOURCE_ONBOARD)
|
||||
self.assertEqual(int(session_row.current_project_id or 0), 1)
|
||||
|
||||
async def test_project_id_inherits_from_recent_session_when_message_has_no_project_text(self) -> None:
|
||||
async with self.session_factory() as db:
|
||||
service = AIPMOKernelService(db, tenant_id=1)
|
||||
await service.process_ingress(
|
||||
source_system="pmflow_manual",
|
||||
source_message_id="project-inherit-1",
|
||||
message_text="给项目ID: 1 新增一条信号:跟踪江苏医保支付改革。",
|
||||
sender="老板",
|
||||
channel_ref="pmflow",
|
||||
project_hint_id=1,
|
||||
)
|
||||
second = await service.process_ingress(
|
||||
source_system="pmflow_manual",
|
||||
source_message_id="project-inherit-2",
|
||||
message_text="新增一条信号:跟踪医药招采节奏变化。",
|
||||
sender="老板",
|
||||
channel_ref="pmflow",
|
||||
)
|
||||
second_item = (
|
||||
await db.execute(
|
||||
select(WorkItem).where(
|
||||
and_(
|
||||
WorkItem.tenant_id == 1,
|
||||
WorkItem.source_message_id == "project-inherit-2",
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
self.assertTrue(second["handled"])
|
||||
self.assertEqual(second_item.intent, "signal.create")
|
||||
self.assertEqual(int(second_item.project_id or 0), 1)
|
||||
self.assertEqual(second["linked_project_ids"], [1])
|
||||
|
||||
async def test_intent_llm_primary_path_marks_context_source(self) -> None:
|
||||
prev = os.environ.get("AI_PMO_INTENT_LLM_ENABLED")
|
||||
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = "1"
|
||||
try:
|
||||
async with self.session_factory() as db:
|
||||
service = AIPMOKernelService(db, tenant_id=1)
|
||||
with patch(
|
||||
"app.services.ai_pmo_kernel_service.llm_service.generate_structured_object",
|
||||
AsyncMock(
|
||||
return_value=StructuredExtractionResult(
|
||||
payload={
|
||||
"intent": "signal_source.onboard",
|
||||
"confidence": 0.95,
|
||||
"project_id": 1,
|
||||
"reasoning": "用户要新增信号源",
|
||||
},
|
||||
source_field="content",
|
||||
provider="openai",
|
||||
model="zai/glm-4.7",
|
||||
raw_text='{"intent":"signal_source.onboard"}',
|
||||
validation="ok",
|
||||
)
|
||||
),
|
||||
):
|
||||
result = await service.process_ingress(
|
||||
source_system="pmflow_manual",
|
||||
source_message_id="llm-intent-1",
|
||||
message_text="这个项目每天看竞品动态,帮我接入一下。",
|
||||
sender="老板",
|
||||
channel_ref="pmflow",
|
||||
project_hint_id=1,
|
||||
)
|
||||
item = (
|
||||
await db.execute(
|
||||
select(WorkItem).where(
|
||||
and_(
|
||||
WorkItem.tenant_id == 1,
|
||||
WorkItem.source_message_id == "llm-intent-1",
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
self.assertTrue(result["handled"])
|
||||
self.assertEqual(item.intent, INTENT_SIGNAL_SOURCE_ONBOARD)
|
||||
self.assertEqual((item.context_snapshot or {}).get("context_source"), "llm")
|
||||
finally:
|
||||
if prev is None:
|
||||
os.environ.pop("AI_PMO_INTENT_LLM_ENABLED", None)
|
||||
else:
|
||||
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = prev
|
||||
|
||||
async def test_intent_llm_failure_falls_back_to_rule_resolver(self) -> None:
|
||||
prev = os.environ.get("AI_PMO_INTENT_LLM_ENABLED")
|
||||
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = "1"
|
||||
try:
|
||||
async with self.session_factory() as db:
|
||||
service = AIPMOKernelService(db, tenant_id=1)
|
||||
with patch(
|
||||
"app.services.ai_pmo_kernel_service.llm_service.generate_structured_object",
|
||||
AsyncMock(
|
||||
return_value=StructuredExtractionResult(
|
||||
payload=None,
|
||||
source_field="",
|
||||
provider="openai",
|
||||
model="zai/glm-4.7",
|
||||
error_code="model_not_allowed",
|
||||
raw_text="403 ModelNotAllowed",
|
||||
validation="invalid",
|
||||
)
|
||||
),
|
||||
):
|
||||
await service.process_ingress(
|
||||
source_system="pmflow_manual",
|
||||
source_message_id="llm-intent-fallback-1",
|
||||
message_text="给项目ID: 1 新增一个 GitHub 信号源,监控 openai/codex。",
|
||||
sender="老板",
|
||||
channel_ref="pmflow",
|
||||
project_hint_id=1,
|
||||
)
|
||||
item = (
|
||||
await db.execute(
|
||||
select(WorkItem).where(
|
||||
and_(
|
||||
WorkItem.tenant_id == 1,
|
||||
WorkItem.source_message_id == "llm-intent-fallback-1",
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
self.assertEqual(item.intent, INTENT_SIGNAL_SOURCE_ONBOARD)
|
||||
self.assertEqual((item.context_snapshot or {}).get("context_source"), "rule_fallback")
|
||||
finally:
|
||||
if prev is None:
|
||||
os.environ.pop("AI_PMO_INTENT_LLM_ENABLED", None)
|
||||
else:
|
||||
os.environ["AI_PMO_INTENT_LLM_ENABLED"] = prev
|
||||
|
||||
async def test_policy_gate_blocks_archive_like_request(self) -> None:
|
||||
async with self.session_factory() as db:
|
||||
service = AIPMOKernelService(db, tenant_id=1)
|
||||
|
|
|
|||
|
|
@ -118,6 +118,11 @@ class SignalSourceOrchestrationServiceTest(unittest.TestCase):
|
|||
"请新增一个 GitHub 信号源,监控核心仓库 issue。"
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
SignalSourceOrchestrationService.looks_like_signal_source_request(
|
||||
"给这个项目加个信号源。"
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
SignalSourceOrchestrationService.looks_like_signal_source_request(
|
||||
"把任务 12 分给张三。"
|
||||
|
|
|
|||
|
|
@ -1,14 +1,30 @@
|
|||
import { Outlet } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Outlet, useLocation } from "react-router-dom";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { TopBar } from "./TopBar";
|
||||
|
||||
export default function Layout() {
|
||||
const location = useLocation();
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMobileSidebarOpen(false);
|
||||
}, [location.pathname, location.search]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-[#F3F5F7]">
|
||||
<Sidebar />
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<TopBar />
|
||||
<main className="flex-1 overflow-auto bg-[#F3F5F7] p-6">
|
||||
<div className="relative flex h-screen overflow-hidden bg-[#F3F5F7]">
|
||||
<Sidebar open={mobileSidebarOpen} onClose={() => setMobileSidebarOpen(false)} />
|
||||
{mobileSidebarOpen && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭菜单遮罩"
|
||||
className="fixed inset-0 z-40 bg-slate-900/45 md:hidden"
|
||||
onClick={() => setMobileSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<TopBar onMenuToggle={() => setMobileSidebarOpen((open) => !open)} />
|
||||
<main className="flex-1 overflow-auto bg-[#F3F5F7] p-3 sm:p-4 md:p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
Users,
|
||||
Sparkles,
|
||||
Sliders,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isManagerRole } from "@/lib/rbac";
|
||||
|
|
@ -25,7 +26,12 @@ const baseNavItems = [
|
|||
{ to: "/skills", label: "能力管理", icon: BarChart3 },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
interface SidebarProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ open, onClose }: SidebarProps) {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isManager = isManagerRole(user?.role);
|
||||
|
|
@ -34,21 +40,37 @@ export function Sidebar() {
|
|||
: baseNavItems;
|
||||
|
||||
return (
|
||||
<aside className="flex w-72 flex-col border-r border-slate-200 bg-[#F8F9FA] px-4 py-4">
|
||||
<aside
|
||||
className={cn(
|
||||
"fixed inset-y-0 left-0 z-50 flex w-[min(18rem,85vw)] flex-col border-r border-slate-200 bg-[#F8F9FA] px-4 py-4 transition-transform duration-200 ease-out md:static md:z-auto md:w-72 md:translate-x-0",
|
||||
open ? "translate-x-0" : "-translate-x-full"
|
||||
)}
|
||||
>
|
||||
<div className="flex h-16 items-center gap-2 rounded-2xl bg-white px-4 shadow-[0_8px_22px_rgba(15,23,42,0.08)]">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-blue-600 text-sm font-bold text-white">
|
||||
PM
|
||||
</div>
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<span className="text-base font-bold tracking-tight text-slate-900">PMFlow</span>
|
||||
<p className="text-[11px] text-slate-500">AI 原生项目操作台</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-auto md:hidden"
|
||||
onClick={onClose}
|
||||
aria-label="关闭侧边栏"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<nav className="mt-4 flex-1 space-y-1">
|
||||
{navItems.map(({ to, label, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
onClick={onClose}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex items-center gap-3 rounded-2xl px-4 py-3 text-sm font-medium transition-all",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Bell, CheckCircle2, LoaderCircle, LogOut, ShieldAlert, User } from "lucide-react";
|
||||
import { Bell, CheckCircle2, LoaderCircle, LogOut, Menu, ShieldAlert, User } from "lucide-react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { aiPmoApi, type AIPMOWorkItemListPayload } from "@/api/aiPmo";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
|
@ -49,6 +49,10 @@ const SECTION_CHROME: Record<InboxDisplayKind, string> = {
|
|||
info: "bg-slate-100 text-slate-600",
|
||||
};
|
||||
|
||||
interface TopBarProps {
|
||||
onMenuToggle: () => void;
|
||||
}
|
||||
|
||||
function formatRelativeTime(value?: string | null) {
|
||||
if (!value) return "-";
|
||||
const date = parseBackendDate(value);
|
||||
|
|
@ -63,7 +67,7 @@ function formatRelativeTime(value?: string | null) {
|
|||
return `${Math.floor(diffMs / day)} 天前`;
|
||||
}
|
||||
|
||||
export function TopBar() {
|
||||
export function TopBar({ onMenuToggle }: TopBarProps) {
|
||||
const { user, logout } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
|
@ -142,14 +146,24 @@ export function TopBar() {
|
|||
const badgeCount = inboxData.leaderSummary.decision_count + notifications.unread_count;
|
||||
|
||||
return (
|
||||
<header className="flex h-16 items-center justify-between border-b border-slate-200 bg-white px-6 shadow-[0_1px_0_rgba(15,23,42,0.04)]">
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-slate-900">PMFlow</div>
|
||||
<div className="text-xs text-slate-500">AI 原生项目管理操作台</div>
|
||||
<header className="flex h-16 min-w-0 items-center justify-between border-b border-slate-200 bg-white px-3 shadow-[0_1px_0_rgba(15,23,42,0.04)] sm:px-4 md:px-6">
|
||||
<div className="flex min-w-0 items-center gap-2 sm:gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="md:hidden"
|
||||
onClick={onMenuToggle}
|
||||
aria-label="打开侧边栏菜单"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-slate-900">PMFlow</div>
|
||||
<div className="hidden text-xs text-slate-500 sm:block">AI 原生项目管理操作台</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" title={zhCN.bell.title} className="relative">
|
||||
|
|
|
|||
|
|
@ -361,7 +361,7 @@ export function AIVisibilityPanel({ projectId }: AIVisibilityPanelProps) {
|
|||
)}
|
||||
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<DialogContent className="left-auto right-0 top-0 h-screen w-[760px] max-w-[95vw] translate-x-0 translate-y-0 overflow-y-auto rounded-none">
|
||||
<DialogContent className="left-auto right-0 top-0 h-screen w-[min(760px,100vw)] max-w-[100vw] translate-x-0 translate-y-0 overflow-y-auto rounded-none">
|
||||
<DialogHeader>
|
||||
<DialogTitle>AI 处理日志详情</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
|
|
|||
|
|
@ -60,6 +60,8 @@ interface ModuleFieldEditState {
|
|||
interface SSTCCharterStagePanelProps {
|
||||
charters: SSTCCharter[];
|
||||
selectedCharter: SSTCCharter | null;
|
||||
isMobileViewport: boolean;
|
||||
readOnly: boolean;
|
||||
charterDetail?: CharterDetailShape;
|
||||
charterDraftTitle: string;
|
||||
charterModuleDraft: {
|
||||
|
|
@ -118,6 +120,8 @@ interface SSTCCharterStagePanelProps {
|
|||
export function SSTCCharterStagePanel({
|
||||
charters,
|
||||
selectedCharter,
|
||||
isMobileViewport,
|
||||
readOnly,
|
||||
charterDetail,
|
||||
charterDraftTitle,
|
||||
charterModuleDraft,
|
||||
|
|
@ -157,6 +161,7 @@ export function SSTCCharterStagePanel({
|
|||
)
|
||||
);
|
||||
const sourceSummary = sourceNames.length > 0 ? sourceNames.join("/") : "未绑定";
|
||||
const mobileReadOnly = isMobileViewport && readOnly;
|
||||
const autoFields = activeModule.fields.filter((field) => field.editable === false);
|
||||
const prevModule = activeModuleIndex > 0 ? CHARTER_MODULE_SCHEMAS[activeModuleIndex - 1] : null;
|
||||
const nextModule =
|
||||
|
|
@ -233,34 +238,39 @@ export function SSTCCharterStagePanel({
|
|||
style={{ width: `${filledModulePercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
{mobileReadOnly && (
|
||||
<p className="mt-2 text-xs text-amber-700">移动端只读模式:请在桌面端进行编辑与提交操作</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
|
||||
disabled={!selectedCharter || updateCharterMutation.isPending}
|
||||
onClick={() => {
|
||||
if (!selectedCharter) return;
|
||||
updateCharterMutation.mutate({
|
||||
charterId: selectedCharter.id,
|
||||
title: compactTitle(charterDraftTitle || selectedCharter.title, 24),
|
||||
content: serializedCharterContent,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{updateCharterMutation.isPending ? "保存中…" : "更新版本"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-blue-600 text-white hover:bg-blue-700"
|
||||
disabled={!selectedCharter}
|
||||
onClick={() => selectedCharter && startAlignmentMutation.mutate(selectedCharter.id)}
|
||||
>
|
||||
<PlayCircle className="mr-2 h-4 w-4" />
|
||||
发起对齐
|
||||
</Button>
|
||||
</div>
|
||||
{!mobileReadOnly && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
|
||||
disabled={!selectedCharter || updateCharterMutation.isPending}
|
||||
onClick={() => {
|
||||
if (!selectedCharter) return;
|
||||
updateCharterMutation.mutate({
|
||||
charterId: selectedCharter.id,
|
||||
title: compactTitle(charterDraftTitle || selectedCharter.title, 24),
|
||||
content: serializedCharterContent,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{updateCharterMutation.isPending ? "保存中…" : "更新版本"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-blue-600 text-white hover:bg-blue-700"
|
||||
disabled={!selectedCharter}
|
||||
onClick={() => selectedCharter && startAlignmentMutation.mutate(selectedCharter.id)}
|
||||
>
|
||||
<PlayCircle className="mr-2 h-4 w-4" />
|
||||
发起对齐
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl bg-[#F4F7FF] px-3 py-2 text-xs text-slate-600">
|
||||
|
|
@ -275,15 +285,17 @@ export function SSTCCharterStagePanel({
|
|||
>
|
||||
查看溯源
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2 text-xs text-slate-600 hover:bg-blue-50 hover:text-blue-700"
|
||||
disabled={!selectedCharter}
|
||||
onClick={() => selectedCharter && confirmAlignmentMutation.mutate(selectedCharter.id)}
|
||||
>
|
||||
确认对齐
|
||||
</Button>
|
||||
{!mobileReadOnly && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2 text-xs text-slate-600 hover:bg-blue-50 hover:text-blue-700"
|
||||
disabled={!selectedCharter}
|
||||
onClick={() => selectedCharter && confirmAlignmentMutation.mutate(selectedCharter.id)}
|
||||
>
|
||||
确认对齐
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -344,9 +356,13 @@ export function SSTCCharterStagePanel({
|
|||
<section className="space-y-4">
|
||||
<Input
|
||||
value={charterDraftTitle || selectedCharter?.title || ""}
|
||||
onChange={(e) => setCharterDraftTitle(e.target.value)}
|
||||
onChange={(e) => {
|
||||
if (mobileReadOnly) return;
|
||||
setCharterDraftTitle(e.target.value);
|
||||
}}
|
||||
placeholder="宪章标题(自动精简)"
|
||||
className="border-0 bg-[#F8F9FA] shadow-[inset_0_0_0_1px_rgba(148,163,184,0.28)]"
|
||||
readOnly={mobileReadOnly}
|
||||
className="border-0 bg-[#F8F9FA] shadow-[inset_0_0_0_1px_rgba(148,163,184,0.28)] read-only:cursor-not-allowed read-only:bg-slate-100"
|
||||
/>
|
||||
|
||||
<div className="rounded-xl bg-[#F8F9FA] p-4">
|
||||
|
|
@ -378,16 +394,24 @@ export function SSTCCharterStagePanel({
|
|||
<Textarea
|
||||
rows={field.rows || 3}
|
||||
value={charterModuleDraft.modules[activeModule.key][field.key] || ""}
|
||||
onChange={(e) => updateModuleField(activeModule.key, field.key, e.target.value)}
|
||||
onChange={(e) => {
|
||||
if (mobileReadOnly) return;
|
||||
updateModuleField(activeModule.key, field.key, e.target.value);
|
||||
}}
|
||||
placeholder={field.placeholder}
|
||||
className="border-slate-200 bg-white text-xs shadow-[0_1px_2px_rgba(15,23,42,0.04)]"
|
||||
readOnly={mobileReadOnly}
|
||||
className="border-slate-200 bg-white text-xs shadow-[0_1px_2px_rgba(15,23,42,0.04)] read-only:cursor-not-allowed read-only:bg-slate-100"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={charterModuleDraft.modules[activeModule.key][field.key] || ""}
|
||||
onChange={(e) => updateModuleField(activeModule.key, field.key, e.target.value)}
|
||||
onChange={(e) => {
|
||||
if (mobileReadOnly) return;
|
||||
updateModuleField(activeModule.key, field.key, e.target.value);
|
||||
}}
|
||||
placeholder={field.placeholder}
|
||||
className="border-slate-200 bg-white shadow-[0_1px_2px_rgba(15,23,42,0.04)]"
|
||||
readOnly={mobileReadOnly}
|
||||
className="border-slate-200 bg-white shadow-[0_1px_2px_rgba(15,23,42,0.04)] read-only:cursor-not-allowed read-only:bg-slate-100"
|
||||
/>
|
||||
)}
|
||||
<div className="mt-2 flex items-center justify-between gap-2 text-[11px]">
|
||||
|
|
@ -399,32 +423,34 @@ export function SSTCCharterStagePanel({
|
|||
<p className="text-slate-400">{aiReferenceText}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0 text-blue-700 hover:bg-blue-50"
|
||||
disabled={!selectedCharter || aiPrefillCharterMutation.isPending}
|
||||
onClick={() =>
|
||||
selectedCharter &&
|
||||
aiPrefillCharterMutation.mutate({ charterId: selectedCharter.id, force: true })
|
||||
}
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2 text-[11px] text-slate-600 hover:bg-slate-100"
|
||||
disabled={!selectedCharter || aiPrefillCharterMutation.isPending}
|
||||
onClick={() =>
|
||||
selectedCharter &&
|
||||
aiPrefillCharterMutation.mutate({ charterId: selectedCharter.id, force: true })
|
||||
}
|
||||
>
|
||||
再次优化
|
||||
</Button>
|
||||
</div>
|
||||
{!mobileReadOnly && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0 text-blue-700 hover:bg-blue-50"
|
||||
disabled={!selectedCharter || aiPrefillCharterMutation.isPending}
|
||||
onClick={() =>
|
||||
selectedCharter &&
|
||||
aiPrefillCharterMutation.mutate({ charterId: selectedCharter.id, force: true })
|
||||
}
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2 text-[11px] text-slate-600 hover:bg-slate-100"
|
||||
disabled={!selectedCharter || aiPrefillCharterMutation.isPending}
|
||||
onClick={() =>
|
||||
selectedCharter &&
|
||||
aiPrefillCharterMutation.mutate({ charterId: selectedCharter.id, force: true })
|
||||
}
|
||||
>
|
||||
再次优化
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -440,11 +466,14 @@ export function SSTCCharterStagePanel({
|
|||
return (
|
||||
<div key={`qa-${idx}`} className="rounded-lg bg-white p-2 shadow-[0_1px_2px_rgba(15,23,42,0.08)]">
|
||||
<p className="text-xs font-medium text-slate-700">Q{idx + 1} {question}</p>
|
||||
{editing ? (
|
||||
{editing && !mobileReadOnly ? (
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={alignmentQaAnswers[idx] || ""}
|
||||
onChange={(e) => updateAlignmentQaAnswer(idx, e.target.value)}
|
||||
onChange={(e) => {
|
||||
if (mobileReadOnly) return;
|
||||
updateAlignmentQaAnswer(idx, e.target.value);
|
||||
}}
|
||||
className="mt-2 border-slate-200 bg-white text-xs"
|
||||
/>
|
||||
) : (
|
||||
|
|
@ -452,16 +481,18 @@ export function SSTCCharterStagePanel({
|
|||
{alignmentQaAnswers[idx] || "AI 待补全"}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-[11px] text-slate-600 hover:bg-slate-100"
|
||||
onClick={() => setEditingAlignmentQaIndex(editing ? null : idx)}
|
||||
>
|
||||
{editing ? "完成" : "编辑"}
|
||||
</Button>
|
||||
</div>
|
||||
{!mobileReadOnly && (
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-[11px] text-slate-600 hover:bg-slate-100"
|
||||
onClick={() => setEditingAlignmentQaIndex(editing ? null : idx)}
|
||||
>
|
||||
{editing ? "完成" : "编辑"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,15 +37,15 @@ export function SSTCExecutionBoardPanel({
|
|||
}: SSTCExecutionBoardPanelProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="text-lg">执行看板</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">支持拖拽任务卡片变更状态,实时同步执行状态</p>
|
||||
<p className="text-xs text-muted-foreground sm:text-sm">支持拖拽任务卡片变更状态,实时同步执行状态</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="grid w-full gap-2 sm:flex sm:w-auto sm:flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-blue-600 text-white hover:bg-blue-700"
|
||||
className="w-full bg-blue-600 text-white hover:bg-blue-700 sm:w-auto"
|
||||
disabled={stageLocked || optimizeWorkloadMutation.isPending}
|
||||
onClick={() => optimizeWorkloadMutation.mutate("rebalance")}
|
||||
>
|
||||
|
|
@ -54,12 +54,13 @@ export function SSTCExecutionBoardPanel({
|
|||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={stageLocked || optimizeWorkloadMutation.isPending}
|
||||
onClick={() => optimizeWorkloadMutation.mutate("unassigned")}
|
||||
>
|
||||
AI 仅分配未指派
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={onRefresh}>
|
||||
<Button size="sm" variant="outline" className="w-full sm:w-auto" onClick={onRefresh}>
|
||||
<RefreshCcw className="mr-2 h-4 w-4" />
|
||||
刷新
|
||||
</Button>
|
||||
|
|
@ -76,56 +77,58 @@ export function SSTCExecutionBoardPanel({
|
|||
{optimizeFeedback}
|
||||
</p>
|
||||
)}
|
||||
<div className="grid gap-3 xl:grid-cols-5">
|
||||
{EXECUTION_LANES.map((lane) => (
|
||||
<div
|
||||
key={lane.key}
|
||||
className="min-h-[260px] rounded-md border bg-muted/20 p-2"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => handleDropTask(lane.key, e)}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold">{lane.label}</p>
|
||||
<Badge variant="outline">{executionLanes[lane.key].length}</Badge>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{executionLanes[lane.key].map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
draggable={!stageLocked}
|
||||
onDragStart={(e) => e.dataTransfer.setData("taskId", t.id)}
|
||||
className="cursor-grab rounded-md border bg-card p-2 active:cursor-grabbing"
|
||||
>
|
||||
<p className="text-sm font-medium">{t.title}</p>
|
||||
<div className="mt-1 flex items-center justify-between">
|
||||
<Badge className={`border text-[10px] ${priorityBadgeClass(t.priority)}`}>{toZhPriority(t.priority)}</Badge>
|
||||
<span className="text-[10px] text-muted-foreground">{t.due_date ? t.due_date.slice(0, 10) : "-"}</span>
|
||||
<div className="-mx-1 overflow-x-auto pb-2 [-webkit-overflow-scrolling:touch]">
|
||||
<div className="grid min-w-[920px] grid-cols-5 gap-3 px-1">
|
||||
{EXECUTION_LANES.map((lane) => (
|
||||
<div
|
||||
key={lane.key}
|
||||
className="min-h-[240px] rounded-md border bg-muted/20 p-2"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => handleDropTask(lane.key, e)}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-xs font-semibold sm:text-sm">{lane.label}</p>
|
||||
<Badge variant="outline">{executionLanes[lane.key].length}</Badge>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{executionLanes[lane.key].map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
draggable={!stageLocked}
|
||||
onDragStart={(e) => e.dataTransfer.setData("taskId", t.id)}
|
||||
className="cursor-grab rounded-md border bg-card p-2 active:cursor-grabbing"
|
||||
>
|
||||
<p className="line-clamp-2 text-xs font-medium sm:text-sm">{t.title}</p>
|
||||
<div className="mt-1 flex items-center justify-between">
|
||||
<Badge className={`border text-[10px] ${priorityBadgeClass(t.priority)}`}>{toZhPriority(t.priority)}</Badge>
|
||||
<span className="text-[10px] text-muted-foreground">{t.due_date ? t.due_date.slice(0, 10) : "-"}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-[10px]"
|
||||
disabled={stageLocked}
|
||||
onClick={() => patchTaskStatusMutation.mutate({ taskId: t.id, status: "blocked" })}
|
||||
>
|
||||
阻塞
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-[10px]"
|
||||
disabled={stageLocked}
|
||||
onClick={() => patchTaskStatusMutation.mutate({ taskId: t.id, status: "done" })}
|
||||
>
|
||||
完成
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-[10px]"
|
||||
disabled={stageLocked}
|
||||
onClick={() => patchTaskStatusMutation.mutate({ taskId: t.id, status: "blocked" })}
|
||||
>
|
||||
阻塞
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-[10px]"
|
||||
disabled={stageLocked}
|
||||
onClick={() => patchTaskStatusMutation.mutate({ taskId: t.id, status: "done" })}
|
||||
>
|
||||
完成
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -55,11 +55,11 @@ export function SSTCGlobalHeader({
|
|||
return (
|
||||
<div className="grid gap-4 xl:grid-cols-12">
|
||||
<Card className="xl:col-span-3">
|
||||
<CardHeader className="pb-3">
|
||||
<CardHeader className="min-w-0 pb-3">
|
||||
<CardTitle className="text-xl" title={projectLabel}>
|
||||
{compactProjectTitle}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={statusVariant(overview.project.status)}>
|
||||
{toZhStatus(overview.project.status)}
|
||||
</Badge>
|
||||
|
|
@ -75,7 +75,7 @@ export function SSTCGlobalHeader({
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 xl:col-span-7 md:grid-cols-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:col-span-7 xl:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">全局认知对齐度</CardTitle>
|
||||
|
|
@ -102,7 +102,7 @@ export function SSTCGlobalHeader({
|
|||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<p className="text-sm font-semibold" style={{ color: alignmentColor }}>
|
||||
<p className="text-lg font-semibold sm:text-xl" style={{ color: alignmentColor }}>
|
||||
{Math.round(overview.alignment_score || 0)}%
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
|
@ -116,7 +116,7 @@ export function SSTCGlobalHeader({
|
|||
<CardTitle className="text-base">项目整体进度</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p className="text-2xl font-semibold">{Math.round(progress)}%</p>
|
||||
<p className="text-xl font-semibold sm:text-2xl">{Math.round(progress)}%</p>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-full bg-primary" style={{ width: `${Math.min(progress, 100)}%` }} />
|
||||
</div>
|
||||
|
|
@ -134,7 +134,7 @@ export function SSTCGlobalHeader({
|
|||
<CardTitle className="text-base">团队负载健康度</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p className="text-2xl font-semibold" style={{ color: ratioColor(ratio) }}>
|
||||
<p className="text-xl font-semibold sm:text-2xl" style={{ color: ratioColor(ratio) }}>
|
||||
{(ratio * 100).toFixed(0)}%
|
||||
</p>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
|
|
|
|||
|
|
@ -227,7 +227,7 @@ export function SSTCReleaseStagePanel({
|
|||
</div>
|
||||
|
||||
{verificationItems.length > 0 && (
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-2">
|
||||
<div className="mt-3 grid gap-2 lg:grid-cols-2">
|
||||
{verificationItems.slice(0, 8).map((item) => {
|
||||
const itemId = String(item.id || "");
|
||||
const checked = String(item.status || "").toLowerCase() === "done";
|
||||
|
|
|
|||
|
|
@ -201,7 +201,7 @@ export function SSTCReviewStagePanel({
|
|||
<div className="mt-3 rounded-lg bg-slate-50 p-2 text-xs text-slate-600">{summary}</div>
|
||||
|
||||
{improvementDrafts.length > 0 && (
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-2">
|
||||
<div className="mt-3 grid gap-2 lg:grid-cols-2">
|
||||
{improvementDrafts.map((item, idx) => {
|
||||
const title = String(item.title || "-");
|
||||
const hasTask = !!item.task_id;
|
||||
|
|
|
|||
|
|
@ -18,10 +18,10 @@ export function SSTCRiskFloatPanel({ risks, onJump }: SSTCRiskFloatPanelProps) {
|
|||
|
||||
if (minimized) {
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-40">
|
||||
<div className="fixed bottom-4 right-2 z-40 sm:right-4">
|
||||
<Button
|
||||
variant="default"
|
||||
className="relative"
|
||||
className="relative w-[min(220px,calc(100vw-1rem))] justify-center"
|
||||
onClick={() => setMinimized(false)}
|
||||
title="打开预警中心"
|
||||
>
|
||||
|
|
@ -38,7 +38,7 @@ export function SSTCRiskFloatPanel({ risks, onJump }: SSTCRiskFloatPanelProps) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-40 w-[280px] rounded-lg border bg-card shadow-xl">
|
||||
<div className="fixed bottom-4 right-2 z-40 w-[min(320px,calc(100vw-1rem))] rounded-lg border bg-card shadow-xl sm:right-4">
|
||||
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||
<p className="text-sm font-semibold">项目预警中心</p>
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@ export function SSTCSignalStagePanel({
|
|||
重置筛选
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-2 md:grid-cols-6">
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
<Input
|
||||
value={signalFilters.keyword}
|
||||
onChange={(e) => onSignalFiltersChange({ keyword: e.target.value })}
|
||||
|
|
@ -262,7 +262,7 @@ export function SSTCSignalStagePanel({
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4 grid gap-2 md:grid-cols-4">
|
||||
<div className="mb-4 grid grid-cols-2 gap-2 lg:grid-cols-4">
|
||||
<div className="rounded-xl bg-slate-50 px-3 py-2">
|
||||
<p className="text-[11px] text-slate-500">当前页信号</p>
|
||||
<p className="text-lg font-semibold text-slate-900">{displaySignals.length}</p>
|
||||
|
|
@ -285,8 +285,8 @@ export function SSTCSignalStagePanel({
|
|||
<AIVisibilityPanel projectId={projectId} />
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200">
|
||||
<Table>
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-200 [-webkit-overflow-scrolling:touch]">
|
||||
<Table className="min-w-[1080px]">
|
||||
<TableHeader className="bg-slate-50">
|
||||
<TableRow>
|
||||
<TableHead>优先级</TableHead>
|
||||
|
|
|
|||
|
|
@ -57,7 +57,8 @@ function stageProgressValue(status: string): number {
|
|||
export function SSTCStageNav({ stages, activeStage, signalPreview = [], onChange }: SSTCStageNavProps) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-200/80 bg-gradient-to-b from-slate-50 to-white p-4 shadow-[0_1px_3px_rgba(15,23,42,0.08)]">
|
||||
<div className="flex flex-wrap items-stretch gap-2">
|
||||
<div className="overflow-x-auto pb-1 [-webkit-overflow-scrolling:touch]">
|
||||
<div className="flex min-w-max items-stretch gap-2">
|
||||
{stages.map((stage, idx) => {
|
||||
const displayStatus = toZhStatus(stage.status);
|
||||
const done = isDoneStatus(displayStatus);
|
||||
|
|
@ -66,7 +67,7 @@ export function SSTCStageNav({ stages, activeStage, signalPreview = [], onChange
|
|||
const showSignalHover = stage.key === "signal" && signalPreview.length > 0;
|
||||
const unlockHint = stage.locked ? `解锁条件:完成上游阶段(${stage.summary || "请按链路顺序推进"})` : "已解锁";
|
||||
return (
|
||||
<div key={stage.key} className="group relative flex min-w-[152px] flex-1 items-center">
|
||||
<div key={stage.key} className="group relative flex w-[220px] shrink-0 items-center sm:min-w-[152px] sm:flex-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(stage.key)}
|
||||
|
|
@ -131,6 +132,7 @@ export function SSTCStageNav({ stages, activeStage, signalPreview = [], onChange
|
|||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ export function SSTCTaskCreateDialog({
|
|||
</Button>
|
||||
|
||||
<Dialog open={newTaskOpen} onOpenChange={setNewTaskOpen}>
|
||||
<DialogContent>
|
||||
<DialogContent className="w-[min(560px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建任务</DialogTitle>
|
||||
<DialogDescription>任务将绑定当前宪章(SSTC 强制)</DialogDescription>
|
||||
|
|
@ -79,7 +79,7 @@ export function SSTCTaskCreateDialog({
|
|||
<div className="space-y-3">
|
||||
<Input value={newTaskTitle} onChange={(e) => setNewTaskTitle(e.target.value)} placeholder="任务标题" />
|
||||
<Textarea value={newTaskDesc} onChange={(e) => setNewTaskDesc(e.target.value)} placeholder="任务描述" />
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
<select
|
||||
className="rounded-md border bg-background px-2 py-2 text-sm"
|
||||
value={newTaskPriority}
|
||||
|
|
|
|||
|
|
@ -243,8 +243,9 @@ export function SSTCTaskStagePanel({
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-200 [-webkit-overflow-scrolling:touch]">
|
||||
<Table className="min-w-[980px]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10 text-center">
|
||||
<input
|
||||
|
|
@ -263,8 +264,8 @@ export function SSTCTaskStagePanel({
|
|||
<TableHead className="text-right">截止日期</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{charterTasks.map((t) => {
|
||||
const recs = taskRecommendations[t.id] || [];
|
||||
const status = String(t.status || "").toLowerCase();
|
||||
|
|
@ -391,8 +392,9 @@ export function SSTCTaskStagePanel({
|
|||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,12 @@ import { SSTCReleaseStagePanel } from "@/components/sstc/SSTCReleaseStagePanel";
|
|||
import { SSTCReviewStagePanel } from "@/components/sstc/SSTCReviewStagePanel";
|
||||
import { useSSTCWorkspaceOrchestration, type SSTCWorkspaceProps } from "@/components/sstc/useSSTCWorkspaceOrchestration";
|
||||
|
||||
export function SSTCWorkspace(props: SSTCWorkspaceProps) {
|
||||
interface SSTCWorkspaceViewProps extends SSTCWorkspaceProps {
|
||||
isMobileViewport: boolean;
|
||||
charterReadOnly: boolean;
|
||||
}
|
||||
|
||||
export function SSTCWorkspace(props: SSTCWorkspaceViewProps) {
|
||||
const orchestration = useSSTCWorkspaceOrchestration(props);
|
||||
|
||||
const signalProps = {
|
||||
|
|
@ -60,6 +65,8 @@ export function SSTCWorkspace(props: SSTCWorkspaceProps) {
|
|||
setShowAllAlignmentQa: orchestration.setShowAllAlignmentQa,
|
||||
setEditingAlignmentQaIndex: orchestration.setEditingAlignmentQaIndex,
|
||||
setActiveModuleKey: orchestration.setActiveModuleKey,
|
||||
isMobileViewport: props.isMobileViewport,
|
||||
readOnly: props.charterReadOnly,
|
||||
onSelectCharter: props.onSelectCharter,
|
||||
updateModuleField: orchestration.updateModuleField,
|
||||
updateAlignmentQaAnswer: orchestration.updateAlignmentQaAnswer,
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ export function SignalContentHoverCard({
|
|||
</button>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-h-[85vh] w-[680px] max-w-[94vw] overflow-y-auto rounded-2xl border-0 bg-white shadow-[0_20px_60px_rgba(15,23,42,0.2)]">
|
||||
<DialogContent className="max-h-[85vh] w-[min(680px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] overflow-y-auto rounded-2xl border-0 bg-white shadow-[0_20px_60px_rgba(15,23,42,0.2)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl text-slate-900">{detailTitle}</DialogTitle>
|
||||
<DialogDescription className="text-slate-500">
|
||||
|
|
|
|||
|
|
@ -756,7 +756,7 @@ export default function AIConfigPage() {
|
|||
// ==================== Render ====================
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-950 dark:to-slate-900 p-6">
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 p-3 dark:from-slate-950 dark:to-slate-900 sm:p-4 md:p-6">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
|
|
@ -805,17 +805,17 @@ export default function AIConfigPage() {
|
|||
|
||||
{/* Main Content */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-5">
|
||||
<TabsTrigger value="overview">集成源概览</TabsTrigger>
|
||||
<TabsTrigger value="configure">快速配置</TabsTrigger>
|
||||
<TabsTrigger value="knowledge">知识库</TabsTrigger>
|
||||
<TabsTrigger value="advanced">高级设置</TabsTrigger>
|
||||
<TabsTrigger value="prompts">Prompt 设置</TabsTrigger>
|
||||
<TabsList className="flex w-full min-w-0 flex-nowrap gap-2 overflow-x-auto rounded-lg p-1 [-webkit-overflow-scrolling:touch]">
|
||||
<TabsTrigger value="overview" className="shrink-0 whitespace-nowrap">集成源概览</TabsTrigger>
|
||||
<TabsTrigger value="configure" className="shrink-0 whitespace-nowrap">快速配置</TabsTrigger>
|
||||
<TabsTrigger value="knowledge" className="shrink-0 whitespace-nowrap">知识库</TabsTrigger>
|
||||
<TabsTrigger value="advanced" className="shrink-0 whitespace-nowrap">高级设置</TabsTrigger>
|
||||
<TabsTrigger value="prompts" className="shrink-0 whitespace-nowrap">Prompt 设置</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Overview Tab */}
|
||||
<TabsContent value="overview" className="mt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{integrationSources.map((source) => {
|
||||
const Icon = source.icon;
|
||||
return (
|
||||
|
|
@ -991,7 +991,7 @@ export default function AIConfigPage() {
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="feishu_app_id">应用编号 *</Label>
|
||||
<Input
|
||||
|
|
@ -1145,7 +1145,7 @@ export default function AIConfigPage() {
|
|||
正在修改已有配置:已自动回填当前配置,可直接核对并修改后保存。
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="wecom_corp_id">企业编号 *</Label>
|
||||
<Input
|
||||
|
|
@ -1175,7 +1175,7 @@ export default function AIConfigPage() {
|
|||
onChange={(e) => setWecomConfig({ ...wecomConfig, app_secret: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="wecom_token">验证令牌 *</Label>
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -485,7 +485,7 @@ export default function AIStewardPage() {
|
|||
// ==================== Render ====================
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-950 dark:to-slate-900 p-6">
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 p-3 dark:from-slate-950 dark:to-slate-900 sm:p-4 md:p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
|
|
@ -501,7 +501,7 @@ export default function AIStewardPage() {
|
|||
</div>
|
||||
|
||||
{/* Feature Overview Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<div className="mb-8 grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Card className="border-blue-200 dark:border-blue-800 hover:shadow-lg transition-shadow cursor-pointer" onClick={() => setActiveTab("signals")}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -567,16 +567,16 @@ export default function AIStewardPage() {
|
|||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-2 gap-1 md:grid-cols-4 xl:grid-cols-9">
|
||||
<TabsTrigger value="overview">总览</TabsTrigger>
|
||||
<TabsTrigger value="commander">指挥调度</TabsTrigger>
|
||||
<TabsTrigger value="create">零配置创建</TabsTrigger>
|
||||
<TabsTrigger value="signals">信号捕获</TabsTrigger>
|
||||
<TabsTrigger value="conflicts">认知冲突</TabsTrigger>
|
||||
<TabsTrigger value="blockers">阻塞检测</TabsTrigger>
|
||||
<TabsTrigger value="briefing">每日简报</TabsTrigger>
|
||||
<TabsTrigger value="qa">问答记录</TabsTrigger>
|
||||
<TabsTrigger value="engine">引擎配置</TabsTrigger>
|
||||
<TabsList className="flex w-full min-w-0 flex-nowrap gap-1 overflow-x-auto rounded-lg p-1 [-webkit-overflow-scrolling:touch]">
|
||||
<TabsTrigger value="overview" className="shrink-0 whitespace-nowrap">总览</TabsTrigger>
|
||||
<TabsTrigger value="commander" className="shrink-0 whitespace-nowrap">指挥调度</TabsTrigger>
|
||||
<TabsTrigger value="create" className="shrink-0 whitespace-nowrap">零配置创建</TabsTrigger>
|
||||
<TabsTrigger value="signals" className="shrink-0 whitespace-nowrap">信号捕获</TabsTrigger>
|
||||
<TabsTrigger value="conflicts" className="shrink-0 whitespace-nowrap">认知冲突</TabsTrigger>
|
||||
<TabsTrigger value="blockers" className="shrink-0 whitespace-nowrap">阻塞检测</TabsTrigger>
|
||||
<TabsTrigger value="briefing" className="shrink-0 whitespace-nowrap">每日简报</TabsTrigger>
|
||||
<TabsTrigger value="qa" className="shrink-0 whitespace-nowrap">问答记录</TabsTrigger>
|
||||
<TabsTrigger value="engine" className="shrink-0 whitespace-nowrap">引擎配置</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Overview Tab */}
|
||||
|
|
@ -589,7 +589,7 @@ export default function AIStewardPage() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div className="p-6 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-12 h-12 rounded-full bg-blue-500 flex items-center justify-center">
|
||||
|
|
@ -784,11 +784,11 @@ export default function AIStewardPage() {
|
|||
</Card>
|
||||
|
||||
<Tabs defaultValue="project-overview">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="project-overview">项目概览</TabsTrigger>
|
||||
<TabsTrigger value="project-charter">项目宪章</TabsTrigger>
|
||||
<TabsTrigger value="project-tasks">任务列表</TabsTrigger>
|
||||
<TabsTrigger value="project-risks">风险预警</TabsTrigger>
|
||||
<TabsList className="flex w-full min-w-0 flex-nowrap gap-1 overflow-x-auto rounded-lg p-1 [-webkit-overflow-scrolling:touch]">
|
||||
<TabsTrigger value="project-overview" className="shrink-0 whitespace-nowrap">项目概览</TabsTrigger>
|
||||
<TabsTrigger value="project-charter" className="shrink-0 whitespace-nowrap">项目宪章</TabsTrigger>
|
||||
<TabsTrigger value="project-tasks" className="shrink-0 whitespace-nowrap">任务列表</TabsTrigger>
|
||||
<TabsTrigger value="project-risks" className="shrink-0 whitespace-nowrap">风险预警</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="project-overview">
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ export default function CharterDetailPage() {
|
|||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link to={`/projects/${id}/board`}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
|
|
@ -133,7 +133,7 @@ export default function CharterDetailPage() {
|
|||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="ml-auto"
|
||||
className="sm:ml-auto"
|
||||
onClick={() => setCreateTaskOpen(true)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ export default function DashboardPage() {
|
|||
<p className="text-muted-foreground">查看团队成员工作负载分布</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>工时分布</CardTitle>
|
||||
|
|
@ -91,15 +91,16 @@ export default function DashboardPage() {
|
|||
) : items.length === 0 ? (
|
||||
<p className="text-muted-foreground">暂无数据</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
|
||||
<Table className="min-w-[520px]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>成员</TableHead>
|
||||
<TableHead className="text-right">任务数</TableHead>
|
||||
<TableHead className="text-right">工时 (h)</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((row) => (
|
||||
<TableRow key={row.user_id}>
|
||||
<TableCell>{row.user_name || row.username}</TableCell>
|
||||
|
|
@ -107,8 +108,9 @@ export default function DashboardPage() {
|
|||
<TableCell className="text-right">{(row.estimated_hours ?? 0).toFixed(1)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ export default function FeishuConfigPage() {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-950 dark:to-slate-900 p-6">
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 p-3 dark:from-slate-950 dark:to-slate-900 sm:p-4 md:p-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
|
|
@ -296,7 +296,7 @@ export default function FeishuConfigPage() {
|
|||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
|
|
@ -351,12 +351,12 @@ export default function FeishuConfigPage() {
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="step1">
|
||||
<TabsList className="grid w-full grid-cols-5">
|
||||
<TabsTrigger value="step1">1. 创建应用</TabsTrigger>
|
||||
<TabsTrigger value="step2">2. 获取凭证</TabsTrigger>
|
||||
<TabsTrigger value="step3">3. 配置事件</TabsTrigger>
|
||||
<TabsTrigger value="step4">4. 回调地址</TabsTrigger>
|
||||
<TabsTrigger value="step5">5. 完成</TabsTrigger>
|
||||
<TabsList className="flex w-full min-w-0 flex-nowrap gap-1 overflow-x-auto rounded-lg p-1 [-webkit-overflow-scrolling:touch]">
|
||||
<TabsTrigger value="step1" className="shrink-0 whitespace-nowrap">1. 创建应用</TabsTrigger>
|
||||
<TabsTrigger value="step2" className="shrink-0 whitespace-nowrap">2. 获取凭证</TabsTrigger>
|
||||
<TabsTrigger value="step3" className="shrink-0 whitespace-nowrap">3. 配置事件</TabsTrigger>
|
||||
<TabsTrigger value="step4" className="shrink-0 whitespace-nowrap">4. 回调地址</TabsTrigger>
|
||||
<TabsTrigger value="step5" className="shrink-0 whitespace-nowrap">5. 完成</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="step1">
|
||||
|
|
|
|||
|
|
@ -570,7 +570,7 @@ export default function InboxPage() {
|
|||
<section className="rounded-2xl bg-[#F8F9FA] p-4 md:p-5">
|
||||
<div className="rounded-2xl bg-white p-4 shadow-[0_8px_22px_rgba(15,23,42,0.08)]">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="min-w-[220px] flex-1">
|
||||
<div className="w-full min-w-0 sm:min-w-[220px] sm:flex-1">
|
||||
<Input
|
||||
placeholder="搜索标题、摘要、项目"
|
||||
defaultValue={searchValue}
|
||||
|
|
@ -584,7 +584,7 @@ export default function InboxPage() {
|
|||
<select
|
||||
value={projectFilter}
|
||||
onChange={(event) => setProject(event.target.value)}
|
||||
className="h-10 rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-700"
|
||||
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-700 sm:w-auto"
|
||||
>
|
||||
<option value="all">全部项目</option>
|
||||
{projectOptions.map((option) => (
|
||||
|
|
@ -596,7 +596,7 @@ export default function InboxPage() {
|
|||
<select
|
||||
value={priorityFilter}
|
||||
onChange={(event) => setPriorityFilter(event.target.value)}
|
||||
className="h-10 rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-700"
|
||||
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-700 sm:w-auto"
|
||||
>
|
||||
{PRIORITY_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
|
|
@ -669,7 +669,7 @@ export default function InboxPage() {
|
|||
if (!open) setWorkItemDetailItem(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[90vh] w-[720px] max-w-[95vw] overflow-y-auto">
|
||||
<DialogContent className="max-h-[90vh] w-[min(720px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{workItemDetailItem?.title || "工作项详情"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
|
@ -808,7 +808,7 @@ export default function InboxPage() {
|
|||
if (!open) setNotificationDetailItem(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[90vh] w-[560px] max-w-[95vw] overflow-y-auto">
|
||||
<DialogContent className="max-h-[90vh] w-[min(560px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{notificationDetailItem?.title || "通知详情"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
|
|
|||
|
|
@ -54,6 +54,9 @@ export default function ProjectBoardPage() {
|
|||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const [isMobileViewport, setIsMobileViewport] = useState(() =>
|
||||
typeof window !== "undefined" ? window.innerWidth < 768 : false
|
||||
);
|
||||
|
||||
const [activeStage, setActiveStage] = useState<StageKey>("signal");
|
||||
const [selectedCharterId, setSelectedCharterId] = useState<string | null>(null);
|
||||
|
|
@ -211,6 +214,14 @@ export default function ProjectBoardPage() {
|
|||
}
|
||||
}, [charters, selectedCharterId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const syncViewport = () => setIsMobileViewport(window.innerWidth < 768);
|
||||
syncViewport();
|
||||
window.addEventListener("resize", syncViewport);
|
||||
return () => window.removeEventListener("resize", syncViewport);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSignalPage(1);
|
||||
setSignalFilters(DEFAULT_SIGNAL_FILTERS);
|
||||
|
|
@ -590,6 +601,8 @@ export default function ProjectBoardPage() {
|
|||
<SSTCWorkspace
|
||||
projectId={id!}
|
||||
activeStage={activeStage}
|
||||
isMobileViewport={isMobileViewport}
|
||||
charterReadOnly={isMobileViewport}
|
||||
stages={stageNodes}
|
||||
signals={signals}
|
||||
signalFilters={signalFilters}
|
||||
|
|
@ -632,7 +645,7 @@ export default function ProjectBoardPage() {
|
|||
{risks && <SSTCRiskFloatPanel risks={risks} />}
|
||||
|
||||
<Dialog open={createCharterOpen} onOpenChange={handleCreateCharterDialogChange}>
|
||||
<DialogContent className="flex max-h-[85vh] w-[560px] max-w-[560px] min-w-[560px] flex-col gap-0 overflow-hidden p-0">
|
||||
<DialogContent className="flex max-h-[85vh] w-[min(560px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] flex-col gap-0 overflow-hidden p-0 sm:w-[560px] sm:max-w-[560px]">
|
||||
<DialogHeader className="min-w-0 shrink-0 border-b border-slate-200 px-6 py-5">
|
||||
<DialogTitle className="overflow-hidden truncate">新建需求宪章</DialogTitle>
|
||||
<DialogDescription
|
||||
|
|
@ -792,7 +805,7 @@ export default function ProjectBoardPage() {
|
|||
</Dialog>
|
||||
|
||||
<Dialog open={settingsOpen} onOpenChange={setSettingsOpen}>
|
||||
<DialogContent className="sm:max-w-[640px]">
|
||||
<DialogContent className="w-[min(640px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] sm:max-w-[640px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>项目设置</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
|
|
|||
|
|
@ -236,7 +236,7 @@ export default function ProjectsPage() {
|
|||
<CardDescription className="mt-2 text-slate-500">
|
||||
任何项目相关操作必须纳入完整链路;游离在链路外的信息不具备执行效力。
|
||||
</CardDescription>
|
||||
<div className="mt-4 grid gap-2 md:grid-cols-2 xl:grid-cols-6">
|
||||
<div className="mt-4 grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
{CHAIN_STAGES.map((stage) => {
|
||||
const Icon = stage.icon;
|
||||
return (
|
||||
|
|
@ -302,7 +302,7 @@ export default function ProjectsPage() {
|
|||
</section>
|
||||
) : (
|
||||
<section className="rounded-2xl bg-[#F8F9FA] p-4 md:p-5">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="grid gap-4 sm:grid-cols-2 2xl:grid-cols-3">
|
||||
{pagedProjects.map((p) => {
|
||||
const progressPercent = Math.min(100, typeof p.progress === "number" ? p.progress : 0);
|
||||
const projectLabel = formatProjectNameWithId({ id: p.id, title: p.title });
|
||||
|
|
@ -401,7 +401,7 @@ export default function ProjectsPage() {
|
|||
)}
|
||||
|
||||
<Dialog open={queryOpen} onOpenChange={setQueryOpen}>
|
||||
<DialogContent className="sm:max-w-[560px]">
|
||||
<DialogContent className="w-[min(560px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>条件查询</DialogTitle>
|
||||
<DialogDescription>按项目名称和创建时间过滤项目列表。</DialogDescription>
|
||||
|
|
@ -462,7 +462,7 @@ export default function ProjectsPage() {
|
|||
</Dialog>
|
||||
|
||||
<Dialog open={!!deleteProjectState} onOpenChange={(open) => !open && setDeleteProjectState(null)}>
|
||||
<DialogContent className="sm:max-w-[420px]">
|
||||
<DialogContent className="w-[min(420px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] sm:max-w-[420px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除项目</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
|
|
|||
|
|
@ -137,12 +137,12 @@ export default function RecipesPage() {
|
|||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">食谱管理</h1>
|
||||
<p className="text-muted-foreground">管理您的食谱,AI 智能识别热量</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
|
|
@ -150,7 +150,7 @@ export default function RecipesPage() {
|
|||
手动添加
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogContent className="w-[min(560px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>创建食谱</DialogTitle>
|
||||
<DialogDescription>手动添加食谱信息</DialogDescription>
|
||||
|
|
@ -211,7 +211,7 @@ export default function RecipesPage() {
|
|||
拍照识别
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogContent className="w-[min(720px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] sm:max-w-[720px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>AI 热量识别</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
|
@ -269,7 +269,7 @@ export default function RecipesPage() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="category">分类</Label>
|
||||
<Input
|
||||
|
|
@ -317,8 +317,8 @@ export default function RecipesPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative w-full sm:max-w-sm sm:flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索食谱..."
|
||||
|
|
@ -330,7 +330,7 @@ export default function RecipesPage() {
|
|||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
className="h-10 w-[180px] rounded-md border border-input bg-background px-3 text-sm"
|
||||
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm sm:w-44"
|
||||
>
|
||||
<option value="all">全部分类</option>
|
||||
<option value="主食">主食</option>
|
||||
|
|
@ -352,7 +352,7 @@ export default function RecipesPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredRecipes.map((recipe) => (
|
||||
<Card key={recipe.id} className="overflow-hidden">
|
||||
{recipe.image_url && (
|
||||
|
|
@ -400,7 +400,7 @@ export default function RecipesPage() {
|
|||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2 text-xs text-muted-foreground">
|
||||
<div className="grid grid-cols-2 gap-2 text-xs text-muted-foreground sm:grid-cols-4">
|
||||
<div className="text-center">
|
||||
<Droplets className="h-3 w-3 mx-auto mb-1" />
|
||||
<div>蛋白质</div>
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export default function SkillTasksPage() {
|
|||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link to={userId ? `/users/${userId}/profile` : "/users"}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
|
|
@ -66,8 +66,9 @@ export default function SkillTasksPage() {
|
|||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
|
||||
<Table className="min-w-[720px]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>任务</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
|
|
@ -75,8 +76,8 @@ export default function SkillTasksPage() {
|
|||
<TableHead className="text-right">工时 (h)</TableHead>
|
||||
<TableHead>更新时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((task) => (
|
||||
<TableRow key={task.id}>
|
||||
<TableCell>
|
||||
|
|
@ -103,8 +104,9 @@ export default function SkillTasksPage() {
|
|||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ export default function SkillsPage() {
|
|||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">技能标签管理</h1>
|
||||
<p className="text-muted-foreground">维护技能标签分类并查看关联人数</p>
|
||||
|
|
@ -139,15 +139,15 @@ export default function SkillsPage() {
|
|||
<CardTitle>标签列表</CardTitle>
|
||||
<CardDescription>共 {rows.length} 个</CardDescription>
|
||||
</div>
|
||||
<div className="ml-auto flex flex-wrap gap-2">
|
||||
<div className="ml-auto flex w-full flex-wrap gap-2 sm:w-auto">
|
||||
<Input
|
||||
placeholder="搜索名称/标识/分类"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="max-w-xs"
|
||||
className="w-full sm:w-72"
|
||||
/>
|
||||
<select
|
||||
className="h-10 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
className="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm sm:w-auto"
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
>
|
||||
|
|
@ -169,8 +169,9 @@ export default function SkillsPage() {
|
|||
) : rows.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">暂无技能标签</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
|
||||
<Table className="min-w-[760px]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>分类</TableHead>
|
||||
|
|
@ -178,8 +179,8 @@ export default function SkillsPage() {
|
|||
<TableHead>描述</TableHead>
|
||||
{canManage && <TableHead className="w-12" />}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((tag) => (
|
||||
<TableRow key={tag.id}>
|
||||
<TableCell>
|
||||
|
|
@ -226,8 +227,9 @@ export default function SkillsPage() {
|
|||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export default function UserProfilePage() {
|
|||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link to={isManager ? "/users" : "/dashboard"}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
|
|
@ -42,14 +42,14 @@ export default function UserProfilePage() {
|
|||
<h1 className="text-2xl font-bold tracking-tight">个人中心</h1>
|
||||
<p className="text-muted-foreground">{displayName}</p>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<div className="sm:ml-auto">
|
||||
<Button asChild variant="outline">
|
||||
<Link to={`/users/${userId}/skills`}>技能详情</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.7fr)_360px]">
|
||||
<div className="grid gap-6 2xl:grid-cols-[minmax(0,1.7fr)_360px]">
|
||||
{userId && (
|
||||
<SkillRadarChart
|
||||
userId={userId}
|
||||
|
|
@ -84,7 +84,7 @@ export default function UserProfilePage() {
|
|||
<p className="text-destructive">加载失败</p>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<dl className="grid gap-3 text-sm sm:grid-cols-2 xl:grid-cols-1">
|
||||
<dl className="grid gap-3 text-sm">
|
||||
<div className="rounded-2xl border border-slate-200/80 bg-white px-4 py-3">
|
||||
<dt className="text-xs uppercase tracking-[0.18em] text-slate-500">用户名</dt>
|
||||
<dd className="mt-2 font-medium text-slate-900">{user?.username ?? "-"}</dd>
|
||||
|
|
|
|||
|
|
@ -208,8 +208,8 @@ export default function UserSkillsPage() {
|
|||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link to={`/users/${userId}/profile`}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
|
|
@ -222,7 +222,7 @@ export default function UserSkillsPage() {
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!canManageAll}
|
||||
|
|
@ -244,8 +244,8 @@ export default function UserSkillsPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-5">
|
||||
<div className="space-y-4 lg:col-span-3">
|
||||
<div className="grid gap-4 xl:grid-cols-5">
|
||||
<div className="space-y-4 xl:col-span-3">
|
||||
{bindings.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-10 text-center text-sm text-muted-foreground">
|
||||
|
|
@ -353,8 +353,8 @@ export default function UserSkillsPage() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<Card className="xl:col-span-2">
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle className="text-base">技能雷达图</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
|
|
|
|||
|
|
@ -283,7 +283,7 @@ export default function UsersPage() {
|
|||
<p className="text-muted-foreground">筛选、导入、状态管理、批量删除</p>
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={() => setImportOpen(true)}>
|
||||
<Import className="mr-2 h-4 w-4" />
|
||||
导入
|
||||
|
|
@ -319,12 +319,12 @@ export default function UsersPage() {
|
|||
<CardTitle>用户列表</CardTitle>
|
||||
<CardDescription>共 {total} 人</CardDescription>
|
||||
</div>
|
||||
<div className="ml-auto flex flex-wrap gap-2">
|
||||
<div className="ml-auto flex w-full flex-wrap gap-2 sm:w-auto">
|
||||
<Input
|
||||
placeholder="搜索用户名/姓名/邮箱"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="max-w-xs"
|
||||
className="w-full sm:w-72"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -337,8 +337,9 @@ export default function UsersPage() {
|
|||
) : renderedUsers.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">暂无用户</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
|
||||
<Table className="min-w-[940px]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10">
|
||||
<input
|
||||
|
|
@ -357,8 +358,8 @@ export default function UsersPage() {
|
|||
<TableHead>状态</TableHead>
|
||||
{canManage && <TableHead className="w-12" />}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{renderedUsers.map((u) => {
|
||||
const ratio = u.workload_ratio ?? u.workload ?? 0;
|
||||
return (
|
||||
|
|
@ -455,8 +456,9 @@ export default function UsersPage() {
|
|||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ export default function WorkloadDetailPage() {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm text-muted-foreground">负载比例</CardTitle>
|
||||
|
|
@ -151,7 +151,7 @@ export default function WorkloadDetailPage() {
|
|||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle>负载趋势</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<Button variant={days === 7 ? "default" : "outline"} size="sm" onClick={() => setDays(7)}>
|
||||
|
|
@ -194,11 +194,11 @@ export default function WorkloadDetailPage() {
|
|||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle>任务列表</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex w-full flex-wrap gap-2 sm:w-auto">
|
||||
<select
|
||||
className="h-9 rounded-md border border-input bg-background px-2 text-sm"
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-2 text-sm sm:w-auto"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
|
|
@ -208,7 +208,7 @@ export default function WorkloadDetailPage() {
|
|||
<option value="done">已完成</option>
|
||||
</select>
|
||||
<select
|
||||
className="h-9 rounded-md border border-input bg-background px-2 text-sm"
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-2 text-sm sm:w-auto"
|
||||
value={priorityFilter}
|
||||
onChange={(e) => setPriorityFilter(e.target.value)}
|
||||
>
|
||||
|
|
@ -228,8 +228,9 @@ export default function WorkloadDetailPage() {
|
|||
) : tasks.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">暂无任务</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
|
||||
<Table className="min-w-[760px]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>标题</TableHead>
|
||||
<TableHead>项目</TableHead>
|
||||
|
|
@ -238,8 +239,8 @@ export default function WorkloadDetailPage() {
|
|||
<TableHead>到期</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tasks.map((task) => (
|
||||
<TableRow key={task.id}>
|
||||
<TableCell>{task.title}</TableCell>
|
||||
|
|
@ -250,8 +251,9 @@ export default function WorkloadDetailPage() {
|
|||
<TableCell>{toZhStatus(task.status)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -173,13 +173,13 @@ export default function WorkloadsPage() {
|
|||
<CardContent className="pt-6">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
className="w-[140px]"
|
||||
className="w-full sm:w-40"
|
||||
placeholder="项目编号"
|
||||
value={projectId}
|
||||
onChange={(e) => setProjectId(e.target.value)}
|
||||
/>
|
||||
<select
|
||||
className="h-10 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
className="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm sm:w-auto"
|
||||
value={range}
|
||||
onChange={(e) => setRange(e.target.value as "week" | "month" | "custom")}
|
||||
>
|
||||
|
|
@ -189,8 +189,8 @@ export default function WorkloadsPage() {
|
|||
</select>
|
||||
{range === "custom" && (
|
||||
<>
|
||||
<Input type="date" className="w-[160px]" value={customFrom} onChange={(e) => setCustomFrom(e.target.value)} />
|
||||
<Input type="date" className="w-[160px]" value={customTo} onChange={(e) => setCustomTo(e.target.value)} />
|
||||
<Input type="date" className="w-full sm:w-44" value={customFrom} onChange={(e) => setCustomFrom(e.target.value)} />
|
||||
<Input type="date" className="w-full sm:w-44" value={customTo} onChange={(e) => setCustomTo(e.target.value)} />
|
||||
</>
|
||||
)}
|
||||
<Button variant="outline" onClick={exportExcelLike}>
|
||||
|
|
@ -211,7 +211,7 @@ export default function WorkloadsPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm text-muted-foreground">团队平均负载</CardTitle>
|
||||
|
|
@ -277,8 +277,9 @@ export default function WorkloadsPage() {
|
|||
) : items.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">暂无数据</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
|
||||
<Table className="min-w-[760px]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>成员</TableHead>
|
||||
<TableHead>角色</TableHead>
|
||||
|
|
@ -288,8 +289,8 @@ export default function WorkloadsPage() {
|
|||
<TableHead className="text-right">剩余工时</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((row) => (
|
||||
<TableRow key={row.user_id} className={row.workload_ratio > 100 ? "bg-red-50/40 dark:bg-red-900/10" : ""}>
|
||||
<TableCell>{row.user_name || row.username}</TableCell>
|
||||
|
|
@ -307,8 +308,9 @@ export default function WorkloadsPage() {
|
|||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -320,7 +322,7 @@ export default function WorkloadsPage() {
|
|||
<DialogDescription>预览 旧负责人 → 新负责人,确认后批量应用。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[420px] overflow-auto rounded-md border">
|
||||
<Table>
|
||||
<Table className="min-w-[900px]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>任务</TableHead>
|
||||
|
|
|
|||
Loading…
Reference in New Issue