4 Commits

Author SHA1 Message Date
kris
ea6a855890 feat: surface cutvideo upload capability in health ui 2026-03-23 05:21:48 +08:00
kris
042188f954 feat: restore local model gateway via cliproxy 2026-03-22 18:58:55 +08:00
kris
c657db9b38 feat: surface local model health in web ui 2026-03-22 14:18:38 +08:00
kris
652f0c9f79 feat: extend web tracking and integration controls 2026-03-22 14:13:10 +08:00
11 changed files with 963 additions and 63 deletions

View File

@@ -33,3 +33,7 @@ WEBHOOK_URL=http://127.0.0.1:5670/
GENERIC_TIMEZONE=Asia/Shanghai
TZ=Asia/Shanghai
CLIPROXY_IMAGE=storyforge/cli-proxy-api:patched
CLIPROXY_MANAGEMENT_SECRET=storyforge-local-management
CLIPROXY_DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
# Optional but recommended for local model gateway recovery.
# DASHSCOPE_API_KEY=

View File

@@ -107,6 +107,14 @@ cp .env.example .env
docker compose up -d --build
```
如果要让本机模型网关 `cli-proxy-api` 自动提供 `GLM-5`,建议在启动前确保本机环境里存在:
```bash
export DASHSCOPE_API_KEY=your_dashscope_key
```
或者把它写进本地 `.env``./scripts/start_business.sh` 会自动生成 `data/cliproxyapi/config.yaml` 并把 `glm-5 -> GLM-5` 映射到本机网关。
如果 `collector` 跑在 Docker 里,建议保留:
```bash

View File

