feat: 商业认证完整流程 + 通知时间过滤

商业认证:
- 申请页重写:商户名+说明字段,显示审核中/被拒/已通过状态
- 被拒绝显示原因,可重新申请
- 管理员拒绝时输入原因
- 商业核算页未认证用户点详情弹认证提示,跳转认证页面
- 删除项目仅管理员可见

通知:
- 只显示用户注册后的通知,避免角色变更后看到旧通知
- 搜索未收录通知只发管理员和高级编辑者

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-09 21:55:18 +00:00
parent 1d424984e0
commit 50cf9d3e9b
3 changed files with 105 additions and 19 deletions

View File

@@ -1420,11 +1420,15 @@ def get_notifications(user=Depends(get_current_user)):
if not user["id"]: if not user["id"]:
return [] return []
conn = get_db() conn = get_db()
# Only show notifications created after user registration
user_created = conn.execute("SELECT created_at FROM users WHERE id = ?", (user["id"],)).fetchone()
created_at = user_created["created_at"] if user_created else "2000-01-01"
rows = conn.execute( rows = conn.execute(
"SELECT id, title, body, is_read, created_at FROM notifications " "SELECT id, title, body, is_read, created_at FROM notifications "
"WHERE (target_user_id = ? OR (target_user_id IS NULL AND (target_role = ? OR target_role = 'all'))) " "WHERE (target_user_id = ? OR (target_user_id IS NULL AND (target_role = ? OR target_role = 'all'))) "
"AND created_at >= ? "
"ORDER BY is_read ASC, id DESC LIMIT 200", "ORDER BY is_read ASC, id DESC LIMIT 200",
(user["id"], user["role"]) (user["id"], user["role"], created_at)
).fetchall() ).fetchall()
conn.close() conn.close()
return [dict(r) for r in rows] return [dict(r) for r in rows]

View File

