feat(功能用例): 功能用例页面搭建&搜索面板初版&项目管理基本信息调整&插件管理展开折叠调整

This commit is contained in:
xinxin.wu 2023-09-25 15:39:56 +08:00 committed by fit2-zhao
parent eb3e76b45e
commit c6cc6e064c
22 changed files with 1279 additions and 35 deletions

View File

@ -0,0 +1,76 @@
import { OPERATORS } from './operator';
// eslint-disable-next-line no-shadow
export enum CaseKeyEnum {
NAME = 'name',
UPDATE_TIME = 'updateTime',
MODULES = 'modules',
CREATE_TIME = 'createTime',
CREATOR = 'creator',
TAGS = 'tags',
REVIEW_RESULT = 'reviewResults',
FOLLOW_PEOPLE = 'followPeople',
ASSOCIATED_REQUIREMENTS = 'associated_requirements',
CASE_LEVEL = 'caseLevel',
CASE_STATUS = 'caseStatus',
PRINCIPAL = 'principal', // 责任人
}
// 名称
export const NAME = {
key: CaseKeyEnum.NAME, // 对应字段key
type: 'a-input', // Vue控件名称
label: '显示名称', // 显示名称
operator: {
value: OPERATORS.LIKE.value, // 如果未设置value初始值则value初始值为options[0]
options: [OPERATORS.LIKE, OPERATORS.NOT_LIKE], // 运算符选项
},
};
// 标签
export const TAGS = {
key: CaseKeyEnum.TAGS,
type: 'a-input',
label: '标签',
operator: {
value: OPERATORS.LIKE.value,
options: [OPERATORS.LIKE, OPERATORS.NOT_LIKE],
},
};
// 所属模块
export const MODULE = {
key: 'module',
type: 'a-tree-select',
label: '所属模块',
operator: {
value: OPERATORS.LIKE.value,
options: [OPERATORS.LIKE, OPERATORS.NOT_LIKE],
},
};
// 创建时间
export const CREATE_TIME = {
key: CaseKeyEnum.CREATE_TIME,
type: 'time-select', // 时间选择器
label: '创建时间',
props: {},
operator: {
options: [OPERATORS.BETWEEN, OPERATORS.GT, OPERATORS.LT],
},
};
// 更新时间
export const UPDATE_TIME = {
key: CaseKeyEnum.UPDATE_TIME,
type: 'time-select',
label: '更新时间',
props: {},
operator: {
options: [OPERATORS.BETWEEN, OPERATORS.GT, OPERATORS.LT],
},
};
// 功能用例所需要列表
export const TEST_PLAN_TEST_CASE = [NAME, TAGS, MODULE, CREATE_TIME, UPDATE_TIME];
export default {};

View File

@ -0,0 +1,59 @@
<template>
<a-date-picker
v-if="props.operationType !== 'between'"
v-model:model-value="timeValue"
class="w-[100%]"
show-time
allow-clear
:time-picker-props="{ defaultValue: '00:00:00' }"
format="YYYY-MM-DD HH:mm:ss"
position="br"
@change="changeHandler"
/>
<a-range-picker
v-else
v-model:model-value="timeRangeValue"
position="br"
show-time
class="w-[100%]"
format="YYYY-MM-DD HH:mm"
:time-picker-props="{
defaultValue: ['00:00:00', '00:00:00'],
}"
@change="changeHandler"
></a-range-picker>
</template>
<script setup lang="ts">
import { CalendarValue } from '@arco-design/web-vue/es/date-picker/interface';
import { ref, watch } from 'vue';
type PickerType = 'between' | 'gt' | 'lt'; // | |
const props = defineProps<{
modelValue: [] | string; //
operationType: PickerType; //
}>();
const emits = defineEmits(['updateTime']);
const timeValue = ref<string>('');
const timeRangeValue = ref([]);
const changeHandler = (value: Date | string | number | undefined | (CalendarValue | undefined)[] | undefined) => {
emits('updateTime', value);
};
watch(
() => props.modelValue,
(val) => {
if (!val) {
if (props.operationType === 'between') timeRangeValue.value = [];
timeValue.value = '';
}
}
);
</script>
<style scoped></style>

View File

@ -0,0 +1,28 @@
export default {
operators: {
is_empty: 'Is empty',
is_not_empty: 'Is not empty',
like: 'Contains',
not_like: 'Not included',
in: 'Belong to',
not_in: 'Not belonging',
gt: 'Greater than',
ge: 'Greater than or equal to',
lt: 'Less than',
le: 'Less than or equal to',
equals: 'Equal to',
not_equals: 'Not Equal to',
between: 'Between',
current_user: 'Current user',
},
condition: {
all: 'all',
oneOf: 'or',
},
searchPanel: {
addCondition: 'Add Conditions',
reset: 'reset',
filter: 'filter',
selectTip: 'Please select the query field',
},
};

View File

@ -0,0 +1,29 @@
export default {
// 操作符号
operators: {
is_empty: '空',
is_not_empty: '非空',
like: '包含',
not_like: '不包含',
in: '属于',
not_in: '不属于',
gt: '大于',
ge: '大于等于',
lt: '小于',
le: '小于等于',
equals: '等于',
not_equals: '不等于',
between: '之间',
current_user: '是当前用户',
},
condition: {
all: '所有',
oneOf: '任一',
},
searchPanel: {
addCondition: '添加条件',
reset: '重置',
filter: '筛选',
selectTip: '请选择查询字段',
},
};

