feat: isolate external application conversations
This commit is contained in:
@@ -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="会话标题"
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user