前端代码

This commit is contained in:
2026-08-13 22:35:13 +08:00
parent 9044bafdff
commit f1a0b17bb0
412 changed files with 36769 additions and 6617 deletions
-36
View File
@@ -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>
);
};
-32
View File
@@ -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>
);
};
+39
View File
@@ -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>
+47
View File
@@ -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>
+195
View File
@@ -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>
+90
View File
@@ -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>
+30
View File
@@ -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>
+126
View File
@@ -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>
+95
View File
@@ -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>
-12
View File
@@ -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 };
+95
View File
@@ -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>
+31
View File
@@ -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>
+36
View File
@@ -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>
+49
View File
@@ -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>
+39
View File
@@ -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>
+164
View File
@@ -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>
+131
View File
@@ -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>
+44
View File
@@ -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>
+65
View File
@@ -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>
+98
View File
@@ -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>
+244
View File
@@ -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>
+109
View File
@@ -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>