Ver código fonte

Merge branch 'xinyan' into dev

# Conflicts:
#	src/views/product-manage/Columns.ts
#	src/views/product-manage/comment-detail/index.vue
#	src/views/product-manage/historical-detail/index.vue
xinyan 6 meses atrás
pai
commit
db605a2753

+ 58 - 2
src/views/product-manage/Columns.ts

@@ -53,7 +53,7 @@ export const ProductColumns = [
     slots: { default: 'status' }
   },
   {
-    field: 'update_datetime', title: '更新时间', width: 160, align: 'center', 
+    field: 'update_datetime', title: '更新时间', width: 160, align: 'center',
     slots: { default: 'update_datetime' }
   },
   {
@@ -264,4 +264,60 @@ export const HistoricalColumns = [
     field: 'new_val', title: '现 值', align: 'center',
     slots: { default: 'new_val' }
   },
-] 
+]
+
+export const CompetitorMonitorCommentColumns = [
+  {
+    field: 'country_code', title: '国 家', width: 90, align: 'center',
+    slots: { default: 'country_code' }
+  },
+  {
+    field: 'title', title: '标 题', minWidth: 130, align: 'center',
+    slots: { default: 'title' }
+  },
+  {
+    field: 'reviewer_name', title: '用户昵称', minWidth: 110, align: 'center',
+    slots: { default: 'reviewer_name' }
+  },
+  {
+    field: 'review_date', title: '评论日期', width: 130, align: 'center',
+    slots: { default: 'review_date' }
+  },
+  {
+    field: 'content', title: '评论内容', minWidth: 400, align: 'center',
+    slots: { default: 'content' }
+  },
+  {
+    field: 'score', title: '用户评分', minWidth: 145, align: 'center',
+    slots: { default: 'score' }
+  },
+  {
+    field: 'tiv', title: '评论类型', width: 100, align: 'center',
+    slots: { default: 'tiv' }
+  },
+  {
+    field: 'helpful', title: '评论有用人数', width: 95, align: 'center',
+    slots: { default: 'helpful' }
+  },
+  {
+    field: 'verified', title: '认证购买', width: 90, align: 'center',
+    slots: { default: 'verified' }
+  },
+  {
+    field: 'create_datetime', title: '创建时间', width: 158, align: 'center',
+    slots: { default: 'create_datetime' }
+  },
+  {
+    field: 'operate', title: '操 作', width: 100, align: 'center', fixed: 'right',
+    slots: { default: 'operate' }
+  }
+]
+
+export const NegativeLabelColumns = [
+  {type:'seq', title: '序号', width:48, align: 'center'},
+  {field:'raw_label',title: '原始标签',minWidth: 120,align: 'center',},
+  {field:'kind',title: '类别',minWidth: 76,align: 'center',},
+  {field:'update_datetime',title: '更新时间',minWidth:96,align: 'center',},
+  {field:'create_datetime',title: '创建时间',minWidth:96,align: 'center',},
+  {field:'operate',title: '操作',align: 'center',slots: { default: 'operate' }}
+]

+ 59 - 0
src/views/product-manage/comment-detail/api.ts

@@ -1,5 +1,6 @@
 import { request } from '/@/utils/service';
 
