chore: clean code for next major version

Signed-off-by: Ryan Wang <i@ryanc.cc>
pull/478/head
Ryan Wang 2022-03-03 11:44:12 +08:00
parent 0f5875168d
commit 1eac16acfd
168 changed files with 0 additions and 36840 deletions

View File

@ -1,3 +0,0 @@
> 1%
last 2 versions
not dead

3
.env
View File

@ -1,3 +0,0 @@
NODE_ENV=production
PUBLIC_PATH=/
VUE_APP_API_URL=/

View File

@ -1,3 +0,0 @@
NODE_ENV=development
PUBLIC_PATH=/
VUE_APP_API_URL=http://localhost:8090

View File

@ -1,26 +0,0 @@
module.exports = {
root: true,
env: {
node: true
},
extends: ['plugin:vue/essential', 'eslint:recommended', 'plugin:prettier/recommended'],
parserOptions: {
parser: '@babel/eslint-parser'
},
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'vue/no-mutating-props': 0,
'vue/multi-word-component-names': 0,
'prettier/prettier': [
'error',
{
printWidth: 120,
semi: false,
singleQuote: true,
trailingComma: 'none',
arrowParens: 'avoid'
}
]
}
}

1
.husky/.gitignore vendored
View File

@ -1 +0,0 @@
_

View File

@ -1,6 +0,0 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npm run lint
git add .

1
.npmrc
View File

@ -1 +0,0 @@
shamefully-hoist=true

View File

@ -1,7 +0,0 @@
{
"printWidth": 120,
"semi": false,
"singleQuote": true,
"trailingComma": "none",
"arrowParens": "avoid"
}

View File

@ -1,3 +0,0 @@
module.exports = {
presets: ['@vue/cli-plugin-babel/preset']
}

View File

@ -1,3 +0,0 @@
{
"include": ["./src/**/*"]
}

View File

