feat: 精油价目页优化 — 名称缩放、去容量、红色底色、下架功能
Some checks failed
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 4s
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 1m22s

显示优化:
- 中文名和英文名各一行,nowrap+ellipsis不换行
- 去掉📖 emoji标记
- 去掉右侧容量标签
- 划掉的零售价后面加/瓶,跟会员价一致

管理员功能:
- 信息不全的精油(缺价格/滴数/零售价)显示浅红底色
- 补全后自动恢复正常底色
- 编辑弹窗加"下架"按钮,下架后所有人看到浅灰底色
- 已下架可"重新上架"

后端:
- OilIn model 加 is_active 字段
- upsert 支持更新 is_active

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-09 16:09:52 +00:00
parent 4d5c9874a9
commit 2a823e5bac
2 changed files with 77 additions and 16 deletions

View File

@@ -80,6 +80,7 @@ class OilIn(BaseModel):
drop_count: int drop_count: int
retail_price: Optional[float] = None retail_price: Optional[float] = None
en_name: Optional[str] = None en_name: Optional[str] = None
is_active: Optional[int] = None
class IngredientIn(BaseModel): class IngredientIn(BaseModel):
@@ -659,10 +660,11 @@ def list_oils():
def upsert_oil(oil: OilIn, user=Depends(require_role("admin", "senior_editor"))): def upsert_oil(oil: OilIn, user=Depends(require_role("admin", "senior_editor"))):
conn = get_db() conn = get_db()
conn.execute( conn.execute(
"INSERT INTO oils (name, bottle_price, drop_count, retail_price, en_name) VALUES (?, ?, ?, ?, ?) " "INSERT INTO oils (name, bottle_price, drop_count, retail_price, en_name, is_active) VALUES (?, ?, ?, ?, ?, ?) "
"ON CONFLICT(name) DO UPDATE SET bottle_price=excluded.bottle_price, drop_count=excluded.drop_count, " "ON CONFLICT(name) DO UPDATE SET bottle_price=excluded.bottle_price, drop_count=excluded.drop_count, "
"retail_price=excluded.retail_price, en_name=COALESCE(excluded.en_name, oils.en_name)", "retail_price=excluded.retail_price, en_name=COALESCE(excluded.en_name, oils.en_name), "
(oil.name, oil.bottle_price, oil.drop_count, oil.retail_price, oil.en_name), "is_active=COALESCE(excluded.is_active, oils.is_active)",
(oil.name, oil.bottle_price, oil.drop_count, oil.retail_price, oil.en_name, oil.is_active),
) )
log_audit(conn, user["id"], "upsert_oil", "oil", oil.name, oil.name, log_audit(conn, user["id"], "upsert_oil", "oil", oil.name, oil.name,
json.dumps({"bottle_price": oil.bottle_price, "drop_count": oil.drop_count})) json.dumps({"bottle_price": oil.bottle_price, "drop_count": oil.drop_count}))

View File

