publishDynamic.vue 32 KB

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