docs(api): 添加产品动态时间线前端API文档
- 新增产品动态时间线接口文档,明确前端调用规范 - 定义接口请求参数、响应结构和字段语义说明 - 提供请求示例和错误码说明 - 添加左侧筛选项映射规则和时间格式说明 feat(product): 实现产品首页动态时间线功能 - 重构产品首页布局结构,采用档案横幅型设计 - 新增对象基础概述横幅模块 - 实现产品动态时间线面板组件 - 集成需求池管理概览和最近变化区域 - 添加扩展信息区预留模块位 chore(docs): 更新代理工作说明和前端测试策略 - 添加前端任务测试策略说明 - 更新代理工作流程规范 - 明确git操作执行边界 - 优化组件类型声明更新
This commit is contained in:
569
src/components/custom/business-date-range-picker.vue
Normal file
569
src/components/custom/business-date-range-picker.vue
Normal file
@@ -0,0 +1,569 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { Calendar } from '@element-plus/icons-vue';
|
||||
|
||||
defineOptions({ name: 'BusinessDateRangePicker' });
|
||||
|
||||
type DateRangeValue = [string, string];
|
||||
|
||||
interface DateRangeShortcut {
|
||||
label: string;
|
||||
value: DateRangeValue | (() => DateRangeValue);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
shortcuts?: DateRangeShortcut[];
|
||||
popoverWidth?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
placeholder: '请选择日期范围',
|
||||
disabled: false,
|
||||
shortcuts: () => [],
|
||||
popoverWidth: 458
|
||||
});
|
||||
|
||||
const model = defineModel<DateRangeValue>({
|
||||
default: () => ['', '']
|
||||
});
|
||||
|
||||
const popoverVisible = ref(false);
|
||||
const activeTab = ref<'advanced' | 'custom'>('custom');
|
||||
const draftRange = ref<DateRangeValue>(normalizeDateRange(model.value));
|
||||
const panelMonth = ref(dayjs().startOf('month'));
|
||||
|
||||
const displayText = computed(() => {
|
||||
const normalizedRange = normalizeDateRange(model.value);
|
||||
|
||||
return normalizedRange.every(Boolean) ? normalizedRange.join(' ~ ') : '';
|
||||
});
|
||||
|
||||
const confirmDisabled = computed(() => {
|
||||
return !isCompleteDateRange(draftRange.value);
|
||||
});
|
||||
|
||||
const defaultShortcuts = computed<DateRangeShortcut[]>(() => [
|
||||
{
|
||||
label: '最近 7 天',
|
||||
value: () => buildRecentDateRange(7)
|
||||
},
|
||||
{
|
||||
label: '最近 30 天',
|
||||
value: () => buildRecentDateRange(30)
|
||||
},
|
||||
{
|
||||
label: '本周',
|
||||
value: () => [dayjs().startOf('week').format('YYYY-MM-DD'), dayjs().endOf('week').format('YYYY-MM-DD')]
|
||||
},
|
||||
{
|
||||
label: '本月',
|
||||
value: () => [dayjs().startOf('month').format('YYYY-MM-DD'), dayjs().endOf('month').format('YYYY-MM-DD')]
|
||||
}
|
||||
]);
|
||||
|
||||
const resolvedShortcuts = computed(() => (props.shortcuts?.length ? props.shortcuts : defaultShortcuts.value));
|
||||
|
||||
const panelTitle = computed(() => panelMonth.value.format('YYYY 年 M 月'));
|
||||
|
||||
const calendarCells = computed(() => {
|
||||
const startDate = panelMonth.value.startOf('month').startOf('week');
|
||||
|
||||
return Array.from({ length: 42 }, (_, index) => {
|
||||
const date = startDate.add(index, 'day');
|
||||
const dateText = date.format('YYYY-MM-DD');
|
||||
|
||||
return {
|
||||
date,
|
||||
dateText,
|
||||
dayText: date.format('D'),
|
||||
isCurrentMonth: date.month() === panelMonth.value.month(),
|
||||
isSelected: isSelectedDate(dateText),
|
||||
isInRange: isInSelectedRange(dateText),
|
||||
isStart: draftRange.value[0] === dateText,
|
||||
isEnd: draftRange.value[1] === dateText
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const activeShortcutLabel = computed(() => {
|
||||
const matchedShortcut = resolvedShortcuts.value.find(shortcut => {
|
||||
const shortcutRange = resolveShortcutValue(shortcut);
|
||||
|
||||
return shortcutRange[0] === draftRange.value[0] && shortcutRange[1] === draftRange.value[1];
|
||||
});
|
||||
|
||||
return matchedShortcut?.label || '';
|
||||
});
|
||||
|
||||
function buildRecentDateRange(days: number): DateRangeValue {
|
||||
const end = dayjs();
|
||||
const start = dayjs().subtract(Math.max(days - 1, 0), 'day');
|
||||
|
||||
return [start.format('YYYY-MM-DD'), end.format('YYYY-MM-DD')];
|
||||
}
|
||||
|
||||
function normalizeDateRange(value: readonly string[] | null | undefined): DateRangeValue {
|
||||
const [startDate = '', endDate = ''] = value || [];
|
||||
|
||||
return [formatDate(startDate), formatDate(endDate)];
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parsed = dayjs(value);
|
||||
|
||||
return parsed.isValid() ? parsed.format('YYYY-MM-DD') : '';
|
||||
}
|
||||
|
||||
function isCompleteDateRange(value: readonly string[]) {
|
||||
return value.length === 2 && value.every(item => dayjs(item).isValid());
|
||||
}
|
||||
|
||||
function syncPanelMonth(value: readonly string[]) {
|
||||
const [startDate, endDate] = value;
|
||||
const candidateDate = startDate || endDate;
|
||||
const parsed = dayjs(candidateDate);
|
||||
|
||||
panelMonth.value = parsed.isValid() ? parsed.startOf('month') : dayjs().startOf('month');
|
||||
}
|
||||
|
||||
function isSelectedDate(dateText: string) {
|
||||
return draftRange.value.includes(dateText);
|
||||
}
|
||||
|
||||
function isInSelectedRange(dateText: string) {
|
||||
if (!isCompleteDateRange(draftRange.value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const current = dayjs(dateText);
|
||||
const startDate = dayjs(draftRange.value[0]);
|
||||
const endDate = dayjs(draftRange.value[1]);
|
||||
|
||||
return current.isAfter(startDate, 'day') && current.isBefore(endDate, 'day');
|
||||
}
|
||||
|
||||
function resolveShortcutValue(shortcut: DateRangeShortcut) {
|
||||
return normalizeDateRange(typeof shortcut.value === 'function' ? shortcut.value() : shortcut.value);
|
||||
}
|
||||
|
||||
function updateModel(value: DateRangeValue) {
|
||||
const normalizedRange = normalizeDateRange(value);
|
||||
|
||||
if (!isCompleteDateRange(normalizedRange)) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.value = normalizedRange;
|
||||
}
|
||||
|
||||
function handleVisibleChange(currentVisible: boolean) {
|
||||
popoverVisible.value = currentVisible;
|
||||
|
||||
if (currentVisible) {
|
||||
draftRange.value = normalizeDateRange(model.value);
|
||||
syncPanelMonth(draftRange.value);
|
||||
return;
|
||||
}
|
||||
|
||||
draftRange.value = normalizeDateRange(model.value);
|
||||
}
|
||||
|
||||
function handleShortcutClick(shortcut: DateRangeShortcut) {
|
||||
const shortcutRange = resolveShortcutValue(shortcut);
|
||||
|
||||
draftRange.value = shortcutRange;
|
||||
syncPanelMonth(shortcutRange);
|
||||
}
|
||||
|
||||
function handleDateClick(dateText: string) {
|
||||
const [startDate, endDate] = draftRange.value;
|
||||
|
||||
if (!startDate || (startDate && endDate)) {
|
||||
draftRange.value = [dateText, ''];
|
||||
return;
|
||||
}
|
||||
|
||||
if (dayjs(dateText).isBefore(dayjs(startDate), 'day')) {
|
||||
draftRange.value = [dateText, startDate];
|
||||
return;
|
||||
}
|
||||
|
||||
draftRange.value = [startDate, dateText];
|
||||
}
|
||||
|
||||
function switchPanelMonth(step: number) {
|
||||
panelMonth.value = panelMonth.value.add(step, 'month');
|
||||
}
|
||||
|
||||
function switchPanelYear(step: number) {
|
||||
panelMonth.value = panelMonth.value.add(step, 'year');
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (confirmDisabled.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateModel(draftRange.value);
|
||||
popoverVisible.value = false;
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
draftRange.value = normalizeDateRange(model.value);
|
||||
popoverVisible.value = false;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => model.value,
|
||||
value => {
|
||||
if (!popoverVisible.value) {
|
||||
draftRange.value = normalizeDateRange(value);
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElPopover
|
||||
:visible="popoverVisible"
|
||||
trigger="click"
|
||||
placement="bottom-start"
|
||||
:width="popoverWidth"
|
||||
popper-class="business-date-range-picker__popper"
|
||||
:disabled="disabled"
|
||||
@update:visible="handleVisibleChange"
|
||||
>
|
||||
<template #reference>
|
||||
<ElInput
|
||||
:model-value="displayText"
|
||||
readonly
|
||||
:disabled="disabled"
|
||||
:placeholder="placeholder"
|
||||
class="business-date-range-picker__input"
|
||||
>
|
||||
<template #suffix>
|
||||
<ElIcon><Calendar /></ElIcon>
|
||||
</template>
|
||||
</ElInput>
|
||||
</template>
|
||||
|
||||
<div class="business-date-range-picker__panel">
|
||||
<aside class="business-date-range-picker__shortcuts">
|
||||
<ElButton
|
||||
v-for="shortcut in resolvedShortcuts"
|
||||
:key="shortcut.label"
|
||||
:type="activeShortcutLabel === shortcut.label ? 'primary' : 'default'"
|
||||
class="business-date-range-picker__shortcut"
|
||||
@click="handleShortcutClick(shortcut)"
|
||||
>
|
||||
{{ shortcut.label }}
|
||||
</ElButton>
|
||||
</aside>
|
||||
|
||||
<section class="business-date-range-picker__main">
|
||||
<div class="business-date-range-picker__tabs">
|
||||
<button
|
||||
class="business-date-range-picker__tab"
|
||||
:class="{ 'business-date-range-picker__tab--active': activeTab === 'advanced' }"
|
||||
type="button"
|
||||
@click="activeTab = 'advanced'"
|
||||
>
|
||||
高级选项
|
||||
</button>
|
||||
<button
|
||||
class="business-date-range-picker__tab"
|
||||
:class="{ 'business-date-range-picker__tab--active': activeTab === 'custom' }"
|
||||
type="button"
|
||||
@click="activeTab = 'custom'"
|
||||
>
|
||||
自定义
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'advanced'" class="business-date-range-picker__advanced">
|
||||
<ElButton
|
||||
v-for="shortcut in resolvedShortcuts"
|
||||
:key="shortcut.label"
|
||||
plain
|
||||
:type="activeShortcutLabel === shortcut.label ? 'primary' : 'default'"
|
||||
class="business-date-range-picker__advanced-button"
|
||||
@click="handleShortcutClick(shortcut)"
|
||||
>
|
||||
{{ shortcut.label }}
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<div v-else class="business-date-range-picker__custom">
|
||||
<div class="business-date-range-picker__fields">
|
||||
<ElInput v-model="draftRange[0]" class="business-date-range-picker__field" />
|
||||
<span class="business-date-range-picker__separator">—</span>
|
||||
<ElInput v-model="draftRange[1]" class="business-date-range-picker__field" />
|
||||
</div>
|
||||
|
||||
<div class="business-date-range-picker__calendar">
|
||||
<div class="business-date-range-picker__calendar-header">
|
||||
<button type="button" class="business-date-range-picker__icon-button" @click="switchPanelYear(-1)">
|
||||
«
|
||||
</button>
|
||||
<button type="button" class="business-date-range-picker__icon-button" @click="switchPanelMonth(-1)">
|
||||
‹
|
||||
</button>
|
||||
<span class="business-date-range-picker__calendar-title">{{ panelTitle }}</span>
|
||||
<button type="button" class="business-date-range-picker__icon-button" @click="switchPanelMonth(1)">
|
||||
›
|
||||
</button>
|
||||
<button type="button" class="business-date-range-picker__icon-button" @click="switchPanelYear(1)">
|
||||
»
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="business-date-range-picker__weekdays">
|
||||
<span>日</span>
|
||||
<span>一</span>
|
||||
<span>二</span>
|
||||
<span>三</span>
|
||||
<span>四</span>
|
||||
<span>五</span>
|
||||
<span>六</span>
|
||||
</div>
|
||||
|
||||
<div class="business-date-range-picker__days">
|
||||
<button
|
||||
v-for="cell in calendarCells"
|
||||
:key="cell.dateText"
|
||||
type="button"
|
||||
class="business-date-range-picker__day"
|
||||
:class="{
|
||||
'business-date-range-picker__day--muted': !cell.isCurrentMonth,
|
||||
'business-date-range-picker__day--selected': cell.isSelected,
|
||||
'business-date-range-picker__day--in-range': cell.isInRange,
|
||||
'business-date-range-picker__day--start': cell.isStart,
|
||||
'business-date-range-picker__day--end': cell.isEnd
|
||||
}"
|
||||
@click="handleDateClick(cell.dateText)"
|
||||
>
|
||||
<span>{{ cell.dayText }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="business-date-range-picker__footer">
|
||||
<ElButton @click="handleCancel">取消</ElButton>
|
||||
<ElButton type="primary" :disabled="confirmDisabled" @click="handleConfirm">确定</ElButton>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</ElPopover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.business-date-range-picker__input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.business-date-range-picker__panel {
|
||||
display: grid;
|
||||
grid-template-columns: 102px minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background-color: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.business-date-range-picker__shortcuts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px 10px;
|
||||
border-right: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.business-date-range-picker__shortcut {
|
||||
width: 78px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.business-date-range-picker__main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.business-date-range-picker__tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.business-date-range-picker__tab {
|
||||
position: relative;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--el-text-color-regular);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.business-date-range-picker__tab--active {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.business-date-range-picker__tab--active::after {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 0;
|
||||
left: 12px;
|
||||
height: 2px;
|
||||
background-color: var(--el-color-primary);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.business-date-range-picker__advanced {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
min-height: 230px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.business-date-range-picker__advanced-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.business-date-range-picker__custom {
|
||||
padding: 10px 8px 0;
|
||||
}
|
||||
|
||||
.business-date-range-picker__fields {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 20px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 8px 6px;
|
||||
}
|
||||
|
||||
.business-date-range-picker__separator {
|
||||
color: var(--el-text-color-placeholder);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.business-date-range-picker__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 10px 12px 12px;
|
||||
border-top: 1px solid var(--el-border-color-light);
|
||||
}
|
||||
|
||||
:global(.business-date-range-picker__popper.el-popover.el-popper) {
|
||||
padding: 0;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
}
|
||||
|
||||
.business-date-range-picker__calendar {
|
||||
padding: 0 10px 8px;
|
||||
}
|
||||
|
||||
.business-date-range-picker__calendar-header {
|
||||
display: grid;
|
||||
grid-template-columns: 28px 28px minmax(0, 1fr) 28px 28px;
|
||||
align-items: center;
|
||||
height: 36px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.business-date-range-picker__calendar-title {
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.business-date-range-picker__icon-button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--el-text-color-placeholder);
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.business-date-range-picker__icon-button:hover {
|
||||
color: var(--el-color-primary);
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.business-date-range-picker__weekdays,
|
||||
.business-date-range-picker__days {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
}
|
||||
|
||||
.business-date-range-picker__weekdays {
|
||||
height: 30px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.business-date-range-picker__days {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.business-date-range-picker__day {
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--el-text-color-regular);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.business-date-range-picker__day span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.business-date-range-picker__day:hover span {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.business-date-range-picker__day--muted {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.business-date-range-picker__day--in-range {
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.business-date-range-picker__day--selected span {
|
||||
background-color: var(--el-color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.business-date-range-picker__day--start {
|
||||
border-radius: 6px 0 0 6px;
|
||||
}
|
||||
|
||||
.business-date-range-picker__day--end {
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user