/** 复现步骤中的截图:存 Markdown 图片语法,详情页安全渲染 */ /** Markdown 图片:允许 ] 与 ( 之间有空格,与部分编辑器行为一致 */ export function getBugStepImageMarkdownRegex() { return /!\[[^\]]*\]\s*\(\s*((?:https?:\/\/|\/)[^)\s]+)\s*\)/gi } /** 新建 / 无复现步骤数据时,富文本编辑器内的默认骨架 */ export const BUG_STEPS_DEFAULT_HTML = '

复现步骤:

' + '


' + '


' + '

实际结果:

' + '


' + '


' + '

预期结果:

' + '


' export function escapeHtml(s) { return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') } /** 用于 HTML 属性(如 img src) */ export function escapeHtmlAttr(s) { return String(s) .replace(/&/g, '&') .replace(/"/g, '"') } /** * 上传文件路径中的反斜杠统一为 / * @param {string} url */ export function normalizeUploadPathSlashes(url) { return String(url || '').trim().replace(/\\/g, '/') } /** * 静态资源(Bug 上传图)访问根,不含末尾 / * - 开发环境默认 http://localhost:8881(与后端文件服务常见端口一致) * - 可在 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:8881' } } catch (e2) { /* ignore */ } return '' } /** * 浏览器实际加载图片用的地址:/uploads/... 在开发环境走 8881 端口 * 存库仍可为接口返回的完整 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, '
') const rawUrl = normalizeUploadPathSlashes(m[1]) const displaySrc = rewriteBugImageUrlForAccess(rawUrl) if (displaySrc && (/^https?:\/\//i.test(displaySrc) || displaySrc.startsWith('/'))) { out += `截图` } else { out += escapeHtml(m[0]) } last = m.index + m[0].length } out += escapeHtml(s.slice(last)).replace(/\n/g, '
') 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 → 8881) */ export function rewriteImgSrcsInHtml(html) { return String(html || '').replace(/(]*\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 += '

' + escapeHtml(text).replace(/\n/g, '
') + '

' } const src = rewriteBugImageUrlForAccess(normalizeUploadPathSlashes(m[1])) out += '

' last = m.index + m[0].length } const tail = s.slice(last) if (tail) { out += '

' + escapeHtml(tail).replace(/\n/g, '
') + '

' } return out || BUG_STEPS_DEFAULT_HTML }