feat: add metadata console and seo ai rule pages

This commit is contained in:
root
2026-04-18 20:23:30 +08:00
parent 845d68110b
commit 2d1834422b
14 changed files with 1769 additions and 50 deletions

View File

@@ -2,6 +2,7 @@ export const loginApiUrl = "/guest/login";
export const getSystemConfigListApiUrl = "/system/config/list";
export const getSystemConfigSaveApiUrl = "/system/config/save";
export const getSystemConfigSeoAiRulesResetApiUrl = "/system/config/seo-ai-rules/reset";
export const getSystemPlanListApiUrl = "/system/plan/list";
export const getSystemPlanSaveApiUrl = "/system/plan/save";

View File

@@ -924,7 +924,15 @@ export namespace SiteList {
skipped_template_missing_count?: number;
skipped_duplicate_in_file_count?: number;
strategy_profile_counts?: Record<string, number>;
inserted_domains?: Array<{ d_id?: number; d_domain?: string; strategy_profile?: SeoStrategyProfile }>;
inserted_domains?: Array<{
d_id?: number;
d_domain?: string;
d_name?: string;
t_id?: number;
strategy_profile?: SeoStrategyProfile;
tkd_provider?: string;
tkd_mode?: string;
}>;
skipped_domains?: Array<{ d_domain?: string; reason?: string; t_id?: number }>;
run_root?: string;
uploaded_excel_path?: string;
@@ -934,6 +942,67 @@ export namespace SiteList {
probe_failed_count?: number;
summary_json_path?: string;
summary_html_path?: string;
next_step_notice?: string[];
codex_continue_prompt?: string;
post_import_workflow?: {
enabled?: boolean;
auto_ai_after_import?: boolean;
summary?: string;
inserted_domain_count?: number;
inserted_domain_hosts?: string[];
contains_openai_domains?: boolean;
artifact_audit?: {
summary?: {
total?: number;
state_ready_count?: number;
manifest_ready_count?: number;
positioning_ready_count?: number;
published_copy_ready_count?: number;
sitemap_generated_count?: number;
ai_pending_count?: number;
};
items?: Array<{
host?: string;
site_name?: string;
state_exists?: boolean;
manifest_exists?: boolean;
site_positioning_exists?: boolean;
published_copy_exists?: boolean;
ai_status?: string;
wait_for_ai_before_publish?: boolean;
sitemap_status?: "generated" | "queued_pending" | string;
existing_sitemaps?: string[];
notes?: string[];
}>;
};
recommended_for_operator?: "openai_continue" | "codex_continue";
recommended_for_technical?: "openai_continue" | "codex_continue";
notices?: string[];
choices?: Array<{
key?: "openai_continue" | "codex_continue";
label?: string;
audience?: string;
description?: string;
}>;
openai?: {
domain_ids?: number[];
domain_id_csv?: string;
hosts?: string[];
probe_status?: "passed" | "partial" | "failed" | "skipped" | string;
probe_summary_html_path?: string;
probe_summary_json_path?: string;
operator_steps?: string[];
};
codex?: {
workspace_path?: string;
open_in_vscode_hint?: string;
prompt?: string;
};
ui_suggestion?: {
message?: string;
button_labels?: string[];
};
};
}
export interface DomainImportRunSummaryItem {

View File

@@ -9,6 +9,9 @@ export namespace ConfigList {
sc_val: string;
sc_description: string;
}
export interface resetSeoAiRuleParams {
sc_code?: string;
}
export interface delParams {
sc_id: string;
}
@@ -18,5 +21,8 @@ export namespace ConfigList {
sc_val: string;
sc_description: string;
}
export interface SeoAiRuleCard extends ResList {
note?: Partial<ResList> | null;
}
export interface ResData extends ResultData {}
}

View File

@@ -78,4 +78,125 @@ export namespace VideoList {
tag_list?: any;
}
export interface ResData extends ResultData {}
export interface MetadataMissingTaskPoolPlanTaskStatus {
exists?: boolean;
enabled?: boolean;
limit_seconds?: number;
limit_human?: string;
status_label?: string;
db_ready?: boolean;
db_error?: string;
pt_id?: number;
pt_name?: string;
pt_code?: string;
last_run_at?: string;
}
export interface MetadataMissingTaskPoolStatusData {
generated_at?: string;
recommended_action?: string;
operator_hint?: string[];
workbench?: {
generated_at?: string;
videos_with_any_missing_metadata?: number;
queue_size?: number;
workbench_index_path?: string;
latest_run?: Record<string, any>;
};
task_pool?: {
generated_at?: string;
batch_count?: number;
pending_count?: number;
processing_count?: number;
done_count?: number;
skipped_count?: number;
task_pool_index_path?: string;
};
plan_task?: MetadataMissingTaskPoolPlanTaskStatus;
}
export interface MetadataMissingTaskPoolOpsData {
summary?: MetadataMissingTaskPoolStatusData;
ops?: {
generated_at?: string;
recommended_action?: string;
command_entries?: Array<{
label?: string;
command?: string;
}>;
artifact_entries?: Array<{
label?: string;
path?: string;
}>;
};
}
export interface MetadataMissingWorkbenchSummaryData {
generated_at?: string;
sample_limit?: number;
queue_limit?: number;
prompt_limit?: number;
run_id?: string;
top_missing_fields?: Array<{
field?: string;
missing_count?: number;
missing_ratio?: number;
}>;
next_actions?: Array<{
key?: string;
label?: string;
summary?: string;
}>;
operator_notes?: Array<{
title?: string;
content?: string;
}>;
queue?: {
queue_size?: number;
items?: Array<Record<string, any>>;
};
audit?: {
total_videos?: number;
videos_with_any_missing_metadata?: number;
field_stats?: Array<Record<string, any>>;
};
codex_dispatch_prompt?: string;
}
export interface MetadataMissingTaskPoolData {
generated_at?: string;
source_generated_at?: string;
batch_size?: number;
batch_limit?: number;
total_videos?: number;
videos_with_any_missing_metadata?: number;
queue_size?: number;
operator_hint?: string;
top_missing_fields?: Array<{
field?: string;
missing_count?: number;
missing_ratio?: number;
}>;
todo_summary?: Array<{
batch_id?: string;
label?: string;
focus_field?: string;
items_count?: number;
priority_level?: string;
}>;
batches?: Array<{
batch_id?: string;
batch_no?: number;
batch_label?: string;
focus_field?: string;
priority_level?: string;
items_count?: number;
status_label?: string;
owner?: string;
history_count?: number;
codex_prompt?: string;
items?: Array<Record<string, any>>;
}>;
}
}

View File