@ -1,71 +0,0 @@
{
"name": "halo-admin",
"version": "1.5.0-alpha.1",
"author": "halo-dev",
"description": "Halo admin client.",
"repository": {
"type": "git",
"url": "git+https://github.com/halo-dev/halo-admin.git"
},
"license": "ISC",
"bugs": {
"url": "https://github.com/halo-dev/halo-admin/issues"
},
"homepage": "https://github.com/halo-dev/halo-admin#readme",
"scripts": {
"prepare": "husky install",
"serve": "vue-cli-service serve",
"build": "vue-cli-service build --no-module",
"lint": "vue-cli-service lint",
"test:unit": "vue-cli-service test:unit"
},
"dependencies": {
"@codemirror/basic-setup": "^0.19.1",
"@codemirror/lang-html": "^0.19.4",
"@codemirror/lang-java": "^0.19.1",
"@halo-dev/admin-api": "^1.0.0-alpha.50",
"@halo-dev/editor": "^3.0.0-alpha.2",
"ant-design-vue": "^1.7.8",
"dayjs": "^1.10.7",
"enquire.js": "^2.1.6",
"filepond": "^4.30.3",
"filepond-plugin-file-validate-type": "^1.2.6",
"filepond-plugin-image-preview": "^4.6.10",
"lodash.debounce": "^4.0.8",
"marked": "^4.0.12",
"md5.js": "^1.3.5",
"nprogress": "^0.2.0",
"tiny-pinyin": "^1.3.2",
"verte": "^0.0.12",
"vue": "^2.6.14",
"vue-clipboard2": "^0.3.3",
"vue-contextmenujs": "^1.3.13",
"vue-count-to": "^1.0.13",
"vue-filepond": "^6.0.3",
"vue-ls": "^3.2.2",
"vue-router": "^3.5.3",
"vuedraggable": "^2.24.3",
"vuejs-logger": "^1.10.2",
"vuex": "^3.6.2"
},
"devDependencies": {
"@babel/core": "^7.17.5",
"@babel/eslint-parser": "^7.17.0",
"@vue/cli-plugin-babel": "~5.0.1",
"@vue/cli-plugin-eslint": "~5.0.1",
"@vue/cli-plugin-router": "~5.0.1",
"@vue/cli-plugin-vuex": "~5.0.1",
"@vue/cli-service": "~5.0.1",
"eslint": "^7.32.0",
"eslint-config-prettier": "^8.4.0",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-vue": "^8.4.1",
"husky": "^6.0.0",
"less": "^3.13.1",
"less-loader": "^5.0.0",
"lint-staged": "^11.2.6",
"prettier": "^2.5.1",
"tailwindcss": "^3.0.23",
"vue-template-compiler": "^2.6.14"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +0,0 @@
module.exports = {
plugins: [require('autoprefixer'), require('tailwindcss')]
}

File diff suppressed because it is too large Load Diff

View File

@ -1 +0,0 @@
<svg height="45" viewBox="0 0 52 45" width="52" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><filter id="a" height="122.5%" width="118.8%" x="-9.4%" y="-6.2%"><feOffset dx="0" dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/><feGaussianBlur in="shadowOffsetOuter1" result="shadowBlurOuter1" stdDeviation="1"/><feColorMatrix in="shadowBlurOuter1" result="shadowMatrixOuter1" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/><feMerge><feMergeNode in="shadowMatrixOuter1"/><feMergeNode in="SourceGraphic"/></feMerge></filter><rect id="b" height="40" rx="4" width="48"/><filter id="c" height="110%" width="108.3%" x="-4.2%" y="-2.5%"><feOffset dx="0" dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/><feGaussianBlur in="shadowOffsetOuter1" result="shadowBlurOuter1" stdDeviation=".5"/><feColorMatrix in="shadowBlurOuter1" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/></filter><mask id="d" fill="#fff"><use fill="#fff" fill-rule="evenodd" xlink:href="#b"/></mask></defs><g fill="none" fill-rule="evenodd" filter="url(#a)" transform="translate(2 1)"><use fill="#000" filter="url(#c)" xlink:href="#b"/><use fill="#f0f2f5" fill-rule="evenodd" xlink:href="#b"/><path d="m-1 0h49v10h-49z" fill="#fff" mask="url(#d)"/><path d="m0 0h16v44h-16z" fill="#303648" mask="url(#d)"/></g></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1 +0,0 @@
<svg height="45" viewBox="0 0 52 45" width="52" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><filter id="a" height="122.5%" width="118.8%" x="-9.4%" y="-6.2%"><feOffset dx="0" dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/><feGaussianBlur in="shadowOffsetOuter1" result="shadowBlurOuter1" stdDeviation="1"/><feColorMatrix in="shadowBlurOuter1" result="shadowMatrixOuter1" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/><feMerge><feMergeNode in="shadowMatrixOuter1"/><feMergeNode in="SourceGraphic"/></feMerge></filter><rect id="b" height="40" rx="4" width="48"/><filter id="c" height="110%" width="108.3%" x="-4.2%" y="-2.5%"><feOffset dx="0" dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/><feGaussianBlur in="shadowOffsetOuter1" result="shadowBlurOuter1" stdDeviation=".5"/><feColorMatrix in="shadowBlurOuter1" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/></filter><mask id="d" fill="#fff"><use fill="#fff" fill-rule="evenodd" xlink:href="#b"/></mask></defs><g fill="none" fill-rule="evenodd" filter="url(#a)" transform="translate(2 1)"><use fill="#000" filter="url(#c)" xlink:href="#b"/><use fill="#f0f2f5" fill-rule="evenodd" xlink:href="#b"/><g fill="#fff"><path d="m0 0h16v44h-16z" mask="url(#d)"/><path d="m-1 0h49v10h-49z" mask="url(#d)"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

View File

@ -1 +0,0 @@
<svg height="45" viewBox="0 0 52 45" width="52" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><filter id="a" height="122.5%" width="118.8%" x="-9.4%" y="-6.2%"><feOffset dx="0" dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/><feGaussianBlur in="shadowOffsetOuter1" result="shadowBlurOuter1" stdDeviation="1"/><feColorMatrix in="shadowBlurOuter1" result="shadowMatrixOuter1" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/><feMerge><feMergeNode in="shadowMatrixOuter1"/><feMergeNode in="SourceGraphic"/></feMerge></filter><rect id="b" height="40" rx="4" width="48"/><filter id="c" height="110%" width="108.3%" x="-4.2%" y="-2.5%"><feOffset dx="0" dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/><feGaussianBlur in="shadowOffsetOuter1" result="shadowBlurOuter1" stdDeviation=".5"/><feColorMatrix in="shadowBlurOuter1" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/></filter><mask id="d" fill="#fff"><use fill="#fff" fill-rule="evenodd" xlink:href="#b"/></mask></defs><g fill="none" fill-rule="evenodd" filter="url(#a)" transform="translate(2 1)"><use fill="#000" filter="url(#c)" xlink:href="#b"/><use fill="#f0f2f5" fill-rule="evenodd" xlink:href="#b"/><path d="m-1 0h49v10h-49z" fill="#fff" mask="url(#d)"/><path d="m0 0h16v44h-16z" fill="#303648" mask="url(#d)"/></g></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1 +0,0 @@
<svg height="45" viewBox="0 0 52 45" width="52" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><filter id="a" height="122.5%" width="118.8%" x="-9.4%" y="-6.2%"><feOffset dx="0" dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/><feGaussianBlur in="shadowOffsetOuter1" result="shadowBlurOuter1" stdDeviation="1"/><feColorMatrix in="shadowBlurOuter1" result="shadowMatrixOuter1" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/><feMerge><feMergeNode in="shadowMatrixOuter1"/><feMergeNode in="SourceGraphic"/></feMerge></filter><rect id="b" height="40" rx="4" width="48"/><filter id="c" height="110%" width="108.3%" x="-4.2%" y="-2.5%"><feOffset dx="0" dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/><feGaussianBlur in="shadowOffsetOuter1" result="shadowBlurOuter1" stdDeviation=".5"/><feColorMatrix in="shadowBlurOuter1" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/></filter><mask id="d" fill="#fff"><use fill="#fff" fill-rule="evenodd" xlink:href="#b"/></mask></defs><g fill="none" fill-rule="evenodd" filter="url(#a)" transform="translate(2 1)"><use fill="#000" filter="url(#c)" xlink:href="#b"/><use fill="#f0f2f5" fill-rule="evenodd" xlink:href="#b"/><path d="m-1 0h49v10h-49z" fill="#303648" mask="url(#d)"/></g></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1,29 +0,0 @@
<!DOCTYPE html>
<html lang="zh-cmn-Hans">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="renderer" content="webkit">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<meta name="robots" content="noindex,nofollow" />
<meta name="generator" content="Halo <%= htmlWebpackPlugin.options.version %>" />
<link rel="icon" href="/favicon.ico" />
<title>Halo</title>
<style>
body {height: 100%;background-color: #f5f5f5;}#loader{position:absolute;top:0;right:0;bottom:0;left:0;margin:auto;border:solid 3px #e5e5e5;border-top-color:#333;border-radius:50%;width:30px;height:30px;animation:spin .6s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}
</style>
</head>
<body>
<noscript>
<strong>We're sorry but halo admin client doesn't work properly without JavaScript enabled. Please enable it to
continue.</strong>
</noscript>
<div id="app">
<div id="loader"></div>
</div>
<!-- built files will be auto injected -->
</body>
</html>

View File

@ -1,40 +0,0 @@
<template>
<a-config-provider :locale="locale">
<div id="app" class="h-full">
<router-view />
</div>
</a-config-provider>
</template>
<script>
import zhCN from 'ant-design-vue/lib/locale-provider/zh_CN'
import { DEVICE_TYPE, deviceEnquire } from '@/utils/device'
export default {
data() {
return {
locale: zhCN
}
},
mounted() {
const { $store } = this
deviceEnquire(deviceType => {
switch (deviceType) {
case DEVICE_TYPE.DESKTOP:
$store.commit('TOGGLE_DEVICE', 'desktop')
$store.dispatch('setSidebar', true)
break
case DEVICE_TYPE.TABLET:
$store.commit('TOGGLE_DEVICE', 'tablet')
$store.dispatch('setSidebar', false)
break
case DEVICE_TYPE.MOBILE:
default:
$store.commit('TOGGLE_DEVICE', 'mobile')
$store.dispatch('setSidebar', true)
break
}
})
}
}
</script>

View File

@ -1,233 +0,0 @@
<template>
<a-modal v-model="modalVisible" :width="isMobile() ? '100%' : '50%'" title="附件详情">
<a-row :gutter="24" type="flex">
<a-col :lg="9" :md="24" :sm="24" :xl="9" :xs="24">
<div class="attach-detail-img pb-3">
<a v-if="isImage" :href="attachment.path" target="_blank">
<img :src="attachment.path" class="w-full" loading="lazy" />
</a>
<div v-else></div>
</div>
</a-col>
<a-col :lg="15" :md="24" :sm="24" :xl="15" :xs="24">
<a-list itemLayout="horizontal">
<a-list-item style="padding-top: 0">
<a-list-item-meta>
<template v-if="editable" slot="description">
<a-input ref="nameInput" v-model="attachment.name" @blur="handleUpdateName" />
</template>
<template v-else slot="description">{{ attachment.name }}</template>
<span slot="title">
附件名
<a-button class="!p-0" type="link" @click="handleEditName">
<a-icon type="edit" />
</a-button>
</span>
</a-list-item-meta>
</a-list-item>
<a-list-item>
<a-list-item-meta :description="attachment.mediaType">
<span slot="title">附件类型</span>
</a-list-item-meta>
</a-list-item>
<a-list-item>
<a-list-item-meta :description="attachment.type | typeText">
<span slot="title">存储位置</span>
</a-list-item-meta>
</a-list-item>
<a-list-item>
<a-list-item-meta>
<template slot="description">
{{ attachment.size | fileSizeFormat }}
</template>
<span slot="title">附件大小</span>
</a-list-item-meta>
</a-list-item>
<a-list-item v-if="isImage">
<a-list-item-meta :description="attachment.height + 'x' + attachment.width">
<span slot="title">图片尺寸</span>
</a-list-item-meta>
</a-list-item>
<a-list-item>
<a-list-item-meta>
<template slot="description">
{{ attachment.createTime | moment }}
</template>
<span slot="title">上传日期</span>
</a-list-item-meta>
</a-list-item>
<a-list-item>
<a-list-item-meta>
<template #description>
<a :href="attachment.path" target="_blank">{{ attachment.path }}</a>
</template>
<span slot="title">
普通链接
<a-button class="!p-0" type="link" @click="handleCopyLink(`${encodeURI(attachment.path)}`)">
<a-icon type="copy" />
</a-button>
</span>
</a-list-item-meta>
</a-list-item>
<a-list-item v-if="isImage">
<a-list-item-meta>
<span slot="description">![{{ attachment.name }}]({{ attachment.path }})</span>
<span slot="title">
Markdown 格式
<a-button
class="!p-0"
type="link"
@click="handleCopyLink(`![${attachment.name}](${encodeURI(attachment.path)})`)"
>
<a-icon type="copy" />
</a-button>
</span>
</a-list-item-meta>
</a-list-item>
</a-list>
</a-col>
</a-row>
<template #footer>
<slot name="extraFooter" />
<a-popconfirm cancelText="取消" okText="确定" title="你确定要删除该附件?" @confirm="handleDelete">
<ReactiveButton
:errored="deleteErrored"
:loading="deleting"
erroredText="删除失败"
icon="delete"
loadedText="删除成功"
text="删除"
type="danger"
@callback="handleDeletedCallback"
></ReactiveButton>
</a-popconfirm>
</template>
</a-modal>
</template>
<script>
import { mixin, mixinDevice } from '@/mixins/mixin.js'
import apiClient from '@/utils/api-client'
import { attachmentTypes } from '@/core/constant'
export default {
name: 'AttachmentDetailModal',
mixins: [mixin, mixinDevice],
filters: {
typeText(type) {
return type ? attachmentTypes[type].text : ''
}
},
props: {
visible: {
type: Boolean,
default: true
},
attachment: {
type: Object,
default: () => ({})
}
},
data() {
return {
editable: false,
deleting: false,
deleteErrored: false
}
},
computed: {
modalVisible: {
get() {
return this.visible
},
set(value) {
this.$emit('update:visible', value)
}
},
isImage() {
if (!this.attachment || !this.attachment.mediaType) {
return false
}
return this.attachment.mediaType.startsWith('image')
}
},
methods: {
/**
* Deletes the attachment
*/
async handleDelete() {
try {
this.deleting = true
await apiClient.attachment.delete(this.attachment.id)
} catch (error) {
this.$log.error(error)
this.deleteErrored = true
} finally {
setTimeout(() => {
this.deleting = false
}, 400)
}
},
/**
* Handles the deletion callback event
*/
handleDeletedCallback() {
this.$emit('delete', this.attachment)
this.deleteErrored = false
this.modalVisible = false
},
/**
* Shows the edit name input
*/
handleEditName() {
this.editable = !this.editable
if (this.editable) {
this.$nextTick(() => {
this.$refs.nameInput.focus()
})
}
},
/**
* Updates the attachment name
*/
async handleUpdateName() {
if (!this.attachment.name) {
this.$notification['error']({
message: '提示',
description: '附件名称不能为空!'
})
return
}
try {
// TODO sdk updateName
await apiClient.attachment.update(this.attachment.id, this.attachment.name)
} catch (error) {
this.$log.error(error)
} finally {
this.editable = false
}
},
/**
* Handles the copy link event
* @param {String} link
*/
handleCopyLink(link) {
this.$copyText(link)
.then(message => {
this.$log.debug('copy', message)
this.$message.success('复制成功!')
})
.catch(err => {
this.$log.debug('copy.err', err)
this.$message.error('复制失败!')
})
}
}
}
</script>

View File

@ -1,459 +0,0 @@
<template>
<a-modal v-model="modalVisible" :afterClose="onAfterClose" :title="title" :width="1024" destroyOnClose>
<div class="table-page-search-wrapper">
<a-form layout="inline">
<a-row :gutter="24">
<a-col :md="6" :sm="24">
<a-form-item label="关键词:">
<a-input v-model="list.params.keyword" @keyup.enter="handleSearch()" />
</a-form-item>
</a-col>
<a-col :md="6" :sm="24">
<a-form-item label="存储位置:">
<a-select
v-model="list.params.attachmentType"
:loading="types.loading"
allowClear
@change="handleSearch()"
>
<a-select-option v-for="item in types.data" :key="item" :value="item">
{{ item | typeText }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :md="6" :sm="24">
<a-form-item label="文件类型:">
<a-select
v-model="list.params.mediaType"
:loading="mediaTypes.loading"
allowClear
@change="handleSearch()"
>
<a-select-option v-for="(item, index) in mediaTypes.data" :key="index" :value="item">
{{ item }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :md="6" :sm="24">
<span class="table-page-search-submitButtons">
<a-space>
<a-button type="primary" @click="handleSearch()"></a-button>
<a-button @click="handleResetParam(), handleListAttachments()">重置</a-button>
</a-space>
</span>
</a-col>
</a-row>
</a-form>
</div>
<div class="mb-0 table-operator">
<a-button icon="cloud-upload" type="primary" @click="upload.visible = true">上传</a-button>
</div>
<a-divider />
<a-list
:dataSource="list.data"
:grid="{ gutter: 6, xs: 2, sm: 2, md: 4, lg: 6, xl: 6, xxl: 6 }"
:loading="list.loading"
class="attachments-group"
>
<template #renderItem="item, index">
<a-list-item
@mouseenter="$set(item, 'hover', true)"
@mouseleave="$set(item, 'hover', false)"
:key="index"
@click="handleItemClick(item)"
>
<div :class="`${isItemSelect(item) ? 'border-blue-600' : 'border-slate-200'}`" class="border border-solid">
<div class="attach-thumb attachments-group-item">
<span v-if="!isImage(item)" class="attachments-group-item-type">{{ item.suffix }}</span>
<span
v-else
:style="`background-image:url(${encodeURI(item.thumbPath)})`"
class="attachments-group-item-img"
loading="lazy"
/>
</div>
<a-card-meta class="p-2 cursor-pointer">
<template #description>
<a-tooltip :title="item.name">
<div class="truncate">{{ item.name }}</div>
</a-tooltip>
</template>
</a-card-meta>
<a-icon
v-show="isItemSelect(item) && !item.hover"
type="check-circle"
theme="twoTone"
class="absolute top-1 right-2 font-bold cursor-pointer transition-all"
:style="{ fontSize: '18px', color: 'rgb(37 99 235)' }"
/>
<a-icon
v-show="item.hover"
type="profile"
theme="twoTone"
class="absolute top-1 right-2 font-bold cursor-pointer transition-all"
@click.stop="handleOpenDetail(item)"
:style="{ fontSize: '18px' }"
/>
</div>
</a-list-item>
</template>
</a-list>
<div class="flex justify-between">
<a-popover placement="right" title="预览" trigger="click">
<template slot="content">
<a-tabs v-if="list.selected.length" default-active-key="markdown" tab-position="left">
<a-tab-pane key="markdown" tab="Markdown">
<div class="text-slate-400" v-html="markdownSyntaxList.join('<br />')"></div>
</a-tab-pane>
<a-tab-pane key="html" force-render tab="HTML">
<div class="text-slate-400">
<span v-for="(item, index) in htmlSyntaxList" :key="index" class="text-slate-400">
{{ item }}<br />
</span>
</div>
</a-tab-pane>
</a-tabs>
<div v-else class="text-slate-400">未选择附件</div>
</template>
<a-tooltip placement="top" title="点击预览">
<div class="self-center text-slate-400 select-none cursor-pointer hover:text-blue-400 transition-all">
已选择 {{ list.selected.length }}
</div>
</a-tooltip>
</a-popover>
<div class="page-wrapper flex justify-end self-center">
<a-pagination
:current="pagination.page"
:defaultPageSize="pagination.size"
:pageSizeOptions="['12', '18', '24', '30', '36', '42']"
:total="pagination.total"
class="pagination !mt-0"
showLessItems
showSizeChanger
@change="handlePageChange"
@showSizeChange="handlePageSizeChange"
/>
</div>
</div>
<template slot="footer">
<a-button @click="modalVisible = false">取消</a-button>
<a-button type="primary" :disabled="!list.selected.length" @click="handleConfirm"></a-button>
</template>
<AttachmentUploadModal :visible.sync="upload.visible" @close="handleSearch" />
<AttachmentDetailModal :attachment="list.current" :visible.sync="detailVisible" @delete="handleListAttachments()">
<template #extraFooter>
<a-button :disabled="selectPreviousButtonDisabled" @click="handleSelectPrevious"></a-button>
<a-button :disabled="selectNextButtonDisabled" @click="handleSelectNext"></a-button>
<a-button @click="handleItemClick(list.current)" type="primary">
{{ list.selected.findIndex(item => item.id === list.current.id) > -1 ? '取消选择' : '选择' }}
</a-button>
</template>
</AttachmentDetailModal>
</a-modal>
</template>
<script>
import apiClient from '@/utils/api-client'
import { attachmentTypes } from '@/core/constant'
export default {
name: 'AttachmentSelectModal',
props: {
visible: {
type: Boolean,
default: false
},
title: {
type: String,
default: '选择附件'
},
multiSelect: {
type: Boolean,
default: true
}
},
data() {
return {
list: {
data: [],
total: 0,
hasNext: false,
hasPrevious: false,
loading: false,
params: {
page: 0,
size: 12,
keyword: undefined,
mediaType: undefined,
attachmentType: undefined
},
selected: [],
current: {}
},
mediaTypes: {
data: [],
loading: false
},
types: {
data: [],
loading: false
},
upload: {
visible: false
},
detailVisible: 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
}
},
selectPreviousButtonDisabled() {
const index = this.list.data.findIndex(attachment => attachment.id === this.list.current.id)
return index === 0 && !this.list.hasPrevious
},
selectNextButtonDisabled() {
const index = this.list.data.findIndex(attachment => attachment.id === this.list.current.id)
return index === this.list.data.length - 1 && !this.list.hasNext
},
isImage() {
return function (attachment) {
if (!attachment || !attachment.mediaType) {
return false
}
return attachment.mediaType.startsWith('image')
}
},
isItemSelect() {
return function (attachment) {
return this.list.selected.findIndex(item => item.id === attachment.id) > -1
}
},
markdownSyntaxList() {
if (!this.list.selected.length) {
return []
}
return this.list.selected.map(item => {
return `![${item.name}](${encodeURI(item.path)})`
})
},
htmlSyntaxList() {
if (!this.list.selected.length) {
return []
}
return this.list.selected.map(item => {
return `<img src="${encodeURI(item.path)}" alt="${item.name}">`
})
}
},
watch: {
modalVisible(value) {
if (value) {
this.handleListAttachments()
this.handleListMediaTypes()
this.handleListTypes()
}
}
},
methods: {
/**
* List attachments
*/
async handleListAttachments() {
try {
this.list.loading = true
const response = await apiClient.attachment.list(this.list.params)
this.list.data = response.data.content
this.list.total = response.data.total
this.list.hasNext = response.data.hasNext
this.list.hasPrevious = response.data.hasPrevious
} catch (error) {
this.$log.error(error)
} finally {
this.list.loading = false
}
},
/**
* List attachment media types
*/
async handleListMediaTypes() {
try {
this.mediaTypes.loading = true
const response = await apiClient.attachment.listMediaTypes()
this.mediaTypes.data = response.data
} catch (error) {
this.$log.error(error)
} finally {
this.mediaTypes.loading = false
}
},
/**
* List attachment upload types
*/
async handleListTypes() {
try {
this.types.loading = true
const response = await apiClient.attachment.listTypes()
this.types.data = response.data
} catch (error) {
this.$log.error(error)
} finally {
this.types.loading = false
}
},
/**
* Handle page change
*/
handlePageChange(page = 1) {
this.list.params.page = page - 1
this.handleListAttachments()
},
/**
* Search attachments
*/
handleSearch() {
this.handlePageChange(1)
},
/**
* Reset search params
*/
handleResetParam() {
this.list.params = {
page: 0,
size: 12,
keyword: undefined,
mediaType: undefined,
attachmentType: undefined
}
},
/**
* 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.handleListAttachments()
},
handleItemClick(attachment) {
// single select
if (!this.multiSelect) {
this.$emit('confirm', {
raw: [attachment],
markdown: [`![${attachment.name}](${encodeURI(attachment.path)})`],
html: [`<img src="${encodeURI(attachment.path)}" alt="${attachment.name}">`]
})
this.modalVisible = false
return
}
const isSelect = this.list.selected.findIndex(item => item.id === attachment.id) > -1
isSelect ? this.handleUnselect(attachment) : this.handleSelect(attachment)
},
handleSelect(attachment) {
this.list.selected = [...this.list.selected, attachment]
},
handleUnselect(attachment) {
this.list.selected = this.list.selected.filter(item => item.id !== attachment.id)
},
handleConfirm() {
this.$emit('confirm', {
raw: this.list.selected,
markdown: this.markdownSyntaxList,
html: this.htmlSyntaxList
})
this.modalVisible = false
},
handleOpenDetail(attachment) {
this.list.current = attachment
this.detailVisible = true
},
/**
* Select previous attachment
*/
async handleSelectPrevious() {
const index = this.list.data.findIndex(item => item.id === this.list.current.id)
if (index > 0) {
this.list.current = this.list.data[index - 1]
return
}
if (index === 0 && this.list.hasPrevious) {
this.list.params.page--
await this.handleListAttachments()
this.list.current = this.list.data[this.list.data.length - 1]
}
},
/**
* Select next attachment
*/
async handleSelectNext() {
const index = this.list.data.findIndex(item => item.id === this.list.current.id)
if (index < this.list.data.length - 1) {
this.list.current = this.list.data[index + 1]
return
}
if (index === this.list.data.length - 1 && this.list.hasNext) {
this.list.params.page++
await this.handleListAttachments()
this.list.current = this.list.data[0]
}
},
onAfterClose() {
this.handleResetParam()
this.list.selected = []
}
},
filters: {
typeText(type) {
return attachmentTypes[type].text
}
}
}
</script>

View File

@ -1,39 +0,0 @@
<template>
<a-modal v-model="modalVisible" :afterClose="onClose" :footer="null" destroyOnClose title="上传附件">
<FilePondUpload ref="upload" :uploadHandler="uploadHandler"></FilePondUpload>
</a-modal>
</template>
<script>
import apiClient from '@/utils/api-client'
export default {
name: 'AttachmentUploadModal',
props: {
visible: {
type: Boolean,
default: false
}
},
data() {
return {
uploadHandler: (file, options) => apiClient.attachment.upload(file, options)
}
},
computed: {
modalVisible: {
get() {
return this.visible
},
set(value) {
this.$emit('update:visible', value)
}
}
},
methods: {
onClose() {
this.$refs.upload.handleClearFileList()
this.$emit('close')
}
}
}
</script>

View File

@ -1,100 +0,0 @@
<template>
<a-button
:block="block"
:icon="computedIcon"
:loading="loading"
:size="size"
:type="computedType"
@click="handleClick"
>{{ computedText }}
</a-button>
</template>
<script>
export default {
name: 'ReactiveButton',
props: {
type: {
type: String,
default: 'primary'
},
icon: {
type: String,
default: null
},
size: {
type: String,
default: 'default'
},
block: {
type: Boolean,
default: false
},
loading: {
type: Boolean,
default: false
},
errored: {
type: Boolean,
default: false
},
text: {
type: String,
default: ''
},
loadedText: {
type: String,
default: ''
},
erroredText: {
type: String,
default: ''
}
},
data() {
return {
loaded: false,
hasError: false
}
},
watch: {
loading(value) {
if (!value) {
this.loaded = true
if (this.errored) {
this.hasError = true
}
setTimeout(() => {
this.loaded = false
this.hasError = false
this.$emit('callback')
}, 400)
}
}
},
computed: {
computedType() {
if (this.loaded) {
return this.hasError ? 'danger' : this.type
}
return this.type
},
computedIcon() {
if (this.loaded) {
return this.hasError ? 'close-circle' : 'check-circle'
}
return this.icon
},
computedText() {
if (this.loaded) {
return this.hasError ? this.erroredText : this.loadedText
}
return this.text
}
},
methods: {
handleClick() {
this.$emit('click')
}
}
}
</script>

View File

@ -1,77 +0,0 @@
<template>
<div ref="codemirrorRef"></div>
</template>
<script>
import { basicSetup } from '@codemirror/basic-setup'
import { EditorView, keymap } from '@codemirror/view'
import { EditorState } from '@codemirror/state'
import { indentWithTab } from '@codemirror/commands'
export default {
name: 'Codemirror',
model: {
prop: 'value',
event: 'update:value'
},
props: {
value: {
type: String,
default: ''
},
extensions: {
type: Array,
default: () => []
},
height: {
type: String,
default: '500px'
}
},
data() {
return {
codemirrorState: null,
codemirrorView: null
}
},
mounted() {
this.handleInitCodemirror()
},
beforeDestroy() {
if (this.codemirrorView) {
this.codemirrorView.destroy()
}
},
methods: {
handleInitCodemirror() {
if (this.codemirrorView) {
this.codemirrorView.destroy()
}
const codemirrorRef = this.$refs.codemirrorRef
const onUpdateExtension = EditorView.updateListener.of(vu => {
if (vu.docChanged) {
const doc = vu.state.doc
this.$emit('update:value', doc.toString())
}
})
const defaultTheme = EditorView.theme({
'&': {
height: this.height
}
})
this.codemirrorState = EditorState.create({
doc: this.value,
extensions: [basicSetup, onUpdateExtension, keymap.of([indentWithTab]), defaultTheme, ...this.extensions]
})
this.codemirrorView = new EditorView({
state: this.codemirrorState,
parent: codemirrorRef
})
}
}
}
</script>

View File

@ -1,79 +0,0 @@
<template>
<a-list :dataSource="comments" :loading="loading" item-layout="vertical">
<template #renderItem="item, index">
<a-list-item :key="index" class="!p-0">
<a-comment :avatar="item.avatar">
<template #author>
<a v-if="item.authorUrl" :href="item.authorUrl" class="!text-gray-800 hover:!text-blue-500" target="_blank">
{{ item.author }}
</a>
<span v-else class="!text-gray-500">{{ item.author }}</span>
发表在
<span class="hover:!text-blue-500 cursor-pointer" @click="handleOpenTarget(item)">
{{ targetTitle(item) }}
</span>
</template>
<template #content>
<p v-html="$options.filters.markdownRender(item.content)" />
</template>
<template #datetime>
<a-tooltip :title="item.createTime | moment">
<span>{{ item.createTime | timeAgo }}</span>
</a-tooltip>
</template>
</a-comment>
</a-list-item>
</template>
</a-list>
</template>
<script>
import { datetimeFormat } from '@/utils/datetime'
import apiClient from '@/utils/api-client'
export default {
name: 'CommentListView',
props: {
comments: {
type: Array,
default: () => []
},
loading: {
type: Boolean,
default: false
}
},
computed: {
targetTitle() {
return function (comment) {
if (comment.post) {
return comment.post.title
}
if (comment.sheet) {
return comment.sheet.title
}
if (comment.journal) {
return datetimeFormat(comment.journal.createTime)
}
return ''
}
}
},
methods: {
async handleOpenTarget(comment) {
const { post, sheet } = comment
if (post || sheet) {
const { status, fullPath, id } = post || sheet
if (['PUBLISHED', 'INTIMATE'].includes(status)) {
window.open(fullPath, '_blank')
return
}
if (status === 'DRAFT') {
const target = post ? 'post' : 'sheet'
const link = await apiClient[target].getPreviewLinkById(id)
window.open(link, '_blank')
}
}
}
}
}
</script>

View File

@ -1,119 +0,0 @@
<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

@ -1,157 +0,0 @@
<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

@ -1,146 +0,0 @@
<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,85 +0,0 @@
<template>
<div class="h-full">
<halo-editor
ref="editor"
v-model="originalContentData"
:boxShadow="false"
:toolbars="markdownEditorToolbars"
:uploadRequest="handleAttachmentUpload"
autofocus
@change="handleChange"
@openImagePicker="attachmentSelectVisible = true"
@save="handleSave"
/>
<AttachmentSelectModal :visible.sync="attachmentSelectVisible" @confirm="handleSelectAttachment" />
</div>
</template>
<script>
import haloEditor from '@halo-dev/editor'
import '@halo-dev/editor/dist/lib/style.css'
import apiClient from '@/utils/api-client'
import { markdownEditorToolbars } from '@/core/constant'
export default {
name: 'MarkdownEditor',
components: {
haloEditor: haloEditor.editor
},
props: {
originalContent: {
type: String,
required: false,
default: ''
}
},
data() {
return {
markdownEditorToolbars,
attachmentSelectVisible: false
}
},
computed: {
originalContentData: {
get() {
return this.originalContent
},
set(value) {
this.$emit('update:originalContent', value)
}
}
},
methods: {
handleAttachmentUpload(file) {
return new Promise((resolve, reject) => {
const hideLoading = this.$message.loading('上传中...', 0)
apiClient.attachment
.upload(file)
.then(response => {
const attachment = response.data
resolve({
name: attachment.name,
path: encodeURI(attachment.path)
})
})
.catch(e => {
this.$log.error('upload image error: ', e)
reject(e)
})
.finally(() => {
hideLoading()
})
})
},
handleSelectAttachment({ markdown }) {
this.$refs.editor.insetAtCursor(markdown.join('\n'))
},
handleSave() {
this.$emit('save')
},
handleChange({ originalContent, renderContent }) {
this.$emit('change', { originalContent, renderContent })
}
}
}
</script>

View File

@ -1,30 +0,0 @@
<template>
<div>
<a-input v-model="originalContent" :rows="16" type="textarea" />
</div>
</template>
<script>
export default {
name: 'RichTextEditor',
props: {
originalContent: {
type: String,
required: false,
default: ''
}
},
data() {
return {
originalContentData: ''
}
},
watch: {
originalContent(val) {
this.originalContentData = val
},
originalContentData(val) {
this.$emit('onContentChange', val)
}
}
}
</script>

View File

@ -1,75 +0,0 @@
<script>
import Tooltip from 'ant-design-vue/es/tooltip'
const getStrFullLength = (str = '') =>
str.split('').reduce((pre, cur) => {
const charCode = cur.charCodeAt(0)
if (charCode >= 0 && charCode <= 128) {
return pre + 1
}
return pre + 2
}, 0)
const cutStrByFullLength = (str = '', maxLength) => {
let showLength = 0
return str.split('').reduce((pre, cur) => {
const charCode = cur.charCodeAt(0)
if (charCode >= 0 && charCode <= 128) {
showLength += 1
} else {
showLength += 2
}
if (showLength <= maxLength) {
return pre + cur
}
return pre
}, '')
}
export default {
name: 'Ellipsis',
components: {
Tooltip
},
props: {
prefixCls: {
type: String,
default: 'ant-pro-ellipsis'
},
tooltip: {
type: Boolean
},
length: {
type: Number,
required: true
},
lines: {
type: Number,
default: 1
},
fullWidthRecognition: {
type: Boolean,
default: false
}
},
methods: {
getStrDom(str, fullLength) {
return <span>{cutStrByFullLength(str, this.length) + (fullLength > this.length ? '...' : '')}</span>
},
getTooltip(fullStr, fullLength) {
return (
<Tooltip>
<template slot="title">{fullStr}</template>
{this.getStrDom(fullStr, fullLength)}
</Tooltip>
)
}
},
render() {
const { tooltip, length } = this.$props
const str = this.$slots.default.map(vNode => vNode.text).join('')
const fullLength = getStrFullLength(str)
return tooltip && fullLength > length ? this.getTooltip(str, fullLength) : this.getStrDom(str, fullLength)
}
}
</script>

View File

@ -1,3 +0,0 @@
import Ellipsis from './Ellipsis'
export default Ellipsis

View File

@ -1,26 +0,0 @@
<template>
<div :class="prefixCls">
<div class="float-left">
<slot name="extra">{{ extra }}</slot>
</div>
<div class="float-right">
<slot></slot>
</div>
</div>
</template>
<script>
export default {
name: 'FooterToolBar',
props: {
prefixCls: {
type: String,
default: 'ant-pro-footer-toolbar'
},
extra: {
type: [String, Object],
default: ''
}
}
}
</script>

View File

@ -1,4 +0,0 @@
import FooterToolBar from './FooterToolBar'
import './index.less'
export default FooterToolBar

View File

@ -1,23 +0,0 @@
@import '../../styles/index.less';
@footer-toolbar-prefix-cls: ~'@{ant-pro-prefix}-footer-toolbar';
.@{footer-toolbar-prefix-cls} {
position: fixed;
width: 100%;
bottom: 0;
right: 0;
height: 56px;
line-height: 56px;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.03);
background: #fff;
border-top: 1px solid #e8e8e8;
padding: 0 24px;
z-index: 1000;
&:after {
content: '';
display: block;
clear: both;
}
}

View File

@ -1,14 +0,0 @@
<template>
<div class="footer text-center" style="padding: 0 16px; margin: 48px 0 0">
<div class="copyright" style="color: rgba(0, 0, 0, 0.45); font-size: 14px">
Proudly power by
<router-link :to="{ name: 'About' }"> Halo </router-link>
</div>
</div>
</template>
<script>
export default {
name: 'GlobalFooter'
}
</script>

View File

@ -1,3 +0,0 @@
import GlobalFooter from './GlobalFooter'
export default GlobalFooter

View File

@ -1,136 +0,0 @@
<template>
<transition name="showHeader">
<div v-if="visible" class="header-animat">
<a-layout-header
v-if="visible"
:class="[
fixedHeader && 'ant-header-fixedHeader',
sidebarOpened ? 'ant-header-side-opened' : 'ant-header-side-closed'
]"
style="padding: 0"
>
<div v-if="mode === 'sidemenu'" class="header">
<a-icon
v-if="device === 'mobile'"
:type="collapsed ? 'menu-fold' : 'menu-unfold'"
class="trigger"
@click="toggle"
/>
<a-icon v-else :type="collapsed ? 'menu-unfold' : 'menu-fold'" class="trigger" @click="toggle" />
<user-menu></user-menu>
</div>
<div v-else :class="['top-nav-header-index', theme]">
<div class="header-index-wide">
<div class="header-index-left">
<logo v-if="device !== 'mobile'" class="top-nav-header" />
<s-menu v-if="device !== 'mobile'" :menu="menus" :theme="theme" mode="horizontal" />
<a-icon v-else :type="collapsed ? 'menu-fold' : 'menu-unfold'" class="trigger" @click="toggle" />
</div>
<user-menu class="header-index-right"></user-menu>
</div>
</div>
</a-layout-header>
</div>
</transition>
</template>
<script>
import UserMenu from '../Tools/UserMenu'
import SMenu from '../Menu/'
import Logo from '../Tools/Logo'
import { mixin } from '@/mixins/mixin'
export default {
name: 'GlobalHeader',
components: {
UserMenu,
SMenu,
Logo
},
mixins: [mixin],
props: {
mode: {
type: String,
// sidemenu, topmenu
default: 'sidemenu'
},
menus: {
type: Array,
required: true
},
theme: {
type: String,
required: false,
default: 'dark'
},
collapsed: {
type: Boolean,
required: false,
default: false
},
device: {
type: String,
required: false,
default: 'desktop'
}
},
data() {
return {
visible: true,
oldScrollTop: 0
}
},
mounted() {
document.addEventListener('scroll', this.handleScroll, { passive: true })
},
methods: {
handleScroll() {
if (!this.autoHideHeader) {
return
}
const scrollTop = document.body.scrollTop + document.documentElement.scrollTop
if (!this.ticking) {
this.ticking = true
requestAnimationFrame(() => {
if (this.oldScrollTop > scrollTop) {
this.visible = true
} else if (scrollTop > 300 && this.visible) {
this.visible = false
} else if (scrollTop < 300 && !this.visible) {
this.visible = true
}
this.oldScrollTop = scrollTop
this.ticking = false
})
}
},
toggle() {
this.$emit('toggle')
}
},
beforeDestroy() {
document.body.removeEventListener('scroll', this.handleScroll, true)
}
}
</script>
<style lang="less">
.header-animat {
position: relative;
z-index: 999;
}
.showHeader-enter-active {
transition: all 0.25s ease;
}
.showHeader-leave-active {
transition: all 0.5s ease;
}
.showHeader-enter,
.showHeader-leave-to {
opacity: 0;
}
</style>

View File

@ -1,3 +0,0 @@
import GlobalHeader from './GlobalHeader'
export default GlobalHeader

View File

@ -1,54 +0,0 @@
<template>
<div>
<a-input :defaultValue="defaultValue" :placeholder="placeholder" :value="value" @change="onInputChange">
<template #addonAfter>
<a-button class="!p-0 !h-auto" type="link" @click="attachmentModalVisible = true">
<a-icon type="picture" />
</a-button>
</template>
</a-input>
<AttachmentSelectModal
:multiSelect="false"
:visible.sync="attachmentModalVisible"
@confirm="handleSelectAttachment"
/>
</div>
</template>
<script>
export default {
name: 'AttachmentInput',
props: {
value: {
type: String,
default: ''
},
defaultValue: {
type: String,
default: ''
},
placeholder: {
type: String,
default: ''
},
title: {
type: String,
default: '选择附件'
}
},
data() {
return {
attachmentModalVisible: false
}
},
methods: {
onInputChange(e) {
this.$emit('input', e.target.value)
},
handleSelectAttachment({ raw }) {
if (raw.length) {
this.$emit('input', encodeURI(raw[0].path))
}
}
}
}
</script>

View File

@ -1,123 +0,0 @@
<template>
<div>
<a-form-model
ref="loginForm"
:model="form.model"
:rules="form.rules"
layout="vertical"
@keyup.enter.native="form.needAuthCode ? handleLogin() : handleLoginClick()"
>
<a-form-model-item v-if="!form.needAuthCode" prop="username">
<a-input v-model="form.model.username" placeholder="用户名/邮箱">
<a-icon slot="prefix" style="color: rgba(0, 0, 0, 0.25)" type="user" />
</a-input>
</a-form-model-item>
<a-form-model-item v-if="!form.needAuthCode" prop="password">
<a-input v-model="form.model.password" placeholder="密码" type="password">
<a-icon slot="prefix" style="color: rgba(0, 0, 0, 0.25)" type="lock" />
</a-input>
</a-form-model-item>
<a-form-model-item v-if="form.needAuthCode" prop="authcode">
<a-input v-model="form.model.authcode" :maxLength="6" placeholder="两步验证码">
<a-icon slot="prefix" style="color: rgba(0, 0, 0, 0.25)" type="safety-certificate" />
</a-input>
</a-form-model-item>
<a-form-model-item>
<a-button
:block="true"
:loading="form.logging"
type="primary"
@click="form.needAuthCode ? handleLogin() : handleLoginClick()"
>
{{ buttonName }}
</a-button>
</a-form-model-item>
</a-form-model>
</div>
</template>
<script>
import { mapActions } from 'vuex'
import apiClient from '@/utils/api-client'
export default {
name: 'LoginForm',
data() {
const mfaValidate = (rule, value, callback) => {
if (!value && this.form.needAuthCode) {
callback(new Error('* 请输入两步验证码'))
} else {
callback()
}
}
return {
form: {
model: {
authcode: null,
password: null,
username: null
},
rules: {
username: [{ required: true, message: '* 用户名/邮箱不能为空', trigger: ['change'] }],
password: [{ required: true, message: '* 密码不能为空', trigger: ['change'] }],
authcode: [{ validator: mfaValidate, trigger: ['change'] }]
},
needAuthCode: false,
logging: false
}
}
},
computed: {
buttonName() {
return this.form.needAuthCode ? '验证' : '登录'
}
},
methods: {
...mapActions(['login', 'refreshUserCache', 'refreshOptionsCache']),
handleLoginClick() {
const _this = this
_this.$refs.loginForm.validate(valid => {
if (valid) {
_this.form.logging = true
apiClient
.needMFACode({
username: _this.form.model.username,
password: _this.form.model.password
})
.then(response => {
const data = response.data
if (data && data.needMFACode) {
_this.form.needAuthCode = true
_this.form.model.authcode = null
} else {
_this.handleLogin()
}
})
.finally(() => {
setTimeout(() => {
_this.form.logging = false
}, 300)
})
}
})
},
handleLogin() {
const _this = this
_this.$refs.loginForm.validate(valid => {
if (valid) {
_this.form.logging = true
_this
.login(_this.form.model)
.then(() => {
_this.$emit('success')
})
.finally(() => {
setTimeout(() => {
_this.form.logging = false
}, 300)
})
}
})
}
}
}
</script>

View File

@ -1,38 +0,0 @@
<template>
<div>
<a-modal
v-model="loginModal"
:footer="null"
:maskClosable="false"
:width="320"
title="重新登录"
@cancel="handleCancelLogin"
>
<LoginForm @success="onLoginSucceed" />
</a-modal>
</div>
</template>
<script>
import LoginForm from './LoginForm'
import { mapActions, mapGetters } from 'vuex'
export default {
name: 'LoginModal',
components: {
LoginForm
},
computed: {
...mapGetters(['loginModal'])
},
methods: {
...mapActions(['ToggleLoginModal']),
onLoginSucceed() {
this.$emit('success')
},
handleCancelLogin() {
this.ToggleLoginModal(false)
}
}
}
</script>
<style scoped></style>

View File

@ -1,62 +0,0 @@
<template>
<a-layout-sider
v-model="collapsed"
:class="['sider', isDesktop() ? null : 'shadow', theme, fixedSidebar ? 'ant-fixed-sidemenu' : null]"
:collapsible="collapsible"
:trigger="null"
width="256px"
>
<logo />
<s-menu
:collapsed="collapsed"
:menu="menus"
:mode="mode"
:theme="theme"
style="padding: 16px 0px"
@select="onSelect"
></s-menu>
</a-layout-sider>
</template>
<script>
import Logo from '@/components/Tools/Logo'
import SMenu from './index'
import { mixin, mixinDevice } from '@/mixins/mixin'
export default {
name: 'SideMenu',
components: { Logo, SMenu },
mixins: [mixin, mixinDevice],
props: {
mode: {
type: String,
required: false,
default: 'inline'
},
theme: {
type: String,
required: false,
default: 'dark'
},
collapsible: {
type: Boolean,
required: false,
default: false
},
collapsed: {
type: Boolean,
required: false,
default: false
},
menus: {
type: Array,
required: true
}
},
methods: {
onSelect(obj) {
this.$emit('menuSelect', obj)
}
}
}
</script>

View File

@ -1,3 +0,0 @@
import SMenu from './menu'
export default SMenu

View File

@ -1,165 +0,0 @@
import Menu from 'ant-design-vue/es/menu'
import Icon from 'ant-design-vue/es/icon'
export default {
name: 'SMenu',
props: {
menu: {
type: Array,
required: true
},
theme: {
type: String,
required: false,
default: 'dark'
},
mode: {
type: String,
required: false,
default: 'inline'
},
collapsed: {
type: Boolean,
required: false,
default: false
}
},
data() {
return {
openKeys: [],
selectedKeys: [],
cachedOpenKeys: []
}
},
computed: {
rootSubmenuKeys: vm => {
const keys = []
vm.menu.forEach(item => keys.push(item.path))
return keys
}
},
created() {
this.updateMenu()
},
watch: {
collapsed(val) {
if (val) {
this.cachedOpenKeys = this.openKeys.concat()
this.openKeys = []
} else {
this.openKeys = this.cachedOpenKeys
}
},
$route: function () {
this.updateMenu()
}
},
methods: {
// select menu item
onOpenChange(openKeys) {
// 在水平模式下时执行,并且不再执行后续
if (this.mode === 'horizontal') {
this.openKeys = openKeys
return
}
// 非水平模式时
const latestOpenKey = openKeys.find(key => !this.openKeys.includes(key))
if (!this.rootSubmenuKeys.includes(latestOpenKey)) {
this.openKeys = openKeys
} else {
this.openKeys = latestOpenKey ? [latestOpenKey] : []
}
},
onSelect({ item, key, selectedKeys }) {
this.selectedKeys = selectedKeys
this.$emit('select', { item, key, selectedKeys })
},
updateMenu() {
const routes = this.$route.matched.concat()
if (routes.length >= 4 && this.$route.meta.hidden) {
routes.pop()
this.selectedKeys = [routes[2].path]
} else {
this.selectedKeys = [routes.pop().path]
}
const openKeys = []
if (this.mode === 'inline') {
routes.forEach(item => {
openKeys.push(item.path)
})
}
this.collapsed ? (this.cachedOpenKeys = openKeys) : (this.openKeys = openKeys)
},
// render
renderItem(menu) {
if (!menu.hidden) {
return menu.children && !menu.hideChildrenInMenu ? this.renderSubMenu(menu) : this.renderMenuItem(menu)
}
return null
},
renderMenuItem(menu) {
const target = menu.meta.target || null
const CustomTag = (target && 'a') || 'router-link'
const props = { to: { name: menu.name } }
const attrs = { href: menu.path, target: menu.meta.target }
return (
<Menu.Item {...{ key: menu.path }}>
<CustomTag {...{ props, attrs }}>
{this.renderIcon(menu.meta.icon)}
<span>{menu.meta.title}</span>
</CustomTag>
</Menu.Item>
)
},
renderSubMenu(menu) {
const itemArr = []
if (!menu.hideChildrenInMenu) {
menu.children.forEach(item => itemArr.push(this.renderItem(item)))
}
return (
<Menu.SubMenu {...{ key: menu.path }}>
<span slot="title">
{this.renderIcon(menu.meta.icon)}
<span>{menu.meta.title}</span>
</span>
{itemArr}
</Menu.SubMenu>
)
},
renderIcon(icon) {
if (icon === 'none' || icon === undefined) {
return null
}
const props = {}
typeof icon === 'object' ? (props.component = icon) : (props.type = icon)
return <Icon {...{ props }} />
}
},
render() {
const dynamicProps = {
props: {
mode: this.mode,
theme: this.theme,
openKeys: this.openKeys,
selectedKeys: this.selectedKeys
},
on: {
openChange: this.onOpenChange,
select: this.onSelect
}
}
const menuTree = this.menu.map(item => {
if (item.hidden) {
return null
}
return this.renderItem(item)
})
return <Menu {...dynamicProps}>{menuTree}</Menu>
}
}

View File

@ -1,156 +0,0 @@
import Menu from 'ant-design-vue/es/menu'
import Icon from 'ant-design-vue/es/icon'
const { Item, SubMenu } = Menu
export default {
name: 'SMenu',
props: {
menu: {
type: Array,
required: true
},
theme: {
type: String,
required: false,
default: 'dark'
},
mode: {
type: String,
required: false,
default: 'inline'
},
collapsed: {
type: Boolean,
required: false,
default: false
}
},
data() {
return {
openKeys: [],
selectedKeys: [],
cachedOpenKeys: []
}
},
computed: {
rootSubmenuKeys: vm => {
const keys = []
vm.menu.forEach(item => keys.push(item.path))
return keys
}
},
created() {
this.updateMenu()
},
watch: {
collapsed(val) {
if (val) {
this.cachedOpenKeys = this.openKeys.concat()
this.openKeys = []
} else {
this.openKeys = this.cachedOpenKeys
}
},
$route: function () {
this.updateMenu()
}
},
methods: {
renderIcon: function (h, icon) {
if (icon === 'none' || icon === undefined) {
return null
}
const props = {}
typeof icon === 'object' ? (props.component = icon) : (props.type = icon)
return h(Icon, { props: { ...props } })
},
renderMenuItem: function (h, menu, pIndex, index) {
const target = menu.meta.target || null
return h(Item, { key: menu.path ? menu.path : 'item_' + pIndex + '_' + index }, [
h('router-link', { attrs: { to: { name: menu.name }, target: target } }, [
this.renderIcon(h, menu.meta.icon),
h('span', [menu.meta.title])
])
])
},
renderSubMenu: function (h, menu, pIndex, index) {
const this2_ = this
const subItem = [h('span', { slot: 'title' }, [this.renderIcon(h, menu.meta.icon), h('span', [menu.meta.title])])]
const itemArr = []
const pIndex_ = pIndex + '_' + index
this.$log.debug('menu', menu)
if (!menu.hideChildrenInMenu) {
menu.children.forEach(function (item, i) {
itemArr.push(this2_.renderItem(h, item, pIndex_, i))
})
}
return h(SubMenu, { key: menu.path ? menu.path : 'submenu_' + pIndex + '_' + index }, subItem.concat(itemArr))
},
renderItem: function (h, menu, pIndex, index) {
if (!menu.hidden) {
return menu.children && !menu.hideChildrenInMenu
? this.renderSubMenu(h, menu, pIndex, index)
: this.renderMenuItem(h, menu, pIndex, index)
}
},
renderMenu: function (h, menuTree) {
const this2_ = this
const menuArr = []
menuTree.forEach(function (menu, i) {
if (!menu.hidden) {
menuArr.push(this2_.renderItem(h, menu, '0', i))
}
})
return menuArr
},
onOpenChange(openKeys) {
const latestOpenKey = openKeys.find(key => !this.openKeys.includes(key))
if (!this.rootSubmenuKeys.includes(latestOpenKey)) {
this.openKeys = openKeys
} else {
this.openKeys = latestOpenKey ? [latestOpenKey] : []
}
},
updateMenu() {
const routes = this.$route.matched.concat()
if (routes.length >= 4 && this.$route.meta.hidden) {
routes.pop()
this.selectedKeys = [routes[2].path]
} else {
this.selectedKeys = [routes.pop().path]
}
const openKeys = []
if (this.mode === 'inline') {
routes.forEach(item => {
openKeys.push(item.path)
})
}
this.collapsed ? (this.cachedOpenKeys = openKeys) : (this.openKeys = openKeys)
}
},
render(h) {
return h(
Menu,
{
props: {
theme: this.$props.theme,
mode: this.$props.mode,
openKeys: this.openKeys,
selectedKeys: this.selectedKeys
},
on: {
openChange: this.onOpenChange,
select: obj => {
this.selectedKeys = obj.selectedKeys
this.$emit('select', obj)
}
}
},
this.renderMenu(h, this.menu)
)
}
}

View File

@ -1,165 +0,0 @@
<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">
<template #addonBefore>
<i>K</i>
</template>
</a-input>
</a-col>
<a-col :span="12">
<a-input v-model="meta.value">
<template #addonBefore>
<i>V</i>
</template>
</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">
<template #addonBefore>
<i>K</i>
</template>
</a-input>
</a-col>
<a-col :span="12">
<a-input v-model="meta.value">
<template #addonBefore>
<i>V</i>
</template>
<template #addonAfter>
<a-button class="!p-0 !h-auto" type="link" @click.prevent="handleRemove(index)">
<a-icon type="close" />
</a-button>
</template>
</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 apiClient from '@/utils/api-client'
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 apiClient.theme.getActivatedTheme()
this.presetFields = response.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>

View File

@ -1,28 +0,0 @@
<template>
<a-tag :color="tag.color" :style="{ color: labelColor }">
{{ tag.name }}
</a-tag>
</template>
<script>
import { isHex, isLight } from '@/utils/colorUtil'
export default {
name: 'PostTag',
props: {
tag: {
type: Object,
default: () => {}
}
},
computed: {
labelColor() {
const { color } = this.tag || {}
if (!color) return 'inherit'
if (!isHex(color)) {
return 'inherit'
}
return !isLight(color) ? '#fff' : 'inherit'
}
}
}
</script>

View File

@ -1,283 +0,0 @@
<template>
<div ref="settingDrawer" class="setting-drawer">
<a-drawer :visible="layoutSetting" closable width="300" @close="onClose">
<div class="setting-drawer-index-content">
<div class="mb-6">
<h3 class="setting-drawer-index-title">整体风格设置</h3>
<div class="setting-drawer-index-blockChecbox">
<a-tooltip>
<template #title>暗色菜单风格</template>
<div class="setting-drawer-index-item" @click="handleSetMenuTheme('dark')">
<img alt="dark" src="/images/dark.svg" />
<div v-if="navTheme === 'dark'" class="setting-drawer-index-selectIcon">
<a-icon type="check" />
</div>
</div>
</a-tooltip>
<a-tooltip>
<template #title>亮色菜单风格</template>
<div class="setting-drawer-index-item" @click="handleSetMenuTheme('light')">
<img alt="light" src="/images/dark.svg" />
<div v-if="navTheme !== 'dark'" class="setting-drawer-index-selectIcon">
<a-icon type="check" />
</div>
</div>
</a-tooltip>
</div>
</div>
<a-divider />
<div class="mb-6">
<h3 class="setting-drawer-index-title">主题色</h3>
<div class="h-5">
<a-tooltip v-for="(item, index) in colorList" :key="index" class="setting-drawer-theme-color-colorBlock">
<template #title>{{ item.key }}</template>
<a-tag :color="item.color" @click="handleChangeColor(item.color)">
<a-icon v-if="item.color === primaryColor" type="check"></a-icon>
</a-tag>
</a-tooltip>
</div>
</div>
<a-divider />
<div class="mb-6">
<h3 class="setting-drawer-index-title">导航模式</h3>
<div class="setting-drawer-index-blockChecbox">
<div class="setting-drawer-index-item" @click="handleSetLayout('sidemenu')">
<img alt="sidemenu" src="/images/sidemenu.svg" />
<div v-if="layoutMode === 'sidemenu'" class="setting-drawer-index-selectIcon">
<a-icon type="check" />
</div>
</div>
<div class="setting-drawer-index-item" @click="handleSetLayout('topmenu')">
<img alt="topmenu" src="/images/topmenu.svg" />
<div v-if="layoutMode !== 'sidemenu'" class="setting-drawer-index-selectIcon">
<a-icon type="check" />
</div>
</div>
</div>
</div>
<a-divider />
<div class="mt-6">
<a-list :split="false">
<a-list-item>
<template #actions>
<a-tooltip>
<template #title> 该设定仅 [顶部栏导航] 时有效</template>
<a-select
:disabled="layoutMode !== 'topmenu'"
:value="contentWidth"
size="small"
style="width: 80px"
@change="handleContentWidthChange"
>
<a-select-option value="Fixed">固定</a-select-option>
<a-select-option v-if="layoutMode !== 'sidemenu'" value="Fluid"></a-select-option>
</a-select>
</a-tooltip>
</template>
<a-list-item-meta>
<template #title>
<div>内容区域宽度</div>
</template>
</a-list-item-meta>
</a-list-item>
<a-list-item>
<template #actions>
<a-switch :checked="fixedHeader" size="small" @change="handleSetFixedHeader" />
</template>
<a-list-item-meta>
<template #title>
<div>固定 Header</div>
</template>
</a-list-item-meta>
</a-list-item>
<a-list-item>
<template #actions>
<a-switch
:checked="autoHideHeader"
:disabled="!fixedHeader"
size="small"
@change="handleSetAutoHideHeader"
/>
</template>
<a-list-item-meta>
<template #title>
<a-tooltip placement="left">
<template #title>固定 Header 时可配置</template>
<div :style="{ opacity: !fixedHeader ? '0.5' : '1' }">下滑时隐藏 Header</div>
</a-tooltip>
</template>
</a-list-item-meta>
</a-list-item>
<a-list-item>
<template #actions>
<a-switch
:checked="fixedSidebar"
:disabled="layoutMode === 'topmenu'"
size="small"
@change="handleSetFixedSidebar"
/>
</template>
<a-list-item-meta>
<template #title>
<div :style="{ opacity: layoutMode === 'topmenu' ? '0.5' : '1' }">固定侧边菜单</div>
</template>
</a-list-item-meta>
</a-list-item>
</a-list>
</div>
<a-divider />
</div>
</a-drawer>
</div>
</template>
<script>
import config from '@/config/defaultSettings'
import { colorList, updateTheme } from './setting'
import { mixin, mixinDevice } from '@/mixins/mixin'
import { mapActions, mapGetters } from 'vuex'
export default {
mixins: [mixin, mixinDevice],
data() {
return {
colorList,
baseConfig: Object.assign({}, config)
}
},
watch: {},
mounted() {
//
if (this.primaryColor !== config.primaryColor) {
updateTheme(this.primaryColor)
}
},
computed: {
...mapGetters(['layoutSetting'])
},
methods: {
...mapActions(['setSidebar', 'ToggleLayoutSetting']),
onClose() {
this.ToggleLayoutSetting(false)
},
handleSetMenuTheme(theme) {
this.baseConfig.navTheme = theme
this.$store.dispatch('ToggleTheme', theme)
},
handleSetLayout(mode) {
this.baseConfig.layout = mode
this.$store.dispatch('ToggleLayoutMode', mode)
if (mode === 'sidemenu') {
this.handleContentWidthChange('Fixed')
this.handleSetFixedSidebar(true)
} else {
this.handleSetFixedHeader(true)
this.handleSetFixedSidebar(false)
}
},
handleContentWidthChange(type) {
this.baseConfig.contentWidth = type
this.$store.dispatch('ToggleContentWidth', type)
},
handleChangeColor(color) {
this.baseConfig.primaryColor = color
if (this.primaryColor !== color) {
this.$store.dispatch('ToggleColor', color)
updateTheme(color)
}
},
handleSetFixedHeader(fixed) {
this.baseConfig.fixedHeader = fixed
this.$store.dispatch('ToggleFixedHeader', fixed)
if (!fixed) {
this.handleSetAutoHideHeader(false)
}
},
handleSetAutoHideHeader(autoHidden) {
this.baseConfig.autoHideHeader = autoHidden
this.$store.dispatch('ToggleFixedHeaderHidden', autoHidden)
},
handleSetFixedSidebar(fixed) {
this.baseConfig.fixedSidebar = fixed
this.$store.dispatch('ToggleFixedSidebar', fixed)
}
}
}
</script>
<style lang="less" scoped>
.setting-drawer-index-content {
.setting-drawer-index-blockChecbox {
display: flex;
.setting-drawer-index-item {
margin-right: 16px;
position: relative;
border-radius: 4px;
cursor: pointer;
img {
width: 48px;
}
.setting-drawer-index-selectIcon {
position: absolute;
top: 0;
right: 0;
width: 100%;
padding-top: 15px;
padding-left: 24px;
height: 100%;
color: #1890ff;
font-size: 14px;
font-weight: 700;
}
}
}
.setting-drawer-theme-color-colorBlock {
width: 20px;
height: 20px;
border-radius: 2px;
float: left;
cursor: pointer;
margin-right: 8px;
padding-left: 0;
padding-right: 0;
text-align: center;
color: #fff;
font-weight: 700;
i {
font-size: 14px;
}
}
}
.setting-drawer-index-handle {
position: absolute;
top: 240px;
background: #1890ff;
width: 48px;
height: 48px;
right: 300px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
pointer-events: auto;
z-index: 1001;
text-align: center;
font-size: 16px;
border-radius: 4px 0 0 4px;
i {
color: rgb(255, 255, 255);
font-size: 20px;
}
}
</style>

View File

@ -1,36 +0,0 @@
<template>
<div class="setting-drawer-index-item">
<h3 class="setting-drawer-index-title">{{ title }}</h3>
<slot></slot>
<a-divider v-if="divider" />
</div>
</template>
<script>
export default {
name: 'SettingItem',
props: {
title: {
type: String,
default: ''
},
divider: {
type: Boolean,
default: false
}
}
}
</script>
<style lang="less" scoped>
.setting-drawer-index-item {
margin-bottom: 24px;
.setting-drawer-index-title {
font-size: 14px;
color: rgba(0, 0, 0, 0.85);
line-height: 22px;
margin-bottom: 12px;
}
}
</style>

View File

@ -1,3 +0,0 @@
import SettingDrawer from './SettingDrawer'
export default SettingDrawer

View File

@ -1,100 +0,0 @@
import { message } from 'ant-design-vue/es'
// import defaultSettings from '../defaultSettings';
let lessNodesAppended
const colorList = [
{
key: '红色',
color: '#F5222D'
},
{
key: '浅红色',
color: '#FA541C'
},
{
key: '日暮',
color: '#FAAD14'
},
{
key: '青色',
color: '#13C2C2'
},
{
key: '绿色',
color: '#52C41A'
},
{
key: '默认',
color: '#1890FF'
},
{
key: '蓝色',
color: '#2F54EB'
},
{
key: '紫色',
color: '#722ED1'
}
]
const updateTheme = primaryColor => {
// Don't compile less in production!
/* if (process.env.NODE_ENV === 'production') {
return;
} */
// Determine if the component is remounted
if (!primaryColor) {
return
}
const hideMessage = message.loading('正在编译主题!', 0)
function buildIt() {
if (!window.less) {
return
}
setTimeout(() => {
window.less
.modifyVars({
'@primary-color': primaryColor
})
.then(() => {
hideMessage()
})
.catch(() => {
message.error('Failed to update theme')
hideMessage()
})
}, 200)
}
if (!lessNodesAppended) {
// insert less.js and color.less
const lessStyleNode = document.createElement('link')
const lessConfigNode = document.createElement('script')
const lessScriptNode = document.createElement('script')
lessStyleNode.setAttribute('rel', 'stylesheet/less')
lessStyleNode.setAttribute('href', '/color.less')
lessConfigNode.innerHTML = `
window.less = {
async: true,
env: 'production',
javascriptEnabled: true
};
`
lessScriptNode.src = 'https://unpkg.com/less@3.8.1/dist/less.min.js'
lessScriptNode.async = true
lessScriptNode.onload = () => {
buildIt()
lessScriptNode.onload = null
}
document.body.appendChild(lessStyleNode)
document.body.appendChild(lessConfigNode)
document.body.appendChild(lessScriptNode)
lessNodesAppended = true
} else {
buildIt()
}
}
export { updateTheme, colorList }

View File

@ -1,69 +0,0 @@
<template>
<div :class="center && 'center'" class="head-info">
<span>{{ title }}</span>
<p>{{ content }}</p>
<em v-if="bordered" />
</div>
</template>
<script>
export default {
name: 'HeadInfo',
props: {
title: {
type: String,
default: ''
},
content: {
type: String,
default: ''
},
bordered: {
type: Boolean,
default: false
},
center: {
type: Boolean,
default: true
}
}
}
</script>
<style lang="less" scoped>
.head-info {
position: relative;
text-align: left;
padding: 0 32px 0 0;
min-width: 125px;
&.center {
text-align: center;
padding: 0 32px;
}
span {
color: rgba(0, 0, 0, 0.45);
display: inline-block;
font-size: 14px;
line-height: 22px;
margin-bottom: 4px;
}
p {
color: rgba(0, 0, 0, 0.85);
font-size: 24px;
line-height: 32px;
margin: 0;
}
em {
background-color: #e8e8e8;
position: absolute;
height: 56px;
width: 1px;
top: 0;
right: 0;
}
}
</style>

View File

@ -1,93 +0,0 @@
<template>
<a-popover
:arrowPointAtCenter="true"
:autoAdjustOverflow="true"
:overlayStyle="{ width: '400px', top: '50px' }"
overlayClassName="header-comment-popover"
placement="bottomRight"
title="待审核评论"
trigger="click"
>
<template #content>
<div class="custom-tab-wrapper">
<a-tabs v-model="activeKey" :animated="{ inkBar: true, tabPane: false }" @change="handleListAuditingComments">
<a-tab-pane v-for="target in targets" :key="target.key" :tab="target.label">
<CommentListView :comments="comments[target.dataKey]" :loading="comments.loading" />
</a-tab-pane>
</a-tabs>
</div>
</template>
<span class="inline-block transition-all">
<a-badge v-if="comments.post.length || comments.sheet.length || comments.journal.length" dot>
<a-icon type="bell" />
</a-badge>
<a-badge v-else>
<a-icon type="bell" />
</a-badge>
</span>
</a-popover>
</template>
<script>
import apiClient from '@/utils/api-client'
const targets = [
{
key: 'posts',
dataKey: 'post',
label: '文章'
},
{
key: 'sheets',
dataKey: 'sheet',
label: '页面'
},
{
key: 'journals',
dataKey: 'journal',
label: '日志'
}
]
export default {
name: 'HeaderComment',
data() {
return {
targets: targets,
activeKey: 'posts',
comments: {
post: [],
sheet: [],
journal: [],
loading: false
}
}
},
created() {
this.handleListAuditingComments()
},
methods: {
async handleListAuditingComments() {
try {
this.comments.loading = true
const params = { status: 'AUDITING', size: 20 }
const responses = await Promise.all(
targets.map(target => {
return apiClient.comment.list(target.key, params)
})
)
this.comments.post = responses[0].data.content
this.comments.sheet = responses[1].data.content
this.comments.journal = responses[2].data.content
} catch (e) {
this.$log.error('Failed to get auditing comments', e)
} finally {
this.comments.loading = false
}
}
}
}
</script>

View File

@ -1,7 +0,0 @@
<template>
<div></div>
</template>
<script>
export default {}
</script>
<style lang="less" scoped></style>

View File

@ -1,54 +0,0 @@
<template>
<div class="logo">
<img
alt="Halo Logo"
class="select-none cursor-pointer hover:brightness-125 transition-all"
src="/images/logo.svg"
@click="onLogoClick()"
/>
</div>
</template>
<script>
import { mapActions, mapGetters } from 'vuex'
import apiClient from '@/utils/api-client'
export default {
name: 'Logo',
data() {
return {
clickCount: 0
}
},
computed: {
...mapGetters(['options'])
},
methods: {
...mapActions(['refreshOptionsCache']),
async onLogoClick() {
this.clickCount++
if (this.clickCount === 10) {
try {
await apiClient.option.saveMapView({ developer_mode: true })
await this.refreshOptionsCache()
this.$message.success(`开发者选项已启用!`)
this.clickCount = 0
this.$router.push({ name: 'ToolList' }).catch(() => {})
} catch (e) {
this.$log.error(e)
}
return
}
if (this.clickCount >= 5) {
if (this.options.developer_mode) {
this.$message.info(`当前已启用开发者选项!`)
this.clickCount = 0
} else {
this.$message.info(`再点击 ${10 - this.clickCount} 次即可启用开发者选项!`)
}
}
}
}
}
</script>

View File

@ -1,79 +0,0 @@
<template>
<div class="user-wrapper">
<a :href="options.blog_url" target="_blank">
<a-tooltip placement="bottom" title="点击跳转到首页">
<span class="action">
<a-icon type="link" />
</span>
</a-tooltip>
</a>
<a href="javascript:void(0)" @click="handleShowLayoutSetting">
<a-tooltip placement="bottom" title="后台布局设置">
<span class="action">
<a-icon type="setting" />
</span>
</a-tooltip>
</a>
<header-comment class="action" />
<a-dropdown>
<span v-if="user" class="action ant-dropdown-link user-dropdown-menu">
<a-avatar :src="user.avatar || '//cn.gravatar.com/avatar/?s=256&d=mm'" class="avatar" size="small" />
</span>
<a-menu slot="overlay" class="user-dropdown-menu-wrapper">
<a-menu-item key="0">
<router-link :to="{ name: 'Profile' }">
<a-icon type="user" />
<span>个人资料</span>
</router-link>
</a-menu-item>
<a-menu-divider />
<a-menu-item key="1">
<a href="javascript:void(0);" @click="handleLogout">
<a-icon type="logout" />
<span>退出登录</span>
</a>
</a-menu-item>
</a-menu>
</a-dropdown>
</div>
</template>
<script>
import HeaderComment from './HeaderComment'
import { mapActions, mapGetters } from 'vuex'
export default {
name: 'UserMenu',
components: {
HeaderComment
},
computed: {
...mapGetters(['user', 'options'])
},
methods: {
...mapActions(['logout', 'ToggleLayoutSetting']),
handleLogout() {
const _this = this
this.$confirm({
title: '提示',
content: '确定要注销登录吗 ?',
onOk: async () => {
try {
await _this.logout()
window.location.reload()
} catch (e) {
_this.$message.error({
title: '错误',
description: e.message
})
}
}
})
},
handleShowLayoutSetting() {
this.ToggleLayoutSetting(true)
}
}
}
</script>

View File

@ -1,150 +0,0 @@
<template>
<div>
<file-pond
ref="pond"
:accepted-file-types="accepts"
:allow-multiple="multiple"
:allowImagePreview="allowImagePreview"
:allowRevert="false"
:files="fileList"
:label-idle="label"
:maxFiles="maxFiles"
:maxParallelUploads="maxParallelUploads"
:name="name"
:server="server"
fileValidateTypeLabelExpectedTypes="请选择 {lastType} 格式的文件"
labelFileProcessing="上传中"
labelFileProcessingAborted="取消上传"
labelFileProcessingComplete="上传完成"
labelFileProcessingError="上传错误"
labelFileTypeNotAllowed="不支持当前文件格式"
labelTapToCancel="点击取消"
labelTapToRetry="点击重试"
@init="handleFilePondInit"
>
</file-pond>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import { Axios } from '@halo-dev/admin-api'
import vueFilePond from 'vue-filepond'
import 'filepond/dist/filepond.min.css'
// Plugins
import FilePondPluginImagePreview from 'filepond-plugin-image-preview'
import 'filepond-plugin-image-preview/dist/filepond-plugin-image-preview.min.css'
import FilePondPluginFileValidateType from 'filepond-plugin-file-validate-type'
// Create component and register plugins
const FilePond = vueFilePond(FilePondPluginImagePreview, FilePondPluginFileValidateType)
export default {
name: 'FilePondUpload',
components: {
FilePond
},
props: {
name: {
type: String,
required: false,
default: 'file'
},
field: {
type: String,
required: false,
default: ''
},
multiple: {
type: Boolean,
required: false,
default: true
},
accepts: {
type: Array,
required: false,
default: () => {
return null
}
},
label: {
type: String,
required: false,
default: '点击选择文件或将文件拖拽到此处'
},
uploadHandler: {
type: Function,
required: true
}
},
computed: {
...mapGetters(['options']),
maxParallelUploads() {
if (this.options) {
return this.options.attachment_upload_max_parallel_uploads
}
return 1
},
allowImagePreview() {
if (this.options) {
return this.options.attachment_upload_image_preview_enable
}
return false
},
maxFiles() {
if (this.options) {
return this.options.attachment_upload_max_files
}
return 1
}
},
data: function () {
return {
server: {
process: (fieldName, file, metadata, load, error, progress, abort) => {
const CancelToken = Axios.CancelToken
const source = CancelToken.source()
this.uploadHandler(
file,
{
onUploadProgress: progressEvent => {
if (progressEvent.total > 0) {
progress(progressEvent.lengthComputable, progressEvent.loaded, progressEvent.total)
}
},
cancelToken: source.token
},
this.field
)
.then(response => {
load(response)
this.$log.debug('Uploaded successfully', response)
this.$emit('success', response, file)
})
.catch(failure => {
this.$log.debug('Failed to upload file', failure)
this.$emit('failure', failure, file)
error()
})
return {
abort: () => {
abort()
this.$log.debug('Upload operation aborted by the user')
source.cancel('Upload operation canceled by the user.')
}
}
}
},
fileList: []
}
},
methods: {
handleFilePondInit() {
this.$log.debug('FilePond has initialized')
},
handleClearFileList() {
this.$refs.pond.removeFiles()
}
}
}
</script>

View File

@ -1,33 +0,0 @@
import Vue from 'vue'
import Ellipsis from '@/components/Ellipsis'
import FooterToolbar from '@/components/FooterToolbar'
import FilePondUpload from '@/components/Upload/FilePondUpload'
import AttachmentUploadModal from './Attachment/AttachmentUploadModal'
import AttachmentSelectModal from './Attachment/AttachmentSelectModal'
import AttachmentDetailModal from './Attachment/AttachmentDetailModal'
import ReactiveButton from './Button/ReactiveButton'
import PostTag from './Post/PostTag'
import AttachmentInput from './Input/AttachmentInput'
import CommentListView from './Comment/CommentListView'
const _components = {
Ellipsis,
FooterToolbar,
FilePondUpload,
AttachmentUploadModal,
AttachmentSelectModal,
AttachmentDetailModal,
ReactiveButton,
PostTag,
AttachmentInput,
CommentListView
}
const components = {}
Object.keys(_components).forEach(key => {
components[key] = Vue.component(key, _components[key])
})
export default components

View File

@ -1,14 +0,0 @@
export default {
primaryColor: '#1890FF',
navTheme: 'dark',
layout: 'sidemenu',
contentWidth: 'Fixed',
fixedHeader: false,
fixedSidebar: true,
autoHideHeader: false,
storageOptions: {
namespace: 'halo__',
name: 'ls',
storage: 'local'
}
}

View File

@ -1,256 +0,0 @@
// eslint-disable-next-line
import { BasicLayout, BlankLayout, PageView } from '@/layouts'
export const asyncRouterMap = [
{
path: '/',
name: 'index',
component: BasicLayout,
meta: { title: '首页' },
redirect: '/dashboard',
children: [
// dashboard
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/Dashboard'),
meta: { title: '仪表盘', icon: 'dashboard', hiddenHeaderContent: false, keepAlive: false }
},
// posts
{
path: '/posts',
name: 'Posts',
component: BlankLayout,
redirect: '/posts/list',
meta: { title: '文章', icon: 'form' },
children: [
{
path: '/posts/list',
name: 'PostList',
component: () => import('@/views/post/PostList'),
meta: { title: '所有文章', hiddenHeaderContent: false }
},
{
path: '/posts/write',
name: 'PostWrite',
component: () => import('@/views/post/PostEdit'),
meta: { title: '写文章', hiddenHeaderContent: false, keepAlive: false }
},
{
path: '/posts/edit',
name: 'PostEdit',
hidden: true,
component: () => import('@/views/post/PostEdit'),
meta: { title: '编辑文章', hiddenHeaderContent: false, keepAlive: false }
},
{
path: '/categories',
name: 'CategoryList',
component: () => import('@/views/post/CategoryList'),
meta: { title: '分类目录', hiddenHeaderContent: false }
},
{
path: '/tags',
name: 'TagList',
component: () => import('@/views/post/TagList'),
meta: { title: '标签', hiddenHeaderContent: false }
}
]
},
// sheets
{
path: '/sheets',
name: 'Sheets',
component: BlankLayout,
redirect: '/sheets/list',
meta: { title: '页面', icon: 'read' },
children: [
{
path: '/sheets/list',
name: 'SheetList',
component: () => import('@/views/sheet/SheetList'),
meta: { title: '所有页面', hiddenHeaderContent: false }
},
{
path: '/sheets/write',
name: 'SheetWrite',
component: () => import('@/views/sheet/SheetEdit'),
meta: { title: '新建页面', hiddenHeaderContent: false, keepAlive: false }
},
{
path: '/sheets/edit',
name: 'SheetEdit',
hidden: true,
component: () => import('@/views/sheet/SheetEdit'),
meta: { title: '编辑页面', hiddenHeaderContent: false, keepAlive: false }
},
{
path: '/sheets/links',
name: 'LinkList',
hidden: true,
component: () => import('@/views/sheet/independent/LinkList'),
meta: { title: '友情链接', hiddenHeaderContent: false }
},
{
path: '/sheets/photos',
name: 'PhotoList',
hidden: true,
component: () => import('@/views/sheet/independent/PhotoList'),
meta: { title: '图库', hiddenHeaderContent: false }
},
{
path: '/sheets/journals',
name: 'JournalList',
hidden: true,
component: () => import('@/views/sheet/independent/JournalList'),
meta: { title: '日志', hiddenHeaderContent: false }
}
]
},
// attachments
{
path: '/attachments',
name: 'Attachments',
component: () => import('@/views/attachment/AttachmentList'),
meta: { title: '附件', icon: 'picture', hiddenHeaderContent: false }
},
// comments
{
path: '/comments',
name: 'Comments',
component: () => import('@/views/comment/CommentList'),
meta: { title: '评论', icon: 'message', hiddenHeaderContent: false }
},
// interface
{
path: '/interface',
name: 'Interface',
component: BlankLayout,
redirect: '/interface/themes',
meta: { title: '外观', icon: 'skin' },
children: [
{
path: '/interface/themes',
name: 'ThemeList',
component: () => import('@/views/interface/ThemeList'),
meta: { title: '主题', hiddenHeaderContent: false }
},
{
path: '/interface/themes/setting',
name: 'ThemeSetting',
component: () => import('@/views/interface/ThemeSetting'),
meta: { title: '主题设置', hiddenHeaderContent: false }
},
{
path: '/interface/themes/edit',
name: 'ThemeEdit',
component: () => import('@/views/interface/ThemeEdit'),
meta: { title: '主题编辑', hiddenHeaderContent: false }
},
{
path: '/interface/menus',
name: 'MenuList',
component: () => import('@/views/interface/MenuList'),
meta: { title: '菜单设置', hiddenHeaderContent: false }
}
]
},
// user
{
path: '/user',
name: 'User',
component: PageView,
redirect: '/user/profile',
meta: { title: '用户', icon: 'user' },
children: [
{
path: '/user/profile',
name: 'Profile',
component: () => import('@/views/user/Profile'),
meta: { title: '个人资料', hiddenHeaderContent: false }
}
]
},
// system
{
path: '/system',
name: 'System',
component: BlankLayout,
redirect: '/system/options',
meta: { title: '系统', icon: 'setting' },
children: [
{
path: '/system/developer/options',
name: 'DeveloperOptions',
hidden: true,
component: () => import('@/views/system/developer/DeveloperOptions'),
meta: { title: '开发者选项', hiddenHeaderContent: false }
},
{
path: '/system/options',
name: 'SystemOptions',
component: () => import('@/views/system/SystemOptions'),
meta: { title: '博客设置', hiddenHeaderContent: false }
},
{
path: '/system/tools',
name: 'ToolList',
component: () => import('@/views/system/ToolList'),
meta: { title: '小工具', hiddenHeaderContent: false }
},
{
path: '/system/actionlogs',
name: 'SystemActionLogs',
hidden: true,
component: () => import('@/views/system/ActionLogs'),
meta: { title: '操作日志', hiddenHeaderContent: false }
},
{
path: '/system/about',
name: 'About',
component: () => import('@/views/system/About'),
meta: { title: '关于', hiddenHeaderContent: false }
}
]
}
]
},
{
path: '*',
redirect: '/404',
hidden: true
}
]
export const constantRouterMap = [
{
path: '/login',
name: 'Login',
meta: { title: '登录' },
component: () => import('@/views/user/Login')
},
{
path: '/install',
name: 'Install',
meta: { title: '安装向导' },
component: () => import('@/views/system/Installation')
},
{
path: '/password/reset',
name: 'ResetPassword',
meta: { title: '重置密码' },
component: () => import('@/views/user/ResetPassword')
},
{
path: '/404',
name: 'NotFound',
component: () => import('@/views/exception/404')
}
]

View File

@ -1,31 +0,0 @@
import Vue from 'vue'
import store from '@/store/'
import {
ACCESS_TOKEN,
DEFAULT_COLOR,
DEFAULT_CONTENT_WIDTH_TYPE,
DEFAULT_FIXED_HEADER,
DEFAULT_FIXED_HEADER_HIDDEN,
DEFAULT_FIXED_SIDEBAR,
DEFAULT_LAYOUT_MODE,
DEFAULT_THEME,
OPTIONS,
SIDEBAR_TYPE,
USER
} from '@/store/mutation-types'
import config from '@/config/defaultSettings'
export default function Initializer() {
store.commit('SET_SIDEBAR_TYPE', Vue.ls.get(SIDEBAR_TYPE, true))
store.commit('TOGGLE_THEME', Vue.ls.get(DEFAULT_THEME, config.navTheme))
store.commit('TOGGLE_LAYOUT_MODE', Vue.ls.get(DEFAULT_LAYOUT_MODE, config.layout))
store.commit('TOGGLE_FIXED_HEADER', Vue.ls.get(DEFAULT_FIXED_HEADER, config.fixedHeader))
store.commit('TOGGLE_FIXED_SIDEBAR', Vue.ls.get(DEFAULT_FIXED_SIDEBAR, config.fixedSidebar))
store.commit('TOGGLE_CONTENT_WIDTH', Vue.ls.get(DEFAULT_CONTENT_WIDTH_TYPE, config.contentWidth))
store.commit('TOGGLE_FIXED_HEADER_HIDDEN', Vue.ls.get(DEFAULT_FIXED_HEADER_HIDDEN, config.autoHideHeader))
store.commit('TOGGLE_COLOR', Vue.ls.get(DEFAULT_COLOR, config.primaryColor))
store.commit('SET_TOKEN', Vue.ls.get(ACCESS_TOKEN))
store.commit('SET_USER', Vue.ls.get(USER))
store.commit('SET_OPTIONS', Vue.ls.get(OPTIONS))
// last step
}

View File

@ -1,189 +0,0 @@
export const markdownEditorToolbars = {
bold: true,
italic: true,
header: true,
underline: true,
strikethrough: true,
superscript: true,
subscript: true,
quote: true,
ol: true,
ul: true,
link: true,
imagelink: true,
code: true,
table: true,
undo: true,
redo: true,
save: true,
navigation: true,
subfield: true,
fullscreen: true,
readmodel: true,
htmlcode: true,
preview: true
}
export const actionLogTypes = {
BLOG_INITIALIZED: {
value: 0,
text: '博客初始化'
},
POST_PUBLISHED: {
value: 5,
text: '文章发布'
},
POST_EDITED: {
value: 15,
text: '文章修改'
},
POST_DELETED: {
value: 20,
text: '文章删除'
},
LOGGED_IN: {
value: 25,
text: '用户登录'
},
LOGGED_OUT: {
value: 30,
text: '注销登录'
},
LOGIN_FAILED: {
value: 35,
text: '登录失败'
},
PASSWORD_UPDATED: {
value: 40,
text: '修改密码'
},
PROFILE_UPDATED: {
value: 45,
text: '资料修改'
},
SHEET_PUBLISHED: {
value: 50,
text: '页面发布'
},
SHEET_EDITED: {
value: 55,
text: '页面修改'
},
SHEET_DELETED: {
value: 60,
text: '页面删除'
},
MFA_UPDATED: {
value: 65,
text: '两步验证'
},
LOGGED_PRE_CHECK: {
value: 70,
text: '登录验证'
}
}
export const attachmentTypes = {
LOCAL: {
type: 'LOCAL',
text: '本地'
},
SMMS: {
type: 'SMMS',
text: 'SM.MS'
},
UPOSS: {
type: 'UPOSS',
text: '又拍云'
},
QINIUOSS: {
type: 'QINIUOSS',
text: '七牛云'
},
ALIOSS: {
type: 'ALIOSS',
text: '阿里云'
},
BAIDUBOS: {
type: 'BAIDUBOS',
text: '百度云'
},
TENCENTCOS: {
type: 'TENCENTCOS',
text: '腾讯云'
},
HUAWEIOBS: {
type: 'HUAWEIOBS',
text: '华为云'
},
MINIO: {
type: 'MINIO',
text: 'MinIO'
}
}
export const postStatuses = {
PUBLISHED: {
value: 'PUBLISHED',
color: 'green',
status: 'success',
text: '已发布'
},
DRAFT: {
value: 'DRAFT',
color: 'yellow',
status: 'warning',
text: '草稿'
},
RECYCLE: {
value: 'RECYCLE',
color: 'red',
status: 'error',
text: '回收站'
},
INTIMATE: {
value: 'INTIMATE',
color: 'blue',
status: 'success',
text: '私密'
}
}
export const sheetStatuses = {
PUBLISHED: {
color: 'green',
status: 'success',
text: '已发布'
},
DRAFT: {
color: 'yellow',
status: 'warning',
text: '草稿'
},
RECYCLE: {
color: 'red',
status: 'error',
text: '回收站'
}
}
export const commentStatuses = {
PUBLISHED: {
value: 'PUBLISHED',
color: 'green',
status: 'success',
text: '已发布'
},
AUDITING: {
value: 'AUDITING',
color: 'yellow',
status: 'warning',
text: '待审核'
},
RECYCLE: {
value: 'RECYCLE',
color: 'red',
status: 'error',
text: '回收站'
}
}

View File

@ -1,123 +0,0 @@
import Vue from 'vue'
import {
Affix,
Alert,
Anchor,
AutoComplete,
Avatar,
Badge,
Breadcrumb,
Button,
Card,
Checkbox,
Col,
Collapse,
Comment,
ConfigProvider,
DatePicker,
Divider,
Drawer,
Dropdown,
Empty,
Form,
FormModel,
Icon,
Input,
InputNumber,
Layout,
List,
LocaleProvider,
Menu,
message,
Modal,
notification,
PageHeader,
Pagination,
Popconfirm,
Popover,
Progress,
Radio,
Result,
Row,
Select,
Skeleton,
Space,
Spin,
Steps,
Switch,
Table,
Tabs,
Tag,
Timeline,
TimePicker,
Tooltip,
Tree,
TreeSelect,
Descriptions
} from 'ant-design-vue'
Vue.use(Affix)
Vue.use(Anchor)
Vue.use(AutoComplete)
Vue.use(Alert)
Vue.use(Avatar)
Vue.use(Badge)
Vue.use(Breadcrumb)
Vue.use(Button)
Vue.use(Card)
Vue.use(Collapse)
Vue.use(Checkbox)
Vue.use(Col)
Vue.use(DatePicker)
Vue.use(Divider)
Vue.use(Drawer)
Vue.use(Dropdown)
Vue.use(Form)
Vue.use(FormModel)
Vue.use(Icon)
Vue.use(Input)
Vue.use(InputNumber)
Vue.use(Layout)
Vue.use(List)
Vue.use(LocaleProvider)
Vue.use(Menu)
Vue.use(Modal)
Vue.use(PageHeader)
Vue.use(Pagination)
Vue.use(Popconfirm)
Vue.use(Popover)
Vue.use(Progress)
Vue.use(Radio)
Vue.use(Row)
Vue.use(Select)
Vue.use(Spin)
Vue.use(Switch)
Vue.use(Table)
Vue.use(Tree)
Vue.use(TreeSelect)
Vue.use(Tabs)
Vue.use(Tag)
Vue.use(TimePicker)
Vue.use(Tooltip)
Vue.use(Skeleton)
Vue.use(Comment)
Vue.use(ConfigProvider)
Vue.use(Timeline)
Vue.use(Steps)
Vue.use(Empty)
Vue.use(Result)
Vue.use(Space)
Vue.use(Descriptions)
// message config
message.config({
maxCount: 1
})
Vue.prototype.$message = message
Vue.prototype.$notification = notification
Vue.prototype.$info = Modal.info
Vue.prototype.$success = Modal.success
Vue.prototype.$error = Modal.error
Vue.prototype.$warning = Modal.warning
Vue.prototype.$confirm = Modal.confirm

View File

@ -1,15 +0,0 @@
import Vue from 'vue'
import VueStorage from 'vue-ls'
import config from '@/config/defaultSettings'
// base library
import '@/core/lazy_lib/components_use'
import 'ant-design-vue/dist/antd.less'
import bootstrap from './bootstrap'
import VueClipboard from 'vue-clipboard2'
Vue.use(VueStorage, config.storageOptions)
Vue.use(VueClipboard)
bootstrap()

View File

@ -1,14 +0,0 @@
import Vue from 'vue'
import VueStorage from 'vue-ls'
import config from '@/config/defaultSettings'
// base library
import Antd from 'ant-design-vue'
import 'ant-design-vue/dist/antd.less'
import VueClipboard from 'vue-clipboard2'
Vue.use(Antd)
Vue.use(VueStorage, config.storageOptions)
Vue.use(VueClipboard)

View File

@ -1,52 +0,0 @@
import Vue from 'vue'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
import { timeAgo } from '@/utils/datetime'
dayjs.locale('zh-cn')
import { marked } from 'marked'
Vue.filter('moment', function (dataStr, pattern = 'YYYY-MM-DD HH:mm') {
return dayjs(dataStr).format(pattern)
})
Vue.filter('moment_post_date', function (dataStr, pattern = '/YYYY/MM/') {
return dayjs(dataStr).format(pattern)
})
Vue.filter('moment_post_year', function (dataStr, pattern = '/YYYY/') {
return dayjs(dataStr).format(pattern)
})
Vue.filter('moment_post_day', function (dataStr, pattern = '/YYYY/MM/DD/') {
return dayjs(dataStr).format(pattern)
})
Vue.filter('timeAgo', timeAgo)
Vue.filter('fileSizeFormat', function (value) {
if (!value) {
return '0 Bytes'
}
const unitArr = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
const srcsize = parseFloat(value)
let index = Math.floor(Math.log(srcsize) / Math.log(1024))
let size = srcsize / Math.pow(1024, index)
size = size.toFixed(2)
return size + ' ' + unitArr[index]
})
Vue.filter('dayTime', function (value) {
const days = Math.floor(value / 86400)
const hours = Math.floor((value % 86400) / 3600)
const minutes = Math.floor(((value % 86400) % 3600) / 60)
const seconds = Math.floor(((value % 86400) % 3600) % 60)
return days + 'd ' + hours + 'h ' + minutes + 'm ' + seconds + 's'
})
Vue.filter('markdownRender', function (value) {
return marked.parse(value)
})

View File

@ -1,174 +0,0 @@
<template>
<a-layout :class="['layout', device]">
<!-- SideMenu -->
<a-drawer
v-if="isMobile()"
:closable="false"
:visible="collapsed"
:wrapClassName="`drawer-sider ${navTheme}`"
placement="left"
@close="drawerClose"
>
<side-menu
:collapsed="false"
:collapsible="true"
:menus="menus"
:theme="navTheme"
mode="inline"
@menuSelect="menuSelect"
></side-menu>
</a-drawer>
<side-menu
v-else-if="isSideMenu()"
:collapsed="collapsed"
:collapsible="true"
:menus="menus"
:theme="navTheme"
mode="inline"
></side-menu>
<a-layout
:class="[layoutMode, `content-width-${contentWidth}`]"
:style="{ paddingLeft: contentPaddingLeft, minHeight: '100vh' }"
>
<!-- layout header -->
<global-header
:collapsed="collapsed"
:device="device"
:menus="menus"
:mode="layoutMode"
:theme="navTheme"
@toggle="toggle"
/>
<!-- layout content -->
<a-layout-content :style="{ height: '100%', margin: '24px 24px 0', paddingTop: fixedHeader ? '64px' : '0' }">
<transition name="page-transition">
<route-view />
</transition>
</a-layout-content>
<!-- layout footer -->
<a-layout-footer>
<global-footer />
</a-layout-footer>
</a-layout>
<setting-drawer ref="drawer"></setting-drawer>
<LoginModal @success="onLoginSucceed" />
</a-layout>
</template>
<script>
import { triggerWindowResizeEvent } from '@/utils/util'
import { mapActions } from 'vuex'
import { mixin, mixinDevice } from '@/mixins/mixin'
import config from '@/config/defaultSettings'
import { asyncRouterMap } from '@/config/router.config.js'
import RouteView from './RouteView'
import SideMenu from '@/components/Menu/SideMenu'
import GlobalHeader from '@/components/GlobalHeader'
import GlobalFooter from '@/components/GlobalFooter'
import SettingDrawer from '@/components/SettingDrawer/SettingDrawer'
import LoginModal from '@/components/Login/LoginModal'
export default {
name: 'BasicLayout',
mixins: [mixin, mixinDevice],
components: {
RouteView,
SideMenu,
GlobalHeader,
GlobalFooter,
SettingDrawer,
LoginModal
},
data() {
return {
production: config.production,
collapsed: false,
menus: []
}
},
computed: {
contentPaddingLeft() {
if (!this.fixedSidebar || this.isMobile()) {
return '0'
}
if (this.sidebarOpened) {
return '256px'
}
return '80px'
}
},
watch: {
sidebarOpened(val) {
this.collapsed = !val
}
},
created() {
this.menus = asyncRouterMap.find(item => item.path === '/').children
this.collapsed = !this.sidebarOpened
},
mounted() {
const userAgent = navigator.userAgent
if (userAgent.indexOf('Edge') > -1) {
this.$nextTick(() => {
this.collapsed = !this.collapsed
setTimeout(() => {
this.collapsed = !this.collapsed
}, 16)
})
}
},
methods: {
...mapActions(['setSidebar', 'ToggleLoginModal']),
toggle() {
this.collapsed = !this.collapsed
this.setSidebar(!this.collapsed)
triggerWindowResizeEvent()
},
paddingCalc() {
let left = ''
if (this.sidebarOpened) {
left = this.isDesktop() ? '256px' : '80px'
} else {
left = (this.isMobile() && '0') || (this.fixedSidebar && '80px') || '0'
}
return left
},
menuSelect() {
if (!this.isDesktop()) {
this.collapsed = false
}
},
drawerClose() {
this.collapsed = false
},
onLoginSucceed() {
this.ToggleLoginModal(false)
}
}
}
</script>
<style lang="less">
@import url('../styles/global.less');
.page-transition-enter {
opacity: 0;
}
.page-transition-leave-active {
opacity: 0;
}
.page-transition-enter .page-transition-container,
.page-transition-leave-active .page-transition-container {
-webkit-transform: scale(1.1);
transform: scale(1.1);
}
</style>

View File

@ -1,11 +0,0 @@
<template>
<div>
<router-view />
</div>
</template>
<script>
export default {
name: 'BlankLayout'
}
</script>

View File

@ -1,126 +0,0 @@
<template>
<div :style="!$route.meta.hiddenHeaderContent ? 'margin: -24px -24px 0px;' : null">
<a-affix v-if="affix">
<div v-if="!$route.meta.hiddenHeaderContent" class="page-header">
<div class="page-header-index-wide">
<a-page-header :breadcrumb="{ props: { routes: breadList } }" :sub-title="subTitle" :title="title">
<slot slot="extra" name="extra"></slot>
<slot slot="footer" name="footer"></slot>
<slot name="content" />
</a-page-header>
</div>
</div>
</a-affix>
<div v-if="!$route.meta.hiddenHeaderContent && !affix" class="page-header">
<div class="page-header-index-wide">
<a-page-header :breadcrumb="{ props: { routes: breadList } }" :sub-title="subTitle" :title="title">
<slot slot="extra" name="extra"></slot>
<slot slot="footer" name="footer"></slot>
<slot name="content" />
</a-page-header>
</div>
</div>
<div class="content">
<div class="page-header-index-wide">
<slot>
<router-view ref="content" />
</slot>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'PageView',
props: {
title: {
type: String,
default: null
},
subTitle: {
type: String,
default: null
},
affix: {
type: Boolean,
default: false
}
},
data() {
return {
breadList: []
}
},
created() {
this.getBreadcrumb()
},
watch: {
$route() {
this.getBreadcrumb()
}
},
methods: {
getBreadcrumb() {
this.breadList = []
this.$route.matched.forEach(item => {
item.breadcrumbName = item.meta.title
this.breadList.push(item)
})
}
}
}
</script>
<style lang="less" scoped>
.page-header {
background: #fff;
padding: 0 24px 0;
border-bottom: 1px solid #e8e8e8;
.ant-page-header {
padding: 16px 0px;
}
}
.mobile .page-header,
.tablet .page-header {
padding: 0 !important;
.ant-page-header {
padding: 16px;
}
}
.content {
margin: 24px 24px 0;
.link {
margin-top: 16px;
&:not(:empty) {
margin-bottom: 16px;
}
a {
margin-right: 32px;
height: 24px;
line-height: 24px;
display: inline-block;
i {
font-size: 24px;
margin-right: 8px;
vertical-align: middle;
}
span {
height: 24px;
line-height: 24px;
display: inline-block;
vertical-align: middle;
}
}
}
}
</style>

