fix: 搜索过滤收藏、拼音首字母匹配、清除图片、滑动切换、通知已读
1. 搜索时收藏配方也按关键词过滤,不匹配的隐藏
2. 编辑配方添加精油时支持拼音首字母匹配(如xyc→薰衣草)
3. 品牌设置页清除图片立即保存到后端,不需点保存按钮
4. 左右滑动切换tab,轮播区域内滑动切换图片不触发tab切换
5. 通知列表每条未读通知加"已读"按钮,调用POST /api/notifications/{id}/read
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -39,7 +39,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main content -->
|
<!-- Main content -->
|
||||||
<div class="main">
|
<div class="main" @touchstart="onSwipeStart" @touchend="onSwipeEnd">
|
||||||
<router-view />
|
<router-view />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useAuthStore } from './stores/auth'
|
import { useAuthStore } from './stores/auth'
|
||||||
import { useOilsStore } from './stores/oils'
|
import { useOilsStore } from './stores/oils'
|
||||||
@@ -106,6 +106,48 @@ function toggleUserMenu() {
|
|||||||
showUserMenu.value = !showUserMenu.value
|
showUserMenu.value = !showUserMenu.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Swipe to switch tabs
|
||||||
|
const swipeStartX = ref(0)
|
||||||
|
const swipeStartY = ref(0)
|
||||||
|
|
||||||
|
// Tab order for swipe navigation (only user-accessible tabs)
|
||||||
|
const tabOrder = computed(() => {
|
||||||
|
const tabs = ['search', 'oils']
|
||||||
|
if (auth.isLoggedIn) {
|
||||||
|
tabs.splice(1, 0, 'manage', 'inventory')
|
||||||
|
}
|
||||||
|
if (auth.isBusiness) tabs.push('projects')
|
||||||
|
return tabs
|
||||||
|
})
|
||||||
|
|
||||||
|
function onSwipeStart(e) {
|
||||||
|
const touch = e.touches[0]
|
||||||
|
swipeStartX.value = touch.clientX
|
||||||
|
swipeStartY.value = touch.clientY
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSwipeEnd(e) {
|
||||||
|
const touch = e.changedTouches[0]
|
||||||
|
const dx = touch.clientX - swipeStartX.value
|
||||||
|
const dy = touch.clientY - swipeStartY.value
|
||||||
|
// Only trigger if horizontal swipe is dominant and > 50px
|
||||||
|
if (Math.abs(dx) < 50 || Math.abs(dy) > Math.abs(dx)) return
|
||||||
|
// Check if the swipe originated inside a carousel (data-no-tab-swipe)
|
||||||
|
if (e.target.closest && e.target.closest('[data-no-tab-swipe]')) return
|
||||||
|
|
||||||
|
const tabs = tabOrder.value
|
||||||
|
const currentIdx = tabs.indexOf(ui.currentSection)
|
||||||
|
if (currentIdx < 0) return
|
||||||
|
|
||||||
|
if (dx < -50 && currentIdx < tabs.length - 1) {
|
||||||
|
// Swipe left -> next tab
|
||||||
|
goSection(tabs[currentIdx + 1])
|
||||||
|
} else if (dx > 50 && currentIdx > 0) {
|
||||||
|
// Swipe right -> previous tab
|
||||||
|
goSection(tabs[currentIdx - 1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await auth.initToken()
|
await auth.initToken()
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
|||||||
@@ -355,6 +355,7 @@ import { useDiaryStore } from '../stores/diary'
|
|||||||
import { api } from '../composables/useApi'
|
import { api } from '../composables/useApi'
|
||||||
import { showConfirm, showPrompt } from '../composables/useDialog'
|
import { showConfirm, showPrompt } from '../composables/useDialog'
|
||||||
import { oilEn, recipeNameEn } from '../composables/useOilTranslation'
|
import { oilEn, recipeNameEn } from '../composables/useOilTranslation'
|
||||||
|
import { matchesPinyinInitials } from '../composables/usePinyinMatch'
|
||||||
// TagPicker replaced with inline tag editing
|
// TagPicker replaced with inline tag editing
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -751,7 +752,7 @@ const filteredOilsForAdd = computed(() => {
|
|||||||
if (!q) return oilsStore.oilNames
|
if (!q) return oilsStore.oilNames
|
||||||
return oilsStore.oilNames.filter(n => {
|
return oilsStore.oilNames.filter(n => {
|
||||||
const en = oilEn(n).toLowerCase()
|
const en = oilEn(n).toLowerCase()
|
||||||
return n.includes(q) || en.startsWith(q) || en.includes(q)
|
return n.includes(q) || en.startsWith(q) || en.includes(q) || matchesPinyinInitials(n, q)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,10 @@
|
|||||||
<div class="notif-list">
|
<div class="notif-list">
|
||||||
<div v-for="n in notifications.slice(0, 20)" :key="n.id"
|
<div v-for="n in notifications.slice(0, 20)" :key="n.id"
|
||||||
class="notif-item" :class="{ unread: !n.is_read }">
|
class="notif-item" :class="{ unread: !n.is_read }">
|
||||||
<div class="notif-title">{{ n.title }}</div>
|
<div class="notif-item-header">
|
||||||
|
<div class="notif-title">{{ n.title }}</div>
|
||||||
|
<button v-if="!n.is_read" class="notif-mark-one" @click="markOneRead(n)">已读</button>
|
||||||
|
</div>
|
||||||
<div v-if="n.body" class="notif-body">{{ n.body }}</div>
|
<div v-if="n.body" class="notif-body">{{ n.body }}</div>
|
||||||
<div class="notif-time">{{ formatTime(n.created_at) }}</div>
|
<div class="notif-time">{{ formatTime(n.created_at) }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,6 +108,13 @@ async function submitBug() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function markOneRead(n) {
|
||||||
|
try {
|
||||||
|
await api(`/api/notifications/${n.id}/read`, { method: 'POST', body: '{}' })
|
||||||
|
n.is_read = 1
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
async function markAllRead() {
|
async function markAllRead() {
|
||||||
try {
|
try {
|
||||||
await api('/api/notifications/read-all', { method: 'POST', body: '{}' })
|
await api('/api/notifications/read-all', { method: 'POST', body: '{}' })
|
||||||
@@ -191,7 +201,14 @@ onMounted(loadNotifications)
|
|||||||
padding: 8px 0; border-bottom: 1px solid #f5f5f5; font-size: 13px;
|
padding: 8px 0; border-bottom: 1px solid #f5f5f5; font-size: 13px;
|
||||||
}
|
}
|
||||||
.notif-item.unread { background: #fafafa; }
|
.notif-item.unread { background: #fafafa; }
|
||||||
.notif-title { font-weight: 500; color: #333; }
|
.notif-item-header { display: flex; justify-content: space-between; align-items: center; gap: 6px; }
|
||||||
|
.notif-title { font-weight: 500; color: #333; flex: 1; }
|
||||||
|
.notif-mark-one {
|
||||||
|
background: none; border: 1px solid #ccc; border-radius: 6px;
|
||||||
|
font-size: 11px; color: #7a9e7e; cursor: pointer; padding: 2px 8px;
|
||||||
|
font-family: inherit; white-space: nowrap; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.notif-mark-one:hover { background: #f0faf5; border-color: #7a9e7e; }
|
||||||
.notif-body { color: #888; font-size: 12px; margin-top: 2px; white-space: pre-line; }
|
.notif-body { color: #888; font-size: 12px; margin-top: 2px; white-space: pre-line; }
|
||||||
.notif-time { color: #bbb; font-size: 11px; margin-top: 2px; }
|
.notif-time { color: #bbb; font-size: 11px; margin-top: 2px; }
|
||||||
.notif-empty { text-align: center; color: #ccc; padding: 16px; font-size: 13px; }
|
.notif-empty { text-align: center; color: #ccc; padding: 16px; font-size: 13px; }
|
||||||
|
|||||||
73
frontend/src/composables/usePinyinMatch.js
Normal file
73
frontend/src/composables/usePinyinMatch.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Simple pinyin initial matching for Chinese oil names.
|
||||||
|
* Maps common Chinese characters used in essential oil names to their pinyin initials.
|
||||||
|
* This is a lightweight approach - no full pinyin library needed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Common characters in essential oil / herb names mapped to pinyin initials
|
||||||
|
const PINYIN_MAP = {
|
||||||
|
'薰': 'x', '衣': 'y', '草': 'c', '茶': 'c', '树': 's',
|
||||||
|
'柠': 'n', '檬': 'm', '薄': 'b', '荷': 'h', '迷': 'm',
|
||||||
|
'迭': 'd', '香': 'x', '乳': 'r', '沉': 'c', '丝': 's',
|
||||||
|
'柏': 'b', '尤': 'y', '加': 'j', '利': 'l', '丁': 'd',
|
||||||
|
'肉': 'r', '桂': 'g', '罗': 'l', '勒': 'l', '百': 'b',
|
||||||
|
'里': 'l', '牛': 'n', '至': 'z', '马': 'm', '鞭': 'b',
|
||||||
|
'天': 't', '竺': 'z', '葵': 'k', '生': 's', '姜': 'j',
|
||||||
|
'黑': 'h', '胡': 'h', '椒': 'j', '玫': 'm', '瑰': 'g',
|
||||||
|
'茉': 'm', '莉': 'l', '依': 'y', '兰': 'l', '花': 'h',
|
||||||
|
'橙': 'c', '佛': 'f', '手': 's', '柑': 'g', '葡': 'p',
|
||||||
|
'萄': 't', '柚': 'y', '甜': 't', '苦': 'k', '野': 'y',
|
||||||
|
'山': 's', '松': 's', '杉': 's', '杜': 'd', '雪': 'x',
|
||||||
|
'莲': 'l', '芦': 'l', '荟': 'h', '白': 'b', '芷': 'z',
|
||||||
|
'当': 'd', '归': 'g', '川': 'c', '芎': 'x', '红': 'h',
|
||||||
|
'枣': 'z', '枸': 'g', '杞': 'q', '菊': 'j', '洋': 'y',
|
||||||
|
'甘': 'g', '菘': 's', '蓝': 'l', '永': 'y', '久': 'j',
|
||||||
|
'快': 'k', '乐': 'l', '鼠': 's', '尾': 'w', '岩': 'y',
|
||||||
|
'冷': 'l', '杰': 'j', '绿': 'lv', '芫': 'y', '荽': 's',
|
||||||
|
'椰': 'y', '子': 'z', '油': 'y', '基': 'j', '底': 'd',
|
||||||
|
'精': 'j', '纯': 'c', '露': 'l', '木': 'm', '果': 'g',
|
||||||
|
'叶': 'y', '根': 'g', '皮': 'p', '籽': 'z', '仁': 'r',
|
||||||
|
'大': 'd', '小': 'x', '西': 'x', '东': 'd', '南': 'n',
|
||||||
|
'北': 'b', '中': 'z', '新': 'x', '古': 'g', '老': 'l',
|
||||||
|
'春': 'c', '夏': 'x', '秋': 'q', '冬': 'd', '温': 'w',
|
||||||
|
'热': 'r', '凉': 'l', '冰': 'b', '火': 'h', '水': 's',
|
||||||
|
'金': 'j', '银': 'y', '铜': 't', '铁': 't', '玉': 'y',
|
||||||
|
'珍': 'z', '珠': 'z', '翠': 'c', '碧': 'b', '紫': 'z',
|
||||||
|
'青': 'q', '蓝': 'l', '绿': 'lv', '黄': 'h', '棕': 'z',
|
||||||
|
'褐': 'h', '灰': 'h', '粉': 'f', '豆': 'd', '蔻': 'k',
|
||||||
|
'藿': 'h', '苏': 's', '萃': 'c', '缬': 'x', '安': 'a',
|
||||||
|
'息': 'x', '宁': 'n', '静': 'j', '和': 'h', '平': 'p',
|
||||||
|
'舒': 's', '缓': 'h', '放': 'f', '松': 's', '活': 'h',
|
||||||
|
'力': 'l', '能': 'n', '量': 'l', '保': 'b', '护': 'h',
|
||||||
|
'防': 'f', '御': 'y', '健': 'j', '康': 'k', '美': 'm',
|
||||||
|
'丽': 'l', '清': 'q', '新': 'x', '自': 'z', '然': 'r',
|
||||||
|
'植': 'z', '物': 'w', '芳': 'f', '疗': 'l', '复': 'f',
|
||||||
|
'方': 'f', '单': 'd', '配': 'p', '调': 'd',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pinyin initials string for a Chinese name.
|
||||||
|
* e.g. "薰衣草" -> "xyc"
|
||||||
|
*/
|
||||||
|
export function getPinyinInitials(name) {
|
||||||
|
let result = ''
|
||||||
|
for (const char of name) {
|
||||||
|
const initial = PINYIN_MAP[char]
|
||||||
|
if (initial) {
|
||||||
|
result += initial
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a query matches a name by pinyin initials.
|
||||||
|
* The query is matched as a prefix or substring of the pinyin initials.
|
||||||
|
*/
|
||||||
|
export function matchesPinyinInitials(name, query) {
|
||||||
|
if (!query || !name) return false
|
||||||
|
const initials = getPinyinInitials(name)
|
||||||
|
if (!initials) return false
|
||||||
|
const q = query.toLowerCase()
|
||||||
|
return initials.includes(q)
|
||||||
|
}
|
||||||
@@ -574,6 +574,26 @@ async function clearBrandImage(type) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function clearBrandImage(type) {
|
||||||
|
const fieldMap = { logo: 'brand_logo', bg: 'brand_bg', qr: 'qr_code' }
|
||||||
|
const field = fieldMap[type]
|
||||||
|
if (!field) return
|
||||||
|
try {
|
||||||
|
const res = await api('/api/brand', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ [field]: '' }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
if (type === 'logo') brandLogo.value = ''
|
||||||
|
else if (type === 'bg') brandBg.value = ''
|
||||||
|
else if (type === 'qr') brandQrImage.value = ''
|
||||||
|
ui.showToast('已清除')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ui.showToast('清除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Account
|
// Account
|
||||||
async function updateDisplayName() {
|
async function updateDisplayName() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="recipe-search">
|
<div class="recipe-search">
|
||||||
<!-- Category Carousel (full-width image slides) -->
|
<!-- Category Carousel (full-width image slides) -->
|
||||||
<div class="cat-wrap" v-if="categories.length && !selectedCategory">
|
<div class="cat-wrap" v-if="categories.length && !selectedCategory" data-no-tab-swipe @touchstart="onCarouselTouchStart" @touchend="onCarouselTouchEnd">
|
||||||
<div class="cat-track" :style="{ transform: `translateX(-${catIdx * 100}%)` }">
|
<div class="cat-track" :style="{ transform: `translateX(-${catIdx * 100}%)` }">
|
||||||
<div
|
<div
|
||||||
v-for="cat in categories"
|
v-for="cat in categories"
|
||||||
@@ -237,9 +237,17 @@ const myDiaryRecipes = computed(() => {
|
|||||||
|
|
||||||
const favoritesPreview = computed(() => {
|
const favoritesPreview = computed(() => {
|
||||||
if (!auth.isLoggedIn) return []
|
if (!auth.isLoggedIn) return []
|
||||||
return recipeStore.recipes
|
let list = recipeStore.recipes.filter(r => recipeStore.isFavorite(r))
|
||||||
.filter(r => recipeStore.isFavorite(r))
|
if (searchQuery.value.trim()) {
|
||||||
.slice(0, 6)
|
const q = searchQuery.value.trim().toLowerCase()
|
||||||
|
list = list.filter(r => {
|
||||||
|
const nameMatch = r.name.toLowerCase().includes(q)
|
||||||
|
const oilMatch = r.ingredients.some(ing => ing.oil.toLowerCase().includes(q))
|
||||||
|
const tagMatch = r.tags && r.tags.some(t => t.toLowerCase().includes(q))
|
||||||
|
return nameMatch || oilMatch || tagMatch
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return list.slice(0, 6)
|
||||||
})
|
})
|
||||||
|
|
||||||
function findGlobalIndex(recipe) {
|
function findGlobalIndex(recipe) {
|
||||||
@@ -316,6 +324,18 @@ function clearSearch() {
|
|||||||
searchQuery.value = ''
|
searchQuery.value = ''
|
||||||
selectedCategory.value = null
|
selectedCategory.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Carousel swipe
|
||||||
|
const carouselTouchStartX = ref(0)
|
||||||
|
function onCarouselTouchStart(e) {
|
||||||
|
carouselTouchStartX.value = e.touches[0].clientX
|
||||||
|
}
|
||||||
|
function onCarouselTouchEnd(e) {
|
||||||
|
const dx = e.changedTouches[0].clientX - carouselTouchStartX.value
|
||||||
|
if (Math.abs(dx) > 50) {
|
||||||
|
slideCat(dx < 0 ? 1 : -1)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
Reference in New Issue
Block a user