Browse Source

feat(customers-voice):用户之声模块添加

xinyan 5 months ago
parent
commit
bb7a81f68a

+ 19 - 1
src/stores/countryInfo.ts

@@ -54,5 +54,23 @@ export const useCountryInfoStore = defineStore('countryInfo', () => {
 		{ code: 'PLN', color: '#D22630' }, // 红色
 		{ code: 'PLN', color: '#D22630' }, // 红色
 	];
 	];
 
 
-	return { Countries, Region, CurrencyCodes };
+	const endpoints = [
+		{ code: 'US', endpoint: 'www.amazon.com', currency: '$' },  // 美国
+		{ code: 'UK', endpoint: 'www.amazon.co.uk', currency: '£' },  // 英国
+		{ code: 'DE', endpoint: 'www.amazon.de', currency: '€' },  // 德国
+		{ code: 'FR', endpoint: 'www.amazon.fr', currency: '€' },  // 法国
+		{ code: 'ES', endpoint: 'www.amazon.es', currency: '€' },  // 西班牙
+		{ code: 'IT', endpoint: 'www.amazon.it', currency: '€' },  // 意大利
+		{ code: 'JP', endpoint: 'www.amazon.co.jp', currency: '¥' },  // 日本
+		{ code: 'CA', endpoint: 'www.amazon.ca', currency: '$' },  // 加拿大
+		{ code: 'BE', endpoint: 'www.amazon.com.be', currency: '€' },  // 比利时
+		{ code: 'NL', endpoint: 'www.amazon.nl', currency: '€' },  // 荷兰
+		{ code: 'AU', endpoint: 'www.amazon.com.au', currency: '$' },  // 澳大利亚
+		{ code: 'MX', endpoint: 'www.amazon.com.mx', currency: '$' },  // 墨西哥
+		{ code: 'BR', endpoint: 'www.amazon.com.br', currency: 'R$' },  // 巴西
+		{ code: 'AE', endpoint: 'www.amazon.ae', currency: 'درهم' },  // 阿拉伯联合酋长国
+		{ code: 'SA', endpoint: 'www.amazon.sa', currency: 'SAR' }   // 沙特阿拉伯
+	];
+
+	return { Countries, Region, CurrencyCodes , endpoints };
 });
 });

+ 16 - 0
src/views/customers-voice/Columns.ts

@@ -0,0 +1,16 @@
+export const CustomerVoiceColumns = [
+	{ type: 'seq', title: '序 号', width: 50, align: 'center' },
+	// { field: 'img', title: '图片', minWidth: 300, align: 'center', showOverflow: true, slots: { default: 'img' } },
+	// { field: 'asin', title: '标题asin', minWidth: 'auto', align: 'center', showOverflow: true,  },
+	{ field: 'product_info', title: '产品信息', width: 280, align: 'center', slots: { default: 'product_info' } },
+	{ field: 'sku', title: 'SKU', minWidth: 'auto', align: 'center', showOverflow: true,  },
+	{ field: 'country_code', title: '国家', minWidth: 'auto', align: 'center', showOverflow: true,  slots: { default: 'country_code' },},
+	{ field: 'fulfillment_channel', title: '渠道', minWidth: 'auto', align: 'center', showOverflow: true,  },
+	{ field: 'ncx_rate', title: 'NCX率', minWidth: 'auto', align: 'center', showOverflow: true,  slots: { default: 'ncx_rate' } },
+	{ field: 'ncx_count', title: 'NCX订单', minWidth: 'auto', align: 'center', showOverflow: true,  },
+	{ field: 'order_count', title: '所有订单', minWidth: 'auto', align: 'center', showOverflow: true,  },
+	{ field: 'all_score', title: '星级', minWidth: 'auto', align: 'center', showOverflow: true, slots: { default: 'all_score' } },
+	{ field: 'return_record_rate', title: '退货率', minWidth: 'auto', align: 'center', showOverflow: true, slots: { default:'return_record_rate' } },
+	{ field: 'last_updated_date', title: '最近更新', minWidth: 'auto', align: 'center', showOverflow: true,  },
+	{ field: 'operate', title: '详情', fixed: 'right', align: 'center', width: 90, slots: { default: 'operate' } }
+]

+ 27 - 0
src/views/customers-voice/api.ts

@@ -0,0 +1,27 @@
+import { request } from '/@/utils/service';
+
+const apiPrefix = '/api/voice_of_customers/';
+
+export function getTableData(query: any) {
+	return request({
+		url: apiPrefix + 'list/',
+		method: 'GET',
+		params: query,
+	});
+}
+
+export function getChartData(query: any) {
+	return request({
+		url: apiPrefix + 'chart/',
+		method: 'GET',
+		params: query,
+	});
+}
+
+export function getCommentData(query: any) {
+	return request({
+		url: '/api/voice_of_customers/comment/list/',
+		method: 'GET',
+		params: query,
+	});
+}

+ 194 - 0
src/views/customers-voice/components/DataTable.vue

