Merge remote-tracking branch 'origin/main'

This commit is contained in:
dk
2026-04-28 15:43:41 +08:00
14 changed files with 3584 additions and 958 deletions

View 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>

View File

@@ -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,

View File

@@ -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'> & {

View File

@@ -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']

View 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

View 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[];

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>

View 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);
}

View File

@@ -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[];
}