@@ -0,0 +1,203 @@
< script setup lang = "ts" >
import { onMounted , reactive , ref } from 'vue'
import { useRoute , useRouter } from 'vue-router'
import http , { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
const route = useRoute ( )
const router = useRouter ( )
const auth = useAuthStore ( )
const loading = ref ( true )
const actionLoading = ref ( false )
const sso = reactive ( {
enabled : false ,
providerDisplayName : '学校统一身份认证' ,
isBound : false ,
callbackUrl : '' ,
} )
const ssoErrors : Record < string , string > = {
binding _intent _expired : '绑定请求已失效,请重新发起绑定。' ,
identity _already _bound : '该统一身份账号已经绑定其他教务系统账号。' ,
account _already _bound : '当前教务系统账号已经绑定其他统一身份账号。' ,
account _disabled : '当前教务系统账号已停用或锁定。' ,
account _link _failed : '统一身份账户绑定失败,请重新尝试。' ,
configuration _error : '统一身份认证回调地址配置不正确,请联系管理员。' ,
}
async function loadAccount ( ) {
loading . value = true
try {
const { data } = await http . get ( '/auth/sso/account' )
sso . enabled = Boolean ( data . enabled )
sso . providerDisplayName = String ( data . providerDisplayName || sso . providerDisplayName )
sso . isBound = Boolean ( data . isBound )
sso . callbackUrl = String ( data . callbackUrl || '' )
} catch ( error ) {
ElMessage . error ( apiErrorMessage ( error ) )
} finally {
loading . value = false
}
}
async function startBinding ( ) {
actionLoading . value = true
try {
const { data } = await http . post ( '/auth/sso/prepare-binding' )
window . location . assign ( String ( data . loginUrl ) )
} catch ( error ) {
ElMessage . error ( apiErrorMessage ( error ) )
actionLoading . value = false
}
}
async function unbind ( ) {
try {
const result = await ElMessageBox . prompt (
'解绑后将不能再使用当前统一身份账号登录。请输入教务系统密码确认。' ,
'解除统一身份绑定' ,
{
inputType : 'password' ,
inputPlaceholder : '请输入教务系统密码' ,
inputValidator : ( value ) => Boolean ( value ) || '密码不能为空' ,
confirmButtonText : '确认解绑' ,
cancelButtonText : '取消' ,
type : 'warning' ,
} ,
)
actionLoading . value = true
await http . post ( '/auth/sso/unbind' , { password : result . value } )
ElMessage . success ( '统一身份账户已解绑。' )
await loadAccount ( )
} catch ( error ) {
if ( error === 'cancel' || error === 'close' ) return
ElMessage . error ( apiErrorMessage ( error ) )
} finally {
actionLoading . value = false
}
}
onMounted ( async ( ) => {
const ssoError = String ( route . query . ssoError ? ? '' )
if ( ssoError ) {
ElMessage . error ( ssoErrors [ ssoError ] ? ? '统一身份账户绑定失败,请重新尝试。' )
await router . replace ( '/account' )
}
await loadAccount ( )
} )
< / script >
< template >
< section class = "account-page" >
< header class = "account-heading" >
< div >
< span class = "eyebrow" > ACCOUNT & amp ; SECURITY < / span >
< h1 > 个人账户 < / h1 >
< p > 管理您的登录身份与单点登录绑定 。 < / p >
< / div >
< div class = "account-avatar" > { { auth . user ? . displayName ? . slice ( 0 , 1 ) ? ? '用' } } < / div >
< / header >
< div class = "account-grid" >
< article class = "account-card identity-card" >
< div class = "card-title" >
< div >
< span > 基本信息 < / span >
< h2 > { { auth . user ? . displayName } } < / h2 >
< / div >
< el-tag type = "success" effect = "light" > 账号已启用 < / el-tag >
< / div >
< dl >
< div >
< dt > 登录账号 < / dt >
< dd > { { auth . user ? . userName } } < / dd >
< / div >
< div >
< dt > 系统角色 < / dt >
< dd class = "role-list" >
< el-tag v-for = "role in auth.user?.roles" :key="role" effect="plain" > {{ role }} < / el -tag >
< / dd >
< / div >
< div >
< dt > 数据范围 < / dt >
< dd > { { auth . user ? . effectiveDataScope } } < / dd >
< / div >
< / dl >
< / article >
< article v-loading = "loading" class="account-card sso-card" >
< div class = "card-title" >
< div >
< span > 单点登录 < / span >
< h2 > { { sso . providerDisplayName } } < / h2 >
< / div >
< el-tag v-if = "sso.isBound" type="success" > 已绑定 < / el -tag >
< el-tag v-else-if = "sso.enabled" type="info" > 未绑定 < / el -tag >
< el-tag v-else type = "warning" > 未启用 < / el-tag >
< / div >
< p v-if = "sso.isBound" class="sso-description" >
当前教务系统账号已经关联统一身份认证 。 您可以直接通过 Keycloak 登录 。
< / p >
< p v-else-if = "sso.enabled" class="sso-description" >
绑定后 , 即使统一身份用户名与教务系统账号不同 , 也可以直接登录当前账号 。
< / p >
< p v-else class = "sso-description" > 管理员尚未启用统一身份认证 。 < / p >
< el-button
v-if = "sso.enabled && !sso.isBound"
type = "primary"
:loading = "actionLoading"
@click ="startBinding"
>
绑定统一身份账户
< / el-button >
< el-button
v-else-if = "sso.isBound"
type = "danger"
plain
:loading = "actionLoading"
@click ="unbind"
>
解除绑定
< / el-button >
< details v-if = "sso.enabled && sso.callbackUrl" class="callback-details" >
< summary > 管理员配置参考 < / summary >
< p > Keycloak Valid redirect URI 必须与下列地址完全一致 : < / p >
< code > { { sso . callbackUrl } } < / code >
< / details >
< / article >
< / div >
< / section >
< / template >
< style scoped >
. account - page { display : grid ; gap : 22 px ; }
. account - heading { display : flex ; align - items : center ; justify - content : space - between ; padding : 26 px 30 px ; border - radius : 18 px ; background : linear - gradient ( 125 deg , # 173 a50 , # 176 b87 65 % , # 2 e9b91 ) ; color : white ; box - shadow : 0 18 px 45 px rgba ( 23 , 58 , 80 , .18 ) ; }
. account - heading h1 { margin : 7 px 0 5 px ; font - family : "STZhongsong" , "Songti SC" , serif ; font - size : 30 px ; }
. account - heading p { margin : 0 ; color : rgba ( 255 , 255 , 255 , .72 ) ; }
. account - heading . eyebrow { color : # 8 ee4d8 ; }
. account - avatar { display : grid ; place - items : center ; width : 68 px ; height : 68 px ; border : 1 px solid rgba ( 255 , 255 , 255 , .34 ) ; border - radius : 22 px ; background : rgba ( 255 , 255 , 255 , .12 ) ; font - size : 28 px ; font - weight : 700 ; }
. account - grid { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; gap : 20 px ; }
. account - card { min - width : 0 ; min - height : 310 px ; padding : 28 px ; border : 1 px solid # e2e8ee ; border - radius : 16 px ; background : white ; box - shadow : 0 12 px 34 px rgba ( 35 , 57 , 78 , .07 ) ; }
. card - title { display : flex ; align - items : flex - start ; justify - content : space - between ; gap : 16 px ; padding - bottom : 20 px ; border - bottom : 1 px solid # edf0f3 ; }
. card - title span { color : # 73808 d ; font - size : 12 px ; }
. card - title h2 { margin : 6 px 0 0 ; color : # 263445 ; font - size : 21 px ; }
dl { margin : 6 px 0 0 ; }
dl > div { display : grid ; grid - template - columns : 100 px 1 fr ; gap : 18 px ; padding : 16 px 0 ; border - bottom : 1 px solid # f0f2f4 ; }
dt { color : # 7 b8794 ; font - size : 13 px ; }
dd { margin : 0 ; color : # 263445 ; font - weight : 650 ; }
. role - list { display : flex ; flex - wrap : wrap ; gap : 7 px ; }
. sso - description { min - height : 54 px ; margin : 22 px 0 ; color : # 65717 e ; line - height : 1.7 ; }
. callback - details { margin - top : 24 px ; color : # 65717 e ; font - size : 12 px ; }
. callback - details summary { cursor : pointer ; color : # 315 b73 ; font - weight : 650 ; }
. callback - details p { margin : 10 px 0 7 px ; }
. callback - details code { display : block ; overflow - wrap : anywhere ; padding : 10 px 12 px ; border - radius : 8 px ; background : # f4f7f9 ; color : # 176 b87 ; }
@ media ( max - width : 1100 px ) {
. account - grid { grid - template - columns : 1 fr ; }
. account - heading { padding : 22 px ; }
. account - avatar { width : 56 px ; height : 56 px ; border - radius : 18 px ; }
. account - card { min - height : 0 ; padding : 22 px ; }
}
< / style >