diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 4c19a7c..11eb7cf 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -80,7 +80,7 @@ jobs: echo "=== Batch 3: Remaining tests ===" timeout 300 npx cypress run \ - --spec "cypress/e2e/app-load.cy.js,cypress/e2e/account-settings.cy.js,cypress/e2e/audit-log-advanced.cy.js,cypress/e2e/batch-operations.cy.js,cypress/e2e/bug-tracker-flow.cy.js,cypress/e2e/category-modules.cy.js,cypress/e2e/notification-flow.cy.js,cypress/e2e/oil-reference.cy.js,cypress/e2e/performance.cy.js,cypress/e2e/price-display.cy.js,cypress/e2e/projects-flow.cy.js,cypress/e2e/responsive.cy.js,cypress/e2e/search-advanced.cy.js,cypress/e2e/user-management-flow.cy.js,cypress/e2e/visual-check.cy.js" \ + --spec "cypress/e2e/app-load.cy.js,cypress/e2e/account-settings.cy.js,cypress/e2e/audit-log-advanced.cy.js,cypress/e2e/batch-operations.cy.js,cypress/e2e/bug-tracker-flow.cy.js,cypress/e2e/category-modules.cy.js,cypress/e2e/notification-flow.cy.js,cypress/e2e/oil-reference.cy.js,cypress/e2e/oil-smart-paste.cy.js,cypress/e2e/performance.cy.js,cypress/e2e/price-display.cy.js,cypress/e2e/projects-flow.cy.js,cypress/e2e/responsive.cy.js,cypress/e2e/search-advanced.cy.js,cypress/e2e/user-management-flow.cy.js,cypress/e2e/visual-check.cy.js" \ --config "$CYPRESS_CFG" --env "ADMIN_TOKEN=$ADMIN_TOKEN" B3=$? diff --git a/frontend/cypress/e2e/oil-reference.cy.js b/frontend/cypress/e2e/oil-reference.cy.js index ecd1bd3..8bf11ce 100644 --- a/frontend/cypress/e2e/oil-reference.cy.js +++ b/frontend/cypress/e2e/oil-reference.cy.js @@ -16,12 +16,22 @@ describe('Oil Reference Page', () => { it('filters oils by search', () => { cy.get('.oil-chip').then($chips => { const initial = $chips.length - cy.get('input[placeholder*="搜索精油"]').type('薰衣草') + cy.get('input[placeholder*="搜索"]').type('薰衣草') cy.wait(300) cy.get('.oil-chip').should('have.length.lt', initial) }) }) + it('filters oils by english name', () => { + cy.get('.oil-chip').then($chips => { + const initial = $chips.length + cy.get('input[placeholder*="搜索"]').type('Lavender') + cy.wait(300) + cy.get('.oil-chip').should('have.length.lt', initial) + cy.get('.oil-chip').should('exist') + }) + }) + it('toggles between bottle and drop price view', () => { cy.get('.oil-chip').first().invoke('text').then(textBefore => { cy.contains('滴价').click() diff --git a/frontend/cypress/e2e/oil-smart-paste.cy.js b/frontend/cypress/e2e/oil-smart-paste.cy.js new file mode 100644 index 0000000..7a165b6 --- /dev/null +++ b/frontend/cypress/e2e/oil-smart-paste.cy.js @@ -0,0 +1,51 @@ +describe('Oil Reference Smart Paste', () => { + let adminToken + + before(() => { + cy.getAdminToken().then(token => { adminToken = token }) + }) + + beforeEach(() => { + cy.visit('/oils', { + onBeforeLoad(win) { + win.localStorage.setItem('oil_auth_token', adminToken) + } + }) + cy.get('.oil-chip, .oils-grid', { timeout: 10000 }).should('exist') + }) + + it('smart paste fills product form fields', () => { + cy.contains('button', '+ 新增').click() + cy.contains('button', '🪄 智能识别').click() + + const sample = [ + '优惠顾客价:¥310PT:41', + '', + '零售价:¥465', + '', + '点数:37 规格:100毫升', + '', + '花样年华焕颜精华水 Salubelle Rejuvenating Essence', + ].join('\n') + + cy.get('textarea').type(sample, { parseSpecialCharSequences: false, delay: 0 }) + cy.contains('button', '识别并填入').click() + + cy.get('.add-type-tab.active').should('contain', '其他') + cy.get('input[placeholder="产品名称"]').should('have.value', '花样年华焕颜精华水') + cy.get('input[placeholder="英文名"]').should('have.value', 'Salubelle Rejuvenating Essence') + cy.get('input[placeholder="会员价 ¥"]').should('have.value', '310') + cy.get('input[placeholder="零售价 ¥"]').should('have.value', '465') + cy.get('input[placeholder="容量"]').should('have.value', '100') + }) + + it('smart paste detects standard ml volume as essential oil', () => { + cy.contains('button', '+ 新增').click() + cy.contains('button', '🪄 智能识别').click() + const sample = '会员价:¥200\n零售价:¥267\n规格:15毫升\n薰衣草测试 LavenderTest' + cy.get('textarea').type(sample, { parseSpecialCharSequences: false, delay: 0 }) + cy.contains('button', '识别并填入').click() + cy.get('.add-type-tab.active').should('contain', '精油') + cy.get('input[placeholder="精油名称"]').should('have.value', '薰衣草测试') + }) +}) diff --git a/frontend/src/__tests__/oilProductPaste.test.js b/frontend/src/__tests__/oilProductPaste.test.js new file mode 100644 index 0000000..59ad475 --- /dev/null +++ b/frontend/src/__tests__/oilProductPaste.test.js @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest' +import { parseOilProductPaste } from '../composables/useOilProductPaste' + +describe('parseOilProductPaste', () => { + it('returns empty shape for empty input', () => { + const r = parseOilProductPaste('') + expect(r.cn).toBe('') + expect(r.en).toBe('') + expect(r.memberPrice).toBeNull() + expect(r.retailPrice).toBeNull() + }) + + it('parses the 花样年华 sample as product with 100ml', () => { + const sample = `优惠顾客价:¥310PT:41 + +零售价:¥465 + +点数:37 规格:100毫升 + +花样年华焕颜精华水 Salubelle Rejuvenating Essence` + const r = parseOilProductPaste(sample) + expect(r.type).toBe('product') + expect(r.memberPrice).toBe(310) + expect(r.retailPrice).toBe(465) + expect(r.productAmount).toBe(100) + expect(r.productUnit).toBe('ml') + expect(r.cn).toBe('花样年华焕颜精华水') + expect(r.en).toBe('Salubelle Rejuvenating Essence') + }) + + it('detects essential oil when volume is standard ml', () => { + const sample = `会员价:¥200\n零售价:¥267\n规格:15毫升\n薰衣草 Lavender` + const r = parseOilProductPaste(sample) + expect(r.type).toBe('oil') + expect(r.volume).toBe('15') + expect(r.cn).toBe('薰衣草') + expect(r.en).toBe('Lavender') + }) + + it('handles half-width colon and dollar variant', () => { + const r = parseOilProductPaste('优惠顾客价: ¥99\n零售价: ¥150\n规格: 5ml\n柠檬 Lemon') + expect(r.memberPrice).toBe(99) + expect(r.retailPrice).toBe(150) + expect(r.type).toBe('oil') + expect(r.volume).toBe('5') + }) + + it('parses capsule spec as product', () => { + const r = parseOilProductPaste('优惠顾客价:¥200\n规格:60粒\n深海鱼油 Omega') + expect(r.type).toBe('product') + expect(r.productAmount).toBe(60) + expect(r.productUnit).toBe('capsule') + }) + + it('parses gram spec as product', () => { + const r = parseOilProductPaste('优惠顾客价:¥80\n规格:120克\n洁面乳 Face Wash') + expect(r.productUnit).toBe('g') + expect(r.productAmount).toBe(120) + }) + + it('non-standard ml volume falls to product', () => { + const r = parseOilProductPaste('优惠顾客价:¥310\n规格:100毫升\n精华 Essence') + expect(r.type).toBe('product') + expect(r.productAmount).toBe(100) + expect(r.productUnit).toBe('ml') + }) + + it('name without english part keeps cn only', () => { + const r = parseOilProductPaste('优惠顾客价:¥50\n规格:5毫升\n某国产品') + expect(r.cn).toBe('某国产品') + expect(r.en).toBe('') + }) +}) diff --git a/frontend/src/composables/useOilProductPaste.js b/frontend/src/composables/useOilProductPaste.js new file mode 100644 index 0000000..c4e21b3 --- /dev/null +++ b/frontend/src/composables/useOilProductPaste.js @@ -0,0 +1,52 @@ +const OIL_VOLUMES = new Set(['2.5', '5', '10', '15', '115']) + +export function parseOilProductPaste(raw) { + const result = { + type: 'product', + cn: '', + en: '', + memberPrice: null, + retailPrice: null, + volume: null, + customDrops: null, + productAmount: null, + productUnit: null, + } + if (!raw || !raw.trim()) return result + + const text = raw.replace(/[::]/g, ':').replace(/[¥¥]/g, '') + + const memberMatch = text.match(/(?:优惠顾客价|会员价|批发价)\s*:?\s*(\d+(?:\.\d+)?)/) + const retailMatch = text.match(/零售价\s*:?\s*(\d+(?:\.\d+)?)/) + const specMatch = text.match(/规格\s*:?\s*(\d+(?:\.\d+)?)\s*(毫升|ml|ML|克|g|G|颗|粒|片)/) + + if (memberMatch) result.memberPrice = Number(memberMatch[1]) + if (retailMatch) result.retailPrice = Number(retailMatch[1]) + + for (const line of raw.split(/\r?\n/)) { + const s = line.trim() + if (!s) continue + if (/优惠顾客价|会员价|零售价|点数|规格|PT\s*:|批发价/i.test(s)) continue + const m = s.match(/^([^A-Za-z]+?)\s+([A-Za-z].*)$/) + if (m) { result.cn = m[1].trim(); result.en = m[2].trim() } else { result.cn = s } + break + } + + if (specMatch) { + const amount = specMatch[1] + const unitRaw = specMatch[2].toLowerCase() + const isMl = unitRaw === '毫升' || unitRaw === 'ml' + if (isMl && OIL_VOLUMES.has(String(Number(amount)))) { + result.type = 'oil' + result.volume = String(Number(amount)) + } else { + result.type = 'product' + result.productAmount = Number(amount) + result.productUnit = (unitRaw === '克' || unitRaw === 'g') ? 'g' + : (unitRaw === '颗' || unitRaw === '粒' || unitRaw === '片') ? 'capsule' + : 'ml' + } + } + + return result +} diff --git a/frontend/src/views/OilReference.vue b/frontend/src/views/OilReference.vue index df6d129..b371b2e 100644 --- a/frontend/src/views/OilReference.vue +++ b/frontend/src/views/OilReference.vue @@ -91,7 +91,7 @@
@@ -110,6 +110,14 @@
+ +
+
+ +
+ + +
@@ -437,6 +445,7 @@ import { oilEn } from '../composables/useOilTranslation' import { getOilCard, setOilCard } from '../composables/useOilCards' import { showConfirm } from '../composables/useDialog' import { api } from '../composables/useApi' +import { parseOilProductPaste } from '../composables/useOilProductPaste' const auth = useAuthStore() const oils = useOilsStore() @@ -468,6 +477,28 @@ const activeCard = ref(null) // Add oil form const addType = ref('oil') +const showSmartPaste = ref(false) +const smartPasteText = ref('') + +function runSmartPaste() { + const raw = smartPasteText.value || '' + if (!raw.trim()) return + const parsed = parseOilProductPaste(raw) + if (parsed.memberPrice != null) newBottlePrice.value = parsed.memberPrice + if (parsed.retailPrice != null) newRetailPrice.value = parsed.retailPrice + if (parsed.cn) newOilName.value = parsed.cn + if (parsed.en) newOilEnName.value = parsed.en + addType.value = parsed.type + if (parsed.type === 'oil') { + if (parsed.volume) newVolume.value = parsed.volume + newCustomDrops.value = null + } else { + if (parsed.productAmount != null) newProductAmount.value = parsed.productAmount + if (parsed.productUnit) newProductUnit.value = parsed.productUnit + } + ui.showToast('已识别并填入,请检查后点添加') +} + const newOilName = ref('') const newOilEnName = ref('') const newBottlePrice = ref(null) @@ -614,8 +645,14 @@ const filteredOilNames = computed(() => { if (!searchQuery.value.trim()) return oils.oilNames const q = searchQuery.value.trim().toLowerCase() return oils.oilNames.filter(n => { - const en = getEnglishName(n).toLowerCase() - return n.toLowerCase().includes(q) || en.includes(q) + if (n.toLowerCase().includes(q)) return true + const card = getOilCard(n) + if (card?.en && card.en.toLowerCase().includes(q)) return true + const meta = oils.oilsMeta[n] + if (meta?.enName && meta.enName.toLowerCase().includes(q)) return true + const fallback = oilEn(n) + if (fallback && fallback.toLowerCase().includes(q)) return true + return false }) }) diff --git a/frontend/src/views/RecipeManager.vue b/frontend/src/views/RecipeManager.vue index 6631383..5f17e50 100644 --- a/frontend/src/views/RecipeManager.vue +++ b/frontend/src/views/RecipeManager.vue @@ -1699,7 +1699,7 @@ async function exportExcel() { } const today = new Date().toISOString().slice(0, 10) - XLSX.writeFile(wb, `精油配方${today}.xlsx`) + XLSX.writeFile(wb, `精油配方备份${today}.xlsx`) ui.showToast('导出成功') }