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 3s
PR Preview / test (pull_request) Successful in 4s
PR Preview / deploy-preview (pull_request) Successful in 14s
Test / e2e-test (push) Failing after 53s
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 3s
PR Preview / test (pull_request) Successful in 4s
PR Preview / deploy-preview (pull_request) Successful in 14s
Test / e2e-test (push) Failing after 53s
- 去审核按钮仅管理员可见,其他用户显示已读
- 共享配方通知只发管理员
- 管理员待审核栏加"指派"按钮,选择高级编辑者审核
- 指派后发送通知给被指派人
- 新增 /api/recipes/{id}/assign-review 端点
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -781,15 +781,14 @@ def create_recipe(recipe: RecipeIn, user=Depends(get_current_user)):
|
|||||||
c.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (tag,))
|
c.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (tag,))
|
||||||
c.execute("INSERT OR IGNORE INTO recipe_tags (recipe_id, tag_name) VALUES (?, ?)", (rid, tag))
|
c.execute("INSERT OR IGNORE INTO recipe_tags (recipe_id, tag_name) VALUES (?, ?)", (rid, tag))
|
||||||
log_audit(conn, user["id"], "create_recipe", "recipe", rid, recipe.name)
|
log_audit(conn, user["id"], "create_recipe", "recipe", rid, recipe.name)
|
||||||
# Notify admin and senior editors when non-admin creates a recipe
|
# Notify admin only when non-admin creates a recipe
|
||||||
if user["role"] not in ("admin", "senior_editor"):
|
if user["role"] != "admin":
|
||||||
who = user.get("display_name") or user["username"]
|
who = user.get("display_name") or user["username"]
|
||||||
for role in ("admin", "senior_editor"):
|
conn.execute(
|
||||||
conn.execute(
|
"INSERT INTO notifications (target_role, title, body) VALUES (?, ?, ?)",
|
||||||
"INSERT INTO notifications (target_role, title, body) VALUES (?, ?, ?)",
|
("admin", "📝 新配方待审核",
|
||||||
(role, "📝 新配方待审核",
|
f"{who} 共享了配方「{recipe.name}」,请到管理配方查看。\n[recipe_id:{rid}]")
|
||||||
f"{who} 共享了配方「{recipe.name}」,请到管理配方查看。\n[recipe_id:{rid}]")
|
)
|
||||||
)
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
return {"id": rid}
|
return {"id": rid}
|
||||||
@@ -925,6 +924,31 @@ def reject_recipe(recipe_id: int, body: dict = None, user=Depends(require_role("
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/recipes/{recipe_id}/assign-review")
|
||||||
|
def assign_recipe_review(recipe_id: int, body: dict, user=Depends(require_role("admin"))):
|
||||||
|
reviewer_id = body.get("user_id")
|
||||||
|
if not reviewer_id:
|
||||||
|
raise HTTPException(400, "请选择审核人")
|
||||||
|
conn = get_db()
|
||||||
|
recipe = conn.execute("SELECT name FROM recipes WHERE id = ?", (recipe_id,)).fetchone()
|
||||||
|
if not recipe:
|
||||||
|
conn.close()
|
||||||
|
raise HTTPException(404, "配方不存在")
|
||||||
|
reviewer = conn.execute("SELECT role, display_name, username FROM users WHERE id = ?", (reviewer_id,)).fetchone()
|
||||||
|
if not reviewer:
|
||||||
|
conn.close()
|
||||||
|
raise HTTPException(404, "用户不存在")
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO notifications (target_role, title, body, target_user_id) VALUES (?, ?, ?, ?)",
|
||||||
|
(reviewer["role"], "📋 请审核配方",
|
||||||
|
f"管理员指派你审核配方「{recipe['name']}」,请到管理配方页面查看并反馈意见。\n[recipe_id:{recipe_id}]",
|
||||||
|
reviewer_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/recipes/adopt-batch")
|
@app.post("/api/recipes/adopt-batch")
|
||||||
def adopt_batch(body: dict, user=Depends(require_role("admin"))):
|
def adopt_batch(body: dict, user=Depends(require_role("admin"))):
|
||||||
ids = body.get("ids", [])
|
ids = body.get("ids", [])
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ function isSearchMissing(n) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isReviewable(n) {
|
function isReviewable(n) {
|
||||||
if (!n.title) return false
|
if (!auth.isAdmin || !n.title) return false
|
||||||
return n.title.includes('待审核') || n.title.includes('商业认证') || n.title.includes('申请')
|
return n.title.includes('待审核') || n.title.includes('商业认证') || n.title.includes('申请')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,14 @@
|
|||||||
<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>
|
<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-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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -416,6 +424,7 @@ const editingRecipe = ref(null)
|
|||||||
const showPending = ref(false)
|
const showPending = ref(false)
|
||||||
const pendingRecipes = ref([])
|
const pendingRecipes = ref([])
|
||||||
const pendingCount = ref(0)
|
const pendingCount = ref(0)
|
||||||
|
const seniorEditors = ref([])
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const formName = ref('')
|
const formName = ref('')
|
||||||
@@ -1069,6 +1078,13 @@ onMounted(async () => {
|
|||||||
const res = await api('/api/recipe-reviews')
|
const res = await api('/api/recipe-reviews')
|
||||||
if (res.ok) reviewHistory.value = await res.json()
|
if (res.ok) reviewHistory.value = await res.json()
|
||||||
} catch {}
|
} catch {}
|
||||||
|
try {
|
||||||
|
const res = await api('/api/users')
|
||||||
|
if (res.ok) {
|
||||||
|
const users = await res.json()
|
||||||
|
seniorEditors.value = users.filter(u => u.role === 'senior_editor')
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
// 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')
|
||||||
@@ -1092,6 +1108,24 @@ function editDiaryRecipe(diary) {
|
|||||||
showAddOverlay.value = true
|
showAddOverlay.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function assignReview(recipe) {
|
||||||
|
const userId = recipe._assignTo
|
||||||
|
if (!userId) return
|
||||||
|
try {
|
||||||
|
const res = await api('/api/recipes/' + recipe._id + '/assign-review', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ user_id: userId }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
recipe._showAssign = false
|
||||||
|
recipe._assignTo = ''
|
||||||
|
ui.showToast('已指派审核')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ui.showToast('指派失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openRecipeDetail(recipe) {
|
function openRecipeDetail(recipe) {
|
||||||
const idx = recipeStore.recipes.findIndex(r => r._id === recipe._id)
|
const idx = recipeStore.recipes.findIndex(r => r._id === recipe._id)
|
||||||
if (idx >= 0) previewRecipeIndex.value = idx
|
if (idx >= 0) previewRecipeIndex.value = idx
|
||||||
@@ -1700,6 +1734,9 @@ watch(() => recipeStore.recipes, () => {
|
|||||||
padding: 0; margin-right: 4px; line-height: 1;
|
padding: 0; margin-right: 4px; line-height: 1;
|
||||||
}
|
}
|
||||||
.mini-select.active { background: #4a9d7e; border-color: #4a9d7e; color: #fff; }
|
.mini-select.active { background: #4a9d7e; border-color: #4a9d7e; color: #fff; }
|
||||||
|
.assign-row { display: flex; gap: 6px; align-items: center; margin-top: 4px; }
|
||||||
|
.assign-select { padding: 4px 8px; border: 1px solid #d4cfc7; border-radius: 6px; font-size: 12px; font-family: inherit; }
|
||||||
|
.btn-primary { background: linear-gradient(135deg, #7ec6a4, #4a9d7e); color: #fff; border: none; border-radius: 8px; cursor: pointer; font-family: inherit; }
|
||||||
.tag-list-bar {
|
.tag-list-bar {
|
||||||
display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; padding: 8px 0;
|
display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; padding: 8px 0;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user