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
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:
29
apps/web-antd/src/api/core/file-gallery.ts
Normal file
29
apps/web-antd/src/api/core/file-gallery.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
const prefix = 'file-gallery/';
|
||||
|
||||
export interface FileGalleryItem {
|
||||
id: number;
|
||||
url: string;
|
||||
file_size: number;
|
||||
file_size_text: string;
|
||||
created_at?: string;
|
||||
original_name?: string;
|
||||
}
|
||||
|
||||
export async function getFileGalleryList(params?: { page?: number; page_size?: number; pid?: number; type?: number }) {
|
||||
return requestClient.get<{
|
||||
items: FileGalleryItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}>(`${prefix}list`, { params });
|
||||
}
|
||||
|
||||
export async function deleteFileGalleryItem(id: number) {
|
||||
return requestClient.post<any>(`${prefix}delete`, { id });
|
||||
}
|
||||
|
||||
export async function syncFileGalleryFromOss() {
|
||||
return requestClient.post<{ added: number; skipped: number }>(`${prefix}sync-oss`);
|
||||
}
|
||||
@@ -61,7 +61,7 @@ export async function getOssSignature() {
|
||||
/**
|
||||
* OSS 直传成功后登记 xk_file
|
||||
*/
|
||||
export async function registerOssFile(data: { url: string; type?: number; source?: number }) {
|
||||
export async function registerOssFile(data: { url: string; type?: number; source?: number; file_size?: number }) {
|
||||
return requestClient.post('/upload/register-oss-file', data);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,67 +26,43 @@ 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>
|
||||
<div class="avatar-upload-wrap">
|
||||
<Upload
|
||||
:before-upload="beforeImageUpload"
|
||||
:custom-request="customRequest"
|
||||
@@ -99,20 +75,28 @@ const handleRemove = (e: Event) => {
|
||||
icon="ant-design:delete-outlined"
|
||||
@click="handleRemove"
|
||||
/>
|
||||
<img :src="value" width="100%" />
|
||||
<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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,12 +47,14 @@ const initFileList = () => {
|
||||
}));
|
||||
};
|
||||
|
||||
// 初始化
|
||||
initFileList();
|
||||
|
||||
// 监听外部modelValue变化,同步到fileList
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
const currentUrls = fileList.value.filter(file => file.status === 'done').map(file => file.url);
|
||||
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,
|
||||
@@ -64,9 +63,10 @@ watch(() => props.modelValue, (newVal) => {
|
||||
url,
|
||||
}));
|
||||
}
|
||||
}, { deep: true });
|
||||
},
|
||||
{ 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';
|
||||
|
||||
let res: { url: string };
|
||||
|
||||
// 根据配置选择上传方式
|
||||
if (uploadMethod === 'direct') {
|
||||
// OSS直传模式:文件直接从浏览器上传到OSS
|
||||
// uploadToOss函数会:
|
||||
// 1. 从后端获取OSS签名信息
|
||||
// 2. 生成全局唯一的文件名(UUID + 时间戳)
|
||||
// 3. 使用FormData构造POST请求直接上传到OSS
|
||||
// 4. 处理上传进度和错误
|
||||
// 5. 返回上传后的文件URL
|
||||
res = await uploadToOss({
|
||||
const res =
|
||||
uploadMethod === 'direct'
|
||||
? 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,
|
||||
});
|
||||
}
|
||||
})
|
||||
: 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>
|
||||
|
||||
@@ -286,7 +286,7 @@ export async function uploadToOss(options: UploadOptions): Promise<UploadResult>
|
||||
// 注意:使用后端返回的host,确保配置的灵活性
|
||||
const fileUrl = `${signature.host}/${objectName}`;
|
||||
|
||||
registerOssFile({ url: fileUrl, source: 0 }).catch((err) => {
|
||||
registerOssFile({ url: fileUrl, source: 0, file_size: file.size }).catch((err) => {
|
||||
console.warn('OSS 文件登记失败:', err);
|
||||
});
|
||||
|
||||
|
||||
@@ -164,6 +164,19 @@ export const modalFormProps: VbenFormProps = {
|
||||
label: '简介',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
min: 0,
|
||||
precision: 2,
|
||||
placeholder: '请输入挂号费',
|
||||
class: 'w-full',
|
||||
},
|
||||
fieldName: 'register_price',
|
||||
formItemClass: 'col-span-6',
|
||||
label: '挂号费',
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
component: 'Avatar',
|
||||
componentProps: {
|
||||
|
||||
169
apps/web-antd/src/views/system/file-gallery/index.vue
Normal file
169
apps/web-antd/src/views/system/file-gallery/index.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Button, Card, Empty, Modal, Pagination, Spin, message } from 'ant-design-vue';
|
||||
|
||||
import type { FileGalleryItem } from '#/api/core/file-gallery';
|
||||
import {
|
||||
deleteFileGalleryItem,
|
||||
getFileGalleryList,
|
||||
syncFileGalleryFromOss,
|
||||
} from '#/api/core/file-gallery';
|
||||
|
||||
defineOptions({ name: 'FileGallery' });
|
||||
|
||||
const loading = ref(false);
|
||||
const syncing = ref(false);
|
||||
const items = ref<FileGalleryItem[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(24);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSync() {
|
||||
syncing.value = true;
|
||||
try {
|
||||
const res = await syncFileGalleryFromOss();
|
||||
const data = (res as any)?.data ?? res;
|
||||
message.success(`同步完成:扫描 ${data?.scanned ?? 0} 个,新增 ${data?.added ?? 0} 张,跳过 ${data?.skipped ?? 0} 张`);
|
||||
await load();
|
||||
} finally {
|
||||
syncing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(item: FileGalleryItem) {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '删除后将同时移除 OSS 中的文件,此操作不可恢复,是否继续?',
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
await deleteFileGalleryItem(item.id);
|
||||
message.success('删除成功');
|
||||
await load();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function onPageChange(p: number) {
|
||||
page.value = p;
|
||||
load();
|
||||
}
|
||||
|
||||
load();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height title="图库管理">
|
||||
<Card>
|
||||
<div class="toolbar">
|
||||
<Button type="primary" :loading="syncing" @click="handleSync">同步 OSS 图片</Button>
|
||||
<Button :loading="loading" @click="load">刷新</Button>
|
||||
</div>
|
||||
|
||||
<Spin :spinning="loading">
|
||||
<div v-if="items.length" class="gallery-grid">
|
||||
<div v-for="item in items" :key="item.id" class="gallery-card">
|
||||
<img :src="item.url" alt="" class="gallery-thumb" />
|
||||
<div class="gallery-meta">
|
||||
<div>{{ item.file_size_text || '-' }}</div>
|
||||
<div>{{ item.created_at || '-' }}</div>
|
||||
</div>
|
||||
<Button danger size="small" block @click="handleDelete(item)">删除</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Empty v-else description="暂无图片,可点击「同步 OSS 图片」导入" />
|
||||
</Spin>
|
||||
|
||||
<div v-if="total > pageSize" class="gallery-pagination">
|
||||
<Pagination
|
||||
:current="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
show-size-changer
|
||||
@change="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '#/components/form/components/picker-card-theme.scss' as theme;
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.gallery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.gallery-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.gallery-thumb {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.gallery-meta {
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.gallery-pagination {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dark {
|
||||
.gallery-card {
|
||||
@include theme.picker-card-dark-props;
|
||||
}
|
||||
|
||||
.gallery-thumb {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
.gallery-meta {
|
||||
@include theme.picker-text-secondary-dark;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -3,7 +3,7 @@ import { onMounted, ref } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
|
||||
import { Button, Card, Input, InputNumber, message, Radio, Space, Switch, Tag } from 'ant-design-vue';
|
||||
|
||||
import type { QuickDiscountOption } from '#/utils/pricePercentAdjust';
|
||||
|
||||
@@ -24,6 +24,7 @@ const quickOptions = ref<QuickDiscountOption[]>([
|
||||
]);
|
||||
const newQuickName = ref('');
|
||||
const newQuickValue = ref<number | null>(null);
|
||||
const inputAutoAuditPass = ref(true);
|
||||
|
||||
function parseQuickOptions(raw: unknown): QuickDiscountOption[] {
|
||||
if (typeof raw === 'string') {
|
||||
@@ -59,6 +60,9 @@ async function load() {
|
||||
if (row.config_key === 'order_discount_quick_options') {
|
||||
quickOptions.value = parseQuickOptions(row.config_value);
|
||||
}
|
||||
if (row.config_key === 'input_auto_audit_pass') {
|
||||
inputAutoAuditPass.value = row.config_value === '1' || row.config_value === true || row.config_value === 'true';
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
@@ -92,6 +96,10 @@ async function handleSave() {
|
||||
config_key: 'order_discount_quick_options',
|
||||
config_value: JSON.stringify(quickOptions.value),
|
||||
},
|
||||
{
|
||||
config_key: 'input_auto_audit_pass',
|
||||
config_value: inputAutoAuditPass.value ? '1' : '0',
|
||||
},
|
||||
]);
|
||||
message.success('保存成功');
|
||||
} finally {
|
||||
@@ -135,5 +143,13 @@ onMounted(load);
|
||||
|
||||
<Button type="primary" :loading="saving" @click="handleSave">保存配置</Button>
|
||||
</Card>
|
||||
|
||||
<Card :loading="loading" class="mt-4" title="录入审核配置">
|
||||
<div class="mb-2 font-medium">诊所/医生信息预填录入后是否自动通过审核</div>
|
||||
<Switch v-model:checked="inputAutoAuditPass" checked-children="是" un-checked-children="否" />
|
||||
<div class="mt-4">
|
||||
<Button type="primary" :loading="saving" @click="handleSave">保存配置</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user