Compare commits

..

21 Commits

Author SHA1 Message Date
ed8d49d9a0 fix: 多项修复 — 滴数框/零售价显示/精油英文名保存/再次审核通知
Some checks failed
Test / unit-test (push) Successful in 6s
Test / build-check (push) Successful in 4s
PR Preview / teardown-preview (pull_request) Has been skipped
Test / e2e-test (push) Failing after 25s
PR Preview / test (pull_request) Successful in 6s
PR Preview / deploy-preview (pull_request) Successful in 15s
1. 编辑配方的滴数输入框从 42px 加宽到 58px,确保 50.5 不被 spinner 遮挡
2. 配方卡片在零售价==会员价时也显示零售价(之前因 retail>cost 过滤掉)
3. 精油价目英文名保存后被静态 OIL_CARDS 覆盖,把 getEnglishName 的优先级改
   为先用 DB meta.enName,解决温柔呵护/仕女呵护等显示不更新的问题
4. 再次审核 tag 的配方被非管理员修改时,给管理员发通知,内容含前后 diff
5. 对应 vitest + cypress 测试各一组

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 10:39:19 +00:00
bf29551a31 ci: retrigger after Batch 2 timeout flake
All checks were successful
PR Preview / test (pull_request) Has been skipped
Deploy Production / test (push) Successful in 6s
PR Preview / teardown-preview (pull_request) Successful in 13s
PR Preview / deploy-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 6s
Test / build-check (push) Successful in 4s
Deploy Production / deploy (push) Successful in 5s
Test / e2e-test (push) Successful in 2m59s
2026-04-15 10:05:21 +00:00
a8c9c2252f fix: hourly-backup 改用 python sqlite3 API 做一致性备份
Some checks failed
Test / unit-test (push) Successful in 5s
PR Preview / teardown-preview (pull_request) Has been skipped
Test / build-check (push) Successful in 4s
Test / e2e-test (push) Failing after 6m1s
PR Preview / test (pull_request) Successful in 6s
PR Preview / deploy-preview (pull_request) Successful in 8s
镜像里没有 sqlite3 CLI,原来的 .backup 命令 15 天前就静默失败了。
daily-minio-backup 用 cp 就够,但 hourly 每小时跑要求更强一致性,
改用 Python 内置 sqlite3 的 .backup() API。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:56:40 +00:00
6baecfc2bf ci: retrigger after Batch 3 timeout flake
All checks were successful
PR Preview / test (pull_request) Has been skipped
Deploy Production / test (push) Successful in 7s
PR Preview / teardown-preview (pull_request) Successful in 13s
PR Preview / deploy-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 6s
Test / build-check (push) Successful in 4s
Deploy Production / deploy (push) Successful in 6s
Test / e2e-test (push) Successful in 2m59s
2026-04-15 09:10:44 +00:00
fam
a0de8fa7f3 Merge branch 'main' into feat/kit-export-volume
Some checks failed
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 7s
Test / build-check (push) Successful in 4s
PR Preview / test (pull_request) Successful in 5s
PR Preview / deploy-preview (pull_request) Successful in 16s
Test / e2e-test (push) Failing after 6m56s
2026-04-15 08:54:54 +00:00
0ce14352f1 feat: 套装方案对比导出时配方名后附容量
All checks were successful
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 5s
Test / build-check (push) Successful in 4s
PR Preview / test (pull_request) Successful in 6s
PR Preview / deploy-preview (pull_request) Successful in 14s
Test / e2e-test (push) Successful in 2m58s
形如「某配方(100ml)」。full/simple/横向对比三个 sheet 都带上。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 23:03:05 +00:00
2dca4d13b9 fix: 导出 Excel 合并「单价」为一列,值形如 ¥X.XX/ml|g|颗|滴
Some checks failed
PR Preview / test (pull_request) Has been skipped
Deploy Production / test (push) Successful in 7s
PR Preview / teardown-preview (pull_request) Successful in 14s
PR Preview / deploy-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 6s
Test / build-check (push) Successful in 4s
Deploy Production / deploy (push) Successful in 6s
Test / e2e-test (push) Failing after 6m55s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 22:37:57 +00:00
a28ba1ef57 feat: 精油价目导出改为 Excel(.xlsx)
All checks were successful
Test / unit-test (push) Successful in 6s
Test / build-check (push) Successful in 5s
PR Preview / teardown-preview (pull_request) Has been skipped
Test / e2e-test (push) Successful in 3m2s
PR Preview / test (pull_request) Successful in 6s
PR Preview / deploy-preview (pull_request) Successful in 15s
- 按钮从「导出PDF」改「导出Excel」,动态 import xlsx
- 列:精油/英文名/会员价/零售价/容量/单价/状态
- 文件名:精油价目表YYYY-MM-DD.xlsx
- 新增 e2e(按钮可见 + 点击出 toast)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 22:31:15 +00:00
fef28330f0 ci: 将 oil-smart-paste.cy.js 加入 Batch 3
Some checks failed
PR Preview / test (pull_request) Has been skipped
Deploy Production / test (push) Successful in 6s
PR Preview / teardown-preview (pull_request) Successful in 14s
PR Preview / deploy-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 6s
Test / build-check (push) Successful in 4s
Deploy Production / deploy (push) Successful in 7s
Test / e2e-test (push) Failing after 6m2s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:58:01 +00:00
27418695a5 test: 智能识别与英文名搜索的单测 + e2e
Some checks failed
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 6s
Test / build-check (push) Successful in 6s
PR Preview / test (pull_request) Successful in 6s
PR Preview / deploy-preview (pull_request) Successful in 16s
Test / e2e-test (push) Failing after 6m2s
- 将粘贴解析抽到 useOilProductPaste composable
- 8 条 vitest 覆盖价格/规格/中英文名/类型判断
- 2 条 cypress 覆盖 UI 填充(产品 100ml、精油 15ml)
- 补英文名搜索 e2e;旧 search 用例 placeholder 选择器宽松化

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:45:16 +00:00
1053cf9140 feat: 价目搜索支持英文名(card.en / meta.enName / 静态表)
Some checks failed
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 6s
Test / build-check (push) Successful in 5s
PR Preview / test (pull_request) Successful in 6s
PR Preview / deploy-preview (pull_request) Successful in 14s
Test / e2e-test (push) Failing after 3m2s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:27:56 +00:00
1613b54bc6 feat: 精油价目新增「智能识别」,粘贴产品信息自动填充字段
Some checks failed
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 7s
Test / build-check (push) Successful in 5s
PR Preview / test (pull_request) Successful in 6s
PR Preview / deploy-preview (pull_request) Successful in 25s
Test / e2e-test (push) Failing after 6m4s
识别优惠顾客价/零售价/规格/中英文名,自动切精油或其他产品 tab。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:20:18 +00:00
9fc89cdb74 fix: 导出文件名改为「精油配方备份+日期」
Some checks failed
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 6s
Test / build-check (push) Successful in 3s
PR Preview / test (pull_request) Successful in 5s
PR Preview / deploy-preview (pull_request) Successful in 15s
Test / e2e-test (push) Failing after 6m2s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:04:05 +00:00
cca7dd4471 fix: demo-walkthrough移到Batch2(10个spec),Batch3减到15个
All checks were successful
PR Preview / test (pull_request) Has been skipped
PR Preview / teardown-preview (pull_request) Successful in 15s
PR Preview / deploy-preview (pull_request) Has been skipped
Deploy Production / test (push) Successful in 13s
Deploy Production / deploy (push) Successful in 7s
Test / unit-test (push) Successful in 7s
Test / build-check (push) Successful in 4s
Test / e2e-test (push) Successful in 2m59s
Batch3跑16个spec时内存累积导致最后一个超时。均衡分配。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 19:12:25 +00:00
7fbf5586b5 fix: price-display不再打开配方详情(html2canvas CI卡死)
Some checks failed
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 5s
Test / build-check (push) Successful in 4s
PR Preview / test (pull_request) Successful in 5s
PR Preview / deploy-preview (pull_request) Successful in 14s
Test / e2e-test (push) Failing after 6m53s
改为验证卡片价格格式,配方详情价格已由recipe-detail覆盖。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 18:54:48 +00:00
f8d368a03a ci: retry e2e tests
Some checks failed
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 6s
Test / build-check (push) Successful in 4s
PR Preview / test (pull_request) Successful in 6s
PR Preview / deploy-preview (pull_request) Successful in 9s
Test / e2e-test (push) Failing after 6m55s
2026-04-14 18:40:18 +00:00
317ea3a2b6 test: 分享文本和消耗分析动态单位测试
Some checks failed
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 6s
Test / build-check (push) Successful in 4s
PR Preview / test (pull_request) Successful in 6s
PR Preview / deploy-preview (pull_request) Successful in 14s
Test / e2e-test (push) Failing after 1m27s
新增2个测试: 分享文本各成分用正确单位、消耗分析用量/容量单位

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 18:09:08 +00:00
18a74df083 fix: 补全剩余硬编码单位 — 配方分享文本和商业核算消耗分析
All checks were successful
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 6s
Test / build-check (push) Successful in 4s
PR Preview / test (pull_request) Successful in 5s
PR Preview / deploy-preview (pull_request) Successful in 16s
Test / e2e-test (push) Successful in 2m55s
- RecipeDetailOverlay: 分享文本 ing.drops滴 → unitLabel
- Projects: 消耗分析 每滴→单价, drops滴→unitLabel

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:47:35 +00:00
2330ce1f2c test: PR34测试 — 产品编辑表单单位切换逻辑
All checks were successful
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 5s
Test / build-check (push) Successful in 4s
PR Preview / test (pull_request) Successful in 5s
PR Preview / deploy-preview (pull_request) Successful in 14s
Test / e2e-test (push) Successful in 2m57s
新增8个测试: 单位判断、表单初始化、保存参数、标签适配

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:42:01 +00:00
c9af05219b fix: 打开新增/编辑配方时重新加载oils,确保新增产品可搜索
All checks were successful
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 6s
Test / build-check (push) Successful in 4s
PR Preview / test (pull_request) Successful in 6s
PR Preview / deploy-preview (pull_request) Successful in 17s
Test / e2e-test (push) Successful in 2m58s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:21:15 +00:00
e30891d3d2 feat: 非精油产品编辑页面适配 — 容量输入框+单位下拉(ml/g/颗)
All checks were successful
Test / unit-test (push) Successful in 6s
Test / build-check (push) Successful in 4s
PR Preview / teardown-preview (pull_request) Has been skipped
Test / e2e-test (push) Successful in 2m56s
PR Preview / test (pull_request) Successful in 6s
PR Preview / deploy-preview (pull_request) Successful in 16s
- 编辑弹窗根据unit类型显示不同UI:精油用标准容量下拉,其他产品用数字+单位选择
- 标签"精油名称"/"产品名称"自动切换
- 保存时传unit参数

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:11:50 +00:00
18 changed files with 683 additions and 108 deletions

