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.
 
 
 
 
 
 

437 righe
18 KiB

  1. {% extends "../layout.html" %}
  2. {% block title %}{{ columnInfo.name if columnInfo else '图文列表' }}{% endblock %}
  3. {% block css %}
  4. <!-- WangEditor CSS -->
  5. <link href="https://cdn.jsdelivr.net/npm/@wangeditor/editor@latest/dist/css/style.css" rel="stylesheet">
  6. <style>
  7. /* 全屏弹窗 */
  8. .fullscreen-dialog .el-dialog { margin: 0 !important; width: 100vw !important; height: 100vh !important; max-width: 100vw !important; }
  9. .fullscreen-dialog .el-dialog__body { height: calc(100vh - 120px); padding: 0 20px; overflow: hidden; }
  10. .fullscreen-dialog .el-dialog__header { border-bottom: 1px solid #eee; }
  11. /* 编辑器布局 */
  12. .editor-layout { display: flex; gap: 20px; height: 100%; }
  13. .editor-main { flex: 1; min-width: 0; overflow-y: auto; padding: 20px 0; }
  14. .editor-side { width: 320px; flex-shrink: 0; overflow-y: auto; padding: 20px 0; }
  15. /* 侧边区块 */
  16. .side-section { background: #fff; border: 1px solid #eee; border-radius: 8px; padding: 16px; margin-bottom: 16px; }
  17. .side-title { font-size: 14px; font-weight: 600; color: #333; margin-bottom: 14px; padding-bottom: 8px; border-bottom: 1px solid #f0f0f0; }
  18. /* 封面上传 */
  19. .cover-upload { width: 100%; aspect-ratio: 16/9; border: 2px dashed #ddd; border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: center; overflow: hidden; background: #fafafa; transition: .2s; }
  20. .cover-upload:hover { border-color: #ff7800; background: #fff7f0; }
  21. .cover-upload img { width: 100%; height: 100%; object-fit: cover; }
  22. .cover-placeholder { display: flex; flex-direction: column; align-items: center; gap: 6px; color: #bbb; font-size: 12px; }
  23. /* 富文本编辑器 */
  24. .editor-container { border: 1px solid #ddd; border-radius: 8px; overflow: hidden; }
  25. .editor-toolbar { border-bottom: 1px solid #ddd !important; }
  26. .editor-content { height: 400px; overflow-y: auto; }
  27. /* 列表缩略图 */
  28. .thumb { width: 60px; height: 40px; border-radius: 4px; overflow: hidden; background: #f5f5f5; }
  29. .thumb img { width: 100%; height: 100%; object-fit: cover; }
  30. </style>
  31. {% endblock %}
  32. {% block content %}
  33. <div id="articleApp">
  34. <el-card shadow="never">
  35. <template #header>
  36. <div class="flex items-center justify-between">
  37. <div class="flex items-center gap-2">
  38. <span class="font-medium">{{ columnInfo.name if columnInfo else '图文列表' }}</span>
  39. <el-tag type="primary" size="small">图文列表</el-tag>
  40. </div>
  41. <el-button type="primary" @click="openDialog()">+ 新增文章</el-button>
  42. </div>
  43. </template>
  44. <!-- 筛选栏 -->
  45. <div class="flex items-center gap-4 mb-4">
  46. <el-input v-model="query.keyword" placeholder="搜索标题..." style="width:200px;" clearable @keyup.enter="loadList"></el-input>
  47. <el-select v-model="query.status" placeholder="状态" style="width:100px;" clearable>
  48. <el-option label="已发布" :value="1"></el-option>
  49. <el-option label="待审核" :value="2"></el-option>
  50. <el-option label="草稿" :value="0"></el-option>
  51. </el-select>
  52. <el-button type="primary" @click="loadList">搜索</el-button>
  53. <el-button @click="resetQuery" class="ml-2">重置</el-button>
  54. </div>
  55. <!-- 信息栏 -->
  56. <div class="flex items-center gap-3 mb-3 text-sm text-gray-500">
  57. <span>已选择 <b class="text-orange-500">${ selectedIds.length }</b> 项,共 <b class="text-orange-500">${ total }</b> 条记录</span>
  58. <div class="flex-1"></div>
  59. <el-button size="small" :disabled="!selectedIds.length" @click="batchDelete">批量删除</el-button>
  60. </div>
  61. <!-- 表格 -->
  62. <el-table :data="list" v-loading="loading" @selection-change="handleSelectionChange" border>
  63. <el-table-column type="selection" width="40"></el-table-column>
  64. <el-table-column prop="title" label="标题" min-width="300"></el-table-column>
  65. <el-table-column prop="category" label="分类" width="100"></el-table-column>
  66. <el-table-column label="封面图" width="80">
  67. <template #default="{ row }">
  68. <div class="thumb" v-if="row.cover"><img :src="row.cover" alt=""></div>
  69. <span v-else class="text-gray-300">-</span>
  70. </template>
  71. </el-table-column>
  72. <el-table-column prop="status" label="状态" width="80">
  73. <template #default="{ row }">
  74. <el-tag :type="statusMap[row.status]?.type" size="small">${ statusMap[row.status]?.text }</el-tag>
  75. </template>
  76. </el-table-column>
  77. <el-table-column prop="is_top" label="置顶" width="70">
  78. <template #default="{ row }">
  79. <el-tag :type="row.is_top ? 'primary' : 'info'" size="small">${ row.is_top ? '是' : '否' }</el-tag>
  80. </template>
  81. </el-table-column>
  82. <el-table-column prop="publish_time" label="发布时间" width="140">
  83. <template #default="{ row }">${ row.publish_time?.slice(0,16) || '-' }</template>
  84. </el-table-column>
  85. <el-table-column label="操作" width="140" fixed="right">
  86. <template #default="{ row }">
  87. <el-button type="primary" link size="small" @click="openDialog(row)">编辑</el-button>
  88. <el-button type="primary" link size="small" @click="previewArticle(row)">查看</el-button>
  89. <el-button type="danger" link size="small" @click="deleteItem(row)">删除</el-button>
  90. </template>
  91. </el-table-column>
  92. </el-table>
  93. <!-- 分页 -->
  94. <div class="flex justify-end mt-4" v-if="total > pageSize">
  95. <el-pagination background layout="prev, pager, next" :total="total" :page-size="pageSize" v-model:current-page="page" @current-change="loadList"></el-pagination>
  96. </div>
  97. </el-card>
  98. <!-- 全屏编辑弹窗 -->
  99. <el-dialog v-model="dialogVisible" :title="dialogTitle" fullscreen class="fullscreen-dialog" destroy-on-close :close-on-click-modal="false">
  100. <div class="editor-layout">
  101. <!-- 左侧主区域 -->
  102. <div class="editor-main">
  103. <el-form :model="form" label-position="top">
  104. <el-form-item label="文章标题" required>
  105. <el-input v-model="form.title" placeholder="请输入文章标题" size="large"></el-input>
  106. </el-form-item>
  107. <el-form-item label="文章摘要">
  108. <el-input v-model="form.summary" type="textarea" :rows="3" placeholder="请输入文章摘要(选填,不填则自动截取正文前200字)"></el-input>
  109. </el-form-item>
  110. <el-form-item label="文章正文" required>
  111. <div class="editor-container">
  112. <div id="toolbar-container" class="editor-toolbar"></div>
  113. <div id="editor-container" class="editor-content"></div>
  114. </div>
  115. </el-form-item>
  116. </el-form>
  117. </div>
  118. <!-- 右侧属性区 -->
  119. <div class="editor-side">
  120. <div class="side-section">
  121. <div class="side-title">发布设置</div>
  122. <el-form :model="form" label-position="top" size="default">
  123. <el-form-item label="分类">
  124. <el-select v-model="form.category" placeholder="选择分类" style="width:100%;" filterable allow-create :teleported="false">
  125. <el-option v-for="c in categoryOptions" :key="c" :label="c" :value="c"></el-option>
  126. </el-select>
  127. </el-form-item>
  128. <el-form-item label="状态">
  129. <el-select v-model="form.status" style="width:100%;" :teleported="false">
  130. <el-option label="草稿" :value="0"></el-option>
  131. <el-option label="待审核" :value="2"></el-option>
  132. <el-option label="已发布" :value="1"></el-option>
  133. </el-select>
  134. </el-form-item>
  135. <el-form-item label="发布时间">
  136. <el-date-picker v-model="form.publish_time" type="datetime" placeholder="选择发布时间" style="width:100%;" value-format="YYYY-MM-DD HH:mm:ss" :teleported="false"></el-date-picker>
  137. </el-form-item>
  138. <el-form-item label="排序权重">
  139. <el-input-number v-model="form.sort" :min="0" style="width:100%;"></el-input-number>
  140. </el-form-item>
  141. <div class="flex gap-6">
  142. <el-checkbox v-model="form.is_top" :true-value="1" :false-value="0">置顶</el-checkbox>
  143. <el-checkbox v-model="form.is_recommend" :true-value="1" :false-value="0">推荐到首页</el-checkbox>
  144. </div>
  145. </el-form>
  146. </div>
  147. <div class="side-section">
  148. <div class="side-title">封面图</div>
  149. <div class="cover-upload" @click="triggerCoverUpload">
  150. <img v-if="form.cover" :src="form.cover" alt="封面图">
  151. <div v-else class="cover-placeholder">
  152. <el-icon :size="32"><Plus /></el-icon>
  153. <span>点击上传封面图</span>
  154. </div>
  155. </div>
  156. <input type="file" ref="coverInput" accept="image/*" style="display:none;" @change="handleCoverUpload">
  157. <el-button v-if="form.cover" size="small" class="mt-2 w-full" @click="form.cover = ''">移除封面</el-button>
  158. </div>
  159. <div class="side-section">
  160. <div class="side-title">SEO 设置</div>
  161. <el-form :model="form" label-position="top" size="default">
  162. <el-form-item label="SEO 标题">
  163. <el-input v-model="form.seo_title" placeholder="留空则使用文章标题"></el-input>
  164. </el-form-item>
  165. <el-form-item label="关键词">
  166. <el-input v-model="form.seo_keywords" placeholder="多个关键词用逗号分隔"></el-input>
  167. </el-form-item>
  168. <el-form-item label="描述">
  169. <el-input v-model="form.seo_description" type="textarea" :rows="2" placeholder="留空则使用文章摘要"></el-input>
  170. </el-form-item>
  171. </el-form>
  172. </div>
  173. </div>
  174. </div>
  175. <template #footer>
  176. <div class="flex justify-between">
  177. <el-button @click="dialogVisible = false">返回</el-button>
  178. <div class="flex gap-2">
  179. <el-button @click="saveAsDraft" :loading="saving">保存草稿</el-button>
  180. <el-button type="primary" @click="saveItem" :loading="saving">保存</el-button>
  181. </div>
  182. </div>
  183. </template>
  184. </el-dialog>
  185. </div>
  186. {% endblock %}
  187. {% block js %}
  188. <!-- WangEditor JS -->
  189. <script src="https://cdn.jsdelivr.net/npm/@wangeditor/editor@latest/dist/index.js"></script>
  190. <script>
  191. const col = '{{ col }}';
  192. const { createApp, ref, reactive, onMounted, onBeforeUnmount, nextTick } = Vue;
  193. const app = createApp({
  194. delimiters: ['${', '}'],
  195. setup() {
  196. const loading = ref(false);
  197. const list = ref([]);
  198. const total = ref(0);
  199. const page = ref(1);
  200. const pageSize = ref(20);
  201. const query = reactive({ keyword: '', status: '' });
  202. const selectedIds = ref([]);
  203. const dialogVisible = ref(false);
  204. const dialogTitle = ref('新增文章');
  205. const saving = ref(false);
  206. const coverInput = ref(null);
  207. let editor = null;
  208. const categoryOptions = ['基金会动态', '行业资讯', '通知公告', '公益项目', '党建活动', '党建学习'];
  209. const statusMap = {
  210. 1: { type: 'success', text: '已发布' },
  211. 2: { type: 'warning', text: '待审核' },
  212. 0: { type: 'info', text: '草稿' }
  213. };
  214. const defaultForm = {
  215. id: null, title: '', summary: '', content: '', cover: '', category: '',
  216. is_top: 0, is_recommend: 0, sort: 0, status: 0, publish_time: '',
  217. seo_title: '', seo_keywords: '', seo_description: ''
  218. };
  219. const form = reactive({ ...defaultForm });
  220. async function loadList() {
  221. loading.value = true;
  222. try {
  223. const params = new URLSearchParams({ col, page: page.value, pageSize: pageSize.value, ...query });
  224. const res = await fetch('/admin/content/article/list?' + params).then(r => r.json());
  225. if (res.code === 0) {
  226. list.value = res.data.data || [];
  227. total.value = res.data.count || 0;
  228. }
  229. } finally { loading.value = false; }
  230. }
  231. function resetQuery() {
  232. query.keyword = '';
  233. query.status = '';
  234. page.value = 1;
  235. loadList();
  236. }
  237. function handleSelectionChange(rows) {
  238. selectedIds.value = rows.map(r => r.id);
  239. }
  240. async function openDialog(item) {
  241. if (item) {
  242. dialogTitle.value = '编辑文章';
  243. // 获取完整内容
  244. const res = await fetch('/admin/content/article/detail?id=' + item.id).then(r => r.json());
  245. if (res.code === 0 && res.data) {
  246. const data = res.data;
  247. Object.assign(form, {
  248. id: data.id, title: data.title, summary: data.summary || '', content: data.content || '',
  249. cover: data.cover || '', category: data.category || '', is_top: data.is_top,
  250. is_recommend: data.is_recommend, sort: data.sort || 0, status: data.status,
  251. publish_time: data.publish_time || '', seo_title: data.seo_title || '',
  252. seo_keywords: data.seo_keywords || '', seo_description: data.seo_description || ''
  253. });
  254. }
  255. } else {
  256. dialogTitle.value = '新增文章';
  257. Object.assign(form, { ...defaultForm });
  258. }
  259. dialogVisible.value = true;
  260. nextTick(() => initEditor());
  261. }
  262. function initEditor() {
  263. if (editor) {
  264. editor.destroy();
  265. editor = null;
  266. }
  267. const { createEditor, createToolbar } = window.wangEditor;
  268. editor = createEditor({
  269. selector: '#editor-container',
  270. html: form.content || '<p></p>',
  271. config: {
  272. placeholder: '请输入文章正文...',
  273. onChange(ed) {
  274. form.content = ed.getHtml();
  275. },
  276. MENU_CONF: {
  277. uploadImage: {
  278. async customUpload(file, insertFn) {
  279. const formData = new FormData();
  280. formData.append('file', file);
  281. try {
  282. const res = await fetch('/admin/upload', { method: 'POST', body: formData }).then(r => r.json());
  283. if (res.code === 0) {
  284. insertFn(res.data.url, file.name, res.data.url);
  285. } else {
  286. ElementPlus.ElMessage.error(res.msg || '上传失败');
  287. }
  288. } catch (e) {
  289. ElementPlus.ElMessage.error('上传失败');
  290. }
  291. }
  292. }
  293. }
  294. }
  295. });
  296. createToolbar({
  297. editor,
  298. selector: '#toolbar-container',
  299. config: {}
  300. });
  301. }
  302. function triggerCoverUpload() {
  303. coverInput.value?.click();
  304. }
  305. async function handleCoverUpload(e) {
  306. const file = e.target.files[0];
  307. if (!file) return;
  308. const formData = new FormData();
  309. formData.append('file', file);
  310. try {
  311. const res = await fetch('/admin/upload', { method: 'POST', body: formData }).then(r => r.json());
  312. if (res.code === 0) {
  313. form.cover = res.data.url;
  314. ElementPlus.ElMessage.success('上传成功');
  315. } else {
  316. ElementPlus.ElMessage.error(res.msg || '上传失败');
  317. }
  318. } catch (err) {
  319. ElementPlus.ElMessage.error('上传失败');
  320. }
  321. e.target.value = '';
  322. }
  323. async function saveAsDraft() {
  324. form.status = 0;
  325. await doSave();
  326. }
  327. async function saveItem() {
  328. await doSave();
  329. }
  330. async function doSave() {
  331. if (!form.title.trim()) { ElementPlus.ElMessage.warning('请输入文章标题'); return; }
  332. saving.value = true;
  333. try {
  334. const url = form.id ? '/admin/content/article/edit' : '/admin/content/article/add';
  335. const body = { ...form, col };
  336. const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => r.json());
  337. if (res.code === 0) {
  338. ElementPlus.ElMessage.success('保存成功');
  339. dialogVisible.value = false;
  340. if (editor) { editor.destroy(); editor = null; }
  341. loadList();
  342. } else {
  343. ElementPlus.ElMessage.error(res.msg || '保存失败');
  344. }
  345. } finally { saving.value = false; }
  346. }
  347. async function deleteItem(item) {
  348. try {
  349. await ElementPlus.ElMessageBox.confirm('确定删除"' + item.title + '"?', '提示', { type: 'warning' });
  350. const res = await fetch('/admin/content/article/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: item.id }) }).then(r => r.json());
  351. if (res.code === 0) {
  352. ElementPlus.ElMessage.success('删除成功');
  353. loadList();
  354. } else {
  355. ElementPlus.ElMessage.error(res.msg || '删除失败');
  356. }
  357. } catch {}
  358. }
  359. async function batchDelete() {
  360. if (!selectedIds.value.length) return;
  361. try {
  362. await ElementPlus.ElMessageBox.confirm('确定删除选中的 ' + selectedIds.value.length + ' 条记录?', '提示', { type: 'warning' });
  363. const res = await fetch('/admin/content/article/batchDelete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: selectedIds.value }) }).then(r => r.json());
  364. if (res.code === 0) {
  365. ElementPlus.ElMessage.success('删除成功');
  366. loadList();
  367. } else {
  368. ElementPlus.ElMessage.error(res.msg || '删除失败');
  369. }
  370. } catch {}
  371. }
  372. function previewArticle(row) {
  373. // 可以打开新窗口预览
  374. ElementPlus.ElMessage.info('预览功能待实现');
  375. }
  376. onMounted(() => loadList());
  377. onBeforeUnmount(() => {
  378. if (editor) { editor.destroy(); editor = null; }
  379. });
  380. return {
  381. loading, list, total, page, pageSize, query, selectedIds,
  382. dialogVisible, dialogTitle, saving, form, coverInput,
  383. categoryOptions, statusMap,
  384. loadList, resetQuery, handleSelectionChange, openDialog,
  385. triggerCoverUpload, handleCoverUpload, saveAsDraft, saveItem,
  386. deleteItem, batchDelete, previewArticle
  387. };
  388. }
  389. });
  390. for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
  391. app.component(key, component);
  392. }
  393. app.use(ElementPlus, { locale: ElementPlusLocaleZhCn });
  394. app.mount('#articleApp');
  395. </script>
  396. {% endblock %}