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>
|
||||
Reference in New Issue
Block a user