publish-gateway.js 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. import { get } from '@/core/http/http-client'
  2. import { HttpError } from '@/core/http/http-error'
  3. import { parseApiResponse, snapshotEnvelope } from '@/core/http/response-evaluator'
  4. import { buildRestPath } from '@/core/config/api-paths'
  5. import appSettings from '@/core/config/app-settings'
  6. import { pullHomeCategories } from '@/features/travel/gateway'
  7. const uploadConfigPath = () => buildRestPath('common/uploadData')
  8. const publishPath = () => buildRestPath('find/find/addData')
  9. const videoUploadPath = () => '/common/localuploadvideo'
  10. function resolveUploadUrl(path) {
  11. if (!path) return ''
  12. if (/^https?:\/\//.test(path)) return path
  13. if (path.startsWith('//')) return `https:${path}`
  14. const base = (appSettings.appurl || '').replace(/\/$/, '')
  15. return `${base}${path.startsWith('/') ? path : `/${path}`}`
  16. }
  17. function unwrapUploadConfig(payload) {
  18. const body = payload && typeof payload === 'object' ? payload : {}
  19. if (body.uploadurl) return body
  20. if (body.data && typeof body.data === 'object') return body.data
  21. return body
  22. }
  23. function parseUploadResponse(raw) {
  24. if (!raw) return ''
  25. if (typeof raw === 'string') {
  26. try {
  27. const parsed = JSON.parse(raw)
  28. if (parsed.data && parsed.data.fullurl) return parsed.data.fullurl
  29. if (parsed.data && parsed.data.url) return parsed.data.url
  30. if (parsed.fullurl) return parsed.fullurl
  31. if (parsed.url) return parsed.url
  32. } catch (_) {
  33. return ''
  34. }
  35. }
  36. if (typeof raw === 'object') {
  37. if (raw.data && raw.data.fullurl) return raw.data.fullurl
  38. if (raw.data && raw.data.url) return raw.data.url
  39. if (raw.fullurl) return raw.fullurl
  40. if (raw.url) return raw.url
  41. }
  42. return ''
  43. }
  44. /**
  45. * 上传前解码并压缩图片,降低 App 端大图 / HEIC 导致的内存闪退。
  46. *
  47. * @param {string} src
  48. * @returns {Promise<string>} 可用于 uploadFile 的本地路径
  49. */
  50. export function prepareImageForUpload(src) {
  51. return new Promise((resolve) => {
  52. const fallback = () => resolve(src || '')
  53. if (!src) {
  54. fallback()
  55. return
  56. }
  57. const compress = (path, width = 0, height = 0) => {
  58. if (typeof uni.compressImage !== 'function') {
  59. resolve(path)
  60. return
  61. }
  62. const opts = {
  63. src: path,
  64. quality: 60,
  65. success: (res) => resolve((res && res.tempFilePath) || path),
  66. fail: () => resolve(path)
  67. }
  68. const w = Number(width) || 0
  69. const h = Number(height) || 0
  70. if (w > 1280 || h > 1280) {
  71. if (w >= h) opts.compressedWidth = 1280
  72. else opts.compressedHeight = 1280
  73. }
  74. try {
  75. uni.compressImage(opts)
  76. } catch (_) {
  77. resolve(path)
  78. }
  79. }
  80. uni.getImageInfo({
  81. src,
  82. success: (info) => {
  83. const path = (info && info.path) || src
  84. compress(path, info && info.width, info && info.height)
  85. },
  86. fail: () => {
  87. // 解码失败时仍尝试压缩原路径(部分机型 getImageInfo 对 HEIC 不稳定)
  88. compress(src)
  89. }
  90. })
  91. })
  92. }
  93. /**
  94. * 获取图片上传配置(wanlshop/common/uploadData)
  95. */
  96. export async function fetchUploadConfig() {
  97. const { data, raw } = await get(uploadConfigPath())
  98. const cfg = unwrapUploadConfig(data)
  99. if (cfg.uploadurl) return cfg
  100. return unwrapUploadConfig(raw?.res?.data ?? raw?.data)
  101. }
  102. /**
  103. * 上传单张图片,返回相对路径 url
  104. */
  105. export function uploadImageFile(filePath, config) {
  106. return new Promise((resolve, reject) => {
  107. const uploadurl = resolveUploadUrl(config && config.uploadurl)
  108. if (!uploadurl) {
  109. reject(new Error('上传地址无效'))
  110. return
  111. }
  112. if (!filePath) {
  113. reject(new Error('图片文件无效'))
  114. return
  115. }
  116. const opts = {
  117. url: uploadurl,
  118. filePath,
  119. name: 'file',
  120. success: (res) => {
  121. const url = parseUploadResponse(res.data)
  122. if (url) resolve(url)
  123. else reject(new Error('上传失败'))
  124. },
  125. fail: (err) => reject(err || new Error('上传失败'))
  126. }
  127. // local 不传 formData;非 local 才带 multipart(与参考工程一致)
  128. if (config && config.storage !== 'local' && config.multipart) {
  129. opts.formData = config.multipart
  130. }
  131. uni.uploadFile(opts)
  132. })
  133. }
  134. function postFindData(body) {
  135. return new Promise((resolve, reject) => {
  136. let settled = false
  137. const finish = (fn, value) => {
  138. if (settled) return
  139. settled = true
  140. fn(value)
  141. }
  142. uni.request({
  143. url: publishPath(),
  144. method: 'POST',
  145. data: body,
  146. success(res) {
  147. try {
  148. snapshotEnvelope(res)
  149. const parsed = parseApiResponse(res)
  150. if (parsed.kind === 'success' || parsed.kind === 'passthrough') {
  151. finish(resolve, parsed.data)
  152. return
  153. }
  154. finish(reject, HttpError.fromParsed(parsed))
  155. } catch (err) {
  156. finish(reject, err)
  157. }
  158. },
  159. fail(err) {
  160. finish(reject, HttpError.fromNetwork(err))
  161. },
  162. complete() {
  163. setTimeout(() => {
  164. if (!settled) {
  165. finish(reject, new HttpError('BUSINESS', '发布失败'))
  166. }
  167. }, 0)
  168. }
  169. })
  170. })
  171. }
  172. function normalizeRelativeVideoPath(path) {
  173. if (!path) return ''
  174. const raw = String(path).replace(/\\\//g, '/')
  175. if (/^https?:\/\//i.test(raw)) {
  176. const match = raw.match(/^https?:\/\/[^/]+(\/.*)$/i)
  177. return match ? match[1] : raw
  178. }
  179. return raw.startsWith('/') ? raw : `/${raw}`
  180. }
  181. function resolveVideoPreviewUrl(data) {
  182. if (!data || typeof data !== 'object') return ''
  183. const full = data.fullurl || data.full_url || ''
  184. if (full) return String(full).replace(/\\\//g, '/')
  185. const rel = normalizeRelativeVideoPath(data.url || '')
  186. if (!rel) return ''
  187. const base = (appSettings.cdnurl || appSettings.appurl || '')
  188. .replace(/\/api\/?$/i, '')
  189. .replace(/\/$/, '')
  190. return `${base}${rel}`
  191. }
  192. function parseVideoUploadEnvelope(raw) {
  193. if (!raw) return null
  194. let envelope = raw
  195. if (typeof raw === 'string') {
  196. try {
  197. envelope = JSON.parse(raw)
  198. } catch (_) {
  199. return null
  200. }
  201. }
  202. const body = envelope && envelope.data
  203. if (body && body.video_id != null && (envelope.code === 1 || envelope.code === '1')) {
  204. return {
  205. video_id: body.video_id,
  206. url: normalizeRelativeVideoPath(body.url || ''),
  207. previewUrl: resolveVideoPreviewUrl(body)
  208. }
  209. }
  210. return null
  211. }
  212. /**
  213. * 视频标签列表(/appvideolabel/list)
  214. */
  215. export async function fetchVideoLabels() {
  216. try {
  217. return await pullHomeCategories()
  218. } catch (_) {
  219. return []
  220. }
  221. }
  222. /**
  223. * 上传短视频(/common/localuploadvideo)
  224. */
  225. export function uploadVideoFile(filePath, { fileName, onProgress } = {}) {
  226. return new Promise((resolve, reject) => {
  227. if (!filePath) {
  228. reject(new Error('视频文件无效'))
  229. return
  230. }
  231. const uploadTask = uni.uploadFile({
  232. url: videoUploadPath(),
  233. filePath,
  234. name: 'file',
  235. formData: {
  236. name: fileName || `video-${Date.now()}.mp4`
  237. },
  238. success: (res) => {
  239. const parsed = parseVideoUploadEnvelope(res.data)
  240. if (res.statusCode === 200 && parsed) {
  241. resolve(parsed)
  242. return
  243. }
  244. let message = '视频上传失败'
  245. try {
  246. const envelope = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
  247. if (envelope && envelope.msg) message = String(envelope.msg)
  248. } catch (_) {}
  249. reject(new Error(message))
  250. },
  251. fail: (err) => reject(err || new Error('视频上传失败'))
  252. })
  253. if (onProgress && uploadTask && typeof uploadTask.onProgressUpdate === 'function') {
  254. uploadTask.onProgressUpdate(onProgress)
  255. }
  256. })
  257. }
  258. /**
  259. * 发布种草动态(type=want)
  260. * 使用 uni.request + complete,避免拦截器 return false 时 Promise 永不 settle
  261. */
  262. export function publishGrassPost({ content, images, goodsIds = [] }) {
  263. const body = {
  264. type: 'want',
  265. content,
  266. images,
  267. goods_ids: goodsIds,
  268. video_id: null,
  269. label_id: null
  270. }
  271. return postFindData(body)
  272. }
  273. /**
  274. * 发布短视频(type=video)
  275. */
  276. export function publishVideoPost({ content, videoId, videoUrl, labelId, goodsIds = [] }) {
  277. const body = {
  278. type: 'video',
  279. content,
  280. images: [],
  281. goods_ids: goodsIds,
  282. video_id: videoId,
  283. video: videoUrl || null,
  284. label_id: labelId
  285. }
  286. return postFindData(body)
  287. }