跳到主要内容Javajava
Spring Boot 集成华为云 OBS 实现文件上传与预览功能
介绍基于 Spring Boot 和华为云 OBS 的文件管理方案。涵盖配置连接、限制上传类型、上传至私有桶、生成预签名 URL 及后端代理预览接口。提供完整的 Java 代码示例,包括 Controller、Service、Mapper 及配置文件,确保文件存储的安全性与可追溯性。
雾岛听风27 浏览 Spring Boot 集成华为云 OBS 实现文件上传与预览功能
一、背景与需求
在现代 Web 应用中,文件上传与访问是常见需求。出于安全性考虑,我们通常将文件存储在私有桶(Private Bucket)中,禁止直接公开访问。此时,需要通过预签名 URL(Presigned URL)机制临时授权用户访问特定文件。
本文将演示如何:
- 配置华为云 OBS 连接
- 限制上传文件类型
- 将文件上传至 OBS 并记录元数据
- 生成 1 小时有效的预签名 URL 用于前端预览或下载
- 提供后端代理式文件预览接口(解决跨域、权限控制等问题)
二、项目结构概览
com.hrm.rmhr
├── config
│ └── ObsConfig.java
├── controller
│ └── FileController.java
├── service
│ ├── ObsService.java
│ └── impl/ObsServiceImpl.java
├── entity
│ └── FileMetadata.java
└── mapper
└── FileMetadataMapper.java
三、项目依赖(Maven)
<dependency>
<groupId>com.huaweicloud</groupId>
<artifactId>esdk-obs-java</artifactId>
<version>3.22.8</version>
</dependency>
四、配置文件:application.yml
file:
obs:
endpoint: https://obs.cn-north-4.myhuaweicloud.com
ak: YOUR_ACCESS_KEY_ID
sk: YOUR_SECRET_ACCESS_KEY
相关免费在线工具
- Keycode 信息
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
- Escape 与 Native 编解码
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
- JavaScript / HTML 格式化
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
- JavaScript 压缩与混淆
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
bucket-name:
your-bucket-name
storage-root-directory:
/files/
五、核心配置:ObsConfig
@Configuration
@ConfigurationProperties(prefix = "file.obs")
@Data
public class ObsConfig {
private String endpoint;
private String ak;
private String sk;
private String bucketName;
private String storageRootDirectory;
}
注意:安全提示:AK/SK 属于敏感信息,建议通过配置中心或环境变量注入,避免硬编码。推荐使用 ${OBS_AK} 和 ${OBS_SK}。
六、文件上传逻辑
1. 安全校验
- 检查文件是否为空
- 校验文件扩展名(仅允许常见办公/图片格式)
private static final Set<String> ALLOWED_EXTENSIONS = new HashSet<>(Arrays.asList("xls", "xlsx", "doc", "docx", "pdf", "jpg", "jpeg", "png", "gif", "bmp", "txt", "csv"));
2. 调用 OBS 服务上传
- 使用
MultipartFile 获取输入流
- 构建唯一文件名(UUID + 原始名清洗)
- 按年月 + 分类组织存储路径(如
resume/2025/12/xxx.pdf)
- 保存元数据到数据库(含原始名、大小、类型、上传人、租户等)
3. 返回预签名 URL
上传成功后,立即生成一个 1 小时有效的预签名 URL,供前端直接预览或下载:
{
"code": 200,
"msg": "success",
"data": {
"url": "https://bucket.obs.cn-north-4.myhuaweicloud.com/files/resume/2025/12/xxxx?Expires=...&AccessKeyId=...&Signature=...",
"fileId": "123"
}
}
七、预签名 URL 生成
@PostMapping("/generatePresignedUrl")
public Result<String> generatePresignedUrl(@RequestParam("fileId") Integer fileId, @RequestParam(value = "expirationSeconds", defaultValue = "3600") int expirationSeconds)
用途:前端在需要时动态获取新链接(如刷新过期链接),避免长期暴露 URL。
八、安全预览接口
虽然前端可直接使用预签名 URL 访问文件,但存在以下问题:
- 跨域问题(CORS)
- 无法统一添加鉴权逻辑
- 浏览器对某些 Content-Type 处理不一致(如 PDF 强制下载而非预览)
@GetMapping("/preview/{fileId}")
public void previewFile(@PathVariable Integer fileId, HttpServletRequest request, HttpServletResponse response) throws IOException
关键实现细节:
1. 设置正确的 Content-Type
从数据库读取 contentType(如 application/pdf),确保浏览器正确渲染。
2. 安全设置 Content-Disposition
使用 RFC 5987 标准支持中文文件名,兼容新旧浏览器:
private String encodeFileName(String fileName) {
String asciiName = fileName.replaceAll("[^\\x20-\\x7e]", "_");
String utf8Encoded = URLEncoder.encode(fileName, StandardCharsets.UTF_8).replace("+", "%20");
return "inline; filename=" + asciiName + "; filename*=UTF-8''" + utf8Encoded;
}
inline 表示在浏览器中预览(非强制下载)filename* 支持 UTF-8 编码的文件名
3. 后端下载并转发
通过 new URL(presignedUrl).openStream() 从 OBS 拉取文件,写入 HttpServletResponse 输出流,实现'透明代理'。
九、文件删除
- 先删除 OBS 中的对象
- 再标记数据库记录为已删除(软删除)
- 避免因网络问题导致数据不一致
十、安全与最佳实践
- 不要暴露 AK/SK:使用 IAM 角色或临时凭证更安全。
- 预签名 URL 有效期不宜过长:默认 1 小时,敏感文件可缩短至 5~10 分钟。
- 文件名清洗:防止路径穿越(如
../../../etc/passwd)。
- 租户隔离:多租户系统中,
storageRootDirectory 可包含 tenantCode。
- 日志审计:记录上传/删除操作,便于追踪。
十一、总结
本文完整实现了基于华为云 OBS 的文件上传与安全预览方案,兼顾功能性、安全性与用户体验。核心亮点包括:
- 严格的文件类型校验
- 按分类 + 时间组织存储结构
- 支持中文文件名的安全预览
- 预签名 URL 动态管理
- 后端代理解决跨域与权限问题
适用场景:HR 系统简历上传、OA 附件管理、医疗影像存储、教育资料分发等。
十二、完整代码示例
以下是完整、可直接运行的 Spring Boot 项目代码,包含 MySQL 表结构适配、MyBatis Mapper 接口 + XML、华为云 OBS 上传/预览/软删除、Controller 支持上传与预览、配置文件完整。
1. 主启动类:CskjApplication.java
package org.cskj;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
@MapperScan("org.cskj.mapper")
public class CskjApplication {
public static void main(String[] args) {
SpringApplication.run(CskjApplication.class, args);
}
}
2. OBS 配置类:ObsConfig.java
package org.cskj.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "file.obs")
@Data
public class ObsConfig {
private String endpoint;
private String ak;
private String sk;
private String bucketName;
private String storageRootDirectory = "/files/";
}
3. 实体类:FileMetadata.java
package org.cskj.entity;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class FileMetadata {
private Long id;
private String originalFilename;
private String safeFilename;
private String bucketName;
private Long fileSize;
private String contentType;
private String category;
private LocalDateTime uploadTime;
private String uploader;
private Long tenantCode;
private Boolean deleted;
}
4. Mapper 接口:FileMetadataMapper.java
package org.cskj.mapper;
import org.apache.ibatis.annotations.Param;
import org.cskj.entity.FileMetadata;
public interface FileMetadataMapper {
int save(FileMetadata record);
FileMetadata selectById(@Param("id") Long id);
void deletedFileMetadata(@Param("id") Long id);
}
5. Mapper XML:FileMetadataMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.cskj.mapper.FileMetadataMapper">
<resultMap id="FileMetadataResultMap" type="org.cskj.entity.FileMetadata">
<id column="id" property="id"/>
<result column="original_filename" property="originalFilename"/>
<result column="safe_filename" property="safeFilename"/>
<result column="bucket_name" property="bucketName"/>
<result column="file_size" property="fileSize"/>
<result column="content_type" property="contentType"/>
<result column="category" property="category"/>
<result column="upload_time" property="uploadTime"/>
<result column="uploader" property="uploader"/>
<result column="tenant_code" property="tenantCode"/>
<result column="is_deleted" property="deleted" javaType="boolean" jdbcType="TINYINT"/>
</resultMap>
<insert id="save" useGeneratedKeys="true" keyProperty="id" keyColumn="id">
INSERT INTO file_metadata (original_filename, safe_filename, bucket_name, file_size, content_type, category, upload_time, uploader, tenant_code, is_deleted)
VALUES (#{originalFilename}, #{safeFilename}, #{bucketName}, #{fileSize}, #{contentType}, #{category}, NOW(), #{uploader}, #{tenantCode}, 0)
</insert>
<select id="selectById" resultMap="FileMetadataResultMap">
SELECT id, original_filename, safe_filename, bucket_name, file_size, content_type, category, upload_time, uploader, tenant_code, is_deleted
FROM file_metadata WHERE id = #{id} AND is_deleted = 0
</select>
<update id="deletedFileMetadata">
UPDATE file_metadata SET is_deleted = 1 WHERE id = #{id} AND is_deleted = 0
</update>
</mapper>
6. Service 接口:ObsService.java
package org.cskj.service;
import org.cskj.entity.FileMetadata;
import org.springframework.web.multipart.MultipartFile;
public interface ObsService {
FileMetadata uploadFile(MultipartFile file, String category, String uploader, Long tenantCode) throws Exception;
FileMetadata getFileMetadata(Long fileId);
String generatePresignedUrl(Long fileId, int expirationSeconds) throws Exception;
boolean deleteFile(Long fileId);
}
7. Service 实现:ObsServiceImpl.java
package org.cskj.service.impl;
import com.obs.services.ObsClient;
import com.obs.services.model.HttpMethodEnum;
import com.obs.services.model.PutObjectResult;
import com.obs.services.model.TemporarySignatureRequest;
import com.obs.services.model.TemporarySignatureResponse;
import org.cskj.config.ObsConfig;
import org.cskj.entity.FileMetadata;
import org.cskj.mapper.FileMetadataMapper;
import org.cskj.service.ObsService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.PostConstruct;
import java.io.InputStream;
import java.time.LocalDate;
@Service
public class ObsServiceImpl implements ObsService {
@Autowired
private ObsConfig obsConfig;
@Autowired
private FileMetadataMapper fileMetadataMapper;
private ObsClient obsClient;
private static final Logger log = LoggerFactory.getLogger(ObsServiceImpl.class);
@PostConstruct
public void init() {
this.obsClient = new ObsClient(obsConfig.getAk(), obsConfig.getSk(), obsConfig.getEndpoint());
}
@Override
public FileMetadata uploadFile(MultipartFile file, String category, String uploader, Long tenantCode) throws Exception {
String originalFilename = file.getOriginalFilename();
if (originalFilename == null) originalFilename = "unknown";
String safeFilename = buildSafeFilename(category, originalFilename);
String objectKey = obsConfig.getStorageRootDirectory() + safeFilename;
try (InputStream in = file.getInputStream()) {
PutObjectResult result = obsClient.putObject(obsConfig.getBucketName(), objectKey, in);
log.info("OBS 上传成功:key={}, size={}, ETag={}", objectKey, file.getSize(), result.getEtag());
}
FileMetadata metadata = new FileMetadata();
metadata.setOriginalFilename(originalFilename);
metadata.setSafeFilename(safeFilename);
metadata.setBucketName(obsConfig.getBucketName());
metadata.setFileSize(file.getSize());
metadata.setContentType(file.getContentType());
metadata.setCategory(category);
metadata.setUploader(uploader);
metadata.setTenantCode(tenantCode);
metadata.setDeleted(false);
fileMetadataMapper.save(metadata);
return metadata;
}
@Override
public FileMetadata getFileMetadata(Long fileId) {
return fileMetadataMapper.selectById(fileId);
}
@Override
public String generatePresignedUrl(Long fileId, int expirationSeconds) throws Exception {
FileMetadata meta = getFileMetadata(fileId);
if (meta == null) {
throw new RuntimeException("文件不存在或已被删除");
}
String objectKey = obsConfig.getStorageRootDirectory() + meta.getSafeFilename();
TemporarySignatureRequest request = new TemporarySignatureRequest(HttpMethodEnum.GET, expirationSeconds);
request.setBucketName(meta.getBucketName());
request.setObjectKey(objectKey);
TemporarySignatureResponse response = obsClient.createTemporarySignature(request);
return response.getSignedUrl();
}
@Override
public boolean deleteFile(Long fileId) {
FileMetadata meta = getFileMetadata(fileId);
if (meta == null) return false;
try {
String objectKey = obsConfig.getStorageRootDirectory() + meta.getSafeFilename();
obsClient.deleteObject(obsConfig.getBucketName(), objectKey);
log.info("OBS 文件删除成功:{}", objectKey);
} catch (Exception e) {
log.error("OBS 删除失败", e);
return false;
}
fileMetadataMapper.deletedFileMetadata(fileId);
return true;
}
private String buildSafeFilename(String category, String originalFilename) {
String cleanName = originalFilename.replaceAll("[^a-zA-Z0-9._\\-]", "_");
String timestamp = String.valueOf(System.currentTimeMillis());
String filename = timestamp + "_" + cleanName;
String year = String.valueOf(LocalDate.now().getYear());
String month = String.format("%02d", LocalDate.now().getMonthValue());
if (category != null && !category.trim().isEmpty()) {
return category + "/" + year + "/" + month + "/" + filename;
} else {
return year + "/" + month + "/" + filename;
}
}
}
8. Controller:FileController.java
package org.cskj.controller;
import org.cskj.entity.FileMetadata;
import org.cskj.service.ObsService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletResponse;
import java.io.InputStream;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
@RestController
@RequestMapping("/api/file")
public class FileController {
@Autowired
private ObsService obsService;
private static final Logger log = LoggerFactory.getLogger(FileController.class);
private static final String DEFAULT_UPLOADER = "system";
private static final Long DEFAULT_TENANT_CODE = 1L;
@PostMapping("/upload")
public ResponseEntity<?> upload(@RequestParam("file") MultipartFile file, @RequestParam(value = "category", required = false) String category) {
if (file.isEmpty()) {
return ResponseEntity.badRequest().body("文件为空");
}
try {
FileMetadata meta = obsService.uploadFile(file, category, DEFAULT_UPLOADER, DEFAULT_TENANT_CODE);
String url = obsService.generatePresignedUrl(meta.getId(), 3600);
return ResponseEntity.ok().header("X-File-Id", meta.getId().toString()).body(url);
} catch (Exception e) {
log.error("上传失败", e);
return ResponseEntity.status(500).body("上传失败:" + e.getMessage());
}
}
@GetMapping("/preview/{fileId}")
public void preview(@PathVariable Long fileId, HttpServletResponse response) {
try {
FileMetadata meta = obsService.getFileMetadata(fileId);
if (meta == null) {
response.sendError(HttpServletResponse.SC_NOT_FOUND, "文件不存在");
return;
}
String presignedUrl = obsService.generatePresignedUrl(fileId, 3600);
String contentType = meta.getContentType();
if (contentType == null || contentType.isEmpty()) {
contentType = "application/octet-stream";
}
response.setContentType(contentType);
String encodedName = URLEncoder.encode(meta.getOriginalFilename(), StandardCharsets.UTF_8.toString()).replace("+", "%20");
response.setHeader("Content-Disposition", "inline; filename=\"" + meta.getOriginalFilename() + "\"; filename*=UTF-8''" + encodedName);
try (InputStream in = new URL(presignedUrl).openStream()) {
in.transferTo(response.getOutputStream());
}
} catch (Exception e) {
log.error("预览失败", e);
try {
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "预览失败");
} catch (Exception ignored) {
}
}
}
@PostMapping("/preview")
public void previewFilePost(@RequestBody Map<String, Integer> requestBody, HttpServletResponse response) throws IOException {
Integer fileId = requestBody.get("fileId");
if (fileId == null) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "缺少文件 ID");
return;
}
String presignedUrl = obsService.generatePresignedUrl(fileId, 3600);
FileMetadata metadata = fileMetadataService.selectById(fileId);
if (metadata == null || metadata.getDeleted()) {
response.sendError(HttpServletResponse.SC_NOT_FOUND, "文件不存在");
return;
}
response.setContentType(metadata.getContentType());
String contentDisposition = encodeFileName(metadata.getOriginalFilename());
response.setHeader("Content-Disposition", contentDisposition);
try (InputStream in = new URL(presignedUrl).openStream(); OutputStream out = response.getOutputStream()) {
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = in.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
}
out.flush();
} catch (Exception e) {
log.error("预览文件失败", e);
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "文件读取失败");
}
}
private String encodeFileName(String fileName) {
String asciiName = fileName.replaceAll("[^\\x20-\\x7e]", "_");
String utf8Encoded = URLEncoder.encode(fileName, StandardCharsets.UTF_8).replace("+", "%20");
return "inline; filename=\"" + asciiName + "\"; filename*=UTF-8''" + utf8Encoded;
}
@DeleteMapping("/{fileId}")
public ResponseEntity<?> delete(@PathVariable Long fileId) {
boolean success = obsService.deleteFile(fileId);
if (success) {
return ResponseEntity.ok("删除成功");
} else {
return ResponseEntity.status(404).body("文件不存在或已删除");
}
}
}
9. 配置文件:application.yml
server:
port: 8080
spring:
datasource:
url: jdbc:mysql://localhost:3306/your_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: your_password
driver-class-name: com.mysql.cj.jdbc.Driver
mybatis:
mapper-locations: classpath:mapper/*.xml
configuration:
map-underscore-to-camel-case: true
file:
obs:
endpoint: https://obs.cn-north-4.myhuaweicloud.com
ak: YOUR_HUAWEI_CLOUD_ACCESS_KEY
sk: YOUR_HUAWEI_CLOUD_SECRET_KEY
bucket-name: your-bucket-name
storage-root-directory: /files/
10. Maven 依赖(pom.xml)
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.huaweicloud</groupId>
<artifactId>esdk-obs-java</artifactId>
<version>3.22.8</version>
</dependency>
</dependencies>
11. 测试
curl -F "[email protected]" http://localhost:8080/api/file/upload
curl http://localhost:8080/api/file/preview/1
12. 前端验证 HTML
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>文件预览测试(POST 方式)</title>
<style>
body { font-family: Arial, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background-color: #f5f7fa; }
.container { text-align: center; padding: 30px; border: 1px solid #ddd; border-radius: 8px; background: white; box-shadow: 0 2px 10px rgba(0,0,0,0.1); width: 90%; max-width: 500px; }
h2 { color: #333; margin-bottom: 20px; }
input[type="number"] { width: 100%; padding: 10px; margin: 10px 0; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; font-size: 16px; }
button { padding: 10px 20px; background-color: #409eff; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; width: 100%; }
button:hover { background-color: #53a8ff; }
button:disabled { background-color: #ccc; cursor: not-allowed; }
.tip { color: #666; font-size: 14px; margin-top: 15px; }
.status { margin-top: 10px; font-size: 14px; color: #e6a23c; }
</style>
</head>
<body>
<div class="container">
<h2>文件预览测试(POST 接口)</h2>
<p>请输入文件 ID(fileId):</p>
<input type="number" id="fileIdInput" placeholder="例如:69" min="1"/><br/>
<button id="previewBtn" onclick="previewFile()">🔍 预览文件</button>
<div id="status" class="status"></div>
<div class="tip">后端接口需为 POST /api/file/preview,接收 { "fileId": 69 },返回文件流(Content-Disposition: inline)</div>
</div>
<script>
function setStatus(msg, isError = false) {
const statusEl = document.getElementById('status');
statusEl.textContent = msg;
statusEl.style.color = isError ? '#f56565' : '#e6a23c';
}
async function previewFile() {
const fileIdStr = document.getElementById('fileIdInput').value.trim();
const btn = document.getElementById('previewBtn');
if (!fileIdStr) { alert('请输入文件 ID'); return; }
const fileId = parseInt(fileIdStr, 10);
if (isNaN(fileId) || fileId <= 0) { alert('请输入有效的正整数文件 ID'); return; }
btn.disabled = true;
setStatus('正在请求文件...');
try {
const response = await fetch('http://localhost:8080/app/api/file/preview', {
method: 'POST',
headers: { 'Content-Type': },
: .({ : fileId })
});
(!response.) {
errorMsg = response.().( );
();
}
contentType = response..() || ;
(!contentType.() && !contentType.() && !contentType.()) {
(!()) {
btn. = ;
();
;
}
}
blob = response.();
url = ..(blob);
win = .(url, );
(!win) { (); }
();
} (error) {
.(, error);
( + (error. || ));
(, );
} {
btn. = ;
( (), );
}
}
.().(, {
(e. === ) { (); }
});
</script>
</body>
</html>
'application/json'
body
JSON
stringify
fileId
if
ok
const
await
text
catch
() =>
'未知错误'
throw
new
Error
`HTTP ${response.status}: ${errorMsg}`
const
headers
get
'content-type'
'application/octet-stream'
if
startsWith
'image/'
includes
'pdf'
includes
'text/'
if
confirm
'该文件类型可能无法在浏览器中直接预览,是否仍要下载?'
disabled
false
setStatus
''
return
const
await
blob
const
window
URL
createObjectURL
const
window
open
'_blank'
if
alert
'浏览器阻止了弹出窗口,请允许后重试'
setStatus
'预览已打开 ✅'
catch
console
error
'预览失败:'
alert
'预览失败:'
message
'网络或服务器错误'
setStatus
'预览失败 ❌'
true
finally
disabled
false
setTimeout
() =>
setStatus
''
3000
document
getElementById
'fileIdInput'
addEventListener
'keypress'
(e) =>
if
key
'Enter'
previewFile