功能更新:新增Bug管理模块,完善用户角色分配,优化项目设置

This commit is contained in:
qiaoxinjiu
2026-05-07 19:43:05 +08:00
parent 916248483c
commit f8211673ee
44 changed files with 10410 additions and 437 deletions

73
src/utils/bugHistory.js Normal file
View File

@@ -0,0 +1,73 @@
import { addBugHistory } from '@/api/bugApi'
/** 将任意值转为接口可接受的字符串(空用 '' */
export function toHistoryValue(v) {
if (v === undefined || v === null) return ''
if (typeof v === 'object') {
try {
return JSON.stringify(v)
} catch (e) {
return String(v)
}
}
return String(v)
}
/**
* 写入 Bug 操作历史POST /bug/history/add
* 失败不抛错、不阻断主流程,仅静默忽略(可后续接日志)
*/
export function recordBugHistory(store, { bugId, fieldName, oldValue, newValue, operatorId }) {
if (!bugId || !fieldName) return Promise.resolve()
const uid =
operatorId !== undefined && operatorId !== null && operatorId !== ''
? operatorId
: store && store.state && store.state.currentUser && store.state.currentUser.id
if (uid === undefined || uid === null || uid === '') return Promise.resolve()
return addBugHistory({
bugId: Number(bugId),
fieldName: String(fieldName),
oldValue: toHistoryValue(oldValue),
newValue: toHistoryValue(newValue),
operatorId: Number(uid)
}).catch(() => {})
}
/** 编辑页:与接口 fieldName 对齐的表单字段(用于对比写历史) */
export const BUG_EDIT_HISTORY_FIELDS = [
{ formKey: 'title', fieldName: 'title' },
{ formKey: 'bugType', fieldName: 'bug_type' },
{ formKey: 'severity', fieldName: 'severity' },
{ formKey: 'priority', fieldName: 'priority' },
{ formKey: 'status', fieldName: 'status' },
{ formKey: 'reporterId', fieldName: 'reporter_id' },
{ formKey: 'assigneeId', fieldName: 'assignee_id' },
{ formKey: 'moduleId', fieldName: 'module_id' },
{ formKey: 'caseId', fieldName: 'case_id' },
{ formKey: 'planId', fieldName: 'plan_id' },
{ formKey: 'environment', fieldName: 'environment' },
{ formKey: 'steps', fieldName: 'steps' },
{ formKey: 'reproduceRate', fieldName: 'reproduce_rate' }
]
export function buildBugEditBaseline(form) {
const f = form || {}
const o = {}
BUG_EDIT_HISTORY_FIELDS.forEach(({ formKey }) => {
o[formKey] = f[formKey]
})
return o
}
/** 对比编辑前后并逐字段写历史(仅变更字段) */
export function recordBugEditDiff(store, bugId, baseline, current) {
if (!bugId || !baseline || !current) return Promise.resolve()
const tasks = []
BUG_EDIT_HISTORY_FIELDS.forEach(({ formKey, fieldName }) => {
const ov = baseline[formKey]
const nv = current[formKey]
if (toHistoryValue(ov) === toHistoryValue(nv)) return
tasks.push(recordBugHistory(store, { bugId, fieldName, oldValue: ov, newValue: nv }))
})
return Promise.all(tasks)
}

View File

