index.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400
  1. <template>
  2. <doc-alert title="公众号菜单" url="https://doc.iocoder.cn/mp/menu/" />
  3. <!-- 搜索工作栏 -->
  4. <ContentWrap>
  5. <el-form class="-mb-15px" ref="queryFormRef" :inline="true" label-width="68px">
  6. <el-form-item label="公众号" prop="accountId">
  7. <WxAccountSelect @change="onAccountChanged" />
  8. </el-form-item>
  9. </el-form>
  10. </ContentWrap>
  11. <ContentWrap>
  12. <div class="public-account-management clearfix" v-loading="loading">
  13. <!--左边配置菜单-->
  14. <div class="left">
  15. <div class="weixin-hd">
  16. <div class="weixin-title">{{ accountName }}</div>
  17. </div>
  18. <div class="weixin-menu clearfix">
  19. <MenuPreviewer
  20. v-model="menuList"
  21. :account-id="accountId"
  22. :active-index="activeIndex"
  23. :parent-index="parentIndex"
  24. @menu-clicked="(parent, x) => menuClicked(parent, x)"
  25. @submenu-clicked="(child, x, y) => subMenuClicked(child, x, y)"
  26. />
  27. </div>
  28. <div class="save_div">
  29. <el-button class="save_btn" type="success" @click="onSave" v-hasPermi="['mp:menu:save']"
  30. >保存并发布菜单</el-button
  31. >
  32. <el-button class="save_btn" type="danger" @click="onClear" v-hasPermi="['mp:menu:delete']"
  33. >清空菜单</el-button
  34. >
  35. </div>
  36. </div>
  37. <!--右边配置-->
  38. <div class="right" v-if="showRightPanel">
  39. <MenuEditor
  40. :account-id="accountId"
  41. :is-parent="isParent"
  42. v-model="activeMenu"
  43. @delete="onDeleteMenu"
  44. />
  45. </div>
  46. <!-- 一进页面就显示的默认页面,当点击左边按钮的时候,就不显示了-->
  47. <div v-else class="right">
  48. <p>请选择菜单配置</p>
  49. </div>
  50. </div>
  51. </ContentWrap>
  52. </template>
  53. <script lang="ts" setup name="MpMenu">
  54. import WxAccountSelect from '@/views/mp/components/wx-account-select/main.vue'
  55. import MenuEditor from './components/MenuEditor.vue'
  56. import MenuPreviewer from './components/MenuPreviewer.vue'
  57. import * as MpMenuApi from '@/api/mp/menu'
  58. import * as UtilsTree from '@/utils/tree'
  59. import { RawMenu, Menu } from './components/types'
  60. const message = useMessage() // 消息
  61. const MENU_NOT_SELECTED = '__MENU_NOT_SELECTED__'
  62. // ======================== 列表查询 ========================
  63. const loading = ref(false) // 遮罩层
  64. const accountId = ref<number | undefined>()
  65. const accountName = ref<string | undefined>('')
  66. const menuList = ref<Menu[]>([])
  67. // ======================== 菜单操作 ========================
  68. // 当前选中菜单编码:
  69. // * 一级('x')
  70. // * 二级('x-y')
  71. // * 未选中(MENU_NOT_SELECTED)
  72. const activeIndex = ref<string>(MENU_NOT_SELECTED)
  73. // 二级菜单显示标志: 归属的一级菜单index
  74. // * 未初始化:-1
  75. // * 初始化:x
  76. const parentIndex = ref(-1)
  77. // ======================== 菜单编辑 ========================
  78. const showRightPanel = ref(false) // 右边配置显示默认详情还是配置详情
  79. const isParent = ref<boolean>(true) // 是否一级菜单,控制MenuEditor中name字段长度
  80. const activeMenu = ref<Menu>({}) // 选中菜单,MenuEditor的modelValue
  81. // 一些临时值放在这里进行判断,如果放在 activeMenu,由于引用关系,menu 也会多了多余的参数
  82. enum Level {
  83. Undefined = '0',
  84. Parent = '1',
  85. Child = '2'
  86. }
  87. const tempSelfObj = ref<{
  88. grand: Level
  89. x: number
  90. y: number
  91. }>({
  92. grand: Level.Undefined,
  93. x: 0,
  94. y: 0
  95. })
  96. const dialogNewsVisible = ref(false) // 跳转图文时的素材选择弹窗
  97. /** 侦听公众号变化 **/
  98. const onAccountChanged = (id?: number, name?: string) => {
  99. accountId.value = id
  100. accountName.value = name
  101. getList()
  102. }
  103. /** 查询并转换菜单 **/
  104. const getList = async () => {
  105. loading.value = false
  106. try {
  107. const data = await MpMenuApi.getMenuList(accountId.value)
  108. const menuData = menuListToFrontend(data)
  109. menuList.value = UtilsTree.handleTree(menuData, 'id')
  110. } finally {
  111. loading.value = false
  112. }
  113. }
  114. /** 搜索按钮操作 */
  115. const handleQuery = () => {
  116. resetForm()
  117. getList()
  118. }
  119. // 将后端返回的 menuList,转换成前端的 menuList
  120. const menuListToFrontend = (list: any[]) => {
  121. if (!list) return []
  122. const result: RawMenu[] = []
  123. list.forEach((item: RawMenu) => {
  124. const menu: any = {
  125. ...item
  126. }
  127. menu.reply = {
  128. type: item.replyMessageType,
  129. accountId: item.accountId,
  130. content: item.replyContent,
  131. mediaId: item.replyMediaId,
  132. url: item.replyMediaUrl,
  133. title: item.replyTitle,
  134. description: item.replyDescription,
  135. thumbMediaId: item.replyThumbMediaId,
  136. thumbMediaUrl: item.replyThumbMediaUrl,
  137. articles: item.replyArticles,
  138. musicUrl: item.replyMusicUrl,
  139. hqMusicUrl: item.replyHqMusicUrl
  140. }
  141. result.push(menu as RawMenu)
  142. })
  143. return result
  144. }
  145. // 重置表单,清空表单数据
  146. const resetForm = () => {
  147. // 菜单操作
  148. activeIndex.value = MENU_NOT_SELECTED
  149. parentIndex.value = -1
  150. // 菜单编辑
  151. showRightPanel.value = false
  152. activeMenu.value = {}
  153. tempSelfObj.value = { grand: Level.Undefined, x: 0, y: 0 }
  154. dialogNewsVisible.value = false
  155. }
  156. // ======================== 菜单操作 ========================
  157. // 一级菜单点击事件
  158. const menuClicked = (parent: Menu, x: number) => {
  159. // 右侧的表单相关
  160. showRightPanel.value = true // 右边菜单
  161. activeMenu.value = parent // 这个如果放在顶部,flag 会没有。因为重新赋值了。
  162. tempSelfObj.value.grand = Level.Parent // 表示一级菜单
  163. tempSelfObj.value.x = x // 表示一级菜单索引
  164. isParent.value = true
  165. // 左侧的选中
  166. activeIndex.value = `${x}` // 菜单选中样式
  167. parentIndex.value = x // 二级菜单显示标志
  168. }
  169. // 二级菜单点击事件
  170. const subMenuClicked = (child: Menu, x: number, y: number) => {
  171. // 右侧的表单相关
  172. showRightPanel.value = true // 右边菜单
  173. activeMenu.value = child // 将点击的数据放到临时变量,对象有引用作用
  174. tempSelfObj.value.grand = Level.Child // 表示二级菜单
  175. tempSelfObj.value.x = x // 表示一级菜单索引
  176. tempSelfObj.value.y = y // 表示二级菜单索引
  177. isParent.value = false
  178. // 左侧的选中
  179. activeIndex.value = `${x}-${y}`
  180. }
  181. // 删除当前菜单
  182. const onDeleteMenu = async () => {
  183. try {
  184. await message.confirm('确定要删除吗?')
  185. if (tempSelfObj.value.grand === Level.Parent) {
  186. // 一级菜单的删除方法
  187. menuList.value.splice(tempSelfObj.value.x, 1)
  188. } else if (tempSelfObj.value.grand === Level.Child) {
  189. // 二级菜单的删除方法
  190. menuList.value[tempSelfObj.value.x].children?.splice(tempSelfObj.value.y, 1)
  191. }
  192. // 提示
  193. message.notifySuccess('删除成功')
  194. // 处理菜单的选中
  195. activeMenu.value = {}
  196. showRightPanel.value = false
  197. activeIndex.value = MENU_NOT_SELECTED
  198. } catch {}
  199. }
  200. // ======================== 菜单编辑 ========================
  201. const onSave = async () => {
  202. try {
  203. await message.confirm('确定要保存吗?')
  204. loading.value = true
  205. await MpMenuApi.saveMenu(accountId.value, menuListToBackend())
  206. getList()
  207. message.notifySuccess('发布成功')
  208. } finally {
  209. loading.value = false
  210. }
  211. }
  212. const onClear = async () => {
  213. try {
  214. await message.confirm('确定要删除吗?')
  215. loading.value = true
  216. await MpMenuApi.deleteMenu(accountId.value)
  217. handleQuery()
  218. message.notifySuccess('清空成功')
  219. } finally {
  220. loading.value = false
  221. }
  222. }
  223. // 将前端的 menuList,转换成后端接收的 menuList
  224. const menuListToBackend = () => {
  225. const result: any[] = []
  226. menuList.value.forEach((item) => {
  227. const menu = menuToBackend(item)
  228. result.push(menu)
  229. // 处理子菜单
  230. if (!item.children || item.children.length <= 0) {
  231. return
  232. }
  233. menu.children = []
  234. item.children.forEach((subItem) => {
  235. menu.children.push(menuToBackend(subItem))
  236. })
  237. })
  238. return result
  239. }
  240. // 将前端的 menu,转换成后端接收的 menu
  241. // TODO: @芋艿,需要根据后台API删除不需要的字段
  242. const menuToBackend = (menu: any) => {
  243. let result = {
  244. ...menu,
  245. children: undefined, // 不处理子节点
  246. reply: undefined // 稍后复制
  247. }
  248. result.replyMessageType = menu.reply.type
  249. result.replyContent = menu.reply.content
  250. result.replyMediaId = menu.reply.mediaId
  251. result.replyMediaUrl = menu.reply.url
  252. result.replyTitle = menu.reply.title
  253. result.replyDescription = menu.reply.description
  254. result.replyThumbMediaId = menu.reply.thumbMediaId
  255. result.replyThumbMediaUrl = menu.reply.thumbMediaUrl
  256. result.replyArticles = menu.reply.articles
  257. result.replyMusicUrl = menu.reply.musicUrl
  258. result.replyHqMusicUrl = menu.reply.hqMusicUrl
  259. return result
  260. }
  261. </script>
  262. <!--本组件样式-->
  263. <style lang="scss" scoped="scoped">
  264. /* 公共颜色变量 */
  265. .clearfix {
  266. *zoom: 1;
  267. }
  268. .clearfix::after {
  269. display: table;
  270. clear: both;
  271. content: '';
  272. }
  273. div {
  274. text-align: left;
  275. }
  276. .weixin-hd {
  277. position: relative;
  278. bottom: 426px;
  279. left: 0;
  280. width: 300px;
  281. height: 64px;
  282. color: #fff;
  283. text-align: center;
  284. background: transparent url('./assets/menu_head.png') no-repeat 0 0;
  285. background-position: 0 0;
  286. background-size: 100%;
  287. }
  288. .weixin-title {
  289. position: absolute;
  290. top: 33px;
  291. left: 0;
  292. width: 100%;
  293. font-size: 14px;
  294. color: #fff;
  295. text-align: center;
  296. }
  297. .weixin-menu {
  298. padding-left: 43px;
  299. font-size: 12px;
  300. background: transparent url('./assets/menu_foot.png') no-repeat 0 0;
  301. }
  302. .public-account-management {
  303. width: 1200px;
  304. // min-width: 1200px;
  305. margin: 0 auto;
  306. .left {
  307. position: relative;
  308. display: inline-block;
  309. float: left;
  310. width: 350px;
  311. height: 715px;
  312. padding: 518px 25px 88px;
  313. background: url('./assets/iphone_backImg.png') no-repeat;
  314. background-size: 100% auto;
  315. box-sizing: border-box;
  316. .save_div {
  317. margin-top: 15px;
  318. text-align: center;
  319. .save_btn {
  320. bottom: 20px;
  321. left: 100px;
  322. }
  323. }
  324. }
  325. /* 右边菜单内容 */
  326. .right {
  327. float: left;
  328. width: 63%;
  329. padding: 20px;
  330. margin-left: 20px;
  331. background-color: #e8e7e7;
  332. box-sizing: border-box;
  333. box-sizing: border-box;
  334. }
  335. }
  336. </style>
  337. <!--素材样式-->
  338. <style lang="scss" scoped>
  339. .pagination {
  340. margin-right: 25px;
  341. text-align: right;
  342. }
  343. .select-item {
  344. width: 280px;
  345. padding: 10px;
  346. margin: 0 auto 10px;
  347. border: 1px solid #eaeaea;
  348. }
  349. .ope-row {
  350. padding-top: 10px;
  351. text-align: center;
  352. }
  353. .item-name {
  354. overflow: hidden;
  355. font-size: 12px;
  356. text-align: center;
  357. text-overflow: ellipsis;
  358. white-space: nowrap;
  359. }
  360. </style>