2026-04-24 16:38:43 +08:00
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { ref, watch } from 'vue';
|
|
|
|
|
|
import { fetchGetProductActivityTimelinePage } from '@/service/api';
|
|
|
|
|
|
import {
|
|
|
|
|
|
DEFAULT_PRODUCT_ACTIVITY_PAGE_SIZE,
|
|
|
|
|
|
buildProductActivityDisplayItems,
|
|
|
|
|
|
buildProductActivityRange,
|
|
|
|
|
|
formatProductActivityTime
|
|
|
|
|
|
} from '../product-activity';
|
|
|
|
|
|
import ProductActivityTimelineDialog from './product-activity-timeline-dialog.vue';
|
|
|
|
|
|
|
|
|
|
|
|
defineOptions({ name: 'ProductActivityTimelinePanel' });
|
|
|
|
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
|
productId: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface Emits {
|
|
|
|
|
|
(e: 'latest-time-change', value: string): void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const props = defineProps<Props>();
|
|
|
|
|
|
const emit = defineEmits<Emits>();
|
|
|
|
|
|
|
|
|
|
|
|
const dialogVisible = ref(false);
|
|
|
|
|
|
const loading = ref(false);
|
|
|
|
|
|
const loadError = ref(false);
|
|
|
|
|
|
const items = ref<ReturnType<typeof buildProductActivityDisplayItems>>([]);
|
|
|
|
|
|
|
|
|
|
|
|
async function loadRecentActivities() {
|
|
|
|
|
|
if (!props.productId) {
|
|
|
|
|
|
items.value = [];
|
|
|
|
|
|
loadError.value = false;
|
|
|
|
|
|
emit('latest-time-change', '');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
loading.value = true;
|
|
|
|
|
|
loadError.value = false;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [startTime, endTime] = buildProductActivityRange(30);
|
|
|
|
|
|
const result = await fetchGetProductActivityTimelinePage(props.productId, {
|
|
|
|
|
|
pageNo: 1,
|
|
|
|
|
|
pageSize: DEFAULT_PRODUCT_ACTIVITY_PAGE_SIZE,
|
|
|
|
|
|
startTime,
|
|
|
|
|
|
endTime
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (result.error || !result.data) {
|
|
|
|
|
|
loadError.value = true;
|
|
|
|
|
|
items.value = [];
|
|
|
|
|
|
emit('latest-time-change', '');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
items.value = buildProductActivityDisplayItems(result.data.list);
|
|
|
|
|
|
emit('latest-time-change', formatProductActivityTime(result.data.list[0]?.occurredAt) || '');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openDialog() {
|
|
|
|
|
|
if (!props.productId) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
dialogVisible.value = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
|
() => props.productId,
|
|
|
|
|
|
async () => {
|
|
|
|
|
|
await loadRecentActivities();
|
|
|
|
|
|
},
|
|
|
|
|
|
{ immediate: true }
|
|
|
|
|
|
);
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<ElCard class="product-activity-panel card-wrapper">
|
|
|
|
|
|
<template #header>
|
|
|
|
|
|
<div class="product-activity-panel__header">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h3 class="product-activity-panel__title">产品动态时间线</h3>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<ElButton text type="primary" :disabled="!productId" @click="openDialog">更多</ElButton>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-loading="loading" class="product-activity-panel__body">
|
|
|
|
|
|
<div v-if="loadError" class="product-activity-panel__state">
|
|
|
|
|
|
<ElEmpty description="产品动态加载失败" :image-size="88" />
|
|
|
|
|
|
<ElButton type="primary" plain @click="loadRecentActivities">重新加载</ElButton>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-else-if="items.length" class="product-activity-panel__timeline">
|
|
|
|
|
|
<article v-for="item in items" :key="item.id" class="product-activity-panel__item">
|
|
|
|
|
|
<div class="product-activity-panel__rail">
|
|
|
|
|
|
<span class="product-activity-panel__dot" :class="`product-activity-panel__dot--${item.tone}`" />
|
|
|
|
|
|
<span class="product-activity-panel__line" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="product-activity-panel__content">
|
|
|
|
|
|
<div class="product-activity-panel__meta">
|
|
|
|
|
|
<div class="product-activity-panel__meta-main">
|
|
|
|
|
|
<ElTag effect="plain" size="small">{{ item.tagLabel }}</ElTag>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span class="product-activity-panel__time">{{ item.timeText }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<p class="product-activity-panel__sentence">
|
2026-05-09 11:30:34 +08:00
|
|
|
|
<span class="product-activity-panel__sentence-main">
|
|
|
|
|
|
<template v-for="(part, index) in item.compactTextParts" :key="`${item.id}-${index}`">
|
|
|
|
|
|
<strong v-if="part.strong" class="product-activity-panel__subject">{{ part.text }}</strong>
|
|
|
|
|
|
<span v-else>{{ part.text }}</span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</span>
|
2026-04-24 16:38:43 +08:00
|
|
|
|
<span v-if="item.statusTransition">,状态:{{ item.statusTransition }}</span>
|
|
|
|
|
|
<span v-if="item.reasonText">,原因:{{ item.reasonText }}</span>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<ElEmpty v-else description="当前最近30天暂无可展示的产品动态" :image-size="88" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</ElCard>
|
|
|
|
|
|
|
|
|
|
|
|
<ProductActivityTimelineDialog v-model:visible="dialogVisible" :product-id="productId" />
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.product-activity-panel {
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.product-activity-panel__header {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.product-activity-panel__title {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
color: rgb(15 23 42 / 98%);
|
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.product-activity-panel__desc {
|
|
|
|
|
|
margin: 4px 0 0;
|
|
|
|
|
|
color: rgb(100 116 139 / 92%);
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
line-height: 1.7;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.product-activity-panel__body {
|
|
|
|
|
|
min-height: 520px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.product-activity-panel__state {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
min-height: 420px;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.product-activity-panel__timeline {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.product-activity-panel__item {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: 20px minmax(0, 1fr);
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.product-activity-panel__rail {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.product-activity-panel__dot {
|
|
|
|
|
|
width: 12px;
|
|
|
|
|
|
height: 12px;
|
|
|
|
|
|
margin-top: 6px;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
box-shadow: 0 0 0 4px rgb(255 255 255 / 96%);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.product-activity-panel__dot--sky {
|
|
|
|
|
|
background-color: rgb(14 165 233 / 88%);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.product-activity-panel__dot--emerald {
|
|
|
|
|
|
background-color: rgb(5 150 105 / 88%);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.product-activity-panel__dot--amber {
|
|
|
|
|
|
background-color: rgb(217 119 6 / 88%);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.product-activity-panel__dot--rose {
|
|
|
|
|
|
background-color: rgb(225 29 72 / 88%);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.product-activity-panel__dot--slate {
|
|
|
|
|
|
background-color: rgb(100 116 139 / 88%);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.product-activity-panel__line {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
width: 2px;
|
|
|
|
|
|
min-height: 24px;
|
|
|
|
|
|
margin-top: 4px;
|
|
|
|
|
|
background: linear-gradient(180deg, rgb(203 213 225 / 96%), rgb(226 232 240 / 28%));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.product-activity-panel__item:last-child .product-activity-panel__line {
|
|
|
|
|
|
opacity: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.product-activity-panel__content {
|
|
|
|
|
|
padding: 10px 12px;
|
|
|
|
|
|
border: 1px solid rgb(226 232 240 / 92%);
|
|
|
|
|
|
border-radius: 16px;
|
|
|
|
|
|
background-color: rgb(255 255 255 / 98%);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.product-activity-panel__meta {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.product-activity-panel__meta-main {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.product-activity-panel__time {
|
|
|
|
|
|
color: rgb(100 116 139 / 90%);
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.product-activity-panel__sentence {
|
|
|
|
|
|
margin: 6px 0 0;
|
|
|
|
|
|
color: rgb(71 85 105 / 94%);
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.product-activity-panel__sentence-main {
|
|
|
|
|
|
color: rgb(15 23 42 / 98%);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 11:30:34 +08:00
|
|
|
|
.product-activity-panel__subject {
|
|
|
|
|
|
color: rgb(15 23 42 / 98%);
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 16:38:43 +08:00
|
|
|
|
@media (width <= 768px) {
|
|
|
|
|
|
.product-activity-panel__body {
|
|
|
|
|
|
min-height: auto;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.product-activity-panel__header {
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
align-items: stretch;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|