View File

@@ -74,13 +74,13 @@ jobs:
echo "=== Batch 2: UI flow tests ==="
timeout 300 npx cypress run \
--spec "cypress/e2e/auth-flow.cy.js,cypress/e2e/admin-flow.cy.js,cypress/e2e/navigation.cy.js,cypress/e2e/recipe-detail.cy.js,cypress/e2e/recipe-search.cy.js,cypress/e2e/manage-recipes.cy.js,cypress/e2e/diary-flow.cy.js,cypress/e2e/favorites.cy.js,cypress/e2e/inventory-flow.cy.js" \
--spec "cypress/e2e/auth-flow.cy.js,cypress/e2e/admin-flow.cy.js,cypress/e2e/navigation.cy.js,cypress/e2e/recipe-detail.cy.js,cypress/e2e/recipe-search.cy.js,cypress/e2e/manage-recipes.cy.js,cypress/e2e/diary-flow.cy.js,cypress/e2e/favorites.cy.js,cypress/e2e/inventory-flow.cy.js,cypress/e2e/demo-walkthrough.cy.js" \
--config "$CYPRESS_CFG" --env "ADMIN_TOKEN=$ADMIN_TOKEN"
B2=$?
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,cypress/e2e/demo-walkthrough.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=$?

View File

@@ -887,6 +887,11 @@ def update_recipe(recipe_id: int, update: RecipeUpdate, user=Depends(get_current
conn.close()
raise HTTPException(409, "此配方已被其他人修改,请刷新后重试")
# Snapshot before state for re-review diff notification
before_row = c.execute("SELECT name, note, en_name, volume FROM recipes WHERE id = ?", (recipe_id,)).fetchone()
before_ings = [dict(r) for r in c.execute("SELECT oil_name, drops FROM recipe_ingredients WHERE recipe_id = ?", (recipe_id,)).fetchall()]
before_tags = set(r["tag_name"] for r in c.execute("SELECT tag_name FROM recipe_tags WHERE recipe_id = ?", (recipe_id,)).fetchall())
if update.name is not None:
c.execute("UPDATE recipes SET name = ? WHERE id = ?", (update.name, recipe_id))
# Re-translate en_name if name changed and no explicit en_name provided
@@ -925,6 +930,35 @@ def update_recipe(recipe_id: int, update: RecipeUpdate, user=Depends(get_current
log_audit(conn, user["id"], "update_recipe", "recipe", recipe_id,
rname["name"] if rname else update.name,
json.dumps({"changed": "".join(changed)}, ensure_ascii=False) if changed else None)
# Notify admin when non-admin user edits a recipe tagged 再次审核
after_tags = before_tags if update.tags is None else set(update.tags)
needs_review = "再次审核" in (before_tags | after_tags)
if user.get("role") != "admin" and needs_review and changed:
after_row = c.execute("SELECT name, note, en_name, volume FROM recipes WHERE id = ?", (recipe_id,)).fetchone()
after_ings = [dict(r) for r in c.execute("SELECT oil_name, drops FROM recipe_ingredients WHERE recipe_id = ?", (recipe_id,)).fetchall()]
diff_lines = []
def _fmt_ings(ings):
return "".join(f"{i['oil_name']} {i['drops']}" for i in ings) or "(空)"
if update.name is not None and before_row["name"] != after_row["name"]:
diff_lines.append(f"名称:{before_row['name']}{after_row['name']}")
if update.ingredients is not None and before_ings != after_ings:
diff_lines.append(f"成分:{_fmt_ings(before_ings)}{_fmt_ings(after_ings)}")
if update.tags is not None and before_tags != after_tags:
diff_lines.append(f"标签:{''.join(sorted(before_tags)) or '(空)'}{''.join(sorted(after_tags)) or '(空)'}")
if update.note is not None and (before_row["note"] or "") != (after_row["note"] or ""):
diff_lines.append(f"备注:{before_row['note'] or '(空)'}{after_row['note'] or '(空)'}")
if update.en_name is not None and (before_row["en_name"] or "") != (after_row["en_name"] or ""):
diff_lines.append(f"英文名:{before_row['en_name'] or '(空)'}{after_row['en_name'] or '(空)'}")
if diff_lines:
editor = user.get("display_name") or user.get("username") or f"user#{user['id']}"
title = f"📝 再次审核配方被修改:{after_row['name']}"
body = f"{editor} 修改了配方「{after_row['name']}」:\n\n" + "\n".join(diff_lines)
conn.execute(
"INSERT INTO notifications (target_role, title, body) VALUES (?, ?, ?)",
("admin", title, body),
)
conn.commit()
conn.close()
return {"ok": True}

View File

@@ -18,12 +18,14 @@ spec:
- sh
- -c
- |
set -e
BACKUP_DIR=/data/backups
mkdir -p $BACKUP_DIR
DATE=$(date +%Y%m%d_%H%M%S)
# Backup SQLite database using .backup for consistency
sqlite3 /data/oil_calculator.db ".backup '$BACKUP_DIR/oil_calculator_${DATE}.db'"
echo "Backup done: $BACKUP_DIR/oil_calculator_${DATE}.db ($(du -h $BACKUP_DIR/oil_calculator_${DATE}.db | cut -f1))"
DST="$BACKUP_DIR/oil_calculator_${DATE}.db"
# Consistent snapshot via Python's sqlite3 .backup API (sqlite3 CLI not in image)
python3 -c "import sqlite3; s=sqlite3.connect('/data/oil_calculator.db'); d=sqlite3.connect('$DST'); s.backup(d); d.close(); s.close()"
echo "Backup done: $DST ($(du -h $DST | cut -f1))"
# Keep last 48 backups (2 days of hourly)
ls -t $BACKUP_DIR/oil_calculator_*.db | tail -n +49 | xargs rm -f 2>/dev/null
echo "Backups retained: $(ls $BACKUP_DIR/oil_calculator_*.db | wc -l)"

View File

@@ -0,0 +1,28 @@
describe('Oil Reference Excel Export', () => {
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('shows the Excel export button for admins', () => {
cy.contains('button', '📥 导出Excel').should('be.visible')
})
it('clicking Excel export shows success toast', () => {
cy.window().then(win => {
cy.stub(win.HTMLAnchorElement.prototype, 'click').returns(undefined)
})
cy.contains('button', '📥 导出Excel').click()
cy.get('.toast', { timeout: 10000 }).should('contain', '导出成功')
})
})

View File

@@ -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()

View File

@@ -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', '薰衣草测试')
})
})

