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 06c85a9..b371b2e 100644 --- a/frontend/src/views/OilReference.vue +++ b/frontend/src/views/OilReference.vue @@ -445,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() @@ -478,51 +479,23 @@ const activeCard = ref(null) const addType = ref('oil') const showSmartPaste = ref(false) const smartPasteText = ref('') -const OIL_VOLUMES = new Set(['2.5', '5', '10', '15', '115']) function runSmartPaste() { const raw = smartPasteText.value || '' if (!raw.trim()) return - 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|颗|粒|片)/) - - let cn = '', en = '' - 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) { cn = m[1].trim(); en = m[2].trim() } else { cn = s } - break - } - - if (memberMatch) newBottlePrice.value = Number(memberMatch[1]) - if (retailMatch) newRetailPrice.value = Number(retailMatch[1]) - if (cn) newOilName.value = cn - if (en) newOilEnName.value = en - - if (specMatch) { - const amount = specMatch[1] - const unitRaw = specMatch[2].toLowerCase() - const isMl = unitRaw === '毫升' || unitRaw === 'ml' - if (isMl && OIL_VOLUMES.has(String(Number(amount)))) { - addType.value = 'oil' - newVolume.value = String(Number(amount)) - newCustomDrops.value = null - } else { - addType.value = 'product' - newProductAmount.value = Number(amount) - newProductUnit.value = (unitRaw === '克' || unitRaw === 'g') ? 'g' - : (unitRaw === '颗' || unitRaw === '粒' || unitRaw === '片') ? 'capsule' - : 'ml' - } + 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 { - addType.value = 'product' + if (parsed.productAmount != null) newProductAmount.value = parsed.productAmount + if (parsed.productUnit) newProductUnit.value = parsed.productUnit } - ui.showToast('已识别并填入,请检查后点添加') }