1. 推广员功能增强
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled

This commit is contained in:
李琦
2026-06-18 15:05:02 +08:00
parent d9db629c5d
commit 073f7f882b
10 changed files with 750 additions and 309 deletions

View File

@@ -2,7 +2,7 @@
import { useVModel } from '@vueuse/core';
import { Upload } from 'ant-design-vue';
// 导入上传相关API和工具函数
import GalleryPickLink from '#/components/form/components/gallery-pick-link.vue';
import { uploadFile } from '#/api/core/upload';
import { uploadToOss } from '#/utils/oss-upload';
import {
@@ -26,93 +26,77 @@ const mValue = useVModel(props, 'value', emits, {
defaultValue: props.value,
passive: true,
});
/**
* 自定义上传请求处理函数
*
* 该函数根据preferences配置的上传方式,选择使用OSS直传或后端上传:
* - 'direct': OSS直传模式,文件直接从浏览器上传到阿里云OSS
* - 'backend': 后端上传模式,文件先上传到后端服务器,再由后端上传到OSS
*
* 上传方式说明:
* 1. OSS直传(direct):
* - 调用uploadToOss函数,直接上传到OSS
* - 减少服务器负载,上传速度更快
*
* 2. 后端上传(backend):
* - 调用uploadFile API,通过后端服务器上传
* - 兼容现有功能,所有上传逻辑由后端统一处理
*
* 兼容性处理:
* - 两种上传方式返回的数据结构保持一致:{ url: string }
* - 上传成功后,将URL赋值给mValue,触发组件更新
*
* @param e 上传事件对象,包含file字段(文件对象)
*/
const customRequest = async (e: any) => {
try {
const actualFile = resolveUploadFile(e.file as File);
// 从preferences中读取上传方式配置
// uploadMethod可能的值:'direct'(OSS直传)或 'backend'(后端上传)
const uploadMethod = preferences.app.uploadMethod || 'direct';
let data: { url: string };
// 根据配置选择上传方式
if (uploadMethod === 'direct') {
// OSS直传模式:文件直接从浏览器上传到OSS
// uploadToOss函数会处理签名获取、文件上传等所有逻辑
data = await uploadToOss({
file: actualFile,
});
} else {
// 后端上传模式:文件先上传到后端服务器,再由后端上传到OSS
// uploadFile函数会将文件发送到后端API(/upload/image)
data = await uploadFile({
file: actualFile,
});
}
// 上传成功,将URL赋值给mValue
// mValue是双向绑定的值,更新后会触发父组件的更新
mValue.value = data.url;
} catch (error) {
// 上传失败,记录错误信息
// 注意:这里没有显示错误提示,如果需要可以添加message.error
console.error('头像上传失败', error);
}
};
function onGallerySelect(urls: string[]) {
if (urls[0]) {
mValue.value = urls[0];
}
}
const handleRemove = (e: Event) => {
e.stopPropagation();
mValue.value = '';
};
</script>
<template>
<Upload
:before-upload="beforeImageUpload"
:custom-request="customRequest"
:show-upload-list="false"
list-type="picture-card"
>
<div v-if="mValue" class="m-avatar-wrap">
<Icon
class="m-avatar-icon-delete"
icon="ant-design:delete-outlined"
@click="handleRemove"
/>
<img :src="value" width="100%" />
</div>
<Icon v-else icon="ant-design:plus-outlined" />
</Upload>
<div class="avatar-upload-wrap">
<Upload
:before-upload="beforeImageUpload"
:custom-request="customRequest"
:show-upload-list="false"
list-type="picture-card"
>
<div v-if="mValue" class="m-avatar-wrap">
<Icon
class="m-avatar-icon-delete"
icon="ant-design:delete-outlined"
@click="handleRemove"
/>
<img :src="mValue" width="100%" />
</div>
<Icon v-else icon="ant-design:plus-outlined" />
</Upload>
<GalleryPickLink @select="onGallerySelect" />
</div>
</template>
<style lang="less" scoped>
.avatar-upload-wrap {
display: inline-block;
}
.m-avatar-wrap {
position: relative;
height: 102px;
overflow: hidden;
.m-avatar-icon-delete {
position: absolute;
top: 0;
right: 0;
z-index: 1;
cursor: pointer;
}
}

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
import { ref } from 'vue';
import ImageGalleryPicker from '#/components/form/components/image-gallery-picker.vue';
const props = withDefaults(
defineProps<{
multiple?: boolean;
maxCount?: number;
disabled?: boolean;
}>(),
{
multiple: false,
maxCount: 1,
disabled: false,
},
);
const emit = defineEmits<{
select: [urls: string[]];
}>();
const galleryOpen = ref(false);
function openGallery() {
if (props.disabled) {
return;
}
galleryOpen.value = true;
}
function onSelect(urls: string[]) {
emit('select', urls);
}
</script>
<template>
<div class="gallery-pick-link-wrap">
<a class="gallery-pick-link" :class="{ disabled }" @click.prevent="openGallery">从图库选择</a>
<ImageGalleryPicker
v-model:open="galleryOpen"
:multiple="multiple"
:max-count="maxCount"
@select="onSelect"
/>
</div>
</template>
<style scoped lang="scss">
.gallery-pick-link-wrap {
margin-top: 8px;
}
.gallery-pick-link {
font-size: 13px;
color: #165dff;
cursor: pointer;
user-select: none;
&:hover {
color: #4080ff;
}
&.disabled {
color: #c9cdd4;
cursor: not-allowed;
pointer-events: none;
}
}
.dark .gallery-pick-link {
color: #60a5fa;
&:hover {
color: #93c5fd;
}
&.disabled {
color: #6b7280;
}
}
</style>

View File

@@ -0,0 +1,200 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { Empty, Modal, Pagination, Spin } from 'ant-design-vue';
import type { FileGalleryItem } from '#/api/core/file-gallery';
import { getFileGalleryList } from '#/api/core/file-gallery';
const props = withDefaults(
defineProps<{
open: boolean;
multiple?: boolean;
maxCount?: number;
}>(),
{
multiple: false,
maxCount: 1,
},
);
const emit = defineEmits<{
'update:open': [value: boolean];
select: [urls: string[]];
}>();
const loading = ref(false);
const items = ref<FileGalleryItem[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(24);
const selected = ref<string[]>([]);
async function load() {
loading.value = true;
try {
const res = await getFileGalleryList({
page: page.value,
page_size: pageSize.value,
pid: 0,
type: 0,
});
const data = (res as any)?.data ?? res;
items.value = data?.items ?? [];
total.value = data?.total ?? 0;
} finally {
loading.value = false;
}
}
watch(
() => props.open,
(val) => {
if (val) {
selected.value = [];
page.value = 1;
load();
}
},
);
function toggleSelect(url: string) {
if (props.multiple) {
const idx = selected.value.indexOf(url);
if (idx >= 0) {
selected.value = selected.value.filter((u) => u !== url);
return;
}
if (selected.value.length >= props.maxCount) {
return;
}
selected.value = [...selected.value, url];
return;
}
selected.value = [url];
}
function isSelected(url: string) {
return selected.value.includes(url);
}
function handleOk() {
if (!selected.value.length) {
return;
}
emit('select', [...selected.value]);
emit('update:open', false);
}
function handleCancel() {
emit('update:open', false);
}
function onPageChange(p: number) {
page.value = p;
load();
}
</script>
<template>
<Modal
:open="open"
title="从图库选择"
width="820px"
:ok-button-props="{ disabled: !selected.length }"
@ok="handleOk"
@cancel="handleCancel"
>
<Spin :spinning="loading">
<div v-if="items.length" class="gallery-grid">
<div
v-for="item in items"
:key="item.id"
class="gallery-card"
:class="{ active: isSelected(item.url) }"
@click="toggleSelect(item.url)"
>
<img :src="item.url" alt="" class="gallery-thumb" />
<div class="gallery-meta">
<div>{{ item.file_size_text || '-' }}</div>
<div>{{ item.created_at || '-' }}</div>
</div>
</div>
</div>
<Empty v-else description="图库暂无图片" />
<div v-if="total > pageSize" class="gallery-pagination">
<Pagination
:current="page"
:page-size="pageSize"
:total="total"
size="small"
show-less-items
@change="onPageChange"
/>
</div>
</Spin>
</Modal>
</template>
<style scoped lang="scss">
@use './picker-card-theme.scss' as picker;
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(118px, 1fr));
gap: 12px;
max-height: 420px;
overflow-y: auto;
padding: 4px;
}
.gallery-card {
@include picker.picker-card-base;
padding: 6px;
&.active {
border-color: #165dff;
background: #f2f7ff;
box-shadow: 0 0 0 2px rgba(22, 93, 255, 0.15);
}
}
.gallery-thumb {
width: 100%;
height: 88px;
object-fit: cover;
border-radius: 6px;
background: #f5f5f5;
}
.gallery-meta {
font-size: 11px;
color: #86909c;
}
.gallery-pagination {
margin-top: 16px;
display: flex;
justify-content: center;
}
.dark {
.gallery-card {
@include picker.picker-card-dark-props;
&.active {
border-color: #60a5fa;
background: #374151;
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.2);
}
}
.gallery-thumb {
background: #374151;
}
.gallery-meta {
@include picker.picker-text-secondary-dark;
}
}
</style>

