feat: add metadata console and seo ai rule pages
This commit is contained in:
@@ -2,6 +2,7 @@ export const loginApiUrl = "/guest/login";
|
|||||||
|
|
||||||
export const getSystemConfigListApiUrl = "/system/config/list";
|
export const getSystemConfigListApiUrl = "/system/config/list";
|
||||||
export const getSystemConfigSaveApiUrl = "/system/config/save";
|
export const getSystemConfigSaveApiUrl = "/system/config/save";
|
||||||
|
export const getSystemConfigSeoAiRulesResetApiUrl = "/system/config/seo-ai-rules/reset";
|
||||||
|
|
||||||
export const getSystemPlanListApiUrl = "/system/plan/list";
|
export const getSystemPlanListApiUrl = "/system/plan/list";
|
||||||
export const getSystemPlanSaveApiUrl = "/system/plan/save";
|
export const getSystemPlanSaveApiUrl = "/system/plan/save";
|
||||||
|
|||||||
@@ -924,7 +924,15 @@ export namespace SiteList {
|
|||||||
skipped_template_missing_count?: number;
|
skipped_template_missing_count?: number;
|
||||||
skipped_duplicate_in_file_count?: number;
|
skipped_duplicate_in_file_count?: number;
|
||||||
strategy_profile_counts?: Record<string, 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 }>;
|
skipped_domains?: Array<{ d_domain?: string; reason?: string; t_id?: number }>;
|
||||||
run_root?: string;
|
run_root?: string;
|
||||||
uploaded_excel_path?: string;
|
uploaded_excel_path?: string;
|
||||||
@@ -934,6 +942,67 @@ export namespace SiteList {
|
|||||||
probe_failed_count?: number;
|
probe_failed_count?: number;
|
||||||
summary_json_path?: string;
|
summary_json_path?: string;
|
||||||
summary_html_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 {
|
export interface DomainImportRunSummaryItem {
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ export namespace ConfigList {
|
|||||||
sc_val: string;
|
sc_val: string;
|
||||||
sc_description: string;
|
sc_description: string;
|
||||||
}
|
}
|
||||||
|
export interface resetSeoAiRuleParams {
|
||||||
|
sc_code?: string;
|
||||||
|
}
|
||||||
export interface delParams {
|
export interface delParams {
|
||||||
sc_id: string;
|
sc_id: string;
|
||||||
}
|
}
|
||||||
@@ -18,5 +21,8 @@ export namespace ConfigList {
|
|||||||
sc_val: string;
|
sc_val: string;
|
||||||
sc_description: string;
|
sc_description: string;
|
||||||
}
|
}
|
||||||
|
export interface SeoAiRuleCard extends ResList {
|
||||||
|
note?: Partial<ResList> | null;
|
||||||
|
}
|
||||||
export interface ResData extends ResultData {}
|
export interface ResData extends ResultData {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,4 +78,125 @@ export namespace VideoList {
|
|||||||
tag_list?: any;
|
tag_list?: any;
|
||||||
}
|
}
|
||||||
export interface ResData extends ResultData {}
|
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>>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ResPage, ResultData } from "@/api/interface/index";
|
import { ResPage, ResultData } from "@/api/interface/index";
|
||||||
import { ConfigList } from "@/api/interface/system/config";
|
import { ConfigList } from "@/api/interface/system/config";
|
||||||
import { getSystemConfigListApiUrl, getSystemConfigSaveApiUrl } from "@/api/config";
|
import { getSystemConfigListApiUrl, getSystemConfigSaveApiUrl, getSystemConfigSeoAiRulesResetApiUrl } from "@/api/config";
|
||||||
import http from "@/api";
|
import http from "@/api";
|
||||||
|
|
||||||
|
|
||||||
@@ -12,3 +12,7 @@ export const getConfigList = (params: ConfigList.ReqParams) => {
|
|||||||
export const SaveApi = (params: ConfigList.saveParams) => {
|
export const SaveApi = (params: ConfigList.saveParams) => {
|
||||||
return http.post<ResPage<ResultData>>(getSystemConfigSaveApiUrl, params);
|
return http.post<ResPage<ResultData>>(getSystemConfigSaveApiUrl, params);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const resetSeoAiRulesApi = (params: ConfigList.resetSeoAiRuleParams) => {
|
||||||
|
return http.post<ResPage<ResultData>>(getSystemConfigSeoAiRulesResetApiUrl, params);
|
||||||
|
};
|
||||||
|
|||||||
@@ -19,3 +19,27 @@ export const batchData = (params: VideoList.delParams) => {
|
|||||||
export const batchVipData = (params: VideoList.delParams) => {
|
export const batchVipData = (params: VideoList.delParams) => {
|
||||||
return http.post<ResPage<ResultData>>(getVideoSetLevelApiUrl, params);
|
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`, {});
|
||||||
|
};
|
||||||
|
|||||||
@@ -134,6 +134,21 @@
|
|||||||
"isKeepAlive": true
|
"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",
|
"path": "/resource/endpoint/list",
|
||||||
"name": "endpointList",
|
"name": "endpointList",
|
||||||
@@ -458,6 +473,66 @@
|
|||||||
"isKeepAlive": true
|
"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",
|
"path": "/searchGroup/index",
|
||||||
|
|||||||
@@ -6,3 +6,101 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
gap: 20px;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,60 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="home card">
|
<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">
|
<div class="home-bootstrap-actions">
|
||||||
<el-button type="primary" @click="openBootstrapCenter('overview')">打开 Bootstrap 总控台</el-button>
|
<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>
|
<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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts" name="home">
|
<script setup lang="ts" name="home">
|
||||||
|
import { computed, onMounted, ref } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
|
import { ElMessage } from "element-plus";
|
||||||
import BootstrapOpsOverview from "@/views/site/bootstrapOpsOverview.vue";
|
import BootstrapOpsOverview from "@/views/site/bootstrapOpsOverview.vue";
|
||||||
import BootstrapEnvOverview from "@/views/site/bootstrapEnvOverview.vue";
|
import BootstrapEnvOverview from "@/views/site/bootstrapEnvOverview.vue";
|
||||||
import BootstrapAdminCenter from "@/views/site/bootstrapAdminCenter.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 router = useRouter();
|
||||||
const bootstrapAdminCenterRef = ref<InstanceType<typeof BootstrapAdminCenter> | null>(null);
|
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 BootstrapFocusMode = "all" | "task" | "oncall" | "pipeline" | "import" | "release";
|
||||||
type BootstrapFocusPayload = BootstrapFocusMode | BootstrapFocusMode[];
|
type BootstrapFocusPayload = BootstrapFocusMode | BootstrapFocusMode[];
|
||||||
type BootstrapComparePreset =
|
type BootstrapComparePreset =
|
||||||
@@ -77,6 +143,69 @@ const openBootstrapCenter = (payload: BootstrapAdminOpenPayload = "overview") =>
|
|||||||
bootstrapAdminCenterRef.value?.openDialog(payload);
|
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 goSitePage = (payload: BootstrapDialogPayload = "all") => {
|
||||||
const normalizedFocus = typeof payload === "string" || Array.isArray(payload) ? payload : payload.focusMode ?? "all";
|
const normalizedFocus = typeof payload === "string" || Array.isArray(payload) ? payload : payload.focusMode ?? "all";
|
||||||
const comparePreset = typeof payload === "string" || Array.isArray(payload) ? undefined : payload.comparePreset;
|
const comparePreset = typeof payload === "string" || Array.isArray(payload) ? undefined : payload.comparePreset;
|
||||||
@@ -93,6 +222,10 @@ const goSitePage = (payload: BootstrapDialogPayload = "all") => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadMetadataConsole();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
|||||||
@@ -113,6 +113,193 @@
|
|||||||
<BootstrapEnvDialog v-if="dialogMount.bootstrapEnvDialog" ref="bootstrapEnvDialogRef" />
|
<BootstrapEnvDialog v-if="dialogMount.bootstrapEnvDialog" ref="bootstrapEnvDialogRef" />
|
||||||
<DomainSupplyDialog v-if="dialogMount.domainSupplyDialog" ref="domainSupplyDialogRef" />
|
<DomainSupplyDialog v-if="dialogMount.domainSupplyDialog" ref="domainSupplyDialogRef" />
|
||||||
<DomainImportTaskConsoleDialog ref="domainImportTaskConsoleDialogRef" />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -280,6 +467,17 @@ const domainSpiderMdDialogRef = ref();
|
|||||||
const domainSeoCopyWorkbenchDialogRef = ref();
|
const domainSeoCopyWorkbenchDialogRef = ref();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
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 = () => {
|
const batchAdd = () => {
|
||||||
void ensureDialogMounted("importDialog", () => {
|
void ensureDialogMounted("importDialog", () => {
|
||||||
const params = {
|
const params = {
|
||||||
@@ -444,6 +642,73 @@ const openPublicPath = (path?: string) => {
|
|||||||
window.open(`${BASE_URL}/${String(path).replace(/^\/+/, "")}`, "_blank", "noopener");
|
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 showDomainImportSummary = async (payload: SiteList.DomainUploadSummary) => {
|
||||||
const inserted = payload?.inserted_count ?? 0;
|
const inserted = payload?.inserted_count ?? 0;
|
||||||
const probeProcessed = payload?.probe_processed_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 runRoot = payload?.run_root ?? "";
|
||||||
|
|
||||||
const arrLines = [
|
const arrLines = [
|
||||||
`<p>新增站点:<strong>${inserted}</strong></p>`,
|
`<p>本次新增站点:<strong>${inserted}</strong></p>`,
|
||||||
probeProcessed > 0 ? `<p>自动探测:<strong>${probeProcessed}</strong>,通过 <strong>${probePassed}</strong>,失败 <strong>${probeFailed}</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>` : "",
|
runRoot ? `<p>运行目录:<code>${runRoot}</code></p>` : "",
|
||||||
uploadedExcelPath ? `<p><a href="${BASE_URL}/${String(uploadedExcelPath).replace(/^\/+/, "")}" target="_blank" rel="noopener">打开上传原始 Excel</a></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>` : "",
|
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);
|
].filter(Boolean);
|
||||||
|
|
||||||
if (!arrLines.length) {
|
if (!arrLines.length) {
|
||||||
|
if (payload?.post_import_workflow?.enabled) {
|
||||||
|
openPostImportWorkflowDialog(payload);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,6 +751,10 @@ const showDomainImportSummary = async (payload: SiteList.DomainUploadSummary) =>
|
|||||||
openPublicPath(summaryJsonPath);
|
openPublicPath(summaryJsonPath);
|
||||||
}
|
}
|
||||||
}).catch(() => undefined);
|
}).catch(() => undefined);
|
||||||
|
|
||||||
|
if (payload?.post_import_workflow?.enabled) {
|
||||||
|
openPostImportWorkflowDialog(payload);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const initParam = reactive({
|
const initParam = reactive({
|
||||||
@@ -1530,4 +1803,62 @@ watch(
|
|||||||
.tagTextColor {
|
.tagTextColor {
|
||||||
border: none;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -22,7 +22,13 @@
|
|||||||
<el-input v-model="e.value" :disabled="e.disabled" :placeholder="`请输入${e.label}`" clearable></el-input>
|
<el-input v-model="e.value" :disabled="e.disabled" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="e.type === 'text-tarea'">
|
<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>
|
||||||
<template v-if="e.type === 'textarea'">
|
<template v-if="e.type === 'textarea'">
|
||||||
<el-input
|
<el-input
|
||||||
@@ -43,6 +49,15 @@
|
|||||||
description="平时样式或脚本改动会自动重编;只有当前端站群、CDN 或浏览器还在命中旧静态文件时,再手动提高这个版本号,强制全站切到新静态资源。"
|
description="平时样式或脚本改动会自动重编;只有当前端站群、CDN 或浏览器还在命中旧静态文件时,再手动提高这个版本号,强制全站切到新静态资源。"
|
||||||
show-icon
|
show-icon
|
||||||
/>
|
/>
|
||||||
|
<el-alert
|
||||||
|
v-if="isSeoAiRuleConfig"
|
||||||
|
class="config-helper-alert"
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
title="当前为 AI 文案最高规则配置"
|
||||||
|
description="这里修改后,会直接影响 OpenAI 实时生成和批处理/Codex 导出提示词。建议先改规则文案,再做小批量验证,不要一次性改动多条执行口径。"
|
||||||
|
show-icon
|
||||||
|
/>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="drawerVisible = false">取消</el-button>
|
<el-button @click="drawerVisible = false">取消</el-button>
|
||||||
@@ -69,8 +84,11 @@ type Fields = {
|
|||||||
required: boolean;
|
required: boolean;
|
||||||
options?: Options[];
|
options?: Options[];
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
rows?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isSeoAiRuleCode = (code?: string) => String(code || "").startsWith("SEO_COPY_AI_RULE_");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析系统配置字段的校验提示语。
|
* 解析系统配置字段的校验提示语。
|
||||||
*/
|
*/
|
||||||
@@ -113,6 +131,9 @@ const formColumns = (): Fields[] => {
|
|||||||
label: "配置值",
|
label: "配置值",
|
||||||
prop: "sc_val",
|
prop: "sc_val",
|
||||||
type: "text-tarea",
|
type: "text-tarea",
|
||||||
|
get rows() {
|
||||||
|
return isSeoAiRuleCode(drawerProps.value.row?.sc_code) ? 16 : 5;
|
||||||
|
},
|
||||||
get value() {
|
get value() {
|
||||||
return drawerProps.value.row!.sc_val;
|
return drawerProps.value.row!.sc_val;
|
||||||
},
|
},
|
||||||
@@ -154,6 +175,7 @@ const drawerProps = ref<DrawerProps>({
|
|||||||
row: {}
|
row: {}
|
||||||
});
|
});
|
||||||
const isStaticFileVersion = computed(() => drawerProps.value.row?.sc_code === "STATIC_FILE_VERSION");
|
const isStaticFileVersion = computed(() => drawerProps.value.row?.sc_code === "STATIC_FILE_VERSION");
|
||||||
|
const isSeoAiRuleConfig = computed(() => isSeoAiRuleCode(drawerProps.value.row?.sc_code));
|
||||||
|
|
||||||
const acceptParams = (params: DrawerProps) => {
|
const acceptParams = (params: DrawerProps) => {
|
||||||
drawerProps.value = {
|
drawerProps.value = {
|
||||||
|
|||||||
@@ -1,12 +1,46 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="table-box">
|
<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
|
<ProTable
|
||||||
ref="proTable"
|
ref="proTable"
|
||||||
highlight-current-row
|
highlight-current-row
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:request-api="getTableList"
|
:request-api="getTableList"
|
||||||
:data-callback="dataCallback"
|
: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>
|
<template #empty>
|
||||||
<el-empty description="暂无系统配置" />
|
<el-empty description="暂无系统配置" />
|
||||||
</template>
|
</template>
|
||||||
@@ -19,6 +53,17 @@
|
|||||||
<el-tag v-if="scope.row.sc_code === 'STATIC_FILE_VERSION'" size="small" type="warning" effect="plain">
|
<el-tag v-if="scope.row.sc_code === 'STATIC_FILE_VERSION'" size="small" type="warning" effect="plain">
|
||||||
缓存兜底
|
缓存兜底
|
||||||
</el-tag>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ProTable>
|
</ProTable>
|
||||||
@@ -36,12 +81,16 @@ import { getConfigList, SaveApi } from "@/api/modules/system/config";
|
|||||||
|
|
||||||
import Drawer from "./drawer.vue";
|
import Drawer from "./drawer.vue";
|
||||||
const proTable = ref<ProTableInstance>();
|
const proTable = ref<ProTableInstance>();
|
||||||
|
const activeQuickKey = ref("");
|
||||||
|
|
||||||
const initParam = reactive({
|
const initParam = reactive({
|
||||||
limit: 25,
|
limit: 25,
|
||||||
page: 1
|
page: 1,
|
||||||
|
key: ""
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isSeoAiRuleCode = (code?: string) => String(code || "").startsWith("SEO_COPY_AI_RULE_");
|
||||||
|
|
||||||
const dataCallback = (data: any) => {
|
const dataCallback = (data: any) => {
|
||||||
return {
|
return {
|
||||||
items: data.items,
|
items: data.items,
|
||||||
@@ -55,9 +104,23 @@ const getTableList = (params: any) => {
|
|||||||
let newParams = JSON.parse(JSON.stringify(params));
|
let newParams = JSON.parse(JSON.stringify(params));
|
||||||
initParam.page = newParams.page;
|
initParam.page = newParams.page;
|
||||||
initParam.limit = newParams.limit;
|
initParam.limit = newParams.limit;
|
||||||
|
initParam.key = newParams.key ?? "";
|
||||||
return getConfigList(newParams);
|
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[]>([
|
const columns = ref<ColumnProps[]>([
|
||||||
{
|
{
|
||||||
label: "配置编码 / ID",
|
label: "配置编码 / ID",
|
||||||
@@ -112,10 +175,32 @@ const openDrawer = (row: Partial<ConfigList.ResList> = {}) => {
|
|||||||
.el-table .success-row .el-table-fixed-column--left {
|
.el-table .success-row .el-table-fixed-column--left {
|
||||||
background-color: var(--el-color-success-light-9);
|
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 {
|
.config-code-cell {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.config-description-cell {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
254
src/views/system/config/seoAiRules.vue
Normal file
254
src/views/system/config/seoAiRules.vue
Normal 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>
|
||||||
496
src/views/video/metadataMissing/index.vue
Normal file
496
src/views/video/metadataMissing/index.vue
Normal 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>
|
||||||
Reference in New Issue
Block a user