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
|
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}))
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user