merge dev into dev

feat: 新增用户选择器 通知公告可以指定通知范围

Created-by: kiki1373639299
Author-id: 86659
MR-id: 188386
Commit-by: KAI
Merged-by: Charles_7c
E2E-issues: 
Description: <!--
  非常感谢您的 PR!在提交之前,请务必确保您 PR 的代码经过了完整测试,并且通过了代码规范检查。
-->

<!-- 在 [] 中输入 x 来勾选) -->

## PR 类型

<!-- 您的 PR 引入了哪种类型的变更? -->
<!-- 只支持选择一种类型,如果有多种类型,可以在更新日志中增加 “类型” 列。 -->

- [X] 新 feature
- [ ] Bug 修复
- [ ] 功能增强
- [ ] 文档变更
- [ ] 代码样式变更
- [ ] 重构
- [ ] 性能改进
- [ ] 单元测试
- [ ] CI/CD
- [ ] 其他

## PR 目的

<!-- 描述一下您的 PR 解决了什么问题。如果可以,请链接到相关 issues。 -->

## 解决方案

<!-- 详细描述您是如何解决的问题 -->

## PR 测试

<!-- 如果可以,请为您的 PR 添加或更新单元测试。 -->
<!-- 请描述一下您是如何测试 PR 的。例如:创建/更新单元测试或添加相关的截图。 -->

## Changelog

| 模块  | Changelog | Related issues |
|-----|-----------| -------------- |
|  src/api/system   |    新增查询用户列表接口 以及通知公告字段类型变更      |                |
|  src/component/UserSelect   |    新增用户选择器      |                |
|  src/view/system/notice/add   |    适配用户选择器 以及新增通知范围      |                |
<!-- 如果有多种类型的变更,可以在变更日志表中增加 “类型” 列,该列的值与上方 “PR 类型” 相同。 -->
<!-- Related issues 格式为 Closes #<issue号>,或者 Fixes #<issue号>,或者 Resolves #<issue号>。 -->

## 其他信息

<!-- 请描述一下还有哪些注意事项。例如:如果引入了一个不向下兼容的变更,请描述其影响。 -->

## 提交前确认

- [X] PR 代码经过了完整测试,并且通过了代码规范检查
- [ ] 已经完整填写 Changelog,并链接到了相关 issues
- [X] PR 代码将要提交到 dev 分支

See merge request: continew/continew-admin-ui!1
This commit is contained in:
2024-10-23 16:26:30 +08:00
7 changed files with 418 additions and 25 deletions

View File