View File

@ -1,31 +0,0 @@
<script>
export default {
name: 'RouteView',
props: {
keepAlive: {
type: Boolean,
default: true
}
},
data() {
return {}
},
render() {
const {
$route: { meta }
} = this
const inKeep = (
<keep-alive>
<router-view />
</keep-alive>
)
const notKeep = <router-view />
//
// return meta.keepAlive ? inKeep : notKeep
if (meta.keepAlive === false) {
return notKeep
}
return this.keepAlive || meta.keepAlive ? inKeep : notKeep
}
}
</script>

View File

@ -1,6 +0,0 @@
import BlankLayout from './BlankLayout'
import BasicLayout from './BasicLayout'
import RouteView from './RouteView'
import PageView from './PageView'
export { BasicLayout, BlankLayout, RouteView, PageView }

View File

@ -1,15 +0,0 @@
import Vue from 'vue'
import VueLogger from 'vuejs-logger'
const isProduction = process.env.NODE_ENV === 'production'
const options = {
isEnabled: true,
logLevel: isProduction ? 'error' : 'debug',
stringifyArguments: false,
showLogLevel: true,
showMethodName: true,
separator: '|',
showConsoleColors: true
}
Vue.use(VueLogger, options)

View File

@ -1,25 +0,0 @@
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import Contextmenu from 'vue-contextmenujs'
import store from './store/'
import './logger'
import '@/styles/tailwind.css'
import './core/lazy_use'
import '@/router/guard/'
import '@/filters/filter' // global filter
import './components'
import pkg from '../package.json'
Vue.config.productionTip = false
Vue.prototype.VERSION = pkg.version
Vue.use(router)
Vue.use(Contextmenu)
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')

