feat(个人中心): 个人中心 fake-page&切换组织

This commit is contained in:
baiqi 2023-12-04 17:40:42 +08:00 committed by 刘瑞斌
parent 3c094677ee
commit aa81334f3f
98 changed files with 1953 additions and 191 deletions

View File

@ -22,6 +22,11 @@ export default mergeConfig(
changeOrigin: true, changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/front\/file/, ''), rewrite: (path: string) => path.replace(/^\/front\/file/, ''),
}, },
'/plugin/image': {
target: 'http://172.16.200.18:8081/',
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/front\/plugin\/image/, ''),
},
'/base-display': { '/base-display': {
target: 'http://172.16.200.18:8081/', target: 'http://172.16.200.18:8081/',
changeOrigin: true, changeOrigin: true,

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -1,10 +1,18 @@
// 系统全局类的接口 // 系统全局类的接口
import MSR from '@/api/http/index'; import MSR from '@/api/http/index';
import { GetVersionUrl } from '@/api/requrls/system'; import { GetVersionUrl, OrgOptionsUrl, SwitchOrgUrl } from '@/api/requrls/system';
// 获取系统版本 // 获取系统版本
export function getSystemVersion() { export function getSystemVersion() {
return MSR.get<string>({ url: GetVersionUrl }, { ignoreCancelToken: true }); return MSR.get<string>({ url: GetVersionUrl }, { ignoreCancelToken: true });
} }
export default { getSystemVersion }; // 获取当前登录用户组织机构下拉选项
export function getOrgOptions() {
return MSR.get<{ id: string; name: string }[]>({ url: OrgOptionsUrl }, { ignoreCancelToken: true });
}
// 切换用户当前组织
export function switchUserOrg(organizationId: string, userId: string) {
return MSR.post({ url: SwitchOrgUrl, data: { organizationId, userId } }, { ignoreCancelToken: true });
}

View File

@ -1,5 +1,5 @@
// 系统全局类的接口 // 系统全局类的接口
export const GetVersionUrl = '/system/version/current'; export const GetVersionUrl = '/system/version/current';
export const OrgOptionsUrl = '/system/organization/switch-option';
export default { GetVersionUrl }; export const SwitchOrgUrl = '/system/organization/switch';

View File

@ -50,7 +50,7 @@
</a-spin> </a-spin>
</div> </div>
<div class="flex w-[calc(100%-293px)] flex-col p-[16px]"> <div class="flex w-[calc(100%-293px)] flex-col p-[16px]">
<div class="flex items-center justify-between"> <div class="mb-[16px] flex items-center justify-between">
<div class="flex items-center"> <div class="flex items-center">
<div class="mr-[4px] text-[var(--color-text-1)]">{{ activeFolderName }}</div> <div class="mr-[4px] text-[var(--color-text-1)]">{{ activeFolderName }}</div>
<div class="text-[var(--color-text-4)]">({{ activeFolderName }})</div> <div class="text-[var(--color-text-4)]">({{ activeFolderName }})</div>

View File

@ -1,17 +1,22 @@
<script lang="tsx"> <script lang="tsx">
import { compile, computed, defineComponent, h, ref } from 'vue'; import { compile, computed, defineComponent, h, ref } from 'vue';
import { RouteRecordRaw, useRoute, useRouter } from 'vue-router'; import { RouteRecordRaw, useRoute, useRouter } from 'vue-router';
import { Message } from '@arco-design/web-vue';
import MsIcon from '@/components/pure/ms-icon-font/index.vue'; import MsIcon from '@/components/pure/ms-icon-font/index.vue';
import MsTag from '@/components/pure/ms-tag/ms-tag.vue';
import MsPersonInfoDrawer from '@/components/business/ms-personal-drawer/index.vue';
import { getOrgOptions, switchUserOrg } from '@/api/modules/system';
import { useI18n } from '@/hooks/useI18n'; import { useI18n } from '@/hooks/useI18n';
import useUser from '@/hooks/useUser'; import useUser from '@/hooks/useUser';
import { BOTTOM_MENU_LIST } from '@/router/constants'; import { BOTTOM_MENU_LIST } from '@/router/constants';
import { PERSONAL_ROUTE } from '@/router/routes/base';
import { useAppStore, useUserStore } from '@/store'; import { useAppStore, useUserStore } from '@/store';
import { openWindow, regexUrl } from '@/utils'; import { openWindow, regexUrl } from '@/utils';
import { listenerRouteChange } from '@/utils/route-listener'; import { listenerRouteChange } from '@/utils/route-listener';
import { WorkbenchRouteEnum } from '@/enums/routeEnum';
import useMenuTree from './use-menu-tree'; import useMenuTree from './use-menu-tree';
import type { RouteMeta } from 'vue-router'; import type { RouteMeta } from 'vue-router';
@ -61,7 +66,6 @@
}); });
} }
}; };
const personalActiveMenus = ref(['']);
/** /**
* 查找激活的菜单项 * 查找激活的菜单项
* @param target 目标菜单名 * @param target 目标菜单名
@ -88,11 +92,6 @@
if (isFind) return; // if (isFind) return; //
backtrack(el, [el?.name as string]); backtrack(el, [el?.name as string]);
}); });
personalActiveMenus.value = [''];
if (result.length === 0) {
backtrack(PERSONAL_ROUTE, [PERSONAL_ROUTE.name as string]);
personalActiveMenus.value = [...result];
}
return result; return result;
}; };
/** /**
@ -114,17 +113,53 @@
}; };
const personalMenusVisible = ref(false); const personalMenusVisible = ref(false);
const personalDrawerVisible = ref(false);
const switchOrgVisible = ref(false);
const orgKeyword = ref('');
const originOrgList = ref<{ id: string; name: string }[]>([]);
const orgList = computed(() => originOrgList.value.filter((e) => e.name.includes(orgKeyword.value)));
async function switchOrg(id: string) {
try {
Message.loading(t('personal.switchOrgLoading'));
await switchUserOrg(id, userStore.id || '');
Message.clear();
Message.success(t('personal.switchOrgSuccess'));
personalMenusVisible.value = false;
orgKeyword.value = '';
await router.replace({ name: WorkbenchRouteEnum.WORKBENCH });
userStore.isLogin();
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
}
}
onBeforeMount(async () => {
try {
const res = await getOrgOptions();
originOrgList.value = res || [];
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
}
});
const personalMenus = [ const personalMenus = [
{ {
label: t('personal.info'), label: t('personal.info'),
icon: <MsIcon type="icon-icon-contacts" class="text-[var(--color-text-4)]" />, icon: <MsIcon type="icon-icon-contacts" class="text-[var(--color-text-4)]" />,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion event: () => {
route: PERSONAL_ROUTE.children![0], personalDrawerVisible.value = true;
},
}, },
{ {
label: t('personal.switchOrg'), label: t('personal.switchOrg'),
icon: <MsIcon type="icon-icon_switch_outlined1" class="text-[var(--color-text-4)]" />, icon: <MsIcon type="icon-icon_switch_outlined1" class="text-[var(--color-text-4)]" />,
isTrigger: true,
event: () => {
switchOrgVisible.value = true;
},
}, },
{ {
divider: <a-divider class="ms-dropdown-divider" />, divider: <a-divider class="ms-dropdown-divider" />,
@ -152,19 +187,65 @@
if (e.divider) { if (e.divider) {
return e.divider; return e.divider;
} }
if (e.isTrigger) {
return (
<a-dropdown
trigger="click"
position="right"
v-slots={{
content: () => (
<>
<a-input-search
v-model:model-value={orgKeyword.value}
placeholder={t('personal.searchOrgPlaceholder')}
/>
<a-divider class="ms-dropdown-divider" />
{orgList.value.map((item) => (
<a-doption
key={item.id}
value={item.id}
class={item.id === appStore.currentOrgId ? 'active-org' : ''}
>
{item.name}
{item.id === appStore.currentOrgId ? (
<MsTag
type="primary"
theme="light"
size="small"
class="ml-[4px] !bg-[rgb(var(--primary-9))] px-[4px]"
>
{t('personal.currentOrg')}
</MsTag>
) : null}
</a-doption>
))}
</>
),
}}
onSelect={(orgId: string) => {
switchOrg(orgId);
}}
>
<div
class="arco-trigger-menu-item"
onClick={() => {
if (typeof e.event === 'function') {
e.event();
}
}}
>
{e.icon}
{e.label}
</div>
</a-dropdown>
);
}
return ( return (
<div <div
class={[ class="arco-trigger-menu-item"
'arco-trigger-menu-item',
personalActiveMenus.value.includes(e.route?.name as string)
? 'arco-trigger-menu-selected'
: '',
]}
onClick={() => { onClick={() => {
if (typeof e.event === 'function') { if (typeof e.event === 'function') {
e.event(); e.event();
} else if (e.route) {
goto(e.route);
} }
personalMenusVisible.value = false; personalMenusVisible.value = false;
}} }}
@ -188,6 +269,16 @@
</a-trigger> </a-trigger>
); );
}; };
const personalInfoDrawer = () => {
return (
<MsPersonInfoDrawer
visible={personalDrawerVisible.value}
onUpdate:visible={(e) => {
personalDrawerVisible.value = e;
}}
/>
);
};
const renderSubMenu = () => { const renderSubMenu = () => {
function travel(_route: (RouteRecordRaw | null)[] | null, nodes = []) { function travel(_route: (RouteRecordRaw | null)[] | null, nodes = []) {
@ -220,28 +311,31 @@
}; };
return () => ( return () => (
<a-menu <>
mode={'vertical'} <a-menu
v-model:collapsed={collapsed.value} mode={'vertical'}
v-model:open-keys={openKeys.value} v-model:collapsed={collapsed.value}
show-collapse-button={appStore.device !== 'mobile'} v-model:open-keys={openKeys.value}
auto-open={false} show-collapse-button={appStore.device !== 'mobile'}
selected-keys={selectedKey.value} auto-open={false}
auto-open-selected={true} selected-keys={selectedKey.value}
level-indent={34} auto-open-selected={true}
style="height: 100%;width:100%;" level-indent={34}
onCollapse={setCollapse} style="height: 100%;width:100%;"
trigger-props={{ onCollapse={setCollapse}
'show-arrow': false, trigger-props={{
'popup-offset': -4, 'show-arrow': false,
}} 'popup-offset': -4,
v-slots={{ }}
'collapse-icon': () => (appStore.menuCollapse ? <icon-right /> : <icon-left />), v-slots={{
}} 'collapse-icon': () => (appStore.menuCollapse ? <icon-right /> : <icon-left />),
> }}
{renderSubMenu()} >
{personalInfoMenu()} {renderSubMenu()}
</a-menu> {personalInfoMenu()}
</a-menu>
{personalInfoDrawer()}
</>
); );
}, },
}); });
@ -340,4 +434,8 @@
} }
} }
} }
.active-org {
color: rgb(var(--primary-5));
background-color: rgb(var(--primary-1));
}
</style> </style>

View File

@ -0,0 +1,381 @@
<template>
<div class="flex h-full flex-col gap-[16px]">
<div class="flex items-center">
<div class="font-medium text-[var(--color-text-1)]">{{ t('ms.personal.apiKey') }}</div>
<a-tooltip :content="t('ms.personal.apiKeyTip')" position="right">
<icon-question-circle
class="ml-[4px] text-[var(--color-text-4)] hover:text-[rgb(var(--primary-5))]"
size="16"
/>
</a-tooltip>
</div>
<a-tooltip :content="t('ms.personal.maxTip')" position="right" :disabled="apiKeyList.length < 5">
<a-button type="outline" class="w-[60px]" :disabled="apiKeyList.length >= 5">{{ t('common.new') }}</a-button>
</a-tooltip>
<a-spin class="api-list-content" :loading="loading">
<div v-for="item of apiKeyList" :key="item.id" class="api-item">
<div class="mb-[8px] border-b border-solid border-[var(--color-text-n8)]">
<div class="px-[16px]">
<div class="api-item-label">Access Key</div>
<div class="api-item-value-strong">
{{ item.accessKey }}
<MsTag v-if="item.isExpire" type="warning" theme="light" size="small" class="mx-[4px] px-[4px]">
{{ t('ms.personal.expired') }}
</MsTag>
<MsIcon type="icon-icon_copy_outlined" class="copy-icon" @click="handleCopy(item.accessKey)" />
</div>
<div class="api-item-label">Secret Key</div>
<div class="api-item-value-strong">
{{ item.desensitization ? item.secretKey.replace(/./g, '*') : item.secretKey }}
<MsIcon
:type="item.desensitization ? 'icon-icon_preview_close_one' : 'icon-icon_visible_outlined'"
class="eye-icon"
@click="desensitization(item)"
/>
<MsIcon type="icon-icon_copy_outlined" class="copy-icon" @click="handleCopy(item.secretKey)" />
</div>
</div>
</div>
<div class="px-[16px]">
<div class="api-item-label">{{ t('ms.personal.desc') }}</div>
<a-tooltip :content="item.desc">
<div class="api-item-value one-line-text">{{ item.desc }}</div>
</a-tooltip>
<div class="api-item-label">{{ t('ms.personal.createTime') }}</div>
<div class="api-item-value">{{ dayjs(item.createTime).format('YYYY-MM-DD HH:mm:ss') }}</div>
<div class="api-item-label">{{ t('ms.personal.expireTime') }}</div>
<div class="api-item-value">
{{ dayjs(item.expireTime).format('YYYY-MM-DD HH:mm:ss') }}
<a-tooltip v-if="item.isExpire" :content="t('ms.personal.expiredTip')">
<MsIcon type="icon-icon_warning_filled" class="ml-[4px] text-[rgb(var(--warning-6))]" />
</a-tooltip>
</div>
</div>
<div class="flex items-center justify-between px-[16px]">
<MsTableMoreAction :list="actions" trigger="click" @select="handleMoreActionSelect($event, item)">
<a-button size="mini" type="outline" class="arco-btn-outline--secondary">
{{ t('common.setting') }}
</a-button>
</MsTableMoreAction>
<a-switch
v-model:model-value="item.enable"
size="small"
:before-change="() => handleBeforeEnableChange(item)"
></a-switch>
</div>
</div>
</a-spin>
</div>
<a-modal
v-model:visible="timeModalVisible"
:title="t('ms.personal.changeAvatar')"
title-align="start"
:ok-text="t('common.save')"
class="ms-usemodal"
:width="680"
unmount-on-close
@before-ok="handleTimeConfirm"
@close="handleTimeClose"
>
<a-form ref="timeFormRef" :model="timeForm" layout="vertical">
<a-form-item :label="t('ms.personal.timeSetting')">
<a-radio-group v-model:model-value="timeForm.activeTimeType" type="button">
<a-radio value="forever" class="show-type-icon">{{ t('ms.personal.forever') }}</a-radio>
<a-radio value="custom" class="show-type-icon">{{ t('ms.personal.custom') }}</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item
v-if="timeForm.activeTimeType === 'custom'"
field="time"
:label="t('ms.personal.timeSetting')"
:rules="[{ required: true, message: t('ms.personal.expiredTimeRequired') }]"
asterisk-position="end"
>
<a-date-picker
v-model:model-value="timeForm.time"
show-time
:time-picker-props="{ defaultValue: '00:00:00' }"
format="YYYY-MM-DD HH:mm:ss"
class="w-[240px]"
/>
</a-form-item>
<a-form-item field="desc" :label="t('ms.personal.accessKeyDesc')">
<a-input
v-model:model-value="timeForm.desc"
:max-length="64"
:placeholder="t('ms.personal.accessKeyDescPlaceholder')"
show-word-limit
/>
</a-form-item>
</a-form>
</a-modal>
</template>
<script setup lang="ts">
import { useClipboard } from '@vueuse/core';
import { FormInstance, Message } from '@arco-design/web-vue';
import dayjs from 'dayjs';
import MsIcon from '@/components/pure/ms-icon-font/index.vue';
import MsTableMoreAction from '@/components/pure/ms-table-more-action/index.vue';
import { ActionsItem } from '@/components/pure/ms-table-more-action/types';
import MsTag from '@/components/pure/ms-tag/ms-tag.vue';
import { useI18n } from '@/hooks/useI18n';
import useModal from '@/hooks/useModal';
const { copy } = useClipboard();
const { t } = useI18n();
const { openModal } = useModal();
interface ApiKeyItem {
id: string;
accessKey: string;
secretKey: string;
desc: string;
createTime: number;
expireTime: number;
enable: boolean;
desensitization: boolean;
isExpire: boolean;
}
const loading = ref(false);
const apiKeyList = ref<ApiKeyItem[]>([
{
id: '238dy23d2',
accessKey: 'dueiouhwded',
secretKey: 'asdasdas',
desc: '92387yd9283d2',
createTime: 1629782400000,
expireTime: 1629982400000,
enable: true,
desensitization: true,
isExpire: false,
},
{
id: 'ih02i3d23',
accessKey: 'sdshsd',
secretKey: 'poj4f',
desc: '92387yd9283d2',
createTime: 1629782400000,
expireTime: 1629982400000,
enable: false,
desensitization: true,
isExpire: true,
},
{
id: '34hy34h3',
accessKey: 'sdshsd',
secretKey: 'poj4f',
desc: '92387yd9283d2',
createTime: 1629782400000,
expireTime: 1629982400000,
enable: false,
desensitization: true,
isExpire: true,
},
{
id: 'f23',
accessKey: 'sdshsd',
secretKey: 'poj4f',
desc: '92387yd9283d2',
createTime: 1629782400000,
expireTime: 1629982400000,
enable: false,
desensitization: true,
isExpire: true,
},
{
id: 'ih02i3ed23',
accessKey: 'sdshsd',
secretKey: 'poj4f',
desc: '92387yd9283d2',
createTime: 1629782400000,
expireTime: 1629982400000,
enable: false,
desensitization: true,
isExpire: true,
},
]);
const actions: ActionsItem[] = [
{
label: t('ms.personal.validTime'),
eventTag: 'time',
},
{
isDivider: true,
},
{
label: t('common.delete'),
danger: true,
eventTag: 'delete',
},
];
async function handleCopy(val: string) {
await copy(val);
Message.success(t('ms.personal.copySuccess'));
}
function desensitization(item: ApiKeyItem) {
item.desensitization = !item.desensitization;
}
async function handleBeforeEnableChange(item: ApiKeyItem) {
if (item.enable) {
openModal({
type: 'error',
title: t('ms.personal.confirmClose'),
content: t('ms.personal.closeTip'),
okText: t('common.confirmClose'),
cancelText: t('common.cancel'),
okButtonProps: {
status: 'danger',
},
onBeforeOk: async () => {
try {
Message.success(t('ms.personal.closeSuccess'));
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
}
},
hideCancel: false,
});
return false;
}
try {
Message.success(t('ms.personal.openSuccess'));
return true;
} catch (error) {
console.log(error);
return false;
}
}
function deleteApiKey(item: ApiKeyItem) {
openModal({
type: 'error',
title: t('ms.personal.confirmDelete'),
content: t('ms.personal.deleteTip'),
okText: t('common.confirmDelete'),
cancelText: t('common.cancel'),
okButtonProps: {
status: 'danger',
},
onBeforeOk: async () => {
try {
Message.success(t('common.deleteSuccess'));
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
}
},
hideCancel: false,
});
return false;
}
const timeModalVisible = ref(false);
const defaultTimeForm = {
activeTimeType: 'forever',
time: '',
desc: '',
};
const timeForm = ref({ ...defaultTimeForm });
const timeFormRef = ref<FormInstance>();
function handleTimeConfirm(done: (closed: boolean) => void) {
timeFormRef.value?.validate(async (errors) => {
if (!errors) {
try {
Message.success(t('common.updateSuccess'));
done(true);
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
done(false);
}
} else {
done(false);
}
});
}
function handleTimeClose() {
timeFormRef.value?.resetFields();
timeForm.value = { ...defaultTimeForm };
}
function handleMoreActionSelect(item: ActionsItem, apiKey: ApiKeyItem) {
if (item.eventTag === 'time') {
timeForm.value = {
activeTimeType: 'forever',
time: apiKey.expireTime ? dayjs(apiKey.expireTime).format('YYYY-MM-DD HH:mm:ss') : '',
desc: apiKey.desc,
};
timeModalVisible.value = true;
} else if (item.eventTag === 'delete') {
deleteApiKey(apiKey);
}
}
</script>
<style lang="less" scoped>
.api-list-content {
@apply grid flex-1 overflow-auto;
.ms-scroll-bar();
gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(318px, 2fr));
padding: 16px;
border-radius: var(--border-radius-small);
background-color: var(--color-text-n9);
}
.api-item {
@apply bg-white;
padding: 16px 0;
height: 335px;
border-radius: var(--border-radius-small);
.api-item-label {
font-size: 12px;
line-height: 16px;
color: var(--color-text-4);
}
.api-item-value {
@apply flex items-center;
margin-bottom: 16px;
font-size: 14px;
color: var(--color-text-1);
}
.api-item-value-strong {
@apply flex items-center;
margin-bottom: 8px;
font-size: 14px;
font-weight: 500;
color: var(--color-text-1);
&:hover {
.copy-icon {
@apply visible;
}
}
.copy-icon,
.eye-icon {
@apply cursor-pointer;
margin-left: 4px;
color: var(--color-text-brand);
&:hover {
color: rgb(var(--primary-6));
}
}
.copy-icon {
@apply invisible;
}
}
}
</style>

View File

@ -0,0 +1,265 @@
<template>
<div class="mb-[16px] flex items-center justify-between">
<div class="font-medium text-[var(--color-text-1)]">{{ t('ms.personal.baseInfo') }}</div>
<a-button v-if="!isEdit" type="outline" size="mini" class="p-[2px_8px]" @click="isEdit = true">
{{ t('common.update') }}
</a-button>
</div>
<div class="mb-[16px] flex items-center">
<MsAvatar :avatar="userStore.avatar || 'default'" class="mb-[4px]" />
<a-button
type="outline"
class="arco-btn-outline--secondary ml-[8px] p-[2px_8px]"
size="mini"
@click="avatarModalVisible = true"
>
{{ t('ms.personal.changeAvatar') }}
</a-button>
</div>
<a-form v-if="isEdit" ref="baseInfoFormRef" :model="baseInfoForm" layout="vertical">
<a-form-item
field="name"
:label="t('ms.personal.name')"
:rules="[{ required: true, message: t('ms.personal.nameRequired') }]"
asterisk-position="end"
>
<a-input
v-model:modelValue="baseInfoForm.name"
:placeholder="t('ms.personal.namePlaceholder')"
:max-length="255"
show-word-limit
/>
</a-form-item>
<a-form-item
field="email"
:label="t('ms.personal.email')"
:rules="[{ required: true, message: t('ms.personal.emailRequired') }, { validator: checkUerEmail }]"
asterisk-position="end"
>
<a-input v-model:modelValue="baseInfoForm.email" :placeholder="t('ms.personal.emailPlaceholder')" />
<MsFormItemSub :text="t('ms.personal.emailTip')" :show-fill-icon="false" />
</a-form-item>
<a-form-item
field="phone"
:label="t('ms.personal.phone')"
:rules="[{ required: true, message: t('ms.personal.phoneRequired') }, { validator: checkUerPhone }]"
asterisk-position="end"
>
<a-input
v-model:modelValue="baseInfoForm.phone"
:placeholder="t('ms.personal.phonePlaceholder')"
:max-length="11"
/>
</a-form-item>
<a-form-item>
<a-button type="primary" class="mr-[14px]" :loading="updateLoading" @click="updateBaseInfo">
{{ t('common.update') }}
</a-button>
<a-button type="secondary" :disabled="updateLoading" @click="cancelEdit">{{ t('common.cancel') }}</a-button>
</a-form-item>
</a-form>
<MsDescription v-else :descriptions="descriptions">
<template #tag>
<MsTag> 组织 1 </MsTag>
<br />
<MsTag size="small" class="mt-[8px] !bg-[rgb(var(--primary-1))] !text-[rgb(var(--primary-5))]"> 项目 1 </MsTag>
</template>
</MsDescription>
<a-modal
v-model:visible="avatarModalVisible"
:title="t('ms.personal.changeAvatar')"
title-align="start"
:ok-text="t('common.save')"
class="ms-usemodal"
:width="680"
@before-ok="handleChangeAvatarConfirm"
>
<a-radio-group v-model:model-value="activeAvatarType" type="button">
<a-radio value="builtIn" class="show-type-icon">{{ t('ms.personal.builtIn') }}</a-radio>
<a-radio value="word" class="show-type-icon">{{ t('ms.personal.wordAvatar') }}</a-radio>
</a-radio-group>
<div v-show="activeAvatarType === 'builtIn'" class="avatar-content">
<div class="avatar" @click="changeAvatar('default')">
<MsAvatar avatar="default" class="mb-[4px]" />
<div class="text-[12px] text-[var(--color-text-1)]">{{ t('ms.personal.default') }}</div>
<MsIcon
v-if="activeAvatar === 'default'"
type="icon-icon_succeed_filled"
:style="{ color: 'rgb(var(--success-6))' }"
class="check-icon"
/>
</div>
<div v-for="(avatar, index) of avatarList" :key="avatar" class="avatar" @click="changeAvatar(index)">
<MsAvatar :avatar="avatar" class="mb-[4px]" />
<div class="text-[12px] text-[var(--color-text-1)]">{{ t('ms.personal.avatar', { index: index }) }}</div>
<MsIcon
v-if="activeAvatar === index"
type="icon-icon_succeed_filled"
:style="{ color: 'rgb(var(--success-6))' }"
class="check-icon"
/>
</div>
</div>
<div v-show="activeAvatarType === 'word'" class="mb-[8px] flex flex-wrap gap-[24px] pt-[14px]">
<div class="avatar" @click="changeAvatar('word')">
<MsAvatar avatar="word" class="mb-[4px]">
{{ userStore.name?.substring(0, 4) }}
</MsAvatar>
<div class="text-[12px] text-[var(--color-text-1)]">{{ t('ms.personal.wordAvatar') }}</div>
<MsIcon
v-if="activeAvatar === 'word'"
type="icon-icon_succeed_filled"
:style="{ color: 'rgb(var(--success-6))' }"
class="check-icon"
/>
</div>
</div>
</a-modal>
</template>
<script setup lang="ts">
import { Message } from '@arco-design/web-vue';
import MsAvatar from '@/components/pure/ms-avatar/index.vue';
import MsDescription, { Description } from '@/components/pure/ms-description/index.vue';
import MsIcon from '@/components/pure/ms-icon-font/index.vue';
import MsTag from '@/components/pure/ms-tag/ms-tag.vue';
import MsFormItemSub from '@/components/business/ms-form-item-sub/index.vue';
import { useI18n } from '@/hooks/useI18n';
import useUserStore from '@/store/modules/user/index';
import { validateEmail, validatePhone } from '@/utils/validate';
import type { FormInstance } from '@arco-design/web-vue';
const userStore = useUserStore();
const { t } = useI18n();
const isEdit = ref(false);
const descriptions = ref<Description[]>([
{
label: t('ms.personal.name'),
value: userStore.name || '',
},
{
label: t('ms.personal.email'),
value: userStore.email || '',
},
{
label: t('ms.personal.phone'),
value: userStore.phone || '',
},
{
label: t('ms.personal.org'),
value: [],
isTag: true,
},
]);
const baseInfoForm = ref({
name: userStore.name,
email: userStore.email,
phone: userStore.phone,
});
const baseInfoFormRef = ref<FormInstance>();
const updateLoading = ref(false);
function cancelEdit() {
isEdit.value = false;
baseInfoFormRef.value?.resetFields();
}
/**
* 校验用户邮箱
* @param value 输入的值
* @param callback 失败回调入参是提示信息
* @param index 当前输入的表单项对应 list 的下标用于校验重复输入的时候排除自身
*/
function checkUerEmail(value: string | undefined, callback: (error?: string) => void) {
if (value === '' || value === undefined) {
callback(t('system.user.createUserEmailNotNull'));
} else if (!validateEmail(value)) {
callback(t('system.user.createUserEmailErr'));
}
}
/**
* 校验用户手机号
* @param value 输入的值
* @param callback 失败回调入参是提示信息
*/
function checkUerPhone(value: string | undefined, callback: (error?: string) => void) {
if (value !== '' && value !== undefined && !validatePhone(value)) {
callback(t('system.user.createUserPhoneErr'));
}
}
function updateBaseInfo() {
baseInfoFormRef.value?.validate(async (errors) => {
if (!errors) {
try {
updateLoading.value = true;
Message.success(t('common.updateSuccess'));
isEdit.value = false;
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
} finally {
updateLoading.value = false;
}
}
});
}
const avatarModalVisible = ref(false);
async function handleChangeAvatarConfirm(done: (closed: boolean) => void) {
try {
// if (replaceVersion.value !== '') {
// await useLatestVersion(replaceVersion.value);
// }
// await toggleVersionStatus(activeRecord.value.id);
Message.success(t('common.updateSuccess'));
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
done(false);
} finally {
done(true);
}
}
const activeAvatarType = ref<'builtIn' | 'word'>('builtIn');
const activeAvatar = ref<string | number>('default');
const avatarList = ref<string[]>([]);
let i = 1;
while (i <= 46) {
avatarList.value.push(`/images/avatar/avatar-${i}.jpg`);
i++;
}
function changeAvatar(avatar: string | number) {
activeAvatar.value = avatar;
}
</script>
<style lang="less" scoped>
.avatar-content {
@apply grid items-center justify-center;
grid-template-columns: repeat(auto-fill, minmax(42px, 1fr));
margin-bottom: 8px;
gap: 24px;
padding-top: 14px;
}
.avatar {
@apply relative flex cursor-pointer flex-col items-center justify-center;
margin-bottom: 16px;
}
.check-icon {
@apply absolute right-0 rounded-full bg-white;
bottom: 22px;
}
</style>

