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 4s
PR Preview / deploy-preview (pull_request) Successful in 16s
Test / e2e-test (push) Failing after 55s
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 4s
PR Preview / deploy-preview (pull_request) Successful in 16s
Test / e2e-test (push) Failing after 55s
审核流程:
- 高级编辑者可看到待审核配方,点击推荐通过→通知管理员
- 高级编辑者可直接拒绝(和管理员相同逻辑)
- 管理员收到推荐通知后最终决定
- 去审核通知点击自动展开待审核列表
- 新增 /api/recipes/{id}/recommend 端点
共享:
- 已共享配方再点共享→提示"已共享,感谢贡献"
- 审核中配方再点共享→提示"正在审核中,请耐心等待"
贡献统计:
- 被拒绝的配方也计入总申请数(0/1不会变回0/0)
- reject_recipe日志记录from_user
其他:
- 配方卡片去掉编辑按钮
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -895,7 +895,7 @@ def adopt_recipe(recipe_id: int, user=Depends(require_role("admin"))):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/api/recipes/{recipe_id}/reject")
|
@app.post("/api/recipes/{recipe_id}/reject")
|
||||||
def reject_recipe(recipe_id: int, body: dict = None, user=Depends(require_role("admin"))):
|
def reject_recipe(recipe_id: int, body: dict = None, user=Depends(require_role("admin", "senior_editor"))):
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
row = conn.execute("SELECT id, name, owner_id FROM recipes WHERE id = ?", (recipe_id,)).fetchone()
|
row = conn.execute("SELECT id, name, owner_id FROM recipes WHERE id = ?", (recipe_id,)).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
@@ -917,8 +917,30 @@ def reject_recipe(recipe_id: int, body: dict = None, user=Depends(require_role("
|
|||||||
conn.execute("DELETE FROM recipe_ingredients WHERE recipe_id = ?", (recipe_id,))
|
conn.execute("DELETE FROM recipe_ingredients WHERE recipe_id = ?", (recipe_id,))
|
||||||
conn.execute("DELETE FROM recipe_tags WHERE recipe_id = ?", (recipe_id,))
|
conn.execute("DELETE FROM recipe_tags WHERE recipe_id = ?", (recipe_id,))
|
||||||
conn.execute("DELETE FROM recipes WHERE id = ?", (recipe_id,))
|
conn.execute("DELETE FROM recipes WHERE id = ?", (recipe_id,))
|
||||||
|
from_name = (old_owner["display_name"] or old_owner["username"]) if old_owner else "unknown"
|
||||||
log_audit(conn, user["id"], "reject_recipe", "recipe", recipe_id, row["name"],
|
log_audit(conn, user["id"], "reject_recipe", "recipe", recipe_id, row["name"],
|
||||||
json.dumps({"reason": reason}))
|
json.dumps({"reason": reason, "from_user": from_name}))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/recipes/{recipe_id}/recommend")
|
||||||
|
def recommend_recipe(recipe_id: int, body: dict = None, user=Depends(get_current_user)):
|
||||||
|
"""Senior editor recommends a recipe for admin approval."""
|
||||||
|
if user["role"] not in ("senior_editor", "admin"):
|
||||||
|
raise HTTPException(403, "权限不足")
|
||||||
|
conn = get_db()
|
||||||
|
recipe = conn.execute("SELECT name, owner_id FROM recipes WHERE id = ?", (recipe_id,)).fetchone()
|
||||||
|
if not recipe:
|
||||||
|
conn.close()
|
||||||
|
raise HTTPException(404, "配方不存在")
|
||||||
|
who = user.get("display_name") or user.get("username")
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO notifications (target_role, title, body) VALUES (?, ?, ?)",
|
||||||
|
("admin", "👍 配方推荐通过",
|
||||||
|
f"{who} 审核了配方「{recipe['name']}」并推荐通过,请最终确认。\n[recipe_id:{recipe_id}]")
|
||||||
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
@@ -1511,10 +1533,16 @@ def my_contribution(user=Depends(get_current_user)):
|
|||||||
"SELECT name FROM recipes WHERE owner_id = ?", (user["id"],)
|
"SELECT name FROM recipes WHERE owner_id = ?", (user["id"],)
|
||||||
).fetchall()
|
).fetchall()
|
||||||
pending_names = [r["name"] for r in pending_rows]
|
pending_names = [r["name"] for r in pending_rows]
|
||||||
|
# rejected: count recipes rejected from this user (they were deleted but logged)
|
||||||
|
rejected_count = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM audit_log WHERE action = 'reject_recipe' AND detail LIKE ?",
|
||||||
|
(f'%"from_user": "{display}"%',)
|
||||||
|
).fetchone()[0]
|
||||||
conn.close()
|
conn.close()
|
||||||
|
total = len(adopted_names) + len(pending_names) + rejected_count
|
||||||
return {
|
return {
|
||||||
"adopted_count": len(adopted_names),
|
"adopted_count": len(adopted_names),
|
||||||
"shared_count": len(adopted_names) + len(pending_names),
|
"shared_count": total,
|
||||||
"adopted_names": adopted_names,
|
"adopted_names": adopted_names,
|
||||||
"pending_names": pending_names,
|
"pending_names": pending_names,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,11 +14,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div style="flex:1"></div>
|
<div style="flex:1"></div>
|
||||||
<button
|
|
||||||
v-if="canEditThisRecipe"
|
|
||||||
class="action-btn action-btn-sm"
|
|
||||||
@click="goEditInManager"
|
|
||||||
>编辑</button>
|
|
||||||
<button class="detail-close-btn" @click="handleClose">✕</button>
|
<button class="detail-close-btn" @click="handleClose">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -130,8 +130,14 @@ function isSearchMissing(n) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isReviewable(n) {
|
function isReviewable(n) {
|
||||||
if (!auth.isAdmin || !n.title) return false
|
if (!n.title) return false
|
||||||
return n.title.includes('待审核') || n.title.includes('商业认证') || n.title.includes('申请')
|
// Admin: review recipe/business/applications
|
||||||
|
if (auth.isAdmin) {
|
||||||
|
return n.title.includes('待审核') || n.title.includes('商业认证') || n.title.includes('申请') || n.title.includes('推荐通过')
|
||||||
|
}
|
||||||
|
// Senior editor: assigned reviews
|
||||||
|
if (auth.canManage && n.title.includes('请审核')) return true
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markAdded(n) {
|
async function markAdded(n) {
|
||||||
@@ -141,7 +147,8 @@ async function markAdded(n) {
|
|||||||
function goReview(n) {
|
function goReview(n) {
|
||||||
markOneRead(n)
|
markOneRead(n)
|
||||||
emit('close')
|
emit('close')
|
||||||
if (n.title.includes('配方')) {
|
if (n.title.includes('配方') || n.title.includes('审核') || n.title.includes('推荐')) {
|
||||||
|
localStorage.setItem('oil_open_pending', '1')
|
||||||
router.push('/manage')
|
router.push('/manage')
|
||||||
} else if (n.title.includes('商业认证') || n.title.includes('申请')) {
|
} else if (n.title.includes('商业认证') || n.title.includes('申请')) {
|
||||||
router.push('/users')
|
router.push('/users')
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="recipe-manager">
|
<div class="recipe-manager">
|
||||||
<!-- Review Bar (admin only) -->
|
<!-- Review Bar (admin + senior_editor with assigned reviews) -->
|
||||||
<div v-if="auth.isAdmin && pendingCount > 0" class="review-bar" @click="showPending = !showPending" >
|
<div v-if="(auth.isAdmin || auth.canManage) && pendingCount > 0" class="review-bar" @click="showPending = !showPending" >
|
||||||
📝 待审核配方: {{ pendingCount }} 条
|
📝 待审核配方: {{ pendingCount }} 条
|
||||||
<span class="toggle-icon">{{ showPending ? '▾' : '▸' }}</span>
|
<span class="toggle-icon">{{ showPending ? '▾' : '▸' }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -9,16 +9,22 @@
|
|||||||
<div v-for="r in pendingRecipes" :key="r._id" class="pending-item">
|
<div v-for="r in pendingRecipes" :key="r._id" class="pending-item">
|
||||||
<span class="pending-name clickable" @click="openRecipeDetail(r)">{{ r.name }}</span>
|
<span class="pending-name clickable" @click="openRecipeDetail(r)">{{ r.name }}</span>
|
||||||
<span class="pending-owner">{{ r._owner_name }}</span>
|
<span class="pending-owner">{{ r._owner_name }}</span>
|
||||||
<button class="btn-sm btn-approve" @click="approveRecipe(r)">通过</button>
|
<template v-if="auth.isAdmin">
|
||||||
<button class="btn-sm btn-reject" @click="rejectRecipe(r)">拒绝</button>
|
<button class="btn-sm btn-approve" @click="approveRecipe(r)">通过</button>
|
||||||
<button class="btn-sm btn-outline" @click="r._showAssign = !r._showAssign">指派</button>
|
<button class="btn-sm btn-reject" @click="rejectRecipe(r)">拒绝</button>
|
||||||
<div v-if="r._showAssign" class="assign-row">
|
<button class="btn-sm btn-outline" @click="r._showAssign = !r._showAssign">指派</button>
|
||||||
<select v-model="r._assignTo" class="assign-select">
|
<div v-if="r._showAssign" class="assign-row">
|
||||||
<option value="">选择审核人...</option>
|
<select v-model="r._assignTo" class="assign-select">
|
||||||
<option v-for="u in seniorEditors" :key="u.id" :value="u.id">{{ u.display_name || u.username }}</option>
|
<option value="">选择审核人...</option>
|
||||||
</select>
|
<option v-for="u in seniorEditors" :key="u.id" :value="u.id">{{ u.display_name || u.username }}</option>
|
||||||
<button class="btn-sm btn-primary" @click="assignReview(r)" :disabled="!r._assignTo">发送</button>
|
</select>
|
||||||
</div>
|
<button class="btn-sm btn-primary" @click="assignReview(r)" :disabled="!r._assignTo">发送</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<button class="btn-sm btn-approve" @click="recommendApprove(r)">推荐通过</button>
|
||||||
|
<button class="btn-sm btn-reject" @click="rejectRecipe(r)">拒绝</button>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -127,7 +133,7 @@
|
|||||||
<span v-else-if="getDiaryShareStatus(d) === 'pending'" class="share-tag pending">等待审核</span>
|
<span v-else-if="getDiaryShareStatus(d) === 'pending'" class="share-tag pending">等待审核</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
<button v-if="getDiaryShareStatus(d) !== 'shared'" class="btn-icon" @click="shareDiaryToPublic(d)" title="共享到公共配方库">📤</button>
|
<button class="btn-icon" @click="handleShare(d)" title="共享到公共配方库">📤</button>
|
||||||
<button class="btn-icon" @click="removeDiaryRecipe(d)" title="删除">🗑️</button>
|
<button class="btn-icon" @click="removeDiaryRecipe(d)" title="删除">🗑️</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1110,6 +1116,11 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
// Auto-expand pending if navigated from notification
|
||||||
|
if (localStorage.getItem('oil_open_pending')) {
|
||||||
|
localStorage.removeItem('oil_open_pending')
|
||||||
|
showPending.value = true
|
||||||
|
}
|
||||||
// Open recipe editor if redirected from card view
|
// Open recipe editor if redirected from card view
|
||||||
const editId = localStorage.getItem('oil_edit_recipe_id')
|
const editId = localStorage.getItem('oil_edit_recipe_id')
|
||||||
if (editId) {
|
if (editId) {
|
||||||
@@ -1161,6 +1172,31 @@ function getDiaryShareStatus(d) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleShare(d) {
|
||||||
|
const status = getDiaryShareStatus(d)
|
||||||
|
if (status === 'shared') {
|
||||||
|
ui.showToast('该配方已共享到公共配方库,感谢你的贡献!')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (status === 'pending') {
|
||||||
|
ui.showToast('该配方正在审核中,请耐心等待')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
shareDiaryToPublic(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recommendApprove(recipe) {
|
||||||
|
try {
|
||||||
|
await api('/api/recipes/' + recipe._id + '/recommend', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ recommendation: 'approve' }),
|
||||||
|
})
|
||||||
|
ui.showToast('已推荐通过,等待管理员最终审核')
|
||||||
|
} catch {
|
||||||
|
ui.showToast('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function shareDiaryToPublic(diary) {
|
async function shareDiaryToPublic(diary) {
|
||||||
const ok = await showConfirm(`将「${diary.name}」共享到公共配方库?`)
|
const ok = await showConfirm(`将「${diary.name}」共享到公共配方库?`)
|
||||||
if (!ok) return
|
if (!ok) return
|
||||||
|
|||||||
Reference in New Issue
Block a user