feat(admin): 添加用户管理相关文件

添加用户管理视图、API和状态管理文件
This commit is contained in:
张翔
2026-03-28 14:37:29 +08:00
commit 08ea5fbe98
1643 changed files with 255646 additions and 0 deletions
@@ -0,0 +1,100 @@
<template>
<view class="empty-state">
<Icon :name="iconName" :size="iconSize" color="rgba(200, 200, 200, 255)" />
<Typography variant="h4" weight="semibold" class="empty-title">{{ title }}</Typography>
<Typography variant="body" class="empty-description">{{ description }}</Typography>
<Button v-if="showAction" type="primary" @click="handleAction">
{{ actionText }}
</Button>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Icon from '../Icon/Icon.vue'
import Typography from '../Typography/Typography.vue'
import Button from '../Button/Button.vue'
interface Props {
icon?: string
title?: string
description?: string
showAction?: boolean
actionText?: string
size?: 'small' | 'medium' | 'large'
}
const props = withDefaults(defineProps<Props>(), {
icon: 'empty',
title: '暂无数据',
description: '没有找到相关结果',
showAction: true,
actionText: '重新搜索',
size: 'medium'
})
const emit = defineEmits<{
action: []
}>()
const iconName = computed(() => props.icon || 'empty')
const iconSize = computed(() => {
switch (props.size) {
case 'small':
return '60rpx'
case 'large':
return '100rpx'
default:
return '80rpx'
}
})
const handleAction = () => {
emit('action')
}
</script>
<style scoped lang="scss">
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
gap: 16px;
}
.empty-title {
color: rgba(44, 24, 16, 255);
font-size: 18px;
line-height: 28px;
text-align: center;
}
.empty-description {
color: rgba(113, 113, 122, 255);
font-size: 14px;
line-height: 20px;
text-align: center;
max-width: 280px;
}
@media screen and (max-width: 375px) {
.empty-state {
padding: 48px 16px;
gap: 12px;
}
.empty-title {
font-size: 16px;
line-height: 26px;
}
.empty-description {
font-size: 13px;
line-height: 18px;
max-width: 240px;
}
}
</style>
@@ -0,0 +1,252 @@
<template>
<view class="export-panel">
<Button
type="primary"
size="small"
@click="handleExport"
>
<Icon name="download" size="16rpx" color="#FFFFFF" />
<Typography variant="body" color="#FFFFFF">导出结果</Typography>
</Button>
<view v-if="showExportModal" class="export-modal">
<view class="modal-content">
<view class="modal-header">
<Typography variant="h4" weight="semibold">导出搜索结果</Typography>
<Button size="small" type="text" @click="closeModal">
<Icon name="close" size="16rpx" color="rgba(113, 113, 122, 255)" />
</Button>
</view>
<view class="modal-body">
<view class="export-options">
<Typography variant="body" class="option-title">选择导出格式</Typography>
<view class="format-options">
<view
v-for="format in exportFormats"
:key="format.value"
class="format-option"
:class="{ 'format-option--selected': selectedFormat === format.value }"
@click="selectFormat(format.value)"
>
<Icon
:name="selectedFormat === format.value ? 'checkbox-checked' : 'checkbox-unchecked'"
size="20rpx"
:color="selectedFormat === format.value ? 'rgba(196, 30, 58, 255)' : 'rgba(113, 113, 122, 255)'"
/>
<Typography variant="body" class="format-label">{{ format.label }}</Typography>
</view>
</view>
</view>
<view class="export-options">
<Typography variant="body" class="option-title">选择导出内容</Typography>
<view class="content-options">
<view
v-for="option in contentOptions"
:key="option.value"
class="content-option"
:class="{ 'content-option--selected': selectedContent.includes(option.value) }"
@click="toggleContent(option.value)"
>
<Icon
:name="selectedContent.includes(option.value) ? 'checkbox-checked' : 'checkbox-unchecked'"
size="20rpx"
:color="selectedContent.includes(option.value) ? 'rgba(196, 30, 58, 255)' : 'rgba(113, 113, 122, 255)'"
/>
<Typography variant="body" class="content-label">{{ option.label }}</Typography>
</view>
</view>
</view>
</view>
<view class="modal-footer">
<Button type="text" @click="closeModal">取消</Button>
<Button type="primary" @click="confirmExport">导出</Button>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { SearchResult } from '../../types/search'
import Button from '../Button/Button.vue'
import Icon from '../Icon/Icon.vue'
import Typography from '../Typography/Typography.vue'
interface Props {
results: SearchResult[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
export: [format: string, content: string[]]
}>()
const showExportModal = ref(false)
const selectedFormat = ref('excel')
const selectedContent = ref(['date', 'lunarDate', 'matchedItems'])
const exportFormats = [
{ label: 'Excel (.xlsx)', value: 'excel' },
{ label: 'PDF (.pdf)', value: 'pdf' },
{ label: 'CSV (.csv)', value: 'csv' }
]
const contentOptions = [
{ label: '日期', value: 'date' },
{ label: '农历日期', value: 'lunarDate' },
{ label: '星期', value: 'weekday' },
{ label: '匹配事项', value: 'matchedItems' },
{ label: '匹配度', value: 'matchCount' }
]
function handleExport() {
showExportModal.value = true
}
function closeModal() {
showExportModal.value = false
}
function selectFormat(format: string) {
selectedFormat.value = format
}
function toggleContent(content: string) {
const index = selectedContent.value.indexOf(content)
if (index > -1) {
selectedContent.value.splice(index, 1)
} else {
selectedContent.value.push(content)
}
}
function confirmExport() {
emit('export', selectedFormat.value, selectedContent.value)
closeModal()
}
</script>
<style scoped lang="scss">
.export-panel {
position: relative;
}
.export-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
width: 90%;
max-width: 500px;
background: var(--color-background);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
padding: 24px;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.modal-body {
display: flex;
flex-direction: column;
gap: 20px;
margin-bottom: 24px;
}
.export-options {
display: flex;
flex-direction: column;
gap: 12px;
}
.option-title {
color: var(--color-text);
font-weight: 500;
}
.format-options {
display: flex;
flex-direction: column;
gap: 12px;
}
.format-option {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: rgba(244, 244, 245, 255);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
&:active {
background: rgba(230, 225, 220, 255);
}
}
.format-option--selected {
background: rgba(220, 252, 231, 1);
border: 1px solid rgba(185, 248, 207, 1);
}
.format-label {
color: var(--color-text);
}
.content-options {
display: flex;
flex-direction: column;
gap: 12px;
}
.content-option {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: rgba(244, 244, 245, 255);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
&:active {
background: rgba(230, 225, 220, 255);
}
}
.content-option--selected {
background: rgba(220, 252, 231, 1);
border: 1px solid rgba(185, 248, 207, 1);
}
.content-label {
color: var(--color-text);
}
.modal-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
</style>
@@ -0,0 +1,72 @@
<template>
<view class="loading-indicator">
<view class="loading-spinner"></view>
<Typography v-if="text" variant="caption" class="loading-text">{{ text }}</Typography>
</view>
</template>
<script setup lang="ts">
import Typography from '../Typography/Typography.vue'
interface Props {
text?: string
}
withDefaults(defineProps<Props>(), {
text: ''
})
</script>
<style scoped lang="scss">
.loading-indicator {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
gap: 16px;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(230, 225, 220, 255);
border-top: 4px solid rgba(196, 30, 58, 255);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.loading-text {
color: rgba(113, 113, 122, 255);
font-size: 14px;
line-height: 20px;
text-align: center;
}
@media screen and (max-width: 375px) {
.loading-indicator {
padding: 32px 16px;
gap: 12px;
}
.loading-spinner {
width: 36px;
height: 36px;
border-width: 3px;
}
.loading-text {
font-size: 13px;
line-height: 18px;
}
}
</style>
@@ -0,0 +1,241 @@
<template>
<Card class="search-condition-item">
<view class="condition-header">
<Select
v-model="localCondition.type"
:options="typeOptions"
@change="handleTypeChange"
/>
<Button size="small" type="text" @click="handleDelete">
<Icon name="close" size="16rpx" color="rgba(196, 30, 58, 255)" />
</Button>
</view>
<view class="condition-body">
<view class="condition-section">
<Typography variant="caption" class="section-label">事项</Typography>
<view class="items-selector">
<view
v-for="item in availableItems"
:key="item"
class="item-tag"
:class="{ 'item-tag-selected': localCondition.items.includes(item) }"
@click="toggleItem(item)"
>
<Typography variant="caption" class="item-text">{{ item }}</Typography>
</view>
</view>
</view>
<view class="condition-section">
<Typography variant="caption" class="section-label">逻辑运算符</Typography>
<Select
v-model="localCondition.operator"
:options="operatorOptions"
@change="handleOperatorChange"
/>
</view>
<view class="condition-section">
<view class="exclude-toggle" @click="toggleExclude">
<Icon
:name="localCondition.exclude ? 'checkbox-checked' : 'checkbox-unchecked'"
size="20rpx"
:color="localCondition.exclude ? 'rgba(196, 30, 58, 255)' : 'rgba(113, 113, 122, 255)'"
/>
<Typography variant="body" class="exclude-label">排除条件</Typography>
</view>
</view>
</view>
</Card>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { SearchCondition } from '../../types/search'
import Card from '../Card/Card.vue'
import Button from '../Button/Button.vue'
import Icon from '../Icon/Icon.vue'
import Typography from '../Typography/Typography.vue'
import Select from '../Select/Select.vue'
interface Props {
condition: SearchCondition
}
const props = defineProps<Props>()
const emit = defineEmits<{
update: [condition: SearchCondition]
delete: []
}>()
const localCondition = ref<SearchCondition>({ ...props.condition })
const typeOptions = [
{ label: '宜', value: 'suitable' },
{ label: '忌', value: 'unsuitable' }
]
const operatorOptions = [
{ label: '与(AND', value: 'and' },
{ label: '或(OR', value: 'or' }
]
const suitableActivities = [
'祭祀', '祈福', '求嗣', '开光', '出行', '嫁娶', '订盟', '纳采', '裁衣', '安床',
'修造', '动土', '移徙', '入宅', '开市', '交易', '立券', '挂匾', '纳财', '开仓',
'出货财', '安机械', '会亲友', '进人口', '经络', '安葬', '破土', '谢土', '入殓', '移柩',
'治病', '针灸', '服药', '伐木', '作梁', '修坟', '造畜稠', '教牛马', '牧养', '纳畜',
'捕捉', '畋猎', '取鱼', '造船', '造桥', '开渠', '穿井', '作灶', '作厕', '安香'
]
const avoidActivities = [
'嫁娶', '开市', '动土', '安床', '破土', '安葬', '开仓', '出货财', '造船', '伐木',
'作梁', '修造', '移徙', '入宅', '出行', '交易', '立券', '纳采', '订盟', '祭祀',
'祈福', '求嗣', '开光', '裁衣', '安机械', '会亲友', '进人口', '经络', '入殓', '移柩',
'治病', '针灸', '服药', '修坟', '造畜稠', '教牛马', '牧养', '纳畜', '捕捉', '畋猎',
'取鱼', '开渠', '穿井', '作灶', '作厕', '安香', '挂匾', '纳财', '开仓'
]
const availableItems = computed(() => {
return localCondition.value.type === 'suitable' ? suitableActivities : avoidActivities
})
const toggleItem = (item: string) => {
const index = localCondition.value.items.indexOf(item)
if (index > -1) {
localCondition.value.items.splice(index, 1)
} else {
localCondition.value.items.push(item)
}
emit('update', { ...localCondition.value })
}
const handleTypeChange = () => {
localCondition.value.items = []
emit('update', { ...localCondition.value })
}
const handleOperatorChange = () => {
emit('update', { ...localCondition.value })
}
const toggleExclude = () => {
localCondition.value.exclude = !localCondition.value.exclude
emit('update', { ...localCondition.value })
}
const handleDelete = () => {
emit('delete')
}
watch(() => props.condition, (newCondition) => {
localCondition.value = { ...newCondition }
}, { deep: true })
</script>
<style scoped lang="scss">
.search-condition-item {
margin-bottom: 16px;
}
.condition-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.condition-body {
display: flex;
flex-direction: column;
gap: 16px;
}
.condition-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.section-label {
color: rgba(113, 113, 122, 255);
font-size: 12px;
line-height: 16px;
font-weight: 400;
}
.items-selector {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.item-tag {
padding: 6px 12px;
background-color: rgba(244, 244, 245, 255);
border: 1px solid rgba(230, 225, 220, 255);
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
&:active {
background-color: rgba(230, 225, 220, 255);
}
}
.item-tag-selected {
background-color: rgba(220, 252, 231, 1);
border-color: rgba(185, 248, 207, 1);
}
.item-text {
color: rgba(44, 24, 16, 255);
font-size: 13px;
line-height: 18px;
}
.exclude-toggle {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
cursor: pointer;
transition: all 0.3s ease;
&:active {
opacity: 0.7;
}
}
.exclude-label {
color: rgba(44, 24, 16, 255);
font-size: 14px;
line-height: 20px;
font-weight: 400;
}
@media screen and (max-width: 375px) {
.condition-body {
gap: 12px;
}
.condition-section {
gap: 6px;
}
.items-selector {
gap: 6px;
}
.item-tag {
padding: 5px 10px;
}
.item-text {
font-size: 12px;
line-height: 16px;
}
}
</style>
@@ -0,0 +1,176 @@
<template>
<Card class="search-history-panel">
<view class="panel-header">
<Typography variant="h4" weight="semibold">搜索历史</Typography>
<Button size="small" type="text" @click="handleClearHistory">
<Typography variant="caption" color="rgba(196, 30, 58, 255)">清除历史</Typography>
</Button>
</view>
<view v-if="history.length === 0" class="empty-history">
<EmptyState
icon="empty"
title="暂无搜索历史"
description="执行搜索后,搜索条件将显示在这里"
size="small"
/>
</view>
<view v-else class="history-list">
<view
v-for="item in history"
:key="item.id"
class="history-item"
@click="handleSelectHistory(item)"
>
<view class="history-content">
<Typography variant="body" class="history-title">
{{ formatHistoryTitle(item.condition) }}
</Typography>
<Typography variant="caption" class="history-time">
{{ formatTime(item.createdAt) }}
</Typography>
</view>
<Icon name="right" size="16rpx" color="rgba(113, 113, 122, 255)" />
</view>
</view>
</Card>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import type { SearchRequest } from '../../types/search'
import searchService from '../../services/searchService'
import Card from '../Card/Card.vue'
import Button from '../Button/Button.vue'
import Typography from '../Typography/Typography.vue'
import Icon from '../Icon/Icon.vue'
import EmptyState from '../EmptyState/index.vue'
interface HistoryItem {
id: string
condition: SearchRequest
createdAt: string
}
const emit = defineEmits<{
select: [condition: SearchRequest]
}>()
const history = ref<HistoryItem[]>([])
onMounted(() => {
loadHistory()
})
function loadHistory() {
history.value = searchService.getSearchHistory()
}
function handleSelectHistory(item: HistoryItem) {
emit('select', item.condition)
}
function handleClearHistory() {
uni.showModal({
title: '确认清除',
content: '确定要清除所有搜索历史吗?',
success: (res) => {
if (res.confirm) {
searchService.clearSearchHistory()
history.value = []
}
}
})
}
function formatHistoryTitle(condition: SearchRequest): string {
const conditionCount = condition.conditions.length
const days = condition.days
let title = `${conditionCount}个条件,${days}天范围`
if (conditionCount > 0) {
const firstCondition = condition.conditions[0]
const type = firstCondition.type === 'suitable' ? '宜' : '忌'
const items = firstCondition.items.slice(0, 2).join('、')
title += `${type}${items}${firstCondition.items.length > 2 ? '...' : ''}`
}
return title
}
function formatTime(timeStr: string): string {
const date = new Date(timeStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
if (diffHours < 1) {
return '刚刚'
} else if (diffHours < 24) {
return `${diffHours}小时前`
} else {
const diffDays = Math.floor(diffHours / 24)
if (diffDays < 7) {
return `${diffDays}天前`
} else {
return date.toLocaleDateString()
}
}
}
</script>
<style scoped lang="scss">
.search-history-panel {
margin-bottom: 16px;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.empty-history {
padding: 24px 0;
}
.history-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.history-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: rgba(244, 244, 245, 255);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
&:active {
background: rgba(230, 225, 220, 255);
}
}
.history-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.history-title {
color: var(--color-text);
}
.history-time {
color: var(--color-text-secondary);
}
</style>
@@ -0,0 +1,181 @@
<template>
<Card class="search-result-card">
<view class="card-header">
<Typography variant="h4" weight="semibold" class="date">{{ formatDate(result.date) }}</Typography>
<Typography variant="body" class="weekday">{{ result.weekday }}</Typography>
</view>
<view class="card-body">
<Typography variant="caption" class="lunar-date">{{ result.lunarDate }}</Typography>
<view v-if="result.matchedItems.suitable.length > 0" class="matched-items">
<Typography variant="caption" class="label suitable-label"></Typography>
<Typography variant="body" class="items suitable-items">{{ result.matchedItems.suitable.join('、') }}</Typography>
</view>
<view v-if="result.matchedItems.unsuitable.length > 0" class="matched-items">
<Typography variant="caption" class="label unsuitable-label"></Typography>
<Typography variant="body" class="items unsuitable-items">{{ result.matchedItems.unsuitable.join('、') }}</Typography>
</view>
<Typography variant="caption" class="match-count">匹配度: {{ result.matchCount }}</Typography>
</view>
</Card>
</template>
<script setup lang="ts">
import type { SearchResult } from '../../types/search'
import Card from '../Card/Card.vue'
import Typography from '../Typography/Typography.vue'
interface Props {
result: SearchResult
}
defineProps<Props>()
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}${month}${day}`
}
</script>
<style scoped lang="scss">
.search-result-card {
margin-bottom: 16px;
transition: all 0.3s ease;
&:active {
transform: scale(0.98);
}
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(230, 225, 220, 255);
}
.date {
color: rgba(44, 24, 16, 255);
font-size: 16px;
line-height: 24px;
font-weight: 600;
}
.weekday {
color: rgba(113, 113, 122, 255);
font-size: 14px;
line-height: 20px;
}
.card-body {
display: flex;
flex-direction: column;
gap: 8px;
}
.lunar-date {
color: rgba(113, 113, 122, 255);
font-size: 13px;
line-height: 18px;
margin-bottom: 4px;
}
.matched-items {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
background-color: rgba(244, 244, 245, 255);
border-radius: 8px;
}
.label {
font-size: 12px;
line-height: 16px;
font-weight: 600;
margin-bottom: 4px;
}
.suitable-label {
color: rgba(0, 130, 54, 1);
}
.unsuitable-label {
color: rgba(196, 30, 58, 255);
}
.items {
font-size: 14px;
line-height: 20px;
font-weight: 400;
}
.suitable-items {
color: rgba(1, 102, 48, 1);
}
.unsuitable-items {
color: rgba(196, 30, 58, 255);
}
.match-count {
color: rgba(113, 113, 122, 255);
font-size: 12px;
line-height: 16px;
text-align: right;
margin-top: 4px;
}
@media screen and (max-width: 375px) {
.card-header {
margin-bottom: 10px;
padding-bottom: 10px;
}
.date {
font-size: 15px;
line-height: 22px;
}
.weekday {
font-size: 13px;
line-height: 18px;
}
.card-body {
gap: 6px;
}
.lunar-date {
font-size: 12px;
line-height: 16px;
}
.matched-items {
padding: 6px;
}
.label {
font-size: 11px;
line-height: 15px;
}
.items {
font-size: 13px;
line-height: 18px;
}
.match-count {
font-size: 11px;
line-height: 15px;
}
}
</style>
@@ -0,0 +1,133 @@
<template>
<view class="search-result-list">
<view class="list-header">
<Typography variant="h4" weight="semibold">搜索结果</Typography>
<Typography variant="caption" class="result-count">{{ results.length }}条结果</Typography>
<SortSwitcher
:sortBy="sortBy"
:sortOrder="sortOrder"
@update:sortBy="handleSortByChange"
@update:sortOrder="handleSortOrderChange"
/>
</view>
<scroll-view
class="list-scroll"
scroll-y
@scrolltolower="handleScrollToLower"
>
<SearchResultCard
v-for="result in results"
:key="result.date"
:result="result"
/>
<view v-if="loadingMore" class="loading-more">
<LoadingIndicator text="加载中..." />
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { SearchResult } from '../../types/search'
import SearchResultCard from '../SearchResultCard/index.vue'
import SortSwitcher from '../SortSwitcher/index.vue'
import LoadingIndicator from '../LoadingIndicator/index.vue'
import Typography from '../Typography/Typography.vue'
interface Props {
results: SearchResult[]
sortBy: 'date' | 'matchCount'
sortOrder: 'asc' | 'desc'
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:sortBy': [value: 'date' | 'matchCount']
'update:sortOrder': [value: 'asc' | 'desc']
loadMore: []
}>()
const loadingMore = ref(false)
const handleSortByChange = (value: 'date' | 'matchCount') => {
emit('update:sortBy', value)
}
const handleSortOrderChange = (value: 'asc' | 'desc') => {
emit('update:sortOrder', value)
}
const handleScrollToLower = () => {
if (!loadingMore.value) {
loadingMore.value = true
setTimeout(() => {
emit('loadMore')
loadingMore.value = false
}, 300)
}
}
</script>
<style scoped lang="scss">
.search-result-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.list-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(230, 225, 220, 255);
}
.result-count {
color: rgba(113, 113, 122, 255);
font-size: 12px;
line-height: 16px;
}
.list-scroll {
max-height: 600px;
overflow-y: auto;
}
.loading-more {
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
@media screen and (max-width: 375px) {
.search-result-list {
gap: 12px;
}
.list-header {
gap: 6px;
padding-bottom: 10px;
}
.result-count {
font-size: 11px;
line-height: 15px;
}
.list-scroll {
max-height: 500px;
}
.loading-more {
padding: 16px;
}
}
</style>
@@ -0,0 +1,219 @@
<template>
<view
:class="[
'select',
{
'select--disabled': disabled,
'select--open': isOpen,
[`select--${size}`]: size
}
]"
@click="handleToggle"
>
<view class="select-trigger">
<Typography
v-if="selectedOption"
:variant="size === 'small' ? 'caption' : 'body'"
class="select-value"
>
{{ selectedOption.label }}
</Typography>
<Typography
v-else
:variant="size === 'small' ? 'caption' : 'body'"
class="select-placeholder"
>
{{ placeholder }}
</Typography>
<Icon
:name="isOpen ? 'arrow-up' : 'arrow-down'"
:size="size === 'small' ? '16rpx' : '20rpx'"
color="rgba(113, 113, 122, 255)"
class="select-arrow"
/>
</view>
<view v-if="isOpen" class="select-dropdown">
<view
v-for="option in options"
:key="option.value"
:class="[
'select-option',
{
'select-option--selected': value === option.value
}
]"
@click.stop="handleSelect(option)"
>
<Typography
:variant="size === 'small' ? 'caption' : 'body'"
class="select-option-label"
>
{{ option.label }}
</Typography>
<Icon
v-if="value === option.value"
name="check"
:size="size === 'small' ? '16rpx' : '20rpx'"
color="rgba(196, 30, 58, 255)"
class="select-option-check"
/>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import Typography from '@/components/Typography/Typography.vue'
import Icon from '@/components/Icon/Icon.vue'
interface Option {
label: string
value: string | number
}
interface Props {
modelValue: string | number
options: Option[]
placeholder?: string
disabled?: boolean
size?: 'small' | 'medium' | 'large'
}
const props = withDefaults(defineProps<Props>(), {
placeholder: '请选择',
disabled: false,
size: 'medium'
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string | number): void
(e: 'change', value: string | number): void
}>()
const isOpen = ref(false)
const selectedOption = computed(() => {
return props.options.find(option => option.value === props.modelValue)
})
function handleToggle() {
if (!props.disabled) {
isOpen.value = !isOpen.value
}
}
function handleSelect(option: Option) {
emit('update:modelValue', option.value)
emit('change', option.value)
isOpen.value = false
}
function closeDropdown() {
isOpen.value = false
}
defineExpose({
closeDropdown
})
</script>
<style scoped lang="scss">
.select {
position: relative;
width: 100%;
cursor: pointer;
&.select--disabled {
cursor: not-allowed;
opacity: 0.5;
}
&--small {
min-height: 64rpx;
}
&--medium {
min-height: 80rpx;
}
&--large {
min-height: 96rpx;
}
}
.select-trigger {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 24rpx;
background: var(--color-background);
border: 2rpx solid var(--color-border);
border-radius: var(--radius-md);
transition: all var(--transition-fast);
.select--open & {
border-color: var(--color-primary);
}
.select--disabled & {
background: var(--color-disabled);
}
}
.select-value {
flex: 1;
color: var(--color-text);
}
.select-placeholder {
flex: 1;
color: var(--color-text-secondary);
}
.select-arrow {
flex-shrink: 0;
margin-left: 16rpx;
}
.select-dropdown {
position: absolute;
top: calc(100% + 8rpx);
left: 0;
right: 0;
z-index: 100;
max-height: 400rpx;
overflow-y: auto;
background: var(--color-background);
border: 2rpx solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
}
.select-option {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 24rpx;
transition: all var(--transition-fast);
&:hover {
background: var(--color-hover);
}
&--selected {
background: var(--color-primary-light);
}
}
.select-option-label {
flex: 1;
color: var(--color-text);
}
.select-option-check {
flex-shrink: 0;
margin-left: 16rpx;
}
</style>
@@ -0,0 +1,133 @@
<template>
<view class="sort-switcher">
<Typography variant="caption" class="sort-label">排序</Typography>
<view class="sort-options">
<view
class="sort-option"
:class="{ 'sort-option-active': sortBy === 'date' }"
@click="handleSortByChange('date')"
>
<Typography variant="caption" class="sort-option-text">日期</Typography>
<Icon
v-if="sortBy === 'date'"
:name="sortOrder === 'asc' ? 'arrow-up' : 'arrow-down'"
size="14rpx"
color="rgba(196, 30, 58, 255)"
/>
</view>
<view
class="sort-option"
:class="{ 'sort-option-active': sortBy === 'matchCount' }"
@click="handleSortByChange('matchCount')"
>
<Typography variant="caption" class="sort-option-text">匹配度</Typography>
<Icon
v-if="sortBy === 'matchCount'"
:name="sortOrder === 'asc' ? 'arrow-up' : 'arrow-down'"
size="14rpx"
color="rgba(196, 30, 58, 255)"
/>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import Icon from '../Icon/Icon.vue'
import Typography from '../Typography/Typography.vue'
interface Props {
sortBy: 'date' | 'matchCount'
sortOrder: 'asc' | 'desc'
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:sortBy': [value: 'date' | 'matchCount']
'update:sortOrder': [value: 'asc' | 'desc']
}>()
const handleSortByChange = (value: 'date' | 'matchCount') => {
if (props.sortBy === value) {
const newSortOrder = props.sortOrder === 'asc' ? 'desc' : 'asc'
emit('update:sortOrder', newSortOrder)
} else {
emit('update:sortBy', value)
}
}
</script>
<style scoped lang="scss">
.sort-switcher {
display: flex;
align-items: center;
gap: 8px;
}
.sort-label {
color: rgba(113, 113, 122, 255);
font-size: 12px;
line-height: 16px;
font-weight: 400;
}
.sort-options {
display: flex;
gap: 4px;
}
.sort-option {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
background-color: rgba(244, 244, 245, 255);
border: 1px solid rgba(230, 225, 220, 255);
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
&:active {
background-color: rgba(230, 225, 220, 255);
}
}
.sort-option-active {
background-color: rgba(220, 252, 231, 1);
border-color: rgba(185, 248, 207, 1);
}
.sort-option-text {
color: rgba(44, 24, 16, 255);
font-size: 12px;
line-height: 16px;
font-weight: 400;
}
@media screen and (max-width: 375px) {
.sort-switcher {
gap: 6px;
}
.sort-label {
font-size: 11px;
line-height: 15px;
}
.sort-options {
gap: 3px;
}
.sort-option {
padding: 5px 10px;
}
.sort-option-text {
font-size: 11px;
line-height: 15px;
}
}
</style>
@@ -0,0 +1,238 @@
<template>
<Card class="template-panel">
<view class="panel-header">
<Typography variant="h4" weight="semibold">搜索模板</Typography>
<Button size="small" type="text" @click="handleRefresh">
<Icon name="refresh" size="16rpx" color="rgba(113, 113, 122, 255)" />
</Button>
</view>
<view class="search-bar">
<view class="search-input">
<Icon name="search" size="16rpx" color="rgba(113, 113, 122, 255)" />
<input
v-model="searchKeyword"
placeholder="搜索模板"
class="input-field"
/>
</view>
</view>
<view class="category-tabs">
<view
v-for="category in categories"
:key="category"
class="category-tab"
:class="{ 'category-tab--active': selectedCategory === category }"
@click="selectCategory(category)"
>
<Typography variant="body" class="category-label">{{ category }}</Typography>
</view>
</view>
<view v-if="filteredTemplates.length === 0" class="empty-templates">
<EmptyState
icon="empty"
title="暂无模板"
description="没有找到符合条件的模板"
size="small"
/>
</view>
<view v-else class="template-list">
<view
v-for="template in filteredTemplates"
:key="template.id"
class="template-item"
@click="handleSelectTemplate(template)"
>
<view class="template-content">
<Typography variant="body" weight="semibold" class="template-name">
{{ template.name }}
</Typography>
<Typography variant="caption" class="template-description">
{{ template.description }}
</Typography>
<Typography variant="caption" class="template-category">
{{ template.category }}
</Typography>
</view>
<Icon name="right" size="16rpx" color="rgba(113, 113, 122, 255)" />
</view>
</view>
</Card>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { SearchRequest, SearchTemplate } from '../../types/search'
import templateService from '../../services/templateService'
import Card from '../Card/Card.vue'
import Button from '../Button/Button.vue'
import Icon from '../Icon/Icon.vue'
import Typography from '../Typography/Typography.vue'
import EmptyState from '../EmptyState/index.vue'
const emit = defineEmits<{
select: [condition: SearchRequest]
}>()
const searchKeyword = ref('')
const selectedCategory = ref('全部')
const templates = ref<SearchTemplate[]>([])
const categories = computed(() => {
const allCategories = templateService.getCategories()
return ['全部', ...allCategories]
})
const filteredTemplates = computed(() => {
let result = templates.value
if (selectedCategory.value !== '全部') {
result = templateService.getTemplatesByCategory(selectedCategory.value)
}
if (searchKeyword.value.trim()) {
result = templateService.searchTemplates(searchKeyword.value.trim())
}
return result
})
onMounted(() => {
loadTemplates()
})
function loadTemplates() {
templates.value = templateService.getTemplates()
}
function handleRefresh() {
loadTemplates()
}
function selectCategory(category: string) {
selectedCategory.value = category
}
function handleSelectTemplate(template: SearchTemplate) {
emit('select', template.condition)
}
</script>
<style scoped lang="scss">
.template-panel {
margin-bottom: 16px;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.search-bar {
margin-bottom: 16px;
}
.search-input {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: rgba(244, 244, 245, 255);
border-radius: var(--radius-md);
}
.input-field {
flex: 1;
border: none;
background: transparent;
font-size: 14px;
color: var(--color-text);
outline: none;
&::placeholder {
color: var(--color-text-secondary);
}
}
.category-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.category-tab {
padding: 6px 12px;
background: rgba(244, 244, 245, 255);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
&:active {
background: rgba(230, 225, 220, 255);
}
}
.category-tab--active {
background: var(--color-primary);
}
.category-label {
color: var(--color-text);
font-size: 13px;
.category-tab--active & {
color: #FFFFFF;
}
}
.empty-templates {
padding: 24px 0;
}
.template-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.template-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
background: rgba(244, 244, 245, 255);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
&:active {
background: rgba(230, 225, 220, 255);
}
}
.template-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.template-name {
color: var(--color-text);
}
.template-description {
color: var(--color-text-secondary);
}
.template-category {
color: var(--color-primary);
font-weight: 500;
}
</style>
@@ -0,0 +1,180 @@
<template>
<view
class="touchable-container"
:class="{ 'is-active': isActive, 'is-disabled': disabled }"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
@touchcancel="handleTouchCancel"
@click="handleClick"
@longpress="handleLongPress"
>
<slot></slot>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
interface Props {
disabled?: boolean
activeOpacity?: number
activeScale?: number
longPressDelay?: number
enableHapticFeedback?: boolean
enableRipple?: boolean
}
interface Emits {
(e: 'click', event: any): void
(e: 'longpress', event: any): void
(e: 'touchstart', event: any): void
(e: 'touchend', event: any): void
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
activeOpacity: 0.7,
activeScale: 0.95,
longPressDelay: 500,
enableHapticFeedback: true,
enableRipple: true
})
const emit = defineEmits<Emits>()
const isActive = ref(false)
const touchStartTime = ref(0)
const longPressTimer = ref<NodeJS.Timeout | null>(null)
const touchStartPos = ref({ x: 0, y: 0 })
const handleTouchStart = (event: any) => {
if (props.disabled) return
touchStartTime.value = Date.now()
touchStartPos.value = {
x: event.touches[0].clientX,
y: event.touches[0].clientY
}
isActive.value = true
if (props.enableHapticFeedback) {
uni.vibrateShort({
type: 'light'
})
}
longPressTimer.value = setTimeout(() => {
handleLongPress(event)
}, props.longPressDelay)
emit('touchstart', event)
}
const handleTouchMove = (event: any) => {
if (props.disabled) return
const moveThreshold = 10
const deltaX = Math.abs(event.touches[0].clientX - touchStartPos.value.x)
const deltaY = Math.abs(event.touches[0].clientY - touchStartPos.value.y)
if (deltaX > moveThreshold || deltaY > moveThreshold) {
if (longPressTimer.value) {
clearTimeout(longPressTimer.value)
longPressTimer.value = null
}
}
}
const handleTouchEnd = (event: any) => {
if (props.disabled) return
if (longPressTimer.value) {
clearTimeout(longPressTimer.value)
longPressTimer.value = null
}
const touchDuration = Date.now() - touchStartTime.value
if (touchDuration < props.longPressDelay) {
handleClick(event)
}
isActive.value = false
emit('touchend', event)
}
const handleTouchCancel = () => {
if (longPressTimer.value) {
clearTimeout(longPressTimer.value)
longPressTimer.value = null
}
isActive.value = false
}
const handleClick = (event: any) => {
if (props.disabled) return
if (props.enableHapticFeedback) {
uni.vibrateShort({
type: 'light'
})
}
emit('click', event)
}
const handleLongPress = (event: any) => {
if (props.disabled) return
if (props.enableHapticFeedback) {
uni.vibrateShort({
type: 'heavy'
})
}
emit('longpress', event)
}
</script>
<style scoped lang="scss">
.touchable-container {
position: relative;
overflow: hidden;
transition: opacity 0.15s ease, transform 0.15s ease;
user-select: none;
-webkit-tap-highlight-color: transparent;
&.is-active {
opacity: v-bind(activeOpacity);
transform: scale(v-bind(activeScale));
}
&.is-disabled {
opacity: 0.5;
pointer-events: none;
}
}
.touchable-container::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 100%;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
transform: translate(-50%, -50%) scale(0);
opacity: 0;
pointer-events: none;
transition: transform 0.3s ease, opacity 0.3s ease;
}
.touchable-container.is-active::after {
transform: translate(-50%, -50%) scale(1.5);
opacity: 1;
}
</style>
@@ -0,0 +1,271 @@
<template>
<view class="page">
<view class="page-header">
<Typography variant="h3" weight="bold" class="page-title">黄历搜索</Typography>
</view>
<view class="page-content">
<TemplatePanel @select="selectTemplate" />
<SearchConditionPanel
:conditions="searchRequest.conditions"
:days="searchRequest.days"
@update:conditions="updateConditions"
@update:days="updateDays"
@search="performSearch"
/>
<view v-if="loading" class="loading-container">
<LoadingIndicator text="搜索中..." />
</view>
<view v-else-if="error" class="error-container">
<Typography variant="body" class="error-text">{{ error }}</Typography>
<Button @click="performSearch">重试</Button>
</view>
<SearchResultList
v-else-if="searchResults.length > 0"
:results="searchResults"
:sortBy="searchRequest.sortBy"
:sortOrder="searchRequest.sortOrder"
@update:sortBy="updateSortBy"
@update:sortOrder="updateSortOrder"
@loadMore="loadMoreResults"
/>
<EmptyState
v-else
:showAction="hasSearched"
actionText="重新搜索"
@action="performSearch"
/>
</view>
<BottomNavigation currentTab="almanac" />
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import type { SearchCondition, SearchRequest, SearchResult } from '../../types/search'
import BottomNavigation from '../../components/BottomNavigation/BottomNavigation.vue'
import TemplatePanel from '../../components/TemplatePanel/index.vue'
import SearchConditionPanel from '../../components/SearchConditionPanel/index.vue'
import SearchResultList from '../../components/SearchResultList/index.vue'
import LoadingIndicator from '../../components/LoadingIndicator/index.vue'
import EmptyState from '../../components/EmptyState/index.vue'
import Typography from '../../components/Typography/Typography.vue'
import Button from '../../components/Button/Button.vue'
import searchService from '../../services/searchService'
const loading = ref(false)
const error = ref('')
const hasSearched = ref(false)
const searchResults = ref<SearchResult[]>([])
const searchMode = ref<'keyword' | 'advanced'>('advanced')
const searchRequest = ref<SearchRequest>({
conditions: [],
days: 30,
sortBy: 'date',
sortOrder: 'asc'
})
onMounted(() => {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1] as any
const options = currentPage?.options || {}
if (options.keyword) {
searchMode.value = 'keyword'
handleKeywordSearch(decodeURIComponent(options.keyword))
} else if (options.conditions) {
try {
searchRequest.value.conditions = JSON.parse(decodeURIComponent(options.conditions))
if (options.days) {
searchRequest.value.days = parseInt(options.days)
}
performSearch()
} catch (err) {
console.error('解析搜索条件失败:', err)
}
}
})
const handleKeywordSearch = async (keyword: string) => {
if (!keyword.trim()) {
error.value = '请输入搜索关键词'
return
}
loading.value = true
error.value = ''
hasSearched.value = true
try {
const results = await searchService.searchByKeyword(keyword)
searchResults.value = results
if (results.length === 0) {
error.value = '没有找到符合条件的结果'
}
} catch (err: any) {
console.error('搜索失败:', err)
error.value = '搜索失败,请稍后重试'
} finally {
loading.value = false
}
}
const updateConditions = (conditions: SearchCondition[]) => {
searchRequest.value.conditions = conditions
}
const updateDays = (days: number) => {
searchRequest.value.days = days
}
const updateSortBy = (sortBy: 'date' | 'matchCount') => {
searchRequest.value.sortBy = sortBy
sortResults()
}
const updateSortOrder = (sortOrder: 'asc' | 'desc') => {
searchRequest.value.sortOrder = sortOrder
sortResults()
}
const sortResults = () => {
const sorted = [...searchResults.value].sort((a, b) => {
let comparison = 0
if (searchRequest.value.sortBy === 'date') {
comparison = new Date(a.date).getTime() - new Date(b.date).getTime()
} else if (searchRequest.value.sortBy === 'matchCount') {
comparison = b.matchCount - a.matchCount
}
return searchRequest.value.sortOrder === 'desc' ? -comparison : comparison
})
searchResults.value = sorted
}
const performSearch = async () => {
if (searchRequest.value.conditions.length === 0) {
error.value = '请至少添加一个搜索条件'
return
}
loading.value = true
error.value = ''
hasSearched.value = true
try {
const results = await searchService.search(searchRequest.value)
searchResults.value = results
if (results.length === 0) {
error.value = '没有找到符合条件的结果'
}
} catch (err: any) {
console.error('搜索失败:', err)
error.value = '搜索失败,请稍后重试'
} finally {
loading.value = false
}
}
const loadMoreResults = () => {
console.log('加载更多结果')
}
const selectTemplate = (condition: SearchRequest) => {
searchRequest.value = { ...condition }
performSearch()
}
</script>
<style scoped lang="scss">
.page {
min-height: 100vh;
background-color: rgba(244, 244, 245, 0.3);
padding-bottom: 80px;
}
.page-header {
background-color: rgba(255, 255, 255, 255);
padding: 16px;
display: flex;
justify-content: center;
align-items: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
position: sticky;
top: 0;
z-index: 10;
}
.page-title {
color: rgba(44, 24, 16, 255);
font-size: 18px;
line-height: 28px;
font-weight: 700;
}
.page-content {
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.loading-container {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 20px;
}
.error-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
gap: 16px;
}
.error-text {
color: rgba(196, 30, 58, 255);
font-size: 14px;
line-height: 20px;
text-align: center;
}
@media screen and (max-width: 375px) {
.page-content {
padding: 12px;
gap: 12px;
}
.page-header {
padding: 14px;
}
.page-title {
font-size: 16px;
line-height: 26px;
}
.loading-container,
.error-container {
padding: 32px 16px;
}
.error-text {
font-size: 13px;
line-height: 18px;
}
}
</style>
@@ -0,0 +1,58 @@
export interface SearchCondition {
id: string
type: 'suitable' | 'unsuitable'
items: string[]
operator: 'and' | 'or'
exclude?: boolean
}
export interface SearchRequest {
conditions: SearchCondition[]
days: number
sortBy: 'date' | 'matchCount'
sortOrder: 'asc' | 'desc'
}
export interface SearchResult {
date: string
lunarDate: string
weekday: string
matchedItems: {
suitable: string[]
unsuitable: string[]
}
matchCount: number
almanacData: any
}
export interface SavedSearchCondition {
id: string
name: string
condition: SearchRequest
createdAt: string
}
export interface SearchService {
search(request: SearchRequest): Promise<SearchResult[]>
saveSearchCondition(name: string, condition: SearchRequest): void
getSavedSearchConditions(): SavedSearchCondition[]
deleteSearchCondition(id: string): void
loadSearchCondition(condition: SearchRequest): void
saveSearchHistory(request: SearchRequest): void
getSearchHistory(): any[]
clearSearchHistory(): void
}
export interface SearchHistoryItem {
id: string
condition: SearchRequest
createdAt: string
}
export interface SearchTemplate {
id: string
name: string
description: string
category: string
condition: SearchRequest
}
@@ -0,0 +1,63 @@
import type { ApiError, ErrorType } from './httpClient'
class ErrorHandler {
private errorMessages: Record<ErrorType, string> = {
NETWORK_ERROR: '网络连接失败,请检查网络设置',
TIMEOUT_ERROR: '请求超时,请稍后重试',
BUSINESS_ERROR: '操作失败,请稍后重试',
AUTH_ERROR: '认证失败,请重新登录',
TOKEN_EXPIRED: '登录已过期,请重新登录',
UNKNOWN_ERROR: '未知错误,请稍后重试'
}
handleError(error: ApiError): void {
this.logError(error)
this.showErrorMessage(error.message)
}
showErrorMessage(message: string): void {
uni.showToast({
title: message,
icon: 'none',
duration: 3000
})
}
showSuccessMessage(message: string): void {
uni.showToast({
title: message,
icon: 'success',
duration: 2000
})
}
logError(error: ApiError): void {
console.error('[API Error]', {
type: error.type,
code: error.code,
message: error.message,
detail: error.detail
})
}
getErrorMessage(error: ApiError): string {
return this.errorMessages[error.type] || error.message
}
isAuthError(error: ApiError): boolean {
return error.type === 'AUTH_ERROR' || error.type === 'TOKEN_EXPIRED'
}
isNetworkError(error: ApiError): boolean {
return error.type === 'NETWORK_ERROR' || error.type === 'TIMEOUT_ERROR'
}
isBusinessError(error: ApiError): boolean {
return error.type === 'BUSINESS_ERROR'
}
}
const errorHandler = new ErrorHandler()
export default errorHandler
export { ErrorHandler }
@@ -0,0 +1,195 @@
import appConfig from '../../config'
import type { ApiResult } from '../types/api'
export enum ErrorType {
NETWORK_ERROR = 'NETWORK_ERROR',
TIMEOUT_ERROR = 'TIMEOUT_ERROR',
BUSINESS_ERROR = 'BUSINESS_ERROR',
AUTH_ERROR = 'AUTH_ERROR',
TOKEN_EXPIRED = 'TOKEN_EXPIRED',
UNKNOWN_ERROR = 'UNKNOWN_ERROR'
}
export interface ApiError {
type: ErrorType
code: number
message: string
detail?: any
}
export interface RequestConfig {
url: string
method: 'GET' | 'POST' | 'PUT' | 'DELETE'
data?: any
params?: any
headers?: Record<string, string>
timeout?: number
needAuth?: boolean
}
class HttpClient {
private baseURL: string
private defaultTimeout: number = 10000
private tokenKey: string = 'auth_token'
constructor() {
this.baseURL = appConfig.baseURL
}
async request<T>(config: RequestConfig): Promise<ApiResult<T>> {
const { url, method, data, params, headers = {}, timeout = this.defaultTimeout, needAuth = true } = config
const requestUrl = this.buildUrl(url, params)
const requestHeaders = this.buildHeaders(headers, needAuth)
return new Promise((resolve, reject) => {
uni.request({
url: requestUrl,
method,
data,
header: requestHeaders,
timeout,
success: (response: any) => {
try {
const result = this.handleResponse<T>(response)
resolve(result)
} catch (error) {
reject(error)
}
},
fail: (error: any) => {
reject(this.handleError(error))
}
})
})
}
async get<T>(url: string, params?: any, options?: { needAuth?: boolean }): Promise<ApiResult<T>> {
return this.request<T>({ url, method: 'GET', params, ...options })
}
async post<T>(url: string, data?: any, options?: { needAuth?: boolean }): Promise<ApiResult<T>> {
return this.request<T>({ url, method: 'POST', data, ...options })
}
async put<T>(url: string, data?: any, options?: { needAuth?: boolean }): Promise<ApiResult<T>> {
return this.request<T>({ url, method: 'PUT', data, ...options })
}
async delete<T>(url: string, params?: any, options?: { needAuth?: boolean }): Promise<ApiResult<T>> {
return this.request<T>({ url, method: 'DELETE', params, ...options })
}
private buildUrl(url: string, params?: any): string {
let fullUrl = url.startsWith('http') ? url : `${this.baseURL}${url}`
if (params) {
const queryString = Object.keys(params)
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`)
.join('&')
fullUrl += `?${queryString}`
}
return fullUrl
}
private buildHeaders(headers: Record<string, string>, needAuth: boolean): Record<string, string> {
const requestHeaders: Record<string, string> = {
'Content-Type': 'application/json',
...headers
}
if (needAuth) {
const token = this.getToken()
if (token) {
requestHeaders['Authorization'] = `Bearer ${token}`
}
}
return requestHeaders
}
private handleResponse<T>(response: any): ApiResult<T> {
const { statusCode, data } = response
if (statusCode === 200) {
return data as ApiResult<T>
} else if (statusCode === 401) {
throw {
type: ErrorType.AUTH_ERROR,
code: 401,
message: '认证失败,请重新登录'
} as ApiError
} else if (statusCode === 400 || statusCode === 500) {
const errorMessage = data?.message || '请求失败'
throw {
type: ErrorType.BUSINESS_ERROR,
code: statusCode,
message: errorMessage
} as ApiError
} else {
throw {
type: ErrorType.UNKNOWN_ERROR,
code: statusCode,
message: '未知错误'
} as ApiError
}
}
private handleError(error: any): ApiError {
if (error.errMsg) {
if (error.errMsg.includes('timeout')) {
return {
type: ErrorType.TIMEOUT_ERROR,
code: 0,
message: '请求超时,请稍后重试'
}
} else if (error.errMsg.includes('fail')) {
return {
type: ErrorType.NETWORK_ERROR,
code: 0,
message: '网络连接失败,请检查网络设置'
}
}
}
if (error.type) {
return error as ApiError
}
return {
type: ErrorType.UNKNOWN_ERROR,
code: 0,
message: error.message || '未知错误'
}
}
private getToken(): string | null {
try {
return uni.getStorageSync(this.tokenKey) || null
} catch (error) {
return null
}
}
setToken(token: string): void {
try {
uni.setStorageSync(this.tokenKey, token)
} catch (error) {
console.error('设置Token失败:', error)
}
}
removeToken(): void {
try {
uni.removeStorageSync(this.tokenKey)
} catch (error) {
console.error('删除Token失败:', error)
}
}
}
const httpClient = new HttpClient()
export default httpClient
export { HttpClient }
@@ -0,0 +1,168 @@
interface CacheNode<T> {
key: string
value: T
prev: CacheNode<T> | null
next: CacheNode<T> | null
}
interface LRUCacheOptions {
maxSize?: number
ttl?: number
}
export class LRUCache<T> {
private cache: Map<string, CacheNode<T>>
private head: CacheNode<T> | null
private tail: CacheNode<T> | null
private maxSize: number
private ttl: number
private timers: Map<string, NodeJS.Timeout>
constructor(options: LRUCacheOptions = {}) {
this.cache = new Map()
this.head = null
this.tail = null
this.maxSize = options.maxSize || 100
this.ttl = options.ttl || 5 * 60 * 1000
this.timers = new Map()
}
get(key: string): T | null {
const node = this.cache.get(key)
if (!node) {
return null
}
this.moveToHead(node)
return node.value
}
set(key: string, value: T): void {
const existingNode = this.cache.get(key)
if (existingNode) {
existingNode.value = value
this.moveToHead(existingNode)
this.resetTimer(key)
return
}
const newNode: CacheNode<T> = {
key,
value,
prev: null,
next: this.head
}
this.cache.set(key, newNode)
if (this.head) {
this.head.prev = newNode
}
this.head = newNode
if (!this.tail) {
this.tail = newNode
}
this.evictIfNeeded()
this.resetTimer(key)
}
delete(key: string): void {
const node = this.cache.get(key)
if (!node) {
return
}
if (node.prev) {
node.prev.next = node.next
} else {
this.head = node.next
}
if (node.next) {
node.next.prev = node.prev
} else {
this.tail = node.prev
}
this.cache.delete(key)
this.clearTimer(key)
}
clear(): void {
this.cache.clear()
this.head = null
this.tail = null
this.timers.forEach((timer) => clearTimeout(timer))
this.timers.clear()
}
has(key: string): boolean {
return this.cache.has(key)
}
size(): number {
return this.cache.size
}
keys(): string[] {
return Array.from(this.cache.keys())
}
private moveToHead(node: CacheNode<T>): void {
if (node === this.head) {
return
}
if (node.prev) {
node.prev.next = node.next
}
if (node.next) {
node.next.prev = node.prev
} else {
this.tail = node.prev
}
node.prev = null
node.next = this.head
if (this.head) {
this.head.prev = node
}
this.head = node
}
private evictIfNeeded(): void {
while (this.cache.size > this.maxSize && this.tail) {
const lruKey = this.tail.key
this.delete(lruKey)
}
}
private resetTimer(key: string): void {
this.clearTimer(key)
const timer = setTimeout(() => {
this.delete(key)
}, this.ttl)
this.timers.set(key, timer)
}
private clearTimer(key: string): void {
const timer = this.timers.get(key)
if (timer) {
clearTimeout(timer)
this.timers.delete(key)
}
}
destroy(): void {
this.clear()
}
}
@@ -0,0 +1,387 @@
/**
* 农历工具函数
*
* 提供公历与农历转换、节气计算、生肖计算等功能
*
* @example
* ```typescript
* const lunarDate = solarToLunar(new Date(2024, 1, 10))
* console.log(lunarDate.month) // 1 (正月)
* console.log(lunarDate.day) // 1 (初一)
* ```
*/
export interface LunarDate {
/** 农历年 */
year: number
/** 农历月 (1-12) */
month: number
/** 农历日 (1-30) */
day: number
/** 是否闰月 */
isLeapMonth: boolean
/** 年干支 */
ganZhi?: string
/** 生肖 */
zodiac?: string
}
export interface SolarTerm {
/** 节气名称 */
name: string | null
/** 节气索引 (0-23) */
index: number | null
}
export interface LeapMonthInfo {
/** 是否有闰月 */
hasLeap: boolean
/** 闰月月份 (1-12),无闰月时为null */
leapMonth: number | null
}
// 天干
const TIAN_GAN = ['甲', '乙', '丙', '丁', '戊', '己', '庚', '辛', '壬', '癸']
// 地支
const DI_ZHI = ['子', '丑', '寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥']
// 生肖
const ZODIAC = ['鼠', '牛', '虎', '兔', '龙', '蛇', '马', '羊', '猴', '鸡', '狗', '猪']
// 节气名称
const SOLAR_TERMS = [
'立春', '雨水', '惊蛰', '春分', '清明', '谷雨',
'立夏', '小满', '芒种', '夏至', '小暑', '大暑',
'立秋', '处暑', '白露', '秋分', '寒露', '霜降',
'立冬', '小雪', '大雪', '冬至', '小寒', '大寒'
]
// 农历数据表 (1900-2100)
// 每个元素代表一年的数据,格式为:闰月信息(4位) + 12个月的大小月信息(12位)
// 这是一个简化的实现,实际应该使用完整的农历数据表
const LUNAR_INFO = [
0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260, 0x0d950, 0x16554, 0x056a0, 0x09ad0, 0x055d2,
0x04ae0, 0x0a5b6, 0x0a4d0, 0x0d250, 0x1d255, 0x0b540, 0x0d6a0, 0x0ada2, 0x095b0, 0x14977,
0x04970, 0x0a4b0, 0x0b4b5, 0x06a50, 0x06d40, 0x1ab54, 0x02b60, 0x09570, 0x052f2, 0x04970,
0x06566, 0x0d4a0, 0x0ea50, 0x06e95, 0x05ad0, 0x02b60, 0x186e3, 0x092e0, 0x1c8d7, 0x0c950,
0x0d4a0, 0x1d8a6, 0x0b550, 0x056a0, 0x1a5b4, 0x025d0, 0x092d0, 0x0d2b2, 0x0a950, 0x0b557,
// 2000-2050
0x06ca0, 0x0b550, 0x15355, 0x04da0, 0x0a5d0, 0x14573, 0x052d0, 0x0a9a8, 0x0e950, 0x06aa0,
0x0aea6, 0x0ab50, 0x04b60, 0x0aae4, 0x0a570, 0x05260, 0x0f263, 0x0d950, 0x05b57, 0x056a0,
0x096d0, 0x04dd5, 0x04ad0, 0x0a4d0, 0x0d4d4, 0x0d250, 0x0d558, 0x0b540, 0x0b5a0, 0x195a6,
0x095b0, 0x049b0, 0x0a974, 0x0a4b0, 0x0b27a, 0x06a50, 0x06d40, 0x0af46, 0x0ab60, 0x09570,
0x04af5, 0x04970, 0x064b0, 0x074a3, 0x0ea50, 0x06b58, 0x055c0, 0x0ab60, 0x096d5, 0x092e0
]
// 节气日期表 (2024年)
const SOLAR_TERM_DATES_2024 = [
{ month: 1, day: 6, name: '小寒' },
{ month: 1, day: 20, name: '大寒' },
{ month: 2, day: 4, name: '立春' },
{ month: 2, day: 19, name: '雨水' },
{ month: 3, day: 5, name: '惊蛰' },
{ month: 3, day: 20, name: '春分' },
{ month: 4, day: 4, name: '清明' },
{ month: 4, day: 19, name: '谷雨' },
{ month: 5, day: 5, name: '立夏' },
{ month: 5, day: 20, name: '小满' },
{ month: 6, day: 5, name: '芒种' },
{ month: 6, day: 21, name: '夏至' },
{ month: 7, day: 6, name: '小暑' },
{ month: 7, day: 22, name: '大暑' },
{ month: 8, day: 7, name: '立秋' },
{ month: 8, day: 22, name: '处暑' },
{ month: 9, day: 7, name: '白露' },
{ month: 9, day: 22, name: '秋分' },
{ month: 10, day: 8, name: '寒露' },
{ month: 10, day: 23, name: '霜降' },
{ month: 11, day: 7, name: '立冬' },
{ month: 11, day: 22, name: '小雪' },
{ month: 12, day: 6, name: '大雪' },
{ month: 12, day: 21, name: '冬至' }
]
/**
* 公历转农历
* @param solarDate 公历日期
* @returns 农历日期
*/
export function solarToLunar(solarDate: Date): LunarDate {
if (!isValidDate(solarDate)) {
throw new Error('Invalid date')
}
const year = solarDate.getFullYear()
const month = solarDate.getMonth() + 1
const day = solarDate.getDate()
// 简化的农历转换算法
// 实际应该使用完整的农历数据表
const baseDate = new Date(1900, 0, 31) // 1900年正月初一
const offset = Math.floor((solarDate.getTime() - baseDate.getTime()) / 86400000)
let lunarYear = 1900
let daysInYear = getLunarYearDays(lunarYear)
let remainingOffset = offset
while (remainingOffset >= daysInYear) {
remainingOffset -= daysInYear
lunarYear++
daysInYear = getLunarYearDays(lunarYear)
}
const leapMonthInfo = isLeapMonth(lunarYear, 1)
let lunarMonth = 1
let isLeap = false
while (remainingOffset >= 0) {
const daysInMonth = getLunarMonthDays(lunarYear, lunarMonth, isLeap)
if (remainingOffset < daysInMonth) {
break
}
remainingOffset -= daysInMonth
if (leapMonthInfo.hasLeap && leapMonthInfo.leapMonth === lunarMonth && !isLeap) {
isLeap = true
} else {
isLeap = false
lunarMonth++
}
}
const lunarDay = remainingOffset + 1
return {
year: lunarYear,
month: lunarMonth,
day: lunarDay,
isLeapMonth: isLeap,
ganZhi: getGanZhi(lunarYear),
zodiac: getChineseZodiac(lunarYear)
}
}
/**
* 农历转公历
* @param lunarDate 农历日期
* @returns 公历日期
*/
export function lunarToSolar(lunarDate: LunarDate): Date {
if (lunarDate.month < 1 || lunarDate.month > 12) {
throw new Error('Invalid lunar month')
}
if (lunarDate.day < 1 || lunarDate.day > 30) {
throw new Error('Invalid lunar day')
}
const baseDate = new Date(1900, 0, 31)
let offset = 0
// 计算年份偏移
for (let year = 1900; year < lunarDate.year; year++) {
offset += getLunarYearDays(year)
}
// 计算月份偏移
const leapMonthInfo = isLeapMonth(lunarDate.year, 1)
for (let month = 1; month < lunarDate.month; month++) {
offset += getLunarMonthDays(lunarDate.year, month, false)
if (leapMonthInfo.hasLeap && leapMonthInfo.leapMonth === month) {
offset += getLunarMonthDays(lunarDate.year, month, true)
}
}
// 如果是闰月
if (lunarDate.isLeapMonth && leapMonthInfo.hasLeap && leapMonthInfo.leapMonth === lunarDate.month) {
offset += getLunarMonthDays(lunarDate.year, lunarDate.month, false)
}
// 计算日期偏移
offset += lunarDate.day - 1
return new Date(baseDate.getTime() + offset * 86400000)
}
/**
* 获取农历年天数
* @param year 农历年
* @returns 天数
*/
export function getLunarYearDays(year: number): number {
const leapMonthInfo = isLeapMonth(year, 1)
let days = 0
for (let month = 1; month <= 12; month++) {
days += getLunarMonthDays(year, month, false)
if (leapMonthInfo.hasLeap && leapMonthInfo.leapMonth === month) {
days += getLunarMonthDays(year, month, true)
}
}
return days
}
/**
* 获取农历月天数
* @param year 农历年
* @param month 农历月
* @param isLeap 是否闰月
* @returns 天数
*/
export function getLunarMonthDays(year: number, month: number, isLeap: boolean): number {
const yearIndex = year - 1900
if (yearIndex < 0 || yearIndex >= LUNAR_INFO.length) {
return 30 // 默认大月
}
const lunarData = LUNAR_INFO[yearIndex]
const leapMonth = (lunarData >> 16) & 0x0f
if (isLeap && leapMonth !== month) {
return 0 // 不是闰月
}
const monthIndex = isLeap ? 12 : month - 1
const isBigMonth = (lunarData >> (15 - monthIndex)) & 0x01
return isBigMonth ? 30 : 29
}
/**
* 检测闰月
* @param year 农历年
* @param month 农历月
* @returns 闰月信息
*/
export function isLeapMonth(year: number, month: number): LeapMonthInfo {
const yearIndex = year - 1900
if (yearIndex < 0 || yearIndex >= LUNAR_INFO.length) {
return { hasLeap: false, leapMonth: null }
}
const lunarData = LUNAR_INFO[yearIndex]
const leapMonth = (lunarData >> 16) & 0x0f
return {
hasLeap: leapMonth > 0,
leapMonth: leapMonth > 0 ? leapMonth : null
}
}
/**
* 获取节气
* @param solarDate 公历日期
* @returns 节气信息
*/
export function getSolarTerm(solarDate: Date): SolarTerm {
const year = solarDate.getFullYear()
const month = solarDate.getMonth() + 1
const day = solarDate.getDate()
// 使用2024年的节气数据作为示例
// 实际应该根据年份计算
if (year === 2024) {
const term = SOLAR_TERM_DATES_2024.find(
t => t.month === month && t.day === day
)
if (term) {
return {
name: term.name,
index: SOLAR_TERMS.indexOf(term.name)
}
}
}
return { name: null, index: null }
}
/**
* 获取生肖
* @param year 年份
* @returns 生肖
*/
export function getChineseZodiac(year: number): string {
// 1900年是鼠年
const offset = (year - 1900) % 12
return ZODIAC[offset]
}
/**
* 获取干支
* @param year 年份
* @returns 干支
*/
export function getGanZhi(year: number): string {
// 1900年是庚子年
const ganIndex = (year - 1900) % 10
const zhiIndex = (year - 1900) % 12
return TIAN_GAN[ganIndex] + DI_ZHI[zhiIndex]
}
/**
* 获取农历节日
* @param solarDate 公历日期
* @returns 节日列表
*/
export function getLunarFestivals(solarDate: Date): string[] {
const lunarDate = solarToLunar(solarDate)
const festivals: string[] = []
// 春节
if (lunarDate.month === 1 && lunarDate.day === 1) {
festivals.push('春节')
}
// 元宵节
if (lunarDate.month === 1 && lunarDate.day === 15) {
festivals.push('元宵节')
}
// 端午节
if (lunarDate.month === 5 && lunarDate.day === 5) {
festivals.push('端午节')
}
// 七夕
if (lunarDate.month === 7 && lunarDate.day === 7) {
festivals.push('七夕')
}
// 中元节
if (lunarDate.month === 7 && lunarDate.day === 15) {
festivals.push('中元节')
}
// 中秋节
if (lunarDate.month === 8 && lunarDate.day === 15) {
festivals.push('中秋节')
}
// 重阳节
if (lunarDate.month === 9 && lunarDate.day === 9) {
festivals.push('重阳节')
}
// 腊八节
if (lunarDate.month === 12 && lunarDate.day === 8) {
festivals.push('腊八节')
}
// 除夕
if (lunarDate.month === 12) {
const lastDay = getLunarMonthDays(lunarDate.year, 12, false)
if (lunarDate.day === lastDay) {
festivals.push('除夕')
}
}
return festivals
}
/**
* 验证日期是否有效
* @param date 日期
* @returns 是否有效
*/
function isValidDate(date: Date): boolean {
return date instanceof Date && !isNaN(date.getTime())
}
export default {
solarToLunar,
lunarToSolar,
getLunarYearDays,
getLunarMonthDays,
isLeapMonth,
getSolarTerm,
getChineseZodiac,
getLunarFestivals
}
@@ -0,0 +1,126 @@
class PerformanceMonitor {
private metrics: Map<string, number[]> = new Map()
private readonly MAX_SAMPLES = 100
startMeasure(operation: string): () => void {
const startTime = performance.now()
return () => {
const endTime = performance.now()
const duration = endTime - startTime
this.recordMetric(operation, duration)
console.log(`[性能监控] ${operation}: ${duration.toFixed(2)}ms`)
}
}
recordMetric(operation: string, duration: number): void {
if (!this.metrics.has(operation)) {
this.metrics.set(operation, [])
}
const samples = this.metrics.get(operation)!
samples.push(duration)
if (samples.length > this.MAX_SAMPLES) {
samples.shift()
}
}
getAverage(operation: string): number {
const samples = this.metrics.get(operation)
if (!samples || samples.length === 0) {
return 0
}
const sum = samples.reduce((acc, val) => acc + val, 0)
return sum / samples.length
}
getP95(operation: string): number {
const samples = this.metrics.get(operation)
if (!samples || samples.length === 0) {
return 0
}
const sorted = [...samples].sort((a, b) => a - b)
const index = Math.floor(sorted.length * 0.95)
return sorted[index]
}
getP99(operation: string): number {
const samples = this.metrics.get(operation)
if (!samples || samples.length === 0) {
return 0
}
const sorted = [...samples].sort((a, b) => a - b)
const index = Math.floor(sorted.length * 0.99)
return sorted[index]
}
getMax(operation: string): number {
const samples = this.metrics.get(operation)
if (!samples || samples.length === 0) {
return 0
}
return Math.max(...samples)
}
getMin(operation: string): number {
const samples = this.metrics.get(operation)
if (!samples || samples.length === 0) {
return 0
}
return Math.min(...samples)
}
getMetrics(): Record<string, any> {
const result: Record<string, any> = {}
for (const [operation, samples] of this.metrics.entries()) {
result[operation] = {
average: this.getAverage(operation),
p95: this.getP95(operation),
p99: this.getP99(operation),
max: this.getMax(operation),
min: this.getMin(operation),
count: samples.length
}
}
return result
}
clearMetrics(): void {
this.metrics.clear()
}
clearOperationMetrics(operation: string): void {
this.metrics.delete(operation)
}
printReport(): void {
console.log('========== 性能监控报告 ==========')
const metrics = this.getMetrics()
for (const [operation, data] of Object.entries(metrics)) {
console.log(`\n${operation}:`)
console.log(` 平均耗时: ${data.average.toFixed(2)}ms`)
console.log(` P95耗时: ${data.p95.toFixed(2)}ms`)
console.log(` P99耗时: ${data.p99.toFixed(2)}ms`)
console.log(` 最大耗时: ${data.max.toFixed(2)}ms`)
console.log(` 最小耗时: ${data.min.toFixed(2)}ms`)
console.log(` 采样次数: ${data.count}`)
}
console.log('==================================')
}
}
const performanceMonitor = new PerformanceMonitor()
export default performanceMonitor
@@ -0,0 +1,194 @@
class SearchOptimizer {
private readonly BATCH_SIZE = 100
private readonly MAX_CACHE_SIZE = 50
async batchProcess<T, R>(
items: T[],
processor: (item: T) => R | Promise<R>,
batchSize: number = this.BATCH_SIZE
): Promise<R[]> {
const results: R[] = []
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize)
const batchResults = await Promise.all(batch.map(processor))
results.push(...batchResults)
}
return results
}
async parallelProcess<T, R>(
items: T[],
processor: (item: T) => R | Promise<R>,
concurrency: number = 4
): Promise<R[]> {
const results: R[] = []
const executing: Promise<void>[] = []
for (const item of items) {
const promise = processor(item).then(result => {
results.push(result)
})
executing.push(promise)
if (executing.length >= concurrency) {
await Promise.race(executing)
executing.splice(
executing.findIndex(p => {
return p === Promise.resolve()
}),
1
)
}
}
await Promise.all(executing)
return results
}
paginate<T>(items: T[], page: number, pageSize: number): T[] {
const startIndex = (page - 1) * pageSize
const endIndex = startIndex + pageSize
return items.slice(startIndex, endIndex)
}
debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null
return function(this: any, ...args: Parameters<T>) {
if (timeout) {
clearTimeout(timeout)
}
timeout = setTimeout(() => {
func.apply(this, args)
}, wait)
}
}
throttle<T extends (...args: any[]) => any>(
func: T,
limit: number
): (...args: Parameters<T>) => void {
let inThrottle: boolean = false
return function(this: any, ...args: Parameters<T>) {
if (!inThrottle) {
func.apply(this, args)
inThrottle = true
setTimeout(() => {
inThrottle = false
}, limit)
}
}
}
memoize<T extends (...args: any[]) => any>(
func: T,
keyGenerator?: (...args: Parameters<T>) => string
): T {
const cache = new Map<string, ReturnType<T>>()
return function(this: any, ...args: Parameters<T>): ReturnType<T> {
const key = keyGenerator
? keyGenerator(...args)
: JSON.stringify(args)
if (cache.has(key)) {
return cache.get(key)!
}
const result = func.apply(this, args)
cache.set(key, result)
if (cache.size > this.MAX_CACHE_SIZE) {
const firstKey = cache.keys().next().value
cache.delete(firstKey)
}
return result
} as T
}
lazyLoad<T>(
items: T[],
loadCallback: (items: T[]) => void,
threshold: number = 0.8
): () => void {
let loadedCount = 0
const totalItems = items.length
const loadMore = () => {
const remaining = totalItems - loadedCount
if (remaining <= 0) return
const loadCount = Math.min(
Math.ceil(totalItems * (1 - threshold)),
remaining
)
const newItems = items.slice(loadedCount, loadedCount + loadCount)
loadCallback(newItems)
loadedCount += loadCount
}
return loadMore
}
optimizeSearch<T>(
items: T[],
filterFn: (item: T) => boolean,
mapFn: (item: T) => any = item => item
): any[] {
const result: any[] = []
for (let i = 0; i < items.length; i++) {
if (filterFn(items[i])) {
result.push(mapFn(items[i]))
}
}
return result
}
binarySearch<T>(
items: T[],
target: T,
compareFn: (a: T, b: T) => number
): number {
let left = 0
let right = items.length - 1
while (left <= right) {
const mid = Math.floor((left + right) / 2)
const comparison = compareFn(items[mid], target)
if (comparison === 0) {
return mid
} else if (comparison < 0) {
left = mid + 1
} else {
right = mid - 1
}
}
return -1
}
chunk<T>(array: T[], size: number): T[][] {
const chunks: T[][] = []
for (let i = 0; i < array.length; i += size) {
chunks.push(array.slice(i, i + size))
}
return chunks
}
}
const searchOptimizer = new SearchOptimizer()
export default searchOptimizer
@@ -0,0 +1,93 @@
import type { ApiError, ErrorType } from './httpClient'
const TOKEN_KEY = 'auth_token'
const REFRESH_TOKEN_KEY = 'refresh_token'
const TOKEN_EXPIRY_KEY = 'token_expiry'
class TokenManager {
setToken(token: string, expiresIn?: number): void {
try {
uni.setStorageSync(TOKEN_KEY, token)
if (expiresIn !== undefined) {
const expiryTime = Date.now() + expiresIn * 1000
uni.setStorageSync(TOKEN_EXPIRY_KEY, expiryTime)
}
} catch (error) {
console.error('设置Token失败:', error)
}
}
getToken(): string | null {
try {
const token = uni.getStorageSync(TOKEN_KEY)
if (token === null || token === undefined) {
return null
}
return token
} catch (error) {
console.error('获取Token失败:', error)
return null
}
}
removeToken(): void {
try {
uni.removeStorageSync(TOKEN_KEY)
uni.removeStorageSync(TOKEN_EXPIRY_KEY)
} catch (error) {
console.error('删除Token失败:', error)
}
}
setRefreshToken(refreshToken: string): void {
try {
uni.setStorageSync(REFRESH_TOKEN_KEY, refreshToken)
} catch (error) {
console.error('设置刷新Token失败:', error)
}
}
getRefreshToken(): string | null {
try {
const refreshToken = uni.getStorageSync(REFRESH_TOKEN_KEY)
if (refreshToken === null || refreshToken === undefined) {
return null
}
return refreshToken
} catch (error) {
console.error('获取刷新Token失败:', error)
return null
}
}
removeRefreshToken(): void {
try {
uni.removeStorageSync(REFRESH_TOKEN_KEY)
} catch (error) {
console.error('删除刷新Token失败:', error)
}
}
isTokenExpired(): boolean {
try {
const expiryTime = uni.getStorageSync(TOKEN_EXPIRY_KEY)
if (expiryTime === null || expiryTime === undefined) {
return false
}
return Date.now() >= expiryTime
} catch (error) {
console.error('检查Token过期失败:', error)
return false
}
}
clearAll(): void {
this.removeToken()
this.removeRefreshToken()
}
}
const tokenManager = new TokenManager()
export default tokenManager
export { TokenManager }