@@ -0,0 +1,194 @@
+<script lang="ts" setup>
+/**
+ * @Name: DataTable.vue
+ * @Description: 买家之声-数据表格
+ * @Author: xinyan
+ */
+import { Refresh } from '@element-plus/icons-vue';
+import { usePagination } from '/@/utils/usePagination';
+import { useTableData } from '/@/utils/useTableData';
+import DataTableSlot from './DataTableSlot.vue';
+import * as api from '../api';
+import { CustomerVoiceColumns } from '/@/views/customers-voice/Columns';
+import ShowDetail from './show-detail/index.vue';
+
+interface Parameter {
+	asin: string;
+	date: Array<string>;
+}
+
+const queryParameter: Parameter | undefined = inject('query-parameter');
+const { tableOptions, handlePageChange } = usePagination(fetchList);
+
+const gridRef = ref();
+const gridOptions: any = reactive({
+	size: 'mini',
+	border: false,
+	round: true,
+	stripe: true,
+	currentRowHighLight: true,
+	height: '100%',
+	toolbarConfig: {
+		size: 'large',
+		slots: {
+			buttons: 'toolbar_buttons',
+			tools: 'toolbar_tools',
+		},
+	},
+	rowConfig: {
+		isHover: true,
+	},
+	columnConfig: {
+		resizable: true,
+	},
+	pagerConfig: {
+		total: tableOptions.value.total,
+		page: tableOptions.value.page,
+		limit: tableOptions.value.limit,
+	},
+	loading: false,
+	loadingConfig: {
+		icon: 'vxe-icon-indicator roll',
+		text: '正在拼命加载中...',
+	},
+	columns: '',
+	data: '',
+});
+
+const btnLoading = ref(false);
+
+const showOpen = ref(false);
+const rowData = ref({});
+
+const start_time = dayjs(queryParameter?.date[0]).format('YYYY-MM-DD');
+const end_time = dayjs(queryParameter?.date[1]).format('YYYY-MM-DD');
+
+onBeforeMount(() => {
+	gridOptions.pagerConfig.limit = 10;
+});
+
+onMounted(() => {
+	fetchList();
+});
+
+async function fetchList(isQuery = false) {
+	if (isQuery) {
+		gridOptions.pagerConfig.page = 1;
+	}
+
+	gridOptions.data = [];
+	gridOptions.columns = [];
+
+	const query = {
+		search: queryParameter?.asin,
+		start_time: start_time,
+		end_time: end_time,
+	};
+
+	await useTableData(api.getTableData, query, gridOptions);
+	if (gridOptions && gridOptions.data?.length) await gridRef.value.loadColumn(CustomerVoiceColumns);
+	gridOptions.showHeader = Boolean(gridOptions.data?.length);
+}
+
+function handleRefresh() {
+	fetchList();
+}
+
+
+function handleShow(row: any) {
+	console.log(123);
+	showOpen.value = true;
+	rowData.value = row;
+}
+
+const gridEvents = {
+	custom({ type }: any) {
+		if (type == 'confirm') {
+			fetchList();
+		}
+	},
+};
+
+function cellStyle() {
+	return {
+		fontWeight: 500,
+	};
+}
+
+defineExpose({ fetchList });
+</script>
+
+<template>
+	<vxe-grid ref="gridRef" :cell-style="cellStyle" v-bind="gridOptions" v-on="gridEvents">
+		<!-- 工具栏左侧 -->
+		<template #toolbar_buttons>
+			<!--<div class="flex gap-2">-->
+			<!--	<div>-->
+			<!--		<PermissionButton :icon="Plus" plain round type="primary" @click="handleCreate">新 增</PermissionButton>-->
+			<!--	</div>-->
+			<!--	<div class="custom-el-input">-->
+			<!--		<el-select v-model="templateType" style="width: 200px">-->
+			<!--			<template #prefix>-->
+			<!--				<div class="flex items-center">-->
+			<!--					<el-button-->
+			<!--						size="small"-->
+			<!--						style="margin-left: -7px; font-size: 14px; border-radius: 29px"-->
+			<!--						text-->
+			<!--						type="success"-->
+			<!--						@click.stop="downloadTemplate"-->
+			<!--					>-->
+			<!--						下载-->
+			<!--					</el-button>-->
+			<!--					<VerticalDivider style="margin-left: 7px" />-->
+			<!--				</div>-->
+			<!--			</template>-->
+			<!--			<el-option label="审批查看(供货)" value="cost" />-->
+			<!--		</el-select>-->
+			<!--	</div>-->
+			<!--	<VerticalDivider class="px-1" style="margin-left: 7px" />-->
+			<!--	<ImportButton :icon="Upload" :uploadFunction="api.upload" bg text>导 入</ImportButton>-->
+			<!--</div>-->
+		</template>
+		<!-- 工具栏右侧 -->
+		<template #toolbar_tools>
+			<el-button circle class="toolbar-btn" @click="handleRefresh">
+				<el-icon>
+					<Refresh />
+				</el-icon>
+			</el-button>
+		</template>
+		<template #top>
+			<div class="mb-2"></div>
+		</template>
+		<!-- 分页 -->
+		<template #pager>
+			<vxe-pager
+				v-model:currentPage="gridOptions.pagerConfig.page"
+				v-model:pageSize="gridOptions.pagerConfig.limit"
+				:total="gridOptions.pagerConfig.total"
+				class="mt-1.5"
+				@page-change="handlePageChange"
+			/>
+		</template>
+		<template #empty>
+			<el-empty description="暂无数据" />
+		</template>
+		<!-- 自定义列插槽 -->
+		<template v-for="col in CustomerVoiceColumns" #[`${col.field}`]="{ row }">
+			<DataTableSlot :field="col.field" :row="row" @show-detail="handleShow"/>
+		</template>
+	</vxe-grid>
+	<ShowDetail v-if="showOpen" v-model="showOpen" :rowData="rowData"/>
+</template>
+
+<style scoped>
+.toolbar-btn {
+	width: 34px;
+	height: 34px;
+	font-size: 18px;
+}
+
+:deep(.custom-el-input .el-select__wrapper) {
+	border-radius: 20px;
+}
+</style>

