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

feat: 新增实验案例

上级 28f1a458
......@@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@fortaine/fetch-event-source": "^3.0.6",
"@microsoft/fetch-event-source": "^2.0.1",
"@tinymce/tinymce-vue": "^5.0.0",
"@vant/area-data": "^1.5.1",
......@@ -17,7 +18,7 @@
"@vueuse/integrations": "^9.13.0",
"@vueuse/math": "^9.13.0",
"ali-oss": "^6.17.1",
"axios": "^1.3.3",
"axios": "^1.10.0",
"blueimp-md5": "^2.19.0",
"countup.js": "^2.6.2",
"dayjs": "^1.11.7",
......@@ -1101,6 +1102,15 @@
"@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": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
......@@ -2970,9 +2980,9 @@
}
},
"node_modules/axios": {
"version": "1.7.7",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz",
"integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==",
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
......@@ -8139,6 +8149,11 @@
"@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": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
......@@ -9402,9 +9417,9 @@
"integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg=="
},
"axios": {
"version": "1.7.7",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz",
"integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==",
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
"requires": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
......
......@@ -15,6 +15,7 @@
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@fortaine/fetch-event-source": "^3.0.6",
"@microsoft/fetch-event-source": "^2.0.1",
"@tinymce/tinymce-vue": "^5.0.0",
"@vant/area-data": "^1.5.1",
......@@ -23,7 +24,7 @@
"@vueuse/integrations": "^9.13.0",
"@vueuse/math": "^9.13.0",
"ali-oss": "^6.17.1",
"axios": "^1.3.3",
"axios": "^1.10.0",
"blueimp-md5": "^2.19.0",
"countup.js": "^2.6.2",
"dayjs": "^1.11.7",
......
......@@ -3,6 +3,7 @@ interface Props {
title?: string
hasCardBackground?: boolean
hasBodyBackground?: boolean
loading?: boolean
}
withDefaults(defineProps<Props>(), { hasBodyBackground: true })
</script>
......@@ -17,7 +18,7 @@ withDefaults(defineProps<Props>(), { hasBodyBackground: true })
</div>
</slot>
</div>
<div class="app-card-bd" :class="{ 'has-background': hasBodyBackground }">
<div class="app-card-bd" :class="{ 'has-background': hasBodyBackground }" v-loading="loading">
<slot></slot>
</div>
</div>
......
......@@ -2,12 +2,13 @@
import Editor from '@tinymce/tinymce-vue'
import md5 from 'blueimp-md5'
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({
height: {
type: Number,
default: 400
}
height: { type: Number, default: 400 },
hasAI: { type: Boolean, default: false },
})
const ImageUploadHandler = (blobInfo: any) =>
......@@ -26,7 +27,7 @@ const ImageUploadHandler = (blobInfo: any) =>
signature,
success_action_status: '200',
file,
url: `${host}/${key}`
url: `${host}/${key}`,
}
uploadFile(params)
.then((res: any) => {
......@@ -41,6 +42,8 @@ const ImageUploadHandler = (blobInfo: any) =>
})
})
const editorRef = ref()
const init = {
language: 'zh-Hans',
height: props.height,
......@@ -57,16 +60,96 @@ const init = {
automatic_uploads: true,
quickbars_insert_toolbar: false,
// 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>
<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>
<style lang="scss">
.tox-tinymce-aux {
z-index: 3000 !important;
}
.app-editor-ai {
text-align: right;
margin-top: 10px;
}
</style>
......@@ -80,7 +80,7 @@ function handleClick(path: string) {
v-permission="item.tag"
v-if="item.children">
<template #title>
{{ item.name }}
<router-link :to="item.path">{{ item.name }}</router-link>
</template>
<el-menu-item
:index="subitem.path"
......@@ -88,11 +88,11 @@ function handleClick(path: string) {
:key="subitem.path"
v-permission="subitem.tag"
@click="handleClick(subitem.path)">
{{ subitem.name }}
<router-link :to="subitem.path">{{ subitem.name }}</router-link>
</el-menu-item>
</el-sub-menu>
<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>
</template>
</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 = [
{
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',
title: '商业数据分析实验室',
......@@ -37,13 +44,6 @@ const appConfigList = [
title: '商业数据分析竞赛平台',
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',
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 { 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'
import { getTripConfig, updateTripConfig, getLiveCommodity } from '../api'
import { useConnection, useUserAttr, useMetaEvent, useTag, useGroup, useMaterial } from '../composables/useAllData'
import { useDocumentVisibility } from '@vueuse/core'
import { dmlMenus } from '@/utils/dmlMenus'
import { useAppConfig } from '@/composables/useAppConfig'
const appConfig = useAppConfig()
......@@ -29,94 +30,6 @@ const dmlURL = computed(() => {
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 form = reactive({
experiment_id: props.data.id,
......@@ -132,7 +45,7 @@ const form = reactive({
tag_ids: [],
group_ids: [],
material_ids: [],
auth_config: experimentConfig,
auth_config: dmlMenus,
is_use_common_live_commodities: 0,
live_commodity_ids: [],
})
......@@ -190,9 +103,9 @@ function fetchInfo() {
}
// Ensure auth_config structure is maintained
let authConfig = experimentConfig
let authConfig = dmlMenus
if (data.auth_config && data.auth_config.length > 0) {
authConfig = mergeConfig(experimentConfig, data.auth_config)
authConfig = mergeConfig(dmlMenus, data.auth_config)
}
Object.assign(form, {
......
......@@ -140,3 +140,13 @@ export function getExperimentExamList(params: { experiment_id: string }) {
export function getExperimentScoreDetail(params: { experiment_id: string; type: string }) {
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
const ResultDialog = defineAsyncComponent(() => import('../components/ResultDialog.vue'))
const ReportPreview = defineAsyncComponent(() => import('../components/ReportPreview.vue'))
const Exam = defineAsyncComponent(() => import('../components/Exam.vue'))
const Example = defineAsyncComponent(() => import('../components/Example.vue'))
const route = useRoute()
......@@ -103,15 +104,15 @@ const examURL = ref('')
// 右侧
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 (experimentInfo?.type === 4)
return `${appConfig.dmlURL || import.meta.env.VITE_DML_URL}?experiment_id=${form.experiment_id}`
if (experimentInfo?.type === 5)
return `${import.meta.env.VITE_SAAS_BI_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 `${appConfig.dmlURL || import.meta.env.VITE_DML_URL}${functionPath.value}?experiment_id=${
form.experiment_id
}`
if (experimentInfo?.type === 5) return `${import.meta.env.VITE_SAAS_BI_URL}?experiment_id=${form.experiment_id}`
if (experimentInfo?.type === 6) return `${import.meta.env.VITE_SAAS_AI_URL}?experiment_id=${form.experiment_id}`
return `${import.meta.env.VITE_LAB_URL}&token=${cookies.get('TGC')}`
})
let iframeKey = $ref(Date.now())
......@@ -244,6 +245,10 @@ const empty = ref<string[]>([])
function handleEmpty(name: string) {
empty.value.push(name)
}
const functionPath = ref('')
function handleGoToFunction(path: string) {
functionPath.value = path
}
</script>
<template>
......@@ -266,6 +271,12 @@ function handleEmpty(name: string) {
<el-tab-pane label="实验信息" name="info">
<Info :data="experimentInfo"></Info>
</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')">
<Case
:course_id="form.course_id"
......
......@@ -14,7 +14,7 @@ const studentMenus: IMenuItem[] = [
{ name: '我的实验', path: '/student/lab' },
{ name: '理论学习', path: import.meta.env.VITE_SAAS_LEARN_URL },
{ name: '我的大赛', path: '/student/contest' },
{ name: '大赛成绩查询', path: '/student/contest/score' }
{ name: '大赛成绩查询', path: '/student/contest/score' },
]
// 管理员菜单
const adminMenus: IMenuItem[] = [
......@@ -30,8 +30,9 @@ const adminMenus: IMenuItem[] = [
{ name: '实验操作视频管理', path: '/admin/lab/video', tag: 'v1-teacher-video' },
{ name: '实验讨论交流', path: '/admin/lab/discuss', tag: 'v1-teacher-discussion' },
{ 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: '技能大赛',
......@@ -44,17 +45,17 @@ const adminMenus: IMenuItem[] = [
{ name: '大赛监控', path: '/admin/contest/dashboard', tag: 'v1-expert-statistic' },
{ name: '大赛评分', path: '/admin/contest/check', tag: 'v1-expert-check' },
{ name: '大赛发布成绩', path: '/admin/contest/score', tag: 'v1-expert-score' },
{ name: '客户端日志', path: '/admin/contest/log', tag: '' }
]
{ name: '客户端日志', path: '/admin/contest/log', tag: '' },
],
},
{
name: '成绩分析',
path: '/admin/contest/analyze',
children: [
{ name: '赛项成绩画像', path: '/admin/contest/analyze/score' },
{ name: '学生个人成绩画像', path: '/admin/contest/analyze/student' }
]
}
{ name: '学生个人成绩画像', path: '/admin/contest/analyze/student' },
],
},
]
const appConfig = useAppConfig()
......@@ -69,6 +70,6 @@ export const useMenuStore = defineStore({
} else {
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 }) => ({
// cert: fs.readFileSync(path.join(__dirname, './https/ezijing.com.pem'))
// },
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': {
// target: 'http://com-resource-admin-test.ezijing.com',
// changeOrigin: true,
// rewrite: path => path.replace(/^\/api\/resource/, '')
// },
// '/api/lab': {
// target: 'http://com-resource-api-test.ezijing.com',
// changeOrigin: true,
// rewrite: path => path.replace(/^\/api\/lab/, '')
// }
'/api': 'https://saas-lab.ezijing.com',
},
},
resolve: {
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论