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

添加用户管理视图、API和状态管理文件
This commit is contained in:
张翔
2026-03-28 14:37:29 +08:00
commit 08ea5fbe98
1643 changed files with 255646 additions and 0 deletions
@@ -0,0 +1,100 @@
<template>
<view class="empty-state">
<Icon :name="iconName" :size="iconSize" color="rgba(200, 200, 200, 255)" />
<Typography variant="h4" weight="semibold" class="empty-title">{{ title }}</Typography>
<Typography variant="body" class="empty-description">{{ description }}</Typography>
<Button v-if="showAction" type="primary" @click="handleAction">
{{ actionText }}
</Button>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Icon from '../Icon/Icon.vue'
import Typography from '../Typography/Typography.vue'
import Button from '../Button/Button.vue'
interface Props {
icon?: string
title?: string
description?: string
showAction?: boolean
actionText?: string
size?: 'small' | 'medium' | 'large'
}
const props = withDefaults(defineProps<Props>(), {
icon: 'empty',
title: '暂无数据',
description: '没有找到相关结果',
showAction: true,
actionText: '重新搜索',
size: 'medium'
})
const emit = defineEmits<{
action: []
}>()
const iconName = computed(() => props.icon || 'empty')
const iconSize = computed(() => {
switch (props.size) {
case 'small':
return '60rpx'
case 'large':
return '100rpx'
default:
return '80rpx'
}
})
const handleAction = () => {
emit('action')
}
</script>
<style scoped lang="scss">
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
gap: 16px;
}
.empty-title {
color: rgba(44, 24, 16, 255);
font-size: 18px;
line-height: 28px;
text-align: center;
}
.empty-description {
color: rgba(113, 113, 122, 255);
font-size: 14px;
line-height: 20px;
text-align: center;
max-width: 280px;
}
@media screen and (max-width: 375px) {
.empty-state {
padding: 48px 16px;
gap: 12px;
}
.empty-title {
font-size: 16px;
line-height: 26px;
}
.empty-description {
font-size: 13px;
line-height: 18px;
max-width: 240px;
}
}
</style>
@@ -0,0 +1,252 @@
<template>
<view class="export-panel">
<Button
type="primary"
size="small"
@click="handleExport"
>
<Icon name="download" size="16rpx" color="#FFFFFF" />
<Typography variant="body" color="#FFFFFF">导出结果</Typography>
</Button>
<view v-if="showExportModal" class="export-modal">
<view class="modal-content">
<view class="modal-header">
<Typography variant="h4" weight="semibold">导出搜索结果</Typography>
<Button size="small" type="text" @click="closeModal">
<Icon name="close" size="16rpx" color="rgba(113, 113, 122, 255)" />
</Button>
</view>
<view class="modal-body">
<view class="export-options">
<Typography variant="body" class="option-title">选择导出格式</Typography>
<view class="format-options">
<view
v-for="format in exportFormats"
:key="format.value"
class="format-option"
:class="{ 'format-option--selected': selectedFormat === format.value }"
@click="selectFormat(format.value)"
>
<Icon
:name="selectedFormat === format.value ? 'checkbox-checked' : 'checkbox-unchecked'"
size="20rpx"
:color="selectedFormat === format.value ? 'rgba(196, 30, 58, 255)' : 'rgba(113, 113, 122, 255)'"
/>
<Typography variant="body" class="format-label">{{ format.label }}</Typography>
</view>
</view>
</view>
<view class="export-options">
<Typography variant="body" class="option-title">选择导出内容</Typography>
<view class="content-options">
<view
v-for="option in contentOptions"
:key="option.value"
class="content-option"
:class="{ 'content-option--selected': selectedContent.includes(option.value) }"
@click="toggleContent(option.value)"
>
<Icon
:name="selectedContent.includes(option.value) ? 'checkbox-checked' : 'checkbox-unchecked'"
size="20rpx"
:color="selectedContent.includes(option.value) ? 'rgba(196, 30, 58, 255)' : 'rgba(113, 113, 122, 255)'"
/>
<Typography variant="body" class="content-label">{{ option.label }}</Typography>
</view>
</view>
</view>
</view>
<view class="modal-footer">
<Button type="text" @click="closeModal">取消</Button>
<Button type="primary" @click="confirmExport">导出</Button>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { SearchResult } from '../../types/search'
import Button from '../Button/Button.vue'
import Icon from '../Icon/Icon.vue'
import Typography from '../Typography/Typography.vue'
interface Props {
results: SearchResult[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
export: [format: string, content: string[]]
}>()
const showExportModal = ref(false)
const selectedFormat = ref('excel')
const selectedContent = ref(['date', 'lunarDate', 'matchedItems'])
const exportFormats = [
{ label: 'Excel (.xlsx)', value: 'excel' },
{ label: 'PDF (.pdf)', value: 'pdf' },
{ label: 'CSV (.csv)', value: 'csv' }
]
const contentOptions = [
{ label: '日期', value: 'date' },
{ label: '农历日期', value: 'lunarDate' },
{ label: '星期', value: 'weekday' },
{ label: '匹配事项', value: 'matchedItems' },
{ label: '匹配度', value: 'matchCount' }
]
function handleExport() {
showExportModal.value = true
}
function closeModal() {
showExportModal.value = false
}
function selectFormat(format: string) {
selectedFormat.value = format
}
function toggleContent(content: string) {
const index = selectedContent.value.indexOf(content)
if (index > -1) {
selectedContent.value.splice(index, 1)
} else {
selectedContent.value.push(content)
}
}
function confirmExport() {
emit('export', selectedFormat.value, selectedContent.value)
closeModal()
}
</script>
<style scoped lang="scss">
.export-panel {
position: relative;
}
.export-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
width: 90%;
max-width: 500px;
background: var(--color-background);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
padding: 24px;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.modal-body {
display: flex;
flex-direction: column;
gap: 20px;
margin-bottom: 24px;
}
.export-options {
display: flex;
flex-direction: column;
gap: 12px;
}
.option-title {
color: var(--color-text);
font-weight: 500;
}
.format-options {
display: flex;
flex-direction: column;
gap: 12px;
}
.format-option {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: rgba(244, 244, 245, 255);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
&:active {
background: rgba(230, 225, 220, 255);
}
}
.format-option--selected {
background: rgba(220, 252, 231, 1);
border: 1px solid rgba(185, 248, 207, 1);
}
.format-label {
color: var(--color-text);
}
.content-options {
display: flex;
flex-direction: column;
gap: 12px;
}
.content-option {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: rgba(244, 244, 245, 255);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
&:active {
background: rgba(230, 225, 220, 255);
}
}
.content-option--selected {
background: rgba(220, 252, 231, 1);
border: 1px solid rgba(185, 248, 207, 1);
}
.content-label {
color: var(--color-text);
}
.modal-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
</style>
@@ -0,0 +1,72 @@
<template>
<view class="loading-indicator">
<view class="loading-spinner"></view>
<Typography v-if="text" variant="caption" class="loading-text">{{ text }}</Typography>
</view>
</template>
<script setup lang="ts">
import Typography from '../Typography/Typography.vue'
interface Props {
text?: string
}
withDefaults(defineProps<Props>(), {
text: ''
})
</script>
<style scoped lang="scss">
.loading-indicator {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
gap: 16px;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(230, 225, 220, 255);
border-top: 4px solid rgba(196, 30, 58, 255);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.loading-text {
color: rgba(113, 113, 122, 255);
font-size: 14px;
line-height: 20px;
text-align: center;
}
@media screen and (max-width: 375px) {
.loading-indicator {
padding: 32px 16px;
gap: 12px;
}
.loading-spinner {
width: 36px;
height: 36px;
border-width: 3px;
}
.loading-text {
font-size: 13px;
line-height: 18px;
}
}
</style>
@@ -0,0 +1,241 @@
<template>
<Card class="search-condition-item">
<view class="condition-header">
<Select
v-model="localCondition.type"
:options="typeOptions"
@change="handleTypeChange"
/>
<Button size="small" type="text" @click="handleDelete">
<Icon name="close" size="16rpx" color="rgba(196, 30, 58, 255)" />
</Button>
</view>
<view class="condition-body">
<view class="condition-section">
<Typography variant="caption" class="section-label">事项</Typography>
<view class="items-selector">
<view
v-for="item in availableItems"
:key="item"
class="item-tag"
:class="{ 'item-tag-selected': localCondition.items.includes(item) }"
@click="toggleItem(item)"
>
<Typography variant="caption" class="item-text">{{ item }}</Typography>
</view>
</view>
</view>
<view class="condition-section">
<Typography variant="caption" class="section-label">逻辑运算符</Typography>
<Select
v-model="localCondition.operator"
:options="operatorOptions"
@change="handleOperatorChange"
/>
</view>
<view class="condition-section">
<view class="exclude-toggle" @click="toggleExclude">
<Icon
:name="localCondition.exclude ? 'checkbox-checked' : 'checkbox-unchecked'"
size="20rpx"
:color="localCondition.exclude ? 'rgba(196, 30, 58, 255)' : 'rgba(113, 113, 122, 255)'"
/>
<Typography variant="body" class="exclude-label">排除条件</Typography>
</view>
</view>
</view>
</Card>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { SearchCondition } from '../../types/search'
import Card from '../Card/Card.vue'
import Button from '../Button/Button.vue'
import Icon from '../Icon/Icon.vue'
import Typography from '../Typography/Typography.vue'
import Select from '../Select/Select.vue'
interface Props {
condition: SearchCondition
}
const props = defineProps<Props>()
const emit = defineEmits<{
update: [condition: SearchCondition]
delete: []
}>()
const localCondition = ref<SearchCondition>({ ...props.condition })
const typeOptions = [
{ label: '宜', value: 'suitable' },
{ label: '忌', value: 'unsuitable' }
]
const operatorOptions = [
{ label: '与(AND', value: 'and' },
{ label: '或(OR', value: 'or' }
]
const suitableActivities = [
'祭祀', '祈福', '求嗣', '开光', '出行', '嫁娶', '订盟', '纳采', '裁衣', '安床',
'修造', '动土', '移徙', '入宅', '开市', '交易', '立券', '挂匾', '纳财', '开仓',
'出货财', '安机械', '会亲友', '进人口', '经络', '安葬', '破土', '谢土', '入殓', '移柩',
'治病', '针灸', '服药', '伐木', '作梁', '修坟', '造畜稠', '教牛马', '牧养', '纳畜',
'捕捉', '畋猎', '取鱼', '造船', '造桥', '开渠', '穿井', '作灶', '作厕', '安香'
]
const avoidActivities = [
'嫁娶', '开市', '动土', '安床', '破土', '安葬', '开仓', '出货财', '造船', '伐木',
'作梁', '修造', '移徙', '入宅', '出行', '交易', '立券', '纳采', '订盟', '祭祀',
'祈福', '求嗣', '开光', '裁衣', '安机械', '会亲友', '进人口', '经络', '入殓', '移柩',
'治病', '针灸', '服药', '修坟', '造畜稠', '教牛马', '牧养', '纳畜', '捕捉', '畋猎',
'取鱼', '开渠', '穿井', '作灶', '作厕', '安香', '挂匾', '纳财', '开仓'
]
const availableItems = computed(() => {
return localCondition.value.type === 'suitable' ? suitableActivities : avoidActivities
})
const toggleItem = (item: string) => {
const index = localCondition.value.items.indexOf(item)
if (index > -1) {
localCondition.value.items.splice(index, 1)
} else {
localCondition.value.items.push(item)
}
emit('update', { ...localCondition.value })
}
const handleTypeChange = () => {
localCondition.value.items = []
emit('update', { ...localCondition.value })
}
const handleOperatorChange = () => {
emit('update', { ...localCondition.value })
}
const toggleExclude = () => {
localCondition.value.exclude = !localCondition.value.exclude
emit('update', { ...localCondition.value })
}
const handleDelete = () => {
emit('delete')
}
watch(() => props.condition, (newCondition) => {
localCondition.value = { ...newCondition }
}, { deep: true })
</script>
<style scoped lang="scss">
.search-condition-item {
margin-bottom: 16px;
}
.condition-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.condition-body {
display: flex;
flex-direction: column;
gap: 16px;
}
.condition-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.section-label {
color: rgba(113, 113, 122, 255);
font-size: 12px;
line-height: 16px;
font-weight: 400;
}
.items-selector {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.item-tag {
padding: 6px 12px;
background-color: rgba(244, 244, 245, 255);
border: 1px solid rgba(230, 225, 220, 255);
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
&:active {
background-color: rgba(230, 225, 220, 255);
}
}
.item-tag-selected {
background-color: rgba(220, 252, 231, 1);
border-color: rgba(185, 248, 207, 1);
}
.item-text {
color: rgba(44, 24, 16, 255);
font-size: 13px;
line-height: 18px;
}
.exclude-toggle {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
cursor: pointer;
transition: all 0.3s ease;
&:active {
opacity: 0.7;
}
}
.exclude-label {
color: rgba(44, 24, 16, 255);
font-size: 14px;
line-height: 20px;
font-weight: 400;
}
@media screen and (max-width: 375px) {
.condition-body {
gap: 12px;
}
.condition-section {
gap: 6px;
}
.items-selector {
gap: 6px;
}
.item-tag {
padding: 5px 10px;
}
.item-text {
font-size: 12px;
line-height: 16px;
}
}
</style>
@@ -0,0 +1,176 @@
<template>
<Card class="search-history-panel">
<view class="panel-header">
<Typography variant="h4" weight="semibold">搜索历史</Typography>
<Button size="small" type="text" @click="handleClearHistory">
<Typography variant="caption" color="rgba(196, 30, 58, 255)">清除历史</Typography>
</Button>
</view>
<view v-if="history.length === 0" class="empty-history">
<EmptyState
icon="empty"
title="暂无搜索历史"
description="执行搜索后,搜索条件将显示在这里"
size="small"
/>
</view>
<view v-else class="history-list">
<view
v-for="item in history"
:key="item.id"
class="history-item"
@click="handleSelectHistory(item)"
>
<view class="history-content">
<Typography variant="body" class="history-title">
{{ formatHistoryTitle(item.condition) }}
</Typography>
<Typography variant="caption" class="history-time">
{{ formatTime(item.createdAt) }}
</Typography>
</view>
<Icon name="right" size="16rpx" color="rgba(113, 113, 122, 255)" />
</view>
</view>
</Card>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import type { SearchRequest } from '../../types/search'
import searchService from '../../services/searchService'
import Card from '../Card/Card.vue'
import Button from '../Button/Button.vue'
import Typography from '../Typography/Typography.vue'
import Icon from '../Icon/Icon.vue'
import EmptyState from '../EmptyState/index.vue'
interface HistoryItem {
id: string
condition: SearchRequest
createdAt: string
}
const emit = defineEmits<{
select: [condition: SearchRequest]
}>()
const history = ref<HistoryItem[]>([])
onMounted(() => {
loadHistory()
})
function loadHistory() {
history.value = searchService.getSearchHistory()
}
function handleSelectHistory(item: HistoryItem) {
emit('select', item.condition)
}
function handleClearHistory() {
uni.showModal({
title: '确认清除',
content: '确定要清除所有搜索历史吗?',
success: (res) => {
if (res.confirm) {
searchService.clearSearchHistory()
history.value = []
}
}
})
}
function formatHistoryTitle(condition: SearchRequest): string {
const conditionCount = condition.conditions.length
const days = condition.days
let title = `${conditionCount}个条件,${days}天范围`
if (conditionCount > 0) {
const firstCondition = condition.conditions[0]
const type = firstCondition.type === 'suitable' ? '宜' : '忌'
const items = firstCondition.items.slice(0, 2).join('、')
title += `${type}${items}${firstCondition.items.length > 2 ? '...' : ''}`
}
return title
}
function formatTime(timeStr: string): string {
const date = new Date(timeStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
if (diffHours < 1) {
return '刚刚'
} else if (diffHours < 24) {
return `${diffHours}小时前`
} else {
const diffDays = Math.floor(diffHours / 24)
if (diffDays < 7) {
return `${diffDays}天前`
} else {
return date.toLocaleDateString()
}
}
}
</script>
<style scoped lang="scss">
.search-history-panel {
margin-bottom: 16px;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.empty-history {
padding: 24px 0;
}
.history-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.history-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: rgba(244, 244, 245, 255);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
&:active {
background: rgba(230, 225, 220, 255);
}
}
.history-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.history-title {
color: var(--color-text);
}
.history-time {
color: var(--color-text-secondary);
}
</style>
@@ -0,0 +1,181 @@
<template>
<Card class="search-result-card">
<view class="card-header">
<Typography variant="h4" weight="semibold" class="date">{{ formatDate(result.date) }}</Typography>
<Typography variant="body" class="weekday">{{ result.weekday }}</Typography>
</view>
<view class="card-body">
<Typography variant="caption" class="lunar-date">{{ result.lunarDate }}</Typography>
<view v-if="result.matchedItems.suitable.length > 0" class="matched-items">
<Typography variant="caption" class="label suitable-label"></Typography>
<Typography variant="body" class="items suitable-items">{{ result.matchedItems.suitable.join('、') }}</Typography>
</view>
<view v-if="result.matchedItems.unsuitable.length > 0" class="matched-items">
<Typography variant="caption" class="label unsuitable-label"></Typography>
<Typography variant="body" class="items unsuitable-items">{{ result.matchedItems.unsuitable.join('、') }}</Typography>
</view>
<Typography variant="caption" class="match-count">匹配度: {{ result.matchCount }}</Typography>
</view>
</Card>
</template>
<script setup lang="ts">
import type { SearchResult } from '../../types/search'
import Card from '../Card/Card.vue'
import Typography from '../Typography/Typography.vue'
interface Props {
result: SearchResult
}
defineProps<Props>()
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}${month}${day}`
}
</script>
<style scoped lang="scss">
.search-result-card {
margin-bottom: 16px;
transition: all 0.3s ease;
&:active {
transform: scale(0.98);
}
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(230, 225, 220, 255);
}
.date {
color: rgba(44, 24, 16, 255);
font-size: 16px;
line-height: 24px;
font-weight: 600;
}
.weekday {
color: rgba(113, 113, 122, 255);
font-size: 14px;
line-height: 20px;
}
.card-body {
display: flex;
flex-direction: column;
gap: 8px;
}
.lunar-date {
color: rgba(113, 113, 122, 255);
font-size: 13px;
line-height: 18px;
margin-bottom: 4px;
}
.matched-items {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
background-color: rgba(244, 244, 245, 255);
border-radius: 8px;
}
.label {
font-size: 12px;
line-height: 16px;
font-weight: 600;
margin-bottom: 4px;
}
.suitable-label {
color: rgba(0, 130, 54, 1);
}
.unsuitable-label {
color: rgba(196, 30, 58, 255);
}
.items {
font-size: 14px;
line-height: 20px;
font-weight: 400;
}
.suitable-items {
color: rgba(1, 102, 48, 1);
}
.unsuitable-items {
color: rgba(196, 30, 58, 255);
}
.match-count {
color: rgba(113, 113, 122, 255);
font-size: 12px;
line-height: 16px;
text-align: right;
margin-top: 4px;
}
@media screen and (max-width: 375px) {
.card-header {
margin-bottom: 10px;
padding-bottom: 10px;
}
.date {
font-size: 15px;
line-height: 22px;
}
.weekday {
font-size: 13px;
line-height: 18px;
}
.card-body {
gap: 6px;
}
.lunar-date {
font-size: 12px;
line-height: 16px;
}
.matched-items {
padding: 6px;
}
.label {
font-size: 11px;
line-height: 15px;
}
.items {
font-size: 13px;
line-height: 18px;
}
.match-count {
font-size: 11px;
line-height: 15px;
}
}
</style>
@@ -0,0 +1,133 @@
<template>
<view class="search-result-list">
<view class="list-header">
<Typography variant="h4" weight="semibold">搜索结果</Typography>
<Typography variant="caption" class="result-count">{{ results.length }}条结果</Typography>
<SortSwitcher
:sortBy="sortBy"
:sortOrder="sortOrder"
@update:sortBy="handleSortByChange"
@update:sortOrder="handleSortOrderChange"
/>
</view>
<scroll-view
class="list-scroll"
scroll-y
@scrolltolower="handleScrollToLower"
>
<SearchResultCard
v-for="result in results"
:key="result.date"
:result="result"
/>
<view v-if="loadingMore" class="loading-more">
<LoadingIndicator text="加载中..." />
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { SearchResult } from '../../types/search'
import SearchResultCard from '../SearchResultCard/index.vue'
import SortSwitcher from '../SortSwitcher/index.vue'
import LoadingIndicator from '../LoadingIndicator/index.vue'
import Typography from '../Typography/Typography.vue'
interface Props {
results: SearchResult[]
sortBy: 'date' | 'matchCount'
sortOrder: 'asc' | 'desc'
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:sortBy': [value: 'date' | 'matchCount']
'update:sortOrder': [value: 'asc' | 'desc']
loadMore: []
}>()
const loadingMore = ref(false)
const handleSortByChange = (value: 'date' | 'matchCount') => {
emit('update:sortBy', value)
}
const handleSortOrderChange = (value: 'asc' | 'desc') => {
emit('update:sortOrder', value)
}
const handleScrollToLower = () => {
if (!loadingMore.value) {
loadingMore.value = true
setTimeout(() => {
emit('loadMore')
loadingMore.value = false
}, 300)
}
}
</script>
<style scoped lang="scss">
.search-result-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.list-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(230, 225, 220, 255);
}
.result-count {
color: rgba(113, 113, 122, 255);
font-size: 12px;
line-height: 16px;
}
.list-scroll {
max-height: 600px;
overflow-y: auto;
}
.loading-more {
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
@media screen and (max-width: 375px) {
.search-result-list {
gap: 12px;
}
.list-header {
gap: 6px;
padding-bottom: 10px;
}
.result-count {
font-size: 11px;
line-height: 15px;
}
.list-scroll {
max-height: 500px;
}
.loading-more {
padding: 16px;
}
}
</style>
@@ -0,0 +1,219 @@
<template>
<view
:class="[
'select',
{
'select--disabled': disabled,
'select--open': isOpen,
[`select--${size}`]: size
}
]"
@click="handleToggle"
>
<view class="select-trigger">
<Typography
v-if="selectedOption"
:variant="size === 'small' ? 'caption' : 'body'"
class="select-value"
>
{{ selectedOption.label }}
</Typography>
<Typography
v-else
:variant="size === 'small' ? 'caption' : 'body'"
class="select-placeholder"
>
{{ placeholder }}
</Typography>
<Icon
:name="isOpen ? 'arrow-up' : 'arrow-down'"
:size="size === 'small' ? '16rpx' : '20rpx'"
color="rgba(113, 113, 122, 255)"
class="select-arrow"
/>
</view>
<view v-if="isOpen" class="select-dropdown">
<view
v-for="option in options"
:key="option.value"
:class="[
'select-option',
{
'select-option--selected': value === option.value
}
]"
@click.stop="handleSelect(option)"
>
<Typography
:variant="size === 'small' ? 'caption' : 'body'"
class="select-option-label"
>
{{ option.label }}
</Typography>
<Icon
v-if="value === option.value"
name="check"
:size="size === 'small' ? '16rpx' : '20rpx'"
color="rgba(196, 30, 58, 255)"
class="select-option-check"
/>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import Typography from '@/components/Typography/Typography.vue'
import Icon from '@/components/Icon/Icon.vue'
interface Option {
label: string
value: string | number
}
interface Props {
modelValue: string | number
options: Option[]
placeholder?: string
disabled?: boolean
size?: 'small' | 'medium' | 'large'
}
const props = withDefaults(defineProps<Props>(), {
placeholder: '请选择',
disabled: false,
size: 'medium'
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string | number): void
(e: 'change', value: string | number): void
}>()
const isOpen = ref(false)
const selectedOption = computed(() => {
return props.options.find(option => option.value === props.modelValue)
})
function handleToggle() {
if (!props.disabled) {
isOpen.value = !isOpen.value
}
}
function handleSelect(option: Option) {
emit('update:modelValue', option.value)
emit('change', option.value)
isOpen.value = false
}
function closeDropdown() {
isOpen.value = false
}
defineExpose({
closeDropdown
})
</script>
<style scoped lang="scss">
.select {
position: relative;
width: 100%;
cursor: pointer;
&.select--disabled {
cursor: not-allowed;
opacity: 0.5;
}
&--small {
min-height: 64rpx;
}
&--medium {
min-height: 80rpx;
}
&--large {
min-height: 96rpx;
}
}
.select-trigger {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 24rpx;
background: var(--color-background);
border: 2rpx solid var(--color-border);
border-radius: var(--radius-md);
transition: all var(--transition-fast);
.select--open & {
border-color: var(--color-primary);
}
.select--disabled & {
background: var(--color-disabled);
}
}
.select-value {
flex: 1;
color: var(--color-text);
}
.select-placeholder {
flex: 1;
color: var(--color-text-secondary);
}
.select-arrow {
flex-shrink: 0;
margin-left: 16rpx;
}
.select-dropdown {
position: absolute;
top: calc(100% + 8rpx);
left: 0;
right: 0;
z-index: 100;
max-height: 400rpx;
overflow-y: auto;
background: var(--color-background);
border: 2rpx solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
}
.select-option {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 24rpx;
transition: all var(--transition-fast);
&:hover {
background: var(--color-hover);
}
&--selected {
background: var(--color-primary-light);
}
}
.select-option-label {
flex: 1;
color: var(--color-text);
}
.select-option-check {
flex-shrink: 0;
margin-left: 16rpx;
}
</style>
@@ -0,0 +1,133 @@
<template>
<view class="sort-switcher">
<Typography variant="caption" class="sort-label">排序</Typography>
<view class="sort-options">
<view
class="sort-option"
:class="{ 'sort-option-active': sortBy === 'date' }"
@click="handleSortByChange('date')"
>
<Typography variant="caption" class="sort-option-text">日期</Typography>
<Icon
v-if="sortBy === 'date'"
:name="sortOrder === 'asc' ? 'arrow-up' : 'arrow-down'"
size="14rpx"
color="rgba(196, 30, 58, 255)"
/>
</view>
<view
class="sort-option"
:class="{ 'sort-option-active': sortBy === 'matchCount' }"
@click="handleSortByChange('matchCount')"
>
<Typography variant="caption" class="sort-option-text">匹配度</Typography>
<Icon
v-if="sortBy === 'matchCount'"
:name="sortOrder === 'asc' ? 'arrow-up' : 'arrow-down'"
size="14rpx"
color="rgba(196, 30, 58, 255)"
/>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import Icon from '../Icon/Icon.vue'
import Typography from '../Typography/Typography.vue'
interface Props {
sortBy: 'date' | 'matchCount'
sortOrder: 'asc' | 'desc'
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:sortBy': [value: 'date' | 'matchCount']
'update:sortOrder': [value: 'asc' | 'desc']
}>()
const handleSortByChange = (value: 'date' | 'matchCount') => {
if (props.sortBy === value) {
const newSortOrder = props.sortOrder === 'asc' ? 'desc' : 'asc'
emit('update:sortOrder', newSortOrder)
} else {
emit('update:sortBy', value)
}
}
</script>
<style scoped lang="scss">
.sort-switcher {
display: flex;
align-items: center;
gap: 8px;
}
.sort-label {
color: rgba(113, 113, 122, 255);
font-size: 12px;
line-height: 16px;
font-weight: 400;
}
.sort-options {
display: flex;
gap: 4px;
}
.sort-option {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
background-color: rgba(244, 244, 245, 255);
border: 1px solid rgba(230, 225, 220, 255);
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
&:active {
background-color: rgba(230, 225, 220, 255);
}
}
.sort-option-active {
background-color: rgba(220, 252, 231, 1);
border-color: rgba(185, 248, 207, 1);
}
.sort-option-text {
color: rgba(44, 24, 16, 255);
font-size: 12px;
line-height: 16px;
font-weight: 400;
}
@media screen and (max-width: 375px) {
.sort-switcher {
gap: 6px;
}
.sort-label {
font-size: 11px;
line-height: 15px;
}
.sort-options {
gap: 3px;
}
.sort-option {
padding: 5px 10px;
}
.sort-option-text {
font-size: 11px;
line-height: 15px;
}
}
</style>
@@ -0,0 +1,238 @@
<template>
<Card class="template-panel">
<view class="panel-header">
<Typography variant="h4" weight="semibold">搜索模板</Typography>
<Button size="small" type="text" @click="handleRefresh">
<Icon name="refresh" size="16rpx" color="rgba(113, 113, 122, 255)" />
</Button>
</view>
<view class="search-bar">
<view class="search-input">
<Icon name="search" size="16rpx" color="rgba(113, 113, 122, 255)" />
<input
v-model="searchKeyword"
placeholder="搜索模板"
class="input-field"
/>
</view>
</view>
<view class="category-tabs">
<view
v-for="category in categories"
:key="category"
class="category-tab"
:class="{ 'category-tab--active': selectedCategory === category }"
@click="selectCategory(category)"
>
<Typography variant="body" class="category-label">{{ category }}</Typography>
</view>
</view>
<view v-if="filteredTemplates.length === 0" class="empty-templates">
<EmptyState
icon="empty"
title="暂无模板"
description="没有找到符合条件的模板"
size="small"
/>
</view>
<view v-else class="template-list">
<view
v-for="template in filteredTemplates"
:key="template.id"
class="template-item"
@click="handleSelectTemplate(template)"
>
<view class="template-content">
<Typography variant="body" weight="semibold" class="template-name">
{{ template.name }}
</Typography>
<Typography variant="caption" class="template-description">
{{ template.description }}
</Typography>
<Typography variant="caption" class="template-category">
{{ template.category }}
</Typography>
</view>
<Icon name="right" size="16rpx" color="rgba(113, 113, 122, 255)" />
</view>
</view>
</Card>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { SearchRequest, SearchTemplate } from '../../types/search'
import templateService from '../../services/templateService'
import Card from '../Card/Card.vue'
import Button from '../Button/Button.vue'
import Icon from '../Icon/Icon.vue'
import Typography from '../Typography/Typography.vue'
import EmptyState from '../EmptyState/index.vue'
const emit = defineEmits<{
select: [condition: SearchRequest]
}>()
const searchKeyword = ref('')
const selectedCategory = ref('全部')
const templates = ref<SearchTemplate[]>([])
const categories = computed(() => {
const allCategories = templateService.getCategories()
return ['全部', ...allCategories]
})
const filteredTemplates = computed(() => {
let result = templates.value
if (selectedCategory.value !== '全部') {
result = templateService.getTemplatesByCategory(selectedCategory.value)
}
if (searchKeyword.value.trim()) {
result = templateService.searchTemplates(searchKeyword.value.trim())
}
return result
})
onMounted(() => {
loadTemplates()
})
function loadTemplates() {
templates.value = templateService.getTemplates()
}
function handleRefresh() {
loadTemplates()
}
function selectCategory(category: string) {
selectedCategory.value = category
}
function handleSelectTemplate(template: SearchTemplate) {
emit('select', template.condition)
}
</script>
<style scoped lang="scss">
.template-panel {
margin-bottom: 16px;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.search-bar {
margin-bottom: 16px;
}
.search-input {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: rgba(244, 244, 245, 255);
border-radius: var(--radius-md);
}
.input-field {
flex: 1;
border: none;
background: transparent;
font-size: 14px;
color: var(--color-text);
outline: none;
&::placeholder {
color: var(--color-text-secondary);
}
}
.category-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.category-tab {
padding: 6px 12px;
background: rgba(244, 244, 245, 255);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
&:active {
background: rgba(230, 225, 220, 255);
}
}
.category-tab--active {
background: var(--color-primary);
}
.category-label {
color: var(--color-text);
font-size: 13px;
.category-tab--active & {
color: #FFFFFF;
}
}
.empty-templates {
padding: 24px 0;
}
.template-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.template-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
background: rgba(244, 244, 245, 255);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
&:active {
background: rgba(230, 225, 220, 255);
}
}
.template-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.template-name {
color: var(--color-text);
}
.template-description {
color: var(--color-text-secondary);
}
.template-category {
color: var(--color-primary);
font-weight: 500;
}
</style>
@@ -0,0 +1,180 @@
<template>
<view
class="touchable-container"
:class="{ 'is-active': isActive, 'is-disabled': disabled }"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
@touchcancel="handleTouchCancel"
@click="handleClick"
@longpress="handleLongPress"
>
<slot></slot>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
interface Props {
disabled?: boolean
activeOpacity?: number
activeScale?: number
longPressDelay?: number
enableHapticFeedback?: boolean
enableRipple?: boolean
}
interface Emits {
(e: 'click', event: any): void
(e: 'longpress', event: any): void
(e: 'touchstart', event: any): void
(e: 'touchend', event: any): void
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
activeOpacity: 0.7,
activeScale: 0.95,
longPressDelay: 500,
enableHapticFeedback: true,
enableRipple: true
})
const emit = defineEmits<Emits>()
const isActive = ref(false)
const touchStartTime = ref(0)
const longPressTimer = ref<NodeJS.Timeout | null>(null)
const touchStartPos = ref({ x: 0, y: 0 })
const handleTouchStart = (event: any) => {
if (props.disabled) return
touchStartTime.value = Date.now()
touchStartPos.value = {
x: event.touches[0].clientX,
y: event.touches[0].clientY
}
isActive.value = true
if (props.enableHapticFeedback) {
uni.vibrateShort({
type: 'light'
})
}
longPressTimer.value = setTimeout(() => {
handleLongPress(event)
}, props.longPressDelay)
emit('touchstart', event)
}
const handleTouchMove = (event: any) => {
if (props.disabled) return
const moveThreshold = 10
const deltaX = Math.abs(event.touches[0].clientX - touchStartPos.value.x)
const deltaY = Math.abs(event.touches[0].clientY - touchStartPos.value.y)
if (deltaX > moveThreshold || deltaY > moveThreshold) {
if (longPressTimer.value) {
clearTimeout(longPressTimer.value)
longPressTimer.value = null
}
}
}
const handleTouchEnd = (event: any) => {
if (props.disabled) return
if (longPressTimer.value) {
clearTimeout(longPressTimer.value)
longPressTimer.value = null
}
const touchDuration = Date.now() - touchStartTime.value
if (touchDuration < props.longPressDelay) {
handleClick(event)
}
isActive.value = false
emit('touchend', event)
}
const handleTouchCancel = () => {
if (longPressTimer.value) {
clearTimeout(longPressTimer.value)
longPressTimer.value = null
}
isActive.value = false
}
const handleClick = (event: any) => {
if (props.disabled) return
if (props.enableHapticFeedback) {
uni.vibrateShort({
type: 'light'
})
}
emit('click', event)
}
const handleLongPress = (event: any) => {
if (props.disabled) return
if (props.enableHapticFeedback) {
uni.vibrateShort({
type: 'heavy'
})
}
emit('longpress', event)
}
</script>
<style scoped lang="scss">
.touchable-container {
position: relative;
overflow: hidden;
transition: opacity 0.15s ease, transform 0.15s ease;
user-select: none;
-webkit-tap-highlight-color: transparent;
&.is-active {
opacity: v-bind(activeOpacity);
transform: scale(v-bind(activeScale));
}
&.is-disabled {
opacity: 0.5;
pointer-events: none;
}
}
.touchable-container::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 100%;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
transform: translate(-50%, -50%) scale(0);
opacity: 0;
pointer-events: none;
transition: transform 0.3s ease, opacity 0.3s ease;
}
.touchable-container.is-active::after {
transform: translate(-50%, -50%) scale(1.5);
opacity: 1;
}
</style>