feat(全局): 部分组件调整&bug修复

This commit is contained in:
baiqi 2024-02-02 10:11:43 +08:00 committed by 刘瑞斌
parent 59c2116b03
commit a8676365f6
108 changed files with 3554 additions and 934 deletions

Binary file not shown.

View File

@ -183,7 +183,7 @@ function createAxios(opt?: Partial<CreateAxiosOptions>) {
// authenticationScheme: 'Bearer',
authenticationScheme: '',
baseURL: `${window.location.origin}/${import.meta.env.VITE_API_BASE_URL as string}`,
timeout: 60 * 1000,
timeout: 120 * 1000,
headers: { 'Content-Type': ContentTypeEnum.JSON },
// 如果是form-data格式
// headers: { 'Content-Type': ContentTypeEnum.FORM_URLENCODED },

View File

@ -1,4 +1,3 @@
import { MsFileItem } from '@/components/pure/ms-upload/types';
import { CommentItem, CommentParams } from '@/components/business/ms-comment/types';
import MSR from '@/api/http/index';
@ -53,7 +52,6 @@ import {
GetRecycleCaseListUrl,
GetRecycleCaseModulesCountUrl,
GetReviewCommentListUrl,
GetReviewerListUrl,
GetSearchCustomFieldsUrl,
GetThirdDemandUrl,
getTransferTreeUrl,
@ -86,7 +84,6 @@ import type {
CreateOrUpdateModule,
DeleteCaseType,
DemandItem,
DetailCase,
DragCase,
ImportExcelType,
ModulesTreeType,
@ -95,7 +92,7 @@ import type {
UpdateModule,
} from '@/models/caseManagement/featureCase';
import type { CommonList, MoveModules, TableQueryParams } from '@/models/common';
import type { UserListItem } from '@/models/setting/user';
// 获取模块树
export function getCaseModuleTree(params: TableQueryParams) {
return MSR.get<ModulesTreeType[]>({ url: `${GetCaseModuleTreeUrl}/${params.projectId}` });
@ -311,11 +308,6 @@ export function getDetailCaseReviewPage(data: TableQueryParams) {
return MSR.post<CommonList<CaseManagementTable>>({ url: GetDetailCaseReviewUrl, data });
}
// 获取评审人列表
export function getReviewerList(projectId: string, keyword: string) {
return MSR.get<UserListItem[]>({ url: `${GetReviewerListUrl}/${projectId}`, params: { keyword } });
}
// 用例接口用例分页列表
export function getPublicLinkCaseList(data: TableQueryParams) {
return MSR.post<CommonList<CaseManagementTable>>({ url: GetAssociationPublicCasePageUrl, data });

View File

@ -4,7 +4,7 @@ import { ProjectListUrl, ProjectSwitchUrl } from '@/api/requrls/project-manageme
import type { ProjectListItem } from '@/models/setting/project';
export function getProjectList(organizationId: string) {
return MSR.get<ProjectListItem[]>({ url: ProjectListUrl, params: organizationId });
return MSR.get<ProjectListItem[]>({ url: ProjectListUrl, params: organizationId }, { ignoreCancelToken: true });
}
export function switchProject(data: { projectId: string; userId: string }) {

View File

@ -5,11 +5,13 @@ import {
BatchRemoveMemberUrl,
EditProjectMemberUrl,
GetProjectMemberListUrl,
ProjectMemberCommentOptions,
ProjectMemberOptions,
ProjectUserGroupUrl,
RemoveProjectMemberUrl,
} from '@/api/requrls/project-management/projectMember';
import { ReviewUserItem } from '@/models/caseManagement/caseReview';
import type { CommonList, TableQueryParams } from '@/models/common';
import type { ActionProjectMember, ProjectMemberItem } from '@/models/projectManagement/projectAndPermission';
@ -50,3 +52,11 @@ export function getProjectUserGroup(projectId: string) {
export function getProjectMemberOptions(projectId: string, keyword?: string) {
return MSR.get({ url: `${ProjectMemberOptions}/${projectId}`, params: { keyword } });
}
// 项目成员-@成员下拉选项
export function getProjectMemberCommentOptions(projectId: string, keyword?: string) {
return MSR.get<ReviewUserItem[]>({
url: `${ProjectMemberCommentOptions}/${projectId}`,
params: { keyword },
});
}

View File

@ -22,6 +22,7 @@ import type { CommonList, TableQueryParams } from '@/models/common';
import type {
BatchAddParams,
CreateUserParams,
CreateUserResult,
DeleteUserParams,
ImportResult,
ImportUserParams,
@ -35,6 +36,8 @@ import type {
UserListItem,
} from '@/models/setting/user';
import { Result } from '#/axios';
// 获取用户列表
export function getUserList(data: TableQueryParams) {
return MSR.post<CommonList<UserListItem>>({ url: GetUserListUrl, data });
@ -42,7 +45,7 @@ export function getUserList(data: TableQueryParams) {
// 批量创建用户
export function batchCreateUser(data: CreateUserParams) {
return MSR.post({ url: CreateUserUrl, data });
return MSR.post<CreateUserResult>({ url: CreateUserUrl, data });
}
// 更新用户信息
@ -68,7 +71,7 @@ export function deleteUserInfo(data: DeleteUserParams) {
// 导入用户
export function importUserInfo(data: ImportUserParams) {
return MSR.uploadFile<ImportResult>({ url: ImportUserUrl }, data);
return MSR.uploadFile<Result<ImportResult>>({ url: ImportUserUrl }, data);
}
// 获取系统用户组

View File

@ -104,8 +104,6 @@ export const UpdateCommentItemUrl = '/functional/case/comment/update';
export const DeleteCommentItemUrl = '/functional/case/comment/delete';
// 获取详情用例评审
export const GetDetailCaseReviewUrl = '/functional/case/review/page';
// 获取有权限的评审人
export const GetReviewerListUrl = '/case/review/user-option';
// 获取用例详情弹窗关联用例接口用例
export const GetAssociationPublicCasePageUrl = '/functional/case/test/associate/case/page';
// 获取接口测试接口模块数量

View File

@ -6,3 +6,4 @@ export const RemoveProjectMemberUrl = '/project/member/remove';
export const BatchAddUserGroup = '/project/member/add-role';
export const ProjectUserGroupUrl = '/project/member/get-role/option';
export const ProjectMemberOptions = '/project/member/get-member/option';
export const ProjectMemberCommentOptions = '/project/member/comment/user-option'; // 项目成员-@成员下拉列表

View File

@ -213,6 +213,9 @@
.btn-outline-danger-active();
.btn-outline-danger-disabled();
}
.arco-btn-size-mini {
line-height: 16px;
}
/** 输入框,选择器,文本域 **/
.arco-select {
@ -311,6 +314,14 @@
}
}
}
.arco-input-suffix {
.arco-icon-hover {
.arco-icon-eye-invisible,
.arco-icon-eye {
color: var(--color-text-brand);
}
}
}
.arco-textarea {
.ms-scroll-bar();

View File

@ -126,6 +126,7 @@
/>
</div>
<div
v-if="!props.hideAdd"
v-show="form.list.length > 1"
class="minus"
:class="[
@ -169,6 +170,7 @@
import type { FormItemModel, FormMode } from './types';
import type { FormInstance, ValidatedError } from '@arco-design/web-vue';
import { FieldData } from '@arco-design/web-vue/es/form/interface';
const { t } = useI18n();
@ -182,10 +184,12 @@
isShowDrag?: boolean; //
formWidth?: string; //
showEnable?: boolean; // switch
hideAdd?: boolean; //
}>(),
{
maxHeight: '30vh',
isShowDrag: false,
hideAdd: false,
}
);
@ -292,10 +296,15 @@
formRef.value?.resetFields();
}
function setFields(data: Record<string, FieldData>) {
formRef.value?.setFields(data);
}
defineExpose({
formValidate,
getFormResult,
resetForm,
setFields,
});
</script>

View File

@ -22,7 +22,7 @@
{{ props.prefix }}
</template>
<template #label="{ data }">
<a-tooltip :content="getInputLabel(data)" position="top" :mouse-enter-delay="500" mini>
<a-tooltip :content="getInputLabelTooltip(data)" position="top" :mouse-enter-delay="500" mini>
<div class="one-line-text inline-block">{{ getInputLabel(data) }}</div>
</a-tooltip>
</template>
@ -64,7 +64,7 @@
</template>
<template #label="{ data }">
<slot name="label" :data="{ ...data, [props.labelKey]: getInputLabel(data) }">
<a-tooltip :content="getInputLabel(data)" position="top" :mouse-enter-delay="500" mini>
<a-tooltip :content="getInputLabelTooltip(data)" position="top" :mouse-enter-delay="500" mini>
<div class="one-line-text inline translate-y-[15%]">
{{ getInputLabel(data) }}
</div>
@ -85,6 +85,7 @@
<script setup lang="ts">
import { Ref, ref, watch } from 'vue';
import { useVModel } from '@vueuse/core';
import { useI18n } from '@/hooks/useI18n';
import useSelect from '@/hooks/useSelect';
@ -125,8 +126,9 @@
const { t } = useI18n();
const innerValue = ref<CascaderModelValue>([]);
const innerLevel = ref(''); //
const innerLevel = useVModel(props, 'level', emit); //
const cascader: Ref = ref(null);
let selectedLabelObj: Record<string, any> = {}; // label tooltip
const { maxTagCount, getOptionComputedStyle, calculateMaxTag } = useSelect({
selectRef: cascader,
@ -158,6 +160,16 @@
watch(
() => innerValue.value,
(val) => {
if (Array.isArray(val)) {
// label
const tempObj: Record<string, any> = {};
for (let i = 0; i < val.length; i++) {
if (selectedLabelObj[val[i]]) {
tempObj[val[i]] = selectedLabelObj[val[i]];
}
}
selectedLabelObj = { ...tempObj };
}
emit('update:modelValue', val);
if (val === '') {
innerLevel.value = '';
@ -166,23 +178,6 @@
}
);
watch(
() => props.level,
(val) => {
innerLevel.value = val || '';
},
{
immediate: true,
}
);
watch(
() => innerLevel.value,
(val) => {
emit('update:level', val);
}
);
interface CascaderValue {
level: keyof typeof props.levelTop;
value: string;
@ -213,13 +208,25 @@
calculateMaxTag();
}
// TODO: arco-design cascader path-mode - v-model
// TODO: arco-design cascader label/ path-mode
function getInputLabel(data: CascaderOption) {
const isTagCount = data[props.labelKey].includes('+');
if (!props.pathMode) {
return isTagCount ? data[props.labelKey] : t(data[props.labelKey].split('-').pop());
return isTagCount ? data.label : t((data.label || '').split('/').pop() || ''); //
}
return isTagCount ? data[props.labelKey] : t(data[props.labelKey]);
return isTagCount ? data.label || '' : t(data.label || '');
}
function getInputLabelTooltip(data: CascaderOption) {
const isTagCount = data[props.labelKey].includes('+');
if (isTagCount && Array.isArray(innerValue.value)) {
return Object.values(selectedLabelObj).join('');
}
const label = getInputLabel(data);
if (data.value && typeof data.value === 'string') {
selectedLabelObj[data.value] = label;
}
return label;
}
function clearValues() {

View File

@ -360,10 +360,8 @@
},
];
const getTableList = computed(() => props.getTableFunc);
const { propsRes, propsEvent, loadList, setLoadListParams, resetSelector } = useTable(
getTableList.value,
props.getTableFunc,
{
columns,
showSetting: false,

View File

@ -18,8 +18,6 @@
import { openWindow, regexUrl } from '@/utils';
import { listenerRouteChange } from '@/utils/route-listener';
import { WorkbenchRouteEnum } from '@/enums/routeEnum';
import useMenuTree from './use-menu-tree';
import type { RouteMeta } from 'vue-router';
@ -125,15 +123,22 @@
async function switchOrg(id: string) {
try {
Message.loading(t('personal.switchOrgLoading'));
appStore.showLoading(t('personal.switchOrgLoading'));
await switchUserOrg(id, userStore.id || '');
switchOrgVisible.value = false;
Message.clear();
appStore.hideLoading();
Message.success(t('personal.switchOrgSuccess'));
personalMenusVisible.value = false;
orgKeyword.value = '';
await router.replace({ name: WorkbenchRouteEnum.WORKBENCH });
userStore.isLogin();
await userStore.isLogin(true);
router.replace({
path: route.path,
query: {
...route.query,
organizationId: appStore.currentOrgId,
projectId: appStore.currentProjectId,
},
});
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
@ -212,7 +217,7 @@
return (
<a-trigger
v-model:popup-visible={personalMenusVisible.value}
trigger="click"
trigger="hover"
unmount-on-close={false}
popup-offset={4}
position="right"
@ -309,7 +314,7 @@
}}
>
<a-menu-item class="flex items-center justify-between" key="personalInfo">
<div class="flex items-center gap-[8px] hover:!bg-transparent">
<div class="relative flex items-center gap-[8px] hover:!bg-transparent">
<MsAvatar avatar={userStore.avatar} size={20} />
{userStore.name}
</div>
@ -427,6 +432,7 @@
@apply !bg-transparent;
}
.arco-menu-icon {
margin-right: 8px;
.arco-icon {
&:not(.arco-icon-down) {
font-size: 18px;
@ -456,6 +462,9 @@
}
.arco-menu-pop {
@apply bg-transparent;
&:hover {
background-color: rgb(var(--primary-1)) !important;
}
}
}
}
@ -464,6 +473,10 @@
}
.arco-menu-collapsed {
width: 86px;
.arco-avatar,
.arco-icon {
margin-right: 2px !important;
}
}
.arco-menu {
&:hover {
@ -484,6 +497,9 @@
}
}
}
.arco-menu-item-tooltip {
@apply hidden;
}
.switch-org-dropdown {
@apply absolute max-h-none;

View File

@ -18,7 +18,7 @@
type="button"
>
<a-radio v-for="(option, i) of group.options || []" :key="`option${i}`" :value="option.value">
{{ t(option.label) }}
{{ t(option.label || '') }}
</a-radio>
</a-radio-group>
<a-date-picker

View File

@ -92,7 +92,7 @@
</div>
<a-modal
v-model:visible="timeModalVisible"
:title="t('ms.personal.changeAvatar')"
:title="t('ms.personal.setValidTime')"
title-align="start"
:ok-text="t('common.save')"
class="ms-usemodal"

View File

@ -253,11 +253,20 @@
const avatarModalVisible = ref(false);
const avatarList = ref<string[]>([]);
let i = 1;
while (i <= 46) {
avatarList.value.push(`/images/avatar/avatar-${i}.jpg`);
i++;
}
watch(
() => avatarModalVisible.value,
(val) => {
if (val && avatarList.value.length === 0) {
//
let i = 1;
while (i <= 46) {
avatarList.value.push(`/images/avatar/avatar-${i}.jpg`);
i++;
}
}
}
);
function changeAvatar(avatar: string) {
activeAvatar.value = avatar;

View File

@ -22,6 +22,8 @@
</template>
<script setup lang="ts">
import { useVModel } from '@vueuse/core';
import MsDrawer from '@/components/pure/ms-drawer/index.vue';
import MsMenuPanel from '@/components/pure/ms-menu-panel/index.vue';
import apiKey from './components/apiKey.vue';
@ -41,27 +43,9 @@
const { t } = useI18n();
const innerVisible = ref(false);
watch(
() => props.visible,
(val) => {
innerVisible.value = val;
}
);
const innerVisible = useVModel(props, 'visible', emit);
const activeMenu = ref('baseInfo');
watch(
() => innerVisible.value,
(val) => {
emit('update:visible', val);
if (!val) {
activeMenu.value = 'baseInfo';
}
}
);
const menuList = ref([
{
name: 'personal',

View File

@ -25,7 +25,7 @@ export default {
'ms.personal.avatar': 'Avatar{index}',
'ms.personal.currentPsw': 'Current Password',
'ms.personal.newPsw': 'New Password',
'ms.personal.changePswTip': 'After changing the password, you need to use the new email to log in to the system',
'ms.personal.changePswTip': 'After changing the password, you need to use the new password to log in to the system',
'ms.personal.updatePswSuccess':
'The password has been modified successfully and will automatically log out in {count} seconds. Please log in with the new password.',
'ms.personal.apiKeyTip': 'After adding, you can access MeterSphere',
@ -33,6 +33,7 @@ export default {
'ms.personal.expired': 'Expired',
'ms.personal.expiredTip': 'The expiration time can be changed in [Settings]',
'ms.personal.validTime': 'Effective time',
'ms.personal.setValidTime': 'Set effective time',
'ms.personal.desc': 'Description',
'ms.personal.createTime': 'Created time',
'ms.personal.copySuccess': 'Copied successfully',

View File

@ -7,7 +7,7 @@ export default {
'ms.personal.apiKey': 'APIKEY',
'ms.personal.tripartite': '三方平台账号',
'ms.personal.changeAvatar': '更换头像',
'ms.personal.name': '用户',
'ms.personal.name': '用户名',
'ms.personal.namePlaceholder': '请输入用户名称',
'ms.personal.nameRequired': '用户名称不能为空',
'ms.personal.email': '邮箱',
@ -24,13 +24,14 @@ export default {
'ms.personal.avatar': '头像{index}',
'ms.personal.currentPsw': '当前密码',
'ms.personal.newPsw': '新密码',
'ms.personal.changePswTip': '修改密码后,需要使用新的邮箱登录系统',
'ms.personal.changePswTip': '修改密码后,需要使用新的密码登录系统',
'ms.personal.updatePswSuccess': '密码修改成功,将在 {count} 秒后自动退出,请使用新密码登录',
'ms.personal.apiKeyTip': '新增后,可访问 MeterSphere',
'ms.personal.expireTime': '过期时间',
'ms.personal.expired': '已到期',
'ms.personal.expiredTip': '可在【设置】内更改到期时间',
'ms.personal.validTime': '有效时间',
'ms.personal.setValidTime': '设置有效时间',
'ms.personal.desc': '描述',
'ms.personal.createTime': '创建时间',
'ms.personal.copySuccess': '复制成功',

View File

@ -36,9 +36,13 @@ export interface MsSearchSelectProps {
triggerProps?: TriggerProps; // 触发器属性
loading?: boolean; // 加载状态
fallbackOption?: boolean | ((value: string | number | boolean | Record<string, unknown>) => SelectOptionData); // 自定义值中不存在的选项
shouldCalculateMaxTag?: boolean; // 是否需要计算最大展示选项数量
disabled?: boolean; // 是否禁用
size?: 'mini' | 'small' | 'medium' | 'large'; // 尺寸
remoteFunc?(params: Record<string, any>): Promise<any>; // 远程模式下的请求函数,返回一个 Promise
optionLabelRender?: (item: SelectOptionData) => string; // 自定义 option 的 label 渲染,返回一个 html 字符串,默认使用 item.label
optionTooltipContent?: (item: SelectOptionData) => string; // 自定义 option 的 tooltip 内容,返回一个字符串,默认使用 item.label
remoteFilterFunc?: (options: SelectOptionData[]) => SelectOptionData[]; // 自定义过滤函数,会在远程请求返回数据后执行
}
export interface RadioProps {
options: SelectOptionData[];
@ -61,6 +65,8 @@ export default defineComponent(
const { t } = useI18n();
const innerValue = ref(props.modelValue);
const inputValue = ref('');
const tempInputValue = ref('');
const filterOptions = ref<SelectOptionData[]>([...props.options]); // 实际渲染的 options会根据搜索关键字进行过滤
const remoteOriginOptions = ref<SelectOptionData[]>([...props.options]); // 远程模式下的原始 options接口返回的数据会存储在这里
@ -68,7 +74,7 @@ export default defineComponent(
const { maxTagCount, getOptionComputedStyle, singleTagMaxWidth, calculateMaxTag } = useSelect({
selectRef,
selectVal: innerValue,
isCascade: true,
isCascade: false,
valueKey: props.valueKey,
labelKey: props.labelKey,
});
@ -77,8 +83,8 @@ export default defineComponent(
() => props.modelValue,
(val) => {
innerValue.value = val;
if (Array.isArray(val) && val.length > 0 && props.multiple) {
calculateMaxTag(remoteOriginOptions.value);
if (props.shouldCalculateMaxTag !== false && props.multiple) {
calculateMaxTag();
}
},
{
@ -139,6 +145,9 @@ export default defineComponent(
return item;
}
);
if (props.remoteFilterFunc && typeof props.remoteFilterFunc === 'function') {
remoteOriginOptions.value = props.remoteFilterFunc(remoteOriginOptions.value);
}
emit('remoteSearch', remoteOriginOptions.value);
}
if (val.trim() === '') {
@ -161,10 +170,10 @@ export default defineComponent(
for (let i = 0; i < props.searchKeys.length; i++) {
// 遍历传入的搜索字段
const key = props.searchKeys[i];
if (e[key].includes(val)) {
if (e[key]?.toLowerCase().includes(val.toLowerCase())) {
// 是否匹配
hasMatch = true;
item[key] = e[key].replace(new RegExp(val, 'gi'), highlightedKeyword); // 高亮关键字替换
item[props.labelKey || 'label'] = e[key].replace(new RegExp(val, 'gi'), highlightedKeyword); // 高亮关键字替换
}
}
}
@ -180,6 +189,9 @@ export default defineComponent(
return null;
})
.filter((e) => e) as SelectOptionData[];
if (props.shouldCalculateMaxTag !== false && props.multiple) {
calculateMaxTag();
}
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
@ -188,13 +200,14 @@ export default defineComponent(
}
}
const optionItemLabelRender = (item: SelectOptionData) =>
h('div', {
const optionItemLabelRender = (item: SelectOptionData) => {
return h('div', {
innerHTML:
typeof props.optionLabelRender === 'function'
? props.optionLabelRender(item)
: item[props.labelKey || 'label'],
});
};
// 半选状态
const indeterminate = computed(() => {
@ -238,17 +251,24 @@ export default defineComponent(
handleSelectAllChange(true);
}
}
calculateMaxTag(val);
if (props.shouldCalculateMaxTag !== false && props.multiple) {
calculateMaxTag();
}
}
);
function getOptionItemDisabled(item: SelectOptionData) {
return (
!!props.multiple &&
!!props.atLeastOne &&
Array.isArray(innerValue.value) &&
!!innerValue.value.find((e) => e[props.valueKey || 'value'] === item[props.valueKey || 'value']) &&
innerValue.value.length === 1
!!item.disabled ||
(!!props.multiple &&
!!props.atLeastOne &&
Array.isArray(innerValue.value) &&
!!innerValue.value.find((e) =>
props.objectValue
? e[props.valueKey || 'value'] === item[props.valueKey || 'value']
: e === item[props.valueKey || 'value']
) &&
innerValue.value.length === 1)
);
}
@ -340,57 +360,110 @@ export default defineComponent(
}
});
/**
*
* @param value
*/
function handleChange(value: ModelType) {
if (props.multiple) {
nextTick(() => {
inputValue.value = tempInputValue.value;
});
}
emit('update:modelValue', value);
emit('change', value);
}
/**
*
* @param val
*/
function handleInputValueChange(val: string) {
inputValue.value = val;
if (val !== '') {
// 只存储有值的搜索值因为当搜索完选中一个选项后arco-select 会自动清空输入框,这里需要过滤掉
tempInputValue.value = val;
}
}
const selectFullTooltip = computed(() => {
const values = Array.isArray(innerValue.value) ? innerValue.value : [innerValue.value];
let tooltip = '';
if (props.objectValue) {
// 对象模式下,直接取 tooltipContent 或 label(若搜索的情况下会携带高亮代码所以优先取tooltipContent)
tooltip = values.map((e) => e.tooltipContent || e[props.labelKey || 'label']).join('');
} else {
// 非对象模式下,需要根据 valueKey 取 label
tooltip = remoteOriginOptions.value
.filter((e) => values.includes(e[props.valueKey || 'value']))
.map((e) => e[props.labelKey || 'label'])
.join('');
}
return tooltip;
});
return () => (
<a-select
ref={selectRef}
default-value={innerValue}
placeholder={t(props.placeholder || '')}
allow-clear={props.allowClear}
allow-search={props.allowSearch}
filter-option={false}
loading={loading.value}
multiple={props.multiple}
max-tag-count={maxTagCount.value}
search-delay={300}
class="ms-select"
value-key={props.valueKey || 'value'}
popup-container={props.popupContainer || document.body}
trigger-props={props.triggerProps}
fallback-option={props.fallbackOption}
onChange={(value: ModelType) => {
emit('update:modelValue', value);
emit('change', value);
}}
onSearch={handleSearch}
onPopupVisibleChange={(val: boolean) => {
handleSearch('', true);
emit('popupVisibleChange', val);
}}
onRemove={(val: string | number | boolean | Record<string, any> | undefined) => emit('remove', val)}
onKeyup={(e: KeyboardEvent) => {
// 阻止组件在回车时自动触发的事件
if (e.code === 'Enter') {
e.preventDefault();
e.stopPropagation();
handleSearch('', true);
}
}}
>
{{
prefix: props.prefix ? () => t(props.prefix || '') : null,
label: ({ data }: { data: SelectOptionData }) => (
<a-tooltip content={data.label} position="top" mouse-enter-delay={500} mini>
<div
class="one-line-text"
style={singleTagMaxWidth.value > 0 ? { maxWidth: `${singleTagMaxWidth.value}px` } : {}}
>
{slots.label ? slots.label(data) : data.label}
</div>
</a-tooltip>
),
...selectSlots(),
}}
</a-select>
<div class="w-full">
<a-tooltip content={selectFullTooltip.value} position="top" mouse-enter-delay={500} mini>
<a-select
ref={selectRef}
class="ms-select"
default-value={innerValue}
input-value={inputValue.value}
placeholder={t(props.placeholder || '')}
allow-clear={props.allowClear}
allow-search={props.allowSearch}
filter-option={true}
loading={loading.value}
multiple={props.multiple}
max-tag-count={maxTagCount.value}
search-delay={300}
value-key={props.valueKey || 'value'}
popup-container={props.popupContainer || document.body}
trigger-props={props.triggerProps}
fallback-option={props.fallbackOption}
disabled={props.disabled}
size={props.size}
onChange={handleChange}
onSearch={handleSearch}
onPopupVisibleChange={(val: boolean) => {
if (val) {
handleSearch('', true);
} else {
inputValue.value = '';
tempInputValue.value = '';
}
emit('popupVisibleChange', val);
}}
onRemove={(val: string | number | boolean | Record<string, any> | undefined) => emit('remove', val)}
onKeyup={(e: KeyboardEvent) => {
// 阻止组件在回车时自动触发的事件
if (e.code === 'Enter') {
e.preventDefault();
e.stopPropagation();
handleSearch('', true);
}
if (e.code === 'Backspace' && inputValue.value === '') {
tempInputValue.value = '';
}
}}
onInputValueChange={handleInputValueChange}
>
{{
prefix: props.prefix ? () => t(props.prefix || '') : null,
label: ({ data }: { data: SelectOptionData }) => (
<div
class="one-line-text"
style={singleTagMaxWidth.value > 0 ? { maxWidth: `${singleTagMaxWidth.value}px` } : {}}
>
{slots.label ? slots.label(data) : data.label}
</div>
),
...selectSlots(),
}}
</a-select>
</a-tooltip>
</div>
);
},
{
@ -421,6 +494,10 @@ export default defineComponent(
'labelKey',
'atLeastOne',
'objectValue',
'remoteFilterFunc',
'shouldCalculateMaxTag',
'disabled',
'size',
],
emits: ['update:modelValue', 'remoteSearch', 'popupVisibleChange', 'update:loading', 'remove', 'change'],
}

View File

@ -57,7 +57,7 @@
const fileType = computed(() => {
if (props.type) {
return getFileEnum(`/${props.type.toLowerCase()}`);
return getFileEnum(props.type.toLowerCase());
}
return 'unknown';
});

View File

@ -117,7 +117,7 @@
<style lang="less" scoped>
:deep(.arco-menu-inner) {
overflow-y: hidden;
padding: 9px 20px;
padding: 9px 0;
.arco-menu-selected-label {
bottom: -8px !important;
}

View File

@ -10,6 +10,21 @@
}"
:target-input-search-props="props.targetInputSearchProps"
>
<template #source-title="{ countSelected, checked, indeterminate, onSelectAllChange }">
<div class="flex items-center gap-[8px]">
<a-checkbox :model-value="checked" :indeterminate="indeterminate" @change="onSelectAllChange" />
{{ t('ms.transfer.optional', { count: countSelected }) }}
</div>
</template>
<template #target-title="{ countTotal, checked, indeterminate, onSelectAllChange, onClear }">
<div class="flex items-center justify-between">
<div class="flex items-center gap-[8px]">
<a-checkbox :model-value="checked" :indeterminate="indeterminate" @change="onSelectAllChange" />
{{ t('ms.transfer.selected', { count: countTotal }) }}
</div>
<MsButton type="text" @click="onClear">{{ t('ms.transfer.clear') }}</MsButton>
</div>
</template>
<template #source="{ selectedKeys, onSelect }">
<MsTree
:checkable="true"
@ -19,24 +34,33 @@
:keyword="sourceKeyword"
block-node
default-expand-all
:selectable="false"
@check="onSelect"
>
<template #title="nodeData">
<div class="one-line-text">
<div class="one-line-text text-[var(--color-text-1)]">
{{ nodeData.title }}
</div>
</template>
</MsTree>
</template>
<template #item="{ label }">
<a-tooltip :content="label">
<div class="one-line-text">{{ label }}</div>
</a-tooltip>
</template>
</a-transfer>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import MsButton from '@/components/pure/ms-button/index.vue';
import MsTree from '@/components/business/ms-tree/index.vue';
import type { MsTreeFieldNames, MsTreeNodeData } from '@/components/business/ms-tree/types';
import { useI18n } from '@/hooks/useI18n';
export interface TransferDataItem {
value: string;
label: string;
@ -66,6 +90,8 @@
);
const emit = defineEmits(['update:modelValue']);
const { t } = useI18n();
const innerTarget = ref<string[]>([]);
const transferData = ref<TransferDataItem[]>([]);

View File

@ -0,0 +1,5 @@
export default {
'ms.transfer.optional': 'Optional {count} items',
'ms.transfer.selected': 'Selected {count} items',
'ms.transfer.clear': 'Clear',
};

View File

@ -0,0 +1,5 @@
export default {
'ms.transfer.optional': '可选 {count} 项',
'ms.transfer.selected': '已选 {count} 项',
'ms.transfer.clear': '清空',
};

View File

@ -152,7 +152,7 @@
});
const originalTreeData = ref<MsTreeNodeData[]>([]);
function init() {
function init(isFirstInit = false) {
originalTreeData.value = mapTree<MsTreeNodeData>(props.data, (node: MsTreeNodeData) => {
if (!props.showLine) {
// 线线 switcherIcon switcherIcon
@ -169,22 +169,24 @@
return node;
});
nextTick(() => {
if (props.defaultExpandAll) {
treeRef.value?.expandAll(true);
}
if (!isInitListener.value && treeRef.value) {
setContainer(
props.virtualListProps?.height
? (treeRef.value.$el.querySelector('.arco-virtual-list') as HTMLElement)
: treeRef.value.$el
);
initScrollListener();
if (isFirstInit) {
if (props.defaultExpandAll) {
treeRef.value?.expandAll(true);
}
if (!isInitListener.value && treeRef.value) {
setContainer(
props.virtualListProps?.height
? (treeRef.value.$el.querySelector('.arco-virtual-list') as HTMLElement)
: treeRef.value.$el
);
initScrollListener();
}
}
});
}
onBeforeMount(() => {
init();
init(true);
});
watch(

View File

@ -102,9 +102,6 @@
right: 20px;
}
.arco-list-item-meta-content {
@apply flex-1;
}
.item-wrap {
@apply cursor-pointer;
}

View File

@ -10,6 +10,7 @@
allow-clear
@press-enter="emit('keywordSearch', innerKeyword)"
@search="emit('keywordSearch', innerKeyword)"
@clear="emit('keywordSearch', innerKeyword)"
></a-input-search>
<MsTag
:type="visible ? 'primary' : 'default'"
@ -17,8 +18,9 @@
size="large"
class="min-w-[64px] cursor-pointer"
no-margin
@click="handleOpenFilter"
>
<span :class="!visible ? 'text-[var(--color-text-4)]' : ''" @click="handleOpenFilter">
<span :class="!visible ? 'text-[var(--color-text-4)]' : ''">
<icon-filter class="text-[16px]" />
<span class="ml-[4px]">
<span v-if="filterCount">{{ filterCount }}</span>

View File

@ -6,7 +6,7 @@
'ms-card',
'relative',
'h-full',
props.isFullscreen || isFullScreen ? 'ms-card--no-radius' : '',
props.isFullscreen || isFullScreen ? 'ms-card--fullScreen' : '',
props.autoHeight ? '' : 'min-h-[500px]',
props.noContentPadding ? 'ms-card--noContentPadding' : 'p-[24px]',
props.noBottomRadius ? 'ms-card--noBottomRadius' : '',
@ -23,7 +23,7 @@
<slot name="headerRight"></slot>
<div
v-if="props.showFullScreen"
class="w-[96px] cursor-pointer text-right !text-[var(--color-text-4)]"
class="cursor-pointer text-right !text-[var(--color-text-4)]"
@click="toggleFullScreen"
>
<MsIcon v-if="isFullScreen" type="icon-icon_minify_outlined" />
@ -48,8 +48,8 @@
</div>
<div
v-if="!props.hideFooter && !props.simple"
class="fixed bottom-0 right-[16px] z-[100] flex items-center bg-white p-[24px] shadow-[0_-1px_4px_rgba(2,2,2,0.1)]"
:style="{ width: `calc(100% - ${menuWidth + 16}px)` }"
class="ms-card-footer"
:style="{ width: props.isFullscreen || isFullScreen ? '100%' : `calc(100% - ${menuWidth + 16}px)` }"
>
<div class="ml-0 mr-auto">
<slot name="footerLeft"></slot>
@ -71,7 +71,7 @@
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { computed, watch } from 'vue';
import { useRouter } from 'vue-router';
import useFullScreen from '@/hooks/useFullScreen';
@ -119,7 +119,7 @@
}
);
const emit = defineEmits(['saveAndContinue', 'save']);
const emit = defineEmits(['saveAndContinue', 'save', 'toggleFullScreen']);
const router = useRouter();
const { t } = useI18n();
@ -134,9 +134,27 @@
const fullRef = ref<HTMLElement | null>();
const { isFullScreen, toggleFullScreen } = useFullScreen(fullRef);
watch(
() => isFullScreen.value,
(val) => {
emit('toggleFullScreen', val);
}
);
const _specialHeight = props.hasBreadcrumb ? 32 + props.specialHeight : props.specialHeight; // 32
const cardOverHeight = computed(() => {
if (isFullScreen.value) {
if (props.simple) {
//
return props.noContentPadding ? 0 : 48;
}
if (props.hideFooter) {
//
return props.noContentPadding ? 88 : 118;
}
return 246;
}
if (props.simple) {
//
return props.noContentPadding ? 88 + _specialHeight : 136 + _specialHeight;
@ -153,7 +171,7 @@
return {
overflow: 'auto',
width: 'auto',
height: 'auto',
height: props.autoHeight ? 'auto' : `calc(100vh - ${cardOverHeight.value}px)`,
};
}
if (props.noContentPadding) {
@ -218,10 +236,27 @@
}
}
.ms-card-container {
@apply h-full;
@apply relative;
}
.ms-card-footer {
@apply fixed justify-between bg-white;
right: 16px;
bottom: 0;
z-index: 100;
padding: 24px;
border-bottom: 0;
--tw-shadow: 0 -1px 4px rgb(2 2 2 / 10%);
--tw-shadow-colored: 0 -1px 4px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #00000000), var(--tw-ring-shadow, 0 0 #00000000), var(--tw-shadow);
}
}
.ms-card--no-radius {
.ms-card--fullScreen {
border-radius: 0;
.ms-card-footer {
@apply left-0 right-0 w-full;
}
}
</style>

View File

@ -18,14 +18,16 @@
<div class="ms-description-item-label" :style="{ width: props.labelWidth || '120px' }">
<slot name="item-label">{{ item.label }}</slot>
</div>
<div class="ms-description-item-value">
<div :class="item.isTag ? 'ms-description-item-value--tagline' : 'ms-description-item-value'">
<slot name="item-value" :item="item">
<template v-if="item.isTag">
<slot name="tag" :item="item">
<MsTag
v-for="tag of Array.isArray(item.value) ? item.value : [item.value]"
:key="`${tag}`"
theme="outline"
:theme="item.tagTheme || 'outline'"
:type="item.tagType || 'primary'"
:max-width="item.tagMaxWidth"
color="var(--color-text-n8)"
:class="`mb-[8px] mr-[8px] font-normal !text-[var(--color-text-1)] ${item.tagClass || ''}`"
:closable="item.closable"
@ -53,7 +55,15 @@
{{ t('ms.description.addTagRepeat') }}
</span>
</template>
<MsTag v-else type="primary" theme="outline" class="cursor-pointer" @click="handleEdit">
<!-- 标签数量大于等于10时不显示添加标签 -->
<MsTag
v-else-if="Array.isArray(item.value) && item.value.length < 10"
type="primary"
theme="outline"
:max-width="item.tagMaxWidth"
class="inline-flex cursor-pointer items-center gap-[4px]"
@click="handleEdit"
>
<template #icon>
<MsIcon type="icon-icon_add_outlined" class="text-[rgb(var(--primary-5))]" />
</template>
@ -102,7 +112,7 @@
import MsButton from '@/components/pure/ms-button/index.vue';
import MsIcon from '@/components/pure/ms-icon-font/index.vue';
import MsTag from '@/components/pure/ms-tag/ms-tag.vue';
import MsTag, { TagType, Theme } from '@/components/pure/ms-tag/ms-tag.vue';
import { useI18n } from '@/hooks/useI18n';
@ -112,6 +122,9 @@
key?: string;
isTag?: boolean; //
tagClass?: string; //
tagType?: TagType; //
tagTheme?: Theme; //
tagMaxWidth?: string; //
closable?: boolean; //
showTagAdd?: boolean; //
isButton?: boolean;
@ -227,9 +240,11 @@
color: var(--color-text-3);
word-wrap: break-word;
}
.ms-description-item-value {
.ms-description-item-value,
.ms-description-item-value--tagline {
@apply relative flex-1 overflow-hidden break-all align-top;
}
.ms-description-item-value {
/* stylelint-disable-next-line value-no-vendor-prefix */
display: -webkit-box;
text-overflow: ellipsis;

View File

@ -123,12 +123,12 @@
const props = withDefaults(defineProps<DrawerProps>(), {
footer: true,
mask: true,
closable: true,
showSkeleton: false,
showContinue: false,
popupContainer: 'body',
disabledWidthDrag: false,
okPermission: () => [], //
closable: true,
});
const emit = defineEmits(['update:visible', 'confirm', 'cancel', 'continue']);

View File

@ -241,6 +241,9 @@
}
.arco-list-item-meta {
@apply p-0;
.arco-list-item-meta-content {
@apply flex-1;
}
.arco-list-item-meta-title:not(:last-child) {
@apply mb-0;
}

View File

@ -514,6 +514,15 @@
margin-top: 0;
}
}
:deep(.halo-rich-text-editor) {
padding: 16px 24px !important;
.editor-header {
justify-content: start !important;
}
p:first-child {
margin-top: 0;
}
}
}
:deep(.editor-header) {
svg {

View File

@ -1,18 +1,17 @@
import MentionList from './MentionList.vue';
import { getReviewerList } from '@/api/modules/case-management/featureCase';
import { getReviewUsers } from '@/api/modules/case-management/caseReview';
import useAppStore from '@/store/modules/app';
import type { UserListItem } from '@/models/setting/user';
import { ReviewUserItem } from '@/models/caseManagement/caseReview';
import { Extension, VueRenderer } from '@halo-dev/richtext-editor';
import Suggestion from '@tiptap/suggestion';
import { VueRenderer } from '@halo-dev/richtext-editor';
import type { Instance } from 'tippy.js';
import tippy from 'tippy.js';
const appStore = useAppStore();
const projectMember = ref<UserListItem[]>([]);
const projectMember = ref<ReviewUserItem[]>([]);
async function getMembersToolBar(query: string) {
const params = {
@ -20,8 +19,9 @@ async function getMembersToolBar(query: string) {
keyword: query,
};
try {
projectMember.value = await getReviewerList(params.projectId, params.keyword);
projectMember.value = await getReviewUsers(params.projectId, params.keyword);
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
}
}
@ -29,7 +29,9 @@ async function getMembersToolBar(query: string) {
export default {
items: async ({ query }: any) => {
await getMembersToolBar(query);
return projectMember.value.filter((item: UserListItem) => item.name.toLowerCase().startsWith(query.toLowerCase()));
return projectMember.value.filter((item: ReviewUserItem) =>
item.name.toLowerCase().startsWith(query.toLowerCase())
);
},
render: () => {

View File

@ -18,13 +18,17 @@
<slot name="optional" v-bind="{ rowIndex, record }" />
</template>
<template #columns>
<a-table-column v-if="attrs.selectable && props.selectedKeys" :width="props.firstColumnWidth || 60">
<a-table-column
v-if="attrs.selectable && props.selectedKeys"
:width="props.firstColumnWidth || 60"
fixed="left"
>
<template #title>
<SelectALL
v-if="attrs.selectorType === 'checkbox'"
:total="selectTotal"
:current="selectCurrent"
:show-select-all="(attrs.showPagination as boolean) && props.showSelectorAll"
:show-select-all="!!attrs.showPagination && props.showSelectorAll"
:disabled="(attrs.data as []).length === 0"
@change="handleSelectAllChange"
/>
@ -211,7 +215,7 @@
</div>
<div class="min-w-[500px]">
<ms-pagination
v-if="attrs.showPagination"
v-if="!!attrs.showPagination"
v-show="props.selectorStatus !== SelectAllEnum.CURRENT"
size="small"
v-bind="(attrs.msPagination as MsPaginationI)"
@ -226,7 +230,7 @@
v-model:visible="columnSelectorVisible"
:show-jump-method="(attrs.showJumpMethod as boolean)"
:table-key="(attrs.tableKey as string)"
:show-pagination="(attrs.showPagination as boolean)"
:show-pagination="!!attrs.showPagination"
@init-data="handleInitColumn"
@page-size-change="pageSizeChange"
></ColumnSelector>
@ -569,9 +573,15 @@
}
}
.ms-base-table--hasQuickCreate {
:deep(.arco-table-body) {
:deep(.arco-table-body:not(.arco-scrollbar-container)) {
padding-top: 54px;
}
:deep(.arco-table-element:not(.arco-table-header .arco-table-element)) {
padding-bottom: 54px;
tbody {
transform: translateY(54px);
}
}
:deep(.arco-table-tr:first-child) {
.arco-table-td {
border-top: 1px solid var(--color-text-n8);

View File

@ -8,7 +8,7 @@
...typeStyle,
'margin-right': noMargin ? 0 : tagMargin,
'min-width': props.width && `${props.width}ch`,
'max-width': '144px',
'max-width': props.maxWidth || '144px',
}"
>
<slot name="icon"></slot>
@ -33,6 +33,7 @@
theme?: Theme; // tag
selfStyle?: any; //
width?: number; // tag,max-width
maxWidth?: string;
noMargin?: boolean; // tag
}>(),
{

View File

@ -1,112 +1,137 @@
<template>
<div
v-if="props.mode === 'remote' && props.showTab"
class="sticky top-[0] z-[9999] mb-[8px] flex justify-between bg-white"
>
<a-radio-group v-model:model-value="fileListTab" type="button" size="small">
<a-radio value="all">{{ `${t('ms.upload.all')} (${innerFileList.length})` }}</a-radio>
<a-radio value="waiting">{{ `${t('ms.upload.uploading')} (${totalWaitingFileList.length})` }}</a-radio>
<a-radio value="success">{{ `${t('ms.upload.success')} (${totalSuccessFileList.length})` }}</a-radio>
<a-radio value="error">{{ `${t('ms.upload.fail')} (${totalFailFileList.length})` }}</a-radio>
</a-radio-group>
<slot name="tabExtra"></slot>
</div>
<MsList :data="filterFileList" :bordered="false" :split="false" item-border no-hover>
<template #item="{ item }">
<a-list-item
class="mb-[8px] w-full rounded-[var(--border-radius-small)] border border-solid border-[var(--color-text-n8)] !p-[8px_12px]"
>
<a-list-item-meta>
<template #avatar>
<a-avatar shape="square" class="rounded-[var(--border-radius-mini)] bg-[var(--color-text-n9)]">
<a-image v-if="item.file.type.includes('image/')" :src="item.url" width="40" height="40" hide-footer />
<MsIcon
v-else
:type="getFileIcon(item)"
size="24"
:class="getFileEnum(item.file?.type) === 'unknown' ? 'text-[var(--color-text-4)]' : ''"
/>
</a-avatar>
</template>
<template #title>
<div class="flex items-center">
<a-tooltip :content="item.file.name">
<div class="one-line-text max-w-[80%] font-normal">{{ item.file.name }}</div>
</a-tooltip>
<slot name="title" :item="item"></slot>
</div>
</template>
<template #description>
<div v-if="item.status === UploadStatus.init" class="text-[12px] leading-[16px] text-[var(--color-text-4)]">
{{ t('ms.upload.waiting') }}
</div>
<div
v-else-if="item.status === UploadStatus.done"
class="flex items-center gap-[8px] text-[12px] leading-[16px] text-[var(--color-text-4)]"
>
{{
`${formatFileSize(item.file.size)} ${t('ms.upload.uploadAt')} ${dayjs(item.uploadedTime).format(
'YYYY-MM-DD HH:mm:ss'
)}`
}}
<div>
<div
v-if="props.mode === 'remote' && props.showTab"
class="sticky top-[0] z-[9999] mb-[8px] flex justify-between bg-white"
>
<a-radio-group v-model:model-value="fileListTab" type="button" size="small">
<a-radio value="all">{{ `${t('ms.upload.all')} (${innerFileList.length})` }}</a-radio>
<a-radio value="waiting">{{ `${t('ms.upload.uploading')} (${totalWaitingFileList.length})` }}</a-radio>
<a-radio value="success">{{ `${t('ms.upload.success')} (${totalSuccessFileList.length})` }}</a-radio>
<a-radio value="error">{{ `${t('ms.upload.fail')} (${totalFailFileList.length})` }}</a-radio>
</a-radio-group>
<slot name="tabExtra"></slot>
</div>
<MsList
v-if="props.showMode === 'fileList'"
:data="filterFileList"
:bordered="false"
:split="false"
item-border
no-hover
>
<template #item="{ item }">
<a-list-item
class="mb-[8px] w-full rounded-[var(--border-radius-small)] border border-solid border-[var(--color-text-n8)] !p-[8px_12px]"
>
<a-list-item-meta>
<template #avatar>
<a-avatar shape="square" class="rounded-[var(--border-radius-mini)] bg-[var(--color-text-n9)]">
<a-image v-if="item.file.type.includes('image/')" :src="item.url" width="40" height="40" hide-footer />
<MsIcon
v-else
:type="getFileIcon(item)"
size="24"
:class="item.status === UploadStatus.init ? 'text-[var(--color-text-4)]' : ''"
/>
</a-avatar>
</template>
<template #title>
<div class="flex items-center">
<MsIcon type="icon-icon_succeed_colorful" />
{{ t('ms.upload.uploadSuccess') }}
<a-tooltip :content="item.file.name">
<div class="one-line-text max-w-[80%] font-normal">{{ item.file.name }}</div>
</a-tooltip>
<slot name="title" :item="item"></slot>
</div>
</div>
<a-progress
v-else-if="item.status === UploadStatus.uploading"
:percent="asyncTaskStore.uploadFileTask.singleProgress / 100"
:show-text="false"
size="large"
class="w-[200px]"
/>
<div v-else-if="item.status === UploadStatus.error" class="text-[rgb(var(--danger-6))]">
{{ item.errMsg || t('ms.upload.uploadFail') }}
</template>
<template #description>
<div
v-if="item.status === UploadStatus.init"
class="text-[12px] leading-[16px] text-[var(--color-text-4)]"
>
{{ t('ms.upload.waiting') }}
</div>
<div
v-else-if="item.status === UploadStatus.done"
class="flex items-center gap-[8px] text-[12px] leading-[16px] text-[var(--color-text-4)]"
>
{{
`${formatFileSize(item.file.size)} ${t('ms.upload.uploadAt')} ${dayjs(item.uploadedTime).format(
'YYYY-MM-DD HH:mm:ss'
)}`
}}
<div class="flex items-center">
<MsIcon type="icon-icon_succeed_colorful" />
{{ t('ms.upload.uploadSuccess') }}
</div>
</div>
<a-progress
v-else-if="item.status === UploadStatus.uploading"
:percent="asyncTaskStore.uploadFileTask.singleProgress / 100"
:show-text="false"
size="large"
class="w-[200px]"
/>
<div v-else-if="item.status === UploadStatus.error" class="text-[rgb(var(--danger-6))]">
{{ item.errMsg || t('ms.upload.uploadFail') }}
</div>
</template>
</a-list-item-meta>
<template #actions>
<div class="flex items-center">
<MsButton
v-if="item.file.type.includes('image/')"
type="button"
status="primary"
class="!mr-0"
@click="handlePreview(item)"
>
{{ t('ms.upload.preview') }}
</MsButton>
<MsButton
v-if="item.status === UploadStatus.error"
type="button"
status="secondary"
class="!mr-0"
@click="reupload(item)"
>
{{ t('ms.upload.reUpload') }}
</MsButton>
<MsButton
v-if="props.showDelete"
type="button"
:status="item.deleteContent ? 'primary' : 'danger'"
class="!mr-[4px]"
@click="deleteFile(item)"
>
{{ t(item.deleteContent) || t('ms.upload.delete') }}
</MsButton>
<slot name="actions" :item="item"></slot>
</div>
</template>
</a-list-item-meta>
<template #actions>
<div class="flex items-center">
<MsButton
v-if="item.file.type.includes('image/')"
type="button"
status="primary"
class="!mr-0"
@click="handlePreview(item)"
>
{{ t('ms.upload.preview') }}
</MsButton>
<MsButton
v-if="item.status === UploadStatus.error"
type="button"
status="secondary"
class="!mr-0"
@click="reupload(item)"
>
{{ t('ms.upload.reUpload') }}
</MsButton>
<MsButton
v-if="props.showDelete"
type="button"
:status="item.deleteContent ? 'primary' : 'danger'"
class="!mr-[4px]"
@click="deleteFile(item)"
>
{{ t(item.deleteContent) || t('ms.upload.delete') }}
</MsButton>
<slot name="actions" :item="item"></slot>
</div>
</template>
</a-list-item>
</template>
</MsList>
<a-image-preview-group
v-model:visible="previewVisible"
v-model:current="previewCurrent"
infinite
:src-list="previewList"
/>
</a-list-item>
</template>
</MsList>
<div v-else class="flex w-full items-center gap-[8px]">
<div v-for="item of filterFileList" :key="item.uid" class="image-item">
<a-image
:src="item.url"
width="40"
height="40"
:preview="false"
class="cursor-pointer"
@click="handlePreview(item)"
/>
<icon-close-circle-fill class="image-item-close-icon" @click="deleteFile(item)" />
</div>
</div>
<a-image-preview-group
v-model:visible="previewVisible"
v-model:current="previewCurrent"
infinite
:src-list="previewList"
/>
</div>
</template>
<script setup lang="ts">
@ -123,13 +148,14 @@
import { UploadStatus } from '@/enums/uploadEnum';
import { getFileEnum, getFileIcon } from './iconMap';
import { getFileIcon } from './iconMap';
import type { MsFileItem } from './types';
const props = withDefaults(
defineProps<{
mode?: 'static' | 'remote'; // |
fileList: MsFileItem[];
showMode?: 'fileList' | 'imageList'; // , |
uploadFunc?: (params: any) => Promise<any>; //
requestParams?: Record<string, any>; //
route?: string; //
@ -144,6 +170,7 @@
mode: 'remote',
showTab: true,
showDelete: true,
showMode: 'fileList',
}
);
const emit = defineEmits<{
@ -294,4 +321,23 @@
});
</script>
<style lang="less" scoped></style>
<style lang="less" scoped>
.image-item {
@apply relative;
&:hover {
.image-item-close-icon {
@apply visible;
}
}
.image-item-close-icon {
@apply invisible absolute cursor-pointer rounded-full;
top: -7px;
right: -5px;
z-index: 10;
color: var(--color-text-4);
background-color: var(--color-text-n8);
cursor: pointer;
}
}
</style>

View File

@ -87,7 +87,7 @@ export function getFileEnum(fileType?: string): keyof typeof UploadAcceptEnum {
const keys = Object.keys(UploadAcceptEnum);
for (let i = 0; i < keys.length; i++) {
const key = keys[i] as unknown as keyof typeof UploadAcceptEnum;
if (UploadAcceptEnum[key].split(',').includes(`.${fileType.split('/')[1]}`)) {
if (UploadAcceptEnum[key].includes(fileType)) {
return key;
}
}
@ -98,10 +98,13 @@ export function getFileEnum(fileType?: string): keyof typeof UploadAcceptEnum {
/**
*
* @param item
* @param status
*/
export function getFileIcon(item: MsFileItem) {
if (item.status === UploadStatus.done) {
return FileIconMap[getFileEnum(item.file?.type)]?.[item.status] ?? FileIconMap.unknown[UploadStatus.done];
export function getFileIcon(item: MsFileItem, status?: UploadStatus) {
const fileType = item.file?.name.split('.').pop(); // 通过文件后缀判断文件类型
const _status = status || item.status;
if (_status === UploadStatus.done) {
return FileIconMap[getFileEnum(fileType)]?.[_status] ?? FileIconMap.unknown[UploadStatus.done];
}
return FileIconMap[getFileEnum(item.file?.type)]?.[UploadStatus.init] ?? FileIconMap.unknown[UploadStatus.done];
return FileIconMap[getFileEnum(fileType)]?.[_status || UploadStatus.init] ?? FileIconMap.unknown[UploadStatus.done];
}

View File

@ -16,6 +16,7 @@
}"
@change="handleChange"
@before-upload="beforeUpload"
@exceed-limit="() => Message.warning(t('ms.upload.overLimit', { limit: props.limit }))"
>
<template #upload-button>
<slot>
@ -23,8 +24,8 @@
<div class="ms-upload-icon-box">
<MsIcon
v-if="props.accept !== UploadAcceptEnum.none"
:type="FileIconMap[props.accept][UploadStatus.done]"
class="ms-upload-icon"
:type="fileIconType"
class="ms-upload-icon text-[var(--color-text-4)]"
/>
<div v-else class="ms-upload-icon ms-upload-icon--default"></div>
</div>
@ -69,7 +70,7 @@
import { UploadAcceptEnum, UploadStatus } from '@/enums/uploadEnum';
import { FileIconMap } from './iconMap';
import { FileIconMap, getFileIcon } from './iconMap';
import type { MsFileItem, UploadType } from './types';
const { t } = useI18n();
@ -92,6 +93,7 @@
isAllScreen?: boolean; //
cutHeight: number; //
fileTypeTip?: string; //
limit: number; //
}> & {
accept: UploadType;
fileList: MsFileItem[];
@ -124,6 +126,15 @@
}
);
const fileIconType = computed(() => {
// (绿)
if (fileList.value.length > 0 && !props.multiple) {
return getFileIcon(fileList.value[0], UploadStatus.done);
}
//
return FileIconMap[props.accept][UploadStatus.init];
});
async function beforeUpload(file: File) {
if (!props.multiple && fileList.value.length > 0) {
//

View File

@ -16,4 +16,5 @@ export default {
'ms.upload.success': 'Success',
'ms.upload.detail': 'Detail',
'ms.upload.fileTypeValidate': 'Only files in {type} format are supported',
'ms.upload.overLimit': 'The number of files exceeds the limit, the maximum number of uploads is {limit}',
};

View File

@ -16,4 +16,5 @@ export default {
'ms.upload.fail': '失败',
'ms.upload.detail': '查看详情',
'ms.upload.fileTypeValidate': '仅支持 {type} 格式的文件',
'ms.upload.overLimit': '文件数量超出限制,最大上传数量为 {limit} 个',
};

View File

@ -12,8 +12,8 @@
<template v-if="showProjectSelect">
<a-divider direction="vertical" class="ml-0" />
<a-select
v-model:model-value="appStore.currentProjectId"
class="w-[200px] focus-within:!bg-[var(--color-text-n8)] hover:!bg-[var(--color-text-n8)]"
:default-value="appStore.currentProjectId"
:bordered="false"
allow-search
@change="selectProject"
@ -21,7 +21,12 @@
<template #arrow-icon>
<icon-caret-down />
</template>
<a-tooltip v-for="project of projectList" :key="project.id" :mouse-enter-delay="500" :content="project.name">
<a-tooltip
v-for="project of appStore.projectList"
:key="project.id"
:mouse-enter-delay="500"
:content="project.name"
>
<a-option
:value="project.id"
:class="project.id === appStore.currentProjectId ? 'arco-select-option-selected' : ''"
@ -35,7 +40,7 @@
<TopMenu />
</div>
<ul v-if="!props.isPreview && !props.hideRight" class="right-side">
<li>
<!-- <li>
<a-tooltip :content="t('settings.navbar.search')">
<a-button type="secondary">
<template #icon>
@ -43,7 +48,7 @@
</template>
</a-button>
</a-tooltip>
</li>
</li> -->
<li>
<a-tooltip :content="t('settings.navbar.alerts')">
<div class="message-box-trigger">
@ -87,10 +92,25 @@
</a-button>
</a-tooltip>
<template #content>
<a-doption v-for="item in helpCenterList" :key="item.name" :value="item.name">
<component :is="item.icon"></component>
{{ t(item.name) }}
<a-doption value="doc">
<component :is="IconQuestionCircle"></component>
{{ t('settings.help.doc') }}
</a-doption>
<a-popover position="left">
<a-doption value="version">
<component :is="IconInfoCircle"></component>
{{ t('settings.help.versionInfo') }}
</a-doption>
<template #content>
<div
class="flex cursor-pointer items-center gap-[4px] text-[14px] text-[var(--color-text-1)]"
@click="copyVersion"
>
<div class="text-[var(--color-text-4)]">{{ t('settings.help.version') }}</div>
{{ appStore.version }}
</div>
</template>
</a-popover>
</template>
</a-dropdown>
</li>
@ -118,13 +138,15 @@
</template>
<script lang="ts" setup>
import { computed, onBeforeMount, Ref, ref, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useClipboard } from '@vueuse/core';
import { Message } from '@arco-design/web-vue';
import TopMenu from '@/components/business/ms-top-menu/index.vue';
import MessageBox from '../message-box/index.vue';
import { getProjectList, switchProject } from '@/api/modules/project-management/project';
import { switchProject } from '@/api/modules/project-management/project';
import { MENU_LEVEL, type PathMapRoute } from '@/config/pathMap';
import { useI18n } from '@/hooks/useI18n';
import usePathMap from '@/hooks/usePathMap';
@ -132,9 +154,6 @@
import useLocale from '@/locale/useLocale';
import useAppStore from '@/store/modules/app';
import useUserStore from '@/store/modules/user';
import { hasAnyPermission } from '@/utils/permission';
import type { ProjectListItem } from '@/models/setting/project';
import { IconInfoCircle, IconQuestionCircle } from '@arco-design/web-vue/es/icon';
@ -151,30 +170,13 @@
const router = useRouter();
const { t } = useI18n();
const projectList: Ref<ProjectListItem[]> = ref([]);
async function initProjects() {
try {
if (appStore.currentOrgId && hasAnyPermission(['SYSTEM_PARAMETER_SETTING_BASE:READ'])) {
const res = await getProjectList(appStore.getCurrentOrgId);
projectList.value = res;
} else {
projectList.value = [];
}
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
}
}
onBeforeMount(() => {
initProjects();
});
watch(
() => appStore.currentOrgId,
async () => {
initProjects();
appStore.initProjectList();
},
{
immediate: true,
}
);
@ -189,6 +191,7 @@
) {
appStore.setCurrentProjectId(value as string);
try {
appStore.showLoading();
await switchProject({
projectId: value as string,
userId: userStore.id || '',
@ -197,6 +200,7 @@
// eslint-disable-next-line no-console
console.log(error);
} finally {
appStore.hideLoading();
router.replace({
path: route.path,
query: {
@ -208,28 +212,15 @@
}
}
const helpCenterList = [
// {
// name: 'settings.help.guide',
// icon: IconCompass,
// route: '/help-center/guide',
// },
{
name: 'settings.help.doc',
icon: IconQuestionCircle,
route: '/help-center/guide',
},
// {
// name: 'settings.help.APIDoc',
// icon: IconFile,
// route: '/help-center/guide',
// },
{
name: 'settings.help.version',
icon: IconInfoCircle,
route: '/help-center/guide',
},
];
const { copy, isSupported } = useClipboard();
function copyVersion() {
if (isSupported) {
copy(appStore.version);
Message.success(t('common.copySuccess'));
} else {
Message.warning(t('common.copyNotSupport'));
}
}
const { changeLocale, currentLocale } = useLocale();
const locales = [...LOCALE_OPTIONS];
@ -334,4 +325,3 @@
}
}
</style>
@/models/setting/project @/api/modules/setting/project @/api/modules/project-management/project

View File

@ -0,0 +1,12 @@
// 文件关联用例-用例类型
export const associateFileSourceLocaleMap = {
BUG: 'project.fileManagement.caseTypeBug',
FUNCTIONAL_CASE: 'project.fileManagement.caseTypeFeature',
API_DEBUG: 'project.fileManagement.caseTypeApiDebug',
API_SCENARIO: 'project.fileManagement.caseTypeApiScene',
API_TEST_CASE: 'project.fileManagement.caseTypeApiCase',
API_DEFINITION: 'project.fileManagement.caseTypeApiDefine',
API_DEFINITION_MOCK: 'project.fileManagement.caseTypeApiMock',
};
export default {};

View File

@ -30,33 +30,27 @@ export default function useSelect(config: UseSelectOption) {
*
* @param options
*/
function calculateMaxTag(options?: CascaderOption[] | SelectOptionData[]) {
nextTick(() => {
if (config.selectRef.value && selectViewInner.value && Array.isArray(config.selectVal.value)) {
if (maxTagCount.value >= 1 && config.selectVal.value.length > maxTagCount.value) return; // 已经超过最大数量的展示,不需要再计算
function calculateMaxTag() {
setTimeout(() => {
if (config.selectRef.value && selectViewInner.value) {
const innerViewWidth = selectViewInner.value?.getBoundingClientRect().width;
let lastWidth = innerViewWidth - 60; // 60px 是“+N”的标签宽度+聚焦输入框的宽度
let tagCount = 0;
const values = Object.values(config.selectVal.value);
for (let i = 0; i < values.length; i++) {
const option = options?.find((e) => e[config.valueKey || 'value'] === values[i]);
const tagWidth = (option ? option[config.labelKey || 'label']?.length || 0 : values[i].length) * 12; // 计算每个标签渲染出来的宽度文字大小在12px时宽度也是 12px
if (lastWidth > tagWidth + 36) {
tagCount += 1;
lastWidth -= tagWidth + 36; // 36px是标签的边距、边框等宽度
} else {
lastWidth = 0; // 当剩余宽度已经放不下刚添加的标签,则剩余宽度置为 0避免后面再进行计算
break;
const childrenNodes = selectViewInner.value.children;
if (maxTagCount.value >= 1 && maxTagCount.value < config.selectVal.value.length) {
return;
}
for (let i = 0; i < childrenNodes.length; i++) {
const child = childrenNodes[i];
if (child.classList.contains('arco-tag')) {
lastWidth -= child.clientWidth - 6; // 6px 是标签的边距、边框等宽度
}
if (lastWidth < 30) {
// 30px 是隐藏的输入搜索框的宽度+边距+容错宽度
maxTagCount.value = Math.max(1, i - 1);
break;
} else {
maxTagCount.value = Infinity;
}
}
if (lastWidth === 0) {
maxTagCount.value = tagCount || 1;
}
if (tagCount <= 1 && values.length > 0) {
singleTagMaxWidth.value = innerViewWidth - 100; // 100px 是 60px + 标签边距边框和 x 图标等40px
} else {
singleTagMaxWidth.value = 0;
}
}
});

View File

@ -210,7 +210,7 @@
background-color: var(--color-bg-3);
transition: padding 0.2s cubic-bezier(0.34, 0.69, 0.1, 1);
.arco-layout-content {
padding: 16px 16px 0 0;
padding: 8px 16px 0 0;
min-height: 500px;
}
}

View File

@ -76,6 +76,7 @@ export default {
'common.expandAll': 'Expand all',
'common.copy': 'Copy',
'common.copySuccess': 'Copy successfully',
'common.copyNotSupport': 'Your browser does not support automatic copying. Please copy manually.',
'common.fork': 'Fork',
'common.forked': 'Forked',
'common.more': 'More',

View File

@ -15,6 +15,7 @@ export default {
'settings.help.guide': 'Use Guide',
'settings.help.doc': 'Help docs',
'settings.help.APIDoc': 'API docs',
'settings.help.versionInfo': 'Version info',
'settings.help.version': 'Version',
'settings.menu': 'Menu',
'settings.tabBar': 'Tab Bar',

View File

@ -77,6 +77,7 @@ export default {
'common.expandAll': '展开全部',
'common.copy': '复制',
'common.copySuccess': '复制成功',
'common.copyNotSupport': '您的浏览器不支持自动复制,请手动复制',
'common.fork': '关注',
'common.forked': '已关注',
'common.more': '更多',

View File

@ -15,7 +15,8 @@ export default {
'settings.help.guide': '新手指引',
'settings.help.doc': '帮助文档',
'settings.help.APIDoc': 'API文档',
'settings.help.version': '版本信息',
'settings.help.versionInfo': '版本信息',
'settings.help.version': '版本',
'settings.menu': '菜单栏',
'settings.tabBar': '多页签',
'settings.footer': '底部',

View File

@ -1,5 +1,6 @@
import { BatchApiParams, TableQueryParams } from '@/models/common';
export type FileStorageType = 'GIT' | 'MINIO';
// 文件列表查询参数
export interface FileListQueryParams extends TableQueryParams {
moduleIds: string[];
@ -25,13 +26,13 @@ export interface FileItem {
branch?: string; // 分支
filePath?: string; // 文件路径
fileVersion?: string; // 文件版本
storage?: FileStorageType; // 存储方式
}
// 文件详情
export interface FileDetail extends FileItem {
projectId: string;
moduleName: string; // 所属模块名
moduleId: string;
storage?: string; // 存储方式
createUser: string;
createTime: number;
}

View File

@ -120,3 +120,8 @@ export interface RegisterByInviteParams {
password: string;
phone: string;
}
export interface CreateUserResult {
errorEmails: Record<string, any>;
successList: any[];
}

View File

@ -33,6 +33,16 @@ const ApiTest: AppRouteRecordRaw = {
isTopMenu: true,
},
},
{
path: 'management',
name: ApiTestRouteEnum.API_TEST_MANAGEMENT,
component: () => import('@/views/api-test/management/index.vue'),
meta: {
locale: 'menu.apiTest.management',
roles: ['*'],
isTopMenu: true,
},
},
],
};

View File

@ -95,7 +95,7 @@ const ProjectManagement: AppRouteRecordRaw = {
component: () => import('@/views/project-management/projectAndPermission/projectVersion/index.vue'),
meta: {
locale: 'project.permission.projectVersion',
roles: ['*'],
roles: ['PROJECT_VERSION:READ'],
},
},
// 成员
@ -252,7 +252,7 @@ const ProjectManagement: AppRouteRecordRaw = {
component: () => import('@/views/project-management/messageManagement/edit.vue'),
meta: {
locale: 'menu.projectManagement.messageManagementEdit',
roles: ['*'],
roles: ['PROJECT_MESSAGE:READ+UPDATE'],
breadcrumbs: [
{
name: ProjectManagementRouteEnum.PROJECT_MANAGEMENT_MESSAGE_MANAGEMENT,

View File

@ -4,7 +4,7 @@ import { cloneDeep } from 'lodash-es';
import type { BreadcrumbItem } from '@/components/business/ms-breadcrumb/types';
import { getProjectInfo } from '@/api/modules/project-management/basicInfo';
import { getProjectList } from '@/api/modules/project-management/project';
import { getPageConfig } from '@/api/modules/setting/config';
import { getPackageType, getSystemVersion } from '@/api/modules/system';
import { getMenuList } from '@/api/modules/user';
@ -13,6 +13,7 @@ import { useI18n } from '@/hooks/useI18n';
import { watchStyle, watchTheme } from '@/utils/theme';
import type { PageConfig, PageConfigKeys, Style, Theme } from '@/models/setting/config';
import { ProjectListItem } from '@/models/setting/project';
import type { AppState } from './types';
import type { NotificationReturn } from '@arco-design/web-vue/es/notification/interface';
@ -59,6 +60,7 @@ const useAppStore = defineStore('app', {
...defaultPlatformConfig,
},
packageType: '',
projectList: [] as ProjectListItem[],
}),
getters: {
@ -218,6 +220,7 @@ const useAppStore = defineStore('app', {
try {
this.version = await getSystemVersion();
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
}
},
@ -228,6 +231,7 @@ const useAppStore = defineStore('app', {
try {
this.packageType = await getPackageType();
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
}
},
@ -293,6 +297,7 @@ const useAppStore = defineStore('app', {
window.document.title = this.pageConfig.title;
}
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
}
},
@ -303,6 +308,19 @@ const useAppStore = defineStore('app', {
async setCurrentMenuConfig(menuConfig: string[]) {
this.currentMenuConfig = menuConfig;
},
async initProjectList() {
try {
if (this.currentOrgId) {
const res = await getProjectList(this.getCurrentOrgId);
this.projectList = res;
} else {
this.projectList = [];
}
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
}
},
},
persist: {
paths: ['currentOrgId', 'currentProjectId', 'pageConfig'],

View File

@ -2,7 +2,7 @@ import type { MsFileItem } from '@/components/pure/ms-upload/types';
import type { BreadcrumbItem } from '@/components/business/ms-breadcrumb/types';
import type { LoginConfig, PageConfig, PlatformConfig, ThemeConfig } from '@/models/setting/config';
import { UserGroupAuthSetting } from '@/models/setting/usergroup';
import { ProjectListItem } from '@/models/setting/project';
import type { RouteRecordNormalized, RouteRecordRaw } from 'vue-router';
@ -38,6 +38,7 @@ export interface AppState {
innerHeight: number;
currentMenuConfig: string[];
packageType: string;
projectList: ProjectListItem[];
}
export interface UploadFileTaskState {

View File

@ -111,8 +111,11 @@ const useUserStore = defineStore('user', {
this.logoutCallBack();
}
},
// 是否已经登录
async isLogin() {
/**
*
* @param forceSet id id
*/
async isLogin(forceSet = false) {
try {
const res = await userIsLogin();
const appStore = useAppStore();
@ -120,10 +123,10 @@ const useUserStore = defineStore('user', {
this.setInfo(res);
const { organizationId, projectId } = getHashParameters();
// 如果访问页面的时候携带了组织 ID和项目 ID则不设置
if (!organizationId) {
if (!organizationId || forceSet) {
appStore.setCurrentOrgId(res.lastOrganizationId || '');
}
if (!projectId) {
if (!projectId || forceSet) {
appStore.setCurrentProjectId(res.lastProjectId || '');
}
return true;

View File

@ -1,7 +1,5 @@
import JSEncrypt from 'jsencrypt';
import { codeCharset } from '@/config/apiTest';
import { isObject } from './is';
type TargetContext = '_self' | '_parent' | '_blank' | '_top';

View File

@ -1,12 +1,21 @@
<template>
<div class="font-medium" :style="{ color: methodColor }">{{ props.method }}</div>
<MsTag
v-if="props.isTag"
:self-style="{ border: `1px solid ${methodColor}`, color: methodColor, backgroundColor: 'white' }"
>
{{ props.method }}
</MsTag>
<div v-else class="font-medium" :style="{ color: methodColor }">{{ props.method }}</div>
</template>
<script setup lang="ts">
import MsTag from '@/components/pure/ms-tag/ms-tag.vue';
import { RequestMethods } from '@/enums/apiEnum';
const props = defineProps<{
method: RequestMethods;
isTag?: boolean; //
}>();
const colorMaps = [

View File

@ -0,0 +1,38 @@
<template>
<a-select
v-model:model-value="method"
:placeholder="t('common.pleaseSelect')"
@change="(val) => emit('change', val as string)"
>
<template #label="{ data }">
<apiMethodName :method="data.value" class="inline-block" />
</template>
<a-option v-for="item of RequestMethods" :key="item" :value="item">
<apiMethodName :method="item" />
</a-option>
</a-select>
</template>
<script setup lang="ts">
import { useVModel } from '@vueuse/core';
import apiMethodName from '@/views/api-test/components/apiMethodName.vue';
import { useI18n } from '@/hooks/useI18n';
import { RequestMethods } from '@/enums/apiEnum';
const props = defineProps<{
modelValue: string;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
(e: 'change', value: string): void;
}>();
const { t } = useI18n();
const method = useVModel(props, 'modelValue', emit);
</script>
<style lang="less" scoped></style>

View File

@ -0,0 +1,52 @@
<template>
<MsTag :self-style="status.style"> {{ status.text }}</MsTag>
</template>
<script setup lang="ts">
import MsTag from '@/components/pure/ms-tag/ms-tag.vue';
import { useI18n } from '@/hooks/useI18n';
import { RequestDefinitionStatus } from '@/enums/apiEnum';
const props = defineProps<{
status: RequestDefinitionStatus;
}>();
const { t } = useI18n();
const statusMap = {
[RequestDefinitionStatus.DEPRECATED]: {
bgColor: 'var(--color-text-n8)',
color: 'var(--color-text-4)',
text: 'apiTestManagement.deprecate',
},
[RequestDefinitionStatus.PROCESSING]: {
bgColor: 'rgb(var(--link-2))',
color: 'rgb(var(--link-5))',
text: 'apiTestManagement.processing',
},
[RequestDefinitionStatus.DEBUGGING]: {
bgColor: 'rgb(var(--link-2))',
color: 'rgb(var(--link-6))',
text: 'apiTestManagement.debugging',
},
[RequestDefinitionStatus.DONE]: {
bgColor: 'rgb(var(--success-2))',
color: 'rgb(var(--success-6))',
text: 'apiTestManagement.done',
},
};
const status = computed(() => {
const config = statusMap[props.status];
return {
style: {
backgroundColor: config.bgColor,
color: config.color,
},
text: t(config.text),
};
});
</script>
<style lang="less" scoped></style>

View File

@ -66,7 +66,7 @@
}
const props = defineProps<{
mode: 'add' | 'rename' | 'fileRename' | 'fileUpdateDesc' | 'repositoryRename';
mode: 'add' | 'rename';
visible?: boolean;
title?: string;
allNames: string[];

View File

@ -26,14 +26,11 @@
@change="handleActiveDebugChange"
/>
<a-input-group class="flex-1">
<a-select v-model:model-value="activeDebug.method" class="w-[140px]" @change="handleActiveDebugChange">
<template #label="{ data }">
<apiMethodName :method="data.value" class="inline-block" />
</template>
<a-option v-for="method of RequestMethods" :key="method" :value="method">
<apiMethodName :method="method" />
</a-option>
</a-select>
<apiMethodSelect
v-model:model-value="activeDebug.method"
class="w-[140px]"
@change="handleActiveDebugChange"
/>
<a-input
v-model:model-value="activeDebug.url"
:max-length="255"
@ -198,6 +195,7 @@
import debugRest from './rest.vue';
import debugSetting from './setting.vue';
import apiMethodName from '@/views/api-test/components/apiMethodName.vue';
import apiMethodSelect from '@/views/api-test/components/apiMethodSelect.vue';
import { useI18n } from '@/hooks/useI18n';
import { registerCatchSaveShortcut, removeCatchSaveShortcut } from '@/utils/event';

View File

@ -106,7 +106,7 @@
import type { ActionsItem } from '@/components/pure/ms-table-more-action/types';
import MsTree from '@/components/business/ms-tree/index.vue';
import type { MsTreeNodeData } from '@/components/business/ms-tree/types';
import popConfirm from './popConfirm.vue';
import popConfirm from '@/views/api-test/components/popConfirm.vue';
import { deleteReviewModule, getReviewModules, moveReviewModule } from '@/api/modules/case-management/caseReview';
import { useI18n } from '@/hooks/useI18n';
@ -336,7 +336,7 @@
<style lang="less" scoped>
.folder {
@apply flex cursor-pointer items-center justify-between;
@apply flex items-center justify-between;
padding: 8px 4px;
border-radius: var(--border-radius-small);
@ -344,7 +344,7 @@
background-color: rgb(var(--primary-1));
}
.folder-text {
@apply flex cursor-pointer items-center;
@apply flex items-center;
.folder-icon {
margin-right: 4px;
color: var(--color-text-4);

View File

@ -84,7 +84,7 @@ export default {
'apiTestDebug.storageByResult': '按结果存储',
'apiTestDebug.storageByResultPlaceholder': '如 {a}',
'apiTestDebug.extractParameter': '提取参数',
'apiTestDebug.searchTip': '请输入分组名称',
'apiTestDebug.searchTip': '请输入模块/请求名称',
'apiTestDebug.allRequest': '全部请求',
'apiTestDebug.deleteFolderTipTitle': '是否删除 `{name}` 模块?',
'apiTestDebug.deleteFolderTipContent': '该操作会删除模块及其下所有资源,请谨慎操作!',

View File

@ -0,0 +1,409 @@
<template>
<MsDrawer
v-model:visible="visible"
width="100%"
:popup-container="props.popupContainer"
:closable="false"
:ok-disabled="disabledConfirm"
disabled-width-drag
no-title
@confirm="confirmImport"
@cancel="cancelImport"
>
<template #title> </template>
<div class="flex items-center justify-between p-[12px_8px]">
<div class="font-medium text-[var(--color-text-1)]">{{ t('apiTestManagement.importApi') }}</div>
<a-radio-group v-model:model-value="importType" type="button">
<a-radio value="file">{{ t('apiTestManagement.fileImport') }}</a-radio>
<a-radio value="time">{{ t('apiTestManagement.timeImport') }}</a-radio>
</a-radio-group>
</div>
<div
v-if="importType === 'file'"
class="my-[16px] flex items-center gap-[16px] rounded-[var(--border-radius-small)] bg-[var(--color-text-n9)] p-[16px]"
>
<div
v-for="item of importFormatList"
:key="item.value"
:class="`import-item ${importFormat === item.value ? 'import-item--active' : ''}`"
@click="() => setActiveImportFormat(item.value)"
>
<div class="flex h-[24px] w-[24px] items-center justify-center rounded-[var(--border-radius-small)] bg-white">
<MsIcon :type="item.icon" :class="`text-[${item.iconColor}]`" :size="18" />
</div>
<div class="text-[var(--color-text-1)]">{{ item.name }}</div>
</div>
</div>
<a-form ref="importFormRef" :model="importForm" layout="vertical">
<template v-if="importType === 'file'">
<a-form-item :label="t('apiTestManagement.belongModule')">
<a-tree-select
v-model:modelValue="importForm.module"
:data="moduleTree"
class="w-[436px]"
:field-names="{ title: 'name', key: 'id', children: 'children' }"
allow-search
/>
</a-form-item>
<a-form-item>
<template #label>
<div class="flex items-center gap-[2px]">
{{ t('apiTestManagement.importMode') }}
<a-tooltip position="right">
<icon-question-circle
class="ml-[4px] text-[var(--color-text-4)] hover:text-[rgb(var(--primary-5))]"
size="16"
/>
<template #content>
<div>{{ t('apiTestManagement.importModeTip1') }}</div>
<div>{{ t('apiTestManagement.importModeTip2') }}</div>
<div>{{ t('apiTestManagement.importModeTip3') }}</div>
<div>{{ t('apiTestManagement.importModeTip4') }}</div>
<div class="h-[22px] w-full"></div>
<div>{{ t('apiTestManagement.importModeTip5') }}</div>
<div>{{ t('apiTestManagement.importModeTip6') }}</div>
<div>{{ t('apiTestManagement.importModeTip7') }}</div>
</template>
</a-tooltip>
</div>
</template>
<a-select v-model:model-value="importForm.mode" class="w-[240px]">
<a-option value="cover">{{ t('apiTestManagement.cover') }}</a-option>
<a-option value="uncover">{{ t('apiTestManagement.uncover') }}</a-option>
</a-select>
</a-form-item>
<a-collapse v-model:active-key="moreSettingActive" :bordered="false" :show-expand-icon="false">
<a-collapse-item :key="1">
<template #header>
<MsButton
type="text"
@click="() => (moreSettingActive.length > 0 ? (moreSettingActive = []) : (moreSettingActive = [1]))"
>
{{ t('apiTestDebug.moreSetting') }}
<icon-down v-if="moreSettingActive.length > 0" class="text-rgb(var(--primary-5))" />
<icon-right v-else class="text-rgb(var(--primary-5))" />
</MsButton>
</template>
<div class="mt-[16px]">
<a-checkbox v-model:model-value="importForm.syncImportCase" class="mr-[24px]">
{{ t('apiTestManagement.syncImportCase') }}
</a-checkbox>
<a-checkbox v-model:model-value="importForm.syncUpdateDirectory">
{{ t('apiTestManagement.syncUpdateDirectory') }}
</a-checkbox>
</div>
</a-collapse-item>
</a-collapse>
<a-form-item :label="t('apiTestManagement.importType')" class="mt-[8px]">
<a-radio-group v-model:model-value="importForm.importType" type="button">
<a-radio value="file">{{ t('apiTestManagement.fileImport') }}</a-radio>
<a-radio value="url">{{ t('apiTestManagement.urlImport') }}</a-radio>
</a-radio-group>
</a-form-item>
<MsUpload
v-if="importForm.importType === 'file'"
v-model:file-list="importForm.file"
accept="json"
:auto-upload="false"
draggable
size-unit="MB"
class="w-full"
>
<template #subText>
<div class="flex">
{{ t('apiTestManagement.importSwaggerFileTip1') }}
<div class="text-[rgb(var(--warning-6))]">{{ t('apiTestManagement.importSwaggerFileTip2') }}</div>
{{ t('apiTestManagement.importSwaggerFileTip3') }}
</div>
</template>
</MsUpload>
<template v-else>
<a-form-item
field="url"
label="SwaggerURL"
asterisk-position="end"
:rules="[{ required: true, message: t('apiTestManagement.swaggerURLRequired') }]"
>
<a-input
v-model:model-value="importForm.url"
:placeholder="t('apiTestManagement.urlImportPlaceholder')"
class="w-[700px]"
allow-clear
></a-input>
</a-form-item>
<div class="mb-[16px] flex items-center gap-[8px]">
<a-switch v-model:model-value="importForm.basicAuth" type="line" size="small"></a-switch>
{{ t('apiTestManagement.basicAuth') }}
</div>
<template v-if="importForm.basicAuth">
<a-form-item
field="account"
:label="t('apiTestManagement.account')"
asterisk-position="end"
:rules="[{ required: true, message: t('apiTestManagement.accountRequired') }]"
>
<a-input
v-model:model-value="importForm.account"
:placeholder="t('common.pleaseInput')"
class="w-[500px]"
allow-clear
></a-input>
</a-form-item>
<a-form-item
field="password"
:label="t('apiTestManagement.password')"
asterisk-position="end"
:rules="[{ required: true, message: t('apiTestManagement.passwordRequired') }]"
autocomplete="new-password"
>
<a-input-password
v-model:model-value="importForm.password"
:placeholder="t('common.pleaseInput')"
class="w-[500px]"
autocomplete="new-password"
allow-clear
></a-input-password>
</a-form-item>
</template>
</template>
</template>
<template v-else>
<a-form-item
field="taskName"
:label="t('apiTestManagement.taskName')"
:rules="[{ required: true, message: t('apiTestManagement.taskNameRequired') }]"
>
<div class="flex w-full items-center gap-[8px]">
<a-input
v-model:model-value="importForm.taskName"
:placeholder="t('apiTestManagement.taskNamePlaceholder')"
:max-length="255"
class="flex-1"
></a-input>
<MsButton type="text">{{ t('apiTestManagement.timeTaskList') }}</MsButton>
</div>
</a-form-item>
<a-form-item
field="url"
label="SwaggerURL"
asterisk-position="end"
:rules="[{ required: true, message: t('apiTestManagement.swaggerURLRequired') }]"
>
<a-input
v-model:model-value="importForm.url"
:placeholder="t('apiTestManagement.urlImportPlaceholder')"
class="w-[700px]"
allow-clear
></a-input>
</a-form-item>
<div class="mb-[16px] flex items-center gap-[8px]">
<a-switch v-model:model-value="importForm.basicAuth" type="line" size="small"></a-switch>
{{ t('apiTestManagement.basicAuth') }}
</div>
<template v-if="importForm.basicAuth">
<a-form-item
field="account"
:label="t('apiTestManagement.account')"
:rules="[{ required: true, message: t('apiTestManagement.accountRequired') }]"
asterisk-position="end"
>
<a-input
v-model:model-value="importForm.account"
:placeholder="t('common.pleaseInput')"
class="w-[500px]"
allow-clear
/>
</a-form-item>
<a-form-item
field="password"
:label="t('apiTestManagement.password')"
:rules="[{ required: true, message: t('apiTestManagement.passwordRequired') }]"
asterisk-position="end"
autocomplete="new-password"
>
<a-input-password
v-model:model-value="importForm.password"
:placeholder="t('common.pleaseInput')"
class="w-[500px]"
autocomplete="new-password"
allow-clear
/>
</a-form-item>
</template>
<a-form-item :label="t('apiTestManagement.belongModule')">
<a-tree-select
v-model:modelValue="importForm.module"
:data="moduleTree"
class="w-[436px]"
:field-names="{ title: 'name', key: 'id', children: 'children' }"
allow-search
/>
</a-form-item>
<a-form-item>
<template #label>
<div class="flex items-center gap-[2px]">
{{ t('apiTestManagement.importMode') }}
<a-tooltip position="right">
<icon-question-circle
class="ml-[4px] text-[var(--color-text-4)] hover:text-[rgb(var(--primary-5))]"
size="16"
/>
<template #content>
<div>{{ t('apiTestManagement.importModeTip1') }}</div>
<div>{{ t('apiTestManagement.importModeTip2') }}</div>
<div>{{ t('apiTestManagement.importModeTip3') }}</div>
<div>{{ t('apiTestManagement.importModeTip4') }}</div>
<div class="h-[22px] w-full"></div>
<div>{{ t('apiTestManagement.importModeTip5') }}</div>
<div>{{ t('apiTestManagement.importModeTip6') }}</div>
<div>{{ t('apiTestManagement.importModeTip7') }}</div>
</template>
</a-tooltip>
</div>
</template>
<a-select v-model:model-value="importForm.mode" class="w-[240px]">
<a-option value="cover">{{ t('apiTestManagement.cover') }}</a-option>
<a-option value="uncover">{{ t('apiTestManagement.uncover') }}</a-option>
</a-select>
</a-form-item>
<a-form-item :label="t('apiTestManagement.syncFrequency')">
<a-select v-model:model-value="importForm.syncFrequency" class="w-[240px]">
<template #label="{ data }">
<div class="flex items-center">
{{ data.value }}
<div class="ml-[4px] text-[var(--color-text-4)]">{{ data.label.split('?')[1] }}</div>
</div>
</template>
<a-option v-for="item of syncFrequencyOptions" :key="item.value" :value="item.value" class="block">
<div class="flex w-full items-center justify-between">
{{ item.value }}
<div class="ml-[4px] text-[var(--color-text-4)]">{{ item.label }}</div>
</div>
</a-option>
<template #footer>
<div class="flex items-center p-[4px_8px]">
<MsButton type="text">{{ t('apiTestManagement.customFrequency') }}</MsButton>
</div>
</template>
</a-select>
</a-form-item>
</template>
</a-form>
</MsDrawer>
</template>
<script setup lang="ts">
import { useVModel } from '@vueuse/core';
import { FormInstance, Message } from '@arco-design/web-vue';
import MsButton from '@/components/pure/ms-button/index.vue';
import MsDrawer from '@/components/pure/ms-drawer/index.vue';
import MsIcon from '@/components/pure/ms-icon-font/index.vue';
import MsUpload from '@/components/pure/ms-upload/index.vue';
import { useI18n } from '@/hooks/useI18n';
import { mapTree } from '@/utils';
import { ModuleTreeNode } from '@/models/projectManagement/file';
import { RequestImportFormat } from '@/enums/apiEnum';
const props = defineProps<{
visible: boolean;
moduleTree: ModuleTreeNode[];
popupContainer?: string;
}>();
const emit = defineEmits(['update:visible']);
const { t } = useI18n();
const visible = useVModel(props, 'visible', emit);
const importType = ref<'file' | 'time'>('file');
const importFormat = ref<keyof typeof RequestImportFormat>('SWAGGER');
const importFormatList = [
{
name: 'Swagger',
value: RequestImportFormat.SWAGGER,
icon: 'icon-icon_swagger',
iconColor: 'rgb(var(--success-7))',
},
];
function setActiveImportFormat(format: RequestImportFormat) {
importFormat.value = format;
}
const defaultForm = {
taskName: '',
module: 'root',
mode: 'cover',
syncImportCase: true,
syncUpdateDirectory: false,
importType: 'file',
file: [],
url: '',
basicAuth: false,
account: '',
password: '',
syncFrequency: '0 0 0/1 * ?',
};
const importForm = ref({ ...defaultForm });
const importFormRef = ref<FormInstance>();
const moreSettingActive = ref<number[]>([]);
const disabledConfirm = computed(() => {
if (importType.value === 'file') {
if (importForm.value.importType === 'file') {
return !importForm.value.file.length;
}
return !importForm.value.url;
}
return !importForm.value.taskName || !importForm.value.url;
});
const moduleTree = computed(() => mapTree(props.moduleTree, (node) => ({ ...node, draggable: false })));
const syncFrequencyOptions = [
{ label: t('apiTestManagement.timeTaskHour'), value: '0 0 0/1 * ?' },
{ label: t('apiTestManagement.timeTaskSixHour'), value: '0 0 0/6 * ?' },
{ label: t('apiTestManagement.timeTaskTwelveHour'), value: '0 0 0/12 * ?' },
{ label: t('apiTestManagement.timeTaskDay'), value: '0 0 0 * ?' },
];
function cancelImport() {
visible.value = false;
importForm.value = { ...defaultForm };
importFormRef.value?.resetFields();
}
function confirmImport() {
importFormRef.value?.validate(async (errors) => {
if (!errors) {
try {
Message.success(t('common.importSuccess'));
cancelImport();
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
}
}
});
}
</script>
<style lang="less" scoped>
.import-item {
@apply flex cursor-pointer items-center bg-white;
padding: 8px;
gap: 6px;
width: 200px;
border-radius: var(--border-radius-small);
}
.import-item--active {
border: 1px solid rgb(var(--primary-5));
background-color: rgb(var(--primary-1));
}
:deep(.arco-form-item) {
margin-bottom: 16px;
}
:deep(.arco-select-view-value::after) {
@apply hidden;
}
</style>

View File

@ -0,0 +1,821 @@
<template>
<div class="border-b border-[var(--color-text-n8)] px-[22px] pb-[16px]">
<MsEditableTab
v-model:active-tab="activeRequestTab"
v-model:tabs="apiTabs"
:more-action-list="tabMoreActionList"
@add="addDebugTab"
@close="closeDebugTab"
@change="setActiveDebug"
@more-action-select="handleTabMoreActionSelect"
>
<template #label="{ tab }">
<apiMethodName v-if="tab.id !== 'all'" :method="tab.method" class="mr-[4px]" />
{{ tab.label }}
</template>
</MsEditableTab>
</div>
<div class="p-[16px_22px]">
<div class="flex items-center justify-between">
<div class="flex items-center gap-[8px]">
<a-switch v-model:model-value="showSubdirectory" size="small" type="line"></a-switch>
{{ t('apiTestManagement.showSubdirectory') }}
</div>
<div class="flex items-center gap-[8px]">
<a-button type="outline" class="arco-btn-outline--secondary !p-[8px]">
<template #icon>
<icon-location class="text-[var(--color-text-4)]" />
</template>
</a-button>
<MsSelect
v-model:model-value="checkedEnv"
mode="static"
:options="envOptions"
class="w-[200px]"
:search-keys="['label']"
allow-search
/>
<a-input-search
v-model:model-value="keyword"
:placeholder="t('apiTestManagement.searchPlaceholder')"
allow-clear
class="mr-[8px] w-[240px]"
@search="loadApiList"
@press-enter="loadApiList"
/>
<a-button type="outline" class="arco-btn-outline--secondary !p-[8px]">
<template #icon>
<icon-refresh class="text-[var(--color-text-4)]" />
</template>
</a-button>
</div>
</div>
<ms-base-table
v-bind="propsRes"
:action-config="batchActions"
no-disable
filter-icon-align-left
v-on="propsEvent"
@selected-change="handleTableSelect"
@batch-action="handleTableBatch"
>
<template #typeFilter="{ columnConfig }">
<a-trigger v-model:popup-visible="typeFilterVisible" trigger="click" @popup-visible-change="handleFilterHidden">
<a-button type="text" class="arco-btn-text--secondary" @click="typeFilterVisible = true">
{{ t(columnConfig.title as string) }}
<icon-down :class="typeFilterVisible ? 'text-[rgb(var(--primary-5))]' : ''" />
</a-button>
<template #content>
<div class="arco-table-filters-content">
<div class="flex items-center justify-center px-[6px] py-[2px]">
<a-checkbox-group v-model:model-value="typeFilters" direction="vertical" size="small">
<a-checkbox v-for="key of RequestMethods" :key="key" :value="key">
<apiMethodName :method="key" />
</a-checkbox>
</a-checkbox-group>
</div>
</div>
</template>
</a-trigger>
</template>
<template #statusFilter="{ columnConfig }">
<a-trigger
v-model:popup-visible="statusFilterVisible"
trigger="click"
@popup-visible-change="handleFilterHidden"
>
<a-button type="text" class="arco-btn-text--secondary" @click="statusFilterVisible = true">
{{ t(columnConfig.title as string) }}
<icon-down :class="statusFilterVisible ? 'text-[rgb(var(--primary-5))]' : ''" />
</a-button>
<template #content>
<div class="arco-table-filters-content">
<div class="flex items-center justify-center px-[6px] py-[2px]">
<a-checkbox-group v-model:model-value="statusFilters" direction="vertical" size="small">
<a-checkbox v-for="key of RequestDefinitionStatus" :key="key" :value="key">
<apiStatus :status="key" />
</a-checkbox>
</a-checkbox-group>
</div>
</div>
</template>
</a-trigger>
</template>
<template #type="{ record }">
<apiMethodName :method="record.type" is-tag />
</template>
<template #status="{ record }">
<a-select
v-model:model-value="record.status"
:placeholder="t('common.pleaseSelect')"
class="param-input w-full"
@change="() => handleStatusChange(record)"
>
<template #label>
<apiStatus :status="record.status" />
</template>
<a-option v-for="item of RequestDefinitionStatus" :key="item" :value="item">
<apiStatus :status="item" />
</a-option>
</a-select>
</template>
<template #action="{ record }">
<MsButton type="text" class="!mr-0">
{{ t('apiTestManagement.execute') }}
</MsButton>
<a-divider direction="vertical" :margin="8"></a-divider>
<MsButton type="text" class="!mr-0">
{{ t('common.copy') }}
</MsButton>
<a-divider direction="vertical" :margin="8"></a-divider>
<MsTableMoreAction :list="tableMoreActionList" @select="handleTableMoreActionSelect($event, record)" />
</template>
</ms-base-table>
</div>
<a-modal v-model:visible="showBatchModal" title-align="start" class="ms-modal-upload ms-modal-medium" :width="480">
<template #title>
{{ t('common.edit') }}
<div class="text-[var(--color-text-4)]">
{{
t('apiTestManagement.batchModalSubTitle', {
count: batchParams.currentSelectCount || tableSelected.length,
})
}}
</div>
</template>
<a-form ref="batchFormRef" class="rounded-[4px]" :model="batchForm" layout="vertical">
<a-form-item
field="attr"
:label="t('apiTestManagement.chooseAttr')"
:rules="[{ required: true, message: t('apiTestManagement.attrRequired') }]"
asterisk-position="end"
>
<a-select v-model="batchForm.attr" :placeholder="t('common.pleaseSelect')">
<a-option v-for="item of attrOptions" :key="item.value" :value="item.value">
{{ t(item.name) }}
</a-option>
</a-select>
</a-form-item>
<a-form-item
v-if="batchForm.attr === 'tag'"
field="values"
:label="t('apiTestManagement.batchUpdate')"
:rules="[{ required: true, message: t('apiTestManagement.valueRequired') }]"
asterisk-position="end"
class="mb-0"
>
<MsTagsInput
v-model:model-value="batchForm.values"
placeholder="common.tagsInputPlaceholder"
allow-clear
unique-value
retain-input-value
/>
</a-form-item>
<a-form-item
v-else
field="value"
:label="t('apiTestManagement.batchUpdate')"
:rules="[{ required: true, message: t('apiTestManagement.valueRequired') }]"
asterisk-position="end"
class="mb-0"
>
<apiMethodSelect
v-if="batchForm.attr === 'type'"
v-model:model-value="batchForm.value"
@change="handleActiveDebugChange"
/>
<a-select
v-else
v-model="batchForm.value"
:placeholder="t('common.pleaseSelect')"
:disabled="batchForm.attr === ''"
>
<a-option v-for="item of valueOptions" :key="item.value" :value="item.value">
{{ t(item.name) }}
</a-option>
</a-select>
</a-form-item>
</a-form>
<template #footer>
<a-button type="secondary" :disabled="batchUpdateLoading" @click="cancelBatch">
{{ t('common.cancel') }}
</a-button>
<a-button type="primary" :loading="batchUpdateLoading" @click="batchUpdate">
{{ t('common.update') }}
</a-button>
</template>
</a-modal>
<a-modal
v-model:visible="moveModalVisible"
title-align="start"
class="ms-modal-no-padding ms-modal-small"
:mask-closable="false"
:ok-text="t('apiTestManagement.batchMoveConfirm')"
:ok-button-props="{ disabled: selectedModuleKeys.length === 0 }"
:cancel-button-props="{ disabled: batchMoveApiLoading }"
:on-before-ok="handleApiMove"
@close="handleMoveApiModalCancel"
>
<template #title>
<div class="flex items-center">
{{ isBatchMove ? t('common.batchMove') : t('common.move') }}
<div
class="one-line-text ml-[4px] max-w-[70%] text-[var(--color-text-4)]"
:title="
isBatchMove
? t('apiTestManagement.batchModalSubTitle', { count: tableSelected.length })
: `(${activeApi?.name})`
"
>
{{
isBatchMove
? t('apiTestManagement.batchModalSubTitle', { count: tableSelected.length })
: `(${activeApi?.name})`
}}
</div>
</div>
</template>
<moduleTree
v-if="moveModalVisible"
:is-expand-all="true"
is-modal
:active-module="props.activeModule"
@folder-node-select="folderNodeSelect"
/>
</a-modal>
</template>
<script setup lang="ts">
import { FormInstance, Message } from '@arco-design/web-vue';
import dayjs from 'dayjs';
import MsButton from '@/components/pure/ms-button/index.vue';
import MsEditableTab from '@/components/pure/ms-editable-tab/index.vue';
import { TabItem } from '@/components/pure/ms-editable-tab/types';
import MsBaseTable from '@/components/pure/ms-table/base-table.vue';
import type { BatchActionParams, BatchActionQueryParams, MsTableColumn } from '@/components/pure/ms-table/type';
import useTable from '@/components/pure/ms-table/useTable';
import MsTableMoreAction from '@/components/pure/ms-table-more-action/index.vue';
import { ActionsItem } from '@/components/pure/ms-table-more-action/types';
import MsTagsInput from '@/components/pure/ms-tags-input/index.vue';
import MsSelect from '@/components/business/ms-select';
import moduleTree from '../moduleTree.vue';
import apiMethodName from '@/views/api-test/components/apiMethodName.vue';
import apiMethodSelect from '@/views/api-test/components/apiMethodSelect.vue';
import apiStatus from '@/views/api-test/components/apiStatus.vue';
import { useI18n } from '@/hooks/useI18n';
import useModal from '@/hooks/useModal';
import useTableStore from '@/hooks/useTableStore';
import useAppStore from '@/store/modules/app';
import { RequestDefinitionStatus, RequestMethods } from '@/enums/apiEnum';
import { TableKeyEnum } from '@/enums/tableEnum';
const props = defineProps<{
module: string;
allCount: number;
activeModule: string;
offspringIds: string[];
}>();
const emit = defineEmits<{
(e: 'init', params: any): void;
}>();
const appStore = useAppStore();
const { t } = useI18n();
const { openModal } = useModal();
const activeRequestTab = ref<string | number>('all');
const apiTabs = ref<TabItem[]>([
{
id: 'all',
label: `${t('apiTestManagement.allApi')}(${props.allCount})`,
closable: false,
},
]);
const activeApiTab = ref<TabItem>(apiTabs.value[0]);
function setActiveDebug(item: TabItem) {
activeApiTab.value = item;
}
function handleActiveDebugChange() {
activeApiTab.value.unSaved = true;
}
function addDebugTab(defaultProps?: Partial<TabItem>) {
const id = `debug-${Date.now()}`;
apiTabs.value.push({
module: props.module,
label: t('apiTestDebug.newApi'),
id,
...defaultProps,
});
activeRequestTab.value = id;
nextTick(() => {
if (defaultProps) {
handleActiveDebugChange();
}
});
}
function closeDebugTab(tab: TabItem) {
const index = apiTabs.value.findIndex((item) => item.id === tab.id);
apiTabs.value.splice(index, 1);
if (activeRequestTab.value === tab.id) {
activeRequestTab.value = apiTabs.value[0]?.id || '';
}
}
const tabMoreActionList = [
{
eventTag: 'closeAll',
label: t('apiTestManagement.closeAll'),
},
{
eventTag: 'closeOther',
label: t('apiTestManagement.closeOther'),
},
];
function handleTabMoreActionSelect(event: ActionsItem) {
if (event.eventTag === 'closeOther') {
apiTabs.value = apiTabs.value.filter((item) => item.id === activeRequestTab.value || item.closable === false);
} else if (event.eventTag === 'closeAll') {
apiTabs.value = apiTabs.value.filter((item) => item.id === 'all');
activeRequestTab.value = 'all';
}
}
const showSubdirectory = ref(false);
const checkedEnv = ref('DEV');
const envOptions = ref([
{
label: 'DEV',
value: 'DEV',
},
{
label: 'TEST',
value: 'TEST',
},
{
label: 'PRE',
value: 'PRE',
},
{
label: 'PROD',
value: 'PROD',
},
]);
const keyword = ref('');
const columns: MsTableColumn = [
{
title: 'ID',
dataIndex: 'num',
sortIndex: 1,
sortable: {
sortDirections: ['ascend', 'descend'],
sorter: true,
},
width: 100,
},
{
title: 'apiTestManagement.apiName',
dataIndex: 'name',
showTooltip: true,
sortable: {
sortDirections: ['ascend', 'descend'],
sorter: true,
},
width: 200,
},
{
title: 'apiTestManagement.apiType',
dataIndex: 'type',
slotName: 'type',
titleSlotName: 'typeFilter',
width: 120,
},
{
title: 'apiTestManagement.apiStatus',
dataIndex: 'status',
slotName: 'status',
titleSlotName: 'statusFilter',
width: 130,
},
{
title: 'apiTestManagement.responsiblePerson',
titleSlotName: 'responsiblePersonFilter',
showTooltip: true,
width: 100,
},
{
title: 'apiTestManagement.path',
slotName: 'path',
dataIndex: 'path',
width: 100,
},
{
title: 'common.tag',
dataIndex: 'tags',
isTag: true,
width: 150,
},
{
title: 'apiTestManagement.version',
dataIndex: 'version',
width: 100,
},
{
title: 'apiTestManagement.createTime',
dataIndex: 'createTime',
sortable: {
sortDirections: ['ascend', 'descend'],
sorter: true,
},
width: 200,
},
{
title: 'apiTestManagement.updateTime',
dataIndex: 'updateTime',
sortable: {
sortDirections: ['ascend', 'descend'],
sorter: true,
},
width: 200,
},
{
title: 'common.operation',
slotName: 'action',
dataIndex: 'operation',
fixed: 'right',
width: 150,
},
];
const tableStore = useTableStore();
await tableStore.initColumn(TableKeyEnum.API_TEST, columns, 'drawer');
const { propsRes, propsEvent, loadList, setLoadListParams, resetSelector } = useTable(
() =>
Promise.resolve({
list: [
{
id: 1001,
name: 'asdasdasd',
type: RequestMethods.CONNECT,
status: RequestDefinitionStatus.DEBUGGING,
},
{
id: 10011,
name: '1123',
type: RequestMethods.OPTIONS,
status: RequestDefinitionStatus.DEPRECATED,
},
{
id: 10012,
name: 'vfd',
type: RequestMethods.POST,
status: RequestDefinitionStatus.DONE,
},
{
id: 10013,
name: 'ccf',
type: RequestMethods.DELETE,
status: RequestDefinitionStatus.PROCESSING,
},
],
total: 0,
}),
{
tableKey: TableKeyEnum.API_TEST,
showSetting: true,
selectable: true,
showSelectAll: true,
draggable: { type: 'handle', width: 32 },
},
(item) => ({
...item,
createTime: dayjs(item.createTime).format('YYYY-MM-DD HH:mm:ss'),
updateTime: dayjs(item.updateTime).format('YYYY-MM-DD HH:mm:ss'),
})
);
const batchActions = {
baseAction: [
{
label: 'common.export',
eventTag: 'export',
},
{
label: 'common.edit',
eventTag: 'edit',
},
{
label: 'common.move',
eventTag: 'move',
},
],
moreAction: [
{
label: 'common.delete',
eventTag: 'delete',
danger: true,
},
],
};
const tableMoreActionList = [
{
eventTag: 'delete',
label: t('common.delete'),
danger: true,
},
];
const typeFilterVisible = ref(false);
const typeFilters = ref(Object.keys(RequestMethods));
const statusFilterVisible = ref(false);
const statusFilters = ref(Object.keys(RequestDefinitionStatus));
const tableQueryParams = ref<any>();
function loadApiList() {
const params = {
keyword: keyword.value,
projectId: appStore.currentProjectId,
moduleIds: props.activeModule === 'all' ? [] : [props.activeModule, ...props.offspringIds],
env: checkedEnv.value,
showSubdirectory: showSubdirectory.value,
filter: { status: statusFilters.value, type: typeFilters.value },
};
setLoadListParams(params);
loadList();
tableQueryParams.value = {
...params,
current: propsRes.value.msPagination?.current,
pageSize: propsRes.value.msPagination?.pageSize,
};
emit('init', {
...tableQueryParams.value,
});
}
function handleFilterHidden(val: boolean) {
if (!val) {
loadApiList();
}
}
async function handleStatusChange(record: any) {
try {
Message.success(t('common.updateSuccess'));
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
}
}
onBeforeMount(() => {
loadApiList();
});
function emitTableParams() {
emit('init', {
keyword: keyword.value,
moduleIds: [],
projectId: appStore.currentProjectId,
current: propsRes.value.msPagination?.current,
pageSize: propsRes.value.msPagination?.pageSize,
});
}
const tableSelected = ref<(string | number)[]>([]);
const batchParams = ref<BatchActionQueryParams>({
selectedIds: [],
selectAll: false,
excludeIds: [],
currentSelectCount: 0,
});
/**
* 删除接口
*/
function deleteApi(record?: any, isBatch?: boolean, params?: BatchActionQueryParams) {
let title = t('apiTestManagement.deleteApiTipTitle', { name: record?.name });
let selectIds = [record?.id || ''];
if (isBatch) {
title = t('apiTestManagement.batchDeleteApiTip', {
count: params?.currentSelectCount || tableSelected.value.length,
});
selectIds = tableSelected.value as string[];
}
openModal({
type: 'error',
title,
content: t('apiTestManagement.deleteApiTip'),
okText: t('common.confirmDelete'),
cancelText: t('common.cancel'),
okButtonProps: {
status: 'danger',
},
maskClosable: false,
onBeforeOk: async () => {
try {
Message.success(t('common.deleteSuccess'));
resetSelector();
loadList();
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
}
},
hideCancel: false,
});
}
/**
* 处理表格更多按钮事件
* @param item
*/
function handleTableMoreActionSelect(item: ActionsItem, record: any) {
switch (item.eventTag) {
case 'delete':
deleteApi(record);
break;
default:
break;
}
}
/**
* 处理表格选中
*/
function handleTableSelect(arr: (string | number)[]) {
tableSelected.value = arr;
}
const showBatchModal = ref(false);
const batchUpdateLoading = ref(false);
const batchFormRef = ref<FormInstance>();
const batchForm = ref({
attr: '',
value: '',
values: [],
});
const attrOptions = [
{
name: 'apiTestManagement.apiStatus',
value: 'status',
},
{
name: 'apiTestManagement.apiType',
value: 'type',
},
{
name: 'common.tag',
value: 'tag',
},
];
const valueOptions = computed(() => {
switch (batchForm.value.attr) {
case 'status':
return [
{
name: 'apiTestManagement.processing',
value: RequestDefinitionStatus.PROCESSING,
},
{
name: 'apiTestManagement.done',
value: RequestDefinitionStatus.DONE,
},
{
name: 'apiTestManagement.deprecate',
value: RequestDefinitionStatus.DEPRECATED,
},
{
name: 'apiTestManagement.debugging',
value: RequestDefinitionStatus.DEBUGGING,
},
];
default:
return [];
}
});
function cancelBatch() {
showBatchModal.value = false;
batchFormRef.value?.resetFields();
batchForm.value = {
attr: '',
value: '',
values: [],
};
}
function batchUpdate() {
batchFormRef.value?.validate(async (errors) => {
if (!errors) {
try {
batchUpdateLoading.value = true;
Message.success(t('common.updateSuccess'));
cancelBatch();
resetSelector();
loadList();
} catch (error) {
console.log(error);
} finally {
batchUpdateLoading.value = false;
}
}
});
}
const moveModalVisible = ref(false);
const selectedModuleKeys = ref<(string | number)[]>([]); //
const isBatchMove = ref(false); //
const activeApi = ref<any | null>(null); //
const batchMoveApiLoading = ref(false); // loading
/**
* 单个/批量移动接口
*/
async function handleApiMove() {
try {
batchMoveApiLoading.value = true;
// await batchMoveFile({
// selectIds: isBatchMove.value ? batchParams.value?.selectedIds || [] : [activeApi.value?.id || ''],
// selectAll: !!batchParams.value?.selectAll,
// excludeIds: batchParams.value?.excludeIds || [],
// condition: { keyword: keyword.value },
// projectId: appStore.currentProjectId,
// moduleIds: props.activeModule === 'all' ? [] : [props.activeModule],
// moveModuleId: selectedModuleKeys.value[0],
// });
Message.success(t('apiTestManagement.batchMoveSuccess'));
if (isBatchMove.value) {
tableSelected.value = [];
isBatchMove.value = false;
} else {
activeApi.value = null;
}
loadList();
resetSelector();
emitTableParams();
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
} finally {
batchMoveApiLoading.value = false;
}
}
function handleMoveApiModalCancel() {
moveModalVisible.value = false;
selectedModuleKeys.value = [];
}
/**
* 处理文件夹树节点选中事件
*/
function folderNodeSelect(keys: (string | number)[]) {
selectedModuleKeys.value = keys;
}
/**
* 处理表格选中后批量操作
* @param event 批量操作事件对象
*/
function handleTableBatch(event: BatchActionParams, params: BatchActionQueryParams) {
tableSelected.value = params?.selectedIds || [];
batchParams.value = params;
switch (event.eventTag) {
case 'delete':
deleteApi(undefined, true, params);
break;
case 'edit':
showBatchModal.value = true;
break;
case 'move':
isBatchMove.value = true;
moveModalVisible.value = true;
break;
default:
break;
}
}
</script>
<style lang="less" scoped>
:deep(.param-input:not(.arco-input-focus, .arco-select-view-focus)) {
&:not(:hover) {
border-color: transparent !important;
.arco-input::placeholder {
@apply invisible;
}
.arco-select-view-icon {
@apply invisible;
}
.arco-select-view-value {
color: var(--color-text-brand);
}
}
}
</style>

View File

@ -0,0 +1,38 @@
<template>
<a-tabs v-model:active-key="activeTab" animation lazy-load>
<a-tab-pane key="api" title="API">
<api
:active-module="activeModule"
:module="props.module"
:all-count="props.allCount"
:offspring-ids="props.offspringIds"
/>
</a-tab-pane>
<a-tab-pane key="case" title="CASE"> </a-tab-pane>
<a-tab-pane key="mock" title="MOCK"> </a-tab-pane>
<a-tab-pane key="doc" :title="t('apiTestManagement.doc')"> </a-tab-pane>
</a-tabs>
</template>
<script setup lang="ts">
import api from './api.vue';
import { useI18n } from '@/hooks/useI18n';
const props = defineProps<{
module: string;
allCount: number;
activeModule: string;
offspringIds: string[];
}>();
const { t } = useI18n();
const activeTab = ref('api');
</script>
<style lang="less" scoped>
:deep(.arco-tabs-nav) {
border-bottom: 1px solid var(--color-text-n8);
}
</style>

View File

@ -0,0 +1,446 @@
<template>
<div>
<template v-if="!props.isModal">
<a-select
v-model:model-value="moduleProtocol"
:options="moduleProtocolOptions"
class="mb-[8px]"
@change="(val) => handleProtocolChange(val as string)"
/>
<div class="mb-[8px] flex items-center gap-[8px]">
<a-input v-model:model-value="moduleKeyword" :placeholder="t('apiTestManagement.searchTip')" allow-clear />
<a-dropdown @select="handleSelect">
<a-button type="primary">{{ t('apiTestManagement.newApi') }}</a-button>
<template #content>
<a-doption value="newApi">{{ t('apiTestManagement.newApi') }}</a-doption>
<a-doption value="import">{{ t('apiTestManagement.importApi') }}</a-doption>
</template>
</a-dropdown>
</div>
<div class="folder" @click="setActiveFolder('all')">
<div :class="allFolderClass">
<MsIcon type="icon-icon_folder_filled1" class="folder-icon" />
<div class="folder-name">{{ t('apiTestManagement.allApi') }}</div>
<div class="folder-count">({{ allFileCount }})</div>
</div>
<div class="ml-auto flex items-center">
<a-tooltip :content="isExpandAll ? t('common.collapseAll') : t('common.expandAll')">
<MsButton type="icon" status="secondary" class="!mr-0 p-[4px]" @click="changeExpand">
<MsIcon :type="isExpandAll ? 'icon-icon_folder_collapse1' : 'icon-icon_folder_expansion1'" />
</MsButton>
</a-tooltip>
<a-dropdown @select="handleSelect">
<MsButton type="icon" class="!mr-0 p-[2px]">
<MsIcon
type="icon-icon_create_planarity"
size="18"
class="text-[rgb(var(--primary-5))] hover:text-[rgb(var(--primary-4))]"
/>
</MsButton>
<template #content>
<a-doption value="newApi">{{ t('apiTestManagement.newApi') }}</a-doption>
<a-doption value="addModule">{{ t('apiTestManagement.addSubModule') }}</a-doption>
</template>
</a-dropdown>
<popConfirm mode="add" :all-names="rootModulesName" parent-id="NONE" @add-finish="initModules">
<span id="addModulePopSpan"></span>
</popConfirm>
</div>
</div>
<a-divider class="my-[8px]" />
</template>
<a-input
v-if="props.isModal"
v-model:model-value="moduleKeyword"
:placeholder="t('apiTestManagement.moveSearchTip')"
allow-clear
class="mb-[16px]"
/>
<a-spin class="min-h-[400px] w-full" :loading="loading">
<MsTree
v-model:focus-node-key="focusNodeKey"
v-model:selected-keys="selectedKeys"
:data="folderTree"
:keyword="moduleKeyword"
:node-more-actions="folderMoreActions"
:default-expand-all="isExpandAll"
:expand-all="isExpandAll"
:empty-text="t('apiTestManagement.noMatchModule')"
:virtual-list-props="virtualListProps"
:field-names="{
title: 'name',
key: 'id',
children: 'children',
count: 'count',
}"
block-node
title-tooltip-position="left"
@select="folderNodeSelect"
@more-action-select="handleFolderMoreSelect"
@more-actions-close="moreActionsClose"
@drop="handleDrop"
>
<template #title="nodeData">
<div class="inline-flex w-full">
<div class="one-line-text w-[calc(100%-32px)] text-[var(--color-text-1)]">{{ nodeData.name }}</div>
<div v-if="!props.isModal" class="ml-auto text-[var(--color-text-4)]">({{ nodeData.count || 0 }})</div>
</div>
</template>
<template v-if="!props.isModal" #extra="nodeData">
<!-- 默认模块的 id 是root默认模块不可编辑不可添加子模块 -->
<popConfirm
v-if="nodeData.id !== 'root'"
mode="add"
:all-names="(nodeData.children || []).map((e: ModuleTreeNode) => e.name || '')"
:parent-id="nodeData.id"
@close="resetFocusNodeKey"
@add-finish="() => initModules()"
>
<MsButton type="icon" size="mini" class="ms-tree-node-extra__btn !mr-0" @click="setFocusNodeKey(nodeData)">
<MsIcon type="icon-icon_add_outlined" size="14" class="text-[var(--color-text-4)]" />
</MsButton>
</popConfirm>
<popConfirm
v-if="nodeData.id !== 'root'"
mode="rename"
:parent-id="nodeData.id"
:node-id="nodeData.id"
:field-config="{ field: renameFolderTitle }"
:all-names="(nodeData.children || []).map((e: ModuleTreeNode) => e.name || '')"
@close="resetFocusNodeKey"
@rename-finish="initModules"
>
<span :id="`renameSpan${nodeData.id}`" class="relative"></span>
</popConfirm>
</template>
</MsTree>
</a-spin>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeMount, ref, watch } from 'vue';
import { Message } from '@arco-design/web-vue';
import MsButton from '@/components/pure/ms-button/index.vue';
import MsIcon from '@/components/pure/ms-icon-font/index.vue';
import type { ActionsItem } from '@/components/pure/ms-table-more-action/types';
import MsTree from '@/components/business/ms-tree/index.vue';
import type { MsTreeNodeData } from '@/components/business/ms-tree/types';
import popConfirm from '@/views/api-test/components/popConfirm.vue';
import { deleteReviewModule, getReviewModules, moveReviewModule } from '@/api/modules/case-management/caseReview';
import { useI18n } from '@/hooks/useI18n';
import useModal from '@/hooks/useModal';
import useAppStore from '@/store/modules/app';
import { mapTree } from '@/utils';
import { ModuleTreeNode } from '@/models/projectManagement/file';
const props = withDefaults(
defineProps<{
modulesCount?: Record<string, number>; //
isExpandAll?: boolean; //
activeModule?: string | number; // key
isModal?: boolean; //
}>(),
{
activeModule: 'all',
}
);
const emit = defineEmits(['init', 'change', 'newApi', 'import', 'folderNodeSelect']);
const appStore = useAppStore();
const { t } = useI18n();
const { openModal } = useModal();
const moduleProtocol = ref('http');
const moduleProtocolOptions = ref([
{
label: 'HTTP',
value: 'http',
},
]);
function handleProtocolChange(value: string | number | Record<string, any>) {
console.log(value);
}
function handleSelect(value: string | number | Record<string, any> | undefined) {
switch (value) {
case 'newApi':
emit('newApi');
break;
case 'import':
emit('import');
break;
case 'addModule':
document.querySelector('#addModulePopSpan')?.dispatchEvent(new Event('click'));
break;
default:
break;
}
}
const virtualListProps = computed(() => {
if (props.isModal) {
return {
height: 'calc(60vh - 190px)',
};
}
return {
height: 'calc(100vh - 305px)',
};
});
const allFileCount = ref(0);
const isExpandAll = ref(props.isExpandAll);
const rootModulesName = ref<string[]>([]); //
watch(
() => props.isExpandAll,
(val) => {
isExpandAll.value = val;
}
);
function changeExpand() {
isExpandAll.value = !isExpandAll.value;
}
const moduleKeyword = ref('');
const folderTree = ref<ModuleTreeNode[]>([]);
const focusNodeKey = ref<string | number>('');
const selectedKeys = ref<Array<string | number>>([props.activeModule]);
const allFolderClass = computed(() =>
selectedKeys.value[0] === 'all' ? 'folder-text folder-text--active' : 'folder-text'
);
const loading = ref(false);
watch(
() => selectedKeys.value,
(arr) => {
emit('change', arr ? arr[0] : '');
}
);
function setActiveFolder(id: string) {
selectedKeys.value = [id];
}
function setFocusNodeKey(node: MsTreeNodeData) {
focusNodeKey.value = node.id || '';
}
const folderMoreActions: ActionsItem[] = [
{
label: 'common.rename',
eventTag: 'rename',
},
{
label: 'apiTestManagement.execute',
eventTag: 'execute',
},
{
label: 'apiTestManagement.share',
eventTag: 'share',
},
{
isDivider: true,
},
{
label: 'common.delete',
eventTag: 'delete',
danger: true,
},
];
const renamePopVisible = ref(false);
/**
* 初始化模块树
* @param isSetDefaultKey 是否设置第一个节点为选中节点
*/
async function initModules(isSetDefaultKey = false) {
try {
loading.value = true;
const res = await getReviewModules(appStore.currentProjectId);
folderTree.value = mapTree<ModuleTreeNode>(res, (e) => {
return {
...e,
hideMoreAction: e.id === 'root',
draggable: e.id !== 'root' && !props.isModal,
disabled: e.id === selectedKeys.value[0] && props.isModal,
};
});
if (isSetDefaultKey) {
selectedKeys.value = [folderTree.value[0].id];
}
emit('init', folderTree.value);
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
} finally {
loading.value = false;
}
}
/**
* 处理文件夹树节点选中事件
*/
function folderNodeSelect(_selectedKeys: (string | number)[], node: MsTreeNodeData) {
const offspringIds: string[] = [];
mapTree(node.children || [], (e) => {
offspringIds.push(e.id);
return e;
});
emit('folderNodeSelect', _selectedKeys, offspringIds);
}
/**
* 删除文件夹
* @param node 节点信息
*/
function deleteFolder(node: MsTreeNodeData) {
openModal({
type: 'error',
title: t('apiTestDebug.deleteFolderTipTitle', { name: node.name }),
content: t('apiTestDebug.deleteFolderTipContent'),
okText: t('apiTestDebug.deleteConfirm'),
okButtonProps: {
status: 'danger',
},
maskClosable: false,
onBeforeOk: async () => {
try {
await deleteReviewModule(node.id);
Message.success(t('apiTestDebug.deleteSuccess'));
initModules();
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
}
},
hideCancel: false,
});
}
const renameFolderTitle = ref(''); //
function resetFocusNodeKey() {
focusNodeKey.value = '';
renamePopVisible.value = false;
renameFolderTitle.value = '';
}
/**
* 处理树节点更多按钮事件
* @param item
*/
function handleFolderMoreSelect(item: ActionsItem, node: MsTreeNodeData) {
switch (item.eventTag) {
case 'delete':
deleteFolder(node);
resetFocusNodeKey();
break;
case 'rename':
renameFolderTitle.value = node.name || '';
renamePopVisible.value = true;
document.querySelector(`#renameSpan${node.id}`)?.dispatchEvent(new Event('click'));
break;
default:
break;
}
}
/**
* 处理文件夹树节点拖拽事件
* @param tree 树数据
* @param dragNode 拖拽节点
* @param dropNode 释放节点
* @param dropPosition 释放位置
*/
async function handleDrop(
tree: MsTreeNodeData[],
dragNode: MsTreeNodeData,
dropNode: MsTreeNodeData,
dropPosition: number
) {
try {
loading.value = true;
await moveReviewModule({
dragNodeId: dragNode.id as string,
dropNodeId: dropNode.id || '',
dropPosition,
});
Message.success(t('apiTestDebug.moduleMoveSuccess'));
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
} finally {
loading.value = false;
initModules();
}
}
function moreActionsClose() {
if (!renamePopVisible.value) {
// key
resetFocusNodeKey();
}
}
onBeforeMount(() => {
initModules();
});
/**
* 初始化模块文件数量
*/
watch(
() => props.modulesCount,
(obj) => {
folderTree.value = mapTree<ModuleTreeNode>(folderTree.value, (node) => {
return {
...node,
count: obj?.[node.id] || 0,
};
});
}
);
defineExpose({
initModules,
});
</script>
<style lang="less" scoped>
.folder {
@apply flex cursor-pointer items-center justify-between;
padding: 8px 4px;
border-radius: var(--border-radius-small);
&:hover {
background-color: rgb(var(--primary-1));
}
.folder-text {
@apply flex flex-1 cursor-pointer items-center;
.folder-icon {
margin-right: 4px;
color: var(--color-text-4);
}
.folder-name {
color: var(--color-text-1);
}
.folder-count {
margin-left: 4px;
color: var(--color-text-4);
}
}
.folder-text--active {
.folder-icon,
.folder-name,
.folder-count {
color: rgb(var(--primary-5));
}
}
}
</style>

View File

@ -0,0 +1,74 @@
<template>
<MsCard simple no-content-padding>
<MsSplitBox :size="0.25" :max="0.5">
<template #first>
<div class="p-[24px]">
<moduleTree
@init="(val) => (folderTree = val)"
@new-api="newApi"
@change="(val) => (activeModule = val)"
@import="importDrawerVisible = true"
@folder-node-select="(keys, _offspringIds) => (offspringIds = _offspringIds)"
/>
</div>
<!-- <div class="b-0 absolute w-[88%]">
<a-divider class="!my-0 !mb-2" />
<div class="case h-[38px]">
<div class="flex items-center" :class="getActiveClass('recycle')" @click="setActiveFolder('recycle')">
<MsIcon type="icon-icon_delete-trash_outlined" class="folder-icon" />
<div class="folder-name mx-[4px]">{{ t('caseManagement.featureCase.recycle') }}</div>
<div class="folder-count">({{ recycleModulesCount.all || 0 }})</div></div
>
</div>
</div> -->
</template>
<template #second>
<div class="relative flex h-full flex-col">
<div
id="managementContainer"
:class="['absolute z-[101] h-full w-full', importDrawerVisible ? '' : 'invisible']"
style="transition: all 0.3s"
>
<importApi
v-model:visible="importDrawerVisible"
:module-tree="folderTree"
popup-container="#managementContainer"
/>
</div>
<management
:module="activeModule"
:all-count="allCount"
:active-module="activeModule"
:offspring-ids="offspringIds"
/>
</div>
</template>
</MsSplitBox>
</MsCard>
</template>
<script lang="ts" setup>
import MsCard from '@/components/pure/ms-card/index.vue';
import MsSplitBox from '@/components/pure/ms-split-box/index.vue';
import importApi from './components/import.vue';
import management from './components/management/index.vue';
import moduleTree from './components/moduleTree.vue';
import { useI18n } from '@/hooks/useI18n';
import { ModuleTreeNode } from '@/models/projectManagement/file';
const { t } = useI18n();
const activeModule = ref<string>('all');
const folderTree = ref<ModuleTreeNode[]>([]);
const allCount = ref(0);
const importDrawerVisible = ref(false);
const offspringIds = ref<string[]>([]);
function newApi() {
// debugRef.value?.addDebugTab();
}
</script>
<style lang="less" scoped></style>

View File

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

View File

@ -0,0 +1,75 @@
export default {
'apiTestManagement.newApi': '新建接口',
'apiTestManagement.importApi': '导入接口',
'apiTestManagement.fileImport': '文件导入',
'apiTestManagement.timeImport': '定时导入',
'apiTestManagement.addSubModule': '添加子模块',
'apiTestManagement.allApi': '全部接口',
'apiTestManagement.searchTip': '请输入模块/接口名称',
'apiTestManagement.moveSearchTip': '请输入模块名称搜索',
'apiTestManagement.noMatchModule': '暂无匹配的模块/接口',
'apiTestManagement.execute': '执行',
'apiTestManagement.share': '分享 API',
'apiTestManagement.doc': '文档',
'apiTestManagement.closeAll': '关闭全部tab',
'apiTestManagement.closeOther': '关闭其他tab',
'apiTestManagement.showSubdirectory': '显示子目录用例',
'apiTestManagement.searchPlaceholder': '输入 ID/名称/api路径搜索',
'apiTestManagement.apiName': '接口名称',
'apiTestManagement.apiType': '请求类型',
'apiTestManagement.apiStatus': '状态',
'apiTestManagement.responsiblePerson': '责任人',
'apiTestManagement.path': '路径',
'apiTestManagement.version': '版本',
'apiTestManagement.createTime': '创建时间',
'apiTestManagement.updateTime': '更新时间',
'apiTestManagement.deprecate': '已废弃',
'apiTestManagement.processing': '进行中',
'apiTestManagement.debugging': '联调中',
'apiTestManagement.done': '已完成',
'apiTestManagement.deleteApiTipTitle': '确认删除 {name} 吗?',
'apiTestManagement.deleteApiTip': '删除后,接口将放入回收站,可在回收站内进行数据恢复',
'apiTestManagement.batchDeleteApiTip': '确认删除已选中的 {count} 个接口吗?',
'apiTestManagement.batchModalSubTitle': '(已选 {count} 个接口)',
'apiTestManagement.chooseAttr': '选择属性',
'apiTestManagement.attrRequired': '属性不能为空',
'apiTestManagement.batchUpdate': '批量更新为',
'apiTestManagement.valueRequired': '属性值不能为空',
'apiTestManagement.batchMoveConfirm': '移动至所选模块',
'apiTestManagement.belongModule': '所属模块',
'apiTestManagement.importMode': '导入模式',
'apiTestManagement.importModeTip1': '覆盖:',
'apiTestManagement.importModeTip2': '1.同一接口请求类型+路径一致,请求参数内容不一致则覆盖',
'apiTestManagement.importModeTip3': '2.同一接口请求类型+路径一致,请求参数内容一致不做变更',
'apiTestManagement.importModeTip4': '3.非同一接口,请求类型+路径一致,则新增',
'apiTestManagement.importModeTip5': '不覆盖:',
'apiTestManagement.importModeTip6': '1.同一接口请求类型+路径一致,则不做变更',
'apiTestManagement.importModeTip7': '2.非同一接口请求类型+路径一致,则新增',
'apiTestManagement.cover': '覆盖',
'apiTestManagement.uncover': '不覆盖',
'apiTestManagement.moreSetting': '更多设置',
'apiTestManagement.importType': '导入方式',
'apiTestManagement.urlImport': 'URL 导入',
'apiTestManagement.syncImportCase': '同步导入接口用例',
'apiTestManagement.syncUpdateDirectory': '同步更新接口所在目录',
'apiTestManagement.importSwaggerFileTip1': '支持 Swagger 3.0 版本的 json 文件,',
'apiTestManagement.importSwaggerFileTip2': '2.0 的文件建议自行在官网转换成 3.0 再进行导入',
'apiTestManagement.importSwaggerFileTip3': ',大小不超过 50M',
'apiTestManagement.urlImportPlaceholder': '请输入OpenAPI/Swagger URL',
'apiTestManagement.swaggerURLRequired': 'SwaggerURL 不能为空',
'apiTestManagement.basicAuth': 'Basic Auth 认证',
'apiTestManagement.account': '账号',
'apiTestManagement.accountRequired': '账号不能为空',
'apiTestManagement.password': '密码',
'apiTestManagement.passwordRequired': '密码不能为空',
'apiTestManagement.taskName': '任务名称',
'apiTestManagement.taskNamePlaceholder': '请输入任务名称',
'apiTestManagement.taskNameRequired': '任务名称不能为空',
'apiTestManagement.syncFrequency': '同步频率',
'apiTestManagement.timeTaskList': '定时任务列表',
'apiTestManagement.timeTaskHour': '(每小时)',
'apiTestManagement.timeTaskSixHour': '(每 6 小时)',
'apiTestManagement.timeTaskTwelveHour': '(每 12 小时)',
'apiTestManagement.timeTaskDay': '(每天)',
'apiTestManagement.customFrequency': '自定义频率',
};

View File

@ -190,7 +190,7 @@
}
function getFileType(type: string) {
const fileTypes = type ? getFileEnum(`/${type.toLowerCase()}`) : 'unknown';
const fileTypes = type ? getFileEnum(type.toLowerCase()) : 'unknown';
return FileIconMap[fileTypes][UploadStatus.done];
}

View File

@ -16,7 +16,7 @@
<div>XXXXXX <span>(101)</span></div>
<a-input-search
v-model="keyword"
:max-length="250"
:max-length="255"
:placeholder="t('project.member.searchMember')"
allow-clear
@search="searchHandler"

View File

@ -225,49 +225,7 @@
/>
</a-tooltip>
</div>
<a-form ref="dialogFormRef" :model="caseResultForm" layout="vertical">
<a-form-item field="reason" :label="t('caseManagement.caseReview.reviewResult')" class="mb-[8px]">
<a-radio-group v-model:model-value="caseResultForm.result" @change="() => dialogFormRef?.resetFields()">
<a-radio value="PASS">
<div class="inline-flex items-center">
<MsIcon type="icon-icon_succeed_filled" class="mr-[4px] text-[rgb(var(--success-6))]" />
{{ t('caseManagement.caseReview.pass') }}
</div>
</a-radio>
<a-radio value="UN_PASS">
<div class="inline-flex items-center">
<MsIcon type="icon-icon_close_filled" class="mr-[4px] text-[rgb(var(--danger-6))]" />
{{ t('caseManagement.caseReview.fail') }}
</div>
</a-radio>
<a-radio value="UNDER_REVIEWED">
<div class="inline-flex items-center">
<MsIcon type="icon-icon_warning_filled" class="mr-[4px] text-[rgb(var(--warning-6))]" />
{{ t('caseManagement.caseReview.suggestion') }}
</div>
<a-tooltip :content="t('caseManagement.caseReview.suggestionTip')" position="right">
<icon-question-circle
class="ml-[4px] mt-[2px] text-[var(--color-text-4)] hover:text-[rgb(var(--primary-5))]"
size="16"
/>
</a-tooltip>
</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item
field="reason"
:label="t('caseManagement.caseReview.reason')"
:rules="
caseResultForm.result === 'UN_PASS' || caseResultForm.result === 'UNDER_REVIEWED'
? [{ required: true, message: t('caseManagement.caseReview.reasonRequired') }]
: []
"
asterisk-position="end"
class="mb-0"
>
<MsRichText v-model:raw="caseResultForm.reason" class="w-full" />
</a-form-item>
</a-form>
<reviewForm ref="dialogFormRef" />
<a-button type="primary" class="mt-[16px]" :loading="submitReviewLoading" @click="submitReview">
{{ t('caseManagement.caseReview.submitReview') }}
</a-button>
@ -292,7 +250,7 @@
* @description 功能测试-用例评审-用例详情
*/
import { useRoute, useRouter } from 'vue-router';
import { FormInstance, Message } from '@arco-design/web-vue';
import { Message } from '@arco-design/web-vue';
import dayjs from 'dayjs';
import MSAvatar from '@/components/pure/ms-avatar/index.vue';
@ -302,13 +260,13 @@
import MsEmpty from '@/components/pure/ms-empty/index.vue';
import MsIcon from '@/components/pure/ms-icon-font/index.vue';
import MsPagination from '@/components/pure/ms-pagination/index';
import MsRichText from '@/components/pure/ms-rich-text/MsRichText.vue';
import MsSplitBox from '@/components/pure/ms-split-box/index.vue';
import { MsFileItem } from '@/components/pure/ms-upload/types';
import caseLevel from '@/components/business/ms-case-associate/caseLevel.vue';
import type { CaseLevel } from '@/components/business/ms-case-associate/types';
import caseTemplateDetail from '../caseManagementFeature/components/caseTemplateDetail.vue';
import caseTabDemand from '../caseManagementFeature/components/tabContent/tabDemand/associatedDemandTable.vue';
import caseTabDetail from '../caseManagementFeature/components/tabContent/tabDetail.vue';
import reviewForm from './components/reviewForm.vue';
import {
getCaseReviewHistoryList,
@ -325,6 +283,8 @@
import type { DetailCase } from '@/models/caseManagement/featureCase';
import { CaseManagementRouteEnum } from '@/enums/routeEnum';
import { Instance } from 'tippy.js';
const route = useRoute();
const router = useRouter();
const appStore = useAppStore();
@ -529,11 +489,7 @@
);
const autoNext = ref(false);
const caseResultForm = ref({
result: 'PASS' as ReviewResult,
reason: '',
});
const dialogFormRef = ref<FormInstance>();
const dialogFormRef = ref<InstanceType<typeof reviewForm>>();
const demandKeyword = ref('');
const caseDemandRef = ref<InstanceType<typeof caseTabDemand>>();
@ -553,47 +509,46 @@
const submitReviewLoading = ref(false);
//
function submitReview() {
dialogFormRef.value?.validate(async (errors) => {
if (!errors) {
try {
submitReviewLoading.value = true;
const params = {
projectId: appStore.currentProjectId,
caseId: activeCaseId.value,
reviewId: route.query.id as string,
status: caseResultForm.value.result,
reviewPassRule: reviewDetail.value.reviewPassRule,
content: caseResultForm.value.reason,
notifier: '', // TODO:
};
await saveCaseReviewResult(params);
Message.success(t('caseManagement.caseReview.reviewSuccess'));
caseResultForm.value = {
result: 'PASS' as ReviewResult,
reason: '',
};
if (autoNext.value) {
// id
const index = caseList.value.findIndex((e) => e.caseId === activeCaseId.value);
if (index < caseList.value.length - 1) {
activeCaseId.value = caseList.value[index + 1].caseId;
} else {
//
loadCaseDetail();
initReviewHistoryList();
}
dialogFormRef.value?.validateForm(async (caseResultForm: Record<string, any>) => {
try {
submitReviewLoading.value = true;
const params = {
projectId: appStore.currentProjectId,
caseId: activeCaseId.value,
reviewId: route.query.id as string,
status: caseResultForm.value.result,
reviewPassRule: reviewDetail.value.reviewPassRule,
content: caseResultForm.value.reason,
notifier: '', // TODO:
};
await saveCaseReviewResult(params);
Message.success(t('caseManagement.caseReview.reviewSuccess'));
caseResultForm.value = {
result: 'PASS' as ReviewResult,
reason: '',
fileList: [] as MsFileItem[],
};
if (autoNext.value) {
// id
const index = caseList.value.findIndex((e) => e.caseId === activeCaseId.value);
if (index < caseList.value.length - 1) {
activeCaseId.value = caseList.value[index + 1].caseId;
} else {
//
//
loadCaseDetail();
initReviewHistoryList();
}
loadCaseList();
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
} finally {
submitReviewLoading.value = false;
} else {
//
loadCaseDetail();
initReviewHistoryList();
}
loadCaseList();
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
} finally {
submitReviewLoading.value = false;
}
});
}

View File

@ -9,6 +9,7 @@
:confirm-loading="confirmLoading"
:associated-ids="[]"
:type="RequestModuleEnum.CASE_MANAGEMENT"
:table-params="{ reviewId: props.reviewId }"
@close="emit('close')"
@save="saveHandler"
>
@ -90,7 +91,7 @@
const props = defineProps<{
visible: boolean;
project: string;
// associatedIds: string[];
reviewId?: string;
}>();
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void;

View File

@ -33,7 +33,7 @@
@selected-change="handleTableSelect"
@batch-action="handleTableBatch"
>
<template #resultColumn>
<template #resultTitle>
<div class="flex items-center text-[var(--color-text-3)]">
{{ t('caseManagement.caseReview.reviewResult') }}
<a-tooltip :content="t('caseManagement.caseReview.reviewResultTip')" position="right">
@ -96,7 +96,6 @@
</ms-base-table>
<a-modal
v-model:visible="dialogVisible"
:on-before-ok="handleDeleteConfirm"
class="p-[4px]"
title-align="start"
body-class="p-0"
@ -158,7 +157,35 @@
asterisk-position="end"
class="mb-0"
>
<MsRichText v-model:raw="dialogForm.reason" class="w-full" />
<div class="flex w-full items-center">
<a-mention
v-model:model-value="dialogForm.reason"
type="textarea"
:auto-size="{ minRows: 1 }"
:max-length="1000"
allow-clear
class="flex flex-1 items-center"
/>
<MsUpload
v-model:file-list="dialogForm.fileList"
accept="image"
size-unit="MB"
:auto-upload="false"
multiple
:limit="10"
:disabled="dialogForm.fileList.length >= 10"
>
<a-button type="outline" class="ml-[8px] p-[8px_6px]" :disabled="dialogForm.fileList.length >= 10">
<icon-file-image :size="18" />
</a-button>
</MsUpload>
</div>
<MsFileList
v-model:file-list="dialogForm.fileList"
show-mode="imageList"
:show-tab="false"
class="mt-[8px]"
/>
</a-form-item>
<a-form-item
v-if="dialogShowType === 'changeReviewer'"
@ -241,10 +268,12 @@
import MsButton from '@/components/pure/ms-button/index.vue';
import MsIcon from '@/components/pure/ms-icon-font/index.vue';
import MsPopconfirm from '@/components/pure/ms-popconfirm/index.vue';
import MsRichText from '@/components/pure/ms-rich-text/MsRichText.vue';
import MsBaseTable from '@/components/pure/ms-table/base-table.vue';
import type { BatchActionParams, BatchActionQueryParams, MsTableColumn } from '@/components/pure/ms-table/type';
import useTable from '@/components/pure/ms-table/useTable';
import MsFileList from '@/components/pure/ms-upload/fileList.vue';
import MsUpload from '@/components/pure/ms-upload/index.vue';
import { MsFileItem } from '@/components/pure/ms-upload/types';
import MsSelect from '@/components/business/ms-select';
import {
@ -255,7 +284,8 @@
getReviewDetailCasePage,
getReviewUsers,
} from '@/api/modules/case-management/caseReview';
import { reviewResultMap, reviewStatusMap } from '@/config/caseManagement';
import { getProjectMemberCommentOptions } from '@/api/modules/project-management/projectMember';
import { reviewResultMap } from '@/config/caseManagement';
import { useI18n } from '@/hooks/useI18n';
import useModal from '@/hooks/useModal';
import useTableStore from '@/hooks/useTableStore';
@ -296,6 +326,10 @@
dataIndex: 'num',
slotName: 'num',
sortIndex: 1,
sortable: {
sortDirections: ['ascend', 'descend'],
sorter: true,
},
showTooltip: true,
width: 100,
},
@ -313,27 +347,23 @@
title: 'caseManagement.caseReview.reviewer',
dataIndex: 'reviewNames',
slotName: 'reviewNames',
sortable: {
sortDirections: ['ascend', 'descend'],
sorter: true,
},
width: 150,
},
{
title: 'caseManagement.caseReview.reviewResult',
dataIndex: 'status',
slotName: 'status',
titleSlotName: 'resultColumn',
titleSlotName: 'resultTitle',
width: 110,
},
{
title: 'caseManagement.caseReview.version',
dataIndex: 'versionName',
width: 90,
},
// {
// title: 'caseManagement.caseReview.version',
// dataIndex: 'versionName',
// width: 90,
// },
{
title: 'caseManagement.caseReview.creator',
dataIndex: 'creator',
dataIndex: 'createUserName',
width: 150,
},
{
@ -345,7 +375,6 @@
},
];
const tableStore = useTableStore();
tableStore.initColumn(TableKeyEnum.CASE_MANAGEMENT_REVIEW_CASE, columns, 'drawer');
const { propsRes, propsEvent, loadList, setLoadListParams, resetSelector, getTableQueryParams } = useTable(
getReviewDetailCasePage,
{
@ -442,6 +471,7 @@
reason: '',
reviewer: [] as string[],
isAppend: false,
fileList: [] as MsFileItem[],
};
const dialogForm = ref({ ...defaultDialogForm });
const dialogFormRef = ref<FormInstance>();
@ -488,27 +518,6 @@
}
}
/**
* 删除拦截
* @param done 关闭弹窗
*/
async function handleDeleteConfirm(done: (closed: boolean) => void) {
try {
// if (replaceVersion.value !== '') {
// await useLatestVersion(replaceVersion.value);
// }
// await toggleVersionStatus(activeRecord.value.id);
// Message.success(t('caseManagement.caseReview.close', { name: activeRecord.value.name }));
loadList();
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
done(false);
} finally {
done(true);
}
}
function handleArchive(record: ReviewItem) {
openModal({
type: 'warning',
@ -730,72 +739,22 @@
}
onBeforeMount(async () => {
await initReviewers();
const [, memberRes] = await Promise.all([
initReviewers(),
getProjectMemberCommentOptions(appStore.currentProjectId, keyword.value),
]);
const memberOptions = memberRes.map((e) => ({ label: e.name, value: e.id }));
filterConfigList.value = [
{
title: 'ID',
dataIndex: 'ID',
dataIndex: 'id',
type: FilterType.INPUT,
},
{
title: 'caseManagement.caseReview.name',
title: 'caseManagement.caseReview.caseName',
dataIndex: 'name',
type: FilterType.INPUT,
},
{
title: 'caseManagement.caseReview.caseCount',
dataIndex: 'caseCount',
type: FilterType.NUMBER,
},
{
title: 'caseManagement.caseReview.status',
dataIndex: 'status',
type: FilterType.SELECT,
selectProps: {
mode: 'static',
options: [
{
label: t(reviewStatusMap.PREPARED.label),
value: 'PREPARED',
},
{
label: t(reviewStatusMap.UNDERWAY.label),
value: 'UNDERWAY',
},
{
label: t(reviewStatusMap.COMPLETED.label),
value: 'COMPLETED',
},
{
label: t(reviewStatusMap.ARCHIVED.label),
value: 'ARCHIVED',
},
],
},
},
{
title: 'caseManagement.caseReview.passRate',
dataIndex: 'passRate',
type: FilterType.NUMBER,
},
{
title: 'caseManagement.caseReview.type',
dataIndex: 'reviewPassRule',
type: FilterType.SELECT,
selectProps: {
mode: 'static',
options: [
{
label: t('caseManagement.caseReview.single'),
value: 'SINGLE',
},
{
label: t('caseManagement.caseReview.multi'),
value: 'MULTIPLE',
},
],
},
},
{
title: 'caseManagement.caseReview.reviewer',
dataIndex: 'reviewers',
@ -805,54 +764,35 @@
options: reviewersOptions.value,
},
},
{
title: 'caseManagement.caseReview.reviewResult',
dataIndex: 'status',
type: FilterType.SELECT,
selectProps: {
mode: 'static',
options: Object.keys(reviewResultMap).map((e) => ({
label: t(reviewResultMap[e as ReviewResult].label),
value: e,
})),
},
},
{
title: 'caseManagement.caseReview.creator',
dataIndex: 'createUser',
type: FilterType.SELECT,
selectProps: {
mode: 'static',
options: reviewersOptions.value,
options: memberOptions,
},
},
{
title: 'caseManagement.caseReview.module',
dataIndex: 'module',
type: FilterType.TREE_SELECT,
treeSelectData: props.moduleTree,
treeSelectProps: {
fieldNames: {
title: 'name',
key: 'id',
children: 'children',
},
},
},
{
title: 'caseManagement.caseReview.tag',
dataIndex: 'tags',
type: FilterType.TAGS_INPUT,
},
{
title: 'caseManagement.caseReview.desc',
dataIndex: 'description',
type: FilterType.INPUT,
},
{
title: 'caseManagement.caseReview.startTime',
dataIndex: 'startTime',
type: FilterType.DATE_PICKER,
},
{
title: 'caseManagement.caseReview.endTime',
dataIndex: 'endTime',
type: FilterType.DATE_PICKER,
},
];
});
defineExpose({
searchCase,
});
await tableStore.initColumn(TableKeyEnum.CASE_MANAGEMENT_REVIEW_CASE, columns, 'drawer');
</script>
<style lang="less" scoped>
@ -871,4 +811,3 @@
@apply inline-flex;
}
</style>
@/config/caseManagement

View File

@ -34,7 +34,9 @@
@popup-visible-change="handleFilterHidden"
>
<a-button type="text" class="arco-btn-text--secondary p-[8px_4px]" @click="statusFilterVisible = true">
{{ t(columnConfig.title as string) }}
<div class="font-medium">
{{ t(columnConfig.title as string) }}
</div>
<icon-down :class="statusFilterVisible ? 'text-[rgb(var(--primary-5))]' : ''" />
</a-button>
<template #content>
@ -101,10 +103,10 @@
<MsButton type="text" class="!mr-0" @click="() => editReview(record)">
{{ t('common.edit') }}
</MsButton>
<a-divider direction="vertical" :margin="8"></a-divider>
<!-- <a-divider direction="vertical" :margin="8"></a-divider>
<MsButton type="text" class="!mr-0">
{{ t('common.export') }}
</MsButton>
</MsButton> -->
<a-divider direction="vertical" :margin="8"></a-divider>
<MsTableMoreAction :list="getMoreAction(record.status)" @select="handleMoreActionSelect($event, record)" />
</template>
@ -169,6 +171,7 @@
import ModuleTree from './moduleTree.vue';
import { getReviewList, getReviewUsers } from '@/api/modules/case-management/caseReview';
import { getProjectMemberCommentOptions } from '@/api/modules/project-management/projectMember';
import { reviewStatusMap } from '@/config/caseManagement';
import { useI18n } from '@/hooks/useI18n';
import useModal from '@/hooks/useModal';
@ -211,8 +214,12 @@
onBeforeMount(async () => {
try {
const res = await getReviewUsers(appStore.currentProjectId, keyword.value);
const userOptions = res.map((e) => ({ label: e.name, value: e.id }));
const [userRes, memberRes] = await Promise.all([
getReviewUsers(appStore.currentProjectId, keyword.value),
getProjectMemberCommentOptions(appStore.currentProjectId, keyword.value),
]);
const userOptions = userRes.map((e) => ({ label: e.name, value: e.id }));
const memberOptions = memberRes.map((e) => ({ label: e.name, value: e.id }));
filterConfigList.value = [
{
title: 'ID',
@ -293,7 +300,7 @@
type: FilterType.SELECT,
selectProps: {
mode: 'static',
options: userOptions,
options: memberOptions,
},
},
{
@ -341,6 +348,10 @@
dataIndex: 'num',
slotName: 'num',
sortIndex: 1,
sortable: {
sortDirections: ['ascend', 'descend'],
sorter: true,
},
showTooltip: true,
width: 100,
},
@ -382,16 +393,13 @@
title: 'caseManagement.caseReview.reviewer',
slotName: 'reviewers',
dataIndex: 'reviewers',
sortable: {
sortDirections: ['ascend', 'descend'],
sorter: true,
},
width: 150,
},
{
title: 'caseManagement.caseReview.creator',
dataIndex: 'createUser',
width: 90,
dataIndex: 'createUserName',
showTooltip: true,
width: 120,
},
{
title: 'caseManagement.caseReview.module',
@ -402,7 +410,7 @@
title: 'caseManagement.caseReview.tag',
dataIndex: 'tags',
isTag: true,
width: 300,
width: 170,
},
{
title: 'caseManagement.caseReview.desc',
@ -420,11 +428,11 @@
slotName: 'action',
dataIndex: 'operation',
fixed: 'right',
width: 150,
width: 110,
},
];
const tableStore = useTableStore();
tableStore.initColumn(TableKeyEnum.CASE_MANAGEMENT_REVIEW, columns, 'drawer');
await tableStore.initColumn(TableKeyEnum.CASE_MANAGEMENT_REVIEW, columns, 'drawer');
const { propsRes, propsEvent, loadList, setLoadListParams, resetSelector } = useTable(
getReviewList,
{
@ -438,9 +446,12 @@
...item,
tags: (item.tags || []).map((e: string) => ({ id: e, name: e })),
reviewers: item.reviewers.map((e: ReviewDetailReviewersItem) => e.userName),
cycle: `${dayjs(item.startTime).format('YYYY-MM-DD HH:mm:ss')} - ${dayjs(item.endTime).format(
'YYYY-MM-DD HH:mm:ss'
)}`,
cycle:
item.startTime && item.endTime
? `${dayjs(item.startTime).format('YYYY-MM-DD HH:mm:ss')} - ${dayjs(item.endTime).format(
'YYYY-MM-DD HH:mm:ss'
)}`
: '',
};
}
);

View File

@ -0,0 +1,112 @@
<template>
<a-form ref="dialogFormRef" :model="caseResultForm" layout="vertical">
<a-form-item field="reason" :label="t('caseManagement.caseReview.reviewResult')" class="mb-[8px]">
<a-radio-group v-model:model-value="caseResultForm.result" @change="() => dialogFormRef?.resetFields()">
<a-radio value="PASS">
<div class="inline-flex items-center">
<MsIcon type="icon-icon_succeed_filled" class="mr-[4px] text-[rgb(var(--success-6))]" />
{{ t('caseManagement.caseReview.pass') }}
</div>
</a-radio>
<a-radio value="UN_PASS">
<div class="inline-flex items-center">
<MsIcon type="icon-icon_close_filled" class="mr-[4px] text-[rgb(var(--danger-6))]" />
{{ t('caseManagement.caseReview.fail') }}
</div>
</a-radio>
<a-radio value="UNDER_REVIEWED">
<div class="inline-flex items-center">
<MsIcon type="icon-icon_warning_filled" class="mr-[4px] text-[rgb(var(--warning-6))]" />
{{ t('caseManagement.caseReview.suggestion') }}
</div>
<a-tooltip :content="t('caseManagement.caseReview.suggestionTip')" position="right">
<icon-question-circle
class="ml-[4px] mt-[2px] text-[var(--color-text-4)] hover:text-[rgb(var(--primary-5))]"
size="16"
/>
</a-tooltip>
</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item
field="reason"
:label="t('caseManagement.caseReview.reason')"
:rules="
caseResultForm.result !== 'PASS'
? [{ required: true, message: t('caseManagement.caseReview.reasonRequired') }]
: []
"
asterisk-position="end"
class="mb-0"
>
<div class="flex w-full items-center">
<a-mention
v-model:model-value="caseResultForm.reason"
type="textarea"
:auto-size="{ minRows: 1 }"
:max-length="1000"
allow-clear
class="flex flex-1 items-center"
/>
<MsUpload
v-model:file-list="caseResultForm.fileList"
accept="image"
size-unit="MB"
:auto-upload="false"
:limit="10"
multiple
>
<a-button type="outline" class="ml-[8px] p-[8px_6px]">
<icon-file-image :size="18" />
</a-button>
</MsUpload>
</div>
<MsFileList
v-model:file-list="caseResultForm.fileList"
show-mode="imageList"
:show-tab="false"
class="mt-[8px]"
/>
</a-form-item>
</a-form>
</template>
<script setup lang="ts">
import { FormInstance } from '@arco-design/web-vue';
import MsIcon from '@/components/pure/ms-icon-font/index.vue';
import MsFileList from '@/components/pure/ms-upload/fileList.vue';
import MsUpload from '@/components/pure/ms-upload/index.vue';
import { MsFileItem } from '@/components/pure/ms-upload/types';
import { useI18n } from '@/hooks/useI18n';
import { ReviewResult } from '@/models/caseManagement/caseReview';
const { t } = useI18n();
const dialogFormRef = ref<FormInstance>();
const caseResultForm = ref({
result: 'PASS' as ReviewResult,
reason: '',
fileList: [] as MsFileItem[],
});
function validateForm(cb: (form: Record<string, any>) => void) {
dialogFormRef.value?.validate((errors) => {
if (!errors && typeof cb === 'function') {
cb(caseResultForm.value);
}
});
}
defineExpose({
validateForm,
});
</script>
<style lang="less" scoped>
.image-preview-container {
margin-top: 8px;
}
</style>

View File

@ -117,6 +117,7 @@
<AssociateDrawer
v-model:visible="associateDrawerVisible"
v-model:project="associateDrawerProject"
:review-id="reviewId"
@success="writeAssociateCases"
/>
<deleteReviewModal v-model:visible="deleteModalVisible" :record="reviewDetail" @success="handleDeleteSuccess" />
@ -172,11 +173,12 @@
const reviewDetail = ref<ReviewItem>({
...reviewDefaultDetail,
});
const reviewId = ref(route.query.id as string);
async function initDetail() {
try {
loading.value = true;
const res = await getReviewDetail(route.query.id as string);
const res = await getReviewDetail(reviewId.value);
reviewDetail.value = res;
} catch (error) {
// eslint-disable-next-line no-console
@ -203,7 +205,7 @@
modulesCount.value = await getReviewDetailModuleCount({
...params,
viewFlag: onlyMine.value,
reviewId: route.query.id as string,
reviewId: reviewId.value,
});
} catch (error) {
// eslint-disable-next-line no-console
@ -237,7 +239,7 @@
try {
loading.value = true;
await associateReviewCase({
reviewId: route.query.id as string,
reviewId: reviewId.value,
projectId: appStore.currentProjectId,
reviewers: params.reviewers,
baseAssociateCaseRequest: params,
@ -258,7 +260,7 @@
router.push({
name: CaseManagementRouteEnum.CASE_MANAGEMENT_REVIEW_CREATE,
query: {
id: route.query.id,
id: reviewId.value,
},
});
}
@ -267,7 +269,7 @@
router.push({
name: CaseManagementRouteEnum.CASE_MANAGEMENT_CASE_DETAIL,
query: {
reviewId: route.query.id,
reviewId: reviewId.value,
},
});
}
@ -337,7 +339,7 @@
followLoading.value = true;
await followReview({
userId: userStore.id || '',
caseReviewId: route.query.id as string,
caseReviewId: reviewId.value,
});
Message.success(
reviewDetail.value.followFlag
@ -357,7 +359,7 @@
router.push({
name: CaseManagementRouteEnum.CASE_MANAGEMENT_REVIEW_CREATE,
query: {
copyId: route.query.id,
copyId: reviewId.value,
},
});
}
@ -381,4 +383,3 @@
@apply hidden;
}
</style>
@/config/caseManagement

View File

@ -50,7 +50,7 @@ export default {
'caseManagement.caseReview.batchMoveConfirm': 'Move {count} use cases to the selected module',
'caseManagement.caseReview.batchMoveTitleSub': '({count} reviews selected)',
'caseManagement.caseReview.batchMoveSuccess': 'Review moved successfully',
'caseManagement.caseReview.folderSearchPlaceholder': 'Please enter a group name',
'caseManagement.caseReview.folderSearchPlaceholder': 'Please enter a module name',
'caseManagement.caseReview.allReviews': 'All reviews',
'caseManagement.caseReview.noReviews': 'No matching review data yet',
'caseManagement.caseReview.deleteFolderTipTitle': 'Remove the `{name}` module?',

View File

@ -43,10 +43,10 @@ export default {
'caseManagement.caseReview.archiveSuccess': '归档成功',
'caseManagement.caseReview.move': '移动到',
'caseManagement.caseReview.batchMove': '批量移动',
'caseManagement.caseReview.batchMoveConfirm': '移动 {count} 个用例至已选模块 ',
'caseManagement.caseReview.batchMoveConfirm': '移动 {count} 个评审至已选模块 ',
'caseManagement.caseReview.batchMoveTitleSub': '(已选 {count} 条评审) ',
'caseManagement.caseReview.batchMoveSuccess': '评审移动成功',
'caseManagement.caseReview.folderSearchPlaceholder': '请输入分组名称',
'caseManagement.caseReview.folderSearchPlaceholder': '请输入模块名称',
'caseManagement.caseReview.allReviews': '全部评审',
'caseManagement.caseReview.noReviews': '暂无匹配的评审数据',
'caseManagement.caseReview.deleteFolderTipTitle': '是否删除 `{name}` 模块?',

View File

@ -86,7 +86,7 @@
});
const innerSlogan = computed(() => {
return props.isPreview ? props.slogan : appStore.pageConfig.slogan;
return props.isPreview ? props.slogan : t(appStore.pageConfig.slogan);
});
const errorMessage = ref('');

View File

@ -20,10 +20,10 @@
:before-change="handleEnableIntercept"
:disabled="loading"
size="small"
class="mr-[4px]"
class="mr-[8px]"
type="line"
/>
<a-tooltip :content="t('project.fileManagement.uploadTipSingle')">
<a-tooltip v-if="fileType === 'jar'" :content="t('project.fileManagement.uploadTipSingle')">
<MsIcon type="icon-icon-maybe_outlined" class="mr-[8px] cursor-pointer hover:text-[rgb(var(--primary-5))]" />
</a-tooltip>
<MsButton
@ -176,6 +176,16 @@
:action-config="caseBatchActions"
v-on="caseTableEvent"
>
<template #id="{ record }">
<a-tooltip :content="`${record.id}`">
<a-button type="text" class="px-0" @click="goCaseDetail(record.id)">
<div class="one-line-text max-w-[168px]">{{ record.id }}</div>
</a-button>
</a-tooltip>
</template>
<template #sourceType="{ record }">
{{ t(associateFileSourceLocaleMap[record.sourceType]) }}
</template>
<template #action="{ record }">
<MsButton
v-permission="['PROJECT_FILE_MANAGEMENT:READ+UPDATE']"
@ -215,7 +225,6 @@
import MsBaseTable from '@/components/pure/ms-table/base-table.vue';
import type { MsPaginationI, MsTableColumn } from '@/components/pure/ms-table/type';
import useTable from '@/components/pure/ms-table/useTable';
import { getFileEnum } from '@/components/pure/ms-upload/iconMap';
import MsDetailDrawer from '@/components/business/ms-detail-drawer/index.vue';
import MsThumbnailCard from '@/components/business/ms-thumbnail-card/index.vue';
import popConfirm from './popConfirm.vue';
@ -232,13 +241,16 @@
upgradeAssociation,
} from '@/api/modules/project-management/fileManagement';
import { CompressImgUrl, OriginImgUrl } from '@/api/requrls/project-management/fileManagement';
import { associateFileSourceLocaleMap } from '@/config/fileManagement';
import { useI18n } from '@/hooks/useI18n';
import useLocale from '@/locale/useLocale';
import router from '@/router';
import { useAppStore } from '@/store';
import useUserStore from '@/store/modules/user';
import { downloadByteFile, formatFileSize } from '@/utils';
import { AssociationItem, FileDetail } from '@/models/projectManagement/file';
import { CaseManagementRouteEnum } from '@/enums/routeEnum';
const props = defineProps<{
visible: boolean;
@ -352,10 +364,17 @@
}
async function handleFileTagClose(tag: string | number, item: Description) {
await updateFile({
id: props.fileId,
tags: Array.isArray(item.value) ? item.value.filter((e) => e !== tag) : [],
});
try {
const lastTags = Array.isArray(item.value) ? item.value.filter((e) => e !== tag) : [];
await updateFile({
id: props.fileId,
tags: lastTags,
});
item.value = [...lastTags];
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
}
}
/**
@ -379,23 +398,30 @@
const caseColumns: MsTableColumn = [
{
title: 'project.fileManagement.id',
title: 'project.fileManagement.caseId',
dataIndex: 'id',
slotName: 'id',
showTooltip: true,
width: 150,
},
{
title: 'project.fileManagement.name',
title: 'project.fileManagement.caseName',
dataIndex: 'sourceName',
showTooltip: true,
width: 200,
width: 150,
},
{
title: 'project.fileManagement.type',
title: 'project.fileManagement.caseType',
dataIndex: 'sourceType',
slotName: 'sourceType',
width: 100,
showTooltip: true,
},
{
title: 'project.fileManagement.fileVersion',
title: 'project.fileManagement.caseFileVersion',
dataIndex: 'fileVersion',
width: 140,
showTooltip: true,
},
{
title: 'common.operation',
@ -412,10 +438,10 @@
} = useTable(getAssociationList, {
scroll: { x: 800 },
columns: caseColumns,
heightUsed: 200,
selectable: true,
showSelectAll: true,
showPagination: false,
size: 'default',
});
const caseBatchActions = {
@ -445,6 +471,15 @@
}
}
function goCaseDetail(id: string) {
router.push({
name: CaseManagementRouteEnum.CASE_MANAGEMENT_CASE_DETAIL,
query: {
id,
},
});
}
const versionColumns: MsTableColumn = [
{
title: 'project.fileManagement.fileVersion',
@ -504,9 +539,7 @@
}
function loadedFile(detail: FileDetail) {
if (detail.fileType) {
fileType.value = getFileEnum(`/${detail.fileType.toLowerCase()}`);
}
fileType.value = detail.fileType;
renameTitle.value = detail.name;
fileDescriptions.value = [
{
@ -543,6 +576,7 @@
showTagAdd: true,
closable: true,
key: 'tag',
tagType: 'default',
},
{
label: t('project.fileManagement.createTime'),

View File

@ -196,8 +196,8 @@
function reset(val: boolean) {
if (!val) {
form.value.field = '';
formRef.value?.resetFields();
form.value.field = '';
}
}
</script>

View File

@ -1,9 +1,9 @@
<template>
<div class="flex h-[calc(100vh-88px)] flex-col overflow-hidden p-[24px]">
<div class="flex h-[calc(100vh-88px)] flex-col p-[24px]">
<div class="header">
<a-button v-permission="['PROJECT_FILE_MANAGEMENT:READ+ADD']" type="primary" @click="handleAddClick">{{
t('project.fileManagement.addFile')
}}</a-button>
<a-button v-permission="['PROJECT_FILE_MANAGEMENT:READ+ADD']" type="primary" @click="handleAddClick">
{{ t('project.fileManagement.addFile') }}
</a-button>
<div class="header-right">
<a-select v-model="tableFileType" class="w-[240px]" :loading="fileTypeLoading" @change="searchList">
<template #prefix>
@ -21,6 +21,7 @@
class="w-[240px]"
@search="searchList"
@press-enter="searchList"
@clear="searchList"
/>
<a-radio-group
v-if="props.activeFolderType === 'folder'"
@ -113,7 +114,7 @@
:type="item.fileType"
:url="`${CompressImgUrl}/${userStore.id}/${item.id}`"
:footer-text="item.name"
:more-actions="item.fileType === 'JAR' ? getJarFileActions(item) : normalFileActions"
:more-actions="item.fileType === 'jar' ? getJarFileActions(item) : normalFileActions"
@click="openFileDetail(item.id, index)"
@action-select="handleMoreActionSelect($event, item)"
/>
@ -241,6 +242,7 @@
:rules="[{ required: true, message: t('project.fileManagement.gitFilePathNotNull') }]"
required
asterisk-position="end"
class="mb-0"
>
<a-input
v-model:model-value="storageForm.path"
@ -356,6 +358,7 @@
import useAsyncTaskStore from '@/store/modules/app/asyncTask';
import useUserStore from '@/store/modules/user';
import { characterLimit, downloadByteFile, formatFileSize } from '@/utils';
import { hasAnyPermission } from '@/utils/permission';
import type { FileItem, FileListQueryParams, Repository } from '@/models/projectManagement/file';
import { RouteEnum } from '@/enums/routeEnum';
@ -444,7 +447,7 @@
},
];
}
return [
const normalActions = [
{
label: 'project.fileManagement.move',
eventTag: 'move',
@ -458,10 +461,20 @@
danger: true,
},
];
if (showType.value === 'card') {
return [
{
label: 'project.fileManagement.download',
eventTag: 'download',
},
...normalActions,
];
}
return normalActions;
});
function getJarFileActions(record: FileItem) {
const jarFileActions: ActionsItem[] = [
let jarFileActions: ActionsItem[] = [
{
label: 'project.fileManagement.move',
eventTag: 'move',
@ -483,10 +496,24 @@
danger: true,
},
];
if (record.enable) {
return jarFileActions.filter((e) => e.label !== 'common.enable');
if (showType.value === 'card') {
jarFileActions = [
{
label: 'project.fileManagement.download',
eventTag: 'download',
},
...jarFileActions,
];
}
return jarFileActions.filter((e) => e.label !== 'common.disable');
if (record.storage === 'GIT') {
jarFileActions = jarFileActions.filter((e) => e.eventTag !== 'move');
}
if (record.enable) {
jarFileActions = jarFileActions.filter((e) => e.label !== 'common.enable');
} else if (record.enable === false) {
jarFileActions = jarFileActions.filter((e) => e.label !== 'common.disable');
}
return jarFileActions;
}
const columns: MsTableColumn = [
@ -494,6 +521,7 @@
title: 'project.fileManagement.name',
slotName: 'name',
dataIndex: 'name',
fixed: 'left',
width: 270,
},
{
@ -515,7 +543,7 @@
},
{
title: 'project.fileManagement.creator',
dataIndex: 'creator',
dataIndex: 'createUser',
showTooltip: true,
width: 120,
},
@ -544,12 +572,13 @@
},
];
const tableStore = useTableStore();
await tableStore.initColumn(TableKeyEnum.FILE_MANAGEMENT_FILE, columns, 'drawer');
const { propsRes, propsEvent, loadList, setLoadListParams, resetSelector, resetPagination } = useTable(
getFileList,
{
tableKey: TableKeyEnum.FILE_MANAGEMENT_FILE,
showSetting: true,
selectable: true,
selectable: !!hasAnyPermission(['PROJECT_FILE_MANAGEMENT:READ+DOWNLOAD+UPDATE+DELETE']),
showSelectAll: true,
},
(item) => {
@ -809,6 +838,7 @@
*/
async function changeFileType() {
await initFileTypes();
resetSelector();
setTableParams();
if (showType.value === 'card') {
cardListRef.value?.reload();
@ -938,6 +968,9 @@
case 'toggle':
toggleJarFile(record);
break;
case 'download':
handleDownload(record);
break;
default:
break;
}

View File

@ -8,7 +8,7 @@ export default {
'project.fileManagement.addStorage': 'Add repository',
'project.fileManagement.rename': 'Rename',
'project.fileManagement.nameNotNull': 'name cannot be empty',
'project.fileManagement.namePlaceholder': 'Please enter the group name and press Enter to save',
'project.fileManagement.namePlaceholder': 'Please enter the module name and press Enter to save',
'project.fileManagement.renameSuccess': 'Rename successful',
'project.fileManagement.updateDescSuccess': 'File description updated successfully',
'project.fileManagement.addSubModuleSuccess': 'Added successfully',
@ -132,4 +132,15 @@ export default {
'project.fileManagement.batchMoveConfirm': 'Move to selected module',
'project.fileManagement.batchMoveSuccess': 'File moved successfully',
'project.fileManagement.moduleMoveSuccess': 'Module moved successfully',
'project.fileManagement.caseId': 'Case ID',
'project.fileManagement.caseName': 'Case name',
'project.fileManagement.caseType': 'Case type',
'project.fileManagement.caseFileVersion': 'Associated file version',
'project.fileManagement.caseTypeBug': 'Bug',
'project.fileManagement.caseTypeFeature': 'Feature',
'project.fileManagement.caseTypeApiDebug': 'Api debug',
'project.fileManagement.caseTypeApiScene': 'Api scene',
'project.fileManagement.caseTypeApiCase': 'Api case',
'project.fileManagement.caseTypeApiDefine': 'Api definition',
'project.fileManagement.caseTypeApiMock': 'Api Mock',
};

View File

@ -8,7 +8,7 @@ export default {
'project.fileManagement.addStorage': '添加存储库',
'project.fileManagement.rename': '重命名',
'project.fileManagement.nameNotNull': '名字不能为空',
'project.fileManagement.namePlaceholder': '请输入分组名称,按回车键保存',
'project.fileManagement.namePlaceholder': '请输入模块名称,按回车键保存',
'project.fileManagement.renameSuccess': '重命名成功',
'project.fileManagement.updateDescSuccess': '文件描述更新成功',
'project.fileManagement.addSubModuleSuccess': '添加成功',
@ -124,4 +124,15 @@ export default {
'project.fileManagement.batchMoveConfirm': '移动至所选模块',
'project.fileManagement.batchMoveSuccess': '文件移动成功',
'project.fileManagement.moduleMoveSuccess': '模块移动成功',
'project.fileManagement.caseId': '用例 ID',
'project.fileManagement.caseName': '用例名称',
'project.fileManagement.caseType': '用例类型',
'project.fileManagement.caseFileVersion': '关联的文件版本',
'project.fileManagement.caseTypeBug': '缺陷',
'project.fileManagement.caseTypeFeature': '功能用例',
'project.fileManagement.caseTypeApiDebug': '接口调试',
'project.fileManagement.caseTypeApiScene': '接口场景',
'project.fileManagement.caseTypeApiCase': '接口用例',
'project.fileManagement.caseTypeApiDefine': '接口定义',
'project.fileManagement.caseTypeApiMock': '接口 Mock',
};

View File

@ -1,121 +1,113 @@
<template>
<MsCard ref="fullRef" :special-height="132" :is-fullscreen="isFullScreen" simple>
<div id="mscard">
<div class="mb-[16px] 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"
allow-clear
class="mr-[8px] w-[240px]"
:prefix="t('project.messageManagement.robot')"
:multiple="true"
:has-all-select="true"
:default-all-select="true"
:popup-container="isFullScreen ? '#mscard' : undefined"
>
<template #footer>
<div class="mb-[6px] mt-[4px] p-[3px_8px]">
<MsButton v-permission="['PROJECT_MESSAGE:READ+ADD']" 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="toggleFullScreen">
<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>
<ms-base-table
ref="tableRef"
v-bind="propsRes"
v-model:expandedKeys="expandedKeys"
no-disable
:indent-size="0"
v-on="propsEvent"
<MsCard
ref="fullRef"
:special-height="132"
show-full-screen
hide-back
hide-footer
@toggle-full-screen="handleToggleFullScreen"
>
<template #headerLeft>
<div class="font-medium text-[var(--color-text-000)]">{{ t('project.messageManagement.config') }}</div>
</template>
<template #headerRight>
<MsSelect
v-model:model-value="robotFilters"
:options="robotOptions"
:allow-search="false"
allow-clear
class="mr-[8px] !w-[240px]"
:prefix="t('project.messageManagement.robot')"
:multiple="true"
:has-all-select="true"
:default-all-select="true"
>
<template #name="{ record }">
<span class="font-medium text-[var(--color-text-1)]">{{ record.name }}</span>
<template #footer>
<div class="mb-[6px] mt-[4px] p-[3px_8px]">
<MsButton v-permission="['PROJECT_MESSAGE:READ+ADD']" 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>
<template #eventName="{ record }">
<span>{{ record.eventName || '' }}</span>
</template>
<template #receiver="{ record, dataIndex }">
<MsSelect
v-if="!record.children"
:id="`${record.taskType}-${record.event}`"
v-model:model-value="record.receivers"
v-model:loading="record.loading"
mode="remote"
:options="defaultReceivers"
:search-keys="['label']"
allow-search
:at-least-one="true"
value-key="id"
label-key="name"
:multiple="true"
:placeholder="t('project.messageManagement.receiverPlaceholder')"
:remote-extra-params="{ projectId: appStore.currentProjectId }"
:remote-func="getMessageUserList"
:remote-fields-map="{ label: 'name', value: 'id', id: 'id' }"
:not-auto-init-search="true"
:popup-container="isFullScreen ? '#mscard' : undefined"
:fallback-option="(val) => ({
</MsSelect>
</template>
<ms-base-table
ref="tableRef"
v-bind="propsRes"
v-model:expandedKeys="expandedKeys"
no-disable
:indent-size="0"
v-on="propsEvent"
>
<template #name="{ record }">
<span class="font-medium text-[var(--color-text-1)]">{{ record.name }}</span>
</template>
<template #eventName="{ record }">
<span>{{ record.eventName || '' }}</span>
</template>
<template #receiver="{ record, dataIndex }">
<MsSelect
v-if="!record.children"
v-model:model-value="record.receivers"
v-model:loading="record.loading"
class="w-full"
mode="remote"
:options="defaultReceivers"
:remote-filter-func="(opts) => getReceiverOptions(opts, record.event)"
:search-keys="['label']"
allow-search
:at-least-one="true"
value-key="id"
label-key="name"
:multiple="true"
:placeholder="t('project.messageManagement.receiverPlaceholder')"
:remote-extra-params="{ projectId: appStore.currentProjectId }"
:remote-func="getMessageUserList"
:remote-fields-map="{ label: 'name', value: 'id', id: 'id' }"
:not-auto-init-search="true"
:fallback-option="(val) => ({
label: (val as Record<string, any>).name,
value: val,
})"
:object-value="true"
@remove="changeMessageReceivers(false, record, dataIndex as string)"
@popup-visible-change="changeMessageReceivers($event, record, dataIndex as string)"
:object-value="true"
@remove="changeMessageReceivers(false, record, dataIndex as string)"
@popup-visible-change="changeMessageReceivers($event, record, dataIndex as string)"
/>
<span v-else></span>
</template>
<template #robot="{ record, dataIndex }">
<div v-if="!record.children && record.projectRobotConfigMap?.[dataIndex as string]" class="flex items-center">
<a-switch
v-model:model-value="record.projectRobotConfigMap[dataIndex as string].enable"
v-permission="['PROJECT_MESSAGE:READ+UPDATE']"
:before-change="(val) => handleChangeIntercept(!!val, record, dataIndex as string)"
size="small"
type="line"
/>
<span v-else></span>
</template>
<template #robot="{ record, dataIndex }">
<div v-if="!record.children && record.projectRobotConfigMap?.[dataIndex as string]" class="flex items-center">
<a-switch
v-model:model-value="record.projectRobotConfigMap[dataIndex as string].enable"
v-permission="['PROJECT_MESSAGE:READ+UPDATE']"
:before-change="(val) => handleChangeIntercept(!!val, record, dataIndex as string)"
size="small"
type="line"
/>
<a-popover position="right" :popup-container="isFullScreen ? '#mscard' : undefined">
<div
class="ml-[8px] mr-[4px] cursor-pointer text-[var(--color-text-1)] hover:text-[rgb(var(--primary-6))]"
>
{{ t('common.preview') }}
</div>
<template #content>
<MessagePreview
:robot="record.projectRobotConfigMap[dataIndex as string]"
:function-name="record.functionName"
:event-name="record.eventName"
/>
</template>
</a-popover>
<MsButton
v-permission="['PROJECT_MESSAGE:READ+UPDATE']"
v-xpack
type="button"
@click="editRobot(record, dataIndex as string)"
>{{ t('common.setting') }}</MsButton
>
</div>
<span v-else></span>
</template>
</ms-base-table>
</div>
<a-popover position="right">
<div class="ml-[8px] mr-[4px] cursor-pointer text-[var(--color-text-1)] hover:text-[rgb(var(--primary-6))]">
{{ t('common.preview') }}
</div>
<template #content>
<MessagePreview
:robot="record.projectRobotConfigMap[dataIndex as string]"
:function-name="record.functionName"
:event-name="record.eventName"
/>
</template>
</a-popover>
<MsButton
v-permission="['PROJECT_MESSAGE:READ+UPDATE']"
v-xpack
type="button"
@click="editRobot(record, dataIndex as string)"
>{{ t('common.setting') }}</MsButton
>
</div>
<span v-else></span>
</template>
</ms-base-table>
</MsCard>
</template>
@ -139,7 +131,6 @@
getRobotList,
saveMessageConfig,
} from '@/api/modules/project-management/messageManagement';
import useFullScreen from '@/hooks/useFullScreen';
import { useI18n } from '@/hooks/useI18n';
import useAppStore from '@/store/modules/app';
@ -157,9 +148,6 @@
const robotFilters = ref<string[]>([]);
const robotOptions = ref<(SelectOptionData & RobotItem)[]>([]);
const fullRef = ref<HTMLElement | null>();
const { isFullScreen, toggleFullScreen } = useFullScreen(fullRef);
const tableRef = ref<InstanceType<typeof MsBaseTable> | null>(null);
const staticColumns: MsTableColumn = [
@ -219,6 +207,7 @@
);
const expandedKeys = ref<string[]>([]);
const heightUsed = ref(428);
interface TableMessageChildrenItem {
functionName: string;
@ -243,7 +232,7 @@
showPagination: false,
hoverable: false,
showExpand: true,
heightUsed: 50,
heightUsed: heightUsed.value,
rowKey: 'key',
rowClass: (record: TableMessageItem) => {
if (record.children) {
@ -302,6 +291,10 @@
}
);
function handleToggleFullScreen(val: boolean) {
propsRes.value.heightUsed = val ? 224 : 428;
}
function spanMethod(data: {
record: TableData;
column: TableColumnData | TableOperationColumn;
@ -326,6 +319,14 @@
}));
}
function getReceiverOptions(options, event: string) {
if (event === 'CREATE') {
//
return options.filter((e) => !['OPERATOR', 'CREATE_USER', 'FOLLOW_PEOPLE'].includes(e.id));
}
return options;
}
async function initRobotList() {
const res = await getRobotList(appStore.currentProjectId);
robotOptions.value = res

View File

@ -89,6 +89,7 @@
:ok-loading="drawerLoading"
:show-continue="!isEdit"
:ok-text="isEdit ? t('common.update') : t('common.create')"
:save-continue-text="t('project.messageManagement.saveContinueText')"
@confirm="handleDrawerConfirm"
@continue="handleDrawerConfirm(true)"
@cancel="handleDrawerCancel"
@ -407,8 +408,16 @@
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'),
content: () =>
h('div', {
innerHTML: `<div>${t(
robot.enable ? 'project.messageManagement.disableContent' : 'project.messageManagement.enableContent',
{ robot: robot.name }
)}</div><div>${robot.platform === 'MAIL' ? t('project.messageManagement.enableEmailContentTip') : ''}</div>`,
}),
okText: t(robot.enable ? 'project.messageManagement.disableConfirm' : 'project.messageManagement.enableConfirm', {
robot: robot.name,
}),
cancelText: t('common.cancel'),
maskClosable: false,
onBeforeOk: async () => {

View File

@ -54,12 +54,14 @@ export default {
'project.messageManagement.appSecretRequired': 'AppSecret cannot be empty',
'project.messageManagement.disableTitle': 'Are you sure you want to close {name}?',
'project.messageManagement.disableContent':
'After closing, it will no longer receive in-site message notifications and will not be displayed on the message list page.',
'After closing, it will no longer receive {robot} message notifications and will not be displayed on the message list page.',
'project.messageManagement.disableConfirm': 'Confirm close',
'project.messageManagement.disableSuccess': 'Closed successfully',
'project.messageManagement.enableTitle': 'Turn on {name}',
'project.messageManagement.enableContent':
'After turning it on, site messages will be displayed in the message settings list, and the notification type needs to be manually set.',
'After turning it on, {robot} messages will be displayed in the message settings list, and the notification type needs to be manually set.',
'project.messageManagement.enableEmailContentTip':
'Email notifications can be sent only after configuring the SMTP service in System Settings-Email Settings.',
'project.messageManagement.enableConfirm': 'Confirm to open',
'project.messageManagement.enableSuccess': 'Opened successfully',
'project.messageManagement.deleteTitle': 'Confirm to delete {name}?',
@ -100,4 +102,5 @@ export default {
'project.messageManagement.receiverNotNull': 'Please set at least one message recipient',
'project.messageManagement.unsetReceiversTip': 'Please set the message recipients before configuring the template.',
'project.messageManagement.noMatchField': 'No matching fields yet',
'project.messageManagement.saveContinueText': 'Save and continue creating',
};

View File

@ -12,7 +12,7 @@ export default {
'project.messageManagement.updateAt': '更新于',
'project.messageManagement.status': '状态',
'project.messageManagement.statusTipOn': '开启:在消息通知列表展示及使用',
'project.messageManagement.statusTipOff': '关闭:暂不使用机器人',
'project.messageManagement.statusTipOff': '关闭:暂不使用机器人',
'project.messageManagement.choosePlatform': '选择配置平台',
'project.messageManagement.WE_COM': '企业微信',
'project.messageManagement.DING_TALK': '钉钉',
@ -49,11 +49,12 @@ export default {
'project.messageManagement.appSecretPlaceholder': '打开帮助文档可直接获取',
'project.messageManagement.appSecretRequired': 'AppSecret 不能为空',
'project.messageManagement.disableTitle': '确定关闭 {name} 吗?',
'project.messageManagement.disableContent': '关闭后,将不在接收站内信通知,且不在消息列表页展示',
'project.messageManagement.disableContent': '关闭后,将不在接收 {robot} 通知,且不在消息列表页展示',
'project.messageManagement.disableConfirm': '确认关闭',
'project.messageManagement.disableSuccess': '关闭成功',
'project.messageManagement.enableTitle': '开启 {name}',
'project.messageManagement.enableContent': '开启后,站内信则显示在消息设置列表,需要手动设置通知类型',
'project.messageManagement.enableContent': '开启后,{robot} 则显示在消息设置列表,需要手动设置通知类型',
'project.messageManagement.enableEmailContentTip': '在系统设置-邮件设置中配置 SMTP 服务后才可发送邮件通知',
'project.messageManagement.enableConfirm': '确认开启',
'project.messageManagement.enableSuccess': '开启成功',
'project.messageManagement.deleteTitle': '确认删除 {name} ',
@ -92,4 +93,5 @@ export default {
'project.messageManagement.receiverNotNull': '请最少设置一位消息接收人',
'project.messageManagement.unsetReceiversTip': '配置模板前请先设置消息接收人',
'project.messageManagement.noMatchField': '暂无匹配字段',
'project.messageManagement.saveContinueText': '保存并继续创建',
};

View File

@ -31,17 +31,18 @@
</div>
</div>
<div class="mt-[40px] flex justify-center">
<a-button type="outline" @click="() => openProjectVersion(true)">
<a-button v-permission="['PROJECT_VERSION:READ+UPDATE']" type="outline" @click="() => openProjectVersion(true)">
{{ t('project.projectVersion.openVersion') }}
</a-button>
</div>
</div>
</a-spin>
<div v-if="projectVersionStatus">
<div class="flex justify-between">
<div v-if="projectVersionStatus" class="table-container">
<div class="mb-[16px] flex justify-between">
<div>
<a-switch
v-model:model-value="projectVersionStatus"
v-permission="['PROJECT_VERSION:READ+UPDATE']"
size="small"
:before-change="(val) => openProjectVersion(val)"
type="line"
@ -56,14 +57,20 @@
allow-clear
@search="searchVersion"
@press-enter="searchVersion"
@clear="searchVersion"
/>
</div>
<MsBaseTable v-bind="propsRes" v-on="propsEvent">
<template #statusTitle>
<div class="flex items-center">
{{ t('project.projectVersion.status') }}
<div class="font-medium text-[var(--color-text-3)]">
{{ t('project.projectVersion.status') }}
</div>
<a-popover position="rt">
<icon-info-circle class="ml-[4px] hover:text-[rgb(var(--primary-5))]" size="16" />
<icon-info-circle
class="ml-[4px] text-[var(--color-text-3)] hover:text-[rgb(var(--primary-5))]"
size="16"
/>
<template #title>
<div class="w-[256px]"> {{ t('project.projectVersion.statusTip') }} </div>
</template>
@ -93,7 +100,7 @@
</a-popover>
</div>
</template>
<template #quickCreate>
<template v-if="hasAnyPermission(['PROJECT_VERSION:READ+ADD'])" #quickCreate>
<a-form
v-if="showQuickCreateForm"
ref="quickCreateFormRef"
@ -144,6 +151,7 @@
<template #status="{ record }">
<a-switch
v-model:model-value="record.status"
v-permission="['PROJECT_VERSION:READ+UPDATE']"
size="small"
:before-change="(val) => handleStatusChange(val, record)"
type="line"
@ -152,6 +160,7 @@
<template #latest="{ record }">
<a-switch
v-model:model-value="record.latest"
v-permission="['PROJECT_VERSION:READ+UPDATE']"
:disabled="record.latest"
:before-change="() => handleUseLatestVersionChange(record)"
size="small"
@ -161,6 +170,7 @@
<template #action="{ record }">
<a-tooltip :content="t('project.projectVersion.latestVersionDeleteTip')" :disabled="!record.latest">
<MsButton
v-permission="['PROJECT_VERSION:READ+DELETE']"
type="text"
:loading="delLoading"
:disabled="record.latest"
@ -234,6 +244,7 @@
import { useI18n } from '@/hooks/useI18n';
import useModal from '@/hooks/useModal';
import useAppStore from '@/store/modules/app';
import { hasAnyPermission } from '@/utils/permission';
import { ProjectItem, ProjectVersionOption } from '@/models/projectManagement/projectVersion';
import { ColumnEditTypeEnum } from '@/enums/tableEnum';
@ -310,7 +321,7 @@
dataIndex: 'name',
showTooltip: true,
editType: ColumnEditTypeEnum.INPUT,
width: 150,
width: 120,
},
{
title: 'project.projectVersion.status',
@ -346,6 +357,7 @@
{
title: 'project.projectVersion.creator',
dataIndex: 'createUser',
showTooltip: true,
},
{
title: 'common.operation',
@ -358,8 +370,8 @@
const { propsRes, propsEvent, loadList, setKeyword, setLoadListParams } = useTable(
getVersionList,
{
scroll: { x: '100%' },
columns,
size: 'default',
},
(item) => {
return {
@ -585,4 +597,10 @@
box-shadow: 0 5px 5px -3px rgb(0 0 0 / 10%), 0 8px 10px 1px rgb(0 0 0 / 6%), 0 3px 14px 2px rgb(0 0 0 / 5%);
gap: 2px;
}
.table-container {
.ms-scroll-bar();
@apply overflow-auto;
min-width: 850px;
}
</style>

View File

@ -12,7 +12,7 @@
</div>
<a-input-search
v-model="keyword"
:max-length="250"
:max-length="255"
allow-clear
:placeholder="t('organization.member.searchMember')"
class="w-[230px]"

View File

@ -198,6 +198,7 @@
Message.success(
isEdit.value ? t('system.project.updateProjectSuccess') : t('system.project.createProjectSuccess')
);
appStore.initProjectList();
handleCancel(true);
} catch (error) {
// eslint-disable-next-line no-console

View File

@ -664,7 +664,6 @@
},
];
const tableStore = useTableStore();
tableStore.initColumn(TableKeyEnum.SYSTEM_AUTH, columns, 'drawer');
const { propsRes, propsEvent, loadList } = useTable(getAuthList, {
tableKey: TableKeyEnum.SYSTEM_AUTH,
columns,
@ -1193,6 +1192,7 @@
>;
export declare type AuthConfigInstance = InstanceType<typeof _default>;
await tableStore.initColumn(TableKeyEnum.SYSTEM_AUTH, columns, 'drawer');
</script>
<style lang="less" scoped></style>

View File

@ -38,7 +38,7 @@
{
key: 'memoryCleanup',
title: t('system.config.memoryCleanup'),
permission: ['SYSTEM_PARAMETER_SETTING_MEMORY_CLEAN:READ'],
permission: ['SYSTEM_PARAMETER_SETTING_MEMORY_CLEAN:READ+UPDATE'],
},
]);

Some files were not shown because too many files have changed in this diff Show More