@@ -124,23 +124,20 @@
v-for="name in filteredOilNames" v-for="name in filteredOilNames"
:key="name + '-' + cardVersion" :key="name + '-' + cardVersion"
class="oil-chip" class="oil-chip"
:class="{ 'oil-chip--inactive': getMeta(name)?.isActive === false, 'oil-chip--incomplete': auth.isAdmin && isIncomplete(name) }"
:style="chipStyle(name)" :style="chipStyle(name)"
@click="openOilDetail(name)" @click="openOilDetail(name)"
> >
<div style="flex:1;min-width:0"> <div style="flex:1;min-width:0">
<span class="oil-chip-name">{{ name }} <div class="oil-name-line">{{ name }}</div>
<span v-if="getOilCard(name)" style="font-size:9px;color:var(--sage);background:var(--sage-mist);padding:1px 5px;border-radius:6px;vertical-align:middle">📖</span> <div class="oil-en-line">{{ getEnglishName(name) }}</div>
</span>
<br>
<span style="font-size:10px;color:var(--text-light);font-weight:400">{{ getEnglishName(name) }}</span>
</div> </div>
<div style="text-align:right;flex-shrink:0"> <div style="text-align:right;flex-shrink:0">
<template v-if="viewMode === 'bottle'"> <template v-if="viewMode === 'bottle'">
<div style="font-size:13px;color:var(--sage-dark);font-weight:600"> <div style="font-size:13px;color:var(--sage-dark);font-weight:600">
¥{{ (getMeta(name)?.bottlePrice || 0).toFixed(0) }}<span style="font-size:10px;font-weight:400;color:var(--text-light)">/</span> ¥{{ (getMeta(name)?.bottlePrice || 0).toFixed(0) }}<span style="font-size:10px;font-weight:400;color:var(--text-light)">/</span>
<span v-if="getMeta(name)?.dropCount" style="font-size:10px;font-weight:400;color:var(--text-light)"> {{ volumeLabel(getMeta(name).dropCount) }}</span>
</div> </div>
<div v-if="getMeta(name)?.retailPrice" style="font-size:11px;color:var(--text-light);text-decoration:line-through">¥{{ getMeta(name).retailPrice }}</div> <div v-if="getMeta(name)?.retailPrice" style="font-size:11px;color:var(--text-light);text-decoration:line-through">¥{{ getMeta(name).retailPrice }}/</div>
</template> </template>
<template v-else> <template v-else>
<div style="font-size:13px;color:var(--sage-dark);font-weight:600"> <div style="font-size:13px;color:var(--sage-dark);font-weight:600">
@@ -331,9 +328,15 @@
</div> </div>
</div> </div>
<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:16px"> <div style="display:flex;gap:10px;justify-content:space-between;margin-top:16px">
<button class="btn-outline" @click="editingOilName = null">取消</button> <button
<button class="btn-primary" @click="saveEditOil">保存</button> style="padding:8px 14px;border-radius:8px;font-size:13px;cursor:pointer;font-family:inherit;border:1.5px solid #e8b4b0;background:transparent;color:#c0392b"
@click="toggleOilActive"
>{{ getMeta(editingOilName)?.isActive === false ? '重新上架' : '下架' }}</button>
<div style="display:flex;gap:10px">
<button class="btn-outline" @click="editingOilName = null">取消</button>
<button class="btn-primary" @click="saveEditOil">保存</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -466,14 +469,18 @@ function volumeLabel(dropCount, name) {
} }
function chipStyle(name) { function chipStyle(name) {
const meta = getMeta(name)
const isActive = meta?.isActive !== false
const hasCard = !!getOilCard(name) const hasCard = !!getOilCard(name)
if (!isActive) return 'opacity:0.7;background:#f5f5f5'
if (hasCard) return 'cursor:pointer;border-left:3px solid var(--sage);background:linear-gradient(90deg,var(--sage-mist),white)' if (hasCard) return 'cursor:pointer;border-left:3px solid var(--sage);background:linear-gradient(90deg,var(--sage-mist),white)'
return '' return ''
} }
function isIncomplete(name) {
const meta = getMeta(name)
if (!meta) return true
// Incomplete if missing bottle price, drop count, or retail price
return !meta.bottlePrice || !meta.dropCount || !meta.retailPrice
}
function getEffectiveDropCount() { function getEffectiveDropCount() {
if (newVolume.value === 'custom') return newCustomDrops.value || 0 if (newVolume.value === 'custom') return newCustomDrops.value || 0
return VOLUME_OPTIONS[newVolume.value] || 0 return VOLUME_OPTIONS[newVolume.value] || 0
@@ -662,6 +669,31 @@ async function saveEditOil() {
} }
} }
async function toggleOilActive() {
const name = editingOilName.value
if (!name) return
const meta = getMeta(name)
const newActive = meta?.isActive === false ? 1 : 0
try {
await api('/api/oils', {
method: 'POST',
body: JSON.stringify({
name,
bottle_price: meta?.bottlePrice || 0,
drop_count: meta?.dropCount || 1,
retail_price: meta?.retailPrice,
is_active: newActive,
}),
})
await oils.loadOils()
cardVersion.value++
ui.showToast(newActive ? '已重新上架' : '已下架')
editingOilName.value = null
} catch {
ui.showToast('操作失败')
}
}
async function removeOil(name) { async function removeOil(name) {
const ok = await showConfirm(`确定删除精油 "${name}"`) const ok = await showConfirm(`确定删除精油 "${name}"`)
if (!ok) return if (!ok) return
@@ -1161,6 +1193,33 @@ async function saveCardImage(name) {
.oil-chip:hover { .oil-chip:hover {
box-shadow: 0 4px 16px rgba(90,60,30,0.12); box-shadow: 0 4px 16px rgba(90,60,30,0.12);
} }
.oil-chip--inactive {
opacity: 0.5;
background: #f0f0f0 !important;
}
.oil-chip--incomplete {
background: #fff5f5 !important;
}
.oil-name-line {
font-size: 14px;
font-weight: 500;
color: var(--text-dark);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.oil-en-line {
font-size: 10px;
color: var(--text-light);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Shrink name for long oils */
@container (max-width: 180px) {
.oil-name-line { font-size: 12px; }
.oil-en-line { font-size: 9px; }
}
.oil-chip-actions { .oil-chip-actions {
position: absolute; position: absolute;