feat(消息管理): useSelect钩子&消息管理部分页面&部分组件调整

This commit is contained in:
baiqi 2023-09-28 16:31:43 +08:00 committed by 刘瑞斌
parent 9404db0222
commit bbccbe49be
38 changed files with 1515 additions and 226 deletions

View File

@ -570,6 +570,194 @@ const fileList = [
updateTime: 18975439859,
createTime: 18975439859,
},
{
id: 100001,
name: 'JAR',
url: 'https://github.com/metersphere/metersphere/blob/v2.10/.gitignore',
type: 'JAR',
desc: 'fwihflhlofihlasjkhfdlkasjdhgaksuidhoasidoasidasopidapsoidaps',
storage: 'minio',
tag: ['dsadasd'],
size: '12MB',
enable: true,
fileVersion: 'v2.10',
fileModule: 'XXX',
creator: '创建人',
updater: '更新人',
updateTime: 18975439859,
createTime: 18975439859,
},
{
id: 1000002,
name: 'PNG',
url: 'http://localhost:5173/front/base-display/get/logo-platform',
type: 'PNG',
desc: 'sfakjdghkjrugheoirugkblasjblsdjkhflksdjfsldkjklnjkhkljds',
storage: 'github',
gitBranch: 'master',
gitVersion: 'v2.10',
gitPath: '/asdas/xas/xas/fd/f/',
tag: ['asfasdfas'],
size: '12MB',
enable: false,
fileModule: 'XXX',
creator: '创建人',
updater: '更新人',
updateTime: 18975439859,
createTime: 18975439859,
},
{
id: 1000003,
name: 'PNG',
url: 'http://localhost:5173/front/base-display/get/logo-platform',
type: 'PNG',
desc: 'sfakjdghkjrugheoirugkblasjblsdjkhflksdjfsldkjklnjkhkljds',
storage: 'github',
gitBranch: 'master',
gitVersion: 'v2.10',
gitPath: '/asdas/xas/xas/fd/f/',
tag: ['asfasdfas'],
size: '12MB',
enable: false,
fileModule: 'XXX',
creator: '创建人',
updater: '更新人',
updateTime: 18975439859,
createTime: 18975439859,
},
{
id: 1000004,
name: 'PNG',
url: 'http://localhost:5173/front/base-display/get/logo-platform',
type: 'PNG',
desc: 'sfakjdghkjrugheoirugkblasjblsdjkhflksdjfsldkjklnjkhkljds',
storage: 'github',
gitBranch: 'master',
gitVersion: 'v2.10',
gitPath: '/asdas/xas/xas/fd/f/',
tag: ['asfasdfas'],
size: '12MB',
enable: false,
fileModule: 'XXX',
creator: '创建人',
updater: '更新人',
updateTime: 18975439859,
createTime: 18975439859,
},
{
id: 1000005,
name: 'PNG',
url: 'http://localhost:5173/front/base-display/get/logo-platform',
type: 'PNG',
desc: 'sfakjdghkjrugheoirugkblasjblsdjkhflksdjfsldkjklnjkhkljds',
storage: 'github',
gitBranch: 'master',
gitVersion: 'v2.10',
gitPath: '/asdas/xas/xas/fd/f/',
tag: ['asfasdfas'],
size: '12MB',
enable: false,
fileModule: 'XXX',
creator: '创建人',
updater: '更新人',
updateTime: 18975439859,
createTime: 18975439859,
},
{
id: 1000006,
name: 'PNG',
url: 'http://localhost:5173/front/base-display/get/logo-platform',
type: 'PNG',
desc: 'sfakjdghkjrugheoirugkblasjblsdjkhflksdjfsldkjklnjkhkljds',
storage: 'github',
gitBranch: 'master',
gitVersion: 'v2.10',
gitPath: '/asdas/xas/xas/fd/f/',
tag: ['asfasdfas'],
size: '12MB',
enable: false,
fileModule: 'XXX',
creator: '创建人',
updater: '更新人',
updateTime: 18975439859,
createTime: 18975439859,
},
{
id: 1000007,
name: 'PNG',
url: 'http://localhost:5173/front/base-display/get/logo-platform',
type: 'PNG',
desc: 'sfakjdghkjrugheoirugkblasjblsdjkhflksdjfsldkjklnjkhkljds',
storage: 'github',
gitBranch: 'master',
gitVersion: 'v2.10',
gitPath: '/asdas/xas/xas/fd/f/',
tag: ['asfasdfas'],
size: '12MB',
enable: false,
fileModule: 'XXX',
creator: '创建人',
updater: '更新人',
updateTime: 18975439859,
createTime: 18975439859,
},
{
id: 1000008,
name: 'PNG',
url: 'http://localhost:5173/front/base-display/get/logo-platform',
type: 'PNG',
desc: 'sfakjdghkjrugheoirugkblasjblsdjkhflksdjfsldkjklnjkhkljds',
storage: 'github',
gitBranch: 'master',
gitVersion: 'v2.10',
gitPath: '/asdas/xas/xas/fd/f/',
tag: ['asfasdfas'],
size: '12MB',
enable: false,
fileModule: 'XXX',
creator: '创建人',
updater: '更新人',
updateTime: 18975439859,
createTime: 18975439859,
},
{
id: 1000009,
name: 'PNG',
url: 'http://localhost:5173/front/base-display/get/logo-platform',
type: 'PNG',
desc: 'sfakjdghkjrugheoirugkblasjblsdjkhflksdjfsldkjklnjkhkljds',
storage: 'github',
gitBranch: 'master',
gitVersion: 'v2.10',
gitPath: '/asdas/xas/xas/fd/f/',
tag: ['asfasdfas'],
size: '12MB',
enable: false,
fileModule: 'XXX',
creator: '创建人',
updater: '更新人',
updateTime: 18975439859,
createTime: 18975439859,
},
{
id: 1000010,
name: 'PNG',
url: 'http://localhost:5173/front/base-display/get/logo-platform',
type: 'PNG',
desc: 'sfakjdghkjrugheoirugkblasjblsdjkhflksdjfsldkjklnjkhkljds',
storage: 'github',
gitBranch: 'master',
gitVersion: 'v2.10',
gitPath: '/asdas/xas/xas/fd/f/',
tag: ['asfasdfas'],
size: '12MB',
enable: false,
fileModule: 'XXX',
creator: '创建人',
updater: '更新人',
updateTime: 18975439859,
createTime: 18975439859,
},
];
// 获取文件列表
export function getFileList(data: TableQueryParams): Promise<CommonList<any>> {

View File

@ -0,0 +1,153 @@
import MSR from '@/api/http/index';
import {
RobotListUrl,
GetRobotUrl,
AddRobotUrl,
UpdateRobotUrl,
EnableRobotUrl,
} from '@/api/requrls/project-management/messageManagement';
import type { RobotItem, RobotAddParams, RobotEditParams } from '@/models/projectManagement/message';
import type { TableQueryParams, CommonList } from '@/models/common';
const list = [
{
id: '1',
name: '站内信',
description: '系统内置,在顶部导航栏显示消息通知',
platform: 'IN_SITE',
enable: true,
webhook: 'asdasdasfasfsaf',
projectId: '1',
createTime: 1695721467045,
createUser: 'admin',
},
{
id: '2',
name: '邮件',
description: '系统内置,以添加用户邮箱为通知方式',
platform: 'MAIL',
enable: false,
webhook: 'sdfsdfasfasf',
projectId: '1',
createTime: 1695721467045,
createUser: 'admin',
},
{
id: '3',
name: '飞书',
description: '',
platform: 'LARK',
enable: false,
webhook: 'asdfgasdgasfgasgas',
projectId: '1',
createTime: 1695721467045,
createUser: 'admin',
updateUser: 'bai',
updateTime: 1695721467045,
},
{
id: '4',
name: '钉钉',
description: '',
platform: 'DING_TALK',
enable: false,
webhook: 'asfgasfasdfa',
type: 'CUSTOM',
projectId: '1',
createTime: 1695721467045,
createUser: 'admin',
updateUser: 'bai',
updateTime: 1695721467045,
},
{
id: '44',
name: '钉钉',
description: '',
platform: 'DING_TALK',
enable: false,
webhook: 'asfgasfasdfa',
appKey: 'asfasfasfasfasf',
appSecret: 'asfasfasfasfasf',
type: 'ENTERPRISE',
projectId: '1',
createTime: 1695721467045,
createUser: 'admin',
updateUser: 'bai',
updateTime: 1695721467045,
},
{
id: '5',
name: '企业微信',
description: '',
platform: 'WE_COM',
enable: false,
webhook: 'vevbbt',
projectId: '1',
createTime: 1695721467045,
createUser: 'admin',
updateUser: 'bai',
updateTime: 1695721467045,
},
{
id: '5',
name: '自定义',
description: '',
platform: 'CUSTOM',
enable: false,
webhook: 'bytnm',
projectId: '1',
createTime: 1695721467045,
createUser: 'admin',
updateUser: 'bai',
updateTime: 1695721467045,
},
{
id: '5',
name: '自定义',
description: '',
platform: 'CUSTOM',
enable: false,
webhook: 'bytnm',
projectId: '1',
createTime: 1695721467045,
createUser: 'admin',
updateUser: 'bai',
updateTime: 1695721467045,
},
{
id: '5',
name: '自定义',
description: '',
platform: 'CUSTOM',
enable: false,
webhook: 'bytnm',
projectId: '1',
createTime: 1695721467045,
createUser: 'admin',
updateUser: 'bai',
updateTime: 1695721467045,
},
];
export function getRobotList(projectId: string) {
// return MSR.post<RobotItem[]>({ url: `${RobotListUrl}/${projectId}` });
return Promise.resolve(list);
}
export function getRobotDetail(robotId: string) {
// return MSR.get<RobotItem>({ url: GetRobotUrl, params: robotId });
return Promise.resolve(list.find((item) => item.id === robotId));
}
export function addRobot(data: RobotAddParams) {
return MSR.post({ url: AddRobotUrl, data });
}
export function updateRobot(data: RobotEditParams) {
return MSR.post({ url: UpdateRobotUrl, data });
}
export function toggleRobot(id: string) {
return MSR.get({ url: EnableRobotUrl, params: id });
}

View File

@ -0,0 +1,6 @@
export const RobotListUrl = '/project/robot/list/page';
export const UpdateRobotUrl = '/project/robot/update';
export const AddRobotUrl = '/project/robot/add';
export const GetRobotUrl = '/project/robot/get';
export const EnableRobotUrl = '/project/robot/enable';
export const DeleteRobotUrl = '/project/robot/delete';

View File

@ -328,6 +328,16 @@
.arco-checkbox-icon {
border: 1px solid var(--color-text-input-border);
}
&:hover {
.arco-checkbox-icon-hover::before {
background-color: rgb(var(--primary-9)) !important;
}
}
.arco-checkbox-icon-hover {
&:hover::before {
background-color: rgb(var(--primary-9)) !important;
}
}
}
.arco-checkbox-indeterminate .arco-checkbox-icon {
border-color: rgba(var(--primary-7));
@ -407,8 +417,12 @@
.arco-dropdown,
.arco-trigger-menu,
.arco-select-dropdown {
padding: 6px;
border: 0.5px solid var(--color-text-n8);
box-shadow: 0 3px 14px 2px rgb(0 0 0 / 5%), 0 8px 10px 1px rgb(0 0 0 / 6%), 0 5px 5px -3px rgb(0 0 0 / 10%);
.arco-select-dropdown-header {
margin-bottom: 4px;
}
.arco-dropdown-list,
.arco-select-dropdown-list,
.arco-trigger-menu-inner {
@ -427,6 +441,18 @@
background-color: rgb(var(--primary-1));
}
}
.arco-select-option {
margin: 0;
.arco-select-option-checkbox {
padding-top: 3px;
padding-bottom: 3px;
&:hover {
.arco-checkbox-icon-hover::before {
background-color: rgb(var(--primary-9)) !important;
}
}
}
}
.ms-dropdown-divider {
margin: 6px 0;
}
@ -442,6 +468,13 @@
}
}
}
.arco-select-dropdown-has-header {
padding-top: 6px !important;
}
.arco-select-dropdown-footer {
margin-top: 4px;
margin-bottom: 4px;
}
.arco-dropdown-option-content {
@apply flex items-center;
@ -742,3 +775,14 @@
white-space: nowrap;
}
}
/** Alter **/
.arco-alert-title {
font-size: 14px;
font-weight: 400;
}
.arco-alert-icon {
@apply flex;
margin-top: 1px;
}

