u
This commit is contained in:
1 parent
9aa4a72321
commit
9bf7c40b70
40 files changed
+918
-189
No files matched your search
@@ -0,0 +1,147 @@
|
||||
<script setup lang="ts">
|
||||
import type { UploadImageResponse } from '@/utils/file'
|
||||
import { Delete } from '@element-plus/icons-vue'
|
||||
import { ref } from 'vue'
|
||||
import { uploadImage, uploadToAliyun } from '@/utils/file'
|
||||
import SvgIcon from './SvgIcon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
autoUploadAliyun: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', val: string): void
|
||||
(e: 'change', val: UploadImageResponse | null): void
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
async function handleClickUploadImage() {
|
||||
if (loading.value)
|
||||
return
|
||||
|
||||
try {
|
||||
const res = await uploadImage()
|
||||
loading.value = true
|
||||
|
||||
if (props.autoUploadAliyun) {
|
||||
const aliyunRes = await uploadToAliyun(res.blob, res.name, res.size)
|
||||
|
||||
emit('update:modelValue', aliyunRes.url)
|
||||
}
|
||||
else {
|
||||
emit('update:modelValue', res.url)
|
||||
}
|
||||
emit('change', res)
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemove(e: MouseEvent) {
|
||||
e.stopPropagation()
|
||||
emit('update:modelValue', '')
|
||||
emit('change', null)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="upload-card" :class="{ loading }" @click="handleClickUploadImage">
|
||||
<div
|
||||
v-if="props.modelValue && !loading"
|
||||
class="remove-icon"
|
||||
@click="handleRemove"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>
|
||||
</div>
|
||||
|
||||
<div v-if="!props.modelValue && !loading" class="upload-inner">
|
||||
<SvgIcon icon="material-symbols:arrow-upload-ready-rounded" class="upload-icon" />
|
||||
<div class="upload-text">
|
||||
点击上传
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="loading" class="upload-inner">
|
||||
<SvgIcon icon="line-md:loading-twotone-loop" class="upload-icon" />
|
||||
<div class="upload-text">
|
||||
上传中...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<img v-else :src="props.modelValue" alt="已上传图片" class="upload-image">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.upload-card {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #fafafa;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.3s ease;
|
||||
padding: 4px;
|
||||
|
||||
&:hover {
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
&.loading {
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.remove-icon {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
opacity: 1;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
|
||||
&:hover {
|
||||
background-color: red;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-inner {
|
||||
color: #999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.upload-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.upload-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -67,11 +67,11 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
const toolbarClass = computed(() => {
|
||||
return props.mode === 'view' ? 'toolbar-view' : 'toolbar-edit'
|
||||
return props.mode === 'view' ? 'wang-toolbar-view' : 'wang-toolbar-edit'
|
||||
})
|
||||
|
||||
const editorClass = computed(() => {
|
||||
return props.mode === 'view' ? 'editor-view' : 'editor-edit'
|
||||
return props.mode === 'view' ? 'wang-editor-view' : 'wang-editor-edit'
|
||||
})
|
||||
|
||||
// 编辑器实例,必须用 shallowRef
|
||||
@@ -210,13 +210,13 @@ function doGenerateDirectory() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="editor-container">
|
||||
<div class="wang-editor-container">
|
||||
<div class="main">
|
||||
<Toolbar v-if="props.mode === 'edit'" :class="toolbarClass" class="toolbar" :editor="editorRef" :default-config="toolbarConfig" />
|
||||
<Editor key="main" v-model="valueHtml" :class="editorClass" class="editor" :default-config="editorConfig" @on-change="handleChange" @on-created="handleCreated" />
|
||||
</div>
|
||||
|
||||
<div v-if="showDirecotry" class="directory">
|
||||
<div v-if="showDirecotry" class="wang-editor-directory">
|
||||
<el-collapse v-model="activeCollapseNames">
|
||||
<el-collapse-item title="目录" name="1">
|
||||
<el-anchor :offset="70">
|
||||
@@ -232,9 +232,9 @@ function doGenerateDirectory() {
|
||||
<style lang="scss" scoped>
|
||||
.toolbar {
|
||||
}
|
||||
.toolbar-view {
|
||||
.wang-toolbar-view {
|
||||
}
|
||||
.toolbar-edit {
|
||||
.wang-toolbar-edit {
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
.editor {
|
||||
@@ -243,7 +243,7 @@ function doGenerateDirectory() {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
.editor-view {
|
||||
.wang-editor-view {
|
||||
height: auto;
|
||||
:deep(.w-e-text-container) {
|
||||
background-color: transparent;
|
||||
@@ -253,18 +253,18 @@ function doGenerateDirectory() {
|
||||
padding: 0 0px !important;
|
||||
}
|
||||
}
|
||||
.editor-edit {
|
||||
height: 500px !important;
|
||||
.wang-editor-edit {
|
||||
height: 600px !important;
|
||||
border: 1px solid #ccc;
|
||||
border-top: transparent;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
.wang-editor-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.directory {
|
||||
.wang-editor-directory {
|
||||
width: 200px;
|
||||
height: 100%;
|
||||
margin-left: 16px;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { effectScope, nextTick, onScopeDispose, ref, watch } from "vue";
|
||||
import { useElementSize } from "@vueuse/core";
|
||||
import * as echarts from "echarts/core";
|
||||
import { BarChart, GaugeChart, LineChart, PictorialBarChart, PieChart, RadarChart, ScatterChart } from "echarts/charts";
|
||||
import type { BarSeriesOption, GaugeSeriesOption, LineSeriesOption, PictorialBarSeriesOption, PieSeriesOption, RadarSeriesOption, ScatterSeriesOption } from "echarts/charts";
|
||||
import { DatasetComponent, GridComponent, LegendComponent, TitleComponent, ToolboxComponent, TooltipComponent, TransformComponent } from "echarts/components";
|
||||
import type { DatasetComponentOption, GridComponentOption, LegendComponentOption, TitleComponentOption, ToolboxComponentOption, TooltipComponentOption } from "echarts/components";
|
||||
import { LabelLayout, UniversalTransition } from "echarts/features";
|
||||
import { CanvasRenderer } from "echarts/renderers";
|
||||
import type { BarSeriesOption, GaugeSeriesOption, LineSeriesOption, PictorialBarSeriesOption, PieSeriesOption, RadarSeriesOption, ScatterSeriesOption } from 'echarts/charts'
|
||||
import type { DatasetComponentOption, GridComponentOption, LegendComponentOption, TitleComponentOption, ToolboxComponentOption, TooltipComponentOption } from 'echarts/components'
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import { BarChart, GaugeChart, LineChart, PictorialBarChart, PieChart, RadarChart, ScatterChart } from 'echarts/charts'
|
||||
import { DatasetComponent, GridComponent, LegendComponent, TitleComponent, ToolboxComponent, TooltipComponent, TransformComponent } from 'echarts/components'
|
||||
import * as echarts from 'echarts/core'
|
||||
import { LabelLayout, UniversalTransition } from 'echarts/features'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { effectScope, nextTick, onScopeDispose, ref, watch } from 'vue'
|
||||
|
||||
export type ECOption = echarts.ComposeOption<
|
||||
| BarSeriesOption
|
||||
@@ -22,7 +22,7 @@ export type ECOption = echarts.ComposeOption<
|
||||
| GridComponentOption
|
||||
| ToolboxComponentOption
|
||||
| DatasetComponentOption
|
||||
>;
|
||||
>
|
||||
|
||||
echarts.use([
|
||||
TitleComponent,
|
||||
@@ -41,13 +41,13 @@ echarts.use([
|
||||
GaugeChart,
|
||||
LabelLayout,
|
||||
UniversalTransition,
|
||||
CanvasRenderer
|
||||
]);
|
||||
CanvasRenderer,
|
||||
])
|
||||
|
||||
interface ChartHooks {
|
||||
onRender?: (chart: echarts.ECharts) => void | Promise<void>;
|
||||
onUpdated?: (chart: echarts.ECharts) => void | Promise<void>;
|
||||
onDestroy?: (chart: echarts.ECharts) => void | Promise<void>;
|
||||
onRender?: (chart: echarts.ECharts) => void | Promise<void>
|
||||
onUpdated?: (chart: echarts.ECharts) => void | Promise<void>
|
||||
onDestroy?: (chart: echarts.ECharts) => void | Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,33 +57,33 @@ interface ChartHooks {
|
||||
* @param darkMode dark mode
|
||||
*/
|
||||
export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: ChartHooks = {}) {
|
||||
const scope = effectScope();
|
||||
const scope = effectScope()
|
||||
|
||||
const darkMode = ref(false);
|
||||
const darkMode = ref(false)
|
||||
|
||||
const domRef = ref<HTMLElement | null>(null);
|
||||
const initialSize = { width: 0, height: 0 };
|
||||
const { width, height } = useElementSize(domRef, initialSize);
|
||||
const domRef = ref<HTMLElement | null>(null)
|
||||
const initialSize = { width: 0, height: 0 }
|
||||
const { width, height } = useElementSize(domRef, initialSize)
|
||||
|
||||
let chart: echarts.ECharts | null = null;
|
||||
const chartOptions: T = optionsFactory();
|
||||
let chart: echarts.ECharts | null = null
|
||||
const chartOptions: T = optionsFactory()
|
||||
|
||||
const {
|
||||
onRender = (instance) => {
|
||||
const textColor = darkMode.value ? "rgb(224, 224, 224)" : "rgb(31, 31, 31)";
|
||||
const maskColor = darkMode.value ? "rgba(0, 0, 0, 0.4)" : "rgba(255, 255, 255, 0.8)";
|
||||
const textColor = darkMode.value ? 'rgb(224, 224, 224)' : 'rgb(31, 31, 31)'
|
||||
const maskColor = darkMode.value ? 'rgba(0, 0, 0, 0.4)' : 'rgba(255, 255, 255, 0.8)'
|
||||
|
||||
instance.showLoading({
|
||||
textColor,
|
||||
fontSize: 14,
|
||||
maskColor
|
||||
});
|
||||
maskColor,
|
||||
})
|
||||
},
|
||||
onUpdated = (instance) => {
|
||||
instance.hideLoading();
|
||||
instance.hideLoading()
|
||||
},
|
||||
onDestroy
|
||||
} = hooks;
|
||||
onDestroy,
|
||||
} = hooks
|
||||
|
||||
/**
|
||||
* whether can render chart
|
||||
@@ -91,12 +91,12 @@ export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: C
|
||||
* when domRef is ready and initialSize is valid
|
||||
*/
|
||||
function canRender() {
|
||||
return domRef.value && initialSize.width > 0 && initialSize.height > 0;
|
||||
return domRef.value && initialSize.width > 0 && initialSize.height > 0
|
||||
}
|
||||
|
||||
/** is chart rendered */
|
||||
function isRendered() {
|
||||
return Boolean(domRef.value && chart);
|
||||
return Boolean(domRef.value && chart)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,59 +105,61 @@ export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: C
|
||||
* @param callback callback function
|
||||
*/
|
||||
async function updateOptions(callback: (opts: T, optsFactory: () => T) => ECOption = () => chartOptions) {
|
||||
if (!isRendered()) return;
|
||||
if (!isRendered())
|
||||
return
|
||||
|
||||
const updatedOpts = callback(chartOptions, optionsFactory);
|
||||
const updatedOpts = callback(chartOptions, optionsFactory)
|
||||
|
||||
Object.assign(chartOptions, updatedOpts);
|
||||
Object.assign(chartOptions, updatedOpts)
|
||||
|
||||
if (isRendered()) {
|
||||
chart?.clear();
|
||||
chart?.clear()
|
||||
}
|
||||
|
||||
chart?.setOption({ ...updatedOpts, backgroundColor: "transparent" });
|
||||
chart?.setOption({ ...updatedOpts, backgroundColor: 'transparent' })
|
||||
|
||||
await onUpdated?.(chart!);
|
||||
await onUpdated?.(chart!)
|
||||
}
|
||||
|
||||
function setOptions(options: T) {
|
||||
chart?.setOption(options);
|
||||
chart?.setOption(options)
|
||||
}
|
||||
|
||||
/** render chart */
|
||||
async function render() {
|
||||
if (!isRendered()) {
|
||||
const chartTheme = darkMode.value ? "dark" : "light";
|
||||
const chartTheme = darkMode.value ? 'dark' : 'light'
|
||||
|
||||
await nextTick();
|
||||
await nextTick()
|
||||
|
||||
chart = echarts.init(domRef.value, chartTheme);
|
||||
chart = echarts.init(domRef.value, chartTheme)
|
||||
|
||||
chart.setOption({ ...chartOptions, backgroundColor: "transparent" });
|
||||
chart.setOption({ ...chartOptions, backgroundColor: 'transparent' })
|
||||
|
||||
await onRender?.(chart);
|
||||
await onRender?.(chart)
|
||||
}
|
||||
}
|
||||
|
||||
/** resize chart */
|
||||
function resize() {
|
||||
chart?.resize();
|
||||
chart?.resize()
|
||||
}
|
||||
|
||||
/** destroy chart */
|
||||
async function destroy() {
|
||||
if (!chart) return;
|
||||
if (!chart)
|
||||
return
|
||||
|
||||
await onDestroy?.(chart);
|
||||
chart?.dispose();
|
||||
chart = null;
|
||||
await onDestroy?.(chart)
|
||||
chart?.dispose()
|
||||
chart = null
|
||||
}
|
||||
|
||||
/** change chart theme */
|
||||
async function changeTheme() {
|
||||
await destroy();
|
||||
await render();
|
||||
await onUpdated?.(chart!);
|
||||
await destroy()
|
||||
await render()
|
||||
await onUpdated?.(chart!)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,47 +169,47 @@ export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: C
|
||||
* @param h height
|
||||
*/
|
||||
async function renderChartBySize(w: number, h: number) {
|
||||
initialSize.width = w;
|
||||
initialSize.height = h;
|
||||
initialSize.width = w
|
||||
initialSize.height = h
|
||||
|
||||
// size is abnormal, destroy chart
|
||||
if (!canRender()) {
|
||||
await destroy();
|
||||
await destroy()
|
||||
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
// resize chart
|
||||
if (isRendered()) {
|
||||
resize();
|
||||
resize()
|
||||
}
|
||||
|
||||
// render chart
|
||||
await render();
|
||||
await render()
|
||||
|
||||
if (chart) {
|
||||
await onUpdated?.(chart);
|
||||
await onUpdated?.(chart)
|
||||
}
|
||||
}
|
||||
|
||||
scope.run(() => {
|
||||
watch([width, height], ([newWidth, newHeight]) => {
|
||||
renderChartBySize(newWidth, newHeight);
|
||||
});
|
||||
renderChartBySize(newWidth, newHeight)
|
||||
})
|
||||
|
||||
watch(darkMode, () => {
|
||||
changeTheme();
|
||||
});
|
||||
});
|
||||
changeTheme()
|
||||
})
|
||||
})
|
||||
|
||||
onScopeDispose(() => {
|
||||
destroy();
|
||||
scope.stop();
|
||||
});
|
||||
destroy()
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
return {
|
||||
domRef,
|
||||
updateOptions,
|
||||
setOptions
|
||||
};
|
||||
setOptions,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { AddPostParams, PostEditorForm } from '@/types/post'
|
||||
import { httpGet, httpPostJson } from '@/request/utils'
|
||||
|
||||
export function addPostApi(params: AddPostParams) {
|
||||
return httpPostJson<PostEditorForm>('/post/add', params)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { PostTag } from '@/types/post/tag'
|
||||
import { httpGet, httpPostJson } from '@/request/utils'
|
||||
|
||||
export function getPostTagApi() {
|
||||
return httpGet<PostTag[]>('/post/tag')
|
||||
}
|
||||
@@ -31,6 +31,7 @@ declare module 'vue' {
|
||||
'IIc:outlineSettings': typeof import('~icons/ic/outline-settings')['default']
|
||||
'IIc:twotoneHeadphones': typeof import('~icons/ic/twotone-headphones')['default']
|
||||
'IIconoir:infoCircleSolid': typeof import('~icons/iconoir/info-circle-solid')['default']
|
||||
ImageUploadCard: typeof import('./../components/ImageUploadCard.vue')['default']
|
||||
'IMajesticons:settingsCogLine': typeof import('~icons/majesticons/settings-cog-line')['default']
|
||||
'IMaterialSymbols:call': typeof import('~icons/material-symbols/call')['default']
|
||||
'IMaterialSymbols:chatBubbleOutline': typeof import('~icons/material-symbols/chat-bubble-outline')['default']
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export interface PostEditorForm {
|
||||
id: number
|
||||
title: string
|
||||
content: string
|
||||
summary: string
|
||||
cover: string
|
||||
tags: string[]
|
||||
status: number
|
||||
publishTime: string
|
||||
updateTime: string
|
||||
}
|
||||
|
||||
export interface AddPostParams {
|
||||
title: string
|
||||
content: string
|
||||
summary: string
|
||||
cover: string
|
||||
status: number
|
||||
tags: string[]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface PostTag {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
@@ -149,10 +149,12 @@ export interface UploadToAliyunResponse {
|
||||
fileSize: string
|
||||
}
|
||||
|
||||
export function uploadToAliyun(blob: Blob, fileName: string, fileSize: string, onProgress?: (percent: number) => void): Promise<UploadToAliyunResponse> {
|
||||
export function uploadToAliyun(blob: Blob, fileName: string, fileSize: string, showProgress: boolean = true, onProgress?: (percent: number) => void): Promise<UploadToAliyunResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 在开始上传时显示上传进度
|
||||
showUploadProgress('uploading')
|
||||
if (showProgress) {
|
||||
showUploadProgress('uploading')
|
||||
}
|
||||
|
||||
getAliyunKeyApi()
|
||||
.then(async (res) => {
|
||||
@@ -177,7 +179,9 @@ export function uploadToAliyun(blob: Blob, fileName: string, fileSize: string, o
|
||||
if (event.lengthComputable && onProgress) {
|
||||
const percent = (event.loaded / event.total) * 100
|
||||
onProgress(Number.parseFloat(percent.toFixed(2))) // 保留两位小数
|
||||
updateUploadProgress(percent) // 更新进度条
|
||||
if (showProgress) {
|
||||
updateUploadProgress(percent) // 更新进度条
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,12 +195,13 @@ export function uploadToAliyun(blob: Blob, fileName: string, fileSize: string, o
|
||||
fileMapName: allFilePath,
|
||||
})
|
||||
|
||||
updateUploadProgress(100) // 上传完毕
|
||||
ElMessage.success('上传成功')
|
||||
|
||||
setTimeout(() => {
|
||||
closeUploadProgress() // 关闭上传进度条
|
||||
}, 500)
|
||||
if (showProgress) {
|
||||
updateUploadProgress(100) // 上传完毕
|
||||
ElMessage.success('上传成功')
|
||||
setTimeout(() => {
|
||||
closeUploadProgress() // 关闭上传进度条
|
||||
}, 500)
|
||||
}
|
||||
|
||||
resolve({
|
||||
url: allFilePath,
|
||||
@@ -205,36 +210,42 @@ export function uploadToAliyun(blob: Blob, fileName: string, fileSize: string, o
|
||||
})
|
||||
}
|
||||
else {
|
||||
updateUploadProgress(0, 'failed') // 上传失败
|
||||
ElMessage.error('上传失败,请稍后再试')
|
||||
if (showProgress) {
|
||||
updateUploadProgress(0, 'failed') // 上传失败
|
||||
ElMessage.error('上传失败,请稍后再试')
|
||||
|
||||
setTimeout(() => {
|
||||
closeUploadProgress() // 关闭上传进度条
|
||||
}, 500)
|
||||
setTimeout(() => {
|
||||
closeUploadProgress() // 关闭上传进度条
|
||||
}, 500)
|
||||
}
|
||||
|
||||
reject(new Error(`OSS上传失败: ${xhr.responseText}`))
|
||||
}
|
||||
}
|
||||
|
||||
xhr.onerror = () => {
|
||||
updateUploadProgress(0, 'failed') // 上传失败
|
||||
ElMessage.error('上传失败,请稍后再试')
|
||||
if (showProgress) {
|
||||
updateUploadProgress(0, 'failed') // 上传失败
|
||||
ElMessage.error('上传失败,请稍后再试')
|
||||
|
||||
setTimeout(() => {
|
||||
closeUploadProgress() // 关闭上传进度条
|
||||
}, 500)
|
||||
setTimeout(() => {
|
||||
closeUploadProgress() // 关闭上传进度条
|
||||
}, 500)
|
||||
}
|
||||
reject(new Error(`OSS上传异常: ${xhr.responseText}`))
|
||||
}
|
||||
|
||||
xhr.send(formData)
|
||||
}
|
||||
catch (error) {
|
||||
updateUploadProgress(0, 'failed') // 上传失败
|
||||
ElMessage.error('上传失败,请稍后再试')
|
||||
if (showProgress) {
|
||||
updateUploadProgress(0, 'failed') // 上传失败
|
||||
ElMessage.error('上传失败,请稍后再试')
|
||||
|
||||
setTimeout(() => {
|
||||
closeUploadProgress() // 关闭上传进度条
|
||||
}, 500)
|
||||
setTimeout(() => {
|
||||
closeUploadProgress() // 关闭上传进度条
|
||||
}, 500)
|
||||
}
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,10 +1,324 @@
|
||||
<script lang="ts" setup>
|
||||
import type { FormInstance } from 'element-plus'
|
||||
import type { PostEditorForm } from '@/types/post'
|
||||
import type { PostTag } from '@/types/post/tag'
|
||||
import type { UploadImageResponse } from '@/utils/file'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import CustomLoading from '@/components/CustomLoading.vue'
|
||||
import ImageUploadCard from '@/components/ImageUploadCard.vue'
|
||||
import WangEditor from '@/components/wang-editor/index.vue'
|
||||
import { addPostApi } from '@/request/api/post'
|
||||
import { getPostTagApi } from '@/request/api/post/tag'
|
||||
import { uploadImage, uploadToAliyun } from '@/utils/file'
|
||||
|
||||
const wangEditorRef = ref<any>(null)
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
const loading = reactive({
|
||||
init: true,
|
||||
confirm: false,
|
||||
})
|
||||
|
||||
const showPushDrawer = ref<boolean>(false)
|
||||
const imageUpload = ref<UploadImageResponse | null>(null)
|
||||
|
||||
const tagOptions = ref<PostTag[]>([])
|
||||
const statusOptions = [
|
||||
{
|
||||
label: '草稿',
|
||||
value: 0,
|
||||
},
|
||||
{
|
||||
label: '发布',
|
||||
value: 1,
|
||||
},
|
||||
]
|
||||
|
||||
const oldForm = ref<PostEditorForm>({
|
||||
id: 0,
|
||||
title: '',
|
||||
content: '',
|
||||
summary: '',
|
||||
cover: '',
|
||||
tags: [],
|
||||
publishTime: '',
|
||||
updateTime: '',
|
||||
status: 0,
|
||||
})
|
||||
|
||||
const form = ref<PostEditorForm>({
|
||||
id: 0,
|
||||
title: '',
|
||||
content: '',
|
||||
summary: '',
|
||||
cover: '',
|
||||
tags: [],
|
||||
publishTime: '',
|
||||
updateTime: '',
|
||||
status: 0,
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
title: [
|
||||
{ required: true, message: '文章标题不能为空', trigger: 'blur' },
|
||||
],
|
||||
summary: [
|
||||
{ required: true, message: '摘要不能为空', trigger: 'blur' },
|
||||
],
|
||||
status: [
|
||||
{ required: true, message: '状态不能为空', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
const isUpdate = computed(() => form.value.id !== 0)
|
||||
|
||||
onMounted(() => {
|
||||
loading.init = false
|
||||
|
||||
oldForm.value = JSON.parse(JSON.stringify(form.value))
|
||||
})
|
||||
|
||||
async function handleShowPushDrawer() {
|
||||
loading.init = true
|
||||
showPushDrawer.value = true
|
||||
|
||||
if (!isUpdate.value) {
|
||||
form.value.summary = (wangEditorRef.value.getText() || '').slice(0, 100)
|
||||
}
|
||||
|
||||
await getPostTagApi().then((res) => {
|
||||
if (res.isSuccess) {
|
||||
tagOptions.value = res.data
|
||||
}
|
||||
else {
|
||||
ElMessage.error(res.message || '获取帖子标签失败,请稍后再试')
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.log(err)
|
||||
ElMessage.error('获取帖子标签失败,请稍后再试')
|
||||
})
|
||||
|
||||
loading.init = false
|
||||
}
|
||||
|
||||
async function handleSave(formEl: FormInstance | undefined) {
|
||||
if (!formEl)
|
||||
return
|
||||
|
||||
await formEl.validate(async (valid) => {
|
||||
if (!valid)
|
||||
return
|
||||
|
||||
if (form.value.content === '') {
|
||||
ElMessage.warning('文章内容不能为空')
|
||||
return
|
||||
}
|
||||
|
||||
loading.confirm = true
|
||||
|
||||
if (form.value.cover !== oldForm.value.cover) {
|
||||
if (form.value.cover && imageUpload.value !== null) {
|
||||
const aliyunRes = await uploadToAliyun(imageUpload.value.blob, imageUpload.value.name, imageUpload.value.size, false)
|
||||
form.value.cover = aliyunRes.url
|
||||
}
|
||||
}
|
||||
|
||||
addPostApi({
|
||||
title: form.value.title,
|
||||
content: form.value.content,
|
||||
summary: form.value.summary,
|
||||
status: form.value.status,
|
||||
cover: form.value.cover,
|
||||
tags: form.value.tags,
|
||||
}).then((res) => {
|
||||
if (res.isSuccess) {
|
||||
console.log(res)
|
||||
}
|
||||
else {
|
||||
ElMessage.error(res.message || '保存失败,请稍后再试')
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.log(err)
|
||||
ElMessage.error('保存失败,请稍后再试')
|
||||
}).finally(() => {
|
||||
loading.confirm = false
|
||||
|
||||
showPushDrawer.value = false
|
||||
})
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="" />
|
||||
<div class="editor-container">
|
||||
<div class="toolbar">
|
||||
<input v-model="form.title" placeholder="输入标题" class="editor-title-input" maxlength="100">
|
||||
<div class="toolbar-extra">
|
||||
<div v-if="form.publishTime" class="time">
|
||||
更新于:{{ form.publishTime }}
|
||||
</div>
|
||||
<el-button type="primary" @click="handleShowPushDrawer()">
|
||||
发布
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<WangEditor ref="wangEditorRef" v-model="form.content" :show-direcotry="true" />
|
||||
</div>
|
||||
|
||||
<el-drawer v-model="showPushDrawer" title="发布文章">
|
||||
<CustomLoading v-if="loading.init" :in-container="true" />
|
||||
|
||||
<el-form
|
||||
v-else
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="formRules"
|
||||
label-width="auto"
|
||||
>
|
||||
<el-form-item label="文章标题" prop="title">
|
||||
<el-input
|
||||
v-model="form.title"
|
||||
placeholder="请输入文字标题"
|
||||
size="large"
|
||||
show-word-limit
|
||||
maxlength="100"
|
||||
type="textarea"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="标签" prop="tags">
|
||||
<el-select v-model="form.tags" placeholder="请选择标签">
|
||||
<el-option
|
||||
v-for="item in tagOptions"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.name"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="文章封面 " prop="cover">
|
||||
<ImageUploadCard
|
||||
v-model="form.cover"
|
||||
@change="(data) => {
|
||||
imageUpload = data
|
||||
}"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="文章摘要" prop="summary">
|
||||
<el-input
|
||||
v-model="form.summary"
|
||||
placeholder="请输入文章摘要"
|
||||
size="large"
|
||||
show-word-limit
|
||||
maxlength="100"
|
||||
type="textarea"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="form.status" placeholder="请选择状态">
|
||||
<el-option
|
||||
v-for="item in statusOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<div style="flex: auto">
|
||||
<el-button>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="loading.confirm" @click="handleSave(formRef)">
|
||||
确认并发布
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
//
|
||||
.editor-container {
|
||||
padding: 16px 32px;
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
.editor-title-input {
|
||||
width: 1000px;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
padding: 8px 0px;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
resize: none;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.toolbar-extra {
|
||||
.time {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.upload-card {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #fafafa;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.3s ease;
|
||||
padding: 4px;
|
||||
&:hover {
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
&.loading {
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.upload-inner {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.upload-loading-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.upload-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-cover {
|
||||
height: 192px;
|
||||
width: 128px;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user