Add 模型连通检测启动器}0987654321
This commit is contained in:
parent
a3a4ce61ff
commit
fea6328ab2
|
|
@ -28,18 +28,18 @@ LLM_REQUEST_TIMEOUT=90
|
|||
|
||||
# 模型名统一使用裸名,不带供应商前缀
|
||||
# GLM-4.7 配 GLM_API_KEY
|
||||
# MiniMax-M2.5 配 OPENAI_API_KEY
|
||||
# GLM-5(经 OpenAI 兼容网关)配 OPENAI_API_KEY
|
||||
GLM_API_KEY=
|
||||
GLM_BASE_URL=https://api.edgefn.net/v1
|
||||
GLM_MODEL=GLM-4.7
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_BASE_URL=https://api.edgefn.net/v1
|
||||
OPENAI_MODEL=MiniMax-M2.5
|
||||
OPENAI_MODEL=GLM-5
|
||||
|
||||
LLM_ROUTING_LOW_PROVIDER=glm
|
||||
LLM_ROUTING_LOW_MODEL=GLM-4.7
|
||||
LLM_ROUTING_HIGH_PROVIDER=openai
|
||||
LLM_ROUTING_HIGH_MODEL=MiniMax-M2.5
|
||||
LLM_ROUTING_HIGH_MODEL=GLM-5
|
||||
LLM_ROUTING_SAFETY_PROVIDER=glm
|
||||
LLM_ROUTING_SAFETY_MODEL=GLM-4.7
|
||||
|
||||
|
|
|
|||
15
CLAUDE.md
15
CLAUDE.md
|
|
@ -19,6 +19,7 @@ AIProjectManger/
|
|||
├── CLAUDE.md # 现有 Claude 说明文档
|
||||
├── start.sh / stop.sh # 一键启动/停止入口
|
||||
├── docker-compose*.yml # 容器编排
|
||||
├── mdproject/ # 执行计划与任务清单(中文为主)
|
||||
├── backend/ # FastAPI 后端
|
||||
├── frontend/ # React 前端
|
||||
├── scripts/ # 根目录运维脚本
|
||||
|
|
@ -75,6 +76,7 @@ frontend/
|
|||
│ ├── pages/ # 页面级组件
|
||||
│ ├── components/
|
||||
│ │ ├── ui/ # 基础 UI 组件
|
||||
│ │ ├── inbox/ # 消息台 Feed 列表组件
|
||||
│ │ └── sstc/ # SSTC 工作流组件(已模块化拆分)
|
||||
│ ├── hooks/ # 自定义 hooks
|
||||
│ ├── lib/ # 通用工具
|
||||
|
|
@ -85,6 +87,10 @@ frontend/
|
|||
└── vite.config.ts
|
||||
```
|
||||
|
||||
- 新增:`frontend/src/components/inbox/InboxListItem.tsx`(消息台 Feed 行组件,支持移动端与行内展开)
|
||||
- 新增:`frontend/src/lib/inboxTextMap.ts`(消息台用户文案映射与标题清洗)
|
||||
- 新增:`frontend/src/lib/formatInboxTime.ts`(消息台相对时间与完整时间格式化)
|
||||
|
||||
### 5.1 `frontend/src/components/sstc/` 目录说明(模块化拆分后)
|
||||
- `SSTCWorkspace.tsx`:SSTC 工作区主容器(组装各阶段组件)
|
||||
- `useSSTCWorkspaceOrchestration.ts`:工作区编排 Hook(状态聚合、请求编排、事件路由)
|
||||
|
|
@ -125,8 +131,17 @@ frontend/
|
|||
```bash
|
||||
./start.sh dev # 推荐:docker-dev 热更新模式
|
||||
./stop.sh dev
|
||||
./devops/tunnel/start.sh # 公网穿透启动器(独立入口)
|
||||
./scripts/start_model_probe.sh # 独立模型连通性检测(中文日志)
|
||||
```
|
||||
|
||||
- 启动入口收敛:仅保留 2 个用户入口
|
||||
- 正常启动器:`./start.sh`
|
||||
- 公网穿透启动器:`./devops/tunnel/start.sh`
|
||||
- 已移除:`scripts/start_legacy_local.sh`、`scripts/stop_legacy_local.sh`(旧本地启动链路)
|
||||
- 新增:`scripts/start_model_probe.sh`(独立模型连通性检测启动器,输出中文日志并落盘)
|
||||
- 新增:`scripts/model_probe.py`(模型检测核心逻辑,读取 `.env` 并探测 low/high 路由模型)
|
||||
|
||||
### 前端
|
||||
```bash
|
||||
cd frontend
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
|
|
@ -518,9 +519,101 @@ async def _warmup_world_models() -> None:
|
|||
logger.warning("[WM_WARMUP] 预热失败", error=str(exc))
|
||||
|
||||
|
||||
def _startup_log(message: str, **fields) -> None:
|
||||
logger.info(f"[后端] {message}", **fields)
|
||||
|
||||
|
||||
async def _probe_model_connectivity_on_startup() -> None:
|
||||
from .services.llm import llm_service
|
||||
|
||||
low_provider = str(os.getenv("LLM_ROUTING_LOW_PROVIDER", os.getenv("LLM_PROVIDER", "glm")) or "").strip() or "glm"
|
||||
low_model = str(os.getenv("LLM_ROUTING_LOW_MODEL", os.getenv("GLM_MODEL", "GLM-4.7")) or "").strip() or "GLM-4.7"
|
||||
|
||||
high_provider = str(os.getenv("LLM_ROUTING_HIGH_PROVIDER", os.getenv("LLM_PROVIDER", "openai")) or "").strip() or "openai"
|
||||
high_model = str(os.getenv("LLM_ROUTING_HIGH_MODEL", os.getenv("OPENAI_MODEL", "GLM-5")) or "").strip() or "GLM-5"
|
||||
|
||||
probe_targets: list[tuple[str, str, str]] = [
|
||||
("LOW", low_provider, low_model),
|
||||
("HIGH", high_provider, high_model),
|
||||
]
|
||||
deduped_targets: list[tuple[str, str, str]] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for level, provider, model in probe_targets:
|
||||
key = (provider.lower(), model)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
deduped_targets.append((level, provider, model))
|
||||
|
||||
lines: list[str] = [
|
||||
"┌──────────────────────────────┐",
|
||||
"│ 模型连通性检测 │",
|
||||
]
|
||||
|
||||
for level, provider, model in deduped_targets:
|
||||
try:
|
||||
runtime = llm_service._env_provider_runtime(provider)
|
||||
runtime["model"] = model
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"startup_model_probe_runtime_invalid",
|
||||
level=level,
|
||||
provider=provider,
|
||||
model=model,
|
||||
error=str(exc),
|
||||
)
|
||||
lines.append(f"│ {model:<12} ✗ 失败 (0.0s) │")
|
||||
continue
|
||||
started_at = time.perf_counter()
|
||||
ok = False
|
||||
error_text = ""
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
llm_service._chat_completion(
|
||||
provider=str(runtime.get("provider") or provider),
|
||||
prompt="ping",
|
||||
system_prompt="你是连通性检测助手,只回复 OK。",
|
||||
api_key=str(runtime.get("api_key") or ""),
|
||||
base_url=str(runtime.get("base_url") or ""),
|
||||
model=str(runtime.get("model") or model),
|
||||
temperature=0.0,
|
||||
max_tokens=1,
|
||||
function_name="startup_model_probe",
|
||||
response_mode="fast",
|
||||
),
|
||||
timeout=8.0,
|
||||
)
|
||||
ok = bool(str(result or "").strip())
|
||||
if not ok:
|
||||
error_text = "empty_response"
|
||||
except Exception as exc:
|
||||
error_text = str(exc)
|
||||
logger.error(
|
||||
"startup_model_probe_failed",
|
||||
level=level,
|
||||
provider=provider,
|
||||
model=model,
|
||||
error=error_text,
|
||||
)
|
||||
|
||||
elapsed = time.perf_counter() - started_at
|
||||
if ok:
|
||||
lines.append(f"│ {model:<12} ✓ 正常 ({elapsed:.1f}s) │")
|
||||
else:
|
||||
lines.append(f"│ {model:<12} ✗ 失败 ({elapsed:.1f}s) │")
|
||||
if error_text:
|
||||
_startup_log("模型连通性失败(不阻断启动)", model=model, provider=provider, error=error_text)
|
||||
|
||||
lines.append("└──────────────────────────────┘")
|
||||
for line in lines:
|
||||
logger.info(line)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
"""应用生命周期:初始化表与系统技能标签。"""
|
||||
backend_port = str(os.getenv("BACKEND_PORT", "8000") or "8000").strip() or "8000"
|
||||
_startup_log("服务启动初始化中")
|
||||
auto_create_tables = os.getenv("AUTO_CREATE_TABLES", "false").lower() in {
|
||||
"1",
|
||||
"true",
|
||||
|
|
@ -535,6 +628,8 @@ async def lifespan(_: FastAPI):
|
|||
raise RuntimeError("AUTO_CREATE_TABLES=true 已被禁用;请使用 Alembic migration 变更数据库。")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(_check_database_dialect_guard)
|
||||
_startup_log("数据库连接成功")
|
||||
await _probe_model_connectivity_on_startup()
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
|
|
@ -569,8 +664,10 @@ async def lifespan(_: FastAPI):
|
|||
_warmup_world_models(),
|
||||
name="wm-world-model-warmup",
|
||||
)
|
||||
_startup_log("后端运行中", url=f"http://127.0.0.1:{backend_port}")
|
||||
|
||||
yield
|
||||
_startup_log("服务关闭中")
|
||||
outbox_task.cancel()
|
||||
try:
|
||||
await outbox_task
|
||||
|
|
@ -586,6 +683,7 @@ async def lifespan(_: FastAPI):
|
|||
await knowledge_scheduler.stop()
|
||||
await scheduler.stop()
|
||||
await engine.dispose()
|
||||
_startup_log("服务已关闭")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Cloudflare Quick Tunnel (No Domain Required)
|
||||
# Generates a random stable-ish URL for the session
|
||||
|
||||
echo "🚀 Starting Quick Tunnel..."
|
||||
echo "⚠️ Note: This uses a random trycloudflare.com URL."
|
||||
echo "⚠️ For a fixed custom domain, you must PURCHASE a domain and configure it via setup.sh"
|
||||
|
||||
# Kill existing tunnels
|
||||
pkill -f "cloudflared tunnel" >/dev/null 2>&1 || true
|
||||
|
||||
echo "🌐 Starting Frontend Tunnel (port 5174)..."
|
||||
cloudflared tunnel --url http://localhost:5174 > logs/tunnel-frontend.log 2>&1 &
|
||||
PID_FRONT=$!
|
||||
|
||||
echo "🌐 Starting Backend Tunnel (port 8000)..."
|
||||
cloudflared tunnel --url http://localhost:8000 > logs/tunnel-backend.log 2>&1 &
|
||||
PID_BACK=$!
|
||||
|
||||
echo "⏳ Waiting for tunnels to initialize..."
|
||||
sleep 5
|
||||
|
||||
URL_FRONT=$(grep -o 'https://.*\.trycloudflare.com' logs/tunnel-frontend.log | head -1)
|
||||
URL_BACK=$(grep -o 'https://.*\.trycloudflare.com' logs/tunnel-backend.log | head -1)
|
||||
|
||||
check_url() {
|
||||
local url="$1"
|
||||
local max_retry="$2"
|
||||
local retry=1
|
||||
while [ "$retry" -le "$max_retry" ]; do
|
||||
if curl -kfsS --max-time 8 "$url" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
retry=$((retry + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
if [ -n "$URL_FRONT" ] && [ -n "$URL_BACK" ]; then
|
||||
FRONT_OK=false
|
||||
BACK_OK=false
|
||||
if check_url "${URL_FRONT}/login" 25 || check_url "${URL_FRONT}" 25; then
|
||||
FRONT_OK=true
|
||||
fi
|
||||
if check_url "${URL_BACK}/healthz" 25; then
|
||||
BACK_OK=true
|
||||
fi
|
||||
|
||||
echo ""
|
||||
if [ "$FRONT_OK" = true ] && [ "$BACK_OK" = true ]; then
|
||||
echo "✅ Tunnels Started and reachable!"
|
||||
else
|
||||
echo "⚠️ Tunnels started, but health check not fully ready yet. Retry in 10-30s."
|
||||
fi
|
||||
echo "----------------------------------------"
|
||||
echo "Frontend: $URL_FRONT"
|
||||
echo "Backend: $URL_BACK"
|
||||
echo "----------------------------------------"
|
||||
echo "ℹ️ Frontend tunnel can call backend via /api proxy; no need to set VITE_API_BASE_URL."
|
||||
echo "----------------------------------------"
|
||||
else
|
||||
echo "❌ Failed to get tunnel URLs. Check logs/tunnel-*.log"
|
||||
fi
|
||||
|
||||
wait $PID_FRONT $PID_BACK
|
||||
|
|
@ -1,46 +1,135 @@
|
|||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Cloudflare Tunnel Start Script for PMFlow
|
||||
# PMFlow 公网穿透启动器(保留为唯一 tunnel 入口)
|
||||
# 优先命名隧道;未配置时自动退回 quick tunnel。
|
||||
|
||||
CONFIG_FILE="devops/tunnel/config.yml"
|
||||
SETUP_SCRIPT="devops/tunnel/setup.sh"
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
|
||||
CONFIG_FILE="${SCRIPT_DIR}/config.yml"
|
||||
SETUP_SCRIPT="${SCRIPT_DIR}/setup.sh"
|
||||
LOG_DIR="${ROOT_DIR}/logs"
|
||||
|
||||
echo "🚀 Checking Cloudflare Tunnel configuration..."
|
||||
FRONTEND_PORT="${PMFLOW_TUNNEL_FRONTEND_PORT:-${FRONTEND_PORT:-5174}}"
|
||||
BACKEND_PORT="${PMFLOW_TUNNEL_BACKEND_PORT:-${BACKEND_PORT:-8000}}"
|
||||
TUNNEL_PROTOCOL="${PMFLOW_TUNNEL_PROTOCOL:-quic}"
|
||||
HEALTH_RETRIES="${PMFLOW_TUNNEL_HEALTH_RETRIES:-3}"
|
||||
FOREGROUND="${PMFLOW_TUNNEL_FOREGROUND:-1}"
|
||||
|
||||
# Check if config.yml exists
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
echo "⚠️ Named Tunnel Configuration not found."
|
||||
echo " If you have a domain, you can set up a stable tunnel."
|
||||
echo " If not, you can use Quick Tunnels (random URLs)."
|
||||
FRONT_LOG="${LOG_DIR}/tunnel-frontend.log"
|
||||
BACK_LOG="${LOG_DIR}/tunnel-backend.log"
|
||||
|
||||
extract_url() {
|
||||
local logfile="$1"
|
||||
grep -aEo 'https://[-a-z0-9]+\.trycloudflare\.com' "$logfile" 2>/dev/null | tail -1
|
||||
}
|
||||
|
||||
wait_for_url() {
|
||||
local logfile="$1"
|
||||
local retries="${2:-30}"
|
||||
local i=1
|
||||
local url=""
|
||||
while [ "$i" -le "$retries" ]; do
|
||||
url="$(extract_url "$logfile")"
|
||||
if [ -n "$url" ]; then
|
||||
echo "$url"
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
i=$((i + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
check_url() {
|
||||
local url="$1"
|
||||
local max_retry="$2"
|
||||
local retry=1
|
||||
while [ "$retry" -le "$max_retry" ]; do
|
||||
if curl -kfsS --max-time 8 "$url" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
retry=$((retry + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
run_quick_tunnels() {
|
||||
mkdir -p "$LOG_DIR"
|
||||
pkill -f "cloudflared tunnel .*127\\.0\\.0\\.1:${FRONTEND_PORT}" >/dev/null 2>&1 || true
|
||||
pkill -f "cloudflared tunnel .*127\\.0\\.0\\.1:${BACKEND_PORT}" >/dev/null 2>&1 || true
|
||||
|
||||
echo "🚀 启动临时隧道..."
|
||||
echo "📂 日志目录: ${LOG_DIR}"
|
||||
echo "🌐 前端隧道: 127.0.0.1:${FRONTEND_PORT}"
|
||||
cloudflared tunnel --protocol "${TUNNEL_PROTOCOL}" --url "http://127.0.0.1:${FRONTEND_PORT}" >"$FRONT_LOG" 2>&1 &
|
||||
local front_pid=$!
|
||||
|
||||
echo "🌐 后端隧道: 127.0.0.1:${BACKEND_PORT}"
|
||||
cloudflared tunnel --protocol "${TUNNEL_PROTOCOL}" --url "http://127.0.0.1:${BACKEND_PORT}" >"$BACK_LOG" 2>&1 &
|
||||
local back_pid=$!
|
||||
|
||||
echo "⏳ 等待隧道地址..."
|
||||
local front_url=""
|
||||
local back_url=""
|
||||
front_url="$(wait_for_url "$FRONT_LOG" 45 || true)"
|
||||
back_url="$(wait_for_url "$BACK_LOG" 45 || true)"
|
||||
|
||||
if [ -z "$front_url" ] || [ -z "$back_url" ]; then
|
||||
echo "❌ 未获取到公网地址。"
|
||||
echo " 前端日志: $FRONT_LOG"
|
||||
echo " 后端日志: $BACK_LOG"
|
||||
tail -n 20 "$FRONT_LOG" || true
|
||||
tail -n 20 "$BACK_LOG" || true
|
||||
kill "$front_pid" "$back_pid" >/dev/null 2>&1 || true
|
||||
return 1
|
||||
fi
|
||||
|
||||
local front_ok=false
|
||||
local back_ok=false
|
||||
if check_url "${front_url}/login" "$HEALTH_RETRIES" || check_url "${front_url}" "$HEALTH_RETRIES"; then
|
||||
front_ok=true
|
||||
fi
|
||||
if check_url "${back_url}/healthz" "$HEALTH_RETRIES"; then
|
||||
back_ok=true
|
||||
fi
|
||||
|
||||
if [ "$front_ok" = true ] && [ "$back_ok" = true ]; then
|
||||
echo "✅ 隧道连通成功"
|
||||
else
|
||||
echo "⚠️ 隧道已启动,但健康检查未全部通过(可稍后重试)"
|
||||
fi
|
||||
|
||||
echo "----------------------------------------"
|
||||
echo "前端: ${front_url}"
|
||||
echo "后端: ${back_url}"
|
||||
echo "----------------------------------------"
|
||||
echo "ℹ️ 停止命令: pkill -f \"cloudflared tunnel\""
|
||||
|
||||
if [ "$FOREGROUND" != "1" ]; then
|
||||
echo "✅ 已切到后台运行"
|
||||
echo " 前端 PID: ${front_pid}"
|
||||
echo " 后端 PID: ${back_pid}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
echo ""
|
||||
read -p "Do you have a domain and want to set up a named tunnel? (y/N): " SETUP_CHOICE
|
||||
echo "🔴 tunnel 已关闭"
|
||||
kill "$front_pid" "$back_pid" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
wait "$front_pid" "$back_pid"
|
||||
}
|
||||
|
||||
if [[ "$SETUP_CHOICE" =~ ^[Yy]$ ]]; then
|
||||
if [ -f "$SETUP_SCRIPT" ]; then
|
||||
# Run setup script and check its exit code
|
||||
if ! bash "$SETUP_SCRIPT"; then
|
||||
echo "❌ Setup failed or was cancelled."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "❌ Setup script not found at $SETUP_SCRIPT"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "🔄 Falling back to Quick Tunnels..."
|
||||
bash devops/tunnel/start-quick.sh
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Re-check config after potential setup
|
||||
echo "🚀 启动公网穿透..."
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
echo "✅ Configuration found."
|
||||
echo "🌐 Starting tunnel for Frontend and Backend..."
|
||||
|
||||
# Run the tunnel using the config file
|
||||
cloudflared tunnel run --config "$CONFIG_FILE"
|
||||
else
|
||||
echo "❌ Failed to establish configuration."
|
||||
exit 1
|
||||
echo "✅ 检测到命名隧道配置: ${CONFIG_FILE}"
|
||||
echo "🌐 使用命名隧道运行(前台)"
|
||||
exec cloudflared tunnel run --config "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
echo "⚠️ 未检测到命名隧道配置,自动切换临时隧道。"
|
||||
echo " 若需固定域名,请先执行: ${SETUP_SCRIPT}"
|
||||
run_quick_tunnels
|
||||
|
|
|
|||
|
|
@ -91,7 +91,24 @@ services:
|
|||
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:?missing_OLLAMA_BASE_URL}
|
||||
OLLAMA_MODEL: ${OLLAMA_MODEL:?missing_OLLAMA_MODEL}
|
||||
PYTHONUNBUFFERED: "1"
|
||||
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload", "--reload-dir", "app"]
|
||||
command:
|
||||
[
|
||||
"uvicorn",
|
||||
"app.main:app",
|
||||
"--host",
|
||||
"0.0.0.0",
|
||||
"--port",
|
||||
"8000",
|
||||
"--reload",
|
||||
"--reload-dir",
|
||||
"app",
|
||||
"--reload-exclude",
|
||||
"*/__pycache__/*",
|
||||
"--reload-exclude",
|
||||
"*.pyc",
|
||||
"--reload-exclude",
|
||||
"alembic/versions/*"
|
||||
]
|
||||
volumes:
|
||||
- ./backend:/app/backend
|
||||
- ./.openclaw:/app/.openclaw:ro
|
||||
|
|
|
|||
|
|
@ -603,17 +603,17 @@ export function FloatingQAChat() {
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
className={`fixed bottom-6 right-6 z-40 flex h-12 w-12 items-center justify-center rounded-full text-white shadow-xl ring-1 ring-black/10 transition ${
|
||||
className={`fixed bottom-4 right-4 z-[120] flex h-11 w-11 items-center justify-center rounded-full text-white shadow-xl ring-1 ring-black/10 transition md:bottom-6 md:right-6 ${
|
||||
open ? "bg-violet-700" : "bg-violet-600 hover:bg-violet-700"
|
||||
}`}
|
||||
aria-label="打开 AI 聊天"
|
||||
>
|
||||
<Sparkles className="h-5 w-5" />
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{unread ? <span className="absolute right-1 top-1 h-2.5 w-2.5 rounded-full bg-red-500" /> : null}
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={`fixed bottom-20 left-3 right-3 z-50 h-[72vh] rounded-2xl border bg-background/95 shadow-2xl backdrop-blur-sm transition-all duration-300 sm:bottom-24 sm:left-auto sm:right-6 sm:h-[78vh] sm:w-[380px] ${
|
||||
className={`fixed bottom-20 left-3 right-3 z-[121] h-[72vh] rounded-2xl border bg-background/95 shadow-2xl backdrop-blur-sm transition-all duration-300 sm:bottom-24 sm:left-auto sm:right-6 sm:h-[78vh] sm:w-[380px] ${
|
||||
open ? "translate-y-0 opacity-100" : "pointer-events-none translate-y-3 opacity-0"
|
||||
}`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,220 @@
|
|||
import { BellRing, CheckCircle2, ChevronDown, ChevronUp, CircleAlert, LoaderCircle } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatInboxAbsoluteTime, formatInboxTime } from "@/lib/formatInboxTime";
|
||||
import {
|
||||
mapDisplayKindLabel,
|
||||
mapIntentLabel,
|
||||
mapStageText,
|
||||
normalizeProjectLabel,
|
||||
sanitizeInboxSubtitle,
|
||||
sanitizeInboxTitle,
|
||||
} from "@/lib/inboxTextMap";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { InboxActionDescriptor, InboxDisplayKind, InboxItemViewModel } from "@/types/inbox";
|
||||
|
||||
const STATUS_STYLES: Record<InboxDisplayKind, string> = {
|
||||
action_required: "border-amber-500/40 bg-amber-500/10 text-amber-700",
|
||||
auto_running: "border-sky-500/40 bg-sky-500/10 text-sky-700",
|
||||
auto_done: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700",
|
||||
info: "border-slate-300 bg-slate-100 text-slate-600",
|
||||
};
|
||||
|
||||
const STATUS_ICON_STYLES: Record<InboxDisplayKind, string> = {
|
||||
action_required: "bg-amber-100 text-amber-700",
|
||||
auto_running: "bg-sky-100 text-sky-700",
|
||||
auto_done: "bg-emerald-100 text-emerald-700",
|
||||
info: "bg-slate-200 text-slate-600",
|
||||
};
|
||||
|
||||
function formatTechnicalValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return "-";
|
||||
if (typeof value === "string") return value.trim() || "-";
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveStatusIcon(displayKind: InboxDisplayKind) {
|
||||
if (displayKind === "action_required") return CircleAlert;
|
||||
if (displayKind === "auto_running") return LoaderCircle;
|
||||
if (displayKind === "auto_done") return CheckCircle2;
|
||||
return BellRing;
|
||||
}
|
||||
|
||||
export function buildInboxActionKey(item: InboxItemViewModel, action: InboxActionDescriptor): string {
|
||||
return `${item.id}:${action.kind}:${action.label}`;
|
||||
}
|
||||
|
||||
interface InboxListItemProps {
|
||||
item: InboxItemViewModel;
|
||||
expanded: boolean;
|
||||
pendingActionKey: string | null;
|
||||
onToggleExpand: (itemId: string) => void;
|
||||
onAction: (item: InboxItemViewModel, action: InboxActionDescriptor) => void;
|
||||
}
|
||||
|
||||
export default function InboxListItem({
|
||||
item,
|
||||
expanded,
|
||||
pendingActionKey,
|
||||
onToggleExpand,
|
||||
onAction,
|
||||
}: InboxListItemProps) {
|
||||
const isActionRow = item.display_kind === "action_required";
|
||||
const projectLabel = normalizeProjectLabel(item.project_name, item.project_id);
|
||||
const title = sanitizeInboxTitle(item.title);
|
||||
const subtitle = sanitizeInboxSubtitle(item.summary);
|
||||
const displayTime = formatInboxTime(item.updated_at || item.created_at);
|
||||
const fullTime = formatInboxAbsoluteTime(item.updated_at || item.created_at);
|
||||
const StatusIcon = resolveStatusIcon(item.display_kind);
|
||||
|
||||
const payload = (item.source_notification?.payload ?? {}) as Record<string, unknown>;
|
||||
const rawIntent = String(item.source_work_item?.intent || payload.intent || "").trim();
|
||||
const typedActions = payload.typed_actions ?? payload["typed actions"] ?? payload.typedActions;
|
||||
const chatId = payload.chat_id ?? payload.source_chat_id ?? payload.chatId;
|
||||
|
||||
const primaryActionKey = buildInboxActionKey(item, item.primary_action);
|
||||
const secondaryActionKey = buildInboxActionKey(item, item.secondary_action);
|
||||
const weakActionKey = buildInboxActionKey(item, item.weak_action);
|
||||
|
||||
return (
|
||||
<article className="overflow-hidden rounded-xl border border-slate-200 bg-white">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onToggleExpand(item.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onToggleExpand(item.id);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 md:px-4",
|
||||
isActionRow ? "min-h-[72px]" : "min-h-[52px]"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className={cn("mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full", STATUS_ICON_STYLES[item.display_kind])}>
|
||||
<StatusIcon className={cn("h-3.5 w-3.5", item.display_kind === "auto_running" && "animate-spin")} />
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||
<p className="line-clamp-1 text-sm font-semibold text-slate-900">{title}</p>
|
||||
<div className="ml-2 shrink-0 text-right">
|
||||
<div className="max-w-[120px] truncate text-[11px] text-slate-500 sm:max-w-[180px]">{projectLabel}</div>
|
||||
<div className="text-[11px] text-slate-400">{displayTime}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex items-center justify-between gap-2">
|
||||
<p className="line-clamp-1 min-w-0 text-xs text-slate-500">{subtitle}</p>
|
||||
<Badge variant="outline" className={cn("shrink-0 text-[11px]", STATUS_STYLES[item.display_kind])}>
|
||||
{mapDisplayKindLabel(item.display_kind)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center justify-end gap-2">
|
||||
{isActionRow && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 bg-blue-600 px-3 text-white hover:bg-blue-700"
|
||||
disabled={pendingActionKey === primaryActionKey}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onAction(item, item.primary_action);
|
||||
}}
|
||||
>
|
||||
{pendingActionKey === primaryActionKey ? "处理中…" : item.primary_action.label}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleExpand(item.id);
|
||||
}}
|
||||
>
|
||||
详情
|
||||
{expanded ? <ChevronUp className="ml-1 h-3.5 w-3.5" /> : <ChevronDown className="ml-1 h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t border-slate-200 bg-slate-50 px-3 py-3 md:px-4">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="rounded-lg bg-white px-3 py-2">
|
||||
<div className="text-[11px] font-semibold text-slate-500">当前阶段</div>
|
||||
<div className="mt-1 text-sm text-slate-700">{mapStageText(item.stage_text || mapDisplayKindLabel(item.display_kind))}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white px-3 py-2">
|
||||
<div className="text-[11px] font-semibold text-slate-500">下一步</div>
|
||||
<div className="mt-1 line-clamp-2 text-sm text-slate-700">{sanitizeInboxSubtitle(item.next_step_text || item.ai_plan || "暂无")}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 rounded-lg border border-dashed border-slate-200 bg-white px-3 py-2">
|
||||
<div className="text-[11px] font-semibold text-slate-500">技术字段(默认折叠)</div>
|
||||
<div className="mt-1 grid gap-1 text-xs text-slate-600 sm:grid-cols-2">
|
||||
<div>intent(可读):{mapIntentLabel(rawIntent)}</div>
|
||||
<div>intent(原始):{rawIntent || "-"}</div>
|
||||
<div>typed actions:{formatTechnicalValue(typedActions)}</div>
|
||||
<div>chat_id:{formatTechnicalValue(chatId)}</div>
|
||||
<div>重复次数:{item.dedupe_count}</div>
|
||||
<div>撤销状态:{item.can_undo ? `可撤销(截止 ${item.undo_deadline || "-"})` : "不可撤销"}</div>
|
||||
<div className="sm:col-span-2">完整时间戳:{fullTime}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{!isActionRow && item.primary_action.kind !== "none" && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 bg-blue-600 px-3 text-white hover:bg-blue-700"
|
||||
disabled={pendingActionKey === primaryActionKey}
|
||||
onClick={() => onAction(item, item.primary_action)}
|
||||
>
|
||||
{pendingActionKey === primaryActionKey ? "处理中…" : item.primary_action.label}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{item.secondary_action.kind !== "none" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
|
||||
disabled={pendingActionKey === secondaryActionKey}
|
||||
onClick={() => onAction(item, item.secondary_action)}
|
||||
>
|
||||
{pendingActionKey === secondaryActionKey ? "处理中…" : item.secondary_action.label}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{item.weak_action.kind === "none" ? (
|
||||
<span className="text-xs text-slate-500">{item.weak_action.label}</span>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 text-slate-600 hover:bg-slate-100"
|
||||
disabled={pendingActionKey === weakActionKey}
|
||||
onClick={() => onAction(item, item.weak_action)}
|
||||
>
|
||||
{pendingActionKey === weakActionKey ? "处理中…" : item.weak_action.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
|||
import { Outlet, useLocation } from "react-router-dom";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { TopBar } from "./TopBar";
|
||||
import { FloatingQAChat } from "@/components/ai-steward/FloatingQAChat";
|
||||
|
||||
export default function Layout() {
|
||||
const location = useLocation();
|
||||
|
|
@ -28,6 +29,7 @@ export default function Layout() {
|
|||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
<FloatingQAChat />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Bell, CheckCircle2, LoaderCircle, LogOut, Menu, ShieldAlert, User } from "lucide-react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { aiPmoApi, type AIPMOWorkItemListPayload } from "@/api/aiPmo";
|
||||
import { sstcApi, type SSTCRisks } from "@/api/sstc";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import { notificationsApi, type NotificationListPayload } from "@/api/notifications";
|
||||
import { SSTCRiskTopbarEntry } from "@/components/sstc/SSTCRiskFloatPanel";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { parseBackendDate } from "@/lib/datetime";
|
||||
import { zhCN } from "@/lib/zhCN";
|
||||
|
|
@ -27,6 +30,16 @@ const EMPTY_NOTIFICATIONS: NotificationListPayload = {
|
|||
const EMPTY_WORK_ITEMS: AIPMOWorkItemListPayload = {
|
||||
items: [],
|
||||
};
|
||||
const EMPTY_PROJECT_RISKS: SSTCRisks = {
|
||||
conflicts: [],
|
||||
risks: [],
|
||||
summary: {
|
||||
conflict_count: 0,
|
||||
risk_count: 0,
|
||||
blocked_task_count: 0,
|
||||
overdue_task_count: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const SECTION_STYLES: Record<InboxDisplayKind, string> = {
|
||||
action_required: "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
||||
|
|
@ -83,6 +96,14 @@ export function TopBar({ onMenuToggle }: TopBarProps) {
|
|||
const inboxData = useMemo(() => buildInboxView({ notifications, workItems }), [notifications, workItems]);
|
||||
const currentProjectId = location.pathname.match(/^\/projects\/(\d+)/)?.[1] || null;
|
||||
const inboxHref = currentProjectId ? `/inbox?project_id=${currentProjectId}` : "/inbox";
|
||||
const { data: boardOverviewPayload } = useQuery({
|
||||
queryKey: ["sstc-topbar-risk-overview", currentProjectId],
|
||||
queryFn: () => sstcApi.getBoardOverview(currentProjectId!),
|
||||
enabled: !!currentProjectId,
|
||||
refetchInterval: POLL_INTERVAL_MS,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
const currentProjectRisks = boardOverviewPayload?.risks || EMPTY_PROJECT_RISKS;
|
||||
|
||||
const fetchBellData = async (notifyOnNew: boolean) => {
|
||||
const [notificationsResult, workItemsResult] = await Promise.allSettled([
|
||||
|
|
@ -300,6 +321,7 @@ export function TopBar({ onMenuToggle }: TopBarProps) {
|
|||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{currentProjectId && <SSTCRiskTopbarEntry risks={currentProjectRisks} />}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="rounded-full">
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ export function SSTCGlobalHeader({
|
|||
const progress = overview.task_progress.progress || 0;
|
||||
const ratio = overview.workload_health.avg_ratio || 0;
|
||||
const workloadMembers = overview.workload_health.members || [];
|
||||
const taskCount = Number(overview.project.task_count || 0);
|
||||
const projectLabel = formatProjectNameWithId({
|
||||
id: overview.project.id,
|
||||
title: overview.project.title,
|
||||
|
|
@ -98,7 +99,12 @@ export function SSTCGlobalHeader({
|
|||
<CardContent className="space-y-1 text-sm text-muted-foreground">
|
||||
<p>项目描述:{overview.project.description || "暂无"}</p>
|
||||
<p>创建时间(北京时间):{formatBeijingDateTime(overview.project.created_at)}</p>
|
||||
<p>任务总数:{overview.project.task_count}</p>
|
||||
<p>任务总数:{taskCount}</p>
|
||||
{taskCount === 0 ? (
|
||||
<div className="mt-2 rounded-md border border-dashed border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-600">
|
||||
当前暂无任务。建议进入「任务阶段」执行 AI 拆解或手动创建首批任务。
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,53 +1,37 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { AlertTriangle, Brain, Minimize2, Maximize2 } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { AlertTriangle, Brain, Maximize2 } from "lucide-react";
|
||||
import type { SSTCRisks } from "@/api/sstc";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
|
||||
interface SSTCRiskFloatPanelProps {
|
||||
interface SSTCRiskPanelProps {
|
||||
risks: SSTCRisks;
|
||||
onJump?: (entityId: string) => void;
|
||||
}
|
||||
|
||||
export function SSTCRiskFloatPanel({ risks, onJump }: SSTCRiskFloatPanelProps) {
|
||||
const [minimized, setMinimized] = useState(true);
|
||||
function formatBadgeCount(total: number): string {
|
||||
return total > 99 ? "99+" : String(total);
|
||||
}
|
||||
|
||||
function computeRiskTotal(risks: SSTCRisks): number {
|
||||
return (risks.summary.conflict_count || 0) + (risks.summary.risk_count || 0);
|
||||
}
|
||||
|
||||
export function SSTCRiskCenterPanel({ risks, onJump }: SSTCRiskPanelProps) {
|
||||
const total = useMemo(
|
||||
() => (risks.summary.conflict_count || 0) + (risks.summary.risk_count || 0),
|
||||
() => computeRiskTotal(risks),
|
||||
[risks]
|
||||
);
|
||||
|
||||
if (minimized) {
|
||||
return (
|
||||
<div className="fixed bottom-4 right-2 z-40 sm:right-4">
|
||||
<Button
|
||||
variant="default"
|
||||
className="relative w-[min(220px,calc(100vw-1rem))] justify-center"
|
||||
onClick={() => setMinimized(false)}
|
||||
title="打开预警中心"
|
||||
>
|
||||
<AlertTriangle className="mr-2 h-4 w-4" />
|
||||
预警中心
|
||||
{total > 0 && (
|
||||
<span className="absolute -right-2 -top-2 rounded-full bg-red-600 px-1.5 text-[10px] text-white">
|
||||
{total}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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="w-[min(360px,calc(100vw-1rem))] rounded-lg bg-card">
|
||||
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||
<p className="text-sm font-semibold">项目预警中心</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full bg-red-600 px-2 py-0.5 text-[10px] text-white">
|
||||
{total}
|
||||
{formatBadgeCount(total)}
|
||||
</span>
|
||||
<button onClick={() => setMinimized(true)} title="最小化">
|
||||
<Minimize2 className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
|
|
@ -108,3 +92,31 @@ export function SSTCRiskFloatPanel({ risks, onJump }: SSTCRiskFloatPanelProps) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SSTCRiskTopbarEntry({ risks, onJump }: SSTCRiskPanelProps) {
|
||||
const total = useMemo(() => computeRiskTotal(risks), [risks]);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="relative h-9 gap-1.5 rounded-full px-3 text-slate-700 hover:bg-slate-100"
|
||||
title="打开预警中心"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">预警</span>
|
||||
{total > 0 && (
|
||||
<span className="absolute -right-0.5 -top-0.5 min-w-4 rounded-full bg-red-500 px-1 text-center text-[10px] leading-4 text-white">
|
||||
{formatBadgeCount(total)}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[min(360px,calc(100vw-1rem))] rounded-lg border p-0">
|
||||
<SSTCRiskCenterPanel risks={risks} onJump={onJump} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,264 +1,56 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
BellRing,
|
||||
CheckCircle2,
|
||||
Filter,
|
||||
LoaderCircle,
|
||||
ShieldAlert,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { BellRing } from "lucide-react";
|
||||
import { aiPmoApi } from "@/api/aiPmo";
|
||||
import { notificationsApi } from "@/api/notifications";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import InboxListItem, { buildInboxActionKey } from "@/components/inbox/InboxListItem";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import { buildInboxView, getInboxSectionLabel } from "@/lib/inbox";
|
||||
import { getApiErrorMessage } from "@/lib/apiError";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toZhPriority } from "@/lib/display";
|
||||
import { parseBackendDate } from "@/lib/datetime";
|
||||
import type { InboxActionDescriptor, InboxDisplayKind, InboxItemViewModel, InboxSectionKey } from "@/types/inbox";
|
||||
import { buildInboxView } from "@/lib/inbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { InboxActionDescriptor, InboxDisplayKind, InboxItemViewModel } from "@/types/inbox";
|
||||
|
||||
const POLL_INTERVAL_MS = 15000;
|
||||
|
||||
const STATUS_LABELS: Record<InboxDisplayKind, string> = {
|
||||
action_required: "需你决策",
|
||||
auto_running: "AI 已接手",
|
||||
auto_done: "AI 执行完成",
|
||||
info: "仅通知",
|
||||
type InboxTabKey = "all" | "needs_action" | "in_progress" | "done";
|
||||
|
||||
const TAB_LABELS: Record<InboxTabKey, string> = {
|
||||
all: "全部",
|
||||
needs_action: "待处理",
|
||||
in_progress: "进行中",
|
||||
done: "已完成",
|
||||
};
|
||||
|
||||
const STATUS_STYLES: Record<InboxDisplayKind, string> = {
|
||||
action_required: "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
||||
auto_running: "border-sky-500/40 bg-sky-500/10 text-sky-700 dark:text-sky-300",
|
||||
auto_done: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
||||
info: "border-slate-400/40 bg-slate-500/10 text-slate-700 dark:text-slate-300",
|
||||
const TAB_TO_DISPLAY_KIND: Partial<Record<InboxTabKey, InboxDisplayKind>> = {
|
||||
needs_action: "action_required",
|
||||
in_progress: "auto_running",
|
||||
done: "auto_done",
|
||||
};
|
||||
|
||||
const METRIC_CARD_STYLES = [
|
||||
"bg-blue-50 text-blue-700",
|
||||
"bg-sky-50 text-sky-700",
|
||||
"bg-emerald-50 text-emerald-700",
|
||||
"bg-amber-50 text-amber-700",
|
||||
"bg-violet-50 text-violet-700",
|
||||
];
|
||||
|
||||
const PRIORITY_OPTIONS = [
|
||||
{ value: "all", label: "全部优先级" },
|
||||
{ value: "high", label: "高优先级" },
|
||||
{ value: "medium", label: "中优先级" },
|
||||
{ value: "low", label: "低优先级" },
|
||||
];
|
||||
|
||||
function matchesPriority(item: InboxItemViewModel, priorityFilter: string): boolean {
|
||||
if (priorityFilter === "all") return true;
|
||||
if (priorityFilter === "high") return item.priority_rank >= 3;
|
||||
if (priorityFilter === "medium") return item.priority_rank === 2;
|
||||
return item.priority_rank <= 1;
|
||||
function normalizeTabKey(raw?: string | null): InboxTabKey {
|
||||
const value = String(raw || "").trim();
|
||||
if (value === "action_required" || value === "needs_action") return "needs_action";
|
||||
if (value === "auto_running" || value === "in_progress") return "in_progress";
|
||||
if (value === "auto_done" || value === "done") return "done";
|
||||
return "all";
|
||||
}
|
||||
|
||||
function buildScopedSections(items: InboxItemViewModel[]) {
|
||||
const sectionKeys: InboxSectionKey[] = ["all", "action_required", "auto_running", "auto_done", "info"];
|
||||
return sectionKeys.map((key) => ({
|
||||
key,
|
||||
label: getInboxSectionLabel(key),
|
||||
count: key === "all" ? items.length : items.filter((item) => item.display_kind === key).length,
|
||||
}));
|
||||
function sortByLatest(items: InboxItemViewModel[]): InboxItemViewModel[] {
|
||||
const toTimestamp = (item: InboxItemViewModel): number => {
|
||||
const date = parseBackendDate(item.updated_at || item.created_at);
|
||||
return date ? date.getTime() : -1;
|
||||
};
|
||||
|
||||
return [...items].sort((a, b) => toTimestamp(b) - toTimestamp(a));
|
||||
}
|
||||
|
||||
function isToday(value?: string | null) {
|
||||
const date = parseBackendDate(value);
|
||||
if (!date) return false;
|
||||
const now = new Date();
|
||||
return (
|
||||
date.getFullYear() === now.getFullYear() &&
|
||||
date.getMonth() === now.getMonth() &&
|
||||
date.getDate() === now.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
function formatDetailTime(value?: string | null) {
|
||||
const date = parseBackendDate(value);
|
||||
if (!date) return value || "-";
|
||||
return date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function formatJsonBlock(value: unknown): string {
|
||||
if (!value) return "暂无";
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function buildActionKey(item: InboxItemViewModel, action: InboxActionDescriptor): string {
|
||||
return `${item.id}:${action.kind}:${action.label}`;
|
||||
}
|
||||
|
||||
function renderMiniList(title: string, description: string, items: InboxItemViewModel[], onOpen: (item: InboxItemViewModel) => void) {
|
||||
return (
|
||||
<Card className="rounded-2xl border-0 bg-white shadow-[0_8px_22px_rgba(15,23,42,0.08)]">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base text-slate-900">{title}</CardTitle>
|
||||
<CardDescription className="text-slate-500">{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{items.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-3 py-4 text-sm text-slate-500">
|
||||
当前为空
|
||||
</div>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="w-full rounded-xl border border-slate-100 bg-slate-50 px-3 py-3 text-left transition hover:border-slate-200 hover:bg-white"
|
||||
onClick={() => onOpen(item)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className="line-clamp-1 text-sm font-semibold text-slate-900">{item.title}</span>
|
||||
<Badge variant="outline" className={cn("text-[11px]", STATUS_STYLES[item.display_kind])}>
|
||||
{STATUS_LABELS[item.display_kind]}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-slate-500">{item.summary}</p>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function InboxCard({
|
||||
item,
|
||||
onAction,
|
||||
pendingActionKey,
|
||||
}: {
|
||||
item: InboxItemViewModel;
|
||||
onAction: (item: InboxItemViewModel, action: InboxActionDescriptor) => void;
|
||||
pendingActionKey: string | null;
|
||||
}) {
|
||||
const stageCards = [
|
||||
{ label: "当前阶段", value: item.stage_text || STATUS_LABELS[item.display_kind] },
|
||||
{ label: "预计下一步", value: item.next_step_text || item.ai_plan || "-" },
|
||||
{ label: "撤销状态", value: item.can_undo ? `可撤销,截止 ${item.undo_deadline || "-"}` : "不可撤销" },
|
||||
];
|
||||
const primaryActionKey = buildActionKey(item, item.primary_action);
|
||||
const secondaryActionKey = buildActionKey(item, item.secondary_action);
|
||||
const weakActionKey = buildActionKey(item, item.weak_action);
|
||||
const primaryPending = pendingActionKey === primaryActionKey;
|
||||
const secondaryPending = pendingActionKey === secondaryActionKey;
|
||||
const weakPending = pendingActionKey === weakActionKey;
|
||||
|
||||
return (
|
||||
<Card className="rounded-2xl border-0 bg-white shadow-[0_8px_22px_rgba(15,23,42,0.08)] transition hover:-translate-y-0.5 hover:shadow-[0_14px_30px_rgba(15,23,42,0.12)]">
|
||||
<CardHeader className="space-y-3 pb-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline" className={cn("text-[11px]", STATUS_STYLES[item.display_kind])}>
|
||||
{STATUS_LABELS[item.display_kind]}
|
||||
</Badge>
|
||||
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">
|
||||
{toZhPriority(item.priority)}
|
||||
</span>
|
||||
{item.project_id && (
|
||||
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">
|
||||
{item.project_name || `项目 #${item.project_id}`}
|
||||
</span>
|
||||
)}
|
||||
{item.dedupe_count > 1 && (
|
||||
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">
|
||||
重复 {item.dedupe_count} 次
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xs text-slate-400">{item.updated_at || item.created_at || "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-base text-slate-900">{item.title}</CardTitle>
|
||||
<CardDescription className="line-clamp-2 text-slate-500">{item.summary}</CardDescription>
|
||||
</div>
|
||||
<BellRing className="mt-0.5 h-4 w-4 shrink-0 text-slate-400" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{item.ai_plan && (
|
||||
<div className="rounded-xl bg-slate-50 px-4 py-3">
|
||||
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">AI 已做/将做什么</div>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-700">{item.ai_plan}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.blocking_reason && (
|
||||
<div className="rounded-xl border border-amber-100 bg-amber-50 px-4 py-3">
|
||||
<div className="text-xs font-semibold tracking-[0.18em] text-amber-700">为什么需要你</div>
|
||||
<p className="mt-2 text-sm leading-6 text-amber-900/80">{item.blocking_reason}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(item.stage_text || item.next_step_text || item.can_undo) && (
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{stageCards.map((stage) => (
|
||||
<div key={stage.label} className="rounded-xl bg-slate-50 px-4 py-3">
|
||||
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">{stage.label}</div>
|
||||
<p className="mt-2 text-sm text-slate-700">{stage.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
className="bg-blue-600 text-white hover:bg-blue-700"
|
||||
disabled={primaryPending}
|
||||
onClick={() => onAction(item, item.primary_action)}
|
||||
>
|
||||
{primaryPending ? "处理中…" : item.primary_action.label}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
|
||||
disabled={secondaryPending}
|
||||
onClick={() => onAction(item, item.secondary_action)}
|
||||
>
|
||||
{secondaryPending ? "加载中…" : item.secondary_action.label}
|
||||
</Button>
|
||||
{item.weak_action.kind === "none" ? (
|
||||
<span className="px-3 py-2 text-sm text-slate-400">{item.weak_action.label}</span>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-slate-500 hover:bg-slate-50 hover:text-slate-700"
|
||||
disabled={weakPending}
|
||||
onClick={() => onAction(item, item.weak_action)}
|
||||
>
|
||||
{weakPending ? "处理中…" : item.weak_action.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
function matchesTab(item: InboxItemViewModel, tab: InboxTabKey): boolean {
|
||||
if (tab === "all") return true;
|
||||
return item.display_kind === TAB_TO_DISPLAY_KIND[tab];
|
||||
}
|
||||
|
||||
export default function InboxPage() {
|
||||
|
|
@ -266,34 +58,25 @@ export default function InboxPage() {
|
|||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [notificationDetailItem, setNotificationDetailItem] = useState<InboxItemViewModel | null>(null);
|
||||
const [workItemDetailItem, setWorkItemDetailItem] = useState<InboxItemViewModel | null>(null);
|
||||
const [pendingActionKey, setPendingActionKey] = useState<string | null>(null);
|
||||
const [priorityFilter, setPriorityFilter] = useState("all");
|
||||
const [onlyDecision, setOnlyDecision] = useState(false);
|
||||
const [onlyUnread, setOnlyUnread] = useState(false);
|
||||
const activeSection = (searchParams.get("section") as InboxSectionKey) || "all";
|
||||
const [expandedItemId, setExpandedItemId] = useState<string | null>(null);
|
||||
|
||||
const activeTab = normalizeTabKey(searchParams.get("section"));
|
||||
const projectFilter = searchParams.get("project_id") || "all";
|
||||
const searchValue = searchParams.get("q") || "";
|
||||
|
||||
const notificationsQuery = useQuery({
|
||||
queryKey: ["notifications", "inbox"],
|
||||
queryFn: () => notificationsApi.list({ limit: 60 }),
|
||||
queryFn: () => notificationsApi.list({ limit: 80 }),
|
||||
refetchInterval: POLL_INTERVAL_MS,
|
||||
});
|
||||
|
||||
const workItemsQuery = useQuery({
|
||||
queryKey: ["ai-pmo-work-items", "inbox"],
|
||||
queryFn: () => aiPmoApi.listWorkItems({ limit: 60 }),
|
||||
queryFn: () => aiPmoApi.listWorkItems({ limit: 80 }),
|
||||
refetchInterval: POLL_INTERVAL_MS,
|
||||
});
|
||||
|
||||
const workItemDetailQuery = useQuery({
|
||||
queryKey: ["ai-pmo-work-item-detail", workItemDetailItem?.source_work_item?.id || null],
|
||||
queryFn: () => aiPmoApi.getWorkItem(Number(workItemDetailItem?.source_work_item?.id)),
|
||||
enabled: Boolean(workItemDetailItem?.source_work_item?.id),
|
||||
});
|
||||
|
||||
const inboxData = useMemo(() => {
|
||||
return buildInboxView({
|
||||
notifications: notificationsQuery.data || { items: [], unread_count: 0 },
|
||||
|
|
@ -317,42 +100,35 @@ export default function InboxPage() {
|
|||
});
|
||||
}, [inboxData.items, projectFilter]);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const query = searchValue.trim().toLowerCase();
|
||||
return projectScopedItems.filter((item) => {
|
||||
if (activeSection !== "all" && item.display_kind !== activeSection) return false;
|
||||
if (onlyDecision && item.display_kind !== "action_required") return false;
|
||||
if (onlyUnread && item.is_read) return false;
|
||||
if (!matchesPriority(item, priorityFilter)) return false;
|
||||
if (!query) return true;
|
||||
return `${item.title} ${item.summary} ${item.ai_plan || ""} ${item.project_name || ""}`.toLowerCase().includes(query);
|
||||
});
|
||||
}, [activeSection, onlyDecision, onlyUnread, priorityFilter, projectScopedItems, searchValue]);
|
||||
|
||||
const scopedSections = useMemo(() => buildScopedSections(projectScopedItems), [projectScopedItems]);
|
||||
const scopedLeaderSummary = useMemo(() => {
|
||||
const tabCounts = useMemo(() => {
|
||||
return {
|
||||
decision_count: projectScopedItems.filter((item) => item.display_kind === "action_required").length,
|
||||
auto_running_count: projectScopedItems.filter((item) => item.display_kind === "auto_running").length,
|
||||
auto_done_today: projectScopedItems.filter(
|
||||
(item) => item.display_kind === "auto_done" && isToday(item.updated_at || item.created_at)
|
||||
).length,
|
||||
high_priority_blocked_count: projectScopedItems.filter(
|
||||
(item) => item.display_kind === "action_required" && item.priority_rank >= 3 && Boolean(item.blocking_reason)
|
||||
).length,
|
||||
external_authorization_count: projectScopedItems.filter(
|
||||
(item) =>
|
||||
item.display_kind === "action_required" &&
|
||||
/(github|gitlab|jira|接入|授权|跨系统|外部系统)/i.test(`${item.title} ${item.summary} ${item.blocking_reason || ""}`)
|
||||
).length,
|
||||
top_decisions: projectScopedItems.filter((item) => item.display_kind === "action_required").slice(0, 3),
|
||||
recent_completions: projectScopedItems.filter((item) => item.display_kind === "auto_done").slice(0, 3),
|
||||
};
|
||||
all: projectScopedItems.length,
|
||||
needs_action: projectScopedItems.filter((item) => item.display_kind === "action_required").length,
|
||||
in_progress: projectScopedItems.filter((item) => item.display_kind === "auto_running").length,
|
||||
done: projectScopedItems.filter((item) => item.display_kind === "auto_done").length,
|
||||
} satisfies Record<InboxTabKey, number>;
|
||||
}, [projectScopedItems]);
|
||||
|
||||
const setSection = (section: string) => {
|
||||
const searchedItems = useMemo(() => {
|
||||
const query = searchValue.trim().toLowerCase();
|
||||
if (!query) return projectScopedItems;
|
||||
|
||||
return projectScopedItems.filter((item) => {
|
||||
return `${item.title} ${item.summary} ${item.project_name || ""} ${item.stage_text || ""}`
|
||||
.toLowerCase()
|
||||
.includes(query);
|
||||
});
|
||||
}, [projectScopedItems, searchValue]);
|
||||
|
||||
const feedItems = useMemo(() => {
|
||||
const tabItems = searchedItems.filter((item) => matchesTab(item, activeTab));
|
||||
return sortByLatest(tabItems);
|
||||
}, [searchedItems, activeTab]);
|
||||
|
||||
const setTab = (tab: string) => {
|
||||
const normalized = normalizeTabKey(tab);
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set("section", section);
|
||||
next.set("section", normalized);
|
||||
setSearchParams(next);
|
||||
};
|
||||
|
||||
|
|
@ -370,6 +146,10 @@ export default function InboxPage() {
|
|||
setSearchParams(next);
|
||||
};
|
||||
|
||||
const toggleExpand = (itemId: string) => {
|
||||
setExpandedItemId((current) => (current === itemId ? null : itemId));
|
||||
};
|
||||
|
||||
const refreshAll = async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["notifications", "inbox"] }),
|
||||
|
|
@ -382,28 +162,12 @@ export default function InboxPage() {
|
|||
try {
|
||||
await notificationsApi.markRead(Number(item.source_notification.id));
|
||||
} catch {
|
||||
// 不阻断后续跳转。
|
||||
// 不阻断后续操作。
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openNotificationDetail = async (item: InboxItemViewModel) => {
|
||||
await markNotificationRead(item);
|
||||
setNotificationDetailItem(item);
|
||||
await refreshAll();
|
||||
};
|
||||
|
||||
const openWorkItemDetail = async (item: InboxItemViewModel) => {
|
||||
setWorkItemDetailItem(item);
|
||||
await refreshAll();
|
||||
};
|
||||
|
||||
const openTarget = async (item: InboxItemViewModel, options?: { forceDirect?: boolean }) => {
|
||||
if (!options?.forceDirect && item.source_kind === "notification" && item.display_kind === "info") {
|
||||
await openNotificationDetail(item);
|
||||
return;
|
||||
}
|
||||
|
||||
const openTarget = async (item: InboxItemViewModel) => {
|
||||
await markNotificationRead(item);
|
||||
|
||||
if (item.target_url) {
|
||||
|
|
@ -412,60 +176,46 @@ export default function InboxPage() {
|
|||
await refreshAll();
|
||||
return;
|
||||
}
|
||||
if (item.source_kind === "ai_pmo_work_item") {
|
||||
await openWorkItemDetail(item);
|
||||
return;
|
||||
}
|
||||
toast({ title: "当前卡片暂无可打开的详情入口", variant: "error" });
|
||||
|
||||
toast({ title: "当前消息暂无可打开的目标入口", variant: "default" });
|
||||
};
|
||||
|
||||
const handleAction = async (item: InboxItemViewModel, action: InboxActionDescriptor) => {
|
||||
const actionKey = buildActionKey(item, action);
|
||||
const actionKey = buildInboxActionKey(item, action);
|
||||
setPendingActionKey(actionKey);
|
||||
|
||||
try {
|
||||
if (item.source_kind === "notification" && action.label === "查看详情") {
|
||||
await openNotificationDetail(item);
|
||||
return;
|
||||
}
|
||||
if (action.kind === "open_target") {
|
||||
await openTarget(item);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.kind === "open_detail") {
|
||||
if (item.source_kind === "notification") {
|
||||
await openNotificationDetail(item);
|
||||
return;
|
||||
}
|
||||
await openWorkItemDetail(item);
|
||||
await markNotificationRead(item);
|
||||
toggleExpand(item.id);
|
||||
await refreshAll();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.kind === "confirm" && item.source_kind === "ai_pmo_work_item" && item.source_work_item) {
|
||||
const result = await aiPmoApi.decideWorkItem(item.source_work_item.id, {
|
||||
action: "approve",
|
||||
project_id: item.project_id ?? undefined,
|
||||
});
|
||||
await refreshAll();
|
||||
|
||||
const followupReply = String(result.followup_reply || "").trim();
|
||||
if (result.status === "succeeded") {
|
||||
toast({ title: followupReply || "AI PMO 已继续执行并完成该工作项", variant: "success" });
|
||||
toast({ title: followupReply || "已继续执行并完成", variant: "success" });
|
||||
} else if (result.status === "pending_manual") {
|
||||
toast({ title: followupReply || "继续执行后仍需补充信息或人工确认", variant: "default" });
|
||||
toast({ title: followupReply || "继续执行后仍需人工确认", variant: "default" });
|
||||
} else {
|
||||
toast({ title: followupReply || "继续执行失败,请查看详情", variant: "error" });
|
||||
}
|
||||
setWorkItemDetailItem({
|
||||
...item,
|
||||
display_kind:
|
||||
result.status === "succeeded"
|
||||
? "auto_done"
|
||||
: result.status === "pending_manual"
|
||||
? "action_required"
|
||||
: "info",
|
||||
stage_text: String(result.target_state || item.stage_text || "").trim() || item.stage_text,
|
||||
next_step_text: String(result.followup_reply || item.next_step_text || "").trim() || item.next_step_text,
|
||||
});
|
||||
setExpandedItemId(item.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.kind === "reject" && item.source_kind === "ai_pmo_work_item" && item.source_work_item) {
|
||||
const result = await aiPmoApi.decideWorkItem(item.source_work_item.id, {
|
||||
action: "reject",
|
||||
|
|
@ -474,22 +224,17 @@ export default function InboxPage() {
|
|||
await refreshAll();
|
||||
const followupReply = String(result.followup_reply || "").trim();
|
||||
toast({ title: followupReply || "已标记失效,不再继续执行", variant: "default" });
|
||||
if (workItemDetailItem?.id === item.id) {
|
||||
setWorkItemDetailItem(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.kind === "mark_read" && item.source_kind === "notification" && item.source_notification) {
|
||||
await notificationsApi.markRead(item.source_notification.id);
|
||||
await refreshAll();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.kind === "none") {
|
||||
if (item.source_kind === "ai_pmo_work_item") {
|
||||
await openWorkItemDetail(item);
|
||||
return;
|
||||
}
|
||||
toast({ title: action.label, variant: "default" });
|
||||
toggleExpand(item.id);
|
||||
}
|
||||
} catch (error) {
|
||||
toast({ title: getApiErrorMessage(error), variant: "error" });
|
||||
|
|
@ -501,8 +246,8 @@ export default function InboxPage() {
|
|||
const markAllRead = async () => {
|
||||
try {
|
||||
await notificationsApi.markAllRead();
|
||||
toast({ title: "站内通知已全部设为已读", variant: "success" });
|
||||
await refreshAll();
|
||||
toast({ title: "站内通知已全部设为已读", variant: "success" });
|
||||
} catch (error) {
|
||||
toast({ title: getApiErrorMessage(error, "全部已读失败"), variant: "error" });
|
||||
}
|
||||
|
|
@ -511,82 +256,28 @@ export default function InboxPage() {
|
|||
const isLoading = notificationsQuery.isLoading || workItemsQuery.isLoading;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6 pb-16">
|
||||
<section className="rounded-2xl bg-[#F8F9FA] p-4 md:p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-4 pb-12">
|
||||
<section className="rounded-2xl bg-[#F8F9FA] p-3 md:p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-slate-900">消息台</h1>
|
||||
<p className="mt-1 text-slate-500">AI 决策收件箱 · 先看摘要,再处理真正需要你拍板的事项</p>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
当前结果:{filteredItems.length} 条
|
||||
{projectFilter !== "all" ? `(已筛选项目 #${projectFilter})` : "(全租户汇总)"}
|
||||
</p>
|
||||
<h1 className="text-xl font-bold tracking-tight text-slate-900 md:text-2xl">消息台</h1>
|
||||
<p className="mt-1 text-xs text-slate-500 md:text-sm">列表 Feed · 最新在上 · 支持移动端</p>
|
||||
<p className="mt-1 text-xs text-slate-500">当前结果:{feedItems.length} 条</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
|
||||
className="h-9 border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
|
||||
onClick={markAllRead}
|
||||
disabled={notificationsQuery.data?.unread_count === 0}
|
||||
>
|
||||
全部已读
|
||||
</Button>
|
||||
<Button className="bg-blue-600 text-white hover:bg-blue-700" onClick={() => setSection("action_required")}>
|
||||
<SlidersHorizontal className="mr-2 h-4 w-4" />
|
||||
先看待决策
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-2xl bg-white p-4 shadow-[0_8px_22px_rgba(15,23,42,0.08)]">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700">
|
||||
AI 决策收件箱
|
||||
</span>
|
||||
<p className="text-sm font-medium text-slate-700">铃铛只看摘要,完整工作流统一沉到 Inbox</p>
|
||||
</div>
|
||||
<CardDescription className="mt-2 text-slate-500">
|
||||
只保留真正可执行的事项;查询、闲聊、健康提示默认不进入消息台。
|
||||
</CardDescription>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
||||
{[
|
||||
{ icon: ShieldAlert, label: "需你决策", value: scopedLeaderSummary.decision_count },
|
||||
{ icon: LoaderCircle, label: "AI处理中", value: scopedLeaderSummary.auto_running_count },
|
||||
{ icon: CheckCircle2, label: "今日自动完成", value: scopedLeaderSummary.auto_done_today },
|
||||
{ icon: Sparkles, label: "高优先级阻塞", value: scopedLeaderSummary.high_priority_blocked_count },
|
||||
{ icon: Filter, label: "待授权外部接入", value: scopedLeaderSummary.external_authorization_count },
|
||||
].map(({ icon: Icon, label, value }, index) => (
|
||||
<div key={label} className="rounded-xl bg-slate-50 px-3 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-slate-500">{label}</div>
|
||||
<div className="mt-2 text-2xl font-bold text-slate-900">{value}</div>
|
||||
</div>
|
||||
<span className={cn("inline-flex h-9 w-9 items-center justify-center rounded-full", METRIC_CARD_STYLES[index])}>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||||
{renderMiniList("最需要你拍板的 3 件事", "优先看高风险、上下文不完整或需要授权的事项。", scopedLeaderSummary.top_decisions, (item) =>
|
||||
void handleAction(item, item.primary_action)
|
||||
)}
|
||||
{renderMiniList("AI 刚完成的 3 件事", "自动结果会优先给出产物和影响范围。", scopedLeaderSummary.recent_completions, (item) =>
|
||||
void handleAction(item, item.primary_action)
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl bg-[#F8F9FA] p-4 md:p-5">
|
||||
<div className="rounded-2xl bg-white p-4 shadow-[0_8px_22px_rgba(15,23,42,0.08)]">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="w-full min-w-0 sm:min-w-[220px] sm:flex-1">
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-[minmax(0,1fr)_180px]">
|
||||
<Input
|
||||
placeholder="搜索标题、摘要、项目"
|
||||
placeholder="搜索标题、副标题、项目"
|
||||
defaultValue={searchValue}
|
||||
className="border-slate-200 bg-white text-slate-700 placeholder:text-slate-400"
|
||||
onBlur={(event) => setSearch(event.target.value)}
|
||||
|
|
@ -594,11 +285,11 @@ export default function InboxPage() {
|
|||
if (event.key === "Enter") setSearch((event.target as HTMLInputElement).value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={projectFilter}
|
||||
onChange={(event) => setProject(event.target.value)}
|
||||
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-700 sm:w-auto"
|
||||
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-700"
|
||||
>
|
||||
<option value="all">全部项目</option>
|
||||
{projectOptions.map((option) => (
|
||||
|
|
@ -607,307 +298,60 @@ export default function InboxPage() {
|
|||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={priorityFilter}
|
||||
onChange={(event) => setPriorityFilter(event.target.value)}
|
||||
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-700 sm:w-auto"
|
||||
>
|
||||
{PRIORITY_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="inline-flex items-center gap-2 rounded-full bg-slate-50 px-3 py-2 text-sm text-slate-600">
|
||||
<Switch checked={onlyDecision} onCheckedChange={setOnlyDecision} />
|
||||
仅需决策
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-2 rounded-full bg-slate-50 px-3 py-2 text-sm text-slate-600">
|
||||
<Switch checked={onlyUnread} onCheckedChange={setOnlyUnread} />
|
||||
仅未读
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeSection} onValueChange={setSection}>
|
||||
<TabsList className="mt-4 grid h-auto w-full grid-cols-2 gap-2 rounded-2xl bg-slate-50 p-2 md:grid-cols-5">
|
||||
{scopedSections.map((section) => (
|
||||
<TabsTrigger
|
||||
key={section.key}
|
||||
value={section.key}
|
||||
className="rounded-xl border border-transparent text-slate-600 data-[state=active]:border-slate-200 data-[state=active]:bg-white data-[state=active]:text-slate-900"
|
||||
<div className="mt-3">
|
||||
<div className="mb-2 text-xs text-slate-500">信息流筛选(单列时间流,不分栏)</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(Object.keys(TAB_LABELS) as InboxTabKey[]).map((tabKey) => (
|
||||
<button
|
||||
key={tabKey}
|
||||
type="button"
|
||||
onClick={() => setTab(tabKey)}
|
||||
className={cn(
|
||||
"inline-flex h-8 items-center gap-1 rounded-full border px-3 text-xs transition sm:text-sm",
|
||||
activeTab === tabKey
|
||||
? "border-slate-300 bg-white text-slate-900"
|
||||
: "border-slate-200 bg-slate-50 text-slate-600 hover:bg-white"
|
||||
)}
|
||||
>
|
||||
{section.label} {section.count}
|
||||
</TabsTrigger>
|
||||
<span>{TAB_LABELS[tabKey]}</span>
|
||||
<span className="text-[11px] text-slate-500">{tabCounts[tabKey]}</span>
|
||||
</button>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{isLoading ? (
|
||||
<section className="rounded-2xl bg-[#F8F9FA] p-6">
|
||||
<div className="flex items-center gap-2 rounded-xl bg-white px-4 py-3 text-slate-500 shadow-[0_4px_14px_rgba(15,23,42,0.06)]">
|
||||
<section className="rounded-2xl bg-[#F8F9FA] p-4">
|
||||
<div className="flex items-center gap-2 rounded-xl bg-white px-4 py-3 text-slate-500">
|
||||
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
|
||||
加载消息台中…
|
||||
</div>
|
||||
</section>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<section className="rounded-2xl bg-[#F8F9FA] p-6">
|
||||
<Card className="rounded-2xl border-0 bg-white shadow-[0_8px_22px_rgba(15,23,42,0.08)]">
|
||||
<CardContent className="py-12 text-center text-slate-500">
|
||||
<BellRing className="mx-auto mb-3 h-12 w-12 opacity-50" />
|
||||
) : feedItems.length === 0 ? (
|
||||
<section className="rounded-2xl bg-[#F8F9FA] p-4">
|
||||
<div className="rounded-xl bg-white px-4 py-10 text-center text-slate-500">
|
||||
<BellRing className="mx-auto mb-3 h-10 w-10 opacity-50" />
|
||||
<p className="font-medium text-slate-700">当前没有匹配收件项</p>
|
||||
<p className="mt-1 text-sm">请调整筛选条件后重试</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<section className="rounded-2xl bg-[#F8F9FA] p-4 md:p-5">
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{filteredItems.map((item) => (
|
||||
<InboxCard
|
||||
<section className="space-y-2 rounded-2xl bg-[#F8F9FA] p-2 md:p-3">
|
||||
{feedItems.map((item) => (
|
||||
<InboxListItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
expanded={expandedItemId === item.id}
|
||||
pendingActionKey={pendingActionKey}
|
||||
onToggleExpand={toggleExpand}
|
||||
onAction={(target, action) => void handleAction(target, action)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(workItemDetailItem)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setWorkItemDetailItem(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[90vh] w-[min(720px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{workItemDetailItem?.title || "工作项详情"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{workItemDetailItem?.project_name || (workItemDetailItem?.project_id ? `项目 #${workItemDetailItem.project_id}` : "未绑定项目")}
|
||||
{" · "}
|
||||
{formatDetailTime(workItemDetailItem?.updated_at || workItemDetailItem?.created_at)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{workItemDetailItem && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline" className={cn("text-[11px]", STATUS_STYLES[workItemDetailItem.display_kind])}>
|
||||
{STATUS_LABELS[workItemDetailItem.display_kind]}
|
||||
</Badge>
|
||||
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">
|
||||
{toZhPriority(workItemDetailItem.priority)}
|
||||
</span>
|
||||
{workItemDetailItem.source_work_item?.intent && (
|
||||
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">
|
||||
{workItemDetailItem.source_work_item.intent}
|
||||
</span>
|
||||
)}
|
||||
{workItemDetailItem.source_work_item?.source_system && (
|
||||
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">
|
||||
{workItemDetailItem.source_work_item.source_system}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
|
||||
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">工作项摘要</div>
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-slate-700">
|
||||
{workItemDetailItem.summary || "暂无摘要"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{workItemDetailItem.source_work_item?.blocking_reason && (
|
||||
<div className="rounded-xl border border-amber-100 bg-amber-50 px-4 py-3">
|
||||
<div className="text-xs font-semibold tracking-[0.18em] text-amber-700">阻塞原因</div>
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-amber-900/80">
|
||||
{workItemDetailItem.source_work_item.blocking_reason}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{workItemDetailItem.source_work_item?.ai_plan && (
|
||||
<div className="rounded-xl border border-slate-200 bg-white px-4 py-3">
|
||||
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">AI 执行计划</div>
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-slate-700">
|
||||
{workItemDetailItem.source_work_item.ai_plan}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="rounded-xl bg-slate-50 px-4 py-3">
|
||||
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">当前阶段</div>
|
||||
<p className="mt-2 text-sm text-slate-700">
|
||||
{workItemDetailItem.source_work_item?.stage_text || STATUS_LABELS[workItemDetailItem.display_kind]}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-slate-50 px-4 py-3">
|
||||
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">下一步</div>
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm text-slate-700">
|
||||
{workItemDetailItem.source_work_item?.next_step_text || "暂无"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{workItemDetailQuery.isLoading ? (
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-500">
|
||||
加载工作项详情中…
|
||||
</div>
|
||||
) : workItemDetailQuery.data ? (
|
||||
<>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div className="rounded-xl bg-slate-50 px-4 py-3">
|
||||
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">决策记录</div>
|
||||
<p className="mt-2 text-sm text-slate-700">{workItemDetailQuery.data.decision_records.length} 条</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-slate-50 px-4 py-3">
|
||||
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">执行计划</div>
|
||||
<p className="mt-2 text-sm text-slate-700">{workItemDetailQuery.data.plan_bundles.length} 条</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-slate-50 px-4 py-3">
|
||||
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">回执记录</div>
|
||||
<p className="mt-2 text-sm text-slate-700">{workItemDetailQuery.data.reply_receipts.length} 条</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
|
||||
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">执行结果</div>
|
||||
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap break-all text-xs leading-6 text-slate-700">
|
||||
{formatJsonBlock(workItemDetailQuery.data.execution_result)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
|
||||
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">复盘摘要</div>
|
||||
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap break-all text-xs leading-6 text-slate-700">
|
||||
{formatJsonBlock(workItemDetailQuery.data.review_summary)}
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||
工作项详情加载失败,请稍后重试。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setWorkItemDetailItem(null)}>
|
||||
关闭
|
||||
</Button>
|
||||
{workItemDetailItem?.target_url && (
|
||||
<Button
|
||||
className="bg-blue-600 text-white hover:bg-blue-700"
|
||||
onClick={() => {
|
||||
void openTarget(workItemDetailItem, { forceDirect: true });
|
||||
setWorkItemDetailItem(null);
|
||||
}}
|
||||
>
|
||||
进入项目
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(notificationDetailItem)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setNotificationDetailItem(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[90vh] w-[min(560px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{notificationDetailItem?.title || "通知详情"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{notificationDetailItem?.project_name || (notificationDetailItem?.project_id ? `项目 #${notificationDetailItem.project_id}` : "未绑定项目")}
|
||||
{" · "}
|
||||
{formatDetailTime(notificationDetailItem?.updated_at || notificationDetailItem?.created_at)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{notificationDetailItem && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline" className={cn("text-[11px]", STATUS_STYLES[notificationDetailItem.display_kind])}>
|
||||
{STATUS_LABELS[notificationDetailItem.display_kind]}
|
||||
</Badge>
|
||||
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">
|
||||
{toZhPriority(notificationDetailItem.priority)}
|
||||
</span>
|
||||
{notificationDetailItem.source_notification?.source && (
|
||||
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">
|
||||
{notificationDetailItem.source_notification.source}
|
||||
</span>
|
||||
)}
|
||||
{notificationDetailItem.source_work_item?.source_system && (
|
||||
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">
|
||||
{notificationDetailItem.source_work_item.source_system}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
|
||||
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">通知摘要</div>
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-slate-700">
|
||||
{notificationDetailItem.source_notification?.content || notificationDetailItem.summary || "暂无摘要"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{notificationDetailItem.ai_plan && (
|
||||
<div className="rounded-xl bg-slate-50 px-4 py-3">
|
||||
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">AI 已做/将做什么</div>
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-slate-700">{notificationDetailItem.ai_plan}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notificationDetailItem.blocking_reason && (
|
||||
<div className="rounded-xl border border-amber-100 bg-amber-50 px-4 py-3">
|
||||
<div className="text-xs font-semibold tracking-[0.18em] text-amber-700">当前阻塞</div>
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-amber-900/80">{notificationDetailItem.blocking_reason}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="rounded-xl bg-slate-50 px-4 py-3">
|
||||
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">当前阶段</div>
|
||||
<p className="mt-2 text-sm text-slate-700">{notificationDetailItem.stage_text || STATUS_LABELS[notificationDetailItem.display_kind]}</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-slate-50 px-4 py-3">
|
||||
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">建议下一步</div>
|
||||
<p className="mt-2 text-sm text-slate-700">{notificationDetailItem.next_step_text || "先确认通知内容,再进入结果页处理。"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setNotificationDetailItem(null)}>
|
||||
关闭
|
||||
</Button>
|
||||
{notificationDetailItem?.target_url && (
|
||||
<Button
|
||||
className="bg-blue-600 text-white hover:bg-blue-700"
|
||||
onClick={() => {
|
||||
const currentItem = notificationDetailItem;
|
||||
setNotificationDetailItem(null);
|
||||
if (currentItem) {
|
||||
void openTarget(currentItem, { forceDirect: true });
|
||||
}
|
||||
}}
|
||||
>
|
||||
进入结果页
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useDeferredValue, useEffect, useMemo, useState } from "react";
|
|||
import { useParams, Link, useNavigate } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, ArrowLeft, ChevronDown, ChevronRight, Plus } from "lucide-react";
|
||||
import { aiPmoApi } from "@/api/aiPmo";
|
||||
import { aiPmoApi, type AIPMOWorkItem } from "@/api/aiPmo";
|
||||
import { sstcApi, type AIRoutingConfig, type SSTCSignal, type StageKey } from "@/api/sstc";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -23,7 +23,6 @@ import { BudgetControlPanel } from "@/components/budget/BudgetControlPanel";
|
|||
import { SSTCGlobalHeader } from "@/components/sstc/SSTCGlobalHeader";
|
||||
import { SSTCStageNav } from "@/components/sstc/SSTCStageNav";
|
||||
import { SSTCWorkspace } from "@/components/sstc/SSTCWorkspace";
|
||||
import { SSTCRiskFloatPanel } from "@/components/sstc/SSTCRiskFloatPanel";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import { formatProjectNameWithId, toZhPriority } from "@/lib/display";
|
||||
import { getApiErrorMessage } from "@/lib/apiError";
|
||||
|
|
@ -47,6 +46,70 @@ const DEFAULT_SIGNAL_FILTERS: SignalFilterState = {
|
|||
created_from: "",
|
||||
created_to: "",
|
||||
};
|
||||
const REGISTRY_REPAIR_SOURCE_MESSAGE_ID_RE = /^om_registry_repair_(\d+)_(\d+)_(base|explicit)$/i;
|
||||
const REGISTRY_REPAIR_TEXT_RE = /补录历史接入记录\s*registry#(\d+)(?:(([^)]+)))?/i;
|
||||
|
||||
function normalizeTechText(value?: string | null): string {
|
||||
const raw = String(value || "").trim();
|
||||
if (!raw) return "";
|
||||
return raw
|
||||
.replace(/补录历史接入记录/g, "补全历史接入信息")
|
||||
.replace(/registry#(\d+)/gi, "记录#$1")
|
||||
.replace(/请仅执行入库,不需要二次处理。?/g, "请确认后继续,系统会自动完成补录。");
|
||||
}
|
||||
|
||||
function dedupeWorkItemsBySemanticKey(items: AIPMOWorkItem[]): AIPMOWorkItem[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: AIPMOWorkItem[] = [];
|
||||
for (const item of items) {
|
||||
const sourceMessageId = String(item.source_message_id || "").trim();
|
||||
const sourceMatch = sourceMessageId.match(REGISTRY_REPAIR_SOURCE_MESSAGE_ID_RE);
|
||||
let key = `work_item:${item.id}`;
|
||||
if (sourceMatch) {
|
||||
key = `registry_repair:${sourceMatch[1]}:${sourceMatch[2]}`;
|
||||
} else {
|
||||
const merged = [item.title, item.summary, item.next_step_text].filter(Boolean).join(" ");
|
||||
const textMatch = merged.match(REGISTRY_REPAIR_TEXT_RE);
|
||||
if (textMatch) {
|
||||
key = `registry_repair:${item.project_id || "na"}:${textMatch[1]}`;
|
||||
}
|
||||
}
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
deduped.push(item);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function toReadableWorkItemTitle(item: AIPMOWorkItem): string {
|
||||
const sourceMessageId = String(item.source_message_id || "").trim();
|
||||
const sourceMatch = sourceMessageId.match(REGISTRY_REPAIR_SOURCE_MESSAGE_ID_RE);
|
||||
if (sourceMatch) {
|
||||
return `补全历史接入记录(#${sourceMatch[2]})`;
|
||||
}
|
||||
const titleMatch = String(item.title || "").match(REGISTRY_REPAIR_TEXT_RE);
|
||||
if (titleMatch) {
|
||||
return `补全历史接入记录(#${titleMatch[1]})`;
|
||||
}
|
||||
return normalizeTechText(item.title) || `工作项#${item.id}`;
|
||||
}
|
||||
|
||||
function toReadableWorkItemSummary(item: AIPMOWorkItem): string {
|
||||
const merged = [item.title, item.summary, item.next_step_text].filter(Boolean).join(" ");
|
||||
const textMatch = merged.match(REGISTRY_REPAIR_TEXT_RE);
|
||||
if (textMatch) {
|
||||
const sourceName = String(textMatch[2] || "").trim();
|
||||
if (sourceName) {
|
||||
return `检测到「${sourceName}」存在历史接入信息缺失,请确认后继续,系统将自动入库并回填关联。`;
|
||||
}
|
||||
return "检测到历史接入信息缺失,请确认后继续,系统将自动入库并回填关联。";
|
||||
}
|
||||
return (
|
||||
normalizeTechText(item.next_step_text)
|
||||
|| normalizeTechText(item.summary)
|
||||
|| "请进入收件箱查看详情并选择下一步处理。"
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProjectBoardPage() {
|
||||
const SIGNAL_PAGE_SIZE = 10;
|
||||
|
|
@ -322,7 +385,14 @@ export default function ProjectBoardPage() {
|
|||
}),
|
||||
24
|
||||
);
|
||||
const pmoWorkItems = pmoWorkItemsPayload?.items || [];
|
||||
const pmoWorkItems = useMemo(
|
||||
() => dedupeWorkItemsBySemanticKey(pmoWorkItemsPayload?.items || []),
|
||||
[pmoWorkItemsPayload?.items]
|
||||
);
|
||||
const pendingManualWorkItemCount = useMemo(
|
||||
() => pmoWorkItems.filter((item) => item.state === "waiting_manual").length,
|
||||
[pmoWorkItems]
|
||||
);
|
||||
const riskSummary = (pmoRiskSnapshot?.summary || {}) as Record<string, number>;
|
||||
const dailyPayload = (pmoDailySnapshot?.payload || {}) as Record<string, unknown>;
|
||||
const riskActions = Array.isArray(dailyPayload.focus_actions)
|
||||
|
|
@ -529,7 +599,7 @@ export default function ProjectBoardPage() {
|
|||
{ label: "Blocked Tasks", value: Number(riskSummary.blocked_task_count || 0) },
|
||||
{ label: "待确认信号源", value: Number(riskSummary.pending_signal_source_count || 0) },
|
||||
{ label: "风险通知", value: Number(riskSummary.unread_risk_notification_count || 0) },
|
||||
{ label: "待确认工作项", value: pmoWorkItems.filter((item) => item.state === "waiting_manual").length },
|
||||
{ label: "待确认工作项", value: pendingManualWorkItemCount },
|
||||
].map((metric) => (
|
||||
<div key={metric.label} className="rounded-xl bg-slate-50 px-4 py-3">
|
||||
<div className="text-xs font-semibold tracking-[0.12em] text-slate-500">{metric.label}</div>
|
||||
|
|
@ -563,8 +633,19 @@ export default function ProjectBoardPage() {
|
|||
</Button>
|
||||
</div>
|
||||
{pmoWorkItems.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-4 py-6 text-sm text-slate-500">
|
||||
当前项目暂无 PMO 工作项。
|
||||
<div className="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-4 py-6">
|
||||
<div className="text-sm text-slate-600">当前项目暂无 PMO 工作项。</div>
|
||||
<div className="mt-2 text-xs text-slate-500">
|
||||
建议下一步:先在任务阶段创建首批任务,或前往消息台确认待处理信号。
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setActiveStage("task")}>
|
||||
去任务阶段
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => navigate(`/inbox?project_id=${id}`)}>
|
||||
打开消息台
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
pmoWorkItems.map((item) => (
|
||||
|
|
@ -575,11 +656,11 @@ export default function ProjectBoardPage() {
|
|||
onClick={() => navigate(`/inbox?project_id=${id}&q=${encodeURIComponent(item.title)}`)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="line-clamp-2 text-sm font-medium text-slate-900">{item.title}</div>
|
||||
<div className="line-clamp-2 text-sm font-medium text-slate-900">{toReadableWorkItemTitle(item)}</div>
|
||||
{item.state === "waiting_manual" && <AlertTriangle className="h-4 w-4 shrink-0 text-amber-500" />}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-slate-500">{item.stage_text || item.state}</div>
|
||||
<div className="mt-2 line-clamp-2 text-xs text-slate-600">{item.next_step_text || item.summary}</div>
|
||||
<div className="mt-2 line-clamp-2 text-xs text-slate-600">{toReadableWorkItemSummary(item)}</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
|
|
@ -642,8 +723,6 @@ export default function ProjectBoardPage() {
|
|||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{risks && <SSTCRiskFloatPanel risks={risks} />}
|
||||
|
||||
<Dialog open={createCharterOpen} onOpenChange={handleCreateCharterDialogChange}>
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/skillradarchart.tsx","./src/components/themeinit.tsx","./src/components/ai-steward/floatingqachat.tsx","./src/components/ai-steward/openclawcommanderpanel.tsx","./src/components/ai-steward/promptrolesettings.tsx","./src/components/ai-steward/qahistorypanel.tsx","./src/components/budget/budgetcontrolpanel.tsx","./src/components/command/commanditems.ts","./src/components/layout/layout.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/topbar.tsx","./src/components/model-capability/costoptimizationpanel.tsx","./src/components/model-capability/experimentsyncpanel.tsx","./src/components/model-capability/modelcapabilitymatrix.tsx","./src/components/model-capability/modelupdateimpactpanel.tsx","./src/components/sstc/aivisibilitypanel.tsx","./src/components/sstc/sstcchainview.tsx","./src/components/sstc/sstccharterstagepanel.tsx","./src/components/sstc/sstcexecutionboardpanel.tsx","./src/components/sstc/sstcglobalheader.tsx","./src/components/sstc/sstcreleasecreatedialog.tsx","./src/components/sstc/sstcreleasestagepanel.tsx","./src/components/sstc/sstcreviewcreatedialog.tsx","./src/components/sstc/sstcreviewstagepanel.tsx","./src/components/sstc/sstcriskfloatpanel.tsx","./src/components/sstc/sstcsignalstagepanel.tsx","./src/components/sstc/sstcstagenav.tsx","./src/components/sstc/sstctaskcreatedialog.tsx","./src/components/sstc/sstctaskstagepanel.tsx","./src/components/sstc/sstcworkspace.tsx","./src/components/sstc/signalcontenthovercard.tsx","./src/components/sstc/chartermodules.ts","./src/components/sstc/sstcutils.ts","./src/components/sstc/usesstcworkspaceorchestration.ts","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/users/userformdialog.tsx","./src/hooks/usetheme.ts","./src/lib/api.ts","./src/lib/apierror.ts","./src/lib/datetime.ts","./src/lib/display.ts","./src/lib/feishueventmode.ts","./src/lib/inbox.ts","./src/lib/rbac.ts","./src/lib/sessionguard.ts","./src/lib/title.ts","./src/lib/utils.ts","./src/lib/zhcn.ts","./src/pages/aiconfigpage.tsx","./src/pages/aistewardpage.tsx","./src/pages/charterdetailpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/feishuconfigpage.tsx","./src/pages/inboxpage.tsx","./src/pages/loginpage.tsx","./src/pages/projectboardpage.tsx","./src/pages/projectspage.tsx","./src/pages/recipespage.tsx","./src/pages/schedulercenterpage.tsx","./src/pages/skilltaskspage.tsx","./src/pages/skillspage.tsx","./src/pages/userprofilepage.tsx","./src/pages/userskillspage.tsx","./src/pages/userspage.tsx","./src/pages/workloaddetailpage.tsx","./src/pages/workloadspage.tsx","./src/router/index.tsx","./src/stores/auth.ts","./src/types/inbox.ts"],"version":"5.6.3"}
|
||||
{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/skillradarchart.tsx","./src/components/themeinit.tsx","./src/components/ai-steward/floatingqachat.tsx","./src/components/ai-steward/openclawcommanderpanel.tsx","./src/components/ai-steward/promptrolesettings.tsx","./src/components/ai-steward/qahistorypanel.tsx","./src/components/budget/budgetcontrolpanel.tsx","./src/components/command/commanditems.ts","./src/components/inbox/inboxlistitem.tsx","./src/components/layout/layout.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/topbar.tsx","./src/components/model-capability/costoptimizationpanel.tsx","./src/components/model-capability/experimentsyncpanel.tsx","./src/components/model-capability/modelcapabilitymatrix.tsx","./src/components/model-capability/modelupdateimpactpanel.tsx","./src/components/sstc/aivisibilitypanel.tsx","./src/components/sstc/sstcchainview.tsx","./src/components/sstc/sstccharterstagepanel.tsx","./src/components/sstc/sstcexecutionboardpanel.tsx","./src/components/sstc/sstcglobalheader.tsx","./src/components/sstc/sstcreleasecreatedialog.tsx","./src/components/sstc/sstcreleasestagepanel.tsx","./src/components/sstc/sstcreviewcreatedialog.tsx","./src/components/sstc/sstcreviewstagepanel.tsx","./src/components/sstc/sstcriskfloatpanel.tsx","./src/components/sstc/sstcsignalstagepanel.tsx","./src/components/sstc/sstcstagenav.tsx","./src/components/sstc/sstctaskcreatedialog.tsx","./src/components/sstc/sstctaskstagepanel.tsx","./src/components/sstc/sstcworkspace.tsx","./src/components/sstc/signalcontenthovercard.tsx","./src/components/sstc/chartermodules.ts","./src/components/sstc/sstcutils.ts","./src/components/sstc/usesstcworkspaceorchestration.ts","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/users/userformdialog.tsx","./src/hooks/usetheme.ts","./src/lib/api.ts","./src/lib/apierror.ts","./src/lib/datetime.ts","./src/lib/display.ts","./src/lib/feishueventmode.ts","./src/lib/formatinboxtime.ts","./src/lib/inbox.ts","./src/lib/inboxtextmap.ts","./src/lib/rbac.ts","./src/lib/sessionguard.ts","./src/lib/title.ts","./src/lib/utils.ts","./src/lib/zhcn.ts","./src/pages/aiconfigpage.tsx","./src/pages/aistewardpage.tsx","./src/pages/charterdetailpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/feishuconfigpage.tsx","./src/pages/inboxpage.tsx","./src/pages/loginpage.tsx","./src/pages/projectboardpage.tsx","./src/pages/projectspage.tsx","./src/pages/recipespage.tsx","./src/pages/schedulercenterpage.tsx","./src/pages/skilltaskspage.tsx","./src/pages/skillspage.tsx","./src/pages/userprofilepage.tsx","./src/pages/userskillspage.tsx","./src/pages/userspage.tsx","./src/pages/workloaddetailpage.tsx","./src/pages/workloadspage.tsx","./src/router/index.tsx","./src/stores/auth.ts","./src/types/inbox.ts"],"version":"5.6.3"}
|
||||
|
|
@ -6,6 +6,7 @@ export default defineConfig(({ mode }) => {
|
|||
const env = loadEnv(mode, process.cwd(), '');
|
||||
const tunnelDomain = env.VITE_APP_DOMAIN;
|
||||
const proxyTarget = env.VITE_DEV_API_PROXY_TARGET || "http://127.0.0.1:8000";
|
||||
const usePolling = String(env.CHOKIDAR_USEPOLLING || "false").toLowerCase() === "true";
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
|
|
@ -17,6 +18,10 @@ export default defineConfig(({ mode }) => {
|
|||
server: {
|
||||
port: 5174,
|
||||
allowedHosts: true,
|
||||
watch: usePolling ? {
|
||||
usePolling: true,
|
||||
interval: 150,
|
||||
} : undefined,
|
||||
hmr: tunnelDomain ? {
|
||||
host: tunnelDomain,
|
||||
clientPort: 443,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,193 @@
|
|||
# 消息台重构任务单(卡片改列表 Feed)
|
||||
|
||||
> 目标:把消息台从双列卡片重构为高密度列表 Feed,保证用户可读、可操作、可追溯。
|
||||
> 范围:仅前端(不改后端接口)。
|
||||
> 新增硬约束:信息流严格按时间倒序,最新在上。
|
||||
|
||||
---
|
||||
|
||||
## 0. 实施原则(先定边界)
|
||||
|
||||
- 不改 `backend` 接口与字段,仅做前端映射/渲染重构。
|
||||
- 保持 `buildInboxView` 领域聚合能力不变,避免影响 TopBar 与其他入口。
|
||||
- 用户视角文案与技术字段解耦:技术字段可在行内展开区可见,默认隐藏。
|
||||
- 主列表排序规则统一:`updated_at || created_at` 降序(最新在上)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 现状定位(SCOUT)
|
||||
|
||||
- [x] 定位页面入口:`frontend/src/pages/InboxPage.tsx`
|
||||
- [x] 定位数据聚合:`frontend/src/lib/inbox.ts`
|
||||
- [x] 定位类型定义:`frontend/src/types/inbox.ts`
|
||||
- [x] 定位顶部铃铛预览依赖:`frontend/src/components/layout/TopBar.tsx`
|
||||
- [x] 确认以下信息:
|
||||
- [x] 列表数据来源接口(notifications + ai-pmo work-items)
|
||||
- [x] 当前卡片组件边界(`InboxCard`)
|
||||
- [x] Tab 分类逻辑位置(`activeSection` + `display_kind`)
|
||||
|
||||
---
|
||||
|
||||
## 2. 爆炸半径评估(TRACE)
|
||||
|
||||
- [x] 执行 `FIND_USAGE buildInboxView`
|
||||
- [x] 执行 `FIND_USAGE InboxDisplayKind`
|
||||
- [x] 执行 `FIND_USAGE InboxSectionKey`
|
||||
- [x] 执行 `FIND_USAGE getInboxSectionLabel`
|
||||
- [x] 评估结论记录:
|
||||
- [x] `InboxPage` 改造是否会影响 `TopBar` 预览行为
|
||||
- [x] 是否需要保持 `action_required|auto_running|auto_done|info` 内部枚举不变
|
||||
|
||||
> 说明:本次推荐保持内部枚举不变,仅在 UI 层映射为“待处理/进行中/已完成/全部”。
|
||||
|
||||
---
|
||||
|
||||
## 3. 组件与文件改造清单(SNIPER)
|
||||
|
||||
### 3.1 新增文件
|
||||
|
||||
- [x] `frontend/src/components/inbox/InboxListItem.tsx`
|
||||
- [x] `frontend/src/lib/inboxTextMap.ts`
|
||||
- [x] `frontend/src/lib/formatInboxTime.ts`
|
||||
|
||||
### 3.2 修改文件
|
||||
|
||||
- [x] `frontend/src/pages/InboxPage.tsx`
|
||||
- [x] `frontend/src/components/layout/TopBar.tsx`(复用统一时间格式函数,可选;本次保持现状未改)
|
||||
- [x] `CLAUDE.md`(目录结构变更同步)
|
||||
|
||||
---
|
||||
|
||||
## 4. 列表行设计规范
|
||||
|
||||
### 4.1 两种行高
|
||||
|
||||
- 需要操作行:72px
|
||||
- 左:状态图标 + 标题 + 副标题
|
||||
- 右:项目名、时间、`主操作`、`详情`
|
||||
- 纯通知行:52px
|
||||
- 左:状态图标 + 标题 + 副标题
|
||||
- 右:项目名、时间、`详情`
|
||||
|
||||
### 4.2 行内交互
|
||||
|
||||
- [x] 点击行本体:展开/收起详情(inline,不跳页)
|
||||
- [x] 点击主操作按钮:执行动作,不触发行展开
|
||||
- [x] 点击详情按钮:仅展开详情(或打开详情弹窗,二选一,需统一)
|
||||
|
||||
### 4.3 详情展开区(默认折叠)
|
||||
|
||||
- [x] 显示技术字段:`intent` 原始值、typed actions、`chat_id`、完整时间戳、重复次数、撤销信息
|
||||
- [x] 默认主行不出现技术字段
|
||||
|
||||
---
|
||||
|
||||
## 5. 文案映射策略(用户视角)
|
||||
|
||||
### 5.1 intent 映射(显示层)
|
||||
|
||||
- `signal_source.onboard` → `信号源接入`
|
||||
- `signal.create` → `新信号识别`
|
||||
- `project.create` → `项目已创建`
|
||||
- `query.read_only` → `信息查询`
|
||||
- `followup.confirm` → `确认执行`
|
||||
- `followup.continue` → `跟进处理`
|
||||
- `ignore` → `系统忽略`
|
||||
|
||||
### 5.2 状态映射(显示层)
|
||||
|
||||
- `action_required` → `待处理`
|
||||
- `auto_running` → `进行中`
|
||||
- `auto_done` → `已完成`
|
||||
- `info` → `通知`
|
||||
|
||||
### 5.3 标题清洗规则
|
||||
|
||||
- [x] 优先使用 `notification.title` / `work_item.title`
|
||||
- [x] 包含 `给项目ID:` 前缀时仅保留后半段
|
||||
- [x] `registry#` 替换为 `历史记录`
|
||||
- [x] 去除明显技术噪音字段展示(默认行)
|
||||
|
||||
---
|
||||
|
||||
## 6. Tab 重构(不改底层枚举)
|
||||
|
||||
## 用户 Tab(展示)
|
||||
|
||||
- `待处理`(映射 `action_required`)
|
||||
- `进行中`(映射 `auto_running`)
|
||||
- `已完成`(映射 `auto_done`)
|
||||
- `全部`(映射 `all`)
|
||||
|
||||
## 计数规则
|
||||
|
||||
- [x] 每个 Tab 角标来自当前项目过滤后的实时集合
|
||||
- [x] `全部 = 待处理 + 进行中 + 已完成 + 通知`(若通知不单列则并入全部但不单独显示)
|
||||
|
||||
---
|
||||
|
||||
## 7. 时间与排序规则(强制)
|
||||
|
||||
### 7.1 时间显示
|
||||
|
||||
- 1 分钟内:`刚刚`
|
||||
- 1 小时内:`X 分钟前`
|
||||
- 当天:`HH:mm`
|
||||
- 昨天:`昨天 HH:mm`
|
||||
- 更早:`MM-DD HH:mm`
|
||||
|
||||
### 7.2 排序(硬约束)
|
||||
|
||||
- 主 Feed 严格按 `updated_at || created_at` 降序。
|
||||
- 最新消息永远在最上方。
|
||||
- Tab 切换、搜索、项目筛选后仍保持同一倒序规则。
|
||||
- 如时间缺失:降级到 `created_at`;仍缺失时排到分组末尾。
|
||||
|
||||
---
|
||||
|
||||
## 8. 首屏密度与布局调整
|
||||
|
||||
- [x] 折叠/压缩顶部指标区(避免占用过高首屏)
|
||||
- [x] 列表单列展示(移动端与桌面统一 Feed 心智)
|
||||
- [x] 状态筛选仅作为过滤器,不采用任何看板分栏布局
|
||||
- [x] 验证 1080p 下首屏可见条数 `>= 8`
|
||||
|
||||
---
|
||||
|
||||
## 9. 验收标准(Definition of Done)
|
||||
|
||||
- [x] 一屏可见条数 `>= 8`
|
||||
- [x] 主列表默认无技术字段暴露(展开区除外)
|
||||
- [x] Tab 筛选准确:待处理/进行中/已完成/全部
|
||||
- [x] 时间显示全部为相对/友好格式
|
||||
- [x] 点击行可展开/收起详情
|
||||
- [x] 主 Feed 严格“最新在上”
|
||||
- [x] `npm run build` 通过
|
||||
- [x] `tsc --noEmit` 零错误
|
||||
|
||||
---
|
||||
|
||||
## 10. 自检命令(前端)
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
技术词裸露扫描(渲染主行):
|
||||
|
||||
```bash
|
||||
rg -n "typed actions|read_only|registry#|chat_id" frontend/src/pages frontend/src/components/inbox
|
||||
```
|
||||
|
||||
> 期望:仅允许出现在“展开详情区”相关代码路径,不允许出现在默认主行渲染文本。
|
||||
|
||||
---
|
||||
|
||||
## 11. 交付物
|
||||
|
||||
- [x] 变更文件清单
|
||||
- [x] 首屏可见条数描述(含测试分辨率)
|
||||
- [x] 排序规则验证说明(最新在上)
|
||||
- [x] 构建与类型检查结果
|
||||
|
|
@ -10,7 +10,7 @@ COMPOSE_FILE="${COMPOSE_FILE_PATH:-docker-compose.yml}"
|
|||
|
||||
require_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || {
|
||||
echo "[ERROR] missing command: $1"
|
||||
echo "[错误] 缺少命令: $1"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
|
@ -19,19 +19,31 @@ require_cmd docker
|
|||
require_cmd curl
|
||||
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
echo "[ERROR] Docker daemon is not running."
|
||||
echo "[错误] Docker 服务未启动。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$ENV_FILE" && -f "${PROJECT_DIR}/.env.example" ]]; then
|
||||
cp "${PROJECT_DIR}/.env.example" "$ENV_FILE"
|
||||
echo "[INFO] .env created from .env.example"
|
||||
echo "[信息] 已根据 .env.example 生成 .env"
|
||||
fi
|
||||
|
||||
compose() {
|
||||
docker compose -f "$COMPOSE_FILE" "$@"
|
||||
}
|
||||
|
||||
compose_quiet() {
|
||||
local tmp_log
|
||||
tmp_log="$(mktemp)"
|
||||
if compose "$@" >"$tmp_log" 2>&1; then
|
||||
rm -f "$tmp_log"
|
||||
return 0
|
||||
fi
|
||||
cat "$tmp_log"
|
||||
rm -f "$tmp_log"
|
||||
return 1
|
||||
}
|
||||
|
||||
backend_health() {
|
||||
curl -fsS "http://127.0.0.1:${BACKEND_PORT:-8000}/healthz" >/dev/null
|
||||
}
|
||||
|
|
@ -51,71 +63,71 @@ db_health() {
|
|||
|
||||
case "$ACTION" in
|
||||
start)
|
||||
echo "[INFO] compose file: ${COMPOSE_FILE}"
|
||||
compose up -d
|
||||
echo "[OK] stack started"
|
||||
echo "[信息] 编排文件: ${COMPOSE_FILE}"
|
||||
compose_quiet up -d
|
||||
echo "[完成] 服务栈已启动"
|
||||
;;
|
||||
restart)
|
||||
echo "[INFO] compose file: ${COMPOSE_FILE}"
|
||||
compose down
|
||||
compose up -d
|
||||
echo "[OK] stack restarted"
|
||||
echo "[信息] 编排文件: ${COMPOSE_FILE}"
|
||||
compose_quiet down
|
||||
compose_quiet up -d
|
||||
echo "[完成] 服务栈已重启"
|
||||
;;
|
||||
stop)
|
||||
echo "[INFO] compose file: ${COMPOSE_FILE}"
|
||||
compose down
|
||||
echo "[OK] stack stopped"
|
||||
echo "[信息] 编排文件: ${COMPOSE_FILE}"
|
||||
compose_quiet down
|
||||
echo "[完成] 服务栈已停止"
|
||||
;;
|
||||
status|ps)
|
||||
echo "[INFO] compose file: ${COMPOSE_FILE}"
|
||||
echo "[信息] 编排文件: ${COMPOSE_FILE}"
|
||||
compose ps
|
||||
;;
|
||||
logs)
|
||||
echo "[INFO] compose file: ${COMPOSE_FILE}"
|
||||
echo "[信息] 编排文件: ${COMPOSE_FILE}"
|
||||
compose logs --tail=200 "${2:-}"
|
||||
;;
|
||||
health)
|
||||
backend_health && echo "[OK] backend healthz"
|
||||
db_health && echo "[OK] db pg_isready"
|
||||
redis_health && echo "[OK] redis ping"
|
||||
backend_health && echo "[完成] 后端健康检查通过(/healthz)"
|
||||
db_health && echo "[完成] 数据库健康检查通过(pg_isready)"
|
||||
redis_health && echo "[完成] Redis 健康检查通过(PING)"
|
||||
;;
|
||||
dev-start)
|
||||
COMPOSE_FILE="docker-compose.dev.yml"
|
||||
echo "[INFO] compose file: ${COMPOSE_FILE}"
|
||||
compose up -d
|
||||
echo "[OK] dev stack started"
|
||||
echo "[信息] 编排文件: ${COMPOSE_FILE}"
|
||||
compose_quiet up -d
|
||||
echo "[完成] 开发环境服务栈已启动"
|
||||
;;
|
||||
dev-restart)
|
||||
COMPOSE_FILE="docker-compose.dev.yml"
|
||||
echo "[INFO] compose file: ${COMPOSE_FILE}"
|
||||
compose down
|
||||
compose up -d
|
||||
echo "[OK] dev stack restarted"
|
||||
echo "[信息] 编排文件: ${COMPOSE_FILE}"
|
||||
compose_quiet down
|
||||
compose_quiet up -d
|
||||
echo "[完成] 开发环境服务栈已重启"
|
||||
;;
|
||||
dev-stop)
|
||||
COMPOSE_FILE="docker-compose.dev.yml"
|
||||
echo "[INFO] compose file: ${COMPOSE_FILE}"
|
||||
compose down
|
||||
echo "[OK] dev stack stopped"
|
||||
echo "[信息] 编排文件: ${COMPOSE_FILE}"
|
||||
compose_quiet down
|
||||
echo "[完成] 开发环境服务栈已停止"
|
||||
;;
|
||||
dev-status|dev-ps)
|
||||
COMPOSE_FILE="docker-compose.dev.yml"
|
||||
echo "[INFO] compose file: ${COMPOSE_FILE}"
|
||||
echo "[信息] 编排文件: ${COMPOSE_FILE}"
|
||||
compose ps
|
||||
;;
|
||||
dev-logs)
|
||||
COMPOSE_FILE="docker-compose.dev.yml"
|
||||
echo "[INFO] compose file: ${COMPOSE_FILE}"
|
||||
echo "[信息] 编排文件: ${COMPOSE_FILE}"
|
||||
compose logs --tail=200 "${2:-}"
|
||||
;;
|
||||
dev-health)
|
||||
COMPOSE_FILE="docker-compose.dev.yml"
|
||||
backend_health && echo "[OK] backend healthz"
|
||||
db_health && echo "[OK] db pg_isready"
|
||||
redis_health && echo "[OK] redis ping"
|
||||
backend_health && echo "[完成] 后端健康检查通过(/healthz)"
|
||||
db_health && echo "[完成] 数据库健康检查通过(pg_isready)"
|
||||
redis_health && echo "[完成] Redis 健康检查通过(PING)"
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 {start|restart|stop|status|ps|logs [service]|health|dev-start|dev-restart|dev-stop|dev-status|dev-ps|dev-logs [service]|dev-health}"
|
||||
echo "用法: $0 {start|restart|stop|status|ps|logs [service]|health|dev-start|dev-restart|dev-stop|dev-status|dev-ps|dev-logs [service]|dev-health}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
|
|
|||
|
|
@ -0,0 +1,285 @@
|
|||
#!/usr/bin/env python3
|
||||
"""独立模型连通性检测脚本(中文日志输出)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import ssl
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_env_file(env_path: Path) -> None:
|
||||
if not env_path.exists():
|
||||
return
|
||||
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if len(value) >= 2 and ((value[0] == value[-1] == '"') or (value[0] == value[-1] == "'")):
|
||||
value = value[1:-1]
|
||||
os.environ.setdefault(key, value)
|
||||
|
||||
|
||||
def provider_runtime(provider: str) -> dict[str, str]:
|
||||
p = provider.strip().lower()
|
||||
if p in {"glm", "zhipu"}:
|
||||
return {
|
||||
"provider": p,
|
||||
"base_url": str(os.getenv("GLM_BASE_URL", "")).strip(),
|
||||
"api_key": str(os.getenv("GLM_API_KEY", "")).strip(),
|
||||
}
|
||||
if p == "openai":
|
||||
return {
|
||||
"provider": p,
|
||||
"base_url": str(os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")).strip(),
|
||||
"api_key": str(os.getenv("OPENAI_API_KEY", "")).strip(),
|
||||
}
|
||||
if p == "deepseek":
|
||||
return {
|
||||
"provider": p,
|
||||
"base_url": str(os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1")).strip(),
|
||||
"api_key": str(os.getenv("DEEPSEEK_API_KEY", "")).strip(),
|
||||
}
|
||||
if p == "ollama":
|
||||
return {
|
||||
"provider": p,
|
||||
"base_url": str(os.getenv("OLLAMA_BASE_URL", os.getenv("OLLAMA_HOST", "http://127.0.0.1:11434"))).strip(),
|
||||
"api_key": "",
|
||||
}
|
||||
return {"provider": p, "base_url": "", "api_key": ""}
|
||||
|
||||
|
||||
def extract_json_detail(text: str) -> dict[str, str]:
|
||||
detail = {
|
||||
"content": "",
|
||||
"reasoning_content": "",
|
||||
"error_message": "",
|
||||
"body_preview": text[:180].replace("\n", " "),
|
||||
}
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except Exception:
|
||||
return detail
|
||||
|
||||
if isinstance(data, dict):
|
||||
choices = data.get("choices") or []
|
||||
if isinstance(choices, list) and choices:
|
||||
first = choices[0] if isinstance(choices[0], dict) else {}
|
||||
msg = first.get("message") if isinstance(first, dict) else {}
|
||||
if isinstance(msg, dict):
|
||||
detail["content"] = str(msg.get("content") or "").strip()
|
||||
detail["reasoning_content"] = str(msg.get("reasoning_content") or "").strip()
|
||||
|
||||
if not detail["error_message"]:
|
||||
err = data.get("error")
|
||||
if isinstance(err, dict):
|
||||
detail["error_message"] = str(err.get("message") or "").strip()
|
||||
elif err:
|
||||
detail["error_message"] = str(err).strip()
|
||||
|
||||
if not detail["error_message"]:
|
||||
if data.get("message"):
|
||||
detail["error_message"] = str(data.get("message")).strip()
|
||||
elif data.get("reason"):
|
||||
detail["error_message"] = str(data.get("reason")).strip()
|
||||
return detail
|
||||
|
||||
|
||||
def probe_model(
|
||||
level: str,
|
||||
provider: str,
|
||||
model: str,
|
||||
timeout: float,
|
||||
insecure: bool,
|
||||
) -> dict[str, object]:
|
||||
runtime = provider_runtime(provider)
|
||||
base_url = runtime["base_url"].rstrip("/")
|
||||
api_key = runtime["api_key"]
|
||||
started = time.perf_counter()
|
||||
|
||||
if not base_url:
|
||||
elapsed = time.perf_counter() - started
|
||||
return {
|
||||
"level": level,
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"ok": False,
|
||||
"elapsed": elapsed,
|
||||
"reason": f"provider={provider} 缺少 base_url 配置",
|
||||
"http_status": 0,
|
||||
}
|
||||
|
||||
url = f"{base_url}/chat/completions"
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": "你是连通性检测助手,只回复OK。"},
|
||||
{"role": "user", "content": "ping"},
|
||||
],
|
||||
"temperature": 0,
|
||||
"max_tokens": 16,
|
||||
}
|
||||
req = urllib.request.Request(
|
||||
url=url,
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
if api_key:
|
||||
req.add_header("Authorization", f"Bearer {api_key}")
|
||||
|
||||
status_code = 0
|
||||
body = ""
|
||||
error_text = ""
|
||||
ssl_context = ssl._create_unverified_context() if insecure else None
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout, context=ssl_context) as resp:
|
||||
status_code = int(resp.getcode() or 0)
|
||||
body = resp.read().decode("utf-8", errors="replace")
|
||||
except urllib.error.HTTPError as exc:
|
||||
status_code = int(exc.code or 0)
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
error_text = str(exc)
|
||||
except Exception as exc: # pragma: no cover
|
||||
error_text = str(exc)
|
||||
|
||||
elapsed = time.perf_counter() - started
|
||||
detail = extract_json_detail(body)
|
||||
content = detail["content"]
|
||||
reasoning = detail["reasoning_content"]
|
||||
has_text = bool(content)
|
||||
has_reasoning = bool(reasoning)
|
||||
http_ok = 200 <= status_code < 300
|
||||
|
||||
if http_ok and (has_text or has_reasoning):
|
||||
note = "文本返回" if has_text else "仅推理内容"
|
||||
return {
|
||||
"level": level,
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"ok": True,
|
||||
"elapsed": elapsed,
|
||||
"reason": note,
|
||||
"http_status": status_code,
|
||||
}
|
||||
|
||||
reason_parts: list[str] = []
|
||||
if status_code:
|
||||
reason_parts.append(f"HTTP {status_code}")
|
||||
if detail["error_message"]:
|
||||
reason_parts.append(str(detail["error_message"]))
|
||||
elif error_text:
|
||||
reason_parts.append(error_text)
|
||||
elif http_ok:
|
||||
reason_parts.append("响应为空(content/reasoning_content 均为空)")
|
||||
else:
|
||||
reason_parts.append(detail["body_preview"] or "请求失败")
|
||||
|
||||
return {
|
||||
"level": level,
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"ok": False,
|
||||
"elapsed": elapsed,
|
||||
"reason": " | ".join(reason_parts),
|
||||
"http_status": status_code,
|
||||
}
|
||||
|
||||
|
||||
def render_result_line(item: dict[str, object]) -> str:
|
||||
status_text = "✓ 正常" if bool(item["ok"]) else "✗ 失败"
|
||||
elapsed_text = f"{float(item['elapsed']):.2f}s"
|
||||
return (
|
||||
f"[{item['level']}] {item['model']} ({item['provider']}) "
|
||||
f"{status_text} ({elapsed_text})"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="PMFlow 模型连通性独立检测")
|
||||
parser.add_argument(
|
||||
"--env-file",
|
||||
default=str(Path(__file__).resolve().parents[1] / ".env"),
|
||||
help="环境变量文件路径(默认根目录 .env)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout",
|
||||
type=float,
|
||||
default=float(os.getenv("PMFLOW_MODEL_PROBE_TIMEOUT", "20")),
|
||||
help="单次请求超时时间(秒)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--insecure",
|
||||
action="store_true",
|
||||
default=str(os.getenv("PMFLOW_MODEL_PROBE_INSECURE", "1")).strip().lower() in {"1", "true", "yes"},
|
||||
help="关闭 TLS 证书校验(默认开启,可用 PMFLOW_MODEL_PROBE_INSECURE=0 关闭)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--strict-tls",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="强制开启 TLS 证书校验(优先级高于 --insecure)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
load_env_file(Path(args.env_file))
|
||||
|
||||
insecure = bool(args.insecure and not args.strict_tls)
|
||||
|
||||
low_provider = str(os.getenv("LLM_ROUTING_LOW_PROVIDER", os.getenv("LLM_PROVIDER", "glm")) or "").strip() or "glm"
|
||||
low_model = str(os.getenv("LLM_ROUTING_LOW_MODEL", os.getenv("GLM_MODEL", "GLM-4.7")) or "").strip() or "GLM-4.7"
|
||||
high_provider = str(os.getenv("LLM_ROUTING_HIGH_PROVIDER", os.getenv("LLM_PROVIDER", "openai")) or "").strip() or "openai"
|
||||
high_model = str(os.getenv("LLM_ROUTING_HIGH_MODEL", os.getenv("OPENAI_MODEL", "GLM-5")) or "").strip() or "GLM-5"
|
||||
|
||||
targets: list[tuple[str, str, str]] = [("LOW", low_provider, low_model), ("HIGH", high_provider, high_model)]
|
||||
deduped: list[tuple[str, str, str]] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for level, provider, model in targets:
|
||||
key = (provider.lower(), model)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
deduped.append((level, provider, model))
|
||||
|
||||
started_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
print("┌────────────────────────────────────────┐")
|
||||
print("│ PMFlow 模型连通性独立检测 │")
|
||||
print(f"│ 启动时间: {started_at} │")
|
||||
print(f"│ TLS校验: {'关闭' if insecure else '开启'} │")
|
||||
print("├────────────────────────────────────────┤")
|
||||
|
||||
results: list[dict[str, object]] = []
|
||||
for level, provider, model in deduped:
|
||||
result = probe_model(
|
||||
level=level,
|
||||
provider=provider,
|
||||
model=model,
|
||||
timeout=args.timeout,
|
||||
insecure=insecure,
|
||||
)
|
||||
results.append(result)
|
||||
print(f"│ {render_result_line(result)}")
|
||||
print(f"│ 说明: {result['reason']}")
|
||||
|
||||
print("└────────────────────────────────────────┘")
|
||||
|
||||
failed = [item for item in results if not bool(item["ok"])]
|
||||
if failed:
|
||||
print(f"[结果] 检测完成:{len(failed)} 个模型异常。")
|
||||
return 1
|
||||
print("[结果] 检测完成:全部模型连通正常。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
#!/bin/bash
|
||||
# PMFlow 启动器:后端 + 前端 + Cloudflare Quick Tunnel(自动输出临时公网地址)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
die() {
|
||||
echo -e "${RED}❌ $1${NC}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
kill_port() {
|
||||
local port="$1"
|
||||
lsof -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null | xargs kill -9 2>/dev/null || true
|
||||
}
|
||||
|
||||
wait_http_ok() {
|
||||
local url="$1"
|
||||
local max_retry="$2"
|
||||
local retry=1
|
||||
while [ "$retry" -le "$max_retry" ]; do
|
||||
if curl -fsS "$url" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
retry=$((retry + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_remote_ok() {
|
||||
local url="$1"
|
||||
local max_retry="$2"
|
||||
local retry=1
|
||||
while [ "$retry" -le "$max_retry" ]; do
|
||||
if curl -kfsS --max-time 8 "$url" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
retry=$((retry + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
echo "🚀 PMFlow 启动器"
|
||||
echo "===================="
|
||||
|
||||
[ -d "venv" ] || die "虚拟环境不存在,请先创建: python -m venv venv"
|
||||
[ -d "backend" ] || die "缺少 backend 目录"
|
||||
[ -d "frontend" ] || die "缺少 frontend 目录"
|
||||
command -v cloudflared >/dev/null 2>&1 || die "未安装 cloudflared"
|
||||
command -v npm >/dev/null 2>&1 || die "未安装 npm"
|
||||
|
||||
mkdir -p logs
|
||||
rm -f logs/backend.pid logs/frontend.pid logs/tunnel.pid logs/tunnel-frontend.pid logs/tunnel-backend.pid logs/public_url.txt
|
||||
rm -f logs/tunnel.log logs/tunnel-backend.log logs/tunnel-frontend.log
|
||||
|
||||
echo "🧹 清理旧进程与端口..."
|
||||
pkill -f "cloudflared tunnel --url http://127.0.0.1:5174" >/dev/null 2>&1 || true
|
||||
pkill -f "cloudflared tunnel --url http://localhost:5174" >/dev/null 2>&1 || true
|
||||
pkill -f "cloudflared tunnel --url http://127.0.0.1:8000" >/dev/null 2>&1 || true
|
||||
pkill -f "cloudflared tunnel --url http://localhost:8000" >/dev/null 2>&1 || true
|
||||
kill_port 8000
|
||||
kill_port 5173
|
||||
kill_port 5174
|
||||
sleep 1
|
||||
|
||||
echo -e "${GREEN}▶️ 启动后端 (8000)...${NC}"
|
||||
(
|
||||
cd backend
|
||||
nohup "$PROJECT_DIR/venv/bin/uvicorn" app.main:app --host 0.0.0.0 --port 8000 --reload --reload-dir app > "$PROJECT_DIR/logs/backend.log" 2>&1 &
|
||||
echo $! > "$PROJECT_DIR/logs/backend.pid"
|
||||
)
|
||||
BACKEND_PID="$(cat logs/backend.pid)"
|
||||
wait_http_ok "http://127.0.0.1:8000/healthz" 15 || die "后端启动失败,查看 logs/backend.log"
|
||||
echo -e "${GREEN}✅ 后端已启动 (PID: ${BACKEND_PID})${NC}"
|
||||
|
||||
echo -e "${GREEN}▶️ 启动前端 (5174)...${NC}"
|
||||
(
|
||||
cd frontend
|
||||
nohup npm run dev -- --host 0.0.0.0 --port 5174 > "$PROJECT_DIR/logs/frontend.log" 2>&1 &
|
||||
echo $! > "$PROJECT_DIR/logs/frontend.pid"
|
||||
)
|
||||
FRONTEND_PID="$(cat logs/frontend.pid)"
|
||||
wait_http_ok "http://127.0.0.1:5174/login" 20 || wait_http_ok "http://127.0.0.1:5174" 10 || die "前端启动失败,查看 logs/frontend.log"
|
||||
echo -e "${GREEN}✅ 前端已启动 (PID: ${FRONTEND_PID})${NC}"
|
||||
|
||||
echo -e "${GREEN}▶️ 启动 Cloudflare Quick Tunnels...${NC}"
|
||||
# Start frontend tunnel
|
||||
nohup cloudflared tunnel --url http://127.0.0.1:5174 > logs/tunnel-frontend.log 2>&1 &
|
||||
TUNNEL_FRONT_PID=$!
|
||||
echo "$TUNNEL_FRONT_PID" > logs/tunnel-frontend.pid
|
||||
|
||||
# Start backend tunnel
|
||||
nohup cloudflared tunnel --url http://127.0.0.1:8000 > logs/tunnel-backend.log 2>&1 &
|
||||
TUNNEL_BACK_PID=$!
|
||||
echo "$TUNNEL_BACK_PID" > logs/tunnel-backend.pid
|
||||
|
||||
TEMP_URL_FRONT=""
|
||||
TEMP_URL_BACK=""
|
||||
|
||||
for _ in {1..25}; do
|
||||
if [ -z "$TEMP_URL_FRONT" ]; then
|
||||
TEMP_URL_FRONT="$(grep -aEo 'https://[a-z0-9-]+\.trycloudflare\.com' logs/tunnel-frontend.log | head -n 1 || true)"
|
||||
fi
|
||||
if [ -z "$TEMP_URL_BACK" ]; then
|
||||
TEMP_URL_BACK="$(grep -aEo 'https://[a-z0-9-]+\.trycloudflare\.com' logs/tunnel-backend.log | head -n 1 || true)"
|
||||
fi
|
||||
|
||||
if [ -n "$TEMP_URL_FRONT" ] && [ -n "$TEMP_URL_BACK" ]; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ -n "$TEMP_URL_FRONT" ] && [ -n "$TEMP_URL_BACK" ]; then
|
||||
FRONT_OK=false
|
||||
BACK_OK=false
|
||||
if wait_remote_ok "${TEMP_URL_FRONT}/login" 25 || wait_remote_ok "${TEMP_URL_FRONT}" 25; then
|
||||
FRONT_OK=true
|
||||
fi
|
||||
if wait_remote_ok "${TEMP_URL_BACK}/healthz" 25; then
|
||||
BACK_OK=true
|
||||
fi
|
||||
|
||||
echo "$TEMP_URL_FRONT" > logs/public_url.txt
|
||||
echo "$TEMP_URL_BACK" >> logs/public_url.txt
|
||||
|
||||
if [ "$FRONT_OK" = true ] && [ "$BACK_OK" = true ]; then
|
||||
echo -e "${GREEN}✅ Tunnels 已启动且公网可访问${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠️ Tunnel 已启动,但公网健康检查未全部通过(请稍后再试)${NC}"
|
||||
fi
|
||||
echo -e "🌐 前端公网地址(临时): ${YELLOW}${TEMP_URL_FRONT}${NC}"
|
||||
echo -e "🌐 后端公网地址(临时): ${YELLOW}${TEMP_URL_BACK}${NC}"
|
||||
echo -e "${YELLOW}ℹ️ 前端公网默认通过 /api 代理访问本地后端,无需手动修改 VITE_API_BASE_URL。${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠️ Tunnel 已启动但部分地址未解析,请查看 logs/tunnel-*.log${NC}"
|
||||
fi
|
||||
|
||||
LAN_IP="$(ipconfig getifaddr en0 2>/dev/null || echo '本机IP')"
|
||||
echo
|
||||
echo -e "${GREEN}🎉 启动完成${NC}"
|
||||
echo "===================="
|
||||
echo "本地前端: http://127.0.0.1:5174"
|
||||
echo "本地后端: http://127.0.0.1:8000"
|
||||
echo "局域网前端: http://${LAN_IP}:5174"
|
||||
echo "API 文档: http://127.0.0.1:8000/docs"
|
||||
if [ -n "$TEMP_URL_FRONT" ]; then
|
||||
echo "前端公网: ${TEMP_URL_FRONT}"
|
||||
fi
|
||||
if [ -n "$TEMP_URL_BACK" ]; then
|
||||
echo "后端公网: ${TEMP_URL_BACK}"
|
||||
fi
|
||||
echo "日志: logs/backend.log | logs/frontend.log | logs/tunnel-*.log"
|
||||
echo "停止: ./stop.sh"
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
LOG_DIR="${ROOT_DIR}/logs"
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
TIMESTAMP="$(date '+%Y%m%d_%H%M%S')"
|
||||
LOG_FILE="${LOG_DIR}/model_probe_${TIMESTAMP}.log"
|
||||
ENV_FILE="${PMFLOW_ENV_FILE:-${ROOT_DIR}/.env}"
|
||||
|
||||
echo "[模型检测] 开始执行独立连通性检测"
|
||||
echo "[模型检测] 环境文件: ${ENV_FILE}"
|
||||
echo "[模型检测] 日志文件: ${LOG_FILE}"
|
||||
|
||||
python3 "${ROOT_DIR}/scripts/model_probe.py" --env-file "${ENV_FILE}" "$@" 2>&1 | tee "${LOG_FILE}"
|
||||
STATUS=${PIPESTATUS[0]}
|
||||
|
||||
if [[ "${STATUS}" -eq 0 ]]; then
|
||||
echo "[模型检测] 完成:全部模型连通正常"
|
||||
else
|
||||
echo "[模型检测] 完成:存在连通性异常,请查看 ${LOG_FILE}"
|
||||
fi
|
||||
|
||||
exit "${STATUS}"
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
#!/bin/bash
|
||||
# Neural Sync 停止器 - 停止所有服务
|
||||
|
||||
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo "🛑 停止 Neural Sync 服务"
|
||||
echo "===================="
|
||||
|
||||
# 从 PID 文件读取并停止
|
||||
if [ -f "logs/backend.pid" ]; then
|
||||
BACKEND_PID=$(cat logs/backend.pid)
|
||||
if ps -p $BACKEND_PID > /dev/null 2>&1; then
|
||||
kill $BACKEND_PID 2>/dev/null
|
||||
echo -e "${GREEN}✅ 后端已停止 (PID: $BACKEND_PID)${NC}"
|
||||
fi
|
||||
rm logs/backend.pid
|
||||
fi
|
||||
|
||||
if [ -f "logs/frontend.pid" ]; then
|
||||
FRONTEND_PID=$(cat logs/frontend.pid)
|
||||
if ps -p $FRONTEND_PID > /dev/null 2>&1; then
|
||||
kill $FRONTEND_PID 2>/dev/null
|
||||
echo -e "${GREEN}✅ 前端已停止 (PID: $FRONTEND_PID)${NC}"
|
||||
fi
|
||||
rm logs/frontend.pid
|
||||
fi
|
||||
|
||||
if [ -f "logs/tunnel.pid" ]; then
|
||||
TUNNEL_PID=$(cat logs/tunnel.pid)
|
||||
if ps -p $TUNNEL_PID > /dev/null 2>&1; then
|
||||
kill $TUNNEL_PID 2>/dev/null
|
||||
echo -e "${GREEN}✅ Cloudflare Tunnel 已停止 (PID: $TUNNEL_PID)${NC}"
|
||||
fi
|
||||
rm logs/tunnel.pid
|
||||
fi
|
||||
|
||||
if [ -f "logs/tunnel-frontend.pid" ]; then
|
||||
TUNNEL_FRONT_PID=$(cat logs/tunnel-frontend.pid)
|
||||
if ps -p $TUNNEL_FRONT_PID > /dev/null 2>&1; then
|
||||
kill $TUNNEL_FRONT_PID 2>/dev/null
|
||||
echo -e "${GREEN}✅ 前端 Tunnel 已停止 (PID: $TUNNEL_FRONT_PID)${NC}"
|
||||
fi
|
||||
rm logs/tunnel-frontend.pid
|
||||
fi
|
||||
|
||||
if [ -f "logs/tunnel-backend.pid" ]; then
|
||||
TUNNEL_BACK_PID=$(cat logs/tunnel-backend.pid)
|
||||
if ps -p $TUNNEL_BACK_PID > /dev/null 2>&1; then
|
||||
kill $TUNNEL_BACK_PID 2>/dev/null
|
||||
echo -e "${GREEN}✅ 后端 Tunnel 已停止 (PID: $TUNNEL_BACK_PID)${NC}"
|
||||
fi
|
||||
rm logs/tunnel-backend.pid
|
||||
fi
|
||||
|
||||
# Fallback: kill by name just in case
|
||||
pkill -f "cloudflared tunnel --url http://127.0.0.1:5174" >/dev/null 2>&1 || true
|
||||
pkill -f "cloudflared tunnel --url http://localhost:5174" >/dev/null 2>&1 || true
|
||||
pkill -f "cloudflared tunnel --url http://127.0.0.1:8000" >/dev/null 2>&1 || true
|
||||
pkill -f "cloudflared tunnel --url http://localhost:8000" >/dev/null 2>&1 || true
|
||||
pkill -f "cloudflared tunnel run" >/dev/null 2>&1 || true
|
||||
|
||||
# 清理端口 (8000 后端, 5173/5174 前端)
|
||||
lsof -ti:8000 2>/dev/null | xargs kill -9 2>/dev/null || true
|
||||
lsof -ti:5173 2>/dev/null | xargs kill -9 2>/dev/null || true
|
||||
lsof -ti:5174 2>/dev/null | xargs kill -9 2>/dev/null || true
|
||||
|
||||
# 停止 Ollama(可选)
|
||||
read -p "是否同时停止 Ollama? (y/N) " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
pkill -9 -f "ollama serve" 2>/dev/null && echo -e "${GREEN}✅ Ollama 已停止${NC}" || true
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}🎉 所有服务已停止${NC}"
|
||||
46
start.sh
46
start.sh
|
|
@ -9,7 +9,7 @@ MODE="${1:-dev}"
|
|||
stop_legacy_conflicts() {
|
||||
local pid cmd
|
||||
if [[ "${PMFLOW_KEEP_LEGACY:-0}" == "1" ]]; then
|
||||
echo "[PMFlow] skip legacy conflict cleanup (PMFLOW_KEEP_LEGACY=1)"
|
||||
echo "[PMFlow] 跳过旧链路冲突清理(PMFLOW_KEEP_LEGACY=1)"
|
||||
return
|
||||
fi
|
||||
|
||||
|
|
@ -18,7 +18,7 @@ stop_legacy_conflicts() {
|
|||
cmd="$(ps -p "$pid" -o command= 2>/dev/null || true)"
|
||||
if [[ "$cmd" == *"uvicorn app.main:app"* ]]; then
|
||||
kill "$pid" 2>/dev/null || true
|
||||
echo "[PMFlow] stopped legacy backend pid=$pid"
|
||||
echo "[PMFlow] 已停止旧链路后端 pid=$pid"
|
||||
fi
|
||||
done < <(lsof -tiTCP:8000 -sTCP:LISTEN 2>/dev/null || true)
|
||||
|
||||
|
|
@ -27,7 +27,7 @@ stop_legacy_conflicts() {
|
|||
cmd="$(ps -p "$pid" -o command= 2>/dev/null || true)"
|
||||
if [[ "$cmd" == *"npm run dev -- --host 0.0.0.0 --port 5174"* ]] || [[ "$cmd" == *"vite --host 0.0.0.0 --port 5174"* ]]; then
|
||||
kill "$pid" 2>/dev/null || true
|
||||
echo "[PMFlow] stopped legacy frontend pid=$pid"
|
||||
echo "[PMFlow] 已停止旧链路前端 pid=$pid"
|
||||
fi
|
||||
done < <(lsof -tiTCP:5174 -sTCP:LISTEN 2>/dev/null || true)
|
||||
}
|
||||
|
|
@ -160,11 +160,11 @@ start_or_reuse_tunnel() {
|
|||
|
||||
print_temp_public_urls() {
|
||||
if [[ "${PMFLOW_NO_TUNNEL:-0}" == "1" ]]; then
|
||||
echo "[PMFlow] skip temporary tunnel (PMFLOW_NO_TUNNEL=1)"
|
||||
echo "[PMFlow] 跳过临时公网隧道(PMFLOW_NO_TUNNEL=1)"
|
||||
return
|
||||
fi
|
||||
if ! command -v cloudflared >/dev/null 2>&1; then
|
||||
echo "[PMFlow] cloudflared not found, skip temporary public urls"
|
||||
echo "[PMFlow] 未检测到 cloudflared,跳过临时公网地址生成"
|
||||
return
|
||||
fi
|
||||
|
||||
|
|
@ -173,9 +173,9 @@ print_temp_public_urls() {
|
|||
local expose_backend="${PMFLOW_EXPOSE_BACKEND_TUNNEL:-0}"
|
||||
local protocol="${PMFLOW_TUNNEL_PROTOCOL:-http2}"
|
||||
local frontend_watch="${PMFLOW_FRONTEND_TUNNEL_WATCH:-1}"
|
||||
echo "[PMFlow] tunnel protocol: ${protocol}"
|
||||
echo "[PMFlow] 隧道协议: ${protocol}"
|
||||
frontend_port="$(resolve_frontend_tunnel_port)"
|
||||
echo "[PMFlow] tunnel frontend origin: http://127.0.0.1:${frontend_port}"
|
||||
echo "[PMFlow] 隧道前端源站: http://127.0.0.1:${frontend_port}"
|
||||
front_url=""
|
||||
if [[ "$frontend_watch" == "1" ]]; then
|
||||
bash scripts/tunnel_frontend_watch.sh start >/dev/null 2>&1 || true
|
||||
|
|
@ -200,44 +200,40 @@ print_temp_public_urls() {
|
|||
: > logs/public_url.txt
|
||||
if [[ -n "$front_url" ]]; then
|
||||
echo "frontend=$front_url" >> logs/public_url.txt
|
||||
echo "[PMFlow] temp frontend: $front_url"
|
||||
echo "[PMFlow] 临时前端地址: $front_url"
|
||||
else
|
||||
echo "[PMFlow] temp frontend: (pending, check logs/tunnel-frontend.log)"
|
||||
echo "[PMFlow] 临时前端地址: (等待中,请查看 logs/tunnel-frontend.log)"
|
||||
fi
|
||||
if [[ "$expose_backend" != "1" ]]; then
|
||||
echo "[PMFlow] temp backend: (skip, PMFLOW_EXPOSE_BACKEND_TUNNEL=0)"
|
||||
echo "[PMFlow] 临时后端地址: (已跳过,PMFLOW_EXPOSE_BACKEND_TUNNEL=0)"
|
||||
else
|
||||
if [[ -n "$back_url" ]]; then
|
||||
echo "backend=$back_url" >> logs/public_url.txt
|
||||
echo "[PMFlow] temp backend: $back_url"
|
||||
echo "[PMFlow] 临时后端地址: $back_url"
|
||||
else
|
||||
echo "[PMFlow] temp backend: (pending, check logs/tunnel-backend.log)"
|
||||
echo "[PMFlow] 临时后端地址: (等待中,请查看 logs/tunnel-backend.log)"
|
||||
fi
|
||||
fi
|
||||
echo "[PMFlow] public urls saved: logs/public_url.txt"
|
||||
echo "[PMFlow] 公网地址已写入: logs/public_url.txt"
|
||||
}
|
||||
|
||||
case "$MODE" in
|
||||
dev|"")
|
||||
echo "[PMFlow] start mode: docker-dev hot reload"
|
||||
echo "[PMFlow] 启动模式: 开发环境(Docker 热重载)"
|
||||
stop_legacy_conflicts
|
||||
bash scripts/docker_stack.sh dev-start
|
||||
echo "[PMFlow] frontend: http://127.0.0.1:5174"
|
||||
echo "[PMFlow] backend: http://127.0.0.1:8000"
|
||||
echo "[PMFlow] 前端地址: http://127.0.0.1:5174"
|
||||
echo "[PMFlow] 后端地址: http://127.0.0.1:8000"
|
||||
print_temp_public_urls
|
||||
;;
|
||||
prod)
|
||||
echo "[PMFlow] start mode: docker prod compose"
|
||||
echo "[PMFlow] 启动模式: 生产环境(Docker Compose)"
|
||||
stop_legacy_conflicts
|
||||
bash scripts/docker_stack.sh start
|
||||
echo "[PMFlow] frontend: http://127.0.0.1:5174"
|
||||
echo "[PMFlow] backend: http://127.0.0.1:8000"
|
||||
echo "[PMFlow] 前端地址: http://127.0.0.1:5174"
|
||||
echo "[PMFlow] 后端地址: http://127.0.0.1:8000"
|
||||
print_temp_public_urls
|
||||
;;
|
||||
legacy)
|
||||
echo "[PMFlow] start mode: legacy local (venv + npm + tunnel)"
|
||||
bash scripts/start_legacy_local.sh
|
||||
;;
|
||||
urls)
|
||||
if [[ -s logs/public_url.txt ]]; then
|
||||
local_front_url="$(rg '^frontend=' logs/public_url.txt | tail -n 1 | sed 's/^frontend=//' || true)"
|
||||
|
|
@ -252,12 +248,12 @@ case "$MODE" in
|
|||
echo "前端: ${local_front_url:-未就绪}"
|
||||
echo "后端: ${local_back_url:-未暴露}"
|
||||
else
|
||||
echo "[PMFlow] no public url record found (logs/public_url.txt)"
|
||||
echo "[PMFlow] 未找到公网地址记录(logs/public_url.txt)"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 [dev|prod|legacy|urls]"
|
||||
echo "用法: $0 [dev|prod|urls]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
|
|
|||
6
stop.sh
6
stop.sh
|
|
@ -15,12 +15,8 @@ case "$MODE" in
|
|||
echo "[PMFlow] stop mode: docker prod compose"
|
||||
bash scripts/docker_stack.sh stop
|
||||
;;
|
||||
legacy)
|
||||
echo "[PMFlow] stop mode: legacy local (venv + npm + tunnel)"
|
||||
bash scripts/stop_legacy_local.sh
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 [dev|prod|legacy]"
|
||||
echo "Usage: $0 [dev|prod]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
|
|
|||
Loading…
Reference in New Issue