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>
+43 -44
View File
@@ -9,6 +9,7 @@ export default {
search: 'Suchen',
male: 'Männlich',
female: 'Weiblich',
pleaseSelect: 'Bitte auswählen',
},
nav: {
almanac: 'Almanach',
@@ -45,63 +46,61 @@ export default {
monthly: 'Monatlich',
selectDate: 'Datum auswählen',
selectMonth: 'Monat auswählen',
overallFortune: 'Gesamtschicksal',
career: 'Karriere',
wealth: 'Wohlstand',
relationship: 'Beziehung',
health: 'Gesundheit',
luckyColor: 'Glücksfarbe',
luckyNumber: 'Glückszahl',
luckyDirection: 'Glücksrichtung',
noChart: 'Bitte erstellen Sie zuerst ein Diagramm auf der Zi Wei Dou Shu Seite',
overallScore: 'Gesamtschicksal: {score}Pkt',
career: 'Karriere: ',
wealth: 'Wohlstand: ',
relationship: 'Beziehung: ',
health: 'Gesundheit: ',
luckyColor: 'Glücksfarbe: ',
luckyNumber: 'Glückszahl: ',
luckyDirection: 'Glücksrichtung: ',
noChartHint: 'Bitte erstellen Sie zuerst ein Diagramm auf der Zi Wei Dou Shu Seite',
},
search: {
conditions: 'Suchbedingungen',
searchConditions: 'Suchbedingungen',
addCondition: 'Bedingung hinzufügen',
addConditionHint: 'Bitte fügen Sie mindestens eine Suchbedingung hinzu',
addConditionEmpty: 'Suchbedingungen hinzufügen',
days: 'Suchtage',
dayUnit: 'Tage',
searchResult: 'Suchergebnisse',
searchDays: 'Suchtage',
daysUnit: '{count} Tage',
searchResults: 'Suchergebnisse',
resultCount: '{count} Ergebnisse',
matchCount: 'Übereinstimmung: {count}',
matchCount: 'Übereinstimmung',
sort: 'Sortieren',
sortByDate: 'Datum',
sortByMatch: 'Übereinstimmung',
typeSuitable: 'Günstig',
typeUnsuitable: 'Ungünstig',
suitable: 'Günstig',
unsuitable: 'Ungünstig',
operatorAnd: 'UND',
operatorOr: 'ODER',
excludeCondition: 'Bedingung ausschließen',
items: 'Einträge',
operator: 'Logischer Operator',
},
template: {
title: 'Suchvorlagen',
searchPlaceholder: 'Vorlagen durchsuchen',
empty: 'Keine Vorlagen',
emptyDesc: 'Keine Vorlagen entsprechen den Kriterien',
all: 'Alle',
},
export: {
title: 'Suchergebnisse exportieren',
selectFormat: 'Exportformat auswählen',
selectContent: 'Exportinhalt auswählen',
date: 'Datum',
lunarDate: 'Mondkalender-Datum',
weekday: 'Wochentag',
matchedItems: 'Übereinstimmende Einträge',
matchCount: 'Übereinstimmung',
},
history: {
title: 'Suchverlauf',
clear: 'Verlauf löschen',
clearConfirm: 'Löschen bestätigen',
clearConfirmMsg: 'Möchten Sie den gesamten Suchverlauf löschen?',
empty: 'Kein Suchverlauf',
emptyDesc: 'Suchbedingungen werden nach der Suche hier angezeigt',
activityItems: 'Einträge',
logicalOperator: 'Logischer Operator',
dateFormat: '{day}.{month}.{year}',
searchHistory: 'Suchverlauf',
clearHistory: 'Verlauf löschen',
noHistoryTitle: 'Kein Suchverlauf',
noHistoryDesc: 'Suchbedingungen werden nach der Suche hier angezeigt',
confirmClear: 'Löschen bestätigen',
confirmClearContent: 'Möchten Sie den gesamten Suchverlauf löschen?',
historyConditionDays: '{count} Bedingungen, {days} Tage Bereich',
justNow: 'Gerade eben',
hoursAgo: 'Vor {count} Std.',
daysAgo: 'Vor {count} Tag(en)',
searchTemplates: 'Suchvorlagen',
noTemplatesTitle: 'Keine Vorlagen',
noTemplatesDesc: 'Keine Vorlagen entsprechen den Kriterien',
allCategories: 'Alle',
},
export: {
exportResults: 'Ergebnisse exportieren',
exportSearchResults: 'Suchergebnisse exportieren',
selectFormat: 'Exportformat auswählen',
selectContent: 'Exportinhalt auswählen',
export: 'Exportieren',
contentDate: 'Datum',
contentLunarDate: 'Mondkalender-Datum',
contentWeekday: 'Wochentag',
contentMatchedItems: 'Übereinstimmende Einträge',
contentMatchCount: 'Übereinstimmung',
},
}
+43 -44
View File
@@ -9,6 +9,7 @@ export default {
search: 'Search',
male: 'Male',
female: 'Female',
pleaseSelect: 'Please select',
},
nav: {
almanac: 'Almanac',
@@ -45,63 +46,61 @@ export default {
monthly: 'Monthly',
selectDate: 'Select Date',
selectMonth: 'Select Month',
overallFortune: 'Overall Fortune',
career: 'Career',
wealth: 'Wealth',
relationship: 'Relationship',
health: 'Health',
luckyColor: 'Lucky Color',
luckyNumber: 'Lucky Number',
luckyDirection: 'Lucky Direction',
noChart: 'Please generate a chart on the Zi Wei Dou Shu page first',
overallScore: 'Overall Fortune: {score}pts',
career: 'Career: ',
wealth: 'Wealth: ',
relationship: 'Relationship: ',
health: 'Health: ',
luckyColor: 'Lucky Color: ',
luckyNumber: 'Lucky Number: ',
luckyDirection: 'Lucky Direction: ',
noChartHint: 'Please generate a chart on the Zi Wei Dou Shu page first',
},
search: {
conditions: 'Search Conditions',
searchConditions: 'Search Conditions',
addCondition: 'Add Condition',
addConditionHint: 'Please add at least one search condition',
addConditionEmpty: 'Add search conditions',
days: 'Search Days',
dayUnit: 'days',
searchResult: 'Search Results',
searchDays: 'Search Days',
daysUnit: '{count} days',
searchResults: 'Search Results',
resultCount: '{count} results',
matchCount: 'Match: {count}',
matchCount: 'Match',
sort: 'Sort',
sortByDate: 'Date',
sortByMatch: 'Match',
typeSuitable: 'Suitable',
typeUnsuitable: 'Unsuitable',
suitable: 'Suitable',
unsuitable: 'Unsuitable',
operatorAnd: 'AND',
operatorOr: 'OR',
excludeCondition: 'Exclude Condition',
items: 'Items',
operator: 'Logical Operator',
},
template: {
title: 'Search Templates',
searchPlaceholder: 'Search templates',
empty: 'No Templates',
emptyDesc: 'No templates match the criteria',
all: 'All',
},
export: {
title: 'Export Search Results',
selectFormat: 'Select Export Format',
selectContent: 'Select Export Content',
date: 'Date',
lunarDate: 'Lunar Date',
weekday: 'Weekday',
matchedItems: 'Matched Items',
matchCount: 'Match Score',
},
history: {
title: 'Search History',
clear: 'Clear History',
clearConfirm: 'Confirm Clear',
clearConfirmMsg: 'Are you sure you want to clear all search history?',
empty: 'No Search History',
emptyDesc: 'Search conditions will appear here after searching',
activityItems: 'Items',
logicalOperator: 'Logical Operator',
dateFormat: '{month}/{day}/{year}',
searchHistory: 'Search History',
clearHistory: 'Clear History',
noHistoryTitle: 'No Search History',
noHistoryDesc: 'Search conditions will appear here after searching',
confirmClear: 'Confirm Clear',
confirmClearContent: 'Are you sure you want to clear all search history?',
historyConditionDays: '{count} conditions, {days} days range',
justNow: 'Just now',
hoursAgo: '{count}h ago',
daysAgo: '{count}d ago',
searchTemplates: 'Search Templates',
noTemplatesTitle: 'No Templates',
noTemplatesDesc: 'No templates match the criteria',
allCategories: 'All',
},
export: {
exportResults: 'Export Results',
exportSearchResults: 'Export Search Results',
selectFormat: 'Select Export Format',
selectContent: 'Select Export Content',
export: 'Export',
contentDate: 'Date',
contentLunarDate: 'Lunar Date',
contentWeekday: 'Weekday',
contentMatchedItems: 'Matched Items',
contentMatchCount: 'Match Score',
},
}
+43 -44
View File
@@ -9,6 +9,7 @@ export default {
search: 'Buscar',
male: 'Masculino',
female: 'Femenino',
pleaseSelect: 'Seleccione',
},
nav: {
almanac: 'Almanaque',
@@ -45,63 +46,61 @@ export default {
monthly: 'Mensual',
selectDate: 'Seleccionar fecha',
selectMonth: 'Seleccionar mes',
overallFortune: 'Fortuna general',
career: 'Carrera',
wealth: 'Riqueza',
relationship: 'Relación',
health: 'Salud',
luckyColor: 'Color de la suerte',
luckyNumber: 'Número de la suerte',
luckyDirection: 'Dirección de la suerte',
noChart: 'Primero genere una carta en la página de Zi Wei Dou Shu',
overallScore: 'Fortuna general: {score}pts',
career: 'Carrera: ',
wealth: 'Riqueza: ',
relationship: 'Relación: ',
health: 'Salud: ',
luckyColor: 'Color de la suerte: ',
luckyNumber: 'Número de la suerte: ',
luckyDirection: 'Dirección de la suerte: ',
noChartHint: 'Primero genere una carta en la página de Zi Wei Dou Shu',
},
search: {
conditions: 'Condiciones de búsqueda',
searchConditions: 'Condiciones de búsqueda',
addCondition: 'Añadir condición',
addConditionHint: 'Añada al menos una condición de búsqueda',
addConditionEmpty: 'Añadir condiciones de búsqueda',
days: 'Días de búsqueda',
dayUnit: 'días',
searchResult: 'Resultados de búsqueda',
searchDays: 'Días de búsqueda',
daysUnit: '{count} días',
searchResults: 'Resultados de búsqueda',
resultCount: '{count} resultados',
matchCount: 'Coincidencia: {count}',
matchCount: 'Coincidencia',
sort: 'Ordenar',
sortByDate: 'Fecha',
sortByMatch: 'Coincidencia',
typeSuitable: 'Favorable',
typeUnsuitable: 'Desfavorable',
suitable: 'Favorable',
unsuitable: 'Desfavorable',
operatorAnd: 'Y',
operatorOr: 'O',
excludeCondition: 'Excluir condición',
items: 'Elementos',
operator: 'Operador lógico',
},
template: {
title: 'Plantillas de búsqueda',
searchPlaceholder: 'Buscar plantillas',
empty: 'Sin plantillas',
emptyDesc: 'No hay plantillas que coincidan con los criterios',
all: 'Todo',
},
export: {
title: 'Exportar resultados',
selectFormat: 'Seleccionar formato de exportación',
selectContent: 'Seleccionar contenido de exportación',
date: 'Fecha',
lunarDate: 'Fecha lunar',
weekday: 'Día de la semana',
matchedItems: 'Elementos coincidentes',
matchCount: 'Coincidencia',
},
history: {
title: 'Historial de búsqueda',
clear: 'Borrar historial',
clearConfirm: 'Confirmar borrado',
clearConfirmMsg: '¿Desea borrar todo el historial de búsqueda?',
empty: 'Sin historial',
emptyDesc: 'Las condiciones de búsqueda aparecerán aquí después de buscar',
activityItems: 'Elementos',
logicalOperator: 'Operador lógico',
dateFormat: '{day}/{month}/{year}',
searchHistory: 'Historial de búsqueda',
clearHistory: 'Borrar historial',
noHistoryTitle: 'Sin historial',
noHistoryDesc: 'Las condiciones de búsqueda aparecerán aquí después de buscar',
confirmClear: 'Confirmar borrado',
confirmClearContent: '¿Desea borrar todo el historial de búsqueda?',
historyConditionDays: '{count} condiciones, {days} días de rango',
justNow: 'Justo ahora',
hoursAgo: 'Hace {count}h',
daysAgo: 'Hace {count}d',
searchTemplates: 'Plantillas de búsqueda',
noTemplatesTitle: 'Sin plantillas',
noTemplatesDesc: 'No hay plantillas que coincidan con los criterios',
allCategories: 'Todo',
},
export: {
exportResults: 'Exportar resultados',
exportSearchResults: 'Exportar resultados de búsqueda',
selectFormat: 'Seleccionar formato de exportación',
selectContent: 'Seleccionar contenido de exportación',
export: 'Exportar',
contentDate: 'Fecha',
contentLunarDate: 'Fecha lunar',
contentWeekday: 'Día de la semana',
contentMatchedItems: 'Elementos coincidentes',
contentMatchCount: 'Coincidencia',
},
}
+43 -44
View File
@@ -9,6 +9,7 @@ export default {
search: 'Rechercher',
male: 'Homme',
female: 'Femme',
pleaseSelect: 'Veuillez sélectionner',
},
nav: {
almanac: 'Almanach',
@@ -45,63 +46,61 @@ export default {
monthly: 'Mensuel',
selectDate: 'Sélectionner la date',
selectMonth: 'Sélectionner le mois',
overallFortune: 'Destin global',
career: 'Carrière',
wealth: 'Richesse',
relationship: 'Relation',
health: 'Santé',
luckyColor: 'Couleur porte-bonheur',
luckyNumber: 'Numéro chance',
luckyDirection: 'Direction porte-bonheur',
noChart: 'Veuillez d\'abord générer un diagramme sur la page Zi Wei Dou Shu',
overallScore: 'Destin global : {score}pts',
career: 'Carrière : ',
wealth: 'Richesse : ',
relationship: 'Relation : ',
health: 'Santé : ',
luckyColor: 'Couleur porte-bonheur : ',
luckyNumber: 'Numéro chance : ',
luckyDirection: 'Direction porte-bonheur : ',
noChartHint: 'Veuillez d\'abord générer un diagramme sur la page Zi Wei Dou Shu',
},
search: {
conditions: 'Conditions de recherche',
searchConditions: 'Conditions de recherche',
addCondition: 'Ajouter une condition',
addConditionHint: 'Veuillez ajouter au moins une condition de recherche',
addConditionEmpty: 'Ajouter des conditions de recherche',
days: 'Jours de recherche',
dayUnit: 'jours',
searchResult: 'Résultats de recherche',
searchDays: 'Jours de recherche',
daysUnit: '{count} jours',
searchResults: 'Résultats de recherche',
resultCount: '{count} résultats',
matchCount: 'Correspondance : {count}',
matchCount: 'Correspondance',
sort: 'Trier',
sortByDate: 'Date',
sortByMatch: 'Correspondance',
typeSuitable: 'Favorable',
typeUnsuitable: 'Défavorable',
suitable: 'Favorable',
unsuitable: 'Défavorable',
operatorAnd: 'ET',
operatorOr: 'OU',
excludeCondition: 'Exclure la condition',
items: 'Éléments',
operator: 'Opérateur logique',
},
template: {
title: 'Modèles de recherche',
searchPlaceholder: 'Rechercher des modèles',
empty: 'Aucun modèle',
emptyDesc: 'Aucun modèle ne correspond aux critères',
all: 'Tous',
},
export: {
title: 'Exporter les résultats',
selectFormat: 'Sélectionner le format d\'export',
selectContent: 'Sélectionner le contenu d\'export',
date: 'Date',
lunarDate: 'Date lunaire',
weekday: 'Jour de la semaine',
matchedItems: 'Éléments correspondants',
matchCount: 'Correspondance',
},
history: {
title: 'Historique de recherche',
clear: 'Effacer l\'historique',
clearConfirm: 'Confirmer la suppression',
clearConfirmMsg: 'Voulez-vous effacer tout l\'historique de recherche ?',
empty: 'Aucun historique',
emptyDesc: 'Les conditions de recherche apparaîtront ici après la recherche',
activityItems: 'Éléments',
logicalOperator: 'Opérateur logique',
dateFormat: '{day}/{month}/{year}',
searchHistory: 'Historique de recherche',
clearHistory: 'Effacer l\'historique',
noHistoryTitle: 'Aucun historique',
noHistoryDesc: 'Les conditions de recherche apparaîtront ici après la recherche',
confirmClear: 'Confirmer la suppression',
confirmClearContent: 'Voulez-vous effacer tout l\'historique de recherche ?',
historyConditionDays: '{count} conditions, {days} jours de plage',
justNow: 'À l\'instant',
hoursAgo: 'Il y a {count}h',
daysAgo: 'Il y a {count}j',
searchTemplates: 'Modèles de recherche',
noTemplatesTitle: 'Aucun modèle',
noTemplatesDesc: 'Aucun modèle ne correspond aux critères',
allCategories: 'Tous',
},
export: {
exportResults: 'Exporter les résultats',
exportSearchResults: 'Exporter les résultats de recherche',
selectFormat: 'Sélectionner le format d\'export',
selectContent: 'Sélectionner le contenu d\'export',
export: 'Exporter',
contentDate: 'Date',
contentLunarDate: 'Date lunaire',
contentWeekday: 'Jour de la semaine',
contentMatchedItems: 'Éléments correspondants',
contentMatchCount: 'Correspondance',
},
}
+43 -44
View File
@@ -9,6 +9,7 @@ export default {
search: '検索',
male: '男性',
female: '女性',
pleaseSelect: '選択してください',
},
nav: {
almanac: '黄暦',
@@ -45,63 +46,61 @@ export default {
monthly: '月運',
selectDate: '日付を選択',
selectMonth: '月を選択',
overallFortune: '総合運勢',
career: '事業',
wealth: '財運',
relationship: '恋愛',
health: '健康',
luckyColor: 'ラッキーカラー',
luckyNumber: 'ラッキーナンバー',
luckyDirection: '吉方位',
noChart: '先に紫微斗数ページで排盤を完了してください',
overallScore: '総合運勢{score}点',
career: '事業',
wealth: '財運',
relationship: '恋愛',
health: '健康',
luckyColor: 'ラッキーカラー',
luckyNumber: 'ラッキーナンバー',
luckyDirection: '吉方位',
noChartHint: '先に紫微斗数ページで排盤を完了してください',
},
search: {
conditions: '検索条件',
searchConditions: '検索条件',
addCondition: '条件を追加',
addConditionHint: '検索条件を少なくとも1つ追加してください',
addConditionEmpty: '検索条件を追加',
days: '検索日数',
dayUnit: '',
searchResult: '検索結果',
searchDays: '検索日数',
daysUnit: '{count}日',
searchResults: '検索結果',
resultCount: '{count}件の結果',
matchCount: '一致度: {count}',
matchCount: '一致度',
sort: '並び替え',
sortByDate: '日付',
sortByMatch: '一致度',
typeSuitable: '宜',
typeUnsuitable: '忌',
suitable: '宜',
unsuitable: '忌',
operatorAnd: 'AND',
operatorOr: 'OR',
excludeCondition: '除外条件',
items: '事項',
operator: '論理演算子',
},
template: {
title: '検索テンプレート',
searchPlaceholder: 'テンプレートを検索',
empty: 'テンプレートなし',
emptyDesc: '条件に一致するテンプレートがありません',
all: 'すべて',
},
export: {
title: '検索結果をエクスポート',
selectFormat: 'エクスポート形式を選択',
selectContent: 'エクスポート内容を選択',
date: '日付',
lunarDate: '旧暦日付',
weekday: '曜日',
matchedItems: '一致事項',
matchCount: '一致度',
},
history: {
title: '検索履歴',
clear: '履歴をクリア',
clearConfirm: 'クリアの確認',
clearConfirmMsg: 'すべての検索履歴をクリアしますか?',
empty: '検索履歴なし',
emptyDesc: '検索実行後、条件がここに表示されます',
activityItems: '事項',
logicalOperator: '論理演算子',
dateFormat: '{year}年{month}月{day}日',
searchHistory: '検索履歴',
clearHistory: '履歴をクリア',
noHistoryTitle: '検索履歴なし',
noHistoryDesc: '検索実行後、条件がここに表示されます',
confirmClear: 'クリアの確認',
confirmClearContent: 'すべての検索履歴をクリアしますか?',
historyConditionDays: '{count}つの条件、{days}日範囲',
justNow: 'たった今',
hoursAgo: '{count}時間前',
daysAgo: '{count}日前',
searchTemplates: '検索テンプレート',
noTemplatesTitle: 'テンプレートなし',
noTemplatesDesc: '条件に一致するテンプレートがありません',
allCategories: 'すべて',
},
export: {
exportResults: '結果をエクスポート',
exportSearchResults: '検索結果をエクスポート',
selectFormat: 'エクスポート形式を選択',
selectContent: 'エクスポート内容を選択',
export: 'エクスポート',
contentDate: '日付',
contentLunarDate: '旧暦日付',
contentWeekday: '曜日',
contentMatchedItems: '一致事項',
contentMatchCount: '一致度',
},
}
+43 -44
View File
@@ -9,6 +9,7 @@ export default {
search: '검색',
male: '남성',
female: '여성',
pleaseSelect: '선택하세요',
},
nav: {
almanac: '황력',
@@ -45,63 +46,61 @@ export default {
monthly: '월운',
selectDate: '날짜 선택',
selectMonth: '월 선택',
overallFortune: '종합 운세',
career: '사업',
wealth: '재운',
relationship: '연애',
health: '건강',
luckyColor: '행운의 색',
luckyNumber: '행운의 숫자',
luckyDirection: '행운의 방위',
noChart: '먼저 자미두수 페이지에서 명반을 생성해 주세요',
overallScore: '종합 운세: {score}점',
career: '사업: ',
wealth: '재운: ',
relationship: '연애: ',
health: '건강: ',
luckyColor: '행운의 색: ',
luckyNumber: '행운의 숫자: ',
luckyDirection: '행운의 방위: ',
noChartHint: '먼저 자미두수 페이지에서 명반을 생성해 주세요',
},
search: {
conditions: '검색 조건',
searchConditions: '검색 조건',
addCondition: '조건 추가',
addConditionHint: '검색 조건을 최소 하나 추가해 주세요',
addConditionEmpty: '검색 조건 추가',
days: '검색 일수',
dayUnit: '',
searchResult: '검색 결과',
searchDays: '검색 일수',
daysUnit: '{count}일',
searchResults: '검색 결과',
resultCount: '{count}개 결과',
matchCount: '일치도: {count}',
matchCount: '일치도',
sort: '정렬',
sortByDate: '날짜',
sortByMatch: '일치도',
typeSuitable: '宜',
typeUnsuitable: '忌',
suitable: '宜',
unsuitable: '忌',
operatorAnd: 'AND',
operatorOr: 'OR',
excludeCondition: '제외 조건',
items: '항목',
operator: '논리 연산자',
},
template: {
title: '검색 템플릿',
searchPlaceholder: '템플릿 검색',
empty: '템플릿 없음',
emptyDesc: '조건에 맞는 템플릿이 없습니다',
all: '전체',
},
export: {
title: '검색 결과 내보내기',
selectFormat: '내보내기 형식 선택',
selectContent: '내보내기 내용 선택',
date: '날짜',
lunarDate: '음력 날짜',
weekday: '요일',
matchedItems: '일치 항목',
matchCount: '일치도',
},
history: {
title: '검색 기록',
clear: '기록 삭제',
clearConfirm: '삭제 확인',
clearConfirmMsg: '모든 검색 기록을 삭제하시겠습니까?',
empty: '검색 기록 없음',
emptyDesc: '검색 실행 후 조건이 여기에 표시됩니다',
activityItems: '항목',
logicalOperator: '논리 연산자',
dateFormat: '{year}년 {month}월 {day}일',
searchHistory: '검색 기록',
clearHistory: '기록 삭제',
noHistoryTitle: '검색 기록 없음',
noHistoryDesc: '검색 실행 후 조건이 여기에 표시됩니다',
confirmClear: '삭제 확인',
confirmClearContent: '모든 검색 기록을 삭제하시겠습니까?',
historyConditionDays: '{count}개 조건, {days}일 범위',
justNow: '방금',
hoursAgo: '{count}시간 전',
daysAgo: '{count}일 전',
searchTemplates: '검색 템플릿',
noTemplatesTitle: '템플릿 없음',
noTemplatesDesc: '조건에 맞는 템플릿이 없습니다',
allCategories: '전체',
},
export: {
exportResults: '결과 내보내기',
exportSearchResults: '검색 결과 내보내기',
selectFormat: '내보내기 형식 선택',
selectContent: '내보내기 내용 선택',
export: '내보내기',
contentDate: '날짜',
contentLunarDate: '음력 날짜',
contentWeekday: '요일',
contentMatchedItems: '일치 항목',
contentMatchCount: '일치도',
},
}
+43 -44
View File
@@ -9,6 +9,7 @@ export default {
search: 'Pesquisar',
male: 'Masculino',
female: 'Feminino',
pleaseSelect: 'Selecione',
},
nav: {
almanac: 'Almanaque',
@@ -45,63 +46,61 @@ export default {
monthly: 'Mensal',
selectDate: 'Selecionar data',
selectMonth: 'Selecionar mês',
overallFortune: 'Fortuna geral',
career: 'Carreira',
wealth: 'Riqueza',
relationship: 'Relacionamento',
health: 'Saúde',
luckyColor: 'Cor da sorte',
luckyNumber: 'Número da sorte',
luckyDirection: 'Direção da sorte',
noChart: 'Primeiro gere uma carta na página Zi Wei Dou Shu',
overallScore: 'Fortuna geral: {score}pts',
career: 'Carreira: ',
wealth: 'Riqueza: ',
relationship: 'Relacionamento: ',
health: 'Saúde: ',
luckyColor: 'Cor da sorte: ',
luckyNumber: 'Número da sorte: ',
luckyDirection: 'Direção da sorte: ',
noChartHint: 'Primeiro gere uma carta na página Zi Wei Dou Shu',
},
search: {
conditions: 'Condições de pesquisa',
searchConditions: 'Condições de pesquisa',
addCondition: 'Adicionar condição',
addConditionHint: 'Adicione pelo menos uma condição de pesquisa',
addConditionEmpty: 'Adicionar condições de pesquisa',
days: 'Dias de pesquisa',
dayUnit: 'dias',
searchResult: 'Resultados da pesquisa',
searchDays: 'Dias de pesquisa',
daysUnit: '{count} dias',
searchResults: 'Resultados da pesquisa',
resultCount: '{count} resultados',
matchCount: 'Correspondência: {count}',
matchCount: 'Correspondência',
sort: 'Ordenar',
sortByDate: 'Data',
sortByMatch: 'Correspondência',
typeSuitable: 'Favorável',
typeUnsuitable: 'Desfavorável',
suitable: 'Favorável',
unsuitable: 'Desfavorável',
operatorAnd: 'E',
operatorOr: 'OU',
excludeCondition: 'Excluir condição',
items: 'Itens',
operator: 'Operador lógico',
},
template: {
title: 'Modelos de pesquisa',
searchPlaceholder: 'Pesquisar modelos',
empty: 'Sem modelos',
emptyDesc: 'Nenhum modelo corresponde aos critérios',
all: 'Todos',
},
export: {
title: 'Exportar resultados',
selectFormat: 'Selecionar formato de exportação',
selectContent: 'Selecionar conteúdo de exportação',
date: 'Data',
lunarDate: 'Data lunar',
weekday: 'Dia da semana',
matchedItems: 'Itens correspondentes',
matchCount: 'Correspondência',
},
history: {
title: 'Histórico de pesquisa',
clear: 'Limpar histórico',
clearConfirm: 'Confirmar limpeza',
clearConfirmMsg: 'Deseja limpar todo o histórico de pesquisa?',
empty: 'Sem histórico',
emptyDesc: 'As condições de pesquisa aparecerão aqui após a pesquisa',
activityItems: 'Itens',
logicalOperator: 'Operador lógico',
dateFormat: '{day}/{month}/{year}',
searchHistory: 'Histórico de pesquisa',
clearHistory: 'Limpar histórico',
noHistoryTitle: 'Sem histórico',
noHistoryDesc: 'As condições de pesquisa aparecerão aqui após a pesquisa',
confirmClear: 'Confirmar limpeza',
confirmClearContent: 'Deseja limpar todo o histórico de pesquisa?',
historyConditionDays: '{count} condições, {days} dias de intervalo',
justNow: 'Agora mesmo',
hoursAgo: 'Há {count}h',
daysAgo: 'Há {count}d',
searchTemplates: 'Modelos de pesquisa',
noTemplatesTitle: 'Sem modelos',
noTemplatesDesc: 'Nenhum modelo corresponde aos critérios',
allCategories: 'Todos',
},
export: {
exportResults: 'Exportar resultados',
exportSearchResults: 'Exportar resultados de pesquisa',
selectFormat: 'Selecionar formato de exportação',
selectContent: 'Selecionar conteúdo de exportação',
export: 'Exportar',
contentDate: 'Data',
contentLunarDate: 'Data lunar',
contentWeekday: 'Dia da semana',
contentMatchedItems: 'Itens correspondentes',
contentMatchCount: 'Correspondência',
},
}
@@ -9,6 +9,7 @@ export default {
search: '搜索',
male: '男',
female: '女',
pleaseSelect: '请选择',
},
nav: {
almanac: '黄历',
@@ -45,63 +46,61 @@ export default {
monthly: '月运',
selectDate: '选择日期',
selectMonth: '选择月份',
overallFortune: '综合运势',
career: '事业',
wealth: '财运',
relationship: '感情',
health: '健康',
luckyColor: '幸运色',
luckyNumber: '幸运数字',
luckyDirection: '幸运方位',
noChart: '请先在紫微斗数页面完成排盘',
overallScore: '综合运势{score}分',
career: '事业',
wealth: '财运',
relationship: '感情',
health: '健康',
luckyColor: '幸运色',
luckyNumber: '幸运数字',
luckyDirection: '幸运方位',
noChartHint: '请先在紫微斗数页面完成排盘',
},
search: {
conditions: '搜索条件',
searchConditions: '搜索条件',
addCondition: '添加条件',
addConditionHint: '请至少添加一个搜索条件',
addConditionEmpty: '请添加搜索条件',
days: '搜索天数',
dayUnit: '',
searchResult: '搜索结果',
searchDays: '搜索天数',
daysUnit: '{count}天',
searchResults: '搜索结果',
resultCount: '共{count}条结果',
matchCount: '匹配度: {count}',
matchCount: '匹配度',
sort: '排序',
sortByDate: '日期',
sortByMatch: '匹配度',
typeSuitable: '宜',
typeUnsuitable: '忌',
suitable: '宜',
unsuitable: '忌',
operatorAnd: '与(AND',
operatorOr: '或(OR',
excludeCondition: '排除条件',
items: '事项',
operator: '逻辑运算符',
},
template: {
title: '搜索模板',
searchPlaceholder: '搜索模板',
empty: '暂无模板',
emptyDesc: '没有找到符合条件的模板',
all: '全部',
},
export: {
title: '导出搜索结果',
selectFormat: '选择导出格式',
selectContent: '选择导出内容',
date: '日期',
lunarDate: '农历日期',
weekday: '星期',
matchedItems: '匹配事项',
matchCount: '匹配度',
},
history: {
title: '搜索历史',
clear: '清除历史',
clearConfirm: '确认清除',
clearConfirmMsg: '确定要清除所有搜索历史吗?',
empty: '暂无搜索历史',
emptyDesc: '执行搜索后,搜索条件将显示在这里',
activityItems: '事项',
logicalOperator: '逻辑运算符',
dateFormat: '{year}年{month}月{day}日',
searchHistory: '搜索历史',
clearHistory: '清除历史',
noHistoryTitle: '暂无搜索历史',
noHistoryDesc: '执行搜索后,搜索条件将显示在这里',
confirmClear: '确认清除',
confirmClearContent: '确定要清除所有搜索历史吗?',
historyConditionDays: '{count}个条件,{days}天范围',
justNow: '刚刚',
hoursAgo: '{count}小时前',
daysAgo: '{count}天前',
searchTemplates: '搜索模板',
noTemplatesTitle: '暂无模板',
noTemplatesDesc: '没有找到符合条件的模板',
allCategories: '全部',
},
export: {
exportResults: '导出结果',
exportSearchResults: '导出搜索结果',
selectFormat: '选择导出格式',
selectContent: '选择导出内容',
export: '导出',
contentDate: '日期',
contentLunarDate: '农历日期',
contentWeekday: '星期',
contentMatchedItems: '匹配事项',
contentMatchCount: '匹配度',
},
}
@@ -9,6 +9,7 @@ export default {
search: '搜尋',
male: '男',
female: '女',
pleaseSelect: '請選擇',
},
nav: {
almanac: '黃曆',
@@ -45,63 +46,61 @@ export default {
monthly: '月運',
selectDate: '選擇日期',
selectMonth: '選擇月份',
overallFortune: '綜合運勢',
career: '事業',
wealth: '財運',
relationship: '感情',
health: '健康',
luckyColor: '幸運色',
luckyNumber: '幸運數字',
luckyDirection: '幸運方位',
noChart: '請先在紫微斗數頁面完成排盤',
overallScore: '綜合運勢{score}分',
career: '事業',
wealth: '財運',
relationship: '感情',
health: '健康',
luckyColor: '幸運色',
luckyNumber: '幸運數字',
luckyDirection: '幸運方位',
noChartHint: '請先在紫微斗數頁面完成排盤',
},
search: {
conditions: '搜尋條件',
searchConditions: '搜尋條件',
addCondition: '新增條件',
addConditionHint: '請至少新增一個搜尋條件',
addConditionEmpty: '請新增搜尋條件',
days: '搜尋天數',
dayUnit: '',
searchResult: '搜尋結果',
searchDays: '搜尋天數',
daysUnit: '{count}天',
searchResults: '搜尋結果',
resultCount: '共{count}條結果',
matchCount: '匹配度: {count}',
matchCount: '匹配度',
sort: '排序',
sortByDate: '日期',
sortByMatch: '匹配度',
typeSuitable: '宜',
typeUnsuitable: '忌',
suitable: '宜',
unsuitable: '忌',
operatorAnd: '與(AND',
operatorOr: '或(OR',
excludeCondition: '排除條件',
items: '事項',
operator: '邏輯運算子',
},
template: {
title: '搜尋模板',
searchPlaceholder: '搜尋模板',
empty: '暫無模板',
emptyDesc: '沒有找到符合條件的模板',
all: '全部',
},
export: {
title: '匯出搜尋結果',
selectFormat: '選擇匯出格式',
selectContent: '選擇匯出內容',
date: '日期',
lunarDate: '農曆日期',
weekday: '星期',
matchedItems: '匹配事項',
matchCount: '匹配度',
},
history: {
title: '搜尋歷史',
clear: '清除歷史',
clearConfirm: '確認清除',
clearConfirmMsg: '確定要清除所有搜尋歷史嗎?',
empty: '暫無搜尋歷史',
emptyDesc: '執行搜尋後,搜尋條件將顯示在這裡',
activityItems: '事項',
logicalOperator: '邏輯運算子',
dateFormat: '{year}年{month}月{day}日',
searchHistory: '搜尋歷史',
clearHistory: '清除歷史',
noHistoryTitle: '暫無搜尋歷史',
noHistoryDesc: '執行搜尋後,搜尋條件將顯示在這裡',
confirmClear: '確認清除',
confirmClearContent: '確定要清除所有搜尋歷史嗎?',
historyConditionDays: '{count}個條件,{days}天範圍',
justNow: '剛剛',
hoursAgo: '{count}小時前',
daysAgo: '{count}天前',
searchTemplates: '搜尋模板',
noTemplatesTitle: '暫無模板',
noTemplatesDesc: '沒有找到符合條件的模板',
allCategories: '全部',
},
export: {
exportResults: '匯出結果',
exportSearchResults: '匯出搜尋結果',
selectFormat: '選擇匯出格式',
selectContent: '選擇匯出內容',
export: '匯出',
contentDate: '日期',
contentLunarDate: '農曆日期',
contentWeekday: '星期',
contentMatchedItems: '匹配事項',
contentMatchCount: '匹配度',
},
}
@@ -32,17 +32,6 @@ export interface SavedSearchCondition {
createdAt: string
}
export interface SearchService {
search(request: SearchRequest): Promise<SearchResult[]>
saveSearchCondition(name: string, condition: SearchRequest): void
getSavedSearchConditions(): SavedSearchCondition[]
deleteSearchCondition(id: string): void
loadSearchCondition(condition: SearchRequest): void
saveSearchHistory(request: SearchRequest): void
getSearchHistory(): any[]
clearSearchHistory(): void
}
export interface SearchHistoryItem {
id: string
condition: SearchRequest
@@ -1,18 +1,31 @@
import type { ApiError, ErrorType } from './httpClient'
export enum AppErrorType {
CALCULATION_ERROR = 'CALCULATION_ERROR',
DATA_ERROR = 'DATA_ERROR',
STORAGE_ERROR = 'STORAGE_ERROR',
VALIDATION_ERROR = 'VALIDATION_ERROR',
UNKNOWN_ERROR = 'UNKNOWN_ERROR'
}
export interface AppError {
type: AppErrorType
code: number
message: string
detail?: any
}
class ErrorHandler {
private errorMessages: Record<ErrorType, string> = {
NETWORK_ERROR: '网络连接失败,请检查网络设置',
TIMEOUT_ERROR: '请求超时,请稍后重试',
BUSINESS_ERROR: '操作失败,请稍后重试',
AUTH_ERROR: '认证失败,请重新登录',
TOKEN_EXPIRED: '登录已过期,请重新登录',
private errorMessages: Record<AppErrorType, string> = {
CALCULATION_ERROR: '计算出错,请稍后重试',
DATA_ERROR: '数据异常,请稍后重试',
STORAGE_ERROR: '存储操作失败,请检查设备空间',
VALIDATION_ERROR: '输入数据有误,请检查后重试',
UNKNOWN_ERROR: '未知错误,请稍后重试'
}
handleError(error: ApiError): void {
this.logError(error)
this.showErrorMessage(error.message)
handleError(error: AppError | Error): void {
const appError = this.toAppError(error)
this.logError(appError)
this.showErrorMessage(appError.message)
}
showErrorMessage(message: string): void {
@@ -31,8 +44,8 @@ class ErrorHandler {
})
}
logError(error: ApiError): void {
console.error('[API Error]', {
logError(error: AppError): void {
console.error('[App Error]', {
type: error.type,
code: error.code,
message: error.message,
@@ -40,20 +53,23 @@ class ErrorHandler {
})
}
getErrorMessage(error: ApiError): string {
getErrorMessage(error: AppError): string {
return this.errorMessages[error.type] || error.message
}
isAuthError(error: ApiError): boolean {
return error.type === 'AUTH_ERROR' || error.type === 'TOKEN_EXPIRED'
createError(type: AppErrorType, message: string, code: number = 0, detail?: any): AppError {
return { type, code, message, detail }
}
isNetworkError(error: ApiError): boolean {
return error.type === 'NETWORK_ERROR' || error.type === 'TIMEOUT_ERROR'
}
isBusinessError(error: ApiError): boolean {
return error.type === 'BUSINESS_ERROR'
private toAppError(error: AppError | Error): AppError {
if ('type' in error && 'code' in error) {
return error as AppError
}
return {
type: AppErrorType.UNKNOWN_ERROR,
code: 0,
message: error.message || '未知错误'
}
}
}