View File

@ -0,0 +1,192 @@
<template>
<div class="mb-[16px] flex items-center justify-between">
<div class="font-medium text-[var(--color-text-1)]">{{ t('ms.personal.localExecution') }}</div>
</div>
<div class="grid grid-cols-2 gap-[16px]">
<div class="config-card">
<div class="config-card-title">
<div class="config-card-title-text">{{ t('ms.personal.apiLocalExecution') }}</div>
<MsTag theme="outline" :type="tagMap[apiConfig.status].type" size="small" class="px-[4px]">
{{ tagMap[apiConfig.status].text }}
</MsTag>
</div>
<a-input
v-model:model-value="apiConfig.url"
:placeholder="t('ms.personal.apiLocalExecutionPlaceholder')"
class="mb-[16px]"
></a-input>
<div class="config-card-footer">
<a-button
type="outline"
class="px-[8px]"
size="mini"
:disabled="apiConfig.url.trim() === ''"
:loading="testApiLoading"
@click="testApi"
>
{{ t('ms.personal.test') }}
</a-button>
<div class="flex items-center">
<div class="mr-[4px] text-[12px] leading-[16px] text-[var(--color-text-4)]">
{{ t('ms.personal.priorityLocalExec') }}
</div>
<a-switch
v-model:model-value="apiConfig.isPriorityLocalExec"
size="small"
:disabled="apiConfig.status !== 1 || testApiLoading"
:before-change="(val) => handleApiPriorityBeforeChange(val)"
/>
</div>
</div>
</div>
<div class="config-card">
<div class="config-card-title">
<div class="config-card-title-text">{{ t('ms.personal.uiLocalExecution') }}</div>
<MsTag theme="outline" :type="tagMap[uiConfig.status].type" size="small" class="px-[4px]">
{{ tagMap[uiConfig.status].text }}
</MsTag>
</div>
<a-input
v-model:model-value="uiConfig.url"
:placeholder="t('ms.personal.uiLocalExecutionPlaceholder')"
class="mb-[16px]"
></a-input>
<div class="config-card-footer">
<a-button
type="outline"
class="px-[8px]"
size="mini"
:disabled="uiConfig.url.trim() === ''"
:loading="testUiLoading"
@click="testUi"
>
{{ t('ms.personal.test') }}
</a-button>
<div class="flex items-center">
<div class="mr-[4px] text-[12px] leading-[16px] text-[var(--color-text-4)]">
{{ t('ms.personal.priorityLocalExec') }}
</div>
<a-switch
v-model:model-value="uiConfig.isPriorityLocalExec"
size="small"
:disabled="uiConfig.status !== 1 || testUiLoading"
:before-change="(val) => handleUiPriorityBeforeChange(val)"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Message } from '@arco-design/web-vue';
import MsTag, { TagType } from '@/components/pure/ms-tag/ms-tag.vue';
import { useI18n } from '@/hooks/useI18n';
const { t } = useI18n();
type Status = 0 | 1 | 2;
interface TagMapItem {
type: TagType;
text: string;
}
const tagMap: Record<Status, TagMapItem> = {
0: {
type: 'default',
text: t('ms.personal.unConfig'),
},
1: {
type: 'success',
text: t('ms.personal.testPass'),
},
2: {
type: 'danger',
text: t('ms.personal.testFail'),
},
};
const testApiLoading = ref(false);
const apiConfig = ref({
url: '',
status: 1 as Status,
isPriorityLocalExec: false,
});
async function testApi() {
try {
testApiLoading.value = true;
Message.success(t('ms.personal.testPass'));
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
} finally {
testApiLoading.value = false;
}
}
async function handleApiPriorityBeforeChange(val: string | number | boolean) {
try {
Message.success(val ? t('ms.personal.apiLocalExecutionOpen') : t('ms.personal.apiLocalExecutionClose'));
return true;
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
return false;
}
}
const testUiLoading = ref(false);
const uiConfig = ref({
url: '',
status: 1 as Status,
isPriorityLocalExec: false,
});
async function testUi() {
try {
testApiLoading.value = true;
Message.success(t('ms.personal.testPass'));
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
} finally {
testApiLoading.value = false;
}
}
async function handleUiPriorityBeforeChange(val: string | number | boolean) {
try {
Message.success(val ? t('ms.personal.uiLocalExecutionOpen') : t('ms.personal.uiLocalExecutionClose'));
return true;
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
return false;
}
}
</script>
<style lang="less" scoped>
.config-card {
padding: 16px;
border: 2px solid white;
border-radius: var(--border-radius-medium);
background-color: var(--color-text-n9);
box-shadow: 0 6px 15px 0 rgb(120 56 135 / 5%);
.config-card-title {
@apply flex items-center;
gap: 8px;
margin-bottom: 16px;
.config-card-title-text {
@apply font-medium;
color: var(--color-text-1);
}
}
.config-card-footer {
@apply flex items-center justify-between;
}
}
</style>

