Refactor comment drawer into modal (halo-dev/console#463)

* refactor: target comment list modal

* feat: support create comment

* refactor: modal title

* feat: support switch target

* feat: support publish and reply
pull/3445/head
Ryan Wang 2022-02-24 15:15:32 +08:00 committed by GitHub
parent 0f601839d0
commit e8248c828c
8 changed files with 511 additions and 372 deletions

View File

@ -0,0 +1,119 @@
<template>
<a-modal v-model="modalVisible" destroyOnClose title="评论回复" @close="onClose">
<template #footer>
<ReactiveButton
:errored="submitErrored"
:loading="submitting"
erroredText="回复失败"
loadedText="回复成功"
text="回复"
type="primary"
@callback="handleSubmitCallback"
@click="handleSubmit"
></ReactiveButton>
</template>
<a-form-model ref="replyCommentForm" :model="model" :rules="rules" layout="vertical">
<a-form-model-item prop="content">
<a-input ref="contentInput" v-model="model.content" :autoSize="{ minRows: 8 }" type="textarea" />
</a-form-model-item>
</a-form-model>
</a-modal>
</template>
<script>
import apiClient from '@/utils/api-client'
export default {
name: 'CommentReplyModal',
props: {
visible: {
type: Boolean,
default: true
},
comment: {
type: Object,
default: null
},
targetId: {
type: Number,
default: 0
},
target: {
type: String,
required: true,
validator: value => {
return ['post', 'sheet', 'journal'].indexOf(value) !== -1
}
}
},
data() {
return {
model: {},
submitting: false,
submitErrored: false,
rules: {
content: [{ required: true, message: '* 内容不能为空', trigger: ['change'] }]
}
}
},
computed: {
modalVisible: {
get() {
return this.visible
},
set(value) {
this.$emit('update:visible', value)
}
}
},
watch: {
modalVisible(value) {
if (value) {
this.$nextTick(() => {
this.$refs.contentInput.focus()
})
}
}
},
methods: {
handleSubmit() {
const _this = this
_this.$refs.replyCommentForm.validate(async valid => {
if (valid) {
try {
_this.submitting = true
_this.model.postId = _this.targetId
if (_this.comment) {
_this.model.parentId = _this.comment.id
}
await apiClient.comment.create(`${_this.target}s`, _this.model)
} catch (e) {
_this.submitErrored = true
} finally {
setTimeout(() => {
_this.submitting = false
}, 400)
}
}
})
},
handleSubmitCallback() {
if (this.submitErrored) {
this.submitErrored = false
} else {
this.model = {}
this.modalVisible = false
this.$emit('succeed')
}
},
onClose() {
this.model = {}
this.modalVisible = false
}
}
}
</script>

View File

@ -0,0 +1,157 @@
<template>
<a-modal v-model="modalVisible" :afterClose="onClose" :title="title" :width="1024" destroyOnClose>
<a-spin :spinning="list.loading">
<TargetCommentTreeNode
v-for="(comment, index) in list.data"
:key="index"
:comment="comment"
:target="target"
:target-id="targetId"
@reload="handleGetComments"
/>
</a-spin>
<a-empty v-if="!list.loading && !list.data.length" />
<div class="page-wrapper">
<a-pagination
:current="pagination.page"
:defaultPageSize="pagination.size"
:pageSizeOptions="['10', '20', '50', '100']"
:total="pagination.total"
class="pagination"
showLessItems
showSizeChanger
@change="handlePageChange"
@showSizeChange="handlePageSizeChange"
/>
</div>
<template #footer>
<slot name="extraFooter" />
<a-button type="primary" @click="replyModalVisible = true">创建评论</a-button>
<a-button @click="modalVisible = false">关闭</a-button>
</template>
<CommentReplyModal
:target="target"
:target-id="targetId"
:visible.sync="replyModalVisible"
@succeed="handleGetComments"
/>
</a-modal>
</template>
<script>
// components
import TargetCommentTreeNode from './TargetCommentTreeNode'
import CommentReplyModal from './CommentReplyModal'
import apiClient from '@/utils/api-client'
export default {
name: 'TargetCommentListModal',
components: {
TargetCommentTreeNode,
CommentReplyModal
},
props: {
visible: {
type: Boolean,
default: true
},
title: {
type: String,
default: '评论'
},
target: {
type: String,
required: true,
validator: value => {
return ['post', 'sheet', 'journal'].indexOf(value) !== -1
}
},
targetId: {
type: Number,
required: true,
default: 0
}
},
data() {
return {
list: {
data: [],
loading: false,
params: {
page: 0,
size: 10
},
total: 0
},
replyModalVisible: false
}
},
computed: {
modalVisible: {
get() {
return this.visible
},
set(value) {
this.$emit('update:visible', value)
}
},
pagination() {
return {
page: this.list.params.page + 1,
size: this.list.params.size,
total: this.list.total
}
}
},
watch: {
modalVisible(value) {
if (value) {
this.handleGetComments()
}
},
targetId() {
this.handleGetComments()
}
},
methods: {
async handleGetComments() {
try {
this.list.loading = true
const response = await apiClient.comment.listAsTreeView(`${this.target}s`, this.targetId, this.list.params)
this.list.data = response.data.content
this.list.total = response.data.total
} catch (e) {
this.$log.error('Failed to get target comments', e)
} finally {
this.list.loading = false
}
},
/**
* Handle page change
*/
handlePageChange(page = 1) {
this.list.params.page = page - 1
this.handleGetComments()
},
/**
* Handle page size change
*/
handlePageSizeChange(current, size) {
this.list.params.page = 0
this.list.params.size = size
this.handleGetComments()
},
onClose() {
this.$emit('close')
}
}
}
</script>

View File

@ -0,0 +1,146 @@
<template>
<a-comment>
<template #author>
<a :href="comment.authorUrl" target="_blank">
<a-icon v-if="comment.isAdmin" style="margin-right: 3px" type="user" />
{{ comment.author }}
</a>
</template>
<template #avatar>
<a-avatar :alt="comment.author" :src="comment.avatar" size="large" />
</template>
<template #content>
<p v-html="$options.filters.markdownRender(comment.content)"></p>
</template>
<template #datetime>
<a-tooltip>
<template #title>
<span>{{ comment.createTime | moment }}</span>
</template>
<span>{{ comment.createTime | timeAgo }}</span>
</a-tooltip>
</template>
<template #actions>
<a-dropdown v-if="comment.status === 'AUDITING'" :trigger="['click']">
<span>通过</span>
<template #overlay>
<a-menu>
<a-menu-item key="1" @click="handleChangeStatus('PUBLISHED')"> </a-menu-item>
<a-menu-item key="2" @click="handlePublishAndReply"> </a-menu-item>
</a-menu>
</template>
</a-dropdown>
<span v-else-if="comment.status === 'PUBLISHED'" @click="replyModalVisible = true">回复</span>
<a-popconfirm
v-else-if="comment.status === 'RECYCLE'"
:title="'你确定要还原该评论?'"
cancelText="取消"
okText="确定"
@confirm="handleChangeStatus('PUBLISHED')"
>
还原
</a-popconfirm>
<a-popconfirm
v-if="comment.status === 'PUBLISHED' || comment.status === 'AUDITING'"
:title="'你确定要将该评论移到回收站?'"
cancelText="取消"
okText="确定"
@confirm="handleChangeStatus('RECYCLE')"
>
回收站
</a-popconfirm>
<a-popconfirm :title="'你确定要永久删除该评论?'" cancelText="取消" okText="确定" @confirm="handleDelete">
删除
</a-popconfirm>
</template>
<template v-if="comment.children">
<TargetCommentTreeNode
v-for="(child, index) in comment.children"
:key="index"
:comment="child"
:target="target"
:target-id="targetId"
@reload="$emit('reload')"
/>
</template>
<CommentReplyModal
:comment="comment"
:target="target"
:target-id="targetId"
:visible.sync="replyModalVisible"
@succeed="$emit('reload')"
/>
</a-comment>
</template>
<script>
import apiClient from '@/utils/api-client'
import CommentReplyModal from './CommentReplyModal'
export default {
name: 'TargetCommentTreeNode',
components: {
CommentReplyModal
},
props: {
target: {
type: String,
required: true,
validator: value => {
return ['post', 'sheet', 'journal'].indexOf(value) !== -1
}
},
targetId: {
type: Number,
required: true,
default: 0
},
comment: {
type: Object,
required: false,
default: null
}
},
data() {
return {
replyModalVisible: false
}
},
methods: {
async handleChangeStatus(status) {
try {
await apiClient.comment.updateStatusById(`${this.target}s`, this.comment.id, status)
} catch (e) {
this.$log.error('Failed to change comment status', e)
} finally {
this.$emit('reload')
}
},
async handlePublishAndReply() {
await this.handleChangeStatus('PUBLISHED')
this.replyModalVisible = true
},
async handleDelete() {
try {
await apiClient.comment.delete(`${this.target}s`, this.comment.id)
} catch (e) {
this.$log.error('Failed to delete comment', e)
} finally {
this.$emit('reload')
}
}
}
}
</script>

View File

@ -1,245 +0,0 @@
<template>
<a-drawer
:afterVisibleChange="handleAfterVisibleChanged"
:visible="visible"
:width="isMobile() ? '100%' : '480'"
closable
destroyOnClose
title="评论列表"
@close="onClose"
>
<a-row align="middle" type="flex">
<a-col :span="24">
<a-list itemLayout="horizontal">
<a-list-item>
<a-list-item-meta>
<template slot="description">
<p class="comment-drawer-content" v-html="description"></p>
</template>
<h3 slot="title">{{ title }}</h3>
</a-list-item-meta>
</a-list-item>
</a-list>
</a-col>
<a-divider />
<a-col :span="24">
<a-spin :spinning="list.loading">
<a-empty v-if="list.data.length === 0" />
<TargetCommentTree
v-for="(comment, index) in list.data"
v-else
:key="index"
:comment="comment"
@delete="handleCommentDelete"
@editStatus="handleEditStatusClick"
@reply="handleCommentReply"
/>
</a-spin>
</a-col>
</a-row>
<a-divider />
<div class="page-wrapper">
<a-pagination
:current="list.pagination.page"
:defaultPageSize="list.pagination.size"
:total="list.pagination.total"
showLessItems
@change="handlePaginationChange"
></a-pagination>
</div>
<a-divider class="divider-transparent" />
<div class="bottom-control">
<a-button type="primary" @click="handleCommentReply({})"></a-button>
</div>
<a-modal v-model="replyModal.visible" :title="replyModalTitle" destroyOnClose @close="onReplyModalClose">
<template slot="footer">
<ReactiveButton
:errored="replyModal.saveErrored"
:loading="replyModal.saving"
erroredText="回复失败"
loadedText="回复成功"
text="回复"
type="primary"
@callback="handleReplyCallback"
@click="handleReplyClick"
></ReactiveButton>
</template>
<a-form-model ref="replyCommentForm" :model="replyModal.model" :rules="replyModal.rules" layout="vertical">
<a-form-model-item prop="content">
<a-input ref="contentInput" v-model="replyModal.model.content" :autoSize="{ minRows: 8 }" type="textarea" />
</a-form-model-item>
</a-form-model>
</a-modal>
</a-drawer>
</template>
<script>
import { mixin, mixinDevice } from '@/mixins/mixin.js'
import TargetCommentTree from './TargetCommentTree'
import apiClient from '@/utils/api-client'
export default {
name: 'TargetCommentDrawer',
mixins: [mixin, mixinDevice],
components: { TargetCommentTree },
data() {
return {
list: {
data: [],
loading: false,
selected: {},
pagination: {
page: 1,
size: 10,
sort: null,
total: 1
},
queryParam: {
page: 0,
size: 10,
sort: null,
keyword: null
}
},
replyModal: {
model: {},
visible: false,
saving: false,
saveErrored: false,
rules: {
content: [{ required: true, message: '* 内容不能为空', trigger: ['change'] }]
}
}
}
},
props: {
visible: {
type: Boolean,
required: false,
default: false
},
title: {
type: String,
required: false,
default: ''
},
description: {
type: String,
required: false,
default: ''
},
target: {
type: String,
required: false,
default: ''
},
id: {
type: Number,
required: false,
default: 0
}
},
computed: {
replyModalTitle() {
return this.list.selected.id ? `回复给:${this.list.selected.author}` : '评论'
}
},
methods: {
handleListComments() {
this.list.loading = true
this.list.queryParam.page = this.list.pagination.page - 1
this.list.queryParam.size = this.list.pagination.size
this.list.queryParam.sort = this.list.pagination.sort
apiClient.comment
.listAsTreeView(this.target, this.id, this.list.queryParam)
.then(response => {
this.list.data = response.data.content
this.list.pagination.total = response.data.total
})
.finally(() => {
this.list.loading = false
})
},
handlePaginationChange(page, pageSize) {
this.list.pagination.page = page
this.list.pagination.size = pageSize
this.handleListComments()
},
handleCommentReply(comment) {
this.list.selected = comment
this.replyModal.visible = true
this.replyModal.model.parentId = comment.id
this.replyModal.model.postId = this.id
this.$nextTick(() => {
this.$refs.contentInput.focus()
})
},
handleReplyClick() {
const _this = this
_this.$refs.replyCommentForm.validate(valid => {
if (valid) {
_this.replyModal.saving = true
apiClient.comment
.create(_this.target, _this.replyModal.model)
.catch(() => {
_this.replyModal.saveErrored = true
})
.finally(() => {
setTimeout(() => {
_this.replyModal.saving = false
}, 400)
})
}
})
},
handleReplyCallback() {
if (this.replyModal.saveErrored) {
this.replyModal.saveErrored = false
} else {
this.replyModal.model = {}
this.list.selected = {}
this.replyModal.visible = false
this.handleListComments()
}
},
handleEditStatusClick(comment, status) {
apiClient.comment
.updateStatusById(this.target, comment.id, status)
.then(() => {
this.$message.success('操作成功!')
})
.finally(() => {
this.handleListComments()
})
},
handleCommentDelete(comment) {
apiClient.comment
.delete(this.target, comment.id)
.then(() => {
this.$message.success('删除成功!')
})
.finally(() => {
this.handleListComments()
})
},
onReplyModalClose() {
this.replyModal.model = {}
this.list.selected = {}
this.replyModal.visible = false
},
onClose() {
this.list.data = []
this.list.pagination = {
page: 1,
size: 10,
sort: ''
}
this.$emit('close', false)
},
handleAfterVisibleChanged(visible) {
if (visible) {
this.handleListComments()
}
}
}
}
</script>

View File

@ -1,93 +0,0 @@
<template>
<div>
<a-comment>
<template slot="actions">
<a-dropdown v-if="comment.status === 'AUDITING'" :trigger="['click']">
<span>通过</span>
<a-menu slot="overlay">
<a-menu-item key="1" @click="handleEditStatusClick('PUBLISHED')"> </a-menu-item>
<a-menu-item key="2"> 通过并回复</a-menu-item>
</a-menu>
</a-dropdown>
<span v-else-if="comment.status === 'PUBLISHED'" @click="handleReplyClick"></span>
<a-popconfirm
v-else-if="comment.status === 'RECYCLE'"
:title="'你确定要还原该评论?'"
cancelText="取消"
okText="确定"
@confirm="handleEditStatusClick('PUBLISHED')"
>
还原
</a-popconfirm>
<a-popconfirm
v-if="comment.status === 'PUBLISHED' || comment.status === 'AUDITING'"
:title="'你确定要将该评论移到回收站?'"
cancelText="取消"
okText="确定"
@confirm="handleEditStatusClick('RECYCLE')"
>
回收站
</a-popconfirm>
<a-popconfirm :title="'你确定要永久删除该评论?'" cancelText="取消" okText="确定" @confirm="handleDeleteClick">
删除
</a-popconfirm>
</template>
<a slot="author" :href="comment.authorUrl" target="_blank">
<a-icon v-if="comment.isAdmin" style="margin-right: 3px" type="user" />
{{ comment.author }}
</a>
<a-avatar slot="avatar" :alt="comment.author" :src="comment.avatar" size="large" />
<p slot="content" v-html="content"></p>
<a-tooltip slot="datetime">
<span slot="title">{{ comment.createTime | moment }}</span>
<span>{{ comment.createTime | timeAgo }}</span>
</a-tooltip>
<template v-if="comment.children">
<TargetCommentTree
v-for="(child, index) in comment.children"
:key="index"
:comment="child"
v-bind="$attrs"
@delete="handleDeleteClick"
@editStatus="handleEditStatusClick"
@reply="handleReplyClick"
v-on="$listeners"
/>
</template>
</a-comment>
</div>
</template>
<script>
import { marked } from 'marked'
export default {
name: 'TargetCommentTree',
props: {
comment: {
type: Object,
required: false,
default: null
}
},
computed: {
content() {
return marked.parse(this.comment.content)
}
},
methods: {
handleReplyClick() {
this.$emit('reply', this.comment)
},
handleEditStatusClick(status) {
this.$emit('editStatus', this.comment, status)
},
handleDeleteClick() {
this.$emit('delete', this.comment)
}
}
}
</script>

View File

@ -408,14 +408,18 @@
</template>
</PostSettingModal>
<TargetCommentDrawer
:id="selectedPost.id"
:description="selectedPost.summary"
:target="`posts`"
:title="selectedPost.title"
:visible="postCommentVisible"
<TargetCommentListModal
:target-id="selectedPost.id"
:title="`「${selectedPost.title}」的评论`"
:visible.sync="postCommentVisible"
target="post"
@close="onPostCommentsClose"
/>
>
<template #extraFooter>
<a-button :disabled="selectPreviousButtonDisabled" @click="handleSelectPrevious"> </a-button>
<a-button :disabled="selectNextButtonDisabled" @click="handleSelectNext"> </a-button>
</template>
</TargetCommentListModal>
</page-view>
</template>
@ -423,7 +427,7 @@
import { mixin, mixinDevice } from '@/mixins/mixin.js'
import { PageView } from '@/layouts'
import PostSettingModal from './components/PostSettingModal.vue'
import TargetCommentDrawer from '../comment/components/TargetCommentDrawer'
import TargetCommentListModal from '@/components/Comment/TargetCommentListModal'
import apiClient from '@/utils/api-client'
import { postStatuses } from '@/core/constant'
@ -480,7 +484,7 @@ export default {
components: {
PageView,
PostSettingModal,
TargetCommentDrawer
TargetCommentListModal
},
mixins: [mixin, mixinDevice],
data() {
@ -733,12 +737,11 @@ export default {
onPostSavedCallback() {
this.handleListPosts(false)
},
onPostCommentsClose() {
this.postCommentVisible = false
this.selectedPost = {}
setTimeout(() => {
this.handleListPosts(false)
}, 500)
this.handleListPosts(false)
},
/**

View File

@ -254,20 +254,24 @@
<a-button :disabled="selectNextButtonDisabled" @click="handleSelectNext"> </a-button>
</template>
</SheetSettingModal>
<TargetCommentDrawer
:id="selectedSheet.id"
:description="selectedSheet.summary"
:target="`sheets`"
:title="selectedSheet.title"
:visible="sheetCommentVisible"
<TargetCommentListModal
:target-id="selectedSheet.id"
:title="`「${selectedSheet.title}」的评论`"
:visible.sync="sheetCommentVisible"
target="sheet"
@close="onSheetCommentsClose"
/>
>
<template #extraFooter>
<a-button :disabled="selectPreviousButtonDisabled" @click="handleSelectPrevious"> </a-button>
<a-button :disabled="selectNextButtonDisabled" @click="handleSelectNext"> </a-button>
</template>
</TargetCommentListModal>
</div>
</template>
<script>
import { mixin, mixinDevice } from '@/mixins/mixin.js'
import SheetSettingModal from './SheetSettingModal'
import TargetCommentDrawer from '../../comment/components/TargetCommentDrawer'
import TargetCommentListModal from '@/components/Comment/TargetCommentListModal'
import apiClient from '@/utils/api-client'
import { sheetStatuses } from '@/core/constant'
@ -310,7 +314,7 @@ export default {
mixins: [mixin, mixinDevice],
components: {
SheetSettingModal,
TargetCommentDrawer
TargetCommentListModal
},
data() {
return {
@ -445,9 +449,7 @@ export default {
onSheetCommentsClose() {
this.sheetCommentVisible = false
this.selectedSheet = {}
setTimeout(() => {
this.handleListSheets(false)
}, 500)
this.handleListSheets(false)
},
onSheetSavedCallback() {
this.handleListSheets(false)

View File

@ -154,22 +154,27 @@
</a-form-model>
</a-modal>
<TargetCommentDrawer
:id="list.selected.id"
:description="list.selected.content"
:target="`journals`"
:visible="journalCommentDrawer.visible"
@close="onJournalCommentsDrawerClose"
/>
<AttachmentSelectModal :visible.sync="attachmentSelect.visible" @confirm="handleSelectAttachment" />
<TargetCommentListModal
:target-id="list.selected.id"
:title="`「${$options.filters.moment(list.selected.createTime)}」的评论`"
:visible.sync="journalCommentDrawer.visible"
target="journal"
@close="onJournalCommentsDrawerClose"
>
<template #extraFooter>
<a-button :disabled="selectPreviousButtonDisabled" @click="handleSelectPrevious"> </a-button>
<a-button :disabled="selectNextButtonDisabled" @click="handleSelectNext"> </a-button>
</template>
</TargetCommentListModal>
</page-view>
</template>
<script>
// components
import { PageView } from '@/layouts'
import TargetCommentDrawer from '../../comment/components/TargetCommentDrawer'
import TargetCommentListModal from '@/components/Comment/TargetCommentListModal'
// libs
import { mixin, mixinDevice } from '@/mixins/mixin.js'
@ -178,7 +183,7 @@ import apiClient from '@/utils/api-client'
export default {
mixins: [mixin, mixinDevice],
components: { PageView, TargetCommentDrawer },
components: { PageView, TargetCommentListModal },
data() {
return {
list: {
@ -191,6 +196,8 @@ export default {
keyword: undefined,
type: undefined
},
hasPrevious: false,
hasNext: false,
selected: {},
journalType: {
PUBLIC: {
@ -239,6 +246,14 @@ export default {
size: this.list.params.size,
total: this.list.total
}
},
selectPreviousButtonDisabled() {
const index = this.list.data.findIndex(journal => journal.id === this.list.selected.id)
return index === 0 && !this.list.hasPrevious
},
selectNextButtonDisabled() {
const index = this.list.data.findIndex(journal => journal.id === this.list.selected.id)
return index === this.list.data.length - 1 && !this.list.hasNext
}
},
methods: {
@ -251,6 +266,8 @@ export default {
this.list.data = data.content
this.list.total = data.total
this.list.hasPrevious = data.hasPrevious
this.list.hasNext = data.hasNext
} catch (e) {
this.$log.error(e)
} finally {
@ -356,6 +373,7 @@ export default {
onJournalCommentsDrawerClose() {
this.form.model = {}
this.journalCommentDrawer.visible = false
this.handleListJournals()
},
handleSaveOptions() {
apiClient.option
@ -371,6 +389,38 @@ export default {
},
handleSelectAttachment({ markdown }) {
this.$set(this.form.model, 'sourceContent', (this.form.model.sourceContent || '') + '\n' + markdown.join('\n'))
},
/**
* Select previous journal
*/
async handleSelectPrevious() {
const index = this.list.data.findIndex(journal => journal.id === this.list.selected.id)
if (index > 0) {
this.list.selected = this.list.data[index - 1]
return
}
if (index === 0 && this.list.hasPrevious) {
this.list.params.page--
await this.handleListJournals()
this.list.selected = this.list.data[this.list.data.length - 1]
}
},
/**
* Select next journal
*/
async handleSelectNext() {
const index = this.list.data.findIndex(journal => journal.id === this.list.selected.id)
if (index < this.list.data.length - 1) {
this.list.selected = this.list.data[index + 1]
return
}
if (index === this.list.data.length - 1 && this.list.hasNext) {
this.list.params.page++
await this.handleListJournals()
this.list.selected = this.list.data[0]
}
}
}
}