执行全项目配置隐患与模型命名扫描清单

This commit is contained in:
Yuuuu 2026-03-11 10:07:51 +08:00
parent 054d056182
commit 331e9109e2
46 changed files with 1321 additions and 317 deletions

View File

@ -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`,默认开启);当模型超时/不可用/结构化解析失败时自动降级到规则链路,不中断主流程

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 分给张三。"

View File

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

View File

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

View File

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

View File

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

View File

@ -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,7 +238,11 @@ export function SSTCCharterStagePanel({
style={{ width: `${filledModulePercent}%` }}
/>
</div>
{mobileReadOnly && (
<p className="mt-2 text-xs text-amber-700">移动端只读模式:请在桌面端进行编辑与提交操作</p>
)}
</div>
{!mobileReadOnly && (
<div className="flex flex-wrap gap-2">
<Button
size="sm"
@ -261,6 +270,7 @@ export function SSTCCharterStagePanel({
发起对齐
</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,6 +285,7 @@ export function SSTCCharterStagePanel({
>
查看溯源
</Button>
{!mobileReadOnly && (
<Button
size="sm"
variant="ghost"
@ -284,6 +295,7 @@ export function SSTCCharterStagePanel({
>
确认对齐
</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,6 +423,7 @@ export function SSTCCharterStagePanel({
<p className="text-slate-400">{aiReferenceText}</p>
)}
</div>
{!mobileReadOnly && (
<div className="flex items-center gap-1">
<Button
size="sm"
@ -425,6 +450,7 @@ export function SSTCCharterStagePanel({
再次优化
</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,6 +481,7 @@ export function SSTCCharterStagePanel({
{alignmentQaAnswers[idx] || "AI 待补全"}
</p>
)}
{!mobileReadOnly && (
<div className="mt-2 flex justify-end">
<Button
size="sm"
@ -462,6 +492,7 @@ export function SSTCCharterStagePanel({
{editing ? "完成" : "编辑"}
</Button>
</div>
)}
</div>
);
}

View File

@ -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,16 +77,17 @@ export function SSTCExecutionBoardPanel({
{optimizeFeedback}
</p>
)}
<div className="grid gap-3 xl:grid-cols-5">
<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-[260px] rounded-md border bg-muted/20 p-2"
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-sm font-semibold">{lane.label}</p>
<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">
@ -96,7 +98,7 @@ export function SSTCExecutionBoardPanel({
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>
<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>
@ -127,6 +129,7 @@ export function SSTCExecutionBoardPanel({
</div>
))}
</div>
</div>
</CardContent>
</Card>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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)}
@ -133,5 +134,6 @@ export function SSTCStageNav({ stages, activeStage, signalPreview = [], onChange
})}
</div>
</div>
</div>
);
}

View File

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

View File

@ -243,7 +243,8 @@ export function SSTCTaskStagePanel({
</div>
</div>
)}
<Table>
<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">
@ -393,6 +394,7 @@ export function SSTCTaskStagePanel({
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,7 +91,8 @@ export default function DashboardPage() {
) : items.length === 0 ? (
<p className="text-muted-foreground">暂无数据</p>
) : (
<Table>
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
<Table className="min-w-[520px]">
<TableHeader>
<TableRow>
<TableHead>成员</TableHead>
@ -109,6 +110,7 @@ export default function DashboardPage() {
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,7 +66,8 @@ export default function SkillTasksPage() {
</p>
</div>
) : (
<Table>
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
<Table className="min-w-[720px]">
<TableHeader>
<TableRow>
<TableHead>任务</TableHead>
@ -105,6 +106,7 @@ export default function SkillTasksPage() {
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>

View File

@ -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,7 +169,8 @@ export default function SkillsPage() {
) : rows.length === 0 ? (
<p className="py-8 text-center text-muted-foreground">暂无技能标签</p>
) : (
<Table>
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
<Table className="min-w-[760px]">
<TableHeader>
<TableRow>
<TableHead>名称</TableHead>
@ -228,6 +229,7 @@ export default function SkillsPage() {
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>

View File

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

View File

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

View File

@ -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,7 +337,8 @@ export default function UsersPage() {
) : renderedUsers.length === 0 ? (
<p className="py-8 text-center text-muted-foreground">暂无用户</p>
) : (
<Table>
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
<Table className="min-w-[940px]">
<TableHeader>
<TableRow>
<TableHead className="w-10">
@ -457,6 +458,7 @@ export default function UsersPage() {
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>

View File

@ -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,7 +228,8 @@ export default function WorkloadDetailPage() {
) : tasks.length === 0 ? (
<p className="py-8 text-center text-muted-foreground">暂无任务</p>
) : (
<Table>
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
<Table className="min-w-[760px]">
<TableHeader>
<TableRow>
<TableHead>标题</TableHead>
@ -252,6 +253,7 @@ export default function WorkloadDetailPage() {
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>

View File

@ -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,7 +277,8 @@ export default function WorkloadsPage() {
) : items.length === 0 ? (
<p className="py-8 text-center text-muted-foreground">暂无数据</p>
) : (
<Table>
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
<Table className="min-w-[760px]">
<TableHeader>
<TableRow>
<TableHead>成员</TableHead>
@ -309,6 +310,7 @@ export default function WorkloadsPage() {
))}
</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>