提交 602d12af authored 作者: 王鹏飞's avatar 王鹏飞

feat: 新增全媒体运营

上级 c72cfcea
import httpRequest from '@/utils/axios'
// 获取全媒体运营方案详情
export function getOperations(params?: { id: string }) {
return httpRequest.get('/api/lab/v1/experiment/operation/detail', { params })
}
// 保存全媒体运营方案
export function saveOperations(data: { content: string }) {
return httpRequest.post('/api/lab/v1/experiment/operation/save', data)
}
<script setup>
import AppEditor from '@/components/base/AppEditor.vue'
import { useFileDialog } from '@vueuse/core'
import { upload } from '@/utils/upload'
import { Delete } from '@element-plus/icons-vue'
const form = defineModel('form', {
type: Object,
default: () => ({}),
})
const props = defineProps({
steps: { type: Array, default: () => [] },
})
const activeTab = ref(1)
const handleNext = () => {
activeTab.value++
}
const handlePrev = () => {
activeTab.value--
}
const handleSave = () => {
emit('save', form)
}
const emit = defineEmits(['save'])
const { open, onChange } = useFileDialog({
accept: 'image/*,video/*',
})
const currentItem = computed(() => {
return props.steps.find((item) => item.name === activeTab.value)
})
const key = computed(() => {
return currentItem.value?.key
})
const handleUpload = async () => {
if (currentItem.value.isImage) {
open({ accept: 'image/*' })
} else if (currentItem.value.isVideo) {
open({ accept: 'video/*' })
}
}
const isLoading = ref(false)
onChange(async (files) => {
if (!files) return
const [file] = files
isLoading.value = true
const res = await upload(file)
if (currentItem.value.isImage) {
form.value[key.value] = form.value[key.value] ? [...form.value[key.value], res] : [res]
} else {
form.value[key.value] = res
}
isLoading.value = false
})
const handleDelete = (url) => {
form.value[key.value] = form.value[key.value].filter((item) => item !== url)
}
</script>
<template>
<AppCard>
<el-tabs stretch v-model="activeTab" class="operations-tabs">
<el-tab-pane :label="item.label" :name="item.name" v-for="item in steps" :key="item.name">
<AppCard :title="item.title">
<div style="background: #ecf2f7; padding: 40px; border-radius: 20px; min-height: 200px">
<el-button
type="primary"
round
@click="handleUpload"
:loading="isLoading"
v-if="item.isImage || item.isVideo"
>点击上传</el-button
>
<AppEditor v-model="form[item.key]" v-else />
<div class="upload-list">
<ul class="image-list" v-if="item.isImage">
<li v-for="url in form[item.key]" :key="url">
<el-icon @click="handleDelete(url)"><Delete /></el-icon>
<img :src="url" />
</li>
</ul>
<video class="video-player" v-if="item.isVideo && form[item.key]" :src="form[item.key]" controls></video>
</div>
</div>
</AppCard>
</el-tab-pane>
</el-tabs>
<el-row justify="center">
<el-button type="primary" plain @click="handlePrev" v-if="activeTab > 1">上一步</el-button>
<el-button type="primary" @click="handleSave" style="width: 200px">保存</el-button>
<el-button type="primary" plain @click="handleNext" v-if="activeTab < steps.length">下一步</el-button>
</el-row>
</AppCard>
</template>
<style lang="scss" scoped>
.operations-tabs {
:deep(.el-tabs__header) {
width: 800px;
margin: 0 auto 20px;
}
:deep(.el-tabs__nav-wrap::after) {
display: none;
}
}
.upload-list {
margin: 20px 0;
}
.image-list {
display: flex;
flex-wrap: wrap;
gap: 20px;
li {
position: relative;
width: 180px;
height: 240px;
background-color: #fff;
border: 1px solid #eee;
img {
width: 100%;
height: 100%;
object-fit: contain;
}
.el-icon {
display: none;
position: absolute;
top: 10px;
right: 10px;
cursor: pointer;
color: #999;
font-size: 22px;
}
&:hover {
.el-icon {
display: block;
}
}
}
}
.video-player {
max-height: 400px;
}
</style>
import type { RouteRecordRaw } from 'vue-router'
import Layout from '@/components/layout/Index.vue'
const routes: RouteRecordRaw[] = [
{
path: '/operations',
component: Layout,
redirect: '/operations/plan',
children: [
{ path: 'plan', component: () => import('./views/Index.vue'), props: { type: 'plan' } },
{ path: 'audiovisual', component: () => import('./views/Index.vue'), props: { type: 'audiovisual' } },
{ path: 'flow', component: () => import('./views/Index.vue'), props: { type: 'flow' } },
],
},
]
export { routes }
<script setup>
import Steps from '../components/Steps.vue'
import { getOperations, saveOperations } from '../api'
import { ElMessage } from 'element-plus'
defineProps({ type: { type: String, default: 'plan' } })
const typeMap = {
plan: {
steps: [
{
name: 1,
label: '第一步',
title: '全媒体运营的主题(方向)描述(4分)',
key: 'theme',
},
{
name: 2,
label: '第二步',
title: '运营的渠道路径(5分)',
key: 'path',
},
{
name: 3,
label: '第三步',
title: '运营的重点和难点(5分)',
key: 'difficulty',
},
{
name: 4,
label: '第四步',
title: '运营策划框架方案(从媒介技术、加工匹配、传播、反馈等,要点式表述)(6分)',
key: 'framework',
},
],
},
audiovisual: {
steps: [
{
name: 1,
label: '第一步',
title: '综合稿件标题(5分)',
key: 'title',
},
{
name: 2,
label: '第二步',
title: '导语(5分)',
key: 'intro',
},
{
name: 3,
label: '第三步',
title: '正文报道文字(不少于200字)(5分)',
key: 'content',
},
{
name: 4,
label: '第四步',
title: '主题活动(场景)现场照片(不少于2张)(10分)',
key: 'images',
isImage: true,
},
{
name: 5,
label: '第五步',
title:
'原创视频短片(不少于60秒)。该视频至少包括:字幕、音乐或音效、一段同期声采访(或现场声)等要素。(30分)',
key: 'video',
isVideo: true,
},
],
},
flow: {
steps: [
{
name: 1,
label: '第一步',
title: '上述全媒体综合文稿拟分发的媒体平台并逐一说明理由(10分)',
key: 'platforms',
},
{
name: 2,
label: '第二步',
title: '流量运营及直播运营的预期成效分析(5分)',
key: 'traffic',
},
{
name: 3,
label: '第三步',
title: '运营风险管控解析(5分)',
key: 'risk',
},
],
},
}
const form = reactive({ plan: {}, audiovisual: {}, flow: {} })
const fetchInfo = async () => {
const res = await getOperations()
const detail = res.data.detail
try {
const parsed = JSON.parse(detail.content)
Object.assign(form, parsed)
} catch (error) {
console.error(error)
}
}
onMounted(fetchInfo)
const handleSave = async () => {
await saveOperations({ content: JSON.stringify(form) })
ElMessage.success('保存成功')
}
</script>
<template>
<AppCard>
<Steps v-model:form="form[type]" :steps="typeMap[type].steps" @save="handleSave" />
</AppCard>
</template>
...@@ -397,6 +397,17 @@ const adminMenus: IMenuItem[] = [ ...@@ -397,6 +397,17 @@ const adminMenus: IMenuItem[] = [
{ id: 30, name: '营销分析', path: '/analyze/marketing' }, { id: 30, name: '营销分析', path: '/analyze/marketing' },
], ],
}, },
{
id: 40,
name: '全媒体运营',
path: '/operations',
icon: markRaw(IconMarket),
children: [
{ id: 401, name: '创意策划方案', path: '/operations/plan' },
{ id: 402, name: '视听运营', path: '/operations/audiovisual' },
{ id: 403, name: '流量运营', path: '/operations/flow' },
],
},
] ]
export const useMenuStore = defineStore({ export const useMenuStore = defineStore({
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论