Files
oil-formula-calculator/frontend/src/views/BugTracker.vue
Hera Zhao 7dbcd2778e
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 5s
PR Preview / deploy-preview (pull_request) Successful in 12s
Test / e2e-test (push) Failing after 23s
Fix BugTracker: match actual API data model
- is_resolved (0/1/2/3) instead of string status
- priority (0/1/2 numbers) instead of strings
- content field instead of title/description
- display_name/username for reporter
- comment endpoint /comment (singular), body: {content}
- Fix duplicate content display in template

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 21:38:18 +00:00

633 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
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="bug-tracker">
<div class="toolbar">
<h3 class="page-title">🐛 Bug Tracker</h3>
<button class="btn-primary" @click="showAddBug = true">+ 新增Bug</button>
</div>
<!-- Active Bugs -->
<div class="section-header">
<span>🔴 活跃 ({{ activeBugs.length }})</span>
</div>
<div class="bug-list">
<div v-for="bug in activeBugs" :key="bug._id || bug.id" class="bug-card" :class="'priority-' + (bug.priority || 'normal')">
<div class="bug-header">
<span class="bug-priority" :class="'p-' + (bug.priority || 'normal')">{{ priorityLabel(bug.priority) }}</span>
<span class="bug-status" :class="'s-' + (bug.status || 'open')">{{ statusLabel(bug.status) }}</span>
<span class="bug-date">{{ formatDate(bug.created_at) }}</span>
</div>
<div class="bug-title">{{ bug.content }}</div>
<div v-if="bug.display_name" class="bug-reporter">{{ bug.display_name || bug.username }}</div>
<!-- Status workflow: is_resolved: 0=open, 1=testing, 2=fixed, 3=tested -->
<div class="bug-actions">
<template v-if="bug.is_resolved === 0">
<button class="btn-sm btn-status" @click="updateStatus(bug, 1)">待测试</button>
</template>
<template v-else-if="bug.is_resolved === 1">
<button class="btn-sm btn-status" @click="updateStatus(bug, 2)">已修复</button>
<button class="btn-sm btn-reopen" @click="updateStatus(bug, 0)">重新打开</button>
</template>
<template v-else-if="bug.is_resolved === 2">
<button class="btn-sm btn-status" @click="updateStatus(bug, 3)">已测试</button>
<button class="btn-sm btn-reopen" @click="updateStatus(bug, 0)">重新打开</button>
</template>
<button class="btn-sm btn-delete" @click="removeBug(bug)">删除</button>
</div>
<!-- Comments -->
<div class="comments-section" v-if="expandedBugId === (bug._id || bug.id)">
<div v-for="comment in (bug.comments || [])" :key="comment._id || comment.id" class="comment-item">
<div class="comment-meta">
<span class="comment-author">{{ comment.display_name || comment.username || '系统' }}</span>
<span class="comment-action" v-if="comment.action">{{ comment.action }}</span>
<span class="comment-time">{{ formatDate(comment.created_at) }}</span>
</div>
<div class="comment-text">{{ comment.content }}</div>
</div>
<div class="comment-add">
<input
v-model="newComment"
class="form-input"
placeholder="添加备注..."
@keydown.enter="addComment(bug)"
/>
<button class="btn-primary btn-sm" @click="addComment(bug)" :disabled="!newComment.trim()">发送</button>
</div>
</div>
<button class="btn-toggle-comments" @click="toggleComments(bug)">
💬 {{ (bug.comments || []).length }} 条备注
{{ expandedBugId === (bug._id || bug.id) ? '' : '' }}
</button>
</div>
<div v-if="activeBugs.length === 0" class="empty-hint">暂无活跃Bug</div>
</div>
<!-- Resolved Bugs -->
<div class="section-header" style="margin-top:20px" @click="showResolved = !showResolved">
<span> 已解决 ({{ resolvedBugs.length }})</span>
<span class="toggle-icon">{{ showResolved ? '▾' : '▸' }}</span>
</div>
<div v-if="showResolved" class="bug-list">
<div v-for="bug in resolvedBugs" :key="bug._id || bug.id" class="bug-card resolved">
<div class="bug-header">
<span class="bug-priority" :class="'p-' + (bug.priority || 'normal')">{{ priorityLabel(bug.priority) }}</span>
<span class="bug-status s-tested">已解决</span>
<span class="bug-date">{{ formatDate(bug.created_at) }}</span>
</div>
<div class="bug-title">{{ bug.content }}</div>
<div class="bug-actions">
<button class="btn-sm btn-reopen" @click="updateStatus(bug, 0)">重新打开</button>
<button class="btn-sm btn-delete" @click="removeBug(bug)">删除</button>
</div>
</div>
<div v-if="resolvedBugs.length === 0" class="empty-hint">暂无已解决Bug</div>
</div>
<!-- Add Bug Modal -->
<div v-if="showAddBug" class="overlay" @click.self="showAddBug = false">
<div class="overlay-panel">
<div class="overlay-header">
<h3>新增Bug</h3>
<button class="btn-close" @click="showAddBug = false"></button>
</div>
<div class="form-group">
<label>Bug 内容</label>
<textarea v-model="bugForm.content" class="form-textarea" rows="4" placeholder="描述问题、复现步骤等..."></textarea>
</div>
<div class="form-group">
<label>优先级</label>
<div class="priority-btns">
<button
v-for="p in priorities"
:key="p.value"
class="priority-btn"
:class="{ active: bugForm.priority === p.value, ['p-' + p.value]: true }"
@click="bugForm.priority = p.value"
>{{ p.label }}</button>
</div>
</div>
<div class="overlay-footer">
<button class="btn-outline" @click="showAddBug = false">取消</button>
<button class="btn-primary" @click="createBug" :disabled="!bugForm.content.trim()">提交</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, reactive, onMounted } from 'vue'
import { useAuthStore } from '../stores/auth'
import { useUiStore } from '../stores/ui'
import { api } from '../composables/useApi'
import { showConfirm } from '../composables/useDialog'
const auth = useAuthStore()
const ui = useUiStore()
const bugs = ref([])
const showAddBug = ref(false)
const showResolved = ref(false)
const expandedBugId = ref(null)
const newComment = ref('')
const bugForm = reactive({
content: '',
priority: 2,
})
// priority: 0=urgent, 1=high, 2=normal
const priorities = [
{ value: 0, label: '紧急' },
{ value: 1, label: '高' },
{ value: 2, label: '中' },
]
// is_resolved: 0=open, 1=testing, 2=fixed, 3=tested
const activeBugs = computed(() =>
bugs.value.filter(b => b.is_resolved !== 2 && b.is_resolved !== 3)
.sort((a, b) => (a.priority ?? 2) - (b.priority ?? 2))
)
const resolvedBugs = computed(() =>
bugs.value.filter(b => b.is_resolved === 2 || b.is_resolved === 3)
)
function priorityLabel(p) {
const map = { 0: '紧急', 1: '高', 2: '中' }
return map[p] ?? '中'
}
function statusLabel(s) {
const map = { 0: '待处理', 1: '待测试', 2: '已修复', 3: '已测试' }
return map[s] ?? '待处理'
}
function formatDate(d) {
if (!d) return ''
return new Date(d).toLocaleString('zh-CN', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function toggleComments(bug) {
const id = bug._id || bug.id
expandedBugId.value = expandedBugId.value === id ? null : id
}
async function loadBugs() {
try {
const res = await api('/api/bug-reports')
if (res.ok) {
bugs.value = await res.json()
}
} catch {
bugs.value = []
}
}
async function createBug() {
if (!bugForm.content.trim()) return
try {
const res = await api('/api/bug-report', {
method: 'POST',
body: JSON.stringify({
content: bugForm.content.trim(),
priority: bugForm.priority,
}),
})
if (res.ok) {
showAddBug.value = false
bugForm.content = ''
bugForm.priority = 2
await loadBugs()
ui.showToast('Bug已提交')
}
} catch {
ui.showToast('提交失败')
}
}
async function updateStatus(bug, newStatus) {
const id = bug.id
try {
const res = await api(`/api/bug-reports/${id}`, {
method: 'PUT',
body: JSON.stringify({ status: newStatus }),
})
if (res.ok) {
bug.is_resolved = newStatus
ui.showToast(`状态已更新: ${statusLabel(newStatus)}`)
}
} catch {
ui.showToast('更新失败')
}
}
async function removeBug(bug) {
const ok = await showConfirm(`确定删除 "${bug.content}"`)
if (!ok) return
const id = bug._id || bug.id
try {
const res = await api(`/api/bug-reports/${id}`, { method: 'DELETE' })
if (res.ok) {
bugs.value = bugs.value.filter(b => (b._id || b.id) !== id)
ui.showToast('已删除')
}
} catch {
ui.showToast('删除失败')
}
}
async function addComment(bug) {
if (!newComment.value.trim()) return
const id = bug._id || bug.id
try {
const res = await api(`/api/bug-reports/${id}/comment`, {
method: 'POST',
body: JSON.stringify({
content: newComment.value.trim(),
}),
})
if (res.ok) {
newComment.value = ''
await loadBugs()
ui.showToast('备注已添加')
}
} catch {
ui.showToast('添加失败')
}
}
onMounted(() => {
loadBugs()
})
</script>
<style scoped>
.bug-tracker {
padding: 0 12px 24px;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.page-title {
margin: 0;
font-size: 16px;
color: #3e3a44;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 4px;
font-size: 14px;
font-weight: 600;
color: #3e3a44;
margin-bottom: 8px;
cursor: pointer;
}
.toggle-icon {
font-size: 12px;
color: #999;
}
.bug-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.bug-card {
padding: 14px 16px;
background: #fff;
border: 1.5px solid #e5e4e7;
border-radius: 12px;
border-left: 4px solid #e5e4e7;
}
.bug-card.priority-critical { border-left-color: #d32f2f; }
.bug-card.priority-high { border-left-color: #f57c00; }
.bug-card.priority-normal { border-left-color: #1976d2; }
.bug-card.priority-low { border-left-color: #9e9e9e; }
.bug-card.resolved { opacity: 0.7; }
.bug-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.bug-priority {
padding: 2px 10px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
.p-critical { background: #ffebee; color: #c62828; }
.p-high { background: #fff3e0; color: #e65100; }
.p-normal { background: #e3f2fd; color: #1565c0; }
.p-low { background: #f5f5f5; color: #757575; }
.bug-status {
padding: 2px 10px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
}
.s-open { background: #ffebee; color: #c62828; }
.s-testing { background: #fff3e0; color: #e65100; }
.s-fixed { background: #e3f2fd; color: #1565c0; }
.s-tested { background: #e8f5e9; color: #2e7d5a; }
.bug-date {
font-size: 11px;
color: #b0aab5;
margin-left: auto;
}
.bug-title {
font-weight: 600;
font-size: 15px;
color: #3e3a44;
margin-bottom: 4px;
}
.bug-desc {
font-size: 13px;
color: #6b6375;
line-height: 1.6;
margin-bottom: 4px;
}
.bug-reporter {
font-size: 12px;
color: #b0aab5;
margin-bottom: 6px;
}
.bug-actions {
display: flex;
gap: 6px;
margin-top: 8px;
flex-wrap: wrap;
}
.btn-sm {
padding: 5px 14px;
font-size: 12px;
border-radius: 8px;
border: none;
cursor: pointer;
font-family: inherit;
font-weight: 500;
}
.btn-status {
background: linear-gradient(135deg, #7ec6a4, #4a9d7e);
color: #fff;
}
.btn-reopen {
background: #fff3e0;
color: #e65100;
border: 1.5px solid #ffe0b2;
}
.btn-delete {
background: #fff;
color: #ef5350;
border: 1.5px solid #ffcdd2;
}
.btn-toggle-comments {
border: none;
background: transparent;
color: #6b6375;
font-size: 12px;
cursor: pointer;
padding: 6px 0;
font-family: inherit;
margin-top: 4px;
}
.btn-toggle-comments:hover {
color: #3e3a44;
}
/* Comments */
.comments-section {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #f0eeeb;
}
.comment-item {
padding: 8px 10px;
background: #f8f7f5;
border-radius: 8px;
margin-bottom: 6px;
}
.comment-meta {
display: flex;
justify-content: space-between;
margin-bottom: 2px;
}
.comment-author {
font-size: 12px;
font-weight: 600;
color: #3e3a44;
}
.comment-time {
font-size: 11px;
color: #b0aab5;
}
.comment-text {
font-size: 13px;
color: #6b6375;
line-height: 1.5;
}
.comment-add {
display: flex;
gap: 6px;
margin-top: 8px;
}
.form-input {
flex: 1;
padding: 8px 12px;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
font-size: 13px;
font-family: inherit;
outline: none;
}
.form-input:focus {
border-color: #7ec6a4;
}
/* Overlay */
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.overlay-panel {
background: #fff;
border-radius: 16px;
padding: 24px;
max-width: 480px;
width: 100%;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
}
.overlay-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 18px;
}
.overlay-header h3 {
margin: 0;
font-size: 17px;
color: #3e3a44;
}
.overlay-footer {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 18px;
}
.form-group {
margin-bottom: 14px;
}
.form-group label {
display: block;
font-size: 13px;
font-weight: 600;
color: #3e3a44;
margin-bottom: 6px;
}
.form-textarea {
width: 100%;
border: 1.5px solid #d4cfc7;
border-radius: 8px;
padding: 10px 12px;
font-size: 13px;
font-family: inherit;
resize: vertical;
outline: none;
box-sizing: border-box;
}
.form-textarea:focus {
border-color: #7ec6a4;
}
.priority-btns {
display: flex;
gap: 6px;
}
.priority-btn {
padding: 6px 16px;
border-radius: 8px;
border: 1.5px solid #e5e4e7;
background: #fff;
font-size: 12px;
cursor: pointer;
font-family: inherit;
transition: all 0.15s;
}
.priority-btn.active {
font-weight: 600;
}
.priority-btn.active.p-low { background: #f5f5f5; border-color: #9e9e9e; color: #616161; }
.priority-btn.active.p-normal { background: #e3f2fd; border-color: #64b5f6; color: #1565c0; }
.priority-btn.active.p-high { background: #fff3e0; border-color: #ffb74d; color: #e65100; }
.priority-btn.active.p-critical { background: #ffebee; border-color: #ef9a9a; color: #c62828; }
/* Buttons */
.btn-primary {
background: linear-gradient(135deg, #7ec6a4 0%, #4a9d7e 100%);
color: #fff;
border: none;
border-radius: 10px;
padding: 9px 20px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
font-weight: 500;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
.btn-outline {
background: #fff;
color: #6b6375;
border: 1.5px solid #d4cfc7;
border-radius: 10px;
padding: 9px 20px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
}
.btn-close {
border: none;
background: #f0eeeb;
width: 28px;
height: 28px;
border-radius: 50%;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
color: #6b6375;
}
.empty-hint {
text-align: center;
color: #b0aab5;
font-size: 13px;
padding: 24px 0;
}
</style>