feat: 组件

This commit is contained in:
2023-07-18 22:06:20 +08:00
parent bb673dfd8a
commit 30a7f03650
14 changed files with 613 additions and 0 deletions

View File

@ -59,6 +59,9 @@
"@types/node": "^20.3.1",
"@vitejs/plugin-vue": "^4.1.0",
"@vitejs/plugin-vue-jsx": "^3.0.1",
"@vue-office/docx": "^1.2.0",
"@vue-office/excel": "^1.2.2",
"@vue-office/pdf": "^1.2.0",
"@vue/tsconfig": "^0.4.0",
"@vueuse/core": "^10.2.0",
"co": "^4.6.0",
@ -88,6 +91,8 @@
"vite-plugin-eslint": "^1.8.1",
"vite-plugin-libcss": "^1.1.0",
"vite-plugin-vue-setup-extend": "^0.4.0",
"vue-cropper": "1.0.8",
"vue-demi": "^0.13.11",
"vue-router": "^4.2.2",
"vue-tsc": "^1.4.2"
}

View File

@ -0,0 +1,42 @@
<!--
* @Author: zhaojinfeng 121016171@qq.com
* @Date: 2023-07-18 12:22:11
* @LastEditors: zhaojinfeng 121016171@qq.com
* @LastEditTime: 2023-07-18 12:46:10
* @FilePath: \vue3\packages\preview-office-view\index.vue
* @Description:
*
-->
<template>
<div class="preview-office-view overflow-hidden h-full">
<template v-if="src">
<VueOfficeDocx v-if="extension === 'docx'" class="h-full" :src="src" />
<VueOfficeExcel v-else-if="extension === 'xlsx'" class="h-full" :src="src" />
<VueOfficePdf v-else-if="extension === 'pdf'" class="h-full" :src="src" />
</template>
<el-empty v-else>
<template #description>
暂不支持.{{ extension }}格式的文件
</template>
</el-empty>
</div>
</template>
<script lang="ts" setup name="ThPreviewOfficeView">
import VueOfficePdf from '@vue-office/pdf'
import VueOfficeDocx from '@vue-office/docx'
import VueOfficeExcel from '@vue-office/excel'
import '@vue-office/excel/lib/index.css'
import '@vue-office/docx/lib/index.css'
const route = useRoute()
const { url, extension } = route.query
const src = computed(() => {
if (typeof url === 'string')
return url
if (Array.isArray(url))
return url[0]
return ''
})
</script>

View File

@ -0,0 +1,37 @@
<!--
* @Author: zhaojinfeng 121016171@qq.com
* @Date: 2023-07-18 12:21:03
* @LastEditors: zhaojinfeng 121016171@qq.com
* @LastEditTime: 2023-07-18 13:02:44
* @FilePath: \vue3\packages\preview-office\index.vue
* @Description:
*
-->
<template>
<iframe v-if="src" class="h-full w-full" title="Office预览" :src="src" />
<el-empty v-else />
</template>
<script lang="ts" setup name="ThPreviewOffice">
const { file } = defineProps<{
/** 待预览的文件对象 */
file?: FileVO
}>()
const router = useRouter()
const ALLOW_EXTENSION: (string | undefined)[] = ['docx', 'xlsx', 'pdf']
const src = computed(() => {
if (!file || !ALLOW_EXTENSION.includes(file.extension))
return ''
return router.resolve({
name: 'preview-office',
query: {
url: file.url,
extension: file.extension,
},
}).href
})
</script>

View File

@ -0,0 +1,8 @@
<template>
<div>
SelectTableModal
</div>
</template>
<script lang="ts" setup name="ThSelectTableModal">
</script>

View File

