基于naive ui重构设置-团队页面及弹窗

This commit is contained in:
jingrow 2025-12-30 01:17:10 +08:00
parent 8b617d833b
commit 345d3db483
4 changed files with 451 additions and 115 deletions

View File

@ -1,88 +1,162 @@
<template>
<Dialog
:options="{
title: $t('Add New Member'),
actions: [
{
label: $t('Invite Member'),
variant: 'solid',
onClick: inviteMember,
},
],
}"
v-model="show"
<n-modal
v-model:show="showDialog"
preset="card"
:title="$t('Add New Member')"
:style="modalStyle"
:mask-closable="true"
:close-on-esc="true"
class="invite-member-modal"
>
<template #body-content>
<div class="space-y-4">
<FormControl :label="$t('Username')" v-model="username" />
<div
v-if="$resources.roles.data?.length > 0"
class="flex items-center space-x-2"
>
<FormControl
class="w-full"
type="autocomplete"
:label="$t('Select Role')"
:options="roleOptions"
v-model="selectedRole"
/>
<Button
:label="$t('Add')"
icon-left="plus"
<template #header>
<span class="text-lg font-semibold">{{ $t('Add New Member') }}</span>
</template>
<n-space vertical :size="20">
<n-form-item :label="$t('Username')" :required="true">
<n-input
v-model:value="username"
placeholder=""
:size="inputSize"
class="w-full"
/>
</n-form-item>
<div v-if="$resources.roles.data?.length > 0" class="space-y-4">
<div class="flex items-end gap-2">
<n-form-item :label="$t('Select Role')" class="flex-1">
<n-select
v-model:value="selectedRole"
:options="roleOptions"
:placeholder="$t('Select a role')"
:size="inputSize"
class="w-full"
/>
</n-form-item>
<n-button
type="primary"
:disabled="!selectedRole"
@click="addRole"
class="mt-5"
/>
</div>
<div
v-if="selectedRoles.length > 0"
class="divide-y rounded border border-gray-300 px-1.5"
>
<div
class="flex w-full items-center space-x-2 py-1.5"
v-for="role in selectedRoles"
:size="buttonSize"
>
<div class="flex w-full items-center justify-between px-3 py-2">
<div class="text-base text-gray-800">{{ role.label }}</div>
</div>
<Button
class="ml-auto"
variant="ghost"
icon="x"
<template #icon>
<n-icon><PlusIcon /></n-icon>
</template>
{{ $t('Add') }}
</n-button>
</div>
<div v-if="selectedRoles.length > 0" class="space-y-2">
<div
v-for="role in selectedRoles"
:key="role.value"
class="flex items-center justify-between p-3 rounded border border-gray-200 bg-gray-50"
>
<div class="text-base text-gray-800">{{ role.label }}</div>
<n-button
tertiary
circle
size="small"
@click="removeRole(role.value)"
/>
>
<template #icon>
<n-icon><XIcon /></n-icon>
</template>
</n-button>
</div>
</div>
</div>
<n-alert
v-if="$team.inviteTeamMember.error"
type="error"
:title="getErrorMessage($team.inviteTeamMember.error)"
/>
</n-space>
<template #action>
<n-space :vertical="isMobile" :size="isMobile ? 12 : 16" class="w-full">
<n-button @click="hide" :block="isMobile" :size="buttonSize">
{{ $t('Cancel') }}
</n-button>
<n-button
type="primary"
:loading="$team.inviteTeamMember.loading"
@click="inviteMember"
:block="isMobile"
:size="buttonSize"
>
{{ $t('Invite Member') }}
</n-button>
</n-space>
</template>
</Dialog>
</n-modal>
</template>
<script>
import {
NModal,
NFormItem,
NInput,
NSpace,
NAlert,
NButton,
NSelect,
NIcon,
} from 'naive-ui';
import { toast } from 'vue-sonner';
import PlusIcon from '~icons/lucide/plus';
import XIcon from '~icons/lucide/x';
export default {
components: {
NModal,
NFormItem,
NInput,
NSpace,
NAlert,
NButton,
NSelect,
NIcon,
PlusIcon,
XIcon,
},
emits: ['success', 'update:modelValue'],
props: {
modelValue: {
type: Boolean,
default: false
}
},
data() {
return {
username: '',
show: true,
selectedRoles: [],
selectedRole: null,
windowWidth: window.innerWidth,
};
},
resources: {
roles() {
computed: {
showDialog: {
get() {
return this.modelValue;
},
set(value) {
this.$emit('update:modelValue', value);
}
},
isMobile() {
return this.windowWidth <= 768;
},
modalStyle() {
return {
type: 'list',
pagetype: 'Jcloud Role',
fields: ['name', 'title'],
initialData: [],
auto: true,
width: this.isMobile ? '95vw' : '700px',
maxWidth: this.isMobile ? '95vw' : '90vw',
};
},
},
computed: {
inputSize() {
return this.isMobile ? 'medium' : 'large';
},
buttonSize() {
return this.isMobile ? 'medium' : 'medium';
},
roleOptions() {
if (!this.$resources.roles.data) return [];
return this.$resources.roles.data
.filter((role) => {
return !this.selectedRoles.some(
@ -95,11 +169,36 @@ export default {
}));
},
},
mounted() {
this.handleResize();
window.addEventListener('resize', this.handleResize);
},
beforeUnmount() {
window.removeEventListener('resize', this.handleResize);
},
methods: {
handleResize() {
this.windowWidth = window.innerWidth;
},
getErrorMessage(error) {
if (!error) return '';
if (typeof error === 'string') return error;
if (error.message) return error.message;
if (error.messages && Array.isArray(error.messages)) {
return error.messages.join('\n');
}
return String(error);
},
addRole() {
if (this.selectedRole) {
this.selectedRoles.push(this.selectedRole);
this.selectedRole = null;
const role = this.$resources.roles.data.find(r => r.name === this.selectedRole);
if (role) {
this.selectedRoles.push({
label: role.title,
value: role.name,
});
this.selectedRole = null;
}
}
},
removeRole(roleToRemove) {
@ -115,19 +214,87 @@ export default {
if (this.$team.inviteTeamMember.loading) return;
toast.success(this.$t('Member added to team'), { duration: 2000 });
this.show = false;
this.$team.inviteTeamMember.submit({
username: this.username,
roles: this.selectedRoles.map((role) => role.value),
});
this.username = '';
this.selectedRoles = [];
this.hide();
setTimeout(() => {
if (this.$team?.getTeamMembers) {
this.$team.getTeamMembers.submit();
}
this.$emit('success');
}, 500);
},
hide() {
this.showDialog = false;
this.username = '';
this.selectedRoles = [];
this.selectedRole = null;
}
},
resources: {
roles() {
return {
type: 'list',
pagetype: 'Jcloud Role',
fields: ['name', 'title'],
initialData: [],
auto: true,
};
},
}
};
</script>
</script>
<style scoped>
:deep(.invite-member-modal .n-card) {
width: 700px;
max-width: 90vw;
}
:deep(.invite-member-modal .n-card-body) {
padding: 24px;
}
:deep(.invite-member-modal .n-form-item) {
margin-bottom: 0;
}
:deep(.invite-member-modal .n-input),
:deep(.invite-member-modal .n-select) {
width: 100%;
}
:deep(.invite-member-modal .n-card__action) {
padding: 16px 24px;
}
@media (max-width: 768px) {
:deep(.invite-member-modal .n-card) {
width: 95vw !important;
max-width: 95vw !important;
margin: 20px auto;
border-radius: 12px;
}
:deep(.invite-member-modal .n-card-body) {
padding: 20px 16px;
}
:deep(.invite-member-modal .n-card__header) {
padding: 16px;
font-size: 18px;
}
:deep(.invite-member-modal .n-card__action) {
padding: 12px 16px;
}
:deep(.invite-member-modal .n-button) {
width: 100%;
font-size: 16px;
height: 44px;
}
}
</style>

View File

@ -1,22 +1,129 @@
<template>
<div class="p-5">
<ObjectList :options="teamMembersListOptions"> </ObjectList>
<div class="team-settings-container">
<n-card :title="$t('Team Members')" class="settings-card">
<ObjectList :options="teamMembersListOptions" />
</n-card>
<!-- 团队设置弹窗 -->
<n-modal
v-model:show="showTeamSettingsDialog"
preset="card"
:title="$t('Settings')"
:style="modalStyle"
:mask-closable="true"
:close-on-esc="true"
class="team-settings-modal"
>
<template #header>
<span class="text-lg font-semibold">{{ $t('Settings') }}</span>
</template>
<n-space vertical :size="20">
<n-form-item>
<div class="flex items-center justify-between w-full">
<div class="flex-1">
<div class="text-base font-medium text-gray-900 mb-1">
{{ $t('Enforce Two-Factor Authentication') }}
</div>
<div class="text-sm text-gray-600">
{{ $t('Require all team members to enable two-factor authentication') }}
</div>
</div>
<n-switch
:value="enforce2FA"
@update:value="handleEnforce2FAChange"
size="medium"
/>
</div>
</n-form-item>
</n-space>
</n-modal>
<!-- 邀请成员弹窗 -->
<InviteTeamMemberDialog
v-if="showInviteMemberDialog"
v-model="showInviteMemberDialog"
@success="handleInviteSuccess"
/>
<!-- 移除成员确认弹窗 -->
<n-modal
v-model:show="showRemoveMemberDialog"
preset="dialog"
:title="$t('Remove Member')"
:positive-text="$t('Remove')"
:positive-button-props="{ type: 'error' }"
:loading="team.removeTeamMember.loading"
:mask-closable="true"
:close-on-esc="true"
@positive-click="handleRemoveMember"
>
<div class="py-4">
<p class="text-base">
{{ $t('Are you sure you want to remove') }} <strong>{{ selectedMember?.full_name || '' }}</strong> {{ $t('from the team?') }}
</p>
</div>
</n-modal>
</div>
</template>
<script setup>
import { defineAsyncComponent, h, ref, getCurrentInstance } from 'vue';
import {
NCard,
NModal,
NSpace,
NFormItem,
NSwitch,
NButton,
} from 'naive-ui';
import { defineAsyncComponent, h, ref, getCurrentInstance, computed, onMounted } from 'vue';
import { toast } from 'vue-sonner';
import { getTeam } from '../../data/team';
import { confirmDialog, renderDialog } from '../../utils/components';
import { icon } from '../../utils/components';
import ObjectList from '../ObjectList.vue';
import UserWithAvatarCell from '../UserWithAvatarCell.vue';
import InviteTeamMemberDialog from './InviteTeamMemberDialog.vue';
const instance = getCurrentInstance();
const $t = instance?.appContext.config.globalProperties.$t || ((key) => key);
const team = getTeam();
team.getTeamMembers.submit();
const windowWidth = ref(window.innerWidth);
const showTeamSettingsDialog = ref(false);
const showInviteMemberDialog = ref(false);
const showRemoveMemberDialog = ref(false);
const selectedMember = ref(null);
const isMobile = computed(() => windowWidth.value <= 768);
const modalStyle = computed(() => ({
width: isMobile.value ? '95vw' : '700px',
maxWidth: isMobile.value ? '95vw' : '90vw',
}));
const buttonSize = computed(() => (isMobile.value ? 'medium' : 'medium'));
const enforce2FA = computed(() => {
return Boolean(team.pg?.enforce_2fa);
});
function handleEnforce2FAChange(value) {
team.setValue.submit({ enforce_2fa: value });
}
function handleInviteSuccess() {
showInviteMemberDialog.value = false;
team.getTeamMembers.submit();
}
function handleRemoveMember() {
if (!selectedMember.value) return;
team.removeTeamMember.submit({ member: selectedMember.value.name });
toast.success($t('Member removed'), { duration: 2000 });
setTimeout(() => {
team.getTeamMembers.submit();
showRemoveMemberDialog.value = false;
selectedMember.value = null;
}, 500);
}
const teamMembersListOptions = ref({
onRowClick: () => {},
rowHeight: 50,
@ -36,7 +143,6 @@ const teamMembersListOptions = ref({
}
],
rowActions({ row }) {
let team = getTeam();
if (row.name === team.pg.user || row.name === team.pg.user_info?.name)
return [];
return [
@ -44,47 +150,105 @@ const teamMembersListOptions = ref({
label: $t('Remove Member'),
condition: () => row.name !== team.pg.user,
onClick() {
if (team.removeTeamMember.loading) return;
confirmDialog({
title: $t('Remove Member'),
message: $t('Are you sure you want to remove <b>{name}</b> from the team?', { name: row.full_name }),
onSuccess({ hide }) {
toast.success($t('Member removed'), { duration: 2000 });
hide();
team.removeTeamMember.submit({ member: row.name });
setTimeout(() => {
team.getTeamMembers.submit();
}, 500);
}
});
selectedMember.value = row;
showRemoveMemberDialog.value = true;
}
}
];
},
actions() {
return [
{
label: $t('Settings'),
iconLeft: 'settings',
onClick() {
const TeamSettingsDialog = defineAsyncComponent(() =>
import('./TeamSettingsDialog.vue')
);
renderDialog(h(TeamSettingsDialog));
}
},
{
label: $t('Add Member'),
variant: 'solid',
iconLeft: 'plus',
onClick() {
const InviteTeamMemberDialog = defineAsyncComponent(() =>
import('./InviteTeamMemberDialog.vue')
);
renderDialog(h(InviteTeamMemberDialog));
}
secondaryAction() {
return {
label: $t('Settings'),
icon: 'settings',
onClick: () => {
showTeamSettingsDialog.value = true;
}
];
};
},
primaryAction() {
return {
label: $t('Add Member'),
slots: { prefix: icon('plus') },
onClick: () => {
showInviteMemberDialog.value = true;
}
};
}
});
</script>
function handleResize() {
windowWidth.value = window.innerWidth;
}
onMounted(() => {
handleResize();
window.addEventListener('resize', handleResize);
team.getTeamMembers.submit();
});
</script>
<style scoped>
.team-settings-container {
width: 100%;
padding: 24px;
}
.settings-card {
border-radius: 12px;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
transition: box-shadow 0.2s ease;
}
.settings-card:hover {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
:deep(.team-settings-modal .n-card) {
width: 700px;
max-width: 90vw;
}
:deep(.team-settings-modal .n-card-body) {
padding: 24px;
}
:deep(.team-settings-modal .n-card__action) {
padding: 16px 24px;
}
@media (max-width: 768px) {
.team-settings-container {
padding: 16px;
}
.settings-card {
border-radius: 8px;
}
:deep(.team-settings-modal .n-card) {
width: 95vw !important;
max-width: 95vw !important;
margin: 20px auto;
border-radius: 12px;
}
:deep(.team-settings-modal .n-card-body) {
padding: 20px 16px;
}
:deep(.team-settings-modal .n-card__header) {
padding: 16px;
font-size: 18px;
}
:deep(.team-settings-modal .n-card__action) {
padding: 12px 16px;
}
:deep(.team-settings-modal .n-button) {
width: 100%;
font-size: 16px;
height: 44px;
}
}
</style>

View File

@ -4,27 +4,30 @@
$account.user.name === $account.team.user ||
$account.user.user_type === 'System User'
"
class="space-y-5"
class="team-settings-view"
>
<AccountMembers />
<AccountGroups />
<TeamSettings />
</div>
</template>
<script>
import AccountGroups from './AccountGroups.vue';
import AccountMembers from './AccountMembers.vue';
import TeamSettings from '../../components/settings/TeamSettings.vue';
export default {
name: 'AccountSettings',
name: 'TeamSettingsView',
pageMeta() {
return {
title: 'Settings - Team'
};
},
components: {
AccountMembers,
AccountGroups
TeamSettings
}
};
</script>
<style scoped>
.team-settings-view {
width: 100%;
}
</style>

View File

@ -843,6 +843,8 @@ Username is required,用户名为必填项,
Member added to team,已添加成员到团队,
Remove Member,移除成员,
Are you sure you want to remove <b>{name}</b> from the team?,确定要将 <b>{name}</b> 从团队中移除吗?,
Are you sure you want to remove,确定要将,
from the team?,从团队中移除吗?,
Member removed,成员已被删除,
Add Member,添加成员,
Enforce Two-Factor Authentication,强制启用双因素认证,

Can't render this file because it has a wrong number of fields in line 400.