@@ -1,6 +1,6 @@
import { ResPage, ResultData } from "@/api/interface/index";
import { ConfigList } from "@/api/interface/system/config";
import { getSystemConfigListApiUrl, getSystemConfigSaveApiUrl } from "@/api/config";
import { getSystemConfigListApiUrl, getSystemConfigSaveApiUrl, getSystemConfigSeoAiRulesResetApiUrl } from "@/api/config";
import http from "@/api";
@@ -12,3 +12,7 @@ export const getConfigList = (params: ConfigList.ReqParams) => {
export const SaveApi = (params: ConfigList.saveParams) => {
return http.post<ResPage<ResultData>>(getSystemConfigSaveApiUrl, params);
};
export const resetSeoAiRulesApi = (params: ConfigList.resetSeoAiRuleParams) => {
return http.post<ResPage<ResultData>>(getSystemConfigSeoAiRulesResetApiUrl, params);
};

View File

@@ -19,3 +19,27 @@ export const batchData = (params: VideoList.delParams) => {
export const batchVipData = (params: VideoList.delParams) => {
return http.post<ResPage<ResultData>>(getVideoSetLevelApiUrl, params);
};
export const getMetadataMissingTaskPoolStatus = () => {
return http.get<VideoList.MetadataMissingTaskPoolStatusData>(`/video/metadata/missing/task-pool/status`);
};
export const getMetadataMissingTaskPoolOps = () => {
return http.get<VideoList.MetadataMissingTaskPoolOpsData>(`/video/metadata/missing/task-pool/ops`);
};
export const getMetadataMissingWorkbenchSummary = () => {
return http.get<VideoList.MetadataMissingWorkbenchSummaryData>(`/video/metadata/missing/workbench`);
};
export const getMetadataMissingTaskPoolSummary = () => {
return http.get<VideoList.MetadataMissingTaskPoolData>(`/video/metadata/missing/task-pool`);
};
export const runMetadataMissingWorkbench = () => {
return http.post<ResultData>(`/video/metadata/missing/workbench/run`, {});
};
export const runMetadataMissingTaskPool = () => {
return http.post<ResultData>(`/video/metadata/missing/task-pool/run`, {});
};

View File

@@ -119,26 +119,41 @@
"isKeepAlive": true
},
"children": [
{
"path": "/system/config/index",
"name": "systemConfigIndex",
"component": "/system/config/index",
"meta": {
{
"path": "/system/config/index",
"name": "systemConfigIndex",
"component": "/system/config/index",
"meta": {
"an_id": 1201,
"icon": "Histogram",
"title": "配置列表",
"isLink": "",
"isHide": false,
"isFull": false,
"isAffix": false,
"isKeepAlive": true
}
},
{
"path": "/resource/endpoint/list",
"name": "endpointList",
"component": "/resource/endpoint/index",
"meta": {
"isAffix": false,
"isKeepAlive": true
}
},
{
"path": "/system/config/seo-ai-rules",
"name": "systemConfigSeoAiRules",
"component": "/system/config/seoAiRules",
"meta": {
"an_id": 1202,
"icon": "MagicStick",
"title": "SEO AI规则",
"isLink": "",
"isHide": false,
"isFull": false,
"isAffix": false,
"isKeepAlive": true
}
},
{
"path": "/resource/endpoint/list",
"name": "endpointList",
"component": "/resource/endpoint/index",
"meta": {
"an_id": 2504,
"icon": "Operation",
"title": "端点资源列表",
@@ -443,25 +458,85 @@
}
},
{
"path": "/video/index",
"name": "Video",
"component": "/video/index",
"meta": {
{
"path": "/video/index",
"name": "Video",
"component": "/video/index",
"meta": {
"an_id": 2101,
"icon": "VideoCamera",
"title": "视频列表",
"isLink": "",
"isHide": false,
"isFull": false,
"isAffix": false,
"isKeepAlive": true
}
},
{
"path": "/searchGroup/index",
"name": "searchGroupIndex",
"isAffix": false,
"isKeepAlive": true
}
},
{
"path": "/video/metadata/missing/workbench",
"name": "videoMetadataMissingWorkbench",
"component": "/video/metadataMissing/index",
"meta": {
"an_id": 2110,
"icon": "DataAnalysis",
"title": "缺字段工作台",
"isLink": "",
"isHide": false,
"isFull": false,
"isAffix": false,
"isKeepAlive": true
}
},
{
"path": "/video/metadata/missing/task-pool",
"name": "videoMetadataMissingTaskPool",
"component": "/video/metadataMissing/index",
"meta": {
"an_id": 2111,
"icon": "Tickets",
"title": "缺字段任务池",
"isLink": "",
"isHide": false,
"isFull": false,
"isAffix": false,
"isKeepAlive": true
}
},
{
"path": "/video/metadata/missing/task-pool/status",
"name": "videoMetadataMissingTaskPoolStatus",
"component": "/video/metadataMissing/index",
"meta": {
"an_id": 2113,
"icon": "DataLine",
"title": "缺字段状态总览",
"isLink": "",
"isHide": true,
"isFull": false,
"isAffix": false,
"isKeepAlive": true
}
},
{
"path": "/video/metadata/missing/task-pool/ops",
"name": "videoMetadataMissingTaskPoolOps",
"component": "/video/metadataMissing/index",
"meta": {
"an_id": 2114,
"icon": "Tools",
"title": "缺字段运维总览",
"isLink": "",
"isHide": true,
"isFull": false,
"isAffix": false,
"isKeepAlive": true
}
},
{
"path": "/searchGroup/index",
"name": "searchGroupIndex",
"component": "/searchGroup/index",
"meta": {
"an_id": 2109,

View File

@@ -6,3 +6,101 @@
width: 100%;
gap: 20px;
}
.home-metadata-console {
border: 1px solid #e5e7eb;
border-radius: 20px;
background: linear-gradient(135deg, #fffdf4 0%, #ffffff 58%, #eef6ff 100%);
&__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
&__title {
font-size: 22px;
font-weight: 800;
line-height: 1.2;
color: #111827;
}
&__subtitle {
margin-top: 8px;
color: #6b7280;
line-height: 1.7;
}
&__actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
&__stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
margin-top: 18px;
}
&__hint {
margin-top: 16px;
padding: 14px 16px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid #e5e7eb;
line-height: 1.8;
color: #374151;
}
&__links {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
margin-top: 12px;
}
}
.home-metadata-stat {
padding: 16px 18px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid #e5e7eb;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.04);
&__label {
font-size: 13px;
color: #6b7280;
}
&__value {
margin-top: 8px;
font-size: 30px;
font-weight: 800;
line-height: 1.1;
color: #111827;
}
&__tip {
margin-top: 10px;
color: #6b7280;
line-height: 1.7;
font-size: 13px;
}
}
.home-bootstrap-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin: 0 0 16px;
}
@media (max-width: 960px) {
.home-metadata-console__stats {
grid-template-columns: 1fr;
}
}

View File

@@ -1,5 +1,60 @@
<template>
<div class="home card">
<el-card shadow="never" class="home-metadata-console">
<template #header>
<div class="home-metadata-console__header">
<div>
<div class="home-metadata-console__title">视频缺字段控制台</div>
<div class="home-metadata-console__subtitle">后台打开先看这里工作台任务池和计划任务状态都汇总到首屏</div>
</div>
<div class="home-metadata-console__actions">
<el-button :loading="metadataLoading" @click="loadMetadataConsole">刷新状态</el-button>
<el-button type="primary" :loading="workbenchRunning" @click="handleRunWorkbench">刷新工作台</el-button>
<el-button type="warning" plain :loading="taskPoolRunning" @click="handleRunTaskPool">刷新任务池</el-button>
</div>
</div>
</template>
<el-alert
:type="metadataAlertType"
:closable="false"
show-icon
:title="metadataAlertTitle"
:description="metadataAlertDescription"
/>
<div class="home-metadata-console__stats">
<div class="home-metadata-stat">
<div class="home-metadata-stat__label">缺字段视频总量</div>
<div class="home-metadata-stat__value">{{ metadataSummary.workbench?.videos_with_any_missing_metadata || 0 }}</div>
<div class="home-metadata-stat__tip">越大越需要先看工作台再按批次派给 Codex</div>
</div>
<div class="home-metadata-stat">
<div class="home-metadata-stat__label">重采优先队列</div>
<div class="home-metadata-stat__value">{{ metadataSummary.workbench?.queue_size || 0 }}</div>
<div class="home-metadata-stat__tip">这是最适合优先处理的一批适合先做首轮修复</div>
</div>
<div class="home-metadata-stat">
<div class="home-metadata-stat__label">任务池批次数</div>
<div class="home-metadata-stat__value">{{ metadataSummary.task_pool?.batch_count || 0 }}</div>
<div class="home-metadata-stat__tip">批次越清晰后续技术和 Codex 的协作越顺</div>
</div>
</div>
<div class="home-metadata-console__hint">
<strong>当前建议</strong>{{ metadataSummary.recommended_action || "先刷新状态,再看工作台和任务池。" }}
<br />
<strong>首条提示</strong>{{ metadataFirstHint }}
</div>
<div class="home-metadata-console__links">
<el-button text type="primary" @click="openMetadataRoute('/video/metadata/missing/task-pool/status')">打开状态总览</el-button>
<el-button text type="primary" @click="openMetadataRoute('/video/metadata/missing/task-pool/ops')">打开运维总览</el-button>
<el-button text type="primary" @click="openMetadataRoute('/video/metadata/missing/task-pool')">打开任务池</el-button>
<el-button text type="primary" @click="openMetadataRoute('/video/metadata/missing/workbench')">打开工作台</el-button>
</div>
</el-card>
<div class="home-bootstrap-actions">
<el-button type="primary" @click="openBootstrapCenter('overview')">打开 Bootstrap 总控台</el-button>
<el-button type="warning" plain @click="openBootstrapCenter({ tab: 'ops', opsFocus: { panel: 'oncall', stage: 'all', clearHostFocus: true, hostConsolePanel: 'oncall' } })">值班待办</el-button>
@@ -13,14 +68,25 @@
</template>
<script setup lang="ts" name="home">
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import BootstrapOpsOverview from "@/views/site/bootstrapOpsOverview.vue";
import BootstrapEnvOverview from "@/views/site/bootstrapEnvOverview.vue";
import BootstrapAdminCenter from "@/views/site/bootstrapAdminCenter.vue";
import { ref } from "vue";
import {
getMetadataMissingTaskPoolStatus,
runMetadataMissingTaskPool,
runMetadataMissingWorkbench
} from "@/api/modules/video/list";
import type { VideoList } from "@/api/interface/video/list";
const router = useRouter();
const bootstrapAdminCenterRef = ref<InstanceType<typeof BootstrapAdminCenter> | null>(null);
const metadataLoading = ref(false);
const workbenchRunning = ref(false);
const taskPoolRunning = ref(false);
const metadataSummary = ref<VideoList.MetadataMissingTaskPoolStatusData>({});
type BootstrapFocusMode = "all" | "task" | "oncall" | "pipeline" | "import" | "release";
type BootstrapFocusPayload = BootstrapFocusMode | BootstrapFocusMode[];
type BootstrapComparePreset =
@@ -77,6 +143,69 @@ const openBootstrapCenter = (payload: BootstrapAdminOpenPayload = "overview") =>
bootstrapAdminCenterRef.value?.openDialog(payload);
};
const loadMetadataConsole = async () => {
metadataLoading.value = true;
try {
const response = await getMetadataMissingTaskPoolStatus();
metadataSummary.value = response.data || {};
} finally {
metadataLoading.value = false;
}
};
const handleRunWorkbench = async () => {
workbenchRunning.value = true;
try {
const response = await runMetadataMissingWorkbench();
ElMessage.success(response.msg || "工作台刷新已触发");
await loadMetadataConsole();
} finally {
workbenchRunning.value = false;
}
};
const handleRunTaskPool = async () => {
taskPoolRunning.value = true;
try {
const response = await runMetadataMissingTaskPool();
ElMessage.success(response.msg || "任务池刷新已触发");
await loadMetadataConsole();
} finally {
taskPoolRunning.value = false;
}
};
const openMetadataRoute = (path: string) => {
router.push(path);
};
const metadataFirstHint = computed(() => metadataSummary.value.operator_hint?.[0] || "当前还没有额外提示,建议先看状态总览。");
const metadataAlertType = computed(() => {
const status = metadataSummary.value.plan_task?.status_label || "missing";
if (status === "enabled") return "success";
if (status === "disabled") return "warning";
if (status === "db_blocked") return "error";
return "info";
});
const metadataAlertTitle = computed(() => {
const status = metadataSummary.value.plan_task?.status_label || "missing";
if (status === "enabled") return "视频缺字段计划任务已启用";
if (status === "disabled") return "视频缺字段计划任务已存在但未启用";
if (status === "db_blocked") return "当前环境暂时无法读取视频缺字段计划任务";
return "视频缺字段计划任务尚未补齐";
});
const metadataAlertDescription = computed(() => {
const status = metadataSummary.value.plan_task?.status_label || "missing";
const action = metadataSummary.value.recommended_action || "先刷新状态,再决定下一步。";
if (status === "enabled") return `当前工作台和任务池已经可持续刷新。${action}`;
if (status === "disabled") return `当前后台已经有这条计划任务,但还没有启用。${action}`;
if (status === "db_blocked") return `这通常说明当前后台环境没读到完整数据库上下文。${action}`;
return `当前还没补齐这条计划任务,建议先补齐再观察。${action}`;
});
const goSitePage = (payload: BootstrapDialogPayload = "all") => {
const normalizedFocus = typeof payload === "string" || Array.isArray(payload) ? payload : payload.focusMode ?? "all";
const comparePreset = typeof payload === "string" || Array.isArray(payload) ? undefined : payload.comparePreset;
@@ -93,6 +222,10 @@ const goSitePage = (payload: BootstrapDialogPayload = "all") => {
}
});
};
onMounted(() => {
loadMetadataConsole();
});
</script>
<style scoped lang="scss">

View File

@@ -113,6 +113,193 @@
<BootstrapEnvDialog v-if="dialogMount.bootstrapEnvDialog" ref="bootstrapEnvDialogRef" />
<DomainSupplyDialog v-if="dialogMount.domainSupplyDialog" ref="domainSupplyDialogRef" />
<DomainImportTaskConsoleDialog ref="domainImportTaskConsoleDialogRef" />
<el-dialog
v-model="postImportWorkflowDialog.visible"
title="导入完成,选择下一步"
width="760px"
draggable
destroy-on-close
>
<div class="post-import-workflow" v-if="postImportWorkflowDialog.payload">
<el-alert
:title="postImportWorkflowDialog.payload.post_import_workflow?.summary || '本批域名已导入完成,请选择后续推进方式'"
:type="postImportWorkflowDialog.payload.probe_failed_count ? 'warning' : 'success'"
:closable="false"
show-icon
/>
<div class="post-import-workflow__metrics">
<el-tag type="success">新增 {{ postImportWorkflowDialog.payload.inserted_count || 0 }} </el-tag>
<el-tag type="info">探测 {{ postImportWorkflowDialog.payload.probe_processed_count || 0 }} </el-tag>
<el-tag :type="(postImportWorkflowDialog.payload.probe_failed_count || 0) > 0 ? 'danger' : 'success'">
失败 {{ postImportWorkflowDialog.payload.probe_failed_count || 0 }}
</el-tag>
</div>
<div
v-if="(postImportWorkflowDialog.payload.next_step_notice || []).length"
class="post-import-workflow__section"
>
<div class="post-import-workflow__title">下一步提醒</div>
<ul class="post-import-workflow__list">
<li v-for="(item, index) in postImportWorkflowDialog.payload.next_step_notice" :key="`notice-${index}`">
{{ item }}
</li>
</ul>
</div>
<div class="post-import-workflow__section">
<div class="post-import-workflow__grid">
<div class="post-import-workflow__card">
<div class="post-import-workflow__title">技术继续Codex</div>
<div class="post-import-workflow__desc">
{{
postImportWorkflowDialog.payload.post_import_workflow?.choices?.find(item => item.key === "codex_continue")
?.description || "复制提示词给 Codex让 Codex 接手这批新站的 SEO 收口。"
}}
</div>
<div class="post-import-workflow__hint">
{{ postImportWorkflowDialog.payload.post_import_workflow?.codex?.open_in_vscode_hint || "先打开 VS Code 工作区,再把提示词发给 Codex" }}
</div>
<div class="post-import-workflow__path" v-if="postImportWorkflowDialog.payload.post_import_workflow?.codex?.workspace_path">
工作区{{ postImportWorkflowDialog.payload.post_import_workflow?.codex?.workspace_path }}
</div>
<div class="post-import-workflow__actions">
<el-button type="primary" @click="copyPostImportCodexPrompt">复制Codex提示词</el-button>
</div>
</div>
<div class="post-import-workflow__card">
<div class="post-import-workflow__title">运营继续OpenAI</div>
<div class="post-import-workflow__desc">
{{
postImportWorkflowDialog.payload.post_import_workflow?.choices?.find(item => item.key === "openai_continue")
?.description || "先确认探测结果,再显式提交后台 AI 生成队列。"
}}
</div>
<ul
v-if="(postImportWorkflowDialog.payload.post_import_workflow?.openai?.operator_steps || []).length"
class="post-import-workflow__list"
>
<li
v-for="(item, index) in postImportWorkflowDialog.payload.post_import_workflow?.openai?.operator_steps || []"
:key="`operator-${index}`"
>
{{ item }}
</li>
</ul>
<div class="post-import-workflow__actions">
<el-button type="success" :loading="postImportWorkflowDialog.submittingOpenAi" @click="continuePostImportWithOpenAi">
OpenAI继续
</el-button>
</div>
</div>
</div>
</div>
<div class="post-import-workflow__section">
<div class="post-import-workflow__title">探测与导入产物</div>
<div class="post-import-workflow__actions">
<el-button plain :disabled="!postImportWorkflowDialog.payload.summary_html_path" @click="openPublicPath(postImportWorkflowDialog.payload.summary_html_path)">
打开探测HTML摘要
</el-button>
<el-button plain :disabled="!postImportWorkflowDialog.payload.summary_json_path" @click="openPublicPath(postImportWorkflowDialog.payload.summary_json_path)">
打开探测JSON摘要
</el-button>
<el-button plain :disabled="!postImportWorkflowDialog.payload.uploaded_excel_path" @click="openPublicPath(postImportWorkflowDialog.payload.uploaded_excel_path)">
打开原始Excel
</el-button>
</div>
</div>
<div
v-if="(postImportWorkflowDialog.payload.post_import_workflow?.artifact_audit?.items || []).length"
class="post-import-workflow__section"
>
<div class="post-import-workflow__title">自动产物审计</div>
<div class="post-import-workflow__metrics">
<el-tag type="success">
state {{ postImportWorkflowDialog.payload.post_import_workflow?.artifact_audit?.summary?.state_ready_count || 0 }}/{{ postImportWorkflowDialog.payload.post_import_workflow?.artifact_audit?.summary?.total || 0 }}
</el-tag>
<el-tag type="success">
manifest {{ postImportWorkflowDialog.payload.post_import_workflow?.artifact_audit?.summary?.manifest_ready_count || 0 }}/{{ postImportWorkflowDialog.payload.post_import_workflow?.artifact_audit?.summary?.total || 0 }}
</el-tag>
<el-tag type="success">
positioning {{ postImportWorkflowDialog.payload.post_import_workflow?.artifact_audit?.summary?.positioning_ready_count || 0 }}/{{ postImportWorkflowDialog.payload.post_import_workflow?.artifact_audit?.summary?.total || 0 }}
</el-tag>
<el-tag type="success">
copy {{ postImportWorkflowDialog.payload.post_import_workflow?.artifact_audit?.summary?.published_copy_ready_count || 0 }}/{{ postImportWorkflowDialog.payload.post_import_workflow?.artifact_audit?.summary?.total || 0 }}
</el-tag>
<el-tag :type="(postImportWorkflowDialog.payload.post_import_workflow?.artifact_audit?.summary?.sitemap_generated_count || 0) > 0 ? 'success' : 'warning'">
sitemap {{ postImportWorkflowDialog.payload.post_import_workflow?.artifact_audit?.summary?.sitemap_generated_count || 0 }}/{{ postImportWorkflowDialog.payload.post_import_workflow?.artifact_audit?.summary?.total || 0 }}
</el-tag>
<el-tag type="warning">
ai_pending {{ postImportWorkflowDialog.payload.post_import_workflow?.artifact_audit?.summary?.ai_pending_count || 0 }}
</el-tag>
</div>
<el-table
:data="postImportWorkflowDialog.payload.post_import_workflow?.artifact_audit?.items || []"
size="small"
border
>
<el-table-column prop="host" label="域名" min-width="180" />
<el-table-column prop="site_name" label="站点名" min-width="140" />
<el-table-column label="state" width="82">
<template #default="{ row }">
<el-tag :type="row.state_exists ? 'success' : 'danger'">{{ row.state_exists ? "已生成" : "缺失" }}</el-tag>
</template>
</el-table-column>
<el-table-column label="manifest" width="90">
<template #default="{ row }">
<el-tag :type="row.manifest_exists ? 'success' : 'danger'">{{ row.manifest_exists ? "已生成" : "缺失" }}</el-tag>
</template>
</el-table-column>
<el-table-column label="定位资源" width="96">
<template #default="{ row }">
<el-tag :type="row.site_positioning_exists ? 'success' : 'danger'">{{ row.site_positioning_exists ? "已生成" : "缺失" }}</el-tag>
</template>
</el-table-column>
<el-table-column label="starter copy" width="96">
<template #default="{ row }">
<el-tag :type="row.published_copy_exists ? 'success' : 'warning'">{{ row.published_copy_exists ? "已落盘" : "未落盘" }}</el-tag>
</template>
</el-table-column>
<el-table-column label="AI状态" min-width="100">
<template #default="{ row }">
<el-tag :type="row.ai_status === 'ai_pending' ? 'warning' : 'info'">{{ row.ai_status || "unknown" }}</el-tag>
</template>
</el-table-column>
<el-table-column label="Sitemap" min-width="120">
<template #default="{ row }">
<el-tag :type="row.sitemap_status === 'generated' ? 'success' : 'warning'">
{{ row.sitemap_status === "generated" ? "已生成" : "已入队待生成" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="说明" min-width="260">
<template #default="{ row }">
<div class="post-import-workflow__audit-notes">
{{ (row.notes || []).join("") }}
</div>
</template>
</el-table-column>
</el-table>
</div>
<div
v-if="postImportWorkflowDialog.payload.codex_continue_prompt"
class="post-import-workflow__section"
>
<div class="post-import-workflow__title">Codex提示词预览</div>
<el-input
:model-value="postImportWorkflowDialog.payload.codex_continue_prompt"
type="textarea"
:rows="10"
readonly
/>
</div>
</div>
<template #footer>
<el-button @click="postImportWorkflowDialog.visible = false">稍后再说</el-button>
<el-button type="primary" plain @click="copyPostImportCodexPrompt">复制Codex提示词</el-button>
<el-button type="success" :loading="postImportWorkflowDialog.submittingOpenAi" @click="continuePostImportWithOpenAi">
OpenAI继续
</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -280,6 +467,17 @@ const domainSpiderMdDialogRef = ref();
const domainSeoCopyWorkbenchDialogRef = ref();
const route = useRoute();
const router = useRouter();
const clipboard = navigator.clipboard;
const postImportWorkflowDialog = reactive<{
visible: boolean;
payload: SiteList.DomainUploadSummary | null;
submittingOpenAi: boolean;
}>({
visible: false,
payload: null,
submittingOpenAi: false
});
const batchAdd = () => {
void ensureDialogMounted("importDialog", () => {
const params = {
@@ -444,6 +642,73 @@ const openPublicPath = (path?: string) => {
window.open(`${BASE_URL}/${String(path).replace(/^\/+/, "")}`, "_blank", "noopener");
};
const openPostImportWorkflowDialog = (payload: SiteList.DomainUploadSummary) => {
postImportWorkflowDialog.payload = payload;
postImportWorkflowDialog.visible = true;
};
const copyPostImportCodexPrompt = async () => {
const prompt = String(postImportWorkflowDialog.payload?.codex_continue_prompt ?? "").trim();
if (prompt === "") {
ElMessage.warning("当前没有可复制的 Codex 提示词");
return;
}
try {
await clipboard.writeText(prompt);
ElMessage.success("Codex 提示词已复制");
} catch {
ElMessage.error("复制失败,请手动复制");
}
};
const continuePostImportWithOpenAi = async () => {
const domainIds = (postImportWorkflowDialog.payload?.post_import_workflow?.openai?.domain_ids || [])
.map(item => Number(item || 0))
.filter(item => item > 0);
if (domainIds.length === 0) {
ElMessage.warning("当前批次没有可提交的站点 ID");
return;
}
try {
await ElMessageBox.confirm(
`确认把这批新导入站点提交到 AI 生成队列吗?\n共 ${domainIds.length} 个站点`,
"OpenAI继续",
{
confirmButtonText: "提交AI生成",
cancelButtonText: "取消",
type: "warning",
center: true,
draggable: true
}
);
} catch {
return;
}
postImportWorkflowDialog.submittingOpenAi = true;
try {
const res = await submitDomainSeoCopyAiGenerate({
d_ids: domainIds,
wait_for_ai_before_publish: 1
});
const intQueued = Number(res.data?.queued_count ?? domainIds.length);
const strJobId = String(res.data?.job?.job_id ?? "").trim();
ElMessage.success(`已提交 ${intQueued} 个站点进入 AI 生成队列`);
postImportWorkflowDialog.visible = false;
if (strJobId !== "") {
domainImportTaskConsoleDialogRef.value?.open("AI文案生成任务", strJobId, () => {
proTable.value?.getTableList();
});
return;
}
proTable.value?.getTableList();
} finally {
postImportWorkflowDialog.submittingOpenAi = false;
}
};
const showDomainImportSummary = async (payload: SiteList.DomainUploadSummary) => {
const inserted = payload?.inserted_count ?? 0;
const probeProcessed = payload?.probe_processed_count ?? 0;
@@ -455,8 +720,9 @@ const showDomainImportSummary = async (payload: SiteList.DomainUploadSummary) =>
const runRoot = payload?.run_root ?? "";
const arrLines = [
`<p>新增站点:<strong>${inserted}</strong></p>`,
probeProcessed > 0 ? `<p>自动探测:<strong>${probeProcessed}</strong>,通过 <strong>${probePassed}</strong>,失败 <strong>${probeFailed}</strong></p>` : "",
`<p>本次新增站点:<strong>${inserted}</strong></p>`,
probeProcessed > 0 ? `<p>自动探测:<strong>${probeProcessed}</strong>,通过 <strong>${probePassed}</strong>,失败 <strong>${probeFailed}</strong></p>` : "",
`<p>建议先看探测摘要,确认页面链路正常后,再决定走 <strong>OpenAI继续</strong> 还是 <strong>Codex继续</strong>。</p>`,
runRoot ? `<p>运行目录:<code>${runRoot}</code></p>` : "",
uploadedExcelPath ? `<p><a href="${BASE_URL}/${String(uploadedExcelPath).replace(/^\/+/, "")}" target="_blank" rel="noopener">打开上传原始 Excel</a></p>` : "",
summaryHtmlPath ? `<p><a href="${BASE_URL}/${String(summaryHtmlPath).replace(/^\/+/, "")}" target="_blank" rel="noopener">打开探测 HTML 摘要</a></p>` : "",
@@ -464,6 +730,9 @@ const showDomainImportSummary = async (payload: SiteList.DomainUploadSummary) =>
].filter(Boolean);
if (!arrLines.length) {
if (payload?.post_import_workflow?.enabled) {
openPostImportWorkflowDialog(payload);
}
return;
}
@@ -482,6 +751,10 @@ const showDomainImportSummary = async (payload: SiteList.DomainUploadSummary) =>
openPublicPath(summaryJsonPath);
}
}).catch(() => undefined);
if (payload?.post_import_workflow?.enabled) {
openPostImportWorkflowDialog(payload);
}
};
const initParam = reactive({
@@ -1530,4 +1803,62 @@ watch(
.tagTextColor {
border: none;
}
.post-import-workflow {
display: flex;
flex-direction: column;
gap: 16px;
}
.post-import-workflow__metrics {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.post-import-workflow__section {
display: flex;
flex-direction: column;
gap: 10px;
}
.post-import-workflow__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.post-import-workflow__card {
border: 1px solid var(--el-border-color-light);
border-radius: 10px;
padding: 14px;
background: var(--el-fill-color-blank);
display: flex;
flex-direction: column;
gap: 10px;
}
.post-import-workflow__title {
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.post-import-workflow__desc,
.post-import-workflow__hint,
.post-import-workflow__path {
font-size: 13px;
line-height: 1.7;
color: var(--el-text-color-regular);
word-break: break-all;
}
.post-import-workflow__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.post-import-workflow__list {
margin: 0;
padding-left: 18px;
color: var(--el-text-color-regular);
line-height: 1.8;
}
@media (max-width: 900px) {
.post-import-workflow__grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -22,7 +22,13 @@
<el-input v-model="e.value" :disabled="e.disabled" :placeholder="`请输入${e.label}`" clearable></el-input>
</template>
<template v-if="e.type === 'text-tarea'">
<el-input v-model="e.value" :rows="5" type="textarea" :placeholder="`请输入${e.label}`" clearable></el-input>
<el-input
v-model="e.value"
:rows="e.rows || 5"
type="textarea"
:placeholder="`请输入${e.label}`"
clearable
></el-input>
</template>
<template v-if="e.type === 'textarea'">
<el-input
@@ -43,6 +49,15 @@
description="平时样式或脚本改动会自动重编只有当前端站群、CDN 或浏览器还在命中旧静态文件时,再手动提高这个版本号,强制全站切到新静态资源。"
show-icon
/>
<el-alert
v-if="isSeoAiRuleConfig"
class="config-helper-alert"
type="warning"
:closable="false"
title="当前为 AI 文案最高规则配置"
description="这里修改后,会直接影响 OpenAI 实时生成和批处理/Codex 导出提示词。建议先改规则文案,再做小批量验证,不要一次性改动多条执行口径。"
show-icon
/>
</el-form>
<template #footer>
<el-button @click="drawerVisible = false">取消</el-button>
@@ -69,8 +84,11 @@ type Fields = {
required: boolean;
options?: Options[];
disabled?: boolean;
rows?: number;
};
const isSeoAiRuleCode = (code?: string) => String(code || "").startsWith("SEO_COPY_AI_RULE_");
/**
* 解析系统配置字段的校验提示语。
*/
@@ -113,6 +131,9 @@ const formColumns = (): Fields[] => {
label: "配置值",
prop: "sc_val",
type: "text-tarea",
get rows() {
return isSeoAiRuleCode(drawerProps.value.row?.sc_code) ? 16 : 5;
},
get value() {
return drawerProps.value.row!.sc_val;
},
@@ -154,6 +175,7 @@ const drawerProps = ref<DrawerProps>({
row: {}
});
const isStaticFileVersion = computed(() => drawerProps.value.row?.sc_code === "STATIC_FILE_VERSION");
const isSeoAiRuleConfig = computed(() => isSeoAiRuleCode(drawerProps.value.row?.sc_code));
const acceptParams = (params: DrawerProps) => {
drawerProps.value = {

View File

@@ -1,12 +1,46 @@
<template>
<div class="table-box">
<template>
<div class="table-box">
<el-alert
class="config-ai-rule-alert"
type="warning"
:closable="false"
title="SEO AI 文案最高规则"
description="GPT 模板当前已经支持在系统配置里直接编辑 AI 文案规则。建议优先通过下方快捷按钮进入这 3 条规则:最高指令、详情摘要规则、详情正文规则。"
show-icon
/>
<ProTable
ref="proTable"
highlight-current-row
:columns="columns"
:request-api="getTableList"
:data-callback="dataCallback"
:row-class-name="tableRowClassName"
>
<template #tableHeader>
<div class="config-quick-filters">
<el-button
:type="activeQuickKey === '' ? 'primary' : 'default'"
plain
@click="applyQuickFilter('')"
>
全部配置
</el-button>
<el-button
:type="activeQuickKey === 'SEO_COPY_AI_RULE_' ? 'warning' : 'default'"
plain
@click="applyQuickFilter('SEO_COPY_AI_RULE_')"
>
SEO AI 规则
</el-button>
<el-button
:type="activeQuickKey === 'STATIC_FILE_VERSION' ? 'info' : 'default'"
plain
@click="applyQuickFilter('STATIC_FILE_VERSION')"
>
静态缓存兜底
</el-button>
</div>
</template>
<template #empty>
<el-empty description="暂无系统配置" />
</template>
@@ -19,14 +53,25 @@
<el-tag v-if="scope.row.sc_code === 'STATIC_FILE_VERSION'" size="small" type="warning" effect="plain">
缓存兜底
</el-tag>
<el-tag v-if="isSeoAiRuleCode(scope.row.sc_code)" size="small" type="danger" effect="plain">
SEO AI
</el-tag>
</div>
</template>
<template #sc_description="scope">
<div class="config-description-cell">
<span>{{ scope.row.sc_description }}</span>
<el-tag v-if="isSeoAiRuleCode(scope.row.sc_code)" size="small" type="danger" effect="light">
最高规则
</el-tag>
</div>
</template>
</ProTable>
<Drawer ref="drawerRef" />
</div>
</template>
<script setup lang="tsx" name="complexProTable">
<script setup lang="tsx" name="complexProTable">
import { reactive, ref } from "vue";
import { ConfigList } from "@/api/interface/system/config";
import ProTable from "@/components/ProTable/index.vue";
@@ -36,28 +81,46 @@ import { getConfigList, SaveApi } from "@/api/modules/system/config";
import Drawer from "./drawer.vue";
const proTable = ref<ProTableInstance>();
const activeQuickKey = ref("");
const initParam = reactive({
limit: 25,
page: 1
page: 1,
key: ""
});
const dataCallback = (data: any) => {
return {
items: data.items,
const isSeoAiRuleCode = (code?: string) => String(code || "").startsWith("SEO_COPY_AI_RULE_");
const dataCallback = (data: any) => {
return {
items: data.items,
total: data.total,
total_page: data.total_pages,
page: initParam.page,
limit: initParam.limit
};
};
const getTableList = (params: any) => {
let newParams = JSON.parse(JSON.stringify(params));
initParam.page = newParams.page;
initParam.limit = newParams.limit;
return getConfigList(newParams);
};
const getTableList = (params: any) => {
let newParams = JSON.parse(JSON.stringify(params));
initParam.page = newParams.page;
initParam.limit = newParams.limit;
initParam.key = newParams.key ?? "";
return getConfigList(newParams);
};
const applyQuickFilter = (keyword: string) => {
activeQuickKey.value = keyword;
if (!proTable.value) return;
proTable.value.searchParam.key = keyword;
proTable.value.search();
};
const tableRowClassName = ({ row }: { row: ConfigList.ResList }) => {
if (isSeoAiRuleCode(row?.sc_code)) return "danger-row";
if (row?.sc_code === "STATIC_FILE_VERSION") return "warning-row";
return "";
};
const columns = ref<ColumnProps[]>([
{
label: "配置编码 / ID",
@@ -112,10 +175,32 @@ const openDrawer = (row: Partial<ConfigList.ResList> = {}) => {
.el-table .success-row .el-table-fixed-column--left {
background-color: var(--el-color-success-light-9);
}
.el-table .danger-row,
.el-table .danger-row .el-table-fixed-column--right,
.el-table .danger-row .el-table-fixed-column--left {
background-color: var(--el-color-danger-light-9);
}
.config-ai-rule-alert {
margin-bottom: 16px;
}
.config-quick-filters {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.config-code-cell {
display: inline-flex;
align-items: center;
gap: 8px;
}
.config-description-cell {
display: inline-flex;
align-items: center;
gap: 8px;
}
</style>

View File

@@ -0,0 +1,254 @@
<template>
<div class="seo-ai-rules-page">
<el-alert
class="seo-ai-rules-page__alert"
type="warning"
:closable="false"
title="SEO AI 文案规则专属入口"
description="这里集中维护 GPT 模板当前 AI 文案最高规则。上半部分是实际会进入 AI 提示词的规则,下半部分“实验备注”只用于记录修改目的、观察指标和回看日期,不会喂给模型。"
show-icon
/>
<div class="seo-ai-rules-page__toolbar">
<el-button type="primary" plain @click="refreshRules">刷新规则</el-button>
<el-button type="danger" plain @click="resetAllRules">全部恢复默认</el-button>
<el-button plain @click="openSystemConfig">打开系统配置总表</el-button>
</div>
<el-row :gutter="16" v-loading="loading">
<el-col v-for="item in ruleItems" :key="item.sc_code" :xs="24" :sm="24" :md="12" :lg="8">
<el-card class="seo-ai-rules-page__card" shadow="hover">
<template #header>
<div class="seo-ai-rules-page__card-header">
<div>
<div class="seo-ai-rules-page__card-title">{{ resolveRuleTitle(item.sc_code) }}</div>
<div class="seo-ai-rules-page__card-code">{{ item.sc_code }}</div>
</div>
<div class="seo-ai-rules-page__card-actions">
<el-button type="warning" link @click="openNoteDrawer(item)">编辑备注</el-button>
<el-button type="danger" link @click="resetSingleRule(item)">恢复默认</el-button>
<el-button type="primary" link @click="openDrawer(item)">编辑</el-button>
</div>
</div>
</template>
<div class="seo-ai-rules-page__description">{{ item.sc_description }}</div>
<pre class="seo-ai-rules-page__value">{{ item.sc_val }}</pre>
<div class="seo-ai-rules-page__note-title">实验备注</div>
<pre class="seo-ai-rules-page__note">{{ item.note?.sc_val || "暂未填写实验备注" }}</pre>
</el-card>
</el-col>
</el-row>
<el-empty v-if="!loading && ruleItems.length === 0" description="当前还没有加载到 SEO AI 规则配置" />
<Drawer ref="drawerRef" />
</div>
</template>
<script setup lang="ts" name="SeoAiRulesConfig">
import { ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { ConfigList } from "@/api/interface/system/config";
import { getConfigList, SaveApi, resetSeoAiRulesApi } from "@/api/modules/system/config";
import Drawer from "./drawer.vue";
const router = useRouter();
const drawerRef = ref<InstanceType<typeof Drawer> | null>(null);
const loading = ref(false);
const ruleItems = ref<ConfigList.SeoAiRuleCard[]>([]);
const RULE_CODE = "SEO_COPY_AI_RULE_";
const NOTE_SUFFIX = "_NOTE";
const resolveRuleTitle = (code: string) => {
if (code === "SEO_COPY_AI_RULE_HIGHEST") return "最高指令";
if (code === "SEO_COPY_AI_RULE_DETAIL_SUMMARY") return "详情摘要规则";
if (code === "SEO_COPY_AI_RULE_DETAIL_BODY") return "详情正文规则";
if (code === "SEO_COPY_AI_RULE_MISSING_FIELDS") return "缺失字段处理规则";
if (code === "SEO_COPY_AI_RULE_MOVIE_STYLE") return "电影细分规则";
if (code === "SEO_COPY_AI_RULE_SERIES_STYLE") return "剧集细分规则";
if (code === "SEO_COPY_AI_RULE_VARIETY_STYLE") return "综艺细分规则";
if (code === "SEO_COPY_AI_RULE_ANIME_STYLE") return "动漫细分规则";
if (code === "SEO_COPY_AI_RULE_SHORT_DRAMA_STYLE") return "短剧细分规则";
return code;
};
const resolveNoteCode = (code: string) => `${code}${NOTE_SUFFIX}`;
const loadRules = async () => {
loading.value = true;
try {
const { data } = await getConfigList({
page: 1,
limit: 100,
key: RULE_CODE
});
const allItems = Array.isArray(data?.items) ? data.items : [];
const noteMap = new Map<string, ConfigList.ResList>();
allItems.forEach((item: ConfigList.ResList) => {
if (String(item.sc_code || "").endsWith(NOTE_SUFFIX)) {
noteMap.set(String(item.sc_code), item);
}
});
ruleItems.value = allItems
.filter((item: ConfigList.ResList) => !String(item.sc_code || "").endsWith(NOTE_SUFFIX))
.map((item: ConfigList.ResList) => ({
...item,
note: noteMap.get(resolveNoteCode(item.sc_code)) || null
}));
} finally {
loading.value = false;
}
};
const refreshRules = async () => {
await loadRules();
ElMessage.success("SEO AI 规则已刷新");
};
const resetAllRules = async () => {
await ElMessageBox.confirm("是否将全部 SEO AI 规则恢复为当前代码默认基线?该操作会覆盖后台现有自定义内容。", "恢复默认", {
type: "warning",
confirmButtonText: "恢复全部",
cancelButtonText: "取消"
});
await resetSeoAiRulesApi({});
await loadRules();
ElMessage.success("全部 SEO AI 规则已恢复默认");
};
const resetSingleRule = async (row: ConfigList.ResList) => {
await ElMessageBox.confirm(`是否将「${resolveRuleTitle(row.sc_code)}」恢复为默认规则?`, "恢复默认", {
type: "warning",
confirmButtonText: "恢复默认",
cancelButtonText: "取消"
});
await resetSeoAiRulesApi({ sc_code: row.sc_code });
await loadRules();
ElMessage.success(`${resolveRuleTitle(row.sc_code)}」已恢复默认`);
};
const openSystemConfig = () => {
router.push("/system/config/index");
};
const openDrawer = (row: Partial<ConfigList.ResList> = {}) => {
drawerRef.value?.acceptParams({
title: "编辑",
row: { ...row },
api: SaveApi,
getTableList: loadRules,
successMessage: "SEO AI 规则保存完成,已刷新专属页面"
} as any);
};
const openNoteDrawer = (row: ConfigList.SeoAiRuleCard) => {
const noteRow = row.note && row.note.sc_code
? { ...row.note }
: {
sc_code: resolveNoteCode(row.sc_code),
sc_val: "",
sc_description: `${resolveRuleTitle(row.sc_code)} 实验备注`
};
drawerRef.value?.acceptParams({
title: "编辑实验备注",
row: noteRow,
api: SaveApi,
getTableList: loadRules,
successMessage: `${resolveRuleTitle(row.sc_code)} 的实验备注已保存`
} as any);
};
void loadRules();
</script>
<style scoped lang="scss">
.seo-ai-rules-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.seo-ai-rules-page__alert {
margin-bottom: 4px;
}
.seo-ai-rules-page__toolbar {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.seo-ai-rules-page__card {
margin-bottom: 16px;
min-height: 100%;
}
.seo-ai-rules-page__card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.seo-ai-rules-page__card-actions {
display: inline-flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.seo-ai-rules-page__card-title {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.seo-ai-rules-page__card-code {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
word-break: break-all;
}
.seo-ai-rules-page__description {
margin-bottom: 12px;
color: var(--el-text-color-regular);
line-height: 1.6;
}
.seo-ai-rules-page__value {
margin: 0;
padding: 14px;
border-radius: 10px;
background: var(--el-fill-color-light);
color: var(--el-text-color-primary);
white-space: pre-wrap;
word-break: break-word;
line-height: 1.7;
font-size: 13px;
}
.seo-ai-rules-page__note-title {
margin-top: 14px;
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-regular);
}
.seo-ai-rules-page__note {
margin: 0;
padding: 12px;
border-radius: 10px;
background: var(--el-color-warning-light-9);
color: var(--el-text-color-primary);
white-space: pre-wrap;
word-break: break-word;
line-height: 1.7;
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,496 @@
<template>
<div class="metadata-missing-page">
<el-card shadow="never" class="metadata-missing-page__hero">
<template #header>
<div class="metadata-missing-page__hero-header">
<div>
<div class="metadata-missing-page__title">{{ pageTitle }}</div>
<div class="metadata-missing-page__subtitle">{{ pageSubtitle }}</div>
</div>
<div class="metadata-missing-page__hero-actions">
<el-button :loading="loading" @click="loadCurrentPage">刷新当前页</el-button>
<el-button type="primary" plain :loading="workbenchRunning" @click="handleRunWorkbench">刷新工作台</el-button>
<el-button type="warning" plain :loading="taskPoolRunning" @click="handleRunTaskPool">刷新任务池</el-button>
</div>
</div>
</template>
<el-alert :type="heroAlertType" :closable="false" show-icon :title="heroAlertTitle" :description="heroAlertDescription" />
<div class="metadata-missing-page__nav">
<el-button :type="isMode('workbench') ? 'primary' : 'default'" @click="goMode('workbench')">工作台</el-button>
<el-button :type="isMode('task-pool') ? 'primary' : 'default'" @click="goMode('task-pool')">任务池</el-button>
<el-button :type="isMode('status') ? 'primary' : 'default'" @click="goMode('status')">状态总览</el-button>
<el-button :type="isMode('ops') ? 'primary' : 'default'" @click="goMode('ops')">运维总览</el-button>
</div>
</el-card>
<template v-if="currentMode === 'workbench'">
<div class="metadata-missing-page__stats">
<div class="metadata-stat-card">
<div class="metadata-stat-card__label">总视频数</div>
<div class="metadata-stat-card__value">{{ workbenchSummary.audit?.total_videos || 0 }}</div>
</div>
<div class="metadata-stat-card">
<div class="metadata-stat-card__label">缺字段视频数</div>
<div class="metadata-stat-card__value">{{ workbenchSummary.audit?.videos_with_any_missing_metadata || 0 }}</div>
</div>
<div class="metadata-stat-card">
<div class="metadata-stat-card__label">重采优先队列</div>
<div class="metadata-stat-card__value">{{ workbenchSummary.queue?.queue_size || 0 }}</div>
</div>
</div>
<el-card shadow="never">
<template #header><span>下一步动作</span></template>
<el-empty v-if="!(workbenchSummary.next_actions || []).length" description="当前还没有下一步动作建议" />
<div v-else class="metadata-action-list">
<div v-for="item in workbenchSummary.next_actions || []" :key="item.key || item.label" class="metadata-action-card">
<div class="metadata-action-card__title">{{ item.label || "-" }}</div>
<div class="metadata-action-card__desc">{{ item.summary || "-" }}</div>
</div>
</div>
</el-card>
<el-card shadow="never">
<template #header><span>缺失最严重字段</span></template>
<el-table :data="workbenchSummary.top_missing_fields || []" border>
<el-table-column prop="field" label="字段" min-width="180" />
<el-table-column prop="missing_count" label="缺失数" min-width="120" />
<el-table-column label="缺失率" min-width="120">
<template #default="scope">
{{ formatPercent(scope.row?.missing_ratio) }}
</template>
</el-table-column>
</el-table>
</el-card>
<el-card shadow="never">
<template #header>
<div class="metadata-card-header">
<span>Codex 派单提示词</span>
<el-button text type="primary" @click="copyPrompt(workbenchSummary.codex_dispatch_prompt)">复制提示词</el-button>
</div>
</template>
<pre class="metadata-code-block">{{ workbenchSummary.codex_dispatch_prompt || "当前还没有生成提示词" }}</pre>
</el-card>
</template>
<template v-else-if="currentMode === 'task-pool'">
<div class="metadata-missing-page__stats">
<div class="metadata-stat-card">
<div class="metadata-stat-card__label">批次数</div>
<div class="metadata-stat-card__value">{{ taskPoolSummary.batches?.length || 0 }}</div>
</div>
<div class="metadata-stat-card">
<div class="metadata-stat-card__label">缺字段视频数</div>
<div class="metadata-stat-card__value">{{ taskPoolSummary.videos_with_any_missing_metadata || 0 }}</div>
</div>
<div class="metadata-stat-card">
<div class="metadata-stat-card__label">重采优先队列</div>
<div class="metadata-stat-card__value">{{ taskPoolSummary.queue_size || 0 }}</div>
</div>
</div>
<el-alert
v-if="taskPoolSummary.operator_hint"
type="info"
:closable="false"
show-icon
:title="String(taskPoolSummary.operator_hint)"
/>
<el-card shadow="never">
<template #header><span>批次列表</span></template>
<el-table :data="taskPoolSummary.batches || []" border>
<el-table-column prop="batch_id" label="批次ID" min-width="180" />
<el-table-column prop="batch_label" label="批次名称" min-width="220" />
<el-table-column prop="focus_field" label="重点字段" min-width="140" />
<el-table-column prop="priority_level" label="优先级" min-width="100" />
<el-table-column prop="status_label" label="状态" min-width="120" />
<el-table-column prop="items_count" label="数量" min-width="100" />
<el-table-column label="操作" min-width="120" fixed="right">
<template #default="scope">
<el-button text type="primary" @click="copyPrompt(scope.row?.codex_prompt)">复制提示词</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</template>
<template v-else-if="currentMode === 'status'">
<div class="metadata-missing-page__stats">
<div class="metadata-stat-card">
<div class="metadata-stat-card__label">缺字段视频数</div>
<div class="metadata-stat-card__value">{{ statusSummary.workbench?.videos_with_any_missing_metadata || 0 }}</div>
</div>
<div class="metadata-stat-card">
<div class="metadata-stat-card__label">重采优先队列</div>
<div class="metadata-stat-card__value">{{ statusSummary.workbench?.queue_size || 0 }}</div>
</div>
<div class="metadata-stat-card">
<div class="metadata-stat-card__label">任务池批次数</div>
<div class="metadata-stat-card__value">{{ statusSummary.task_pool?.batch_count || 0 }}</div>
</div>
</div>
<el-card shadow="never">
<template #header><span>计划任务状态</span></template>
<el-descriptions :column="2" border>
<el-descriptions-item label="状态">{{ statusSummary.plan_task?.status_label || "-" }}</el-descriptions-item>
<el-descriptions-item label="启用">{{ statusSummary.plan_task?.enabled ? "是" : "否" }}</el-descriptions-item>
<el-descriptions-item label="频率">{{ statusSummary.plan_task?.limit_human || "-" }}</el-descriptions-item>
<el-descriptions-item label="任务编码">{{ statusSummary.plan_task?.pt_code || "-" }}</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card shadow="never">
<template #header><span>运维提示</span></template>
<el-empty v-if="!(statusSummary.operator_hint || []).length" description="当前还没有额外提示" />
<div v-else class="metadata-hint-list">
<div v-for="(item, index) in statusSummary.operator_hint || []" :key="index" class="metadata-hint-list__item">
{{ item }}
</div>
</div>
</el-card>
</template>
<template v-else>
<el-card shadow="never">
<template #header><span>运维动作</span></template>
<div class="metadata-action-list">
<div class="metadata-action-card">
<div class="metadata-action-card__title">推荐动作</div>
<div class="metadata-action-card__desc">{{ opsSummary.ops?.recommended_action || statusSummary.recommended_action || "-" }}</div>
</div>
</div>
</el-card>
<el-card shadow="never">
<template #header><span>常用命令</span></template>
<el-table :data="opsSummary.ops?.command_entries || []" border>
<el-table-column prop="label" label="用途" min-width="180" />
<el-table-column prop="command" label="命令 / 接口" min-width="420" show-overflow-tooltip />
</el-table>
</el-card>
<el-card shadow="never">
<template #header><span>产物路径</span></template>
<el-table :data="opsSummary.ops?.artifact_entries || []" border>
<el-table-column prop="label" label="名称" min-width="180" />
<el-table-column prop="path" label="路径" min-width="420" show-overflow-tooltip />
</el-table>
</el-card>
</template>
</div>
</template>
<script setup lang="ts" name="VideoMetadataMissingPage">
import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import {
getMetadataMissingTaskPoolOps,
getMetadataMissingTaskPoolStatus,
getMetadataMissingTaskPoolSummary,
getMetadataMissingWorkbenchSummary,
runMetadataMissingTaskPool,
runMetadataMissingWorkbench
} from "@/api/modules/video/list";
import type { VideoList } from "@/api/interface/video/list";
type PageMode = "workbench" | "task-pool" | "status" | "ops";
const route = useRoute();
const router = useRouter();
const loading = ref(false);
const workbenchRunning = ref(false);
const taskPoolRunning = ref(false);
const statusSummary = ref<VideoList.MetadataMissingTaskPoolStatusData>({});
const opsSummary = ref<VideoList.MetadataMissingTaskPoolOpsData>({});
const workbenchSummary = ref<VideoList.MetadataMissingWorkbenchSummaryData>({});
const taskPoolSummary = ref<VideoList.MetadataMissingTaskPoolData>({});
const currentMode = computed<PageMode>(() => {
const path = route.path;
if (path.endsWith("/task-pool/ops")) return "ops";
if (path.endsWith("/task-pool/status")) return "status";
if (path.endsWith("/task-pool")) return "task-pool";
return "workbench";
});
const pageTitle = computed(() => {
return {
workbench: "视频缺字段工作台",
"task-pool": "视频缺字段任务池",
status: "视频缺字段状态总览",
ops: "视频缺字段运维总览"
}[currentMode.value];
});
const pageSubtitle = computed(() => {
return {
workbench: "这里看缺字段规模、重点字段和 Codex 派单提示词。",
"task-pool": "这里看分批结果、批次状态和复制提示词入口。",
status: "这里看计划任务、缺字段规模和当前建议动作。",
ops: "这里看常用命令、推荐动作和运维产物路径。"
}[currentMode.value];
});
const heroAlertType = computed(() => {
if (currentMode.value === "status" || currentMode.value === "ops") {
const label = statusSummary.value.plan_task?.status_label || "missing";
if (label === "enabled") return "success";
if (label === "disabled") return "warning";
if (label === "db_blocked") return "error";
return "info";
}
if (currentMode.value === "task-pool") {
return (taskPoolSummary.value.batches?.length || 0) > 0 ? "success" : "warning";
}
return (workbenchSummary.value.audit?.videos_with_any_missing_metadata || 0) > 0 ? "warning" : "success";
});
const heroAlertTitle = computed(() => {
if (currentMode.value === "status" || currentMode.value === "ops") {
const label = statusSummary.value.plan_task?.status_label || "missing";
if (label === "enabled") return "计划任务已启用";
if (label === "disabled") return "计划任务已存在但未启用";
if (label === "db_blocked") return "当前环境暂时无法读取计划任务";
return "计划任务尚未补齐";
}
if (currentMode.value === "task-pool") return "这里已经完全迁到 Vue 后台,不再依赖 PHP 页面入口";
return "视频缺字段工作台已经迁到 Vue 后台首层页面";
});
const heroAlertDescription = computed(() => {
if (currentMode.value === "status" || currentMode.value === "ops") {
return statusSummary.value.recommended_action || "先刷新当前页,再看下一步动作。";
}
if (currentMode.value === "task-pool") {
return taskPoolSummary.value.operator_hint || "当前适合从这里继续看批次和复制提示词。";
}
return (workbenchSummary.value.next_actions || []).map(item => item.label).filter(Boolean).join(" / ") || "当前适合先看缺字段规模和重点字段。";
});
const isMode = (mode: PageMode) => currentMode.value === mode;
const goMode = (mode: PageMode) => {
const map: Record<PageMode, string> = {
workbench: "/video/metadata/missing/workbench",
"task-pool": "/video/metadata/missing/task-pool",
status: "/video/metadata/missing/task-pool/status",
ops: "/video/metadata/missing/task-pool/ops"
};
router.push(map[mode]);
};
const formatPercent = (value?: number) => `${(((value || 0) as number) * 100).toFixed(2)}%`;
const copyPrompt = async (text?: string) => {
const value = String(text || "").trim();
if (!value) {
ElMessage.warning("当前没有可复制的提示词");
return;
}
await navigator.clipboard.writeText(value);
ElMessage.success("提示词已复制");
};
const loadCurrentPage = async () => {
loading.value = true;
try {
if (currentMode.value === "workbench") {
const res = await getMetadataMissingWorkbenchSummary();
workbenchSummary.value = res.data || {};
return;
}
if (currentMode.value === "task-pool") {
const res = await getMetadataMissingTaskPoolSummary();
taskPoolSummary.value = res.data || {};
return;
}
if (currentMode.value === "status") {
const res = await getMetadataMissingTaskPoolStatus();
statusSummary.value = res.data || {};
return;
}
const [statusRes, opsRes] = await Promise.all([getMetadataMissingTaskPoolStatus(), getMetadataMissingTaskPoolOps()]);
statusSummary.value = statusRes.data || {};
opsSummary.value = opsRes.data || {};
} finally {
loading.value = false;
}
};
const handleRunWorkbench = async () => {
workbenchRunning.value = true;
try {
const res = await runMetadataMissingWorkbench();
ElMessage.success(res.msg || "工作台刷新已触发");
await loadCurrentPage();
} finally {
workbenchRunning.value = false;
}
};
const handleRunTaskPool = async () => {
taskPoolRunning.value = true;
try {
const res = await runMetadataMissingTaskPool();
ElMessage.success(res.msg || "任务池刷新已触发");
await loadCurrentPage();
} finally {
taskPoolRunning.value = false;
}
};
watch(
() => route.path,
() => {
loadCurrentPage();
}
);
onMounted(() => {
loadCurrentPage();
});
</script>
<style scoped lang="scss">
.metadata-missing-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.metadata-missing-page__hero {
border-radius: 20px;
background: linear-gradient(135deg, #fffdf4 0%, #ffffff 55%, #eef6ff 100%);
}
.metadata-missing-page__hero-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
}
.metadata-missing-page__title {
font-size: 24px;
font-weight: 800;
color: #111827;
line-height: 1.2;
}
.metadata-missing-page__subtitle {
margin-top: 8px;
color: #6b7280;
line-height: 1.7;
}
.metadata-missing-page__hero-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.metadata-missing-page__nav {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-top: 16px;
}
.metadata-missing-page__stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
.metadata-stat-card {
padding: 18px;
border-radius: 16px;
background: #fff;
border: 1px solid #e5e7eb;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.04);
}
.metadata-stat-card__label {
font-size: 13px;
color: #6b7280;
}
.metadata-stat-card__value {
margin-top: 10px;
font-size: 30px;
font-weight: 800;
line-height: 1;
color: #111827;
}
.metadata-action-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.metadata-action-card {
padding: 16px;
border-radius: 14px;
background: #f8fafc;
border: 1px solid #e5e7eb;
}
.metadata-action-card__title {
font-size: 16px;
font-weight: 700;
color: #111827;
}
.metadata-action-card__desc {
margin-top: 8px;
line-height: 1.8;
color: #475569;
}
.metadata-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.metadata-code-block {
margin: 0;
padding: 16px;
border-radius: 14px;
background: #0f172a;
color: #e2e8f0;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.7;
}
.metadata-hint-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.metadata-hint-list__item {
padding: 14px 16px;
border-radius: 14px;
background: #f8fafc;
border: 1px solid #e5e7eb;
line-height: 1.8;
}
@media (max-width: 960px) {
.metadata-missing-page__stats,
.metadata-action-list {
grid-template-columns: 1fr;
}
}
</style>