@ -0,0 +1,239 @@
<template>
<div class="upload-avatar">
<div class="el-upload el-upload--picture-card" @click="showDialog = true">
<image-avatar
v-if="avatarUrl"
:file-id="avatarUrl"
:size="146"
/>
<el-icon v-else class="avatar-uploader-icon">
<plus />
</el-icon>
</div>
<slot name="text">
<div class="el-upload__tip w-400px">
<slot name="tip">
单张图片不大于{{ fileSize }}MB且格式为jpegjpgpng或bmp只可上传1张
</slot>
</div>
</slot>
</div>
<el-dialog
v-model="showDialog"
title="上传头像"
width="800px"
append-to-body
@opened="modalOpened"
@closed="closeDialog"
>
<el-row>
<el-col :xs="24" :md="12" :style="{ height: '350px' }">
<VueCropper
v-if="visible"
ref="cropperRef"
:img="cropperURL"
:info="true"
:auto-crop="options.autoCrop"
:auto-crop-width="autoCropWidth"
:auto-crop-height="autoCropHeight"
:fixed-box="options.fixedBox"
:output-type="options.outputType"
@real-time="realTime"
/>
</el-col>
<el-col :xs="24" :md="12" :style="{ height: '350px' }">
<div
class="avatar-upload-preview"
:style="imageStyle"
>
<img
v-show="options.previews.url"
:src="options.previews.url"
:style="options.previews.img"
alt="实时预览图片"
>
</div>
</el-col>
</el-row>
<br>
<el-row>
<el-col :lg="2" :md="2">
<el-button @click="selectImage">
选择
<el-icon class="el-icon--right">
<Upload />
</el-icon>
</el-button>
</el-col>
<el-col :lg="{ span: 1, offset: 2 }" :md="2">
<el-button icon="Plus" @click="changeScale(1)" />
</el-col>
<el-col :lg="{ span: 1, offset: 1 }" :md="2">
<el-button icon="Minus" @click="changeScale(-1)" />
</el-col>
<el-col :lg="{ span: 1, offset: 1 }" :md="2">
<el-button icon="RefreshLeft" @click="rotateLeft()" />
</el-col>
<el-col :lg="{ span: 1, offset: 1 }" :md="2">
<el-button icon="RefreshRight" @click="rotateRight()" />
</el-col>
<el-col :lg="{ span: 2, offset: 6 }" :md="2">
<el-button type="primary" :loading="loading" @click="confirmUpload">
</el-button>
</el-col>
</el-row>
</el-dialog>
</template>
<script lang="ts" setup name="ThUploadAvatar">
import { ElMessage } from 'element-plus'
import type { CSSProperties } from 'vue'
import { VueCropper } from 'vue-cropper'
const props = withDefaults(defineProps<{
accpet?: string
modelValue?: string
/**
* 图片大小单位MB
*
* @default 2
*/
fileSize?: number
/** 默认生成截图框宽度 */
autoCropHeight?: number
/** 默认生成截图框高度 */
autoCropWidth?: number
uploadFunction?: (...args: any) => Promise<FileVO>
}>(), {
accpet: '.jpeg,.jpg,.png,.bmp',
fileSize: 2,
autoCropHeight: 200,
autoCropWidth: 200,
uploadFunction: () => Promise.resolve({}),
})
const emit = defineEmits<{
(event: 'update:modelValue', modelValue?: string): void
(event: 'onSuccess', file: FileVO): void
(event: 'onError', error: Error): void
}>()
const avatarUrl = useVModel(props, 'modelValue', emit)
const imageStyle = computed(() => ({
height: `${props.autoCropHeight}px`,
width: `${props.autoCropWidth}px`,
}))
let loading = $ref(false)
interface PreviewData {
div: CSSProperties
h: number
html: string
img: CSSProperties
url: string
w: number
}
interface Option {
/** 裁剪图片的地址 */
img: any
/** 是否默认生成截图框 */
autoCrop: boolean
/** 固定截图框大小 不允许改变 */
fixedBox: boolean
/** 默认生成截图为PNG格式 */
outputType: string
/** 预览数据 */
previews: Partial<PreviewData>
visible: boolean
}
// 图片裁剪数据
const options = reactive<Option>({
img: '',
autoCrop: true,
fixedBox: true,
outputType: 'png',
previews: {},
visible: false,
})
const visible = ref(false)
const cropperRef = $ref<InstanceType<typeof VueCropper>>()
const { open, reset, onChange } = useFileDialog()
let showDialog = $ref(false)
/** 向左旋转 */
function rotateLeft() {
cropperRef?.rotateLeft()
}
/** 向右旋转 */
function rotateRight() {
cropperRef?.rotateRight()
}
/** 打开弹出层结束时的回调 */
function modalOpened() {
visible.value = true
}
/** 图片缩放 */
function changeScale(num?: number) {
cropperRef?.changeScale(num || 1)
}
/** 实时预览 */
function realTime(data: PreviewData) {
options.previews = data
}
const file = shallowRef()
const cropperURL = useObjectUrl(file)
onChange((param) => {
file.value = param?.item(0)
})
function selectImage() {
open({
accept: props.accpet,
multiple: false,
})
}
async function uploadImg(blob: Blob) {
if (blob.size > props.fileSize * 1024 * 1024) {
ElMessage.error('您的图片经过剪裁依然很大,请重新选择图片')
return
}
loading = true
const formData = new FormData()
formData.append('file', blob)
try {
const res = await props.uploadFunction(formData)
avatarUrl.value = res.url
showDialog = false
emit('onSuccess', res)
}
catch (err: any) {
emit('onError', err)
loading = false
}
}
function confirmUpload() {
cropperRef?.getCropBlob(uploadImg)
}
/** 关闭窗口 */
function closeDialog() {
options.visible = false
reset()
loading = false
}
</script>
<style>
@import url('vue-cropper/dist/index.css');
</style>