View File

@ -0,0 +1,49 @@
// 运算符号
export const OPERATORS = {
LIKE: {
label: 'operators.like',
value: 'like',
},
NOT_LIKE: {
label: 'operators.not_like',
value: 'not like',
},
IN: {
label: 'operators.in',
value: 'in',
},
NOT_IN: {
label: 'operators.not_in',
value: 'not in',
},
GT: {
label: 'operators.gt',
value: 'gt',
},
GE: {
label: 'operators.ge',
value: 'ge',
},
LT: {
label: 'operators.lt',
value: 'lt',
},
LE: {
label: 'operators.le',
value: 'le',
},
EQ: {
label: 'operators.equals',
value: 'eq',
},
BETWEEN: {
label: 'operators.between',
value: 'between',
},
CURRENT_USER: {
label: 'operators.current_user',
value: 'current user',
},
};
export default {};

View File

@ -0,0 +1,137 @@
<template>
<div class="overflow-y-auto">
<div class="flex flex-wrap items-start gap-[8px]">
<div class="flex-1">
<component
:is="form.searchKey.type"
v-bind="form.searchKey.props"
v-model="form.searchKey.value"
@change="cate1ChangeHandler"
>
<a-optgroup
v-for="(group, index) of props.selectGroupList"
:key="`${group.label as string + index}`"
:label="group.label"
>
<a-option
v-for="groupOptions of group.options"
:key="groupOptions.value"
:value="groupOptions.value"
:disabled="isDisabledList.indexOf(groupOptions.value) > -1"
>{{ groupOptions.label }}</a-option
>
</a-optgroup>
</component>
</div>
<div class="w-[100px]">
<component
:is="form.operatorCondition.type"
v-bind="form.operatorCondition.props"
v-model="form.operatorCondition.value"
@change="operatorChangeHandler"
>
<a-option v-for="operator of form.operatorCondition.options" :key="operator.value" :value="operator.value">
{{ t(operator.label) }}
</a-option>
</component>
</div>
<div class="flex flex-1">
<component
:is="form.queryContent.type"
v-if="form.queryContent.type !== 'time-select'"
v-bind="form.queryContent.props"
v-model="form.queryContent.value"
@change="filterKeyChange"
>
<template v-if="form.queryContent.type === 'a-select'">
<a-option v-for="opt of form.queryContent.options" :key="opt.value" :value="opt.value">{{
opt.label
}}</a-option>
</template>
<template v-if="form.queryContent.type === 'a-select-group'">
<a-select v-model="form.queryContent.value" v-bind="form.queryContent.props">
<a-optgroup v-for="group of form.searchKey.options" :key="group.id" :label="group.label">
<a-option v-for="groupOptions of group.options" :key="groupOptions.id" :value="groupOptions.id">{{
groupOptions.label
}}</a-option>
</a-optgroup>
</a-select>
</template>
</component>
<TimerSelect
v-else
:model-value="form.queryContent.value"
v-bind="form.queryContent.props"
:operation-type="form.operatorCondition.value"
@update-time="updateTimeValue"
/>
<div class="minus"> <slot></slot></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watchEffect } from 'vue';
import { cloneDeep } from 'lodash-es';
import { useI18n } from '@/hooks/useI18n';
import { TEST_PLAN_TEST_CASE } from './caseUtils';
import TimerSelect from './component/ms-date-picker.vue';
import { SelectOptionData } from '@arco-design/web-vue';
const { t } = useI18n();
const props = defineProps<{
formItem: Record<string, any>;
index: number;
formList: Record<string, any>[];
selectGroupList: SelectOptionData[];
}>();
const emits = defineEmits(['dataUpdated']);
const form = ref({ ...cloneDeep(props.formItem) });
watchEffect(() => {
form.value.queryContent.value = props.formItem.queryContent.value;
});
//
const cate1ChangeHandler = (value: string) => {
const { operatorCondition, queryContent } = form.value;
operatorCondition.value = '';
operatorCondition.options = [];
// Key
const currentKeysConfig = TEST_PLAN_TEST_CASE.find((item) => item.key === value);
if (currentKeysConfig) {
operatorCondition.options = currentKeysConfig.operator.options;
operatorCondition.value = currentKeysConfig.operator.options[0].value;
queryContent.type = currentKeysConfig.type;
}
emits('dataUpdated', form.value, props.index);
};
//
const isDisabledList = computed(() => {
return props.formList.map((item) => item.searchKey.value) || [];
});
//
const operatorChangeHandler = (value: string) => {
form.value.queryContent.value = value === 'between' ? [] : '';
emits('dataUpdated', form.value, props.index);
};
//
const filterKeyChange = () => {
emits('dataUpdated', form.value, props.index);
};
//
const updateTimeValue = (time: string | []) => {
form.value.queryContent.value = time;
emits('dataUpdated', form.value, props.index);
};
</script>
<style scoped></style>

View File

