21
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
zpc 2026-04-05 16:51:24 +08:00
parent a32354f941
commit a6c639dd43
9 changed files with 238 additions and 90 deletions

View File

@ -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

View File

@ -0,0 +1,93 @@
import { post } from './api'
/**
* 从后端获取 COS 预签名上传 URL
* @param {number} count - 文件数量
* @param {string} ext - 文件扩展名
* @returns {Promise<Array<{cosKey, presignUrl, accessUrl}>>}
*/
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<void>}
*/
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

View File

@ -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}`)

View File

@ -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
{
/// <summary>
/// COS 预签名上传
/// </summary>
[Route("business/CosUpload")]
public class CosUploadController : BaseController
{
private readonly IConfiguration _config;
public CosUploadController(IConfiguration config)
{
_config = config;
}
/// <summary>
/// 获取 COS PUT 预签名 URL前端直传
/// </summary>
[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<object>();
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
{
/// <summary>
/// 需要上传的文件数量
/// </summary>
public int Count { get; set; } = 1;
/// <summary>
/// 文件扩展名,如 .jpg
/// </summary>
public string Ext { get; set; } = ".jpg";
}
}

View File

@ -49,13 +49,13 @@ namespace ZR.Admin.WebApi.Controllers.Business
}
/// <summary>
/// 新增故障(含图片上传APP端调用
/// 新增故障(图片已上传至COS提交COS URL
/// </summary>
/// <returns></returns>
[HttpPost("add")]
[ActionPermissionFilter(Permission = "odfcablefaults:list")]
[Log(Title = "干线故障", BusinessType = BusinessType.INSERT)]
public async Task<IActionResult> Add([FromForm] OdfCableFaultAddDto dto)
public async Task<IActionResult> Add([FromBody] OdfCableFaultAddDto dto)
{
dto.UserId = HttpContext.GetUId();
var response = await _OdfCableFaultsService.AddFault(dto);

View File

@ -31,6 +31,7 @@
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="7.0.12" />
<PackageReference Include="NLog.Web.AspNetCore" Version="5.4.0" />
<PackageReference Include="Mapster" Version="7.4.0" />
<PackageReference Include="Tencent.QCloud.Cos.Sdk" Version="5.4.44" />
</ItemGroup>
<ItemGroup>

View File

@ -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", //egcn-hangzhou

View File

@ -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; }
/// <summary>
/// COS 图片访问 URL 列表(前端直传 COS 后传入)
/// </summary>
public string[] ImageUrls { get; set; }
}
/// <summary>

View File

@ -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
}
/// <summary>
/// 新增故障(图片上传)
/// 新增故障(图片上传至 COS仅保存 URL
/// </summary>
public async Task<int> 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
{