+ 128 - 0
src/views/customers-voice/components/DataTableSlot.vue

@@ -0,0 +1,128 @@
+<script lang="ts" setup>
+/**
+ * @Name: DataTableSlot.vue
+ * @Description: 买家之声-数据表格插槽
+ * @Author: xinyan
+ */
+
+import { CopyDocument, Picture as IconPicture, View } from '@element-plus/icons-vue';
+import PermissionButton from '/@/components/PermissionButton/index.vue';
+import { handleCopy } from '/@/utils/useCopyText';
+import { useCountryInfoStore } from '/@/stores/countryInfo';
+
+const props = defineProps<{
+	row: any;
+	field: any;
+}>();
+const { row, field } = props;
+
+const emit = defineEmits(['show-detail']);
+
+const countryInfoStore = useCountryInfoStore();
+const country = countryInfoStore.Countries.find((c) => c.code == row.country_code);
+const color = country ? country.color : '#3875F6';
+
+const endpoints = countryInfoStore.endpoints.find((c) => c.code == row.country_code);
+const endpoint = endpoints?endpoints.endpoint:null;
+const url = computed(() => {
+	return `https://${endpoint}/dp/${row.asin}`;
+});
+
+const ncx_rate = computed(() => {
+	return row.ncx_count / row.total_count;
+});
+
+function handleShow() {
+	emit('show-detail', row);
+}
+</script>
+
+<template>
+	<div class="font-semibold">
+		<div v-if="field === 'country_code'">
+			<el-tag :disable-transitions="true" :style="{ color: color, borderColor: color }" effect="plain" round>
+				{{ country ? country.name : '-' }}
+			</el-tag>
+		</div>
+		<div v-else-if="field === 'operate'">
+			<div class="flex justify-center gap-2">
+				<div>
+					<PermissionButton circle plain type="success" @click="handleShow">
+						<el-icon>
+							<View />
+						</el-icon>
+					</PermissionButton>
+				</div>
+			</div>
+		</div>
+		<div v-else-if="field === 'ncx_rate'">
+			{{ ncx_rate || '-' }}
+		</div>
+		<div v-else-if="field === 'product_info'">
+			<div class="flex justify-start items-center font-medium">
+				<div v-if="row.img" style="width: 60px; height: 60px; margin-right: 5px">
+					<el-tooltip effect="light" placement="right-start">
+						<el-image
+							:src="`https://d1ge0kk1l5kms0.cloudfront.net/images/I/${row.img}.jpg`"
+							fit="scale-down"
+							lazy
+							style="width: 60px; height: 60px; margin-right: 5px"
+						/>
+						<template #content>
+							<el-image :src="`https://d1ge0kk1l5kms0.cloudfront.net/images/I/${row.img}.jpg`" style="width: 250px" />
+						</template>
+					</el-tooltip>
+				</div>
+				<el-image v-else lazy style="min-width: 60px; margin-right: 5px; font-size: 2.7rem">
+					<!--<div slot="error" class="image-slot">-->
+					<!--  <i class="el-icon-picture-outline"></i>-->
+					<!--</div>-->
+					<template #error>
+						<div class="image-slot">
+							<el-icon>
+								<icon-picture />
+							</el-icon>
+						</div>
+					</template>
+				</el-image>
+				<div class="text-left">
+					<el-tooltip :content="row.product_name" :disabled="!row.product_name" effect="dark" placement="top-start" show-after="350">
+						<el-link :disabled="!row.product_name" :href="url" :underline="false" target="_blank" type="primary">
+							<span class="line-clamp-2 text-ellipsis whitespace-normal">
+								{{ row.product_name || '--' }}
+							</span>
+						</el-link>
+					</el-tooltip>
+					<div>
+						<div class="flex">
+							ASIN:
+							<span class="font-semibold italic ml-1" style="color: #1d2129">
+								{{ row.asin || '--' }}
+							</span>
+							<el-button :disabled="!row.asin" :icon="CopyDocument" class="ml-1 cursor-pointer" link @click="handleCopy(row.asin || '')"></el-button>
+						</div>
+					</div>
+				</div>
+			</div>
+		</div>
+		<div v-else-if="field === 'all_score'">
+			<template v-if="row.all_score !== null && row.all_score !== undefined && row.all_score !== ''">
+				<el-tooltip v-if="row.all_score > 0" :content="row.all_score" effect="dark" placement="top" show-after="350">
+					<el-rate v-if="row.all_score > 0" v-model="row.all_score" :colors="['#FF0000', '#FF9900', '#67C23A']" disabled text-color="#1e293b" />
+				</el-tooltip>
+				<span v-else>{{ row.all_score }}</span>
+			</template>
+			<template v-else>
+				<span>-</span>
+			</template>
+		</div>
+		<div v-else-if="field === 'return_record_rate'">
+			{{ (row.return_record_rate * 100).toFixed(2) + '%' || '-' }}
+		</div>
+		<div v-else>
+			{{ row[field] || '-' }}
+		</div>
+	</div>
+</template>
+
+<style scoped></style>

