Files
oil-formula-calculator/frontend/src/components/UserMenu.vue
Hera Zhao 9e1ebb3c86
All checks were successful
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 6s
Test / build-check (push) Successful in 4s
PR Preview / test (pull_request) Successful in 5s
PR Preview / deploy-preview (pull_request) Successful in 14s
Test / e2e-test (push) Successful in 53s
revert: 删除购油方案功能,修复MyDiary模板错误
完全移除oil_plans相关代码:
- 后端: 7个API端点、2个数据库表
- 前端: plans store、Inventory方案UI、UserManagement方案编辑器
- UserMenu: 方案通知按钮
- 修复MyDiary.vue多余的section-card div导致的构建失败

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:51:52 +00:00

330 lines
11 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="usermenu-overlay" @mousedown.self="$emit('close')">
<div class="usermenu-card">
<div class="usermenu-name">
{{ auth.user.username }}
<button v-if="!auth.user.username_changed" class="rename-btn" @click="changeUsername"> 改名</button>
</div>
<div class="usermenu-actions">
<button class="usermenu-btn" @click="goMyDiary">
📖 我的
</button>
<button class="usermenu-btn" @click="toggleNotifications">
🔔 通知
<span v-if="unreadCount > 0" class="unread-badge">{{ unreadCount }}</span>
</button>
<button class="usermenu-btn" @click="showBugReport">
🐛 反馈问题
</button>
<template v-if="auth.isAdmin">
<button class="usermenu-btn" @click="goAdmin('audit')">📜 操作日志</button>
<button class="usermenu-btn" @click="goAdmin('bugs')">🐛 Bug管理</button>
<button class="usermenu-btn" @click="goAdmin('users')">👥 用户管理</button>
</template>
<button class="usermenu-btn usermenu-btn-logout" @click="handleLogout">
🚪 退出登录
</button>
</div>
<!-- Inline Notification Panel -->
<div v-if="showNotifPanel" class="notif-panel">
<div class="notif-header">
<span>通知 ({{ notifications.length }})</span>
<button v-if="unreadCount > 0" class="notif-mark-all" @click="markAllRead">全部已读</button>
</div>
<div class="notif-list">
<div v-for="n in notifications.slice(0, 20)" :key="n.id"
class="notif-item" :class="{ unread: !n.is_read }">
<div class="notif-item-header">
<div class="notif-title">{{ n.title }}</div>
<div v-if="!n.is_read" class="notif-actions">
<!-- 搜索未收录通知已添加按钮 -->
<button v-if="isSearchMissing(n)" class="notif-action-btn notif-btn-added" @click="markAdded(n)">已添加</button>
<!-- 用户名更新通知去修改按钮已改过则显示已读 -->
<button v-else-if="isUsernameNotice(n) && !auth.user.username_changed" class="notif-action-btn notif-btn-plan" @click="goRename(n)">去修改</button>
<button v-else-if="isUsernameNotice(n) && auth.user.username_changed" class="notif-mark-one" @click="markOneRead(n)">已读</button>
<!-- 审核类通知去审核按钮 -->
<button v-else-if="isReviewable(n)" class="notif-action-btn notif-btn-review" @click="goReview(n)">去审核</button>
<!-- 默认已读按钮 -->
<button v-else class="notif-mark-one" @click="markOneRead(n)">已读</button>
</div>
</div>
<div v-if="n.body" class="notif-body">{{ n.body }}</div>
<div class="notif-time">{{ formatTime(n.created_at) }}</div>
</div>
<div v-if="notifications.length === 0" class="notif-empty">暂无通知</div>
</div>
</div>
<!-- Bug Report Modal -->
<div v-if="showBugForm" class="bug-form">
<textarea v-model="bugContent" class="bug-textarea" rows="3" placeholder="描述你遇到的问题..."></textarea>
<div class="bug-form-actions">
<button class="btn-sm btn-outline" @click="showBugForm = false">取消</button>
<button class="btn-sm btn-primary" @click="submitBug" :disabled="!bugContent.trim()">提交</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { useUiStore } from '../stores/ui'
import { api } from '../composables/useApi'
const emit = defineEmits(['close'])
const auth = useAuthStore()
const ui = useUiStore()
const router = useRouter()
const notifications = ref([])
const showNotifPanel = ref(false)
const showBugForm = ref(false)
const bugContent = ref('')
const unreadCount = computed(() => notifications.value.filter(n => !n.is_read).length)
function formatTime(d) {
if (!d) return ''
return new Date(d + 'Z').toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
function goMyDiary() {
emit('close')
router.push('/mydiary')
}
function goAdmin(section) {
emit('close')
router.push('/' + section)
}
function toggleNotifications() {
showNotifPanel.value = !showNotifPanel.value
showBugForm.value = false
}
function showBugReport() {
showBugForm.value = !showBugForm.value
showNotifPanel.value = false
}
async function submitBug() {
if (!bugContent.value.trim()) return
try {
const res = await api('/api/bug-report', {
method: 'POST',
body: JSON.stringify({ content: bugContent.value.trim(), priority: 0 }),
})
if (res.ok) {
bugContent.value = ''
showBugForm.value = false
ui.showToast('反馈已提交')
}
} catch {
ui.showToast('提交失败')
}
}
function isSearchMissing(n) {
return n.title && n.title.includes('用户需求')
}
async function changeUsername() {
const { showPrompt } = await import('../composables/useDialog')
const newName = await showPrompt('输入新用户名(只能修改一次):', auth.user.username)
if (!newName || !newName.trim() || newName.trim() === auth.user.username) return
try {
const res = await api('/api/me/username', {
method: 'PUT',
body: JSON.stringify({ username: newName.trim() }),
})
if (res.ok) {
await auth.loadMe()
ui.showToast('用户名已修改')
} else {
const err = await res.json().catch(() => ({}))
ui.showToast(err.detail || '修改失败')
}
} catch {
ui.showToast('修改失败')
}
}
function isUsernameNotice(n) {
return n.title && n.title.includes('用户名更新')
}
function goRename(n) {
markOneRead(n)
changeUsername()
}
function isReviewable(n) {
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) {
const { showConfirm: confirm } = await import('../composables/useDialog')
const ok = await confirm('确认已添加该配方?将通知其他编辑者和搜索用户。')
if (!ok) return
try {
await api(`/api/notifications/${n.id}/added`, { method: 'POST' })
n.is_read = 1
ui.showToast('已标记,已通知相关人员')
} catch {
await markOneRead(n)
}
}
function goReview(n) {
markOneRead(n)
emit('close')
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')
}
}
async function markOneRead(n) {
try {
await api(`/api/notifications/${n.id}/read`, { method: 'POST', body: '{}' })
n.is_read = 1
} catch {}
}
async function markAllRead() {
try {
await api('/api/notifications/read-all', { method: 'POST', body: '{}' })
notifications.value.forEach(n => n.is_read = 1)
} catch {}
}
async function loadNotifications() {
try {
const res = await api('/api/notifications')
if (res.ok) notifications.value = await res.json()
} catch {}
}
function handleLogout() {
auth.logout()
ui.showToast('已退出登录')
emit('close')
window.location.href = '/'
}
onMounted(loadNotifications)
</script>
<style scoped>
.usermenu-overlay {
position: fixed;
inset: 0;
z-index: 4000;
}
.usermenu-card {
position: absolute;
top: 56px;
right: 16px;
background: #fff;
border-radius: 14px;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.15);
padding: 18px 20px 14px;
min-width: 200px;
max-width: 340px;
z-index: 4001;
}
.usermenu-name { font-size: 16px; font-weight: 600; color: #3e3a44; margin-bottom: 14px; display: flex; align-items: center; gap: 8px; }
.rename-btn { background: none; border: none; font-size: 12px; color: #b0aab5; cursor: pointer; padding: 0; }
.rename-btn:hover { color: #4a9d7e; }
.usermenu-actions { display: flex; flex-direction: column; gap: 4px; }
.usermenu-btn {
display: flex; align-items: center; gap: 6px; width: 100%;
padding: 9px 10px; border: none; background: none; border-radius: 8px;
font-size: 14px; color: #3e3a44; cursor: pointer; font-family: inherit;
text-align: left; transition: background 0.15s; position: relative;
}
.usermenu-btn:hover { background: #f5f3f0; }
.usermenu-btn-logout {
color: #d9534f; margin-top: 6px; border-top: 1px solid #eee;
padding-top: 12px; border-radius: 0 0 8px 8px;
}
.unread-badge {
background: #d9534f; color: #fff; font-size: 11px; font-weight: 600;
min-width: 18px; height: 18px; line-height: 18px; text-align: center;
border-radius: 9px; padding: 0 5px; margin-left: auto;
}
/* Notification panel */
.notif-panel {
margin-top: 12px; border-top: 1px solid #eee; padding-top: 10px;
}
.notif-header {
display: flex; justify-content: space-between; align-items: center;
font-size: 13px; font-weight: 600; color: #666; margin-bottom: 8px;
}
.notif-mark-all {
background: none; border: none; color: var(--sage, #7a9e7e);
cursor: pointer; font-size: 12px; font-family: inherit;
}
.notif-list { max-height: 250px; overflow-y: auto; }
.notif-item {
padding: 8px 0; border-bottom: 1px solid #f5f5f5; font-size: 13px;
}
.notif-item.unread { background: #fafafa; }
.notif-item-header { display: flex; justify-content: space-between; align-items: center; gap: 6px; }
.notif-title { font-weight: 500; color: #333; flex: 1; }
.notif-mark-one {
background: none; border: 1px solid #ccc; border-radius: 6px;
font-size: 11px; color: #7a9e7e; cursor: pointer; padding: 2px 8px;
font-family: inherit; white-space: nowrap; flex-shrink: 0;
}
.notif-mark-one:hover { background: #f0faf5; border-color: #7a9e7e; }
.notif-actions { display: flex; gap: 4px; flex-shrink: 0; }
.notif-action-btn {
background: none; border: 1px solid #ccc; border-radius: 6px;
font-size: 11px; cursor: pointer; padding: 2px 8px;
font-family: inherit; white-space: nowrap;
}
.notif-btn-added { color: #4a9d7e; border-color: #7ec6a4; }
.notif-btn-added:hover { background: #e8f5e9; }
.notif-btn-review { color: #e65100; border-color: #ffb74d; }
.notif-btn-review:hover { background: #fff3e0; }
.notif-body { color: #888; font-size: 12px; margin-top: 2px; white-space: pre-line; }
.notif-time { color: #bbb; font-size: 11px; margin-top: 2px; }
.notif-empty { text-align: center; color: #ccc; padding: 16px; font-size: 13px; }
/* Bug report form */
.bug-form { margin-top: 12px; border-top: 1px solid #eee; padding-top: 10px; }
.bug-textarea {
width: 100%; padding: 8px 10px; border: 1.5px solid #d4cfc7;
border-radius: 8px; font-size: 13px; font-family: inherit;
outline: none; resize: vertical; box-sizing: border-box;
}
.bug-textarea:focus { border-color: #7a9e7e; }
.bug-form-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 8px; }
.btn-sm { padding: 6px 14px; border-radius: 8px; font-size: 13px; cursor: pointer; font-family: inherit; border: none; }
.btn-primary { background: #7a9e7e; color: white; }
.btn-outline { background: white; color: #666; border: 1px solid #d4cfc7; }
.btn-sm:disabled { opacity: 0.5; cursor: not-allowed; }
</style>