docs(api): 添加产品动态时间线前端API文档
- 新增产品动态时间线接口文档,明确前端调用规范 - 定义接口请求参数、响应结构和字段语义说明 - 提供请求示例和错误码说明 - 添加左侧筛选项映射规则和时间格式说明 feat(product): 实现产品首页动态时间线功能 - 重构产品首页布局结构,采用档案横幅型设计 - 新增对象基础概述横幅模块 - 实现产品动态时间线面板组件 - 集成需求池管理概览和最近变化区域 - 添加扩展信息区预留模块位 chore(docs): 更新代理工作说明和前端测试策略 - 添加前端任务测试策略说明 - 更新代理工作流程规范 - 明确git操作执行边界 - 优化组件类型声明更新
This commit is contained in:
569
src/components/custom/business-date-range-picker.vue
Normal file
569
src/components/custom/business-date-range-picker.vue
Normal file
@@ -0,0 +1,569 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { Calendar } from '@element-plus/icons-vue';
|
||||
|
||||
defineOptions({ name: 'BusinessDateRangePicker' });
|
||||
|
||||
type DateRangeValue = [string, string];
|
||||
|
||||
interface DateRangeShortcut {
|
||||
label: string;
|
||||
value: DateRangeValue | (() => DateRangeValue);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
shortcuts?: DateRangeShortcut[];
|
||||
popoverWidth?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
placeholder: '请选择日期范围',
|
||||
disabled: false,
|
||||
shortcuts: () => [],
|
||||
popoverWidth: 458
|
||||
});
|
||||
|
||||
const model = defineModel<DateRangeValue>({
|
||||
default: () => ['', '']
|
||||
});
|
||||
|
||||
const popoverVisible = ref(false);
|
||||
const activeTab = ref<'advanced' | 'custom'>('custom');
|
||||
const draftRange = ref<DateRangeValue>(normalizeDateRange(model.value));
|
||||
const panelMonth = ref(dayjs().startOf('month'));
|
||||
|
||||
const displayText = computed(() => {
|
||||
const normalizedRange = normalizeDateRange(model.value);
|
||||
|
||||
return normalizedRange.every(Boolean) ? normalizedRange.join(' ~ ') : '';
|
||||
});
|
||||
|
||||
const confirmDisabled = computed(() => {
|
||||
return !isCompleteDateRange(draftRange.value);
|
||||
});
|
||||
|
||||
const defaultShortcuts = computed<DateRangeShortcut[]>(() => [
|
||||
{
|
||||
label: '最近 7 天',
|
||||
value: () => buildRecentDateRange(7)
|
||||
},
|
||||
{
|
||||
label: '最近 30 天',
|
||||
value: () => buildRecentDateRange(30)
|
||||
},
|
||||
{
|
||||
label: '本周',
|
||||
value: () => [dayjs().startOf('week').format('YYYY-MM-DD'), dayjs().endOf('week').format('YYYY-MM-DD')]
|
||||
},
|
||||
{
|
||||
label: '本月',
|
||||
value: () => [dayjs().startOf('month').format('YYYY-MM-DD'), dayjs().endOf('month').format('YYYY-MM-DD')]
|
||||
}
|
||||
]);
|
||||
|
||||
const resolvedShortcuts = computed(() => (props.shortcuts?.length ? props.shortcuts : defaultShortcuts.value));
|
||||
|
||||
const panelTitle = computed(() => panelMonth.value.format('YYYY 年 M 月'));
|
||||
|
||||
const calendarCells = computed(() => {
|
||||
const startDate = panelMonth.value.startOf('month').startOf('week');
|
||||
|
||||
return Array.from({ length: 42 }, (_, index) => {
|
||||
const date = startDate.add(index, 'day');
|
||||
const dateText = date.format('YYYY-MM-DD');
|
||||
|
||||
return {
|
||||
date,
|
||||
dateText,
|
||||
dayText: date.format('D'),
|
||||
isCurrentMonth: date.month() === panelMonth.value.month(),
|
||||
isSelected: isSelectedDate(dateText),
|
||||
isInRange: isInSelectedRange(dateText),
|
||||
isStart: draftRange.value[0] === dateText,
|
||||
isEnd: draftRange.value[1] === dateText
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const activeShortcutLabel = computed(() => {
|
||||
const matchedShortcut = resolvedShortcuts.value.find(shortcut => {
|
||||
const shortcutRange = resolveShortcutValue(shortcut);
|
||||
|
||||
return shortcutRange[0] === draftRange.value[0] && shortcutRange[1] === draftRange.value[1];
|
||||
});
|
||||
|
||||
return matchedShortcut?.label || '';
|
||||
});
|
||||
|
||||
function buildRecentDateRange(days: number): DateRangeValue {
|
||||
const end = dayjs();
|
||||
const start = dayjs().subtract(Math.max(days - 1, 0), 'day');
|
||||
|
||||
return [start.format('YYYY-MM-DD'), end.format('YYYY-MM-DD')];
|
||||
}
|
||||
|
||||
function normalizeDateRange(value: readonly string[] | null | undefined): DateRangeValue {
|
||||
const [startDate = '', endDate = ''] = value || [];
|
||||
|
||||
return [formatDate(startDate), formatDate(endDate)];
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parsed = dayjs(value);
|
||||
|
||||
return parsed.isValid() ? parsed.format('YYYY-MM-DD') : '';
|
||||
}
|
||||
|
||||
function isCompleteDateRange(value: readonly string[]) {
|
||||
return value.length === 2 && value.every(item => dayjs(item).isValid());
|
||||
}
|
||||
|
||||
function syncPanelMonth(value: readonly string[]) {
|
||||
const [startDate, endDate] = value;
|
||||
const candidateDate = startDate || endDate;
|
||||
const parsed = dayjs(candidateDate);
|
||||
|
||||
panelMonth.value = parsed.isValid() ? parsed.startOf('month') : dayjs().startOf('month');
|
||||
}
|
||||
|
||||
function isSelectedDate(dateText: string) {
|
||||
return draftRange.value.includes(dateText);
|
||||
}
|
||||
|
||||
function isInSelectedRange(dateText: string) {
|
||||
if (!isCompleteDateRange(draftRange.value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const current = dayjs(dateText);
|
||||
const startDate = dayjs(draftRange.value[0]);
|
||||
const endDate = dayjs(draftRange.value[1]);
|
||||
|
||||
return current.isAfter(startDate, 'day') && current.isBefore(endDate, 'day');
|
||||
}
|
||||
|
||||
function resolveShortcutValue(shortcut: DateRangeShortcut) {
|
||||
return normalizeDateRange(typeof shortcut.value === 'function' ? shortcut.value() : shortcut.value);
|
||||
}
|
||||
|
||||
function updateModel(value: DateRangeValue) {
|
||||
const normalizedRange = normalizeDateRange(value);
|
||||
|
||||
if (!isCompleteDateRange(normalizedRange)) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.value = normalizedRange;
|
||||
}
|
||||
|
||||
function handleVisibleChange(currentVisible: boolean) {
|
||||
popoverVisible.value = currentVisible;
|
||||
|
||||
if (currentVisible) {
|
||||
draftRange.value = normalizeDateRange(model.value);
|
||||
syncPanelMonth(draftRange.value);
|
||||
return;
|
||||
}
|
||||
|
||||
draftRange.value = normalizeDateRange(model.value);
|
||||
}
|
||||
|
||||
function handleShortcutClick(shortcut: DateRangeShortcut) {
|
||||
const shortcutRange = resolveShortcutValue(shortcut);
|
||||
|
||||
draftRange.value = shortcutRange;
|
||||
syncPanelMonth(shortcutRange);
|
||||
}
|
||||
|
||||
function handleDateClick(dateText: string) {
|
||||
const [startDate, endDate] = draftRange.value;
|
||||
|
||||
if (!startDate || (startDate && endDate)) {
|
||||
draftRange.value = [dateText, ''];
|
||||
return;
|
||||
}
|
||||
|
||||
if (dayjs(dateText).isBefore(dayjs(startDate), 'day')) {
|
||||
draftRange.value = [dateText, startDate];
|
||||
return;
|
||||
}
|
||||
|
||||
draftRange.value = [startDate, dateText];
|
||||
}
|
||||
|
||||
function switchPanelMonth(step: number) {
|
||||
panelMonth.value = panelMonth.value.add(step, 'month');
|
||||
}
|
||||
|
||||
function switchPanelYear(step: number) {
|
||||
panelMonth.value = panelMonth.value.add(step, 'year');
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (confirmDisabled.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateModel(draftRange.value);
|
||||
popoverVisible.value = false;
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
draftRange.value = normalizeDateRange(model.value);
|
||||
popoverVisible.value = false;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => model.value,
|
||||
value => {
|
||||
if (!popoverVisible.value) {
|
||||
draftRange.value = normalizeDateRange(value);
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElPopover
|
||||
:visible="popoverVisible"
|
||||
trigger="click"
|
||||
placement="bottom-start"
|
||||
:width="popoverWidth"
|
||||
popper-class="business-date-range-picker__popper"
|
||||
:disabled="disabled"
|
||||
@update:visible="handleVisibleChange"
|
||||
>
|
||||
<template #reference>
|
||||
<ElInput
|
||||
:model-value="displayText"
|
||||
readonly
|
||||
:disabled="disabled"
|
||||
:placeholder="placeholder"
|
||||
class="business-date-range-picker__input"
|
||||
>
|
||||
<template #suffix>
|
||||
<ElIcon><Calendar /></ElIcon>
|
||||
</template>
|
||||
</ElInput>
|
||||
</template>
|
||||
|
||||
<div class="business-date-range-picker__panel">
|
||||
<aside class="business-date-range-picker__shortcuts">
|
||||
<ElButton
|
||||
v-for="shortcut in resolvedShortcuts"
|
||||
:key="shortcut.label"
|
||||
:type="activeShortcutLabel === shortcut.label ? 'primary' : 'default'"
|
||||
class="business-date-range-picker__shortcut"
|
||||
@click="handleShortcutClick(shortcut)"
|
||||
>
|
||||
{{ shortcut.label }}
|
||||
</ElButton>
|
||||
</aside>
|
||||
|
||||
<section class="business-date-range-picker__main">
|
||||
<div class="business-date-range-picker__tabs">
|
||||
<button
|
||||
class="business-date-range-picker__tab"
|
||||
:class="{ 'business-date-range-picker__tab--active': activeTab === 'advanced' }"
|
||||
type="button"
|
||||
@click="activeTab = 'advanced'"
|
||||
>
|
||||
高级选项
|
||||
</button>
|
||||
<button
|
||||
class="business-date-range-picker__tab"
|
||||
:class="{ 'business-date-range-picker__tab--active': activeTab === 'custom' }"
|
||||
type="button"
|
||||
@click="activeTab = 'custom'"
|
||||
>
|
||||
自定义
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'advanced'" class="business-date-range-picker__advanced">
|
||||
<ElButton
|
||||
v-for="shortcut in resolvedShortcuts"
|
||||
:key="shortcut.label"
|
||||
plain
|
||||
:type="activeShortcutLabel === shortcut.label ? 'primary' : 'default'"
|
||||
class="business-date-range-picker__advanced-button"
|
||||
@click="handleShortcutClick(shortcut)"
|
||||
>
|
||||
{{ shortcut.label }}
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<div v-else class="business-date-range-picker__custom">
|
||||
<div class="business-date-range-picker__fields">
|
||||
<ElInput v-model="draftRange[0]" class="business-date-range-picker__field" />
|
||||
<span class="business-date-range-picker__separator">—</span>
|
||||
<ElInput v-model="draftRange[1]" class="business-date-range-picker__field" />
|
||||
</div>
|
||||
|
||||
<div class="business-date-range-picker__calendar">
|
||||
<div class="business-date-range-picker__calendar-header">
|
||||
<button type="button" class="business-date-range-picker__icon-button" @click="switchPanelYear(-1)">
|
||||
«
|
||||
</button>
|
||||
<button type="button" class="business-date-range-picker__icon-button" @click="switchPanelMonth(-1)">
|
||||
‹
|
||||
</button>
|
||||
<span class="business-date-range-picker__calendar-title">{{ panelTitle }}</span>
|
||||
<button type="button" class="business-date-range-picker__icon-button" @click="switchPanelMonth(1)">
|
||||
›
|
||||
</button>
|
||||
<button type="button" class="business-date-range-picker__icon-button" @click="switchPanelYear(1)">
|
||||
»
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="business-date-range-picker__weekdays">
|
||||
<span>日</span>
|
||||
<span>一</span>
|
||||
<span>二</span>
|
||||
<span>三</span>
|
||||
<span>四</span>
|
||||
<span>五</span>
|
||||
<span>六</span>
|
||||
</div>
|
||||
|
||||
<div class="business-date-range-picker__days">
|
||||
<button
|
||||
v-for="cell in calendarCells"
|
||||
:key="cell.dateText"
|
||||
type="button"
|
||||
class="business-date-range-picker__day"
|
||||
:class="{
|
||||
'business-date-range-picker__day--muted': !cell.isCurrentMonth,
|
||||
'business-date-range-picker__day--selected': cell.isSelected,
|
||||
'business-date-range-picker__day--in-range': cell.isInRange,
|
||||
'business-date-range-picker__day--start': cell.isStart,
|
||||
'business-date-range-picker__day--end': cell.isEnd
|
||||
}"
|
||||
@click="handleDateClick(cell.dateText)"
|
||||
>
|
||||
<span>{{ cell.dayText }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="business-date-range-picker__footer">
|
||||
<ElButton @click="handleCancel">取消</ElButton>
|
||||
<ElButton type="primary" :disabled="confirmDisabled" @click="handleConfirm">确定</ElButton>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</ElPopover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.business-date-range-picker__input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.business-date-range-picker__panel {
|
||||
display: grid;
|
||||
grid-template-columns: 102px minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background-color: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.business-date-range-picker__shortcuts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px 10px;
|
||||
border-right: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.business-date-range-picker__shortcut {
|
||||
width: 78px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.business-date-range-picker__main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.business-date-range-picker__tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.business-date-range-picker__tab {
|
||||
position: relative;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--el-text-color-regular);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.business-date-range-picker__tab--active {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.business-date-range-picker__tab--active::after {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 0;
|
||||
left: 12px;
|
||||
height: 2px;
|
||||
background-color: var(--el-color-primary);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.business-date-range-picker__advanced {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
min-height: 230px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.business-date-range-picker__advanced-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.business-date-range-picker__custom {
|
||||
padding: 10px 8px 0;
|
||||
}
|
||||
|
||||
.business-date-range-picker__fields {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 20px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 8px 6px;
|
||||
}
|
||||
|
||||
.business-date-range-picker__separator {
|
||||
color: var(--el-text-color-placeholder);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.business-date-range-picker__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 10px 12px 12px;
|
||||
border-top: 1px solid var(--el-border-color-light);
|
||||
}
|
||||
|
||||
:global(.business-date-range-picker__popper.el-popover.el-popper) {
|
||||
padding: 0;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
}
|
||||
|
||||
.business-date-range-picker__calendar {
|
||||
padding: 0 10px 8px;
|
||||
}
|
||||
|
||||
.business-date-range-picker__calendar-header {
|
||||
display: grid;
|
||||
grid-template-columns: 28px 28px minmax(0, 1fr) 28px 28px;
|
||||
align-items: center;
|
||||
height: 36px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.business-date-range-picker__calendar-title {
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.business-date-range-picker__icon-button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--el-text-color-placeholder);
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.business-date-range-picker__icon-button:hover {
|
||||
color: var(--el-color-primary);
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.business-date-range-picker__weekdays,
|
||||
.business-date-range-picker__days {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
}
|
||||
|
||||
.business-date-range-picker__weekdays {
|
||||
height: 30px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.business-date-range-picker__days {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.business-date-range-picker__day {
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--el-text-color-regular);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.business-date-range-picker__day span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.business-date-range-picker__day:hover span {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.business-date-range-picker__day--muted {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.business-date-range-picker__day--in-range {
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.business-date-range-picker__day--selected span {
|
||||
background-color: var(--el-color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.business-date-range-picker__day--start {
|
||||
border-radius: 6px 0 0 6px;
|
||||
}
|
||||
|
||||
.business-date-range-picker__day--end {
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -18,6 +18,23 @@ type ProductResponse = Omit<Api.Product.Product, 'id' | 'managerUserId'> & {
|
||||
|
||||
type ProductPageResponse = Api.Product.PageResult<ProductResponse>;
|
||||
|
||||
type ProductActivityTimelineItemResponse = Omit<
|
||||
Api.Product.ProductActivityTimelineItem,
|
||||
'id' | 'operatorUserId' | 'targetUserId' | 'occurredAt'
|
||||
> & {
|
||||
id: string | number;
|
||||
operatorUserId?: string | number | null;
|
||||
targetUserId?: string | number | null;
|
||||
occurredAt: number | string;
|
||||
};
|
||||
|
||||
type ProductActivityTimelinePageResponse = Omit<
|
||||
Api.Product.PageResult<ProductActivityTimelineItemResponse>,
|
||||
'total'
|
||||
> & {
|
||||
total: number | string;
|
||||
};
|
||||
|
||||
function normalizeProduct(product: ProductResponse): Api.Product.Product {
|
||||
return {
|
||||
...product,
|
||||
@@ -26,6 +43,54 @@ function normalizeProduct(product: ProductResponse): Api.Product.Product {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOccurredAt(occurredAt: number | string) {
|
||||
const value = Number(occurredAt);
|
||||
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function normalizePageTotal(total: number | string) {
|
||||
const value = Number(total);
|
||||
|
||||
return Number.isFinite(value) ? Math.max(0, value) : 0;
|
||||
}
|
||||
|
||||
function normalizeProductActivityTimelineItem(
|
||||
item: ProductActivityTimelineItemResponse
|
||||
): Api.Product.ProductActivityTimelineItem {
|
||||
return {
|
||||
...item,
|
||||
id: normalizeStringId(item.id),
|
||||
operatorUserId: normalizeNullableStringId(item.operatorUserId),
|
||||
targetUserId: normalizeNullableStringId(item.targetUserId),
|
||||
occurredAt: normalizeOccurredAt(item.occurredAt)
|
||||
};
|
||||
}
|
||||
|
||||
function createProductActivityTimelinePageQuery(params: Api.Product.ProductActivityTimelinePageParams) {
|
||||
const query = new URLSearchParams();
|
||||
|
||||
query.append('pageNo', String(params.pageNo));
|
||||
query.append('pageSize', String(params.pageSize));
|
||||
|
||||
if (params.activityType) {
|
||||
query.append('activityType', params.activityType);
|
||||
}
|
||||
|
||||
params.actionTypes?.forEach(actionType => {
|
||||
if (actionType) {
|
||||
query.append('actionTypes', actionType);
|
||||
}
|
||||
});
|
||||
|
||||
if (params.startTime && params.endTime) {
|
||||
query.append('startTime', params.startTime);
|
||||
query.append('endTime', params.endTime);
|
||||
}
|
||||
|
||||
return query.toString();
|
||||
}
|
||||
|
||||
/** 鑾峰彇浜у搧鍒嗛〉 */
|
||||
export async function fetchGetProductPage(params?: Api.Product.ProductSearchParams) {
|
||||
const result = await request<ProductPageResponse>({
|
||||
@@ -123,6 +188,24 @@ export async function fetchGetProductMembers(id: string) {
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchGetProductActivityTimelinePage(
|
||||
id: string,
|
||||
params: Api.Product.ProductActivityTimelinePageParams
|
||||
) {
|
||||
const query = createProductActivityTimelinePageQuery(params);
|
||||
const url = query ? `${PRODUCT_PREFIX}/${id}/activities/page?${query}` : `${PRODUCT_PREFIX}/${id}/activities/page`;
|
||||
const result = await request<ProductActivityTimelinePageResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url,
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<ProductActivityTimelinePageResponse>, data => ({
|
||||
total: normalizePageTotal(data.total),
|
||||
list: data.list.map(normalizeProductActivityTimelineItem)
|
||||
}));
|
||||
}
|
||||
|
||||
export async function fetchCreateProductMember(id: string, data: Api.Product.CreateProductMemberParams) {
|
||||
const result = await request<string | number>({
|
||||
...safeJsonRequestConfig,
|
||||
|
||||
55
src/typings/api/product.d.ts
vendored
55
src/typings/api/product.d.ts
vendored
@@ -109,6 +109,61 @@ declare namespace Api {
|
||||
remark?: string | null;
|
||||
}
|
||||
|
||||
type ProductActivityType = 'status' | 'product' | 'member';
|
||||
|
||||
type ProductActivityActionType =
|
||||
| 'create'
|
||||
| 'change_manager'
|
||||
| 'pause'
|
||||
| 'resume'
|
||||
| 'archive'
|
||||
| 'abandon'
|
||||
| 'add_member'
|
||||
| 'update_member'
|
||||
| 'remove_member';
|
||||
|
||||
interface ProductActivityTimelinePageParams extends PageParams {
|
||||
/** 分类 */
|
||||
activityType?: ProductActivityType | null;
|
||||
/** 动作编码数组,多选时按重复 query 参数传递 */
|
||||
actionTypes?: ProductActivityActionType[] | null;
|
||||
/** 开始时间,格式 yyyy-MM-dd HH:mm:ss */
|
||||
startTime?: string | null;
|
||||
/** 结束时间,格式 yyyy-MM-dd HH:mm:ss */
|
||||
endTime?: string | null;
|
||||
}
|
||||
|
||||
interface ProductActivityTimelineItem {
|
||||
/** 动态唯一标识 */
|
||||
id: string;
|
||||
/** 动态类型 */
|
||||
type: ProductActivityType;
|
||||
/** 动作编码 */
|
||||
actionType: ProductActivityActionType;
|
||||
/** 动作中文名称 */
|
||||
actionName: string;
|
||||
/** 操作人用户 ID */
|
||||
operatorUserId?: string | null;
|
||||
/** 操作人名称 */
|
||||
operatorName: string;
|
||||
/** 目标用户 ID,成员类动态使用 */
|
||||
targetUserId?: string | null;
|
||||
/** 目标用户名称,成员类动态使用 */
|
||||
targetUserName?: string | null;
|
||||
/** 动态发生时间,毫秒时间戳 */
|
||||
occurredAt: number;
|
||||
/** 可直接展示的摘要文案 */
|
||||
summary: string;
|
||||
/** 原因说明 */
|
||||
reason?: string | null;
|
||||
/** 原状态编码 */
|
||||
fromStatus?: ProductStatusCode | null;
|
||||
/** 目标状态编码 */
|
||||
toStatus?: ProductStatusCode | null;
|
||||
/** 补充明细,当前为 JSON 字符串 */
|
||||
details?: string | null;
|
||||
}
|
||||
|
||||
type ProductSearchParams = CommonType.RecordNullable<
|
||||
Pick<PageParams, 'pageNo' | 'pageSize'> &
|
||||
Pick<Product, 'directionCode' | 'managerUserId' | 'statusCode'> & {
|
||||
|
||||
4
src/typings/components.d.ts
vendored
4
src/typings/components.d.ts
vendored
@@ -10,6 +10,7 @@ declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AppProvider: typeof import('./../components/common/app-provider.vue')['default']
|
||||
BetterScroll: typeof import('./../components/custom/better-scroll.vue')['default']
|
||||
BusinessDateRangePicker: typeof import('./../components/custom/business-date-range-picker.vue')['default']
|
||||
BusinessFormDialog: typeof import('./../components/custom/business-form-dialog.vue')['default']
|
||||
BusinessFormDrawer: typeof import('./../components/custom/business-form-drawer.vue')['default']
|
||||
BusinessFormSection: typeof import('./../components/custom/business-form-section.vue')['default']
|
||||
@@ -29,12 +30,14 @@ declare module 'vue' {
|
||||
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
||||
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
||||
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
ElDatePickerPanel: typeof import('element-plus/es')['ElDatePickerPanel']
|
||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
@@ -56,6 +59,7 @@ declare module 'vue' {
|
||||
ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm']
|
||||
ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||
ElRadio: typeof import('element-plus/es')['ElRadio']
|
||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
||||
|
||||
390
src/views/product/dashboard/homepage.ts
Normal file
390
src/views/product/dashboard/homepage.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const productStatusLabelMap = {
|
||||
active: '启用',
|
||||
paused: '暂停',
|
||||
archived: '归档',
|
||||
abandoned: '废弃'
|
||||
} as const satisfies Record<Api.Product.ProductStatusCode, string>;
|
||||
|
||||
export interface ProductHomepageMetric {
|
||||
label: string;
|
||||
value: string;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
export interface ProductHomepageFact {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ProductHomepageBanner {
|
||||
identity: {
|
||||
name: string;
|
||||
code: string;
|
||||
directionCode: string;
|
||||
statusCode: Api.Product.ProductStatusCode | null;
|
||||
statusLabel: string;
|
||||
managerLabel: string;
|
||||
description: string;
|
||||
facts: ProductHomepageFact[];
|
||||
};
|
||||
metrics: ProductHomepageMetric[];
|
||||
}
|
||||
|
||||
export interface ProductHomepageBannerSource {
|
||||
product: Api.Product.Product | null;
|
||||
settings: Api.Product.ProductSettings | null;
|
||||
members: readonly Api.Product.ProductMember[];
|
||||
requirementSummary: ProductRequirementPoolSummary;
|
||||
latestActivityTime?: string | null;
|
||||
}
|
||||
|
||||
export interface ProductHomepageTimelineItem {
|
||||
key: string;
|
||||
tag: '对象' | '状态' | '团队';
|
||||
title: string;
|
||||
content: string;
|
||||
time: string;
|
||||
tone: 'sky' | 'emerald' | 'amber' | 'rose' | 'slate';
|
||||
}
|
||||
|
||||
export interface ProductRequirementPoolSummarySource {
|
||||
total: number;
|
||||
todo: number;
|
||||
analyzing: number;
|
||||
planned: number;
|
||||
done: number;
|
||||
highPriorityTodo: number;
|
||||
}
|
||||
|
||||
export interface ProductRequirementPoolSummary {
|
||||
metrics: ProductHomepageMetric[];
|
||||
distribution: Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
}>;
|
||||
total: number;
|
||||
todo: number;
|
||||
highPriorityTodo: number;
|
||||
}
|
||||
|
||||
export interface ProductRequirementPoolRecentChangeSource {
|
||||
id: string;
|
||||
title: string;
|
||||
actionLabel: string;
|
||||
time: string;
|
||||
statusLabel: string;
|
||||
}
|
||||
|
||||
export interface ProductRequirementPoolRecentChange {
|
||||
id: string;
|
||||
title: string;
|
||||
actionLabel: string;
|
||||
time: string;
|
||||
statusLabel: string;
|
||||
}
|
||||
|
||||
export interface ProductHomepageExtensionModule {
|
||||
key: 'milestone' | 'risk' | 'document';
|
||||
title: string;
|
||||
description: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
function normalizeCount(value: number | null | undefined) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.max(0, Number(value));
|
||||
}
|
||||
|
||||
function getTimeValue(value: string | null | undefined) {
|
||||
const parsed = dayjs(value);
|
||||
|
||||
return parsed.isValid() ? parsed.valueOf() : 0;
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined) {
|
||||
const parsed = dayjs(value);
|
||||
|
||||
return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm') : '--';
|
||||
}
|
||||
|
||||
function getStatusLabel(status: Api.Product.ProductStatusCode | null | undefined) {
|
||||
if (!status) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return productStatusLabelMap[status] || '--';
|
||||
}
|
||||
|
||||
function getActiveMembers(members: readonly Api.Product.ProductMember[]) {
|
||||
return members.filter(item => item.status === 0);
|
||||
}
|
||||
|
||||
function getManagerLabel(settings: Api.Product.ProductSettings | null, members: readonly Api.Product.ProductMember[]) {
|
||||
return (
|
||||
settings?.baseInfo.managerUserNickname ||
|
||||
getActiveMembers(members).find(item => item.managerFlag)?.userNickname ||
|
||||
'--'
|
||||
);
|
||||
}
|
||||
|
||||
function getRoleSummary(members: readonly Api.Product.ProductMember[]) {
|
||||
const activeMembers = getActiveMembers(members);
|
||||
|
||||
if (!activeMembers.length) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
const roleCounter = new Map<string, number>();
|
||||
|
||||
activeMembers.forEach(member => {
|
||||
const roleName = member.roleName || '未命名角色';
|
||||
|
||||
roleCounter.set(roleName, (roleCounter.get(roleName) || 0) + 1);
|
||||
});
|
||||
|
||||
return Array.from(roleCounter.entries())
|
||||
.sort((left, right) => {
|
||||
const leftWeight = left[0].includes('经理') ? 0 : 1;
|
||||
const rightWeight = right[0].includes('经理') ? 0 : 1;
|
||||
|
||||
if (leftWeight !== rightWeight) {
|
||||
return leftWeight - rightWeight;
|
||||
}
|
||||
|
||||
return left[0].localeCompare(right[0], 'zh-CN');
|
||||
})
|
||||
.map(([roleName, count]) => `${roleName} ${count} 人`)
|
||||
.join(' / ');
|
||||
}
|
||||
|
||||
function resolveLatestTimelineTime(
|
||||
product: Api.Product.Product | null,
|
||||
settings: Api.Product.ProductSettings | null,
|
||||
members: readonly Api.Product.ProductMember[]
|
||||
) {
|
||||
const timeValues = [
|
||||
product?.createTime,
|
||||
product?.updateTime,
|
||||
settings?.lifecycle.lastStatusReason ? product?.updateTime : null,
|
||||
...members.flatMap(member => [member.joinedTime, member.leftTime || null])
|
||||
];
|
||||
|
||||
const latestValue = timeValues.reduce((latest, current) => {
|
||||
return Math.max(latest, getTimeValue(current));
|
||||
}, 0);
|
||||
|
||||
return latestValue ? dayjs(latestValue).format('YYYY-MM-DD HH:mm') : '--';
|
||||
}
|
||||
|
||||
export function buildRequirementPoolSummary(
|
||||
source: ProductRequirementPoolSummarySource | null | undefined
|
||||
): ProductRequirementPoolSummary {
|
||||
const total = normalizeCount(source?.total);
|
||||
const todo = normalizeCount(source?.todo);
|
||||
const analyzing = normalizeCount(source?.analyzing);
|
||||
const planned = normalizeCount(source?.planned);
|
||||
const done = normalizeCount(source?.done);
|
||||
const highPriorityTodo = normalizeCount(source?.highPriorityTodo);
|
||||
const distribution = [
|
||||
{ label: '待处理', value: String(todo) },
|
||||
{ label: '分析中', value: String(analyzing) },
|
||||
{ label: '已规划', value: String(planned) },
|
||||
{ label: '已完成', value: String(done) }
|
||||
];
|
||||
|
||||
return {
|
||||
metrics: [
|
||||
{
|
||||
label: '需求总量',
|
||||
value: String(total),
|
||||
hint: '当前需求池累计收录的需求数量'
|
||||
},
|
||||
{
|
||||
label: '状态类型',
|
||||
value: String(distribution.length),
|
||||
hint: '首页当前重点展示的需求状态分层'
|
||||
},
|
||||
{
|
||||
label: '待处理',
|
||||
value: String(todo),
|
||||
hint: '等待进入分析或分派的需求数量'
|
||||
},
|
||||
{
|
||||
label: '高优先级待处理',
|
||||
value: String(highPriorityTodo),
|
||||
hint: '需要优先推进的待处理需求数量'
|
||||
}
|
||||
],
|
||||
distribution,
|
||||
total,
|
||||
todo,
|
||||
highPriorityTodo
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRequirementPoolRecentChanges(
|
||||
source: readonly ProductRequirementPoolRecentChangeSource[] | null | undefined
|
||||
) {
|
||||
return [...(source || [])]
|
||||
.filter(item => getTimeValue(item.time) > 0)
|
||||
.sort((left, right) => getTimeValue(right.time) - getTimeValue(left.time))
|
||||
.map(item => ({
|
||||
...item,
|
||||
time: formatDateTime(item.time)
|
||||
})) satisfies ProductRequirementPoolRecentChange[];
|
||||
}
|
||||
|
||||
export function buildProductHomepageTimeline(
|
||||
product: Api.Product.Product | null,
|
||||
settings: Api.Product.ProductSettings | null,
|
||||
members: readonly Api.Product.ProductMember[]
|
||||
) {
|
||||
const items: Array<Omit<ProductHomepageTimelineItem, 'time'> & { time: string | null | undefined }> = [];
|
||||
|
||||
if (product?.createTime) {
|
||||
items.push({
|
||||
key: `product-create-${product.id}`,
|
||||
tag: '对象',
|
||||
title: '创建产品',
|
||||
content: `产品 ${product.name || product.code} 已创建并进入产品管理域。`,
|
||||
time: product.createTime,
|
||||
tone: 'sky'
|
||||
});
|
||||
}
|
||||
|
||||
const statusReason =
|
||||
settings?.lifecycle.lastStatusReason || settings?.baseInfo.lastStatusReason || product?.lastStatusReason;
|
||||
|
||||
if (product?.updateTime && settings?.lifecycle.statusCode && statusReason) {
|
||||
const statusCode = settings.lifecycle.statusCode;
|
||||
const toneMap: Record<Api.Product.ProductStatusCode, ProductHomepageTimelineItem['tone']> = {
|
||||
active: 'emerald',
|
||||
paused: 'amber',
|
||||
archived: 'slate',
|
||||
abandoned: 'rose'
|
||||
};
|
||||
|
||||
items.push({
|
||||
key: `product-status-${product.id}-${product.updateTime}`,
|
||||
tag: '状态',
|
||||
title: `状态调整为${getStatusLabel(statusCode)}`,
|
||||
content: statusReason,
|
||||
time: product.updateTime,
|
||||
tone: toneMap[statusCode]
|
||||
});
|
||||
}
|
||||
|
||||
members.forEach(member => {
|
||||
if (member.joinedTime) {
|
||||
items.push({
|
||||
key: `member-join-${member.id}`,
|
||||
tag: '团队',
|
||||
title: '成员加入',
|
||||
content: `${member.userNickname} 以${member.roleName}身份加入当前产品。`,
|
||||
time: member.joinedTime,
|
||||
tone: member.managerFlag ? 'emerald' : 'sky'
|
||||
});
|
||||
}
|
||||
|
||||
if (member.status === 1 && member.leftTime) {
|
||||
items.push({
|
||||
key: `member-leave-${member.id}`,
|
||||
tag: '团队',
|
||||
title: '成员移出',
|
||||
content: `${member.userNickname} 已退出当前产品团队。`,
|
||||
time: member.leftTime,
|
||||
tone: 'rose'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return items
|
||||
.filter(item => getTimeValue(item.time) > 0)
|
||||
.sort((left, right) => getTimeValue(right.time) - getTimeValue(left.time))
|
||||
.slice(0, 8)
|
||||
.map(item => ({
|
||||
...item,
|
||||
time: formatDateTime(item.time)
|
||||
})) satisfies ProductHomepageTimelineItem[];
|
||||
}
|
||||
|
||||
function buildProductHomepageBannerIdentity(source: ProductHomepageBannerSource) {
|
||||
const { product, settings, members } = source;
|
||||
const managerLabel = getManagerLabel(settings, members);
|
||||
const baseInfo = settings?.baseInfo;
|
||||
const statusCode = resolveProductHomepageStatusCode(product, settings);
|
||||
|
||||
return {
|
||||
name: product?.name || baseInfo?.name || '--',
|
||||
code: product?.code || baseInfo?.code || '--',
|
||||
directionCode: product?.directionCode || baseInfo?.directionCode || '',
|
||||
statusCode,
|
||||
statusLabel: getStatusLabel(statusCode),
|
||||
managerLabel,
|
||||
description: resolveProductHomepageDescription(product, settings),
|
||||
facts: [
|
||||
{ label: '产品经理', value: managerLabel },
|
||||
{ label: '角色摘要', value: getRoleSummary(members) }
|
||||
]
|
||||
} satisfies ProductHomepageBanner['identity'];
|
||||
}
|
||||
|
||||
function resolveProductHomepageStatusCode(
|
||||
product: Api.Product.Product | null,
|
||||
settings: Api.Product.ProductSettings | null
|
||||
) {
|
||||
return settings?.lifecycle.statusCode || product?.statusCode || null;
|
||||
}
|
||||
|
||||
function resolveProductHomepageDescription(
|
||||
product: Api.Product.Product | null,
|
||||
settings: Api.Product.ProductSettings | null
|
||||
) {
|
||||
return product?.description?.trim() || settings?.baseInfo.description?.trim() || '';
|
||||
}
|
||||
|
||||
function buildProductHomepageBannerMetrics(source: ProductHomepageBannerSource) {
|
||||
const activeMembers = getActiveMembers(source.members);
|
||||
const fallbackLatestTimelineTime = resolveLatestTimelineTime(source.product, source.settings, source.members);
|
||||
const latestTimelineTime = source.latestActivityTime?.trim() || fallbackLatestTimelineTime || '--';
|
||||
const { requirementSummary } = source;
|
||||
|
||||
return [
|
||||
{
|
||||
label: '团队人数',
|
||||
value: String(activeMembers.length),
|
||||
hint: '当前处于有效状态的团队成员数'
|
||||
},
|
||||
{
|
||||
label: '需求总量',
|
||||
value: String(requirementSummary.total),
|
||||
hint: '需求池累计收录的需求数量'
|
||||
},
|
||||
{
|
||||
label: '待处理需求',
|
||||
value: String(requirementSummary.todo),
|
||||
hint: '等待进入分析或分派的需求数量'
|
||||
},
|
||||
{
|
||||
label: '最近动态时间',
|
||||
value: latestTimelineTime,
|
||||
hint: '对象或团队最近一次可确认的变动时间'
|
||||
}
|
||||
] satisfies ProductHomepageMetric[];
|
||||
}
|
||||
|
||||
export function buildProductHomepageBanner(source: ProductHomepageBannerSource): ProductHomepageBanner {
|
||||
return {
|
||||
identity: buildProductHomepageBannerIdentity(source),
|
||||
metrics: buildProductHomepageBannerMetrics(source)
|
||||
};
|
||||
}
|
||||
|
||||
export function getProductHomepageExtensionModules(modules: readonly ProductHomepageExtensionModule[]) {
|
||||
return [...modules];
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
60
src/views/product/dashboard/mock.ts
Normal file
60
src/views/product/dashboard/mock.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type {
|
||||
ProductHomepageExtensionModule,
|
||||
ProductRequirementPoolRecentChangeSource,
|
||||
ProductRequirementPoolSummarySource
|
||||
} from './homepage';
|
||||
|
||||
export const productRequirementPoolMock = {
|
||||
summary: {
|
||||
total: 18,
|
||||
todo: 3,
|
||||
analyzing: 5,
|
||||
planned: 6,
|
||||
done: 4,
|
||||
highPriorityTodo: 2
|
||||
} satisfies ProductRequirementPoolSummarySource,
|
||||
recentChanges: [
|
||||
{
|
||||
id: 'req-1001',
|
||||
title: '支持产品资料标签归档',
|
||||
actionLabel: '新增需求',
|
||||
time: '2026-04-22 16:20:00',
|
||||
statusLabel: '待处理'
|
||||
},
|
||||
{
|
||||
id: 'req-1002',
|
||||
title: '统一需求池状态颜色',
|
||||
actionLabel: '状态流转',
|
||||
time: '2026-04-23 11:00:00',
|
||||
statusLabel: '分析中'
|
||||
},
|
||||
{
|
||||
id: 'req-1003',
|
||||
title: '补充对象首页需求池统计接口',
|
||||
actionLabel: '关闭需求',
|
||||
time: '2026-04-23 14:30:00',
|
||||
statusLabel: '已完成'
|
||||
}
|
||||
] satisfies ProductRequirementPoolRecentChangeSource[]
|
||||
};
|
||||
|
||||
export const productHomepageExtensionMock = [
|
||||
{
|
||||
key: 'milestone',
|
||||
title: '里程碑',
|
||||
description: '当前先承接产品对象下的版本节点与阶段目标,后续接真实里程碑聚合接口。',
|
||||
items: ['对象首页改版验收', '需求池统计接口接入', '产品资料结构梳理']
|
||||
},
|
||||
{
|
||||
key: 'risk',
|
||||
title: '风险点管理',
|
||||
description: '预留给跨需求、跨团队的产品级风险摘要,避免把风险信息挤进时间线。',
|
||||
items: ['需求池真实接口尚未接入', '对象首页长期指标来源待统一', '团队调整记录缺少专用日志接口']
|
||||
},
|
||||
{
|
||||
key: 'document',
|
||||
title: '产品资料',
|
||||
description: '用于承接产品说明、制度文档、对外资料等对象档案信息,当前先保留正式结构位。',
|
||||
items: ['产品定位说明', '对象上下文使用说明', '需求池维护约定']
|
||||
}
|
||||
] satisfies ProductHomepageExtensionModule[];
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
367
src/views/product/dashboard/product-activity.ts
Normal file
367
src/views/product/dashboard/product-activity.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const productStatusLabelMap = {
|
||||
active: '启用',
|
||||
paused: '暂停',
|
||||
archived: '归档',
|
||||
abandoned: '废弃'
|
||||
} as const satisfies Record<Api.Product.ProductStatusCode, string>;
|
||||
|
||||
const activityTypeLabelMap = {
|
||||
product: '产品',
|
||||
status: '状态',
|
||||
member: '成员'
|
||||
} as const satisfies Record<Api.Product.ProductActivityType, string>;
|
||||
|
||||
export type ProductActivityFilterType = 'all' | Api.Product.ProductActivityType;
|
||||
|
||||
export type ProductActivityTone = 'sky' | 'emerald' | 'amber' | 'rose' | 'slate';
|
||||
|
||||
export interface ProductActivityDisplayItem extends Api.Product.ProductActivityTimelineItem {
|
||||
tagLabel: string;
|
||||
timeText: string;
|
||||
actionText: string;
|
||||
displaySummary: string;
|
||||
compactText: string;
|
||||
operatorText: string;
|
||||
reasonText: string;
|
||||
statusTransition: string;
|
||||
tone: ProductActivityTone;
|
||||
}
|
||||
|
||||
export const PRODUCT_ACTIVITY_TYPE_OPTIONS: Array<{ label: string; value: ProductActivityFilterType }> = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '产品', value: 'product' },
|
||||
{ label: '状态', value: 'status' },
|
||||
{ label: '成员', value: 'member' }
|
||||
];
|
||||
|
||||
export const PRODUCT_ACTIVITY_ACTION_OPTIONS: Array<{
|
||||
label: string;
|
||||
value: Api.Product.ProductActivityActionType;
|
||||
type: Api.Product.ProductActivityType;
|
||||
}> = [
|
||||
{ label: '产品创建', value: 'create', type: 'product' },
|
||||
{ label: '产品经理变更', value: 'change_manager', type: 'product' },
|
||||
{ label: '暂停', value: 'pause', type: 'status' },
|
||||
{ label: '恢复', value: 'resume', type: 'status' },
|
||||
{ label: '归档', value: 'archive', type: 'status' },
|
||||
{ label: '废弃', value: 'abandon', type: 'status' },
|
||||
{ label: '成员加入', value: 'add_member', type: 'member' },
|
||||
{ label: '成员调整', value: 'update_member', type: 'member' },
|
||||
{ label: '成员移出', value: 'remove_member', type: 'member' }
|
||||
];
|
||||
|
||||
export const PRODUCT_ACTIVITY_TIME_SHORTCUTS = [
|
||||
{ label: '最近7天', days: 7 },
|
||||
{ label: '最近30天', days: 30 },
|
||||
{ label: '最近90天', days: 90 }
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_PRODUCT_ACTIVITY_PAGE_SIZE = 10;
|
||||
|
||||
type ActivityDetailRecord = Record<string, unknown>;
|
||||
|
||||
function getStatusLabel(status: Api.Product.ProductStatusCode | null | undefined) {
|
||||
if (!status) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return productStatusLabelMap[status] || '--';
|
||||
}
|
||||
|
||||
function getActivityTone(item: Api.Product.ProductActivityTimelineItem): ProductActivityTone {
|
||||
if (item.type === 'status') {
|
||||
if (item.actionType === 'resume') {
|
||||
return 'emerald';
|
||||
}
|
||||
|
||||
if (item.actionType === 'pause') {
|
||||
return 'amber';
|
||||
}
|
||||
|
||||
if (item.actionType === 'abandon') {
|
||||
return 'rose';
|
||||
}
|
||||
|
||||
return 'slate';
|
||||
}
|
||||
|
||||
if (item.type === 'product') {
|
||||
return item.actionType === 'change_manager' ? 'emerald' : 'sky';
|
||||
}
|
||||
|
||||
return item.actionType === 'remove_member' ? 'rose' : 'sky';
|
||||
}
|
||||
|
||||
export function formatProductActivityTime(occurredAt: number | null | undefined) {
|
||||
if (!Number.isFinite(occurredAt)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parsed = dayjs(Number(occurredAt));
|
||||
|
||||
return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm') : '';
|
||||
}
|
||||
|
||||
export function buildProductActivityRange(days: number): [string, string] {
|
||||
const end = dayjs().endOf('day');
|
||||
const start = dayjs()
|
||||
.subtract(Math.max(days - 1, 0), 'day')
|
||||
.startOf('day');
|
||||
|
||||
return [start.format('YYYY-MM-DD HH:mm:ss'), end.format('YYYY-MM-DD HH:mm:ss')];
|
||||
}
|
||||
|
||||
export function getProductActivityActionOptions(activityType: ProductActivityFilterType) {
|
||||
if (activityType === 'all') {
|
||||
return PRODUCT_ACTIVITY_ACTION_OPTIONS;
|
||||
}
|
||||
|
||||
return PRODUCT_ACTIVITY_ACTION_OPTIONS.filter(item => item.type === activityType);
|
||||
}
|
||||
|
||||
export function normalizeProductActivityActionTypes(
|
||||
activityType: ProductActivityFilterType,
|
||||
actionTypes: readonly Api.Product.ProductActivityActionType[]
|
||||
) {
|
||||
const allowed = new Set(getProductActivityActionOptions(activityType).map(item => item.value));
|
||||
|
||||
return actionTypes.filter(actionType => allowed.has(actionType));
|
||||
}
|
||||
|
||||
function parseActivityDetails(details: string | null | undefined): ActivityDetailRecord | null {
|
||||
if (!details?.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(details);
|
||||
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
const normalized = parsed as ActivityDetailRecord;
|
||||
const fieldChanges = normalized.fieldChanges;
|
||||
|
||||
if (fieldChanges && typeof fieldChanges === 'object' && !Array.isArray(fieldChanges)) {
|
||||
return {
|
||||
...normalized,
|
||||
...(fieldChanges as ActivityDetailRecord)
|
||||
};
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getRecordValue(record: ActivityDetailRecord | null, keys: readonly string[]) {
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const matchedKey = keys.find(key => key in record);
|
||||
|
||||
return matchedKey ? record[matchedKey] : undefined;
|
||||
}
|
||||
|
||||
function getFieldChangeText(
|
||||
record: ActivityDetailRecord | null,
|
||||
keys: readonly string[],
|
||||
preferredSide: 'before' | 'after'
|
||||
) {
|
||||
const rawValue = getRecordValue(record, keys);
|
||||
|
||||
if (rawValue && typeof rawValue === 'object' && !Array.isArray(rawValue)) {
|
||||
const fieldChange = rawValue as { before?: unknown; after?: unknown };
|
||||
const preferredValue = fieldChange[preferredSide];
|
||||
|
||||
if (preferredValue !== null && preferredValue !== undefined && String(preferredValue).trim()) {
|
||||
return String(preferredValue).trim();
|
||||
}
|
||||
|
||||
const fallbackSide = preferredSide === 'after' ? fieldChange.before : fieldChange.after;
|
||||
|
||||
if (fallbackSide !== null && fallbackSide !== undefined && String(fallbackSide).trim()) {
|
||||
return String(fallbackSide).trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (rawValue !== null && rawValue !== undefined && String(rawValue).trim()) {
|
||||
return String(rawValue).trim();
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function getActivityTargetUserName(
|
||||
item: Api.Product.ProductActivityTimelineItem,
|
||||
detailsRecord: ActivityDetailRecord | null
|
||||
) {
|
||||
const targetUserName = item.targetUserName?.trim() || '';
|
||||
|
||||
if (targetUserName) {
|
||||
return targetUserName;
|
||||
}
|
||||
|
||||
const preferredSide = item.actionType === 'remove_member' ? 'before' : 'after';
|
||||
|
||||
return getFieldChangeText(
|
||||
detailsRecord,
|
||||
[
|
||||
'memberUserName',
|
||||
'memberUserNickname',
|
||||
'memberName',
|
||||
'userNickname',
|
||||
'userName',
|
||||
'targetUserName',
|
||||
'targetUserNickname'
|
||||
],
|
||||
preferredSide
|
||||
);
|
||||
}
|
||||
|
||||
function getActivityTargetRoleName(
|
||||
item: Api.Product.ProductActivityTimelineItem,
|
||||
detailsRecord: ActivityDetailRecord | null
|
||||
) {
|
||||
const preferredSide = item.actionType === 'remove_member' ? 'before' : 'after';
|
||||
|
||||
return getFieldChangeText(detailsRecord, ['roleName', 'memberRoleName', 'targetRoleName'], preferredSide);
|
||||
}
|
||||
|
||||
function getRoleTransitionText(detailsRecord: ActivityDetailRecord | null) {
|
||||
const beforeRoleName = getFieldChangeText(detailsRecord, ['roleName', 'memberRoleName', 'targetRoleName'], 'before');
|
||||
const afterRoleName = getFieldChangeText(detailsRecord, ['roleName', 'memberRoleName', 'targetRoleName'], 'after');
|
||||
|
||||
if (beforeRoleName && afterRoleName && beforeRoleName !== afterRoleName) {
|
||||
return `${beforeRoleName} -> ${afterRoleName}`;
|
||||
}
|
||||
|
||||
return afterRoleName || beforeRoleName;
|
||||
}
|
||||
|
||||
function isGenericActivitySummary(summaryText: string, actionText: string) {
|
||||
if (!summaryText) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return summaryText === actionText || summaryText === actionText.replace('执行了', '执行了');
|
||||
}
|
||||
|
||||
function buildMemberChangeSummary(
|
||||
item: Api.Product.ProductActivityTimelineItem,
|
||||
detailsRecord: ActivityDetailRecord | null,
|
||||
operatorText: string
|
||||
) {
|
||||
const memberName = getActivityTargetUserName(item, detailsRecord);
|
||||
const roleName = getActivityTargetRoleName(item, detailsRecord);
|
||||
|
||||
if (!memberName) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const memberDetail = roleName ? `${memberName}(${roleName})` : memberName;
|
||||
const actionLabel = item.actionType === 'add_member' ? '将成员加入产品' : '将成员移出产品';
|
||||
|
||||
return operatorText === '--' ? `${actionLabel}:${memberDetail}` : `${operatorText}${actionLabel}:${memberDetail}`;
|
||||
}
|
||||
|
||||
function buildMemberUpdateSummary(
|
||||
item: Api.Product.ProductActivityTimelineItem,
|
||||
detailsRecord: ActivityDetailRecord | null,
|
||||
operatorText: string
|
||||
) {
|
||||
const memberName = getActivityTargetUserName(item, detailsRecord);
|
||||
const roleTransitionText = getRoleTransitionText(detailsRecord);
|
||||
const memberText = memberName || '成员';
|
||||
const roleText = roleTransitionText ? `,角色:${roleTransitionText}` : '';
|
||||
|
||||
return operatorText === '--'
|
||||
? `调整成员:${memberText}${roleText}`
|
||||
: `${operatorText}调整成员:${memberText}${roleText}`;
|
||||
}
|
||||
|
||||
function buildManagerChangeSummary(detailsRecord: ActivityDetailRecord | null, operatorText: string) {
|
||||
const beforeManagerName = getFieldChangeText(
|
||||
detailsRecord,
|
||||
['beforeManagerUserName', 'beforeManagerUserNickname', 'managerUserName', 'managerUserNickname', 'managerName'],
|
||||
'before'
|
||||
);
|
||||
const afterManagerName = getFieldChangeText(
|
||||
detailsRecord,
|
||||
['afterManagerUserName', 'afterManagerUserNickname', 'managerUserName', 'managerUserNickname', 'managerName'],
|
||||
'after'
|
||||
);
|
||||
|
||||
if (!beforeManagerName && !afterManagerName) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const transitionText =
|
||||
beforeManagerName && afterManagerName
|
||||
? `${beforeManagerName} -> ${afterManagerName}`
|
||||
: afterManagerName || beforeManagerName;
|
||||
|
||||
return operatorText === '--' ? `变更产品经理:${transitionText}` : `${operatorText}变更产品经理:${transitionText}`;
|
||||
}
|
||||
|
||||
function resolveDetailedSummary(
|
||||
item: Api.Product.ProductActivityTimelineItem,
|
||||
operatorText: string,
|
||||
actionText: string
|
||||
) {
|
||||
const summaryText = item.summary?.trim() || '';
|
||||
const detailsRecord = parseActivityDetails(item.details);
|
||||
|
||||
if (!isGenericActivitySummary(summaryText, actionText)) {
|
||||
return summaryText;
|
||||
}
|
||||
|
||||
if (item.actionType === 'add_member' || item.actionType === 'remove_member') {
|
||||
return buildMemberChangeSummary(item, detailsRecord, operatorText) || summaryText || actionText;
|
||||
}
|
||||
|
||||
if (item.actionType === 'update_member') {
|
||||
return buildMemberUpdateSummary(item, detailsRecord, operatorText);
|
||||
}
|
||||
|
||||
if (item.actionType === 'change_manager') {
|
||||
return buildManagerChangeSummary(detailsRecord, operatorText) || summaryText || actionText;
|
||||
}
|
||||
|
||||
return summaryText || actionText;
|
||||
}
|
||||
|
||||
export function buildProductActivityDisplayItem(
|
||||
item: Api.Product.ProductActivityTimelineItem
|
||||
): ProductActivityDisplayItem {
|
||||
const operatorText = item.operatorName?.trim() || '--';
|
||||
const actionText =
|
||||
operatorText === '--' ? `执行了【${item.actionName}】` : `${operatorText}执行了【${item.actionName}】`;
|
||||
const displaySummary = item.type === 'status' ? actionText : resolveDetailedSummary(item, operatorText, actionText);
|
||||
const compactText = displaySummary;
|
||||
|
||||
return {
|
||||
...item,
|
||||
tagLabel: activityTypeLabelMap[item.type],
|
||||
timeText: formatProductActivityTime(item.occurredAt) || '--',
|
||||
actionText,
|
||||
displaySummary,
|
||||
compactText,
|
||||
operatorText,
|
||||
reasonText: item.reason?.trim() || '',
|
||||
statusTransition:
|
||||
item.type === 'status' && item.fromStatus && item.toStatus
|
||||
? `${getStatusLabel(item.fromStatus)} -> ${getStatusLabel(item.toStatus)}`
|
||||
: '',
|
||||
tone: getActivityTone(item)
|
||||
};
|
||||
}
|
||||
|
||||
export function buildProductActivityDisplayItems(
|
||||
items: readonly Api.Product.ProductActivityTimelineItem[] | null | undefined
|
||||
) {
|
||||
return [...(items || [])].map(buildProductActivityDisplayItem);
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { getProductStatusLabel } from '../shared/product-master-data';
|
||||
|
||||
export interface ProductDashboardMetricCard {
|
||||
key: 'status' | 'team' | 'manager' | 'action';
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ProductDashboardTeamSummary {
|
||||
managerDisplayName: string;
|
||||
activeMemberCount: number;
|
||||
latestJoinedMemberLabel: string;
|
||||
roleSummaries: string[];
|
||||
}
|
||||
|
||||
export interface ProductDashboardQuickLink {
|
||||
key: 'requirement' | 'setting' | 'list';
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ProductDashboardActivityItem {
|
||||
key: string;
|
||||
title: string;
|
||||
content: string;
|
||||
time: string;
|
||||
tag: string;
|
||||
tone: 'sky' | 'emerald' | 'amber' | 'rose' | 'slate';
|
||||
}
|
||||
|
||||
export interface ProductDashboardPlaceholderPanel {
|
||||
title: string;
|
||||
description: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
export interface ProductDashboardGrowthModule {
|
||||
key: 'requirement-analysis' | 'project-progress' | 'rd-milestone';
|
||||
title: string;
|
||||
description: string;
|
||||
indicators: string[];
|
||||
}
|
||||
|
||||
function getActiveMembers(members: readonly Api.Product.ProductMember[]) {
|
||||
return members.filter(item => item.status === 0);
|
||||
}
|
||||
|
||||
function getTimeValue(value: string | null | undefined) {
|
||||
const parsed = dayjs(value);
|
||||
|
||||
return parsed.isValid() ? parsed.valueOf() : 0;
|
||||
}
|
||||
|
||||
function formatActivityTime(value: string | null | undefined) {
|
||||
const parsed = dayjs(value);
|
||||
|
||||
return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm') : '--';
|
||||
}
|
||||
|
||||
export function getProductDashboardMetricCards(
|
||||
settings: Api.Product.ProductSettings | null,
|
||||
members: readonly Api.Product.ProductMember[]
|
||||
) {
|
||||
const activeMembers = getActiveMembers(members);
|
||||
const managerDisplayName =
|
||||
settings?.baseInfo.managerUserNickname || activeMembers.find(item => item.managerFlag)?.userNickname || '--';
|
||||
const actionCount = settings?.lifecycle.availableActions.length || 0;
|
||||
const statusLabel = settings ? getProductStatusLabel(settings.lifecycle.statusCode) : '--';
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'status',
|
||||
label: '当前状态',
|
||||
value: statusLabel
|
||||
},
|
||||
{
|
||||
key: 'team',
|
||||
label: '团队成员',
|
||||
value: `${activeMembers.length} 人`
|
||||
},
|
||||
{
|
||||
key: 'manager',
|
||||
label: '当前经理',
|
||||
value: managerDisplayName
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
label: '可执行动作',
|
||||
value: `${actionCount} 项`
|
||||
}
|
||||
] satisfies ProductDashboardMetricCard[];
|
||||
}
|
||||
|
||||
export function getProductDashboardTeamSummary(
|
||||
settings: Api.Product.ProductSettings | null,
|
||||
members: readonly Api.Product.ProductMember[]
|
||||
): ProductDashboardTeamSummary {
|
||||
const activeMembers = getActiveMembers(members);
|
||||
const latestJoinedMember = activeMembers
|
||||
.slice()
|
||||
.sort((left, right) => getTimeValue(right.joinedTime) - getTimeValue(left.joinedTime))[0];
|
||||
const latestJoinedDate = latestJoinedMember ? dayjs(latestJoinedMember.joinedTime) : null;
|
||||
|
||||
const roleCounter = new Map<string, number>();
|
||||
|
||||
activeMembers.forEach(member => {
|
||||
const roleName = member.roleName || '未命名角色';
|
||||
|
||||
roleCounter.set(roleName, (roleCounter.get(roleName) || 0) + 1);
|
||||
});
|
||||
|
||||
const roleSummaries = Array.from(roleCounter.entries())
|
||||
.sort((left, right) => {
|
||||
const leftManagerWeight = left[0].includes('经理') ? 0 : 1;
|
||||
const rightManagerWeight = right[0].includes('经理') ? 0 : 1;
|
||||
|
||||
if (leftManagerWeight !== rightManagerWeight) {
|
||||
return leftManagerWeight - rightManagerWeight;
|
||||
}
|
||||
|
||||
return left[0].localeCompare(right[0], 'zh-CN');
|
||||
})
|
||||
.map(([roleName, count]) => `${roleName} ${count} 人`);
|
||||
|
||||
return {
|
||||
managerDisplayName:
|
||||
settings?.baseInfo.managerUserNickname || activeMembers.find(item => item.managerFlag)?.userNickname || '--',
|
||||
activeMemberCount: activeMembers.length,
|
||||
latestJoinedMemberLabel:
|
||||
latestJoinedMember && latestJoinedDate?.isValid()
|
||||
? `${latestJoinedMember.userNickname} · ${latestJoinedDate.format('YYYY-MM-DD')}`
|
||||
: '--',
|
||||
roleSummaries
|
||||
};
|
||||
}
|
||||
|
||||
export function getProductDashboardQuickLinks() {
|
||||
return [
|
||||
{
|
||||
key: 'requirement',
|
||||
label: '进入需求页',
|
||||
description: '查看当前产品下的需求承接位'
|
||||
},
|
||||
{
|
||||
key: 'setting',
|
||||
label: '查看设置',
|
||||
description: '进入产品基础信息、团队和生命周期管理'
|
||||
},
|
||||
{
|
||||
key: 'list',
|
||||
label: '返回列表',
|
||||
description: '退出当前对象视角,回到产品入口页'
|
||||
}
|
||||
] satisfies ProductDashboardQuickLink[];
|
||||
}
|
||||
|
||||
export function getProductDashboardActivityItems(
|
||||
product: Api.Product.Product | null,
|
||||
settings: Api.Product.ProductSettings | null,
|
||||
members: readonly Api.Product.ProductMember[]
|
||||
) {
|
||||
const items: ProductDashboardActivityItem[] = [];
|
||||
|
||||
if (product?.createTime) {
|
||||
items.push({
|
||||
key: `product-create-${product.id}`,
|
||||
title: '创建产品',
|
||||
content: `产品 ${product.name || product.code} 已建立并纳入对象上下文。`,
|
||||
time: product.createTime,
|
||||
tag: '创建',
|
||||
tone: 'sky'
|
||||
});
|
||||
}
|
||||
|
||||
if (settings && settings.baseInfo.lastStatusReason && product?.updateTime) {
|
||||
const statusCode = settings.lifecycle.statusCode;
|
||||
let tone: ProductDashboardActivityItem['tone'] = 'slate';
|
||||
|
||||
if (statusCode === 'active') {
|
||||
tone = 'emerald';
|
||||
} else if (statusCode === 'paused') {
|
||||
tone = 'amber';
|
||||
}
|
||||
|
||||
items.push({
|
||||
key: `product-status-${product.id}-${product.updateTime}`,
|
||||
title: `状态调整为${getProductStatusLabel(settings.lifecycle.statusCode)}`,
|
||||
content: settings.baseInfo.lastStatusReason,
|
||||
time: product.updateTime,
|
||||
tag: '状态',
|
||||
tone
|
||||
});
|
||||
}
|
||||
|
||||
members.forEach(member => {
|
||||
if (member.joinedTime) {
|
||||
items.push({
|
||||
key: `member-join-${member.id}`,
|
||||
title: '成员加入',
|
||||
content: `${member.userNickname} 以${member.roleName}身份加入当前产品。`,
|
||||
time: member.joinedTime,
|
||||
tag: '团队',
|
||||
tone: member.managerFlag ? 'emerald' : 'sky'
|
||||
});
|
||||
}
|
||||
|
||||
if (member.status === 1 && member.leftTime) {
|
||||
items.push({
|
||||
key: `member-leave-${member.id}`,
|
||||
title: '成员退出',
|
||||
content: `${member.userNickname} 已退出当前产品团队。`,
|
||||
time: member.leftTime,
|
||||
tag: '团队',
|
||||
tone: 'rose'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return items
|
||||
.filter(item => getTimeValue(item.time) > 0)
|
||||
.sort((left, right) => getTimeValue(right.time) - getTimeValue(left.time))
|
||||
.slice(0, 6)
|
||||
.map(item => ({
|
||||
...item,
|
||||
time: formatActivityTime(item.time)
|
||||
}));
|
||||
}
|
||||
|
||||
export function getProductDashboardRecentActivityPlaceholder() {
|
||||
return {
|
||||
title: '最近动态',
|
||||
description: '当前先基于产品详情、生命周期与团队关系展示已知动态;后续接入审计日志后可继续扩充为完整时间线。',
|
||||
items: ['产品创建记录', '状态调整记录', '成员加入记录', '成员退出记录']
|
||||
} satisfies ProductDashboardPlaceholderPanel;
|
||||
}
|
||||
|
||||
export function getProductDashboardRdMilestonePlaceholder() {
|
||||
return {
|
||||
title: '研发令 / 里程碑摘要',
|
||||
description: '当前未接入研发令与里程碑聚合能力,后续将在这里展示年度研发令、关键节点和版本里程碑。',
|
||||
items: ['当前年度研发令', '历史研发令', '关键节点计划']
|
||||
} satisfies ProductDashboardPlaceholderPanel;
|
||||
}
|
||||
|
||||
export function getProductDashboardGrowthModules() {
|
||||
return [
|
||||
{
|
||||
key: 'requirement-analysis',
|
||||
title: '需求分析',
|
||||
description: '暂未接入需求统计接口,后续将展示需求总量、状态分布与优先级分布。',
|
||||
indicators: ['需求总数', '待处理数量', '高优先级数量']
|
||||
},
|
||||
{
|
||||
key: 'project-progress',
|
||||
title: '项目推进',
|
||||
description: '当前未汇总项目推进数据,后续将展示关联项目、里程碑与风险摘要。',
|
||||
indicators: ['关联项目数', '进行中项目', '近期里程碑']
|
||||
},
|
||||
{
|
||||
key: 'rd-milestone',
|
||||
title: '研发令与里程碑',
|
||||
description: '当前未接入研发令与里程碑聚合能力,后续将在此展示研发令编号与关键节点信息。',
|
||||
indicators: ['当前年度研发令', '历史研发令', '关键节点']
|
||||
}
|
||||
] satisfies ProductDashboardGrowthModule[];
|
||||
}
|
||||
Reference in New Issue
Block a user