View File

@ -1,91 +0,0 @@
// import Vue from 'vue'
import { DEVICE_TYPE } from '@/utils/device'
import { mapState } from 'vuex'
// const mixinsComputed = Vue.config.optionMergeStrategies.computed
// const mixinsMethods = Vue.config.optionMergeStrategies.methods
const mixin = {
computed: {
...mapState({
layoutMode: state => state.app.layout,
navTheme: state => state.app.theme,
primaryColor: state => state.app.color,
fixedHeader: state => state.app.fixedHeader,
fixedSidebar: state => state.app.fixedSidebar,
contentWidth: state => state.app.contentWidth,
autoHideHeader: state => state.app.autoHideHeader,
sidebarOpened: state => state.app.sidebar
})
},
methods: {
isTopMenu() {
return this.layoutMode === 'topmenu'
},
isSideMenu() {
return !this.isTopMenu()
}
}
}
const mixinDevice = {
computed: {
...mapState({
device: state => state.app.device
})
},
methods: {
isMobile() {
return this.device === DEVICE_TYPE.MOBILE
},
isDesktop() {
return this.device === DEVICE_TYPE.DESKTOP
},
isTablet() {
return this.device === DEVICE_TYPE.TABLET
}
}
}
const mixinPostEdit = {
data() {
return {
viewMetas: {
pageHeaderHeight: 0,
pageFooterHeight: 0
}
}
},
computed: {
editorHeight() {
const toolbarHeight = 64
const contentMarginTop = 24
const titleInputHeight = 40
return `calc(100vh - ${
toolbarHeight +
contentMarginTop +
titleInputHeight +
this.viewMetas.pageHeaderHeight +
this.viewMetas.pageFooterHeight +
10
}px - 1rem)`
}
},
mounted() {
this.handleGetViewMetas()
},
methods: {
handleGetViewMetas() {
const pageHeaderView = document.getElementsByClassName('page-header')
if (pageHeaderView && pageHeaderView.length > 0) {
this.viewMetas.pageHeaderHeight = pageHeaderView[0].clientHeight
}
const pageFooterView = document.getElementsByClassName('ant-layout-footer')
if (pageFooterView && pageFooterView.length > 0) {
this.viewMetas.pageFooterHeight = pageFooterView[0].clientHeight
}
}
}
}
export { mixin, mixinDevice, mixinPostEdit }