@@ -40,6 +40,7 @@ export interface UserQuery {
createTime?: Array<string>
deptId?: string
sort: Array<string>
userIds?: Array<string>
}
export interface UserPageQuery extends UserQuery, PageQuery {
@@ -190,6 +191,8 @@ export interface NoticeResp {
type: string
effectiveTime: string
terminateTime: string
noticeScope: number
noticeUsers: Array<string>
createUserString: string
createTime: string
updateUserString: string

View File

@@ -9,6 +9,9 @@ const BASE_URL = '/system/user'
export function listUser(query: T.UserPageQuery) {
return http.get<PageRes<T.UserResp[]>>(`${BASE_URL}`, query)
}
export function listAllUser(query: Partial<T.UserPageQuery>) {
return http.get<T.UserResp[]>(`${BASE_URL}/list`, query)
}
/** @desc 查询用户详情 */
export function getUser(id: string) {

View File

@@ -0,0 +1,238 @@
<template>
<div class="container">
<a-row :gutter="16">
<a-col :span="24" :md="5" class="section">
<a-input v-model="searchKey" placeholder="请输入部门名称" allow-clear>
<template #prefix>
<icon-search />
</template>
</a-input>
<a-tree
ref="treeRef"
:data="treeData"
block-node
@select="handleDeptSelect"
/>
</a-col>
<a-col :span="24" :md="14" class="section">
<GiTable
v-model:selectedKeys="selectedKeys"
style="min-height: 600px;"
row-key="id"
:data="dataList"
:columns="tableColumns"
:loading="loading"
:scroll="{ x: '100%', y: '100%' }"
:pagination="pagination"
:disabled-tools="['size', 'fullscreen', 'setting', 'refresh']"
:row-selection="{ type: props.multiple ? 'checkbox' : 'radio', showCheckedAll: true }"
@select="onRowSelect"
@select-all="onTableSelectAll"
@refresh="search"
>
<template #top>
<div>
<a-space class="mt-5">
<a-input v-model="queryForm.description" placeholder="用户名/昵称/描述" />
<a-button @click="search">
<template #icon>
<icon-search />
</template>
</a-button>
<a-button @click="onRefresh">
<template #icon>
<icon-refresh />
</template>
</a-button>
</a-space>
</div>
<a-alert class="mt-5">
<template v-if="selectedKeys.length > 0">
已选中{{ selectedKeys.length }}条记录(可跨页)
</template>
<template v-else>
未选中任何项目
</template>
<template v-if="selectedKeys.length > 0" #action>
<a-link @click="onClearSelected">清空</a-link>
</template>
</a-alert>
</template>
<template #status="{ record }">
<GiCellStatus :status="record.status" />
</template>
</GiTable>
</a-col>
<a-col :span="24" :md="5" class="section">
<a-card title="已选用户">
<a-table :columns="rightColumn" :data="selectedData">
<template #action="{ record }">
<a-button @click="handleDeleteSelectUser(record)">
<icon-delete />
</a-button>
</template>
</a-table>
</a-card>
</a-col>
</a-row>
</div>
</template>
<script setup lang="ts">
import type { TreeNodeData } from '@arco-design/web-vue'
import { useDept } from '@/hooks/app'
import { useTable } from '@/hooks'
import { listAllUser, listUser } from '@/apis'
import type { UserItem, UserSelectPropType } from '@/components/UserSelect/type'
const props = withDefaults(defineProps<UserSelectPropType & { selectedUsers: string | string[] }>(), {
multiple: false,
selectedUsers: () => []
})
const emit = defineEmits(['update:selectedUsers'])
// 查询表单引用
const queryForm = ref({ description: '' })
// 部门树引用
const treeRef = ref()
const selectedKeys = ref<string[]>([])
const selectedDeptId = ref<string>('')
const selectedData = ref<any[]>([])
const { tableData: dataList, loading, pagination, search } = useTable(
(page) => listUser({ ...queryForm.value, deptId: selectedDeptId.value, sort: [], ...page }),
{ immediate: false, formatResult: (data) => data.map((i) => ({ ...i, disabled: false })) }
)
// 刷新表单
const onRefresh = () => {
queryForm.value.description = ''
search()
}
// 使用 useDept 钩子获取部门列表数据
const { deptList, getDeptList } = useDept({
onSuccess: () => {
nextTick(() => treeRef.value?.expandAll(true))
}
})
// 部门树过滤函数
const deptTreeSearch = (keyword: string, data: TreeNodeData[]): TreeNodeData[] => {
return data
.map((item) => ({
...item,
children: item.children ? deptTreeSearch(keyword, item.children) : []
}))
.filter(
(item) =>
item.title?.toLowerCase().includes(keyword.toLowerCase()) || item.children?.length
)
}
// 过滤树数据
const searchKey = ref('')
const treeData = computed(() => {
return searchKey.value ? deptTreeSearch(searchKey.value, deptList.value) : deptList.value
})
// 表格列定义
const tableColumns = [
{ title: '昵称', dataIndex: 'nickname' },
{ title: '部门', dataIndex: 'deptName' },
{ title: '角色', dataIndex: 'roleNames' },
{ title: '手机号', dataIndex: 'phone' },
{ title: '邮箱', dataIndex: 'email' },
{ title: '状态', dataIndex: 'status', slotName: 'status' }
]
// 右侧已选用户列定义
const rightColumn = [
{ title: '昵称', dataIndex: 'nickname' },
{ title: '操作', dataIndex: 'action', slotName: 'action' }
]
// 处理部门选择
const handleDeptSelect = (keys: Array<any>) => {
selectedDeptId.value = keys[0] || ''
search()
}
const emitSelectedUsers = () => {
emit('update:selectedUsers', selectedKeys.value)
}
// 从选中列表中移除用户
const handleDeleteSelectUser = (user: UserItem) => {
selectedData.value = selectedData.value.filter((item) => item.id !== user.id)
selectedKeys.value = selectedData.value.map((item) => item.id)
emitSelectedUsers()
}
// 行选择事件
const onRowSelect = (rowKeys: string[], rowKey: string, record: UserItem) => {
selectedData.value = props.multiple
? rowKeys.includes(rowKey)
? [...selectedData.value, record]
: selectedData.value.filter((item) => item.id !== rowKey)
: [record]
selectedKeys.value = selectedData.value.map((item) => item.id)
emitSelectedUsers()
}
// 全选事件
const onTableSelectAll = (checked: boolean) => {
selectedData.value = checked
? [...selectedData.value, ...dataList.value.filter((item) => !selectedKeys.value.includes(item.id))]
: []
selectedKeys.value = selectedData.value.map((item) => item.id)
emitSelectedUsers()
}
// 清空所有选中数据
const onClearSelected = () => {
selectedData.value = []
selectedKeys.value = []
emitSelectedUsers()
}
// 初始化函数
const init = (selectUsers: string[]) => {
getDeptList()
search()
if (selectUsers && selectUsers.length > 0) {
// admin的id是number 不是string 类型 所以处理一下
listAllUser({ userIds: selectUsers }).then((dataList) => {
selectedData.value = dataList.data.map((data) => {
return { ...data, id: `${data.id}` }
})
})
}
}
watch(() => props.selectedUsers, (newValue) => {
const newSelectedKeys = Array.isArray(newValue) ? newValue : [newValue]
selectedKeys.value = newSelectedKeys.filter(Boolean)
selectedData.value = dataList.value.filter((item) => selectedKeys.value.includes(item.id))
}, { immediate: true })
defineExpose({ init, onClearSelected })
</script>
<style scoped>
.container {
padding: 20px;
}
.section {
margin-bottom: 20px;
}
.mt-5 {
margin-top: 5px;
}
</style>

View File

@@ -0,0 +1,110 @@
<template>
<div>
<div style="display: flex;">
<a-select
v-model="selectedUsers"
:allow-clear="true"
:multiple="props.multiple"
:max-tag-count="4"
:field-names="{ value: 'id', label: 'nickname' }"
:options="options"
@change="handleSelectChange"
/>
<a-tooltip content="选择用户">
<a-button @click="onOpen">
<template #icon>
<icon-plus />
</template>
</a-button>
</a-tooltip>
</div>
<a-modal
v-model:visible="visible"
title="用户选择"
:width="width >= 1350 ? 1350 : '100%'"
:esc-to-close="true"
@ok="handleModalOk"
>
<UserSelectContent
ref="userSelectContentRef"
:value="selectedUsers"
:multiple="props.multiple"
:selected-users="selectedUsers"
@update:selected-users="updateSelectedUsers"
/>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { useWindowSize } from '@vueuse/core'
import UserSelectContent from './component/UserSelectContent.vue'
import { type UserResp, listAllUser } from '@/apis'
import type { UserSelectPropType } from '@/components/UserSelect/type'
const props = withDefaults(defineProps<UserSelectPropType>(), {
multiple: false, // 是否支持多选
value: ''
})
const emit = defineEmits(['update:value'])
const visible = ref<boolean>(false) // 控制弹窗显示的状态
const { width } = useWindowSize() // 获取窗口的宽度,用于设置弹窗宽度
const options = ref<UserResp[]>([]) // 保存用户选项列表
const userSelectContentRef = ref() // 引用 UserSelectContent 组件实例
const selectedUsers = ref<string[]>([]) // 保存已选择的用户
// 打开用户选择弹窗
const onOpen = () => {
visible.value = true
userSelectContentRef.value.init(selectedUsers.value) // 调用子组件的初始化方法
}
// 发出数据更新事件
const emitDataChange = () => {
emit('update:value', selectedUsers.value.filter(Boolean)) // 发出更新事件
}
// 处理用户选择变更事件
const handleSelectChange = (value: any) => {
selectedUsers.value = props.multiple ? value : [...value]
emitDataChange() // 每次选择变化时发出更新事件
}
// 更新已选择的用户列表
const updateSelectedUsers = (users: string[]) => {
selectedUsers.value = users
emitDataChange() // 每次选择变化时发出更新事件
}
// 弹窗确认按钮点击事件
const handleModalOk = () => {
emitDataChange() // 确认时发出数据更新事件
visible.value = false // 关闭弹窗
}
// 组件挂载后初始化用户列表
onMounted(async () => {
const { data } = await listAllUser({}) // 获取所有用户
options.value = data.map((user) => {
user.id = String(user.id)
user.disabled = false // 初始化时设置用户未被禁用
return user
})
// 初始化选择的用户
selectedUsers.value = Array.isArray(props.value) ? props.value : props.value.split(',')
})
</script>
<style scoped>
:deep(.arco-input-append) {
padding: 0;
.arco-btn {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border: 1px solid transparent;
}
}
</style>

View File

@@ -0,0 +1,13 @@
export interface UserSelectPropType {
multiple: boolean
value: string | string[]
}
export interface UserItem {
id: string
nickname: string
deptName: string
roleNames: string
phone: string
email: string
status: number
}

View File

@@ -48,6 +48,8 @@ declare module 'vue' {
RouterView: typeof import('vue-router')['RouterView']
SecondForm: typeof import('./../components/GenCron/CronForm/component/second-form.vue')['default']
TextCopy: typeof import('./../components/TextCopy/index.vue')['default']
UserSelect: typeof import('./../components/UserSelect/index.vue')['default']
UserSelectContent: typeof import('./../components/UserSelect/component/UserSelectContent.vue')['default']
Verify: typeof import('./../components/Verify/index.vue')['default']
VerifyPoints: typeof import('./../components/Verify/Verify/VerifyPoints.vue')['default']
VerifySlide: typeof import('./../components/Verify/Verify/VerifySlide.vue')['default']

View File

@@ -1,29 +1,33 @@
<template>
<div ref="containerRef" class="detail">
<div class="detail_header">
<a-affix :target="(containerRef as HTMLElement)">
<a-page-header title="通知公告" :subtitle="type === 'edit' ? '修改' : '新增'" @back="onBack">
<template #extra>
<a-button type="primary" @click="onReleased">
<template #icon>
<icon-save v-if="type === 'edit'" />
<icon-send v-else />
</template>
<template #default>
{{ type === 'edit' ? '保存' : '发布' }}
</template>
</a-button>
</template>
</a-page-header>
</a-affix>
</div>
<div class="detail_content" style="display: flex; flex-direction: column;">
<GiForm ref="formRef" v-model="form" :options="options" :columns="columns" />
<div style="flex: 1;">
<AiEditor v-model="form.content" />
</div>
</div>
<div ref="containerRef" class="detail">
<div class="detail_header">
<a-affix :target="(containerRef as HTMLElement)">
<a-page-header title="通知公告" :subtitle="type === 'edit' ? '修改' : '新增'" @back="onBack">
<template #extra>
<a-button type="primary" @click="onReleased">
<template #icon>
<icon-save v-if="type === 'edit'" />
<icon-send v-else />
</template>
<template #default>
{{ type === 'edit' ? '保存' : '发布' }}
</template>
</a-button>
</template>
</a-page-header>
</a-affix>
</div>
<div class="detail_content" style="display: flex; flex-direction: column;">
<GiForm ref="formRef" v-model="form" :options="options" :columns="columns">
<template #noticeUsers>
<UserSelect v-model:value="form.noticeUsers" :multiple="true" class="w-full" />
</template>
</GiForm>
<div style="flex: 1;">
<AiEditor v-model="form.content" />
</div>
</div>
</div>
</template>
<script setup lang="tsx">
@@ -46,7 +50,8 @@ const { form, resetForm } = useForm({
type: '',
effectiveTime: '',
terminateTime: '',
content: ''
content: '',
noticeScope: 1
})
const options: Options = {
form: { size: 'large' },
@@ -88,7 +93,24 @@ const columns: Columns = reactive([
props: {
showTime: true
}
},
{
label: '通知范围',
field: 'noticeScope',
type: 'radio-group',
options: [{ label: '所有人', value: 1 }, { label: '指定用户', value: 2 }],
rules: [{ required: true, message: '请选择通知范围' }]
},
{
label: '指定用户',
field: 'noticeUsers',
type: 'input',
hide: () => {
return form.noticeScope === 1
},
rules: [{ required: true, message: '请选择指定用户' }]
}
])
// 修改
const onUpdate = async (id: string) => {
@@ -103,6 +125,8 @@ const onReleased = async () => {
const isInvalid = await formRef.value?.formRef?.validate()
if (isInvalid) return false
try {
// 通知范围 所有人 去除指定用户
form.noticeUsers = form.noticeScope === 1 ? null : form.noticeUsers
if (type === 'edit') {
await updateNotice(form, id as string)
Message.success('修改成功')