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

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/ │ └── versions/
│ ├── 20260310_add_feishu_outbox_reply_hash.py # 飞书 outbox 回执哈希幂等字段 │ ├── 20260310_add_feishu_outbox_reply_hash.py # 飞书 outbox 回执哈希幂等字段
│ ├── 20260311_add_pg_trgm_text_indexes.py # pg_trgm 扩展与文本检索索引 │ ├── 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 依赖 ├── requirements.txt # Python 依赖
├── Dockerfile ├── Dockerfile
└── tests/ └── tests/
@ -152,3 +155,4 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- `agents.defaults.timeoutSeconds` 在 OpenClaw 网关行为未验证,当前以 `LLM_REQUEST_TIMEOUT` 环境变量为准 - `agents.defaults.timeoutSeconds` 在 OpenClaw 网关行为未验证,当前以 `LLM_REQUEST_TIMEOUT` 环境变量为准
- World Model 静态层已接入 Redis 缓存(key: `wm:static:{tenant_id}:{project_id}`,TTL 600s) - World Model 静态层已接入 Redis 缓存(key: `wm:static:{tenant_id}:{project_id}`,TTL 600s)
- 写操作后主动 invalidate,动态层不缓存 - 写操作后主动 invalidate,动态层不缓存
- AI PMO 意图识别已启用 LLM 主路径(`AI_PMO_INTENT_LLM_ENABLED`,默认开启);当模型超时/不可用/结构化解析失败时自动降级到规则链路,不中断主流程

View File