View File

@ -66,16 +66,6 @@
}
});
}
if (arr && arr.length > 0) {
nextTick(() => {
if (msCardListRef.value) {
//
const listContent = msCardListRef.value;
const { scrollTop, scrollHeight, clientHeight } = listContent;
isArrivedBottom.value = scrollHeight - clientHeight - scrollTop < props.shadowLimit;
}
});
}
}
watch(
@ -219,7 +209,7 @@
@apply overflow-hidden;
.ms-container--shadow();
.ms-card-list {
@apply grid overflow-auto;
@apply grid max-h-full overflow-auto;
.ms-scroll-bar();

View File

@ -71,8 +71,8 @@
</template>
<script setup lang="ts">
import { ref, watch, Ref, nextTick, onMounted, computed, onBeforeUnmount } from 'vue';
import { calculateMaxDepth } from '@/utils';
import { ref, watch, Ref } from 'vue';
import useSelect from '@/hooks/useSelect';
import type { CascaderOption } from '@arco-design/web-vue';
import type { VirtualListProps } from '@arco-design/web-vue/es/_components/virtual-list-v2/interface';
@ -102,11 +102,15 @@
const innerValue = ref<CascaderModelValue>([]);
const innerLevel = ref(''); //
const maxTagCount = ref(1); // tag
const cascader: Ref = ref(null);
const cascaderWidth = ref(0); // cascader
const cascaderDeep = ref(1); //
const cascaderViewInner = ref<HTMLElement | null>(null); // DOM
const { maxTagCount, getOptionComputedStyle, calculateMaxTag } = useSelect({
selectRef: cascader,
selectVal: innerValue,
isCascade: true,
options: props.options,
panelWidth: props.panelWidth,
});
watch(
() => props.modelValue,
@ -155,27 +159,6 @@
}
);
watch(
() => props.options,
(arr) => {
cascaderDeep.value = calculateMaxDepth(arr);
},
{
immediate: true,
deep: true,
}
);
const getOptionComputedStyle = computed(() => {
// 80px
return {
width:
cascaderDeep.value <= 2
? `${cascaderWidth.value / cascaderDeep.value - 80 - cascaderDeep.value * 4}px`
: `${props.panelWidth}px` || '150px',
};
});
interface CascaderValue {
level: keyof typeof props.levelTop;
value: string;
@ -203,42 +186,12 @@
innerLevel.value = '';
}
}
nextTick(() => {
if (cascader.value && cascaderViewInner.value && Array.isArray(innerValue.value)) {
if (maxTagCount.value !== 0 && innerValue.value.length > maxTagCount.value) return; //
let lastWidth = cascaderViewInner.value?.getBoundingClientRect().width || 0;
const tags = cascaderViewInner.value.querySelectorAll('.arco-tag');
let tagCount = 0;
for (let i = 0; i < tags.length; i++) {
const tagWidth = Number(getComputedStyle(tags[i]).width.replace('px', ''));
if (lastWidth < tagWidth + 65) {
// 65px +N+
lastWidth = 0; // 0
break;
} else {
tagCount += 1;
lastWidth = lastWidth - tagWidth - 5;
}
}
maxTagCount.value = tagCount === 0 ? 1 : tagCount;
}
});
calculateMaxTag();
}
function clearValues() {
innerLevel.value = '';
}
onMounted(() => {
if (cascader.value) {
cascaderWidth.value = cascader.value.$el.nextElementSibling.getBoundingClientRect().width;
cascaderViewInner.value = cascader.value.$el.nextElementSibling.querySelector('.arco-select-view-inner');
}
});
onBeforeUnmount(() => {
cascaderViewInner.value = null; // DOM
});
</script>
<style lang="less">
@ -273,3 +226,4 @@
}
}
</style>
@/hooks/useSelect

View File