View File

@ -1 +0,0 @@
import './permissionGuard'

View File

@ -1,70 +0,0 @@
import router from '@/router'
import store from '@/store'
import NProgress from 'nprogress'
import { domTitle, setDocumentTitle } from '@/utils/domUtil'
import apiClient from '@/utils/api-client'
NProgress.configure({ showSpinner: false, speed: 500 })
const whiteList = ['Login', 'Install', 'NotFound', 'ResetPassword'] // no redirect whitelist
let progressTimer = null
router.beforeEach(async (to, from, next) => {
onProgressTimerDone()
progressTimer = setTimeout(() => {
NProgress.start()
}, 250)
to.meta && typeof to.meta.title !== 'undefined' && setDocumentTitle(`${to.meta.title} - ${domTitle}`)
if (store.getters.token) {
if (to.name === 'Install') {
next()
return
}
const response = await apiClient.isInstalled()
if (!response.data) {
next({
name: 'Install'
})
onProgressTimerDone()
return
}
if (to.name === 'Login') {
next({
name: 'Dashboard'
})
onProgressTimerDone()
return
}
if (!store.getters.options) {
store.dispatch('refreshOptionsCache').then()
}
next()
return
}
// Check whitelist
if (whiteList.includes(to.name)) {
next()
return
}
next({
name: 'Login',
query: {
redirect: to.fullPath
}
})
onProgressTimerDone()
})
router.afterEach(() => {
onProgressTimerDone()
})
function onProgressTimerDone() {
if (progressTimer && progressTimer !== 0) {
clearTimeout(progressTimer)
progressTimer = null
NProgress.done()
}
}