View File

@@ -29,16 +29,14 @@ describe('Price Display Regression', () => {
})
})
it('recipe detail shows non-zero total cost', () => {
it('recipe cards show price in correct format', () => {
cy.visit('/')
cy.get('.recipe-card', { timeout: 10000 }).first().click()
cy.wait(1000)
// Look for any ¥ amount > 0 in the detail overlay
cy.get('[class*="overlay"], [class*="detail"]').invoke('text').then(text => {
const prices = [...text.matchAll(\s*(\d+\.?\d*)/g)].map(m => parseFloat(m[1]))
const nonZero = prices.filter(p => p > 0)
expect(nonZero.length, 'Detail should show at least one non-zero price').to.be.gte(1)
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
// Verify multiple cards have prices
cy.get('.recipe-card-price').should('have.length.gte', 1)
cy.get('.recipe-card-price').each($el => {
const text = $el.text()
expect(text).to.match(|💰/)
})
})
})

View File

@@ -0,0 +1,92 @@
// Verifies that when a non-admin edits a recipe tagged 再次审核,
// an admin-targeted notification is created containing a before/after diff.
describe('Re-review notification on non-admin edit', () => {
let adminToken
let viewerToken
let recipeId
before(() => {
cy.getAdminToken().then(t => {
adminToken = t
const uname = 'editor_' + Date.now()
cy.request({
method: 'POST', url: '/api/register',
body: { username: uname, password: 'pw12345678' }
}).then(res => {
viewerToken = res.body.token
// Look up user id via admin /api/users, then promote to editor
cy.request({ url: '/api/users', headers: { Authorization: `Bearer ${adminToken}` } })
.then(r => {
const u = r.body.find(x => x.username === uname)
cy.request({
method: 'PUT', url: `/api/users/${u.id}`,
headers: { Authorization: `Bearer ${adminToken}` },
body: { role: 'editor' },
})
})
})
})
})
it('creates admin notification with diff lines', () => {
// Editor creates their own recipe tagged 再次审核
cy.request({
method: 'POST', url: '/api/recipes',
headers: { Authorization: `Bearer ${viewerToken}` },
body: {
name: 're-review-fixture-' + Date.now(),
ingredients: [{ oil_name: '薰衣草', drops: 3 }],
tags: ['再次审核'],
},
}).then(res => {
recipeId = res.body.id
expect(recipeId).to.be.a('number')
})
// Mark notifications read so we can detect the new one
cy.request({
method: 'POST', url: '/api/notifications/read-all',
headers: { Authorization: `Bearer ${adminToken}` }, body: {},
})
// Non-admin edits the recipe
cy.then(() => {
cy.request({
method: 'PUT', url: `/api/recipes/${recipeId}`,
headers: { Authorization: `Bearer ${viewerToken}` },
body: {
ingredients: [{ oil_name: '薰衣草', drops: 5 }, { oil_name: '柠檬', drops: 2 }],
note: '新备注',
},
}).then(r => expect(r.status).to.eq(200))
})
// Admin sees a new unread notification mentioning the recipe and diff
cy.then(() => {
cy.request({ url: '/api/notifications', headers: { Authorization: `Bearer ${adminToken}` } })
.then(res => {
const unread = res.body.filter(n => !n.is_read && n.title && n.title.includes('再次审核配方被修改'))
expect(unread.length).to.be.greaterThan(0)
expect(unread[0].body).to.match(/成分|备注/)
expect(unread[0].body).to.contain('→')
})
})
})
it('admin edits do NOT create re-review notification', () => {
cy.request({
method: 'POST', url: '/api/notifications/read-all',
headers: { Authorization: `Bearer ${adminToken}` }, body: {},
})
cy.request({
method: 'PUT', url: `/api/recipes/${recipeId}`,
headers: { Authorization: `Bearer ${adminToken}` },
body: { note: '管理员备注' },
})
cy.request({ url: '/api/notifications', headers: { Authorization: `Bearer ${adminToken}` } })
.then(res => {
const unread = res.body.filter(n => !n.is_read && n.title && n.title.includes('再次审核配方被修改'))
expect(unread.length).to.eq(0)
})
})
})

View File

@@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest'
// Replicates the fixed fmtCostWithRetail logic: retail shown whenever any ingredient
// has a retail price stored (even when it equals the member price).
function fmtCostWithRetail(ingredients, oilsMeta) {
const cost = ingredients.reduce((s, i) => {
const m = oilsMeta[i.oil]
return s + (m ? (m.bottlePrice / m.dropCount) * i.drops : 0)
}, 0)
const retail = ingredients.reduce((s, i) => {
const m = oilsMeta[i.oil]
if (m && m.retailPrice && m.dropCount) return s + (m.retailPrice / m.dropCount) * i.drops
return s + (m ? (m.bottlePrice / m.dropCount) * i.drops : 0)
}, 0)
const anyRetail = ingredients.some(i => {
const m = oilsMeta[i.oil]
return m && m.retailPrice && m.dropCount
})
if (anyRetail && retail > 0) {
return { cost: '¥ ' + cost.toFixed(2), retail: '¥ ' + retail.toFixed(2), hasRetail: true }
}
return { cost: '¥ ' + cost.toFixed(2), retail: null, hasRetail: false }
}
describe('fmtCostWithRetail — retail price display', () => {
it('shows retail when retail > member', () => {
const meta = { '玫瑰': { bottlePrice: 100, retailPrice: 150, dropCount: 10 } }
const r = fmtCostWithRetail([{ oil: '玫瑰', drops: 5 }], meta)
expect(r.hasRetail).toBe(true)
expect(r.retail).toBe('¥ 75.00')
})
it('still shows retail when retail === member (regression: 带玫瑰护手霜 case)', () => {
const meta = { '玫瑰护手霜': { bottlePrice: 300, retailPrice: 300, dropCount: 50 } }
const r = fmtCostWithRetail([{ oil: '玫瑰护手霜', drops: 5 }], meta)
expect(r.hasRetail).toBe(true)
expect(r.cost).toBe('¥ 30.00')
expect(r.retail).toBe('¥ 30.00')
})
it('no retail when ingredient has no retail price', () => {
const meta = { '薰衣草': { bottlePrice: 100, retailPrice: null, dropCount: 10 } }
const r = fmtCostWithRetail([{ oil: '薰衣草', drops: 5 }], meta)
expect(r.hasRetail).toBe(false)
expect(r.retail).toBeNull()
})
})
// getEnglishName priority fix — DB en_name must beat static card override.
function getEnglishName(name, oilsMeta, cards, aliases, oilEnFn) {
const meta = oilsMeta[name]
if (meta?.enName) return meta.enName
if (cards[name]?.en) return cards[name].en
if (aliases[name] && cards[aliases[name]]?.en) return cards[aliases[name]].en
const base = name.replace(/呵护$/, '')
if (base !== name && cards[base]?.en) return cards[base].en
return oilEnFn ? oilEnFn(name) : ''
}
describe('getEnglishName — DB wins over static card', () => {
const cards = {
'温柔呵护': { en: 'Soft Talk' },
'椒样薄荷': { en: 'Peppermint' },
'西班牙牛至': { en: 'Oregano' },
}
const aliases = { '仕女呵护': '温柔呵护', '薄荷呵护': '椒样薄荷', '牛至呵护': '西班牙牛至' }
it('uses DB en_name over static card en (温柔呵护 regression)', () => {
const meta = { '温柔呵护': { enName: 'Clary Calm' } }
expect(getEnglishName('温柔呵护', meta, cards, aliases)).toBe('Clary Calm')
})
it('uses DB en_name over aliased card en (仕女呵护 regression)', () => {
const meta = { '仕女呵护': { enName: 'Soft Talk Touch' } }
expect(getEnglishName('仕女呵护', meta, cards, aliases)).toBe('Soft Talk Touch')
})
it('falls back to static card when DB en_name is empty', () => {
const meta = { '温柔呵护': { enName: '' } }
expect(getEnglishName('温柔呵护', meta, cards, aliases)).toBe('Soft Talk')
})
it('alias still works as fallback', () => {
const meta = { '牛至呵护': {} }
expect(getEnglishName('牛至呵护', meta, cards, aliases)).toBe('Oregano')
})
})

View File

@@ -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('')
})
})

