Browse Source

refactor: post setting (#376)

* refactor: post setting modal

* refactor: pagination

* refactor: post setting modal

* fix: TagSelect

* feat: add draft button

* feat: add meta editor

* style: remove PostSettingDrawer.vue
pull/381/head
Ryan Wang 3 years ago committed by GitHub
parent
commit
760fffd605
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 151
      src/components/Post/MetaEditor.vue
  2. 8
      src/filters/filter.js
  3. 51
      src/views/post/PostEdit.vue
  4. 478
      src/views/post/PostList.vue
  5. 45
      src/views/post/components/CategoryTree.vue
  6. 567
      src/views/post/components/PostSettingDrawer.vue
  7. 394
      src/views/post/components/PostSettingModal.vue
  8. 36
      src/views/post/components/TagSelect.vue

151
src/components/Post/MetaEditor.vue

@ -0,0 +1,151 @@
<template>
<div>
<a-form>
<a-form-item v-for="(meta, index) in presetMetas" :key="index">
<a-row :gutter="5">
<a-col :span="12">
<a-input v-model="meta.key" :disabled="true"><i slot="addonBefore">K</i></a-input>
</a-col>
<a-col :span="12">
<a-input v-model="meta.value">
<i slot="addonBefore">V</i>
</a-input>
</a-col>
</a-row>
</a-form-item>
</a-form>
<a-form>
<a-form-item v-for="(meta, index) in customMetas" :key="index">
<a-row :gutter="5">
<a-col :span="12">
<a-input v-model="meta.key"><i slot="addonBefore">K</i></a-input>
</a-col>
<a-col :span="12">
<a-input v-model="meta.value">
<i slot="addonBefore">V</i>
<a slot="addonAfter" href="javascript:void(0);" @click.prevent="handleRemove(index)">
<a-icon type="close" />
</a>
</a-input>
</a-col>
</a-row>
</a-form-item>
<a-form-item>
<a-button type="dashed" @click="handleAdd">新增</a-button>
</a-form-item>
</a-form>
</div>
</template>
<script>
import themeApi from '@/api/theme'
export default {
name: 'MetaEditor',
props: {
target: {
type: String,
default: 'post',
validator: function(value) {
return ['post', 'sheet'].indexOf(value) !== -1
}
},
targetId: {
type: Number,
default: null
},
metas: {
type: Array,
default: () => []
}
},
data() {
return {
presetFields: [],
presetMetas: [],
customMetas: []
}
},
watch: {
presetMetas: {
handler() {
this.handleChange()
},
deep: true
},
customMetas: {
handler() {
this.handleChange()
},
deep: true
},
targetId() {
this.handleGenerateMetas()
}
},
created() {
this.handleListPresetMetasField()
},
methods: {
/**
* Fetch preset metas fields
*
* @returns {Promise<void>}
*/
async handleListPresetMetasField() {
try {
const response = await themeApi.getActivatedTheme()
this.presetFields = response.data.data[`${this.target}MetaField`] || []
this.handleGenerateMetas()
} catch (e) {
this.$log.error(e)
}
},
/**
* Generate preset and custom metas
*/
handleGenerateMetas() {
this.presetMetas = this.presetFields.map(field => {
const meta = this.metas.find(meta => meta.key === field)
return meta ? { key: field, value: meta.value } : { key: field, value: '' }
})
this.customMetas = this.metas
.filter(meta => this.presetFields.indexOf(meta.key) === -1)
.map(meta => {
return {
key: meta.key,
value: meta.value
}
})
},
/**
* Add a new custom meta
*/
handleAdd() {
this.customMetas.push({
key: '',
value: ''
})
},
/**
* Remove custom meta
*
* @param index
*/
handleRemove(index) {
this.customMetas.splice(index, 1)
},
/**
* Handle change
*/
handleChange() {
this.$emit('update:metas', this.presetMetas.concat(this.customMetas))
}
}
}
</script>

8
src/filters/filter.js

@ -7,14 +7,6 @@ import { timeAgo } from '@/utils/datetime'
dayjs.locale('zh-cn')
Vue.filter('NumberFormat', function(value) {
if (!value) {
return '0'
}
// 将整数部分逢三一断
return value.toString().replace(/(\d)(?=(?:\d{3})+$)/g, '$1,')
})
Vue.filter('moment', function(dataStr, pattern = 'YYYY-MM-DD HH:mm') {
return dayjs(dataStr).format(pattern)
})

51
src/views/post/PostEdit.vue

@ -32,18 +32,11 @@
</a-col>
</a-row>
<PostSettingDrawer
:categoryIds="selectedCategoryIds"
:metas="selectedMetas"
<PostSettingModal
:post="postToStage"
:tagIds="selectedTagIds"
:visible="postSettingVisible"
@close="postSettingVisible = false"
@onRefreshCategoryIds="onRefreshCategoryIdsFromSetting"
@onRefreshPost="onRefreshPostFromSetting"
@onRefreshPostMetas="onRefreshPostMetasFromSetting"
@onRefreshTagIds="onRefreshTagIdsFromSetting"
@onSaved="handleRestoreSavedStatus"
:savedCallback="onPostSavedCallback"
:visible.sync="postSettingVisible"
@onUpdate="onUpdateFromSetting"
/>
<AttachmentDrawer v-model="attachmentDrawerVisible" />
@ -54,7 +47,7 @@
import { mixin, mixinDevice, mixinPostEdit } from '@/mixins/mixin.js'
import { datetimeFormat } from '@/utils/datetime'
import PostSettingDrawer from './components/PostSettingDrawer'
import PostSettingModal from './components/PostSettingModal'
import AttachmentDrawer from '../attachment/components/AttachmentDrawer'
import MarkdownEditor from '@/components/Editor/MarkdownEditor'
import { PageView } from '@/layouts'
@ -64,7 +57,7 @@ import postApi from '@/api/post'
export default {
mixins: [mixin, mixinDevice, mixinPostEdit],
components: {
PostSettingDrawer,
PostSettingModal,
AttachmentDrawer,
MarkdownEditor,
PageView
@ -74,9 +67,6 @@ export default {
attachmentDrawerVisible: false,
postSettingVisible: false,
postToStage: {},
selectedTagIds: [],
selectedCategoryIds: [],
selectedMetas: [],
contentChanges: 0,
draftSaving: false,
previewSaving: false,
@ -89,19 +79,12 @@ export default {
next(vm => {
if (postId) {
postApi.get(postId).then(response => {
const post = response.data.data
vm.postToStage = post
vm.selectedTagIds = post.tagIds
vm.selectedCategoryIds = post.categoryIds
vm.selectedMetas = post.metas
vm.postToStage = response.data.data
})
}
})
},
destroyed: function() {
if (this.postSettingVisible) {
this.postSettingVisible = false
}
destroyed() {
if (this.attachmentDrawerVisible) {
this.attachmentDrawerVisible = false
}
@ -110,9 +93,6 @@ export default {
}
},
beforeRouteLeave(to, from, next) {
if (this.postSettingVisible) {
this.postSettingVisible = false
}
if (this.attachmentDrawerVisible) {
this.attachmentDrawerVisible = false
}
@ -247,17 +227,12 @@ export default {
this.contentChanges++
this.postToStage.originalContent = val
},
onRefreshPostFromSetting(post) {
this.postToStage = post
},
onRefreshTagIdsFromSetting(tagIds) {
this.selectedTagIds = tagIds
},
onRefreshCategoryIdsFromSetting(categoryIds) {
this.selectedCategoryIds = categoryIds
onPostSavedCallback() {
this.contentChanges = 0
this.$router.push({ name: 'PostList' })
},
onRefreshPostMetasFromSetting(metas) {
this.selectedMetas = metas
onUpdateFromSetting(post) {
this.postToStage = post
}
}
}

478
src/views/post/PostList.vue

@ -1,35 +1,35 @@
<template>
<page-view>
<a-card :bordered="false" :bodyStyle="{ padding: '16px' }">
<a-card :bodyStyle="{ padding: '16px' }" :bordered="false">
<div class="table-page-search-wrapper">
<a-form layout="inline">
<a-row :gutter="48">
<a-col :md="6" :sm="24">
<a-form-item label="关键词:">
<a-input v-model="queryParam.keyword" @keyup.enter="handleQuery()" />
<a-input v-model="list.params.keyword" @keyup.enter="handleQuery()" />
</a-form-item>
</a-col>
<a-col :md="6" :sm="24">
<a-form-item label="文章状态:">
<a-select v-model="queryParam.status" placeholder="请选择文章状态" @change="handleQuery()" allowClear>
<a-select-option v-for="status in Object.keys(postStatus)" :key="status" :value="status">{{
postStatus[status].text
}}</a-select-option>
<a-select v-model="list.params.status" allowClear placeholder="请选择文章状态" @change="handleQuery()">
<a-select-option v-for="status in Object.keys(postStatus)" :key="status" :value="status">
{{ postStatus[status].text }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :md="6" :sm="24">
<a-form-item label="分类目录:">
<a-select
v-model="queryParam.categoryId"
v-model="list.params.categoryId"
:loading="categories.loading"
allowClear
placeholder="请选择分类"
@change="handleQuery()"
:loading="categoriesLoading"
allowClear
>
<a-select-option v-for="category in categories" :key="category.id"
>{{ category.name }}({{ category.postCount }})</a-select-option
>
<a-select-option v-for="category in categories.data" :key="category.id">
{{ category.name }}({{ category.postCount }})
</a-select-option>
</a-select>
</a-form-item>
</a-col>
@ -48,36 +48,26 @@
<div class="table-operator">
<router-link :to="{ name: 'PostWrite' }">
<a-button type="primary" icon="plus">写文章</a-button>
<a-button icon="plus" type="primary">写文章</a-button>
</router-link>
<a-dropdown v-show="queryParam.status != null && queryParam.status !== '' && !isMobile()">
<a-dropdown v-show="list.params.status != null && list.params.status !== '' && !isMobile()">
<a-menu slot="overlay">
<a-menu-item key="1" v-if="queryParam.status === 'DRAFT' || queryParam.status === 'RECYCLE'">
<a-menu-item v-if="['DRAFT', 'RECYCLE'].includes(list.params.status)" key="1">
<a href="javascript:void(0);" @click="handleEditStatusMore(postStatus.PUBLISHED.value)">
<span>发布</span>
</a>
</a-menu-item>
<a-menu-item
key="2"
v-if="
queryParam.status === 'PUBLISHED' || queryParam.status === 'DRAFT' || queryParam.status === 'INTIMATE'
"
>
<a-menu-item v-if="['PUBLISHED', 'DRAFT', 'INTIMATE'].includes(list.params.status)" key="2">
<a href="javascript:void(0);" @click="handleEditStatusMore(postStatus.RECYCLE.value)">
<span>移到回收站</span>
</a>
</a-menu-item>
<a-menu-item
key="3"
v-if="
queryParam.status === 'RECYCLE' || queryParam.status === 'PUBLISHED' || queryParam.status === 'INTIMATE'
"
>
<a-menu-item v-if="['RECYCLE', 'PUBLISHED', 'INTIMATE'].includes(list.params.status)" key="3">
<a href="javascript:void(0);" @click="handleEditStatusMore(postStatus.DRAFT.value)">
<span>草稿</span>
</a>
</a-menu-item>
<a-menu-item key="4" v-if="queryParam.status === 'RECYCLE' || queryParam.status === 'DRAFT'">
<a-menu-item v-if="['RECYCLE', 'DRAFT'].includes(list.params.status)" key="4">
<a href="javascript:void(0);" @click="handleDeleteMore">
<span>永久删除</span>
</a>
@ -93,13 +83,13 @@
<!-- Mobile -->
<a-list
v-if="isMobile()"
:dataSource="formattedPosts"
:loading="list.loading"
:pagination="false"
itemLayout="vertical"
size="large"
:pagination="false"
:dataSource="formattedPosts"
:loading="postsLoading"
>
<a-list-item slot="renderItem" slot-scope="item, index" :key="index">
<a-list-item :key="index" slot="renderItem" slot-scope="item, index">
<template slot="actions">
<span>
<a-icon type="eye" />
@ -109,34 +99,30 @@
<a-icon type="message" />
{{ item.commentCount }}
</span>
<a-dropdown placement="topLeft" :trigger="['click']">
<a-dropdown :trigger="['click']" placement="topLeft">
<span>
<a-icon type="bars" />
</span>
<a-menu slot="overlay">
<a-menu-item
v-if="item.status === 'PUBLISHED' || item.status === 'DRAFT' || item.status === 'INTIMATE'"
>
<a-menu-item v-if="['PUBLISHED', 'DRAFT', 'INTIMATE'].includes(item.status)">
<a href="javascript:void(0);" @click="handleEditClick(item)">编辑</a>
</a-menu-item>
<a-menu-item v-else-if="item.status === 'RECYCLE'">
<a-popconfirm
:title="'你确定要发布【' + item.title + '】文章?'"
@confirm="handleEditStatusClick(item.id, 'PUBLISHED')"
okText="确定"
cancelText="取消"
okText="确定"
@confirm="handleEditStatusClick(item.id, 'PUBLISHED')"
>
<a href="javascript:void(0);">还原</a>
</a-popconfirm>
</a-menu-item>
<a-menu-item
v-if="item.status === 'PUBLISHED' || item.status === 'DRAFT' || item.status === 'INTIMATE'"
>
<a-menu-item v-if="['PUBLISHED', 'DRAFT', 'INTIMATE'].includes(item.status)">
<a-popconfirm
:title="'你确定要将【' + item.title + '】文章移到回收站?'"
@confirm="handleEditStatusClick(item.id, 'RECYCLE')"
okText="确定"
cancelText="取消"
okText="确定"
@confirm="handleEditStatusClick(item.id, 'RECYCLE')"
>
<a href="javascript:void(0);">回收站</a>
</a-popconfirm>
@ -144,17 +130,17 @@
<a-menu-item v-else-if="item.status === 'RECYCLE'">
<a-popconfirm
:title="'你确定要永久删除【' + item.title + '】文章?'"
@confirm="handleDeleteClick(item.id)"
okText="确定"
cancelText="取消"
okText="确定"
@confirm="handleDeleteClick(item.id)"
>
<a href="javascript:void(0);">删除</a>
</a-popconfirm>
</a-menu-item>
<a-menu-item>
<a rel="noopener noreferrer" href="javascript:void(0);" @click="handleShowPostSettings(item)"
>设置</a
>
<a href="javascript:void(0);" rel="noopener noreferrer" @click="handleShowPostSettings(item)">
设置
</a>
</a-menu-item>
</a-menu>
</a-dropdown>
@ -173,29 +159,29 @@
style="max-width: 300px;display: block;white-space: nowrap;overflow: hidden;text-overflow: ellipsis;"
>
<a-icon
type="pushpin"
v-if="item.topPriority !== 0"
style="margin-right: 3px;"
theme="twoTone"
twoToneColor="red"
style="margin-right: 3px;"
type="pushpin"
/>
<a
v-if="['PUBLISHED', 'INTIMATE'].includes(item.status)"
:href="item.fullPath"
target="_blank"
class="no-underline"
target="_blank"
>
<a-tooltip placement="top" :title="'点击访问【' + item.title + '】'">{{ item.title }}</a-tooltip>
<a-tooltip :title="'点击访问【' + item.title + '】'" placement="top">{{ item.title }}</a-tooltip>
</a>
<a
v-else-if="item.status === 'DRAFT'"
href="javascript:void(0)"
class="no-underline"
href="javascript:void(0)"
@click="handlePreview(item.id)"
>
<a-tooltip placement="topLeft" :title="'点击预览【' + item.title + '】'">{{ item.title }}</a-tooltip>
<a-tooltip :title="'点击预览【' + item.title + '】'" placement="topLeft">{{ item.title }}</a-tooltip>
</a>
<a v-else href="javascript:void(0);" class="no-underline" disabled>
<a v-else class="no-underline" disabled href="javascript:void(0);">
{{ item.title }}
</a>
</span>
@ -207,61 +193,61 @@
v-for="(category, categoryIndex) in item.categories"
:key="'category_' + categoryIndex"
color="blue"
@click="handleSelectCategory(category)"
style="margin-bottom: 8px"
>{{ category.name }}</a-tag
>
@click="handleSelectCategory(category)"
>{{ category.name }}
</a-tag>
<br />
<a-tag
v-for="(tag, tagIndex) in item.tags"
:key="'tag_' + tagIndex"
color="green"
style="margin-bottom: 8px"
>{{ tag.name }}</a-tag
>
>{{ tag.name }}
</a-tag>
</a-list-item>
</a-list>
<!-- Desktop -->
<a-table
v-else
:columns="columns"
:dataSource="formattedPosts"
:loading="list.loading"
:pagination="false"
:rowKey="post => post.id"
:rowSelection="{
selectedRowKeys: selectedRowKeys,
onChange: onSelectionChange,
getCheckboxProps: getCheckboxProps
}"
:columns="columns"
:dataSource="formattedPosts"
:loading="postsLoading"
:pagination="false"
:scrollToFirstRowOnChange="true"
>
<span slot="postTitle" slot-scope="text, record">
<a-icon
type="pushpin"
v-if="record.topPriority !== 0"
style="margin-right: 3px;"
theme="twoTone"
twoToneColor="red"
style="margin-right: 3px;"
type="pushpin"
/>
<a
v-if="['PUBLISHED', 'INTIMATE'].includes(record.status)"
:href="record.fullPath"
target="_blank"
class="no-underline"
target="_blank"
>
<a-tooltip placement="top" :title="'点击访问【' + text + '】'">{{ text }}</a-tooltip>
<a-tooltip :title="'点击访问【' + text + '】'" placement="top">{{ text }}</a-tooltip>
</a>
<a
v-else-if="record.status === 'DRAFT'"
href="javascript:void(0)"
class="no-underline"
href="javascript:void(0)"
@click="handlePreview(record.id)"
>
<a-tooltip placement="topLeft" :title="'点击预览【' + text + '】'">{{ text }}</a-tooltip>
<a-tooltip :title="'点击预览【' + text + '】'" placement="topLeft">{{ text }}</a-tooltip>
</a>
<a v-else href="javascript:void(0);" class="no-underline" disabled>
<a v-else class="no-underline" disabled href="javascript:void(0);">
{{ text }}
</a>
</span>
@ -274,8 +260,8 @@
v-for="(category, index) in categoriesOfPost"
:key="index"
color="blue"
@click="handleSelectCategory(category)"
style="margin-bottom: 8px;cursor:pointer"
@click="handleSelectCategory(category)"
>{{ category.name }}</a-tag
>
</span>
@ -289,14 +275,14 @@
<span
slot="commentCount"
slot-scope="text, record"
@click="handleShowPostComments(record)"
style="cursor: pointer;"
@click="handleShowPostComments(record)"
>
<a-badge
:count="record.commentCount"
:numberStyle="{ backgroundColor: '#f38181' }"
:showZero="true"
:overflowCount="999"
:showZero="true"
/>
</span>
@ -304,8 +290,8 @@
<a-badge
:count="visits"
:numberStyle="{ backgroundColor: '#00e0ff' }"
:showZero="true"
:overflowCount="9999"
:showZero="true"
/>
</span>
@ -320,17 +306,17 @@
<span slot="action" slot-scope="text, post">
<a
v-if="['PUBLISHED', 'DRAFT', 'INTIMATE'].includes(post.status)"
href="javascript:void(0);"
@click="handleEditClick(post)"
v-if="post.status === 'PUBLISHED' || post.status === 'DRAFT' || post.status === 'INTIMATE'"
>编辑</a
>
<a-popconfirm
v-else-if="post.status === 'RECYCLE'"
:title="'你确定要发布【' + post.title + '】文章?'"
@confirm="handleEditStatusClick(post.id, 'PUBLISHED')"
okText="确定"
cancelText="取消"
v-else-if="post.status === 'RECYCLE'"
okText="确定"
@confirm="handleEditStatusClick(post.id, 'PUBLISHED')"
>
<a href="javascript:void(0);">还原</a>
</a-popconfirm>
@ -338,21 +324,21 @@
<a-divider type="vertical" />
<a-popconfirm
v-if="['PUBLISHED', 'DRAFT', 'INTIMATE'].includes(post.status)"
:title="'你确定要将【' + post.title + '】文章移到回收站?'"
@confirm="handleEditStatusClick(post.id, 'RECYCLE')"
okText="确定"
cancelText="取消"
v-if="post.status === 'PUBLISHED' || post.status === 'DRAFT' || post.status === 'INTIMATE'"
okText="确定"
@confirm="handleEditStatusClick(post.id, 'RECYCLE')"
>
<a href="javascript:void(0);">回收站</a>
</a-popconfirm>
<a-popconfirm
v-else-if="post.status === 'RECYCLE'"
:title="'你确定要永久删除【' + post.title + '】文章?'"
@confirm="handleDeleteClick(post.id)"
okText="确定"
cancelText="取消"
v-else-if="post.status === 'RECYCLE'"
okText="确定"
@confirm="handleDeleteClick(post.id)"
>
<a href="javascript:void(0);">删除</a>
</a-popconfirm>
@ -364,42 +350,43 @@
</a-table>
<div class="page-wrapper">
<a-pagination
v-if="posts && posts.length > 0"
class="pagination"
:current="pagination.page"
:total="pagination.total"
:defaultPageSize="pagination.size"
:pageSizeOptions="['1', '2', '5', '10', '20', '50', '100']"
showSizeChanger
@showSizeChange="handlePaginationChange"
@change="handlePaginationChange"
:pageSizeOptions="['10', '20', '50', '100']"
:total="pagination.total"
class="pagination"
showLessItems
showSizeChanger
@change="handlePageChange"
@showSizeChange="handlePageSizeChange"
/>
</div>
</div>
</a-card>
<PostSettingDrawer
<PostSettingModal
:loading="postSettingLoading"
:post="selectedPost"
:tagIds="selectedTagIds"
:categoryIds="selectedCategoryIds"
:metas="selectedMetas"
:needTitle="true"
:saveDraftButton="false"
:visible="postSettingVisible"
@close="onPostSettingsClose"
@onRefreshPost="onRefreshPostFromSetting"
@onRefreshTagIds="onRefreshTagIdsFromSetting"
@onRefreshCategoryIds="onRefreshCategoryIdsFromSetting"
@onRefreshPostMetas="onRefreshPostMetasFromSetting"
/>
:savedCallback="onPostSavedCallback"
:visible.sync="postSettingVisible"
@onClose="selectedPost = {}"
>
<template #extraFooter>
<a-button :disabled="selectPreviousButtonDisabled" @click="handleSelectPrevious">
上一篇
</a-button>
<a-button :disabled="selectNextButtonDisabled" @click="handleSelectNext">
下一篇
</a-button>
</template>
</PostSettingModal>
<TargetCommentDrawer
:visible="postCommentVisible"
:title="selectedPost.title"
:id="selectedPost.id"
:description="selectedPost.summary"
:target="`posts`"
:id="selectedPost.id"
:title="selectedPost.title"
:visible="postCommentVisible"
@close="onPostCommentsClose"
/>
</page-view>
@ -408,7 +395,7 @@
<script>
import { mixin, mixinDevice } from '@/mixins/mixin.js'
import { PageView } from '@/layouts'
import PostSettingDrawer from './components/PostSettingDrawer'
import PostSettingModal from './components/PostSettingModal.vue'
import TargetCommentDrawer from '../comment/components/TargetCommentDrawer'
import categoryApi from '@/api/category'
import postApi from '@/api/post'
@ -466,92 +453,90 @@ export default {
name: 'PostList',
components: {
PageView,
PostSettingDrawer,
PostSettingModal,
TargetCommentDrawer
},
mixins: [mixin, mixinDevice],
data() {
return {
postStatus: postApi.postStatus,
pagination: {
page: 1,
size: 10,
sort: null,
total: 1
columns,
list: {
data: [],
loading: false,
total: 0,
hasPrevious: false,
hasNext: false,
params: {
page: 0,
size: 10,
keyword: null,
categoryId: null,
status: null
}
},
queryParam: {
page: 0,
size: 10,
sort: null,
keyword: null,
categoryId: null,
status: null
categories: {
data: [],
loading: false
},
//
columns,
selectedRowKeys: [],
categories: [],
selectedMetas: [
{
key: '',
value: ''
}
],
posts: [],
postsLoading: false,
categoriesLoading: false,
postSettingVisible: false,
postSettingLoading: false,
postCommentVisible: false,
selectedPost: {},
selectedTagIds: [],
selectedCategoryIds: []
selectedPost: {}
}
},
computed: {
formattedPosts() {
return this.posts.map(post => {
return this.list.data.map(post => {
post.statusProperty = this.postStatus[post.status]
return post
})
},
pagination() {
return {
page: this.list.params.page + 1,
size: this.list.params.size,
total: this.list.total
}
},
selectPreviousButtonDisabled() {
const index = this.list.data.findIndex(post => post.id === this.selectedPost.id)
return index === 0 && !this.list.hasPrevious
},
selectNextButtonDisabled() {
const index = this.list.data.findIndex(post => post.id === this.selectedPost.id)
return index === this.list.data.length - 1 && !this.list.hasNext
}
},
beforeMount() {
this.handleListCategories()
},
destroyed: function() {
if (this.postSettingVisible) {
this.postSettingVisible = false
}
},
beforeRouteEnter(to, from, next) {
next(vm => {
if (to.query.page) {
vm.pagination.page = Number(to.query.page) + 1
vm.list.params.page = Number(to.query.page)
}
if (to.query.size) {
vm.pagination.size = Number(to.query.size)
vm.list.params.size = Number(to.query.size)
}
vm.queryParam.sort = to.query.sort
vm.queryParam.keyword = to.query.keyword
vm.queryParam.categoryId = to.query.categoryId
vm.queryParam.status = to.query.status
vm.list.params.sort = to.query.sort
vm.list.params.keyword = to.query.keyword
vm.list.params.categoryId = to.query.categoryId
vm.list.params.status = to.query.status
vm.handleListPosts()
})
},
beforeRouteLeave(to, from, next) {
if (this.postSettingVisible) {
this.postSettingVisible = false
}
next()
},
watch: {
queryParam: {
'list.params': {
deep: true,
handler: function(newVal) {
if (newVal) {
const params = JSON.parse(JSON.stringify(this.queryParam))
const params = JSON.parse(JSON.stringify(this.list.params))
const path = this.$router.history.current.path
this.$router.push({ path, query: params }).catch(err => err)
}
@ -559,36 +544,42 @@ export default {
}
},
methods: {
handleListPosts(enableLoading = true) {
if (enableLoading) {
this.postsLoading = true
/**
* Fetch post data
*/
async handleListPosts(enableLoading = true) {
try {
if (enableLoading) {
this.list.loading = true
}
const response = await postApi.query(this.list.params)
this.list.data = response.data.data.content
this.list.total = response.data.data.total
this.list.hasPrevious = response.data.data.hasPrevious
this.list.hasNext = response.data.data.hasNext
} catch (error) {
this.$log.error(error)
} finally {
this.list.loading = false
}
// Set from pagination
this.queryParam.page = this.pagination.page - 1
this.queryParam.size = this.pagination.size
this.queryParam.sort = this.pagination.sort
postApi
.query(this.queryParam)
.then(response => {
this.posts = response.data.data.content
this.pagination.total = response.data.data.total
})
.finally(() => {
this.postsLoading = false
})
},
handleListCategories() {
this.categoriesLoading = true
categoryApi
.listAll(true)
.then(response => {
this.categories = response.data.data
})
.finally(() => {
setTimeout(() => {
this.categoriesLoading = false
}, 200)
})
/**
* Fetch categories data
*/
async handleListCategories() {
try {
this.categories.loading = true
const response = await categoryApi.listAll(true)
this.categories.data = response.data.data
} catch (error) {
this.$log.error(error)
} finally {
this.categories.loading = false
}
},
handleEditClick(post) {
this.$router.push({ name: 'PostEdit', query: { postId: post.id } })
@ -600,31 +591,47 @@ export default {
getCheckboxProps(post) {
return {
props: {
disabled: this.queryParam.status == null || this.queryParam.status === '',
disabled: this.list.params.status == null || this.list.params.status === '',
name: post.title
}
}
},
handlePaginationChange(page, pageSize) {
this.$log.debug(`Current: ${page}, PageSize: ${pageSize}`)
this.pagination.page = page
this.pagination.size = pageSize
/**
* Handle page change
*/
handlePageChange(page = 1) {
this.list.params.page = page - 1
this.handleListPosts()
},
/**
* Handle page size change
*/
handlePageSizeChange(current, size) {
this.$log.debug(`Current: ${current}, PageSize: ${size}`)
this.list.params.page = 0
this.list.params.size = size
this.handleListPosts()
},
/**
* Reset query params
*/
handleResetParam() {
this.queryParam.keyword = null
this.queryParam.categoryId = null
this.queryParam.status = null
this.list.params.keyword = null
this.list.params.categoryId = null
this.list.params.status = null
this.handleClearRowKeys()
this.handlePaginationChange(1, this.pagination.size)
this.handlePageChange(1)
this.handleListCategories()
},
handleQuery() {
this.handleClearRowKeys()
this.handlePaginationChange(1, this.pagination.size)
this.handlePageChange(1)
},
handleSelectCategory(category) {
this.queryParam.categoryId = category.id
this.list.params.categoryId = category.id
this.handleQuery()
},
handleEditStatusClick(postId, status) {
@ -678,13 +685,16 @@ export default {
})
},
handleShowPostSettings(post) {
postApi.get(post.id).then(response => {
this.selectedPost = response.data.data
this.selectedTagIds = this.selectedPost.tagIds
this.selectedCategoryIds = this.selectedPost.categoryIds
this.selectedMetas = this.selectedPost.metas
this.postSettingVisible = true
})
this.postSettingVisible = true
this.postSettingLoading = true
postApi
.get(post.id)
.then(response => {
this.selectedPost = response.data.data
})
.finally(() => {
this.postSettingLoading = false
})
},
handleShowPostComments(post) {
postApi.get(post.id).then(response => {
@ -700,13 +710,8 @@ export default {
handleClearRowKeys() {
this.selectedRowKeys = []
},
//
onPostSettingsClose() {
this.postSettingVisible = false
this.selectedPost = {}
setTimeout(() => {
this.handleListPosts(false)
}, 500)
onPostSavedCallback() {
this.handleListPosts(false)
},
onPostCommentsClose() {
this.postCommentVisible = false
@ -715,17 +720,50 @@ export default {
this.handleListPosts(false)
}, 500)
},
onRefreshPostFromSetting(post) {
this.selectedPost = post
},
onRefreshTagIdsFromSetting(tagIds) {
this.selectedTagIds = tagIds
},
onRefreshCategoryIdsFromSetting(categoryIds) {
this.selectedCategoryIds = categoryIds
/**
* Select previous post
*/
async handleSelectPrevious() {
const index = this.list.data.findIndex(post => post.id === this.selectedPost.id)
if (index > 0) {
this.postSettingLoading = true
const response = await postApi.get(this.list.data[index - 1].id)
this.selectedPost = response.data.data
this.postSettingLoading = false
return
}
if (index === 0 && this.list.hasPrevious) {
this.list.params.page--
await this.handleListPosts()
this.postSettingLoading = true
const response = await postApi.get(this.list.data[this.list.data.length - 1].id)
this.selectedPost = response.data.data
this.postSettingLoading = false
}
},
onRefreshPostMetasFromSetting(metas) {
this.selectedMetas = metas
/**
* Select next post
*/
async handleSelectNext() {
const index = this.list.data.findIndex(post => post.id === this.selectedPost.id)
if (index < this.list.data.length - 1) {
this.postSettingLoading = true
const response = await postApi.get(this.list.data[index + 1].id)
this.selectedPost = response.data.data
this.postSettingLoading = false
return
}
if (index === this.list.data.length - 1 && this.list.hasNext) {
this.list.params.page++
await this.handleListPosts()
this.postSettingLoading = true
const response = await postApi.get(this.list.data[0].id)
this.selectedPost = response.data.data
this.postSettingLoading = false
}
}
}
}

45
src/views/post/components/CategoryTree.vue

@ -1,5 +1,13 @@
<template>
<a-tree checkable :treeData="categoryTree" defaultExpandAll checkStrictly :checkedKeys="categoryIds" @check="onCheck">
<a-tree
checkable
:treeData="categoryTree"
defaultExpandAll
checkStrictly
showLine
:checkedKeys="categoryIds"
@check="onCheck"
>
</a-tree>
</template>
@ -17,19 +25,42 @@ export default {
type: Array,
required: false,
default: () => []
},
categories: {
type: Array,
required: false,
default: () => []
}
},
data() {
return {
categories: {
data: [],
loading: false
}
}
},
computed: {
categoryTree() {
return categoryApi.concreteTree(this.categories)
if (!this.categories.data.length) {
return []
}
return categoryApi.concreteTree(this.categories.data)
}
},
created() {
this.handleListCategories()
},
methods: {
async handleListCategories() {
try {
this.categories.loading = true
const { data } = await categoryApi.listAll()
this.categories.data = data.data
} catch (error) {
this.$log.error(error)
} finally {
this.categories.loading = false
}
},
onCheck(checkedKeys, e) {
this.$log.debug('Chekced keys', checkedKeys)
this.$log.debug('e', e)

567
src/views/post/components/PostSettingDrawer.vue

@ -1,567 +0,0 @@
<template>
<a-drawer
title="文章设置"
:width="isMobile() ? '100%' : '480'"
placement="right"
closable
destroyOnClose
@close="onClose"
:visible="visible"
:afterVisibleChange="handleAfterVisibleChanged"
>
<div class="post-setting-drawer-content">
<div class="mb-4">
<h3 class="post-setting-drawer-title">基本设置</h3>
<div class="post-setting-drawer-item">
<a-form>
<a-form-item label="文章标题:" v-if="needTitle">
<a-input v-model="selectedPost.title" />
</a-form-item>
<a-form-item label="文章别名:" :help="fullPath">
<a-input v-model="selectedPost.slug">
<template #addonAfter>
<a-popconfirm
title="是否确定根据标题重新生成别名?"
ok-text="确定"
cancel-text="取消"
placement="left"
@confirm="handleSetPinyinSlug"
>
<a-icon class="cursor-pointer" type="sync" />
</a-popconfirm>
</template>
</a-input>
</a-form-item>
<a-form-item label="发表时间:">
<a-date-picker
showTime
:defaultValue="pickerDefaultValue"
format="YYYY-MM-DD HH:mm:ss"
placeholder="选择文章发表时间"
@change="onPostDateChange"
@ok="onPostDateOk"
/>
</a-form-item>
<a-form-item label="开启评论:">
<a-radio-group v-model="selectedPost.disallowComment" :defaultValue="false">
<a-radio :value="false">开启</a-radio>
<a-radio :value="true">关闭</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="是否置顶:">
<a-radio-group v-model="selectedPost.topPriority" :defaultValue="0">
<a-radio :value="1"></a-radio>
<a-radio :value="0"></a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="自定义模板:" v-if="customTpls.length > 0">
<a-select v-model="selectedPost.template">
<a-select-option key="" value=""></a-select-option>
<a-select-option v-for="tpl in customTpls" :key="tpl" :value="tpl">{{ tpl }}</a-select-option>
</a-select>
</a-form-item>
</a-form>
</div>
</div>
<a-divider />
<div class="mb-4">
<h3 class="post-setting-drawer-title">分类目录</h3>
<div class="post-setting-drawer-item">
<a-form>
<a-form-item>
<category-tree v-model="selectedCategoryIds" :categories="categories" />
</a-form-item>
<a-form-item v-if="categoryFormVisible">
<category-select-tree :categories="categories" v-model="categoryToCreate.parentId" />
</a-form-item>
<a-form-item v-if="categoryFormVisible">
<a-input placeholder="分类名称" v-model="categoryToCreate.name" />
</a-form-item>
<a-form-item v-if="categoryFormVisible">
<a-input placeholder="分类路径" v-model="categoryToCreate.slug" />
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" v-if="categoryFormVisible" @click="handlerCreateCategory">保存</a-button>
<a-button type="dashed" v-if="!categoryFormVisible" @click="categoryFormVisible = true">新增</a-button>
<a-button v-if="categoryFormVisible" @click="categoryFormVisible = false">取消</a-button>
</a-space>
</a-form-item>
</a-form>
</div>
</div>
<a-divider />
<div class="mb-4">
<h3 class="post-setting-drawer-title">标签</h3>
<div class="post-setting-drawer-item">
<a-form>
<a-form-item>
<TagSelect v-model="selectedTagIds" />
</a-form-item>
</a-form>
</div>
</div>
<a-divider />
<div class="mb-4">
<h3 class="post-setting-drawer-title">摘要</h3>
<div class="post-setting-drawer-item">
<a-form>
<a-form-item>
<a-input
type="textarea"
:autoSize="{ minRows: 5 }"
v-model="selectedPost.summary"
placeholder="如不填写,会从文章中自动截取"
/>
</a-form-item>
</a-form>
</div>
</div>
<a-divider />
<div class="mb-4">
<h3 class="post-setting-drawer-title">封面图</h3>
<div class="post-setting-drawer-item">
<div class="post-thumb">
<a-space direction="vertical">
<img
class="img"
:src="selectedPost.thumbnail || '/images/placeholder.jpg'"
@click="thumbDrawerVisible = true"
/>
<a-input v-model="selectedPost.thumbnail" placeholder="点击封面图选择图片,或者输入外部链接"></a-input>
<a-button type="dashed" @click="selectedPost.thumbnail = null">移除</a-button>
</a-space>
</div>
</div>
</div>
<a-divider class="divider-transparent" />
</div>
<AttachmentSelectDrawer v-model="thumbDrawerVisible" @listenToSelect="handleSelectPostThumb" :drawerWidth="480" />
<a-drawer
title="高级设置"
:width="isMobile() ? '100%' : '480'"
placement="right"
closable
destroyOnClose
@close="advancedVisible = false"
:visible="advancedVisible"
>
<div class="post-setting-drawer-content">
<div class="mb-4">
<h3 class="post-setting-drawer-title">加密设置</h3>
<div class="post-setting-drawer-item">
<a-form>
<a-form-item label="访问密码:">
<a-input-password v-model="selectedPost.password" autocomplete="new-password" />
</a-form-item>
</a-form>
</div>
</div>
<a-divider />
<div class="mb-4">
<h3 class="post-setting-drawer-title">SEO 设置</h3>
<div class="post-setting-drawer-item">
<a-form>
<a-form-item label="自定义关键词:">
<a-input
v-model="selectedPost.metaKeywords"
placeholder="多个关键词以英文逗号隔开,如不填写,将自动使用标签作为关键词"
/>
</a-form-item>
<a-form-item label="自定义描述:">
<a-input
type="textarea"
:autoSize="{ minRows: 5 }"
v-model="selectedPost.metaDescription"
placeholder="如不填写,会从文章中自动截取"
/>
</a-form-item>
</a-form>
</div>
</div>
<a-divider />
<div class="mb-4">
<h3 class="post-setting-drawer-title">元数据</h3>
<a-form>
<a-form-item v-for="(meta, index) in selectedMetas" :key="index" :prop="'metas.' + index + '.value'">
<a-row :gutter="5">
<a-col :span="12">
<a-input v-model="meta.key"><i slot="addonBefore">K</i></a-input>
</a-col>
<a-col :span="12">
<a-input v-model="meta.value">
<i slot="addonBefore">V</i>
<a href="javascript:void(0);" slot="addonAfter" @click.prevent="handleRemovePostMeta(meta)">
<a-icon type="close" />
</a>
</a-input>
</a-col>
</a-row>
</a-form-item>
<a-form-item>
<a-button type="dashed" @click="handleInsertPostMeta">新增</a-button>
</a-form-item>
</a-form>
</div>
<a-divider class="divider-transparent" />
</div>
<div class="bottom-control">
<a-space>
<a-button type="primary" @click="advancedVisible = false">返回</a-button>
</a-space>
</div>
</a-drawer>
<div class="bottom-control">
<a-space>
<a-button type="dashed" @click="advancedVisible = true">高级</a-button>
<ReactiveButton
type="danger"
v-if="saveDraftButton"
@click="handleDraftClick"
@callback="handleSavedCallback"
:loading="draftSaving"
:errored="draftSaveErrored"
text="保存草稿"
loadedText="保存成功"
erroredText="保存失败"
></ReactiveButton>
<ReactiveButton
@click="handlePublishClick()"
@callback="handleSavedCallback"
:loading="saving"
:errored="saveErrored"
:text="`${selectedPost.id ? '保存' : '发布'}`"
:loadedText="`${selectedPost.id ? '保存' : '发布'}成功`"
:erroredText="`${selectedPost.id ? '保存' : '发布'}失败`"
></ReactiveButton>
</a-space>
</div>
</a-drawer>
</template>
<script>
// components
import CategoryTree from './CategoryTree'
import CategorySelectTree from './CategorySelectTree'
import TagSelect from './TagSelect'
// libs
import { mixin, mixinDevice } from '@/mixins/mixin.js'
import { datetimeFormat } from '@/utils/datetime'
import pinyin from 'tiny-pinyin'
import { mapGetters } from 'vuex'
// apis
import categoryApi from '@/api/category'
import postApi from '@/api/post'
import themeApi from '@/api/theme'
export default {
name: 'PostSettingDrawer',
mixins: [mixin, mixinDevice],
components: {
CategoryTree,
CategorySelectTree,
TagSelect
},
data() {
return {
thumbDrawerVisible: false,
categoryFormVisible: false,
advancedVisible: false,
selectedPost: this.post,
selectedTagIds: this.tagIds,
selectedCategoryIds: this.categoryIds,
categories: [],
categoryToCreate: {},
customTpls: [],
saving: false,
saveErrored: false,
draftSaving: false,
draftSaveErrored: false
}
},
props: {
post: {
type: Object,
required: true
},
tagIds: {
type: Array,
required: true
},
categoryIds: {
type: Array,
required: true
},
metas: {
type: Array,
required: true
},
needTitle: {
type: Boolean,
required: false,
default: false
},
saveDraftButton: {
type: Boolean,
required: false,
default: true
},
visible: {
type: Boolean,
required: false,
default: true
}
},
watch: {
post(val) {
this.selectedPost = val
},
selectedPost(val) {
this.$emit('onRefreshPost', val)
},
tagIds(val) {
this.selectedTagIds = val
},
selectedTagIds(val) {
this.$emit('onRefreshTagIds', val)
},
categoryIds(val) {
this.selectedCategoryIds = val
},
selectedCategoryIds(val) {
this.$emit('onRefreshCategoryIds', val)
},
selectedMetas(val) {
this.$emit('onRefreshPostMetas', val)
}
},
computed: {
...mapGetters(['options']),
selectedMetas() {
return this.metas
},
pickerDefaultValue() {
if (this.selectedPost.createTime) {
const date = new Date(this.selectedPost.createTime)
return datetimeFormat(date, 'YYYY-MM-DD HH:mm:ss')
}
return datetimeFormat(new Date(), 'YYYY-MM-DD HH:mm:ss')
},
fullPath() {
const permalinkType = this.options.post_permalink_type
const blogUrl = this.options.blog_url
const archivesPrefix = this.options.archives_prefix
const pathSuffix = this.options.path_suffix ? this.options.path_suffix : ''
switch (permalinkType) {
case 'DEFAULT':
return `${blogUrl}/${archivesPrefix}/${
this.selectedPost.slug ? this.selectedPost.slug : '{slug}'
}${pathSuffix}`
case 'YEAR':
return `${blogUrl}${datetimeFormat(
this.selectedPost.createTime ? this.selectedPost.createTime : new Date(),
'/YYYY/'
)}${this.selectedPost.slug ? this.selectedPost.slug : '{slug}'}${pathSuffix}`
case 'DATE':
return `${blogUrl}${datetimeFormat(
this.selectedPost.createTime ? this.selectedPost.createTime : new Date(),
'/YYYY/MM/'
)}${this.selectedPost.slug ? this.selectedPost.slug : '{slug}'}${pathSuffix}`
case 'DAY':
return `${blogUrl}${datetimeFormat(
this.selectedPost.createTime ? this.selectedPost.createTime : new Date(),
'/YYYY/MM/DD/'
)}${this.selectedPost.slug ? this.selectedPost.slug : '{slug}'}${pathSuffix}`
case 'ID':
return `${blogUrl}/?p=${this.selectedPost.id ? this.selectedPost.id : '{id}'}`
case 'ID_SLUG':
return `${blogUrl}/${archivesPrefix}/${this.selectedPost.id ? this.selectedPost.id : '{id}'}${pathSuffix}`
default:
return ''
}
}
},
methods: {
handleAfterVisibleChanged(visible) {
if (visible) {
this.handleListCategories()
this.handleListPresetMetasField()
this.handleListCustomTpls()
if (!this.selectedPost.slug && !this.selectedPost.id) {
this.handleSetPinyinSlug()
}
}
},
handleListCategories() {
categoryApi.listAll().then(response => {
this.categories = response.data.data
})
},
handleListPresetMetasField() {
if (this.metas.length <= 0) {
themeApi.getActivatedTheme().then(response => {
const fields = response.data.data.postMetaField
if (fields && fields.length > 0) {
for (let i = 0, len = fields.length; i < len; i++) {
this.selectedMetas.push({
value: '',
key: fields[i]
})
}
}
})
}
},
handleListCustomTpls() {
themeApi.customPostTpls().then(response => {
this.customTpls = response.data.data
})
},
handleSelectPostThumb(data) {
this.selectedPost.thumbnail = encodeURI(data.path)
this.thumbDrawerVisible = false
},
handlerCreateCategory() {
if (!this.categoryToCreate.name) {
this.$notification['error']({
message: '提示',
description: '分类名称不能为空!'
})
return
}
categoryApi
.create(this.categoryToCreate)
.then(() => {
this.categoryToCreate = {}
this.categoryFormVisible = false
})
.finally(() => {
this.handleListCategories()
})
},
handleDraftClick() {
this.selectedPost.status = 'DRAFT'
this.createOrUpdatePost()
},
handlePublishClick() {
this.selectedPost.status = 'PUBLISHED'
this.createOrUpdatePost()
},
createOrUpdatePost() {
if (!this.selectedPost.title) {
this.$notification['error']({
message: '提示',
description: '文章标题不能为空!'
})
return
}
// Set category ids
this.selectedPost.categoryIds = this.selectedCategoryIds
// Set tag ids
this.selectedPost.tagIds = this.selectedTagIds
// Set post metas
this.selectedPost.metas = this.selectedMetas
if (this.selectedPost.status === 'DRAFT') {
this.draftSaving = true
} else {
this.saving = true
}
if (this.selectedPost.id) {
// Update the post
postApi
.update(this.selectedPost.id, this.selectedPost, false)
.catch(() => {
if (this.selectedPost.status === 'DRAFT') {
this.draftSaveErrored = true
} else {
this.saveErrored = true
}
})
.finally(() => {
setTimeout(() => {
this.saving = false
this.draftSaving = false
}, 400)
})
} else {
// Create the post
postApi
.create(this.selectedPost, false)
.catch(() => {
if (this.selectedPost.status === 'DRAFT') {
this.draftSaveErrored = true
} else {
this.saveErrored = true
}
})
.then(response => {
this.selectedPost = response.data.data
})
.finally(() => {
setTimeout(() => {
this.saving = false
this.draftSaving = false
}, 400)
})
}
},
handleSavedCallback() {
if (this.draftSaveErrored || this.saveErrored) {
this.draftSaveErrored = false
this.saveErrored = false
} else {
this.$emit('onSaved', true)
this.$router.push({ name: 'PostList' })
}
},
onClose() {
this.$emit('close', false)
},
onPostDateChange(value) {
this.selectedPost.createTime = value.valueOf()
},
onPostDateOk(value) {
this.selectedPost.createTime = value.valueOf()
},
handleRemovePostMeta(item) {
const index = this.selectedMetas.indexOf(item)
if (index !== -1) {
this.selectedMetas.splice(index, 1)
}
},
handleInsertPostMeta() {
this.selectedMetas.push({
value: '',
key: ''
})
},
handleSetPinyinSlug() {
if (this.selectedPost.title) {
if (pinyin.isSupported()) {
let result = ''
const tokens = pinyin.parse(this.selectedPost.title.replace(/\s+/g, '').toLowerCase())
let lastToken
tokens.forEach(token => {
if (token.type === 2) {
const target = token.target ? token.target.toLowerCase() : ''
result += result && !/\n|\s/.test(lastToken.target) ? '-' + target : target
} else {
result += (lastToken && lastToken.type === 2 ? '-' : '') + token.target
}
lastToken = token
})
this.$set(this.selectedPost, 'slug', result)
}
}
}
}
}
</script>

394
src/views/post/components/PostSettingModal.vue

@ -0,0 +1,394 @@
<template>
<a-modal
v-model="modalVisible"
:afterClose="onClosed"
:bodyStyle="{ padding: 0 }"
:maskClosable="false"
:width="680"
destroyOnClose
>
<template #title>
{{ modalTitle }}
<a-icon v-if="loading" type="loading" />
</template>
<div class="card-container">
<a-tabs type="card">
<a-tab-pane key="normal" tab="常规">
<a-form :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }" labelAlign="left">
<a-form-item label="文章标题">
<a-input v-model="form.model.title" />
</a-form-item>
<a-form-item :help="fullPath" label="文章别名">
<a-input v-model="form.model.slug">
<template #addonAfter>
<a-popconfirm
cancel-text="取消"
ok-text="确定"
placement="left"
title="是否确定根据标题重新生成别名?"
@confirm="handleGenerateSlug"
>
<a-icon class="cursor-pointer" type="sync" />
</a-popconfirm>
</template>
</a-input>
</a-form-item>
<a-form-item label="分类目录">
<category-tree v-model="form.model.categoryIds" />
</a-form-item>
<a-form-item label="标签">
<TagSelect v-model="form.model.tagIds" />
</a-form-item>
<a-form-item label="摘要">
<a-input
v-model="form.model.summary"
:autoSize="{ minRows: 5 }"
placeholder="如不填写,会从文章中自动截取"
type="textarea"
/>
</a-form-item>
</a-form>
</a-tab-pane>
<a-tab-pane key="advanced" tab="高级">
<a-form :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }" labelAlign="left">
<a-form-item label="禁止评论">
<a-switch v-model="form.model.disallowComment" />
</a-form-item>
<a-form-item label="是否置顶">
<a-switch v-model="topPriority" />
</a-form-item>
<a-form-item label="发表时间:">
<a-date-picker
:defaultValue="createTimeDefaultValue"
format="YYYY-MM-DD HH:mm:ss"
placeholder="选择文章发表时间"
showTime
@change="onCreateTimeSelect"
@ok="onCreateTimeSelect"
/>
</a-form-item>
<a-form-item label="自定义模板:">
<a-select v-model="form.model.template">
<a-select-option key="" value=""></a-select-option>
<a-select-option v-for="template in templates" :key="template" :value="template">
{{ template }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="访问密码:">
<a-input-password v-model="form.model.password" autocomplete="new-password" />
</a-form-item>
<a-form-item label="封面图:">
<div class="post-thumb">
<a-space direction="vertical">
<img
:src="form.model.thumbnail || '/images/placeholder.jpg'"
alt="Post cover thumbnail"
class="img"
@click="attachmentSelectVisible = true"
/>
<a-input v-model="form.model.thumbnail" placeholder="点击封面图选择图片,或者输入外部链接"></a-input>
<a-button type="dashed" @click="form.model.thumbnail = null">移除</a-button>
</a-space>
</div>
</a-form-item>
</a-form>
</a-tab-pane>
<a-tab-pane key="seo" tab="SEO">
<a-form :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }" labelAlign="left">
<a-form-item label="自定义关键词">
<a-input
v-model="form.model.metaKeywords"
:autoSize="{ minRows: 5 }"
placeholder="多个关键词以英文逗号隔开,如不填写,将自动使用标签作为关键词"
type="textarea"
/>
</a-form-item>
<a-form-item label="自定义描述">
<a-input
v-model="form.model.metaDescription"
:autoSize="{ minRows: 5 }"
placeholder="如不填写,会从文章中自动截取"
type="textarea"
/>
</a-form-item>
</a-form>
</a-tab-pane>
<a-tab-pane key="meta" tab="元数据">
<MetaEditor :metas.sync="form.model.metas" :targetId="form.model.id" target="post" />
</a-tab-pane>
</a-tabs>
</div>
<template slot="footer">
<slot name="extraFooter" />
<a-button :disabled="loading" @click="modalVisible = false">
关闭
</a-button>
<ReactiveButton
v-if="!form.model.id"
:errored="form.draftSaveErrored"
:loading="form.draftSaving"
erroredText="保存失败"
loadedText="保存成功"
text="保存草稿"
type="danger"
@callback="handleSavedCallback"
@click="handleCreateOrUpdate('DRAFT')"
></ReactiveButton>
<ReactiveButton
:errored="form.saveErrored"
:erroredText="`${form.model.id ? '保存' : '发布'}失败`"
:loadedText="`${form.model.id ? '保存' : '发布'}成功`"
:loading="form.saving"
:text="`${form.model.id ? '保存' : '发布'}`"
@callback="handleSavedCallback"
@click="handleCreateOrUpdate()"
></ReactiveButton>
</template>
<AttachmentSelectDrawer
v-model="attachmentSelectVisible"
:drawerWidth="480"
@listenToSelect="handleSelectPostThumb"
/>
</a-modal>
</template>
<script>
// components
import CategoryTree from './CategoryTree'
import TagSelect from './TagSelect'
import MetaEditor from '@/components/Post/MetaEditor'
// libs
import { mixin, mixinDevice } from '@/mixins/mixin.js'
import { datetimeFormat } from '@/utils/datetime'
import pinyin from 'tiny-pinyin'
import { mapGetters } from 'vuex'
// apis
import postApi from '@/api/post'
import themeApi from '@/api/theme'
export default {
name: 'PostSettingModal',
mixins: [mixin, mixinDevice],
components: {
CategoryTree,
TagSelect,
MetaEditor
},
props: {
visible: {
type: Boolean,
default: false
},
loading: {
type: Boolean,
default: false
},
post: {
type: Object,
default: () => ({})
},
savedCallback: {
type: Function,
default: null
}
},
data() {
return {
form: {
model: {},
saving: false,
saveErrored: false,
draftSaving: false,
draftSaveErrored: false
},
templates: [],
attachmentSelectVisible: false
}
},
computed: {
...mapGetters(['options']),
modalVisible: {
get() {
return this.visible
},
set(value) {
this.$emit('update:visible', value)
}
},
modalTitle() {
return this.form.model.id ? '文章设置' : '文章发布'
},
createTimeDefaultValue() {
if (this.form.model.createTime) {
const date = new Date(this.form.model.createTime)
return datetimeFormat(date, 'YYYY-MM-DD HH:mm:ss')
}
return datetimeFormat(new Date(), 'YYYY-MM-DD HH:mm:ss')
},
topPriority: {
get() {
return this.form.model.topPriority !== 0
},
set(value) {
this.form.model.topPriority = value ? 1 : 0
}
},
fullPath() {
const permalinkType = this.options.post_permalink_type
const blogUrl = this.options.blog_url
const archivesPrefix = this.options.archives_prefix
const pathSuffix = this.options.path_suffix || ''
const slug = this.form.model.slug || '{slug}'
const createTime = this.form.model.createTime || new Date()
const id = this.form.model.id || '{id}'
switch (permalinkType) {
case 'DEFAULT':
return `${blogUrl}/${archivesPrefix}/${slug}${pathSuffix}`
case 'YEAR':
return `${blogUrl}${datetimeFormat(createTime, '/YYYY/')}${slug}${pathSuffix}`
case 'DATE':
return `${blogUrl}${datetimeFormat(createTime, '/YYYY/MM/')}${slug}${pathSuffix}`
case 'DAY':
return `${blogUrl}${datetimeFormat(createTime, '/YYYY/MM/DD/')}${slug}${pathSuffix}`
case 'ID':
return `${blogUrl}/?p=${id}`
case 'ID_SLUG':
return `${blogUrl}/${archivesPrefix}/${id}${pathSuffix}`
default:
return ''
}
}
},
watch: {
modalVisible(value) {
if (value) {
this.form.model = Object.assign({}, this.post)
if (!this.form.model.slug && !this.form.model.id) {
this.handleGenerateSlug()
}
}
},
post: {
deep: true,
handler(value) {
this.form.model = Object.assign({}, value)
}
}
},
created() {
this.handleListCustomTemplates()
},
methods: {
/**
* Creates or updates a post
*/
async handleCreateOrUpdate(preStatus = 'PUBLISHED') {
if (!this.form.model.title) {
this.$notification['error']({
message: '提示',
description: '文章标题不能为空!'
})
return
}
this.form.model.status = preStatus
const { id, status } = this.form.model
try {
this.form[status === 'PUBLISHED' ? 'saving' : 'draftSaving'] = true
if (id) {
await postApi.update(id, this.form.model, false)
} else {
await postApi.create(this.form.model, false)
}
} catch (error) {
this.form[status === 'PUBLISHED' ? 'saveErrored' : 'draftSaveErrored'] = true
this.$log.error(error)
} finally {
setTimeout(() => {
this.form.saving = false
this.form.draftSaving = false
}, 400)
}
},
/**
* Handle saved callback event
*/
handleSavedCallback() {
if (this.form.saveErrored || this.form.draftSaveErrored) {
this.form.saveErrored = false
this.form.draftSaveErrored = false
} else {
this.savedCallback && this.savedCallback()
}
},
/**
* Handle list custom templates
*/
async handleListCustomTemplates() {
try {
const response = await themeApi.customPostTpls()
this.templates = response.data.data
} catch (error) {
this.$log.error(error)
}
},
/**
* Handle create time selected event
*/
onCreateTimeSelect(value) {
this.form.model.createTime = value.valueOf()
},
/**
* Generate slug
*/
handleGenerateSlug() {
if (this.form.model.title) {
if (pinyin.isSupported()) {
let result = ''
const tokens = pinyin.parse(this.form.model.title.replace(/\s+/g, '').toLowerCase())
let lastToken
tokens.forEach(token => {
if (token.type === 2) {
const target = token.target ? token.target.toLowerCase() : ''
result += result && !/\n|\s/.test(lastToken.target) ? '-' + target : target
} else {
result += (lastToken && lastToken.type === 2 ? '-' : '') + token.target
}
lastToken = token
})
this.$set(this.form.model, 'slug', result)
}
}
},
/**
* Select post thumbnail
* @param data
*/
handleSelectPostThumb(data) {
this.form.model.thumbnail = encodeURI(data.path)
this.attachmentSelectVisible = false
},
/**
* Handle modal close event
*/
onClosed() {
this.$emit('onClose')
this.$emit('onUpdate', this.form.model)
}
}
}
</script>

36
src/views/post/components/TagSelect.vue

@ -1,16 +1,15 @@
<template>
<div>
<a-select
v-model="selectedTagNames"
class="w-full"
allowClear
mode="tags"
placeholder="选择或输入标签"
@change="handleChange"
>
<a-select-option v-for="tag in tags" :key="tag.id" :value="tag.name">{{ tag.name }}</a-select-option>
</a-select>
</div>
<a-select
v-model="selectedTagNames"
:token-separators="[',', '|']"
allowClear
class="w-full"
mode="tags"
placeholder="选择或输入标签"
@change="handleChange"
>
<a-select-option v-for="tag in tags" :key="tag.id" :value="tag.name">{{ tag.name }}</a-select-option>
</a-select>
</template>
<script>
@ -44,6 +43,15 @@ export default {
if (newValue) {
this.selectedTagNames = this.tagIds.map(tagId => this.tagIdMap[tagId].name)
}
},
tagIds: {
handler(newValue) {
if (!this.tags.length) {
return
}
this.selectedTagNames = newValue.map(tagId => this.tagIdMap[tagId].name)
},
deep: true
}
},
computed: {
@ -72,14 +80,12 @@ export default {
})
},
handleChange() {
this.$log.debug('Changed')
const tagNamesToCreate = this.selectedTagNames.filter(tagName => !this.tagNameMap[tagName])
this.$log.debug('Tag names to create', tagNamesToCreate)
if (tagNamesToCreate === []) {
if (!tagNamesToCreate.length) {
const tagIds = this.selectedTagNames.map(tagName => this.tagNameMap[tagName].id)
// If empty
this.$emit('change', tagIds)
return
}

Loading…
Cancel
Save