+const apiPrefix = '/api/choice/reviews/';
 
 export function getChartData(query: any) {
   return request({
@@ -8,3 +9,61 @@ export function getChartData(query: any) {
     params: query
   });
 }
+
+export function getTableData(query: any) {
+  return request({
+    url: apiPrefix,
+    method: 'GET',
+    params: query
+  });
+}
+
+export function createComment(body: any) {
+  return request({
+    url: apiPrefix,
+    method: 'POST',
+    data: body
+  });
+}
+
+export function exportData(query: any) {
+  return request({
+    url: apiPrefix + 'export_data/',
+    method: 'GET',
+    params: query,
+    responseType: 'blob'
+  });
+}
+
+//负面标签
+export function getNegativeTags(query: any) {
+  return request({
+    url: '/api/choice/reviews_labels/',
+    method: 'GET',
+    params: query
+  });
+}
+
+export function createNegativeTags(body: any) {
+  return request({
+    url: '/api/choice/reviews_labels/',
+    method: 'POST',
+    data: body
+  });
+}
+
+export function updateNegativeTags(body: any){
+  return request({
+    url: '/api/choice/reviews_labels/' +`${body.id}/` ,
+    method: 'PUT',
+    data: body
+  });
+}
+
+export function deleteNegativeTags(body: any) {
+  return request({
+    url: '/api/choice/reviews_labels/' + `${body.id}/` ,
+    method: 'DELETE',
+    data: body
+  });
+}

+ 215 - 0
src/views/product-manage/comment-detail/component/CreateDialog.vue

@@ -0,0 +1,215 @@
+<script lang="ts" setup>
+/**
+ * @Name: CreateDialog.vue
+ * @Description: 评论详情-创建对话框
+ * @Author: xinyan
+ */
+
+import { ElMessage, FormInstance, FormRules } from 'element-plus';
+import { DictionaryStore } from '/@/stores/dictionary';
+import { useResponse } from '/@/utils/useResponse';
+import * as api from '../api';
+import { Close, Finished } from '@element-plus/icons-vue';
+import { useTivEnum } from '/@/views/product-manage/comment-detail/enum';
+
+const { data: staticData } = DictionaryStore();
+
+const loading = ref(false);
+const createDialog = <Ref>useTemplateRef('createDialog');
+const createOpen = defineModel({ default: false });
+
+const emit = defineEmits(['refresh']);
+
+interface RuleForm {
+	asin: any;
+	country_code: any;
+	content: any;
+	create_datetime: any;
+	helpful: any;
+	review_date: any;
+	reviewer_id: any;
+	reviewer_name: any;
+	rid: any;
+	score: any;
+	title: any;
+	tiv: number;
+	verified: any;
+}
+
+const ruleFormRef = ref<FormInstance>();
+const ruleForm = reactive<RuleForm>({
+	asin: '',
+	country_code: '',
+	content: '',
+	create_datetime: '',
+	helpful: null,
+	review_date: '',
+	reviewer_id: '',
+	reviewer_name: '',
+	rid: '',
+	score: '',
+	title: '',
+	tiv: null,
+	verified: false,
+});
+
+const rules = reactive<FormRules<RuleForm>>({
+	asin: [{ required: true, message: '请输入ASIN', trigger: 'blur' }],
+	country_code: [{ required: true, message: '请选择国家', trigger: 'blur' }],
+	content: [{ required: true, message: '请输入评论内容', trigger: 'blur' }],
+	create_datetime: [{ required: true, message: '请选择创建时间', trigger: 'blur' }],
+	helpful: [
+		{ required: true, message: '请输入评论有用人数', trigger: 'blur' },
+		{
+			type: 'number',
+			message: '必须为大于0的整数',
+			transform(value) {
+				return Number(value);
+			},
+			validator(rule, value, callback) {
+				if (value < 0 || !Number.isInteger(value)) {
+					callback(new Error('必须为大于0的整数'));
+				} else {
+					callback();
+				}
+			},
+			trigger: 'blur',
+		},
+	],
+	review_date: [{ required: true, message: '请选择评论日期', trigger: 'blur' }],
+	reviewer_id: [{ required: true, message: '请输入用户ID', trigger: 'blur' }],
+	reviewer_name: [{ required: true, message: '请输入用户昵称', trigger: 'blur' }],
+	rid: [{ required: true, message: '请输入评论ID', trigger: 'blur' }],
+	score: [{ required: true, message: '请输入用户评分', trigger: 'blur' }],
+	title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
+	tiv: [{ required: true, message: '请输入评论类型', trigger: 'blur' }],
+});
+
+// TODO: 后端接口报错
+const submitForm = async (formEl: FormInstance | undefined) => {
+	if (!formEl) return;
+	await formEl.validate(async (valid, fields) => {
+		if (valid) {
+			const body = {
+				asin: ruleForm.asin,
+				country_code: ruleForm.country_code,
+				content: ruleForm.content,
+				create_datetime: ruleForm.create_datetime,
+				helpful: ruleForm.helpful,
+				review_date: dayjs(ruleForm.review_date).format('YYYY-MM-DD'),
+				reviewer_id: ruleForm.reviewer_id,
+				reviewer_name: ruleForm.reviewer_name,
+				rid: ruleForm.rid,
+				score: ruleForm.score,
+				title: ruleForm.title,
+				tiv: ruleForm.tiv,
+				verified: ruleForm.verified,
+			};
+			const res = await useResponse(api.createComment, body, loading);
+			if (res.code === 2000) {
+				ElMessage.success('创建成功');
+				createOpen.value = false;
+				emit('refresh');
+			}
+		} else {
+			// createOpen.value = false;
+			ElMessage.error('创建失败,请检查表单');
+		}
+	});
+};
+
+function cancelDialog() {
+	resetForm(ruleFormRef.value);
+	createDialog.value.visible = false;
+}
+
+const resetForm = (formEl: FormInstance | undefined) => {
+	if (!formEl) return;
+	formEl.resetFields();
+};
+</script>
+
+<template>
+	<el-dialog
+		ref="createDialog"
+		v-model="createOpen"
+		:close-on-click-modal="false"
+		:close-on-press-escape="false"
+		:title="`评论详情 - 创建 `"
+		style="width: 40%"
+	>
+		<el-form ref="ruleFormRef" :model="ruleForm" :rules="rules" class="mx-2.5 mt-5" label-position="top" label-width="auto" status-icon>
+			<el-row :gutter="20">
+				<el-col :span="12">
+					<el-form-item class="font-medium" label="ASIN" prop="asin">
+						<el-input v-model="ruleForm.asin" />
+					</el-form-item>
+
+					<el-form-item class="font-medium" label="国 家" prop="country_code">
+						<el-select v-model="ruleForm.country_code" placeholder="请选择国家">
+							<el-option v-for="item in staticData.country_code" :key="item.value" :label="item.label" :value="item.value" />
+						</el-select>
+					</el-form-item>
+
+					<el-form-item class="font-medium" label="用户ID" prop="reviewer_id">
+						<el-input v-model="ruleForm.reviewer_id" />
+					</el-form-item>
+
+					<el-form-item class="font-medium" label="评论日期" prop="review_date">
+						<el-date-picker v-model="ruleForm.review_date" format="YYYY-MM-DD" style="width: 100%" type="date" />
+					</el-form-item>
+
+					<el-form-item class="font-medium" label="用户评分" prop="score">
+						<el-input-number v-model="ruleForm.score" max="5" min="1" />
+					</el-form-item>
+
+					<el-form-item class="font-medium" label="评论有用人数" prop="helpful">
+						<el-input v-model="ruleForm.helpful" />
+					</el-form-item>
+
+					<el-form-item class="font-medium" label="创建时间" prop="create_datetime">
+						<el-date-picker v-model="ruleForm.create_datetime" style="width: 100%" type="datetime" />
+					</el-form-item>
+				</el-col>
+
+				<el-col :span="12">
+					<el-form-item class="font-medium" label="评论ID" prop="rid">
+						<el-input v-model="ruleForm.rid" />
+					</el-form-item>
+
+					<el-form-item class="font-medium" label="标题" prop="title">
+						<el-input v-model="ruleForm.title" />
+					</el-form-item>
+
+					<el-form-item class="font-medium" label="用户昵称" prop="reviewer_name">
+						<el-input v-model="ruleForm.reviewer_name" />
+					</el-form-item>
+
+					<el-form-item class="font-medium" label="评价内容" prop="content">
+						<el-input v-model="ruleForm.content" />
+					</el-form-item>
+
+					<el-form-item class="font-medium" label="评论类型" prop="tiv">
+						<el-select v-model="ruleForm.tiv" placeholder="请选择评论类型">
+							<el-option v-for="item in useTivEnum" :key="item.value" :label="item.label" :value="item.value"></el-option>
+						</el-select>
+					</el-form-item>
+
+					<el-form-item class="font-medium" label="认证购买" prop="verified">
+						<el-switch v-model="ruleForm.verified" />
+					</el-form-item>
+				</el-col>
+			</el-row>
+		</el-form>
+		<template #footer>
+			<el-button :icon="Close" @click="cancelDialog">取 消</el-button>
+			<el-button :icon="Finished" :loading="loading" type="primary" @click="submitForm(ruleFormRef)">确 定</el-button>
+		</template>
+	</el-dialog>
+</template>
+
+<style scoped>
+:deep(.el-drawer .el-drawer__header) {
+	border: none !important;
+}
+</style>

+ 100 - 0
src/views/product-manage/comment-detail/component/CreateLabelDialog.vue

@@ -0,0 +1,100 @@
+<script lang="ts" setup>
+/**
+ * @Name: CreateLabelDialog.vue
+ * @Description: 负面标签-创建弹窗
+ * @Author: xinyan
+ */
+import { Close, Finished } from '@element-plus/icons-vue';
+import { ElMessage, FormInstance, FormRules } from 'element-plus';
+import { useResponse } from '/@/utils/useResponse';
+import * as api from '../api';
+
+const props = defineProps({
+	rowData: <any>Object,
+});
+const { rowData } = props;
+
+const loading = ref(false);
+
+const createOpen = defineModel({ default: false });
+const createDialog = <Ref>useTemplateRef('createDialog');
+
+const emit = defineEmits(['refresh']);
+
+interface RuleForm {
+	raw_label: any;
+	kind: any;
+}
+
+const ruleFormRef = ref<FormInstance>();
+const ruleForm = reactive<RuleForm>({
+	raw_label: '',
+	kind: '',
+});
+
+const rules = reactive<FormRules<RuleForm>>({
+	raw_label: [{ required: true, message: '请输入原始标签', trigger: 'blur' }],
+});
+
+const submitForm = async (formEl: FormInstance | undefined) => {
+	if (!formEl) return;
+	await formEl.validate(async (valid, fields) => {
+		if (valid) {
+			const body = {
+				review: rowData.id,
+				raw_label: ruleForm.raw_label,
+				kind: ruleForm.kind,
+			};
+			const res = await useResponse(api.createNegativeTags, body, loading);
+			if (res.code === 2000) {
+				ElMessage.success('创建成功');
+				createOpen.value = false;
+				emit('refresh');
+			}
+		} else {
+			// createOpen.value = false;
+			ElMessage.error('创建失败,请检查表单');
+		}
+	});
+};
+
+function cancelDialog() {
+	resetForm(ruleFormRef.value);
+	createDialog.value.visible = false;
+}
+
+const resetForm = (formEl: FormInstance | undefined) => {
+	if (!formEl) return;
+	formEl.resetFields();
+};
+</script>
+
+<template>
+	<el-dialog
+		ref="createDialog"
+		v-model="createOpen"
+		:close-on-click-modal="false"
+		:close-on-press-escape="false"
+		:title="`负面标签 - 创建 `"
+		style="width: 35%"
+	>
+		<el-form ref="ruleFormRef" :model="ruleForm" :rules="rules" class="mx-2.5 mt-5" label-width="auto" status-icon>
+			<el-row :gutter="20">
+				<el-col :span="24">
+					<el-form-item class="font-medium" label="原始标签" prop="raw_label">
+						<el-input v-model="ruleForm.raw_label" type="textarea" maxlength="150" show-word-limit :autosize="{ minRows: 2, maxRows: 4 }"/>
+					</el-form-item>
+					<el-form-item class="font-medium" label="类别" prop="kind">
+						<el-input v-model="ruleForm.kind" type="textarea" maxlength="150" show-word-limit/>
+					</el-form-item>
+				</el-col>
+			</el-row>
+		</el-form>
+		<template #footer>
+			<el-button :icon="Close" @click="cancelDialog">取 消</el-button>
+			<el-button :icon="Finished" :loading="loading" type="primary" @click="submitForm(ruleFormRef)">确 定</el-button>
+		</template>
+	</el-dialog>
+</template>
+
+<style scoped></style>

+ 282 - 0
src/views/product-manage/comment-detail/component/DataTable.vue

@@ -0,0 +1,282 @@
+<script lang="ts" setup>
+/**
+ * @Name: Table.vue
+ * @Description: 竞品监控表格
+ * @Author: Cheney
+ */
+
+import { Delete, Download, InfoFilled, Plus, Refresh, RefreshRight, Search, Upload } from '@element-plus/icons-vue';
+import { ElMessage } from 'element-plus';
+import { usePagination } from '/@/utils/usePagination';
+import { useTableData } from '/@/utils/useTableData';
+import { useResponse } from '/@/utils/useResponse';
+import { CompetitorMonitorCommentColumns } from '/@/views/product-manage/Columns';
+import DataTableSlot from './DataTableSlot.vue';
+import PermissionButton from '/src/components/PermissionButton/index.vue';
+import ImportButton from '/src/components/ImportButton/index.vue';
+import VerticalDivider from '/src/components/VerticalDivider/index.vue';
+import * as api from '../api';
+import { downloadFile } from '/@/utils/service';
+import { getTableData } from '../api';
+import CreateDialog from '/@/views/product-manage/comment-detail/component/CreateDialog.vue';
+import { DictionaryStore } from '/@/stores/dictionary';
+import { useScoreEnum, useTivEnum } from '/@/views/product-manage/comment-detail/enum';
+import NegativeLabel from '/@/views/product-manage/comment-detail/component/NegativeLabel.vue';
+
+const { data: staticData } = DictionaryStore();
+
+const btnLoading = ref(false);
+
+const formInline = reactive<any>({
+	country: '',
+	score: '',
+	tiv: ''
+});
+
+const props = defineProps({
+	asin: String
+});
+const { asin } = props;
+const { tableOptions, handlePageChange } = usePagination(fetchList);
+
+const gridRef = ref();
+const gridOptions: any = reactive({
+  // id: 'competitor-monitor-comment',
+  // keepSource: true,
+  size: 'mini',
+  border: false,
+  round: true,
+  stripe: true,
+  showHeader: true,
+  currentRowHighLight: true,
+  height: 650,
+	// customConfig: {
+	// 	storage: {
+	// 		visible: true,
+	// 		resizable:false,
+	// 	}
+	// },
+  toolbarConfig: {
+    size: 'large',
+    // custom: true,
+    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 createOpen = ref(false);
+const rowData = ref<any>({});
+
+const isShowLabel = ref(false);
+
+onMounted(() => {
+  fetchList();
+});
+
+// TODO: 删除goods
+async function fetchList() {
+  gridOptions.data = [];
+  gridOptions.columns = [];
+
+  const query = {
+		asin: asin,
+		country_code:formInline?.country,
+		score: formInline?.score,
+		tiv: formInline?.tiv,
+  };
+  await useTableData(api.getTableData, query, gridOptions);
+  await gridRef.value.loadColumn(CompetitorMonitorCommentColumns);
+  // gridOptions.showHeader = Boolean(gridOptions.data?.length);
+}
+
+function handleRefresh() {
+  fetchList();
+}
+
+async function handleDownload() {
+  gridOptions.loading = true;
+  try {
+    const query = {
+			asin: asin,
+			country_code:formInline?.country,
+			score: formInline?.score,
+			tiv: formInline?.tiv,
+    };
+    const response = await api.exportData(query);
+    const url = window.URL.createObjectURL(new Blob([ response.data ]));
+    const link = document.createElement('a');
+    link.href = url;
+    link.setAttribute('download', '评论详情数据.xlsx');
+    document.body.appendChild(link);
+    link.click();
+    ElMessage.success('数据导出成功!');
+  } catch (error) {
+    ElMessage.error('数据导出失败,请重试!');
+    console.error(error);
+  } finally {
+    gridOptions.loading = false;
+  }
+}
+
+function handleCreate() {
+	createOpen.value = true;
+}
+
+function showLabel(row: any)
+{
+  isShowLabel.value = true;
+  rowData.value = row;
+}
+
+async function handleQuery() {
+	btnLoading.value = true;
+	await fetchList();
+	btnLoading.value = false;
+}
+
+function resetParameter() {
+	for (const key in formInline) {
+		formInline[key] = '';
+	}
+}
+
+defineExpose({ fetchList });
+
+</script>
+
+<template>
+	<!--查询条件-->
+	<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="6">
+						<div class="flex items-center">
+							<span class="mr-2">国 家</span>
+							<el-select v-model="formInline.country" clearable placeholder="请选择国家">
+								<el-option v-for="item in staticData.country_code" :key="item.value" :label="item.label"
+													 :value="item.value" />
+							</el-select>
+						</div>
+					</el-col>
+					<el-col :span="6">
+						<div class="flex items-center">
+							<span class="mr-2">用户评分</span>
+							<el-select v-model="formInline.score" clearable placeholder="请选择用户评分">
+								<el-option v-for="item in useScoreEnum" :key="item.value" :label="item.label"
+													 :value="item.value" />
+							</el-select>
+						</div>
+					</el-col>
+					<el-col :span="6">
+						<div class="flex items-center">
+							<span class="mr-2">评论类型</span>
+							<el-select v-model="formInline.tiv" clearable placeholder="请选择评论类型">
+								<el-option v-for="item in useTivEnum" :key="item.value" :label="item.label"
+													 :value="item.value" />
+							</el-select>
+						</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="RefreshRight" color="#ECECF1C9" style="width: 88px; color: #3c3c3c;" @click="resetParameter">
+				重 置
+			</el-button>
+		</div>
+	</div>
+	<el-divider ref="dividerContainer" style="margin: 20px 0 12px 0;" />
+  <vxe-grid ref="gridRef" v-bind="gridOptions">
+    <template #toolbar_buttons>
+      <div class="flex gap-2">
+        <PermissionButton :icon="Plus" plain round type="primary" @click="handleCreate">
+          新 增
+        </PermissionButton>
+        <VerticalDivider class="px-1" style="margin-left: 7px;" />
+      </div>
+    </template>
+    <template #toolbar_tools>
+      <el-button circle class="toolbar-btn" @click="handleRefresh">
+        <el-icon>
+          <Refresh />
+        </el-icon>
+      </el-button>
+      <el-popconfirm
+          :icon="InfoFilled"
+          icon-color="#626AEF"
+          title="是否确认导出所有数据项?"
+          width="220"
+          @confirm="handleDownload"
+      >
+        <template #reference>
+          <el-button circle class="mr-3 toolbar-btn">
+            <el-icon>
+              <Download />
+            </el-icon>
+          </el-button>
+        </template>
+        <template #actions="{ confirm, cancel }">
+          <el-button size="small" @click="cancel">No!</el-button>
+          <el-button size="small" type="danger" @click="confirm">
+            Yes?
+          </el-button>
+        </template>
+      </el-popconfirm>
+    </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 v-for="col in CompetitorMonitorCommentColumns" #[`${col.field}`]="{ row }">
+      <DataTableSlot :key="row.id" :field="col.field" :row="row" @open-negativeLabel="showLabel"/>
+    </template>
+  </vxe-grid>
+	<CreateDialog v-if="createOpen" v-model="createOpen" @refresh="fetchList" />
+	<NegativeLabel v-if="isShowLabel" v-model="isShowLabel" :rowData="rowData"></NegativeLabel>
+</template>
+
+<style scoped>
+.toolbar-btn {
+  width: 34px;
+  height: 34px;
+  font-size: 18px
+}
+
+:deep(.custom-el-input .el-select__wrapper) {
+  border-radius: 20px;
+}
+</style>

+ 144 - 0
src/views/product-manage/comment-detail/component/DataTableSlot.vue

@@ -0,0 +1,144 @@
+<script lang="ts" setup>
+/**
+ * @Name: DataTableSlot.vue
+ * @Description: 竞品监控-表格插槽
+ * @Author: Cheney
+ */
+
+import { useCountryInfoStore } from '/@/stores/countryInfo';
+import { Delete, InfoFilled, Operation, Tickets, Timer } from '@element-plus/icons-vue';
+import { getTagType } from '/@/utils/useTagColor';
+import PermissionButton from '/@/components/PermissionButton/index.vue';
+import ProductInfo from '/@/views/product-manage/component/ProductInfo.vue';
+import ProgressBar from '/@/views/product-manage/product-monitor/component/ProgressBar.vue';
+import { useTivEnum } from '/@/views/product-manage/comment-detail/enum';
+
+
+const props = defineProps<{
+  row: any,
+  field: any
+}>();
+const { row, field } = props;
+
+const emit: any = defineEmits([ 'open-negativeLabel']);
+
+const countryInfoStore = useCountryInfoStore();
+const country = countryInfoStore.Countries.find(c => c.code == row.country_code);
+const color = country ? country.color : '#3875F6';
+
+function getCommentType(value): string {
+	const type = useTivEnum.find(item => item.value === value);
+	return type ? type.label : '未知';
+}
+
+function getTagType(type: number): string {
+	switch (type) {
+		case 0:
+			return 'success';
+		case 1:
+			return 'primary';
+		case 2:
+			return 'warning';
+		case 3:
+			return 'danger';
+		default:
+			return 'info';
+	}
+}
+
+function handleNegativeLabel() {
+  emit('open-negativeLabel', row);
+}
+
+</script>
+
+<template>
+  <div class="font-medium">
+    <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 === 'title'">
+			<span style="font-weight: bold;">{{ row.title }}</span>
+		</div>
+		<div v-else-if="field === 'reviewer_name'">
+				<span style="font-weight: bold;">{{ row.reviewer_name }}</span>
+		</div>
+		<div v-else-if="field === 'content'">
+			<el-tooltip popper-class="custom-tooltip" effect="dark" :content="row.content" placement="top" :disabled="row.content.length <= 50">
+				<span class="content-ellipsis">{{ row.content }}</span>
+			</el-tooltip>
+		</div>
+    <div v-else-if="field === 'score'">
+      <template v-if="row.score !== null && row.score !== undefined && row.score !== ''">
+        <el-rate
+            v-if="row.score > 0"
+            v-model="row.score"
+            :colors="['#FF0000', '#FF9900', '#67C23A']"
+            disabled
+            text-color="#1e293b"
+        />
+        <span v-else>{{ row.score }}</span>
+      </template>
+      <template v-else>
+        <span>-</span>
+      </template>
+    </div>
+		<div v-else-if="field === 'tiv'">
+			<el-tag :type="getTagType(row.tiv)">
+				{{ getCommentType(row.tiv) }}
+			</el-tag>
+		</div>
+		<div v-else-if="field === 'helpful'">
+				<span >{{ row.helpful }}</span>
+		</div>
+		<div v-else-if="field === 'verified'">
+			<el-switch v-model="row.verified" disabled></el-switch>
+		</div>
+		<div v-else-if="field === 'create_datetime'">
+				<span >{{ row.create_datetime }}</span>
+		</div>
+    <div v-else-if="field === 'operate'">
+      <div class="flex justify-center gap-2 mb-2">
+          <PermissionButton size="small" type="success" @click="handleNegativeLabel">
+						负面标签
+          </PermissionButton>
+      </div>
+    </div>
+    <div v-else>
+      {{ row[field] || '-' }}
+    </div>
+  </div>
+</template>
+
+<style scoped>
+:deep(.flex-1 .el-progress__text) {
+  font-size: 14px !important;
+}
+</style>
+
+<style lang="scss">
+.custom-btn-tooltip {
+  background-color: #EFF9EB !important;
+  color: #606266 !important;
+  border: 1px solid #67C23A !important;
+  font-size: 14px;
+}
+
+.content-ellipsis {
+	display: -webkit-box;
+	-webkit-box-orient: vertical;
+	-webkit-line-clamp: 3; /* 显示的行数 */
+	overflow: hidden;
+	text-overflow: ellipsis;
+	max-height: 4.5em; /* 根据字号和行高设置最大高度 */
+	line-height: 1.5em; /* 控制行高,与你的内容相匹配 */
+}
+
+.custom-tooltip {
+	max-width: 350px;  /* 设置最大宽度 */
+	white-space: pre-wrap; /* 允许文本换行 */
+	word-wrap: break-word; /* 处理长单词换行 */
+}
+</style>

+ 102 - 0
src/views/product-manage/comment-detail/component/EditLabelDialog.vue

@@ -0,0 +1,102 @@
+<script setup lang="ts">
+/**
+ * @Name: EditLabelDialog.vue
+ * @Description: 负面标签-编辑弹窗
+ * @Author: xinyan
+ */
+import { Close, Finished } from '@element-plus/icons-vue';
+import { ElMessage, FormInstance, FormRules } from 'element-plus';
+import { useResponse } from '/@/utils/useResponse';
+import * as api from '../api';
+import { updateNegativeTags } from '../api';
+
+const props = defineProps({
+	editData: <any>Object,
+	rowData: <any>Object,
+});
+const { editData , rowData } = props;
+
+const loading = ref(false);
+
+const editOpen = defineModel({ default: false });
+const editDialog = <Ref>useTemplateRef('editDialog');
+
+const emit = defineEmits(['refresh']);
+
+interface RuleForm {
+	raw_label: any;
+	kind: any;
+}
+
+const ruleFormRef = ref<FormInstance>();
+const ruleForm = reactive<RuleForm>({
+	raw_label: editData?.raw_label,
+	kind: editData?.kind,
+});
+
+const rules = reactive<FormRules<RuleForm>>({
+	raw_label: [{ required: true, message: '请输入原始标签', trigger: 'blur' }],
+});
+
+const submitForm = async (formEl: FormInstance | undefined) => {
+	if (!formEl) return;
+	await formEl.validate(async (valid, fields) => {
+		if (valid) {
+			const query = {
+				review: rowData.id,
+				raw_label: ruleForm.raw_label,
+				kind: ruleForm.kind,
+			};
+			const res = await useResponse(api.updateNegativeTags, { id: editData?.id, ...query }, loading);
+			if (res.code === 2000) {
+				ElMessage.success('更新成功');
+				editOpen.value = false;
+				emit('refresh');
+			}
+		} else {
+			// editOpen.value = false;
+			ElMessage.error('更新失败,请检查表单');
+		}
+	});
+};
+
+function cancelDialog() {
+	resetForm(ruleFormRef.value);
+	editDialog.value.visible = false;
+}
+
+const resetForm = (formEl: FormInstance | undefined) => {
+	if (!formEl) return;
+	formEl.resetFields();
+};
+</script>
+
+<template>
+	<el-dialog
+		ref="editDialog"
+		v-model="editOpen"
+		:close-on-click-modal="false"
+		:close-on-press-escape="false"
+		:title="`负面标签 - 编辑 `"
+		style="width: 35%"
+	>
+		<el-form ref="ruleFormRef" :model="ruleForm" :rules="rules" class="mx-2.5 mt-5" label-width="auto" status-icon>
+			<el-row :gutter="20">
+				<el-col :span="24">
+					<el-form-item class="font-medium" label="原始标签" prop="raw_label">
+						<el-input v-model="ruleForm.raw_label" type="textarea" maxlength="150" show-word-limit :autosize="{ minRows: 2, maxRows: 4 }"/>
+					</el-form-item>
+					<el-form-item class="font-medium" label="类别" prop="kind">
+						<el-input v-model="ruleForm.kind" type="textarea" maxlength="150" show-word-limit/>
+					</el-form-item>
+				</el-col>
+			</el-row>
+		</el-form>
+		<template #footer>
+			<el-button :icon="Close" @click="cancelDialog">取 消</el-button>
+			<el-button :icon="Finished" :loading="loading" type="primary" @click="submitForm(ruleFormRef)">确 定</el-button>
+		</template>
+	</el-dialog>
+</template>
+
+<style scoped></style>

+ 195 - 3
src/views/product-manage/comment-detail/component/NegativeLabel.vue

@@ -4,15 +4,207 @@
  * @Description: 负面标签
  * @Author: Cheney
  */
+import { Delete, DocumentCopy, Edit, InfoFilled, Plus } from '@element-plus/icons-vue';
+import * as api from '../api';
+import PermissionButton from '/@/components/PermissionButton/index.vue';
+import { usePagination } from '/@/utils/usePagination';
+import { useTableData } from '/@/utils/useTableData';
+import { NegativeLabelColumns } from '/@/views/product-manage/Columns';
+import CreateLabelDialog from '/@/views/product-manage/comment-detail/component/CreateLabelDialog.vue';
+import EditLabelDialog from '/@/views/product-manage/comment-detail/component/EditLabelDialog.vue';
+import { useResponse } from '/@/utils/useResponse';
+import { ElMessage } from 'element-plus';
+import { handleCopy } from '/@/utils/useCopyText';
 
+const isShowLabel = defineModel({ default: false });
+
+const props = defineProps({
+	rowData: <any>Object,
+});
+
+const { rowData } = props;
+const { tableOptions, handlePageChange } = usePagination(fetchList);
+
+const createOpen = ref(false);
+const editOpen = ref(false);
+
+const editData = ref<any>();
+
+const gridRef = ref();
+const gridOptions: any = reactive({
+	// id: 'NegativeLabel-table',
+	// keepSource: true,
+	height: 620,
+	size: 'mini',
+	border: false,
+	round: true,
+	stripe: true,
+	showHeader: true,
+	showOverflow: true,
+	currentRowHighLight: true,
+	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: '',
+});
+
+async function fetchList() {
+	gridOptions.data = [];
+	gridOptions.columns = [];
+
+	const query = {
+		review: rowData.id,
+	};
+	await useTableData(api.getNegativeTags, query, gridOptions);
+	await gridRef.value.loadColumn(NegativeLabelColumns);
+	gridOptions.showHeader = Boolean(gridOptions.data?.length);
+}
+
+function handleCreate() {
+	createOpen.value = true;
+}
+
+function handleEdit(row) {
+	editOpen.value = true;
+	editData.value = row;
+}
+
+async function singleDelete(row: any) {
+	const res = await useResponse(api.deleteNegativeTags, row);
+	if (res.code === 2000) {
+		ElMessage.success({ message: '删除成功', plain: true });
+		await fetchList();
+	}
+}
+
+function cellStyle() {
+	return{
+		fontWeight: 500,
+	}
+}
+
+onMounted(() => {
+	fetchList();
+});
 </script>
 
 <template>
-  <el-card shadow="hover" class="border-none">
-    负面标签
-  </el-card>
+	<div class="drawer-container">
+		<el-drawer
+			ref="editDrawer"
+			v-model="isShowLabel"
+			:show-close="false"
+			direction="rtl"
+			size="50%"
+			style="background-color: #f3f4fb"
+			title="负面标签"
+		>
+			<div class="sticky top-0" style="background-color: #f3f4fb; min-height: 20px; z-index: 2"></div>
+			<div class="px-5">
+				<el-card class="mb-3">
+					<el-descriptions :column="1" direction="vertical">
+						<el-descriptions-item>
+							<template #label>
+								<div class="title-item">标题</div>
+							</template>
+							<span class="cell-item">{{ rowData.title }}</span>
+						</el-descriptions-item>
+						<el-descriptions-item>
+							<template #label>
+								<div class="title-item">评论内容</div>
+							</template>
+							<span class="cell-item">{{ rowData.content }}</span>
+							<el-icon class="ml-2 cursor-pointer" @click="handleCopy(rowData.content)">
+								<DocumentCopy />
+							</el-icon>
+						</el-descriptions-item>
+					</el-descriptions>
+				</el-card>
+				<el-card>
+					<vxe-grid ref="gridRef" v-bind="gridOptions" :cell-style="cellStyle">
+						<template #toolbar_buttons>
+							<div class="flex gap-2">
+								<PermissionButton :icon="Plus" plain round type="primary" @click="handleCreate"> 新 增 </PermissionButton>
+							</div>
+						</template>
+						<template #operate="{ row }">
+							<div class="flex justify-center gap-2">
+							<el-button :icon="Edit" link type="primary" @click="handleEdit(row)"></el-button>
+							<el-popconfirm
+								:icon="InfoFilled"
+								icon-color="#626AEF"
+								title="你确定要删除此项吗?"
+								width="220"
+								@confirm="singleDelete(row)"
+							>
+								<template #reference>
+									<PermissionButton link type="danger">
+										<el-icon>
+											<Delete />
+										</el-icon>
+									</PermissionButton>
+								</template>
+								<template #actions="{ confirm, cancel }">
+									<el-button size="small" @click="cancel">No!</el-button>
+									<el-button
+										size="small"
+										type="danger"
+										@click="confirm"
+									>
+										Yes?
+									</el-button>
+								</template>
+							</el-popconfirm>
+								</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>
+					</vxe-grid>
+				</el-card>
+			</div>
+		</el-drawer>
+		<CreateLabelDialog v-if="createOpen" v-model="createOpen" :rowData="rowData" @refresh="fetchList"></CreateLabelDialog>
+		<EditLabelDialog v-if="editOpen" v-model="editOpen" :editData="editData" :rowData="rowData" @refresh="fetchList"></EditLabelDialog>
+	</div>
 </template>
 
 <style scoped>
+.title-item {
+	font-weight: bold;
+	font-size: 16px;
+}
 
+.cell-item {
+	font-size: 14px;
+	font-weight: 600;
+	color: #666;
+}
 </style>

+ 14 - 0
src/views/product-manage/comment-detail/enum.ts

@@ -0,0 +1,14 @@
+export const useTivEnum = [
+	{value: 0, label: '纯文本'},
+	{value: 1, label: '附图片'},
+	{value: 2, label: '附视频'},
+	{value: 3, label: '附图片和视频'},
+]
+
+export const useScoreEnum = [
+	{value: 1, label: '一星'},
+	{value: 2, label: '两星'},
+	{value: 3, label: '三星'},
+	{value: 4, label: '四星'},
+	{value: 5, label: '五星'},
+]

+ 12 - 0
src/views/product-manage/comment-detail/index.vue

@@ -5,9 +5,15 @@
  * @Author: Cheney
  */
 
+import { DocumentCopy, Picture as IconPicture, RefreshRight, Search } from '@element-plus/icons-vue';
+import NegativeLabel from '/@/views/product-manage/comment-detail/component/NegativeLabel.vue';
+import NegativeClassification from '/@/views/product-manage/comment-detail/component/NegativeClassification.vue';
 import AverageMonthly from '/@/views/product-manage/comment-detail/component/AverageMonthly.vue';
 import TitleCard from './component/TitleCard.vue';
 
+import VerticalDivider from '/@/components/VerticalDivider/index.vue';
+import { handleCopy } from '/@/utils/useCopyText';
+import DataTable from '/@/views/product-manage/comment-detail/component/DataTable.vue';
 
 const isShowComment = defineModel({ default: false });
 
@@ -47,6 +53,12 @@ const { rowData } = props;
         <div class="mt-5">
           <AverageMonthly :asin="rowData.asin" />
         </div>
+				<!-- Data Table -->
+				<div class="mt-5">
+				<el-card class="border-none" shadow="hover">
+						<DataTable :asin="rowData.asin" />
+				</el-card>
+				</div>
       </div>
     </el-drawer>