View File

@@ -542,3 +542,106 @@ describe('oil card branding — PR33', () => {
expect(oilPriceUnit('植物空胶囊')).toBe('颗')
})
})
// ---------------------------------------------------------------------------
// PR34: Product edit UI — unit-based form switching
// ---------------------------------------------------------------------------
describe('product edit UI logic — PR34', () => {
it('drop unit shows standard volume selector', () => {
const unit = 'drop'
expect(unit === 'drop').toBe(true)
})
it('non-drop unit shows amount + unit selector', () => {
for (const u of ['ml', 'g', 'capsule']) {
expect(u !== 'drop').toBe(true)
}
})
it('edit form initializes correct unit from meta', () => {
const meta = { unit: 'g', dropCount: 80 }
const editUnit = meta.unit || 'drop'
const editProductAmount = editUnit !== 'drop' ? meta.dropCount : null
const editProductUnit = editUnit !== 'drop' ? editUnit : 'ml'
expect(editUnit).toBe('g')
expect(editProductAmount).toBe(80)
expect(editProductUnit).toBe('g')
})
it('edit form defaults to drop for oils', () => {
const meta = { unit: 'drop', dropCount: 280 }
const editUnit = meta.unit || 'drop'
expect(editUnit).toBe('drop')
})
it('edit form defaults to drop when unit is undefined', () => {
const meta = { dropCount: 280 }
const editUnit = meta.unit || 'drop'
expect(editUnit).toBe('drop')
})
it('save uses product amount and unit for non-drop', () => {
const editUnit = 'ml'
const editProductAmount = 200
const editProductUnit = 'ml'
const dropCount = 280 // from standard volume selector
const finalDropCount = editUnit !== 'drop' ? editProductAmount : dropCount
const finalUnit = editUnit !== 'drop' ? editProductUnit : null
expect(finalDropCount).toBe(200)
expect(finalUnit).toBe('ml')
})
it('save uses standard drop count for oils', () => {
const editUnit = 'drop'
const editProductAmount = null
const dropCount = 280
const finalDropCount = editUnit !== 'drop' ? editProductAmount : dropCount
const finalUnit = editUnit !== 'drop' ? 'ml' : null
expect(finalDropCount).toBe(280)
expect(finalUnit).toBeNull()
})
it('label adapts: 精油名称 for oils, 产品名称 for products', () => {
const labelForDrop = 'drop' === 'drop' ? '精油名称' : '产品名称'
const labelForMl = 'ml' === 'drop' ? '精油名称' : '产品名称'
expect(labelForDrop).toBe('精油名称')
expect(labelForMl).toBe('产品名称')
})
})
// ---------------------------------------------------------------------------
// PR34: Share text and consumption analysis use dynamic unit
// ---------------------------------------------------------------------------
describe('share text and consumption use dynamic unit — PR34', () => {
const UNIT_MAP = { drop: '滴', ml: 'ml', g: 'g', capsule: '颗' }
function unitLabel(name, unitMap) { return UNIT_MAP[unitMap[name] || 'drop'] }
it('share text uses unitLabel for each ingredient', () => {
const units = { '薰衣草': 'drop', '无香乳液': 'ml', '植物空胶囊': 'capsule' }
const ings = [
{ oil: '薰衣草', drops: 3 },
{ oil: '无香乳液', drops: 30 },
{ oil: '植物空胶囊', drops: 2 },
]
const lines = ings.map(i => `${i.oil} ${i.drops}${unitLabel(i.oil, units)}`)
expect(lines[0]).toBe('薰衣草 3滴')
expect(lines[1]).toBe('无香乳液 30ml')
expect(lines[2]).toBe('植物空胶囊 2颗')
})
it('consumption analysis uses unitLabel per oil', () => {
const units = { '薰衣草': 'drop', '活力磨砂膏': 'g' }
const data = [
{ oil: '薰衣草', drops: 15, bottleDrops: 280 },
{ oil: '活力磨砂膏', drops: 30, bottleDrops: 70 },
]
const display = data.map(c => ({
usage: `${c.drops}${unitLabel(c.oil, units)}`,
capacity: `${c.bottleDrops}${unitLabel(c.oil, units)}`,
}))
expect(display[0].usage).toBe('15滴')
expect(display[0].capacity).toBe('280滴')
expect(display[1].usage).toBe('30g')
expect(display[1].capacity).toBe('70g')
})
})