View File

@ -1,14 +0,0 @@
import Vue from 'vue'
import Router from 'vue-router'
import { asyncRouterMap, constantRouterMap } from '@/config/router.config'
Vue.use(Router)
export default new Router({
mode: 'hash',
base: process.env.BASE_URL,
scrollBehavior: () => ({
y: 0
}),
routes: constantRouterMap.concat(asyncRouterMap)
})

View File

@ -1,12 +0,0 @@
const getters = {
device: state => state.app.device,
theme: state => state.app.theme,
color: state => state.app.color,
layoutSetting: state => state.app.layoutSetting,
loginModal: state => state.app.loginModal,
token: state => state.user.token,
user: state => state.user.user,
options: state => state.option.options
}
export default getters

View File

@ -1,21 +0,0 @@
import Vue from 'vue'
import Vuex from 'vuex'
import app from './modules/app'
import user from './modules/user'
import option from './modules/option'
import getters from './getters'
Vue.use(Vuex)
export default new Vuex.Store({
modules: {
app,
user,
option
},
state: {},
mutations: {},
actions: {},
getters
})

View File

@ -1,110 +0,0 @@
import Vue from 'vue'
import {
DEFAULT_COLOR,
DEFAULT_CONTENT_WIDTH_TYPE,
DEFAULT_FIXED_HEADER,
DEFAULT_FIXED_HEADER_HIDDEN,
DEFAULT_FIXED_SIDEBAR,
DEFAULT_LAYOUT_MODE,
DEFAULT_THEME,
LAYOUT_SETTING,
SIDEBAR_TYPE
} from '@/store/mutation-types'
const app = {
state: {
sidebar: true,
device: 'desktop',
theme: '',
layout: '',
contentWidth: '',
fixedHeader: false,
fixedSidebar: false,
autoHideHeader: false,
color: null,
layoutSetting: false,
loginModal: false
},
mutations: {
SET_SIDEBAR_TYPE: (state, type) => {
state.sidebar = type
Vue.ls.set(SIDEBAR_TYPE, type)
},
CLOSE_SIDEBAR: state => {
Vue.ls.set(SIDEBAR_TYPE, true)
state.sidebar = false
},
TOGGLE_DEVICE: (state, device) => {
state.device = device
},
TOGGLE_THEME: (state, theme) => {
Vue.ls.set(DEFAULT_THEME, theme)
state.theme = theme
},
TOGGLE_LAYOUT_MODE: (state, layout) => {
Vue.ls.set(DEFAULT_LAYOUT_MODE, layout)
state.layout = layout
},
TOGGLE_FIXED_HEADER: (state, fixed) => {
Vue.ls.set(DEFAULT_FIXED_HEADER, fixed)
state.fixedHeader = fixed
},
TOGGLE_FIXED_SIDEBAR: (state, fixed) => {
Vue.ls.set(DEFAULT_FIXED_SIDEBAR, fixed)
state.fixedSidebar = fixed
},
TOGGLE_FIXED_HEADER_HIDDEN: (state, show) => {
Vue.ls.set(DEFAULT_FIXED_HEADER_HIDDEN, show)
state.autoHideHeader = show
},
TOGGLE_CONTENT_WIDTH: (state, type) => {
Vue.ls.set(DEFAULT_CONTENT_WIDTH_TYPE, type)
state.contentWidth = type
},
TOGGLE_COLOR: (state, color) => {
Vue.ls.set(DEFAULT_COLOR, color)
state.color = color
},
TOGGLE_LAYOUT_SETTING: (state, show) => {
Vue.ls.set(LAYOUT_SETTING, show)
state.layoutSetting = show
},
TOGGLE_LOGIN_MODAL: (state, show) => {
state.loginModal = show
}
},
actions: {
setSidebar({ commit }, type) {
commit('SET_SIDEBAR_TYPE', type)
},
ToggleTheme({ commit }, theme) {
commit('TOGGLE_THEME', theme)
},
ToggleLayoutMode({ commit }, mode) {
commit('TOGGLE_LAYOUT_MODE', mode)
},
ToggleFixedHeader({ commit }, fixedHeader) {
commit('TOGGLE_FIXED_HEADER', fixedHeader)
},
ToggleFixedSidebar({ commit }, fixedSidebar) {
commit('TOGGLE_FIXED_SIDEBAR', fixedSidebar)
},
ToggleFixedHeaderHidden({ commit }, show) {
commit('TOGGLE_FIXED_HEADER_HIDDEN', show)
},
ToggleContentWidth({ commit }, type) {
commit('TOGGLE_CONTENT_WIDTH', type)
},
ToggleColor({ commit }, color) {
commit('TOGGLE_COLOR', color)
},
ToggleLayoutSetting({ commit }, show) {
commit('TOGGLE_LAYOUT_SETTING', show)
},
ToggleLoginModal({ commit }, show) {
commit('TOGGLE_LOGIN_MODAL', show)
}
}
}
export default app

View File

@ -1,46 +0,0 @@
import Vue from 'vue'
import { OPTIONS } from '@/store/mutation-types'
import apiClient from '@/utils/api-client'
const keys = [
'blog_url',
'developer_mode',
'attachment_upload_image_preview_enable',
'attachment_upload_max_parallel_uploads',
'attachment_upload_max_files',
'sheet_prefix',
'post_permalink_type',
'sheet_permalink_type',
'archives_prefix',
'path_suffix',
'default_editor',
'default_menu_team'
]
const option = {
state: {
options: []
},
mutations: {
SET_OPTIONS: (state, options) => {
Vue.ls.set(OPTIONS, options)
state.options = options
}
},
actions: {
refreshOptionsCache({ commit }) {
return new Promise((resolve, reject) => {
apiClient.option
.listAsMapViewByKeys(keys)
.then(response => {
commit('SET_OPTIONS', response.data)
resolve(response)
})
.catch(error => {
reject(error)
})
})
}
}
}
export default option

View File

