- 新增产品动态时间线接口文档,明确前端调用规范 - 定义接口请求参数、响应结构和字段语义说明 - 提供请求示例和错误码说明 - 添加左侧筛选项映射规则和时间格式说明 feat(product): 实现产品首页动态时间线功能 - 重构产品首页布局结构,采用档案横幅型设计 - 新增对象基础概述横幅模块 - 实现产品动态时间线面板组件 - 集成需求池管理概览和最近变化区域 - 添加扩展信息区预留模块位 chore(docs): 更新代理工作说明和前端测试策略 - 添加前端任务测试策略说明 - 更新代理工作流程规范 - 明确git操作执行边界 - 优化组件类型声明更新
570 lines
15 KiB
Vue
570 lines
15 KiB
Vue
<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>
|