debug
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
:model="drawerProps.row"
|
||||
:hide-required-asterisk="drawerProps.isView"
|
||||
>
|
||||
<el-form-item v-for="e in formColumns" :key="e.label" :label="e.label" :prop="e.prop" :required="e.required">
|
||||
<el-form-item v-for="e in formColumns()" :key="e.label" :label="e.label" :prop="e.prop" :required="e.required">
|
||||
<template v-if="e.type === 'switch'">
|
||||
<el-switch
|
||||
v-model="e.value"
|
||||
@@ -62,7 +62,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="UserDrawer">
|
||||
import { ref, computed } from "vue";
|
||||
import { ref } from "vue";
|
||||
import { ElMessage, FormInstance } from "element-plus";
|
||||
import { ConfigList } from "@/api/interface/ad/config";
|
||||
type Options = {
|
||||
@@ -104,7 +104,7 @@ const generateRules = (fields: Fields[]) => {
|
||||
);
|
||||
};
|
||||
|
||||
const formColumns = computed((): Fields[] => {
|
||||
const formColumns = (): Fields[] => {
|
||||
return [
|
||||
{
|
||||
label: "配置编码",
|
||||
@@ -120,7 +120,7 @@ const formColumns = computed((): Fields[] => {
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: "描述",
|
||||
label: "配置说明",
|
||||
prop: "ggpz_description",
|
||||
type: "text",
|
||||
disabled: true,
|
||||
@@ -145,7 +145,7 @@ const formColumns = computed((): Fields[] => {
|
||||
required: true
|
||||
}
|
||||
];
|
||||
});
|
||||
};
|
||||
const rules = ref();
|
||||
|
||||
interface DrawerProps {
|
||||
@@ -154,6 +154,7 @@ interface DrawerProps {
|
||||
row: Partial<ConfigList.ResList>;
|
||||
api?: (params: any) => Promise<any>;
|
||||
getTableList?: () => void;
|
||||
successMessage?: string;
|
||||
}
|
||||
|
||||
const drawerVisible = ref(false);
|
||||
@@ -163,25 +164,25 @@ const drawerProps = ref<DrawerProps>({
|
||||
row: {}
|
||||
});
|
||||
|
||||
// 接收父组件传过来的参数
|
||||
const acceptParams = (params: DrawerProps) => {
|
||||
drawerProps.value = params;
|
||||
rules.value = generateRules(formColumns.value);
|
||||
rules.value = generateRules(formColumns());
|
||||
drawerVisible.value = true;
|
||||
};
|
||||
|
||||
// 提交数据(新增/编辑)
|
||||
const ruleFormRef = ref<FormInstance>();
|
||||
const handleSubmit = () => {
|
||||
ruleFormRef.value!.validate(async valid => {
|
||||
if (!valid) return;
|
||||
try {
|
||||
await drawerProps.value.api!(drawerProps.value.row);
|
||||
ElMessage.success({ message: `${drawerProps.value.title}成功!` });
|
||||
ElMessage.success({
|
||||
message: drawerProps.value.successMessage || `${drawerProps.value.title}完成,已刷新列表`
|
||||
});
|
||||
drawerProps.value.getTableList!();
|
||||
drawerVisible.value = false;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} catch (_error) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -7,24 +7,11 @@
|
||||
:request-api="getTableList"
|
||||
:data-callback="dataCallback"
|
||||
>
|
||||
<!-- 表格操作 -->
|
||||
<template #tableHeader="scope">
|
||||
<el-button type="primary" style="display: none" :icon="CirclePlus" @click="openDrawer(true)">新增</el-button>
|
||||
<el-button
|
||||
style="display: none"
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
plain
|
||||
:disabled="!scope.isSelected"
|
||||
@click="batchDelete(scope.selectedList)"
|
||||
>
|
||||
批量删除配置
|
||||
</el-button>
|
||||
<template #empty>
|
||||
<el-empty description="暂无广告配置" />
|
||||
</template>
|
||||
<!-- 表格操作 -->
|
||||
<template #operation="scope">
|
||||
<el-button type="primary" :icon="EditPen" @click="openDrawer(false, scope.row)">编辑</el-button>
|
||||
<el-button type="danger" style="display: none" :icon="Delete" @click="deleteAccount(scope.row)">删除</el-button>
|
||||
<el-button type="primary" link :icon="EditPen" @click="openDrawer(scope.row)">编辑</el-button>
|
||||
</template>
|
||||
</ProTable>
|
||||
<Drawer ref="drawerRef" />
|
||||
@@ -33,18 +20,16 @@
|
||||
|
||||
<script lang="tsx" setup name="ConfigList">
|
||||
import { reactive, ref } from "vue";
|
||||
import { useHandleData } from "@/hooks/useHandleData";
|
||||
import ProTable from "@/components/ProTable/index.vue";
|
||||
import { Delete, EditPen, CirclePlus } from "@element-plus/icons-vue";
|
||||
import { EditPen } from "@element-plus/icons-vue";
|
||||
import { ColumnProps, ProTableInstance } from "@/components/ProTable/interface";
|
||||
import { ConfigList } from "@/api/interface/ad/config";
|
||||
import { getList, saveData, deleteItem } from "@/api/modules/ad/config";
|
||||
import { getList, saveData } from "@/api/modules/ad/config";
|
||||
import Drawer from "./drawer.vue";
|
||||
// ProTable 实例
|
||||
const proTable = ref<ProTableInstance>();
|
||||
|
||||
const initParam = reactive({
|
||||
limit: 15,
|
||||
limit: 25,
|
||||
page: 1
|
||||
});
|
||||
|
||||
@@ -65,15 +50,16 @@ const getTableList = (params: any) => {
|
||||
return getList(newParams);
|
||||
};
|
||||
|
||||
// 表格配置项
|
||||
const columns = reactive<ColumnProps<ConfigList.ResList>[]>([
|
||||
{ type: "selection", fixed: "left", width: 50 },
|
||||
const columns = ref<ColumnProps[]>([
|
||||
{
|
||||
prop: "key",
|
||||
label: "查询关键字",
|
||||
label: "配置编码 / ID",
|
||||
isShow: false,
|
||||
search: {
|
||||
el: "input"
|
||||
el: "input",
|
||||
props: {
|
||||
placeholder: "请输入配置编码或 ID"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -83,42 +69,31 @@ const columns = reactive<ColumnProps<ConfigList.ResList>[]>([
|
||||
},
|
||||
{
|
||||
prop: "ggpz_code",
|
||||
label: "配置编码"
|
||||
label: "配置编码",
|
||||
showOverflowTooltip: true
|
||||
},
|
||||
{
|
||||
prop: "ggpz_val",
|
||||
label: "配置值"
|
||||
label: "配置值",
|
||||
showOverflowTooltip: true
|
||||
},
|
||||
{
|
||||
prop: "ggpz_description",
|
||||
label: "描述"
|
||||
label: "配置说明",
|
||||
showOverflowTooltip: true
|
||||
},
|
||||
{ prop: "operation", label: "操作", fixed: "right", width: 150 }
|
||||
{ prop: "operation", label: "操作", fixed: "right", width: 90 }
|
||||
]);
|
||||
|
||||
// 删除信息
|
||||
const deleteAccount = async (params: ConfigList.ResList) => {
|
||||
await useHandleData(deleteItem, { ggpz_id: String(params.ggpz_id) }, "删除所选信息");
|
||||
proTable.value?.getTableList();
|
||||
};
|
||||
|
||||
// 批量删除信息
|
||||
const batchDelete = async (id: any[]) => {
|
||||
const ids = id.map((item: ConfigList.ResList) => item.ggpz_id);
|
||||
await useHandleData(deleteItem, { ggpz_id: ids.join(",") }, "删除所选信息");
|
||||
proTable.value?.clearSelection();
|
||||
proTable.value?.getTableList();
|
||||
};
|
||||
|
||||
// 打开 drawer(新增、编辑)
|
||||
const drawerRef = ref<InstanceType<typeof Drawer> | null>(null);
|
||||
|
||||
const openDrawer = (isEdit: boolean, row: Partial<ConfigList.ResList> = {}) => {
|
||||
const openDrawer = (row: Partial<ConfigList.ResList> = {}) => {
|
||||
const params = {
|
||||
title: isEdit ? "新增配置" : "编辑配置",
|
||||
title: "编辑广告配置",
|
||||
row: { ...row },
|
||||
api: saveData,
|
||||
getTableList: proTable.value?.getTableList
|
||||
getTableList: proTable.value?.getTableList,
|
||||
successMessage: "广告配置保存完成,已刷新列表"
|
||||
};
|
||||
drawerRef.value?.acceptParams(params as any);
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
:model="drawerProps.row"
|
||||
:hide-required-asterisk="drawerProps.isView"
|
||||
>
|
||||
<el-form-item v-for="e in formColumns" :key="e.label" :label="e.label" :prop="e.prop" :required="e.required">
|
||||
<el-form-item v-for="e in formColumns()" :key="e.label" :label="e.label" :prop="e.prop" :required="e.required">
|
||||
<template v-if="e.type === 'switch'">
|
||||
<el-switch
|
||||
v-model="e.value"
|
||||
@@ -62,7 +62,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="UserDrawer">
|
||||
import { ref, computed } from "vue";
|
||||
import { ref } from "vue";
|
||||
import { ElMessage, FormInstance } from "element-plus";
|
||||
import { GroupList } from "@/api/interface/ad/group";
|
||||
type Options = {
|
||||
@@ -103,7 +103,7 @@ const generateRules = (fields: Fields[]) => {
|
||||
);
|
||||
};
|
||||
|
||||
const formColumns = computed((): Fields[] => {
|
||||
const formColumns = (): Fields[] => {
|
||||
return [
|
||||
{
|
||||
label: "分组名称",
|
||||
@@ -130,7 +130,7 @@ const formColumns = computed((): Fields[] => {
|
||||
required: true
|
||||
}
|
||||
];
|
||||
});
|
||||
};
|
||||
const rules = ref();
|
||||
|
||||
interface DrawerProps {
|
||||
@@ -139,6 +139,7 @@ interface DrawerProps {
|
||||
row: Partial<GroupList.ResList>;
|
||||
api?: (params: any) => Promise<any>;
|
||||
getTableList?: () => void;
|
||||
successMessage?: string;
|
||||
}
|
||||
|
||||
const drawerVisible = ref(false);
|
||||
@@ -148,25 +149,25 @@ const drawerProps = ref<DrawerProps>({
|
||||
row: {}
|
||||
});
|
||||
|
||||
// 接收父组件传过来的参数
|
||||
const acceptParams = (params: DrawerProps) => {
|
||||
drawerProps.value = params;
|
||||
rules.value = generateRules(formColumns.value);
|
||||
rules.value = generateRules(formColumns());
|
||||
drawerVisible.value = true;
|
||||
};
|
||||
|
||||
// 提交数据(新增/编辑)
|
||||
const ruleFormRef = ref<FormInstance>();
|
||||
const handleSubmit = () => {
|
||||
ruleFormRef.value!.validate(async valid => {
|
||||
if (!valid) return;
|
||||
try {
|
||||
await drawerProps.value.api!(drawerProps.value.row);
|
||||
ElMessage.success({ message: `${drawerProps.value.title}成功!` });
|
||||
ElMessage.success({
|
||||
message: drawerProps.value.successMessage || `${drawerProps.value.title}完成,已刷新列表`
|
||||
});
|
||||
drawerProps.value.getTableList!();
|
||||
drawerVisible.value = false;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} catch (_error) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
:request-api="getTableList"
|
||||
:data-callback="dataCallback"
|
||||
>
|
||||
<!-- 表格操作 -->
|
||||
<template #empty>
|
||||
<el-empty description="暂无广告分组" />
|
||||
</template>
|
||||
<template #tableHeader="scope">
|
||||
<el-button type="primary" :icon="CirclePlus" @click="openDrawer(true)">新增</el-button>
|
||||
<el-button type="primary" :icon="CirclePlus" @click="openDrawer(true)">新增广告分组</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
@@ -17,13 +19,12 @@
|
||||
:disabled="!scope.isSelected"
|
||||
@click="batchDelete(scope.selectedList)"
|
||||
>
|
||||
批量删除分组
|
||||
批量删除广告分组
|
||||
</el-button>
|
||||
</template>
|
||||
<!-- 表格操作 -->
|
||||
<template #operation="scope">
|
||||
<el-button type="primary" :icon="EditPen" @click="openDrawer(false, scope.row)">编辑</el-button>
|
||||
<el-button type="danger" :icon="Delete" @click="deleteAccount(scope.row)">删除</el-button>
|
||||
<el-button type="primary" link :icon="EditPen" @click="openDrawer(false, scope.row)">编辑</el-button>
|
||||
<el-button type="danger" link :icon="Delete" @click="deleteAccount(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</ProTable>
|
||||
<Drawer ref="drawerRef" />
|
||||
@@ -39,11 +40,10 @@ import { ColumnProps, ProTableInstance } from "@/components/ProTable/interface";
|
||||
import { GroupList } from "@/api/interface/ad/group";
|
||||
import { getList, saveData, deleteItem } from "@/api/modules/ad/group";
|
||||
import Drawer from "./drawer.vue";
|
||||
// ProTable 实例
|
||||
const proTable = ref<ProTableInstance>();
|
||||
|
||||
const initParam = reactive({
|
||||
limit: 15,
|
||||
limit: 25,
|
||||
page: 1
|
||||
});
|
||||
|
||||
@@ -64,15 +64,17 @@ const getTableList = (params: any) => {
|
||||
return getList(newParams);
|
||||
};
|
||||
|
||||
// 表格配置项
|
||||
const columns = reactive<ColumnProps<GroupList.ResList>[]>([
|
||||
const columns = ref<ColumnProps[]>([
|
||||
{ type: "selection", fixed: "left", width: 50 },
|
||||
{
|
||||
prop: "key",
|
||||
label: "查询关键字",
|
||||
label: "分组名称 / 编码 / ID",
|
||||
isShow: false,
|
||||
search: {
|
||||
el: "input"
|
||||
el: "input",
|
||||
props: {
|
||||
placeholder: "请输入分组名称、编码或 ID"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -82,38 +84,43 @@ const columns = reactive<ColumnProps<GroupList.ResList>[]>([
|
||||
},
|
||||
{
|
||||
prop: "ggfz_name",
|
||||
label: "分组名称"
|
||||
label: "分组名称",
|
||||
showOverflowTooltip: true
|
||||
},
|
||||
{
|
||||
prop: "ggfz_code",
|
||||
label: "分组编码"
|
||||
label: "分组编码",
|
||||
showOverflowTooltip: true
|
||||
},
|
||||
{ prop: "operation", label: "操作", fixed: "right", width: 230 }
|
||||
{ prop: "operation", label: "操作", fixed: "right", width: 165 }
|
||||
]);
|
||||
|
||||
// 删除信息
|
||||
const deleteAccount = async (params: GroupList.ResList) => {
|
||||
await useHandleData(deleteItem, { ggfz_id: String(params.ggfz_id) }, "删除所选信息");
|
||||
await useHandleData(
|
||||
deleteItem,
|
||||
{ ggfz_id: String(params.ggfz_id) },
|
||||
`删除广告分组【${params.ggfz_name || params.ggfz_id}】`,
|
||||
"广告分组删除完成,已刷新列表"
|
||||
);
|
||||
proTable.value?.getTableList();
|
||||
};
|
||||
|
||||
// 批量删除信息
|
||||
const batchDelete = async (id: any[]) => {
|
||||
const ids = id.map((item: GroupList.ResList) => item.ggfz_id);
|
||||
await useHandleData(deleteItem, { ggfz_id: ids.join(",") }, "删除所选信息");
|
||||
await useHandleData(deleteItem, { ggfz_id: ids.join(",") }, "批量删除所选广告分组", "广告分组批量删除完成,已刷新列表");
|
||||
proTable.value?.clearSelection();
|
||||
proTable.value?.getTableList();
|
||||
};
|
||||
|
||||
// 打开 drawer(新增、编辑)
|
||||
const drawerRef = ref<InstanceType<typeof Drawer> | null>(null);
|
||||
|
||||
const openDrawer = (isEdit: boolean, row: Partial<GroupList.ResList> = {}) => {
|
||||
const openDrawer = (isCreate: boolean, row: Partial<GroupList.ResList> = {}) => {
|
||||
const params = {
|
||||
title: isEdit ? "新增分组" : "编辑分组",
|
||||
title: isCreate ? "新增广告分组" : "编辑广告分组",
|
||||
row: { ...row },
|
||||
api: saveData,
|
||||
getTableList: proTable.value?.getTableList
|
||||
getTableList: proTable.value?.getTableList,
|
||||
successMessage: isCreate ? "广告分组新增完成,已刷新列表" : "广告分组保存完成,已刷新列表"
|
||||
};
|
||||
drawerRef.value?.acceptParams(params as any);
|
||||
};
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
<template>
|
||||
<el-drawer v-model="drawerVisible" :destroy-on-close="true" size="720" :title="`${drawerProps.title}`">
|
||||
<el-form
|
||||
ref="ruleFormRef"
|
||||
label-width="90px"
|
||||
label-position="top"
|
||||
label-suffix=" :"
|
||||
:rules="rules"
|
||||
:disabled="drawerProps.isView"
|
||||
:model="drawerProps.row"
|
||||
:hide-required-asterisk="drawerProps.isView"
|
||||
>
|
||||
<el-form-item v-for="e in formColumns" :key="e.label" :label="e.label" :prop="e.prop" :required="e.required">
|
||||
<template v-if="e.type === 'switch'">
|
||||
<el-switch
|
||||
v-model="e.value"
|
||||
:active-value="`${1}`"
|
||||
:inactive-value="`${0}`"
|
||||
active-text="上架"
|
||||
inactive-text="下架"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="e.type === 'radio'">
|
||||
<el-radio-group v-model="e.value" class="ml-4">
|
||||
<el-radio v-for="item in e.options" :key="item.value" :value="item.value" size="large">{{
|
||||
item.label
|
||||
}}</el-radio>
|
||||
</el-radio-group>
|
||||
</template>
|
||||
<template v-if="e.type === 'select'">
|
||||
<el-select filterable v-model="e.value" :placeholder="`请选择${e.label}`" clearable>
|
||||
<el-option
|
||||
v-for="(item, index) in e.options"
|
||||
:key="index"
|
||||
:label="getField(item, e.fieldNames?.label)"
|
||||
:value="getField(item, e.fieldNames?.value)"
|
||||
/>
|
||||
</el-select>
|
||||
<div>{{ e.describe }}</div>
|
||||
</template>
|
||||
<template v-if="e.type === 'text'">
|
||||
<el-input v-model="e.value" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
<div>{{ e.describe }}</div>
|
||||
</template>
|
||||
<template v-if="e.type === 'text-i'">
|
||||
<el-input v-model="e.value" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'text-num'">
|
||||
<el-input v-model="e.value" type="number" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'textarea'">
|
||||
<el-input
|
||||
type="textarea"
|
||||
v-model="e.value"
|
||||
maxlength="3650"
|
||||
:placeholder="`请输入${e.label}`"
|
||||
show-word-limit
|
||||
/>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="drawerVisible = false">取消</el-button>
|
||||
<el-button v-show="!drawerProps.isView" type="primary" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="UserDrawer">
|
||||
import { ref, computed } from "vue";
|
||||
import { ElMessage, FormInstance } from "element-plus";
|
||||
import { adList } from "@/api/interface/ad/list";
|
||||
import { getAllList } from "@/api/modules/site/list";
|
||||
type Options = {
|
||||
label: string;
|
||||
value: string | number;
|
||||
};
|
||||
|
||||
const getField = (item: any, fieldName: string | undefined) => {
|
||||
return fieldName ? item[fieldName] : "";
|
||||
};
|
||||
type Fields = {
|
||||
label: string;
|
||||
prop: string;
|
||||
type: string;
|
||||
value: any;
|
||||
required: boolean;
|
||||
options?: Options[];
|
||||
fieldNames?: { label: string; value: string };
|
||||
describe?: string;
|
||||
};
|
||||
const generateRules = (fields: Fields[]) => {
|
||||
return fields.reduce(
|
||||
(acc, field) => {
|
||||
const { type, required, label, prop } = field;
|
||||
let message = "";
|
||||
if (type === "select") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "tag") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "radio") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "text") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "text-i") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "text-num") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "textarea") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "image") {
|
||||
message = `请上传${label}`;
|
||||
}
|
||||
if (type === "image-more") {
|
||||
message = `请上传${label}`;
|
||||
}
|
||||
acc[prop] = [{ required, message }];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>
|
||||
);
|
||||
};
|
||||
|
||||
const getSiteList = ref([] as any);
|
||||
getAllList().then(res => {
|
||||
getSiteList.value = res.data.item;
|
||||
});
|
||||
|
||||
const formColumns = computed((): Fields[] => {
|
||||
return [
|
||||
{
|
||||
label: "源站点",
|
||||
prop: "source_si_id",
|
||||
type: "select",
|
||||
get value() {
|
||||
return drawerProps.value.row!.source_si_id;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.source_si_id = val;
|
||||
},
|
||||
options: getSiteList.value,
|
||||
fieldNames: { label: "si_name", value: "si_id" },
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: "目标站点 ",
|
||||
prop: "target_si_id",
|
||||
type: "select",
|
||||
describe: "注意:目标站点不能跟源站点一样",
|
||||
get value() {
|
||||
return drawerProps.value.row!.target_si_id;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.target_si_id = val;
|
||||
},
|
||||
options: getSiteList.value,
|
||||
fieldNames: { label: "si_name", value: "si_id" },
|
||||
required: true
|
||||
}
|
||||
];
|
||||
});
|
||||
const rules = ref();
|
||||
|
||||
interface DrawerProps {
|
||||
title: string;
|
||||
isView: boolean;
|
||||
row: Partial<adList.List>;
|
||||
api?: (params: any) => Promise<any>;
|
||||
getTableList?: () => void;
|
||||
clearSelection?: () => void;
|
||||
}
|
||||
|
||||
const drawerVisible = ref(false);
|
||||
const drawerProps = ref<DrawerProps>({
|
||||
isView: false,
|
||||
title: "",
|
||||
row: {}
|
||||
});
|
||||
|
||||
// 接收父组件传过来的参数
|
||||
const acceptParams = (params: DrawerProps) => {
|
||||
drawerProps.value = params;
|
||||
rules.value = generateRules(formColumns.value);
|
||||
drawerVisible.value = true;
|
||||
};
|
||||
|
||||
// 提交数据(新增/编辑)
|
||||
const ruleFormRef = ref<FormInstance>();
|
||||
const handleSubmit = () => {
|
||||
ruleFormRef.value!.validate(async valid => {
|
||||
if (!valid) return;
|
||||
try {
|
||||
const params = { ...drawerProps.value.row };
|
||||
if (params.target_si_id == params.source_si_id) {
|
||||
ElMessage.warning({ message: `目标站点不能跟源站点一样!` });
|
||||
return;
|
||||
}
|
||||
|
||||
await drawerProps.value.api!(params);
|
||||
ElMessage.success({ message: `${drawerProps.value.title}成功!` });
|
||||
drawerProps.value.getTableList!();
|
||||
drawerProps.value.clearSelection!();
|
||||
drawerVisible.value = false;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
acceptParams
|
||||
});
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.el-checkbox-button__inner {
|
||||
margin-right: 10px;
|
||||
margin-bottom: 10px;
|
||||
border-left-color: #dcdfe6 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -9,25 +9,6 @@
|
||||
:model="drawerProps.row"
|
||||
:hide-required-asterisk="drawerProps.isView"
|
||||
>
|
||||
<!-- <div class="c-two-columns">
|
||||
<el-form-item style="width: 48%" label="广告标题" prop="gg_wen_zi">
|
||||
<el-input v-model="drawerProps.row.gg_wen_zi" placeholder="请输入广告标题" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 48%" label="类型" prop="gg_lei_xing">
|
||||
<el-radio-group v-model="drawerProps.row.gg_lei_xing" style="margin-top: -4px">
|
||||
<el-radio v-for="item in siteType" :key="item.value" :value="item.value" size="large">{{
|
||||
item.label
|
||||
}}</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 48%" label="是否显示" prop="a_status">
|
||||
<el-radio-group v-model="drawerProps.row.a_status">
|
||||
<el-radio v-for="item in statusList" :key="item.value" :value="item.value" size="large">{{
|
||||
item.label
|
||||
}}</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</div> -->
|
||||
<div class="c-two-columns">
|
||||
<el-form-item style="width: 48%" label="广告标题" prop="gg_wen_zi">
|
||||
<el-input v-model="drawerProps.row.gg_wen_zi" placeholder="请输入广告标题" clearable />
|
||||
@@ -90,6 +71,7 @@ interface DrawerProps {
|
||||
row: Partial<adList.ResList>;
|
||||
api?: (params: any) => Promise<any>;
|
||||
getTableList?: () => void;
|
||||
successMessage?: string;
|
||||
}
|
||||
|
||||
const drawerVisible = ref(false);
|
||||
@@ -99,15 +81,19 @@ const drawerProps = ref<DrawerProps>({
|
||||
row: {}
|
||||
});
|
||||
|
||||
// 接收父组件传过来的参数
|
||||
const acceptParams = (params: DrawerProps) => {
|
||||
drawerProps.value = params;
|
||||
if (drawerProps.value.title === "新增") {
|
||||
drawerProps.value.row.gg_lei_xing ??= 0;
|
||||
drawerProps.value.row.gg_status ??= 0;
|
||||
drawerProps.value.row.gg_sort ??= 0;
|
||||
}
|
||||
drawerVisible.value = true;
|
||||
};
|
||||
|
||||
const idList = ref();
|
||||
|
||||
// 获取分组ID列表
|
||||
|
||||
getAllList().then(res => {
|
||||
idList.value = res.data.items;
|
||||
});
|
||||
@@ -116,33 +102,34 @@ const statusList = reactive([
|
||||
{ value: 0, label: "显示" },
|
||||
{ value: 1, label: "隐藏" }
|
||||
]);
|
||||
// 广告类型:0=图片,1=文字
|
||||
|
||||
const siteType = reactive([
|
||||
{ value: 0, label: "图片" },
|
||||
{ value: 1, label: "文字" }
|
||||
]);
|
||||
|
||||
// /rolling顶部滚动(只支持文字)/top-left(左上)/top-right(右上)/bottom-left(左下)/bottom-right(右下)
|
||||
// const adType = reactive([
|
||||
// { value: "pause", label: "暂停" },
|
||||
// { value: "rolling", label: "顶部滚动" },
|
||||
// { value: "top-left", label: "左上角" },
|
||||
// { value: "top-right", label: "右上角" },
|
||||
// { value: "bottom-left", label: "左下角" },
|
||||
// { value: "bottom-right", label: "右下角" }
|
||||
// ]);
|
||||
// 提交数据(新增/编辑)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const ruleFormRef = ref<FormInstance>();
|
||||
const handleSubmit = () => {
|
||||
ruleFormRef.value!.validate(async valid => {
|
||||
if (!valid) return;
|
||||
try {
|
||||
await drawerProps.value.api!(drawerProps.value.row);
|
||||
ElMessage.success({ message: `${drawerProps.value.title}广告成功!` });
|
||||
ElMessage.success({
|
||||
message: drawerProps.value.successMessage || `${drawerProps.value.title}广告完成,已刷新列表`
|
||||
});
|
||||
drawerProps.value.getTableList!();
|
||||
drawerVisible.value = false;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} catch (_error) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
:request-api="getTableList"
|
||||
:data-callback="dataCallback"
|
||||
>
|
||||
<!-- 表格 header 按钮 -->
|
||||
<template #empty>
|
||||
<el-empty description="暂无广告数据" />
|
||||
</template>
|
||||
<template #tableHeader="scope">
|
||||
<el-button type="primary" :icon="CirclePlus" @click="openDrawer('新增')">新增广告</el-button>
|
||||
<el-button
|
||||
@@ -20,7 +22,6 @@
|
||||
批量删除广告
|
||||
</el-button>
|
||||
</template>
|
||||
<!-- 表格操作 -->
|
||||
<template #operation="scope">
|
||||
<el-button type="primary" link :icon="EditPen" @click="openDrawer('编辑', scope.row)">编辑</el-button>
|
||||
<el-button type="danger" link :icon="Delete" @click="deleteAccount(scope.row)">删除</el-button>
|
||||
@@ -40,19 +41,17 @@ import { useHandleData } from "@/hooks/useHandleData";
|
||||
import { DelApi, ListApi, SaveApi } from "@/api/modules/ad/list";
|
||||
import Drawer from "./components/Drawer.vue";
|
||||
import { adGroupAllListApi } from "@/api/modules/ad/group";
|
||||
<Drawer ref="drawerRef" />;
|
||||
|
||||
// 视频分类
|
||||
|
||||
const adGroupAllList = ref([] as any);
|
||||
adGroupAllListApi().then(res => {
|
||||
adGroupAllList.value = res.data.items;
|
||||
});
|
||||
|
||||
// ProTable 实例
|
||||
const proTable = ref<ProTableInstance>();
|
||||
|
||||
const initParam = reactive({
|
||||
limit: 15,
|
||||
limit: 25,
|
||||
page: 1
|
||||
});
|
||||
|
||||
@@ -73,15 +72,17 @@ const getTableList = (params: any) => {
|
||||
return ListApi(newParams);
|
||||
};
|
||||
|
||||
// 表格配置项
|
||||
const columns = reactive<ColumnProps<adList.ResList>[]>([
|
||||
const columns = ref<ColumnProps[]>([
|
||||
{ type: "selection", fixed: "left", width: 50 },
|
||||
{
|
||||
prop: "key",
|
||||
label: "查询关键字",
|
||||
label: "广告标题 / ID / 分组名称",
|
||||
isShow: false,
|
||||
search: {
|
||||
el: "input"
|
||||
el: "input",
|
||||
props: {
|
||||
placeholder: "请输入广告标题、ID 或分组名称"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -91,7 +92,8 @@ const columns = reactive<ColumnProps<adList.ResList>[]>([
|
||||
},
|
||||
{
|
||||
prop: "gg_wen_zi",
|
||||
label: "广告标题"
|
||||
label: "广告标题",
|
||||
showOverflowTooltip: true
|
||||
},
|
||||
{
|
||||
prop: "gg_sort",
|
||||
@@ -125,17 +127,18 @@ const columns = reactive<ColumnProps<adList.ResList>[]>([
|
||||
},
|
||||
{
|
||||
prop: "ggfz_name",
|
||||
label: "分组"
|
||||
label: "广告分组",
|
||||
showOverflowTooltip: true
|
||||
},
|
||||
{
|
||||
prop: "ggfz_id",
|
||||
label: "分组",
|
||||
label: "广告分组",
|
||||
isShow: false,
|
||||
search: { el: "select", props: { filterable: true } },
|
||||
fieldNames: { label: "ggfz_name", value: "ggfz_id" },
|
||||
enum: adGroupAllList
|
||||
},
|
||||
// 广告类型:0=图片,1=文字
|
||||
|
||||
{
|
||||
prop: "gg_lei_xing",
|
||||
label: "广告类型",
|
||||
@@ -149,7 +152,8 @@ const columns = reactive<ColumnProps<adList.ResList>[]>([
|
||||
},
|
||||
{
|
||||
prop: "gg_tiao_zhuan_di_zhi",
|
||||
label: "跳转地址"
|
||||
label: "跳转地址",
|
||||
showOverflowTooltip: true
|
||||
},
|
||||
{
|
||||
prop: "gg_status",
|
||||
@@ -162,23 +166,25 @@ const columns = reactive<ColumnProps<adList.ResList>[]>([
|
||||
);
|
||||
}
|
||||
},
|
||||
{ prop: "operation", label: "操作", fixed: "right", width: 230 }
|
||||
{ prop: "operation", label: "操作", fixed: "right", width: 165 }
|
||||
]);
|
||||
|
||||
// 删除广告
|
||||
const deleteAccount = async (params: adList.ResList) => {
|
||||
await useHandleData(DelApi, { gg_id: String(params.gg_id) }, `删除所选信息`);
|
||||
await useHandleData(
|
||||
DelApi,
|
||||
{ gg_id: String(params.gg_id) },
|
||||
`删除广告【${params.gg_wen_zi || params.gg_id}】`,
|
||||
"广告删除完成,已刷新列表"
|
||||
);
|
||||
proTable.value?.getTableList();
|
||||
};
|
||||
// 批量删除广告
|
||||
const batchDelete = async (id: any[]) => {
|
||||
const ids = id.map((item: adList.ResList) => item.gg_id);
|
||||
await useHandleData(DelApi, { gg_id: ids.join(",") }, "删除所选信息");
|
||||
await useHandleData(DelApi, { gg_id: ids.join(",") }, "批量删除所选广告", "广告批量删除完成,已刷新列表");
|
||||
proTable.value?.clearSelection();
|
||||
proTable.value?.getTableList();
|
||||
};
|
||||
|
||||
// 打开 drawer(新增、查看、编辑)
|
||||
const drawerRef = ref<InstanceType<typeof Drawer> | null>(null);
|
||||
const openDrawer = (title: string, row: Partial<adList.ResList> = {}) => {
|
||||
const params = {
|
||||
@@ -186,7 +192,8 @@ const openDrawer = (title: string, row: Partial<adList.ResList> = {}) => {
|
||||
api: SaveApi,
|
||||
getTableList: proTable.value?.getTableList,
|
||||
isView: title === "查看",
|
||||
row: { ...row }
|
||||
row: { ...row },
|
||||
successMessage: title === "新增" ? "广告新增完成,已刷新列表" : "广告保存完成,已刷新列表"
|
||||
};
|
||||
drawerRef.value?.acceptParams(params);
|
||||
};
|
||||
|
||||
@@ -1,294 +0,0 @@
|
||||
<template>
|
||||
<el-drawer v-model="drawerVisible" :destroy-on-close="true" size="720" :title="`${drawerProps.title}`">
|
||||
<el-form
|
||||
ref="ruleFormRef"
|
||||
label-width="80px"
|
||||
label-suffix=" :"
|
||||
:rules="rules"
|
||||
:disabled="drawerProps.isView"
|
||||
:model="drawerProps.row"
|
||||
:hide-required-asterisk="drawerProps.isView"
|
||||
>
|
||||
<el-form-item v-for="e in formColumns" :key="e.label" :label="e.label" :prop="e.prop" :required="e.required">
|
||||
<template v-if="e.type === 'switch'">
|
||||
<el-switch
|
||||
v-model="e.value"
|
||||
:active-value="`${1}`"
|
||||
:inactive-value="`${0}`"
|
||||
active-text="上架"
|
||||
inactive-text="下架"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="e.type === 'radio'">
|
||||
<el-radio-group v-model="e.value" class="ml-4">
|
||||
<el-radio v-for="item in e.options" :key="item.value" :value="item.value" size="large">{{
|
||||
item.label
|
||||
}}</el-radio>
|
||||
</el-radio-group>
|
||||
</template>
|
||||
<template v-if="e.type === 'select'">
|
||||
<el-select filterable v-model="e.value" :placeholder="`请选择${e.label}`" clearable>
|
||||
<el-option
|
||||
v-for="(item, index) in e.options"
|
||||
:key="index"
|
||||
:label="getField(item, e.fieldNames?.label)"
|
||||
:value="getField(item, e.fieldNames?.value)"
|
||||
/>
|
||||
</el-select>
|
||||
<div>{{ e.describe }}</div>
|
||||
</template>
|
||||
<template v-if="e.type === 'text'">
|
||||
<el-input v-model="e.value" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'text-i'">
|
||||
<el-input v-model="e.value" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'text-num'">
|
||||
<el-input v-model="e.value" type="number" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'textarea'">
|
||||
<el-input
|
||||
type="textarea"
|
||||
v-model="e.value"
|
||||
maxlength="3650"
|
||||
:placeholder="`请输入${e.label}`"
|
||||
show-word-limit
|
||||
/>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="drawerVisible = false">取消</el-button>
|
||||
<el-button v-show="!drawerProps.isView" type="primary" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="UserDrawer">
|
||||
import { ref, computed, reactive } from "vue";
|
||||
import { ElMessage, FormInstance } from "element-plus";
|
||||
import { adList } from "@/api/interface/ad/list";
|
||||
import { arrAdType } from "@/utils/serviceDict";
|
||||
import { getAllList } from "@/api/modules/site/list";
|
||||
type Options = {
|
||||
label: string;
|
||||
value: string | number;
|
||||
};
|
||||
|
||||
const getField = (item: any, fieldName: string | undefined) => {
|
||||
return fieldName ? item[fieldName] : "";
|
||||
};
|
||||
type Fields = {
|
||||
label: string;
|
||||
prop: string;
|
||||
type: string;
|
||||
value: any;
|
||||
required: boolean;
|
||||
options?: Options[];
|
||||
fieldNames?: { label: string; value: string };
|
||||
describe?: string;
|
||||
};
|
||||
const generateRules = (fields: Fields[]) => {
|
||||
return fields.reduce(
|
||||
(acc, field) => {
|
||||
const { type, required, label, prop } = field;
|
||||
let message = "";
|
||||
if (type === "select") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "tag") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "radio") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "text") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "text-i") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "text-num") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "textarea") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "image") {
|
||||
message = `请上传${label}`;
|
||||
}
|
||||
if (type === "image-more") {
|
||||
message = `请上传${label}`;
|
||||
}
|
||||
acc[prop] = [{ required, message }];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>
|
||||
);
|
||||
};
|
||||
const getSiteList = ref([] as any);
|
||||
getAllList().then(res => {
|
||||
getSiteList.value = res.data.item;
|
||||
});
|
||||
|
||||
const statusList = reactive([
|
||||
{ value: 0, label: "隐藏" },
|
||||
{ value: 1, label: "显示" }
|
||||
]);
|
||||
const formColumns = computed((): Fields[] => {
|
||||
return [
|
||||
{
|
||||
label: "站点",
|
||||
prop: "si_id",
|
||||
type: "select",
|
||||
describe: "注意:批量选择广告修改只会改变这里选中的站点广告",
|
||||
get value() {
|
||||
return drawerProps.value.row!.si_id;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.si_id = val;
|
||||
},
|
||||
options: getSiteList.value,
|
||||
fieldNames: { label: "si_name", value: "si_id" },
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: "广告标题",
|
||||
prop: "a_title",
|
||||
type: "text",
|
||||
get value() {
|
||||
return drawerProps.value.row!.a_title;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.a_title = val;
|
||||
},
|
||||
required: false
|
||||
},
|
||||
{
|
||||
label: "封面URL",
|
||||
prop: "cover",
|
||||
type: "text",
|
||||
get value() {
|
||||
return drawerProps.value.row!.cover;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.cover = val;
|
||||
},
|
||||
required: false
|
||||
},
|
||||
{
|
||||
label: "跳转URL",
|
||||
prop: "target",
|
||||
type: "text",
|
||||
get value() {
|
||||
return drawerProps.value.row!.target;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.target = val;
|
||||
},
|
||||
required: false
|
||||
},
|
||||
{
|
||||
label: "跳转类型",
|
||||
prop: "link_type",
|
||||
type: "radio",
|
||||
get value() {
|
||||
return drawerProps.value.row!.link_type;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.link_type = val;
|
||||
},
|
||||
options: arrAdType,
|
||||
fieldNames: { label: "label", value: "value" },
|
||||
required: false
|
||||
},
|
||||
{
|
||||
label: "是否显示",
|
||||
prop: "a_status",
|
||||
type: "radio",
|
||||
get value() {
|
||||
return drawerProps.value.row!.a_status;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.a_status = val;
|
||||
},
|
||||
options: statusList,
|
||||
fieldNames: { label: "label", value: "value" },
|
||||
required: false
|
||||
}
|
||||
];
|
||||
});
|
||||
const rules = ref();
|
||||
|
||||
interface DrawerProps {
|
||||
title: string;
|
||||
isView: boolean;
|
||||
row: Partial<adList.List>;
|
||||
api?: (params: any) => Promise<any>;
|
||||
getTableList?: () => void;
|
||||
clearSelection?: () => void;
|
||||
}
|
||||
|
||||
const drawerVisible = ref(false);
|
||||
const drawerProps = ref<DrawerProps>({
|
||||
isView: false,
|
||||
title: "",
|
||||
row: {}
|
||||
});
|
||||
|
||||
// 接收父组件传过来的参数
|
||||
const acceptParams = (params: DrawerProps) => {
|
||||
drawerProps.value = params;
|
||||
rules.value = generateRules(formColumns.value);
|
||||
drawerVisible.value = true;
|
||||
};
|
||||
|
||||
// 检查对象中除了 a_id 以外的其他字段是否都为空
|
||||
function isOtherFieldsEmpty(obj): boolean {
|
||||
// 如果 link_type 的值为 '0' 或 '1',则不做限制
|
||||
if (obj.link_type === 0 || obj.link_type === 1) {
|
||||
return false;
|
||||
}
|
||||
if (obj.a_status === 0 || obj.a_status === 1) {
|
||||
return false;
|
||||
}
|
||||
// 获取除了 a_id 以外的其他字段
|
||||
const otherFields = Object.keys(obj).filter(key => key !== "a_id" && key !== "si_id");
|
||||
// 检查这些字段是否都为空
|
||||
return otherFields.every(field => !obj[field]);
|
||||
}
|
||||
|
||||
// 提交数据(新增/编辑)
|
||||
const ruleFormRef = ref<FormInstance>();
|
||||
const handleSubmit = () => {
|
||||
ruleFormRef.value!.validate(async valid => {
|
||||
if (!valid) return;
|
||||
try {
|
||||
const params = { ...drawerProps.value.row };
|
||||
// 检查示例对象
|
||||
if (isOtherFieldsEmpty(params)) {
|
||||
ElMessage.warning({ message: `字段至少有一个值不能为空!` });
|
||||
return;
|
||||
}
|
||||
await drawerProps.value.api!(params);
|
||||
ElMessage.success({ message: `${drawerProps.value.title}成功!` });
|
||||
drawerProps.value.getTableList!();
|
||||
drawerProps.value.clearSelection!();
|
||||
drawerVisible.value = false;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
acceptParams
|
||||
});
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.el-checkbox-button__inner {
|
||||
margin-right: 10px;
|
||||
margin-bottom: 10px;
|
||||
border-left-color: #dcdfe6 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,247 +0,0 @@
|
||||
<template>
|
||||
<el-drawer v-model="drawerVisible" :destroy-on-close="true" size="720" :title="`${drawerProps.title}`">
|
||||
<el-form
|
||||
ref="ruleFormRef"
|
||||
label-width="80px"
|
||||
label-suffix=" :"
|
||||
:rules="rules"
|
||||
:disabled="drawerProps.isView"
|
||||
:model="drawerProps.row"
|
||||
:hide-required-asterisk="drawerProps.isView"
|
||||
>
|
||||
<el-form-item v-for="e in formColumns" :key="e.label" :label="e.label" :prop="e.prop" :required="e.required">
|
||||
<template v-if="e.type === 'switch'">
|
||||
<el-switch
|
||||
v-model="e.value"
|
||||
:active-value="`${1}`"
|
||||
:inactive-value="`${0}`"
|
||||
active-text="上架"
|
||||
inactive-text="下架"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="e.type === 'radio'">
|
||||
<el-radio-group v-model="e.value" class="ml-4">
|
||||
<el-radio v-for="item in e.options" :key="item.value" :value="item.value" size="large">{{
|
||||
item.label
|
||||
}}</el-radio>
|
||||
</el-radio-group>
|
||||
</template>
|
||||
<template v-if="e.type === 'select'">
|
||||
<el-select filterable v-model="e.value" :placeholder="`请选择${e.label}`" clearable>
|
||||
<el-option
|
||||
v-for="(item, index) in e.options"
|
||||
:key="index"
|
||||
:label="getField(item, e.fieldNames?.label)"
|
||||
:value="getField(item, e.fieldNames?.value)"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
<template v-if="e.type === 'text'">
|
||||
<el-input v-model="e.value" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
<div>{{ e.describe }}</div>
|
||||
</template>
|
||||
<template v-if="e.type === 'text-i'">
|
||||
<el-input v-model="e.value" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'text-num'">
|
||||
<el-input v-model="e.value" type="number" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'textarea'">
|
||||
<el-input
|
||||
type="textarea"
|
||||
v-model="e.value"
|
||||
maxlength="3650"
|
||||
:placeholder="`请输入${e.label}`"
|
||||
show-word-limit
|
||||
/>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="drawerVisible = false">取消</el-button>
|
||||
<el-button v-show="!drawerProps.isView" type="primary" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="UserDrawer">
|
||||
import { ref, computed } from "vue";
|
||||
import { ElMessage, FormInstance } from "element-plus";
|
||||
import { adList } from "@/api/interface/ad/list";
|
||||
import { getAllList } from "@/api/modules/site/list";
|
||||
type Options = {
|
||||
label: string;
|
||||
value: string | number;
|
||||
};
|
||||
|
||||
const getField = (item: any, fieldName: string | undefined) => {
|
||||
return fieldName ? item[fieldName] : "";
|
||||
};
|
||||
type Fields = {
|
||||
label: string;
|
||||
prop: string;
|
||||
type: string;
|
||||
value: any;
|
||||
required: boolean;
|
||||
options?: Options[];
|
||||
fieldNames?: { label: string; value: string };
|
||||
describe?: string;
|
||||
};
|
||||
const generateRules = (fields: Fields[]) => {
|
||||
return fields.reduce(
|
||||
(acc, field) => {
|
||||
const { type, required, label, prop } = field;
|
||||
let message = "";
|
||||
if (type === "select") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "tag") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "radio") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "text") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "text-i") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "text-num") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "textarea") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "image") {
|
||||
message = `请上传${label}`;
|
||||
}
|
||||
if (type === "image-more") {
|
||||
message = `请上传${label}`;
|
||||
}
|
||||
acc[prop] = [{ required, message }];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>
|
||||
);
|
||||
};
|
||||
|
||||
const getSiteList = ref([] as any);
|
||||
getAllList().then(res => {
|
||||
getSiteList.value = res.data.item;
|
||||
});
|
||||
const formColumns = computed((): Fields[] => {
|
||||
return [
|
||||
{
|
||||
label: "站 点",
|
||||
prop: "si_id",
|
||||
type: "select",
|
||||
get value() {
|
||||
return drawerProps.value.row!.si_id;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.si_id = val;
|
||||
},
|
||||
options: getSiteList.value,
|
||||
fieldNames: { label: "si_name", value: "si_id" },
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: "字 段",
|
||||
prop: "column",
|
||||
type: "select",
|
||||
get value() {
|
||||
return drawerProps.value.row!.column;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.column = val;
|
||||
},
|
||||
options: [
|
||||
{ label: "封面URL", value: "cover" },
|
||||
{ label: "跳转URL", value: "target" }
|
||||
],
|
||||
fieldNames: { label: "label", value: "value" },
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: "旧内容",
|
||||
prop: "from",
|
||||
type: "text",
|
||||
describe: "",
|
||||
get value() {
|
||||
return drawerProps.value.row!.from;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.from = val;
|
||||
},
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: "新内容",
|
||||
prop: "to",
|
||||
type: "text",
|
||||
describe: "",
|
||||
get value() {
|
||||
return drawerProps.value.row!.to;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.to = val;
|
||||
},
|
||||
required: true
|
||||
}
|
||||
];
|
||||
});
|
||||
const rules = ref();
|
||||
|
||||
interface DrawerProps {
|
||||
title: string;
|
||||
isView: boolean;
|
||||
row: Partial<adList.List>;
|
||||
api?: (params: any) => Promise<any>;
|
||||
getTableList?: () => void;
|
||||
clearSelection?: () => void;
|
||||
}
|
||||
|
||||
const drawerVisible = ref(false);
|
||||
const drawerProps = ref<DrawerProps>({
|
||||
isView: false,
|
||||
title: "",
|
||||
row: {}
|
||||
});
|
||||
|
||||
// 接收父组件传过来的参数
|
||||
const acceptParams = (params: DrawerProps) => {
|
||||
drawerProps.value = params;
|
||||
rules.value = generateRules(formColumns.value);
|
||||
drawerVisible.value = true;
|
||||
};
|
||||
|
||||
// 提交数据(新增/编辑)
|
||||
const ruleFormRef = ref<FormInstance>();
|
||||
const handleSubmit = () => {
|
||||
ruleFormRef.value!.validate(async valid => {
|
||||
if (!valid) return;
|
||||
try {
|
||||
const params = { ...drawerProps.value.row };
|
||||
await drawerProps.value.api!(params);
|
||||
ElMessage.success({ message: `${drawerProps.value.title}成功!` });
|
||||
drawerProps.value.getTableList!();
|
||||
drawerProps.value.clearSelection!();
|
||||
drawerVisible.value = false;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
acceptParams
|
||||
});
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.el-checkbox-button__inner {
|
||||
margin-right: 10px;
|
||||
margin-bottom: 10px;
|
||||
border-left-color: #dcdfe6 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,52 +1,26 @@
|
||||
<template>
|
||||
<div class="table-box">
|
||||
<ProTable ref="proTable" highlight-current-row :columns="columns" :request-api="ListApi"></ProTable>
|
||||
<div class="module-unavailable">
|
||||
<el-alert title="广告模板列表未部署到当前 Linux 测试服" type="warning" :closable="false" show-icon />
|
||||
<el-card class="module-unavailable__card" shadow="never">
|
||||
<p>当前前端旧页面仍指向 `admin/ad/template/list`,实测会直接返回控制器不存在。</p>
|
||||
<p>`ad/group`、`ad/list`、`ad/config` 仍属于当前测试服广告主线,但 `ad/template` 不在现有后端路由中。</p>
|
||||
<p>本页先收为说明态,避免继续暴露会误导验收的假列表。</p>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="tsx" setup name="adTemplate">
|
||||
import { adTemplate } from "@/api/interface/ad/list";
|
||||
import ProTable from "@/components/ProTable/index.vue";
|
||||
import { ColumnProps, ProTableInstance } from "@/components/ProTable/interface";
|
||||
import { reactive, ref } from "vue";
|
||||
import { ListApi } from "@/api/modules/ad/template";
|
||||
<style scoped lang="scss">
|
||||
.module-unavailable {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
// ProTable 实例
|
||||
const proTable = ref<ProTableInstance>();
|
||||
.module-unavailable__card {
|
||||
color: var(--el-text-color-regular);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
// 表格配置项
|
||||
const columns = reactive<ColumnProps<adTemplate.List>[]>([
|
||||
{
|
||||
prop: "key",
|
||||
label: "查询关键字",
|
||||
isShow: false,
|
||||
search: {
|
||||
el: "input"
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: "at_id",
|
||||
label: "ID",
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
prop: "at_name",
|
||||
label: "模板名称"
|
||||
},
|
||||
{
|
||||
prop: "at_code",
|
||||
label: "模板编码"
|
||||
},
|
||||
|
||||
{
|
||||
prop: "at_description",
|
||||
label: "模板描述"
|
||||
},
|
||||
{
|
||||
prop: "created_at",
|
||||
label: "添加时间"
|
||||
}
|
||||
]);
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
.module-unavailable__card p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,390 +0,0 @@
|
||||
<template>
|
||||
<el-drawer v-model="drawerVisible" :destroy-on-close="true" size="720" :title="`${drawerProps.title}`">
|
||||
<el-form
|
||||
ref="ruleFormRef"
|
||||
label-width="100px"
|
||||
label-suffix=" :"
|
||||
:rules="rules"
|
||||
:disabled="drawerProps.isView"
|
||||
:model="drawerProps.row"
|
||||
:hide-required-asterisk="drawerProps.isView"
|
||||
>
|
||||
<el-form-item v-for="e in formColumns" :key="e.label" :label="e.label" :prop="e.prop" :required="e.required">
|
||||
<template v-if="e.type === 'switch'">
|
||||
<el-switch
|
||||
v-model="e.value"
|
||||
:active-value="`${1}`"
|
||||
:inactive-value="`${0}`"
|
||||
active-text="上架"
|
||||
inactive-text="下架"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="e.type === 'radio'">
|
||||
<el-radio-group v-model="e.value" class="ml-4">
|
||||
<el-radio v-for="item in e.options" :key="item.value" :value="item.value" size="large">{{
|
||||
item.label
|
||||
}}</el-radio>
|
||||
</el-radio-group>
|
||||
</template>
|
||||
<template v-if="e.type === 'select'">
|
||||
<el-select filterable multiple v-model="e.value" :placeholder="`请选择${e.label}`" clearable>
|
||||
<el-option
|
||||
v-for="(item, index) in e.options"
|
||||
:key="index"
|
||||
:label="getField(item, e.fieldNames?.label)"
|
||||
:value="getField(item, e.fieldNames?.value)"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
<template v-if="e.type === 'text'">
|
||||
<el-input v-model="e.value" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'text-i'">
|
||||
<el-input v-model="e.value" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'text-num'">
|
||||
<el-input v-model="e.value" type="number" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'textarea-i'">
|
||||
<el-input
|
||||
type="textarea"
|
||||
v-model="e.value"
|
||||
maxlength="36500"
|
||||
:autosize="{ minRows: 8, maxRows: 9 }"
|
||||
:placeholder="`请输入${e.label}`"
|
||||
show-word-limit
|
||||
/>
|
||||
<div style="width: 100%">{{ e.describe }}</div>
|
||||
</template>
|
||||
<template v-if="e.type === 'textarea'">
|
||||
<el-input
|
||||
type="textarea"
|
||||
v-model="e.value"
|
||||
maxlength="3650"
|
||||
:placeholder="`请输入${e.label}`"
|
||||
show-word-limit
|
||||
/>
|
||||
</template>
|
||||
<template v-if="e.type === 'tag'">
|
||||
<div class="tag-more-h">
|
||||
<el-checkbox-group v-model="TagList" @change="placeableCheckedCitiesChange">
|
||||
<el-checkbox-button class="vip-power" v-for="item in getTagList" :value="item.t_id" :key="item.t_id">
|
||||
{{ item.t_name }}
|
||||
</el-checkbox-button>
|
||||
</el-checkbox-group>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="e.type === 'text-i'">
|
||||
<UploadImg disabled v-model:image-url="drawerProps.row!.at_cover" width="135px" height="135px" :file-size="3">
|
||||
<template #empty>
|
||||
<el-icon><Avatar /></el-icon>
|
||||
<span>请上传封面</span>
|
||||
</template>
|
||||
</UploadImg>
|
||||
</template>
|
||||
<template v-if="e.type === 'textarea-i'">
|
||||
<div :class="{ 'img-more-h': fileList.length > 0 }">
|
||||
<UploadImgs disabled v-model:file-list="fileList" :drag="false" border-radius="50%">
|
||||
<template #empty>
|
||||
<el-icon><Picture /></el-icon>
|
||||
<span>请上传照片</span>
|
||||
</template>
|
||||
</UploadImgs>
|
||||
</div>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="drawerVisible = false">取消</el-button>
|
||||
<el-button v-show="!drawerProps.isView" type="primary" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="UserDrawer">
|
||||
import { ref, computed } from "vue";
|
||||
import { ElMessage, FormInstance } from "element-plus";
|
||||
import { AtlasList } from "@/api/interface/atlas/list";
|
||||
import UploadImgs from "@/components/Upload/Imgs.vue";
|
||||
import UploadImg from "@/components/Upload/Img.vue";
|
||||
import { getTypeList } from "@/api/modules/tag/list";
|
||||
import { extractURI } from "@/utils/eleValidate";
|
||||
import { getAllList } from "@/api/modules/site/list";
|
||||
type Options = {
|
||||
label: string;
|
||||
value: string | number;
|
||||
};
|
||||
interface Item {
|
||||
t_id: any;
|
||||
uri: any;
|
||||
}
|
||||
const getField = (item: any, fieldName: string | undefined) => {
|
||||
return fieldName ? item[fieldName] : "";
|
||||
};
|
||||
type Fields = {
|
||||
label: string;
|
||||
prop: string;
|
||||
type: string;
|
||||
value: any;
|
||||
required: boolean;
|
||||
options?: Options[];
|
||||
fieldNames?: { label: string; value: string };
|
||||
describe?: string;
|
||||
};
|
||||
const generateRules = (fields: Fields[]) => {
|
||||
return fields.reduce(
|
||||
(acc, field) => {
|
||||
const { type, required, label, prop } = field;
|
||||
let message = "";
|
||||
if (type === "select") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "tag") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "radio") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "text") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "text-num") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "text-i") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "textarea-i") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "image") {
|
||||
message = `请上传${label}`;
|
||||
}
|
||||
if (type === "image-more") {
|
||||
message = `请上传${label}`;
|
||||
}
|
||||
acc[prop] = [{ required, message }];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>
|
||||
);
|
||||
};
|
||||
|
||||
const fileList = ref([] as any);
|
||||
const TagList = ref([] as any);
|
||||
const getTagList = ref([] as any);
|
||||
getTypeList({ limit: 5000, page: 1, t_type: 2, t_hidden: 1 }).then(res => {
|
||||
getTagList.value = res.data.item;
|
||||
});
|
||||
const getSiteList = ref([] as any);
|
||||
getAllList().then(res => {
|
||||
getSiteList.value = res.data.item;
|
||||
});
|
||||
|
||||
const formColumns = computed((): Fields[] => {
|
||||
let newColumns = [] as any;
|
||||
let columns = [
|
||||
{
|
||||
label: "名称",
|
||||
prop: "at_name",
|
||||
type: "text",
|
||||
get value() {
|
||||
return drawerProps.value.row!.at_name;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.at_name = val;
|
||||
},
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: "状态",
|
||||
prop: "at_status",
|
||||
type: "switch",
|
||||
get value() {
|
||||
return String(drawerProps.value.row!.at_status);
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.at_status = val;
|
||||
},
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: "精彩站点",
|
||||
prop: "at_wonderful_site_id",
|
||||
type: "select",
|
||||
get value() {
|
||||
return drawerProps.value.row!.at_wonderful_site_id;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.at_wonderful_site_id = val;
|
||||
},
|
||||
options: getSiteList.value,
|
||||
fieldNames: { label: "si_name", value: "si_id" },
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: "推荐站点",
|
||||
prop: "at_recommend_site_id",
|
||||
type: "select",
|
||||
get value() {
|
||||
return drawerProps.value.row!.at_recommend_site_id;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.at_recommend_site_id = val;
|
||||
},
|
||||
options: getSiteList.value,
|
||||
fieldNames: { label: "si_name", value: "si_id" },
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: "封面",
|
||||
prop: "at_cover",
|
||||
type: "text-i",
|
||||
get value() {
|
||||
return drawerProps.value.row!.at_cover;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.at_cover = val;
|
||||
},
|
||||
required: true
|
||||
},
|
||||
|
||||
{
|
||||
label: "标签",
|
||||
prop: "at_tag",
|
||||
type: "tag",
|
||||
get value() {
|
||||
return drawerProps.value.row!.at_tag;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.at_tag = val;
|
||||
},
|
||||
options: getTagList.value,
|
||||
fieldNames: { label: "label", value: "value" },
|
||||
required: true
|
||||
}
|
||||
];
|
||||
newColumns = [...columns];
|
||||
if (drawerProps.value.title === "编辑图册") {
|
||||
let columnsSon = [
|
||||
{
|
||||
label: "图册资源",
|
||||
prop: "at_source",
|
||||
type: "textarea-i",
|
||||
describe: "多个用逗号(,)隔开",
|
||||
get value() {
|
||||
return drawerProps.value.row!.at_source;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.at_source = val;
|
||||
},
|
||||
required: false
|
||||
}
|
||||
];
|
||||
newColumns.splice(6, 0, ...columnsSon);
|
||||
}
|
||||
|
||||
return newColumns;
|
||||
});
|
||||
const rules = ref();
|
||||
|
||||
interface DrawerProps {
|
||||
title: string;
|
||||
isView: boolean;
|
||||
row: Partial<AtlasList.ResList>;
|
||||
api?: (params: any) => Promise<any>;
|
||||
getTableList?: () => void;
|
||||
}
|
||||
|
||||
const drawerVisible = ref(false);
|
||||
const drawerProps = ref<DrawerProps>({
|
||||
isView: false,
|
||||
title: "",
|
||||
row: {}
|
||||
});
|
||||
|
||||
// 接收父组件传过来的参数
|
||||
const acceptParams = (params: DrawerProps) => {
|
||||
drawerProps.value = params;
|
||||
rules.value = generateRules(formColumns.value);
|
||||
drawerVisible.value = true;
|
||||
if (drawerProps.value.title === "新增图册") {
|
||||
drawerProps.value.row!.at_status = 0;
|
||||
drawerProps.value.row!.at_status = 0;
|
||||
}
|
||||
TagList.value = [];
|
||||
fileList.value = [];
|
||||
if (drawerProps.value.row.at_cover) {
|
||||
drawerProps.value.row.at_cover_code = drawerProps.value.row.at_cover.code;
|
||||
const data = drawerProps.value.row.at_cover_detail;
|
||||
drawerProps.value.row.at_cover = data;
|
||||
}
|
||||
if (drawerProps.value.row.at_tag) {
|
||||
TagList.value = drawerProps.value.row.at_tag.map((obj: Item) => {
|
||||
obj.t_id = obj.t_id;
|
||||
return obj.t_id;
|
||||
});
|
||||
}
|
||||
if (drawerProps.value.row.at_source) {
|
||||
const data = drawerProps.value.row.at_source_detail;
|
||||
drawerProps.value.row.at_source = data;
|
||||
const newArray = data.map(item => ({ url: item }));
|
||||
fileList.value = newArray;
|
||||
}
|
||||
};
|
||||
|
||||
//标签数组
|
||||
async function placeableCheckedCitiesChange(value) {
|
||||
drawerProps.value.row.at_tag = value;
|
||||
TagList.value = value;
|
||||
drawerProps.value.row.at_tag = JSON.stringify(drawerProps.value.row.at_tag);
|
||||
}
|
||||
// 提交数据(新增/编辑)
|
||||
const ruleFormRef = ref<FormInstance>();
|
||||
const handleSubmit = () => {
|
||||
ruleFormRef.value!.validate(async valid => {
|
||||
if (!valid) return;
|
||||
try {
|
||||
const params = { ...drawerProps.value.row };
|
||||
let arrCoverData = {
|
||||
uri: extractURI(params.at_cover),
|
||||
code: params.at_cover_code
|
||||
};
|
||||
params.at_cover = arrCoverData;
|
||||
params.at_tag = JSON.stringify(TagList.value);
|
||||
if (drawerProps.value.title === "编辑图册") {
|
||||
if (Array.isArray(params.at_source)) {
|
||||
params.at_source = params.at_source.map(uri => ({ uri: extractURI(uri), code: params.at_cover_code }));
|
||||
} else if (params.at_source != null) {
|
||||
params.at_source = params.at_source.split(",");
|
||||
params.at_source = params.at_source.map(uri => ({ uri: extractURI(uri), code: params.at_cover_code }));
|
||||
}
|
||||
}
|
||||
await drawerProps.value.api!(params);
|
||||
ElMessage.success({ message: `${drawerProps.value.title}成功!` });
|
||||
drawerProps.value.getTableList!();
|
||||
drawerVisible.value = false;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
acceptParams
|
||||
});
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.el-checkbox-button__inner {
|
||||
margin-right: 10px;
|
||||
margin-bottom: 10px;
|
||||
border-left-color: #dcdfe6 !important;
|
||||
}
|
||||
.el-upload--picture-card {
|
||||
display: none;
|
||||
}
|
||||
.img-more-h {
|
||||
height: 300px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
</style>
|
||||
@@ -1,503 +0,0 @@
|
||||
<template>
|
||||
<el-drawer v-model="drawerVisible" :destroy-on-close="true" size="720" :title="`${drawerProps.title}`">
|
||||
<el-form
|
||||
ref="ruleFormRef"
|
||||
label-width="90px"
|
||||
label-suffix=" :"
|
||||
:rules="rules"
|
||||
:disabled="drawerProps.isView"
|
||||
:model="drawerProps.row"
|
||||
:hide-required-asterisk="drawerProps.isView"
|
||||
>
|
||||
<el-form-item v-for="e in formColumns" :key="e.label" :label="e.label" :prop="e.prop" :required="e.required">
|
||||
<template v-if="e.type === 'switch'">
|
||||
<el-switch
|
||||
v-model="e.value"
|
||||
:active-value="`${1}`"
|
||||
:inactive-value="`${0}`"
|
||||
active-text="上架"
|
||||
inactive-text="下架"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="e.type === 'radio'">
|
||||
<el-radio-group v-model="e.value" class="ml-4">
|
||||
<el-radio v-for="item in e.options" :key="item.value" :value="item.value" size="large">{{
|
||||
item.label
|
||||
}}</el-radio>
|
||||
</el-radio-group>
|
||||
</template>
|
||||
<template v-if="e.type === 'select'">
|
||||
<el-select filterable multiple v-model="e.value" :placeholder="`请选择${e.label}`" clearable>
|
||||
<el-option
|
||||
v-for="(item, index) in e.options"
|
||||
:key="index"
|
||||
:label="getField(item, e.fieldNames?.label)"
|
||||
:value="getField(item, e.fieldNames?.value)"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
<template v-if="e.type === 'text'">
|
||||
<el-input v-model="e.value" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'text-i'">
|
||||
<el-input v-model="e.value" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'text-num'">
|
||||
<el-input v-model="e.value" type="number" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'textarea-i'">
|
||||
<el-input
|
||||
|
||||
v-model="e.value"
|
||||
maxlength="36500"
|
||||
:autosize="{ minRows: 8, maxRows: 9 }"
|
||||
:placeholder="`请输入${e.label}`"
|
||||
show-word-limit
|
||||
/>
|
||||
<div style="width: 100%">{{ e.describe }}</div>
|
||||
</template>
|
||||
<template v-if="e.type === 'textarea'">
|
||||
<el-input
|
||||
type="textarea"
|
||||
v-model="e.value"
|
||||
maxlength="3650"
|
||||
:placeholder="`请输入${e.label}`"
|
||||
show-word-limit
|
||||
/>
|
||||
</template>
|
||||
<template v-if="e.type === 'tag'">
|
||||
<div class="tag-more-s">
|
||||
<el-input
|
||||
v-model="drawerProps.row.searchText"
|
||||
placeholder="请输入搜索内容"
|
||||
clearable
|
||||
@input="handleSearch"
|
||||
/>
|
||||
<div>
|
||||
<span>选中标签:</span>
|
||||
<div class="tag-more-h">
|
||||
<el-checkbox-group v-model="TagList" @change="placeableCheckedCitiesChange">
|
||||
<el-checkbox-button
|
||||
class="vip-power"
|
||||
v-for="item in matchingObjects"
|
||||
:value="item.t_id"
|
||||
:key="item.t_id"
|
||||
>
|
||||
{{ item.t_name }}
|
||||
</el-checkbox-button>
|
||||
</el-checkbox-group>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span>未选中标签:</span>
|
||||
<div class="tag-more-h">
|
||||
<el-checkbox-group v-model="TagList" @change="placeableCheckedCitiesChange">
|
||||
<el-checkbox-button
|
||||
class="vip-power"
|
||||
v-for="item in nonMatchingObjects"
|
||||
:value="item.t_id"
|
||||
:key="item.t_id"
|
||||
>
|
||||
{{ item.t_name }}
|
||||
</el-checkbox-button>
|
||||
</el-checkbox-group>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="e.type === 'text-i'">
|
||||
<UploadImg disabled v-model:image-url="drawerProps.row!.at_cover" width="135px" height="135px" :file-size="3">
|
||||
<template #empty>
|
||||
<el-icon><Avatar /></el-icon>
|
||||
<span>请上传封面</span>
|
||||
</template>
|
||||
</UploadImg>
|
||||
</template>
|
||||
<template v-if="e.type === 'textarea-i'">
|
||||
<div :class="{ 'img-more-h': fileList.length > 0 }">
|
||||
<UploadImgs disabled v-model:file-list="fileList" :drag="false" border-radius="50%">
|
||||
<template #empty>
|
||||
<el-icon><Picture /></el-icon>
|
||||
<span>请上传照片</span>
|
||||
</template>
|
||||
</UploadImgs>
|
||||
</div>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="drawerVisible = false">取消</el-button>
|
||||
<el-button v-show="!drawerProps.isView" type="primary" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="UserDrawer">
|
||||
import { ref, computed, reactive } from "vue";
|
||||
import { ElMessage, FormInstance } from "element-plus";
|
||||
import { AtlasList } from "@/api/interface/atlas/list";
|
||||
import UploadImgs from "@/components/Upload/Imgs.vue";
|
||||
import UploadImg from "@/components/Upload/Img.vue";
|
||||
import { getTypeList } from "@/api/modules/tag/list";
|
||||
import { extractURI } from "@/utils/eleValidate";
|
||||
import { getAllList } from "@/api/modules/site/list";
|
||||
type Options = {
|
||||
label: string;
|
||||
value: string | number;
|
||||
};
|
||||
interface Item {
|
||||
t_id: any;
|
||||
uri: any;
|
||||
}
|
||||
const getField = (item: any, fieldName: string | undefined) => {
|
||||
return fieldName ? item[fieldName] : "";
|
||||
};
|
||||
type Fields = {
|
||||
label: string;
|
||||
prop: string;
|
||||
type: string;
|
||||
value: any;
|
||||
required: boolean;
|
||||
options?: Options[];
|
||||
fieldNames?: { label: string; value: string };
|
||||
describe?: string;
|
||||
};
|
||||
const generateRules = (fields: Fields[]) => {
|
||||
return fields.reduce(
|
||||
(acc, field) => {
|
||||
const { type, required, label, prop } = field;
|
||||
let message = "";
|
||||
if (type === "select") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "tag") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "radio") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "text") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "text-num") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "text-i") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "textarea-i") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "image") {
|
||||
message = `请上传${label}`;
|
||||
}
|
||||
if (type === "image-more") {
|
||||
message = `请上传${label}`;
|
||||
}
|
||||
acc[prop] = [{ required, message }];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>
|
||||
);
|
||||
};
|
||||
|
||||
const fileList = ref([] as any);
|
||||
const TagList = ref([] as any);
|
||||
const getTagList = async () => {
|
||||
try {
|
||||
const initParam = reactive({
|
||||
t_type: 2,
|
||||
limit: 3650,
|
||||
page: 1,
|
||||
t_hidden: 1
|
||||
});
|
||||
const siteRes = await getTypeList(initParam);
|
||||
arrTagList.value = siteRes.data.item;
|
||||
arrNewTagList.value = siteRes.data.item;
|
||||
matchingObjects.value = [];
|
||||
matchingObjectsA.value = [];
|
||||
// 遍历第一个数组的每个元素
|
||||
TagList.value.forEach(index => {
|
||||
// 遍历第二个数组的每个元素
|
||||
arrNewTagList.value.forEach(obj => {
|
||||
// 如果第二个数组中的元素的t_id与第一个数组中的元素相同,则将其添加到新数组中
|
||||
if (obj.t_id === index) {
|
||||
matchingObjects.value = [...matchingObjects.value, obj];
|
||||
matchingObjectsA.value = [...matchingObjectsA.value, obj];
|
||||
const matchingIds = matchingObjectsA.value.map(obj => obj.t_id);
|
||||
TagList.value = matchingIds;
|
||||
}
|
||||
});
|
||||
});
|
||||
// 创建一个新数组,包含objectArray中id不存在于arrayA的元素
|
||||
nonMatchingObjects.value = arrNewTagList.value.filter(obj => !TagList.value.includes(obj.t_id));
|
||||
nonMatchingObjectsB.value = arrNewTagList.value.filter(obj => !TagList.value.includes(obj.t_id));
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
const getSiteList = ref([] as any);
|
||||
getAllList().then(res => {
|
||||
getSiteList.value = res.data.item;
|
||||
});
|
||||
|
||||
const arrTagList = ref([] as any);
|
||||
const arrNewTagList = ref([] as any);
|
||||
const matchingObjects = ref([] as any); //选中相同
|
||||
const nonMatchingObjects = ref([] as any); //没有选中的
|
||||
const matchingObjectsA = ref([] as any); //选中相同
|
||||
const nonMatchingObjectsB = ref([] as any); //没有选中的
|
||||
|
||||
// 处理搜索事件
|
||||
function handleSearch() {
|
||||
// 在输入事件中实时更新过滤后的标签列表
|
||||
filteredTagList();
|
||||
}
|
||||
function filteredTagList() {
|
||||
if (drawerProps.value.row!.searchText.length > 0) {
|
||||
let arrData = arrTagList.value.filter(tag =>
|
||||
tag.t_name.toLowerCase().includes(drawerProps.value.row!.searchText.toLowerCase())
|
||||
);
|
||||
if (arrData.length > 0) {
|
||||
matchingObjects.value = matchingObjects.value.filter(objA => arrData.some(objB => objB.t_id === objA.t_id));
|
||||
nonMatchingObjects.value = nonMatchingObjects.value.filter(objA => arrData.some(objB => objB.t_id === objA.t_id));
|
||||
} else {
|
||||
matchingObjects.value = [];
|
||||
nonMatchingObjects.value = [];
|
||||
}
|
||||
} else {
|
||||
matchingObjects.value = matchingObjectsA.value;
|
||||
nonMatchingObjects.value = nonMatchingObjectsB.value;
|
||||
}
|
||||
}
|
||||
|
||||
//标签数组
|
||||
async function placeableCheckedCitiesChange(value) {
|
||||
matchingObjects.value = [];
|
||||
matchingObjectsA.value = [];
|
||||
drawerProps.value.row.at_tag = value;
|
||||
// 遍历第一个数组的每个元素
|
||||
TagList.value.forEach(index => {
|
||||
// 遍历第二个数组的每个元素
|
||||
arrNewTagList.value.forEach(obj => {
|
||||
// 如果第二个数组中的元素的t_id与第一个数组中的元素相同,则将其添加到新数组中
|
||||
if (obj.t_id === index) {
|
||||
matchingObjects.value = [...matchingObjects.value, obj];
|
||||
matchingObjectsA.value = [...matchingObjectsA.value, obj];
|
||||
const matchingIds = matchingObjectsA.value.map(obj => obj.t_id);
|
||||
TagList.value = matchingIds;
|
||||
}
|
||||
});
|
||||
});
|
||||
nonMatchingObjects.value = arrNewTagList.value.filter(obj => !TagList.value.includes(obj.t_id));
|
||||
nonMatchingObjectsB.value = arrNewTagList.value.filter(obj => !TagList.value.includes(obj.t_id));
|
||||
}
|
||||
const formColumns = computed((): Fields[] => {
|
||||
let newColumns = [] as any;
|
||||
let columns = [
|
||||
{
|
||||
label: "图册名称",
|
||||
prop: "at_name",
|
||||
type: "text",
|
||||
get value() {
|
||||
return drawerProps.value.row!.at_name;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.at_name = val;
|
||||
},
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: "图册状态",
|
||||
prop: "at_status",
|
||||
type: "switch",
|
||||
get value() {
|
||||
return String(drawerProps.value.row!.at_status);
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.at_status = val;
|
||||
},
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: "精彩站点",
|
||||
prop: "at_wonderful_site_id",
|
||||
type: "select",
|
||||
get value() {
|
||||
return drawerProps.value.row!.at_wonderful_site_id;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.at_wonderful_site_id = val;
|
||||
},
|
||||
options: getSiteList.value,
|
||||
fieldNames: { label: "si_name", value: "si_id" },
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: "推荐站点",
|
||||
prop: "at_recommend_site_id",
|
||||
type: "select",
|
||||
get value() {
|
||||
return drawerProps.value.row!.at_recommend_site_id;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.at_recommend_site_id = val;
|
||||
},
|
||||
options: getSiteList.value,
|
||||
fieldNames: { label: "si_name", value: "si_id" },
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: "图册封面",
|
||||
prop: "at_cover_edit",
|
||||
type: "text",
|
||||
get value() {
|
||||
return drawerProps.value.row!.at_cover_edit;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.at_cover_edit = val;
|
||||
},
|
||||
required: true
|
||||
},
|
||||
|
||||
{
|
||||
label: "图册标签",
|
||||
prop: "at_tag",
|
||||
type: "tag",
|
||||
get value() {
|
||||
return drawerProps.value.row!.at_tag;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.at_tag = val;
|
||||
},
|
||||
options: arrTagList.value,
|
||||
fieldNames: { label: "label", value: "value" },
|
||||
required: true
|
||||
}
|
||||
];
|
||||
newColumns = [...columns];
|
||||
if (drawerProps.value.title === "编辑图册") {
|
||||
let columnsSon = [
|
||||
{
|
||||
label: "图册资源",
|
||||
prop: "at_source",
|
||||
type: "textarea-i",
|
||||
describe: "多个用逗号(,)隔开",
|
||||
get value() {
|
||||
return drawerProps.value.row!.at_source;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.at_source = val;
|
||||
},
|
||||
required: false
|
||||
}
|
||||
];
|
||||
newColumns.splice(6, 0, ...columnsSon);
|
||||
}
|
||||
|
||||
return newColumns;
|
||||
});
|
||||
const rules = ref();
|
||||
|
||||
interface DrawerProps {
|
||||
title: string;
|
||||
isView: boolean;
|
||||
row: Partial<AtlasList.ResList>;
|
||||
api?: (params: any) => Promise<any>;
|
||||
getTableList?: () => void;
|
||||
}
|
||||
|
||||
const drawerVisible = ref(false);
|
||||
const drawerProps = ref<DrawerProps>({
|
||||
isView: false,
|
||||
title: "",
|
||||
row: {}
|
||||
});
|
||||
|
||||
// 接收父组件传过来的参数
|
||||
const acceptParams = (params: DrawerProps) => {
|
||||
drawerProps.value = params;
|
||||
rules.value = generateRules(formColumns.value);
|
||||
drawerVisible.value = true;
|
||||
if (drawerProps.value.title === "新增图册") {
|
||||
drawerProps.value.row!.at_status = 0;
|
||||
}
|
||||
TagList.value = [];
|
||||
fileList.value = [];
|
||||
if (drawerProps.value.row.at_cover) {
|
||||
drawerProps.value.row.at_cover_code = drawerProps.value.row.at_cover.code;
|
||||
drawerProps.value.row.at_cover_edit = drawerProps.value.row.at_cover.uri;
|
||||
}
|
||||
if (drawerProps.value.row.at_tag) {
|
||||
TagList.value = drawerProps.value.row.at_tag.map((obj: Item) => obj.t_id);
|
||||
}
|
||||
if (drawerProps.value.row.at_source) {
|
||||
const data = drawerProps.value.row.at_source_detail;
|
||||
drawerProps.value.row.at_source = data;
|
||||
const newArray = data.map(item => ({ url: item }));
|
||||
fileList.value = newArray;
|
||||
}
|
||||
getTagList();
|
||||
};
|
||||
|
||||
// 提交数据(新增/编辑)
|
||||
const ruleFormRef = ref<FormInstance>();
|
||||
const handleSubmit = () => {
|
||||
console.log(drawerProps.value.row);
|
||||
ruleFormRef.value!.validate(async valid => {
|
||||
if (!valid) return;
|
||||
try {
|
||||
const params = { ...drawerProps.value.row };
|
||||
let arrCoverData = {
|
||||
uri: params.at_cover_edit,
|
||||
code: params.at_cover_code
|
||||
};
|
||||
params.at_cover = arrCoverData;
|
||||
params.at_tag = JSON.stringify(TagList.value);
|
||||
if (drawerProps.value.title === "编辑图册") {
|
||||
if (Array.isArray(params.at_source)) {
|
||||
params.at_source = params.at_source.map(uri => ({ uri: extractURI(uri), code: params.at_cover_code }));
|
||||
} else if (params.at_source != null) {
|
||||
params.at_source = params.at_source.split(",");
|
||||
params.at_source = params.at_source.map(uri => ({ uri: extractURI(uri), code: params.at_cover_code }));
|
||||
}
|
||||
}
|
||||
await drawerProps.value.api!(params);
|
||||
ElMessage.success({ message: `${drawerProps.value.title}成功!` });
|
||||
drawerProps.value.getTableList!();
|
||||
drawerVisible.value = false;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
acceptParams
|
||||
});
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.el-checkbox-button__inner {
|
||||
margin-right: 10px;
|
||||
margin-bottom: 10px;
|
||||
border-left-color: #dcdfe6 !important;
|
||||
}
|
||||
.el-upload--picture-card {
|
||||
display: none;
|
||||
}
|
||||
.img-more-h {
|
||||
height: 300px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
.tag-more-h {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.tag-more-s {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid black;
|
||||
box-shadow: 0 0 4px 2px rgb(0 0 0 / 90%);
|
||||
}
|
||||
</style>
|
||||
@@ -1,233 +0,0 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="drawerVisible"
|
||||
:title="drawerProps.title + '--' + drawerProps.name"
|
||||
:destroy-on-close="true"
|
||||
width="1100px"
|
||||
draggable
|
||||
>
|
||||
<el-form
|
||||
ref="ruleFormRef"
|
||||
label-width="0px"
|
||||
label-suffix=" :"
|
||||
:rules="rules"
|
||||
:disabled="drawerProps.isView"
|
||||
:model="drawerProps.row"
|
||||
:hide-required-asterisk="drawerProps.isView"
|
||||
>
|
||||
<el-form-item v-for="e in formColumns" :key="e.label" :label="e.label" :prop="e.prop" :required="e.required">
|
||||
<template v-if="e.type === 'switch'">
|
||||
<el-switch v-model="e.value" />
|
||||
</template>
|
||||
<template v-if="e.type === 'radio'">
|
||||
<el-radio-group v-model="e.value" class="ml-4">
|
||||
<el-radio v-for="item in e.options" :key="item.value" :label="item.value" size="large">{{
|
||||
item.label
|
||||
}}</el-radio>
|
||||
</el-radio-group>
|
||||
</template>
|
||||
<template v-if="e.type === 'select'">
|
||||
<el-select filterable v-model="e.value" :placeholder="`请选择${e.label}`" clearable>
|
||||
<el-option
|
||||
v-for="(item, index) in e.options"
|
||||
:key="index"
|
||||
:label="getField(item, e.fieldNames?.label)"
|
||||
:value="getField(item, e.fieldNames?.value)"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
<template v-if="e.type === 'tag'">
|
||||
<el-tag
|
||||
style="margin: 7px; margin-right: 10px"
|
||||
v-for="item in drawerProps.row!.at_tag"
|
||||
:key="item.t_id"
|
||||
:type="item.type"
|
||||
effect="dark"
|
||||
>
|
||||
{{ item.t_name }}
|
||||
</el-tag>
|
||||
</template>
|
||||
|
||||
<template v-if="e.type === 'text'">
|
||||
<el-input v-model="e.value" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'textarea'">
|
||||
<el-input
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
v-model="e.value"
|
||||
maxlength="500000"
|
||||
:placeholder="`请输入${e.label}`"
|
||||
show-word-limit
|
||||
/>
|
||||
</template>
|
||||
<template v-if="e.type === 'textareaMore'">
|
||||
<el-input
|
||||
type="textarea"
|
||||
v-model="e.value"
|
||||
maxlength="500000"
|
||||
:placeholder="`请输入${e.label}`"
|
||||
show-word-limit
|
||||
/>
|
||||
</template>
|
||||
<template v-if="e.type === 'image'">
|
||||
<div class="c-dialog-h">
|
||||
<img v-for="(item, index) in e.value" :key="index" :src="item" alt="" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="e.type === 'textarea-i'">
|
||||
<div :class="{ 'img-more-h': fileList.length > 0 }">
|
||||
<UploadImgs disabled v-model:file-list="fileList" height="150px" width="19%" :drag="false">
|
||||
<template #empty>
|
||||
<el-icon><Picture /></el-icon>
|
||||
<span>请上传照片</span>
|
||||
</template>
|
||||
</UploadImgs>
|
||||
</div>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="drawerVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="drawerVisible = false">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="UserDrawer">
|
||||
import { ref, computed } from "vue";
|
||||
import { FormInstance } from "element-plus";
|
||||
import { AtlasList } from "@/api/interface/atlas/list";
|
||||
import UploadImgs from "@/components/Upload/Imgs.vue";
|
||||
type Options = {
|
||||
label: string;
|
||||
value: string | number;
|
||||
};
|
||||
|
||||
const getField = (item: any, fieldName: string | undefined) => {
|
||||
return fieldName ? item[fieldName] : "";
|
||||
};
|
||||
|
||||
type Fields = {
|
||||
label: string;
|
||||
prop: string;
|
||||
type: string;
|
||||
value: any;
|
||||
required: boolean;
|
||||
options?: Options[];
|
||||
fieldNames?: { label: string; value: string };
|
||||
};
|
||||
const generateRules = (fields: Fields[]) => {
|
||||
return fields.reduce(
|
||||
(acc, field) => {
|
||||
const { type, required, label, prop } = field;
|
||||
let message = "";
|
||||
if (type === "select") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "text") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "textarea") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "textareaMore") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "radio") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
acc[prop] = [{ required, message }];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>
|
||||
);
|
||||
};
|
||||
|
||||
const formColumns = computed((): Fields[] => {
|
||||
return [
|
||||
{
|
||||
label: "",
|
||||
prop: "at_source_detail",
|
||||
// type: "image",
|
||||
type: "textarea-i",
|
||||
get value() {
|
||||
return drawerProps.value.row!.at_source_detail;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.at_source_detail = val;
|
||||
},
|
||||
options: drawerProps.value.row!.at_tag,
|
||||
fieldNames: { label: "at_source_detail", value: "t_id" },
|
||||
required: false
|
||||
}
|
||||
];
|
||||
});
|
||||
const rules = ref();
|
||||
const fileList = ref([] as any);
|
||||
interface DrawerProps {
|
||||
title: string;
|
||||
name?: any;
|
||||
isView: boolean;
|
||||
row: Partial<AtlasList.ResList>;
|
||||
api?: (params: any) => Promise<any>;
|
||||
getTableList?: () => void;
|
||||
}
|
||||
|
||||
const drawerVisible = ref(false);
|
||||
const drawerProps = ref<DrawerProps>({
|
||||
isView: false,
|
||||
title: "",
|
||||
name: "",
|
||||
row: {}
|
||||
});
|
||||
|
||||
// 接收父组件传过来的参数
|
||||
const acceptParams = async (params: DrawerProps) => {
|
||||
drawerProps.value = params;
|
||||
rules.value = generateRules(formColumns.value);
|
||||
drawerProps.value.name = drawerProps.value.row!.at_name;
|
||||
drawerVisible.value = true;
|
||||
fileList.value = [];
|
||||
if (drawerProps.value.row.at_source) {
|
||||
const data = drawerProps.value.row.at_source_detail;
|
||||
drawerProps.value.row.at_source = data;
|
||||
const newArray = data.map(item => ({ url: item }));
|
||||
fileList.value = newArray;
|
||||
}
|
||||
};
|
||||
|
||||
// 提交数据(新增/编辑)
|
||||
const ruleFormRef = ref<FormInstance>();
|
||||
|
||||
defineExpose({
|
||||
acceptParams
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.el-dialog .el-dialog__header {
|
||||
padding: 15px 0;
|
||||
}
|
||||
.c-dialog-h {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
width: 1100px;
|
||||
max-height: 600px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
.c-dialog-h img {
|
||||
width: 19.05%;
|
||||
height: 150px;
|
||||
margin-right: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.img-more-h .upload .upload-image {
|
||||
object-fit: fill !important;
|
||||
}
|
||||
.img-more-h {
|
||||
height: 520px;
|
||||
margin-top: 15px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
</style>
|
||||
@@ -1,237 +1,31 @@
|
||||
<template>
|
||||
<div class="table-box">
|
||||
<ProTable ref="proTable" highlight-current-row :columns="columns" :request-api="getList">
|
||||
<!-- 表格 header 按钮 -->
|
||||
<template #tableHeader="scope">
|
||||
<el-button type="primary" style="display: none" :icon="CirclePlus" @click="openDrawer(true)">新增</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
plain
|
||||
:disabled="!scope.isSelected"
|
||||
@click="batchDelete(scope.selectedList)"
|
||||
>
|
||||
批量删除图册
|
||||
</el-button>
|
||||
</template>
|
||||
<!-- 表格操作 -->
|
||||
<template #operation="scope">
|
||||
<el-button type="primary" :icon="EditPen" @click="openDrawer(false, scope.row)">编辑</el-button>
|
||||
<el-button type="danger" :icon="Delete" @click="deleteAccount(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</ProTable>
|
||||
<Drawer ref="drawerRef" />
|
||||
<TagDialog ref="tagDialogRef" />
|
||||
<ImgDialog ref="imgDialogRef" />
|
||||
<div class="module-unavailable">
|
||||
<el-alert
|
||||
title="图册管理模块未部署到当前 Linux 测试服"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<el-card class="module-unavailable__card" shadow="never">
|
||||
<p>当前后端未提供 `atlas` 控制器,图册列表、编辑、删除接口都会直接返回控制器不存在。</p>
|
||||
<p>相关页面先收为提示态,避免继续暴露一组必然失败的 CRUD 操作。</p>
|
||||
<p>如果后续补齐图册后端,再恢复表格、抽屉和资源对话框联调。</p>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="tsx" name="complexProTable">
|
||||
import { reactive, ref } from "vue";
|
||||
import { AtlasList } from "@/api/interface/atlas/list";
|
||||
import { useHandleData } from "@/hooks/useHandleData";
|
||||
import ProTable from "@/components/ProTable/index.vue";
|
||||
import { Delete, EditPen, CirclePlus } from "@element-plus/icons-vue";
|
||||
import { ProTableInstance, ColumnProps } from "@/components/ProTable/interface";
|
||||
import { getList, saveData, deleteItem } from "@/api/modules/atlas/list";
|
||||
import Drawer from "./drawer.vue";
|
||||
import TagDialog from "./tagDialog.vue";
|
||||
import ImgDialog from "./imgDialog.vue";
|
||||
import { getTypeList } from "@/api/modules/tag/list";
|
||||
import { arrSxStatus } from "@/utils/serviceDict";
|
||||
import { getAllList } from "@/api/modules/site/list";
|
||||
// ProTable 实例
|
||||
const proTable = ref<ProTableInstance>();
|
||||
const getTagList = ref([] as any);
|
||||
getTypeList({ limit: 5000, page: 1, t_type: 2, t_hidden: 1 }).then(res => {
|
||||
getTagList.value = res.data.item;
|
||||
});
|
||||
// 表格配置项
|
||||
const columns = reactive<ColumnProps<AtlasList.ResList>[]>([
|
||||
{ type: "selection", fixed: "left", width: 50 },
|
||||
{
|
||||
prop: "key",
|
||||
label: "搜索内容",
|
||||
isShow: false,
|
||||
search: {
|
||||
el: "input"
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: "at_id",
|
||||
label: "ID",
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
prop: "at_name",
|
||||
label: "图册名称"
|
||||
},
|
||||
{
|
||||
prop: "at_cover",
|
||||
label: "图册封面",
|
||||
width: 120,
|
||||
render(scope) {
|
||||
if (scope.row.at_cover) {
|
||||
const data = scope.row.at_cover_detail;
|
||||
//循环数组对某个对象key重新赋值
|
||||
const modifiedArray = [] as any;
|
||||
modifiedArray.push(data);
|
||||
return (
|
||||
<el-image
|
||||
style="width: 98%; height: 30px;display: flex;align-items: center;"
|
||||
src={modifiedArray[0]}
|
||||
preview-src-list={modifiedArray}
|
||||
hide-on-click-modal={true}
|
||||
z-index={999999999}
|
||||
preview-teleported={true}
|
||||
fit="fill"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: "at_source_detail",
|
||||
label: "图册资源",
|
||||
width: 120,
|
||||
render(scope) {
|
||||
if (scope.row.at_source_detail) {
|
||||
return <el-tag onClick={() => openImgFun(scope.row)}>查看</el-tag>;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: "at_status",
|
||||
label: "图册状态",
|
||||
search: { el: "select", props: { filterable: true } },
|
||||
enum: arrSxStatus,
|
||||
render(scope) {
|
||||
return (
|
||||
<el-tag type={scope.row.at_status === 0 ? "danger" : "success"}>
|
||||
{scope.row.at_status === 0 ? "下架" : "上架"}
|
||||
</el-tag>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: "at_wonderful_site_id_detail",
|
||||
label: "精彩站点",
|
||||
render(scope) {
|
||||
if (scope.row.at_wonderful_site_id_detail) {
|
||||
return <span>{scope.row.at_wonderful_site_id_detail.join(", ")}</span>;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: "at_recommend_site_id_detail",
|
||||
label: "推荐站点",
|
||||
render(scope) {
|
||||
if (scope.row.at_recommend_site_id_detail) {
|
||||
return <span>{scope.row.at_recommend_site_id_detail.join(", ")}</span>;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: "at_wonderful_site_id",
|
||||
label: "精彩站点",
|
||||
isShow: false,
|
||||
search: { el: "select", props: { filterable: true } },
|
||||
fieldNames: { label: "si_name", value: "si_id" },
|
||||
enum: getAllList
|
||||
},
|
||||
{
|
||||
prop: "at_recommend_site_id",
|
||||
label: "推荐站点",
|
||||
isShow: false,
|
||||
search: { el: "select", props: { filterable: true } },
|
||||
fieldNames: { label: "si_name", value: "si_id" },
|
||||
enum: getAllList
|
||||
},
|
||||
{
|
||||
prop: "at_tag",
|
||||
label: "标签",
|
||||
width: 250,
|
||||
// showOverflowTooltip: false,
|
||||
// align: "left",
|
||||
render(scope) {
|
||||
if (scope.row.at_tag_detail) {
|
||||
return <span>{scope.row.at_tag_detail.join(", ")}</span>;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: "at_tag",
|
||||
label: "标签",
|
||||
isShow: false,
|
||||
search: { el: "select", props: { filterable: true } },
|
||||
fieldNames: { label: "t_name", value: "t_id" },
|
||||
enum: getTagList
|
||||
},
|
||||
|
||||
{
|
||||
prop: "at_count_play",
|
||||
label: "查看次数"
|
||||
},
|
||||
{ prop: "created_at", label: "添加时间", width: 170 },
|
||||
{ prop: "operation", label: "操作", fixed: "right", width: 230 }
|
||||
]);
|
||||
|
||||
// 删除图册信息
|
||||
const deleteAccount = async (params: AtlasList.ResList) => {
|
||||
await useHandleData(deleteItem, { at_id: params.at_id }, "删除所选信息");
|
||||
proTable.value?.getTableList();
|
||||
};
|
||||
|
||||
// 批量删除图册信息
|
||||
const batchDelete = async (id: any[]) => {
|
||||
const ids = id.map((item: AtlasList.ResList) => item.at_id);
|
||||
await useHandleData(deleteItem, { at_id: ids.join(",") }, "删除所选信息");
|
||||
proTable.value?.clearSelection();
|
||||
proTable.value?.getTableList();
|
||||
};
|
||||
// 打开 drawer(新增、编辑)
|
||||
const drawerRef = ref<InstanceType<typeof Drawer> | null>(null);
|
||||
|
||||
const openDrawer = (isEdit: boolean, row: Partial<AtlasList.ResList> = {}) => {
|
||||
const params = {
|
||||
title: isEdit ? "新增图册" : "编辑图册",
|
||||
row: { ...row },
|
||||
api: saveData,
|
||||
getTableList: proTable.value?.getTableList
|
||||
};
|
||||
drawerRef.value?.acceptParams(params as any);
|
||||
};
|
||||
// 标签
|
||||
const tagDialogRef = ref<InstanceType<typeof TagDialog> | null>(null);
|
||||
// const openTagFun = (row: Partial<AtlasList.ResList> = {}) => {
|
||||
// const params = {
|
||||
// title: "图册标签",
|
||||
// row: { ...row },
|
||||
// getTableList: proTable.value?.getTableList
|
||||
// };
|
||||
// tagDialogRef.value?.acceptParams(params as any);
|
||||
// };
|
||||
// 图册资源
|
||||
const imgDialogRef = ref<InstanceType<typeof TagDialog> | null>(null);
|
||||
const openImgFun = (row: Partial<AtlasList.ResList> = {}) => {
|
||||
const params = {
|
||||
title: "图册资源",
|
||||
row: { ...row },
|
||||
getTableList: proTable.value?.getTableList
|
||||
};
|
||||
imgDialogRef.value?.acceptParams(params as any);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.el-table .warning-row,
|
||||
.el-table .warning-row .el-table-fixed-column--right,
|
||||
.el-table .warning-row .el-table-fixed-column--left {
|
||||
background-color: var(--el-color-warning-light-9);
|
||||
<style scoped lang="scss">
|
||||
.module-unavailable {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
.el-table .success-row,
|
||||
.el-table .success-row .el-table-fixed-column--right,
|
||||
.el-table .success-row .el-table-fixed-column--left {
|
||||
background-color: var(--el-color-success-light-9);
|
||||
|
||||
.module-unavailable__card {
|
||||
color: var(--el-text-color-regular);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.module-unavailable__card p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
<template>
|
||||
<el-dialog v-model="drawerVisible" :title="drawerProps.title" :destroy-on-close="true" width="520px" draggable>
|
||||
<el-form
|
||||
ref="ruleFormRef"
|
||||
label-width="50px"
|
||||
label-suffix=" :"
|
||||
:rules="rules"
|
||||
:disabled="drawerProps.isView"
|
||||
:model="drawerProps.row"
|
||||
:hide-required-asterisk="drawerProps.isView"
|
||||
>
|
||||
<el-form-item v-for="e in formColumns" :key="e.label" :label="e.label" :prop="e.prop" :required="e.required">
|
||||
<template v-if="e.type === 'switch'">
|
||||
<el-switch v-model="e.value" />
|
||||
</template>
|
||||
<template v-if="e.type === 'radio'">
|
||||
<el-radio-group v-model="e.value" class="ml-4">
|
||||
<el-radio v-for="item in e.options" :key="item.value" :label="item.value" size="large">{{
|
||||
item.label
|
||||
}}</el-radio>
|
||||
</el-radio-group>
|
||||
</template>
|
||||
<template v-if="e.type === 'select'">
|
||||
<el-select filterable v-model="e.value" :placeholder="`请选择${e.label}`" clearable>
|
||||
<el-option
|
||||
v-for="(item, index) in e.options"
|
||||
:key="index"
|
||||
:label="getField(item, e.fieldNames?.label)"
|
||||
:value="getField(item, e.fieldNames?.value)"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
<template v-if="e.type === 'tag'">
|
||||
<el-tag
|
||||
style="margin: 7px; margin-right: 10px"
|
||||
v-for="item in drawerProps.row!.at_tag"
|
||||
:key="item.t_id"
|
||||
:type="item.type"
|
||||
effect="dark"
|
||||
>
|
||||
{{ item.t_name }}
|
||||
</el-tag>
|
||||
</template>
|
||||
|
||||
<template v-if="e.type === 'text'">
|
||||
<el-input v-model="e.value" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'textarea'">
|
||||
<el-input
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
v-model="e.value"
|
||||
maxlength="500000"
|
||||
:placeholder="`请输入${e.label}`"
|
||||
show-word-limit
|
||||
/>
|
||||
</template>
|
||||
<template v-if="e.type === 'textareaMore'">
|
||||
<el-input
|
||||
type="textarea"
|
||||
v-model="e.value"
|
||||
maxlength="500000"
|
||||
:placeholder="`请输入${e.label}`"
|
||||
show-word-limit
|
||||
/>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="drawerVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="drawerVisible = false">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="UserDrawer">
|
||||
import { ref, computed } from "vue";
|
||||
import { FormInstance } from "element-plus";
|
||||
import { AtlasList } from "@/api/interface/atlas/list";
|
||||
type Options = {
|
||||
label: string;
|
||||
value: string | number;
|
||||
};
|
||||
|
||||
const getField = (item: any, fieldName: string | undefined) => {
|
||||
return fieldName ? item[fieldName] : "";
|
||||
};
|
||||
|
||||
type Fields = {
|
||||
label: string;
|
||||
prop: string;
|
||||
type: string;
|
||||
value: any;
|
||||
required: boolean;
|
||||
options?: Options[];
|
||||
fieldNames?: { label: string; value: string };
|
||||
};
|
||||
const generateRules = (fields: Fields[]) => {
|
||||
return fields.reduce(
|
||||
(acc, field) => {
|
||||
const { type, required, label, prop } = field;
|
||||
let message = "";
|
||||
if (type === "select") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "text") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "textarea") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "textareaMore") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "radio") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
acc[prop] = [{ required, message }];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>
|
||||
);
|
||||
};
|
||||
|
||||
const formColumns = computed((): Fields[] => {
|
||||
return [
|
||||
{
|
||||
label: "标签",
|
||||
prop: "t_name",
|
||||
type: "tag",
|
||||
get value() {
|
||||
return drawerProps.value.row!.t_name;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.t_name = val;
|
||||
},
|
||||
options: drawerProps.value.row!.at_tag,
|
||||
fieldNames: { label: "t_name", value: "t_id" },
|
||||
required: false
|
||||
}
|
||||
];
|
||||
});
|
||||
const rules = ref();
|
||||
|
||||
interface DrawerProps {
|
||||
title: string;
|
||||
isView: boolean;
|
||||
row: Partial<AtlasList.ResList>;
|
||||
api?: (params: any) => Promise<any>;
|
||||
getTableList?: () => void;
|
||||
}
|
||||
|
||||
const drawerVisible = ref(false);
|
||||
const drawerProps = ref<DrawerProps>({
|
||||
isView: false,
|
||||
title: "",
|
||||
row: {}
|
||||
});
|
||||
|
||||
// 接收父组件传过来的参数
|
||||
const acceptParams = async (params: DrawerProps) => {
|
||||
drawerProps.value = params;
|
||||
rules.value = generateRules(formColumns.value);
|
||||
drawerVisible.value = true;
|
||||
};
|
||||
|
||||
// 提交数据(新增/编辑)
|
||||
const ruleFormRef = ref<FormInstance>();
|
||||
|
||||
defineExpose({
|
||||
acceptParams
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.el-dialog .el-dialog__header {
|
||||
padding: 15px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,161 +0,0 @@
|
||||
<template>
|
||||
<el-drawer v-model="drawerVisible" :destroy-on-close="true" size="800px" :title="`${drawerProps.title}渠道`">
|
||||
<el-form
|
||||
ref="ruleFormRef"
|
||||
label-width="90px"
|
||||
label-suffix=" :"
|
||||
:rules="rules"
|
||||
:disabled="drawerProps.isView"
|
||||
:model="drawerProps.row"
|
||||
:hide-required-asterisk="drawerProps.isView"
|
||||
>
|
||||
<el-form-item v-for="e in formColumns" :key="e.label" :label="e.label" :prop="e.prop" :required="e.required">
|
||||
<template v-if="e.type === 'switch'">
|
||||
<el-switch v-model="e.value" />
|
||||
</template>
|
||||
<template v-if="e.type === 'select'">
|
||||
<el-select v-model="e.value" :placeholder="`请选择${e.label}`" clearable>
|
||||
<el-option v-for="item in e.options" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</template>
|
||||
<template v-if="e.type === 'text'">
|
||||
<el-input v-model="e.value" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'textarea'">
|
||||
<el-input
|
||||
type="textarea"
|
||||
v-model="e.value"
|
||||
maxlength="3650"
|
||||
:rows="10"
|
||||
:placeholder="`请输入${e.label}`"
|
||||
show-word-limit
|
||||
/>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="drawerVisible = false">取消</el-button>
|
||||
<el-button v-show="!drawerProps.isView" type="primary" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="UserDrawer">
|
||||
import { ref, computed } from "vue";
|
||||
import { ElMessage, FormInstance } from "element-plus";
|
||||
import { ChannelStatisticsList } from "@/api/interface/channel/statistics";
|
||||
type Options = {
|
||||
label: string;
|
||||
value: string | number;
|
||||
};
|
||||
|
||||
type Fields = {
|
||||
label: string;
|
||||
prop: string;
|
||||
type: string;
|
||||
value: any;
|
||||
required: boolean;
|
||||
options?: Options[];
|
||||
};
|
||||
const generateRules = (fields: Fields[]) => {
|
||||
return fields.reduce(
|
||||
(acc, field) => {
|
||||
const { type, required, label, prop } = field;
|
||||
let message = "";
|
||||
if (type === "select") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "text") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
acc[prop] = [{ required, message }];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>
|
||||
);
|
||||
};
|
||||
|
||||
const formColumns = computed((): Fields[] => {
|
||||
return [
|
||||
{
|
||||
label: "渠道名称",
|
||||
prop: "csc_name",
|
||||
type: "text",
|
||||
get value() {
|
||||
return drawerProps.value.row!.csc_name;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.csc_name = val;
|
||||
},
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: "渠道编码",
|
||||
prop: "csc_code",
|
||||
type: "text",
|
||||
get value() {
|
||||
return drawerProps.value.row!.csc_code;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.csc_code = val;
|
||||
},
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: "统计代码",
|
||||
prop: "csc_statistical_code",
|
||||
type: "textarea",
|
||||
get value() {
|
||||
return drawerProps.value.row!.csc_statistical_code;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.csc_statistical_code = val;
|
||||
},
|
||||
required: true
|
||||
}
|
||||
];
|
||||
});
|
||||
const rules = ref();
|
||||
|
||||
interface DrawerProps {
|
||||
title: string;
|
||||
isView: boolean;
|
||||
row: Partial<ChannelStatisticsList.ResList>;
|
||||
api?: (params: any) => Promise<any>;
|
||||
getTableList?: () => void;
|
||||
}
|
||||
|
||||
const drawerVisible = ref(false);
|
||||
const drawerProps = ref<DrawerProps>({
|
||||
isView: false,
|
||||
title: "",
|
||||
row: {}
|
||||
});
|
||||
|
||||
// 接收父组件传过来的参数
|
||||
const acceptParams = (params: DrawerProps) => {
|
||||
drawerProps.value = params;
|
||||
rules.value = generateRules(formColumns.value);
|
||||
drawerVisible.value = true;
|
||||
};
|
||||
|
||||
// 提交数据(新增/编辑)
|
||||
const ruleFormRef = ref<FormInstance>();
|
||||
const handleSubmit = () => {
|
||||
ruleFormRef.value!.validate(async valid => {
|
||||
if (!valid) return;
|
||||
try {
|
||||
await drawerProps.value.api!(drawerProps.value.row);
|
||||
ElMessage.success({ message: `${drawerProps.value.title}成功!` });
|
||||
drawerProps.value.getTableList!();
|
||||
drawerVisible.value = false;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
acceptParams
|
||||
});
|
||||
</script>
|
||||
@@ -1,110 +1,31 @@
|
||||
<template>
|
||||
<div class="table-box">
|
||||
<ProTable ref="proTable" highlight-current-row :columns="columns" :request-api="ListApi">
|
||||
<!-- 表格 header 按钮 -->
|
||||
<template #tableHeader>
|
||||
<el-button type="primary" :icon="CirclePlus" @click="openDrawer(true)">新增</el-button>
|
||||
<!-- <el-button
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
plain
|
||||
:disabled="!scope.isSelected"
|
||||
@click="batchDelete(scope.selectedList)"
|
||||
>
|
||||
批量删除
|
||||
</el-button> -->
|
||||
</template>
|
||||
<!-- 表格操作 -->
|
||||
<template #operation="scope">
|
||||
<el-button type="primary" :icon="EditPen" @click="openDrawer(false, scope.row)">编辑</el-button>
|
||||
<el-button type="danger" :icon="Delete" @click="deleteAccount(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</ProTable>
|
||||
<Drawer ref="drawerRef" />
|
||||
<div class="module-unavailable">
|
||||
<el-alert
|
||||
title="渠道统计模块未部署到当前 Linux 测试服"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<el-card class="module-unavailable__card" shadow="never">
|
||||
<p>当前页面依赖旧 `/admin/channel/statisticalcode/*` 接口,测试服会直接返回 `controller not exists: app\\admin\\controller\\Admin`。</p>
|
||||
<p>本页先收为说明态,避免继续暴露渠道统计代码的列表、编辑和删除能力。</p>
|
||||
<p>如果后续补齐渠道模块,再恢复当前列表页和抽屉。</p>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="tsx" name="complexProTable">
|
||||
import { reactive, ref } from "vue";
|
||||
import { useHandleData } from "@/hooks/useHandleData";
|
||||
import { ChannelStatisticsList } from "@/api/interface/channel/statistics";
|
||||
import ProTable from "@/components/ProTable/index.vue";
|
||||
import { EditPen, CirclePlus, Delete } from "@element-plus/icons-vue";
|
||||
import { ProTableInstance, ColumnProps } from "@/components/ProTable/interface";
|
||||
import { ListApi, SaveApi, DelApi } from "@/api/modules/channel/statistics";
|
||||
|
||||
import Drawer from "./drawer.vue";
|
||||
// ProTable 实例
|
||||
const proTable = ref<ProTableInstance>();
|
||||
|
||||
// 表格配置项
|
||||
const columns = reactive<ColumnProps<ChannelStatisticsList.ResList>[]>([
|
||||
{ label: "关键字", search: { el: "input", key: "key" } },
|
||||
{
|
||||
prop: "csc_id",
|
||||
label: "ID",
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
prop: "csc_name",
|
||||
label: "渠道名称"
|
||||
},
|
||||
{
|
||||
prop: "csc_code",
|
||||
label: "渠道编码"
|
||||
},
|
||||
{
|
||||
prop: "csc_statistical_code",
|
||||
label: "统计代码"
|
||||
},
|
||||
{
|
||||
prop: "created_at",
|
||||
label: "创建时间"
|
||||
},
|
||||
{
|
||||
prop: "updated_at",
|
||||
label: "更新时间"
|
||||
},
|
||||
{ prop: "operation", label: "操作", fixed: "right", width: 200 }
|
||||
]);
|
||||
|
||||
// 删除站点信息
|
||||
const deleteAccount = async (params: ChannelStatisticsList.ResList) => {
|
||||
await useHandleData(DelApi, { csc_id: params.csc_id }, "删除所选信息");
|
||||
proTable.value?.getTableList();
|
||||
};
|
||||
|
||||
// 批量删除站点信息
|
||||
// const batchDelete = async (id: any[]) => {
|
||||
// const ids = id.map((item: ChannelStatisticsList.ResList) => item.csc_id);
|
||||
// await useHandleData(DelApi, { csc_id: ids.join(",") }, "删除所选信息");
|
||||
// proTable.value?.clearSelection();
|
||||
// proTable.value?.getTableList();
|
||||
// };
|
||||
|
||||
// 打开 drawer(新增、编辑)
|
||||
const drawerRef = ref<InstanceType<typeof Drawer> | null>(null);
|
||||
|
||||
const openDrawer = (isEdit: boolean, row: Partial<ChannelStatisticsList.ResList> = {}) => {
|
||||
const params = {
|
||||
title: isEdit ? "新增" : "编辑",
|
||||
row: { ...row },
|
||||
api: SaveApi,
|
||||
getTableList: proTable.value?.getTableList
|
||||
};
|
||||
drawerRef.value?.acceptParams(params as any);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.el-table .warning-row,
|
||||
.el-table .warning-row .el-table-fixed-column--right,
|
||||
.el-table .warning-row .el-table-fixed-column--left {
|
||||
background-color: var(--el-color-warning-light-9);
|
||||
<style scoped lang="scss">
|
||||
.module-unavailable {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
.el-table .success-row,
|
||||
.el-table .success-row .el-table-fixed-column--right,
|
||||
.el-table .success-row .el-table-fixed-column--left {
|
||||
background-color: var(--el-color-success-light-9);
|
||||
|
||||
.module-unavailable__card {
|
||||
color: var(--el-text-color-regular);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.module-unavailable__card p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
class="sc-sou-nav"
|
||||
v-model="drawerVisible"
|
||||
:title="drawerProps.title + '--' + drawerProps.name"
|
||||
:destroy-on-close="true"
|
||||
size="980"
|
||||
draggable
|
||||
>
|
||||
<el-form
|
||||
ref="ruleFormRef"
|
||||
label-width="0px"
|
||||
label-suffix=" :"
|
||||
:rules="rules"
|
||||
:disabled="drawerProps.isView"
|
||||
:model="drawerProps.row"
|
||||
:hide-required-asterisk="drawerProps.isView"
|
||||
>
|
||||
<el-form-item v-for="e in formColumns" :key="e.label" :label="e.label" :prop="e.prop" :required="e.required">
|
||||
<template v-if="e.type === 'image'">
|
||||
<div class="c-dialog-h">
|
||||
<img v-for="(item, index) in e.value" :key="index" :src="item" alt="" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="e.type === 'textarea-i'">
|
||||
<div v-if="fileList.length == 0" style="width: 100%; height: 100px; line-height: 100px; text-align: center">
|
||||
正在加载中....
|
||||
</div>
|
||||
<div v-else :class="{ 'img-more-h': fileList.length > 0 }">
|
||||
<UploadImgs disabled v-model:file-list="fileList" height="100px" width="98px" :drag="false">
|
||||
<template #empty>
|
||||
<el-icon><Picture /></el-icon>
|
||||
<span>请上传照片</span>
|
||||
</template>
|
||||
</UploadImgs>
|
||||
</div>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="drawerVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="drawerVisible = false">确定</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="UserDrawer">
|
||||
import { ref, computed } from "vue";
|
||||
import { FormInstance } from "element-plus";
|
||||
import { ChapterList } from "@/api/interface/manga/chapter";
|
||||
import UploadImgs from "@/components/Upload/Imgs.vue";
|
||||
import { fetchAndDecodeImage } from "@/utils/decrypt";
|
||||
|
||||
type Options = {
|
||||
label: string;
|
||||
value: string | number;
|
||||
};
|
||||
|
||||
type Fields = {
|
||||
label: string;
|
||||
prop: string;
|
||||
type: string;
|
||||
value: any;
|
||||
required: boolean;
|
||||
options?: Options[];
|
||||
fieldNames?: { label: string; value: string };
|
||||
};
|
||||
const generateRules = (fields: Fields[]) => {
|
||||
return fields.reduce(
|
||||
(acc, field) => {
|
||||
const { type, required, label, prop } = field;
|
||||
let message = "";
|
||||
if (type === "select") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "text") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "textarea") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "textareaMore") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "radio") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
acc[prop] = [{ required, message }];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>
|
||||
);
|
||||
};
|
||||
|
||||
const formColumns = computed((): Fields[] => {
|
||||
return [
|
||||
{
|
||||
label: "",
|
||||
prop: "mhzj_nei_rong",
|
||||
type: "textarea-i",
|
||||
get value() {
|
||||
return drawerProps.value.row!.mhzj_nei_rong;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.mhzj_nei_rong = val;
|
||||
},
|
||||
required: false
|
||||
}
|
||||
];
|
||||
});
|
||||
const rules = ref();
|
||||
const fileList = ref([] as any);
|
||||
interface DrawerProps {
|
||||
title: string;
|
||||
name?: any;
|
||||
isView: boolean;
|
||||
row: Partial<ChapterList.ResList>;
|
||||
api?: (params: any) => Promise<any>;
|
||||
getTableList?: () => void;
|
||||
}
|
||||
|
||||
const drawerVisible = ref(false);
|
||||
const drawerProps = ref<DrawerProps>({
|
||||
isView: false,
|
||||
title: "",
|
||||
name: "",
|
||||
row: {}
|
||||
});
|
||||
|
||||
// 接收父组件传过来的参数
|
||||
const acceptParams = async (params: DrawerProps) => {
|
||||
drawerProps.value = params;
|
||||
rules.value = generateRules(formColumns.value);
|
||||
drawerProps.value.name = drawerProps.value.row!.name;
|
||||
drawerVisible.value = true;
|
||||
fileList.value = [];
|
||||
if (drawerProps.value.row.url) {
|
||||
if (drawerProps.value.row!.encrypted == false) {
|
||||
const data = drawerProps.value.row.url;
|
||||
const newArray = data.map(item => ({ url: item }));
|
||||
fileList.value = newArray;
|
||||
} else {
|
||||
// Assuming drawerProps.value.row.url is an array of URLs
|
||||
const newArray = drawerProps.value.row.url.map(item => ({ url: item }));
|
||||
// Process each URL with fetchAndDecodeImage before assigning to fileList
|
||||
const processedArrayPromises = newArray.map(async item => {
|
||||
const decodedUrl = await fetchAndDecodeImage(item.url);
|
||||
return { url: decodedUrl };
|
||||
});
|
||||
|
||||
// Wait for all promises to resolve
|
||||
Promise.all(processedArrayPromises)
|
||||
.then(processedArray => {
|
||||
fileList.value = processedArray;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error processing URLs: ", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
// const acceptParams = async (params: DrawerProps) => {
|
||||
// drawerProps.value = params;
|
||||
// rules.value = generateRules(formColumns.value);
|
||||
// drawerProps.value.name = drawerProps.value.row!.name;
|
||||
// drawerVisible.value = true;
|
||||
// fileList.value = [];
|
||||
// if (drawerProps.value.row.url) {
|
||||
// const urls = drawerProps.value.row.url;
|
||||
// if (drawerProps.value.row.encrypted === false) {
|
||||
// // 如果未加密,直接处理
|
||||
// fileList.value = urls.map((url: string) => ({ url }));
|
||||
// } else {
|
||||
// // 如果加密,解码后处理
|
||||
// const uniqueUrls = [...new Set(urls)]; // 去重
|
||||
// const processedArrayPromises = uniqueUrls.map(async (url: any) => {
|
||||
// const decodedUrl = await fetchAndDecodeImage(url);
|
||||
// return { url: decodedUrl };
|
||||
// });
|
||||
|
||||
// try {
|
||||
// const processedArray = await Promise.all(processedArrayPromises);
|
||||
// // 处理完后,将原始数组中的每个 URL 映射到处理后的 URL
|
||||
// const urlMap = new Map(uniqueUrls.map((url, index) => [url, processedArray[index].url]));
|
||||
// fileList.value = urls.map((url: string) => ({ url: urlMap.get(url) }));
|
||||
// } catch (error) {
|
||||
// console.error("Error processing URLs: ", error);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// };
|
||||
// const acceptParams = async (params: DrawerProps) => {
|
||||
// drawerProps.value = params;
|
||||
// rules.value = generateRules(formColumns.value);
|
||||
// drawerProps.value.name = drawerProps.value.row!.name;
|
||||
// drawerVisible.value = true;
|
||||
// fileList.value = [];
|
||||
// if (drawerProps.value.row.url) {
|
||||
// fileList.value = drawerProps.value.row.url;
|
||||
// console.log(fileList.value);
|
||||
// }
|
||||
// // if (drawerProps.value.row.url) {
|
||||
// // const urls = drawerProps.value.row.url;
|
||||
// // if (drawerProps.value.row.encrypted === false) {
|
||||
// // // 如果未加密,直接处理
|
||||
// // fileList.value = urls.map((url: string) => ({ url }));
|
||||
// // } else {
|
||||
// // // 如果加密,解码后处理
|
||||
// // const uniqueUrls = [...new Set(urls)]; // 去重
|
||||
// // const processedArrayPromises = uniqueUrls.map(async (url: any) => {
|
||||
// // const decodedUrl = await fetchAndDecodeImage(url);
|
||||
// // return { url: decodedUrl };
|
||||
// // });
|
||||
|
||||
// // try {
|
||||
// // const processedArray = await Promise.all(processedArrayPromises);
|
||||
// // // 处理完后,将原始数组中的每个 URL 映射到处理后的 URL
|
||||
// // const urlMap = new Map(uniqueUrls.map((url, index) => [url, processedArray[index].url]));
|
||||
// // fileList.value = urls.map((url: string) => ({ url: urlMap.get(url) }));
|
||||
// // } catch (error) {
|
||||
// // console.error("Error processing URLs: ", error);
|
||||
// // }
|
||||
// // }
|
||||
// // }
|
||||
// // if (drawerProps.value.row.url) {
|
||||
// // if (drawerProps.value.row!.encrypted == false) {
|
||||
// // const data = drawerProps.value.row.url;
|
||||
// // const newArray = data.map(item => ({ url: item }));
|
||||
// // fileList.value = newArray;
|
||||
// // } else {
|
||||
// // // Assuming drawerProps.value.row.url is an array of URLs
|
||||
// // const newArray = drawerProps.value.row.url.map(item => ({ url: item }));
|
||||
// // // Process each URL with fetchAndDecodeImage before assigning to fileList
|
||||
// // const processedArrayPromises = newArray.map(async item => {
|
||||
// // const decodedUrl = await fetchAndDecodeImage(item.url);
|
||||
// // return { url: decodedUrl };
|
||||
// // });
|
||||
|
||||
// // // Wait for all promises to resolve
|
||||
// // Promise.all(processedArrayPromises)
|
||||
// // .then(processedArray => {
|
||||
// // fileList.value = processedArray;
|
||||
// // })
|
||||
// // .catch(error => {
|
||||
// // console.error("Error processing URLs: ", error);
|
||||
// // });
|
||||
// // }
|
||||
// // }
|
||||
// };
|
||||
|
||||
// 提交数据(新增/编辑)
|
||||
const ruleFormRef = ref<FormInstance>();
|
||||
|
||||
defineExpose({
|
||||
acceptParams
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.img-more-h .upload .upload-image {
|
||||
object-fit: cover !important;
|
||||
}
|
||||
.img-more-h {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
</style>
|
||||
<style lang="scss">
|
||||
.sc-sou-nav {
|
||||
.el-upload--picture-card {
|
||||
display: none;
|
||||
}
|
||||
.el-drawer__header {
|
||||
padding: 7px;
|
||||
}
|
||||
.el-drawer__body {
|
||||
padding: 7px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,8 @@
|
||||
.home {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
.home-bg {
|
||||
width: 70%;
|
||||
max-width: 1200px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,107 @@
|
||||
<template>
|
||||
<div class="home card">
|
||||
<img class="home-bg" src="@/assets/images/welcome.png" alt="welcome" />
|
||||
<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>
|
||||
<el-button type="danger" plain @click="openBootstrapCenter({ tab: 'ops', opsFocus: { panel: 'attention', stage: 'attention', clearHostFocus: true, hostConsolePanel: 'runs' } })">异常聚焦</el-button>
|
||||
<el-button type="info" plain @click="openBootstrapCenter('env')">查看环境模板</el-button>
|
||||
</div>
|
||||
<BootstrapOpsOverview />
|
||||
<BootstrapEnvOverview action-text="前往站点页查看环境模板" @open-dialog="goSitePage" />
|
||||
<BootstrapAdminCenter ref="bootstrapAdminCenterRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="home"></script>
|
||||
<script setup lang="ts" name="home">
|
||||
import { useRouter } from "vue-router";
|
||||
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";
|
||||
|
||||
const router = useRouter();
|
||||
const bootstrapAdminCenterRef = ref<InstanceType<typeof BootstrapAdminCenter> | null>(null);
|
||||
type BootstrapFocusMode = "all" | "task" | "oncall" | "pipeline" | "import" | "release";
|
||||
type BootstrapFocusPayload = BootstrapFocusMode | BootstrapFocusMode[];
|
||||
type BootstrapComparePreset =
|
||||
| "latest_two"
|
||||
| "latest_applied_vs_dry_run"
|
||||
| "latest_template_two"
|
||||
| "latest_template_apply_vs_preview"
|
||||
| "latest_restore_two"
|
||||
| "latest_restore_apply_vs_preview";
|
||||
type BootstrapRestorePreset = "latest_restore_after_preview" | "latest_restore_before_preview";
|
||||
type BootstrapHistoryActionPreset = "template_family" | "restore_family" | "restore_preview_only" | "restore_apply_only";
|
||||
type BootstrapDialogPayload =
|
||||
| BootstrapFocusPayload
|
||||
| {
|
||||
focusMode?: BootstrapFocusPayload;
|
||||
comparePreset?: BootstrapComparePreset;
|
||||
restorePreset?: BootstrapRestorePreset;
|
||||
historyActionPreset?: BootstrapHistoryActionPreset;
|
||||
};
|
||||
type OpsPanelKey = "queue" | "oncall" | "tasks" | "runs" | "attention" | "completed";
|
||||
type BootstrapStageKey =
|
||||
| "all"
|
||||
| "ready_to_register"
|
||||
| "ready_to_apply"
|
||||
| "ready_for_prepare"
|
||||
| "ready_to_import"
|
||||
| "ready_to_release_dry_run"
|
||||
| "ready_to_publish"
|
||||
| "attention"
|
||||
| "published";
|
||||
type HostConsolePanelKey = "bundle" | "oncall" | "runs" | "completed";
|
||||
type OpsInspectorKind = "bundle" | "oncall" | "task" | "run" | "attention_bundle" | "failed_run" | "completed";
|
||||
type BootstrapOpsFocusPayload = {
|
||||
panel?: OpsPanelKey;
|
||||
stage?: BootstrapStageKey;
|
||||
host?: string;
|
||||
keyword?: string;
|
||||
clearHostFocus?: boolean;
|
||||
hostConsolePanel?: HostConsolePanelKey;
|
||||
inspectorKind?: OpsInspectorKind | "default";
|
||||
};
|
||||
type BootstrapAdminOpenPayload =
|
||||
| "overview"
|
||||
| "ops"
|
||||
| "env"
|
||||
| {
|
||||
tab?: "overview" | "ops" | "env";
|
||||
opsFocus?: BootstrapOpsFocusPayload;
|
||||
envDialog?: BootstrapDialogPayload;
|
||||
refresh?: boolean;
|
||||
};
|
||||
|
||||
const openBootstrapCenter = (payload: BootstrapAdminOpenPayload = "overview") => {
|
||||
bootstrapAdminCenterRef.value?.openDialog(payload);
|
||||
};
|
||||
|
||||
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;
|
||||
const restorePreset = typeof payload === "string" || Array.isArray(payload) ? undefined : payload.restorePreset;
|
||||
const historyActionPreset = typeof payload === "string" || Array.isArray(payload) ? undefined : payload.historyActionPreset;
|
||||
const queryFocus = Array.isArray(normalizedFocus) ? normalizedFocus.join(",") : normalizedFocus;
|
||||
router.push({
|
||||
path: "/site/index",
|
||||
query: {
|
||||
...(queryFocus === "all" ? {} : { bootstrapFocus: queryFocus }),
|
||||
...(comparePreset ? { bootstrapComparePreset: comparePreset } : {}),
|
||||
...(restorePreset ? { bootstrapRestorePreset: restorePreset } : {}),
|
||||
...(historyActionPreset ? { bootstrapHistoryActionPreset: historyActionPreset } : {})
|
||||
}
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "./index.scss";
|
||||
@use "./index.scss" as *;
|
||||
|
||||
.home-bootstrap-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -67,27 +67,26 @@ const loginForm = reactive<Login.ReqLoginForm>({
|
||||
au_pwd: ""
|
||||
});
|
||||
|
||||
// login
|
||||
|
||||
const login = (formEl: FormInstance | undefined) => {
|
||||
if (!formEl) return;
|
||||
formEl.validate(async valid => {
|
||||
if (!valid) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
// 1.执行登录接口
|
||||
|
||||
const { data } = await loginApi({ ...loginForm });
|
||||
console.log(data["admin-token"]);
|
||||
userStore.setToken(data["admin-token"]);
|
||||
userStore.setUserInfo({ name: loginForm.au_name });
|
||||
|
||||
// 2.添加动态路由
|
||||
|
||||
await initDynamicRouter();
|
||||
|
||||
// 3.清空 tabs、keepAlive 数据
|
||||
|
||||
tabsStore.setTabs([]);
|
||||
keepAliveStore.setKeepAliveName([]);
|
||||
|
||||
// 4.跳转到首页
|
||||
|
||||
router.push(HOME_URL);
|
||||
ElNotification({
|
||||
title: getTimeState(),
|
||||
@@ -101,14 +100,14 @@ const login = (formEl: FormInstance | undefined) => {
|
||||
});
|
||||
};
|
||||
|
||||
// resetForm
|
||||
|
||||
const resetForm = (formEl: FormInstance | undefined) => {
|
||||
if (!formEl) return;
|
||||
formEl.resetFields();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// 监听 enter 事件(调用登录)
|
||||
|
||||
document.onkeydown = (e: KeyboardEvent) => {
|
||||
e = (window.event as KeyboardEvent) || e;
|
||||
if (e.code === "Enter" || e.code === "enter" || e.code === "NumpadEnter") {
|
||||
@@ -120,5 +119,5 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../index.scss";
|
||||
@use "../index.scss" as *;
|
||||
</style>
|
||||
|
||||
@@ -1,83 +1,83 @@
|
||||
.login-container {
|
||||
height: 100%;
|
||||
min-height: 550px;
|
||||
background-color: #eeeeee;
|
||||
background-image: url("@/assets/images/login_bg.svg");
|
||||
background-size: 100% 100%;
|
||||
background-size: cover;
|
||||
.login-box {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
width: 96.5%;
|
||||
height: 94%;
|
||||
padding: 0 50px;
|
||||
background-color: rgb(255 255 255 / 80%);
|
||||
border-radius: 10px;
|
||||
.dark {
|
||||
position: absolute;
|
||||
top: 13px;
|
||||
right: 18px;
|
||||
}
|
||||
.login-left {
|
||||
width: 800px;
|
||||
margin-right: 10px;
|
||||
.login-left-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
.login-form {
|
||||
width: 420px;
|
||||
padding: 50px 40px 45px;
|
||||
background-color: var(--el-bg-color);
|
||||
border-radius: 10px;
|
||||
box-shadow: rgb(0 0 0 / 10%) 0 2px 10px 2px;
|
||||
.login-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 45px;
|
||||
.login-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
.logo-text {
|
||||
padding: 0 0 0 25px;
|
||||
margin: 0;
|
||||
font-size: 42px;
|
||||
font-weight: bold;
|
||||
color: #34495e;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
.el-form-item {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.login-btn {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin-top: 40px;
|
||||
white-space: nowrap;
|
||||
.el-button {
|
||||
width: 185px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (width <= 1250px) {
|
||||
.login-left {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (width <= 600px) {
|
||||
.login-form {
|
||||
width: 97% !important;
|
||||
}
|
||||
}
|
||||
.login-container {
|
||||
height: 100%;
|
||||
min-height: 550px;
|
||||
background-color: #eeeeee;
|
||||
background-image: url("@/assets/images/login_bg.svg");
|
||||
background-size: 100% 100%;
|
||||
background-size: cover;
|
||||
.login-box {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
width: 96.5%;
|
||||
height: 94%;
|
||||
padding: 0 50px;
|
||||
background-color: rgb(255 255 255 / 80%);
|
||||
border-radius: 10px;
|
||||
.dark {
|
||||
position: absolute;
|
||||
top: 13px;
|
||||
right: 18px;
|
||||
}
|
||||
.login-left {
|
||||
width: 800px;
|
||||
margin-right: 10px;
|
||||
.login-left-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
.login-form {
|
||||
width: 420px;
|
||||
padding: 50px 40px 45px;
|
||||
background-color: var(--el-bg-color);
|
||||
border-radius: 10px;
|
||||
box-shadow: rgb(0 0 0 / 10%) 0 2px 10px 2px;
|
||||
.login-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 45px;
|
||||
.login-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
.logo-text {
|
||||
padding: 0 0 0 25px;
|
||||
margin: 0;
|
||||
font-size: 42px;
|
||||
font-weight: bold;
|
||||
color: #34495e;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
.el-form-item {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.login-btn {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin-top: 40px;
|
||||
white-space: nowrap;
|
||||
.el-button {
|
||||
width: 185px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (width <= 1250px) {
|
||||
.login-left {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (width <= 600px) {
|
||||
.login-form {
|
||||
width: 97% !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
<template>
|
||||
<div class="login-container flx-center">
|
||||
<div class="login-box">
|
||||
<SwitchDark class="dark" />
|
||||
<div class="login-left">
|
||||
<img class="login-left-img" src="@/assets/images/login_left.png" alt="login" />
|
||||
</div>
|
||||
<template>
|
||||
<div class="login-container flx-center">
|
||||
<div class="login-box">
|
||||
<SwitchDark class="dark" />
|
||||
<div class="login-left">
|
||||
<img class="login-left-img" src="@/assets/images/login_left.png" alt="login" />
|
||||
</div>
|
||||
<div class="login-form">
|
||||
<div class="login-logo">
|
||||
<!-- <img class="login-icon" src="@/customize/images/logo.png" alt="" /> -->
|
||||
<h2 class="logo-text">{{ title }}</h2>
|
||||
</div>
|
||||
<LoginForm />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="login">
|
||||
import LoginForm from "./components/LoginForm.vue";
|
||||
import SwitchDark from "@/components/SwitchDark/index.vue";
|
||||
const title = import.meta.env.VITE_GLOB_APP_TITLE;
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "./index.scss";
|
||||
</style>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="login">
|
||||
import LoginForm from "./components/LoginForm.vue";
|
||||
import SwitchDark from "@/components/SwitchDark/index.vue";
|
||||
const title = import.meta.env.VITE_GLOB_APP_TITLE;
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "./index.scss" as *;
|
||||
</style>
|
||||
|
||||
@@ -1,141 +1,31 @@
|
||||
<template>
|
||||
<div class="table-box">
|
||||
<ProTable
|
||||
ref="proTable"
|
||||
highlight-current-row
|
||||
:columns="columns"
|
||||
:request-api="getTableList"
|
||||
:data-callback="dataCallback"
|
||||
>
|
||||
</ProTable>
|
||||
<ImgDialog ref="imgDialogRef" />
|
||||
<div class="module-unavailable">
|
||||
<el-alert
|
||||
title="漫画章节模块未部署到当前 Linux 测试服"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<el-card class="module-unavailable__card" shadow="never">
|
||||
<p>当前后端未提供 `Manhua` 控制器,漫画章节接口会直接返回控制器不存在。</p>
|
||||
<p>本页先收为说明态,避免继续暴露“可点开但一定失败”的章节列表和资源查看。</p>
|
||||
<p>如果后续补齐漫画章节接口,再恢复当前章节页和资源弹层。</p>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="tsx" name="complexProTable">
|
||||
import { reactive, ref } from "vue";
|
||||
import ProTable from "@/components/ProTable/index.vue";
|
||||
import { ProTableInstance, ColumnProps } from "@/components/ProTable/interface";
|
||||
import { ChapterList } from "@/api/interface/manga/chapter";
|
||||
import { getList } from "@/api/modules/manga/chapter";
|
||||
import ImgDialog from "../../components/imgDialog.vue";
|
||||
// import { fetchAndDecodeImage } from "@/utils/decrypt";
|
||||
<style scoped lang="scss">
|
||||
.module-unavailable {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
// ProTable 实例
|
||||
const proTable = ref<ProTableInstance>();
|
||||
.module-unavailable__card {
|
||||
color: var(--el-text-color-regular);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
const initParam = reactive({
|
||||
limit: 15,
|
||||
page: 1
|
||||
});
|
||||
|
||||
const dataCallback = (data: any) => {
|
||||
return {
|
||||
items: data.items,
|
||||
total: data.total,
|
||||
total_page: data.total_pages,
|
||||
page: initParam.page,
|
||||
limit: initParam.limit
|
||||
};
|
||||
};
|
||||
|
||||
const getTableList = async (params: any) => {
|
||||
let newParams = JSON.parse(JSON.stringify(params));
|
||||
initParam.page = newParams.page;
|
||||
initParam.limit = newParams.limit;
|
||||
return getList(newParams);
|
||||
};
|
||||
|
||||
// 表格配置项
|
||||
const columns = reactive<ColumnProps<ChapterList.ResList>[]>([
|
||||
{ type: "selection", fixed: "left", width: 50 },
|
||||
{
|
||||
prop: "key",
|
||||
label: "搜索内容",
|
||||
isShow: false,
|
||||
search: {
|
||||
el: "input"
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: "mhzj_id",
|
||||
label: "ID",
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
prop: "mhzj_pai_xu",
|
||||
label: "章节序号",
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
prop: "mhzj_ming_zi",
|
||||
label: "章节标题"
|
||||
},
|
||||
{
|
||||
prop: "mhzj_nei_rong",
|
||||
label: "图册资源",
|
||||
render(scope) {
|
||||
if (scope.row.mhzj_nei_rong) {
|
||||
return <el-tag onClick={() => openImgFun(scope.row)}>查看</el-tag>;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ prop: "mhzj_source_code", label: "源站" },
|
||||
{ prop: "created_at", label: "添加时间" }
|
||||
]);
|
||||
|
||||
// 图册资源
|
||||
const imgDialogRef = ref<InstanceType<typeof ImgDialog> | null>(null);
|
||||
|
||||
// const openImgFun = async (row: Partial<ChapterList.ResList> = {}) => {
|
||||
// row.name = row.mhzj_ming_zi;
|
||||
// row.url = row.mhzj_nei_rong_url;
|
||||
|
||||
// if (!row.url) {
|
||||
// console.error("No URL provided in the row.");
|
||||
// return;
|
||||
// }
|
||||
|
||||
// const newArray = row.url.map(item => ({ url: item }));
|
||||
|
||||
// try {
|
||||
// // Process each URL with fetchAndDecodeImage before assigning to fileList
|
||||
// const processedArrayPromises = newArray.map(async item => {
|
||||
// const decodedUrl = await fetchAndDecodeImage(item.url);
|
||||
// console.log(decodedUrl, "decodedUrl");
|
||||
// return { url: decodedUrl };
|
||||
// });
|
||||
|
||||
// // Wait for all promises to resolve
|
||||
// const processedArray = await Promise.all(processedArrayPromises);
|
||||
// row.url = processedArray;
|
||||
|
||||
// console.log(row.url, " row.url");
|
||||
|
||||
// const params = {
|
||||
// title: "图册资源",
|
||||
// row: { ...row },
|
||||
// getTableList: proTable.value?.getTableList
|
||||
// };
|
||||
// imgDialogRef.value?.acceptParams(params as any);
|
||||
// } catch (error) {
|
||||
// console.error("Error processing URLs: ", error);
|
||||
// }
|
||||
// };
|
||||
|
||||
const openImgFun = (row: Partial<ChapterList.ResList> = {}) => {
|
||||
row.name = row.mhzj_ming_zi;
|
||||
row.url = row.mhzj_nei_rong_url;
|
||||
const params = {
|
||||
title: "图册资源",
|
||||
row: { ...row },
|
||||
getTableList: proTable.value?.getTableList
|
||||
};
|
||||
imgDialogRef.value?.acceptParams(params as any);
|
||||
};
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.el-upload--picture-card {
|
||||
display: none;
|
||||
.module-unavailable__card p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
<template>
|
||||
<el-drawer v-model="drawerVisible" :destroy-on-close="true" size="720" :title="`${drawerProps.title}`">
|
||||
<el-form
|
||||
ref="ruleFormRef"
|
||||
label-width="90px"
|
||||
label-suffix=" :"
|
||||
:rules="rules"
|
||||
:disabled="drawerProps.isView"
|
||||
:model="drawerProps.row"
|
||||
:hide-required-asterisk="drawerProps.isView"
|
||||
>
|
||||
<el-form-item v-for="e in formColumns" :key="e.label" :label="e.label" :prop="e.prop" :required="e.required">
|
||||
<template v-if="e.type === 'switch'">
|
||||
<el-switch
|
||||
v-model="e.value"
|
||||
:active-value="`${1}`"
|
||||
:inactive-value="`${0}`"
|
||||
active-text="上架"
|
||||
inactive-text="下架"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="e.type === 'switch-index'">
|
||||
<el-switch
|
||||
v-model="e.value"
|
||||
:active-value="`${1}`"
|
||||
:inactive-value="`${0}`"
|
||||
active-text="是"
|
||||
inactive-text="否"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="e.type === 'radio'">
|
||||
<el-radio-group v-model="e.value" class="ml-4">
|
||||
<el-radio v-for="item in e.options" :key="item.value" :label="item.value" size="large">{{
|
||||
item.label
|
||||
}}</el-radio>
|
||||
</el-radio-group>
|
||||
</template>
|
||||
<template v-if="e.type === 'select'">
|
||||
<el-select filterable v-model="e.value" :placeholder="`请选择${e.label}`" clearable>
|
||||
<el-option
|
||||
v-for="(item, index) in e.options"
|
||||
:key="index"
|
||||
:label="getField(item, e.fieldNames?.label)"
|
||||
:value="getField(item, e.fieldNames?.value)"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
<template v-if="e.type === 'text'">
|
||||
<el-input v-model="e.value" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'text-i'">
|
||||
<el-input v-model="e.value" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'text-num'">
|
||||
<el-input v-model="e.value" type="number" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'textarea'">
|
||||
<el-input
|
||||
type="textarea"
|
||||
v-model="e.value"
|
||||
maxlength="36500"
|
||||
:autosize="{ minRows: 20, maxRows: 21 }"
|
||||
:placeholder="`请输入${e.label}`"
|
||||
show-word-limit
|
||||
/>
|
||||
</template>
|
||||
<template v-if="e.type === 'textarea-i'">
|
||||
<el-input
|
||||
type="textarea"
|
||||
v-model="e.value"
|
||||
maxlength="36500"
|
||||
:autosize="{ minRows: 8, maxRows: 9 }"
|
||||
:placeholder="`请输入${e.label}`"
|
||||
show-word-limit
|
||||
/>
|
||||
<div style="width: 100%">{{ e.describe }}</div>
|
||||
</template>
|
||||
<template v-if="e.type === 'textarea-i'">
|
||||
<div :class="{ 'img-more-h': fileList.length > 0 }">
|
||||
<UploadImgs disabled v-model:file-list="fileList" :drag="false" border-radius="50%">
|
||||
<template #empty>
|
||||
<el-icon><Picture /></el-icon>
|
||||
<span>请上传照片</span>
|
||||
</template>
|
||||
</UploadImgs>
|
||||
</div>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="drawerVisible = false">取消</el-button>
|
||||
<el-button v-show="!drawerProps.isView" type="primary" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="UserDrawer">
|
||||
import { ref, computed } from "vue";
|
||||
import { ElMessage, FormInstance } from "element-plus";
|
||||
import { ChapterList } from "@/api/interface/manga/chapter";
|
||||
import UploadImgs from "@/components/Upload/Imgs.vue";
|
||||
import { extractURI } from "@/utils/eleValidate";
|
||||
type Options = {
|
||||
label: string;
|
||||
value: string | number;
|
||||
};
|
||||
interface Item {
|
||||
uri: any;
|
||||
code: any;
|
||||
}
|
||||
const getField = (item: any, fieldName: string | undefined) => {
|
||||
return fieldName ? item[fieldName] : "";
|
||||
};
|
||||
type Fields = {
|
||||
label: string;
|
||||
prop: string;
|
||||
type: string;
|
||||
value: any;
|
||||
required: boolean;
|
||||
options?: Options[];
|
||||
fieldNames?: { label: string; value: string };
|
||||
describe?: string;
|
||||
};
|
||||
const generateRules = (fields: Fields[]) => {
|
||||
return fields.reduce(
|
||||
(acc, field) => {
|
||||
const { type, required, label, prop } = field;
|
||||
let message = "";
|
||||
if (type === "select") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "switch") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "switch-index") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "tag") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "radio") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "text") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "textarea-i") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "text-i") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "text-num") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "image") {
|
||||
message = `请上传${label}`;
|
||||
}
|
||||
if (type === "image-more") {
|
||||
message = `请上传${label}`;
|
||||
}
|
||||
acc[prop] = [{ required, message }];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>
|
||||
);
|
||||
};
|
||||
|
||||
const formColumns = computed((): Fields[] => {
|
||||
return [
|
||||
{
|
||||
label: "章节标题",
|
||||
prop: "mhzj_ming_zi",
|
||||
type: "text",
|
||||
get value() {
|
||||
return drawerProps.value.row!.mhzj_ming_zi;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.mhzj_ming_zi = val;
|
||||
},
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: "章节序号",
|
||||
prop: "mhzj_pai_xu",
|
||||
type: "text-num",
|
||||
get value() {
|
||||
return drawerProps.value.row!.mhzj_pai_xu;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.mhzj_pai_xu = val;
|
||||
},
|
||||
required: true
|
||||
},
|
||||
// {
|
||||
// label: "是否VIP",
|
||||
// prop: "mc_vip",
|
||||
// type: "switch-index",
|
||||
// get value() {
|
||||
// return String(drawerProps.value.row!.mc_vip);
|
||||
// },
|
||||
// set value(val) {
|
||||
// drawerProps.value.row!.mc_vip = val;
|
||||
// },
|
||||
// required: true
|
||||
// },
|
||||
|
||||
{
|
||||
label: "图册资源",
|
||||
prop: "mhzj_nei_rong",
|
||||
type: "textarea-i",
|
||||
describe: "多个用逗号(,)隔开",
|
||||
get value() {
|
||||
return drawerProps.value.row!.mhzj_nei_rong;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.mhzj_nei_rong = val;
|
||||
},
|
||||
required: true
|
||||
}
|
||||
];
|
||||
});
|
||||
const rules = ref();
|
||||
const fileList = ref([] as any);
|
||||
interface DrawerProps {
|
||||
title: string;
|
||||
isView: boolean;
|
||||
row: Partial<ChapterList.ResList>;
|
||||
api?: (params: any) => Promise<any>;
|
||||
getTableList?: () => void;
|
||||
}
|
||||
|
||||
const drawerVisible = ref(false);
|
||||
const drawerProps = ref<DrawerProps>({
|
||||
isView: false,
|
||||
title: "",
|
||||
row: {}
|
||||
});
|
||||
|
||||
// 接收父组件传过来的参数
|
||||
const acceptParams = (params: DrawerProps) => {
|
||||
drawerProps.value = params;
|
||||
rules.value = generateRules(formColumns.value);
|
||||
drawerVisible.value = true;
|
||||
fileList.value = [];
|
||||
if (drawerProps.value.row.mhzj_nei_rong) {
|
||||
const data = JSON.parse(drawerProps.value.row.mhzj_nei_rong);
|
||||
drawerProps.value.row.mhzj_nei_rong = data.map((obj: Item) => {
|
||||
obj.uri = obj.uri;
|
||||
drawerProps.value.row.cover_code = obj.code;
|
||||
return obj.uri;
|
||||
});
|
||||
const newArr = data.map(item => ({ url: item }));
|
||||
fileList.value = newArr;
|
||||
}
|
||||
// if (drawerProps.value.row.mhzj_nei_rong_detail) {
|
||||
// const data = drawerProps.value.row.mhzj_nei_rong_detail;
|
||||
// const newArr = data.map(item => ({ url: item }));
|
||||
// drawerProps.value.row.mhzj_nei_rong = data;
|
||||
// fileList.value = newArr;
|
||||
// }
|
||||
};
|
||||
|
||||
// 提交数据(新增/编辑)
|
||||
const ruleFormRef = ref<FormInstance>();
|
||||
const handleSubmit = () => {
|
||||
ruleFormRef.value!.validate(async valid => {
|
||||
if (!valid) return;
|
||||
try {
|
||||
const params = { ...drawerProps.value.row };
|
||||
if (Array.isArray(params.mhzj_nei_rong)) {
|
||||
params.mhzj_nei_rong = params.mhzj_nei_rong.map(item => ({
|
||||
uri: item,
|
||||
code: params.cover_code
|
||||
}));
|
||||
// params.mhzj_nei_rong = params.mhzj_nei_rong.map(uri => ({ uri: extractURI(uri), code: params.cover_code }));
|
||||
// params.mhzj_nei_rong = params.mhzj_nei_rong;
|
||||
} else {
|
||||
params.mhzj_nei_rong = params.mhzj_nei_rong.split(",");
|
||||
params.mhzj_nei_rong = params.mhzj_nei_rong.map(uri => ({ uri: extractURI(uri), code: params.cover_code }));
|
||||
params.mhzj_nei_rong = params.mhzj_nei_rong;
|
||||
}
|
||||
|
||||
return;
|
||||
await drawerProps.value.api!(params);
|
||||
ElMessage.success({ message: `${drawerProps.value.title}成功!` });
|
||||
drawerProps.value.getTableList!();
|
||||
drawerVisible.value = false;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
acceptParams
|
||||
});
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.el-checkbox-button__inner {
|
||||
margin-right: 10px;
|
||||
margin-bottom: 10px;
|
||||
border-left-color: #dcdfe6 !important;
|
||||
}
|
||||
.el-upload--picture-card {
|
||||
display: none;
|
||||
}
|
||||
.img-more-h {
|
||||
height: 430px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
</style>
|
||||
@@ -1,175 +0,0 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="drawerVisible"
|
||||
:title="drawerProps.title + '--' + drawerProps.name"
|
||||
:destroy-on-close="true"
|
||||
width="1000px"
|
||||
draggable
|
||||
>
|
||||
<el-form
|
||||
ref="ruleFormRef"
|
||||
label-width="0px"
|
||||
label-suffix=" :"
|
||||
:rules="rules"
|
||||
:disabled="drawerProps.isView"
|
||||
:model="drawerProps.row"
|
||||
:hide-required-asterisk="drawerProps.isView"
|
||||
>
|
||||
<el-form-item v-for="e in formColumns" :key="e.label" :label="e.label" :prop="e.prop" :required="e.required">
|
||||
<template v-if="e.type === 'image'">
|
||||
<div class="c-dialog-h">
|
||||
<img v-for="(item, index) in e.value" :key="index" :src="item" alt="" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="e.type === 'textarea-i'">
|
||||
<div :class="{ 'img-more-h': fileList.length > 0 }">
|
||||
<UploadImgs disabled v-model:file-list="fileList" height="120px" width="13.3%" :drag="false">
|
||||
<template #empty>
|
||||
<el-icon><Picture /></el-icon>
|
||||
<span>请上传照片</span>
|
||||
</template>
|
||||
</UploadImgs>
|
||||
</div>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="drawerVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="drawerVisible = false">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="UserDrawer">
|
||||
import { ref, computed } from "vue";
|
||||
import { FormInstance } from "element-plus";
|
||||
import { ChapterList } from "@/api/interface/manga/chapter";
|
||||
import UploadImgs from "@/components/Upload/Imgs.vue";
|
||||
type Options = {
|
||||
label: string;
|
||||
value: string | number;
|
||||
};
|
||||
|
||||
type Fields = {
|
||||
label: string;
|
||||
prop: string;
|
||||
type: string;
|
||||
value: any;
|
||||
required: boolean;
|
||||
options?: Options[];
|
||||
fieldNames?: { label: string; value: string };
|
||||
};
|
||||
const generateRules = (fields: Fields[]) => {
|
||||
return fields.reduce(
|
||||
(acc, field) => {
|
||||
const { type, required, label, prop } = field;
|
||||
let message = "";
|
||||
if (type === "select") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "text") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "textarea") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "textareaMore") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "radio") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
acc[prop] = [{ required, message }];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>
|
||||
);
|
||||
};
|
||||
|
||||
const formColumns = computed((): Fields[] => {
|
||||
return [
|
||||
{
|
||||
label: "",
|
||||
prop: "mhzj_nei_rong",
|
||||
type: "textarea-i",
|
||||
get value() {
|
||||
return drawerProps.value.row!.mhzj_nei_rong;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.mhzj_nei_rong = val;
|
||||
},
|
||||
required: false
|
||||
}
|
||||
];
|
||||
});
|
||||
const rules = ref();
|
||||
const fileList = ref([] as any);
|
||||
interface DrawerProps {
|
||||
title: string;
|
||||
name?: any;
|
||||
isView: boolean;
|
||||
row: Partial<ChapterList.ResList>;
|
||||
api?: (params: any) => Promise<any>;
|
||||
getTableList?: () => void;
|
||||
}
|
||||
|
||||
const drawerVisible = ref(false);
|
||||
const drawerProps = ref<DrawerProps>({
|
||||
isView: false,
|
||||
title: "",
|
||||
name: "",
|
||||
row: {}
|
||||
});
|
||||
|
||||
// 接收父组件传过来的参数
|
||||
const acceptParams = async (params: DrawerProps) => {
|
||||
drawerProps.value = params;
|
||||
rules.value = generateRules(formColumns.value);
|
||||
drawerProps.value.name = drawerProps.value.row!.mhzj_ming_zi;
|
||||
drawerVisible.value = true;
|
||||
fileList.value = [];
|
||||
if (drawerProps.value.row.mhzj_nei_rong) {
|
||||
const data = JSON.parse(drawerProps.value.row.mhzj_nei_rong);
|
||||
const newArray = data.map(item => ({ url: item }));
|
||||
// const newArray = data.map(() => ({ url: "https://loremflickr.com/390/122?random=59925" }));
|
||||
fileList.value = newArray;
|
||||
}
|
||||
};
|
||||
|
||||
// 提交数据(新增/编辑)
|
||||
const ruleFormRef = ref<FormInstance>();
|
||||
|
||||
defineExpose({
|
||||
acceptParams
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.el-dialog .el-dialog__header {
|
||||
padding: 15px 0;
|
||||
}
|
||||
.c-dialog-h {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
width: 1100px;
|
||||
max-height: 600px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
.c-dialog-h img {
|
||||
width: 19.05%;
|
||||
height: 150px;
|
||||
margin-right: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.img-more-h .upload .upload-image {
|
||||
object-fit: fill !important;
|
||||
}
|
||||
.img-more-h {
|
||||
height: 450px;
|
||||
margin-top: 15px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
.el-dialog {
|
||||
--el-dialog-margin-top: 4vh !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,200 +0,0 @@
|
||||
<template>
|
||||
<el-drawer v-model="drawerVisible" :destroy-on-close="true" size="720" :title="`${drawerProps.title}`">
|
||||
<el-form
|
||||
ref="ruleFormRef"
|
||||
label-width="80px"
|
||||
label-position="top"
|
||||
label-suffix=" :"
|
||||
:rules="rules"
|
||||
:disabled="drawerProps.isView"
|
||||
:model="drawerProps.row"
|
||||
:hide-required-asterisk="drawerProps.isView"
|
||||
>
|
||||
<el-form-item v-for="e in formColumns" :key="e.label" :label="e.label" :prop="e.prop" :required="e.required">
|
||||
<template v-if="e.type === 'switch'">
|
||||
<el-switch
|
||||
v-model="e.value"
|
||||
:active-value="`${1}`"
|
||||
:inactive-value="`${0}`"
|
||||
active-text="上架"
|
||||
inactive-text="下架"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="e.type === 'radio'">
|
||||
<el-radio-group v-model="e.value" class="ml-4">
|
||||
<el-radio v-for="item in e.options" :key="item.value" :value="item.value" size="large">{{
|
||||
item.label
|
||||
}}</el-radio>
|
||||
</el-radio-group>
|
||||
</template>
|
||||
<template v-if="e.type === 'select'">
|
||||
<el-select filterable multiple v-model="e.value" :placeholder="`请选择${e.label}`" clearable>
|
||||
<el-option
|
||||
v-for="(item, index) in e.options"
|
||||
:key="index"
|
||||
:label="getField(item, e.fieldNames?.label)"
|
||||
:value="getField(item, e.fieldNames?.value)"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
<template v-if="e.type === 'text'">
|
||||
<el-input v-model="e.value" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'text-i'">
|
||||
<el-input v-model="e.value" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'text-num'">
|
||||
<el-input v-model="e.value" type="number" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'textarea'">
|
||||
<el-input
|
||||
type="textarea"
|
||||
v-model="e.value"
|
||||
maxlength="3650"
|
||||
:placeholder="`请输入${e.label}`"
|
||||
show-word-limit
|
||||
/>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="drawerVisible = false">取消</el-button>
|
||||
<el-button v-show="!drawerProps.isView" type="primary" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="UserDrawer">
|
||||
import { ref, computed } from "vue";
|
||||
import { ElMessage, FormInstance } from "element-plus";
|
||||
import { ChapterList } from "@/api/interface/manga/chapter";
|
||||
import { arrNoticeSwitch } from "@/utils/serviceDict";
|
||||
type Options = {
|
||||
label: string;
|
||||
value: string | number;
|
||||
};
|
||||
|
||||
const getField = (item: any, fieldName: string | undefined) => {
|
||||
return fieldName ? item[fieldName] : "";
|
||||
};
|
||||
type Fields = {
|
||||
label: string;
|
||||
prop: string;
|
||||
type: string;
|
||||
value: any;
|
||||
required: boolean;
|
||||
options?: Options[];
|
||||
fieldNames?: { label: string; value: string };
|
||||
};
|
||||
const generateRules = (fields: Fields[]) => {
|
||||
return fields.reduce(
|
||||
(acc, field) => {
|
||||
const { type, required, label, prop } = field;
|
||||
let message = "";
|
||||
if (type === "select") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "tag") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "radio") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "text") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "text-i") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "text-num") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "textarea") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "image") {
|
||||
message = `请上传${label}`;
|
||||
}
|
||||
if (type === "image-more") {
|
||||
message = `请上传${label}`;
|
||||
}
|
||||
acc[prop] = [{ required, message }];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>
|
||||
);
|
||||
};
|
||||
|
||||
const formColumns = computed((): Fields[] => {
|
||||
return [
|
||||
{
|
||||
label: "是否VIP",
|
||||
prop: "mc_vip",
|
||||
type: "radio",
|
||||
get value() {
|
||||
return drawerProps.value.row!.mc_vip;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.mc_vip = val;
|
||||
},
|
||||
options: arrNoticeSwitch,
|
||||
fieldNames: { label: "label", value: "value" },
|
||||
required: false
|
||||
}
|
||||
];
|
||||
});
|
||||
const rules = ref();
|
||||
|
||||
interface DrawerProps {
|
||||
title: string;
|
||||
isView: boolean;
|
||||
row: Partial<ChapterList.ResList>;
|
||||
api?: (params: any) => Promise<any>;
|
||||
getTableList?: () => void;
|
||||
clearSelection?: () => void;
|
||||
}
|
||||
|
||||
const drawerVisible = ref(false);
|
||||
const drawerProps = ref<DrawerProps>({
|
||||
isView: false,
|
||||
title: "",
|
||||
row: {}
|
||||
});
|
||||
|
||||
// 接收父组件传过来的参数
|
||||
const acceptParams = (params: DrawerProps) => {
|
||||
drawerProps.value = params;
|
||||
rules.value = generateRules(formColumns.value);
|
||||
drawerVisible.value = true;
|
||||
drawerProps.value.row!.mc_vip = 0;
|
||||
};
|
||||
|
||||
// 提交数据(新增/编辑)
|
||||
const ruleFormRef = ref<FormInstance>();
|
||||
const handleSubmit = () => {
|
||||
ruleFormRef.value!.validate(async valid => {
|
||||
if (!valid) return;
|
||||
try {
|
||||
const params = { ...drawerProps.value.row };
|
||||
await drawerProps.value.api!(params);
|
||||
ElMessage.success({ message: `${drawerProps.value.title}成功!` });
|
||||
drawerProps.value.getTableList!();
|
||||
drawerProps.value.clearSelection!();
|
||||
drawerVisible.value = false;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
acceptParams
|
||||
});
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.el-checkbox-button__inner {
|
||||
margin-right: 10px;
|
||||
margin-bottom: 10px;
|
||||
border-left-color: #dcdfe6 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,224 +0,0 @@
|
||||
<template>
|
||||
<el-drawer v-model="drawerVisible" size="80%" title="章节列表" :destroy-on-close="true">
|
||||
<ProTable
|
||||
ref="proTable"
|
||||
highlight-current-row
|
||||
:columns="columns"
|
||||
:request-api="getTableList"
|
||||
:data-callback="dataCallback"
|
||||
:init-param="initParam"
|
||||
:is-displayed="isDisplayed"
|
||||
:is-search-location="isSearchLocation"
|
||||
>
|
||||
<!-- 表格 header 按钮 -->
|
||||
<template #tableHeader="scope">
|
||||
<el-button type="primary" style="display: none" :icon="CirclePlus" @click="openDrawer(true)">
|
||||
新增漫画章节
|
||||
</el-button>
|
||||
<el-button
|
||||
style="display: none"
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
plain
|
||||
:disabled="!scope.isSelected"
|
||||
@click="batchDelete(scope.selectedList)"
|
||||
>
|
||||
批量删除章节
|
||||
</el-button>
|
||||
<el-button
|
||||
style="display: none"
|
||||
type="primary"
|
||||
:icon="EditPen"
|
||||
plain
|
||||
:disabled="!scope.isSelected"
|
||||
@click="batchModify(scope.selectedList)"
|
||||
>
|
||||
批量编辑漫画章节
|
||||
</el-button>
|
||||
</template>
|
||||
<!-- 表格操作 -->
|
||||
<template #operation="scope">
|
||||
<el-button type="primary" link :icon="EditPen" @click="openDrawer(false, scope.row)">编辑</el-button>
|
||||
<el-button type="danger" style="display: none" link :icon="Delete" @click="deleteAccount(scope.row)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</ProTable>
|
||||
<template #footer>
|
||||
<el-button @click="drawerVisible = false">取消</el-button>
|
||||
<el-button @click="drawerVisible = false" type="primary">确定</el-button>
|
||||
</template>
|
||||
<ChapterDrawer ref="chapterDrawerRef" />
|
||||
<ModifyDrawer ref="modifyDrawerRef" />
|
||||
<ImgDialog ref="imgDialogRef" />
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="tsx">
|
||||
import { ref, reactive } from "vue";
|
||||
import ProTable from "@/components/ProTable/index.vue";
|
||||
import { ColumnProps, ProTableInstance } from "@/components/ProTable/interface";
|
||||
import { useHandleData } from "@/hooks/useHandleData";
|
||||
import { Delete, EditPen, CirclePlus } from "@element-plus/icons-vue";
|
||||
import { ChapterList } from "@/api/interface/manga/chapter";
|
||||
import { getList, saveData, deleteItem, batchData } from "@/api/modules/manga/chapter";
|
||||
import ChapterDrawer from "../component/chapterDrawer.vue";
|
||||
import ModifyDrawer from "../component/modifyDrawer.vue";
|
||||
import ImgDialog from "../../components/imgDialog.vue";
|
||||
// ProTable 实例
|
||||
const proTable = ref<ProTableInstance>();
|
||||
|
||||
// 如果表格需要初始化请求参数,直接定义传给 ProTable(之后每次请求都会自动带上该参数,此参数更改之后也会一直带上,改变此参数会自动刷新表格数据)
|
||||
const initParam = reactive({
|
||||
mhxq_id: 0,
|
||||
limit: 25,
|
||||
page: 1
|
||||
});
|
||||
|
||||
const dataCallback = (data: any) => {
|
||||
return {
|
||||
items: data.items,
|
||||
total: data.total,
|
||||
total_page: data.total_pages,
|
||||
page: initParam.page,
|
||||
limit: initParam.limit
|
||||
};
|
||||
};
|
||||
|
||||
const getTableList = async (params: any) => {
|
||||
let newParams = JSON.parse(JSON.stringify(params));
|
||||
initParam.page = newParams.page;
|
||||
initParam.limit = newParams.limit;
|
||||
return getList(newParams);
|
||||
};
|
||||
|
||||
const isDisplayed = ref<any>(false);
|
||||
const isSearchLocation = ref<any>(false);
|
||||
|
||||
// 表格配置项
|
||||
const columns = reactive<ColumnProps<ChapterList.ResList>[]>([
|
||||
// { type: "selection", fixed: "left", width: 50 },
|
||||
{
|
||||
prop: "key",
|
||||
label: "搜索内容",
|
||||
isShow: false,
|
||||
search: {
|
||||
el: "input"
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: "mhzj_id",
|
||||
label: "ID",
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
prop: "mhzj_pai_xu",
|
||||
label: "章节序号",
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
prop: "mhzj_ming_zi",
|
||||
label: "章节标题"
|
||||
},
|
||||
{
|
||||
prop: "mhzj_nei_rong",
|
||||
label: "图册资源",
|
||||
render(scope) {
|
||||
if (scope.row.mhzj_nei_rong) {
|
||||
return <el-tag onClick={() => openImgFun(scope.row)}>查看</el-tag>;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ prop: "mhzj_source_code", label: "源站" },
|
||||
{ prop: "created_at", label: "添加时间" }
|
||||
// { prop: "operation", label: "操作", fixed: "right", width: 200 }
|
||||
]);
|
||||
|
||||
const drawerVisible = ref(false);
|
||||
// 接收父组件传过来的参数
|
||||
const acceptParams = (data: any) => {
|
||||
drawerVisible.value = true;
|
||||
initParam.mhxq_id = data.mhxq_id;
|
||||
};
|
||||
|
||||
// 删除章节
|
||||
const deleteAccount = async (params: ChapterList.ResList) => {
|
||||
await useHandleData(deleteItem, { mhzj_id: params.mhzj_id, mhxq_id: initParam.mhxq_id }, "删除所选信息");
|
||||
proTable.value?.getTableList();
|
||||
};
|
||||
|
||||
// 批量删除章节
|
||||
const batchDelete = async (id: any[]) => {
|
||||
const ids = id.map((item: ChapterList.ResList) => item.mhzj_id);
|
||||
await useHandleData(deleteItem, { mhzj_id: ids.join(","), mhxq_id: initParam.mhxq_id }, "删除所选信息");
|
||||
proTable.value?.clearSelection();
|
||||
proTable.value?.getTableList();
|
||||
};
|
||||
|
||||
// 打开 drawer(新增、编辑)
|
||||
const chapterDrawerRef = ref<InstanceType<typeof ChapterDrawer> | null>(null);
|
||||
|
||||
const openDrawer = (isEdit: boolean, row: Partial<ChapterList.ResList> = {}) => {
|
||||
row.mhxq_id = initParam.mhxq_id;
|
||||
const params = {
|
||||
title: isEdit ? "新增漫画章节" : "编辑漫画章节",
|
||||
row: { ...row },
|
||||
api: saveData,
|
||||
getTableList: proTable.value?.getTableList
|
||||
};
|
||||
chapterDrawerRef.value?.acceptParams(params as any);
|
||||
};
|
||||
|
||||
// 批量修改视频信息
|
||||
const modifyDrawerRef = ref<InstanceType<typeof ModifyDrawer> | null>(null);
|
||||
const batchModify = async (id: any[], row: Partial<ChapterList.ResList> = {}) => {
|
||||
const ids = id.map((item: ChapterList.ResList) => item.mhxq_id);
|
||||
row.mhzj_id = ids.join(",");
|
||||
row.mhxq_id = initParam.mhxq_id;
|
||||
const params = {
|
||||
title: "批量编辑漫画章节",
|
||||
row: { ...row },
|
||||
api: batchData,
|
||||
getTableList: proTable.value?.getTableList,
|
||||
clearSelection: proTable.value?.clearSelection
|
||||
};
|
||||
modifyDrawerRef.value?.acceptParams(params as any);
|
||||
};
|
||||
|
||||
// 图册资源
|
||||
const imgDialogRef = ref<InstanceType<typeof ImgDialog> | null>(null);
|
||||
const openImgFun = (row: Partial<ChapterList.ResList> = {}) => {
|
||||
row.name = row.mhzj_ming_zi;
|
||||
row.url = row.mhzj_nei_rong_url;
|
||||
const params = {
|
||||
title: "图册资源",
|
||||
row: { ...row },
|
||||
getTableList: proTable.value?.getTableList
|
||||
};
|
||||
imgDialogRef.value?.acceptParams(params as any);
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
acceptParams
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.demo-form-inline {
|
||||
.el-form-item {
|
||||
width: 420px;
|
||||
}
|
||||
:deep(.el-form-item__content) {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: flex-start;
|
||||
.tips {
|
||||
margin-left: 10px;
|
||||
font-size: 12px;
|
||||
color: #999999;
|
||||
}
|
||||
}
|
||||
}
|
||||
.btn-group {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,481 +0,0 @@
|
||||
<template>
|
||||
<el-drawer v-model="drawerVisible" :destroy-on-close="true" size="720" :title="`${drawerProps.title}`">
|
||||
<el-form
|
||||
ref="ruleFormRef"
|
||||
label-width="90px"
|
||||
label-suffix=" :"
|
||||
:rules="rules"
|
||||
:disabled="drawerProps.isView"
|
||||
:model="drawerProps.row"
|
||||
:hide-required-asterisk="drawerProps.isView"
|
||||
>
|
||||
<el-form-item v-for="e in formColumns" :key="e.label" :label="e.label" :prop="e.prop" :required="e.required">
|
||||
<template v-if="e.type === 'switch'">
|
||||
<el-switch
|
||||
v-model="e.value"
|
||||
:active-value="`${1}`"
|
||||
:inactive-value="`${0}`"
|
||||
active-text="上架"
|
||||
inactive-text="下架"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="e.type === 'switch-index'">
|
||||
<el-switch
|
||||
v-model="e.value"
|
||||
:active-value="`${1}`"
|
||||
:inactive-value="`${0}`"
|
||||
active-text="是"
|
||||
inactive-text="否"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="e.type === 'radio'">
|
||||
<el-radio-group v-model="e.value" class="ml-4">
|
||||
<el-radio v-for="item in e.options" :key="item.value" :value="item.value" size="large">{{
|
||||
item.label
|
||||
}}</el-radio>
|
||||
</el-radio-group>
|
||||
</template>
|
||||
<template v-if="e.type === 'select'">
|
||||
<el-select filterable v-model="e.value" :placeholder="`请选择${e.label}`" clearable>
|
||||
<el-option
|
||||
v-for="(item, index) in e.options"
|
||||
:key="index"
|
||||
:label="getField(item, e.fieldNames?.label)"
|
||||
:value="getField(item, e.fieldNames?.value)"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
<template v-if="e.type === 'text'">
|
||||
<el-input v-model="e.value" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'text-i'">
|
||||
<el-input v-model="e.value" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'text-num'">
|
||||
<el-input v-model="e.value" type="number" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'textarea'">
|
||||
<el-input
|
||||
type="textarea"
|
||||
v-model="e.value"
|
||||
:rows="5"
|
||||
maxlength="3650"
|
||||
:placeholder="`请输入${e.label}`"
|
||||
show-word-limit
|
||||
/>
|
||||
</template>
|
||||
<template v-if="e.type === 'tag'">
|
||||
<div class="tag-more-s">
|
||||
<el-input
|
||||
v-model="drawerProps.row.searchText"
|
||||
placeholder="请输入搜索内容"
|
||||
clearable
|
||||
@input="handleSearch"
|
||||
/>
|
||||
<div>
|
||||
<span>选中标签:</span>
|
||||
<div class="tag-more-h">
|
||||
<el-checkbox-group v-model="TagList" @change="placeableCheckedCitiesChange">
|
||||
<el-checkbox-button
|
||||
class="vip-power"
|
||||
v-for="item in matchingObjects"
|
||||
:value="item.t_id"
|
||||
:key="item.t_id"
|
||||
>
|
||||
{{ item.t_name }}
|
||||
</el-checkbox-button>
|
||||
</el-checkbox-group>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span>未选中标签:</span>
|
||||
<div class="tag-more-h">
|
||||
<el-checkbox-group v-model="TagList" @change="placeableCheckedCitiesChange">
|
||||
<el-checkbox-button
|
||||
class="vip-power"
|
||||
v-for="item in nonMatchingObjects"
|
||||
:value="item.t_id"
|
||||
:key="item.t_id"
|
||||
>
|
||||
{{ item.t_name }}
|
||||
</el-checkbox-button>
|
||||
</el-checkbox-group>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="e.type === 'text-i'">
|
||||
<UploadImg disabled v-model:image-url="drawerProps.row!.m_cover" width="135px" height="135px" :file-size="3">
|
||||
<template #empty>
|
||||
<el-icon><Avatar /></el-icon>
|
||||
<span>请上传封面</span>
|
||||
</template>
|
||||
</UploadImg>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="drawerVisible = false">取消</el-button>
|
||||
<el-button v-show="!drawerProps.isView" type="primary" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="UserDrawer">
|
||||
import { ref, computed, reactive } from "vue";
|
||||
import { ElMessage, FormInstance } from "element-plus";
|
||||
import { MangaList } from "@/api/interface/manga/list";
|
||||
import { arrNovelType } from "@/utils/serviceDict";
|
||||
import { getTypeList } from "@/api/modules/tag/list";
|
||||
import UploadImg from "@/components/Upload/Img.vue";
|
||||
// import { extractURI } from "@/utils/eleValidate";
|
||||
type Options = {
|
||||
label: string;
|
||||
value: string | number;
|
||||
};
|
||||
interface Item {
|
||||
t_id: any;
|
||||
uri: any;
|
||||
}
|
||||
const getField = (item: any, fieldName: string | undefined) => {
|
||||
return fieldName ? item[fieldName] : "";
|
||||
};
|
||||
type Fields = {
|
||||
label: string;
|
||||
prop: string;
|
||||
type: string;
|
||||
value: any;
|
||||
required: boolean;
|
||||
options?: Options[];
|
||||
fieldNames?: { label: string; value: string };
|
||||
};
|
||||
const generateRules = (fields: Fields[]) => {
|
||||
return fields.reduce(
|
||||
(acc, field) => {
|
||||
const { type, required, label, prop } = field;
|
||||
let message = "";
|
||||
if (type === "select") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "tag") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "radio") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "textarea") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "text") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "text-i") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "text-num") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "image") {
|
||||
message = `请上传${label}`;
|
||||
}
|
||||
if (type === "image-more") {
|
||||
message = `请上传${label}`;
|
||||
}
|
||||
acc[prop] = [{ required, message }];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>
|
||||
);
|
||||
};
|
||||
|
||||
const TagList = ref([] as any);
|
||||
const arrTagList = ref([] as any);
|
||||
const arrNewTagList = ref([] as any);
|
||||
const matchingObjects = ref([] as any); //选中相同
|
||||
const nonMatchingObjects = ref([] as any); //没有选中的
|
||||
const matchingObjectsA = ref([] as any); //选中相同
|
||||
const nonMatchingObjectsB = ref([] as any); //没有选中的
|
||||
const getTagList = async () => {
|
||||
try {
|
||||
const initParam = reactive({
|
||||
t_type: 1,
|
||||
limit: 3650,
|
||||
page: 1,
|
||||
t_hidden: 1
|
||||
});
|
||||
const siteRes = await getTypeList(initParam);
|
||||
arrTagList.value = siteRes.data.item;
|
||||
arrNewTagList.value = siteRes.data.item;
|
||||
matchingObjects.value = [];
|
||||
matchingObjectsA.value = [];
|
||||
// 遍历第一个数组的每个元素
|
||||
TagList.value.forEach(index => {
|
||||
// 遍历第二个数组的每个元素
|
||||
arrNewTagList.value.forEach(obj => {
|
||||
// 如果第二个数组中的元素的t_id与第一个数组中的元素相同,则将其添加到新数组中
|
||||
if (obj.t_id === index) {
|
||||
matchingObjects.value = [...matchingObjects.value, obj];
|
||||
matchingObjectsA.value = [...matchingObjectsA.value, obj];
|
||||
const matchingIds = matchingObjectsA.value.map(obj => obj.t_id);
|
||||
TagList.value = matchingIds;
|
||||
}
|
||||
});
|
||||
});
|
||||
// 创建一个新数组,包含objectArray中id不存在于arrayA的元素
|
||||
nonMatchingObjects.value = arrNewTagList.value.filter(obj => !TagList.value.includes(obj.t_id));
|
||||
nonMatchingObjectsB.value = arrNewTagList.value.filter(obj => !TagList.value.includes(obj.t_id));
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理搜索事件
|
||||
function handleSearch() {
|
||||
// 在输入事件中实时更新过滤后的标签列表
|
||||
filteredTagList();
|
||||
}
|
||||
function filteredTagList() {
|
||||
if (drawerProps.value.row!.searchText.length > 0) {
|
||||
let arrData = arrTagList.value.filter(tag =>
|
||||
tag.t_name.toLowerCase().includes(drawerProps.value.row!.searchText.toLowerCase())
|
||||
);
|
||||
if (arrData.length > 0) {
|
||||
matchingObjects.value = matchingObjects.value.filter(objA => arrData.some(objB => objB.t_id === objA.t_id));
|
||||
nonMatchingObjects.value = nonMatchingObjects.value.filter(objA => arrData.some(objB => objB.t_id === objA.t_id));
|
||||
} else {
|
||||
matchingObjects.value = [];
|
||||
nonMatchingObjects.value = [];
|
||||
}
|
||||
} else {
|
||||
matchingObjects.value = matchingObjectsA.value;
|
||||
nonMatchingObjects.value = nonMatchingObjectsB.value;
|
||||
}
|
||||
}
|
||||
|
||||
//标签数组
|
||||
async function placeableCheckedCitiesChange(value) {
|
||||
matchingObjects.value = [];
|
||||
matchingObjectsA.value = [];
|
||||
drawerProps.value.row.m_tags = value;
|
||||
// 遍历第一个数组的每个元素
|
||||
TagList.value.forEach(index => {
|
||||
// 遍历第二个数组的每个元素
|
||||
arrNewTagList.value.forEach(obj => {
|
||||
// 如果第二个数组中的元素的t_id与第一个数组中的元素相同,则将其添加到新数组中
|
||||
if (obj.t_id === index) {
|
||||
matchingObjects.value = [...matchingObjects.value, obj];
|
||||
matchingObjectsA.value = [...matchingObjectsA.value, obj];
|
||||
const matchingIds = matchingObjectsA.value.map(obj => obj.t_id);
|
||||
TagList.value = matchingIds;
|
||||
}
|
||||
});
|
||||
});
|
||||
nonMatchingObjects.value = arrNewTagList.value.filter(obj => !TagList.value.includes(obj.t_id));
|
||||
nonMatchingObjectsB.value = arrNewTagList.value.filter(obj => !TagList.value.includes(obj.t_id));
|
||||
}
|
||||
|
||||
const formColumns = computed((): Fields[] => {
|
||||
return [
|
||||
{
|
||||
label: "漫画名称",
|
||||
prop: "m_name",
|
||||
type: "text",
|
||||
get value() {
|
||||
return drawerProps.value.row!.m_name;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.m_name = val;
|
||||
},
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: "漫画作者",
|
||||
prop: "m_author",
|
||||
type: "text",
|
||||
get value() {
|
||||
return drawerProps.value.row!.m_author;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.m_author = val;
|
||||
},
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: "来源 ID",
|
||||
prop: "m_source_id",
|
||||
type: "text-num",
|
||||
get value() {
|
||||
return drawerProps.value.row!.m_source_id;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.m_source_id = val;
|
||||
},
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: "阅 读 数",
|
||||
prop: "m_hits",
|
||||
type: "text-num",
|
||||
get value() {
|
||||
return drawerProps.value.row!.m_hits;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.m_hits = val;
|
||||
},
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: "漫画状态",
|
||||
prop: "m_status",
|
||||
type: "switch",
|
||||
get value() {
|
||||
return String(drawerProps.value.row!.m_status);
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.m_status = val;
|
||||
},
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: "漫画类型",
|
||||
prop: "m_type",
|
||||
type: "radio",
|
||||
get value() {
|
||||
return drawerProps.value.row!.m_type;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.m_type = val;
|
||||
},
|
||||
options: arrNovelType,
|
||||
fieldNames: { label: "label", value: "value" },
|
||||
required: true
|
||||
},
|
||||
// {
|
||||
// label: "首页轮播状态",
|
||||
// prop: "m_slide_status",
|
||||
// type: "switch-index",
|
||||
// get value() {
|
||||
// return String(drawerProps.value.row!.m_slide_status);
|
||||
// },
|
||||
// set value(val) {
|
||||
// drawerProps.value.row!.m_slide_status = val;
|
||||
// },
|
||||
// required: true
|
||||
// },
|
||||
|
||||
{
|
||||
label: "漫画封面",
|
||||
prop: "m_cover_edit",
|
||||
type: "text",
|
||||
get value() {
|
||||
return drawerProps.value.row!.m_cover_edit;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.m_cover_edit = val;
|
||||
},
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: "漫画描述",
|
||||
prop: "m_description",
|
||||
type: "textarea",
|
||||
get value() {
|
||||
return drawerProps.value.row!.m_description;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.m_description = val;
|
||||
},
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: "漫画标签",
|
||||
prop: "m_tags",
|
||||
type: "tag",
|
||||
get value() {
|
||||
return drawerProps.value.row!.m_tags;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.m_tags = val;
|
||||
},
|
||||
options: arrTagList.value,
|
||||
fieldNames: { label: "label", value: "value" },
|
||||
required: true
|
||||
}
|
||||
];
|
||||
});
|
||||
const rules = ref();
|
||||
|
||||
interface DrawerProps {
|
||||
title: string;
|
||||
isView: boolean;
|
||||
row: Partial<MangaList.ResList>;
|
||||
api?: (params: any) => Promise<any>;
|
||||
getTableList?: () => void;
|
||||
}
|
||||
|
||||
const drawerVisible = ref(false);
|
||||
const drawerProps = ref<DrawerProps>({
|
||||
isView: false,
|
||||
title: "",
|
||||
row: {}
|
||||
});
|
||||
|
||||
// 接收父组件传过来的参数
|
||||
const acceptParams = (params: DrawerProps) => {
|
||||
drawerProps.value = params;
|
||||
rules.value = generateRules(formColumns.value);
|
||||
drawerVisible.value = true;
|
||||
TagList.value = [];
|
||||
if (drawerProps.value.title === "新增漫画") {
|
||||
drawerProps.value.row!.m_type = 0;
|
||||
}
|
||||
if (drawerProps.value.row.m_cover) {
|
||||
drawerProps.value.row.m_cover_code = drawerProps.value.row.m_cover.code;
|
||||
drawerProps.value.row.m_cover_edit = drawerProps.value.row.m_cover.uri;
|
||||
}
|
||||
if (drawerProps.value.row.m_tags) {
|
||||
TagList.value = drawerProps.value.row.m_tags.map((obj: Item) => obj.t_id).map(Number);
|
||||
}
|
||||
getTagList();
|
||||
};
|
||||
|
||||
// 提交数据(新增/编辑)
|
||||
const ruleFormRef = ref<FormInstance>();
|
||||
const handleSubmit = () => {
|
||||
ruleFormRef.value!.validate(async valid => {
|
||||
if (!valid) return;
|
||||
try {
|
||||
const params = { ...drawerProps.value.row };
|
||||
let arrData = {
|
||||
uri: params.m_cover_edit,
|
||||
code: params.m_cover_code
|
||||
};
|
||||
params.m_cover = arrData;
|
||||
params.m_tags = TagList.value;
|
||||
await drawerProps.value.api!(params);
|
||||
ElMessage.success({ message: `${drawerProps.value.title}成功!` });
|
||||
drawerProps.value.getTableList!();
|
||||
drawerVisible.value = false;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
acceptParams
|
||||
});
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.el-checkbox-button__inner {
|
||||
margin-right: 10px;
|
||||
margin-bottom: 10px;
|
||||
border-left-color: #dcdfe6 !important;
|
||||
}
|
||||
.tag-more-s {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid black;
|
||||
box-shadow: 0 0 4px 2px rgb(0 0 0 / 90%);
|
||||
}
|
||||
</style>
|
||||
@@ -1,72 +1,31 @@
|
||||
<template>
|
||||
<div class="table-box">
|
||||
<ProTable
|
||||
ref="proTable"
|
||||
highlight-current-row
|
||||
:columns="columns"
|
||||
:request-api="getTableList"
|
||||
:data-callback="dataCallback"
|
||||
>
|
||||
</ProTable>
|
||||
<div class="module-unavailable">
|
||||
<el-alert
|
||||
title="漫画分类模块未部署到当前 Linux 测试服"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<el-card class="module-unavailable__card" shadow="never">
|
||||
<p>当前后端未提供 `Manhua` 控制器,漫画分类接口会直接返回控制器不存在。</p>
|
||||
<p>本页先保留为说明态,避免把缺模块误判成前端筛选或表格故障。</p>
|
||||
<p>如果后续补齐漫画分类接口,再恢复当前列表页即可。</p>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="tsx" name="complexProTable">
|
||||
import { reactive, ref } from "vue";
|
||||
import { fenLeiList } from "@/api/interface/manga/fenlei";
|
||||
import ProTable from "@/components/ProTable/index.vue";
|
||||
import { ProTableInstance, ColumnProps } from "@/components/ProTable/interface";
|
||||
import { getList } from "@/api/modules/manga/fenlei";
|
||||
<style scoped lang="scss">
|
||||
.module-unavailable {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
// ProTable 实例
|
||||
const proTable = ref<ProTableInstance>();
|
||||
.module-unavailable__card {
|
||||
color: var(--el-text-color-regular);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
const initParam = reactive({
|
||||
limit: 15,
|
||||
page: 1
|
||||
});
|
||||
|
||||
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 getList(newParams);
|
||||
};
|
||||
|
||||
// 表格配置项
|
||||
const columns = reactive<ColumnProps<fenLeiList.ResList>[]>([
|
||||
{ type: "selection", fixed: "left", width: 50 },
|
||||
{
|
||||
prop: "key",
|
||||
label: "搜索内容",
|
||||
isShow: false,
|
||||
search: {
|
||||
el: "input"
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: "mhfl_id",
|
||||
label: "ID",
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
prop: "mhfl_ming_zi",
|
||||
label: "分类名称"
|
||||
},
|
||||
|
||||
{
|
||||
prop: "mhfl_source_code",
|
||||
label: "来源"
|
||||
}
|
||||
]);
|
||||
</script>
|
||||
.module-unavailable__card p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,200 +1,31 @@
|
||||
<template>
|
||||
<div class="table-box">
|
||||
<ProTable
|
||||
ref="proTable"
|
||||
highlight-current-row
|
||||
:columns="columns"
|
||||
:request-api="getTableList"
|
||||
:data-callback="dataCallback"
|
||||
>
|
||||
<!-- 表格 header 按钮 -->
|
||||
<template #tableHeader="scope">
|
||||
<el-button type="primary" style="display: none" :icon="CirclePlus" @click="openDrawer(true)">新增</el-button>
|
||||
<el-button
|
||||
style="display: none"
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
plain
|
||||
:disabled="!scope.isSelected"
|
||||
@click="batchDelete(scope.selectedList)"
|
||||
>
|
||||
批量删除漫画
|
||||
</el-button>
|
||||
</template>
|
||||
<!-- 表格操作 -->
|
||||
<template #operation="scope">
|
||||
<el-button type="success" link :icon="ChatLineSquare" @click="openChapterDrawer(scope.row)">章节</el-button>
|
||||
<el-button type="primary" style="display: none" link :icon="EditPen" @click="openDrawer(false, scope.row)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="danger" style="display: none" link :icon="Delete" @click="deleteAccount(scope.row)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</ProTable>
|
||||
<Drawer ref="drawerRef" />
|
||||
<ChapterDrawer ref="chapterDrawerRef" />
|
||||
<div class="module-unavailable">
|
||||
<el-alert
|
||||
title="漫画管理模块未部署到当前 Linux 测试服"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<el-card class="module-unavailable__card" shadow="never">
|
||||
<p>当前后端未提供 `Manhua` 控制器,漫画列表接口会直接返回控制器不存在。</p>
|
||||
<p>本页先收为说明态,避免继续暴露会误导验收的假列表。</p>
|
||||
<p>如果后续测试服补齐漫画模块,再恢复列表、章节抽屉和编辑链路。</p>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="tsx" name="complexProTable">
|
||||
import { reactive, ref } from "vue";
|
||||
import { MangaList } from "@/api/interface/manga/list";
|
||||
import { useHandleData } from "@/hooks/useHandleData";
|
||||
import ProTable from "@/components/ProTable/index.vue";
|
||||
import { Delete, EditPen, CirclePlus, ChatLineSquare } from "@element-plus/icons-vue";
|
||||
import { ProTableInstance, ColumnProps } from "@/components/ProTable/interface";
|
||||
import { getList, saveData, deleteItem } from "@/api/modules/manga/list";
|
||||
import Drawer from "./drawer.vue";
|
||||
import ChapterDrawer from "./components/chapter.vue";
|
||||
import { getAllList } from "@/api/modules/manga/fenlei";
|
||||
import { LOSE_STR_KEY, LOGIN_URL } from "@/config";
|
||||
import router from "@/routers";
|
||||
import { h } from "vue";
|
||||
import { AsyncImageComponent } from "@/utils/decrypt";
|
||||
// ProTable 实例
|
||||
const proTable = ref<ProTableInstance>();
|
||||
|
||||
//漫画分类
|
||||
const getFenLeiList = ref([] as any);
|
||||
getAllList().then(res => {
|
||||
if (res.code === LOSE_STR_KEY) {
|
||||
router.replace(LOGIN_URL);
|
||||
return;
|
||||
}
|
||||
getFenLeiList.value = res.data.items;
|
||||
});
|
||||
|
||||
const initParam = reactive({
|
||||
limit: 15,
|
||||
page: 1
|
||||
});
|
||||
|
||||
const dataCallback = (data: any) => {
|
||||
return {
|
||||
items: data.items,
|
||||
total: data.total,
|
||||
total_page: data.total_pages,
|
||||
page: initParam.page,
|
||||
limit: initParam.limit
|
||||
};
|
||||
};
|
||||
|
||||
const getTableList = async (params: any) => {
|
||||
let newParams = JSON.parse(JSON.stringify(params));
|
||||
initParam.page = newParams.page;
|
||||
initParam.limit = newParams.limit;
|
||||
return await getList(newParams);
|
||||
};
|
||||
|
||||
// 表格配置项
|
||||
const columns = reactive<ColumnProps<MangaList.ResList>[]>([
|
||||
// { type: "selection", fixed: "left", width: 50 },
|
||||
{
|
||||
prop: "key",
|
||||
label: "搜索内容",
|
||||
isShow: false,
|
||||
search: {
|
||||
el: "input"
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: "mhxq_id",
|
||||
label: "ID",
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
prop: "mhxq_ming_zi",
|
||||
label: "名称",
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
prop: "https_cover_url",
|
||||
label: "封面",
|
||||
width: 120,
|
||||
render(scope) {
|
||||
return h(AsyncImageComponent, { url: scope.row.https_cover_url });
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: "mhxq_zhuang_tai",
|
||||
label: "状态"
|
||||
},
|
||||
{
|
||||
prop: "mhfl_id",
|
||||
label: "分类",
|
||||
width: 100,
|
||||
fieldNames: { label: "mhfl_ming_zi", value: "mhfl_id" },
|
||||
enum: getFenLeiList
|
||||
},
|
||||
{
|
||||
prop: "mhxq_zuo_zhe",
|
||||
label: "作者"
|
||||
},
|
||||
|
||||
{
|
||||
prop: "mhxq_biao_qian",
|
||||
label: "标签",
|
||||
width: 250,
|
||||
render(scope) {
|
||||
if (scope.row.mhxq_biao_qian) {
|
||||
if (Array.isArray(scope.row.mhxq_biao_qian)) {
|
||||
return <span>{scope.row.mhxq_biao_qian.join(", ")}</span>;
|
||||
} else {
|
||||
const data = JSON.parse(scope.row.mhxq_biao_qian);
|
||||
return <span>{data.join(", ")}</span>;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ prop: "mhxq_geng_xin_shi_jian", label: "最后更新时间", width: 120 },
|
||||
{ prop: "mhxq_jian_jie", label: "描述", width: 170 },
|
||||
{ prop: "created_at", label: "添加时间", width: 170 },
|
||||
{ prop: "operation", label: "操作", fixed: "right", width: 120 }
|
||||
]);
|
||||
|
||||
// 删除漫画信息
|
||||
const deleteAccount = async (params: MangaList.ResList) => {
|
||||
await useHandleData(deleteItem, { mhxq_id: params.mhxq_id }, "删除所选信息");
|
||||
proTable.value?.getTableList();
|
||||
};
|
||||
|
||||
// 批量删除漫画信息
|
||||
const batchDelete = async (id: any[]) => {
|
||||
const ids = id.map((item: MangaList.ResList) => item.mhxq_id);
|
||||
await useHandleData(deleteItem, { mhxq_id: ids.join(",") }, "删除所选信息");
|
||||
proTable.value?.clearSelection();
|
||||
proTable.value?.getTableList();
|
||||
};
|
||||
// 打开 drawer(新增、编辑)
|
||||
const drawerRef = ref<InstanceType<typeof Drawer> | null>(null);
|
||||
|
||||
const openDrawer = (isEdit: boolean, row: Partial<MangaList.ResList> = {}) => {
|
||||
const params = {
|
||||
title: isEdit ? "新增漫画" : "编辑漫画",
|
||||
row: { ...row },
|
||||
api: saveData,
|
||||
getTableList: proTable.value?.getTableList
|
||||
};
|
||||
drawerRef.value?.acceptParams(params as any);
|
||||
};
|
||||
|
||||
// 章节
|
||||
const chapterDrawerRef = ref<InstanceType<typeof ChapterDrawer> | null>(null);
|
||||
const openChapterDrawer = (row: Partial<MangaList.ResList> = {}) => {
|
||||
chapterDrawerRef.value?.acceptParams(row as any);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.el-table .warning-row,
|
||||
.el-table .warning-row .el-table-fixed-column--right,
|
||||
.el-table .warning-row .el-table-fixed-column--left {
|
||||
background-color: var(--el-color-warning-light-9);
|
||||
<style scoped lang="scss">
|
||||
.module-unavailable {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
.el-table .success-row,
|
||||
.el-table .success-row .el-table-fixed-column--right,
|
||||
.el-table .success-row .el-table-fixed-column--left {
|
||||
background-color: var(--el-color-success-light-9);
|
||||
|
||||
.module-unavailable__card {
|
||||
color: var(--el-text-color-regular);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.module-unavailable__card p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
<template>
|
||||
<el-dialog v-model="drawerVisible" :title="drawerProps.title" :destroy-on-close="true" width="520px" draggable>
|
||||
<el-form
|
||||
ref="ruleFormRef"
|
||||
label-width="50px"
|
||||
label-suffix=" :"
|
||||
:rules="rules"
|
||||
:disabled="drawerProps.isView"
|
||||
:model="drawerProps.row"
|
||||
:hide-required-asterisk="drawerProps.isView"
|
||||
>
|
||||
<el-form-item v-for="e in formColumns" :key="e.label" :label="e.label" :prop="e.prop" :required="e.required">
|
||||
<template v-if="e.type === 'switch'">
|
||||
<el-switch v-model="e.value" />
|
||||
</template>
|
||||
<template v-if="e.type === 'radio'">
|
||||
<el-radio-group v-model="e.value" class="ml-4">
|
||||
<el-radio v-for="item in e.options" :key="item.value" :label="item.value" size="large">{{
|
||||
item.label
|
||||
}}</el-radio>
|
||||
</el-radio-group>
|
||||
</template>
|
||||
<template v-if="e.type === 'select'">
|
||||
<el-select filterable v-model="e.value" :placeholder="`请选择${e.label}`" clearable>
|
||||
<el-option
|
||||
v-for="(item, index) in e.options"
|
||||
:key="index"
|
||||
:label="getField(item, e.fieldNames?.label)"
|
||||
:value="getField(item, e.fieldNames?.value)"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
<template v-if="e.type === 'tag'">
|
||||
<el-tag
|
||||
style="margin: 7px; margin-right: 10px"
|
||||
v-for="item in drawerProps.row!.m_tags"
|
||||
:key="item.t_id"
|
||||
:type="item.type"
|
||||
effect="dark"
|
||||
>
|
||||
{{ item.t_name }}
|
||||
</el-tag>
|
||||
</template>
|
||||
|
||||
<template v-if="e.type === 'text'">
|
||||
<el-input v-model="e.value" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'textarea'">
|
||||
<el-input
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
v-model="e.value"
|
||||
maxlength="500000"
|
||||
:placeholder="`请输入${e.label}`"
|
||||
show-word-limit
|
||||
/>
|
||||
</template>
|
||||
<template v-if="e.type === 'textareaMore'">
|
||||
<el-input
|
||||
type="textarea"
|
||||
v-model="e.value"
|
||||
maxlength="500000"
|
||||
:placeholder="`请输入${e.label}`"
|
||||
show-word-limit
|
||||
/>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="drawerVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="drawerVisible = false">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="UserDrawer">
|
||||
import { ref, computed } from "vue";
|
||||
import { FormInstance } from "element-plus";
|
||||
import { MangaList } from "@/api/interface/manga/list";
|
||||
type Options = {
|
||||
label: string;
|
||||
value: string | number;
|
||||
};
|
||||
|
||||
const getField = (item: any, fieldName: string | undefined) => {
|
||||
return fieldName ? item[fieldName] : "";
|
||||
};
|
||||
|
||||
type Fields = {
|
||||
label: string;
|
||||
prop: string;
|
||||
type: string;
|
||||
value: any;
|
||||
required: boolean;
|
||||
options?: Options[];
|
||||
fieldNames?: { label: string; value: string };
|
||||
};
|
||||
const generateRules = (fields: Fields[]) => {
|
||||
return fields.reduce(
|
||||
(acc, field) => {
|
||||
const { type, required, label, prop } = field;
|
||||
let message = "";
|
||||
if (type === "select") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "text") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "textarea") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "textareaMore") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "radio") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
acc[prop] = [{ required, message }];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>
|
||||
);
|
||||
};
|
||||
|
||||
const formColumns = computed((): Fields[] => {
|
||||
return [
|
||||
{
|
||||
label: "标签",
|
||||
prop: "t_name",
|
||||
type: "tag",
|
||||
get value() {
|
||||
return drawerProps.value.row!.t_name;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.t_name = val;
|
||||
},
|
||||
options: drawerProps.value.row!.m_tags,
|
||||
fieldNames: { label: "t_name", value: "t_id" },
|
||||
required: false
|
||||
}
|
||||
];
|
||||
});
|
||||
const rules = ref();
|
||||
|
||||
interface DrawerProps {
|
||||
title: string;
|
||||
isView: boolean;
|
||||
row: Partial<MangaList.ResList>;
|
||||
api?: (params: any) => Promise<any>;
|
||||
getTableList?: () => void;
|
||||
}
|
||||
|
||||
const drawerVisible = ref(false);
|
||||
const drawerProps = ref<DrawerProps>({
|
||||
isView: false,
|
||||
title: "",
|
||||
row: {}
|
||||
});
|
||||
|
||||
// 接收父组件传过来的参数
|
||||
const acceptParams = async (params: DrawerProps) => {
|
||||
drawerProps.value = params;
|
||||
rules.value = generateRules(formColumns.value);
|
||||
drawerVisible.value = true;
|
||||
};
|
||||
|
||||
// 提交数据(新增/编辑)
|
||||
const ruleFormRef = ref<FormInstance>();
|
||||
|
||||
defineExpose({
|
||||
acceptParams
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.el-dialog .el-dialog__header {
|
||||
padding: 15px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,224 +0,0 @@
|
||||
<template>
|
||||
<el-drawer v-model="drawerVisible" :destroy-on-close="true" size="720" :title="`${drawerProps.title}`">
|
||||
<el-form
|
||||
ref="ruleFormRef"
|
||||
label-width="100px"
|
||||
label-suffix=" :"
|
||||
:rules="rules"
|
||||
:disabled="drawerProps.isView"
|
||||
:model="drawerProps.row"
|
||||
:hide-required-asterisk="drawerProps.isView"
|
||||
>
|
||||
<el-form-item v-for="e in formColumns" :key="e.label" :label="e.label" :prop="e.prop" :required="e.required">
|
||||
<template v-if="e.type === 'switch'">
|
||||
<el-switch
|
||||
v-model="e.value"
|
||||
:active-value="`${1}`"
|
||||
:inactive-value="`${0}`"
|
||||
active-text="正常"
|
||||
inactive-text="禁用"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="e.type === 'radio'">
|
||||
<el-radio-group v-model="e.value" class="ml-4">
|
||||
<el-radio v-for="item in e.options" :key="item.value" :label="item.value" size="large">{{
|
||||
item.label
|
||||
}}</el-radio>
|
||||
</el-radio-group>
|
||||
</template>
|
||||
<template v-if="e.type === 'select'">
|
||||
<el-select filterable v-model="e.value" :placeholder="`请选择${e.label}`" clearable>
|
||||
<el-option
|
||||
v-for="(item, index) in e.options"
|
||||
:key="index"
|
||||
:label="getField(item, e.fieldNames?.label)"
|
||||
:value="getField(item, e.fieldNames?.value)"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
<template v-if="e.type === 'text'">
|
||||
<el-input v-model="e.value" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'text-num'">
|
||||
<el-input v-model="e.value" type="number" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'textarea'">
|
||||
<el-input
|
||||
type="textarea"
|
||||
v-model="e.value"
|
||||
maxlength="3650"
|
||||
:placeholder="`请输入${e.label}`"
|
||||
show-word-limit
|
||||
/>
|
||||
</template>
|
||||
<template v-if="e.type === 'time'">
|
||||
<el-date-picker v-model="e.value" type="date" placeholder="选择日期" value-format="YYYY-MM-DD">
|
||||
</el-date-picker>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="drawerVisible = false">取消</el-button>
|
||||
<el-button v-show="!drawerProps.isView" type="primary" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="UserDrawer">
|
||||
import { ref, computed } from "vue";
|
||||
import { ElMessage, FormInstance } from "element-plus";
|
||||
import { MiUserList } from "@/api/interface/miUser/list";
|
||||
// import { formateDate } from "@/utils/dateForMatter";
|
||||
type Options = {
|
||||
label: string;
|
||||
value: string | number;
|
||||
};
|
||||
|
||||
const getField = (item: any, fieldName: string | undefined) => {
|
||||
return fieldName ? item[fieldName] : "";
|
||||
};
|
||||
type Fields = {
|
||||
label: string;
|
||||
prop: string;
|
||||
type: string;
|
||||
value: any;
|
||||
required: boolean;
|
||||
options?: Options[];
|
||||
fieldNames?: { label: string; value: string };
|
||||
};
|
||||
const generateRules = (fields: Fields[]) => {
|
||||
return fields.reduce(
|
||||
(acc, field) => {
|
||||
const { type, required, label, prop } = field;
|
||||
let message = "";
|
||||
if (type === "select") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "time") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "text") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "text-num") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
acc[prop] = [{ required, message }];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>
|
||||
);
|
||||
};
|
||||
|
||||
const formColumns = computed((): Fields[] => {
|
||||
let newColumns = [] as any;
|
||||
let columns = [
|
||||
{
|
||||
label: "用户昵称",
|
||||
prop: "mu_nickname",
|
||||
type: "text",
|
||||
get value() {
|
||||
return drawerProps.value.row!.mu_nickname;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.mu_nickname = val;
|
||||
},
|
||||
required: false
|
||||
},
|
||||
{
|
||||
label: "用户邮箱",
|
||||
prop: "mu_email",
|
||||
type: "text",
|
||||
get value() {
|
||||
return drawerProps.value.row!.mu_email;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.mu_email = val;
|
||||
},
|
||||
required: true
|
||||
},
|
||||
|
||||
{
|
||||
label: "用户状态",
|
||||
prop: "mu_status",
|
||||
type: "switch",
|
||||
get value() {
|
||||
return String(drawerProps.value.row!.mu_status);
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.mu_status = val;
|
||||
},
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: "用户推ID",
|
||||
prop: "mu_referrer_id",
|
||||
type: "text",
|
||||
get value() {
|
||||
return drawerProps.value.row!.mu_referrer_id;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.mu_referrer_id = val;
|
||||
},
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: "VIP过期时间",
|
||||
prop: "mu_vip_expired",
|
||||
type: "time",
|
||||
get value() {
|
||||
return String(drawerProps.value.row!.mu_vip_expired);
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.mu_vip_expired = val;
|
||||
},
|
||||
required: false
|
||||
}
|
||||
];
|
||||
newColumns = [...columns];
|
||||
return newColumns;
|
||||
});
|
||||
const rules = ref();
|
||||
|
||||
interface DrawerProps {
|
||||
title: string;
|
||||
isView: boolean;
|
||||
row: Partial<MiUserList.ResList>;
|
||||
api?: (params: any) => Promise<any>;
|
||||
getTableList?: () => void;
|
||||
}
|
||||
|
||||
const drawerVisible = ref(false);
|
||||
const drawerProps = ref<DrawerProps>({
|
||||
isView: false,
|
||||
title: "",
|
||||
row: {}
|
||||
});
|
||||
|
||||
// 接收父组件传过来的参数
|
||||
const acceptParams = (params: DrawerProps) => {
|
||||
drawerProps.value = params;
|
||||
rules.value = generateRules(formColumns.value);
|
||||
drawerVisible.value = true;
|
||||
};
|
||||
|
||||
// 提交数据(新增/编辑)
|
||||
const ruleFormRef = ref<FormInstance>();
|
||||
const handleSubmit = () => {
|
||||
ruleFormRef.value!.validate(async valid => {
|
||||
if (!valid) return;
|
||||
try {
|
||||
await drawerProps.value.api!(drawerProps.value.row);
|
||||
ElMessage.success({ message: `${drawerProps.value.title}成功!` });
|
||||
drawerProps.value.getTableList!();
|
||||
drawerVisible.value = false;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
acceptParams
|
||||
});
|
||||
</script>
|
||||
@@ -1,194 +1,31 @@
|
||||
<template>
|
||||
<div class="table-box">
|
||||
<ProTable ref="proTable" highlight-current-row :columns="columns" :request-api="getTableList">
|
||||
<!-- 表格 header 按钮 -->
|
||||
<template #tableHeader="scope">
|
||||
<el-button type="primary" style="display: none" :icon="CirclePlus" @click="openDrawer(true)">新增</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
plain
|
||||
:disabled="!scope.isSelected"
|
||||
@click="batchDelete(scope.selectedList)"
|
||||
>
|
||||
批量删除用户
|
||||
</el-button>
|
||||
</template>
|
||||
<!-- 表格操作 -->
|
||||
<template #operation="scope">
|
||||
<el-button type="primary" :icon="EditPen" @click="openDrawer(false, scope.row)">编辑</el-button>
|
||||
<el-button type="danger" :icon="Delete" @click="deleteAccount(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</ProTable>
|
||||
<Drawer ref="drawerRef" />
|
||||
<div class="module-unavailable">
|
||||
<el-alert
|
||||
title="用户管理模块未部署到当前 Linux 测试服"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<el-card class="module-unavailable__card" shadow="never">
|
||||
<p>当前后端未提供 `miaouser` 控制器,用户列表、编辑、删除接口都会直接返回控制器不存在。</p>
|
||||
<p>本页已收为提示态,避免把后端缺模块误判成前端运行故障。</p>
|
||||
<p>如果后续测试服补齐该模块,再恢复列表和抽屉联调即可。</p>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="tsx" name="complexPro用户le">
|
||||
import { reactive, ref } from "vue";
|
||||
import { MiUserList } from "@/api/interface/miUser/list";
|
||||
import { useHandleData } from "@/hooks/useHandleData";
|
||||
import ProTable from "@/components/ProTable/index.vue";
|
||||
import { Delete, EditPen, CirclePlus } from "@element-plus/icons-vue";
|
||||
import { ProTableInstance, ColumnProps } from "@/components/ProTable/interface";
|
||||
import { getList, saveData, deleteItem } from "@/api/modules/miUser/list";
|
||||
import Drawer from "./drawer.vue";
|
||||
import { shortcuts, arrUserStatus, arrPermissionStatus } from "@/utils/serviceDict";
|
||||
import { getAllList } from "@/api/modules/site/list";
|
||||
// Pro用户le 实例
|
||||
const proTable = ref<ProTableInstance>();
|
||||
|
||||
const getTableList = (params: any) => {
|
||||
let newParams = JSON.parse(JSON.stringify(params));
|
||||
if (newParams.createTime && newParams.createTime.length > 0) {
|
||||
newParams.createTime && (newParams.mu_start_time = newParams.createTime[0] + " 00:00:00");
|
||||
newParams.createTime && (newParams.mu_end_time = newParams.createTime[1] + " 23:59:59");
|
||||
}
|
||||
delete newParams.createTime;
|
||||
return getList(newParams);
|
||||
};
|
||||
|
||||
// 表格配置项
|
||||
const columns = reactive<ColumnProps<MiUserList.ResList>[]>([
|
||||
{ type: "selection", fixed: "left", width: 50 },
|
||||
{
|
||||
prop: "key",
|
||||
label: "搜索内容",
|
||||
isShow: false,
|
||||
search: {
|
||||
el: "input"
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: "mu_id",
|
||||
label: "ID",
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
prop: "mu_nickname",
|
||||
label: "用户昵称"
|
||||
},
|
||||
{
|
||||
prop: "mu_status",
|
||||
label: "用户状态",
|
||||
search: { el: "select", props: { filterable: true } },
|
||||
enum: arrUserStatus,
|
||||
render(scope) {
|
||||
return (
|
||||
<el-tag type={scope.row.mu_status === 0 ? "danger" : "success"}>
|
||||
{scope.row.mu_status === 0 ? "禁用" : "启用"}
|
||||
</el-tag>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: "si_id",
|
||||
label: "站点",
|
||||
search: { el: "select", props: { filterable: true } },
|
||||
fieldNames: { label: "si_name", value: "si_id" },
|
||||
enum: getAllList
|
||||
},
|
||||
{
|
||||
prop: "mu_email_verified",
|
||||
label: "邮箱是否验证 ",
|
||||
search: { el: "select", props: { filterable: true } },
|
||||
enum: arrPermissionStatus,
|
||||
render(scope) {
|
||||
return (
|
||||
<el-tag type={scope.row.mu_email_verified === 0 ? "danger" : "success"}>
|
||||
{scope.row.mu_email_verified === 0 ? "否" : "是"}
|
||||
</el-tag>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: "mu_vip",
|
||||
label: "是否VIP",
|
||||
enum: arrPermissionStatus,
|
||||
isShow: false,
|
||||
search: { el: "select", props: { filterable: true } },
|
||||
render(scope) {
|
||||
return <el-tag type={scope.row.mu_vip === 0 ? "danger" : "success"}>{scope.row.mu_vip === 0 ? "否" : "是"}</el-tag>;
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
prop: "mu_email",
|
||||
label: "邮箱"
|
||||
},
|
||||
{
|
||||
prop: "mu_channel_code",
|
||||
label: "渠道代码",
|
||||
render(scope) {
|
||||
if (scope.row.mu_channel_code.length > 0) {
|
||||
return <p>{scope.row.mu_channel_code} </p>;
|
||||
}
|
||||
return "--";
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: "mu_channel_domain",
|
||||
label: "渠道域名",
|
||||
render(scope) {
|
||||
if (scope.row.mu_channel_domain.length > 0) {
|
||||
return <p>{scope.row.mu_channel_domain} </p>;
|
||||
}
|
||||
return "--";
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: "createTime",
|
||||
label: "创建时间",
|
||||
width: 180,
|
||||
isShow: false,
|
||||
search: {
|
||||
el: "date-picker",
|
||||
span: 2,
|
||||
props: { type: "daterange", valueFormat: "YYYY-MM-DD", shortcuts: shortcuts, clearable: true },
|
||||
defaultValue: []
|
||||
}
|
||||
},
|
||||
{ prop: "mu_vip_expired", label: "VIP过期时间" },
|
||||
{ prop: "created_at", label: "添加时间" },
|
||||
{ prop: "operation", label: "操作", fixed: "right", width: 230 }
|
||||
]);
|
||||
|
||||
// 删除用户信息
|
||||
const deleteAccount = async (params: MiUserList.ResList) => {
|
||||
await useHandleData(deleteItem, { mu_id: params.mu_id }, "删除所选信息");
|
||||
proTable.value?.getTableList();
|
||||
};
|
||||
|
||||
// 批量删除用户信息
|
||||
const batchDelete = async (id: any[]) => {
|
||||
const ids = id.map((item: MiUserList.ResList) => item.mu_id);
|
||||
await useHandleData(deleteItem, { mu_id: ids.join(",") }, "删除所选信息");
|
||||
proTable.value?.clearSelection();
|
||||
proTable.value?.getTableList();
|
||||
};
|
||||
// 打开 drawer(新增、编辑)
|
||||
const drawerRef = ref<InstanceType<typeof Drawer> | null>(null);
|
||||
|
||||
const openDrawer = (isEdit: boolean, row: Partial<MiUserList.ResList> = {}) => {
|
||||
const params = {
|
||||
title: isEdit ? "新增用户" : "编辑用户",
|
||||
row: { ...row },
|
||||
api: saveData,
|
||||
getTableList: proTable.value?.getTableList
|
||||
};
|
||||
drawerRef.value?.acceptParams(params as any);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.el-用户le .warning-row,
|
||||
.el-用户le .warning-row .el-用户le-fixed-column--right,
|
||||
.el-用户le .warning-row .el-用户le-fixed-column--left {
|
||||
background-color: var(--el-color-warning-light-9);
|
||||
<style scoped lang="scss">
|
||||
.module-unavailable {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
.el-用户le .success-row,
|
||||
.el-用户le .success-row .el-用户le-fixed-column--right,
|
||||
.el-用户le .success-row .el-用户le-fixed-column--left {
|
||||
background-color: var(--el-color-success-light-9);
|
||||
|
||||
.module-unavailable__card {
|
||||
color: var(--el-text-color-regular);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.module-unavailable__card p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,91 +1,30 @@
|
||||
<template>
|
||||
<div class="table-box">
|
||||
<ProTable
|
||||
ref="proTable"
|
||||
highlight-current-row
|
||||
:columns="columns"
|
||||
:request-api="getTableList"
|
||||
:data-callback="dataCallback"
|
||||
>
|
||||
</ProTable>
|
||||
<div class="page-box">
|
||||
<el-alert
|
||||
title="当前测试服未部署小说章节管理"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<div class="page-box__body">
|
||||
<p>当前页面依赖的 `/xiaoshuo/zhangjie/list`、`/admin/novel/chapter/save`、`/admin/novel/chapter/del` 都不是测试服现有后端主线。</p>
|
||||
<p>如果后续要恢复这一页,需要先补齐章节接口,再重新接回列表、保存和删除动作。</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="tsx" name="complexProTable">
|
||||
import { reactive, ref } from "vue";
|
||||
import ProTable from "@/components/ProTable/index.vue";
|
||||
import { ProTableInstance, ColumnProps } from "@/components/ProTable/interface";
|
||||
import { ChapterList } from "@/api/interface/novel/chapter";
|
||||
import { getList } from "@/api/modules/novel/chapter";
|
||||
|
||||
// ProTable 实例
|
||||
const proTable = ref<ProTableInstance>();
|
||||
|
||||
const initParam = reactive({
|
||||
limit: 15,
|
||||
page: 1
|
||||
});
|
||||
|
||||
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 getList(newParams);
|
||||
};
|
||||
|
||||
// 表格配置项
|
||||
const columns = reactive<ColumnProps<ChapterList.ResList>[]>([
|
||||
{ type: "selection", fixed: "left", width: 50 },
|
||||
{
|
||||
prop: "key",
|
||||
label: "搜索内容",
|
||||
isShow: false,
|
||||
search: {
|
||||
el: "input"
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: "xszj_id",
|
||||
label: "ID",
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
prop: "xszj_pai_xu",
|
||||
label: "章节序号",
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
prop: "xszj_ming_zi",
|
||||
label: "章节标题"
|
||||
},
|
||||
{
|
||||
prop: "xszj_nei_rong",
|
||||
label: "章节内容",
|
||||
showOverflowTooltip: true,
|
||||
render(scope) {
|
||||
return (
|
||||
<div class="fixed-width" v-html={scope.row.xszj_nei_rong}>
|
||||
{scope.row.xszj_nei_rong}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{ prop: "created_at", label: "添加时间", width: 170 }
|
||||
]);
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.el-upload--picture-card {
|
||||
display: none;
|
||||
.page-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-box__body {
|
||||
padding: 20px 24px;
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-light);
|
||||
color: var(--el-text-color-regular);
|
||||
line-height: 1.8;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
<template>
|
||||
<el-drawer v-model="drawerVisible" :destroy-on-close="true" size="720" :title="`${drawerProps.title}`">
|
||||
<el-form
|
||||
ref="ruleFormRef"
|
||||
label-width="90px"
|
||||
label-suffix=" :"
|
||||
:rules="rules"
|
||||
:disabled="drawerProps.isView"
|
||||
:model="drawerProps.row"
|
||||
:hide-required-asterisk="drawerProps.isView"
|
||||
>
|
||||
<el-form-item v-for="e in formColumns" :key="e.label" :label="e.label" :prop="e.prop" :required="e.required">
|
||||
<template v-if="e.type === 'switch'">
|
||||
<el-switch
|
||||
v-model="e.value"
|
||||
:active-value="`${1}`"
|
||||
:inactive-value="`${0}`"
|
||||
active-text="上架"
|
||||
inactive-text="下架"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="e.type === 'switch-index'">
|
||||
<el-switch
|
||||
v-model="e.value"
|
||||
:active-value="`${1}`"
|
||||
:inactive-value="`${0}`"
|
||||
active-text="是"
|
||||
inactive-text="否"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="e.type === 'radio'">
|
||||
<el-radio-group v-model="e.value" class="ml-4">
|
||||
<el-radio v-for="item in e.options" :key="item.value" :label="item.value" size="large">{{
|
||||
item.label
|
||||
}}</el-radio>
|
||||
</el-radio-group>
|
||||
</template>
|
||||
<template v-if="e.type === 'select'">
|
||||
<el-select filterable v-model="e.value" :placeholder="`请选择${e.label}`" clearable>
|
||||
<el-option
|
||||
v-for="(item, index) in e.options"
|
||||
:key="index"
|
||||
:label="getField(item, e.fieldNames?.label)"
|
||||
:value="getField(item, e.fieldNames?.value)"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
<template v-if="e.type === 'text'">
|
||||
<el-input v-model="e.value" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'text-i'">
|
||||
<el-input v-model="e.value" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'text-num'">
|
||||
<el-input v-model="e.value" type="number" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'textarea'">
|
||||
<el-input
|
||||
type="textarea"
|
||||
v-model="e.value"
|
||||
maxlength="36500"
|
||||
:autosize="{ minRows: 25, maxRows: 32 }"
|
||||
:placeholder="`请输入${e.label}`"
|
||||
show-word-limit
|
||||
/>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="drawerVisible = false">取消</el-button>
|
||||
<el-button v-show="!drawerProps.isView" type="primary" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="UserDrawer">
|
||||
import { ref, computed } from "vue";
|
||||
import { ElMessage, FormInstance } from "element-plus";
|
||||
import { ChapterList } from "@/api/interface/novel/chapter";
|
||||
type Options = {
|
||||
label: string;
|
||||
value: string | number;
|
||||
};
|
||||
|
||||
const getField = (item: any, fieldName: string | undefined) => {
|
||||
return fieldName ? item[fieldName] : "";
|
||||
};
|
||||
type Fields = {
|
||||
label: string;
|
||||
prop: string;
|
||||
type: string;
|
||||
value: any;
|
||||
required: boolean;
|
||||
options?: Options[];
|
||||
fieldNames?: { label: string; value: string };
|
||||
};
|
||||
const generateRules = (fields: Fields[]) => {
|
||||
return fields.reduce(
|
||||
(acc, field) => {
|
||||
const { type, required, label, prop } = field;
|
||||
let message = "";
|
||||
if (type === "select") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "tag") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "radio") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "text") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "text-i") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "text-num") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "image") {
|
||||
message = `请上传${label}`;
|
||||
}
|
||||
if (type === "image-more") {
|
||||
message = `请上传${label}`;
|
||||
}
|
||||
acc[prop] = [{ required, message }];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>
|
||||
);
|
||||
};
|
||||
|
||||
const formColumns = computed((): Fields[] => {
|
||||
return [
|
||||
{
|
||||
label: "章节标题",
|
||||
prop: "nc_title",
|
||||
type: "text",
|
||||
get value() {
|
||||
return drawerProps.value.row!.nc_title;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.nc_title = val;
|
||||
},
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: "章节序号",
|
||||
prop: "nc_index",
|
||||
type: "text-num",
|
||||
get value() {
|
||||
return drawerProps.value.row!.nc_index;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.nc_index = val;
|
||||
},
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: "阅读次数",
|
||||
prop: "nc_hits",
|
||||
type: "text-num",
|
||||
get value() {
|
||||
return drawerProps.value.row!.nc_hits;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.nc_hits = val;
|
||||
},
|
||||
required: false
|
||||
},
|
||||
{
|
||||
label: "章节内容",
|
||||
prop: "nc_content",
|
||||
type: "textarea",
|
||||
get value() {
|
||||
return drawerProps.value.row!.nc_content;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.nc_content = val;
|
||||
},
|
||||
required: true
|
||||
}
|
||||
];
|
||||
});
|
||||
const rules = ref();
|
||||
|
||||
interface DrawerProps {
|
||||
title: string;
|
||||
isView: boolean;
|
||||
row: Partial<ChapterList.ResList>;
|
||||
api?: (params: any) => Promise<any>;
|
||||
getTableList?: () => void;
|
||||
}
|
||||
|
||||
const drawerVisible = ref(false);
|
||||
const drawerProps = ref<DrawerProps>({
|
||||
isView: false,
|
||||
title: "",
|
||||
row: {}
|
||||
});
|
||||
|
||||
// 接收父组件传过来的参数
|
||||
const acceptParams = (params: DrawerProps) => {
|
||||
drawerProps.value = params;
|
||||
rules.value = generateRules(formColumns.value);
|
||||
drawerVisible.value = true;
|
||||
};
|
||||
|
||||
// 提交数据(新增/编辑)
|
||||
const ruleFormRef = ref<FormInstance>();
|
||||
const handleSubmit = () => {
|
||||
ruleFormRef.value!.validate(async valid => {
|
||||
if (!valid) return;
|
||||
try {
|
||||
const params = { ...drawerProps.value.row };
|
||||
await drawerProps.value.api!(params);
|
||||
ElMessage.success({ message: `${drawerProps.value.title}成功!` });
|
||||
drawerProps.value.getTableList!();
|
||||
drawerVisible.value = false;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
acceptParams
|
||||
});
|
||||
</script>
|
||||
@@ -1,181 +0,0 @@
|
||||
<template>
|
||||
<el-drawer v-model="drawerVisible" size="80%" title="章节列表" :destroy-on-close="true">
|
||||
<ProTable
|
||||
ref="proTable"
|
||||
highlight-current-row
|
||||
:columns="columns"
|
||||
:request-api="getTableList"
|
||||
:data-callback="dataCallback"
|
||||
:init-param="initParam"
|
||||
:is-displayed="isDisplayed"
|
||||
:is-search-location="isSearchLocation"
|
||||
>
|
||||
<!-- 表格 header 按钮 -->
|
||||
<template #tableHeader="scope">
|
||||
<el-button type="primary" style="display: none" :icon="CirclePlus" @click="openDrawer(true)">
|
||||
新增小说章节
|
||||
</el-button>
|
||||
<el-button
|
||||
style="display: none"
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
plain
|
||||
:disabled="!scope.isSelected"
|
||||
@click="batchDelete(scope.selectedList)"
|
||||
>
|
||||
批量删除章节
|
||||
</el-button>
|
||||
</template>
|
||||
<!-- 表格操作 -->
|
||||
<template #operation="scope">
|
||||
<el-button type="primary" link :icon="EditPen" @click="openDrawer(false, scope.row)">编辑</el-button>
|
||||
<el-button type="danger" link :icon="Delete" @click="deleteAccount(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</ProTable>
|
||||
<template #footer>
|
||||
<el-button @click="drawerVisible = false">取消</el-button>
|
||||
<el-button @click="drawerVisible = false" type="primary">确定</el-button>
|
||||
</template>
|
||||
<ChapterDrawer ref="chapterDrawerRef" />
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="tsx" name="UserDrawer">
|
||||
import { ref, reactive } from "vue";
|
||||
import ProTable from "@/components/ProTable/index.vue";
|
||||
import { ColumnProps, ProTableInstance } from "@/components/ProTable/interface";
|
||||
import { useHandleData } from "@/hooks/useHandleData";
|
||||
import { Delete, EditPen, CirclePlus } from "@element-plus/icons-vue";
|
||||
import { ChapterList } from "@/api/interface/novel/chapter";
|
||||
import { getList, saveData, deleteItem } from "@/api/modules/novel/chapter";
|
||||
import ChapterDrawer from "../component/chapterDrawer.vue";
|
||||
// ProTable 实例
|
||||
const proTable = ref<ProTableInstance>();
|
||||
|
||||
// 如果表格需要初始化请求参数,直接定义传给 ProTable(之后每次请求都会自动带上该参数,此参数更改之后也会一直带上,改变此参数会自动刷新表格数据)
|
||||
const initParam = reactive({
|
||||
xsxq_id: 0,
|
||||
limit: 15,
|
||||
page: 1
|
||||
});
|
||||
|
||||
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 getList(newParams);
|
||||
};
|
||||
|
||||
const isDisplayed = ref<any>(false);
|
||||
const isSearchLocation = ref<any>(false);
|
||||
|
||||
// 表格配置项
|
||||
const columns = reactive<ColumnProps<ChapterList.ResList>[]>([
|
||||
{ type: "selection", fixed: "left", width: 50 },
|
||||
{
|
||||
prop: "key",
|
||||
label: "搜索内容",
|
||||
isShow: false,
|
||||
search: {
|
||||
el: "input"
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: "xszj_id",
|
||||
label: "ID",
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
prop: "xszj_pai_xu",
|
||||
label: "章节序号",
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
prop: "xszj_ming_zi",
|
||||
label: "章节标题"
|
||||
},
|
||||
{
|
||||
prop: "xszj_nei_rong",
|
||||
label: "章节内容",
|
||||
showOverflowTooltip: true,
|
||||
render(scope) {
|
||||
return (
|
||||
<div class="fixed-width" v-html={scope.row.xszj_nei_rong}>
|
||||
{scope.row.xszj_nei_rong}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{ prop: "created_at", label: "添加时间", width: 170 }
|
||||
// { prop: "operation", label: "操作", fixed: "right", width: 200 }
|
||||
]);
|
||||
|
||||
const drawerVisible = ref(false);
|
||||
// 接收父组件传过来的参数
|
||||
const acceptParams = (xsxq_id: number) => {
|
||||
drawerVisible.value = true;
|
||||
initParam.xsxq_id = xsxq_id;
|
||||
};
|
||||
|
||||
// 删除章节
|
||||
const deleteAccount = async (params: ChapterList.ResList) => {
|
||||
await useHandleData(deleteItem, { xszj_id: params.xszj_id, xsxq_id: initParam.xsxq_id }, "删除所选信息");
|
||||
proTable.value?.getTableList();
|
||||
};
|
||||
|
||||
// 批量删除章节
|
||||
const batchDelete = async (id: any[]) => {
|
||||
const ids = id.map((item: ChapterList.ResList) => item.xszj_id);
|
||||
await useHandleData(deleteItem, { xszj_id: ids.join(","), xsxq_id: initParam.xsxq_id }, "删除所选信息");
|
||||
proTable.value?.clearSelection();
|
||||
proTable.value?.getTableList();
|
||||
};
|
||||
|
||||
// 打开 drawer(新增、编辑)
|
||||
const chapterDrawerRef = ref<InstanceType<typeof ChapterDrawer> | null>(null);
|
||||
|
||||
const openDrawer = (isEdit: boolean, row: Partial<ChapterList.ResList> = {}) => {
|
||||
row.xsxq_id = initParam.xsxq_id;
|
||||
const params = {
|
||||
title: isEdit ? "新增小说章节" : "编辑小说章节",
|
||||
row: { ...row },
|
||||
api: saveData,
|
||||
getTableList: proTable.value?.getTableList
|
||||
};
|
||||
chapterDrawerRef.value?.acceptParams(params as any);
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
acceptParams
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.demo-form-inline {
|
||||
.el-form-item {
|
||||
width: 420px;
|
||||
}
|
||||
:deep(.el-form-item__content) {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: flex-start;
|
||||
.tips {
|
||||
margin-left: 10px;
|
||||
font-size: 12px;
|
||||
color: #999999;
|
||||
}
|
||||
}
|
||||
}
|
||||
.btn-group {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,457 +0,0 @@
|
||||
<template>
|
||||
<el-drawer v-model="drawerVisible" :destroy-on-close="true" size="720" :title="`${drawerProps.title}`">
|
||||
<el-form
|
||||
ref="ruleFormRef"
|
||||
label-width="90px"
|
||||
label-suffix=" :"
|
||||
:rules="rules"
|
||||
:disabled="drawerProps.isView"
|
||||
:model="drawerProps.row"
|
||||
:hide-required-asterisk="drawerProps.isView"
|
||||
>
|
||||
<el-form-item v-for="e in formColumns" :key="e.label" :label="e.label" :prop="e.prop" :required="e.required">
|
||||
<template v-if="e.type === 'switch'">
|
||||
<el-switch
|
||||
v-model="e.value"
|
||||
:active-value="`${1}`"
|
||||
:inactive-value="`${0}`"
|
||||
active-text="上架"
|
||||
inactive-text="下架"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="e.type === 'switch-index'">
|
||||
<el-switch
|
||||
v-model="e.value"
|
||||
:active-value="`${1}`"
|
||||
:inactive-value="`${0}`"
|
||||
active-text="是"
|
||||
inactive-text="否"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="e.type === 'radio'">
|
||||
<el-radio-group v-model="e.value" class="ml-4">
|
||||
<el-radio v-for="item in e.options" :key="item.value" :value="item.value" size="large">{{
|
||||
item.label
|
||||
}}</el-radio>
|
||||
</el-radio-group>
|
||||
</template>
|
||||
<template v-if="e.type === 'select'">
|
||||
<el-select filterable v-model="e.value" :placeholder="`请选择${e.label}`" clearable>
|
||||
<el-option
|
||||
v-for="(item, index) in e.options"
|
||||
:key="index"
|
||||
:label="getField(item, e.fieldNames?.label)"
|
||||
:value="getField(item, e.fieldNames?.value)"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
<template v-if="e.type === 'text'">
|
||||
<el-input v-model="e.value" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'text-i'">
|
||||
<el-input v-model="e.value" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'text-num'">
|
||||
<el-input v-model="e.value" type="number" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'textarea'">
|
||||
<el-input
|
||||
type="textarea"
|
||||
v-model="e.value"
|
||||
maxlength="3650"
|
||||
:placeholder="`请输入${e.label}`"
|
||||
show-word-limit
|
||||
/>
|
||||
</template>
|
||||
<template v-if="e.type === 'tag'">
|
||||
<div class="tag-more-s">
|
||||
<el-input
|
||||
v-model="drawerProps.row.searchText"
|
||||
placeholder="请输入搜索内容"
|
||||
clearable
|
||||
@input="handleSearch"
|
||||
/>
|
||||
<div>
|
||||
<span>选中标签:</span>
|
||||
<div class="tag-more-h">
|
||||
<el-checkbox-group v-model="TagList" @change="placeableCheckedCitiesChange">
|
||||
<el-checkbox-button
|
||||
class="vip-power"
|
||||
v-for="item in matchingObjects"
|
||||
:value="item.t_id"
|
||||
:key="item.t_id"
|
||||
>
|
||||
{{ item.t_name }}
|
||||
</el-checkbox-button>
|
||||
</el-checkbox-group>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span>未选中标签:</span>
|
||||
<div class="tag-more-h">
|
||||
<el-checkbox-group v-model="TagList" @change="placeableCheckedCitiesChange">
|
||||
<el-checkbox-button
|
||||
class="vip-power"
|
||||
v-for="item in nonMatchingObjects"
|
||||
:value="item.t_id"
|
||||
:key="item.t_id"
|
||||
>
|
||||
{{ item.t_name }}
|
||||
</el-checkbox-button>
|
||||
</el-checkbox-group>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="e.type === 'text-i'">
|
||||
<UploadImg disabled v-model:image-url="drawerProps.row!.n_cover" width="135px" height="135px" :file-size="3">
|
||||
<template #empty>
|
||||
<el-icon><Avatar /></el-icon>
|
||||
<span>请上传封面</span>
|
||||
</template>
|
||||
</UploadImg>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="drawerVisible = false">取消</el-button>
|
||||
<el-button v-show="!drawerProps.isView" type="primary" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="UserDrawer">
|
||||
import { ref, computed, reactive } from "vue";
|
||||
import { ElMessage, FormInstance } from "element-plus";
|
||||
import { NovelList } from "@/api/interface/novel/list";
|
||||
import { arrNovelType } from "@/utils/serviceDict";
|
||||
import { getTypeList } from "@/api/modules/tag/list";
|
||||
import UploadImg from "@/components/Upload/Img.vue";
|
||||
import { extractURI } from "@/utils/eleValidate";
|
||||
type Options = {
|
||||
label: string;
|
||||
value: string | number;
|
||||
};
|
||||
|
||||
interface Item {
|
||||
t_id: any;
|
||||
uri: any;
|
||||
}
|
||||
const getField = (item: any, fieldName: string | undefined) => {
|
||||
return fieldName ? item[fieldName] : "";
|
||||
};
|
||||
type Fields = {
|
||||
label: string;
|
||||
prop: string;
|
||||
type: string;
|
||||
value: any;
|
||||
required: boolean;
|
||||
options?: Options[];
|
||||
fieldNames?: { label: string; value: string };
|
||||
};
|
||||
const generateRules = (fields: Fields[]) => {
|
||||
return fields.reduce(
|
||||
(acc, field) => {
|
||||
const { type, required, label, prop } = field;
|
||||
let message = "";
|
||||
if (type === "select") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "tag") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "radio") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "text") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "text-i") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "text-num") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "image") {
|
||||
message = `请上传${label}`;
|
||||
}
|
||||
if (type === "image-more") {
|
||||
message = `请上传${label}`;
|
||||
}
|
||||
acc[prop] = [{ required, message }];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>
|
||||
);
|
||||
};
|
||||
|
||||
const TagList = ref([] as any);
|
||||
const arrTagList = ref([] as any);
|
||||
const arrNewTagList = ref([] as any);
|
||||
const matchingObjects = ref([] as any); //选中相同
|
||||
const nonMatchingObjects = ref([] as any); //没有选中的
|
||||
const matchingObjectsA = ref([] as any); //选中相同
|
||||
const nonMatchingObjectsB = ref([] as any); //没有选中的
|
||||
const getTagList = async () => {
|
||||
try {
|
||||
const initParam = reactive({
|
||||
t_type: 3,
|
||||
limit: 3650,
|
||||
page: 1,
|
||||
t_hidden: 1
|
||||
});
|
||||
const siteRes = await getTypeList(initParam);
|
||||
arrTagList.value = siteRes.data.item;
|
||||
arrNewTagList.value = siteRes.data.item;
|
||||
matchingObjects.value = [];
|
||||
matchingObjectsA.value = [];
|
||||
// 遍历第一个数组的每个元素
|
||||
TagList.value.forEach(index => {
|
||||
// 遍历第二个数组的每个元素
|
||||
arrNewTagList.value.forEach(obj => {
|
||||
// 如果第二个数组中的元素的t_id与第一个数组中的元素相同,则将其添加到新数组中
|
||||
if (obj.t_id === index) {
|
||||
matchingObjects.value = [...matchingObjects.value, obj];
|
||||
matchingObjectsA.value = [...matchingObjectsA.value, obj];
|
||||
const matchingIds = matchingObjectsA.value.map(obj => obj.t_id);
|
||||
TagList.value = matchingIds;
|
||||
}
|
||||
});
|
||||
});
|
||||
// 创建一个新数组,包含objectArray中id不存在于arrayA的元素
|
||||
nonMatchingObjects.value = arrNewTagList.value.filter(obj => !TagList.value.includes(obj.t_id));
|
||||
nonMatchingObjectsB.value = arrNewTagList.value.filter(obj => !TagList.value.includes(obj.t_id));
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理搜索事件
|
||||
function handleSearch() {
|
||||
// 在输入事件中实时更新过滤后的标签列表
|
||||
filteredTagList();
|
||||
}
|
||||
function filteredTagList() {
|
||||
if (drawerProps.value.row!.searchText.length > 0) {
|
||||
let arrData = arrTagList.value.filter(tag =>
|
||||
tag.t_name.toLowerCase().includes(drawerProps.value.row!.searchText.toLowerCase())
|
||||
);
|
||||
if (arrData.length > 0) {
|
||||
matchingObjects.value = matchingObjects.value.filter(objA => arrData.some(objB => objB.t_id === objA.t_id));
|
||||
nonMatchingObjects.value = nonMatchingObjects.value.filter(objA => arrData.some(objB => objB.t_id === objA.t_id));
|
||||
} else {
|
||||
matchingObjects.value = [];
|
||||
nonMatchingObjects.value = [];
|
||||
}
|
||||
} else {
|
||||
matchingObjects.value = matchingObjectsA.value;
|
||||
nonMatchingObjects.value = nonMatchingObjectsB.value;
|
||||
}
|
||||
}
|
||||
|
||||
//标签数组
|
||||
async function placeableCheckedCitiesChange(value) {
|
||||
matchingObjects.value = [];
|
||||
matchingObjectsA.value = [];
|
||||
drawerProps.value.row.n_tags = value;
|
||||
// 遍历第一个数组的每个元素
|
||||
TagList.value.forEach(index => {
|
||||
// 遍历第二个数组的每个元素
|
||||
arrNewTagList.value.forEach(obj => {
|
||||
// 如果第二个数组中的元素的t_id与第一个数组中的元素相同,则将其添加到新数组中
|
||||
if (obj.t_id === index) {
|
||||
matchingObjects.value = [...matchingObjects.value, obj];
|
||||
matchingObjectsA.value = [...matchingObjectsA.value, obj];
|
||||
const matchingIds = matchingObjectsA.value.map(obj => obj.t_id);
|
||||
TagList.value = matchingIds;
|
||||
}
|
||||
});
|
||||
});
|
||||
nonMatchingObjects.value = arrNewTagList.value.filter(obj => !TagList.value.includes(obj.t_id));
|
||||
nonMatchingObjectsB.value = arrNewTagList.value.filter(obj => !TagList.value.includes(obj.t_id));
|
||||
}
|
||||
const formColumns = computed((): Fields[] => {
|
||||
return [
|
||||
{
|
||||
label: "小说名称",
|
||||
prop: "n_name",
|
||||
type: "text",
|
||||
get value() {
|
||||
return drawerProps.value.row!.n_name;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.n_name = val;
|
||||
},
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: "小说作者",
|
||||
prop: "n_author",
|
||||
type: "text",
|
||||
get value() {
|
||||
return drawerProps.value.row!.n_author;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.n_author = val;
|
||||
},
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: "来源 ID",
|
||||
prop: "n_source_id",
|
||||
type: "text-num",
|
||||
get value() {
|
||||
return drawerProps.value.row!.n_source_id;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.n_source_id = val;
|
||||
},
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: "小说状态",
|
||||
prop: "n_at_status",
|
||||
type: "switch",
|
||||
get value() {
|
||||
return String(drawerProps.value.row!.n_at_status);
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.n_at_status = val;
|
||||
},
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: "小说类型",
|
||||
prop: "n_type",
|
||||
type: "radio",
|
||||
get value() {
|
||||
return drawerProps.value.row!.n_type;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.n_type = val;
|
||||
},
|
||||
options: arrNovelType,
|
||||
fieldNames: { label: "label", value: "value" },
|
||||
required: true
|
||||
},
|
||||
// {
|
||||
// label: "首页轮播状态",
|
||||
// prop: "n_slide_status",
|
||||
// type: "switch-index",
|
||||
// get value() {
|
||||
// return String(drawerProps.value.row!.n_slide_status);
|
||||
// },
|
||||
// set value(val) {
|
||||
// drawerProps.value.row!.n_slide_status = val;
|
||||
// },
|
||||
// required: true
|
||||
// },
|
||||
|
||||
// {
|
||||
// label: "封面",
|
||||
// prop: "n_cover",
|
||||
// type: "text-i",
|
||||
// get value() {
|
||||
// return drawerProps.value.row!.n_cover;
|
||||
// },
|
||||
// set value(val) {
|
||||
// drawerProps.value.row!.n_cover = val;
|
||||
// },
|
||||
// required: true
|
||||
// },
|
||||
{
|
||||
label: "小说标签",
|
||||
prop: "n_tags",
|
||||
type: "tag",
|
||||
get value() {
|
||||
return drawerProps.value.row!.n_tags;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.n_tags = val;
|
||||
},
|
||||
options: arrTagList.value,
|
||||
fieldNames: { label: "label", value: "value" },
|
||||
required: true
|
||||
}
|
||||
];
|
||||
});
|
||||
const rules = ref();
|
||||
|
||||
interface DrawerProps {
|
||||
title: string;
|
||||
isView: boolean;
|
||||
row: Partial<NovelList.ResList>;
|
||||
api?: (params: any) => Promise<any>;
|
||||
getTableList?: () => void;
|
||||
}
|
||||
|
||||
const drawerVisible = ref(false);
|
||||
const drawerProps = ref<DrawerProps>({
|
||||
isView: false,
|
||||
title: "",
|
||||
row: {}
|
||||
});
|
||||
|
||||
// 接收父组件传过来的参数
|
||||
const acceptParams = (params: DrawerProps) => {
|
||||
drawerProps.value = params;
|
||||
rules.value = generateRules(formColumns.value);
|
||||
drawerVisible.value = true;
|
||||
TagList.value = [];
|
||||
if (drawerProps.value.title === "新增小说") {
|
||||
drawerProps.value.row!.n_type = 0;
|
||||
}
|
||||
if (drawerProps.value.row.n_cover) {
|
||||
drawerProps.value.row.n_cover_code = drawerProps.value.row.n_cover.code;
|
||||
drawerProps.value.row.n_cover = drawerProps.value.row.n_cover_detail;
|
||||
}
|
||||
if (drawerProps.value.row.n_tags) {
|
||||
TagList.value = drawerProps.value.row.n_tags.map((obj: Item) => obj.t_id).map(Number);
|
||||
}
|
||||
getTagList();
|
||||
};
|
||||
|
||||
// 提交数据(新增/编辑)
|
||||
const ruleFormRef = ref<FormInstance>();
|
||||
const handleSubmit = () => {
|
||||
ruleFormRef.value!.validate(async valid => {
|
||||
if (!valid) return;
|
||||
try {
|
||||
const params = { ...drawerProps.value.row };
|
||||
let arrData = {
|
||||
uri: extractURI(params.n_cover),
|
||||
code: params.n_cover_code
|
||||
};
|
||||
|
||||
params.n_cover = arrData;
|
||||
params.n_tags = TagList.value;
|
||||
await drawerProps.value.api!(params);
|
||||
ElMessage.success({ message: `${drawerProps.value.title}成功!` });
|
||||
drawerProps.value.getTableList!();
|
||||
drawerVisible.value = false;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
acceptParams
|
||||
});
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.el-checkbox-button__inner {
|
||||
margin-right: 10px;
|
||||
margin-bottom: 10px;
|
||||
border-left-color: #dcdfe6 !important;
|
||||
}
|
||||
.tag-more-h {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.tag-more-s {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid black;
|
||||
box-shadow: 0 0 4px 2px rgb(0 0 0 / 90%);
|
||||
}
|
||||
</style>
|
||||
@@ -7,6 +7,9 @@
|
||||
:request-api="getTableList"
|
||||
:data-callback="dataCallback"
|
||||
>
|
||||
<template #empty>
|
||||
<el-empty description="暂无小说分类" />
|
||||
</template>
|
||||
</ProTable>
|
||||
</div>
|
||||
</template>
|
||||
@@ -18,17 +21,14 @@ import ProTable from "@/components/ProTable/index.vue";
|
||||
import { ProTableInstance, ColumnProps } from "@/components/ProTable/interface";
|
||||
import { getList } from "@/api/modules/novel/fenlei";
|
||||
|
||||
// ProTable 实例
|
||||
const proTable = ref<ProTableInstance>();
|
||||
|
||||
const initParam = reactive({
|
||||
limit: 15,
|
||||
limit: 25,
|
||||
page: 1
|
||||
});
|
||||
|
||||
const dataCallback = (data: any) => {
|
||||
console.log(data);
|
||||
// 转换为数组
|
||||
const arr = Object.entries(data.all).map(([xsfl_id, xsfl_name]) => ({ xsfl_id: Number(xsfl_id), xsfl_name }));
|
||||
return {
|
||||
items: arr,
|
||||
@@ -46,15 +46,17 @@ const getTableList = (params: any) => {
|
||||
return getList(newParams);
|
||||
};
|
||||
|
||||
// 表格配置项
|
||||
const columns = reactive<ColumnProps<fenLeiList.ResList>[]>([
|
||||
const columns = ref<ColumnProps[]>([
|
||||
{ type: "selection", fixed: "left", width: 50 },
|
||||
{
|
||||
prop: "key",
|
||||
label: "搜索内容",
|
||||
label: "分类名称 / ID",
|
||||
isShow: false,
|
||||
search: {
|
||||
el: "input"
|
||||
el: "input",
|
||||
props: {
|
||||
placeholder: "请输入分类名称或 ID"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -64,12 +66,8 @@ const columns = reactive<ColumnProps<fenLeiList.ResList>[]>([
|
||||
},
|
||||
{
|
||||
prop: "xsfl_name",
|
||||
label: "分类名称"
|
||||
},
|
||||
|
||||
// {
|
||||
// prop: "xsfl_pinyin",
|
||||
// label: "拼音"
|
||||
// }
|
||||
label: "分类名称",
|
||||
showOverflowTooltip: true
|
||||
}
|
||||
]);
|
||||
</script>
|
||||
|
||||
@@ -7,86 +7,34 @@
|
||||
:request-api="getTableList"
|
||||
:data-callback="dataCallback"
|
||||
>
|
||||
<!-- 表格 header 按钮 -->
|
||||
<template #tableHeader="scope">
|
||||
<el-button type="primary" :icon="CirclePlus" @click="openSetDrawerRef(true)">批量设置</el-button>
|
||||
<el-button type="primary" style="display: none" :icon="CirclePlus" @click="openDrawer(true)">新增</el-button>
|
||||
<el-button
|
||||
style="display: none"
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
plain
|
||||
:disabled="!scope.isSelected"
|
||||
@click="batchDelete(scope.selectedList)"
|
||||
>
|
||||
批量删除小说
|
||||
</el-button>
|
||||
<template #empty>
|
||||
<el-empty description="暂无小说数据" />
|
||||
</template>
|
||||
<!-- 表格操作 -->
|
||||
<template #operation="scope">
|
||||
<el-button type="success" link :icon="ChatLineSquare" @click="openChapterDrawer(scope.row)">章节</el-button>
|
||||
<el-button type="primary" style="display: none" link :icon="EditPen" @click="openDrawer(false, scope.row)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="danger" style="display: none" link :icon="Delete" @click="deleteAccount(scope.row)">
|
||||
删除
|
||||
</el-button>
|
||||
<template #tableHeader>
|
||||
<el-button type="primary" :icon="CirclePlus" @click="openSetDrawerRef">批量设置等级</el-button>
|
||||
</template>
|
||||
</ProTable>
|
||||
<Drawer ref="drawerRef" />
|
||||
<SetDrawer ref="setDrawerRef" />
|
||||
<ChapterDrawer ref="chapterDrawerRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="tsx" name="complexProTable">
|
||||
import { defineComponent, onMounted, h } from "vue";
|
||||
import { reactive, ref } from "vue";
|
||||
import { NovelList } from "@/api/interface/novel/list";
|
||||
import { useHandleData } from "@/hooks/useHandleData";
|
||||
import ProTable from "@/components/ProTable/index.vue";
|
||||
import { Delete, EditPen, CirclePlus, ChatLineSquare } from "@element-plus/icons-vue";
|
||||
import { CirclePlus } from "@element-plus/icons-vue";
|
||||
import { ProTableInstance, ColumnProps } from "@/components/ProTable/interface";
|
||||
import { getList, saveData, deleteItem,levelSet } from "@/api/modules/novel/list";
|
||||
import Drawer from "./drawer.vue";
|
||||
import { getList, levelSet } from "@/api/modules/novel/list";
|
||||
import SetDrawer from "./setDrawer.vue";
|
||||
import ChapterDrawer from "./components/chapter.vue";
|
||||
import { getAllList } from "@/api/modules/novel/fenlei";
|
||||
// import { LOSE_STR_KEY, LOGIN_URL } from "@/config";
|
||||
import router from "@/routers";
|
||||
import { getEndpointAllApi } from "@/api/modules/system/endpoint";
|
||||
|
||||
//端点列表
|
||||
const Endpoints = ref([] as any);
|
||||
// ProTable 实例
|
||||
const proTable = ref<ProTableInstance>();
|
||||
|
||||
//小说分类
|
||||
// const getFenLeiList = ref([] as any);
|
||||
// getAllList().then(res => {
|
||||
// if (res.code === LOSE_STR_KEY) {
|
||||
// router.replace(LOGIN_URL);
|
||||
// return;
|
||||
// }
|
||||
// getFenLeiList.value = res.data.items;
|
||||
// });
|
||||
|
||||
const initParam = reactive({
|
||||
limit: 15,
|
||||
limit: 25,
|
||||
page: 1
|
||||
});
|
||||
|
||||
const dataCallback = (data: any) => {
|
||||
console.log(data)
|
||||
// data.items.forEach((item: any) => {
|
||||
// const DataString = JSON.parse(item.xsxq_feng_mian);
|
||||
// const found = Endpoints.value.find(item2 => item2.zydd_code === DataString.code);
|
||||
// if (found && found.zydd_domain) {
|
||||
// item["https_cover_url"] = found.zydd_domain + DataString.uri;
|
||||
// } else {
|
||||
// item["https_cover_url"] = import.meta.env.VITE_API_URL + DataString.uri;
|
||||
// }
|
||||
// });
|
||||
return {
|
||||
items: data.data,
|
||||
total: data.total,
|
||||
@@ -98,48 +46,30 @@ const dataCallback = (data: any) => {
|
||||
};
|
||||
|
||||
const getTableList = async (params: any) => {
|
||||
// 获取 getEndpointAllApi 的返回值
|
||||
// const Configs = await getEndpointAllApi();
|
||||
// Endpoints.value = Configs.data.items;
|
||||
let newParams = JSON.parse(JSON.stringify(params));
|
||||
initParam.page = newParams.page;
|
||||
initParam.limit = newParams.limit;
|
||||
return getList(newParams);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
created_at
|
||||
:
|
||||
{$date: {…}}
|
||||
|
||||
*/
|
||||
|
||||
// 表格配置项
|
||||
const columns = reactive<ColumnProps<NovelList.ResList>[]>([
|
||||
const columns = ref<ColumnProps[]>([
|
||||
{ type: "selection", fixed: "left", width: 50 },
|
||||
{
|
||||
prop: "key",
|
||||
label: "搜索内容",
|
||||
label: "小说名称 / ID",
|
||||
isShow: false,
|
||||
search: {
|
||||
el: "input"
|
||||
el: "input",
|
||||
props: {
|
||||
placeholder: "请输入小说名称或 ID"
|
||||
}
|
||||
}
|
||||
},
|
||||
// {
|
||||
// prop: "n_id",
|
||||
// label: "id",
|
||||
// width: 100
|
||||
// },
|
||||
// {
|
||||
// prop: "n_source_id",
|
||||
// label: "源id",
|
||||
// width: 100
|
||||
// },
|
||||
{
|
||||
prop: "n_name",
|
||||
label: "名称",
|
||||
width: 120
|
||||
label: "小说名称",
|
||||
width: 180,
|
||||
showOverflowTooltip: true
|
||||
},
|
||||
{
|
||||
prop: "n_level",
|
||||
@@ -149,8 +79,9 @@ const columns = reactive<ColumnProps<NovelList.ResList>[]>([
|
||||
|
||||
{
|
||||
prop: "n_name_pinyin",
|
||||
label: "拼音",
|
||||
width: 120
|
||||
label: "拼音标识",
|
||||
width: 150,
|
||||
showOverflowTooltip: true
|
||||
},
|
||||
|
||||
{
|
||||
@@ -169,151 +100,42 @@ const columns = reactive<ColumnProps<NovelList.ResList>[]>([
|
||||
},
|
||||
{
|
||||
prop: "n_author",
|
||||
label: "作者"
|
||||
label: "作者",
|
||||
showOverflowTooltip: true
|
||||
},
|
||||
|
||||
{
|
||||
prop: "n_category",
|
||||
label: "分类",
|
||||
width: 120,
|
||||
//fieldNames: { label: "xsfl_name", value: "xsfl_id" },
|
||||
//enum: getFenLeiList
|
||||
label: "小说分类",
|
||||
width: 140,
|
||||
showOverflowTooltip: true
|
||||
},
|
||||
|
||||
{
|
||||
prop: "n_description",
|
||||
label: "描述"
|
||||
label: "小说简介",
|
||||
showOverflowTooltip: true
|
||||
},
|
||||
// {
|
||||
// prop: "n_site_name",
|
||||
// label: "源站"
|
||||
// },
|
||||
{
|
||||
prop: "n_latest_chapter_name",
|
||||
label: "最新章节"
|
||||
label: "最新章节",
|
||||
showOverflowTooltip: true
|
||||
},
|
||||
|
||||
// { prop: "xsxq_source_code", label: "源码", width: 200 },
|
||||
// { prop: "xsxq_zi_shu", label: "字数", width: 150 },
|
||||
// { prop: "xsxq_jie_shao", label: "描述", width: 200 },
|
||||
// { prop: "xsxq_geng_xin_shi_jian", label: "最后更新时间", width: 170 },
|
||||
// { prop: "created_at", label: "添加时间", width: 170 },
|
||||
{ prop: "n_update_time", label: "更新时间", width: 170 },
|
||||
// { prop: "operation", label: "操作", fixed: "right", width: 100 }
|
||||
{ prop: "n_update_time", label: "更新时间", width: 170 }
|
||||
]);
|
||||
|
||||
// 删除小说信息
|
||||
const deleteAccount = async (params: NovelList.ResList) => {
|
||||
await useHandleData(deleteItem, { xsxq_id: params.xsxq_id }, "删除所选信息");
|
||||
proTable.value?.getTableList();
|
||||
};
|
||||
|
||||
// 批量删除小说信息
|
||||
const batchDelete = async (id: any[]) => {
|
||||
const ids = id.map((item: NovelList.ResList) => item.xsxq_id);
|
||||
await useHandleData(deleteItem, { xsxq_id: ids.join(",") }, "删除所选信息");
|
||||
proTable.value?.clearSelection();
|
||||
proTable.value?.getTableList();
|
||||
};
|
||||
// 打开 drawer(新增、编辑)
|
||||
const drawerRef = ref<InstanceType<typeof Drawer> | null>(null);
|
||||
const openDrawer = (isEdit: boolean, row: Partial<NovelList.ResList> = {}) => {
|
||||
const params = {
|
||||
title: isEdit ? "新增小说" : "编辑小说",
|
||||
row: { ...row },
|
||||
api: saveData,
|
||||
getTableList: proTable.value?.getTableList
|
||||
};
|
||||
drawerRef.value?.acceptParams(params as any);
|
||||
};
|
||||
|
||||
// 批量设置推荐等级
|
||||
const setDrawerRef = ref<InstanceType<typeof SetDrawer> | null>(null);
|
||||
const openSetDrawerRef = (isEdit: boolean, row: Partial<NovelList.ResList> = {}) => {
|
||||
const openSetDrawerRef = () => {
|
||||
const params = {
|
||||
title: "批量设置",
|
||||
row: { ...row },
|
||||
title: "批量设置小说等级",
|
||||
row: {},
|
||||
api: levelSet,
|
||||
getTableList: proTable.value?.getTableList
|
||||
getTableList: proTable.value?.getTableList,
|
||||
successMessage: "小说等级批量设置完成,已刷新列表"
|
||||
};
|
||||
setDrawerRef.value?.acceptParams(params as any);
|
||||
};
|
||||
|
||||
// 章节
|
||||
const chapterDrawerRef = ref<InstanceType<typeof ChapterDrawer> | null>(null);
|
||||
const openChapterDrawer = (row: Partial<NovelList.ResList> = {}) => {
|
||||
chapterDrawerRef.value?.acceptParams(row.xsxq_id!);
|
||||
};
|
||||
|
||||
// 异步加载图片组件
|
||||
const AsyncImageComponent = defineComponent({
|
||||
props: {
|
||||
url: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const imageUrl = ref("");
|
||||
const isLoading = ref(true);
|
||||
const error = ref(null);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await fetchImageData(props.url);
|
||||
const decodedImage = decode(data);
|
||||
imageUrl.value = decodedImage;
|
||||
isLoading.value = false;
|
||||
} catch (err) {
|
||||
console.error("Error converting image to base64:", err);
|
||||
error.value = err;
|
||||
isLoading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (isLoading.value) {
|
||||
return h("el-image", { src: "Loading..." });
|
||||
}
|
||||
|
||||
if (error.value) {
|
||||
return h("el-image", { src: "无法加载" });
|
||||
}
|
||||
return (
|
||||
<el-image
|
||||
style="width: 98%; height: 30px;display: flex;align-items: center;"
|
||||
src={imageUrl.value}
|
||||
preview-src-list={[imageUrl.value]}
|
||||
hide-on-click-modal={true}
|
||||
z-index={999999999}
|
||||
preview-teleported={true}
|
||||
// fit="contain"
|
||||
fit="cover"
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// 获取图片数据
|
||||
async function fetchImageData(url) {
|
||||
const response = await fetch(url);
|
||||
const data = await response.arrayBuffer();
|
||||
return data;
|
||||
}
|
||||
|
||||
// 解码图片函数
|
||||
function decode(data, key = 0x88) {
|
||||
let binary = "";
|
||||
let bytes = new Uint8Array(data);
|
||||
let len = bytes.byteLength;
|
||||
for (let i = 0; i < len; i++) {
|
||||
binary += String.fromCharCode(bytes[i] ^ key);
|
||||
}
|
||||
let src = window.btoa(binary);
|
||||
let image = "data:image/jpeg;base64," + src;
|
||||
return image;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<el-drawer v-model="drawerVisible" :destroy-on-close="true" size="720" :title="`${drawerProps.title}`">
|
||||
<el-drawer v-model="drawerVisible" :destroy-on-close="true" size="720" :title="drawerProps.title">
|
||||
<el-form
|
||||
ref="ruleFormRef"
|
||||
label-width="150px"
|
||||
@@ -9,109 +9,18 @@
|
||||
:model="drawerProps.row"
|
||||
:hide-required-asterisk="drawerProps.isView"
|
||||
>
|
||||
<el-form-item v-for="e in formColumns" :key="e.label" :label="e.label" :prop="e.prop" :required="e.required">
|
||||
<!-- <template v-if="e.type === 'switch'">
|
||||
<el-switch
|
||||
v-model="e.value"
|
||||
:active-value="`${1}`"
|
||||
:inactive-value="`${0}`"
|
||||
active-text="上架"
|
||||
inactive-text="下架"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="e.type === 'switch-index'">
|
||||
<el-switch
|
||||
v-model="e.value"
|
||||
:active-value="`${1}`"
|
||||
:inactive-value="`${0}`"
|
||||
active-text="是"
|
||||
inactive-text="否"
|
||||
/>
|
||||
</template> -->
|
||||
<!-- <template v-if="e.type === 'radio'">
|
||||
<el-radio-group v-model="e.value" class="ml-4">
|
||||
<el-radio v-for="item in e.options" :key="item.value" :value="item.value" size="large">{{
|
||||
item.label
|
||||
}}</el-radio>
|
||||
</el-radio-group>
|
||||
</template>
|
||||
<template v-if="e.type === 'select'">
|
||||
<el-select filterable v-model="e.value" :placeholder="`请选择${e.label}`" clearable>
|
||||
<el-option
|
||||
v-for="(item, index) in e.options"
|
||||
:key="index"
|
||||
:label="getField(item, e.fieldNames?.label)"
|
||||
:value="getField(item, e.fieldNames?.value)"
|
||||
/>
|
||||
</el-select>
|
||||
</template> -->
|
||||
<template v-if="e.type === 'text'">
|
||||
<el-input v-model="e.value" :placeholder="`请输入${e.label},多个使用, 分割`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'text-i'">
|
||||
<el-input v-model="e.value" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'text-num'">
|
||||
<el-input v-model="e.value" type="number" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'textarea'">
|
||||
<el-input
|
||||
type="textarea"
|
||||
:rows="20"
|
||||
v-model="e.value"
|
||||
maxlength="365000"
|
||||
:placeholder="`请输入${e.label},多个使用, 分割`"
|
||||
show-word-limit
|
||||
/>
|
||||
</template>
|
||||
<!-- <template v-if="e.type === 'tag'">
|
||||
<div class="tag-more-s">
|
||||
<el-input
|
||||
v-model="drawerProps.row.searchText"
|
||||
placeholder="请输入搜索内容"
|
||||
clearable
|
||||
@input="handleSearch"
|
||||
/>
|
||||
<div>
|
||||
<span>选中标签:</span>
|
||||
<div class="tag-more-h">
|
||||
<el-checkbox-group v-model="TagList" @change="placeableCheckedCitiesChange">
|
||||
<el-checkbox-button
|
||||
class="vip-power"
|
||||
v-for="item in matchingObjects"
|
||||
:value="item.t_id"
|
||||
:key="item.t_id"
|
||||
>
|
||||
{{ item.t_name }}
|
||||
</el-checkbox-button>
|
||||
</el-checkbox-group>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span>未选中标签:</span>
|
||||
<div class="tag-more-h">
|
||||
<el-checkbox-group v-model="TagList" @change="placeableCheckedCitiesChange">
|
||||
<el-checkbox-button
|
||||
class="vip-power"
|
||||
v-for="item in nonMatchingObjects"
|
||||
:value="item.t_id"
|
||||
:key="item.t_id"
|
||||
>
|
||||
{{ item.t_name }}
|
||||
</el-checkbox-button>
|
||||
</el-checkbox-group>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template> -->
|
||||
<!-- <template v-if="e.type === 'text-i'">
|
||||
<UploadImg disabled v-model:image-url="drawerProps.row!.n_cover" width="135px" height="135px" :file-size="3">
|
||||
<template #empty>
|
||||
<el-icon><Avatar /></el-icon>
|
||||
<span>请上传封面</span>
|
||||
</template>
|
||||
</UploadImg>
|
||||
</template> -->
|
||||
<el-form-item label="推荐等级" prop="n_level" required>
|
||||
<el-input v-model="drawerProps.row.n_level" type="number" placeholder="请输入推荐等级" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="小说名称" prop="keywords" required>
|
||||
<el-input
|
||||
v-model="drawerProps.row.keywords"
|
||||
type="textarea"
|
||||
:rows="16"
|
||||
maxlength="365000"
|
||||
placeholder="请输入小说名称,多个使用英文逗号分隔"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@@ -121,169 +30,15 @@
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="UserDrawer">
|
||||
import { ref, computed, reactive } from "vue";
|
||||
<script setup lang="ts" name="NovelLevelBatchDrawer">
|
||||
import { reactive, ref } from "vue";
|
||||
import { ElMessage, FormInstance } from "element-plus";
|
||||
// import { NovelList } from "@/api/interface/novel/list";
|
||||
import { arrNovelType } from "@/utils/serviceDict";
|
||||
// import { getTypeList } from "@/api/modules/tag/list";
|
||||
// import UploadImg from "@/components/Upload/Img.vue";
|
||||
import { extractURI } from "@/utils/eleValidate";
|
||||
type Options = {
|
||||
label: string;
|
||||
value: string | number;
|
||||
};
|
||||
import { NovelList } from "@/api/interface/novel/list";
|
||||
|
||||
interface Item {
|
||||
t_id: any;
|
||||
uri: any;
|
||||
}
|
||||
// const getField = (item: any, fieldName: string | undefined) => {
|
||||
// return fieldName ? item[fieldName] : "";
|
||||
// };
|
||||
type Fields = {
|
||||
label: string;
|
||||
prop: string;
|
||||
type: string;
|
||||
value: any;
|
||||
required: boolean;
|
||||
options?: Options[];
|
||||
fieldNames?: { label: string; value: string };
|
||||
};
|
||||
const generateRules = (fields: Fields[]) => {
|
||||
return fields.reduce((acc, field) => {
|
||||
const { type, required, label, prop } = field;
|
||||
let message = "";
|
||||
|
||||
if (type === "text") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
|
||||
if (type === "text-num") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
|
||||
acc[prop] = [{ required, message }];
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
};
|
||||
|
||||
const TagList = ref([] as any);
|
||||
const arrTagList = ref([] as any);
|
||||
const arrNewTagList = ref([] as any);
|
||||
const matchingObjects = ref([] as any); //选中相同
|
||||
const nonMatchingObjects = ref([] as any); //没有选中的
|
||||
const matchingObjectsA = ref([] as any); //选中相同
|
||||
const nonMatchingObjectsB = ref([] as any); //没有选中的
|
||||
// const getTagList = async () => {
|
||||
// try {
|
||||
// const initParam = reactive({
|
||||
// t_type: 3,
|
||||
// limit: 3650,
|
||||
// page: 1,
|
||||
// t_hidden: 1
|
||||
// });
|
||||
// const siteRes = await getTypeList(initParam);
|
||||
// arrTagList.value = siteRes.data.item;
|
||||
// arrNewTagList.value = siteRes.data.item;
|
||||
// matchingObjects.value = [];
|
||||
// matchingObjectsA.value = [];
|
||||
// // 遍历第一个数组的每个元素
|
||||
// TagList.value.forEach(index => {
|
||||
// // 遍历第二个数组的每个元素
|
||||
// arrNewTagList.value.forEach(obj => {
|
||||
// // 如果第二个数组中的元素的t_id与第一个数组中的元素相同,则将其添加到新数组中
|
||||
// if (obj.t_id === index) {
|
||||
// matchingObjects.value = [...matchingObjects.value, obj];
|
||||
// matchingObjectsA.value = [...matchingObjectsA.value, obj];
|
||||
// const matchingIds = matchingObjectsA.value.map(obj => obj.t_id);
|
||||
// TagList.value = matchingIds;
|
||||
// }
|
||||
// });
|
||||
// });
|
||||
// // 创建一个新数组,包含objectArray中id不存在于arrayA的元素
|
||||
// nonMatchingObjects.value = arrNewTagList.value.filter(obj => !TagList.value.includes(obj.t_id));
|
||||
// nonMatchingObjectsB.value = arrNewTagList.value.filter(obj => !TagList.value.includes(obj.t_id));
|
||||
// } catch (error) {
|
||||
// console.log(error);
|
||||
// }
|
||||
// };
|
||||
|
||||
// 处理搜索事件
|
||||
function handleSearch() {
|
||||
// 在输入事件中实时更新过滤后的标签列表
|
||||
filteredTagList();
|
||||
}
|
||||
function filteredTagList() {
|
||||
if (drawerProps.value.row!.searchText.length > 0) {
|
||||
let arrData = arrTagList.value.filter(tag =>
|
||||
tag.t_name.toLowerCase().includes(drawerProps.value.row!.searchText.toLowerCase())
|
||||
);
|
||||
if (arrData.length > 0) {
|
||||
matchingObjects.value = matchingObjects.value.filter(objA => arrData.some(objB => objB.t_id === objA.t_id));
|
||||
nonMatchingObjects.value = nonMatchingObjects.value.filter(objA => arrData.some(objB => objB.t_id === objA.t_id));
|
||||
} else {
|
||||
matchingObjects.value = [];
|
||||
nonMatchingObjects.value = [];
|
||||
}
|
||||
} else {
|
||||
matchingObjects.value = matchingObjectsA.value;
|
||||
nonMatchingObjects.value = nonMatchingObjectsB.value;
|
||||
}
|
||||
}
|
||||
|
||||
//标签数组
|
||||
async function placeableCheckedCitiesChange(value) {
|
||||
matchingObjects.value = [];
|
||||
matchingObjectsA.value = [];
|
||||
drawerProps.value.row.n_tags = value;
|
||||
// 遍历第一个数组的每个元素
|
||||
TagList.value.forEach(index => {
|
||||
// 遍历第二个数组的每个元素
|
||||
arrNewTagList.value.forEach(obj => {
|
||||
// 如果第二个数组中的元素的t_id与第一个数组中的元素相同,则将其添加到新数组中
|
||||
if (obj.t_id === index) {
|
||||
matchingObjects.value = [...matchingObjects.value, obj];
|
||||
matchingObjectsA.value = [...matchingObjectsA.value, obj];
|
||||
const matchingIds = matchingObjectsA.value.map(obj => obj.t_id);
|
||||
TagList.value = matchingIds;
|
||||
}
|
||||
});
|
||||
});
|
||||
nonMatchingObjects.value = arrNewTagList.value.filter(obj => !TagList.value.includes(obj.t_id));
|
||||
nonMatchingObjectsB.value = arrNewTagList.value.filter(obj => !TagList.value.includes(obj.t_id));
|
||||
}
|
||||
const formColumns = computed((): Fields[] => {
|
||||
return [
|
||||
{
|
||||
label: "推荐等级",
|
||||
prop: "n_level",
|
||||
type: "text-num",
|
||||
get value() {
|
||||
return drawerProps.value.row!.n_level;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.n_level = val;
|
||||
},
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: "小说名字",
|
||||
prop: "keywords",
|
||||
type: "textarea",
|
||||
get value() {
|
||||
return drawerProps.value.row!.keywords;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.keywords = val;
|
||||
},
|
||||
required: true
|
||||
},
|
||||
|
||||
|
||||
];
|
||||
const rules = reactive({
|
||||
n_level: [{ required: true, message: "请输入推荐等级" }],
|
||||
keywords: [{ required: true, message: "请输入小说名称" }]
|
||||
});
|
||||
const rules = ref();
|
||||
|
||||
interface DrawerProps {
|
||||
title: string;
|
||||
@@ -291,6 +46,7 @@ interface DrawerProps {
|
||||
row: Partial<NovelList.ResList>;
|
||||
api?: (params: any) => Promise<any>;
|
||||
getTableList?: () => void;
|
||||
successMessage?: string;
|
||||
}
|
||||
|
||||
const drawerVisible = ref(false);
|
||||
@@ -300,45 +56,27 @@ const drawerProps = ref<DrawerProps>({
|
||||
row: {}
|
||||
});
|
||||
|
||||
// 接收父组件传过来的参数
|
||||
const acceptParams = (params: DrawerProps) => {
|
||||
drawerProps.value = params;
|
||||
rules.value = generateRules(formColumns.value);
|
||||
drawerProps.value = {
|
||||
...params,
|
||||
row: { ...params.row }
|
||||
};
|
||||
drawerVisible.value = true;
|
||||
TagList.value = [];
|
||||
if (drawerProps.value.title === "新增小说") {
|
||||
drawerProps.value.row!.n_type = 0;
|
||||
}
|
||||
if (drawerProps.value.row.n_cover) {
|
||||
drawerProps.value.row.n_cover_code = drawerProps.value.row.n_cover.code;
|
||||
drawerProps.value.row.n_cover = drawerProps.value.row.n_cover_detail;
|
||||
}
|
||||
if (drawerProps.value.row.n_tags) {
|
||||
TagList.value = drawerProps.value.row.n_tags.map((obj: Item) => obj.t_id).map(Number);
|
||||
}
|
||||
// getTagList();
|
||||
};
|
||||
|
||||
// 提交数据(新增/编辑)
|
||||
const ruleFormRef = ref<FormInstance>();
|
||||
const handleSubmit = () => {
|
||||
ruleFormRef.value!.validate(async valid => {
|
||||
if (!valid) return;
|
||||
try {
|
||||
const params = { ...drawerProps.value.row };
|
||||
// let arrData = {
|
||||
// uri: extractURI(params.n_cover),
|
||||
// code: params.n_cover_code
|
||||
// };
|
||||
|
||||
// params.n_cover = arrData;
|
||||
// params.n_tags = TagList.value;
|
||||
await drawerProps.value.api!(params);
|
||||
ElMessage.success({ message: `${drawerProps.value.title}成功!` });
|
||||
await drawerProps.value.api!({ ...drawerProps.value.row });
|
||||
ElMessage.success({
|
||||
message: drawerProps.value.successMessage || `${drawerProps.value.title}完成,已刷新列表`
|
||||
});
|
||||
drawerProps.value.getTableList!();
|
||||
drawerVisible.value = false;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} catch (_error) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -347,19 +85,3 @@ defineExpose({
|
||||
acceptParams
|
||||
});
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.el-checkbox-button__inner {
|
||||
margin-right: 10px;
|
||||
margin-bottom: 10px;
|
||||
border-left-color: #dcdfe6 !important;
|
||||
}
|
||||
.tag-more-h {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.tag-more-s {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid black;
|
||||
box-shadow: 0 0 4px 2px rgb(0 0 0 / 90%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
<template>
|
||||
<el-drawer v-model="drawerVisible" :destroy-on-close="true" size="720" :title="`${drawerProps.title}资源端点`">
|
||||
<el-form
|
||||
ref="ruleFormRef"
|
||||
label-width="90px"
|
||||
label-position="top"
|
||||
label-suffix=" :"
|
||||
:rules="rules"
|
||||
:disabled="drawerProps.isView"
|
||||
:model="drawerProps.row"
|
||||
:hide-required-asterisk="drawerProps.isView"
|
||||
>
|
||||
<el-form-item label="端点名称" prop="zydd_ming_zi">
|
||||
<el-input
|
||||
type="text"
|
||||
disabled
|
||||
v-model="drawerProps.row.zydd_ming_zi"
|
||||
placeholder="请输入资源访问端点域名"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="端点编码" prop="zydd_code">
|
||||
<el-input
|
||||
type="text"
|
||||
disabled
|
||||
v-model="drawerProps.row.zydd_code"
|
||||
placeholder="请输入资源访问端点域名"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="端点域名" prop="zydd_domain">
|
||||
<el-input type="textarea" v-model="drawerProps.row.zydd_domain" placeholder="请输入资源访问端点域名" clearable />
|
||||
</el-form-item>
|
||||
<!-- <el-form-item label="端点类型" prop="re_type">
|
||||
<el-switch
|
||||
v-model="drawerProps.row.re_type"
|
||||
:active-value="`${1}`"
|
||||
:inactive-value="`${0}`"
|
||||
active-text="VIP"
|
||||
inactive-text="普通"
|
||||
/>
|
||||
</el-form-item> -->
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="drawerVisible = false">取消</el-button>
|
||||
<el-button v-show="!drawerProps.isView" type="primary" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="tsx" name="Drawer">
|
||||
import { ref, reactive } from "vue";
|
||||
import { ElMessage, FormInstance } from "element-plus";
|
||||
import { Endpoint } from "@/api/interface/system/endpoint";
|
||||
|
||||
const rules = reactive({
|
||||
zydd_domain: [{ required: true, message: "请输入端点域名" }]
|
||||
});
|
||||
|
||||
interface DrawerProps {
|
||||
title: string;
|
||||
isView: boolean;
|
||||
row: Partial<Endpoint.List>;
|
||||
api?: (params: any) => Promise<any>;
|
||||
getTableList?: () => void;
|
||||
}
|
||||
|
||||
const drawerVisible = ref(false);
|
||||
const drawerProps = ref<DrawerProps>({
|
||||
isView: false,
|
||||
title: "",
|
||||
row: {}
|
||||
});
|
||||
|
||||
// 接收父组件传过来的参数
|
||||
const acceptParams = (params: DrawerProps) => {
|
||||
drawerProps.value = params;
|
||||
drawerVisible.value = true;
|
||||
};
|
||||
|
||||
// 提交数据(新增/编辑)
|
||||
const ruleFormRef = ref<FormInstance>();
|
||||
const handleSubmit = () => {
|
||||
ruleFormRef.value!.validate(async valid => {
|
||||
if (!valid) return;
|
||||
try {
|
||||
const params = { ...drawerProps.value.row };
|
||||
await drawerProps.value.api!(params);
|
||||
ElMessage.success({ message: `${drawerProps.value.title}资源端点成功!` });
|
||||
drawerProps.value.getTableList!();
|
||||
drawerVisible.value = false;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
defineExpose({
|
||||
acceptParams
|
||||
});
|
||||
</script>
|
||||
@@ -1,172 +1,31 @@
|
||||
<template>
|
||||
<div class="table-box">
|
||||
<!-- <ProTable ref="proTable" highlight-current-row :columns="columns" :request-api="ListApi"> -->
|
||||
<ProTable
|
||||
title="端点列表"
|
||||
ref="proTable"
|
||||
row-key="id"
|
||||
:indent="20"
|
||||
:columns="columns"
|
||||
:request-api="getTableList"
|
||||
:init-param="initParam"
|
||||
:data-callback="dataCallback"
|
||||
:request-auto="true"
|
||||
:search-col="{ xs: 1, sm: 1, md: 2, lg: 3, xl: 3 }"
|
||||
>
|
||||
<!-- 表格 header 按钮 -->
|
||||
<template #tableHeader="scope">
|
||||
<el-button type="primary" style="display: none" :icon="CirclePlus" @click="openDrawer('新增')">
|
||||
新增资源端点
|
||||
</el-button>
|
||||
<el-button
|
||||
style="display: none"
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
plain
|
||||
:disabled="!scope.isSelected"
|
||||
@click="batchDelete(scope.selectedList)"
|
||||
>
|
||||
批量删除端点
|
||||
</el-button>
|
||||
</template>
|
||||
<!-- 表格操作 -->
|
||||
<template #operation="scope">
|
||||
<el-button type="primary" :icon="EditPen" @click="openDrawer('编辑', scope.row)"> 编辑 </el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
style="display: none"
|
||||
v-if="scope.row.pid != 0"
|
||||
link
|
||||
:icon="Delete"
|
||||
@click="deleteAccount(scope.row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</ProTable>
|
||||
<Drawer ref="drawerRef" />
|
||||
<div class="module-unavailable">
|
||||
<el-alert
|
||||
title="资源端点模块未部署到当前 Linux 测试服"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<el-card class="module-unavailable__card" shadow="never">
|
||||
<p>当前端点页混用了 `/system/zydd/*` 和 `/admin/resource/endpoint/*` 旧链路,测试服带真实 `admin-token` 访问也会直接返回控制器不存在。</p>
|
||||
<p>本页先收为说明态,避免继续暴露端点列表、编辑和删除这些无法真实联调的历史能力。</p>
|
||||
<p>如果后续补齐资源端点后端,再恢复当前列表页和抽屉。</p>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="tsx" setup name="endpoint">
|
||||
import ProTable from "@/components/ProTable/index.vue";
|
||||
import { DelApi, ListApi, SaveApi } from "@/api/modules/system/endpoint";
|
||||
import { reactive, ref } from "vue";
|
||||
import { ColumnProps, ProTableInstance } from "@/components/ProTable/interface";
|
||||
import { Endpoint } from "@/api/interface/system/endpoint";
|
||||
import { CirclePlus, EditPen, Delete } from "@element-plus/icons-vue";
|
||||
import Drawer from "./components/Drawer.vue";
|
||||
import { useHandleData } from "@/hooks/useHandleData";
|
||||
<style scoped lang="scss">
|
||||
.module-unavailable {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
// ProTable 实例
|
||||
const proTable = ref<ProTableInstance>();
|
||||
.module-unavailable__card {
|
||||
color: var(--el-text-color-regular);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
const initParam = reactive({
|
||||
limit: 15,
|
||||
page: 1
|
||||
});
|
||||
|
||||
const getTableList = (params: any) => {
|
||||
let newParams = JSON.parse(JSON.stringify(params));
|
||||
initParam.page = newParams.page;
|
||||
initParam.limit = newParams.limit;
|
||||
return ListApi(newParams);
|
||||
};
|
||||
|
||||
const dataCallback = (data: any) => {
|
||||
// const groupedData = groupBySgId(data.item);
|
||||
// let arrNewData = [] as any;
|
||||
// arrNewData = groupedData.map((item, index) => ({
|
||||
// ...item,
|
||||
// id: index + 1, // 这里id是从1开始计数
|
||||
// pid: 0
|
||||
// }));
|
||||
return {
|
||||
items: data.items,
|
||||
limit: initParam.limit,
|
||||
page: initParam.page,
|
||||
total: data.total,
|
||||
total_page: data.total_page
|
||||
};
|
||||
};
|
||||
//树结构
|
||||
// function groupBySgId(data) {
|
||||
// const groupMap = new Map();
|
||||
// data.forEach(item => {
|
||||
// const { reg_id, reg_name } = item;
|
||||
// if (!groupMap.has(reg_id)) {
|
||||
// groupMap.set(reg_id, {
|
||||
// reg_id,
|
||||
// reg_name,
|
||||
// re_type: 2,
|
||||
// re_domain: "",
|
||||
// created_at: "",
|
||||
// children: []
|
||||
// });
|
||||
// }
|
||||
// item.reg_name = "";
|
||||
// groupMap.get(reg_id).children.push(item);
|
||||
// });
|
||||
// return Array.from(groupMap.values());
|
||||
// }
|
||||
|
||||
// 表格配置项
|
||||
const columns = reactive<ColumnProps<Endpoint.List>[]>([
|
||||
{ type: "selection", fixed: "left", width: 50 },
|
||||
{
|
||||
prop: "key",
|
||||
label: "查询关键字",
|
||||
isShow: false,
|
||||
search: {
|
||||
el: "input"
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: "zydd_id",
|
||||
label: "ID",
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
prop: "zydd_ming_zi",
|
||||
label: "名称"
|
||||
},
|
||||
{
|
||||
prop: "zydd_code",
|
||||
label: "编码"
|
||||
},
|
||||
{
|
||||
prop: "zydd_domain",
|
||||
label: "资源访问端点域名"
|
||||
},
|
||||
|
||||
{ prop: "operation", label: "操作", width: 230 }
|
||||
]);
|
||||
|
||||
// 打开 drawer(新增、查看、编辑)
|
||||
const drawerRef = ref<InstanceType<typeof Drawer> | null>(null);
|
||||
const openDrawer = (title: string, row: Partial<Endpoint.List> = {}) => {
|
||||
const params = {
|
||||
title,
|
||||
api: SaveApi,
|
||||
getTableList: proTable.value?.getTableList,
|
||||
isView: title === "查看",
|
||||
row: { ...row }
|
||||
};
|
||||
drawerRef.value?.acceptParams(params);
|
||||
};
|
||||
|
||||
// 删除资源端点
|
||||
const deleteAccount = async (params: Endpoint.List) => {
|
||||
await useHandleData(DelApi, { re_id: params.re_id }, `删除所选信息`);
|
||||
proTable.value?.getTableList();
|
||||
};
|
||||
// 批量删除
|
||||
const batchDelete = async (id: any[]) => {
|
||||
const ids = id.map((item: Endpoint.List) => item.re_id);
|
||||
await useHandleData(DelApi, { re_id: ids.join(",") }, "删除所选信息");
|
||||
proTable.value?.clearSelection();
|
||||
proTable.value?.getTableList();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
.module-unavailable__card p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
<template>
|
||||
<el-drawer v-model="drawerVisible" :destroy-on-close="true" size="720px" :title="`${drawerProps.title}资源端点分组`">
|
||||
<el-form
|
||||
ref="ruleFormRef"
|
||||
label-width="90px"
|
||||
label-position="top"
|
||||
label-suffix=" :"
|
||||
:rules="rules"
|
||||
:disabled="drawerProps.isView"
|
||||
:model="drawerProps.row"
|
||||
:hide-required-asterisk="drawerProps.isView"
|
||||
>
|
||||
<el-form-item label="分组名称" prop="reg_name">
|
||||
<el-input v-model="drawerProps.row.reg_name" placeholder="请输入资源端点分组名称" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="分组编码" prop="reg_code">
|
||||
<el-input v-model="drawerProps.row.reg_code" placeholder="请输入资源端点分组编码" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="分组描述" prop="reg_description">
|
||||
<el-input
|
||||
type="textarea"
|
||||
v-model="drawerProps.row.reg_description"
|
||||
placeholder="请输入资源端点分组描述"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="drawerVisible = false">取消</el-button>
|
||||
<el-button v-show="!drawerProps.isView" type="primary" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="tsx" name="Drawer">
|
||||
import { ref, reactive } from "vue";
|
||||
import { ElMessage, FormInstance } from "element-plus";
|
||||
import { EndpointGroup } from "@/api/interface/system/endpointGroup";
|
||||
|
||||
const rules = reactive({
|
||||
reg_code: [{ required: true, message: "请输入资源端点分组编码" }],
|
||||
reg_name: [{ required: true, message: "请输入资源端点分组名称" }]
|
||||
});
|
||||
|
||||
interface DrawerProps {
|
||||
title: string;
|
||||
isView: boolean;
|
||||
row: Partial<EndpointGroup.List>;
|
||||
api?: (params: any) => Promise<any>;
|
||||
getTableList?: () => void;
|
||||
}
|
||||
|
||||
const drawerVisible = ref(false);
|
||||
const drawerProps = ref<DrawerProps>({
|
||||
isView: false,
|
||||
title: "",
|
||||
row: {}
|
||||
});
|
||||
|
||||
// 接收父组件传过来的参数
|
||||
const acceptParams = (params: DrawerProps) => {
|
||||
drawerProps.value = params;
|
||||
drawerVisible.value = true;
|
||||
};
|
||||
|
||||
// 提交数据(新增/编辑)
|
||||
const ruleFormRef = ref<FormInstance>();
|
||||
const handleSubmit = () => {
|
||||
ruleFormRef.value!.validate(async valid => {
|
||||
if (!valid) return;
|
||||
try {
|
||||
if (drawerProps.value.row.reg_description?.length == 0) {
|
||||
delete drawerProps.value.row.reg_description;
|
||||
}
|
||||
await drawerProps.value.api!(drawerProps.value.row);
|
||||
ElMessage.success({ message: `${drawerProps.value.title}资源端点分组成功!` });
|
||||
drawerProps.value.getTableList!();
|
||||
drawerVisible.value = false;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
defineExpose({
|
||||
acceptParams
|
||||
});
|
||||
</script>
|
||||
@@ -1,127 +1,31 @@
|
||||
<template>
|
||||
<div class="table-box">
|
||||
<ProTable ref="proTable" highlight-current-row :columns="columns" :request-api="ListApi">
|
||||
<!-- 表格 header 按钮 -->
|
||||
<template #tableHeader="scope">
|
||||
<el-button type="primary" :icon="CirclePlus" @click="openDrawer('新增')">新增资源端点分组</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
plain
|
||||
:disabled="!scope.isSelected"
|
||||
@click="batchDelete(scope.selectedList)"
|
||||
>
|
||||
批量删除端点分组
|
||||
</el-button>
|
||||
</template>
|
||||
<!-- 表格操作 -->
|
||||
<template #operation="scope">
|
||||
<el-button type="primary" link :icon="EditPen" @click="openDrawer('编辑', scope.row)">编辑</el-button>
|
||||
<el-button type="danger" link :icon="Delete" @click="deleteAccount(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</ProTable>
|
||||
<Drawer ref="drawerRef" />
|
||||
<div class="module-unavailable">
|
||||
<el-alert
|
||||
title="资源端点分组模块未部署到当前 Linux 测试服"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<el-card class="module-unavailable__card" shadow="never">
|
||||
<p>当前端点分组页依赖旧 `/admin/resource/endpoint/group/*` 接口,测试服带真实 `admin-token` 访问也会直接返回控制器不存在。</p>
|
||||
<p>本页先收为说明态,避免继续暴露端点分组新增、编辑和删除这些无法真实落库的历史能力。</p>
|
||||
<p>如果后续补齐资源端点分组后端,再恢复当前列表页和抽屉。</p>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="tsx" setup name="endpointGroup">
|
||||
import { EndpointGroup } from "@/api/interface/system/endpointGroup";
|
||||
import ProTable from "@/components/ProTable/index.vue";
|
||||
import { ColumnProps, ProTableInstance } from "@/components/ProTable/interface";
|
||||
import { reactive, ref } from "vue";
|
||||
import { DelApi, ListApi, SaveApi } from "@/api/modules/system/endpointGroup";
|
||||
import { CirclePlus, EditPen, Delete } from "@element-plus/icons-vue";
|
||||
import Drawer from "./components/Drawer.vue";
|
||||
import { useHandleData } from "@/hooks/useHandleData";
|
||||
// import { useRouter } from "vue-router";
|
||||
// const router = useRouter();
|
||||
// ProTable 实例
|
||||
const proTable = ref<ProTableInstance>();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
// let index = 0;
|
||||
// 表格配置项
|
||||
const columns = reactive<ColumnProps<EndpointGroup.List>[]>([
|
||||
{ type: "selection", fixed: "left", width: 50 },
|
||||
{
|
||||
prop: "reg_name",
|
||||
label: "查询关键字",
|
||||
isShow: false,
|
||||
search: {
|
||||
el: "input"
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: "reg_id",
|
||||
label: "ID",
|
||||
width: 100
|
||||
},
|
||||
// {
|
||||
// prop: "reg_id",
|
||||
// label: "端点列表跳转",
|
||||
// width: 120,
|
||||
// render(scope) {
|
||||
// if (scope.row.reg_id) {
|
||||
// const data = scope.row.reg_id;
|
||||
// return (
|
||||
// <el-tag onClick={() => JumpFum(data)} style="cursor:pointer;">
|
||||
// 点击跳转
|
||||
// </el-tag>
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
// },
|
||||
{
|
||||
prop: "reg_name",
|
||||
label: "端点分组名称"
|
||||
},
|
||||
{
|
||||
prop: "reg_code",
|
||||
label: "端点分组编码"
|
||||
},
|
||||
{
|
||||
prop: "reg_description",
|
||||
label: "描述"
|
||||
},
|
||||
{
|
||||
prop: "created_at",
|
||||
label: "添加时间"
|
||||
},
|
||||
<style scoped lang="scss">
|
||||
.module-unavailable {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
{ prop: "operation", label: "操作", width: 230 }
|
||||
]);
|
||||
.module-unavailable__card {
|
||||
color: var(--el-text-color-regular);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
// 打开 drawer(新增、查看、编辑)
|
||||
const drawerRef = ref<InstanceType<typeof Drawer> | null>(null);
|
||||
const openDrawer = (title: string, row: Partial<EndpointGroup.List> = {}) => {
|
||||
const params = {
|
||||
title,
|
||||
api: SaveApi,
|
||||
getTableList: proTable.value?.getTableList,
|
||||
isView: title === "查看",
|
||||
row: { ...row }
|
||||
};
|
||||
drawerRef.value?.acceptParams(params);
|
||||
};
|
||||
// 删除资源端点分组
|
||||
const deleteAccount = async (params: EndpointGroup.List) => {
|
||||
await useHandleData(DelApi, { reg_id: params.reg_id }, `删除【${params.reg_name}】的资源端点分组`);
|
||||
proTable.value?.getTableList();
|
||||
};
|
||||
// 批量删除
|
||||
const batchDelete = async (id: any[]) => {
|
||||
const ids = id.map((item: EndpointGroup.List) => item.reg_id);
|
||||
await useHandleData(DelApi, { reg_id: ids.join(",") }, "删除所选信息");
|
||||
proTable.value?.clearSelection();
|
||||
proTable.value?.getTableList();
|
||||
};
|
||||
|
||||
// const JumpFum = val => {
|
||||
// index++;
|
||||
// let Data = {
|
||||
// id: val
|
||||
// };
|
||||
// router.push({ path: "/resource/endpoint/list", query: Data });
|
||||
// };
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
.module-unavailable__card p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
<template>
|
||||
<el-drawer v-model="drawerVisible" :destroy-on-close="true" size="720" :title="`${drawerProps.title}`">
|
||||
<el-form
|
||||
ref="ruleFormRef"
|
||||
label-width="60px"
|
||||
label-position="top"
|
||||
label-suffix=" :"
|
||||
:rules="rules"
|
||||
:disabled="drawerProps.isView"
|
||||
:model="drawerProps.row"
|
||||
:hide-required-asterisk="drawerProps.isView"
|
||||
>
|
||||
<el-form-item v-for="e in formColumns" :key="e.label" :label="e.label" :prop="e.prop" :required="e.required">
|
||||
<template v-if="e.type === 'switch'">
|
||||
<el-switch
|
||||
v-model="e.value"
|
||||
:active-value="`${1}`"
|
||||
:inactive-value="`${0}`"
|
||||
active-text="开启"
|
||||
inactive-text="关闭"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="e.type === 'radio'">
|
||||
<el-radio-group v-model="e.value" class="ml-4">
|
||||
<el-radio v-for="item in e.options" :key="item.value" :label="item.value" size="large">{{
|
||||
item.label
|
||||
}}</el-radio>
|
||||
</el-radio-group>
|
||||
</template>
|
||||
<template v-if="e.type === 'select'">
|
||||
<el-select filterable v-model="e.value" :placeholder="`请选择${e.label}`" clearable>
|
||||
<el-option
|
||||
v-for="(item, index) in e.options"
|
||||
:key="index"
|
||||
:label="getField(item, e.fieldNames?.label)"
|
||||
:value="getField(item, e.fieldNames?.value)"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
<template v-if="e.type === 'text'">
|
||||
<el-input v-model="e.value" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'text-num'">
|
||||
<el-input v-model="e.value" type="number" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'textarea'">
|
||||
<el-input
|
||||
type="textarea"
|
||||
v-model="e.value"
|
||||
maxlength="3650"
|
||||
:placeholder="`请输入${e.label}`"
|
||||
show-word-limit
|
||||
/>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="drawerVisible = false">取消</el-button>
|
||||
<el-button v-show="!drawerProps.isView" type="primary" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="UserDrawer">
|
||||
import { ref, computed } from "vue";
|
||||
import { ElMessage, FormInstance } from "element-plus";
|
||||
import { SearchGroupList } from "@/api/interface/searchGroup/list";
|
||||
|
||||
type Options = {
|
||||
label: string;
|
||||
value: string | number;
|
||||
};
|
||||
|
||||
const getField = (item: any, fieldName: string | undefined) => {
|
||||
return fieldName ? item[fieldName] : "";
|
||||
};
|
||||
type Fields = {
|
||||
label: string;
|
||||
prop: string;
|
||||
type: string;
|
||||
value: any;
|
||||
required: boolean;
|
||||
options?: Options[];
|
||||
fieldNames?: { label: string; value: string };
|
||||
};
|
||||
const generateRules = (fields: Fields[]) => {
|
||||
return fields.reduce(
|
||||
(acc, field) => {
|
||||
const { type, required, label, prop } = field;
|
||||
let message = "";
|
||||
if (type === "select") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "text") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "text-num") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
acc[prop] = [{ required, message }];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>
|
||||
);
|
||||
};
|
||||
|
||||
const formColumns = computed((): Fields[] => {
|
||||
return [
|
||||
{
|
||||
label: "名称",
|
||||
prop: "sg_name",
|
||||
type: "text",
|
||||
get value() {
|
||||
return drawerProps.value.row!.sg_name;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.sg_name = val;
|
||||
},
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: "排序",
|
||||
prop: "sg_order",
|
||||
type: "text-num",
|
||||
get value() {
|
||||
return drawerProps.value.row!.sg_order;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.sg_order = val;
|
||||
},
|
||||
required: true
|
||||
}
|
||||
];
|
||||
});
|
||||
const rules = ref();
|
||||
|
||||
interface DrawerProps {
|
||||
title: string;
|
||||
isView: boolean;
|
||||
row: Partial<SearchGroupList.ResList>;
|
||||
api?: (params: any) => Promise<any>;
|
||||
getTableList?: () => void;
|
||||
}
|
||||
|
||||
const drawerVisible = ref(false);
|
||||
const drawerProps = ref<DrawerProps>({
|
||||
isView: false,
|
||||
title: "",
|
||||
row: {}
|
||||
});
|
||||
|
||||
// 接收父组件传过来的参数
|
||||
const acceptParams = (params: DrawerProps) => {
|
||||
drawerProps.value = params;
|
||||
rules.value = generateRules(formColumns.value);
|
||||
drawerVisible.value = true;
|
||||
};
|
||||
|
||||
// 提交数据(新增/编辑)
|
||||
const ruleFormRef = ref<FormInstance>();
|
||||
const handleSubmit = () => {
|
||||
ruleFormRef.value!.validate(async valid => {
|
||||
if (!valid) return;
|
||||
try {
|
||||
await drawerProps.value.api!(drawerProps.value.row);
|
||||
ElMessage.success({ message: `${drawerProps.value.title}成功!` });
|
||||
drawerProps.value.getTableList!();
|
||||
drawerVisible.value = false;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
acceptParams
|
||||
});
|
||||
</script>
|
||||
@@ -1,148 +1,31 @@
|
||||
<template>
|
||||
<div class="table-box">
|
||||
<ProTable ref="proTable" highlight-current-row :columns="columns" :request-api="getList">
|
||||
<!-- 表格 header 按钮 -->
|
||||
<template #tableHeader="scope">
|
||||
<el-button type="primary" :icon="CirclePlus" @click="openDrawer(true)">新增</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
plain
|
||||
:disabled="!scope.isSelected"
|
||||
@click="batchDelete(scope.selectedList)"
|
||||
>
|
||||
批量删除搜索分组
|
||||
</el-button>
|
||||
</template>
|
||||
<!-- 表格操作 -->
|
||||
<template #operation="scope">
|
||||
<el-button type="primary" :icon="EditPen" @click="openDrawer(false, scope.row)">编辑</el-button>
|
||||
<el-button type="danger" :icon="Delete" @click="deleteAccount(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</ProTable>
|
||||
<Drawer ref="drawerRef" />
|
||||
<div class="module-unavailable">
|
||||
<el-alert
|
||||
title="搜索分组模块未部署到当前 Linux 测试服"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<el-card class="module-unavailable__card" shadow="never">
|
||||
<p>当前后端未提供 `Search` 控制器,搜索分组接口会直接返回控制器不存在。</p>
|
||||
<p>本页先收为说明态,避免继续暴露会误导验收的搜索分组 CRUD。</p>
|
||||
<p>如果后续补齐搜索分组模块,再恢复当前列表页和抽屉联调。</p>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="tsx" name="complexProTable">
|
||||
import { reactive, ref } from "vue";
|
||||
import { SearchGroupList } from "@/api/interface/searchGroup/list";
|
||||
import { useHandleData } from "@/hooks/useHandleData";
|
||||
import ProTable from "@/components/ProTable/index.vue";
|
||||
import { Delete, EditPen, CirclePlus } from "@element-plus/icons-vue";
|
||||
import { ProTableInstance, ColumnProps } from "@/components/ProTable/interface";
|
||||
import { getList, saveData, deleteItem } from "@/api/modules/searchGroup/list";
|
||||
import Drawer from "./drawer.vue";
|
||||
// ProTable 实例
|
||||
const proTable = ref<ProTableInstance>();
|
||||
|
||||
// 表格配置项
|
||||
const columns = reactive<ColumnProps<SearchGroupList.ResList>[]>([
|
||||
{ type: "selection", fixed: "left", width: 50 },
|
||||
{
|
||||
prop: "key",
|
||||
label: "搜索内容",
|
||||
isShow: false,
|
||||
search: {
|
||||
el: "input"
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: "sg_id",
|
||||
label: "ID",
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
prop: "sg_name",
|
||||
label: "名称"
|
||||
},
|
||||
{
|
||||
prop: "sg_order",
|
||||
label: "排序"
|
||||
},
|
||||
{ prop: "created_at", label: "添加时间" },
|
||||
{ prop: "operation", label: "操作", fixed: "right", width: 230 }
|
||||
]);
|
||||
import { ElMessageBox, ElMessage } from "element-plus";
|
||||
// 删除搜索分组信息
|
||||
const deleteAccount = async (params: SearchGroupList.ResList) => {
|
||||
// 第一步验证
|
||||
ElMessageBox.confirm(`是否删除该分组,如果删除则该分组下的所有搜索词也会被删除?`, "温馨提示", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
center: true,
|
||||
draggable: true,
|
||||
type: "warning"
|
||||
})
|
||||
|
||||
.then(() => {
|
||||
// 第二步验证
|
||||
useHandleData(deleteItem, { sg_id: params.sg_id }, "确认删除该分组?")
|
||||
.then(() => {
|
||||
proTable.value?.getTableList(); // 操作成功后刷新表格
|
||||
})
|
||||
.catch(() => {
|
||||
// 第二步操作取消
|
||||
ElMessage({
|
||||
type: "info",
|
||||
message: "删除操作已取消"
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// 批量删除搜索分组信息
|
||||
const batchDelete = async (id: any[]) => {
|
||||
const ids = id.map((item: SearchGroupList.ResList) => item.sg_id);
|
||||
// await useHandleData(deleteItem, { sg_id: ids.join(",") }, "删除所选信息");
|
||||
// 第一步验证
|
||||
ElMessageBox.confirm(`是否删除该分组,如果删除则该分组下的所有搜索词也会被删除?`, "温馨提示", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
center: true,
|
||||
draggable: true,
|
||||
type: "warning"
|
||||
})
|
||||
|
||||
.then(() => {
|
||||
// 第二步验证
|
||||
useHandleData(deleteItem, { sg_id: ids.join(",") }, "确认删除该分组?")
|
||||
.then(() => {
|
||||
proTable.value?.clearSelection();
|
||||
proTable.value?.getTableList();
|
||||
})
|
||||
.catch(() => {
|
||||
// 第二步操作取消
|
||||
ElMessage({
|
||||
type: "info",
|
||||
message: "删除操作已取消"
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
// 打开 drawer(新增、编辑)
|
||||
const drawerRef = ref<InstanceType<typeof Drawer> | null>(null);
|
||||
|
||||
const openDrawer = (isEdit: boolean, row: Partial<SearchGroupList.ResList> = {}) => {
|
||||
const params = {
|
||||
title: isEdit ? "新增搜索分组" : "编辑搜索分组",
|
||||
row: { ...row },
|
||||
api: saveData,
|
||||
getTableList: proTable.value?.getTableList
|
||||
};
|
||||
drawerRef.value?.acceptParams(params as any);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.el-table .warning-row,
|
||||
.el-table .warning-row .el-table-fixed-column--right,
|
||||
.el-table .warning-row .el-table-fixed-column--left {
|
||||
background-color: var(--el-color-warning-light-9);
|
||||
<style scoped lang="scss">
|
||||
.module-unavailable {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
.el-table .success-row,
|
||||
.el-table .success-row .el-table-fixed-column--right,
|
||||
.el-table .success-row .el-table-fixed-column--left {
|
||||
background-color: var(--el-color-success-light-9);
|
||||
|
||||
.module-unavailable__card {
|
||||
color: var(--el-text-color-regular);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.module-unavailable__card p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
<template>
|
||||
<el-drawer v-model="drawerVisible" :destroy-on-close="true" size="520" :title="`${drawerProps.title}`">
|
||||
<el-form
|
||||
ref="ruleFormRef"
|
||||
label-width="60px"
|
||||
label-suffix=" :"
|
||||
:rules="rules"
|
||||
:disabled="drawerProps.isView"
|
||||
:model="drawerProps.row"
|
||||
:hide-required-asterisk="drawerProps.isView"
|
||||
>
|
||||
<el-form-item v-for="e in formColumns" :key="e.label" :label="e.label" :prop="e.prop" :required="e.required">
|
||||
<template v-if="e.type === 'switch'">
|
||||
<el-switch
|
||||
v-model="e.value"
|
||||
:active-value="`${1}`"
|
||||
:inactive-value="`${0}`"
|
||||
active-text="开启"
|
||||
inactive-text="关闭"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="e.type === 'radio'">
|
||||
<el-radio-group v-model="e.value" class="ml-4">
|
||||
<el-radio v-for="item in e.options" :key="item.value" :label="item.value" size="large">{{
|
||||
item.label
|
||||
}}</el-radio>
|
||||
</el-radio-group>
|
||||
</template>
|
||||
<template v-if="e.type === 'select'">
|
||||
<el-select filterable v-model="e.value" :placeholder="`请选择${e.label}`" clearable>
|
||||
<el-option
|
||||
v-for="(item, index) in e.options"
|
||||
:key="index"
|
||||
:label="getField(item, e.fieldNames?.label)"
|
||||
:value="getField(item, e.fieldNames?.value)"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
<template v-if="e.type === 'text'">
|
||||
<el-input v-model="e.value" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'text-num'">
|
||||
<el-input v-model="e.value" type="number" :placeholder="`请输入${e.label}`" clearable></el-input>
|
||||
</template>
|
||||
<template v-if="e.type === 'textarea'">
|
||||
<el-input
|
||||
type="textarea"
|
||||
v-model="e.value"
|
||||
maxlength="3650"
|
||||
:placeholder="`请输入${e.label}`"
|
||||
show-word-limit
|
||||
/>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="drawerVisible = false">取消</el-button>
|
||||
<el-button v-show="!drawerProps.isView" type="primary" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="UserDrawer">
|
||||
import { ref, computed } from "vue";
|
||||
import { ElMessage, FormInstance } from "element-plus";
|
||||
import { SearchKeyList } from "@/api/interface/searchKey/list";
|
||||
import { arrPlanSwitch } from "@/utils/serviceDict";
|
||||
type Options = {
|
||||
label: string;
|
||||
value: string | number;
|
||||
};
|
||||
|
||||
const getField = (item: any, fieldName: string | undefined) => {
|
||||
return fieldName ? item[fieldName] : "";
|
||||
};
|
||||
type Fields = {
|
||||
label: string;
|
||||
prop: string;
|
||||
type: string;
|
||||
value: any;
|
||||
required: boolean;
|
||||
options?: Options[];
|
||||
fieldNames?: { label: string; value: string };
|
||||
};
|
||||
const generateRules = (fields: Fields[]) => {
|
||||
return fields.reduce(
|
||||
(acc, field) => {
|
||||
const { type, required, label, prop } = field;
|
||||
let message = "";
|
||||
if (type === "select") {
|
||||
message = `请选择${label}`;
|
||||
}
|
||||
if (type === "text") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
if (type === "text-num") {
|
||||
message = `请输入${label}`;
|
||||
}
|
||||
acc[prop] = [{ required, message }];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>
|
||||
);
|
||||
};
|
||||
|
||||
const formColumns = computed((): Fields[] => {
|
||||
return [
|
||||
{
|
||||
label: "名称",
|
||||
prop: "sw_title",
|
||||
type: "text",
|
||||
get value() {
|
||||
return drawerProps.value.row!.sw_title;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.sw_title = val;
|
||||
},
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: "状态",
|
||||
prop: "sw_status",
|
||||
type: "radio",
|
||||
get value() {
|
||||
return drawerProps.value.row!.sw_status;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.sw_status = val;
|
||||
},
|
||||
options: arrPlanSwitch,
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: "地址",
|
||||
prop: "sw_html_path",
|
||||
type: "text",
|
||||
get value() {
|
||||
return drawerProps.value.row!.sw_html_path;
|
||||
},
|
||||
set value(val) {
|
||||
drawerProps.value.row!.sw_html_path = val;
|
||||
},
|
||||
required: false
|
||||
}
|
||||
];
|
||||
});
|
||||
const rules = ref();
|
||||
|
||||
interface DrawerProps {
|
||||
title: string;
|
||||
isView: boolean;
|
||||
row: Partial<SearchKeyList.ResList>;
|
||||
api?: (params: any) => Promise<any>;
|
||||
getTableList?: () => void;
|
||||
}
|
||||
|
||||
const drawerVisible = ref(false);
|
||||
const drawerProps = ref<DrawerProps>({
|
||||
isView: false,
|
||||
title: "",
|
||||
row: {}
|
||||
});
|
||||
|
||||
// 接收父组件传过来的参数
|
||||
const acceptParams = (params: DrawerProps) => {
|
||||
drawerProps.value = params;
|
||||
rules.value = generateRules(formColumns.value);
|
||||
drawerVisible.value = true;
|
||||
};
|
||||
|
||||
// 提交数据(新增/编辑)
|
||||
const ruleFormRef = ref<FormInstance>();
|
||||
const handleSubmit = () => {
|
||||
ruleFormRef.value!.validate(async valid => {
|
||||
if (!valid) return;
|
||||
try {
|
||||
const params = { ...drawerProps.value.row };
|
||||
if (drawerProps.value.title == "编辑SEO关键词") {
|
||||
// params.sw_status = String(drawerProps.value.row?.sw_status);
|
||||
if (drawerProps.value.row?.sw_html_path == null) {
|
||||
delete params.sw_html_path;
|
||||
}
|
||||
}
|
||||
|
||||
await drawerProps.value.api!(params);
|
||||
ElMessage.success({ message: `${drawerProps.value.title}成功!` });
|
||||
drawerProps.value.getTableList!();
|
||||
drawerVisible.value = false;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
acceptParams
|
||||
});
|
||||
</script>
|
||||
@@ -1,144 +1,31 @@
|
||||
<template>
|
||||
<div class="table-box">
|
||||
<ProTable
|
||||
title="缓存列表"
|
||||
ref="proTable"
|
||||
row-key="id"
|
||||
:indent="20"
|
||||
:columns="columns"
|
||||
:request-api="getTableList"
|
||||
:data-callback="dataCallback"
|
||||
:request-auto="true"
|
||||
:search-col="{ xs: 1, sm: 1, md: 2, lg: 3, xl: 3 }"
|
||||
>
|
||||
<!-- 表格 header 按钮 -->
|
||||
<template #tableHeader="scope">
|
||||
<el-button type="primary" :icon="CirclePlus" @click="openDrawer(true)">新增</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
plain
|
||||
:disabled="!scope.isSelected"
|
||||
@click="batchDelete(scope.selectedList)"
|
||||
>
|
||||
批量删除关键词
|
||||
</el-button>
|
||||
</template>
|
||||
<!-- 表格操作 -->
|
||||
<template #operation="scope">
|
||||
<el-button type="primary" :icon="EditPen" @click="openDrawer(false, scope.row)">编辑</el-button>
|
||||
<el-button type="danger" :icon="Delete" @click="deleteAccount(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</ProTable>
|
||||
<Drawer ref="drawerRef" />
|
||||
<div class="module-unavailable">
|
||||
<el-alert
|
||||
title="SEO 关键词模块未部署到当前 Linux 测试服"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<el-card class="module-unavailable__card" shadow="never">
|
||||
<p>当前后端没有可用的关键词管理接口,`/system/keyword/list` 实测会直接返回控制器不存在。</p>
|
||||
<p>本页先收为说明态,避免继续展示一套无法真实读写的关键词管理页。</p>
|
||||
<p>如果后续补齐关键词接口,再恢复列表、抽屉和批量删除操作。</p>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="tsx" name="complexProTable">
|
||||
import { reactive, ref } from "vue";
|
||||
import { SearchKeyList } from "@/api/interface/searchKey/list";
|
||||
import { useHandleData } from "@/hooks/useHandleData";
|
||||
import ProTable from "@/components/ProTable/index.vue";
|
||||
import { Delete, EditPen, CirclePlus } from "@element-plus/icons-vue";
|
||||
import { ProTableInstance, ColumnProps } from "@/components/ProTable/interface";
|
||||
import { getList, saveData, deleteItem } from "@/api/modules/searchKey/list";
|
||||
import Drawer from "./drawer.vue";
|
||||
import { arrPlanSwitch } from "@/utils/serviceDict";
|
||||
// ProTable 实例
|
||||
const proTable = ref<ProTableInstance>();
|
||||
const initParam = reactive({
|
||||
limit: 15,
|
||||
page: 1
|
||||
});
|
||||
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 getList(newParams);
|
||||
};
|
||||
|
||||
// 表格配置项
|
||||
const columns = reactive<ColumnProps<SearchKeyList.ResList>[]>([
|
||||
{ type: "selection", fixed: "left", width: 50 },
|
||||
{
|
||||
prop: "key",
|
||||
label: "搜索内容",
|
||||
isShow: false,
|
||||
search: {
|
||||
el: "input"
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: "sw_id",
|
||||
label: "ID",
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
prop: "sw_title",
|
||||
label: "名称"
|
||||
},
|
||||
{
|
||||
prop: "sw_status",
|
||||
label: "状态",
|
||||
tag: true,
|
||||
enum: arrPlanSwitch,
|
||||
render(scope) {
|
||||
return <el-tag type={scope.row.sw_status ? "success" : "info"}>{scope.row.sw_status == 1 ? "开启" : "关闭"}</el-tag>;
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: "sw_html_path",
|
||||
label: "地址"
|
||||
},
|
||||
{ prop: "operation", label: "操作", fixed: "right", width: 230 }
|
||||
]);
|
||||
|
||||
// 删除搜索词信息
|
||||
const deleteAccount = async (params: SearchKeyList.ResList) => {
|
||||
await useHandleData(deleteItem, { sw_id: String(params.sw_id) }, "删除所选信息");
|
||||
proTable.value?.getTableList();
|
||||
};
|
||||
|
||||
// 批量删除搜索词信息
|
||||
const batchDelete = async (id: any[]) => {
|
||||
const ids = id.map((item: SearchKeyList.ResList) => item.sw_id);
|
||||
await useHandleData(deleteItem, { sw_id: ids.join(",") }, "删除所选信息");
|
||||
proTable.value?.clearSelection();
|
||||
proTable.value?.getTableList();
|
||||
};
|
||||
// 打开 drawer(新增、编辑)
|
||||
const drawerRef = ref<InstanceType<typeof Drawer> | null>(null);
|
||||
|
||||
const openDrawer = (isEdit: boolean, row: Partial<SearchKeyList.ResList> = {}) => {
|
||||
const params = {
|
||||
title: isEdit ? "新增SEO关键词" : "编辑SEO关键词",
|
||||
row: { ...row },
|
||||
api: saveData,
|
||||
getTableList: proTable.value?.getTableList
|
||||
};
|
||||
drawerRef.value?.acceptParams(params as any);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.el-table .warning-row,
|
||||
.el-table .warning-row .el-table-fixed-column--right,
|
||||
.el-table .warning-row .el-table-fixed-column--left {
|
||||
background-color: var(--el-color-warning-light-9);
|
||||
<style scoped lang="scss">
|
||||
.module-unavailable {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
.el-table .success-row,
|
||||
.el-table .success-row .el-table-fixed-column--right,
|
||||
.el-table .success-row .el-table-fixed-column--left {
|
||||
background-color: var(--el-color-success-light-9);
|
||||
|
||||
.module-unavailable__card {
|
||||
color: var(--el-text-color-regular);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.module-unavailable__card p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
109
src/views/site/batchStrategyDialog.vue
Normal file
109
src/views/site/batchStrategyDialog.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<el-drawer v-model="drawerVisible" :destroy-on-close="true" size="720" :title="drawerProps.title">
|
||||
<el-form
|
||||
ref="ruleFormRef"
|
||||
label-width="150px"
|
||||
label-suffix=" :"
|
||||
:rules="rules"
|
||||
:disabled="drawerProps.isView"
|
||||
:model="drawerProps.row"
|
||||
:hide-required-asterisk="drawerProps.isView"
|
||||
>
|
||||
<el-form-item label="策略包" prop="strategy_profile" required>
|
||||
<el-select v-model="drawerProps.row.strategy_profile" placeholder="请选择要批量套用的策略包">
|
||||
<el-option label="标准 standard" value="standard" />
|
||||
<el-option label="流量 traffic" value="traffic" />
|
||||
<el-option label="扩张 expand" value="expand" />
|
||||
<el-option label="封闭 closed" value="closed" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="选中站点" prop="domains" required>
|
||||
<el-input
|
||||
v-model="drawerProps.row.domains"
|
||||
type="textarea"
|
||||
:rows="12"
|
||||
maxlength="365000"
|
||||
placeholder="请选择至少一个站点"
|
||||
show-word-limit
|
||||
disabled
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="drawerVisible = false">取消</el-button>
|
||||
<el-button v-show="!drawerProps.isView" type="primary" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="SiteBatchStrategyDrawer">
|
||||
import { reactive, ref } from "vue";
|
||||
import { ElMessage, FormInstance } from "element-plus";
|
||||
|
||||
type BatchStrategyRow = {
|
||||
strategy_profile: "standard" | "traffic" | "expand" | "closed";
|
||||
d_ids: number[];
|
||||
domains: string;
|
||||
};
|
||||
|
||||
const rules = reactive({
|
||||
strategy_profile: [{ required: true, message: "请选择策略包" }],
|
||||
domains: [{ required: true, message: "请先选择至少一个站点" }]
|
||||
});
|
||||
|
||||
interface DrawerProps {
|
||||
title: string;
|
||||
isView: boolean;
|
||||
row: BatchStrategyRow;
|
||||
api?: (params: any) => Promise<any>;
|
||||
getTableList?: () => void;
|
||||
successMessage?: string;
|
||||
}
|
||||
|
||||
const drawerVisible = ref(false);
|
||||
const drawerProps = ref<DrawerProps>({
|
||||
isView: false,
|
||||
title: "",
|
||||
row: {
|
||||
strategy_profile: "standard",
|
||||
d_ids: [],
|
||||
domains: ""
|
||||
}
|
||||
});
|
||||
|
||||
const acceptParams = (params: DrawerProps) => {
|
||||
drawerProps.value = {
|
||||
...params,
|
||||
row: {
|
||||
strategy_profile: params.row.strategy_profile ?? "standard",
|
||||
d_ids: [...(params.row.d_ids ?? [])],
|
||||
domains: params.row.domains ?? ""
|
||||
}
|
||||
};
|
||||
drawerVisible.value = true;
|
||||
};
|
||||
|
||||
const ruleFormRef = ref<FormInstance>();
|
||||
const handleSubmit = () => {
|
||||
ruleFormRef.value!.validate(async valid => {
|
||||
if (!valid) return;
|
||||
try {
|
||||
await drawerProps.value.api!({
|
||||
d_ids: drawerProps.value.row.d_ids,
|
||||
strategy_profile: drawerProps.value.row.strategy_profile
|
||||
});
|
||||
ElMessage.success({
|
||||
message: drawerProps.value.successMessage || `${drawerProps.value.title}完成,已刷新站点列表`
|
||||
});
|
||||
drawerProps.value.getTableList?.();
|
||||
drawerVisible.value = false;
|
||||
} catch (_error) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
acceptParams
|
||||
});
|
||||
</script>
|
||||
4038
src/views/site/bootstrapAdminCenter.vue
Normal file
4038
src/views/site/bootstrapAdminCenter.vue
Normal file
File diff suppressed because it is too large
Load Diff
2629
src/views/site/bootstrapEnvDialog.vue
Normal file
2629
src/views/site/bootstrapEnvDialog.vue
Normal file
File diff suppressed because it is too large
Load Diff
1111
src/views/site/bootstrapEnvOverview.vue
Normal file
1111
src/views/site/bootstrapEnvOverview.vue
Normal file
File diff suppressed because it is too large
Load Diff
2938
src/views/site/bootstrapOpsOverview.vue
Normal file
2938
src/views/site/bootstrapOpsOverview.vue
Normal file
File diff suppressed because it is too large
Load Diff
121
src/views/site/domainExternalSeoAizhanKeywordAttentionDialog.vue
Normal file
121
src/views/site/domainExternalSeoAizhanKeywordAttentionDialog.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="爱站关键词异常队列" width="1200px" draggable>
|
||||
<div class="aizhan-keyword-attention-dialog">
|
||||
<div class="aizhan-keyword-attention-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadQueue">刷新</el-button>
|
||||
<el-button v-if="queue.summary_html_path" type="success" plain @click="openPublicPath(queue.summary_html_path)">HTML摘要</el-button>
|
||||
<el-button v-if="queue.summary_json_path" type="info" plain @click="openPublicPath(queue.summary_json_path)">JSON摘要</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
:title="queue.queue_count ? `当前有 ${queue.queue_count} 个域名需要关注` : '当前爱站关键词面没有高优先异常'"
|
||||
:description="queue.note || '这里优先收爱站结果面里的验证码、阻塞、0词、0来路问题。'"
|
||||
:type="queue.queue_count ? 'warning' : 'success'"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<div class="aizhan-keyword-attention-dialog__metrics">
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">queue</div>
|
||||
<div class="metric-card__value">{{ queue.queue_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">ready</div>
|
||||
<div class="metric-card__value">{{ queue.ready_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">captcha</div>
|
||||
<div class="metric-card__value">{{ queue.captcha_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">blocked</div>
|
||||
<div class="metric-card__value">{{ queue.blocked_count || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="queue.items || []" border max-height="460">
|
||||
<el-table-column prop="host" label="Host" min-width="170" />
|
||||
<el-table-column prop="attention_level" label="级别" width="90" />
|
||||
<el-table-column label="原因" min-width="220">
|
||||
<template #default="{ row }">{{ (row.reason_codes || []).join(", ") || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="baidu_pc_ip_range" label="百度来路" width="120" />
|
||||
<el-table-column prop="baidu_mobile_ip_range" label="移动来路" width="120" />
|
||||
<el-table-column prop="pc_keyword_count" label="PC词数" width="90" />
|
||||
<el-table-column prop="mobile_keyword_count" label="移动词数" width="90" />
|
||||
<el-table-column prop="recommended_action" label="建议动作" min-width="320" />
|
||||
</el-table>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainExternalSeoAizhanKeywordAttentionQueue } from "@/api/modules/site/list";
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL as string;
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const queue = ref<SiteList.DomainExternalSeoAizhanKeywordAttentionQueueData>({});
|
||||
|
||||
const openPublicPath = (path?: string) => {
|
||||
if (!path) return;
|
||||
window.open(`${BASE_URL}/${String(path).replace(/^\/+/, "")}`, "_blank", "noopener");
|
||||
};
|
||||
|
||||
const loadQueue = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDomainExternalSeoAizhanKeywordAttentionQueue({ limit: 100 });
|
||||
queue.value = res.data ?? {};
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取爱站关键词异常队列失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadQueue();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.aizhan-keyword-attention-dialog__toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.aizhan-keyword-attention-dialog__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.metric-card__label {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.metric-card__value {
|
||||
color: #303133;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
159
src/views/site/domainExternalSeoAizhanKeywordDialog.vue
Normal file
159
src/views/site/domainExternalSeoAizhanKeywordDialog.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="爱站关键词摘要" width="1220px" draggable>
|
||||
<div class="aizhan-keyword-dialog">
|
||||
<div class="aizhan-keyword-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadSummary">刷新</el-button>
|
||||
<el-button type="warning" plain @click="openAttentionDialog">异常队列</el-button>
|
||||
<el-button type="info" plain @click="openTrendDialog">趋势摘要</el-button>
|
||||
<el-button v-if="summary.summary_html_path" type="success" plain @click="openPublicPath(summary.summary_html_path)">HTML摘要</el-button>
|
||||
<el-button v-if="summary.summary_json_path" type="info" plain @click="openPublicPath(summary.summary_json_path)">JSON摘要</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
:title="alertTitle"
|
||||
:description="summary.note || '这里优先沉淀爱站综合查询页的轻量关键词结果面:百度来路区间、PC/移动关键词数,以及对应排名页入口。'"
|
||||
:type="alertType"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<div class="aizhan-keyword-dialog__metrics">
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">Hosts</div>
|
||||
<div class="metric-card__value">{{ summary.host_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">Ready</div>
|
||||
<div class="metric-card__value">{{ summary.status_buckets?.ready || 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">Captcha</div>
|
||||
<div class="metric-card__value">{{ summary.status_buckets?.captcha || 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">Blocked</div>
|
||||
<div class="metric-card__value">{{ summary.status_buckets?.blocked || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="summary.items || []" border max-height="480">
|
||||
<el-table-column prop="host" label="Host" min-width="170" />
|
||||
<el-table-column prop="status" label="状态" width="100" />
|
||||
<el-table-column prop="site_title" label="站点标题" min-width="160" />
|
||||
<el-table-column prop="baidu_pc_ip_range" label="百度来路" width="120" />
|
||||
<el-table-column prop="baidu_mobile_ip_range" label="移动来路" width="120" />
|
||||
<el-table-column prop="pc_keyword_count" label="PC词数" width="90" />
|
||||
<el-table-column prop="mobile_keyword_count" label="移动词数" width="90" />
|
||||
<el-table-column label="PC排名页" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-link v-if="row.pc_rank_url" :href="row.pc_rank_url" target="_blank" type="primary">打开</el-link>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="移动排名页" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-link v-if="row.mobile_rank_url" :href="row.mobile_rank_url" target="_blank" type="primary">打开</el-link>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="probed_at" label="探测时间" min-width="180" />
|
||||
</el-table>
|
||||
</div>
|
||||
</el-dialog>
|
||||
<DomainExternalSeoAizhanKeywordAttentionDialog ref="attentionDialogRef" />
|
||||
<DomainExternalSeoAizhanKeywordTrendDialog ref="trendDialogRef" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainExternalSeoAizhanKeywordProbeSummary } from "@/api/modules/site/list";
|
||||
import DomainExternalSeoAizhanKeywordAttentionDialog from "./domainExternalSeoAizhanKeywordAttentionDialog.vue";
|
||||
import DomainExternalSeoAizhanKeywordTrendDialog from "./domainExternalSeoAizhanKeywordTrendDialog.vue";
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL as string;
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const summary = ref<SiteList.DomainExternalSeoAizhanKeywordProbeData>({});
|
||||
const attentionDialogRef = ref();
|
||||
const trendDialogRef = ref();
|
||||
|
||||
const openPublicPath = (path?: string) => {
|
||||
if (!path) return;
|
||||
window.open(`${BASE_URL}/${String(path).replace(/^\/+/, "")}`, "_blank", "noopener");
|
||||
};
|
||||
|
||||
const alertTitle = computed(() => {
|
||||
if ((summary.value.status_buckets?.ready || 0) > 0) return "当前已拿到爱站关键词摘要";
|
||||
if ((summary.value.status_buckets?.captcha || 0) > 0) return "当前爱站探测有部分命中验证";
|
||||
return "当前爱站关键词摘要还在准备态";
|
||||
});
|
||||
|
||||
const alertType = computed(() => {
|
||||
if ((summary.value.status_buckets?.ready || 0) > 0) return "success";
|
||||
if ((summary.value.status_buckets?.captcha || 0) > 0 || (summary.value.status_buckets?.blocked || 0) > 0) return "warning";
|
||||
return "info";
|
||||
});
|
||||
|
||||
const loadSummary = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDomainExternalSeoAizhanKeywordProbeSummary({ limit: 50 });
|
||||
summary.value = res.data ?? {};
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取爱站关键词摘要失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openAttentionDialog = () => {
|
||||
attentionDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const openTrendDialog = () => {
|
||||
trendDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadSummary();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.aizhan-keyword-dialog__toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.aizhan-keyword-dialog__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.metric-card__label {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.metric-card__value {
|
||||
color: #303133;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
133
src/views/site/domainExternalSeoAizhanKeywordTrendDialog.vue
Normal file
133
src/views/site/domainExternalSeoAizhanKeywordTrendDialog.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="爱站关键词趋势摘要" width="1160px" draggable>
|
||||
<div class="aizhan-keyword-trend-dialog">
|
||||
<div class="aizhan-keyword-trend-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadSummary">刷新</el-button>
|
||||
<el-button v-if="summary.summary_html_path" type="success" plain @click="openPublicPath(summary.summary_html_path)">HTML摘要</el-button>
|
||||
<el-button v-if="summary.summary_json_path" type="info" plain @click="openPublicPath(summary.summary_json_path)">JSON摘要</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
:title="`当前趋势:${summary.health_label || 'unknown'}`"
|
||||
:description="summary.note || '这里展示爱站关键词面的轻量趋势判断。'"
|
||||
:type="alertType"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<div class="aizhan-keyword-trend-dialog__metrics">
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">ready</div>
|
||||
<div class="metric-card__value">{{ summary.ready_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">queue</div>
|
||||
<div class="metric-card__value">{{ summary.queue_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">keyword_ready</div>
|
||||
<div class="metric-card__value">{{ summary.keyword_ready_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">traffic_ready</div>
|
||||
<div class="metric-card__value">{{ summary.traffic_ready_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">zero_keyword</div>
|
||||
<div class="metric-card__value">{{ summary.zero_keyword_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">zero_traffic</div>
|
||||
<div class="metric-card__value">{{ summary.zero_traffic_count || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="summary.ready_items || []" border max-height="420">
|
||||
<el-table-column prop="host" label="Host" min-width="170" />
|
||||
<el-table-column prop="baidu_pc_ip_range" label="百度来路" width="120" />
|
||||
<el-table-column prop="baidu_mobile_ip_range" label="移动来路" width="120" />
|
||||
<el-table-column prop="pc_keyword_count" label="PC词数" width="90" />
|
||||
<el-table-column prop="mobile_keyword_count" label="移动词数" width="90" />
|
||||
<el-table-column prop="probed_at" label="探测时间" min-width="180" />
|
||||
</el-table>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainExternalSeoAizhanKeywordTrendSummary } from "@/api/modules/site/list";
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL as string;
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const summary = ref<SiteList.DomainExternalSeoAizhanKeywordTrendData>({});
|
||||
|
||||
const alertType = computed(() => {
|
||||
const label = summary.value.health_label || "";
|
||||
if (label === "active") return "success";
|
||||
if (label === "warmup") return "warning";
|
||||
if (label === "blocked") return "warning";
|
||||
return "info";
|
||||
});
|
||||
|
||||
const openPublicPath = (path?: string) => {
|
||||
if (!path) return;
|
||||
window.open(`${BASE_URL}/${String(path).replace(/^\/+/, "")}`, "_blank", "noopener");
|
||||
};
|
||||
|
||||
const loadSummary = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDomainExternalSeoAizhanKeywordTrendSummary({ limit: 20 });
|
||||
summary.value = res.data ?? {};
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取爱站关键词趋势摘要失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadSummary();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.aizhan-keyword-trend-dialog__toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.aizhan-keyword-trend-dialog__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.metric-card__label {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.metric-card__value {
|
||||
color: #303133;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
131
src/views/site/domainExternalSeoBaiduFeedbackDialog.vue
Normal file
131
src/views/site/domainExternalSeoBaiduFeedbackDialog.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="百度反馈摘要" width="1100px" draggable>
|
||||
<div class="baidu-feedback-dialog">
|
||||
<div class="baidu-feedback-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadSummary">刷新</el-button>
|
||||
<el-button v-if="summary.summary_html_path" type="success" plain @click="openPublicPath(summary.summary_html_path)">HTML摘要</el-button>
|
||||
<el-button v-if="summary.summary_json_path" type="info" plain @click="openPublicPath(summary.summary_json_path)">JSON摘要</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
:title="summary.status === 'ready' ? '当前已有百度反馈数据' : '当前还没有百度反馈数据'"
|
||||
:description="summary.note || '这里展示百度相关的结果面摘要。'"
|
||||
:type="summary.status === 'ready' ? 'success' : 'warning'"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<div class="baidu-feedback-dialog__metrics">
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">hosts</div>
|
||||
<div class="metric-card__value">{{ summary.hosts_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">rows</div>
|
||||
<div class="metric-card__value">{{ summary.rows_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">impressions</div>
|
||||
<div class="metric-card__value">{{ summary.metrics?.impressions || 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">clicks</div>
|
||||
<div class="metric-card__value">{{ summary.metrics?.clicks || 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">ctr</div>
|
||||
<div class="metric-card__value">{{ summary.metrics?.ctr || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="providers">
|
||||
{{ (summary.providers || []).join(", ") || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="avg_position">
|
||||
{{ summary.metrics?.avg_position ?? "-" }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-table v-loading="loading" :data="summary.host_rows || []" border max-height="420">
|
||||
<el-table-column prop="host" label="Host" min-width="180" />
|
||||
<el-table-column prop="provider" label="Provider" min-width="160" />
|
||||
<el-table-column prop="rows_count" label="Rows" width="90" />
|
||||
<el-table-column prop="impressions" label="Impressions" width="110" />
|
||||
<el-table-column prop="clicks" label="Clicks" width="90" />
|
||||
<el-table-column prop="ctr" label="CTR" width="90" />
|
||||
<el-table-column prop="avg_position" label="Avg Position" width="110" />
|
||||
</el-table>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainExternalSeoBaiduFeedbackSummary } from "@/api/modules/site/list";
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL as string;
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const summary = ref<SiteList.DomainExternalSeoBaiduFeedbackSummaryData>({});
|
||||
|
||||
const openPublicPath = (path?: string) => {
|
||||
if (!path) return;
|
||||
window.open(`${BASE_URL}/${String(path).replace(/^\/+/, "")}`, "_blank", "noopener");
|
||||
};
|
||||
|
||||
const loadSummary = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDomainExternalSeoBaiduFeedbackSummary({ limit: 20 });
|
||||
summary.value = res.data ?? {};
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取百度反馈摘要失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadSummary();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.baidu-feedback-dialog__toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.baidu-feedback-dialog__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.metric-card__label {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.metric-card__value {
|
||||
color: #303133;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
172
src/views/site/domainExternalSeoBaiduPlanDialog.vue
Normal file
172
src/views/site/domainExternalSeoBaiduPlanDialog.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="百度 Provider 计划" width="920px" draggable>
|
||||
<div class="baidu-plan-dialog">
|
||||
<div class="baidu-plan-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadSummary">刷新</el-button>
|
||||
<el-button type="warning" plain @click="openPushSummaryDialog">百度推送观察</el-button>
|
||||
<el-button type="success" plain @click="openSiteQueryDialog">百度收录探测</el-button>
|
||||
<el-button type="primary" plain @click="openFeedbackDialog">百度反馈摘要</el-button>
|
||||
<el-button type="success" plain @click="openPushTaskDialog">百度推送任务</el-button>
|
||||
<el-button type="danger" plain @click="openPushAttentionDialog">百度推送异常队列</el-button>
|
||||
<el-button type="info" plain @click="openPushTrendDialog">百度推送趋势</el-button>
|
||||
<el-button v-if="summary.summary_html_path" type="success" plain @click="openPublicPath(summary.summary_html_path)">HTML摘要</el-button>
|
||||
<el-button v-if="summary.summary_json_path" type="info" plain @click="openPublicPath(summary.summary_json_path)">JSON摘要</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
:title="summary.status || 'planned'"
|
||||
:description="summary.note || '当前这里展示百度 provider 的准备态。'"
|
||||
:type="summary.credential_ready ? 'success' : 'warning'"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<div class="baidu-plan-dialog__metrics">
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">凭证</div>
|
||||
<div class="metric-card__value">{{ summary.credential_ready ? "ready" : "missing" }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">文件大小</div>
|
||||
<div class="metric-card__value">{{ summary.credential_file_size || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="credential_path">{{ summary.credential_path || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="updated_at">{{ summary.credential_updated_at || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="submission_scope">
|
||||
{{ (summary.submission_scope?.capabilities || []).join(", ") || "-" }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="baidu-plan-dialog__steps">
|
||||
<div class="baidu-plan-dialog__title">下一步</div>
|
||||
<ul>
|
||||
<li v-for="(step, index) in summary.next_steps || []" :key="`${index}-${step}`">{{ step }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
<DomainExternalSeoBaiduPushDialog ref="pushDialogRef" />
|
||||
<DomainExternalSeoBaiduFeedbackDialog ref="feedbackDialogRef" />
|
||||
<DomainExternalSeoSiteQueryDialog ref="siteQueryDialogRef" />
|
||||
<DomainExternalSeoBaiduPushTaskDialog ref="taskDialogRef" />
|
||||
<DomainExternalSeoBaiduPushAttentionDialog ref="attentionDialogRef" />
|
||||
<DomainExternalSeoBaiduPushTrendDialog ref="trendDialogRef" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainExternalSeoBaiduProviderPlan } from "@/api/modules/site/list";
|
||||
import DomainExternalSeoBaiduPushDialog from "./domainExternalSeoBaiduPushDialog.vue";
|
||||
import DomainExternalSeoBaiduFeedbackDialog from "./domainExternalSeoBaiduFeedbackDialog.vue";
|
||||
import DomainExternalSeoSiteQueryDialog from "./domainExternalSeoSiteQueryDialog.vue";
|
||||
import DomainExternalSeoBaiduPushTaskDialog from "./domainExternalSeoBaiduPushTaskDialog.vue";
|
||||
import DomainExternalSeoBaiduPushAttentionDialog from "./domainExternalSeoBaiduPushAttentionDialog.vue";
|
||||
import DomainExternalSeoBaiduPushTrendDialog from "./domainExternalSeoBaiduPushTrendDialog.vue";
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL as string;
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const summary = ref<SiteList.DomainExternalSeoBaiduProviderPlanData>({});
|
||||
const pushDialogRef = ref();
|
||||
const feedbackDialogRef = ref();
|
||||
const siteQueryDialogRef = ref();
|
||||
const taskDialogRef = ref();
|
||||
const attentionDialogRef = ref();
|
||||
const trendDialogRef = ref();
|
||||
|
||||
const openPublicPath = (path?: string) => {
|
||||
if (!path) return;
|
||||
window.open(`${BASE_URL}/${String(path).replace(/^\/+/, "")}`, "_blank", "noopener");
|
||||
};
|
||||
|
||||
const openPushSummaryDialog = () => {
|
||||
pushDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const openFeedbackDialog = () => {
|
||||
feedbackDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const openSiteQueryDialog = () => {
|
||||
siteQueryDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const openPushTaskDialog = () => {
|
||||
taskDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const openPushAttentionDialog = () => {
|
||||
attentionDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const openPushTrendDialog = () => {
|
||||
trendDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const loadSummary = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDomainExternalSeoBaiduProviderPlan();
|
||||
summary.value = res.data ?? {};
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取百度 provider 计划失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadSummary();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.baidu-plan-dialog__toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.baidu-plan-dialog__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.metric-card__label {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.metric-card__value {
|
||||
color: #303133;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.baidu-plan-dialog__steps {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.baidu-plan-dialog__title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
138
src/views/site/domainExternalSeoBaiduPushAttentionDialog.vue
Normal file
138
src/views/site/domainExternalSeoBaiduPushAttentionDialog.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="百度推送异常队列" width="1180px" draggable>
|
||||
<div class="baidu-push-attention-dialog">
|
||||
<div class="baidu-push-attention-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadQueue">刷新</el-button>
|
||||
<el-button v-if="queue.summary_html_path" type="success" plain @click="openPublicPath(queue.summary_html_path)">HTML摘要</el-button>
|
||||
<el-button v-if="queue.summary_json_path" type="info" plain @click="openPublicPath(queue.summary_json_path)">JSON摘要</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
:title="queue.queue_count ? `当前有 ${queue.queue_count} 个域名需要关注` : '当前没有域名级百度推送异常'"
|
||||
:description="queue.note || '这里会收百度推送链里需要优先处理的配置和进度问题。'"
|
||||
:type="queue.queue_count ? 'warning' : 'success'"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<div class="baidu-push-attention-dialog__metrics">
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">域名异常数</div>
|
||||
<div class="metric-card__value">{{ queue.queue_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">系统异常数</div>
|
||||
<div class="metric-card__value">{{ queue.system_attention_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">任务启用</div>
|
||||
<div class="metric-card__value">{{ queue.task_enabled ? "yes" : "no" }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">DB 状态</div>
|
||||
<div class="metric-card__value">{{ queue.db_ready ? "ready" : "blocked" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table v-if="(queue.system_items || []).length" :data="queue.system_items || []" border max-height="220" class="baidu-push-attention-dialog__table">
|
||||
<el-table-column prop="attention_level" label="系统级别" width="100" />
|
||||
<el-table-column label="原因" min-width="220">
|
||||
<template #default="{ row }">{{ (row.reason_codes || []).join(", ") || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="reason_summary" label="说明" min-width="300" />
|
||||
<el-table-column prop="recommended_action" label="建议动作" min-width="320" />
|
||||
</el-table>
|
||||
|
||||
<el-table v-loading="loading" :data="queue.items || []" border max-height="420">
|
||||
<el-table-column prop="domain" label="Domain" min-width="180" />
|
||||
<el-table-column prop="attention_level" label="级别" width="90" />
|
||||
<el-table-column label="原因" min-width="220">
|
||||
<template #default="{ row }">{{ (row.reason_codes || []).join(", ") || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Token" width="120">
|
||||
<template #default="{ row }">{{ row.token_ready ? row.token_masked || "yes" : "no" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="State" width="90">
|
||||
<template #default="{ row }">{{ row.state_ready ? "yes" : "no" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="txt_file_count" label="txt 文件" width="90" />
|
||||
<el-table-column prop="latest_txt_file" label="最新 txt" min-width="180" />
|
||||
<el-table-column prop="recommended_action" label="建议动作" min-width="320" />
|
||||
</el-table>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainExternalSeoBaiduPushAttentionQueue } from "@/api/modules/site/list";
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL as string;
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const queue = ref<SiteList.DomainExternalSeoBaiduPushAttentionQueueData>({});
|
||||
|
||||
const openPublicPath = (path?: string) => {
|
||||
if (!path) return;
|
||||
window.open(`${BASE_URL}/${String(path).replace(/^\/+/, "")}`, "_blank", "noopener");
|
||||
};
|
||||
|
||||
const loadQueue = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDomainExternalSeoBaiduPushAttentionQueue({ limit: 100 });
|
||||
queue.value = res.data ?? {};
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取百度推送异常队列失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadQueue();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.baidu-push-attention-dialog__toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.baidu-push-attention-dialog__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.metric-card__label {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.metric-card__value {
|
||||
color: #303133;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.baidu-push-attention-dialog__table {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
154
src/views/site/domainExternalSeoBaiduPushDialog.vue
Normal file
154
src/views/site/domainExternalSeoBaiduPushDialog.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="百度推送观察摘要" width="1100px" draggable>
|
||||
<div class="baidu-push-dialog">
|
||||
<div class="baidu-push-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadSummary">刷新</el-button>
|
||||
<el-button type="success" plain @click="openTaskDialog">任务快捷管理</el-button>
|
||||
<el-button type="warning" plain @click="openAttentionDialog">异常队列</el-button>
|
||||
<el-button type="info" plain @click="openTrendDialog">趋势摘要</el-button>
|
||||
<el-button v-if="summary.summary_html_path" type="success" plain @click="openPublicPath(summary.summary_html_path)">HTML摘要</el-button>
|
||||
<el-button v-if="summary.summary_json_path" type="info" plain @click="openPublicPath(summary.summary_json_path)">JSON摘要</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
:title="summary.task_enabled ? '百度推送任务已启用' : '百度推送任务当前未启用'"
|
||||
:description="summary.note || '当前这里展示百度推送执行链的观察摘要。'"
|
||||
:type="summary.task_enabled ? 'success' : 'warning'"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<div class="baidu-push-dialog__metrics">
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">任务启用</div>
|
||||
<div class="metric-card__value">{{ summary.task_enabled ? "yes" : "no" }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">域名数</div>
|
||||
<div class="metric-card__value">{{ summary.domain_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">Token 就绪</div>
|
||||
<div class="metric-card__value">{{ summary.token_ready_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">State 就绪</div>
|
||||
<div class="metric-card__value">{{ summary.state_ready_count || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="task_last_exec">{{ summary.task_last_exec || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="db_status">
|
||||
{{ summary.db_ready ? "ready" : `blocked: ${summary.db_error || "-"}` }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-table v-loading="loading" :data="summary.items || []" border max-height="420">
|
||||
<el-table-column prop="domain" label="Domain" min-width="180" />
|
||||
<el-table-column label="Token" width="120">
|
||||
<template #default="{ row }">{{ row.token_ready ? row.token_masked || "yes" : "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="State" width="90">
|
||||
<template #default="{ row }">{{ row.state_ready ? "yes" : "no" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="txt_file_count" label="txt 文件" width="90" />
|
||||
<el-table-column prop="latest_txt_file" label="最新 txt" min-width="180" />
|
||||
<el-table-column prop="file_index" label="file_index" width="100" />
|
||||
<el-table-column prop="line_index" label="line_index" width="100" />
|
||||
<el-table-column prop="updated_at" label="进度更新时间" min-width="180" />
|
||||
</el-table>
|
||||
</div>
|
||||
</el-dialog>
|
||||
<DomainExternalSeoBaiduPushTaskDialog ref="taskDialogRef" />
|
||||
<DomainExternalSeoBaiduPushAttentionDialog ref="attentionDialogRef" />
|
||||
<DomainExternalSeoBaiduPushTrendDialog ref="trendDialogRef" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainExternalSeoBaiduPushSummary } from "@/api/modules/site/list";
|
||||
import DomainExternalSeoBaiduPushTaskDialog from "./domainExternalSeoBaiduPushTaskDialog.vue";
|
||||
import DomainExternalSeoBaiduPushAttentionDialog from "./domainExternalSeoBaiduPushAttentionDialog.vue";
|
||||
import DomainExternalSeoBaiduPushTrendDialog from "./domainExternalSeoBaiduPushTrendDialog.vue";
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL as string;
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const summary = ref<SiteList.DomainExternalSeoBaiduPushSummaryData>({});
|
||||
const taskDialogRef = ref();
|
||||
const attentionDialogRef = ref();
|
||||
const trendDialogRef = ref();
|
||||
|
||||
const openPublicPath = (path?: string) => {
|
||||
if (!path) return;
|
||||
window.open(`${BASE_URL}/${String(path).replace(/^\/+/, "")}`, "_blank", "noopener");
|
||||
};
|
||||
|
||||
const loadSummary = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDomainExternalSeoBaiduPushSummary({ limit: 50 });
|
||||
summary.value = res.data ?? {};
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取百度推送观察摘要失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openAttentionDialog = () => {
|
||||
attentionDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const openTrendDialog = () => {
|
||||
trendDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const openTaskDialog = () => {
|
||||
taskDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadSummary();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.baidu-push-dialog__toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.baidu-push-dialog__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.metric-card__label {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.metric-card__value {
|
||||
color: #303133;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
101
src/views/site/domainExternalSeoBaiduPushTaskDialog.vue
Normal file
101
src/views/site/domainExternalSeoBaiduPushTaskDialog.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="百度推送任务快捷管理" width="760px" draggable>
|
||||
<div class="baidu-push-task-dialog">
|
||||
<div class="baidu-push-task-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadStatus">刷新</el-button>
|
||||
<el-button type="success" plain :disabled="saving || !status.task_exists" @click="saveStatus(1)">启用任务</el-button>
|
||||
<el-button type="warning" plain :disabled="saving || !status.task_exists" @click="saveStatus(0)">关闭任务</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
:title="`当前状态:${status.status_label || 'unknown'}`"
|
||||
:description="status.note || '这里直接复用 PlanTask 的 PUSH_BAIDU_VIDEO_URL。'"
|
||||
:type="status.pt_enable === 1 ? 'success' : 'warning'"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<el-descriptions :column="1" border size="small" class="baidu-push-task-dialog__desc">
|
||||
<el-descriptions-item label="task_code">{{ status.task_code || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="pt_name">{{ status.pt_name || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="pt_enable">{{ status.pt_enable === 1 ? "1 / enabled" : "0 / disabled" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="pt_limit">{{ form.pt_limit }}</el-descriptions-item>
|
||||
<el-descriptions-item label="pt_last_exec">{{ status.pt_last_exec || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="db_status">
|
||||
{{ status.db_ready ? "ready" : `blocked: ${status.db_error || "-"}` }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-form label-width="120px" class="baidu-push-task-dialog__form">
|
||||
<el-form-item label="执行频率(秒)">
|
||||
<el-input-number v-model="form.pt_limit" :min="0" :step="60" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="saving" :disabled="!status.task_exists" @click="saveStatus(status.pt_enable === 1 ? 1 : 0)">
|
||||
保存当前频率
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainExternalSeoBaiduPushTaskStatus, saveDomainExternalSeoBaiduPushTaskStatus } from "@/api/modules/site/list";
|
||||
|
||||
const visible = ref(false);
|
||||
const saving = ref(false);
|
||||
const status = ref<SiteList.DomainExternalSeoBaiduPushTaskStatusData>({});
|
||||
const form = ref({
|
||||
pt_limit: 0
|
||||
});
|
||||
|
||||
const loadStatus = async () => {
|
||||
try {
|
||||
const res = await getDomainExternalSeoBaiduPushTaskStatus();
|
||||
status.value = res.data ?? {};
|
||||
form.value.pt_limit = Number(status.value.pt_limit || 0);
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取百度推送任务状态失败");
|
||||
}
|
||||
};
|
||||
|
||||
const saveStatus = async (ptEnable: number) => {
|
||||
try {
|
||||
saving.value = true;
|
||||
const res = await saveDomainExternalSeoBaiduPushTaskStatus({
|
||||
pt_enable: ptEnable,
|
||||
pt_limit: Number(form.value.pt_limit || 0)
|
||||
});
|
||||
status.value = res.data ?? {};
|
||||
form.value.pt_limit = Number(status.value.pt_limit || 0);
|
||||
ElMessage.success("百度推送任务状态已更新");
|
||||
} catch (_error) {
|
||||
ElMessage.error("保存百度推送任务状态失败");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadStatus();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.baidu-push-task-dialog__toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.baidu-push-task-dialog__desc {
|
||||
margin: 16px 0;
|
||||
}
|
||||
</style>
|
||||
139
src/views/site/domainExternalSeoBaiduPushTrendDialog.vue
Normal file
139
src/views/site/domainExternalSeoBaiduPushTrendDialog.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="百度推送趋势摘要" width="1100px" draggable>
|
||||
<div class="baidu-push-trend-dialog">
|
||||
<div class="baidu-push-trend-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadSummary">刷新</el-button>
|
||||
<el-button v-if="summary.summary_html_path" type="success" plain @click="openPublicPath(summary.summary_html_path)">HTML摘要</el-button>
|
||||
<el-button v-if="summary.summary_json_path" type="info" plain @click="openPublicPath(summary.summary_json_path)">JSON摘要</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
:title="`当前趋势:${summary.health_label || 'unknown'}`"
|
||||
:description="summary.note || '这里展示百度推送链的 proxy trend。'"
|
||||
:type="alertType"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<div class="baidu-push-trend-dialog__metrics">
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">recent_state</div>
|
||||
<div class="metric-card__value">{{ summary.recent_state_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">stale_state</div>
|
||||
<div class="metric-card__value">{{ summary.stale_state_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">never_started</div>
|
||||
<div class="metric-card__value">{{ summary.never_started_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">queue_count</div>
|
||||
<div class="metric-card__value">{{ summary.queue_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">txt_missing</div>
|
||||
<div class="metric-card__value">{{ summary.txt_missing_count || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="task_last_exec">{{ summary.task_last_exec || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="db_status">
|
||||
{{ summary.db_ready ? "ready" : `blocked: ${summary.db_error || "-"}` }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-table v-loading="loading" :data="summary.recent_state_items || []" border max-height="420">
|
||||
<el-table-column prop="domain" label="Domain" min-width="180" />
|
||||
<el-table-column prop="updated_at" label="最近 state 更新时间" min-width="180" />
|
||||
<el-table-column prop="age_hours" label="距今小时" width="100" />
|
||||
<el-table-column label="Token" width="90">
|
||||
<template #default="{ row }">{{ row.token_ready ? "yes" : "no" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="State" width="90">
|
||||
<template #default="{ row }">{{ row.state_ready ? "yes" : "no" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="txt_file_count" label="txt 文件" width="90" />
|
||||
</el-table>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainExternalSeoBaiduPushTrendSummary } from "@/api/modules/site/list";
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL as string;
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const summary = ref<SiteList.DomainExternalSeoBaiduPushTrendData>({});
|
||||
|
||||
const alertType = computed(() => {
|
||||
const label = summary.value.health_label || "";
|
||||
if (label === "active") return "success";
|
||||
if (label === "blocked" || label === "stagnating") return "warning";
|
||||
return "info";
|
||||
});
|
||||
|
||||
const openPublicPath = (path?: string) => {
|
||||
if (!path) return;
|
||||
window.open(`${BASE_URL}/${String(path).replace(/^\/+/, "")}`, "_blank", "noopener");
|
||||
};
|
||||
|
||||
const loadSummary = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDomainExternalSeoBaiduPushTrendSummary({ limit: 20 });
|
||||
summary.value = res.data ?? {};
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取百度推送趋势摘要失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadSummary();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.baidu-push-trend-dialog__toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.baidu-push-trend-dialog__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.metric-card__label {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.metric-card__value {
|
||||
color: #303133;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
256
src/views/site/domainExternalSeoCnOverviewDialog.vue
Normal file
256
src/views/site/domainExternalSeoCnOverviewDialog.vue
Normal file
@@ -0,0 +1,256 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="大陆 SEO 总览" width="1220px" draggable>
|
||||
<div class="cn-overview-dialog">
|
||||
<div class="cn-overview-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadSummary">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
:title="summary.health_label ? `当前大盘状态:${buildHealthLabel(summary.health_label)}` : '当前还没有大陆 SEO 总览数据'"
|
||||
:description="summary.note || '这里会把快照摘要、收录变化、关键词升降和自动建议聚合到一个视角。'"
|
||||
:type="buildHealthType(summary.health_label)"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<div class="cn-overview-dialog__hero">
|
||||
<div class="cn-overview-dialog__hero-main">
|
||||
<div class="cn-overview-dialog__hero-label">大陆 SEO 大盘</div>
|
||||
<div class="cn-overview-dialog__hero-value">{{ buildHealthLabel(summary.health_label) }}</div>
|
||||
<div class="cn-overview-dialog__hero-note">
|
||||
先看收录和关键词是否同时在变好,再结合优先建议判断下一步要补哪里。
|
||||
</div>
|
||||
</div>
|
||||
<div class="cn-overview-dialog__hero-metrics">
|
||||
<div v-for="item in buildHeroMetrics(summary)" :key="item.label" class="metric-card metric-card--hero">
|
||||
<div class="metric-card__label">{{ item.label }}</div>
|
||||
<div class="metric-card__value">{{ item.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cn-overview-dialog__metrics">
|
||||
<div class="metric-card"><div class="metric-card__label">Host</div><div class="metric-card__value">{{ summary.snapshot_summary?.host_count || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">indexed_like</div><div class="metric-card__value">{{ summary.snapshot_summary?.indexed_like_host_count || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">keyword_ready</div><div class="metric-card__value">{{ summary.snapshot_summary?.keyword_ready_host_count || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">traffic_ready</div><div class="metric-card__value">{{ summary.snapshot_summary?.traffic_ready_host_count || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">newly_indexed</div><div class="metric-card__value">{{ summary.index_movement?.newly_indexed_count || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">lost_indexed</div><div class="metric-card__value">{{ summary.index_movement?.lost_indexed_count || 0 }}</div></div>
|
||||
</div>
|
||||
|
||||
<div class="cn-overview-dialog__metrics cn-overview-dialog__metrics--priority">
|
||||
<div class="metric-card metric-card--priority-high"><div class="metric-card__label">high</div><div class="metric-card__value">{{ summary.priority_buckets?.high || 0 }}</div></div>
|
||||
<div class="metric-card metric-card--priority-medium"><div class="metric-card__label">medium</div><div class="metric-card__value">{{ summary.priority_buckets?.medium || 0 }}</div></div>
|
||||
<div class="metric-card metric-card--priority-low"><div class="metric-card__label">low</div><div class="metric-card__value">{{ summary.priority_buckets?.low || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">keyword_up</div><div class="metric-card__value">{{ summary.keyword_movement?.up_count || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">keyword_down</div><div class="metric-card__value">{{ summary.keyword_movement?.down_count || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">keyword_dropped</div><div class="metric-card__value">{{ summary.keyword_movement?.dropped_count || 0 }}</div></div>
|
||||
</div>
|
||||
|
||||
<div class="cn-overview-dialog__descriptions">
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="latest_snapshot_date">{{ summary.snapshot_summary?.latest_metric_date || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="latest_index_date">{{ summary.index_movement?.latest_metric_date || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="latest_keyword_date">{{ summary.keyword_movement?.latest_metric_date || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="health_label">{{ summary.health_label || "-" }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<div class="cn-overview-dialog__attention">
|
||||
<div class="pane-card">
|
||||
<div class="pane-card__title">大盘重点 Host</div>
|
||||
<div v-if="!(summary.top_hosts || []).length" class="pane-card__empty">当前还没有重点 Host 数据</div>
|
||||
<div v-else class="cn-overview-dialog__focus-list">
|
||||
<div v-for="(item, index) in (summary.top_hosts || []).slice(0, 3)" :key="`${item.host || 'host'}-${index}`" class="cn-overview-dialog__focus-item">
|
||||
<div class="cn-overview-dialog__focus-main">
|
||||
<div class="cn-overview-dialog__focus-host">{{ item.host || "-" }}</div>
|
||||
<div class="cn-overview-dialog__focus-meta">
|
||||
{{ item.provider || "-" }} / 收录 {{ item.indexed_status || "-" }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="cn-overview-dialog__focus-side">
|
||||
<div>PC词数 {{ item.pc_keyword_count ?? "-" }}</div>
|
||||
<div>移动词数 {{ item.mobile_keyword_count ?? "-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pane-card">
|
||||
<div class="pane-card__title">当前优先动作</div>
|
||||
<div v-if="!(summary.top_suggestions || []).length" class="pane-card__empty">当前还没有自动建议</div>
|
||||
<div v-else class="cn-overview-dialog__focus-list">
|
||||
<div
|
||||
v-for="(item, index) in (summary.top_suggestions || []).slice(0, 3)"
|
||||
:key="`${item.title || 'suggestion'}-${index}`"
|
||||
class="cn-overview-dialog__focus-item"
|
||||
>
|
||||
<div class="cn-overview-dialog__focus-main">
|
||||
<div class="cn-overview-dialog__focus-host">{{ item.title || "-" }}</div>
|
||||
<div class="cn-overview-dialog__focus-meta">{{ item.summary || "-" }}</div>
|
||||
</div>
|
||||
<div class="cn-overview-dialog__focus-side cn-overview-dialog__focus-side--action">
|
||||
{{ item.action || "-" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="16" class="cn-overview-dialog__panes">
|
||||
<el-col :span="12">
|
||||
<div class="pane-card">
|
||||
<div class="pane-card__title">重点 Host</div>
|
||||
<el-table v-loading="loading" :data="summary.top_hosts || []" border max-height="360">
|
||||
<el-table-column prop="host" label="Host" min-width="160" />
|
||||
<el-table-column prop="provider" label="Provider" width="100" />
|
||||
<el-table-column prop="indexed_status" label="收录状态" width="110" />
|
||||
<el-table-column prop="pc_keyword_count" label="PC词数" width="90" />
|
||||
<el-table-column prop="mobile_keyword_count" label="移动词数" width="90" />
|
||||
</el-table>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<div class="pane-card">
|
||||
<div class="pane-card__title">优先建议</div>
|
||||
<el-table v-loading="loading" :data="summary.top_suggestions || []" border max-height="360">
|
||||
<el-table-column prop="priority" label="优先级" width="90" />
|
||||
<el-table-column prop="title" label="建议标题" min-width="160" />
|
||||
<el-table-column prop="action" label="建议动作" min-width="260" />
|
||||
</el-table>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainExternalSeoCnOverview } from "@/api/modules/site/list";
|
||||
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const summary = ref<SiteList.DomainExternalSeoCnOverviewData>({});
|
||||
|
||||
const loadSummary = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDomainExternalSeoCnOverview({ days: 14, limit: 20 });
|
||||
summary.value = res.data ?? {};
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取大陆 SEO 总览失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const buildHealthType = (healthLabel?: string) => {
|
||||
if (healthLabel === "growing") return "success";
|
||||
if (healthLabel === "attention" || healthLabel === "captcha") return "warning";
|
||||
return "info";
|
||||
};
|
||||
|
||||
const buildHealthLabel = (healthLabel?: string) => {
|
||||
if (healthLabel === "growing") return "增长中";
|
||||
if (healthLabel === "attention") return "需要关注";
|
||||
if (healthLabel === "captcha") return "抓取风控偏多";
|
||||
return healthLabel || "等待大盘数据";
|
||||
};
|
||||
|
||||
const buildHeroMetrics = (data: SiteList.DomainExternalSeoCnOverviewData) => {
|
||||
return [
|
||||
{
|
||||
label: "收录新增",
|
||||
value: `${Number(data.index_movement?.newly_indexed_count || 0)}`,
|
||||
},
|
||||
{
|
||||
label: "收录丢失",
|
||||
value: `${Number(data.index_movement?.lost_indexed_count || 0)}`,
|
||||
},
|
||||
{
|
||||
label: "关键词上升",
|
||||
value: `${Number(data.keyword_movement?.up_count || 0)}`,
|
||||
},
|
||||
{
|
||||
label: "关键词下降",
|
||||
value: `${Number(data.keyword_movement?.down_count || 0)}`,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadSummary();
|
||||
};
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.cn-overview-dialog__toolbar { margin-bottom: 12px; }
|
||||
.cn-overview-dialog__hero {
|
||||
display:grid;
|
||||
grid-template-columns:minmax(0,1.1fr) minmax(0,1fr);
|
||||
gap:12px;
|
||||
margin:16px 0;
|
||||
}
|
||||
.cn-overview-dialog__hero-main {
|
||||
border-radius:12px;
|
||||
padding:16px;
|
||||
color:#fff;
|
||||
background:linear-gradient(135deg, #14532d 0%, #1d4ed8 100%);
|
||||
}
|
||||
.cn-overview-dialog__hero-label { font-size:13px; opacity:.86; }
|
||||
.cn-overview-dialog__hero-value { margin-top:6px; font-size:28px; font-weight:700; }
|
||||
.cn-overview-dialog__hero-note { margin-top:8px; font-size:13px; line-height:1.6; opacity:.92; }
|
||||
.cn-overview-dialog__hero-metrics {
|
||||
display:grid;
|
||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||
gap:12px;
|
||||
}
|
||||
.cn-overview-dialog__metrics { display:grid; grid-template-columns:repeat(6,minmax(120px,1fr)); gap:12px; margin:16px 0; }
|
||||
.cn-overview-dialog__metrics--priority { grid-template-columns:repeat(6,minmax(120px,1fr)); }
|
||||
.metric-card { border:1px solid #ebeef5; border-radius:12px; padding:12px; background:#fafafa; }
|
||||
.metric-card--hero { background:#fafafa; }
|
||||
.metric-card--priority-high { background:#fff1f0; border-color:#fecaca; }
|
||||
.metric-card--priority-medium { background:#fffbeb; border-color:#fde68a; }
|
||||
.metric-card--priority-low { background:#f0f9ff; border-color:#bae6fd; }
|
||||
.metric-card__label { color:#909399; font-size:12px; margin-bottom:6px; }
|
||||
.metric-card__value { color:#303133; font-size:22px; font-weight:600; }
|
||||
.cn-overview-dialog__descriptions { margin: 8px 0 16px; }
|
||||
.cn-overview-dialog__attention {
|
||||
display:grid;
|
||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||
gap:16px;
|
||||
margin-bottom:16px;
|
||||
}
|
||||
.cn-overview-dialog__panes { margin-top: 16px; }
|
||||
.pane-card { border:1px solid #ebeef5; border-radius:12px; padding:12px; background:#fff; }
|
||||
.pane-card__title { font-weight:600; margin-bottom:12px; }
|
||||
.pane-card__empty { color:#909399; font-size:13px; }
|
||||
.cn-overview-dialog__focus-list { display:flex; flex-direction:column; gap:10px; }
|
||||
.cn-overview-dialog__focus-item {
|
||||
display:flex;
|
||||
justify-content:space-between;
|
||||
gap:12px;
|
||||
padding:10px;
|
||||
border-radius:10px;
|
||||
background:#fafafa;
|
||||
}
|
||||
.cn-overview-dialog__focus-main { min-width:0; }
|
||||
.cn-overview-dialog__focus-host { color:#303133; font-weight:600; word-break:break-all; }
|
||||
.cn-overview-dialog__focus-meta { margin-top:6px; color:#606266; font-size:12px; line-height:1.5; }
|
||||
.cn-overview-dialog__focus-side { color:#606266; font-size:12px; line-height:1.8; text-align:right; }
|
||||
.cn-overview-dialog__focus-side--action { max-width:220px; text-align:left; }
|
||||
@media (max-width: 1100px) {
|
||||
.cn-overview-dialog__hero,
|
||||
.cn-overview-dialog__attention,
|
||||
.cn-overview-dialog__hero-metrics,
|
||||
.cn-overview-dialog__metrics,
|
||||
.cn-overview-dialog__metrics--priority {
|
||||
grid-template-columns:1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
108
src/views/site/domainExternalSeoFailedQueueDialog.vue
Normal file
108
src/views/site/domainExternalSeoFailedQueueDialog.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="站外异常队列" width="1100px" draggable>
|
||||
<div class="external-failed-dialog">
|
||||
<div class="external-failed-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadQueue">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
:title="queue.queue_count ? '当前存在站外前置异常 Host' : '当前没有站外前置异常 Host'"
|
||||
:description="queue.note || '当前队列基于 proxy_readiness 摘要生成。'"
|
||||
:type="queue.queue_count ? 'warning' : 'success'"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<div class="external-failed-dialog__summary">
|
||||
<span>队列:{{ queue.queue_count || 0 }}</span>
|
||||
<span>趋势:{{ trendLabel() }}</span>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="rows" border max-height="420">
|
||||
<el-table-column prop="host" label="Host" min-width="180" />
|
||||
<el-table-column prop="external_readiness" label="站外准备态" width="120" />
|
||||
<el-table-column prop="probe_status" label="探测状态" width="110" />
|
||||
<el-table-column prop="failed_stage" label="失败阶段" width="110" />
|
||||
<el-table-column prop="search_keyword" label="关键词样本" min-width="180" />
|
||||
<el-table-column prop="probed_at" label="探测时间" min-width="180" />
|
||||
<el-table-column label="操作" width="300" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.summary_html_path" link type="primary" @click="openPublicPath(row.summary_html_path)">HTML摘要</el-button>
|
||||
<el-button v-if="row.summary_json_path" link type="info" @click="openPublicPath(row.summary_json_path)">JSON摘要</el-button>
|
||||
<el-button v-if="row.probe_command" link type="warning" @click="showCommand('单条探测命令', row.probe_command)">探测命令</el-button>
|
||||
<el-button v-if="row.sample_command" link type="success" @click="showCommand('自动样本命令', row.sample_command)">样本命令</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainExternalSeoFailedQueue } from "@/api/modules/site/list";
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL as string;
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const rows = ref<SiteList.DomainExternalSeoFailedQueueItem[]>([]);
|
||||
const queue = ref<SiteList.DomainExternalSeoFailedQueueData>({});
|
||||
|
||||
const openPublicPath = (path?: string) => {
|
||||
if (!path) return;
|
||||
window.open(`${BASE_URL}/${String(path).replace(/^\/+/, "")}`, "_blank", "noopener");
|
||||
};
|
||||
|
||||
const showCommand = async (title: string, command?: string) => {
|
||||
if (!command) return;
|
||||
await ElMessageBox.alert(`<code>${command}</code>`, title, {
|
||||
dangerouslyUseHTMLString: true,
|
||||
confirmButtonText: "知道了"
|
||||
});
|
||||
};
|
||||
|
||||
const trendLabel = () => {
|
||||
const label = queue.value.failure_trend?.label || "flat";
|
||||
if (label === "improving") return "改善中";
|
||||
if (label === "worsening") return "上升中";
|
||||
return "持平";
|
||||
};
|
||||
|
||||
const loadQueue = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDomainExternalSeoFailedQueue({ limit: 50 });
|
||||
queue.value = res.data ?? {};
|
||||
rows.value = res.data.items ?? [];
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取站外异常队列失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadQueue();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.external-failed-dialog__toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.external-failed-dialog__summary {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin: 12px 0;
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
226
src/views/site/domainExternalSeoIndexMovementDialog.vue
Normal file
226
src/views/site/domainExternalSeoIndexMovementDialog.vue
Normal file
@@ -0,0 +1,226 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="收录变化摘要" width="1220px" draggable>
|
||||
<div class="index-movement-dialog">
|
||||
<div class="index-movement-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadSummary">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
:title="summary.latest_host_count ? '当前已可分析收录变化' : '当前收录历史快照还不够做变化分析'"
|
||||
:description="summary.note || '这里消费 seo_external_snapshot 表里的 site: 收录历史快照。'"
|
||||
:type="buildHealthType(summary)"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<div class="index-movement-dialog__hero">
|
||||
<div class="index-movement-dialog__hero-main">
|
||||
<div class="index-movement-dialog__hero-label">收录变化大盘</div>
|
||||
<div class="index-movement-dialog__hero-value">{{ buildHealthLabel(summary) }}</div>
|
||||
<div class="index-movement-dialog__hero-note">
|
||||
先看新增收录和丢失收录,再看验证码、阻塞和无结果是不是在升高。
|
||||
</div>
|
||||
</div>
|
||||
<div class="index-movement-dialog__hero-metrics">
|
||||
<div v-for="item in buildHeroMetrics(summary)" :key="item.label" class="metric-card metric-card--hero">
|
||||
<div class="metric-card__label">{{ item.label }}</div>
|
||||
<div class="metric-card__value">{{ item.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="index-movement-dialog__metrics">
|
||||
<div class="metric-card"><div class="metric-card__label">latest</div><div class="metric-card__value">{{ summary.latest_host_count || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">newly_indexed</div><div class="metric-card__value">{{ summary.newly_indexed_count || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">lost_indexed</div><div class="metric-card__value">{{ summary.lost_indexed_count || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">captcha</div><div class="metric-card__value">{{ summary.captcha_count || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">blocked</div><div class="metric-card__value">{{ summary.blocked_count || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">no_result</div><div class="metric-card__value">{{ summary.no_result_count || 0 }}</div></div>
|
||||
</div>
|
||||
|
||||
<div class="index-movement-dialog__attention">
|
||||
<div class="index-movement-dialog__attention-box">
|
||||
<div class="index-movement-dialog__attention-title">当前优先关注</div>
|
||||
<div class="index-movement-dialog__attention-grid">
|
||||
<div class="index-movement-dialog__mini">
|
||||
<div class="index-movement-dialog__mini-label">新增收录</div>
|
||||
<div class="index-movement-dialog__mini-value">{{ summary.newly_indexed_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="index-movement-dialog__mini">
|
||||
<div class="index-movement-dialog__mini-label">丢失收录</div>
|
||||
<div class="index-movement-dialog__mini-value">{{ summary.lost_indexed_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="index-movement-dialog__mini">
|
||||
<div class="index-movement-dialog__mini-label">验证码</div>
|
||||
<div class="index-movement-dialog__mini-value">{{ summary.captcha_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="index-movement-dialog__mini">
|
||||
<div class="index-movement-dialog__mini-label">状态变化</div>
|
||||
<div class="index-movement-dialog__mini-value">{{ summary.changed_count || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="latest_metric_date">{{ summary.latest_metric_date || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="previous_metric_date">{{ summary.previous_metric_date || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="latest_host_count">{{ summary.latest_host_count || 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="changed_count">{{ summary.changed_count || 0 }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-tabs>
|
||||
<el-tab-pane :label="`新增收录 (${summary.newly_indexed_count || 0})`">
|
||||
<el-table v-loading="loading" :data="summary.newly_indexed_items || []" border max-height="320">
|
||||
<el-table-column prop="host" label="Host" min-width="180" />
|
||||
<el-table-column prop="previous_state" label="Prev" width="120" />
|
||||
<el-table-column prop="normalized_state" label="Now" width="120" />
|
||||
<el-table-column prop="previous_metric_date" label="Prev Date" width="120" />
|
||||
<el-table-column prop="metric_date" label="Now Date" width="120" />
|
||||
<el-table-column prop="result_count_text" label="Result" min-width="200" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="`丢失收录 (${summary.lost_indexed_count || 0})`">
|
||||
<el-table v-loading="loading" :data="summary.lost_indexed_items || []" border max-height="320">
|
||||
<el-table-column prop="host" label="Host" min-width="180" />
|
||||
<el-table-column prop="previous_state" label="Prev" width="120" />
|
||||
<el-table-column prop="normalized_state" label="Now" width="120" />
|
||||
<el-table-column prop="previous_metric_date" label="Prev Date" width="120" />
|
||||
<el-table-column prop="metric_date" label="Now Date" width="120" />
|
||||
<el-table-column prop="result_count_text" label="Result" min-width="200" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="`验证码 (${summary.captcha_count || 0})`">
|
||||
<el-table v-loading="loading" :data="summary.captcha_items || []" border max-height="320">
|
||||
<el-table-column prop="host" label="Host" min-width="180" />
|
||||
<el-table-column prop="metric_date" label="Date" width="120" />
|
||||
<el-table-column prop="normalized_state" label="State" width="120" />
|
||||
<el-table-column prop="result_count_text" label="Result" min-width="220" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="`无结果 (${summary.no_result_count || 0})`">
|
||||
<el-table v-loading="loading" :data="summary.no_result_items || []" border max-height="320">
|
||||
<el-table-column prop="host" label="Host" min-width="180" />
|
||||
<el-table-column prop="metric_date" label="Date" width="120" />
|
||||
<el-table-column prop="normalized_state" label="State" width="120" />
|
||||
<el-table-column prop="result_count_text" label="Result" min-width="220" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="`阻塞 (${summary.blocked_count || 0})`">
|
||||
<el-table v-loading="loading" :data="summary.blocked_items || []" border max-height="320">
|
||||
<el-table-column prop="host" label="Host" min-width="180" />
|
||||
<el-table-column prop="metric_date" label="Date" width="120" />
|
||||
<el-table-column prop="normalized_state" label="State" width="120" />
|
||||
<el-table-column prop="result_count_text" label="Result" min-width="220" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="`状态变化 (${summary.changed_count || 0})`">
|
||||
<el-table v-loading="loading" :data="summary.changed_items || []" border max-height="320">
|
||||
<el-table-column prop="host" label="Host" min-width="180" />
|
||||
<el-table-column prop="previous_state" label="Prev" width="120" />
|
||||
<el-table-column prop="normalized_state" label="Now" width="120" />
|
||||
<el-table-column prop="previous_metric_date" label="Prev Date" width="120" />
|
||||
<el-table-column prop="metric_date" label="Now Date" width="120" />
|
||||
<el-table-column prop="result_count_text" label="Result" min-width="220" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainExternalSeoIndexMovementSummary } from "@/api/modules/site/list";
|
||||
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const summary = ref<SiteList.DomainExternalSeoIndexMovementSummaryData>({});
|
||||
|
||||
const loadSummary = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDomainExternalSeoIndexMovementSummary({ days: 14, limit: 30 });
|
||||
summary.value = res.data ?? {};
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取收录变化摘要失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const buildHealthType = (data: SiteList.DomainExternalSeoIndexMovementSummaryData) => {
|
||||
if ((data.lost_indexed_count || 0) > 0 || (data.blocked_count || 0) > 0) return "warning";
|
||||
if ((data.newly_indexed_count || 0) > 0) return "success";
|
||||
return data.latest_host_count ? "info" : "warning";
|
||||
};
|
||||
|
||||
const buildHealthLabel = (data: SiteList.DomainExternalSeoIndexMovementSummaryData) => {
|
||||
if ((data.lost_indexed_count || 0) > 0) return "存在收录波动";
|
||||
if ((data.newly_indexed_count || 0) > 0) return "收录正在增长";
|
||||
if ((data.captcha_count || 0) > 0) return "存在验证码干扰";
|
||||
return data.latest_host_count ? "收录变化平稳" : "等待收录历史数据";
|
||||
};
|
||||
|
||||
const buildHeroMetrics = (data: SiteList.DomainExternalSeoIndexMovementSummaryData) => {
|
||||
return [
|
||||
{ label: "新增收录", value: `${data.newly_indexed_count || 0}` },
|
||||
{ label: "丢失收录", value: `${data.lost_indexed_count || 0}` },
|
||||
{ label: "验证码", value: `${data.captcha_count || 0}` },
|
||||
{ label: "阻塞", value: `${data.blocked_count || 0}` },
|
||||
];
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadSummary();
|
||||
};
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.index-movement-dialog__toolbar { margin-bottom: 12px; }
|
||||
.index-movement-dialog__hero {
|
||||
display:grid;
|
||||
grid-template-columns:minmax(0,1.1fr) minmax(0,1fr);
|
||||
gap:12px;
|
||||
margin:16px 0;
|
||||
}
|
||||
.index-movement-dialog__hero-main {
|
||||
border-radius:12px;
|
||||
padding:16px;
|
||||
color:#fff;
|
||||
background:linear-gradient(135deg, #166534 0%, #0f766e 100%);
|
||||
}
|
||||
.index-movement-dialog__hero-label { font-size:13px; opacity:.86; }
|
||||
.index-movement-dialog__hero-value { margin-top:6px; font-size:28px; font-weight:700; }
|
||||
.index-movement-dialog__hero-note { margin-top:8px; font-size:13px; line-height:1.6; opacity:.92; }
|
||||
.index-movement-dialog__hero-metrics {
|
||||
display:grid;
|
||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||
gap:12px;
|
||||
}
|
||||
.index-movement-dialog__metrics { display:grid; grid-template-columns:repeat(6,minmax(120px,1fr)); gap:12px; margin:16px 0; }
|
||||
.index-movement-dialog__attention { margin:0 0 16px; }
|
||||
.index-movement-dialog__attention-box { border:1px solid #ebeef5; border-radius:12px; padding:14px; background:#fff; }
|
||||
.index-movement-dialog__attention-title { font-weight:600; margin-bottom:10px; }
|
||||
.index-movement-dialog__attention-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; }
|
||||
.index-movement-dialog__mini { padding:10px; border-radius:10px; background:#fafafa; }
|
||||
.index-movement-dialog__mini-label { color:#909399; font-size:12px; }
|
||||
.index-movement-dialog__mini-value { margin-top:6px; color:#303133; font-weight:600; line-height:1.5; }
|
||||
.metric-card { border:1px solid #ebeef5; border-radius:12px; padding:12px; background:#fafafa; }
|
||||
.metric-card--hero { background:#fafafa; }
|
||||
.metric-card__label { color:#909399; font-size:12px; margin-bottom:6px; }
|
||||
.metric-card__value { color:#303133; font-size:22px; font-weight:600; }
|
||||
@media (max-width: 1100px) {
|
||||
.index-movement-dialog__hero,
|
||||
.index-movement-dialog__hero-metrics,
|
||||
.index-movement-dialog__metrics,
|
||||
.index-movement-dialog__attention-grid {
|
||||
grid-template-columns:1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
387
src/views/site/domainExternalSeoIntegrationOverviewDialog.vue
Normal file
387
src/views/site/domainExternalSeoIntegrationOverviewDialog.vue
Normal file
@@ -0,0 +1,387 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="站外 SEO 联调总览" width="980px" draggable>
|
||||
<div class="integration-overview-dialog">
|
||||
<div class="integration-overview-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadSummary">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
:title="buildTitle(summary)"
|
||||
:type="buildHealthType(summary.health_label)"
|
||||
:description="summary.note || ''"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<div class="integration-overview-dialog__hero">
|
||||
<div class="integration-overview-dialog__hero-main">
|
||||
<div class="integration-overview-dialog__hero-label">当前联调状态</div>
|
||||
<div class="integration-overview-dialog__hero-value">{{ buildHealthLabel(summary.health_label) }}</div>
|
||||
<div class="integration-overview-dialog__hero-note">
|
||||
dry-run 与正式回推会一起汇总到这里,值班时先看这一块。
|
||||
</div>
|
||||
</div>
|
||||
<div class="integration-overview-dialog__hero-metrics">
|
||||
<div v-for="item in buildHeroMetrics(summary)" :key="item.label" class="integration-overview-dialog__metric">
|
||||
<div class="integration-overview-dialog__metric-label">{{ item.label }}</div>
|
||||
<div class="integration-overview-dialog__metric-value">{{ item.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="integration-overview-dialog__cards">
|
||||
<div class="integration-overview-dialog__card integration-overview-dialog__card--validate">
|
||||
<div class="integration-overview-dialog__card-title">最近 dry-run</div>
|
||||
<div class="integration-overview-dialog__card-run">{{ summary.validate?.latest_run?.run_id || "-" }}</div>
|
||||
<div class="integration-overview-dialog__card-grid">
|
||||
<div class="integration-overview-dialog__mini">
|
||||
<div class="integration-overview-dialog__mini-label">总数</div>
|
||||
<div class="integration-overview-dialog__mini-value">{{ summary.validate?.total || 0 }}</div>
|
||||
</div>
|
||||
<div class="integration-overview-dialog__mini">
|
||||
<div class="integration-overview-dialog__mini-label">通过率</div>
|
||||
<div class="integration-overview-dialog__mini-value">{{ Number(summary.validate?.latest_run?.success_rate || 0).toFixed(0) }}%</div>
|
||||
</div>
|
||||
<div class="integration-overview-dialog__mini">
|
||||
<div class="integration-overview-dialog__mini-label">告警</div>
|
||||
<div class="integration-overview-dialog__mini-value">{{ summary.validate?.latest_run?.alert_level || "none" }}</div>
|
||||
</div>
|
||||
<div class="integration-overview-dialog__mini">
|
||||
<div class="integration-overview-dialog__mini-label">原因</div>
|
||||
<div class="integration-overview-dialog__mini-value integration-overview-dialog__mini-value--reason">
|
||||
{{ summary.validate?.latest_run?.alert_reason || "-" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="integration-overview-dialog__card integration-overview-dialog__card--snapshot">
|
||||
<div class="integration-overview-dialog__card-title">最近正式回推</div>
|
||||
<div class="integration-overview-dialog__card-run">{{ summary.snapshot?.latest_run?.run_id || "-" }}</div>
|
||||
<div class="integration-overview-dialog__card-grid">
|
||||
<div class="integration-overview-dialog__mini">
|
||||
<div class="integration-overview-dialog__mini-label">总数</div>
|
||||
<div class="integration-overview-dialog__mini-value">{{ summary.snapshot?.total || 0 }}</div>
|
||||
</div>
|
||||
<div class="integration-overview-dialog__mini">
|
||||
<div class="integration-overview-dialog__mini-label">成功率</div>
|
||||
<div class="integration-overview-dialog__mini-value">{{ Number(summary.snapshot?.latest_run?.success_rate || 0).toFixed(0) }}%</div>
|
||||
</div>
|
||||
<div class="integration-overview-dialog__mini">
|
||||
<div class="integration-overview-dialog__mini-label">告警</div>
|
||||
<div class="integration-overview-dialog__mini-value">{{ summary.snapshot?.latest_run?.alert_level || "none" }}</div>
|
||||
</div>
|
||||
<div class="integration-overview-dialog__mini">
|
||||
<div class="integration-overview-dialog__mini-label">原因</div>
|
||||
<div class="integration-overview-dialog__mini-value integration-overview-dialog__mini-value--reason">
|
||||
{{ summary.snapshot?.latest_run?.alert_reason || "-" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="integration-overview-dialog__attention">
|
||||
<div class="integration-overview-dialog__attention-box">
|
||||
<div class="integration-overview-dialog__attention-title">dry-run 优先关注</div>
|
||||
<div v-if="!(summary.validate?.top_attention_runs || []).length" class="integration-overview-dialog__attention-empty">当前没有高优先级 dry-run 异常</div>
|
||||
<div v-else class="integration-overview-dialog__attention-list">
|
||||
<div v-for="item in (summary.validate?.top_attention_runs || []).slice(0, 3)" :key="item.run_id" class="integration-overview-dialog__attention-item">
|
||||
<div class="integration-overview-dialog__attention-run">{{ item.run_id }}</div>
|
||||
<div class="integration-overview-dialog__attention-reason">{{ item.alert_reason || "-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="integration-overview-dialog__attention-box">
|
||||
<div class="integration-overview-dialog__attention-title">正式回推优先关注</div>
|
||||
<div v-if="!(summary.snapshot?.top_attention_runs || []).length" class="integration-overview-dialog__attention-empty">当前没有高优先级正式回推异常</div>
|
||||
<div v-else class="integration-overview-dialog__attention-list">
|
||||
<div v-for="item in (summary.snapshot?.top_attention_runs || []).slice(0, 3)" :key="item.run_id" class="integration-overview-dialog__attention-item">
|
||||
<div class="integration-overview-dialog__attention-run">{{ item.run_id }}</div>
|
||||
<div class="integration-overview-dialog__attention-reason">{{ item.alert_reason || "-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="buildRows(summary)" border>
|
||||
<el-table-column prop="stage" label="阶段" width="140" />
|
||||
<el-table-column prop="run_id" label="最新 Run" min-width="180" />
|
||||
<el-table-column prop="success_rate" label="成功率" width="100" />
|
||||
<el-table-column prop="alert_level" label="告警" width="100" />
|
||||
<el-table-column prop="alert_reason" label="原因" min-width="260" />
|
||||
</el-table>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainExternalSeoIntegrationOverview } from "@/api/modules/site/list";
|
||||
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const summary = ref<SiteList.DomainExternalSeoIntegrationOverviewData>({});
|
||||
|
||||
const loadSummary = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDomainExternalSeoIntegrationOverview({ limit: 10 });
|
||||
summary.value = res.data ?? {};
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取站外 SEO 联调总览失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const buildRows = (data: SiteList.DomainExternalSeoIntegrationOverviewData) => {
|
||||
return [
|
||||
{
|
||||
stage: "dry-run 校验",
|
||||
run_id: data.validate?.latest_run?.run_id || "-",
|
||||
success_rate: `${Number(data.validate?.latest_run?.success_rate || 0).toFixed(0)}%`,
|
||||
alert_level: data.validate?.latest_run?.alert_level || "none",
|
||||
alert_reason: data.validate?.latest_run?.alert_reason || "-",
|
||||
},
|
||||
{
|
||||
stage: "正式回推",
|
||||
run_id: data.snapshot?.latest_run?.run_id || "-",
|
||||
success_rate: `${Number(data.snapshot?.latest_run?.success_rate || 0).toFixed(0)}%`,
|
||||
alert_level: data.snapshot?.latest_run?.alert_level || "none",
|
||||
alert_reason: data.snapshot?.latest_run?.alert_reason || "-",
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const buildHealthType = (healthLabel?: string) => {
|
||||
if (healthLabel === "validate_blocked" || healthLabel === "push_blocked") return "error";
|
||||
if (healthLabel === "running") return "success";
|
||||
return "info";
|
||||
};
|
||||
|
||||
const buildHealthLabel = (healthLabel?: string) => {
|
||||
if (healthLabel === "validate_blocked") return "字段校验受阻";
|
||||
if (healthLabel === "push_blocked") return "正式回推受阻";
|
||||
if (healthLabel === "running") return "联调运行中";
|
||||
return "等待联调数据";
|
||||
};
|
||||
|
||||
const buildHeroMetrics = (data: SiteList.DomainExternalSeoIntegrationOverviewData) => {
|
||||
const validateLatest = data.validate?.latest_run;
|
||||
const snapshotLatest = data.snapshot?.latest_run;
|
||||
return [
|
||||
{
|
||||
label: "dry-run 成功率",
|
||||
value: `${Number(validateLatest?.success_rate || 0).toFixed(0)}%`,
|
||||
},
|
||||
{
|
||||
label: "正式回推成功率",
|
||||
value: `${Number(snapshotLatest?.success_rate || 0).toFixed(0)}%`,
|
||||
},
|
||||
{
|
||||
label: "dry-run 高优先级",
|
||||
value: `${Number(data.validate?.alert_buckets?.high || 0)}`,
|
||||
},
|
||||
{
|
||||
label: "回推高优先级",
|
||||
value: `${Number(data.snapshot?.alert_buckets?.high || 0)}`,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const buildTitle = (data: SiteList.DomainExternalSeoIntegrationOverviewData) => {
|
||||
if (data.health_label === "validate_blocked") {
|
||||
return "当前主要卡在字段校验阶段,先修 dry-run 问题。";
|
||||
}
|
||||
if (data.health_label === "push_blocked") {
|
||||
return "当前 dry-run 已经过,但正式回推仍有阻塞。";
|
||||
}
|
||||
if (data.health_label === "running") {
|
||||
return "联调链路已经跑起来,可以继续观察 dry-run 和正式回推的稳定性。";
|
||||
}
|
||||
return "当前还没有足够的联调记录。";
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadSummary();
|
||||
};
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.integration-overview-dialog__toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.integration-overview-dialog__hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.integration-overview-dialog__hero-main {
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #1f4b99 0%, #2e7d6b 100%);
|
||||
}
|
||||
|
||||
.integration-overview-dialog__hero-label {
|
||||
font-size: 13px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.integration-overview-dialog__hero-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.integration-overview-dialog__hero-note {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
opacity: 0.9;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.integration-overview-dialog__hero-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.integration-overview-dialog__metric {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.integration-overview-dialog__metric-label {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.integration-overview-dialog__metric-value {
|
||||
margin-top: 8px;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.integration-overview-dialog__cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.integration-overview-dialog__card {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.integration-overview-dialog__card-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.integration-overview-dialog__card-run {
|
||||
color: #606266;
|
||||
font-size: 12px;
|
||||
margin-bottom: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.integration-overview-dialog__card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.integration-overview-dialog__mini {
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.integration-overview-dialog__mini-label {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.integration-overview-dialog__mini-value {
|
||||
margin-top: 6px;
|
||||
color: #303133;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.integration-overview-dialog__mini-value--reason {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.integration-overview-dialog__attention {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.integration-overview-dialog__attention-box {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.integration-overview-dialog__attention-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.integration-overview-dialog__attention-empty {
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.integration-overview-dialog__attention-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.integration-overview-dialog__attention-item {
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.integration-overview-dialog__attention-run {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.integration-overview-dialog__attention-reason {
|
||||
margin-top: 6px;
|
||||
color: #303133;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.integration-overview-dialog__hero,
|
||||
.integration-overview-dialog__cards,
|
||||
.integration-overview-dialog__attention {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
204
src/views/site/domainExternalSeoKeywordMovementDialog.vue
Normal file
204
src/views/site/domainExternalSeoKeywordMovementDialog.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="关键词升降摘要" width="1220px" draggable>
|
||||
<div class="keyword-movement-dialog">
|
||||
<div class="keyword-movement-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadSummary">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
:title="summary.latest_keyword_count ? '当前已可分析关键词升降' : '当前关键词历史快照还不够做升降分析'"
|
||||
:description="summary.note || '这里消费 seo_external_snapshot 表里的关键词历史快照。'"
|
||||
:type="buildHealthType(summary)"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<div class="keyword-movement-dialog__hero">
|
||||
<div class="keyword-movement-dialog__hero-main">
|
||||
<div class="keyword-movement-dialog__hero-label">关键词变化大盘</div>
|
||||
<div class="keyword-movement-dialog__hero-value">{{ buildHealthLabel(summary) }}</div>
|
||||
<div class="keyword-movement-dialog__hero-note">
|
||||
先看新增词和上升词,再看下降和掉词是否在扩大。
|
||||
</div>
|
||||
</div>
|
||||
<div class="keyword-movement-dialog__hero-metrics">
|
||||
<div v-for="item in buildHeroMetrics(summary)" :key="item.label" class="metric-card metric-card--hero">
|
||||
<div class="metric-card__label">{{ item.label }}</div>
|
||||
<div class="metric-card__value">{{ item.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="keyword-movement-dialog__metrics">
|
||||
<div class="metric-card"><div class="metric-card__label">latest</div><div class="metric-card__value">{{ summary.latest_keyword_count || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">new</div><div class="metric-card__value">{{ summary.new_count || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">up</div><div class="metric-card__value">{{ summary.up_count || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">down</div><div class="metric-card__value">{{ summary.down_count || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">dropped</div><div class="metric-card__value">{{ summary.dropped_count || 0 }}</div></div>
|
||||
</div>
|
||||
|
||||
<div class="keyword-movement-dialog__attention">
|
||||
<div class="keyword-movement-dialog__attention-box">
|
||||
<div class="keyword-movement-dialog__attention-title">当前优先关注</div>
|
||||
<div class="keyword-movement-dialog__attention-grid">
|
||||
<div class="keyword-movement-dialog__mini">
|
||||
<div class="keyword-movement-dialog__mini-label">新增词</div>
|
||||
<div class="keyword-movement-dialog__mini-value">{{ summary.new_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="keyword-movement-dialog__mini">
|
||||
<div class="keyword-movement-dialog__mini-label">上升词</div>
|
||||
<div class="keyword-movement-dialog__mini-value">{{ summary.up_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="keyword-movement-dialog__mini">
|
||||
<div class="keyword-movement-dialog__mini-label">下降词</div>
|
||||
<div class="keyword-movement-dialog__mini-value">{{ summary.down_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="keyword-movement-dialog__mini">
|
||||
<div class="keyword-movement-dialog__mini-label">掉词</div>
|
||||
<div class="keyword-movement-dialog__mini-value">{{ summary.dropped_count || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="latest_metric_date">{{ summary.latest_metric_date || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="previous_metric_date">{{ summary.previous_metric_date || "-" }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-tabs>
|
||||
<el-tab-pane :label="`新增 (${summary.new_count || 0})`">
|
||||
<el-table v-loading="loading" :data="summary.new_items || []" border max-height="320">
|
||||
<el-table-column prop="host" label="Host" min-width="160" />
|
||||
<el-table-column prop="keyword" label="Keyword" min-width="180" />
|
||||
<el-table-column prop="device" label="Device" width="90" />
|
||||
<el-table-column prop="rank_value" label="Rank" width="90" />
|
||||
<el-table-column prop="search_volume" label="Volume" width="100" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="`上升 (${summary.up_count || 0})`">
|
||||
<el-table v-loading="loading" :data="summary.up_items || []" border max-height="320">
|
||||
<el-table-column prop="host" label="Host" min-width="160" />
|
||||
<el-table-column prop="keyword" label="Keyword" min-width="180" />
|
||||
<el-table-column prop="previous_rank" label="Prev" width="90" />
|
||||
<el-table-column prop="rank_value" label="Now" width="90" />
|
||||
<el-table-column prop="rank_change" label="Change" width="90" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="`下降 (${summary.down_count || 0})`">
|
||||
<el-table v-loading="loading" :data="summary.down_items || []" border max-height="320">
|
||||
<el-table-column prop="host" label="Host" min-width="160" />
|
||||
<el-table-column prop="keyword" label="Keyword" min-width="180" />
|
||||
<el-table-column prop="previous_rank" label="Prev" width="90" />
|
||||
<el-table-column prop="rank_value" label="Now" width="90" />
|
||||
<el-table-column prop="rank_change" label="Change" width="90" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="`掉词 (${summary.dropped_count || 0})`">
|
||||
<el-table v-loading="loading" :data="summary.dropped_items || []" border max-height="320">
|
||||
<el-table-column prop="host" label="Host" min-width="160" />
|
||||
<el-table-column prop="keyword" label="Keyword" min-width="180" />
|
||||
<el-table-column prop="device" label="Device" width="90" />
|
||||
<el-table-column prop="rank_value" label="Rank" width="90" />
|
||||
<el-table-column prop="search_volume" label="Volume" width="100" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainExternalSeoKeywordMovementSummary } from "@/api/modules/site/list";
|
||||
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const summary = ref<SiteList.DomainExternalSeoKeywordMovementSummaryData>({});
|
||||
|
||||
const loadSummary = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDomainExternalSeoKeywordMovementSummary({ days: 14, limit: 30 });
|
||||
summary.value = res.data ?? {};
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取关键词升降摘要失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const buildHealthType = (data: SiteList.DomainExternalSeoKeywordMovementSummaryData) => {
|
||||
if ((data.down_count || 0) > 0 || (data.dropped_count || 0) > 0) return "warning";
|
||||
if ((data.up_count || 0) > 0 || (data.new_count || 0) > 0) return "success";
|
||||
return data.latest_keyword_count ? "info" : "warning";
|
||||
};
|
||||
|
||||
const buildHealthLabel = (data: SiteList.DomainExternalSeoKeywordMovementSummaryData) => {
|
||||
if ((data.dropped_count || 0) > 0) return "存在掉词风险";
|
||||
if ((data.up_count || 0) > 0 || (data.new_count || 0) > 0) return "关键词正在增长";
|
||||
return data.latest_keyword_count ? "关键词变化平稳" : "等待关键词历史数据";
|
||||
};
|
||||
|
||||
const buildHeroMetrics = (data: SiteList.DomainExternalSeoKeywordMovementSummaryData) => {
|
||||
return [
|
||||
{ label: "新增词", value: `${data.new_count || 0}` },
|
||||
{ label: "上升词", value: `${data.up_count || 0}` },
|
||||
{ label: "下降词", value: `${data.down_count || 0}` },
|
||||
{ label: "掉词", value: `${data.dropped_count || 0}` },
|
||||
];
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadSummary();
|
||||
};
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.keyword-movement-dialog__toolbar { margin-bottom: 12px; }
|
||||
.keyword-movement-dialog__hero {
|
||||
display:grid;
|
||||
grid-template-columns:minmax(0,1.1fr) minmax(0,1fr);
|
||||
gap:12px;
|
||||
margin:16px 0;
|
||||
}
|
||||
.keyword-movement-dialog__hero-main {
|
||||
border-radius:12px;
|
||||
padding:16px;
|
||||
color:#fff;
|
||||
background:linear-gradient(135deg, #7c3aed 0%, #2563eb 100%);
|
||||
}
|
||||
.keyword-movement-dialog__hero-label { font-size:13px; opacity:.86; }
|
||||
.keyword-movement-dialog__hero-value { margin-top:6px; font-size:28px; font-weight:700; }
|
||||
.keyword-movement-dialog__hero-note { margin-top:8px; font-size:13px; line-height:1.6; opacity:.92; }
|
||||
.keyword-movement-dialog__hero-metrics {
|
||||
display:grid;
|
||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||
gap:12px;
|
||||
}
|
||||
.keyword-movement-dialog__metrics { display:grid; grid-template-columns:repeat(5,minmax(120px,1fr)); gap:12px; margin:16px 0; }
|
||||
.keyword-movement-dialog__attention { margin:0 0 16px; }
|
||||
.keyword-movement-dialog__attention-box { border:1px solid #ebeef5; border-radius:12px; padding:14px; background:#fff; }
|
||||
.keyword-movement-dialog__attention-title { font-weight:600; margin-bottom:10px; }
|
||||
.keyword-movement-dialog__attention-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; }
|
||||
.keyword-movement-dialog__mini { padding:10px; border-radius:10px; background:#fafafa; }
|
||||
.keyword-movement-dialog__mini-label { color:#909399; font-size:12px; }
|
||||
.keyword-movement-dialog__mini-value { margin-top:6px; color:#303133; font-weight:600; line-height:1.5; }
|
||||
.metric-card { border:1px solid #ebeef5; border-radius:12px; padding:12px; background:#fafafa; }
|
||||
.metric-card--hero { background:#fafafa; }
|
||||
.metric-card__label { color:#909399; font-size:12px; margin-bottom:6px; }
|
||||
.metric-card__value { color:#303133; font-size:22px; font-weight:600; }
|
||||
@media (max-width: 980px) {
|
||||
.keyword-movement-dialog__hero,
|
||||
.keyword-movement-dialog__hero-metrics,
|
||||
.keyword-movement-dialog__metrics,
|
||||
.keyword-movement-dialog__attention-grid {
|
||||
grid-template-columns:1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
81
src/views/site/domainExternalSeoManualImportRunDialog.vue
Normal file
81
src/views/site/domainExternalSeoManualImportRunDialog.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="最近站外手工报表记录" width="980px" draggable>
|
||||
<div class="manual-import-run-dialog">
|
||||
<div class="manual-import-run-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadRuns">刷新</el-button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="rows" border>
|
||||
<el-table-column prop="run_id" label="Run ID" min-width="180" />
|
||||
<el-table-column prop="status" label="状态" width="120" />
|
||||
<el-table-column prop="provider_key" label="Provider" min-width="160" />
|
||||
<el-table-column prop="rows_total" label="行数" width="90" />
|
||||
<el-table-column prop="hosts_count" label="域名数" width="90" />
|
||||
<el-table-column label="指标" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<span>
|
||||
imp {{ row.metrics?.impressions ?? "-" }},
|
||||
clk {{ row.metrics?.clicks ?? "-" }},
|
||||
ctr {{ row.metrics?.ctr ?? "-" }},
|
||||
pos {{ row.metrics?.avg_position ?? "-" }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updated_at" label="更新时间" min-width="180" />
|
||||
<el-table-column label="操作" width="320" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.summary_html_path" link type="primary" @click="openPublicPath(row.summary_html_path)">HTML摘要</el-button>
|
||||
<el-button v-if="row.summary_json_path" link type="info" @click="openPublicPath(row.summary_json_path)">JSON摘要</el-button>
|
||||
<el-button v-if="row.csv_path" link type="warning" @click="openPublicPath(row.csv_path)">标准CSV</el-button>
|
||||
<el-button v-if="row.uploaded_report_path" link type="success" @click="openPublicPath(row.uploaded_report_path)">原始报表</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainExternalSeoManualImportRuns } from "@/api/modules/site/list";
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL as string;
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const rows = ref<SiteList.DomainExternalSeoManualImportRunItem[]>([]);
|
||||
|
||||
const openPublicPath = (path?: string) => {
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
window.open(`${BASE_URL}/${String(path).replace(/^\/+/, "")}`, "_blank", "noopener");
|
||||
};
|
||||
|
||||
const loadRuns = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDomainExternalSeoManualImportRuns();
|
||||
rows.value = res.data.items ?? [];
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取站外手工报表记录失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadRuns();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.manual-import-run-dialog__toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="站外 SEO 优化建议" width="1180px" draggable>
|
||||
<div class="optimization-suggestion-dialog">
|
||||
<div class="optimization-suggestion-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadSummary">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
:title="(summary.suggestions || []).length ? '当前已生成自动优化建议' : '当前还没有足够历史快照生成建议'"
|
||||
:description="summary.note || '这里会结合收录变化、关键词升降和历史快照自动给出第一版动作建议。'"
|
||||
:type="(summary.suggestions || []).length ? 'success' : 'warning'"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<div class="optimization-suggestion-dialog__metrics">
|
||||
<div class="metric-card"><div class="metric-card__label">indexed_like</div><div class="metric-card__value">{{ summary.summary?.indexed_like_host_count || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">newly_indexed</div><div class="metric-card__value">{{ summary.index_movement?.newly_indexed_count || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">lost_indexed</div><div class="metric-card__value">{{ summary.index_movement?.lost_indexed_count || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">keyword_up</div><div class="metric-card__value">{{ summary.keyword_movement?.up_count || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">keyword_down</div><div class="metric-card__value">{{ summary.keyword_movement?.down_count || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">dropped</div><div class="metric-card__value">{{ summary.keyword_movement?.dropped_count || 0 }}</div></div>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="summary.suggestions || []" border max-height="520">
|
||||
<el-table-column prop="priority" label="优先级" width="100" />
|
||||
<el-table-column prop="title" label="建议标题" min-width="180" />
|
||||
<el-table-column prop="summary" label="现状判断" min-width="260" />
|
||||
<el-table-column prop="action" label="建议动作" min-width="300" />
|
||||
<el-table-column label="证据" min-width="240">
|
||||
<template #default="{ row }">
|
||||
<pre class="optimization-suggestion-dialog__evidence">{{ JSON.stringify(row.evidence || {}, null, 2) }}</pre>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainExternalSeoOptimizationSuggestions } from "@/api/modules/site/list";
|
||||
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const summary = ref<SiteList.DomainExternalSeoOptimizationSuggestionData>({});
|
||||
|
||||
const loadSummary = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDomainExternalSeoOptimizationSuggestions({ days: 14, limit: 20 });
|
||||
summary.value = res.data ?? {};
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取站外 SEO 优化建议失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadSummary();
|
||||
};
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.optimization-suggestion-dialog__toolbar { margin-bottom: 12px; }
|
||||
.optimization-suggestion-dialog__metrics { display:grid; grid-template-columns:repeat(6,minmax(120px,1fr)); gap:12px; margin:16px 0; }
|
||||
.metric-card { border:1px solid #ebeef5; border-radius:8px; padding:12px; background:#fafafa; }
|
||||
.metric-card__label { color:#909399; font-size:12px; margin-bottom:6px; }
|
||||
.metric-card__value { color:#303133; font-size:22px; font-weight:600; }
|
||||
.optimization-suggestion-dialog__evidence { margin:0; white-space:pre-wrap; word-break:break-word; font-size:12px; color:#606266; }
|
||||
</style>
|
||||
1056
src/views/site/domainExternalSeoProviderDialog.vue
Normal file
1056
src/views/site/domainExternalSeoProviderDialog.vue
Normal file
File diff suppressed because it is too large
Load Diff
195
src/views/site/domainExternalSeoRealSummaryDialog.vue
Normal file
195
src/views/site/domainExternalSeoRealSummaryDialog.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="真实站外结果" width="920px" draggable>
|
||||
<div class="external-real-dialog">
|
||||
<div class="external-real-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadSummary">刷新</el-button>
|
||||
<el-button v-if="summary.summary_html_path" type="success" plain @click="openSummaryFile(summary.summary_html_path)">打开 HTML 摘要</el-button>
|
||||
<el-button v-if="summary.summary_json_path" type="info" plain @click="openSummaryFile(summary.summary_json_path)">打开 JSON 摘要</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
:title="alertTitle"
|
||||
:description="summary.note || '当前尚未拉取真实外部平台数据。'"
|
||||
:type="summary.real_metrics_available ? 'success' : 'warning'"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<div class="external-real-dialog__summary">
|
||||
<span>数据来源:{{ summary.provider_label || "-" }}</span>
|
||||
<span>来源状态:{{ summary.provider_status || "-" }}</span>
|
||||
<span>来源模式:{{ sourceModeLabel(summary.source_mode) }}</span>
|
||||
<span>当前状态:{{ statusLabel(summary.status) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="external-real-dialog__metrics">
|
||||
<div v-for="metric in summary.headline_metrics || []" :key="metric.key || metric.label" class="metric-card">
|
||||
<div class="metric-card__label">{{ metric.label || "-" }}</div>
|
||||
<div class="metric-card__value">{{ renderMetric(metric) }}</div>
|
||||
<div class="metric-card__note">{{ metric.note || "-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="统计周期">{{ summary.days || 0 }} 天</el-descriptions-item>
|
||||
<el-descriptions-item label="来源说明">{{ sourceHint }}</el-descriptions-item>
|
||||
<el-descriptions-item label="结果更新时间">{{ formatDateTime(summary.generated_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最新快照日期">{{ formatDateTime(summary.snapshot_metrics?.latest_metric_date) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="external-real-dialog__steps">
|
||||
<div class="external-real-dialog__steps-title">下一步</div>
|
||||
<ul>
|
||||
<li v-for="(step, index) in summary.next_steps || []" :key="`${index}-${step}`">{{ step }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainExternalSeoRealSummary } from "@/api/modules/site/list";
|
||||
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const summary = ref<SiteList.DomainExternalSeoRealSummaryData>({});
|
||||
|
||||
const alertTitle = computed(() => {
|
||||
if (summary.value.source_mode === "automation_snapshot") {
|
||||
return "当前展示的是 Node 自动快照结果";
|
||||
}
|
||||
if (summary.value.real_metrics_available) {
|
||||
return "当前已接入真实站外指标";
|
||||
}
|
||||
return "当前还在真实结果接入阶段";
|
||||
});
|
||||
|
||||
const sourceHint = computed(() => {
|
||||
if (summary.value.source_mode === "automation_snapshot") {
|
||||
return "自动快照优先,主要看收录、起词和流量就绪,不是展现/点击报表。";
|
||||
}
|
||||
if (summary.value.source_mode === "manual_csv") {
|
||||
return "当前来自人工 CSV / 报表导入,适合临时补录和历史对账。";
|
||||
}
|
||||
return "当前仍在平台级真实结果接入阶段。";
|
||||
});
|
||||
|
||||
const loadSummary = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDomainExternalSeoRealSummary({ days: 7 });
|
||||
summary.value = res.data ?? {};
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取真实站外 summary 骨架失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openSummaryFile = (path?: string) => {
|
||||
if (!path) return;
|
||||
window.open(`${import.meta.env.VITE_API_URL}/${String(path).replace(/^\/+/, "")}`, "_blank", "noopener");
|
||||
};
|
||||
|
||||
const statusLabel = (status?: string) => {
|
||||
if (status === "ready") return "已就绪";
|
||||
if (status === "not_configured") return "未配置";
|
||||
if (status === "provider_ready_but_property_map_missing") return "缺少属性映射";
|
||||
if (status === "provider_ready_but_client_missing") return "缺少客户端依赖";
|
||||
if (status === "ready_for_api_implementation") return "可接 API";
|
||||
return status || "-";
|
||||
};
|
||||
|
||||
const sourceModeLabel = (mode?: string) => {
|
||||
if (mode === "automation_snapshot") return "自动快照";
|
||||
if (mode === "manual_csv") return "人工报表";
|
||||
if (mode === "provider_skeleton") return "接入骨架";
|
||||
return mode || "-";
|
||||
};
|
||||
|
||||
const renderMetric = (metric?: any) => {
|
||||
if (!metric) return "-";
|
||||
const value = metric.value;
|
||||
if (value === null || value === undefined || value === "") return "-";
|
||||
if (metric.format === "percent" && typeof value === "number") {
|
||||
return `${(value * 100).toFixed(2)}%`;
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const formatDateTime = (value?: string) => {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
const pad = (num: number) => String(num).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadSummary();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.external-real-dialog__toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.external-real-dialog__summary {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin: 12px 0;
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.external-real-dialog__steps {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.external-real-dialog__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(150px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
|
||||
}
|
||||
|
||||
.metric-card__label {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.metric-card__value {
|
||||
color: #303133;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.metric-card__note {
|
||||
margin-top: 8px;
|
||||
color: #606266;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.external-real-dialog__steps-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
362
src/views/site/domainExternalSeoResultObserveDialog.vue
Normal file
362
src/views/site/domainExternalSeoResultObserveDialog.vue
Normal file
@@ -0,0 +1,362 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="SEO 结果观察面板" width="980px" draggable>
|
||||
<div class="result-observe-dialog">
|
||||
<div class="result-observe-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadData">刷新</el-button>
|
||||
<el-button v-if="realSummary.summary_html_path" type="success" plain @click="openSummaryFile(realSummary.summary_html_path)">
|
||||
打开真实结果摘要
|
||||
</el-button>
|
||||
<el-button v-if="realSummary.summary_json_path" type="info" plain @click="openSummaryFile(realSummary.summary_json_path)">
|
||||
打开 JSON
|
||||
</el-button>
|
||||
<el-button type="warning" plain @click="openSnapshotRunsDialog">最近自动快照接收</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
:title="heroTitle"
|
||||
:description="heroDescription"
|
||||
:type="realSummary.real_metrics_available ? 'success' : 'warning'"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<div class="result-observe-dialog__metrics" v-loading="loading">
|
||||
<div
|
||||
v-for="(metric, index) in displayHeadlineMetrics"
|
||||
:key="metric.key || metric.label || index"
|
||||
class="metric-card"
|
||||
:class="{ 'metric-card--primary': index === 0 }"
|
||||
>
|
||||
<div class="metric-card__label">{{ metric.label || "-" }}</div>
|
||||
<div class="metric-card__value">{{ renderHeadlineMetric(metric) }}</div>
|
||||
<div class="metric-card__note">{{ metric.note || "-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="result-observe-dialog__insights">
|
||||
<div class="insight-card">
|
||||
<div class="insight-card__title">当前准备态</div>
|
||||
<div class="insight-card__value">{{ readinessSummary.hero_summary || "当前还没有准备态摘要" }}</div>
|
||||
<div class="insight-card__meta">
|
||||
已恢复 {{ readinessSummary.ready_count || 0 }} / {{ readinessSummary.host_count || 0 }},
|
||||
当前异常 {{ readinessSummary.queue_count || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="insight-card">
|
||||
<div class="insight-card__title">当前趋势</div>
|
||||
<div class="insight-card__value">{{ trendLabel(trendSummary.proxy_signal_trend?.label) }}</div>
|
||||
<div class="insight-card__meta">
|
||||
最新失败 {{ trendSummary.proxy_signal_trend?.latest_failed_count || 0 }},
|
||||
前次失败 {{ trendSummary.proxy_signal_trend?.previous_failed_count || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="insight-card">
|
||||
<div class="insight-card__title">最近自动快照接收</div>
|
||||
<div class="insight-card__value">{{ snapshotRunHealth }}</div>
|
||||
<div class="insight-card__meta">
|
||||
最新接收 {{ snapshotRuns.latest_run?.received_count || 0 }},
|
||||
失败 {{ snapshotRuns.latest_run?.failed_count || 0 }},
|
||||
成功率 {{ Number(snapshotRuns.latest_run?.success_rate || 0).toFixed(0) }}%
|
||||
</div>
|
||||
</div>
|
||||
<div class="insight-card">
|
||||
<div class="insight-card__title">最近自动快照时间</div>
|
||||
<div class="insight-card__value">{{ formatDateTime(snapshotRuns.latest_run?.updated_at) }}</div>
|
||||
<div class="insight-card__meta">
|
||||
总回推 {{ snapshotRuns.total || 0 }} 笔,
|
||||
高优先 {{ snapshotRuns.alert_buckets?.high || 0 }},
|
||||
中优先 {{ snapshotRuns.alert_buckets?.medium || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="12" class="result-observe-dialog__lists">
|
||||
<el-col :span="12">
|
||||
<div class="list-card">
|
||||
<div class="list-card__title">当前重点词</div>
|
||||
<el-table :data="realSummary.top_queries || []" border max-height="220" size="small">
|
||||
<el-table-column prop="query" label="搜索词" min-width="180" />
|
||||
<el-table-column prop="impressions" label="展现" width="90" />
|
||||
</el-table>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<div class="list-card">
|
||||
<div class="list-card__title">当前重点页</div>
|
||||
<el-table :data="realSummary.top_pages || []" border max-height="220" size="small">
|
||||
<el-table-column prop="page" label="页面" min-width="200" />
|
||||
<el-table-column prop="impressions" label="展现" width="90" />
|
||||
</el-table>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<div class="result-observe-dialog__failure-box">
|
||||
<div class="list-card">
|
||||
<div class="list-card__title">最近失败的自动快照 Run</div>
|
||||
<div v-if="!attentionSnapshotRuns.length" class="list-card__empty">最近没有需要优先关注的自动快照异常</div>
|
||||
<el-table v-else :data="attentionSnapshotRuns" border max-height="220" size="small">
|
||||
<el-table-column prop="run_id" label="Run ID" min-width="200" />
|
||||
<el-table-column label="告警" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.alert_level === 'high'" type="danger" size="small">高</el-tag>
|
||||
<el-tag v-else-if="row.alert_level === 'medium'" type="warning" size="small">中</el-tag>
|
||||
<el-tag v-else size="small">低</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="alert_reason" label="原因" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column label="成功率" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ Number(row.success_rate || 0).toFixed(0) }}%
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="失败数" width="90">
|
||||
<template #default="{ row }">
|
||||
{{ row.failed_count || 0 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="更新时间" min-width="170">
|
||||
<template #default="{ row }">
|
||||
{{ formatDateTime(row.updated_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default>
|
||||
<el-button link type="primary" @click="openSnapshotRunsDialog">查看详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-descriptions :column="2" border size="small" class="result-observe-dialog__meta">
|
||||
<el-descriptions-item label="结果来源">{{ realSummary.provider_label || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="统计周期">{{ realSummary.days || 0 }} 天</el-descriptions-item>
|
||||
<el-descriptions-item label="结果更新时间">{{ formatDateTime(realSummary.generated_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="准备态更新时间">{{ formatDateTime(readinessSummary.generated_at) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
<DomainExternalSeoSnapshotRunDialog ref="snapshotRunsDialogRef" />
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainExternalSeoRealSummary, getDomainExternalSeoSnapshotRuns, getDomainExternalSeoSummary, getDomainExternalSeoTrendSummary } from "@/api/modules/site/list";
|
||||
import DomainExternalSeoSnapshotRunDialog from "./domainExternalSeoSnapshotRunDialog.vue";
|
||||
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const realSummary = ref<SiteList.DomainExternalSeoRealSummaryData>({});
|
||||
const readinessSummary = ref<SiteList.DomainExternalSeoSummaryData>({});
|
||||
const trendSummary = ref<SiteList.DomainExternalSeoTrendData>({});
|
||||
const snapshotRuns = ref<SiteList.DomainExternalSeoSnapshotRunData>({});
|
||||
const snapshotRunsDialogRef = ref();
|
||||
|
||||
const displayHeadlineMetrics = computed(() => {
|
||||
return (realSummary.value.headline_metrics || []).slice(0, 4);
|
||||
});
|
||||
|
||||
const heroTitle = computed(() => {
|
||||
if (realSummary.value.source_mode === "automation_snapshot") {
|
||||
return "每天先看这 4 个自动化信号:采集域名、疑似已收录、已起词、流量就绪";
|
||||
}
|
||||
if (realSummary.value.real_metrics_available) {
|
||||
return "每天先看这 4 个数字:展现、点击、点击率、平均排名";
|
||||
}
|
||||
return "当前还没有稳定的真实结果数据,先继续观察准备态和接入状态";
|
||||
});
|
||||
|
||||
const heroDescription = computed(() => {
|
||||
if (realSummary.value.source_mode === "automation_snapshot") {
|
||||
return "当前这块优先展示 Node 自动快照。更适合无人值守时先看收录、起词和流量信号,再决定要不要继续补料或观察。";
|
||||
}
|
||||
if (realSummary.value.real_metrics_available) {
|
||||
return "当展现开始持续增长、点击跟着起来,说明这批域名已经开始进入真实 SEO 结果观察期。";
|
||||
}
|
||||
return realSummary.value.note || "当前还在接入真实结果阶段。";
|
||||
});
|
||||
|
||||
const snapshotRunHealth = computed(() => {
|
||||
if (Number(snapshotRuns.value.alert_buckets?.high || 0) > 0) return "有高优先异常";
|
||||
if (Number(snapshotRuns.value.alert_buckets?.medium || 0) > 0) return "有中优先异常";
|
||||
if (Number(snapshotRuns.value.latest_run?.received_count || 0) > 0) return "最近回推稳定";
|
||||
return "等待新的自动快照";
|
||||
});
|
||||
|
||||
const attentionSnapshotRuns = computed(() => {
|
||||
return (snapshotRuns.value.top_attention_runs || []).slice(0, 5);
|
||||
});
|
||||
|
||||
const renderHeadlineMetric = (metric?: any) => {
|
||||
if (!metric) return "-";
|
||||
const value = metric.value;
|
||||
if (value === null || value === undefined || value === "") return "-";
|
||||
if (metric.format === "percent" && typeof value === "number") {
|
||||
return `${(value * 100).toFixed(2)}%`;
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const trendLabel = (label?: string) => {
|
||||
if (label === "improving") return "改善中";
|
||||
if (label === "worsening") return "上升中";
|
||||
return "持平";
|
||||
};
|
||||
|
||||
const formatDateTime = (value?: string) => {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
const pad = (num: number) => String(num).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
};
|
||||
|
||||
const openSummaryFile = (path?: string) => {
|
||||
if (!path) return;
|
||||
window.open(`${import.meta.env.VITE_API_URL}/${String(path).replace(/^\/+/, "")}`, "_blank", "noopener");
|
||||
};
|
||||
|
||||
const openSnapshotRunsDialog = () => {
|
||||
snapshotRunsDialogRef.value?.open?.();
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [realRes, summaryRes, trendRes, snapshotRunRes] = await Promise.all([
|
||||
getDomainExternalSeoRealSummary({ days: 7 }),
|
||||
getDomainExternalSeoSummary({ limit: 20 }),
|
||||
getDomainExternalSeoTrendSummary({ limit: 10 }),
|
||||
getDomainExternalSeoSnapshotRuns({ limit: 10 })
|
||||
]);
|
||||
realSummary.value = realRes.data ?? {};
|
||||
readinessSummary.value = summaryRes.data ?? {};
|
||||
trendSummary.value = trendRes.data ?? {};
|
||||
snapshotRuns.value = snapshotRunRes.data ?? {};
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取 SEO 结果观察面板失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadData();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.result-observe-dialog__toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.result-observe-dialog__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
|
||||
}
|
||||
|
||||
.metric-card--primary {
|
||||
border-color: #cfe3ff;
|
||||
background: linear-gradient(180deg, #eef6ff 0%, #ffffff 100%);
|
||||
}
|
||||
|
||||
.metric-card__label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.metric-card__value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #303133;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.metric-card__note {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.result-observe-dialog__insights {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.insight-card {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.insight-card__title {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.insight-card__value {
|
||||
color: #303133;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.insight-card__meta {
|
||||
margin-top: 8px;
|
||||
color: #606266;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.result-observe-dialog__lists {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.result-observe-dialog__failure-box {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.list-card {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.list-card__title {
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.list-card__empty {
|
||||
padding: 10px 2px;
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.result-observe-dialog__meta {
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
146
src/views/site/domainExternalSeoSearchConsoleFetchDialog.vue
Normal file
146
src/views/site/domainExternalSeoSearchConsoleFetchDialog.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="Search Console Fetch 摘要" width="1080px" draggable>
|
||||
<div class="search-console-fetch-dialog">
|
||||
<div class="search-console-fetch-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadSummary">刷新</el-button>
|
||||
<el-button v-if="summary.summary_html_path" type="success" plain @click="openPublicPath(summary.summary_html_path)">HTML摘要</el-button>
|
||||
<el-button v-if="summary.summary_json_path" type="info" plain @click="openPublicPath(summary.summary_json_path)">JSON摘要</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
:title="summary.status || 'planned'"
|
||||
:description="summary.note || '当前这里展示 Search Console fetch 骨架。'"
|
||||
:type="summary.client_dependency_ready ? 'success' : 'warning'"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<div class="search-console-fetch-dialog__metrics">
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">请求数</div>
|
||||
<div class="metric-card__value">{{ summary.planned_request_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">凭证</div>
|
||||
<div class="metric-card__value">{{ summary.credential_ready ? "ready" : "missing" }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">映射</div>
|
||||
<div class="metric-card__value">{{ summary.property_map_ready ? "ready" : "missing" }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">Client</div>
|
||||
<div class="metric-card__value">{{ summary.client_dependency_ready ? "ready" : "missing" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="blocked_reason">{{ summary.blocked_reason || "-" }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="search-console-fetch-dialog__steps">
|
||||
<div class="search-console-fetch-dialog__title">下一步</div>
|
||||
<ul>
|
||||
<li v-for="(step, index) in summary.next_steps || []" :key="`${index}-${step}`">{{ step }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="summary.request_plan || []" border max-height="360">
|
||||
<el-table-column prop="host" label="Host" min-width="180" />
|
||||
<el-table-column prop="site_url" label="Site URL" min-width="260" />
|
||||
<el-table-column prop="property_scope" label="Scope" width="120" />
|
||||
<el-table-column label="Dimensions" min-width="160">
|
||||
<template #default="{ row }">{{ (row.request_dimensions || []).join(", ") || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Metrics" min-width="220">
|
||||
<template #default="{ row }">{{ (row.request_metrics || []).join(", ") || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Date Range" min-width="180">
|
||||
<template #default="{ row }">
|
||||
{{ row.date_range?.start_date || "-" }} ~ {{ row.date_range?.end_date || "-" }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="request_status" label="请求状态" width="140" />
|
||||
</el-table>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainExternalSeoSearchConsoleFetchSummary } from "@/api/modules/site/list";
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL as string;
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const summary = ref<SiteList.DomainExternalSeoSearchConsoleFetchData>({});
|
||||
|
||||
const openPublicPath = (path?: string) => {
|
||||
if (!path) return;
|
||||
window.open(`${BASE_URL}/${String(path).replace(/^\/+/, "")}`, "_blank", "noopener");
|
||||
};
|
||||
|
||||
const loadSummary = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDomainExternalSeoSearchConsoleFetchSummary({ days: 7 });
|
||||
summary.value = res.data ?? {};
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取 Search Console fetch 摘要失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadSummary();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.search-console-fetch-dialog__toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.search-console-fetch-dialog__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.metric-card__label {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.metric-card__value {
|
||||
color: #303133;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-console-fetch-dialog__steps {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.search-console-fetch-dialog__title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
147
src/views/site/domainExternalSeoSearchConsolePlanDialog.vue
Normal file
147
src/views/site/domainExternalSeoSearchConsolePlanDialog.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="Search Console Provider 计划" width="980px" draggable>
|
||||
<div class="search-console-plan-dialog">
|
||||
<div class="search-console-plan-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadSummary">刷新</el-button>
|
||||
<el-button type="warning" plain @click="openFetchDialog">Fetch 摘要</el-button>
|
||||
<el-button v-if="summary.summary_html_path" type="success" plain @click="openPublicPath(summary.summary_html_path)">HTML摘要</el-button>
|
||||
<el-button v-if="summary.summary_json_path" type="info" plain @click="openPublicPath(summary.summary_json_path)">JSON摘要</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
:title="summary.status || 'planned'"
|
||||
:description="summary.note || '当前这里展示 Search Console provider 的准备态。'"
|
||||
:type="summary.client_dependency_ready ? 'success' : 'warning'"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<div class="search-console-plan-dialog__metrics">
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">凭证</div>
|
||||
<div class="metric-card__value">{{ summary.credential_ready ? "ready" : "missing" }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">映射</div>
|
||||
<div class="metric-card__value">{{ summary.property_map_ready ? "ready" : "missing" }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">Client</div>
|
||||
<div class="metric-card__value">{{ summary.client_dependency_ready ? "ready" : "missing" }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">Mapped Hosts</div>
|
||||
<div class="metric-card__value">{{ summary.mapped_hosts_count || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="credential_path">{{ summary.credential_path || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="property_map_path">{{ summary.property_map_path || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="fetch_scope">
|
||||
{{ (summary.fetch_scope?.dimensions || []).join(", ") || "-" }} /
|
||||
{{ (summary.fetch_scope?.aggregations || []).join(", ") || "-" }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="search-console-plan-dialog__steps">
|
||||
<div class="search-console-plan-dialog__title">下一步</div>
|
||||
<ul>
|
||||
<li v-for="(step, index) in summary.next_steps || []" :key="`${index}-${step}`">{{ step }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="summary.properties_preview || []" border max-height="320">
|
||||
<el-table-column prop="host" label="Host" min-width="180" />
|
||||
<el-table-column prop="site_url" label="Site URL" min-width="280" />
|
||||
<el-table-column prop="property_scope" label="Property Scope" width="140" />
|
||||
</el-table>
|
||||
</div>
|
||||
</el-dialog>
|
||||
<DomainExternalSeoSearchConsoleFetchDialog ref="fetchDialogRef" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainExternalSeoSearchConsoleProviderPlan } from "@/api/modules/site/list";
|
||||
import DomainExternalSeoSearchConsoleFetchDialog from "./domainExternalSeoSearchConsoleFetchDialog.vue";
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL as string;
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const summary = ref<SiteList.DomainExternalSeoSearchConsoleProviderPlanData>({});
|
||||
const fetchDialogRef = ref();
|
||||
|
||||
const openPublicPath = (path?: string) => {
|
||||
if (!path) return;
|
||||
window.open(`${BASE_URL}/${String(path).replace(/^\/+/, "")}`, "_blank", "noopener");
|
||||
};
|
||||
|
||||
const openFetchDialog = () => {
|
||||
fetchDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const loadSummary = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDomainExternalSeoSearchConsoleProviderPlan({ days: 7 });
|
||||
summary.value = res.data ?? {};
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取 Search Console provider 计划失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadSummary();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.search-console-plan-dialog__toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.search-console-plan-dialog__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.metric-card__label {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.metric-card__value {
|
||||
color: #303133;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-console-plan-dialog__steps {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.search-console-plan-dialog__title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
144
src/views/site/domainExternalSeoSiteQueryDialog.vue
Normal file
144
src/views/site/domainExternalSeoSiteQueryDialog.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="百度 site: 收录探测" width="1180px" draggable>
|
||||
<div class="site-query-dialog">
|
||||
<div class="site-query-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadSummary">刷新</el-button>
|
||||
<el-button v-if="summary.summary_html_path" type="success" plain @click="openPublicPath(summary.summary_html_path)">HTML摘要</el-button>
|
||||
<el-button v-if="summary.summary_json_path" type="info" plain @click="openPublicPath(summary.summary_json_path)">JSON摘要</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
:title="alertTitle"
|
||||
:description="summary.note || '这里优先沉淀 site: 收录探测状态。当前如果命中百度安全验证,也会明确标成 captcha,而不是假装有精确收录量。'"
|
||||
:type="alertType"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<div class="site-query-dialog__metrics">
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">Hosts</div>
|
||||
<div class="metric-card__value">{{ summary.host_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">疑似收录</div>
|
||||
<div class="metric-card__value">{{ summary.status_buckets?.indexed_like || 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">Captcha</div>
|
||||
<div class="metric-card__value">{{ summary.status_buckets?.captcha || 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">No Result</div>
|
||||
<div class="metric-card__value">{{ summary.status_buckets?.no_result || 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">Blocked</div>
|
||||
<div class="metric-card__value">{{ summary.status_buckets?.blocked || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="summary.items || []" border max-height="460">
|
||||
<el-table-column prop="host" label="Host" min-width="180" />
|
||||
<el-table-column prop="query_status" label="状态" width="110" />
|
||||
<el-table-column prop="engine_variant" label="Variant" width="110" />
|
||||
<el-table-column prop="http_status" label="HTTP" width="80" />
|
||||
<el-table-column prop="page_title" label="页面标题" min-width="180" />
|
||||
<el-table-column prop="result_count_text" label="结果文本" min-width="180" />
|
||||
<el-table-column label="Captcha" width="90">
|
||||
<template #default="{ row }">{{ row.captcha_detected ? "yes" : "no" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Host 命中" width="100">
|
||||
<template #default="{ row }">{{ row.has_host_mention ? "yes" : "no" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="probed_at" label="探测时间" min-width="180" />
|
||||
</el-table>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainExternalSeoSiteQueryProbeSummary } from "@/api/modules/site/list";
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL as string;
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const summary = ref<SiteList.DomainExternalSeoSiteQueryProbeData>({});
|
||||
|
||||
const openPublicPath = (path?: string) => {
|
||||
if (!path) return;
|
||||
window.open(`${BASE_URL}/${String(path).replace(/^\/+/, "")}`, "_blank", "noopener");
|
||||
};
|
||||
|
||||
const alertTitle = computed(() => {
|
||||
const indexedLike = summary.value.status_buckets?.indexed_like || 0;
|
||||
const captcha = summary.value.status_buckets?.captcha || 0;
|
||||
if (indexedLike > 0) return "当前已有部分域名疑似命中百度收录";
|
||||
if (captcha > 0) return "当前探测主要被百度安全验证拦截";
|
||||
return "当前还没有拿到明确收录信号";
|
||||
});
|
||||
|
||||
const alertType = computed(() => {
|
||||
const indexedLike = summary.value.status_buckets?.indexed_like || 0;
|
||||
const captcha = summary.value.status_buckets?.captcha || 0;
|
||||
if (indexedLike > 0) return "success";
|
||||
if (captcha > 0) return "warning";
|
||||
return "info";
|
||||
});
|
||||
|
||||
const loadSummary = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDomainExternalSeoSiteQueryProbeSummary({ limit: 50 });
|
||||
summary.value = res.data ?? {};
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取百度 site: 收录探测失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadSummary();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.site-query-dialog__toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.site-query-dialog__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.metric-card__label {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.metric-card__value {
|
||||
color: #303133;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
87
src/views/site/domainExternalSeoSnapshotChecklistDialog.vue
Normal file
87
src/views/site/domainExternalSeoSnapshotChecklistDialog.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="站外快照字段校验清单" width="980px" draggable>
|
||||
<div class="snapshot-checklist-dialog">
|
||||
<div class="snapshot-checklist-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadSummary">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
:title="summary.note || '字段清单'"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
|
||||
<div class="snapshot-checklist-dialog__common">
|
||||
<span class="snapshot-checklist-dialog__label">通用必填:</span>
|
||||
<el-tag v-for="item in summary.common_required || []" :key="item" size="small" class="snapshot-checklist-dialog__tag">{{ item }}</el-tag>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="summary.rows || []" border>
|
||||
<el-table-column prop="provider" label="Provider" min-width="140" />
|
||||
<el-table-column prop="snapshot_type" label="Snapshot Type" min-width="180" />
|
||||
<el-table-column prop="scope" label="Scope" width="100" />
|
||||
<el-table-column label="必填字段" min-width="240">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-for="item in row.required || []" :key="item" size="small" class="snapshot-checklist-dialog__tag">{{ item }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="推荐字段" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-for="item in row.recommended || []" :key="item" size="small" type="success" class="snapshot-checklist-dialog__tag">{{ item }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainExternalSeoSnapshotChecklist } from "@/api/modules/site/list";
|
||||
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const summary = ref<SiteList.DomainExternalSeoSnapshotChecklistData>({});
|
||||
|
||||
const loadSummary = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDomainExternalSeoSnapshotChecklist();
|
||||
summary.value = res.data ?? {};
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取站外快照字段校验清单失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadSummary();
|
||||
};
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.snapshot-checklist-dialog__toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.snapshot-checklist-dialog__common {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.snapshot-checklist-dialog__label {
|
||||
margin-right: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.snapshot-checklist-dialog__tag {
|
||||
margin-right: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
</style>
|
||||
364
src/views/site/domainExternalSeoSnapshotRunDialog.vue
Normal file
364
src/views/site/domainExternalSeoSnapshotRunDialog.vue
Normal file
@@ -0,0 +1,364 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="最近站外快照接收记录" width="1080px" draggable>
|
||||
<div class="snapshot-run-dialog">
|
||||
<div class="snapshot-run-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadRuns">刷新</el-button>
|
||||
</div>
|
||||
<div v-if="summary" class="snapshot-run-dialog__summary">
|
||||
<el-alert
|
||||
:title="buildSummaryTitle(summary)"
|
||||
:type="summary.alert_buckets?.high ? 'error' : summary.alert_buckets?.medium ? 'warning' : 'success'"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="summary" class="snapshot-run-dialog__hero">
|
||||
<div class="snapshot-run-dialog__hero-main">
|
||||
<div class="snapshot-run-dialog__hero-label">最近回推总览</div>
|
||||
<div class="snapshot-run-dialog__hero-value">{{ buildSnapshotHealth(summary) }}</div>
|
||||
<div class="snapshot-run-dialog__hero-note">
|
||||
先看高优先级 run 数量,再看最新一笔回推是否把结果面带起来。
|
||||
</div>
|
||||
</div>
|
||||
<div class="snapshot-run-dialog__hero-metrics">
|
||||
<div v-for="item in buildHeroMetrics(summary)" :key="item.label" class="snapshot-run-dialog__metric">
|
||||
<div class="snapshot-run-dialog__metric-label">{{ item.label }}</div>
|
||||
<div class="snapshot-run-dialog__metric-value">{{ item.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="summary" class="snapshot-run-dialog__attention">
|
||||
<div class="snapshot-run-dialog__attention-box">
|
||||
<div class="snapshot-run-dialog__attention-title">优先关注的回推 Run</div>
|
||||
<div v-if="!(summary.top_attention_runs || []).length" class="snapshot-run-dialog__attention-empty">当前没有高优先级回推异常</div>
|
||||
<div v-else class="snapshot-run-dialog__attention-list">
|
||||
<div v-for="item in (summary.top_attention_runs || []).slice(0, 3)" :key="item.run_id" class="snapshot-run-dialog__attention-item">
|
||||
<div class="snapshot-run-dialog__attention-run">{{ item.run_id }}</div>
|
||||
<div class="snapshot-run-dialog__attention-reason">{{ item.alert_reason || "-" }}</div>
|
||||
<div class="snapshot-run-dialog__attention-meta">
|
||||
成功率 {{ Number(item.success_rate || 0).toFixed(0) }}% / 失败 {{ Number(item.failed_count || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="snapshot-run-dialog__attention-box">
|
||||
<div class="snapshot-run-dialog__attention-title">最新一笔结果面</div>
|
||||
<div class="snapshot-run-dialog__latest-grid">
|
||||
<div class="snapshot-run-dialog__mini">
|
||||
<div class="snapshot-run-dialog__mini-label">Run</div>
|
||||
<div class="snapshot-run-dialog__mini-value snapshot-run-dialog__mini-value--break">
|
||||
{{ summary.latest_run?.run_id || "-" }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="snapshot-run-dialog__mini">
|
||||
<div class="snapshot-run-dialog__mini-label">结果健康度</div>
|
||||
<div class="snapshot-run-dialog__mini-value">{{ summary.latest_run?.post_ingest_health_label || "-" }}</div>
|
||||
</div>
|
||||
<div class="snapshot-run-dialog__mini">
|
||||
<div class="snapshot-run-dialog__mini-label">接收趋势</div>
|
||||
<div class="snapshot-run-dialog__mini-value">{{ summary.latest_run?.post_ingest_trend_label || "-" }}</div>
|
||||
</div>
|
||||
<div class="snapshot-run-dialog__mini">
|
||||
<div class="snapshot-run-dialog__mini-label">告警</div>
|
||||
<div class="snapshot-run-dialog__mini-value">{{ summary.latest_run?.alert_reason || "-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="rows" border>
|
||||
<el-table-column prop="run_id" label="Run ID" min-width="180" />
|
||||
<el-table-column prop="source" label="Source" width="120" />
|
||||
<el-table-column prop="received_count" label="接收" width="90" />
|
||||
<el-table-column prop="inserted_count" label="新增" width="90" />
|
||||
<el-table-column prop="updated_count" label="更新" width="90" />
|
||||
<el-table-column prop="failed_count" label="失败" width="90" />
|
||||
<el-table-column label="成功率" width="100">
|
||||
<template #default="{ row }">
|
||||
<span>{{ Number(row.success_rate || 0).toFixed(0) }}%</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="告警" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.alert_level && row.alert_level !== 'none'" :type="row.alert_level === 'high' ? 'danger' : 'warning'" size="small">
|
||||
{{ row.alert_level }}
|
||||
</el-tag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="结果健康度" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.post_ingest_health_label" size="small">{{ row.post_ingest_health_label }}</el-tag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="接收趋势" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.post_ingest_trend_label" size="small" type="success">{{ row.post_ingest_trend_label }}</el-tag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="失败聚合" min-width="240">
|
||||
<template #default="{ row }">
|
||||
<span v-if="!(row.error_buckets || []).length">-</span>
|
||||
<span v-else>
|
||||
{{
|
||||
(row.error_buckets || [])
|
||||
.slice(0, 3)
|
||||
.map((item: any) => `${item.message} x${item.count}`)
|
||||
.join(" ; ")
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updated_at" label="更新时间" min-width="180" />
|
||||
<el-table-column label="操作" width="320" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.summary_html_path" link type="primary" @click="openPublicPath(row.summary_html_path)">HTML摘要</el-button>
|
||||
<el-button v-if="row.summary_json_path" link type="info" @click="openPublicPath(row.summary_json_path)">JSON摘要</el-button>
|
||||
<el-button v-if="row.payload_json_path" link type="warning" @click="openPublicPath(row.payload_json_path)">原始快照</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainExternalSeoSnapshotRuns } from "@/api/modules/site/list";
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL as string;
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const rows = ref<SiteList.DomainExternalSeoSnapshotRunItem[]>([]);
|
||||
const summary = ref<SiteList.DomainExternalSeoSnapshotRunData | null>(null);
|
||||
|
||||
const openPublicPath = (path?: string) => {
|
||||
if (!path) return;
|
||||
window.open(`${BASE_URL}/${String(path).replace(/^\/+/, "")}`, "_blank", "noopener");
|
||||
};
|
||||
|
||||
const loadRuns = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDomainExternalSeoSnapshotRuns({ limit: 20 });
|
||||
summary.value = res.data ?? null;
|
||||
rows.value = res.data.items ?? [];
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取站外快照接收记录失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const buildSummaryTitle = (data?: SiteList.DomainExternalSeoSnapshotRunData | null) => {
|
||||
const high = Number(data?.alert_buckets?.high || 0);
|
||||
const medium = Number(data?.alert_buckets?.medium || 0);
|
||||
if (high > 0) {
|
||||
const item = (data?.top_attention_runs || [])[0];
|
||||
return `最近回推里有 ${high} 笔高优先级异常,先看 ${item?.run_id || "最新异常 run"}。`;
|
||||
}
|
||||
if (medium > 0) {
|
||||
const item = (data?.top_attention_runs || [])[0];
|
||||
return `最近回推里有 ${medium} 笔需要关注的异常,建议先看 ${item?.run_id || "异常 run"}。`;
|
||||
}
|
||||
return "最近回推整体稳定,当前没有高优先级接收异常。";
|
||||
};
|
||||
|
||||
const buildSnapshotHealth = (data?: SiteList.DomainExternalSeoSnapshotRunData | null) => {
|
||||
if (Number(data?.alert_buckets?.high || 0) > 0) return "高优先级异常待处理";
|
||||
if (Number(data?.alert_buckets?.medium || 0) > 0) return "回推可运行但需关注";
|
||||
if (Number(data?.latest_run?.success_rate || 0) > 0) return "最近回推整体稳定";
|
||||
return "等待新的回推数据";
|
||||
};
|
||||
|
||||
const buildHeroMetrics = (data?: SiteList.DomainExternalSeoSnapshotRunData | null) => {
|
||||
return [
|
||||
{
|
||||
label: "总回推笔数",
|
||||
value: `${Number(data?.total || 0)}`,
|
||||
},
|
||||
{
|
||||
label: "高优先级 Run",
|
||||
value: `${Number(data?.alert_buckets?.high || 0)}`,
|
||||
},
|
||||
{
|
||||
label: "最新成功率",
|
||||
value: `${Number(data?.latest_run?.success_rate || 0).toFixed(0)}%`,
|
||||
},
|
||||
{
|
||||
label: "最新失败数",
|
||||
value: `${Number(data?.latest_run?.failed_count || 0)}`,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadRuns();
|
||||
};
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.snapshot-run-dialog__toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.snapshot-run-dialog__summary {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.snapshot-run-dialog__hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.snapshot-run-dialog__hero-main {
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #0f766e 0%, #2563eb 100%);
|
||||
}
|
||||
|
||||
.snapshot-run-dialog__hero-label {
|
||||
font-size: 13px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.snapshot-run-dialog__hero-value {
|
||||
margin-top: 6px;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.snapshot-run-dialog__hero-note {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.snapshot-run-dialog__hero-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.snapshot-run-dialog__metric {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.snapshot-run-dialog__metric-label {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.snapshot-run-dialog__metric-value {
|
||||
margin-top: 8px;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.snapshot-run-dialog__attention {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.snapshot-run-dialog__attention-box {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.snapshot-run-dialog__attention-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.snapshot-run-dialog__attention-empty {
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.snapshot-run-dialog__attention-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.snapshot-run-dialog__attention-item {
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.snapshot-run-dialog__attention-run {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.snapshot-run-dialog__attention-reason {
|
||||
margin-top: 6px;
|
||||
color: #303133;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.snapshot-run-dialog__attention-meta {
|
||||
margin-top: 6px;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.snapshot-run-dialog__latest-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.snapshot-run-dialog__mini {
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.snapshot-run-dialog__mini-label {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.snapshot-run-dialog__mini-value {
|
||||
margin-top: 6px;
|
||||
color: #303133;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.snapshot-run-dialog__mini-value--break {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.snapshot-run-dialog__hero,
|
||||
.snapshot-run-dialog__attention,
|
||||
.snapshot-run-dialog__hero-metrics,
|
||||
.snapshot-run-dialog__latest-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
81
src/views/site/domainExternalSeoSnapshotSummaryDialog.vue
Normal file
81
src/views/site/domainExternalSeoSnapshotSummaryDialog.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="站外快照摘要" width="1180px" draggable>
|
||||
<div class="snapshot-summary-dialog">
|
||||
<div class="snapshot-summary-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadSummary">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
:title="summary.snapshot_count ? '当前已有历史快照可分析' : '当前还没有历史快照数据'"
|
||||
:description="summary.note || '这里展示 seo_external_snapshot 表里的历史快照摘要。'"
|
||||
:type="summary.snapshot_count ? 'success' : 'warning'"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<div class="snapshot-summary-dialog__metrics">
|
||||
<div class="metric-card"><div class="metric-card__label">快照数</div><div class="metric-card__value">{{ summary.snapshot_count || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">Host</div><div class="metric-card__value">{{ summary.host_count || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">indexed_like</div><div class="metric-card__value">{{ summary.indexed_like_host_count || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">keyword_ready</div><div class="metric-card__value">{{ summary.keyword_ready_host_count || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">traffic_ready</div><div class="metric-card__value">{{ summary.traffic_ready_host_count || 0 }}</div></div>
|
||||
</div>
|
||||
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="days">{{ summary.days || 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="latest_metric_date">{{ summary.latest_metric_date || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="providers">{{ (summary.providers || []).join(", ") || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="keyword_snapshot_count">{{ summary.keyword_snapshot_count || 0 }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-table v-loading="loading" :data="summary.hosts || []" border max-height="420">
|
||||
<el-table-column prop="host" label="Host" min-width="160" />
|
||||
<el-table-column prop="provider" label="Provider" width="120" />
|
||||
<el-table-column prop="metric_date" label="日期" width="110" />
|
||||
<el-table-column prop="indexed_status" label="收录状态" width="120" />
|
||||
<el-table-column prop="baidu_pc_ip_range" label="百度来路" width="120" />
|
||||
<el-table-column prop="baidu_mobile_ip_range" label="移动来路" width="120" />
|
||||
<el-table-column prop="pc_keyword_count" label="PC词数" width="90" />
|
||||
<el-table-column prop="mobile_keyword_count" label="移动词数" width="90" />
|
||||
</el-table>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainExternalSeoSnapshotSummary } from "@/api/modules/site/list";
|
||||
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const summary = ref<SiteList.DomainExternalSeoSnapshotSummaryData>({});
|
||||
|
||||
const loadSummary = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDomainExternalSeoSnapshotSummary({ days: 7, limit: 20 });
|
||||
summary.value = res.data ?? {};
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取站外快照摘要失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadSummary();
|
||||
};
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.snapshot-summary-dialog__toolbar { margin-bottom: 12px; }
|
||||
.snapshot-summary-dialog__metrics { display:grid; grid-template-columns:repeat(5,minmax(120px,1fr)); gap:12px; margin:16px 0; }
|
||||
.metric-card { border:1px solid #ebeef5; border-radius:8px; padding:12px; background:#fafafa; }
|
||||
.metric-card__label { color:#909399; font-size:12px; margin-bottom:6px; }
|
||||
.metric-card__value { color:#303133; font-size:22px; font-weight:600; }
|
||||
</style>
|
||||
173
src/views/site/domainExternalSeoSnapshotTrendDialog.vue
Normal file
173
src/views/site/domainExternalSeoSnapshotTrendDialog.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="快照接收趋势" width="980px" draggable>
|
||||
<div class="snapshot-trend-dialog">
|
||||
<div class="snapshot-trend-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadSummary">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
:title="summary.success_trend?.label ? `当前趋势:${buildTrendLabel(summary.success_trend.label)}` : '当前还没有快照接收趋势数据'"
|
||||
:description="summary.note || '这里展示最近几次 snapshots/save 的成功率变化。'"
|
||||
:type="buildTrendType(summary.success_trend?.label)"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<div class="snapshot-trend-dialog__hero">
|
||||
<div class="snapshot-trend-dialog__hero-main">
|
||||
<div class="snapshot-trend-dialog__hero-label">接收趋势面板</div>
|
||||
<div class="snapshot-trend-dialog__hero-value">{{ buildTrendLabel(summary.success_trend?.label) }}</div>
|
||||
<div class="snapshot-trend-dialog__hero-note">
|
||||
先看最近成功率和失败数是在改善还是变差,再决定是否缩批次或继续放量。
|
||||
</div>
|
||||
</div>
|
||||
<div class="snapshot-trend-dialog__hero-metrics">
|
||||
<div v-for="item in buildHeroMetrics(summary)" :key="item.label" class="metric-card metric-card--hero">
|
||||
<div class="metric-card__label">{{ item.label }}</div>
|
||||
<div class="metric-card__value">{{ item.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="snapshot-trend-dialog__metrics">
|
||||
<div class="metric-card"><div class="metric-card__label">runs</div><div class="metric-card__value">{{ summary.runs_count || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">latest success%</div><div class="metric-card__value">{{ summary.success_trend?.latest_success_rate ?? 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">prev success%</div><div class="metric-card__value">{{ summary.success_trend?.previous_success_rate ?? 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">latest failed</div><div class="metric-card__value">{{ summary.success_trend?.latest_failed_count ?? 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">prev failed</div><div class="metric-card__value">{{ summary.success_trend?.previous_failed_count ?? 0 }}</div></div>
|
||||
</div>
|
||||
|
||||
<div class="snapshot-trend-dialog__attention">
|
||||
<div class="snapshot-trend-dialog__attention-box">
|
||||
<div class="snapshot-trend-dialog__attention-title">最新一笔回推</div>
|
||||
<div class="snapshot-trend-dialog__attention-grid">
|
||||
<div class="snapshot-trend-dialog__mini">
|
||||
<div class="snapshot-trend-dialog__mini-label">Run</div>
|
||||
<div class="snapshot-trend-dialog__mini-value snapshot-trend-dialog__mini-value--break">{{ summary.latest_run?.run_id || "-" }}</div>
|
||||
</div>
|
||||
<div class="snapshot-trend-dialog__mini">
|
||||
<div class="snapshot-trend-dialog__mini-label">Source</div>
|
||||
<div class="snapshot-trend-dialog__mini-value">{{ summary.latest_run?.source || "-" }}</div>
|
||||
</div>
|
||||
<div class="snapshot-trend-dialog__mini">
|
||||
<div class="snapshot-trend-dialog__mini-label">成功率</div>
|
||||
<div class="snapshot-trend-dialog__mini-value">{{ summary.latest_run?.success_rate ?? 0 }}%</div>
|
||||
</div>
|
||||
<div class="snapshot-trend-dialog__mini">
|
||||
<div class="snapshot-trend-dialog__mini-label">失败数</div>
|
||||
<div class="snapshot-trend-dialog__mini-value">{{ summary.latest_run?.failed_count ?? 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="summary.recent_runs || []" border>
|
||||
<el-table-column prop="run_id" label="Run ID" min-width="180" />
|
||||
<el-table-column prop="source" label="Source" width="120" />
|
||||
<el-table-column prop="received_count" label="接收" width="90" />
|
||||
<el-table-column prop="inserted_count" label="新增" width="90" />
|
||||
<el-table-column prop="updated_count" label="更新" width="90" />
|
||||
<el-table-column prop="failed_count" label="失败" width="90" />
|
||||
<el-table-column prop="success_rate" label="成功率%" width="100" />
|
||||
<el-table-column prop="updated_at" label="更新时间" min-width="180" />
|
||||
</el-table>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainExternalSeoSnapshotTrend } from "@/api/modules/site/list";
|
||||
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const summary = ref<SiteList.DomainExternalSeoSnapshotTrendData>({});
|
||||
|
||||
const loadSummary = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDomainExternalSeoSnapshotTrend({ limit: 10 });
|
||||
summary.value = res.data ?? {};
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取快照接收趋势失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const buildTrendType = (label?: string) => {
|
||||
if (label === "improving") return "success";
|
||||
if (label === "worsening") return "warning";
|
||||
return "info";
|
||||
};
|
||||
|
||||
const buildTrendLabel = (label?: string) => {
|
||||
if (label === "improving") return "趋势改善中";
|
||||
if (label === "worsening") return "趋势转差";
|
||||
if (label === "flat") return "趋势持平";
|
||||
return "等待趋势数据";
|
||||
};
|
||||
|
||||
const buildHeroMetrics = (data: SiteList.DomainExternalSeoSnapshotTrendData) => {
|
||||
return [
|
||||
{ label: "最新成功率", value: `${data.success_trend?.latest_success_rate ?? 0}%` },
|
||||
{ label: "上一笔成功率", value: `${data.success_trend?.previous_success_rate ?? 0}%` },
|
||||
{ label: "最新失败数", value: `${data.success_trend?.latest_failed_count ?? 0}` },
|
||||
{ label: "上一笔失败数", value: `${data.success_trend?.previous_failed_count ?? 0}` },
|
||||
];
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadSummary();
|
||||
};
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.snapshot-trend-dialog__toolbar { margin-bottom: 12px; }
|
||||
.snapshot-trend-dialog__hero {
|
||||
display:grid;
|
||||
grid-template-columns:minmax(0,1.1fr) minmax(0,1fr);
|
||||
gap:12px;
|
||||
margin:16px 0;
|
||||
}
|
||||
.snapshot-trend-dialog__hero-main {
|
||||
border-radius:12px;
|
||||
padding:16px;
|
||||
color:#fff;
|
||||
background:linear-gradient(135deg, #1d4ed8 0%, #0f766e 100%);
|
||||
}
|
||||
.snapshot-trend-dialog__hero-label { font-size:13px; opacity:.86; }
|
||||
.snapshot-trend-dialog__hero-value { margin-top:6px; font-size:28px; font-weight:700; }
|
||||
.snapshot-trend-dialog__hero-note { margin-top:8px; font-size:13px; line-height:1.6; opacity:.92; }
|
||||
.snapshot-trend-dialog__hero-metrics {
|
||||
display:grid;
|
||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||
gap:12px;
|
||||
}
|
||||
.snapshot-trend-dialog__metrics { display:grid; grid-template-columns:repeat(5,minmax(120px,1fr)); gap:12px; margin:16px 0; }
|
||||
.snapshot-trend-dialog__attention { margin:0 0 16px; }
|
||||
.snapshot-trend-dialog__attention-box { border:1px solid #ebeef5; border-radius:12px; padding:14px; background:#fff; }
|
||||
.snapshot-trend-dialog__attention-title { font-weight:600; margin-bottom:10px; }
|
||||
.snapshot-trend-dialog__attention-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; }
|
||||
.snapshot-trend-dialog__mini { padding:10px; border-radius:10px; background:#fafafa; }
|
||||
.snapshot-trend-dialog__mini-label { color:#909399; font-size:12px; }
|
||||
.snapshot-trend-dialog__mini-value { margin-top:6px; color:#303133; font-weight:600; line-height:1.5; }
|
||||
.snapshot-trend-dialog__mini-value--break { word-break:break-all; }
|
||||
.metric-card { border:1px solid #ebeef5; border-radius:12px; padding:12px; background:#fafafa; }
|
||||
.metric-card--hero { background:#fafafa; }
|
||||
.metric-card__label { color:#909399; font-size:12px; margin-bottom:6px; }
|
||||
.metric-card__value { color:#303133; font-size:22px; font-weight:600; }
|
||||
@media (max-width: 980px) {
|
||||
.snapshot-trend-dialog__hero,
|
||||
.snapshot-trend-dialog__hero-metrics,
|
||||
.snapshot-trend-dialog__metrics,
|
||||
.snapshot-trend-dialog__attention-grid {
|
||||
grid-template-columns:1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
371
src/views/site/domainExternalSeoSnapshotValidateRunDialog.vue
Normal file
371
src/views/site/domainExternalSeoSnapshotValidateRunDialog.vue
Normal file
@@ -0,0 +1,371 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="最近站外快照校验记录" width="1080px" draggable>
|
||||
<div class="snapshot-validate-run-dialog">
|
||||
<div class="snapshot-validate-run-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadRuns">刷新</el-button>
|
||||
</div>
|
||||
<div v-if="summary" class="snapshot-validate-run-dialog__summary">
|
||||
<el-alert
|
||||
:title="buildSummaryTitle(summary)"
|
||||
:type="summary.alert_buckets?.high ? 'error' : summary.alert_buckets?.medium ? 'warning' : 'success'"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="summary" class="snapshot-validate-run-dialog__hero">
|
||||
<div class="snapshot-validate-run-dialog__hero-main">
|
||||
<div class="snapshot-validate-run-dialog__hero-label">最近 dry-run 总览</div>
|
||||
<div class="snapshot-validate-run-dialog__hero-value">{{ buildValidateHealth(summary) }}</div>
|
||||
<div class="snapshot-validate-run-dialog__hero-note">
|
||||
先看高优先级 dry-run 数量,再看最新一笔字段校验是否已经恢复通过。
|
||||
</div>
|
||||
</div>
|
||||
<div class="snapshot-validate-run-dialog__hero-metrics">
|
||||
<div v-for="item in buildHeroMetrics(summary)" :key="item.label" class="snapshot-validate-run-dialog__metric">
|
||||
<div class="snapshot-validate-run-dialog__metric-label">{{ item.label }}</div>
|
||||
<div class="snapshot-validate-run-dialog__metric-value">{{ item.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="summary" class="snapshot-validate-run-dialog__attention">
|
||||
<div class="snapshot-validate-run-dialog__attention-box">
|
||||
<div class="snapshot-validate-run-dialog__attention-title">优先关注的 dry-run</div>
|
||||
<div v-if="!(summary.top_attention_runs || []).length" class="snapshot-validate-run-dialog__attention-empty">当前没有高优先级 dry-run 异常</div>
|
||||
<div v-else class="snapshot-validate-run-dialog__attention-list">
|
||||
<div v-for="item in (summary.top_attention_runs || []).slice(0, 3)" :key="item.run_id" class="snapshot-validate-run-dialog__attention-item">
|
||||
<div class="snapshot-validate-run-dialog__attention-run">{{ item.run_id }}</div>
|
||||
<div class="snapshot-validate-run-dialog__attention-reason">{{ item.alert_reason || "-" }}</div>
|
||||
<div class="snapshot-validate-run-dialog__attention-meta">
|
||||
通过率 {{ Number(item.success_rate || 0).toFixed(0) }}% / 错误 {{ Number(item.error_count || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="snapshot-validate-run-dialog__attention-box">
|
||||
<div class="snapshot-validate-run-dialog__attention-title">最新一笔字段校验</div>
|
||||
<div class="snapshot-validate-run-dialog__latest-grid">
|
||||
<div class="snapshot-validate-run-dialog__mini">
|
||||
<div class="snapshot-validate-run-dialog__mini-label">Run</div>
|
||||
<div class="snapshot-validate-run-dialog__mini-value snapshot-validate-run-dialog__mini-value--break">
|
||||
{{ summary.latest_run?.run_id || "-" }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="snapshot-validate-run-dialog__mini">
|
||||
<div class="snapshot-validate-run-dialog__mini-label">状态</div>
|
||||
<div class="snapshot-validate-run-dialog__mini-value">{{ summary.latest_run?.passed ? "passed" : "failed" }}</div>
|
||||
</div>
|
||||
<div class="snapshot-validate-run-dialog__mini">
|
||||
<div class="snapshot-validate-run-dialog__mini-label">通过率</div>
|
||||
<div class="snapshot-validate-run-dialog__mini-value">{{ Number(summary.latest_run?.success_rate || 0).toFixed(0) }}%</div>
|
||||
</div>
|
||||
<div class="snapshot-validate-run-dialog__mini">
|
||||
<div class="snapshot-validate-run-dialog__mini-label">主要原因</div>
|
||||
<div class="snapshot-validate-run-dialog__mini-value">{{ summary.latest_run?.alert_reason || "-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="rows" border>
|
||||
<el-table-column prop="run_id" label="Run ID" min-width="180" />
|
||||
<el-table-column prop="received_count" label="接收" width="90" />
|
||||
<el-table-column prop="valid_count" label="通过" width="90" />
|
||||
<el-table-column prop="error_count" label="错误" width="90" />
|
||||
<el-table-column prop="warning_count" label="警告" width="90" />
|
||||
<el-table-column label="通过率" width="100">
|
||||
<template #default="{ row }">
|
||||
<span>{{ Number(row.success_rate || 0).toFixed(0) }}%</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="告警" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.alert_level && row.alert_level !== 'none'" :type="row.alert_level === 'high' ? 'danger' : row.alert_level === 'medium' ? 'warning' : 'info'" size="small">
|
||||
{{ row.alert_level }}
|
||||
</el-tag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.passed ? 'success' : 'danger'" size="small">
|
||||
{{ row.passed ? "passed" : "failed" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="错误聚合" min-width="240">
|
||||
<template #default="{ row }">
|
||||
<span v-if="!(row.error_buckets || []).length">-</span>
|
||||
<span v-else>
|
||||
{{
|
||||
(row.error_buckets || [])
|
||||
.slice(0, 3)
|
||||
.map((item: any) => `${item.message} x${item.count}`)
|
||||
.join(" ; ")
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="警告聚合" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<span v-if="!(row.warning_buckets || []).length">-</span>
|
||||
<span v-else>
|
||||
{{
|
||||
(row.warning_buckets || [])
|
||||
.slice(0, 2)
|
||||
.map((item: any) => `${item.message} x${item.count}`)
|
||||
.join(" ; ")
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updated_at" label="更新时间" min-width="180" />
|
||||
<el-table-column label="操作" width="320" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.summary_html_path" link type="primary" @click="openPublicPath(row.summary_html_path)">HTML摘要</el-button>
|
||||
<el-button v-if="row.summary_json_path" link type="info" @click="openPublicPath(row.summary_json_path)">JSON摘要</el-button>
|
||||
<el-button v-if="row.payload_json_path" link type="warning" @click="openPublicPath(row.payload_json_path)">原始快照</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainExternalSeoSnapshotValidateRuns } from "@/api/modules/site/list";
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL as string;
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const rows = ref<SiteList.DomainExternalSeoSnapshotValidateRunItem[]>([]);
|
||||
const summary = ref<SiteList.DomainExternalSeoSnapshotValidateRunData | null>(null);
|
||||
|
||||
const openPublicPath = (path?: string) => {
|
||||
if (!path) return;
|
||||
window.open(`${BASE_URL}/${String(path).replace(/^\/+/, "")}`, "_blank", "noopener");
|
||||
};
|
||||
|
||||
const loadRuns = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDomainExternalSeoSnapshotValidateRuns({ limit: 20 });
|
||||
summary.value = res.data ?? null;
|
||||
rows.value = res.data.items ?? [];
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取站外快照校验记录失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const buildSummaryTitle = (data?: SiteList.DomainExternalSeoSnapshotValidateRunData | null) => {
|
||||
const high = Number(data?.alert_buckets?.high || 0);
|
||||
const medium = Number(data?.alert_buckets?.medium || 0);
|
||||
if (high > 0) {
|
||||
const item = (data?.top_attention_runs || [])[0];
|
||||
return `最近 dry-run 里有 ${high} 笔高优先级校验失败,先看 ${item?.run_id || "最新异常 run"}。`;
|
||||
}
|
||||
if (medium > 0) {
|
||||
const item = (data?.top_attention_runs || [])[0];
|
||||
return `最近 dry-run 里有 ${medium} 笔需要关注的字段问题,建议先看 ${item?.run_id || "异常 run"}。`;
|
||||
}
|
||||
return "最近 dry-run 整体稳定,当前没有高优先级字段校验问题。";
|
||||
};
|
||||
|
||||
const buildValidateHealth = (data?: SiteList.DomainExternalSeoSnapshotValidateRunData | null) => {
|
||||
if (Number(data?.alert_buckets?.high || 0) > 0) return "字段校验高风险";
|
||||
if (Number(data?.alert_buckets?.medium || 0) > 0) return "字段校验需关注";
|
||||
if (data?.latest_run?.passed) return "最近 dry-run 已恢复通过";
|
||||
return "等待新的 dry-run 数据";
|
||||
};
|
||||
|
||||
const buildHeroMetrics = (data?: SiteList.DomainExternalSeoSnapshotValidateRunData | null) => {
|
||||
return [
|
||||
{
|
||||
label: "总校验笔数",
|
||||
value: `${Number(data?.total || 0)}`,
|
||||
},
|
||||
{
|
||||
label: "高优先级 Run",
|
||||
value: `${Number(data?.alert_buckets?.high || 0)}`,
|
||||
},
|
||||
{
|
||||
label: "最新通过率",
|
||||
value: `${Number(data?.latest_run?.success_rate || 0).toFixed(0)}%`,
|
||||
},
|
||||
{
|
||||
label: "最新错误数",
|
||||
value: `${Number(data?.latest_run?.error_count || 0)}`,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadRuns();
|
||||
};
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.snapshot-validate-run-dialog__toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.snapshot-validate-run-dialog__summary {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.snapshot-validate-run-dialog__hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.snapshot-validate-run-dialog__hero-main {
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #9a3412 0%, #b91c1c 100%);
|
||||
}
|
||||
|
||||
.snapshot-validate-run-dialog__hero-label {
|
||||
font-size: 13px;
|
||||
opacity: 0.86;
|
||||
}
|
||||
|
||||
.snapshot-validate-run-dialog__hero-value {
|
||||
margin-top: 6px;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.snapshot-validate-run-dialog__hero-note {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.snapshot-validate-run-dialog__hero-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.snapshot-validate-run-dialog__metric {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.snapshot-validate-run-dialog__metric-label {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.snapshot-validate-run-dialog__metric-value {
|
||||
margin-top: 8px;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.snapshot-validate-run-dialog__attention {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.snapshot-validate-run-dialog__attention-box {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.snapshot-validate-run-dialog__attention-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.snapshot-validate-run-dialog__attention-empty {
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.snapshot-validate-run-dialog__attention-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.snapshot-validate-run-dialog__attention-item {
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.snapshot-validate-run-dialog__attention-run {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.snapshot-validate-run-dialog__attention-reason {
|
||||
margin-top: 6px;
|
||||
color: #303133;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.snapshot-validate-run-dialog__attention-meta {
|
||||
margin-top: 6px;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.snapshot-validate-run-dialog__latest-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.snapshot-validate-run-dialog__mini {
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.snapshot-validate-run-dialog__mini-label {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.snapshot-validate-run-dialog__mini-value {
|
||||
margin-top: 6px;
|
||||
color: #303133;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.snapshot-validate-run-dialog__mini-value--break {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.snapshot-validate-run-dialog__hero,
|
||||
.snapshot-validate-run-dialog__attention,
|
||||
.snapshot-validate-run-dialog__hero-metrics,
|
||||
.snapshot-validate-run-dialog__latest-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
214
src/views/site/domainExternalSeoSummaryDialog.vue
Normal file
214
src/views/site/domainExternalSeoSummaryDialog.vue
Normal file
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="站外效果摘要" width="1100px" draggable>
|
||||
<div class="external-seo-dialog">
|
||||
<div class="external-seo-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadSummary">刷新</el-button>
|
||||
<el-button type="warning" plain @click="openFailedQueueDialog">站外异常队列</el-button>
|
||||
<el-button type="info" plain @click="openTrendDialog">站外效果趋势</el-button>
|
||||
<el-button type="success" plain @click="openRealSummaryDialog">真实站外结果</el-button>
|
||||
<el-button type="primary" plain @click="openProviderDialog">站外数据接入</el-button>
|
||||
<el-button type="info" plain @click="openSearchConsolePlanDialog">Search Console 计划</el-button>
|
||||
<el-button v-if="summary.summary_html_path" type="success" plain @click="openPublicPath(summary.summary_html_path)">HTML摘要</el-button>
|
||||
<el-button v-if="summary.summary_json_path" type="info" plain @click="openPublicPath(summary.summary_json_path)">JSON摘要</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
:title="summary.real_external_metrics ? '当前已接入真实站外数据' : '当前展示的是上线前准备态摘要'"
|
||||
:description="summary.note || '当前还没有接入真实搜索引擎平台数据,这里先用导入探测、失败队列、重跑和趋势结果判断这批域名是否已经具备站外起量基础。'"
|
||||
:type="summary.real_external_metrics ? 'success' : 'warning'"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<el-alert
|
||||
v-if="summary.hero_summary"
|
||||
:title="summary.hero_summary"
|
||||
type="info"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="external-seo-dialog__hero"
|
||||
/>
|
||||
|
||||
<div class="external-seo-dialog__metrics">
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">域名数</div>
|
||||
<div class="metric-card__value">{{ summary.host_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">已恢复</div>
|
||||
<div class="metric-card__value">{{ summary.ready_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">待关注</div>
|
||||
<div class="metric-card__value">{{ summary.attention_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">关键词</div>
|
||||
<div class="metric-card__value">{{ summary.tracked_keyword_count || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="external-seo-dialog__summary">
|
||||
<span>失败趋势:{{ trendLabel() }}</span>
|
||||
<span>当前异常:{{ summary.queue_count || 0 }}</span>
|
||||
<span>重跑记录:{{ summary.rerun_runs_count || 0 }}</span>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="rows" border max-height="420">
|
||||
<el-table-column prop="host" label="域名" min-width="180" />
|
||||
<el-table-column prop="external_readiness" label="当前状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="readinessTagType(row.external_readiness)">{{ readinessLabel(row.external_readiness) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="probe_status" label="探测状态" width="110" />
|
||||
<el-table-column prop="failed_stage" label="失败阶段" width="110" />
|
||||
<el-table-column prop="search_keyword" label="关键词样本" min-width="180" />
|
||||
<el-table-column prop="sample_source" label="样本来源" min-width="140" />
|
||||
<el-table-column prop="probed_at" label="探测时间" min-width="180" />
|
||||
</el-table>
|
||||
</div>
|
||||
</el-dialog>
|
||||
<DomainExternalSeoFailedQueueDialog ref="failedQueueDialogRef" />
|
||||
<DomainExternalSeoTrendDialog ref="trendDialogRef" />
|
||||
<DomainExternalSeoRealSummaryDialog ref="realSummaryDialogRef" />
|
||||
<DomainExternalSeoProviderDialog ref="providerDialogRef" />
|
||||
<DomainExternalSeoSearchConsolePlanDialog ref="searchConsolePlanDialogRef" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainExternalSeoSummary } from "@/api/modules/site/list";
|
||||
import DomainExternalSeoFailedQueueDialog from "./domainExternalSeoFailedQueueDialog.vue";
|
||||
import DomainExternalSeoTrendDialog from "./domainExternalSeoTrendDialog.vue";
|
||||
import DomainExternalSeoRealSummaryDialog from "./domainExternalSeoRealSummaryDialog.vue";
|
||||
import DomainExternalSeoProviderDialog from "./domainExternalSeoProviderDialog.vue";
|
||||
import DomainExternalSeoSearchConsolePlanDialog from "./domainExternalSeoSearchConsolePlanDialog.vue";
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL as string;
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const rows = ref<SiteList.DomainExternalSeoHostItem[]>([]);
|
||||
const summary = ref<SiteList.DomainExternalSeoSummaryData>({});
|
||||
const failedQueueDialogRef = ref();
|
||||
const trendDialogRef = ref();
|
||||
const realSummaryDialogRef = ref();
|
||||
const providerDialogRef = ref();
|
||||
const searchConsolePlanDialogRef = ref();
|
||||
|
||||
const openPublicPath = (path?: string) => {
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
window.open(`${BASE_URL}/${String(path).replace(/^\/+/, "")}`, "_blank", "noopener");
|
||||
};
|
||||
|
||||
const trendLabel = () => {
|
||||
const label = summary.value.failure_trend?.label || "flat";
|
||||
if (label === "improving") return "改善中";
|
||||
if (label === "worsening") return "上升中";
|
||||
return "持平";
|
||||
};
|
||||
|
||||
const readinessLabel = (label?: string) => {
|
||||
if (label === "ready") return "已恢复";
|
||||
if (label === "attention") return "待关注";
|
||||
if (label === "probe_failed") return "探测失败";
|
||||
return label || "-";
|
||||
};
|
||||
|
||||
const readinessTagType = (label?: string) => {
|
||||
if (label === "ready") return "success";
|
||||
if (label === "attention") return "warning";
|
||||
if (label === "probe_failed") return "danger";
|
||||
return "info";
|
||||
};
|
||||
|
||||
const openFailedQueueDialog = () => {
|
||||
failedQueueDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const openTrendDialog = () => {
|
||||
trendDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const openRealSummaryDialog = () => {
|
||||
realSummaryDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const openProviderDialog = () => {
|
||||
providerDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const openSearchConsolePlanDialog = () => {
|
||||
searchConsolePlanDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const loadSummary = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDomainExternalSeoSummary({ limit: 20 });
|
||||
summary.value = res.data ?? {};
|
||||
rows.value = res.data.hosts ?? [];
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取站外效果摘要失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadSummary();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.external-seo-dialog__toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.external-seo-dialog__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.external-seo-dialog__hero {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.metric-card__label {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.metric-card__value {
|
||||
color: #303133;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.external-seo-dialog__summary {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin: 0 0 12px;
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
153
src/views/site/domainExternalSeoTrendDialog.vue
Normal file
153
src/views/site/domainExternalSeoTrendDialog.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="站外效果趋势" width="1100px" draggable>
|
||||
<div class="external-trend-dialog">
|
||||
<div class="external-trend-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadSummary">刷新</el-button>
|
||||
<el-button v-if="summary.summary_html_path" type="success" plain @click="openPublicPath(summary.summary_html_path)">HTML摘要</el-button>
|
||||
<el-button v-if="summary.summary_json_path" type="info" plain @click="openPublicPath(summary.summary_json_path)">JSON摘要</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
:title="summary.real_external_metrics ? '当前已接入真实站外趋势' : '当前展示的是站外恢复趋势'"
|
||||
:description="summary.note || '当前趋势基于导入探测、失败队列和重跑结果生成,用来看这批域名是否已经恢复到可起量状态。'"
|
||||
:type="summary.real_external_metrics ? 'success' : 'warning'"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<el-alert
|
||||
v-if="summary.hero_summary"
|
||||
:title="summary.hero_summary"
|
||||
type="info"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="external-trend-dialog__hero"
|
||||
/>
|
||||
|
||||
<div class="external-trend-dialog__metrics">
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">域名数</div>
|
||||
<div class="metric-card__value">{{ summary.host_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">已恢复</div>
|
||||
<div class="metric-card__value">{{ summary.ready_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">待关注</div>
|
||||
<div class="metric-card__value">{{ summary.attention_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">当前异常</div>
|
||||
<div class="metric-card__value">{{ summary.external_queue_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">趋势</div>
|
||||
<div class="metric-card__value">{{ trendLabel() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="summary.recent_reruns || []" border max-height="220">
|
||||
<el-table-column prop="run_id" label="最近重跑批次" min-width="160" />
|
||||
<el-table-column prop="passed_count" label="通过" width="90" />
|
||||
<el-table-column prop="failed_count" label="失败" width="90" />
|
||||
<el-table-column prop="updated_at" label="更新时间" min-width="180" />
|
||||
</el-table>
|
||||
|
||||
<div class="external-trend-dialog__spacer" />
|
||||
|
||||
<el-table v-loading="loading" :data="summary.recent_import_runs || []" border max-height="220">
|
||||
<el-table-column prop="run_id" label="最近导入批次" min-width="160" />
|
||||
<el-table-column prop="processed_count" label="探测" width="90" />
|
||||
<el-table-column prop="passed_count" label="通过" width="90" />
|
||||
<el-table-column prop="failed_count" label="失败" width="90" />
|
||||
<el-table-column prop="updated_at" label="更新时间" min-width="180" />
|
||||
</el-table>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainExternalSeoTrendSummary } from "@/api/modules/site/list";
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL as string;
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const summary = ref<SiteList.DomainExternalSeoTrendData>({});
|
||||
|
||||
const openPublicPath = (path?: string) => {
|
||||
if (!path) return;
|
||||
window.open(`${BASE_URL}/${String(path).replace(/^\/+/, "")}`, "_blank", "noopener");
|
||||
};
|
||||
|
||||
const trendLabel = () => {
|
||||
const label = summary.value.proxy_signal_trend?.label || "flat";
|
||||
if (label === "improving") return "改善中";
|
||||
if (label === "worsening") return "上升中";
|
||||
return "持平";
|
||||
};
|
||||
|
||||
const loadSummary = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDomainExternalSeoTrendSummary({ limit: 10 });
|
||||
summary.value = res.data ?? {};
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取站外效果趋势失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadSummary();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.external-trend-dialog__toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.external-trend-dialog__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.external-trend-dialog__hero {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.metric-card__label {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.metric-card__value {
|
||||
color: #303133;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.external-trend-dialog__spacer {
|
||||
height: 12px;
|
||||
}
|
||||
</style>
|
||||
203
src/views/site/domainImportExternalSeoClosureActionDialog.vue
Normal file
203
src/views/site/domainImportExternalSeoClosureActionDialog.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="闭环动作记录" width="980px" draggable>
|
||||
<div class="closure-action-dialog">
|
||||
<div class="closure-action-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadSummary">刷新</el-button>
|
||||
<el-button type="info" plain @click="openHostHistory()">按 Host 看历史</el-button>
|
||||
</div>
|
||||
<el-alert
|
||||
:title="heroTitle"
|
||||
:description="heroDescription"
|
||||
:type="heroType"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<div class="closure-action-dialog__metrics">
|
||||
<div class="metric-card"><div class="metric-card__label">动作记录</div><div class="metric-card__value">{{ summary.total || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">高优先</div><div class="metric-card__value">{{ summary.alert_buckets?.high || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">中优先</div><div class="metric-card__value">{{ summary.alert_buckets?.medium || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">待处理</div><div class="metric-card__value">{{ summary.status_buckets?.pending || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">观察中</div><div class="metric-card__value">{{ summary.status_buckets?.observing || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">改善</div><div class="metric-card__value">{{ summary.result_buckets?.improved || 0 }}</div></div>
|
||||
</div>
|
||||
|
||||
<div class="closure-action-dialog__panes">
|
||||
<div class="pane-card">
|
||||
<div class="pane-card__title">优先关注动作</div>
|
||||
<div v-if="!(summary.top_attention_runs || []).length" class="pane-card__empty">当前没有高优先或中优先动作</div>
|
||||
<div v-else class="focus-list">
|
||||
<div v-for="item in (summary.top_attention_runs || []).slice(0, 5)" :key="item.run_id" class="focus-item">
|
||||
<div class="focus-title">{{ item.action_label || "-" }}</div>
|
||||
<div class="focus-meta">{{ item.target_host || item.target_key || "-" }} / {{ formatAlertLabel(item.alert_level) }} / {{ item.result_label || "pending" }}</div>
|
||||
<div class="focus-desc">{{ item.alert_reason || item.result_summary || item.action_summary || "-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pane-card">
|
||||
<div class="pane-card__title">动作有效性</div>
|
||||
<div v-if="!(summary.effectiveness || []).length" class="pane-card__empty">当前还没有动作效果统计</div>
|
||||
<div v-else class="focus-list">
|
||||
<div v-for="item in (summary.effectiveness || []).slice(0, 5)" :key="item.action_key" class="focus-item">
|
||||
<div class="focus-title">{{ item.action_label || item.action_key || "-" }}</div>
|
||||
<div class="focus-meta">改善 {{ item.improved || 0 }} / 无变化 {{ item.no_change || 0 }} / 变差 {{ item.regression || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="rows" border>
|
||||
<el-table-column prop="run_id" label="Run ID" min-width="180" />
|
||||
<el-table-column prop="action_label" label="动作" min-width="180" />
|
||||
<el-table-column prop="target_host" label="Host" min-width="140" />
|
||||
<el-table-column prop="closure_stage" label="阶段" min-width="140" />
|
||||
<el-table-column prop="status" label="状态" width="100" />
|
||||
<el-table-column prop="result_label" label="结果" width="110" />
|
||||
<el-table-column prop="alert_level" label="告警" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ formatAlertLabel(row.alert_level) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updated_at" label="更新时间" min-width="180" />
|
||||
<el-table-column label="操作" width="320" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.target_host" link type="info" @click="openHostHistory(row.target_host)">Host 历史</el-button>
|
||||
<el-button link type="primary" @click="refreshResult(row)">回写结果</el-button>
|
||||
<el-button link type="warning" @click="updateStatus(row, 'running')">进行中</el-button>
|
||||
<el-button link type="primary" @click="updateStatus(row, 'observing')">观察中</el-button>
|
||||
<el-button link type="success" @click="updateStatus(row, 'done')">完成</el-button>
|
||||
<el-button link type="info" @click="updateStatus(row, 'ignored')">忽略</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-dialog>
|
||||
<DomainImportExternalSeoClosureHostHistoryDialog ref="hostHistoryDialogRef" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainImportExternalSeoClosureActionSummary, refreshDomainImportExternalSeoClosureActionResult, updateDomainImportExternalSeoClosureActionStatus } from "@/api/modules/site/list";
|
||||
import DomainImportExternalSeoClosureHostHistoryDialog from "./domainImportExternalSeoClosureHostHistoryDialog.vue";
|
||||
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const rows = ref<SiteList.DomainImportExternalSeoClosureActionSummaryItem[]>([]);
|
||||
const summary = ref<SiteList.DomainImportExternalSeoClosureActionSummaryData>({});
|
||||
const hostHistoryDialogRef = ref();
|
||||
|
||||
const heroTitle = computed(() => {
|
||||
const high = summary.value.alert_buckets?.high || 0;
|
||||
const medium = summary.value.alert_buckets?.medium || 0;
|
||||
if (high > 0) return `当前有 ${high} 条高优先闭环动作`;
|
||||
if (medium > 0) return `当前有 ${medium} 条中优先闭环动作`;
|
||||
return "当前闭环动作状态整体平稳";
|
||||
});
|
||||
|
||||
const heroDescription = computed(() => {
|
||||
const latest = summary.value.latest_run;
|
||||
if (latest?.alert_reason) {
|
||||
return latest.alert_reason;
|
||||
}
|
||||
return "这里会汇总动作执行、结果回写和优先关注项,方便直接跟进处理。";
|
||||
});
|
||||
|
||||
const heroType = computed(() => {
|
||||
const high = summary.value.alert_buckets?.high || 0;
|
||||
const medium = summary.value.alert_buckets?.medium || 0;
|
||||
if (high > 0) return "error";
|
||||
if (medium > 0) return "warning";
|
||||
return "success";
|
||||
});
|
||||
|
||||
const loadSummary = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDomainImportExternalSeoClosureActionSummary({ limit: 20 });
|
||||
summary.value = res.data ?? {};
|
||||
rows.value = summary.value.items ?? [];
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取闭环动作记录失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const updateStatus = async (row: SiteList.DomainImportExternalSeoClosureActionSummaryItem, status: "running" | "observing" | "done" | "ignored") => {
|
||||
try {
|
||||
await updateDomainImportExternalSeoClosureActionStatus({
|
||||
run_id: row.run_id,
|
||||
status,
|
||||
});
|
||||
ElMessage.success(`已更新为${status}`);
|
||||
await loadSummary();
|
||||
} catch (_error) {
|
||||
ElMessage.error("更新闭环动作状态失败");
|
||||
}
|
||||
};
|
||||
|
||||
const refreshResult = async (row: SiteList.DomainImportExternalSeoClosureActionSummaryItem) => {
|
||||
try {
|
||||
await refreshDomainImportExternalSeoClosureActionResult({
|
||||
run_id: row.run_id,
|
||||
});
|
||||
ElMessage.success("已刷新动作结果");
|
||||
await loadSummary();
|
||||
} catch (_error) {
|
||||
ElMessage.error("回写动作结果失败");
|
||||
}
|
||||
};
|
||||
|
||||
const formatAlertLabel = (value?: string) => {
|
||||
if (value === "high") return "高优先";
|
||||
if (value === "medium") return "中优先";
|
||||
if (value === "low") return "低优先";
|
||||
return "平稳";
|
||||
};
|
||||
|
||||
const openHostHistory = (host = "") => {
|
||||
hostHistoryDialogRef.value?.open(host);
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadSummary();
|
||||
};
|
||||
|
||||
defineExpose({ open, loadSummary });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.closure-action-dialog__toolbar { margin-bottom: 12px; }
|
||||
.closure-action-dialog__metrics {
|
||||
display:grid;
|
||||
grid-template-columns:repeat(6,minmax(120px,1fr));
|
||||
gap:12px;
|
||||
margin:16px 0;
|
||||
}
|
||||
.metric-card { border:1px solid #ebeef5; border-radius:12px; padding:12px; background:#fafafa; }
|
||||
.metric-card__label { color:#909399; font-size:12px; margin-bottom:6px; }
|
||||
.metric-card__value { color:#303133; font-size:22px; font-weight:600; }
|
||||
.closure-action-dialog__panes {
|
||||
display:grid;
|
||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||
gap:16px;
|
||||
margin-bottom:16px;
|
||||
}
|
||||
.pane-card { border:1px solid #ebeef5; border-radius:12px; padding:12px; background:#fff; }
|
||||
.pane-card__title { font-weight:600; margin-bottom:12px; }
|
||||
.pane-card__empty { color:#909399; font-size:13px; }
|
||||
.focus-list { display:flex; flex-direction:column; gap:10px; }
|
||||
.focus-item { padding:10px; border-radius:10px; background:#fafafa; }
|
||||
.focus-title { color:#303133; font-weight:600; }
|
||||
.focus-meta { margin-top:6px; color:#606266; font-size:12px; line-height:1.5; }
|
||||
.focus-desc { margin-top:4px; color:#909399; font-size:12px; line-height:1.5; }
|
||||
@media (max-width: 1100px) {
|
||||
.closure-action-dialog__metrics,
|
||||
.closure-action-dialog__panes {
|
||||
grid-template-columns:1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
304
src/views/site/domainImportExternalSeoClosureDialog.vue
Normal file
304
src/views/site/domainImportExternalSeoClosureDialog.vue
Normal file
@@ -0,0 +1,304 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="导入与站外 SEO 闭环总览" width="1180px" draggable>
|
||||
<div class="closure-dialog">
|
||||
<div class="closure-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadSummary">刷新</el-button>
|
||||
<el-button type="primary" plain @click="openActionRuns">闭环动作记录</el-button>
|
||||
<el-button type="info" plain @click="openHostHistory()">按 Host 看历史</el-button>
|
||||
<el-button type="warning" plain @click="openTodayDashboard">闭环今日值班面</el-button>
|
||||
<el-button type="info" plain @click="openImportHealth">导入健康台</el-button>
|
||||
<el-button type="danger" plain @click="openManualAttention">人工关注队列</el-button>
|
||||
<el-button type="success" plain @click="openCnOverview">大陆 SEO 总览</el-button>
|
||||
<el-button type="warning" plain @click="openIndexMovement">收录变化</el-button>
|
||||
<el-button type="primary" plain @click="openKeywordMovement">关键词升降</el-button>
|
||||
<el-button type="info" plain @click="openOptimization">自动优化建议</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
:title="summary.health_label ? `当前闭环状态:${healthLabelText(summary.health_label)}` : '当前还没有闭环总览数据'"
|
||||
:description="summary.hero_summary || '这里会把导入恢复主线和大陆 SEO 效果面放到一个视角里。'"
|
||||
:type="healthType(summary.health_label)"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<div class="closure-dialog__hero">
|
||||
<div class="closure-dialog__hero-main">
|
||||
<div class="closure-dialog__hero-label">闭环阶段</div>
|
||||
<div class="closure-dialog__hero-value">{{ stageText(summary.closure_stage) }}</div>
|
||||
<div class="closure-dialog__hero-note">{{ summary.hero_summary || "-" }}</div>
|
||||
</div>
|
||||
<div class="closure-dialog__hero-metrics">
|
||||
<div class="metric-card metric-card--hero">
|
||||
<div class="metric-card__label">导入失败</div>
|
||||
<div class="metric-card__value">{{ summary.import_summary?.active_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card metric-card--hero">
|
||||
<div class="metric-card__label">人工接管</div>
|
||||
<div class="metric-card__value">{{ summary.import_summary?.manual_attention_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card metric-card--hero">
|
||||
<div class="metric-card__label">收录 Host</div>
|
||||
<div class="metric-card__value">{{ summary.external_summary?.indexed_like_host_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="metric-card metric-card--hero">
|
||||
<div class="metric-card__label">关键词 Ready</div>
|
||||
<div class="metric-card__value">{{ summary.external_summary?.keyword_ready_host_count || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="closure-dialog__grid">
|
||||
<div class="pane-card">
|
||||
<div class="pane-card__title">导入恢复侧</div>
|
||||
<div class="pane-card__line">健康度:{{ summary.import_summary?.health_label || "-" }}</div>
|
||||
<div class="pane-card__line">阶段:{{ summary.import_summary?.workbench_stage || "-" }}</div>
|
||||
<div class="pane-card__line">导入记录:{{ summary.import_summary?.import_runs_count || 0 }}</div>
|
||||
<div class="pane-card__line">当前失败:{{ summary.import_summary?.active_count || 0 }}</div>
|
||||
<div class="pane-card__line">人工接管:{{ summary.import_summary?.manual_attention_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="pane-card">
|
||||
<div class="pane-card__title">站外效果侧</div>
|
||||
<div class="pane-card__line">大盘健康:{{ summary.external_summary?.health_label || "-" }}</div>
|
||||
<div class="pane-card__line">快照 Host:{{ summary.external_summary?.host_count || 0 }}</div>
|
||||
<div class="pane-card__line">收录 Host:{{ summary.external_summary?.indexed_like_host_count || 0 }}</div>
|
||||
<div class="pane-card__line">关键词 Ready:{{ summary.external_summary?.keyword_ready_host_count || 0 }}</div>
|
||||
<div class="pane-card__line">高优先建议:{{ summary.external_summary?.priority_high_count || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="closure-dialog__attention">
|
||||
<div class="pane-card">
|
||||
<div class="pane-card__title">当前优先动作</div>
|
||||
<div v-if="!(summary.priority_actions || []).length" class="pane-card__empty">当前没有优先动作</div>
|
||||
<div v-else class="closure-dialog__focus-list">
|
||||
<div v-for="item in summary.priority_actions || []" :key="item.key" class="closure-dialog__focus-item">
|
||||
<div class="closure-dialog__focus-main">
|
||||
<div class="closure-dialog__focus-title">{{ item.label || "-" }}</div>
|
||||
<div class="closure-dialog__focus-meta">{{ item.summary || "-" }}</div>
|
||||
</div>
|
||||
<div class="closure-dialog__focus-side">
|
||||
<el-button size="small" type="success" plain @click="createAction(item)">加入动作</el-button>
|
||||
<el-button size="small" type="primary" plain @click="handleAction(item.action_key)">
|
||||
打开
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pane-card">
|
||||
<div class="pane-card__title">站外优先建议</div>
|
||||
<div v-if="!(summary.top_seo_suggestions || []).length" class="pane-card__empty">当前还没有外部效果建议</div>
|
||||
<div v-else class="closure-dialog__focus-list">
|
||||
<div v-for="(item, index) in (summary.top_seo_suggestions || []).slice(0, 3)" :key="`${item.title || 'suggestion'}-${index}`" class="closure-dialog__focus-item">
|
||||
<div class="closure-dialog__focus-main">
|
||||
<div class="closure-dialog__focus-title">{{ item.title || "-" }}</div>
|
||||
<div class="closure-dialog__focus-meta">{{ item.summary || "-" }}</div>
|
||||
</div>
|
||||
<div class="closure-dialog__focus-side closure-dialog__focus-side--text">
|
||||
{{ item.action || "-" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
<DomainImportManualAttentionDialog ref="manualAttentionDialogRef" />
|
||||
<DomainExternalSeoCnOverviewDialog ref="cnOverviewDialogRef" />
|
||||
<DomainExternalSeoIndexMovementDialog ref="indexMovementDialogRef" />
|
||||
<DomainExternalSeoKeywordMovementDialog ref="keywordMovementDialogRef" />
|
||||
<DomainExternalSeoOptimizationSuggestionDialog ref="optimizationDialogRef" />
|
||||
<DomainImportExternalSeoClosureActionDialog ref="actionDialogRef" />
|
||||
<DomainImportExternalSeoClosureHostHistoryDialog ref="hostHistoryDialogRef" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainImportExternalSeoClosure, runDomainImportExternalSeoClosureAction } from "@/api/modules/site/list";
|
||||
import DomainImportManualAttentionDialog from "./domainImportManualAttentionDialog.vue";
|
||||
import DomainExternalSeoCnOverviewDialog from "./domainExternalSeoCnOverviewDialog.vue";
|
||||
import DomainExternalSeoIndexMovementDialog from "./domainExternalSeoIndexMovementDialog.vue";
|
||||
import DomainExternalSeoKeywordMovementDialog from "./domainExternalSeoKeywordMovementDialog.vue";
|
||||
import DomainExternalSeoOptimizationSuggestionDialog from "./domainExternalSeoOptimizationSuggestionDialog.vue";
|
||||
import DomainImportExternalSeoClosureActionDialog from "./domainImportExternalSeoClosureActionDialog.vue";
|
||||
import DomainImportExternalSeoClosureHostHistoryDialog from "./domainImportExternalSeoClosureHostHistoryDialog.vue";
|
||||
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const summary = ref<SiteList.DomainImportExternalSeoClosureData>({});
|
||||
const manualAttentionDialogRef = ref();
|
||||
const cnOverviewDialogRef = ref();
|
||||
const indexMovementDialogRef = ref();
|
||||
const keywordMovementDialogRef = ref();
|
||||
const optimizationDialogRef = ref();
|
||||
const actionDialogRef = ref();
|
||||
const hostHistoryDialogRef = ref();
|
||||
const requestGlobalDialogOpen = (key: string) => {
|
||||
window.dispatchEvent(new CustomEvent("site-dialog-open", { detail: { key } }));
|
||||
};
|
||||
|
||||
const loadSummary = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDomainImportExternalSeoClosure({ days: 14, limit: 10 });
|
||||
summary.value = res.data ?? {};
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取闭环总览失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const healthType = (label?: string) => {
|
||||
if (label === "growing") return "success";
|
||||
if (label === "attention") return "warning";
|
||||
return "info";
|
||||
};
|
||||
|
||||
const healthLabelText = (label?: string) => {
|
||||
if (label === "growing") return "增长中";
|
||||
if (label === "attention") return "需要关注";
|
||||
if (label === "warming") return "正在起量";
|
||||
return "平稳";
|
||||
};
|
||||
|
||||
const stageText = (stage?: string) => {
|
||||
const mapping: Record<string, string> = {
|
||||
import_not_started: "导入未开始",
|
||||
import_recovery_needed: "导入恢复中",
|
||||
manual_intervention_needed: "人工接管中",
|
||||
external_waiting_data: "等待站外数据",
|
||||
waiting_indexing: "等待收录起量",
|
||||
indexing_without_keywords: "收录已起、词面未起",
|
||||
seo_attention: "站外效果需要关注",
|
||||
seo_growing: "站外效果增长中",
|
||||
closed_loop_running: "闭环运行中",
|
||||
};
|
||||
return mapping[stage || ""] || (stage || "-");
|
||||
};
|
||||
|
||||
const openImportHealth = () => requestGlobalDialogOpen("domainImportHealthWorkbenchDialog");
|
||||
const openManualAttention = () => manualAttentionDialogRef.value?.open();
|
||||
const openCnOverview = () => cnOverviewDialogRef.value?.open();
|
||||
const openIndexMovement = () => indexMovementDialogRef.value?.open();
|
||||
const openKeywordMovement = () => keywordMovementDialogRef.value?.open();
|
||||
const openOptimization = () => optimizationDialogRef.value?.open();
|
||||
const openActionRuns = () => actionDialogRef.value?.open();
|
||||
const openHostHistory = (host = "") => hostHistoryDialogRef.value?.open(host);
|
||||
const openTodayDashboard = () => requestGlobalDialogOpen("domainImportExternalSeoClosureTodayDialog");
|
||||
|
||||
const handleAction = (actionKey?: string) => {
|
||||
if (actionKey === "open_import_health") return openImportHealth();
|
||||
if (actionKey === "open_manual_attention") return openManualAttention();
|
||||
if (actionKey === "open_cn_overview") return openCnOverview();
|
||||
if (actionKey === "open_index_movement") return openIndexMovement();
|
||||
if (actionKey === "open_keyword_movement") return openKeywordMovement();
|
||||
if (actionKey === "open_optimization") return openOptimization();
|
||||
};
|
||||
|
||||
const createAction = async (item: { key?: string; label?: string; summary?: string }) => {
|
||||
let targetType = "closure";
|
||||
let targetKey = item.key || "";
|
||||
let targetHost = "";
|
||||
if (item.key === "open_manual_attention") {
|
||||
targetType = "manual_attention";
|
||||
targetHost = String(summary.value.import_summary?.manual_attention_count || 0);
|
||||
} else if (item.key === "open_index_movement") {
|
||||
targetType = "index_movement";
|
||||
} else if (item.key === "open_keyword_movement") {
|
||||
targetType = "keyword_movement";
|
||||
} else if (item.key === "open_optimization") {
|
||||
targetType = "optimization";
|
||||
} else if (item.key === "open_import_health") {
|
||||
targetType = "import_health";
|
||||
}
|
||||
try {
|
||||
await runDomainImportExternalSeoClosureAction({
|
||||
action_key: item.key,
|
||||
action_label: item.label,
|
||||
action_summary: item.summary,
|
||||
closure_stage: summary.value.closure_stage,
|
||||
health_label: summary.value.health_label,
|
||||
target_type: targetType,
|
||||
target_key: targetKey,
|
||||
target_host: targetHost,
|
||||
status: "pending",
|
||||
});
|
||||
ElMessage.success("已加入闭环动作记录");
|
||||
openActionRuns();
|
||||
} catch (_error) {
|
||||
ElMessage.error("写入闭环动作记录失败");
|
||||
}
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadSummary();
|
||||
};
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.closure-dialog__toolbar { margin-bottom: 12px; }
|
||||
.closure-dialog__hero {
|
||||
display:grid;
|
||||
grid-template-columns:minmax(0,1.1fr) minmax(0,1fr);
|
||||
gap:12px;
|
||||
margin:16px 0;
|
||||
}
|
||||
.closure-dialog__hero-main {
|
||||
border-radius:12px;
|
||||
padding:16px;
|
||||
color:#fff;
|
||||
background:linear-gradient(135deg, #0f766e 0%, #1d4ed8 100%);
|
||||
}
|
||||
.closure-dialog__hero-label { font-size:13px; opacity:.86; }
|
||||
.closure-dialog__hero-value { margin-top:6px; font-size:28px; font-weight:700; }
|
||||
.closure-dialog__hero-note { margin-top:8px; font-size:13px; line-height:1.6; opacity:.92; }
|
||||
.closure-dialog__hero-metrics {
|
||||
display:grid;
|
||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||
gap:12px;
|
||||
}
|
||||
.closure-dialog__grid,
|
||||
.closure-dialog__attention {
|
||||
display:grid;
|
||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||
gap:16px;
|
||||
margin:16px 0;
|
||||
}
|
||||
.metric-card { border:1px solid #ebeef5; border-radius:12px; padding:12px; background:#fafafa; }
|
||||
.metric-card--hero { background:#fafafa; }
|
||||
.metric-card__label { color:#909399; font-size:12px; margin-bottom:6px; }
|
||||
.metric-card__value { color:#303133; font-size:22px; font-weight:600; }
|
||||
.pane-card { border:1px solid #ebeef5; border-radius:12px; padding:12px; background:#fff; }
|
||||
.pane-card__title { font-weight:600; margin-bottom:12px; }
|
||||
.pane-card__line { color:#606266; font-size:13px; line-height:1.9; }
|
||||
.pane-card__empty { color:#909399; font-size:13px; }
|
||||
.closure-dialog__focus-list { display:flex; flex-direction:column; gap:10px; }
|
||||
.closure-dialog__focus-item {
|
||||
display:flex;
|
||||
justify-content:space-between;
|
||||
gap:12px;
|
||||
padding:10px;
|
||||
border-radius:10px;
|
||||
background:#fafafa;
|
||||
}
|
||||
.closure-dialog__focus-main { min-width:0; }
|
||||
.closure-dialog__focus-title { color:#303133; font-weight:600; word-break:break-all; }
|
||||
.closure-dialog__focus-meta { margin-top:6px; color:#606266; font-size:12px; line-height:1.5; }
|
||||
.closure-dialog__focus-side { display:flex; align-items:center; }
|
||||
.closure-dialog__focus-side--text { color:#606266; font-size:12px; text-align:left; max-width:220px; }
|
||||
@media (max-width: 1100px) {
|
||||
.closure-dialog__hero,
|
||||
.closure-dialog__hero-metrics,
|
||||
.closure-dialog__grid,
|
||||
.closure-dialog__attention {
|
||||
grid-template-columns:1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="按 Host 看闭环历史" width="980px" draggable>
|
||||
<div class="closure-host-dialog">
|
||||
<div class="closure-host-dialog__toolbar">
|
||||
<el-input v-model="host" placeholder="输入 host,例如 a.com" clearable class="closure-host-dialog__input" @keyup.enter="loadSummary" />
|
||||
<el-button type="primary" plain @click="loadSummary">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
:title="host ? `当前 Host:${host}` : '先选择一个 Host 查看闭环历史'"
|
||||
:description="host ? heroDescription : '也可以先看最近最活跃的 Host,再继续下钻。'"
|
||||
:type="host ? 'success' : 'info'"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<div class="closure-host-dialog__panes">
|
||||
<div class="pane-card">
|
||||
<div class="pane-card__title">最近活跃 Host</div>
|
||||
<div v-if="!hostEntries.length" class="pane-card__empty">当前还没有可用的 Host 历史</div>
|
||||
<div v-else class="focus-list">
|
||||
<div v-for="item in hostEntries" :key="item.host" class="focus-item">
|
||||
<div>
|
||||
<div class="focus-title">{{ item.host }}</div>
|
||||
<div class="focus-meta">动作 {{ item.count }}</div>
|
||||
</div>
|
||||
<div class="focus-side">
|
||||
<el-button size="small" type="primary" plain @click="openHost(item.host)">查看</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pane-card">
|
||||
<div class="pane-card__title">当前 Host 摘要</div>
|
||||
<div v-if="!summary.host_selected" class="pane-card__empty">选择 Host 后这里会显示动作、结果和告警分布。</div>
|
||||
<div v-else class="host-summary">
|
||||
<div class="metric-card"><div class="metric-card__label">动作数</div><div class="metric-card__value">{{ summary.total_actions || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">高优先</div><div class="metric-card__value">{{ summary.alert_buckets?.high || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">改善</div><div class="metric-card__value">{{ summary.result_buckets?.improved || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">变差</div><div class="metric-card__value">{{ summary.result_buckets?.regression || 0 }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table v-if="summary.host_selected" v-loading="loading" :data="summary.items || []" border>
|
||||
<el-table-column prop="run_id" label="Run ID" min-width="180" />
|
||||
<el-table-column prop="action_label" label="动作" min-width="180" />
|
||||
<el-table-column prop="closure_stage" label="阶段" min-width="140" />
|
||||
<el-table-column prop="status" label="状态" width="100" />
|
||||
<el-table-column prop="result_label" label="结果" width="110" />
|
||||
<el-table-column prop="alert_level" label="告警" width="100" />
|
||||
<el-table-column prop="updated_at" label="更新时间" min-width="180" />
|
||||
</el-table>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainImportExternalSeoClosureHostHistory } from "@/api/modules/site/list";
|
||||
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const host = ref("");
|
||||
const summary = ref<SiteList.DomainImportExternalSeoClosureHostHistoryData>({});
|
||||
|
||||
const hostEntries = computed(() => Object.entries(summary.value.host_buckets || {}).map(([currentHost, count]) => ({ host: currentHost, count })));
|
||||
const heroDescription = computed(() => {
|
||||
const latest = summary.value.latest_run;
|
||||
if (latest?.alert_reason) return latest.alert_reason;
|
||||
if (latest?.result_summary) return latest.result_summary;
|
||||
return "这里会按 Host 展示闭环动作、结果回写和告警轨迹。";
|
||||
});
|
||||
|
||||
const loadSummary = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDomainImportExternalSeoClosureHostHistory({ host: host.value, limit: 20 });
|
||||
summary.value = res.data ?? {};
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取闭环 Host 历史失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openHost = async (value: string) => {
|
||||
host.value = value;
|
||||
await loadSummary();
|
||||
};
|
||||
|
||||
const open = async (value = "") => {
|
||||
visible.value = true;
|
||||
host.value = value;
|
||||
await loadSummary();
|
||||
};
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.closure-host-dialog__toolbar { display:flex; gap:12px; margin-bottom:12px; }
|
||||
.closure-host-dialog__input { max-width:320px; }
|
||||
.closure-host-dialog__panes {
|
||||
display:grid;
|
||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||
gap:16px;
|
||||
margin:16px 0;
|
||||
}
|
||||
.pane-card { border:1px solid #ebeef5; border-radius:12px; padding:12px; background:#fff; }
|
||||
.pane-card__title { font-weight:600; margin-bottom:12px; }
|
||||
.pane-card__empty { color:#909399; font-size:13px; }
|
||||
.focus-list { display:flex; flex-direction:column; gap:10px; }
|
||||
.focus-item { display:flex; justify-content:space-between; align-items:center; gap:12px; padding:10px; border-radius:10px; background:#fafafa; }
|
||||
.focus-title { color:#303133; font-weight:600; }
|
||||
.focus-meta { margin-top:6px; color:#606266; font-size:12px; }
|
||||
.focus-side { display:flex; align-items:center; }
|
||||
.host-summary { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
|
||||
.metric-card { border:1px solid #ebeef5; border-radius:12px; padding:12px; background:#fafafa; }
|
||||
.metric-card__label { color:#909399; font-size:12px; margin-bottom:6px; }
|
||||
.metric-card__value { color:#303133; font-size:22px; font-weight:600; }
|
||||
@media (max-width: 1100px) {
|
||||
.closure-host-dialog__panes,
|
||||
.host-summary {
|
||||
grid-template-columns:1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
170
src/views/site/domainImportExternalSeoClosureTodayDialog.vue
Normal file
170
src/views/site/domainImportExternalSeoClosureTodayDialog.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="闭环今日值班面" width="1100px" draggable>
|
||||
<div class="closure-today-dialog">
|
||||
<div class="closure-today-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadSummary">刷新</el-button>
|
||||
<el-button type="primary" plain @click="openActionRuns">闭环动作记录</el-button>
|
||||
<el-button type="info" plain @click="openClosure">闭环总览</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
:title="summary.health_label ? `今日闭环状态:${summary.health_label}` : '当前还没有今日闭环数据'"
|
||||
:description="summary.hero_summary || '这里会聚合今天该处理的闭环动作、异常和改善项。'"
|
||||
:type="summary.health_label === 'growing' ? 'success' : summary.health_label === 'attention' ? 'warning' : 'info'"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<div class="closure-today-dialog__metrics">
|
||||
<div class="metric-card"><div class="metric-card__label">待跟动作</div><div class="metric-card__value">{{ summary.pending_action_count || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">需关注动作</div><div class="metric-card__value">{{ summary.attention_action_count || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">今日改善</div><div class="metric-card__value">{{ summary.improved_action_count || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">高优先动作</div><div class="metric-card__value">{{ summary.alert_buckets?.high || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">人工接管</div><div class="metric-card__value">{{ summary.manual_attention_count || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">导入失败</div><div class="metric-card__value">{{ summary.import_active_count || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">已收录 Host</div><div class="metric-card__value">{{ summary.indexed_like_host_count || 0 }}</div></div>
|
||||
<div class="metric-card"><div class="metric-card__label">关键词 Ready</div><div class="metric-card__value">{{ summary.keyword_ready_host_count || 0 }}</div></div>
|
||||
</div>
|
||||
|
||||
<div class="closure-today-dialog__panes">
|
||||
<div class="pane-card">
|
||||
<div class="pane-card__title">优先动作</div>
|
||||
<div v-if="!(summary.priority_actions || []).length" class="pane-card__empty">当前没有新的优先动作</div>
|
||||
<div v-else class="focus-list">
|
||||
<div v-for="item in (summary.priority_actions || []).slice(0, 5)" :key="item.key || item.label" class="focus-item">
|
||||
<div class="focus-title">{{ item.label || "-" }}</div>
|
||||
<div class="focus-desc">{{ item.summary || "-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pane-card">
|
||||
<div class="pane-card__title">今日动作</div>
|
||||
<div v-if="!(summary.today_actions || []).length" class="pane-card__empty">今天还没有闭环动作</div>
|
||||
<div v-else class="focus-list">
|
||||
<div v-for="item in (summary.today_actions || []).slice(0, 8)" :key="item.run_id" class="focus-item">
|
||||
<div class="focus-main">
|
||||
<div class="focus-title">{{ item.action_label || "-" }}</div>
|
||||
<div class="focus-meta">{{ item.target_host || item.target_key || "-" }} / {{ item.status || "-" }} / {{ item.result_label || "pending" }} / {{ formatAlertLabel(item.alert_level) }}</div>
|
||||
<div class="focus-desc">{{ item.alert_reason || item.result_summary || item.action_summary || "-" }}</div>
|
||||
<div class="focus-actions">
|
||||
<el-button v-if="item.target_host" size="small" type="info" plain @click="openHostHistory(item.target_host)">Host 历史</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pane-card">
|
||||
<div class="pane-card__title">优先关注动作</div>
|
||||
<div v-if="!(summary.top_attention_runs || []).length" class="pane-card__empty">当前没有高优先或中优先动作</div>
|
||||
<div v-else class="focus-list">
|
||||
<div v-for="item in (summary.top_attention_runs || []).slice(0, 5)" :key="item.run_id" class="focus-item">
|
||||
<div class="focus-main">
|
||||
<div class="focus-title">{{ item.action_label || "-" }}</div>
|
||||
<div class="focus-meta">{{ item.target_host || item.target_key || "-" }} / {{ formatAlertLabel(item.alert_level) }}</div>
|
||||
<div class="focus-desc">{{ item.alert_reason || item.result_summary || "-" }}</div>
|
||||
<div class="focus-actions">
|
||||
<el-button v-if="item.target_host" size="small" type="info" plain @click="openHostHistory(item.target_host)">Host 历史</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pane-card">
|
||||
<div class="pane-card__title">动作有效性</div>
|
||||
<div v-if="!(summary.top_effectiveness || []).length" class="pane-card__empty">当前还没有动作效果统计</div>
|
||||
<div v-else class="focus-list">
|
||||
<div v-for="item in (summary.top_effectiveness || []).slice(0, 5)" :key="item.action_key" class="focus-item">
|
||||
<div class="focus-main">
|
||||
<div class="focus-title">{{ item.action_label || item.action_key || "-" }}</div>
|
||||
<div class="focus-meta">改善 {{ item.improved || 0 }} / 无变化 {{ item.no_change || 0 }} / 变差 {{ item.regression || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
<DomainImportExternalSeoClosureActionDialog ref="actionDialogRef" />
|
||||
<DomainImportExternalSeoClosureHostHistoryDialog ref="hostHistoryDialogRef" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainImportExternalSeoClosureToday } from "@/api/modules/site/list";
|
||||
import DomainImportExternalSeoClosureActionDialog from "./domainImportExternalSeoClosureActionDialog.vue";
|
||||
import DomainImportExternalSeoClosureHostHistoryDialog from "./domainImportExternalSeoClosureHostHistoryDialog.vue";
|
||||
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const summary = ref<SiteList.DomainImportExternalSeoClosureTodayData>({});
|
||||
const actionDialogRef = ref();
|
||||
const hostHistoryDialogRef = ref();
|
||||
const requestGlobalDialogOpen = (key: string) => {
|
||||
window.dispatchEvent(new CustomEvent("site-dialog-open", { detail: { key } }));
|
||||
};
|
||||
|
||||
const loadSummary = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDomainImportExternalSeoClosureToday({ days: 14, limit: 10 });
|
||||
summary.value = res.data ?? {};
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取闭环今日值班面失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openClosure = () => requestGlobalDialogOpen("domainImportExternalSeoClosureDialog");
|
||||
const openActionRuns = () => actionDialogRef.value?.open();
|
||||
const openHostHistory = (host = "") => hostHistoryDialogRef.value?.open(host);
|
||||
|
||||
const formatAlertLabel = (value?: string) => {
|
||||
if (value === "high") return "高优先";
|
||||
if (value === "medium") return "中优先";
|
||||
if (value === "low") return "低优先";
|
||||
return "平稳";
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadSummary();
|
||||
};
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.closure-today-dialog__toolbar { margin-bottom: 12px; }
|
||||
.closure-today-dialog__metrics {
|
||||
display:grid;
|
||||
grid-template-columns:repeat(6,minmax(120px,1fr));
|
||||
gap:12px;
|
||||
margin:16px 0;
|
||||
}
|
||||
.metric-card { border:1px solid #ebeef5; border-radius:12px; padding:12px; background:#fafafa; }
|
||||
.metric-card__label { color:#909399; font-size:12px; margin-bottom:6px; }
|
||||
.metric-card__value { color:#303133; font-size:22px; font-weight:600; }
|
||||
.closure-today-dialog__panes {
|
||||
display:grid;
|
||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||
gap:16px;
|
||||
}
|
||||
.pane-card { border:1px solid #ebeef5; border-radius:12px; padding:12px; background:#fff; }
|
||||
.pane-card__title { font-weight:600; margin-bottom:12px; }
|
||||
.pane-card__empty { color:#909399; font-size:13px; }
|
||||
.focus-list { display:flex; flex-direction:column; gap:10px; }
|
||||
.focus-item { padding:10px; border-radius:10px; background:#fafafa; }
|
||||
.focus-title { color:#303133; font-weight:600; }
|
||||
.focus-meta { margin-top:6px; color:#606266; font-size:12px; line-height:1.5; }
|
||||
.focus-desc { margin-top:4px; color:#909399; font-size:12px; line-height:1.5; }
|
||||
.focus-actions { margin-top:8px; }
|
||||
@media (max-width: 1100px) {
|
||||
.closure-today-dialog__metrics,
|
||||
.closure-today-dialog__panes {
|
||||
grid-template-columns:1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
261
src/views/site/domainImportFailedQueueDialog.vue
Normal file
261
src/views/site/domainImportFailedQueueDialog.vue
Normal file
@@ -0,0 +1,261 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="导入后探测失败队列" width="1100px" draggable>
|
||||
<div class="failed-queue-dialog">
|
||||
<div class="failed-queue-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadQueue">刷新</el-button>
|
||||
<el-button type="success" plain :loading="selfHealingLoading" @click="runSelfHealing">后台执行自动修复</el-button>
|
||||
<el-button type="danger" plain :loading="rerunLoading" @click="runQueueRerun">后台执行整批重跑</el-button>
|
||||
<el-button type="warning" plain :loading="remediationLoading" @click="runQueueRemediation">后台执行补料候选</el-button>
|
||||
<el-button v-if="rerunAllCommand" type="warning" plain @click="showRerunAllCommand">整批重跑命令</el-button>
|
||||
<el-button type="info" plain @click="openSelfHealingDialog">最近自动修复记录</el-button>
|
||||
<el-button type="info" plain @click="openRerunDialog">最近重跑记录</el-button>
|
||||
<el-button type="info" plain @click="openRemediationDialog">最近补料记录</el-button>
|
||||
</div>
|
||||
<el-alert
|
||||
title="这里是导入成功后,自动页面探测仍未通过的 host"
|
||||
description="它不代表域名没有入库,而是代表首页、搜索、详情、播放这条页面链路还没有探通。"
|
||||
type="warning"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
<div class="failed-queue-dialog__summary">
|
||||
<span>当前探测失败:{{ queueSummary.active_count || healthTrend.active_count || healthTrend.queue_count || 0 }}</span>
|
||||
<span>已自动消退:{{ queueSummary.resolved_count || healthTrend.resolved_count || 0 }}</span>
|
||||
<span>重跑记录:{{ healthTrend.rerun_runs_count || 0 }}</span>
|
||||
<span>补料记录:{{ healthTrend.remediation_runs_count || 0 }}</span>
|
||||
<span>{{ trendLabel() }}</span>
|
||||
</div>
|
||||
<div class="failed-queue-dialog__summary">
|
||||
<span>重跑消退:{{ queueSummary.resolved_by_rerun_count || healthTrend.resolved_by_rerun_count || 0 }}</span>
|
||||
<span>补料消退:{{ queueSummary.resolved_by_remediation_count || healthTrend.resolved_by_remediation_count || 0 }}</span>
|
||||
</div>
|
||||
<div v-if="resolvedPreview().length" class="failed-queue-dialog__resolved">
|
||||
<span class="failed-queue-dialog__resolved-label">最近自动消退:</span>
|
||||
<span>{{ resolvedPreview().join(" / ") }}</span>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="rows" border>
|
||||
<el-table-column prop="host" label="Host" min-width="180" />
|
||||
<el-table-column prop="run_id" label="Run ID" min-width="160" />
|
||||
<el-table-column prop="failed_stage" label="失败阶段" width="100" />
|
||||
<el-table-column label="失败明细" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<span>
|
||||
{{ formatFailedChecks(row) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="search_keyword" label="搜索词样本" min-width="160" />
|
||||
<el-table-column label="补料候选" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<span>
|
||||
{{ row.remediation_plan?.title || "-" }} / {{ row.remediation_plan?.summary || "-" }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updated_at" label="更新时间" min-width="180" />
|
||||
<el-table-column label="操作" width="320" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.summary_html_path" link type="primary" @click="openPublicPath(row.summary_html_path)">HTML摘要</el-button>
|
||||
<el-button v-if="row.summary_json_path" link type="info" @click="openPublicPath(row.summary_json_path)">JSON摘要</el-button>
|
||||
<el-button v-if="row.rerun_command" link type="warning" @click="showCommand('单条重跑命令', row.rerun_command)">重跑命令</el-button>
|
||||
<el-button v-if="row.sample_command" link type="success" @click="showCommand('自动样本命令', row.sample_command)">样本命令</el-button>
|
||||
<el-button v-if="row.remediation_command" link type="danger" @click="showCommand('补料候选命令', row.remediation_command)">补料命令</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-dialog>
|
||||
<DomainImportSelfHealingDialog ref="selfHealingDialogRef" />
|
||||
<DomainImportRerunDialog ref="rerunDialogRef" />
|
||||
<DomainImportRemediationDialog ref="remediationDialogRef" />
|
||||
<DomainImportTaskConsoleDialog ref="taskConsoleDialogRef" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainImportFailedQueue, getDomainImportHealthTrend, runDomainImportFailedQueue, runDomainImportFailureRemediation, runDomainImportSelfHealing } from "@/api/modules/site/list";
|
||||
import DomainImportSelfHealingDialog from "./domainImportSelfHealingDialog.vue";
|
||||
import DomainImportRerunDialog from "./domainImportRerunDialog.vue";
|
||||
import DomainImportRemediationDialog from "./domainImportRemediationDialog.vue";
|
||||
import DomainImportTaskConsoleDialog from "./domainImportTaskConsoleDialog.vue";
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL as string;
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const selfHealingLoading = ref(false);
|
||||
const rerunLoading = ref(false);
|
||||
const remediationLoading = ref(false);
|
||||
const rows = ref<SiteList.DomainImportFailedQueueItem[]>([]);
|
||||
const queueSummary = ref<SiteList.DomainImportFailedQueueData>({});
|
||||
const rerunAllCommand = ref("");
|
||||
const selfHealingDialogRef = ref();
|
||||
const rerunDialogRef = ref();
|
||||
const remediationDialogRef = ref();
|
||||
const taskConsoleDialogRef = ref();
|
||||
const healthTrend = ref<SiteList.DomainImportHealthTrendData>({});
|
||||
|
||||
const formatFailedChecks = (row: SiteList.DomainImportFailedQueueItem) => {
|
||||
return (row.failed_checks || [])
|
||||
.slice(0, 2)
|
||||
.map(item => `${item?.stage || "-"}:${item?.detail || "-"}`)
|
||||
.join(" | ") || "-";
|
||||
};
|
||||
|
||||
const openPublicPath = (path?: string) => {
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
window.open(`${BASE_URL}/${String(path).replace(/^\/+/, "")}`, "_blank", "noopener");
|
||||
};
|
||||
|
||||
const showCommand = async (title: string, command?: string) => {
|
||||
if (!command) {
|
||||
return;
|
||||
}
|
||||
await ElMessageBox.alert(`<code>${command}</code>`, title, {
|
||||
dangerouslyUseHTMLString: true,
|
||||
confirmButtonText: "知道了"
|
||||
});
|
||||
};
|
||||
|
||||
const showRerunAllCommand = async () => {
|
||||
await showCommand("整批重跑命令", rerunAllCommand.value);
|
||||
};
|
||||
|
||||
const openRerunDialog = () => {
|
||||
rerunDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const openRemediationDialog = () => {
|
||||
remediationDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const openSelfHealingDialog = () => {
|
||||
selfHealingDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const trendLabel = () => {
|
||||
const label = healthTrend.value.failure_trend?.label || "flat";
|
||||
if (label === "improving") return "失败趋势改善中";
|
||||
if (label === "worsening") return "失败趋势上升";
|
||||
return "失败趋势持平";
|
||||
};
|
||||
|
||||
const resolvedPreview = () => {
|
||||
return queueSummary.value.resolved_hosts_preview || healthTrend.value.resolved_hosts_preview || [];
|
||||
};
|
||||
|
||||
const runQueueRerun = async () => {
|
||||
rerunLoading.value = true;
|
||||
try {
|
||||
const res = await runDomainImportFailedQueue({ limit: 50 });
|
||||
const job = res.data.job;
|
||||
if (!job?.job_id) {
|
||||
throw new Error("job_id missing");
|
||||
}
|
||||
ElMessage.success("重跑任务已创建");
|
||||
taskConsoleDialogRef.value?.open("失败队列重跑任务", job.job_id, async () => {
|
||||
await loadQueue();
|
||||
openRerunDialog();
|
||||
});
|
||||
} catch (_error) {
|
||||
ElMessage.error("执行失败队列重跑失败");
|
||||
} finally {
|
||||
rerunLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const runSelfHealing = async () => {
|
||||
selfHealingLoading.value = true;
|
||||
try {
|
||||
const res = await runDomainImportSelfHealing();
|
||||
const job = res.data.job;
|
||||
if (!job?.job_id) {
|
||||
throw new Error("job_id missing");
|
||||
}
|
||||
ElMessage.success("自动修复任务已创建");
|
||||
taskConsoleDialogRef.value?.open("自动修复任务", job.job_id, async () => {
|
||||
await loadQueue();
|
||||
openSelfHealingDialog();
|
||||
});
|
||||
} catch (_error) {
|
||||
ElMessage.error("执行自动修复失败");
|
||||
} finally {
|
||||
selfHealingLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const runQueueRemediation = async () => {
|
||||
remediationLoading.value = true;
|
||||
try {
|
||||
const res = await runDomainImportFailureRemediation({ limit: 50 });
|
||||
const job = res.data.job;
|
||||
if (!job?.job_id) {
|
||||
throw new Error("job_id missing");
|
||||
}
|
||||
ElMessage.success("补料任务已创建");
|
||||
taskConsoleDialogRef.value?.open("失败补料候选任务", job.job_id, async () => {
|
||||
await loadQueue();
|
||||
openRemediationDialog();
|
||||
});
|
||||
} catch (_error) {
|
||||
ElMessage.error("执行失败队列补料候选失败");
|
||||
} finally {
|
||||
remediationLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadQueue = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [queueRes, trendRes] = await Promise.all([
|
||||
getDomainImportFailedQueue({ limit: 50 }),
|
||||
getDomainImportHealthTrend({ limit: 10 })
|
||||
]);
|
||||
queueSummary.value = queueRes.data ?? {};
|
||||
rows.value = queueSummary.value.items ?? [];
|
||||
rerunAllCommand.value = queueSummary.value.rerun_all_command ?? "";
|
||||
healthTrend.value = trendRes.data ?? {};
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取导入后探测失败队列失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadQueue();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.failed-queue-dialog__toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.failed-queue-dialog__summary {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.failed-queue-dialog__resolved {
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.failed-queue-dialog__resolved-label {
|
||||
margin-right: 8px;
|
||||
color: #606266;
|
||||
}
|
||||
</style>
|
||||
677
src/views/site/domainImportHealthWorkbenchDialog.vue
Normal file
677
src/views/site/domainImportHealthWorkbenchDialog.vue
Normal file
@@ -0,0 +1,677 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="导入健康台" width="1100px" draggable>
|
||||
<div class="import-health-workbench">
|
||||
<div class="import-health-workbench__toolbar">
|
||||
<el-button type="primary" plain @click="loadSummary">刷新</el-button>
|
||||
<el-button type="primary" plain :loading="workbenchRunLoading" @click="runWorkbenchSummaryAction">刷新并落盘健康台</el-button>
|
||||
<el-button type="success" plain :loading="selfHealingLoading" @click="runSelfHealingAction">执行自动修复</el-button>
|
||||
<el-button type="success" plain @click="openClosureOverview">闭环总览</el-button>
|
||||
<el-button type="info" plain @click="openImportRuns">最近导入记录</el-button>
|
||||
<el-button type="warning" plain @click="openFailedQueue">探测失败队列</el-button>
|
||||
<el-button type="info" plain @click="openRerunRuns">最近重跑记录</el-button>
|
||||
<el-button type="warning" plain @click="openRemediationRuns">最近补料记录</el-button>
|
||||
<el-button type="info" plain @click="openSelfHealingRuns">最近自动修复记录</el-button>
|
||||
<el-button type="danger" plain @click="openManualAttention">人工关注队列</el-button>
|
||||
<el-button type="primary" plain @click="openManualHandleRuns">最近人工处理记录</el-button>
|
||||
<el-button type="info" plain @click="openWorkbenchRuns">最近健康台记录</el-button>
|
||||
<el-button type="info" plain @click="openWorkbenchTrend">健康台趋势</el-button>
|
||||
</div>
|
||||
<div class="import-health-workbench__hero">
|
||||
<div class="import-health-workbench__hero-title">导入健康台</div>
|
||||
<div class="import-health-workbench__hero-desc">{{ healthLabelText() }}</div>
|
||||
<div v-if="summary.hero_summary" class="import-health-workbench__hero-note">
|
||||
{{ summary.hero_summary }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="import-health-workbench__stats">
|
||||
<span>导入记录:{{ summary.import_runs_count || 0 }}</span>
|
||||
<span>当前失败:{{ summary.active_count || summary.queue_count || 0 }}</span>
|
||||
<span>自动修复记录:{{ summary.self_healing_runs_count || 0 }}</span>
|
||||
<span>人工关注:{{ summary.manual_attention_count || 0 }}</span>
|
||||
<span>重跑恢复:{{ summary.resolved_by_rerun_count || 0 }}</span>
|
||||
<span>补料恢复:{{ summary.resolved_by_remediation_count || 0 }}</span>
|
||||
</div>
|
||||
<div class="import-health-workbench__blocks">
|
||||
<div class="import-health-workbench__block">
|
||||
<div class="import-health-workbench__block-title">最新导入</div>
|
||||
<div>{{ summary.latest_import_run?.run_id || "-" }}</div>
|
||||
<div>失败 {{ summary.latest_import_run?.failed_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="import-health-workbench__block">
|
||||
<div class="import-health-workbench__block-title">最新失败 Host</div>
|
||||
<div>{{ summary.latest_failed_item?.host || "-" }}</div>
|
||||
<div>{{ summary.latest_failed_item?.failed_stage || "-" }}</div>
|
||||
</div>
|
||||
<div class="import-health-workbench__block">
|
||||
<div class="import-health-workbench__block-title">最新自动修复</div>
|
||||
<div>{{ summary.latest_self_healing_run?.run_id || "-" }}</div>
|
||||
<div>{{ summary.latest_self_healing_run?.status || "-" }}</div>
|
||||
</div>
|
||||
<div class="import-health-workbench__block">
|
||||
<div class="import-health-workbench__block-title">最新重跑</div>
|
||||
<div>{{ summary.latest_rerun_run?.run_id || "-" }}</div>
|
||||
<div>失败 {{ summary.latest_rerun_run?.failed_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="import-health-workbench__block">
|
||||
<div class="import-health-workbench__block-title">最新补料</div>
|
||||
<div>{{ summary.latest_remediation_run?.run_id || "-" }}</div>
|
||||
<div>恢复 {{ summary.latest_remediation_run?.recovered_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="import-health-workbench__block">
|
||||
<div class="import-health-workbench__block-title">失败趋势</div>
|
||||
<div>{{ failureTrendText() }}</div>
|
||||
</div>
|
||||
<div class="import-health-workbench__block">
|
||||
<div class="import-health-workbench__block-title">人工关注</div>
|
||||
<div>{{ summary.manual_attention_count || 0 }}</div>
|
||||
<div>{{ summary.latest_manual_attention_item?.host || "-" }}</div>
|
||||
</div>
|
||||
<div class="import-health-workbench__block">
|
||||
<div class="import-health-workbench__block-title">最新人工处理</div>
|
||||
<div>{{ summary.latest_manual_handle_run?.run_id || "-" }}</div>
|
||||
<div>{{ summary.latest_manual_handle_run?.status || "-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="(summary.resolved_hosts_preview || []).length" class="import-health-workbench__resolved">
|
||||
<span class="import-health-workbench__resolved-label">最近恢复:</span>
|
||||
<span>{{ (summary.resolved_hosts_preview || []).join(" / ") }}</span>
|
||||
</div>
|
||||
<div class="import-health-workbench__insights">
|
||||
<div class="import-health-workbench__insight-card">
|
||||
<div class="import-health-workbench__panel-title">当前失败阶段分布</div>
|
||||
<div
|
||||
v-for="item in (summary.top_failed_stages || []).slice(0, 5)"
|
||||
:key="item.stage"
|
||||
class="import-health-workbench__panel-row"
|
||||
>
|
||||
<span>{{ item.stage || "-" }}</span>
|
||||
<span>{{ item.count || 0 }}</span>
|
||||
</div>
|
||||
<div v-if="!(summary.top_failed_stages || []).length" class="import-health-workbench__empty">当前没有活跃失败阶段</div>
|
||||
</div>
|
||||
<div class="import-health-workbench__insight-card">
|
||||
<div class="import-health-workbench__panel-title">当前恢复主路径</div>
|
||||
<div class="import-health-workbench__mix-main">{{ recoveryMixText() }}</div>
|
||||
<div class="import-health-workbench__mix-sub">
|
||||
重跑恢复 {{ summary.recovery_mix?.resolved_by_rerun_count || 0 }} /
|
||||
补料恢复 {{ summary.recovery_mix?.resolved_by_remediation_count || 0 }}
|
||||
</div>
|
||||
<div class="import-health-workbench__mix-sub">
|
||||
自动修复记录 {{ summary.recovery_mix?.self_healing_runs_count || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="import-health-workbench__insights">
|
||||
<div class="import-health-workbench__insight-card">
|
||||
<div class="import-health-workbench__panel-title">人工关注分层</div>
|
||||
<div class="import-health-workbench__mix-main">{{ manualAttentionText() }}</div>
|
||||
<div class="import-health-workbench__mix-sub">
|
||||
高优先 {{ summary.manual_attention_buckets?.high || 0 }} /
|
||||
中优先 {{ summary.manual_attention_buckets?.medium || 0 }} /
|
||||
低优先 {{ summary.manual_attention_buckets?.low || 0 }}
|
||||
</div>
|
||||
<div
|
||||
v-for="item in (summary.recent_manual_attention_items || []).slice(0, 3)"
|
||||
:key="item.host"
|
||||
class="import-health-workbench__panel-row"
|
||||
>
|
||||
<span>{{ item.host || "-" }}</span>
|
||||
<span>{{ item.attention_reason || "-" }}</span>
|
||||
</div>
|
||||
<div v-if="!(summary.recent_manual_attention_items || []).length" class="import-health-workbench__empty">当前没有需要人工接管的 host</div>
|
||||
</div>
|
||||
<div class="import-health-workbench__insight-card">
|
||||
<div class="import-health-workbench__panel-title">最近人工处理</div>
|
||||
<div class="import-health-workbench__mix-main">{{ manualHandleText() }}</div>
|
||||
<div class="import-health-workbench__mix-sub">
|
||||
处理记录 {{ summary.manual_handle_runs_count || 0 }} / 最新复核 {{ summary.latest_manual_handle_run?.reprobe_status || "-" }}
|
||||
</div>
|
||||
<div
|
||||
v-for="item in (summary.recent_manual_handle_runs || []).slice(0, 3)"
|
||||
:key="item.run_id"
|
||||
class="import-health-workbench__panel-row"
|
||||
>
|
||||
<span>{{ item.host || "-" }}</span>
|
||||
<span>{{ item.action || "-" }} / {{ item.status || "-" }}</span>
|
||||
</div>
|
||||
<div v-if="!(summary.recent_manual_handle_runs || []).length" class="import-health-workbench__empty">当前还没有人工处理记录</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="import-health-workbench__insights">
|
||||
<div class="import-health-workbench__insight-card">
|
||||
<div class="import-health-workbench__panel-title">健康台记录告警</div>
|
||||
<div class="import-health-workbench__mix-main">{{ workbenchAlertText() }}</div>
|
||||
<div class="import-health-workbench__mix-sub">
|
||||
高优先 {{ workbenchRunsSummary.alert_buckets?.high || 0 }} /
|
||||
中优先 {{ workbenchRunsSummary.alert_buckets?.medium || 0 }} /
|
||||
低优先 {{ workbenchRunsSummary.alert_buckets?.low || 0 }}
|
||||
</div>
|
||||
<div
|
||||
v-for="item in (workbenchRunsSummary.top_attention_runs || []).slice(0, 3)"
|
||||
:key="item.run_id"
|
||||
class="import-health-workbench__panel-row"
|
||||
>
|
||||
<span>{{ item.run_id || "-" }}</span>
|
||||
<span>{{ item.alert_reason || "-" }}</span>
|
||||
</div>
|
||||
<div v-if="!(workbenchRunsSummary.top_attention_runs || []).length" class="import-health-workbench__empty">当前没有需要优先回看的健康台 run</div>
|
||||
</div>
|
||||
<div class="import-health-workbench__insight-card">
|
||||
<div class="import-health-workbench__panel-title">健康台趋势告警</div>
|
||||
<div class="import-health-workbench__mix-main">{{ trendAlertText() }}</div>
|
||||
<div class="import-health-workbench__mix-sub">
|
||||
当前趋势 {{ trendText() }}
|
||||
</div>
|
||||
<div class="import-health-workbench__mix-sub">
|
||||
{{ workbenchTrend.alert_reason || "当前健康台趋势平稳" }}
|
||||
</div>
|
||||
<div
|
||||
v-for="item in (workbenchTrend.stage_buckets || []).slice(0, 3)"
|
||||
:key="item.stage"
|
||||
class="import-health-workbench__panel-row"
|
||||
>
|
||||
<span>{{ item.stage || "-" }}</span>
|
||||
<span>{{ item.count || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="(summary.priority_actions || []).length" class="import-health-workbench__actions">
|
||||
<div
|
||||
v-for="item in summary.priority_actions || []"
|
||||
:key="item.key"
|
||||
class="import-health-workbench__action-card"
|
||||
>
|
||||
<div class="import-health-workbench__action-head">
|
||||
<div class="import-health-workbench__action-title">{{ item.label || "-" }}</div>
|
||||
<el-tag size="small" :type="item.tag_type || 'info'">{{ actionTagText(item.tag_type) }}</el-tag>
|
||||
</div>
|
||||
<div class="import-health-workbench__action-summary">{{ item.summary || "-" }}</div>
|
||||
<div class="import-health-workbench__action-foot">
|
||||
<el-button size="small" :type="buttonType(item.tag_type)" plain @click="handlePriorityAction(item.action_key)">
|
||||
{{ item.action_label || "执行" }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="import-health-workbench__panels">
|
||||
<div class="import-health-workbench__panel">
|
||||
<div class="import-health-workbench__panel-title">最近导入</div>
|
||||
<div
|
||||
v-for="item in (summary.recent_import_runs || []).slice(0, 5)"
|
||||
:key="item.run_id"
|
||||
class="import-health-workbench__panel-row"
|
||||
>
|
||||
<span>{{ item.run_id || "-" }}</span>
|
||||
<span>失败 {{ item.failed_count || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="import-health-workbench__panel">
|
||||
<div class="import-health-workbench__panel-title">最近自动修复</div>
|
||||
<div
|
||||
v-for="item in (summary.recent_self_healing_runs || []).slice(0, 5)"
|
||||
:key="item.run_id"
|
||||
class="import-health-workbench__panel-row"
|
||||
>
|
||||
<span>{{ item.run_id || "-" }}</span>
|
||||
<span>{{ item.status || "-" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="import-health-workbench__panel">
|
||||
<div class="import-health-workbench__panel-title">最近重跑</div>
|
||||
<div
|
||||
v-for="item in (summary.recent_rerun_runs || []).slice(0, 5)"
|
||||
:key="item.run_id"
|
||||
class="import-health-workbench__panel-row"
|
||||
>
|
||||
<span>{{ item.run_id || "-" }}</span>
|
||||
<span>失败 {{ item.failed_count || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="import-health-workbench__panel">
|
||||
<div class="import-health-workbench__panel-title">最近补料</div>
|
||||
<div
|
||||
v-for="item in (summary.recent_remediation_runs || []).slice(0, 5)"
|
||||
:key="item.run_id"
|
||||
class="import-health-workbench__panel-row"
|
||||
>
|
||||
<span>{{ item.run_id || "-" }}</span>
|
||||
<span>恢复 {{ item.recovered_count || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
<DomainImportRunDialog ref="importRunDialogRef" />
|
||||
<DomainImportFailedQueueDialog ref="failedQueueDialogRef" />
|
||||
<DomainImportRerunDialog ref="rerunDialogRef" />
|
||||
<DomainImportRemediationDialog ref="remediationDialogRef" />
|
||||
<DomainImportSelfHealingDialog ref="selfHealingDialogRef" />
|
||||
<DomainImportManualAttentionDialog ref="manualAttentionDialogRef" />
|
||||
<DomainImportManualAttentionHandleDialog ref="manualHandleDialogRef" />
|
||||
<DomainImportHealthWorkbenchSummaryDialog ref="workbenchSummaryDialogRef" />
|
||||
<DomainImportHealthWorkbenchTrendDialog ref="workbenchTrendDialogRef" />
|
||||
<DomainImportTaskConsoleDialog ref="taskConsoleDialogRef" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import {
|
||||
getDomainImportHealthWorkbench,
|
||||
getDomainImportHealthWorkbenchSummary,
|
||||
getDomainImportHealthWorkbenchTrend,
|
||||
runDomainImportHealthWorkbenchSummary,
|
||||
runDomainImportSelfHealing
|
||||
} from "@/api/modules/site/list";
|
||||
import DomainImportRunDialog from "./domainImportRunDialog.vue";
|
||||
import DomainImportFailedQueueDialog from "./domainImportFailedQueueDialog.vue";
|
||||
import DomainImportRerunDialog from "./domainImportRerunDialog.vue";
|
||||
import DomainImportRemediationDialog from "./domainImportRemediationDialog.vue";
|
||||
import DomainImportSelfHealingDialog from "./domainImportSelfHealingDialog.vue";
|
||||
import DomainImportManualAttentionDialog from "./domainImportManualAttentionDialog.vue";
|
||||
import DomainImportManualAttentionHandleDialog from "./domainImportManualAttentionHandleDialog.vue";
|
||||
import DomainImportHealthWorkbenchSummaryDialog from "./domainImportHealthWorkbenchSummaryDialog.vue";
|
||||
import DomainImportHealthWorkbenchTrendDialog from "./domainImportHealthWorkbenchTrendDialog.vue";
|
||||
import DomainImportTaskConsoleDialog from "./domainImportTaskConsoleDialog.vue";
|
||||
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const workbenchRunLoading = ref(false);
|
||||
const selfHealingLoading = ref(false);
|
||||
const summary = ref<SiteList.DomainImportHealthWorkbenchData>({});
|
||||
const workbenchRunsSummary = ref<SiteList.DomainImportHealthWorkbenchSummaryData>({});
|
||||
const workbenchTrend = ref<SiteList.DomainImportHealthWorkbenchTrendData>({});
|
||||
const importRunDialogRef = ref();
|
||||
const failedQueueDialogRef = ref();
|
||||
const rerunDialogRef = ref();
|
||||
const remediationDialogRef = ref();
|
||||
const selfHealingDialogRef = ref();
|
||||
const manualAttentionDialogRef = ref();
|
||||
const manualHandleDialogRef = ref();
|
||||
const workbenchSummaryDialogRef = ref();
|
||||
const workbenchTrendDialogRef = ref();
|
||||
const taskConsoleDialogRef = ref();
|
||||
const requestGlobalDialogOpen = (key: string) => {
|
||||
window.dispatchEvent(new CustomEvent("site-dialog-open", { detail: { key } }));
|
||||
};
|
||||
|
||||
const healthLabelText = () => {
|
||||
const label = summary.value.health_label || "steady";
|
||||
if (label === "worsening") return "探测失败趋势上升,优先处理探测失败队列";
|
||||
if (label === "attention") return "当前仍有失败 host,建议关注自愈结果";
|
||||
if (label === "waiting_self_healing") return "存在失败 host,但还缺自动修复进展";
|
||||
return "导入链整体平稳";
|
||||
};
|
||||
|
||||
const actionTagText = (tagType?: string) => {
|
||||
if (tagType === "danger") return "高优先";
|
||||
if (tagType === "warning") return "先处理";
|
||||
if (tagType === "success") return "已接入";
|
||||
return "建议";
|
||||
};
|
||||
|
||||
const buttonType = (tagType?: string) => {
|
||||
if (tagType === "danger") return "danger";
|
||||
if (tagType === "warning") return "warning";
|
||||
if (tagType === "success") return "success";
|
||||
return "primary";
|
||||
};
|
||||
|
||||
const failureTrendText = () => {
|
||||
const label = summary.value.failure_trend?.label || "flat";
|
||||
if (label === "improving") return "改善中";
|
||||
if (label === "worsening") return "上升中";
|
||||
return "持平";
|
||||
};
|
||||
|
||||
const recoveryMixText = () => {
|
||||
const path = summary.value.recovery_mix?.primary_path || "none";
|
||||
if (path === "rerun") return "当前恢复主要靠重跑";
|
||||
if (path === "remediation") return "当前恢复主要靠补料";
|
||||
if (path === "balanced") return "当前恢复由重跑和补料共同承担";
|
||||
return "当前还没有明显恢复主路径";
|
||||
};
|
||||
|
||||
const manualAttentionText = () => {
|
||||
const count = summary.value.manual_attention_count || 0;
|
||||
if (count > 0) {
|
||||
return `当前有 ${count} 个 host 需要人工接管`;
|
||||
}
|
||||
return "当前没有需要人工接管的 host";
|
||||
};
|
||||
|
||||
const manualHandleText = () => {
|
||||
const latestRun = summary.value.latest_manual_handle_run || {};
|
||||
if (latestRun.run_id) {
|
||||
return `最新人工处理 ${latestRun.run_id},Host ${latestRun.host || "-"},状态 ${latestRun.status || "-"}`;
|
||||
}
|
||||
return "当前还没有人工处理记录";
|
||||
};
|
||||
|
||||
const workbenchAlertText = () => {
|
||||
const level = workbenchRunsSummary.value.latest_run?.alert_level || "none";
|
||||
if (level === "high") return "最近健康台 run 有高优先级告警";
|
||||
if (level === "medium") return "最近健康台 run 需要继续重点关注";
|
||||
if (level === "low") return "当前处于恢复观察态";
|
||||
return "最近健康台 run 整体平稳";
|
||||
};
|
||||
|
||||
const trendText = () => {
|
||||
const label = workbenchTrend.value.label || "flat";
|
||||
if (label === "improving") return "改善中";
|
||||
if (label === "worsening") return "上升中";
|
||||
return "持平";
|
||||
};
|
||||
|
||||
const trendAlertText = () => {
|
||||
const level = workbenchTrend.value.alert_level || "none";
|
||||
if (level === "high") return "趋势高优先级告警";
|
||||
if (level === "medium") return "趋势需要继续重点关注";
|
||||
if (level === "low") return "趋势进入低优先级观察态";
|
||||
return "趋势平稳";
|
||||
};
|
||||
|
||||
const openImportRuns = () => {
|
||||
importRunDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const openFailedQueue = () => {
|
||||
failedQueueDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const openSelfHealingRuns = () => {
|
||||
selfHealingDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const openManualAttention = () => {
|
||||
manualAttentionDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const openManualHandleRuns = () => {
|
||||
manualHandleDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const openRerunRuns = () => {
|
||||
rerunDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const openRemediationRuns = () => {
|
||||
remediationDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const openWorkbenchRuns = () => {
|
||||
workbenchSummaryDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const openWorkbenchTrend = () => {
|
||||
workbenchTrendDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const openClosureOverview = () => {
|
||||
requestGlobalDialogOpen("domainImportExternalSeoClosureDialog");
|
||||
};
|
||||
|
||||
const handlePriorityAction = async (actionKey?: string) => {
|
||||
if (actionKey === "open_import_runs") {
|
||||
openImportRuns();
|
||||
return;
|
||||
}
|
||||
if (actionKey === "open_failed_queue") {
|
||||
openFailedQueue();
|
||||
return;
|
||||
}
|
||||
if (actionKey === "open_self_healing_runs") {
|
||||
openSelfHealingRuns();
|
||||
return;
|
||||
}
|
||||
if (actionKey === "open_manual_attention") {
|
||||
openManualAttention();
|
||||
return;
|
||||
}
|
||||
if (actionKey === "open_manual_handle_runs") {
|
||||
openManualHandleRuns();
|
||||
return;
|
||||
}
|
||||
if (actionKey === "open_rerun_runs") {
|
||||
openRerunRuns();
|
||||
return;
|
||||
}
|
||||
if (actionKey === "open_remediation_runs") {
|
||||
openRemediationRuns();
|
||||
return;
|
||||
}
|
||||
if (actionKey === "run_self_healing") {
|
||||
await runSelfHealingAction();
|
||||
}
|
||||
};
|
||||
|
||||
const runSelfHealingAction = async () => {
|
||||
selfHealingLoading.value = true;
|
||||
try {
|
||||
const res = await runDomainImportSelfHealing();
|
||||
const job = res.data.job;
|
||||
if (!job?.job_id) {
|
||||
throw new Error("job_id missing");
|
||||
}
|
||||
ElMessage.success("自动修复任务已创建");
|
||||
taskConsoleDialogRef.value?.open("自动修复任务", job.job_id, async () => {
|
||||
await loadSummary();
|
||||
openSelfHealingRuns();
|
||||
});
|
||||
} catch (_error) {
|
||||
ElMessage.error("执行自动修复失败");
|
||||
} finally {
|
||||
selfHealingLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const runWorkbenchSummaryAction = async () => {
|
||||
workbenchRunLoading.value = true;
|
||||
try {
|
||||
const res = await runDomainImportHealthWorkbenchSummary({ limit: 10 });
|
||||
const job = res.data.job;
|
||||
if (!job?.job_id) {
|
||||
throw new Error("job_id missing");
|
||||
}
|
||||
ElMessage.success("健康台刷新任务已创建");
|
||||
taskConsoleDialogRef.value?.open("健康台落盘任务", job.job_id, async () => {
|
||||
await loadSummary();
|
||||
openWorkbenchRuns();
|
||||
});
|
||||
} catch (_error) {
|
||||
ElMessage.error("刷新并落盘健康台失败");
|
||||
} finally {
|
||||
workbenchRunLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadSummary = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [workbenchRes, runsRes, trendRes] = await Promise.all([
|
||||
getDomainImportHealthWorkbench({ limit: 10 }),
|
||||
getDomainImportHealthWorkbenchSummary({ limit: 10 }),
|
||||
getDomainImportHealthWorkbenchTrend({ limit: 10 })
|
||||
]);
|
||||
summary.value = workbenchRes.data ?? {};
|
||||
workbenchRunsSummary.value = runsRes.data ?? {};
|
||||
workbenchTrend.value = trendRes.data ?? {};
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取导入健康台失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadSummary();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.import-health-workbench__toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.import-health-workbench__hero {
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #f3f8ff 0%, #eefaf4 100%);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.import-health-workbench__hero-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.import-health-workbench__hero-desc,
|
||||
.import-health-workbench__hero-note,
|
||||
.import-health-workbench__stats,
|
||||
.import-health-workbench__resolved {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.import-health-workbench__hero-note {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.import-health-workbench__stats {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.import-health-workbench__blocks {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.import-health-workbench__block {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.import-health-workbench__block-title {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.import-health-workbench__resolved-label {
|
||||
margin-right: 8px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.import-health-workbench__insights {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.import-health-workbench__insight-card {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.import-health-workbench__mix-main {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.import-health-workbench__mix-sub,
|
||||
.import-health-workbench__empty {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.import-health-workbench__actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.import-health-workbench__action-card {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.import-health-workbench__action-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.import-health-workbench__action-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.import-health-workbench__action-summary {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
line-height: 1.6;
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.import-health-workbench__action-foot {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.import-health-workbench__panels {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.import-health-workbench__panel {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.import-health-workbench__panel-title {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.import-health-workbench__panel-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 6px 0;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
border-top: 1px dashed #f0f2f5;
|
||||
}
|
||||
|
||||
.import-health-workbench__panel-row:first-of-type {
|
||||
border-top: 0;
|
||||
}
|
||||
</style>
|
||||
139
src/views/site/domainImportHealthWorkbenchSummaryDialog.vue
Normal file
139
src/views/site/domainImportHealthWorkbenchSummaryDialog.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="最近导入健康台记录" width="980px" draggable>
|
||||
<div class="import-health-workbench-summary-dialog">
|
||||
<div class="import-health-workbench-summary-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadRuns">刷新</el-button>
|
||||
<el-button type="info" plain @click="openTrend">健康台趋势</el-button>
|
||||
</div>
|
||||
<div class="import-health-workbench-summary-dialog__summary">
|
||||
<span>健康台记录:{{ summary.total || 0 }}</span>
|
||||
<span>最新健康度:{{ summary.latest_run?.health_label || "-" }}</span>
|
||||
<span>最新阶段:{{ summary.latest_run?.workbench_stage || "-" }}</span>
|
||||
<span>最新失败:{{ summary.latest_run?.active_count || 0 }}</span>
|
||||
</div>
|
||||
<div class="import-health-workbench-summary-dialog__summary">
|
||||
<span>高优先级:{{ summary.alert_buckets?.high || 0 }}</span>
|
||||
<span>中优先级:{{ summary.alert_buckets?.medium || 0 }}</span>
|
||||
<span>低优先级:{{ summary.alert_buckets?.low || 0 }}</span>
|
||||
</div>
|
||||
<div v-if="(summary.top_attention_runs || []).length" class="import-health-workbench-summary-dialog__attention">
|
||||
<div class="import-health-workbench-summary-dialog__attention-title">优先关注</div>
|
||||
<div
|
||||
v-for="item in summary.top_attention_runs || []"
|
||||
:key="item.run_id"
|
||||
class="import-health-workbench-summary-dialog__attention-row"
|
||||
>
|
||||
<span>{{ item.run_id || "-" }}</span>
|
||||
<span>{{ item.alert_reason || "-" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="rows" border>
|
||||
<el-table-column prop="run_id" label="Run ID" min-width="180" />
|
||||
<el-table-column prop="health_label" label="健康度" width="110" />
|
||||
<el-table-column prop="workbench_stage" label="阶段" width="140" />
|
||||
<el-table-column prop="alert_level" label="告警" width="90" />
|
||||
<el-table-column prop="active_count" label="当前失败" width="90" />
|
||||
<el-table-column prop="resolved_count" label="已恢复" width="90" />
|
||||
<el-table-column prop="hero_summary" label="摘要" min-width="260" />
|
||||
<el-table-column prop="updated_at" label="更新时间" min-width="180" />
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.summary_html_path" link type="primary" @click="openPublicPath(row.summary_html_path)">HTML摘要</el-button>
|
||||
<el-button v-if="row.summary_json_path" link type="info" @click="openPublicPath(row.summary_json_path)">JSON摘要</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-dialog>
|
||||
<DomainImportHealthWorkbenchTrendDialog ref="trendDialogRef" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainImportHealthWorkbenchSummary } from "@/api/modules/site/list";
|
||||
import DomainImportHealthWorkbenchTrendDialog from "./domainImportHealthWorkbenchTrendDialog.vue";
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL as string;
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const rows = ref<SiteList.DomainImportHealthWorkbenchSummaryItem[]>([]);
|
||||
const summary = ref<SiteList.DomainImportHealthWorkbenchSummaryData>({});
|
||||
const trendDialogRef = ref();
|
||||
|
||||
const openPublicPath = (path?: string) => {
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
window.open(`${BASE_URL}/${String(path).replace(/^\/+/, "")}`, "_blank", "noopener");
|
||||
};
|
||||
|
||||
const loadRuns = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDomainImportHealthWorkbenchSummary({ limit: 20 });
|
||||
summary.value = res.data ?? {};
|
||||
rows.value = summary.value.items ?? [];
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取导入健康台记录失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openTrend = () => {
|
||||
trendDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadRuns();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.import-health-workbench-summary-dialog__toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.import-health-workbench-summary-dialog__summary {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.import-health-workbench-summary-dialog__attention {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.import-health-workbench-summary-dialog__attention-title {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.import-health-workbench-summary-dialog__attention-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 6px 0;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
border-top: 1px dashed #f0f2f5;
|
||||
}
|
||||
|
||||
.import-health-workbench-summary-dialog__attention-row:first-of-type {
|
||||
border-top: 0;
|
||||
}
|
||||
</style>
|
||||
131
src/views/site/domainImportHealthWorkbenchTrendDialog.vue
Normal file
131
src/views/site/domainImportHealthWorkbenchTrendDialog.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="导入健康台趋势" width="920px" draggable>
|
||||
<div class="import-health-workbench-trend-dialog">
|
||||
<div class="import-health-workbench-trend-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadSummary">刷新</el-button>
|
||||
</div>
|
||||
<div class="import-health-workbench-trend-dialog__summary">
|
||||
<span>健康台记录:{{ summary.runs_count || 0 }}</span>
|
||||
<span>最新失败:{{ summary.latest_active_count || 0 }}</span>
|
||||
<span>上一笔失败:{{ summary.previous_active_count || 0 }}</span>
|
||||
<span>{{ trendText() }}</span>
|
||||
</div>
|
||||
<div class="import-health-workbench-trend-dialog__summary">
|
||||
<span>趋势告警:{{ summary.alert_level || "-" }}</span>
|
||||
<span>{{ summary.alert_reason || "-" }}</span>
|
||||
</div>
|
||||
<div class="import-health-workbench-trend-dialog__blocks">
|
||||
<div class="import-health-workbench-trend-dialog__block">
|
||||
<div class="import-health-workbench-trend-dialog__block-title">最新阶段</div>
|
||||
<div>{{ summary.latest_stage || "-" }}</div>
|
||||
<div>{{ summary.latest_health_label || "-" }}</div>
|
||||
</div>
|
||||
<div class="import-health-workbench-trend-dialog__block">
|
||||
<div class="import-health-workbench-trend-dialog__block-title">上一笔阶段</div>
|
||||
<div>{{ summary.previous_stage || "-" }}</div>
|
||||
<div>{{ summary.previous_health_label || "-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="import-health-workbench-trend-dialog__panel">
|
||||
<div class="import-health-workbench-trend-dialog__panel-title">阶段分布</div>
|
||||
<div
|
||||
v-for="item in summary.stage_buckets || []"
|
||||
:key="item.stage"
|
||||
class="import-health-workbench-trend-dialog__panel-row"
|
||||
>
|
||||
<span>{{ item.stage || "-" }}</span>
|
||||
<span>{{ item.count || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainImportHealthWorkbenchTrend } from "@/api/modules/site/list";
|
||||
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const summary = ref<SiteList.DomainImportHealthWorkbenchTrendData>({});
|
||||
|
||||
const trendText = () => {
|
||||
const label = summary.value.label || "flat";
|
||||
if (label === "improving") return "健康台趋势改善中";
|
||||
if (label === "worsening") return "健康台趋势上升";
|
||||
return "健康台趋势持平";
|
||||
};
|
||||
|
||||
const loadSummary = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDomainImportHealthWorkbenchTrend({ limit: 10 });
|
||||
summary.value = res.data ?? {};
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取导入健康台趋势失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadSummary();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.import-health-workbench-trend-dialog__toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.import-health-workbench-trend-dialog__summary {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.import-health-workbench-trend-dialog__blocks {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.import-health-workbench-trend-dialog__block,
|
||||
.import-health-workbench-trend-dialog__panel {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.import-health-workbench-trend-dialog__block-title,
|
||||
.import-health-workbench-trend-dialog__panel-title {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.import-health-workbench-trend-dialog__panel-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 6px 0;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
border-top: 1px dashed #f0f2f5;
|
||||
}
|
||||
|
||||
.import-health-workbench-trend-dialog__panel-row:first-of-type {
|
||||
border-top: 0;
|
||||
}
|
||||
</style>
|
||||
120
src/views/site/domainImportManualAttentionDialog.vue
Normal file
120
src/views/site/domainImportManualAttentionDialog.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="导入人工关注队列" width="980px" draggable>
|
||||
<div class="import-manual-attention-dialog">
|
||||
<div class="import-manual-attention-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadSummary">刷新</el-button>
|
||||
<el-button type="info" plain @click="openHandleRuns">最近人工处理记录</el-button>
|
||||
</div>
|
||||
<div class="import-manual-attention-dialog__summary">
|
||||
<span>人工关注:{{ summary.queue_count || 0 }}</span>
|
||||
<span>高优先:{{ summary.attention_buckets?.high || 0 }}</span>
|
||||
<span>中优先:{{ summary.attention_buckets?.medium || 0 }}</span>
|
||||
<span>低优先:{{ summary.attention_buckets?.low || 0 }}</span>
|
||||
</div>
|
||||
<div class="import-manual-attention-dialog__summary">
|
||||
<span>最新自动修复:{{ summary.self_healing_latest_run?.run_id || "-" }}</span>
|
||||
<span>最新状态:{{ summary.self_healing_latest_run?.status || "-" }}</span>
|
||||
<span>最新降级:{{ summary.self_healing_latest_run?.downgraded_count || 0 }}</span>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="rows" border>
|
||||
<el-table-column prop="host" label="Host" min-width="180" />
|
||||
<el-table-column prop="attention_level" label="优先级" width="90" />
|
||||
<el-table-column prop="attention_source" label="来源" width="120" />
|
||||
<el-table-column prop="failed_stage" label="失败阶段" width="120" />
|
||||
<el-table-column prop="latest_rerun_status" label="最近重跑" width="100" />
|
||||
<el-table-column prop="latest_remediation_status" label="最近补料" width="100" />
|
||||
<el-table-column prop="handled_status" label="人工处理" width="100" />
|
||||
<el-table-column prop="attention_reason" label="原因" min-width="280" />
|
||||
<el-table-column prop="updated_at" label="更新时间" min-width="180" />
|
||||
<el-table-column label="操作" width="360" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="success" @click="runHandle(row, 'resolved')">处理并复核</el-button>
|
||||
<el-button link type="warning" @click="runHandle(row, 'defer')">暂缓</el-button>
|
||||
<el-button link type="danger" @click="runHandle(row, 'ignored')">忽略</el-button>
|
||||
<el-button v-if="row.summary_html_path" link type="primary" @click="openPublicPath(row.summary_html_path)">HTML摘要</el-button>
|
||||
<el-button v-if="row.summary_json_path" link type="info" @click="openPublicPath(row.summary_json_path)">JSON摘要</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-dialog>
|
||||
<DomainImportManualAttentionHandleDialog ref="handleDialogRef" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainImportManualAttentionQueue, runDomainImportManualAttentionHandle } from "@/api/modules/site/list";
|
||||
import DomainImportManualAttentionHandleDialog from "./domainImportManualAttentionHandleDialog.vue";
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL as string;
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const rows = ref<SiteList.DomainImportManualAttentionQueueItem[]>([]);
|
||||
const summary = ref<SiteList.DomainImportManualAttentionQueueData>({});
|
||||
const handleDialogRef = ref();
|
||||
|
||||
const openPublicPath = (path?: string) => {
|
||||
if (!path) return;
|
||||
window.open(`${BASE_URL}/${String(path).replace(/^\/+/, "")}`, "_blank", "noopener");
|
||||
};
|
||||
|
||||
const openHandleRuns = () => {
|
||||
handleDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const runHandle = async (row: SiteList.DomainImportManualAttentionQueueItem, action: "resolved" | "defer" | "ignored") => {
|
||||
const host = row.host || "";
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
const actionLabel = action === "resolved" ? "处理并复核" : action === "defer" ? "暂缓" : "忽略";
|
||||
await ElMessageBox.confirm(`是否对 ${host} 执行“${actionLabel}”?`, "人工处理确认", {
|
||||
type: action === "ignored" ? "warning" : "info"
|
||||
});
|
||||
try {
|
||||
const res = await runDomainImportManualAttentionHandle({ host, action });
|
||||
const item = res.data.summary?.items?.[0] ?? {};
|
||||
ElMessage.success(`${host} 已处理:${item.status || actionLabel}`);
|
||||
await loadSummary();
|
||||
openHandleRuns();
|
||||
} catch (_error) {
|
||||
ElMessage.error(`执行${actionLabel}失败`);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSummary = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDomainImportManualAttentionQueue({ limit: 20 });
|
||||
summary.value = res.data ?? {};
|
||||
rows.value = summary.value.items ?? [];
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取导入人工关注队列失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadSummary();
|
||||
};
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.import-manual-attention-dialog__toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.import-manual-attention-dialog__summary {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
81
src/views/site/domainImportManualAttentionHandleDialog.vue
Normal file
81
src/views/site/domainImportManualAttentionHandleDialog.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="最近人工处理记录" width="900px" draggable>
|
||||
<div class="import-manual-attention-handle-dialog">
|
||||
<div class="import-manual-attention-handle-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadSummary">刷新</el-button>
|
||||
</div>
|
||||
<div class="import-manual-attention-handle-dialog__summary">
|
||||
<span>处理记录:{{ summary.total || 0 }}</span>
|
||||
<span>最新 Host:{{ summary.latest_run?.host || "-" }}</span>
|
||||
<span>最新动作:{{ summary.latest_run?.action || "-" }}</span>
|
||||
<span>最新状态:{{ summary.latest_run?.status || "-" }}</span>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="rows" border>
|
||||
<el-table-column prop="run_id" label="Run ID" min-width="180" />
|
||||
<el-table-column prop="host" label="Host" min-width="160" />
|
||||
<el-table-column prop="action" label="动作" width="100" />
|
||||
<el-table-column prop="status" label="状态" width="100" />
|
||||
<el-table-column prop="reprobe_status" label="复核" width="120" />
|
||||
<el-table-column prop="updated_at" label="更新时间" min-width="180" />
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.summary_html_path" link type="primary" @click="openPublicPath(row.summary_html_path)">HTML摘要</el-button>
|
||||
<el-button v-if="row.summary_json_path" link type="info" @click="openPublicPath(row.summary_json_path)">JSON摘要</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainImportManualAttentionHandleSummary } from "@/api/modules/site/list";
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL as string;
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const rows = ref<SiteList.DomainImportManualAttentionHandleSummaryItem[]>([]);
|
||||
const summary = ref<SiteList.DomainImportManualAttentionHandleSummaryData>({});
|
||||
|
||||
const openPublicPath = (path?: string) => {
|
||||
if (!path) return;
|
||||
window.open(`${BASE_URL}/${String(path).replace(/^\/+/, "")}`, "_blank", "noopener");
|
||||
};
|
||||
|
||||
const loadSummary = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDomainImportManualAttentionHandleSummary({ limit: 20 });
|
||||
summary.value = res.data ?? {};
|
||||
rows.value = summary.value.items ?? [];
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取人工处理记录失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadSummary();
|
||||
};
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.import-manual-attention-handle-dialog__toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.import-manual-attention-handle-dialog__summary {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
73
src/views/site/domainImportRemediationDialog.vue
Normal file
73
src/views/site/domainImportRemediationDialog.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="最近补料记录" width="980px" draggable>
|
||||
<div class="import-remediation-dialog">
|
||||
<div class="import-remediation-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadRuns">刷新</el-button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="rows" border>
|
||||
<el-table-column prop="run_id" label="Run ID" min-width="180" />
|
||||
<el-table-column prop="processed_count" label="补料" width="90" />
|
||||
<el-table-column prop="recovered_count" label="恢复" width="90" />
|
||||
<el-table-column prop="failed_count" label="失败" width="90" />
|
||||
<el-table-column label="Host" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<span>{{ (row.hosts || []).slice(0, 4).join(", ") || "-" }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updated_at" label="更新时间" min-width="180" />
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.summary_html_path" link type="primary" @click="openPublicPath(row.summary_html_path)">HTML摘要</el-button>
|
||||
<el-button v-if="row.summary_json_path" link type="info" @click="openPublicPath(row.summary_json_path)">JSON摘要</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainImportRemediationSummary } from "@/api/modules/site/list";
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL as string;
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const rows = ref<SiteList.DomainImportRemediationSummaryItem[]>([]);
|
||||
|
||||
const openPublicPath = (path?: string) => {
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
window.open(`${BASE_URL}/${String(path).replace(/^\/+/, "")}`, "_blank", "noopener");
|
||||
};
|
||||
|
||||
const loadRuns = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDomainImportRemediationSummary({ limit: 20 });
|
||||
rows.value = res.data.items ?? [];
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取补料记录失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadRuns();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.import-remediation-dialog__toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
73
src/views/site/domainImportRerunDialog.vue
Normal file
73
src/views/site/domainImportRerunDialog.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="最近重跑记录" width="980px" draggable>
|
||||
<div class="import-rerun-dialog">
|
||||
<div class="import-rerun-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadRuns">刷新</el-button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="rows" border>
|
||||
<el-table-column prop="run_id" label="Run ID" min-width="180" />
|
||||
<el-table-column prop="processed_count" label="重跑" width="90" />
|
||||
<el-table-column prop="passed_count" label="通过" width="90" />
|
||||
<el-table-column prop="failed_count" label="失败" width="90" />
|
||||
<el-table-column label="Host" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<span>{{ (row.hosts || []).slice(0, 4).join(", ") || "-" }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updated_at" label="更新时间" min-width="180" />
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.summary_html_path" link type="primary" @click="openPublicPath(row.summary_html_path)">HTML摘要</el-button>
|
||||
<el-button v-if="row.summary_json_path" link type="info" @click="openPublicPath(row.summary_json_path)">JSON摘要</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainImportRerunSummary } from "@/api/modules/site/list";
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL as string;
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const rows = ref<SiteList.DomainImportRerunSummaryItem[]>([]);
|
||||
|
||||
const openPublicPath = (path?: string) => {
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
window.open(`${BASE_URL}/${String(path).replace(/^\/+/, "")}`, "_blank", "noopener");
|
||||
};
|
||||
|
||||
const loadRuns = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDomainImportRerunSummary({ limit: 20 });
|
||||
rows.value = res.data.items ?? [];
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取重跑记录失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadRuns();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.import-rerun-dialog__toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
76
src/views/site/domainImportRunDialog.vue
Normal file
76
src/views/site/domainImportRunDialog.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="最近导入记录" width="980px" draggable>
|
||||
<div class="import-run-dialog">
|
||||
<div class="import-run-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadRuns">刷新</el-button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="rows" border>
|
||||
<el-table-column prop="run_id" label="Run ID" min-width="180" />
|
||||
<el-table-column prop="status" label="导入状态" width="100" />
|
||||
<el-table-column prop="probe_status" label="探测状态" width="100" />
|
||||
<el-table-column prop="processed_count" label="探测" width="90" />
|
||||
<el-table-column prop="passed_count" label="通过" width="90" />
|
||||
<el-table-column prop="failed_count" label="失败" width="90" />
|
||||
<el-table-column label="失败域名" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<span>{{ (row.failed_hosts || []).slice(0, 3).join(", ") || "-" }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updated_at" label="更新时间" min-width="180" />
|
||||
<el-table-column label="操作" width="240" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.summary_html_path" link type="primary" @click="openPublicPath(row.summary_html_path)">HTML摘要</el-button>
|
||||
<el-button v-if="row.summary_json_path" link type="info" @click="openPublicPath(row.summary_json_path)">JSON摘要</el-button>
|
||||
<el-button v-if="row.uploaded_excel_path" link type="success" @click="openPublicPath(row.uploaded_excel_path)">原始Excel</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainImportRunSummary } from "@/api/modules/site/list";
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL as string;
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const rows = ref<SiteList.DomainImportRunSummaryItem[]>([]);
|
||||
|
||||
const openPublicPath = (path?: string) => {
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
window.open(`${BASE_URL}/${String(path).replace(/^\/+/, "")}`, "_blank", "noopener");
|
||||
};
|
||||
|
||||
const loadRuns = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDomainImportRunSummary({ limit: 20 });
|
||||
rows.value = res.data.items ?? [];
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取导入记录失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadRuns();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.import-run-dialog__toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
92
src/views/site/domainImportSelfHealingDialog.vue
Normal file
92
src/views/site/domainImportSelfHealingDialog.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="最近自动修复记录" width="980px" draggable>
|
||||
<div class="import-self-healing-dialog">
|
||||
<div class="import-self-healing-dialog__toolbar">
|
||||
<el-button type="primary" plain @click="loadRuns">刷新</el-button>
|
||||
</div>
|
||||
<div class="import-self-healing-dialog__summary">
|
||||
<span>自动修复记录:{{ summary.total || 0 }}</span>
|
||||
<span>最新状态:{{ summary.latest_run?.status || "-" }}</span>
|
||||
<span>最新队列:{{ summary.latest_run?.queue_count || 0 }}</span>
|
||||
<span>最新选中:{{ summary.latest_run?.selected_count || 0 }}</span>
|
||||
<span>最新跳过:{{ summary.latest_run?.skipped_count || 0 }}</span>
|
||||
<span>最新降级:{{ summary.latest_run?.downgraded_count || 0 }}</span>
|
||||
<span>最新补料恢复:{{ summary.latest_run?.resolved_by_remediation_count || 0 }}</span>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="rows" border>
|
||||
<el-table-column prop="run_id" label="Run ID" min-width="180" />
|
||||
<el-table-column prop="status" label="状态" width="100" />
|
||||
<el-table-column prop="queue_count" label="队列" width="80" />
|
||||
<el-table-column prop="selected_count" label="选中" width="80" />
|
||||
<el-table-column prop="skipped_count" label="跳过" width="80" />
|
||||
<el-table-column prop="downgraded_count" label="降级" width="80" />
|
||||
<el-table-column prop="rerun_runs_count" label="重跑数" width="90" />
|
||||
<el-table-column prop="remediation_runs_count" label="补料数" width="90" />
|
||||
<el-table-column prop="resolved_by_remediation_count" label="补料恢复" width="100" />
|
||||
<el-table-column prop="updated_at" label="更新时间" min-width="180" />
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.summary_html_path" link type="primary" @click="openPublicPath(row.summary_html_path)">HTML摘要</el-button>
|
||||
<el-button v-if="row.summary_json_path" link type="info" @click="openPublicPath(row.summary_json_path)">JSON摘要</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainImportSelfHealingSummary } from "@/api/modules/site/list";
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL as string;
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const rows = ref<SiteList.DomainImportSelfHealingSummaryItem[]>([]);
|
||||
const summary = ref<SiteList.DomainImportSelfHealingSummaryData>({});
|
||||
|
||||
const openPublicPath = (path?: string) => {
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
window.open(`${BASE_URL}/${String(path).replace(/^\/+/, "")}`, "_blank", "noopener");
|
||||
};
|
||||
|
||||
const loadRuns = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDomainImportSelfHealingSummary({ limit: 20 });
|
||||
summary.value = res.data ?? {};
|
||||
rows.value = summary.value.items ?? [];
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取自动修复记录失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await loadRuns();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.import-self-healing-dialog__toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.import-self-healing-dialog__summary {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
209
src/views/site/domainImportTaskConsoleDialog.vue
Normal file
209
src/views/site/domainImportTaskConsoleDialog.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" :title="dialogTitle" width="980px" draggable>
|
||||
<div class="task-console">
|
||||
<div class="task-console__meta">
|
||||
<span>状态:{{ statusLabel(job.status) }}</span>
|
||||
<span>进度:{{ job.progress_percent || 0 }}%</span>
|
||||
<span>阶段:{{ stepLabel(job.current_step) }}</span>
|
||||
<span>任务号:{{ job.job_id || "-" }}</span>
|
||||
</div>
|
||||
<div class="task-console__meta">
|
||||
<span>当前:{{ job.progress_current || 0 }}</span>
|
||||
<span>总数:{{ job.progress_total || 0 }}</span>
|
||||
<span>开始:{{ formatDateTime(job.started_at) }}</span>
|
||||
<span>结束:{{ formatDateTime(job.finished_at) }}</span>
|
||||
</div>
|
||||
<el-progress :percentage="job.progress_percent || 0" :status="progressStatus()" />
|
||||
<div class="task-console__message">{{ job.message || "等待任务状态..." }}</div>
|
||||
<div ref="screenRef" class="task-console__screen">
|
||||
<div v-for="(line, index) in job.log_tail || []" :key="`${index}-${line}`" class="task-console__line">
|
||||
{{ line }}
|
||||
</div>
|
||||
<div v-if="!(job.log_tail || []).length" class="task-console__line task-console__line--muted">等待日志输出...</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import { getDomainImportAsyncJobStatus } from "@/api/modules/site/list";
|
||||
|
||||
const visible = ref(false);
|
||||
const dialogTitle = ref("任务执行窗口");
|
||||
const job = ref<SiteList.DomainImportAsyncJobStatusData>({});
|
||||
const activeJobId = ref("");
|
||||
const screenRef = ref<HTMLElement | null>(null);
|
||||
let onFinished: ((job: SiteList.DomainImportAsyncJobStatusData) => void) | null = null;
|
||||
let finishedNotified = false;
|
||||
let timer: number | null = null;
|
||||
|
||||
const statusLabel = (status?: string) => {
|
||||
const map: Record<string, string> = {
|
||||
queued: "已排队",
|
||||
running: "执行中",
|
||||
success: "成功",
|
||||
failed: "失败"
|
||||
};
|
||||
return status ? map[status] || status : "-";
|
||||
};
|
||||
|
||||
const stepLabel = (step?: string) => {
|
||||
const map: Record<string, string> = {
|
||||
queued: "已排队",
|
||||
starting: "开始执行",
|
||||
prepare: "准备中",
|
||||
sample_discovery: "样本发现",
|
||||
probing: "页面探测",
|
||||
probe_failed: "探测失败",
|
||||
running: "执行中",
|
||||
health_workbench: "健康台落盘",
|
||||
completed: "已完成",
|
||||
failed: "失败"
|
||||
};
|
||||
return step ? map[step] || step : "-";
|
||||
};
|
||||
|
||||
const formatDateTime = (value?: string) => {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
const year = date.getFullYear();
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, "0");
|
||||
const day = `${date.getDate()}`.padStart(2, "0");
|
||||
const hour = `${date.getHours()}`.padStart(2, "0");
|
||||
const minute = `${date.getMinutes()}`.padStart(2, "0");
|
||||
const second = `${date.getSeconds()}`.padStart(2, "0");
|
||||
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
|
||||
};
|
||||
|
||||
const progressStatus = () => {
|
||||
if (job.value.status === "failed") return "exception";
|
||||
if (job.value.status === "success") return "success";
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const stopPolling = () => {
|
||||
if (timer) {
|
||||
window.clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scrollScreenToBottom = async () => {
|
||||
await nextTick();
|
||||
const screenEl = screenRef.value;
|
||||
if (!screenEl) {
|
||||
return;
|
||||
}
|
||||
screenEl.scrollTop = screenEl.scrollHeight;
|
||||
};
|
||||
|
||||
const loadStatus = async (silent = false) => {
|
||||
if (!activeJobId.value) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await getDomainImportAsyncJobStatus({ job_id: activeJobId.value, lines: 200 });
|
||||
job.value = res.data ?? {};
|
||||
if (job.value.status === "success" || job.value.status === "failed") {
|
||||
stopPolling();
|
||||
if (!finishedNotified && onFinished) {
|
||||
finishedNotified = true;
|
||||
onFinished(job.value);
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
stopPolling();
|
||||
if (!silent) {
|
||||
ElMessage.error("读取任务状态失败");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startPolling = () => {
|
||||
stopPolling();
|
||||
timer = window.setInterval(() => {
|
||||
loadStatus(true);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const open = async (title: string, jobId: string, callback?: (job: SiteList.DomainImportAsyncJobStatusData) => void) => {
|
||||
dialogTitle.value = title;
|
||||
activeJobId.value = jobId;
|
||||
job.value = {};
|
||||
onFinished = callback ?? null;
|
||||
finishedNotified = false;
|
||||
visible.value = true;
|
||||
await loadStatus();
|
||||
startPolling();
|
||||
scrollScreenToBottom();
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [visible.value, (job.value.log_tail || []).length, job.value.message, job.value.status],
|
||||
([isVisible]) => {
|
||||
if (!isVisible) {
|
||||
return;
|
||||
}
|
||||
scrollScreenToBottom();
|
||||
}
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling();
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.task-console {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.task-console__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.task-console__message {
|
||||
font-size: 13px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.task-console__screen {
|
||||
min-height: 360px;
|
||||
max-height: 480px;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: #0f172a;
|
||||
color: #d1fae5;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.task-console__line {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.task-console__line--muted {
|
||||
color: #94a3b8;
|
||||
}
|
||||
</style>
|
||||
689
src/views/site/domainSeoCopyWorkbenchDialog.vue
Normal file
689
src/views/site/domainSeoCopyWorkbenchDialog.vue
Normal file
@@ -0,0 +1,689 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="AI文案工作台" width="1080px" draggable>
|
||||
<div class="seo-copy-workbench">
|
||||
<div class="seo-copy-workbench__toolbar">
|
||||
<el-button type="primary" plain @click="loadData">刷新</el-button>
|
||||
<el-input
|
||||
v-model="hostFilter"
|
||||
clearable
|
||||
placeholder="筛选域名,可留空看全部"
|
||||
style="width: 260px"
|
||||
@keyup.enter="loadHistory"
|
||||
/>
|
||||
<el-button type="info" plain @click="loadHistory">筛选历史</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
:title="providerAlertTitle"
|
||||
:description="providerAlertDescription"
|
||||
:type="providerSummary.configured ? 'success' : 'warning'"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<el-alert
|
||||
v-if="providerSummary.config_note"
|
||||
:title="providerSummary.protocol ? `当前协议:${providerSummary.protocol}` : '当前协议说明'"
|
||||
:description="providerSummary.config_note"
|
||||
type="info"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<div class="seo-copy-workbench__grid">
|
||||
<div class="seo-copy-workbench__card">
|
||||
<div class="seo-copy-workbench__card-title">当前 Provider</div>
|
||||
<div class="seo-copy-workbench__card-value">{{ providerSummary.provider_label || "-" }}</div>
|
||||
<div class="seo-copy-workbench__card-meta">
|
||||
model {{ providerSummary.model || "-" }} / fallback {{ providerSummary.fallback_provider || "-" }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="seo-copy-workbench__card">
|
||||
<div class="seo-copy-workbench__card-title">域名总数</div>
|
||||
<div class="seo-copy-workbench__card-value">{{ providerSummary.domain_counts?.total || 0 }}</div>
|
||||
<div class="seo-copy-workbench__card-meta">当前已纳入文案状态追踪的站点数</div>
|
||||
</div>
|
||||
<div class="seo-copy-workbench__card">
|
||||
<div class="seo-copy-workbench__card-title">等待 AI</div>
|
||||
<div class="seo-copy-workbench__card-value">{{ providerSummary.domain_counts?.ai_pending || 0 }}</div>
|
||||
<div class="seo-copy-workbench__card-meta">已提交任务,等待生成或重优化完成</div>
|
||||
</div>
|
||||
<div class="seo-copy-workbench__card">
|
||||
<div class="seo-copy-workbench__card-title">AI 已就绪</div>
|
||||
<div class="seo-copy-workbench__card-value">{{ providerSummary.domain_counts?.ai_ready || 0 }}</div>
|
||||
<div class="seo-copy-workbench__card-meta">已发布 AI 文案,可直接作为前台优先稿</div>
|
||||
</div>
|
||||
<div class="seo-copy-workbench__card">
|
||||
<div class="seo-copy-workbench__card-title">Node 热词反哺域名</div>
|
||||
<div class="seo-copy-workbench__card-value">{{ providerSummary.resource_pool_summary?.reflected_host_count || 0 }}</div>
|
||||
<div class="seo-copy-workbench__card-meta">已写入 keyword_feedback/by_host 的域名数</div>
|
||||
</div>
|
||||
<div class="seo-copy-workbench__card">
|
||||
<div class="seo-copy-workbench__card-title">建议 AI 介入</div>
|
||||
<div class="seo-copy-workbench__card-value">{{ providerSummary.ai_trigger_summary?.recommend_count || 0 }}</div>
|
||||
<div class="seo-copy-workbench__card-meta">基于本地评分规则,值得优先做 AI 重优化的域名数</div>
|
||||
</div>
|
||||
<div class="seo-copy-workbench__card">
|
||||
<div class="seo-copy-workbench__card-title">最近 AI 动作</div>
|
||||
<div class="seo-copy-workbench__card-value">{{ latestHistoryAction }}</div>
|
||||
<div class="seo-copy-workbench__card-meta">{{ latestHistoryMeta }}</div>
|
||||
</div>
|
||||
<div class="seo-copy-workbench__card">
|
||||
<div class="seo-copy-workbench__card-title">OpenAI 占比</div>
|
||||
<div class="seo-copy-workbench__card-value">{{ recentOpenAiCount }}/{{ recentHistoryCount }}</div>
|
||||
<div class="seo-copy-workbench__card-meta">最近历史里真实走 OpenAI 的记录数</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-descriptions :column="2" border size="small" class="seo-copy-workbench__desc">
|
||||
<el-descriptions-item label="Base URL">{{ providerSummary.base_url || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="API Key">{{ providerSummary.api_key_configured ? "已配置" : "未配置" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="缺失项">
|
||||
{{ Array.isArray(providerSummary.missing_fields) && providerSummary.missing_fields.length ? providerSummary.missing_fields.join("、") : "无" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="兼容模式">
|
||||
{{ providerSummary.openai_compatible ? "OpenAI 协议兼容" : "本地规则" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="已发布文案目录">{{ providerSummary.published_root || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="历史目录">{{ providerSummary.history_root || "-" }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="seo-copy-workbench__history-head">
|
||||
<div class="seo-copy-workbench__history-title">资源池与 Node 反哺状态</div>
|
||||
<div class="seo-copy-workbench__history-note">这里看的是本地资源池是否已长好,以及 Node 回推后的热词是否已经反哺到本地。</div>
|
||||
</div>
|
||||
|
||||
<el-descriptions :column="2" border size="small" class="seo-copy-workbench__desc">
|
||||
<el-descriptions-item label="资源根目录">{{ providerSummary.resource_pool_summary?.resource_root || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="站点定位词">
|
||||
{{ providerSummary.resource_pool_summary?.site_positioning_count || 0 }} 份
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="热词反馈文件">
|
||||
{{ providerSummary.resource_pool_summary?.keyword_feedback_host_count || 0 }} 份
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="全局热词数">
|
||||
{{ providerSummary.resource_pool_summary?.hot_keyword_count || 0 }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="最近反哺时间">
|
||||
{{ formatDateTime(providerSummary.resource_pool_summary?.hot_keyword_updated_at) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="回看周期">
|
||||
{{ providerSummary.resource_pool_summary?.latest_days || 0 }} 天
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="疑似已收录域名">
|
||||
{{ providerSummary.resource_pool_summary?.indexed_like_host_count || 0 }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="已起词域名">
|
||||
{{ providerSummary.resource_pool_summary?.keyword_ready_host_count || 0 }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="流量就绪域名">
|
||||
{{ providerSummary.resource_pool_summary?.traffic_ready_host_count || 0 }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="反哺状态">
|
||||
{{ providerSummary.resource_pool_summary?.node_feedback_ready ? "已就绪" : "暂无快照反哺" }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="seo-copy-workbench__history-head">
|
||||
<div class="seo-copy-workbench__history-title">建议优先 AI 介入</div>
|
||||
<div class="seo-copy-workbench__history-note">分数越高,越值得优先把 AI 资源花在这个域名上,避免把成本浪费在还不该优化的站上。</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="providerSummary.ai_trigger_summary?.top_items || []" border size="small" max-height="280">
|
||||
<el-table-column prop="host" label="域名" min-width="160" />
|
||||
<el-table-column prop="score" label="分数" min-width="80" />
|
||||
<el-table-column prop="status" label="当前状态" min-width="100">
|
||||
<template #default="scope">
|
||||
{{ generationStatusLabel(scope.row.status) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="feedback_keyword_count" label="反哺词数" min-width="90" />
|
||||
<el-table-column prop="priority_keyword_count" label="站点主词数" min-width="90" />
|
||||
<el-table-column prop="reasons" label="建议原因" min-width="360" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
{{ Array.isArray(scope.row.reasons) && scope.row.reasons.length ? scope.row.reasons.join(";") : "-" }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div v-if="(providerSummary.resource_pool_summary?.top_keywords || []).length" class="seo-copy-workbench__history-head">
|
||||
<div class="seo-copy-workbench__history-title">最近反哺热词 Top</div>
|
||||
<div class="seo-copy-workbench__history-note">这些词已经能被本地规则和后续 AI 优化共同使用。</div>
|
||||
</div>
|
||||
|
||||
<el-table v-if="(providerSummary.resource_pool_summary?.top_keywords || []).length" :data="providerSummary.resource_pool_summary?.top_keywords || []" border size="small" max-height="260">
|
||||
<el-table-column prop="keyword" label="热词" min-width="200" />
|
||||
<el-table-column prop="host" label="域名" min-width="160" />
|
||||
<el-table-column prop="source" label="来源" min-width="120" />
|
||||
<el-table-column prop="weight" label="权重" min-width="90">
|
||||
<template #default="scope">
|
||||
{{ Number(scope.row.weight || 0).toFixed(2) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="seo-copy-workbench__history-head">
|
||||
<div class="seo-copy-workbench__history-title">配置键提示</div>
|
||||
<div class="seo-copy-workbench__history-note">优先读 `SEO_COPY_AI_*`,同时兼容 `OPENAI_*` 作为回退键。</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="envKeyRows" border size="small">
|
||||
<el-table-column prop="label" label="配置项" min-width="140" />
|
||||
<el-table-column prop="keys" label="支持的环境变量" min-width="420" show-overflow-tooltip />
|
||||
</el-table>
|
||||
|
||||
<div class="seo-copy-workbench__history-head">
|
||||
<div class="seo-copy-workbench__history-title">最近 AI 优化历史</div>
|
||||
<div class="seo-copy-workbench__history-note">会显示生成 / 重优化任务、实际 provider、是否真配好 API、以及这次发布了几类页面。</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="history.items || []" border height="420">
|
||||
<el-table-column prop="recorded_at" label="时间" min-width="170">
|
||||
<template #default="scope">
|
||||
{{ formatDateTime(scope.row.recorded_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="host" label="域名" min-width="160" />
|
||||
<el-table-column prop="action" label="动作" min-width="120">
|
||||
<template #default="scope">
|
||||
{{ actionLabel(scope.row.action) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="provider_effective" label="实际 Provider" min-width="120">
|
||||
<template #default="scope">
|
||||
<el-tag size="small" :type="scope.row.provider_effective === 'openai' ? 'success' : 'info'">
|
||||
{{ providerLabel(scope.row.provider_effective) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="configured" label="配置状态" min-width="100">
|
||||
<template #default="scope">
|
||||
{{ scope.row.configured ? "已配置" : "回退本地" }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="published_pages" label="发布页数" min-width="90" />
|
||||
<el-table-column prop="message" label="结果说明" min-width="280" show-overflow-tooltip />
|
||||
<el-table-column label="操作" min-width="170" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button link type="primary" @click="openHistoryDetail(scope.row)">看 Diff</el-button>
|
||||
<el-button link type="success" @click="openPublishedPreview(scope.row.host)">看现稿</el-button>
|
||||
<el-button link type="warning" @click="rollbackHistory(scope.row)">回滚</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="detailVisible" :title="detailTitle" width="980px" append-to-body>
|
||||
<div v-if="detailMode === 'history'" class="seo-copy-detail">
|
||||
<el-empty v-if="!historyDetail?.item" description="暂无历史明细" />
|
||||
<template v-else>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="域名">{{ historyDetail.item.host || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="任务">{{ actionLabel(historyDetail.item.action) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="时间">{{ formatDateTime(historyDetail.item.recorded_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Provider">{{ providerLabel(historyDetail.item.provider_effective) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="发布页数">{{ historyDetail.item.published_pages || 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="结果说明">{{ historyDetail.item.message || "-" }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="seo-copy-detail__section-title">页面级 Diff</div>
|
||||
<div
|
||||
v-for="item in historyDetail.item.page_diffs || []"
|
||||
:key="`${item.scene}-${item.page_key}`"
|
||||
class="seo-copy-detail__block"
|
||||
>
|
||||
<div class="seo-copy-detail__block-head">
|
||||
<div>{{ sceneLabel(item.scene) }} / {{ item.page_key || "-" }}</div>
|
||||
<div class="seo-copy-detail__changed">变更字段:{{ (item.changed_fields || []).join("、") || "无" }}</div>
|
||||
</div>
|
||||
<div class="seo-copy-detail__compare">
|
||||
<div class="seo-copy-detail__pane">
|
||||
<div class="seo-copy-detail__pane-title">优化前</div>
|
||||
<div v-if="formatDiffPayload(item.before, item.changed_fields || []).length" class="seo-copy-detail__kv-list">
|
||||
<div
|
||||
v-for="row in formatDiffPayload(item.before, item.changed_fields || [])"
|
||||
:key="`before-${item.scene}-${item.page_key}-${row.key}`"
|
||||
class="seo-copy-detail__kv-item"
|
||||
:class="row.changed ? 'seo-copy-detail__kv-item--changed' : ''"
|
||||
>
|
||||
<div class="seo-copy-detail__kv-key">{{ row.key }}</div>
|
||||
<pre>{{ row.value }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<pre v-else>{{ formatJson(item.before) }}</pre>
|
||||
</div>
|
||||
<div class="seo-copy-detail__pane">
|
||||
<div class="seo-copy-detail__pane-title">优化后</div>
|
||||
<div v-if="formatDiffPayload(item.after, item.changed_fields || []).length" class="seo-copy-detail__kv-list">
|
||||
<div
|
||||
v-for="row in formatDiffPayload(item.after, item.changed_fields || [])"
|
||||
:key="`after-${item.scene}-${item.page_key}-${row.key}`"
|
||||
class="seo-copy-detail__kv-item"
|
||||
:class="row.changed ? 'seo-copy-detail__kv-item--changed' : ''"
|
||||
>
|
||||
<div class="seo-copy-detail__kv-key">{{ row.key }}</div>
|
||||
<pre>{{ row.value }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<pre v-else>{{ formatJson(item.after) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-else class="seo-copy-detail">
|
||||
<el-empty v-if="!publishedPreview.items?.length" description="暂无已发布稿" />
|
||||
<div
|
||||
v-for="item in publishedPreview.items || []"
|
||||
:key="`${item.scene}-${item.page_key}`"
|
||||
class="seo-copy-detail__block"
|
||||
>
|
||||
<div class="seo-copy-detail__block-head">
|
||||
<div>{{ sceneLabel(item.scene) }} / {{ item.page_key || "-" }}</div>
|
||||
</div>
|
||||
<div class="seo-copy-detail__pane seo-copy-detail__pane--single">
|
||||
<pre>{{ formatJson(item.data) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
<DomainImportTaskConsoleDialog ref="taskConsoleRef" />
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { SiteList } from "@/api/interface/site/list";
|
||||
import DomainImportTaskConsoleDialog from "./domainImportTaskConsoleDialog.vue";
|
||||
import {
|
||||
getDomainSeoCopyAiProviderStatus,
|
||||
getDomainSeoCopyGenerationHistory,
|
||||
getDomainSeoCopyGenerationHistoryDetail,
|
||||
getDomainSeoCopyPublishedPreview,
|
||||
rollbackDomainSeoCopyHistory
|
||||
} from "@/api/modules/site/list";
|
||||
|
||||
const visible = ref(false);
|
||||
const hostFilter = ref("");
|
||||
const providerSummary = ref<SiteList.DomainSeoCopyAiProviderStatusData>({});
|
||||
const history = ref<SiteList.DomainSeoCopyGenerationHistoryData>({});
|
||||
const detailVisible = ref(false);
|
||||
const detailTitle = ref("AI 文案明细");
|
||||
const detailMode = ref<"history" | "published">("history");
|
||||
const historyDetail = ref<SiteList.DomainSeoCopyGenerationHistoryDetailData>({});
|
||||
const publishedPreview = ref<SiteList.DomainSeoCopyPublishedPreviewData>({});
|
||||
const taskConsoleRef = ref<InstanceType<typeof DomainImportTaskConsoleDialog> | null>(null);
|
||||
|
||||
const envKeyRows = computed(() => {
|
||||
const envKeys = (providerSummary.value as any)?.env_keys ?? {};
|
||||
const labelMap: Record<string, string> = {
|
||||
provider: "Provider",
|
||||
model: "Model",
|
||||
base_url: "Base URL",
|
||||
api_key: "API Key",
|
||||
timeout_ms: "超时"
|
||||
};
|
||||
return Object.keys(envKeys).map(key => ({
|
||||
label: labelMap[key] ?? key,
|
||||
keys: Array.isArray(envKeys[key]) ? envKeys[key].join(" / ") : "-"
|
||||
}));
|
||||
});
|
||||
|
||||
const providerAlertTitle = computed(() => {
|
||||
if (providerSummary.value.configured) {
|
||||
return "当前 AI provider 已配置完成";
|
||||
}
|
||||
return "当前 AI provider 仍会回退到本地增强";
|
||||
});
|
||||
|
||||
const providerAlertDescription = computed(() => {
|
||||
if (providerSummary.value.configured) {
|
||||
return `当前默认 provider 为 ${providerSummary.value.provider_label || "-"},后续提交 AI 生成 / 重优化任务时会优先走真实 AI。`;
|
||||
}
|
||||
const missing = Array.isArray((providerSummary.value as any).missing_fields) && (providerSummary.value as any).missing_fields.length
|
||||
? `当前缺少:${(providerSummary.value as any).missing_fields.join("、")}。`
|
||||
: "";
|
||||
return `${missing}任务虽然能跑,但会自动回退到本地增强生成。`;
|
||||
});
|
||||
|
||||
const latestHistoryRow = computed(() => {
|
||||
const items = Array.isArray(history.value.items) ? history.value.items : [];
|
||||
return items[0] || null;
|
||||
});
|
||||
|
||||
const recentHistoryCount = computed(() => {
|
||||
const items = Array.isArray(history.value.items) ? history.value.items : [];
|
||||
return items.length;
|
||||
});
|
||||
|
||||
const recentOpenAiCount = computed(() => {
|
||||
const items = Array.isArray(history.value.items) ? history.value.items : [];
|
||||
return items.filter(item => item?.provider_effective === "openai").length;
|
||||
});
|
||||
|
||||
const latestHistoryAction = computed(() => {
|
||||
const row = latestHistoryRow.value;
|
||||
if (!row) return "-";
|
||||
return actionLabel(row.action);
|
||||
});
|
||||
|
||||
const latestHistoryMeta = computed(() => {
|
||||
const row = latestHistoryRow.value;
|
||||
if (!row) return "当前还没有 AI 历史记录";
|
||||
return `${formatDateTime(row.recorded_at)} / ${providerLabel(row.provider_effective)} / ${row.host || "-"}`;
|
||||
});
|
||||
|
||||
const loadProviderStatus = async () => {
|
||||
const res = await getDomainSeoCopyAiProviderStatus();
|
||||
providerSummary.value = res.data ?? {};
|
||||
};
|
||||
|
||||
const loadHistory = async () => {
|
||||
const res = await getDomainSeoCopyGenerationHistory({
|
||||
host: hostFilter.value.trim() || undefined,
|
||||
limit: 60
|
||||
});
|
||||
history.value = res.data ?? {};
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
await Promise.all([loadProviderStatus(), loadHistory()]);
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取 AI 文案工作台失败");
|
||||
}
|
||||
};
|
||||
|
||||
const providerLabel = (provider?: string) => {
|
||||
if (provider === "openai") return "OpenAI";
|
||||
if (provider === "local") return "本地增强";
|
||||
return provider || "-";
|
||||
};
|
||||
|
||||
const actionLabel = (action?: string) => {
|
||||
if (action === "ai_generate") return "AI生成";
|
||||
if (action === "ai_optimize") return "AI重优化";
|
||||
return action || "-";
|
||||
};
|
||||
|
||||
const sceneLabel = (scene?: string) => {
|
||||
if (scene === "home") return "首页";
|
||||
if (scene === "category_index") return "分类首页";
|
||||
if (scene === "search") return "搜索页";
|
||||
if (scene === "detail") return "详情页";
|
||||
if (scene === "play") return "播放页";
|
||||
return scene || "-";
|
||||
};
|
||||
|
||||
const generationStatusLabel = (status?: string) => {
|
||||
if (status === "draft") return "草稿";
|
||||
if (status === "local_ready") return "本地已就绪";
|
||||
if (status === "ai_pending") return "等待AI";
|
||||
if (status === "ai_ready") return "AI已就绪";
|
||||
return status || "-";
|
||||
};
|
||||
|
||||
const formatDateTime = (value?: string) => {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
const pad = (num: number) => String(num).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
};
|
||||
|
||||
const formatJson = (value?: Record<string, any>) => {
|
||||
if (!value || !Object.keys(value).length) return "{}";
|
||||
return JSON.stringify(value, null, 2);
|
||||
};
|
||||
|
||||
const formatDiffPayload = (value?: Record<string, any>, changedFields: string[] = []) => {
|
||||
if (!value || !Object.keys(value).length) return [];
|
||||
return Object.entries(value).map(([key, val]) => ({
|
||||
key,
|
||||
value: typeof val === "string" ? val : JSON.stringify(val, null, 2),
|
||||
changed: changedFields.includes(key)
|
||||
}));
|
||||
};
|
||||
|
||||
const openHistoryDetail = async (row: SiteList.DomainSeoCopyGenerationHistoryItem) => {
|
||||
if (!row.host || !row.job_id) {
|
||||
ElMessage.warning("当前记录缺少明细索引");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await getDomainSeoCopyGenerationHistoryDetail({ host: row.host, job_id: row.job_id });
|
||||
historyDetail.value = res.data ?? {};
|
||||
detailMode.value = "history";
|
||||
detailTitle.value = `AI优化 Diff - ${row.host}`;
|
||||
detailVisible.value = true;
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取 AI 优化明细失败");
|
||||
}
|
||||
};
|
||||
|
||||
const openPublishedPreview = async (host?: string) => {
|
||||
if (!host) {
|
||||
ElMessage.warning("当前域名不能为空");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await getDomainSeoCopyPublishedPreview({ host });
|
||||
publishedPreview.value = res.data ?? {};
|
||||
detailMode.value = "published";
|
||||
detailTitle.value = `当前已发布稿 - ${host}`;
|
||||
detailVisible.value = true;
|
||||
} catch (_error) {
|
||||
ElMessage.error("读取已发布稿失败");
|
||||
}
|
||||
};
|
||||
|
||||
const rollbackHistory = async (row: SiteList.DomainSeoCopyGenerationHistoryItem) => {
|
||||
if (!row.host || !row.job_id) {
|
||||
ElMessage.warning("当前记录缺少回滚索引");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(`将 ${row.host} 回滚到历史任务 ${row.job_id} 的已发布稿,是否继续?`, "确认回滚", {
|
||||
type: "warning",
|
||||
confirmButtonText: "继续回滚",
|
||||
cancelButtonText: "取消"
|
||||
});
|
||||
const res = await rollbackDomainSeoCopyHistory({ host: row.host, job_id: row.job_id });
|
||||
const strJobId = String(res.data?.job?.job_id ?? "").trim();
|
||||
if (strJobId) {
|
||||
taskConsoleRef.value?.open("AI文案回滚任务", strJobId, async () => {
|
||||
await loadHistory();
|
||||
if (detailMode.value === "published" && detailVisible.value) {
|
||||
await openPublishedPreview(row.host);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
ElMessage.success(res.data?.message || "回滚成功");
|
||||
await loadHistory();
|
||||
}
|
||||
} catch (_error: any) {
|
||||
if (_error === "cancel") return;
|
||||
ElMessage.error("回滚 AI 历史失败");
|
||||
}
|
||||
};
|
||||
|
||||
const open = async (host = "") => {
|
||||
hostFilter.value = host;
|
||||
visible.value = true;
|
||||
await loadData();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.seo-copy-workbench {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.seo-copy-workbench__toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.seo-copy-workbench__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.seo-copy-workbench__card {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
|
||||
}
|
||||
|
||||
.seo-copy-workbench__card-title {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.seo-copy-workbench__card-value {
|
||||
color: #303133;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.seo-copy-workbench__card-meta {
|
||||
margin-top: 8px;
|
||||
color: #606266;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.seo-copy-workbench__desc {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.seo-copy-workbench__history-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.seo-copy-workbench__history-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.seo-copy-workbench__history-note {
|
||||
color: #606266;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.seo-copy-workbench__grid {
|
||||
grid-template-columns: repeat(3, minmax(160px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.seo-copy-workbench__grid {
|
||||
grid-template-columns: repeat(2, minmax(140px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.seo-copy-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.seo-copy-detail__section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.seo-copy-detail__block {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.seo-copy-detail__block-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.seo-copy-detail__changed {
|
||||
color: #606266;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.seo-copy-detail__compare {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.seo-copy-detail__pane {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.seo-copy-detail__pane--single {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.seo-copy-detail__pane-title {
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.seo-copy-detail__kv-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.seo-copy-detail__kv-item {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.seo-copy-detail__kv-item--changed {
|
||||
border-color: #f59e0b;
|
||||
background: #fff7e6;
|
||||
}
|
||||
|
||||
.seo-copy-detail__kv-key {
|
||||
margin-bottom: 6px;
|
||||
color: #606266;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.seo-copy-detail__pane pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: #303133;
|
||||
}
|
||||
</style>
|
||||
1162
src/views/site/domainSpiderCrawlWorkbenchDialog.vue
Normal file
1162
src/views/site/domainSpiderCrawlWorkbenchDialog.vue
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user