@@ -1,499 +0,0 @@
< template >
< el-config-provider :locale = "zhCn" >
< div class = "p-6 bg-white rounded-lg shadow-md min-h-screen pb-24" v-loading = "loading" >
< div class = "flex justify-between items-center mb-6" >
< span class = "text-2xl font-bold text-gray-700" > 社区管理列表 < / span >
< / div >
< el-table :data = "tableData" style = "width: 100%" row -key = " id " max -height = " 82vh " >
< el-table-column prop = "id" label = "ID" width = "80" sortable fixed > < / el-table-column >
< el-table-column label = "封面" width = "180" >
< template # default = "scope" >
< el-image style = "width: 120px; height: 70px; border-radius: 6px;" :src = "scope.row.cover" :preview-src-list = "[scope.row.cover]" fit = "cover" :preview-teleported = "true" hide -on -click -modal >
< template # error >
< div class = "flex items-center justify-center w-full h-full bg-gray-100 text-gray-500" > 加载失败 < / div >
< / template >
< / el-image >
< / template >
< / el-table-column >
< el-table-column prop = "title" label = "标题" min -width = " 200 " show -overflow -tooltip >
< template # default = "scope" >
< span class = "font-semibold" > { { scope . row . title } } < / span >
< / template >
< / el-table-column >
< el-table-column label = "文章内容" min -width = " 300 " >
< template # default = "scope" >
< div class = "content-preview" :title = "stripHtml(scope.row.content)" >
{ { stripHtml ( scope . row . content ) . length > 100 ? ` ${ stripHtml ( scope . row . content ) . slice ( 0 , 100 ) } ... ` : stripHtml ( scope . row . content ) } }
< / div >
< el-button size = "mini" type = "text" class = "mt-1 text-blue-600" @click ="handleViewContent(scope.row)" > 查看详情 < / el -button >
< / template >
< / el-table-column >
< el-table-column prop = "topic" label = "主题" width = "120" > < / el-table-column >
< el-table-column prop = "excerpt" label = "摘要" min -width = " 250 " show -overflow -tooltip > < / el-table-column >
< el-table-column prop = "create_at" label = "创建时间" width = "180" sortable > < / el-table-column >
< el-table-column prop = "update_at" label = "更新时间" width = "180" sortable > < / el-table-column >
< el-table-column label = "操作" width = "200" fixed = "right" >
< template # default = "scope" >
< div class = "action-buttons" >
< el-button size = "small" type = "primary" :icon = "Edit" @click ="handleEdit(scope.row)" > 编辑 < / el -button >
< el-button size = "small" type = "danger" :icon = "Delete" @click ="handleDelete(scope.row)" > 删除 < / el -button >
< / div >
< / template >
< / el-table-column >
< / el-table >
< el-dialog v-model = "contentDialogVisible" title="文章内容详情" :width="`800px`" :before-close="handleCloseDialog" >
< h3 class = "text-xl font-bold text-gray-800 mb-4" > 标题 : { { currentArticle . title } } < / h3 >
< div class = "content-full text-gray-700 leading-relaxed whitespace-pre-wrap" v-html = "currentArticle.content || '当前文章暂无内容'" >
< / div >
< / el -dialog >
< / div >
< div class = "fixed bottom-0 right-0 w-full p-4 bg-white shadow-[0_-2px_5px_rgba(0,0,0,0.05)] flex justify-end z-10 border-t border-gray-200" >
< el-pagination class = "custom-pagination" background layout = "total, sizes, prev, pager, next, jumper" :total = "total" v -model :current-page = "currentPage" v -model :page-size = "pageSize" : page -sizes = " [ 10 , 20 , 50 , 100 ] " @ size -change = " handleSizeChange " @ current -change = " handleCurrentChange " > < / el-pagination >
< / div >
< el-drawer v-model = "drawerVisible" title="编辑文章" direction="rtl" size="60%" :before-close="handleDrawerClose" destroy -on -close >
< div class = "publish-form-container" >
< div class = "form-group title-group" >
< label class = "form-label" > 文章标题 < / label >
< el-input v-model = "form.title" placeholder="请输入文章标题" clearable :disabled="isSubmitting" / >
< / div >
< div class = "form-group cover-group" >
< label class = "form-label" > 文章封面 < / label >
< el-upload action = "http://localhost:8080/api/upload/cover" name = "image" :show-file-list = "false" :on-success = "handleCoverSuccess" :before-upload = "beforeCoverUpload" :on-error = "handleCoverError" :disabled = "isSubmitting" >
< img v-if = "form.cover" :src="form.cover" class="cover-preview" alt="封面" />
< el -icon v-else class = "cover-uploader-icon" > < Plus / > < / el-icon >
< / el-upload >
< / div >
< div class = "form-group excerpt-group" >
< label class = "form-label" > 文章摘要 < / label >
< el-input
v-model = "form.excerpt"
type = "textarea"
:rows = "4"
placeholder = "请输入文章摘要(可选,若不填则自动从正文截取)"
clearable
:disabled = "isSubmitting"
/ >
< / div >
< div class = "form-group content-group" >
< label class = "form-label" > 文章内容 < / label >
< div ref = "editorRef" class = "quill-editor" > < / div >
< / div >
< / div >
< template # footer >
< div style = "flex: auto" >
< el-button @click ="handleDrawerClose" > 取消 < / el -button >
< el-button type = "primary" @click ="submitArticle" :loading = "isSubmitting" >
更新文章
< / el-button >
< / div >
< / template >
< / el-drawer >
< / el-config-provider >
< / template >
< script lang = "ts" setup >
import { ref , onMounted , watch , nextTick } from 'vue' ;
import { ElMessage , ElMessageBox , ElDialog , ElConfigProvider , ElDrawer , ElInput , ElUpload } from 'element-plus' ;
import { Edit , Delete , Plus } from '@element-plus/icons-vue' ;
import zhCn from 'element-plus/dist/locale/zh-cn.mjs' ;
import Quill from 'quill' ;
import 'quill/dist/quill.snow.css' ;
import type { UploadProps } from 'element-plus' ;
// --- 类型定义 ---
interface Article {
id : number ;
title : string ;
content : string ;
cover : string ;
create _at : string ;
update _at : string ;
is _delete : number ;
topic : string ;
excerpt : string ;
}
interface ListArticleReq {
page : number ;
size : number ;
topic : string ;
}
// --- API 基地址 ---
const API _BASE _URL = 'http://localhost:8080/api' ;
// =================================================================
// 列表页相关状态与逻辑
// =================================================================
const loading = ref ( true ) ;
const tableData = ref < Article [ ] > ( [ ] ) ;
const currentPage = ref ( 1 ) ;
const pageSize = ref ( 10 ) ;
const total = ref ( 0 ) ;
const contentDialogVisible = ref ( false ) ;
const currentArticle = ref < Article > ( { id : 0 , title : '' , content : '' , cover : '' , create _at : '' , update _at : '' , is _delete : 0 , topic : '' , excerpt : '' } ) ;
// --- HTML清理函数 ---
const stripHtml = ( html : string ) => {
if ( ! html ) return '' ;
const tempDiv = document . createElement ( 'div' ) ;
tempDiv . innerHTML = html ;
return tempDiv . textContent || tempDiv . innerText || '' ;
} ;
const fetchData = async ( ) => {
loading . value = true ;
try {
const reqData : ListArticleReq = { page : currentPage . value , size : pageSize . value , topic : "社区服务" } ;
const response = await fetch ( ` ${ API _BASE _URL } /articles/getarticle ` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( reqData )
} ) ;
if ( ! response . ok ) throw new Error ( ` 请求失败!状态码: ${ response . status } ` ) ;
const data = await response . json ( ) ;
tableData . value = data . Article _list || [ ] ;
total . value = data . total || 0 ;
} catch ( error ) {
console . error ( '[ERROR] 获取文章列表失败:' , error ) ;
ElMessage . error ( '获取文章列表失败,请检查接口或网络!' ) ;
} finally {
loading . value = false ;
}
} ;
onMounted ( fetchData ) ;
const handleViewContent = ( row : Article ) => {
currentArticle . value = { ... row } ;
contentDialogVisible . value = true ;
} ;
const handleCloseDialog = ( ) => {
contentDialogVisible . value = false ;
} ;
// --- 🔥 新增:删除功能 ---
const handleDelete = ( row : Article ) => {
ElMessageBox . confirm ( ` 确定要删除文章《 ${ row . title } 》吗?此操作无法撤销! ` , '警告' , {
confirmButtonText : '确定删除' ,
cancelButtonText : '取消' ,
type : 'warning' ,
} )
. then ( async ( ) => {
try {
const response = await fetch ( ` ${ API _BASE _URL } /articles/ ${ row . id } ` , {
method : 'DELETE' ,
} ) ;
if ( ! response . ok ) {
// 如果后端返回错误信息,尝试解析并显示
const errData = await response . json ( ) . catch ( ( ) => null ) ;
throw new Error ( errData ? . message || '删除失败' ) ;
}
ElMessage . success ( '删除成功!' ) ;
// 刷新列表数据
fetchData ( ) ;
} catch ( error ) {
console . error ( '[ERROR] 删除文章失败:' , error ) ;
ElMessage . error ( ` 删除文章失败: ${ ( error as Error ) . message } ` ) ;
}
} )
. catch ( ( ) => {
ElMessage . info ( '已取消删除' ) ;
} ) ;
} ;
const handleSizeChange = ( val : number ) => {
pageSize . value = val ;
currentPage . value = 1 ;
fetchData ( ) ;
} ;
const handleCurrentChange = ( val : number ) => {
currentPage . value = val ;
fetchData ( ) ;
} ;
// =================================================================
// 抽屉编辑相关状态与逻辑
// =================================================================
const drawerVisible = ref ( false ) ;
const isSubmitting = ref ( false ) ;
const defaultFormState = ( ) => ( {
id : null as number | null ,
title : '' ,
topic : 'news' ,
cover : '' ,
content : '' ,
excerpt : '' ,
} ) ;
const form = ref ( defaultFormState ( ) ) ;
// --- 富文本编辑器 ---
const editorRef = ref < HTMLDivElement | null > ( null ) ;
let quillInstance : Quill | null = null ;
const initQuillEditor = ( ) => {
if ( editorRef . value && ! quillInstance ) {
quillInstance = new Quill ( editorRef . value , {
theme : 'snow' ,
modules : {
toolbar : {
container : [ [ 'bold' , 'italic' , 'underline' ] , [ 'link' , 'image' ] ] ,
handlers : {
image : handleEditorImageUpload ,
}
}
} ,
placeholder : '请开始创作你的文章...'
} ) ;
quillInstance . on ( 'text-change' , ( delta , _ , source ) => {
if ( source === 'user' ) {
const pastedBase64 = getPastedBase64Image ( delta ) ;
if ( pastedBase64 ) {
const selection = quillInstance ? . getSelection ( ) ;
if ( ! selection ) return ;
const imageIndex = selection . index ;
quillInstance ? . deleteText ( imageIndex , 1 ) ;
const blob = base64ToBlob ( pastedBase64 ) ;
if ( blob ) {
const file = new File ( [ blob ] , ` pasted-image- ${ Date . now ( ) } .png ` , { type : blob . type } ) ;
uploadEditorImage ( file , imageIndex ) ;
}
}
}
} ) ;
}
} ;
watch ( drawerVisible , ( visible ) => {
if ( visible ) {
nextTick ( ( ) => {
initQuillEditor ( ) ;
if ( quillInstance ) {
quillInstance . root . innerHTML = form . value . content ;
}
} ) ;
} else {
quillInstance = null ;
}
} ) ;
// --- 编辑器图片上传相关函数 ---
function handleEditorImageUpload ( ) {
const fileInput = document . createElement ( 'input' ) ;
fileInput . type = 'file' ;
fileInput . accept = 'image/*' ;
fileInput . onchange = ( ) => {
const file = fileInput . files ? . [ 0 ] ;
if ( ! file ) return ;
const selection = quillInstance ? . getSelection ( ) ;
const insertIndex = selection ? selection . index : quillInstance ? . getLength ( ) || 0 ;
uploadEditorImage ( file , insertIndex ) ;
fileInput . value = '' ;
} ;
fileInput . click ( ) ;
}
async function uploadEditorImage ( file : File , insertIndex : number ) {
if ( ! quillInstance ) return ;
quillInstance . insertText ( insertIndex , '[图片上传中...]' , { color : '#999' , italic : true } ) ;
try {
const formData = new FormData ( ) ;
formData . append ( 'image' , file ) ;
const response = await fetch ( ` ${ API _BASE _URL } /upload/image ` , {
method : 'POST' ,
body : formData ,
} ) ;
if ( ! response . ok ) throw new Error ( ` HTTP状态码: ${ response . status } ` ) ;
const result = await response . json ( ) ;
const imageUrl = result . data ? . url ;
if ( ! imageUrl ) throw new Error ( '未从响应中获取到图片地址' ) ;
quillInstance . deleteText ( insertIndex , '[图片上传中...]' . length ) ;
quillInstance . insertEmbed ( insertIndex , 'image' , imageUrl ) ;
quillInstance . setSelection ( insertIndex + 1 ) ;
} catch ( err ) {
const errMsg = err instanceof Error ? err . message : '未知错误' ;
quillInstance . deleteText ( insertIndex , '[图片上传中...]' . length ) ;
quillInstance . insertText ( insertIndex , ` [图片上传失败: ${ errMsg } ] ` , { color : '#f56c6c' , italic : true } ) ;
ElMessage . error ( ` 编辑器图片上传失败: ${ errMsg } ` ) ;
}
}
function getPastedBase64Image ( delta : any ) : string | null {
if ( ! delta ? . ops ) return null ;
for ( const op of delta . ops ) {
if (
op . insert &&
typeof op . insert === 'object' &&
op . insert . image &&
op . insert . image . startsWith ( 'data:image' )
) {
return op . insert . image ;
}
}
return null ;
}
function base64ToBlob ( base64 : string ) : Blob | null {
try {
const [ prefix , data ] = base64 . split ( ',' ) ;
if ( ! prefix || ! data ) throw new Error ( 'base64格式错误' ) ;
const mimeMatch = prefix . match ( /data:(.*?);/ ) ;
if ( ! mimeMatch ) throw new Error ( '无法解析图片类型' ) ;
const mime = mimeMatch [ 1 ] ;
const binaryData = atob ( data ) ;
const bytes = new Uint8Array ( binaryData . length ) ;
for ( let i = 0 ; i < binaryData . length ; i ++ ) {
bytes [ i ] = binaryData . charCodeAt ( i ) ;
}
return new Blob ( [ bytes ] , { type : mime } ) ;
} catch ( error ) {
console . error ( 'base64转Blob失败: ' , error ) ;
ElMessage . error ( '解析粘贴的图片失败!' ) ;
return null ;
}
}
// --- 操作处理 ---
const handleEdit = ( row : Article ) => {
form . value = {
id : row . id ,
title : row . title ,
topic : row . topic ,
cover : row . cover ,
excerpt : row . excerpt ,
content : row . content ,
} ;
drawerVisible . value = true ;
} ;
const handleDrawerClose = ( ) => {
drawerVisible . value = false ;
} ;
const submitArticle = async ( ) => {
if ( ! form . value . title ) {
ElMessage . warning ( '请输入文章标题' ) ;
return ;
}
if ( ! form . value . id ) {
ElMessage . error ( '文章ID丢失, 无法更新! ' ) ;
return ;
}
isSubmitting . value = true ;
const contentHTML = quillInstance ? . root . innerHTML || '' ;
const excerpt = form . value . excerpt . trim ( ) || quillInstance ? . getText ( ) . trim ( ) . slice ( 0 , 150 ) || '' ;
const submitData = {
title : form . value . title ,
cover : form . value . cover ,
excerpt : excerpt ,
content : contentHTML ,
topic : form . value . topic ,
} ;
console . log ( "修改文章信息:" , JSON . stringify ( submitData ) ) ;
try {
const url = ` ${ API _BASE _URL } /articles/ ${ Number ( form . value . id ) } ` ;
const method = 'PUT' ;
const response = await fetch ( url , {
method : method ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( submitData ) ,
} ) ;
if ( ! response . ok ) throw new Error ( '提交失败' ) ;
ElMessage . success ( '文章更新成功!' ) ;
drawerVisible . value = false ;
fetchData ( ) ;
} catch ( error ) {
const err = error as Error ;
ElMessage . error ( ` 更新失败: ${ err . message } ` ) ;
} finally {
isSubmitting . value = false ;
}
} ;
// --- 封面上传处理 ---
const handleCoverSuccess : UploadProps [ 'onSuccess' ] = ( response ) => {
const ossUrl = response . data ? . url ;
if ( ossUrl ) {
form . value . cover = ossUrl ;
ElMessage . success ( '封面上传成功' ) ;
} else {
ElMessage . error ( '封面上传失败' ) ;
}
} ;
const beforeCoverUpload : UploadProps [ 'beforeUpload' ] = ( rawFile ) => {
const isLt5M = rawFile . size / 1024 / 1024 < 5 ;
if ( ! isLt5M ) {
ElMessage . error ( '图片大小不能超过 5MB!' ) ;
}
return isLt5M ;
} ;
const handleCoverError = ( ) => {
ElMessage . error ( '封面上传失败' ) ;
} ;
< / script >
< style scoped >
/* 表格样式 */
. el - table . el - table _ _cell { vertical - align : middle ; }
. el - table _ _header - wrapper th { background - color : # fafafa ! important ; font - weight : 600 ; color : # 333 ; }
. action - buttons . el - button { margin - right : 8 px ; }
. content - preview { color : # 666 ; line - height : 1.5 ; word - break : break - all ; }
. content - full { min - height : 300 px ; padding : 20 px ; background - color : # f9fafb ; border - radius : 8 px ; }
. el - dialog _ _title { font - size : 18 px ! important ; font - weight : 600 ! important ; }
/* 分页器样式 */
. custom - pagination { justify - content : flex - end ! important ; }
. custom - pagination . el - pagination _ _total ,
. custom - pagination . el - pagination _ _sizes ,
. custom - pagination . el - pagination _ _jump { margin - right : 16 px ! important ; }
. custom - pagination . el - pagination _ _jump . el - input { width : 60 px ! important ; }
/* 抽屉内表单样式 */
. publish - form - container { padding : 0 20 px ; }
. form - group { margin - bottom : 24 px ; }
. form - label { display : block ; margin - bottom : 8 px ; color : # 333 ; font - size : 14 px ; font - weight : 600 ; }
. quill - editor { height : 350 px ; border - radius : 4 px ; border : 1 px solid # dcdfe6 ; }
. cover - uploader . el - upload { border : 1 px dashed # d9d9d9 ; border - radius : 6 px ; cursor : pointer ; position : relative ; overflow : hidden ; }
. cover - uploader . el - upload : hover { border - color : # 409 EFF ; }
. cover - uploader - icon { font - size : 28 px ; color : # 8 c939d ; width : 178 px ; height : 178 px ; text - align : center ; }
. cover - preview { width : 178 px ; height : 178 px ; display : block ; object - fit : cover ; }
< / style >