View File

@ -0,0 +1,100 @@
<template>
<div class="mb-[16px] flex items-center justify-between">
<div class="font-medium text-[var(--color-text-1)]">{{ t('ms.personal.setPsw') }}</div>
</div>
<a-form ref="formRef" :model="form" layout="vertical" :rules="rules">
<a-form-item field="password" :label="t('ms.personal.currentPsw')" asterisk-position="end">
<a-input-password
v-model="form.password"
:placeholder="t('invite.passwordPlaceholder')"
allow-clear
autocomplete="new-password"
/>
</a-form-item>
<a-form-item field="newPsw" :label="t('ms.personal.newPsw')" asterisk-position="end">
<MsPasswordInput v-model:password="form.newPsw" />
<MsFormItemSub :text="t('ms.personal.changePswTip')" :show-fill-icon="false" />
</a-form-item>
<a-form-item field="confirmPsw" class="hidden-item">
<a-input-password
v-model="form.confirmPsw"
:placeholder="t('invite.passwordPlaceholder')"
allow-clear
autocomplete="new-password"
/>
</a-form-item>
<a-form-item>
<a-button type="primary" class="mt-[16px]" :loading="loading" @click="changePsw">
{{ t('common.save') }}
</a-button>
</a-form-item>
</a-form>
</template>
<script setup lang="ts">
import { FormInstance, Message } from '@arco-design/web-vue';
import MsPasswordInput from '@/components/pure/ms-password-input/index.vue';
import MsFormItemSub from '@/components/business/ms-form-item-sub/index.vue';
import { useI18n } from '@/hooks/useI18n';
import { validatePasswordLength, validateWordPassword } from '@/utils/validate';
const { t } = useI18n();
const form = ref({
password: '',
newPsw: '',
confirmPsw: '',
});
const formRef = ref<FormInstance>();
const loading = ref(false);
const pswValidateRes = ref(false);
const pswLengthValidateRes = ref(false);
const rules = {
password: [{ required: true, message: t('invite.passwordNotNull') }],
newPsw: [
{ required: true, message: t('invite.passwordNotNull') },
{
validator: (value: string, callback: (error?: string) => void) => {
pswValidateRes.value = validateWordPassword(value);
pswLengthValidateRes.value = validatePasswordLength(value);
if (!pswLengthValidateRes.value) {
callback(t('invite.passwordLengthRule'));
} else if (!pswValidateRes.value) {
callback(t('invite.passwordWordRule'));
}
},
},
],
confirmPsw: [
{ required: true, message: t('invite.repasswordNotNull') },
{
validator: (value: string, callback: (error?: string) => void) => {
if (value !== form.value.newPsw) {
callback(t('invite.repasswordNotSame'));
}
},
},
],
};
function changePsw() {
formRef.value?.validate(async (errors) => {
if (!errors) {
try {
loading.value = true;
Message.success(t('common.updateSuccess'));
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
} finally {
loading.value = false;
}
}
});
}
</script>
<style lang="less" scoped></style>