+ 150 - 0
src/views/customers-voice/components/show-detail/components/DataDisplay.vue

@@ -0,0 +1,150 @@
+<script lang="ts" setup>
+/**
+ * @Name: DataDisplay.vue
+ * @Description: 买家之声详情-数据展示组件
+ * @Author: xinyan
+ */
+import * as api from '/@/views/customers-voice/api';
+import { useResponse } from '/@/utils/useResponse';
+import { onMounted, ref } from 'vue';
+
+const props: any = defineProps({
+	rowData: Object,
+	queryParameter: Object,
+});
+const { rowData, queryParameter } = props;
+const start_time = dayjs(queryParameter?.date[0]).format('YYYY-MM-DD');
+const end_time = dayjs(queryParameter?.date[1]).format('YYYY-MM-DD');
+
+// 定义评论数据接口
+interface Comment {
+	order_id: string;
+	comment: string;
+	date: string;
+}
+
+const negativeCommentList = ref<Comment[]>([]); // 评论列表
+const page = ref(1); // 当前页
+const loading = ref(false); // 加载状态
+const noMore = ref(false); // 是否没有更多数据
+const disabled = computed(() => loading.value || noMore.value);
+
+// 获取评论数据
+async function fetchList() {
+	if (loading.value || noMore.value) return; // 防止重复请求或没有更多数据时请求
+
+	loading.value = true;
+
+	// 请求参数
+	const query = {
+		asin: 'B0D21RMR85', // 请根据实际情况替换
+		start_time: start_time,
+		end_time: end_time,
+		option: 'return_negative_comment',
+		page: page.value,
+		limit: 5,
+	};
+
+	try {
+		const resp = await useResponse(api.getCommentData, query);
+		const newData = resp.data;
+
+		// 如果返回数据少于 5 条,说明没有更多数据
+		if (newData.length < 5) {
+			noMore.value = true;
+		}
+
+		// 合并数据
+		negativeCommentList.value = [...negativeCommentList.value, ...newData];
+		console.log('=>(DataDisplay.vue:58) negativeCommentList.value', negativeCommentList.value);
+
+		// 页数加 1
+		page.value += 1;
+	} catch (error) {
+		console.error('加载评论数据失败:', error);
+	} finally {
+		loading.value = false;
+	}
+}
+
+// 在组件加载时获取数据
+onMounted(() => {
+	fetchList();
+});
+</script>
+
+<template>
+	<el-card style="height: 600px">
+		<el-row>
+			<el-col :span="8">
+				<div class="infinite-list-wrapper" style="overflow: auto">
+					<ul v-infinite-scroll="fetchList" :infinite-scroll-disabled="disabled" class="list">
+						<li v-for="item in negativeCommentList" :key="item.order_id" class="list-item">
+							<div class="comment-text">{{ item.comment }}</div>
+						</li>
+					</ul>
+					<p v-if="loading">Loading...</p>
+					<p v-if="noMore">No more</p>
+				</div>
+			</el-col>
+			<el-col :span="8">
+				<div class="infinite-list-wrapper" style="overflow: auto">
+					<ul v-infinite-scroll="fetchList" :infinite-scroll-disabled="disabled" class="list">
+						<li v-for="item in negativeCommentList" :key="item.order_id" class="list-item">
+							<div class="comment-text">{{ item.comment }}</div>
+						</li>
+					</ul>
+					<p v-if="loading">Loading...</p>
+					<p v-if="noMore">No more</p>
+				</div>
+			</el-col>
+			<el-col :span="8">
+				<div class="infinite-list-wrapper" style="overflow: auto">
+					<ul v-infinite-scroll="fetchList" :infinite-scroll-disabled="disabled" class="list">
+						<li v-for="item in negativeCommentList" :key="item.order_id" class="list-item">
+							<div class="comment-text">{{ item.comment }}</div>
+						</li>
+					</ul>
+					<p v-if="loading">Loading...</p>
+					<p v-if="noMore">No more</p>
+				</div>
+			</el-col>
+		</el-row>
+	</el-card>
+</template>
+
+<style scoped>
+.infinite-list-wrapper {
+	height: 300px;
+	text-align: center;
+}
+.infinite-list-wrapper .list {
+	padding: 0;
+	margin: 0;
+	list-style: none;
+}
+
+.infinite-list-wrapper .list-item {
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	height: 50px;
+	background: var(--el-color-primary-light-9);
+	margin: 10px;
+	color: var(--el-color-primary);
+}
+.infinite-list-wrapper .list-item + .list-item {
+	margin-top: 10px;
+}
+
+.comment-text {
+	display: -webkit-box;
+	-webkit-box-orient: vertical;
+	-webkit-line-clamp: 1; /* 显示的行数 */
+	overflow: hidden;
+	text-overflow: ellipsis;
+	max-height: 4.5em; /* 根据字号和行高设置最大高度 */
+	line-height: 1.5em; /* 控制行高,与你的内容相匹配 */
+	font-size: 14px;
+}
+</style>