@ -0,0 +1,231 @@
<template>
<div class="filter-panel">
<div class="mb-4 flex items-center justify-between">
<div class="condition-text">{{ t('featureTest.featureCase.setFilterCondition') }}</div>
<div>
<span class="condition-text">{{ t('featureTest.featureCase.followingCondition') }}</span>
<a-select v-model="filterConditions.unit" class="mx-4 w-[68px]" size="small">
<a-option v-for="version of conditionOptions" :key="version.id" :value="version.value">{{
version.name
}}</a-option>
</a-select>
<span class="condition-text">{{ t('featureTest.featureCase.condition') }}</span>
</div>
</div>
<div v-for="(formItem, index) in formModels" :key="index" class="mb-[8px]">
<a-scrollbar class="overflow-y-auto" :style="{ 'max-height': props.maxHeight || '300px' }">
<QueryFromItem
:form-item="formItem"
:form-list="formModels"
:select-group-list="selectGroupList"
:index="index"
@data-updated="handleDataUpdated"
>
<div
v-show="formModels.length > 1"
:class="[
'flex',
'h-[32px]',
'w-[32px]',
'p-[2px]',
'cursor-pointer',
'items-center',
'justify-center',
'text-[var(--color-text-4)]',
'hover:text-[rgb(var(--primary-5))]',
'hover:bg-[rgb(var(--primary-9))]',
'rounded',
'ml-[8px]',
]"
@click="removeField(index)"
>
<icon-minus-circle />
</div>
</QueryFromItem>
</a-scrollbar>
</div>
<div class="flex w-full items-center justify-between">
<a-button class="px-0" type="text" :disabled="isDisabledAdd" @click="addField">
<template #icon>
<icon-plus class="text-[14px]" />
</template>
{{ t('searchPanel.addCondition') }}
</a-button>
<div>
<a-button type="secondary" @click="resetField">{{ t('searchPanel.reset') }}</a-button>
<a-button class="ml-3" type="primary">{{ t('searchPanel.filter') }}</a-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Message } from '@arco-design/web-vue';
import { computed, ref } from 'vue';
import QueryFromItem from './query-form-item.vue';
import { useI18n } from '@/hooks/useI18n';
import type { ConditionOptions, QueryTemplate } from './type';
const { t } = useI18n();
const props = defineProps<{
maxHeight?: string; // 300px
}>();
const filterConditions = ref({
unit: '',
});
const conditionOptions = ref<ConditionOptions[]>([
{
id: '1001',
name: t('condition.all'),
value: '',
},
{
id: '1002',
name: t('condition.oneOf'),
value: 'oneOf',
},
]);
//
const selectGroupList = ref([
{
label: '系统字段',
options: [
{
value: 'name',
label: '名称',
},
{
value: 'updateTime',
label: '更新时间',
},
],
},
{
label: '模版字段',
options: [
{
value: 'tags',
label: '标签',
},
{
value: 'module',
label: '模块',
},
],
},
]);
//
const deaultTemplate: QueryTemplate = {
//
searchKey: {
label: '',
type: 'a-select',
value: '',
field: 'nameKey',
props: {
placeholder: '请选择字段',
},
options: [
{
label: '系统字段',
options: [
{
value: 'name',
label: '名称',
},
{
value: 'updateTime',
label: '更新时间',
},
],
},
{
label: '模版字段',
options: [
{
value: 'tags',
label: '标签',
},
{
value: 'module',
label: '模块',
},
],
},
],
},
//
operatorCondition: {
label: '',
type: 'a-select',
value: '',
field: 'operator',
props: {
placeholder: '请选择条件',
},
options: [],
},
//
queryContent: {
label: '',
type: 'a-input',
value: '',
field: 'condition',
},
};
const formModels = ref<QueryTemplate[]>([{ ...deaultTemplate }]);
//
const removeField = (index: number) => {
formModels.value.splice(index, 1);
};
//
const addField = () => {
const ishasCondition = formModels.value.some((item) => !item.searchKey.value);
if (ishasCondition) {
Message.warning(t('searchPanel.selectTip'));
return;
}
formModels.value.push(deaultTemplate);
};
//
const handleDataUpdated = (newFromData: any, index: number) => {
formModels.value.splice(index, 1, newFromData);
};
//
const isDisabledAdd = computed(() => {
const isSelectValueKeys = formModels.value.map((item) => item.searchKey.value).filter((item) => item);
const allOptions = selectGroupList.value.flatMap((group) => group.options).map((item) => item.value);
const isAllExistValue = allOptions.every((item) => isSelectValueKeys.indexOf(item) > -1);
return isAllExistValue;
});
//
const resetField = () => {
formModels.value = formModels.value.map((item) => ({
...item,
queryContent: {
...item.queryContent,
value: '',
},
}));
};
</script>
<style scoped lang="less">
.filter-panel {
background: var(--color-text-n9);
@apply mt-1 rounded-md p-3;
.condition-text {
color: var(--color-text-2);
}
}
</style>

View File

@ -0,0 +1,31 @@
import { SelectOptionData } from '@arco-design/web-vue';
export interface ConditionOptions {
id: string;
name: string;
value: string;
}
// 选项组下拉options
export interface Option {
value: string;
label: string;
}
export interface QueryField {
label: string;
type: 'a-select' | 'a-input' | 'a-input-number' | 'time-select' | 'a-tree-select';
value: string;
field: string;
props?: {
placeholder: string;
[key: string]: string | number | boolean;
};
options?: SelectOptionData[];
}
export interface QueryTemplate {
searchKey: QueryField;
operatorCondition: QueryField;
queryContent: QueryField;
}