View File

@ -0,0 +1,231 @@
<template>
<div class="flex h-full flex-col overflow-hidden">
<div class="mb-[16px] flex items-center justify-between">
<div class="font-medium text-[var(--color-text-1)]">{{ t('ms.personal.tripartite') }}</div>
</div>
<div class="platform-card-container">
<div class="platform-card">
<div class="mb-[16px] flex items-center">
<a-image src="/plugin/image/jira?imagePath=static/jira.jpg" width="24"></a-image>
<div class="ml-[8px] mr-[4px] font-medium text-[var(--color-text-1)]">JIRA</div>
<a-tooltip :content="t('ms.personal.jiraTip')" position="right">
<icon-exclamation-circle
class="mr-[8px] text-[var(--color-text-4)] hover:text-[rgb(var(--primary-5))]"
size="16"
/>
</a-tooltip>
<MsTag theme="light" :type="tagMap[jiraConfig.status].type" size="small" class="px-[4px]">
{{ tagMap[jiraConfig.status].text }}
</MsTag>
</div>
<a-form ref="jiraFormRef" :model="jiraConfig">
<a-form-item :label="t('ms.personal.authType')">
<a-radio-group v-model:model-value="jiraConfig.authType">
<a-radio value="basic">Basic Auth</a-radio>
<a-radio value="token">Bearer Token</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item :label="t('ms.personal.platformAccount')">
<a-input
v-model:model-value="jiraConfig.platformAccount"
:placeholder="t('ms.personal.platformAccountPlaceholder', { type: 'JIRA' })"
class="w-[312px]"
allow-clear
autocomplete="new-password"
></a-input>
</a-form-item>
<a-form-item :label="t('ms.personal.platformPsw')">
<a-input-password
v-model:model-value="jiraConfig.platformPsw"
:placeholder="t('ms.personal.platformPswPlaceholder', { type: 'JIRA' })"
class="mr-[8px] w-[312px]"
allow-clear
autocomplete="new-password"
></a-input-password>
<a-button type="outline" :disabled="jiraConfig.platformAccount === '' || jiraConfig.platformPsw === ''">
{{ t('ms.personal.valid') }}
</a-button>
</a-form-item>
</a-form>
</div>
<div class="platform-card">
<div class="mb-[16px] flex items-center">
<a-image src="/plugin/image/jira?imagePath=static/jira.jpg" width="24"></a-image>
<div class="ml-[8px] mr-[4px] font-medium text-[var(--color-text-1)]">
{{ t('ms.personal.zendao') }}
</div>
<a-tooltip :content="t('ms.personal.zendaoTip')" position="right">
<icon-exclamation-circle
class="mr-[8px] text-[var(--color-text-4)] hover:text-[rgb(var(--primary-5))]"
size="16"
/>
</a-tooltip>
<MsTag theme="light" :type="tagMap[zendaoConfig.status].type" size="small" class="px-[4px]">
{{ tagMap[zendaoConfig.status].text }}
</MsTag>
</div>
<a-form ref="zendaoFormRef" :model="zendaoConfig">
<a-form-item :label="t('ms.personal.platformAccount')">
<a-input
v-model:model-value="zendaoConfig.platformAccount"
:placeholder="t('ms.personal.platformAccountPlaceholder', { type: t('ms.personal.zendao') })"
class="w-[312px]"
allow-clear
autocomplete="new-password"
></a-input>
</a-form-item>
<a-form-item :label="t('ms.personal.platformPsw')">
<a-input-password
v-model:model-value="zendaoConfig.platformPsw"
:placeholder="t('ms.personal.platformPswPlaceholder', { type: t('ms.personal.zendao') })"
class="mr-[8px] w-[312px]"
allow-clear
autocomplete="new-password"
></a-input-password>
<a-button type="outline" :disabled="zendaoConfig.platformAccount === '' || zendaoConfig.platformPsw === ''">
{{ t('ms.personal.valid') }}
</a-button>
</a-form-item>
</a-form>
</div>
<div class="platform-card">
<div class="mb-[16px] flex items-center">
<a-image src="/plugin/image/jira?imagePath=static/jira.jpg" width="24"></a-image>
<div class="ml-[8px] mr-[4px] font-medium text-[var(--color-text-1)]"> Azure DeVops </div>
<a-tooltip :content="t('ms.personal.azureTip')" position="right">
<icon-exclamation-circle
class="mr-[8px] text-[var(--color-text-4)] hover:text-[rgb(var(--primary-5))]"
size="16"
/>
</a-tooltip>
<MsTag theme="light" :type="tagMap[azureConfig.status].type" size="small" class="px-[4px]">
{{ tagMap[azureConfig.status].text }}
</MsTag>
</div>
<a-form ref="zendaoFormRef" :model="azureConfig">
<a-form-item>
<template #label>
<div class="flex text-right leading-none"> Personal Access Tokens </div>
</template>
<a-input
v-model:model-value="azureConfig.token"
:placeholder="t('ms.personal.azurePlaceholder')"
class="mr-[8px] w-[312px]"
allow-clear
autocomplete="new-password"
></a-input>
<a-button type="outline" :disabled="azureConfig.token === ''">
{{ t('ms.personal.valid') }}
</a-button>
</a-form-item>
</a-form>
</div>
<div class="platform-card">
<div class="mb-[16px] flex items-center">
<a-image src="/plugin/image/jira?imagePath=static/jira.jpg" width="24"></a-image>
<div class="ml-[8px] mr-[4px] font-medium text-[var(--color-text-1)]"> TAPD </div>
<a-popover position="right">
<template #content>
<div class="bg-[var(--color-text-n9)] p-[12px]">
<a-image src="/images/tapd-user.png" :width="385"></a-image>
</div>
</template>
<icon-exclamation-circle
class="mr-[8px] text-[var(--color-text-4)] hover:text-[rgb(var(--primary-5))]"
size="16"
/>
</a-popover>
<MsTag theme="light" :type="tagMap[tapdConfig.status].type" size="small" class="px-[4px]">
{{ tagMap[tapdConfig.status].text }}
</MsTag>
</div>
<a-form ref="zendaoFormRef" :model="tapdConfig">
<a-form-item :label="t('ms.personal.platformName')">
<a-input
v-model:model-value="tapdConfig.name"
:placeholder="t('ms.personal.platformNamePlaceholder')"
class="mr-[8px] w-[312px]"
allow-clear
autocomplete="new-password"
/>
<a-button type="outline" :disabled="tapdConfig.name === ''">
{{ t('ms.personal.valid') }}
</a-button>
</a-form-item>
</a-form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import MsTag, { TagType } from '@/components/pure/ms-tag/ms-tag.vue';
import { useI18n } from '@/hooks/useI18n';
const { t } = useI18n();
type Status = 0 | 1 | 2;
interface TagMapItem {
type: TagType;
text: string;
}
const tagMap: Record<Status, TagMapItem> = {
0: {
type: 'default',
text: t('ms.personal.unValid'),
},
1: {
type: 'success',
text: t('ms.personal.validPass'),
},
2: {
type: 'danger',
text: t('ms.personal.validFail'),
},
};
const jiraConfig = ref({
status: 0 as Status,
authType: 'basic',
platformAccount: '',
platformPsw: '',
});
const zendaoConfig = ref({
status: 0 as Status,
platformAccount: '',
platformPsw: '',
});
const azureConfig = ref({
status: 0 as Status,
token: '',
});
const tapdConfig = ref({
status: 0 as Status,
name: '',
});
</script>
<style lang="less" scoped>
.platform-card-container {
@apply flex flex-1 flex-wrap overflow-auto;
.ms-scroll-bar();
padding: 16px;
border-radius: var(--border-radius-small);
background-color: var(--color-text-n9);
gap: 16px;
}
.platform-card {
@apply w-full bg-white;
padding: 16px;
border-radius: var(--border-radius-small);
:deep(.arco-form-item-label) {
color: var(--color-text-4) !important;
}
}
</style>

View File

@ -0,0 +1,100 @@
<template>
<MsDrawer v-model:visible="innerVisible" :title="t('ms.personal')" :width="960" :footer="false" no-content-padding>
<div class="flex h-full w-full">
<div class="h-full w-[208px] bg-[var(--color-text-n9)]">
<MsMenuPanel
class="h-full !rounded-none bg-[var(--color-text-n9)] p-[16px_24px]"
:default-key="activeMenu"
:menu-list="menuList"
active-class="!bg-transparent font-medium"
@toggle-menu="(val) => (activeMenu = val)"
/>
</div>
<div class="flex-1 p-[24px]">
<baseInfo v-if="activeMenu === 'baseInfo'" />
<setPsw v-else-if="activeMenu === 'setPsw'" />
<apiKey v-else-if="activeMenu === 'apiKey'" />
<localExec v-else-if="activeMenu === 'local'" />
<tripartite v-else-if="activeMenu === 'tripartite'" />
</div>
</div>
</MsDrawer>
</template>
<script setup lang="ts">
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';
import baseInfo from './components/baseInfo.vue';
import localExec from './components/localExec.vue';
import setPsw from './components/setPsw.vue';
import tripartite from './components/tripartite.vue';
import { useI18n } from '@/hooks/useI18n';
const props = defineProps<{
visible: boolean;
}>();
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void;
}>();
const { t } = useI18n();
const innerVisible = ref(false);
watch(
() => props.visible,
(val) => {
innerVisible.value = val;
}
);
watch(
() => innerVisible.value,
(val) => {
emit('update:visible', val);
}
);
const activeMenu = ref('baseInfo');
const menuList = ref([
{
name: 'personal',
title: t('ms.personal.info'),
level: 1,
},
{
name: 'baseInfo',
title: t('ms.personal.baseInfo'),
level: 2,
},
{
name: 'setPsw',
title: t('ms.personal.setPsw'),
level: 2,
},
{
name: 'setting',
title: t('ms.personal.setting'),
level: 1,
},
{
name: 'apiKey',
title: t('ms.personal.apiKey'),
level: 2,
},
{
name: 'local',
title: t('ms.personal.localExecution'),
level: 2,
},
{
name: 'tripartite',
title: t('ms.personal.tripartite'),
level: 2,
},
]);
</script>
<style lang="less" scoped></style>

View File

