feat(i18n): 改造10个组件为i18n国际化,同步更新9种语言翻译文件

- 组件改造:EmptyState、ExportPanel、SearchConditionItem、SearchHistoryPanel、
  SearchResultCard、SearchResultList、Select、SortSwitcher、TemplatePanel、
  BottomNavigation、SearchConditionPanel
- 替换硬编码中文为 $t() 调用和 computed 响应式翻译
- 新增翻译键:search(搜索历史/模板/条件)、export(导出选项)、
  common.pleaseSelect 等
- 同步更新 zh-CN、en、zh-TW、ja、ko、es、fr、de、pt 共9种语言文件
- 重构 errorHandler:从 API 错误处理改为本地应用错误处理
- 移除 search.ts 中未使用的 SearchService 接口
This commit is contained in:
张翔
2026-04-29 19:13:55 +08:00
parent 70d3e3a277
commit 330c3af227
26 changed files with 1335 additions and 490 deletions
@@ -0,0 +1,113 @@
<template>
<view class="bottom-navigation">
<view
v-for="tab in tabs"
:key="tab.key"
:class="['nav-item', { 'nav-item--active': currentTab === tab.key }]"
@click="switchTab(tab.key)"
>
<Icon
:name="tab.icon"
:size="'22rpx'"
:color="currentTab === tab.key ? 'rgba(196, 30, 58, 255)' : 'rgba(113, 113, 122, 255)'"
/>
<text :class="['nav-label', { 'nav-label--active': currentTab === tab.key }]">
{{ tab.label }}
</text>
</view>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Icon from '../Icon/Icon.vue'
const { t } = useI18n()
interface Tab {
key: string
label: string
icon: string
path: string
}
interface Props {
currentTab: string
}
withDefaults(defineProps<Props>(), {
currentTab: 'almanac'
})
const tabs = computed<Tab[]>(() => [
{ key: 'almanac', label: t('nav.almanac'), icon: 'calendar', path: '/pages/almanac-search/index' },
{ key: 'ziwei', label: t('nav.ziwei'), icon: 'star', path: '/pages/ziwei/index' },
{ key: 'fortune', label: t('nav.fortune'), icon: 'heart', path: '/pages/fortune/index' }
])
const switchTab = (key: string) => {
const tab = tabs.find(t => t.key === key)
if (tab) {
uni.navigateTo({ url: tab.path })
}
}
</script>
<style scoped lang="scss">
.bottom-navigation {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
justify-content: space-around;
height: 56px;
background-color: rgba(255, 255, 255, 255);
border-top: 1px solid rgba(230, 225, 220, 255);
padding-bottom: env(safe-area-inset-bottom);
z-index: 100;
}
.nav-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 6px 16px;
cursor: pointer;
transition: all 0.2s ease;
&:active {
transform: scale(0.95);
}
}
.nav-label {
font-size: 10px;
line-height: 14px;
color: rgba(113, 113, 122, 255);
font-weight: 400;
&--active {
color: rgba(196, 30, 58, 255);
font-weight: 500;
}
}
@media screen and (max-width: 375px) {
.bottom-navigation {
height: 50px;
}
.nav-item {
padding: 4px 12px;
}
.nav-label {
font-size: 9px;
}
}
</style>
@@ -0,0 +1,204 @@
<template>
<view
:class="[
'button',
`button--${type}`,
`button--${size}`,
{
'button--block': block,
'button--disabled': disabled,
'button--loading': loading
}
]"
@click="handleClick"
>
<view v-if="loading" class="button-spinner"></view>
<Icon v-if="icon && !loading" :name="icon" :size="iconSize" :color="iconColor" />
<text class="button-text"><slot /></text>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Icon from '../Icon/Icon.vue'
interface Props {
type?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger'
size?: 'small' | 'medium' | 'large'
block?: boolean
disabled?: boolean
loading?: boolean
icon?: string
}
const props = withDefaults(defineProps<Props>(), {
type: 'primary',
size: 'medium',
block: false,
disabled: false,
loading: false
})
const emit = defineEmits<{
click: []
}>()
const iconSize = computed(() => {
switch (props.size) {
case 'small': return '14rpx'
case 'large': return '22rpx'
default: return '18rpx'
}
})
const iconColor = computed(() => {
if (props.type === 'primary') return '#ffffff'
return 'rgba(44, 24, 16, 255)'
})
const handleClick = () => {
if (!props.disabled && !props.loading) {
emit('click')
}
}
</script>
<style scoped lang="scss">
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 8px;
font-weight: 500;
transition: all 0.3s ease;
cursor: pointer;
border: 1px solid transparent;
white-space: nowrap;
&--small {
padding: 6px 12px;
font-size: 12px;
line-height: 16px;
min-height: 32px;
}
&--medium {
padding: 8px 16px;
font-size: 14px;
line-height: 20px;
min-height: 40px;
}
&--large {
padding: 12px 24px;
font-size: 16px;
line-height: 24px;
min-height: 48px;
}
&--block {
width: 100%;
}
&--disabled {
opacity: 0.5;
cursor: not-allowed;
}
&--loading {
cursor: wait;
}
}
.button--primary {
background-color: rgba(196, 30, 58, 255);
color: #ffffff;
&:active:not(.button--disabled):not(.button--loading) {
background-color: rgba(170, 20, 45, 255);
}
}
.button--secondary {
background-color: rgba(244, 244, 245, 255);
color: rgba(44, 24, 16, 255);
border-color: rgba(230, 225, 220, 255);
&:active:not(.button--disabled):not(.button--loading) {
background-color: rgba(230, 225, 220, 255);
}
}
.button--outline {
background-color: transparent;
color: rgba(196, 30, 58, 255);
border-color: rgba(196, 30, 58, 255);
&:active:not(.button--disabled):not(.button--loading) {
background-color: rgba(196, 30, 58, 0.05);
}
}
.button--ghost {
background-color: transparent;
color: rgba(44, 24, 16, 255);
&:active:not(.button--disabled):not(.button--loading) {
background-color: rgba(244, 244, 245, 255);
}
}
.button--danger {
background-color: rgba(220, 38, 38, 255);
color: #ffffff;
&:active:not(.button--disabled):not(.button--loading) {
background-color: rgba(185, 28, 28, 255);
}
}
.button-text {
line-height: 1;
}
.button-spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top: 2px solid #ffffff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.button--secondary .button-spinner,
.button--ghost .button-spinner {
border-color: rgba(44, 24, 16, 0.2);
border-top-color: rgba(44, 24, 16, 0.8);
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@media screen and (max-width: 375px) {
.button--small {
padding: 5px 10px;
font-size: 11px;
min-height: 28px;
}
.button--medium {
padding: 7px 14px;
font-size: 13px;
min-height: 36px;
}
.button--large {
padding: 10px 20px;
font-size: 15px;
min-height: 44px;
}
}
</style>
@@ -0,0 +1,84 @@
<template>
<view :class="['card', { 'card--clickable': clickable, 'card--bordered': bordered }]" @click="handleClick">
<view v-if="$slots.header" class="card-header">
<slot name="header" />
</view>
<view class="card-body">
<slot />
</view>
<view v-if="$slots.footer" class="card-footer">
<slot name="footer" />
</view>
</view>
</template>
<script setup lang="ts">
interface Props {
clickable?: boolean
bordered?: boolean
}
withDefaults(defineProps<Props>(), {
clickable: false,
bordered: true
})
const emit = defineEmits<{
click: []
}>()
const handleClick = () => {
emit('click')
}
</script>
<style scoped lang="scss">
.card {
background-color: rgba(255, 255, 255, 255);
border-radius: 12px;
overflow: hidden;
transition: all 0.3s ease;
&--bordered {
border: 1px solid rgba(230, 225, 220, 255);
}
&--clickable {
cursor: pointer;
&:active {
transform: scale(0.98);
background-color: rgba(244, 244, 245, 255);
}
}
}
.card-header {
padding: 16px;
border-bottom: 1px solid rgba(230, 225, 220, 255);
}
.card-body {
padding: 16px;
}
.card-footer {
padding: 12px 16px;
border-top: 1px solid rgba(230, 225, 220, 255);
background-color: rgba(244, 244, 245, 0.3);
}
@media screen and (max-width: 375px) {
.card-header {
padding: 12px;
}
.card-body {
padding: 12px;
}
.card-footer {
padding: 10px 12px;
}
}
</style>
@@ -1,20 +1,23 @@
<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>
<Typography variant="h4" weight="semibold" class="empty-title">{{ displayTitle }}</Typography>
<Typography variant="body" class="empty-description">{{ displayDescription }}</Typography>
<Button v-if="showAction" type="primary" @click="handleAction">
{{ actionText }}
{{ displayActionText }}
</Button>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Icon from '../Icon/Icon.vue'
import Typography from '../Typography/Typography.vue'
import Button from '../Button/Button.vue'
const { t } = useI18n()
interface Props {
icon?: string
title?: string
@@ -26,13 +29,17 @@ interface Props {
const props = withDefaults(defineProps<Props>(), {
icon: 'empty',
title: '暂无数据',
description: '没有找到相关结果',
title: undefined,
description: undefined,
showAction: true,
actionText: '重新搜索',
actionText: undefined,
size: 'medium'
})
const displayTitle = computed(() => props.title ?? t('common.noData'))
const displayDescription = computed(() => props.description ?? t('common.noResult'))
const displayActionText = computed(() => props.actionText ?? t('common.retry'))
const emit = defineEmits<{
action: []
}>()
@@ -6,13 +6,13 @@
@click="handleExport"
>
<Icon name="download" size="16rpx" color="#FFFFFF" />
<Typography variant="body" color="#FFFFFF">导出结果</Typography>
<Typography variant="body" color="#FFFFFF">{{ $t('export.exportResults') }}</Typography>
</Button>
<view v-if="showExportModal" class="export-modal">
<view class="modal-content">
<view class="modal-header">
<Typography variant="h4" weight="semibold">导出搜索结果</Typography>
<Typography variant="h4" weight="semibold">{{ $t('export.exportSearchResults') }}</Typography>
<Button size="small" type="text" @click="closeModal">
<Icon name="close" size="16rpx" color="rgba(113, 113, 122, 255)" />
</Button>
@@ -20,7 +20,7 @@
<view class="modal-body">
<view class="export-options">
<Typography variant="body" class="option-title">选择导出格式</Typography>
<Typography variant="body" class="option-title">{{ $t('export.selectFormat') }}</Typography>
<view class="format-options">
<view
v-for="format in exportFormats"
@@ -40,7 +40,7 @@
</view>
<view class="export-options">
<Typography variant="body" class="option-title">选择导出内容</Typography>
<Typography variant="body" class="option-title">{{ $t('export.selectContent') }}</Typography>
<view class="content-options">
<view
v-for="option in contentOptions"
@@ -61,8 +61,8 @@
</view>
<view class="modal-footer">
<Button type="text" @click="closeModal">取消</Button>
<Button type="primary" @click="confirmExport">导出</Button>
<Button type="text" @click="closeModal">{{ $t('common.cancel') }}</Button>
<Button type="primary" @click="confirmExport">{{ $t('export.export') }}</Button>
</view>
</view>
</view>
@@ -70,7 +70,8 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { SearchResult } from '../../types/search'
import Button from '../Button/Button.vue'
import Icon from '../Icon/Icon.vue'
@@ -86,6 +87,8 @@ const emit = defineEmits<{
export: [format: string, content: string[]]
}>()
const { t } = useI18n()
const showExportModal = ref(false)
const selectedFormat = ref('excel')
const selectedContent = ref(['date', 'lunarDate', 'matchedItems'])
@@ -96,13 +99,13 @@ const exportFormats = [
{ label: 'CSV (.csv)', value: 'csv' }
]
const contentOptions = [
{ label: '日期', value: 'date' },
{ label: '农历日期', value: 'lunarDate' },
{ label: '星期', value: 'weekday' },
{ label: '匹配事项', value: 'matchedItems' },
{ label: '匹配度', value: 'matchCount' }
]
const contentOptions = computed(() => [
{ label: t('export.contentDate'), value: 'date' },
{ label: t('export.contentLunarDate'), value: 'lunarDate' },
{ label: t('export.contentWeekday'), value: 'weekday' },
{ label: t('export.contentMatchedItems'), value: 'matchedItems' },
{ label: t('export.contentMatchCount'), value: 'matchCount' }
])
function handleExport() {
showExportModal.value = true
@@ -0,0 +1,96 @@
<template>
<view class="icon-wrapper" :style="iconStyle" @click="$emit('click')">
<text class="icon-text" :style="textStyle">{{ iconChar }}</text>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
name: string
size?: string
color?: string
}
const props = withDefaults(defineProps<Props>(), {
size: '20rpx',
color: 'rgba(44, 24, 16, 255)'
})
defineEmits<{
click: []
}>()
const iconMap: Record<string, string> = {
'arrow-up': '↑',
'arrow-down': '↓',
'arrow-left': '←',
'arrow-right': '→',
'check': '✓',
'close': '✕',
'plus': '+',
'minus': '−',
'search': '🔍',
'empty': '📭',
'calendar': '📅',
'star': '★',
'heart': '♥',
'settings': '⚙',
'home': '🏠',
'user': '👤',
'info': 'ℹ',
'warning': '⚠',
'error': '✕',
'success': '✓',
'refresh': '↻',
'download': '⬇',
'upload': '⬆',
'share': '↗',
'copy': '📋',
'delete': '🗑',
'edit': '✎',
'filter': '⊞',
'sort': '⇅',
'export': '📤',
'import': '📥',
'history': '🕐',
'bookmark': '🔖',
'chevron-up': '‹',
'chevron-down': '›',
'chevron-left': '‹',
'chevron-right': '›'
}
const iconChar = computed(() => {
return iconMap[props.name] || props.name.charAt(0).toUpperCase()
})
const iconStyle = computed(() => ({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: props.size,
height: props.size
}))
const textStyle = computed(() => ({
fontSize: props.size,
color: props.color,
lineHeight: '1'
}))
</script>
<style scoped>
.icon-wrapper {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.icon-text {
text-align: center;
line-height: 1;
}
</style>
@@ -13,7 +13,7 @@
<view class="condition-body">
<view class="condition-section">
<Typography variant="caption" class="section-label">事项</Typography>
<Typography variant="caption" class="section-label">{{ $t('search.activityItems') }}</Typography>
<view class="items-selector">
<view
v-for="item in availableItems"
@@ -28,7 +28,7 @@
</view>
<view class="condition-section">
<Typography variant="caption" class="section-label">逻辑运算符</Typography>
<Typography variant="caption" class="section-label">{{ $t('search.logicalOperator') }}</Typography>
<Select
v-model="localCondition.operator"
:options="operatorOptions"
@@ -43,7 +43,7 @@
size="20rpx"
:color="localCondition.exclude ? 'rgba(196, 30, 58, 255)' : 'rgba(113, 113, 122, 255)'"
/>
<Typography variant="body" class="exclude-label">排除条件</Typography>
<Typography variant="body" class="exclude-label">{{ $t('search.excludeCondition') }}</Typography>
</view>
</view>
</view>
@@ -52,6 +52,7 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { SearchCondition } from '../../types/search'
import Card from '../Card/Card.vue'
import Button from '../Button/Button.vue'
@@ -70,17 +71,19 @@ const emit = defineEmits<{
delete: []
}>()
const { t } = useI18n()
const localCondition = ref<SearchCondition>({ ...props.condition })
const typeOptions = [
{ label: '宜', value: 'suitable' },
{ label: '忌', value: 'unsuitable' }
]
const typeOptions = computed(() => [
{ label: t('search.suitable'), value: 'suitable' },
{ label: t('search.unsuitable'), value: 'unsuitable' }
])
const operatorOptions = [
{ label: '与(AND)', value: 'and' },
{ label: '或(OR)', value: 'or' }
]
const operatorOptions = computed(() => [
{ label: t('search.operatorAnd'), value: 'and' },
{ label: t('search.operatorOr'), value: 'or' }
])
const suitableActivities = [
'祭祀', '祈福', '求嗣', '开光', '出行', '嫁娶', '订盟', '纳采', '裁衣', '安床',
@@ -0,0 +1,202 @@
<template>
<Card class="search-condition-panel">
<view class="panel-header">
<Typography variant="h4" weight="semibold" class="panel-title">{{ $t('search.searchConditions') }}</Typography>
<Button size="small" type="primary" icon="plus" @click="addCondition">{{ $t('search.addCondition') }}</Button>
</view>
<view v-if="conditions.length === 0" class="empty-conditions">
<Typography variant="body" class="empty-text">{{ $t('search.addConditionHint') }}</Typography>
</view>
<view v-else class="conditions-list">
<SearchConditionItem
v-for="(condition, index) in conditions"
:key="condition.id"
:condition="condition"
@update="updateCondition(index, $event)"
@delete="removeCondition(index)"
/>
</view>
<view class="days-selector">
<Typography variant="body" class="days-label">{{ $t('search.searchDays') }}</Typography>
<view class="days-options">
<view
v-for="dayOption in dayOptions"
:key="dayOption"
:class="['day-tag', { 'day-tag-selected': days === dayOption }]"
@click="updateDays(dayOption)"
>
<Typography variant="caption" class="day-text">{{ $t('search.daysUnit', { count: dayOption }) }}</Typography>
</view>
</view>
</view>
<view class="panel-footer">
<Button type="primary" block @click="handleSearch">{{ $t('common.search') }}</Button>
</view>
</Card>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { SearchCondition } from '../../types/search'
import Card from '../Card/Card.vue'
import Button from '../Button/Button.vue'
import Typography from '../Typography/Typography.vue'
import SearchConditionItem from '../SearchConditionItem/index.vue'
interface Props {
conditions: SearchCondition[]
days: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:conditions': [conditions: SearchCondition[]]
'update:days': [days: number]
search: []
}>()
const dayOptions = [7, 15, 30, 60, 90]
const addCondition = () => {
const newCondition: SearchCondition = {
id: Date.now().toString(),
type: 'suitable',
items: [],
operator: 'and',
exclude: false
}
const updated = [...props.conditions, newCondition]
emit('update:conditions', updated)
}
const updateCondition = (index: number, condition: SearchCondition) => {
const updated = [...props.conditions]
updated[index] = condition
emit('update:conditions', updated)
}
const removeCondition = (index: number) => {
const updated = props.conditions.filter((_, i) => i !== index)
emit('update:conditions', updated)
}
const updateDays = (days: number) => {
emit('update:days', days)
}
const handleSearch = () => {
emit('search')
}
</script>
<style scoped lang="scss">
.search-condition-panel {
margin-bottom: 16px;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.panel-title {
color: rgba(44, 24, 16, 255);
font-size: 18px;
line-height: 28px;
font-weight: 600;
}
.empty-conditions {
display: flex;
align-items: center;
justify-content: center;
padding: 32px 16px;
}
.empty-text {
color: rgba(113, 113, 122, 255);
font-size: 14px;
line-height: 20px;
text-align: center;
}
.conditions-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
}
.days-selector {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.days-label {
color: rgba(44, 24, 16, 255);
font-size: 14px;
line-height: 20px;
font-weight: 500;
}
.days-options {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.day-tag {
padding: 6px 14px;
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);
}
}
.day-tag-selected {
background-color: rgba(220, 252, 231, 1);
border-color: rgba(185, 248, 207, 1);
}
.day-text {
color: rgba(44, 24, 16, 255);
font-size: 13px;
line-height: 18px;
}
.panel-footer {
margin-top: 8px;
}
@media screen and (max-width: 375px) {
.panel-header {
margin-bottom: 12px;
}
.conditions-list {
gap: 10px;
}
.days-options {
gap: 6px;
}
.day-tag {
padding: 5px 12px;
}
}
</style>
@@ -1,17 +1,17 @@
<template>
<Card class="search-history-panel">
<view class="panel-header">
<Typography variant="h4" weight="semibold">搜索历史</Typography>
<Typography variant="h4" weight="semibold">{{ $t('search.searchHistory') }}</Typography>
<Button size="small" type="text" @click="handleClearHistory">
<Typography variant="caption" color="rgba(196, 30, 58, 255)">清除历史</Typography>
<Typography variant="caption" color="rgba(196, 30, 58, 255)">{{ $t('search.clearHistory') }}</Typography>
</Button>
</view>
<view v-if="history.length === 0" class="empty-history">
<EmptyState
icon="empty"
title="暂无搜索历史"
description="执行搜索后,搜索条件将显示在这里"
:title="$t('search.noHistoryTitle')"
:description="$t('search.noHistoryDesc')"
size="small"
/>
</view>
@@ -39,6 +39,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import type { SearchRequest } from '../../types/search'
import searchService from '../../services/searchService'
import Card from '../Card/Card.vue'
@@ -53,6 +54,8 @@ interface HistoryItem {
createdAt: string
}
const { t } = useI18n()
const emit = defineEmits<{
select: [condition: SearchRequest]
}>()
@@ -73,8 +76,8 @@ function handleSelectHistory(item: HistoryItem) {
function handleClearHistory() {
uni.showModal({
title: '确认清除',
content: '确定要清除所有搜索历史吗?',
title: t('search.confirmClear'),
content: t('search.confirmClearContent'),
success: (res) => {
if (res.confirm) {
searchService.clearSearchHistory()
@@ -88,11 +91,11 @@ function formatHistoryTitle(condition: SearchRequest): string {
const conditionCount = condition.conditions.length
const days = condition.days
let title = `${conditionCount}个条件,${days}天范围`
let title = t('search.historyConditionDays', { count: conditionCount, days })
if (conditionCount > 0) {
const firstCondition = condition.conditions[0]
const type = firstCondition.type === 'suitable' ? '宜' : '忌'
const type = firstCondition.type === 'suitable' ? t('search.suitable') : t('search.unsuitable')
const items = firstCondition.items.slice(0, 2).join('、')
title += `(${type}${items}${firstCondition.items.length > 2 ? '...' : ''})`
@@ -108,13 +111,13 @@ function formatTime(timeStr: string): string {
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
if (diffHours < 1) {
return '刚刚'
return t('search.justNow')
} else if (diffHours < 24) {
return `${diffHours}小时前`
return t('search.hoursAgo', { count: diffHours })
} else {
const diffDays = Math.floor(diffHours / 24)
if (diffDays < 7) {
return `${diffDays}天前`
return t('search.daysAgo', { count: diffDays })
} else {
return date.toLocaleDateString()
}
@@ -9,21 +9,22 @@
<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="caption" class="label suitable-label">{{ $t('search.suitable') }}</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="caption" class="label unsuitable-label">{{ $t('search.unsuitable') }}</Typography>
<Typography variant="body" class="items unsuitable-items">{{ result.matchedItems.unsuitable.join('、') }}</Typography>
</view>
<Typography variant="caption" class="match-count">匹配度: {{ result.matchCount }}</Typography>
<Typography variant="caption" class="match-count">{{ $t('search.matchCount') }}: {{ result.matchCount }}</Typography>
</view>
</Card>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { SearchResult } from '../../types/search'
import Card from '../Card/Card.vue'
import Typography from '../Typography/Typography.vue'
@@ -34,12 +35,14 @@ interface Props {
defineProps<Props>()
const { t } = useI18n()
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}日`
return t('search.dateFormat', { year, month, day })
}
</script>
@@ -1,8 +1,8 @@
<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>
<Typography variant="h4" weight="semibold">{{ $t('search.searchResults') }}</Typography>
<Typography variant="caption" class="result-count">{{ $t('search.resultCount', { count: results.length }) }}</Typography>
<SortSwitcher
:sortBy="sortBy"
:sortOrder="sortOrder"
@@ -23,7 +23,7 @@
/>
<view v-if="loadingMore" class="loading-more">
<LoadingIndicator text="加载中..." />
<LoadingIndicator :text="$t('common.loading')" />
</view>
</scroll-view>
</view>
@@ -23,7 +23,7 @@
:variant="size === 'small' ? 'caption' : 'body'"
class="select-placeholder"
>
{{ placeholder }}
{{ displayPlaceholder }}
</Typography>
<Icon
:name="isOpen ? 'arrow-up' : 'arrow-down'"
@@ -65,6 +65,7 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Typography from '@/components/Typography/Typography.vue'
import Icon from '@/components/Icon/Icon.vue'
@@ -81,12 +82,16 @@ interface Props {
size?: 'small' | 'medium' | 'large'
}
const { t } = useI18n()
const props = withDefaults(defineProps<Props>(), {
placeholder: '请选择',
placeholder: undefined,
disabled: false,
size: 'medium'
})
const displayPlaceholder = computed(() => props.placeholder ?? t('common.pleaseSelect'))
const emit = defineEmits<{
(e: 'update:modelValue', value: string | number): void
(e: 'change', value: string | number): void
@@ -1,6 +1,6 @@
<template>
<view class="sort-switcher">
<Typography variant="caption" class="sort-label">排序</Typography>
<Typography variant="caption" class="sort-label">{{ $t('search.sort') }}</Typography>
<view class="sort-options">
<view
@@ -8,7 +8,7 @@
:class="{ 'sort-option-active': sortBy === 'date' }"
@click="handleSortByChange('date')"
>
<Typography variant="caption" class="sort-option-text">日期</Typography>
<Typography variant="caption" class="sort-option-text">{{ $t('search.sortByDate') }}</Typography>
<Icon
v-if="sortBy === 'date'"
:name="sortOrder === 'asc' ? 'arrow-up' : 'arrow-down'"
@@ -22,7 +22,7 @@
:class="{ 'sort-option-active': sortBy === 'matchCount' }"
@click="handleSortByChange('matchCount')"
>
<Typography variant="caption" class="sort-option-text">匹配度</Typography>
<Typography variant="caption" class="sort-option-text">{{ $t('search.sortByMatch') }}</Typography>
<Icon
v-if="sortBy === 'matchCount'"
:name="sortOrder === 'asc' ? 'arrow-up' : 'arrow-down'"
@@ -1,7 +1,7 @@
<template>
<Card class="template-panel">
<view class="panel-header">
<Typography variant="h4" weight="semibold">搜索模板</Typography>
<Typography variant="h4" weight="semibold">{{ $t('search.searchTemplates') }}</Typography>
<Button size="small" type="text" @click="handleRefresh">
<Icon name="refresh" size="16rpx" color="rgba(113, 113, 122, 255)" />
</Button>
@@ -12,7 +12,7 @@
<Icon name="search" size="16rpx" color="rgba(113, 113, 122, 255)" />
<input
v-model="searchKeyword"
placeholder="搜索模板"
:placeholder="$t('search.searchTemplates')"
class="input-field"
/>
</view>
@@ -33,8 +33,8 @@
<view v-if="filteredTemplates.length === 0" class="empty-templates">
<EmptyState
icon="empty"
title="暂无模板"
description="没有找到符合条件的模板"
:title="$t('search.noTemplatesTitle')"
:description="$t('search.noTemplatesDesc')"
size="small"
/>
</view>
@@ -65,6 +65,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import type { SearchRequest, SearchTemplate } from '../../types/search'
import templateService from '../../services/templateService'
import Card from '../Card/Card.vue'
@@ -73,23 +74,25 @@ import Icon from '../Icon/Icon.vue'
import Typography from '../Typography/Typography.vue'
import EmptyState from '../EmptyState/index.vue'
const { t } = useI18n()
const emit = defineEmits<{
select: [condition: SearchRequest]
}>()
const searchKeyword = ref('')
const selectedCategory = ref('全部')
const selectedCategory = ref('__all__')
const templates = ref<SearchTemplate[]>([])
const categories = computed(() => {
const allCategories = templateService.getCategories()
return ['全部', ...allCategories]
return [t('search.allCategories'), ...allCategories]
})
const filteredTemplates = computed(() => {
let result = templates.value
if (selectedCategory.value !== '全部') {
if (selectedCategory.value !== '__all__') {
result = templateService.getTemplatesByCategory(selectedCategory.value)
}
@@ -105,7 +108,7 @@ onMounted(() => {
})
function loadTemplates() {
templates.value = templateService.getTemplates()
templates.value = templateService.getSearchTemplates()
}
function handleRefresh() {
@@ -0,0 +1,123 @@
<template>
<view :class="['typography', `typography--${variant}`, `typography--${weight}`, { 'typography--ellipsis': ellipsis }]">
<slot />
</view>
</template>
<script setup lang="ts">
interface Props {
variant?: 'h1' | 'h2' | 'h3' | 'h4' | 'body' | 'caption' | 'overline'
weight?: 'regular' | 'medium' | 'semibold' | 'bold'
ellipsis?: boolean
}
withDefaults(defineProps<Props>(), {
variant: 'body',
weight: 'regular',
ellipsis: false
})
</script>
<style scoped lang="scss">
.typography {
color: rgba(44, 24, 16, 255);
&--ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.typography--h1 {
font-size: 28px;
line-height: 40px;
font-weight: 700;
}
.typography--h2 {
font-size: 24px;
line-height: 36px;
font-weight: 700;
}
.typography--h3 {
font-size: 20px;
line-height: 30px;
font-weight: 600;
}
.typography--h4 {
font-size: 18px;
line-height: 28px;
font-weight: 600;
}
.typography--body {
font-size: 14px;
line-height: 20px;
font-weight: 400;
}
.typography--caption {
font-size: 12px;
line-height: 16px;
font-weight: 400;
}
.typography--overline {
font-size: 10px;
line-height: 14px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.typography--regular {
font-weight: 400;
}
.typography--medium {
font-weight: 500;
}
.typography--semibold {
font-weight: 600;
}
.typography--bold {
font-weight: 700;
}
@media screen and (max-width: 375px) {
.typography--h1 {
font-size: 24px;
line-height: 36px;
}
.typography--h2 {
font-size: 20px;
line-height: 30px;
}
.typography--h3 {
font-size: 18px;
line-height: 28px;
}
.typography--h4 {
font-size: 16px;
line-height: 26px;
}
.typography--body {
font-size: 13px;
line-height: 18px;
}
.typography--caption {
font-size: 11px;
line-height: 15px;
}
}
</style>