View File

@@ -66,7 +66,7 @@
<span class="ec-oil-name">{{ getCardOilName(ing.oil) }}</span>
<span class="ec-drops">{{ ing.drops }} {{ oilsStore.unitLabelPlural(ing.oil, ing.drops, cardLang) }}</span>
<span class="ec-cost">{{ oilsStore.fmtPrice(oilsStore.pricePerDrop(ing.oil) * ing.drops) }}</span>
<span v-if="cardHasAnyRetail" class="ec-retail">{{ hasRetailForOil(ing.oil) && retailPerDrop(ing.oil) > oilsStore.pricePerDrop(ing.oil) ? oilsStore.fmtPrice(retailPerDrop(ing.oil) * ing.drops) : '' }}</span>
<span v-if="cardHasAnyRetail" class="ec-retail">{{ hasRetailForOil(ing.oil) ? oilsStore.fmtPrice(retailPerDrop(ing.oil) * ing.drops) : '' }}</span>
</li>
</ul>
@@ -483,7 +483,7 @@ function copyText() {
const ings = cardIngredients.value
const lines = ings.map(ing => {
const cost = oilsStore.pricePerDrop(ing.oil) * ing.drops
return `${ing.oil} ${ing.drops} ${oilsStore.fmtPrice(cost)}`
return `${ing.oil} ${ing.drops}${oilsStore.unitLabel(ing.oil)} ${oilsStore.fmtPrice(cost)}`
})
const total = priceInfo.value.cost
const text = [
@@ -591,7 +591,7 @@ function getCardRecipeName() {
}
const cardHasAnyRetail = computed(() =>
cardIngredients.value.some(ing => hasRetailForOil(ing.oil) && retailPerDrop(ing.oil) > oilsStore.pricePerDrop(ing.oil))
cardIngredients.value.some(ing => hasRetailForOil(ing.oil))
)
const cardTitleSize = computed(() => {
@@ -1699,8 +1699,8 @@ async function saveRecipe() {
}
.editor-drops {
width: 42px;
padding: 5px 2px;
width: 58px;
padding: 5px 4px 5px 6px;
border: 1.5px solid var(--border, #e0d4c0);
border-radius: 8px;
font-size: 13px;

View File

@@ -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
}

View File

@@ -50,7 +50,11 @@ export const useOilsStore = defineStore('oils', () => {
const cost = calcCost(ingredients)
const retail = calcRetailCost(ingredients)
const costStr = fmtPrice(cost)
if (retail > cost) {
const anyRetail = ingredients.some(i => {
const m = oilsMeta.value[i.oil]
return m && m.retailPrice && m.dropCount
})
if (anyRetail && retail > 0) {
return { cost: costStr, retail: fmtPrice(retail), hasRetail: true }
}
return { cost: costStr, retail: null, hasRetail: false }

View File

@@ -267,8 +267,9 @@ async function exportExcel(mode) {
const unit = oils.unitLabel(i.oil)
return `${i.oil} ${i.drops}${unit}`
}).join('、')
const vol = volumeLabel(r)
ws.addRow([
r.name,
vol ? `${r.name}${vol}` : r.name,
(r.tags || []).join('/'),
ingredientStr,
calcMaxTimes(r),
@@ -287,8 +288,9 @@ async function exportExcel(mode) {
for (const r of ka.recipes) {
const price = getSellingPrice(r._id)
const margin = calcMargin(r.kitCost, price)
const vol = volumeLabel(r)
ws.addRow([
r.name,
vol ? `${r.name}${vol}` : r.name,
calcMaxTimes(r),
Number(r.kitCost.toFixed(2)),
Number(r.originalCost.toFixed(2)),
@@ -326,7 +328,8 @@ async function exportExcel(mode) {
for (const row of crossComparison.value) {
const price = getSellingPrice(row.id)
const vals = [row.name]
const vol = volumeLabel(row)
const vals = [vol ? `${row.name}${vol}` : row.name]
if (mode === 'full') vals.push((row.tags || []).join('/'))
for (const ka of kitAnalysis.value) {
const cost = row.costs[ka.id]

View File

@@ -91,7 +91,7 @@
<!-- Search + View Toggle + Add + PDF -->
<div style="display:flex;gap:6px;align-items:center;margin-bottom:12px;flex-wrap:nowrap">
<div class="search-box" style="flex:1;min-width:140px;margin-bottom:0">
<input class="search-input" v-model="searchQuery" placeholder="搜索精油名称…" style="width:100%" />
<input class="search-input" v-model="searchQuery" placeholder="搜索中文或英文名…" style="width:100%" />
</div>
<div style="display:flex;border:1.5px solid var(--border);border-radius:10px;overflow:hidden;flex-shrink:0">
<button @click="viewMode = 'bottle'" :style="viewMode === 'bottle' ? 'background:var(--sage);color:white' : 'background:white;color:var(--text-mid)'" style="border:none;border-radius:0;font-size:12px;padding:6px 12px;cursor:pointer">每瓶价</button>
@@ -99,10 +99,10 @@
</div>
<!-- Desktop: text buttons -->
<button v-if="auth.canManage" class="toolbar-btn-text" @click="showAddForm = !showAddForm">{{ showAddForm ? '收起' : ' 新增' }}</button>
<button v-if="auth.isAdmin" class="toolbar-btn-text" @click="exportPDF">📥 导出PDF</button>
<button v-if="auth.isAdmin" class="toolbar-btn-text" @click="exportExcel">📥 导出Excel</button>
<!-- Mobile: emoji-only buttons -->
<button v-if="auth.canManage" class="toolbar-btn-icon" @click="showAddForm = !showAddForm" title="新增精油"></button>
<button v-if="auth.isAdmin" class="toolbar-btn-icon" @click="exportPDF" title="导出PDF">📄</button>
<button v-if="auth.isAdmin" class="toolbar-btn-icon" @click="exportExcel" title="导出Excel">📄</button>
</div>
<!-- Add Oil Form (toggleable) -->
@@ -110,6 +110,14 @@
<div class="add-type-tabs">
<button class="add-type-tab" :class="{ active: addType === 'oil' }" @click="addType = 'oil'">精油</button>
<button class="add-type-tab" :class="{ active: addType === 'product' }" @click="addType = 'product'">其他</button>
<button class="add-type-tab" :class="{ active: showSmartPaste }" @click="showSmartPaste = !showSmartPaste" style="margin-left:auto">🪄 智能识别</button>
</div>
<div v-if="showSmartPaste" class="form-row" style="flex-direction:column;align-items:stretch;gap:6px">
<textarea v-model="smartPasteText" rows="4" class="form-input-sm" placeholder="粘贴产品信息,例如:&#10;优惠顾客价:¥310&#10;零售价:¥465&#10;规格:100毫升&#10;花样年华焕颜精华水 Salubelle Rejuvenating Essence" style="width:100%;resize:vertical;font-family:inherit"></textarea>
<div style="display:flex;gap:8px">
<button class="btn btn-primary btn-sm" @click="runSmartPaste" :disabled="!smartPasteText.trim()">识别并填入</button>
<button class="btn btn-sm" @click="smartPasteText = ''">清空</button>
</div>
</div>
<!-- 新增精油 -->
<div v-if="addType === 'oil'" class="form-row">
@@ -327,28 +335,45 @@
</div>
<div class="modal-body">
<div class="form-group">
<label>精油名称</label>
<input v-model="editOilDisplayName" class="form-input" type="text" placeholder="精油名称" />
<label>{{ editUnit === 'drop' ? '精油名称' : '产品名称' }}</label>
<input v-model="editOilDisplayName" class="form-input" type="text" :placeholder="editUnit === 'drop' ? '精油名称' : '产品名称'" />
</div>
<div class="form-group">
<label>英文名</label>
<input v-model="editOilEnName" class="form-input" type="text" placeholder="English name" />
</div>
<div class="form-group">
<label>容量</label>
<select v-model="editVolume" class="form-select">
<option value="2.5">2.5ml (46)</option>
<option value="5">5ml (93)</option>
<option value="10">10ml (186)</option>
<option value="15">15ml (280)</option>
<option value="115">115ml (2146)</option>
<option value="custom">自定义</option>
</select>
</div>
<div class="form-group" v-if="editVolume === 'custom'">
<label>自定义滴数</label>
<input v-model.number="editDropCount" class="form-input" type="number" />
</div>
<!-- 精油容量 -->
<template v-if="editUnit === 'drop'">
<div class="form-group">
<label>容量</label>
<select v-model="editVolume" class="form-select">
<option value="2.5">2.5ml (46)</option>
<option value="5">5ml (93)</option>
<option value="10">10ml (186)</option>
<option value="15">15ml (280)</option>
<option value="115">115ml (2146)</option>
<option value="custom">自定义</option>
</select>
</div>
<div class="form-group" v-if="editVolume === 'custom'">
<label>自定义滴数</label>
<input v-model.number="editDropCount" class="form-input" type="number" />
</div>
</template>
<!-- 其他产品容量 -->
<template v-else>
<div class="form-group">
<label>容量</label>
<div style="display:flex;gap:6px;align-items:center">
<input v-model.number="editProductAmount" class="form-input" type="number" min="1" style="flex:1" />
<select v-model="editProductUnit" class="form-select" style="width:70px">
<option value="ml">ml</option>
<option value="g">g</option>
<option value="capsule"></option>
</select>
</div>
</div>
</template>
<div class="form-group">
<label>会员价 (¥)</label>
<input v-model.number="editBottlePrice" class="form-input" type="number" />
@@ -420,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()
@@ -451,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)
@@ -468,6 +516,9 @@ const editVolume = ref('5')
const editDropCount = ref(0)
const editRetailPrice = ref(null)
const editOilEnName = ref('')
const editUnit = ref('drop')
const editProductAmount = ref(null)
const editProductUnit = ref('ml')
const editCardEmoji = ref('')
const editCardEffects = ref('')
const editCardUsage = ref('')
@@ -594,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
})
})
@@ -613,12 +670,12 @@ function getMeta(name) {
}
function getEnglishName(name) {
// 1. Oil card has priority
const card = getOilCard(name)
if (card && card.en) return card.en
// 2. Stored en_name in meta
// 1. User-edited en_name in DB wins — prevents saves being masked by static cards
const meta = oils.oilsMeta[name]
if (meta?.enName) return meta.enName
// 2. Oil card fallback
const card = getOilCard(name)
if (card && card.en) return card.en
// 3. Static translation map
return oilEn(name)
}
@@ -727,6 +784,11 @@ function editOil(name) {
editDropCount.value = dc
editRetailPrice.value = meta?.retailPrice || null
editOilEnName.value = meta?.enName || getEnglishName(name) || ''
editUnit.value = meta?.unit || 'drop'
if (editUnit.value !== 'drop') {
editProductAmount.value = dc
editProductUnit.value = editUnit.value
}
// Load knowledge card if exists
const card = getOilCard(name)
editCardEmoji.value = card?.emoji || ''
@@ -752,12 +814,15 @@ async function saveEditOil() {
if (newName && newName !== oldName) {
await oils.deleteOil(oldName)
}
const finalDropCount = editUnit.value !== 'drop' ? editProductAmount.value : dropCount
const finalUnit = editUnit.value !== 'drop' ? editProductUnit.value : null
await oils.saveOil(
newName || oldName,
editBottlePrice.value,
dropCount,
finalDropCount,
editRetailPrice.value,
editOilEnName.value.trim() || null
editOilEnName.value.trim() || null,
finalUnit
)
// Save knowledge card if any content provided
const finalName = newName || oldName
@@ -826,66 +891,38 @@ async function removeOil(name) {
}
}
// PDF Export
function exportPDF() {
// Excel Export
async function exportExcel() {
const XLSX = (await import('xlsx')).default || await import('xlsx')
const today = new Date()
const dateStr = today.getFullYear() + String(today.getMonth()+1).padStart(2,'0') + String(today.getDate()).padStart(2,'0')
const title = '精油价目表' + dateStr
const dateStr = today.getFullYear() + '-' + String(today.getMonth()+1).padStart(2,'0') + '-' + String(today.getDate()).padStart(2,'0')
const sortedNames = [...oils.oilNames].sort((a, b) => a.localeCompare(b, 'zh'))
let rows = ''
const rows = []
for (const name of sortedNames) {
const meta = getMeta(name)
if (!meta) continue
const en = getEnglishName(name)
const bp = meta.bottlePrice != null ? '¥' + meta.bottlePrice.toFixed(0) : '--'
const rp = meta.retailPrice != null ? '¥' + meta.retailPrice.toFixed(0) : '--'
const vol = volumeLabel(meta.dropCount, name)
const unit = oilPriceUnit(name)
const ppd = oils.pricePerDrop(name) ? '¥' + oils.pricePerDrop(name).toFixed(2) + '/' + unit : '--'
rows += `<tr>
<td>${name}</td>
<td>${en}</td>
<td>${bp}</td>
<td>${rp}</td>
<td>${vol}</td>
<td>${ppd}</td>
</tr>`
const ppdNum = oils.pricePerDrop(name)
rows.push({
'精油': name,
'英文名': en,
'会员价': meta.bottlePrice != null ? Number(meta.bottlePrice.toFixed(2)) : '',
'零售价': meta.retailPrice != null ? Number(meta.retailPrice.toFixed(2)) : '',
'容量': vol,
'单价': ppdNum ? `¥${ppdNum.toFixed(2)}/${unit}` : '',
'状态': meta.isActive === false ? '下架' : '在售',
})
}
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>${title}</title>
<style>
body { font-family: 'PingFang SC','Hiragino Sans GB','Microsoft YaHei',sans-serif; padding: 20px; font-size: 11px; color: #333; }
h1 { font-size: 18px; text-align: center; margin-bottom: 16px; }
table { width: 100%; border-collapse: collapse; }
th { background: #7a9e7e; color: white; padding: 6px 8px; text-align: center; font-size: 11px; font-weight: 600; }
td { padding: 5px 8px; border-bottom: 1px solid #e0e0e0; text-align: center; font-size: 11px; }
td:first-child, th:first-child { text-align: left; font-weight: 500; }
td:nth-child(2), th:nth-child(2) { text-align: left; }
tr:nth-child(even) { background: #f9f9f9; }
tr:hover { background: #e8f5e9; }
@media print { body { padding: 10px; } h1 { font-size: 16px; } }
</style>
</head>
<body>
<h1>doTERRA 精油价目表 ${dateStr}</h1>
<table>
<thead>
<tr><th>精油</th><th>英文名</th><th>会员价</th><th>零售价</th><th>容量</th><th>单价</th></tr>
</thead>
<tbody>${rows}</tbody>
</table>
<p style="text-align:center;font-size:10px;color:#aaa;margin-top:12px">共 ${sortedNames.length} 种精油 · doTERRA 配方计算器导出</p>
</body>
</html>`
const w = window.open('', '_blank')
w.document.write(html)
w.document.close()
w.document.title = title
setTimeout(() => w.print(), 500)
const ws = XLSX.utils.json_to_sheet(rows)
ws['!cols'] = [{ wch: 16 }, { wch: 28 }, { wch: 10 }, { wch: 10 }, { wch: 12 }, { wch: 16 }, { wch: 8 }]
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, '精油价目表')
XLSX.writeFile(wb, `精油价目表${dateStr}.xlsx`)
ui.showToast('导出成功')
}
// ──── Save image logic (identical to RecipeDetailOverlay) ────

View File

@@ -81,7 +81,7 @@
<tr>
<th>精油</th>
<th>用量</th>
<th>每滴</th>
<th>单价</th>
<th>小计</th>
<th></th>
</tr>
@@ -133,8 +133,8 @@
<tbody>
<tr v-for="c in consumptionData" :key="c.oil" :class="{ 'limit-oil': c.isLimit }">
<td>{{ c.oil }}</td>
<td>{{ c.drops }}</td>
<td>{{ c.bottleDrops }}</td>
<td>{{ c.drops }}{{ oils.unitLabel(c.oil) }}</td>
<td>{{ c.bottleDrops }}{{ oils.unitLabel(c.oil) }}</td>
<td>{{ c.sessions }}</td>
<td></td>
</tr>

View File

@@ -44,7 +44,7 @@
<!-- Action buttons -->
<div class="action-bar">
<button class="action-chip" @click="showAddOverlay = true">新增</button>
<button class="action-chip" @click="oils.loadOils(); showAddOverlay = true">新增</button>
<button class="action-chip" :class="{ active: isAllSelected }" @click="toggleSelectAll">
全选<span v-if="totalSelected > 0" class="chip-count">{{ totalSelected }}</span>
</button>
@@ -808,6 +808,7 @@ function editRecipe(recipe) {
}
formNote.value = recipe.note || ''
formTags.value = [...(recipe.tags || [])]
oils.loadOils()
showAddOverlay.value = true
}
@@ -1698,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('导出成功')
}
@@ -2113,7 +2114,7 @@ watch(() => recipeStore.recipes, () => {
.editor-table th { text-align: center; padding: 6px 4px; color: #999; font-weight: 500; font-size: 12px; border-bottom: 1px solid #eee; }
.editor-table th:first-child { text-align: left; }
.editor-table td { padding: 6px 4px; border-bottom: 1px solid #f5f5f5; }
.editor-drops { width: 42px; padding: 5px 2px; border: 1.5px solid #d4cfc7; border-radius: 8px; font-size: 13px; text-align: center; outline: none; font-family: inherit; }
.editor-drops { width: 58px; padding: 5px 4px 5px 6px; border: 1.5px solid #d4cfc7; border-radius: 8px; font-size: 13px; text-align: center; outline: none; font-family: inherit; }
.editor-drops:focus { border-color: #7ec6a4; }
.drops-with-unit { display: flex; align-items: center; gap: 2px; }
.unit-hint { font-size: 11px; color: #b0aab5; white-space: nowrap; }