View File

@ -8,6 +8,7 @@ export enum BugManagementRouteEnum {
export enum FeatureTestRouteEnum { export enum FeatureTestRouteEnum {
FEATURE_TEST = 'featureTest', FEATURE_TEST = 'featureTest',
FEATURE_TEST_CASE = 'featureTestCase',
} }
export enum PerformanceTestRouteEnum { export enum PerformanceTestRouteEnum {

View File

@ -28,6 +28,7 @@ export default {
'menu.performanceTest': 'Performance Test', 'menu.performanceTest': 'Performance Test',
'menu.projectManagement': 'Project', 'menu.projectManagement': 'Project',
'menu.projectManagement.fileManagement': 'File Management', 'menu.projectManagement.fileManagement': 'File Management',
'menu.featureTest.featureCase': 'Feature Case',
'menu.projectManagement.projectPermission': 'Project Permission', 'menu.projectManagement.projectPermission': 'Project Permission',
'menu.projectManagement.log': 'Log', 'menu.projectManagement.log': 'Log',
'menu.settings': 'Settings', 'menu.settings': 'Settings',

View File

@ -28,6 +28,7 @@ export default {
'menu.projectManagement': '项目管理', 'menu.projectManagement': '项目管理',
'menu.projectManagement.log': '日志', 'menu.projectManagement.log': '日志',
'menu.projectManagement.fileManagement': '文件管理', 'menu.projectManagement.fileManagement': '文件管理',
'menu.featureTest.featureCase': '功能用例',
'menu.projectManagement.projectPermission': '项目与权限', 'menu.projectManagement.projectPermission': '项目与权限',
'menu.settings': '系统设置', 'menu.settings': '系统设置',
'menu.settings.system': '系统', 'menu.settings.system': '系统',

View File

@ -40,6 +40,7 @@ export interface ProjectBasicInfoModel {
adminList: AdminList[]; // 管理员 adminList: AdminList[]; // 管理员
projectCreateUserIsAdmin: boolean; // 创建人是否是管理员 projectCreateUserIsAdmin: boolean; // 创建人是否是管理员
moduleIds: string[]; moduleIds: string[];
resourcePoolList: { name: string; id: string }[]; // 资源池列表
} }
export interface UpdateProject { export interface UpdateProject {

View File

@ -6,7 +6,7 @@ import type { AppRouteRecordRaw } from '../types';
const FeatureTest: AppRouteRecordRaw = { const FeatureTest: AppRouteRecordRaw = {
path: '/feature-test', path: '/feature-test',
name: FeatureTestRouteEnum.FEATURE_TEST, name: FeatureTestRouteEnum.FEATURE_TEST,
redirect: '/feature-test/index', redirect: '/feature-test/featureCase',
component: DEFAULT_LAYOUT, component: DEFAULT_LAYOUT,
meta: { meta: {
locale: 'menu.featureTest', locale: 'menu.featureTest',
@ -15,12 +15,15 @@ const FeatureTest: AppRouteRecordRaw = {
hideChildrenInMenu: true, hideChildrenInMenu: true,
}, },
children: [ children: [
// 功能用例
{ {
path: 'index', path: 'featureCase',
name: 'featureTestIndex', name: FeatureTestRouteEnum.FEATURE_TEST_CASE,
component: () => import('@/views/feature-test/index.vue'), component: () => import('@/views/feature-test/featureCase/index.vue'),
meta: { meta: {
locale: 'menu.featureTest.featureCase',
roles: ['*'], roles: ['*'],
isTopMenu: true,
}, },
}, },
], ],

View File

@ -1,6 +1,5 @@
import JSEncrypt from 'jsencrypt'; import JSEncrypt from 'jsencrypt';
import { isObject } from './is'; import { isObject } from './is';
import dayjs from 'dayjs';
type TargetContext = '_self' | '_parent' | '_blank' | '_top'; type TargetContext = '_self' | '_parent' | '_blank' | '_top';
@ -275,13 +274,6 @@ export const downloadUrlFile = (url: string, fileName: string) => {
link.style.display = 'none'; link.style.display = 'none';
link.click(); link.click();
}; };
/**
*
* @param time
*/
export const getTime = (time: string): string => {
return dayjs(time).format('YYYY-MM-DD HH:mm:ss');
};
/** /**
* URL * URL

View File

@ -0,0 +1,125 @@
<template>
<a-popconfirm
v-model:popup-visible="isVisible"
class="ms-pop-confirm--hidden-icon"
position="bottom"
:ok-loading="loading"
:on-before-ok="beforeConfirm"
:popup-container="props.popupContainer || 'body'"
@popup-visible-change="visibleChange"
>
<template #content>
<div class="mb-[8px] font-medium">
{{ props.title || '' }}
</div>
<a-form ref="formRef" :model="form" layout="vertical">
<a-form-item
class="hidden-item"
field="name"
:rules="[{ required: true, message: t('featureTest.featureCase.nameNotNullTip') }]"
>
<a-input
v-model:model-value="form.name"
:max-length="50"
:placeholder="props.placeholder || ''"
class="w-[245px]"
@press-enter="beforeConfirm(undefined)"
/>
</a-form-item>
</a-form>
</template>
<slot></slot>
</a-popconfirm>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useI18n } from '@/hooks/useI18n';
import type { FormInstance } from '@arco-design/web-vue';
import { Message } from '@arco-design/web-vue';
const { t } = useI18n();
export type OperationType = 'add' | 'rename';
const isVisible = ref<boolean>(false);
const loading = ref<boolean>(false);
const props = defineProps<{
operationType: OperationType;
title: string;
nodeName?: string;
visible?: boolean;
popupContainer?: string;
placeholder?: string;
}>();
const emits = defineEmits<{
(e: 'update:visible', visible: boolean): void;
(e: 'close'): void;
}>();
const form = ref({
name: props.title || '',
});
const formRef = ref<FormInstance>();
const visibleChange = () => {
form.value.name = '';
formRef.value?.resetFields();
};
const beforeConfirm = (done?: (closed: boolean) => void) => {
formRef.value?.validate(async (errors) => {
if (!errors) {
try {
loading.value = true;
Message.success(
props.operationType === 'add'
? t('featureTest.featureCase.addSubModuleSuccess')
: t('featureTest.featureCase.renameSuccess')
);
if (done) {
done(true);
} else {
isVisible.value = false;
}
} catch (error) {
console.log(error);
} finally {
loading.value = false;
}
} else if (done) {
done(false);
}
});
};
watch(
() => props.nodeName,
(val) => {
form.value.name = val || '';
}
);
watch(
() => props.visible,
(val) => {
isVisible.value = val;
}
);
watch(
() => isVisible.value,
(val) => {
if (!val) {
emits('close');
}
emits('update:visible', val);
}
);
</script>
<style scoped></style>

View File

@ -0,0 +1,77 @@
<template>
<div class="page-header h-[34px]">
<div class="text-[var(--color-text-1)]"
>{{ t('featureTest.featureCase.allCase') }}
<span class="text-[var(--color-text-4)]"> ({{ allCaseCount }}</span></div
>
<div>
<a-select class="w-[240px]" :placeholder="t('featureTest.featureCase.versionPlaceholder')">
<a-option v-for="version of versionOptions" :key="version.id" :value="version.id">{{ version.name }}</a-option>
</a-select>
<a-input-search
v-model:model-value="keyword"
:placeholder="t('featureTest.featureCase.versionPlaceholder')"
allow-clear
class="mx-[8px] w-[240px]"
></a-input-search>
<MsTag
:type="isExpandFilter ? 'primary' : 'default'"
:theme="isExpandFilter ? 'lightOutLine' : 'outline'"
size="large"
class="-mt-[3px] cursor-pointer"
>
<span :class="!isExpandFilter ? 'text-[var(--color-text-4)]' : ''" @click="isExpandFilterHandler"
><icon-filter class="mr-[4px]" :style="{ 'font-size': '16px' }" />{{
t('featureTest.featureCase.filter')
}}</span
>
</MsTag>
<a-radio-group v-model:model-value="showType" type="button" class="file-show-type ml-[4px]">
<a-radio value="list" class="show-type-icon p-[2px]"><MsIcon type="icon-icon_view-list_outlined" /></a-radio>
<a-radio value="xmind" class="show-type-icon p-[2px]"><icon-mind-mapping /></a-radio>
</a-radio-group>
</div>
</div>
<FilterPanel v-show="isExpandFilter"></FilterPanel>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import MsTag from '@/components/pure/ms-tag/ms-tag.vue';
import { useI18n } from '@/hooks/useI18n';
import FilterPanel from '@/components/business/ms-filter-panel/searchForm.vue';
const { t } = useI18n();
const versionOptions = ref([
{
id: '1001',
name: 'v_1.0',
},
]);
const keyword = ref<string>();
const showType = ref<string>('list');
const allCaseCount = ref<number>(100);
const isExpandFilter = ref<boolean>(false);
// ||
const isExpandFilterHandler = () => {
isExpandFilter.value = !isExpandFilter.value;
};
</script>
<style scoped lang="less">
.page-header {
@apply flex items-center justify-between;
}
.filter-panel {
background: var(--color-text-n9);
@apply mt-1 rounded-md p-3;
.condition-text {
color: var(--color-text-2);
}
}
</style>

View File

@ -0,0 +1,200 @@
<template>
<MsTree
v-model:focus-node-key="focusNodeKey"
:selected-keys="props.selectedKeys"
:data="caseTree"
:keyword="groupKeyword"
:node-more-actions="caseMoreActions"
:expand-all="props.isExpandAll"
:empty-text="t('featureTest.featureCase.caseEmptyContent')"
draggable
block-node
@select="caseNodeSelect"
@more-action-select="handleCaseMoreSelect"
@more-actions-close="moreActionsClose"
>
<template #title="nodeData">
<span class="text-[var(--color-text-1)]">{{ nodeData.title }}</span>
<span class="ml-[4px] text-[var(--color-text-4)]">({{ nodeData.count }})</span>
</template>
<template #extra="nodeData">
<ActionPopConfirm
operation-type="add"
:all-names="[]"
:title="t('featureTest.featureCase.addSubModule')"
@close="resetFocusNodeKey"
>
<MsButton type="icon" size="mini" class="ms-tree-node-extra__btn !mr-0" @click="setFocusKey(nodeData)">
<MsIcon type="icon-icon_add_outlined" size="14" class="text-[var(--color-text-4)]" />
</MsButton>
</ActionPopConfirm>
<ActionPopConfirm
operation-type="rename"
:title="t('featureTest.featureCase.rename')"
:node-name="renameCaseName"
:all-names="[]"
@close="resetFocusNodeKey"
>
<span :id="`renameSpan${nodeData.key}`" class="relative"></span>
</ActionPopConfirm>
</template>
</MsTree>
<div class="recycle w-[88%]">
<a-divider class="mb-[16px]" />
<div class="recycle-bin pt-2">
<MsIcon type="icon-icon_delete-trash_outlined" size="16" class="mx-[10px] text-[var(--color-text-4)]" />
<div class="text-[var(--color-text-1)]">{{ t('featureTest.featureCase.recycle') }}</div>
<div class="recycle-count">({{ recycleCount }})</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import MsTree from '@/components/business/ms-tree/index.vue';
import MsButton from '@/components/pure/ms-button/index.vue';
import { useI18n } from '@/hooks/useI18n';
import type { ActionsItem } from '@/components/pure/ms-table-more-action/types';
import type { MsTreeNodeData } from '@/components/business/ms-tree/types';
import ActionPopConfirm from './actionPopConfirm.vue';
const { t } = useI18n();
const focusNodeKey = ref<string | number>('');
const props = defineProps<{
selectedKeys?: Array<string | number>; // key
isExpandAll: boolean; //
}>();
const emits = defineEmits(['update:selectedKeys', 'caseNodeSelect']);
const groupKeyword = ref<string>('');
const caseTree = ref([
{
title: 'Trunk',
key: 'node1',
count: 18,
children: [
{
title: 'Leaf',
key: 'node2',
count: 28,
},
],
},
{
title: 'Trunk',
key: 'node3',
count: 180,
children: [
{
title: 'Leaf',
key: 'node4',
count: 138,
},
{
title: 'Leaf',
key: 'node5',
count: 108,
},
],
},
{
title: 'Trunk',
key: 'node6',
children: [],
count: 0,
},
]);
const caseMoreActions: ActionsItem[] = [
{
label: 'featureTest.featureCase.rename',
eventTag: 'rename',
},
{
label: 'featureTest.featureCase.delete',
eventTag: 'delete',
danger: true,
},
];
const renameCaseName = ref('');
const selectedNodeKeys = ref(props.selectedKeys || []);
const renamePopVisible = ref(false);
//
const caseNodeSelect = (selectedKeys: (string | number)[]) => {
emits('caseNodeSelect', selectedKeys);
};
const deleteHandler = (node: MsTreeNodeData) => {};
function resetFocusNodeKey() {
focusNodeKey.value = '';
renamePopVisible.value = false;
renameCaseName.value = '';
}
//
const handleCaseMoreSelect = (item: ActionsItem, node: MsTreeNodeData) => {
switch (item.eventTag) {
case 'delete':
deleteHandler(node);
resetFocusNodeKey();
break;
case 'rename':
renameCaseName.value = node.title || '';
renamePopVisible.value = true;
document.querySelector(`#renameSpan${node.key}`)?.dispatchEvent(new Event('click'));
break;
default:
break;
}
};
const moreActionsClose = () => {
if (!renamePopVisible.value) {
resetFocusNodeKey();
}
};
const setFocusKey = (node: MsTreeNodeData) => {
focusNodeKey.value = node.key || '';
};
const recycleCount = ref<number>(100);
watch(
() => props.selectedKeys,
(val) => {
selectedNodeKeys.value = val || [];
}
);
watch(
() => selectedNodeKeys.value,
(val) => {
emits('update:selectedKeys', val);
}
);
</script>
<style scoped lang="less">
.recycle {
@apply absolute bottom-0 bg-white pb-4;
:deep(.arco-divider-horizontal) {
margin: 8px 0;
}
.recycle-bin {
@apply bottom-0 flex items-center bg-white;
.recycle-count {
margin-left: 4px;
color: var(--color-text-4);
}
}
}
</style>

View File

@ -0,0 +1,154 @@
<template>
<div class="mb-[16px]">
<a-button type="primary" class="mr-[12px]"> {{ t('featureTest.featureCase.creatingCase') }} </a-button>
<a-button type="outline"> {{ t('featureTest.featureCase.importCase') }} </a-button>
</div>
<div class="pageWrap">
<MsSplitBox>
<template #left>
<div class="p-[24px]">
<div class="feature-case">
<div class="case h-[38px]">
<div class="flex items-center" :class="getActiveClass('public')" @click="selectActive('public')">
<MsIcon type="icon-icon_folder_outlined-1" class="folder-icon" />
<div class="folder-name mx-[4px]">{{ t('featureTest.featureCase.publicCase') }}</div>
<div class="folder-count">({{ publicCaseCount }})</div></div
>
<div class="back"><icon-arrow-right /></div>
</div>
<a-divider class="my-[8px]" />
<a-input-search class="mb-4" :placeholder="t('featureTest.featureCase.searchTip')" />
<div class="case h-[38px]">
<div class="flex items-center" :class="getActiveClass('all')" @click="selectActive('all')">
<MsIcon type="icon-icon_folder_filled1" class="folder-icon" />
<div class="folder-name mx-[4px]">{{ t('featureTest.featureCase.allCase') }}</div>
<div class="folder-count">(100)</div></div
>
<div class="ml-auto flex items-center">
<a-tooltip
:content="
isExpandAll ? t('project.fileManagement.collapseAll') : t('project.fileManagement.expandAll')
"
>
<MsButton type="icon" status="secondary" class="!mr-0 p-[4px]" @click="expandHandler">
<MsIcon :type="isExpandAll ? 'icon-icon_folder_collapse1' : 'icon-icon_folder_expansion1'" />
</MsButton>
</a-tooltip>
<ActionPopConfirm
operation-type="add"
:title="t('featureTest.featureCase.addSubModule')"
:all-names="[]"
>
<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>
</ActionPopConfirm>
</div>
</div>
<a-divider class="my-[8px]" />
<FeatureCaseTree
v-model:selected-keys="selectedKeys"
:is-expand-all="isExpandAll"
@case-node-select="caseNodeSelect"
></FeatureCaseTree>
</div>
</div>
</template>
<template #right>
<div class="p-[24px]">
<CaseTable></CaseTable>
</div>
</template>
</MsSplitBox>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import MsSplitBox from '@/components/pure/ms-split-box/index.vue';
import MsIcon from '@/components/pure/ms-icon-font/index.vue';
import MsButton from '@/components/pure/ms-button/index.vue';
import FeatureCaseTree from './components/featureCaseTree.vue';
import ActionPopConfirm from './components/actionPopConfirm.vue';
import CaseTable from './components/caseTable.vue';
import { useI18n } from '@/hooks/useI18n';
const { t } = useI18n();
const isExpandAll = ref(false);
const activeCase = ref<string | number>('public'); //
const publicCaseCount = ref<number>(100); //
//
const selectActive = (type: string) => {
activeCase.value = type;
};
//
const getActiveClass = (type: string) => {
return activeCase.value === type ? 'folder-text case-active' : 'folder-text';
};
const expandHandler = () => {
isExpandAll.value = !isExpandAll.value;
};
//
const selectedKeys = computed({
get: () => [activeCase.value],
set: (val) => val,
});
//
function caseNodeSelect(keys: (string | number)[]) {
[activeCase.value] = keys;
}
</script>
<style scoped lang="less">
.pageWrap {
min-width: 1000px;
height: calc(100vh - 136px);
border-radius: var(--border-radius-large);
@apply bg-white;
.case {
@apply flex cursor-pointer items-center justify-between;
.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);
}
.case-active {
.folder-icon,
.folder-name,
.folder-count {
color: rgb(var(--primary-5));
}
}
.back {
margin-right: 8px;
width: 20px;
height: 20px;
border: 1px solid #ffffff;
background: linear-gradient(90deg, rgb(var(--primary-9)) 3.36%, #ffffff 100%);
box-shadow: 0 0 7px rgb(15 0 78 / 9%);
.arco-icon {
color: rgb(var(--primary-5));
}
@apply flex cursor-pointer items-center rounded-full;
}
}
}
</style>

View File

@ -0,0 +1,21 @@
export default {
'featureTest.featureCase.creatingCase': 'Create Case',
'featureTest.featureCase.importCase': 'Import Case',
'featureTest.featureCase.publicCase': 'Public of Cases',
'featureTest.featureCase.allCase': 'All of Cases',
'featureTest.featureCase.searchTip': 'Please enter a group name',
'featureTest.featureCase.caseEmptyContent': 'No use case data yet, please click the button above to create or import',
'featureTest.featureCase.addSubModule': 'Add submodules',
'featureTest.featureCase.rename': 'rename',
'featureTest.featureCase.recycle': 'Recycle',
'featureTest.featureCase.versionPlaceholder': 'The default is the latest version',
'featureTest.featureCase.searchByNameAndId': 'Search by ID or name',
'featureTest.featureCase.filter': 'filter',
'featureTest.featureCase.setFilterCondition': 'Set filters',
'featureTest.featureCase.followingCondition': 'Conform to the following',
'featureTest.featureCase.condition': 'Condition',
'featureTest.featureCase.delete': 'delete',
'featureTest.featureCase.addSubModuleSuccess': 'Add submodule successfully',
'featureTest.featureCase.renameSuccess': 'Rename successful',
'featureTest.featureCase.nameNotNullTip': 'The name can not be null',
};

View File

@ -0,0 +1,21 @@
export default {
'featureTest.featureCase.creatingCase': '创建用例',
'featureTest.featureCase.importCase': '导入用例',
'featureTest.featureCase.publicCase': '公共用例库',
'featureTest.featureCase.allCase': '全部用例',
'featureTest.featureCase.searchTip': '请输入分组名称',
'featureTest.featureCase.caseEmptyContent': '暂无用例数据,请点击上方按钮创建或导入',
'featureTest.featureCase.addSubModule': '添加子模块',
'featureTest.featureCase.rename': '重命名',
'featureTest.featureCase.recycle': '回收站',
'featureTest.featureCase.versionPlaceholder': '默认为最新版本',
'featureTest.featureCase.searchByNameAndId': '通过 ID 或名称搜索',
'featureTest.featureCase.filter': '筛选',
'featureTest.featureCase.setFilterCondition': '设置筛选条件',
'featureTest.featureCase.followingCondition': '符合以下',
'featureTest.featureCase.condition': '条件',
'featureTest.featureCase.delete': '删除',
'featureTest.featureCase.addSubModuleSuccess': '添加子模块成功',
'featureTest.featureCase.renameSuccess': '重命名成功',
'featureTest.featureCase.nameNotNullTip': '名称不能为空',
};

View File

@ -18,10 +18,7 @@
<span v-if="!projectDetail?.deleted && projectDetail?.enable" class="button enable-button mr-1">{{ <span v-if="!projectDetail?.deleted && projectDetail?.enable" class="button enable-button mr-1">{{
t('project.basicInfo.enable') t('project.basicInfo.enable')
}}</span> }}</span>
<span v-if="!projectDetail?.deleted && !projectDetail?.enable" class="button disabled-button mr-1">{{ <span v-else class="button delete-button">{{ t('project.basicInfo.deleted') }}</span>
t('project.basicInfo.disabled')
}}</span>
<span v-if="projectDetail?.deleted" class="button delete-button">{{ t('project.basicInfo.deleted') }}</span>
</div> </div>
<div class="one-line-text text-xs text-[--color-text-4]">{{ projectDetail?.description }}</div> <div class="one-line-text text-xs text-[--color-text-4]">{{ projectDetail?.description }}</div>
</div> </div>
@ -38,11 +35,11 @@
</div> </div>
<div class="label-item"> <div class="label-item">
<span class="label">{{ t('project.basicInfo.resourcePool') }}</span> <span class="label">{{ t('project.basicInfo.resourcePool') }}</span>
<MsTag>资源池</MsTag> <MsTag v-for="pool of projectDetail?.resourcePoolList" :key="pool.id">{{ pool.name }}</MsTag>
</div> </div>
<div class="label-item"> <div class="label-item">
<span class="label">{{ t('project.basicInfo.createTime') }}</span> <span class="label">{{ t('project.basicInfo.createTime') }}</span>
<span>{{ getTime(projectDetail?.createTime as string) }}</span> <span>{{ dayjs(projectDetail?.createTime).format('YYYY-MM-DD HH:mm:ss') }}</span>
</div> </div>
</div> </div>
<UpdateProjectModal ref="projectDetailRef" v-model:visible="isVisible" @success="getProjectDetail()" /> <UpdateProjectModal ref="projectDetailRef" v-model:visible="isVisible" @success="getProjectDetail()" />
@ -56,7 +53,7 @@
import { useAppStore } from '@/store'; import { useAppStore } from '@/store';
import { getProjectInfo } from '@/api/modules/project-management/basicInfo'; import { getProjectInfo } from '@/api/modules/project-management/basicInfo';
import type { ProjectBasicInfoModel } from '@/models/projectManagement/basicInfo'; import type { ProjectBasicInfoModel } from '@/models/projectManagement/basicInfo';
import { getTime } from '@/utils'; import dayjs from 'dayjs';
const { t } = useI18n(); const { t } = useI18n();
const appStore = useAppStore(); const appStore = useAppStore();

View File

@ -119,12 +119,18 @@
</a-table-column> </a-table-column>
</template> </template>
<template #expand-icon="{ record, expanded }"> <template #expand-icon="{ record, expanded }">
<span v-if="(record.pluginForms || []).length && !expanded" class="collapsebtn" <span
><icon-plus :style="{ 'font-size': '12px' }" v-if="(record.pluginForms || []).length && !expanded"
/></span> class="collapsebtn flex items-center justify-center"
<span v-else-if="(record.pluginForms || []).length && expanded" class="expand" >
><icon-minus class="text-[rgb(var(--primary-6))]" :style="{ 'font-size': '12px' }" <icon-right class="text-[var(--color-text-4)]" :style="{ 'font-size': '12px' }" />
/></span> </span>
<span
v-else-if="(record.pluginForms || []).length && expanded"
class="expand flex items-center justify-center"
>
<icon-down class="text-[rgb(var(--primary-6))]" :style="{ 'font-size': '12px' }" />
</span>
</template> </template>
</a-table> </a-table>
</div> </div>
@ -403,23 +409,27 @@
padding: 0 !important; padding: 0 !important;
} }
:deep(.collapsebtn) { :deep(.collapsebtn) {
padding: 0 1px; width: 16px;
border: 1px solid var(--color-text-4); height: 16px;
border-radius: 3px; border-radius: 50%;
background: var(--color-text-n8) !important;
@apply bg-white; @apply bg-white;
} }
:deep(.expand) { :deep(.expand) {
padding: 0 1px; width: 16px;
border: 1px solid rgb(var(--primary-5)); height: 16px;
border-radius: 3px; border-radius: 50%;
@apply bg-white; background: rgb(var(--primary-1));
} }
:deep(.arco-table-expand-btn) { :deep(.arco-table-expand-btn) {
width: 16px; width: 16px;
height: 16px; height: 16px;
border-width: 2px; border: none;
border-radius: 3px; border-radius: 50%;
@apply bg-white; background: var(--color-text-n8) !important;
}
:deep(.arco-table .arco-table-expand-btn:hover) {
border-color: transparent;
} }
.ms-footerNum { .ms-footerNum {
width: 100%; width: 100%;