@@ -1885,6 +1885,33 @@ def register_douyin_routes(app: Any, legacy: Any) -> None:
"items": items[: max(1, min(limit, 100))]
}
async def _refresh_tracked_account_workspace(
owner: dict[str, Any],
tracked_account_id: str,
discovery_note: str = "tracking_refresh"
) -> dict[str, Any]:
account_row = _require_owned_account(tracked_account_id, owner["id"])
profile_url = _first_non_empty(
account_row.get("canonical_profile_url"),
account_row.get("profile_url")
)
if not profile_url:
raise HTTPException(status_code=400, detail="Tracked account has no profile_url to refresh")
request = DouyinAccountSyncRequest(
profile_url=profile_url,
compact_response=True,
discovery_note=discovery_note
)
public_data = await _collect_public_profile(profile_url, None)
creator_data = {"pages": [], "errors": []}
return await run_in_threadpool(
_finalize_sync_workspace,
owner,
request,
public_data,
creator_data
)
def _normalize_report_text(value: Any) -> str:
text = str(value or "").strip()
if not text:
@@ -3374,6 +3401,71 @@ def register_douyin_routes(app: Any, legacy: Any) -> None:
"items": _list_tracked_accounts(account["id"])
}
@app.post("/v2/douyin/tracking/accounts/{tracked_account_id}/refresh")
async def refresh_douyin_tracked_account(
tracked_account_id: str,
account: dict[str, Any] = Depends(legacy.require_approved)
) -> dict[str, Any]:
account_row = _require_owned_account(tracked_account_id, account["id"])
account_payload = _build_account_payload(account_row, include_recent_videos=6)
try:
refreshed = await _refresh_tracked_account_workspace(account, tracked_account_id)
return {
"success": True,
"tracked_account_id": tracked_account_id,
"account": refreshed.get("account", {}),
"sync_errors": refreshed.get("sync_errors", []),
"public_video_count": refreshed.get("public_video_count", 0),
"creator_page_count": refreshed.get("creator_page_count", 0)
}
except HTTPException as exc:
detail = exc.detail if isinstance(exc.detail, dict) else {"message": str(exc.detail)}
return {
"success": False,
"tracked_account_id": tracked_account_id,
"account": account_payload,
"message": detail.get("message") or str(exc.detail),
"detail": detail,
"sync_errors": detail.get("public_errors", []) + detail.get("creator_errors", [])
}
@app.post("/v2/douyin/tracking/refresh")
async def refresh_all_douyin_tracked_accounts(
account: dict[str, Any] = Depends(legacy.require_approved)
) -> dict[str, Any]:
tracked_accounts = _list_tracked_accounts(account["id"])
items: list[dict[str, Any]] = []
errors: list[dict[str, Any]] = []
for tracked in tracked_accounts:
try:
refreshed = await _refresh_tracked_account_workspace(account, tracked["tracked_account_id"])
items.append({
"tracking_id": tracked["id"],
"tracked_account_id": tracked["tracked_account_id"],
"nickname": (refreshed.get("account") or {}).get("nickname", ""),
"sync_errors": refreshed.get("sync_errors", []),
"public_video_count": refreshed.get("public_video_count", 0)
})
except HTTPException as exc:
errors.append({
"tracking_id": tracked["id"],
"tracked_account_id": tracked["tracked_account_id"],
"message": str(exc.detail)
})
except Exception as exc:
errors.append({
"tracking_id": tracked["id"],
"tracked_account_id": tracked["tracked_account_id"],
"message": str(exc)
})
return {
"tracked_count": len(tracked_accounts),
"refreshed": len(items),
"failed": len(errors),
"items": items,
"errors": errors
}
@app.post("/v2/douyin/tracking/cursor")
def update_douyin_tracking_cursor(
request: DouyinTrackingCursorRequest,

View File

@@ -1459,6 +1459,58 @@ def probe_http(url: str, path: str = "", timeout: float = 3.0) -> dict[str, Any]
return tcp
def local_model_public_base_url() -> str:
if not LOCAL_OPENAI_BASE_URL:
return ""
parsed = urlparse(LOCAL_OPENAI_BASE_URL)
scheme = parsed.scheme or "http"
host = parsed.hostname or "127.0.0.1"
if host in {"host.docker.internal", "localhost"}:
host = "127.0.0.1"
port = parsed.port
root = f"{scheme}://{host}"
if port:
root = f"{root}:{port}"
return root
def fetch_local_model_catalog(timeout: float = 8.0) -> dict[str, Any]:
detail = probe_http(LOCAL_OPENAI_BASE_URL, "/models", timeout=timeout)
public_base_url = local_model_public_base_url()
management_url = f"{public_base_url}/management.html" if public_base_url else ""
payload = {
"configured": detail.get("configured", False),
"reachable": detail.get("reachable", False),
"base_url": LOCAL_OPENAI_BASE_URL,
"public_base_url": public_base_url,
"management_url": management_url,
"default_model": LOCAL_OPENAI_MODEL,
"models": [],
"status_code": detail.get("status_code", 0),
"error": detail.get("error", ""),
"url": detail.get("url", ""),
}
if not detail.get("configured") or not detail.get("reachable"):
return payload
try:
response = httpx.get(urljoin(LOCAL_OPENAI_BASE_URL if LOCAL_OPENAI_BASE_URL.endswith("/") else f"{LOCAL_OPENAI_BASE_URL}/", "models"), timeout=timeout)
response.raise_for_status()
data = response.json()
payload["models"] = [
{
"id": item.get("id", ""),
"owned_by": item.get("owned_by", ""),
"created": item.get("created", 0),
}
for item in (data.get("data") or [])
if isinstance(item, dict)
]
except Exception as exc: # pragma: no cover - operational probe
payload["reachable"] = False
payload["error"] = str(exc)
return payload
@app.on_event("startup")
def on_startup() -> None:
db.init_schema()
@@ -1483,10 +1535,25 @@ def healthz() -> dict[str, Any]:
@app.get("/v2/integrations/health")
def integrations_health(account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]:
_ = account
cutvideo_bootstrap = probe_http(CUTVIDEO_BASE_URL, "/api/bootstrap", timeout=5.0)
cutvideo_uploads = probe_http(CUTVIDEO_BASE_URL, "/api/uploads", timeout=5.0)
cutvideo_supports_uploads = bool(
cutvideo_uploads.get("configured")
and cutvideo_uploads.get("reachable")
and int(cutvideo_uploads.get("status_code") or 0) != 404
)
return {
"local_model": {
"base_url": LOCAL_OPENAI_BASE_URL,
**probe_http(LOCAL_OPENAI_BASE_URL, "/models"),
},
"cutvideo": {
"base_url": CUTVIDEO_BASE_URL,
**probe_http(CUTVIDEO_BASE_URL, "/api/bootstrap"),
**cutvideo_bootstrap,
"supports_uploads": cutvideo_supports_uploads,
"upload_status_code": int(cutvideo_uploads.get("status_code") or 0),
"upload_error": cutvideo_uploads.get("error", ""),
"upload_url": cutvideo_uploads.get("url", ""),
},
"huobao": {
"base_url": HUOBAO_BASE_URL,
@@ -1503,6 +1570,12 @@ def integrations_health(account: dict[str, Any] = Depends(require_approved)) ->
}
@app.get("/v2/integrations/local-models")
def integrations_local_models(account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]:
_ = account
return fetch_local_model_catalog()
def seed_defaults() -> None:
if not db.fetch_one("SELECT id FROM model_profiles WHERE is_default = 1 LIMIT 1"):
profile_id = make_id("model")

View File

@@ -65,6 +65,14 @@ services:
image: ${CLIPROXY_IMAGE:-storyforge/cli-proxy-api:patched}
container_name: storyforge-cliproxyapi
restart: unless-stopped
command:
- ./CLIProxyAPI
- -config
- /CLIProxyAPI/config.yaml
volumes:
- ./data/cliproxyapi/config.yaml:/CLIProxyAPI/config.yaml:ro
- ./data/cliproxyapi/auths:/root/.cli-proxy-api
- ./data/cliproxyapi/logs:/CLIProxyAPI/logs
ports:
- "8317:8317"
- "8085:8085"

View File

@@ -0,0 +1,73 @@
#!/bin/sh
set -eu
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
if [ -f "$ROOT/.env" ]; then
set -a
# shellcheck disable=SC1091
. "$ROOT/.env"
set +a
fi
DATA_DIR="$ROOT/data/cliproxyapi"
CONFIG_PATH="$DATA_DIR/config.yaml"
mkdir -p "$DATA_DIR/auths" "$DATA_DIR/logs"
: "${CLIPROXY_MANAGEMENT_SECRET:=storyforge-local-management}"
: "${CLIPROXY_DASHSCOPE_BASE_URL:=https://dashscope.aliyuncs.com/compatible-mode/v1}"
python3 - <<'PY' "$CONFIG_PATH" "$CLIPROXY_MANAGEMENT_SECRET" "$CLIPROXY_DASHSCOPE_BASE_URL"
import os
import sys
from pathlib import Path
config_path = Path(sys.argv[1])
management_secret = sys.argv[2]
base_url = sys.argv[3]
dashscope_api_key = os.environ.get("DASHSCOPE_API_KEY", "").strip()
lines = [
'host: ""',
'port: 8317',
'tls:',
' enable: false',
' cert: ""',
' key: ""',
'remote-management:',
' allow-remote: false',
f' secret-key: "{management_secret}"',
' disable-control-panel: false',
'auth-dir: "/root/.cli-proxy-api"',
'debug: false',
'logging-to-file: true',
'logs-max-total-size-mb: 200',
'usage-statistics-enabled: true',
'request-retry: 2',
]
if dashscope_api_key:
lines.extend(
[
'openai-compatibility:',
' - name: "dashscope"',
f' base-url: "{base_url}"',
' api-key-entries:',
f' - api-key: "{dashscope_api_key}"',
' models:',
' - name: "glm-5"',
' alias: "GLM-5"',
' - name: "glm-5"',
' alias: "glm-5"',
' - name: "qwen3.5-plus"',
' alias: "qwen3.5-plus"',
]
)
config_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
if dashscope_api_key:
print(f"rendered cliproxy config with DashScope upstream -> {config_path}")
else:
print(f"rendered cliproxy config without upstream credentials -> {config_path}")
PY

View File

@@ -5,7 +5,14 @@ ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
COMPOSE_FILE="$ROOT/docker-compose.yml"
cd "$ROOT"
docker compose -f "$COMPOSE_FILE" up -d --build collector n8n
"$ROOT/scripts/render_cliproxy_config.sh"
OWNER="$(docker inspect storyforge-cliproxyapi --format '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}' 2>/dev/null || true)"
if [ -n "$OWNER" ] && [ "$OWNER" != "$ROOT" ]; then
docker rm -f storyforge-cliproxyapi >/dev/null 2>&1 || true
fi
docker compose -f "$COMPOSE_FILE" up -d --build collector n8n cli-proxy-api
python3 - <<'PY'
import time
@@ -14,6 +21,7 @@ import urllib.request
checks = [
("collector", "http://127.0.0.1:8081/healthz"),
("n8n", "http://127.0.0.1:5670/healthz"),
("cli-proxy-api", "http://127.0.0.1:8317/v1/models"),
]
deadline = time.time() + 45
@@ -37,3 +45,4 @@ PY
echo "business started"
echo "collector: http://127.0.0.1:8081/healthz"
echo "n8n: http://127.0.0.1:5670/healthz"
echo "cli-proxy-api: http://127.0.0.1:8317/v1/models"

View File

@@ -13,6 +13,7 @@ import urllib.request
for name, url in [
("collector", "http://127.0.0.1:8081/healthz"),
("n8n", "http://127.0.0.1:5670/healthz"),
("cli-proxy-api", "http://127.0.0.1:8317/v1/models"),
]:
try:
with urllib.request.urlopen(url, timeout=5) as resp:

View File

@@ -35,6 +35,8 @@
- 单账号作品列表 `/v2/douyin/accounts/{id}/videos`
- 跟踪账号 `/v2/douyin/tracking/accounts`
- 跟踪日报 `/v2/douyin/tracking/digest`
- 发布复盘 `/v2/reviews`
- 集成健康 `/v2/integrations/health`
- 最近知识库文档 `/v2/knowledge-bases/{id}/documents`
## 当前已接入的真实动作
@@ -51,10 +53,16 @@
- 查找相似对标账号
- 从相似候选一键保存对标关系
- 把当前对标账号加入跟踪,并绑定 Agent
- 单账号立即同步跟踪对象
- 批量同步全部跟踪对象
- 日报手动标记已读,不再在刷新页面时自动吞掉未读摘要
- 按上次打开后生成跟踪日报与借鉴点摘要
- 查看任务详情、事件、子任务和 artifacts/result
- 从任务详情直接衔接 AI 视频 / 实拍剪辑 / 文案生成
- 在生产中心 / 发布与复盘常驻最近一次任务详情摘要
- 在 Web 中直接创建和编辑复盘
- 在页面里直接看到 `本机模型 / cutvideo / huobao / n8n / ASR` 的真实健康状态
- 依赖不可达时,自动拦住 AI 视频 / 实拍剪辑动作并展示原因
- 使用 Agent 生成文案
- 创建 AI 视频任务
- 创建实拍剪辑任务
@@ -78,10 +86,10 @@ python3 -m http.server 3918
## 后续建议
- 继续补动作型接口,例如导入、绑定 Agent、触发分析与生产
- 继续补多平台真实接入,而不只是一套 Douyin 工作流
- 把对标导入后的 Agent 绑定和知识库入库反馈做得更完整
- 把跟踪日报从 Douyin 扩到多平台统一模型
- 把跟踪日报从 Douyin 扩到多平台统一模型,并接入真正的定时调度
- 把全局搜索和页内搜索合并成统一搜索体验
-`生产中心 / 发布与复盘` 接入更完整的任务与成片对象
-`生产中心 / 发布与复盘` 接入更完整的成片预览与封面对象
- 不要把这套页面重新塞回 `scripts/douyin-browser-capture/control_panel.mjs`
- 抖音采集控制台仍作为独立工具存在,这里才是正式业务应用壳

View File

@@ -24,6 +24,7 @@ const appState = {
trackingDigest: null,
reviews: [],
integrationHealth: null,
localModelCatalog: null,
busy: false,
message: "",
lastAction: null,
@@ -32,6 +33,49 @@ const appState = {
lastJobDetail: null
};
const INTEGRATION_ORDER = ["local_model", "cutvideo", "huobao", "n8n", "asr"];
const INTEGRATION_META = {
local_model: {
label: "本机模型",
hint: "OpenAI-compatible",
impacts: ["账号分析", "高分分析", "文案生成"]
},
cutvideo: {
label: "自动剪辑",
hint: "Windows cutvideo",
impacts: ["实拍剪辑"]
},
huobao: {
label: "AI 视频",
hint: "huobao-drama",
impacts: ["AI 视频"]
},
n8n: {
label: "编排",
hint: "n8n workflow",
impacts: ["AI 视频", "实拍剪辑", "自动链路"]
},
asr: {
label: "ASR",
hint: "素材转写",
impacts: ["分析转写"]
}
};
const PIPELINE_GUARDS = {
aiVideo: {
label: "AI 视频",
openAction: "open-ai-video",
jobAction: "job-to-ai-video",
dependencies: ["n8n", "huobao"]
},
realCut: {
label: "实拍剪辑",
openAction: "open-real-cut",
jobAction: "job-to-real-cut",
dependencies: ["n8n", "cutvideo"]
}
};
function safeArray(value) {
return Array.isArray(value) ? value : [];
}
@@ -554,13 +598,14 @@ async function bootstrap() {
renderAll();
return;
}
const [dashboard, contentSources, accounts, trackingAccountsPayload, reviews, integrationHealth] = await Promise.all([
const [dashboard, contentSources, accounts, trackingAccountsPayload, reviews, integrationHealth, localModelCatalog] = await Promise.all([
storyforgeFetch("/v2/me/dashboard"),
storyforgeFetch("/v2/content-sources").catch(() => []),
storyforgeFetch("/v2/douyin/accounts").catch(() => []),
storyforgeFetch("/v2/douyin/tracking/accounts").catch(() => ({ items: [], cursor_last_seen_at: "" })),
storyforgeFetch("/v2/reviews").catch(() => []),
storyforgeFetch("/v2/integrations/health").catch(() => null)
storyforgeFetch("/v2/integrations/health").catch(() => null),
storyforgeFetch("/v2/integrations/local-models").catch(() => null)
]);
const trackingCursorLastSeenAt = trackingAccountsPayload?.cursor_last_seen_at || "";
if (trackingCursorLastSeenAt) {
@@ -579,6 +624,7 @@ async function bootstrap() {
appState.trackingDigest = trackingDigest;
appState.reviews = safeArray(reviews);
appState.integrationHealth = integrationHealth;
appState.localModelCatalog = localModelCatalog;
appState.documents = await loadKnowledgeDocuments(dashboard.knowledge_bases);
appState.selectedProjectId = appState.selectedProjectId || dashboard.projects?.[0]?.id || "";
const selectedAssistantExists = safeArray(dashboard.assistants).some((item) => item.id === appState.selectedAssistantId);
@@ -592,12 +638,6 @@ async function bootstrap() {
appState.selectedWorkspace = null;
appState.selectedVideos = { items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 };
}
const nextSeenAt = new Date().toISOString();
storyforgeFetch("/v2/douyin/tracking/cursor", {
method: "POST",
body: { last_seen_at: nextSeenAt }
}).catch(() => null);
setLastSeenAt(nextSeenAt);
} catch (error) {
appState.message = error.message;
if (String(error.message || "").includes("401") || String(error.message || "").includes("Not authenticated")) {
@@ -609,6 +649,57 @@ async function bootstrap() {
}
}
async function markTrackingDigestRead() {
const nextSeenAt = new Date().toISOString();
await storyforgeFetch("/v2/douyin/tracking/cursor", {
method: "POST",
body: { last_seen_at: nextSeenAt }
});
setLastSeenAt(nextSeenAt);
}
async function refreshTrackingAccountsAction() {
setBusy(true, "正在同步跟踪账号...");
try {
const payload = await storyforgeFetch("/v2/douyin/tracking/refresh", {
method: "POST"
});
rememberAction(
"跟踪已同步",
`已刷新 ${formatNumber(payload.refreshed || 0)} 个账号${payload.failed ? `,失败 ${formatNumber(payload.failed)}` : ""}`,
payload.failed ? "orange" : "green",
payload
);
await bootstrap();
} finally {
setBusy(false, "");
}
}
async function refreshTrackedAccountAction(trackedAccountId) {
if (!trackedAccountId) {
throw new Error("trackedAccountId is required");
}
setBusy(true, "正在同步该跟踪账号...");
try {
const payload = await storyforgeFetch(`/v2/douyin/tracking/accounts/${encodeURIComponent(trackedAccountId)}/refresh`, {
method: "POST"
});
const success = payload.success !== false;
rememberAction(
success ? "单账号已同步" : "单账号刷新失败",
success
? `已刷新「${payload.account?.nickname || trackedAccountId}」的最新作品。`
: `暂时无法刷新「${payload.account?.nickname || trackedAccountId}」:${payload.message || "请稍后重试"}`,
success ? (safeArray(payload.sync_errors).length ? "orange" : "green") : "orange",
payload
);
await bootstrap();
} finally {
setBusy(false, "");
}
}
function getSelectedProject() {
const projects = safeArray(appState.dashboard?.projects);
return projects.find((item) => item.id === appState.selectedProjectId) || projects[0] || null;
@@ -643,6 +734,14 @@ function getModelOptions() {
return safeArray(appState.dashboard?.model_profiles).map((model) => ({ value: model.id, label: model.name }));
}
function getCurrentModelProfile() {
const models = safeArray(appState.dashboard?.model_profiles);
const currentId = appState.me?.preferred_analysis_model_id
|| models.find((item) => item.is_default)?.id
|| "";
return models.find((item) => item.id === currentId) || models.find((item) => item.is_default) || models[0] || null;
}
function getCompletedJobOptions() {
return safeArray(appState.dashboard?.recent_jobs)
.filter((item) => item.status === "completed")
@@ -778,6 +877,168 @@ function canDeriveRealCut(job) {
return ["video_link", "upload_video"].includes(sourceType);
}
function hasIntegrationHealthData() {
return Boolean(appState.integrationHealth && typeof appState.integrationHealth === "object");
}
function getIntegrationDetail(key) {
const raw = hasIntegrationHealthData() ? appState.integrationHealth?.[key] : null;
return {
key,
available: Boolean(raw && typeof raw === "object"),
configured: Boolean(raw?.configured),
reachable: Boolean(raw?.reachable),
statusCode: Number(raw?.status_code || 0),
error: String(raw?.error || ""),
url: String(raw?.url || raw?.base_url || ""),
baseUrl: String(raw?.base_url || ""),
supportsUploads: raw?.supports_uploads !== undefined ? Boolean(raw?.supports_uploads) : true,
uploadStatusCode: Number(raw?.upload_status_code || 0),
uploadError: String(raw?.upload_error || ""),
uploadUrl: String(raw?.upload_url || "")
};
}
function getIntegrationStatus(detail) {
if (!detail.available) {
return { tone: "blue", summary: "未拉取" };
}
if (detail.key === "cutvideo" && detail.reachable && !detail.supportsUploads) {
return { tone: "orange", summary: "缺上传能力" };
}
if (detail.reachable) {
return { tone: "green", summary: "在线" };
}
if (detail.configured) {
return { tone: "red", summary: "不可达" };
}
return { tone: "orange", summary: "未配置" };
}
function describeIntegrationFailure(key) {
const detail = getIntegrationDetail(key);
const meta = INTEGRATION_META[key] || { label: key };
if (!detail.available) return `${meta.label}健康状态未拉取`;
if (key === "cutvideo" && detail.reachable && !detail.supportsUploads) {
return `${meta.label}缺少 /api/uploads`;
}
if (!detail.configured) return `${meta.label}未配置`;
if (detail.statusCode) return `${meta.label}返回 HTTP ${detail.statusCode}`;
if (detail.error) return `${meta.label}${brief(detail.error, 42)}`;
return `${meta.label}不可达`;
}
function getPipelineGuard(kind) {
const config = PIPELINE_GUARDS[kind];
if (!config) {
return { enabled: true, reason: "", blocked: [] };
}
const blocked = config.dependencies
.map((key) => ({ key, detail: getIntegrationDetail(key), meta: INTEGRATION_META[key] || { label: key } }))
.filter((item) => {
if (!item.detail.available) return false;
if (!item.detail.reachable) return true;
if (item.key === "cutvideo" && !item.detail.supportsUploads) return true;
return false;
});
if (!blocked.length) {
return { enabled: true, reason: "", blocked: [] };
}
return {
enabled: false,
blocked,
reason: `${config.label}暂不可用:${blocked.map((item) => describeIntegrationFailure(item.key)).join("")}`
};
}
function getIntegrationCards() {
const currentModel = getCurrentModelProfile();
const localCatalog = appState.localModelCatalog || {};
return INTEGRATION_ORDER.map((key) => {
const detail = getIntegrationDetail(key);
const status = getIntegrationStatus(detail);
const meta = INTEGRATION_META[key] || { label: key, hint: key, impacts: [] };
let note = "尚未获取健康检查数据";
if (detail.available) {
if (detail.reachable) {
if (key === "cutvideo" && !detail.supportsUploads) {
note = detail.uploadStatusCode
? `主服务在线,但 /api/uploads 返回 HTTP ${detail.uploadStatusCode}`
: (detail.uploadError ? brief(detail.uploadError, 72) : "主服务在线,但缺少上传接口");
} else {
note = detail.statusCode
? `健康探测返回 HTTP ${detail.statusCode}`
: "TCP 探测已通过";
}
} else if (!detail.configured) {
note = "后端还没有配置该依赖地址";
} else if (detail.statusCode) {
note = `探测返回 HTTP ${detail.statusCode}`;
} else if (detail.error) {
note = brief(detail.error, 72);
} else {
note = "探测失败,请检查服务进程和网络";
}
}
let extra = "";
let actions = "";
if (key === "local_model") {
const availableModels = safeArray(localCatalog.models).map((item) => item.id).filter(Boolean);
extra = currentModel
? `当前主模型:${currentModel.name} · ${currentModel.model_name || "-"}`
: `默认模型:${localCatalog.default_model || "GLM-5"}`;
if (availableModels.length) {
extra += ` · 可用:${availableModels.slice(0, 4).join(" / ")}${availableModels.length > 4 ? "…" : ""}`;
}
actions = [
localCatalog.management_url
? `<a class="tag blue" href="${escapeHtml(localCatalog.management_url)}" target="_blank" rel="noreferrer">打开管理页</a>`
: "",
`<span class="tag clickable-tag" data-action="open-preferred-model">设主模型</span>`
].filter(Boolean).join("");
}
return {
key,
meta,
detail,
status,
note,
extra,
actions
};
});
}
function getIntegrationOverview() {
const cards = getIntegrationCards();
const reachableCount = cards.filter((item) => item.detail.available && item.detail.reachable).length;
const availableCount = cards.filter((item) => item.detail.available).length;
const aiVideoGuard = getPipelineGuard("aiVideo");
const realCutGuard = getPipelineGuard("realCut");
const blockedActions = [
!aiVideoGuard.enabled ? aiVideoGuard.reason : "",
!realCutGuard.enabled ? realCutGuard.reason : ""
].filter(Boolean);
const tone = !availableCount
? "blue"
: blockedActions.length
? "red"
: cards.some((item) => item.detail.available && !item.detail.reachable)
? "orange"
: "green";
const headline = !availableCount
? "依赖健康尚未拉取"
: blockedActions.length
? `自动链路受阻:${blockedActions.length}`
: `${reachableCount}/${cards.length} 项依赖在线`;
const subtitle = !availableCount
? "刷新后会显示 cutvideo / huobao / n8n / ASR 的真实状态。"
: blockedActions.length
? blockedActions.join("")
: "AI 视频与实拍剪辑链路当前可直接发起。";
return { cards, tone, headline, subtitle };
}
function getJobSeedBrief(job) {
return [
job?.style_summary,
@@ -885,8 +1146,105 @@ function screenShell(title, subtitle, actionsHtml, bodyHtml) {
`;
}
function button(label, action, tone = "secondary") {
return `<button class="btn btn-${tone}" type="button" data-action="${escapeHtml(action)}">${escapeHtml(label)}</button>`;
function button(label, action, tone = "secondary", options = {}) {
const classes = ["btn", `btn-${tone}`];
if (options.className) classes.push(options.className);
if (options.disabledReason) classes.push("is-disabled");
const targetAction = options.disabledReason ? "show-disabled-reason" : action;
const title = options.disabledReason || options.title || "";
return `
<button
class="${classes.join(" ")}"
type="button"
data-action="${escapeHtml(targetAction)}"
${options.disabledReason ? `data-disabled-reason="${escapeHtml(options.disabledReason)}" aria-disabled="true"` : ""}
${title ? `title="${escapeHtml(title)}"` : ""}
>${escapeHtml(label)}</button>
`.replace(/\s+/g, " ").trim();
}
function actionTag(label, action, attrs = "", options = {}) {
const classes = ["tag"];
const targetAction = options.disabledReason ? "show-disabled-reason" : action;
if (options.disabledReason) {
classes.push("tag-disabled");
} else if (targetAction) {
classes.push("clickable-tag");
}
const title = options.disabledReason || options.title || "";
return `
<span
class="${classes.join(" ")}"
${targetAction ? `data-action="${escapeHtml(targetAction)}"` : ""}
${options.disabledReason ? `data-disabled-reason="${escapeHtml(options.disabledReason)}" aria-disabled="true"` : ""}
${title ? `title="${escapeHtml(title)}"` : ""}
${attrs}
>${escapeHtml(label)}</span>
`.replace(/\s+/g, " ").trim();
}
function renderPipelineButton(kind, tone = "secondary") {
const config = PIPELINE_GUARDS[kind];
if (!config) return "";
const guard = getPipelineGuard(kind);
return button(config.label, config.openAction, tone, {
disabledReason: guard.enabled ? "" : guard.reason
});
}
function renderPipelineJobTag(kind, job, label) {
const config = PIPELINE_GUARDS[kind];
if (!config || !job?.id) return "";
const guard = getPipelineGuard(kind);
return actionTag(label, config.jobAction, `data-job-id="${escapeHtml(job.id)}"`, {
disabledReason: guard.enabled ? "" : guard.reason
});
}
function renderIntegrationOverviewPanel(options = {}) {
const overview = getIntegrationOverview();
const cards = overview.cards;
const showActions = options.showActions !== false;
return `
<div class="panel pad integration-panel ${options.compact ? "integration-panel-compact" : ""}">
<div class="integration-summary ${overview.tone}">
<div>
<strong>${escapeHtml(overview.headline)}</strong>
<p>${escapeHtml(overview.subtitle)}</p>
</div>
${showActions ? `
<div class="integration-actions">
${renderPipelineButton("aiVideo", "primary")}
${renderPipelineButton("realCut")}
</div>
` : ""}
</div>
<div class="task-meta integration-highlights">
${cards.map((item) => `<span class="tag ${item.status.tone}">${escapeHtml(item.meta.label)} ${escapeHtml(item.status.summary)}</span>`).join("")}
</div>
<div class="layout-grid grid-4 integration-grid">
${cards.map((item) => `
<div class="integration-card ${item.status.tone}">
<div class="integration-card-head">
<div>
<h4>${escapeHtml(item.meta.label)}</h4>
<p>${escapeHtml(item.meta.hint)}</p>
</div>
<span class="tag ${item.status.tone}">${escapeHtml(item.status.summary)}</span>
</div>
<div class="task-meta">
${safeArray(item.meta.impacts).map((impact) => `<span class="tag">${escapeHtml(impact)}</span>`).join("")}
${item.detail.statusCode ? `<span class="tag">HTTP ${escapeHtml(item.detail.statusCode)}</span>` : ""}
</div>
<div class="integration-note">${escapeHtml(item.note)}</div>
${item.extra ? `<div class="integration-note">${escapeHtml(item.extra)}</div>` : ""}
<div class="integration-url">${escapeHtml(item.detail.url || item.detail.baseUrl || "未提供探测地址")}</div>
${item.actions ? `<div class="task-meta integration-highlights" style="margin-top:12px;">${item.actions}</div>` : ""}
</div>
`).join("")}
</div>
</div>
`;
}
function renderEmptyState(title, description) {
@@ -936,6 +1294,9 @@ function renderDashboardScreen() {
<div class="stat-card"><small>Agent</small><strong>${escapeHtml(formatNumber(assistants.length))}</strong><div class="stat-foot"><span>已创建</span><span class="warn">${escapeHtml(formatNumber(assistants.filter((item) => !(item.model_profile_id || "")).length))} 个待补模型</span></div></div>
<div class="stat-card"><small>生产任务</small><strong>${escapeHtml(formatNumber(jobs.length))}</strong><div class="stat-foot"><span>最近 20 条</span><span class="positive">${escapeHtml(formatNumber(jobs.filter((item) => item.status === "completed").length))} 条已完成</span></div></div>
</div>
<div style="margin-top:18px;">
${renderIntegrationOverviewPanel({ compact: true })}
</div>
<div class="layout-grid grid-main" style="margin-top:18px;">
<div class="side-stack">
<div class="hero-card">
@@ -1330,10 +1691,11 @@ function renderTrackingScreen() {
}
const trackedAccounts = safeArray(appState.trackingAccounts);
const digestItems = getTrackingDigestItems(12);
const cursorLabel = appState.lastSeenAt ? formatDateTime(appState.lastSeenAt) : "尚未记录";
return screenShell(
"跟踪账号",
"这里已经接上真实跟踪对象和按上次打开后的更新日报。",
`${button("刷新日报", "refresh-data")} ${button("跳到找对标", "goto-discovery", "primary")}`,
`${button("同步全部", "refresh-tracking")} ${button("标记已读", "mark-tracking-read")} ${button("跳到找对标", "goto-discovery", "primary")}`,
`
<div class="hero-card">
<h3>日报逻辑</h3>
@@ -1342,6 +1704,7 @@ function renderTrackingScreen() {
<span class="chip active">按上次打开汇总</span>
<span class="chip">Agent 标借鉴点</span>
<span class="chip">高价值内容可进学习集</span>
<span class="chip">上次已读 ${escapeHtml(cursorLabel)}</span>
</div>
</div>
<div class="layout-grid grid-main" style="margin-top:18px;">
@@ -1361,7 +1724,8 @@ function renderTrackingScreen() {
<div class="task-meta">
<span class="tag green">已跟踪</span>
<span class="tag">${escapeHtml(item.assistant_name || "未绑 Agent")}</span>
<span class="tag clickable-tag" data-action="select-account" data-account-id="${escapeHtml(item.tracked_account_id)}">看详情</span>
${actionTag("立即同步", "refresh-tracked-account", `data-tracked-account-id="${escapeHtml(item.tracked_account_id)}"`)}
${actionTag("看详情", "select-account", `data-account-id="${escapeHtml(item.tracked_account_id)}"`)}
</div>
</div>
`).join("") || `<div class="task-item"><h4>暂无跟踪账号</h4><p>先去找对标把重点账号加入跟踪。</p></div>`}
@@ -1402,21 +1766,15 @@ function renderAutomationScreen() {
const analysisJobs = jobs.filter((item) => item.line_type === "analysis").length;
const aiVideoJobs = jobs.filter((item) => item.line_type === "ai_video").length;
const realCutJobs = jobs.filter((item) => item.line_type === "real_cut").length;
const integrations = appState.integrationHealth || {};
const integrationCards = [
{ key: "cutvideo", label: "自动剪辑", hint: "Windows cutvideo" },
{ key: "huobao", label: "AI 视频", hint: "huobao-drama" },
{ key: "n8n", label: "编排", hint: "n8n workflow" },
{ key: "asr", label: "ASR", hint: "转写服务" }
];
const overview = getIntegrationOverview();
return screenShell(
"自动流程",
"自动同步、日报生成和失败补跑先统一看这里。",
`${button("刷新", "refresh-data")} ${button("去生产", "goto-production", "primary")}`,
`${button("刷新", "refresh-data")} ${renderPipelineButton("aiVideo")} ${renderPipelineButton("realCut")} ${button("去生产", "goto-production", "primary")}`,
`
<div class="hero-card">
<h3>自动流程</h3>
<p>当前按真实任务量给出一版轻量看板后续再接更完整的定时与重试配置</p>
<p>当前按真实任务量和依赖健康状态给出看板自动流程受阻时会直接在这里拦住动作</p>
<div class="mini-grid">
<div class="mini-card"><small>分析任务</small><strong>${escapeHtml(formatNumber(analysisJobs))}</strong></div>
<div class="mini-card"><small>AI 视频</small><strong>${escapeHtml(formatNumber(aiVideoJobs))}</strong></div>
@@ -1424,25 +1782,27 @@ function renderAutomationScreen() {
<div class="mini-card"><small>内容源</small><strong>${escapeHtml(formatNumber(appState.contentSources.length))}</strong></div>
</div>
</div>
<div class="panel pad" style="margin-top:18px;">
<div class="panel-head"><div><h3>集成状态</h3><div class="panel-subtitle">线</div></div></div>
<div class="layout-grid grid-4" style="margin-top:14px;">
${integrationCards.map((item) => {
const detail = integrations[item.key] || {};
const tone = detail.reachable ? "green" : (detail.configured ? "red" : "orange");
const summary = detail.reachable ? "在线" : (detail.configured ? "不可达" : "未配置");
return `
<div class="queue-card">
<h4>${escapeHtml(item.label)}</h4>
<p>${escapeHtml(item.hint)}</p>
<div class="task-meta">
<span class="tag ${tone}">${escapeHtml(summary)}</span>
${detail.status_code ? `<span class="tag">HTTP ${escapeHtml(detail.status_code)}</span>` : ""}
</div>
</div>
`;
}).join("")}
<div style="margin-top:18px;">
${renderIntegrationOverviewPanel({ showActions: false })}
</div>
<div class="panel pad automation-guard-panel" style="margin-top:18px;">
<div class="panel-head">
<div>
<h3>动作防呆</h3>
<div class="panel-subtitle">依赖不可用时相关动作会在这里和生产页一起被拦住</div>
</div>
<span class="tag ${escapeHtml(overview.tone)}">${escapeHtml(overview.headline)}</span>
</div>
<div class="task-meta integration-highlights">
<span class="tag ${getPipelineGuard("aiVideo").enabled ? "green" : "red"}">AI 视频 ${escapeHtml(getPipelineGuard("aiVideo").enabled ? "可执行" : "已拦截")}</span>
<span class="tag ${getPipelineGuard("realCut").enabled ? "green" : "red"}">实拍剪辑 ${escapeHtml(getPipelineGuard("realCut").enabled ? "可执行" : "已拦截")}</span>
<span class="tag blue">ASR ${escapeHtml(getIntegrationStatus(getIntegrationDetail("asr")).summary)}</span>
</div>
<div class="integration-actions" style="margin-top:14px;">
${renderPipelineButton("aiVideo", "primary")}
${renderPipelineButton("realCut")}
</div>
<div class="integration-note" style="margin-top:12px;">${escapeHtml(overview.subtitle)}</div>
</div>
`
);
@@ -1484,6 +1844,9 @@ function renderPlaybookScreen() {
}
const assistants = safeArray(appState.dashboard.assistants);
const models = safeArray(appState.dashboard.model_profiles);
const currentModel = getCurrentModelProfile();
const localCatalog = appState.localModelCatalog || {};
const gatewayModels = safeArray(localCatalog.models).map((item) => item.id).filter(Boolean);
return screenShell(
"Agent",
"这里接真实 Agent 列表,后面再继续补创建和编辑动作。",
@@ -1496,6 +1859,25 @@ function renderPlaybookScreen() {
${models.slice(0, 6).map((model) => `<span class="chip ${model.is_default ? "active" : ""}">${escapeHtml(model.name)}</span>`).join("") || `<span class="chip active">暂无模型</span>`}
</div>
</div>
<div class="panel pad" style="margin-top:18px;">
<div class="panel-head">
<div>
<h3>本机模型网关</h3>
<div class="panel-subtitle">当前默认分析会优先走本机 cli-proxy-api</div>
</div>
<div class="task-meta">
<span class="tag ${escapeHtml(localCatalog.reachable ? "green" : "red")}">${escapeHtml(localCatalog.reachable ? "在线" : "离线")}</span>
${localCatalog.management_url ? `<a class="tag blue" href="${escapeHtml(localCatalog.management_url)}" target="_blank" rel="noreferrer">打开管理页</a>` : ""}
</div>
</div>
<div class="task-item compact">
<h4>${escapeHtml(currentModel?.name || localCatalog.default_model || "GLM-5")}</h4>
<p>${escapeHtml(currentModel ? `${currentModel.model_name || "-"} · ${currentModel.base_url || "-"}` : (localCatalog.public_base_url || localCatalog.base_url || "尚未读取到网关地址"))}</p>
<div class="task-meta">
${gatewayModels.slice(0, 6).map((model) => `<span class="tag">${escapeHtml(model)}</span>`).join("") || `<span class="tag red">暂无可见模型</span>`}
</div>
</div>
</div>
<div class="layout-grid grid-split" style="margin-top:18px;">
<div class="panel pad">
<div class="panel-head"><div><h3>模型列表</h3><div class="panel-subtitle"> model_profiles</div></div></div>
@@ -1563,7 +1945,7 @@ function renderProductionScreen() {
return screenShell(
"生产中心",
"这里已经接上真实任务和知识库文档,后续再继续补任务创建动作。",
`${button("AI 视频", "open-ai-video")} ${button("实拍剪辑", "open-real-cut")} ${button("去复盘", "goto-review", "primary")}`,
`${renderPipelineButton("aiVideo")} ${renderPipelineButton("realCut")} ${button("去复盘", "goto-review", "primary")}`,
`
<div class="panel pad">
<div class="panel-head"><div><h3>生产队列</h3><div class="panel-subtitle"></div></div></div>
@@ -1591,9 +1973,9 @@ function renderProductionScreen() {
<div class="task-meta">
<span class="tag ${statusTone(job.status)}">${escapeHtml(job.status)}</span>
<span class="tag">${escapeHtml(job.line_type || "analysis")}</span>
${canDeriveAiVideo(job) ? `<span class="tag clickable-tag" data-action="job-to-ai-video" data-job-id="${escapeHtml(job.id)}">做 AI 视频</span>` : ""}
${canDeriveRealCut(job) ? `<span class="tag clickable-tag" data-action="job-to-real-cut" data-job-id="${escapeHtml(job.id)}">做实拍剪辑</span>` : ""}
<span class="tag clickable-tag" data-action="open-job-detail" data-job-id="${escapeHtml(job.id)}">看详情</span>
${canDeriveAiVideo(job) ? renderPipelineJobTag("aiVideo", job, "做 AI 视频") : ""}
${canDeriveRealCut(job) ? renderPipelineJobTag("realCut", job, "做实拍剪辑") : ""}
${actionTag("看详情", "open-job-detail", `data-job-id="${escapeHtml(job.id)}"`)}
</div>
</div>
`).join("") || `<div class="task-item"><h4>还没有任务</h4><p>先去找对标导入内容。</p></div>`}
@@ -1674,10 +2056,10 @@ function renderReviewScreen() {
<div class="task-meta">
<span class="tag green">已完成</span>
<span class="tag">${escapeHtml(job.line_type || "analysis")}</span>
<span class="tag clickable-tag" data-action="open-review-from-job" data-job-id="${escapeHtml(job.id)}">写复盘</span>
${canDeriveAiVideo(job) ? `<span class="tag clickable-tag" data-action="job-to-ai-video" data-job-id="${escapeHtml(job.id)}">做 AI 视频</span>` : ""}
${canDeriveRealCut(job) ? `<span class="tag clickable-tag" data-action="job-to-real-cut" data-job-id="${escapeHtml(job.id)}">做实拍剪辑</span>` : ""}
<span class="tag clickable-tag" data-action="open-job-detail" data-job-id="${escapeHtml(job.id)}">看详情</span>
${actionTag("写复盘", "open-review-from-job", `data-job-id="${escapeHtml(job.id)}"`)}
${canDeriveAiVideo(job) ? renderPipelineJobTag("aiVideo", job, "做 AI 视频") : ""}
${canDeriveRealCut(job) ? renderPipelineJobTag("realCut", job, "做实拍剪辑") : ""}
${actionTag("看详情", "open-job-detail", `data-job-id="${escapeHtml(job.id)}"`)}
</div>
</div>
`).join("") || `<div class="review-card"><h4>还没有完成任务</h4><p>先去生产中心跑一条链路。</p></div>`}
@@ -1783,15 +2165,29 @@ async function createProject() {
function openPreferredModelAction() {
const models = getModelOptions();
const currentId = appState.me?.preferred_analysis_model_id
|| safeArray(appState.dashboard?.model_profiles).find((item) => item.is_default)?.id
|| models[0]?.value
|| "";
const currentProfile = getCurrentModelProfile();
const currentId = currentProfile?.id || models[0]?.value || "";
const localCatalog = appState.localModelCatalog || {};
const gatewayModels = safeArray(localCatalog.models).map((item) => item.id).filter(Boolean);
openActionModal({
title: "设置分析主模型",
description: "后续导入分析、市场调研和风格学习会优先使用这里设置的模型。",
submitLabel: "保存模型",
fields: [
{
type: "html",
label: "本机模型网关",
html: `
<div class="task-item compact">
<h4>${escapeHtml(localCatalog.reachable ? "网关在线" : "网关离线")}</h4>
<p>${escapeHtml(currentProfile ? `当前主模型:${currentProfile.name} · ${currentProfile.model_name || "-"}` : `默认模型:${localCatalog.default_model || "GLM-5"}`)}</p>
<div class="task-meta">
${gatewayModels.slice(0, 6).map((model) => `<span class="tag">${escapeHtml(model)}</span>`).join("") || `<span class="tag red">暂未读取到模型目录</span>`}
${localCatalog.management_url ? `<a class="tag blue" href="${escapeHtml(localCatalog.management_url)}" target="_blank" rel="noreferrer">打开管理页</a>` : ""}
</div>
</div>
`
},
{ name: "modelProfileId", label: "主模型", type: "select", value: currentId, options: models }
],
onSubmit: async (values) => {
@@ -1858,10 +2254,10 @@ function renderLastJobDetailCard() {
<p>${escapeHtml(brief(detail.job.style_summary || detail.job.transcript_text || detail.job.error || "暂无摘要", 120))}</p>
<div class="task-meta">
<span class="tag">${escapeHtml(detail.job.line_type || "-")}</span>
${detail.job.status === "completed" ? `<span class="tag clickable-tag" data-action="open-review-from-job" data-job-id="${escapeHtml(detail.job.id)}">写复盘</span>` : ""}
${canDeriveAiVideo(detail.job) ? `<span class="tag clickable-tag" data-action="job-to-ai-video" data-job-id="${escapeHtml(detail.job.id)}">做 AI 视频</span>` : ""}
${canDeriveRealCut(detail.job) ? `<span class="tag clickable-tag" data-action="job-to-real-cut" data-job-id="${escapeHtml(detail.job.id)}">做实拍剪辑</span>` : ""}
<span class="tag clickable-tag" data-action="open-job-detail" data-job-id="${escapeHtml(detail.job.id)}">看详情</span>
${detail.job.status === "completed" ? actionTag("写复盘", "open-review-from-job", `data-job-id="${escapeHtml(detail.job.id)}"`) : ""}
${canDeriveAiVideo(detail.job) ? renderPipelineJobTag("aiVideo", detail.job, "做 AI 视频") : ""}
${canDeriveRealCut(detail.job) ? renderPipelineJobTag("realCut", detail.job, "做实拍剪辑") : ""}
${actionTag("看详情", "open-job-detail", `data-job-id="${escapeHtml(detail.job.id)}"`)}
</div>
</div>
${previewLinks.length ? `
@@ -2405,9 +2801,9 @@ function openJobDetailAction(jobId) {
label: "下一步动作",
html: `
<div class="task-meta">
${canDeriveAiVideo(job) ? `<span class="tag clickable-tag" data-action="job-to-ai-video" data-job-id="${escapeHtml(job.id)}">继续做 AI 视频</span>` : ""}
${canDeriveRealCut(job) ? `<span class="tag clickable-tag" data-action="job-to-real-cut" data-job-id="${escapeHtml(job.id)}">继续做实拍剪辑</span>` : ""}
<span class="tag clickable-tag" data-action="job-to-generate-copy" data-job-id="${escapeHtml(job.id)}">用摘要写文案</span>
${canDeriveAiVideo(job) ? renderPipelineJobTag("aiVideo", job, "继续做 AI 视频") : ""}
${canDeriveRealCut(job) ? renderPipelineJobTag("realCut", job, "继续做实拍剪辑") : ""}
${actionTag("用摘要写文案", "job-to-generate-copy", `data-job-id="${escapeHtml(job.id)}"`)}
</div>
`
},
@@ -2489,6 +2885,11 @@ function openGenerateCopyAction(defaults = {}) {
}
function openCreateAiVideoAction(defaults = {}) {
const guard = getPipelineGuard("aiVideo");
if (!guard.enabled) {
alert(guard.reason);
return;
}
const project = requireSelectedProject();
const assistant = getSelectedAssistant();
const kb = getProjectKnowledgeBases(project.id)[0];
@@ -2529,6 +2930,11 @@ function openCreateAiVideoAction(defaults = {}) {
}
function openCreateRealCutAction(defaults = {}) {
const guard = getPipelineGuard("realCut");
if (!guard.enabled) {
alert(guard.reason);
return;
}
const project = requireSelectedProject();
const sourceJob = defaults.sourceJob || null;
openActionModal({
@@ -2670,10 +3076,27 @@ document.addEventListener("click", async (event) => {
// button and pressing Enter share the same code path.
return;
}
if (name === "show-disabled-reason") {
const reason = action.dataset.disabledReason || action.title || "当前动作暂不可用";
rememberAction("动作已拦截", reason, "orange");
renderAll();
alert(reason);
return;
}
if (name === "auth-refresh" || name === "refresh-data") {
await bootstrap();
return;
}
if (name === "refresh-tracking") {
await refreshTrackingAccountsAction();
return;
}
if (name === "mark-tracking-read") {
await markTrackingDigestRead();
rememberAction("日报已标记", "当前跟踪摘要已更新为已读,下次会从新的时间点继续汇总。", "green");
await bootstrap();
return;
}
if (name === "logout-session") {
await logoutSession();
return;
@@ -2706,6 +3129,10 @@ document.addEventListener("click", async (event) => {
openTrackSelectedAccountAction();
return;
}
if (name === "refresh-tracked-account") {
await refreshTrackedAccountAction(action.dataset.trackedAccountId || "");
return;
}
if (name === "open-import-video-link") {
openImportVideoLinkAction();
return;

View File

@@ -503,6 +503,28 @@ select {
transform: translateY(-1px);
}
.btn.is-disabled {
cursor: not-allowed;
opacity: 0.62;
box-shadow: none;
transform: none;
background: linear-gradient(180deg, #f4f7fb 0%, #e9eff7 100%);
color: var(--muted);
border: 1px solid var(--line);
}
.btn.is-disabled:hover {
transform: none;
}
.btn.is-disabled,
.btn[aria-disabled="true"] {
cursor: not-allowed;
opacity: 0.68;
transform: none;
box-shadow: none;
}
.layout-grid {
display: grid;
gap: 18px;
@@ -676,10 +698,153 @@ select {
color: #b24c4c;
}
.tag-disabled {
cursor: not-allowed;
opacity: 0.72;
background: #f3f6fa;
border-color: var(--line-strong);
color: var(--muted);
}
.clickable-tag {
cursor: pointer;
}
.integration-panel {
display: grid;
gap: 14px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, rgba(245, 249, 255, 0.96) 100%);
}
.integration-panel-compact .integration-grid {
margin-top: 12px;
}
.integration-summary {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 16px 18px;
border-radius: 18px;
border: 1px solid var(--line);
background: linear-gradient(180deg, #fbfdff 0%, #f5f9ff 100%);
}
.integration-summary strong {
display: block;
font-size: 16px;
}
.integration-summary p {
margin: 8px 0 0;
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.integration-summary.green {
border-color: rgba(45, 181, 132, 0.2);
background: linear-gradient(180deg, rgba(45, 181, 132, 0.12) 0%, rgba(255, 255, 255, 0.96) 100%);
}
.integration-summary.orange {
border-color: rgba(242, 154, 56, 0.24);
background: linear-gradient(180deg, rgba(242, 154, 56, 0.13) 0%, rgba(255, 255, 255, 0.96) 100%);
}
.integration-summary.red {
border-color: rgba(228, 103, 103, 0.24);
background: linear-gradient(180deg, rgba(228, 103, 103, 0.13) 0%, rgba(255, 255, 255, 0.97) 100%);
}
.integration-summary.blue {
border-color: rgba(79, 143, 238, 0.18);
background: linear-gradient(180deg, rgba(79, 143, 238, 0.11) 0%, rgba(255, 255, 255, 0.97) 100%);
}
.integration-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: flex-end;
}
.integration-highlights {
margin-top: 12px;
}
.integration-grid {
gap: 14px;
align-items: stretch;
}
.integration-card {
padding: 15px;
border-radius: 18px;
border: 1px solid var(--line);
background: linear-gradient(180deg, #fff 0%, #f8fbff 100%);
box-shadow: var(--shadow-soft);
}
.integration-card.green {
border-color: rgba(45, 181, 132, 0.18);
}
.integration-card.orange {
border-color: rgba(242, 154, 56, 0.18);
}
.integration-card.red {
border-color: rgba(228, 103, 103, 0.2);
}
.integration-card.blue {
border-color: rgba(79, 143, 238, 0.18);
}
.integration-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.integration-card-head h4 {
margin: 0 0 4px;
}
.integration-card-head p {
margin: 0;
color: var(--muted);
font-size: 11px;
}
.integration-note {
margin-top: 12px;
color: var(--text);
font-size: 12px;
line-height: 1.5;
}
.integration-url {
margin-top: 10px;
padding: 9px 11px;
border-radius: 12px;
background: var(--blue-50);
border: 1px solid rgba(106, 164, 255, 0.14);
color: var(--muted);
font-size: 11px;
line-height: 1.45;
word-break: break-all;
}
.automation-guard-panel {
display: grid;
gap: 12px;
}
.mobile-only {
display: none;
}
@@ -1300,6 +1465,20 @@ tbody tr:hover {
min-width: 0;
}
.integration-summary {
flex-direction: column;
align-items: flex-start;
}
.integration-actions {
width: 100%;
}
.integration-actions .btn {
flex: 1 1 calc(50% - 10px);
min-width: 0;
}
.grid-4,
.grid-5,
.mini-grid,
@@ -1328,6 +1507,20 @@ tbody tr:hover {
padding: 14px;
}
.integration-summary {
flex-direction: column;
}
.integration-actions {
width: 100%;
justify-content: stretch;
}
.integration-actions .btn {
flex: 1 1 calc(50% - 5px);
min-width: 0;
}
.task-meta,
.entity-meta,
.row-meta,
@@ -1527,6 +1720,10 @@ tbody tr:hover {
text-align: center;
}
.integration-card-head {
flex-direction: column;
}
.task-item.compact,
.review-card.compact {
padding: 12px;