@@ -0,0 +1,163 @@
import {
STATUS_MAP,
PRIORITY_MAP,
SEVERITY_MAP,
BUG_TYPE_MAP,
formatBugType,
formatReproduceRate
} from '@/utils/bugMaps'
/** 历史记录 field_name / fieldName → 中文 */
const FIELD_NAME_CN = {
status: '状态',
assignee_id: '当前指派',
reporter_id: '创建人',
module_id: '模块',
product_id: '产品',
project_id: '项目',
priority: '优先级',
severity: '严重程度',
bug_type: '类型',
title: '标题',
environment: '环境',
case_id: '关联用例',
plan_id: '关联计划',
steps: '复现步骤',
solution: '解决方案',
resolve_version: '解决版本',
resolve_comment: '解决备注',
comment: '备注',
create: '创建',
delete: '删除',
description: '描述',
user_id: '用户',
is_auto: '自动化',
reproduce_rate: '复现率',
resolved_by: '解决人'
}
const SOLUTION_CN = {
by_design: '设计如此',
duplicate_bug: '重复Bug',
external_reason: '外部原因',
solution_resolved: '已解决',
cannot_reproduce: '无法重现',
deferred: '延期处理',
wont_fix: '不予解决'
}
const RESOLVE_VERSION_CN = {
trunk: '主干',
current_sprint: '当前迭代',
next: '下一版本'
}
function stripHtmlBrief(html, maxLen) {
if (html == null || html === '') return ''
const text = String(html)
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
if (!text) return '(富文本)'
if (maxLen && text.length > maxLen) return text.slice(0, maxLen) + '…'
return text
}
function toSnakeFieldKey(name) {
const s = String(name || '').trim()
if (!s) return ''
return s
.replace(/([a-z\d])([A-Z])/g, '$1_$2')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
.toLowerCase()
.replace(/^_+/, '')
.replace(/_+/g, '_')
}
/** 与 BUG_EDIT_HISTORY_FIELDS / 接口 fieldName 对齐的 snake_case key */
export function normalizeBugHistoryFieldKey(name) {
const snake = toSnakeFieldKey(name)
if (!snake) return ''
const compact = snake.replace(/_/g, '')
const alias = {
assigneeid: 'assignee_id',
reporterid: 'reporter_id',
moduleid: 'module_id',
productid: 'product_id',
projectid: 'project_id',
caseid: 'case_id',
planid: 'plan_id',
bugtype: 'bug_type',
reproducerate: 'reproduce_rate',
resolvedby: 'resolved_by',
userid: 'user_id',
isauto: 'is_auto',
resolveversion: 'resolve_version',
resolvecomment: 'resolve_comment',
oldvalue: 'old_value',
newvalue: 'new_value',
fieldname: 'field_name'
}
return alias[compact] || snake
}
export function formatBugHistoryFieldName(name) {
const k = normalizeBugHistoryFieldKey(name)
if (!k) return '-'
return FIELD_NAME_CN[k] || String(name || '').trim() || '-'
}
/**
* 按字段将历史 old/new 转为中文展示(未知枚举则原样返回)
*/
export function formatBugHistoryCellValue(fieldName, value) {
const fn = normalizeBugHistoryFieldKey(fieldName)
const raw = value
if (raw === undefined || raw === null || raw === '') return '(空)'
const s = String(raw).trim()
switch (fn) {
case 'status':
return STATUS_MAP[Number(s)] != null ? STATUS_MAP[Number(s)] : s
case 'priority':
return PRIORITY_MAP[Number(s)] != null ? PRIORITY_MAP[Number(s)] : s
case 'severity':
return SEVERITY_MAP[Number(s)] != null ? SEVERITY_MAP[Number(s)] : s
case 'bug_type':
return BUG_TYPE_MAP[Number(s)] != null ? BUG_TYPE_MAP[Number(s)] : formatBugType(Number(s))
case 'solution':
return SOLUTION_CN[s] || s
case 'resolve_version':
return RESOLVE_VERSION_CN[s] || s
case 'resolve_comment':
case 'comment':
return stripHtmlBrief(raw, 160)
case 'create':
case 'delete':
if (s === '0' || s === '1') return s === '1' ? '是' : '否'
return s
case 'steps':
return stripHtmlBrief(raw, 120)
case 'reproduce_rate':
return formatReproduceRate(s)
case 'resolved_by':
return s
default:
return s
}
}
/** 解决弹窗历史列表用:单行描述 */
export function formatBugHistoryLineText(h) {
const fn = h.field_name || h.fieldName || ''
var ov = h.old_value
if (ov === undefined || ov === null) ov = h.oldValue
var nv = h.new_value
if (nv === undefined || nv === null) nv = h.newValue
const op = h.operator_id || h.operatorId || h.operator_name || h.operatorName || ''
const label = formatBugHistoryFieldName(fn)
const o = formatBugHistoryCellValue(fn, ov)
const n = formatBugHistoryCellValue(fn, nv)
return `${label}${o}${n}(操作人 ${op}`
}

143
src/utils/bugMaps.js Normal file
View File

