This commit is contained in:
oneao committed 2025-12-22 22:30:20 +08:00
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>