Merge branch 'wyfMain-dev'
This commit is contained in:
commit
b35243bb79
9309
package-lock.json
generated
Normal file
9309
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -14,15 +14,20 @@
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"@highlightjs/vue-plugin": "^2.1.0",
|
||||
"@iconify-json/ion": "^1.2.3",
|
||||
"@kangc/v-md-editor": "^2.3.18",
|
||||
"@vicons/ionicons5": "^0.13.0",
|
||||
"@vueup/vue-quill": "^1.2.0",
|
||||
"@vueuse/core": "^10.7.0",
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"axios": "^1.6.2",
|
||||
"highlight.js": "^11.5.0",
|
||||
"js-audio-recorder": "^1.0.7",
|
||||
"pinia": "^2.1.7",
|
||||
"pinia-plugin-persistedstate": "^3.2.0",
|
||||
"pnpm": "^10.10.0",
|
||||
"quill": "^1.3.7",
|
||||
"quill-image-uploader": "^1.3.0",
|
||||
"quill-mention": "^4.1.0",
|
||||
@ -49,8 +54,9 @@
|
||||
"naive-ui": "^2.35.0",
|
||||
"npm-run-all2": "^6.1.1",
|
||||
"prettier": "^3.1.0",
|
||||
"sass": "^1.88.0",
|
||||
"typescript": "~5.2.0",
|
||||
"unocss": "^66.1.1",
|
||||
"unocss": "0.58.0",
|
||||
"vite": "^4.5.1",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vue-tsc": "^1.8.25",
|
||||
|
992
pnpm-lock.yaml
992
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
22
src/api/components.js
Normal file
22
src/api/components.js
Normal file
@ -0,0 +1,22 @@
|
||||
import _axios from '@/utils/erpRequest'
|
||||
export default {
|
||||
deleteDataByParams: (url, data) => _axios.fetch(url, data, 'DELETE'),
|
||||
putDataByParams: (url, data) => _axios.fetch(url, data, 'PUT'),
|
||||
postDataByParams: (url, data) => _axios.fetch(url, data, 'POST'),
|
||||
findDates: ( data) => _axios.fetch('/report/find/dates', data, 'GET'),
|
||||
postBlobByParams: (url, data) => _axios.fetch(url, data, 'POST', 'blob'),
|
||||
getDataByParams: (url, data) => _axios.fetch(url, data, 'GET'),
|
||||
getBlobByParams: (url, data) => _axios.fetch(url, data, 'GET', 'blob'),
|
||||
uploadFormData: (url, data) => _axios.fetch(url, data, 'POST', 'json', '', true, true),
|
||||
viewDetails: (data) => _axios.fetch('/health/info', data, 'POST'),
|
||||
healthDelex: (data) => _axios.fetch('/health/delex', data, 'POST'),
|
||||
healthDrde: (data) => _axios.fetch('/health/drde', data, 'POST'),
|
||||
healthEdit: (data) => _axios.fetch('/health/edit', data, 'POST'),
|
||||
healthAdddr: (data) => _axios.fetch('/health/adddr', data, 'POST'),
|
||||
healthEditStreet: (data) => _axios.fetch('/health/editstreet', data, 'POST'),
|
||||
healthIllmessage: (data) => _axios.fetch('/health/illmessage', data, 'POST'),
|
||||
healthCall: (url, data) => _axios.fetch(url, data, 'POST'),
|
||||
promotionDownload: (data) => _axios.fetch('/collections/extend', data, 'POST', 'blob'),
|
||||
//只能看到我所在的组织机构树
|
||||
viewMyTree: (data) => _axios.fetch('/department/v2/tree/my', data, 'POST'),
|
||||
}
|
18
src/api/index.js
Normal file
18
src/api/index.js
Normal file
@ -0,0 +1,18 @@
|
||||
// 使用 `import.meta.glob` 来同步导入所有匹配的模块
|
||||
// 使用 `{ eager: true }` 选项来立即加载这些模块
|
||||
const modules = import.meta.glob('./*.js', { eager: true });
|
||||
|
||||
const HTTP = {};
|
||||
for (const path in modules) {
|
||||
if (Object.hasOwnProperty.call(modules, path)) {
|
||||
// 正确移除 './' 和 '.js',只保留文件名
|
||||
const componentName = path.replace(/^\.\/(.*)\.\w+$/, '$1');
|
||||
if (componentName !== 'index') {
|
||||
// 确保我们只获取模块的默认导出
|
||||
HTTP[componentName] = modules[path]?.default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出 HTTP 对象
|
||||
export default { HTTP };
|
BIN
src/assets/image/chatList/addressBook.png
Normal file
BIN
src/assets/image/chatList/addressBook.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 530 B |
BIN
src/assets/image/icon/arrow-right-grey.png
Normal file
BIN
src/assets/image/icon/arrow-right-grey.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 163 B |
BIN
src/assets/image/icon/close-btn-grey.png
Normal file
BIN
src/assets/image/icon/close-btn-grey.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.2 KiB |
20
src/components/common/customBtn.vue
Normal file
20
src/components/common/customBtn.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<n-button
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<template
|
||||
v-for="(slot, name) in $slots"
|
||||
:key="name"
|
||||
#[name]
|
||||
>
|
||||
<slot :name="name"></slot>
|
||||
</template>
|
||||
</n-button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton } from 'naive-ui'
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
</style>
|
134
src/components/common/customModal.vue
Normal file
134
src/components/common/customModal.vue
Normal file
@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<xNModal v-model:show="show" v-bind="$attrs">
|
||||
<template #header>
|
||||
<div class="custom-modal-header">
|
||||
<div class="header-content">
|
||||
<template v-if="$slots.header">
|
||||
<slot name="header"></slot>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ title }}
|
||||
</template>
|
||||
<div class="custom-close-btn" v-if="customCloseBtn">
|
||||
<img src="@/assets/image/icon/close-btn-grey.png" alt="" @click="handleCloseModal"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<slot name="content"></slot>
|
||||
<template #footer>
|
||||
<div class="custom-modal-btns">
|
||||
<customBtn
|
||||
color="#C7C7C9"
|
||||
style="width: 161px; height: 34px;"
|
||||
@click="handleCancel"
|
||||
v-if="actionBtns.cancelBtn"
|
||||
>取消</customBtn
|
||||
>
|
||||
<customBtn
|
||||
color="#46299D"
|
||||
style="width: 161px; height: 34px;"
|
||||
@click="handleConfirm"
|
||||
:loading="state.confirmBtnLoading"
|
||||
v-if="actionBtns.confirmBtn"
|
||||
>确定</customBtn
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</xNModal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, computed } from 'vue'
|
||||
import xNModal from '@/components/x-naive-ui/x-n-modal/index.vue'
|
||||
import customBtn from '@/components/common/customBtn.vue'
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
// 是否显示模态框
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
title: {
|
||||
// 模态框标题
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
actionBtns: {
|
||||
// 操作按钮
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
customCloseBtn: {
|
||||
// 是否显示自定义关闭按钮
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:show', 'cancel', 'confirm'])
|
||||
|
||||
const show = computed({
|
||||
get: () => props.show,
|
||||
set: (val) => emit('update:show', val)
|
||||
})
|
||||
|
||||
const handleCancel = () => {
|
||||
show.value = false
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
state.confirmBtnLoading = true
|
||||
emit('confirm', closeLoading)
|
||||
}
|
||||
|
||||
const closeLoading = () => {
|
||||
state.confirmBtnLoading = false
|
||||
}
|
||||
|
||||
const state = reactive({
|
||||
confirmBtnLoading: false // 确定按钮loading
|
||||
})
|
||||
|
||||
const handleCloseModal = () => {
|
||||
show.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.custom-modal-header {
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
margin: 0 12px;
|
||||
|
||||
.header-content {
|
||||
padding: 0 0 15px;
|
||||
text-align: center;
|
||||
color: #1f2225;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 28px;
|
||||
position: relative;
|
||||
|
||||
.custom-close-btn {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
cursor: pointer;
|
||||
img {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-modal-btns {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 0 50px;
|
||||
}
|
||||
</style>
|
121
src/components/flnlayout/tree/flnindex.vue
Normal file
121
src/components/flnlayout/tree/flnindex.vue
Normal file
@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<div class="fl-tree width-100 fl-mt-md">
|
||||
<n-tree v-if="state.treeLoading"
|
||||
block-line
|
||||
:default-expanded-keys="state.expandedKeys"
|
||||
:default-selected-keys="state.clickKey"
|
||||
label-field="name"
|
||||
key-field="key"
|
||||
:expand-on-click="true"
|
||||
:render-label="renderLabel"
|
||||
:data="state.treeData"
|
||||
@update:selected-keys="handleSelectTree" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
ref,
|
||||
reactive,
|
||||
onBeforeMount,
|
||||
onMounted,
|
||||
getCurrentInstance,
|
||||
computed,
|
||||
defineEmits,
|
||||
watch,
|
||||
nextTick,
|
||||
h
|
||||
} from "vue";
|
||||
|
||||
import { PlusCircleOutlined, MinusCircleOutlined, EditOutlined, PlusOutlined, MinusOutlined, CloseOutlined, CheckOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
import treeLabel from "./treelabel.vue";
|
||||
import { NTree } from 'naive-ui';
|
||||
|
||||
const currentInstance = getCurrentInstance();
|
||||
const { $request } = currentInstance.appContext.config.globalProperties;
|
||||
let props = defineProps({
|
||||
data: Object,
|
||||
refreshCount: Number,
|
||||
config: Object,
|
||||
expandedKeys: Array,
|
||||
clickKey: [String, Number]
|
||||
})
|
||||
const state = reactive({
|
||||
expandedKeys: [],
|
||||
editTitle: '',
|
||||
treeData: [],
|
||||
clickKey: [],
|
||||
treeLoading: true
|
||||
});
|
||||
|
||||
watch(() => props.refreshCount, () => {
|
||||
state.clickKey = [props.clickKey]
|
||||
state.treeLoading = false
|
||||
nextTick(() => {
|
||||
state.treeData = props.data
|
||||
calcDefaultConfig(state.treeData, 1)
|
||||
state.treeLoading = true
|
||||
})
|
||||
});
|
||||
|
||||
watch(() => props.expandedKeys, () => {
|
||||
state.clickKey = [props.clickKey]
|
||||
state.expandedKeys = props.expandedKeys
|
||||
}, { deep: true });
|
||||
|
||||
onBeforeMount(() => {
|
||||
state.clickKey = [props.clickKey]
|
||||
state.treeData = props.data
|
||||
calcDefaultConfig(state.treeData, 1);
|
||||
state.expandedKeys = state.treeData.map(item => item.key)
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
});
|
||||
|
||||
const emit = defineEmits(["triggerTreeAction", "triggerTreeClick", "triggerTreeDefaultClick"]);
|
||||
const handleSelectTree = (keys, option, meta) => {
|
||||
if (keys.length === 1) {
|
||||
emit('triggerTreeClick', { selectedKey: keys[0], tree: option[0] })
|
||||
} else {
|
||||
emit('triggerTreeDefaultClick')
|
||||
}
|
||||
}
|
||||
const renderLabel = (option, checked) => {
|
||||
return h(
|
||||
treeLabel,
|
||||
{
|
||||
dataRef: option,
|
||||
checked: checked,
|
||||
config: props.config,
|
||||
clickKey: props.clickKey,
|
||||
onTriggerTreeAction: handleTreeAction
|
||||
},
|
||||
{}
|
||||
)
|
||||
}
|
||||
|
||||
const calcDefaultConfig = (data, level) => {
|
||||
for (let item of data) {
|
||||
if (!item.key) {
|
||||
item.key = item.title + '_' + level;
|
||||
}
|
||||
item.edit = false
|
||||
if (item.children) {
|
||||
calcDefaultConfig(item.children, level + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const override = ({ option }) => {
|
||||
if (option.children) {
|
||||
return "toggleExpand";
|
||||
}
|
||||
return "default";
|
||||
};
|
||||
const handleTreeAction = ({ type, val }) => {
|
||||
emit('triggerTreeAction', { type, val })
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
121
src/components/flnlayout/tree/treelabel.vue
Normal file
121
src/components/flnlayout/tree/treelabel.vue
Normal file
@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<div class="row items-center">
|
||||
<div v-if="state.treeData.edit">
|
||||
<n-input v-model:value="state.editTitle"
|
||||
style="width:120px" />
|
||||
</div>
|
||||
|
||||
<n-popover trigger="hover"
|
||||
v-else>
|
||||
<template #trigger>
|
||||
<div style="width:120px"
|
||||
class="fl-px-sm sf-text-ellipsis">{{ state.treeData.title }}</div>
|
||||
</template>
|
||||
<div>{{ state.treeData.title }}</div>
|
||||
</n-popover>
|
||||
<n-icon :component="CreateOutline"
|
||||
class="fl-ml-sm"
|
||||
size="20"
|
||||
v-if="config?.actions.includes('edit')&&!state.treeData.edit"
|
||||
@click.stop="handleTreeEdit(state.treeData)" />
|
||||
<n-icon :component="Remove"
|
||||
size="20"
|
||||
v-if="config?.actions.includes('subtraction')&&!state.treeData.edit&&visibleFormItem(config.subtractionShow, state.treeData)"
|
||||
class="fl-ml-sm"
|
||||
@click.stop="handleTreeSubtraction(state.treeData)" />
|
||||
<n-icon :component="Add"
|
||||
size="20"
|
||||
v-if="config?.actions.includes('add')&&!state.treeData.edit&&visibleFormItem(config.addShow, state.treeData)"
|
||||
class="fl-ml-sm"
|
||||
@click.stop="handleTreeAdd(state.treeData)" />
|
||||
<drag-outlined v-if="config?.actions.includes('move')&&!state.treeData.edit&&visibleFormItem(config.moveShow, state.treeData)"
|
||||
class="fl-ml-sm"
|
||||
@click.stop="handleTreeMove(state.treeData)" />
|
||||
|
||||
<!-- <n-icon :component="MoveOutline"
|
||||
size="20"
|
||||
v-if="config?.actions.includes('move')&&!state.treeData.edit&&visibleFormItem(config.moveShow, state.treeData)"
|
||||
class="fl-ml-sm"
|
||||
@click.stop="handleTreeMove(state.treeData)" /> -->
|
||||
|
||||
<n-icon :component="Checkmark"
|
||||
size="20"
|
||||
v-if="state.treeData.edit"
|
||||
class="fl-ml-sm"
|
||||
@click.stop="handleTreeSave(state.treeData)" />
|
||||
<n-icon :component="Close"
|
||||
size="20"
|
||||
v-if="state.treeData.edit"
|
||||
class="fl-ml-md"
|
||||
@click.stop="handleTreeNotSave(state.treeData)" />
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
onBeforeMount,
|
||||
onMounted,
|
||||
watch,
|
||||
reactive
|
||||
} from "vue";
|
||||
import {
|
||||
visibleFormItem,
|
||||
} from "@/utils/helper/form";
|
||||
import {
|
||||
UpOutlined,
|
||||
DownOutlined,
|
||||
CloseCircleOutlined,
|
||||
PlusOutlined,
|
||||
DragOutlined,
|
||||
} from "@ant-design/icons-vue";
|
||||
import { Add, Checkmark, Close, CreateOutline, Remove, MoveOutline } from "@vicons/ionicons5";
|
||||
import { NPopover, NInput, NIcon } from "naive-ui";
|
||||
let props = defineProps({
|
||||
dataRef: Object,
|
||||
checked: Boolean,
|
||||
config: Object,
|
||||
clickKey: [String, Number]
|
||||
})
|
||||
const state = reactive({
|
||||
expandedKeys: [],
|
||||
editTitle: '',
|
||||
treeData: [],
|
||||
});
|
||||
onBeforeMount(() => {
|
||||
state.treeData = props.dataRef.option
|
||||
})
|
||||
watch(() => props.dataRef.option, (val) => {
|
||||
state.treeData = props.dataRef.option
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
})
|
||||
const emit = defineEmits(["triggerTreeAction", "triggerTreeClick"]);
|
||||
|
||||
// const myComponentRef = ref(null);
|
||||
const handleTreeEdit = () => {
|
||||
state.editTitle = state.treeData.title
|
||||
state.treeData.edit = true
|
||||
// myComponentRef.value.$forceUpdate();
|
||||
}
|
||||
const handleTreeAdd = () => {
|
||||
emit('triggerTreeAction', { type: 'add', val: state.treeData })
|
||||
}
|
||||
const handleTreeMove = () => {
|
||||
emit('triggerTreeAction', { type: 'move', val: state.treeData })
|
||||
}
|
||||
|
||||
const handleTreeSubtraction = () => {
|
||||
emit('triggerTreeAction', { type: 'subtraction', val: state.treeData })
|
||||
}
|
||||
const handleTreeSave = () => {
|
||||
state.treeData.title = state.editTitle
|
||||
emit('triggerTreeAction', { type: 'save', val: state.treeData })
|
||||
}
|
||||
const handleTreeNotSave = () => {
|
||||
state.editTitle = ''
|
||||
emit('triggerTreeAction', { type: 'cancel', val: state.treeData })
|
||||
}
|
||||
</script>
|
||||
|
@ -12,6 +12,7 @@ import {
|
||||
ServeUpdateGroupCard
|
||||
} from '@/api/group'
|
||||
import { useInject } from '@/hooks'
|
||||
import customModal from '@/components/common/customModal.vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const { showUserInfoModal } = useInject()
|
||||
@ -41,7 +42,18 @@ const state = reactive({
|
||||
visit_card: '',
|
||||
notice: ''
|
||||
},
|
||||
remark: ''
|
||||
remark: '',
|
||||
isShowModal: false, //是否显示提示模态框
|
||||
customModalStyle: {
|
||||
width: '724px',
|
||||
height: '314px'
|
||||
}, //自定义模态框样式
|
||||
chatSettingOperateHint: '', // 提示信息
|
||||
chatSettingOperateSubHint: '', // 次要提示信息
|
||||
actionBtns: {
|
||||
confirmBtn: true,
|
||||
cancelBtn: true
|
||||
}, // 操作按钮
|
||||
})
|
||||
|
||||
const members = ref<any[]>([])
|
||||
@ -152,6 +164,37 @@ const onChangeRemark = () => {
|
||||
|
||||
loadDetail()
|
||||
loadMembers()
|
||||
|
||||
// 处理模态框的确认
|
||||
const handleModalConfirm = (closeLoading) => {
|
||||
setTimeout(() => {
|
||||
closeLoading()
|
||||
state.isShowModal = false
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
//显示聊天设置操作提示模态框
|
||||
const showChatSettingOperateModal = (type: string) => {
|
||||
state.isShowModal = true
|
||||
switch (type) {
|
||||
case 'clear':
|
||||
state.chatSettingOperateHint = '确定清空聊天记录'
|
||||
break
|
||||
case 'dismiss':
|
||||
state.chatSettingOperateHint = '确定解散本群'
|
||||
break
|
||||
case 'quit':
|
||||
state.chatSettingOperateHint = '确定退出群聊'
|
||||
const findAdmin = search.value.find((item) => item.leader === 2 || item.leader === 1)
|
||||
const isLastAdmin = findAdmin && findAdmin.user_id === userStore.uid
|
||||
if (isLastAdmin) {
|
||||
state.chatSettingOperateSubHint = '退出后,本群将被解散'
|
||||
} else {
|
||||
state.chatSettingOperateSubHint = '退出后,聊天记录将被清空'
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<section class="el-container is-vertical section">
|
||||
@ -160,10 +203,12 @@ loadMembers()
|
||||
<n-icon size="21" :component="Comment" />
|
||||
</div>
|
||||
<div class="center-text">
|
||||
<span>群信息</span>
|
||||
<!-- <span>群信息</span> -->
|
||||
<span>聊天设置</span>
|
||||
</div>
|
||||
<div class="right-icon">
|
||||
<n-icon size="21" :component="Close" @click="onClose" />
|
||||
<!-- <n-icon size="21" :component="Close" @click="onClose" /> -->
|
||||
<img src="@/assets/image/icon/close-btn-grey.png" alt="" @click="onClose" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@ -171,12 +216,20 @@ loadMembers()
|
||||
<div class="info-box">
|
||||
<div class="b-box">
|
||||
<div class="block">
|
||||
<div class="title">群名称:</div>
|
||||
<div class="title">群成员</div>
|
||||
<div class="text">{{ members.length }}人</div>
|
||||
</div>
|
||||
<div class="describe">群主已开启“新成员入群可查看所有聊天记录</div>
|
||||
</div>
|
||||
|
||||
<div class="b-box">
|
||||
<div class="block">
|
||||
<div class="title">群名称</div>
|
||||
</div>
|
||||
<div class="describe">{{ state.detail.name }}</div>
|
||||
</div>
|
||||
|
||||
<div class="b-box">
|
||||
<!-- <div class="b-box">
|
||||
<div class="block">
|
||||
<div class="title">群名片:</div>
|
||||
<div class="text">
|
||||
@ -201,33 +254,33 @@ loadMembers()
|
||||
</div>
|
||||
</div>
|
||||
<div class="describe">{{ state.detail.visit_card || '未设置' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="b-box">
|
||||
<div class="block">
|
||||
<div class="title">群成员:</div>
|
||||
<div class="text">{{ members.length }}人</div>
|
||||
</div>
|
||||
<div class="describe">群主已开启“新成员入群可查看所有聊天记录</div>
|
||||
</div>
|
||||
|
||||
<div class="b-box">
|
||||
</div> -->
|
||||
<!-- <div class="b-box">
|
||||
<div class="block">
|
||||
<div class="title">群简介:</div>
|
||||
</div>
|
||||
<div class="describe">
|
||||
{{ state.detail.profile ? state.detail.profile : '暂无群简介' }}
|
||||
</div>
|
||||
</div> -->
|
||||
<div class="b-box">
|
||||
<div class="block">
|
||||
<div class="title">群类型</div>
|
||||
</div>
|
||||
<div class="describe">
|
||||
{{ '暂无群类型' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="b-box">
|
||||
<div class="block">
|
||||
<div class="title">群公告:</div>
|
||||
<div class="text">
|
||||
<n-button type="primary" text> 更多 </n-button>
|
||||
<div class="title">群公告</div>
|
||||
<!-- <div class="text"> -->
|
||||
<!-- <n-button type="primary" text> 更多 </n-button> -->
|
||||
<img class="icon-right" src="@/assets/image/icon/arrow-right-grey.png" alt="" />
|
||||
<!-- </div> -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="describe">暂无群公告</div>
|
||||
<div class="describe">管理员未设置</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -276,19 +329,37 @@ loadMembers()
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-settings-btns">
|
||||
<n-button class="btn" type="error" ghost @click="showChatSettingOperateModal('clear')">
|
||||
清空聊天记录
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="isAdmin || isLeader"
|
||||
class="btn"
|
||||
type="error"
|
||||
ghost
|
||||
@click="showChatSettingOperateModal('dismiss')"
|
||||
>
|
||||
解散该群
|
||||
</n-button>
|
||||
<n-button class="btn" type="error" ghost @click="showChatSettingOperateModal('quit')">
|
||||
退出群聊
|
||||
</n-button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="el-footer footer bdr-t">
|
||||
<template v-if="!isAdmin">
|
||||
<!-- <footer class="el-footer footer bdr-t"> -->
|
||||
<!-- <template v-if="!isAdmin">
|
||||
<n-popconfirm negative-text="取消" positive-text="确定" @positive-click="onSignOut">
|
||||
<template #trigger>
|
||||
<n-button class="btn" type="error" ghost> 退出群聊 </n-button>
|
||||
</template>
|
||||
确定要退出群吗? 退出后不再接收此群消息!
|
||||
</n-popconfirm>
|
||||
</template>
|
||||
</template> -->
|
||||
|
||||
<n-button
|
||||
<!-- <n-button
|
||||
class="btn"
|
||||
type="primary"
|
||||
text-color="#ffffff"
|
||||
@ -296,8 +367,8 @@ loadMembers()
|
||||
@click="onShowManage(true)"
|
||||
>
|
||||
群聊管理
|
||||
</n-button>
|
||||
</footer>
|
||||
</n-button> -->
|
||||
<!-- </footer> -->
|
||||
</section>
|
||||
|
||||
<GroupLaunch
|
||||
@ -308,6 +379,24 @@ loadMembers()
|
||||
/>
|
||||
|
||||
<GroupManage v-if="isShowManage" :gid="gid" @close="onShowManage(false)" />
|
||||
|
||||
<customModal
|
||||
v-model:show="state.isShowModal"
|
||||
title="提示"
|
||||
:style="state.customModalStyle"
|
||||
:closable="false"
|
||||
@confirm="handleModalConfirm"
|
||||
:actionBtns="state.actionBtns"
|
||||
>
|
||||
<template #content>
|
||||
<div class="custom-modal-content">
|
||||
<text>{{ state.chatSettingOperateHint }}</text>
|
||||
<text style="font-size: 16px; color: #999999; margin: 0; line-height: 22px;">{{
|
||||
state.chatSettingOperateSubHint
|
||||
}}</text>
|
||||
</div>
|
||||
</template>
|
||||
</customModal>
|
||||
</template>
|
||||
<style lang="less" scoped>
|
||||
.section {
|
||||
@ -340,6 +429,10 @@ loadMembers()
|
||||
height: 100%;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
img {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -360,37 +453,49 @@ loadMembers()
|
||||
|
||||
.block {
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
// height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.title {
|
||||
height: 100%;
|
||||
line-height: 30px;
|
||||
flex: auto;
|
||||
// height: 100%;
|
||||
// line-height: 30px;
|
||||
// flex: auto;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: #191919;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.text {
|
||||
height: 100%;
|
||||
line-height: 30px;
|
||||
width: 30%;
|
||||
// height: 100%;
|
||||
// line-height: 30px;
|
||||
// width: 30%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.icon-right {
|
||||
width: 5px;
|
||||
height: 9px;
|
||||
}
|
||||
}
|
||||
|
||||
.describe {
|
||||
width: 100%;
|
||||
min-height: 24px;
|
||||
line-height: 24px;
|
||||
font-size: 13px;
|
||||
color: #b1b1b1;
|
||||
font-weight: 300;
|
||||
// min-height: 24px;
|
||||
// line-height: 24px;
|
||||
// font-size: 13px;
|
||||
// color: #b1b1b1;
|
||||
// font-weight: 300;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: #999999;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -457,6 +562,20 @@ loadMembers()
|
||||
}
|
||||
}
|
||||
|
||||
.chat-settings-btns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
gap: 30px;
|
||||
.btn {
|
||||
width: calc(100% - 50px);
|
||||
background-color: #fff;
|
||||
color: #CF3050;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -481,4 +600,17 @@ loadMembers()
|
||||
background-color: #e1eaff;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
text {
|
||||
font-size: 20px;
|
||||
font-weight: 400;
|
||||
color: #1f2225;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
95
src/components/talk/message/LinkMessage.vue
Normal file
95
src/components/talk/message/LinkMessage.vue
Normal file
@ -0,0 +1,95 @@
|
||||
<!-- 完全复制的textMessage组件,没有用处,仅兜底真有14类型时的场景。后续会单独做制作分享卡片功能,到时再根据分享卡片样式重做本页面 -->
|
||||
<!-- 完全复制的textMessage组件,没有用处,仅兜底真有14类型时的场景。后续会单独做制作分享卡片功能,到时再根据分享卡片样式重做本页面 -->
|
||||
<!-- 完全复制的textMessage组件,没有用处,仅兜底真有14类型时的场景。后续会单独做制作分享卡片功能,到时再根据分享卡片样式重做本页面 -->
|
||||
<!-- 完全复制的textMessage组件,没有用处,仅兜底真有14类型时的场景。后续会单独做制作分享卡片功能,到时再根据分享卡片样式重做本页面 -->
|
||||
<!-- 完全复制的textMessage组件,没有用处,仅兜底真有14类型时的场景。后续会单独做制作分享卡片功能,到时再根据分享卡片样式重做本页面 -->
|
||||
<!-- 完全复制的textMessage组件,没有用处,仅兜底真有14类型时的场景。后续会单独做制作分享卡片功能,到时再根据分享卡片样式重做本页面 -->
|
||||
<!-- 完全复制的textMessage组件,没有用处,仅兜底真有14类型时的场景。后续会单独做制作分享卡片功能,到时再根据分享卡片样式重做本页面 -->
|
||||
<!-- 完全复制的textMessage组件,没有用处,仅兜底真有14类型时的场景。后续会单独做制作分享卡片功能,到时再根据分享卡片样式重做本页面 -->
|
||||
<!-- 完全复制的textMessage组件,没有用处,仅兜底真有14类型时的场景。后续会单独做制作分享卡片功能,到时再根据分享卡片样式重做本页面 -->
|
||||
<!-- 完全复制的textMessage组件,没有用处,仅兜底真有14类型时的场景。后续会单独做制作分享卡片功能,到时再根据分享卡片样式重做本页面 -->
|
||||
<script lang="ts" setup>
|
||||
import { textReplaceEmoji } from '@/utils/emojis'
|
||||
import { textReplaceLink, textReplaceMention } from '@/utils/strings'
|
||||
import { ITalkRecordExtraText, ITalkRecord } from '@/types/chat'
|
||||
|
||||
const props = defineProps<{
|
||||
extra: ITalkRecordExtraText
|
||||
data: ITalkRecord
|
||||
maxWidth?: boolean
|
||||
source?: 'panel' | 'forward' | 'history'
|
||||
}>()
|
||||
|
||||
const float = props.data.float
|
||||
|
||||
let textContent = props.extra?.content || ''
|
||||
|
||||
textContent = textReplaceLink(textContent)
|
||||
|
||||
if (props.data.talk_type == 2) {
|
||||
textContent = textReplaceMention(textContent, '#1890ff')
|
||||
}
|
||||
|
||||
textContent = textReplaceEmoji(textContent)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="im-message-text"
|
||||
:class="{
|
||||
left: float == 'left',
|
||||
right: float == 'right',
|
||||
maxwidth: maxWidth,
|
||||
'radius-reset': source != 'panel',
|
||||
}"
|
||||
>
|
||||
<pre v-html="textContent" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.im-message-text {
|
||||
min-width: 40rpx;
|
||||
min-height: 40rpx;
|
||||
padding: 22rpx 30rpx;
|
||||
color: #1a1a1a;
|
||||
background: #ffffff;
|
||||
border-radius: 0 16rpx 16rpx 16rpx;
|
||||
|
||||
&.right {
|
||||
background-color: #46299d;
|
||||
color: #ffffff;
|
||||
border-radius: 16rpx 0 16rpx 16rpx;
|
||||
}
|
||||
|
||||
&.maxwidth {
|
||||
max-width: 486rpx;
|
||||
}
|
||||
|
||||
&.radius-reset {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
word-wrap: break-word;
|
||||
font-size: 32rpx;
|
||||
font-family: 'PingFang SC', 'Microsoft YaHei', 'Alibaba PuHuiTi 2.0 45';
|
||||
line-height: 44rpx;
|
||||
|
||||
:deep(.emoji) {
|
||||
vertical-align: text-bottom;
|
||||
margin: 0 10rpx;
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
}
|
||||
|
||||
:deep(a) {
|
||||
color: #2196f3;
|
||||
text-decoration: revert;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
25
src/components/talk/message/system/SysGroupAdminMessage.vue
Normal file
25
src/components/talk/message/system/SysGroupAdminMessage.vue
Normal file
@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
import './sys-message.less'
|
||||
import { useInject } from '@/hooks'
|
||||
|
||||
defineProps({
|
||||
extra: Object,
|
||||
data: Object
|
||||
})
|
||||
|
||||
const { showUserInfoModal } = useInject()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="im-message-sys-text">
|
||||
<div class="sys-text">
|
||||
|
||||
<template v-for="(user, index) in extra.members" :key="index">
|
||||
<a @click="showUserInfoModal(user.user_id)">{{ user.nickname }}</a>
|
||||
<em v-show="index < extra.members.length - 1">、</em>
|
||||
</template>
|
||||
|
||||
<span>已成为管理员</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
19
src/components/talk/message/system/SysGroupDismissed.vue
Normal file
19
src/components/talk/message/system/SysGroupDismissed.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<script setup>
|
||||
import './sys-message.less'
|
||||
import { useInject } from '@/hooks'
|
||||
|
||||
const { showUserInfoModal } = useInject()
|
||||
|
||||
defineProps({
|
||||
extra: Object,
|
||||
data: Object
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="im-message-sys-text">
|
||||
<div class="sys-text">
|
||||
<span>{{ extra.content }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
import './sys-message.less'
|
||||
import { useInject } from '@/hooks'
|
||||
|
||||
const { showUserInfoModal } = useInject()
|
||||
|
||||
defineProps({
|
||||
extra: Object,
|
||||
data: Object
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="im-message-sys-text">
|
||||
<div class="sys-text">
|
||||
<a @click="showUserInfoModal(data.user_id)">
|
||||
<!-- {{ data.nickname }} -->
|
||||
管理员
|
||||
</a>
|
||||
<!-- <span>修改群名为</span>
|
||||
<span>"{{ extra.group_name }}"</span> -->
|
||||
<span>修改了群信息</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
import './sys-message.less'
|
||||
import { useInject } from '@/hooks'
|
||||
|
||||
const { showUserInfoModal } = useInject()
|
||||
|
||||
defineProps({
|
||||
extra: Object,
|
||||
data: Object,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="im-message-sys-text">
|
||||
<div class="sys-text">
|
||||
<template v-for="(user, index) in extra?.members" :key="index">
|
||||
<a @click="showUserInfoModal(user.user_id)">{{ user.nickname }}</a>
|
||||
<em v-show="index < extra.members.length - 1">、</em>
|
||||
</template>
|
||||
<span>已离开此群</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
142
src/components/x-naive-ui/README.md
Normal file
142
src/components/x-naive-ui/README.md
Normal file
@ -0,0 +1,142 @@
|
||||
# @x-naive-ui 组件库
|
||||
|
||||
基于 Naive UI 的二次封装组件库,旨在提供更高层级的抽象和更便捷的使用方式,同时保持足够的灵活性。
|
||||
|
||||
@x-naive-ui 的设计理念是在易用性和灵活性之间找到平衡点,通过合理的默认值和可配置项,能够快速开发出高质量的页面,同时保留足够的扩展空间应对特殊需求。
|
||||
|
||||
**如发现文档与实际使用有出入或者不完善 可提交修改**
|
||||
## 设计理念
|
||||
|
||||
### 1. 易用性与灵活性的平衡
|
||||
|
||||
- **约定优于配置**:提供合理的默认值,减少基础使用时的配置量
|
||||
- **保持原有能力**:通过属性透传,保留 Naive UI 原组件的所有功能
|
||||
- **渐进式配置**:简单场景可以快速使用,复杂场景仍可深度定制
|
||||
|
||||
### 2. 通用性与特殊性的权衡
|
||||
|
||||
- **场景覆盖**:优先覆盖 80% 的常见业务场景
|
||||
- **扩展机制**:为剩余 20% 的特殊场景预留扩展接口
|
||||
### 3.<span style="background-color: red;color:#fff">避免过度封装:不追求完美覆盖所有场景,保持组件的可维护性</span>。
|
||||
## 组件列表
|
||||
|
||||
### x-n-data-table
|
||||
数据表格组件,增强了以下能力:
|
||||
- ✨ 拖拽排序(支持整行/手柄模式)
|
||||
- ✨ 列级别的插槽系统
|
||||
- 🎯 统一的样式和交互
|
||||
|
||||
**权衡点**:
|
||||
- 牺牲了一定的性能来换取更好的开发体验
|
||||
- 固化了部分样式以确保视觉一致性
|
||||
|
||||
### x-n-modal
|
||||
模态框组件,预设了常用配置:
|
||||
- ✨ 统一的挂载点管理
|
||||
- ✨ 预设的关闭行为
|
||||
- 🎯 居中布局和统一样式
|
||||
|
||||
**权衡点**:
|
||||
- 限制了一些灵活性以确保使用的一致性
|
||||
- 强制了某些最佳实践(如挂载点)
|
||||
|
||||
### x-n-upload
|
||||
文件上传组件,增强了以下功能:
|
||||
- ✨ 统一的文件处理逻辑
|
||||
- ✨ 内置预览能力
|
||||
- 🎯 更友好的类型支持
|
||||
|
||||
**权衡点**:
|
||||
- 上传接口格式固定,需要后端配合
|
||||
- 为了通用性,部分特殊格式需要额外处理
|
||||
|
||||
### x-search-form
|
||||
搜索表单组件,提供了:
|
||||
- ✨ 声明式配置
|
||||
- ✨ 自动布局
|
||||
- 🎯 统一的搜索重置行为
|
||||
|
||||
**权衡点**:
|
||||
- 牺牲了一些布局灵活性换取使用便利性
|
||||
- 配置项相对复杂,但换来了更好的复用性
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 组件使用建议
|
||||
|
||||
```vue
|
||||
<!-- 推荐:使用声明式配置 -->
|
||||
<x-search-form
|
||||
:search-config="searchConfig"
|
||||
:cols="4"
|
||||
@change="handleSearch"
|
||||
/>
|
||||
|
||||
<!-- 不推荐:内联复杂配置 -->
|
||||
<x-search-form
|
||||
:search-config="[
|
||||
{ type: 'input', key: 'name', label: '姓名' },
|
||||
{ type: 'select', key: 'status', label: '状态' }
|
||||
]"
|
||||
/>
|
||||
```
|
||||
|
||||
### 2. 配置管理建议
|
||||
|
||||
```ts
|
||||
// 推荐:将配置抽离到单独的配置文件
|
||||
import { searchConfig } from './config'
|
||||
import { tableConfig } from './config'
|
||||
|
||||
// 不推荐:在组件内部直接定义<E5AE9A><E4B989><EFBFBD>杂配置
|
||||
const searchConfig = [
|
||||
// ... 大量配置
|
||||
]
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **性能考虑**
|
||||
- 大数据量场景下,优先使用原生组件
|
||||
- 合理使用 `shallowRef` 和 `markRaw`
|
||||
- 避免不必要的响应式转换
|
||||
|
||||
2. **扩展性保证**
|
||||
- 使用 `v-bind` 透传原组件属性
|
||||
- 预留合理的插槽接口
|
||||
- 导出必要的类型定义
|
||||
|
||||
3. **代码质量**
|
||||
- 统一的错误处理机制
|
||||
- 完善的类型声明
|
||||
- 详细的文档注释
|
||||
|
||||
## 未来规划
|
||||
|
||||
1. **组件增强**
|
||||
- 添加更多常用预设
|
||||
- 优化性能表现
|
||||
- 增加更多定制选项
|
||||
|
||||
2. **文档完善**
|
||||
- 补充更多使用示例
|
||||
- 添加在线演示
|
||||
- 完善类型声明
|
||||
|
||||
3. **工具支持**
|
||||
- 提供配置生成器
|
||||
- 添加主题定制能力
|
||||
- 集成表单验证工具
|
||||
|
||||
## 贡献指南
|
||||
|
||||
1. **组件开发原则**
|
||||
- 保持简单性
|
||||
- 关注通用性
|
||||
- 预留扩展性
|
||||
|
||||
2. **代码规范**
|
||||
- 遵循项目 ESLint 配置
|
||||
- 编写单元测试
|
||||
- 提供完整文档
|
||||
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
54
src/components/x-naive-ui/x-address-select/index.vue
Normal file
54
src/components/x-naive-ui/x-address-select/index.vue
Normal file
@ -0,0 +1,54 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import levelTwo from "./data/pc-code.json";
|
||||
import levelThree from "./data/pca-code.json";
|
||||
import levelFour from "./data/pcas-code.json";
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: String,
|
||||
default: undefined
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: undefined
|
||||
},
|
||||
level: {
|
||||
type: Number,
|
||||
default: 3
|
||||
}
|
||||
});
|
||||
const cascaderRef = ref(null);
|
||||
|
||||
const emit = defineEmits(['update:value']);
|
||||
const levelMap = {
|
||||
2: levelTwo,
|
||||
3: levelThree,
|
||||
4: levelFour
|
||||
};
|
||||
|
||||
const options = computed(() => levelMap[props.level] || []);
|
||||
|
||||
const updateValue = (value, option) => {
|
||||
emit("update:value", value);
|
||||
};
|
||||
defineExpose({
|
||||
cascaderRef
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-cascader
|
||||
ref="cascaderRef"
|
||||
:value="value"
|
||||
placeholder="请选择"
|
||||
:options="options"
|
||||
showPath
|
||||
check-strategy="child"
|
||||
value-field="code"
|
||||
label-field="name"
|
||||
filterable
|
||||
@update:value="updateValue"
|
||||
v-bind="{...$attrs}"
|
||||
/>
|
||||
</template>
|
251
src/components/x-naive-ui/x-n-data-table/README.md
Normal file
251
src/components/x-naive-ui/x-n-data-table/README.md
Normal file
@ -0,0 +1,251 @@
|
||||
# @x-n-data-table
|
||||
|
||||
基于 Naive UI 的 n-data-table 组件封装,增加了拖拽排序功能和灵活的插槽支持。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 支持所有 n-data-table 的原有功能
|
||||
- 支持列拖拽排序
|
||||
- 支持拖拽手柄模式
|
||||
- ✨ 支持每列的自定义插槽
|
||||
- ✨ 支持列标题的自定义插槽
|
||||
- 支持自定义拖拽列渲染
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
# 项目中已经包含此组件,无需额外安装
|
||||
```
|
||||
|
||||
## 插槽功能
|
||||
|
||||
> 💡 这是对原生 n-data-table 的重要增强:支持为每一列配置具名插槽
|
||||
|
||||
### 列内容插槽
|
||||
|
||||
使用列的 `key` 作为插槽名称:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<x-n-data-table :columns="columns" :data="data">
|
||||
<!-- 使用 name 列的插槽 -->
|
||||
<template #name="{ row, index }">
|
||||
<n-tag>{{ row.name }}</n-tag>
|
||||
</template>
|
||||
|
||||
<!-- 使用 status 列的插槽 -->
|
||||
<template #status="{ row }">
|
||||
<n-badge :status="row.status" />
|
||||
</template>
|
||||
</x-n-data-table>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 列标题插槽
|
||||
|
||||
使用 `{key}_title` 作为插槽名称:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<x-n-data-table :columns="columns" :data="data">
|
||||
<!-- 自定义 name 列的标题 -->
|
||||
<template #name_title>
|
||||
<n-space>
|
||||
<n-icon><user /></n-icon>
|
||||
<span>用户名</span>
|
||||
</n-space>
|
||||
</template>
|
||||
</x-n-data-table>
|
||||
</template>
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<x-n-data-table
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
>
|
||||
<!-- 自定义拖拽列的内容 -->
|
||||
<template #sort="{ row, index }">
|
||||
<n-space>
|
||||
<n-icon>⋮⋮</n-icon>
|
||||
<span>{{ index + 1 }}</span>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<!-- 自定义名称列的标题 -->
|
||||
<template #name_title>
|
||||
<n-space>
|
||||
<n-icon><list /></n-icon>
|
||||
<span>项目名称</span>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<!-- 自定义名称列的内容 -->
|
||||
<template #name="{ row }">
|
||||
<n-ellipsis>
|
||||
{{ row.name }}
|
||||
</n-ellipsis>
|
||||
</template>
|
||||
</x-n-data-table>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const data = ref([
|
||||
{ id: 1, name: '项目1' },
|
||||
{ id: 2, name: '项目2' },
|
||||
{ id: 3, name: '项目3' }
|
||||
])
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'sort',
|
||||
title: '排序',
|
||||
type: 'drag',
|
||||
handle: true,
|
||||
onDragEnd: ({ oldIndex, newIndex }) => {
|
||||
const newData = [...data.value]
|
||||
const [removed] = newData.splice(oldIndex, 1)
|
||||
newData.splice(newIndex, 0, removed)
|
||||
data.value = newData
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
title: '名称'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### Props
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| columns | `Array<Column \| DragColumn>` | `[]` | 列配置,支持拖拽列 |
|
||||
| data | `Array<object>` | `[]` | 数据源 |
|
||||
| align | `string` | `'center'` | 对齐方式 |
|
||||
|
||||
其他属性与 n-data-table 保持一致。
|
||||
|
||||
### Slots
|
||||
|
||||
| 插槽名 | 参数 | 说明 |
|
||||
|--------|------|------|
|
||||
| `{key}` | `{ row, index }` | 列内容的自定义渲染,key 为列的 key |
|
||||
| `{key}_title` | - | 列标题的自定义渲染,key 为列的 key |
|
||||
|
||||
### DragColumn 配置
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| type | `'drag'` | - | 指定为拖拽列 |
|
||||
| handle | `boolean` | `false` | 是否只能通过手柄拖拽 |
|
||||
| onDragEnd | `(event: DragSortEvent) => void` | - | 拖拽结束回调 |
|
||||
|
||||
### DragSortEvent
|
||||
|
||||
| 属性 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| oldIndex | `number` | 拖拽前的索引 |
|
||||
| newIndex | `number` | 拖拽后的索引 |
|
||||
|
||||
### 方法
|
||||
|
||||
组件暴露了以下方法:
|
||||
|
||||
| 方法名 | 参数 | 说明 |
|
||||
|--------|------|------|
|
||||
| clearFilters | - | 清除过滤条件 |
|
||||
| clearSorter | - | 清除排序条件 |
|
||||
| filter | `(filters: any)` | 设置过滤条件 |
|
||||
| page | `(page: number)` | 跳转到指定页 |
|
||||
| sort | `(columnKey: string, order: 'ascend' \| 'descend' \| false)` | 设置排序 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 拖拽列的 `type` 必须设置为 `'drag'`
|
||||
2. 拖拽功能需要配置 `onDragEnd` 回调来更新数据
|
||||
3. 建议将拖拽列放在表格的第一列
|
||||
4. 如果需要禁用整行拖拽,请设置 `handle: true`
|
||||
5. 插槽名称必须与列的 `key` 对应
|
||||
6. 标题插槽需要加上 `_title` 后缀
|
||||
|
||||
## 示例
|
||||
|
||||
### 完整示例
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<x-n-data-table
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
>
|
||||
<!-- 拖拽列自定义渲染 -->
|
||||
<template #sort="{ index }">
|
||||
<n-space>
|
||||
<n-icon>⋮⋮</n-icon>
|
||||
<span>{{ index + 1 }}</span>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<!-- 名称列标题自定义渲染 -->
|
||||
<template #name_title>
|
||||
<n-space>
|
||||
<n-icon><list /></n-icon>
|
||||
<span>项目名称</span>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<!-- 名称列内容自定义渲染 -->
|
||||
<template #name="{ row }">
|
||||
<n-ellipsis>
|
||||
{{ row.name }}
|
||||
</n-ellipsis>
|
||||
</template>
|
||||
|
||||
<!-- 状态列自定义渲染 -->
|
||||
<template #status="{ row }">
|
||||
<n-tag :type="row.status">
|
||||
{{ row.statusText }}
|
||||
</n-tag>
|
||||
</template>
|
||||
</x-n-data-table>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const data = ref([
|
||||
{ id: 1, name: '项目1', status: 'success', statusText: '正常' },
|
||||
{ id: 2, name: '项目2', status: 'warning', statusText: '警告' }
|
||||
])
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'sort',
|
||||
title: '排序',
|
||||
type: 'drag',
|
||||
handle: true,
|
||||
onDragEnd: ({ oldIndex, newIndex }) => {
|
||||
const newData = [...data.value]
|
||||
const [removed] = newData.splice(oldIndex, 1)
|
||||
newData.splice(newIndex, 0, removed)
|
||||
data.value = newData
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
title: '名称'
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: '状态'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
```
|
214
src/components/x-naive-ui/x-n-data-table/index.vue
Normal file
214
src/components/x-naive-ui/x-n-data-table/index.vue
Normal file
@ -0,0 +1,214 @@
|
||||
<script setup>
|
||||
import {
|
||||
h,
|
||||
useSlots,
|
||||
computed,
|
||||
shallowRef,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
nextTick,
|
||||
markRaw,
|
||||
watch,
|
||||
} from "vue";
|
||||
import Sortable from "sortablejs";
|
||||
import { debounce } from "lodash-es";
|
||||
|
||||
// Props 定义
|
||||
const props = defineProps({
|
||||
columns: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
required: true,
|
||||
},
|
||||
align: {
|
||||
type: String,
|
||||
default: "center",
|
||||
validator: (value) => ["left", "center", "right"].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 拖拽功能相关逻辑
|
||||
*/
|
||||
const useDraggable = (props, emit) => {
|
||||
const dragConfig = {
|
||||
handleId: "drag-handle",
|
||||
handleStyle: { cursor: "move", padding: "0 4px" },
|
||||
};
|
||||
|
||||
const dragColumn = computed(() =>
|
||||
props.columns?.find((col) => col.type === "drag")
|
||||
);
|
||||
|
||||
const sortable = shallowRef();
|
||||
const nDataTableRef = shallowRef();
|
||||
|
||||
const initSortable = () => {
|
||||
if (!dragColumn.value) return;
|
||||
|
||||
const tbody = nDataTableRef.value?.$el?.querySelector?.(
|
||||
".n-data-table-tbody"
|
||||
);
|
||||
if (!tbody) return;
|
||||
|
||||
sortable.value = markRaw(
|
||||
new Sortable(tbody, {
|
||||
animation: 150,
|
||||
handle: dragColumn.value.handle ? `.${dragConfig.handleId}` : undefined,
|
||||
onEnd: ({ oldIndex, newIndex }) => {
|
||||
if (oldIndex === newIndex) return;
|
||||
|
||||
const newData = [...props.data];
|
||||
const [removed] = newData.splice(oldIndex, 1);
|
||||
newData.splice(newIndex, 0, removed);
|
||||
|
||||
emit("update:data", newData);
|
||||
dragColumn.value?.onDragEnd?.({
|
||||
oldIndex,
|
||||
newIndex,
|
||||
data: newData,
|
||||
row: removed,
|
||||
});
|
||||
},
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const debouncedInitSortable = debounce(initSortable, 200);
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(initSortable);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
sortable.value?.destroy();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
() => nextTick(debouncedInitSortable)
|
||||
);
|
||||
|
||||
return {
|
||||
dragConfig,
|
||||
dragColumn,
|
||||
nDataTableRef,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 列渲染相关逻辑
|
||||
*/
|
||||
const useColumns = (props, slots, dragConfig) => {
|
||||
// 创建标题
|
||||
const createTitle = (column, slotKey) => {
|
||||
const titleSlotKey = `${slotKey}_title`;
|
||||
|
||||
if (column.titleRender) {
|
||||
return column.titleRender;
|
||||
}
|
||||
|
||||
if (slots[titleSlotKey]) {
|
||||
return () => slots[titleSlotKey]({ column });
|
||||
}
|
||||
|
||||
return column.title;
|
||||
};
|
||||
|
||||
// 创建展开渲染器
|
||||
const createExpandRenderer = () => {
|
||||
if (!slots["templateExpand"]) return null;
|
||||
return (row, index) => h(slots["templateExpand"], { row, index });
|
||||
};
|
||||
|
||||
// 创建拖拽列渲染器
|
||||
const createDragColumnRenderer = (column, slotKey) => {
|
||||
return (row, index) =>
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
class: [dragConfig.handleId, "drag-handle-wrapper"],
|
||||
style: dragConfig.handleStyle,
|
||||
onClick: column.handle ? (e) => e.stopPropagation() : undefined,
|
||||
},
|
||||
slots[slotKey] ? slots[slotKey]({ row, index, column }) : "⋮⋮"
|
||||
);
|
||||
};
|
||||
|
||||
// 创建普通列渲染器
|
||||
const createDefaultColumnRenderer = (column, slotKey) => {
|
||||
if (slots[slotKey]) {
|
||||
return (row, index) => slots[slotKey]({ row, index, column });
|
||||
}
|
||||
if (column.render) {
|
||||
return column.render;
|
||||
}
|
||||
return (row) => row[column.key];
|
||||
};
|
||||
|
||||
// 创建列配置
|
||||
const createColumnRender = (column) => {
|
||||
const slotKey = column.key;
|
||||
|
||||
const baseColumn = {
|
||||
...column,
|
||||
align: props.align,
|
||||
title: createTitle(column, slotKey),
|
||||
renderExpand: createExpandRenderer(),
|
||||
};
|
||||
|
||||
if (column.type === "drag") {
|
||||
return {
|
||||
...baseColumn,
|
||||
render: createDragColumnRenderer(column, slotKey),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...baseColumn,
|
||||
render: createDefaultColumnRenderer(column, slotKey),
|
||||
};
|
||||
};
|
||||
|
||||
return computed(() => props.columns?.map(createColumnRender) || []);
|
||||
};
|
||||
|
||||
// 事件定义
|
||||
const emit = defineEmits(["update:data"]);
|
||||
const slots = useSlots();
|
||||
|
||||
// 组合功能
|
||||
const { dragConfig, dragColumn, nDataTableRef } = useDraggable(props, emit);
|
||||
const computedColumns = useColumns(props, slots, dragConfig);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-data-table
|
||||
ref="nDataTableRef"
|
||||
:class="[dragColumn?.handle ? 'handle-only-drag' : 'full-row-drag']"
|
||||
remote
|
||||
v-bind="{ ...$attrs, ...$props, columns: computedColumns }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.drag-handle-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.handle-only-drag tbody tr {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.full-row-drag tbody tr {
|
||||
cursor: move;
|
||||
}
|
||||
</style>
|
35
src/components/x-naive-ui/x-n-modal/README.md
Normal file
35
src/components/x-naive-ui/x-n-modal/README.md
Normal file
@ -0,0 +1,35 @@
|
||||
# x-n-modal 模态框组件
|
||||
|
||||
基于 naive-ui 的 n-modal 组件封装,提供预设配置和便捷使用方式。
|
||||
|
||||
## 特点
|
||||
|
||||
- 预设模态框配置,默认只能通过关闭按钮关闭
|
||||
- 自动挂载到 app 根节点
|
||||
- 支持所有 n-modal 原生属性和事件
|
||||
|
||||
## 使用示例
|
||||
```
|
||||
<template>
|
||||
<x-n-modal v-model:show="showModal" title="标题" class="w-[1000px] h-[600px]">
|
||||
<!-- 内容插槽 -->
|
||||
<template #default>
|
||||
模态框内容
|
||||
</template>
|
||||
<!-- 操作按钮插槽 -->
|
||||
<template #action>
|
||||
<n-button>保存</n-button>
|
||||
<n-button>取消</n-button>
|
||||
</template>
|
||||
</x-n-modal>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
const showModal = ref(false)
|
||||
</script>
|
||||
```
|
||||
## 注意事项
|
||||
|
||||
- 自动挂载到 id="app" 的根节点
|
||||
- 默认禁用 ESC 和点击遮罩关闭
|
||||
- 支持通过属性覆盖默认配置
|
39
src/components/x-naive-ui/x-n-modal/index.vue
Normal file
39
src/components/x-naive-ui/x-n-modal/index.vue
Normal file
@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
const app = document.querySelector("#app") as HTMLElement | null;
|
||||
|
||||
interface Emits {
|
||||
(event: 'update:show', value: boolean): void;
|
||||
}
|
||||
const emit = defineEmits<Emits>();
|
||||
const toggleModal = (): void => {
|
||||
emit('update:show', false);
|
||||
};
|
||||
if (!app) {
|
||||
console.warn('找不到app挂载节点,模态框可能无法按预期工作');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
preset="card"
|
||||
:autoFocus="false"
|
||||
:closeOnEsc="false"
|
||||
:mask-closable="false"
|
||||
:bordered="false"
|
||||
:to="app"
|
||||
@update:show="toggleModal"
|
||||
header-style="padding-top:15px;padding-bottom:15px;text-align:center"
|
||||
v-bind="{ ...$attrs, ...$props }"
|
||||
>
|
||||
<template
|
||||
v-for="(slot, name) in $slots"
|
||||
:key="name"
|
||||
#[name]
|
||||
>
|
||||
<slot :name="name"></slot>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
</style>
|
1688
src/components/x-naive-ui/x-n-upload/index.vue
Normal file
1688
src/components/x-naive-ui/x-n-upload/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
144
src/components/x-naive-ui/x-preview-img/index-1.ts
Normal file
144
src/components/x-naive-ui/x-preview-img/index-1.ts
Normal file
@ -0,0 +1,144 @@
|
||||
import { createApp, h, ref } from 'vue'
|
||||
import { NImage, NImageGroup } from 'naive-ui'
|
||||
|
||||
interface PreviewOptions {
|
||||
onStart?: () => void
|
||||
onError?: (e: Event) => void
|
||||
showToolbar?: boolean
|
||||
}
|
||||
|
||||
class ImagePreview {
|
||||
private static instance: {
|
||||
app: any
|
||||
container: HTMLElement
|
||||
} | null = null
|
||||
|
||||
static async preview(
|
||||
sources: string | File | (string | File)[],
|
||||
index = 0,
|
||||
options: PreviewOptions = {},
|
||||
) {
|
||||
try {
|
||||
const urls = await this.normalizeImageSources(
|
||||
Array.isArray(sources) ? sources : [sources]
|
||||
)
|
||||
|
||||
if (!urls.length) {
|
||||
console.warn('[ImagePreview] No valid image sources')
|
||||
return
|
||||
}
|
||||
|
||||
this.destroy()
|
||||
options.onStart?.()
|
||||
|
||||
const container = document.createElement('div')
|
||||
container.style.display = 'none'
|
||||
document.body.appendChild(container)
|
||||
|
||||
const app = createApp({
|
||||
setup() {
|
||||
const imageRef = ref<InstanceType<typeof NImage> | null>(null)
|
||||
|
||||
return () => {
|
||||
if (urls.length === 1) {
|
||||
return h(NImage, {
|
||||
ref: imageRef,
|
||||
src: urls[0],
|
||||
previewDisabled: false,
|
||||
preview: true,
|
||||
showToolbar: options.showToolbar ?? true,
|
||||
style: {
|
||||
display: 'none'
|
||||
},
|
||||
onLoad: () => {
|
||||
imageRef.value?.click()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
return h(NImageGroup, {
|
||||
showToolbar: options.showToolbar ?? true,
|
||||
currentIndex: index
|
||||
}, {
|
||||
default: () => urls.map((url, i) => {
|
||||
const imgRef = ref<InstanceType<typeof NImage> | null>(null)
|
||||
return h(NImage, {
|
||||
ref: i === index ? imgRef : undefined,
|
||||
src: url,
|
||||
previewDisabled: false,
|
||||
preview: true,
|
||||
style: {
|
||||
display: 'none'
|
||||
},
|
||||
onLoad: i === index ? () => {
|
||||
imgRef.value?.click()
|
||||
} : undefined
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
app.mount(container)
|
||||
this.instance = { app, container }
|
||||
|
||||
} catch (error) {
|
||||
console.error('[ImagePreview] Error:', error)
|
||||
options.onError?.(error as Event)
|
||||
}
|
||||
}
|
||||
|
||||
private static async normalizeImageSources(sources: (string | File)[]): Promise<string[]> {
|
||||
const urls: string[] = []
|
||||
|
||||
for (const source of sources) {
|
||||
try {
|
||||
if (typeof source === 'string') {
|
||||
if (source.startsWith('data:') || source.startsWith('http')) {
|
||||
urls.push(source)
|
||||
} else {
|
||||
console.warn('[ImagePreview] Invalid image source:', source)
|
||||
}
|
||||
} else if (source instanceof File) {
|
||||
const url = await this.fileToUrl(source)
|
||||
urls.push(url)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[ImagePreview] Failed to process source:', source, error)
|
||||
}
|
||||
}
|
||||
|
||||
return urls
|
||||
}
|
||||
|
||||
private static fileToUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
reject(new Error('Not an image file'))
|
||||
return
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
private static destroy() {
|
||||
if (this.instance) {
|
||||
const { app, container } = this.instance
|
||||
app.unmount()
|
||||
container.remove()
|
||||
this.instance = null
|
||||
}
|
||||
}
|
||||
|
||||
static close() {
|
||||
this.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
export const previewImage = ImagePreview.preview.bind(ImagePreview)
|
||||
export const closePreview = ImagePreview.close.bind(ImagePreview)
|
54
src/components/x-naive-ui/x-preview-img/index.ts
Normal file
54
src/components/x-naive-ui/x-preview-img/index.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import Viewer from 'viewerjs';
|
||||
import 'viewerjs/dist/viewer.css';
|
||||
|
||||
interface PreviewOptions {
|
||||
toolbar?: boolean;
|
||||
navbar?: boolean;
|
||||
transition?: boolean;
|
||||
transitionDuration?: number;
|
||||
// 其他选项
|
||||
}
|
||||
|
||||
export function previewImage(
|
||||
sources: string | File | (string | File)[],
|
||||
index = 0,
|
||||
options: PreviewOptions = {}
|
||||
) {
|
||||
const container = document.createElement('div');
|
||||
container.style.display = 'none';
|
||||
document.body.appendChild(container);
|
||||
|
||||
const processSource = async (source: string | File) => {
|
||||
const img = document.createElement('img');
|
||||
img.src = typeof source === 'string'
|
||||
? source
|
||||
: await readFileAsDataURL(source);
|
||||
container.appendChild(img);
|
||||
};
|
||||
|
||||
const readFileAsDataURL = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => resolve(e.target?.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
Promise.all(
|
||||
(Array.isArray(sources) ? sources : [sources])
|
||||
.map(source => processSource(source))
|
||||
).then(() => {
|
||||
const viewer = new Viewer(container, {
|
||||
zoomRatio: 0.8,
|
||||
transition: false,
|
||||
transitionDuration: 100, // 默认是300,设置更小的值可以加快动画速度
|
||||
...options,
|
||||
hidden() {
|
||||
viewer.destroy();
|
||||
document.body.removeChild(container);
|
||||
},
|
||||
});
|
||||
viewer.view(index);
|
||||
});
|
||||
}
|
272
src/components/x-naive-ui/x-search-form/index.vue
Normal file
272
src/components/x-naive-ui/x-search-form/index.vue
Normal file
@ -0,0 +1,272 @@
|
||||
<script setup>
|
||||
import { cloneDeep, debounce } from "lodash-es";
|
||||
import { computed, onBeforeUnmount, ref } from "vue";
|
||||
import { NButton, NForm, NFormItemGi, NGrid, NInput, NSelect, NDatePicker } from 'naive-ui'
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
cols: {
|
||||
type: [Number, String],
|
||||
default: 4,
|
||||
},
|
||||
searchConfig: {
|
||||
type: Array,
|
||||
default: () => ([]),
|
||||
},
|
||||
xGap: {
|
||||
type: [Number, String],
|
||||
default: 81,
|
||||
},
|
||||
yGap: {
|
||||
type: [Number, String],
|
||||
default: 20,
|
||||
},
|
||||
autoSearch: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
debounceWait: {
|
||||
type: Number,
|
||||
default: 300,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['change', 'init'])
|
||||
const resetForm = ref({})
|
||||
const formData = ref({})
|
||||
const indexRef = ref(0)
|
||||
const datePickerRef = ref(null)
|
||||
const formRef = ref(null)
|
||||
|
||||
// 防抖搜索函数
|
||||
const debouncedSearch = debounce(() => {
|
||||
emit('change', formData.value)
|
||||
}, props.debounceWait)
|
||||
|
||||
// 清理防抖函数
|
||||
onBeforeUnmount(() => {
|
||||
debouncedSearch.cancel()
|
||||
})
|
||||
|
||||
// 优化计算属性
|
||||
const calculateEmptySpans = computed(() => {
|
||||
const num = props.searchConfig?.reduce((acc, cur) =>
|
||||
acc + (cur.col || 1), 0
|
||||
)
|
||||
const remainder = num % props.cols
|
||||
return remainder === 0 ? props.cols - 1 : props.cols - remainder - 1
|
||||
})
|
||||
|
||||
// 优化表单值变更处理
|
||||
const manualChange = () => {
|
||||
if (props.autoSearch) {
|
||||
debouncedSearch()
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputChange = (value, item) => {
|
||||
// 如果是pair类型的输入
|
||||
if (item.props?.pair && Array.isArray(item.key)) {
|
||||
// 确保value是数组,如果不是则转换为数组
|
||||
const valueArray = Array.isArray(value) ? value : [value, value];
|
||||
|
||||
// 获取默认值来判断数据类型
|
||||
const defaultValues = Array.isArray(item.default) ? item.default : [];
|
||||
|
||||
// 分别赋值给对应的key,并根据default的类型进行转换
|
||||
item.key.forEach((key, index) => {
|
||||
const defaultValue = defaultValues[index];
|
||||
const currentValue = valueArray[index] || '';
|
||||
|
||||
// 如果默认值是数字类型,则转换为数字
|
||||
if (typeof defaultValue === 'number') {
|
||||
formData.value[key] = currentValue === '' ? defaultValue : Number(currentValue);
|
||||
} else {
|
||||
formData.value[key] = currentValue;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 处理普通输入
|
||||
formData.value[item.key] = value;
|
||||
}
|
||||
manualChange();
|
||||
};
|
||||
const handleSelectChange = (value, item) => {
|
||||
formData.value[item.key] = value
|
||||
console.log('formData.value[item.key]',formData.value[item.key]);
|
||||
manualChange()
|
||||
}
|
||||
const handleDateChange = (value, key) => {
|
||||
if (Array.isArray(key)) {
|
||||
key.forEach((k, i) => {
|
||||
formData.value[k] = value?.[i]
|
||||
})
|
||||
} else {
|
||||
formData.value[key] = value
|
||||
}
|
||||
manualChange()
|
||||
}
|
||||
|
||||
const search = async () => {
|
||||
if (!formRef.value) {
|
||||
emit('change', formData.value)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
emit('change', formData.value)
|
||||
} catch (errors) {
|
||||
console.error('表单验证失败:', errors)
|
||||
}
|
||||
}
|
||||
|
||||
// 优化重置函数
|
||||
const reset = () => {
|
||||
formData.value = cloneDeep(resetForm.value)
|
||||
emit('change', formData.value)
|
||||
indexRef.value++
|
||||
if (formRef.value) {
|
||||
formRef.value?.restoreValidation()
|
||||
}
|
||||
}
|
||||
|
||||
// 优化表单对象生成
|
||||
const generateFormObject = (config) => {
|
||||
return config.reduce((formObject, field) => {
|
||||
const defaultValue = field.default ?? undefined;
|
||||
|
||||
if (Array.isArray(field.key)) {
|
||||
if (Array.isArray(defaultValue)) {
|
||||
field.key.forEach((subKey, index) => {
|
||||
// 使用原始默认值,保持类型一致
|
||||
formObject[subKey] = defaultValue[index];
|
||||
});
|
||||
} else {
|
||||
field.key.forEach(subKey => {
|
||||
formObject[subKey] = defaultValue;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
formObject[field.key] = defaultValue;
|
||||
}
|
||||
|
||||
return formObject;
|
||||
}, {});
|
||||
};
|
||||
|
||||
// 初始化函数
|
||||
const initData = () => {
|
||||
const initialData = generateFormObject(props.searchConfig)
|
||||
formData.value = initialData
|
||||
resetForm.value = cloneDeep(initialData)
|
||||
emit('init', initialData)
|
||||
}
|
||||
|
||||
// 初始化
|
||||
initData()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="search-form w-[100%] pb-[20px] bg-[#fff] rounded-[3px] overflow-hidden">
|
||||
<div
|
||||
v-if="title"
|
||||
class="search-form__header h-[59px] border-b-[2px] border-[#EAEAEA]"
|
||||
>
|
||||
<div class="text-[#1F2225] text-[18px] font-[600] h-[100%] flex justify-center align-center w-fit border-b-[4px] border-[#46299D]">
|
||||
{{ title }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-form
|
||||
ref="formRef"
|
||||
class="mt-[20px]"
|
||||
:show-feedback="false"
|
||||
label-placement="left"
|
||||
label-align="left"
|
||||
label-width="110"
|
||||
:key="indexRef"
|
||||
>
|
||||
<n-grid :cols="cols" :x-gap="xGap" :y-gap="yGap">
|
||||
<n-form-item-gi
|
||||
v-for="item in searchConfig"
|
||||
:key="item.key"
|
||||
:span="item.col || 1"
|
||||
:label="item.label"
|
||||
:rule="item.rule"
|
||||
>
|
||||
<!-- Input 类型 -->
|
||||
<template v-if="item.type === 'input'">
|
||||
<n-input
|
||||
:value="formData[item.key]"
|
||||
placeholder="请输入"
|
||||
@input="value => handleInputChange(value, item)"
|
||||
clearable
|
||||
v-bind="item.props"
|
||||
/>
|
||||
</template>
|
||||
<!-- Select 类型 -->
|
||||
<template v-else-if="item.type === 'select'">
|
||||
<n-select
|
||||
:value="formData[item.key]"
|
||||
placeholder="请选择"
|
||||
@update:value="value => handleSelectChange(value, item)"
|
||||
clearable
|
||||
v-bind="item.props"
|
||||
/>
|
||||
</template>
|
||||
<!-- DatePicker 类型 -->
|
||||
<template v-else-if="item.type === 'date-picker'">
|
||||
<n-date-picker
|
||||
ref="datePickerRef"
|
||||
class="w-[100%]"
|
||||
clearable
|
||||
:value="formData[item.key]"
|
||||
@update-formatted-value="value => handleDateChange(value, item.key)"
|
||||
v-bind="item.props"
|
||||
/>
|
||||
</template>
|
||||
</n-form-item-gi>
|
||||
|
||||
<!-- 空白占位 -->
|
||||
<n-form-item-gi :span="calculateEmptySpans" />
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<n-form-item-gi :span="1">
|
||||
<div class="flex justify-end w-full">
|
||||
<n-button
|
||||
class="w-[145px] h-[34px] mr-[20px]"
|
||||
@click="search"
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
>
|
||||
查询
|
||||
</n-button>
|
||||
<n-button
|
||||
class="w-[145px] h-[34px]"
|
||||
color="#EEE9F8"
|
||||
text-color="#46299D"
|
||||
@click="reset"
|
||||
>
|
||||
重置
|
||||
</n-button>
|
||||
</div>
|
||||
</n-form-item-gi>
|
||||
</n-grid>
|
||||
</n-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.search-form {
|
||||
&__header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -11,12 +11,13 @@ export const ChatMsgTypeLogin = 10 // 登录消息
|
||||
export const ChatMsgTypeVote = 11 // 投票消息
|
||||
export const ChatMsgTypeMixed = 12 // 混合消息
|
||||
export const ChatMsgTypeGroupNotice = 13 // 群公告消息
|
||||
export const ChatMsgTypeLink = 14 // 链接消息
|
||||
|
||||
export const ChatMsgSysText = 1000 // 系统文本消息
|
||||
export const ChatMsgSysGroupCreate = 1101 // 创建群聊消息
|
||||
export const ChatMsgSysGroupMemberJoin = 1102 // 加入群聊消息
|
||||
export const ChatMsgSysGroupMemberQuit = 1103 // 群成员退出群消息
|
||||
export const ChatMsgSysGroupMemberKicked = 1104 // 踢出群成员消息
|
||||
export const ChatMsgSysGroupMemberKicked = 1104 // 移出群成员消息(普通群、项目群被踢)
|
||||
export const ChatMsgSysGroupMessageRevoke = 1105 // 管理员撤回成员消息
|
||||
export const ChatMsgSysGroupDismissed = 1106 // 群解散
|
||||
export const ChatMsgSysGroupMuted = 1107 // 群禁言
|
||||
@ -25,6 +26,9 @@ export const ChatMsgSysGroupMemberMuted = 1109 // 群成员禁言
|
||||
export const ChatMsgSysGroupMemberCancelMuted = 1110 // 群成员解除禁言
|
||||
export const ChatMsgSysGroupNotice = 1111 // 编辑群公告
|
||||
export const ChatMsgSysGroupTransfer = 1113 // 变更群主
|
||||
export const ChatMsgSysGroupAdmin = 1114 // 设置管理员
|
||||
export const ChatMsgSysGroupMemberRemoved = 1115 // 移出群成员消息(部门群、公司群自动移出)
|
||||
export const ChatMsgSysGroupInfoChange = 1116 // 管理员更新了群信息
|
||||
|
||||
export const ChatMsgTypeMapping = {
|
||||
[ChatMsgTypeText]: '[文本消息]',
|
||||
@ -44,14 +48,18 @@ export const ChatMsgTypeMapping = {
|
||||
[ChatMsgSysGroupCreate]: '[创建群消息]',
|
||||
[ChatMsgSysGroupMemberJoin]: '[加入群消息]',
|
||||
[ChatMsgSysGroupMemberQuit]: '[退出群消息]',
|
||||
[ChatMsgSysGroupMemberKicked]: '[踢出群消息]',
|
||||
[ChatMsgSysGroupMemberKicked]: '[移出群消息]',
|
||||
[ChatMsgSysGroupMessageRevoke]: '[撤回消息]',
|
||||
[ChatMsgSysGroupDismissed]: '[群解散消息]',
|
||||
[ChatMsgSysGroupMuted]: '[群禁言消息]',
|
||||
[ChatMsgSysGroupCancelMuted]: '[群解除禁言消息]',
|
||||
[ChatMsgSysGroupMemberMuted]: '[群成员禁言消息]',
|
||||
[ChatMsgSysGroupMemberCancelMuted]: '[群成员解除禁言消息]',
|
||||
[ChatMsgSysGroupNotice]: '[群公告]'
|
||||
[ChatMsgSysGroupNotice]: '[群公告]',
|
||||
[ChatMsgSysGroupTransfer]: '[转让群主]',
|
||||
[ChatMsgSysGroupAdmin]: '[设置管理员]',
|
||||
[ChatMsgSysGroupMemberRemoved]: '[移出群成员消息]',
|
||||
[ChatMsgSysGroupInfoChange]: '[群信息更新]'
|
||||
}
|
||||
|
||||
// 消息类型 - 消息组件 映射关系
|
||||
@ -75,12 +83,15 @@ export const MessageComponents = {
|
||||
[ChatMsgSysGroupMemberQuit]: 'sys-group-member-quit-message',
|
||||
[ChatMsgSysGroupMemberKicked]: 'sys-group-member-kicked-message',
|
||||
// [ChatMsgSysGroupMessageRevoke]: '[撤回消息]',
|
||||
// [ChatMsgSysGroupDismissed]: '[群解散消息]',
|
||||
[ChatMsgSysGroupDismissed]: 'sys-group-dismissed',
|
||||
[ChatMsgSysGroupMuted]: 'sys-group-muted-message',
|
||||
[ChatMsgSysGroupCancelMuted]: 'sys-group-cancel-muted-message',
|
||||
[ChatMsgSysGroupMemberMuted]: 'sys-group-member-muted-message',
|
||||
[ChatMsgSysGroupMemberCancelMuted]: 'sys-group-member-cancel-muted-message',
|
||||
[ChatMsgSysGroupTransfer]: 'sys-group-transfer-message'
|
||||
[ChatMsgSysGroupTransfer]: 'sys-group-transfer-message',
|
||||
[ChatMsgSysGroupAdmin]: 'sys-group-admin-message',
|
||||
[ChatMsgSysGroupMemberRemoved]: 'sys-group-member-removed-message',
|
||||
[ChatMsgSysGroupInfoChange]: 'sys-group-info-change-message'
|
||||
}
|
||||
|
||||
// 可转发的消息类型
|
||||
@ -92,5 +103,6 @@ export const ForwardableMessageType = [
|
||||
ChatMsgTypeVideo,
|
||||
ChatMsgTypeFile,
|
||||
ChatMsgTypeLocation,
|
||||
ChatMsgTypeCard
|
||||
ChatMsgTypeCard,
|
||||
ChatMsgTypeLink
|
||||
]
|
||||
|
@ -1,10 +1,10 @@
|
||||
// 主题配置
|
||||
export const overrides = {
|
||||
common: {
|
||||
primaryColor: '#1890ff',
|
||||
primaryColorHover: '#1890ff',
|
||||
primaryColorPressed: '#1890ff',
|
||||
primaryColorSuppl: '#1890ff',
|
||||
primaryColor: '#46299D',
|
||||
primaryColorHover: '#46299D',
|
||||
primaryColorPressed: '#46299D',
|
||||
primaryColorSuppl: '#46299D',
|
||||
bodyColor: '#ffffff'
|
||||
},
|
||||
|
||||
|
@ -1,16 +1,20 @@
|
||||
import '@/assets/css/define/theme.less'
|
||||
import '@/assets/css/define/global.less'
|
||||
import '@/assets/css/dropsize.less'
|
||||
import 'uno.css'
|
||||
import { createApp } from 'vue'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
import * as plugins from './plugins'
|
||||
import request from "@/api/index.js";
|
||||
|
||||
async function bootstrap() {
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(router)
|
||||
|
||||
app.config.globalProperties.$request = request;
|
||||
|
||||
plugins.setPinia(app)
|
||||
plugins.setHljsVuePlugin(app)
|
||||
plugins.setupNaive(app)
|
||||
|
@ -18,7 +18,7 @@ export function isLoggedIn() {
|
||||
*/
|
||||
export function getAccessToken() {
|
||||
// return storage.get(AccessToken) || ''
|
||||
return JSON.parse(localStorage.getItem('token'))||'79b5c732d96d2b27a48a99dfd4a5566c43aaa5796242e854ebe3ffc198d6876b9628e7b764d9af65ab5dbb2d517ced88170491b74b048c0ba827c0d3741462cb89dc59ed46653a449af837a8262941caaef1334d640773710f8cd96473bacfb190cba595a5d6a9c87d70f0999a3ebb41147213b31b4bdccffca66a56acf3baab5af0154f0dce360079f37709f78e13711036899344bddb0fb4cf0f2890287cb62c3fcbe33368caa5e213624577be8b8420ab75b1f50775ee16142a4321c5d56995f37354a66a969da98d95ba6e65d142ed097e04b411c1ebad2f62866d0ec7e1838420530a9941dbbcd00490199f8b892000b1d18303354ea8a7c57f91ffb617f5d82513d2af46e6ce5848a80c59c75b9ddf4a552092d70ecda72c97d99cb5d0f114a50ddfd9674f22576675e3390d2367951eb502aa1dd94e8823d528a503fb'
|
||||
return JSON.parse(localStorage.getItem('token'))||'79b5c732d96d2b27a48a99dfd4a5566c43aaa5796242e854ebe3ffc198d6876b9628e7b764d9af65ab5dbb2d517ced88170491b74b048c0ba827c0d3741462cb89dc59ed46653a449af837a8262941caaef1334d640773710f8cd96473bacfb190cba595a5d6a9c87d70f0999a3ebb41147213b31b4bdccffca66a56acf3baab5af0154f0dce360079f37709f78e13711036899344bddb0fb4cf0f2890287cb62c3fcbe33368caa5e213624577be8b8420ab75b1f50775ee16142a4321c5d56995f37354a66a969da98d95ba6e65d142ed097e04b411c1ebad2f62866d0ec7e1838420530a9941dbbcd00490199f8b891bd3a81a1ac4e73e2aed60deeaec60792c525cc0c96e8f4a666eca6ee7a10716507b402cde5759bbcda1fa681fbe4dcdfe05abbc2b1644c68dc74ebaf8d9c9cc4eb61afaf3de52fa357dbfdfe17acf14'
|
||||
}
|
||||
|
||||
/**
|
||||
|
143
src/utils/erpRequest.js
Normal file
143
src/utils/erpRequest.js
Normal file
@ -0,0 +1,143 @@
|
||||
import axios from "axios";
|
||||
import $router from "../router";
|
||||
import { message } from "ant-design-vue";
|
||||
import { Local } from "@/utils/erpStorage.js";
|
||||
const service = axios.create({
|
||||
baseURL: import.meta.env.VITE_EPR_BASEURL,
|
||||
timeout: 60 * 60 * 1000,
|
||||
authToken: true,
|
||||
responseType: "json", // 请求数据格式
|
||||
});
|
||||
service.interceptors.request.use(
|
||||
(config) => {
|
||||
// set header Content-Type
|
||||
if (config.method === "get") {
|
||||
// || config.method === "delete"
|
||||
config.headers["Content-Type"] = "application/x-www-form-urlencoded";
|
||||
} else {
|
||||
config.headers["Content-Type"] = "application/json";
|
||||
}
|
||||
if (config.isFormData) {
|
||||
config.headers["Content-Type"] = "multipart/form-data";
|
||||
config.headers.Authorization = Local.get("token") || "";
|
||||
} else config.headers.Authorization = Local.get("token") || "";
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
let isRefreshing = false;
|
||||
let refreshSubscribers = [];
|
||||
|
||||
service.interceptors.response.use(
|
||||
(response) => {
|
||||
let isResourse = response.config.responseType;
|
||||
if (response && response.data.status === 409) {
|
||||
// 账户另一处登录 此处踢下线
|
||||
message.error('您的账号已在其他设备登录')
|
||||
$router.push("/login");
|
||||
return;
|
||||
}
|
||||
if (response && response.data.code === 401) {
|
||||
// 若token过期则态获取RefreshToken重新获取token
|
||||
return getRefreshToken(response);
|
||||
}
|
||||
if (
|
||||
response.status === 200 ||
|
||||
response.status === 201 ||
|
||||
response.status === 204
|
||||
) {
|
||||
return isResourse == "blob" ? response : response.data;
|
||||
} else {
|
||||
message.success(res.msg);
|
||||
Promise.reject(response);
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
const getRefreshToken = async (response) => {
|
||||
if (!isRefreshing) {
|
||||
isRefreshing = true;
|
||||
const refreshToken = Local.get("RefreshToken");
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const data = { refreshToken };
|
||||
const res = await fetch(
|
||||
"/user/refresh/token",
|
||||
data,
|
||||
"POST",
|
||||
"json",
|
||||
"application/json",
|
||||
false
|
||||
);
|
||||
if (res.code === 200) {
|
||||
Local.set("token", res.data.Token);
|
||||
Local.set("userInfo", res.data.AccountInfo);
|
||||
Local.set("RefreshToken", res.data.RefreshToken);
|
||||
|
||||
return Promise.resolve(service(response.config));
|
||||
} else {
|
||||
// 跳转登录页
|
||||
$router.push("/login");
|
||||
res.message = res.message || res.msg;
|
||||
return Promise.reject(res);
|
||||
}
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
refreshSubscribers.forEach((callback) => callback());
|
||||
refreshSubscribers = [];
|
||||
}
|
||||
} else {
|
||||
$router.push("/login");
|
||||
return Promise.reject(response);
|
||||
}
|
||||
} else {
|
||||
return new Promise((resolve) => {
|
||||
refreshSubscribers.push(() => {
|
||||
resolve(service(response.config));
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const fetch = async (
|
||||
url = "",
|
||||
data = {},
|
||||
method = "",
|
||||
responseType = "json",
|
||||
contentType = "",
|
||||
authToken = true,
|
||||
isFormData = false
|
||||
) => {
|
||||
if (!method) {
|
||||
method = "GET";
|
||||
}
|
||||
let args = { url, method };
|
||||
if (method === "GET") {
|
||||
// || method === "DELETE"
|
||||
args.params = data;
|
||||
} else {
|
||||
args.data = data;
|
||||
}
|
||||
if (contentType) {
|
||||
args = Object.assign({}, args, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 请求数据格式
|
||||
args.responseType = responseType;
|
||||
args.authToken = authToken;
|
||||
args.isFormData = isFormData;
|
||||
return service(args);
|
||||
};
|
||||
export default { fetch, getRefreshToken };
|
91
src/utils/erpStorage.js
Normal file
91
src/utils/erpStorage.js
Normal file
@ -0,0 +1,91 @@
|
||||
function createStorage(storage) {
|
||||
const cache = new Map();
|
||||
const stringifyCache = new Map();
|
||||
const batchWrites = [];
|
||||
let flushTimer = null;
|
||||
|
||||
// 优化的 JSON 序列化
|
||||
function fastStringify(obj) {
|
||||
const type = typeof obj;
|
||||
if (obj === null) return 'null';
|
||||
if (type === 'string') return `"${obj}"`;
|
||||
if (type === 'number' || type === 'boolean') return String(obj);
|
||||
if (type !== 'object') return '{}';
|
||||
|
||||
const cached = stringifyCache.get(obj);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const result = JSON.stringify(obj);
|
||||
if (result.length < 1000) {
|
||||
stringifyCache.set(obj, result);
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
return '{}';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
set(key, val) {
|
||||
if (!key) return;
|
||||
|
||||
try {
|
||||
// 更新缓存
|
||||
cache.set(key, val);
|
||||
|
||||
// 序列化并立即写入
|
||||
const serialized = fastStringify(val);
|
||||
storage.setItem(key, serialized);
|
||||
|
||||
// 批量写入作为备份
|
||||
batchWrites.push([key, serialized]);
|
||||
if (!flushTimer) {
|
||||
flushTimer = setTimeout(() => {
|
||||
batchWrites.forEach(([k, v]) => {
|
||||
try { storage.setItem(k, v); } catch {}
|
||||
});
|
||||
batchWrites.length = 0;
|
||||
flushTimer = null;
|
||||
}, 100);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Storage error:', e);
|
||||
}
|
||||
},
|
||||
|
||||
get(key) {
|
||||
if (!key) return null;
|
||||
|
||||
// 优先读缓存
|
||||
const cached = cache.get(key);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
try {
|
||||
const val = JSON.parse(storage.getItem(key));
|
||||
cache.set(key, val);
|
||||
return val;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
remove(key) {
|
||||
cache.delete(key);
|
||||
storage.removeItem(key);
|
||||
},
|
||||
clear() {
|
||||
cache.clear();
|
||||
stringifyCache.clear();
|
||||
storage.clear();
|
||||
batchWrites.length = 0;
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
// 导出单例实例
|
||||
export const Local = createStorage(window.localStorage);
|
||||
export const Session = createStorage(window.sessionStorage);
|
347
src/utils/helper/form.js
Normal file
347
src/utils/helper/form.js
Normal file
@ -0,0 +1,347 @@
|
||||
export const getParamsByObj = (params, paramsConfig, obj) => {
|
||||
if (paramsConfig !== undefined && paramsConfig.length > 0 && obj !== null) {
|
||||
for (let i in paramsConfig) {
|
||||
let paramsItem = paramsConfig[i];
|
||||
if (paramsItem.value !== undefined) {
|
||||
params[paramsItem.label] = paramsItem.value;
|
||||
}
|
||||
if (obj && paramsItem.field !== undefined) {
|
||||
if (
|
||||
obj[paramsItem.field] !== undefined &&
|
||||
obj[paramsItem.field] !== null
|
||||
) {
|
||||
params[paramsItem.label] = obj[paramsItem.field];
|
||||
// 对值数组化
|
||||
params[paramsItem.label] =
|
||||
paramsItem.type === "Array"
|
||||
? [params[paramsItem.label]]
|
||||
: params[paramsItem.label];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return params;
|
||||
};
|
||||
|
||||
export const getLabelByOptions = (val, options, customEmpty) => {
|
||||
let label = "";
|
||||
let valOpt = {};
|
||||
for (let i in options) {
|
||||
if (options[i].value === val) {
|
||||
valOpt = options[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (valOpt.class || valOpt.style) {
|
||||
return `<div class="sf-status-label ${valOpt.class}" style="${
|
||||
valOpt.style
|
||||
}">${valOpt.label || customEmpty}</div>`;
|
||||
}
|
||||
return valOpt.label || customEmpty;
|
||||
};
|
||||
|
||||
export const getFormObj = (formConfig) => {
|
||||
let data = {};
|
||||
formConfig.forEach((item) => {
|
||||
if (item.field) {
|
||||
data[item.field] = item.value;
|
||||
if (item.type === "date") {
|
||||
data[item.field] = item.value ? item.value?.format("YYYY-MM-DD") : "";
|
||||
}
|
||||
}
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
export const setFormObj = (formConfig, data) => {
|
||||
formConfig.forEach((item) => {
|
||||
if (item.field && data[item.field] !== undefined) {
|
||||
item.value = data[item.field];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const clearObj = (formData) => {
|
||||
for (let field in formData) {
|
||||
if (Array.isArray(formData[field])) {
|
||||
formData[field] = [];
|
||||
} else if (
|
||||
Object.prototype.toString.call(formData[field]) === "[object Number]"
|
||||
) {
|
||||
formData[field] = 0;
|
||||
} else if (
|
||||
Object.prototype.toString.call(formData[field]) === "[object Object]"
|
||||
) {
|
||||
formData[field] = {};
|
||||
} else {
|
||||
formData[field] = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const visibleFormItem = (itemshow, formObj = {}) => {
|
||||
let flag = true;
|
||||
if (Object.prototype.toString.call(itemshow) === "[object Function]") {
|
||||
return itemshow(formObj);
|
||||
}
|
||||
if (Object.prototype.toString.call(itemshow) === "[object Boolean]") {
|
||||
return itemshow;
|
||||
}
|
||||
let mathArr = [
|
||||
"+",
|
||||
"-",
|
||||
"/",
|
||||
"*",
|
||||
">",
|
||||
">=",
|
||||
"<",
|
||||
"<=",
|
||||
"==",
|
||||
"===",
|
||||
"!==",
|
||||
"||",
|
||||
"&&",
|
||||
];
|
||||
if (itemshow && Object.keys(formObj).length > 0) {
|
||||
let expression = itemshow
|
||||
.replace(/\[%=/g, "")
|
||||
.replace(/%\]/g, "")
|
||||
.replace(/'/g, "");
|
||||
let cs = expression.split(/(\/|%|\*|\+|-|&&|\|\||>|<|>=|<=|\(|\)|===|!==)/);
|
||||
for (let idx in cs) {
|
||||
let csItem = cs[idx];
|
||||
// 右边为''
|
||||
if (formObj[csItem] === "") {
|
||||
cs[idx] = "'" + formObj[csItem] + "'";
|
||||
// 左边可转为数值计算
|
||||
} else if (
|
||||
formObj[csItem] !== undefined &&
|
||||
Object.prototype.toString.call(formObj[csItem]) === "object String"
|
||||
) {
|
||||
cs[idx] = formObj[csItem];
|
||||
} else if (
|
||||
formObj[csItem] !== undefined &&
|
||||
Object.prototype.toString.call(formObj[csItem]) === "object Number"
|
||||
) {
|
||||
cs[idx] = "'" + formObj[csItem] + "'";
|
||||
// 左边字段不存在 右边字段值字符串化
|
||||
} else if (!mathArr.includes(csItem) && formObj[csItem] === undefined) {
|
||||
cs[idx] = "'" + csItem + "'";
|
||||
// 计算公式
|
||||
} else if (mathArr.includes(csItem)) {
|
||||
cs[idx] = csItem;
|
||||
} else {
|
||||
// 左边 在obj中存在 字符串化
|
||||
cs[idx] = "'" + formObj[csItem] + "'";
|
||||
}
|
||||
}
|
||||
cs = cs.join("");
|
||||
flag = flag && window.eval(cs);
|
||||
}
|
||||
return flag;
|
||||
};
|
||||
|
||||
export const validateItem = (item, itemValue) => {
|
||||
let validate = item.validate;
|
||||
let noErr = true;
|
||||
let message = "";
|
||||
switch (validate) {
|
||||
case "required": {
|
||||
// ADD input type is number is empty is null And dropdowntable,userDropdowntable,dropdownbox is empty is []
|
||||
if (itemValue === null || itemValue.length === 0) {
|
||||
noErr = false;
|
||||
message = "请输入内容";
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "": {
|
||||
// ADD input type is number is empty is null And dropdowntable,userDropdowntable,dropdownbox is empty is []
|
||||
if (itemValue === null || itemValue.length === 0) {
|
||||
noErr = false;
|
||||
message = "请输入内容";
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "email": {
|
||||
const regEmail =
|
||||
/^[a-zA-Z0-9_-]+([._\\-]*[a-zA-Z0-9_-])*@[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$/;
|
||||
if (regEmail.test(itemValue) === false) {
|
||||
noErr = false;
|
||||
message = "请输入正确邮箱";
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "chinese": {
|
||||
const regChinese = /^[^\u4e00-\u9fa5]{0,}$/;
|
||||
if (regChinese.test(itemValue) === false) {
|
||||
noErr = false;
|
||||
message = "请输入中文字符";
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "maxDecimals4": {
|
||||
// let regDecimals2 = /^\d+(\.\d{2})?$/
|
||||
const regDecimals4 = /^\d+(?:\.\d{1,4})?$/;
|
||||
if (
|
||||
itemValue !== null &&
|
||||
itemValue !== "" &&
|
||||
regDecimals4.test(itemValue) === false
|
||||
) {
|
||||
noErr = false;
|
||||
message = "最多四位小数";
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "english": {
|
||||
// 添加英文支持-
|
||||
const regEnglish = /^[a-zA-Z0-9_@-]{1,}$/;
|
||||
if (regEnglish.test(itemValue) === false) {
|
||||
noErr = false;
|
||||
message = "请输入英文字符";
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "datetime": {
|
||||
const regDatetime =
|
||||
/^[1-9]\d{3}-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])\s+(20|21|22|23|[0-1]\d):[0-5]\d:[0-5]\d$/;
|
||||
if (regDatetime.test(itemValue) === false) {
|
||||
noErr = false;
|
||||
message = "请输入正确时间";
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "date": {
|
||||
const regDate = /^[1-9]\d{3}-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])$/;
|
||||
if (regDate.test(itemValue) === false) {
|
||||
noErr = false;
|
||||
message = "请输入正确日期";
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "time": {
|
||||
const regTime = /^(20|21|22|23|[0-1]\d):[0-5]\d:[0-5]\d$/;
|
||||
if (regTime.test(itemValue) === false) {
|
||||
noErr = false;
|
||||
message = "请输入正确时间";
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "interger": {
|
||||
const regInterger = /^[1-9]\d*$/;
|
||||
if (regInterger.test(itemValue) === false) {
|
||||
noErr = false;
|
||||
message = "请输入整数";
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "positiveNumber": {
|
||||
// 包括0的正数
|
||||
if (itemValue < 0) {
|
||||
noErr = false;
|
||||
message = "请输入非负数";
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "gt0Number": {
|
||||
// 不包括0的正数
|
||||
if (itemValue <= 0) {
|
||||
noErr = false;
|
||||
message = "请输入非零正数";
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "telephone": {
|
||||
const telephoneNumber = /^1(3|4|5|6|7|8|9)\d{9}$/;
|
||||
if (telephoneNumber.test(itemValue) === false) {
|
||||
noErr = false;
|
||||
message = "请输入正确手机号码";
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "pwdstrong": {
|
||||
const forceRegex = new RegExp("(?=.*[0-9])(?=.*[A-Z])(?=.*[a-z]).{6,20}");
|
||||
if (forceRegex.test(itemValue) === false) {
|
||||
noErr = false;
|
||||
message = "请输入符合格式的密码";
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
noErr = true;
|
||||
}
|
||||
|
||||
if (!noErr) {
|
||||
item.errorMessage = message;
|
||||
} else {
|
||||
item.errorMessage = "";
|
||||
}
|
||||
};
|
||||
export const validataForm = (formConfig) => {
|
||||
if (formConfig) {
|
||||
let formHasErr = false;
|
||||
formConfig.forEach((item) => {
|
||||
if (item.validate !== undefined) {
|
||||
validateItem(item, item.value);
|
||||
}
|
||||
if (item.errorMessage) {
|
||||
formHasErr = true;
|
||||
}
|
||||
});
|
||||
return formHasErr;
|
||||
}
|
||||
};
|
||||
export const calcFormat = (config, val) => {
|
||||
if (val === 0 || val === "0") {
|
||||
return 0;
|
||||
}
|
||||
if (config.format === "w" && val) {
|
||||
return ((Number(val) * 1) / 10000).toFixed(config.toFixed || 2) + "w";
|
||||
}
|
||||
};
|
||||
export const verifyFormat = (config, val) => {
|
||||
// 满足 true
|
||||
if (config.verifyFormat === ".00" && val) {
|
||||
return val.endsWith(".00");
|
||||
}
|
||||
return true;
|
||||
};
|
||||
export const deepCopy = (obj) => {
|
||||
let copy;
|
||||
// 处理非对象类型和函数类型
|
||||
if (obj === null || typeof obj !== "object" || obj instanceof Function) {
|
||||
return obj;
|
||||
}
|
||||
// 处理日期类型
|
||||
if (obj instanceof Date) {
|
||||
copy = new Date();
|
||||
copy.setTime(obj.getTime());
|
||||
return copy;
|
||||
}
|
||||
// 处理数组类型
|
||||
if (obj instanceof Array) {
|
||||
copy = [];
|
||||
for (let i = 0, len = obj.length; i < len; i++) {
|
||||
copy[i] = deepCopy(obj[i]);
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
// 处理对象类型
|
||||
if (obj instanceof Object) {
|
||||
copy = {};
|
||||
for (let prop in obj) {
|
||||
if (obj.hasOwnProperty(prop)) {
|
||||
copy[prop] = deepCopy(obj[prop]);
|
||||
}
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
throw new Error("无法复制此对象,因为类型未知: " + obj);
|
||||
};
|
||||
// 数值千分位显示
|
||||
export const formatNumberWithCommas = (num) => {
|
||||
if (!num) return 0;
|
||||
if (typeof num === "string") {
|
||||
num = Number(num);
|
||||
}
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
};
|
9
src/utils/helper/message.js
Normal file
9
src/utils/helper/message.js
Normal file
@ -0,0 +1,9 @@
|
||||
export const processSuccess = (res, time = 3) => {
|
||||
message.success(res, time)
|
||||
}
|
||||
export const processError = (res, time = 3) => {
|
||||
message.error(res, time)
|
||||
}
|
||||
export const processWarning = (res, time = 3) => {
|
||||
message.warning(res, time)
|
||||
}
|
@ -1,8 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, onMounted,watch } from 'vue'
|
||||
import { computed, ref, onMounted, watch, reactive, onBeforeMount, getCurrentInstance } from 'vue'
|
||||
import { onBeforeRouteUpdate } from 'vue-router'
|
||||
import { useDialogueStore, useTalkStore } from '@/store'
|
||||
import { NDropdown, NIcon, NInput, NPopover } from 'naive-ui'
|
||||
import { NDropdown, NIcon, NInput, NPopover, NTabs, NTab, NCard } from 'naive-ui'
|
||||
import { Search, Plus } from '@icon-park/vue-next'
|
||||
import TalkItem from './TalkItem.vue'
|
||||
import Skeleton from './Skeleton.vue'
|
||||
@ -11,6 +11,13 @@ import GroupLaunch from '@/components/group/GroupLaunch.vue'
|
||||
import { getCacheIndexName } from '@/utils/talk'
|
||||
import { ISession } from '@/types/chat'
|
||||
import { useSessionMenu } from '@/hooks'
|
||||
import customModal from '@/components/common/customModal.vue'
|
||||
import xSearchForm from '@/components/x-naive-ui/x-search-form/index.vue'
|
||||
import flTree from '@/components/flnlayout/tree/flnindex.vue'
|
||||
import { processError, processSuccess } from '@/utils/helper/message.js'
|
||||
|
||||
const currentInstance = getCurrentInstance()
|
||||
const { $request } = currentInstance?.appContext.config.globalProperties
|
||||
|
||||
const {
|
||||
dropdown,
|
||||
@ -27,6 +34,29 @@ const searchKeyword = ref('')
|
||||
const topItems = computed((): ISession[] => talkStore.topItems)
|
||||
const unreadNum = computed(() => talkStore.talkUnreadNum)
|
||||
|
||||
const state = reactive({
|
||||
isShowAddressBookModal: false, // 是否显示通讯录模态框
|
||||
customModalStyle: {
|
||||
width: '1288px',
|
||||
height: '846px',
|
||||
backgroundColor: '#F9F9FD'
|
||||
}, //自定义模态框样式
|
||||
searchConfig: [
|
||||
{
|
||||
label: '姓名',
|
||||
key: 'name',
|
||||
type: 'input',
|
||||
valueType: 'string'
|
||||
}
|
||||
], // 搜索配置
|
||||
|
||||
treeData: [],
|
||||
expandedKeys: [],
|
||||
clickKey: '',
|
||||
treeRefreshCount: 0,
|
||||
treeSelectData: {}
|
||||
})
|
||||
|
||||
const items = computed((): ISession[] => {
|
||||
if (searchKeyword.value.length === 0) {
|
||||
return talkStore.talkItems
|
||||
@ -102,9 +132,81 @@ const onInitialize = () => {
|
||||
// 路由更新事件
|
||||
onBeforeRouteUpdate(onInitialize)
|
||||
|
||||
onBeforeMount(() => {
|
||||
getTreeData()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
onInitialize()
|
||||
})
|
||||
|
||||
// 点击显示通讯录模态框
|
||||
const showAddressBookModal = () => {
|
||||
state.isShowAddressBookModal = true
|
||||
}
|
||||
const handleTreeClick = ({ selectedKey, tree }) => {
|
||||
state.clickKey = tree.key
|
||||
state.treeSelectData = tree
|
||||
}
|
||||
const calcTreeData = (data) => {
|
||||
for (let item of data) {
|
||||
item.key = item.ID
|
||||
item.label = item.name
|
||||
item.title = item.name
|
||||
if (item.sons) {
|
||||
item.children = item.sons
|
||||
calcTreeData(item.children)
|
||||
}
|
||||
delete item.ID
|
||||
delete item.name
|
||||
delete item.sons
|
||||
}
|
||||
}
|
||||
// 获取组织树数据
|
||||
const getTreeData = () => {
|
||||
let url = '/department/v2/tree/filter'
|
||||
let params = {}
|
||||
$request.HTTP.components.postDataByParams(url, params).then(
|
||||
(res) => {
|
||||
if (res.status === 0 && Array.isArray(res.data.nodes)) {
|
||||
let data = res.data.nodes
|
||||
calcTreeData(data)
|
||||
state.treeData = data
|
||||
// // 获取最近点击的部门
|
||||
// let localSelect = Local.get("posimanage_treeSelectData");
|
||||
// if (localSelect && JSON.stringify(localSelect) !== "{}") {
|
||||
// state.treeSelectData = localSelect;
|
||||
// state.expandedKeys = localSelect.pathIds;
|
||||
// state.clickKey = localSelect.key;
|
||||
// } else {
|
||||
// if (JSON.stringify(state.treeSelectData) === "{}") {
|
||||
// state.treeSelectData = data[0];
|
||||
// state.clickKey = data[0].key;
|
||||
// }
|
||||
// if (
|
||||
// state.clickKey === data[0].key &&
|
||||
// !state.expandedKeys.includes(data[0].key)
|
||||
// ) {
|
||||
// state.expandedKeys.push(data[0].key);
|
||||
// }
|
||||
// if (!state.expandedKeys.includes(state.clickKey)) {
|
||||
// state.expandedKeys.push(state.clickKey);
|
||||
// }
|
||||
// }
|
||||
state.treeRefreshCount++
|
||||
// state.tableConfig.refreshCount++;
|
||||
} else {
|
||||
processError(res.msg || '获取失败!')
|
||||
}
|
||||
},
|
||||
() => {
|
||||
processError('获取失败!')
|
||||
},
|
||||
() => {
|
||||
processError('获取失败!')
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -127,7 +229,7 @@ onMounted(() => {
|
||||
v-model:value.trim="searchKeyword"
|
||||
round
|
||||
clearable
|
||||
style="width: 78%"
|
||||
style="width: 78%;"
|
||||
>
|
||||
<template #prefix>
|
||||
<n-icon :component="Search" />
|
||||
@ -139,6 +241,12 @@ onMounted(() => {
|
||||
<n-icon :component="Plus" />
|
||||
</template>
|
||||
</n-button>
|
||||
<img
|
||||
style="width: 19px; height: 20px;"
|
||||
src="@/assets/image/chatList/addressBook.png"
|
||||
alt=""
|
||||
@click="showAddressBookModal"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<!-- 置顶栏目 -->
|
||||
@ -199,6 +307,37 @@ onMounted(() => {
|
||||
</section>
|
||||
|
||||
<GroupLaunch v-if="isShowGroup" @close="isShowGroup = false" @on-submit="onReload" />
|
||||
|
||||
<customModal
|
||||
v-model:show="state.isShowAddressBookModal"
|
||||
title="通讯录"
|
||||
:style="state.customModalStyle"
|
||||
:customCloseBtn="true"
|
||||
:closable="false"
|
||||
>
|
||||
<template #content>
|
||||
<div class="custom-modal-content">
|
||||
<n-card>
|
||||
<n-tabs type="line">
|
||||
<n-tab name="employeeAddressBook">员工通讯录</n-tab>
|
||||
<n-tab name="groupChatList">群聊列表</n-tab>
|
||||
</n-tabs>
|
||||
<xSearchForm :search-config="state.searchConfig"></xSearchForm>
|
||||
<div class="addressBook-content">
|
||||
<div class="addressBook-tree">
|
||||
<fl-tree
|
||||
:data="state.treeData"
|
||||
:expandedKeys="state.expandedKeys"
|
||||
:refreshCount="state.treeRefreshCount"
|
||||
:clickKey="state.clickKey"
|
||||
@triggerTreeClick="handleTreeClick"
|
||||
></fl-tree>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
</customModal>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@ -302,4 +441,21 @@ html[theme-mode='dark'] {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-modal-content {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: 0 12px;
|
||||
.addressBook-content {
|
||||
.addressBook-tree {
|
||||
width: 328px;
|
||||
height: 524px;
|
||||
overflow: auto;
|
||||
border: 1px solid #efeff5;
|
||||
border-radius: 4px;
|
||||
padding: 12px 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { defineConfig } from 'unocss'
|
||||
import { presetAttributify, presetIcons } from 'unocss'
|
||||
import { defineConfig, presetUno, presetAttributify, presetIcons } from 'unocss'
|
||||
|
||||
export default defineConfig({
|
||||
// 预设
|
||||
presets: [
|
||||
presetUno(), // 添加核心预设
|
||||
presetAttributify(), // 启用属性模式
|
||||
presetIcons(), // 启用图标
|
||||
],
|
||||
|
Loading…
Reference in New Issue
Block a user