@@ -0,0 +1,394 @@
< script setup lang = "ts" >
import { ElMessage , ElMessageBox } from "element-plus" ;
import { computed , onMounted , reactive , ref } from "vue" ;
import { api } from "../services/api" ;
import type { SsoAuditItem , SsoClientItem , SsoIdentityItem } from "../types/api" ;
import AdminPagination from "./AdminPagination.vue" ;
const loading = ref ( false ) ;
const savingUrl = ref ( false ) ;
const clients = ref < SsoClientItem [ ] > ( [ ] ) ;
const userClientUrl = ref ( "" ) ;
const activeTab = ref ( "applications" ) ;
const clientDialogOpen = ref ( false ) ;
const editingClientId = ref < number | null > ( null ) ;
const secretDialogOpen = ref ( false ) ;
const revealedSecret = ref ( "" ) ;
const revealedAppId = ref ( "" ) ;
const identityItems = ref < SsoIdentityItem [ ] > ( [ ] ) ;
const identityTotal = ref ( 0 ) ;
const identityPage = ref ( 1 ) ;
const identityPageSize = ref ( 10 ) ;
const identityKeyword = ref ( "" ) ;
const identityClientId = ref < number | undefined > ( ) ;
const auditItems = ref < SsoAuditItem [ ] > ( [ ] ) ;
const auditTotal = ref ( 0 ) ;
const auditPage = ref ( 1 ) ;
const auditPageSize = ref ( 10 ) ;
const auditClientId = ref < number | undefined > ( ) ;
const auditStatus = ref ( "" ) ;
const clientForm = reactive ( {
appId : "" ,
name : "" ,
redirectUrisText : "" ,
status : 1 ,
} ) ;
const dialogTitle = computed ( ( ) => editingClientId . value ? "编辑接入应用" : "新增接入应用" ) ;
onMounted ( loadOverview ) ;
async function loadOverview ( ) {
loading . value = true ;
try {
const [ config , clientList ] = await Promise . all ( [ api . ssoConfig ( ) , api . ssoClients ( ) ] ) ;
userClientUrl . value = config . userClientUrl ;
clients . value = clientList ;
} catch ( error ) {
showError ( error , "应用接入配置加载失败" ) ;
} finally {
loading . value = false ;
}
}
async function savePublicUrl ( ) {
savingUrl . value = true ;
try {
const result = await api . saveSsoConfig ( userClientUrl . value . trim ( ) ) ;
userClientUrl . value = result . userClientUrl ;
ElMessage . success ( "用户端公网地址已保存" ) ;
} catch ( error ) {
showError ( error , "用户端公网地址保存失败" ) ;
} finally {
savingUrl . value = false ;
}
}
function openCreateDialog ( ) {
editingClientId . value = null ;
Object . assign ( clientForm , { appId : "" , name : "" , redirectUrisText : "" , status : 1 } ) ;
clientDialogOpen . value = true ;
}
function openEditDialog ( client : SsoClientItem ) {
editingClientId . value = client . id ;
Object . assign ( clientForm , {
appId : client . appId ,
name : client . name ,
redirectUrisText : client . redirectUris . join ( "\n" ) ,
status : client . status ,
} ) ;
clientDialogOpen . value = true ;
}
async function saveClient ( ) {
const appId = clientForm . appId . trim ( ) ;
const name = clientForm . name . trim ( ) ;
if ( ! appId || ! name ) {
ElMessage . warning ( "请填写应用ID和应用名称" ) ;
return ;
}
const redirectUris = clientForm . redirectUrisText
. split ( /\r?\n/ )
. map ( ( item ) => item . trim ( ) )
. filter ( Boolean ) ;
try {
if ( editingClientId . value ) {
await api . updateSsoClient ( editingClientId . value , { name , redirectUris , status : clientForm . status } ) ;
ElMessage . success ( "接入应用已更新" ) ;
} else {
const result = await api . createSsoClient ( { appId , name , redirectUris , status : clientForm . status } ) ;
revealedSecret . value = result . clientSecret || "" ;
revealedAppId . value = result . appId ;
secretDialogOpen . value = true ;
}
clientDialogOpen . value = false ;
await loadOverview ( ) ;
} catch ( error ) {
showError ( error , "接入应用保存失败" ) ;
}
}
async function toggleClient ( client : SsoClientItem ) {
const targetStatus = client . status ;
try {
await api . updateSsoClient ( client . id , {
name : client . name ,
redirectUris : client . redirectUris ,
status : targetStatus ,
} ) ;
ElMessage . success ( targetStatus === 1 ? "应用已启用" : "应用已停用" ) ;
} catch ( error ) {
client . status = targetStatus === 1 ? 0 : 1 ;
showError ( error , "应用状态更新失败" ) ;
}
}
async function rotateSecret ( client : SsoClientItem ) {
try {
await ElMessageBox . confirm (
` 更新后, ${ client . name } 使用的旧密钥会立即失效。确定继续吗? ` ,
"更新应用密钥" ,
{ type : "warning" , confirmButtonText : "确认更新" , cancelButtonText : "取消" } ,
) ;
const result = await api . rotateSsoClientSecret ( client . id ) ;
revealedSecret . value = result . clientSecret ;
revealedAppId . value = client . appId ;
secretDialogOpen . value = true ;
} catch ( error ) {
if ( error === "cancel" || error === "close" ) return ;
showError ( error , "应用密钥更新失败" ) ;
}
}
async function copySecret ( ) {
await navigator . clipboard . writeText ( revealedSecret . value ) ;
ElMessage . success ( "应用密钥已复制" ) ;
}
async function switchTab ( tabName : string | number ) {
activeTab . value = String ( tabName ) ;
if ( activeTab . value === "identities" ) await loadIdentities ( ) ;
if ( activeTab . value === "audits" ) await loadAudits ( ) ;
}
async function loadIdentities ( ) {
loading . value = true ;
try {
const page = await api . ssoIdentities ( {
clientId : identityClientId . value ,
keyword : identityKeyword . value . trim ( ) ,
page : identityPage . value ,
pageSize : identityPageSize . value ,
} ) ;
identityItems . value = page . items ;
identityTotal . value = page . total ;
} catch ( error ) {
showError ( error , "账号绑定加载失败" ) ;
} finally {
loading . value = false ;
}
}
async function searchIdentities ( ) {
identityPage . value = 1 ;
await loadIdentities ( ) ;
}
async function changeIdentityPage ( page : number , pageSize : number ) {
identityPage . value = page ;
identityPageSize . value = pageSize ;
await loadIdentities ( ) ;
}
async function unlinkIdentity ( item : SsoIdentityItem ) {
try {
await ElMessageBox . confirm (
` 解除后, ${ item . userName } 下次从 ${ item . clientName } 进入时需要重新核对手机号。 ` ,
"解除账号绑定" ,
{ type : "warning" , confirmButtonText : "解除绑定" , cancelButtonText : "取消" } ,
) ;
await api . unlinkSsoIdentity ( item . id ) ;
ElMessage . success ( "账号绑定已解除" ) ;
await Promise . all ( [ loadIdentities ( ) , loadOverview ( ) ] ) ;
} catch ( error ) {
if ( error === "cancel" || error === "close" ) return ;
showError ( error , "解除账号绑定失败" ) ;
}
}
async function loadAudits ( ) {
loading . value = true ;
try {
const page = await api . ssoAudits ( {
clientId : auditClientId . value ,
auditStatus : auditStatus . value ,
page : auditPage . value ,
pageSize : auditPageSize . value ,
} ) ;
auditItems . value = page . items ;
auditTotal . value = page . total ;
} catch ( error ) {
showError ( error , "登录审计加载失败" ) ;
} finally {
loading . value = false ;
}
}
async function searchAudits ( ) {
auditPage . value = 1 ;
await loadAudits ( ) ;
}
async function changeAuditPage ( page : number , pageSize : number ) {
auditPage . value = page ;
auditPageSize . value = pageSize ;
await loadAudits ( ) ;
}
function formatTime ( value ? : string | null ) {
if ( ! value ) return "-" ;
return new Date ( value ) . toLocaleString ( "zh-CN" , { hour12 : false } ) ;
}
function maskPhone ( phone : string ) {
return phone . replace ( /^(\d{3})\d{4}(\d{4})$/ , "$1****$2" ) ;
}
function showError ( error : unknown , fallback : string ) {
ElMessage . error ( error instanceof Error ? error . message : fallback ) ;
}
< / script >
< template >
< section v-loading = "loading" class="sso-page" >
< div class = "page-head inline" >
< div >
< h2 > 应用接入 < / h2 >
< p > 让已在其他可信应用登录的学员免验证码进入千问千答 , 并独立管理密钥 、 账号绑定和登录审计 。 < / p >
< / div >
< el-button type = "primary" @click ="openCreateDialog" > 新增接入应用 < / el -button >
< / div >
< section class = "sso-public-url" >
< div >
< strong > 用户端公网地址 < / strong >
< p > 服务端会使用这个地址生成免登录入口 , 例如 https : //qa.example.com。</p>
< / div >
< el-input v-model = "userClientUrl" placeholder="https://qa.example.com" clearable / >
< el-button :loading = "savingUrl" @click ="savePublicUrl" > 保存地址 < / el -button >
< / section >
< el-tabs :model-value = "activeTab" @ tab -change = " switchTab " >
< el-tab-pane label = "接入应用" name = "applications" >
< el-table :data = "clients" stripe empty -text = " 还没有接入应用 " >
< el-table-column prop = "name" label = "应用名称" min -width = " 180 " / >
< el-table-column prop = "appId" label = "应用ID" min -width = " 180 " / >
< el-table-column label = "回跳地址" min -width = " 240 " show -overflow -tooltip >
< template # default = "{ row }" > { { row . redirectUris . join ( "、" ) || "未配置" } } < / template >
< / el-table-column >
< el-table-column prop = "identityCount" label = "绑定用户" width = "100" align = "center" / >
< el-table-column label = "最近使用" width = "180" >
< template # default = "{ row }" > { { formatTime ( row . lastUsedAt ) } } < / template >
< / el-table-column >
< el-table-column label = "状态" width = "100" align = "center" >
< template # default = "{ row }" >
< el-switch v-model = "row.status" :active-value="1" :inactive-value="0" @change="toggleClient(row)" / >
< / template >
< / el-table-column >
< el-table-column label = "操作" width = "190" fixed = "right" align = "center" >
< template # default = "{ row }" >
< div class = "sso-row-actions" >
< el-button size = "small" @click ="openEditDialog(row)" > 编辑 < / el -button >
< el-button size = "small" @click ="rotateSecret(row)" > 更新密钥 < / el -button >
< / div >
< / template >
< / el-table-column >
< / el-table >
< / el-tab-pane >
< el-tab-pane label = "账号绑定" name = "identities" >
< div class = "sso-filters" >
< el-select v-model = "identityClientId" clearable placeholder="全部应用" >
< el -option v-for = "client in clients" :key="client.id" :label="client.name" :value="client.id" / >
< / el-select >
< el-input v-model = "identityKeyword" clearable placeholder="搜索姓名、手机号或外部用户ID" @keyup.enter="searchIdentities" / >
< el-button type = "primary" @click ="searchIdentities" > 查询 < / el -button >
< / div >
< el-table :data = "identityItems" stripe empty -text = " 暂无账号绑定 " >
< el-table-column prop = "clientName" label = "来源应用" min -width = " 150 " / >
< el-table-column prop = "userName" label = "学员" min -width = " 120 " / >
< el-table-column label = "手机号" width = "140" >
< template # default = "{ row }" > { { maskPhone ( row . phone ) } } < / template >
< / el-table-column >
< el-table-column prop = "externalUserId" label = "外部用户ID" min -width = " 190 " / >
< el-table-column label = "最近免登录" width = "180" >
< template # default = "{ row }" > { { formatTime ( row . lastLoginAt ) } } < / template >
< / el-table-column >
< el-table-column label = "操作" width = "120" align = "center" >
< template # default = "{ row }" > < el-button size = "small" type = "danger" plain @click ="unlinkIdentity(row)" > 解除绑定 < / el -button > < / template >
< / el-table-column >
< / el-table >
< AdminPagination :page = "identityPage" :page-size = "identityPageSize" :total = "identityTotal" @change ="changeIdentityPage" / >
< / el-tab-pane >
< el-tab-pane label = "登录审计" name = "audits" >
< div class = "sso-filters" >
< el-select v-model = "auditClientId" clearable placeholder="全部应用" >
< el -option v-for = "client in clients" :key="client.id" :label="client.name" :value="client.id" / >
< / el-select >
< el-select v-model = "auditStatus" clearable placeholder="全部状态" >
< el -option label = "成功" value = "SUCCESS" / >
< el-option label = "失败" value = "FAILED" / >
< / el-select >
< el-button type = "primary" @click ="searchAudits" > 查询 < / el -button >
< / div >
< el-table :data = "auditItems" stripe empty -text = " 暂无登录审计 " >
< el-table-column prop = "clientName" label = "来源应用" min -width = " 150 " / >
< el-table-column prop = "externalUserId" label = "外部用户ID" min -width = " 190 " / >
< el-table-column label = "环节" width = "110" >
< template # default = "{ row }" > { { row . action === "ticket" ? "申请授权码" : "兑换登录态" } } < / template >
< / el-table-column >
< el-table-column label = "状态" width = "100" align = "center" >
< template # default = "{ row }" > < el-tag : type = "row.status === 'SUCCESS' ? 'success' : 'danger'" > { { row . status === "SUCCESS" ? "成功" : "失败" } } < / el-tag > < / template >
< / el-table-column >
< el-table-column prop = "errorMessage" label = "失败原因" min -width = " 220 " show -overflow -tooltip / >
< el-table-column prop = "ip" label = "请求IP" width = "140" / >
< el-table-column label = "时间" width = "180" > < template # default = "{ row }" > { { formatTime ( row . createdAt ) } } < / template > < / el-table-column >
< / el-table >
< AdminPagination :page = "auditPage" :page-size = "auditPageSize" :total = "auditTotal" @change ="changeAuditPage" / >
< / el-tab-pane >
< / el-tabs >
< el-dialog v-model = "clientDialogOpen" :title="dialogTitle" width="620px" destroy -on -close >
< el-form label -position = " top " >
< div class = "sso-form-grid" >
< el-form-item label = "应用名称" > < el-input v-model = "clientForm.name" placeholder="例如:学员服务 App" / > < / el-form-item >
< el-form-item label = "应用ID" > < el-input v-model = "clientForm.appId" :disabled="Boolean(editingClientId)" placeholder="例如: student-app" / > < / el-form-item >
< / div >
< el-form-item label = "允许回跳地址" >
< el-input v-model = "clientForm.redirectUrisText" type="textarea" :rows="4" placeholder="每行一个完整 HTTPS 地址;不需要回跳时可以留空" / >
< / el-form-item >
< el-form-item label = "启用应用" > < el-switch v-model = "clientForm.status" :active-value="1" :inactive-value="0" / > < / el-form-item >
< / el-form >
< template # footer >
< el-button @click ="clientDialogOpen = false" > 取消 < / el -button >
< el-button type = "primary" @click ="saveClient" > 保存 < / el -button >
< / template >
< / el-dialog >
< el-dialog v-model = "secretDialogOpen" title="请立即保存应用密钥" width="620px" :close-on-click-modal="false" >
< el -alert title = "密钥只在本次显示,关闭后无法再次查看。请保存到接入应用的服务端密钥管理中,不要放入前端代码。" type = "warning" :closable = "false" show -icon / >
< div class = "sso-secret-block" >
< label > 应用ID < / label > < code > { { revealedAppId } } < / code >
< label > 应用密钥 < / label > < code > { { revealedSecret } } < / code >
< / div >
< template # footer >
< el-button @click ="copySecret" > 复制密钥 < / el -button >
< el-button type = "primary" @click ="secretDialogOpen = false" > 我已保存 < / el -button >
< / template >
< / el-dialog >
< / section >
< / template >
< style scoped >
. sso - page { display : grid ; gap : 20 px ; }
. sso - public - url { display : grid ; grid - template - columns : minmax ( 250 px , 1 fr ) minmax ( 320 px , 1.3 fr ) auto ; gap : 18 px ; align - items : center ; padding : 20 px 22 px ; border : 1 px solid # dce8e4 ; border - radius : 16 px ; background : # fff ; }
. sso - public - url p { margin : 6 px 0 0 ; color : # 71817 d ; font - size : 13 px ; }
. sso - row - actions { display : flex ; justify - content : center ; gap : 8 px ; white - space : nowrap ; }
. sso - filters { display : flex ; gap : 12 px ; margin - bottom : 16 px ; }
. sso - filters . el - select { width : 190 px ; }
. sso - filters . el - input { max - width : 360 px ; }
. sso - form - grid { display : grid ; grid - template - columns : 1 fr 1 fr ; gap : 16 px ; }
. sso - secret - block { display : grid ; grid - template - columns : 90 px minmax ( 0 , 1 fr ) ; gap : 12 px 16 px ; align - items : start ; margin - top : 20 px ; }
. sso - secret - block label { color : # 6 c7d79 ; }
. sso - secret - block code { padding : 10 px 12 px ; overflow - wrap : anywhere ; border - radius : 8 px ; background : # f3f7f5 ; color : # 173 d34 ; }
@ media ( max - width : 980 px ) {
. sso - public - url { grid - template - columns : 1 fr ; }
. sso - filters { flex - wrap : wrap ; }
}
< / style >