08ea5fbe98
添加用户管理视图、API和状态管理文件
694 lines
17 KiB
Markdown
694 lines
17 KiB
Markdown
# Uniapp模块迭代计划 - 下一阶段优化
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** 实现首页搜索功能,完善组件测试覆盖率,提升用户体验
|
|
|
|
**Architecture:** 基于uni-app + Vue 3 + TypeScript架构,实现跨平台搜索功能,增强组件可测试性
|
|
|
|
**Tech Stack:** uni-app, Vue 3.5, TypeScript 5.x, Pinia, Playwright, Vitest
|
|
|
|
---
|
|
|
|
## 背景
|
|
|
|
根据全系统功能对接综合评估,Uniapp模块存在以下待优化事项:
|
|
- 首页搜索功能入口存在但未实现(测试失败)
|
|
- 组件测试覆盖率可进一步提升
|
|
- 搜索服务已实现但未与UI集成
|
|
|
|
---
|
|
|
|
## Task 1: 实现首页搜索功能入口
|
|
|
|
**Files:**
|
|
- Modify: `src/pages/index/index.vue`
|
|
- Modify: `src/components/SearchConditionPanel/index.vue`
|
|
- Test: `src/__tests__/components/SearchConditionPanel.test.ts`
|
|
|
|
**Step 1: 编写搜索功能测试**
|
|
|
|
```typescript
|
|
// src/__tests__/components/SearchConditionPanel.test.ts
|
|
import { mount } from '@vue/test-utils';
|
|
import SearchConditionPanel from '@/components/SearchConditionPanel/index.vue';
|
|
|
|
describe('SearchConditionPanel', () => {
|
|
it('应该显示搜索输入框', () => {
|
|
const wrapper = mount(SearchConditionPanel);
|
|
expect(wrapper.find('[data-testid="search-input"]').exists()).toBe(true);
|
|
});
|
|
|
|
it('点击搜索按钮应触发搜索事件', async () => {
|
|
const wrapper = mount(SearchConditionPanel);
|
|
const searchInput = wrapper.find('[data-testid="search-input"]');
|
|
await searchInput.setValue('测试搜索');
|
|
|
|
const searchButton = wrapper.find('[data-testid="search-button"]');
|
|
await searchButton.trigger('click');
|
|
|
|
expect(wrapper.emitted('search')).toBeTruthy();
|
|
expect(wrapper.emitted('search')![0]).toEqual(['测试搜索']);
|
|
});
|
|
|
|
it('空搜索应显示提示信息', async () => {
|
|
const wrapper = mount(SearchConditionPanel);
|
|
const searchButton = wrapper.find('[data-testid="search-button"]');
|
|
await searchButton.trigger('click');
|
|
|
|
expect(wrapper.find('.search-hint').exists()).toBe(true);
|
|
});
|
|
});
|
|
```
|
|
|
|
**Step 2: 运行测试验证失败**
|
|
|
|
Run: `cd everything-is-suitable-uniapp && npm run test -- SearchConditionPanel`
|
|
|
|
Expected: FAIL (功能未实现)
|
|
|
|
**Step 3: 更新首页添加搜索入口**
|
|
|
|
```vue
|
|
<!-- src/pages/index/index.vue -->
|
|
<template>
|
|
<view class="index-page">
|
|
<view class="search-section">
|
|
<view class="search-bar" @click="handleSearchClick">
|
|
<text class="search-icon">🔍</text>
|
|
<text class="search-placeholder">搜索老黄历、日历...</text>
|
|
</view>
|
|
</view>
|
|
|
|
<SearchConditionPanel
|
|
v-if="showSearchPanel"
|
|
:visible="showSearchPanel"
|
|
@search="handleSearch"
|
|
@close="showSearchPanel = false"
|
|
/>
|
|
</view>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref } from 'vue';
|
|
import SearchConditionPanel from '@/components/SearchConditionPanel/index.vue';
|
|
|
|
const showSearchPanel = ref(false);
|
|
|
|
const handleSearchClick = () => {
|
|
showSearchPanel.value = true;
|
|
};
|
|
|
|
const handleSearch = (keyword: string) => {
|
|
uni.navigateTo({
|
|
url: `/pages/almanac-search/index?keyword=${encodeURIComponent(keyword)}`
|
|
});
|
|
showSearchPanel.value = false;
|
|
};
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
.search-section {
|
|
padding: 20rpx 30rpx;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
}
|
|
|
|
.search-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 20rpx 30rpx;
|
|
background: rgba(255, 255, 255, 0.9);
|
|
border-radius: 40rpx;
|
|
|
|
.search-icon {
|
|
margin-right: 20rpx;
|
|
font-size: 32rpx;
|
|
}
|
|
|
|
.search-placeholder {
|
|
color: #999;
|
|
font-size: 28rpx;
|
|
}
|
|
}
|
|
</style>
|
|
```
|
|
|
|
**Step 4: 完善搜索条件面板组件**
|
|
|
|
```vue
|
|
<!-- src/components/SearchConditionPanel/index.vue -->
|
|
<template>
|
|
<view v-if="visible" class="search-panel-overlay" @click="handleClose">
|
|
<view class="search-panel" @click.stop>
|
|
<view class="search-header">
|
|
<text class="title">搜索条件</text>
|
|
<text class="close-btn" @click="handleClose">✕</text>
|
|
</view>
|
|
|
|
<view class="search-content">
|
|
<view class="search-input-wrapper">
|
|
<input
|
|
v-model="searchKeyword"
|
|
class="search-input"
|
|
type="text"
|
|
placeholder="请输入搜索关键词"
|
|
data-testid="search-input"
|
|
@confirm="handleSearch"
|
|
/>
|
|
</view>
|
|
|
|
<view v-if="showHint" class="search-hint">
|
|
<text>请输入搜索关键词</text>
|
|
</view>
|
|
|
|
<view class="search-history" v-if="searchHistory.length > 0">
|
|
<text class="history-title">搜索历史</text>
|
|
<view class="history-tags">
|
|
<text
|
|
v-for="(item, index) in searchHistory"
|
|
:key="index"
|
|
class="history-tag"
|
|
@click="handleHistoryClick(item)"
|
|
>
|
|
{{ item }}
|
|
</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
|
|
<view class="search-footer">
|
|
<button class="search-button" data-testid="search-button" @click="handleSearch">
|
|
搜索
|
|
</button>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed } from 'vue';
|
|
|
|
const props = defineProps<{
|
|
visible: boolean;
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
search: [keyword: string];
|
|
close: [];
|
|
}>();
|
|
|
|
const searchKeyword = ref('');
|
|
const showHint = ref(false);
|
|
const searchHistory = ref<string[]>([]);
|
|
|
|
const handleSearch = () => {
|
|
if (!searchKeyword.value.trim()) {
|
|
showHint.value = true;
|
|
return;
|
|
}
|
|
|
|
showHint.value = false;
|
|
|
|
// 添加到搜索历史
|
|
if (!searchHistory.value.includes(searchKeyword.value)) {
|
|
searchHistory.value.unshift(searchKeyword.value);
|
|
if (searchHistory.value.length > 10) {
|
|
searchHistory.value.pop();
|
|
}
|
|
}
|
|
|
|
emit('search', searchKeyword.value);
|
|
};
|
|
|
|
const handleHistoryClick = (keyword: string) => {
|
|
searchKeyword.value = keyword;
|
|
handleSearch();
|
|
};
|
|
|
|
const handleClose = () => {
|
|
emit('close');
|
|
};
|
|
</script>
|
|
```
|
|
|
|
**Step 5: 运行测试验证通过**
|
|
|
|
Run: `cd everything-is-suitable-uniapp && npm run test -- SearchConditionPanel`
|
|
|
|
Expected: PASS
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/pages/index/index.vue
|
|
git add src/components/SearchConditionPanel/index.vue
|
|
git add src/__tests__/components/SearchConditionPanel.test.ts
|
|
git commit -m "feat(uniapp): implement search functionality on index page"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: 集成搜索服务
|
|
|
|
**Files:**
|
|
- Modify: `src/pages/almanac-search/index.vue`
|
|
- Modify: `src/services/searchService.ts`
|
|
- Test: `src/__tests__/services/searchService.test.ts`
|
|
|
|
**Step 1: 编写搜索服务集成测试**
|
|
|
|
```typescript
|
|
// src/__tests__/services/searchService.test.ts
|
|
import { searchService } from '@/services/searchService';
|
|
|
|
describe('searchService', () => {
|
|
it('应该返回搜索结果', async () => {
|
|
const result = await searchService.search('黄历');
|
|
expect(result).toBeDefined();
|
|
expect(result.items).toBeInstanceOf(Array);
|
|
});
|
|
|
|
it('空关键词应返回空结果', async () => {
|
|
const result = await searchService.search('');
|
|
expect(result.items).toHaveLength(0);
|
|
});
|
|
|
|
it('应该支持分页', async () => {
|
|
const result = await searchService.search('黄历', { page: 1, pageSize: 10 });
|
|
expect(result.pagination).toBeDefined();
|
|
expect(result.pagination.page).toBe(1);
|
|
});
|
|
});
|
|
```
|
|
|
|
**Step 2: 运行测试验证**
|
|
|
|
Run: `cd everything-is-suitable-uniapp && npm run test -- searchService`
|
|
|
|
Expected: PASS (服务已实现)
|
|
|
|
**Step 3: 更新搜索结果页面**
|
|
|
|
```vue
|
|
<!-- src/pages/almanac-search/index.vue -->
|
|
<template>
|
|
<view class="search-result-page">
|
|
<view class="search-header">
|
|
<view class="search-bar">
|
|
<input
|
|
v-model="keyword"
|
|
class="search-input"
|
|
type="text"
|
|
placeholder="搜索老黄历"
|
|
@confirm="handleSearch"
|
|
/>
|
|
<text class="search-btn" @click="handleSearch">搜索</text>
|
|
</view>
|
|
</view>
|
|
|
|
<view class="search-content">
|
|
<view v-if="loading" class="loading">
|
|
<text>搜索中...</text>
|
|
</view>
|
|
|
|
<view v-else-if="results.length === 0" class="empty">
|
|
<text>暂无搜索结果</text>
|
|
</view>
|
|
|
|
<view v-else class="result-list">
|
|
<SearchResultCard
|
|
v-for="item in results"
|
|
:key="item.id"
|
|
:data="item"
|
|
@click="handleItemClick(item)"
|
|
/>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted } from 'vue';
|
|
import { searchService } from '@/services/searchService';
|
|
import SearchResultCard from '@/components/SearchResultCard/index.vue';
|
|
|
|
const keyword = ref('');
|
|
const results = ref<any[]>([]);
|
|
const loading = ref(false);
|
|
|
|
onMounted(() => {
|
|
const pages = getCurrentPages();
|
|
const currentPage = pages[pages.length - 1];
|
|
keyword.value = currentPage.options?.keyword || '';
|
|
if (keyword.value) {
|
|
handleSearch();
|
|
}
|
|
});
|
|
|
|
const handleSearch = async () => {
|
|
if (!keyword.value.trim()) return;
|
|
|
|
loading.value = true;
|
|
try {
|
|
const result = await searchService.search(keyword.value);
|
|
results.value = result.items;
|
|
} catch (error) {
|
|
console.error('搜索失败:', error);
|
|
uni.showToast({ title: '搜索失败', icon: 'none' });
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const handleItemClick = (item: any) => {
|
|
uni.navigateTo({
|
|
url: `/pages/almanac/index?id=${item.id}`
|
|
});
|
|
};
|
|
</script>
|
|
```
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/pages/almanac-search/index.vue
|
|
git add src/__tests__/services/searchService.test.ts
|
|
git commit -m "feat(uniapp): integrate search service with search result page"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: 完善组件测试覆盖率
|
|
|
|
**Files:**
|
|
- Create: `src/__tests__/components/AlmanacCard.test.ts`
|
|
- Create: `src/__tests__/components/CalendarCard.test.ts`
|
|
- Create: `src/__tests__/components/LoadingIndicator.test.ts`
|
|
|
|
**Step 1: 编写老黄历卡片组件测试**
|
|
|
|
```typescript
|
|
// src/__tests__/components/AlmanacCard.test.ts
|
|
import { mount } from '@vue/test-utils';
|
|
import AlmanacCard from '@/components/AlmanacCard/AlmanacCard.vue';
|
|
|
|
describe('AlmanacCard', () => {
|
|
const mockData = {
|
|
date: '2024-01-01',
|
|
lunarDate: '腊月初一',
|
|
suit: ['嫁娶', '出行'],
|
|
avoid: ['动土', '安葬'],
|
|
zodiac: '龙'
|
|
};
|
|
|
|
it('应该正确渲染日期信息', () => {
|
|
const wrapper = mount(AlmanacCard, {
|
|
props: { data: mockData }
|
|
});
|
|
|
|
expect(wrapper.text()).toContain('2024-01-01');
|
|
expect(wrapper.text()).toContain('腊月初一');
|
|
});
|
|
|
|
it('应该显示宜忌信息', () => {
|
|
const wrapper = mount(AlmanacCard, {
|
|
props: { data: mockData }
|
|
});
|
|
|
|
expect(wrapper.text()).toContain('嫁娶');
|
|
expect(wrapper.text()).toContain('动土');
|
|
});
|
|
|
|
it('应该显示生肖信息', () => {
|
|
const wrapper = mount(AlmanacCard, {
|
|
props: { data: mockData }
|
|
});
|
|
|
|
expect(wrapper.text()).toContain('龙');
|
|
});
|
|
|
|
it('点击应触发事件', async () => {
|
|
const wrapper = mount(AlmanacCard, {
|
|
props: { data: mockData }
|
|
});
|
|
|
|
await wrapper.trigger('click');
|
|
expect(wrapper.emitted('click')).toBeTruthy();
|
|
});
|
|
});
|
|
```
|
|
|
|
**Step 2: 编写日历卡片组件测试**
|
|
|
|
```typescript
|
|
// src/__tests__/components/CalendarCard.test.ts
|
|
import { mount } from '@vue/test-utils';
|
|
import CalendarCard from '@/components/CalendarCard/CalendarCard.vue';
|
|
|
|
describe('CalendarCard', () => {
|
|
const mockDate = new Date('2024-01-15');
|
|
|
|
it('应该正确渲染日期', () => {
|
|
const wrapper = mount(CalendarCard, {
|
|
props: { date: mockDate }
|
|
});
|
|
|
|
expect(wrapper.text()).toContain('15');
|
|
});
|
|
|
|
it('应该显示农历信息', () => {
|
|
const wrapper = mount(CalendarCard, {
|
|
props: { date: mockDate, lunarInfo: '腊月初五' }
|
|
});
|
|
|
|
expect(wrapper.text()).toContain('腊月初五');
|
|
});
|
|
|
|
it('今天应该有特殊样式', () => {
|
|
const today = new Date();
|
|
const wrapper = mount(CalendarCard, {
|
|
props: { date: today, isToday: true }
|
|
});
|
|
|
|
expect(wrapper.classes()).toContain('is-today');
|
|
});
|
|
|
|
it('选中状态应有特殊样式', async () => {
|
|
const wrapper = mount(CalendarCard, {
|
|
props: { date: mockDate, selected: false }
|
|
});
|
|
|
|
expect(wrapper.classes()).not.toContain('is-selected');
|
|
|
|
await wrapper.setProps({ selected: true });
|
|
expect(wrapper.classes()).toContain('is-selected');
|
|
});
|
|
});
|
|
```
|
|
|
|
**Step 3: 编写加载指示器组件测试**
|
|
|
|
```typescript
|
|
// src/__tests__/components/LoadingIndicator.test.ts
|
|
import { mount } from '@vue/test-utils';
|
|
import LoadingIndicator from '@/components/LoadingIndicator/index.vue';
|
|
|
|
describe('LoadingIndicator', () => {
|
|
it('默认应该显示加载动画', () => {
|
|
const wrapper = mount(LoadingIndicator);
|
|
expect(wrapper.find('.loading-spinner').exists()).toBe(true);
|
|
});
|
|
|
|
it('应该支持不同尺寸', () => {
|
|
const wrapper = mount(LoadingIndicator, {
|
|
props: { size: 'large' }
|
|
});
|
|
|
|
expect(wrapper.classes()).toContain('size-large');
|
|
});
|
|
|
|
it('应该支持自定义提示文字', () => {
|
|
const wrapper = mount(LoadingIndicator, {
|
|
props: { text: '加载中...' }
|
|
});
|
|
|
|
expect(wrapper.text()).toContain('加载中...');
|
|
});
|
|
|
|
it('隐藏时不应渲染', () => {
|
|
const wrapper = mount(LoadingIndicator, {
|
|
props: { visible: false }
|
|
});
|
|
|
|
expect(wrapper.find('.loading-indicator').exists()).toBe(false);
|
|
});
|
|
});
|
|
```
|
|
|
|
**Step 4: 运行所有组件测试**
|
|
|
|
Run: `cd everything-is-suitable-uniapp && npm run test`
|
|
|
|
Expected: PASS
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/__tests__/components/AlmanacCard.test.ts
|
|
git add src/__tests__/components/CalendarCard.test.ts
|
|
git add src/__tests__/components/LoadingIndicator.test.ts
|
|
git commit -m "test(uniapp): add comprehensive component tests for better coverage"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: 添加E2E测试
|
|
|
|
**Files:**
|
|
- Create: `e2e/search.spec.ts`
|
|
- Modify: `playwright.config.ts`
|
|
|
|
**Step 1: 编写搜索功能E2E测试**
|
|
|
|
```typescript
|
|
// e2e/search.spec.ts
|
|
import { test, expect } from '@playwright/test';
|
|
|
|
test.describe('搜索功能', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.goto('/');
|
|
});
|
|
|
|
test('首页应显示搜索入口', async ({ page }) => {
|
|
await expect(page.locator('.search-bar')).toBeVisible();
|
|
});
|
|
|
|
test('点击搜索入口应打开搜索面板', async ({ page }) => {
|
|
await page.click('.search-bar');
|
|
await expect(page.locator('.search-panel')).toBeVisible();
|
|
});
|
|
|
|
test('输入关键词并搜索应跳转到搜索结果页', async ({ page }) => {
|
|
await page.click('.search-bar');
|
|
await page.fill('[data-testid="search-input"]', '黄历');
|
|
await page.click('[data-testid="search-button"]');
|
|
|
|
await expect(page).toHaveURL(/almanac-search/);
|
|
await expect(page.locator('.result-list')).toBeVisible();
|
|
});
|
|
|
|
test('搜索历史应正确显示', async ({ page }) => {
|
|
await page.click('.search-bar');
|
|
await page.fill('[data-testid="search-input"]', '测试搜索');
|
|
await page.click('[data-testid="search-button"]');
|
|
|
|
// 返回首页再次打开搜索
|
|
await page.goBack();
|
|
await page.click('.search-bar');
|
|
|
|
await expect(page.locator('.history-tag').first()).toContainText('测试搜索');
|
|
});
|
|
|
|
test('空搜索应显示提示', async ({ page }) => {
|
|
await page.click('.search-bar');
|
|
await page.click('[data-testid="search-button"]');
|
|
|
|
await expect(page.locator('.search-hint')).toBeVisible();
|
|
});
|
|
});
|
|
```
|
|
|
|
**Step 2: 运行E2E测试**
|
|
|
|
Run: `cd everything-is-suitable-uniapp && npx playwright test search.spec.ts`
|
|
|
|
Expected: PASS
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add e2e/search.spec.ts
|
|
git commit -m "test(uniapp): add E2E tests for search functionality"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: 优化性能和用户体验
|
|
|
|
**Files:**
|
|
- Modify: `src/components/SearchConditionPanel/index.vue`
|
|
- Create: `src/composables/useDebounce.ts`
|
|
|
|
**Step 1: 添加防抖功能**
|
|
|
|
```typescript
|
|
// src/composables/useDebounce.ts
|
|
import { ref, watch } from 'vue';
|
|
|
|
export function useDebounce<T>(value: Ref<T>, delay: number = 300): Ref<T> {
|
|
const debouncedValue = ref(value.value) as Ref<T>;
|
|
let timeout: ReturnType<typeof setTimeout>;
|
|
|
|
watch(value, (newValue) => {
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(() => {
|
|
debouncedValue.value = newValue;
|
|
}, delay);
|
|
});
|
|
|
|
return debouncedValue;
|
|
}
|
|
```
|
|
|
|
**Step 2: 在搜索组件中应用防抖**
|
|
|
|
```vue
|
|
<!-- src/components/SearchConditionPanel/index.vue -->
|
|
<script setup lang="ts">
|
|
import { useDebounce } from '@/composables/useDebounce';
|
|
|
|
const searchKeyword = ref('');
|
|
const debouncedKeyword = useDebounce(searchKeyword, 300);
|
|
|
|
watch(debouncedKeyword, (value) => {
|
|
if (value.trim()) {
|
|
// 实时搜索建议
|
|
fetchSuggestions(value);
|
|
}
|
|
});
|
|
|
|
const fetchSuggestions = async (keyword: string) => {
|
|
// 获取搜索建议
|
|
};
|
|
</script>
|
|
```
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/composables/useDebounce.ts
|
|
git add src/components/SearchConditionPanel/index.vue
|
|
git commit -m "perf(uniapp): add debounce for search input to improve UX"
|
|
```
|
|
|
|
---
|
|
|
|
## 验收标准
|
|
|
|
- [ ] 首页搜索功能入口正常工作
|
|
- [ ] 搜索结果页面正确显示搜索结果
|
|
- [ ] 组件测试覆盖率 >= 80%
|
|
- [ ] E2E测试全部通过
|
|
- [ ] 搜索响应时间 < 500ms
|
|
- [ ] 用户操作流程符合设计规范
|
|
|
|
---
|
|
|
|
## 风险与依赖
|
|
|
|
| 风险 | 影响 | 缓解措施 |
|
|
|------|------|---------|
|
|
| 搜索服务依赖后端API | 高 | 确保API模块端点路径一致 |
|
|
| 跨平台兼容性问题 | 中 | 充分测试H5、小程序、App |
|
|
| 性能问题 | 低 | 添加防抖、缓存优化 |
|