@ -0,0 +1,87 @@
export default {
'ms.personal': 'Personal center',
'ms.personal.info': 'Personal information',
'ms.personal.baseInfo': 'Basic Information',
'ms.personal.setPsw': 'Password settings',
'ms.personal.setting': 'Personal settings',
'ms.personal.apiKey': 'APIKEY',
'ms.personal.tripartite': 'Tripartite account',
'ms.personal.changeAvatar': 'Change avatar',
'ms.personal.name': 'User name',
'ms.personal.namePlaceholder': 'Please enter user name',
'ms.personal.nameRequired': 'Username cannot be empty',
'ms.personal.email': 'Email',
'ms.personal.emailPlaceholder': 'Please input your email',
'ms.personal.emailRequired': 'E-mail can not be empty',
'ms.personal.emailTip':
'After modifying the email address, you need to use the new email address to log in to the system',
'ms.personal.phone': 'Phone number',
'ms.personal.phonePlaceholder': 'Please enter the phone number',
'ms.personal.phoneRequired': 'Phone number can not be blank',
'ms.personal.org': 'Organizations and projects',
'ms.personal.builtIn': 'System built-in',
'ms.personal.wordAvatar': 'Text avatar',
'ms.personal.default': '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.apiKeyTip': 'After adding, you can access MeterSphere',
'ms.personal.expireTime': 'Expiration',
'ms.personal.expired': 'Expired',
'ms.personal.expiredTip': 'The expiration time can be changed in [Settings]',
'ms.personal.validTime': 'Effective time',
'ms.personal.desc': 'Description',
'ms.personal.createTime': 'Created time',
'ms.personal.copySuccess': 'Copied successfully',
'ms.personal.maxTip': 'Up to 5 APIKEYs can be added',
'ms.personal.confirmClose': 'Confirm to close?',
'ms.personal.closeTip':
'After closing, the test tasks executed using the Access Key will fail. Please operate with caution!',
'ms.personal.closeSuccess': 'Closed successfully',
'ms.personal.confirmDelete': 'Confirm deletion?',
'ms.personal.deleteTip':
'After deletion, the test tasks executed using the Access Key will fail. Please operate with caution!',
'ms.personal.openSuccess': 'Activated successfully',
'ms.personal.forever': 'Permanently valid',
'ms.personal.custom': 'Custom',
'ms.personal.timeSetting': 'Time setting',
'ms.personal.expiredTime': 'Expire date',
'ms.personal.expiredTimeRequired': 'Expiration time cannot be empty',
'ms.personal.accessKeyDesc': 'Access Key description',
'ms.personal.accessKeyDescPlaceholder': 'Please enter Access Key description',
'ms.personal.localExecution': 'Execute locally',
'ms.personal.apiLocalExecution': 'Interface execution locally',
'ms.personal.apiLocalExecutionPlaceholder':
'Please enter the local interface execution program URL and press Enter to detect',
'ms.personal.apiLocalExecutionOpen': 'Interface local priority execution is enabled',
'ms.personal.apiLocalExecutionClose': 'Interface local priority execution is turned off',
'ms.personal.uiLocalExecution': 'UI execution locally',
'ms.personal.uiLocalExecutionPlaceholder': 'Please enter the local selenium-server address and press Enter to check',
'ms.personal.uiLocalExecutionOpen': 'UI local priority execution is enabled',
'ms.personal.uiLocalExecutionClose': 'UI local priority execution is turned off',
'ms.personal.test': 'Test and save',
'ms.personal.testPass': 'Test passed',
'ms.personal.testFail': 'Test failed',
'ms.personal.unConfig': 'Not configured',
'ms.personal.priorityLocalExec': 'Prioritize local execution',
'ms.personal.jiraTip':
'This information is the user authentication information for submitting defects through Jira. If not filled in, the default information configured by the organization will be used.',
'ms.personal.validPass': 'Verification passed',
'ms.personal.validFail': 'Verification failed',
'ms.personal.unValid': 'Not verified',
'ms.personal.valid': 'Verify',
'ms.personal.authType': 'Authentication',
'ms.personal.platformAccount': 'Account',
'ms.personal.platformAccountPlaceholder': 'Please enter {type} account',
'ms.personal.platformPsw': 'Password',
'ms.personal.platformPswPlaceholder': 'Please enter {type} password',
'ms.personal.platformName': 'Nickname',
'ms.personal.platformNamePlaceholder': 'Please enter TAPD nickname',
'ms.personal.zendao': 'ZenTao',
'ms.personal.zendaoTip':
'This information is the user name and password for submitting defects through ZenTao. If not filled in, the default information configured by the organization is used.',
'ms.personal.azureTip':
'This information is the user token information for submitting defects through Azure Devops. If not filled in, the default information configured by the organization will be used.',
'ms.personal.azurePlaceholder': 'Please enter Personal Access Tokens',
};

View File

@ -0,0 +1,80 @@
export default {
'ms.personal': '个人中心',
'ms.personal.info': '个人信息',
'ms.personal.baseInfo': '基本信息',
'ms.personal.setPsw': '密码设置',
'ms.personal.setting': '个人设置',
'ms.personal.apiKey': 'APIKEY',
'ms.personal.tripartite': '三方平台账号',
'ms.personal.changeAvatar': '更换头像',
'ms.personal.name': '用户名称',
'ms.personal.namePlaceholder': '请输入用户名称',
'ms.personal.nameRequired': '用户名称不能为空',
'ms.personal.email': '邮箱',
'ms.personal.emailPlaceholder': '请输入邮箱',
'ms.personal.emailRequired': '邮箱不能为空',
'ms.personal.emailTip': '修改邮箱后,需要使用新的邮箱登录系统',
'ms.personal.phone': '手机号码',
'ms.personal.phonePlaceholder': '请输入手机号码',
'ms.personal.phoneRequired': '手机号码不能为空',
'ms.personal.org': '组织与项目',
'ms.personal.builtIn': '系统内置',
'ms.personal.wordAvatar': '文字头像',
'ms.personal.default': '默认',
'ms.personal.avatar': '头像{index}',
'ms.personal.currentPsw': '当前密码',
'ms.personal.newPsw': '新密码',
'ms.personal.changePswTip': '修改密码后,需要使用新的邮箱登录系统',
'ms.personal.apiKeyTip': '新增后,可访问 MeterSphere',
'ms.personal.expireTime': '过期时间',
'ms.personal.expired': '已到期',
'ms.personal.expiredTip': '可在【设置】内更改到期时间',
'ms.personal.validTime': '有效时间',
'ms.personal.desc': '描述',
'ms.personal.createTime': '创建时间',
'ms.personal.copySuccess': '复制成功',
'ms.personal.maxTip': '最多可添加 5 个APIKEY',
'ms.personal.confirmClose': '确认关闭吗',
'ms.personal.closeTip': '关闭后,将导致使用该 Access Key 执行的测试任务执行失败,请谨慎操作!',
'ms.personal.closeSuccess': '关闭成功',
'ms.personal.confirmDelete': '确认删除吗',
'ms.personal.deleteTip': '删除后,将导致使用该 Access Key 执行的测试任务执行失败,请谨慎操作!',
'ms.personal.openSuccess': '启用成功',
'ms.personal.forever': '永久有效',
'ms.personal.custom': '自定义',
'ms.personal.timeSetting': '时间设置',
'ms.personal.expiredTime': '到期时间',
'ms.personal.expiredTimeRequired': '到期时间不能为空',
'ms.personal.accessKeyDesc': 'Access Key 描述',
'ms.personal.accessKeyDescPlaceholder': '请输入Access Key 描述',
'ms.personal.localExecution': '本地执行',
'ms.personal.apiLocalExecution': '接口本地执行',
'ms.personal.apiLocalExecutionPlaceholder': '请输入本地接口执行程序URL回车检测',
'ms.personal.apiLocalExecutionOpen': '接口本地优先执行已开启',
'ms.personal.apiLocalExecutionClose': '接口本地优先执行已关闭',
'ms.personal.uiLocalExecution': 'UI 本地执行',
'ms.personal.uiLocalExecutionPlaceholder': '请输入本地 selenium-server 地址,回车检测',
'ms.personal.uiLocalExecutionOpen': 'UI 本地优先执行已开启',
'ms.personal.uiLocalExecutionClose': 'UI 本地优先执行已关闭',
'ms.personal.test': '检测并保存',
'ms.personal.testPass': '检测通过',
'ms.personal.testFail': '检测失败',
'ms.personal.unConfig': '未配置',
'ms.personal.priorityLocalExec': '优先本地执行',
'ms.personal.jiraTip': '该信息为通过 Jira 提交缺陷的用户认证信息,若未填写,则使用组织配置的默认信息',
'ms.personal.validPass': '校验通过',
'ms.personal.validFail': '校验失败',
'ms.personal.unValid': '未校验',
'ms.personal.valid': '校验',
'ms.personal.authType': '认证方式',
'ms.personal.platformAccount': '平台账号',
'ms.personal.platformAccountPlaceholder': '请输入 {type} 账号',
'ms.personal.platformPsw': '平台密码',
'ms.personal.platformPswPlaceholder': '请输入 {type} 密码',
'ms.personal.platformName': '平台昵称',
'ms.personal.platformNamePlaceholder': '请输入 TAPD 昵称',
'ms.personal.zendao': '禅道',
'ms.personal.zendaoTip': '该信息为通过禅道提交缺陷的的用户名、密码,若未填写,则使用组织配置的默认信息',
'ms.personal.azureTip': '该信息为通过Azure Devops提交缺陷的用户令牌信息若未填写则使用组织配置的默认信息',
'ms.personal.azurePlaceholder': '请输入 Personal Access Tokens',
};

View File

@ -0,0 +1,17 @@
<template>
<MsIcon v-if="props.avatar === 'default'" type="icon-icon_that_person" size="40" class="text-[var(--color-text-4)]" />
<a-avatar v-else-if="props.avatar === 'word'" class="bg-[rgb(var(--primary-1))] text-[rgb(var(--primary-6))]">
<slot></slot>
</a-avatar>
<a-avatar v-else :image-url="avatar"></a-avatar>
</template>
<script setup lang="ts">
import MsIcon from '@/components/pure/ms-icon-font/index.vue';
const props = defineProps<{
avatar: 'default' | 'word' | string;
}>();
</script>
<style lang="less" scoped></style>

View File

@ -21,43 +21,45 @@
<div class="ms-description-item-value"> <div class="ms-description-item-value">
<slot name="item-value" :item="item"> <slot name="item-value" :item="item">
<template v-if="item.isTag"> <template v-if="item.isTag">
<MsTag <slot name="tag" :item="item">
v-for="tag of Array.isArray(item.value) ? item.value : [item.value]" <MsTag
:key="`${tag}`" v-for="tag of Array.isArray(item.value) ? item.value : [item.value]"
theme="outline" :key="`${tag}`"
color="var(--color-text-n8)" theme="outline"
class="mb-[8px] mr-[8px] font-normal !text-[var(--color-text-1)]" color="var(--color-text-n8)"
:closable="item.closable" :class="`mb-[8px] mr-[8px] font-normal !text-[var(--color-text-1)] ${item.tagClass || ''}`"
@close="emit('tagClose', tag, item)" :closable="item.closable"
> @close="emit('tagClose', tag, item)"
{{ tag }} >
</MsTag> {{ tag }}
<span v-if="!item.showTagAdd" v-show="Array.isArray(item.value) && item.value.length === 0">-</span>
<div v-else>
<template v-if="showTagInput">
<a-input
ref="inputRef"
v-model.trim="addTagInput"
size="mini"
:error="!!tagInputError"
@keyup.enter="handleAddTag(item)"
@blur="handleAddTag(item)"
>
<template #suffix>
<icon-loading v-if="tagInputLoading" class="text-[rgb(var(--primary-5))]" />
</template>
</a-input>
<span v-if="tagInputError" class="text-[12px] leading-[16px] text-[rgb(var(--danger-6))]">
{{ t('ms.description.addTagRepeat') }}
</span>
</template>
<MsTag v-else type="primary" theme="outline" class="cursor-pointer" @click="handleEdit">
<template #icon>
<MsIcon type="icon-icon_add_outlined" class="text-[rgb(var(--primary-5))]" />
</template>
{{ t('ms.description.addTag') }}
</MsTag> </MsTag>
</div> <span v-if="!item.showTagAdd" v-show="Array.isArray(item.value) && item.value.length === 0">-</span>
<div v-else>
<template v-if="showTagInput">
<a-input
ref="inputRef"
v-model.trim="addTagInput"
size="mini"
:error="!!tagInputError"
@keyup.enter="handleAddTag(item)"
@blur="handleAddTag(item)"
>
<template #suffix>
<icon-loading v-if="tagInputLoading" class="text-[rgb(var(--primary-5))]" />
</template>
</a-input>
<span v-if="tagInputError" class="text-[12px] leading-[16px] text-[rgb(var(--danger-6))]">
{{ t('ms.description.addTagRepeat') }}
</span>
</template>
<MsTag v-else type="primary" theme="outline" class="cursor-pointer" @click="handleEdit">
<template #icon>
<MsIcon type="icon-icon_add_outlined" class="text-[rgb(var(--primary-5))]" />
</template>
{{ t('ms.description.addTag') }}
</MsTag>
</div>
</slot>
</template> </template>
<MsButton v-else-if="item.isButton" type="text" @click="handleItemClick(item)"> <MsButton v-else-if="item.isButton" type="text" @click="handleItemClick(item)">
{{ item.value }} {{ item.value }}
@ -108,6 +110,7 @@
value: (string | number) | (string | number)[]; value: (string | number) | (string | number)[];
key?: string; key?: string;
isTag?: boolean; // isTag?: boolean; //
tagClass?: string; //
closable?: boolean; // closable?: boolean; //
showTagAdd?: boolean; // showTagAdd?: boolean; //
isButton?: boolean; isButton?: boolean;