+ 169 - 0
src/views/customers-voice/components/show-detail/components/LineChart.vue

@@ -0,0 +1,169 @@
+<script lang="ts" setup>
+/**
+ * @Name: LineChart.vue
+ * @Description: 买家之声-详情页-折线图
+ * @Author: xinyan
+ */
+
+import * as echarts from 'echarts';
+import { onBeforeUnmount, onMounted, ref } from 'vue';
+import { getChartData } from '/@/views/customers-voice/api';
+
+const props: any = defineProps({
+	rowData: Object,
+});
+const { rowData } = props;
+
+const week = ref('8');
+
+let chartObj: any;
+const chartRef = ref(null);
+const loading = ref(false);
+
+// 初始化 ECharts 图表
+onMounted(() => {
+	addResize();
+	initLine();
+});
+
+// 组件卸载前清理
+onBeforeUnmount(() => {
+	if (chartObj) {
+		chartObj.dispose();
+		chartObj = null;
+	}
+	removeResize();
+});
+
+// ECharts 配置项
+const option: any = {
+	// dataset: {
+	// 	dimensions: ['date', 'ncx_rate'],
+	// 	source: chartData.value,
+	// },
+	// title: {
+	// 	left: '-0.2%',
+	// 	text: '每月平均评分',
+	// 	textStyle: {
+	// 		fontSize: '16px',
+	// 		fontWeight: '500',
+	// 	},
+	// },
+	tooltip: {
+		trigger: 'axis',
+	},
+	legend: {
+		top: '10%',
+		align: 'right',
+	},
+	grid: {
+		top: '30%',
+		left: '5%',
+		right: '5%',
+		bottom: '5%',
+		containLabel: true,
+	},
+	xAxis: {
+		type: 'category',
+		data: [],
+	},
+	yAxis: [
+		{
+			type: 'value',
+			name: 'NCX Rate',
+			min: 2,
+			max: 5,
+			interval: 0.5,
+			position: 'left',
+			axisLine: {
+				show: true,
+				lineStyle: {
+					color: '#70b6e3',
+				},
+			},
+			axisLabel: {
+				formatter: '{value} 分',
+			},
+		},
+	],
+	series: [
+		{
+			id: 0,
+			name: 'NCX Rate',
+			type: 'line',
+			yAxisIndex: 0,
+			itemStyle: {
+				color: '#70b6e3', //改变折线点的颜色
+				lineStyle: {
+					color: '#70b6e3', //改变折线颜色
+				},
+			},
+			data: [],
+		},
+	],
+};
+
+// 初始化 ECharts 图表的函数
+async function initLine() {
+	await loadData();
+	chartObj = echarts.init(chartRef.value);
+	chartObj.setOption(option, true);
+}
+
+// 加载数据
+async function loadData() {
+	try {
+		loading.value = true;
+		const query = {
+			asin: rowData.asin,
+			week: week.value,
+			sku: rowData.sku,
+			channel: rowData.fulfillment_channel,
+		};
+		const res = await getChartData(query);
+		if (res.code === 2000 && res.data) {
+			option.xAxis.data = res.data.date;
+			option.series[0].data = res.data.ncx_rate;
+		}
+	} catch (e) {
+		ElMessage.error('加载数据失败,请稍后再试');
+	} finally {
+		loading.value = false;
+	}
+}
+
+// 处理窗口大小变化
+function resizeChart() {
+	chartObj.resize();
+}
+
+// 添加窗口大小变化事件监听
+function addResize() {
+	window.addEventListener('resize', resizeChart);
+}
+
+// 移除窗口大小变化事件监听
+function removeResize() {
+	window.removeEventListener('resize', resizeChart);
+}
+</script>
+
+<template>
+	<div v-loading="loading" class="mt-10">
+		<div class="mb-3 flex items-center">
+			<span class="font-semibold italic mr-2">时间范围:</span>
+			<el-select v-model="week" style="width: 100px">
+				<el-option label="一周" value="1"></el-option>
+				<el-option label="两周" value="2"></el-option>
+				<el-option label="四周" value="4"></el-option>
+				<el-option label="六周" value="6"></el-option>
+				<el-option label="八周" value="8"></el-option>
+				<el-option label="十二周" value="12"></el-option>
+			</el-select>
+		</div>
+		<!-- 图表区域 -->
+		<div ref="chartRef" style="width: 100%; height: 520px; background: #fff"></div>
+	</div>
+</template>
+
+<style scoped></style>

+ 106 - 0
src/views/customers-voice/components/show-detail/components/TitleCard.vue

