洞察报告;

This commit is contained in:
钱冠学
2024-08-27 17:39:22 +08:00
parent d9a4eab702
commit fd5face9b6
27 changed files with 805 additions and 171 deletions

View File

@@ -22,7 +22,7 @@ npm config set registry %customizeRegistry% && ^
echo ====clear npm cache==== && ^ echo ====clear npm cache==== && ^
npm cache clear --force && ^ npm cache clear --force && ^
echo ====install dependency package==== && ^ echo ====install dependency package==== && ^
npm i --force && ^ npm ci --force && ^
echo ====finished install, switch back to original npm registry==== && ^ echo ====finished install, switch back to original npm registry==== && ^
npm config set registry %originalRegistry% && ^ npm config set registry %originalRegistry% && ^
echo ====finish and exit==== && ^ echo ====finish and exit==== && ^

View File

@@ -38,6 +38,7 @@ function json_to_array(key, jsonData) {
* @param data Array表体数据 * @param data Array表体数据
* @param key Array字段名 * @param key Array字段名
* @param title String标题会居中显示即excel表格第一行 * @param title String标题会居中显示即excel表格第一行
* @param sheetName
* @param filename String文件名 * @param filename String文件名
* @param autoWidth Boolean是否自动根据key自定义列宽度 * @param autoWidth Boolean是否自动根据key自定义列宽度
*/ */
@@ -46,6 +47,7 @@ export const exportJsonToExcel = ({
data, data,
key, key,
title, title,
sheetName,
filename, filename,
autoWidth autoWidth
}) => { }) => {
@@ -64,7 +66,7 @@ export const exportJsonToExcel = ({
const arr = json_to_array(key, data) const arr = json_to_array(key, data)
auto_width(ws, arr) auto_width(ws, arr)
} }
XLSX.utils.book_append_sheet(wb, ws, filename) XLSX.utils.book_append_sheet(wb, ws, sheetName)
XLSX.writeFile(wb, filename + '.xlsx') XLSX.writeFile(wb, filename + '.xlsx')
} }
export default { export default {

View File

@@ -5,6 +5,7 @@ import TemplateMarket from './route.templateMarket'
import Contact from './route.contact' import Contact from './route.contact'
import DocumentLibrary from './route.documentLibrary' import DocumentLibrary from './route.documentLibrary'
import DataStatistics from './route.datastatistics' import DataStatistics from './route.datastatistics'
import SharedRoutes from './route.shared'
import { jsonp } from "vue-jsonp"; import { jsonp } from "vue-jsonp";
import { jsonpUrl } from "../config.js"; import { jsonpUrl } from "../config.js";
import { useStore } from "vuex"; import { useStore } from "vuex";
@@ -24,6 +25,7 @@ const constantRoutes = [
// } // }
// ] // ]
// }, // },
...SharedRoutes,
{ {
path: '/:catchAll(.*)', path: '/:catchAll(.*)',
redirect: '/error/404', redirect: '/error/404',
@@ -471,7 +473,7 @@ const router = createRouter({
}) })
router.beforeEach(async (to, from, next) => { router.beforeEach(async (to, from, next) => {
// token // token
if (!router.options.history.state.back) { if (!to.meta.shared && !router.options.history.state.back) {
await getToken(); await getToken();
} }
if (!to.meta.noRedirectLogin) { if (!to.meta.noRedirectLogin) {

View File

@@ -0,0 +1,20 @@
const SharedIndex = () => import(/* webpackChunkName: 'shared' */ '@views/Shared/Index.vue')
const SharedInsightReport = () => import(/* webpackChunkName: 'shared' */ '@views/DataAnalyse/insight/SharedInsightReport.vue')
const routes = [
{
path: '/shared',
name: 'Shared',
meta: { title: '分享', shared: true },
component: SharedIndex,
children: [
{
path: 'insight/:secret',
name: 'SharedInsight',
component: SharedInsightReport
}
]
}
]
export default routes

View File

@@ -10,7 +10,7 @@ import InsightShare from './components/InsightShare.vue'
import Report from './report/Report.vue' import Report from './report/Report.vue'
import { getInsightReport } from './api' import { getInsightReport, updateInsightReport } from './api'
const route = useRoute() const route = useRoute()
@@ -53,25 +53,53 @@ function onGenerateReport(isInit) {
} }
} }
function onConfirmGenerateReport(isInit) { async function onConfirmGenerateReport(isInit) {
if(loading.value) {
return
}
loading.value = true
// todo // todo
const params = {}
const data = await updateInsightReport(params)
loading.value = false
} }
</script> </script>
<template> <template>
<div class="insight-page"> <a-spin :spinning="loading" tip="加载中" class="spinning">
<InsightEmpty v-if="false" @generate="onGenerateReport" /> <div v-if="!loading" class="insight-page">
<InsightEmpty v-if="!report?.id" @generate="onGenerateReport" />
<InsightShare @regenerate="onGenerateReport" /> <template v-if="report?.id">
<InsightShare :report="report" @regenerate="onGenerateReport" />
<Report :report="report" /> <Report :report="report" />
</template>
</div> </div>
</a-spin>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.insight-page { .spinning {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%; width: 100%;
height: 100%; height: 100%;
:deep(.ant-spin-text) {
margin-top: 12px;
}
}
.insight-page {
display: block;
width: 100%;
min-height: 100%;
padding: 24px; padding: 24px;
} }
</style> </style>

View File

@@ -1,11 +1,100 @@
<script setup> <script setup>
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import SharedAccess from './components/SharedAccess.vue'
import SharedExpired from './components/SharedExpired.vue'
import Report from './report/Report.vue'
import { getInsightReportBySecret, getInsightReportInfoSecret } from './api'
document.title = '伊调研|问卷'
const route = useRoute()
const secret = route.params.secret || ''
const loading = ref(false)
const isExpired = ref(false) // 默认 false
const isPassword = ref(true) // 默认 true
const accessRef = ref(null)
const report = ref({})
// getReportInfo()
async function getReportInfo() {
if(loading.value) {
return
}
loading.value = true
const params = { secret }
const data = await getInsightReportInfoSecret(params)
loading.value = false
}
async function getReport(password) {
if(loading.value) {
return
}
loading.value = true
const params = { password, secret }
const data = await getInsightReportBySecret(params)
loading.value = false
}
</script> </script>
<template> <template>
<div></div> <div class="shared-insight-report">
<SharedExpired v-if="!isExpired" />
<SharedAccess v-else-if="isPassword" ref="accessRef" @password="getReport" />
<div v-else class="wrapper">
<Report :readonly="true" />
</div>
<a-spin v-if="loading" :spinning="loading" tip="加载中" class="spinning" />
</div>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.spinning {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 999;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.8);
:deep(.ant-spin-text) {
margin-top: 12px;
}
}
.shared-insight-report {
overflow-y: auto;
position: relative;
width: 100vw;
height: 100vh;
}
.wrapper {
width: 1280px;
margin: 0 auto;
padding: 24px;
}
</style> </style>

View File

@@ -1,7 +1,25 @@
import request from '@/utils/request' import request from '@/utils/request'
/**
* 新增/修改洞察报告详情
* @param data
* @returns {*}
*/
export function updateInsightReport(data) {
return request({
url: `/console/insightReport`,
method: 'post',
data
})
}
/**
* 获取洞察报告详情
* @param params
* @returns {*}
*/
export function getInsightReport(params) { export function getInsightReport(params) {
return request({ return request({
url: `/console/insightReport/${ params.sn }`, url: `/console/insightReport/${ params.sn }`,
@@ -11,3 +29,46 @@ export function getInsightReport(params) {
} }
/**
* 分享洞察报告
* @param data
* @returns {*}
*/
export function shareReport(data) {
return request({
url: `/console/shareReport`,
method: 'post',
data
})
}
/**
* 获取洞察报告基本信息
* @param params
* @returns {*}
*/
export function getInsightReportInfoSecret(params) {
return request({
url: `/console/insightReport/${ params.sn }`,
method: 'get',
params
})
}
/**
* 获取洞察报告详情 secret
* @param params
* @returns {*}
*/
export function getInsightReportBySecret(params) {
return request({
url: `/console/insightReport/${ params.sn }`,
method: 'get',
params
})
}

View File

@@ -1,13 +1,29 @@
<script setup> <script setup>
import { defineEmits, ref } from 'vue' import { defineEmits, defineProps, ref } from 'vue'
import { message } from 'ant-design-vue'
import { InfoCircleOutlined } from '@ant-design/icons-vue' import { InfoCircleOutlined } from '@ant-design/icons-vue'
import { shareReport } from '../api'
const emits = defineEmits(['regenerate']) const emits = defineEmits(['regenerate'])
const props = defineProps({
report: { type: Object, default: () => Object.assign({}) }
})
const loading = ref(false) const loading = ref(false)
const editable = ref(false) const editable = ref(false)
const password = ref('') // 密码 const password = ref(props.report.password || getRandomStr(4) || '') // 密码
const expiredForDays = ref('0') // 有效期天数 const expiredForDays = ref(+props.report.expireAt || 0) // 有效期天数
function getRandomStr(bit) {
const random = '1234567890qwertyuiopasdfghjklzxcvbnmQWERTYUIOPSDFGHJKLZXCVBNM'
let str = ''
for(let i = 0; i < bit; i += 1) {
str += random[Math.floor(Math.random() * random.length)]
}
return str
}
function onEdit(type) { function onEdit(type) {
if(loading.value) { if(loading.value) {
@@ -24,8 +40,34 @@ function getPopupContainer(el) {
return el?.parentNode || document.body return el?.parentNode || document.body
} }
function onCopy() { async function onCopy() {
// todo if(loading.value) {
return
}
loading.value = true
const params = {
id: props.report.id || '',
passWord: password.value || '',
expireDay: expiredForDays.value ?? 0
}
const data = await shareReport(params)
console.log('data', data)
loading.value = false
copyText()
}
function copyText(text) {
const input = document.createElement('input')
input.value = text || ''
document.body.appendChild(input)
input.select()
document.execCommand('Copy')
document.body.removeChild(input)
message.success('复制成功')
} }
function onUpdateReport() { function onUpdateReport() {
@@ -65,10 +107,10 @@ function onUpdateReport() {
<div class="row"> <div class="row">
<span class="label">链接有效期</span> <span class="label">链接有效期</span>
<a-radio-group v-model:value="expiredForDays" :disabled="loading"> <a-radio-group v-model:value="expiredForDays" :disabled="loading">
<a-radio value="0">永久</a-radio> <a-radio :value="0">永久</a-radio>
<a-radio value="30">30</a-radio> <a-radio :value="30">30</a-radio>
<a-radio value="7">7</a-radio> <a-radio :value="7">7</a-radio>
<a-radio value="1">1</a-radio> <a-radio :value="1">1</a-radio>
</a-radio-group> </a-radio-group>
</div> </div>
<div class="row flex-end"> <div class="row flex-end">

View File

@@ -0,0 +1,102 @@
<script setup>
import { defineEmits, defineExpose, reactive, ref } from 'vue'
import { message } from 'ant-design-vue'
const emits = defineEmits(['password'])
const loading = ref(false)
const formRef = ref(null)
const formState = reactive({ password: '' })
const rules = {
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ pattern: /^[0-9a-zA-Z]{4,4}$/g, message: '请输入4位数字或字母', trigger: 'blur' }
]
}
function onSubmit() {
if(loading.value) {
return
}
loading.value = true
formRef.value.validate().then(() => {
emits('password', formState.password)
}).catch((error) => {
loading.value = false
message.warning(error.errorFields.map((err) => err.errors?.join?.('、')).join('、'))
})
}
function reset() {
loading.value = false
}
defineExpose({ reset })
</script>
<template>
<div class="shared-access">
<img src="../img/icon_lock.png" alt="" class="icon">
<a-form ref="formRef"
:model="formState"
:rules="rules"
layout="vertical"
class="form">
<a-form-item label="请输入密码访问" name="password">
<a-input v-model:value="formState.password"
:disabled="loading"
class="custom-input"
placeholder="请输入密码" />
</a-form-item>
<a-form-item class="no-margin">
<a-button type="primary"
:loading="loading"
block
class="custom-button"
@click="onSubmit()">
确定
</a-button>
</a-form-item>
</a-form>
</div>
</template>
<style scoped lang="scss">
.shared-access {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
.icon {
display: block;
width: 315px;
height: 180px;
margin-bottom: 10px;
}
.form {
width: 358px;
padding: 20px 22px 20px 42px;
border-radius: 10px;
background: #FAFAFA;
:deep(.ant-form-item-required)::before {
display: none !important;
}
.no-margin {
margin: 0;
}
}
</style>

View File

@@ -0,0 +1,38 @@
<script setup>
</script>
<template>
<div class="shared-expired">
<img src="../img/icon_expire.png" alt="" class="icon">
<div class="message">抱歉您访问的链接已过期</div>
</div>
</template>
<style scoped lang="scss">
.shared-expired {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
.icon {
display: block;
width: 554px;
height: 307px;
margin: 0 auto 32px;
}
.message {
line-height: 28px;
text-align: center;
font-family: "Alibaba PuHuiTi 3.0", sans-serif;
font-size: 20px;
font-weight: normal;
color: #8C8C8C;
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -36,6 +36,7 @@ const readonly = computed(() => props.readonly || false)
<style scoped lang="scss"> <style scoped lang="scss">
.insight-report { .insight-report {
width: 100%; width: 100%;
padding-bottom: 28px;
font-family: "Alibaba PuHuiTi 3.0", sans-serif; font-family: "Alibaba PuHuiTi 3.0", sans-serif;
} }

View File

@@ -4,7 +4,8 @@ import { computed, defineEmits, defineProps, ref } from 'vue'
const emits = defineEmits(['active']) const emits = defineEmits(['active'])
const props = defineProps({ const props = defineProps({
columns: { type: Array, default: () => [] }, columns: { type: Array, default: () => [] },
dataSource: { type: Array, default: () => [] } dataSource: { type: Array, default: () => [] },
rowSelectable: { type: Boolean, default: false }
}) })
const page = ref(1) const page = ref(1)
@@ -33,6 +34,10 @@ function customRow(record = {}) {
} }
function onActiveRow(record) { function onActiveRow(record) {
if (!props.rowSelectable) {
return
}
activatedRecord.value = record activatedRecord.value = record
emits('active', record) emits('active', record)
} }

View File

@@ -4,23 +4,96 @@ import { computed, defineProps, ref, watch } from 'vue'
const props = defineProps({ const props = defineProps({
data: { type: Object, default: () => Object.assign({}) } data: { type: Object, default: () => Object.assign({}) },
barTable: { type: Boolean, default: false },
standardStyle: {
type: Object,
default: () => Object.assign({
groupColor: '#FFAA00',
groupBackgroundColor: 'rgba(255, 178, 0, 0.2)',
columnColor: '#434343',
columnBackgroundColor: 'rgba(255, 178, 0, 0.05)'
})
}
}) })
const columns = computed(() => (props.data.headerVOS || []).map((column, columnIndex) => { const tableData = ref([])
const headers = computed(() => {
const list = JSON.parse(JSON.stringify(props.data.headerVOS || []))
const rowTitle = list[0]
// 判断是否需要拆分列。(表格内容的第一列文字,是否包含下划线,根据下划线拆分列)
if(!tableData.value.some((data) => {
return data[rowTitle.dataIndex].indexOf('_') > -1
})) {
// 不需要拆分列,返回当前配置即可
return list
}
/*
* 把含有下划线的文字拆分为两列显示,并且合并列的表头。并且合并显示具有相同文字的连续的行。
* 例: 把 “女_非常喜欢+比较喜欢【TOP2】” 拆分未 “女” 和 “非常喜欢+比较喜欢【TOP2】” 两列
*/
// 拆分出来的一列
const addedFirstRow = {
key: rowTitle.key,
dataIndex: rowTitle.dataIndex,
title: rowTitle.title,
colSpan: 2, // 合并并显示第一列
customRender: ({ text, index }) => {
let idx = index
const rowSpan = text?.split?.('_')[0] === tableData.value[index - 1]?.[rowTitle.dataIndex]?.split?.('_')?.[0] ? 0 : tableData.value.filter((record, recordIndex) => {
if(idx === recordIndex && text?.split?.('_')[0] === record[rowTitle.dataIndex]?.split?.('_')?.[0]) {
idx++
return true
}
return false
}).length
return {
children: text.split('_')[0],
props: {
colSpan: text.split('_').length > 1 ? 1 : 2, // 由于拆分为两列,所以需要合并没有拆分的列
rowSpan // 合并连续行
}
}
}
}
rowTitle.colSpan = 0 // 合并并隐藏第二列
rowTitle.customRender = ({ text, index }) => {
return {
children: text.split('_')[1],
props: {
colSpan: Math.max(0, text.split('_').length - 1)
}
}
}
return [
addedFirstRow,
...list
]
})
const columns = computed(() => headers.value.map((column, columnIndex) => {
column.align = 'center' column.align = 'center'
column.slots = { customRender: column.dataIndex } column.slots = { customRender: column.dataIndex }
column.customHeaderCell = function() { column.customHeaderCell = function() {
const style = {} const style = {}
if(columnIndex === 1) { // 新品表现,一级表头 if(column.title === '新品表现') { // 新品表现,一级表头
style.color = '#70B936' style.color = '#70B936'
style.backgroundColor = 'rgba(112, 185, 54, 0.2)' style.backgroundColor = 'rgba(112, 185, 54, 0.2)'
} }
if(columnIndex === 2) { // 标杆表现,一级表头 if(column.title === '标杆表现') { // 标杆表现,一级表头
style.color = '#FFAA00' style.color = props.standardStyle.groupColor
style.backgroundColor = 'rgba(255, 178, 0, 0.2)' style.backgroundColor = props.standardStyle.groupBackgroundColor
} }
// 表头(第一行)显示不同的文字颜色及背景色
return { return {
style style
} }
@@ -30,33 +103,38 @@ const columns = computed(() => (props.data.headerVOS || []).map((column, columnI
column.children.forEach((child) => { column.children.forEach((child) => {
child.align = 'center' child.align = 'center'
child.slots = { customRender: child.dataIndex } child.slots = { customRender: child.dataIndex }
child.parentTitle = column.title
child.width = '120px'
child.customHeaderCell = function() { child.customHeaderCell = function() {
const style = {} const style = {}
if(columnIndex === 1) { // 新品表现,二级表头 if(column.title === '新品表现') { // 新品表现,二级表头
style.color = '#434343' style.color = '#434343'
style.backgroundColor = 'rgba(112, 185, 54, 0.05)' style.backgroundColor = 'rgba(112, 185, 54, 0.05)'
} }
if(columnIndex === 2) { // 标杆表现,二级表头 if(column.title === '标杆表现') { // 标杆表现,二级表头
style.color = '#434343' style.color = props.standardStyle.columnColor
style.backgroundColor = 'rgba(255, 178, 0, 0.05)' style.backgroundColor = props.standardStyle.columnBackgroundColor
} }
// 表头(第二行)显示不同的文字颜色及背景色
return { return {
style style
} }
} }
child.customCell = function(record, rowIndex) { child.customCell = function(record, rowIndex) {
const rowClass = rowClassName(record, rowIndex)
const style = {} const style = {}
if(columnIndex === 1) { // 新品表现,内容 if(column.title === '新品表现') { // 新品表现,内容
style.color = '#434343' style.color = rowClass === 'gray-row' ? '#818181' : '#434343'
style.backgroundColor = 'rgba(112, 185, 54, 0.05)' style.backgroundColor = 'rgba(112, 185, 54, 0.05)'
} }
if(columnIndex === 2) { // 标杆表现,内容 if(column.title === '标杆表现') { // 标杆表现,内容
style.color = '#434343' style.color = rowClass === 'gray-row' ? '#818181' : props.standardStyle.columnColor
style.backgroundColor = 'rgba(255, 178, 0, 0.05)' style.backgroundColor = props.standardStyle.columnBackgroundColor
} }
// 表格内容显示不同的文字颜色及背景色
return { return {
style style
} }
@@ -65,24 +143,113 @@ const columns = computed(() => (props.data.headerVOS || []).map((column, columnI
} }
return column return column
}).filter((column) => {
// 过滤掉空列('新品表现' 和 '标杆表现',只有第一列表头但是没有第二列表头的情况,表格内容为空,隐藏掉)
return !['新品表现', '标杆表现'].includes(column.title) || column?.children?.length
})) }))
const flatColumns = computed(() => columns.value.flatMap((column) => column.children?.length ? column.children : column)) const flatColumns = computed(() => columns.value.flatMap((column) => column.children?.length ? column.children : column))
const tableData = ref([])
const barMax = ref(0)
const barMin = ref(0)
watch(() => props.data, () => { watch(() => props.data, () => {
tableData.value = props.data.dataVOS || [] tableData.value = props.data.dataVOS || []
const list = JSON.parse(JSON.stringify(props.data.dataVOS || []))
const tableList = []
const rowTitle = props.data.headerVOS?.[0]
if(rowTitle) {
// 重排序, 把具有相同开头文字用下划线区分“开头文字_xxxxx”的放到一起便于后续表格跨行合并。
// 例“女_样本基数”和“女_非常喜欢+比较喜欢【TOP2】”
for(let i = 0; i < list.length; i += 1) {
const row = list[i]
tableList.push(row)
const titleArr = row[rowTitle.dataIndex]?.split?.('_')
if(titleArr?.length > 1) {
for(let j = i + 1; j < list.length; j += 1) {
if(list[j][rowTitle.dataIndex]?.split?.('_')?.[0] === titleArr[0]) {
tableList.push(list[j])
list.splice(j, 1)
}
}
}
}
tableData.value = tableList
} else {
tableData.value = list
}
}, { immediate: true, deep: true }) }, { immediate: true, deep: true })
watch([flatColumns, tableData], () => {
const numericalColumns = flatColumns.value.map((key) => key.dataIndex).slice(1)
tableData.value.forEach((record) => {
if(['概念编码', '样本基数'].includes(record[headers.value[0]?.dataIndex])) {
return
}
// 计算条形图的最大值
barMax.value = Math.max(barMax.value, ...numericalColumns.map((key) => +record[key]).filter((value) => !isNaN(value)))
barMin.value = Math.min(barMin.value, ...numericalColumns.map((key) => +record[key]).filter((value) => !isNaN(value)))
})
}, { deep: true, immediate: true })
const barStyle = computed(() => (record, column) => {
const value = record[column.dataIndex] ? record[column.dataIndex] : 0
const width = (+value * 100 / barMax.value) + '%'
// 计算条形图的长度,根据最大值计算百分比即可
return {
width,
backgroundColor: column.parentTitle === '新品表现' ? '#70B936' : '#FFB200'
}
})
function rowClassName(record) {
// 表格中 '概念编码' 和 '样本基数' 这两行要显示灰色
return Object.keys(record).some((key) => ['概念编码', '样本基数'].some((item) => record[key]?.indexOf?.(item) > -1)) ? 'gray-row' : ''
}
</script> </script>
<template> <template>
<div class="styled-table-wrapper"> <div class="styled-table-wrapper" :class="{'bar-table': props.barTable}">
<a-table :columns="columns" :data-source="tableData" bordered :pagination="false" class="table"> <a-table :columns="columns" :data-source="tableData" bordered :pagination="false"
<template v-for="(column) in flatColumns" :row-class-name="rowClassName" class="table">
<template v-for="(column, columnIndex) in flatColumns"
:key="column.dataIndex" :key="column.dataIndex"
#[column.dataIndex]="{text, record}"> #[column.dataIndex]="{record}">
<template v-if="true">{{ text }}</template> <template v-if="props.barTable" data-desc="显示条形图">
<template v-if="false">{{ record[column.dataIndex] }}</template> <span
v-if="[0].includes(columnIndex) || ['概念编码', '样本基数'].includes(record[headers[0]?.dataIndex])">
{{ record[column.dataIndex] }}
</span>
<div v-else class="cell-bar" :style="barStyle(record, column)">
<span class="text">{{ record[column.dataIndex] }}</span>
<span class="danger-text">{{ record[column.dataIndex + 'Type'] || '' }}</span>
</div>
</template>
<template v-else-if="record[headers[0].dataIndex] === '是否通过概念行动标准'"
data-desc="显示是否选择框">
<span v-if="record[column.dataIndex] === '是否通过概念行动标准'">
{{ record[column.dataIndex] }}
</span>
<a-select v-else placeholder="请选择" class="custom-select" style="width: 90px;">
<a-select-option key="1" value="1">
<span style="color: #70B936;"></span>
</a-select-option>
<a-select-option key="0" value="0">
<span style="color: #FC4545;"></span>
</a-select-option>
</a-select>
</template>
<template v-else data-desc="显示文字">
<span>{{ record[column.dataIndex] }}</span>
<span class="danger-text">{{ record[column.dataIndex + 'Type'] || '' }}</span>
</template>
</template> </template>
</a-table> </a-table>
</div> </div>
@@ -92,6 +259,32 @@ watch(() => props.data, () => {
.styled-table-wrapper { .styled-table-wrapper {
$radius: 6px; $radius: 6px;
.cell-bar {
min-width: 1px;
height: 20px;
line-height: 20px;
white-space: nowrap;
padding: 0;
border-radius: 4px;
text-align: right;
color: #FFFFFF;
.text {
padding-left: 5px;
}
.danger-text {
padding-right: 5px;
}
}
&.bar-table :deep(.ant-table-wrapper) {
.ant-table-tbody > tr > td {
padding-left: 4px;
padding-right: 4px;
}
}
:deep(.ant-table-wrapper) { :deep(.ant-table-wrapper) {
.ant-table-thead > tr > th, .ant-table-tbody > tr > td { .ant-table-thead > tr > th, .ant-table-tbody > tr > td {
padding-top: 10px; padding-top: 10px;
@@ -110,6 +303,16 @@ watch(() => props.data, () => {
.ant-table-thead > tr:first-child > th:last-child { .ant-table-thead > tr:first-child > th:last-child {
border-top-right-radius: $radius; border-top-right-radius: $radius;
} }
.ant-table-body tr.gray-row td {
&:not([rowspan="2"]) {
color: #818181;
}
}
.danger-text {
color: #FC4545;
}
} }
} }
</style> </style>

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { defineProps } from 'vue' import { defineProps, ref, watch } from 'vue'
import Tinymce from '@/components/Tinymce.vue' import Tinymce from '@/components/Tinymce.vue'
@@ -13,12 +13,19 @@ const props = defineProps({
}) })
const richText = ref('')
watch(() => props.report.coreConclusion, (val) => {
richText.value = val || ''
})
</script> </script>
<template> <template>
<SectionTitle>核心结论</SectionTitle> <SectionTitle>核心结论</SectionTitle>
<Section class="section"> <Section class="section">
<Tinymce :curtail="false" <Tinymce v-model:editorData="richText"
:curtail="false"
:curtail-min-height="140" :curtail-min-height="140"
show show
:open3-d-icon="false" :open3-d-icon="false"

View File

@@ -18,55 +18,66 @@ const activeKey = ref('0')
const tabList = ref([]) const tabList = ref([])
watch(() => props.report, () => { watch(() => props.report, () => {
const total = props.report?.chatVOS?.find?.((data) => data.type.startsWith('决策指标-总体'))
const gender = props.report?.chatVOS?.find?.((data) => data.type.startsWith('决策指标-性别'))
const age = props.report?.chatVOS?.find?.((data) => data.type.startsWith('决策指标-年龄'))
const income = props.report?.chatVOS?.find?.((data) => data.type.startsWith('决策指标-家庭月收入'))
const city = props.report?.chatVOS?.find?.((data) => data.type.startsWith('决策指标-城市级别'))
tabList.value = [ tabList.value = [
{ {
key: '0', key: '0',
name: '总体', name: getTableTypeStr(total),
canHide: false, canHide: false,
visible: true, visible: true,
tableData: props.report?.chatVOS?.[0] || {}, tableData: total,
codeStr: getTableCodeRow(props.report?.chatVOS?.[0] || {}) codeStr: getTableCodeRow(total)
}, },
{ {
key: '1', key: '1',
name: '性别', name: getTableTypeStr(gender),
canHide: true, canHide: true,
visible: true, visible: true,
tableData: props.report?.chatVOS?.[1] || {}, tableData: gender,
codeStr: getTableCodeRow(props.report?.chatVOS?.[1] || {}) codeStr: getTableCodeRow(gender)
}, },
{ {
key: '2', key: '2',
name: '年龄段', name: getTableTypeStr(age),
canHide: true, canHide: true,
visible: true, visible: true,
tableData: props.report?.chatVOS?.[2] || {}, tableData: age,
codeStr: getTableCodeRow(props.report?.chatVOS?.[2] || {}) codeStr: getTableCodeRow(age)
}, },
{ {
key: '3', key: '3',
name: '家庭月收入', name: getTableTypeStr(income),
canHide: true, canHide: true,
visible: true, visible: true,
tableData: props.report?.chatVOS?.[3] || {}, tableData: income,
codeStr: getTableCodeRow(props.report?.chatVOS?.[3] || {}) codeStr: getTableCodeRow(income)
}, },
{ {
key: '4', key: '4',
name: '城市级别', name: getTableTypeStr(city),
canHide: true, canHide: true,
visible: true, visible: true,
tableData: props.report?.chatVOS?.[4] || {}, tableData: city,
codeStr: getTableCodeRow(props.report?.chatVOS?.[4] || {}) codeStr: getTableCodeRow(city)
} }
] ].filter((item) => !!item.tableData)
}, { immediate: true }) }, { immediate: true })
function getTableTypeStr(tableData) {
return tableData?.type?.split('-')?.[1] || ''
}
function getTableCodeRow(tableData) { function getTableCodeRow(tableData) {
const codeRow = tableData?.dataVOS?.find?.((row) => Object.keys(row).find((key) => row[key] === '概念编码')) if(!tableData?.dataVOS?.length) {
return ''
}
const codeRow = tableData.dataVOS.find((row) => Object.keys(row).find((key) => row[key] === '概念编码'))
if(!codeRow) { if(!codeRow) {
return '' return ''
} }

View File

@@ -11,11 +11,14 @@ const props = defineProps({
readonly: { type: Boolean, default: false } readonly: { type: Boolean, default: false }
}) })
const tableData = computed(() => []) const tableData = computed(() => props.report?.chatVOS?.find?.((data) => data.type === '其他关键指标-喜欢程度') || {})
const codeStr = computed(() => getTableCodeRow(tableData.value?.chatVOS?.[4] || {})) const codeStr = computed(() => getTableCodeRow(tableData.value))
function getTableCodeRow(tableData) { function getTableCodeRow(tableData) {
const codeRow = tableData?.dataVOS?.find?.((row) => Object.keys(row).find((key) => row[key] === '概念编码')) if(!tableData?.dataVOS?.length) {
return ''
}
const codeRow = tableData.dataVOS.find((row) => Object.keys(row).find((key) => row[key] === '概念编码'))
if(!codeRow) { if(!codeRow) {
return '' return ''
} }
@@ -38,6 +41,7 @@ function getTableCodeRow(tableData) {
<style scoped lang="scss"> <style scoped lang="scss">
.message { .message {
padding-top: 10px;
font-size: 14px; font-size: 14px;
font-weight: normal; font-weight: normal;
color: #434343; color: #434343;

View File

@@ -1,8 +1,8 @@
<script setup> <script setup>
import { computed, defineProps } from 'vue' import { computed, defineProps, ref } from 'vue'
import moment from 'moment' import moment from 'moment'
import { EyeInvisibleOutlined } from '@ant-design/icons-vue' import { EyeOutlined, EyeInvisibleOutlined } from '@ant-design/icons-vue'
import SectionTitle from '../../components/SectionTitle.vue' import SectionTitle from '../../components/SectionTitle.vue'
@@ -17,14 +17,16 @@ const props = defineProps({
const createdAt = computed(() => props.report?.createdAt ? moment(props.report.createdAt).format('YYYY年MM月DD日 HH:mm:ss') : '--') const createdAt = computed(() => props.report?.createdAt ? moment(props.report.createdAt).format('YYYY年MM月DD日 HH:mm:ss') : '--')
const completeAt = computed(() => props.report?.completeAt ? moment(props.report.completeAt).format('YYYY年MM月DD日 HH:mm:ss') : '--') const completeAt = computed(() => props.report?.completeAt ? moment(props.report.completeAt).format('YYYY年MM月DD日 HH:mm:ss') : '--')
const statusStr = computed(() => props.report?.status ?? '--') const statusStr = computed(() => ['未生成', '生成中', '已生成'][props.report?.status] ?? '--')
const sampleNum = computed(() => props.report?.sampleNum ?? '--') const sampleNum = computed(() => props.report?.sampleNum ?? '--')
const surveyVersion = computed(() => props.report?.surveyVersion ?? '--') const surveyVersion = computed(() => props.report?.surveyVersion ?? '--')
const reportVersion = computed(() => props.report?.reportVersion ?? '--') const reportVersion = computed(() => props.report?.reportVersion ?? '--')
function toggleVisibility() { const visible = ref(false)
function toggleVisibility() {
visible.value = !visible.value
} }
</script> </script>
@@ -32,7 +34,8 @@ function toggleVisibility() {
<div class="insight-overview"> <div class="insight-overview">
<SectionTitle> <SectionTitle>
<span class="text">报告概览</span> <span class="text">报告概览</span>
<EyeInvisibleOutlined class="icon" @click="toggleVisibility" /> <EyeOutlined v-if="visible" class="icon" @click="toggleVisibility" />
<EyeInvisibleOutlined v-else class="icon" @click.stop="toggleVisibility" />
</SectionTitle> </SectionTitle>
<Section class="section"> <Section class="section">

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { defineProps } from 'vue' import { defineProps, ref, watch } from 'vue'
import Tinymce from '@/components/Tinymce.vue' import Tinymce from '@/components/Tinymce.vue'
@@ -10,6 +10,14 @@ const props = defineProps({
report: { type: Object, default: () => Object.assign({}) }, report: { type: Object, default: () => Object.assign({}) },
readonly: { type: Boolean, default: false } readonly: { type: Boolean, default: false }
}) })
const richText = ref('')
watch(() => props.report.decisionIndicators, (val) => {
richText.value = val || ''
})
</script> </script>
<template> <template>
@@ -22,7 +30,8 @@ const props = defineProps({
<div class="row"> <div class="row">
<div class="label">决策标准</div> <div class="label">决策标准</div>
<div class="value tinymce-wrapper"> <div class="value tinymce-wrapper">
<Tinymce :curtail="false" <Tinymce v-model:editorData="richText"
:curtail="false"
:curtail-min-height="140" :curtail-min-height="140"
show show
:open3-d-icon="false" :open3-d-icon="false"

View File

@@ -14,57 +14,26 @@ const props = defineProps({
const conceptTypeEnum = { const conceptTypeEnum = {
newest: 1, newest: 1,
standard: 2, standard: 0,
1: 'newest', 1: 'newest',
2: 'standard' 0: 'standard'
} }
const list = computed(() => props.data?.list || [ const list = computed(() => props.report?.config || [
{ // {
id: 1, // id: 41,
name: '零食跨界-屏幕最佳搭档', // concept_name: '极致健康-一站式早餐解决方案',
img: '', // concept_url: 'https://test-cxp-public-web-1302259445.cos.ap-beijing.myqcloud.com/uat-yls/theme/undefined/1724639839680_902_%E6%B0%B4%E5%BA%93.jpg',
type: conceptTypeEnum.newest // concept_type: conceptTypeEnum.newest
}, // },
{ // {
id: 2, // id: 3,
name: '极致颜值-满杯布灵', // concept_name: '标杆概念-馋酸奶',
img: '', // concept_url: 'https://test-cxp-public-web-1302259445.cos.ap-beijing.myqcloud.com/uat-yls/theme/undefined/1724639839680_902_%E6%B0%B4%E5%BA%93.jpg',
type: conceptTypeEnum.newest // concept_type: conceptTypeEnum.standard
}, // },
{
id: 4,
name: '极致健康-一站式早餐解决方案',
img: '',
type: conceptTypeEnum.newest
},
{
id: 11,
name: '零食跨界-屏幕最佳搭档',
img: '',
type: conceptTypeEnum.newest
},
{
id: 21,
name: '极致颜值-满杯布灵',
img: '',
type: conceptTypeEnum.newest
},
{
id: 41,
name: '极致健康-一站式早餐解决方案',
img: '',
type: conceptTypeEnum.newest
},
{
id: 3,
name: '标杆概念-馋酸奶',
img: '',
type: conceptTypeEnum.standard
},
]) ])
</script> </script>
@@ -76,13 +45,13 @@ const list = computed(() => props.data?.list || [
<div v-for="(item) in list" <div v-for="(item) in list"
:key="item.id" :key="item.id"
class="item" class="item"
:class="{[conceptTypeEnum[item.type]]: true}"> :class="{[conceptTypeEnum[item.concept_type]]: true}">
<div class="name"> <div class="name">
<div class="text">{{ item.name || '' }}</div> <div class="text">{{ item.concept_name || '' }}</div>
</div> </div>
<img v-if="item.img" :src="item.img" alt="" class="img"> <img v-if="item.concept_url" :src="item.concept_url" alt="" class="img">
</div> </div>
</div> </div>
</Section> </Section>
@@ -103,6 +72,7 @@ const list = computed(() => props.data?.list || [
} }
.item { .item {
overflow: hidden;
flex: none; flex: none;
width: 198px; width: 198px;
height: 152px; height: 152px;

View File

@@ -71,7 +71,7 @@ watch(() => props.report, () => {
<div class="tab-container"> <div class="tab-container">
<PeopleLike /> <PeopleLike />
<PeopleDislike /> <PeopleDislike />
<ProductImage /> <ProductImage :report="props.report"/>
</div> </div>
</a-tab-pane> </a-tab-pane>
</a-tabs> </a-tabs>

View File

@@ -1,28 +1,19 @@
<script setup> <script setup>
import Section from '../../../components/Section.vue' import PeopleLike from './PeopleLike.vue'
</script> </script>
<template> <template>
<Section class="section"> <PeopleLike title="消费者不喜欢的方面">
<div class="section-header"> <template #title-icon>
<img src="../../../img/icon_like.png" alt="" class="icon"> <img src="../../../img/icon_dislike.png" alt="" class="icon">
<span>消费者喜欢的方面</span> </template>
</div> </PeopleLike>
<div class="section-content">
<div class="area-wrapper"></div>
<div class="table-wrapper"></div>
<div class="table-wrapper"></div>
</div>
</Section>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
@import "./style.scss";
</style> </style>

View File

@@ -1,10 +1,21 @@
<script setup> <script setup>
import { computed, defineProps, ref } from 'vue' import { computed, defineProps, ref } from 'vue'
import { AlignRightOutlined, CloudOutlined, DownloadOutlined } from '@ant-design/icons-vue' import { AlignRightOutlined, CloudOutlined, DownloadOutlined } from '@ant-design/icons-vue'
import moment from 'moment'
import Section from '../../../components/Section.vue' import Section from '../../../components/Section.vue'
import SmallTable from '../../components/SmallTable.vue' import SmallTable from '../../components/SmallTable.vue'
import { exportJsonToExcel } from '@/composables/exportExcel'
const props = defineProps({
title: { type: String, default: '消费者喜欢的方面' }
})
const chartShown = ref(true)
const activeRow = ref({})
const columns = ref([ const columns = ref([
{ {
@@ -31,13 +42,30 @@ const tableData = ref([
{ id: '7', seq: '7', original: 'xxx' } { id: '7', seq: '7', original: 'xxx' }
]) ])
function showChart(show) {
chartShown.value = !!show
}
function onActiveRow(record) {
activeRow.value = record || {}
}
function downloadDetailTable() {
exportJsonToExcel({
data: [['xxx1', 'xxx111'], ['xxx2', 'xxx2']],
filename: `关键词比例_${ props.title }_屏幕最佳搭档_${ moment().format('YYYYMMDDHHmm') }`
})
}
</script> </script>
<template> <template>
<Section class="section"> <Section class="section">
<div class="section-header"> <div class="section-header">
<slot name="title-icon">
<img src="../../../img/icon_like.png" alt="" class="title-icon"> <img src="../../../img/icon_like.png" alt="" class="title-icon">
<span>消费者喜欢的方面</span> </slot>
<span class="title-text">{{ props.title }}</span>
</div> </div>
<div class="section-content"> <div class="section-content">
@@ -46,12 +74,6 @@ const tableData = ref([
<div class="action-button"> <div class="action-button">
<DownloadOutlined class="icon" /> <DownloadOutlined class="icon" />
</div> </div>
<div class="action-button">
<CloudOutlined class="icon" />
</div>
<div class="action-button">
<AlignRightOutlined class="icon" style="rotate: 180deg;" />
</div>
</div> </div>
</div> </div>
@@ -63,29 +85,27 @@ const tableData = ref([
<div class="action-button"> <div class="action-button">
<DownloadOutlined class="icon" /> <DownloadOutlined class="icon" />
</div> </div>
<div class="action-button"> <div class="action-button" @click="showChart(!chartShown)">
<CloudOutlined class="icon" /> <CloudOutlined v-if="!chartShown" class="icon" />
</div> <AlignRightOutlined v-else class="icon" style="rotate: 180deg;" />
<div class="action-button">
<AlignRightOutlined class="icon" style="rotate: 180deg;" />
</div> </div>
</div> </div>
<div v-if="chartShown"></div>
<SmallTable v-else
:data-source="tableData"
:columns="columns"
:row-selectable="true"
@active="onActiveRow" />
</div> </div>
<a-divider type="vertical" class="dashed-divider" /> <a-divider type="vertical" class="dashed-divider" />
<div class="table-wrapper"> <div class="table-wrapper">
<div class="button-wrapper"> <div class="button-wrapper">
<div class="action-button"> <div class="action-button" @click="downloadDetailTable">
<DownloadOutlined class="icon" /> <DownloadOutlined class="icon" />
</div> </div>
<div class="action-button">
<CloudOutlined class="icon" />
</div>
<div class="action-button">
<AlignRightOutlined class="icon" style="rotate: 180deg;" />
</div>
</div> </div>
<SmallTable :data-source="tableData" :columns="columns" /> <SmallTable :data-source="tableData" :columns="columns" />

View File

@@ -4,16 +4,22 @@ import { computed, defineProps } from 'vue'
import Section from '../../../components/Section.vue' import Section from '../../../components/Section.vue'
import StyledTable from '../../components/StyledTable.vue' import StyledTable from '../../components/StyledTable.vue'
const props = defineProps({}) const props = defineProps({
report: { type: Object, default: () => Object.assign({}) }
})
const codeStr = computed(() => getTableCodeRow()) const tableData = computed(() => props.report?.chatVOS?.find?.((data) => data.type === '概念形象') || {})
const codeStr = computed(() => getTableCodeRow(tableData.value))
function getTableCodeRow(tableData) { function getTableCodeRow(tableData) {
const codeRow = tableData?.dataVOS?.find?.((row) => Object.keys(row).find((key) => row[key] === '概念编码')) if(!tableData?.dataVOS?.length) {
return ''
}
const codeRow = tableData.dataVOS.find((row) => Object.keys(row).find((key) => row[key] === '概念编码'))
if(!codeRow) { if(!codeRow) {
return '' return ''
} }
@@ -24,16 +30,18 @@ function getTableCodeRow(tableData) {
<template> <template>
<Section class="section"> <Section class="section">
<div class="section-header"> <div class="section-header">
<img src="../../../img/icon_like.png" alt="" class="title-icon"> <img src="../../../img/icon_light.png" alt="" class="title-icon">
<span>消费者喜欢的方面</span> <span class="title-text">概念传递产品形象</span>
</div> </div>
<StyledTable></StyledTable> <div class="main-wrapper">
<StyledTable :data="tableData" :bar-table="true" />
<div class="message"> <div class="message">
<span class="emphasize">{{ codeStr }}</span> <span class="emphasize">{{ codeStr }}</span>
<span>意为该数值在90%的显著水平下显著高</span> <span>意为该数值在90%的显著水平下显著高</span>
</div> </div>
</div>
</Section> </Section>
</template> </template>
@@ -41,8 +49,12 @@ function getTableCodeRow(tableData) {
<style scoped lang="scss"> <style scoped lang="scss">
@import "./style.scss"; @import "./style.scss";
.main-wrapper {
padding: 16px;
}
.message { .message {
padding: 10px 16px 16px; padding-top: 10px;
font-size: 14px; font-size: 14px;
font-weight: normal; font-weight: normal;
color: #434343; color: #434343;

View File

@@ -17,7 +17,10 @@
width: 20px; width: 20px;
height: 20px; height: 20px;
border: none; border: none;
margin-right: 4px; }
.title-text {
margin-left: 4px;
} }
} }

View File

@@ -0,0 +1,11 @@
<script setup>
</script>
<template>
<RouterView />
</template>
<style scoped lang="scss">
</style>