@@ -0,0 +1,143 @@
/** Bug 枚举与展示与接口一致bug_type 111 */
export const BUG_TYPE_MAP = {
1: '功能缺陷',
2: 'UI问题',
3: '性能问题',
4: '安全漏洞',
5: '兼容性问题',
6: '前端bug',
7: '后端bug',
8: '配置问题',
9: '产品设计缺陷',
10: '产品优化',
11: '其他'
}
export const SEVERITY_MAP = {
1: '致命',
2: '严重',
3: '中等',
4: '轻微'
}
export const PRIORITY_MAP = {
1: '高',
2: '中',
3: '低'
}
/** 复现概率 / 复现率(与接口 reproduceRate 一致) */
export const REPRODUCE_RATE_MAP = {
1: '必现',
2: '偶现',
3: '仅一次',
4: '难重现'
}
export function formatReproduceRate(v) {
const n = Number(v)
if (Number.isNaN(n)) return v === undefined || v === null || v === '' ? '-' : String(v)
return REPRODUCE_RATE_MAP[n] != null ? REPRODUCE_RATE_MAP[n] : String(v)
}
export const STATUS_MAP = {
0: '新建',
1: '待处理',
2: '进行中',
3: '已解决',
4: '已关闭',
5: '已拒绝'
}
/** 已解决状态值(与接口一致) */
export const BUG_STATUS_RESOLVED = 3
/** 已关闭 */
export const BUG_STATUS_CLOSED = 4
/** 待处理(重新打开目标状态) */
export const BUG_STATUS_PENDING = 1
/** 已拒绝 */
export const BUG_STATUS_REJECTED = 5
/**
* 主流程下一状态:新建→待处理→进行中→已解决→已关闭;已拒绝→待处理。
* 已关闭、未知状态无下一档。
*/
export function getBugStatusNextTransition(status) {
const s = Number(status)
if (Number.isNaN(s)) return null
const nextMap = {
0: 1,
1: 2,
2: 3,
3: 4,
5: 1
}
const next = nextMap[s]
if (next === undefined) return null
const label = STATUS_MAP[next]
if (label == null) return null
return { nextStatus: next, nextLabel: label }
}
export function formatBugType(v) {
return BUG_TYPE_MAP[v] != null ? BUG_TYPE_MAP[v] : v
}
export function formatSeverity(v) {
return SEVERITY_MAP[v] != null ? SEVERITY_MAP[v] : v
}
export function formatPriority(v) {
return PRIORITY_MAP[v] != null ? PRIORITY_MAP[v] : v
}
export function formatStatus(v) {
return STATUS_MAP[v] != null ? STATUS_MAP[v] : v
}
/** ElementUI el-tag type */
export function statusTagType(status) {
const map = {
0: 'info',
1: 'warning',
2: 'primary',
3: 'success',
4: 'info',
5: 'danger'
}
return map[Number(status)] || 'info'
}
/** 详情页等状态角标独立配色class 名) */
export function statusBadgeClass(status) {
const map = {
0: 'bug-status-new',
1: 'bug-status-pending',
2: 'bug-status-progress',
3: 'bug-status-resolved',
4: 'bug-status-closed',
5: 'bug-status-rejected'
}
return map[Number(status)] != null ? map[Number(status)] : 'bug-status-unknown'
}
export function severityTagType(severity) {
const map = { 1: 'danger', 2: 'warning', 3: '', 4: 'info' }
return map[Number(severity)] || 'info'
}
export function priorityTagType(priority) {
const map = { 1: 'danger', 2: 'warning', 3: 'success' }
return map[Number(priority)] || 'info'
}
export function bugTypeTagType(bugType) {
const n = Number(bugType)
if (!n || n < 1) return ''
const cycle = ['', 'success', 'warning', 'danger', 'info']
return cycle[(n - 1) % cycle.length]
}

192
src/utils/bugStepsFormat.js Normal file
View File