@ -1,106 +0,0 @@
import Vue from 'vue'
import { ACCESS_TOKEN, USER } from '@/store/mutation-types'
import apiClient from '@/utils/api-client'
const user = {
state: {
token: null,
user: {}
},
mutations: {
SET_TOKEN: (state, token) => {
Vue.ls.set(ACCESS_TOKEN, token, token ? token.expired_in * 1000 : null)
state.token = token
},
CLEAR_TOKEN: state => {
Vue.ls.remove(ACCESS_TOKEN)
state.token = null
},
SET_USER: (state, user) => {
Vue.ls.set(USER, user)
state.user = user
}
},
actions: {
installCleanToken({ commit }, installData) {
return new Promise((resolve, reject) => {
apiClient.installation
.install(installData)
.then(response => {
commit('CLEAR_TOKEN')
resolve(response)
})
.catch(error => {
reject(error)
})
})
},
refreshUserCache({ commit }) {
return new Promise((resolve, reject) => {
apiClient.user
.getProfile()
.then(response => {
commit('SET_USER', response.data)
resolve(response)
})
.catch(error => {
reject(error)
})
})
},
login({ commit }, { username, password, authcode }) {
return new Promise((resolve, reject) => {
apiClient
.login({ username, password, authcode })
.then(response => {
const token = response.data
Vue.$log.debug('Got token', token)
commit('SET_TOKEN', token)
resolve(response)
})
.catch(error => {
reject(error)
})
})
},
logout({ commit }) {
return new Promise(resolve => {
apiClient
.logout()
.then(() => {
commit('CLEAR_TOKEN')
commit('SET_USER', {})
resolve()
})
.catch(() => {
resolve()
})
})
},
refreshToken({ commit }, refreshToken) {
return new Promise((resolve, reject) => {
apiClient
.refreshToken(refreshToken)
.then(response => {
const token = response.data
Vue.$log.debug('Got token', token)
commit('SET_TOKEN', token)
resolve(response)
})
.catch(error => {
const data = error.data
Vue.$log.debug('Refresh error data', data)
if (data && data.status === 400 && data.data === refreshToken) {
// The refresh token expired
commit('CLEAR_TOKEN')
}
reject(error)
})
})
}
}
}
export default user

View File

@ -1,17 +0,0 @@
export const ACCESS_TOKEN = 'Access-Token'
export const SIDEBAR_TYPE = 'SIDEBAR_TYPE'
export const DEFAULT_THEME = 'DEFAULT_THEME'
export const DEFAULT_LAYOUT_MODE = 'DEFAULT_LAYOUT_MODE'
export const DEFAULT_COLOR = 'DEFAULT_COLOR'
export const DEFAULT_FIXED_HEADER = 'DEFAULT_FIXED_HEADER'
export const DEFAULT_FIXED_SIDEBAR = 'DEFAULT_FIXED_SIDEBAR'
export const DEFAULT_FIXED_HEADER_HIDDEN = 'DEFAULT_FIXED_HEADER_HIDDEN'
export const DEFAULT_CONTENT_WIDTH_TYPE = 'DEFAULT_CONTENT_WIDTH_TYPE'
export const USER = 'USER'
export const OPTIONS = 'OPTIONS'
export const LAYOUT_SETTING = 'LAYOUT_SETTING'
export const CONTENT_WIDTH_TYPE = {
Fluid: 'Fluid',
Fixed: 'Fixed'
}

View File