View File

@ -0,0 +1,122 @@
<template>
<el-table border :data="fileList">
<el-table-column align="center" :label="label1">
<template #default="{ row }">
<template v-if="row.createdBy">
{{ row.name }}
</template>
<el-input
v-else v-model.trim="allName[`${row.id}`]" clearable :loading="allLoading[`${row.id}`]"
placeholder="不填则为默认文件名称"
/>
</template>
</el-table-column>
<el-table-column align="center" :label="label2">
<template #default="{ row, $index }">
<upload-file-link v-if="row.createdBy" :file-id="row.id" :file-name="row.name">
{{ row.name }}
</upload-file-link>
<upload-file-single
v-else :accept="accept" :auto-upload="false" :index-key="`${row.id}`" :max-size="20"
:file-name="allName[`${row.id}`]" :loading="allLoading[`${row.id}`]"
@on-change="$event => onChange($event, row.id)" @on-success="$event => onSuccess($event, row, $index)"
@on-error="onError(row.id)"
/>
</template>
</el-table-column>
<el-table-column align="center" :label="label3" width="200px">
<template #default="{ row, $index }">
<el-popconfirm v-if="row.createdBy" :title="popconfirm" :disabled="disabled" @confirm="remove($index)">
<template #reference>
<el-button type="danger" :disabled="disabled" link>
删除
</el-button>
</template>
</el-popconfirm>
<template v-else>
<el-button type="primary" :loading="allLoading[`${row.id}`]" link @click="save(row.id)">
保存
</el-button>
<el-button type="danger" :loading="allLoading[`${row.id}`]" link @click="remove($index)">
取消
</el-button>
</template>
</template>
</el-table-column>
</el-table>
</template>
<script lang="ts" setup name="ThUploadTable">
import type { UploadFile } from 'element-plus'
const props = withDefaults(defineProps<{
/** 上传格式 */
accept?: string
/** 列1的文字 */
label1?: string
/** 列2的文字 */
label2?: string
/** 列3的文字 */
label3?: string
disabled?: boolean
/** 文件列表支持v-model */
fileList?: FileVO[]
/** 删除按钮提示文字 */
popconfirm?: string
}>(), {
accept: '.jpeg,.jpg,.png,.bmp',
label1: '文件名称',
label2: '图片上传',
label3: '操作',
fileList: () => [],
popconfirm: '是否删除本行?',
})
const emit = defineEmits<{
(event: 'update:fileList', fileList?: FileVO[]): void
}>()
const allName = reactive<Record<string, string | undefined>>({})
const allLoading = reactive<Record<string, boolean | undefined>>({})
function onSuccess(file: FileVO, row: FileVO, index: number) {
const indexKey = `${row.id}`
const newList = [...props.fileList.slice(0, index), file, ...props.fileList.slice(index + 1)]
emit('update:fileList', newList)
allLoading[indexKey] = false
}
function onError(id: number) {
const indexKey = `${id}`
allLoading[indexKey] = false
}
function onChange(uploadFile: UploadFile, id: number) {
const indexKey = `${id}`
if (uploadFile.status !== 'ready') {
allLoading[indexKey] = false
return
}
if (allName[indexKey])
return
const arr = uploadFile.name.split('.')
arr.pop()
/** 去除拓展名的文件名 */
const newFilename = arr.join('.')
// 特殊情况时例如:.npmrc 使用原始文件名
allName[indexKey] = newFilename || uploadFile.name
}
function save(id: number) {
const indexKey = `${id}`
allLoading[indexKey] = true
}
function remove(index: number) {
const newList = [...props.fileList.slice(0, index), ...props.fileList.slice(index + 1)]
emit('update:fileList', newList)
}
</script>

