feat: 增加可信应用免登录接入

This commit is contained in:
2026-08-03 18:28:40 +08:00
parent 59c306f88f
commit 26109648af
21 changed files with 1844 additions and 6 deletions

View File

@@ -44,6 +44,9 @@ const RetrievalLogView = defineAsyncComponent(
const UserManagementView = defineAsyncComponent(
() => import("./components/UserManagementView.vue"),
);
const SsoIntegrationView = defineAsyncComponent(
() => import("./components/SsoIntegrationView.vue"),
);
const admin = ref<AdminProfile | null>(null);
const activeMenu = ref("dashboard");
@@ -608,6 +611,12 @@ async function clearFeishuCache() {
>
系统配置
</button>
<button
:class="{ active: activeMenu === 'sso' }"
@click="switchMenu('sso')"
>
应用接入
</button>
<button
:class="{ active: activeMenu === 'records' }"
@click="switchMenu('records')"
@@ -1003,6 +1012,8 @@ async function clearFeishuCache() {
v-if="activeMenu === 'content-generation'"
/>
<SsoIntegrationView v-if="activeMenu === 'sso'" />
<template v-if="activeMenu === 'configs'">
<div class="page-head inline">
<div>

View File

@@ -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: 20px; }
.sso-public-url { display: grid; grid-template-columns: minmax(250px, 1fr) minmax(320px, 1.3fr) auto; gap: 18px; align-items: center; padding: 20px 22px; border: 1px solid #dce8e4; border-radius: 16px; background: #fff; }
.sso-public-url p { margin: 6px 0 0; color: #71817d; font-size: 13px; }
.sso-row-actions { display: flex; justify-content: center; gap: 8px; white-space: nowrap; }
.sso-filters { display: flex; gap: 12px; margin-bottom: 16px; }
.sso-filters .el-select { width: 190px; }
.sso-filters .el-input { max-width: 360px; }
.sso-form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.sso-secret-block { display: grid; grid-template-columns: 90px minmax(0, 1fr); gap: 12px 16px; align-items: start; margin-top: 20px; }
.sso-secret-block label { color: #6c7d79; }
.sso-secret-block code { padding: 10px 12px; overflow-wrap: anywhere; border-radius: 8px; background: #f3f7f5; color: #173d34; }
@media (max-width: 980px) {
.sso-public-url { grid-template-columns: 1fr; }
.sso-filters { flex-wrap: wrap; }
}
</style>

View File

@@ -38,6 +38,9 @@ import type {
PromptHistoryItem,
QuestionInsightRefreshResult,
QuestionInsightSummary,
SsoAuditItem,
SsoClientItem,
SsoIdentityItem,
TopicSessionRecord,
TopicSummaryRecord,
} from "../types/api";
@@ -263,6 +266,27 @@ export const api = {
configs: () => request<SystemConfigItem[]>("/admin/config"),
saveConfig: (payload: Record<string, unknown>) =>
request<SystemConfigItem>("/admin/config", { method: "PUT", body: JSON.stringify(payload) }),
ssoConfig: () => request<{ userClientUrl: string }>("/admin/sso/config"),
saveSsoConfig: (userClientUrl: string) =>
request<{ userClientUrl: string }>("/admin/sso/config", {
method: "PUT",
body: JSON.stringify({ userClientUrl }),
}),
ssoClients: () => request<SsoClientItem[]>("/admin/sso/client/list"),
createSsoClient: (payload: { appId: string; name: string; redirectUris: string[]; status: number }) =>
request<SsoClientItem>("/admin/sso/client", { method: "POST", body: JSON.stringify(payload) }),
updateSsoClient: (id: number, payload: { name: string; redirectUris: string[]; status: number }) =>
request<SsoClientItem>(`/admin/sso/client/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
rotateSsoClientSecret: (id: number) =>
request<{ clientId: number; clientSecret: string }>(`/admin/sso/client/${id}/secret/rotate`, {
method: "POST",
body: "{}",
}),
ssoIdentities: (query: { clientId?: number; keyword?: string; page?: number; pageSize?: number } = {}) =>
request<PageResult<SsoIdentityItem>>(`/admin/sso/identity/list${queryString(query)}`),
unlinkSsoIdentity: (id: number) => request<null>(`/admin/sso/identity/${id}`, { method: "DELETE" }),
ssoAudits: (query: { clientId?: number; auditStatus?: string; page?: number; pageSize?: number } = {}) =>
request<PageResult<SsoAuditItem>>(`/admin/sso/audit/list${queryString(query)}`),
chats: (query: ChatRecordQuery = {}) => request<PageResult<ChatRecord>>(`/admin/chat/list${queryString(query)}`),
chatDetail: (sessionId: number, query: { messagePage?: number; messagePageSize?: number } = {}) =>
request<ChatDetail>(`/admin/chat/${sessionId}${queryString(query)}`),

View File

@@ -18,6 +18,46 @@ export interface AdminProfile {
status: number;
}
export interface SsoClientItem {
id: number;
appId: string;
name: string;
redirectUris: string[];
status: number;
identityCount: number;
lastUsedAt?: string | null;
createdAt: string;
updatedAt: string;
clientSecret?: string;
}
export interface SsoIdentityItem {
id: number;
clientId: number;
clientName: string;
appId: string;
userId: number;
userName: string;
phone: string;
externalUserId: string;
displayNameSnapshot?: string | null;
lastLoginAt?: string | null;
createdAt: string;
}
export interface SsoAuditItem {
id: number;
appId?: string | null;
clientName?: string | null;
userId?: number | null;
externalUserId?: string | null;
action: string;
status: string;
errorMessage?: string | null;
ip?: string | null;
createdAt: string;
}
export interface DashboardStats {
userCount: number;
sessionCount: number;