@@ -241,18 +241,57 @@
</div> </div>
<!-- Business Verification --> <!-- Business Verification -->
<div v-if="!auth.isBusiness" ref="bizCertRef" class="section-card"> <div ref="bizCertRef" class="section-card">
<h4>💼 商业认证</h4> <h4>💼 商业认证</h4>
<p class="hint-text">申请商业认证后可使用商业核算功能</p>
<div class="form-group"> <!-- 已认证 -->
<label>申请说明</label> <div v-if="auth.isBusiness" class="biz-status biz-approved">
<textarea v-model="businessReason" class="form-textarea" rows="3" placeholder="请说明您的申请理由..."></textarea> <div class="biz-status-icon"></div>
<div class="biz-status-text">已认证商业用户</div>
</div> </div>
<button class="btn-primary" @click="applyBusiness" :disabled="!businessReason.trim()">提交申请</button>
</div> <!-- 审核中 -->
<div v-else class="section-card"> <template v-else-if="bizApp.status === 'pending'">
<h4>💼 商业认证</h4> <div class="biz-status biz-pending">
<div class="verified-badge"> 已认证商业用户</div> <div class="biz-status-icon"></div>
<div class="biz-status-text">认证申请审核中</div>
<div class="biz-status-detail">商户名{{ bizApp.business_name }}</div>
<div class="biz-status-detail">提交时间{{ formatDate(bizApp.created_at) }}</div>
</div>
</template>
<!-- 被拒绝可重新申请 -->
<template v-else-if="bizApp.status === 'rejected'">
<div class="biz-status biz-rejected">
<div class="biz-status-icon"></div>
<div class="biz-status-text">认证申请未通过</div>
<div v-if="bizApp.reject_reason" class="biz-reject-reason">原因{{ bizApp.reject_reason }}</div>
</div>
<p class="hint-text">你可以修改信息后重新申请</p>
<div class="form-group">
<label>商户名称</label>
<input v-model="businessName" class="form-input" placeholder="你的商户/品牌名称" />
</div>
<div class="form-group">
<label>申请说明</label>
<textarea v-model="businessReason" class="form-textarea" rows="3" placeholder="请说明您的业务情况和申请理由..."></textarea>
</div>
<button class="btn-primary" @click="applyBusiness" :disabled="!businessName.trim()">重新申请</button>
</template>
<!-- 首次申请 -->
<template v-else>
<p class="hint-text">申请商业认证后可使用商业核算功能请填写以下信息</p>
<div class="form-group">
<label>商户名称</label>
<input v-model="businessName" class="form-input" placeholder="你的商户/品牌名称" />
</div>
<div class="form-group">
<label>申请说明</label>
<textarea v-model="businessReason" class="form-textarea" rows="3" placeholder="请说明您的业务情况和申请理由..."></textarea>
</div>
<button class="btn-primary" @click="applyBusiness" :disabled="!businessName.trim()">提交申请</button>
</template>
</div> </div>
</div> </div>
</div> </div>
@@ -300,13 +339,20 @@ const displayName = ref('')
const oldPassword = ref('') const oldPassword = ref('')
const newPassword = ref('') const newPassword = ref('')
const confirmPassword = ref('') const confirmPassword = ref('')
const businessName = ref('')
const businessReason = ref('') const businessReason = ref('')
const bizApp = ref({ status: null })
onMounted(async () => { onMounted(async () => {
await diaryStore.loadDiary() await diaryStore.loadDiary()
displayName.value = auth.user.display_name || '' displayName.value = auth.user.display_name || ''
await loadBrandSettings() await loadBrandSettings()
returnRecipeId.value = localStorage.getItem('oil_return_recipe_id') || null returnRecipeId.value = localStorage.getItem('oil_return_recipe_id') || null
// Load business application status
try {
const bizRes = await api('/api/my-business-application')
if (bizRes.ok) bizApp.value = await bizRes.json()
} catch {}
// 从商业核算跳转过来,滚到商业认证区域 // 从商业核算跳转过来,滚到商业认证区域
if (route.query.section === 'biz-cert') { if (route.query.section === 'biz-cert') {
await nextTick() await nextTick()
@@ -623,13 +669,30 @@ async function changePassword() {
} }
async function applyBusiness() { async function applyBusiness() {
if (!businessName.value.trim()) {
ui.showToast('请填写商户名称')
return
}
try { try {
await api('/api/business-apply', { const res = await api('/api/business-apply', {
method: 'POST', method: 'POST',
body: JSON.stringify({ reason: businessReason.value }), body: JSON.stringify({
business_name: businessName.value.trim(),
document: businessReason.value.trim(),
}),
}) })
businessReason.value = '' if (res.ok) {
ui.showToast('申请已提交,请等待审核') businessName.value = ''
businessReason.value = ''
bizApp.value = { status: 'pending', business_name: businessName.value }
ui.showToast('申请已提交,请等待管理员审核')
// Reload status
const bizRes = await api('/api/my-business-application')
if (bizRes.ok) bizApp.value = await bizRes.json()
} else {
const err = await res.json().catch(() => ({}))
ui.showToast(err.detail || '提交失败')
}
} catch { } catch {
ui.showToast('提交失败') ui.showToast('提交失败')
} }
@@ -1084,6 +1147,20 @@ async function applyBusiness() {
text-align: center; text-align: center;
} }
.biz-status {
padding: 16px;
border-radius: 12px;
text-align: center;
margin-bottom: 12px;
}
.biz-status-icon { font-size: 32px; margin-bottom: 8px; }
.biz-status-text { font-size: 15px; font-weight: 600; margin-bottom: 4px; }
.biz-status-detail { font-size: 12px; color: #999; }
.biz-approved { background: #e8f5e9; color: #2e7d5a; }
.biz-pending { background: #fff8e1; color: #e65100; }
.biz-rejected { background: #fce4ec; color: #c62828; }
.biz-reject-reason { font-size: 13px; margin-top: 8px; padding: 8px 12px; background: rgba(0,0,0,0.05); border-radius: 8px; }
/* Buttons */ /* Buttons */
.btn-primary { .btn-primary {
background: linear-gradient(135deg, #7ec6a4 0%, #4a9d7e 100%); background: linear-gradient(135deg, #7ec6a4 0%, #4a9d7e 100%);

View File

@@ -27,8 +27,8 @@
<div class="review-list"> <div class="review-list">
<div v-for="app in businessApps" :key="app._id || app.id" class="review-item"> <div v-for="app in businessApps" :key="app._id || app.id" class="review-item">
<div class="review-info"> <div class="review-info">
<span class="review-name">{{ app.user_name || app.display_name }}</span> <span class="review-name">{{ app.display_name || app.username }}</span>
<span class="review-reason">{{ app.reason }}</span> <span class="review-reason">商户名{{ app.business_name }}</span>
</div> </div>
<div class="review-actions"> <div class="review-actions">
<button class="btn-sm btn-approve" @click="approveBusiness(app)">通过</button> <button class="btn-sm btn-approve" @click="approveBusiness(app)">通过</button>
@@ -101,7 +101,7 @@ import { ref, computed, onMounted } from 'vue'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { useUiStore } from '../stores/ui' import { useUiStore } from '../stores/ui'
import { api } from '../composables/useApi' import { api } from '../composables/useApi'
import { showConfirm } from '../composables/useDialog' import { showConfirm, showPrompt } from '../composables/useDialog'
const auth = useAuthStore() const auth = useAuthStore()
const ui = useUiStore() const ui = useUiStore()
@@ -248,8 +248,13 @@ async function approveBusiness(app) {
async function rejectBusiness(app) { async function rejectBusiness(app) {
const id = app._id || app.id const id = app._id || app.id
const reason = await showPrompt('请输入拒绝原因(选填):')
if (reason === null) return
try { try {
const res = await api(`/api/business-applications/${id}/reject`, { method: 'POST' }) const res = await api(`/api/business-applications/${id}/reject`, {
method: 'POST',
body: JSON.stringify({ reason: reason || '' }),
})
if (res.ok) { if (res.ok) {
businessApps.value = businessApps.value.filter(item => (item._id || item.id) !== id) businessApps.value = businessApps.value.filter(item => (item._id || item.id) !== id)
ui.showToast('已拒绝') ui.showToast('已拒绝')