@@ -0,0 +1,106 @@
+<script setup lang="ts">/**
+ * @Name: TitleCard.vue
+ * @Description: 买家之声详情-标题卡片
+ * @Author: xinyan
+ */
+import { CopyDocument } from '@element-plus/icons-vue';
+import { handleCopy } from '/@/utils/useCopyText';
+import VerticalDivider from '/@/components/VerticalDivider/index.vue';
+import { useCountryInfoStore } from '/@/stores/countryInfo';
+
+
+const props: any = defineProps({
+	rowData: Object
+});
+const { rowData } = props;
+
+const countryInfoStore = useCountryInfoStore();
+const endpoints = countryInfoStore.endpoints.find((c) => c.code == rowData.country_code);
+const endpoint = endpoints?endpoints.endpoint:null;
+const url = computed(() => {
+	return `https://${endpoint}/dp/${rowData.asin}`;
+});
+
+const ncx_rate = computed(() => {
+	return rowData.ncx_count / rowData.total_count;
+});
+
+</script>
+
+<template>
+	<el-card body-class="flex justify-between items-center gap-5" class="border-none top-5 z-10 mt-5">
+		<div class="flex">
+			<el-image :src="`https://d1ge0kk1l5kms0.cloudfront.net/images/I/${rowData.img}.jpg`" class="mr-5"
+								fit="fill"
+								lazy style="min-width: 135px; height: 135px;">
+				<template #error>
+					<div class="flex justify-center items-center h-full w-full text-2xl"
+							 style="background:var(--el-fill-color-light)">
+						<el-icon>
+							<icon-picture />
+						</el-icon>
+					</div>
+				</template>
+			</el-image>
+			<div class="flex flex-col justify-between">
+				<el-link :href="url"
+								 :underline="false"
+								 target="_blank"
+								 style="font-size: 18px;justify-content: left !important;"
+								 :disabled="!rowData.product_name"
+								 type="primary">
+					<span class="line-clamp-2 text-ellipsis whitespace-normal" >{{ rowData.product_name || '--' }}</span>
+				</el-link>
+				<div class="mt-2">
+					<el-row>
+						<el-col :span="6">
+							<div class="font-semibold italic">ASIN : {{ rowData.asin || '-'}}</div>
+						</el-col>
+						<el-col :span="6">
+							<div class="font-semibold italic">SKU : {{ rowData.sku || '-'}}</div>
+						</el-col>
+					</el-row>
+					<el-divider style="margin-top: 10px;margin-bottom: 10px;"/>
+					<el-row>
+						<el-col :span="3">
+							<div class="font-semibold italic">总订单数 : {{ rowData.order_count || '-'}}</div>
+						</el-col>
+						<el-col :span="3">
+							<div class="font-semibold italic">NCX订单 : {{ rowData.ncx_count || '-'}}</div>
+						</el-col>
+						<el-col :span="3">
+							<div class="font-semibold italic">NCX率 : {{ ncx_rate || '-'}}</div>
+						</el-col>
+						<el-col :span="3">
+							<div class="font-semibold italic">退货率 : {{ rowData.return_record_rate || '-'}}</div>
+						</el-col>
+						<el-col :span="6">
+							<div class="font-semibold italic">最近更新 : {{ rowData.last_updated_date || '-'}}</div>
+						</el-col>
+						<el-col :span="4">
+							<div>
+								<template v-if="rowData.all_score !== null && rowData.all_score !== undefined && rowData.all_score !== ''">
+									<el-tooltip v-if="rowData.all_score > 0" :content="rowData.all_score" effect="dark" placement="top" show-after="350">
+										<div class="flex items-center">
+											<div class="font-semibold italic">星级 :</div>
+											<el-rate v-if="rowData.all_score > 0" v-model="rowData.all_score" :colors="['#FF0000', '#FF9900', '#67C23A']" disabled text-color="#1e293b" />
+										</div>
+									</el-tooltip>
+									<span v-else>{{ rowData.all_score }}</span>
+								</template>
+								<template v-else>
+									<span>-</span>
+								</template>
+							</div>
+						</el-col>
+					</el-row>
+				</div>
+			</div>
+		</div>
+		<!--<el-button :icon="Back" plain round type="info" @click="handleBack">返 回</el-button>-->
+	</el-card>
+</template>
+
+<style scoped>
+
+</style>

+ 52 - 0
src/views/customers-voice/components/show-detail/index.vue

@@ -0,0 +1,52 @@
+<script lang="ts" setup>
+/**
+ * @Name: index.vue
+ * @Description: 买家之声-详情页
+ * @Author: xinyan
+ */
+import TitleCard from '/@/views/customers-voice/components/show-detail/components/TitleCard.vue';
+import LineChart from '/@/views/customers-voice/components/show-detail/components/LineChart.vue';
+import DataDisplay from '/@/views/customers-voice/components/show-detail/components/DataDisplay.vue';
+
+interface Parameter {
+	asin: string;
+	date: Array<string>;
+}
+
+const queryParameter: Parameter | undefined = inject('query-parameter');
+
+const showOpen = defineModel({ default: false });
+
+const showDrawer = <Ref>useTemplateRef('showDrawer');
+
+const props: any = defineProps({
+	rowData: Object,
+});
+const { rowData } = props;
+
+const emit = defineEmits(['refresh']);
+</script>
+
+<template>
+	<div class="drawer-container">
+		<el-drawer
+			ref="showDrawer"
+			v-model="showOpen"
+
+			:close-on-click-modal="false"
+			:close-on-press-escape="false"
+			direction="btt"
+			size="80%"
+		>
+			<div class="px-5">
+				<TitleCard :rowData="rowData"></TitleCard>
+				<LineChart :queryParameter="queryParameter" :rowData="rowData"></LineChart>
+				<DataDisplay :queryParameter="queryParameter" :rowData="rowData"></DataDisplay>
+			</div>
+		</el-drawer>
+	</div>
+</template>
+
+<style scoped>
+
+</style>