@ -1,124 +0,0 @@
@charset "UTF-8";
/*!
* animate.css -https://daneden.github.io/animate.css/
* Version - 3.7.2
* Licensed under the MIT license - https://opensource.org/licenses/MIT
*
* Copyright (c) 2019 Daniel Eden
*/
@-webkit-keyframes fadeInRight {
from {
opacity: 0;
-webkit-transform: translate3d(100%, 0, 0);
transform: translate3d(100%, 0, 0);
}
to {
opacity: 1;
-webkit-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
}
}
@keyframes fadeInRight {
from {
opacity: 0;
-webkit-transform: translate3d(100%, 0, 0);
transform: translate3d(100%, 0, 0);
}
to {
opacity: 1;
-webkit-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
}
}
.fadeInRight {
-webkit-animation-name: fadeInRight;
animation-name: fadeInRight;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.fadeIn {
animation-name: fadeIn;
}
.animated {
-webkit-animation-duration: 1s;
animation-duration: 1s;
-webkit-animation-fill-mode: both;
animation-fill-mode: both;
}
.animated.infinite {
-webkit-animation-iteration-count: infinite;
animation-iteration-count: infinite;
}
.animated.delay-1s {
-webkit-animation-delay: 1s;
animation-delay: 1s;
}
.animated.delay-2s {
-webkit-animation-delay: 2s;
animation-delay: 2s;
}
.animated.delay-3s {
-webkit-animation-delay: 3s;
animation-delay: 3s;
}
.animated.delay-4s {
-webkit-animation-delay: 4s;
animation-delay: 4s;
}
.animated.delay-5s {
-webkit-animation-delay: 5s;
animation-delay: 5s;
}
.animated.fast {
-webkit-animation-duration: 800ms;
animation-duration: 800ms;
}
.animated.faster {
-webkit-animation-duration: 500ms;
animation-duration: 500ms;
}
.animated.slow {
-webkit-animation-duration: 2s;
animation-duration: 2s;
}
.animated.slower {
-webkit-animation-duration: 3s;
animation-duration: 3s;
}
@media (print), (prefers-reduced-motion: reduce) {
.animated {
-webkit-animation-duration: 1ms !important;
animation-duration: 1ms !important;
-webkit-transition-duration: 1ms !important;
transition-duration: 1ms !important;
-webkit-animation-iteration-count: 1 !important;
animation-iteration-count: 1 !important;
}
}

View File

@ -1,948 +0,0 @@
@import './index.less';
@import './style.less';
body {
overflow-y: overlay;
padding: 0 !important;
}
.layout.ant-layout {
height: auto;
overflow-x: hidden;
&.mobile,
&.tablet {
.ant-layout-content {
.content {
margin: 24px 0 0;
}
}
/**
* ant-table-wrapper
* 覆盖的表格手机模式样式,如果想修改在手机上表格最低宽度,可以在这里改动
*/
.ant-table-wrapper {
.ant-table-content {
overflow-y: auto;
}
.ant-table-body {
min-width: 800px;
}
}
.topmenu {
/* 必须为 topmenu 才能启用流式布局 */
&.content-width-Fluid {
.header-index-wide {
margin-left: 0;
}
}
}
}
&.mobile {
.sidemenu {
.ant-header-fixedHeader {
&.ant-header-side-opened,
&.ant-header-side-closed {
width: 100%;
}
}
}
}
&.ant-layout-has-sider {
flex-direction: row;
}
.trigger {
font-size: 20px;
line-height: 64px;
padding: 0 24px;
cursor: pointer;
transition: color 0.3s;
&:hover {
background: rgba(0, 0, 0, 0.025);
}
}
.topmenu {
.ant-header-fixedHeader {
position: fixed;
top: 0;
right: 0;
z-index: 9;
width: 100%;
transition: width 0.2s;
&.ant-header-side-opened {
width: 100%;
}
&.ant-header-side-closed {
width: 100%;
}
}
/* 必须为 topmenu 才能启用流式布局 */
&.content-width-Fluid {
.header-index-wide {
max-width: unset;
.header-index-left {
flex: 1 1 1000px;
.logo {
margin-left: 25px;
}
.ant-menu.ant-menu-horizontal {
max-width: calc(100vw - 190px - 238px - 25px);
flex: 1 1 calc(100vw - 190px - 238px - 25px);
}
}
.header-index-right {
margin-right: 25px;
}
}
.page-header-index-wide {
max-width: unset;
}
}
}
.sidemenu {
.ant-header-fixedHeader {
position: fixed;
top: 0;
right: 0;
z-index: 9;
width: 100%;
transition: width 0.2s;
&.ant-header-side-opened {
width: calc(100% - 256px);
}
&.ant-header-side-closed {
width: calc(100% - 80px);
}
}
}
.header {
height: 64px;
padding: 0;
background: #fff;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
position: relative;
}
.header,
.top-nav-header-index {
.user-wrapper {
float: right;
height: 100%;
.action {
cursor: pointer;
padding: 0 18px;
display: inline-block;
transition: all 0.3s;
height: 100%;
color: rgba(0, 0, 0, 0.65);
&:hover {
background: rgba(0, 0, 0, 0.025);
}
.avatar {
margin: 20px 0 20px 0;
color: #1890ff;
background: hsla(0, 0%, 100%, 0.85);
vertical-align: middle;
}
.icon {
font-size: 16px;
padding: 4px;
}
}
}
&.dark {
.user-wrapper {
.action {
color: rgba(255, 255, 255, 0.85);
a {
color: rgba(255, 255, 255, 0.85);
}
&:hover {
background: rgba(255, 255, 255, 0.16);
}
}
}
}
}
&.mobile,
&.tablet {
.top-nav-header-index {
.header-index-wide {
.header-index-left {
.trigger {
color: rgba(255, 255, 255, 0.85);
padding: 0 24px;
}
.logo.top-nav-header {
flex: 0;
text-align: center;
line-height: 58px;
}
}
}
&.light {
.header-index-wide {
.header-index-left {
.trigger {
color: rgba(0, 0, 0, 0.65);
}
}
}
}
}
}
&.tablet {
// overflow: hidden; text-overflow:ellipsis; white-space: nowrap;
.top-nav-header-index {
.header-index-wide {
.ant-menu.ant-menu-horizontal {
flex: 1 1 auto;
white-space: normal;
}
}
}
}
.top-nav-header-index {
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
position: relative;
transition: background 0.3s, width 0.2s;
.header-index-wide {
max-width: 1200px;
margin: auto;
padding-left: 0;
display: flex;
height: 64px;
.ant-menu.ant-menu-horizontal {
max-width: 835px;
flex: 0 1 835px;
border: none;
height: 64px;
line-height: 64px;
}
.header-index-left {
flex: 0 1 1000px;
display: flex;
.logo.top-nav-header {
flex: 0 0 115px;
width: 115px;
height: 64px;
position: relative;
line-height: 64px;
transition: all 0.3s;
overflow: hidden;
img,
svg {
display: inline-block;
vertical-align: middle;
width: 56px;
}
}
}
.header-index-right {
flex: 0 0 auto;
align-self: flex-end;
height: 64px;
overflow: hidden;
.content-box {
float: right;
.action {
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
&.light {
background-color: #fff;
}
}
// 内容区
.layout-content {
margin: 24px 24px 0px;
height: 100%;
height: 64px;
padding: 0 12px 0 0;
}
}
.topmenu {
.page-header-index-wide {
max-width: 1200px;
margin: 0 auto;
}
}
// drawer-sider 自定义
.ant-drawer.drawer-sider {
.sider {
box-shadow: none;
}
&.dark {
.ant-drawer-content {
background-color: rgb(0, 21, 41);
}
}
&.light {
box-shadow: none;
.ant-drawer-content {
background-color: #fff;
}
}
.ant-drawer-body {
padding: 0;
}
}
// 菜单样式
.sider {
box-shadow: 2px 0 6px rgba(0, 21, 41, 0.35);
position: relative;
z-index: @ant-global-sider-zindex;
min-height: 100vh;
.ant-layout-sider-children {
overflow-y: hidden;
&:hover {
overflow-y: auto;
}
}
&.ant-fixed-sidemenu {
position: fixed;
height: 100%;
}
.logo {
position: relative;
text-align: center;
height: 64px;
overflow: hidden;
line-height: 64px;
background: #002140;
transition: all 0.3s;
img,
svg {
display: inline-block;
vertical-align: middle;
width: 64px;
}
}
&.light {
background-color: #fff;
box-shadow: 2px 0px 8px 0px rgba(29, 35, 41, 0.05);
.logo {
background: #fff;
box-shadow: 1px 1px 0px 0px #e8e8e8;
}
.ant-menu-light {
border-right-color: transparent;
}
}
}
// 外置的样式控制
.user-dropdown-menu {
span {
user-select: none;
}
}
.user-dropdown-menu-wrapper.ant-dropdown-menu {
padding: 4px 0;
.ant-dropdown-menu-item {
width: 160px;
}
.ant-dropdown-menu-item > .anticon:first-child,
.ant-dropdown-menu-item > a > .anticon:first-child,
.ant-dropdown-menu-submenu-title > .anticon:first-child .ant-dropdown-menu-submenu-title > a > .anticon:first-child {
min-width: 12px;
margin-right: 8px;
}
}
.table-page-search-wrapper {
.ant-form-inline {
.ant-form-item {
display: flex;
margin-bottom: 20px;
margin-right: 0;
.ant-form-item-control-wrapper {
flex: 1 1;
display: inline-block;
vertical-align: middle;
}
> .ant-form-item-label {
line-height: 32px;
padding-right: 8px;
width: auto;
}
.ant-form-item-control {
height: 32px;
line-height: 32px;
}
}
}
.table-page-search-submitButtons {
display: block;
margin-bottom: 24px;
white-space: nowrap;
}
}
.ant-table-thead > tr > th {
background: #fff !important;
}
.content {
.table-operator {
margin-bottom: 18px;
button {
margin-right: 8px;
}
}
}
.ant-card {
.ant-card-head {
padding: 0 16px !important;
.ant-card-head-wrapper {
.ant-card-head-title {
padding: 12px 0 !important;
}
}
}
}
.ant-form {
.ant-form-item {
padding-bottom: 0 !important;
margin-bottom: 20px;
}
}
.ant-list-item {
word-break: break-all;
}
.card-container {
background: #f5f5f5;
& > .ant-tabs-card {
& > .ant-tabs-content {
margin-top: -16px;
& > .ant-tabs-tabpane {
background: #fff;
padding: 16px;
}
}
& > .ant-tabs-bar {
border-color: #fff;
.ant-tabs-tab {
border: none !important;
margin-right: 0 !important;
background: transparent;
}
.ant-tabs-tab-active {
border-color: #fff;
background: #fff;
}
}
}
}
.ant-comment {
.ant-comment-actions {
margin-bottom: 0 !important;
margin-top: 0 !important;
padding-bottom: 0 !important;
}
}
.ant-comment-inner {
.ant-comment-content {
.ant-comment-content-detail {
p {
margin-top: 1rem;
margin-bottom: 0;
img {
width: 100%;
}
}
}
}
}
.ant-comment-avatar {
img {
width: 40px !important;
height: 40px !important;
}
}
.ant-anchor-link-title {
white-space: normal !important;
}
.bottom-control {
position: absolute;
bottom: 0px;
width: 100%;
border-top: 1px solid rgb(232, 232, 232);
padding: 10px 16px;
text-align: right;
left: 0px;
background: rgb(255, 255, 255);
border-radius: 0px 0px 4px 4px;
}
.page-wrapper {
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: end;
-ms-flex-pack: end;
justify-content: flex-end;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: horizontal;
-webkit-box-direction: normal;
-ms-flex-flow: row wrap;
flex-flow: row wrap;
.ant-pagination-options-size-changer.ant-select {
margin: 0;
}
.pagination {
margin-top: 1rem;
}
}
.divider-transparent {
background: transparent !important;
}
.custom-tab-wrapper {
.ant-tabs-nav {
.ant-tabs-tab {
margin: 0 24px 0 0;
padding: 12px 0;
}
}
}
.comment-content-wrapper {
h1 {
font-size: 18px;
}
h2 {
font-size: 16px;
}
h3 {
font-size: 14px;
}
h4 {
font-size: 12px;
}
h5 {
font-size: 10px;
}
h6 {
font-size: 8px;
}
img {
width: 100%;
}
margin-bottom: 0;
p {
margin-bottom: 0;
}
}
.post-thumb,
.sheet-thumb {
.img {
width: 100%;
cursor: pointer;
border-radius: 4px;
}
}
.ant-calendar-picker {
width: 100% !important;
}
#editor {
.v-note-wrapper {
height: 100%;
}
}
.attach-item {
width: 50%;
padding-bottom: 28%;
float: left;
}
.attach-thumb,
.photo-thumb {
width: 100%;
padding-bottom: 56%;
}
.attach-item,
.attach-thumb,
.photo-thumb {
margin: 0 auto;
position: relative;
overflow: hidden;
cursor: pointer;
img,
span {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
}
span {
display: flex;
font-size: 12px;
align-items: center;
justify-content: center;
color: #9b9ea0;
}
}
.analysis-card-container {
position: relative;
overflow: hidden;
width: 100%;
.meta {
position: relative;
overflow: hidden;
width: 100%;
color: rgba(0, 0, 0, 0.45);
font-size: 14px;
line-height: 22px;
.analysis-card-action {
cursor: pointer;
position: absolute;
top: 0;
right: 0;
}
}
.number {
overflow: hidden;
text-overflow: ellipsis;
word-break: break-all;
white-space: nowrap;
color: #000;
margin-top: 4px;
margin-bottom: 0;
font-size: 32px;
line-height: 38px;
height: 38px;
}
}
.ant-tree-child-tree {
li {
overflow: hidden;
}
}
.exception {
min-height: 500px;
height: 80%;
align-items: center;
text-align: center;
margin-top: 150px;
}
.mobile {
.exception {
margin-top: 30px;
}
}
.select-attachment-checkbox {
display: block;
width: 100%;
height: 100%;
position: absolute;
top: 0;
bottom: 0;
left: 0;
z-index: 10;
.ant-checkbox {
margin-left: 4px;
}
}
.ant-list-item {
.ant-list-item-main,
.ant-list-item-meta-content,
pre {
overflow-x: auto;
}
}
.journal-list-content,
.comment-drawer-content {
img {
width: 50%;
}
}
.ant-input-group-addon {
line-height: initial !important;
}
.theme-screenshot {
width: 100%;
margin: 0 auto;
position: relative;
padding-bottom: 56%;
overflow: hidden;
img {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
}
}
.card-header-fixed .ant-card-head {
position: fixed;
background: white;
z-index: 999;
top: 0;
}
// 附件图片样式
.attachments-group, .photos-group {
&-item {
padding: 0;
height: 130px;
&-img {
display: block;
height: 100%;
background-repeat: no-repeat;
background-size: cover;
background-position: center;
}
.attachments-group &-type {
font-size: 38px;
text-transform: capitalize;
}
}
}
.ant-affix {
z-index: 1000 !important;
}
.header-comment-popover {
.ant-popover-content {
.ant-popover-inner-content {
height: 500px;
overflow-y: auto;
}
}
}
#nprogress {
pointer-events: none;
}
#nprogress .bar {
background: #29d;
position: fixed;
z-index: 1031;
top: 0;
left: 0;
width: 100%;
height: 2px;
}
/* Fancy blur effect */
#nprogress .peg {
display: block;
position: absolute;
right: 0px;
width: 100px;
height: 100%;
box-shadow: 0 0 10px #29d, 0 0 5px #29d;
opacity: 1;
-webkit-transform: rotate(3deg) translate(0px, -4px);
-ms-transform: rotate(3deg) translate(0px, -4px);
transform: rotate(3deg) translate(0px, -4px);
}
/* Remove these to get rid of the spinner */
#nprogress .spinner {
display: block;
position: fixed;
z-index: 1031;
top: 15px;
right: 15px;
}
#nprogress .spinner-icon {
width: 18px;
height: 18px;
box-sizing: border-box;
border: solid 2px transparent;
border-top-color: #29d;
border-left-color: #29d;
border-radius: 50%;
-webkit-animation: nprogress-spinner 400ms linear infinite;
animation: nprogress-spinner 400ms linear infinite;
}
.nprogress-custom-parent {
overflow: hidden;
position: relative;
}
.nprogress-custom-parent #nprogress .spinner,
.nprogress-custom-parent #nprogress .bar {
position: absolute;
}
@-webkit-keyframes nprogress-spinner {
0% {
-webkit-transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
}
}
@keyframes nprogress-spinner {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* scroll bar style */
*::-webkit-scrollbar-track-piece {
background-color: #f8f8f8;
-webkit-border-radius: 2em;
-moz-border-radius: 2em;
border-radius: 2em;
}
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-thumb {
background-color: #ddd;
background-clip: padding-box;
-webkit-border-radius: 2em;
-moz-border-radius: 2em;
border-radius: 2em;
}
*::-webkit-scrollbar-thumb:hover {
background-color: #bbb;
}

View File

@ -1,11 +0,0 @@
@import '~ant-design-vue/lib/style/index';
button,
html [type='button'] {
-webkit-appearance: none;
}
// The prefix to use on all css classes from ant-pro.
@ant-pro-prefix: ant-pro;
@ant-global-sider-zindex: 106;
@ant-global-header-zindex: 105;

View File

@ -1,35 +0,0 @@
@import './animate.less';
.container-wrapper {
background: #ffffff;
position: absolute;
border-radius: 5px;
top: 45%;
left: 50%;
margin: -160px 0 0 -160px;
width: 320px;
padding: 22px 28px 28px 28px;
box-shadow: rgba(0, 0, 0, 0.08) 0 4px 12px;
.tip {
cursor: pointer;
margin-left: 0.5rem;
float: right;
}
}
.halo-logo {
margin-top: 10px;
margin-bottom: 38px;
text-align: center;
width: 100%;
span {
margin-left: 5px;
font-size: 12px;
color: #1790fe;
}
img {
width: 88px;
}
}

View File

@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -1,118 +0,0 @@
import { AdminApiClient, Axios, HaloRestAPIClient } from '@halo-dev/admin-api'
import store from '@/store'
import { message, notification } from 'ant-design-vue'
import { isObject } from './util'
const storedApiUrl = localStorage.getItem('apiUrl')
const apiUrl = storedApiUrl ? storedApiUrl : process.env.VUE_APP_API_URL
const haloRestApiClient = new HaloRestAPIClient({
baseUrl: apiUrl
})
const apiClient = new AdminApiClient(haloRestApiClient)
haloRestApiClient.interceptors.request.use(
config => {
const token = store.getters.token
if (token && token.access_token) {
config.headers['Admin-Authorization'] = token.access_token
}
return config
},
error => {
return Promise.reject(error)
}
)
let isRefreshingToken = false
let pendingRequests = []
haloRestApiClient.interceptors.response.use(
response => {
return response
},
async error => {
if (Axios.isCancel(error)) {
return Promise.reject(error)
}
if (/Network Error/.test(error.message)) {
message.error('网络错误,请检查网络连接')
return Promise.reject(error)
}
const token = store.getters.token
const originalRequest = error.config
const response = error.response
const data = response ? response.data : null
if (data) {
if (data.status === 400) {
const params = data.data
if (isObject(params)) {
const paramMessages = Object.keys(params || {}).map(key => params[key])
notification.error({
message: data.message,
description: h => {
const errorNodes = paramMessages.map(errorDetail => {
return h('a-alert', {
props: {
message: errorDetail,
banner: true,
showIcon: false,
type: 'error'
}
})
})
return h('div', errorNodes)
},
duration: 10
})
} else {
message.error(data.message)
}
return Promise.reject(error)
}
if (data.status === 401) {
if (!isRefreshingToken) {
isRefreshingToken = true
try {
await store.dispatch('refreshToken', token.refresh_token)
pendingRequests.forEach(callback => callback())
pendingRequests = []
return Axios(originalRequest)
} catch (e) {
message.warning('当前登录状态已失效,请重新登录')
await store.dispatch('ToggleLoginModal', true)
return Promise.reject(e)
} finally {
isRefreshingToken = false
}
} else {
return new Promise(resolve => {
pendingRequests.push(() => {
resolve(Axios(originalRequest))
})
})
}
}
message.error(data.message || '服务器错误')
return Promise.reject(error)
}
message.error('网络异常')
return Promise.reject(error)
}
)
export default apiClient
export { haloRestApiClient }

View File

@ -1,47 +0,0 @@
const isLight = color => {
if (!isHex(color)) {
return false
}
// Check the format of the color, HEX or RGB?
let r, g, b, hsp
if (color.match(/^rgb/)) {
// If HEX --> store the red, green, blue values in separate variables
color = color.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(?:\.\d+)?))?\)$/)
r = color[1]
g = color[2]
b = color[3]
} else {
// If RGB --> Convert it to HEX: http://gist.github.com/983661
color = +('0x' + color.slice(1).replace(color.length < 5 && /./g, '$&$&'))
r = color >> 16
g = (color >> 8) & 255
b = color & 255
}
// HSP (Highly Sensitive Poo) equation from http://alienryderflex.com/hsp.html
hsp = Math.sqrt(0.299 * (r * r) + 0.587 * (g * g) + 0.114 * (b * b))
// Using the HSP value, determine whether the color is light or dark
return hsp > 127.5
}
const randomHex = () => {
return '#' + Math.floor(Math.random() * 16777215).toString(16)
}
const hexRegExp = /(^#[0-9A-F])/i
const isHex = color => {
return hexRegExp.test(color)
}
const isRgb = color => {
return /^rgb/.test(color)
}
const isHsl = color => {
return /^hsl/.test(color)
}
export { isLight, randomHex, isHex, hexRegExp, isRgb, isHsl }

View File

@ -1,37 +0,0 @@
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
dayjs.locale('zh-cn')
function datetimeFormat(datetime = new Date(), pattern = 'YYYY-MM-DD HH:mm') {
return dayjs(datetime).format(pattern)
}
function timeAgo(datetime) {
const currentTime = new Date().getTime()
const between = currentTime - datetime
const days = Math.floor(between / (24 * 3600 * 1000))
if (days === 0) {
const leave1 = between % (24 * 3600 * 1000)
const hours = Math.floor(leave1 / (3600 * 1000))
if (hours === 0) {
const leave2 = leave1 % (3600 * 1000)
const minutes = Math.floor(leave2 / (60 * 1000))
if (minutes === 0) {
const leave3 = leave2 % (60 * 1000)
const seconds = Math.round(leave3 / 1000)
return seconds + ' 秒前'
}
return minutes + ' 分钟前'
}
return hours + ' 小时前'
}
if (days < 0) return '刚刚'
if (days < 5) {
return days + ' 天前'
} else {
return dayjs(datetime).format('YYYY-MM-DD HH:mm')
}
}
export { datetimeFormat, timeAgo }

View File

@ -1,33 +0,0 @@
import enquireJs from 'enquire.js'
export const DEVICE_TYPE = {
DESKTOP: 'desktop',
TABLET: 'tablet',
MOBILE: 'mobile'
}
export const deviceEnquire = function (callback) {
const matchDesktop = {
match: () => {
callback && callback(DEVICE_TYPE.DESKTOP)
}
}
const matchTablet = {
match: () => {
callback && callback(DEVICE_TYPE.TABLET)
}
}
const matchMobile = {
match: () => {
callback && callback(DEVICE_TYPE.MOBILE)
}
}
// screen and (max-width: 1087.99px)
enquireJs
.register('screen and (max-width: 576px)', matchMobile)
.register('screen and (min-width: 576px) and (max-width: 1199px)', matchTablet)
.register('screen and (min-width: 1200px)', matchDesktop)
}

View File

@ -1,19 +0,0 @@
export const setDocumentTitle = function (title) {
document.title = title
const ua = navigator.userAgent
// eslint-disable-next-line
const regex = /\bMicroMessenger\/([\d\.]+)/
if (regex.test(ua) && /ip(hone|od|ad)/i.test(ua)) {
const i = document.createElement('iframe')
i.src = '/favicon.ico'
i.style.display = 'none'
i.onload = function () {
setTimeout(function () {
i.remove()
}, 9)
}
document.body.appendChild(i)
}
}
export const domTitle = 'Halo'

View File

@ -1,25 +0,0 @@
export function triggerWindowResizeEvent() {
const event = document.createEvent('HTMLEvents')
event.initEvent('resize', true, true)
event.eventType = 'message'
window.dispatchEvent(event)
}
export function isObject(value) {
return value && typeof value === 'object' && value.constructor === Object
}
export function deepClone(source) {
if (!source && typeof source !== 'object') {
throw new Error('error arguments')
}
const targetObj = source.constructor === Array ? [] : {}
Object.keys(source).forEach(keys => {
if (source[keys] && typeof source[keys] === 'object') {
targetObj[keys] = deepClone(source[keys])
} else {
targetObj[keys] = source[keys]
}
})
return targetObj
}

View File

@ -1,497 +0,0 @@
<template>
<page-view>
<a-row :gutter="12" align="middle" type="flex">
<a-col :span="24" class="pb-3">
<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="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="list.params.attachmentType"
:loading="types.loading"
allowClear
@change="handleQuery()"
>
<a-select-option v-for="item in types.data" :key="item" :value="item">
{{ item | typeText }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :md="6" :sm="24">
<a-form-item label="文件类型:">
<a-select
v-model="list.params.mediaType"
:loading="mediaTypes.loading"
allowClear
@change="handleQuery()"
>
<a-select-option v-for="(item, index) in mediaTypes.data" :key="index" :value="item"
>{{ item }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :md="6" :sm="24">
<span class="table-page-search-submitButtons">
<a-space>
<a-button type="primary" @click="handleQuery()"></a-button>
<a-button @click="handleResetParam()"></a-button>
</a-space>
</span>
</a-col>
</a-row>
</a-form>
</div>
<div class="mb-0 table-operator">
<a-button icon="cloud-upload" type="primary" @click="upload.visible = true">上传</a-button>
<a-button v-show="list.selected.length" icon="check-circle" type="primary" @click="handleSelectAll">
全选
</a-button>
<a-button v-show="list.selected.length" icon="delete" type="danger" @click="handleDeleteAttachmentInBatch">
删除
</a-button>
<a-button v-show="list.selected.length" icon="close" @click="list.selected = []"> </a-button>
</div>
</a-card>
</a-col>
<a-col :span="24">
<a-list
:dataSource="list.data"
:grid="{ gutter: 6, xs: 2, sm: 2, md: 4, lg: 6, xl: 6, xxl: 6 }"
:loading="list.loading"
class="attachments-group"
>
<template #renderItem="item, index">
<a-list-item
@mouseenter="$set(item, 'hover', true)"
@mouseleave="$set(item, 'hover', false)"
:key="index"
@click="handleItemClick(item)"
@contextmenu.prevent="handleContextMenu($event, item)"
>
<div
:class="`${isItemSelect(item) ? 'border-blue-600' : 'border-slate-200'}`"
class="border border-solid"
>
<div class="attach-thumb attachments-group-item">
<span v-if="!isImage(item)" class="attachments-group-item-type">{{ item.suffix }}</span>
<span
v-else
:style="`background-image:url(${encodeURI(item.thumbPath)})`"
class="attachments-group-item-img"
loading="lazy"
/>
</div>
<a-card-meta class="p-2 cursor-pointer">
<template #description>
<a-tooltip :title="item.name">
<div class="truncate">{{ item.name }}</div>
</a-tooltip>
</template>
</a-card-meta>
<a-icon
v-show="!isItemSelect(item) && item.hover"
type="plus-circle"
theme="twoTone"
class="absolute top-1 right-2 font-bold cursor-pointer transition-all"
:style="{ fontSize: '18px', color: 'rgb(37 99 235)' }"
@click.stop="handleSelect(item)"
/>
<a-icon
v-show="isItemSelect(item)"
type="check-circle"
theme="twoTone"
class="absolute top-1 right-2 font-bold cursor-pointer transition-all"
:style="{ fontSize: '18px', color: 'rgb(37 99 235)' }"
/>
<a-icon
v-show="item.hover && list.selected.length > 0"
type="profile"
theme="twoTone"
class="absolute top-1 left-2 font-bold cursor-pointer transition-all"
@click.stop="handleOpenDetail(item)"
:style="{ fontSize: '18px' }"
/>
</div>
</a-list-item>
</template>
</a-list>
</a-col>
</a-row>
<div class="page-wrapper">
<a-pagination
:current="pagination.page"
:defaultPageSize="pagination.size"
:pageSizeOptions="['18', '36', '54', '72', '90', '108']"
:total="pagination.total"
class="pagination"
showLessItems
showSizeChanger
@change="handlePageChange"
@showSizeChange="handlePageSizeChange"
/>
</div>
<AttachmentUploadModal :visible.sync="upload.visible" @close="onUploadClose" />
<AttachmentDetailModal
:addToPhoto="true"
:attachment="list.current"
:visible.sync="detailVisible"
@delete="handleListAttachments()"
>
<template #extraFooter>
<a-button :disabled="selectPreviousButtonDisabled" @click="handleSelectPrevious"></a-button>
<a-button :disabled="selectNextButtonDisabled" @click="handleSelectNext"></a-button>
</template>
</AttachmentDetailModal>
</page-view>
</template>
<script>
import { mixin, mixinDevice } from '@/mixins/mixin.js'
import { PageView } from '@/layouts'
import apiClient from '@/utils/api-client'
import { attachmentTypes } from '@/core/constant'
export default {
components: {
PageView
},
mixins: [mixin, mixinDevice],
filters: {
typeText(type) {
return attachmentTypes[type].text
}
},
data() {
return {
list: {
data: [],
loading: false,
total: 0,
hasNext: false,
hasPrevious: false,
params: {
page: 0,
size: 18,
keyword: undefined,
mediaType: undefined,
attachmentType: undefined
},
selected: [],
current: {}
},
mediaTypes: {
data: [],
loading: false
},
types: {
data: [],
loading: false
},
upload: {
visible: false
},
detailVisible: false
}
},
computed: {
isImage() {
return function (attachment) {
if (!attachment || !attachment.mediaType) {
return false
}
return attachment.mediaType.startsWith('image')
}
},
isItemSelect() {
return function (attachment) {
return this.list.selected.findIndex(item => item.id === attachment.id) > -1
}
},
pagination() {
return {
page: this.list.params.page + 1,
size: this.list.params.size,
total: this.list.total
}
},
selectPreviousButtonDisabled() {
const index = this.list.data.findIndex(attachment => attachment.id === this.list.current.id)
return index === 0 && !this.list.hasPrevious
},
selectNextButtonDisabled() {
const index = this.list.data.findIndex(attachment => attachment.id === this.list.current.id)
return index === this.list.data.length - 1 && !this.list.hasNext
}
},
created() {
this.handleListAttachments()
this.handleListMediaTypes()
this.handleListTypes()
},
methods: {
/**
* List attachments
*/
async handleListAttachments() {
try {
this.list.loading = true
const response = await apiClient.attachment.list(this.list.params)
this.list.data = response.data.content
this.list.total = response.data.total
this.list.hasNext = response.data.hasNext
this.list.hasPrevious = response.data.hasPrevious
} catch (error) {
this.$log.error(error)
} finally {
this.list.loading = false
}
},
/**
* List attachment media types
*/
async handleListMediaTypes() {
try {
this.mediaTypes.loading = true
const response = await apiClient.attachment.listMediaTypes()
this.mediaTypes.data = response.data
} catch (error) {
this.$log.error(error)
} finally {
this.mediaTypes.loading = false
}
},
/**
* List attachment upload types
*/
async handleListTypes() {
try {
this.types.loading = true
const response = await apiClient.attachment.listTypes()
this.types.data = response.data
} catch (error) {
this.$log.error(error)
} finally {
this.types.loading = false
}
},
/**
* Handle open attachment detail modal event
*/
handleOpenDetail(attachment) {
this.list.current = attachment
this.detailVisible = true
},
handleItemClick(attachment) {
if (this.list.selected.length <= 0) {
this.handleOpenDetail(attachment)
return
}
this.isItemSelect(attachment) ? this.handleUnselect(attachment) : this.handleSelect(attachment)
},
handleSelect(attachment) {
this.list.selected = [...this.list.selected, attachment]
},
handleUnselect(attachment) {
this.list.selected = this.list.selected.filter(item => item.id !== attachment.id)
},
handleSelectAll() {
this.list.selected = this.list.data
},
/**
* Show context menu
*/
handleContextMenu(event, item) {
this.$contextmenu({
items: [
{
label: `复制${this.isImage(item) ? '图片' : '文件'}链接`,
onClick: () => {
const text = `${encodeURI(item.path)}`
this.$copyText(text)
.then(message => {
this.$log.debug('copy', message)
this.$message.success('复制成功!')
})
.catch(err => {
this.$log.debug('copy.err', err)
this.$message.error('复制失败!')
})
},
divided: true
},
{
disabled: !this.isImage(item),
label: '复制 Markdown 格式链接',
onClick: () => {
const text = `![${item.name}](${encodeURI(item.path)})`
this.$copyText(text)
.then(message => {
this.$log.debug('copy', message)
this.$message.success('复制成功!')
})
.catch(err => {
this.$log.debug('copy.err', err)
this.$message.error('复制失败!')
})
},
divided: true
},
{
label: '删除',
onClick: () => {
this.$confirm({
title: '提示',
content: '确定删除该附件?',
okText: '确定',
cancelText: '取消',
onOk: async () => {
await apiClient.attachment.delete(item.id)
await this.handleListAttachments()
this.handleUnselect(item)
}
})
}
}
],
event,
minWidth: 210
})
return false
},
/**
* Handle page change
*/
handlePageChange(page = 1) {
this.list.params.page = page - 1
this.handleListAttachments()
},
/**
* 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.handleListAttachments()
},
/**
* Reset query params
*/
handleResetParam() {
this.list.params.keyword = undefined
this.list.params.mediaType = undefined
this.list.params.attachmentType = undefined
this.handlePageChange()
this.handleListMediaTypes()
this.handleListTypes()
},
/**
* Search attachments
*/
handleQuery() {
this.handlePageChange()
},
onUploadClose() {
this.handlePageChange()
this.handleListMediaTypes()
this.handleListTypes()
},
/**
* Deletes selected attachments
*/
handleDeleteAttachmentInBatch() {
const _this = this
if (this.list.selected.length <= 0) {
this.$message.warn('你还未选择任何附件,请至少选择一个!')
return
}
this.$confirm({
title: '确定要批量删除选中的附件吗?',
content: '一旦删除不可恢复,请谨慎操作',
async onOk() {
try {
const attachmentIds = _this.list.selected.map(attachment => attachment.id)
await apiClient.attachment.deleteInBatch(attachmentIds)
_this.list.selected = []
_this.$message.success('删除成功')
} catch (e) {
_this.$log.error('Failed to delete selected attachments', e)
} finally {
await _this.handleListAttachments()
}
}
})
},
/**
* Select previous attachment
*/
async handleSelectPrevious() {
const index = this.list.data.findIndex(item => item.id === this.list.current.id)
if (index > 0) {
this.list.current = this.list.data[index - 1]
return
}
if (index === 0 && this.list.hasPrevious) {
this.list.params.page--
await this.handleListAttachments()
this.list.current = this.list.data[this.list.data.length - 1]
}
},
/**
* Select next attachment
*/
async handleSelectNext() {
const index = this.list.data.findIndex(item => item.id === this.list.current.id)
if (index < this.list.data.length - 1) {
this.list.current = this.list.data[index + 1]
return
}
if (index === this.list.data.length - 1 && this.list.hasNext) {
this.list.params.page++
await this.handleListAttachments()
this.list.current = this.list.data[0]
}
}
}
}
</script>

Some files were not shown because too many files have changed in this diff Show More