docs(api): 添加产品动态时间线前端API文档

- 新增产品动态时间线接口文档,明确前端调用规范
- 定义接口请求参数、响应结构和字段语义说明
- 提供请求示例和错误码说明
- 添加左侧筛选项映射规则和时间格式说明

feat(product): 实现产品首页动态时间线功能

- 重构产品首页布局结构,采用档案横幅型设计
- 新增对象基础概述横幅模块
- 实现产品动态时间线面板组件
- 集成需求池管理概览和最近变化区域
- 添加扩展信息区预留模块位

chore(docs): 更新代理工作说明和前端测试策略

- 添加前端任务测试策略说明
- 更新代理工作流程规范
- 明确git操作执行边界
- 优化组件类型声明更新
This commit is contained in:
2026-04-24 16:38:43 +08:00
parent 4122dfa50d
commit 5b9c7e781b
14 changed files with 3584 additions and 958 deletions

View File

@@ -0,0 +1,528 @@
<script setup lang="ts">
import { reactive, ref, watch } from 'vue';
import dayjs from 'dayjs';
import { fetchGetProductActivityTimelinePage } from '@/service/api';
import BusinessDateRangePicker from '@/components/custom/business-date-range-picker.vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import {
DEFAULT_PRODUCT_ACTIVITY_PAGE_SIZE,
PRODUCT_ACTIVITY_TIME_SHORTCUTS,
PRODUCT_ACTIVITY_TYPE_OPTIONS,
type ProductActivityFilterType,
buildProductActivityDisplayItems,
buildProductActivityRange
} from '../product-activity';
defineOptions({ name: 'ProductActivityTimelineDialog' });
interface Props {
productId: string;
}
const props = defineProps<Props>();
const visible = defineModel<boolean>('visible', {
default: false
});
const loading = ref(false);
const loadError = ref(false);
const items = ref<ReturnType<typeof buildProductActivityDisplayItems>>([]);
const pagination = reactive({
pageNo: 1,
pageSize: DEFAULT_PRODUCT_ACTIVITY_PAGE_SIZE,
total: 0
});
const filters = reactive<{
activityType: ProductActivityFilterType;
timeRange: [string, string];
}>({
activityType: 'all',
timeRange: buildProductActivityRange(30)
});
const timeRangeShortcuts = PRODUCT_ACTIVITY_TIME_SHORTCUTS.map(shortcut => ({
label: shortcut.label,
value: () => {
const end = dayjs();
const start = dayjs()
.subtract(Math.max(shortcut.days - 1, 0), 'day')
.startOf('day');
return [start.format('YYYY-MM-DD'), end.format('YYYY-MM-DD')] as [string, string];
}
}));
function buildDraftDateRange(timeRange: [string, string]) {
const [startTime, endTime] = timeRange;
return [dayjs(startTime).format('YYYY-MM-DD'), dayjs(endTime).format('YYYY-MM-DD')] as [string, string];
}
function buildApiTimeRange(dateRange: [string, string]): [string, string] {
const [startDate, endDate] = dateRange;
return [
dayjs(startDate).startOf('day').format('YYYY-MM-DD HH:mm:ss'),
dayjs(endDate).endOf('day').format('YYYY-MM-DD HH:mm:ss')
];
}
function buildQueryParams(): Api.Product.ProductActivityTimelinePageParams {
const [startTime, endTime] = filters.timeRange;
return {
pageNo: pagination.pageNo,
pageSize: pagination.pageSize,
activityType: filters.activityType === 'all' ? null : filters.activityType,
startTime,
endTime
};
}
function resetFilters() {
filters.activityType = 'all';
filters.timeRange = buildProductActivityRange(30);
pagination.pageNo = 1;
pagination.pageSize = DEFAULT_PRODUCT_ACTIVITY_PAGE_SIZE;
}
async function loadActivities() {
if (!visible.value || !props.productId) {
items.value = [];
pagination.total = 0;
loadError.value = false;
return;
}
loading.value = true;
loadError.value = false;
try {
const result = await fetchGetProductActivityTimelinePage(props.productId, buildQueryParams());
if (result.error || !result.data) {
items.value = [];
pagination.total = 0;
loadError.value = true;
return;
}
items.value = buildProductActivityDisplayItems(result.data.list);
pagination.total = result.data.total;
} finally {
loading.value = false;
}
}
function reloadActivities() {
loadActivities().catch(() => {
loadError.value = true;
});
}
function handleQuery() {
pagination.pageNo = 1;
reloadActivities();
}
function handleReset() {
resetFilters();
reloadActivities();
}
function handlePageChange(pageNo: number) {
pagination.pageNo = pageNo;
reloadActivities();
}
function handleDateRangeChange(dateRange: [string, string]) {
filters.timeRange = buildApiTimeRange(dateRange);
pagination.pageNo = 1;
reloadActivities();
}
watch(
() => filters.activityType,
() => {
if (!visible.value) {
return;
}
pagination.pageNo = 1;
reloadActivities();
}
);
watch([() => visible.value, () => props.productId], ([currentVisible, productId]) => {
if (!currentVisible) {
return;
}
if (!productId) {
items.value = [];
pagination.total = 0;
loadError.value = false;
return;
}
reloadActivities();
});
</script>
<template>
<BusinessFormDialog
v-model="visible"
class="product-activity-dialog"
title="产品动态时间线"
width="1100px"
:scrollbar="false"
>
<div class="product-activity-dialog__layout">
<aside class="product-activity-dialog__filters">
<section class="product-activity-dialog__section">
<div class="product-activity-dialog__section-header">
<h4>分类</h4>
</div>
<ElRadioGroup v-model="filters.activityType" class="product-activity-dialog__radio-group">
<ElRadioButton
v-for="option in PRODUCT_ACTIVITY_TYPE_OPTIONS"
:key="option.value"
:label="option.value"
:value="option.value"
>
{{ option.label }}
</ElRadioButton>
</ElRadioGroup>
</section>
<section class="product-activity-dialog__section">
<div class="product-activity-dialog__section-header">
<h4>时间范围</h4>
</div>
<BusinessDateRangePicker
:model-value="buildDraftDateRange(filters.timeRange)"
:shortcuts="timeRangeShortcuts"
placeholder="请选择时间范围"
@update:model-value="handleDateRangeChange"
/>
</section>
<div class="product-activity-dialog__actions">
<ElButton @click="handleReset">重置</ElButton>
<ElButton type="primary" @click="handleQuery">查询</ElButton>
</div>
</aside>
<section class="product-activity-dialog__result">
<div class="product-activity-dialog__result-header">
<h4>查询结果</h4>
<span v-if="pagination.total" class="product-activity-dialog__result-total">
{{ pagination.total }}
</span>
</div>
<div v-loading="loading" class="product-activity-dialog__result-body">
<div v-if="loadError" class="product-activity-dialog__state">
<ElEmpty description="产品动态加载失败" :image-size="88" />
<ElButton type="primary" plain @click="loadActivities">重新加载</ElButton>
</div>
<ElScrollbar v-else-if="items.length" class="product-activity-dialog__scrollbar">
<div class="product-activity-dialog__timeline">
<article v-for="item in items" :key="item.id" class="product-activity-dialog__item">
<div class="product-activity-dialog__rail">
<span class="product-activity-dialog__dot" :class="`product-activity-dialog__dot--${item.tone}`" />
<span class="product-activity-dialog__line" />
</div>
<div class="product-activity-dialog__content">
<div class="product-activity-dialog__meta">
<div class="product-activity-dialog__meta-main">
<ElTag effect="plain" size="small">{{ item.tagLabel }}</ElTag>
</div>
<span class="product-activity-dialog__time">{{ item.timeText }}</span>
</div>
<p class="product-activity-dialog__sentence">
<span class="product-activity-dialog__sentence-main">{{ item.compactText }}</span>
<span v-if="item.statusTransition">状态{{ item.statusTransition }}</span>
<span v-if="item.reasonText">原因{{ item.reasonText }}</span>
</p>
</div>
</article>
</div>
</ElScrollbar>
<ElEmpty v-else description="当前筛选条件下暂无产品动态" :image-size="88" />
</div>
</section>
</div>
<template #footer="{ close }">
<div class="product-activity-dialog__footer-inner">
<div class="product-activity-dialog__footer-pagination">
<ElPagination
v-if="pagination.total"
layout="total,prev,pager,next"
:current-page="pagination.pageNo"
:page-size="pagination.pageSize"
:total="pagination.total"
@current-change="handlePageChange"
/>
</div>
<ElButton @click="close">关闭</ElButton>
</div>
</template>
</BusinessFormDialog>
</template>
<style scoped>
.product-activity-dialog {
:deep(.el-dialog) {
max-width: calc(100vw - 32px);
}
}
.product-activity-dialog__layout {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
gap: 16px;
height: 640px;
}
.product-activity-dialog__filters,
.product-activity-dialog__result {
min-width: 0;
border: 1px solid var(--el-border-color-light);
border-radius: 18px;
background-color: var(--el-fill-color-lighter);
}
.product-activity-dialog__filters {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
padding: 16px;
overflow-y: auto;
}
.product-activity-dialog__section {
display: flex;
flex-direction: column;
gap: 12px;
}
.product-activity-dialog__section-header {
display: flex;
flex-direction: column;
gap: 4px;
}
.product-activity-dialog__section-header h4,
.product-activity-dialog__result-header h4 {
margin: 0;
color: var(--el-text-color-primary);
font-size: 14px;
font-weight: 700;
}
.product-activity-dialog__section-header span,
.product-activity-dialog__result-total {
margin: 0;
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.6;
}
.product-activity-dialog__radio-group {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.product-activity-dialog__radio-group :deep(.el-radio-button__inner) {
width: 100%;
}
.product-activity-dialog__actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: auto;
}
.product-activity-dialog__time-range-input {
width: 100%;
}
.product-activity-dialog__result {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
padding: 16px;
overflow: hidden;
background: linear-gradient(180deg, var(--el-bg-color), var(--el-fill-color-lighter));
}
.product-activity-dialog__result-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.product-activity-dialog__result-body {
flex: 1;
min-height: 0;
overflow: hidden;
}
.product-activity-dialog__state {
display: flex;
height: 100%;
flex-direction: column;
align-items: center;
justify-content: center;
}
.product-activity-dialog__scrollbar {
height: 100%;
}
.product-activity-dialog__scrollbar :deep(.el-scrollbar__wrap) {
overflow-x: hidden;
}
.product-activity-dialog__timeline {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 100%;
}
.product-activity-dialog__item {
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
gap: 12px;
}
.product-activity-dialog__rail {
display: flex;
flex-direction: column;
align-items: center;
}
.product-activity-dialog__dot {
width: 12px;
height: 12px;
margin-top: 6px;
border-radius: 999px;
box-shadow: 0 0 0 4px var(--el-bg-color);
}
.product-activity-dialog__dot--sky {
background-color: rgb(14 165 233 / 88%);
}
.product-activity-dialog__dot--emerald {
background-color: rgb(5 150 105 / 88%);
}
.product-activity-dialog__dot--amber {
background-color: rgb(217 119 6 / 88%);
}
.product-activity-dialog__dot--rose {
background-color: rgb(225 29 72 / 88%);
}
.product-activity-dialog__dot--slate {
background-color: rgb(100 116 139 / 88%);
}
.product-activity-dialog__line {
flex: 1;
width: 2px;
min-height: 24px;
margin-top: 4px;
background: linear-gradient(180deg, var(--el-border-color), transparent);
}
.product-activity-dialog__item:last-child .product-activity-dialog__line {
opacity: 0;
}
.product-activity-dialog__content {
padding: 10px 12px;
border: 1px solid var(--el-border-color-light);
border-radius: 16px;
background-color: var(--el-bg-color);
}
.product-activity-dialog__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.product-activity-dialog__meta-main {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.product-activity-dialog__time {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.product-activity-dialog__sentence {
margin: 6px 0 0;
color: var(--el-text-color-regular);
font-size: 14px;
line-height: 1.6;
}
.product-activity-dialog__sentence-main {
color: var(--el-text-color-primary);
}
.product-activity-dialog__footer-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: 100%;
}
.product-activity-dialog__footer-pagination {
min-width: 0;
}
@media (width <= 960px) {
.product-activity-dialog__layout {
grid-template-columns: 1fr;
height: 640px;
}
.product-activity-dialog__result-header {
flex-direction: column;
align-items: stretch;
}
.product-activity-dialog__footer-inner {
flex-direction: column;
align-items: stretch;
}
}
</style>

View File

@@ -0,0 +1,275 @@
<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">
<span class="product-activity-panel__sentence-main">{{ item.compactText }}</span>
<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%);
}
@media (width <= 768px) {
.product-activity-panel__body {
min-height: auto;
}
.product-activity-panel__header {
flex-direction: column;
align-items: stretch;
}
}
</style>