fix: 提交标签同步

This commit is contained in:
2026-03-09 16:55:28 +08:00
parent 39246c9483
commit fccdcaf89a
11 changed files with 570 additions and 109 deletions

View File

@@ -1,8 +1,8 @@
import request from '@/config/axios' import request from '@/config/axios'
import type { Dayjs } from 'dayjs'; import type { Dayjs } from 'dayjs';
/** 自定义标签类型product-产品, store-店铺, supplier-供货商 */ /** 自定义标签类型product-产品, store-店铺, supplier-供货商, member-会员 */
export type CustomTagType = 'product' | 'store' | 'supplier' export type CustomTagType = 'product' | 'store' | 'supplier' | 'member'
/** 自定义标签信息 */ /** 自定义标签信息 */
export interface CustomTag { export interface CustomTag {

View File

@@ -66,7 +66,7 @@ export const TagConfigApi = {
deleteTagConfig: (id: number) => deleteTagConfig: (id: number) =>
request.delete({ url: '/ydoyun/tag-config/delete?id=' + id }), request.delete({ url: '/ydoyun/tag-config/delete?id=' + id }),
manualSync: (tagConfigId: number) => manualSync: (tagConfigId: number) =>
request.post({ url: '/ydoyun/tag-config/manual-sync?tagConfigId=' + tagConfigId }), request.post({ url: '/ydoyun/tag-config/manual-sync?tagConfigId=' + tagConfigId })
} }
export const TagApi = { export const TagApi = {
@@ -90,3 +90,22 @@ export const TagSyncHistoryApi = {
getTagSyncHistory: (id: number) => getTagSyncHistory: (id: number) =>
request.get({ url: '/ydoyun/tag-sync-history/get?id=' + id }), request.get({ url: '/ydoyun/tag-sync-history/get?id=' + id }),
} }
/** 标签同步详情 */
export interface TagSyncDetail {
id?: number
syncHistoryId?: number
tagId?: number
tagName?: string
tagType?: string
sqlExecuted?: string
execStatus?: string
recordCount?: number
errorMessage?: string
execOrder?: number
}
export const TagSyncDetailApi = {
getDetailListBySyncHistoryId: (syncHistoryId: number) =>
request.get({ url: '/ydoyun/tag-sync-detail/list-by-sync-history-id?syncHistoryId=' + syncHistoryId }),
}

View File

@@ -831,6 +831,16 @@ const remainingRouter: AppRouteRecordRaw[] = [
hidden: true hidden: true
}, },
children: [ children: [
{
path: 'custom-tag',
name: 'CustomTag',
meta: {
title: '定制标签',
noCache: true,
canTo: true
},
component: () => import(/* webpackChunkName: "custom-tag" */ '@/views/ydoyun/customtag/index.vue')
},
{ {
path: 'product-custom-tag', path: 'product-custom-tag',
name: 'ProductCustomTag', name: 'ProductCustomTag',

View File

@@ -96,6 +96,10 @@ defineOptions({ name: 'Login' })
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif; font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
} }
.bg-container { .bg-container {
@@ -199,10 +203,10 @@ defineOptions({ name: 'Login' })
width: 100%; width: 100%;
max-width: 1300px; max-width: 1300px;
margin: 0 auto; margin: 0 auto;
padding: 80px 40px 100px; padding: 40px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: center;
gap: 40px; gap: 40px;
} }

View File

@@ -1,5 +1,5 @@
<template> <template>
<Dialog :title="dialogTitle" v-model="dialogVisible" width="560px"> <Dialog :title="dialogTitle" v-model="dialogVisible" width="720px">
<el-form <el-form
ref="formRef" ref="formRef"
:model="formData" :model="formData"
@@ -7,9 +7,18 @@
label-width="110px" label-width="110px"
v-loading="formLoading" v-loading="formLoading"
> >
<el-form-item label="标签类别" prop="type">
<el-select v-model="formData.type" placeholder="请选择标签类别" class="!w-full" @change="loadInitialSqlByType">
<el-option label="产品 (product)" value="product" />
<el-option label="门店 (store)" value="store" />
<el-option label="供货商 (supplier)" value="supplier" />
<el-option label="会员 (member)" value="member" />
</el-select>
<div class="form-tip">选择与标准标签一致的类别名称不能与同类型标准标签重复</div>
</el-form-item>
<el-form-item label="标签名称" prop="name"> <el-form-item label="标签名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入标签名称,如:销售区域" maxlength="50" show-word-limit /> <el-input v-model="formData.name" placeholder="请输入标签名称,如:销售区域" maxlength="50" show-word-limit @blur="validateNameWithStandardTags" />
<div class="form-tip">用于报表中展示的标签名称</div> <div class="form-tip">用于报表中展示的标签名称不能与同类型标准标签重名</div>
</el-form-item> </el-form-item>
<el-form-item label="公式描述" prop="expression"> <el-form-item label="公式描述" prop="expression">
<el-input <el-input
@@ -45,13 +54,22 @@
<div class="form-tip">用于报表展示时的颜色标识可点击预设色或使用取色盘</div> <div class="form-tip">用于报表展示时的颜色标识可点击预设色或使用取色盘</div>
</el-form-item> </el-form-item>
<el-form-item label="SQL 脚本" prop="sqlScript"> <el-form-item label="SQL 脚本" prop="sqlScript">
<el-input <div class="sql-script-field">
v-model="formData.sqlScript" <div class="param-ref-row">
type="textarea" <el-button size="small" type="success" plain @click="insertInsertTemplate">
:rows="4" 插入 {{ insertTableName }} 模板
placeholder="请输入 SQL 脚本,用于获取标签数据" </el-button>
/> </div>
<div class="form-tip">执行 SQL 获取标签选项支持多列第一列作为显示值</div> <el-input
ref="sqlScriptInputRef"
v-model="formData.sqlScript"
type="textarea"
:rows="8"
placeholder="请输入标签同步时执行的 SQL 脚本,支持多行。可点击上方按钮插入 INSERT 模板"
class="sql-script-area"
/>
<div class="form-tip"> SQL 将用于标签同步将结果写入对应标签表支持多列第一列作为 code第二列作为 name</div>
</div>
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
@@ -62,6 +80,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { CustomTagApi, CustomTag } from '@/api/ydoyun/customtag' import { CustomTagApi, CustomTag } from '@/api/ydoyun/customtag'
import { TagApi } from '@/api/ydoyun/tagconfig'
defineOptions({ name: 'CustomTagForm' }) defineOptions({ name: 'CustomTagForm' })
@@ -75,17 +94,77 @@ const formType = ref('')
const formData = ref({ const formData = ref({
id: undefined, id: undefined,
type: undefined,
name: undefined, name: undefined,
expression: undefined, expression: undefined,
color: undefined, color: undefined,
sqlScript: undefined sqlScript: undefined
}) })
const validateNameWithStandardTags = async (_rule: any, value: string, callback: (e?: Error) => void) => {
if (!value?.trim()) return callback()
const type = formData.value.type
if (!type) return callback()
try {
const list = await TagApi.getTagListByType(type)
const res = list as any
const tags = res?.data ?? res ?? []
const exists = Array.isArray(tags) && tags.some((t: any) => (t.name || '').trim() === value.trim())
if (exists) {
callback(new Error('该名称与同类型标准标签重复,请更换'))
} else {
callback()
}
} catch {
callback()
}
}
const formRules = reactive({ const formRules = reactive({
name: [{ required: true, message: '标签名称不能为空', trigger: 'blur' }] type: [{ required: true, message: '请选择标签类别', trigger: 'change' }],
name: [
{ required: true, message: '标签名称不能为空', trigger: 'blur' },
{ validator: validateNameWithStandardTags, trigger: 'blur' }
]
}) })
const formRef = ref() const formRef = ref()
const sqlScriptInputRef = ref()
const INSERT_TABLE_MAP: Record<string, string> = {
product: 'ydoyun_tag_product',
supplier: 'ydoyun_tag_supplier',
store: 'ydoyun_tag_store',
member: 'ydoyun_tag_member'
}
const insertTableName = computed(() => {
const type = formData.value.type || 'product'
return INSERT_TABLE_MAP[type] || 'ydoyun_tag_product'
})
const getInsertTemplate = () => {
const table = insertTableName.value
return `INSERT INTO ${table} (code, name, color)
SELECT code, name, color FROM (
-- 在此编写您的查询逻辑,将 code、name、color 替换为实际字段color 用于保存标签颜色
SELECT '' AS code, '' AS name, '' AS color FROM (SELECT 1) _ LIMIT 0
) t
`
}
const insertInsertTemplate = () => {
const template = getInsertTemplate()
const oldScript = formData.value.sqlScript || ''
formData.value.sqlScript = template + (oldScript ? '\n' + oldScript : '')
}
/** 根据类型加载初始 SQL仅新增且当前为空时 */
const loadInitialSqlByType = () => {
if (formType.value !== 'create' || formData.value.sqlScript?.trim()) return
if (!formData.value.type) return
formData.value.sqlScript = getInsertTemplate()
}
/** 预设颜色选项 */ /** 预设颜色选项 */
const presetColors = [ const presetColors = [
@@ -114,6 +193,8 @@ const open = async (type: string, id?: number) => {
} finally { } finally {
formLoading.value = false formLoading.value = false
} }
} else {
loadInitialSqlByType()
} }
} }
@@ -124,7 +205,8 @@ const submitForm = async () => {
await formRef.value.validate() await formRef.value.validate()
formLoading.value = true formLoading.value = true
try { try {
const data = formData.value as unknown as CustomTag const base = { ...formData.value }
const data = (formType.value === 'create' ? { ...base, useParams: false } : base) as unknown as CustomTag
if (formType.value === 'create') { if (formType.value === 'create') {
await CustomTagApi.createCustomTag(data) await CustomTagApi.createCustomTag(data)
message.success(t('common.createSuccess')) message.success(t('common.createSuccess'))
@@ -143,6 +225,7 @@ const submitForm = async () => {
const resetForm = () => { const resetForm = () => {
formData.value = { formData.value = {
id: undefined, id: undefined,
type: undefined,
name: undefined, name: undefined,
expression: undefined, expression: undefined,
color: undefined, color: undefined,
@@ -200,4 +283,18 @@ const resetForm = () => {
flex: 1; flex: 1;
min-width: 120px; min-width: 120px;
} }
.sql-script-field { width: 100%; }
.param-ref-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.sql-script-area { width: 100%; }
.sql-script-area :deep(textarea) {
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 13px;
}
</style> </style>

View File

@@ -1,5 +1,5 @@
<template> <template>
<Dialog :title="dialogTitle" v-model="dialogVisible" width="560px"> <Dialog :title="dialogTitle" v-model="dialogVisible" width="720px">
<el-form <el-form
ref="formRef" ref="formRef"
:model="formData" :model="formData"
@@ -7,9 +7,18 @@
label-width="110px" label-width="110px"
v-loading="formLoading" v-loading="formLoading"
> >
<el-form-item label="标签类型" prop="type">
<el-select v-model="formData.type" placeholder="请选择标签类型" class="!w-full" :disabled="!!props.tagType">
<el-option label="产品 (product)" value="product" />
<el-option label="门店 (store)" value="store" />
<el-option label="供货商 (supplier)" value="supplier" />
<el-option label="会员 (member)" value="member" />
</el-select>
<div class="form-tip">选择与标准标签一致的类别名称不能与同类型标准标签重复</div>
</el-form-item>
<el-form-item label="标签名称" prop="name"> <el-form-item label="标签名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入标签名称" maxlength="50" show-word-limit /> <el-input v-model="formData.name" placeholder="请输入标签名称" maxlength="50" show-word-limit @blur="validateNameWithStandardTags" />
<div class="form-tip">用于报表中展示的标签名称</div> <div class="form-tip">用于报表中展示的标签名称不能与同类型标准标签重名</div>
</el-form-item> </el-form-item>
<el-form-item label="公式描述" prop="expression"> <el-form-item label="公式描述" prop="expression">
<el-input <el-input
@@ -52,30 +61,23 @@
</el-form-item> </el-form-item>
<el-form-item label="SQL 脚本" prop="sqlScript"> <el-form-item label="SQL 脚本" prop="sqlScript">
<div class="sql-script-field"> <div class="sql-script-field">
<div v-if="formData.useParams === 1" class="param-insert-bar"> <div class="param-ref-row">
<span class="param-label">插入参数变量</span> <el-button size="small" type="success" plain @click="insertInsertTemplate">
<el-select 插入 {{ insertTableName }} 模板
v-model="selectedParamIndex" </el-button>
:placeholder="paramList.length > 0 ? '选择参数后点击插入' : '请先填写上方参数列表'" <template v-if="formData.useParams === 1">
clearable <span class="param-ref-label">引用参数</span>
class="param-select" <el-button
:disabled="paramList.length === 0"
>
<el-option
v-for="(p, idx) in paramList" v-for="(p, idx) in paramList"
:key="idx" :key="idx"
:label="`参数${idx + 1}: ${p}`" size="small"
:value="idx" type="info"
/> plain
</el-select> @click="insertParamRef(p, idx)"
<el-button >
type="primary" 引用{{ p }}
size="small" </el-button>
:disabled="paramList.length === 0 || selectedParamIndex === null" </template>
@click="selectedParamIndex != null ? insertParamVar(selectedParamIndex) : null"
>
插入
</el-button>
</div> </div>
<el-input <el-input
ref="sqlScriptInputRef" ref="sqlScriptInputRef"
@@ -112,6 +114,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { CustomTagApi, CustomTag, CustomTagType } from '@/api/ydoyun/customtag' import { CustomTagApi, CustomTag, CustomTagType } from '@/api/ydoyun/customtag'
import { TagApi } from '@/api/ydoyun/tagconfig'
defineOptions({ name: 'CustomTagFormWithParams' }) defineOptions({ name: 'CustomTagFormWithParams' })
@@ -138,14 +141,85 @@ const formData = ref<Partial<CustomTag>>({
params: undefined params: undefined
}) })
const validateNameWithStandardTags = async (_rule: any, value: string, callback: (e?: Error) => void) => {
if (!value?.trim()) return callback()
const type = formData.value.type ?? props.tagType
if (!type) return callback()
try {
const list = await TagApi.getTagListByType(type)
const res = list as any
const tags = res?.data ?? res ?? []
const exists = Array.isArray(tags) && tags.some((t: any) => (t.name || '').trim() === value.trim())
if (exists) {
callback(new Error('该名称与同类型标准标签重复,请更换'))
} else {
callback()
}
} catch {
callback()
}
}
const formRules = reactive({ const formRules = reactive({
name: [{ required: true, message: '标签名称不能为空', trigger: 'blur' }] type: [{ required: true, message: '请选择标签类型', trigger: 'change' }],
name: [
{ required: true, message: '标签名称不能为空', trigger: 'blur' },
{ validator: validateNameWithStandardTags, trigger: 'blur' }
]
}) })
const formRef = ref() const formRef = ref()
const sqlScriptInputRef = ref() const sqlScriptInputRef = ref()
const sqlCursorPos = ref({ start: 0, end: 0 }) const sqlCursorPos = ref({ start: 0, end: 0 })
const selectedParamIndex = ref<number | null>(null)
const INSERT_TABLE_MAP: Record<string, string> = {
product: 'ydoyun_tag_product',
supplier: 'ydoyun_tag_supplier',
store: 'ydoyun_tag_store',
member: 'ydoyun_tag_member'
}
const insertTableName = computed(() => {
const type = formData.value.type ?? props.tagType ?? 'product'
return INSERT_TABLE_MAP[type] || 'ydoyun_tag_product'
})
const getInsertTemplate = () => {
const table = insertTableName.value
return `INSERT INTO ${table} (code, name, color)
SELECT code, name, color FROM (
-- 在此编写您的查询逻辑,将 code、name、color 替换为实际字段color 用于保存标签颜色
SELECT '' AS code, '' AS name, '' AS color FROM (SELECT 1) _ LIMIT 0
) t
`
}
const insertInsertTemplate = () => {
const template = getInsertTemplate()
const oldScript = formData.value.sqlScript || ''
formData.value.sqlScript = template + (oldScript ? '\n' + oldScript : '')
}
const insertAtCursor = (text: string) => {
const textarea = sqlScriptInputRef.value?.$el?.querySelector('textarea')
if (textarea) {
const start = textarea.selectionStart
const end = textarea.selectionEnd
const sql = formData.value.sqlScript || ''
formData.value.sqlScript = sql.slice(0, start) + text + sql.slice(end)
nextTick(() => {
textarea.focus()
const newPos = start + text.length
textarea.setSelectionRange(newPos, newPos)
})
} else {
formData.value.sqlScript = (formData.value.sqlScript || '') + text
}
}
const insertParamRef = (_p: string, idx: number) => {
insertAtCursor(`\${参数${idx + 1}}`)
}
const paramList = computed(() => { const paramList = computed(() => {
const p = formData.value.params const p = formData.value.params
@@ -160,24 +234,6 @@ const saveSqlCursor = () => {
} }
} }
const insertParamVar = (index: number) => {
const variable = `\${参数${index + 1}}`
const current = formData.value.sqlScript || ''
const { start, end } = sqlCursorPos.value
const before = current.slice(0, start)
const after = current.slice(end)
formData.value.sqlScript = before + variable + after
selectedParamIndex.value = null
nextTick(() => {
sqlCursorPos.value = { start: start + variable.length, end: start + variable.length }
const textarea = sqlScriptInputRef.value?.$el?.querySelector('textarea')
if (textarea) {
textarea.focus()
textarea.setSelectionRange(start + variable.length, start + variable.length)
}
})
}
const sqlSegments = computed(() => { const sqlSegments = computed(() => {
const sql = formData.value.sqlScript || '' const sql = formData.value.sqlScript || ''
const regex = /\$\{([^}]+)\}/g const regex = /\$\{([^}]+)\}/g
@@ -241,9 +297,19 @@ const open = async (type: string, id?: number) => {
} finally { } finally {
formLoading.value = false formLoading.value = false
} }
} else if (type === 'create') {
loadInitialSqlByType()
} }
} }
/** 根据类型加载初始 SQL仅新增且当前为空时 */
const loadInitialSqlByType = () => {
if (formType.value !== 'create' || formData.value.sqlScript?.trim()) return
const type = formData.value.type ?? props.tagType
if (!type) return
formData.value.sqlScript = getInsertTemplate()
}
defineExpose({ open }) defineExpose({ open })
const emit = defineEmits(['success']) const emit = defineEmits(['success'])
@@ -294,12 +360,10 @@ const resetForm = () => {
.color-picker-wrap { display: flex; align-items: center; gap: 12px; } .color-picker-wrap { display: flex; align-items: center; gap: 12px; }
.color-picker-wrap .color-input { flex: 1; min-width: 120px; } .color-picker-wrap .color-input { flex: 1; min-width: 120px; }
.sql-script-field { width: 100%; } .sql-script-field { width: 100%; }
.param-insert-bar { .param-ref-row {
display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 8px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-bottom: 8px;
padding: 8px 12px; background: var(--el-fill-color-light); border-radius: 6px;
} }
.param-label { font-size: 13px; color: var(--el-text-color-secondary); margin-right: 4px; } .param-ref-label { font-size: 13px; color: var(--el-text-color-regular); margin-right: 4px; }
.param-select { width: 180px; }
.sql-preview { .sql-preview {
margin-top: 10px; padding: 10px 12px; background: var(--el-fill-color-lighter); margin-top: 10px; padding: 10px 12px; background: var(--el-fill-color-lighter);
border-radius: 6px; font-family: monospace; font-size: 13px; border-radius: 6px; font-family: monospace; font-size: 13px;

View File

@@ -8,6 +8,14 @@
:inline="true" :inline="true"
label-width="90px" label-width="90px"
> >
<el-form-item label="标签类型" prop="type">
<el-select v-model="queryParams.type" placeholder="全部" clearable class="!w-180px">
<el-option label="产品" value="product" />
<el-option label="门店" value="store" />
<el-option label="供货商" value="supplier" />
<el-option label="会员" value="member" />
</el-select>
</el-form-item>
<el-form-item label="标签名称" prop="name"> <el-form-item label="标签名称" prop="name">
<el-input <el-input
v-model="queryParams.name" v-model="queryParams.name"
@@ -81,16 +89,20 @@
@selection-change="handleRowCheckboxChange" @selection-change="handleRowCheckboxChange"
> >
<el-table-column type="selection" width="55" /> <el-table-column type="selection" width="55" />
<el-table-column label="标签名称" align="center" prop="name" min-width="120" :show-overflow-tooltip="true" /> <el-table-column label="标签名称" align="center" prop="name" min-width="120" :show-overflow-tooltip="true">
<el-table-column label="公式描述" align="center" prop="expression" min-width="150" :show-overflow-tooltip="true" />
<el-table-column label="标签颜色" align="center" prop="color" width="100">
<template #default="scope"> <template #default="scope">
<el-tag v-if="scope.row.color" :color="scope.row.color" effect="dark" size="small"> <el-tag v-if="scope.row.color" :color="scope.row.color" effect="dark" size="small" class="!border-0">
{{ scope.row.color }} {{ scope.row.name || '-' }}
</el-tag> </el-tag>
<span v-else>-</span> <span v-else>{{ scope.row.name || '-' }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="标签类型" align="center" prop="type" width="100">
<template #default="scope">
{{ typeLabelMap[scope.row.type] || scope.row.type || '-' }}
</template>
</el-table-column>
<el-table-column label="公式描述" align="center" prop="expression" min-width="150" :show-overflow-tooltip="true" />
<el-table-column <el-table-column
label="创建时间" label="创建时间"
align="center" align="center"
@@ -146,9 +158,17 @@ const loading = ref(true)
const list = ref<CustomTag[]>([]) const list = ref<CustomTag[]>([])
const total = ref(0) const total = ref(0)
const typeLabelMap: Record<string, string> = {
product: '产品',
store: '门店',
supplier: '供货商',
member: '会员'
}
const queryParams = reactive({ const queryParams = reactive({
pageNo: 1, pageNo: 1,
pageSize: 10, pageSize: 10,
type: undefined as string | undefined,
name: undefined, name: undefined,
expression: undefined, expression: undefined,
createTime: [] as string[] createTime: [] as string[]
@@ -178,6 +198,7 @@ const handleQuery = () => {
/** 重置 */ /** 重置 */
const resetQuery = () => { const resetQuery = () => {
queryFormRef.value?.resetFields() queryFormRef.value?.resetFields()
queryParams.type = undefined
handleQuery() handleQuery()
} }

View File

@@ -7,6 +7,11 @@
:inline="true" :inline="true"
label-width="90px" label-width="90px"
> >
<el-form-item label="标签类型" prop="type">
<el-select v-model="queryParams.type" placeholder="标签类型" class="!w-140px" disabled>
<el-option label="产品" value="product" />
</el-select>
</el-form-item>
<el-form-item label="标签名称" prop="name"> <el-form-item label="标签名称" prop="name">
<el-input <el-input
v-model="queryParams.name" v-model="queryParams.name"
@@ -62,14 +67,20 @@
@selection-change="handleRowCheckboxChange" @selection-change="handleRowCheckboxChange"
> >
<el-table-column type="selection" width="55" /> <el-table-column type="selection" width="55" />
<el-table-column label="标签名称" align="center" prop="name" min-width="120" :show-overflow-tooltip="true" /> <el-table-column label="标签名称" align="center" prop="name" min-width="120" :show-overflow-tooltip="true">
<el-table-column label="公式描述" align="center" prop="expression" min-width="150" :show-overflow-tooltip="true" />
<el-table-column label="标签颜色" align="center" prop="color" width="100">
<template #default="scope"> <template #default="scope">
<el-tag v-if="scope.row.color" :color="scope.row.color" effect="dark" size="small">{{ scope.row.color }}</el-tag> <el-tag v-if="scope.row.color" :color="scope.row.color" effect="dark" size="small" class="!border-0">
<span v-else>-</span> {{ scope.row.name || '-' }}
</el-tag>
<span v-else>{{ scope.row.name || '-' }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="标签类型" align="center" prop="type" width="90">
<template #default="scope">
{{ typeLabelMap[scope.row.type] || scope.row.type || '产品' }}
</template>
</el-table-column>
<el-table-column label="公式描述" align="center" prop="expression" min-width="150" :show-overflow-tooltip="true" />
<el-table-column label="代入参数" align="center" prop="useParams" width="90"> <el-table-column label="代入参数" align="center" prop="useParams" width="90">
<template #default="scope"> <template #default="scope">
<el-tag v-if="scope.row.useParams === true || scope.row.useParams === 1" type="success" size="small"></el-tag> <el-tag v-if="scope.row.useParams === true || scope.row.useParams === 1" type="success" size="small"></el-tag>
@@ -107,6 +118,13 @@ defineOptions({ name: 'ProductCustomTag' })
const message = useMessage() const message = useMessage()
const { t } = useI18n() const { t } = useI18n()
const typeLabelMap: Record<string, string> = {
product: '产品',
store: '门店',
supplier: '供货商',
member: '会员'
}
const loading = ref(true) const loading = ref(true)
const list = ref<CustomTag[]>([]) const list = ref<CustomTag[]>([])
const total = ref(0) const total = ref(0)

View File

@@ -7,6 +7,11 @@
:inline="true" :inline="true"
label-width="90px" label-width="90px"
> >
<el-form-item label="标签类型" prop="type">
<el-select v-model="queryParams.type" placeholder="标签类型" class="!w-140px" disabled>
<el-option label="门店" value="store" />
</el-select>
</el-form-item>
<el-form-item label="标签名称" prop="name"> <el-form-item label="标签名称" prop="name">
<el-input <el-input
v-model="queryParams.name" v-model="queryParams.name"
@@ -62,14 +67,20 @@
@selection-change="handleRowCheckboxChange" @selection-change="handleRowCheckboxChange"
> >
<el-table-column type="selection" width="55" /> <el-table-column type="selection" width="55" />
<el-table-column label="标签名称" align="center" prop="name" min-width="120" :show-overflow-tooltip="true" /> <el-table-column label="标签名称" align="center" prop="name" min-width="120" :show-overflow-tooltip="true">
<el-table-column label="公式描述" align="center" prop="expression" min-width="150" :show-overflow-tooltip="true" />
<el-table-column label="标签颜色" align="center" prop="color" width="100">
<template #default="scope"> <template #default="scope">
<el-tag v-if="scope.row.color" :color="scope.row.color" effect="dark" size="small">{{ scope.row.color }}</el-tag> <el-tag v-if="scope.row.color" :color="scope.row.color" effect="dark" size="small" class="!border-0">
<span v-else>-</span> {{ scope.row.name || '-' }}
</el-tag>
<span v-else>{{ scope.row.name || '-' }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="标签类型" align="center" prop="type" width="90">
<template #default="scope">
{{ typeLabelMap[scope.row.type] || scope.row.type || '门店' }}
</template>
</el-table-column>
<el-table-column label="公式描述" align="center" prop="expression" min-width="150" :show-overflow-tooltip="true" />
<el-table-column label="代入参数" align="center" prop="useParams" width="90"> <el-table-column label="代入参数" align="center" prop="useParams" width="90">
<template #default="scope"> <template #default="scope">
<el-tag v-if="scope.row.useParams === true || scope.row.useParams === 1" type="success" size="small"></el-tag> <el-tag v-if="scope.row.useParams === true || scope.row.useParams === 1" type="success" size="small"></el-tag>
@@ -107,6 +118,13 @@ defineOptions({ name: 'StoreCustomTag' })
const message = useMessage() const message = useMessage()
const { t } = useI18n() const { t } = useI18n()
const typeLabelMap: Record<string, string> = {
product: '产品',
store: '门店',
supplier: '供货商',
member: '会员'
}
const loading = ref(true) const loading = ref(true)
const list = ref<CustomTag[]>([]) const list = ref<CustomTag[]>([])
const total = ref(0) const total = ref(0)

View File

@@ -7,6 +7,11 @@
:inline="true" :inline="true"
label-width="90px" label-width="90px"
> >
<el-form-item label="标签类型" prop="type">
<el-select v-model="queryParams.type" placeholder="标签类型" class="!w-140px" disabled>
<el-option label="供货商" value="supplier" />
</el-select>
</el-form-item>
<el-form-item label="标签名称" prop="name"> <el-form-item label="标签名称" prop="name">
<el-input <el-input
v-model="queryParams.name" v-model="queryParams.name"
@@ -62,14 +67,20 @@
@selection-change="handleRowCheckboxChange" @selection-change="handleRowCheckboxChange"
> >
<el-table-column type="selection" width="55" /> <el-table-column type="selection" width="55" />
<el-table-column label="标签名称" align="center" prop="name" min-width="120" :show-overflow-tooltip="true" /> <el-table-column label="标签名称" align="center" prop="name" min-width="120" :show-overflow-tooltip="true">
<el-table-column label="公式描述" align="center" prop="expression" min-width="150" :show-overflow-tooltip="true" />
<el-table-column label="标签颜色" align="center" prop="color" width="100">
<template #default="scope"> <template #default="scope">
<el-tag v-if="scope.row.color" :color="scope.row.color" effect="dark" size="small">{{ scope.row.color }}</el-tag> <el-tag v-if="scope.row.color" :color="scope.row.color" effect="dark" size="small" class="!border-0">
<span v-else>-</span> {{ scope.row.name || '-' }}
</el-tag>
<span v-else>{{ scope.row.name || '-' }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="标签类型" align="center" prop="type" width="90">
<template #default="scope">
{{ typeLabelMap[scope.row.type] || scope.row.type || '供货商' }}
</template>
</el-table-column>
<el-table-column label="公式描述" align="center" prop="expression" min-width="150" :show-overflow-tooltip="true" />
<el-table-column label="代入参数" align="center" prop="useParams" width="90"> <el-table-column label="代入参数" align="center" prop="useParams" width="90">
<template #default="scope"> <template #default="scope">
<el-tag v-if="scope.row.useParams === true || scope.row.useParams === 1" type="success" size="small"></el-tag> <el-tag v-if="scope.row.useParams === true || scope.row.useParams === 1" type="success" size="small"></el-tag>
@@ -107,6 +118,13 @@ defineOptions({ name: 'SupplierCustomTag' })
const message = useMessage() const message = useMessage()
const { t } = useI18n() const { t } = useI18n()
const typeLabelMap: Record<string, string> = {
product: '产品',
store: '门店',
supplier: '供货商',
member: '会员'
}
const loading = ref(true) const loading = ref(true)
const list = ref<CustomTag[]>([]) const list = ref<CustomTag[]>([])
const total = ref(0) const total = ref(0)

View File

@@ -1079,7 +1079,8 @@
<EditSqlModal v-model="editSqlModalVisible" :tag="editSqlTag" @confirm="onEditSqlConfirm" /> <EditSqlModal v-model="editSqlModalVisible" :tag="editSqlTag" @confirm="onEditSqlConfirm" />
<el-drawer v-model="syncHistoryVisible" title="同步历史" size="600px" destroy-on-close> <Teleport to="body">
<el-drawer v-model="syncHistoryVisible" title="同步历史" size="600px" direction="rtl" destroy-on-close append-to-body :z-index="3000">
<el-form :model="historyQuery" :inline="true" class="mb-4"> <el-form :model="historyQuery" :inline="true" class="mb-4">
<el-form-item label="同步时间"> <el-form-item label="同步时间">
<el-date-picker <el-date-picker
@@ -1093,15 +1094,16 @@
/> />
</el-form-item> </el-form-item>
<el-form-item label="同步类型"> <el-form-item label="同步类型">
<el-select v-model="historyQuery.syncType" placeholder="全部" clearable style="width: 120px"> <el-select v-model="historyQuery.syncType" placeholder="全部" clearable style="width: 120px" :teleported="false">
<el-option label="手动" value="manual" /> <el-option label="手动" value="manual" />
<el-option label="定时" value="cron" /> <el-option label="定时" value="cron" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="同步状态"> <el-form-item label="同步状态">
<el-select v-model="historyQuery.syncStatus" placeholder="全部" clearable style="width: 120px"> <el-select v-model="historyQuery.syncStatus" placeholder="全部" clearable style="width: 120px" :teleported="false">
<el-option label="成功" value="success" /> <el-option label="成功" value="success" />
<el-option label="失败" value="fail" /> <el-option label="失败" value="fail" />
<el-option label="部分成功" value="partial" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
@@ -1109,15 +1111,39 @@
<el-button @click="resetHistoryQuery">重置</el-button> <el-button @click="resetHistoryQuery">重置</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
<el-table :data="syncHistoryList" v-loading="historyLoading" stripe> <el-table :data="syncHistoryList" v-loading="historyLoading" stripe row-key="id" @expand-change="onHistoryExpand">
<el-table-column type="expand">
<template #default="{ row }">
<div class="sync-detail-expand">
<div v-if="detailLoadingMap[row.id]" class="detail-loading">加载中...</div>
<el-table v-else :data="detailMap[row.id] || []" size="small" border>
<el-table-column label="标签名称" prop="tagName" width="120" />
<el-table-column label="标签类型" prop="tagType" width="90" />
<el-table-column label="状态" prop="execStatus" width="80">
<template #default="{ row: d }">
<el-tag :type="d.execStatus === 'success' ? 'success' : 'danger'" size="small">
{{ d.execStatus === 'success' ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="执行SQL" prop="sqlExecuted" min-width="300" show-overflow-tooltip>
<template #default="{ row: d }">
<pre class="sql-executed clickable" @click="openSqlViewer(d)">{{ d.sqlExecuted || '-' }}</pre>
</template>
</el-table-column>
<el-table-column label="错误信息" prop="errorMessage" width="180" show-overflow-tooltip />
</el-table>
</div>
</template>
</el-table-column>
<el-table-column label="同步时间" prop="syncTime" width="170" :formatter="dateFormatter" /> <el-table-column label="同步时间" prop="syncTime" width="170" :formatter="dateFormatter" />
<el-table-column label="同步类型" prop="syncType" width="80"> <el-table-column label="同步类型" prop="syncType" width="80">
<template #default="{ row }">{{ row.syncType === 'manual' ? '手动' : '定时' }}</template> <template #default="{ row }">{{ syncTypeOptions.find(o => o.value === row.syncType)?.label || row.syncType || '-' }}</template>
</el-table-column> </el-table-column>
<el-table-column label="状态" prop="syncStatus" width="80"> <el-table-column label="状态" prop="syncStatus" width="90">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="row.syncStatus === 'success' ? 'success' : 'danger'" size="small"> <el-tag :type="row.syncStatus === 'success' ? 'success' : row.syncStatus === 'partial' ? 'warning' : 'danger'" size="small">
{{ row.syncStatus === 'success' ? '成功' : '失败' }} {{ syncStatusOptions.find(o => o.value === row.syncStatus)?.label || row.syncStatus || '-' }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
@@ -1132,14 +1158,41 @@
class="mt-4" class="mt-4"
/> />
</el-drawer> </el-drawer>
<!-- 执行SQL 查看弹窗 -->
<el-dialog
v-model="sqlViewerVisible"
:title="sqlViewerTitle"
width="700px"
destroy-on-close
:z-index="3100"
class="sql-viewer-dialog"
>
<el-input
v-model="sqlViewerContent"
type="textarea"
:rows="16"
readonly
class="sql-viewer-textarea"
/>
<template #footer>
<span v-if="copySuccessTip" class="sql-copy-tip">已复制</span>
<el-button type="primary" @click="copySqlContent">
<Icon icon="ep:document-copy" class="mr-1" />
复制
</el-button>
<el-button @click="sqlViewerVisible = false">关闭</el-button>
</template>
</el-dialog>
</Teleport>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ElMessageBox, ElLoading } from 'element-plus' import { ElMessage, ElMessageBox, ElLoading } from 'element-plus'
import { dateFormatter, getDateRange } from '@/utils/formatTime' import { dateFormatter, getDateRange } from '@/utils/formatTime'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { TagConfigApi, TagApi, TagSyncHistoryApi } from '@/api/ydoyun/tagconfig' import { TagConfigApi, TagApi, TagSyncHistoryApi, TagSyncDetailApi } from '@/api/ydoyun/tagconfig'
import type { Tag, TagConfig, TagSyncHistory } from '@/api/ydoyun/tagconfig' import type { Tag, TagConfig, TagSyncHistory } from '@/api/ydoyun/tagconfig'
import { ReportDatabaseApi, type ReportDatabase } from '@/api/ydoyun/reportdatabase' import { ReportDatabaseApi, type ReportDatabase } from '@/api/ydoyun/reportdatabase'
import { useAppStore } from '@/store/modules/app' import { useAppStore } from '@/store/modules/app'
@@ -1569,7 +1622,7 @@ const handleManualSync = async () => {
return return
} }
try { try {
await message.confirm('确定要立即执行一次标签同步吗?', '手动同步') await message.confirm('确定要立即执行一次标签同步吗?将先初始化标签库表,再执行标签同步。', '手动同步')
} catch { } catch {
return return
} }
@@ -1752,9 +1805,25 @@ const editPresetTag = (type: string, name: string) => {
openEditSqlModal(tag) openEditSqlModal(tag)
} }
const formatTime = (v: string) => { const formatTime = (v: string | Date | number | null | undefined) => {
if (!v) return '-' if (v == null || v === '') return '-'
return v.replace('T', ' ').slice(0, 19) let date: Date
if (typeof v === 'number') {
date = v > 1e12 ? new Date(v) : new Date(v * 1000)
} else if (v instanceof Date) {
date = v
} else if (typeof v === 'string') {
const n = Number(v)
if (!isNaN(n)) {
date = n > 1e12 ? new Date(n) : new Date(n * 1000)
} else {
date = new Date(v)
}
} else {
return '-'
}
if (isNaN(date.getTime())) return '-'
return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
} }
const syncHistoryVisible = ref(false) const syncHistoryVisible = ref(false)
@@ -1763,6 +1832,16 @@ const syncHistoryList = ref<TagSyncHistory[]>([])
const historyTotal = ref(0) const historyTotal = ref(0)
const getDefaultSyncTime = () => getDateRange(dayjs(), dayjs()) const getDefaultSyncTime = () => getDateRange(dayjs(), dayjs())
const syncTypeOptions = [
{ label: '手动', value: 'manual' },
{ label: '定时', value: 'cron' }
]
const syncStatusOptions = [
{ label: '成功', value: 'success' },
{ label: '失败', value: 'fail' },
{ label: '部分成功', value: 'partial' }
]
const historyQuery = reactive({ const historyQuery = reactive({
pageNo: 1, pageNo: 1,
pageSize: 10, pageSize: 10,
@@ -1772,22 +1851,56 @@ const historyQuery = reactive({
syncStatus: undefined as string | undefined syncStatus: undefined as string | undefined
}) })
const openSyncHistory = () => { const openSyncHistory = async () => {
if (!currentConfig.value?.id) { let tagConfigId = currentConfig.value?.id
message.warning('请先选择同步绑定并保存配置') if (!tagConfigId) {
return try {
const res = await TagConfigApi.getTagConfigByTenant()
const data = (res as any)?.data ?? res
tagConfigId = data?.id
} catch {
/* ignore */
}
} }
historyQuery.tagConfigId = currentConfig.value.id historyQuery.tagConfigId = tagConfigId ?? undefined
historyQuery.syncTime = getDefaultSyncTime() historyQuery.syncTime = getDefaultSyncTime()
syncHistoryVisible.value = true syncHistoryVisible.value = true
loadSyncHistory() if (tagConfigId) {
loadSyncHistory()
} else {
syncHistoryList.value = []
historyTotal.value = 0
message.warning('请先选择同步绑定并保存配置')
}
}
const detailMap = ref<Record<number, any[]>>({})
const detailLoadingMap = ref<Record<number, boolean>>({})
const onHistoryExpand = async (row: any, expandedRows: any[]) => {
const isExpanded = expandedRows.some((r) => r.id === row.id)
if (!isExpanded || detailMap.value[row.id]) return
detailLoadingMap.value = { ...detailLoadingMap.value, [row.id]: true }
try {
const res = await TagSyncDetailApi.getDetailListBySyncHistoryId(row.id)
const list = (res as any)?.data ?? res
detailMap.value = { ...detailMap.value, [row.id]: Array.isArray(list) ? list : [] }
} finally {
detailLoadingMap.value = { ...detailLoadingMap.value, [row.id]: false }
}
} }
const loadSyncHistory = async () => { const loadSyncHistory = async () => {
if (!historyQuery.tagConfigId) return if (!historyQuery.tagConfigId) return
historyLoading.value = true historyLoading.value = true
detailMap.value = {}
try { try {
const res = await TagSyncHistoryApi.getTagSyncHistoryPage(historyQuery) const params = {
...historyQuery,
syncType: historyQuery.syncType || undefined,
syncStatus: historyQuery.syncStatus || undefined
}
const res = await TagSyncHistoryApi.getTagSyncHistoryPage(params)
const data = (res as any)?.data ?? res const data = (res as any)?.data ?? res
syncHistoryList.value = data?.list || [] syncHistoryList.value = data?.list || []
historyTotal.value = data?.total || 0 historyTotal.value = data?.total || 0
@@ -1804,6 +1917,35 @@ const resetHistoryQuery = () => {
loadSyncHistory() loadSyncHistory()
} }
/** 执行SQL 查看弹窗 */
const sqlViewerVisible = ref(false)
const sqlViewerContent = ref('')
const sqlViewerTitle = ref('执行SQL')
const openSqlViewer = (d: { tagName?: string; sqlExecuted?: string }) => {
sqlViewerTitle.value = `执行SQL${d.tagName ? ' - ' + d.tagName : ''}`
sqlViewerContent.value = d.sqlExecuted || '-'
copySuccessTip.value = false
sqlViewerVisible.value = true
}
const copySuccessTip = ref(false)
let copyTipTimer: ReturnType<typeof setTimeout> | null = null
const copySqlContent = async () => {
const text = sqlViewerContent.value
if (!text || text === '-') return
try {
await navigator.clipboard.writeText(text)
copySuccessTip.value = true
if (copyTipTimer) clearTimeout(copyTipTimer)
copyTipTimer = setTimeout(() => {
copySuccessTip.value = false
copyTipTimer = null
}, 2000)
} catch {
copySuccessTip.value = false
ElMessage.error({ message: '复制失败', zIndex: 9999 })
}
}
onMounted(() => { onMounted(() => {
loadDatabaseList() loadDatabaseList()
loadTags() loadTags()
@@ -2221,6 +2363,56 @@ onMounted(() => {
color: #fff; color: #fff;
} }
.sync-detail-expand {
padding: 12px 24px;
background: var(--el-fill-color-light);
}
.sync-detail-expand .detail-loading {
padding: 16px;
text-align: center;
color: var(--el-text-color-secondary);
}
.sync-detail-expand .sql-executed {
margin: 0;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
}
.sync-detail-expand .sql-executed.clickable {
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
color: var(--el-color-primary);
text-decoration: underline;
transition: color 0.2s, background 0.2s;
}
.sync-detail-expand .sql-executed.clickable:hover {
color: var(--el-color-primary-light-3);
background: var(--el-fill-color);
}
/* 执行SQL 查看弹窗 */
.sql-viewer-dialog :deep(.el-dialog__body) {
padding-top: 12px;
}
.sql-viewer-dialog :deep(.el-dialog__footer) {
display: flex;
align-items: center;
gap: 12px;
}
.sql-copy-tip {
color: var(--el-color-success);
font-size: 14px;
margin-right: 8px;
}
.sql-viewer-textarea :deep(textarea) {
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 13px;
line-height: 1.5;
}
@media (max-width: 1200px) { @media (max-width: 1200px) {
.rules-grid { .rules-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;