View File

@ -0,0 +1,13 @@
import type { Meta, StoryObj } from '@storybook/vue3'
import ThPreviewOffice from '../packages/preview-office/index.vue'
const meta = {
title: '数据展示/Office预览/PreviewOffice',
component: ThPreviewOffice,
tags: ['autodocs'],
} satisfies Meta<typeof ThPreviewOffice>
export default meta
type Story = StoryObj<typeof meta>
export const Base: Story = {}

View File

@ -0,0 +1,22 @@
/*
* @Author: zhaojinfeng 121016171@qq.com
* @Date: 2023-07-18 12:22:11
* @LastEditors: zhaojinfeng 121016171@qq.com
* @LastEditTime: 2023-07-18 12:39:40
* @FilePath: \vue3\stories\PreviewOfficeView.stories.ts
* @Description:
*
*/
import type { Meta, StoryObj } from '@storybook/vue3'
import ThPreviewOfficeView from '../packages/preview-office-view/index.vue'
const meta = {
title: '数据展示/Office预览/PreviewOfficeView',
component: ThPreviewOfficeView,
tags: ['autodocs'],
} satisfies Meta<typeof ThPreviewOfficeView>
export default meta
type Story = StoryObj<typeof meta>
export const Base: Story = {}

View File

@ -0,0 +1,13 @@
import type { Meta, StoryObj } from '@storybook/vue3'
import ThSelectTableModal from '../packages/select-table-modal/index.vue'
const meta = {
title: '表单组件/SelectTableModal',
component: ThSelectTableModal,
tags: ['autodocs'],
} satisfies Meta<typeof ThSelectTableModal>
export default meta
type Story = StoryObj<typeof meta>
export const Base: Story = {}

View File

@ -0,0 +1,13 @@
import type { Meta, StoryObj } from '@storybook/vue3'
import ThUploadAvatar from '../packages/upload-avatar/index.vue'
const meta = {
title: '表单组件/UploadAvatar',
component: ThUploadAvatar,
tags: ['autodocs'],
} satisfies Meta<typeof ThUploadAvatar>
export default meta
type Story = StoryObj<typeof meta>
export const Base: Story = {}

View File

@ -0,0 +1,32 @@
/*
* @Author: zhaojinfeng 121016171@qq.com
* @Date: 2023-07-18 12:23:37
* @LastEditors: zhaojinfeng 121016171@qq.com
* @LastEditTime: 2023-07-18 16:11:39
* @FilePath: \vue3\stories\UploadTable.stories.ts
* @Description:
*
*/
import type { Meta, StoryObj } from '@storybook/vue3'
import ThUploadTable from '../packages/upload-table/index.vue'
const meta = {
title: '表单组件/UploadTable',
component: ThUploadTable,
tags: ['autodocs'],
args: {
accept: '.jpeg,.jpg,.png,.bmp',
label1: '文件名称',
label2: '图片上传',
label3: '操作',
fileList: [],
},
argTypes: {
label1: { control: 'select', options: ['small', 'medium', 'large'] },
},
} satisfies Meta<typeof ThUploadTable>
export default meta
type Story = StoryObj<typeof meta>
export const Base: Story = {}

15
types/components.d.ts vendored
View File

@ -8,8 +8,23 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
Button: typeof import('./../packages/button/index.vue')['default']
ElButton: typeof import('element-plus/es')['ElButton']
ElCol: typeof import('element-plus/es')['ElCol']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm']
ElRow: typeof import('element-plus/es')['ElRow']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
Header: typeof import('./../packages/header/index.vue')['default']
PreviewOffice: typeof import('./../packages/preview-office/index.vue')['default']
PreviewOfficeView: typeof import('./../packages/preview-office-view/index.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SelectTableModal: typeof import('./../packages/select-table-modal/index.vue')['default']
UploadAvatar: typeof import('./../packages/upload-avatar/index.vue')['default']
UploadTable: typeof import('./../packages/upload-table/index.vue')['default']
}
}

