From a6c639dd43f22b394fde47f715079e43e4bdb848 Mon Sep 17 00:00:00 2001 From: zpc Date: Sun, 5 Apr 2026 16:51:24 +0800 Subject: [PATCH] 21 --- odf-uniapp/pages/fault-add/index.vue | 52 ++++++----- odf-uniapp/services/cos.js | 93 +++++++++++++++++++ odf-uniapp/services/trunk.js | 34 +------ .../Business/CosUploadController.cs | 90 ++++++++++++++++++ .../Business/OdfCableFaultsController.cs | 4 +- server/ZR.Admin.WebApi/ZR.Admin.WebApi.csproj | 1 + server/ZR.Admin.WebApi/appsettings.json | 10 ++ .../Business/Dto/OdfCableFaultsDto.cs | 6 +- .../Business/OdfCableFaultsService.cs | 38 +------- 9 files changed, 238 insertions(+), 90 deletions(-) create mode 100644 odf-uniapp/services/cos.js create mode 100644 server/ZR.Admin.WebApi/Controllers/Business/CosUploadController.cs diff --git a/odf-uniapp/pages/fault-add/index.vue b/odf-uniapp/pages/fault-add/index.vue index 599b015..4f799f3 100644 --- a/odf-uniapp/pages/fault-add/index.vue +++ b/odf-uniapp/pages/fault-add/index.vue @@ -138,6 +138,7 @@ import { ref, reactive, getCurrentInstance, nextTick } from 'vue' import { onLoad } from '@dcloudio/uni-app' import { addFault } from '@/services/trunk' +import { getPresignUrls, uploadToCos } from '@/services/cos' import { addWatermark } from '@/utils/watermark' const statusBarHeight = uni.getSystemInfoSync().statusBarHeight || 0 @@ -311,10 +312,10 @@ async function handleSubmit() { if (submitting.value) return submitting.value = true - uni.showLoading({ title: '提交中...', mask: true }) + uni.showLoading({ title: '处理图片中...', mask: true }) try { - // 水印处理 + // 1. 水印处理 const watermarkLines = [ `${form.faultTime} ${form.personnel}`, `故障原因:${form.faultReason || ''}`, @@ -332,35 +333,39 @@ async function handleSubmit() { } }) watermarkedPhotos.push(result) - // 每张图处理完后等一下,让 canvas 状态重置 await nextTick() } catch (err) { watermarkedPhotos.push(photo) } } - // 构建上传数据 - const files = watermarkedPhotos.map((path, index) => ({ - name: 'images', - uri: path - })) + // 2. 获取 COS 预签名 URL + uni.showLoading({ title: '准备上传...', mask: true }) + const presignList = await getPresignUrls(watermarkedPhotos.length, '.jpg') - const formData = { - files, - data: { - cableId: cableId.value, - faultTime: form.faultTime, - personnel: form.personnel, - faultReason: form.faultReason, - mileage: form.mileage, - mileageCorrection: form.mileageCorrection, - latitude: String(form.latitude), - longitude: String(form.longitude), - remark: form.remark - } + // 3. 逐张直传到 COS + const imageUrls = [] + for (let i = 0; i < watermarkedPhotos.length; i++) { + uni.showLoading({ title: `上传图片 ${i + 1}/${watermarkedPhotos.length}`, mask: true }) + await uploadToCos(presignList[i].presignUrl, watermarkedPhotos[i]) + imageUrls.push(presignList[i].accessUrl) } - const res = await addFault(formData) + // 4. 提交故障表单(JSON) + uni.showLoading({ title: '提交中...', mask: true }) + const res = await addFault({ + cableId: Number(cableId.value), + faultTime: form.faultTime, + personnel: form.personnel, + faultReason: form.faultReason, + mileage: form.mileage, + mileageCorrection: form.mileageCorrection, + latitude: Number(form.latitude), + longitude: Number(form.longitude), + remark: form.remark, + imageUrls + }) + if (res.code === 200) { uni.showToast({ title: '提交成功', icon: 'success' }) setTimeout(() => { @@ -370,7 +375,8 @@ async function handleSubmit() { uni.showToast({ title: res.msg || '提交失败', icon: 'none' }) } } catch (err) { - uni.showToast({ title: '网络异常,请重试', icon: 'none' }) + console.error('[fault-add] 提交失败:', err) + uni.showToast({ title: err.message || '网络异常,请重试', icon: 'none' }) } finally { uni.hideLoading() submitting.value = false diff --git a/odf-uniapp/services/cos.js b/odf-uniapp/services/cos.js new file mode 100644 index 0000000..e666b05 --- /dev/null +++ b/odf-uniapp/services/cos.js @@ -0,0 +1,93 @@ +import { post } from './api' + +/** + * 从后端获取 COS 预签名上传 URL + * @param {number} count - 文件数量 + * @param {string} ext - 文件扩展名 + * @returns {Promise>} + */ +export async function getPresignUrls(count, ext = '.jpg') { + const res = await post('/business/CosUpload/presignUrl', { count, ext }) + if (res.code !== 200) { + throw new Error(res.msg || '获取上传地址失败') + } + return res.data +} + +/** + * 通过预签名 URL 直传文件到 COS + * @param {string} presignUrl - PUT 预签名 URL + * @param {string} filePath - 本地文件路径 + * @returns {Promise} + */ +export function uploadToCos(presignUrl, filePath) { + return new Promise((resolve, reject) => { + // #ifdef H5 + _uploadH5(presignUrl, filePath).then(resolve).catch(reject) + // #endif + // #ifdef APP-PLUS + _uploadApp(presignUrl, filePath).then(resolve).catch(reject) + // #endif + }) +} + +// #ifdef H5 +async function _uploadH5(presignUrl, filePath) { + // H5 端 filePath 可能是 base64 或 blob URL + let blob + if (filePath.startsWith('data:')) { + const resp = await fetch(filePath) + blob = await resp.blob() + } else { + const resp = await fetch(filePath) + blob = await resp.blob() + } + const res = await fetch(presignUrl, { + method: 'PUT', + headers: { 'Content-Type': 'image/jpeg' }, + body: blob + }) + if (!res.ok) { + throw new Error(`COS上传失败: ${res.status}`) + } +} +// #endif + +// #ifdef APP-PLUS +function _uploadApp(presignUrl, filePath) { + return new Promise((resolve, reject) => { + plus.io.resolveLocalFileSystemURL(filePath, (entry) => { + entry.file((file) => { + const reader = new plus.io.FileReader() + reader.onloadend = (e) => { + const base64 = e.target.result + // 将 base64 转为 ArrayBuffer + const binary = atob(base64.split(',')[1]) + const len = binary.length + const bytes = new Uint8Array(len) + for (let i = 0; i < len; i++) { + bytes[i] = binary.charCodeAt(i) + } + // 使用 XMLHttpRequest 发送 PUT + const xhr = new XMLHttpRequest() + xhr.open('PUT', presignUrl, true) + xhr.setRequestHeader('Content-Type', 'image/jpeg') + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve() + } else { + reject(new Error(`COS上传失败: ${xhr.status}`)) + } + } + xhr.onerror = () => reject(new Error('COS上传网络错误')) + xhr.send(bytes.buffer) + } + reader.onerror = () => reject(new Error('读取文件失败')) + reader.readAsDataURL(file) + }) + }, (err) => { + reject(new Error('解析文件路径失败: ' + JSON.stringify(err))) + }) + }) +} +// #endif diff --git a/odf-uniapp/services/trunk.js b/odf-uniapp/services/trunk.js index 4ab29d6..97ab689 100644 --- a/odf-uniapp/services/trunk.js +++ b/odf-uniapp/services/trunk.js @@ -1,5 +1,4 @@ -import { get, post, BASE_URL } from './api' -import store from '@/store' +import { get, post } from './api' export const getCableList = (deptId) => get('/business/OdfCables/list', { deptId }) @@ -11,36 +10,11 @@ export const getFaultDetail = (id) => get(`/business/OdfCableFaults/${id}`) /** - * 新增故障(multipart/form-data,含图片上传) - * @param {FormData|object} formData - 包含故障信息和图片的 FormData + * 新增故障(JSON 提交,图片已上传至 COS) + * @param {object} data - 故障信息,含 imageUrls 数组 * @returns {Promise} */ -export function addFault(formData) { - return new Promise((resolve, reject) => { - const header = { - 'Authorization': `Bearer ${store.token}`, - 'Userid': store.userId, - 'Username': store.userName - } - uni.uploadFile({ - url: BASE_URL + '/business/OdfCableFaults/add', - files: formData.files || [], - formData: formData.data || {}, - header, - success(res) { - try { - const result = JSON.parse(res.data) - resolve({ code: result.code, msg: result.msg, data: result.data }) - } catch (e) { - reject({ code: -1, msg: '解析响应失败' }) - } - }, - fail(err) { - reject({ code: -1, msg: err.errMsg || '网络异常' }) - } - }) - }) -} +export const addFault = (data) => post('/business/OdfCableFaults/add', data) export const incrementFaultCount = (id) => post(`/business/OdfCableFaults/incrementFaultCount/${id}`) diff --git a/server/ZR.Admin.WebApi/Controllers/Business/CosUploadController.cs b/server/ZR.Admin.WebApi/Controllers/Business/CosUploadController.cs new file mode 100644 index 0000000..b876331 --- /dev/null +++ b/server/ZR.Admin.WebApi/Controllers/Business/CosUploadController.cs @@ -0,0 +1,90 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using COSXML; +using COSXML.Auth; +using COSXML.Model.Tag; + +namespace ZR.Admin.WebApi.Controllers.Business +{ + /// + /// COS 预签名上传 + /// + [Route("business/CosUpload")] + public class CosUploadController : BaseController + { + private readonly IConfiguration _config; + + public CosUploadController(IConfiguration config) + { + _config = config; + } + + /// + /// 获取 COS PUT 预签名 URL(前端直传) + /// + [HttpPost("presignUrl")] + public IActionResult GetPresignUrl([FromBody] CosPresignRequestDto dto) + { + var section = _config.GetSection("TencentCos"); + var appId = section["AppId"]; + var bucket = section["Bucket"]; + var region = section["Region"]; + var secretId = section["SecretId"]; + var secretKey = section["SecretKey"]; + var domainUrl = section["DomainUrl"]; + var expireMinutes = int.Parse(section["PresignExpireMinutes"] ?? "10"); + + int count = Math.Clamp(dto.Count, 1, 9); + string ext = string.IsNullOrWhiteSpace(dto.Ext) ? ".jpg" : dto.Ext; + + var cosConfig = new CosXmlConfig.Builder() + .SetRegion(region) + .Build(); + + var credentialProvider = new DefaultQCloudCredentialProvider( + secretId, secretKey, (long)expireMinutes * 60); + var cosXml = new CosXmlServer(cosConfig, credentialProvider); + + var results = new List(); + var dateDir = DateTime.Now.ToString("yyyyMMdd"); + + for (int i = 0; i < count; i++) + { + var cosKey = $"fault-images/{dateDir}/{Guid.NewGuid():N}{ext}"; + + var preSignatureStruct = new PreSignatureStruct + { + appid = appId, + bucket = bucket, + region = region, + key = cosKey, + httpMethod = "PUT", + isHttps = true, + signDurationSecond = expireMinutes * 60, + headers = null, + queryParameters = null + }; + + var presignUrl = cosXml.GenerateSignURL(preSignatureStruct); + var accessUrl = $"{domainUrl.TrimEnd('/')}/{cosKey}"; + + results.Add(new { cosKey, presignUrl, accessUrl }); + } + + return SUCCESS(results); + } + } + + public class CosPresignRequestDto + { + /// + /// 需要上传的文件数量 + /// + public int Count { get; set; } = 1; + + /// + /// 文件扩展名,如 .jpg + /// + public string Ext { get; set; } = ".jpg"; + } +} diff --git a/server/ZR.Admin.WebApi/Controllers/Business/OdfCableFaultsController.cs b/server/ZR.Admin.WebApi/Controllers/Business/OdfCableFaultsController.cs index d7a48fd..237dc85 100644 --- a/server/ZR.Admin.WebApi/Controllers/Business/OdfCableFaultsController.cs +++ b/server/ZR.Admin.WebApi/Controllers/Business/OdfCableFaultsController.cs @@ -49,13 +49,13 @@ namespace ZR.Admin.WebApi.Controllers.Business } /// - /// 新增故障(含图片上传,APP端调用) + /// 新增故障(图片已上传至COS,提交COS URL) /// /// [HttpPost("add")] [ActionPermissionFilter(Permission = "odfcablefaults:list")] [Log(Title = "干线故障", BusinessType = BusinessType.INSERT)] - public async Task Add([FromForm] OdfCableFaultAddDto dto) + public async Task Add([FromBody] OdfCableFaultAddDto dto) { dto.UserId = HttpContext.GetUId(); var response = await _OdfCableFaultsService.AddFault(dto); diff --git a/server/ZR.Admin.WebApi/ZR.Admin.WebApi.csproj b/server/ZR.Admin.WebApi/ZR.Admin.WebApi.csproj index 87fd9bc..082f81f 100644 --- a/server/ZR.Admin.WebApi/ZR.Admin.WebApi.csproj +++ b/server/ZR.Admin.WebApi/ZR.Admin.WebApi.csproj @@ -31,6 +31,7 @@ + diff --git a/server/ZR.Admin.WebApi/appsettings.json b/server/ZR.Admin.WebApi/appsettings.json index 0373b51..e4c8c11 100644 --- a/server/ZR.Admin.WebApi/appsettings.json +++ b/server/ZR.Admin.WebApi/appsettings.json @@ -54,6 +54,16 @@ "notAllowedExt": [ ".bat", ".exe", ".jar", ".js" ], "requestLimitSize": 50 //请求body大小限制 }, + // 腾讯云COS配置 + "TencentCos": { + "AppId": "1308826010", + "Bucket": "youdas-1308826010", + "Region": "ap-shanghai", + "SecretId": "AKIDNdjgTFyZ3UmvsdDbpsiNp690e6MPFrHV", + "SecretKey": "5xc6PVWM0SggYEguxyxkS5bvgNr8B0c2", + "DomainUrl": "https://youdas-1308826010.cos.ap-shanghai.myqcloud.com", + "PresignExpireMinutes": 10 + }, //阿里云存储配置 "ALIYUN_OSS": { "REGIONID": "https://oss-cn-shanghai.aliyuncs.com", //eg:cn-hangzhou diff --git a/server/ZR.Model/Business/Dto/OdfCableFaultsDto.cs b/server/ZR.Model/Business/Dto/OdfCableFaultsDto.cs index dfa72a8..a8e9a3e 100644 --- a/server/ZR.Model/Business/Dto/OdfCableFaultsDto.cs +++ b/server/ZR.Model/Business/Dto/OdfCableFaultsDto.cs @@ -1,4 +1,3 @@ -using Microsoft.AspNetCore.Http; using MiniExcelLibs.Attributes; namespace ZR.Model.Business.Dto @@ -50,7 +49,10 @@ namespace ZR.Model.Business.Dto public long? UserId { get; set; } - public IFormFile[] Images { get; set; } + /// + /// COS 图片访问 URL 列表(前端直传 COS 后传入) + /// + public string[] ImageUrls { get; set; } } /// diff --git a/server/ZR.Service/Business/OdfCableFaultsService.cs b/server/ZR.Service/Business/OdfCableFaultsService.cs index f058968..4565876 100644 --- a/server/ZR.Service/Business/OdfCableFaultsService.cs +++ b/server/ZR.Service/Business/OdfCableFaultsService.cs @@ -1,7 +1,5 @@ using Infrastructure; using Infrastructure.Attribute; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; using ZR.Model; using ZR.Model.Business; using ZR.Model.Business.Dto; @@ -156,7 +154,7 @@ namespace ZR.Service.Business } /// - /// 新增故障(含图片上传) + /// 新增故障(图片已上传至 COS,仅保存 URL) /// public async Task AddFault(OdfCableFaultAddDto dto) { @@ -170,7 +168,7 @@ namespace ZR.Service.Business } // 校验至少 1 张图片 - if (dto.Images == null || dto.Images.Length == 0) + if (dto.ImageUrls == null || dto.ImageUrls.Length == 0) { throw new CustomException("请至少上传一张图片"); } @@ -196,36 +194,10 @@ namespace ZR.Service.Business var faultEntity = Insertable(model).ExecuteReturnEntity(); int faultId = faultEntity.Id; - // 保存图片文件并插入图片记录 - IWebHostEnvironment webHostEnvironment = App.WebHostEnvironment; - string webRootPath = webHostEnvironment.WebRootPath; - string uploadDir = Path.Combine("uploads", "fault"); - string fullDir = Path.Combine(webRootPath, uploadDir); - - if (!Directory.Exists(fullDir)) + // 插入图片记录(COS URL) + foreach (var imageUrl in dto.ImageUrls) { - Directory.CreateDirectory(fullDir); - } - - foreach (var image in dto.Images) - { - string fileExt = Path.GetExtension(image.FileName); - string fileName = $"{DateTime.Now:yyyyMMdd}_{Guid.NewGuid():N}{fileExt}"; - string filePath = Path.Combine(fullDir, fileName); - - using (var stream = new FileStream(filePath, FileMode.Create)) - { - await image.CopyToAsync(stream); - } - - string imageUrl = $"/{uploadDir}/{fileName}".Replace("\\", "/"); - - // 拼接完整URL,前端可直接使用 - var request = App.HttpContext?.Request; - if (request != null) - { - imageUrl = $"{request.Scheme}://{request.Host}{imageUrl}"; - } + if (string.IsNullOrWhiteSpace(imageUrl)) continue; var imageRecord = new OdfCableFaultImages {