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
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:
@@ -80,6 +80,7 @@ class OilIn(BaseModel):
|
||||
drop_count: int
|
||||
retail_price: Optional[float] = None
|
||||
en_name: Optional[str] = None
|
||||
is_active: Optional[int] = None
|
||||
|
||||
|
||||
class IngredientIn(BaseModel):
|
||||
@@ -659,10 +660,11 @@ def list_oils():
|
||||
def upsert_oil(oil: OilIn, user=Depends(require_role("admin", "senior_editor"))):
|
||||
conn = get_db()
|
||||
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, "
|
||||
"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),
|
||||
"retail_price=excluded.retail_price, en_name=COALESCE(excluded.en_name, oils.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,
|
||||
json.dumps({"bottle_price": oil.bottle_price, "drop_count": oil.drop_count}))
|
||||
|
||||
@@ -124,23 +124,20 @@
|
||||
v-for="name in filteredOilNames"
|
||||
:key="name + '-' + cardVersion"
|
||||
class="oil-chip"
|
||||
:class="{ 'oil-chip--inactive': getMeta(name)?.isActive === false, 'oil-chip--incomplete': auth.isAdmin && isIncomplete(name) }"
|
||||
:style="chipStyle(name)"
|
||||
@click="openOilDetail(name)"
|
||||
>
|
||||
<div style="flex:1;min-width:0">
|
||||
<span class="oil-chip-name">{{ name }}
|
||||
<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>
|
||||
</span>
|
||||
<br>
|
||||
<span style="font-size:10px;color:var(--text-light);font-weight:400">{{ getEnglishName(name) }}</span>
|
||||
<div class="oil-name-line">{{ name }}</div>
|
||||
<div class="oil-en-line">{{ getEnglishName(name) }}</div>
|
||||
</div>
|
||||
<div style="text-align:right;flex-shrink:0">
|
||||
<template v-if="viewMode === 'bottle'">
|
||||
<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>
|
||||
<span v-if="getMeta(name)?.dropCount" style="font-size:10px;font-weight:400;color:var(--text-light)"> {{ volumeLabel(getMeta(name).dropCount) }}</span>
|
||||
</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 v-else>
|
||||
<div style="font-size:13px;color:var(--sage-dark);font-weight:600">
|
||||
@@ -331,9 +328,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:16px">
|
||||
<button class="btn-outline" @click="editingOilName = null">取消</button>
|
||||
<button class="btn-primary" @click="saveEditOil">保存</button>
|
||||
<div style="display:flex;gap:10px;justify-content:space-between;margin-top:16px">
|
||||
<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>
|
||||
@@ -466,14 +469,18 @@ function volumeLabel(dropCount, name) {
|
||||
}
|
||||
|
||||
function chipStyle(name) {
|
||||
const meta = getMeta(name)
|
||||
const isActive = meta?.isActive !== false
|
||||
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)'
|
||||
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() {
|
||||
if (newVolume.value === 'custom') return newCustomDrops.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) {
|
||||
const ok = await showConfirm(`确定删除精油 "${name}"?`)
|
||||
if (!ok) return
|
||||
@@ -1161,6 +1193,33 @@ async function saveCardImage(name) {
|
||||
.oil-chip:hover {
|
||||
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 {
|
||||
position: absolute;
|
||||
|
||||
Reference in New Issue
Block a user