+ 95 - 0
src/views/customers-voice/index.vue

@@ -0,0 +1,95 @@
+<script lang="ts" setup>
+/**
+ * @Name: index.vue
+ * @Description: 用户之声-首页界面
+ * @Author: xinyan
+ */
+
+import VerticalDivider from '/src/components/VerticalDivider/index.vue';
+import { Delete, RefreshLeft, Search } from '@element-plus/icons-vue';
+import { useTemplateRef } from 'vue';
+import { useTableHeight } from '/@/utils/useTableHeight';
+import DataTable from '/@/views/customers-voice/components/DataTable.vue';
+
+const titleContainer: Ref<HTMLElement | null> = useTemplateRef('titleContainer');
+const queryContainer: Ref<HTMLElement | null> = useTemplateRef('queryContainer');
+const { tableHeight } = useTableHeight(titleContainer, queryContainer);
+
+const tableRef: Ref<any> = useTemplateRef('table');
+
+const btnLoading = ref(false);
+const resetLoading = ref(false);
+
+const formInline = reactive<any>({
+	asin: '',
+	date: [new Date(new Date().setDate(new Date().getDate() - 7)), new Date()],
+});
+provide('query-parameter', formInline);
+
+async function handleQuery() {
+	btnLoading.value = true;
+	await tableRef.value?.fetchList(true);
+	btnLoading.value = false;
+}
+
+async function resetParameter() {
+	formInline.asin = '';
+	formInline.date = [new Date(new Date().setDate(new Date().getDate() - 7)), new Date()];
+	resetLoading.value = true;
+	await tableRef.value?.fetchList(true);
+	resetLoading.value = false;
+}
+</script>
+
+<template>
+	<div class="p-5">
+		<el-card class="h-full" style="color: rgba(0, 0, 0, 0.88)">
+			<div ref="titleContainer" class="text-xl font-semibold pb-5">买家之声</div>
+			<!-- 查询条件 -->
+			<div ref="queryContainer" class="flex justify-between">
+				<div class="flex flex-1">
+					<div class="w-full whitespace-nowrap">
+						<el-row :gutter="20" style="margin-bottom: 5px">
+							<el-col :span="5">
+								<div class="flex items-center">
+									<el-input v-model="formInline.asin" clearable placeholder="请输入标题/ASIN">
+										<template #prepend>
+											<el-icon>
+												<Search />
+											</el-icon>
+										</template>
+									</el-input>
+								</div>
+							</el-col>
+							<el-col :span="5">
+								<div class="flex items-center">
+									<span class="mr-2">日期</span>
+									<el-date-picker
+										v-model="formInline.date"
+										end-placeholder="结束日期"
+										range-separator="至"
+										start-placeholder="开始日期"
+										type="daterange"
+									/>
+								</div>
+							</el-col>
+						</el-row>
+					</div>
+				</div>
+				<VerticalDivider />
+				<div class="flex gap-1.5 ml-5">
+					<el-button :icon="Search" :loading="btnLoading" type="primary" @click="handleQuery"> 查 询</el-button>
+					<el-button :icon="RefreshLeft" :loading="resetLoading" color="#ECECF1C9" style="width: 88px; color: #3c3c3c" @click="resetParameter">
+						重 置
+					</el-button>
+				</div>
+			</div>
+			<el-divider ref="dividerContainer" style="margin: 20px 0 12px 0" />
+			<div :style="{ height: tableHeight + 'px' }">
+				<DataTable ref="table" />
+			</div>
+		</el-card>
+	</div>
+</template>
+
+<style scoped></style>

+ 0 - 3
src/views/price-approval/approval-review-supply/components/DataTable.vue

@@ -18,13 +18,10 @@ import VerticalDivider from '/src/components/VerticalDivider/index.vue';
 import * as api from '../api';
 import * as api from '../api';
 import { useResponse } from '/@/utils/useResponse';
 import { useResponse } from '/@/utils/useResponse';
 import {
 import {
-	CostDetailColumns,
-	SupplyCheckColumns_Regular,
 	SupplyCheckColumns_Special,
 	SupplyCheckColumns_Special,
 } from '/@/views/price-approval/Columns';
 } from '/@/views/price-approval/Columns';
 import EditDrawer from '/@/views/price-approval/approval-review-supply/components/EditDrawer.vue';
 import EditDrawer from '/@/views/price-approval/approval-review-supply/components/EditDrawer.vue';
 
 
