You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

429 regels
21 KiB

  1. {% extends "../layout.html" %}
  2. {% block title %}栏目管理{% endblock %}
  3. {% block content %}
  4. <div id="columnApp">
  5. <el-card shadow="never">
  6. <template #header>
  7. <div class="flex items-center justify-between">
  8. <span class="font-medium">栏目管理</span>
  9. <div class="flex gap-2">
  10. <el-button @click="toggleAllTree">展开/折叠全部</el-button>
  11. <el-button type="primary" @click="showAddModal(0, null)">+ 新增顶级栏目</el-button>
  12. </div>
  13. </div>
  14. </template>
  15. <el-tree ref="treeRef" :data="treeData" node-key="id" default-expand-all :expand-on-click-node="false" v-loading="loading">
  16. <template #default="{ node, data }">
  17. <div class="tree-node flex items-center justify-between w-full py-2 pr-4">
  18. <div class="flex items-center gap-2">
  19. <el-icon v-if="data.icon && data.parent_id === 0" :size="18"><component :is="'el-icon-' + data.icon"></component></el-icon>
  20. <span v-else class="text-base">${ data.parent_id === 0 ? '📁' : '📄' }</span>
  21. <span class="font-medium">${ data.name }</span>
  22. <el-tag v-if="data.key" size="small" type="info">${ data.key }</el-tag>
  23. <el-tag v-if="data.parent_id === 0 && data.is_single_page" size="small">单页面</el-tag>
  24. <el-tag v-if="data.type" :type="typeTagMap[data.type]" size="small">${ typeMap[data.type] }</el-tag>
  25. <el-tag v-if="!data.visible" type="info" size="small">隐藏</el-tag>
  26. </div>
  27. <div class="flex items-center gap-1">
  28. <el-button type="primary" link size="small" @click.stop="editItem(data)">编辑</el-button>
  29. <el-button v-if="data.parent_id === 0" type="primary" link size="small" @click.stop="showAddModal(data.id, data)">+ 子栏目</el-button>
  30. <el-button v-if="data.type === 'form'" type="warning" link size="small" @click.stop="openFormConfig(data)">表单配置</el-button>
  31. <el-button v-if="data.parent_id !== 0" type="danger" link size="small" @click.stop="deleteItem(data)">删除</el-button>
  32. </div>
  33. </div>
  34. </template>
  35. </el-tree>
  36. </el-card>
  37. <!-- 新增/编辑弹窗 -->
  38. <el-dialog v-model="dialogVisible" :title="dialogTitle" width="560px" destroy-on-close>
  39. <el-tabs v-model="activeTab" v-if="showSeoTab">
  40. <el-tab-pane label="基本信息" name="basic">
  41. <el-form :model="form" label-width="100px">
  42. <el-form-item label="栏目名称" required>
  43. <el-input v-model="form.name" placeholder="请输入栏目名称"></el-input>
  44. </el-form-item>
  45. <el-form-item label="栏目标识">
  46. <el-input v-model="form.key" placeholder="如: about-us(用于路由)"></el-input>
  47. </el-form-item>
  48. <el-form-item label="图标" v-if="form.parent_id === 0">
  49. <div class="flex items-center gap-2">
  50. <el-button @click="iconPickerVisible = true">
  51. <el-icon v-if="form.icon"><component :is="'el-icon-' + form.icon"></component></el-icon>
  52. <span v-else>选择图标</span>
  53. </el-button>
  54. <span v-if="form.icon" class="text-gray-500">${ form.icon }</span>
  55. <el-button v-if="form.icon" link type="danger" @click="form.icon = ''">清除</el-button>
  56. </div>
  57. </el-form-item>
  58. <el-form-item label="栏目类型" v-if="form.parent_id === 0">
  59. <el-radio-group v-model="form.is_single_page">
  60. <el-radio :value="1">单页面(二级为模块)</el-radio>
  61. <el-radio :value="0">多页面(二级为独立页面)</el-radio>
  62. </el-radio-group>
  63. </el-form-item>
  64. <el-form-item label="内容类型">
  65. <el-select v-model="form.type" placeholder="无" style="width:100%;" :teleported="false">
  66. <el-option label="无" value=""></el-option>
  67. <el-option label="图文列表" value="article"></el-option>
  68. <el-option label="图片列表" value="image"></el-option>
  69. <el-option label="文字列表" value="text"></el-option>
  70. <el-option label="单页" value="page"></el-option>
  71. <el-option label="人员列表" value="person"></el-option>
  72. <el-option label="自定义表单" value="form"></el-option>
  73. <el-option label="捐赠收支数据" value="donation"></el-option>
  74. <el-option label="岗位管理" value="job"></el-option>
  75. <el-option label="轮播图" value="banner"></el-option>
  76. </el-select>
  77. </el-form-item>
  78. <el-form-item label="排序">
  79. <el-input-number v-model="form.sort" :min="1" style="width:120px;"></el-input-number>
  80. </el-form-item>
  81. <el-form-item label="显示状态">
  82. <el-switch v-model="form.visible" :active-value="1" :inactive-value="0"></el-switch>
  83. </el-form-item>
  84. <el-form-item label="外部链接">
  85. <el-input v-model="form.link" placeholder="留空则使用系统页面"></el-input>
  86. </el-form-item>
  87. </el-form>
  88. </el-tab-pane>
  89. <el-tab-pane label="SEO配置" name="seo"></el-tab-pane>
  90. <el-form :model="form" label-width="120px">
  91. <el-form-item label="页面标题">
  92. <el-input v-model="form.seo_title" placeholder="用于浏览器标签"></el-input>
  93. </el-form-item>
  94. <el-form-item label="关键词">
  95. <el-input v-model="form.seo_keywords" placeholder="多个关键词用逗号分隔"></el-input>
  96. </el-form-item>
  97. <el-form-item label="描述">
  98. <el-input v-model="form.seo_description" type="textarea" :rows="3"></el-input>
  99. </el-form-item>
  100. <el-form-item label="URL别名">
  101. <el-input v-model="form.slug" placeholder="如: about-us"></el-input>
  102. </el-form-item>
  103. </el-form>
  104. </el-tab-pane>
  105. </el-tabs>
  106. <!-- 无SEO配置时直接显示基本信息 -->
  107. <el-form v-if="!showSeoTab" :model="form" label-width="100px">
  108. <el-form-item label="栏目名称" required>
  109. <el-input v-model="form.name" placeholder="请输入栏目名称"></el-input>
  110. </el-form-item>
  111. <el-form-item label="栏目标识">
  112. <el-input v-model="form.key" placeholder="如: about-us(用于路由)"></el-input>
  113. </el-form-item>
  114. <el-form-item label="图标" v-if="form.parent_id === 0">
  115. <div class="flex items-center gap-2">
  116. <el-button @click="iconPickerVisible = true">
  117. <el-icon v-if="form.icon"><component :is="'el-icon-' + form.icon"></component></el-icon>
  118. <span v-else>选择图标</span>
  119. </el-button>
  120. <span v-if="form.icon" class="text-gray-500">${ form.icon }</span>
  121. <el-button v-if="form.icon" link type="danger" @click="form.icon = ''">清除</el-button>
  122. </div>
  123. </el-form-item>
  124. <el-form-item label="栏目类型" v-if="form.parent_id === 0">
  125. <el-radio-group v-model="form.is_single_page">
  126. <el-radio :value="1">单页面(二级为模块)</el-radio>
  127. <el-radio :value="0">多页面(二级为独立页面)</el-radio>
  128. </el-radio-group>
  129. </el-form-item>
  130. <el-form-item label="内容类型">
  131. <el-select v-model="form.type" placeholder="无" style="width:100%;" :teleported="false">
  132. <el-option label="无" value=""></el-option>
  133. <el-option label="图文列表" value="article"></el-option>
  134. <el-option label="图片列表" value="image"></el-option>
  135. <el-option label="文字列表" value="text"></el-option>
  136. <el-option label="单页" value="page"></el-option>
  137. <el-option label="人员列表" value="person"></el-option>
  138. <el-option label="自定义表单" value="form"></el-option>
  139. <el-option label="捐赠收支数据" value="donation"></el-option>
  140. <el-option label="岗位管理" value="job"></el-option>
  141. <el-option label="轮播图" value="banner"></el-option>
  142. </el-select>
  143. </el-form-item>
  144. <el-form-item label="排序">
  145. <el-input-number v-model="form.sort" :min="1" style="width:120px;"></el-input-number>
  146. </el-form-item>
  147. <el-form-item label="显示状态">
  148. <el-switch v-model="form.visible" :active-value="1" :inactive-value="0"></el-switch>
  149. </el-form-item>
  150. <el-form-item label="外部链接">
  151. <el-input v-model="form.link" placeholder="留空则使用系统页面"></el-input>
  152. </el-form-item>
  153. </el-form>
  154. <template #footer></template>
  155. <el-button @click="dialogVisible = false">取消</el-button>
  156. <el-button type="primary" @click="saveItem" :loading="saving">保存</el-button>
  157. </template>
  158. </el-dialog>
  159. <!-- 图标选择弹窗 -->
  160. <el-dialog v-model="iconPickerVisible" title="选择图标" width="600px">
  161. <el-input v-model="iconSearch" placeholder="搜索图标..." class="mb-4" clearable></el-input>
  162. <div class="icon-grid">
  163. <div v-for="icon in filteredIcons" :key="icon" class="icon-item" :class="{ active: form.icon === icon }" @click="selectIcon(icon)">
  164. <el-icon :size="20"><component :is="'el-icon-' + icon"></component></el-icon>
  165. <span class="icon-name">${ icon }</span>
  166. </div>
  167. </div>
  168. </el-dialog>
  169. <!-- 表单配置抽屉 -->
  170. <el-drawer v-model="formDrawerVisible" :title="formDrawerTitle" size="450px" destroy-on-close>
  171. <div class="mb-3">
  172. <el-button type="primary" size="small" @click="addFormField">+ 添加字段</el-button>
  173. </div>
  174. <div v-if="formFields.length === 0" class="text-center text-gray-400 py-10">暂无字段</div>
  175. <div v-else class="space-y-3">
  176. <el-card v-for="(field, idx) in formFields" :key="idx" shadow="never">
  177. <template #header>
  178. <div class="flex items-center justify-between">
  179. <span>#${ idx + 1 }</span>
  180. <div class="flex gap-1">
  181. <el-button v-if="idx > 0" link size="small" @click="moveField(idx, -1)">↑</el-button>
  182. <el-button v-if="idx < formFields.length - 1" link size="small" @click="moveField(idx, 1)">↓</el-button>
  183. <el-button type="danger" link size="small" @click="removeField(idx)">删除</el-button>
  184. </div>
  185. </div>
  186. </template>
  187. <el-form label-width="70px" size="small">
  188. <el-form-item label="字段名"><el-input v-model="field.name"></el-input></el-form-item>
  189. <el-form-item label="类型">
  190. <el-select v-model="field.fieldType" style="width:100%;" :teleported="false">
  191. <el-option label="单行文本" value="text"></el-option>
  192. <el-option label="多行文本" value="textarea"></el-option>
  193. <el-option label="邮箱" value="email"></el-option>
  194. <el-option label="日期" value="date"></el-option>
  195. <el-option label="单选" value="radio"></el-option>
  196. <el-option label="下拉选择" value="select"></el-option>
  197. <el-option label="多选" value="checkbox"></el-option>
  198. <el-option label="文件上传" value="file"></el-option>
  199. </el-select>
  200. </el-form-item>
  201. <el-form-item label="必填"><el-switch v-model="field.required"></el-switch></el-form-item>
  202. </el-form>
  203. </el-card>
  204. </div>
  205. <template #footer>
  206. <el-button @click="formDrawerVisible = false">取消</el-button>
  207. <el-button type="primary" @click="saveFormConfig" :loading="formSaving">保存</el-button>
  208. </template>
  209. </el-drawer>
  210. </div>
  211. <style>
  212. .tree-node { min-height: 40px; }
  213. .el-tree-node__content { height: auto !important; padding: 4px 0; }
  214. .icon-grid { display: grid; grid-template-columns: repeat(10, 1fr); gap: 6px; max-height: 360px; overflow-y: auto; }
  215. .icon-item { display: flex; flex-direction: column; align-items: center; padding: 8px 4px; border: 1px solid #eee; border-radius: 4px; cursor: pointer; }
  216. .icon-item:hover { border-color: #ff7800; background: #fff7f0; }
  217. .icon-item.active { border-color: #ff7800; background: #ff7800; color: #fff; }
  218. .icon-name { font-size: 9px; text-align: center; word-break: break-all; margin-top: 4px; line-height: 1.2; max-width: 100%; overflow: hidden; }
  219. </style>
  220. {% endblock %}
  221. {% block js %}
  222. <script>
  223. const { createApp, ref, reactive, computed, onMounted } = Vue;
  224. // Element Plus 常用图标列表
  225. const iconList = [
  226. 'HomeFilled', 'House', 'OfficeBuilding', 'School', 'Shop', 'ShoppingCart',
  227. 'Document', 'DocumentCopy', 'Folder', 'FolderOpened', 'Files', 'Collection',
  228. 'Reading', 'Notebook', 'Tickets', 'Memo', 'Postcard', 'Message', 'ChatDotRound',
  229. 'Phone', 'PhoneFilled', 'Cellphone', 'Position', 'Location', 'LocationFilled',
  230. 'User', 'UserFilled', 'Avatar', 'Service', 'Coordinate', 'Management',
  231. 'Setting', 'Tools', 'Operation', 'Platform', 'Monitor', 'DataAnalysis',
  232. 'PieChart', 'DataLine', 'TrendCharts', 'Histogram', 'Odometer', 'Timer',
  233. 'Calendar', 'Clock', 'AlarmClock', 'Watch', 'Stopwatch', 'Bell', 'BellFilled',
  234. 'Notification', 'Flag', 'Trophy', 'Medal', 'Present', 'GoodsFilled', 'Goods',
  235. 'Box', 'Briefcase', 'Suitcase', 'SuitcaseLine', 'Wallet', 'WalletFilled',
  236. 'Money', 'Coin', 'CreditCard', 'Discount', 'PriceTag', 'Sell', 'SoldOut',
  237. 'Camera', 'CameraFilled', 'Picture', 'PictureFilled', 'Film', 'VideoCamera',
  238. 'Headset', 'Microphone', 'Mute', 'VideoPlay', 'VideoPause', 'MuteNotification',
  239. 'Star', 'StarFilled', 'Sunny', 'Moon', 'Cloudy', 'PartlyCloudy', 'Lightning',
  240. 'Sunrise', 'Sunset', 'MostlyCloudy', 'Pouring', 'Drizzling', 'WindPower',
  241. 'Link', 'Share', 'Connection', 'Promotion', 'Guide', 'Help', 'HelpFilled',
  242. 'QuestionFilled', 'InfoFilled', 'WarningFilled', 'SuccessFilled', 'CircleCheckFilled',
  243. 'Edit', 'EditPen', 'Delete', 'DeleteFilled', 'Plus', 'Minus', 'Check', 'Close',
  244. 'Search', 'ZoomIn', 'ZoomOut', 'View', 'Hide', 'Refresh', 'RefreshRight',
  245. 'Upload', 'Download', 'UploadFilled', 'Printer', 'List', 'Grid', 'Menu',
  246. 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'DArrowLeft', 'DArrowRight',
  247. 'Top', 'Bottom', 'Back', 'Right', 'Sort', 'SortUp', 'SortDown', 'Rank',
  248. 'Lock', 'Unlock', 'Key', 'Open', 'TurnOff', 'SwitchButton', 'SwitchFilled',
  249. 'Cpu', 'SetUp', 'Opportunity', 'Aim', 'Compass', 'MapLocation', 'Place',
  250. 'Bicycle', 'Ship', 'Van', 'Truck', 'CaretTop', 'CaretBottom', 'CaretLeft', 'CaretRight',
  251. 'Coffee', 'CoffeeCup', 'Dessert', 'IceCream', 'Food', 'Burger', 'KnifeFork',
  252. 'Apple', 'Grape', 'Cherry', 'Orange', 'Pear', 'Watermelon', 'Sugar', 'Lollipop',
  253. 'FirstAidKit', 'MagicStick', 'Brush', 'Scissor', 'Stamp', 'Paperclip', 'Magnet',
  254. 'Basketball', 'Football', 'Baseball', 'Soccer', 'GobletFull', 'GobletSquareFull',
  255. 'IceDrink', 'IceTea', 'Milk', 'HotWater', 'NoSmoking', 'Smoking', 'Female', 'Male'
  256. ];
  257. const app = createApp({
  258. delimiters: ['${', '}'],
  259. setup() {
  260. const loading = ref(false);
  261. const treeRef = ref(null);
  262. const treeData = ref([]);
  263. const typeMap = {
  264. 'article': '图文列表', 'image': '图片列表', 'text': '文字列表',
  265. 'page': '单页', 'person': '人员列表', 'form': '自定义表单',
  266. 'donation': '捐赠收支', 'job': '岗位管理', 'banner': '轮播图'
  267. };
  268. const typeTagMap = {
  269. 'article': 'primary', 'image': 'success', 'text': 'warning',
  270. 'page': 'info', 'person': 'danger', 'form': 'primary',
  271. 'donation': 'success', 'job': 'info', 'banner': 'warning'
  272. };
  273. const dialogVisible = ref(false);
  274. const dialogTitle = ref('新增栏目');
  275. const activeTab = ref('basic');
  276. const saving = ref(false);
  277. const parentIsSinglePage = ref(false);
  278. const form = reactive({
  279. id: null, parent_id: 0, name: '', key: '', icon: '', type: '', is_single_page: 0, sort: 1, visible: 1,
  280. link: '', seo_title: '', seo_keywords: '', seo_description: '', slug: ''
  281. });
  282. // 图标选择
  283. const iconPickerVisible = ref(false);
  284. const iconSearch = ref('');
  285. const filteredIcons = computed(() => {
  286. if (!iconSearch.value) return iconList;
  287. return iconList.filter(i => i.toLowerCase().includes(iconSearch.value.toLowerCase()));
  288. });
  289. function selectIcon(icon) {
  290. form.icon = icon;
  291. iconPickerVisible.value = false;
  292. }
  293. const showSeoTab = computed(() => {
  294. if (form.parent_id === 0 && form.is_single_page === 1) return true;
  295. if (form.parent_id !== 0 && !parentIsSinglePage.value) return true;
  296. return false;
  297. });
  298. const formDrawerVisible = ref(false);
  299. const formDrawerTitle = ref('表单字段配置');
  300. const formFields = ref([]);
  301. const formSaving = ref(false);
  302. const currentFormColumnId = ref(null);
  303. async function loadList() {
  304. loading.value = true;
  305. try {
  306. const res = await fetch('/admin/system/column/list').then(r => r.json());
  307. if (res.code === 0) treeData.value = res.data || [];
  308. } finally { loading.value = false; }
  309. }
  310. let allExpanded = true;
  311. function toggleAllTree() {
  312. if (!treeRef.value) return;
  313. allExpanded = !allExpanded;
  314. Object.values(treeRef.value.store.nodesMap).forEach(node => {
  315. if (node.childNodes?.length > 0) node.expanded = allExpanded;
  316. });
  317. }
  318. function showAddModal(parentId, parentData) {
  319. dialogTitle.value = parentId ? '新增子栏目' : '新增顶级栏目';
  320. activeTab.value = 'basic';
  321. parentIsSinglePage.value = parentData ? !!parentData.is_single_page : false;
  322. Object.assign(form, {
  323. id: null, parent_id: parentId || 0, name: '', key: '', icon: '', type: '', is_single_page: 0, sort: 1, visible: 1,
  324. link: '', seo_title: '', seo_keywords: '', seo_description: '', slug: ''
  325. });
  326. dialogVisible.value = true;
  327. }
  328. function editItem(row) {
  329. dialogTitle.value = '编辑栏目';
  330. activeTab.value = 'basic';
  331. if (row.parent_id !== 0) {
  332. const parent = treeData.value.find(p => p.id === row.parent_id);
  333. parentIsSinglePage.value = parent ? !!parent.is_single_page : false;
  334. } else {
  335. parentIsSinglePage.value = false;
  336. }
  337. Object.assign(form, {
  338. id: row.id, parent_id: row.parent_id || 0, name: row.name, key: row.key || '', icon: row.icon || '',
  339. type: row.type || '', is_single_page: row.is_single_page || 0, sort: row.sort || 1, visible: row.visible,
  340. link: row.link || '', seo_title: row.seo_title || '', seo_keywords: row.seo_keywords || '',
  341. seo_description: row.seo_description || '', slug: row.slug || ''
  342. });
  343. dialogVisible.value = true;
  344. }
  345. async function saveItem() {
  346. if (!form.name.trim()) { ElementPlus.ElMessage.warning('请输入栏目名称'); return; }
  347. saving.value = true;
  348. try {
  349. const url = form.id ? '/admin/system/column/edit' : '/admin/system/column/add';
  350. const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form) }).then(r => r.json());
  351. if (res.code === 0) { ElementPlus.ElMessage.success('保存成功'); dialogVisible.value = false; loadList(); }
  352. else { ElementPlus.ElMessage.error(res.msg || '保存失败'); }
  353. } finally { saving.value = false; }
  354. }
  355. async function deleteItem(row) {
  356. try {
  357. await ElementPlus.ElMessageBox.confirm('确定删除"' + row.name + '"?', '提示', { type: 'warning' });
  358. const res = await fetch('/admin/system/column/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: row.id }) }).then(r => r.json());
  359. if (res.code === 0) { ElementPlus.ElMessage.success('删除成功'); loadList(); }
  360. else { ElementPlus.ElMessage.error(res.msg || '删除失败'); }
  361. } catch {}
  362. }
  363. function openFormConfig(row) {
  364. currentFormColumnId.value = row.id;
  365. formDrawerTitle.value = '【' + row.name + '】表单配置';
  366. formFields.value = row.form_config ? JSON.parse(row.form_config) : [];
  367. formDrawerVisible.value = true;
  368. }
  369. function addFormField() { formFields.value.push({ name: '新字段', fieldType: 'text', required: false }); }
  370. function moveField(idx, dir) {
  371. const t = idx + dir;
  372. if (t < 0 || t >= formFields.value.length) return;
  373. [formFields.value[idx], formFields.value[t]] = [formFields.value[t], formFields.value[idx]];
  374. }
  375. function removeField(idx) { formFields.value.splice(idx, 1); }
  376. async function saveFormConfig() {
  377. formSaving.value = true;
  378. try {
  379. const res = await fetch('/admin/system/column/saveFormConfig', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: currentFormColumnId.value, form_config: formFields.value }) }).then(r => r.json());
  380. if (res.code === 0) { ElementPlus.ElMessage.success('保存成功'); formDrawerVisible.value = false; loadList(); }
  381. else { ElementPlus.ElMessage.error(res.msg || '保存失败'); }
  382. } finally { formSaving.value = false; }
  383. }
  384. onMounted(() => loadList());
  385. return {
  386. loading, treeRef, treeData, typeMap, typeTagMap,
  387. dialogVisible, dialogTitle, activeTab, saving, form, showSeoTab,
  388. iconPickerVisible, iconSearch, filteredIcons, selectIcon,
  389. formDrawerVisible, formDrawerTitle, formFields, formSaving,
  390. loadList, toggleAllTree, showAddModal, editItem, saveItem, deleteItem,
  391. openFormConfig, addFormField, moveField, removeField, saveFormConfig
  392. };
  393. }
  394. });
  395. // 注册所有图标组件
  396. for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
  397. app.component('el-icon-' + key, component);
  398. }
  399. app.use(ElementPlus, { locale: ElementPlusLocaleZhCn });
  400. app.mount('#columnApp');
  401. </script>
  402. {% endblock %}