@@ -0,0 +1,192 @@
/** 复现步骤中的截图:存 Markdown 图片语法,详情页安全渲染 */
/** Markdown 图片:允许 ] 与 ( 之间有空格,与部分编辑器行为一致 */
export function getBugStepImageMarkdownRegex() {
return /!\[[^\]]*\]\s*\(\s*((?:https?:\/\/|\/)[^)\s]+)\s*\)/gi
}
/** 新建 / 无复现步骤数据时,富文本编辑器内的默认骨架 */
export const BUG_STEPS_DEFAULT_HTML =
'<p>复现步骤:</p>' +
'<p><br></p>' +
'<p><br></p>' +
'<p>实际结果:</p>' +
'<p><br></p>' +
'<p><br></p>' +
'<p>预期结果:</p>' +
'<p><br></p>'
export function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
/** 用于 HTML 属性(如 img src */
export function escapeHtmlAttr(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
}
/**
* 上传文件路径中的反斜杠统一为 /
* @param {string} url
*/
export function normalizeUploadPathSlashes(url) {
return String(url || '').trim().replace(/\\/g, '/')
}
/**
* 静态资源Bug 上传图)访问根,不含末尾 /
* - 开发环境默认 http://localhost:5010与后端文件服务常见端口一致
* - 可在 index.html 里设置 window.__BUG_UPLOAD_ORIGIN__ 覆盖
* - 打包时可配置 VUE_APP_BUG_UPLOAD_ORIGIN需在 webpack DefinePlugin 中注入)
*/
export function getBugUploadStaticOrigin() {
if (typeof window !== 'undefined' && window.__BUG_UPLOAD_ORIGIN__) {
return String(window.__BUG_UPLOAD_ORIGIN__).replace(/\/$/, '')
}
try {
if (typeof process !== 'undefined' && process.env && process.env.VUE_APP_BUG_UPLOAD_ORIGIN) {
return String(process.env.VUE_APP_BUG_UPLOAD_ORIGIN).replace(/\/$/, '')
}
} catch (e) { /* ignore */ }
try {
if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'development') {
return 'http://localhost:5010'
}
} catch (e2) { /* ignore */ }
return ''
}
/**
* 浏览器实际加载图片用的地址:/uploads/... 在开发环境走 5010 端口
* 存库仍可为接口返回的完整 URL仅展示/预览时改写
*/
export function rewriteBugImageUrlForAccess(url) {
const u = normalizeUploadPathSlashes(url)
if (!u) return ''
const staticOrigin = getBugUploadStaticOrigin()
let path = ''
if (/^https?:\/\//i.test(u)) {
const dbl = u.indexOf('//')
const pathStart = u.indexOf('/', dbl >= 0 ? dbl + 2 : 0)
path = pathStart >= 0 ? u.slice(pathStart) : '/'
} else if (u.startsWith('/')) {
path = u
} else {
return u
}
path = normalizeUploadPathSlashes(path)
if (staticOrigin && path.indexOf('/uploads/') === 0) {
return staticOrigin + path
}
if (/^https?:\/\//i.test(u)) return u
return resolveUploadPublicUrl(path)
}
/** 相对路径补全为当前站点 origin无专用静态域时 */
export function resolveUploadPublicUrl(url) {
const u = String(url || '').trim()
if (!u) return ''
if (/^https?:\/\//i.test(u)) return u
if (u.startsWith('//')) return `${window.location.protocol}${u}`
if (u.startsWith('/')) return `${window.location.origin}${u}`
return u
}
/**
* 解析 POST /bug/upload 成功响应(与接口一致:{ code, message, data: { url } }
*/
export function parseBugUploadFileUrl(res) {
if (!res) return ''
const inner = res.data
if (inner && typeof inner === 'object' && inner.url != null && inner.url !== '') {
return String(inner.url).trim()
}
if (typeof inner === 'string' && (inner.startsWith('http') || inner.startsWith('/'))) {
return inner.trim()
}
if (typeof res === 'string' && (res.startsWith('http') || res.startsWith('/'))) {
return res.trim()
}
if (res.url != null && res.url !== '') {
return String(res.url).trim()
}
const legacy =
(inner && inner.fileUrl) ||
(inner && inner.file_url) ||
(inner && inner.path) ||
(inner && typeof inner.data === 'object' && inner.data && inner.data.url) ||
''
return legacy ? String(legacy).trim() : ''
}
/**
* 将步骤文本转为可展示的 HTML仅把 Markdown 图片转为 img其余转义
*/
export function formatBugStepsToHtml(raw) {
const s = String(raw || '')
const re = getBugStepImageMarkdownRegex()
let out = ''
let last = 0
let m
re.lastIndex = 0
while ((m = re.exec(s)) !== null) {
out += escapeHtml(s.slice(last, m.index)).replace(/\n/g, '<br>')
const rawUrl = normalizeUploadPathSlashes(m[1])
const displaySrc = rewriteBugImageUrlForAccess(rawUrl)
if (displaySrc && (/^https?:\/\//i.test(displaySrc) || displaySrc.startsWith('/'))) {
out += `<img class="bug-step-img" src="${escapeHtmlAttr(displaySrc)}" alt="截图" />`
} else {
out += escapeHtml(m[0])
}
last = m.index + m[0].length
}
out += escapeHtml(s.slice(last)).replace(/\n/g, '<br>')
return out
}
/** 判断是否为富文本 HTML与旧版纯文本 / Markdown 区分) */
export function isStepsLikelyHtml(s) {
return /<\s*(p|div|br|img|span|ul|ol|li|h[1-6]|table|strong|em)\b/i.test(String(s || '').trim())
}
/** 将 HTML 中 img 的 src 改写为可访问地址(开发环境 /uploads → 5010 */
export function rewriteImgSrcsInHtml(html) {
return String(html || '').replace(/(<img\b[^>]*\bsrc\s*=\s*)(["'])([^"']*)\2/gi, function (_m, pre, q, src) {
const fixed = rewriteBugImageUrlForAccess(normalizeUploadPathSlashes(src))
return pre + q + escapeHtmlAttr(fixed) + q
})
}
/**
* 旧数据(纯文本 / Markdown 图片)转为 wangEditor 可用的 HTML
*/
export function legacyStepsToEditorHtml(raw) {
const s = String(raw || '')
if (!s.trim()) return BUG_STEPS_DEFAULT_HTML
if (isStepsLikelyHtml(s)) return rewriteImgSrcsInHtml(s)
const re = getBugStepImageMarkdownRegex()
let out = ''
let last = 0
let m
re.lastIndex = 0
while ((m = re.exec(s)) !== null) {
const text = s.slice(last, m.index)
if (text) {
out += '<p>' + escapeHtml(text).replace(/\n/g, '<br/>') + '</p>'
}
const src = rewriteBugImageUrlForAccess(normalizeUploadPathSlashes(m[1]))
out += '<p><img src="' + escapeHtmlAttr(src) + '" style="max-width:100%;"/></p>'
last = m.index + m[0].length
}
const tail = s.slice(last)
if (tail) {
out += '<p>' + escapeHtml(tail).replace(/\n/g, '<br/>') + '</p>'
}
return out || BUG_STEPS_DEFAULT_HTML
}

View File

@@ -0,0 +1,39 @@
/**
* 全局「最近选择的产品 + 项目」缓存localStorage供 Bug 列表、用例、计划、报告等共用。
* 兼容旧 keyeffekt_bug_list_last_product_project
*/
export const LAST_PRODUCT_PROJECT_CACHE_KEY = 'effekt_last_product_project'
const LEGACY_BUG_LIST_CACHE_KEY = 'effekt_bug_list_last_product_project'
export function readLastProductProjectCache() {
try {
let raw = localStorage.getItem(LAST_PRODUCT_PROJECT_CACHE_KEY)
if (!raw) raw = localStorage.getItem(LEGACY_BUG_LIST_CACHE_KEY)
if (!raw) return null
const o = JSON.parse(raw)
if (!o || typeof o !== 'object') return null
return { productId: o.productId, projectId: o.projectId }
} catch (e) {
return null
}
}
export function saveLastProductProjectCache(productId, projectId) {
if (productId === '' || productId === undefined || productId === null) return
if (projectId === '' || projectId === undefined || projectId === null) return
try {
const payload = JSON.stringify({ productId, projectId })
localStorage.setItem(LAST_PRODUCT_PROJECT_CACHE_KEY, payload)
if (localStorage.getItem(LEGACY_BUG_LIST_CACHE_KEY)) {
localStorage.removeItem(LEGACY_BUG_LIST_CACHE_KEY)
}
} catch (e) {
// quota / 隐私模式等忽略
}
}
export function pickIdFromOptions(options, rawId) {
const row = (options || []).find(p => String(p.id) === String(rawId))
return row ? row.id : rawId
}

View File

@@ -30,10 +30,17 @@ service.interceptors.response.use(
// 兼容后端返回结构:{ success, code, message, data }
if (data && data.code === 500) {
Message.error('服务异常')
return Promise.reject(new Error(data.message || '服务异常'))
} else if (data && data.code === 451) {
router.push({ name: 'login' })
Message.error(data.message || '登录已失效,请重新登录')
return Promise.reject(new Error(data.message || '登录已失效'))
} else if (data && data.success === false) {
Message.error(data.message || '请求失败')
return Promise.reject(new Error(data.message || '请求失败'))
} else if (data && data.code !== undefined && data.code !== 20000) {
Message.error(data.message || '请求失败')
return Promise.reject(new Error(data.message || '请求失败'))
} else {
return response.data
}