Files
cn-rdms-web/src/components/custom/business-date-range-picker.vue
hongawen 5b9c7e781b docs(api): 添加产品动态时间线前端API文档
- 新增产品动态时间线接口文档,明确前端调用规范
- 定义接口请求参数、响应结构和字段语义说明
- 提供请求示例和错误码说明
- 添加左侧筛选项映射规则和时间格式说明

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

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

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

- 添加前端任务测试策略说明
- 更新代理工作流程规范
- 明确git操作执行边界
- 优化组件类型声明更新
2026-04-24 16:38:43 +08:00

570 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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