publishDynamic.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634
  1. <template>
  2. <div class="publish-dynamic-container">
  3. <!-- 头部导航 -->
  4. <div class="header-bar">
  5. <div class="header-left">
  6. <el-button text @click="handleGoBack">
  7. <el-icon :size="20">
  8. <ArrowLeft />
  9. </el-icon>
  10. 返回
  11. </el-button>
  12. </div>
  13. <div class="header-title">发布动态</div>
  14. <div class="header-right" />
  15. </div>
  16. <!-- 表单内容 -->
  17. <div class="form-container">
  18. <el-form ref="formRef" :model="formData" :rules="rules" label-position="top">
  19. <!-- 图片上传 -->
  20. <el-form-item label="图片" prop="images">
  21. <div class="upload-section">
  22. <el-upload
  23. v-model:file-list="fileList"
  24. list-type="picture-card"
  25. :limit="20"
  26. :on-preview="handlePicturePreview"
  27. :on-remove="handleRemoveImage"
  28. :on-change="handleFileChange"
  29. :before-upload="beforeImageUpload"
  30. :http-request="handleImageUpload"
  31. accept="image/*"
  32. multiple
  33. class="dynamic-upload"
  34. >
  35. <div class="upload-trigger">
  36. <el-icon :size="32" color="#999">
  37. <Plus />
  38. </el-icon>
  39. <div class="upload-count">({{ fileList.length }}/20)</div>
  40. </div>
  41. </el-upload>
  42. </div>
  43. </el-form-item>
  44. <!-- 标题 -->
  45. <el-form-item label="标题" prop="title">
  46. <el-input v-model="formData.title" placeholder="请输入标题" maxlength="50" show-word-limit size="large" />
  47. </el-form-item>
  48. <!-- 正文 -->
  49. <el-form-item label="正文" prop="content">
  50. <el-input
  51. v-model="formData.content"
  52. type="textarea"
  53. placeholder="请输入正文"
  54. :rows="8"
  55. maxlength="500"
  56. show-word-limit
  57. />
  58. </el-form-item>
  59. <!-- 位置 -->
  60. <el-form-item label="位置" prop="location">
  61. <el-input v-model="formData.location" placeholder="请选择或许位置" size="large" readonly @click="handleSelectLocation">
  62. <template #suffix>
  63. <el-icon color="#999">
  64. <Location />
  65. </el-icon>
  66. </template>
  67. </el-input>
  68. </el-form-item>
  69. </el-form>
  70. </div>
  71. <!-- 底部操作按钮 -->
  72. <div class="footer-actions">
  73. <el-button size="large" class="draft-btn" @click="handleSaveDraft"> 保存草稿 </el-button>
  74. <el-button type="primary" size="large" class="publish-btn" :loading="publishing" @click="handlePublish"> 发布 </el-button>
  75. </div>
  76. <!-- 图片预览对话框 -->
  77. <el-dialog v-model="previewDialogVisible" width="800px" append-to-body>
  78. <img :src="previewImageUrl" alt="预览图片" style="width: 100%" />
  79. </el-dialog>
  80. <!-- 位置选择对话框 -->
  81. <el-dialog v-model="locationDialogVisible" title="选择位置" width="500px" append-to-body>
  82. <el-input v-model="locationSearch" placeholder="搜索位置" clearable @input="handleSearchLocation">
  83. <template #prefix>
  84. <el-icon>
  85. <Search />
  86. </el-icon>
  87. </template>
  88. </el-input>
  89. <div class="location-list">
  90. <div v-for="location in locationList" :key="location.id" class="location-item" @click="handleChooseLocation(location)">
  91. <el-icon>
  92. <Location />
  93. </el-icon>
  94. <span>{{ location.name }}</span>
  95. </div>
  96. <el-empty v-if="locationList.length === 0" description="暂无位置信息" />
  97. </div>
  98. </el-dialog>
  99. </div>
  100. </template>
  101. <script setup lang="ts">
  102. import { ref, reactive, onMounted } from "vue";
  103. import { useRouter } from "vue-router";
  104. import { ElMessage, ElMessageBox } from "element-plus";
  105. import { ArrowLeft, Plus, Location, Search } from "@element-plus/icons-vue";
  106. import type { FormInstance, FormRules, UploadUserFile, UploadFile } from "element-plus";
  107. // import { publishDynamic, saveDraft, uploadDynamicImage } from "@/api/modules/dynamicManagement";
  108. const router = useRouter();
  109. // 接口定义
  110. interface FormData {
  111. title: string;
  112. content: string;
  113. images: string[];
  114. location: string;
  115. locationId?: string;
  116. }
  117. interface LocationItem {
  118. id: string;
  119. name: string;
  120. address: string;
  121. }
  122. // 响应式数据
  123. const formRef = ref<FormInstance>();
  124. const fileList = ref<UploadUserFile[]>([]);
  125. const previewDialogVisible = ref(false);
  126. const previewImageUrl = ref("");
  127. const locationDialogVisible = ref(false);
  128. const locationSearch = ref("");
  129. const locationList = ref<LocationItem[]>([]);
  130. const publishing = ref(false);
  131. const pendingUploadFiles = ref<UploadFile[]>([]);
  132. const uploading = ref(false);
  133. // 表单数据
  134. const formData = reactive<FormData>({
  135. title: "",
  136. content: "",
  137. images: [],
  138. location: ""
  139. });
  140. // 表单验证规则
  141. const rules = reactive<FormRules<FormData>>({
  142. title: [{ required: true, message: "请输入标题", trigger: "blur" }],
  143. content: [{ required: true, message: "请输入正文", trigger: "blur" }]
  144. });
  145. // 返回上一页
  146. const handleGoBack = async () => {
  147. // 检查是否有未保存的内容
  148. if (formData.title || formData.content || fileList.value.length > 0) {
  149. try {
  150. await ElMessageBox.confirm("当前有未保存的内容,确定要离开吗?", "提示", {
  151. confirmButtonText: "确定",
  152. cancelButtonText: "取消",
  153. type: "warning"
  154. });
  155. router.back();
  156. } catch {
  157. // 用户取消
  158. }
  159. } else {
  160. router.back();
  161. }
  162. };
  163. // 文件改变
  164. const handleFileChange = (uploadFile: UploadFile, uploadFiles: UploadFile[]) => {
  165. // 验证文件
  166. if (uploadFile.raw) {
  167. const isImage = uploadFile.raw.type.startsWith("image/");
  168. if (!isImage) {
  169. const index = uploadFiles.findIndex(f => f.uid === uploadFile.uid);
  170. if (index > -1) {
  171. uploadFiles.splice(index, 1);
  172. }
  173. ElMessage.warning("只能上传图片文件");
  174. return;
  175. }
  176. const isLt20M = uploadFile.raw.size / 1024 / 1024 < 20;
  177. if (!isLt20M) {
  178. const index = uploadFiles.findIndex(f => f.uid === uploadFile.uid);
  179. if (index > -1) {
  180. uploadFiles.splice(index, 1);
  181. }
  182. ElMessage.warning("图片大小不能超过 20MB");
  183. return;
  184. }
  185. }
  186. // 添加到待上传队列
  187. const existingIndex = fileList.value.findIndex(f => f.uid === uploadFile.uid);
  188. if (existingIndex === -1 && uploadFile.status) {
  189. fileList.value.push(uploadFile as UploadUserFile);
  190. }
  191. const readyFiles = fileList.value.filter(file => file.status === "ready");
  192. if (readyFiles.length) {
  193. readyFiles.forEach(file => {
  194. if (!pendingUploadFiles.value.some(item => item.uid === file.uid)) {
  195. pendingUploadFiles.value.push(file as UploadFile);
  196. }
  197. });
  198. }
  199. processUploadQueue();
  200. };
  201. // 处理上传队列
  202. const processUploadQueue = async () => {
  203. if (uploading.value || pendingUploadFiles.value.length === 0) {
  204. return;
  205. }
  206. const file = pendingUploadFiles.value.shift();
  207. if (file) {
  208. await uploadSingleFile(file);
  209. processUploadQueue();
  210. }
  211. };
  212. // 单个文件上传
  213. const uploadSingleFile = async (file: UploadFile) => {
  214. if (!file.raw) {
  215. return;
  216. }
  217. const rawFile = file.raw as File;
  218. const uploadFormData = new FormData();
  219. uploadFormData.append("file", rawFile);
  220. file.status = "uploading";
  221. file.percentage = 0;
  222. uploading.value = true;
  223. try {
  224. // TODO: 集成真实上传接口时,取消下面的注释
  225. // const result = await uploadDynamicImage(uploadFormData);
  226. // if (result?.code === 200 && result.data) {
  227. // const imageUrl = result.data.url;
  228. // file.status = "success";
  229. // file.percentage = 100;
  230. // file.url = imageUrl;
  231. // file.response = { url: imageUrl };
  232. // if (!formData.images.includes(imageUrl)) {
  233. // formData.images.push(imageUrl);
  234. // }
  235. // } else {
  236. // throw new Error(result?.msg || "图片上传失败");
  237. // }
  238. // 临时方案:使用 FileReader 模拟上传
  239. await new Promise<void>((resolve, reject) => {
  240. const reader = new FileReader();
  241. reader.onload = e => {
  242. const imageUrl = e.target?.result as string;
  243. file.status = "success";
  244. file.percentage = 100;
  245. file.url = imageUrl;
  246. file.response = { url: imageUrl };
  247. if (!formData.images.includes(imageUrl)) {
  248. formData.images.push(imageUrl);
  249. }
  250. resolve();
  251. };
  252. reader.onerror = () => reject(new Error("读取文件失败"));
  253. reader.readAsDataURL(rawFile);
  254. });
  255. } catch (error: any) {
  256. file.status = "fail";
  257. if (file.url && file.url.startsWith("blob:")) {
  258. URL.revokeObjectURL(file.url);
  259. }
  260. const index = fileList.value.findIndex(f => f.uid === file.uid);
  261. if (index > -1) {
  262. fileList.value.splice(index, 1);
  263. }
  264. ElMessage.error(error?.message || "图片上传失败");
  265. } finally {
  266. uploading.value = false;
  267. fileList.value = [...fileList.value];
  268. }
  269. };
  270. // 图片上传前验证
  271. const beforeImageUpload = (file: File) => {
  272. const isImage = file.type.startsWith("image/");
  273. const isLt20M = file.size / 1024 / 1024 < 20;
  274. if (!isImage) {
  275. ElMessage.error("只能上传图片文件!");
  276. return false;
  277. }
  278. if (!isLt20M) {
  279. ElMessage.error("图片大小不能超过 20MB!");
  280. return false;
  281. }
  282. return true;
  283. };
  284. // 自定义上传
  285. const handleImageUpload = async (options: any) => {
  286. // 上传逻辑已在 uploadSingleFile 中处理
  287. return;
  288. };
  289. // 图片预览
  290. const handlePicturePreview = (uploadFile: UploadUserFile) => {
  291. previewImageUrl.value = uploadFile.url!;
  292. previewDialogVisible.value = true;
  293. };
  294. // 移除图片
  295. const handleRemoveImage = (uploadFile: UploadUserFile, uploadFiles: UploadUserFile[]) => {
  296. const index = formData.images.findIndex(url => url === uploadFile.url);
  297. if (index > -1) {
  298. formData.images.splice(index, 1);
  299. }
  300. };
  301. // 选择位置
  302. const handleSelectLocation = () => {
  303. locationDialogVisible.value = true;
  304. // 加载位置列表
  305. loadLocationList();
  306. };
  307. // 搜索位置
  308. const handleSearchLocation = () => {
  309. // TODO: 实现位置搜索
  310. loadLocationList(locationSearch.value);
  311. };
  312. // 加载位置列表
  313. const loadLocationList = (keyword?: string) => {
  314. // TODO: 集成真实接口
  315. // 模拟数据
  316. const mockLocations: LocationItem[] = [
  317. { id: "1", name: "天安门广场", address: "北京市东城区" },
  318. { id: "2", name: "故宫博物院", address: "北京市东城区景山前街4号" },
  319. { id: "3", name: "颐和园", address: "北京市海淀区新建宫门路19号" },
  320. { id: "4", name: "长城", address: "北京市延庆区" },
  321. { id: "5", name: "鸟巢", address: "北京市朝阳区国家体育场南路1号" }
  322. ];
  323. if (keyword) {
  324. locationList.value = mockLocations.filter(loc => loc.name.includes(keyword) || loc.address.includes(keyword));
  325. } else {
  326. locationList.value = mockLocations;
  327. }
  328. };
  329. // 选择位置
  330. const handleChooseLocation = (location: LocationItem) => {
  331. formData.location = location.name;
  332. formData.locationId = location.id;
  333. locationDialogVisible.value = false;
  334. };
  335. // 保存草稿
  336. const handleSaveDraft = async () => {
  337. if (!formData.title && !formData.content && fileList.value.length === 0) {
  338. ElMessage.warning("请至少填写标题或正文");
  339. return;
  340. }
  341. try {
  342. // TODO: 集成真实接口时,取消下面的注释
  343. // await saveDraft({
  344. // title: formData.title,
  345. // content: formData.content,
  346. // images: formData.images,
  347. // location: formData.location,
  348. // locationId: formData.locationId
  349. // });
  350. ElMessage.success("草稿保存成功");
  351. router.back();
  352. } catch (error) {
  353. ElMessage.error("保存草稿失败");
  354. }
  355. };
  356. // 发布动态
  357. const handlePublish = async () => {
  358. if (!formRef.value) return;
  359. await formRef.value.validate(async (valid: boolean) => {
  360. if (valid) {
  361. // 检查是否有图片正在上传
  362. if (uploading.value || pendingUploadFiles.value.length > 0) {
  363. ElMessage.warning("图片正在上传中,请稍候...");
  364. return;
  365. }
  366. publishing.value = true;
  367. try {
  368. // TODO: 集成真实接口时,取消下面的注释
  369. // await publishDynamic({
  370. // title: formData.title,
  371. // content: formData.content,
  372. // images: formData.images,
  373. // location: formData.location,
  374. // locationId: formData.locationId
  375. // });
  376. // 模拟发布延迟
  377. await new Promise(resolve => setTimeout(resolve, 1000));
  378. ElMessage.success("发布成功");
  379. router.back();
  380. } catch (error) {
  381. ElMessage.error("发布失败");
  382. } finally {
  383. publishing.value = false;
  384. }
  385. }
  386. });
  387. };
  388. // 初始化
  389. onMounted(() => {
  390. // 可以在这里加载草稿数据
  391. });
  392. </script>
  393. <style scoped lang="scss">
  394. .publish-dynamic-container {
  395. display: flex;
  396. flex-direction: column;
  397. min-height: 100vh;
  398. background: #f5f7fa;
  399. // 头部导航
  400. .header-bar {
  401. position: sticky;
  402. top: 0;
  403. z-index: 100;
  404. display: flex;
  405. align-items: center;
  406. justify-content: space-between;
  407. height: 60px;
  408. padding: 0 20px;
  409. background: #ffffff;
  410. box-shadow: 0 2px 4px rgb(0 0 0 / 5%);
  411. .header-left {
  412. flex: 1;
  413. :deep(.el-button) {
  414. padding: 8px 0;
  415. font-size: 16px;
  416. color: #606266;
  417. &:hover {
  418. color: #409eff;
  419. }
  420. .el-icon {
  421. margin-right: 4px;
  422. }
  423. }
  424. }
  425. .header-title {
  426. flex: 1;
  427. font-size: 18px;
  428. font-weight: 600;
  429. color: #303133;
  430. text-align: center;
  431. }
  432. .header-right {
  433. flex: 1;
  434. }
  435. }
  436. // 表单容器
  437. .form-container {
  438. flex: 1;
  439. width: 100%;
  440. max-width: 800px;
  441. padding: 30px 20px 100px;
  442. margin: 0 auto;
  443. :deep(.el-form) {
  444. .el-form-item {
  445. margin-bottom: 30px;
  446. .el-form-item__label {
  447. padding-bottom: 12px;
  448. font-size: 16px;
  449. font-weight: 600;
  450. color: #303133;
  451. }
  452. }
  453. }
  454. // 图片上传区域
  455. .upload-section {
  456. :deep(.el-upload-list) {
  457. display: flex;
  458. flex-wrap: wrap;
  459. gap: 8px;
  460. }
  461. :deep(.el-upload--picture-card) {
  462. width: 148px;
  463. height: 148px;
  464. border: 2px dashed #dcdfe6;
  465. border-radius: 8px;
  466. transition: all 0.3s;
  467. &:hover {
  468. border-color: #409eff;
  469. }
  470. .upload-trigger {
  471. display: flex;
  472. flex-direction: column;
  473. align-items: center;
  474. justify-content: center;
  475. height: 100%;
  476. .upload-count {
  477. margin-top: 8px;
  478. font-size: 14px;
  479. color: #909399;
  480. }
  481. }
  482. }
  483. :deep(.el-upload-list--picture-card .el-upload-list__item) {
  484. width: 148px;
  485. height: 148px;
  486. margin: 0;
  487. border-radius: 8px;
  488. }
  489. }
  490. // 输入框样式
  491. :deep(.el-input__inner),
  492. :deep(.el-textarea__inner) {
  493. border-radius: 8px;
  494. }
  495. :deep(.el-textarea__inner) {
  496. padding: 12px 15px;
  497. font-size: 15px;
  498. line-height: 1.6;
  499. }
  500. }
  501. // 底部操作按钮
  502. .footer-actions {
  503. position: fixed;
  504. right: 0;
  505. bottom: 0;
  506. left: 0;
  507. z-index: 99;
  508. display: flex;
  509. gap: 16px;
  510. justify-content: center;
  511. padding: 16px 20px;
  512. background: #ffffff;
  513. box-shadow: 0 -2px 8px rgb(0 0 0 / 8%);
  514. .el-button {
  515. min-width: 180px;
  516. height: 48px;
  517. font-size: 16px;
  518. border-radius: 8px;
  519. }
  520. .draft-btn {
  521. color: #606266;
  522. background: #f5f7fa;
  523. border-color: #dcdfe6;
  524. &:hover {
  525. color: #409eff;
  526. background: #ecf5ff;
  527. border-color: #409eff;
  528. }
  529. }
  530. .publish-btn {
  531. background: #409eff;
  532. border-color: #409eff;
  533. }
  534. }
  535. // 位置选择对话框
  536. :deep(.el-dialog) {
  537. border-radius: 12px;
  538. .location-list {
  539. max-height: 400px;
  540. margin-top: 16px;
  541. overflow-y: auto;
  542. .location-item {
  543. display: flex;
  544. gap: 12px;
  545. align-items: center;
  546. padding: 12px 16px;
  547. cursor: pointer;
  548. border-radius: 8px;
  549. transition: all 0.3s;
  550. &:hover {
  551. background: #f5f7fa;
  552. }
  553. .el-icon {
  554. font-size: 18px;
  555. color: #909399;
  556. }
  557. span {
  558. font-size: 15px;
  559. color: #303133;
  560. }
  561. }
  562. }
  563. }
  564. }
  565. // 响应式适配
  566. @media (width <= 768px) {
  567. .publish-dynamic-container {
  568. .form-container {
  569. padding: 20px 16px 100px;
  570. }
  571. .footer-actions {
  572. padding: 12px 16px;
  573. .el-button {
  574. min-width: 140px;
  575. height: 44px;
  576. font-size: 15px;
  577. }
  578. }
  579. }
  580. }
  581. </style>