feat: isolate external application conversations

This commit is contained in:
2026-08-03 18:57:19 +08:00
parent 26109648af
commit cd3c875106
26 changed files with 843 additions and 110 deletions

View File

@@ -6,6 +6,7 @@ import type {
AiLogRecord,
ChatRecord,
QuestionInsightSummary,
SsoClientItem,
} from "../types/api";
import AdminPagination from "./AdminPagination.vue";
import AiLogDetailDrawer from "./AiLogDetailDrawer.vue";
@@ -15,6 +16,7 @@ type RecordTab = "chats" | "questionInsights" | "aiLogs" | "operationLogs";
const loading = ref(false);
const activeTab = ref<RecordTab>("chats");
const chats = ref<ChatRecord[]>([]);
const ssoClients = ref<SsoClientItem[]>([]);
const aiLogs = ref<AiLogRecord[]>([]);
const operationLogs = ref<Record<string, unknown>[]>([]);
const insights = ref<QuestionInsightSummary | null>(null);
@@ -32,6 +34,8 @@ const chatFilters = reactive({
keyword: "",
userId: undefined as number | undefined,
status: "",
sourceType: "",
sourceClientId: undefined as number | undefined,
dateFrom: "",
dateTo: "",
});
@@ -42,7 +46,9 @@ const insightFilters = reactive({
maxMessages: 5000,
});
onMounted(() => loadTab("chats"));
onMounted(async () => {
await Promise.all([loadTab("chats"), loadSsoClients()]);
});
watch(activeTab, (tab) => loadTab(tab));
async function loadTab(tab: RecordTab = activeTab.value) {
@@ -91,6 +97,13 @@ async function loadTab(tab: RecordTab = activeTab.value) {
loading.value = false;
}
}
async function loadSsoClients() {
try {
ssoClients.value = await api.ssoClients();
} catch {
ssoClients.value = [];
}
}
async function changePage(tab: RecordTab, page: number, pageSize: number) {
Object.assign(pagers[tab], { page, pageSize });
@@ -123,6 +136,8 @@ async function resetChatFilters() {
keyword: "",
userId: undefined,
status: "",
sourceType: "",
sourceClientId: undefined,
dateFrom: "",
dateTo: "",
});
@@ -173,6 +188,8 @@ function buildChatQuery() {
keyword: chatFilters.keyword,
userId: chatFilters.userId,
status: chatFilters.status,
sourceType: chatFilters.sourceType,
sourceClientId: chatFilters.sourceClientId,
dateFrom: formatDateTime(chatFilters.dateFrom, "start"),
dateTo: formatDateTime(chatFilters.dateTo, "end"),
};
@@ -220,6 +237,13 @@ function formatMoney(value?: number | null, currency = "CNY") {
label="已停止"
value="STOPPED" /><el-option label="失败" value="FAILED"
/></el-select>
<el-select v-model="chatFilters.sourceType" placeholder="会话来源" clearable @change="chatFilters.sourceClientId = undefined">
<el-option label="直接访问" value="direct" />
<el-option label="第三方应用" value="sso" />
</el-select>
<el-select v-if="chatFilters.sourceType === 'sso'" v-model="chatFilters.sourceClientId" placeholder="来源应用" clearable>
<el-option v-for="client in ssoClients" :key="client.id" :label="client.name" :value="client.id" />
</el-select>
<div class="record-date-range" aria-label="时间范围">
<label class="record-date-field"
><span>开始时间</span
@@ -257,6 +281,11 @@ function formatMoney(value?: number | null, currency = "CNY") {
prop="userName"
label="用户"
width="120"
/><el-table-column
prop="sourceName"
label="会话来源"
min-width="160"
show-overflow-tooltip
/><el-table-column
prop="title"
label="会话标题"

View File

@@ -3,12 +3,13 @@ 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 type { EntitlementPlan, SsoAuditItem, SsoClientItem, SsoIdentityItem } from "../types/api";
import AdminPagination from "./AdminPagination.vue";
const loading = ref(false);
const savingUrl = ref(false);
const clients = ref<SsoClientItem[]>([]);
const entitlementPlans = ref<EntitlementPlan[]>([]);
const userClientUrl = ref("");
const activeTab = ref("applications");
const clientDialogOpen = ref(false);
@@ -35,6 +36,8 @@ const clientForm = reactive({
appId: "",
name: "",
redirectUrisText: "",
allowAutoRegister: false,
defaultEntitlementPlanId: undefined as number | undefined,
status: 1,
});
@@ -45,9 +48,14 @@ onMounted(loadOverview);
async function loadOverview() {
loading.value = true;
try {
const [config, clientList] = await Promise.all([api.ssoConfig(), api.ssoClients()]);
const [config, clientList, plans] = await Promise.all([
api.ssoConfig(),
api.ssoClients(),
api.entitlementPlans(false),
]);
userClientUrl.value = config.userClientUrl;
clients.value = clientList;
entitlementPlans.value = plans;
} catch (error) {
showError(error, "应用接入配置加载失败");
} finally {
@@ -70,7 +78,14 @@ async function savePublicUrl() {
function openCreateDialog() {
editingClientId.value = null;
Object.assign(clientForm, { appId: "", name: "", redirectUrisText: "", status: 1 });
Object.assign(clientForm, {
appId: "",
name: "",
redirectUrisText: "",
allowAutoRegister: false,
defaultEntitlementPlanId: undefined,
status: 1,
});
clientDialogOpen.value = true;
}
@@ -80,6 +95,8 @@ function openEditDialog(client: SsoClientItem) {
appId: client.appId,
name: client.name,
redirectUrisText: client.redirectUris.join("\n"),
allowAutoRegister: client.allowAutoRegister,
defaultEntitlementPlanId: client.defaultEntitlementPlanId ?? undefined,
status: client.status,
});
clientDialogOpen.value = true;
@@ -92,16 +109,33 @@ async function saveClient() {
ElMessage.warning("请填写应用ID和应用名称");
return;
}
if (clientForm.allowAutoRegister && !clientForm.defaultEntitlementPlanId) {
ElMessage.warning("允许自动注册时请选择默认权益版本");
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 });
await api.updateSsoClient(editingClientId.value, {
name,
redirectUris,
allowAutoRegister: clientForm.allowAutoRegister,
defaultEntitlementPlanId: clientForm.defaultEntitlementPlanId ?? null,
status: clientForm.status,
});
ElMessage.success("接入应用已更新");
} else {
const result = await api.createSsoClient({ appId, name, redirectUris, status: clientForm.status });
const result = await api.createSsoClient({
appId,
name,
redirectUris,
allowAutoRegister: clientForm.allowAutoRegister,
defaultEntitlementPlanId: clientForm.defaultEntitlementPlanId ?? null,
status: clientForm.status,
});
revealedSecret.value = result.clientSecret || "";
revealedAppId.value = result.appId;
secretDialogOpen.value = true;
@@ -119,6 +153,8 @@ async function toggleClient(client: SsoClientItem) {
await api.updateSsoClient(client.id, {
name: client.name,
redirectUris: client.redirectUris,
allowAutoRegister: client.allowAutoRegister,
defaultEntitlementPlanId: client.defaultEntitlementPlanId ?? null,
status: targetStatus,
});
ElMessage.success(targetStatus === 1 ? "应用已启用" : "应用已停用");
@@ -272,6 +308,12 @@ function showError(error: unknown, fallback: string) {
<template #default="{ row }">{{ row.redirectUris.join("、") || "未配置" }}</template>
</el-table-column>
<el-table-column prop="identityCount" label="绑定用户" width="100" align="center" />
<el-table-column label="新用户策略" min-width="190">
<template #default="{ row }">
<span v-if="row.allowAutoRegister">自动注册 · {{ row.defaultEntitlementPlanName || "未配置权益" }}</span>
<span v-else>仅已有学员</span>
</template>
</el-table-column>
<el-table-column label="最近使用" width="180">
<template #default="{ row }">{{ formatTime(row.lastUsedAt) }}</template>
</el-table-column>
@@ -353,6 +395,18 @@ function showError(error: unknown, fallback: string) {
<el-form-item label="允许回跳地址">
<el-input v-model="clientForm.redirectUrisText" type="textarea" :rows="4" placeholder="每行一个完整 HTTPS 地址;不需要回跳时可以留空" />
</el-form-item>
<div class="sso-auto-register">
<div>
<strong>自动注册新学员</strong>
<p>可信应用传入本地尚不存在的已验证手机号和姓名时自动创建学员并分配默认权益</p>
</div>
<el-switch v-model="clientForm.allowAutoRegister" />
</div>
<el-form-item v-if="clientForm.allowAutoRegister" label="默认权益版本">
<el-select v-model="clientForm.defaultEntitlementPlanId" placeholder="请选择自动注册学员的权益" style="width: 100%">
<el-option v-for="plan in entitlementPlans" :key="plan.id" :label="plan.name" :value="plan.id" />
</el-select>
</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>
@@ -384,6 +438,8 @@ function showError(error: unknown, fallback: string) {
.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-auto-register { display: flex; align-items: center; justify-content: space-between; gap: 24px; margin-bottom: 18px; padding: 16px; border: 1px solid #dce8e4; border-radius: 12px; background: #f8fbfa; }
.sso-auto-register p { margin: 6px 0 0; color: #71817d; font-size: 13px; line-height: 1.6; }
.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; }

View File

@@ -369,6 +369,12 @@ async function deleteUser(row: AdminUser) {
width="150"
/><el-table-column prop="name" label="姓名" width="140" />
<el-table-column prop="nickname" label="昵称" width="140" />
<el-table-column label="注册来源" width="130">
<template #default="{ row }">
<el-tag v-if="row.registrationSource === 'sso'" type="info">第三方接入</el-tag>
<el-tag v-else>直接学员</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="130"
><template #default="{ row }"
><el-switch

View File

@@ -273,9 +273,9 @@ export const api = {
body: JSON.stringify({ userClientUrl }),
}),
ssoClients: () => request<SsoClientItem[]>("/admin/sso/client/list"),
createSsoClient: (payload: { appId: string; name: string; redirectUris: string[]; status: number }) =>
createSsoClient: (payload: { appId: string; name: string; redirectUris: string[]; allowAutoRegister: boolean; defaultEntitlementPlanId?: number | null; status: number }) =>
request<SsoClientItem>("/admin/sso/client", { method: "POST", body: JSON.stringify(payload) }),
updateSsoClient: (id: number, payload: { name: string; redirectUris: string[]; status: number }) =>
updateSsoClient: (id: number, payload: { name: string; redirectUris: string[]; allowAutoRegister: boolean; defaultEntitlementPlanId?: number | null; 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`, {

View File

@@ -23,6 +23,9 @@ export interface SsoClientItem {
appId: string;
name: string;
redirectUris: string[];
allowAutoRegister: boolean;
defaultEntitlementPlanId?: number | null;
defaultEntitlementPlanName?: string | null;
status: number;
identityCount: number;
lastUsedAt?: string | null;
@@ -143,6 +146,8 @@ export interface AdminUser {
phone: string;
name: string;
nickname?: string | null;
registrationSource: "direct" | "sso" | string;
registrationClientId?: number | null;
status: number;
dailyChatLimit: number;
dailyChatUsed: number;
@@ -484,6 +489,9 @@ export interface ChatRecord {
userId: number;
userPhone: string;
userName: string;
sourceType: "direct" | "sso";
sourceClientId?: number | null;
sourceName: string;
title: string;
messageCount: number;
lastMessageAt?: string | null;
@@ -715,6 +723,8 @@ export interface ChatRecordQuery {
keyword?: string;
userId?: number | null;
status?: string;
sourceType?: string;
sourceClientId?: number | null;
dateFrom?: string;
dateTo?: string;
page?: number;