feat(components): 添加业务周范围选择器组件并增强表格搜索功能

- 新增 BusinessWeekRangePicker 组件到全局组件注册
- 在 performance 页面中实现默认选中团队视角
- 移除周报周期格式化工具函数,优化搜索面板
- 添加自定义周范围配对搜索字段类型支持
- 实现日期选择器快捷选项
- 添加周范围选择器 UI 组件和相应的样式布局
This commit is contained in:
dk
2026-07-24 13:59:27 +08:00
parent a9c6ddccba
commit 2ac36bd4f6
4 changed files with 197 additions and 15 deletions

View File

@@ -11,15 +11,20 @@ interface Option {
value: string | number;
}
interface DatePickerShortcut {
text: string;
value: Date | [Date, Date] | (() => Date) | (() => [Date, Date]);
}
export interface SearchField {
/** 字段键名 */
key: string;
/** 字段标签 */
label: string;
/** 字段类型 */
type: 'input' | 'select' | 'date' | 'dateRange' | 'dict';
type: 'input' | 'select' | 'date' | 'dateRange' | 'weekRangePair' | 'dict';
/** date 字段的日期粒度 */
dateType?: 'date' | 'month';
dateType?: 'date' | 'month' | 'week';
/** dateRange 字段的日期范围粒度 */
dateRangeType?: 'daterange' | 'monthrange';
/** 日期面板展示格式 */
@@ -28,6 +33,12 @@ export interface SearchField {
rangeSeparator?: string;
/** 日期字段提交格式 */
valueFormat?: string;
/** 日期快捷选项 */
shortcuts?: DatePickerShortcut[];
/** 日期面板切换回调 */
onCalendarChange?: (value: unknown) => void;
/** 日期弹层显示状态回调 */
onVisibleChange?: (visible: boolean) => void;
/** 占位列数,默认 1 */
span?: number;
/** select 类型的选项 */
@@ -142,6 +153,55 @@ function getFieldValue(field: SearchField) {
const value = props.modelValue[field.key];
return field.resolveValue ? field.resolveValue(value) : value;
}
function resolveWeekPickerValue(value: unknown, index: 0 | 1) {
if (!Array.isArray(value)) return '';
return value[index] || '';
}
function normalizeWeekPickerValue(value: string | null | undefined) {
if (!value) return '';
const [year, month, day] = value.split('-').map(item => Number(item));
const date = new Date(year, (month || 1) - 1, day || 1);
if (Number.isNaN(date.getTime())) return '';
const dayOfWeek = date.getDay();
if (dayOfWeek === 0) {
date.setDate(date.getDate() + 1);
}
const currentDay = date.getDay() || 7;
date.setDate(date.getDate() - currentDay + 1);
const normalizedYear = date.getFullYear();
const normalizedMonth = String(date.getMonth() + 1).padStart(2, '0');
const normalizedDay = String(date.getDate()).padStart(2, '0');
return `${normalizedYear}-${normalizedMonth}-${normalizedDay}`;
}
function updateWeekRangeFieldValue(field: SearchField, index: 0 | 1, rawValue: string | null | undefined) {
const currentValue = getFieldValue(field);
const nextValue: [string, string] = [
resolveWeekPickerValue(currentValue, 0),
resolveWeekPickerValue(currentValue, 1)
];
nextValue[index] = normalizeWeekPickerValue(rawValue);
if (!nextValue[0] && !nextValue[1]) {
updateFieldValue(field, undefined);
return;
}
if (nextValue[0] && nextValue[1] && nextValue[0] > nextValue[1]) {
const temp = nextValue[0];
nextValue[0] = nextValue[1];
nextValue[1] = temp;
}
updateFieldValue(field, nextValue);
}
</script>
<!-- eslint-disable vue/no-mutating-props -->
@@ -188,7 +248,10 @@ function getFieldValue(field: SearchField) {
clearable
:disabled="props.disabled"
:format="field.format"
:shortcuts="field.shortcuts"
:value-format="field.valueFormat || 'YYYY-MM-DD'"
@calendar-change="field.onCalendarChange?.($event)"
@visible-change="field.onVisibleChange?.($event)"
@update:model-value="val => updateFieldValue(field, val)"
/>
<ElDatePicker
@@ -199,12 +262,38 @@ function getFieldValue(field: SearchField) {
clearable
:disabled="props.disabled"
:format="field.format"
:shortcuts="field.shortcuts"
:value-format="field.valueFormat || 'YYYY-MM-DD'"
:range-separator="field.rangeSeparator || '至'"
:start-placeholder="field.dateRangeType === 'monthrange' ? '开始月份' : '开始日期'"
:end-placeholder="field.dateRangeType === 'monthrange' ? '结束月份' : '结束日期'"
@calendar-change="field.onCalendarChange?.($event)"
@visible-change="field.onVisibleChange?.($event)"
@update:model-value="val => updateFieldValue(field, val)"
/>
<div v-else-if="field.type === 'weekRangePair'" class="table-search-fields__week-range-pair">
<ElDatePicker
:model-value="resolveWeekPickerValue(getFieldValue(field), 0)"
type="week"
placeholder="开始周"
clearable
:disabled="props.disabled"
format="YYYY[年第]ww[]"
value-format="YYYY-MM-DD"
@update:model-value="val => updateWeekRangeFieldValue(field, 0, val)"
/>
<span class="table-search-fields__week-range-separator">至</span>
<ElDatePicker
:model-value="resolveWeekPickerValue(getFieldValue(field), 1)"
type="week"
placeholder="结束周"
clearable
:disabled="props.disabled"
format="YYYY[年第]ww[]"
value-format="YYYY-MM-DD"
@update:model-value="val => updateWeekRangeFieldValue(field, 1, val)"
/>
</div>
<DictSelect
v-else-if="field.type === 'dict'"
:model-value="getFieldValue(field)"
@@ -290,7 +379,10 @@ function getFieldValue(field: SearchField) {
clearable
:disabled="props.disabled"
:format="field.format"
:shortcuts="field.shortcuts"
:value-format="field.valueFormat || 'YYYY-MM-DD'"
@calendar-change="field.onCalendarChange?.($event)"
@visible-change="field.onVisibleChange?.($event)"
@update:model-value="val => updateFieldValue(field, val)"
/>
<ElDatePicker
@@ -301,12 +393,38 @@ function getFieldValue(field: SearchField) {
clearable
:disabled="props.disabled"
:format="field.format"
:shortcuts="field.shortcuts"
:value-format="field.valueFormat || 'YYYY-MM-DD'"
:range-separator="field.rangeSeparator || '至'"
:start-placeholder="field.dateRangeType === 'monthrange' ? '开始月份' : '开始日期'"
:end-placeholder="field.dateRangeType === 'monthrange' ? '结束月份' : '结束日期'"
@calendar-change="field.onCalendarChange?.($event)"
@visible-change="field.onVisibleChange?.($event)"
@update:model-value="val => updateFieldValue(field, val)"
/>
<div v-else-if="field.type === 'weekRangePair'" class="table-search-fields__week-range-pair">
<ElDatePicker
:model-value="resolveWeekPickerValue(getFieldValue(field), 0)"
type="week"
placeholder="开始周"
clearable
:disabled="props.disabled"
format="YYYY[年第]ww[]"
value-format="YYYY-MM-DD"
@update:model-value="val => updateWeekRangeFieldValue(field, 0, val)"
/>
<span class="table-search-fields__week-range-separator">至</span>
<ElDatePicker
:model-value="resolveWeekPickerValue(getFieldValue(field), 1)"
type="week"
placeholder="结束周"
clearable
:disabled="props.disabled"
format="YYYY[年第]ww[]"
value-format="YYYY-MM-DD"
@update:model-value="val => updateWeekRangeFieldValue(field, 1, val)"
/>
</div>
<DictSelect
v-else-if="field.type === 'dict'"
:model-value="getFieldValue(field)"
@@ -362,4 +480,23 @@ function getFieldValue(field: SearchField) {
width: 100%;
min-width: 0;
}
.table-search-fields__week-range-pair {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
}
.table-search-fields__week-range-pair :deep(.el-date-editor) {
min-width: 0;
flex: 1;
}
.table-search-fields__week-range-separator {
flex: 0 0 auto;
color: var(--el-text-color-placeholder);
font-size: 12px;
}
</style>

View File

@@ -20,6 +20,7 @@ declare module 'vue' {
BusinessRichTextView: typeof import('./../components/custom/business-rich-text-view.vue')['default']
BusinessUserPicker: typeof import('./../components/custom/business-user-picker.vue')['default']
BusinessUserSelect: typeof import('./../components/custom/business-user-select.vue')['default']
BusinessWeekRangePicker: typeof import('./../components/custom/business-week-range-picker.vue')['default']
ButtonIcon: typeof import('./../components/custom/button-icon.vue')['default']
CountTo: typeof import('./../components/custom/count-to.vue')['default']
CurrentUserRoleTags: typeof import('./../components/custom/current-user-role-tags.vue')['default']

View File

@@ -97,8 +97,9 @@ function transformPageResult(response: PerformanceSheetPageResponse, pageNo: num
const { hasAuth } = useAuth();
const authStore = useAuthStore();
const canUseTeamDashboard = computed(() => hasAuth(PerformancePermission.TeamDashboard));
const searchParams = reactive(createSearchParams());
const teamViewMode = ref<TeamViewMode>('self');
const teamViewMode = ref<TeamViewMode>(canUseTeamDashboard.value ? 'team' : 'self');
const subordinateTreeLoading = ref(false);
const subordinateTree = ref<Api.SystemManage.MySubordinateTreeNode | null>(null);
const selectedSubordinateUserId = ref<string | null>(null);
@@ -134,7 +135,6 @@ const ACTION_ICON_MAP = {
response: markRaw(IconMdiFileDocumentEditOutline)
};
const canUseTeamDashboard = computed(() => hasAuth(PerformancePermission.TeamDashboard));
const canCreate = computed(() => hasAuth(PerformancePermission.SheetCreate));
const canUpdate = computed(() => hasAuth(PerformancePermission.SheetUpdate));
const canDelete = computed(() => hasAuth(PerformancePermission.SheetDelete));

View File

@@ -7,7 +7,7 @@ import { fetchGetWorkReportStatusDict } from '@/service/api';
import type { SearchField } from '@/components/custom/table-search-fields.vue';
import TableSearchFields from '@/components/custom/table-search-fields.vue';
import { BOOLEAN_TRUE_FALSE_OPTIONS, type WorkReportSearchParams, type WorkReportType } from '../types';
import { formatIsoWeekRangeLabel, normalizeWeeklySearchRange } from '../utils';
import { normalizeWeeklySearchRange } from '../utils';
dayjs.extend(isoWeek);
@@ -44,11 +44,43 @@ const statusOptions = computed(() =>
}))
);
const weeklyPeriodPlaceholder = computed(() => {
const range = normalizeWeeklySearchRange(model.value.periodStartDate as string[] | undefined);
if (!range?.length) return '请选择周报周期';
return formatIsoWeekRangeLabel(range[0], range[1]) || '请选择周报周期';
});
const projectPeriodDraftStartDate = ref('');
function parseProjectPeriodStartDate() {
const startDate = projectPeriodDraftStartDate.value || (model.value.periodStartDate as string[] | undefined)?.[0];
const parsed = dayjs(startDate);
return parsed.isValid() ? parsed.toDate() : new Date();
}
function handleProjectPeriodCalendarChange(value: unknown) {
const startDate = Array.isArray(value) ? value[0] : '';
const parsed = dayjs(startDate);
projectPeriodDraftStartDate.value = parsed.isValid() ? parsed.format('YYYY-MM-DD') : '';
}
function handleProjectPeriodVisibleChange(visible: boolean) {
if (!visible) {
projectPeriodDraftStartDate.value = '';
}
}
function buildProjectPeriodShortcut(text: string, mutator: (date: Date) => void) {
return {
text,
value: () => {
const startDate = parseProjectPeriodStartDate();
const endDate = new Date(startDate.getTime());
mutator(endDate);
return [startDate, endDate] as [Date, Date];
}
};
}
const projectPeriodShortcuts = [
buildProjectPeriodShortcut('两星期', date => date.setDate(date.getDate() + 14)),
buildProjectPeriodShortcut('三星期', date => date.setDate(date.getDate() + 21)),
buildProjectPeriodShortcut('一个月', date => date.setMonth(date.getMonth() + 1))
];
const fields = computed<SearchField[]>(() => {
const baseFields: SearchField[] = [
@@ -69,10 +101,7 @@ const fields = computed<SearchField[]>(() => {
const weeklyPeriodField: SearchField = {
key: 'periodStartDate',
label: '周期',
type: 'dateRange',
placeholder: weeklyPeriodPlaceholder.value,
format: 'YYYY[年第]ww[周]',
rangeSeparator: '至'
type: 'weekRangePair'
};
const teamReporterField: SearchField[] = props.teamMode
@@ -107,7 +136,15 @@ const fields = computed<SearchField[]>(() => {
if (props.reportType === 'project') {
return [
baseFields[0],
{ key: 'periodStartDate', label: '周期', type: 'dateRange', placeholder: '请选择周期' },
{
key: 'periodStartDate',
label: '周期',
type: 'dateRange',
placeholder: '请选择周期',
shortcuts: projectPeriodShortcuts,
onCalendarChange: handleProjectPeriodCalendarChange,
onVisibleChange: handleProjectPeriodVisibleChange
},
{
key: 'projectId',
label: '项目',
@@ -165,6 +202,13 @@ watch(
watch(
() => model.value.periodStartDate,
value => {
if (props.reportType === 'project') {
if (!Array.isArray(value) || !value[0]) {
projectPeriodDraftStartDate.value = '';
}
return;
}
if (props.reportType !== 'weekly') return;
const normalizedValue = normalizeWeeklySearchRange(value as string[] | undefined);