perf: 优化个人账户页面

This commit is contained in:
xiaojunnuo
2026-03-13 19:39:27 +08:00
parent eae4f721e8
commit e50611666e
10 changed files with 516 additions and 55 deletions
@@ -115,11 +115,17 @@ export abstract class BaseController {
if (projectId) {
await authService.checkProjectId(service, id, projectId);
}else{
if(allowAdmin){
await authService.checkUserIdButAllowAdmin(this.ctx, service, id);
if(userId === 0){
//系统级别,不检查权限
}else{
await authService.checkUserId( service, id, userId);
if(allowAdmin){
await authService.checkUserIdButAllowAdmin(this.ctx, service, id);
}else{
await authService.checkUserId( service, id, userId);
}
}
}
return {projectId,userId}
}
@@ -102,4 +102,5 @@ export default {
deviceName: "Device Name",
deviceNameHelper: "Please enter the device name used to identify the device",
passkeyRegisterHelper: "Site domain change will invalidate passkey",
userInfo: "User Info",
};
@@ -727,7 +727,7 @@ export default {
paymentSetting: "Payment Settings",
captchaSetting: "Captcha Setting",
pipelineSetting: "Pipeline Settings",
oauthSetting: "OAuth2 Settings",
oauthSetting: "Login Settings",
networkSetting: "Network Settings",
adminModeSetting: "Admin Mode Settings",
adminModeHelper: "enterprise mode : allow to create and manage pipelines, roles, users, etc.\n saas mode : only allow to create and manage pipelines",
@@ -769,7 +769,8 @@ export default {
oauthAutoRedirectHelper: "Whether to auto redirect to OAuth2 login when login (using the first enabled OAuth2 login type)",
oauthOnly: "OAuth2 Login Only",
oauthOnlyHelper: "Whether to only allow OAuth2 login, disable password login",
enablePasskey: "Enable Passkey Login",
passkeyEnabledHelper: "Whether to enable Passkey login",
email: {
templates: "Email Templates",
templateType: "Template Type",
@@ -96,7 +96,7 @@ export default {
nickName: "昵称",
phoneNumber: "手机号",
changePassword: "修改密码",
updateProfile: "修改个人信息",
updateProfile: "修改信息",
oauthLoginTitle: "其他登录方式",
oauthOnlyLoginTitle: "登录",
@@ -104,4 +104,5 @@ export default {
deviceName: "设备名称",
deviceNameHelper: "请输入当前设备名称,绑定多个时好做区分",
passkeyRegisterHelper: "1、站点域名变更会导致passkey失效;\n2、同一设备同一个用户绑定多次只有最后一次的有效,之前绑定的会失效,需要手动删除",
userInfo: "账号信息",
};
@@ -728,7 +728,7 @@ export default {
paymentSetting: "支付设置",
captchaSetting: "验证码设置",
pipelineSetting: "流水线设置",
oauthSetting: "第三方登录",
oauthSetting: "登录设置",
networkSetting: "网络设置",
adminModeSetting: "管理模式",
adminModeHelper: "企业管理模式: 企业内部使用,通过项目来隔离权限,流水线、授权数据属于项目。\nsaas模式:供外部用户注册使用,各个用户之间数据隔离,流水线、授权数据属于用户。",
@@ -780,7 +780,8 @@ export default {
oauthAutoRedirectHelper: "是否自动跳转第三方登录(使用第一个已启用的第三方登录类型)",
oauthOnly: "仅使用第三方登录",
oauthOnlyHelper: "是否仅使用第三方登录,关闭密码登录(注意:请务必在测试第三方登录功能正常后再开启,否则会导致无法登录)\n 如果无法登录,请访问 http://你的certd地址/#/login?oauthOnly=false 来临时关闭此模式",
enablePasskey: "启用Passkey",
passkeyEnabledHelper: "是否启用Passkey登录",
email: {
templates: "邮件模板",
templateType: "模板类型",
@@ -38,6 +38,7 @@ export type SysPublicSetting = {
passwordLoginEnabled?: boolean;
smsLoginEnabled?: boolean;
defaultLoginType?: string;
passkeyEnabled?: boolean;
selfServicePasswordRetrievalEnabled?: boolean;
limitUserPipelineCount?: number;
@@ -299,6 +299,9 @@ h6 {
font-size: 12px;
margin-top: 3px;
margin-bottom: 3px;
white-space: pre-wrap;
word-wrap: break-word;
word-break: break-word;
&.error {
color: #ff4d4f;
@@ -3,58 +3,122 @@
<template #header>
<div class="title">{{ t("certd.myInfo") }}</div>
</template>
<div class="p-10">
<a-descriptions title="" bordered :column="2">
<a-descriptions-item :label="t('authentication.username')">{{ userInfo.username }}</a-descriptions-item>
<a-descriptions-item :label="t('authentication.nickName')">{{ userInfo.nickName }}</a-descriptions-item>
<a-descriptions-item :label="t('authentication.avatar')">
<a-avatar v-if="userInfo.avatar" size="large" :src="userAvatar" style="background-color: #eee"> </a-avatar>
<a-avatar v-else size="large" style="background-color: #00b4f5">
{{ userInfo.username }}
</a-avatar>
</a-descriptions-item>
<a-descriptions-item :label="t('authentication.email')">{{ userInfo.email }}</a-descriptions-item>
<a-descriptions-item :label="t('authentication.phoneNumber')">{{ userInfo.phoneCode }}{{ userInfo.mobile }}</a-descriptions-item>
<a-descriptions-item label="角色">
<fs-values-format :model-value="userInfo.roleIds" :dict="roleDict" />
</a-descriptions-item>
<a-descriptions-item v-if="settingStore.sysPublic.oauthEnabled && settingStore.isPlus" label="第三方账号绑定">
<div class="profile-container md:p-8">
<div class="profile-card md:rounded">
<div class="card-header">
<div class="header-bg-gradient"></div>
<div class="header-content">
<div class="avatar-wrapper">
<a-avatar v-if="userInfo.avatar" :size="100" :src="userAvatar" class="user-avatar"> </a-avatar>
<a-avatar v-else size="100" class="user-avatar default-avatar">
{{ userInfo.username }}
</a-avatar>
<!-- <div class="status-indicator"></div> -->
</div>
<div class="user-info">
<h2 class="user-name flex items-center">
{{ userInfo.nickName }}
<fs-values-format :model-value="userInfo.roleIds" :dict="roleDict" color="blue" />
</h2>
<div class="user-details">
<a-tag color="blue" class="detail-tag">
<span class="tag-icon">👤</span>
{{ userInfo.username }}
</a-tag>
<a-tag v-if="userInfo.email" color="green" class="detail-tag">
<span class="tag-icon">📧</span>
{{ userInfo.email }}
</a-tag>
<a-tag v-if="userInfo.mobile" color="purple" class="detail-tag">
<span class="tag-icon">📱</span>
{{ userInfo.mobile }}
</a-tag>
</div>
</div>
<div class="action-buttons">
<a-button type="primary" class="action-btn" @click="doUpdate">
{{ t("authentication.updateProfile") }}
</a-button>
<change-password-button class="ml-10" :show-button="true" />
</div>
</div>
</div>
</div>
<div v-if="settingStore.sysPublic.oauthEnabled && settingStore.isPlus" class="bindings-card md:rounded">
<div class="card-title">
<fs-icon icon="ion:link-outline" class="title-icon" />
<span>第三方账号绑定</span>
</div>
<div class="bindings-list">
<template v-for="item in computedOauthBounds" :key="item.name">
<div v-if="item.addonId" class="flex items-center gap-2 mb-2">
<fs-icon :icon="item.icon" class="mr-2 text-blue-500 w-5 flex justify-center items-center" />
<span class="mr-2 w-36">{{ item.title }}</span>
<a-button v-if="item.bound" type="primary" danger @click="unbind(item.name)">解绑</a-button>
<a-button v-else type="primary" @click="bind(item.name)">绑定</a-button>
<div v-if="item.addonId" class="binding-item">
<div class="binding-icon">
<fs-icon :icon="item.icon" class="icon" />
</div>
<div class="binding-info">
<span class="binding-name">{{ item.title }}</span>
<span class="binding-status" :class="item.bound ? 'bound' : 'unbound'">
{{ item.bound ? "已绑定" : "未绑定" }}
</span>
</div>
<a-button v-if="item.bound" type="primary" danger class="action-btn" @click="unbind(item.name)">
<template #icon><fs-icon icon="ion:unlink-outline" /></template>
解绑
</a-button>
<a-button v-else type="primary" class="action-btn" @click="bind(item.name)">
<template #icon><fs-icon icon="ion:link-outline" /></template>
绑定
</a-button>
</div>
</template>
</a-descriptions-item>
<a-descriptions-item label="Passkey">
<div v-if="passkeys.length > 0" class="flex flex-col gap-2">
<div v-for="passkey in passkeys" :key="passkey.id" class="flex items-center gap-4 p-2 border-b">
<fs-icon icon="ion:finger-print" class="text-blue-500 fs-24" />
<span class="w-40 truncate" :title="passkey.passkeyId">{{ passkey.deviceName }}</span>
<span>
<div class="text-sm text-gray-500">注册时间{{ formatDate(passkey.registeredAt) }}</div>
<div class="text-sm text-gray-500">最后使用{{ formatDate(passkey.updateTime) }}</div>
</span>
<div v-if="computedOauthBounds.length === 0" class="empty-text">暂无可用的第三方账号绑定</div>
</div>
</div>
<a-button type="primary" danger @click="unbindPasskey(passkey.id)">解绑</a-button>
<div v-if="settingStore.sysPublic.passkeyEnabled && settingStore.isPlus" class="passkey-card md:rounded">
<div class="card-title">
<fs-icon icon="ion:finger-print" class="title-icon" />
<span>Passkey 安全密钥</span>
</div>
<div class="passkey-list">
<div v-for="passkey in passkeys" :key="passkey.id" class="passkey-item">
<div class="passkey-icon">
<fs-icon icon="ion:finger-print" class="icon" />
</div>
<div class="passkey-info">
<div class="passkey-name">{{ passkey.deviceName }}</div>
<div class="passkey-meta flex items-center">
<span class="meta-item flex items-center">
<fs-icon icon="ion:calendar-outline" class="meta-icon" />
{{ formatDate(passkey.registeredAt) }}
</span>
<span class="meta-item flex items-center">
<fs-icon icon="ion:time-outline" class="meta-icon" />
最近使用<fs-time-humanize :model-value="passkey.updateTime" />
</span>
</div>
</div>
<a-button type="primary" danger class="remove-btn" @click="unbindPasskey(passkey.id)">
<template #icon><fs-icon icon="ion:trash-outline" /></template>
移除
</a-button>
</div>
<div v-else class="text-gray-500">暂无Passkey</div>
<a-button v-if="passkeySupported" type="primary" class="mt-2" @click="registerPasskey">注册Passkey</a-button>
<div v-if="!passkeySupported" class="text-red-500 text-sm mt-2">
{{ t("authentication.passkeyNotSupported") }}
</div>
<pre class="helper"
>{{ t("authentication.passkeyRegisterHelper") }}
</pre>
</a-descriptions-item>
<a-descriptions-item :label="t('common.handle')">
<a-button type="primary" @click="doUpdate">{{ t("authentication.updateProfile") }}</a-button>
<change-password-button class="ml-10" :show-button="true"> </change-password-button>
</a-descriptions-item>
</a-descriptions>
</div>
<div v-if="passkeys.length === 0" class="empty-state">
<fs-icon icon="ion:finger-print" class="empty-icon" />
<p class="empty-text">暂无Passkey</p>
</div>
<div v-if="!passkeySupported" class="warning-box">
<fs-icon icon="ion:warning-outline" class="warning-icon" />
<span>{{ t("authentication.passkeyNotSupported") }}</span>
</div>
<a-button v-if="passkeySupported" type="primary" class="add-btn" @click="registerPasskey">
<template #icon><fs-icon icon="ion:add-circle-outline" /></template>
注册新的Passkey
</a-button>
<pre class="helper">{{ t("authentication.passkeyRegisterHelper") }}</pre>
</div>
</div>
</fs-page>
</template>
@@ -278,3 +342,380 @@ onMounted(async () => {
checkPasskeySupport();
});
</script>
<style lang="less">
.page-user-profile {
:deep(.ant-descriptions-item-label) {
font-weight: 500;
color: rgba(0, 0, 0, 0.85);
}
}
.profile-container {
display: flex;
flex-direction: column;
gap: 20px;
max-width: 1000px;
.profile-card,
.bindings-card,
.passkey-card {
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
overflow: hidden;
transition: all 0.3s ease;
}
.bindings-card,
.passkey-card {
padding: 18px;
}
.profile-card:hover,
.bindings-card:hover,
.passkey-card:hover {
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
transform: translateY(-2px);
}
.card-header {
position: relative;
padding: 40px 30px;
}
.header-bg-gradient {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
opacity: 0.08;
}
.header-content {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 30px;
}
.avatar-wrapper {
position: relative;
flex-shrink: 0;
}
.user-avatar {
border: 4px solid #ffffff;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
}
.status-indicator {
position: absolute;
bottom: 8px;
right: 8px;
width: 16px;
height: 16px;
background: #52c41a;
border: 3px solid #ffffff;
border-radius: 50%;
}
.user-info {
flex: 1;
min-width: 0;
}
.user-name {
margin: 0 0 12px 0;
font-size: 24px;
font-weight: 600;
color: #2c3e50;
display: flex;
align-items: center;
gap: 10px;
}
.user-details {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.detail-tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 20px;
font-size: 13px;
}
.tag-icon {
font-size: 14px;
}
.action-buttons {
display: flex;
gap: 10px;
align-items: center;
}
.action-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 20px;
font-weight: 500;
border-radius: 8px;
transition: all 0.3s ease;
}
.action-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.card-title {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 2px solid #f0f0f0;
}
.title-icon {
font-size: 20px;
color: #667eea;
}
.bindings-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.binding-item {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background: #ffffff;
border-radius: 12px;
border: 1px solid #f0f0f0;
transition: all 0.3s ease;
}
.binding-item:hover {
border-color: #667eea;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.1);
}
.binding-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 10px;
}
.binding-icon .icon {
font-size: 20px;
color: #ffffff;
}
.binding-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.binding-name {
font-size: 16px;
font-weight: 500;
color: #2c3e50;
}
.binding-status {
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
font-weight: 500;
}
.binding-status.bound {
background: #e6fffa;
color: #38a169;
}
.binding-status.unbound {
background: #fffaf0;
color: #d69e2e;
}
.passkey-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 20px;
}
.passkey-item {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background: #ffffff;
border-radius: 12px;
border: 1px solid #f0f0f0;
transition: all 0.3s ease;
}
.passkey-item:hover {
border-color: #667eea;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.1);
}
.passkey-icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
border-radius: 12px;
}
.passkey-icon .icon {
font-size: 24px;
color: #ffffff;
}
.passkey-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
}
.passkey-name {
font-size: 16px;
font-weight: 600;
color: #2c3e50;
}
.passkey-meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 13px;
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
color: #6b7280;
}
.meta-icon {
font-size: 14px;
color: #9ca3af;
}
.remove-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: 8px;
transition: all 0.3s ease;
}
.remove-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.2);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
color: #9ca3af;
}
.empty-icon {
font-size: 48px;
margin-bottom: 12px;
opacity: 0.5;
}
.empty-text {
margin: 0;
font-size: 14px;
}
.warning-box {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: #fff7ed;
border: 1px solid #fed7d7;
border-radius: 8px;
margin-bottom: 16px;
}
.warning-icon {
font-size: 18px;
color: #f56565;
}
.add-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 20px;
border-radius: 8px;
transition: all 0.3s ease;
}
.add-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.helper {
background: #f8f9fa;
padding: 12px 16px;
border-radius: 8px;
font-size: 13px;
color: #6b7280;
border: 1px solid #e5e7eb;
margin-top: 16px;
}
@media (max-width: 768px) {
.header-content {
flex-direction: column;
text-align: center;
}
.user-details {
justify-content: center;
}
.action-buttons {
justify-content: center;
width: 100%;
}
}
}
</style>
@@ -1,6 +1,12 @@
<template>
<div class="sys-settings-form sys-settings-oauth">
<a-form :model="formState" name="register" :label-col="{ span: 8 }" :wrapper-col="{ span: 16 }" autocomplete="off" @finish="onFinish">
<a-form-item :label="t('certd.sys.setting.enablePasskey')" :name="['public', 'passkeyEnabled']">
<div class="flex-o">
<a-switch v-model:checked="formState.public.passkeyEnabled" :disabled="!settingsStore.isPlus" :title="t('certd.plusFeature')" />
<vip-button class="ml-5" mode="button"></vip-button>
</div>
</a-form-item>
<a-form-item :label="t('certd.sys.setting.enableOauth')" :name="['public', 'oauthEnabled']">
<div class="flex-o">
<a-switch v-model:checked="formState.public.oauthEnabled" :disabled="!settingsStore.isPlus" :title="t('certd.plusFeature')" />
@@ -14,7 +14,7 @@ export class SysAddonController extends AddonController {
async getProjectUserId(permission:string){
return {
projectId:null,userId:0 //0为系统级别
projectId:null,userId:0 //0为系统级别
}
}
getUserId() {