@ -1,5 +1,5 @@
<template>
<div class="mt-[4px] w-full text-[12px] text-[var(--color-text-4)]">
<div class="mt-[4px] flex w-full items-center text-[12px] text-[var(--color-text-4)]">
{{ props.text }}
<MsIcon
v-if="props.showFillIcon"
@ -7,6 +7,13 @@
class="cursor-pointer text-[rgb(var(--primary-6))]"
@click="fillHeapByDefault"
></MsIcon>
<MsIcon
v-else-if="props.icon"
:type="props.icon"
class="cursor-pointer text-[rgb(var(--primary-6))]"
@click="emit('iconClick')"
></MsIcon>
<slot></slot>
</div>
</template>
@ -17,12 +24,14 @@
defineProps<{
text: string;
showFillIcon?: boolean;
icon?: string;
iconText?: string;
}>(),
{
showFillIcon: true,
}
);
const emit = defineEmits(['fill']);
const emit = defineEmits(['fill', 'iconClick']);
function fillHeapByDefault() {
emit('fill');

View File

@ -1,6 +1,7 @@
import { watch, ref, h, defineComponent, onBeforeMount } from 'vue';
import { watch, ref, h, defineComponent, onBeforeMount, computed, Ref, Slot } from 'vue';
import { debounce } from 'lodash-es';
import { useI18n } from '@/hooks/useI18n';
import useSelect from '@/hooks/useSelect';
import type { SelectOptionData } from '@arco-design/web-vue';
@ -15,11 +16,14 @@ export type RemoteFieldsMap = {
export interface MsSearchSelectProps {
mode?: 'static' | 'remote'; // 静态模式,远程模式。默认为静态模式,需要传入 options 数据;远程模式需要传入请求函数
modelValue: ModelType;
allowSearch?: boolean;
allowClear?: boolean;
placeholder?: string;
prefix?: string;
searchKeys: string[]; // 需要搜索的 key 名,关键字会遍历这个 key 数组,然后取 item[key] 进行模糊匹配
hasAllSelect?: boolean; // 是否有全选选项
searchKeys?: string[]; // 需要搜索的 key 名,关键字会遍历这个 key 数组,然后取 item[key] 进行模糊匹配
valueKey?: string; // 选项的 value 字段名,默认为 value
options: SelectOptionData[];
multiple?: boolean; // 是否多选
remoteFieldsMap?: RemoteFieldsMap; // 远程模式下的结果 key 映射,例如 { value: 'id' },表示远程请求时,会将返回结果的 id 赋值到 value 字段
remoteExtraParams?: Record<string, any>; // 远程模式下的额外参数
remoteFunc?(params: Record<string, any>): Promise<any>; // 远程模式下的请求函数,返回一个 Promise
@ -27,13 +31,20 @@ export interface MsSearchSelectProps {
optionTooltipContent?: (item: SelectOptionData) => string; // 自定义 option 的 tooltip 内容,返回一个字符串,默认使用 item.label
}
export interface MsSearchSelectSlots {
prefix?: string;
header?: (() => JSX.Element) | Slot<any>;
default?: () => JSX.Element[];
footer?: Slot<any>;
}
export default defineComponent(
(props: MsSearchSelectProps, { emit }) => {
(props: MsSearchSelectProps & MsSearchSelectSlots, { emit, slots }) => {
const { t } = useI18n();
const innerValue = ref(props.modelValue);
const filterOptions = ref<SelectOptionData[]>([]);
const remoteOriginOptions = ref<SelectOptionData[]>([...props.options]);
const filterOptions = ref<SelectOptionData[]>([]); // 实际渲染的 options会根据搜索关键字进行过滤
const remoteOriginOptions = ref<SelectOptionData[]>([...props.options]); // 远程模式下的原始 options接口返回的数据会存储在这里
watch(
() => props.modelValue,
(val) => {
@ -41,12 +52,14 @@ export default defineComponent(
}
);
watch(
() => props.options,
(arr) => {
filterOptions.value = [...arr];
}
);
const selectRef = ref<Ref>();
const { maxTagCount, getOptionComputedStyle, calculateMaxTag } = useSelect({
selectRef,
selectVal: innerValue,
isCascade: true,
options: props.options,
});
const loading = ref(false);
@ -76,7 +89,11 @@ export default defineComponent(
}
if (val.trim() === '') {
// 如果搜索关键字为空,则直接返回所有数据
filterOptions.value = [...remoteOriginOptions.value];
filterOptions.value = remoteOriginOptions.value.map((e) => ({
...e,
tooltipContent: typeof props.optionTooltipContent === 'function' ? props.optionTooltipContent(e) : e.label,
}));
calculateMaxTag();
return;
}
const highlightedKeyword = `<span class="text-[rgb(var(--primary-4))]">${val}</span>`;
@ -84,6 +101,7 @@ export default defineComponent(
.map((e) => {
const item = { ...e };
let hasMatch = false;
if (props.searchKeys) {
for (let i = 0; i < props.searchKeys.length; i++) {
// 遍历传入的搜索字段
const key = props.searchKeys[i];
@ -93,13 +111,16 @@ export default defineComponent(
item[key] = e[key].replace(new RegExp(val, 'gi'), highlightedKeyword); // 高亮关键字替换
}
}
}
if (hasMatch) {
return item;
}
return null;
})
.filter((e) => e) as SelectOptionData[];
calculateMaxTag();
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
} finally {
loading.value = false;
@ -111,31 +132,92 @@ export default defineComponent(
? h('div', { innerHTML: props.optionLabelRender(item) })
: item.label;
// 半选状态
const indeterminate = computed(() => {
if (props.multiple && Array.isArray(innerValue.value)) {
return innerValue.value.length > 0 && innerValue.value.length < filterOptions.value.length;
}
return false;
});
const isSelectAll = computed({
get: () => {
if (props.multiple && Array.isArray(innerValue.value)) {
return innerValue.value.length === filterOptions.value.length;
}
return false;
},
set: (val) => val,
});
function handleSelectAllChange(val: boolean) {
isSelectAll.value = val;
if (val) {
innerValue.value = filterOptions.value.map((item) => item[props.valueKey || 'value']);
emit('update:modelValue', innerValue.value);
} else {
innerValue.value = [];
emit('update:modelValue', []);
}
}
const selectSlots = () => {
const _slots: MsSearchSelectSlots = {
default: () =>
filterOptions.value.map((item) => (
<a-tooltip content={item.tooltipContent} mouse-enter-delay={500}>
<a-option key={item.id} value={item[props.valueKey || 'value']}>
<div class="one-line-text" style={getOptionComputedStyle.value}>
{optionItemLabelRender(item)}
</div>
</a-option>
</a-tooltip>
)),
};
if (props.hasAllSelect) {
_slots.header = () => (
<div class="arco-select-option mb-[4px] h-[30px] rounded-[var(--border-radius-small)] !p-[3px_8px]">
<a-checkbox
model-value={isSelectAll.value}
indeterminate={indeterminate.value}
class="w-full"
onChange={handleSelectAllChange}
>
{t('common.allSelect')}
</a-checkbox>
</div>
);
}
if (slots.header) {
_slots.header = slots.header;
}
if (slots.footer) {
_slots.footer = slots.footer;
}
return _slots;
};
onBeforeMount(() => {
handleUserSearch('');
});
return () => (
<a-select
ref={selectRef}
default-value={innerValue}
placeholder={t(props.placeholder || '')}
allow-clear={props.allowClear}
allow-search
allow-search={props.allowSearch}
filter-option={false}
loading={loading.value}
multiple={props.multiple}
max-tag-count={maxTagCount.value}
onUpdate:model-value={(value: ModelType) => emit('update:modelValue', value)}
onInputValueChange={debounce(handleUserSearch, 300)}
>
{{
prefix: () => t(props.prefix || ''),
default: () =>
filterOptions.value.map((item) => (
<a-tooltip content={item.tooltipContent} mouse-enter-delay={500}>
<a-option key={item.id} value={item.value}>
{optionItemLabelRender(item)}
</a-option>
</a-tooltip>
)),
...selectSlots(),
}}
</a-select>
);
@ -145,16 +227,20 @@ export default defineComponent(
props: [
'mode',
'modelValue',
'allowSearch',
'allowClear',
'placeholder',
'prefix',
'searchKeys',
'valueKey',
'options',
'optionLabelRender',
'remoteFieldsMap',
'remoteExtraParams',
'remoteFunc',
'optionTooltipContent',
'prefix',
'hasAllSelect',
'multiple',
],
emits: ['update:modelValue'],
}

View File

@ -39,7 +39,7 @@
}
padding: 0 4px;
font-size: 1rem;
font-size: 14px;
border-radius: var(--border-radius-mini);
line-height: 22px;
}

View File

@ -1,70 +0,0 @@
<template>
<div :class="`ms-button ms-button-${props.type} ms-button--${props.status}`" @click="clickHandler">
<slot></slot>
</div>
</template>
<script setup lang="ts">
const props = withDefaults(
defineProps<{
type?: 'text' | 'icon' | 'button';
status?: 'primary' | 'danger' | 'secondary';
}>(),
{
type: 'text',
status: 'primary',
}
);
const emit = defineEmits(['click']);
function clickHandler() {
emit('click');
}
</script>
<style lang="less" scoped>
.ms-button {
@apply flex cursor-pointer items-center align-middle;
padding: 0 4px;
font-size: 1rem;
border-radius: var(--border-radius-mini);
line-height: 22px;
}
.ms-button-text {
@apply p-0;
color: rgb(var(--primary-5));
}
.ms-button-icon {
padding: 4px;
color: var(--color-text-4);
&:hover {
color: rgb(var(--primary-5));
background-color: rgb(var(--primary-9));
.arco-icon {
color: rgb(var(--primary-5));
}
}
}
.ms-button--secondary {
color: var(--color-text-2);
&:not(.ms-button-text):hover {
background-color: var(--color-text-n8);
}
}
.ms-button--primary {
color: rgb(var(--primary-5));
&:not(.ms-button-text):hover {
background-color: rgb(var(--primary-9));
}
}
.ms-button--danger {
color: rgb(var(--danger-6));
&:not(.ms-button-text):hover {
color: rgb(var(--danger-6));
background-color: rgb(var(--danger-1));
}
}
</style>

View File

@ -5,6 +5,7 @@
'ms-card',
'relative',
'h-full',
props.isFullscreen ? 'ms-card--no-radius' : '',
props.autoHeight ? '' : 'min-h-[500px]',
props.noContentPadding ? 'ms-card--noContentPadding' : 'p-[24px]',
]"
@ -15,17 +16,8 @@
</div>
<a-divider v-if="!props.simple" class="mb-[16px]" />
<div class="ms-card-container">
<a-scrollbar
class="pr-[5px]"
:style="{
overflow: 'auto',
width: props.otherWidth
? `calc(100vw - ${menuWidth}px - ${props.otherWidth}px)`
: `calc(100vw - ${menuWidth}px - 58px)`,
height: props.autoHeight ? 'auto' : `calc(100vh - ${cardOverHeight}px)`,
}"
>
<div :style="{ minWidth: `${props.minWidth || 1000}px` }">
<a-scrollbar class="pr-[5px]" :style="getComputedContentStyle">
<div class="relative h-full w-full" :style="{ minWidth: `${props.minWidth || 1000}px` }">
<slot></slot>
</div>
</a-scrollbar>
@ -72,10 +64,11 @@
specialHeight: number; //
hideBack: boolean; //
autoHeight: boolean; //
otherWidth?: number; //
minWidth?: number; //
otherWidth: number; //
minWidth: number; //
hasBreadcrumb: boolean; //
noContentPadding: boolean; // padding
isFullscreen?: boolean; //
handleBack: () => void; //
}>
>(),
@ -117,6 +110,23 @@
return 246 + _specialHeight;
});
const getComputedContentStyle = computed(() => {
if (props.isFullscreen) {
return {
overflow: 'auto',
width: 'calc(100vw - 58px)',
height: 'auto',
};
}
return {
overflow: 'auto',
width: props.otherWidth
? `calc(100vw - ${menuWidth.value}px - ${props.otherWidth}px)`
: `calc(100vw - ${menuWidth.value}px - 58px)`,
height: props.autoHeight ? 'auto' : `calc(100vh - ${cardOverHeight.value}px)`,
};
});
function back() {
if (typeof props.handleBack === 'function') {
props.handleBack();
@ -128,7 +138,7 @@
<style lang="less" scoped>
.ms-card {
@apply overflow-hidden bg-white;
@apply relative overflow-hidden bg-white;
border-radius: var(--border-radius-large);
box-shadow: 0 0 10px rgb(120 56 135 / 5%);
@ -158,4 +168,7 @@
}
}
}
.ms-card--no-radius {
border-radius: 0;
}
</style>

View File

@ -44,6 +44,9 @@
</a-scrollbar>
<template #footer>
<slot name="footer">
<div class="flex items-center justify-between">
<slot name="footerLeft"></slot>
<div class="ml-auto flex gap-[12px]">
<a-button :disabled="props.okLoading" @click="handleCancel">
{{ t(props.cancelText || 'ms.drawer.cancel') }}
</a-button>
@ -53,6 +56,8 @@
<a-button type="primary" :loading="props.okLoading" @click="handleOk">
{{ t(props.okText || 'ms.drawer.ok') }}
</a-button>
</div>
</div>
</slot>
</template>
</a-drawer>

View File

@ -154,14 +154,6 @@
initScrollListener();
});
}
if (props.data.length > 0) {
nextTick(() => {
//
const listContent = listRef.value?.$el.querySelector('.arco-list-content');
const { scrollTop, scrollHeight, clientHeight } = listContent;
isArrivedBottom.value = scrollHeight - clientHeight - scrollTop < props.itemHeight;
});
}
},
{
immediate: true,

View File

@ -84,12 +84,6 @@
listContent.addEventListener('scroll', listenScroll);
});
}
nextTick(() => {
//
const listContent = listRef.value?.$el.querySelector('.arco-list-content');
const { scrollTop, scrollHeight, clientHeight } = listContent;
isArrivedBottom.value = scrollHeight - clientHeight - scrollTop < 20;
});
});
function handleReachBottom() {

View File

@ -2,7 +2,7 @@
<div class="navbar">
<div class="left-side">
<a-space>
<div class="flex max-w-[145px] items-center overflow-hidden">
<div class="one-line-text flex max-w-[145px] items-center">
<img :src="props.logo" class="mr-[4px] h-[32px] w-[32px]" />
{{ props.name }}
</div>

View File

@ -225,6 +225,20 @@ export const pathMap: PathMapItem[] = [
permission: [],
level: MENU_LEVEL[2],
},
{
key: 'PROJECT_MANAGEMENT_MESSAGE_MANAGEMENT', // 项目管理-消息管理
locale: 'menu.projectManagement.messageManagement',
route: RouteEnum.PROJECT_MANAGEMENT_MESSAGE_MANAGEMENT,
permission: [],
level: MENU_LEVEL[2],
},
{
key: 'PROJECT_MANAGEMENT_MESSAGE_MANAGEMENT_EDIT', // 项目管理-消息管理-编辑
locale: 'menu.projectManagement.messageManagementEdit',
route: RouteEnum.PROJECT_MANAGEMENT_MESSAGE_MANAGEMENT_EDIT,
permission: [],
level: MENU_LEVEL[2],
},
],
},
];

