提交 12371c27 authored 作者: 王鹏飞's avatar 王鹏飞

Merge branch 'pro' into 202412

......@@ -2,3 +2,8 @@ VITE_LOGIN_URL=http://172.16.3.203:1001/auth/login/index
VITE_LAB_URL=http://172.16.3.203:1012/bi/?proc=0&action=index
VITE_SURVEYKING_URL=http://172.16.3.203:1011
VITE_STATIC_URL=https://saas-lab-api
# 直播语音识别
VITE_ACCESS_KEY_ID=LTAI5t7YUVzDVSFLYvnuWGuq
VITE_ACCESS_KEY_SECRET=GBsohg5hSUP99dzIuRuCQilUXTSiYe
VITE_APP_KEY=W7Yqc8L49MEnnLsE
......@@ -302,6 +302,11 @@
"onWatcherCleanup": true,
"useId": true,
"useModel": true,
"useTemplateRef": true
"useTemplateRef": true,
"createRef": true,
"onElementRemoval": true,
"useCountdown": true,
"usePreferredReducedTransparency": true,
"useSSRWidth": true
}
}
......@@ -28,6 +28,7 @@ declare global {
const createGlobalState: typeof import('@vueuse/core')['createGlobalState']
const createInjectionState: typeof import('@vueuse/core')['createInjectionState']
const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn']
const createRef: typeof import('@vueuse/core')['createRef']
const createReusableTemplate: typeof import('@vueuse/core')['createReusableTemplate']
const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable']
const createTemplatePromise: typeof import('@vueuse/core')['createTemplatePromise']
......@@ -62,6 +63,7 @@ declare global {
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onClickOutside: typeof import('@vueuse/core')['onClickOutside']
const onDeactivated: typeof import('vue')['onDeactivated']
const onElementRemoval: typeof import('@vueuse/core')['onElementRemoval']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onKeyStroke: typeof import('@vueuse/core')['onKeyStroke']
const onLongPress: typeof import('@vueuse/core')['onLongPress']
......@@ -144,6 +146,7 @@ declare global {
const useCloned: typeof import('@vueuse/core')['useCloned']
const useColorMode: typeof import('@vueuse/core')['useColorMode']
const useConfirmDialog: typeof import('@vueuse/core')['useConfirmDialog']
const useCountdown: typeof import('@vueuse/core')['useCountdown']
const useCounter: typeof import('@vueuse/core')['useCounter']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVar: typeof import('@vueuse/core')['useCssVar']
......@@ -224,12 +227,14 @@ declare global {
const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark']
const usePreferredLanguages: typeof import('@vueuse/core')['usePreferredLanguages']
const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion']
const usePreferredReducedTransparency: typeof import('@vueuse/core')['usePreferredReducedTransparency']
const usePrevious: typeof import('@vueuse/core')['usePrevious']
const useRafFn: typeof import('@vueuse/core')['useRafFn']
const useRefHistory: typeof import('@vueuse/core')['useRefHistory']
const useResizeObserver: typeof import('@vueuse/core')['useResizeObserver']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSSRWidth: typeof import('@vueuse/core')['useSSRWidth']
const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation']
const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea']
const useScriptTag: typeof import('@vueuse/core')['useScriptTag']
......
差异被折叠。
......@@ -22,14 +22,16 @@
"@tinymce/tinymce-vue": "^5.0.1",
"@vue-flow/controls": "^1.1.2",
"@vue-flow/core": "^1.39.0",
"@vueuse/components": "^11.2.0",
"@vueuse/core": "^11.2.0",
"axios": "^1.6.8",
"@vueuse/components": "^13.3.0",
"@vueuse/core": "^13.3.0",
"axios": "^1.9.0",
"blueimp-md5": "^2.19.0",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.10",
"echarts": "^5.5.0",
"echarts-wordcloud": "^2.1.0",
"element-plus": "^2.8.7",
"eventsource-parser": "^3.0.2",
"file-saver": "^2.0.5",
"html-to-image": "^1.11.11",
"jspdf": "^2.5.1",
......@@ -37,14 +39,16 @@
"nanoid": "^5.0.7",
"pinia": "^2.2.6",
"scroll-into-view-if-needed": "^3.1.0",
"vue": "^3.5.12",
"vue": "^3.5.16",
"vue-echarts": "^6.6.9",
"vue-router": "^4.4.5",
"vue-markdown-render": "^2.2.1",
"vue-router": "^4.5.1",
"xss": "^1.0.15"
},
"devDependencies": {
"@tsconfig/node20": "^20.1.4",
"@types/blueimp-md5": "^2.18.2",
"@types/crypto-js": "^4.2.2",
"@types/file-saver": "^2.0.7",
"@types/node": "^20.17.6",
"@vitejs/plugin-vue": "^5.1.4",
......
class PCMProcessor extends AudioWorkletProcessor {
process(inputs) {
const input = inputs[0]
if (input.length > 0) {
const inputData = input[0] // 取第一个通道
const inputData16 = new Int16Array(inputData.length)
for (let i = 0; i < inputData.length; i++) {
// PCM 16-bit 转换
inputData16[i] = Math.max(-1, Math.min(1, inputData[i])) * 0x7fff
}
// 通过 port 向主线程发送处理后的数据
this.port.postMessage(inputData16.buffer, [inputData16.buffer])
}
return true // 保持处理器活跃
}
}
registerProcessor('pcm-processor', PCMProcessor)
import httpRequest from '@/utils/axios'
// 获取天工AI的使用详情
export function getAIUsage(params: { marketing_material_id: string }) {
return httpRequest.get('/api/lab/v1/experiment/marketing-ai/ai-usage-detail', { params })
}
// 天工AI-聊天
export function postAIChat(data: {
marketing_material_id: string
context: string
type: number
chart_id: string | null
}) {
return httpRequest.post('/api/lab/v1/experiment/marketing-ai/sky-agents-chat', data)
}
// 天工3.0文字生成图片
export function postGenerateImage(data: {
marketing_material_id: string
context: string
type: number
chart_id: string | null
}) {
return httpRequest.post('/api/lab/v1/experiment/marketing-ai/sky-agent3-generate-image', data)
}
......@@ -180,3 +180,8 @@ export function getProductList(params?: {
export function getAuth() {
return httpRequest.get('/api/lab/v1/experiment/auth/all')
}
// 获取实验旅程中的群组列表
export function getGroupList() {
return httpRequest.get('/api/lab/v1/experiment/itinerary/groups')
}
import httpRequest from '@/utils/axios'
// 获取实验试题列表
export function getExperimentQuestionList() {
return httpRequest.get('/api/lab/v1/student/experiment-question/list')
}
// 学生创建群组页面-获取老师维护的群组
export function getTeacherGroups(params?: { type?: string }) {
return httpRequest.get('/api/lab/v1/experiment/group/teacher-groups', { params })
}
// 学生创建标签页面-获取老师维护的标签
export function getTeacherTags() {
return httpRequest.get('/api/lab/v1/experiment/tag/teacher-tags')
}
// 学生创建营销物料页面-获取老师维护的营销物料
export function getTeacherMaterials(params?: { type?: string }) {
return httpRequest.get('/api/lab/v1/experiment/marketing-ai/teacher-materials', { params })
}
......@@ -96,3 +96,26 @@ textarea:focus {
margin: -8px -15px;
padding: 8px 15px;
}
.markdown-body {
line-height: 1.6;
}
.markdown-body h1 {
font-size: 24px;
}
.markdown-body h2 {
font-size: 20px;
}
.markdown-body h3 {
font-size: 18px;
}
.markdown-body table {
width: 100%;
border-collapse: collapse;
border-spacing: 0;
}
.markdown-body table th,
.markdown-body table td {
padding: 8px 16px;
border: 1px solid #e6e6e6;
}
......@@ -27,6 +27,7 @@ const component = computed(() => {
MAWeibo: markRaw(defineAsyncComponent(() => import('./components/marketingAction/weibo/Index.vue'))),
MADingTalk: markRaw(defineAsyncComponent(() => import('./components/marketingAction/dingtalk/Index.vue'))),
MAAB: markRaw(defineAsyncComponent(() => import('./components/marketingAction/ab/Index.vue'))),
MAWechatVideo: markRaw(defineAsyncComponent(() => import('./components/marketingAction/wechatVideo/Index.vue'))),
MAXiaohongshu: markRaw(defineAsyncComponent(() => import('./components/marketingAction/xiaohongshu/Index.vue'))),
CBAttributeJudgment: markRaw(defineAsyncComponent(() => import('./components/conditionalBranch/attributeJudgment/Index.vue'))),
CBGroupJudgment: markRaw(defineAsyncComponent(() => import('./components/conditionalBranch/groupJudgment/Index.vue'))),
......
......@@ -27,7 +27,7 @@ const list = ref([
type_name: '触发条件',
icon: '13',
component_type: 1,
component_name: 'TCRealTimeTrigger'
component_name: 'TCRealTimeTrigger',
},
{
name: '加入群组',
......@@ -35,7 +35,7 @@ const list = ref([
type_name: '触发条件',
icon: '14',
component_type: 2,
component_name: 'TCJoinGroup'
component_name: 'TCJoinGroup',
},
{
name: '变更属性',
......@@ -43,7 +43,7 @@ const list = ref([
type_name: '触发条件',
icon: '15',
component_type: 3,
component_name: 'TCChangeProps'
component_name: 'TCChangeProps',
},
{
name: '公众号',
......@@ -52,7 +52,7 @@ const list = ref([
icon: '1',
component_type: 4,
component_name: 'TCOffiaccount',
connection_type: 1
connection_type: 1,
},
{
name: '抖音',
......@@ -61,7 +61,7 @@ const list = ref([
icon: '6',
component_type: 5,
component_name: 'TCDouyin',
connection_type: 6
connection_type: 6,
},
{
name: '小红书',
......@@ -70,7 +70,7 @@ const list = ref([
icon: '8',
component_type: 6,
component_name: 'TCXiaohongshu',
connection_type: 8
connection_type: 8,
},
{
name: '微博',
......@@ -79,7 +79,7 @@ const list = ref([
icon: '7',
component_type: 7,
component_name: 'TCWeibo',
connection_type: 7
connection_type: 7,
},
{
name: '自定义',
......@@ -88,7 +88,7 @@ const list = ref([
icon: '12',
component_type: 10,
component_name: 'TCCustom',
connection_type: 12
connection_type: 12,
},
{
name: '小鹅通',
......@@ -97,7 +97,7 @@ const list = ref([
icon: '3',
component_type: 8,
component_name: 'TCXiaoetong',
connection_type: 3
connection_type: 3,
},
{
name: '问卷星',
......@@ -106,9 +106,9 @@ const list = ref([
icon: '4',
component_type: 9,
component_name: 'TCWenjuanxing',
connection_type: 4
}
]
connection_type: 4,
},
],
},
{
name: '营销动作',
......@@ -121,7 +121,7 @@ const list = ref([
icon: '16',
component_type: 99,
component_name: 'MAEndTrip',
color: '#AA1941'
color: '#AA1941',
},
{
name: '加入群组',
......@@ -129,7 +129,7 @@ const list = ref([
type_name: '营销动作',
icon: '14',
component_type: 1,
component_name: 'MAJoinGroup'
component_name: 'MAJoinGroup',
},
{
name: '移除群组',
......@@ -137,7 +137,7 @@ const list = ref([
type_name: '营销动作',
icon: '17',
component_type: 2,
component_name: 'MALeaveGroup'
component_name: 'MALeaveGroup',
},
{
name: '变更属性',
......@@ -145,7 +145,7 @@ const list = ref([
type_name: '营销动作',
icon: '15',
component_type: 3,
component_name: 'MAChangeProps'
component_name: 'MAChangeProps',
},
{
name: '延时处理',
......@@ -153,7 +153,7 @@ const list = ref([
type_name: '营销动作',
icon: '18',
component_type: 4,
component_name: 'MADelayProcess'
component_name: 'MADelayProcess',
},
{
name: '内部通知',
......@@ -161,7 +161,7 @@ const list = ref([
type_name: '营销动作',
icon: '19',
component_type: 5,
component_name: 'MAInternalNotice'
component_name: 'MAInternalNotice',
},
{
name: '短信',
......@@ -170,7 +170,7 @@ const list = ref([
icon: '10',
component_type: 11,
component_name: 'MASMS',
connection_type: 10
connection_type: 10,
},
{
name: '邮件',
......@@ -179,7 +179,7 @@ const list = ref([
icon: '9',
component_type: 10,
component_name: 'MAEmail',
connection_type: 9
connection_type: 9,
},
{
name: '公众号',
......@@ -188,7 +188,7 @@ const list = ref([
icon: '1',
component_type: 6,
component_name: 'MAOffiaccount',
connection_type: 1
connection_type: 1,
},
{
name: '抖音',
......@@ -197,9 +197,17 @@ const list = ref([
icon: '6',
component_type: 7,
component_name: 'MADouyin',
connection_type: 6
connection_type: 6,
},
{
name: '小红书',
type: 2,
type_name: '营销动作',
icon: '8',
component_type: 13,
component_name: 'MAXiaohongshu',
connection_type: 8,
},
{ name: '小红书', type: 2, type_name: '营销动作', icon: '8', component_type: 13, component_name: 'MAXiaohongshu', connection_type: 8 },
{
name: '微博',
type: 2,
......@@ -207,7 +215,7 @@ const list = ref([
icon: '7',
component_type: 8,
component_name: 'MAWeibo',
connection_type: 7
connection_type: 7,
},
{
name: '钉钉',
......@@ -216,7 +224,7 @@ const list = ref([
icon: '2',
component_type: 9,
component_name: 'MADingTalk',
connection_type: 2
connection_type: 2,
},
{
name: 'A/B分配',
......@@ -224,9 +232,17 @@ const list = ref([
type_name: '营销动作',
icon: '101',
component_type: 12,
component_name: 'MAAB'
}
]
component_name: 'MAAB',
},
{
name: '视频号',
type: 2,
type_name: '营销动作',
icon: 'wechatVideo',
component_type: 16,
component_name: 'MAWechatVideo',
},
],
},
{
name: '条件分支',
......@@ -238,7 +254,7 @@ const list = ref([
type_name: '条件分支',
icon: '20',
component_type: 1,
component_name: 'CBAttributeJudgment'
component_name: 'CBAttributeJudgment',
},
{
name: '标签判断',
......@@ -246,7 +262,7 @@ const list = ref([
type_name: '条件分支',
icon: '21',
component_type: 2,
component_name: 'CBLabelJudgment'
component_name: 'CBLabelJudgment',
},
{
name: '群组判断',
......@@ -254,7 +270,7 @@ const list = ref([
type_name: '条件分支',
icon: '22',
component_type: 3,
component_name: 'CBGroupJudgment'
component_name: 'CBGroupJudgment',
},
{
name: '事件判断',
......@@ -262,7 +278,7 @@ const list = ref([
type_name: '条件分支',
icon: '23',
component_type: 5,
component_name: 'CBEventJudgment'
component_name: 'CBEventJudgment',
},
{
name: '时间判断',
......@@ -270,7 +286,7 @@ const list = ref([
type_name: '条件分支',
icon: '24',
component_type: 4,
component_name: 'CBTimeJudgment'
component_name: 'CBTimeJudgment',
},
{
name: '公众号',
......@@ -279,7 +295,7 @@ const list = ref([
icon: '1',
component_type: 6,
component_name: 'CBOffiaccount',
connection_type: 1
connection_type: 1,
},
{
name: '小红书',
......@@ -288,7 +304,7 @@ const list = ref([
icon: '8',
component_type: 7,
component_name: 'CBXiaohongshu',
connection_type: 8
connection_type: 8,
},
{
name: '抖音',
......@@ -297,17 +313,19 @@ const list = ref([
icon: '6',
component_type: 8,
component_name: 'CBDouyin',
connection_type: 6
}
]
}
connection_type: 6,
},
],
},
])
const currentList = computed(() => {
return list.value.map(item => {
return list.value.map((item) => {
return {
...item,
children: item.children.filter(item => (item.connection_type ? connections.value.find(connection => connection.type === item.connection_type) : true))
children: item.children.filter((item) =>
item.connection_type ? connections.value.find((connection) => connection.type === item.connection_type) : true
),
}
})
})
......@@ -326,9 +344,18 @@ const onDragStart = (event: DragEvent, data: any) => {
<dt :style="`background: ${parent.background?.color}`">{{ parent.name }}</dt>
<dd>
<ul>
<li v-for="(item, index) in parent.children" :key="index" :draggable="true" @dragstart="event => onDragStart(event, item)">
<li
v-for="(item, index) in parent.children"
:key="index"
:draggable="true"
@dragstart="(event) => onDragStart(event, item)">
<div class="icon-box">
<Icon class="circle" :color="item.color || parent.background?.color" :name="parent.background?.icon || ''" w="60" h="60"></Icon>
<Icon
class="circle"
:color="item.color || parent.background?.color"
:name="parent.background?.icon || ''"
w="60"
h="60"></Icon>
<Icon class="icon" color="#fff" :name="item.icon" w="24" h="24"></Icon>
</div>
<p>{{ item.name }}</p>
......
<script setup lang="ts">
import ConfigTemplate from '../../ConfigTemplate.vue'
import { useConnection, useMaterial } from '../../../composables/useAllData'
const MaterialPreview = defineAsyncComponent(() => import('@/components/MaterialPreview.vue'))
const props = defineProps<{ node: any }>()
const role = inject('role') as string
const form = reactive({
operate: '',
connection_id: '',
message: '',
material_id: ''
})
watchEffect(() => {
Object.assign(form, props.node.data[role])
})
const operateList = ref([{ label: '推送视频号视频', value: '0' }])
const { materialList } = useMaterial('4')
const { connectionList } = useConnection(10)
const material = computed(() => materialList.value.find(item => item.id === form.material_id))
watch(material, () => {
if (material.value) {
form.message = material.value.content
}
})
</script>
<template>
<ConfigTemplate :model="form" :node="node" :stepNum="2">
<template #default="{ step }: { step: number }">
<template v-if="step === 0">
<el-form-item>
<el-radio-group v-model="form.operate" v-if="step === 0">
<el-radio v-for="item in operateList" :key="item.value" :value="item.value">
{{ item.label }}
</el-radio>
</el-radio-group>
</el-form-item>
</template>
<template v-else-if="step === 1">
<el-form-item>
<el-select placeholder="请选择" style="width: 100%" v-model="form.connection_id">
<el-option v-for="item in connectionList" :key="item.id" :value="item.id" :label="item.name"></el-option>
</el-select>
</el-form-item>
</template>
<template v-else-if="step === 2">
<el-form-item>
<el-select placeholder="请选择资料内容" v-model="form.material_id" style="width: 100%">
<el-option v-for="item in materialList" :key="item.id" :value="item.id" :label="item.name"></el-option>
</el-select>
<MaterialPreview :data="material" v-if="material"></MaterialPreview>
</el-form-item>
<!-- <el-form-item prop="message">
<el-input v-model="form.message" :rows="3" type="textarea" placeholder="请输入短信内容"></el-input>
</el-form-item> -->
</template>
</template>
</ConfigTemplate>
</template>
<script setup lang="ts">
import ConfigViewTemplate from '../../ConfigViewTemplate.vue'
import { useConnection } from '../../../composables/useAllData'
const role = inject('role') as string
defineProps<{ node: any }>()
const { connectionList } = useConnection()
const operateList = [{ label: '推送视频号视频', value: '0' }]
const getConnectionLabel = function (id: string) {
return connectionList.value.find(item => item.id === id)?.name || ''
}
</script>
<template>
<ConfigViewTemplate :node="node">
<el-form-item :label="role === 'student' ? '我的答案' : '学生答案'">
{{ operateList.find(item => item.value === node.data.student?.operate)?.label || '' }}
{{ getConnectionLabel(node.data.student?.connection_id) }}
{{ node.data.student?.message }}
</el-form-item>
<el-form-item label="正确答案">
{{ operateList.find(item => item.value === node.data.teacher?.operate)?.label || '' }}
{{ getConnectionLabel(node.data.teacher?.connection_id) }}
{{ node.data.teacher?.message }}
</el-form-item>
</ConfigViewTemplate>
</template>
<!-- 公众号 -->
<script setup lang="ts">
import NodeTemplate from '../../NodeTemplate.vue'
import Icon from '@/components/ConnectionIcon.vue'
const Config = defineAsyncComponent(() => import('./Config.vue'))
const ConfigView = defineAsyncComponent(() => import('./ConfigView.vue'))
const Rule = defineAsyncComponent(() => import('./Rule.vue'))
const props = defineProps<{ node: any }>()
const action = inject('action') as string
const role = inject('role') as string
const templateType = inject('templateType') as string
// 是否置灰
const isGray = computed(() => {
return templateType === '2' && role === 'student' && action === 'edit' && !props.node.data[role]
})
// 设置
const settingVisible = ref(false)
</script>
<template>
<NodeTemplate :node="node" :connectionType="2" @setting="settingVisible = true">
<div class="node-item">
<Icon class="circle" name="square" :color="isGray ? '#9a9a9a' : '#19AAA5'" w="60" h="60"></Icon>
<Icon class="icon" name="wechatVideo" color="#fff" w="24" h="24"></Icon>
</div>
</NodeTemplate>
<!-- 配置 -->
<Config v-model="settingVisible" :node="node" v-if="settingVisible && action === 'edit'" />
<!-- 查看配置 -->
<ConfigView v-model="settingVisible" :node="node" v-if="settingVisible && action === 'view'" />
<!-- 数据生成规则 -->
<Rule v-model="settingVisible" :node="node" v-if="settingVisible && action === 'rule'" />
</template>
<script setup lang="ts">
import RuleTemplate from '../../RuleTemplate.vue'
defineProps<{ node: any }>()
</script>
<template>
<RuleTemplate :node="node" step>
<template #header-answer>答案</template>
</RuleTemplate>
</template>
<script lang="ts">
export default { name: 'AppMain' }
<script setup lang="ts">
const isIframe = computed(() => {
return window.self !== window.top
})
const padding = computed(() => {
return isIframe.value ? '0 10px' : '10px'
})
</script>
<template>
......@@ -11,7 +17,7 @@ export default { name: 'AppMain' }
<style>
.app-main {
flex: 1;
padding: 10px;
padding: v-bind(padding);
overflow: hidden;
box-sizing: border-box;
}
......
<script setup lang="ts">
import type { GroupRule } from '@/types'
import { PriceTag, Plus, CloseBold } from '@element-plus/icons-vue'
import { useGroup } from '@/composables/useAllData'
const rule = defineModel<GroupRule>({ default: { current_logic_operate: 'and', items: [] } })
const { buttonText = '添加条件' } = defineProps<{
buttonText?: string
}>()
const { groupList } = useGroup()
// 获取逻辑运算符名称
function getLogicalName(value: 'and' | 'or') {
return value === 'or' ? '或' : '且'
}
// 切换逻辑运算符
function toggleOperate(rule: GroupRule) {
rule.current_logic_operate = rule.current_logic_operate === 'or' ? 'and' : 'or'
}
// 添加条件
function handleAdd(items: any[]) {
items.push({ tag_id: '' })
}
// 删除
function handleRemove(items: any[], index: number) {
items.splice(index, 1)
}
</script>
<template>
<el-card shadow="never">
<template #header>
<slot name="header">
<el-button circle color="#567722" :icon="PriceTag"></el-button>
群组满足以下条件
</slot>
</template>
<div class="rule" v-if="rule.items.length">
<div class="rule-operator">
<span @click="toggleOperate(rule)">{{ getLogicalName(rule.current_logic_operate) }}</span>
</div>
<div class="rule-list">
<el-row justify="space-between" class="rule-item" v-for="(item, index) in rule.items" :key="index">
<div>
群组 等于
<el-form-item>
<el-select v-model="item.tag_id" style="width: 300px">
<el-option
v-for="option in groupList"
:key="option.id"
:label="option.name"
:value="option.id"></el-option>
</el-select>
</el-form-item>
</div>
<el-button text :icon="CloseBold" @click="handleRemove(rule.items, index)"></el-button>
</el-row>
</div>
</div>
<el-button text :icon="Plus" @click="handleAdd(rule.items)">{{ buttonText }}</el-button>
</el-card>
</template>
<style src="@/assets/styles/rule.scss"></style>
......@@ -7,6 +7,10 @@ import { useRfmRes } from '@/composables/useRFMData'
// const tagRule = ref(inject('tagRule') as TagRule)
const tagRule = defineModel<TagRule>({ default: { current_logic_operate: 'and', items: [] } })
const { buttonText = '添加条件' } = defineProps<{
buttonText?: string
}>()
const { tagList } = useTag()
const { rfmResList } = useRfmRes()
......@@ -31,11 +35,11 @@ function handleRemove(items: any[], index: number) {
}
function showRfm(id: string) {
return tagList.value.find(item => item.id === id)?.label == '4'
return tagList.value.find((item) => item.id === id)?.label == '4'
}
function handleRfmChange(rfmKey: string, item: any) {
const found = rfmResList.value.find(item => item.frm_key === rfmKey)
const found = rfmResList.value.find((item) => item.frm_key === rfmKey)
item.rfm_value = found?.frm_value
}
// const a = [
......@@ -53,8 +57,10 @@ function handleRfmChange(rfmKey: string, item: any) {
<template>
<el-card shadow="never">
<template #header>
<el-button circle color="#567722" :icon="PriceTag"></el-button>
标签满足以下条件
<slot name="header">
<el-button circle color="#567722" :icon="PriceTag"></el-button>
标签满足以下条件
</slot>
</template>
<div class="rule" v-if="tagRule.items.length">
<div class="rule-operator">
......@@ -66,11 +72,23 @@ function handleRfmChange(rfmKey: string, item: any) {
标签 等于
<el-form-item>
<el-select v-model="item.tag_id" style="width: 300px">
<el-option v-for="option in tagList" :key="option.id" :label="option.name" :value="option.id"></el-option>
<el-option
v-for="option in tagList"
:key="option.id"
:label="option.name"
:value="option.id"></el-option>
</el-select>
<template v-if="showRfm(item.tag_id)">
<el-select v-model="item.rfm_key" @change="value => handleRfmChange(value, item)" style="width: 400px; margin: 0 10px">
<el-option v-for="item in rfmResList" :key="item.frm_key" :label="item.frm_value" :value="item.frm_key" style="height: auto">
<el-select
v-model="item.rfm_key"
@change="(value) => handleRfmChange(value, item)"
style="width: 400px; margin: 0 10px">
<el-option
v-for="item in rfmResList"
:key="item.frm_key"
:label="item.frm_value"
:value="item.frm_key"
style="height: auto">
<div style="line-height: 24px; padding: 5px 0">
<p>
<span style="float: left">{{ item.frm_value }}</span>
......@@ -78,7 +96,9 @@ function handleRfmChange(rfmKey: string, item: any) {
{{ item.frm_extend_info.customer_marketing_strategy }}
</span>
</p>
<p style="clear: both; color: var(--el-text-color-secondary); font-size: 13px">{{ item.frm_extend_info.label_desc }}</p>
<p style="clear: both; color: var(--el-text-color-secondary); font-size: 13px">
{{ item.frm_extend_info.label_desc }}
</p>
</div>
</el-option>
</el-select>
......@@ -90,7 +110,10 @@ function handleRfmChange(rfmKey: string, item: any) {
<el-table-column prop="frm_extend_info.m" label="M值" width="52" />
<el-table-column prop="frm_value" label="标签值" width="110" />
<el-table-column prop="frm_extend_info.label_desc" label="标签说明" />
<el-table-column prop="frm_extend_info.customer_marketing_strategy" label="客户营销策略" width="110" />
<el-table-column
prop="frm_extend_info.customer_marketing_strategy"
label="客户营销策略"
width="110" />
</el-table>
<template #reference>
<el-icon><QuestionFilled /></el-icon>
......@@ -103,7 +126,7 @@ function handleRfmChange(rfmKey: string, item: any) {
</el-row>
</div>
</div>
<el-button text :icon="Plus" @click="handleAdd(tagRule.items)">添加条件</el-button>
<el-button text :icon="Plus" @click="handleAdd(tagRule.items)">{{ buttonText }}</el-button>
</el-card>
</template>
......
......@@ -2,6 +2,7 @@
import { QuestionFilled } from '@element-plus/icons-vue'
import { useUserAttr, useMetaEvent, useUserAttrRange } from '@/composables/useRFMData'
import { searchMetaMemberAttrs } from '@/api/base'
import { cloneDeep } from 'lodash-es'
defineProps<{ label: string }>()
const form = defineModel<any>()
......@@ -9,12 +10,12 @@ const form = defineModel<any>()
const ruleList = [
{ label: '属性值平均法', value: '101', basis: ['1'] },
{ label: '属性值分类法', value: '102', basis: ['1'] },
{ label: '事件发生次数平均法', value: '201', basis: ['2'] }
{ label: '事件发生次数平均法', value: '201', basis: ['2'] },
]
const defaultLevel = [
{ level: '高', value: '' },
{ level: '低', value: '' }
{ level: '低', value: '' },
]
const defaultScore = [
......@@ -22,7 +23,7 @@ const defaultScore = [
{ score: 2, min_value: '', max_value: '' },
{ score: 3, min_value: '', max_value: '' },
{ score: 4, min_value: '', max_value: '' },
{ score: 5, min_value: '', max_value: '' }
{ score: 5, min_value: '', max_value: '' },
]
onMounted(() => {
......@@ -33,8 +34,8 @@ onMounted(() => {
event_id: '-1',
attr_id: '',
attr_type: '',
config: [...defaultScore],
extend_config: { default_score_config: { switch: false, score: undefined } }
config: cloneDeep(defaultScore),
extend_config: { default_score_config: { switch: false, score: undefined } },
},
form.value
)
......@@ -45,7 +46,7 @@ const { metaEventList, fetchMetaEventList } = useMetaEvent()
const { userAttrRange, fetchUserAttrRange } = useUserAttrRange()
const currentRuleList = computed(() => {
return ruleList.filter(item => item.basis.includes(form.value.basis))
return ruleList.filter((item) => item.basis.includes(form.value.basis))
})
const currentMetaEventList = computed(() => {
......@@ -58,21 +59,31 @@ function handleBasisChange(value: any) {
} else {
form.value.rule = '201'
}
form.value.attr_id = ''
// 清空数据
Object.assign(
form.value,
cloneDeep({
event_id: '-1',
attr_id: '',
attr_type: '',
config: defaultScore,
extend_config: { default_score_config: { switch: false, score: undefined } },
})
)
handleRuleChange(form.value.rule)
}
function handleRuleChange(value: any) {
if (value === '102') {
form.value.config = [...defaultLevel]
form.value.config = cloneDeep(defaultLevel)
} else {
form.value.config = [...defaultScore]
form.value.config = cloneDeep(defaultScore)
}
form.value.attr_id = ''
}
function handleAttrChange(value: any) {
form.value.attr_type = userAttrList.value.find(item => item.id === value)?.type
form.value.attr_type = userAttrList.value.find((item) => item.id === value)?.type
}
const options = ref<{ label: string; value: string }[]>([])
......@@ -81,7 +92,7 @@ async function remoteMethod(search: string = '') {
options.value = []
if (form.value.attr_id) {
loading.value = true
await searchMetaMemberAttrs({ search, member_meta_id: form.value.attr_id, per_page: 100 }).then(res => {
await searchMetaMemberAttrs({ search, member_meta_id: form.value.attr_id, per_page: 100 }).then((res) => {
options.value = res.data.list.map((item: any) => {
return { label: item.attr_value, value: item.attr_value }
})
......@@ -96,7 +107,7 @@ function querySearch(queryString: string, cb: any) {
watch(
() => form.value.attr_id,
attrId => {
(attrId) => {
if (form.value.rule === '102') {
remoteMethod()
} else {
......@@ -120,7 +131,7 @@ const a = [
{ id: '002', label: '1500' },
{ id: '003', label: '3000' },
{ id: '004', label: '2200' },
{ id: '005', label: '1800' }
{ id: '005', label: '1800' },
]
const defaultOptions = Array.from({ length: 5 }).map((_, index) => ({ value: index + 1, label: index + 1 }))
......@@ -147,11 +158,23 @@ const defaultOptions = Array.from({ length: 5 }).map((_, index) => ({ value: ind
</el-radio-group>
<p style="margin-left: 10px">计算规则:</p>
<el-select v-model="form.rule" style="width: 170px" @change="handleRuleChange">
<el-option v-for="item in currentRuleList" :key="item.value" :label="item.label" :value="item.value"></el-option>
<el-option
v-for="item in currentRuleList"
:key="item.value"
:label="item.label"
:value="item.value"></el-option>
</el-select>
<div class="rfm-tips">
<el-popover popper-class="rfm-popover" placement="right" title="属性值平均法" :width="400" trigger="hover" v-if="form.rule == '101'">
<p>用于计算选中属性的平均值,通过对选定的字段中的所有记录进行数值相加,然后除以记录的数量来计算的。主要针对“数字”和“整数”两种字段类型。</p>
<el-popover
popper-class="rfm-popover"
placement="right"
title="属性值平均法"
:width="400"
trigger="hover"
v-if="form.rule == '101'">
<p>
用于计算选中属性的平均值,通过对选定的字段中的所有记录进行数值相加,然后除以记录的数量来计算的。主要针对“数字”和“整数”两种字段类型。
</p>
<p>举例:</p>
<el-table :data="a" border>
<el-table-column prop="id" label="用户ID" />
......@@ -162,13 +185,25 @@ const defaultOptions = Array.from({ length: 5 }).map((_, index) => ({ value: ind
<el-icon><QuestionFilled /></el-icon>
</template>
</el-popover>
<el-popover popper-class="rfm-popover" placement="right" title="属性值分类法" :width="400" trigger="hover" v-if="form.rule == '102'">
<el-popover
popper-class="rfm-popover"
placement="right"
title="属性值分类法"
:width="400"
trigger="hover"
v-if="form.rule == '102'">
<p>将数据的属性值按照一定的规则或特性进行分类,本系统中分了“高”和“低”两类。主要针对“字符串”的字段类型。</p>
<template #reference>
<el-icon><QuestionFilled /></el-icon>
</template>
</el-popover>
<el-popover popper-class="rfm-popover" placement="right" title="事件发生次数平均法" :width="400" trigger="hover" v-if="form.rule == '201'">
<el-popover
popper-class="rfm-popover"
placement="right"
title="事件发生次数平均法"
:width="400"
trigger="hover"
v-if="form.rule == '201'">
<p>分析事件发生频率的方法,即通过计算用户事件发生的平均次数。</p>
<template #reference>
<el-icon><QuestionFilled /></el-icon>
......@@ -177,19 +212,37 @@ const defaultOptions = Array.from({ length: 5 }).map((_, index) => ({ value: ind
</div>
<el-select v-model="form.event_id" placeholder="选择事件" style="width: 160px" v-if="form.basis === '2'">
<el-option v-for="item in currentMetaEventList" :key="item.event_id" :label="item.event_name" :value="item.event_id"></el-option>
<el-option
v-for="item in currentMetaEventList"
:key="item.event_id"
:label="item.event_name"
:value="item.event_id"></el-option>
</el-select>
<el-select v-model="form.attr_id" placeholder="选择属性" style="width: 160px" @change="handleAttrChange" v-else>
<el-option v-for="item in userAttrList" :key="item.id" :label="item.name" :value="item.id"></el-option>
</el-select>
<div style="flex: 1; display: flex; justify-content: space-between" v-if="form.basis == 1 && form.rule != '102' && form.attr_id">
<p>最小值:{{ userAttrRange.min }}<br />最大值:{{ userAttrRange.max }}<br />"0"值数量:{{ userAttrRange.zero_count }}</p>
<p>平均值:{{ userAttrRange.avg }}<br />中位数:{{ userAttrRange.median }}<br />中位数(不含0):{{ userAttrRange.no_zero_median }}</p>
<div
style="flex: 1; display: flex; justify-content: space-between"
v-if="form.basis == 1 && form.rule != '102' && form.attr_id">
<p>
最小值:{{ userAttrRange.min }}<br />最大值:{{ userAttrRange.max }}<br />"0"值数量:{{
userAttrRange.zero_count
}}
</p>
<p>
平均值:{{ userAttrRange.avg }}<br />中位数:{{ userAttrRange.median }}<br />中位数(不含0):{{
userAttrRange.no_zero_median
}}
</p>
</div>
</div>
<div class="rfm-header-extra" v-if="form.rule === '101' && form.extend_config">
<p>未匹配数据默认赋值</p>
<el-select-v2 v-model="form.extend_config.default_score_config.score" :options="defaultOptions" style="width: 100px; margin: 0 10px" clearable />
<el-select-v2
v-model="form.extend_config.default_score_config.score"
:options="defaultOptions"
style="width: 100px; margin: 0 10px"
clearable />
<el-switch v-model="form.extend_config.default_score_config.switch"></el-switch>
</div>
<div class="rfm-body">
......
import md5 from 'blueimp-md5'
import { useStorage } from '@vueuse/core'
import { fetchEventSource } from '@fortaine/fetch-event-source'
import axios from 'axios'
import { getAIUsage, postGenerateImage } from '@/api/ai'
import { ElMessage } from 'element-plus'
export function useAI(config) {
// AI 配置列表
const options = [
{ label: '文心一言', value: 'yiyan' },
{ label: 'DeepSeek', value: 'deepseek' },
{ label: '通义千问', value: 'qwen' },
{ label: '天工', value: 'tiangong' },
]
const ai = useStorage('ai', 'tiangong')
const messages = ref([])
const isLoading = ref(false)
async function post(data) {
isLoading.value = true
try {
switch (ai.value) {
case 'yiyan':
await yiyan(data)
break
case 'deepseek':
await siliconflow(data)
break
case 'qwen':
await qwen(data)
break
case 'tiangong':
await tiangong(data)
break
default:
throw new Error('未找到对应的 AI 配置')
}
} catch (err) {
console.error('AI 请求失败:', err)
} finally {
isLoading.value = false
}
}
// 文心一言
async function yiyan(data) {
// 获取token
const getAccessToken = async () => {
const AK = 'wY7bvMpkWeZbDVq9w3EDvpjU'
const SK = 'XJwpiJWxs5HXkOtbo6tQrvYPZFJAWdAy'
const resp = await axios.post(
'/api/qianfan/oauth/2.0/token?grant_type=client_credentials&client_id=' + AK + '&client_secret=' + SK
)
return resp.data.access_token
}
const resp = await axios.post(
'/api/qianfan/rpc/2.0/ai_custom/v1/wenxinworkshop/chat/eb-instant?access_token=' + (await getAccessToken()),
{
messages: [{ role: 'user', content: data.content }],
}
)
messages.value.push({ role: 'assistant', content: resp.data.result.replaceAll('\n', '<br/>') })
}
// DeepSeek
// async function deepseek(data) {
// const apiKey = 'sk-f1a6f0a7013241de8393cb2cb108e777'
// const resp = await axios.post(
// '/api/deepseek/chat/completions',
// {
// model: 'deepseek-chat',
// messages: [{ role: 'user', content: data.content }],
// },
// {
// headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
// }
// )
// if (resp.data) {
// const [choice = {}] = resp.data.choices
// messages.value.push({ role: 'assistant', content: choice.message.content.replaceAll('\n', '<br/>') })
// }
// }
// 硅基流动
async function siliconflow(data) {
const apiKey = 'sk-bivnwauskdbvpspvmdorrgkrpwlyfxbfcezqsfsevowzubdj'
const resp = await axios.post(
'/api/siliconflow/v1/chat/completions',
{
model: 'deepseek-ai/DeepSeek-V3',
messages: [{ role: 'user', content: data.content }],
},
{
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
}
)
if (resp.data) {
const [choice = {}] = resp.data.choices
messages.value.push({ role: 'assistant', content: choice.message.content.replaceAll('\n', '<br/>') })
}
}
// 通义千问
async function qwen(data) {
const apiKey = 'sk-afd0fcdb53bf4058b2068b8548820150'
const resp = await axios.post(
'/api/qwen/compatible-mode/v1/chat/completions',
{
model: 'qwen-max',
messages: [{ role: 'user', content: data.content }],
},
{
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
}
)
if (resp.data) {
const [choice = {}] = resp.data.choices
messages.value.push({ role: 'assistant', content: choice.message.content.replaceAll('\n', '<br/>') })
}
}
// 天工
async function tiangong(data) {
const appKey = 'a8701b73637562d33a53c668a90ee3be'
const appSecret = 'e191593f486bb88a39c634f46926762dddc97b9082e192af'
const timestamp = Math.floor(Date.now() / 1000)
const sign = md5(`${appKey}${appSecret}${timestamp}`)
return await fetchEventSource('/api/tiangong/sky-saas-writing/api/v1/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json', app_key: appKey, sign, timestamp, stream: 'true' },
body: JSON.stringify({
chat_history: [{ role: 'user', content: data.content }],
stream_resp_type: 'update',
}),
async onopen(response) {
console.log(response)
if (response.ok) {
return response
} else {
throw response
}
},
onmessage(res) {
console.log(res.data)
const message = JSON.parse(res.data)
if (message.type !== 1) return
const messageId = message.conversation_id
const messageIndex = messages.value.findIndex((message) => message.id === messageId)
const content = message?.arguments?.[0]?.messages?.[0]?.text || ''
if (messageIndex === -1) {
messages.value.push({ id: messageId, role: 'assistant', content })
} else {
messages.value[messageIndex].content = content
}
isLoading.value = false
},
onerror(err) {
isLoading.value = false
throw err
},
})
}
const usages = ref({
chart_count: 0,
ai_creation_count: 0,
ai_polish_count: 0,
ai_expand_count: 0,
ai_refresh_count: 0,
chart_max_count: 20,
ai_creation_max_count: 5,
ai_polish_max_count: 5,
ai_expand_max_count: 5,
ai_refresh_max_count: 5,
})
async function fetchUsages() {
const res = await getAIUsage(config)
usages.value = res.data.detail
}
async function generateText(data) {
isLoading.value = true
const docAction = {
2: 'write',
3: 'rewrite',
4: 'expand',
5: 'rewrite',
7: 'abbreviate',
8: 'summary',
}
const params = {
content: data.content,
doc_action: docAction[data.type],
full_text: !!(data.type === 2),
}
await fetchEventSource('/api/lab/v1/experiment/marketing-ai/sky-agent3-chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...config,
...data,
api_type: parseInt(data.type) === 1 ? 1 : 2,
context: data.content,
params: params,
}),
async onopen(response) {
if (response.ok) {
return
} else {
throw response
}
},
onmessage(res) {
const message = JSON.parse(res.data)
// 聊天返回内容
if (data.type === '1') {
if (message.code === 0) {
ElMessage.error(message.message)
return
}
const conversationId = message.conversation_id
const messageIndex = messages.value.findIndex((session) => session.conversationId === conversationId)
const content = message?.arguments?.reduce((a, b) => {
a = b?.messages[0]?.text || ''
return a
}, '')
if (messageIndex === -1) {
messages.value.push({ conversationId, role: 'assistant', content, input: data.context })
} else {
if (content) {
messages.value[messageIndex].content = content
}
}
} else {
// 按钮功能返回内容
const requestId = message.request_id
const messageIndex = messages.value.findIndex((session) => session.conversationId === requestId)
if (messageIndex === -1) {
messages.value.push({
conversationId: requestId,
role: 'assistant',
content: message.data?.text || '',
input: data.context,
})
} else {
messages.value[messageIndex].content = message.data?.text
}
}
isLoading.value = false
},
onclose() {
fetchUsages()
isLoading.value = false
},
onerror(err) {
console.log(err)
isLoading.value = false
throw err
},
})
}
// 生成图片
async function generateImage(data) {
isLoading.value = true
try {
const res = await postGenerateImage({ ...config, ...data })
if (res.data.detail.image_url) {
messages.value.push({ type: 'image', role: 'assistant', ...res.data.detail })
} else {
ElMessage.error(res.data.detail.failure_reason)
}
fetchUsages()
} catch (error) {
console.log(error)
}
isLoading.value = false
}
return { ai, options, post, messages, isLoading, usages, fetchUsages, generateText, generateImage }
}
import { getMetaUserAttrList, getMetaEventList, getTagList, getConnectionList, getUserList } from '@/api/base'
import {
getMetaUserAttrList,
getMetaEventList,
getTagList,
getConnectionList,
getUserList,
getGroupList,
} from '@/api/base'
import { useMapStore } from '@/stores/map'
// 用户属性类型
......@@ -36,6 +43,12 @@ export interface ConnectionType {
config_attributes: any
}
// 群组类型
export interface GroupType {
id: string
name: string
}
// 所有用户属性
const userAttrList = ref<AttrType[]>([])
const userAttrLoading = ref(false)
......@@ -100,8 +113,9 @@ export function useConnection() {
const connectionType = useMapStore().getMapValuesByKey('experiment_connection_type')
await getConnectionList().then((res: any) => {
connectionList.value = res.data.items.map((item: any) => {
const connection = connectionType.find(type => type.value == item.type)
const attrs = typeof item.config_attributes === 'string' ? JSON.parse(item.config_attributes) : item.config_attributes
const connection = connectionType.find((type) => type.value == item.type)
const attrs =
typeof item.config_attributes === 'string' ? JSON.parse(item.config_attributes) : item.config_attributes
const name = Array.isArray(attrs) ? attrs.find((item: any) => item.prop === 'name')?.value : attrs.name
return { ...item, config_attributes: attrs, name, type_name: connection?.label || item.type }
})
......@@ -142,3 +156,21 @@ export function useUser() {
})
return { fetchUserList, userList, userValue }
}
// 所有群组
const groupList = ref<GroupType[]>([])
export function useGroup() {
function fetchGroupList() {
getGroupList().then((res: any) => {
groupList.value = res.data.items
})
}
onMounted(() => {
if (!groupList.value?.length) fetchGroupList()
})
function getGroup(groupId: string) {
return groupList.value.find((item) => item.id === groupId)
}
return { fetchGroupList, groupList, getGroup }
}
......@@ -4,50 +4,58 @@ export function useChat() {
const messages = ref([])
const isLoading = ref(false)
async function post(message, isReplace = true) {
function post(message, isReplace = true) {
isLoading.value = true
await fetchEventSource('/api/lab/v1/experiment/qwen/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'qwen-long', messages: [message] }),
async onopen(response) {
if (response.ok) {
return response
} else {
throw response
}
},
onmessage(res) {
console.log(res.data)
if (res.data === '[DONE]') {
isLoading.value = false
}
try {
const message = JSON.parse(res.data)
if (message.error) {
ElMessage.error(message.error.message)
return
return new Promise((resolve, reject) => {
fetchEventSource('/api/lab/v1/experiment/qwen/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'qwen-long', messages: [message] }),
async onopen(response) {
if (response.ok) {
return response
} else {
isLoading.value = false
reject(response)
throw response
}
const id = message.id
const messageIndex = messages.value.findIndex((session) => session.id === id)
let content = message?.choices[0]?.delta.content || ''
if (isReplace) {
content = content.replaceAll('\n', '<br/>')
},
onmessage(res) {
console.log(res.data)
if (res.data === '[DONE]') {
isLoading.value = false
resolve(messages.value.at(-1))
return
}
if (messageIndex === -1) {
messages.value.push({ id, role: 'assistant', content })
} else {
messages.value[messageIndex].content = messages.value[messageIndex].content + content
try {
const message = JSON.parse(res.data)
if (message.error) {
ElMessage.error(message.error.message)
return
}
const id = message.id
const messageIndex = messages.value.findIndex((session) => session.id === id)
let content = message?.choices[0]?.delta.content || ''
if (isReplace) {
content = content.replaceAll('\n', '<br/>')
}
if (messageIndex === -1) {
messages.value.push({ id, role: 'assistant', content })
} else {
messages.value[messageIndex].content = messages.value[messageIndex].content + content
}
} catch (error) {
console.log(error)
isLoading.value = false
reject(error)
}
} catch (error) {
console.log(error)
},
onerror(err) {
isLoading.value = false
}
},
onerror(err) {
isLoading.value = false
throw err
},
reject(err)
throw err
},
})
})
}
......
import type { Ref } from 'vue'
import { getExperimentQuestionList, getTeacherGroups, getTeacherTags, getTeacherMaterials } from '@/api/question'
import { useUserStore } from '@/stores/user'
type QuesitonItem = {
experiment_id: string
id: string
type: string
}
type OptionItem = {
id: string
name: string
}
type MaterialItem = {
id: string
name: string
type: string
content: ''
}
export function useQuestion(questionType: string | Ref<string>, type?: string) {
questionType = shallowRef(questionType)
const useStore = useUserStore()
// 试题列表
const questionList = ref<QuesitonItem[]>([])
const fetchQuestionList = async () => {
const res = await getExperimentQuestionList()
questionList.value = res.data.items
}
const hasQuestion = computed(() => {
return !!questionList.value.find((item) => item.type === questionType.value)
})
onMounted(async () => {
if (useStore.role?.id === 1) {
await fetchQuestionList()
}
if (hasQuestion.value) {
if (questionType.value == '202') await fetchTeacherTagList()
if (questionType.value == '301' || questionType.value == '302') await fetchTeacherGroupList()
if (['401', '402', '403', '404', '405', '406', '407', '408'].includes(questionType.value)) {
await fetchTeacherMaterialList()
}
}
})
const teacherGroupList = ref<OptionItem[]>([])
const fetchTeacherGroupList = async () => {
const res = await getTeacherGroups({ type })
teacherGroupList.value = res.data.items
}
const teacherTagList = ref<OptionItem[]>([])
const fetchTeacherTagList = async () => {
const res = await getTeacherTags()
teacherTagList.value = res.data.items
}
const teacherMaterialAllList = ref<MaterialItem[]>([])
const teacherMaterialList = computed(() => {
const questionTypes: any = {
'401': 1,
'402': 2,
'403': 3,
'404': 4,
'405': 5,
'406': 6,
'407': 7,
'408': 8,
}
return teacherMaterialAllList.value.filter((item) => item.type == questionTypes[questionType.value])
})
const fetchTeacherMaterialList = async () => {
const res = await getTeacherMaterials()
teacherMaterialAllList.value = res.data.items
}
return {
hasQuestion,
questionList,
fetchQuestionList,
teacherGroupList,
fetchTeacherGroupList,
teacherTagList,
fetchTeacherTagList,
teacherMaterialList,
fetchTeacherMaterialList,
}
}
......@@ -34,3 +34,8 @@ export function getMemberMetaAttrs() {
export function getUserTags(params: { sso_id: string; limit: number }) {
return httpRequest.get('/api/lab/v1/experiment/analyse/user-tags', { params })
}
// AI分析与总结
export function getAISummary() {
return httpRequest.get('/api/lab/v1/experiment/member/ai-all-person', { adapter: 'fetch', responseType: 'stream' })
}
<script setup>
import { getAISummary } from '../api'
import { aiStreamParse } from '@/utils/parse'
import VueMarkdown from 'vue-markdown-render'
const content = ref('')
const isLoading = ref(false)
async function fetchAI() {
isLoading.value = true
const stream = await getAISummary()
aiStreamParse(stream, (json, messageContent) => {
content.value += messageContent
})
isLoading.value = false
}
onMounted(() => {
fetchAI()
})
</script>
<template>
<el-dialog title="AI用户整体画像分析与建议">
<div v-loading="isLoading">
<VueMarkdown :source="content" class="markdown-body" />
</div>
<template #footer>
<el-row justify="center">
<el-button round @click="$emit('update:modelValue', false)">关闭</el-button>
</el-row>
</template>
</el-dialog>
</template>
......@@ -8,6 +8,9 @@ import { useMapStore } from '@/stores/map'
import { getNameByValue } from '@/utils/dictionary'
import * as api from '../api'
const AISummaryDialog = defineAsyncComponent(() => import('../components/AISummaryDialog.vue'))
const aiDialogVisible = ref(false)
const connectionTypeList = useMapStore().getMapValuesByKey('experiment_connection_type')
const statusList = useMapStore().getMapValuesByKey('system_status')
......@@ -57,7 +60,7 @@ const genderOption = computed(() => {
return {
grid: { left: '60', right: '60' },
tooltip: {
trigger: 'item'
trigger: 'item',
},
yAxis: {
data: ['男性', '女性'],
......@@ -67,15 +70,20 @@ const genderOption = computed(() => {
axisLabel: {
formatter: function (value, index) {
const total = parseInt(man.total) + parseInt(woman.total)
return value + '\n' + (index === 0 ? ((man.total / total) * 100).toFixed(1) : ((woman.total / total) * 100).toFixed(1)) + '%'
}
}
return (
value +
'\n' +
(index === 0 ? ((man.total / total) * 100).toFixed(1) : ((woman.total / total) * 100).toFixed(1)) +
'%'
)
},
},
},
xAxis: {
splitLine: { show: false },
axisLabel: { show: false },
axisTick: { show: false },
axisLine: { show: false }
axisLine: { show: false },
},
series: [
{
......@@ -86,10 +94,10 @@ const genderOption = computed(() => {
symbolMargin: 10,
data: [
{ value: man.total, symbol: manIcon, itemStyle: { color: '#767aca' } },
{ value: woman.total, symbol: womanIcon, itemStyle: { color: '#d26080' } }
]
}
]
{ value: woman.total, symbol: womanIcon, itemStyle: { color: '#d26080' } },
],
},
],
}
})
......@@ -101,7 +109,7 @@ async function fetchConnections() {
loading2.value = true
try {
const res = await api.getMemberConnections({ sso_id: userValue.value })
connection.value = res.data.items.map(item => {
connection.value = res.data.items.map((item) => {
return { ...item, group_name: getNameByValue(item.group_name, connectionTypeList) }
})
} finally {
......@@ -113,24 +121,24 @@ const connectionOption = computed(() => {
return {
grid: { left: '5%', top: '10%', right: '5%', bottom: '5%', containLabel: true },
tooltip: {
trigger: 'axis'
trigger: 'axis',
},
xAxis: {
type: 'category',
axisLabel: { interval: 0 },
data: connection.value.map(item => item.group_name)
data: connection.value.map((item) => item.group_name),
},
yAxis: {
type: 'value'
type: 'value',
},
series: [
{
name: '数据',
type: 'bar',
label: { show: true, position: 'top' },
data: connection.value.map(item => item.total)
}
]
data: connection.value.map((item) => item.total),
},
],
}
})
......@@ -142,7 +150,7 @@ async function fetchStatus() {
loading3.value = true
try {
const res = await api.getMemberStatus({ sso_id: userValue.value })
status.value = res.data.items.map(item => {
status.value = res.data.items.map((item) => {
return { name: getNameByValue(item.group_name, statusList), value: item.total }
})
} finally {
......@@ -155,7 +163,7 @@ const statusOption = computed(() => {
grid: { left: '5%', top: '10%', right: '5%', bottom: '5%', containLabel: true },
tooltip: {
trigger: 'item',
formatter: '{b}: {c}<br />{d}%'
formatter: '{b}: {c}<br />{d}%',
},
series: [
{
......@@ -163,9 +171,9 @@ const statusOption = computed(() => {
label: { formatter: '{b}\n{d}%' },
itemStyle: { borderRadius: 6 },
radius: [0, '70%'],
data: status.value
}
]
data: status.value,
},
],
}
})
</script>
......@@ -173,15 +181,28 @@ const statusOption = computed(() => {
<template>
<AppCard title="用户分析">
<el-form inline label-suffix=":">
<el-form-item label="实验名称">{{ info?.name }}</el-form-item>
<el-form-item label="请选择学生/老师">
<el-select v-model="userValue" filterable>
<el-option v-for="item in userList" :label="item.name" :value="item.sso_id" :key="item.sso_id"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="DataLine" :loading="loading" @click="handleStart">分析</el-button>
</el-form-item>
<div style="display: flex; justify-content: space-between">
<div>
<el-form-item label="实验名称">{{ info?.name }}</el-form-item>
<el-form-item label="请选择学生/老师">
<el-select v-model="userValue" filterable>
<el-option
v-for="item in userList"
:label="item.name"
:value="item.sso_id"
:key="item.sso_id"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="DataLine" :loading="loading" @click="handleStart">分析</el-button>
</el-form-item>
</div>
<div>
<el-form-item>
<el-button type="primary" @click="aiDialogVisible = true">AI分析与总结</el-button>
</el-form-item>
</div>
</div>
<el-divider style="margin: 10px 0" />
<el-form-item label="用户总数">
<b class="total">{{ userTotal }}</b>
......@@ -199,6 +220,7 @@ const statusOption = computed(() => {
<UserChart :ssoId="userValue" />
</div>
</AppCard>
<AISummaryDialog v-model="aiDialogVisible" v-if="aiDialogVisible"></AISummaryDialog>
</template>
<style lang="scss" scoped>
......
......@@ -5,6 +5,7 @@ import type { DetailsProp, PlatformItem, ConfigAttribute } from '../types'
const StepOne = defineAsyncComponent(() => import('../components/StepOne.vue'))
const StepTwo = defineAsyncComponent(() => import('../components/StepTwo.vue'))
const WechatVideo = defineAsyncComponent(() => import('../components/WechatVideo.vue'))
const props = defineProps<{ data?: DetailsProp }>()
......@@ -18,7 +19,7 @@ const platformList: PlatformItem[] = [
type: '1',
type_name: '公众号',
config_attributes: [
{ label: '连接名称', prop: 'name', value: '' }
{ label: '连接名称', prop: 'name', value: '' },
// { label: '公众号类型', prop: 'type', value: '' },
// { label: '授权方昵称', prop: 'nikeName', value: '' }
],
......@@ -30,7 +31,7 @@ const platformList: PlatformItem[] = [
return false
}
return true
}
},
},
{
type: '2',
......@@ -39,8 +40,8 @@ const platformList: PlatformItem[] = [
{ label: '连接名称', prop: 'name', value: '钉钉' },
{ label: 'AgentId', prop: 'agentId', value: '8441459810' },
{ label: 'AppKey', prop: 'appKey', value: 'dingigucs3beqlotpf24' },
{ label: 'AppSecret', prop: 'appSecret', value: '6dNRvuOzvX_xq5N9tFdjepdf3FeooN25yUZK6ammDbPUVq9sfdXD-sKUg' }
]
{ label: 'AppSecret', prop: 'appSecret', value: '6dNRvuOzvX_xq5N9tFdjepdf3FeooN25yUZK6ammDbPUVq9sfdXD-sKUg' },
],
},
{
type: '3',
......@@ -49,8 +50,8 @@ const platformList: PlatformItem[] = [
{ label: '连接名称', prop: 'name', value: '小鹅通' },
{ label: 'app_id', prop: 'app_id', value: 'appc4bolgenF58' },
{ label: 'client_id', prop: 'client_id', value: '_5e7f809dd6317_qSMuUoAi?type=2SDK' },
{ label: 'secret_key', prop: 'secret_key', value: 'xiao_5ac1dd24803ae_GtfAOxiS1pdf3FeooN2huhu92WRE52S-SkOh' }
]
{ label: 'secret_key', prop: 'secret_key', value: 'xiao_5ac1dd24803ae_GtfAOxiS1pdf3FeooN2huhu92WRE52S-SkOh' },
],
},
{
type: '4',
......@@ -58,8 +59,8 @@ const platformList: PlatformItem[] = [
config_attributes: [
{ label: '连接名称', prop: 'name', value: '问卷星' },
{ label: 'AppKey', prop: 'appKey', value: '82286f9c5114dc2bda214cd9567dodc' },
{ label: 'AppSecret', prop: 'appSecret', value: 'pages/wjxqList/wjxqList?activityId= P251FBP' }
]
{ label: 'AppSecret', prop: 'appSecret', value: 'pages/wjxqList/wjxqList?activityId= P251FBP' },
],
},
{ type: '5', type_name: '今日头条', config_attributes: [{ label: '连接名称', prop: 'name', value: '今日头条' }] },
{
......@@ -73,11 +74,11 @@ const platformList: PlatformItem[] = [
label: '网站应用简介',
prop: 'dyInput3',
value:
'不仅是下载抖音应用程序的官方渠道,也是一个展示抖音最新动态、功能更新和推广活动的平台。用户可以通过官网了解抖音的特色功能、查看热门视频、参与互动活动,以及获取帮助和教程等。官网还为创作者和企业提供了一个展示空间,让他们了解如何利用抖音平台进行内容创作、品牌推广和电子商务等。'
'不仅是下载抖音应用程序的官方渠道,也是一个展示抖音最新动态、功能更新和推广活动的平台。用户可以通过官网了解抖音的特色功能、查看热门视频、参与互动活动,以及获取帮助和教程等。官网还为创作者和企业提供了一个展示空间,让他们了解如何利用抖音平台进行内容创作、品牌推广和电子商务等。',
},
{ label: '应用官网', prop: 'dyInput4', value: 'https://www.douyin.com' },
{ label: '联系人姓名', prop: 'dyInput5', value: '清控紫荆(北京)教育股份有限公司' }
]
{ label: '联系人姓名', prop: 'dyInput5', value: '清控紫荆(北京)教育股份有限公司' },
],
},
{
type: '7',
......@@ -85,8 +86,8 @@ const platformList: PlatformItem[] = [
config_attributes: [
{ label: '连接名称', prop: 'name', value: '微博' },
{ label: 'AppKey', prop: 'appKey', value: '1206405345' },
{ label: 'AppSecret', prop: 'appSecret', value: '6a6095e113cd28fde6e14c7b7145c5c5' }
]
{ label: 'AppSecret', prop: 'appSecret', value: '6a6095e113cd28fde6e14c7b7145c5c5' },
],
},
{
type: '8',
......@@ -94,8 +95,8 @@ const platformList: PlatformItem[] = [
config_attributes: [
{ label: '连接名称', prop: 'name', value: '小红书' },
{ label: 'AppKey', prop: 'appKey', value: '6c1dd8dd64d074d56124c751f6bc240b' },
{ label: 'AppSecret', prop: 'appSecret', value: '' }
]
{ label: 'AppSecret', prop: 'appSecret', value: '' },
],
},
{
type: '9',
......@@ -105,8 +106,8 @@ const platformList: PlatformItem[] = [
{ label: 'client_id', prop: 'client_id', value: 'swanzhong' },
{ label: 'client_secret', prop: 'client_secret', value: '563a8c6a89d2368194c1c7889c508b34' },
{ label: 'token URL', prop: 'token', value: 'openapi/user/get' },
{ label: 'API URL', prop: 'apiUrl', value: 'openapi/user/check' }
]
{ label: 'API URL', prop: 'apiUrl', value: 'openapi/user/check' },
],
},
{
type: '10',
......@@ -116,8 +117,8 @@ const platformList: PlatformItem[] = [
{ label: 'client_id', prop: 'client_id', value: 'FbFgN2of-mlc' },
{ label: 'SdkAppId', prop: 'sdkAppId', value: 'CV3X1%2FJG7mdNZm03l9puvwPAktmfw1aj8XvBb6sm696MqoW57' },
{ label: 'token URL', prop: 'token', value: 'https://oauth-login.cloud.ali.com/oauth2/v3/token' },
{ label: 'API URL', prop: 'apiUrl', value: 'oauth2v3wPAktm' }
]
{ label: 'API URL', prop: 'apiUrl', value: 'oauth2v3wPAktm' },
],
},
{ type_name: '内部消息', type: '11', config_attributes: [{ label: '连接名称', prop: 'name', value: '内部消息' }] },
{
......@@ -126,14 +127,14 @@ const platformList: PlatformItem[] = [
config_attributes: [
{ label: '连接名称', prop: 'name', value: '自定义' },
{ label: 'APP类型', prop: 'appType', value: '自定义' },
{ label: 'AppId', prop: 'appId', value: 'Custom App ID' }
]
{ label: 'AppId', prop: 'appId', value: 'Custom App ID' },
],
},
{
type: '13',
type_name: '紫荆表单',
icon: '99',
config_attributes: [{ label: '连接名称', prop: 'name', value: '紫荆表单' }]
config_attributes: [{ label: '连接名称', prop: 'name', value: '紫荆表单' }],
},
{
type: '14',
......@@ -148,14 +149,32 @@ const platformList: PlatformItem[] = [
return false
}
return true
}
},
},
{
icon: 'mall',
type: '15',
type_name: '紫荆商城',
config_attributes: [{ label: '连接名称', prop: 'name', value: '紫荆商城' }]
}
config_attributes: [{ label: '连接名称', prop: 'name', value: '紫荆商城' }],
},
{
icon: 'wechatVideo',
type: '16',
type_name: '视频号',
config_attributes: [{ label: '连接名称', prop: 'name', value: '视频号' }],
async onBeforePrev(stepActive) {
if (stepActive == 2) {
return wechatVideoRef.value?.handlePrev()
}
return true
},
async onBeforeNext(stepActive) {
if (stepActive == 2) {
return wechatVideoRef.value?.handleNext()
}
return true
},
},
]
// 请求参数
......@@ -172,7 +191,7 @@ watchEffect(() => {
Object.assign(params, { type, config_attributes: attributes })
})
const selectedItem = computed(() => platformList.find(item => item.type === params.type))
const selectedItem = computed(() => platformList.find((item) => item.type === params.type))
const defaultStepActive = props.data?.id ? 2 : 1
const stepActive = ref(defaultStepActive)
......@@ -183,13 +202,17 @@ function handleChange(data: PlatformItem) {
}
// 上一步
function handlePrev() {
async function handlePrev() {
if (selectedItem.value?.onBeforePrev) {
const next = await selectedItem.value?.onBeforePrev(stepActive.value, selectedItem.value)
if (!next) return
}
stepActive.value--
}
// 下一步
async function handleNext() {
const isEmpty = params.config_attributes.find(item => item.value === '')
const isEmpty = params.config_attributes.find((item) => item.value === '')
if (isEmpty && stepActive.value === 2) {
ElMessage('请填写完整')
return
......@@ -223,6 +246,8 @@ async function handleSave() {
emit('update')
emit('update:modelValue', false)
}
const wechatVideoRef = ref<any>(null)
</script>
<template>
......@@ -239,7 +264,13 @@ async function handleSave() {
</el-tab-pane>
<!-- 第二步 -->
<el-tab-pane disabled lazy label="配置连接信息" :name="2">
<StepTwo :platform="selectedItem" v-model="params.config_attributes" v-if="selectedItem"></StepTwo>
<template v-if="selectedItem">
<WechatVideo
ref="wechatVideoRef"
v-model="params.config_attributes"
v-if="params.type === '16'"></WechatVideo>
<StepTwo :platform="selectedItem" v-model="params.config_attributes" v-else></StepTwo>
</template>
</el-tab-pane>
<el-tab-pane disabled lazy label="测试连接" :name="3">
<el-button type="primary" @click="handleTest">测试连接</el-button>
......
......@@ -50,6 +50,7 @@ const edit = function () {
const iconMap: Record<string, string> = {
'13': '99',
'14': '100',
'16': 'wechatVideo'
}
const generateUserData = function () {
......
<script setup>
defineProps(['data'])
const step = ref(1)
const stepLength = 4
function handlePrev() {
if (step.value === 1) return true
step.value--
}
function handleNext() {
if (step.value === stepLength) return true
step.value++
}
defineExpose({ step, handlePrev, handleNext })
</script>
<template>
<div class="wechat-video">
<div class="wechat-video-step" v-show="step === 1">
<h6 class="wechat-video-step__title">第1步:请点击下图示例的“视频号”</h6>
<img src="/wechat_video/1.png" />
</div>
<div class="wechat-video-step" v-show="step === 2">
<h6 class="wechat-video-step__title">第2步:请点击下图示例的</h6>
<img src="/wechat_video/2.png" />
</div>
<div class="wechat-video-step" v-show="step === 3">
<h6 class="wechat-video-step__title">第3步:请点击下图示例的“发表视频”</h6>
<img src="/wechat_video/3.png" />
</div>
<div class="wechat-video-step" v-show="step === 4">
<h6 class="wechat-video-step__title">第4步:上传头像,维护视频号名称,勾选相关规范和隐私说明,点击“创建”</h6>
<img src="/wechat_video/4.png" />
</div>
</div>
</template>
<style lang="scss">
.wechat-video {
.wechat-video-step {
margin-bottom: 20px;
img {
display: block;
width: 375px;
margin: 0 auto;
}
&__title {
margin-bottom: 20px;
}
}
}
</style>
......@@ -22,6 +22,7 @@ export interface PlatformItem {
type_name: string
icon?: string
config_attributes?: ConfigAttribute[]
onBeforePrev?: (index: number, data: PlatformItem) => Promise<boolean> | boolean
onBeforeNext?: (index: number, data: PlatformItem) => Promise<boolean> | boolean
}
......@@ -60,10 +61,10 @@ export interface OtherFields {
rule: any
}
export interface StudentFollow{
export interface StudentFollow {
follow_flag: string
logs: any[] | undefined
connect_id: string
type: string
data: any
}
\ No newline at end of file
}
......@@ -10,7 +10,7 @@ import {
updateDynamicGroup,
getGroupInfo,
createRFMGroup,
updateRFMGroup
updateRFMGroup,
} from '../api'
import UserRule from '@/components/rule/UserRule.vue'
import EventRule from '@/components/rule/EventRule.vue'
......@@ -18,6 +18,7 @@ import LabelRule from '@/components/rule/LabelRule.vue'
import UserActionRule from '@/components/rule/UserActionRule.vue'
import RFMRule from '@/components/rule/RFMRule.vue'
import { pick, merge } from 'lodash-es'
import { useQuestion } from '@/composables/useQuestion'
interface Props {
data: Partial<Group>
......@@ -47,7 +48,8 @@ const form: any = reactive({
event_attr_rule: { current_logic_operate: 'and', items: [] },
tag_rule: { current_logic_operate: 'and', items: [] },
user_action_rule: { current_logic_operate: 'and', items: [] },
rules: { R: {}, F: {}, M: {} }
rules: { R: {}, F: {}, M: {} },
teacher_id: '',
})
function genRuleData(data: any) {
......@@ -56,7 +58,7 @@ function genRuleData(data: any) {
}
function fetchInfo() {
if (!props.data.id) return
getGroupInfo({ id: props.data.id }).then(res => {
getGroupInfo({ id: props.data.id }).then((res) => {
const { detail } = res.data
const user_attr_rule = genRuleData(detail.user_attr_rule)
const event_attr_rule = genRuleData(detail.event_attr_rule)
......@@ -82,7 +84,7 @@ function fetchInfo() {
event_attr_rule: { ...event_attr_rule, items: eventRuleItems },
user_action_rule,
tag_rule: { ...tag_rule, items: tagRuleItems },
update_rule: { type: 1, info: 1 }
update_rule: { type: 1, info: 1 },
})
})
}
......@@ -90,9 +92,14 @@ function fetchInfo() {
watchEffect(() => fetchInfo())
const rules = ref<FormRules>({
name: [{ required: true, message: '请输入群组名称' }]
name: [{ required: true, message: '请输入群组名称' }],
teacher_id: [{ required: true, message: '请选择老师标签' }],
})
const questionType = props.data.type === '1' ? '301' : '302'
const { hasQuestion, teacherGroupList } = useQuestion(questionType, props.data.type)
// 提交
function handleSubmit() {
formRef.value?.validate().then(() => (isUpdate.value ? handleUpdate() : handleCreate()))
......@@ -122,7 +129,7 @@ async function handleCreate() {
user_attr_rule: JSON.stringify([form.user_attr_rule]),
event_attr_rule: JSON.stringify([form.event_attr_rule]),
tag_rule: JSON.stringify([{ ...form.tag_rule, ...tagRule }]),
user_action_rule: JSON.stringify(form.user_action_rule)
user_action_rule: JSON.stringify(form.user_action_rule),
},
[
'name',
......@@ -132,7 +139,8 @@ async function handleCreate() {
'user_attr_rule',
'event_attr_rule',
'tag_rule',
'user_action_rule'
'user_action_rule',
'teacher_id',
]
)
await createDynamicGroup(params)
......@@ -142,9 +150,9 @@ async function handleCreate() {
{
...form,
update_rule: JSON.stringify(form.update_rule),
rules: JSON.stringify(form.rules)
rules: JSON.stringify(form.rules),
},
['name', 'status', 'update_status', 'update_rule', 'rules']
['name', 'status', 'update_status', 'update_rule', 'rules', 'teacher_id']
)
await createRFMGroup(params)
}
......@@ -156,7 +164,7 @@ async function handleCreate() {
async function handleUpdate() {
if (props.data.type === '1') {
// 静态群组
const params = pick(form, ['id', 'name', 'status'])
const params = pick(form, ['id', 'name', 'status', 'teacher_id'])
await updateStaticGroup(params)
} else if (props.data.type === '2') {
// 动态群组
......@@ -186,7 +194,7 @@ async function handleUpdate() {
user_attr_rule: JSON.stringify([{ ...form.user_attr_rule, items: attrRuleItems }]),
event_attr_rule: JSON.stringify([{ ...form.event_attr_rule, items: eventRuleItems }]),
tag_rule: JSON.stringify([{ ...form.tag_rule, ...tagRule }]),
user_action_rule: JSON.stringify(form.user_action_rule)
user_action_rule: JSON.stringify(form.user_action_rule),
},
[
'id',
......@@ -197,7 +205,8 @@ async function handleUpdate() {
'user_attr_rule',
'event_attr_rule',
'tag_rule',
'user_action_rule'
'user_action_rule',
'teacher_id',
]
)
await updateDynamicGroup(params)
......@@ -209,7 +218,8 @@ async function handleUpdate() {
'status',
'update_status',
'update_rule',
'rules'
'rules',
'teacher_id',
])
await updateRFMGroup(params)
}
......@@ -221,7 +231,7 @@ async function handleUpdate() {
<template>
<el-dialog :title="title" :close-on-click-modal="false" width="980px" @closed="$emit('update:modelValue', false)">
<el-form ref="formRef" :model="form" :rules="rules" label-suffix=":" label-width="100px">
<el-form ref="formRef" :model="form" :rules="rules" label-suffix=":" label-width="110px">
<el-form-item label="群组名称" prop="name">
<el-input v-model="form.name" placeholder="请输入" />
</el-form-item>
......@@ -269,6 +279,11 @@ async function handleUpdate() {
</div>
</el-form-item>
</template>
<el-form-item label="匹配老师标签" prop="teacher_id" v-if="hasQuestion">
<el-select v-model="form.teacher_id" style="width: 100%">
<el-option v-for="item in teacherGroupList" :key="item.id" :label="item.name" :value="item.id"></el-option>
</el-select>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-switch v-model="form.status" active-text="生效" active-value="1" inactive-text="失效" inactive-value="0" />
</el-form-item>
......
import httpRequest from '@/utils/axios'
import type { LabelTypeListRequest, LabelTypeCreateRequest, LabelTypeUpdateRequest, LabelListRequest, LabelCreateRequest, LabelUpdateRequest } from './types'
import type {
LabelTypeListRequest,
LabelTypeCreateRequest,
LabelTypeUpdateRequest,
LabelListRequest,
LabelCreateRequest,
LabelUpdateRequest,
} from './types'
// 获取标签类型列表
export function getLabelTypeList(params?: LabelTypeListRequest) {
......@@ -70,3 +77,8 @@ export function updateLabelRule(data: { id: string; rules: string }) {
export function getLabelMembers(params: { tag_id: string }) {
return httpRequest.get('/api/lab/v1/experiment/tag/bda-statistics-users', { params })
}
// AI分析与总结
export function getAISummary() {
return httpRequest.get('/api/lab/v1/experiment/member/ai-tag', { responseType: 'stream', adapter: 'fetch' })
}
<script setup>
import { getAISummary } from '../api'
import { aiStreamParse } from '@/utils/parse'
import VueMarkdown from 'vue-markdown-render'
const content = ref('')
const isLoading = ref(false)
async function fetchAI() {
isLoading.value = true
const stream = await getAISummary()
aiStreamParse(stream, (json, messageContent) => {
content.value += messageContent
})
isLoading.value = false
}
onMounted(() => {
fetchAI()
})
</script>
<template>
<el-dialog title="AI标签建议">
<div v-loading="isLoading">
<VueMarkdown :source="content" class="markdown-body" />
</div>
<template #footer>
<el-row justify="center">
<el-button round @click="$emit('update:modelValue', false)">关闭</el-button>
</el-row>
</template>
</el-dialog>
</template>
......@@ -6,6 +6,7 @@ import { updateStatusRuleList, dateUnitList, weekList, labelList } from '@/utils
import { createLabel, updateLabel } from '../api'
import { useLabelType } from '../composables/useLabelType'
import { pick } from 'lodash-es'
import { useQuestion } from '@/composables/useQuestion'
const props = defineProps<{
data?: Label
......@@ -30,7 +31,8 @@ const form = reactive({
update_rule: { type: 1, info: 1 },
status: '1',
label: '',
weight: 0
weight: 0,
teacher_id: '',
})
watchEffect(() => {
if (props.data) {
......@@ -48,9 +50,12 @@ const rules = ref<FormRules>({
name: [{ required: true, message: '请输入标签名称' }],
label: [{ required: true, message: '请选择标签类型' }],
type_id: [{ required: true, message: '请选择标签目录' }],
update_status: [{ required: true, message: '请选择更新评率' }]
update_status: [{ required: true, message: '请选择更新评率' }],
teacher_id: [{ required: true, message: '请选择老师标签' }],
})
const { hasQuestion, teacherTagList } = useQuestion('202')
// 提交
function handleSubmit() {
formRef?.validate().then(() => (isUpdate ? handleUpdate() : handleCreate()))
......@@ -64,7 +69,8 @@ function handleCreate() {
'update_rule',
'status',
'label',
'weight'
'weight',
'teacher_id',
])
createLabel(params).then(() => {
ElMessage({ message: '创建成功', type: 'success' })
......@@ -82,7 +88,8 @@ function handleUpdate() {
'update_rule',
'status',
'label',
'weight'
'weight',
'teacher_id',
])
updateLabel(params).then(() => {
ElMessage({ message: '修改成功', type: 'success' })
......@@ -94,7 +101,7 @@ function handleUpdate() {
<template>
<el-dialog :title="title" :close-on-click-modal="false" width="600px" @closed="$emit('update:modelValue', false)">
<el-form ref="formRef" :model="form" :rules="rules" label-suffix=":" label-width="100px">
<el-form ref="formRef" :model="form" :rules="rules" label-suffix=":" label-width="110px">
<el-form-item label="标签名称" prop="name">
<el-input v-model="form.name" placeholder="请输入" />
</el-form-item>
......@@ -103,6 +110,11 @@ function handleUpdate() {
<el-option v-for="item in labelList" :key="item.value" :label="item.label" :value="item.value"></el-option>
</el-select>
</el-form-item>
<el-form-item label="匹配老师标签" prop="teacher_id" v-if="hasQuestion">
<el-select v-model="form.teacher_id" style="width: 100%">
<el-option v-for="item in teacherTagList" :key="item.id" :label="item.name" :value="item.id"></el-option>
</el-select>
</el-form-item>
<el-form-item label="标签目录" prop="type_id">
<el-select v-model="form.type_id" style="width: 100%">
<el-option v-for="item in typeList" :key="item.id" :label="item.name" :value="item.id"></el-option>
......
......@@ -31,13 +31,13 @@ const statusList = useMapStore().getMapValuesByKey('system_status')
const formRef = $ref<FormInstance>()
const form = reactive({
const form: any = reactive({
id: '',
rules: undefined
rules: undefined,
})
function fetchInfo() {
getLabelRule({ id: props.data.id }).then(res => {
getLabelRule({ id: props.data.id }).then((res) => {
const { detail } = res.data
let rules = detail.rules
......@@ -47,9 +47,9 @@ function fetchInfo() {
event_attr_rule: {
current_logic_operate: 'and',
happen_info: { is_happened: true, event_id: '-1', event_name: '所有事件', attr_list: [] },
trigger_info: { operate: '', operate_name: '', value: '' }
trigger_info: { operate: '', operate_name: '', value: '' },
},
tag_rule: { event_id: '', event_name: '', attr_id: '', attr_name: '', type: 1, value: undefined }
tag_rule: { event_id: '', event_name: '', attr_id: '', attr_name: '', type: 1, value: undefined },
}
}
if (detail.label == '3') {
......@@ -57,9 +57,9 @@ function fetchInfo() {
event_attr_rule: {
current_logic_operate: 'and',
happen_info: { is_happened: true, event_id: '-1', event_name: '所有事件', attr_list: [] },
trigger_info: { operate: '', operate_name: '', value: '' }
trigger_info: { operate: '', operate_name: '', value: '' },
},
tag_rule: { way: '' }
tag_rule: { way: '' },
}
}
if (detail.label === '4') {
......@@ -75,7 +75,7 @@ function fetchInfo() {
rules = {
user_attr_rule: { current_logic_operate: 'and', items: [] },
event_attr_rule: { current_logic_operate: 'and', items: [] },
user_action_rule: { current_logic_operate: 'and', items: [] }
user_action_rule: { current_logic_operate: 'and', items: [] },
}
}
}
......@@ -100,9 +100,15 @@ function handleUpdate() {
// item.value = Array.isArray(item.value) ? item.value.join(',') : item.value
// return item
// })
let rules = form.rules
if (props.data.label == '1' && rules?.length > 0) {
rules = rules.map((item: any, index: number) => {
return { ...item, level: index }
})
}
const params = {
id: form.id,
rules: JSON.stringify(form.rules)
rules: JSON.stringify(rules),
// user_attr_rule: JSON.stringify([{ ...form.user_attr_rule, items: attrRuleItems }]),
// event_attr_rule: JSON.stringify([{ ...form.event_attr_rule, items: eventRuleItems }])
}
......
......@@ -29,6 +29,7 @@ export interface Label {
updated_operator: Operator
label: string
weight: string
teacher_id?: string
}
// 标签更新规则
export interface LabelUpdateRule {
......@@ -38,7 +39,10 @@ export interface LabelUpdateRule {
export type LabelListRequest = Pick<Label, 'id' | 'name'> & { experiment_id?: string }
export type LabelUpdateRequest = Pick<Label, 'id' | 'name' | 'type_id' | 'update_status' | 'update_rule' | 'status' | 'label'> & {
export type LabelUpdateRequest = Pick<
Label,
'id' | 'name' | 'type_id' | 'update_status' | 'update_rule' | 'status' | 'label' | 'teacher_id'
> & {
experiment_id?: string
}
......
......@@ -16,6 +16,8 @@ const userStore = useUserStore()
const LabelFormDialog = defineAsyncComponent(() => import('../components/LabelFormDialog.vue'))
const LabelViewDialog = defineAsyncComponent(() => import('../components/LabelViewDialog.vue'))
const LabelRuleDialog = defineAsyncComponent(() => import('../components/LabelRuleDialog.vue'))
const AISummaryDialog = defineAsyncComponent(() => import('../components/AISummaryDialog.vue'))
const aiDialogVisible = ref(false)
const statusList = useMapStore().getMapValuesByKey('system_status')
const { typeList } = useLabelType()
......@@ -36,7 +38,7 @@ const listOptions = computed(() => {
params.updated_operator = listParams.updated_operator
}
return params
}
},
},
filters: [
{ type: 'input', prop: 'name', placeholder: '请输入标签名称' },
......@@ -46,16 +48,16 @@ const listOptions = computed(() => {
placeholder: '请选择标签目录',
options: typeList.value,
labelKey: 'name',
valueKey: 'id'
valueKey: 'id',
},
{
type: 'select',
prop: 'label',
placeholder: '请选择标签类型',
options: labelList
options: labelList,
},
{ type: 'select', prop: 'status', placeholder: '请选择标签状态', options: statusList },
{ type: 'input', prop: 'updated_operator', placeholder: '更新人', slots: 'filter-user' }
{ type: 'input', prop: 'updated_operator', placeholder: '更新人', slots: 'filter-user' },
],
columns: [
{ type: 'selection' },
......@@ -67,7 +69,7 @@ const listOptions = computed(() => {
prop: 'label',
computed({ row }: { row: Label }) {
return getNameByValue(row.label, labelList) || row.label
}
},
},
{ label: '标签目录', prop: 'tag_type.name' },
{ label: '标签权重', prop: 'weight' },
......@@ -76,7 +78,7 @@ const listOptions = computed(() => {
prop: 'update_status',
computed({ row }: { row: Label }) {
return getNameByValue(row.update_status, updateStatusRuleList)
}
},
},
{
label: '状态',
......@@ -84,18 +86,18 @@ const listOptions = computed(() => {
computed({ row }: { row: Label }) {
const color = row.status === '1' ? 'var(--main-success-color)' : 'var(--main-color)'
return `<span style="color: ${color}">${getNameByValue(row.status, statusList)}</span>`
}
},
},
{
label: '更新人',
prop: 'updated_operator.real_name',
computed({ row }: any) {
return row.updated_operator?.real_name || row.updated_operator?.nickname
}
},
},
{ label: '更新时间', prop: 'updated_time' },
{ label: '操作', slots: 'table-x', width: 320 }
]
{ label: '操作', slots: 'table-x', width: 320 },
],
}
})
// 刷新
......@@ -151,11 +153,11 @@ function handleSelectionChange(selection: Label[]) {
}
const handleRemoves = async function () {
const ids = multipleSelection.map(item => item.id)
const ids = multipleSelection.map((item) => item.id)
await ElMessageBox.confirm('确定要删除选中的标签数据吗?', '提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning'
type: 'warning',
})
await deleteLabels({ ids: JSON.stringify(ids) })
appList?.refetch(true)
......@@ -169,10 +171,23 @@ const handleRemoves = async function () {
<div class="label-left"><LabelType :active-id="listParams.type_id" @select="handleSelect"></LabelType></div>
<AppList v-bind="listOptions" ref="appList" class="label-right" @selection-change="handleSelectionChange">
<template #header-buttons>
<el-button type="primary" :icon="Plus" @click="handleAdd" v-if="!userStore.status.tag_status">新建</el-button>
<el-button type="primary" plain :icon="Delete" :disabled="!multipleSelection.length" @click="handleRemoves" v-permission="'experiment_tag_delete'"
>删除</el-button
>
<div style="display: flex; justify-content: space-between">
<div>
<el-button type="primary" :icon="Plus" @click="handleAdd" v-if="!userStore.status.tag_status"
>新建</el-button
>
<el-button
type="primary"
plain
:icon="Delete"
:disabled="!multipleSelection.length"
@click="handleRemoves"
v-permission="'experiment_tag_delete'"
>删除</el-button
>
</div>
<el-button type="primary" @click="aiDialogVisible = true">AI建议</el-button>
</div>
</template>
<template #filter-user>
<SelectUser v-model="listParams.updated_operator" placeholder="更新人" @change="handleRefresh"></SelectUser>
......@@ -181,18 +196,27 @@ const handleRemoves = async function () {
<template #table-x="{ row }">
<el-button type="primary" plain @click="handleRule(row)">规则</el-button>
<el-button type="primary" plain @click="handleView(row)">查看</el-button>
<el-button type="primary" plain @click="handleUpdate(row)" v-permission="'experiment_tag_update'">编辑</el-button>
<el-button type="primary" plain @click="handleRemove(row)" v-permission="'experiment_tag_delete'">删除</el-button>
<el-button type="primary" plain @click="handleUpdate(row)" v-permission="'experiment_tag_update'"
>编辑</el-button
>
<el-button type="primary" plain @click="handleRemove(row)" v-permission="'experiment_tag_delete'"
>删除</el-button
>
</template>
</AppList>
</div>
</AppCard>
<!-- 新建/修改标签 -->
<LabelFormDialog v-model="formVisible" :data="currentRow" @update="handleRefresh" v-if="formVisible"></LabelFormDialog>
<LabelFormDialog
v-model="formVisible"
:data="currentRow"
@update="handleRefresh"
v-if="formVisible"></LabelFormDialog>
<!-- 查看标签 -->
<LabelViewDialog v-model="viewVisible" :data="currentRow" v-if="viewVisible && currentRow"></LabelViewDialog>
<!-- 规则 -->
<LabelRuleDialog v-model="ruleVisible" :data="currentRow" v-if="ruleVisible && currentRow"></LabelRuleDialog>
<AISummaryDialog v-model="aiDialogVisible" v-if="aiDialogVisible"></AISummaryDialog>
</template>
<style lang="scss">
......
import httpRequest from '@/utils/axios'
// 获取订单列表
export function getOrderList(params?: { live_commodity_type_id?: string; live_commodity_title?: string }) {
return httpRequest.get('/api/lab/v1/experiment/live-practice-order/list', { params })
}
import type { RouteRecordRaw } from 'vue-router'
import Layout from '@/components/layout/Index.vue'
const routes: RouteRecordRaw[] = [
{
path: '/live/order',
component: Layout,
children: [{ path: '', component: () => import('./views/Index.vue') }]
}
]
export { routes }
<script setup>
import LiveProductCategory from '@/components/LiveProductCategory.vue'
import { getOrderList } from '../api'
const appList = ref(null)
// 刷新
const handleRefresh = () => {
appList.value?.refetch()
}
const listParams = reactive({ name: '', live_commodity_type_id: '', live_commodity_title: '' })
// 列表配置
const listOptions = computed(() => {
return {
remote: {
httpRequest: getOrderList,
params: listParams,
beforeRequest(params, isReset) {
if (isReset) listParams.live_commodity_type_id = ''
params.live_commodity_type_id = listParams.live_commodity_type_id
return params
},
},
filters: [
{ label: '直播主题品类', prop: 'live_commodity_type_id', slots: 'filter-category' },
{ label: '直播主题名称', prop: 'live_commodity_title', type: 'input' },
],
columns: [
{ label: '序号', type: 'index', width: 60 },
{ label: '主播', prop: 'updated_operator_name' },
{
label: '商品信息',
prop: 'commodity_info',
computed({ row }) {
try {
const info = JSON.parse(row.commodity_info)
const [picture] = JSON.parse(info.picture_addreses) || []
return `<div style="display: flex; align-items: center; gap: 10px;"><img src="${picture.url}" width="100" />${info.title}</div>`
} catch (e) {
console.log(e)
}
return '--'
},
},
{
label: '单价/数量',
prop: 'num',
computed({ row }) {
return `¥${row.price}<br/>x${row.num}`
},
},
{ label: '订单编号', prop: 'id' },
{
label: '订单状态',
prop: 'status',
computed() {
return '待发货'
},
},
{ label: '下单时间', prop: 'updated_time' },
],
}
})
</script>
<template>
<AppCard title="订单管理">
<AppList v-bind="listOptions" ref="appList">
<template #filter-category>
<LiveProductCategory v-model="listParams.live_commodity_type_id" @change="handleRefresh"></LiveProductCategory>
</template>
</AppList>
</AppCard>
</template>
......@@ -3,19 +3,11 @@ import { useChat } from '@/composables/useChat'
import { getAttrList } from '../api'
const form = inject('form')
const { isLoading, post, messages } = useChat()
watch(
messages,
() => {
const lastMessage = messages.value[messages.value.length - 1]
if (lastMessage) {
form.shopping_guide_short_title = lastMessage.content.replace('推荐导购短标题:', '')
}
},
{ deep: true }
)
const { post } = useChat()
const descLoading = ref(false)
function handleAIGenerate() {
descLoading.value = true
post({
role: 'user',
content: `请根据以下内容,给出1个用于抖音电商使用的推荐的“导购短标题”内容:${form.title}
......@@ -25,6 +17,30 @@ function handleAIGenerate() {
3. 标题要具有吸引力,能够激发消费者的购买欲望。
4. 标题要简洁明了,不要过于冗长或复杂。
5. 输出结果以“推荐导购短标题:”开始`,
}).then((res) => {
form.shopping_guide_short_title = res.content.replace('推荐导购短标题:', '')
descLoading.value = false
})
}
const titleLoading = ref(false)
function handleAIGenerateTitle() {
titleLoading.value = true
post({
role: 'user',
content: `
请根据以下内容,给出1个用于抖音电商使用的推荐的“商品标题”内容:${form.title}
要求:
1. 标题要简洁明了,能够吸引消费者的注意力。
2. 标题要突出商品的特点和优势,如设计、材质、功能等。
3. 标题要具有吸引力,能够激发消费者的购买欲望。
4. 标题要简洁明了,不要过于冗长或复杂。
5. 字数控制在8-30个字(16-60字符)以内
6. 输出结果以“推荐商品标题:”开始
`,
}).then((res) => {
form.title = res.content.replace('推荐商品标题:', '')
titleLoading.value = false
})
}
......@@ -76,7 +92,13 @@ const unimportanceTotal = computed(() => {
<div>
<el-form-item label="商品标题" prop="title" :rules="[{ required: true, message: '请输入', trigger: 'blur' }]">
<div class="form-tips">标题不规范会有可能引起商品下架,影响您的正常销售,请点击学习商品发布规范认真填写</div>
<el-input placeholder="至少输入8个字(16个字符)以上,30个字(60个字符)以下" size="large" v-model="form.title" />
<div style="display: flex; width: 100%; align-items: center; gap: 20px">
<el-input
placeholder="至少输入8个字(16个字符)以上,30个字(60个字符)以下"
size="large"
v-model="form.title" />
<el-button type="primary" plain @click="handleAIGenerateTitle" :loading="titleLoading">一键智能优化</el-button>
</div>
</el-form-item>
<el-form-item label="导购短标题" prop="shopping_guide_short_title">
<div class="form-tips">短标题可用于商品搜索、首页推荐、物流单等场景,请提炼商品关键信息,客观准确填写</div>
......@@ -86,7 +108,7 @@ const unimportanceTotal = computed(() => {
size="large"
v-model="form.shopping_guide_short_title"
style="flex: 1" />
<el-button type="primary" plain @click="handleAIGenerate" :loading="isLoading">一键智能推荐</el-button>
<el-button type="primary" plain @click="handleAIGenerate" :loading="descLoading">一键智能推荐</el-button>
</div>
</el-form-item>
<el-form-item
......
......@@ -61,3 +61,8 @@ export function getRecordList(params: { live_practice_id: string }) {
export function getRecord(params: { id: string }) {
return httpRequest.get('/api/lab/v1/experiment/live-practice/live-practice-record', { params })
}
// 推送字幕
export function pushSubtitle(params: { subtitle: string; selling_point: string; marketing_campaign: string }) {
return httpRequest.get('/api/lab/v1/experiment/live-practice/push-subtitle', { params })
}
<script setup>
import Live from './Live.vue'
import LivePlayback from './LivePlayback.vue'
import { getTest, getRecord, pushSubtitle } from '../api'
import { useCountdown } from '@/composables/useCountdown'
const props = defineProps({
id: { type: String },
recordId: { type: String },
isView: { type: Boolean, default: false },
})
const { timeLeft, formattedTime, stop, reset } = useCountdown({
// 倒计时结束
onEnd: () => {
live.value?.stop()
},
})
const live = ref(null)
const detail = ref(null)
provide('detail', detail)
const duration = computed(() => {
return parseInt(detail.value?.duration) * 60 || 0
})
const isLocalUpload = computed(() => {
return detail.value?.upload_way == 2
})
async function fetchInfo() {
const res = await getTest({ id: props.id })
detail.value = res.data.detail
timeLeft.value = duration.value
}
watchEffect(() => {
!props.isView && fetchInfo()
})
// 商品卖点
const hotList = computed(() => {
return detail.value?.live_speech.selling_point.split(/;|;/)
})
// 营销活动
const actList = computed(() => {
return detail.value?.live_speech.marketing_campaign.split(/;|;/)
})
// 直播记录数据
const record = ref(null)
provide('record', record)
// 直播数据
const stats = computed(() => {
const result = { totalViewers: 0, peakViewers: 0, totalGifts: 0, totalLikes: 0, totalGiftViewers: 0 }
return Object.assign(result, record.value?.live_info.stats)
})
const timelines = ref([])
const fetchRecord = async () => {
const res = await getRecord({ id: props.recordId })
const resDetail = res.data.detail
record.value = { ...resDetail, live_info: JSON.parse(resDetail.live_info) }
detail.value = resDetail.live_practice_info
timeLeft.value = duration.value
timelines.value = JSON.parse(resDetail.subtitle)?.Result?.Sentences || []
}
onMounted(() => {
props.isView && fetchRecord()
})
// 毫秒转分钟 00:00,需要补0
const formatTime = (time) => {
const minutes = Math.floor(time / 1000 / 60)
const seconds = Math.floor((time / 1000) % 60)
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
}
const orderCount = ref(0)
const onSentenceEnd = async (result, data) => {
console.log('onSentenceEnd', result, data)
const res = await pushSubtitle({
subtitle: result,
selling_point: detail.value.live_speech.selling_point,
marketing_campaign: detail.value.live_speech.marketing_campaign,
})
orderCount.value += res.data.count
}
const currentStats = ref({})
const onStatsChange = (stats) => {
currentStats.value = stats
}
</script>
<template>
<div class="live-row">
<div class="live-col">
<template v-if="isView">
<LivePlayback :record="record" v-if="record"></LivePlayback>
</template>
<template v-else>
<Live
ref="live"
:orderCount="orderCount"
:isLocalUpload="isLocalUpload"
:onStart="() => reset(duration)"
:onStop="stop"
:onSentenceEnd="onSentenceEnd"
:onStatsChange="onStatsChange"
v-if="detail" />
</template>
</div>
<div class="live-col" style="flex: 1" v-if="isView">
<el-timeline style="max-width: 600px">
<el-timeline-item
placement="top"
v-for="(item, index) in timelines"
:key="index"
:timestamp="formatTime(item.BeginTime)">
{{ item.Text }}
</el-timeline-item>
</el-timeline>
</div>
<div class="live-col" style="flex: 1">
<h2 class="h2-title">直播话术</h2>
<div class="live-talk-content" v-html="detail?.live_speech.content"></div>
</div>
<div class="live-col" style="width: 350px" v-if="!isView">
<div class="live-col-box" v-if="isView">
<h2 class="h2-title">直播数据</h2>
<div class="live-data">
<dl>
<dt>观众总人数:</dt>
<dd>{{ stats.totalViewers }}</dd>
</dl>
<dl>
<dt>最高峰人数:</dt>
<dd>{{ stats.peakViewers }}</dd>
</dl>
<dl>
<dt>点赞数:</dt>
<dd>{{ stats.totalLikes }}</dd>
</dl>
<dl>
<dt>刷礼物人数:</dt>
<dd>{{ stats.totalGiftViewers }}</dd>
</dl>
<dl>
<dt>刷礼物总数:</dt>
<dd>{{ stats.totalGifts }}</dd>
</dl>
</div>
</div>
<div class="live-col-box" v-else>
<h2 class="h2-title">倒计时</h2>
<h3 class="live-time">{{ formattedTime }}</h3>
<div class="live-data2">
<dl>
<dt>观众人数</dt>
<dd>{{ currentStats.viewers }}</dd>
</dl>
<dl>
<dt>订单量</dt>
<dd>{{ orderCount }}</dd>
</dl>
</div>
</div>
<div class="live-col-box">
<h2 class="h2-title">主题卖点</h2>
<ul class="live-tag live-tag__hot">
<li v-for="item in hotList" :key="item">{{ item }}</li>
</ul>
</div>
<div class="live-col-box">
<h2 class="h2-title">营销活动</h2>
<ul class="live-tag live-tag__act">
<li v-for="item in actList" :key="item">{{ item }}</li>
</ul>
</div>
</div>
</div>
</template>
<style lang="scss">
.live-row {
height: 100%;
display: flex;
gap: 20px;
}
.live-col {
padding: 20px;
border-radius: 10px;
border: 1px solid #eee;
max-height: 100%;
overflow-y: auto;
.h2-title {
margin-top: 0;
}
}
.live-tag {
margin: 20px 0;
background-color: #eee;
border-radius: 10px;
padding: 20px;
gap: 10px;
li {
line-height: 30px;
background-color: #fff;
text-align: center;
border: 1px solid rgba(105, 113, 140, 0.12);
}
}
.live-data {
padding: 0 0 20px 10px;
dl {
margin: 10px 0;
display: flex;
}
dt {
font-weight: bold;
}
}
.live-data2 {
display: flex;
gap: 10px;
text-align: center;
dl {
flex: 1;
text-align: center;
margin-bottom: 40px;
}
dt {
font-size: 20px;
font-weight: bold;
}
dd {
font-size: 42px;
text-align: center;
color: var(--main-color);
}
}
.live-time {
height: 140px;
font-size: 72px;
text-align: center;
color: var(--main-color);
}
.live-tag__hot {
display: flex;
flex-wrap: wrap;
li {
flex: 0 0 calc(50% - 10px);
}
}
.live-tag__act {
li {
margin: 10px 0;
}
}
.live-talk-content {
max-height: 660px;
overflow-y: auto;
line-height: 24px;
}
</style>
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论