27
types/file.d.ts vendored Normal file
View File

@ -0,0 +1,27 @@
declare interface FileVO {
/**
* 文件VO
*
*/
id?: number
/**
* 文件名称
*
*/
name?: string
/**
* 文件大小(字节)
*
*/
size?: number
/**
* 文件扩展名
*
*/
extension?: string
/**
* 文件url
*
*/
url?: string
}

View File

@ -3480,6 +3480,21 @@
"@volar/typescript" "1.4.1-patch.2"
"@volar/vue-language-core" "1.6.5"
"@vue-office/docx@^1.2.0":
version "1.2.0"
resolved "https://registry.npmmirror.com/@vue-office/docx/-/docx-1.2.0.tgz#7b490cbfd4f37c33bc8bd12c2a64f4a989dee1a0"
integrity sha512-aeWr/q+mRFtfaLpz94uZ4RkxAhwUGCJsDfLQQ76n5g2dgVbZPbXx3eKxGuw/1B+phm88bJNLYbT7Onbjs+j9fg==
"@vue-office/excel@^1.2.2":
version "1.2.2"
resolved "https://registry.npmmirror.com/@vue-office/excel/-/excel-1.2.2.tgz#345b9b0b3c70a9abb31f1054f949249c2a71566b"
integrity sha512-Zei6fF7Nb/j1CNfKsJuE9Yubck3UEuw9HwVVf++atKpgRpKYMlEBV/yDJ5aHhKiW2Do6nuitH4eGqmjpluZ4Lg==
"@vue-office/pdf@^1.2.0":
version "1.2.0"
resolved "https://registry.npmmirror.com/@vue-office/pdf/-/pdf-1.2.0.tgz#0abdbf2ed18bb9094a78f441ff659ac4e6fc200e"
integrity sha512-1tCl7Oq9lu6JnqB+4zVdIDsvrsQT/tTb5Nv8d6UCWx7S7KBcEjmjHCM+XVEfbr0JPP4c6IRAbNe+Hs+EZMX2aw==
"@vue/babel-helper-vue-transform-on@^1.0.2":
version "1.0.2"
resolved "https://registry.npmmirror.com/@vue/babel-helper-vue-transform-on/-/babel-helper-vue-transform-on-1.0.2.tgz#9b9c691cd06fc855221a2475c3cc831d774bc7dc"
@ -9419,11 +9434,21 @@ vue-component-type-helpers@^1.6.5:
resolved "https://registry.npmmirror.com/vue-component-type-helpers/-/vue-component-type-helpers-1.6.5.tgz#ff6b75529063744b0966655725f3e02f30d97cd7"
integrity sha512-iGdlqtajmiqed8ptURKPJ/Olz0/mwripVZszg6tygfZSIL9kYFPJTNY6+Q6OjWGznl2L06vxG5HvNvAnWrnzbg==
vue-cropper@1.0.8:
version "1.0.8"
resolved "https://registry.npmmirror.com/vue-cropper/-/vue-cropper-1.0.8.tgz#05853bb7702557d05a4784a8d0cd072b57dd8e4f"
integrity sha512-EX9XoT5a/PQ62J6KDZz8hhaFi9ME1k2yBZlRfYCm8iySzTcjw0nDBq8Y65HtyHaH2jJwUKgYfD6mdFCE0GhUzA==
vue-demi@*, vue-demi@>=0.14.5:
version "0.14.5"
resolved "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.5.tgz#676d0463d1a1266d5ab5cba932e043d8f5f2fbd9"
integrity sha512-o9NUVpl/YlsGJ7t+xuqJKx8EBGf1quRhCiT6D/J0pfwmk9zUwYkC7yrF4SZCe6fETvSM3UNL2edcbYrSyc4QHA==
vue-demi@^0.13.11:
version "0.13.11"
resolved "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.13.11.tgz#7d90369bdae8974d87b1973564ad390182410d99"
integrity sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==
vue-docgen-api@^4.40.0:
version "4.72.5"
resolved "https://registry.npmmirror.com/vue-docgen-api/-/vue-docgen-api-4.72.5.tgz#446bd2b98ee858740302ec7aaffdb3b1b1e8ca80"