前端代码
This commit is contained in:
@@ -1,36 +0,0 @@
|
||||
import { GithubOutlined } from '@ant-design/icons';
|
||||
import { DefaultFooter } from '@ant-design/pro-components';
|
||||
import React from 'react';
|
||||
|
||||
const Footer: React.FC = () => {
|
||||
return (
|
||||
<DefaultFooter
|
||||
style={{
|
||||
background: 'none',
|
||||
}}
|
||||
copyright="Powered by Ant Desgin"
|
||||
links={[
|
||||
{
|
||||
key: 'Ant Design Pro',
|
||||
title: 'Ant Design Pro',
|
||||
href: 'https://pro.ant.design',
|
||||
blankTarget: true,
|
||||
},
|
||||
{
|
||||
key: 'github',
|
||||
title: <GithubOutlined />,
|
||||
href: 'https://github.com/ant-design/ant-design-pro',
|
||||
blankTarget: true,
|
||||
},
|
||||
{
|
||||
key: 'Ant Design',
|
||||
title: 'Ant Design',
|
||||
href: 'https://ant.design',
|
||||
blankTarget: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
@@ -1,41 +0,0 @@
|
||||
import { Dropdown } from 'antd';
|
||||
import type { DropDownProps } from 'antd/es/dropdown';
|
||||
import { createStyles } from 'antd-style';
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
const useStyles = createStyles(({ token }) => {
|
||||
return {
|
||||
dropdown: {
|
||||
[`@media screen and (max-width: ${token.screenXS}px)`]: {
|
||||
width: '100%',
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export type HeaderDropdownProps = {
|
||||
overlayClassName?: string;
|
||||
placement?:
|
||||
| 'bottomLeft'
|
||||
| 'bottomRight'
|
||||
| 'topLeft'
|
||||
| 'topCenter'
|
||||
| 'topRight'
|
||||
| 'bottomCenter';
|
||||
} & Omit<DropDownProps, 'overlay'>;
|
||||
|
||||
const HeaderDropdown: React.FC<HeaderDropdownProps> = ({
|
||||
overlayClassName: cls,
|
||||
...restProps
|
||||
}) => {
|
||||
const { styles } = useStyles();
|
||||
return (
|
||||
<Dropdown
|
||||
overlayClassName={classNames(styles.dropdown, cls)}
|
||||
{...restProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeaderDropdown;
|
||||
@@ -1,142 +0,0 @@
|
||||
import { services } from "@/services";
|
||||
import {
|
||||
LogoutOutlined,
|
||||
SettingOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { history, useModel } from "@umijs/max";
|
||||
import type { MenuProps } from "antd";
|
||||
import { Spin } from "antd";
|
||||
import { createStyles } from "antd-style";
|
||||
import React from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import HeaderDropdown from "../HeaderDropdown";
|
||||
|
||||
export type GlobalHeaderRightProps = {
|
||||
menu?: boolean;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const AvatarName = () => {
|
||||
const { initialState } = useModel("@@initialState");
|
||||
const { currentUser } = initialState || {};
|
||||
return <span className="anticon">{currentUser?.name}</span>;
|
||||
};
|
||||
|
||||
const useStyles = createStyles(({ token }) => {
|
||||
return {
|
||||
action: {
|
||||
display: "flex",
|
||||
height: "48px",
|
||||
marginLeft: "auto",
|
||||
overflow: "hidden",
|
||||
alignItems: "center",
|
||||
padding: "0 8px",
|
||||
cursor: "pointer",
|
||||
borderRadius: token.borderRadius,
|
||||
"&:hover": {
|
||||
backgroundColor: token.colorBgTextHover,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const AvatarDropdown: React.FC<GlobalHeaderRightProps> = ({
|
||||
menu,
|
||||
children,
|
||||
}) => {
|
||||
/**
|
||||
* 退出登录,并且将当前的 url 保存
|
||||
*/
|
||||
const loginOut = async () => {
|
||||
await services.admin.Logout({});
|
||||
const { search, pathname } = window.location;
|
||||
const urlParams = new URL(window.location.href).searchParams;
|
||||
const searchParams = new URLSearchParams({
|
||||
redirect: pathname + search,
|
||||
});
|
||||
/** 此方法会跳转到 redirect 参数所在的位置 */
|
||||
const redirect = urlParams.get("redirect");
|
||||
// Note: There may be security issues, please note
|
||||
if (window.location.pathname !== "/user/login" && !redirect) {
|
||||
history.replace({
|
||||
pathname: "/user/login",
|
||||
search: searchParams.toString(),
|
||||
});
|
||||
}
|
||||
};
|
||||
const { styles } = useStyles();
|
||||
|
||||
const { initialState, setInitialState } = useModel("@@initialState");
|
||||
|
||||
const onMenuClick: MenuProps["onClick"] = (event) => {
|
||||
const { key } = event;
|
||||
if (key === "logout") {
|
||||
flushSync(() => {
|
||||
setInitialState((s) => ({ ...s, currentUser: undefined }));
|
||||
});
|
||||
loginOut();
|
||||
return;
|
||||
}
|
||||
history.push(`/account/${key}`);
|
||||
};
|
||||
|
||||
const loading = (
|
||||
<span className={styles.action}>
|
||||
<Spin
|
||||
size="small"
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
marginRight: 8,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
|
||||
if (!initialState) {
|
||||
return loading;
|
||||
}
|
||||
|
||||
const { currentUser } = initialState;
|
||||
|
||||
if (!currentUser || !currentUser.name) {
|
||||
return loading;
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
...(menu
|
||||
? [
|
||||
{
|
||||
key: "center",
|
||||
icon: <UserOutlined />,
|
||||
label: "个人中心",
|
||||
},
|
||||
{
|
||||
key: "settings",
|
||||
icon: <SettingOutlined />,
|
||||
label: "个人设置",
|
||||
},
|
||||
{
|
||||
type: "divider" as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: "logout",
|
||||
icon: <LogoutOutlined />,
|
||||
label: "退出登录",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<HeaderDropdown
|
||||
menu={{
|
||||
selectedKeys: [],
|
||||
onClick: onMenuClick,
|
||||
items: menuItems,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</HeaderDropdown>
|
||||
);
|
||||
};
|
||||
@@ -1,32 +0,0 @@
|
||||
import { QuestionCircleOutlined } from '@ant-design/icons';
|
||||
import { SelectLang as UmiSelectLang } from '@umijs/max';
|
||||
|
||||
export type SiderTheme = 'light' | 'dark';
|
||||
|
||||
export const SelectLang: React.FC = () => {
|
||||
return (
|
||||
<UmiSelectLang
|
||||
style={{
|
||||
padding: 4,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Question: React.FC = () => {
|
||||
return (
|
||||
<a
|
||||
href="https://pro.ant.design/docs/getting-started"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
padding: '4px',
|
||||
fontSize: '18px',
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
<QuestionCircleOutlined />
|
||||
</a>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<error-preview v-if="showError" :error-data="errorInfo" @close="handleClose" @confirm="handleConfirm" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
import { emitter } from '@/utils/bus'
|
||||
import ErrorPreview from '@/components/errorPreview/index.vue'
|
||||
|
||||
const showError = ref(false)
|
||||
const errorInfo = ref(null)
|
||||
let cb = null
|
||||
|
||||
const showErrorDialog = (data) => {
|
||||
// 这玩意同时只允许存在一个
|
||||
if(showError.value) return
|
||||
|
||||
errorInfo.value = data
|
||||
showError.value = true
|
||||
cb = data?.fn || null
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
showError.value = false
|
||||
errorInfo.value = null
|
||||
cb = null
|
||||
}
|
||||
|
||||
const handleConfirm = (code) => {
|
||||
cb && cb(code)
|
||||
handleClose()
|
||||
}
|
||||
|
||||
emitter.on('show-error', showErrorDialog)
|
||||
|
||||
onUnmounted(() => {
|
||||
emitter.off('show-error', showErrorDialog)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="flex gap-2">
|
||||
<el-tag
|
||||
v-for="tag in modelValue"
|
||||
:key="tag"
|
||||
:closable="editable"
|
||||
:disable-transitions="false"
|
||||
@close="handleClose(tag)"
|
||||
>
|
||||
{{ tag }}
|
||||
</el-tag>
|
||||
<template v-if="editable">
|
||||
<el-input
|
||||
v-if="inputVisible"
|
||||
ref="InputRef"
|
||||
v-model="inputValue"
|
||||
class="w-20"
|
||||
size="small"
|
||||
@keyup.enter="handleInputConfirm"
|
||||
@blur="handleInputConfirm"
|
||||
/>
|
||||
<el-button v-else class="button-new-tag" size="small" @click="showInput">
|
||||
+ 新增
|
||||
</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineOptions({
|
||||
name: 'ArrayCtrl'
|
||||
})
|
||||
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
const inputValue = ref('')
|
||||
const inputVisible = ref(false)
|
||||
const InputRef = ref(null)
|
||||
|
||||
const modelValue = defineModel()
|
||||
|
||||
defineProps({
|
||||
editable: {
|
||||
type: Boolean,
|
||||
default: () => false
|
||||
}
|
||||
})
|
||||
|
||||
const handleClose = (tag) => {
|
||||
modelValue.value.splice(modelValue.value.indexOf(tag), 1)
|
||||
}
|
||||
|
||||
const showInput = () => {
|
||||
inputVisible.value = true
|
||||
nextTick(() => {
|
||||
InputRef.value?.input?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
const handleInputConfirm = () => {
|
||||
if (inputValue.value) {
|
||||
modelValue.value.push(inputValue.value)
|
||||
}
|
||||
inputVisible.value = false
|
||||
inputValue.value = ''
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div
|
||||
class="kra-bottom-info flex flex-col md:flex-row gap-2 items-center text-sm text-slate-700 dark:text-slate-500 justify-center py-2"
|
||||
>
|
||||
<div class="text-center font-bold text-active">Kra</div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineOptions({
|
||||
name: 'BottomInfo'
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 白色描边:让页脚文字在彩色/深色背景(如登录页右侧蓝色 banner)上依然清晰可读。
|
||||
用多方向 text-shadow 叠加实现描边,不会像 -webkit-text-stroke 那样把小字“掏空”变细;
|
||||
text-shadow 可继承,设在根节点即作用到内部所有 span / a 文本。
|
||||
白底、浅灰底上白描边几乎不可见,不影响原有观感。 */
|
||||
.kra-bottom-info {
|
||||
text-shadow:
|
||||
0 0 1px #fff,
|
||||
1px 1px 0 #fff,
|
||||
-1px 1px 0 #fff,
|
||||
1px -1px 0 #fff,
|
||||
-1px -1px 0 #fff,
|
||||
0 1px 0 #fff,
|
||||
0 -1px 0 #fff,
|
||||
1px 0 0 #fff,
|
||||
-1px 0 0 #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<VCharts
|
||||
v-if="renderChart"
|
||||
:option="options"
|
||||
:autoresize="autoResize"
|
||||
:style="{ width, height }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, nextTick } from 'vue'
|
||||
import VCharts from 'vue-echarts'
|
||||
import { useWindowResize } from '@/hooks/use-windows-resize'
|
||||
|
||||
defineProps({
|
||||
options: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
}
|
||||
},
|
||||
autoResize: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
default: '100%'
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '100%'
|
||||
}
|
||||
})
|
||||
const renderChart = ref(false)
|
||||
nextTick(() => {
|
||||
renderChart.value = true
|
||||
})
|
||||
useWindowResize(() => {
|
||||
renderChart.value = false
|
||||
nextTick(() => {
|
||||
renderChart.value = true
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less"></style>
|
||||
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
width="30%"
|
||||
class="overlay"
|
||||
:show-close="false"
|
||||
>
|
||||
<template #header>
|
||||
<input
|
||||
v-model="searchInput"
|
||||
class="quick-input"
|
||||
placeholder="请输入你需要快捷到达的功能"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div v-for="(option, index) in options" :key="index">
|
||||
<div v-if="option.children.length" class="quick-title">
|
||||
{{ option.label }}
|
||||
</div>
|
||||
<div
|
||||
v-for="(item, key) in option.children"
|
||||
:key="index + '-' + key"
|
||||
class="quick-item"
|
||||
@click="item.func"
|
||||
>
|
||||
{{ item.label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="close">关闭</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRouterStore } from '@/pinia/modules/router'
|
||||
import { useThemeStore, useUserStore } from '@/pinia'
|
||||
defineOptions({
|
||||
name: 'CommandMenu'
|
||||
})
|
||||
const themeStore = useThemeStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRouter()
|
||||
const routerStore = useRouterStore()
|
||||
const dialogVisible = ref(false)
|
||||
const searchInput = ref('')
|
||||
const options = reactive([])
|
||||
const deepMenus = (menus) => {
|
||||
const arr = []
|
||||
menus?.forEach((menu) => {
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
arr.push(...deepMenus(menu.children))
|
||||
} else {
|
||||
if (
|
||||
menu.meta.title &&
|
||||
menu.meta.title.indexOf(searchInput.value) > -1
|
||||
) {
|
||||
arr.push({
|
||||
label: menu.meta.title,
|
||||
func: () => changeRouter(menu)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
return arr
|
||||
}
|
||||
|
||||
const addQuickMenu = () => {
|
||||
const option = {
|
||||
label: '跳转',
|
||||
children: []
|
||||
}
|
||||
const menus = deepMenus(routerStore.asyncRouters[0]?.children || [])
|
||||
option.children.push(...menus)
|
||||
options.push(option)
|
||||
}
|
||||
|
||||
const addQuickOption = () => {
|
||||
const option = {
|
||||
label: '操作',
|
||||
children: []
|
||||
}
|
||||
const quickArr = [
|
||||
{
|
||||
label: '亮色主题',
|
||||
func: () => changeMode(false)
|
||||
},
|
||||
{
|
||||
label: '暗色主题',
|
||||
func: () => changeMode(true)
|
||||
},
|
||||
{
|
||||
label: '退出登录',
|
||||
func: () => userStore.LoginOut()
|
||||
}
|
||||
]
|
||||
option.children.push(
|
||||
...quickArr.filter((item) => item.label.indexOf(searchInput.value) > -1)
|
||||
)
|
||||
options.push(option)
|
||||
}
|
||||
|
||||
addQuickMenu()
|
||||
addQuickOption()
|
||||
|
||||
const open = () => {
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const changeRouter = (e) => {
|
||||
const index = e.name
|
||||
const query = {}
|
||||
const params = {}
|
||||
routerStore.routeMap[index]?.parameters &&
|
||||
routerStore.routeMap[index]?.parameters.forEach((item) => {
|
||||
if (item.type === 'query') {
|
||||
query[item.key] = item.value
|
||||
} else {
|
||||
params[item.key] = item.value
|
||||
}
|
||||
})
|
||||
if (index === route.name) return
|
||||
if (e.name.indexOf('http://') > -1 || e.name.indexOf('https://') > -1) {
|
||||
window.open(e.name)
|
||||
} else {
|
||||
router.push({ name: index, query, params })
|
||||
}
|
||||
dialogVisible.value = false
|
||||
}
|
||||
|
||||
const changeMode = (darkMode) => {
|
||||
themeStore.toggleTheme(darkMode)
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
dialogVisible.value = false
|
||||
}
|
||||
|
||||
defineExpose({ open })
|
||||
|
||||
watch(searchInput, () => {
|
||||
options.length = 0
|
||||
addQuickMenu()
|
||||
addQuickOption()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.overlay {
|
||||
border-radius: 4px;
|
||||
.el-dialog__header {
|
||||
padding: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
.el-dialog__body {
|
||||
padding: 12px !important;
|
||||
height: 50vh;
|
||||
overflow: auto !important;
|
||||
}
|
||||
.quick-title {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
}
|
||||
.quick-input {
|
||||
@apply bg-gray-50 dark:bg-gray-800;
|
||||
color: #666;
|
||||
border-radius: 4px 4px 0 0;
|
||||
border: none;
|
||||
padding: 12px 16px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
.quick-item {
|
||||
font-size: 14px;
|
||||
padding: 8px;
|
||||
margin: 4px 0;
|
||||
&:hover {
|
||||
@apply bg-gray-200 dark:bg-slate-500;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<span class="headerAvatar">
|
||||
<template v-if="picType === 'avatar'">
|
||||
<el-avatar v-if="userStore.userInfo.headerImg" :size="30" :src="avatar" />
|
||||
<el-avatar v-else :size="30" :src="noAvatar" />
|
||||
</template>
|
||||
<template v-if="picType === 'img'">
|
||||
<img v-if="userStore.userInfo.headerImg" :src="avatar" class="avatar" />
|
||||
<img v-else :src="noAvatar" class="avatar" />
|
||||
</template>
|
||||
<template v-if="picType === 'file'">
|
||||
<el-image
|
||||
:src="file"
|
||||
class="file"
|
||||
:preview-src-list="previewSrcList"
|
||||
:preview-teleported="true"
|
||||
/>
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import noAvatarPng from '@/assets/noBody.png'
|
||||
import { useUserStore } from '@/pinia/modules/user'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'CustomPic'
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
picType: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'avatar'
|
||||
},
|
||||
picSrc: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
preview: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const path = ref(import.meta.env.VITE_BASE_API + '/')
|
||||
const noAvatar = ref(noAvatarPng)
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const avatar = computed(() => {
|
||||
if (props.picSrc === '') {
|
||||
if (
|
||||
userStore.userInfo.headerImg !== '' &&
|
||||
userStore.userInfo.headerImg.slice(0, 4) === 'http'
|
||||
) {
|
||||
return userStore.userInfo.headerImg
|
||||
}
|
||||
return path.value + userStore.userInfo.headerImg
|
||||
} else {
|
||||
if (props.picSrc !== '' && props.picSrc.slice(0, 4) === 'http') {
|
||||
return props.picSrc
|
||||
}
|
||||
return path.value + props.picSrc
|
||||
}
|
||||
})
|
||||
const file = computed(() => {
|
||||
if (props.picSrc && props.picSrc.slice(0, 4) !== 'http') {
|
||||
return path.value + props.picSrc
|
||||
}
|
||||
return props.picSrc
|
||||
})
|
||||
const previewSrcList = computed(() => (props.preview ? [file.value] : []))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.headerAvatar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.file {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,30 @@
|
||||
<!--
|
||||
登录 / 初始化等入口页共用的品牌头部:logo + 大写字标。
|
||||
统一维护尺寸与样式,保证各入口页一致。
|
||||
-->
|
||||
<template>
|
||||
<div class="flex flex-col items-center">
|
||||
<Logo :size="logoSize" />
|
||||
<p
|
||||
class="mt-4 text-2xl font-bold uppercase tracking-[0.2em] text-gray-800 dark:text-white"
|
||||
>
|
||||
{{ $KRA.appName }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Logo from '@/components/logo/index.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'EntryBrand'
|
||||
})
|
||||
|
||||
defineProps({
|
||||
// logo 尺寸(单位 rem,见 logo 组件)
|
||||
logoSize: {
|
||||
type: Number,
|
||||
default: 6
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div
|
||||
class="fixed inset-0 bg-black/40 dark:bg-black/60 flex items-center justify-center z-[999]"
|
||||
@click.self="closeModal"
|
||||
>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-dialog dark:shadow-lg w-full max-w-md mx-4 transform transition-all duration-300 ease-in-out border border-transparent dark:border-gray-700">
|
||||
<!-- 弹窗头部 -->
|
||||
<div class="p-5 border-b border-gray-100 dark:border-gray-700 flex justify-between items-center">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-100">{{ displayData.title }}</h3>
|
||||
<div class="text-gray-400 dark:text-gray-300 hover:text-gray-600 dark:hover:text-gray-200 transition-colors cursor-pointer" @click="closeModal">
|
||||
<close class="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 弹窗内容 -->
|
||||
<div class="p-6 pt-0">
|
||||
<!-- 错误类型 -->
|
||||
<div class="mb-4">
|
||||
<div class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-2">错误类型</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<lock v-if="displayData.icon === 'lock'" :class="['w-5 h-5', displayData.color]" />
|
||||
<warn v-if="displayData.icon === 'warn'" :class="['w-5 h-5', displayData.color]" />
|
||||
<server v-if="displayData.icon === 'server'" :class="['w-5 h-5', displayData.color]" />
|
||||
<span class="font-medium text-gray-800 dark:text-gray-100">{{ displayData.type }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 具体错误 -->
|
||||
<div class="mb-6">
|
||||
<div class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-2">具体错误</div>
|
||||
<div class="bg-gray-100 dark:bg-gray-900/40 rounded-lg p-3 text-sm text-gray-700 dark:text-gray-200 leading-relaxed">
|
||||
{{ displayData.message }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提示信息 -->
|
||||
<div v-if="displayData.tips">
|
||||
<div class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-2">提示</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<idea class="text-blue-500 dark:text-blue-400 w-5 h-5" />
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300">{{ displayData.tips }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 弹窗底部 -->
|
||||
<div class="py-2 px-4 border-t border-gray-100 dark:border-gray-700 flex justify-end">
|
||||
<div class="px-4 py-2 bg-blue-600 dark:bg-blue-500 text-white dark:text-gray-100 rounded-lg hover:bg-blue-700 dark:hover:bg-blue-600 transition-colors font-medium text-sm shadow-sm cursor-pointer" @click="handleConfirm">
|
||||
确定
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
errorData: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const emits = defineEmits(['close', 'confirm']);
|
||||
|
||||
const presetErrors = {
|
||||
500: {
|
||||
title: '检测到接口错误',
|
||||
type: '服务器发生内部错误',
|
||||
icon: 'server',
|
||||
color: 'text-red-500 dark:text-red-400',
|
||||
tips: '此类错误内容常见于后台panic,请先查看后台日志,如果影响您正常使用可强制登出清理缓存'
|
||||
},
|
||||
404: {
|
||||
title: '资源未找到',
|
||||
type: 'Not Found',
|
||||
icon: 'warn',
|
||||
color: 'text-orange-500 dark:text-orange-400',
|
||||
tips: '此类错误多为接口未注册(或未重启)或者请求路径(方法)与api路径(方法)不符--如果为自动化代码请检查是否存在空格'
|
||||
},
|
||||
401: {
|
||||
title: '身份认证失败',
|
||||
type: '身份令牌无效',
|
||||
icon: 'lock',
|
||||
color: 'text-purple-500 dark:text-purple-400',
|
||||
tips: '您的身份认证已过期或无效,请重新登录。'
|
||||
},
|
||||
'network': {
|
||||
title: '网络错误',
|
||||
type: 'Network Error',
|
||||
icon: 'fa-wifi-slash',
|
||||
color: 'text-gray-500 dark:text-gray-400',
|
||||
tips: '无法连接到服务器,请检查您的网络连接。'
|
||||
}
|
||||
};
|
||||
|
||||
const displayData = computed(() => {
|
||||
const preset = presetErrors[props.errorData.code];
|
||||
if (preset) {
|
||||
return {
|
||||
...preset,
|
||||
message: props.errorData.message || '没有提供额外信息。'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: '未知错误',
|
||||
type: '检测到请求错误',
|
||||
icon: 'fa-question-circle',
|
||||
color: 'text-gray-400 dark:text-gray-300',
|
||||
message: props.errorData.message || '发生了一个未知错误。',
|
||||
tips: '请检查控制台获取更多信息。'
|
||||
};
|
||||
});
|
||||
|
||||
const closeModal = () => {
|
||||
emits('close')
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
emits('confirm', props.errorData.code);
|
||||
closeModal();
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<el-button type="primary" icon="download" @click="exportExcelFunc"
|
||||
>导出</el-button
|
||||
>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import { exportExcel } from '@/api/exportTemplate'
|
||||
|
||||
const props = defineProps({
|
||||
filterDeleted: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
templateId: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
condition: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
limit: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
offset: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
order: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const exportExcelFunc = async () => {
|
||||
if (props.templateId === '') {
|
||||
ElMessage.error('组件未设置模板ID')
|
||||
return
|
||||
}
|
||||
let baseUrl = import.meta.env.VITE_BASE_API
|
||||
if (baseUrl === "/"){
|
||||
baseUrl = ""
|
||||
}
|
||||
const paramsCopy = JSON.parse(JSON.stringify(props.condition))
|
||||
|
||||
if (props.filterDeleted) {
|
||||
paramsCopy.filterDeleted = 'true'
|
||||
}
|
||||
|
||||
if (props.limit) {
|
||||
paramsCopy.limit = props.limit
|
||||
}
|
||||
if (props.offset) {
|
||||
paramsCopy.offset = props.offset
|
||||
}
|
||||
if (props.order) {
|
||||
paramsCopy.order = props.order
|
||||
}
|
||||
const params = Object.entries(paramsCopy)
|
||||
.map(
|
||||
([key, value]) =>
|
||||
`${encodeURIComponent(key)}=${encodeURIComponent(value)}`
|
||||
)
|
||||
.join('&')
|
||||
|
||||
const res = await exportExcel({
|
||||
templateID: props.templateId,
|
||||
params
|
||||
})
|
||||
|
||||
if(res.code === 0){
|
||||
ElMessage.success('创建导出任务成功,开始下载')
|
||||
const url = `${baseUrl}${res.data}`
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<el-button type="primary" icon="download" @click="exportTemplateFunc"
|
||||
>下载模板</el-button
|
||||
>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {exportTemplate} from "@/api/exportTemplate";
|
||||
|
||||
const props = defineProps({
|
||||
templateId: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
const exportTemplateFunc = async () => {
|
||||
if (props.templateId === '') {
|
||||
ElMessage.error('组件未设置模板ID')
|
||||
return
|
||||
}
|
||||
let baseUrl = import.meta.env.VITE_BASE_API
|
||||
if (baseUrl === "/"){
|
||||
baseUrl = ""
|
||||
}
|
||||
|
||||
const res = await exportTemplate({
|
||||
templateID: props.templateId
|
||||
})
|
||||
|
||||
if(res.code === 0){
|
||||
ElMessage.success('创建导出任务成功,开始下载')
|
||||
const url = `${baseUrl}${res.data}`
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<el-upload
|
||||
:action="url"
|
||||
:show-file-list="false"
|
||||
:on-success="handleSuccess"
|
||||
:multiple="false"
|
||||
:headers="{'x-token': token}"
|
||||
>
|
||||
<el-button type="primary" icon="upload" class="ml-3"> 导入 </el-button>
|
||||
</el-upload>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useUserStore } from "@/pinia";
|
||||
|
||||
let baseUrl = import.meta.env.VITE_BASE_API
|
||||
if (baseUrl === "/"){
|
||||
baseUrl = ""
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
templateId: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const token = userStore.token
|
||||
|
||||
const emit = defineEmits(['on-success'])
|
||||
|
||||
const url = `${baseUrl}/sysExportTemplate/importExcel?templateID=${props.templateId}`
|
||||
|
||||
const handleSuccess = (res) => {
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('导入成功')
|
||||
emit('on-success')
|
||||
} else {
|
||||
ElMessage.error(res.msg)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { Primitive } from 'reka-ui'
|
||||
import { cn, FOCUS_RING } from '@/core/componentLibrary/utils'
|
||||
import SvgIcon from '@/components/svgIcon/svgIcon.vue'
|
||||
|
||||
defineOptions({ name: 'IconButton' })
|
||||
|
||||
const props = defineProps({
|
||||
// Iconify 图标名(外壳层统一走在线 lucide 集,如 'lucide:search')
|
||||
icon: { type: String, default: '' },
|
||||
// hover / text 模式下展开显示的文案
|
||||
label: { type: String, default: '' },
|
||||
// icon:常态显示图标、hover 时morph为文案;text:始终显示文案
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'icon',
|
||||
validator: (v) => ['icon', 'text'].includes(v)
|
||||
},
|
||||
disabled: { type: Boolean, default: false },
|
||||
// 透传给图标的附加 class(如刷新态的 animate-spin)
|
||||
iconClass: { type: null, default: '' },
|
||||
// 透传给 reka Primitive:默认渲染 <button>,asChild 时把行为合并到插槽根元素
|
||||
as: { type: [String, Object], default: 'button' },
|
||||
asChild: { type: Boolean, default: false },
|
||||
class: { type: null, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['click'])
|
||||
|
||||
const hovered = ref(false)
|
||||
|
||||
// 展开条件:text 模式恒展开;无图标时只能展开显示文案;icon 模式 hover 时展开
|
||||
const expanded = computed(
|
||||
() => props.mode === 'text' || !props.icon || hovered.value
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
:disabled="disabled || undefined"
|
||||
:class="
|
||||
cn(
|
||||
'group relative inline-flex h-9 min-w-9 cursor-pointer appearance-none items-center justify-center rounded-[var(--kra-radius)] bg-transparent px-2 text-muted-foreground outline-none transition-colors hover:bg-black/10 hover:text-base-text disabled:pointer-events-none disabled:opacity-50 dark:hover:bg-white/10',
|
||||
FOCUS_RING,
|
||||
props.class
|
||||
)
|
||||
"
|
||||
@mouseenter="hovered = true"
|
||||
@mouseleave="hovered = false"
|
||||
@click="(e) => emit('click', e)"
|
||||
>
|
||||
<span class="flex items-center justify-center">
|
||||
<!-- 图标列:展开时宽度收拢为 0,图标同步向左滑出(收起时反向从左滑回) -->
|
||||
<span
|
||||
v-if="icon"
|
||||
class="grid transition-[grid-template-columns] duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]"
|
||||
:class="expanded && label ? 'grid-cols-[0fr]' : 'grid-cols-[1fr]'"
|
||||
>
|
||||
<span class="flex min-w-0 items-center justify-center overflow-hidden">
|
||||
<svg-icon
|
||||
:icon="icon"
|
||||
:class="
|
||||
cn(
|
||||
'text-[18px] leading-none transition-[transform,opacity] duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
|
||||
expanded && label
|
||||
? '-translate-x-6 opacity-0'
|
||||
: 'translate-x-0 opacity-100',
|
||||
iconClass
|
||||
)
|
||||
"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<!-- 文案列:展开时宽度撑开,文案从右侧滑入(收起时向右滑出) -->
|
||||
<span
|
||||
v-if="label"
|
||||
class="grid transition-[grid-template-columns] duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]"
|
||||
:class="expanded ? 'grid-cols-[1fr]' : 'grid-cols-[0fr]'"
|
||||
>
|
||||
<span class="flex min-w-0 items-center justify-center overflow-hidden">
|
||||
<span
|
||||
class="whitespace-nowrap px-1 text-[13px] font-medium leading-none transition-[transform,opacity] duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]"
|
||||
:class="expanded ? 'translate-x-0 opacity-100' : 'translate-x-6 opacity-0'"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* 这个文件作为组件的目录
|
||||
* 目的是统一管理对外输出的组件,方便分类
|
||||
*/
|
||||
/**
|
||||
* 布局组件
|
||||
*/
|
||||
import Footer from './Footer';
|
||||
import { Question, SelectLang } from './RightContent';
|
||||
import { AvatarDropdown, AvatarName } from './RightContent/AvatarDropdown';
|
||||
|
||||
export { AvatarDropdown, AvatarName, Footer, Question, SelectLang };
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup>
|
||||
import { computed, ref, watchEffect } from 'vue';
|
||||
import { useThemeStore } from '@/pinia';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
const props = defineProps({
|
||||
// logo 尺寸,单位为:rem
|
||||
size: {
|
||||
type: Number,
|
||||
default: 2
|
||||
},
|
||||
// 侧栏深色时强制走暗色 logo:浅色主题 + 深色侧边栏场景下全局 isDark 仍为 false,
|
||||
// 通栏侧边 / 移动抽屉把此值传进来,让 logo 连同侧栏一起变深。
|
||||
dark: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const darkLogoPath = '/logo.svg'
|
||||
const lightLogoPath = '/logo.svg'
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
const { isDark } = storeToRefs(themeStore);
|
||||
|
||||
// 有效暗色:全局暗色 或 侧栏深色(props.dark)任一成立即走暗色 logo
|
||||
const effectiveDark = computed(() => props.dark || isDark.value);
|
||||
|
||||
const logoSrc = ref('');
|
||||
const showTextPlaceholder = ref(false);
|
||||
|
||||
// 检查图片是否存在
|
||||
function checkImageExists(url) {
|
||||
return new Promise((resolve) => {
|
||||
const tryToLoad = new Image();
|
||||
tryToLoad.onload = () => resolve(true);
|
||||
tryToLoad.onerror = () => resolve(false);
|
||||
tryToLoad.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
watchEffect(async (onCleanup) => {
|
||||
// effectiveDark 快速翻转时,用 stale 标记丢弃过期的异步结果,避免旧结果覆盖新值
|
||||
let stale = false
|
||||
onCleanup(() => (stale = true))
|
||||
showTextPlaceholder.value = false; // 重置占位符状态
|
||||
|
||||
// 暗色模式直接 load,可以省一次亮色的 load
|
||||
if (effectiveDark.value && await checkImageExists(darkLogoPath)) {
|
||||
if (!stale) logoSrc.value = darkLogoPath;
|
||||
return;
|
||||
}
|
||||
|
||||
if (await checkImageExists(lightLogoPath)) {
|
||||
if (!stale) logoSrc.value = lightLogoPath;
|
||||
return
|
||||
}
|
||||
|
||||
if (stale) return
|
||||
// 到这里就包没有提供两种 logo 了
|
||||
showTextPlaceholder.value = true;
|
||||
console.error(
|
||||
'错误: 在公共目录中找不到 logo.svg。'
|
||||
);
|
||||
console.warn(
|
||||
'解决方案: 请在 public 目录中放置 logo.svg。'
|
||||
);
|
||||
});
|
||||
|
||||
// 直接用 16px 作为默认的基准大小
|
||||
const SPACING = 16
|
||||
function getSize() {
|
||||
return {
|
||||
width: `${props.size * SPACING}px`,
|
||||
height: `${props.size * SPACING}px`,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img v-if="!showTextPlaceholder && logoSrc" :src="logoSrc" :alt="$KRA.appName" class="object-contain"
|
||||
:style="{
|
||||
...getSize()
|
||||
}" :class="{
|
||||
'filter invert-[90%] hue-rotate-180 brightness-110':
|
||||
effectiveDark && logoSrc === '/logo.svg',
|
||||
}" />
|
||||
<div v-else-if="showTextPlaceholder"
|
||||
class="rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-gray-700 dark:text-gray-200 font-bold text-xs"
|
||||
:style="{
|
||||
...getSize()
|
||||
}">
|
||||
KRA
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<vue-office-docx :src="docx" @rendered="rendered" />
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
name: 'Docx'
|
||||
}
|
||||
</script>
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
// 引入VueOfficeDocx组件
|
||||
import VueOfficeDocx from '@vue-office/docx'
|
||||
// 引入相关样式
|
||||
import '@vue-office/docx/lib/index.css'
|
||||
|
||||
const model = defineModel({
|
||||
type: String
|
||||
})
|
||||
|
||||
const docx = ref(null)
|
||||
watch(
|
||||
() => model,
|
||||
(value) => {
|
||||
docx.value = value
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
const rendered = () => {}
|
||||
</script>
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<VueOfficeExcel
|
||||
:src="excel"
|
||||
@rendered="renderedHandler"
|
||||
@error="errorHandler"
|
||||
style="height: 100vh; width: 100vh"
|
||||
/>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
name: 'Excel'
|
||||
}
|
||||
</script>
|
||||
<script setup>
|
||||
//引入VueOfficeExcel组件
|
||||
import VueOfficeExcel from '@vue-office/excel'
|
||||
//引入相关样式
|
||||
import '@vue-office/excel/lib/index.css'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: () => ''
|
||||
}
|
||||
})
|
||||
const excel = ref('')
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => (excel.value = val),
|
||||
{ immediate: true }
|
||||
)
|
||||
const renderedHandler = () => {}
|
||||
const errorHandler = () => {}
|
||||
</script>
|
||||
<style></style>
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div class="border border-solid border-gray-100 h-full w-full">
|
||||
<el-row>
|
||||
<div v-if="ext === 'docx'">
|
||||
<Docx v-model="fullFileURL" />
|
||||
</div>
|
||||
<div v-else-if="ext === 'pdf'">
|
||||
<Pdf v-model="fullFileURL" />
|
||||
</div>
|
||||
<div v-else-if="ext === 'xlsx'">
|
||||
<Excel v-model="fullFileURL" />
|
||||
</div>
|
||||
<div v-else-if="ext === 'image'">
|
||||
<el-image :src="fullFileURL" lazy />
|
||||
</div>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
name: 'Office'
|
||||
}
|
||||
</script>
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import Docx from '@/components/office/docx.vue'
|
||||
import Pdf from '@/components/office/pdf.vue'
|
||||
import Excel from '@/components/office/excel.vue'
|
||||
|
||||
const path = ref(import.meta.env.VITE_BASE_API)
|
||||
|
||||
const model = defineModel({ type: String })
|
||||
|
||||
const fileUrl = ref('')
|
||||
const ext = ref('')
|
||||
watch(
|
||||
() => model,
|
||||
(val) => {
|
||||
fileUrl.value = val
|
||||
const fileExt = val.split('.')[1] || ''
|
||||
const image = ['png', 'jpg', 'jpeg', 'gif']
|
||||
ext.value = image.includes(fileExt?.toLowerCase()) ? 'image' : fileExt
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
const fullFileURL = computed(() => {
|
||||
return path.value + '/' + fileUrl.value
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<vue-office-pdf
|
||||
:src="pdf"
|
||||
@rendered="renderedHandler"
|
||||
@error="errorHandler"
|
||||
/>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
name: 'Pdf'
|
||||
}
|
||||
</script>
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
//引入VueOfficeDocx组件
|
||||
import VueOfficePdf from '@vue-office/pdf'
|
||||
//引入相关样式
|
||||
import '@vue-office/docx/lib/index.css'
|
||||
console.log('pdf===>')
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: () => ''
|
||||
}
|
||||
})
|
||||
const pdf = ref(null)
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => (pdf.value = val),
|
||||
{ immediate: true }
|
||||
)
|
||||
const renderedHandler = () => {
|
||||
console.log('pdf 加载成功')
|
||||
}
|
||||
const errorHandler = () => {
|
||||
console.log('pdf 错误')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<div class="richtext-wrapper border border-solid border-gray-100 h-full z-10">
|
||||
<Toolbar
|
||||
:editor="editorRef"
|
||||
:default-config="toolbarConfig"
|
||||
mode="default"
|
||||
/>
|
||||
<Editor
|
||||
v-model="valueHtml"
|
||||
class="overflow-y-hidden mt-0.5"
|
||||
style="height: 18rem"
|
||||
:default-config="editorConfig"
|
||||
mode="default"
|
||||
@onCreated="handleCreated"
|
||||
@onChange="change"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import '@wangeditor/editor/dist/css/style.css' // 引入 css
|
||||
|
||||
const basePath = import.meta.env.VITE_BASE_API
|
||||
|
||||
import { onBeforeUnmount, ref, shallowRef, watch } from 'vue'
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getUrl } from '@/utils/image'
|
||||
import { useUserStore } from '@/pinia/modules/user'
|
||||
|
||||
const emits = defineEmits(['change', 'update:modelValue'])
|
||||
|
||||
const change = (editor) => {
|
||||
emits('change', editor)
|
||||
emits('update:modelValue', valueHtml.value)
|
||||
}
|
||||
|
||||
const userStore = useUserStore()
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const editorRef = shallowRef()
|
||||
const valueHtml = ref('')
|
||||
|
||||
const toolbarConfig = {}
|
||||
const editorConfig = {
|
||||
placeholder: '请输入内容...',
|
||||
MENU_CONF: {}
|
||||
}
|
||||
editorConfig.MENU_CONF['uploadImage'] = {
|
||||
fieldName: 'file',
|
||||
server: basePath + '/fileUploadAndDownload/upload?noSave=1',
|
||||
headers: {
|
||||
'x-token': userStore.token,
|
||||
},
|
||||
customInsert(res, insertFn) {
|
||||
if (res.code === 0) {
|
||||
const urlPath = getUrl(res.data.file.url)
|
||||
insertFn(urlPath, res.data.file.name)
|
||||
return
|
||||
}
|
||||
ElMessage.error(res.msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 组件销毁时,也及时销毁编辑器
|
||||
onBeforeUnmount(() => {
|
||||
const editor = editorRef.value
|
||||
if (editor == null) return
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
const handleCreated = (editor) => {
|
||||
editorRef.value = editor
|
||||
valueHtml.value = props.modelValue
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
valueHtml.value = props.modelValue
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.richtext-wrapper {
|
||||
:deep(.w-e-text-container [data-slate-editor]) {
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.17em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 0.83em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 0.67em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin: 1em 0;
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--el-color-primary, #409eff);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.w-e-text-container [data-slate-editor] ul ul) {
|
||||
list-style-type: circle;
|
||||
}
|
||||
|
||||
:deep(.w-e-text-container [data-slate-editor] ul ul ul) {
|
||||
list-style-type: square;
|
||||
}
|
||||
|
||||
:deep(.w-e-text-container [data-slate-editor] ol ol) {
|
||||
list-style-type: lower-alpha;
|
||||
}
|
||||
|
||||
:deep(.w-e-text-container [data-slate-editor] ol ol ol) {
|
||||
list-style-type: lower-roman;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div class="richtext-wrapper border border-solid border-gray-100 h-full">
|
||||
<Editor
|
||||
v-model="valueHtml"
|
||||
class="overflow-y-hidden mt-0.5"
|
||||
:default-config="editorConfig"
|
||||
mode="default"
|
||||
@onCreated="handleCreated"
|
||||
@onChange="change"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import '@wangeditor/editor/dist/css/style.css' // 引入 css
|
||||
|
||||
import { onBeforeUnmount, ref, shallowRef, watch } from 'vue'
|
||||
import { Editor } from '@wangeditor/editor-for-vue'
|
||||
|
||||
const emits = defineEmits(['change', 'update:modelValue'])
|
||||
const editorConfig = ref({
|
||||
readOnly: true
|
||||
})
|
||||
const change = (editor) => {
|
||||
emits('change', editor)
|
||||
emits('update:modelValue', valueHtml.value)
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const editorRef = shallowRef()
|
||||
const valueHtml = ref('')
|
||||
|
||||
// 组件销毁时,也及时销毁编辑器
|
||||
onBeforeUnmount(() => {
|
||||
const editor = editorRef.value
|
||||
if (editor == null) return
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
const handleCreated = (editor) => {
|
||||
editorRef.value = editor
|
||||
valueHtml.value = props.modelValue
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
valueHtml.value = props.modelValue
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.richtext-wrapper {
|
||||
:deep(.w-e-text-container [data-slate-editor]) {
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.17em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 0.83em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 0.67em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin: 1em 0;
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--el-color-primary, #409eff);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.w-e-text-container [data-slate-editor] ul ul) {
|
||||
list-style-type: circle;
|
||||
}
|
||||
|
||||
:deep(.w-e-text-container [data-slate-editor] ul ul ul) {
|
||||
list-style-type: square;
|
||||
}
|
||||
|
||||
:deep(.w-e-text-container [data-slate-editor] ol ol) {
|
||||
list-style-type: lower-alpha;
|
||||
}
|
||||
|
||||
:deep(.w-e-text-container [data-slate-editor] ol ol ol) {
|
||||
list-style-type: lower-roman;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-upload
|
||||
v-model:file-list="fileList"
|
||||
multiple
|
||||
:action="`${getBaseUrl()}/fileUploadAndDownload/upload?noSave=1`"
|
||||
:on-error="uploadError"
|
||||
:on-success="uploadSuccess"
|
||||
:on-remove="uploadRemove"
|
||||
:show-file-list="true"
|
||||
:limit="limit"
|
||||
:accept="accept"
|
||||
class="upload-btn"
|
||||
:headers="{'x-token': token}"
|
||||
>
|
||||
<el-button type="primary"> 上传文件 </el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getBaseUrl } from '@/utils/format'
|
||||
import { getUploadErrorMessage } from '@/utils/uploadResponse'
|
||||
import { useUserStore } from "@/pinia";
|
||||
|
||||
defineOptions({
|
||||
name: 'UploadCommon'
|
||||
})
|
||||
|
||||
defineProps({
|
||||
limit: {
|
||||
type: Number,
|
||||
default: 3
|
||||
},
|
||||
accept: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const token = userStore.token
|
||||
|
||||
const fullscreenLoading = ref(false)
|
||||
|
||||
const model = defineModel({ type: Array })
|
||||
|
||||
const fileList = ref(model.value || [])
|
||||
|
||||
const emits = defineEmits(['on-success', 'on-error'])
|
||||
|
||||
const uploadSuccess = (res) => {
|
||||
const errorMessage = getUploadErrorMessage(res)
|
||||
if (errorMessage) {
|
||||
ElMessage.error(errorMessage)
|
||||
fileList.value.pop()
|
||||
return
|
||||
}
|
||||
const { data } = res
|
||||
model.value.push({
|
||||
name: data.file.name,
|
||||
url: data.file.url
|
||||
})
|
||||
emits('on-success', res)
|
||||
}
|
||||
|
||||
const uploadRemove = (file) => {
|
||||
const index = model.value.indexOf(file)
|
||||
if (index > -1) {
|
||||
model.value.splice(index, 1)
|
||||
fileList.value = model.value
|
||||
}
|
||||
}
|
||||
|
||||
const uploadError = (err) => {
|
||||
ElMessage({
|
||||
type: 'error',
|
||||
message: '上传失败'
|
||||
})
|
||||
fullscreenLoading.value = false
|
||||
emits('on-error', err)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div
|
||||
class="w-40 h-40 relative rounded border border-dashed border-gray-300 cursor-pointer group"
|
||||
:class="rounded ? 'rounded-full' : ''"
|
||||
>
|
||||
<div class="w-full h-full overflow-hidden" :class="rounded ? 'rounded-full' : ''">
|
||||
<el-icon
|
||||
v-if="isVideoExt(model || '')"
|
||||
:size="32"
|
||||
class="absolute top-[calc(50%-16px)] left-[calc(50%-16px)]"
|
||||
>
|
||||
<VideoPlay />
|
||||
</el-icon>
|
||||
<video
|
||||
v-if="isVideoExt(model || '')"
|
||||
class="w-full h-full object-cover"
|
||||
muted
|
||||
preload="metadata"
|
||||
>
|
||||
<source :src="getUrl(model) + '#t=1'" />
|
||||
</video>
|
||||
|
||||
<el-image
|
||||
v-if="model && !isVideoExt(model)"
|
||||
class="w-full h-full"
|
||||
:src="imgUrl"
|
||||
:preview-src-list="srcList"
|
||||
fit="cover"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="text-gray-600 group-hover:bg-gray-200 group-hover:opacity-60 w-full h-full flex justify-center items-center"
|
||||
@click="chooseItem"
|
||||
>
|
||||
<el-icon>
|
||||
<plus />
|
||||
</el-icon>
|
||||
上传
|
||||
</div>
|
||||
</div>
|
||||
<!-- 删除按钮在外层容器中 -->
|
||||
<div
|
||||
v-if="model"
|
||||
class="right-0 top-0 hidden text-gray-400 group-hover:flex justify-center items-center absolute z-10"
|
||||
@click="deleteItem"
|
||||
>
|
||||
<el-icon :size="24">
|
||||
<CircleCloseFilled />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { getUrl, isVideoExt } from '@/utils/image'
|
||||
import { CircleCloseFilled, Plus } from '@element-plus/icons-vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
model: {
|
||||
default: '',
|
||||
type: String
|
||||
},
|
||||
rounded: {
|
||||
default: false,
|
||||
type: Boolean
|
||||
}
|
||||
})
|
||||
|
||||
const emits = defineEmits(['chooseItem', 'deleteItem'])
|
||||
|
||||
const chooseItem = () => {
|
||||
emits('chooseItem')
|
||||
}
|
||||
|
||||
const deleteItem = () => {
|
||||
emits('deleteItem')
|
||||
}
|
||||
|
||||
const imgUrl = computed(() => {
|
||||
return getUrl(props.model)
|
||||
})
|
||||
|
||||
const srcList = computed(() => {
|
||||
return imgUrl.value ? [imgUrl.value] : []
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,529 @@
|
||||
<template>
|
||||
<div>
|
||||
<selectComponent :rounded="rounded" v-if="!props.multiple" :model="model" @chooseItem="openChooseImg" @deleteItem="openChooseImg" />
|
||||
<div v-else class="w-full gap-4 flex flex-wrap">
|
||||
<draggable
|
||||
v-model="model"
|
||||
class="flex flex-wrap gap-4"
|
||||
item-key="url"
|
||||
ghost-class="ghost-item"
|
||||
handle=".drag-handle"
|
||||
animation="300"
|
||||
@start="onDragStart"
|
||||
@end="onDragEnd"
|
||||
>
|
||||
<template #item="{element, index}">
|
||||
<div class="relative group">
|
||||
<div class="drag-handle absolute left-2 top-2 w-8 h-8 flex items-center justify-center cursor-move z-10 opacity-0 group-hover:opacity-100 rounded-full">
|
||||
<el-icon :size="18"><Menu /></el-icon>
|
||||
</div>
|
||||
<selectComponent :rounded="rounded" :model="element" @chooseItem="openChooseImg"
|
||||
@deleteItem="deleteImg(index)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
<selectComponent :rounded="rounded" v-if="currentImageCount < props.maxUpdateCount || props.maxUpdateCount === 0"
|
||||
@chooseItem="openChooseImg" @deleteItem="openChooseImg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-drawer v-model="drawer" title="媒体库 | 点击“文件名”可以编辑,选择的类别即是上传的类别" :size="880">
|
||||
<div class="flex">
|
||||
<div class="w-64" style="border-right: solid 1px var(--el-border-color);">
|
||||
<el-scrollbar style="height: calc(100vh - 110px)">
|
||||
<el-tree
|
||||
:data="categories"
|
||||
node-key="id"
|
||||
:props="defaultProps"
|
||||
@node-click="handleNodeClick"
|
||||
default-expand-all
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<div class="w-36" :class="search.classId === data.ID ? 'text-blue-500 font-bold' : ''">{{ data.name }}
|
||||
</div>
|
||||
<el-dropdown>
|
||||
<el-icon class="ml-3 text-right" v-if="data.ID > 0"><MoreFilled /></el-icon>
|
||||
<el-icon class="ml-3 text-right mt-1" v-else><Plus /></el-icon>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="addCategoryFun(data)">添加分类</el-dropdown-item>
|
||||
<el-dropdown-item @click="editCategory(data)" v-if="data.ID > 0">编辑分类</el-dropdown-item>
|
||||
<el-dropdown-item @click="deleteCategoryFun(data.ID)" v-if="data.ID > 0">删除分类</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
</el-tree>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
<div class="ml-4 w-[605px]">
|
||||
<div class="kra-btn-list gap-2">
|
||||
<el-input v-model.trim="search.keyword" class="w-96" placeholder="请输入文件名或备注" clearable />
|
||||
<el-button type="primary" icon="search" @click="onSubmit"></el-button>
|
||||
</div>
|
||||
<div class="kra-btn-list gap-2">
|
||||
<el-button @click="useSelectedImages" type="danger" :disabled="selectedImages.length === 0" :icon="ArrowLeftBold">选定</el-button>
|
||||
<upload-common :image-common="imageCommon" :classId="search.classId" @on-success="onSuccess" />
|
||||
<cropper-image :classId="search.classId" @on-success="onSuccess" />
|
||||
<QRCodeUpload :classId="search.classId" @on-success="onSuccess" />
|
||||
<upload-image :image-url="imageUrl" :file-size="2048" :max-w-h="1080" :classId="search.classId" @on-success="onSuccess" />
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<div v-for="(item,key) in picList" :key="key" class="w-40">
|
||||
<div class="w-40 h-40 border rounded overflow-hidden border-dashed border-gray-300 cursor-pointer relative group">
|
||||
<el-image :key="key" :src="getUrl(item.url)" fit="cover" class="w-full h-full relative" @click="toggleImageSelection(item)" :class="{ selected: isSelected(item) }">
|
||||
<template #error>
|
||||
<el-icon v-if="isVideoExt(item.url || '')" :size="32" class="absolute top-[calc(50%-16px)] left-[calc(50%-16px)]">
|
||||
<VideoPlay />
|
||||
</el-icon>
|
||||
<video v-if="isVideoExt(item.url || '')"
|
||||
class="w-full h-full object-cover"
|
||||
muted
|
||||
preload="metadata"
|
||||
@click="toggleImageSelection(item)"
|
||||
:class="{ selected: isSelected(item) }"
|
||||
>
|
||||
<source :src="getUrl(item.url) + '#t=1'">
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
<div v-else class="w-full h-full object-cover flex items-center justify-center">
|
||||
<el-icon :size="32">
|
||||
<icon-picture />
|
||||
</el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
<div class="absolute -right-1 top-1 w-8 h-8 group-hover:inline-block hidden" @click="deleteCheck(item)">
|
||||
<el-icon :size="18">
|
||||
<CloseBold />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-hidden text-nowrap overflow-ellipsis text-center w-full cursor-pointer" @click="editFileNameFunc(item)">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-pagination
|
||||
:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
class="justify-center"
|
||||
layout="total, prev, pager, next, jumper"
|
||||
@current-change="handleCurrentChange"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
|
||||
<!-- 添加分类弹窗 -->
|
||||
<el-dialog v-model="categoryDialogVisible" @close="closeAddCategoryDialog" width="520"
|
||||
:title="(categoryFormData.ID === 0 ? '添加' : '编辑') + '分类'"
|
||||
draggable
|
||||
>
|
||||
<el-form ref="categoryForm" :rules="rules" :model="categoryFormData" label-width="80px">
|
||||
<el-form-item label="上级分类">
|
||||
<el-tree-select
|
||||
v-model="categoryFormData.pid"
|
||||
:data="categories"
|
||||
check-strictly
|
||||
:props="defaultProps"
|
||||
:render-after-expand="false"
|
||||
style="width: 240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="分类名称" prop="name">
|
||||
<el-input v-model.trim="categoryFormData.name" placeholder="分类名称"></el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="closeAddCategoryDialog">取消</el-button>
|
||||
<el-button type="primary" @click="confirmAddCategory">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { getUrl, isVideoExt } from '@/utils/image'
|
||||
import { computed, ref } from 'vue'
|
||||
import { getFileList, editFileName, deleteFile } from '@/api/fileUploadAndDownload'
|
||||
import UploadImage from '@/components/upload/image.vue'
|
||||
import UploadCommon from '@/components/upload/common.vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
ArrowLeftBold,
|
||||
CloseBold,
|
||||
Menu,
|
||||
MoreFilled,
|
||||
Picture as IconPicture,
|
||||
Plus,
|
||||
VideoPlay
|
||||
} from '@element-plus/icons-vue'
|
||||
import selectComponent from '@/components/selectImage/selectComponent.vue'
|
||||
import { addCategory, deleteCategory, getCategoryList } from '@/api/attachmentCategory'
|
||||
import CropperImage from "@/components/upload/cropper.vue";
|
||||
import QRCodeUpload from "@/components/upload/QR-code.vue";
|
||||
import draggable from 'vuedraggable'
|
||||
|
||||
const imageUrl = ref('')
|
||||
const imageCommon = ref('')
|
||||
|
||||
const search = ref({
|
||||
keyword: null,
|
||||
classId: 0
|
||||
})
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const pageSize = ref(20)
|
||||
|
||||
const model = defineModel({ type: [String, Array] })
|
||||
|
||||
const props = defineProps({
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
fileType: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
maxUpdateCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
rounded: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const deleteImg = (index) => {
|
||||
model.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const handleSizeChange = (val) => {
|
||||
pageSize.value = val
|
||||
getImageList()
|
||||
}
|
||||
|
||||
const handleCurrentChange = (val) => {
|
||||
page.value = val
|
||||
getImageList()
|
||||
}
|
||||
|
||||
const onSubmit = () => {
|
||||
search.value.classId = 0
|
||||
page.value = 1
|
||||
getImageList()
|
||||
}
|
||||
|
||||
const editFileNameFunc = async(row) => {
|
||||
ElMessageBox.prompt('请输入文件名或者备注', '编辑', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
inputPattern: /\S/,
|
||||
inputErrorMessage: '不能为空',
|
||||
inputValue: row.name
|
||||
}).then(async({ value }) => {
|
||||
row.name = value
|
||||
const res = await editFileName(row)
|
||||
if (res.code === 0) {
|
||||
ElMessage({
|
||||
type: 'success',
|
||||
message: '编辑成功!'
|
||||
})
|
||||
await getImageList()
|
||||
}
|
||||
}).catch(() => {
|
||||
ElMessage({
|
||||
type: 'info',
|
||||
message: '取消修改'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const drawer = ref(false)
|
||||
const picList = ref([])
|
||||
|
||||
const imageTypeList = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg']
|
||||
const videoTypeList = ['mp4', 'avi', 'rmvb', 'rm', 'asf', 'divx', 'mpg', 'mpeg', 'mpe', 'wmv', 'mkv', 'vob']
|
||||
|
||||
const listObj = {
|
||||
image: imageTypeList,
|
||||
video: videoTypeList
|
||||
}
|
||||
|
||||
const chooseImg = (url) => {
|
||||
if (props.fileType) {
|
||||
const typeSuccess = listObj[props.fileType].some(item => {
|
||||
if (url?.toLowerCase().includes(item)) {
|
||||
return true
|
||||
}
|
||||
})
|
||||
if (!typeSuccess) {
|
||||
ElMessage({
|
||||
type: 'error',
|
||||
message: '当前类型不支持使用'
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
//if (props.multiple) {
|
||||
// model.value.push(url)
|
||||
//} else {
|
||||
model.value = url
|
||||
//}
|
||||
drawer.value = false
|
||||
}
|
||||
|
||||
const openChooseImg = async() => {
|
||||
if (model.value && !props.multiple) {
|
||||
model.value = ''
|
||||
return
|
||||
}
|
||||
selectedImages.value = []
|
||||
await getImageList()
|
||||
await fetchCategories()
|
||||
drawer.value = true
|
||||
}
|
||||
|
||||
const getImageList = async() => {
|
||||
const res = await getFileList({ page: page.value, pageSize: pageSize.value, ...search.value })
|
||||
if (res.code === 0) {
|
||||
picList.value = res.data.list
|
||||
total.value = res.data.total
|
||||
page.value = res.data.page
|
||||
pageSize.value = res.data.pageSize
|
||||
}
|
||||
}
|
||||
|
||||
const deleteCheck = (item) => {
|
||||
ElMessageBox.confirm('是否删除该文件', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(async() => {
|
||||
const res = await deleteFile(item)
|
||||
if (res.code === 0) {
|
||||
ElMessage({
|
||||
type: 'success',
|
||||
message: '删除成功!'
|
||||
})
|
||||
await getImageList()
|
||||
}
|
||||
}).catch(() => {
|
||||
ElMessage({
|
||||
type: 'info',
|
||||
message: '已取消删除'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
children: 'children',
|
||||
label: 'name',
|
||||
value: 'ID'
|
||||
}
|
||||
|
||||
const categories = ref([])
|
||||
const fetchCategories = async() => {
|
||||
const res = await getCategoryList()
|
||||
let data = {
|
||||
name: '全部分类',
|
||||
ID: 0,
|
||||
pid: 0,
|
||||
children:[]
|
||||
}
|
||||
if (res.code === 0) {
|
||||
categories.value = res.data || []
|
||||
categories.value.unshift(data)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNodeClick = (node) => {
|
||||
search.value.keyword = null
|
||||
search.value.classId = node.ID
|
||||
page.value = 1
|
||||
getImageList()
|
||||
}
|
||||
|
||||
const onSuccess = () => {
|
||||
search.value.keyword = null
|
||||
page.value = 1
|
||||
getImageList()
|
||||
}
|
||||
|
||||
const categoryDialogVisible = ref(false)
|
||||
const categoryFormData = ref({
|
||||
ID: 0,
|
||||
pid: 0,
|
||||
name: ''
|
||||
})
|
||||
|
||||
const categoryForm = ref(null)
|
||||
const rules = ref({
|
||||
name: [
|
||||
{ required: true, message: '请输入分类名称', trigger: 'blur' },
|
||||
{ max: 20, message: '最多20位字符', trigger: 'blur' }
|
||||
]
|
||||
})
|
||||
|
||||
const addCategoryFun = (category) => {
|
||||
categoryDialogVisible.value = true
|
||||
categoryFormData.value.ID = 0
|
||||
categoryFormData.value.pid = category.ID
|
||||
}
|
||||
|
||||
const editCategory = (category) => {
|
||||
categoryFormData.value = {
|
||||
ID: category.ID,
|
||||
pid: category.pid,
|
||||
name: category.name
|
||||
}
|
||||
categoryDialogVisible.value = true
|
||||
}
|
||||
|
||||
const deleteCategoryFun = async(id) => {
|
||||
const res = await deleteCategory({ id: id })
|
||||
if (res.code === 0) {
|
||||
ElMessage.success({ type: 'success', message: '删除成功' })
|
||||
await fetchCategories()
|
||||
}
|
||||
}
|
||||
|
||||
const confirmAddCategory = async() => {
|
||||
categoryForm.value.validate(async valid => {
|
||||
if (valid) {
|
||||
const res = await addCategory(categoryFormData.value)
|
||||
if (res.code === 0) {
|
||||
ElMessage({ type: 'success', message: '操作成功' })
|
||||
await fetchCategories()
|
||||
closeAddCategoryDialog()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const closeAddCategoryDialog = () => {
|
||||
categoryDialogVisible.value = false
|
||||
categoryFormData.value = {
|
||||
ID: 0,
|
||||
pid: 0,
|
||||
name: ''
|
||||
}
|
||||
}
|
||||
|
||||
const selectedImages = ref([])
|
||||
|
||||
const currentImageCount = computed(() => {
|
||||
if (Array.isArray(model.value)) {
|
||||
return model.value.length
|
||||
}
|
||||
return model.value ? 1 : 0
|
||||
})
|
||||
|
||||
const canSelectMoreImages = computed(() => {
|
||||
return props.maxUpdateCount === 0 || currentImageCount.value + selectedImages.value.length < props.maxUpdateCount
|
||||
})
|
||||
|
||||
const toggleImageSelection = (item) => {
|
||||
if (props.multiple === false) {
|
||||
chooseImg(item.url)
|
||||
return
|
||||
}
|
||||
const index = selectedImages.value.findIndex(img => img.ID === item.ID)
|
||||
if (index > -1) {
|
||||
selectedImages.value.splice(index, 1)
|
||||
} else {
|
||||
if (!canSelectMoreImages.value) {
|
||||
ElMessage({
|
||||
type: 'warning',
|
||||
message: `最多可选择${props.maxUpdateCount}个文件`
|
||||
})
|
||||
return
|
||||
}
|
||||
selectedImages.value.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
const isSelected = (item) => {
|
||||
return selectedImages.value.some(img => img.ID === item.ID)
|
||||
}
|
||||
|
||||
const useSelectedImages = () => {
|
||||
if (!Array.isArray(model.value)) {
|
||||
model.value = []
|
||||
}
|
||||
if (props.maxUpdateCount !== 0 && model.value.length + selectedImages.value.length > props.maxUpdateCount) {
|
||||
ElMessage({
|
||||
type: 'warning',
|
||||
message: `最多可选择${props.maxUpdateCount}个文件`
|
||||
})
|
||||
return
|
||||
}
|
||||
model.value.push(...selectedImages.value.map(item => item.url))
|
||||
drawer.value = false
|
||||
selectedImages.value = []
|
||||
}
|
||||
|
||||
const onDragStart = () => {
|
||||
// 拖拽开始时的处理
|
||||
document.body.style.cursor = 'grabbing'
|
||||
}
|
||||
|
||||
const onDragEnd = () => {
|
||||
// 拖拽结束时的处理
|
||||
document.body.style.cursor = 'default'
|
||||
// 确保model是数组类型
|
||||
if (!Array.isArray(model.value)) {
|
||||
model.value = []
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
<style scoped>
|
||||
.selected {
|
||||
border: 3px solid #409eff;
|
||||
}
|
||||
|
||||
.selected:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
border: 10px solid #409eff;
|
||||
}
|
||||
|
||||
.selected:after {
|
||||
content: "";
|
||||
width: 9px;
|
||||
height: 14px;
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 0;
|
||||
border: 3px solid #fff;
|
||||
border-top-color: transparent;
|
||||
border-left-color: transparent;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.ghost-item {
|
||||
opacity: 0.5;
|
||||
background: #c8ebfb;
|
||||
border: 1px dashed #409eff;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
background-color: rgba(64, 158, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.drag-handle:hover {
|
||||
background-color: rgba(64, 158, 255, 0.2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<template v-if="localIcon">
|
||||
<svg aria-hidden="true" width="1em" height="1em" stroke-width="1.75" v-bind="bindAttrs">
|
||||
<use :xlink:href="'#' + localIcon" rel="external nofollow" />
|
||||
</svg>
|
||||
</template>
|
||||
<template v-else-if="icon">
|
||||
<Icon :icon="icon" v-bind="bindAttrs" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, useAttrs } from 'vue';
|
||||
import { Icon } from '@iconify/vue'
|
||||
|
||||
/**
|
||||
* 使用示例:
|
||||
* 本地图标(所有可用的本地图标见控制台输出):
|
||||
* <SvgIcon localIcon="lock" class="text-red-500 text-3xl" />
|
||||
*
|
||||
* 在线图标(相关查询网站:https://icones.js.org/ 或是:https://icon-sets.iconify.design/):
|
||||
* <SvgIcon icon="mingcute:love-fill" class="text-red-500 text-3xl" />
|
||||
*/
|
||||
defineProps({
|
||||
// 通过 symbol id 使用本地注册的 svg 图标
|
||||
localIcon: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
// Iconify 图标名称, 例如: 'mdi:home'
|
||||
icon: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
const attrs = useAttrs();
|
||||
|
||||
const bindAttrs = computed(() => ({
|
||||
class: (attrs.class) || '',
|
||||
style: (attrs.style) || ''
|
||||
}))
|
||||
</script>
|
||||
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-button type="primary" icon="iphone" @click="createQrCode"> 扫码上传</el-button>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="dialogVisible" title="扫码上传" width="320px" :show-close="false" append-to-body :close-on-click-modal="false"
|
||||
draggable
|
||||
>
|
||||
<div class="m-2">
|
||||
<vue-qr :logoSrc="logoSrc"
|
||||
:size="291"
|
||||
:margin="0"
|
||||
:autoColor="true"
|
||||
:dotScale="1"
|
||||
:text="codeUrl"
|
||||
colorDark="green"
|
||||
colorLight="white"
|
||||
ref="qrcode"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="dialogVisible = false">取 消</el-button>
|
||||
<el-button type="primary" @click="onFinished">完成上传</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import logoSrc from '@/assets/logo.svg'
|
||||
import vueQr from 'vue-qr/src/packages/vue-qr.vue'
|
||||
import { ref } from 'vue'
|
||||
import { useUserStore } from '@/pinia/modules/user'
|
||||
|
||||
defineOptions({
|
||||
name: 'QRCodeUpload'
|
||||
})
|
||||
|
||||
const emit = defineEmits(['on-success'])
|
||||
|
||||
const props = defineProps({
|
||||
classId: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
})
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const userStore = useUserStore()
|
||||
const codeUrl = ref('')
|
||||
|
||||
const createQrCode = () => {
|
||||
const local = window.location
|
||||
codeUrl.value = local.protocol + '//' + local.host + '/#/scanUpload?id=' + props.classId + '&token=' + userStore.token + '&t=' + Date.now()
|
||||
dialogVisible.value = true
|
||||
console.log(codeUrl.value)
|
||||
}
|
||||
|
||||
const onFinished = () => {
|
||||
dialogVisible.value = false
|
||||
codeUrl.value = ''
|
||||
emit('on-success', '')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-upload
|
||||
:action="`${getBaseUrl()}/fileUploadAndDownload/upload`"
|
||||
:before-upload="checkFile"
|
||||
:on-error="uploadError"
|
||||
:on-success="uploadSuccess"
|
||||
:show-file-list="false"
|
||||
:data="{'classId': props.classId}"
|
||||
:headers="{'x-token': token}"
|
||||
multiple
|
||||
class="upload-btn"
|
||||
>
|
||||
<el-button type="primary" :icon="Upload">普通上传</el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { isVideoMime, isImageMime } from '@/utils/image'
|
||||
import { getBaseUrl } from '@/utils/format'
|
||||
import { getUploadErrorMessage } from '@/utils/uploadResponse'
|
||||
import { Upload } from "@element-plus/icons-vue";
|
||||
import { useUserStore } from "@/pinia";
|
||||
|
||||
defineOptions({
|
||||
name: 'UploadCommon'
|
||||
})
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const token = userStore.token
|
||||
|
||||
const props = defineProps({
|
||||
classId: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['on-success'])
|
||||
|
||||
const fullscreenLoading = ref(false)
|
||||
|
||||
const checkFile = (file) => {
|
||||
fullscreenLoading.value = true
|
||||
const isLt500K = file.size / 1024 / 1024 < 0.5 // 500K, @todo 应支持在项目中设置
|
||||
const isLt5M = file.size / 1024 / 1024 < 5 // 5MB, @todo 应支持项目中设置
|
||||
const isVideo = isVideoMime(file.type)
|
||||
const isImage = isImageMime(file.type)
|
||||
let pass = true
|
||||
if (!isVideo && !isImage) {
|
||||
ElMessage.error(
|
||||
'上传图片只能是 jpg,png,svg,webp 格式, 上传视频只能是 mp4,webm 格式!'
|
||||
)
|
||||
fullscreenLoading.value = false
|
||||
pass = false
|
||||
}
|
||||
if (!isLt5M && isVideo) {
|
||||
ElMessage.error('上传视频大小不能超过 5MB')
|
||||
fullscreenLoading.value = false
|
||||
pass = false
|
||||
}
|
||||
if (!isLt500K && isImage) {
|
||||
ElMessage.error('未压缩的上传图片大小不能超过 500KB,请使用压缩上传')
|
||||
fullscreenLoading.value = false
|
||||
pass = false
|
||||
}
|
||||
|
||||
console.log('upload file check result: ', pass)
|
||||
|
||||
return pass
|
||||
}
|
||||
|
||||
const uploadSuccess = (res) => {
|
||||
fullscreenLoading.value = false
|
||||
const errorMessage = getUploadErrorMessage(res)
|
||||
if (errorMessage) {
|
||||
ElMessage.error(errorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
const { data } = res
|
||||
if (data?.file) {
|
||||
emit('on-success', data.file.url)
|
||||
}
|
||||
}
|
||||
|
||||
const uploadError = () => {
|
||||
ElMessage({
|
||||
type: 'error',
|
||||
message: '上传失败'
|
||||
})
|
||||
fullscreenLoading.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,244 @@
|
||||
<template>
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
:action="`${getBaseUrl()}/fileUploadAndDownload/upload`"
|
||||
accept="image/*"
|
||||
:show-file-list="false"
|
||||
:auto-upload="false"
|
||||
:data="{'classId': props.classId}"
|
||||
:on-success="handleImageSuccess"
|
||||
:on-change="handleFileChange"
|
||||
:headers="{'x-token': token}"
|
||||
>
|
||||
<el-button type="primary" icon="crop"> 裁剪上传</el-button>
|
||||
</el-upload>
|
||||
|
||||
<el-dialog v-model="dialogVisible" title="图片裁剪" width="1200px" append-to-body @close="dialogVisible = false" :close-on-click-modal="false" draggable>
|
||||
<div class="flex gap-[30px] h-[600px]">
|
||||
<!-- 左侧编辑区 -->
|
||||
<div class="flex flex-col flex-1">
|
||||
<div class="flex-1 bg-[#f8f8f8] rounded-lg overflow-hidden">
|
||||
<VueCropper
|
||||
ref="cropperRef"
|
||||
:img="imgSrc"
|
||||
outputType="jpeg"
|
||||
:autoCrop="true"
|
||||
:autoCropWidth="cropWidth"
|
||||
:autoCropHeight="cropHeight"
|
||||
:fixedBox="false"
|
||||
:fixed="fixedRatio"
|
||||
:fixedNumber="fixedNumber"
|
||||
:centerBox="true"
|
||||
:canMoveBox="true"
|
||||
:full="false"
|
||||
:maxImgSize="1200"
|
||||
:original="true"
|
||||
@realTime="handleRealTime"
|
||||
></VueCropper>
|
||||
</div>
|
||||
|
||||
<!-- 工具栏 -->
|
||||
<div class="mt-[20px] flex items-center p-[10px] bg-white rounded-lg shadow-[0_2px_12px_rgba(0,0,0,0.1)]">
|
||||
<el-button-group>
|
||||
<el-tooltip content="向左旋转">
|
||||
<el-button @click="rotate(-90)" :icon="RefreshLeft" />
|
||||
</el-tooltip>
|
||||
<el-tooltip content="向右旋转">
|
||||
<el-button @click="rotate(90)" :icon="RefreshRight" />
|
||||
</el-tooltip>
|
||||
<el-button :icon="Plus" @click="changeScale(1)"></el-button>
|
||||
<el-button :icon="Minus" @click="changeScale(-1)"></el-button>
|
||||
</el-button-group>
|
||||
|
||||
|
||||
<el-select v-model="currentRatio" placeholder="选择比例" class="w-32 ml-4" @change="onCurrentRatio">
|
||||
<el-option v-for="(item, index) in ratioOptions" :key="index" :label="item.label" :value="index" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧预览区 -->
|
||||
<div class="w-[340px]">
|
||||
<div class="bg-white p-5 rounded-lg shadow-[0_2px_12px_rgba(0,0,0,0.1)]">
|
||||
<div class="mb-[15px] text-gray-600">裁剪预览</div>
|
||||
<div class="bg-white p-5 rounded-lg shadow-[0_2px_12px_rgba(0,0,0,0.1)]"
|
||||
:style="{'width': previews.w + 'px', 'height': previews.h + 'px'}"
|
||||
>
|
||||
<div class="w-full h-full relative overflow-hidden">
|
||||
<img :src="previews.url" :style="previews.img" alt="" class="max-w-none absolute transition-all duration-300 ease-in-out image-render-pixelated origin-[0_0]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="dialogVisible = false">取 消</el-button>
|
||||
<el-button type="primary" @click="handleUpload" :loading="uploading"> {{ uploading ? '上传中...' : '上 传' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, getCurrentInstance } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { RefreshLeft, RefreshRight, Plus, Minus } from '@element-plus/icons-vue'
|
||||
import 'vue-cropper/dist/index.css'
|
||||
import { VueCropper } from 'vue-cropper'
|
||||
import { getBaseUrl } from '@/utils/format'
|
||||
import { getUploadErrorMessage } from '@/utils/uploadResponse'
|
||||
|
||||
defineOptions({
|
||||
name: 'CropperImage'
|
||||
})
|
||||
|
||||
const emit = defineEmits(['on-success'])
|
||||
|
||||
const props = defineProps({
|
||||
classId: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
})
|
||||
|
||||
const uploadRef = ref(null)
|
||||
// 响应式数据
|
||||
const dialogVisible = ref(false)
|
||||
const imgSrc = ref('')
|
||||
const cropperRef = ref(null)
|
||||
const { proxy } = getCurrentInstance()
|
||||
const previews = ref({})
|
||||
const uploading = ref(false)
|
||||
|
||||
// 缩放控制
|
||||
const changeScale = (value) => {
|
||||
proxy.$refs.cropperRef.changeScale(value)
|
||||
}
|
||||
|
||||
// 比例预设
|
||||
const ratioOptions = ref([
|
||||
{ label: '1:1', value: [1, 1] },
|
||||
{ label: '16:9', value: [16, 9] },
|
||||
{ label: '9:16', value: [9, 16] },
|
||||
{ label: '4:3', value: [4, 3] },
|
||||
{ label: '自由比例', value: [] }
|
||||
])
|
||||
|
||||
const fixedNumber = ref([1, 1])
|
||||
const cropWidth = ref(300)
|
||||
const cropHeight = ref(300)
|
||||
|
||||
const fixedRatio = ref(false)
|
||||
const currentRatio = ref(4)
|
||||
const onCurrentRatio = () => {
|
||||
fixedNumber.value = ratioOptions.value[currentRatio.value].value
|
||||
switch (currentRatio.value) {
|
||||
case 0:
|
||||
cropWidth.value = 300
|
||||
cropHeight.value = 300
|
||||
fixedRatio.value = true
|
||||
break
|
||||
case 1:
|
||||
cropWidth.value = 300
|
||||
cropHeight.value = 300 * 9 / 16
|
||||
fixedRatio.value = true
|
||||
break
|
||||
case 2:
|
||||
cropWidth.value = 300 * 9 / 16
|
||||
cropHeight.value = 300
|
||||
fixedRatio.value = true
|
||||
break
|
||||
case 3:
|
||||
cropWidth.value = 300
|
||||
cropHeight.value = 300 * 3 / 4
|
||||
fixedRatio.value = true
|
||||
break
|
||||
default:
|
||||
cropWidth.value = 300
|
||||
cropHeight.value = 300
|
||||
fixedRatio.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 文件处理
|
||||
const handleFileChange = (file) => {
|
||||
const isImage = file.raw.type.includes('image')
|
||||
if (!isImage) {
|
||||
ElMessage.error('请选择图片文件')
|
||||
return
|
||||
}
|
||||
|
||||
if (file.raw.size / 1024 / 1024 > 8) {
|
||||
ElMessage.error('文件大小不能超过8MB!')
|
||||
return false
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
imgSrc.value = e.target.result
|
||||
dialogVisible.value = true
|
||||
}
|
||||
reader.readAsDataURL(file.raw)
|
||||
}
|
||||
|
||||
// 旋转控制
|
||||
const rotate = (degree) => {
|
||||
if (degree === -90) {
|
||||
proxy.$refs.cropperRef.rotateLeft()
|
||||
} else {
|
||||
proxy.$refs.cropperRef.rotateRight()
|
||||
}
|
||||
}
|
||||
|
||||
// 实时预览
|
||||
const handleRealTime = (data) => {
|
||||
previews.value = data
|
||||
//console.log(data)
|
||||
}
|
||||
|
||||
// 上传处理
|
||||
const handleUpload = () => {
|
||||
uploading.value = true
|
||||
proxy.$refs.cropperRef.getCropBlob((blob) => {
|
||||
try {
|
||||
const file = new File([blob], `${Date.now()}.jpg`, { type: 'image/jpeg' })
|
||||
uploadRef.value.clearFiles()
|
||||
uploadRef.value.handleStart(file)
|
||||
uploadRef.value.submit()
|
||||
|
||||
} catch (error) {
|
||||
uploading.value = false
|
||||
ElMessage.error('上传失败: ' + error.message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleImageSuccess = (res) => {
|
||||
const errorMessage = getUploadErrorMessage(res)
|
||||
if (errorMessage) {
|
||||
uploading.value = false
|
||||
ElMessage.error(errorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
const { data } = res
|
||||
if (data) {
|
||||
setTimeout(() => {
|
||||
uploading.value = false
|
||||
dialogVisible.value = false
|
||||
previews.value = {}
|
||||
ElMessage.success('上传成功')
|
||||
emit('on-success', data.file?.url || data.url)
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.vue-cropper) {
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-upload
|
||||
:action="`${getBaseUrl()}/fileUploadAndDownload/upload`"
|
||||
:show-file-list="false"
|
||||
:on-success="handleImageSuccess"
|
||||
:before-upload="beforeImageUpload"
|
||||
:multiple="false"
|
||||
:data="{'classId': props.classId}"
|
||||
:headers="{'x-token': token}"
|
||||
>
|
||||
<el-button type="primary" :icon="Upload">压缩上传</el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ImageCompress from '@/utils/image'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getBaseUrl } from '@/utils/format'
|
||||
import { getUploadErrorMessage } from '@/utils/uploadResponse'
|
||||
import { Upload } from "@element-plus/icons-vue";
|
||||
import { useUserStore } from "@/pinia";
|
||||
|
||||
defineOptions({
|
||||
name: 'UploadImage'
|
||||
})
|
||||
|
||||
const emit = defineEmits(['on-success'])
|
||||
const props = defineProps({
|
||||
imageUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
fileSize: {
|
||||
type: Number,
|
||||
default: 2048 // 2M 超出后执行压缩
|
||||
},
|
||||
maxWH: {
|
||||
type: Number,
|
||||
default: 1920 // 图片长宽上限
|
||||
},
|
||||
classId: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
})
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const token = userStore.token
|
||||
|
||||
const beforeImageUpload = (file) => {
|
||||
const isJPG = file.type?.toLowerCase() === 'image/jpeg'
|
||||
const isPng = file.type?.toLowerCase() === 'image/png'
|
||||
if (!isJPG && !isPng) {
|
||||
ElMessage.error('上传头像图片只能是 jpg或png 格式!')
|
||||
return false
|
||||
}
|
||||
|
||||
const isRightSize = file.size / 1024 < props.fileSize
|
||||
if (!isRightSize) {
|
||||
// 压缩
|
||||
const compress = new ImageCompress(file, props.fileSize, props.maxWH)
|
||||
return compress.compress()
|
||||
}
|
||||
return isRightSize
|
||||
}
|
||||
|
||||
const handleImageSuccess = (res) => {
|
||||
const errorMessage = getUploadErrorMessage(res)
|
||||
if (errorMessage) {
|
||||
ElMessage.error(errorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
const { data } = res
|
||||
if (data?.file) {
|
||||
emit('on-success', data.file.url)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.image-uploader {
|
||||
border: 1px dashed #d9d9d9;
|
||||
width: 180px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.image-uploader {
|
||||
border-color: #409eff;
|
||||
}
|
||||
.image-uploader-icon {
|
||||
font-size: 28px;
|
||||
color: #8c939d;
|
||||
width: 178px;
|
||||
height: 178px;
|
||||
line-height: 178px;
|
||||
text-align: center;
|
||||
}
|
||||
.image {
|
||||
width: 178px;
|
||||
height: 178px;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div
|
||||
class="kra-warning-bar px-1.5 py-2 flex items-center my-2 bg-amber-50 gap-2 text-amber-500 dark:bg-amber-700 dark:text-gray-200"
|
||||
:class="href && 'cursor-pointer'"
|
||||
@click="open"
|
||||
>
|
||||
<el-icon>
|
||||
<warning-filled />
|
||||
</el-icon>
|
||||
<span class="text-sm">
|
||||
{{ title }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { WarningFilled } from '@element-plus/icons-vue'
|
||||
const prop = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
href: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const open = () => {
|
||||
if (prop.href) {
|
||||
window.open(prop.href)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user