提交 1e61b62c authored 作者: 王鹏飞's avatar 王鹏飞

feat: 新增实验案例

上级 28f1a458
...@@ -9,6 +9,7 @@ ...@@ -9,6 +9,7 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"@fortaine/fetch-event-source": "^3.0.6",
"@microsoft/fetch-event-source": "^2.0.1", "@microsoft/fetch-event-source": "^2.0.1",
"@tinymce/tinymce-vue": "^5.0.0", "@tinymce/tinymce-vue": "^5.0.0",
"@vant/area-data": "^1.5.1", "@vant/area-data": "^1.5.1",
...@@ -17,7 +18,7 @@ ...@@ -17,7 +18,7 @@
"@vueuse/integrations": "^9.13.0", "@vueuse/integrations": "^9.13.0",
"@vueuse/math": "^9.13.0", "@vueuse/math": "^9.13.0",
"ali-oss": "^6.17.1", "ali-oss": "^6.17.1",
"axios": "^1.3.3", "axios": "^1.10.0",
"blueimp-md5": "^2.19.0", "blueimp-md5": "^2.19.0",
"countup.js": "^2.6.2", "countup.js": "^2.6.2",
"dayjs": "^1.11.7", "dayjs": "^1.11.7",
...@@ -1101,6 +1102,15 @@ ...@@ -1101,6 +1102,15 @@
"@floating-ui/core": "^1.0.1" "@floating-ui/core": "^1.0.1"
} }
}, },
"node_modules/@fortaine/fetch-event-source": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@fortaine/fetch-event-source/-/fetch-event-source-3.0.6.tgz",
"integrity": "sha512-621GAuLMvKtyZQ3IA6nlDWhV1V/7PGOTNIGLUifxt0KzM+dZIweJ6F3XvQF3QnqeNfS1N7WQ0Kil1Di/lhChEw==",
"license": "MIT",
"engines": {
"node": ">=16.15"
}
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
...@@ -2970,9 +2980,9 @@ ...@@ -2970,9 +2980,9 @@
} }
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.7.7", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
"integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.6", "follow-redirects": "^1.15.6",
...@@ -8139,6 +8149,11 @@ ...@@ -8139,6 +8149,11 @@
"@floating-ui/core": "^1.0.1" "@floating-ui/core": "^1.0.1"
} }
}, },
"@fortaine/fetch-event-source": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@fortaine/fetch-event-source/-/fetch-event-source-3.0.6.tgz",
"integrity": "sha512-621GAuLMvKtyZQ3IA6nlDWhV1V/7PGOTNIGLUifxt0KzM+dZIweJ6F3XvQF3QnqeNfS1N7WQ0Kil1Di/lhChEw=="
},
"@humanfs/core": { "@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
...@@ -9402,9 +9417,9 @@ ...@@ -9402,9 +9417,9 @@
"integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==" "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg=="
}, },
"axios": { "axios": {
"version": "1.7.7", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
"integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
"requires": { "requires": {
"follow-redirects": "^1.15.6", "follow-redirects": "^1.15.6",
"form-data": "^4.0.0", "form-data": "^4.0.0",
......
...@@ -15,6 +15,7 @@ ...@@ -15,6 +15,7 @@
}, },
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"@fortaine/fetch-event-source": "^3.0.6",
"@microsoft/fetch-event-source": "^2.0.1", "@microsoft/fetch-event-source": "^2.0.1",
"@tinymce/tinymce-vue": "^5.0.0", "@tinymce/tinymce-vue": "^5.0.0",
"@vant/area-data": "^1.5.1", "@vant/area-data": "^1.5.1",
...@@ -23,7 +24,7 @@ ...@@ -23,7 +24,7 @@
"@vueuse/integrations": "^9.13.0", "@vueuse/integrations": "^9.13.0",
"@vueuse/math": "^9.13.0", "@vueuse/math": "^9.13.0",
"ali-oss": "^6.17.1", "ali-oss": "^6.17.1",
"axios": "^1.3.3", "axios": "^1.10.0",
"blueimp-md5": "^2.19.0", "blueimp-md5": "^2.19.0",
"countup.js": "^2.6.2", "countup.js": "^2.6.2",
"dayjs": "^1.11.7", "dayjs": "^1.11.7",
......
...@@ -3,6 +3,7 @@ interface Props { ...@@ -3,6 +3,7 @@ interface Props {
title?: string title?: string
hasCardBackground?: boolean hasCardBackground?: boolean
hasBodyBackground?: boolean hasBodyBackground?: boolean
loading?: boolean
} }
withDefaults(defineProps<Props>(), { hasBodyBackground: true }) withDefaults(defineProps<Props>(), { hasBodyBackground: true })
</script> </script>
...@@ -17,7 +18,7 @@ withDefaults(defineProps<Props>(), { hasBodyBackground: true }) ...@@ -17,7 +18,7 @@ withDefaults(defineProps<Props>(), { hasBodyBackground: true })
</div> </div>
</slot> </slot>
</div> </div>
<div class="app-card-bd" :class="{ 'has-background': hasBodyBackground }"> <div class="app-card-bd" :class="{ 'has-background': hasBodyBackground }" v-loading="loading">
<slot></slot> <slot></slot>
</div> </div>
</div> </div>
......
...@@ -2,12 +2,13 @@ ...@@ -2,12 +2,13 @@
import Editor from '@tinymce/tinymce-vue' import Editor from '@tinymce/tinymce-vue'
import md5 from 'blueimp-md5' import md5 from 'blueimp-md5'
import { getSignature, uploadFile } from '@/api/base' import { getSignature, uploadFile } from '@/api/base'
import { ArrowDown } from '@element-plus/icons-vue'
import { useAI } from '@/composables/useAI'
import { ElMessage } from 'element-plus'
const props = defineProps({ const props = defineProps({
height: { height: { type: Number, default: 400 },
type: Number, hasAI: { type: Boolean, default: false },
default: 400
}
}) })
const ImageUploadHandler = (blobInfo: any) => const ImageUploadHandler = (blobInfo: any) =>
...@@ -26,7 +27,7 @@ const ImageUploadHandler = (blobInfo: any) => ...@@ -26,7 +27,7 @@ const ImageUploadHandler = (blobInfo: any) =>
signature, signature,
success_action_status: '200', success_action_status: '200',
file, file,
url: `${host}/${key}` url: `${host}/${key}`,
} }
uploadFile(params) uploadFile(params)
.then((res: any) => { .then((res: any) => {
...@@ -41,6 +42,8 @@ const ImageUploadHandler = (blobInfo: any) => ...@@ -41,6 +42,8 @@ const ImageUploadHandler = (blobInfo: any) =>
}) })
}) })
const editorRef = ref()
const init = { const init = {
language: 'zh-Hans', language: 'zh-Hans',
height: props.height, height: props.height,
...@@ -57,16 +60,96 @@ const init = { ...@@ -57,16 +60,96 @@ const init = {
automatic_uploads: true, automatic_uploads: true,
quickbars_insert_toolbar: false, quickbars_insert_toolbar: false,
// style_formats: [{ title: '悬挂缩进', block: 'p', styles: { textIndent: '-2em', paddingLeft: '2em' } }], // style_formats: [{ title: '悬挂缩进', block: 'p', styles: { textIndent: '-2em', paddingLeft: '2em' } }],
content_style: 'img {max-width:100%;}' content_style: 'img {max-width:100%;}',
setup: (editor: any) => {
editorRef.value = editor
},
}
const aiDialogVisible = ref(false)
const form = reactive({
function: '润色',
tone: '口语化',
})
const { post, isLoading } = useAI()
const handleAI = async () => {
const text = editorRef.value.getContent({ format: 'text' })
if (!text.trim()) {
ElMessage.warning('请先输入一些内容')
return
}
// 根据功能生成对应的prompt
const functionMap: any = {
改写: `改写以上内容`,
扩写: `扩写以上内容`,
缩写: `缩写以上内容`,
润色: `润色以上内容`,
}
const toneMap: any = {
更正式: '请帮我使用正式、专业的语言风格',
更活泼: '请帮我使用活泼、生动的语言风格',
党政风: '请帮我使用党政机关公文风格',
口语化: '请帮我使用口语化、通俗易懂的表达方式',
}
const prompt = `${text}\n\n${toneMap[form.tone]}${
functionMap[form.function]
}\n请仅返回优化后的内容,不要添加任何解释或前缀。/no_think`
await post(
{ prompt },
{
onUpdate: (content) => {
editorRef.value.setContent(content)
},
}
)
} }
</script> </script>
<template> <template>
<editor :init="init" v-bind="$attrs" style="width: 100%" /> <div class="app-editor" style="width: 100%">
<editor :init="init" v-bind="$attrs" style="width: 100%" />
<div class="app-editor-ai" v-if="hasAI">
<el-button-group size="small">
<el-button type="primary" @click="handleAI" :loading="isLoading">AI{{ form.function }}</el-button>
<el-button type="primary" :icon="ArrowDown" @click="aiDialogVisible = true"></el-button>
</el-button-group>
</div>
<el-dialog title="AI" v-model="aiDialogVisible" width="500px">
<el-form label-position="top">
<el-form-item label="您需要AI辅助的功能是:">
<el-radio-group v-model="form.function">
<el-radio-button label="改写" value="改写" />
<el-radio-button label="扩写" value="扩写" />
<el-radio-button label="缩写" value="缩写" />
<el-radio-button label="润色" value="润色" />
</el-radio-group>
</el-form-item>
<el-form-item label="您需要的文本语气是:">
<el-radio-group v-model="form.tone">
<el-radio-button label="更正式" value="更正式" />
<el-radio-button label="更活泼" value="更活泼" />
<el-radio-button label="党政风" value="党政风" />
<el-radio-button label="口语化" value="口语化" />
</el-radio-group>
</el-form-item>
</el-form>
</el-dialog>
</div>
</template> </template>
<style lang="scss"> <style lang="scss">
.tox-tinymce-aux { .tox-tinymce-aux {
z-index: 3000 !important; z-index: 3000 !important;
} }
.app-editor-ai {
text-align: right;
margin-top: 10px;
}
</style> </style>
...@@ -80,7 +80,7 @@ function handleClick(path: string) { ...@@ -80,7 +80,7 @@ function handleClick(path: string) {
v-permission="item.tag" v-permission="item.tag"
v-if="item.children"> v-if="item.children">
<template #title> <template #title>
{{ item.name }} <router-link :to="item.path">{{ item.name }}</router-link>
</template> </template>
<el-menu-item <el-menu-item
:index="subitem.path" :index="subitem.path"
...@@ -88,11 +88,11 @@ function handleClick(path: string) { ...@@ -88,11 +88,11 @@ function handleClick(path: string) {
:key="subitem.path" :key="subitem.path"
v-permission="subitem.tag" v-permission="subitem.tag"
@click="handleClick(subitem.path)"> @click="handleClick(subitem.path)">
{{ subitem.name }} <router-link :to="subitem.path">{{ subitem.name }}</router-link>
</el-menu-item> </el-menu-item>
</el-sub-menu> </el-sub-menu>
<el-menu-item :index="item.path" v-permission="item.tag" @click="handleClick(item.path)" v-else> <el-menu-item :index="item.path" v-permission="item.tag" @click="handleClick(item.path)" v-else>
{{ item.name }} <router-link :to="item.path">{{ item.name }}</router-link>
</el-menu-item> </el-menu-item>
</template> </template>
</el-menu> </el-menu>
......
import { fetchEventSource } from '@fortaine/fetch-event-source'
interface AIMessage {
id: string
role: string
content: string
}
interface AIRequestOptions {
onUpdate?: (content: string, message?: AIMessage) => void
isReplace?: boolean
}
interface AIRequestData {
model?: string
prompt?: string
messages?: AIMessage[]
}
export function useAI() {
const messages = ref<AIMessage[]>([])
const isLoading = ref(false)
function post(data: AIRequestData, options: AIRequestOptions = {}) {
const { onUpdate, isReplace = true } = options
const { model = 'qwen-long', prompt, messages: messagesData, ...rest } = data
const params = { model, messages: messagesData || [{ role: 'user', content: prompt }], ...rest }
isLoading.value = true
return new Promise((resolve, reject) => {
let content = ''
fetchEventSource('/api/lab/v1/experiment/qwen/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
async onopen(response) {
if (response.ok) {
return
} else {
isLoading.value = false
reject(response)
}
},
onmessage(res) {
console.log(res.data)
if (res.data === '[DONE]') {
isLoading.value = false
resolve(content)
return
}
try {
const message = JSON.parse(res.data)
const id = message.id
const messageIndex = messages.value.findIndex((session) => session.id === id)
content += message?.choices[0]?.delta.content || ''
content = content.replace(/<think>[\s\S]*?<\/think>/g, '')
if (isReplace) {
content = content.replaceAll('\n', '<br/>')
}
if (messageIndex === -1) {
messages.value.push({ id, role: 'assistant', content })
} else {
messages.value[messageIndex].content = content
}
if (onUpdate) {
onUpdate(content, messages.value.at(-1))
}
} catch (error) {
console.log(error)
}
},
onerror(err) {
isLoading.value = false
reject(err)
},
})
})
}
return { messages, post, isLoading }
}
const appConfigList = [ const appConfigList = [
{
system: 'dml',
title: '数智营销实践教学平台',
logo: 'https://webapp-pub.ezijing.com/website/base/logo.svg',
hosts: ['saas-dml-web'],
dmlURL: import.meta.env.VITE_DML_PRO_URL,
},
{ {
system: 'default', system: 'default',
title: '商业数据分析实验室', title: '商业数据分析实验室',
...@@ -37,13 +44,6 @@ const appConfigList = [ ...@@ -37,13 +44,6 @@ const appConfigList = [
title: '商业数据分析竞赛平台', title: '商业数据分析竞赛平台',
hosts: ['saas-game'], hosts: ['saas-game'],
}, },
{
system: 'dml',
title: '数智营销实践教学平台',
// logo: 'https://webapp-pub.ezijing.com/website/base/logo.svg',
hosts: ['saas-dml-web'],
dmlURL: import.meta.env.VITE_DML_PRO_URL,
},
{ {
system: 'swsjfxs', system: 'swsjfxs',
title: '商务数据分析师', title: '商务数据分析师',
......
import httpRequest from '@/utils/axios'
import type { CaseCreateItem } from './types'
// 获取实验列表
export function getExperimentList(params: { course_id?: string }) {
return httpRequest.get('/api/lab/v1/teacher/cases/experiments', { params })
}
// 获取案例列表
export function getCaseList(params?: {
name?: string
type?: string
experiment_name?: string
page?: number
page_size?: number
}) {
return httpRequest.get('/api/lab/v1/teacher/cases/list', { params })
}
// 获取案例详情
export function getCase(params: { id: string }) {
return httpRequest.get('/api/lab/v1/teacher/cases/view', { params })
}
// 创建案例
export function createCase(data: CaseCreateItem) {
return httpRequest.post('/api/lab/v1/teacher/cases/create', data)
}
// 更新案例
export function updateCase(data: CaseCreateItem) {
return httpRequest.post('/api/lab/v1/teacher/cases/update', data)
}
// 删除案例
export function deleteCase(params: { id: string }) {
return httpRequest.post('/api/lab/v1/teacher/cases/delete', params)
}
// 关联实验、删除实验
export function bindExperiment(params: { case_id: string; experiment_id: string; type: 'add' | 'delete' }) {
return httpRequest.post('/api/lab/v1/teacher/cases/experiments', params)
}
<script setup lang="ts">
import type { CaseStep } from '../types'
import { Edit, Delete, Plus } from '@element-plus/icons-vue'
import AppEditor from '@/components/base/AppEditor.vue'
import { dmlMenus } from '@/utils/dmlMenus'
// 步骤类型枚举
enum StepType {
ORIGIN = 1, // 案例原文
TASK = 2, // 任务
SUMMARY = 3, // 案例总结
}
interface Props {
modelValue: CaseStep[]
}
interface Emits {
(e: 'update:modelValue', value: CaseStep[]): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// 编辑相关状态
const editOriginDialogVisible = ref(false)
const editTaskDialogVisible = ref(false)
const editingStep = ref<CaseStep | null>(null)
// 操作步骤相关状态
const selectedStep = ref<number>(1)
const hasOperationSteps = ref<boolean>(false)
const operationSteps = ref<
Array<{ id: number; content: string; relatedFunction?: { id: number; name: string; path: string } }>
>([])
// 计算属性:确保始终有固定的首尾节点
const steps = computed({
get: () => {
const currentSteps = [...props.modelValue]
// 确保有案例原文节点
if (!currentSteps.find((s) => s.type === StepType.ORIGIN)) {
currentSteps.unshift({
id: 'origin',
type: StepType.ORIGIN,
name: '案例原文',
content: '',
})
}
// 确保有案例总结节点
if (!currentSteps.find((s) => s.type === StepType.SUMMARY)) {
currentSteps.push({
id: 'summary',
type: StepType.SUMMARY,
name: '案例总结',
})
}
return currentSteps
},
set: (value) => {
emit('update:modelValue', value)
},
})
// 计算属性:当前选中步骤的内容
const currentStepContent = computed({
get: () => {
const step = operationSteps.value.find((s) => s.id === selectedStep.value)
return step?.content || ''
},
set: (value: string) => {
const stepIndex = operationSteps.value.findIndex((s) => s.id === selectedStep.value)
if (stepIndex !== -1) {
operationSteps.value[stepIndex].content = value
}
},
})
// 计算属性:当前选中步骤的关联功能ID
const currentStepRelatedFunctionId = computed({
get: () => {
const step = operationSteps.value.find((s) => s.id === selectedStep.value)
return step?.relatedFunction?.id || null
},
set: (value: number | null) => {
const stepIndex = operationSteps.value.findIndex((s) => s.id === selectedStep.value)
if (stepIndex !== -1) {
if (value) {
// 根据ID查找对应的功能信息
const findFunction = (menus: any[]): any => {
for (const menu of menus) {
if (menu.id === value) {
return { id: menu.id, name: menu.name, path: menu.path }
}
if (menu.children) {
const found = findFunction(menu.children)
if (found) return found
}
}
return null
}
const functionInfo = findFunction(dmlMenus)
if (functionInfo) {
operationSteps.value[stepIndex].relatedFunction = functionInfo
}
} else {
delete operationSteps.value[stepIndex].relatedFunction
}
}
},
})
// 编辑步骤
function editStep(step: CaseStep) {
editingStep.value = { ...step }
if (step.type === StepType.ORIGIN) {
editOriginDialogVisible.value = true
} else if (step.type === StepType.TASK) {
// 恢复任务的操作步骤数据
if (step.config?.operationSteps) {
operationSteps.value = [...step.config.operationSteps]
hasOperationSteps.value = operationSteps.value.length > 0
selectedStep.value = operationSteps.value.length > 0 ? 1 : 1
} else {
// 重置操作步骤状态
operationSteps.value = []
hasOperationSteps.value = false
selectedStep.value = 1
}
editTaskDialogVisible.value = true
}
}
// 保存编辑
function saveEdit() {
if (editingStep.value) {
const index = steps.value.findIndex((s) => s.id === editingStep.value!.id)
if (index !== -1) {
const newSteps = [...steps.value]
const updatedStep = { ...editingStep.value }
// 如果是任务,保存操作步骤内容
if (editingStep.value.type === StepType.TASK && hasOperationSteps.value) {
updatedStep.config = {
operationSteps: operationSteps.value,
}
}
newSteps[index] = updatedStep
steps.value = newSteps
}
}
editOriginDialogVisible.value = false
editTaskDialogVisible.value = false
editingStep.value = null
}
// 添加任务
function addTask(afterIndex: number) {
const newTask: CaseStep = {
id: `task_${Date.now()}`,
type: StepType.TASK,
name: `任务${steps.value.filter((s) => s.type === StepType.TASK).length + 1}`,
}
const newSteps = [...steps.value]
newSteps.splice(afterIndex + 1, 0, newTask)
steps.value = newSteps
}
// 删除任务
function deleteTask(stepId: string) {
const index = steps.value.findIndex((s) => s.id === stepId)
if (index !== -1 && steps.value[index].type === StepType.TASK) {
const newSteps = [...steps.value]
newSteps.splice(index, 1)
steps.value = newSteps
}
}
// 添加操作步骤
function addOperationStep() {
if (!hasOperationSteps.value) {
hasOperationSteps.value = true
}
const newStepId = operationSteps.value.length + 1
operationSteps.value.push({
id: newStepId,
content: '',
relatedFunction: undefined,
})
selectedStep.value = newStepId
}
// 删除操作步骤
function deleteStep(stepId: number) {
// 删除指定步骤
const stepIndex = operationSteps.value.findIndex((s) => s.id === stepId)
if (stepIndex !== -1) {
operationSteps.value.splice(stepIndex, 1)
// 重新整理步骤编号
operationSteps.value.forEach((step, index) => {
step.id = index + 1
})
// 调整选中状态
if (operationSteps.value.length === 0) {
// 如果没有步骤了,重置状态
hasOperationSteps.value = false
selectedStep.value = 1
} else if (selectedStep.value >= stepId) {
selectedStep.value = Math.max(1, selectedStep.value - 1)
}
}
}
</script>
<template>
<div class="case-steps">
<div class="steps-container">
<template v-for="(step, index) in steps" :key="step.id">
<!-- 步骤节点 -->
<div
class="step-node"
:class="{
origin: step.type === StepType.ORIGIN,
task: step.type === StepType.TASK,
summary: step.type === StepType.SUMMARY,
}">
<span class="step-name">{{ step.name }}</span>
<!-- 遮罩层 -->
<div class="node-overlay">
<!-- 编辑按钮 -->
<el-button
v-if="step.type === StepType.ORIGIN || step.type === StepType.TASK"
size="small"
class="action-btn edit-btn"
type="primary"
circle
:icon="Edit"
@click="editStep(step)">
</el-button>
<!-- 删除按钮(仅任务节点) -->
<el-button
v-if="step.type === StepType.TASK"
size="small"
class="action-btn delete-btn"
@click="deleteTask(step.id)"
type="danger"
:icon="Delete"
circle>
</el-button>
</div>
</div>
<!-- 连接线和添加按钮 -->
<div v-if="index < steps.length - 1" class="step-line-container">
<div class="step-line"></div>
<el-button
v-if="step.type === StepType.TASK || step.type === StepType.ORIGIN"
class="add-task-btn"
size="small"
:icon="Plus"
@click="addTask(index)"></el-button>
</div>
</template>
</div>
</div>
<!-- 编辑案例原文弹窗 -->
<el-dialog v-model="editOriginDialogVisible" title="编辑案例原文" width="800px">
<el-form v-if="editingStep && editingStep.type === StepType.ORIGIN" :model="editingStep" label-position="top">
<el-form-item label="名称">
<el-input v-model="editingStep.name" />
</el-form-item>
<el-form-item label="内容">
<AppEditor v-model="editingStep.content" placeholder="请输入案例原文内容" hasAI />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editOriginDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveEdit">保存</el-button>
</template>
</el-dialog>
<!-- 编辑任务弹窗 -->
<el-dialog v-model="editTaskDialogVisible" title="编辑任务" width="800px">
<el-form v-if="editingStep && editingStep.type === StepType.TASK" :model="editingStep" label-position="top">
<el-form-item label="任务名称">
<el-input v-model="editingStep.name" placeholder="请输入任务名称" />
</el-form-item>
<el-form-item label="任务描述">
<AppEditor v-model="editingStep.content" placeholder="请输入任务描述" hasAI />
</el-form-item>
<!-- 操作步骤 -->
<el-form-item label="操作步骤">
<div class="steps-selector">
<div class="steps-row">
<div
v-for="step in operationSteps"
:key="step.id"
class="step-circle"
:class="{ active: selectedStep === step.id }"
@click="selectedStep = step.id">
{{ step.id }}
<el-button size="small" type="danger" class="delete-step-btn" circle @click.stop="deleteStep(step.id)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
<el-button type="primary" @click="addOperationStep" class="add-step-btn"> 添加操作步骤 </el-button>
</div>
</div>
</el-form-item>
<!-- 步骤内容编辑器 -->
<el-form-item v-if="hasOperationSteps && selectedStep" :label="`步骤${selectedStep}:`">
<el-tree-select
v-model="currentStepRelatedFunctionId"
placeholder="请选择关联功能"
:render-after-expand="false"
:data="dmlMenus"
node-key="id"
:props="{ label: 'name' }"
clearable
style="width: 100%; margin-bottom: 10px" />
<AppEditor v-model="currentStepContent" placeholder="在这里开始编辑您的富媒体内容..." hasAI />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editTaskDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveEdit">保存</el-button>
</template>
</el-dialog>
</template>
<style lang="scss" scoped>
.case-steps {
margin: 20px 0;
width: 100%;
min-width: 0; /* 确保flex子元素可以收缩 */
.steps-container {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
overflow-x: auto;
overflow-y: hidden;
padding: 20px 0;
/* 自定义滚动条样式 */
&::-webkit-scrollbar {
height: 8px;
}
&::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
&::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
&:hover {
background: #a8a8a8;
}
}
}
.step-node {
position: relative;
width: 120px;
height: 120px;
border: 2px solid #dcdfe6;
border-radius: 50%;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
cursor: pointer;
transition: all 0.3s;
flex-shrink: 0; /* 防止圆形被压缩 */
&.origin {
border-color: #409eff;
background: #f0f6ff;
&:hover {
box-shadow: 0 0 10px rgba(64, 158, 255, 0.3);
.node-overlay {
opacity: 1;
visibility: visible;
}
}
}
&.task {
border-color: #e6a23c;
background: #fdf6ec;
&:hover {
box-shadow: 0 0 10px rgba(230, 162, 60, 0.3);
.node-overlay {
opacity: 1;
visibility: visible;
}
}
}
&.summary {
border-color: #67c23a;
background: #f6ffed;
&:hover {
box-shadow: 0 0 10px rgba(103, 194, 58, 0.3);
.node-overlay {
opacity: 1;
visibility: visible;
}
}
}
.step-name {
font-size: 14px;
font-weight: 500;
text-align: center;
line-height: 1.2;
z-index: 2;
position: relative;
}
.node-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
z-index: 3;
}
.action-btn {
width: 32px;
height: 32px;
padding: 0;
font-size: 14px;
border: 2px solid #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
&:hover {
transform: scale(1.1);
}
}
}
.step-line-container {
position: relative;
display: flex;
align-items: center;
cursor: pointer;
padding: 10px 0;
flex-shrink: 0; /* 防止连接线被压缩 */
.step-line {
width: 80px;
height: 2px;
background: var(--main-color);
position: relative;
transition: all 0.3s;
&::after {
content: '';
position: absolute;
right: -5px;
top: -3px;
width: 0;
height: 0;
border-left: 8px solid var(--main-color);
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
}
}
&:hover .step-line {
background: #ff7875;
box-shadow: 0 0 8px rgba(245, 108, 108, 0.4);
}
.add-task-btn {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 10;
opacity: 0;
visibility: hidden;
}
&:hover .add-task-btn {
opacity: 1;
visibility: visible;
}
}
}
/* 操作步骤样式 */
.no-steps {
display: flex;
justify-content: center;
padding: 20px 0;
}
.steps-selector {
.steps-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.step-circle {
position: relative;
width: 32px;
height: 32px;
border: 2px solid var(--main-color);
border-radius: 50%;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
&:hover {
background: #fef0f0;
.delete-step-btn {
opacity: 1;
visibility: visible;
}
}
&.active {
background: var(--main-color);
color: #fff;
border-color: var(--main-color);
box-shadow: 0 0 0 2px rgba(245, 108, 108, 0.2);
}
.delete-step-btn {
position: absolute;
top: -8px;
right: -8px;
width: 16px;
height: 16px;
padding: 0;
font-size: 8px;
opacity: 0;
visibility: hidden;
transition: all 0.2s;
z-index: 10;
}
}
}
/* 步骤编辑器样式 */
.step-editor-header {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
</style>
<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus'
import type { CaseItem, CaseFormData, CaseStep } from '../types'
import { useMapStore } from '@/stores/map'
import CaseSteps from './CaseSteps.vue'
import { pick } from 'lodash-es'
interface Props {
data?: CaseItem
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'submit', data: any): void
}>()
const router = useRouter()
// 实验类型
const types = useMapStore().getMapValuesByKey('experiment_type')
const formRef = $ref<FormInstance>()
const form = reactive<CaseFormData>({
name: '',
times: '',
type: '',
content: '',
})
// 步骤数据
const steps = ref<CaseStep[]>([{ id: 'task1', type: 2, name: '任务1' }])
watch(
() => props.data,
(value) => {
if (value) {
Object.assign(form, value)
try {
steps.value = JSON.parse(value.content)
} catch (error) {
steps.value = []
console.error(error)
}
}
}
)
const rules = ref<FormRules>({
name: [{ required: true, message: '请输入案例名称', trigger: 'blur' }],
times: [{ required: true, message: '请输入案例课时数', trigger: 'blur' }],
type: [{ required: true, message: '请选择案例所属实验类型', trigger: 'change' }],
})
// 提交
function handleSubmit() {
formRef?.validate().then(() => {
const submitData = { ...pick(form, ['name', 'times', 'type']), content: JSON.stringify(steps.value) }
emit('submit', submitData)
})
}
</script>
<template>
<h4>基础信息</h4>
<el-form ref="formRef" :model="form" :rules="rules" inline>
<el-form-item label="案例名称" prop="name">
<el-input v-model="form.name"></el-input>
</el-form-item>
<el-form-item label="案例课时数" prop="times">
<el-input v-model="form.times"></el-input>
</el-form-item>
<el-form-item label="案例所属实验类型" prop="type">
<el-select v-model="form.type">
<el-option v-for="item in types" :key="item.id" v-bind="item"></el-option>
</el-select>
</el-form-item>
</el-form>
<el-divider />
<h4>案例步骤</h4>
<!-- 步骤流程图 -->
<CaseSteps v-model="steps" />
<el-row justify="center">
<el-button type="primary" round auto-insert-space @click="handleSubmit">保存</el-button>
<el-button round auto-insert-space @click="router.back()">取消</el-button>
</el-row>
</template>
<script setup lang="ts">
import { getExperimentList } from '../api'
defineEmits<{
(e: 'bind', id: string): void
}>()
// 列表配置
const listOptions = computed(() => {
return {
remote: {
httpRequest: getExperimentList,
callback(res: any) {
return { list: res }
},
},
columns: [
{ label: '序号', type: 'index', width: 60 },
{ label: '实验名称', prop: 'name' },
{ label: '操作', slots: 'table-x', width: 180 },
],
}
})
</script>
<template>
<el-dialog title="选择实验" width="600px">
<AppList v-bind="listOptions" ref="appList">
<template #table-x="{ row }">
<el-button text type="primary"
><router-link :to="`/admin/lab/experiment/${row.id}`" target="_blank">查看</router-link></el-button
>
<el-button text type="primary" @click="$emit('bind', row.id)">关联</el-button>
</template>
</AppList>
</el-dialog>
</template>
<script setup lang="ts">
import type { ExperimentItem } from '../types'
import { CirclePlus } from '@element-plus/icons-vue'
import AppList from '@/components/base/AppList.vue'
import { bindExperiment } from '../api'
import { ElMessage, ElMessageBox } from 'element-plus'
const SelectExperiment = defineAsyncComponent(() => import('./SelectExperiment.vue'))
const props = defineProps<{
id: string
experiments?: ExperimentItem[]
}>()
const emit = defineEmits<{
(e: 'update'): void
}>()
const appList = ref<InstanceType<typeof AppList> | null>(null)
// 列表配置
const listOptions = computed(() => {
return {
data: props.experiments,
columns: [
{ label: '序号', type: 'index', width: 60 },
{ label: '实验名称', prop: 'name' },
{ label: '实验类型', prop: 'type_name' },
{ label: '更新人', prop: 'updated_operator_name' },
{ label: '更新时间', prop: 'updated_time' },
{ label: '操作', slots: 'table-x', width: 180 },
],
}
})
const dialogVisible = ref(false)
const handleAdd = () => {
dialogVisible.value = true
}
const handleRemove = (row: ExperimentItem) => {
ElMessageBox.confirm('确定要删除吗?', '提示').then(() => {
bindExperiment({ case_id: props.id, experiment_id: row.id, type: 'delete' }).then(() => {
ElMessage({ message: '删除成功', type: 'success' })
emit('update')
})
})
}
const handleBind = (id: string) => {
bindExperiment({ case_id: props.id, experiment_id: id, type: 'add' }).then(() => {
ElMessage({ message: '关联成功', type: 'success' })
emit('update')
dialogVisible.value = false
})
}
</script>
<template>
<AppList v-bind="listOptions" ref="appList">
<template #header-buttons>
<el-button type="primary" :icon="CirclePlus" @click="handleAdd">关联实验</el-button>
</template>
<template #table-x="{ row }">
<el-button text type="primary"
><router-link :to="`/admin/lab/experiment/${row.id}`" target="_blank">查看实验</router-link></el-button
>
<el-button text type="danger" @click="handleRemove(row)" v-permission="'v1-backend-experiment-class-add'"
>删除</el-button
>
</template>
</AppList>
<SelectExperiment v-model="dialogVisible" @bind="handleBind" v-if="dialogVisible" />
</template>
import type { RouteRecordRaw } from 'vue-router'
import AppLayout from '@/components/layout/Index.vue'
export const routes: Array<RouteRecordRaw> = [
{
path: '/admin/lab',
redirect: '/admin/lab/example',
},
{
path: '/admin/lab/example',
component: AppLayout,
children: [
{ path: '', component: () => import('./views/Index.vue') },
{ path: 'create', component: () => import('./views/Create.vue') },
{ path: ':id/edit', component: () => import('./views/Update.vue'), props: true },
{ path: ':id', component: () => import('./views/View.vue'), props: true },
],
},
]
export interface CaseItem {
created_operator_name: string
created_time: string
delete_time: string
experiment_id: string
id: string
name: string
content: string
status: string
status_name: string
type_name: string
type: string
times: string
updated_operator_name: string
updated_time: string
experiments?: ExperimentItem[]
}
export interface CaseFormData {
id?: string
name: string
type: string
times: string
content: string
steps?: CaseStep[]
}
export type CaseCreateItem = CaseFormData
export type CaseUpdateItem = CaseCreateItem & { id: string }
export interface ExperimentItem {
id: string
name: string
type: string
type_name: string
created_time: string
status: string
created_operator_name: string
updated_time: string
updated_operator: string
updated_operator_name: string
}
export interface CaseStep {
id: string
type: 1 | 2 | 3 // 1: 案例原文, 2: 任务, 3: 案例总结
name: string
content?: string
config?: any
}
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { createCase } from '../api'
import Form from '../components/Form.vue'
import type { CaseFormData } from '../types'
const router = useRouter()
const handleSubmit = async (data: CaseFormData) => {
await createCase(data)
ElMessage.success('创建成功')
router.back()
}
</script>
<template>
<AppCard title="新增案例">
<Form @submit="handleSubmit" />
</AppCard>
</template>
<script setup lang="ts">
import type { CaseItem } from '../types'
import { CirclePlus } from '@element-plus/icons-vue'
import AppList from '@/components/base/AppList.vue'
import { useMapStore } from '@/stores/map'
import { getCaseList, deleteCase } from '../api'
import { ElMessage, ElMessageBox } from 'element-plus'
// 实验类型
const types = useMapStore().getMapValuesByKey('experiment_type')
const route = useRoute()
const appList = $ref<InstanceType<typeof AppList> | null>(null)
// 列表配置
const listOptions = $computed(() => {
return {
remote: {
httpRequest: getCaseList,
params: { name: '', experiment_id: route.query.experiment_id || '' },
},
filters: [
{ type: 'select', prop: 'type', label: '实验类型', options: types },
{ type: 'input', prop: 'name', label: '案例名称', placeholder: '请输入案例名称' },
{ type: 'input', prop: 'experiment_name', label: '实验名称', placeholder: '请输入实验名称' },
],
columns: [
{ label: '案例名称', prop: 'name' },
{ label: '实验类型', prop: 'type_name' },
{ label: '课时数', prop: 'times' },
{ label: '生效状态', prop: 'status_name' },
{ label: '更新人', prop: 'updated_operator_name' },
{ label: '更新时间', prop: 'updated_time' },
{ label: '操作', slots: 'table-x', width: 250, fixed: 'right' },
],
}
})
// 删除
function handleDelete(row: CaseItem) {
ElMessageBox.confirm('确定要删除吗?', '提示').then(() => {
deleteCase({ id: row.id }).then(() => {
ElMessage({ message: '删除成功', type: 'success' })
appList?.refetch()
})
})
}
</script>
<template>
<AppCard title="案例管理">
<AppList v-bind="listOptions" ref="appList">
<template #header-buttons>
<router-link to="/admin/lab/example/create"
><el-button type="primary" :icon="CirclePlus">新增案例</el-button></router-link
>
</template>
<template #table-x="{ row }">
<el-button type="primary" round><router-link :to="`/admin/lab/example/${row.id}`">查看</router-link></el-button>
<el-button type="primary" round
><router-link :to="`/admin/lab/example/${row.id}/edit`">编辑</router-link></el-button
>
<el-button type="danger" round @click="handleDelete(row)">删除</el-button>
</template>
</AppList>
</AppCard>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { updateCase, getCase } from '../api'
import Form from '../components/Form.vue'
import type { CaseItem, CaseFormData } from '../types'
const props = defineProps<{ id: string }>()
const router = useRouter()
const handleSubmit = async (data: CaseFormData) => {
await updateCase({ ...data, id: props.id })
ElMessage.success('创建成功')
router.back()
}
const detail = ref<CaseItem>()
const loading = ref(false)
function fetchInfo() {
if (!props.id) return
loading.value = true
getCase({ id: props.id })
.then((res) => {
detail.value = res.data
})
.finally(() => {
loading.value = false
})
}
onMounted(() => {
fetchInfo()
})
</script>
<template>
<AppCard title="编辑案例" :loading="loading">
<Form @submit="handleSubmit" :data="detail" />
</AppCard>
</template>
<script setup lang="ts">
import type { CaseItem } from '../types'
import ViewExperiment from '../components/ViewExperiment.vue'
import { getCase } from '../api'
interface Props {
id: string
}
const props = defineProps<Props>()
const detail = ref<CaseItem | null>(null)
const loading = ref(false)
function fetchInfo() {
if (!props.id) return
loading.value = true
getCase({ id: props.id })
.then((res) => {
detail.value = res.data
})
.finally(() => {
loading.value = false
})
}
onMounted(() => {
fetchInfo()
})
</script>
<template>
<AppCard title="查看案例" :loading="loading">
<template v-if="detail">
<el-descriptions>
<el-descriptions-item label="案例名称:">{{ detail.name }}</el-descriptions-item>
<el-descriptions-item label="实验类型:">{{ detail.type_name }}</el-descriptions-item>
<el-descriptions-item label="课时数:">{{ detail.times }}</el-descriptions-item>
<el-descriptions-item label="有效状态:">{{ detail.status_name }}</el-descriptions-item>
<el-descriptions-item label="创建人:">{{ detail.created_operator_name }}</el-descriptions-item>
<el-descriptions-item label="创建时间:">{{ detail.created_time }}</el-descriptions-item>
</el-descriptions>
</template>
<el-divider />
<ViewExperiment :id="id" :experiments="detail?.experiments" @update="fetchInfo" />
</AppCard>
</template>
...@@ -5,6 +5,7 @@ import { ElMessage } from 'element-plus' ...@@ -5,6 +5,7 @@ import { ElMessage } from 'element-plus'
import { getTripConfig, updateTripConfig, getLiveCommodity } from '../api' import { getTripConfig, updateTripConfig, getLiveCommodity } from '../api'
import { useConnection, useUserAttr, useMetaEvent, useTag, useGroup, useMaterial } from '../composables/useAllData' import { useConnection, useUserAttr, useMetaEvent, useTag, useGroup, useMaterial } from '../composables/useAllData'
import { useDocumentVisibility } from '@vueuse/core' import { useDocumentVisibility } from '@vueuse/core'
import { dmlMenus } from '@/utils/dmlMenus'
import { useAppConfig } from '@/composables/useAppConfig' import { useAppConfig } from '@/composables/useAppConfig'
const appConfig = useAppConfig() const appConfig = useAppConfig()
...@@ -29,94 +30,6 @@ const dmlURL = computed(() => { ...@@ -29,94 +30,6 @@ const dmlURL = computed(() => {
return `${appConfig.dmlURL || import.meta.env.VITE_DML_URL}/trip/template?experiment_id=${props.data.id}` return `${appConfig.dmlURL || import.meta.env.VITE_DML_URL}/trip/template?experiment_id=${props.data.id}`
}) })
const experimentConfig: any = [
{
id: 1,
name: '基础配置',
is_checked: false,
pid: 0,
children: [
{ id: 2, name: '连接管理', is_checked: false, pid: 1, children: [] },
{ id: 3, name: '用户属性管理', is_checked: false, pid: 1, children: [] },
{ id: 4, name: '事件属性管理', is_checked: false, pid: 1, children: [] },
],
},
{
id: 5,
name: '营销策划',
is_checked: false,
pid: 0,
children: [],
},
{
id: 6,
name: '用户画像',
is_checked: false,
pid: 0,
children: [],
},
{
id: 7,
name: '用户识别',
is_checked: false,
pid: 0,
children: [
{ id: 8, name: '标签管理', is_checked: false, pid: 7, children: [] },
{ id: 9, name: '群组管理', is_checked: false, pid: 7, children: [] },
{ id: 71, name: '运营策略管理', is_checked: false, pid: 7, children: [] },
],
},
{
id: 10,
name: '营销内容设计',
is_checked: false,
pid: 0,
children: [
{ id: 11, name: '文本资料管理', is_checked: false, pid: 10, children: [] },
{ id: 12, name: '图片资料管理', is_checked: false, pid: 10, children: [] },
{ id: 13, name: '卡券资料管理', is_checked: false, pid: 10, children: [] },
{ id: 14, name: '视频资料管理', is_checked: false, pid: 10, children: [] },
{ id: 15, name: 'H5资料管理', is_checked: false, pid: 10, children: [] },
{ id: 16, name: '二维码资料管理', is_checked: false, pid: 10, children: [] },
{ id: 17, name: '语言资料管理', is_checked: false, pid: 10, children: [] },
{ id: 18, name: '小程序资料管理', is_checked: false, pid: 10, children: [] },
],
},
{
id: 19,
name: '自动化营销',
is_checked: false,
pid: 0,
children: [],
},
{
id: 20,
name: '直播带货',
is_checked: false,
pid: 0,
children: [
{ id: 21, name: '商品品类管理', is_checked: false, pid: 20, children: [] },
{ id: 22, name: '商品属性管理', is_checked: false, pid: 20, children: [] },
{ id: 23, name: '商品管理', is_checked: false, pid: 20, children: [] },
{ id: 24, name: '直播练习', is_checked: false, pid: 20, children: [] },
{ id: 25, name: '直播话术管理', is_checked: false, pid: 20, children: [] },
{ id: 201, name: '订单管理', is_checked: false, pid: 20, children: [] },
],
},
{
id: 26,
name: '数据分析',
is_checked: false,
pid: 0,
children: [
{ id: 27, name: '用户分析', is_checked: false, pid: 26, children: [] },
{ id: 28, name: '标签群组分析', is_checked: false, pid: 26, children: [] },
{ id: 29, name: '事件分析', is_checked: false, pid: 26, children: [] },
{ id: 30, name: '营销分析', is_checked: false, pid: 26, children: [] },
],
},
]
const formRef = $ref<FormInstance>() const formRef = $ref<FormInstance>()
const form = reactive({ const form = reactive({
experiment_id: props.data.id, experiment_id: props.data.id,
...@@ -132,7 +45,7 @@ const form = reactive({ ...@@ -132,7 +45,7 @@ const form = reactive({
tag_ids: [], tag_ids: [],
group_ids: [], group_ids: [],
material_ids: [], material_ids: [],
auth_config: experimentConfig, auth_config: dmlMenus,
is_use_common_live_commodities: 0, is_use_common_live_commodities: 0,
live_commodity_ids: [], live_commodity_ids: [],
}) })
...@@ -190,9 +103,9 @@ function fetchInfo() { ...@@ -190,9 +103,9 @@ function fetchInfo() {
} }
// Ensure auth_config structure is maintained // Ensure auth_config structure is maintained
let authConfig = experimentConfig let authConfig = dmlMenus
if (data.auth_config && data.auth_config.length > 0) { if (data.auth_config && data.auth_config.length > 0) {
authConfig = mergeConfig(experimentConfig, data.auth_config) authConfig = mergeConfig(dmlMenus, data.auth_config)
} }
Object.assign(form, { Object.assign(form, {
......
...@@ -140,3 +140,13 @@ export function getExperimentExamList(params: { experiment_id: string }) { ...@@ -140,3 +140,13 @@ export function getExperimentExamList(params: { experiment_id: string }) {
export function getExperimentScoreDetail(params: { experiment_id: string; type: string }) { export function getExperimentScoreDetail(params: { experiment_id: string; type: string }) {
return httpRequest.get('/api/lab/v1/student/experiment-question/score-detail', { params }) return httpRequest.get('/api/lab/v1/student/experiment-question/score-detail', { params })
} }
// 获取实验案例详情(包含步骤数据)
export function getExperimentCaseDetail(params: { experiment_id: string }) {
return httpRequest.get('/api/lab/v1/student/experiment-cases2/detail', { params })
}
// 获取实验案例示例数据
export function getExperimentExample(params: { experiment_id: string }) {
return httpRequest.get('/api/lab/v1/student/experiment-cases2/detail', { params })
}
<script setup lang="ts">
import { useExample } from '../composables/useExample'
interface Props {
experiment_id: string
}
const props = defineProps<Props>()
const emits = defineEmits(['empty', 'goToFunction'])
const experimentId = computed(() => props.experiment_id)
const { example, tasks, currentTask, operationSteps, selectedTask, selectedStep, currentStep, switchTask, switchStep } =
useExample(experimentId, emits)
const goToFunction = (path: string) => {
emits('goToFunction', path)
}
</script>
<template>
<div class="example-container">
<template v-if="example">
<!-- 头部信息 -->
<div class="header">
<div class="title">{{ example.name }}</div>
<div class="times">{{ example.times }}学时</div>
</div>
<!-- 案例原文 -->
<div class="section">
<div class="section-title">案例原文</div>
<div class="content-area">
<div class="content-text" v-html="example.content.find((item) => item.type === 1)?.content || ''"></div>
</div>
</div>
<!-- 任务选择 -->
<div class="section" v-if="tasks.length > 0">
<div class="task-buttons">
<button
v-for="task in tasks"
:key="task.id"
class="task-button"
:class="{ active: selectedTask === task.id }"
@click="switchTask(task.id)">
{{ task.name }}
</button>
</div>
</div>
<!-- 任务描述 -->
<div class="section" v-if="currentTask">
<div class="section-title">任务描述</div>
<div class="content-area">
<div class="content-text" v-html="currentTask?.content || ''"></div>
</div>
</div>
<!-- 操作步骤 -->
<div class="section" v-if="operationSteps.length > 0">
<div class="section-title">操作步骤:</div>
<div class="steps-container">
<button
v-for="(step, index) in operationSteps"
:key="step.id"
class="step-button"
:class="{ active: selectedStep === step.id }"
@click="switchStep(step.id)">
{{ index + 1 }}
</button>
</div>
<div class="content-area">
<div class="content-text" v-html="currentStep?.content"></div>
</div>
<!-- 快速进入按钮 -->
<div class="quick-access" v-if="currentStep?.relatedFunction">
<el-button plain type="primary" @click="goToFunction(currentStep.relatedFunction.path)">快速进入</el-button>
</div>
</div>
</template>
<el-empty description="暂无数据" v-else />
</div>
</template>
<style lang="scss" scoped>
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding-bottom: 15px;
border-bottom: 1px solid #e8e8e8;
.title {
font-size: 20px;
font-weight: 600;
color: #333;
}
.times {
font-size: 16px;
color: #666;
background: #f5f5f5;
padding: 4px 12px;
border-radius: 4px;
}
}
.section {
margin-bottom: 30px;
.section-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 15px;
}
}
.content-area {
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 6px;
min-height: 100px;
max-height: 300px;
overflow-y: auto;
padding: 15px;
.content-text {
line-height: 1.6;
color: #333;
:deep(p) {
margin-bottom: 10px;
}
}
/* 自定义滚动条 */
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
&::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
&:hover {
background: #a8a8a8;
}
}
}
.task-buttons {
display: flex;
gap: 15px;
margin-bottom: 20px;
.task-button {
padding: 10px 20px;
border: 2px solid var(--main-color);
border-radius: 20px;
background: #fff;
color: var(--main-color);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background: #f8f8f8;
}
&.active {
background: var(--main-color);
color: #fff;
}
}
}
.steps-container {
display: flex;
gap: 10px;
margin-bottom: 20px;
.step-button {
width: 40px;
height: 40px;
border: 2px solid var(--main-color);
border-radius: 50%;
background: #fff;
color: var(--main-color);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: #f8f8f8;
}
&.active {
background: var(--main-color);
color: #fff;
}
}
}
.quick-access {
margin-top: 20px;
text-align: center;
}
</style>
import type { Ref } from 'vue'
import { getExperimentExample } from '../api'
// 定义案例步骤类型
interface CaseStep {
id: string
type: number
name: string
content?: string
config?: {
operationSteps?: Array<{
id: number
content: string
relatedFunction?: {
id: number
name: string
path: string
}
}>
}
}
// 定义案例详情类型
interface CaseDetail {
id: string
name: string
times: string
type: string
status: string
created_operator: string
created_time: string
updated_operator: string
updated_time: string
content: CaseStep[]
delete_time: string
status_name: string
created_operator_name: string
updated_operator_name: string
type_name: string
}
export function useExample(experimentId: Ref<string>, emits: any) {
const example = ref<CaseDetail>()
const selectedTask = ref('task1')
const selectedStep = ref(1)
const tasks = computed(() => example.value?.content.filter((item) => item.type === 2) || [])
const currentTask = computed(() => {
return example.value?.content.find((item) => item.id === selectedTask.value)
})
const operationSteps = computed(() => {
return currentTask.value?.config?.operationSteps || []
})
const currentStep = computed(() => {
return operationSteps.value.find((item) => item.id === selectedStep.value)
})
const fetchExample = async () => {
const res = await getExperimentExample({ experiment_id: experimentId.value })
if (res.data.detail) {
// 解析案例内容
try {
const content = JSON.parse(res.data.detail.content || '[]')
example.value = { ...res.data.detail, content: content }
selectedTask.value = tasks.value[0].id
selectedStep.value = operationSteps.value[0].id
} catch (error) {
console.error('获取案例数据失败:', error)
}
} else {
example.value = undefined
emits('empty')
}
}
watchEffect(() => {
if (experimentId.value) {
fetchExample()
}
})
// 切换任务
const switchTask = (taskId: string) => {
selectedTask.value = taskId
selectedStep.value = 1
}
// 切换操作步骤
const switchStep = (stepId: number) => {
selectedStep.value = stepId
}
return {
example,
tasks,
currentTask,
operationSteps,
selectedTask,
selectedStep,
currentStep,
switchTask,
switchStep,
}
}
...@@ -26,6 +26,7 @@ const PrepareDialog = defineAsyncComponent(() => import('../components/PrepareDi ...@@ -26,6 +26,7 @@ const PrepareDialog = defineAsyncComponent(() => import('../components/PrepareDi
const ResultDialog = defineAsyncComponent(() => import('../components/ResultDialog.vue')) const ResultDialog = defineAsyncComponent(() => import('../components/ResultDialog.vue'))
const ReportPreview = defineAsyncComponent(() => import('../components/ReportPreview.vue')) const ReportPreview = defineAsyncComponent(() => import('../components/ReportPreview.vue'))
const Exam = defineAsyncComponent(() => import('../components/Exam.vue')) const Exam = defineAsyncComponent(() => import('../components/Exam.vue'))
const Example = defineAsyncComponent(() => import('../components/Example.vue'))
const route = useRoute() const route = useRoute()
...@@ -103,15 +104,15 @@ const examURL = ref('') ...@@ -103,15 +104,15 @@ const examURL = ref('')
// 右侧 // 右侧
const cookies = useCookies(['TGC']) const cookies = useCookies(['TGC'])
const LAB_URL: any = computed(() => { const LAB_URL = computed<string>(() => {
if (tabActive.value === 'exam' && examURL.value && experimentInfo?.exam_status === 1) return examURL.value if (tabActive.value === 'exam' && examURL.value && experimentInfo?.exam_status === 1) return examURL.value
if (experimentInfo?.type === 4) if (experimentInfo?.type === 4)
return `${appConfig.dmlURL || import.meta.env.VITE_DML_URL}?experiment_id=${form.experiment_id}` return `${appConfig.dmlURL || import.meta.env.VITE_DML_URL}${functionPath.value}?experiment_id=${
if (experimentInfo?.type === 5) form.experiment_id
return `${import.meta.env.VITE_SAAS_BI_URL}?experiment_id=${form.experiment_id}` }`
if (experimentInfo?.type === 6) if (experimentInfo?.type === 5) return `${import.meta.env.VITE_SAAS_BI_URL}?experiment_id=${form.experiment_id}`
return `${import.meta.env.VITE_SAAS_AI_URL}?experiment_id=${form.experiment_id}` if (experimentInfo?.type === 6) return `${import.meta.env.VITE_SAAS_AI_URL}?experiment_id=${form.experiment_id}`
return `${appConfig.dmlURL || import.meta.env.VITE_LAB_URL}&token=${cookies.get('TGC')}` return `${import.meta.env.VITE_LAB_URL}&token=${cookies.get('TGC')}`
}) })
let iframeKey = $ref(Date.now()) let iframeKey = $ref(Date.now())
...@@ -244,6 +245,10 @@ const empty = ref<string[]>([]) ...@@ -244,6 +245,10 @@ const empty = ref<string[]>([])
function handleEmpty(name: string) { function handleEmpty(name: string) {
empty.value.push(name) empty.value.push(name)
} }
const functionPath = ref('')
function handleGoToFunction(path: string) {
functionPath.value = path
}
</script> </script>
<template> <template>
...@@ -266,6 +271,12 @@ function handleEmpty(name: string) { ...@@ -266,6 +271,12 @@ function handleEmpty(name: string) {
<el-tab-pane label="实验信息" name="info"> <el-tab-pane label="实验信息" name="info">
<Info :data="experimentInfo"></Info> <Info :data="experimentInfo"></Info>
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="实验案例" name="example" v-if="!empty.includes('example')">
<Example
:experiment_id="form.experiment_id"
@empty="handleEmpty('example')"
@goToFunction="handleGoToFunction"></Example>
</el-tab-pane>
<el-tab-pane label="案例原文" name="case" v-if="!empty.includes('case')"> <el-tab-pane label="案例原文" name="case" v-if="!empty.includes('case')">
<Case <Case
:course_id="form.course_id" :course_id="form.course_id"
......
...@@ -14,7 +14,7 @@ const studentMenus: IMenuItem[] = [ ...@@ -14,7 +14,7 @@ const studentMenus: IMenuItem[] = [
{ name: '我的实验', path: '/student/lab' }, { name: '我的实验', path: '/student/lab' },
{ name: '理论学习', path: import.meta.env.VITE_SAAS_LEARN_URL }, { name: '理论学习', path: import.meta.env.VITE_SAAS_LEARN_URL },
{ name: '我的大赛', path: '/student/contest' }, { name: '我的大赛', path: '/student/contest' },
{ name: '大赛成绩查询', path: '/student/contest/score' } { name: '大赛成绩查询', path: '/student/contest/score' },
] ]
// 管理员菜单 // 管理员菜单
const adminMenus: IMenuItem[] = [ const adminMenus: IMenuItem[] = [
...@@ -30,8 +30,9 @@ const adminMenus: IMenuItem[] = [ ...@@ -30,8 +30,9 @@ const adminMenus: IMenuItem[] = [
{ name: '实验操作视频管理', path: '/admin/lab/video', tag: 'v1-teacher-video' }, { name: '实验操作视频管理', path: '/admin/lab/video', tag: 'v1-teacher-video' },
{ name: '实验讨论交流', path: '/admin/lab/discuss', tag: 'v1-teacher-discussion' }, { name: '实验讨论交流', path: '/admin/lab/discuss', tag: 'v1-teacher-discussion' },
{ name: '实验成绩管理', path: '/admin/lab/score', tag: 'v1-teacher-record' }, { name: '实验成绩管理', path: '/admin/lab/score', tag: 'v1-teacher-record' },
{ name: '实验监控', path: '/admin/lab/dashboard' } { name: '实验监控', path: '/admin/lab/dashboard' },
] { name: '案例管理', path: '/admin/lab/example' },
],
}, },
{ {
name: '技能大赛', name: '技能大赛',
...@@ -44,17 +45,17 @@ const adminMenus: IMenuItem[] = [ ...@@ -44,17 +45,17 @@ const adminMenus: IMenuItem[] = [
{ name: '大赛监控', path: '/admin/contest/dashboard', tag: 'v1-expert-statistic' }, { name: '大赛监控', path: '/admin/contest/dashboard', tag: 'v1-expert-statistic' },
{ name: '大赛评分', path: '/admin/contest/check', tag: 'v1-expert-check' }, { name: '大赛评分', path: '/admin/contest/check', tag: 'v1-expert-check' },
{ name: '大赛发布成绩', path: '/admin/contest/score', tag: 'v1-expert-score' }, { name: '大赛发布成绩', path: '/admin/contest/score', tag: 'v1-expert-score' },
{ name: '客户端日志', path: '/admin/contest/log', tag: '' } { name: '客户端日志', path: '/admin/contest/log', tag: '' },
] ],
}, },
{ {
name: '成绩分析', name: '成绩分析',
path: '/admin/contest/analyze', path: '/admin/contest/analyze',
children: [ children: [
{ name: '赛项成绩画像', path: '/admin/contest/analyze/score' }, { name: '赛项成绩画像', path: '/admin/contest/analyze/score' },
{ name: '学生个人成绩画像', path: '/admin/contest/analyze/student' } { name: '学生个人成绩画像', path: '/admin/contest/analyze/student' },
] ],
} },
] ]
const appConfig = useAppConfig() const appConfig = useAppConfig()
...@@ -69,6 +70,6 @@ export const useMenuStore = defineStore({ ...@@ -69,6 +70,6 @@ export const useMenuStore = defineStore({
} else { } else {
return appConfig.adminMenus || state.adminMenus return appConfig.adminMenus || state.adminMenus
} }
} },
} },
}) })
export interface DmlMenu {
id: number
name: string
is_checked: boolean
pid: number
children?: DmlMenu[]
path?: string
}
export const dmlMenus: DmlMenu[] = [
{
id: 1,
name: '基础配置',
is_checked: false,
pid: 0,
path: '/connect',
children: [
{ id: 2, name: '连接管理', is_checked: false, pid: 1, path: '/connect', children: [] },
{ id: 3, name: '用户属性管理', is_checked: false, pid: 1, path: '/metadata/user', children: [] },
{ id: 4, name: '事件属性管理', is_checked: false, pid: 1, path: '/metadata/event', children: [] },
],
},
{
id: 5,
name: '营销策划',
is_checked: false,
pid: 0,
path: '/market/my',
children: [],
},
{
id: 6,
name: '用户画像',
is_checked: false,
pid: 0,
path: '/user',
children: [],
},
{
id: 7,
name: '用户识别',
is_checked: false,
pid: 0,
path: '/label',
children: [
{ id: 8, name: '标签管理', is_checked: false, pid: 7, path: '/label', children: [] },
{ id: 9, name: '群组管理', is_checked: false, pid: 7, path: '/group', children: [] },
{ id: 71, name: '运营策略管理', is_checked: false, pid: 7, path: '/strategy', children: [] },
],
},
{
id: 10,
name: '营销内容设计',
is_checked: false,
pid: 0,
path: '/material',
children: [
{ id: 11, name: '文本资料管理', is_checked: false, pid: 10, path: '/material?type=1', children: [] },
{ id: 12, name: '图片资料管理', is_checked: false, pid: 10, path: '/material?type=2', children: [] },
{ id: 13, name: '卡券资料管理', is_checked: false, pid: 10, path: '/material?type=8', children: [] },
{ id: 14, name: '视频资料管理', is_checked: false, pid: 10, path: '/material?type=4', children: [] },
{ id: 15, name: 'H5资料管理', is_checked: false, pid: 10, path: '/material?type=5', children: [] },
{ id: 16, name: '二维码资料管理', is_checked: false, pid: 10, path: '/material?type=6', children: [] },
{ id: 17, name: '语音资料管理', is_checked: false, pid: 10, path: '/material?type=3', children: [] },
{ id: 18, name: '小程序资料管理', is_checked: false, pid: 10, path: '/material?type=7', children: [] },
],
},
{
id: 19,
name: '自动化营销',
is_checked: false,
pid: 0,
path: '/trip/my',
children: [],
},
{
id: 20,
name: '直播带货',
is_checked: false,
pid: 0,
path: '/live',
children: [
{ id: 21, name: '商品品类管理', is_checked: false, pid: 20, path: '/live/product/category', children: [] },
{ id: 22, name: '商品属性管理', is_checked: false, pid: 20, path: '/live/product/attr', children: [] },
{ id: 23, name: '商品管理', is_checked: false, pid: 20, path: '/live/product/management', children: [] },
{ id: 24, name: '直播练习', is_checked: false, pid: 20, path: '/live/test', children: [] },
{ id: 25, name: '直播话术管理', is_checked: false, pid: 20, path: '/live/talk', children: [] },
{ id: 201, name: '订单管理', is_checked: false, pid: 20, path: '/live/order', children: [] },
],
},
{
id: 26,
name: '数据分析',
is_checked: false,
pid: 0,
path: '/analyze',
children: [
{ id: 27, name: '用户分析', is_checked: false, pid: 26, path: '/analyze/user', children: [] },
{ id: 28, name: '标签群组分析', is_checked: false, pid: 26, path: '/analyze/label', children: [] },
{ id: 29, name: '事件分析', is_checked: false, pid: 26, path: '/analyze/event', children: [] },
{ id: 30, name: '营销分析', is_checked: false, pid: 26, path: '/analyze/marketing', children: [] },
],
},
]
...@@ -30,17 +30,17 @@ export default defineConfig(({ mode }) => ({ ...@@ -30,17 +30,17 @@ export default defineConfig(({ mode }) => ({
// cert: fs.readFileSync(path.join(__dirname, './https/ezijing.com.pem')) // cert: fs.readFileSync(path.join(__dirname, './https/ezijing.com.pem'))
// }, // },
proxy: { proxy: {
'/api': 'https://saas-lab.ezijing.com', // '/api/lab': {
// target: 'http://local-com-resource-api.frontend.ezijing.com',
// changeOrigin: true,
// rewrite: (path) => path.replace(/^\/api\/lab/, ''),
// },
// '/api/resource': { // '/api/resource': {
// target: 'http://com-resource-admin-test.ezijing.com', // target: 'http://com-resource-admin-test.ezijing.com',
// changeOrigin: true, // changeOrigin: true,
// rewrite: path => path.replace(/^\/api\/resource/, '') // rewrite: path => path.replace(/^\/api\/resource/, '')
// }, // },
// '/api/lab': { '/api': 'https://saas-lab.ezijing.com',
// target: 'http://com-resource-api-test.ezijing.com',
// changeOrigin: true,
// rewrite: path => path.replace(/^\/api\/lab/, '')
// }
}, },
}, },
resolve: { resolve: {
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论