test(cache): 修复CacheConfigTest边界值测试
- 修改 shouldVerifyCacheManager_withMaximumIntegerTtl 为 shouldVerifyCacheManager_withMaximumAllowedTtl - 使用正确的最大TTL值(10080分钟,7天)而不是 Integer.MAX_VALUE - 新增 shouldThrowException_whenTtlExceedsMaximum 测试验证边界检查 - 所有1266个测试用例通过 - 覆盖率: 指令81.89%, 行88.48%, 分支51.55% docs: 添加项目状态报告 - 生成 PROJECT_STATUS_REPORT.md 详细记录项目当前状态 - 包含质量指标、已完成功能、待办事项和技术债务
This commit is contained in:
84
frontend/admin/src/components/ExportFieldPanel.vue
Normal file
84
frontend/admin/src/components/ExportFieldPanel.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div class="text-sm font-semibold text-mosquito-ink">{{ title }}</div>
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
v-for="field in fields"
|
||||
:key="field.key"
|
||||
class="flex items-center gap-2 text-xs text-mosquito-ink/80"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4"
|
||||
:checked="isChecked(field.key)"
|
||||
:disabled="field.required"
|
||||
@change="onToggle(field.key, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<span>{{ field.label }}</span>
|
||||
<span v-if="field.required" class="rounded-full bg-mosquito-accent/10 px-2 py-0.5 text-[10px] font-semibold text-mosquito-brand">
|
||||
必选
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-mosquito-ink/70">
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="selectAll">全选</button>
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" @click="clearOptional">仅保留必选</button>
|
||||
<button
|
||||
data-test="export-button"
|
||||
class="mos-btn mos-btn-accent !py-1 !px-3 !text-xs"
|
||||
@click="emit('export')"
|
||||
>
|
||||
导出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
export type ExportField = {
|
||||
key: string
|
||||
label: string
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
fields: ExportField[]
|
||||
selected: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:selected', value: string[]): void
|
||||
(event: 'export'): void
|
||||
}>()
|
||||
|
||||
const requiredKeys = computed(() => props.fields.filter((field) => field.required).map((field) => field.key))
|
||||
|
||||
const normalizeSelection = (next: string[]) => {
|
||||
const merged = new Set([...requiredKeys.value, ...next])
|
||||
return props.fields.map((field) => field.key).filter((key) => merged.has(key))
|
||||
}
|
||||
|
||||
const isChecked = (key: string) => normalizeSelection(props.selected).includes(key)
|
||||
|
||||
const onToggle = (key: string, checked: boolean) => {
|
||||
if (requiredKeys.value.includes(key)) {
|
||||
emit('update:selected', normalizeSelection(props.selected))
|
||||
return
|
||||
}
|
||||
const next = checked
|
||||
? [...props.selected, key]
|
||||
: props.selected.filter((item) => item !== key)
|
||||
emit('update:selected', normalizeSelection(next))
|
||||
}
|
||||
|
||||
const selectAll = () => {
|
||||
emit('update:selected', normalizeSelection(props.fields.map((field) => field.key)))
|
||||
}
|
||||
|
||||
const clearOptional = () => {
|
||||
emit('update:selected', normalizeSelection([]))
|
||||
}
|
||||
</script>
|
||||
48
frontend/admin/src/components/FilterPaginationBar.vue
Normal file
48
frontend/admin/src/components/FilterPaginationBar.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-mosquito-ink/70">
|
||||
<slot name="filters" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-mosquito-ink/70">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<slot />
|
||||
|
||||
<div v-if="showPagination" class="mt-2 flex items-center justify-between text-xs text-mosquito-ink/70">
|
||||
<button class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs" :disabled="page <= 0" @click="$emit('prev')">
|
||||
上一页
|
||||
</button>
|
||||
<div>第 {{ page + 1 }} / {{ totalPages }} 页</div>
|
||||
<button
|
||||
class="mos-btn mos-btn-secondary !py-1 !px-2 !text-xs"
|
||||
:disabled="page >= totalPages - 1"
|
||||
@click="$emit('next')"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
page?: number
|
||||
totalPages?: number
|
||||
}>(),
|
||||
{
|
||||
page: 0,
|
||||
totalPages: 0
|
||||
}
|
||||
)
|
||||
|
||||
const showPagination = props.totalPages > 1
|
||||
|
||||
defineEmits<{
|
||||
prev: []
|
||||
next: []
|
||||
}>()
|
||||
</script>
|
||||
56
frontend/admin/src/components/ListSection.vue
Normal file
56
frontend/admin/src/components/ListSection.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
<header v-if="$slots.title || $slots.subtitle" class="space-y-2">
|
||||
<h1 v-if="$slots.title" class="mos-title text-2xl font-semibold">
|
||||
<slot name="title" />
|
||||
</h1>
|
||||
<p v-if="$slots.subtitle" class="mos-muted text-sm">
|
||||
<slot name="subtitle" />
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="mos-card p-5">
|
||||
<FilterPaginationBar
|
||||
v-if="page !== undefined && totalPages !== undefined"
|
||||
:page="page"
|
||||
:total-pages="totalPages"
|
||||
@prev="emit('prev')"
|
||||
@next="emit('next')"
|
||||
>
|
||||
<template #filters>
|
||||
<slot name="filters" />
|
||||
</template>
|
||||
<template #actions>
|
||||
<slot name="actions" />
|
||||
</template>
|
||||
<slot />
|
||||
<slot name="empty" />
|
||||
</FilterPaginationBar>
|
||||
<template v-else>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<slot name="filters" />
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 space-y-3">
|
||||
<slot />
|
||||
<slot name="empty" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="$slots.footer" class="mt-4">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import FilterPaginationBar from './FilterPaginationBar.vue'
|
||||
|
||||
defineProps<{ page?: number; totalPages?: number }>()
|
||||
const emit = defineEmits<{ (event: 'prev'): void; (event: 'next'): void }>()
|
||||
</script>
|
||||
@@ -0,0 +1,40 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ExportFieldPanel from '../ExportFieldPanel.vue'
|
||||
|
||||
describe('ExportFieldPanel', () => {
|
||||
it('emits updated selection when toggling optional field', async () => {
|
||||
const wrapper = mount(ExportFieldPanel, {
|
||||
props: {
|
||||
title: 'Fields',
|
||||
fields: [
|
||||
{ key: 'name', label: 'Name', required: true },
|
||||
{ key: 'status', label: 'Status' }
|
||||
],
|
||||
selected: ['name']
|
||||
}
|
||||
})
|
||||
|
||||
const inputs = wrapper.findAll('input[type="checkbox"]')
|
||||
expect(inputs).toHaveLength(2)
|
||||
expect((inputs[0].element as HTMLInputElement).checked).toBe(true)
|
||||
expect((inputs[0].element as HTMLInputElement).disabled).toBe(true)
|
||||
|
||||
await inputs[1].setValue(true)
|
||||
const emitted = wrapper.emitted('update:selected')
|
||||
expect(emitted).toBeTruthy()
|
||||
expect(emitted?.[0][0]).toEqual(['name', 'status'])
|
||||
})
|
||||
|
||||
it('emits export event when clicking export button', async () => {
|
||||
const wrapper = mount(ExportFieldPanel, {
|
||||
props: {
|
||||
title: 'Fields',
|
||||
fields: [{ key: 'name', label: 'Name' }],
|
||||
selected: ['name']
|
||||
}
|
||||
})
|
||||
|
||||
await wrapper.get('[data-test="export-button"]').trigger('click')
|
||||
expect(wrapper.emitted('export')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
26
frontend/admin/src/components/__tests__/ListSection.test.ts
Normal file
26
frontend/admin/src/components/__tests__/ListSection.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ListSection from '../ListSection.vue'
|
||||
|
||||
describe('ListSection', () => {
|
||||
it('renders provided slots', () => {
|
||||
const wrapper = mount(ListSection, {
|
||||
slots: {
|
||||
title: '<div data-test="title">Title</div>',
|
||||
subtitle: '<div data-test="subtitle">Subtitle</div>',
|
||||
filters: '<div data-test="filters">Filters</div>',
|
||||
actions: '<div data-test="actions">Actions</div>',
|
||||
default: '<div data-test="content">Content</div>',
|
||||
empty: '<div data-test="empty">Empty</div>',
|
||||
footer: '<div data-test="footer">Footer</div>'
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-test="title"]').text()).toBe('Title')
|
||||
expect(wrapper.find('[data-test="subtitle"]').text()).toBe('Subtitle')
|
||||
expect(wrapper.find('[data-test="filters"]').text()).toBe('Filters')
|
||||
expect(wrapper.find('[data-test="actions"]').text()).toBe('Actions')
|
||||
expect(wrapper.find('[data-test="content"]').text()).toBe('Content')
|
||||
expect(wrapper.find('[data-test="empty"]').text()).toBe('Empty')
|
||||
expect(wrapper.find('[data-test="footer"]').text()).toBe('Footer')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user