Non puoi selezionare più di 25 argomenti Gli argomenti devono iniziare con una lettera o un numero, possono includere trattini ('-') e possono essere lunghi fino a 35 caratteri.
 
 
 
 
 

302 righe
12 KiB

  1. {% extends "./layout.html" %}
  2. {% block title %}内容管理{% endblock %}
  3. {% block css %}
  4. <link rel="stylesheet" href="/static/lib/wangeditor/css/style.css">
  5. <style>
  6. .editor-toolbar { border: 1px solid #dcdfe6; border-bottom: none; border-radius: 4px 4px 0 0; }
  7. .editor-container { border: 1px solid #dcdfe6; border-radius: 0 0 4px 4px; min-height: 300px; }
  8. /* 手机模拟框 */
  9. .phone-frame {
  10. width: 375px; height: 720px; margin: 0 auto;
  11. border: 8px solid #1a1a1a; border-radius: 40px;
  12. background: #f5f5f5; position: relative; overflow: hidden;
  13. box-shadow: 0 8px 32px rgba(0,0,0,0.15);
  14. }
  15. .phone-notch {
  16. width: 120px; height: 24px; background: #1a1a1a;
  17. border-radius: 0 0 16px 16px; margin: 0 auto;
  18. position: relative; z-index: 2;
  19. }
  20. .phone-screen {
  21. height: calc(100% - 24px); overflow-y: auto; background: #fff;
  22. }
  23. .phone-screen::-webkit-scrollbar { width: 0; }
  24. /* 预览内容样式 */
  25. .preview-header {
  26. padding: 16px 16px 12px; border-bottom: 1px solid #f0f0f0;
  27. }
  28. .preview-title { font-size: 18px; font-weight: 600; color: #303133; line-height: 1.5; }
  29. .preview-body { padding: 16px; font-size: 14px; color: #606266; line-height: 1.8; }
  30. .preview-body img { max-width: 100% !important; height: auto !important; }
  31. .preview-body p { margin-bottom: 12px; }
  32. .preview-body p:last-child { margin-bottom: 0; }
  33. </style>
  34. {% endblock %}
  35. {% block content %}
  36. <div id="contentApp" v-cloak>
  37. <!-- 搜索栏 -->
  38. <el-card shadow="never" class="mb-4">
  39. <el-form :inline="true" @submit.prevent="loadList()" class="flex items-center flex-wrap gap-2">
  40. <el-form-item label="关键字" class="!mb-0">
  41. <el-input v-model="keyword" placeholder="标题/标识" clearable style="width:180px;" />
  42. </el-form-item>
  43. <el-form-item label="状态" class="!mb-0">
  44. <el-select v-model="statusFilter" placeholder="全部" clearable style="width:120px;">
  45. <el-option label="启用" :value="1"></el-option>
  46. <el-option label="禁用" :value="0"></el-option>
  47. </el-select>
  48. </el-form-item>
  49. <el-form-item class="!mb-0">
  50. <el-button type="primary" @click="loadList()">搜索</el-button>
  51. <el-button @click="resetFilter">重置</el-button>
  52. </el-form-item>
  53. </el-form>
  54. </el-card>
  55. <!-- 列表 -->
  56. <el-card shadow="never">
  57. <template #header>
  58. <el-button v-if="canAdd" type="primary" @click="showAdd">+ 新增</el-button>
  59. </template>
  60. <el-table :data="tableData" v-loading="loading" stripe border>
  61. <el-table-column prop="content_key" label="内容标识" width="180"></el-table-column>
  62. <el-table-column prop="title" label="标题" min-width="200"></el-table-column>
  63. <el-table-column prop="remark" label="备注" min-width="160">
  64. <template #default="{ row }">${ row.remark || '-' }</template>
  65. </el-table-column>
  66. <el-table-column label="状态" width="80" align="center">
  67. <template #default="{ row }">
  68. <el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">${ row.status === 1 ? '启用' : '禁用' }</el-tag>
  69. </template>
  70. </el-table-column>
  71. <el-table-column prop="sort" label="排序" width="80" align="center"></el-table-column>
  72. <el-table-column prop="update_time" label="更新时间" width="170">
  73. <template #default="{ row }">${ row.update_time || row.create_time || '-' }</template>
  74. </el-table-column>
  75. <el-table-column label="操作" width="200" align="center">
  76. <template #default="{ row }">
  77. <el-button type="primary" link @click="showPreview(row)">预览</el-button>
  78. <el-button v-if="canEdit" type="primary" link @click="showEdit(row)">编辑</el-button>
  79. <el-button v-if="canDelete" type="danger" link @click="handleDelete(row)">删除</el-button>
  80. </template>
  81. </el-table-column>
  82. </el-table>
  83. <div class="flex justify-end mt-4">
  84. <el-pagination
  85. v-model:current-page="pagination.page"
  86. :page-size="pagination.pageSize"
  87. :total="pagination.total"
  88. layout="total, prev, pager, next"
  89. @current-change="loadList"
  90. />
  91. </div>
  92. </el-card>
  93. <!-- 新增/编辑弹窗 -->
  94. <el-dialog v-model="dialogVisible" :title="dialogTitle" width="1000px" top="5vh" destroy-on-close draggable :close-on-click-modal="false" @closed="onDialogClosed">
  95. <div style="max-height: calc(90vh - 160px); overflow-y: auto; padding-right: 24px;">
  96. <el-form :model="form" label-width="90px">
  97. <el-form-item label="内容标识" required>
  98. <el-input v-model="form.content_key" placeholder="如 index_content" :disabled="!!form.id" />
  99. <div v-if="!form.id" class="text-xs text-gray-400 mt-1">创建后不可修改</div>
  100. </el-form-item>
  101. <el-form-item label="标题" required>
  102. <el-input v-model="form.title" placeholder="请输入标题" />
  103. </el-form-item>
  104. <el-form-item label="备注">
  105. <el-input v-model="form.remark" placeholder="请输入备注" />
  106. </el-form-item>
  107. <el-form-item label="富文本内容">
  108. <div style="width:780px; max-width:100%;">
  109. <div id="toolbar-container" class="editor-toolbar"></div>
  110. <div id="editor-container" class="editor-container"></div>
  111. </div>
  112. </el-form-item>
  113. <el-form-item label="排序">
  114. <el-input-number v-model="form.sort" :min="0" :precision="0" :step="1" />
  115. </el-form-item>
  116. <el-form-item label="状态">
  117. <el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
  118. </el-form-item>
  119. </el-form>
  120. </div>
  121. <template #footer>
  122. <el-button @click="dialogVisible = false">取消</el-button>
  123. <el-button type="primary" @click="handleSave" :loading="saving">确定</el-button>
  124. </template>
  125. </el-dialog>
  126. <!-- 预览抽屉 -->
  127. <el-drawer v-model="previewVisible" title="内容预览" size="480px" destroy-on-close>
  128. <div class="phone-frame">
  129. <div class="phone-notch"></div>
  130. <div class="phone-screen">
  131. <div class="preview-header">
  132. <div class="preview-title">${ previewData.title }</div>
  133. </div>
  134. <div class="preview-body" v-html="previewData.content"></div>
  135. </div>
  136. </div>
  137. </el-drawer>
  138. </div>
  139. {% endblock %}
  140. {% block js %}
  141. <script src="/static/lib/wangeditor/index.js"></script>
  142. <script>
  143. const canAdd = {{ canAdd | dump | safe }};
  144. const canEdit = {{ canEdit | dump | safe }};
  145. const canDelete = {{ canDelete | dump | safe }};
  146. const { createApp, ref, reactive, onMounted, nextTick } = Vue;
  147. const app = createApp({
  148. delimiters: ['${', '}'],
  149. setup() {
  150. const keyword = ref('');
  151. const statusFilter = ref('');
  152. const loading = ref(false);
  153. const tableData = ref([]);
  154. const pagination = reactive({ page: 1, pageSize: 10, total: 0 });
  155. const dialogVisible = ref(false);
  156. const dialogTitle = ref('新增内容');
  157. const saving = ref(false);
  158. const form = reactive({ id: null, content_key: '', title: '', remark: '', content: '', sort: 0, status: 1 });
  159. // 预览
  160. const previewVisible = ref(false);
  161. const previewData = reactive({ title: '', content: '' });
  162. let editorInstance = null;
  163. async function loadList(page) {
  164. if (typeof page === 'number') pagination.page = page;
  165. loading.value = true;
  166. try {
  167. const params = new URLSearchParams({
  168. page: pagination.page, pageSize: pagination.pageSize,
  169. keyword: keyword.value,
  170. status: statusFilter.value !== '' ? statusFilter.value : ''
  171. });
  172. const res = await fetch('/admin/content/list?' + params).then(r => r.json());
  173. if (res.code === 0) {
  174. tableData.value = res.data.data || [];
  175. pagination.total = res.data.count || 0;
  176. } else {
  177. ElementPlus.ElMessage.error(res.msg || '加载失败');
  178. }
  179. } finally { loading.value = false; }
  180. }
  181. function resetFilter() {
  182. keyword.value = ''; statusFilter.value = ''; pagination.page = 1; loadList();
  183. }
  184. function showAdd() {
  185. dialogTitle.value = '新增内容';
  186. Object.assign(form, { id: null, content_key: '', title: '', remark: '', content: '', sort: 0, status: 1 });
  187. dialogVisible.value = true;
  188. nextTick(() => initEditor(''));
  189. }
  190. async function showEdit(row) {
  191. dialogTitle.value = '编辑内容';
  192. const res = await fetch('/admin/content/detail?id=' + row.id).then(r => r.json());
  193. if (res.code !== 0) { ElementPlus.ElMessage.error(res.msg || '获取详情失败'); return; }
  194. const item = res.data;
  195. Object.assign(form, {
  196. id: item.id, content_key: item.content_key, title: item.title,
  197. remark: item.remark || '', content: item.content || '',
  198. sort: item.sort || 0, status: item.status
  199. });
  200. dialogVisible.value = true;
  201. nextTick(() => initEditor(form.content));
  202. }
  203. async function showPreview(row) {
  204. const res = await fetch('/admin/content/detail?id=' + row.id).then(r => r.json());
  205. if (res.code !== 0) { ElementPlus.ElMessage.error(res.msg || '获取详情失败'); return; }
  206. previewData.title = res.data.title || '';
  207. previewData.content = res.data.content || '';
  208. previewVisible.value = true;
  209. }
  210. function initEditor(html) {
  211. destroyEditor();
  212. const { createEditor, createToolbar } = window.wangEditor;
  213. editorInstance = createEditor({
  214. selector: '#editor-container',
  215. html: html || '',
  216. config: {
  217. placeholder: '请输入内容...',
  218. MENU_CONF: {
  219. uploadImage: {
  220. server: '/admin/upload', fieldName: 'file', maxFileSize: 5 * 1024 * 1024,
  221. customInsert(res, insertFn) {
  222. if (res.code === 0 && res.data && res.data.url) { insertFn(res.data.url, res.data.name || '', ''); }
  223. else { ElementPlus.ElMessage.error(res.msg || '上传失败'); }
  224. }
  225. }
  226. },
  227. onChange(editor) { form.content = editor.getHtml(); }
  228. }
  229. });
  230. createToolbar({ editor: editorInstance, selector: '#toolbar-container', config: {} });
  231. }
  232. function destroyEditor() {
  233. if (editorInstance) { try { editorInstance.destroy(); } catch (e) {} editorInstance = null; }
  234. var toolbar = document.getElementById('toolbar-container');
  235. var editor = document.getElementById('editor-container');
  236. if (toolbar) toolbar.innerHTML = '';
  237. if (editor) editor.innerHTML = '';
  238. }
  239. function onDialogClosed() { destroyEditor(); }
  240. async function handleSave() {
  241. if (!form.content_key.trim()) { ElementPlus.ElMessage.warning('内容标识不能为空'); return; }
  242. if (!form.title.trim()) { ElementPlus.ElMessage.warning('标题不能为空'); return; }
  243. saving.value = true;
  244. try {
  245. const url = form.id ? '/admin/content/edit' : '/admin/content/add';
  246. const body = { title: form.title, remark: form.remark, content: form.content, sort: form.sort, status: form.status };
  247. if (form.id) { body.id = form.id; } else { body.content_key = form.content_key; }
  248. const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => r.json());
  249. if (res.code === 0) { ElementPlus.ElMessage.success('保存成功'); dialogVisible.value = false; loadList(); }
  250. else { ElementPlus.ElMessage.error(res.msg || '保存失败'); }
  251. } finally { saving.value = false; }
  252. }
  253. async function handleDelete(row) {
  254. try {
  255. await ElementPlus.ElMessageBox.confirm('确定要删除「' + row.title + '」吗?', '提示', { type: 'warning' });
  256. const res = await fetch('/admin/content/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: row.id }) }).then(r => r.json());
  257. if (res.code === 0) { ElementPlus.ElMessage.success('删除成功'); loadList(); }
  258. else { ElementPlus.ElMessage.error(res.msg || '删除失败'); }
  259. } catch {}
  260. }
  261. onMounted(() => loadList());
  262. return {
  263. keyword, statusFilter, loading, tableData, pagination,
  264. dialogVisible, dialogTitle, saving, form,
  265. previewVisible, previewData,
  266. canAdd, canEdit, canDelete,
  267. loadList, resetFilter, showAdd, showEdit, showPreview, handleSave, handleDelete, onDialogClosed
  268. };
  269. }
  270. });
  271. app.use(ElementPlus, { locale: ElementPlusLocaleZhCn });
  272. app.mount('#contentApp');
  273. </script>
  274. {% endblock %}