View File

@@ -3,7 +3,7 @@ import { computed, ref, watch } from 'vue';
import { Modal, Upload } from 'ant-design-vue';
import type { UploadFile } from 'ant-design-vue';
// 导入上传相关API和工具函数
import GalleryPickLink from '#/components/form/components/gallery-pick-link.vue';
import { uploadFile } from '#/api/core/upload';
import { uploadToOss } from '#/utils/oss-upload';
import {
@@ -13,7 +13,6 @@ import {
import { preferences } from '@vben/preferences';
import { Icon } from '#/components/icon';
// 定义接口
interface FileItem {
uid: string;
name: string;
@@ -21,7 +20,6 @@ interface FileItem {
url: string;
}
// 定义props
interface Props {
modelValue: string[];
multiple?: boolean;
@@ -40,7 +38,6 @@ const emit = defineEmits<{
const fileList = ref<FileItem[]>([]);
// 初始化fileList
const initFileList = () => {
fileList.value = props.modelValue.map((url) => ({
uid: url,
@@ -50,23 +47,26 @@ const initFileList = () => {
}));
};
// 初始化
initFileList();
// 监听外部modelValue变化,同步到fileList
watch(() => props.modelValue, (newVal) => {
const currentUrls = fileList.value.filter(file => file.status === 'done').map(file => file.url);
if (JSON.stringify(newVal) !== JSON.stringify(currentUrls)) {
fileList.value = newVal.map((url) => ({
uid: url,
name: url.split('/').pop() || 'file',
status: 'done' as const,
url,
}));
}
}, { deep: true });
watch(
() => props.modelValue,
(newVal) => {
const currentUrls = fileList.value
.filter((file) => file.status === 'done')
.map((file) => file.url);
if (JSON.stringify(newVal) !== JSON.stringify(currentUrls)) {
fileList.value = newVal.map((url) => ({
uid: url,
name: url.split('/').pop() || 'file',
status: 'done' as const,
url,
}));
}
},
{ deep: true },
);
// 统一通过updateModelValue函数更新modelValue
const updateModelValue = () => {
const urls = fileList.value
.filter((file) => file.status === 'done' && file.url)
@@ -74,36 +74,10 @@ const updateModelValue = () => {
emit('update:modelValue', urls);
};
/**
* 自定义上传请求处理函数
*
* 该函数根据preferences配置的上传方式,选择使用OSS直传或后端上传:
* - 'direct': OSS直传模式,文件直接从浏览器上传到阿里云OSS
* - 'backend': 后端上传模式,文件先上传到后端服务器,再由后端上传到OSS
*
* 两种上传方式的区别:
* 1. OSS直传(direct):
* - 优点:减少服务器负载,上传速度更快,用户体验更好
* - 缺点:需要后端提供签名接口,配置相对复杂
* - 实现:调用uploadToOss函数,直接上传到OSS
*
* 2. 后端上传(backend):
* - 优点:兼容现有功能,所有上传逻辑由后端统一处理
* - 缺点:增加服务器负载,上传速度相对较慢
* - 实现:调用uploadFile API,通过后端服务器上传
*
* 兼容性处理:
* - 两种上传方式返回的数据结构保持一致:{ url: string }
* - 确保无论使用哪种方式,组件的行为都是一致的
* - 如果上传失败,会更新文件状态为error,并调用onError回调
*
* @param options 上传选项,包含file、onProgress、onSuccess、onError等
*/
const customRequest = async (options: any) => {
const { file, onProgress, onSuccess, onError } = options;
const actualFile = resolveUploadFile(file as File);
// 创建上传中的文件项,用于在UI中显示上传状态
const uploadingFile: FileItem = {
uid: file.uid,
name: file.name,
@@ -111,107 +85,80 @@ const customRequest = async (options: any) => {
url: '',
};
// 将上传中的文件添加到文件列表
fileList.value = [...fileList.value, uploadingFile];
try {
// 从preferences中读取上传方式配置
// uploadMethod可能的值:'direct'(OSS直传)或 'backend'(后端上传)
const uploadMethod = preferences.app.uploadMethod || 'direct';
const res =
uploadMethod === 'direct'
? await uploadToOss({
file: actualFile,
onProgress: (percent) => {
if (onProgress) {
onProgress(percent);
}
},
})
: await uploadFile({ file: actualFile });
let res: { url: string };
// 根据配置选择上传方式
if (uploadMethod === 'direct') {
// OSS直传模式:文件直接从浏览器上传到OSS
// uploadToOss函数会:
// 1. 从后端获取OSS签名信息
// 2. 生成全局唯一的文件名(UUID + 时间戳)
// 3. 使用FormData构造POST请求直接上传到OSS
// 4. 处理上传进度和错误
// 5. 返回上传后的文件URL
res = await uploadToOss({
file: actualFile,
onProgress: (percent) => {
// 将OSS上传进度传递给组件
// percent范围:0-100,表示上传百分比
if (onProgress) {
onProgress(percent);
}
},
});
} else {
// 后端上传模式:文件先上传到后端服务器,再由后端上传到OSS
// uploadFile函数会:
// 1. 将文件发送到后端API(/upload/image)
// 2. 后端接收文件后上传到OSS
// 3. 返回上传后的文件URL
res = await uploadFile({
file: actualFile,
});
}
// 上传成功,更新文件状态为完成
// 将上传结果中的URL赋值给文件项
const updatedFileList = fileList.value.map(item =>
item.uid === file.uid
? { ...item, status: 'done' as const, url: res.url }
: item
fileList.value = fileList.value.map((item) =>
item.uid === file.uid ? { ...item, status: 'done' as const, url: res.url } : item,
);
fileList.value = updatedFileList;
// 更新组件的modelValue,触发父组件的更新
updateModelValue();
// 调用成功回调,通知上传组件上传已完成
onSuccess(res);
} catch (error) {
// 上传失败,记录错误信息
console.error('上传失败', error);
// 更新文件状态为错误
// 错误状态的文件会在UI中显示错误图标
const updatedFileList = fileList.value.map(item =>
item.uid === file.uid
? { ...item, status: 'error' as const }
: item
fileList.value = fileList.value.map((item) =>
item.uid === file.uid ? { ...item, status: 'error' as const } : item,
);
fileList.value = updatedFileList;
// 调用错误回调,通知上传组件上传失败
onError(error);
}
};
// 处理文件删除
const handleRemove = (file: UploadFile) => {
fileList.value = fileList.value.filter((item) => item.uid !== file.uid);
updateModelValue();
};
// 预览功能
const previewVisible = ref(false);
const previewImage = ref('');
const previewTitle = ref('');
function onGallerySelect(urls: string[]) {
const remain = props.maxCount - fileList.value.length;
const picked = props.multiple ? urls.slice(0, remain) : urls.slice(0, 1);
for (const url of picked) {
if (fileList.value.some((f) => f.url === url)) {
continue;
}
fileList.value = [
...fileList.value,
{
uid: url,
name: url.split('/').pop() || 'file',
status: 'done' as const,
url,
},
];
}
updateModelValue();
}
const handlePreview = async (file: UploadFile) => {
previewImage.value = file.url || '';
previewVisible.value = true;
previewTitle.value = file.name || file.url?.split('/').pop() || '';
};
// 计算是否显示上传按钮
const showUploadButton = computed(() =>
props.multiple
? fileList.value.length < props.maxCount
: fileList.value.length === 0,
props.multiple ? fileList.value.length < props.maxCount : fileList.value.length === 0,
);
const galleryRemainCount = computed(() => Math.max(props.maxCount - fileList.value.length, 0));
</script>
<template>
<div>
<div class="upload-image-wrap">
<Upload
:file-list="fileList"
:before-upload="beforeImageUpload"
@@ -223,23 +170,24 @@ const showUploadButton = computed(() =>
showRemoveIcon: true,
}"
list-type="picture-card"
accept="image/*"
@remove="handleRemove"
@preview="handlePreview"
accept="image/*"
>
<div v-if="showUploadButton" class="upload-button">
<!-- 恢复使用Icon组件 -->
<Icon icon="ant-design:plus-outlined" />
<div class="ant-upload-text">上传图片</div>
</div>
</Upload>
<Modal
v-model:visible="previewVisible"
:title="previewTitle"
footer=""
width="60%"
>
<GalleryPickLink
v-if="showUploadButton"
:multiple="multiple"
:max-count="galleryRemainCount"
@select="onGallerySelect"
/>
<Modal v-model:visible="previewVisible" :title="previewTitle" footer="" width="60%">
<img alt="预览图片" style="width: 100%" :src="previewImage" />
</Modal>
</div>