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 { ref, reactive, getCurrentInstance, nextTick } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import { addFault } from '@/services/trunk' import { addFault } from '@/services/trunk'
import { getPresignUrls, uploadToCos } from '@/services/cos'
import { addWatermark } from '@/utils/watermark' import { addWatermark } from '@/utils/watermark'
const statusBarHeight = uni.getSystemInfoSync().statusBarHeight || 0 const statusBarHeight = uni.getSystemInfoSync().statusBarHeight || 0
@ -311,10 +312,10 @@ async function handleSubmit() {
if (submitting.value) return if (submitting.value) return
submitting.value = true submitting.value = true
uni.showLoading({ title: '提交中...', mask: true }) uni.showLoading({ title: '处理图片中...', mask: true })
try { try {
// // 1.
const watermarkLines = [ const watermarkLines = [
`${form.faultTime} ${form.personnel}`, `${form.faultTime} ${form.personnel}`,
`故障原因:${form.faultReason || ''}`, `故障原因:${form.faultReason || ''}`,
@ -332,35 +333,39 @@ async function handleSubmit() {
} }
}) })
watermarkedPhotos.push(result) watermarkedPhotos.push(result)
// canvas
await nextTick() await nextTick()
} catch (err) { } catch (err) {
watermarkedPhotos.push(photo) watermarkedPhotos.push(photo)
} }
} }
// // 2. COS URL
const files = watermarkedPhotos.map((path, index) => ({ uni.showLoading({ title: '准备上传...', mask: true })
name: 'images', const presignList = await getPresignUrls(watermarkedPhotos.length, '.jpg')
uri: path
}))
const formData = { // 3. COS
files, const imageUrls = []
data: { for (let i = 0; i < watermarkedPhotos.length; i++) {
cableId: cableId.value, uni.showLoading({ title: `上传图片 ${i + 1}/${watermarkedPhotos.length}`, mask: true })
faultTime: form.faultTime, await uploadToCos(presignList[i].presignUrl, watermarkedPhotos[i])
personnel: form.personnel, imageUrls.push(presignList[i].accessUrl)
faultReason: form.faultReason,
mileage: form.mileage,
mileageCorrection: form.mileageCorrection,
latitude: String(form.latitude),
longitude: String(form.longitude),
remark: form.remark
}
} }
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) { if (res.code === 200) {
uni.showToast({ title: '提交成功', icon: 'success' }) uni.showToast({ title: '提交成功', icon: 'success' })
setTimeout(() => { setTimeout(() => {
@ -370,7 +375,8 @@ async function handleSubmit() {
uni.showToast({ title: res.msg || '提交失败', icon: 'none' }) uni.showToast({ title: res.msg || '提交失败', icon: 'none' })
} }
} catch (err) { } catch (err) {
uni.showToast({ title: '网络异常,请重试', icon: 'none' }) console.error('[fault-add] 提交失败:', err)
uni.showToast({ title: err.message || '网络异常,请重试', icon: 'none' })
} finally { } finally {
uni.hideLoading() uni.hideLoading()
submitting.value = false 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 { get, post } from './api'
import store from '@/store'
export const getCableList = (deptId) => export const getCableList = (deptId) =>
get('/business/OdfCables/list', { deptId }) get('/business/OdfCables/list', { deptId })
@ -11,36 +10,11 @@ export const getFaultDetail = (id) =>
get(`/business/OdfCableFaults/${id}`) get(`/business/OdfCableFaults/${id}`)
/** /**
* 新增故障multipart/form-data含图片上传 * 新增故障JSON 提交图片已上传至 COS
* @param {FormData|object} formData - 包含故障信息和图片的 FormData * @param {object} data - 故障信息 imageUrls 数组
* @returns {Promise} * @returns {Promise}
*/ */
export function addFault(formData) { export const addFault = (data) => post('/business/OdfCableFaults/add', data)
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 incrementFaultCount = (id) => export const incrementFaultCount = (id) =>
post(`/business/OdfCableFaults/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> /// <summary>
/// 新增故障(含图片上传APP端调用 /// 新增故障(图片已上传至COS提交COS URL
/// </summary> /// </summary>
/// <returns></returns> /// <returns></returns>
[HttpPost("add")] [HttpPost("add")]
[ActionPermissionFilter(Permission = "odfcablefaults:list")] [ActionPermissionFilter(Permission = "odfcablefaults:list")]
[Log(Title = "干线故障", BusinessType = BusinessType.INSERT)] [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(); dto.UserId = HttpContext.GetUId();
var response = await _OdfCableFaultsService.AddFault(dto); var response = await _OdfCableFaultsService.AddFault(dto);

View File

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

View File

@ -54,6 +54,16 @@
"notAllowedExt": [ ".bat", ".exe", ".jar", ".js" ], "notAllowedExt": [ ".bat", ".exe", ".jar", ".js" ],
"requestLimitSize": 50 //body "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": { "ALIYUN_OSS": {
"REGIONID": "https://oss-cn-shanghai.aliyuncs.com", //egcn-hangzhou "REGIONID": "https://oss-cn-shanghai.aliyuncs.com", //egcn-hangzhou

View File

@ -1,4 +1,3 @@
using Microsoft.AspNetCore.Http;
using MiniExcelLibs.Attributes; using MiniExcelLibs.Attributes;
namespace ZR.Model.Business.Dto namespace ZR.Model.Business.Dto
@ -50,7 +49,10 @@ namespace ZR.Model.Business.Dto
public long? UserId { get; set; } public long? UserId { get; set; }
public IFormFile[] Images { get; set; } /// <summary>
/// COS 图片访问 URL 列表(前端直传 COS 后传入)
/// </summary>
public string[] ImageUrls { get; set; }
} }
/// <summary> /// <summary>

View File

@ -1,7 +1,5 @@
using Infrastructure; using Infrastructure;
using Infrastructure.Attribute; using Infrastructure.Attribute;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using ZR.Model; using ZR.Model;
using ZR.Model.Business; using ZR.Model.Business;
using ZR.Model.Business.Dto; using ZR.Model.Business.Dto;
@ -156,7 +154,7 @@ namespace ZR.Service.Business
} }
/// <summary> /// <summary>
/// 新增故障(图片上传) /// 新增故障(图片上传至 COS仅保存 URL
/// </summary> /// </summary>
public async Task<int> AddFault(OdfCableFaultAddDto dto) public async Task<int> AddFault(OdfCableFaultAddDto dto)
{ {
@ -170,7 +168,7 @@ namespace ZR.Service.Business
} }
// 校验至少 1 张图片 // 校验至少 1 张图片
if (dto.Images == null || dto.Images.Length == 0) if (dto.ImageUrls == null || dto.ImageUrls.Length == 0)
{ {
throw new CustomException("请至少上传一张图片"); throw new CustomException("请至少上传一张图片");
} }
@ -196,36 +194,10 @@ namespace ZR.Service.Business
var faultEntity = Insertable(model).ExecuteReturnEntity(); var faultEntity = Insertable(model).ExecuteReturnEntity();
int faultId = faultEntity.Id; int faultId = faultEntity.Id;
// 保存图片文件并插入图片记录 // 插入图片记录COS URL
IWebHostEnvironment webHostEnvironment = App.WebHostEnvironment; foreach (var imageUrl in dto.ImageUrls)
string webRootPath = webHostEnvironment.WebRootPath;
string uploadDir = Path.Combine("uploads", "fault");
string fullDir = Path.Combine(webRootPath, uploadDir);
if (!Directory.Exists(fullDir))
{ {
Directory.CreateDirectory(fullDir); if (string.IsNullOrWhiteSpace(imageUrl)) continue;
}
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}";
}
var imageRecord = new OdfCableFaultImages var imageRecord = new OdfCableFaultImages
{ {