publishDynamic.vue 33 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091
  1. <template>
  2. <div class="publish-dynamic-container">
  3. <!-- 头部导航 -->
  4. <div class="header-section">
  5. <el-button @click="handleGoBack" class="back-btn"> 返回 </el-button>
  6. <div class="header-title">动态发布</div>
  7. </div>
  8. <!-- 表单内容 -->
  9. <div class="form-container">
  10. <el-form ref="formRef" :model="formData" :rules="rules" label-position="top">
  11. <!-- 图片/视频上传 -->
  12. <el-form-item label="图片/视频" prop="images">
  13. <div class="upload-section" :class="{ 'at-upload-limit': fileList.length >= (hasVideoInList() ? 1 : 20) }">
  14. <el-upload
  15. v-model:file-list="fileList"
  16. list-type="picture-card"
  17. :limit="hasVideoInList() ? 1 : 20"
  18. :on-preview="handlePicturePreview"
  19. :on-remove="handleRemoveImage"
  20. :on-change="handleFileChange"
  21. :before-upload="beforeImageUpload"
  22. :http-request="handleImageUpload"
  23. accept="image/*,video/mp4,video/*"
  24. multiple
  25. class="dynamic-upload"
  26. >
  27. <template #file="{ file }">
  28. <div class="upload-file-preview">
  29. <!-- 视频缩略图 -->
  30. <video v-if="isVideoFile(file)" :src="file.url" class="upload-video-thumb" />
  31. <!-- 图片缩略图 -->
  32. <img v-else :src="file.url" class="upload-image-thumb" />
  33. <!-- 操作按钮 -->
  34. <div class="upload-actions">
  35. <span class="upload-action-item" @click.stop="handlePicturePreview(file)">
  36. <el-icon :size="20"><ZoomIn /></el-icon>
  37. </span>
  38. <span class="upload-action-item" @click.stop="handleRemoveFile(file)">
  39. <el-icon :size="20"><Delete /></el-icon>
  40. </span>
  41. </div>
  42. </div>
  43. </template>
  44. <div class="upload-trigger">
  45. <el-icon :size="32" color="#999">
  46. <Plus />
  47. </el-icon>
  48. <div class="upload-count">({{ fileList.length }}/{{ hasVideoInList() ? 1 : 20 }})</div>
  49. </div>
  50. </el-upload>
  51. </div>
  52. </el-form-item>
  53. <!-- 标题 -->
  54. <el-form-item label="标题" prop="title">
  55. <el-input v-model="formData.title" placeholder="请输入标题" maxlength="50" show-word-limit size="large" />
  56. </el-form-item>
  57. <!-- 正文 -->
  58. <el-form-item label="正文" prop="content">
  59. <el-input
  60. v-model="formData.content"
  61. type="textarea"
  62. placeholder="请输入正文"
  63. :rows="8"
  64. maxlength="500"
  65. show-word-limit
  66. />
  67. </el-form-item>
  68. <!-- 位置 -->
  69. <el-form-item label="位置" prop="location">
  70. <el-select
  71. v-model="formData.storePosition"
  72. filterable
  73. clearable
  74. placeholder="请输入地址进行查询"
  75. remote
  76. reserve-keyword
  77. :remote-method="getLonAndLat"
  78. @change="selectAddress"
  79. @clear="handleClearLocation"
  80. >
  81. <el-option v-for="item in addressList" :key="item.id" :label="item.name" :value="item.location">
  82. <span style="float: left">{{ item.name }}</span>
  83. <span style="float: right; font-size: 13px; color: var(--el-text-color-secondary)">{{ item.district }}</span>
  84. </el-option>
  85. </el-select>
  86. </el-form-item>
  87. </el-form>
  88. <div class="footer-actions">
  89. <el-button size="large" class="draft-btn" @click="handleSaveDraft"> 保存草稿 </el-button>
  90. <el-button type="primary" size="large" class="publish-btn" :loading="publishing" @click="handlePublish"> 发布 </el-button>
  91. </div>
  92. </div>
  93. <PcImagePreviewViewer
  94. v-model:visible="previewImageViewerVisible"
  95. :url-list="previewImageUrl ? [previewImageUrl] : []"
  96. :initial-index="0"
  97. />
  98. <PcVideoPreviewDialog
  99. v-model="previewVideoDialogVisible"
  100. :src="previewVideoUrl"
  101. title="查看"
  102. :autoplay="false"
  103. @closed="previewVideoUrl = ''"
  104. />
  105. <!-- 位置选择对话框 -->
  106. <el-dialog v-model="locationDialogVisible" title="选择位置" width="500px" append-to-body>
  107. <el-input v-model="locationSearch" placeholder="搜索位置" clearable>
  108. <template #prefix>
  109. <el-icon>
  110. <Search />
  111. </el-icon>
  112. </template>
  113. </el-input>
  114. <div class="location-list">
  115. <div v-for="location in locationList" :key="location.id" class="location-item" @click="handleChooseLocation(location)">
  116. <el-icon>
  117. <Location />
  118. </el-icon>
  119. <span>{{ location.name }}</span>
  120. </div>
  121. <el-empty v-if="locationList.length === 0" description="暂无位置信息" />
  122. </div>
  123. </el-dialog>
  124. </div>
  125. </template>
  126. <script setup lang="ts" name="publishDynamic">
  127. import { ref, reactive, onMounted } from "vue";
  128. import { useRouter, useRoute } from "vue-router";
  129. import { ElMessage, ElMessageBox } from "element-plus";
  130. import { ArrowLeft, Plus, Location, Search, ZoomIn, Delete } from "@element-plus/icons-vue";
  131. import type { FormInstance, FormRules, UploadUserFile, UploadFile } from "element-plus";
  132. // import { publishDynamic, saveDraft, uploadDynamicImage } from "@/api/modules/dynamicManagement";
  133. // import { addOrUpdateDynamic } from "@/api/modules/dynamicManagement";
  134. import { addOrUpdateDynamic } from "@/api/modules/newLoginApi";
  135. import { uploadFilesToOss, isUploadUserCancelledError } from "@/api/upload.js";
  136. import { useSimpleUploadOverlayStore } from "@/stores/modules/simpleUploadOverlay";
  137. import { useUserStore } from "@/stores/modules/user";
  138. import { getUserDraftDynamics, getInputPrompt } from "@/api/modules/newLoginApi";
  139. import PcImagePreviewViewer from "@/components/pcMediaPreview/PcImagePreviewViewer.vue";
  140. import PcVideoPreviewDialog from "@/components/pcMediaPreview/PcVideoPreviewDialog.vue";
  141. const router = useRouter();
  142. const route = useRoute();
  143. const userStore = useUserStore();
  144. /** 多文件入队共用一个全局上传弹层;单次 OSS 请求带 skip,避免与 withSimpleUploadOverlay 重复 */
  145. let publishSimpleOverlayPending = 0;
  146. let publishSimpleOverlayHadError = false;
  147. function publishOverlaySleep(ms: number) {
  148. return new Promise<void>(resolve => setTimeout(resolve, ms));
  149. }
  150. async function finishPublishSimpleOverlay() {
  151. const overlay = useSimpleUploadOverlayStore();
  152. if (publishSimpleOverlayHadError) {
  153. overlay.dismiss();
  154. } else {
  155. if (overlay.show && overlay.percent < 100) {
  156. overlay.completeSuccess();
  157. }
  158. await publishOverlaySleep(420);
  159. overlay.dismiss();
  160. }
  161. }
  162. // 接口定义
  163. interface FormData {
  164. title: string;
  165. content: string;
  166. images: string[];
  167. location: string;
  168. locationId?: string;
  169. address?: string; // 经纬度
  170. addressProvince?: string; // 省市
  171. storePosition: string;
  172. queryAddress: string;
  173. storePositionLongitude: string;
  174. storePositionLatitude: string;
  175. }
  176. interface LocationItem {
  177. id: string;
  178. name: string;
  179. address: string;
  180. latitude?: string; // 纬度
  181. longitude?: string; // 经度
  182. province?: string; // 省市
  183. }
  184. // 响应式数据
  185. const formRef = ref<FormInstance>();
  186. const fileList = ref<UploadUserFile[]>([]);
  187. const previewImageViewerVisible = ref(false);
  188. const previewVideoDialogVisible = ref(false);
  189. const previewImageUrl = ref("");
  190. const previewVideoUrl = ref("");
  191. const locationDialogVisible = ref(false);
  192. const locationSearch = ref("");
  193. const locationList = ref<LocationItem[]>([]);
  194. const publishing = ref(false);
  195. const pendingUploadFiles = ref<UploadFile[]>([]);
  196. const uploading = ref(false);
  197. const currentDraftId = ref<number | string | null>(null); // 当前编辑的草稿ID
  198. const isEditMode = ref(false); // 是否为编辑模式
  199. /** 动态发布:体积上限与提示(与商户端口径一致;仅在此处弹一次,避免 before-upload 与 on-change 重复提示) */
  200. const DYNAMIC_IMAGE_MAX_MB = 20;
  201. const DYNAMIC_VIDEO_MAX_MB = 500;
  202. const UPLOAD_TIP_IMAGE = "图片建议不超过 20MB";
  203. const UPLOAD_TIP_VIDEO = "视频建议不超过 500MB";
  204. // 表单数据
  205. const formData = reactive<FormData>({
  206. title: "",
  207. content: "",
  208. images: [],
  209. location: "",
  210. address: "", // 经纬度
  211. addressProvince: "", // 省市
  212. storePosition: "",
  213. queryAddress: "",
  214. storePositionLongitude: "",
  215. storePositionLatitude: ""
  216. });
  217. // 经纬度查询
  218. const addressMap = new Map();
  219. const addressList = ref<any[]>([]);
  220. const getLonAndLat = async (keyword: string) => {
  221. if (keyword) {
  222. console.log("地址查询", keyword);
  223. let param = {
  224. addressName: keyword
  225. };
  226. let res: any = await getInputPrompt(param as any);
  227. if (res.code == "200") {
  228. res.data.tips.forEach((item: any) => {
  229. addressMap.set(item.location, item.name);
  230. });
  231. addressList.value = res?.data?.tips || [];
  232. console.log("res", res);
  233. } else {
  234. ElMessage.error("新增失败!");
  235. }
  236. } else {
  237. addressList.value = [];
  238. }
  239. };
  240. const selectAddress = async (param: any) => {
  241. // 如果清空了位置,清空相关字段
  242. if (!formData.storePosition || formData.storePosition === "") {
  243. handleClearLocation();
  244. return;
  245. }
  246. if (typeof formData.storePosition !== "string") {
  247. ElMessage.warning("地址格式不正确,请重新选择");
  248. return;
  249. }
  250. if (!formData.storePosition.includes(",")) {
  251. ElMessage.warning("地址格式不正确,缺少经纬度信息");
  252. return;
  253. }
  254. // 安全地分割地址字符串
  255. let locationList = formData.storePosition.split(",");
  256. // 查找对应的地址名称
  257. addressList.value.forEach((item: any) => {
  258. console.log(item);
  259. if (item.location == formData.storePosition) {
  260. formData.queryAddress = item.name;
  261. formData.addressProvince = item.district;
  262. }
  263. });
  264. formData.storePositionLongitude = locationList[0]?.trim();
  265. formData.storePositionLatitude = locationList[1]?.trim();
  266. };
  267. // 清空位置
  268. const handleClearLocation = () => {
  269. formData.storePosition = "";
  270. formData.queryAddress = "";
  271. formData.storePositionLongitude = "";
  272. formData.storePositionLatitude = "";
  273. formData.addressProvince = "";
  274. formData.location = "";
  275. formData.locationId = "";
  276. formData.address = "";
  277. addressList.value = [];
  278. };
  279. // 表单验证规则
  280. const rules = reactive<FormRules<FormData>>({
  281. title: [{ required: true, message: "请输入标题", trigger: "blur" }],
  282. content: [{ required: true, message: "请输入正文", trigger: "blur" }]
  283. });
  284. // 返回上一页
  285. const handleGoBack = async () => {
  286. // 检查是否有未保存的内容
  287. if (formData.title || formData.content || fileList.value.length > 0) {
  288. try {
  289. await ElMessageBox.confirm("当前有未保存的内容,确定要离开吗?", "提示", {
  290. confirmButtonText: "确定",
  291. cancelButtonText: "取消",
  292. type: "warning"
  293. });
  294. router.back();
  295. } catch {
  296. // 用户取消
  297. }
  298. } else {
  299. router.back();
  300. }
  301. };
  302. // 文件改变
  303. const handleFileChange = (uploadFile: UploadFile, uploadFiles: UploadFile[]) => {
  304. // 验证文件
  305. if (uploadFile.raw) {
  306. const isImage = uploadFile.raw.type.startsWith("image/");
  307. const isVideo = uploadFile.raw.type.startsWith("video/");
  308. const isValidType = isImage || isVideo;
  309. if (!isValidType) {
  310. const index = uploadFiles.findIndex(f => f.uid === uploadFile.uid);
  311. if (index > -1) {
  312. uploadFiles.splice(index, 1);
  313. }
  314. // 提示由 beforeImageUpload 统一给出,避免与 before-upload 双次弹窗
  315. return;
  316. }
  317. // 权限:已上传视频则不能再传;已上传图片则只能继续传图片(检查时排除当前文件,用已存在的列表)
  318. const otherFiles = fileList.value.filter(f => f.uid !== uploadFile.uid);
  319. const alreadyHasVideo = otherFiles.some(f => isVideoFile(f));
  320. const alreadyHasImage = otherFiles.some(f => !isVideoFile(f));
  321. if (alreadyHasVideo) {
  322. const index = uploadFiles.findIndex(f => f.uid === uploadFile.uid);
  323. if (index > -1) uploadFiles.splice(index, 1);
  324. return;
  325. }
  326. if (alreadyHasImage && isVideo) {
  327. const index = uploadFiles.findIndex(f => f.uid === uploadFile.uid);
  328. if (index > -1) uploadFiles.splice(index, 1);
  329. return;
  330. }
  331. // 体积超限:不在此弹窗(与 beforeImageUpload 重复);仅从列表移除,由 beforeImageUpload 统一提示一次
  332. const maxSizeMb = isVideo ? DYNAMIC_VIDEO_MAX_MB : DYNAMIC_IMAGE_MAX_MB;
  333. const isLtMaxSize = uploadFile.raw.size / 1024 / 1024 < maxSizeMb;
  334. if (!isLtMaxSize) {
  335. const index = uploadFiles.findIndex(f => f.uid === uploadFile.uid);
  336. if (index > -1) {
  337. uploadFiles.splice(index, 1);
  338. }
  339. return;
  340. }
  341. }
  342. // 为视频文件创建临时预览URL
  343. if (uploadFile.raw && uploadFile.raw.type.startsWith("video/") && !uploadFile.url) {
  344. uploadFile.url = URL.createObjectURL(uploadFile.raw);
  345. console.log("为视频创建临时URL:", uploadFile.url);
  346. }
  347. // 添加到待上传队列
  348. const existingIndex = fileList.value.findIndex(f => f.uid === uploadFile.uid);
  349. if (existingIndex === -1 && uploadFile.status) {
  350. fileList.value.push(uploadFile as UploadUserFile);
  351. }
  352. const readyFiles = fileList.value.filter(file => file.status === "ready");
  353. if (readyFiles.length) {
  354. readyFiles.forEach(file => {
  355. if (!pendingUploadFiles.value.some(item => item.uid === file.uid)) {
  356. if (publishSimpleOverlayPending === 0) {
  357. publishSimpleOverlayHadError = false;
  358. }
  359. publishSimpleOverlayPending++;
  360. if (publishSimpleOverlayPending === 1) {
  361. useSimpleUploadOverlayStore().beginUpload();
  362. }
  363. pendingUploadFiles.value.push(file as UploadFile);
  364. }
  365. });
  366. }
  367. processUploadQueue();
  368. };
  369. // 处理上传队列
  370. const processUploadQueue = async () => {
  371. if (uploading.value || pendingUploadFiles.value.length === 0) {
  372. return;
  373. }
  374. const file = pendingUploadFiles.value.shift();
  375. if (file) {
  376. await uploadSingleFile(file);
  377. processUploadQueue();
  378. }
  379. };
  380. // 单个文件上传
  381. const uploadSingleFile = async (file: UploadFile) => {
  382. if (!file.raw) {
  383. if (publishSimpleOverlayPending > 0) {
  384. publishSimpleOverlayPending--;
  385. if (publishSimpleOverlayPending === 0) {
  386. await finishPublishSimpleOverlay();
  387. }
  388. }
  389. return;
  390. }
  391. const rawFile = file.raw as File;
  392. file.status = "uploading";
  393. file.percentage = 0;
  394. uploading.value = true;
  395. try {
  396. const isVideo = rawFile.type.startsWith("video/");
  397. const urls = await uploadFilesToOss(rawFile, isVideo ? "video" : "image", {
  398. skipSimpleUploadOverlay: true,
  399. maxVideoMb: DYNAMIC_VIDEO_MAX_MB
  400. });
  401. const fileUrl = urls[0];
  402. if (!fileUrl) {
  403. ElMessage.error("上传失败,未返回文件地址");
  404. throw new Error("上传失败,未返回文件地址");
  405. }
  406. file.status = "success";
  407. file.percentage = 100;
  408. file.url = fileUrl;
  409. file.response = { url: fileUrl };
  410. if (!formData.images.includes(fileUrl)) {
  411. formData.images.push(fileUrl);
  412. }
  413. } catch (error: any) {
  414. if (!isUploadUserCancelledError(error)) {
  415. publishSimpleOverlayHadError = true;
  416. console.error("上传失败:", error);
  417. }
  418. file.status = "fail";
  419. if (file.url && file.url.startsWith("blob:")) {
  420. URL.revokeObjectURL(file.url);
  421. }
  422. const index = fileList.value.findIndex(f => f.uid === file.uid);
  423. if (index > -1) {
  424. fileList.value.splice(index, 1);
  425. }
  426. // OSS 网络/签名错误已在 upload.js 中 ElMessage,避免重复弹窗
  427. } finally {
  428. if (publishSimpleOverlayPending > 0) {
  429. publishSimpleOverlayPending--;
  430. if (publishSimpleOverlayPending === 0) {
  431. await finishPublishSimpleOverlay();
  432. }
  433. }
  434. uploading.value = false;
  435. fileList.value = [...fileList.value];
  436. }
  437. };
  438. // 图片上传前验证(与 el-upload on-change 相比先执行或后执行因版本而异,此处为唯一弹窗出口)
  439. const beforeImageUpload = (file: File) => {
  440. const isImage = file.type.startsWith("image/");
  441. const isVideo = file.type.startsWith("video/");
  442. const isValidType = isImage || isVideo;
  443. if (!isValidType) {
  444. ElMessage.warning("只能上传图片或视频文件");
  445. return false;
  446. }
  447. // 权限:已上传视频则不能再传;已上传图片则只能继续传图片(排除当前文件,避免清除图片后选视频被误判)
  448. const permission = checkUploadPermission(file, file);
  449. if (!permission.allowed) {
  450. ElMessage.warning(permission.message || "当前不可上传该文件");
  451. return false;
  452. }
  453. const isLtMaxSize = file.size / 1024 / 1024 < (isVideo ? DYNAMIC_VIDEO_MAX_MB : DYNAMIC_IMAGE_MAX_MB);
  454. if (!isLtMaxSize) {
  455. ElMessage.warning(isVideo ? UPLOAD_TIP_VIDEO : UPLOAD_TIP_IMAGE);
  456. return false;
  457. }
  458. return true;
  459. };
  460. // 自定义上传
  461. const handleImageUpload = async (options: any) => {
  462. // 上传逻辑已在 uploadSingleFile 中处理
  463. return;
  464. };
  465. // 判断文件是否为视频
  466. const isVideoFile = (file: any) => {
  467. const fileName = file.name || file.url || "";
  468. return fileName.toLowerCase().endsWith(".mp4") || file.raw?.type?.startsWith("video/") || false;
  469. };
  470. // 上传类型权限:第一个是图片则只能继续传图片;第一个是视频则只能传一个视频
  471. const hasVideoInList = () => fileList.value.some(f => isVideoFile(f));
  472. const hasImageInList = () => fileList.value.some(f => !isVideoFile(f));
  473. /** 校验上传权限,excludeRawFile 为当前正在校验的文件,校验时排除它(避免刚选中的文件已被加入列表导致误判) */
  474. const checkUploadPermission = (file: File, excludeRawFile?: File): { allowed: boolean; message?: string } => {
  475. const isVideo = file.type.startsWith("video/");
  476. const existingFiles = excludeRawFile ? fileList.value.filter(f => f.raw !== excludeRawFile) : fileList.value;
  477. const existingHasVideo = existingFiles.some(f => isVideoFile(f));
  478. const existingHasImage = existingFiles.some(f => !isVideoFile(f));
  479. if (existingHasVideo) {
  480. return { allowed: false, message: "已上传视频,只能上传一个视频,不可再上传" };
  481. }
  482. if (existingHasImage && isVideo) {
  483. return { allowed: false, message: "已上传图片,后续只能上传图片,不能上传视频" };
  484. }
  485. return { allowed: true };
  486. };
  487. // 图片/视频预览(统一 PC 组件)
  488. const handlePicturePreview = (uploadFile: UploadUserFile) => {
  489. const url = uploadFile.url!;
  490. if (isVideoFile(uploadFile)) {
  491. previewVideoUrl.value = url;
  492. previewVideoDialogVisible.value = true;
  493. } else {
  494. previewImageUrl.value = url;
  495. previewImageViewerVisible.value = true;
  496. }
  497. };
  498. // 移除图片/视频(组件回调)
  499. const handleRemoveImage = (uploadFile: UploadUserFile, uploadFiles: UploadUserFile[]) => {
  500. console.log("组件回调删除文件:", uploadFile);
  501. // 从 formData.images 中移除对应的 URL
  502. const fileUrl = uploadFile.url || (uploadFile.response as any)?.url;
  503. if (fileUrl) {
  504. // 清理 blob URL
  505. if (fileUrl.startsWith("blob:")) {
  506. URL.revokeObjectURL(fileUrl);
  507. }
  508. const index = formData.images.findIndex(url => url === fileUrl);
  509. if (index > -1) {
  510. formData.images.splice(index, 1);
  511. }
  512. }
  513. };
  514. // 手动移除文件(点击自定义删除按钮)
  515. const handleRemoveFile = (file: UploadUserFile) => {
  516. console.log("删除文件:", file);
  517. // 从 formData.images 中移除对应的 URL
  518. const fileUrl = file.url || (file.response as any)?.url;
  519. console.log("文件URL:", fileUrl);
  520. if (fileUrl) {
  521. // 清理 blob URL
  522. if (fileUrl.startsWith("blob:")) {
  523. URL.revokeObjectURL(fileUrl);
  524. console.log("清理临时URL");
  525. }
  526. const index = formData.images.findIndex(url => url === fileUrl);
  527. console.log("在 images 数组中的索引:", index);
  528. if (index > -1) {
  529. formData.images.splice(index, 1);
  530. }
  531. }
  532. // 从 fileList 中移除
  533. const fileIndex = fileList.value.findIndex(f => f.uid === file.uid);
  534. console.log("在 fileList 中的索引:", fileIndex);
  535. if (fileIndex > -1) {
  536. fileList.value.splice(fileIndex, 1);
  537. }
  538. console.log("删除后 fileList 长度:", fileList.value.length);
  539. console.log("删除后 images 长度:", formData.images.length);
  540. };
  541. // 选择位置
  542. const handleChooseLocation = (location: LocationItem) => {
  543. formData.location = location.name;
  544. formData.locationId = location.id;
  545. // 保存经纬度和省市信息(如果有)
  546. if (location.longitude && location.latitude) {
  547. formData.address = `${location.longitude},${location.latitude}`;
  548. }
  549. if (location.province) {
  550. formData.addressProvince = location.province;
  551. }
  552. console.log("选择位置:", {
  553. name: formData.location,
  554. address: formData.address,
  555. province: formData.addressProvince
  556. });
  557. locationDialogVisible.value = false;
  558. };
  559. // 保存草稿
  560. const handleSaveDraft = async () => {
  561. if (!formData.title && !formData.content && fileList.value.length === 0) {
  562. ElMessage.warning("请至少填写标题或正文");
  563. return;
  564. }
  565. // 检查是否有图片/视频正在上传
  566. if (uploading.value || pendingUploadFiles.value.length > 0) {
  567. ElMessage.warning("图片/视频正在上传中,请稍候...");
  568. return;
  569. }
  570. try {
  571. // 获取当前用户信息
  572. const phone = userStore.userInfo?.phone || "";
  573. const currentUserPhoneId = phone.startsWith("store_") ? phone : `store_${phone}`;
  574. const createId = userStore.userInfo?.id || userStore.userInfo?.userId || 0;
  575. // 调用保存草稿接口
  576. const params: any = {
  577. title: formData.title || "未命名",
  578. context: formData.content || "", // 正文
  579. imagePath: formData.images.join(","), // 多个图片/视频用逗号分隔
  580. phoneId: currentUserPhoneId, // 店铺ID
  581. createId: createId, // 创建者ID
  582. type: "2", // 动态类型
  583. draft: 1, // ✅ 1表示草稿
  584. addressName: formData.queryAddress, // 地址名称
  585. address: formData.storePositionLongitude + "," + formData.storePositionLatitude, // 经纬度
  586. addressProvince: formData.addressProvince || "" // 省市
  587. };
  588. // 如果是编辑模式,传递ID
  589. if (isEditMode.value && currentDraftId.value) {
  590. params.id = currentDraftId.value;
  591. }
  592. await addOrUpdateDynamic(params);
  593. ElMessage.success("草稿保存成功");
  594. router.back();
  595. } catch (error) {
  596. console.error("保存草稿失败:", error);
  597. ElMessage.error("保存草稿失败");
  598. }
  599. };
  600. // 发布动态
  601. const handlePublish = async () => {
  602. if (!formRef.value) return;
  603. await formRef.value.validate(async (valid: boolean) => {
  604. if (valid) {
  605. // 检查是否有图片正在上传
  606. if (uploading.value || pendingUploadFiles.value.length > 0) {
  607. ElMessage.warning("图片/视频正在上传中,请稍候...");
  608. return;
  609. }
  610. // 检查是否有上传的文件
  611. if (formData.images.length === 0) {
  612. ElMessage.warning("请至少上传一张图片或一个视频");
  613. return;
  614. }
  615. publishing.value = true;
  616. try {
  617. // 获取当前用户信息
  618. const phone = userStore.userInfo?.phone || "";
  619. const currentUserPhoneId = phone.startsWith("store_") ? phone : `store_${phone}`;
  620. const createId = userStore.userInfo?.id || userStore.userInfo?.userId || 0;
  621. // 调用发布动态接口
  622. const params: any = {
  623. title: formData.title,
  624. context: formData.content, // 正文
  625. imagePath: formData.images.join(","), // 多个图片/视频用逗号分隔
  626. phoneId: currentUserPhoneId, // 店铺ID
  627. createId: createId, // 创建者ID
  628. type: "2", // 动态类型
  629. draft: 0, // 0表示发布
  630. addressName: formData.queryAddress, // 地址名称
  631. address: formData.storePositionLongitude + "," + formData.storePositionLatitude, // 经纬度
  632. addressProvince: formData.addressProvince || "" // 省市
  633. };
  634. // 如果是编辑模式,传递ID
  635. if (isEditMode.value && currentDraftId.value) {
  636. params.id = currentDraftId.value;
  637. }
  638. await addOrUpdateDynamic(params);
  639. ElMessage.success("发布成功");
  640. router.back();
  641. } catch (error) {
  642. console.error("发布失败:", error);
  643. ElMessage.error("发布失败");
  644. } finally {
  645. publishing.value = false;
  646. }
  647. }
  648. });
  649. };
  650. // 加载草稿数据
  651. const loadDraftData = async (draftId: string | number) => {
  652. try {
  653. console.log("加载草稿数据,ID:", draftId);
  654. // 获取当前用户的手机号
  655. const phone = userStore.userInfo?.phone || "";
  656. const phoneId = phone.startsWith("store_") ? phone : `store_${phone}`;
  657. // 调用接口获取草稿列表
  658. const res: any = await getUserDraftDynamics({
  659. phoneId: phoneId,
  660. page: 1,
  661. size: 1000
  662. });
  663. if (res.code === 200) {
  664. const dataList = res.data?.records || res.data?.list || [];
  665. // 查找指定的草稿
  666. const draft = dataList.find((item: any) => item.id == draftId);
  667. if (draft) {
  668. console.log("找到草稿数据:", draft);
  669. // 回显标题
  670. formData.title = draft.title || "";
  671. // 回显正文
  672. formData.content = draft.context || "";
  673. // 回显位置
  674. formData.location = draft.addressName || "";
  675. formData.address = draft.address || "";
  676. formData.addressProvince = draft.addressProvince || "";
  677. formData.storePosition = draft.address || ""; // 回显到下拉框(经纬度格式)
  678. formData.queryAddress = draft.addressName || ""; // 回显地址名称
  679. formData.storePositionLongitude = draft.address ? draft.address.split(",")[0]?.trim() : "";
  680. formData.storePositionLatitude = draft.address ? draft.address.split(",")[1]?.trim() : "";
  681. // 如果有地址,需要将其添加到 addressList 中以便下拉框显示
  682. if (draft.address && draft.addressName) {
  683. addressList.value = [
  684. {
  685. id: draft.id || Date.now(),
  686. name: draft.addressName,
  687. location: draft.address,
  688. district: draft.addressProvince || ""
  689. }
  690. ];
  691. }
  692. // 回显图片/视频
  693. if (draft.imagePath) {
  694. const imageUrls = draft.imagePath.split(",").filter((url: string) => url.trim());
  695. formData.images = imageUrls;
  696. // 构建文件列表用于显示
  697. fileList.value = imageUrls.map((url: string, index: number) => {
  698. const isVideo = url.toLowerCase().endsWith(".mp4");
  699. return {
  700. uid: Date.now() + index,
  701. name: isVideo ? `video_${index + 1}.mp4` : `image_${index + 1}.jpg`,
  702. status: "success" as const,
  703. url: url,
  704. response: { url: url }
  705. };
  706. });
  707. console.log("回显图片/视频列表:", fileList.value);
  708. }
  709. ElMessage.success("草稿数据加载成功");
  710. } else {
  711. ElMessage.warning("未找到指定的草稿");
  712. }
  713. }
  714. } catch (error) {
  715. console.error("加载草稿数据失败:", error);
  716. ElMessage.error("加载草稿数据失败");
  717. }
  718. };
  719. // 初始化
  720. onMounted(() => {
  721. // 检查是否为编辑草稿模式
  722. const draftId = route.query.draftId;
  723. const mode = route.query.mode;
  724. if (draftId && mode === "edit") {
  725. console.log("进入编辑草稿模式, draftId:", draftId);
  726. isEditMode.value = true;
  727. currentDraftId.value = draftId as string | number;
  728. loadDraftData(draftId as string | number);
  729. }
  730. });
  731. </script>
  732. <style scoped lang="scss">
  733. .header-section {
  734. display: flex;
  735. align-items: center;
  736. justify-content: space-between;
  737. padding: 16px 0;
  738. border-bottom: 1px solid #e4e7ed;
  739. .back-btn {
  740. display: flex;
  741. gap: 4px;
  742. align-items: center;
  743. padding: 8px 16px;
  744. margin-left: 20px;
  745. font-size: 15px;
  746. color: #606266;
  747. transition: all 0.3s;
  748. &:hover {
  749. color: #409eff;
  750. background: #ecf5ff;
  751. }
  752. }
  753. .header-title {
  754. flex: 1;
  755. font-size: 20px;
  756. font-weight: 600;
  757. color: #303133;
  758. text-align: center;
  759. }
  760. .header-right {
  761. width: 100px;
  762. }
  763. }
  764. .publish-dynamic-container {
  765. display: flex;
  766. flex-direction: column;
  767. background: #ffffff;
  768. // 头部导航
  769. .header-bar {
  770. position: sticky;
  771. top: 0;
  772. z-index: 100;
  773. display: flex;
  774. align-items: center;
  775. justify-content: space-between;
  776. height: 60px;
  777. .header-left {
  778. flex: 1;
  779. .return-btn {
  780. background: #ffffff;
  781. border: 1px solid #dcdfe6;
  782. }
  783. :deep(.el-button) {
  784. padding: 8px 0;
  785. font-size: 16px;
  786. color: #606266;
  787. &:hover {
  788. color: #409eff;
  789. }
  790. .el-icon {
  791. margin-right: 4px;
  792. }
  793. }
  794. }
  795. .header-title {
  796. flex: 1;
  797. font-size: 18px;
  798. font-weight: 600;
  799. color: #303133;
  800. text-align: center;
  801. }
  802. .header-right {
  803. flex: 1;
  804. }
  805. }
  806. // 表单容器
  807. .form-container {
  808. flex: 1;
  809. width: 100%;
  810. max-width: 800px;
  811. padding: 30px 20px;
  812. margin: 0 auto;
  813. :deep(.el-form) {
  814. .el-form-item {
  815. margin-bottom: 15px;
  816. .el-form-item__label {
  817. padding-bottom: 5px;
  818. font-size: 16px;
  819. font-weight: 600;
  820. color: #303133;
  821. }
  822. }
  823. }
  824. // 图片上传区域
  825. .upload-section {
  826. // 达到上传限制时隐藏上传组件(+ 按钮)
  827. &.at-upload-limit :deep(.el-upload--picture-card) {
  828. display: none;
  829. }
  830. :deep(.el-upload-list) {
  831. display: flex;
  832. flex-wrap: wrap;
  833. gap: 8px;
  834. }
  835. :deep(.el-upload--picture-card) {
  836. width: 138px;
  837. height: 138px;
  838. border: 1px dashed #dcdfe6;
  839. border-radius: 8px;
  840. transition: all 0.3s;
  841. &:hover {
  842. border-color: #409eff;
  843. }
  844. .upload-trigger {
  845. display: flex;
  846. flex-direction: column;
  847. align-items: center;
  848. justify-content: center;
  849. height: 100%;
  850. .upload-count {
  851. margin-top: 8px;
  852. font-size: 14px;
  853. color: #909399;
  854. }
  855. }
  856. }
  857. :deep(.el-upload-list--picture-card .el-upload-list__item) {
  858. width: 138px;
  859. height: 138px;
  860. margin: 0;
  861. border-radius: 8px;
  862. }
  863. // 自定义文件预览
  864. .upload-file-preview {
  865. position: relative;
  866. width: 100%;
  867. height: 100%;
  868. overflow: hidden;
  869. border-radius: 8px;
  870. .upload-video-thumb,
  871. .upload-image-thumb {
  872. width: 100%;
  873. height: 100%;
  874. object-fit: cover;
  875. }
  876. .upload-actions {
  877. position: absolute;
  878. top: 0;
  879. left: 0;
  880. z-index: 10;
  881. display: flex;
  882. gap: 8px;
  883. align-items: center;
  884. justify-content: center;
  885. width: 100%;
  886. height: 100%;
  887. background: rgb(0 0 0 / 50%);
  888. opacity: 0;
  889. transition: opacity 0.3s;
  890. .upload-action-item {
  891. display: flex;
  892. align-items: center;
  893. justify-content: center;
  894. width: 36px;
  895. height: 36px;
  896. color: #ffffff;
  897. cursor: pointer;
  898. background: rgb(0 0 0 / 50%);
  899. border-radius: 50%;
  900. transition: transform 0.2s;
  901. &:hover {
  902. transform: scale(1.1);
  903. }
  904. }
  905. }
  906. &:hover .upload-actions {
  907. opacity: 1;
  908. }
  909. }
  910. }
  911. // 输入框样式
  912. :deep(.el-input__inner),
  913. :deep(.el-textarea__inner) {
  914. border-radius: 8px;
  915. }
  916. :deep(.el-textarea__inner) {
  917. padding: 12px 15px;
  918. font-size: 15px;
  919. line-height: 1.6;
  920. }
  921. }
  922. // 底部操作按钮
  923. .footer-actions {
  924. display: flex;
  925. justify-content: center;
  926. .el-button {
  927. min-width: 150px;
  928. height: 45px;
  929. margin-top: 20px;
  930. font-size: 16px;
  931. border-radius: 8px;
  932. }
  933. .draft-btn {
  934. color: #606266;
  935. background: #f5f7fa;
  936. border-color: #dcdfe6;
  937. &:hover {
  938. color: #409eff;
  939. background: #ecf5ff;
  940. border-color: #409eff;
  941. }
  942. }
  943. .publish-btn {
  944. background: #409eff;
  945. border-color: #409eff;
  946. }
  947. }
  948. // 位置选择对话框
  949. :deep(.el-dialog) {
  950. border-radius: 12px;
  951. .location-list {
  952. max-height: 400px;
  953. margin-top: 16px;
  954. overflow-y: auto;
  955. .location-item {
  956. display: flex;
  957. gap: 12px;
  958. align-items: center;
  959. padding: 12px 16px;
  960. cursor: pointer;
  961. border-radius: 8px;
  962. transition: all 0.3s;
  963. &:hover {
  964. background: #f5f7fa;
  965. }
  966. .el-icon {
  967. font-size: 18px;
  968. color: #909399;
  969. }
  970. span {
  971. font-size: 15px;
  972. color: #303133;
  973. }
  974. }
  975. }
  976. }
  977. }
  978. // 响应式适配
  979. @media (width <= 768px) {
  980. .publish-dynamic-container {
  981. .form-container {
  982. padding: 20px 16px 100px;
  983. }
  984. .footer-actions {
  985. padding: 12px 16px;
  986. .el-button {
  987. min-width: 140px;
  988. height: 44px;
  989. font-size: 15px;
  990. }
  991. }
  992. }
  993. }
  994. </style>