一、需求背景
很多后台管理系统都会遇到一个很典型的需求:
- 在后台右上角放一个"使用手册"入口
- 用户第一次登录进入系统时,弹一个引导框提醒他可以先看手册
- 点击"查看手册"时,希望新窗口直接预览 PDF,而不是下载
- 刷新页面后不应该重复弹窗,只有真正重新登录后才再次弹出
这类需求不复杂,但很容易在两个地方踩坑:
- 对象存储里的 PDF 链接直接打开时变成下载
- 登录引导弹窗写成"页面初始化弹出",导致一刷新就反复出现
这篇文章把完整的实现思路、前后端代码和踩坑点都整理一下,方便后面直接复用。
为了方便发博客,下面所有示例都做了匿名化处理:
- 不出现真实项目名称
- 不出现真实域名
- 不出现真实 OSS 地址
- 代码示例只保留实现关键点
二、问题拆解
这个需求本质上要解决两件事:
1. 怎么让 PDF 在新窗口里预览,而不是下载
很多同学会直接这样写:
ts
window.open('https://storage.example.com/files/manual.pdf')
但这样是否预览,取决于对象存储返回的响应头。
如果返回的是:
http
Content-Type: application/pdf
Content-Disposition: attachment
浏览器就会直接下载,而不是在线预览。
2. 怎么保证"首次登录弹一次",而不是"每次刷新都弹"
很多实现会在 Layout 页面里监听:
- 用户已登录
- 用户信息已加载完成
然后直接弹窗。
这种写法的问题是:
- 页面刷新时,Layout 组件会重新挂载
- 组件内的
ref状态会重置 - 于是系统又会把这次当成"第一次进入"
所以,真正要判断的不是"当前页面是否初始化",而是"这次是不是刚刚登录成功"。
三、两种可选方案
方案一:直接改对象存储预览配置
思路是:
- 使用自定义域名访问对象存储
- 将 PDF 对象的
Content-Disposition改成inline - 让浏览器直接预览
这个方案没有问题,但通常会有几个前提:
- 你有对象存储控制台权限
- 你能修改对象元数据
- 你能处理不同环境下的域名和访问策略
如果只是想快速把业务需求落地,这条路不一定是最省事的。
方案二:后端做文件转发预览
这个方案更稳,也更适合业务系统。
思路是:
- 前端不再直接打开对象存储链接
- 前端统一打开自己的后端接口
- 后端去请求对象存储上的 PDF
- 后端重新设置响应头为
inline - 浏览器看到的是后端返回的 PDF 流,于是直接预览
这次我采用的就是这条方案。
四、整体实现方案
最终落地结构如下:
后端
- 配置一个"使用手册标题 + 使用手册源地址"
- 提供一个匿名访问的预览接口
/api/system/manual/preview - 接口内部请求对象存储的 PDF
- 后端将响应头改成浏览器可预览的形式
前端
- 右上角放一个"使用手册"按钮
- 登录后弹出一次提示框
- 点击"查看手册"时,新窗口打开后端预览地址
弹窗控制
- 在"登录成功"的那一刻,写一个"待弹出引导"的会话标记
- 进入后台 Layout 后检查这个标记
- 如果存在,则弹窗并立即消费掉这个标记
- 因为标记已经被消费,所以刷新页面不会再次弹出
- 重新登录时,再次写入标记,于是会再次弹出
五、后端实现
1. 配置手册标题和源地址
先在配置文件里增加手册配置:
yaml
manual:
guide:
title: 系统使用手册
url: https://static.example.com/files/manual.pdf
这里不要写真实域名,博客里用示例地址即可。
2. 定义配置类
java
package com.example.system.manual.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
import javax.validation.constraints.NotBlank;
@Data
@Validated
@ConfigurationProperties(prefix = "manual.guide")
public class ManualProperties {
@NotBlank(message = "手册标题不能为空")
private String title;
@NotBlank(message = "手册地址不能为空")
private String url;
}
再通过配置类注册它:
java
package com.example.system.manual.config;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(ManualProperties.class)
public class ManualConfiguration {
}
这样以后想换手册 PDF,只需要改配置,不需要改代码。
3. 编写预览接口
后端接口的目标很明确:
- 对前端暴露一个统一入口
- 内部去请求远程 PDF
- 返回给浏览器时强制按预览处理
示例代码如下:
java
package com.example.system.manual.controller;
import cn.hutool.core.io.IoUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.Header;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletResponse;
@Slf4j
@RestController
@RequestMapping("/system/manual")
public class ManualController {
@Resource
private ManualProperties manualProperties;
@GetMapping("/preview")
public void preview(HttpServletResponse response) throws Exception {
try (HttpResponse remoteResponse = HttpRequest.get(manualProperties.getUrl())
.timeout(10_000)
.execute()) {
if (!remoteResponse.isOk()) {
response.sendError(HttpStatus.BAD_GATEWAY.value(), "手册暂时无法访问");
return;
}
byte[] content = remoteResponse.bodyBytes();
if (content == null || content.length == 0) {
response.sendError(HttpStatus.NOT_FOUND.value(), "手册内容不存在");
return;
}
String fileName = buildFilename(manualProperties.getTitle());
String contentType = resolveContentType(remoteResponse.header(Header.CONTENT_TYPE.getValue()));
response.setContentType(contentType);
response.setHeader(
Header.CONTENT_DISPOSITION.getValue(),
"inline;filename=" + java.net.URLEncoder.encode(fileName, "UTF-8")
);
response.setContentLengthLong(content.length);
IoUtil.write(response.getOutputStream(), false, content);
} catch (Exception ex) {
log.error("预览使用手册失败", ex);
response.sendError(HttpStatus.BAD_GATEWAY.value(), "手册暂时无法访问");
}
}
private String buildFilename(String title) {
if (StrUtil.endWithIgnoreCase(title, ".pdf")) {
return title;
}
return title + ".pdf";
}
private String resolveContentType(String remoteContentType) {
if (StrUtil.isBlank(remoteContentType)) {
return "application/pdf";
}
return StrUtil.subBefore(remoteContentType, ";", false);
}
}
4. 为什么这个接口建议由后端统一返回
因为一旦使用后端转发,前端就不用再关心:
- 当前手册真实地址是什么
- 对象存储是不是强制下载
- 预览时要不要拼额外参数
前端只需要知道一个固定地址:
text
/api/system/manual/preview
这会让业务代码非常干净。
5. 这个接口是否需要鉴权
这取决于你的实际需求。
如果你希望:
- 用户点击右上角按钮后直接新窗口打开
- 不想单独处理新窗口鉴权
那可以直接把这个接口放开。
如果你对安全要求更高,也可以改成:
- 登录态校验
- 一次性签名地址
- 中转页鉴权后再预览
这次为了业务实现简单、交互顺畅,我采用的是"匿名访问预览接口"的方式。
六、前端实现
1. 统一的手册工具方法
建议把"打开手册"和"弹窗待展示标记"统一抽到一个工具文件里。
示例:
ts
import { useCache } from '@/hooks/web/useCache'
import { config } from '@/config/axios/config'
const { wsCache: sessionCache } = useCache('sessionStorage')
const MANUAL_GUIDE_PENDING_KEY = 'manualGuidePending'
export const getManualTitle = () => {
return '系统使用手册'
}
export const getManualUrl = () => {
return `${config.base_url}/system/manual/preview`
}
export const openManual = () => {
const newWindow = window.open(getManualUrl(), '_blank', 'noopener,noreferrer')
return !!newWindow
}
export const markManualGuidePending = () => {
sessionCache.set(MANUAL_GUIDE_PENDING_KEY, true)
}
export const shouldAutoOpenManualGuide = () => {
return !!sessionCache.get(MANUAL_GUIDE_PENDING_KEY)
}
export const consumeManualGuidePending = () => {
sessionCache.delete(MANUAL_GUIDE_PENDING_KEY)
}
export const clearManualGuidePending = () => {
sessionCache.delete(MANUAL_GUIDE_PENDING_KEY)
}
这里有两个核心点:
openManual()统一打开后端预览地址manualGuidePending用sessionStorage保存一次性登录引导状态
2. 顶部按钮
右上角按钮实现可以非常简单:
vue
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { getManualTitle, openManual } from '@/utils/manual'
const manualTitle = getManualTitle()
const handleOpenManual = () => {
if (!openManual()) {
ElMessage.warning('新窗口打开失败,请检查浏览器弹窗拦截设置')
}
}
</script>
<template>
<el-button text @click="handleOpenManual">
{{ manualTitle }}
</el-button>
</template>
这样顶部入口和弹窗入口都复用同一个打开逻辑,不会出现两个入口行为不一致的问题。
3. 登录引导弹窗
弹窗组件里不要再写"只要用户信息加载好了就弹"。
正确做法是:
- 只有存在
manualGuidePending标记时才弹 - 一旦准备弹出,就立即消费掉这个标记
示例:
vue
<script setup lang="ts">
import { useUserStore } from '@/store/modules/user'
import {
consumeManualGuidePending,
getManualTitle,
openManual,
shouldAutoOpenManualGuide
} from '@/utils/manual'
const dialogVisible = ref(false)
const hasAutoOpened = ref(false)
const userStore = useUserStore()
const manualTitle = getManualTitle()
const handleOpenManual = () => {
if (!openManual()) {
return
}
dialogVisible.value = false
}
watch(
() => userStore.getIsSetUser,
async (value) => {
if (!value || hasAutoOpened.value || !shouldAutoOpenManualGuide()) {
return
}
hasAutoOpened.value = true
consumeManualGuidePending()
await nextTick()
dialogVisible.value = true
},
{ immediate: true }
)
</script>
这里一定要注意:
不要只用组件内的 ref 记录是否弹过
因为:
- 组件一刷新就会重建
ref会恢复初始值- 然后弹窗又会再来一次
所以真正的"是否需要自动弹出"应该由会话状态决定,而不是由单个页面组件决定。
七、为什么刷新后会重复弹窗
这个问题非常典型,值得单独说一下。
很多实现最开始会写成这样:
ts
watch(
() => userStore.getIsSetUser,
(value) => {
if (value) {
dialogVisible.value = true
}
},
{ immediate: true }
)
看起来没问题,但其实逻辑不对。
因为这个条件表示的是:
只要当前页面判断用户已经登录成功,就弹窗
而不是:
这次是否是刚完成登录,所以应该弹一次
于是会出现下面这个现象:
- 用户登录成功,弹窗弹出
- 用户关闭弹窗
- 用户刷新页面
- 用户信息重新加载
getIsSetUser又变成true- 弹窗再次出现
这就是"把登录事件写成页面事件"的典型问题。
八、正确做法:在登录成功时打标记
真正靠谱的方式,是把控制权放到登录成功的那一刻。
登录成功时写入:
ts
markManualGuidePending()
例如账号密码登录:
ts
const res = await loginApi(loginForm)
setToken(res)
markManualGuidePending()
router.push('/')
手机验证码登录:
ts
const res = await smsLoginApi(formData)
setToken(res)
markManualGuidePending()
router.push('/')
注册成功自动登录:
ts
const res = await registerApi(formData)
setToken(res)
markManualGuidePending()
router.push('/')
社交登录:
ts
const res = await socialLoginApi(code)
setToken(res)
markManualGuidePending()
router.push('/')
这样整个流程就顺了:
- 登录成功时写入标记
- Layout 检测到标记后弹窗
- 弹窗显示前就消费标记
- 刷新页面时,因为标记已经被删掉,所以不会再弹
- 退出重新登录时,重新写入标记,于是会再次弹出
九、退出登录时记得清理标记
如果有退出登录逻辑,建议顺手把这个标记也清掉:
ts
async function logout() {
await logoutApi()
removeToken()
clearManualGuidePending()
}
这样整个状态流转会更干净。
十、完整流程图
可以把这件事理解成下面这条链路:
text
登录成功
-> 写入 manualGuidePending = true
-> 跳转后台首页
-> Layout 初始化
-> 检测到 manualGuidePending = true
-> 消费 manualGuidePending
-> 弹出"查看使用手册"提示框
-> 用户点击"查看手册"
-> window.open('/api/system/manual/preview')
-> 后端请求对象存储 PDF
-> 后端返回 inline 响应
-> 浏览器新窗口在线预览 PDF
这个流程的优点是职责非常清晰:
- 登录模块只负责"写标记"
- Layout 只负责"读标记并消费"
- 手册工具类只负责"统一打开"
- 后端只负责"统一转发预览"
十一、为什么这种方案更适合业务项目
相比直接把对象存储链接塞给前端,这种做法有几个明显优势:
1. 地址统一
前端永远只认一个接口:
text
/api/system/manual/preview
后面不管对象存储地址怎么变,前端都不用改。
2. 预览行为可控
是否预览,不再受对象存储默认下载行为影响,而由后端统一决定。
3. 弹窗逻辑更符合业务语义
弹窗是"首次登录提示",不是"页面刷新提示"。
把控制点放在登录成功处,语义最准确,也最不容易出 bug。
4. 更方便后续扩展
以后如果你还想做:
- 不同角色显示不同手册
- 切换版本手册
- 后台动态配置手册地址
- 埋点统计谁点击过手册
这套结构都会比较容易扩展。
十二、容易忽略的细节
这里再补几个实战里容易忽略的小点。
1. window.open 可能被浏览器拦截
所以前端最好做一下失败提示:
ts
if (!openManual()) {
ElMessage.warning('新窗口打开失败,请检查浏览器弹窗拦截设置')
}
2. 后端超时时间不要太长
因为预览接口本质上是代理请求,如果远程文件地址一直很慢,用户体验会很差。
一般建议加超时,比如:
java
HttpRequest.get(url).timeout(10_000)
3. 文件名最好补 .pdf
因为有些标题只是"系统使用手册",不带后缀。
如果直接作为 Content-Disposition 的文件名返回,兼容性不一定最好,所以建议统一补 .pdf。
4. 不要在博客里暴露真实地址
比如下面这些都不建议直接发出去:
- 真实 OSS 地址
- 真实业务域名
- 真实接口前缀
- 真实项目名
博客最好都换成:
text
https://static.example.com/manual.pdf
/api/system/manual/preview
某后台系统
这样更安全,也更利于复用。
十三、总结
这个需求真正的关键,不是"弹窗怎么写",也不是"按钮怎么放",而是两个判断要做对:
第一,PDF 是否预览,取决于谁返回响应头
如果浏览器直接访问对象存储链接,那就受对象存储响应头控制。
如果由后端转发,那就由后端决定是预览还是下载。
第二,首次登录弹窗,取决于登录事件,而不是页面刷新事件
如果你把弹窗触发点放在 Layout 初始化,就很容易出现刷新反复弹。
如果你把弹窗触发点建立在"登录成功打标记"上,逻辑就会非常稳。
一句话概括这次实现:
手册预览交给后端统一兜底,首次提示交给登录成功事件统一打标。
这套方式很适合后台管理系统,简单、稳定,而且后面扩展也方便。
十四、可直接复用的最小实现清单
如果你只想快速照着做,最少只需要这几步:
- 配置手册标题和源地址
- 后端新增
/system/manual/preview接口 - 接口里请求远程 PDF,并返回
inline - 前端封装
openManual() - 登录成功时调用
markManualGuidePending() - Layout 中检测
shouldAutoOpenManualGuide() - 弹窗显示前执行
consumeManualGuidePending()
照这个顺序实现,基本不会走偏。