feat(admin): 添加用户管理相关文件
添加用户管理视图、API和状态管理文件
This commit is contained in:
@@ -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 }
|
||||
Reference in New Issue
Block a user