feat: links and link groups support drag and drop sorting (#574)

refactor LinkList.vue, use Vue.Draggable to sort links and teams via dragging

<!--  Thanks for sending a pull request!  Here are some tips for you:
1. 如果这是你的第一次,请阅读我们的贡献指南:<https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md>。
1. If this is your first time, please read our contributor guidelines: <https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md>.
2. 请根据你解决问题的类型为 Pull Request 添加合适的标签。
2. Please label this pull request according to what type of issue you are addressing, especially if this is a release targeted pull request.
3. 请确保你已经添加并运行了适当的测试。
3. Ensure you have added or ran the appropriate tests for your PR.
-->

#### What type of PR is this?
/kind feature
<!--
添加其中一个类别:
Add one of the following kinds:

/kind bug
/kind cleanup
/kind documentation
/kind feature
/kind optimization

适当添加其中一个或多个类别(可选):
Optionally add one or more of the following kinds if applicable:

/kind api-change
/kind deprecation
/kind failing-test
/kind flake
/kind regression
-->

#### What this PR does / why we need it:
使用Vue.Draggable重构了友链功能。现在可以通过可视化拖拽对友链或整个分组进行排序。支持友链移入/移出分组。
#### Which issue(s) this PR fixes:

<!--
PR 合并时自动关闭 issue。
Automatically closes linked issue when PR is merged.

用法:`Fixes #<issue 号>`,或者 `Fixes (粘贴 issue 完整链接)`
Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`.
-->
Fixes # 1905

#### Screenshots:

<!--
如果此 PR 有 UI 的改动,最好截图说明这个 PR 的改动。
If there are UI changes to this PR, it is best to take a screenshot to illustrate the changes to this PR.

eg.

Before:

![screenshot-before](https://user-images.githubusercontent.com/screenshot.png)

After:

![screenshot-after](https://user-images.githubusercontent.com/screenshot.png)
-->
演示视频:
<video src="https://user-images.githubusercontent.com/65994555/169685416-e4b86306-bd96-4e5a-bbde-d40f6383d9ca.mp4"></video>

#### Special notes for your reviewer:

#### Does this PR introduce a user-facing change?

<!--
如果当前 Pull Request 的修改不会造成用户侧的任何变更,在 `release-note` 代码块儿中填写 `NONE`。
否则请填写用户侧能够理解的 Release Note。如果当前 Pull Request 包含破坏性更新(Break Change),
Release Note 需要以 `action required` 开头。
If no, just write "NONE" in the release-note block below.
If yes, a release note is required:
Enter your extended release note in the block below. If the PR requires additional action from users switching to the new release, include the string "action required".
-->

```release-note
重构了管理友链的界面,现在可以通过拖拽的形式可视化的管理友链及友链分组的排序
```
pull/597/head
Killer_Queen 2022-08-05 21:16:14 +08:00 committed by GitHub
parent ca8f1f138b
commit 6f9fe25a3e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 358 additions and 214 deletions

View File

@ -23,7 +23,7 @@
"@codemirror/basic-setup": "^0.19.3",
"@codemirror/lang-html": "^0.19.4",
"@codemirror/lang-java": "^0.19.1",
"@halo-dev/admin-api": "^1.0.0",
"@halo-dev/admin-api": "^1.1.0",
"@halo-dev/editor": "^3.0.5",
"ant-design-vue": "^1.7.8",
"dayjs": "^1.11.0",

View File

@ -7,7 +7,7 @@ specifiers:
'@codemirror/basic-setup': ^0.19.3
'@codemirror/lang-html': ^0.19.4
'@codemirror/lang-java': ^0.19.1
'@halo-dev/admin-api': ^1.0.0
'@halo-dev/admin-api': ^1.1.0
'@halo-dev/editor': ^3.0.5
'@tailwindcss/aspect-ratio': ^0.4.0
'@vue/cli-plugin-babel': ~5.0.4

View File

@ -0,0 +1,100 @@
<template>
<a-modal :visible="form.visible" :title="title" :closable="false" :maskClosable="false">
<a-form-model ref="linkForm" :model="form.model" :rules="rules" layout="horizontal">
<a-form-model-item label="网站名称:" prop="name">
<a-input v-model="form.model.name" />
</a-form-model-item>
<a-form-model-item help="* 需要加上 http://" label="网站地址:" prop="url">
<a-input v-model="form.model.url" />
</a-form-model-item>
<a-form-model-item label="Logo" prop="logo">
<a-input v-model="form.model.logo" />
</a-form-model-item>
<a-form-model-item label="分组:" prop="team">
<a-auto-complete v-model="form.model.team" :dataSource="teams" allowClear />
</a-form-model-item>
<a-form-model-item label="描述:" prop="description">
<a-input v-model="form.model.description" :autoSize="{ minRows: 5 }" type="textarea" />
</a-form-model-item>
</a-form-model>
<template #footer>
<ReactiveButton
:errored="form.errored"
:loading="form.saving"
erroredText="保存失败"
loadedText="保存成功"
text="保存"
type="primary"
@callback="$emit('saved')"
@click="handleCreateOrUpdateLink"
></ReactiveButton>
<a-button @click="$emit('close')"></a-button>
</template>
</a-modal>
</template>
<script>
export default {
name: 'LinkCreateModal',
props: {
form_: Object,
teams: Array
},
data() {
return {
rules: {
name: [
{ required: true, message: '* 友情链接名称不能为空', trigger: ['change'] },
{ max: 255, message: '* 友情链接名称的字符长度不能超过 255', trigger: ['change'] }
],
url: [
{ required: true, message: '* 友情链接地址不能为空', trigger: ['change'] },
{ max: 1023, message: '* 友情链接地址的字符长度不能超过 1023', trigger: ['change'] },
{ type: 'url', message: '* 友情链接地址格式有误', trigger: ['change'] }
],
logo: [{ max: 1023, message: '* 友情链接 Logo 的字符长度不能超过 1023', trigger: ['change'] }],
description: [{ max: 255, message: '* 友情链接描述的字符长度不能超过 255', trigger: ['change'] }],
team: [{ max: 255, message: '* 友情链接分组的字符长度 255', trigger: ['change'] }]
}
}
},
computed: {
form: {
get() {
return this.form_
},
set(value) {
this.$emit('update:form', value)
}
},
title() {
if (this.isUpdateMode) {
return '修改友情链接'
}
return '添加友情链接'
},
isUpdateMode() {
return !!this.form.model.id
},
dragOptions() {
return {
animation: 200,
disabled: false,
ghostClass: 'ghost'
}
}
},
methods: {
handleCreateOrUpdateLink() {
const _this = this
_this.$refs.linkForm.validate(valid => {
if (valid) {
_this.$emit('createOrUpdateLink')
}
})
}
}
}
</script>
<style scoped></style>

View File

@ -1,139 +1,88 @@
<template>
<page-view>
<template #extra>
<a-space>
<a-button type="primary" @click="form.visible = true">添加</a-button>
</a-space>
</template>
<LinkCreateModal
:form_.sync="form"
:teams="computedTeams"
@close="
form.visible = false
form.model = {}
"
@createOrUpdateLink="handleCreateOrUpdateLink"
@saved="handleSavedCallback"
/>
<a-row :gutter="12">
<a-col :lg="10" :md="10" :sm="24" :xl="10" :xs="24" class="pb-3">
<a-card :bodyStyle="{ padding: '16px' }" :title="title">
<a-form-model ref="linkForm" :model="form.model" :rules="form.rules" layout="horizontal">
<a-form-model-item label="网站名称:" prop="name">
<a-input v-model="form.model.name" />
</a-form-model-item>
<a-form-model-item help="* 需要加上 http://" label="网站地址:" prop="url">
<a-input v-model="form.model.url" />
</a-form-model-item>
<a-form-model-item label="Logo" prop="logo">
<a-input v-model="form.model.logo" />
</a-form-model-item>
<a-form-model-item label="分组:" prop="team">
<a-auto-complete v-model="form.model.team" :dataSource="computedTeams" allowClear />
</a-form-model-item>
<a-form-model-item label="排序编号:" prop="priority">
<a-input-number v-model="form.model.priority" :min="0" style="width: 100%" />
</a-form-model-item>
<a-form-model-item label="描述:" prop="description">
<a-input v-model="form.model.description" :autoSize="{ minRows: 5 }" type="textarea" />
</a-form-model-item>
<a-form-model-item>
<ReactiveButton
v-if="!isUpdateMode"
:errored="form.errored"
:loading="form.saving"
erroredText="保存失败"
loadedText="保存成功"
text="保存"
type="primary"
@callback="handleSavedCallback"
@click="handleCreateOrUpdateLink"
></ReactiveButton>
<a-button-group v-else>
<ReactiveButton
:errored="form.errored"
:loading="form.saving"
erroredText="更新失败"
loadedText="更新成功"
text="更新"
type="primary"
@callback="handleSavedCallback"
@click="handleCreateOrUpdateLink"
></ReactiveButton>
<a-button v-if="isUpdateMode" type="dashed" @click="form.model = {}"></a-button>
</a-button-group>
</a-form-model-item>
</a-form-model>
</a-card>
</a-col>
<a-col :lg="14" :md="14" :sm="24" :xl="14" :xs="24" class="pb-3">
<a-card :bodyStyle="{ padding: '16px' }" title="所有友情链接">
<!-- Mobile -->
<a-list
v-if="isMobile()"
:dataSource="table.data"
:loading="table.loading"
itemLayout="vertical"
size="large"
>
<a-list-item :key="index" slot="renderItem" slot-scope="item, index">
<template slot="actions">
<a-dropdown :trigger="['click']" placement="topLeft">
<span>
<a-icon type="bars" />
</span>
<a-menu slot="overlay">
<a-menu-item @click="form.model = item">编辑</a-menu-item>
<a-menu-item>
<a-popconfirm
:title="'你确定要删除【' + item.name + '】链接?'"
cancelText="取消"
okText="确定"
@confirm="handleDeleteLink(item.id)"
>
删除
</a-popconfirm>
</a-menu-item>
</a-menu>
</a-dropdown>
<a-col :span="24" class="pb-3">
<a-empty v-if="linkTeam.length === 0" />
<draggable
v-else
:list="linkTeam"
class="list-group"
group="pull: 'false', put: false"
handle=".mover"
v-bind="dragOptions"
@update="handleUpdateInBatch"
>
<transition-group type="transition">
<a-card
v-for="team in linkTeam"
:key="team.team"
:bodyStyle="{ padding: '16px' }"
style="margin-bottom: 10px"
>
<template #title>
{{ team.team ? team.team : '默认分组' }}
<a-icon class="cursor-move mover ml-1 list-group-item" type="bars" />
</template>
<template slot="extra">
<span>
{{ item.team }}
</span>
</template>
<a-list-item-meta>
<template slot="description">
{{ item.description }}
</template>
<span
slot="title"
style="
max-width: 300px;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
"
>
{{ item.name }}
</span>
</a-list-item-meta>
<a :href="item.url" target="_blank">{{ item.url }}</a>
</a-list-item>
</a-list>
<!-- Desktop -->
<a-table
v-else
:columns="table.columns"
:dataSource="table.data"
:loading="table.loading"
:rowKey="link => link.id"
:scrollToFirstRowOnChange="true"
>
<template slot="url" slot-scope="text">
<a :href="text" target="_blank">{{ text }}</a>
</template>
<ellipsis slot="name" slot-scope="text" :length="15" tooltip>{{ text }}</ellipsis>
<span slot="action" slot-scope="text, record">
<a-button class="!p-0" type="link" @click="handleEdit(record)"></a-button>
<a-divider type="vertical" />
<a-popconfirm
:title="'你确定要删除【' + record.name + '】链接?'"
cancelText="取消"
okText="确定"
@confirm="handleDeleteLink(record.id)"
<draggable
:list="team.links"
group="link"
v-bind="dragOptions"
@add="modal.lastAdd = team"
@remove="handleRemove($event, team)"
@update="handleUpdateInBatch"
>
<a-button class="!p-0" type="link">删除</a-button>
</a-popconfirm>
</span>
</a-table>
</a-card>
<transition-group
class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
type="transition"
>
<div
v-for="link in team.links"
:key="link.name"
class="cursor-move relative flex items-center space-x-3 rounded border border-solid border-gray-300 bg-white px-2 py-2 shadow-sm hover:border-gray-400 hover:shadow"
>
<div v-if="link.logo" class="flex-shrink-0">
<a-avatar :src="link.logo" class="h-12 w-12 rounded-full" size="large" />
</div>
<div class="flex flex-col gap-y-1.5 overflow-hidden">
<p class="mb-0 truncate text-sm font-medium text-gray-900 truncate">
{{ link.name }}
</p>
<p class="mb-0 truncate text-sm text-gray-500">{{ link.description }}</p>
</div>
<div class="absolute top-2 right-2 cursor-pointer hover:text-blue-600">
<a-dropdown>
<div style="width: 30px; display: flex; justify-content: flex-end">
<a-icon type="more" />
</div>
<template #overlay>
<a-menu>
<a-menu-item @click="handleEdit(link)"> </a-menu-item>
<a-menu-item @click="handleDeleteLink(link.id)"> </a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
</transition-group>
</draggable>
</a-card>
</transition-group>
</draggable>
</a-col>
</a-row>
<div style="position: fixed; bottom: 30px; right: 30px">
@ -161,82 +110,46 @@
<script>
import { PageView } from '@/layouts'
import { mapActions } from 'vuex'
import draggable from 'vuedraggable'
import { mixin, mixinDevice } from '@/mixins/mixin.js'
import apiClient from '@/utils/api-client'
const columns = [
{
title: '名称',
dataIndex: 'name',
ellipsis: true,
scopedSlots: { customRender: 'name' }
},
{
title: '网址',
dataIndex: 'url',
ellipsis: true,
scopedSlots: { customRender: 'url' }
},
{
title: '分组',
ellipsis: true,
dataIndex: 'team'
},
{
title: '排序',
dataIndex: 'priority'
},
{
title: '操作',
key: 'action',
scopedSlots: { customRender: 'action' }
}
]
import LinkCreateModal from '@/views/sheet/components/LinkCreateModal'
import { Modal } from 'ant-design-vue'
export default {
mixins: [mixin, mixinDevice],
components: {
PageView
LinkCreateModal,
PageView,
draggable
},
data() {
return {
modal: {
toDelete: [],
visible: false,
newIndex: null,
lastAdd: null,
lastRemove: null
},
table: {
columns,
data: [],
loading: false
},
form: {
visible: false,
model: {},
saving: false,
errored: false,
rules: {
name: [
{ required: true, message: '* 友情链接名称不能为空', trigger: ['change'] },
{ max: 255, message: '* 友情链接名称的字符长度不能超过 255', trigger: ['change'] }
],
url: [
{ required: true, message: '* 友情链接地址不能为空', trigger: ['change'] },
{ max: 1023, message: '* 友情链接地址的字符长度不能超过 1023', trigger: ['change'] },
{ type: 'url', message: '* 友情链接地址格式有误', trigger: ['change'] }
],
logo: [{ max: 1023, message: '* 友情链接 Logo 的字符长度不能超过 1023', trigger: ['change'] }],
description: [{ max: 255, message: '* 友情链接描述的字符长度不能超过 255', trigger: ['change'] }],
team: [{ max: 255, message: '* 友情链接分组的字符长度 255', trigger: ['change'] }]
}
errored: false
},
optionsModal: {
visible: false,
data: []
},
teams: []
teams: [],
linkTeam: []
}
},
computed: {
title() {
if (this.isUpdateMode) {
return '修改友情链接'
}
return '添加友情链接'
},
isUpdateMode() {
return !!this.form.model.id
},
@ -244,6 +157,13 @@ export default {
return this.teams.filter(item => {
return item !== ''
})
},
dragOptions() {
return {
animation: 200,
disabled: false,
ghostClass: 'ghost'
}
}
},
created() {
@ -253,12 +173,87 @@ export default {
},
methods: {
...mapActions(['refreshOptionsCache']),
getPriority() {
const params = []
for (const team of this.linkTeam) {
for (const link of team.links) {
link.team = team.team
params.push(link)
}
}
let priority = params.length
for (const link of params) {
link.priority = priority--
}
return params
},
handleUpdateInBatch() {
const params = this.getPriority()
apiClient.link.updateInBatch(params).finally(() => {
this.table.loading = false
})
},
removeConfirm() {
Modal.confirm({
title: '确定移出分组吗',
content: '移出最后一个链接后,该分组将消失。确定要移出分组吗?',
onCancel: () => {
this.recoverTeam()
},
onOk: () => {
this.removeTeam()
}
})
},
removeTeam() {
this.linkTeam.splice(this.linkTeam.indexOf(this.modal.lastRemove), 1)
this.modal.newIndex = null
},
recoverTeam() {
const recover = this.modal.lastAdd.links.splice(this.modal.newIndex, 1)
this.modal.lastRemove.links.push(recover[0])
this.modal.newIndex = null
},
handleRemove(evt, team) {
this.modal.lastRemove = team
if (team.links.length === 0) {
this.modal.newIndex = evt.newIndex
this.removeConfirm()
}
this.handleUpdateInBatch()
},
splitIntoTeam(data) {
const teamMap = new Map()
for (const link of data) {
if (teamMap.has(link.team)) {
const team = teamMap.get(link.team)
team.links.push(link)
if (team.priority < link.priority) {
team.priority = link.priority
}
} else {
const team = {
team: link.team,
priority: link.priority,
links: [link]
}
teamMap.set(link.team, team)
}
}
this.linkTeam = Array.from(teamMap.values()).sort((a, b) => {
return b.priority - a.priority
})
},
handleListLinks() {
this.table.loading = true
apiClient.link
.list()
.then(response => {
this.table.data = response.data
this.table.data.sort((a, b) => {
return b.priority - a.priority
})
this.splitIntoTeam(this.table.data)
})
.finally(() => {
this.table.loading = false
@ -275,8 +270,8 @@ export default {
})
},
handleEdit(record) {
this.form.model = record
this.$refs.linkForm.clearValidate()
this.form.visible = true
this.form.model = Object.assign({}, record)
},
handleDeleteLink(id) {
apiClient.link
@ -290,35 +285,75 @@ export default {
})
},
handleCreateOrUpdateLink() {
const _this = this
_this.$refs.linkForm.validate(valid => {
if (valid) {
_this.form.saving = true
if (_this.isUpdateMode) {
apiClient.link
.update(_this.form.model.id, _this.form.model)
.catch(() => {
this.form.errored = true
})
.finally(() => {
setTimeout(() => {
_this.form.saving = false
}, 400)
})
this.form.saving = true
if (this.isUpdateMode) {
let toTeam, fromTeam, removeId
for (const team of this.linkTeam) {
if (toTeam && fromTeam) {
break
}
if (team.team === this.form.model.team) {
for (let link of team.links) {
if (link.id === this.form.model.id) {
// update
apiClient.link
.update(this.form.model.id, this.form.model)
.catch(() => {
this.form.errored = true
})
.finally(() => {
setTimeout(() => {
this.form.saving = false
}, 400)
})
return
}
}
toTeam = team
} else {
apiClient.link
.create(_this.form.model)
.catch(() => {
this.form.errored = true
})
.finally(() => {
setTimeout(() => {
_this.form.saving = false
}, 400)
})
for (let i = 0; i < team.links.length; i++) {
if (team.links[i].id === this.form.model.id) {
fromTeam = team
removeId = i
break
}
}
}
}
})
if (!toTeam) {
toTeam = {
links: [],
priority: -1,
team: this.form.model.team
}
this.linkTeam.push(toTeam)
}
toTeam.links.push(this.form.model)
fromTeam.links.splice(removeId, 1)
const params = this.getPriority()
apiClient.link
.updateInBatch(params)
.catch(() => {
this.form.errored = true
})
.finally(() => {
setTimeout(() => {
this.form.saving = false
}, 400)
})
} else {
apiClient.link
.create(this.form.model)
.catch(() => {
this.form.errored = true
})
.finally(() => {
setTimeout(() => {
this.form.saving = false
}, 400)
})
}
},
handleSavedCallback() {
if (this.form.errored) {
@ -327,6 +362,7 @@ export default {
this.form.model = {}
this.handleListLinks()
this.handleListLinkTeams()
this.form.visible = false
}
},
handleSaveOptions() {
@ -344,3 +380,11 @@ export default {
}
}
</script>
<style>
.list-group {
min-height: 20px;
}
.list-group-item {
cursor: move;
}
</style>