View File

@ -18,6 +18,8 @@ export enum PerformanceTestRouteEnum {
export enum ProjectManagementRouteEnum {
PROJECT_MANAGEMENT = 'projectManagement',
PROJECT_MANAGEMENT_FILE_MANAGEMENT = 'projectManagementFileManageMent',
PROJECT_MANAGEMENT_MESSAGE_MANAGEMENT = 'projectManagementMessageManagement',
PROJECT_MANAGEMENT_MESSAGE_MANAGEMENT_EDIT = 'projectManagementMessageManagementEdit',
PROJECT_MANAGEMENT_LOG = 'projectManagementLog',
PROJECT_MANAGEMENT_PERMISSION = 'projectManagementPermission',
PROJECT_MANAGEMENT_PERMISSION_BASIC_INFO = 'projectManagementPermissionBasicInfo',

View File

@ -31,6 +31,13 @@ export default function useContainerShadow(options: ContainerShadowOptions) {
}
}
function calculateArrivedPosition(listContent: HTMLElement) {
const { scrollTop, scrollHeight, clientHeight } = listContent;
const scrollBottom = scrollHeight - clientHeight - scrollTop;
isArrivedTop.value = scrollTop < options.overHeight;
isArrivedBottom.value = scrollBottom < options.overHeight;
}
/**
*
* @param event
@ -38,16 +45,14 @@ export default function useContainerShadow(options: ContainerShadowOptions) {
function listenScroll(event: Event) {
if (event.target) {
const listContent = event.target as HTMLElement;
const { scrollTop, scrollHeight, clientHeight } = listContent;
const scrollBottom = scrollHeight - clientHeight - scrollTop;
isArrivedTop.value = scrollTop < options.overHeight;
isArrivedBottom.value = scrollBottom < options.overHeight;
calculateArrivedPosition(listContent);
}
}
function initScrollListener() {
if (!isInitListener.value && containerRef.value) {
containerRef.value.addEventListener('scroll', listenScroll);
calculateArrivedPosition(containerRef.value); // 初始化计算一次,因为初始化的时候内容可能超出可视区域了
isInitListener.value = true;
}
}

View File

@ -0,0 +1,97 @@
import { Ref, computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { calculateMaxDepth } from '@/utils';
import type { CascaderOption, SelectOptionData } from '@arco-design/web-vue';
export interface UseSelectOption {
selectRef: Ref; // 选择器 ref 对象
selectVal: Ref; // 选择器的 v-model
isCascade?: boolean; // 是否级联选择器
panelWidth?: number; // 级联选择器的下拉面板宽度
options?: CascaderOption[] | SelectOptionData[]; // 级联选择器的选项
}
/**
* Select
* @param selectRef ref
* @param selectVal v-model
*/
export default function useSelect(config: UseSelectOption) {
const maxTagCount = ref(1);
const selectWidth = ref(0);
const selectViewInner = ref<HTMLElement | null>(null); // 输入框内容容器 DOM
const cascadeDeep = ref(0); // 级联选择器的深度
/**
*
*/
function calculateMaxTag() {
nextTick(() => {
if (config.selectRef.value && selectViewInner.value && Array.isArray(config.selectVal.value)) {
if (maxTagCount.value !== 0 && config.selectVal.value.length > maxTagCount.value) return; // 已经超过最大数量的展示,不需要再计算
let lastWidth = selectViewInner.value?.getBoundingClientRect().width || 0;
const tags = selectViewInner.value.querySelectorAll('.arco-tag');
let tagCount = 0;
for (let i = 0; i < tags.length; i++) {
const tagWidth = Number(getComputedStyle(tags[i]).width.replace('px', ''));
if (lastWidth < tagWidth + 65) {
// 65px 是“+N”的标签宽度+聚焦输入框的宽度
lastWidth = 0; // 当剩余宽度已经放不下刚添加的标签,则剩余宽度置为 0避免后面再进行计算
break;
} else {
tagCount += 1;
lastWidth = lastWidth - tagWidth - 5;
}
}
maxTagCount.value = tagCount === 0 ? 1 : tagCount;
}
});
}
const getOptionComputedStyle = computed(() => {
if (config.isCascade) {
// 减去 80px 是为了防止溢出,因为会出现单选框、右侧箭头
return {
width:
cascadeDeep.value <= 2
? `${selectWidth.value / cascadeDeep.value - 80 - cascadeDeep.value * 4}px`
: `${config.panelWidth}px` || '150px',
};
}
// 减去 60px 是为了防止溢出,因为有复选框、边距等
return {
width: `${selectWidth.value - 60}px`,
};
});
watch(
() => config.options,
(arr) => {
if (config.isCascade && arr && arr.length > 0) {
// 级联选择器的选项发生变化时,重新计算最大深度
cascadeDeep.value = calculateMaxDepth(arr);
}
},
{
immediate: true,
deep: true,
}
);
onMounted(() => {
if (config.selectRef.value) {
selectWidth.value = config.selectRef.value.$el.nextElementSibling.getBoundingClientRect().width;
selectViewInner.value = config.selectRef.value.$el.nextElementSibling.querySelector('.arco-select-view-inner');
}
});
onBeforeUnmount(() => {
selectViewInner.value = null; // 释放 DOM 引用
});
return {
maxTagCount,
getOptionComputedStyle, // 获取选择器选项的样式
calculateMaxTag, // 在需要的时机调用此函数以计算最大标签数量,一般在 select 的 change 事件中调用
};
}

View File

@ -55,4 +55,7 @@ export default {
'common.internal': 'Internal',
'common.custom': 'Custom',
'common.preview': 'Preview',
'common.fullScreen': 'Full Screen',
'common.offFullScreen': 'Exit',
'common.allSelect': 'Select All',
};

View File

@ -28,6 +28,8 @@ export default {
'menu.performanceTest': 'Performance Test',
'menu.projectManagement': 'Project',
'menu.projectManagement.fileManagement': 'File Management',
'menu.projectManagement.messageManagement': 'Message Management',
'menu.projectManagement.messageManagementEdit': 'Update Template',
'menu.featureTest.featureCase': 'Feature Case',
'meun.workstation': 'Workstation',
'menu.loadTest': 'Performance Test',

View File

@ -57,4 +57,7 @@ export default {
'common.internal': '内部',
'common.custom': '自定义',
'common.preview': '预览',
'common.fullScreen': '全屏',
'common.offFullScreen': '退出全屏',
'common.allSelect': '全选',
};

View File

@ -31,6 +31,8 @@ export default {
'menu.projectManagement': '项目管理',
'menu.projectManagement.log': '日志',
'menu.projectManagement.fileManagement': '文件管理',
'menu.projectManagement.messageManagement': '消息管理',
'menu.projectManagement.messageManagementEdit': '更新模板',
'menu.featureTest.featureCase': '功能用例',
'menu.projectManagement.projectPermission': '项目与权限',
'menu.settings': '系统设置',

View File

@ -0,0 +1,35 @@
// 支持添加机器人的平台类型
export type ProjectRobotPlatformCanEdit = 'DING_TALK' | 'LARK' | 'WE_COM' | 'CUSTOM';
// 机器人全平台类型
export type ProjectRobotPlatform = ProjectRobotPlatformCanEdit | 'IN_SITE' | 'MAIL';
// 钉钉机器人类型
export type ProjectRobotDingTalkType = 'CUSTOM' | 'ENTERPRISE';
export interface RobotCommon {
name: string;
platform: ProjectRobotPlatformCanEdit;
webhook: string;
enable: boolean;
description?: string;
}
export interface RobotAddParams extends RobotCommon {
projectId: string;
type?: ProjectRobotDingTalkType;
appKey?: string;
appSecret?: string;
}
export interface RobotEditParams extends RobotAddParams {
id: string;
}
export interface RobotItem extends Omit<RobotEditParams, 'platform'> {
platform: ProjectRobotPlatform;
createUser: string;
createTime: number;
updateUser?: string;
updateTime?: number;
}

View File

@ -90,6 +90,38 @@ const ProjectManagement: AppRouteRecordRaw = {
isTopMenu: true,
},
},
// 消息管理
{
path: 'messageManagement',
name: ProjectManagementRouteEnum.PROJECT_MANAGEMENT_MESSAGE_MANAGEMENT,
component: () => import('@/views/project-management/messageManagement/index.vue'),
meta: {
locale: 'menu.projectManagement.messageManagement',
roles: ['*'],
isTopMenu: true,
},
},
{
path: 'messageManagementEdit',
name: ProjectManagementRouteEnum.PROJECT_MANAGEMENT_MESSAGE_MANAGEMENT_EDIT,
component: () => import('@/views/project-management/messageManagement/edit.vue'),
meta: {
locale: 'menu.projectManagement.messageManagementEdit',
roles: ['*'],
breadcrumbs: [
{
name: ProjectManagementRouteEnum.PROJECT_MANAGEMENT_MESSAGE_MANAGEMENT,
locale: 'menu.projectManagement.messageManagement',
},
{
name: ProjectManagementRouteEnum.PROJECT_MANAGEMENT_MESSAGE_MANAGEMENT_EDIT,
locale: 'menu.projectManagement.messageManagementEdit',
editTag: 'id',
editLocale: 'menu.projectManagement.messageManagementEdit',
},
],
},
},
// 项目日志
{
path: 'log',

View File

@ -35,7 +35,7 @@
<a-input
v-else
v-model:model-value="form.field"
:max-length="props.fieldConfig?.maxLength"
:max-length="props.fieldConfig?.maxLength || 50"
:placeholder="props.fieldConfig?.placeholder || t('project.fileManagement.namePlaceholder')"
class="w-[245px]"
@press-enter="beforeConfirm(undefined)"
@ -48,7 +48,7 @@
</template>
<script setup lang="ts">
import { onBeforeMount, ref, watch } from 'vue';
import { ref, watch } from 'vue';
import { useI18n } from '@/hooks/useI18n';
import { Message } from '@arco-design/web-vue';

View File

@ -71,6 +71,9 @@
</template>
<script setup lang="ts">
/**
* @description 项目管理-文件管理
*/
import { computed, ref } from 'vue';
import { useI18n } from '@/hooks/useI18n';
import MsSplitBox from '@/components/pure/ms-split-box/index.vue';

View File

@ -1,3 +0,0 @@
<template> ProjectManagement is waiting for development </template>
<script setup></script>

View File

@ -0,0 +1,71 @@
<template>
<MsCard ref="fullRef" :special-height="132" :is-fullscreen="isFullscreen" simple>
<div class="flex items-center justify-between">
<div class="font-medium text-[var(--color-text-000)]">{{ t('project.messageManagement.config') }}</div>
<div>
<MsSelect
v-model:model-value="robotFilters"
:options="robotOptions"
:allow-search="false"
class="mr-[8px] w-[240px]"
:prefix="t('project.messageManagement.robot')"
value-key="id"
:multiple="true"
:has-all-select="true"
>
<template #footer>
<div class="mb-[6px] mt-[4px] p-[3px_8px]">
<MsButton type="text" @click="emit('createRobot')">
<MsIcon type="icon-icon_add_outlined" class="mr-[8px] text-[rgb(var(--primary-6))]" size="14" />
{{ t('project.messageManagement.createBot') }}
</MsButton>
</div>
</template>
</MsSelect>
<a-button type="outline" class="arco-btn-outline--secondary px-[5px]" @click="toggle">
<template #icon>
<MsIcon
:type="isFullscreen ? 'icon-icon_off_screen' : 'icon-icon_full_screen_one'"
class="text-[var(--color-text-4)]"
size="14"
/>
</template>
{{ t(isFullscreen ? 'common.offFullScreen' : 'common.fullScreen') }}
</a-button>
</div>
</div>
</MsCard>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useFullscreen } from '@vueuse/core';
import { useI18n } from '@/hooks/useI18n';
import MsCard from '@/components/pure/ms-card/index.vue';
import MsSelect from '@/components/business/ms-select';
import MsIcon from '@/components/pure/ms-icon-font/index.vue';
import MsButton from '@/components/pure/ms-button/index.vue';
import type { SelectOptionData } from '@arco-design/web-vue';
const emit = defineEmits(['createRobot']);
const { t } = useI18n();
const robotFilters = ref([]);
const robotOptions = ref<SelectOptionData[]>([
{
label: '机器人1',
id: 'robot1',
},
{
label: '机器人2',
id: 'robot2',
},
]);
const fullRef = ref<HTMLElement | null>();
const { isFullscreen, toggle } = useFullscreen(fullRef);
</script>
<style lang="less" scoped></style>

View File

@ -0,0 +1,547 @@
<template>
<div>
<MsCard :min-width="1060" :special-height="132" simple>
<a-alert v-if="!getIsVisited()" :show-icon="false" class="mb-[16px]" closable @close="addVisited">
{{ t('project.messageManagement.botListTips') }}
<template #close-element>
<span class="text-[14px]">{{ t('project.messageManagement.notRemind') }}</span>
</template>
</a-alert>
<a-button type="primary" class="mb-[16px]" @click="handleCreateClick">
{{ t('project.messageManagement.createBot') }}
</a-button>
<div
ref="robotListContainerRef"
:class="['robot-list-container', containerStatusClass]"
:style="{ height: `calc(100% - ${getIsVisited() ? 48 : 104}px)` }"
>
<div ref="robotListRef" class="robot-list">
<div v-for="robot of botList" :key="robot.id" class="robot-card">
<div class="flex">
<MsIcon
:type="IconMap[robot.platform]"
class="mr-[8px] h-[40px] w-[40px] bg-[var(--color-text-n9)] p-[8px] text-[rgb(var(--primary-5))]"
/>
<div class="flex flex-col">
<div class="font-medium text-[var(--color-text-1)]">{{ robot.name }}</div>
<div v-if="['IN_SITE', 'MAIL'].includes(robot.platform)" class="text-[12px] text-[var(--color-text-4)]">
{{ robot.description }}
</div>
<div v-else class="flex items-center text-[12px] text-[var(--color-text-4)]">
<div class="mr-[16px]">
<a-tooltip position="tl" mini :content="robot.createUser">{{ robot.createUser }}</a-tooltip>
{{
`${t('project.messageManagement.createAt')} ${dayjs(robot.createTime).format(
'YYYY-MM-DD HH:mm:ss'
)}`
}}
</div>
<div>
{{
`${robot.updateUser} ${t('project.messageManagement.updateAt')} ${dayjs(robot.updateTime).format(
'YYYY-MM-DD HH:mm:ss'
)}`
}}
</div>
</div>
</div>
</div>
<div class="flex items-center justify-between leading-[24px]">
<div v-if="!['IN_SITE', 'MAIL'].includes(robot.platform)">
<a-button
type="outline"
size="mini"
class="arco-btn-outline--secondary mr-[8px]"
@click="editRobot(robot)"
>
{{ t('common.edit') }}
</a-button>
<a-button type="outline" size="mini" class="arco-btn-outline--secondary" @click="deleteRobot(robot)">{{
t('common.delete')
}}</a-button>
</div>
<a-switch
v-model:model-value="robot.enable"
size="small"
class="ml-auto"
@change="handleEnableIntercept(robot)"
/>
</div>
</div>
</div>
</div>
</MsCard>
<MsDrawer
v-model:visible="showDetailDrawer"
:width="960"
:title="t('project.messageManagement.createBot')"
:ok-loading="drawerLoading"
:show-continue="!isEdit"
:ok-text="isEdit ? t('common.update') : t('common.create')"
@confirm="handleDrawerConfirm"
@continue="handleDrawerConfirm(true)"
@cancel="handleDrawerCancel"
>
<a-form ref="robotFormRef" :model="robotForm" layout="vertical">
<a-form-item :label="t('project.messageManagement.choosePlatform')" field="platform">
<div class="grid w-full grid-cols-4 gap-[16px]">
<div
v-for="platform of editPlatformList"
:key="platform.key"
:class="['platform-card', robotForm.platform === platform.key ? 'platform-card--active' : '']"
@click="robotForm.platform = platform.key"
>
<MsIcon
:type="IconMap[platform.key]"
class="h-[32px] w-[32px] rounded-[var(--border-radius-small)] bg-[var(--color-text-n9)] p-[4px]"
/>
{{ platform.name }}
</div>
<div
:class="['platform-card-custom', robotForm.platform === 'CUSTOM' ? 'platform-card--active' : '']"
@click="robotForm.platform = 'CUSTOM'"
>
<MsIcon type="icon-icon_add_outlined" size="14" class="text-[rgb(var(--primary-6))]" />
<div class="ml-[8px] text-[rgb(var(--primary-6))]">{{ t('project.messageManagement.CUSTOM') }}</div>
<div class="text-[var(--color-text-n4)]">{{ t('project.messageManagement.business') }}</div>
</div>
</div>
</a-form-item>
<a-form-item
:label="t('project.messageManagement.name')"
field="name"
asterisk-position="end"
:rules="[{ required: true, message: t('project.messageManagement.nameRequired') }]"
required
>
<a-input
v-model:model-value="robotForm.name"
:max-length="250"
:placeholder="t('project.messageManagement.namePlaceholder')"
show-word-limit
allow-clear
></a-input>
</a-form-item>
<a-form-item
v-if="robotForm.platform === 'DING_TALK'"
:label="t('project.messageManagement.dingTalkType')"
field="type"
>
<a-radio-group v-model:model-value="robotForm.type" type="button">
<a-radio value="CUSTOM">
{{ t('project.messageManagement.CUSTOM') }}
</a-radio>
<a-radio value="ENTERPRISE">
{{ t('project.messageManagement.ENTERPRISE') }}
</a-radio>
</a-radio-group>
<template v-if="robotForm.type === 'CUSTOM'">
<MsFormItemSub :text="t('project.messageManagement.dingTalkCustomTip')" :show-fill-icon="false">
<MsButton
type="text"
class="ml-[8px] !text-[12px]"
@click="openExternalLink('https://open.dingtalk.com/document/orgapp/custom-robot-access')"
>
<MsIcon type="icon-icon_share" size="12" class="mr-[4px]" />
{{ t('project.messageManagement.noticeDetail') }}
</MsButton>
</MsFormItemSub>
<a-alert :title="t('project.messageManagement.dingTalkCustomTitle')">
<div class="text-[var(--color-text-2)]">{{ t('project.messageManagement.dingTalkCustomContent1') }}</div>
<div class="text-[var(--color-text-2)]">
{{ t('project.messageManagement.dingTalkCustomContent2', { at: '@' }) }}
</div>
<div class="text-[var(--color-text-2)]">{{ t('project.messageManagement.dingTalkCustomContent3') }}</div>
</a-alert>
</template>
<template v-else>
<MsFormItemSub :text="t('project.messageManagement.dingTalkEnterpriseTip')" :show-fill-icon="false">
<MsButton
type="text"
class="ml-[8px] !text-[12px]"
@click="
openExternalLink(
'https://open.dingtalk.com/document/orgapp/the-creation-and-installation-of-the-application-robot-in-the'
)
"
>
<MsIcon type="icon-icon_share" size="12" class="mr-[4px]" />
{{ t('project.messageManagement.helpDoc') }}
</MsButton>
</MsFormItemSub>
<a-alert :title="t('project.messageManagement.dingTalkEnterpriseTitle')">
<div class="text-[var(--color-text-2)]">
{{ t('project.messageManagement.dingTalkEnterpriseContent1', { at: '@' }) }}
</div>
<div class="text-[var(--color-text-2)]">
{{ t('project.messageManagement.dingTalkEnterpriseContent2') }}
</div>
</a-alert>
</template>
</a-form-item>
<template v-if="robotForm.platform === 'DING_TALK' && robotForm.type === 'ENTERPRISE'">
<a-form-item
:label="t('project.messageManagement.appKey')"
field="appKey"
asterisk-position="end"
:rules="[{ required: true, message: t('project.messageManagement.appKeyRequired') }]"
required
>
<a-input
v-model:model-value="robotForm.appKey"
:placeholder="t('project.messageManagement.appKeyPlaceholder')"
allow-clear
></a-input>
</a-form-item>
<a-form-item
:label="t('project.messageManagement.appSecret')"
field="appSecret"
asterisk-position="end"
:rules="[{ required: true, message: t('project.messageManagement.appSecretRequired') }]"
required
>
<a-input
v-model:model-value="robotForm.appSecret"
:placeholder="t('project.messageManagement.appSecretPlaceholder')"
allow-clear
></a-input>
</a-form-item>
</template>
<a-form-item
:label="t('project.messageManagement.webhook')"
field="webhook"
asterisk-position="end"
:rules="[
{
required: true,
message: t('project.messageManagement.webhookRequired'),
},
]"
required
>
<a-input
v-model:model-value="robotForm.webhook"
:placeholder="
t(
robotForm.platform === 'CUSTOM'
? 'project.messageManagement.webhookCustomPlaceholder'
: 'project.messageManagement.webhookPlaceholder',
{
type: t(`project.messageManagement.${robotForm.platform}`),
}
)
"
allow-clear
></a-input>
</a-form-item>
</a-form>
<template #footerLeft>
<a-switch v-model:model-value="robotForm.enable" size="small" class="mr-[4px]"></a-switch>
{{ t('project.messageManagement.status') }}
<a-tooltip position="tl" mini>
<template #content>
<div>{{ t('project.messageManagement.statusTipOn') }}</div>
<div>{{ t('project.messageManagement.statusTipOff') }}</div>
</template>
<icon-question-circle class="ml-[4px] text-[var(--color-text-4)] hover:text-[rgb(var(--primary-6))]" />
</a-tooltip>
</template>
</MsDrawer>
</div>
</template>
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue';
import dayjs from 'dayjs';
import { FormInstance, Message, ValidatedError } from '@arco-design/web-vue';
import { useI18n } from '@/hooks/useI18n';
import MsCard from '@/components/pure/ms-card/index.vue';
import MsIcon from '@/components/pure/ms-icon-font/index.vue';
import MsDrawer from '@/components/pure/ms-drawer/index.vue';
import MsFormItemSub from '@/components/business/ms-form-item-sub/index.vue';
import MsButton from '@/components/pure/ms-button/index.vue';
import useModal from '@/hooks/useModal';
import useVisit from '@/hooks/useVisit';
import {
addRobot,
updateRobot,
getRobotList,
getRobotDetail,
toggleRobot,
} from '@/api/modules/project-management/messageManagement';
import useAppStore from '@/store/modules/app';
import { characterLimit } from '@/utils';
import useContainerShadow from '@/hooks/useContainerShadow';
import type {
ProjectRobotPlatform,
RobotItem,
RobotAddParams,
RobotEditParams,
ProjectRobotPlatformCanEdit,
} from '@/models/projectManagement/message';
const props = defineProps<{
activeTab: string;
}>();
const { t } = useI18n();
const appStore = useAppStore();
const { openModal } = useModal();
const botList = ref<RobotItem[]>([]);
const IconMap: Record<ProjectRobotPlatform, string> = {
IN_SITE: 'icon-icon_bot1',
MAIL: 'icon-icon_mail',
LARK: 'icon-logo_lark',
DING_TALK: 'icon-logo_dingtalk',
WE_COM: 'icon-logo_wechat-work',
CUSTOM: 'icon-icon_bot1',
};
const robotListContainerRef = ref<HTMLDivElement | null>(null);
const robotListRef = ref<HTMLDivElement | null>(null);
const { containerStatusClass, setContainer, initScrollListener } = useContainerShadow({
overHeight: 20,
containerClassName: 'robot-list-container',
});
async function initRobotList() {
const res = await getRobotList(appStore.currentProjectId);
botList.value = res as RobotItem[];
nextTick(() => {
if (robotListRef.value) {
setContainer(robotListRef.value);
initScrollListener();
}
});
}
watch(
() => props.activeTab,
(value) => {
if (value === 'botList') {
initRobotList();
}
},
{ immediate: true }
);
const visitedKey = 'messageManagementRobotListTip';
const { addVisited, getIsVisited } = useVisit(visitedKey);
const showDetailDrawer = ref(false);
const drawerLoading = ref(false);
const defaultRobot = {
id: '',
projectId: appStore.currentProjectId,
name: '',
platform: 'WE_COM',
enable: false,
webhook: '',
type: 'CUSTOM',
appKey: '',
appSecret: '',
} as RobotAddParams | RobotEditParams | RobotItem;
const robotForm = ref({ ...defaultRobot });
const robotFormRef = ref<FormInstance | null>(null);
const isEdit = ref(false);
const editPlatformList = ref<{ name: string; key: ProjectRobotPlatformCanEdit }[]>([
{
name: t('project.messageManagement.WE_COM'),
key: 'WE_COM',
},
{
name: t('project.messageManagement.DING_TALK'),
key: 'DING_TALK',
},
{
name: t('project.messageManagement.LARK'),
key: 'LARK',
},
]);
function handleCreateClick() {
isEdit.value = false;
showDetailDrawer.value = true;
robotForm.value = { ...defaultRobot };
}
function editRobot(robot: RobotItem) {
isEdit.value = true;
robotForm.value = { ...robot };
showDetailDrawer.value = true;
}
function openExternalLink(url: string) {
window.open(url);
}
/**
* 启用/禁用机器人
* @param robot 机器人信息
*/
function handleEnableIntercept(robot: RobotItem) {
robot.enable = !robot.enable;
openModal({
type: robot.enable ? 'warning' : 'info',
title: t(robot.enable ? 'project.messageManagement.disableTitle' : 'project.messageManagement.enableTitle', {
name: characterLimit(robot.name),
}),
content: t(robot.enable ? 'project.messageManagement.disableContent' : 'project.messageManagement.enableContent'),
okText: t(robot.enable ? 'project.messageManagement.disableConfirm' : 'project.messageManagement.enableConfirm'),
cancelText: t('common.cancel'),
maskClosable: false,
onBeforeOk: async () => {
try {
Message.success(
t(robot.enable ? 'project.messageManagement.disableSuccess' : 'project.messageManagement.enableSuccess')
);
initRobotList();
} catch (error) {
console.log(error);
}
},
hideCancel: false,
});
}
/**
* 删除机器人
* @param robot 机器人信息
*/
function deleteRobot(robot: RobotItem) {
openModal({
type: 'error',
title: t('project.messageManagement.deleteTitle', { name: robot.name }),
content: t('project.messageManagement.deleteContent'),
okText: t('common.confirmDelete'),
cancelText: t('common.cancel'),
okButtonProps: {
status: 'danger',
},
maskClosable: false,
onBeforeOk: async () => {
try {
Message.success(t('common.deleteSuccess'));
initRobotList();
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
}
},
hideCancel: false,
});
}
/**
* 保存机器人
* @param isContinue 是否继续添加
*/
async function saveRobot(isContinue: boolean) {
try {
drawerLoading.value = true;
const params = { ...robotForm.value };
switch (robotForm.value.platform) {
case 'WE_COM':
case 'LARK':
case 'CUSTOM':
params.type = undefined;
params.appKey = undefined;
params.appSecret = undefined;
break;
default:
break;
}
if (isEdit.value) {
await updateRobot(params as RobotEditParams);
Message.success(t('common.updateSuccess'));
showDetailDrawer.value = false;
} else {
await addRobot(params as RobotAddParams);
Message.success(t('common.addSuccess'));
robotFormRef.value?.resetFields();
robotForm.value = { ...defaultRobot };
if (!isContinue) {
showDetailDrawer.value = false;
}
}
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
} finally {
drawerLoading.value = false;
}
}
/**
* 处理抽屉确认
* @param isContinue 是否继续添加
*/
function handleDrawerConfirm(isContinue: boolean) {
robotFormRef.value?.validate(async (errors: Record<string, ValidatedError> | undefined) => {
if (!errors) {
saveRobot(isContinue);
}
});
}
function handleDrawerCancel() {
showDetailDrawer.value = false;
robotFormRef.value?.resetFields();
robotForm.value = { ...defaultRobot };
}
defineExpose({
createRobot: handleCreateClick,
});
</script>
<style lang="less" scoped>
.robot-list-container {
@apply relative;
.ms-container--shadow();
.robot-list {
@apply grid h-full overflow-y-auto;
.ms-scroll-bar();
padding: 16px;
grid-template-columns: repeat(2, minmax(128px, 1fr));
border-radius: var(--border-radius-small);
background-color: var(--color-text-n9);
gap: 16px;
.robot-card {
@apply flex flex-col bg-white;
padding: 24px;
max-height: 128px;
border-radius: var(--border-radius-small);
gap: 16px;
}
}
}
.platform-card,
.platform-card-custom {
@apply flex cursor-pointer items-center;
padding: 12px;
border: 1px solid var(--color-text-n8);
border-radius: var(--border-radius-small);
gap: 8px;
&:hover {
box-shadow: 0 4px 10px rgb(100 100 102 / 15%);
}
}
.platform-card--active {
border-color: rgb(var(--primary-5));
}
.platform-card-custom {
@apply border-dashed;
border-width: 2px;
gap: 0;
}
</style>

View File

@ -0,0 +1,11 @@
<template>
<div> </div>
</template>
<script setup lang="ts">
/**
* @description 项目管理-消息管理-编辑消息模板
*/
</script>
<style lang="less" scoped></style>

View File

@ -0,0 +1,37 @@
<template>
<MsTabCard v-model:active-tab="activeTab" :title="t('project.messageManagement')" :tab-list="tabList" />
<MessageList v-if="activeTab === 'config'" @create-robot="createRobot" />
<RobotList v-else-if="activeTab === 'botList'" ref="robotListRef" :active-tab="activeTab" />
</template>
<script setup lang="ts">
/**
* @description 项目管理-消息管理
*/
import { nextTick, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from '@/hooks/useI18n';
import MsTabCard from '@/components/pure/ms-tab-card/index.vue';
import RobotList from './components/robotList.vue';
import MessageList from './components/messageList.vue';
const route = useRoute();
const { t } = useI18n();
const activeTab = ref((route.query.tab as string) || 'config');
const tabList = [
{ key: 'config', title: t('project.messageManagement.config') },
{ key: 'botList', title: t('project.messageManagement.botList') },
];
const robotListRef = ref();
function createRobot() {
activeTab.value = 'botList';
nextTick(() => {
robotListRef.value?.createRobot();
});
}
</script>
<style lang="less" scoped></style>

View File

@ -0,0 +1 @@
export default {};

View File

@ -0,0 +1,60 @@
export default {
'project.messageManagement': '消息管理',
'project.messageManagement.config': '消息设置',
'project.messageManagement.botList': '机器人列表',
'project.messageManagement.robot': '机器人',
'project.messageManagement.botListTips':
'机器人的开启与关闭状态与消息设置列表机器人同步;如:站内信开启,消息列表页则展示站内信通知列',
'project.messageManagement.notRemind': '不再提醒',
'project.messageManagement.createBot': '创建机器人',
'project.messageManagement.createAt': '创建于',
'project.messageManagement.updateAt': '更新于',
'project.messageManagement.status': '状态',
'project.messageManagement.statusTipOn': '开启:在消息通知列表展示及使用',
'project.messageManagement.statusTipOff': '关闭:暂不使用改机器人',
'project.messageManagement.choosePlatform': '选择配置平台',
'project.messageManagement.WE_COM': '企业微信',
'project.messageManagement.DING_TALK': '钉钉',
'project.messageManagement.LARK': '飞书',
'project.messageManagement.CUSTOM': '自定义',
'project.messageManagement.business': '(企业版)',
'project.messageManagement.name': '机器人名称',
'project.messageManagement.nameRequired': '机器人名称不能为空',
'project.messageManagement.namePlaceholder': '请输入机器人名称',
'project.messageManagement.webhook': 'Webhook',
'project.messageManagement.webhookRequired': 'Webhook 不能为空',
'project.messageManagement.webhookPlaceholder': '在{type}群内,点击「机器人」可直接获取',
'project.messageManagement.webhookCustomPlaceholder': '请输入 webhook',
'project.messageManagement.dingTalkType': '机器人类型',
'project.messageManagement.ENTERPRISE': '企业内部应用',
'project.messageManagement.dingTalkCustomTip': '钉钉自定义机器人产品线下公告',
'project.messageManagement.noticeDetail': '公告详情',
'project.messageManagement.dingTalkCustomTitle': '添加钉钉自定义机器人场景注意事项',
'project.messageManagement.dingTalkCustomContent1':
'1. 若使用安全验证的钉钉机器人,请选择 “自定义关键词” 验证,关键词为 “消息通知”;',
'project.messageManagement.dingTalkCustomContent2': '2. 若使用 {at} 功能,接收人必须是机器人所在群的用户;',
'project.messageManagement.dingTalkCustomContent3': '3. 若使用手机通知,接收人手机号必须为钉钉企业所使用的手机号',
'project.messageManagement.expand': '展开',
'project.messageManagement.close': '收起',
'project.messageManagement.dingTalkEnterpriseTip': '添加企业内部应用文档',
'project.messageManagement.helpDoc': '帮助文档',
'project.messageManagement.dingTalkEnterpriseTitle': '添加企业内部应用机器人场景注意事项',
'project.messageManagement.dingTalkEnterpriseContent1': '1. 若使用 {at} 功能,接收人必须是机器人所在群的用户;',
'project.messageManagement.dingTalkEnterpriseContent2': '2. 若使用手机通知,接收人手机号必须为钉钉企业所使用的手机号',
'project.messageManagement.appKey': 'AppKey',
'project.messageManagement.appKeyPlaceholder': '打开帮助文档可直接获取',
'project.messageManagement.appKeyRequired': 'AppKey 不能为空',
'project.messageManagement.appSecret': 'AppSecret',
'project.messageManagement.appSecretPlaceholder': '打开帮助文档可直接获取',
'project.messageManagement.appSecretRequired': 'AppSecret 不能为空',
'project.messageManagement.disableTitle': '确定关闭 {name} 吗?',
'project.messageManagement.disableContent': '关闭后,将不在接收站内信通知,且不在消息列表页展示',
'project.messageManagement.disableConfirm': '确认关闭',
'project.messageManagement.disableSuccess': '关闭成功',
'project.messageManagement.enableTitle': '开启 {name}',
'project.messageManagement.enableContent': '开启后,站内信则显示在消息设置列表,需要手动设置通知类型',
'project.messageManagement.enableConfirm': '确认开启',
'project.messageManagement.enableSuccess': '开启成功',
'project.messageManagement.deleteTitle': '确认删除 {name} ',
'project.messageManagement.deleteContent': '删除机器人后将不再推送绑定的消息事件',
};

View File

@ -26,9 +26,9 @@
</template>
<template #operation="{ record }">
<div class="flex flex-row flex-nowrap">
<MsButton @click="showAuthDrawer(record)">{{ t('project.userGroup.viewAuth') }}</MsButton>
<MsButton class="!mr-0" @click="showAuthDrawer(record)">{{ t('project.userGroup.viewAuth') }}</MsButton>
<a-divider v-if="!record.internal" direction="vertical" />
<MsButton v-if="!record.internal" status="danger" @click="handleDelete(record)">{{
<MsButton v-if="!record.internal" class="!mr-0" status="danger" @click="handleDelete(record)">{{
t('common.delete')
}}</MsButton>
</div>
@ -112,7 +112,7 @@
updateOrAddProjectUserGroup,
} from '@/api/modules/project-management/usergroup';
import UserDrawer from './userDrawer.vue';
import MsButton from '@/components/pure/ms-button/not-mr.vue';
import MsButton from '@/components/pure/ms-button/index.vue';
import useModal from '@/hooks/useModal';
import { Message, ValidatedError } from '@arco-design/web-vue';
import MsDrawer from '@/components/pure/ms-drawer/index.vue';

View File

@ -2,7 +2,7 @@
<MsCard simple auto-height>
<div class="filter-box">
<div class="filter-item">
<MsSearchSelect
<MsSelect
v-model:model-value="operUser"
mode="remote"
placeholder="system.log.operatorPlaceholder"
@ -21,6 +21,7 @@
:option-label-render="
(item) => `${item.label}<span class='text-[var(--color-text-2)]'>${item.email}</span>`
"
allow-search
allow-clear
/>
</div>
@ -142,7 +143,7 @@
import MsBaseTable from '@/components/pure/ms-table/base-table.vue';
import MsButton from '@/components/pure/ms-button/index.vue';
import { MENU_LEVEL } from '@/config/pathMap';
import MsSearchSelect from '@/components/business/ms-search-select/index';
import MsSelect from '@/components/business/ms-select/index';
import useAppStore from '@/store/modules/app';
import type { CascaderOption, SelectOptionData } from '@arco-design/web-vue';
@ -439,6 +440,7 @@
title: 'system.log.operateName',
dataIndex: 'content',
slotName: 'content',
showTooltip: true,
},
{
title: 'system.log.time',
@ -548,3 +550,4 @@
}
}
</style>
@/components/business/ms-select/index

View File

@ -1,6 +1,6 @@
export default {
'system.log.operator': 'Operator',
'system.log.operatorPlaceholder': 'Please enter username/email to search',
'system.log.operatorPlaceholder': 'Enter username/email to search',
'system.log.operateTime': 'Operation time',
'system.log.operateRange': 'Operating range',
'system.log.operateType': 'Operation type',

View File

@ -1,6 +1,6 @@
export default {
'system.log.operator': '操作人',
'system.log.operatorPlaceholder': '输入用户名/邮箱搜索',
'system.log.operatorPlaceholder': '输入用户名/邮箱搜索',
'system.log.operateTime': '操作时间',
'system.log.operateRange': '操作范围',
'system.log.operateType': '操作类型',