@ -9,8 +9,8 @@
"enabled": true, "enabled": true,
"priority": 300, "priority": 300,
"tier": "low", "tier": "low",
"provider": "openai", "provider": "glm",
"model": "zai/glm-4-flash", "model": "GLM-4.7",
"match_any": [ "match_any": [
"feishu_level_classification", "feishu_level_classification",
"feishu_signal_extraction", "feishu_signal_extraction",
@ -26,7 +26,7 @@
"priority": 220, "priority": 220,
"tier": "high", "tier": "high",
"provider": "openai", "provider": "openai",
"model": "minimax/MiniMax-M2.5", "model": "MiniMax-M2.5",
"match_any": [ "match_any": [
"adaptive_rag_answer", "adaptive_rag_answer",
"feishu_project_prefill", "feishu_project_prefill",
@ -49,7 +49,7 @@
"priority": 200, "priority": 200,
"tier": "high", "tier": "high",
"provider": "openai", "provider": "openai",
"model": "minimax/MiniMax-M2.5", "model": "MiniMax-M2.5",
"match_any": [ "match_any": [
"analyze_food_calories", "analyze_food_calories",
"feishu_new_project_brief", "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 # Full Adaptive RAG
# ============================================================================ # ============================================================================

View File

@ -61,6 +61,7 @@ from .decision_record import DecisionRecord
from .plan_bundle import PlanBundle from .plan_bundle import PlanBundle
from .reply_receipt import ReplyReceipt from .reply_receipt import ReplyReceipt
from .portfolio_snapshot import PortfolioSnapshot from .portfolio_snapshot import PortfolioSnapshot
from .conversation_session import ConversationSession
__all__ = [ __all__ = [
"Base", "Base",
@ -126,4 +127,5 @@ __all__ = [
"PlanBundle", "PlanBundle",
"ReplyReceipt", "ReplyReceipt",
"PortfolioSnapshot", "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, RecipeImageUploadRequest,
RecipeAnalyzeCaloriesResponse, RecipeAnalyzeCaloriesResponse,
) )
from .conversation_session import ConversationSessionBase, ConversationSessionRead
from . import ai as ai_schemas from . import ai as ai_schemas
__all__ = [ __all__ = [
@ -99,4 +100,6 @@ __all__ = [
"RecipeResponse", "RecipeResponse",
"RecipeImageUploadRequest", "RecipeImageUploadRequest",
"RecipeAnalyzeCaloriesResponse", "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 asyncio
import hashlib import hashlib
import json import json
import os
import re import re
import time import time
from dataclasses import dataclass from dataclasses import dataclass
@ -15,11 +16,13 @@ from sqlalchemy import and_, func, or_, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ..config.ai_prompts import PROMPT_AI_PMO_INTENT_CLASSIFICATION
from ..core.logging import get_logger from ..core.logging import get_logger
from ..database import AsyncSessionLocal from ..database import AsyncSessionLocal
from ..models import ( from ..models import (
AIProcessingLog, AIProcessingLog,
Budget, Budget,
ConversationSession,
DecisionRecord, DecisionRecord,
IngestEvent, IngestEvent,
Notification, Notification,
@ -61,6 +64,7 @@ from .context_cache import ContextCacheService
from .notification_service import NotificationService from .notification_service import NotificationService
from .openclaw_commander_service import OpenClawCommanderService from .openclaw_commander_service import OpenClawCommanderService
from .ai_pmo_domain_workflow import SignalSourceDomainWorkflow from .ai_pmo_domain_workflow import SignalSourceDomainWorkflow
from .llm import llm_service
from .signal_source_orchestration_service import SignalSourceOrchestrationService from .signal_source_orchestration_service import SignalSourceOrchestrationService
logger = get_logger(__name__) logger = get_logger(__name__)
@ -143,6 +147,37 @@ POLICY_GATE_RULES: tuple[tuple[str, tuple[str, ...]], ...] = (
("charter_rewrite", ("改写charter", "success criteria", "成功标准改", "范围重大变更")), ("charter_rewrite", ("改写charter", "success criteria", "成功标准改", "范围重大变更")),
("critical_path_change", ("关键路径", "里程碑改动", "影响多个项目", "跨项目依赖调整")), ("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_TTL_SECONDS = 10 * 60
WORLD_MODEL_STATIC_CACHE_KEY_PREFIX = "wm:static" 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) 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]: def _safe_int(value: Any) -> Optional[int]:
try: try:
if value is None or value == "": if value is None or value == "":
@ -1004,6 +1046,7 @@ class AIPMOKernelService:
source_message_id=synthetic_message_id, source_message_id=synthetic_message_id,
owner_user_id=_safe_int(getattr(operator, "id", None)), owner_user_id=_safe_int(getattr(operator, "id", None)),
) )
session = await self._get_or_create_session(tenant_id=self.tenant_id, sender=sender)
try: try:
world_model_query_tables: set[str] = set() world_model_query_tables: set[str] = set()
@ -1011,6 +1054,7 @@ class AIPMOKernelService:
message_text=clean_text, message_text=clean_text,
project_hint_id=project_hint_id, project_hint_id=project_hint_id,
query_tables=world_model_query_tables, query_tables=world_model_query_tables,
session=session,
) )
world_model = await self.build_world_model( world_model = await self.build_world_model(
tenant_id=self.tenant_id, tenant_id=self.tenant_id,
@ -1021,8 +1065,16 @@ class AIPMOKernelService:
) )
self._schedule_portfolio_snapshot_persist(world_model=world_model) self._schedule_portfolio_snapshot_persist(world_model=world_model)
work_item.state = STATE_MODELED 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 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, text=clean_text,
project_id=_safe_int(world_model.get("resolved_project_id")), project_id=_safe_int(world_model.get("resolved_project_id")),
) )
@ -1041,6 +1093,13 @@ class AIPMOKernelService:
processing_status="success", processing_status="success",
duration_ms=int((time.perf_counter() - started) * 1000), 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() await self.db.commit()
return { return {
"handled": True, "handled": True,
@ -1138,6 +1197,13 @@ class AIPMOKernelService:
processing_status="success", processing_status="success",
duration_ms=int((time.perf_counter() - started) * 1000), 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() await self.db.commit()
return self._build_capture_result( return self._build_capture_result(
work_item=work_item, 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", processing_status="success" if work_item.state in {STATE_SUCCEEDED, STATE_WAITING_MANUAL} else "failed",
duration_ms=int((time.perf_counter() - started) * 1000), 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() await self.db.commit()
return self._build_capture_result( return self._build_capture_result(
work_item=work_item, work_item=work_item,
@ -1558,12 +1633,14 @@ class AIPMOKernelService:
message_text: str, message_text: str,
sender: str, sender: str,
project_hint_id: Optional[int], project_hint_id: Optional[int],
session: Optional[ConversationSession] = None,
) -> WorldModel: ) -> WorldModel:
query_tables: set[str] = set() query_tables: set[str] = set()
project = await self._resolve_project( project = await self._resolve_project(
message_text=message_text, message_text=message_text,
project_hint_id=project_hint_id, project_hint_id=project_hint_id,
query_tables=query_tables, query_tables=query_tables,
session=session,
) )
return await self.build_world_model( return await self.build_world_model(
tenant_id=self.tenant_id, tenant_id=self.tenant_id,
@ -1584,6 +1661,7 @@ class AIPMOKernelService:
message_text: str, message_text: str,
project_hint_id: Optional[int], project_hint_id: Optional[int],
query_tables: Optional[set[str]] = None, query_tables: Optional[set[str]] = None,
session: Optional[ConversationSession] = None,
) -> Optional[Project]: ) -> Optional[Project]:
if project_hint_id is not None: if project_hint_id is not None:
self._record_world_model_query_table(query_tables, "projects") 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() normalized_candidate = re.sub(r"\s+", "", str(candidate or "")).lower()
if normalized_candidate and normalized_candidate in normalized_message: if normalized_candidate and normalized_candidate in normalized_message:
return project 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 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( async def _build_portfolio_summary(
self, self,
*, *,

View File

@ -215,6 +215,17 @@ STRUCTURED_OBJECT_SCHEMAS: dict[str, dict[str, Any]] = {
"next_actions": "list", "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) return isinstance(value, list)
if expected == "number": if expected == "number":
return isinstance(value, (int, float)) and not isinstance(value, bool) 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": if expected == "dict_or_none":
return value is None or isinstance(value, dict) return value is None or isinstance(value, dict)
return True return True

View File

@ -33,7 +33,7 @@ from .prompt_profile_service import prompt_profile_service
from .llm import llm_service from .llm import llm_service
SOURCE_ONBOARDING_ACTION_RE = re.compile( 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, re.IGNORECASE,
) )
SOURCE_KIND_HINTS: tuple[tuple[str, tuple[str, ...]], ...] = ( SOURCE_KIND_HINTS: tuple[tuple[str, tuple[str, ...]], ...] = (
@ -66,9 +66,12 @@ class SignalSourceOrchestrationService:
@classmethod @classmethod
def looks_like_signal_source_request(cls, text: str) -> bool: def looks_like_signal_source_request(cls, text: str) -> bool:
normalized = re.sub(r"\s+", "", str(text or "")) normalized = re.sub(r"\s+", "", str(text or ""))
if len(normalized) < 6: if len(normalized) < 3:
return False 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 True
return bool(SOURCE_ONBOARDING_ACTION_RE.search(normalized)) 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 import and_, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine 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_cadence_service import AIPMOCadenceService
from app.services.ai_pmo_kernel_service import ( from app.services.ai_pmo_kernel_service import (
AIPMOKernelService, AIPMOKernelService,
@ -15,11 +29,14 @@ from app.services.ai_pmo_kernel_service import (
IntentResolver, IntentResolver,
) )
from app.services.ai_pmo_domain_workflow import SignalSourceDomainWorkflow from app.services.ai_pmo_domain_workflow import SignalSourceDomainWorkflow
from app.services.llm import StructuredExtractionResult
from app.services.signal_source_orchestration_service import SignalSourceOrchestrationService from app.services.signal_source_orchestration_service import SignalSourceOrchestrationService
class AIPMOKernelServiceTest(unittest.IsolatedAsyncioTestCase): class AIPMOKernelServiceTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None: 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 = NamedTemporaryFile(prefix="pmflow-ai-pmo-test-", suffix=".db", delete=False)
tmp.close() tmp.close()
self.db_path = tmp.name self.db_path = tmp.name
@ -149,6 +166,10 @@ class AIPMOKernelServiceTest(unittest.IsolatedAsyncioTestCase):
async def asyncTearDown(self) -> None: async def asyncTearDown(self) -> None:
await self.engine.dispose() 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): if getattr(self, "db_path", None):
try: try:
os.remove(self.db_path) os.remove(self.db_path)
@ -254,6 +275,187 @@ class AIPMOKernelServiceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(second["status"], "ignored") self.assertEqual(second["status"], "ignored")
self.assertFalse(second["direct_reply_required"]) 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 def test_policy_gate_blocks_archive_like_request(self) -> None:
async with self.session_factory() as db: async with self.session_factory() as db:
service = AIPMOKernelService(db, tenant_id=1) service = AIPMOKernelService(db, tenant_id=1)

View File

@ -118,6 +118,11 @@ class SignalSourceOrchestrationServiceTest(unittest.TestCase):
"请新增一个 GitHub 信号源,监控核心仓库 issue。" "请新增一个 GitHub 信号源,监控核心仓库 issue。"
) )
) )
self.assertTrue(
SignalSourceOrchestrationService.looks_like_signal_source_request(
"给这个项目加个信号源。"
)
)
self.assertFalse( self.assertFalse(
SignalSourceOrchestrationService.looks_like_signal_source_request( SignalSourceOrchestrationService.looks_like_signal_source_request(
"把任务 12 分给张三。" "把任务 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 { Sidebar } from "./Sidebar";
import { TopBar } from "./TopBar"; import { TopBar } from "./TopBar";
export default function Layout() { export default function Layout() {
const location = useLocation();
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
useEffect(() => {
setMobileSidebarOpen(false);
}, [location.pathname, location.search]);
return ( return (
<div className="flex h-screen bg-[#F3F5F7]"> <div className="relative flex h-screen overflow-hidden bg-[#F3F5F7]">
<Sidebar /> <Sidebar open={mobileSidebarOpen} onClose={() => setMobileSidebarOpen(false)} />
<div className="flex flex-1 flex-col overflow-hidden"> {mobileSidebarOpen && (
<TopBar /> <button
<main className="flex-1 overflow-auto bg-[#F3F5F7] p-6"> 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 /> <Outlet />
</main> </main>
</div> </div>

View File

@ -9,6 +9,7 @@ import {
Users, Users,
Sparkles, Sparkles,
Sliders, Sliders,
X,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { isManagerRole } from "@/lib/rbac"; import { isManagerRole } from "@/lib/rbac";
@ -25,7 +26,12 @@ const baseNavItems = [
{ to: "/skills", label: "能力管理", icon: BarChart3 }, { 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 { theme, toggleTheme } = useTheme();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const isManager = isManagerRole(user?.role); const isManager = isManagerRole(user?.role);
@ -34,21 +40,37 @@ export function Sidebar() {
: baseNavItems; : baseNavItems;
return ( 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-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"> <div className="flex h-9 w-9 items-center justify-center rounded-xl bg-blue-600 text-sm font-bold text-white">
PM PM
</div> </div>
<div> <div className="min-w-0">
<span className="text-base font-bold tracking-tight text-slate-900">PMFlow</span> <span className="text-base font-bold tracking-tight text-slate-900">PMFlow</span>
<p className="text-[11px] text-slate-500">AI 原生项目操作台</p> <p className="text-[11px] text-slate-500">AI 原生项目操作台</p>
</div> </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> </div>
<nav className="mt-4 flex-1 space-y-1"> <nav className="mt-4 flex-1 space-y-1">
{navItems.map(({ to, label, icon: Icon }) => ( {navItems.map(({ to, label, icon: Icon }) => (
<NavLink <NavLink
key={to} key={to}
to={to} to={to}
onClick={onClose}
className={({ isActive }) => className={({ isActive }) =>
cn( cn(
"flex items-center gap-3 rounded-2xl px-4 py-3 text-sm font-medium transition-all", "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 { 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 { Link, useLocation, useNavigate } from "react-router-dom";
import { aiPmoApi, type AIPMOWorkItemListPayload } from "@/api/aiPmo"; import { aiPmoApi, type AIPMOWorkItemListPayload } from "@/api/aiPmo";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
@ -49,6 +49,10 @@ const SECTION_CHROME: Record<InboxDisplayKind, string> = {
info: "bg-slate-100 text-slate-600", info: "bg-slate-100 text-slate-600",
}; };
interface TopBarProps {
onMenuToggle: () => void;
}
function formatRelativeTime(value?: string | null) { function formatRelativeTime(value?: string | null) {
if (!value) return "-"; if (!value) return "-";
const date = parseBackendDate(value); const date = parseBackendDate(value);
@ -63,7 +67,7 @@ function formatRelativeTime(value?: string | null) {
return `${Math.floor(diffMs / day)} 天前`; return `${Math.floor(diffMs / day)} 天前`;
} }
export function TopBar() { export function TopBar({ onMenuToggle }: TopBarProps) {
const { user, logout } = useAuthStore(); const { user, logout } = useAuthStore();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
@ -142,14 +146,24 @@ export function TopBar() {
const badgeCount = inboxData.leaderSummary.decision_count + notifications.unread_count; const badgeCount = inboxData.leaderSummary.decision_count + notifications.unread_count;
return ( 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)]"> <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 items-center gap-4"> <div className="flex min-w-0 items-center gap-2 sm:gap-4">
<div> <Button
<div className="text-sm font-semibold text-slate-900">PMFlow</div> type="button"
<div className="text-xs text-slate-500">AI 原生项目管理操作台</div> 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> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-1 sm:gap-2">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" title={zhCN.bell.title} className="relative"> <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}> <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> <DialogHeader>
<DialogTitle>AI 处理日志详情</DialogTitle> <DialogTitle>AI 处理日志详情</DialogTitle>
<DialogDescription> <DialogDescription>

View File

@ -60,6 +60,8 @@ interface ModuleFieldEditState {
interface SSTCCharterStagePanelProps { interface SSTCCharterStagePanelProps {
charters: SSTCCharter[]; charters: SSTCCharter[];
selectedCharter: SSTCCharter | null; selectedCharter: SSTCCharter | null;
isMobileViewport: boolean;
readOnly: boolean;
charterDetail?: CharterDetailShape; charterDetail?: CharterDetailShape;
charterDraftTitle: string; charterDraftTitle: string;
charterModuleDraft: { charterModuleDraft: {
@ -118,6 +120,8 @@ interface SSTCCharterStagePanelProps {
export function SSTCCharterStagePanel({ export function SSTCCharterStagePanel({
charters, charters,
selectedCharter, selectedCharter,
isMobileViewport,
readOnly,
charterDetail, charterDetail,
charterDraftTitle, charterDraftTitle,
charterModuleDraft, charterModuleDraft,
@ -157,6 +161,7 @@ export function SSTCCharterStagePanel({
) )
); );
const sourceSummary = sourceNames.length > 0 ? sourceNames.join("/") : "未绑定"; const sourceSummary = sourceNames.length > 0 ? sourceNames.join("/") : "未绑定";
const mobileReadOnly = isMobileViewport && readOnly;
const autoFields = activeModule.fields.filter((field) => field.editable === false); const autoFields = activeModule.fields.filter((field) => field.editable === false);
const prevModule = activeModuleIndex > 0 ? CHARTER_MODULE_SCHEMAS[activeModuleIndex - 1] : null; const prevModule = activeModuleIndex > 0 ? CHARTER_MODULE_SCHEMAS[activeModuleIndex - 1] : null;
const nextModule = const nextModule =
@ -233,34 +238,39 @@ export function SSTCCharterStagePanel({
style={{ width: `${filledModulePercent}%` }} style={{ width: `${filledModulePercent}%` }}
/> />
</div> </div>
{mobileReadOnly && (
<p className="mt-2 text-xs text-amber-700">移动端只读模式:请在桌面端进行编辑与提交操作</p>
)}
</div> </div>
<div className="flex flex-wrap gap-2"> {!mobileReadOnly && (
<Button <div className="flex flex-wrap gap-2">
size="sm" <Button
variant="outline" size="sm"
className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50" variant="outline"
disabled={!selectedCharter || updateCharterMutation.isPending} className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
onClick={() => { disabled={!selectedCharter || updateCharterMutation.isPending}
if (!selectedCharter) return; onClick={() => {
updateCharterMutation.mutate({ if (!selectedCharter) return;
charterId: selectedCharter.id, updateCharterMutation.mutate({
title: compactTitle(charterDraftTitle || selectedCharter.title, 24), charterId: selectedCharter.id,
content: serializedCharterContent, title: compactTitle(charterDraftTitle || selectedCharter.title, 24),
}); content: serializedCharterContent,
}} });
> }}
{updateCharterMutation.isPending ? "保存中…" : "更新版本"} >
</Button> {updateCharterMutation.isPending ? "保存中…" : "更新版本"}
<Button </Button>
size="sm" <Button
className="bg-blue-600 text-white hover:bg-blue-700" size="sm"
disabled={!selectedCharter} className="bg-blue-600 text-white hover:bg-blue-700"
onClick={() => selectedCharter && startAlignmentMutation.mutate(selectedCharter.id)} disabled={!selectedCharter}
> onClick={() => selectedCharter && startAlignmentMutation.mutate(selectedCharter.id)}
<PlayCircle className="mr-2 h-4 w-4" /> >
发起对齐 <PlayCircle className="mr-2 h-4 w-4" />
</Button> 发起对齐
</div> </Button>
</div>
)}
</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"> <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>
<Button {!mobileReadOnly && (
size="sm" <Button
variant="ghost" size="sm"
className="h-7 px-2 text-xs text-slate-600 hover:bg-blue-50 hover:text-blue-700" variant="ghost"
disabled={!selectedCharter} className="h-7 px-2 text-xs text-slate-600 hover:bg-blue-50 hover:text-blue-700"
onClick={() => selectedCharter && confirmAlignmentMutation.mutate(selectedCharter.id)} disabled={!selectedCharter}
> onClick={() => selectedCharter && confirmAlignmentMutation.mutate(selectedCharter.id)}
确认对齐 >
</Button> 确认对齐
</Button>
)}
</div> </div>
</div> </div>
@ -344,9 +356,13 @@ export function SSTCCharterStagePanel({
<section className="space-y-4"> <section className="space-y-4">
<Input <Input
value={charterDraftTitle || selectedCharter?.title || ""} value={charterDraftTitle || selectedCharter?.title || ""}
onChange={(e) => setCharterDraftTitle(e.target.value)} onChange={(e) => {
if (mobileReadOnly) return;
setCharterDraftTitle(e.target.value);
}}
placeholder="宪章标题(自动精简)" 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"> <div className="rounded-xl bg-[#F8F9FA] p-4">
@ -378,16 +394,24 @@ export function SSTCCharterStagePanel({
<Textarea <Textarea
rows={field.rows || 3} rows={field.rows || 3}
value={charterModuleDraft.modules[activeModule.key][field.key] || ""} 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} 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 <Input
value={charterModuleDraft.modules[activeModule.key][field.key] || ""} 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} 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]"> <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> <p className="text-slate-400">{aiReferenceText}</p>
)} )}
</div> </div>
<div className="flex items-center gap-1"> {!mobileReadOnly && (
<Button <div className="flex items-center gap-1">
size="sm" <Button
variant="ghost" size="sm"
className="h-7 w-7 p-0 text-blue-700 hover:bg-blue-50" variant="ghost"
disabled={!selectedCharter || aiPrefillCharterMutation.isPending} className="h-7 w-7 p-0 text-blue-700 hover:bg-blue-50"
onClick={() => disabled={!selectedCharter || aiPrefillCharterMutation.isPending}
selectedCharter && onClick={() =>
aiPrefillCharterMutation.mutate({ charterId: selectedCharter.id, force: true }) selectedCharter &&
} aiPrefillCharterMutation.mutate({ charterId: selectedCharter.id, force: true })
> }
<Sparkles className="h-3.5 w-3.5" /> >
</Button> <Sparkles className="h-3.5 w-3.5" />
<Button </Button>
size="sm" <Button
variant="ghost" size="sm"
className="h-7 px-2 text-[11px] text-slate-600 hover:bg-slate-100" variant="ghost"
disabled={!selectedCharter || aiPrefillCharterMutation.isPending} className="h-7 px-2 text-[11px] text-slate-600 hover:bg-slate-100"
onClick={() => disabled={!selectedCharter || aiPrefillCharterMutation.isPending}
selectedCharter && onClick={() =>
aiPrefillCharterMutation.mutate({ charterId: selectedCharter.id, force: true }) selectedCharter &&
} aiPrefillCharterMutation.mutate({ charterId: selectedCharter.id, force: true })
> }
再次优化 >
</Button> 再次优化
</div> </Button>
</div>
)}
</div> </div>
</div> </div>
); );
@ -440,11 +466,14 @@ export function SSTCCharterStagePanel({
return ( return (
<div key={`qa-${idx}`} className="rounded-lg bg-white p-2 shadow-[0_1px_2px_rgba(15,23,42,0.08)]"> <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> <p className="text-xs font-medium text-slate-700">Q{idx + 1} {question}</p>
{editing ? ( {editing && !mobileReadOnly ? (
<Textarea <Textarea
rows={2} rows={2}
value={alignmentQaAnswers[idx] || ""} 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" className="mt-2 border-slate-200 bg-white text-xs"
/> />
) : ( ) : (
@ -452,16 +481,18 @@ export function SSTCCharterStagePanel({
{alignmentQaAnswers[idx] || "AI 待补全"} {alignmentQaAnswers[idx] || "AI 待补全"}
</p> </p>
)} )}
<div className="mt-2 flex justify-end"> {!mobileReadOnly && (
<Button <div className="mt-2 flex justify-end">
size="sm" <Button
variant="ghost" size="sm"
className="h-6 px-2 text-[11px] text-slate-600 hover:bg-slate-100" variant="ghost"
onClick={() => setEditingAlignmentQaIndex(editing ? null : idx)} className="h-6 px-2 text-[11px] text-slate-600 hover:bg-slate-100"
> onClick={() => setEditingAlignmentQaIndex(editing ? null : idx)}
{editing ? "完成" : "编辑"} >
</Button> {editing ? "完成" : "编辑"}
</div> </Button>
</div>
)}
</div> </div>
); );
} }

View File

@ -37,15 +37,15 @@ export function SSTCExecutionBoardPanel({
}: SSTCExecutionBoardPanelProps) { }: SSTCExecutionBoardPanelProps) {
return ( return (
<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">
<div> <div className="min-w-0">
<CardTitle className="text-lg">执行看板</CardTitle> <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>
<div className="flex flex-wrap gap-2"> <div className="grid w-full gap-2 sm:flex sm:w-auto sm:flex-wrap">
<Button <Button
size="sm" 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} disabled={stageLocked || optimizeWorkloadMutation.isPending}
onClick={() => optimizeWorkloadMutation.mutate("rebalance")} onClick={() => optimizeWorkloadMutation.mutate("rebalance")}
> >
@ -54,12 +54,13 @@ export function SSTCExecutionBoardPanel({
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
className="w-full sm:w-auto"
disabled={stageLocked || optimizeWorkloadMutation.isPending} disabled={stageLocked || optimizeWorkloadMutation.isPending}
onClick={() => optimizeWorkloadMutation.mutate("unassigned")} onClick={() => optimizeWorkloadMutation.mutate("unassigned")}
> >
AI 仅分配未指派 AI 仅分配未指派
</Button> </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" /> <RefreshCcw className="mr-2 h-4 w-4" />
刷新 刷新
</Button> </Button>
@ -76,56 +77,58 @@ export function SSTCExecutionBoardPanel({
{optimizeFeedback} {optimizeFeedback}
</p> </p>
)} )}
<div className="grid gap-3 xl:grid-cols-5"> <div className="-mx-1 overflow-x-auto pb-2 [-webkit-overflow-scrolling:touch]">
{EXECUTION_LANES.map((lane) => ( <div className="grid min-w-[920px] grid-cols-5 gap-3 px-1">
<div {EXECUTION_LANES.map((lane) => (
key={lane.key} <div
className="min-h-[260px] rounded-md border bg-muted/20 p-2" key={lane.key}
onDragOver={(e) => e.preventDefault()} className="min-h-[240px] rounded-md border bg-muted/20 p-2"
onDrop={(e) => handleDropTask(lane.key, e)} 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> <div className="mb-2 flex items-center justify-between">
<Badge variant="outline">{executionLanes[lane.key].length}</Badge> <p className="text-xs font-semibold sm:text-sm">{lane.label}</p>
</div> <Badge variant="outline">{executionLanes[lane.key].length}</Badge>
<div className="space-y-2"> </div>
{executionLanes[lane.key].map((t) => ( <div className="space-y-2">
<div {executionLanes[lane.key].map((t) => (
key={t.id} <div
draggable={!stageLocked} key={t.id}
onDragStart={(e) => e.dataTransfer.setData("taskId", t.id)} draggable={!stageLocked}
className="cursor-grab rounded-md border bg-card p-2 active:cursor-grabbing" 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"> <p className="line-clamp-2 text-xs font-medium sm:text-sm">{t.title}</p>
<Badge className={`border text-[10px] ${priorityBadgeClass(t.priority)}`}>{toZhPriority(t.priority)}</Badge> <div className="mt-1 flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">{t.due_date ? t.due_date.slice(0, 10) : "-"}</span> <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>
<div className="mt-2 flex gap-1"> ))}
<Button </div>
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> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -55,11 +55,11 @@ export function SSTCGlobalHeader({
return ( return (
<div className="grid gap-4 xl:grid-cols-12"> <div className="grid gap-4 xl:grid-cols-12">
<Card className="xl:col-span-3"> <Card className="xl:col-span-3">
<CardHeader className="pb-3"> <CardHeader className="min-w-0 pb-3">
<CardTitle className="text-xl" title={projectLabel}> <CardTitle className="text-xl" title={projectLabel}>
{compactProjectTitle} {compactProjectTitle}
</CardTitle> </CardTitle>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Badge variant={statusVariant(overview.project.status)}> <Badge variant={statusVariant(overview.project.status)}>
{toZhStatus(overview.project.status)} {toZhStatus(overview.project.status)}
</Badge> </Badge>
@ -75,7 +75,7 @@ export function SSTCGlobalHeader({
</CardContent> </CardContent>
</Card> </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> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-base">全局认知对齐度</CardTitle> <CardTitle className="text-base">全局认知对齐度</CardTitle>
@ -102,7 +102,7 @@ export function SSTCGlobalHeader({
</PieChart> </PieChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </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)}% {Math.round(overview.alignment_score || 0)}%
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@ -116,7 +116,7 @@ export function SSTCGlobalHeader({
<CardTitle className="text-base">项目整体进度</CardTitle> <CardTitle className="text-base">项目整体进度</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <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-2 overflow-hidden rounded-full bg-muted">
<div className="h-full bg-primary" style={{ width: `${Math.min(progress, 100)}%` }} /> <div className="h-full bg-primary" style={{ width: `${Math.min(progress, 100)}%` }} />
</div> </div>
@ -134,7 +134,7 @@ export function SSTCGlobalHeader({
<CardTitle className="text-base">团队负载健康度</CardTitle> <CardTitle className="text-base">团队负载健康度</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <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)}% {(ratio * 100).toFixed(0)}%
</p> </p>
<div className="h-2 overflow-hidden rounded-full bg-muted"> <div className="h-2 overflow-hidden rounded-full bg-muted">

View File

@ -227,7 +227,7 @@ export function SSTCReleaseStagePanel({
</div> </div>
{verificationItems.length > 0 && ( {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) => { {verificationItems.slice(0, 8).map((item) => {
const itemId = String(item.id || ""); const itemId = String(item.id || "");
const checked = String(item.status || "").toLowerCase() === "done"; 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> <div className="mt-3 rounded-lg bg-slate-50 p-2 text-xs text-slate-600">{summary}</div>
{improvementDrafts.length > 0 && ( {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) => { {improvementDrafts.map((item, idx) => {
const title = String(item.title || "-"); const title = String(item.title || "-");
const hasTask = !!item.task_id; const hasTask = !!item.task_id;

View File

@ -18,10 +18,10 @@ export function SSTCRiskFloatPanel({ risks, onJump }: SSTCRiskFloatPanelProps) {
if (minimized) { if (minimized) {
return ( return (
<div className="fixed bottom-4 right-4 z-40"> <div className="fixed bottom-4 right-2 z-40 sm:right-4">
<Button <Button
variant="default" variant="default"
className="relative" className="relative w-[min(220px,calc(100vw-1rem))] justify-center"
onClick={() => setMinimized(false)} onClick={() => setMinimized(false)}
title="打开预警中心" title="打开预警中心"
> >
@ -38,7 +38,7 @@ export function SSTCRiskFloatPanel({ risks, onJump }: SSTCRiskFloatPanelProps) {
} }
return ( 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"> <div className="flex items-center justify-between border-b px-3 py-2">
<p className="text-sm font-semibold">项目预警中心</p> <p className="text-sm font-semibold">项目预警中心</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@ -204,7 +204,7 @@ export function SSTCSignalStagePanel({
重置筛选 重置筛选
</Button> </Button>
</div> </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 <Input
value={signalFilters.keyword} value={signalFilters.keyword}
onChange={(e) => onSignalFiltersChange({ keyword: e.target.value })} onChange={(e) => onSignalFiltersChange({ keyword: e.target.value })}
@ -262,7 +262,7 @@ export function SSTCSignalStagePanel({
/> />
</div> </div>
</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"> <div className="rounded-xl bg-slate-50 px-3 py-2">
<p className="text-[11px] text-slate-500">当前页信号</p> <p className="text-[11px] text-slate-500">当前页信号</p>
<p className="text-lg font-semibold text-slate-900">{displaySignals.length}</p> <p className="text-lg font-semibold text-slate-900">{displaySignals.length}</p>
@ -285,8 +285,8 @@ export function SSTCSignalStagePanel({
<AIVisibilityPanel projectId={projectId} /> <AIVisibilityPanel projectId={projectId} />
</div> </div>
<div className="overflow-hidden rounded-xl border border-slate-200"> <div className="overflow-x-auto rounded-xl border border-slate-200 [-webkit-overflow-scrolling:touch]">
<Table> <Table className="min-w-[1080px]">
<TableHeader className="bg-slate-50"> <TableHeader className="bg-slate-50">
<TableRow> <TableRow>
<TableHead>优先级</TableHead> <TableHead>优先级</TableHead>

View File

@ -57,7 +57,8 @@ function stageProgressValue(status: string): number {
export function SSTCStageNav({ stages, activeStage, signalPreview = [], onChange }: SSTCStageNavProps) { export function SSTCStageNav({ stages, activeStage, signalPreview = [], onChange }: SSTCStageNavProps) {
return ( 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="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) => { {stages.map((stage, idx) => {
const displayStatus = toZhStatus(stage.status); const displayStatus = toZhStatus(stage.status);
const done = isDoneStatus(displayStatus); const done = isDoneStatus(displayStatus);
@ -66,7 +67,7 @@ export function SSTCStageNav({ stages, activeStage, signalPreview = [], onChange
const showSignalHover = stage.key === "signal" && signalPreview.length > 0; const showSignalHover = stage.key === "signal" && signalPreview.length > 0;
const unlockHint = stage.locked ? `解锁条件:完成上游阶段(${stage.summary || "请按链路顺序推进"})` : "已解锁"; const unlockHint = stage.locked ? `解锁条件:完成上游阶段(${stage.summary || "请按链路顺序推进"})` : "已解锁";
return ( 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 <button
type="button" type="button"
onClick={() => onChange(stage.key)} onClick={() => onChange(stage.key)}
@ -131,6 +132,7 @@ export function SSTCStageNav({ stages, activeStage, signalPreview = [], onChange
</div> </div>
); );
})} })}
</div>
</div> </div>
</div> </div>
); );

View File

@ -71,7 +71,7 @@ export function SSTCTaskCreateDialog({
</Button> </Button>
<Dialog open={newTaskOpen} onOpenChange={setNewTaskOpen}> <Dialog open={newTaskOpen} onOpenChange={setNewTaskOpen}>
<DialogContent> <DialogContent className="w-[min(560px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] sm:max-w-[560px]">
<DialogHeader> <DialogHeader>
<DialogTitle>新建任务</DialogTitle> <DialogTitle>新建任务</DialogTitle>
<DialogDescription>任务将绑定当前宪章(SSTC 强制)</DialogDescription> <DialogDescription>任务将绑定当前宪章(SSTC 强制)</DialogDescription>
@ -79,7 +79,7 @@ export function SSTCTaskCreateDialog({
<div className="space-y-3"> <div className="space-y-3">
<Input value={newTaskTitle} onChange={(e) => setNewTaskTitle(e.target.value)} placeholder="任务标题" /> <Input value={newTaskTitle} onChange={(e) => setNewTaskTitle(e.target.value)} placeholder="任务标题" />
<Textarea value={newTaskDesc} onChange={(e) => setNewTaskDesc(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 <select
className="rounded-md border bg-background px-2 py-2 text-sm" className="rounded-md border bg-background px-2 py-2 text-sm"
value={newTaskPriority} value={newTaskPriority}

View File

@ -243,8 +243,9 @@ export function SSTCTaskStagePanel({
</div> </div>
</div> </div>
)} )}
<Table> <div className="overflow-x-auto rounded-xl border border-slate-200 [-webkit-overflow-scrolling:touch]">
<TableHeader> <Table className="min-w-[980px]">
<TableHeader>
<TableRow> <TableRow>
<TableHead className="w-10 text-center"> <TableHead className="w-10 text-center">
<input <input
@ -263,8 +264,8 @@ export function SSTCTaskStagePanel({
<TableHead className="text-right">截止日期</TableHead> <TableHead className="text-right">截止日期</TableHead>
<TableHead className="text-right">操作</TableHead> <TableHead className="text-right">操作</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{charterTasks.map((t) => { {charterTasks.map((t) => {
const recs = taskRecommendations[t.id] || []; const recs = taskRecommendations[t.id] || [];
const status = String(t.status || "").toLowerCase(); const status = String(t.status || "").toLowerCase();
@ -391,8 +392,9 @@ export function SSTCTaskStagePanel({
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div>
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@ -6,7 +6,12 @@ import { SSTCReleaseStagePanel } from "@/components/sstc/SSTCReleaseStagePanel";
import { SSTCReviewStagePanel } from "@/components/sstc/SSTCReviewStagePanel"; import { SSTCReviewStagePanel } from "@/components/sstc/SSTCReviewStagePanel";
import { useSSTCWorkspaceOrchestration, type SSTCWorkspaceProps } from "@/components/sstc/useSSTCWorkspaceOrchestration"; 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 orchestration = useSSTCWorkspaceOrchestration(props);
const signalProps = { const signalProps = {
@ -60,6 +65,8 @@ export function SSTCWorkspace(props: SSTCWorkspaceProps) {
setShowAllAlignmentQa: orchestration.setShowAllAlignmentQa, setShowAllAlignmentQa: orchestration.setShowAllAlignmentQa,
setEditingAlignmentQaIndex: orchestration.setEditingAlignmentQaIndex, setEditingAlignmentQaIndex: orchestration.setEditingAlignmentQaIndex,
setActiveModuleKey: orchestration.setActiveModuleKey, setActiveModuleKey: orchestration.setActiveModuleKey,
isMobileViewport: props.isMobileViewport,
readOnly: props.charterReadOnly,
onSelectCharter: props.onSelectCharter, onSelectCharter: props.onSelectCharter,
updateModuleField: orchestration.updateModuleField, updateModuleField: orchestration.updateModuleField,
updateAlignmentQaAnswer: orchestration.updateAlignmentQaAnswer, updateAlignmentQaAnswer: orchestration.updateAlignmentQaAnswer,

View File

@ -115,7 +115,7 @@ export function SignalContentHoverCard({
</button> </button>
<Dialog open={open} onOpenChange={setOpen}> <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> <DialogHeader>
<DialogTitle className="text-xl text-slate-900">{detailTitle}</DialogTitle> <DialogTitle className="text-xl text-slate-900">{detailTitle}</DialogTitle>
<DialogDescription className="text-slate-500"> <DialogDescription className="text-slate-500">

View File

@ -756,7 +756,7 @@ export default function AIConfigPage() {
// ==================== Render ==================== // ==================== Render ====================
return ( 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"> <div className="max-w-6xl mx-auto">
{/* Header */} {/* Header */}
<div className="text-center mb-8"> <div className="text-center mb-8">
@ -805,17 +805,17 @@ export default function AIConfigPage() {
{/* Main Content */} {/* Main Content */}
<Tabs value={activeTab} onValueChange={setActiveTab}> <Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-5"> <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">集成源概览</TabsTrigger> <TabsTrigger value="overview" className="shrink-0 whitespace-nowrap">集成源概览</TabsTrigger>
<TabsTrigger value="configure">快速配置</TabsTrigger> <TabsTrigger value="configure" className="shrink-0 whitespace-nowrap">快速配置</TabsTrigger>
<TabsTrigger value="knowledge">知识库</TabsTrigger> <TabsTrigger value="knowledge" className="shrink-0 whitespace-nowrap">知识库</TabsTrigger>
<TabsTrigger value="advanced">高级设置</TabsTrigger> <TabsTrigger value="advanced" className="shrink-0 whitespace-nowrap">高级设置</TabsTrigger>
<TabsTrigger value="prompts">Prompt 设置</TabsTrigger> <TabsTrigger value="prompts" className="shrink-0 whitespace-nowrap">Prompt 设置</TabsTrigger>
</TabsList> </TabsList>
{/* Overview Tab */} {/* Overview Tab */}
<TabsContent value="overview" className="mt-6"> <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) => { {integrationSources.map((source) => {
const Icon = source.icon; const Icon = source.icon;
return ( return (
@ -991,7 +991,7 @@ export default function AIConfigPage() {
))} ))}
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div> <div>
<Label htmlFor="feishu_app_id">应用编号 *</Label> <Label htmlFor="feishu_app_id">应用编号 *</Label>
<Input <Input
@ -1145,7 +1145,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> <div>
<Label htmlFor="wecom_corp_id">企业编号 *</Label> <Label htmlFor="wecom_corp_id">企业编号 *</Label>
<Input <Input
@ -1175,7 +1175,7 @@ export default function AIConfigPage() {
onChange={(e) => setWecomConfig({ ...wecomConfig, app_secret: e.target.value })} onChange={(e) => setWecomConfig({ ...wecomConfig, app_secret: e.target.value })}
/> />
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div> <div>
<Label htmlFor="wecom_token">验证令牌 *</Label> <Label htmlFor="wecom_token">验证令牌 *</Label>
<Input <Input

View File

@ -485,7 +485,7 @@ export default function AIStewardPage() {
// ==================== Render ==================== // ==================== Render ====================
return ( 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"> <div className="max-w-7xl mx-auto">
{/* Header */} {/* Header */}
<div className="text-center mb-8"> <div className="text-center mb-8">
@ -501,7 +501,7 @@ export default function AIStewardPage() {
</div> </div>
{/* Feature Overview Cards */} {/* 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")}> <Card className="border-blue-200 dark:border-blue-800 hover:shadow-lg transition-shadow cursor-pointer" onClick={() => setActiveTab("signals")}>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -567,16 +567,16 @@ export default function AIStewardPage() {
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="pt-6">
<Tabs value={activeTab} onValueChange={setActiveTab}> <Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2 gap-1 md:grid-cols-4 xl:grid-cols-9"> <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">总览</TabsTrigger> <TabsTrigger value="overview" className="shrink-0 whitespace-nowrap">总览</TabsTrigger>
<TabsTrigger value="commander">指挥调度</TabsTrigger> <TabsTrigger value="commander" className="shrink-0 whitespace-nowrap">指挥调度</TabsTrigger>
<TabsTrigger value="create">零配置创建</TabsTrigger> <TabsTrigger value="create" className="shrink-0 whitespace-nowrap">零配置创建</TabsTrigger>
<TabsTrigger value="signals">信号捕获</TabsTrigger> <TabsTrigger value="signals" className="shrink-0 whitespace-nowrap">信号捕获</TabsTrigger>
<TabsTrigger value="conflicts">认知冲突</TabsTrigger> <TabsTrigger value="conflicts" className="shrink-0 whitespace-nowrap">认知冲突</TabsTrigger>
<TabsTrigger value="blockers">阻塞检测</TabsTrigger> <TabsTrigger value="blockers" className="shrink-0 whitespace-nowrap">阻塞检测</TabsTrigger>
<TabsTrigger value="briefing">每日简报</TabsTrigger> <TabsTrigger value="briefing" className="shrink-0 whitespace-nowrap">每日简报</TabsTrigger>
<TabsTrigger value="qa">问答记录</TabsTrigger> <TabsTrigger value="qa" className="shrink-0 whitespace-nowrap">问答记录</TabsTrigger>
<TabsTrigger value="engine">引擎配置</TabsTrigger> <TabsTrigger value="engine" className="shrink-0 whitespace-nowrap">引擎配置</TabsTrigger>
</TabsList> </TabsList>
{/* Overview Tab */} {/* Overview Tab */}
@ -589,7 +589,7 @@ export default function AIStewardPage() {
</p> </p>
</div> </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="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="flex items-center gap-3 mb-4">
<div className="w-12 h-12 rounded-full bg-blue-500 flex items-center justify-center"> <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> </Card>
<Tabs defaultValue="project-overview"> <Tabs defaultValue="project-overview">
<TabsList className="grid w-full grid-cols-4"> <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">项目概览</TabsTrigger> <TabsTrigger value="project-overview" className="shrink-0 whitespace-nowrap">项目概览</TabsTrigger>
<TabsTrigger value="project-charter">项目宪章</TabsTrigger> <TabsTrigger value="project-charter" className="shrink-0 whitespace-nowrap">项目宪章</TabsTrigger>
<TabsTrigger value="project-tasks">任务列表</TabsTrigger> <TabsTrigger value="project-tasks" className="shrink-0 whitespace-nowrap">任务列表</TabsTrigger>
<TabsTrigger value="project-risks">风险预警</TabsTrigger> <TabsTrigger value="project-risks" className="shrink-0 whitespace-nowrap">风险预警</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="project-overview"> <TabsContent value="project-overview">

View File

@ -118,7 +118,7 @@ export default function CharterDetailPage() {
return ( return (
<div className="space-y-6"> <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> <Button variant="ghost" size="icon" asChild>
<Link to={`/projects/${id}/board`}> <Link to={`/projects/${id}/board`}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
@ -133,7 +133,7 @@ export default function CharterDetailPage() {
</p> </p>
</div> </div>
<Button <Button
className="ml-auto" className="sm:ml-auto"
onClick={() => setCreateTaskOpen(true)} onClick={() => setCreateTaskOpen(true)}
> >
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />

View File

@ -44,7 +44,7 @@ export default function DashboardPage() {
<p className="text-muted-foreground">查看团队成员工作负载分布</p> <p className="text-muted-foreground">查看团队成员工作负载分布</p>
</div> </div>
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-2">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>工时分布</CardTitle> <CardTitle>工时分布</CardTitle>
@ -91,15 +91,16 @@ export default function DashboardPage() {
) : items.length === 0 ? ( ) : items.length === 0 ? (
<p className="text-muted-foreground">暂无数据</p> <p className="text-muted-foreground">暂无数据</p>
) : ( ) : (
<Table> <div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
<TableHeader> <Table className="min-w-[520px]">
<TableHeader>
<TableRow> <TableRow>
<TableHead>成员</TableHead> <TableHead>成员</TableHead>
<TableHead className="text-right">任务数</TableHead> <TableHead className="text-right">任务数</TableHead>
<TableHead className="text-right">工时 (h)</TableHead> <TableHead className="text-right">工时 (h)</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{items.map((row) => ( {items.map((row) => (
<TableRow key={row.user_id}> <TableRow key={row.user_id}>
<TableCell>{row.user_name || row.username}</TableCell> <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> <TableCell className="text-right">{(row.estimated_hours ?? 0).toFixed(1)}</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</div>
)} )}
</CardContent> </CardContent>
</Card> </Card>

View File

@ -169,7 +169,7 @@ export default function FeishuConfigPage() {
}, []); }, []);
return ( 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"> <div className="max-w-4xl mx-auto">
{/* Header */} {/* Header */}
<div className="text-center mb-8"> <div className="text-center mb-8">
@ -296,7 +296,7 @@ export default function FeishuConfigPage() {
)} )}
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex gap-3"> <div className="flex flex-wrap gap-3">
<Button <Button
onClick={handleSave} onClick={handleSave}
disabled={loading} disabled={loading}
@ -351,12 +351,12 @@ export default function FeishuConfigPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Tabs defaultValue="step1"> <Tabs defaultValue="step1">
<TabsList className="grid w-full grid-cols-5"> <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">1. 创建应用</TabsTrigger> <TabsTrigger value="step1" className="shrink-0 whitespace-nowrap">1. 创建应用</TabsTrigger>
<TabsTrigger value="step2">2. 获取凭证</TabsTrigger> <TabsTrigger value="step2" className="shrink-0 whitespace-nowrap">2. 获取凭证</TabsTrigger>
<TabsTrigger value="step3">3. 配置事件</TabsTrigger> <TabsTrigger value="step3" className="shrink-0 whitespace-nowrap">3. 配置事件</TabsTrigger>
<TabsTrigger value="step4">4. 回调地址</TabsTrigger> <TabsTrigger value="step4" className="shrink-0 whitespace-nowrap">4. 回调地址</TabsTrigger>
<TabsTrigger value="step5">5. 完成</TabsTrigger> <TabsTrigger value="step5" className="shrink-0 whitespace-nowrap">5. 完成</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="step1"> <TabsContent value="step1">

View File

@ -570,7 +570,7 @@ export default function InboxPage() {
<section className="rounded-2xl bg-[#F8F9FA] p-4 md:p-5"> <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="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="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 <Input
placeholder="搜索标题、摘要、项目" placeholder="搜索标题、摘要、项目"
defaultValue={searchValue} defaultValue={searchValue}
@ -584,7 +584,7 @@ export default function InboxPage() {
<select <select
value={projectFilter} value={projectFilter}
onChange={(event) => setProject(event.target.value)} 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> <option value="all">全部项目</option>
{projectOptions.map((option) => ( {projectOptions.map((option) => (
@ -596,7 +596,7 @@ export default function InboxPage() {
<select <select
value={priorityFilter} value={priorityFilter}
onChange={(event) => setPriorityFilter(event.target.value)} 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) => ( {PRIORITY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}> <option key={option.value} value={option.value}>
@ -669,7 +669,7 @@ export default function InboxPage() {
if (!open) setWorkItemDetailItem(null); 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> <DialogHeader>
<DialogTitle>{workItemDetailItem?.title || "工作项详情"}</DialogTitle> <DialogTitle>{workItemDetailItem?.title || "工作项详情"}</DialogTitle>
<DialogDescription> <DialogDescription>
@ -808,7 +808,7 @@ export default function InboxPage() {
if (!open) setNotificationDetailItem(null); 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> <DialogHeader>
<DialogTitle>{notificationDetailItem?.title || "通知详情"}</DialogTitle> <DialogTitle>{notificationDetailItem?.title || "通知详情"}</DialogTitle>
<DialogDescription> <DialogDescription>

View File

@ -54,6 +54,9 @@ export default function ProjectBoardPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { toast } = useToast(); const { toast } = useToast();
const [isMobileViewport, setIsMobileViewport] = useState(() =>
typeof window !== "undefined" ? window.innerWidth < 768 : false
);
const [activeStage, setActiveStage] = useState<StageKey>("signal"); const [activeStage, setActiveStage] = useState<StageKey>("signal");
const [selectedCharterId, setSelectedCharterId] = useState<string | null>(null); const [selectedCharterId, setSelectedCharterId] = useState<string | null>(null);
@ -211,6 +214,14 @@ export default function ProjectBoardPage() {
} }
}, [charters, selectedCharterId]); }, [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(() => { useEffect(() => {
setSignalPage(1); setSignalPage(1);
setSignalFilters(DEFAULT_SIGNAL_FILTERS); setSignalFilters(DEFAULT_SIGNAL_FILTERS);
@ -590,6 +601,8 @@ export default function ProjectBoardPage() {
<SSTCWorkspace <SSTCWorkspace
projectId={id!} projectId={id!}
activeStage={activeStage} activeStage={activeStage}
isMobileViewport={isMobileViewport}
charterReadOnly={isMobileViewport}
stages={stageNodes} stages={stageNodes}
signals={signals} signals={signals}
signalFilters={signalFilters} signalFilters={signalFilters}
@ -632,7 +645,7 @@ export default function ProjectBoardPage() {
{risks && <SSTCRiskFloatPanel risks={risks} />} {risks && <SSTCRiskFloatPanel risks={risks} />}
<Dialog open={createCharterOpen} onOpenChange={handleCreateCharterDialogChange}> <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"> <DialogHeader className="min-w-0 shrink-0 border-b border-slate-200 px-6 py-5">
<DialogTitle className="overflow-hidden truncate">新建需求宪章</DialogTitle> <DialogTitle className="overflow-hidden truncate">新建需求宪章</DialogTitle>
<DialogDescription <DialogDescription
@ -792,7 +805,7 @@ export default function ProjectBoardPage() {
</Dialog> </Dialog>
<Dialog open={settingsOpen} onOpenChange={setSettingsOpen}> <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> <DialogHeader>
<DialogTitle>项目设置</DialogTitle> <DialogTitle>项目设置</DialogTitle>
<DialogDescription> <DialogDescription>

View File

@ -236,7 +236,7 @@ export default function ProjectsPage() {
<CardDescription className="mt-2 text-slate-500"> <CardDescription className="mt-2 text-slate-500">
任何项目相关操作必须纳入完整链路;游离在链路外的信息不具备执行效力。 任何项目相关操作必须纳入完整链路;游离在链路外的信息不具备执行效力。
</CardDescription> </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) => { {CHAIN_STAGES.map((stage) => {
const Icon = stage.icon; const Icon = stage.icon;
return ( return (
@ -302,7 +302,7 @@ export default function ProjectsPage() {
</section> </section>
) : ( ) : (
<section className="rounded-2xl bg-[#F8F9FA] p-4 md:p-5"> <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) => { {pagedProjects.map((p) => {
const progressPercent = Math.min(100, typeof p.progress === "number" ? p.progress : 0); const progressPercent = Math.min(100, typeof p.progress === "number" ? p.progress : 0);
const projectLabel = formatProjectNameWithId({ id: p.id, title: p.title }); const projectLabel = formatProjectNameWithId({ id: p.id, title: p.title });
@ -401,7 +401,7 @@ export default function ProjectsPage() {
)} )}
<Dialog open={queryOpen} onOpenChange={setQueryOpen}> <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> <DialogHeader>
<DialogTitle>条件查询</DialogTitle> <DialogTitle>条件查询</DialogTitle>
<DialogDescription>按项目名称和创建时间过滤项目列表。</DialogDescription> <DialogDescription>按项目名称和创建时间过滤项目列表。</DialogDescription>
@ -462,7 +462,7 @@ export default function ProjectsPage() {
</Dialog> </Dialog>
<Dialog open={!!deleteProjectState} onOpenChange={(open) => !open && setDeleteProjectState(null)}> <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> <DialogHeader>
<DialogTitle>确认删除项目</DialogTitle> <DialogTitle>确认删除项目</DialogTitle>
<DialogDescription> <DialogDescription>

View File

@ -137,12 +137,12 @@ export default function RecipesPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-center justify-between gap-3">
<div> <div>
<h1 className="text-2xl font-bold tracking-tight">食谱管理</h1> <h1 className="text-2xl font-bold tracking-tight">食谱管理</h1>
<p className="text-muted-foreground">管理您的食谱,AI 智能识别热量</p> <p className="text-muted-foreground">管理您的食谱,AI 智能识别热量</p>
</div> </div>
<div className="flex gap-2"> <div className="flex flex-wrap gap-2">
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}> <Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline"> <Button variant="outline">
@ -150,7 +150,7 @@ export default function RecipesPage() {
手动添加 手动添加
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent className="w-[min(560px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] sm:max-w-[560px]">
<DialogHeader> <DialogHeader>
<DialogTitle>创建食谱</DialogTitle> <DialogTitle>创建食谱</DialogTitle>
<DialogDescription>手动添加食谱信息</DialogDescription> <DialogDescription>手动添加食谱信息</DialogDescription>
@ -211,7 +211,7 @@ export default function RecipesPage() {
拍照识别 拍照识别
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-2xl"> <DialogContent className="w-[min(720px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] sm:max-w-[720px]">
<DialogHeader> <DialogHeader>
<DialogTitle>AI 热量识别</DialogTitle> <DialogTitle>AI 热量识别</DialogTitle>
<DialogDescription> <DialogDescription>
@ -269,7 +269,7 @@ export default function RecipesPage() {
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div> <div>
<Label htmlFor="category">分类</Label> <Label htmlFor="category">分类</Label>
<Input <Input
@ -317,8 +317,8 @@ export default function RecipesPage() {
</div> </div>
</div> </div>
<div className="flex gap-4 items-center"> <div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 max-w-sm"> <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" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <Input
placeholder="搜索食谱..." placeholder="搜索食谱..."
@ -330,7 +330,7 @@ export default function RecipesPage() {
<select <select
value={categoryFilter} value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)} 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="all">全部分类</option>
<option value="主食">主食</option> <option value="主食">主食</option>
@ -352,7 +352,7 @@ export default function RecipesPage() {
</CardContent> </CardContent>
</Card> </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) => ( {filteredRecipes.map((recipe) => (
<Card key={recipe.id} className="overflow-hidden"> <Card key={recipe.id} className="overflow-hidden">
{recipe.image_url && ( {recipe.image_url && (
@ -400,7 +400,7 @@ export default function RecipesPage() {
</Badge> </Badge>
)} )}
</div> </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"> <div className="text-center">
<Droplets className="h-3 w-3 mx-auto mb-1" /> <Droplets className="h-3 w-3 mx-auto mb-1" />
<div>蛋白质</div> <div>蛋白质</div>

View File

@ -28,7 +28,7 @@ export default function SkillTasksPage() {
return ( return (
<div className="space-y-6"> <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> <Button variant="ghost" size="icon" asChild>
<Link to={userId ? `/users/${userId}/profile` : "/users"}> <Link to={userId ? `/users/${userId}/profile` : "/users"}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
@ -66,8 +66,9 @@ export default function SkillTasksPage() {
</p> </p>
</div> </div>
) : ( ) : (
<Table> <div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
<TableHeader> <Table className="min-w-[720px]">
<TableHeader>
<TableRow> <TableRow>
<TableHead>任务</TableHead> <TableHead>任务</TableHead>
<TableHead>状态</TableHead> <TableHead>状态</TableHead>
@ -75,8 +76,8 @@ export default function SkillTasksPage() {
<TableHead className="text-right">工时 (h)</TableHead> <TableHead className="text-right">工时 (h)</TableHead>
<TableHead>更新时间</TableHead> <TableHead>更新时间</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{items.map((task) => ( {items.map((task) => (
<TableRow key={task.id}> <TableRow key={task.id}>
<TableCell> <TableCell>
@ -103,8 +104,9 @@ export default function SkillTasksPage() {
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</div>
)} )}
</CardContent> </CardContent>
</Card> </Card>

View File

@ -119,7 +119,7 @@ export default function SkillsPage() {
return ( return (
<div className="space-y-6"> <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> <div>
<h1 className="text-2xl font-bold tracking-tight">技能标签管理</h1> <h1 className="text-2xl font-bold tracking-tight">技能标签管理</h1>
<p className="text-muted-foreground">维护技能标签分类并查看关联人数</p> <p className="text-muted-foreground">维护技能标签分类并查看关联人数</p>
@ -139,15 +139,15 @@ export default function SkillsPage() {
<CardTitle>标签列表</CardTitle> <CardTitle>标签列表</CardTitle>
<CardDescription>共 {rows.length} 个</CardDescription> <CardDescription>共 {rows.length} 个</CardDescription>
</div> </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 <Input
placeholder="搜索名称/标识/分类" placeholder="搜索名称/标识/分类"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
className="max-w-xs" className="w-full sm:w-72"
/> />
<select <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} value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)} onChange={(e) => setCategoryFilter(e.target.value)}
> >
@ -169,8 +169,9 @@ export default function SkillsPage() {
) : rows.length === 0 ? ( ) : rows.length === 0 ? (
<p className="py-8 text-center text-muted-foreground">暂无技能标签</p> <p className="py-8 text-center text-muted-foreground">暂无技能标签</p>
) : ( ) : (
<Table> <div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
<TableHeader> <Table className="min-w-[760px]">
<TableHeader>
<TableRow> <TableRow>
<TableHead>名称</TableHead> <TableHead>名称</TableHead>
<TableHead>分类</TableHead> <TableHead>分类</TableHead>
@ -178,8 +179,8 @@ export default function SkillsPage() {
<TableHead>描述</TableHead> <TableHead>描述</TableHead>
{canManage && <TableHead className="w-12" />} {canManage && <TableHead className="w-12" />}
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{rows.map((tag) => ( {rows.map((tag) => (
<TableRow key={tag.id}> <TableRow key={tag.id}>
<TableCell> <TableCell>
@ -226,8 +227,9 @@ export default function SkillsPage() {
)} )}
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</div>
)} )}
</CardContent> </CardContent>
</Card> </Card>

View File

@ -32,7 +32,7 @@ export default function UserProfilePage() {
return ( return (
<div className="space-y-6"> <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> <Button variant="ghost" size="icon" asChild>
<Link to={isManager ? "/users" : "/dashboard"}> <Link to={isManager ? "/users" : "/dashboard"}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
@ -42,14 +42,14 @@ export default function UserProfilePage() {
<h1 className="text-2xl font-bold tracking-tight">个人中心</h1> <h1 className="text-2xl font-bold tracking-tight">个人中心</h1>
<p className="text-muted-foreground">{displayName}</p> <p className="text-muted-foreground">{displayName}</p>
</div> </div>
<div className="ml-auto"> <div className="sm:ml-auto">
<Button asChild variant="outline"> <Button asChild variant="outline">
<Link to={`/users/${userId}/skills`}>技能详情</Link> <Link to={`/users/${userId}/skills`}>技能详情</Link>
</Button> </Button>
</div> </div>
</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 && ( {userId && (
<SkillRadarChart <SkillRadarChart
userId={userId} userId={userId}
@ -84,7 +84,7 @@ export default function UserProfilePage() {
<p className="text-destructive">加载失败</p> <p className="text-destructive">加载失败</p>
) : ( ) : (
<div className="space-y-5"> <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"> <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> <dt className="text-xs uppercase tracking-[0.18em] text-slate-500">用户名</dt>
<dd className="mt-2 font-medium text-slate-900">{user?.username ?? "-"}</dd> <dd className="mt-2 font-medium text-slate-900">{user?.username ?? "-"}</dd>

View File

@ -208,8 +208,8 @@ export default function UserSkillsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between gap-4"> <div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
<Button variant="ghost" size="icon" asChild> <Button variant="ghost" size="icon" asChild>
<Link to={`/users/${userId}/profile`}> <Link to={`/users/${userId}/profile`}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
@ -222,7 +222,7 @@ export default function UserSkillsPage() {
</p> </p>
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex flex-wrap gap-2">
<Button <Button
variant="outline" variant="outline"
disabled={!canManageAll} disabled={!canManageAll}
@ -244,8 +244,8 @@ export default function UserSkillsPage() {
</div> </div>
</div> </div>
<div className="grid gap-4 lg:grid-cols-5"> <div className="grid gap-4 xl:grid-cols-5">
<div className="space-y-4 lg:col-span-3"> <div className="space-y-4 xl:col-span-3">
{bindings.length === 0 && ( {bindings.length === 0 && (
<Card> <Card>
<CardContent className="py-10 text-center text-sm text-muted-foreground"> <CardContent className="py-10 text-center text-sm text-muted-foreground">
@ -353,8 +353,8 @@ export default function UserSkillsPage() {
))} ))}
</div> </div>
<Card className="lg:col-span-2"> <Card className="xl:col-span-2">
<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 className="text-base">技能雷达图</CardTitle> <CardTitle className="text-base">技能雷达图</CardTitle>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<select <select

View File

@ -283,7 +283,7 @@ export default function UsersPage() {
<p className="text-muted-foreground">筛选、导入、状态管理、批量删除</p> <p className="text-muted-foreground">筛选、导入、状态管理、批量删除</p>
</div> </div>
{canManage && ( {canManage && (
<div className="flex gap-2"> <div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setImportOpen(true)}> <Button variant="outline" onClick={() => setImportOpen(true)}>
<Import className="mr-2 h-4 w-4" /> <Import className="mr-2 h-4 w-4" />
导入 导入
@ -319,12 +319,12 @@ export default function UsersPage() {
<CardTitle>用户列表</CardTitle> <CardTitle>用户列表</CardTitle>
<CardDescription>共 {total} 人</CardDescription> <CardDescription>共 {total} 人</CardDescription>
</div> </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 <Input
placeholder="搜索用户名/姓名/邮箱" placeholder="搜索用户名/姓名/邮箱"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
className="max-w-xs" className="w-full sm:w-72"
/> />
</div> </div>
</div> </div>
@ -337,8 +337,9 @@ export default function UsersPage() {
) : renderedUsers.length === 0 ? ( ) : renderedUsers.length === 0 ? (
<p className="py-8 text-center text-muted-foreground">暂无用户</p> <p className="py-8 text-center text-muted-foreground">暂无用户</p>
) : ( ) : (
<Table> <div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
<TableHeader> <Table className="min-w-[940px]">
<TableHeader>
<TableRow> <TableRow>
<TableHead className="w-10"> <TableHead className="w-10">
<input <input
@ -357,8 +358,8 @@ export default function UsersPage() {
<TableHead>状态</TableHead> <TableHead>状态</TableHead>
{canManage && <TableHead className="w-12" />} {canManage && <TableHead className="w-12" />}
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{renderedUsers.map((u) => { {renderedUsers.map((u) => {
const ratio = u.workload_ratio ?? u.workload ?? 0; const ratio = u.workload_ratio ?? u.workload ?? 0;
return ( return (
@ -455,8 +456,9 @@ export default function UsersPage() {
</TableRow> </TableRow>
); );
})} })}
</TableBody> </TableBody>
</Table> </Table>
</div>
)} )}
</CardContent> </CardContent>
</Card> </Card>

View File

@ -121,7 +121,7 @@ export default function WorkloadDetailPage() {
</Button> </Button>
</div> </div>
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 lg:grid-cols-3">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-sm text-muted-foreground">负载比例</CardTitle> <CardTitle className="text-sm text-muted-foreground">负载比例</CardTitle>
@ -151,7 +151,7 @@ export default function WorkloadDetailPage() {
</div> </div>
<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> <CardTitle>负载趋势</CardTitle>
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant={days === 7 ? "default" : "outline"} size="sm" onClick={() => setDays(7)}> <Button variant={days === 7 ? "default" : "outline"} size="sm" onClick={() => setDays(7)}>
@ -194,11 +194,11 @@ export default function WorkloadDetailPage() {
</Card> </Card>
<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> <CardTitle>任务列表</CardTitle>
<div className="flex gap-2"> <div className="flex w-full flex-wrap gap-2 sm:w-auto">
<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={statusFilter} value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)} onChange={(e) => setStatusFilter(e.target.value)}
> >
@ -208,7 +208,7 @@ export default function WorkloadDetailPage() {
<option value="done">已完成</option> <option value="done">已完成</option>
</select> </select>
<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} value={priorityFilter}
onChange={(e) => setPriorityFilter(e.target.value)} onChange={(e) => setPriorityFilter(e.target.value)}
> >
@ -228,8 +228,9 @@ export default function WorkloadDetailPage() {
) : tasks.length === 0 ? ( ) : tasks.length === 0 ? (
<p className="py-8 text-center text-muted-foreground">暂无任务</p> <p className="py-8 text-center text-muted-foreground">暂无任务</p>
) : ( ) : (
<Table> <div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
<TableHeader> <Table className="min-w-[760px]">
<TableHeader>
<TableRow> <TableRow>
<TableHead>标题</TableHead> <TableHead>标题</TableHead>
<TableHead>项目</TableHead> <TableHead>项目</TableHead>
@ -238,8 +239,8 @@ export default function WorkloadDetailPage() {
<TableHead>到期</TableHead> <TableHead>到期</TableHead>
<TableHead>状态</TableHead> <TableHead>状态</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{tasks.map((task) => ( {tasks.map((task) => (
<TableRow key={task.id}> <TableRow key={task.id}>
<TableCell>{task.title}</TableCell> <TableCell>{task.title}</TableCell>
@ -250,8 +251,9 @@ export default function WorkloadDetailPage() {
<TableCell>{toZhStatus(task.status)}</TableCell> <TableCell>{toZhStatus(task.status)}</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</div>
)} )}
</CardContent> </CardContent>
</Card> </Card>

View File

@ -173,13 +173,13 @@ export default function WorkloadsPage() {
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Input <Input
className="w-[140px]" className="w-full sm:w-40"
placeholder="项目编号" placeholder="项目编号"
value={projectId} value={projectId}
onChange={(e) => setProjectId(e.target.value)} onChange={(e) => setProjectId(e.target.value)}
/> />
<select <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} value={range}
onChange={(e) => setRange(e.target.value as "week" | "month" | "custom")} onChange={(e) => setRange(e.target.value as "week" | "month" | "custom")}
> >
@ -189,8 +189,8 @@ export default function WorkloadsPage() {
</select> </select>
{range === "custom" && ( {range === "custom" && (
<> <>
<Input type="date" className="w-[160px]" value={customFrom} onChange={(e) => setCustomFrom(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-[160px]" value={customTo} onChange={(e) => setCustomTo(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}> <Button variant="outline" onClick={exportExcelLike}>
@ -211,7 +211,7 @@ export default function WorkloadsPage() {
</CardContent> </CardContent>
</Card> </Card>
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 lg:grid-cols-3">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-sm text-muted-foreground">团队平均负载</CardTitle> <CardTitle className="text-sm text-muted-foreground">团队平均负载</CardTitle>
@ -277,8 +277,9 @@ export default function WorkloadsPage() {
) : items.length === 0 ? ( ) : items.length === 0 ? (
<p className="py-8 text-center text-muted-foreground">暂无数据</p> <p className="py-8 text-center text-muted-foreground">暂无数据</p>
) : ( ) : (
<Table> <div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
<TableHeader> <Table className="min-w-[760px]">
<TableHeader>
<TableRow> <TableRow>
<TableHead>成员</TableHead> <TableHead>成员</TableHead>
<TableHead>角色</TableHead> <TableHead>角色</TableHead>
@ -288,8 +289,8 @@ export default function WorkloadsPage() {
<TableHead className="text-right">剩余工时</TableHead> <TableHead className="text-right">剩余工时</TableHead>
<TableHead>操作</TableHead> <TableHead>操作</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{items.map((row) => ( {items.map((row) => (
<TableRow key={row.user_id} className={row.workload_ratio > 100 ? "bg-red-50/40 dark:bg-red-900/10" : ""}> <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> <TableCell>{row.user_name || row.username}</TableCell>
@ -307,8 +308,9 @@ export default function WorkloadsPage() {
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
@ -320,7 +322,7 @@ export default function WorkloadsPage() {
<DialogDescription>预览 旧负责人 → 新负责人,确认后批量应用。</DialogDescription> <DialogDescription>预览 旧负责人 → 新负责人,确认后批量应用。</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="max-h-[420px] overflow-auto rounded-md border"> <div className="max-h-[420px] overflow-auto rounded-md border">
<Table> <Table className="min-w-[900px]">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>任务</TableHead> <TableHead>任务</TableHead>