Merge branch 'wyfMain-dev'

This commit is contained in:
wangyifeng 2025-05-12 13:59:58 +08:00
commit b35243bb79
41 changed files with 14514 additions and 321 deletions

9309
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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",

File diff suppressed because it is too large Load Diff

22
src/api/components.js Normal file
View 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
View 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 };

Binary file not shown.

After

Width:  |  Height:  |  Size: 530 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View 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>

View 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>

View 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>

View 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>

View File

@ -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>

View 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>

View 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>

View 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>

View File

@ -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>

View File

@ -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>

View 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

View 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>

View 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>
```

View 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>

View 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 和点击遮罩关闭
- 支持通过属性覆盖默认配置

View 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>

File diff suppressed because it is too large Load Diff

View 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)

View 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);
});
}

View 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 : [];
// keydefault
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>

View File

@ -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
]

View File

@ -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'
},

View File

@ -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)

View File

@ -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
View 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
View 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
View 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, ",");
};

View 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)
}

View File

@ -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>

View File

@ -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(), // 启用图标
],