-
 const router = useRouter();
 const router = useRouter();
 
 
 interface Parameter {
 interface Parameter {

+ 3 - 3
src/views/price-approval/approval-review-supply/components/DataTableSlot.vue

@@ -48,17 +48,17 @@ function onConfirm() {
 </script>
 </script>
 
 
 <template>
 <template>
-	<div class="font-medium">
+	<div class="font-semibold">
 		<div v-if="field === 'operate'">
 		<div v-if="field === 'operate'">
 			<div class="flex justify-center gap-2">
 			<div class="flex justify-center gap-2">
-				<div v-if="hasPermission('SkuAttrUpdate')">
+				<div>
 					<PermissionButton circle plain type="warning" @click="handleEdit">
 					<PermissionButton circle plain type="warning" @click="handleEdit">
 						<el-icon>
 						<el-icon>
 							<Operation />
 							<Operation />
 						</el-icon>
 						</el-icon>
 					</PermissionButton>
 					</PermissionButton>
 				</div>
 				</div>
-				<div v-if="hasPermission('SkuAttrDelete')">
+				<div>
 					<el-popconfirm :icon="InfoFilled" icon-color="#626AEF" title="你确定要删除此项吗?" width="220" @confirm="onConfirm">
 					<el-popconfirm :icon="InfoFilled" icon-color="#626AEF" title="你确定要删除此项吗?" width="220" @confirm="onConfirm">
 						<template #reference>
 						<template #reference>
 							<PermissionButton circle plain type="danger">
 							<PermissionButton circle plain type="danger">

+ 33 - 3
src/views/price-approval/direct-sales/component/DataTableSlot.vue

@@ -1,4 +1,5 @@
 <script lang="ts" setup>
 <script lang="ts" setup>
+
 /**
 /**
  * @Name: DataTableSlot.vue
  * @Name: DataTableSlot.vue
  * @Description: 审批查看(直接销售)数据表格插槽
  * @Description: 审批查看(直接销售)数据表格插槽
@@ -20,11 +21,31 @@ const emit = defineEmits(['edit-row', 'handle-delete', 'handle-manage', 'show-de
 
 
 const countryInfoStore = useCountryInfoStore();
 const countryInfoStore = useCountryInfoStore();
 const country = countryInfoStore.Countries.find((c) => c.name == row.country_code);
 const country = countryInfoStore.Countries.find((c) => c.name == row.country_code);
-const color = country ? country.color : '#3875F6';
+const color = country ? country.color : '#626AEF';
 
 
 const currency = countryInfoStore.CurrencyCodes.find((c) => c.code == row.currency_code);
 const currency = countryInfoStore.CurrencyCodes.find((c) => c.code == row.currency_code);
 const currencyColor = currency ? currency.color : '#626AEF';
 const currencyColor = currency ? currency.color : '#626AEF';
 
 
+// 日常活动销售利润 = 日常活动售价(人民币)-出口报关价-头程运费-尾程费用-转发费-广告费-退货成本-VAT-仓储费-佣金
+const routine_activity_profit = computed(
+	() =>
+		row.price_daily_rmb -
+		(row.export_tax +
+			row.first_cost +
+			row.final_cost +
+			row.forwarding_fee +
+			row.ad_budget +
+			row.return_or_refurbishment +
+			row.storage_charges +
+			row.brokerage)
+);
+
+// 日常活动毛利率 = 日常活动销售利润/日常活动售价(人民币)
+const gross_margin_daily = computed(() => routine_activity_profit.value / row.price_daily_rmb);
+
+// 平均毛利 = 0.8*日常活动毛利率 + 0.2*毛利率
+const average_gross_profit = computed(() => 0.8 * gross_margin_daily.value + 0.2 * row.gross_profit_margin);
+
 function handleEdit() {
 function handleEdit() {
 	emit('edit-row', row);
 	emit('edit-row', row);
 }
 }
@@ -35,7 +56,7 @@ function onConfirm() {
 </script>
 </script>
 
 
 <template>
 <template>
-	<div class="font-medium">
+	<div class="font-semibold">
 		<div v-if="field === 'operate'">
 		<div v-if="field === 'operate'">
 			<div class="flex justify-center gap-2">
 			<div class="flex justify-center gap-2">
 				<div v-if="hasPermission('SkuAttrUpdate')">
 				<div v-if="hasPermission('SkuAttrUpdate')">
@@ -62,8 +83,17 @@ function onConfirm() {
 				</div>
 				</div>
 			</div>
 			</div>
 		</div>
 		</div>
+		<div v-else-if="field === 'average_gross_profit'">
+			{{ average_gross_profit || '-'}}
+		</div>
+		<div v-else-if="field === 'routine_activity_profit'">
+			{{ routine_activity_profit || '-'}}
+		</div>
+		<div v-else-if="field === 'gross_margin_daily'">
+			{{ gross_margin_daily || '-'}}
+		</div>
 		<div v-else-if="field === 'country_code'">
 		<div v-else-if="field === 'country_code'">
-			<el-tag :disable-transitions="true" :style="{ color: color, borderColor: color }" effect="plain" round>
+			<el-tag :disable-transitions="true" :style="{color: color, borderColor: color }" effect="plain" round>
 				{{ country ? country.name : '-' }}
 				{{ country ? country.name : '-' }}
 			</el-tag>
 			</el-tag>
 		</div>
 		</div>

+ 1 - 0
src/views/product-manage/component/ProductInfo.vue

@@ -29,6 +29,7 @@ const props = defineProps({
     default: false
     default: false
   }
   }
 });
 });
+
 </script>
 </script>
 
 
 <template>
 <template>