web-上传组件、富文本组件
This commit is contained in:
9
schoolNewsWeb/src/apis/resource/index.ts
Normal file
9
schoolNewsWeb/src/apis/resource/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* @description 资源相关API接口导出
|
||||||
|
* @author yslg
|
||||||
|
* @since 2025-10-15
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './resourceCategory';
|
||||||
|
export * from './resourceTag';
|
||||||
|
export * from './resource';
|
||||||
206
schoolNewsWeb/src/apis/resource/resource.ts
Normal file
206
schoolNewsWeb/src/apis/resource/resource.ts
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* @description 资源API接口
|
||||||
|
* @filename resource.ts
|
||||||
|
* @author yslg
|
||||||
|
* @copyright xyzh
|
||||||
|
* @since 2025-10-15
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { api } from '@/apis';
|
||||||
|
import type { ResultDomain, Resource, ResourceSearchParams } from '@/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 资源API服务
|
||||||
|
*/
|
||||||
|
export const resourceApi = {
|
||||||
|
// ==================== 资源基础操作 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取资源列表
|
||||||
|
* @param filter 筛选条件
|
||||||
|
* @returns Promise<ResultDomain<Resource>>
|
||||||
|
*/
|
||||||
|
async getResourceList(filter?: ResourceSearchParams): Promise<ResultDomain<Resource>> {
|
||||||
|
const response = await api.get<Resource>('/news/resources/list', filter);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID获取资源详情
|
||||||
|
* @param resourceID 资源ID
|
||||||
|
* @returns Promise<ResultDomain<Resource>>
|
||||||
|
*/
|
||||||
|
async getResourceById(resourceID: string): Promise<ResultDomain<Resource>> {
|
||||||
|
const response = await api.get<Resource>(`/news/resources/resource/${resourceID}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建资源
|
||||||
|
* @param resource 资源信息
|
||||||
|
* @returns Promise<ResultDomain<Resource>>
|
||||||
|
*/
|
||||||
|
async createResource(resource: Resource): Promise<ResultDomain<Resource>> {
|
||||||
|
const response = await api.post<Resource>('/news/resources/resource', resource);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新资源
|
||||||
|
* @param resource 资源信息
|
||||||
|
* @returns Promise<ResultDomain<Resource>>
|
||||||
|
*/
|
||||||
|
async updateResource(resource: Resource): Promise<ResultDomain<Resource>> {
|
||||||
|
const response = await api.put<Resource>('/news/resources/resource', resource);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除资源
|
||||||
|
* @param resourceID 资源ID
|
||||||
|
* @returns Promise<ResultDomain<boolean>>
|
||||||
|
*/
|
||||||
|
async deleteResource(resourceID: string): Promise<ResultDomain<boolean>> {
|
||||||
|
const response = await api.delete<boolean>(`/news/resources/resource/${resourceID}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 资源状态操作 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新资源状态
|
||||||
|
* @param resourceID 资源ID
|
||||||
|
* @param status 状态值(0草稿 1已发布 2下架)
|
||||||
|
* @returns Promise<ResultDomain<Resource>>
|
||||||
|
*/
|
||||||
|
async updateResourceStatus(resourceID: string, status: number): Promise<ResultDomain<Resource>> {
|
||||||
|
const response = await api.put<Resource>(`/news/resources/resource/${resourceID}/status`, null, {
|
||||||
|
params: { status }
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布资源
|
||||||
|
* @param resourceID 资源ID
|
||||||
|
* @returns Promise<ResultDomain<Resource>>
|
||||||
|
*/
|
||||||
|
async publishResource(resourceID: string): Promise<ResultDomain<Resource>> {
|
||||||
|
const response = await api.post<Resource>(`/news/resources/resource/${resourceID}/publish`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下架资源
|
||||||
|
* @param resourceID 资源ID
|
||||||
|
* @returns Promise<ResultDomain<Resource>>
|
||||||
|
*/
|
||||||
|
async unpublishResource(resourceID: string): Promise<ResultDomain<Resource>> {
|
||||||
|
const response = await api.post<Resource>(`/news/resources/resource/${resourceID}/unpublish`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 资源统计操作 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 增加浏览次数
|
||||||
|
* @param resourceID 资源ID
|
||||||
|
* @returns Promise<ResultDomain<Resource>>
|
||||||
|
*/
|
||||||
|
async incrementViewCount(resourceID: string): Promise<ResultDomain<Resource>> {
|
||||||
|
const response = await api.post<Resource>(`/news/resources/resource/${resourceID}/view`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 增加点赞次数
|
||||||
|
* @param resourceID 资源ID
|
||||||
|
* @returns Promise<ResultDomain<Resource>>
|
||||||
|
*/
|
||||||
|
async incrementLikeCount(resourceID: string): Promise<ResultDomain<Resource>> {
|
||||||
|
const response = await api.post<Resource>(`/news/resources/resource/${resourceID}/like`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 增加收藏次数
|
||||||
|
* @param resourceID 资源ID
|
||||||
|
* @returns Promise<ResultDomain<Resource>>
|
||||||
|
*/
|
||||||
|
async incrementCollectCount(resourceID: string): Promise<ResultDomain<Resource>> {
|
||||||
|
const response = await api.post<Resource>(`/news/resources/resource/${resourceID}/collect`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 资源推荐和轮播操作 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置资源推荐
|
||||||
|
* @param resourceID 资源ID
|
||||||
|
* @param isRecommend 是否推荐
|
||||||
|
* @returns Promise<ResultDomain<Resource>>
|
||||||
|
*/
|
||||||
|
async setResourceRecommend(resourceID: string, isRecommend: boolean): Promise<ResultDomain<Resource>> {
|
||||||
|
const response = await api.put<Resource>(`/news/resources/resource/${resourceID}/recommend`, null, {
|
||||||
|
params: { isRecommend }
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置资源轮播
|
||||||
|
* @param resourceID 资源ID
|
||||||
|
* @param isBanner 是否轮播
|
||||||
|
* @returns Promise<ResultDomain<Resource>>
|
||||||
|
*/
|
||||||
|
async setResourceBanner(resourceID: string, isBanner: boolean): Promise<ResultDomain<Resource>> {
|
||||||
|
const response = await api.put<Resource>(`/news/resources/resource/${resourceID}/banner`, null, {
|
||||||
|
params: { isBanner }
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 资源查询操作 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取推荐资源列表
|
||||||
|
* @param limit 限制数量
|
||||||
|
* @returns Promise<ResultDomain<Resource>>
|
||||||
|
*/
|
||||||
|
async getRecommendResources(limit?: number): Promise<ResultDomain<Resource>> {
|
||||||
|
const response = await api.get<Resource>('/news/resources/recommend', { limit });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取轮播资源列表
|
||||||
|
* @param limit 限制数量
|
||||||
|
* @returns Promise<ResultDomain<Resource>>
|
||||||
|
*/
|
||||||
|
async getBannerResources(limit?: number): Promise<ResultDomain<Resource>> {
|
||||||
|
const response = await api.get<Resource>('/news/resources/banner', { limit });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索资源
|
||||||
|
* @param keyword 搜索关键词
|
||||||
|
* @param categoryID 分类ID(可选)
|
||||||
|
* @param status 状态(可选)
|
||||||
|
* @returns Promise<ResultDomain<Resource>>
|
||||||
|
*/
|
||||||
|
async searchResources(
|
||||||
|
keyword: string,
|
||||||
|
categoryID?: string,
|
||||||
|
status?: number
|
||||||
|
): Promise<ResultDomain<Resource>> {
|
||||||
|
const params: any = { keyword };
|
||||||
|
if (categoryID) params.categoryID = categoryID;
|
||||||
|
if (status !== undefined) params.status = status;
|
||||||
|
|
||||||
|
const response = await api.get<Resource>('/news/resources/search', params);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default resourceApi;
|
||||||
98
schoolNewsWeb/src/apis/resource/resourceCategory.ts
Normal file
98
schoolNewsWeb/src/apis/resource/resourceCategory.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* @description 资源分类API接口
|
||||||
|
* @filename resourceCategory.ts
|
||||||
|
* @author yslg
|
||||||
|
* @copyright xyzh
|
||||||
|
* @since 2025-10-15
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { api } from '@/apis';
|
||||||
|
import type { ResultDomain, ResourceCategory } from '@/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 资源分类API服务
|
||||||
|
*/
|
||||||
|
export const resourceCategoryApi = {
|
||||||
|
/**
|
||||||
|
* 获取分类列表
|
||||||
|
* @returns Promise<ResultDomain<ResourceCategory>>
|
||||||
|
*/
|
||||||
|
async getCategoryList(): Promise<ResultDomain<ResourceCategory>> {
|
||||||
|
const response = await api.get<ResourceCategory>('/news/categorys/list');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID获取分类详情
|
||||||
|
* @param categoryID 分类ID
|
||||||
|
* @returns Promise<ResultDomain<ResourceCategory>>
|
||||||
|
*/
|
||||||
|
async getCategoryById(categoryID: string): Promise<ResultDomain<ResourceCategory>> {
|
||||||
|
const response = await api.get<ResourceCategory>(`/news/categorys/category/${categoryID}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建分类
|
||||||
|
* @param category 分类信息
|
||||||
|
* @returns Promise<ResultDomain<ResourceCategory>>
|
||||||
|
*/
|
||||||
|
async createCategory(category: ResourceCategory): Promise<ResultDomain<ResourceCategory>> {
|
||||||
|
const response = await api.post<ResourceCategory>('/news/categorys/category', category);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新分类
|
||||||
|
* @param category 分类信息
|
||||||
|
* @returns Promise<ResultDomain<ResourceCategory>>
|
||||||
|
*/
|
||||||
|
async updateCategory(category: ResourceCategory): Promise<ResultDomain<ResourceCategory>> {
|
||||||
|
const response = await api.put<ResourceCategory>('/news/categorys/category', category);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除分类
|
||||||
|
* @param categoryID 分类ID
|
||||||
|
* @returns Promise<ResultDomain<boolean>>
|
||||||
|
*/
|
||||||
|
async deleteCategory(categoryID: string): Promise<ResultDomain<boolean>> {
|
||||||
|
const response = await api.delete<boolean>(`/news/categorys/category/${categoryID}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新分类状态
|
||||||
|
* @param categoryID 分类ID
|
||||||
|
* @param status 状态值
|
||||||
|
* @returns Promise<ResultDomain<ResourceCategory>>
|
||||||
|
*/
|
||||||
|
async updateCategoryStatus(categoryID: string, status: number): Promise<ResultDomain<ResourceCategory>> {
|
||||||
|
const response = await api.put<ResourceCategory>(`/news/categorys/category/${categoryID}/status`, null, {
|
||||||
|
params: { status }
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取分类树
|
||||||
|
* @returns Promise<ResultDomain<ResourceCategory>>
|
||||||
|
*/
|
||||||
|
async getCategoryTree(): Promise<ResultDomain<ResourceCategory>> {
|
||||||
|
const response = await api.get<ResourceCategory>('/news/categorys/tree');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取子分类
|
||||||
|
* @param parentID 父分类ID
|
||||||
|
* @returns Promise<ResultDomain<ResourceCategory>>
|
||||||
|
*/
|
||||||
|
async getChildCategories(parentID: string): Promise<ResultDomain<ResourceCategory>> {
|
||||||
|
const response = await api.get<ResourceCategory>(`/news/categorys/category/${parentID}/children`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default resourceCategoryApi;
|
||||||
143
schoolNewsWeb/src/apis/resource/resourceTag.ts
Normal file
143
schoolNewsWeb/src/apis/resource/resourceTag.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* @description 标签API接口
|
||||||
|
* @filename resourceTag.ts
|
||||||
|
* @author yslg
|
||||||
|
* @copyright xyzh
|
||||||
|
* @since 2025-10-15
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { api } from '@/apis';
|
||||||
|
import type { ResultDomain, Tag, ResourceTag } from '@/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标签API服务
|
||||||
|
*/
|
||||||
|
export const resourceTagApi = {
|
||||||
|
// ==================== 标签基础操作 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取标签列表
|
||||||
|
* @returns Promise<ResultDomain<Tag>>
|
||||||
|
*/
|
||||||
|
async getTagList(): Promise<ResultDomain<Tag>> {
|
||||||
|
const response = await api.get<Tag>('/news/tags/list');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID获取标签详情
|
||||||
|
* @param tagID 标签ID
|
||||||
|
* @returns Promise<ResultDomain<Tag>>
|
||||||
|
*/
|
||||||
|
async getTagById(tagID: string): Promise<ResultDomain<Tag>> {
|
||||||
|
const response = await api.get<Tag>(`/news/tags/tag/${tagID}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建标签
|
||||||
|
* @param tag 标签信息
|
||||||
|
* @returns Promise<ResultDomain<Tag>>
|
||||||
|
*/
|
||||||
|
async createTag(tag: Tag): Promise<ResultDomain<Tag>> {
|
||||||
|
const response = await api.post<Tag>('/news/tags/tag', tag);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新标签
|
||||||
|
* @param tag 标签信息
|
||||||
|
* @returns Promise<ResultDomain<Tag>>
|
||||||
|
*/
|
||||||
|
async updateTag(tag: Tag): Promise<ResultDomain<Tag>> {
|
||||||
|
const response = await api.put<Tag>('/news/tags/tag', tag);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除标签
|
||||||
|
* @param tagID 标签ID
|
||||||
|
* @returns Promise<ResultDomain<boolean>>
|
||||||
|
*/
|
||||||
|
async deleteTag(tagID: string): Promise<ResultDomain<boolean>> {
|
||||||
|
const response = await api.delete<boolean>(`/news/tags/tag/${tagID}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索标签
|
||||||
|
* @param keyword 搜索关键词
|
||||||
|
* @returns Promise<ResultDomain<Tag>>
|
||||||
|
*/
|
||||||
|
async searchTags(keyword: string): Promise<ResultDomain<Tag>> {
|
||||||
|
const response = await api.get<Tag>('/news/tags/search', { keyword });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 资源标签关联操作 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取资源的标签列表
|
||||||
|
* @param resourceID 资源ID
|
||||||
|
* @returns Promise<ResultDomain<Tag>>
|
||||||
|
*/
|
||||||
|
async getResourceTags(resourceID: string): Promise<ResultDomain<Tag>> {
|
||||||
|
const response = await api.get<Tag>(`/news/tags/resource/${resourceID}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为资源添加单个标签
|
||||||
|
* @param resourceID 资源ID
|
||||||
|
* @param tagID 标签ID
|
||||||
|
* @returns Promise<ResultDomain<ResourceTag>>
|
||||||
|
*/
|
||||||
|
async addResourceTag(resourceID: string, tagID: string): Promise<ResultDomain<ResourceTag>> {
|
||||||
|
const response = await api.post<ResourceTag>(`/news/tags/resource/${resourceID}/tag/${tagID}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量为资源添加标签
|
||||||
|
* @param resourceID 资源ID
|
||||||
|
* @param tagIDs 标签ID列表
|
||||||
|
* @returns Promise<ResultDomain<ResourceTag>>
|
||||||
|
*/
|
||||||
|
async batchAddResourceTags(resourceID: string, tagIDs: string): Promise<ResultDomain<ResourceTag>> {
|
||||||
|
const response = await api.post<ResourceTag>(`/news/tags/resource/${resourceID}/tags`, tagIDs);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除资源的标签
|
||||||
|
* @param resourceID 资源ID
|
||||||
|
* @param tagID 标签ID
|
||||||
|
* @returns Promise<ResultDomain<boolean>>
|
||||||
|
*/
|
||||||
|
async removeResourceTag(resourceID: string, tagID: string): Promise<ResultDomain<boolean>> {
|
||||||
|
const response = await api.delete<boolean>(`/news/tags/resource/${resourceID}/tag/${tagID}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空资源的所有标签
|
||||||
|
* @param resourceID 资源ID
|
||||||
|
* @returns Promise<ResultDomain<boolean>>
|
||||||
|
*/
|
||||||
|
async clearResourceTags(resourceID: string): Promise<ResultDomain<boolean>> {
|
||||||
|
const response = await api.delete<boolean>(`/news/tags/resource/${resourceID}/tags`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据标签获取资源列表
|
||||||
|
* @param tagID 标签ID
|
||||||
|
* @returns Promise<ResultDomain<string>>
|
||||||
|
*/
|
||||||
|
async getResourcesByTag(tagID: string): Promise<ResultDomain<string>> {
|
||||||
|
const response = await api.get<string>(`/news/tags/tag/${tagID}/resources`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default resourceTagApi;
|
||||||
@@ -1,5 +1,91 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div v-if="!asDialog" class="upload-container">
|
||||||
|
<div
|
||||||
|
class="upload-area"
|
||||||
|
:class="{ 'is-dragover': isDragover, 'is-disabled': uploading }"
|
||||||
|
@click="handleClickUpload"
|
||||||
|
@drop.prevent="handleDrop"
|
||||||
|
@dragover.prevent="handleDragOver"
|
||||||
|
@dragleave.prevent="handleDragLeave"
|
||||||
|
>
|
||||||
|
<div class="upload-icon">📁</div>
|
||||||
|
<div class="upload-text">
|
||||||
|
将文件拖到此处,或<span class="link-text">点击上传</span>
|
||||||
|
</div>
|
||||||
|
<div class="upload-tip">
|
||||||
|
{{ tip || `支持 ${accept || '所有'} 格式,单个文件不超过 ${maxSize}MB` }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 隐藏的文件输入框 -->
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
:accept="accept"
|
||||||
|
:multiple="multiple"
|
||||||
|
@change="handleFileSelect"
|
||||||
|
style="display: none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 文件列表 -->
|
||||||
|
<div v-if="selectedFiles.length > 0" class="file-list">
|
||||||
|
<div v-for="(file, index) in selectedFiles" :key="index" class="file-item">
|
||||||
|
<!-- 文件预览 -->
|
||||||
|
<div class="file-preview">
|
||||||
|
<img
|
||||||
|
v-if="isImageFile(file)"
|
||||||
|
:src="getFilePreviewUrl(file)"
|
||||||
|
class="preview-image"
|
||||||
|
@click="showImagePreview(file)"
|
||||||
|
/>
|
||||||
|
<div v-else class="preview-icon">
|
||||||
|
<span class="file-type-icon">{{ getFileTypeIcon(file) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 文件信息 -->
|
||||||
|
<div class="file-info">
|
||||||
|
<span class="file-name">{{ file.name }}</span>
|
||||||
|
<span class="file-size">{{ formatFileSize(file.size) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<div class="file-actions">
|
||||||
|
<el-button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
@click="previewFile(file)"
|
||||||
|
:disabled="uploading"
|
||||||
|
>
|
||||||
|
预览
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
@click="removeFile(index)"
|
||||||
|
:disabled="uploading"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 上传按钮 -->
|
||||||
|
<div v-if="selectedFiles.length > 0" class="upload-actions">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
@click="handleUpload"
|
||||||
|
:loading="uploading"
|
||||||
|
:disabled="selectedFiles.length === 0"
|
||||||
|
>
|
||||||
|
{{ uploading ? '上传中...' : '确定上传' }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
|
v-else
|
||||||
v-model="visible"
|
v-model="visible"
|
||||||
:title="title"
|
:title="title"
|
||||||
width="600px"
|
width="600px"
|
||||||
@@ -37,8 +123,35 @@
|
|||||||
<!-- 文件列表 -->
|
<!-- 文件列表 -->
|
||||||
<div v-if="selectedFiles.length > 0" class="file-list">
|
<div v-if="selectedFiles.length > 0" class="file-list">
|
||||||
<div v-for="(file, index) in selectedFiles" :key="index" class="file-item">
|
<div v-for="(file, index) in selectedFiles" :key="index" class="file-item">
|
||||||
|
<!-- 文件预览 -->
|
||||||
|
<div class="file-preview">
|
||||||
|
<img
|
||||||
|
v-if="isImageFile(file)"
|
||||||
|
:src="getFilePreviewUrl(file)"
|
||||||
|
class="preview-image"
|
||||||
|
@click="showImagePreview(file)"
|
||||||
|
/>
|
||||||
|
<div v-else class="preview-icon">
|
||||||
|
<span class="file-type-icon">{{ getFileTypeIcon(file) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 文件信息 -->
|
||||||
|
<div class="file-info">
|
||||||
<span class="file-name">{{ file.name }}</span>
|
<span class="file-name">{{ file.name }}</span>
|
||||||
<span class="file-size">{{ formatFileSize(file.size) }}</span>
|
<span class="file-size">{{ formatFileSize(file.size) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<div class="file-actions">
|
||||||
|
<el-button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
@click="previewFile(file)"
|
||||||
|
:disabled="uploading"
|
||||||
|
>
|
||||||
|
预览
|
||||||
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
type="text"
|
type="text"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -50,6 +163,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="handleClose" :disabled="uploading">取消</el-button>
|
<el-button @click="handleClose" :disabled="uploading">取消</el-button>
|
||||||
@@ -63,6 +177,43 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 图片预览对话框 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="imagePreviewVisible"
|
||||||
|
title="图片预览"
|
||||||
|
width="80%"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
>
|
||||||
|
<div class="image-preview-container">
|
||||||
|
<img :src="previewImageUrl" class="preview-large-image" />
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 文件预览对话框 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="filePreviewVisible"
|
||||||
|
title="文件预览"
|
||||||
|
width="80%"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
>
|
||||||
|
<div class="file-preview-container">
|
||||||
|
<div v-if="previewFileType === 'image'" class="image-preview">
|
||||||
|
<img :src="previewFileUrl" class="preview-large-image" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="previewFileType === 'text'" class="text-preview">
|
||||||
|
<pre>{{ previewTextContent }}</pre>
|
||||||
|
</div>
|
||||||
|
<div v-else class="unsupported-preview">
|
||||||
|
<div class="preview-icon-large">
|
||||||
|
<span class="file-type-icon-large">{{ currentPreviewFile ? getFileTypeIcon(currentPreviewFile) : '📄' }}</span>
|
||||||
|
</div>
|
||||||
|
<p>该文件类型不支持预览</p>
|
||||||
|
<p>文件名:{{ currentPreviewFile?.name }}</p>
|
||||||
|
<p>文件大小:{{ currentPreviewFile ? formatFileSize(currentPreviewFile.size) : '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -80,6 +231,7 @@ interface Props {
|
|||||||
module?: string;
|
module?: string;
|
||||||
businessId?: string;
|
businessId?: string;
|
||||||
tip?: string;
|
tip?: string;
|
||||||
|
asDialog?: boolean; // 是否作为弹窗使用
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@@ -89,7 +241,8 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
maxSize: 10,
|
maxSize: 10,
|
||||||
multiple: false,
|
multiple: false,
|
||||||
module: 'common',
|
module: 'common',
|
||||||
tip: ''
|
tip: '',
|
||||||
|
asDialog: true
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -104,10 +257,19 @@ const uploading = ref(false);
|
|||||||
const isDragover = ref(false);
|
const isDragover = ref(false);
|
||||||
|
|
||||||
const visible = computed({
|
const visible = computed({
|
||||||
get: () => props.modelValue,
|
get: () => props.asDialog ? props.modelValue : false,
|
||||||
set: (val) => emit('update:modelValue', val)
|
set: (val) => props.asDialog ? emit('update:modelValue', val) : undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 预览相关
|
||||||
|
const imagePreviewVisible = ref(false);
|
||||||
|
const filePreviewVisible = ref(false);
|
||||||
|
const previewImageUrl = ref('');
|
||||||
|
const previewFileUrl = ref('');
|
||||||
|
const previewFileType = ref('');
|
||||||
|
const previewTextContent = ref('');
|
||||||
|
const currentPreviewFile = ref<File | null>(null);
|
||||||
|
|
||||||
// 点击上传区域
|
// 点击上传区域
|
||||||
function handleClickUpload() {
|
function handleClickUpload() {
|
||||||
if (uploading.value) return;
|
if (uploading.value) return;
|
||||||
@@ -203,6 +365,65 @@ function formatFileSize(bytes: number): string {
|
|||||||
return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i];
|
return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 判断是否为图片文件
|
||||||
|
function isImageFile(file: File): boolean {
|
||||||
|
return file.type.startsWith('image/');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取文件预览URL
|
||||||
|
function getFilePreviewUrl(file: File): string {
|
||||||
|
return URL.createObjectURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取文件类型图标
|
||||||
|
function getFileTypeIcon(file: File): string {
|
||||||
|
const extension = file.name.split('.').pop()?.toLowerCase();
|
||||||
|
const iconMap: Record<string, string> = {
|
||||||
|
'pdf': '📄',
|
||||||
|
'doc': '📝',
|
||||||
|
'docx': '📝',
|
||||||
|
'txt': '📄',
|
||||||
|
'zip': '📦',
|
||||||
|
'rar': '📦',
|
||||||
|
'mp4': '🎬',
|
||||||
|
'avi': '🎬',
|
||||||
|
'mp3': '🎵',
|
||||||
|
'wav': '🎵',
|
||||||
|
'xls': '📊',
|
||||||
|
'xlsx': '📊',
|
||||||
|
'ppt': '📊',
|
||||||
|
'pptx': '📊'
|
||||||
|
};
|
||||||
|
return iconMap[extension || ''] || '📄';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示图片预览
|
||||||
|
function showImagePreview(file: File) {
|
||||||
|
previewImageUrl.value = getFilePreviewUrl(file);
|
||||||
|
imagePreviewVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预览文件
|
||||||
|
async function previewFile(file: File) {
|
||||||
|
currentPreviewFile.value = file;
|
||||||
|
previewFileUrl.value = getFilePreviewUrl(file);
|
||||||
|
|
||||||
|
if (isImageFile(file)) {
|
||||||
|
previewFileType.value = 'image';
|
||||||
|
} else if (file.type.startsWith('text/')) {
|
||||||
|
previewFileType.value = 'text';
|
||||||
|
try {
|
||||||
|
previewTextContent.value = await file.text();
|
||||||
|
} catch (error) {
|
||||||
|
previewTextContent.value = '无法读取文件内容';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
previewFileType.value = 'unsupported';
|
||||||
|
}
|
||||||
|
|
||||||
|
filePreviewVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
// 上传文件
|
// 上传文件
|
||||||
async function handleUpload() {
|
async function handleUpload() {
|
||||||
if (selectedFiles.value.length === 0) {
|
if (selectedFiles.value.length === 0) {
|
||||||
@@ -249,7 +470,9 @@ async function handleUpload() {
|
|||||||
|
|
||||||
// 关闭对话框
|
// 关闭对话框
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
|
if (props.asDialog) {
|
||||||
visible.value = false;
|
visible.value = false;
|
||||||
|
}
|
||||||
selectedFiles.value = [];
|
selectedFiles.value = [];
|
||||||
isDragover.value = false;
|
isDragover.value = false;
|
||||||
uploading.value = false;
|
uploading.value = false;
|
||||||
@@ -257,8 +480,10 @@ function handleClose() {
|
|||||||
|
|
||||||
// 打开对话框
|
// 打开对话框
|
||||||
function open() {
|
function open() {
|
||||||
|
if (props.asDialog) {
|
||||||
visible.value = true;
|
visible.value = true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
open
|
open
|
||||||
@@ -355,4 +580,134 @@ defineExpose({
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
margin-right: 12px;
|
margin-right: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-preview {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
margin-right: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f5f7fa;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.preview-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-icon {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.file-type-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
color: #606266;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-size {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview-container {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
|
||||||
|
.preview-large-image {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 70vh;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview-container {
|
||||||
|
min-height: 300px;
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
|
||||||
|
.preview-large-image {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 70vh;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-preview {
|
||||||
|
pre {
|
||||||
|
background: #f5f7fa;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsupported-preview {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
|
||||||
|
.preview-icon-large {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
.file-type-icon-large {
|
||||||
|
font-size: 64px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 8px 0;
|
||||||
|
color: #606266;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-actions {
|
||||||
|
margin-top: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ import Quill from 'quill';
|
|||||||
import { FileUpload } from '@/components/file';
|
import { FileUpload } from '@/components/file';
|
||||||
import type { SysFile } from '@/types';
|
import type { SysFile } from '@/types';
|
||||||
import { FILE_DOWNLOAD_URL } from '@/config';
|
import { FILE_DOWNLOAD_URL } from '@/config';
|
||||||
import { ImageResize } from '@/utils/quill-resize';
|
import { registerImageResize } from '@/utils/quill-resize';
|
||||||
// Quill 样式已在 main.ts 中全局引入
|
// Quill 样式已在 main.ts 中全局引入
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -204,7 +204,10 @@ function initQuill() {
|
|||||||
const node = super.create() as HTMLVideoElement;
|
const node = super.create() as HTMLVideoElement;
|
||||||
node.setAttribute('src', value);
|
node.setAttribute('src', value);
|
||||||
node.setAttribute('controls', 'true');
|
node.setAttribute('controls', 'true');
|
||||||
node.setAttribute('style', 'max-width: 100%; display: block; margin: 12px auto;');
|
node.setAttribute('class', 'custom-video');
|
||||||
|
node.setAttribute('data-custom-video', 'true');
|
||||||
|
// 视频默认居中显示
|
||||||
|
node.setAttribute('style', 'max-width: 100%; display: block; margin: 0 auto;');
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +216,33 @@ function initQuill() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 自定义图片 Blot(与文字同行显示)
|
||||||
|
const InlineEmbed: any = Quill.import('blots/embed');
|
||||||
|
|
||||||
|
class CustomImageBlot extends InlineEmbed {
|
||||||
|
static blotName = 'customImage';
|
||||||
|
static tagName = 'img';
|
||||||
|
|
||||||
|
static create(value: string) {
|
||||||
|
const node = super.create() as HTMLImageElement;
|
||||||
|
node.setAttribute('src', value);
|
||||||
|
node.setAttribute('class', 'custom-image');
|
||||||
|
node.setAttribute('data-custom-image', 'true');
|
||||||
|
// 图片与文字同行显示
|
||||||
|
node.setAttribute('style', 'max-width: 100%; display: inline-block; vertical-align: bottom;');
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
static value(node: HTMLImageElement) {
|
||||||
|
return node.getAttribute('src');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Quill.register(VideoBlot);
|
Quill.register(VideoBlot);
|
||||||
|
Quill.register(CustomImageBlot);
|
||||||
|
|
||||||
|
// 注册图片/视频拉伸模块
|
||||||
|
registerImageResize();
|
||||||
|
|
||||||
// 配置选项
|
// 配置选项
|
||||||
const options = {
|
const options = {
|
||||||
@@ -249,7 +278,17 @@ function initQuill() {
|
|||||||
matchVisual: false
|
matchVisual: false
|
||||||
},
|
},
|
||||||
// 启用图片/视频缩放模块
|
// 启用图片/视频缩放模块
|
||||||
imageResize: ImageResize
|
imageResize: {
|
||||||
|
onResizeEnd: () => {
|
||||||
|
console.log('🔄 图片/视频拉伸结束,强制更新内容');
|
||||||
|
// 强制触发内容更新
|
||||||
|
if (quillInstance) {
|
||||||
|
const html = quillInstance.root.innerHTML;
|
||||||
|
emit('update:modelValue', html);
|
||||||
|
emit('change', html);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
placeholder: props.placeholder,
|
placeholder: props.placeholder,
|
||||||
readOnly: props.readOnly || props.disabled
|
readOnly: props.readOnly || props.disabled
|
||||||
@@ -369,12 +408,12 @@ function handleUploadSuccess(files: SysFile[]) {
|
|||||||
|
|
||||||
// 根据类型插入内容
|
// 根据类型插入内容
|
||||||
if (uploadType.value === 'image') {
|
if (uploadType.value === 'image') {
|
||||||
// 插入图片
|
// 插入自定义图片(与文字同行显示)
|
||||||
quillInstance!.insertEmbed(range.index, 'image', downloadUrl);
|
quillInstance!.insertEmbed(range.index, 'customImage', downloadUrl);
|
||||||
// 移动光标到图片后面
|
// 移动光标到图片后面
|
||||||
quillInstance!.setSelection(range.index + 1);
|
quillInstance!.setSelection(range.index + 1);
|
||||||
} else if (uploadType.value === 'video') {
|
} else if (uploadType.value === 'video') {
|
||||||
// 插入自定义视频(使用 customVideo 而不是默认的 video)
|
// 插入自定义视频(单行居中显示)
|
||||||
quillInstance!.insertEmbed(range.index, 'customVideo', downloadUrl);
|
quillInstance!.insertEmbed(range.index, 'customVideo', downloadUrl);
|
||||||
// 移动光标到视频后面
|
// 移动光标到视频后面
|
||||||
quillInstance!.setSelection(range.index + 1);
|
quillInstance!.setSelection(range.index + 1);
|
||||||
@@ -464,50 +503,93 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 图片样式 - 默认内联显示,底部对齐
|
||||||
img {
|
img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: bottom;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 视频容器样式
|
// 视频容器样式 - 默认内联显示,底部对齐
|
||||||
iframe, video {
|
iframe, video {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自定义视频默认样式 - 单行居中显示
|
||||||
|
.custom-video {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
display: block;
|
display: block;
|
||||||
margin: 12px auto; // 默认居中
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自定义图片默认样式 - 与文字同行显示
|
||||||
|
.custom-image {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: bottom;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quill 视频包装器
|
// Quill 视频包装器
|
||||||
.ql-video {
|
.ql-video {
|
||||||
display: block;
|
display: inline-block;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 支持对齐方式
|
// 支持对齐方式 - 图片和视频分别处理
|
||||||
.ql-align-center {
|
.ql-align-center {
|
||||||
text-align: center;
|
text-align: center !important;
|
||||||
|
|
||||||
iframe, video, .ql-video {
|
// 视频始终居中显示
|
||||||
margin-left: auto;
|
video, .custom-video {
|
||||||
margin-right: auto;
|
display: block !important;
|
||||||
|
margin-left: auto !important;
|
||||||
|
margin-right: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片跟随文字对齐
|
||||||
|
img, .custom-image {
|
||||||
|
display: inline-block !important;
|
||||||
|
vertical-align: bottom !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ql-align-right {
|
.ql-align-right {
|
||||||
text-align: right;
|
text-align: right !important;
|
||||||
|
|
||||||
iframe, video, .ql-video {
|
// 视频始终居中显示
|
||||||
margin-left: auto;
|
video, .custom-video {
|
||||||
margin-right: 0;
|
display: block !important;
|
||||||
|
margin-left: auto !important;
|
||||||
|
margin-right: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片跟随文字对齐
|
||||||
|
img, .custom-image {
|
||||||
|
display: inline-block !important;
|
||||||
|
vertical-align: bottom !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ql-align-left {
|
.ql-align-left {
|
||||||
text-align: left;
|
text-align: left !important;
|
||||||
|
|
||||||
iframe, video, .ql-video {
|
// 视频始终居中显示
|
||||||
margin-left: 0;
|
video, .custom-video {
|
||||||
margin-right: auto;
|
display: block !important;
|
||||||
|
margin-left: auto !important;
|
||||||
|
margin-right: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片跟随文字对齐
|
||||||
|
img, .custom-image {
|
||||||
|
display: inline-block !important;
|
||||||
|
vertical-align: bottom !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="rich-text-example">
|
|
||||||
<h2>富文本编辑器示例</h2>
|
|
||||||
|
|
||||||
<div class="example-section">
|
|
||||||
<h3>基础使用</h3>
|
|
||||||
<RichTextComponent
|
|
||||||
v-model="content1"
|
|
||||||
placeholder="请输入内容..."
|
|
||||||
height="300px"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="preview">
|
|
||||||
<h4>预览:</h4>
|
|
||||||
<div v-html="content1" class="preview-content"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="example-section">
|
|
||||||
<h3>带字数统计</h3>
|
|
||||||
<RichTextComponent
|
|
||||||
v-model="content2"
|
|
||||||
placeholder="最多输入500字..."
|
|
||||||
height="200px"
|
|
||||||
:max-length="500"
|
|
||||||
show-word-count
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="example-section">
|
|
||||||
<h3>只读模式</h3>
|
|
||||||
<RichTextComponent
|
|
||||||
v-model="content3"
|
|
||||||
height="150px"
|
|
||||||
read-only
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="example-section">
|
|
||||||
<h3>禁用状态</h3>
|
|
||||||
<RichTextComponent
|
|
||||||
v-model="content4"
|
|
||||||
height="150px"
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="example-section">
|
|
||||||
<h3>错误状态</h3>
|
|
||||||
<RichTextComponent
|
|
||||||
v-model="content5"
|
|
||||||
height="150px"
|
|
||||||
error
|
|
||||||
error-message="内容不能为空"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="example-section">
|
|
||||||
<h3>使用 ref 调用方法</h3>
|
|
||||||
<RichTextComponent
|
|
||||||
ref="editorRef"
|
|
||||||
v-model="content6"
|
|
||||||
height="200px"
|
|
||||||
/>
|
|
||||||
<div class="button-group">
|
|
||||||
<el-button @click="getText">获取纯文本</el-button>
|
|
||||||
<el-button @click="getHTML">获取HTML</el-button>
|
|
||||||
<el-button @click="clearContent">清空内容</el-button>
|
|
||||||
<el-button @click="setContent">设置内容</el-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue';
|
|
||||||
import { ElButton, ElMessage } from 'element-plus';
|
|
||||||
import RichTextComponent from './RichTextComponent.vue';
|
|
||||||
|
|
||||||
const content1 = ref('<p>这是一段<strong>富文本</strong>内容</p>');
|
|
||||||
const content2 = ref('');
|
|
||||||
const content3 = ref('<p>这是只读内容,无法编辑</p>');
|
|
||||||
const content4 = ref('<p>这是禁用状态</p>');
|
|
||||||
const content5 = ref('');
|
|
||||||
const content6 = ref('<p>测试内容</p>');
|
|
||||||
|
|
||||||
const editorRef = ref();
|
|
||||||
|
|
||||||
function getText() {
|
|
||||||
const text = editorRef.value?.getText();
|
|
||||||
ElMessage.success(`纯文本内容:${text}`);
|
|
||||||
console.log('纯文本:', text);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getHTML() {
|
|
||||||
const html = editorRef.value?.getHTML();
|
|
||||||
ElMessage.success('HTML已输出到控制台');
|
|
||||||
console.log('HTML:', html);
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearContent() {
|
|
||||||
editorRef.value?.clear();
|
|
||||||
ElMessage.success('内容已清空');
|
|
||||||
}
|
|
||||||
|
|
||||||
function setContent() {
|
|
||||||
const newContent = '<h2>新标题</h2><p>这是通过方法设置的内容</p><ul><li>列表项1</li><li>列表项2</li></ul>';
|
|
||||||
editorRef.value?.setContent(newContent);
|
|
||||||
ElMessage.success('内容已设置');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.rich-text-example {
|
|
||||||
padding: 20px;
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin-bottom: 30px;
|
|
||||||
color: #303133;
|
|
||||||
}
|
|
||||||
|
|
||||||
.example-section {
|
|
||||||
margin-bottom: 40px;
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
color: #606266;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview {
|
|
||||||
margin-top: 20px;
|
|
||||||
padding: 16px;
|
|
||||||
background: #f5f7fa;
|
|
||||||
border-radius: 4px;
|
|
||||||
|
|
||||||
h4 {
|
|
||||||
margin: 0 0 12px 0;
|
|
||||||
color: #606266;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-content {
|
|
||||||
padding: 12px;
|
|
||||||
background: white;
|
|
||||||
border-radius: 4px;
|
|
||||||
min-height: 100px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-group {
|
|
||||||
margin-top: 16px;
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
@@ -44,6 +44,13 @@ export interface Resource extends BaseDTO {
|
|||||||
creator?: string;
|
creator?: string;
|
||||||
/** 更新者 */
|
/** 更新者 */
|
||||||
updater?: string;
|
updater?: string;
|
||||||
|
|
||||||
|
/** 标签列表 */
|
||||||
|
tags?: Tag[];
|
||||||
|
/** 是否允许评论 */
|
||||||
|
allowComment?: boolean;
|
||||||
|
/** 是否置顶 */
|
||||||
|
isTop?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -106,6 +113,8 @@ export interface ResourceTag extends BaseDTO {
|
|||||||
* 标签实体
|
* 标签实体
|
||||||
*/
|
*/
|
||||||
export interface Tag extends BaseDTO {
|
export interface Tag extends BaseDTO {
|
||||||
|
/** 标签ID */
|
||||||
|
tagID?: string;
|
||||||
/** 标签名称 */
|
/** 标签名称 */
|
||||||
name?: string;
|
name?: string;
|
||||||
/** 标签描述 */
|
/** 标签描述 */
|
||||||
|
|||||||
@@ -8,14 +8,24 @@ import Quill from 'quill';
|
|||||||
|
|
||||||
interface ResizeOptions {
|
interface ResizeOptions {
|
||||||
modules?: string[];
|
modules?: string[];
|
||||||
|
onResizeEnd?: (element: HTMLElement) => void; // 拉伸结束回调
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResizeState {
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
startWidth: number;
|
||||||
|
startHeight: number;
|
||||||
|
aspectRatio: number;
|
||||||
|
position: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ImageResize {
|
export class ImageResize {
|
||||||
quill: any;
|
quill: any;
|
||||||
options: ResizeOptions;
|
options: ResizeOptions;
|
||||||
overlay: HTMLElement | null = null;
|
overlay: HTMLElement | null = null;
|
||||||
img: HTMLImageElement | HTMLVideoElement | null = null;
|
element: HTMLImageElement | HTMLVideoElement | null = null;
|
||||||
handle: HTMLElement | null = null;
|
resizeState: ResizeState | null = null;
|
||||||
|
|
||||||
constructor(quill: any, options: ResizeOptions = {}) {
|
constructor(quill: any, options: ResizeOptions = {}) {
|
||||||
this.quill = quill;
|
this.quill = quill;
|
||||||
@@ -25,14 +35,17 @@ export class ImageResize {
|
|||||||
|
|
||||||
// 等待编辑器完全初始化
|
// 等待编辑器完全初始化
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
this.initEventListeners();
|
||||||
|
console.log('✅ ImageResize 事件监听器已添加');
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
private initEventListeners() {
|
||||||
// 监听编辑器点击事件
|
// 监听编辑器点击事件
|
||||||
this.quill.root.addEventListener('click', this.handleClick.bind(this));
|
this.quill.root.addEventListener('click', this.handleClick.bind(this));
|
||||||
|
|
||||||
// 点击编辑器外部时隐藏 resize 控件
|
// 点击编辑器外部时隐藏 resize 控件
|
||||||
document.addEventListener('click', this.handleDocumentClick.bind(this));
|
document.addEventListener('click', this.handleDocumentClick.bind(this));
|
||||||
|
|
||||||
console.log('✅ ImageResize 事件监听器已添加');
|
|
||||||
}, 100);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleClick(e: MouseEvent) {
|
handleClick(e: MouseEvent) {
|
||||||
@@ -41,7 +54,7 @@ export class ImageResize {
|
|||||||
console.log('🖱️ 点击事件:', target.tagName, target);
|
console.log('🖱️ 点击事件:', target.tagName, target);
|
||||||
|
|
||||||
// 检查是否点击了图片或视频
|
// 检查是否点击了图片或视频
|
||||||
if (target.tagName === 'IMG' || target.tagName === 'VIDEO') {
|
if (this.isResizableElement(target)) {
|
||||||
console.log('📷 检测到图片/视频点击,显示缩放控件');
|
console.log('📷 检测到图片/视频点击,显示缩放控件');
|
||||||
this.showResizer(target as HTMLImageElement | HTMLVideoElement);
|
this.showResizer(target as HTMLImageElement | HTMLVideoElement);
|
||||||
} else if (!this.overlay || !this.overlay.contains(target)) {
|
} else if (!this.overlay || !this.overlay.contains(target)) {
|
||||||
@@ -50,6 +63,10 @@ export class ImageResize {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isResizableElement(element: HTMLElement): boolean {
|
||||||
|
return element.tagName === 'IMG' || element.tagName === 'VIDEO';
|
||||||
|
}
|
||||||
|
|
||||||
handleDocumentClick(e: MouseEvent) {
|
handleDocumentClick(e: MouseEvent) {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
|
|
||||||
@@ -60,7 +77,7 @@ export class ImageResize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
showResizer(element: HTMLImageElement | HTMLVideoElement) {
|
showResizer(element: HTMLImageElement | HTMLVideoElement) {
|
||||||
this.img = element;
|
this.element = element;
|
||||||
|
|
||||||
console.log('🎯 显示缩放控件,元素:', element);
|
console.log('🎯 显示缩放控件,元素:', element);
|
||||||
|
|
||||||
@@ -72,6 +89,14 @@ export class ImageResize {
|
|||||||
if (!this.overlay) return;
|
if (!this.overlay) return;
|
||||||
|
|
||||||
// 更新遮罩层位置和大小
|
// 更新遮罩层位置和大小
|
||||||
|
this.updateOverlayPosition(element);
|
||||||
|
|
||||||
|
console.log('✅ 缩放控件已显示');
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateOverlayPosition(element: HTMLImageElement | HTMLVideoElement) {
|
||||||
|
if (!this.overlay) return;
|
||||||
|
|
||||||
const rect = element.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
const containerRect = this.quill.root.getBoundingClientRect();
|
const containerRect = this.quill.root.getBoundingClientRect();
|
||||||
|
|
||||||
@@ -87,15 +112,14 @@ export class ImageResize {
|
|||||||
this.overlay.style.top = `${rect.top - containerRect.top + this.quill.root.scrollTop}px`;
|
this.overlay.style.top = `${rect.top - containerRect.top + this.quill.root.scrollTop}px`;
|
||||||
this.overlay.style.width = `${rect.width}px`;
|
this.overlay.style.width = `${rect.width}px`;
|
||||||
this.overlay.style.height = `${rect.height}px`;
|
this.overlay.style.height = `${rect.height}px`;
|
||||||
|
|
||||||
console.log('✅ 缩放控件已显示');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hideResizer() {
|
hideResizer() {
|
||||||
if (this.overlay) {
|
if (this.overlay) {
|
||||||
this.overlay.style.display = 'none';
|
this.overlay.style.display = 'none';
|
||||||
}
|
}
|
||||||
this.img = null;
|
this.element = null;
|
||||||
|
this.resizeState = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
createOverlay() {
|
createOverlay() {
|
||||||
@@ -199,85 +223,122 @@ export class ImageResize {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
if (!this.img || !this.overlay) return;
|
if (!this.element || !this.overlay) return;
|
||||||
|
|
||||||
const startX = e.clientX;
|
// 初始化拉伸状态
|
||||||
const startY = e.clientY;
|
this.resizeState = {
|
||||||
const startWidth = this.img.offsetWidth;
|
startX: e.clientX,
|
||||||
const startHeight = this.img.offsetHeight;
|
startY: e.clientY,
|
||||||
const aspectRatio = startWidth / startHeight;
|
startWidth: this.element.offsetWidth,
|
||||||
|
startHeight: this.element.offsetHeight,
|
||||||
|
aspectRatio: this.element.offsetWidth / this.element.offsetHeight,
|
||||||
|
position
|
||||||
|
};
|
||||||
|
|
||||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||||
if (!this.img || !this.overlay) return;
|
if (!this.element || !this.overlay || !this.resizeState) return;
|
||||||
|
|
||||||
const deltaX = moveEvent.clientX - startX;
|
this.updateElementSize(moveEvent);
|
||||||
const deltaY = moveEvent.clientY - startY;
|
|
||||||
|
|
||||||
let newWidth = startWidth;
|
|
||||||
let newHeight = startHeight;
|
|
||||||
|
|
||||||
// 根据拉伸方向计算新尺寸
|
|
||||||
if (position.includes('e')) {
|
|
||||||
newWidth = startWidth + deltaX;
|
|
||||||
} else if (position.includes('w')) {
|
|
||||||
newWidth = startWidth - deltaX;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (position.includes('s')) {
|
|
||||||
newHeight = startHeight + deltaY;
|
|
||||||
} else if (position.includes('n')) {
|
|
||||||
newHeight = startHeight - deltaY;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保持纵横比(对于对角拉伸)
|
|
||||||
if (position.length === 2) {
|
|
||||||
newHeight = newWidth / aspectRatio;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 限制最小尺寸
|
|
||||||
if (newWidth < 50) newWidth = 50;
|
|
||||||
if (newHeight < 50) newHeight = 50;
|
|
||||||
|
|
||||||
// 应用新尺寸(同时设置 style 和属性)
|
|
||||||
this.img.style.width = `${newWidth}px`;
|
|
||||||
this.img.style.height = `${newHeight}px`;
|
|
||||||
this.img.setAttribute('width', `${newWidth}`);
|
|
||||||
this.img.setAttribute('height', `${newHeight}`);
|
|
||||||
|
|
||||||
|
|
||||||
// 更新遮罩层
|
|
||||||
this.overlay.style.width = `${newWidth}px`;
|
|
||||||
this.overlay.style.height = `${newHeight}px`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = () => {
|
||||||
document.removeEventListener('mousemove', handleMouseMove);
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
document.removeEventListener('mouseup', handleMouseUp);
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
|
||||||
// 强制触发 Quill 的 text-change 事件
|
// 拉伸结束,触发更新
|
||||||
// 使用 Quill 的内部方法
|
this.onResizeEnd();
|
||||||
console.log('🔄 尝试触发 text-change 事件');
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateElementSize(moveEvent: MouseEvent) {
|
||||||
|
if (!this.element || !this.overlay || !this.resizeState) return;
|
||||||
|
|
||||||
|
const deltaX = moveEvent.clientX - this.resizeState.startX;
|
||||||
|
const deltaY = moveEvent.clientY - this.resizeState.startY;
|
||||||
|
|
||||||
|
let newWidth = this.resizeState.startWidth;
|
||||||
|
let newHeight = this.resizeState.startHeight;
|
||||||
|
|
||||||
|
// 根据拉伸方向计算新尺寸
|
||||||
|
if (this.resizeState.position.includes('e')) {
|
||||||
|
newWidth = this.resizeState.startWidth + deltaX;
|
||||||
|
} else if (this.resizeState.position.includes('w')) {
|
||||||
|
newWidth = this.resizeState.startWidth - deltaX;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.resizeState.position.includes('s')) {
|
||||||
|
newHeight = this.resizeState.startHeight + deltaY;
|
||||||
|
} else if (this.resizeState.position.includes('n')) {
|
||||||
|
newHeight = this.resizeState.startHeight - deltaY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保持纵横比(对于对角拉伸)
|
||||||
|
if (this.resizeState.position.length === 2) {
|
||||||
|
newHeight = newWidth / this.resizeState.aspectRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制最小尺寸
|
||||||
|
if (newWidth < 50) newWidth = 50;
|
||||||
|
if (newHeight < 50) newHeight = 50;
|
||||||
|
|
||||||
|
// 应用新尺寸
|
||||||
|
this.applyElementSize(newWidth, newHeight);
|
||||||
|
|
||||||
|
// 更新遮罩层
|
||||||
|
this.overlay.style.width = `${newWidth}px`;
|
||||||
|
this.overlay.style.height = `${newHeight}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyElementSize(width: number, height: number) {
|
||||||
|
if (!this.element) return;
|
||||||
|
|
||||||
|
// 同时设置 style 和属性
|
||||||
|
this.element.style.width = `${width}px`;
|
||||||
|
this.element.style.height = `${height}px`;
|
||||||
|
this.element.setAttribute('width', `${width}`);
|
||||||
|
this.element.setAttribute('height', `${height}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onResizeEnd() {
|
||||||
|
if (!this.element) return;
|
||||||
|
|
||||||
|
console.log('🔄 拉伸结束,触发HTML更新');
|
||||||
|
|
||||||
|
// 调用自定义回调
|
||||||
|
if (this.options.onResizeEnd) {
|
||||||
|
this.options.onResizeEnd(this.element);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 强制触发 Quill 的内容更新
|
||||||
|
this.triggerQuillUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private triggerQuillUpdate() {
|
||||||
|
// 方案1: 使用 Quill 的内部方法
|
||||||
if (this.quill.emitter) {
|
if (this.quill.emitter) {
|
||||||
this.quill.emitter.emit('text-change', {
|
this.quill.emitter.emit('text-change', {
|
||||||
oldRange: null,
|
oldRange: null,
|
||||||
range: null,
|
range: null,
|
||||||
source: 'user'
|
source: 'user'
|
||||||
});
|
});
|
||||||
console.log('✅ text-change 事件已触发');
|
console.log('✅ Quill text-change 事件已触发');
|
||||||
} else {
|
|
||||||
console.log('❌ quill.emitter 不存在');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 备用方案:直接触发 DOM 事件
|
// 方案2: 触发 DOM 事件
|
||||||
console.log('🔄 尝试触发 input 事件');
|
const inputEvent = new Event('input', { bubbles: true });
|
||||||
const event = new Event('input', { bubbles: true });
|
this.quill.root.dispatchEvent(inputEvent);
|
||||||
this.quill.root.dispatchEvent(event);
|
console.log('✅ DOM input 事件已触发');
|
||||||
console.log('✅ input 事件已触发');
|
|
||||||
|
|
||||||
};
|
// 方案3: 强制更新 Quill 内容
|
||||||
|
setTimeout(() => {
|
||||||
document.addEventListener('mousemove', handleMouseMove);
|
if (this.quill.update) {
|
||||||
document.addEventListener('mouseup', handleMouseUp);
|
this.quill.update();
|
||||||
|
console.log('✅ Quill 内容已更新');
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
|
|||||||
@@ -15,43 +15,48 @@
|
|||||||
<!-- 分类和标签 -->
|
<!-- 分类和标签 -->
|
||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="文章分类" prop="category">
|
<el-form-item label="文章分类" prop="categoryID">
|
||||||
<el-select v-model="articleForm.category" placeholder="请选择分类" style="width: 100%">
|
<el-select v-model="articleForm.categoryID" placeholder="请选择分类" style="width: 100%" :loading="categoryLoading">
|
||||||
<el-option label="新闻资讯" value="news" />
|
<el-option
|
||||||
<el-option label="技术文章" value="tech" />
|
v-for="category in categoryList"
|
||||||
<el-option label="学习资料" value="study" />
|
:key="category.categoryID || category.id"
|
||||||
<el-option label="通知公告" value="notice" />
|
:label="category.name"
|
||||||
|
:value="category.categoryID || category.id || ''"
|
||||||
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="标签" prop="tags">
|
<el-form-item label="标签" prop="tags">
|
||||||
<el-select v-model="articleForm.tags" multiple placeholder="请选择标签" style="width: 100%">
|
<el-select v-model="articleForm.tags" multiple placeholder="请选择标签" style="width: 100%" :loading="tagLoading">
|
||||||
<el-option label="重要" value="important" />
|
<el-option
|
||||||
<el-option label="推荐" value="recommend" />
|
v-for="tag in tagList"
|
||||||
<el-option label="热门" value="hot" />
|
:key="tag.id || tag.tagID"
|
||||||
<el-option label="原创" value="original" />
|
:label="tag.name"
|
||||||
|
:value="tag.id || tag.tagID || ''"
|
||||||
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
<!-- 摘要 -->
|
|
||||||
<el-form-item label="文章摘要" prop="summary">
|
|
||||||
<el-input v-model="articleForm.summary" type="textarea" :rows="3" placeholder="请输入文章摘要(选填)"
|
|
||||||
maxlength="200" show-word-limit />
|
|
||||||
</el-form-item>
|
|
||||||
|
|
||||||
<!-- 封面图 -->
|
<!-- 封面图 -->
|
||||||
<el-form-item label="封面图片">
|
<el-form-item label="封面图片">
|
||||||
<el-upload class="cover-uploader" :show-file-list="false" :on-success="handleCoverSuccess"
|
<!-- 上传区域 - 只在没有封面图片时显示 -->
|
||||||
:before-upload="beforeCoverUpload" action="#">
|
<FileUpload
|
||||||
<img v-if="articleForm.cover" :src="articleForm.cover" class="cover" />
|
v-if="!articleForm.coverImage"
|
||||||
<el-icon v-else class="cover-uploader-icon">
|
:as-dialog="false"
|
||||||
<Plus />
|
accept="image/*"
|
||||||
</el-icon>
|
:max-size="2"
|
||||||
</el-upload>
|
:multiple="false"
|
||||||
<div class="upload-tip">建议尺寸:800x450px,支持jpg、png格式</div>
|
tip="建议尺寸:800x450px,支持jpg、png格式"
|
||||||
|
@success="handleCoverUploadSuccess"
|
||||||
|
/>
|
||||||
|
<!-- 封面预览 - 只在有封面图片时显示 -->
|
||||||
|
<div v-if="articleForm.coverImage" class="cover-preview">
|
||||||
|
<img :src="articleForm.coverImage" class="cover" />
|
||||||
|
<el-button type="danger" size="small" @click="removeCover">删除封面</el-button>
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<!-- 文章内容 -->
|
<!-- 文章内容 -->
|
||||||
@@ -93,28 +98,22 @@
|
|||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 预览对话框 -->
|
<!-- 文章预览组件 -->
|
||||||
<el-dialog v-model="previewVisible" title="文章预览" width="900px" :close-on-click-modal="false">
|
<ArticleShowView
|
||||||
<div class="article-preview">
|
v-model="previewVisible"
|
||||||
<h1 class="preview-title">{{ articleForm.title }}</h1>
|
:as-dialog="true"
|
||||||
<div class="preview-meta">
|
title="文章预览"
|
||||||
<span>分类:{{ getCategoryLabel(articleForm.category) }}</span>
|
width="900px"
|
||||||
<span v-if="articleForm.tags.length">
|
:article-data="articleForm"
|
||||||
标签:{{ articleForm.tags.join(', ') }}
|
:category-list="categoryList"
|
||||||
</span>
|
:show-edit-button="false"
|
||||||
</div>
|
@close="previewVisible = false"
|
||||||
<div class="preview-summary" v-if="articleForm.summary">
|
/>
|
||||||
{{ articleForm.summary }}
|
|
||||||
</div>
|
|
||||||
<img v-if="articleForm.cover" :src="articleForm.cover" class="preview-cover" />
|
|
||||||
<div class="preview-content ql-editor" v-html="articleForm.content"></div>
|
|
||||||
</div>
|
|
||||||
</el-dialog>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
import { useRouter, useRoute } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
import {
|
import {
|
||||||
ElForm,
|
ElForm,
|
||||||
@@ -126,13 +125,14 @@ import {
|
|||||||
ElRow,
|
ElRow,
|
||||||
ElCol,
|
ElCol,
|
||||||
ElCheckbox,
|
ElCheckbox,
|
||||||
ElUpload,
|
ElMessage
|
||||||
ElIcon,
|
|
||||||
ElMessage,
|
|
||||||
ElDialog
|
|
||||||
} from 'element-plus';
|
} from 'element-plus';
|
||||||
import { ArrowLeft, Plus } from '@element-plus/icons-vue';
|
import { ArrowLeft } from '@element-plus/icons-vue';
|
||||||
import { RichTextComponent } from '@/components/text';
|
import { RichTextComponent } from '@/components/text';
|
||||||
|
import { FileUpload } from '@/components/file';
|
||||||
|
import { ArticleShowView } from './index';
|
||||||
|
import { resourceCategoryApi, resourceTagApi, resourceApi } from '@/apis/resource';
|
||||||
|
import { Resource, ResourceCategory, Tag } from '@/types/resource';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -146,17 +146,25 @@ const previewVisible = ref(false);
|
|||||||
// 是否编辑模式
|
// 是否编辑模式
|
||||||
const isEdit = ref(false);
|
const isEdit = ref(false);
|
||||||
|
|
||||||
|
// 数据状态
|
||||||
|
const categoryList = ref<ResourceCategory[]>([]);
|
||||||
|
const tagList = ref<Tag[]>([]);
|
||||||
|
const categoryLoading = ref(false);
|
||||||
|
const tagLoading = ref(false);
|
||||||
|
|
||||||
// 表单数据
|
// 表单数据
|
||||||
const articleForm = reactive({
|
const articleForm = ref<Resource>({
|
||||||
title: '',
|
title: '',
|
||||||
category: '',
|
|
||||||
tags: [] as string[],
|
|
||||||
summary: '',
|
|
||||||
cover: '',
|
|
||||||
content: '',
|
content: '',
|
||||||
|
categoryID: '',
|
||||||
|
author: '',
|
||||||
|
source: '',
|
||||||
|
sourceUrl: '',
|
||||||
|
viewCount: 0,
|
||||||
|
coverImage: '',
|
||||||
|
tags: [] as Tag[],
|
||||||
allowComment: true,
|
allowComment: true,
|
||||||
isTop: false,
|
isTop: false,
|
||||||
isRecommend: false
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 表单验证规则
|
// 表单验证规则
|
||||||
@@ -165,7 +173,7 @@ const rules = {
|
|||||||
{ required: true, message: '请输入文章标题', trigger: 'blur' },
|
{ required: true, message: '请输入文章标题', trigger: 'blur' },
|
||||||
{ min: 5, max: 100, message: '标题长度在 5 到 100 个字符', trigger: 'blur' }
|
{ min: 5, max: 100, message: '标题长度在 5 到 100 个字符', trigger: 'blur' }
|
||||||
],
|
],
|
||||||
category: [
|
categoryID: [
|
||||||
{ required: true, message: '请选择文章分类', trigger: 'change' }
|
{ required: true, message: '请选择文章分类', trigger: 'change' }
|
||||||
],
|
],
|
||||||
content: [
|
content: [
|
||||||
@@ -173,19 +181,43 @@ const rules = {
|
|||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
// 检查是否是编辑模式
|
|
||||||
const id = route.query.id;
|
|
||||||
if (id) {
|
|
||||||
isEdit.value = true;
|
|
||||||
loadArticle(id as string);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 加载文章数据(编辑模式)
|
// 加载分类列表
|
||||||
function loadArticle(id: string) {
|
async function loadCategoryList() {
|
||||||
// TODO: 调用API加载文章数据
|
try {
|
||||||
console.log('加载文章:', id);
|
categoryLoading.value = true;
|
||||||
|
const result = await resourceCategoryApi.getCategoryList();
|
||||||
|
if (result.success) {
|
||||||
|
// 数组数据从 dataList 获取
|
||||||
|
categoryList.value = result.dataList || [];
|
||||||
|
} else {
|
||||||
|
ElMessage.error(result.message || '加载分类失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载分类失败:', error);
|
||||||
|
ElMessage.error('加载分类失败');
|
||||||
|
} finally {
|
||||||
|
categoryLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载标签列表
|
||||||
|
async function loadTagList() {
|
||||||
|
try {
|
||||||
|
tagLoading.value = true;
|
||||||
|
const result = await resourceTagApi.getTagList();
|
||||||
|
if (result.success) {
|
||||||
|
// 数组数据从 dataList 获取
|
||||||
|
tagList.value = result.dataList || [];
|
||||||
|
} else {
|
||||||
|
ElMessage.error(result.message || '加载标签失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载标签失败:', error);
|
||||||
|
ElMessage.error('加载标签失败');
|
||||||
|
} finally {
|
||||||
|
tagLoading.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回
|
// 返回
|
||||||
@@ -235,8 +267,8 @@ async function handleSaveDraft() {
|
|||||||
|
|
||||||
// 预览
|
// 预览
|
||||||
function handlePreview() {
|
function handlePreview() {
|
||||||
console.log(articleForm.content);
|
console.log(articleForm.value.content);
|
||||||
if (!articleForm.title) {
|
if (!articleForm.value.title) {
|
||||||
ElMessage.warning('请先输入文章标题');
|
ElMessage.warning('请先输入文章标题');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -244,35 +276,46 @@ function handlePreview() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 封面上传成功
|
// 封面上传成功
|
||||||
function handleCoverSuccess(response: any) {
|
function handleCoverUploadSuccess(files: any[]) {
|
||||||
// TODO: 处理上传成功的响应
|
if (files && files.length > 0) {
|
||||||
articleForm.cover = response.url;
|
const file = files[0];
|
||||||
|
// 使用文件下载URL构建完整路径
|
||||||
|
import('@/config').then(config => {
|
||||||
|
articleForm.value.coverImage = config.FILE_DOWNLOAD_URL + file.id;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 上传前验证
|
// 删除封面
|
||||||
function beforeCoverUpload(file: File) {
|
function removeCover() {
|
||||||
const isImage = file.type.startsWith('image/');
|
articleForm.value.coverImage = '';
|
||||||
const isLt2M = file.size / 1024 / 1024 < 2;
|
|
||||||
|
|
||||||
if (!isImage) {
|
|
||||||
ElMessage.error('只能上传图片文件!');
|
|
||||||
}
|
|
||||||
if (!isLt2M) {
|
|
||||||
ElMessage.error('图片大小不能超过 2MB!');
|
|
||||||
}
|
|
||||||
return isImage && isLt2M;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取分类标签
|
|
||||||
function getCategoryLabel(value: string): string {
|
onMounted(async () => {
|
||||||
const map: Record<string, string> = {
|
// 并行加载分类和标签数据
|
||||||
news: '新闻资讯',
|
await Promise.all([
|
||||||
tech: '技术文章',
|
loadCategoryList(),
|
||||||
study: '学习资料',
|
loadTagList()
|
||||||
notice: '通知公告'
|
]);
|
||||||
};
|
|
||||||
return map[value] || value;
|
// 如果是编辑模式,加载文章数据
|
||||||
|
const id = route.query.id;
|
||||||
|
if (id) {
|
||||||
|
try {
|
||||||
|
isEdit.value = true;
|
||||||
|
const result = await resourceApi.getResourceById(id as string);
|
||||||
|
if (result.success && result.data) {
|
||||||
|
articleForm.value = result.data;
|
||||||
|
} else {
|
||||||
|
ElMessage.error(result.message || '加载文章失败');
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载文章失败:', error);
|
||||||
|
ElMessage.error('加载文章失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -341,82 +384,16 @@ function getCategoryLabel(value: string): string {
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-preview {
|
.cover-preview {
|
||||||
padding: 20px;
|
margin-top: 16px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
:deep(.ql-code-block-container) {
|
.cover {
|
||||||
margin: 12px 0; // 上下间距
|
width: 200px;
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ql-code-block) {
|
|
||||||
background: #282c34; // 代码块背景色(类似深色主题)
|
|
||||||
color: #abb2bf; // 代码文字颜色
|
|
||||||
padding: 12px; // 内边距
|
|
||||||
border-radius: 4px; // 圆角
|
|
||||||
overflow-x: auto; // 横向滚动
|
|
||||||
font-family: 'Courier New', monospace; // 等宽字体
|
|
||||||
white-space: pre; // 保留空格和换行
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-title {
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #303133;
|
|
||||||
margin: 0 0 16px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-meta {
|
|
||||||
display: flex;
|
|
||||||
gap: 24px;
|
|
||||||
color: #909399;
|
|
||||||
font-size: 14px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
padding-bottom: 16px;
|
|
||||||
border-bottom: 1px solid #ebeef5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-summary {
|
|
||||||
background: #f5f7fa;
|
|
||||||
padding: 16px;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: #606266;
|
|
||||||
line-height: 1.6;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-cover {
|
|
||||||
width: 100%;
|
|
||||||
max-height: 400px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-content {
|
|
||||||
// ql-editor 类会自动应用 Quill 的默认样式
|
|
||||||
// 这里只添加必要的自定义样式覆盖
|
|
||||||
|
|
||||||
// 图片和视频样式(保留用户设置的尺寸)
|
|
||||||
:deep(img[width]),
|
|
||||||
:deep(video[width]),
|
|
||||||
:deep(img[style*="width"]),
|
|
||||||
:deep(video[style*="width"]) {
|
|
||||||
// 如果有 width 属性或 style 中包含 width,使用用户设置的尺寸
|
|
||||||
max-width: 100%;
|
|
||||||
// 不强制设置 height: auto,保留用户设置的固定尺寸
|
|
||||||
display: block;
|
|
||||||
margin: 12px auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 没有 width 属性的图片和视频使用默认样式
|
|
||||||
:deep(img:not([width]):not([style*="width"])),
|
|
||||||
:deep(video:not([width]):not([style*="width"])),
|
|
||||||
:deep(iframe) {
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
height: auto;
|
||||||
|
border-radius: 4px;
|
||||||
display: block;
|
display: block;
|
||||||
margin: 12px auto;
|
margin-bottom: 8px;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
382
schoolNewsWeb/src/views/article/ArticleShowView.vue
Normal file
382
schoolNewsWeb/src/views/article/ArticleShowView.vue
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Dialog 模式 -->
|
||||||
|
<el-dialog
|
||||||
|
v-if="asDialog"
|
||||||
|
v-model="visible"
|
||||||
|
:title="title"
|
||||||
|
:width="width"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<div class="article-show-container">
|
||||||
|
<!-- 文章头部信息 -->
|
||||||
|
<div class="article-header">
|
||||||
|
<h1 class="article-title">{{ articleData.title }}</h1>
|
||||||
|
<div class="article-meta">
|
||||||
|
<span v-if="articleData.category" class="meta-item">
|
||||||
|
分类:{{ getCategoryLabel(articleData.category) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="articleData.tags && articleData.tags.length" class="meta-item">
|
||||||
|
标签:{{ getTagsString(articleData.tags) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="articleData.author" class="meta-item">
|
||||||
|
作者:{{ articleData.author }}
|
||||||
|
</span>
|
||||||
|
<span v-if="articleData.createTime" class="meta-item">
|
||||||
|
发布时间:{{ formatDate(articleData.createTime) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 封面图片 -->
|
||||||
|
<div v-if="articleData.coverImage" class="article-cover">
|
||||||
|
<img :src="articleData.coverImage" class="cover-image" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 文章内容 -->
|
||||||
|
<div class="article-content ql-editor" v-html="articleData.content"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="handleClose">关闭</el-button>
|
||||||
|
<el-button v-if="showEditButton" type="primary" @click="handleEdit">编辑</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 非 Dialog 模式 -->
|
||||||
|
<div v-else class="article-show-container">
|
||||||
|
<!-- 文章头部信息 -->
|
||||||
|
<div class="article-header">
|
||||||
|
<h1 class="article-title">{{ articleData.title }}</h1>
|
||||||
|
<div class="article-meta">
|
||||||
|
<span v-if="articleData.category" class="meta-item">
|
||||||
|
分类:{{ getCategoryLabel(articleData.category) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="articleData.tags && articleData.tags.length" class="meta-item">
|
||||||
|
标签:{{ getTagsString(articleData.tags) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="articleData.author" class="meta-item">
|
||||||
|
作者:{{ articleData.author }}
|
||||||
|
</span>
|
||||||
|
<span v-if="articleData.createTime" class="meta-item">
|
||||||
|
发布时间:{{ formatDate(articleData.createTime) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 封面图片 -->
|
||||||
|
<div v-if="articleData.coverImage" class="article-cover">
|
||||||
|
<img :src="articleData.coverImage" class="cover-image" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 文章内容 -->
|
||||||
|
<div class="article-content ql-editor" v-html="articleData.content"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { ElDialog, ElButton } from 'element-plus';
|
||||||
|
|
||||||
|
interface ArticleData {
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
coverImage?: string;
|
||||||
|
category?: string;
|
||||||
|
tags?: Array<{ name?: string }> | string[]; // 支持对象数组或字符串数组
|
||||||
|
author?: string;
|
||||||
|
createTime?: string | Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue?: boolean; // Dialog 模式下的显示状态
|
||||||
|
asDialog?: boolean; // 是否作为 Dialog 使用
|
||||||
|
title?: string; // Dialog 标题
|
||||||
|
width?: string; // Dialog 宽度
|
||||||
|
articleData?: ArticleData; // 文章数据
|
||||||
|
categoryList?: Array<{ id?: string; categoryID?: string; name?: string }>; // 分类列表
|
||||||
|
showEditButton?: boolean; // 是否显示编辑按钮
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
modelValue: false,
|
||||||
|
asDialog: true,
|
||||||
|
title: '文章预览',
|
||||||
|
width: '900px',
|
||||||
|
articleData: () => ({}),
|
||||||
|
categoryList: () => [],
|
||||||
|
showEditButton: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean];
|
||||||
|
'close': [];
|
||||||
|
'edit': [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Dialog 显示状态
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.asDialog ? props.modelValue : false,
|
||||||
|
set: (val) => props.asDialog ? emit('update:modelValue', val) : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取标签字符串
|
||||||
|
function getTagsString(tags: Array<{ name?: string }> | string[]): string {
|
||||||
|
if (!tags || tags.length === 0) return '';
|
||||||
|
|
||||||
|
if (typeof tags[0] === 'string') {
|
||||||
|
return (tags as string[]).join(', ');
|
||||||
|
} else {
|
||||||
|
return (tags as Array<{ name?: string }>)
|
||||||
|
.map(tag => tag.name || '')
|
||||||
|
.filter(name => name)
|
||||||
|
.join(', ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取分类标签
|
||||||
|
function getCategoryLabel(categoryId: string): string {
|
||||||
|
if (!props.categoryList || !categoryId) return '';
|
||||||
|
|
||||||
|
const category = props.categoryList.find(cat =>
|
||||||
|
cat.id === categoryId || cat.categoryID === categoryId
|
||||||
|
);
|
||||||
|
|
||||||
|
return category?.name || categoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
function formatDate(date: string | Date): string {
|
||||||
|
if (!date) return '';
|
||||||
|
|
||||||
|
const d = new Date(date);
|
||||||
|
return d.toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭处理
|
||||||
|
function handleClose() {
|
||||||
|
if (props.asDialog) {
|
||||||
|
visible.value = false;
|
||||||
|
}
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑处理
|
||||||
|
function handleEdit() {
|
||||||
|
emit('edit');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露方法
|
||||||
|
defineExpose({
|
||||||
|
open: () => {
|
||||||
|
if (props.asDialog) {
|
||||||
|
visible.value = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
close: handleClose
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.article-show-container {
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid #ebeef5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #303133;
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
color: #909399;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
background: #909399;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-cover {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-image {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 400px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content {
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #303133;
|
||||||
|
font-size: 16px;
|
||||||
|
|
||||||
|
// 继承富文本编辑器的样式
|
||||||
|
:deep(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(video),
|
||||||
|
:deep(iframe) {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对齐方式样式 - 图片和视频分别处理
|
||||||
|
:deep(.ql-align-center) {
|
||||||
|
text-align: center !important;
|
||||||
|
|
||||||
|
// 视频始终居中显示
|
||||||
|
video, .custom-video {
|
||||||
|
display: block !important;
|
||||||
|
margin-left: auto !important;
|
||||||
|
margin-right: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片跟随文字对齐
|
||||||
|
img, .custom-image {
|
||||||
|
display: inline-block !important;
|
||||||
|
vertical-align: bottom !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ql-align-right) {
|
||||||
|
text-align: right !important;
|
||||||
|
|
||||||
|
// 视频始终居中显示
|
||||||
|
video, .custom-video {
|
||||||
|
display: block !important;
|
||||||
|
margin-left: auto !important;
|
||||||
|
margin-right: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片跟随文字对齐
|
||||||
|
img, .custom-image {
|
||||||
|
display: inline-block !important;
|
||||||
|
vertical-align: bottom !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ql-align-left) {
|
||||||
|
text-align: left !important;
|
||||||
|
|
||||||
|
// 视频始终居中显示
|
||||||
|
video, .custom-video {
|
||||||
|
display: block !important;
|
||||||
|
margin-left: auto !important;
|
||||||
|
margin-right: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片跟随文字对齐
|
||||||
|
img, .custom-image {
|
||||||
|
display: inline-block !important;
|
||||||
|
vertical-align: bottom !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他富文本样式
|
||||||
|
:deep(h1), :deep(h2), :deep(h3), :deep(h4), :deep(h5), :deep(h6) {
|
||||||
|
margin: 24px 0 16px 0;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(p) {
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(blockquote) {
|
||||||
|
margin: 16px 0;
|
||||||
|
padding: 16px;
|
||||||
|
background: #f5f7fa;
|
||||||
|
border-left: 4px solid #409eff;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(code) {
|
||||||
|
background: #f5f7fa;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(pre) {
|
||||||
|
background: #f5f7fa;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 16px 0;
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(ul), :deep(ol) {
|
||||||
|
margin: 16px 0;
|
||||||
|
padding-left: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(li) {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(table) {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(th), :deep(td) {
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(th) {
|
||||||
|
background: #f5f7fa;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
2
schoolNewsWeb/src/views/article/index.ts
Normal file
2
schoolNewsWeb/src/views/article/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as ArticleAddView } from './ArticleAddView.vue';
|
||||||
|
export { default as ArticleShowView } from './ArticleShowView.vue';
|
||||||
Reference in New Issue
Block a user