View File

@ -117,11 +117,6 @@
} }
); );
const contentExtraHeight = computed(() => {
// 146 30 60
return 146 - (props.noContentPadding ? 24 : 0) - (props.footer ? 0 : 60);
});
const handleContinue = () => { const handleContinue = () => {
emit('continue'); emit('continue');
}; };
@ -178,6 +173,12 @@
}; };
</script> </script>
<style lang="less" scoped>
.arco-scrollbar {
@apply h-full;
}
</style>
<style lang="less"> <style lang="less">
.arco-drawer { .arco-drawer {
@apply bg-white; @apply bg-white;
@ -229,7 +230,4 @@
background-color: var(--color-neutral-3); background-color: var(--color-neutral-3);
cursor: col-resize; cursor: col-resize;
} }
.arco-scrollbar {
@apply h-full;
}
</style> </style>

View File

@ -0,0 +1,74 @@
<template>
<div class="menu-wrapper">
<div class="menu-content">
<div v-if="props.title" class="mb-2 font-medium">{{ props.title }}</div>
<div class="menu">
<div
v-for="(item, index) of props.menuList"
:key="item.name"
class="menu-item px-2"
:class="{
'text-[--color-text-4]': item.level === 1,
'menu-item--active': item.name === currentKey && item.level !== 1,
'cursor-pointer': item.level !== 1,
'mt-[2px]': item.level === 1 && index !== 0,
[props.activeClass || '']: item.name === currentKey && item.level !== 1,
}"
:style="{
'border-top': item.level === 1 && index !== 0 ? '1px solid var(--color-border-2)' : 'none',
}"
>
<div @click="toggleMenu(item.name)">{{ item.title }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
title?: string;
defaultKey?: string;
menuList: {
title: string;
level: number;
name: string;
}[];
activeClass?: string;
}>();
const emit = defineEmits<{
(e: 'toggleMenu', val: string): void;
}>();
const currentKey = ref(props.defaultKey);
const toggleMenu = (itemName: string) => {
if (itemName) {
currentKey.value = itemName;
emit('toggleMenu', itemName);
}
};
</script>
<style lang="less" scoped>
.menu-wrapper {
border-radius: 12px;
color: var(--color-text-1);
box-shadow: 0 0 10px rgb(120 56 135/ 5%);
.menu-content {
width: 100%;
.menu {
.menu-item {
height: 38px;
line-height: 38px;
font-family: 'PingFang SC';
}
}
}
}
.menu-item--active {
border-radius: 4px;
color: rgb(var(--primary-5));
background-color: rgb(var(--primary-1));
}
</style>

View File

@ -0,0 +1,86 @@
<template>
<a-popover position="tl" trigger="focus" :title="t('ms.passwordInput.passwordTipTitle')">
<a-input-password
v-model="innerPsw"
:placeholder="t('ms.passwordInput.passwordPlaceholder')"
allow-clear
autocomplete="new-password"
@input="validatePsw"
@clear="validatePsw(innerPsw)"
/>
<template #content>
<div class="check-list-item">
<template v-if="pswLengthValidateRes">
<icon-check-circle-fill class="check-list-item--success" />{{ t('ms.passwordInput.passwordLengthRule') }}
</template>
<template v-else>
<icon-close-circle-fill class="check-list-item--error" />{{ t('ms.passwordInput.passwordLengthRule') }}
</template>
</div>
<div class="check-list-item">
<template v-if="pswValidateRes">
<icon-check-circle-fill class="check-list-item--success" />{{ t('ms.passwordInput.passwordWordRule') }}
</template>
<template v-else>
<icon-close-circle-fill class="check-list-item--error" />{{ t('ms.passwordInput.passwordWordRule') }}
</template>
</div>
</template>
</a-popover>
</template>
<script setup lang="ts">
import { useI18n } from '@/hooks/useI18n';
import { validatePasswordLength, validateWordPassword } from '@/utils/validate';
const props = defineProps<{
password: string;
}>();
const emit = defineEmits<{
(e: 'update:password', val: string): void;
}>();
const { t } = useI18n();
const innerPsw = ref(props.password);
watch(
() => props.password,
(val) => {
innerPsw.value = val;
}
);
watch(
() => innerPsw.value,
(val) => {
emit('update:password', val);
}
);
const pswValidateRes = ref(false);
const pswLengthValidateRes = ref(false);
function validatePsw(value: string) {
pswValidateRes.value = validateWordPassword(value);
pswLengthValidateRes.value = validatePasswordLength(value);
}
</script>
<style lang="less" scoped>
.check-list-item {
@apply flex items-center gap-2;
&:first-child {
@apply mt-2;
}
&:not(:last-child) {
@apply mb-2;
}
.check-list-item--success {
color: rgb(var(--success-6));
}
.check-list-item--error {
color: rgb(var(--danger-6));
}
}
</style>

View File

@ -0,0 +1,6 @@
export default {
'ms.passwordInput.passwordPlaceholder': 'Please enter password',
'ms.passwordInput.passwordTipTitle': 'Passwords must match both, and only the following rules are supported:',
'ms.passwordInput.passwordLengthRule': 'Length is 8-32 bits',
'ms.passwordInput.passwordWordRule': 'Must contain numbers and letters, Chinese or spaces are not allowed',
};

View File

@ -0,0 +1,6 @@
export default {
'ms.passwordInput.passwordPlaceholder': '请输入密码',
'ms.passwordInput.passwordTipTitle': '密码须同时符合,仅支持以下规则',
'ms.passwordInput.passwordLengthRule': '长度为8-32位',
'ms.passwordInput.passwordWordRule': '必须包含数字和字母,不允许输入中文或空格',
};

View File

@ -6,7 +6,13 @@
<template #content> <template #content>
<template v-for="item of props.list"> <template v-for="item of props.list">
<a-divider v-if="item.isDivider" :key="`${item.label}-divider`" margin="4px" /> <a-divider v-if="item.isDivider" :key="`${item.label}-divider`" margin="4px" />
<a-doption v-else :key="item.label" :class="item.danger ? 'error-6' : ''" :disabled="item.disabled"> <a-doption
v-else
:key="item.label"
:class="item.danger ? 'error-6' : ''"
:disabled="item.disabled"
:value="item.eventTag"
>
<MsIcon v-if="item.icon" :type="item.icon" /> <MsIcon v-if="item.icon" :type="item.icon" />
{{ t(item.label || '') }} {{ t(item.label || '') }}
</a-doption> </a-doption>
@ -32,7 +38,7 @@
const emit = defineEmits(['select', 'close']); const emit = defineEmits(['select', 'close']);
function selectHandler(value: SelectedValue) { function selectHandler(value: SelectedValue) {
const item = props.list.find((e: ActionsItem) => t(e.label || '') === value); const item = props.list.find((e: ActionsItem) => e.eventTag === value);
emit('select', item); emit('select', item);
} }

View File

@ -137,4 +137,7 @@
background: none !important; background: none !important;
} }
} }
.arco-tag-size-small {
line-height: 16px;
}
</style> </style>

View File

