feat:图片上传修改
This commit is contained in:
1 parent
24abae4e69
commit
342807749b
14 files changed
+455
-61
No files matched your search
@@ -25,16 +25,16 @@ VITE_OAUTH2_APP_CLIENT='mini:mini'
|
||||
# VITE_ZZD_CORP_ID=
|
||||
|
||||
# --------------------------- 开发环境 api地址 ----------------------------------
|
||||
# VITE_APP_DEV_SERVICE = https://www.pjzhhq.cn/api # 域名线上
|
||||
VITE_APP_DEV_SERVICE = https://www.pjzhhq.cn/api # 域名线上
|
||||
#VITE_APP_DEV_SERVICE = http://59.202.170.152:81/api # 信创线上
|
||||
VITE_APP_DEV_SERVICE = https://dzc-test-1.fy.takin.cc # 邓-本地
|
||||
# VITE_APP_DEV_SERVICE = https://dzc-test-1.fy.takin.cc # 邓-本地
|
||||
# VITE_APP_DEV_SERVICE = http://127.0.0.1:9999 # 邓-本地
|
||||
# VITE_APP_DEV_SERVICE = http://123.157.250.162:8089/api # 测试线上
|
||||
|
||||
# --------------------------- 开发环境 图片访问基础地址 ---------------------------
|
||||
# VITE_IMAGE_DEV_PATH = https://www.pjzhhq.cn/api # 域名线上
|
||||
VITE_IMAGE_DEV_PATH = https://www.pjzhhq.cn/api # 域名线上
|
||||
#VITE_IMAGE_DEV_PATH = http://59.202.170.152:81/api # 信创线上
|
||||
VITE_IMAGE_DEV_PATH = https://dzc-test-1.fy.takin.cc # 邓-本地
|
||||
# VITE_IMAGE_DEV_PATH = https://dzc-test-1.fy.takin.cc # 邓-本地
|
||||
# VITE_IMAGE_DEV_PATH = http://127.0.0.1:9999
|
||||
# VITE_IMAGE_DEV_PATH = http://123.157.250.162:8089/api # 测试线上
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { hooks, router } from "@@/index"
|
||||
import { faceApi } from "@@/api"
|
||||
|
||||
import { filePath } from "@@/utils/file"
|
||||
import { uploadedFileUrl } from "@@/utils/file-upload"
|
||||
|
||||
const state = reactive<any>({
|
||||
detailType: "add",
|
||||
@@ -45,13 +46,8 @@ const getDetail = async () => {
|
||||
}
|
||||
|
||||
const beforeComplete = (uploadFile: any) => {
|
||||
if (JSON.parse(uploadFile.data).code == 0) {
|
||||
uploadFileUrl.value = JSON.parse(uploadFile.data).data.url
|
||||
return true
|
||||
} else {
|
||||
uploadFileUrl.value = ""
|
||||
return false
|
||||
}
|
||||
uploadFileUrl.value = uploadedFileUrl({ response: uploadFile.data })
|
||||
return !!uploadFileUrl.value
|
||||
}
|
||||
|
||||
const applyFormRef = ref<any>(null)
|
||||
@@ -59,6 +55,9 @@ const doSubmit = async () => {
|
||||
applyFormRef.value.submit()
|
||||
const { isPass } = applyFormRef.value.validate()
|
||||
if (!isPass || !uploadFileUrl.value) return
|
||||
if (applyForm.faceImg.some((item: any) => item?.status !== undefined && item.status !== 2)) {
|
||||
return uni.showToast({ title: "请先完成人脸图片上传", icon: "none" })
|
||||
}
|
||||
try {
|
||||
uni.showLoading({ title: "提交中...", mask: false })
|
||||
const { data, code }: any =
|
||||
|
||||
@@ -95,6 +95,7 @@ import feedbackApi from '@/sheep/api/feedback'
|
||||
import { onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { filePath } from '@/sheep/utils/file'
|
||||
import { useDefaultHeader } from '@/sheep/hooks/use-token'
|
||||
import { uploadedFileUrl } from '@/sheep/utils/file-upload'
|
||||
|
||||
const typeOptions = ref<{ value: string; label: string }[]>([])
|
||||
const typeOptionsLoaded = ref(false)
|
||||
@@ -180,20 +181,13 @@ async function submitFeedback() {
|
||||
uni.showToast({ title: '请填写完整信息', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (uploadList.value.some(item => !uploadedFileUrl(item))) {
|
||||
return uni.showToast({ title: '请完成图片上传或移除失败图片', icon: 'none' })
|
||||
}
|
||||
|
||||
try {
|
||||
uni.showLoading({ title: '提交中...' })
|
||||
let picUrlArr: any[] = []
|
||||
uploadList.value.forEach(item => {
|
||||
if (item.response) {
|
||||
try {
|
||||
const res = JSON.parse(item.response)
|
||||
if (res.code === 0 && res.data && res.data.url) {
|
||||
picUrlArr.push(res.data.url)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
})
|
||||
const picUrlArr = uploadList.value.map(uploadedFileUrl)
|
||||
const data = {
|
||||
title: title.value.trim(),
|
||||
type: typeOptions.value[typeIndex.value].value,
|
||||
|
||||
@@ -105,6 +105,7 @@ import { onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import maintenanceApi from '@/sheep/api/maintenance'
|
||||
import { hooks } from '@@/index'
|
||||
import { filePath } from '@/sheep/utils/file'
|
||||
import { uploadedFileUrl } from '@/sheep/utils/file-upload'
|
||||
|
||||
const typeOptions = ref<any[]>([])
|
||||
const typeOptionsLoaded = ref(false)
|
||||
@@ -240,20 +241,10 @@ async function submitRepair() {
|
||||
return
|
||||
}
|
||||
|
||||
// 收集所有上传成功图片的 url
|
||||
let picUrl: string[] = []
|
||||
if (uploadFileUrl.value.length > 0) {
|
||||
uploadFileUrl.value.forEach(item => {
|
||||
if (item.response) {
|
||||
try {
|
||||
const res = JSON.parse(item.response)
|
||||
if (res.code === 0 && res.data && res.data.url) {
|
||||
picUrl.push(res.data.url)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
})
|
||||
if (uploadFileUrl.value.some(item => !uploadedFileUrl(item))) {
|
||||
return uni.showToast({ title: '请完成图片上传或移除失败图片', icon: 'none' })
|
||||
}
|
||||
const picUrl = uploadFileUrl.value.map(uploadedFileUrl)
|
||||
|
||||
try {
|
||||
uni.showLoading({ title: '提交中...' })
|
||||
|
||||
@@ -79,6 +79,7 @@ import NodeTree from "@/sheep/components/node-tree.vue"
|
||||
import { useCategaryManage } from "./use-categary-manage"
|
||||
import { hooks } from "@@/index"
|
||||
import { filePath } from "@/sheep/utils/file"
|
||||
import { uploadedFileUrl } from "@/sheep/utils/file-upload"
|
||||
|
||||
const categaryFormDrawer = ref(false)
|
||||
const form = reactive<any>({
|
||||
@@ -130,11 +131,13 @@ const editCategary = (item) => {
|
||||
const saveCategary = async () => {
|
||||
if (!form.name) return uni.showToast({ title: "请填写分类名称", icon: "none" })
|
||||
if (selectedNodeId.value === form.id) return uni.showToast({ title: "父级分类不能是自己", icon: "none" })
|
||||
const picUrl = form.picUrl?.length ? uploadedFileUrl(form.picUrl[0]) : ""
|
||||
if (form.picUrl?.length && !picUrl) return uni.showToast({ title: "请完成分类图片上传或移除失败图片", icon: "none" })
|
||||
try {
|
||||
const params = {
|
||||
...form,
|
||||
parentId: selectedNodeId.value,
|
||||
picUrl: form.picUrl?.length ? JSON.parse(form.picUrl[0].response).data.url : "",
|
||||
picUrl,
|
||||
}
|
||||
uni.showLoading({ title: "保存中..." })
|
||||
const { code, msg } = await (form.id ? prosperityShopApi.editCategary(params) : prosperityShopApi.addCategary(params))
|
||||
|
||||
@@ -192,6 +192,7 @@ import { prosperityShopApi } from "@/sheep/api"
|
||||
import ShopCategaryPicker from "@/sheep/components/shop-categary-picker.vue"
|
||||
import { onLoad } from "@dcloudio/uni-app"
|
||||
import { filePath } from "@/sheep/utils/file"
|
||||
import { uploadedFileUrl } from "@/sheep/utils/file-upload"
|
||||
import { displayPrice, storePrice } from "@/sheep/utils/formatMoney"
|
||||
|
||||
const form = reactive<any>({
|
||||
@@ -322,17 +323,9 @@ const addSkuListItem = () => {
|
||||
|
||||
// 保存
|
||||
const onsubmit = async (evt: TM.FORM_SUBMIT_RESULT) => {
|
||||
// console.log("校验结果:", evt)
|
||||
console.log(form.carouselImages)
|
||||
|
||||
const submitData = {
|
||||
...form,
|
||||
coverImage: JSON.parse(form.coverImage[0].response).data.url,
|
||||
// carouselImages: form.carouselImages.map((item: any) => JSON.parse(item.response).data.url).join(","),
|
||||
}
|
||||
console.log("提交的数据:", submitData)
|
||||
|
||||
if (!evt.isPass) return
|
||||
const coverImage = uploadedFileUrl(form.coverImage[0])
|
||||
if (!coverImage) return uni.showToast({ title: "请先完成商品主图上传", icon: "none" })
|
||||
|
||||
if (form.priceType == 1) {
|
||||
for (let i = 0; i < form.skuList.length; i++) {
|
||||
@@ -358,7 +351,7 @@ const onsubmit = async (evt: TM.FORM_SUBMIT_RESULT) => {
|
||||
price: storePrice(item.price),
|
||||
}
|
||||
}),
|
||||
coverImage: JSON.parse(form.coverImage[0].response).data.url,
|
||||
coverImage,
|
||||
// carouselImages: form.carouselImages.map((item: any) => JSON.parse(item.response).data.url).join(","),
|
||||
}
|
||||
uni.showLoading({ title: "提交中..." })
|
||||
|
||||
@@ -7,6 +7,7 @@ import { hooks, router } from "@@/index"
|
||||
import { orderApi, refundApi, commonApi } from "@@/api"
|
||||
|
||||
import { filePath } from "@@/utils/file"
|
||||
import { uploadedFileUrl } from "@@/utils/file-upload"
|
||||
|
||||
const data = {
|
||||
payType: [
|
||||
@@ -20,10 +21,10 @@ const state = reactive<any>({
|
||||
shopCarList: [],
|
||||
setting: {},
|
||||
selectList: [],
|
||||
header: "",
|
||||
header: {},
|
||||
})
|
||||
const uploadApplyPicUrls = ref<Array<Record<string, any> | string>>([]) // 补充凭证图片
|
||||
const applyPicUrls = computed(() => uploadApplyPicUrls.value.map((item: any) => JSON.parse(item.response).data.url).join(","))
|
||||
const applyPicUrls = computed(() => uploadApplyPicUrls.value.map(uploadedFileUrl).filter(Boolean).join(","))
|
||||
|
||||
const applyForm = reactive({
|
||||
way: data.payType[0].type,
|
||||
@@ -94,6 +95,7 @@ const radioChange = (e: any) => {
|
||||
const doSubmit = async () => {
|
||||
if (isEmpty(applyForm.reasonStr)) return uni.showToast({ title: "请选择申请原因", icon: "none" })
|
||||
if (!applyPicUrls.value.length) return uni.showToast({ title: "请补充凭证图片", icon: "none" })
|
||||
if (uploadApplyPicUrls.value.some(item => !uploadedFileUrl(item))) return uni.showToast({ title: "请完成图片上传或移除失败图片", icon: "none" })
|
||||
if (isEmpty(applyForm.remark.trim())) return uni.showToast({ title: "请填写相关描述", icon: "none" })
|
||||
const _pa = {
|
||||
way: applyForm.way,
|
||||
@@ -125,7 +127,8 @@ const doSubmit = async () => {
|
||||
<view class="flex-row items-start justify-between" v-for="(v, i) in state.shopCarList" :key="i"
|
||||
@tap.stop="router.jump('/pages/canteen/product-detail', { id: v.productId })">
|
||||
<view class="flex-row items-center">
|
||||
<image class="w-160rpx h-160rpx rounded-8rpx" :src="$fileUrl(filePath(v.productPic))" mode="aspectFill" @error="$fileRetry(filePath(v.productPic))" />
|
||||
<image class="w-160rpx h-160rpx rounded-8rpx" :src="$fileUrl(filePath(v.productPic))"
|
||||
mode="aspectFill" @error="$fileRetry(filePath(v.productPic))" />
|
||||
<view class="flex-col m-l-15rpx second-text-26">
|
||||
<view class="sub-text-28">{{ v.productName }}</view>
|
||||
<view class="m-t-5rpx">¥ {{ hooks.useMoney(v.productPrice) }}</view>
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { hooks } from '@@/index'
|
||||
import { uploadFile, uploadedFileUrl } from '@/sheep/utils/file-upload'
|
||||
|
||||
interface Props {
|
||||
modelValue?: string
|
||||
@@ -291,7 +292,7 @@ const uploadImage = (filePath: string) => {
|
||||
// 使用项目中的hooks获取请求头
|
||||
const headers = hooks.useDefaultHeader()
|
||||
|
||||
uni.uploadFile({
|
||||
uploadFile({
|
||||
url: '/api/admin/sys-file/upload',
|
||||
filePath: filePath,
|
||||
name: 'file',
|
||||
@@ -301,7 +302,7 @@ const uploadImage = (filePath: string) => {
|
||||
const data = JSON.parse(res.data)
|
||||
if (data.code === 0 && data.data && data.data.url) {
|
||||
// 修复URL格式问题,确保fileName参数格式正确
|
||||
let imageUrl = data.data.url
|
||||
let imageUrl = uploadedFileUrl({ response: res.data })
|
||||
// 如果URL中包含fileName参数但没有等号,则修复它
|
||||
if (imageUrl.includes('fileName ') && !imageUrl.includes('fileName=')) {
|
||||
imageUrl = imageUrl.replace('fileName ', 'fileName=')
|
||||
@@ -324,7 +325,7 @@ const uploadImage = (filePath: string) => {
|
||||
}
|
||||
},
|
||||
fail: (error) => {
|
||||
uni.showToast({ title: '上传失败', icon: 'none' })
|
||||
uni.showToast({ title: error.errMsg || '上传失败', icon: 'none' })
|
||||
},
|
||||
complete: () => {
|
||||
uni.hideLoading()
|
||||
|
||||
@@ -4,7 +4,7 @@ export const FILE_ACCESS_FIELDS = ['appId', 'kid', 'ts', 'sign']
|
||||
const FILE_PATHS = ['/sys-file/oss/file', '/admin/sys-file/oss/file', '/api/admin/sys-file/oss/file']
|
||||
export interface FileAccessOptions {
|
||||
appId: string; kid: string; key: string; baseUrl: () => string
|
||||
allowedOrigins?: string[]; now?: () => number
|
||||
allowedOrigins?: string[]; now?: () => number; uploadBaseUrl?: () => string
|
||||
}
|
||||
const decode = (s: string) => decodeURIComponent(s.replace(/\+/g, ' '))
|
||||
const encode = (s: string) => encodeURIComponent(s).replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase())
|
||||
@@ -35,7 +35,8 @@ export function createFileAccess(options: FileAccessOptions) {
|
||||
const cache = new Map<string, { url: string; ts: number }>()
|
||||
let uploadSequence = 0
|
||||
const base = () => (options.baseUrl() || '').replace(/\/+$/, '')
|
||||
const allowed = () => [origin(base()), ...(options.allowedOrigins || []).map(origin)].filter(Boolean)
|
||||
const uploadBase = () => (options.uploadBaseUrl?.() || base()).replace(/\/+$/, '')
|
||||
const allowed = () => [origin(base()), origin(uploadBase()), ...(options.allowedOrigins || []).map(origin)].filter(Boolean)
|
||||
function resolve(source?: string | null): string {
|
||||
const raw = typeof source === 'string' ? source.trim() : ''
|
||||
if (!raw || /^(?:data:|blob:|file:|wxfile:|content:|\/static\/|static\/|\/assets\/|\/src\/|@\/|#)/i.test(raw)) return raw
|
||||
@@ -97,11 +98,22 @@ export function createFileAccess(options: FileAccessOptions) {
|
||||
const query = head.slice(at + 1).split('&').filter(p => !FILE_ACCESS_FIELDS.includes(decode(p.split('=')[0])))
|
||||
return head.slice(0, at) + (query.length ? '?' + query.join('&') : '') + hash
|
||||
}
|
||||
function resolveUpload(source: string): string {
|
||||
const raw = typeof source === 'string' ? source.trim() : ''
|
||||
if (!raw || /^[a-z][a-z0-9+.-]*:/i.test(raw)) return raw
|
||||
if (raw.startsWith('//')) return (uploadBase().startsWith('http:') ? 'http:' : 'https:') + raw
|
||||
const path = raw.startsWith('/') ? raw : '/' + raw
|
||||
const endpoint = /^\/(?:api\/)?(?:admin\/)?sys-file\/(upload|inner-upload|qn\/upload)(?=[?#]|$)/.exec(path)
|
||||
if (endpoint) return uploadBase() + '/admin/sys-file/' + endpoint[1] + path.slice(endpoint[0].length)
|
||||
const prefix = uploadBase().replace(/^https?:\/\/[^/?#]+/i, '')
|
||||
return prefix && (path.startsWith(prefix + '/sys-file/') || path.startsWith(prefix + '/admin/sys-file/'))
|
||||
? (origin(uploadBase()) || '') + path : raw
|
||||
}
|
||||
function uploadTarget(source: string): string | null {
|
||||
const resolved = resolve(source), host = origin(resolved)
|
||||
const resolved = resolveUpload(source), host = origin(resolved)
|
||||
if ((host && !allowed().includes(host)) || (/^[a-z][a-z0-9+.-]*:/i.test(resolved) && !host)) return null
|
||||
const path = (host ? resolved.replace(/^https?:\/\/[^/?#]+/i, '') : resolved).split(/[?#]/)[0]
|
||||
const prefix = base().replace(/^https?:\/\/[^/?#]+/i, '')
|
||||
const prefix = uploadBase().replace(/^https?:\/\/[^/?#]+/i, '')
|
||||
for (const [target, suffix] of [['upload', 'upload'], ['inner-upload', 'inner-upload'], ['qn-upload', 'qn/upload']]) {
|
||||
if (['/sys-file/', '/admin/sys-file/', '/api/sys-file/', '/api/admin/sys-file/', prefix + '/admin/sys-file/', prefix + '/sys-file/']
|
||||
.some(start => path === start + suffix)) return target
|
||||
@@ -131,7 +143,7 @@ export function createFileAccess(options: FileAccessOptions) {
|
||||
const parsed = parse(source)
|
||||
if (parsed) cache.delete(parsed.unsigned)
|
||||
}
|
||||
return { uploadHeaders, uploadTarget, url, resolve, strip, protected: (s?: string | null) => !!parse(s), synchronize, invalidate, clear: () => cache.clear(), ttl: () => ttl }
|
||||
return { uploadHeaders, uploadTarget, resolveUpload, url, resolve, strip, protected: (s?: string | null) => !!parse(s), synchronize, invalidate, clear: () => cache.clear(), ttl: () => ttl }
|
||||
}
|
||||
|
||||
const unescapeAttribute = (s: string) => s.replace(/&/gi, '&').replace(/"/gi, '"').replace(/&#(?:39|x27);/gi, "'").replace(/&#(x[0-9a-f]+|\d+);/gi, (_, n) => { const code = n[0].toLowerCase() === 'x' ? parseInt(n.slice(1), 16) : Number(n); return code <= 0x10ffff ? String.fromCodePoint(code) : ''; })
|
||||
|
||||
@@ -8,6 +8,7 @@ const client = createFileAccess({
|
||||
kid: import.meta.env.VITE_FILE_ACCESS_KEY_ID,
|
||||
key: import.meta.env.VITE_FILE_ACCESS_KEY,
|
||||
baseUrl: () => isDevEnv() ? (import.meta.env.VITE_IMAGE_DEV_PATH || import.meta.env.VITE_APP_DEV_SERVICE) : import.meta.env.VITE_APP_PROD_IMAGE,
|
||||
uploadBaseUrl: () => isDevEnv() ? (import.meta.env.VITE_API_URL || '/api') : import.meta.env.VITE_APP_PROD_SERVICE,
|
||||
allowedOrigins: [import.meta.env.VITE_IMAGE_DEV_PATH, import.meta.env.VITE_APP_DEV_SERVICE, import.meta.env.VITE_APP_PROD_IMAGE, import.meta.env.VITE_APP_PROD_SERVICE, ...(import.meta.env.VITE_FILE_ACCESS_ALLOWED_ORIGINS || '').split(',')],
|
||||
})
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
@@ -24,6 +25,17 @@ export const fileUrls = (sources: any) => Array.isArray(sources) ? sources.map(a
|
||||
export const originalFileUrl = (source?: string | null) => client.strip(source)
|
||||
export const resolveFileUrl = (source?: string | null) => client.resolve(source)
|
||||
export const fileUploadHeaders = (source: string) => client.uploadHeaders(source)
|
||||
export const isFileUpload = (source: string) => !!client.uploadTarget(source)
|
||||
/** Resolve the API host and renew credentials at dispatch, never in page state. */
|
||||
export function prepareFileUpload<T extends { url: string; header?: any }>(options: T): T {
|
||||
if (!isFileUpload(options.url)) return options
|
||||
const url = client.resolveUpload(options.url)
|
||||
const header = { ...(options.header || {}) }
|
||||
for (const name of Object.keys(header)) {
|
||||
if (/^x-file-/i.test(name) || name.toLowerCase() === 'content-type') delete header[name]
|
||||
}
|
||||
return { ...options, url, header: { ...header, ...fileUploadHeaders(url) } }
|
||||
}
|
||||
export const fileHtml = (html: string) => rewriteFileHtml(html, accessFileUrl)
|
||||
export const stripFileHtml = (html: string) => rewriteFileHtml(html, originalFileUrl)
|
||||
export const fileNodes = (nodes: any) => rewriteFileNodes(nodes, accessFileUrl)
|
||||
@@ -74,7 +86,7 @@ export function installFileAccess(app: any) {
|
||||
if (typeof uni.addInterceptor === 'function') {
|
||||
uni.addInterceptor('uploadFile', {
|
||||
invoke(options: any) {
|
||||
options.header = { ...(options.header || {}), ...fileUploadHeaders(options.url) }
|
||||
Object.assign(options, prepareFileUpload(options))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useDefaultHeader } from '../hooks/use-token'
|
||||
import { isFileUpload, originalFileUrl, prepareFileUpload, refreshFileAccess } from './file-access'
|
||||
|
||||
function responseBody(data: unknown): any {
|
||||
try { return typeof data === 'string' ? JSON.parse(data) : data }
|
||||
catch { return undefined }
|
||||
}
|
||||
|
||||
/** Read only completed uploads; keep stable file references in submitted business data. */
|
||||
export function uploadedFileUrl(item: any): string {
|
||||
if (!item || (item.status !== undefined && item.status !== 2)) return ''
|
||||
const body = responseBody(item.response)
|
||||
if (!body || (body.code !== undefined && Number(body.code) !== 0)) return ''
|
||||
return typeof body.data?.url === 'string' ? originalFileUrl(body.data.url) : ''
|
||||
}
|
||||
|
||||
/** Photo/video components and rich text share the same per-file upload flow. */
|
||||
export async function uploadFile(options: UniApp.UploadFileOption) {
|
||||
if (!isFileUpload(options.url)) return uni.uploadFile(options)
|
||||
await refreshFileAccess()
|
||||
|
||||
const fail = (error: UniApp.GeneralCallbackResult & { statusCode?: number; reason?: string }) => {
|
||||
console.warn('[file-upload] ' + JSON.stringify({ statusCode: error.statusCode, reason: error.reason, errMsg: error.errMsg }))
|
||||
options.fail?.(error)
|
||||
options.complete?.(error)
|
||||
}
|
||||
const run = (retryClock: boolean): UniApp.UploadTask | undefined => {
|
||||
let request: UniApp.UploadFileOption
|
||||
try {
|
||||
const header = { ...(options.header || {}) }
|
||||
// A page may have cached its headers before login/Token renewal.
|
||||
for (const name of Object.keys(header)) if (name.toLowerCase() === 'authorization') delete header[name]
|
||||
for (const [name, value] of Object.entries(useDefaultHeader())) {
|
||||
if (!Object.keys(header).some(key => key.toLowerCase() === name.toLowerCase())) header[name] = value
|
||||
}
|
||||
request = prepareFileUpload({ ...options, header })
|
||||
} catch {
|
||||
fail({ errMsg: '上传凭证生成失败,请检查文件访问配置' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
return uni.uploadFile({
|
||||
...request,
|
||||
success: async response => {
|
||||
const body = responseBody(response.data)
|
||||
// These rejections happen before storage. Never retry a timeout or a completed upload.
|
||||
if (retryClock && response.statusCode === 403 && ['upload_clock_skew', 'upload_signature_expired'].includes(body?.reason)) {
|
||||
await refreshFileAccess(true)
|
||||
run(false)
|
||||
return
|
||||
}
|
||||
if (response.statusCode !== 200 || Number(body?.code) !== 0 || typeof body?.data?.url !== 'string' || !body.data.url) {
|
||||
fail({ ...response, reason: body?.reason, errMsg: body?.msg || `上传失败(${response.statusCode})` })
|
||||
return
|
||||
}
|
||||
options.success?.(response)
|
||||
options.complete?.({ ...response, errMsg: response.errMsg || 'uploadFile:ok' })
|
||||
},
|
||||
fail,
|
||||
complete: undefined,
|
||||
})
|
||||
} catch {
|
||||
fail({ errMsg: '无法发起上传,请重试' })
|
||||
}
|
||||
}
|
||||
return run(true)
|
||||
}
|
||||
@@ -40,6 +40,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { uploadFile } from '@/sheep/utils/file-upload'
|
||||
import { previewFiles } from '@/sheep/utils/file-access'
|
||||
|
||||
import { type PropType, computed, onMounted, ref, shallowRef, watch } from 'vue'
|
||||
@@ -318,6 +319,10 @@ const covertFileType = (files: CHOOSEFILE_FILES[]) => {
|
||||
*/
|
||||
const covertFileTypeByModelvalue = (files: Array<Record<string, any> | string>) => {
|
||||
let flist = files.map((item): TM.TMUPLOAD_PHOTO_INFO => {
|
||||
// Keep the result/status when the parent restores an uploader model.
|
||||
if (typeof item === 'object' && typeof item.status === 'number' && 'response' in item) {
|
||||
return { ...item, statusText: STATUS_TEXT.get(item.status) || "" } as TM.TMUPLOAD_PHOTO_INFO;
|
||||
}
|
||||
let status = TMUPLOAD_PHOTO_STATUS.UPLOAD_SUCCESS;
|
||||
if (typeof item == 'string') {
|
||||
return {
|
||||
@@ -428,6 +433,7 @@ const choose = () => {
|
||||
// 结束。
|
||||
list.value = [...list.value, ...covertFileType(resultfiles)]
|
||||
emit('change', list.value.slice(0))
|
||||
emit('update:modelValue', list.value.slice(0))
|
||||
if (props.autoStart) {
|
||||
startUpload();
|
||||
}
|
||||
@@ -439,6 +445,7 @@ const choose = () => {
|
||||
// 结束。
|
||||
list.value = [...list.value, ...covertFileType(resultfiles)]
|
||||
emit('change', list.value.slice(0))
|
||||
emit('update:modelValue', list.value.slice(0))
|
||||
if (props.autoStart) {
|
||||
startUpload();
|
||||
}
|
||||
@@ -489,7 +496,9 @@ const upload = () => {
|
||||
upload();
|
||||
return;
|
||||
}
|
||||
uni.uploadFile({
|
||||
item.status = TMUPLOAD_PHOTO_STATUS.UPLOADING;
|
||||
item.statusText = STATUS_TEXT.get(item.status) || "";
|
||||
uploadFile({
|
||||
url: props.url,
|
||||
filePath: item.path,
|
||||
name: props.name,
|
||||
@@ -516,6 +525,7 @@ const upload = () => {
|
||||
upload();
|
||||
},
|
||||
fail: (res) => {
|
||||
uni.showToast({ title: res.errMsg || '上传失败', icon: 'none' });
|
||||
item.status = TMUPLOAD_PHOTO_STATUS.UPLOAD_FAILED;
|
||||
item.response = res.errMsg;
|
||||
item.statusText = STATUS_TEXT.get(item.status) || ""
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { uploadFile } from '@/sheep/utils/file-upload'
|
||||
import { PropType, computed, onMounted, ref, shallowRef, watch } from 'vue'
|
||||
import { covetUniNumber } from '../../libs/tool';
|
||||
import { TMUPLOAD_PHOTO_STATUS } from '../../interface';
|
||||
@@ -315,6 +316,10 @@ const covertFileType = (files: CHOOSEFILE_FILES[]) => {
|
||||
*/
|
||||
const covertFileTypeByModelvalue = (files: Array<Record<string, any> | string>) => {
|
||||
let flist = files.map((item): TM.TMUPLOAD_PHOTO_INFO => {
|
||||
// Keep the result/status when the parent restores an uploader model.
|
||||
if (typeof item === 'object' && typeof item.status === 'number' && 'response' in item) {
|
||||
return { ...item, statusText: STATUS_TEXT.get(item.status) || "" } as TM.TMUPLOAD_PHOTO_INFO;
|
||||
}
|
||||
let status = TMUPLOAD_PHOTO_STATUS.UPLOAD_SUCCESS;
|
||||
if (typeof item == 'string') {
|
||||
return {
|
||||
@@ -381,6 +386,7 @@ const choose = () => {
|
||||
if (resultfiles.length == 0) return;
|
||||
list.value = [...list.value, ...covertFileType(resultfiles)]
|
||||
emit('change', list.value.slice(0))
|
||||
emit('update:modelValue', list.value.slice(0))
|
||||
if (props.autoStart) {
|
||||
startUpload();
|
||||
}
|
||||
@@ -413,7 +419,9 @@ const upload = () => {
|
||||
upload();
|
||||
return;
|
||||
}
|
||||
uni.uploadFile({
|
||||
item.status = TMUPLOAD_PHOTO_STATUS.UPLOADING;
|
||||
item.statusText = STATUS_TEXT.get(item.status) || "";
|
||||
uploadFile({
|
||||
url: props.url,
|
||||
filePath: item.path,
|
||||
name: props.name,
|
||||
@@ -440,6 +448,7 @@ const upload = () => {
|
||||
upload();
|
||||
},
|
||||
fail: (res) => {
|
||||
uni.showToast({ title: res.errMsg || '上传失败', icon: 'none' });
|
||||
item.status = TMUPLOAD_PHOTO_STATUS.UPLOAD_FAILED;
|
||||
item.response = res.errMsg;
|
||||
item.statusText = STATUS_TEXT.get(item.status) || ""
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const { createHmac } = require('node:crypto')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const Module = require('node:module')
|
||||
const ts = require('typescript')
|
||||
const vue = require('vue')
|
||||
const { parse } = require('vue/compiler-sfc')
|
||||
const root = path.resolve(__dirname, '..')
|
||||
const key = JSON.parse(fs.readFileSync(path.join(root, 'test-vectors/file-upload.json'), 'utf8'))[0].key
|
||||
const rawUrl = '/admin/sys-file/oss/file?fileName=photo.png'
|
||||
const ok = { statusCode: 200, data: JSON.stringify({ code: 0, data: { url: rawUrl } }) }
|
||||
const settle = async () => { await new Promise(setImmediate); await vue.nextTick() }
|
||||
const compile = source => ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText
|
||||
|
||||
function runtime({ dev = true, apiBase = 'https://api.example/gateway/api', signingKey = key } = {}) {
|
||||
const oldUni = global.uni, oldNow = Date.now, oldWarn = console.warn
|
||||
const state = { now: 1793232000000, offset: 7200000, clockCalls: 0, offline: false,
|
||||
token: { accessToken: 'first-token' }, intercept: undefined }
|
||||
const requests = [], replies = [], warnings = [], toasts = [], files = []
|
||||
Date.now = () => state.now
|
||||
console.warn = (...args) => warnings.push(args)
|
||||
global.uni = {
|
||||
request(options) {
|
||||
state.clockCalls++
|
||||
queueMicrotask(() => state.offline ? options.fail({ errMsg: 'offline' }) : options.success({
|
||||
statusCode: 200, data: { code: 0, data: { serverTime: String(state.now + state.offset), ttlSeconds: '1800' } },
|
||||
}))
|
||||
},
|
||||
addInterceptor(name, value) { assert.equal(name, 'uploadFile'); state.intercept = value },
|
||||
onNetworkStatusChange() {}, getStorageSync() { return state.token },
|
||||
uploadFile(options) {
|
||||
state.intercept?.invoke(options)
|
||||
requests.push(options)
|
||||
const reply = replies.shift() || ok
|
||||
if (reply.throw) throw new Error('transport could not start')
|
||||
queueMicrotask(() => {
|
||||
if (reply.fail) options.fail?.(reply.fail)
|
||||
else options.success?.(reply)
|
||||
options.complete?.(reply.fail || reply)
|
||||
})
|
||||
return { abort() {} }
|
||||
},
|
||||
chooseImage(options) { options.success({ tempFiles: files }) },
|
||||
chooseVideo(options) { options.success({ tempFilePath: files[0].path, size: files[0].size }) },
|
||||
showToast(options) { toasts.push(options.title) }, showLoading() {}, hideLoading() {},
|
||||
}
|
||||
const env = { VITE_FILE_ACCESS_APP_ID: 'h5', VITE_FILE_ACCESS_KEY_ID: 'v1', VITE_FILE_ACCESS_KEY: signingKey,
|
||||
VITE_API_URL: '/api', VITE_APP_DEV_SERVICE: 'https://dev.example', VITE_IMAGE_DEV_PATH: 'https://dev.example',
|
||||
VITE_APP_PROD_SERVICE: apiBase, VITE_APP_PROD_IMAGE: 'https://images.example/api' }
|
||||
function load(relative, stubs = {}) {
|
||||
const filename = path.join(root, relative), loaded = new Module(filename, module)
|
||||
loaded.filename = filename; loaded.paths = Module._nodeModulePaths(path.dirname(filename))
|
||||
const originalRequire = loaded.require.bind(loaded)
|
||||
loaded.require = name => name in stubs ? stubs[name] : originalRequire(name)
|
||||
loaded._compile(compile(fs.readFileSync(filename, 'utf8').replace(/import\.meta\.env/g, '(' + JSON.stringify(env) + ')')), filename)
|
||||
return loaded.exports
|
||||
}
|
||||
const core = load('src/sheep/utils/file-access-core.ts')
|
||||
const access = load('src/sheep/utils/file-access.ts', { './file-access-core': core, './app': { isDevEnv: () => dev } })
|
||||
const hooks = load('src/sheep/hooks/use-token.ts', { '../constant/storage-constant': { default: { TOKEN: 'test-token' } },
|
||||
'../router/index': {}, '../utils/app': { isDevEnv: () => dev } })
|
||||
const flow = load('src/sheep/utils/file-upload.ts', { './file-access': access, '../hooks/use-token': hooks })
|
||||
access.installFileAccess({ config: { globalProperties: {} }, mixin() {} })
|
||||
const calls = options => new Promise(resolve => {
|
||||
const result = { successes: [], failures: [], completes: [] }
|
||||
flow.uploadFile({ url: '/api/admin/sys-file/upload', filePath: 'local.png', name: 'file', ...options,
|
||||
success: value => result.successes.push(value), fail: value => result.failures.push(value),
|
||||
complete: value => { result.completes.push(value); resolve(result) },
|
||||
})
|
||||
})
|
||||
return { state, requests, replies, warnings, toasts, files, access, flow, core, hooks, calls,
|
||||
close() { access.stopFileAccess(); global.uni = oldUni; Date.now = oldNow; console.warn = oldWarn } }
|
||||
}
|
||||
|
||||
function verifySignature(request) {
|
||||
const h = request.header
|
||||
assert.equal(h['X-File-App-Id'], 'h5')
|
||||
assert.equal(h['X-File-Key-Id'], 'v1')
|
||||
assert.match(h['X-File-Nonce'], /^[A-Za-z0-9_-]{16,64}$/)
|
||||
const canonical = ['v1', 'oss-file-upload', 'POST', 'upload', 'h5', 'v1', h['X-File-Timestamp'], h['X-File-Nonce']].join('\n')
|
||||
assert.equal(h['X-File-Signature'], createHmac('sha256', Buffer.from(key, 'base64')).update(canonical).digest('hex'))
|
||||
}
|
||||
|
||||
test('each upload calibrates time, reads current login and keeps multipart fields without duplicate headers', async () => {
|
||||
const env = runtime()
|
||||
try {
|
||||
const options = { filePath: 'blob:photo', formData: { dir: 'refund', groupId: 2 },
|
||||
header: { authorization: 'stale', 'tenant-id': '42', 'X-Custom': 'kept', 'content-type': 'application/json', 'x-file-nonce': 'stale' } }
|
||||
const first = await env.calls(options)
|
||||
assert.equal(first.successes.length, 1); assert.equal(first.completes.length, 1)
|
||||
const request = env.requests[0]
|
||||
verifySignature(request)
|
||||
assert.equal(request.url, '/api/admin/sys-file/upload')
|
||||
assert.equal(request.filePath, options.filePath); assert.deepEqual(request.formData, options.formData)
|
||||
assert.equal(request.header.Authorization, 'Bearer first-token')
|
||||
assert.equal(request.header['tenant-id'], '42'); assert.equal(request.header['TENANT-ID'], undefined)
|
||||
assert.equal(request.header['CLIENT-TOC'], 'Y'); assert.equal(request.header['X-Custom'], 'kept')
|
||||
assert.equal(request.header['content-type'], undefined); assert.equal(request.header['x-file-nonce'], undefined)
|
||||
assert.equal(Number(request.header['X-File-Timestamp']), (env.state.now + env.state.offset) / 1000)
|
||||
assert.equal(options.header.authorization, 'stale')
|
||||
env.state.now += 40 * 60000; env.state.token = { access_token: { tokenValue: 'renewed-token' } }
|
||||
await env.calls(options)
|
||||
verifySignature(env.requests[1])
|
||||
assert.equal(env.requests[1].header.Authorization, 'Bearer renewed-token')
|
||||
assert.notEqual(request.header['X-File-Nonce'], env.requests[1].header['X-File-Nonce'])
|
||||
assert.equal(env.state.clockCalls, 2)
|
||||
} finally { env.close() }
|
||||
})
|
||||
|
||||
test('production uploads use the configured API host and prefix, preserving third-party upload options', async () => {
|
||||
const env = runtime({ dev: false })
|
||||
try {
|
||||
for (const url of ['/api/admin/sys-file/upload', 'admin/sys-file/upload', '/sys-file/upload',
|
||||
'/gateway/api/admin/sys-file/upload', 'https://api.example/gateway/api/admin/sys-file/upload']) {
|
||||
await env.calls({ url })
|
||||
assert.equal(env.requests.at(-1).url, 'https://api.example/gateway/api/admin/sys-file/upload')
|
||||
verifySignature(env.requests.at(-1))
|
||||
}
|
||||
const owned = env.access.prepareFileUpload({ url: '/api/admin/sys-file/upload?dir=a%2Bb#x' })
|
||||
assert.equal(owned.url, 'https://api.example/gateway/api/admin/sys-file/upload?dir=a%2Bb#x')
|
||||
assert.equal(env.access.prepareFileUpload(owned).url, owned.url)
|
||||
for (const url of ['https://outside.example/admin/sys-file/upload', '//outside.example/admin/sys-file/upload',
|
||||
'https://api.example.evil/admin/sys-file/upload', '/admin/user/import']) {
|
||||
const headers = { Authorization: 'outside-auth', 'Content-Type': 'custom' }
|
||||
await env.calls({ url, header: headers })
|
||||
const request = env.requests.at(-1)
|
||||
assert.equal(request.url, url); assert.deepEqual(request.header, headers)
|
||||
assert.equal(request.header['X-File-Signature'], undefined)
|
||||
}
|
||||
} finally { env.close() }
|
||||
})
|
||||
|
||||
for (const reason of ['upload_clock_skew', 'upload_signature_expired']) test(reason + ': retry once with a fresh nonce after clock sync', async () => {
|
||||
const env = runtime()
|
||||
try {
|
||||
const denied = { statusCode: 403, data: JSON.stringify({ code: 403, reason }) }
|
||||
env.replies.push(denied, ok)
|
||||
const result = await env.calls()
|
||||
assert.equal(env.requests.length, 2); assert.equal(env.state.clockCalls, 2)
|
||||
env.requests.forEach(verifySignature)
|
||||
assert.notEqual(env.requests[0].header['X-File-Nonce'], env.requests[1].header['X-File-Nonce'])
|
||||
assert.equal(result.successes.length, 1); assert.equal(result.failures.length, 0); assert.equal(result.completes.length, 1)
|
||||
env.replies.push(denied, denied)
|
||||
const failed = await env.calls()
|
||||
assert.equal(env.requests.length, 4); assert.equal(failed.failures.length, 1); assert.equal(failed.completes.length, 1)
|
||||
} finally { env.close() }
|
||||
})
|
||||
|
||||
test('HTTP/business/format/network errors fail exactly once and never retry possible completed writes', async () => {
|
||||
const env = runtime()
|
||||
try {
|
||||
for (const reply of [
|
||||
{ statusCode: 403, data: JSON.stringify({ reason: 'unknown_upload_key' }) },
|
||||
{ statusCode: 403, data: JSON.stringify({ reason: 'upload_signature_replayed' }) },
|
||||
{ statusCode: 404, data: '<html>not found</html>' },
|
||||
{ statusCode: 415, data: JSON.stringify({ code: 415, msg: '文件类型不支持' }) },
|
||||
{ statusCode: 200, data: JSON.stringify({ code: 1, msg: '存储失败' }) },
|
||||
{ statusCode: 200, data: '{broken' },
|
||||
{ statusCode: 200, data: JSON.stringify({ code: 0, data: {} }) },
|
||||
{ statusCode: 200, data: JSON.stringify({ code: 1, reason: 'upload_clock_skew' }) },
|
||||
{ fail: { errMsg: 'uploadFile:fail timeout' } }, { throw: true },
|
||||
]) {
|
||||
const before = env.requests.length
|
||||
env.replies.push(reply)
|
||||
const result = await env.calls()
|
||||
assert.equal(env.requests.length, before + 1)
|
||||
assert.equal(result.successes.length, 0); assert.equal(result.failures.length, 1); assert.equal(result.completes.length, 1)
|
||||
}
|
||||
assert.equal(JSON.stringify(env.warnings).includes(key), false)
|
||||
} finally { env.close() }
|
||||
})
|
||||
|
||||
test('offline clock sync retains signing and a bad key stops before sending a file', async () => {
|
||||
let env = runtime()
|
||||
try {
|
||||
env.state.offline = true
|
||||
await env.calls(); verifySignature(env.requests[0])
|
||||
assert.equal(Number(env.requests[0].header['X-File-Timestamp']), env.state.now / 1000)
|
||||
} finally { env.close() }
|
||||
env = runtime({ signingKey: '' })
|
||||
try {
|
||||
const result = await env.calls()
|
||||
assert.equal(env.requests.length, 0); assert.equal(result.failures.length, 1)
|
||||
assert.match(result.failures[0].errMsg, /凭证/)
|
||||
} finally { env.close() }
|
||||
})
|
||||
|
||||
test('only successful uploads or edit echoes supply a stable URL', () => {
|
||||
const env = runtime()
|
||||
try {
|
||||
const signed = env.access.fileUrl(rawUrl)
|
||||
assert.equal(env.flow.uploadedFileUrl({ status: 2, response: JSON.stringify({ code: 0, data: { url: signed } }) }), env.access.originalFileUrl(signed))
|
||||
assert.equal(env.flow.uploadedFileUrl({ response: { data: { url: rawUrl } } }), rawUrl)
|
||||
for (const item of [null, { status: 1, response: ok.data }, { status: 3, response: ok.data },
|
||||
{ response: 'uploadFile:fail timeout' }, { response: '{broken' }, { response: { code: 403, data: { url: rawUrl } } }]) {
|
||||
assert.equal(env.flow.uploadedFileUrl(item), '')
|
||||
}
|
||||
} finally { env.close() }
|
||||
})
|
||||
|
||||
// Execute each real script-setup. Uni's conditional compilation selects H5 branches here too.
|
||||
function sfc(env, relative, names, stubs = {}, propValues = {}) {
|
||||
const descriptor = parse(fs.readFileSync(path.join(root, relative), 'utf8')).descriptor
|
||||
let enabled = true
|
||||
const stack = []
|
||||
const source = descriptor.scriptSetup.content.split('\n').filter(line => {
|
||||
const start = /\/\/\s*#(ifdef|ifndef)\s+(\S+)/.exec(line)
|
||||
if (start) { stack.push(enabled); enabled = enabled && (start[1] === 'ifdef' ? start[2] === 'H5' : start[2] !== 'H5'); return false }
|
||||
if (/\/\/\s*#endif/.test(line)) { enabled = stack.pop(); return false }
|
||||
return enabled
|
||||
}).join('\n')
|
||||
const props = vue.reactive({ ...propValues }), events = [], scope = vue.effectScope(), loaded = { exports: {} }
|
||||
const dependencies = {
|
||||
vue: { ...vue, onMounted() {} }, '@dcloudio/uni-app': { onLoad() {}, onShow() {}, onReachBottom() {}, onPullDownRefresh() {} },
|
||||
'@/sheep/utils/file-upload': env.flow, '@@/utils/file-upload': env.flow,
|
||||
'@/sheep/utils/file-access': env.access, '@@/index': { hooks: env.hooks, router: {} },
|
||||
'../../libs/tool': { covetUniNumber: String }, '../../libs/config': { useTmConfig: () => ({ config: {} }) },
|
||||
'../../interface': { TMUPLOAD_PHOTO_STATUS: { NOT: 0, UPLOADING: 1, UPLOAD_SUCCESS: 2, UPLOAD_FAILED: 3, EXCEEDS_SIZE_LIMIT: 5 } },
|
||||
...stubs,
|
||||
}
|
||||
const defineProps = schema => {
|
||||
for (const [name, option] of Object.entries(schema || {})) if (!(name in props)) {
|
||||
props[name] = typeof option.default === 'function' ? option.default() : option.default
|
||||
}
|
||||
return props
|
||||
}
|
||||
const defineEmits = () => (name, value) => {
|
||||
events.push({ name, value })
|
||||
if (name === 'update:modelValue') props.modelValue = value
|
||||
}
|
||||
scope.run(() => new Function('require', 'exports', 'module', 'defineProps', 'defineEmits', 'defineOptions', 'defineExpose', 'withDefaults',
|
||||
compile(source.replace(/import\.meta\.env/g, '({})') + '\nexport { ' + names.join(', ') + ' }'))(
|
||||
name => name in dependencies ? dependencies[name] : require(name), loaded.exports, loaded,
|
||||
defineProps, defineEmits, () => {}, () => {}, (props, defaults) => Object.assign(props, defaults, propValues)))
|
||||
return { ...loaded.exports, props, events, close: () => scope.stop() }
|
||||
}
|
||||
|
||||
for (const kind of ['photo', 'video']) test('actual ' + kind + ' component signs selections, exposes pending state and does not turn failures into success', async () => {
|
||||
const env = runtime()
|
||||
const component = sfc(env, `src/uni_modules/tm-ui/components/tm-upload-${kind}/tm-upload-${kind}.vue`, ['choose', 'startUpload', 'list', 'uploading'], {},
|
||||
{ url: '/api/admin/sys-file/upload', header: { Authorization: 'stale' } })
|
||||
try {
|
||||
env.files.push({ path: 'blob:one.png', size: 42 }, { path: 'blob:two.png', size: 42 })
|
||||
env.replies.push({ statusCode: 403, data: JSON.stringify({ reason: 'unknown_upload_key' }) }, ok)
|
||||
component.choose()
|
||||
assert.ok(component.events.some(event => event.name === 'update:modelValue' && event.value[0].status === 1))
|
||||
await settle()
|
||||
env.requests.forEach(verifySignature)
|
||||
assert.equal(component.list.value[0].status, 3)
|
||||
assert.equal(component.uploading.value, false)
|
||||
if (kind === 'photo') {
|
||||
assert.equal(component.list.value[1].status, 2)
|
||||
assert.notEqual(env.requests[0].header['X-File-Nonce'], env.requests[1].header['X-File-Nonce'])
|
||||
assert.equal(env.flow.uploadedFileUrl(component.list.value[1]), rawUrl)
|
||||
}
|
||||
const restored = sfc(env, `src/uni_modules/tm-ui/components/tm-upload-${kind}/tm-upload-${kind}.vue`, ['list'], {},
|
||||
{ modelValue: component.props.modelValue })
|
||||
try { assert.equal(restored.list.value[0].status, 3) } finally { restored.close() }
|
||||
component.startUpload(); await settle()
|
||||
assert.equal(component.list.value[0].status, 2)
|
||||
verifySignature(env.requests.at(-1))
|
||||
} finally { component.close(); env.close() }
|
||||
})
|
||||
|
||||
test('actual rich text uploader sends credentials and retains unsigned references; failed responses do not open insertion dialog', async () => {
|
||||
const env = runtime()
|
||||
const editor = sfc(env, 'src/sheep/components/rich-text-editor/rich-text-editor.vue', ['uploadImage', 'pendingImageUrl', 'showImageModal'])
|
||||
try {
|
||||
editor.uploadImage('blob:richtext'); await settle()
|
||||
verifySignature(env.requests[0]); assert.equal(editor.pendingImageUrl.value, rawUrl); assert.equal(editor.showImageModal.value, true)
|
||||
editor.showImageModal.value = false
|
||||
env.replies.push({ statusCode: 403, data: JSON.stringify({ msg: '上传身份校验失败' }) })
|
||||
editor.uploadImage('blob:invalid'); await settle()
|
||||
assert.equal(editor.showImageModal.value, false)
|
||||
assert.ok(env.toasts.includes('上传身份校验失败'))
|
||||
} finally { editor.close(); env.close() }
|
||||
})
|
||||
|
||||
test('actual refund form handles failed uploads safely and submits only stable successful references', async () => {
|
||||
const env = runtime(), submitted = []
|
||||
const page = sfc(env, 'src/pages/refund/apply.vue', ['uploadApplyPicUrls', 'applyPicUrls', 'applyForm', 'doSubmit'], {
|
||||
'@@/api': { refundApi: { create: async body => { submitted.push(body); return { code: 0 } } } },
|
||||
'@@/utils/file': { filePath: env.access.fileUrl }, '@@/index': { hooks: env.hooks, router: { jump() {} } },
|
||||
})
|
||||
try {
|
||||
page.applyForm.reasonStr = '测试原因'; page.applyForm.remark = '测试描述'
|
||||
const success = { status: 2, response: ok.data }, failure = { status: 3, response: '上传身份校验失败' }
|
||||
page.uploadApplyPicUrls.value = [success, failure]
|
||||
assert.equal(page.applyPicUrls.value, rawUrl)
|
||||
await page.doSubmit(); assert.equal(submitted.length, 0)
|
||||
page.uploadApplyPicUrls.value = [success, { status: 1, response: null }]
|
||||
await page.doSubmit(); assert.equal(submitted.length, 0)
|
||||
page.uploadApplyPicUrls.value = [success]
|
||||
await page.doSubmit(); assert.equal(submitted.length, 1); assert.equal(submitted[0].applyPicUrls, rawUrl)
|
||||
} finally { page.close(); env.close() }
|
||||
})
|
||||
Reference in new issue
Block a user