This commit is contained in:
yiqiuyang
2025-10-16 15:50:48 +08:00
4 changed files with 436 additions and 10 deletions

View File

@ -9,6 +9,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@vueup/vue-quill": "^1.2.0",
"@vueuse/core": "^13.8.0", "@vueuse/core": "^13.8.0",
"axios": "^1.12.2", "axios": "^1.12.2",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
@ -17,6 +18,7 @@
"mitt": "^3.0.1", "mitt": "^3.0.1",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.5.0", "pinia-plugin-persistedstate": "^4.5.0",
"quill": "^2.0.3",
"terser": "^5.43.1", "terser": "^5.43.1",
"v-viewer": "^3.0.11", "v-viewer": "^3.0.11",
"vue": "^3.5.18", "vue": "^3.5.18",

View File

@ -89,6 +89,15 @@ const routes = [
title: '下载中心', title: '下载中心',
}, },
}, },
// ============================================ 管理页面 ============================================
{
path: '/manager',
name: 'Manager',
component: () => import('@/views/manager/index.vue'),
meta: {
title: '管理中心',
},
},
] ]
const router = createRouter({ const router = createRouter({

View File

@ -1,10 +0,0 @@
<script setup>
import {ref, onMounted} from 'vue'
onMounted(() => {})
</script>
<template>
<div></div>
</template>
<style lang="scss" scoped></style>

425
src/views/manager/index.vue Normal file
View File

@ -0,0 +1,425 @@
<script setup>
import axios from 'axios';
import { ref, reactive, onMounted } from 'vue';
import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
const API_BASE = window.config?.baseUrl
// 新闻列表
const newsList = ref([]);
// 分页与状态过滤
const page = ref(1);
const pageSize = ref(10);
const totalPages = ref(1);
const statusFilter = ref("");
const loading = ref(false);
// 编辑新闻表单
const editingNews = reactive({
title: "",
cover_image: "",
snapshot: "",
content: "",
status: "draft",
slug: "",
datetime: null,
});
const showForm = ref(false);
const isEditing = ref(false);
// 响应式引用 Quill 实例
const quillInstance = ref(null);
// 当编辑器就绪时保存实例
const onEditorReady = (quill) => {
quillInstance.value = quill;
quill.root.addEventListener('paste', handlePaste);
};
// ---------------- Quill 配置 ----------------
const editorOption= {
placeholder: "请在这里输入",
modules:{
toolbar: {
container: [
['bold', 'italic', 'underline', 'strike'],
[{ header: 1 }, { header: 2 }],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ indent: '-1' }, { indent: '+1' }],
[{ color: [] }, { background: [] }],
[{ align: [] }],
['link', 'image'],
['clean'],
],
handlers: {
image: imageHandler,
}
},
}
};
async function handlePaste(event) {
const clipboardData = event.clipboardData || window.clipboardData;
if (!clipboardData) return;
const items = clipboardData.items;
if (!items) return;
// 检查是否有图片类型
let blob = null;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') === 0) {
blob = items[i].getAsFile();
break;
}
}
if (!blob) return;
// 阻止默认粘贴行为(避免插入 base64
event.preventDefault();
try {
// 上传图片
const imageUrl = await uploadImage(blob);
// 获取当前光标位置
const quill = quillInstance.value;
const selection = quill.getSelection();
const index = selection ? selection.index : quill.getLength();
// 插入图片 URL
quill.insertEmbed(index, 'image', imageUrl);
// 可选:移动光标到图片后
quill.setSelection(index + 1);
} catch (error) {
console.error('粘贴图片上传失败:', error);
// 可选:提示用户
}
}
function imageHandler() {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/*');
input.click();
input.onchange = () => {
const file = input.files[0];
// 将图片上传到服务器
uploadImage(file)
.then((imageUrl) => {
// 将图片 URL 插入到编辑器中
const quill = quillInstance.value
const range = quill.getSelection();
quill.insertEmbed(range.index, 'image', imageUrl);
})
.catch((error) => {
console.error('图片上传失败:', error);
});
};
}
// ----------------- 获取新闻 -----------------
const fetchNews = async () => {
try {
const res = await axios.get(`${API_BASE}/news/`, {
params: {
page: page.value,
page_size: pageSize.value,
status: statusFilter.value || undefined,
},
});
if (res.data.code === 0) {
newsList.value = res.data.data;
totalPages.value = res.data.pagination.total_pages;
}
} catch (err) {
console.error("获取新闻失败", err);
}
};
// ----------------- 添加/编辑 -----------------
const saveNews = async () => {
try {
if (isEditing.value) {
await axios.post(`${API_BASE}/news/B8fpNxunbxj37x3VRcVz`, editingNews);
} else {
await axios.post(`${API_BASE}/news/VbxWW8EdJQGyWzJyvSrN`, editingNews);
}
showForm.value = false;
fetchNews();
} catch (err) {
console.error("保存新闻失败", err);
}
};
const editNews = async (news) => {
loading.value = true;
try {
const res = await axios.get(`${API_BASE}/news/details`, {
params: { slug: news.slug }
});
console.log(res.data)
if (res.data.code === 0) {
console.log(res.data.data)
Object.assign(editingNews, res.data.data);
isEditing.value = true;
showForm.value = true;
}
} finally {
loading.value = false;
}
};
const addNews = () => {
Object.assign(editingNews, {
title: "",
cover_image: "",
snapshot: "",
content: "",
status: "draft",
slug: "",
});
isEditing.value = false;
showForm.value = true;
};
// 分页
const prevPage = () => {
if (page.value > 1) {
page.value--;
fetchNews();
}
};
const nextPage = () => {
if (page.value < totalPages.value) {
page.value++;
fetchNews();
}
};
function handleImageUpload(event) {
const file = event.target.files[0];
if (!file) return;
uploadImage(file)
.then((imageUrl) => {
editingNews.cover_image = imageUrl;
})
.catch((error) => {
console.error('封面图片上传失败:', error);
});
}
function uploadImage(file) {
return new Promise((resolve, reject) => {
const formData = new FormData();
formData.append('file', file);
fetch(`${API_BASE}/update/img`, {
method: 'POST',
body: formData,
})
.then((response) => response.json())
.then((data) => {
if (data.code === 0) {
console.log(data)
resolve(`${API_BASE}/download/img/${data.data}`);
} else {
reject(data.msg);
}
})
.catch((error) => {
reject(error);
});
});
}
onMounted(() => {
fetchNews();
});
</script>
<template>
<div class="page">
<!-- 操作栏 -->
<div class="my-card flex j-s a-c toolbar">
<select v-model="statusFilter" @change="fetchNews">
<option value="">全部状态</option>
<option value="draft">草稿</option>
<option value="published">发布</option>
<option value="disable">关闭</option>
</select>
<button @click="addNews">添加新闻</button>
</div>
<!-- 新闻列表 -->
<div class="my-card">
<table>
<thead>
<tr>
<th>ID</th>
<th>标题</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="n in newsList" :key="n.slug">
<td>{{ n.id }}</td>
<td>{{ n.title }}</td>
<td>{{ n.status }}</td>
<td>
<button @click="editNews(n)">编辑</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div class="my-card flex j-s a-c pagination">
<button @click="prevPage" :disabled="page === 1">上一页</button>
<span>{{ page }} / {{ totalPages }}</span>
<button @click="nextPage" :disabled="page === totalPages">下一页</button>
</div>
<!-- 添加/编辑表单 -->
<div v-if="showForm" class="my-card news-form">
<h3>{{ isEditing ? "编辑新闻" : "添加新闻" }}</h3>
<label>标题: <input v-model="editingNews.title" /></label><br />
<label>发布时间:<input type="date" v-model="editingNews.datetime" /></label><br />
<label>封面图片:
<input type="file" accept="image/*" @change="handleImageUpload" />
</label>
<div v-if="editingNews.cover_image">
<img :src="editingNews.cover_image" alt="封面预览" style="max-width:200px; margin-top:10px;" />
</div>
<label>内容简介: <textarea class="snapshot" v-model="editingNews.snapshot"></textarea></label><br />
<label>内容:</label>
<quill-editor v-model:content="editingNews.content" content-type="delta"
theme="snow" style="height: 300px" :options="editorOption" @ready="onEditorReady"/>
<label>状态:
<select v-model="editingNews.status">
<option value="draft">草稿</option>
<option value="published">发布</option>
<option value="disable">关闭</option>
</select>
</label><br />
<button @click="saveNews">{{ isEditing ? "保存修改" : "添加" }}</button>
<button @click="showForm=false">取消</button>
</div>
</div>
</template>
<style lang="scss" scoped>
.my-card {
width: 80%;
padding: 20px;
margin-bottom: 20px;
margin: 20px auto;
border: 1px solid #eee;
border-radius: 8px;
background-color: #fff;
box-shadow: 0 2px 6px rgba(0,0,0,0.05);
}
.flex {
display: flex;
}
.j-s { justify-content: space-between; }
.a-c { align-items: center; }
.toolbar select,
.toolbar button {
margin-right: 10px;
padding: 6px 12px;
font-size: 14px;
border-radius: 4px;
border: 1px solid #ccc;
background-color: #fafafa;
transition: 0.2s;
}
.toolbar button:hover {
background-color: #007BFF;
color: #fff;
border-color: #007BFF;
cursor: pointer;
}
table {
width: 100%;
border-collapse: collapse;
th, td {
padding: 10px 12px;
border: 1px solid #ddd;
text-align: left;
}
th {
background-color: #f0f0f0;
font-weight: 600;
}
tbody tr:nth-child(odd) { background-color: #fdfdfd; }
tbody tr:hover { background-color: #f5faff; }
}
button {
padding: 6px 12px;
border-radius: 4px;
border: 1px solid #ccc;
background-color: #fafafa;
transition: 0.2s;
}
button:hover {
background-color: #007BFF;
color: #fff;
border-color: #007BFF;
cursor: pointer;
}
.pagination {
gap: 10px;
button {
min-width: 80px;
text-align: center;
}
}
.snapshot{
height: 100px;
font-size: 14px;
}
.news-form input,
.news-form textarea,
.news-form select {
width: 100%;
margin-bottom: 12px;
padding: 8px 10px;
border-radius: 4px;
border: 1px solid #ccc;
font-size: 14px;
transition: 0.2s;
}
.news-form input:focus,
.news-form textarea:focus,
.news-form select:focus {
border-color: #007BFF;
outline: none;
}
.news-form button {
margin-right: 10px;
min-width: 100px;
}
.news-form h3 {
margin-bottom: 15px;
font-size: 18px;
color: #333;
}
</style>