@ -14,7 +14,7 @@
<MsList :data="filterFileList" :bordered="false" :split="false" item-border no-hover> <MsList :data="filterFileList" :bordered="false" :split="false" item-border no-hover>
<template #item="{ item }"> <template #item="{ item }">
<a-list-item <a-list-item
class="mb-[8px] rounded-[var(--border-radius-small)] border border-solid border-[var(--color-text-n8)] !p-[8px_12px]" 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> <a-list-item-meta>
<template #avatar> <template #avatar>
@ -25,11 +25,11 @@
:type="getFileIcon(item)" :type="getFileIcon(item)"
size="24" size="24"
:class="getFileEnum(item.file?.type) === 'unknown' ? 'text-[var(--color-text-4)]' : ''" :class="getFileEnum(item.file?.type) === 'unknown' ? 'text-[var(--color-text-4)]' : ''"
></MsIcon> />
</a-avatar> </a-avatar>
</template> </template>
<template #title> <template #title>
<div class="font-normal">{{ item.file.name }}</div> <div class="one-line-text max-w-[80%] font-normal">{{ item.file.name }}</div>
</template> </template>
<template #description> <template #description>
<div v-if="item.status === UploadStatus.init" class="text-[12px] leading-[16px] text-[var(--color-text-4)]"> <div v-if="item.status === UploadStatus.init" class="text-[12px] leading-[16px] text-[var(--color-text-4)]">
@ -37,13 +37,17 @@
</div> </div>
<div <div
v-else-if="item.status === UploadStatus.done" v-else-if="item.status === UploadStatus.done"
class="text-[12px] leading-[16px] text-[var(--color-text-4)]" 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( `${formatFileSize(item.file.size)} ${t('ms.upload.uploadAt')} ${dayjs(item.uploadedTime).format(
'YYYY-MM-DD HH:mm:ss' 'YYYY-MM-DD HH:mm:ss'
)}` )}`
}} }}
<div class="flex items-center">
<MsIcon type="icon-icon_succeed_colorful" />
{{ t('ms.upload.uploadSuccess') }}
</div>
</div> </div>
<a-progress <a-progress
v-else-if="item.status === UploadStatus.uploading" v-else-if="item.status === UploadStatus.uploading"

View File

@ -9,6 +9,7 @@ export default {
'ms.upload.uploadAt': 'Uploaded at', 'ms.upload.uploadAt': 'Uploaded at',
'ms.upload.fail': 'Failed', 'ms.upload.fail': 'Failed',
'ms.upload.delete': 'Delete', 'ms.upload.delete': 'Delete',
'ms.upload.uploadSuccess': 'Upload successful',
'ms.upload.uploadFail': 'Upload failed', 'ms.upload.uploadFail': 'Upload failed',
'ms.upload.all': 'All', 'ms.upload.all': 'All',
'ms.upload.uploading': 'Waiting/Uploading', 'ms.upload.uploading': 'Waiting/Uploading',

View File

@ -8,6 +8,7 @@ export default {
'ms.upload.preview': '预览', 'ms.upload.preview': '预览',
'ms.upload.uploadAt': '上传于', 'ms.upload.uploadAt': '上传于',
'ms.upload.uploadFail': '上传失败', 'ms.upload.uploadFail': '上传失败',
'ms.upload.uploadSuccess': '上传成功',
'ms.upload.delete': '删除', 'ms.upload.delete': '删除',
'ms.upload.all': '全部', 'ms.upload.all': '全部',
'ms.upload.uploading': '等待/上传中', 'ms.upload.uploading': '等待/上传中',

View File

@ -13,7 +13,7 @@
<a-divider direction="vertical" class="ml-0" /> <a-divider direction="vertical" class="ml-0" />
<a-select <a-select
class="w-auto min-w-[150px] max-w-[200px] focus-within:!bg-[var(--color-text-n8)] hover:!bg-[var(--color-text-n8)]" class="w-auto min-w-[150px] max-w-[200px] focus-within:!bg-[var(--color-text-n8)] hover:!bg-[var(--color-text-n8)]"
:default-value="appStore.getCurrentProjectId" :default-value="appStore.currentProjectId"
:bordered="false" :bordered="false"
allow-search allow-search
@change="selectProject" @change="selectProject"
@ -24,7 +24,7 @@
<a-tooltip v-for="project of projectList" :key="project.id" :mouse-enter-delay="500" :content="project.name"> <a-tooltip v-for="project of projectList" :key="project.id" :mouse-enter-delay="500" :content="project.name">
<a-option <a-option
:value="project.id" :value="project.id"
:class="project.id === appStore.getCurrentProjectId ? 'arco-select-option-selected' : ''" :class="project.id === appStore.currentProjectId ? 'arco-select-option-selected' : ''"
> >
{{ project.name }} {{ project.name }}
</a-option> </a-option>
@ -178,7 +178,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, onBeforeMount, Ref, ref } from 'vue'; import { computed, onBeforeMount, Ref, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
// import useUser from '@/hooks/useUser'; // import useUser from '@/hooks/useUser';
@ -213,7 +213,7 @@
const projectList: Ref<ProjectListItem[]> = ref([]); const projectList: Ref<ProjectListItem[]> = ref([]);
onBeforeMount(async () => { async function initProjects() {
try { try {
const res = await getProjectList(appStore.getCurrentOrgId); const res = await getProjectList(appStore.getCurrentOrgId);
projectList.value = res; projectList.value = res;
@ -221,8 +221,19 @@
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(error); console.log(error);
} }
}
onBeforeMount(() => {
initProjects();
}); });
watch(
() => appStore.currentOrgId,
async () => {
initProjects();
}
);
const showProjectSelect = computed(() => { const showProjectSelect = computed(() => {
const { getRouteLevelByKey } = usePathMap(); const { getRouteLevelByKey } = usePathMap();
// //

View File

@ -1,7 +1,9 @@
export enum GitPlatformEnum { export enum GitPlatformEnum {
GITEA = 'Gitea',
GITHUB = 'Github', GITHUB = 'Github',
GITLAB = 'Gitlab', GITLAB = 'Gitlab',
GITEE = 'Gitee', GITEE = 'Gitee',
OTHER = 'Other',
} }
export enum AuthScopeEnum { export enum AuthScopeEnum {
SYSTEM = 'SYSTEM', SYSTEM = 'SYSTEM',

View File

@ -19,7 +19,7 @@
@collapse="setCollapsed" @collapse="setCollapsed"
> >
<div class="menu-wrapper"> <div class="menu-wrapper">
<Menu /> <MsMenu />
</div> </div>
</a-layout-sider> </a-layout-sider>
<a-drawer <a-drawer
@ -31,7 +31,7 @@
:closable="false" :closable="false"
@cancel="drawerCancel" @cancel="drawerCancel"
> >
<Menu /> <MsMenu />
</a-drawer> </a-drawer>
<a-layout class="layout-content" :style="paddingStyle"> <a-layout class="layout-content" :style="paddingStyle">
<a-spin :loading="appStore.loading" :tip="appStore.loadingTip"> <a-spin :loading="appStore.loading" :tip="appStore.loadingTip">
@ -60,9 +60,9 @@
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import Footer from '@/components/pure/footer/index.vue'; import Footer from '@/components/pure/footer/index.vue';
import Menu from '@/components/pure/menu/index.vue';
import NavBar from '@/components/pure/navbar/index.vue'; import NavBar from '@/components/pure/navbar/index.vue';
import MsBreadCrumb from '@/components/business/ms-breadcrumb/index.vue'; import MsBreadCrumb from '@/components/business/ms-breadcrumb/index.vue';
import MsMenu from '@/components/business/ms-menu/index.vue';
import PageLayout from './page-layout.vue'; import PageLayout from './page-layout.vue';
import { GetTitleImgUrl } from '@/api/requrls/setting/config'; import { GetTitleImgUrl } from '@/api/requrls/setting/config';

View File

@ -74,4 +74,5 @@ export default {
'common.fork': 'Fork', 'common.fork': 'Fork',
'common.more': 'More', 'common.more': 'More',
'common.recycle': 'Recycle Bin', 'common.recycle': 'Recycle Bin',
'common.new': 'New',
}; };

View File

@ -74,4 +74,5 @@ export default {
'common.fork': '关注', 'common.fork': '关注',
'common.more': '更多', 'common.more': '更多',
'common.recycle': '回收站', 'common.recycle': '回收站',
'common.new': '新增',
}; };

View File

@ -3,7 +3,7 @@ import { createRouter, createWebHashHistory } from 'vue-router';
import 'nprogress/nprogress.css'; import 'nprogress/nprogress.css';
import createRouteGuard from './guard'; import createRouteGuard from './guard';
import appRoutes from './routes'; import appRoutes from './routes';
import { INVITE_ROUTE, NOT_FOUND_ROUTE, PERSONAL_ROUTE, REDIRECT_MAIN } from './routes/base'; import { INVITE_ROUTE, NOT_FOUND_ROUTE, REDIRECT_MAIN } from './routes/base';
import NProgress from 'nprogress'; // progress bar import NProgress from 'nprogress'; // progress bar
NProgress.configure({ showSpinner: false }); // NProgress Configuration NProgress.configure({ showSpinner: false }); // NProgress Configuration
@ -27,7 +27,6 @@ const router = createRouter({
REDIRECT_MAIN, REDIRECT_MAIN,
NOT_FOUND_ROUTE, NOT_FOUND_ROUTE,
INVITE_ROUTE, INVITE_ROUTE,
PERSONAL_ROUTE,
], ],
scrollBehavior() { scrollBehavior() {
return { top: 0 }; return { top: 0 };

View File

@ -38,18 +38,3 @@ export const INVITE_ROUTE: RouteRecordRaw = {
hideInMenu: true, hideInMenu: true,
}, },
}; };
export const PERSONAL_ROUTE: RouteRecordRaw = {
path: '/personal',
name: 'personal',
component: DEFAULT_LAYOUT,
meta: {},
children: [
{
path: '/personal/info',
name: 'personalInfo',
component: () => import('@/views/base/personal/index.vue'),
meta: {},
},
],
};

View File

@ -110,6 +110,8 @@ const useUserStore = defineStore('user', {
} }
return true; return true;
} catch (err) { } catch (err) {
// eslint-disable-next-line no-console
console.log(err);
return false; return false;
} }
}, },

View File

@ -309,7 +309,7 @@ export const downloadUrlFile = (url: string, fileName: string) => {
*/ */
export const getHashParameters = (): Record<string, string> => { export const getHashParameters = (): Record<string, string> => {
const query = window.location.hash.split('?')[1]; // 获取 URL 哈希参数部分 const query = window.location.hash.split('?')[1]; // 获取 URL 哈希参数部分
const paramsArray = query.split('&'); // 将哈希参数字符串分割成数组 const paramsArray = query?.split('&') || []; // 将哈希参数字符串分割成数组
const params: Record<string, string> = {}; const params: Record<string, string> = {};
// 遍历数组并解析参数 // 遍历数组并解析参数

View File

@ -7,8 +7,7 @@ export const passwordLengthRegex = /^.{8,32}$/;
// 密码校验,必须包含数字和字母 // 密码校验,必须包含数字和字母
export const passwordWordRegex = /^(?=.*[a-zA-Z])(?=.*\d)[a-zA-Z0-9!@#$%^&*]+$/; export const passwordWordRegex = /^(?=.*[a-zA-Z])(?=.*\d)[a-zA-Z0-9!@#$%^&*]+$/;
// Git地址校验 // Git地址校验
export const gitRepositoryUrlRegex = export const gitRepositoryUrlRegex = /\.git$/;
/^(?:(?:git:\/\/|https?:\/\/)(?:www\.)?)?(github\.com|gitee\.com)\/([^/]+)\/([^/]+)\.git$/;
/** /**
* *

View File

@ -14,34 +14,7 @@
<a-input v-model="form.name" :placeholder="t('invite.namePlaceholder')" allow-clear /> <a-input v-model="form.name" :placeholder="t('invite.namePlaceholder')" allow-clear />
</a-form-item> </a-form-item>
<a-form-item field="password" class="hidden-item"> <a-form-item field="password" class="hidden-item">
<a-popover position="tl" trigger="focus" :title="t('invite.passwordTipTitle')"> <MsPasswordInput v-model:password="form.password" />
<a-input-password
v-model="form.password"
:placeholder="t('invite.passwordPlaceholder')"
allow-clear
autocomplete="new-password"
@input="validatePsw"
@clear="validatePsw(form.password)"
/>
<template #content>
<div class="check-list-item">
<template v-if="pswLengthValidateRes">
<icon-check-circle-fill class="check-list-item--success" />{{ t('invite.passwordLengthRule') }}
</template>
<template v-else>
<icon-close-circle-fill class="check-list-item--error" />{{ t('invite.passwordLengthRule') }}
</template>
</div>
<div class="check-list-item">
<template v-if="pswValidateRes">
<icon-check-circle-fill class="check-list-item--success" />{{ t('invite.passwordWordRule') }}
</template>
<template v-else>
<icon-close-circle-fill class="check-list-item--error" />{{ t('invite.passwordWordRule') }}
</template>
</div>
</template>
</a-popover>
</a-form-item> </a-form-item>
<a-form-item field="rePassword" class="hidden-item"> <a-form-item field="rePassword" class="hidden-item">
<a-input-password <a-input-password
@ -62,6 +35,8 @@
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { FormInstance, Message } from '@arco-design/web-vue'; import { FormInstance, Message } from '@arco-design/web-vue';
import MsPasswordInput from '@/components/pure/ms-password-input/index.vue';
import { registerByInvite } from '@/api/modules/setting/user'; import { registerByInvite } from '@/api/modules/setting/user';
import { useI18n } from '@/hooks/useI18n'; import { useI18n } from '@/hooks/useI18n';
import { encrypted, sleep } from '@/utils'; import { encrypted, sleep } from '@/utils';

View File

@ -15,7 +15,11 @@ export default {
'invite.passwordLengthRule': 'The length is 8-32 digits', 'invite.passwordLengthRule': 'The length is 8-32 digits',
'invite.passwordWordRule': 'Must contain numbers and letters, Chinese or spaces are not allowed', 'invite.passwordWordRule': 'Must contain numbers and letters, Chinese or spaces are not allowed',
'invite.success': 'Registered successfully', 'invite.success': 'Registered successfully',
'personal.info': 'Personal Info', 'personal.info': 'My Info',
'personal.switchOrg': 'Switch Org', 'personal.switchOrg': 'Switch Org',
'personal.searchOrgPlaceholder': 'Please enter organization name',
'personal.currentOrg': 'Current org',
'personal.switchOrgLoading': 'Switching org...',
'personal.switchOrgSuccess': 'Switching org successfully',
'personal.exit': 'Log out', 'personal.exit': 'Log out',
}; };

View File

@ -17,5 +17,9 @@ export default {
'invite.success': '注册成功', 'invite.success': '注册成功',
'personal.info': '个人信息', 'personal.info': '个人信息',
'personal.switchOrg': '切换组织', 'personal.switchOrg': '切换组织',
'personal.searchOrgPlaceholder': '请输入组织名称',
'personal.currentOrg': '当前组织',
'personal.switchOrgLoading': '切换组织中...',
'personal.switchOrgSuccess': '切换组织成功',
'personal.exit': '退出系统', 'personal.exit': '退出系统',
}; };

View File

@ -1,7 +0,0 @@
<template>
<div> </div>
</template>
<script setup lang="ts"></script>
<style lang="less" scoped></style>

View File

@ -229,6 +229,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
/**
* @description 功能测试-用例评审-用例详情
*/
import { FormInstance } from '@arco-design/web-vue'; import { FormInstance } from '@arco-design/web-vue';
import dayjs from 'dayjs'; import dayjs from 'dayjs';

View File

@ -192,6 +192,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
/**
* @description 功能测试-用例评审-创建评审
*/
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { Message } from '@arco-design/web-vue'; import { Message } from '@arco-design/web-vue';

View File

@ -99,6 +99,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
/**
* @description 功能测试-用例评审-评审详情
*/
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import MsButton from '@/components/pure/ms-button/index.vue'; import MsButton from '@/components/pure/ms-button/index.vue';

View File

@ -24,6 +24,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
/**
* @description 功能测试-用例评审-评审列表
*/
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import MsCard from '@/components/pure/ms-card/index.vue'; import MsCard from '@/components/pure/ms-card/index.vue';

View File

@ -20,8 +20,11 @@
:before-change="handleEnableIntercept" :before-change="handleEnableIntercept"
:disabled="loading" :disabled="loading"
size="small" size="small"
class="mr-[8px]" class="mr-[4px]"
/> />
<a-tooltip :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 <MsButton
type="icon" type="icon"
status="secondary" status="secondary"

View File

@ -67,7 +67,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue'; import { computed, onBeforeMount, ref, watch } from 'vue';
import { Message } from '@arco-design/web-vue'; import { Message } from '@arco-design/web-vue';
import MsButton from '@/components/pure/ms-button/index.vue'; import MsButton from '@/components/pure/ms-button/index.vue';
@ -312,6 +312,12 @@
} }
); );
onBeforeMount(() => {
if (props.isModal) {
initModules();
}
});
defineExpose({ defineExpose({
initModules, initModules,
}); });

View File

@ -44,6 +44,21 @@
@batch-action="handleTableBatch" @batch-action="handleTableBatch"
> >
<template #name="{ record, rowIndex }"> <template #name="{ record, rowIndex }">
<MsTag
v-if="record.fileType.toLowerCase() === 'jar'"
theme="light"
type="success"
:self-style="
record.enable
? {}
: {
color: 'var(--color-text-4)',
backgroundColor: 'var(--color-text-n9)',
}
"
>
{{ t(record.enable ? 'common.enable' : 'common.disable') }}
</MsTag>
<a-tooltip :content="record.name"> <a-tooltip :content="record.name">
<a-button type="text" class="px-0" @click="openFileDetail(record.id, rowIndex)"> <a-button type="text" class="px-0" @click="openFileDetail(record.id, rowIndex)">
<div class="one-line-text max-w-[168px]">{{ record.name }}</div> <div class="one-line-text max-w-[168px]">{{ record.name }}</div>
@ -79,6 +94,7 @@
:remote-params="{ :remote-params="{
projectId: appStore.currentProjectId, projectId: appStore.currentProjectId,
moduleId: props.activeFolder, moduleId: props.activeFolder,
moduleIds: isMyOrAllFolder ? [] : [props.activeFolder],
fileType: tableFileType, fileType: tableFileType,
combine, combine,
keyword, keyword,
@ -255,7 +271,14 @@
<template #title> <template #title>
<div class="flex items-center"> <div class="flex items-center">
{{ isBatchMove ? t('project.fileManagement.batchMoveTitle') : t('project.fileManagement.singleMoveTitle') }} {{ isBatchMove ? t('project.fileManagement.batchMoveTitle') : t('project.fileManagement.singleMoveTitle') }}
<div class="ml-[4px] text-[var(--color-text-4)]"> <div
class="one-line-text ml-[4px] max-w-[70%] text-[var(--color-text-4)]"
:title="
isBatchMove
? t('project.fileManagement.batchMoveTitleSub', { count: tableSelected.length })
: `(${activeFile?.name})`
"
>
{{ {{
isBatchMove isBatchMove
? t('project.fileManagement.batchMoveTitleSub', { count: tableSelected.length }) ? t('project.fileManagement.batchMoveTitleSub', { count: tableSelected.length })
@ -297,6 +320,7 @@
import useTable from '@/components/pure/ms-table/useTable'; import useTable from '@/components/pure/ms-table/useTable';
import MsTableMoreAction from '@/components/pure/ms-table-more-action/index.vue'; import MsTableMoreAction from '@/components/pure/ms-table-more-action/index.vue';
import type { ActionsItem } from '@/components/pure/ms-table-more-action/types'; import type { ActionsItem } from '@/components/pure/ms-table-more-action/types';
import MsTag from '@/components/pure/ms-tag/ms-tag.vue';
import MsFileList from '@/components/pure/ms-upload/fileList.vue'; import MsFileList from '@/components/pure/ms-upload/fileList.vue';
import MsUpload from '@/components/pure/ms-upload/index.vue'; import MsUpload from '@/components/pure/ms-upload/index.vue';
import type { MsFileItem, UploadType } from '@/components/pure/ms-upload/types'; import type { MsFileItem, UploadType } from '@/components/pure/ms-upload/types';
@ -465,7 +489,7 @@
title: 'project.fileManagement.name', title: 'project.fileManagement.name',
slotName: 'name', slotName: 'name',
dataIndex: 'name', dataIndex: 'name',
width: 200, width: 270,
}, },
{ {
title: 'project.fileManagement.type', title: 'project.fileManagement.type',
@ -757,6 +781,7 @@
combine.value.storage = 'minio'; combine.value.storage = 'minio';
} }
let moduleIds: string[] = [props.activeFolder, ...props.offspringIds]; let moduleIds: string[] = [props.activeFolder, ...props.offspringIds];
if (isMyOrAllFolder.value) { if (isMyOrAllFolder.value) {
moduleIds = []; moduleIds = [];
} }

View File

@ -309,7 +309,7 @@
id: '', id: '',
projectId: '', projectId: '',
name: '', name: '',
platform: GitPlatformEnum.GITHUB, platform: GitPlatformEnum.GITEA,
url: '', url: '',
token: '', token: '',
userName: '', userName: '',

View File

@ -56,6 +56,7 @@ export default {
'project.fileManagement.normalFileDesc': 'All file types', 'project.fileManagement.normalFileDesc': 'All file types',
'project.fileManagement.jarFile': 'JAR files', 'project.fileManagement.jarFile': 'JAR files',
'project.fileManagement.jarFileDesc': 'Files used for interface testing', 'project.fileManagement.jarFileDesc': 'Files used for interface testing',
'project.fileManagement.uploadTipSingle': 'Interface test script execution needs to be enabled',
'project.fileManagement.uploadTip': 'project.fileManagement.uploadTip':
'Interface test script execution needs to be enabled, which can be enabled with one click; files can be opened individually', 'Interface test script execution needs to be enabled, which can be enabled with one click; files can be opened individually',
'project.fileManagement.fileTypeTip': 'Switch the file type and the selected/uploaded file list will be cleared.', 'project.fileManagement.fileTypeTip': 'Switch the file type and the selected/uploaded file list will be cleared.',

View File

@ -54,6 +54,7 @@ export default {
'project.fileManagement.normalFileDesc': '所有文件类型', 'project.fileManagement.normalFileDesc': '所有文件类型',
'project.fileManagement.jarFile': 'JAR 文件', 'project.fileManagement.jarFile': 'JAR 文件',
'project.fileManagement.jarFileDesc': '用于接口测试的文件', 'project.fileManagement.jarFileDesc': '用于接口测试的文件',
'project.fileManagement.uploadTipSingle': '接口测试脚本执行需开启',
'project.fileManagement.uploadTip': '接口测试脚本执行需开启,可一键开启;可对文件单独开启', 'project.fileManagement.uploadTip': '接口测试脚本执行需开启,可一键开启;可对文件单独开启',
'project.fileManagement.fileTypeTip': '切换文件类型,已选/已上传文件列表会清空', 'project.fileManagement.fileTypeTip': '切换文件类型,已选/已上传文件列表会清空',
'project.fileManagement.normalFileSubText': '支持任意文件类型,文件大小不超过 {size} MB', 'project.fileManagement.normalFileSubText': '支持任意文件类型,文件大小不超过 {size} MB',

View File

@ -1,27 +1,12 @@
<template> <template>
<div class="wrapper flex min-h-[500px]" :style="{ height: 'calc(100vh - 90px)' }"> <div class="wrapper flex min-h-[500px]" :style="{ height: 'calc(100vh - 90px)' }">
<div class="left-menu-wrapper mr-[16px] w-[208px] min-w-[208px] bg-white p-[24px]"> <MsMenuPanel
<div class="left-content"> :title="t('project.permission.projectAndPermission')"
<div class="mb-2 font-medium">{{ t('project.permission.projectAndPermission') }}</div> :default-key="currentKey"
<div class="menu"> :menu-list="menuList"
<div class="mr-[16px] w-[208px] min-w-[208px] bg-white p-[24px]"
v-for="(item, index) of menuList" @toggle-menu="toggleMenu"
:key="item.key" />
class="menu-item px-2"
:class="{
'text-[--color-text-4]': item.level === 1,
'is-active': item.name === currentKey && item.level !== 1,
'cursor-pointer': item.level !== 1,
}"
:style="{
'border-top': item.level === 1 && index !== 0 ? '1px solid var(--color-border-2)' : 'none',
}"
>
<div @click="toggleMenu(item.name)">{{ t(item.title) }}</div>
</div>
</div>
</div>
</div>
<MsCard simple :other-width="290" :min-width="700" :loading="isLoading"> <MsCard simple :other-width="290" :min-width="700" :loading="isLoading">
<router-view></router-view> <router-view></router-view>
</MsCard> </MsCard>
@ -36,6 +21,7 @@
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import MsCard from '@/components/pure/ms-card/index.vue'; import MsCard from '@/components/pure/ms-card/index.vue';
import MsMenuPanel from '@/components/pure/ms-menu-panel/index.vue';
import { useI18n } from '@/hooks/useI18n'; import { useI18n } from '@/hooks/useI18n';
@ -48,43 +34,43 @@
const menuList = ref([ const menuList = ref([
{ {
key: 'project', key: 'project',
title: 'project.permission.project', title: t('project.permission.project'),
level: 1, level: 1,
name: '', name: '',
}, },
{ {
key: 'projectBasicInfo', key: 'projectBasicInfo',
title: 'project.permission.basicInfo', title: t('project.permission.basicInfo'),
level: 2, level: 2,
name: ProjectManagementRouteEnum.PROJECT_MANAGEMENT_PERMISSION_BASIC_INFO, name: ProjectManagementRouteEnum.PROJECT_MANAGEMENT_PERMISSION_BASIC_INFO,
}, },
{ {
key: 'projectMenuManage', key: 'projectMenuManage',
title: 'project.permission.menuManagement', title: t('project.permission.menuManagement'),
level: 2, level: 2,
name: ProjectManagementRouteEnum.PROJECT_MANAGEMENT_PERMISSION_MENU_MANAGEMENT, name: ProjectManagementRouteEnum.PROJECT_MANAGEMENT_PERMISSION_MENU_MANAGEMENT,
}, },
{ {
key: 'projectVersion', key: 'projectVersion',
title: 'project.permission.projectVersion', title: t('project.permission.projectVersion'),
level: 2, level: 2,
name: ProjectManagementRouteEnum.PROJECT_MANAGEMENT_PERMISSION_VERSION, name: ProjectManagementRouteEnum.PROJECT_MANAGEMENT_PERMISSION_VERSION,
}, },
{ {
key: 'memberPermission', key: 'memberPermission',
title: 'project.permission.memberPermission', title: t('project.permission.memberPermission'),
level: 1, level: 1,
name: '', name: '',
}, },
{ {
key: 'projectMember', key: 'projectMember',
title: 'project.permission.member', title: t('project.permission.member'),
level: 2, level: 2,
name: ProjectManagementRouteEnum.PROJECT_MANAGEMENT_PERMISSION_MEMBER, name: ProjectManagementRouteEnum.PROJECT_MANAGEMENT_PERMISSION_MEMBER,
}, },
{ {
key: 'projectUserGroup', key: 'projectUserGroup',
title: 'project.permission.userGroup', title: t('project.permission.userGroup'),
level: 2, level: 2,
name: ProjectManagementRouteEnum.PROJECT_MANAGEMENT_PERMISSION_USER_GROUP, name: ProjectManagementRouteEnum.PROJECT_MANAGEMENT_PERMISSION_USER_GROUP,
}, },