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")
|
||||
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()
|
||||
row = conn.execute("SELECT id, name, owner_id FROM recipes WHERE id = ?", (recipe_id,)).fetchone()
|
||||
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_tags WHERE recipe_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"],
|
||||
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.close()
|
||||
return {"ok": True}
|
||||
@@ -1511,10 +1533,16 @@ def my_contribution(user=Depends(get_current_user)):
|
||||
"SELECT name FROM recipes WHERE owner_id = ?", (user["id"],)
|
||||
).fetchall()
|
||||
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()
|
||||
total = len(adopted_names) + len(pending_names) + rejected_count
|
||||
return {
|
||||
"adopted_count": len(adopted_names),
|
||||
"shared_count": len(adopted_names) + len(pending_names),
|
||||
"shared_count": total,
|
||||
"adopted_names": adopted_names,
|
||||
"pending_names": pending_names,
|
||||
}
|
||||
|
||||
@@ -14,11 +14,6 @@
|
||||
</button>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -130,8 +130,14 @@ function isSearchMissing(n) {
|
||||
}
|
||||
|
||||
function isReviewable(n) {
|
||||
if (!auth.isAdmin || !n.title) return false
|
||||
return n.title.includes('待审核') || n.title.includes('商业认证') || n.title.includes('申请')
|
||||
if (!n.title) return false
|
||||
// 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) {
|
||||
@@ -141,7 +147,8 @@ async function markAdded(n) {
|
||||
function goReview(n) {
|
||||
markOneRead(n)
|
||||
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')
|
||||
} else if (n.title.includes('商业认证') || n.title.includes('申请')) {
|
||||
router.push('/users')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="recipe-manager">
|
||||
<!-- Review Bar (admin only) -->
|
||||
<div v-if="auth.isAdmin && pendingCount > 0" class="review-bar" @click="showPending = !showPending" >
|
||||
<!-- Review Bar (admin + senior_editor with assigned reviews) -->
|
||||
<div v-if="(auth.isAdmin || auth.canManage) && pendingCount > 0" class="review-bar" @click="showPending = !showPending" >
|
||||
📝 待审核配方: {{ pendingCount }} 条
|
||||
<span class="toggle-icon">{{ showPending ? '▾' : '▸' }}</span>
|
||||
</div>
|
||||
@@ -9,16 +9,22 @@
|
||||
<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-owner">{{ r._owner_name }}</span>
|
||||
<button class="btn-sm btn-approve" @click="approveRecipe(r)">通过</button>
|
||||
<button class="btn-sm btn-reject" @click="rejectRecipe(r)">拒绝</button>
|
||||
<button class="btn-sm btn-outline" @click="r._showAssign = !r._showAssign">指派</button>
|
||||
<div v-if="r._showAssign" class="assign-row">
|
||||
<select v-model="r._assignTo" class="assign-select">
|
||||
<option value="">选择审核人...</option>
|
||||
<option v-for="u in seniorEditors" :key="u.id" :value="u.id">{{ u.display_name || u.username }}</option>
|
||||
</select>
|
||||
<button class="btn-sm btn-primary" @click="assignReview(r)" :disabled="!r._assignTo">发送</button>
|
||||
</div>
|
||||
<template v-if="auth.isAdmin">
|
||||
<button class="btn-sm btn-approve" @click="approveRecipe(r)">通过</button>
|
||||
<button class="btn-sm btn-reject" @click="rejectRecipe(r)">拒绝</button>
|
||||
<button class="btn-sm btn-outline" @click="r._showAssign = !r._showAssign">指派</button>
|
||||
<div v-if="r._showAssign" class="assign-row">
|
||||
<select v-model="r._assignTo" class="assign-select">
|
||||
<option value="">选择审核人...</option>
|
||||
<option v-for="u in seniorEditors" :key="u.id" :value="u.id">{{ u.display_name || u.username }}</option>
|
||||
</select>
|
||||
<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>
|
||||
|
||||
@@ -127,7 +133,7 @@
|
||||
<span v-else-if="getDiaryShareStatus(d) === 'pending'" class="share-tag pending">等待审核</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1110,6 +1116,11 @@ onMounted(async () => {
|
||||
}
|
||||
} 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
|
||||
const editId = localStorage.getItem('oil_edit_recipe_id')
|
||||
if (editId) {
|
||||
@@ -1161,6 +1172,31 @@ function getDiaryShareStatus(d) {
|
||||
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) {
|
||||
const ok = await showConfirm(`将「${diary.name}」共享到公共配方库?`)
|
||||
if (!ok) return
|
||||
|
||||
Reference in New Issue
Block a user