|
- {% extends "../layout.html" %}
-
- {% block title %}栏目管理{% endblock %}
-
- {% block content %}
- <div id="columnApp">
- <el-card shadow="never">
- <template #header>
- <div class="flex items-center justify-between">
- <span class="font-medium">栏目管理</span>
- <div class="flex gap-2">
- <el-button @click="toggleAllTree">展开/折叠全部</el-button>
- <el-button type="primary" @click="showAddModal(0, null)">+ 新增顶级栏目</el-button>
- </div>
- </div>
- </template>
-
- <el-tree ref="treeRef" :data="treeData" node-key="id" default-expand-all :expand-on-click-node="false" v-loading="loading">
- <template #default="{ node, data }">
- <div class="tree-node flex items-center justify-between w-full py-2 pr-4">
- <div class="flex items-center gap-2">
- <el-icon v-if="data.icon && data.parent_id === 0" :size="18"><component :is="'el-icon-' + data.icon"></component></el-icon>
- <span v-else class="text-base">${ data.parent_id === 0 ? '📁' : '📄' }</span>
- <span class="font-medium">${ data.name }</span>
- <el-tag v-if="data.key" size="small" type="info">${ data.key }</el-tag>
- <el-tag v-if="data.parent_id === 0 && data.is_single_page" size="small">单页面</el-tag>
- <el-tag v-if="data.type" :type="typeTagMap[data.type]" size="small">${ typeMap[data.type] }</el-tag>
- <el-tag v-if="!data.visible" type="info" size="small">隐藏</el-tag>
- </div>
- <div class="flex items-center gap-1">
- <el-button type="primary" link size="small" @click.stop="editItem(data)">编辑</el-button>
- <el-button v-if="data.parent_id === 0" type="primary" link size="small" @click.stop="showAddModal(data.id, data)">+ 子栏目</el-button>
- <el-button v-if="data.type === 'form'" type="warning" link size="small" @click.stop="openFormConfig(data)">表单配置</el-button>
- <el-button v-if="data.parent_id !== 0" type="danger" link size="small" @click.stop="deleteItem(data)">删除</el-button>
- </div>
- </div>
- </template>
- </el-tree>
- </el-card>
-
- <!-- 新增/编辑弹窗 -->
- <el-dialog v-model="dialogVisible" :title="dialogTitle" width="560px" destroy-on-close>
- <el-tabs v-model="activeTab" v-if="showSeoTab">
- <el-tab-pane label="基本信息" name="basic">
- <el-form :model="form" label-width="100px">
- <el-form-item label="栏目名称" required>
- <el-input v-model="form.name" placeholder="请输入栏目名称"></el-input>
- </el-form-item>
- <el-form-item label="英文名称" v-if="form.parent_id === 0">
- <el-input v-model="form.name_en" placeholder="如: ABOUT US"></el-input>
- </el-form-item>
- <el-form-item label="栏目描述" v-if="form.parent_id === 0">
- <el-input v-model="form.description" placeholder="一句话描述"></el-input>
- </el-form-item>
- <el-form-item label="Banner图" v-if="form.parent_id === 0">
- <div class="flex items-center gap-3">
- <el-image v-if="form.banner_image" :src="form.banner_image" style="width:200px;height:80px;border-radius:4px;" fit="cover"></el-image>
- <el-button @click="uploadBanner">上传图片</el-button>
- <el-button v-if="form.banner_image" link type="danger" @click="form.banner_image = ''">清除</el-button>
- </div>
- </el-form-item>
- <el-form-item label="栏目标识">
- <el-input v-model="form.key" placeholder="如: about-us(用于路由)"></el-input>
- </el-form-item>
- <el-form-item label="图标" v-if="form.parent_id === 0">
- <div class="flex items-center gap-2">
- <el-button @click="iconPickerVisible = true">
- <el-icon v-if="form.icon"><component :is="'el-icon-' + form.icon"></component></el-icon>
- <span v-else>选择图标</span>
- </el-button>
- <span v-if="form.icon" class="text-gray-500">${ form.icon }</span>
- <el-button v-if="form.icon" link type="danger" @click="form.icon = ''">清除</el-button>
- </div>
- </el-form-item>
- <el-form-item label="栏目类型" v-if="form.parent_id === 0">
- <el-radio-group v-model="form.is_single_page">
- <el-radio :value="1">单页面(二级为模块)</el-radio>
- <el-radio :value="0">多页面(二级为独立页面)</el-radio>
- </el-radio-group>
- </el-form-item>
- <el-form-item label="内容类型">
- <el-select v-model="form.type" placeholder="无" style="width:100%;" :teleported="false">
- <el-option label="无" value=""></el-option>
- <el-option label="图文列表" value="article"></el-option>
- <el-option label="图片列表" value="image"></el-option>
- <el-option label="文字列表" value="text"></el-option>
- <el-option label="单页" value="page"></el-option>
- <el-option label="人员列表" value="person"></el-option>
- <el-option label="自定义表单" value="form"></el-option>
- <el-option label="捐赠收支数据" value="donation"></el-option>
- <el-option label="岗位管理" value="job"></el-option>
- <el-option label="轮播图" value="banner"></el-option>
- </el-select>
- </el-form-item>
- <el-form-item label="排序">
- <el-input-number v-model="form.sort" :min="1" style="width:120px;"></el-input-number>
- </el-form-item>
- <el-form-item label="显示状态">
- <el-switch v-model="form.visible" :active-value="1" :inactive-value="0"></el-switch>
- </el-form-item>
- <el-form-item label="外部链接">
- <el-input v-model="form.link" placeholder="留空则使用系统页面"></el-input>
- </el-form-item>
- </el-form>
- </el-tab-pane>
- <el-tab-pane label="SEO配置" name="seo"></el-tab-pane>
- <el-form :model="form" label-width="120px">
- <el-form-item label="页面标题">
- <el-input v-model="form.seo_title" placeholder="用于浏览器标签"></el-input>
- </el-form-item>
- <el-form-item label="关键词">
- <el-input v-model="form.seo_keywords" placeholder="多个关键词用逗号分隔"></el-input>
- </el-form-item>
- <el-form-item label="描述">
- <el-input v-model="form.seo_description" type="textarea" :rows="3"></el-input>
- </el-form-item>
- <el-form-item label="URL别名">
- <el-input v-model="form.slug" placeholder="如: about-us"></el-input>
- </el-form-item>
- </el-form>
- </el-tab-pane>
- </el-tabs>
- <!-- 无SEO配置时直接显示基本信息 -->
- <el-form v-if="!showSeoTab" :model="form" label-width="100px">
- <el-form-item label="栏目名称" required>
- <el-input v-model="form.name" placeholder="请输入栏目名称"></el-input>
- </el-form-item>
- <el-form-item label="英文名称" v-if="form.parent_id === 0">
- <el-input v-model="form.name_en" placeholder="如: ABOUT US"></el-input>
- </el-form-item>
- <el-form-item label="栏目描述" v-if="form.parent_id === 0">
- <el-input v-model="form.description" placeholder="一句话描述"></el-input>
- </el-form-item>
- <el-form-item label="Banner图" v-if="form.parent_id === 0">
- <div class="flex items-center gap-3">
- <el-image v-if="form.banner_image" :src="form.banner_image" style="width:200px;height:80px;border-radius:4px;" fit="cover"></el-image>
- <el-button @click="uploadBanner">上传图片</el-button>
- <el-button v-if="form.banner_image" link type="danger" @click="form.banner_image = ''">清除</el-button>
- </div>
- </el-form-item>
- <el-form-item label="栏目标识">
- <el-input v-model="form.key" placeholder="如: about-us(用于路由)"></el-input>
- </el-form-item>
- <el-form-item label="图标" v-if="form.parent_id === 0">
- <div class="flex items-center gap-2">
- <el-button @click="iconPickerVisible = true">
- <el-icon v-if="form.icon"><component :is="'el-icon-' + form.icon"></component></el-icon>
- <span v-else>选择图标</span>
- </el-button>
- <span v-if="form.icon" class="text-gray-500">${ form.icon }</span>
- <el-button v-if="form.icon" link type="danger" @click="form.icon = ''">清除</el-button>
- </div>
- </el-form-item>
- <el-form-item label="栏目类型" v-if="form.parent_id === 0">
- <el-radio-group v-model="form.is_single_page">
- <el-radio :value="1">单页面(二级为模块)</el-radio>
- <el-radio :value="0">多页面(二级为独立页面)</el-radio>
- </el-radio-group>
- </el-form-item>
- <el-form-item label="内容类型">
- <el-select v-model="form.type" placeholder="无" style="width:100%;" :teleported="false">
- <el-option label="无" value=""></el-option>
- <el-option label="图文列表" value="article"></el-option>
- <el-option label="图片列表" value="image"></el-option>
- <el-option label="文字列表" value="text"></el-option>
- <el-option label="单页" value="page"></el-option>
- <el-option label="人员列表" value="person"></el-option>
- <el-option label="自定义表单" value="form"></el-option>
- <el-option label="捐赠收支数据" value="donation"></el-option>
- <el-option label="岗位管理" value="job"></el-option>
- <el-option label="轮播图" value="banner"></el-option>
- </el-select>
- </el-form-item>
- <el-form-item label="排序">
- <el-input-number v-model="form.sort" :min="1" style="width:120px;"></el-input-number>
- </el-form-item>
- <el-form-item label="显示状态">
- <el-switch v-model="form.visible" :active-value="1" :inactive-value="0"></el-switch>
- </el-form-item>
- <el-form-item label="外部链接">
- <el-input v-model="form.link" placeholder="留空则使用系统页面"></el-input>
- </el-form-item>
- </el-form>
- <template #footer></template>
- <el-button @click="dialogVisible = false">取消</el-button>
- <el-button type="primary" @click="saveItem" :loading="saving">保存</el-button>
- </template>
- </el-dialog>
-
- <!-- 图标选择弹窗 -->
- <el-dialog v-model="iconPickerVisible" title="选择图标" width="600px">
- <el-input v-model="iconSearch" placeholder="搜索图标..." class="mb-4" clearable></el-input>
- <div class="icon-grid">
- <div v-for="icon in filteredIcons" :key="icon" class="icon-item" :class="{ active: form.icon === icon }" @click="selectIcon(icon)">
- <el-icon :size="20"><component :is="'el-icon-' + icon"></component></el-icon>
- <span class="icon-name">${ icon }</span>
- </div>
- </div>
- </el-dialog>
-
- <!-- 表单配置抽屉 -->
- <el-drawer v-model="formDrawerVisible" :title="formDrawerTitle" size="450px" destroy-on-close>
- <div class="mb-3">
- <el-button type="primary" size="small" @click="addFormField">+ 添加字段</el-button>
- </div>
- <div v-if="formFields.length === 0" class="text-center text-gray-400 py-10">暂无字段</div>
- <div v-else class="space-y-3">
- <el-card v-for="(field, idx) in formFields" :key="idx" shadow="never">
- <template #header>
- <div class="flex items-center justify-between">
- <span>#${ idx + 1 }</span>
- <div class="flex gap-1">
- <el-button v-if="idx > 0" link size="small" @click="moveField(idx, -1)">↑</el-button>
- <el-button v-if="idx < formFields.length - 1" link size="small" @click="moveField(idx, 1)">↓</el-button>
- <el-button type="danger" link size="small" @click="removeField(idx)">删除</el-button>
- </div>
- </div>
- </template>
- <el-form label-width="70px" size="small">
- <el-form-item label="字段名"><el-input v-model="field.name"></el-input></el-form-item>
- <el-form-item label="类型">
- <el-select v-model="field.fieldType" style="width:100%;" :teleported="false">
- <el-option label="单行文本" value="text"></el-option>
- <el-option label="多行文本" value="textarea"></el-option>
- <el-option label="邮箱" value="email"></el-option>
- <el-option label="日期" value="date"></el-option>
- <el-option label="单选" value="radio"></el-option>
- <el-option label="下拉选择" value="select"></el-option>
- <el-option label="多选" value="checkbox"></el-option>
- <el-option label="文件上传" value="file"></el-option>
- </el-select>
- </el-form-item>
- <el-form-item label="必填"><el-switch v-model="field.required"></el-switch></el-form-item>
- </el-form>
- </el-card>
- </div>
- <template #footer>
- <el-button @click="formDrawerVisible = false">取消</el-button>
- <el-button type="primary" @click="saveFormConfig" :loading="formSaving">保存</el-button>
- </template>
- </el-drawer>
- </div>
-
- <style>
- .tree-node { min-height: 40px; }
- .el-tree-node__content { height: auto !important; padding: 4px 0; }
- .icon-grid { display: grid; grid-template-columns: repeat(10, 1fr); gap: 6px; max-height: 360px; overflow-y: auto; }
- .icon-item { display: flex; flex-direction: column; align-items: center; padding: 8px 4px; border: 1px solid #eee; border-radius: 4px; cursor: pointer; }
- .icon-item:hover { border-color: #ff7800; background: #fff7f0; }
- .icon-item.active { border-color: #ff7800; background: #ff7800; color: #fff; }
- .icon-name { font-size: 9px; text-align: center; word-break: break-all; margin-top: 4px; line-height: 1.2; max-width: 100%; overflow: hidden; }
- </style>
- {% endblock %}
-
- {% block js %}
- <script>
- const { createApp, ref, reactive, computed, onMounted } = Vue;
-
- // Element Plus 常用图标列表
- const iconList = [
- 'HomeFilled', 'House', 'OfficeBuilding', 'School', 'Shop', 'ShoppingCart',
- 'Document', 'DocumentCopy', 'Folder', 'FolderOpened', 'Files', 'Collection',
- 'Reading', 'Notebook', 'Tickets', 'Memo', 'Postcard', 'Message', 'ChatDotRound',
- 'Phone', 'PhoneFilled', 'Cellphone', 'Position', 'Location', 'LocationFilled',
- 'User', 'UserFilled', 'Avatar', 'Service', 'Coordinate', 'Management',
- 'Setting', 'Tools', 'Operation', 'Platform', 'Monitor', 'DataAnalysis',
- 'PieChart', 'DataLine', 'TrendCharts', 'Histogram', 'Odometer', 'Timer',
- 'Calendar', 'Clock', 'AlarmClock', 'Watch', 'Stopwatch', 'Bell', 'BellFilled',
- 'Notification', 'Flag', 'Trophy', 'Medal', 'Present', 'GoodsFilled', 'Goods',
- 'Box', 'Briefcase', 'Suitcase', 'SuitcaseLine', 'Wallet', 'WalletFilled',
- 'Money', 'Coin', 'CreditCard', 'Discount', 'PriceTag', 'Sell', 'SoldOut',
- 'Camera', 'CameraFilled', 'Picture', 'PictureFilled', 'Film', 'VideoCamera',
- 'Headset', 'Microphone', 'Mute', 'VideoPlay', 'VideoPause', 'MuteNotification',
- 'Star', 'StarFilled', 'Sunny', 'Moon', 'Cloudy', 'PartlyCloudy', 'Lightning',
- 'Sunrise', 'Sunset', 'MostlyCloudy', 'Pouring', 'Drizzling', 'WindPower',
- 'Link', 'Share', 'Connection', 'Promotion', 'Guide', 'Help', 'HelpFilled',
- 'QuestionFilled', 'InfoFilled', 'WarningFilled', 'SuccessFilled', 'CircleCheckFilled',
- 'Edit', 'EditPen', 'Delete', 'DeleteFilled', 'Plus', 'Minus', 'Check', 'Close',
- 'Search', 'ZoomIn', 'ZoomOut', 'View', 'Hide', 'Refresh', 'RefreshRight',
- 'Upload', 'Download', 'UploadFilled', 'Printer', 'List', 'Grid', 'Menu',
- 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'DArrowLeft', 'DArrowRight',
- 'Top', 'Bottom', 'Back', 'Right', 'Sort', 'SortUp', 'SortDown', 'Rank',
- 'Lock', 'Unlock', 'Key', 'Open', 'TurnOff', 'SwitchButton', 'SwitchFilled',
- 'Cpu', 'SetUp', 'Opportunity', 'Aim', 'Compass', 'MapLocation', 'Place',
- 'Bicycle', 'Ship', 'Van', 'Truck', 'CaretTop', 'CaretBottom', 'CaretLeft', 'CaretRight',
- 'Coffee', 'CoffeeCup', 'Dessert', 'IceCream', 'Food', 'Burger', 'KnifeFork',
- 'Apple', 'Grape', 'Cherry', 'Orange', 'Pear', 'Watermelon', 'Sugar', 'Lollipop',
- 'FirstAidKit', 'MagicStick', 'Brush', 'Scissor', 'Stamp', 'Paperclip', 'Magnet',
- 'Basketball', 'Football', 'Baseball', 'Soccer', 'GobletFull', 'GobletSquareFull',
- 'IceDrink', 'IceTea', 'Milk', 'HotWater', 'NoSmoking', 'Smoking', 'Female', 'Male'
- ];
-
- const app = createApp({
- delimiters: ['${', '}'],
- setup() {
- const loading = ref(false);
- const treeRef = ref(null);
- const treeData = ref([]);
-
- const typeMap = {
- 'article': '图文列表', 'image': '图片列表', 'text': '文字列表',
- 'page': '单页', 'person': '人员列表', 'form': '自定义表单',
- 'donation': '捐赠收支', 'job': '岗位管理', 'banner': '轮播图'
- };
- const typeTagMap = {
- 'article': 'primary', 'image': 'success', 'text': 'warning',
- 'page': 'info', 'person': 'danger', 'form': 'primary',
- 'donation': 'success', 'job': 'info', 'banner': 'warning'
- };
-
- const dialogVisible = ref(false);
- const dialogTitle = ref('新增栏目');
- const activeTab = ref('basic');
- const saving = ref(false);
- const parentIsSinglePage = ref(false);
- const form = reactive({
- id: null, parent_id: 0, name: '', name_en: '', description: '', banner_image: '', key: '', icon: '', type: '', is_single_page: 0, sort: 1, visible: 1,
- link: '', seo_title: '', seo_keywords: '', seo_description: '', slug: ''
- });
-
- // 图标选择
- const iconPickerVisible = ref(false);
- const iconSearch = ref('');
- const filteredIcons = computed(() => {
- if (!iconSearch.value) return iconList;
- return iconList.filter(i => i.toLowerCase().includes(iconSearch.value.toLowerCase()));
- });
- function selectIcon(icon) {
- form.icon = icon;
- iconPickerVisible.value = false;
- }
-
- // Banner图片上传
- function uploadBanner() {
- const input = document.createElement('input');
- input.type = 'file';
- input.accept = 'image/*';
- input.onchange = async (e) => {
- const file = e.target.files[0];
- if (!file) return;
- const fd = new FormData();
- fd.append('file', file);
- try {
- const res = await fetch('/admin/upload', { method: 'POST', body: fd }).then(r => r.json());
- if (res.code === 0) { form.banner_image = res.data.url; }
- else { ElementPlus.ElMessage.error(res.msg || '上传失败'); }
- } catch { ElementPlus.ElMessage.error('上传失败'); }
- };
- input.click();
- }
-
- const showSeoTab = computed(() => {
- if (form.parent_id === 0 && form.is_single_page === 1) return true;
- if (form.parent_id !== 0 && !parentIsSinglePage.value) return true;
- return false;
- });
-
- const formDrawerVisible = ref(false);
- const formDrawerTitle = ref('表单字段配置');
- const formFields = ref([]);
- const formSaving = ref(false);
- const currentFormColumnId = ref(null);
-
- async function loadList() {
- loading.value = true;
- try {
- const res = await fetch('/admin/system/column/list').then(r => r.json());
- if (res.code === 0) treeData.value = res.data || [];
- } finally { loading.value = false; }
- }
-
- let allExpanded = true;
- function toggleAllTree() {
- if (!treeRef.value) return;
- allExpanded = !allExpanded;
- Object.values(treeRef.value.store.nodesMap).forEach(node => {
- if (node.childNodes?.length > 0) node.expanded = allExpanded;
- });
- }
-
- function showAddModal(parentId, parentData) {
- dialogTitle.value = parentId ? '新增子栏目' : '新增顶级栏目';
- activeTab.value = 'basic';
- parentIsSinglePage.value = parentData ? !!parentData.is_single_page : false;
- Object.assign(form, {
- id: null, parent_id: parentId || 0, name: '', name_en: '', description: '', banner_image: '', key: '', icon: '', type: '', is_single_page: 0, sort: 1, visible: 1,
- link: '', seo_title: '', seo_keywords: '', seo_description: '', slug: ''
- });
- dialogVisible.value = true;
- }
-
- function editItem(row) {
- dialogTitle.value = '编辑栏目';
- activeTab.value = 'basic';
- if (row.parent_id !== 0) {
- const parent = treeData.value.find(p => p.id === row.parent_id);
- parentIsSinglePage.value = parent ? !!parent.is_single_page : false;
- } else {
- parentIsSinglePage.value = false;
- }
- Object.assign(form, {
- id: row.id, parent_id: row.parent_id || 0, name: row.name, name_en: row.name_en || '', description: row.description || '',
- banner_image: row.banner_image || '', key: row.key || '', icon: row.icon || '',
- type: row.type || '', is_single_page: row.is_single_page || 0, sort: row.sort || 1, visible: row.visible,
- link: row.link || '', seo_title: row.seo_title || '', seo_keywords: row.seo_keywords || '',
- seo_description: row.seo_description || '', slug: row.slug || ''
- });
- dialogVisible.value = true;
- }
-
- async function saveItem() {
- if (!form.name.trim()) { ElementPlus.ElMessage.warning('请输入栏目名称'); return; }
- saving.value = true;
- try {
- const url = form.id ? '/admin/system/column/edit' : '/admin/system/column/add';
- const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form) }).then(r => r.json());
- if (res.code === 0) { ElementPlus.ElMessage.success('保存成功'); dialogVisible.value = false; loadList(); }
- else { ElementPlus.ElMessage.error(res.msg || '保存失败'); }
- } finally { saving.value = false; }
- }
-
- async function deleteItem(row) {
- try {
- await ElementPlus.ElMessageBox.confirm('确定删除"' + row.name + '"?', '提示', { type: 'warning' });
- 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());
- if (res.code === 0) { ElementPlus.ElMessage.success('删除成功'); loadList(); }
- else { ElementPlus.ElMessage.error(res.msg || '删除失败'); }
- } catch {}
- }
-
- function openFormConfig(row) {
- currentFormColumnId.value = row.id;
- formDrawerTitle.value = '【' + row.name + '】表单配置';
- formFields.value = row.form_config ? JSON.parse(row.form_config) : [];
- formDrawerVisible.value = true;
- }
- function addFormField() { formFields.value.push({ name: '新字段', fieldType: 'text', required: false }); }
- function moveField(idx, dir) {
- const t = idx + dir;
- if (t < 0 || t >= formFields.value.length) return;
- [formFields.value[idx], formFields.value[t]] = [formFields.value[t], formFields.value[idx]];
- }
- function removeField(idx) { formFields.value.splice(idx, 1); }
- async function saveFormConfig() {
- formSaving.value = true;
- try {
- 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());
- if (res.code === 0) { ElementPlus.ElMessage.success('保存成功'); formDrawerVisible.value = false; loadList(); }
- else { ElementPlus.ElMessage.error(res.msg || '保存失败'); }
- } finally { formSaving.value = false; }
- }
-
- onMounted(() => loadList());
-
- return {
- loading, treeRef, treeData, typeMap, typeTagMap,
- dialogVisible, dialogTitle, activeTab, saving, form, showSeoTab,
- iconPickerVisible, iconSearch, filteredIcons, selectIcon,
- formDrawerVisible, formDrawerTitle, formFields, formSaving,
- loadList, toggleAllTree, showAddModal, editItem, saveItem, deleteItem,
- openFormConfig, addFormField, moveField, removeField, saveFormConfig,
- uploadBanner
- };
- }
- });
-
- // 注册所有图标组件
- for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
- app.component('el-icon-' + key, component);
- }
-
- app.use(ElementPlus, { locale: ElementPlusLocaleZhCn });
- app.mount('#columnApp');
- </script>
- {% endblock %}
|