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

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

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

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

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

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

View File

@@ -0,0 +1,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>