0.11.2: 旗舰版第三轮完善(通用聊天/企微钉钉飞书扫码登录/个人安全策略)
- 门户通用聊天:选择已批准模型直接对话,审批通过后自动开通用户级运行时 API Key(加密落库,限额取批准值),聊天经受管网关统一认证/限流/配额/审计; 会话哈希链完整性 + busy 租约防并发,失败不落库。 - 扫码登录:identity_providers 扩展 wecom/dingtalk/feishu,管理端配置 (AppID/AppSecret/AgentID/回调/自动开户/默认部门),登录页自动展示; one-time state 防 CSRF,provider_uid 全局唯一防多账号绑定,平台端点 固定公网 URL 复用 public-only 拨号。 - 个人安全策略:账号安全页(登录设备管理/吊销非当前会话/登录提醒开关/ 扫码绑定解绑),登录成功发布 security.login_detected 事件按偏好落站内信 (新增 security 类别),会话索引只存令牌摘要并惰性清理。 - 迁移 000038-000041;修复 social update 参数越界/凭据回读/路由挂载缺失; 全量测试 25 包通过,前端 admin/portal 构建通过,端到端验证完成。
This commit is contained in:
@@ -160,3 +160,50 @@ export function createSAMLProvider(data: SAMLProviderInput) {
|
||||
export function updateSAMLProvider(id: string, data: SAMLProviderInput) {
|
||||
return request.put<SAMLProviderRecord>({ url: `/api/v1/admin/saml-providers/${id}`, params: data })
|
||||
}
|
||||
|
||||
export interface SocialProviderRecord {
|
||||
id: string
|
||||
code: string
|
||||
kind: 'wecom' | 'dingtalk' | 'feishu'
|
||||
display_name: string
|
||||
client_id: string
|
||||
agent_id: string
|
||||
secret_configured: boolean
|
||||
redirect_uri: string
|
||||
portal_return_url: string
|
||||
auto_provision: boolean
|
||||
default_department_id?: string
|
||||
enabled: boolean
|
||||
revision: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface SocialProviderInput {
|
||||
code: string
|
||||
display_name: string
|
||||
client_id: string
|
||||
agent_id?: string
|
||||
secret?: string
|
||||
redirect_uri: string
|
||||
portal_return_url: string
|
||||
auto_provision: boolean
|
||||
default_department_id?: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export function fetchSocialProviders() {
|
||||
return request.get<SocialProviderRecord[]>({ url: '/api/v1/admin/social-providers' })
|
||||
}
|
||||
|
||||
export function createSocialProvider(kind: string, data: SocialProviderInput) {
|
||||
return request.post<SocialProviderRecord>({ url: '/api/v1/admin/social-providers', params: { kind }, data })
|
||||
}
|
||||
|
||||
export function updateSocialProvider(kind: string, data: SocialProviderInput) {
|
||||
return request.put<SocialProviderRecord>({ url: `/api/v1/admin/social-providers/${kind}`, params: data })
|
||||
}
|
||||
|
||||
export function deleteSocialProvider(kind: string) {
|
||||
return request.del({ url: `/api/v1/admin/social-providers/${kind}` })
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<p class="text-g-500 mt-1 text-sm">管理员和门户账号统一管理,权限变更即时生效</p>
|
||||
</div>
|
||||
<ElButton type="primary" @click="openCreate">
|
||||
{{ activeTab === 'department' ? '新增部门' : activeTab === 'oidc' || activeTab === 'saml' ? '新增身份源' : '新增账号' }}
|
||||
{{ activeTab === 'department' ? '新增部门' : activeTab === 'oidc' || activeTab === 'saml' || activeTab === 'social' ? '新增身份源' : '新增账号' }}
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<ElTabPane label="部门" name="department" />
|
||||
<ElTabPane label="OIDC 身份源" name="oidc" />
|
||||
<ElTabPane label="SAML 身份源" name="saml" />
|
||||
<ElTabPane label="扫码登录" name="social" />
|
||||
</ElTabs>
|
||||
|
||||
<ElTable v-if="activeTab === 'admin' || activeTab === 'portal'" v-loading="loading" :data="records" row-key="id">
|
||||
@@ -101,6 +102,31 @@
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
|
||||
<ElTable v-else-if="activeTab === 'social'" v-loading="loading" :data="socialProviders" row-key="kind">
|
||||
<ElTableColumn label="平台" width="110">
|
||||
<template #default="{ row }">{{ socialKindName(row.kind) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="code" label="代码" min-width="120" />
|
||||
<ElTableColumn prop="display_name" label="名称" min-width="140" />
|
||||
<ElTableColumn prop="client_id" label="AppID" min-width="180" show-overflow-tooltip />
|
||||
<ElTableColumn v-if="socialProviders.some((p: SocialProviderRecord) => p.kind === 'wecom')" prop="agent_id" label="AgentID" min-width="110" />
|
||||
<ElTableColumn label="密钥" width="80">
|
||||
<template #default="{ row }"><ElTag :type="row.secret_configured ? 'success' : 'danger'">{{ row.secret_configured ? '已配置' : '缺失' }}</ElTag></template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="自动开户" width="100">
|
||||
<template #default="{ row }">{{ row.auto_provision ? '启用' : '关闭' }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="状态" width="90">
|
||||
<template #default="{ row }"><ElTag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '启用' : '停用' }}</ElTag></template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<ElButton link type="primary" @click="openSocialProviderEdit(row)">编辑</ElButton>
|
||||
<ElButton link type="danger" @click="removeSocialProvider(row)">删除</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
|
||||
<ElTable v-else v-loading="loading" :data="samlProviders" row-key="id">
|
||||
<ElTableColumn prop="code" label="代码" min-width="130" />
|
||||
<ElTableColumn prop="display_name" label="名称" min-width="150" />
|
||||
@@ -257,22 +283,62 @@
|
||||
<ElButton type="primary" :loading="saving" @click="submitSAMLProvider">保存</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
|
||||
<ElDialog v-model="socialDialogVisible" :title="socialEditingKind ? '编辑扫码登录' : '新增扫码登录'" width="720px">
|
||||
<ElAlert class="mb-4" type="info" :closable="false" title="在企业微信/钉钉/飞书开放平台创建应用后填写;回调地址需配置为下方「回调 URL」。" />
|
||||
<ElForm :model="socialForm" label-width="150px">
|
||||
<ElFormItem label="平台" required>
|
||||
<ElSelect v-model="socialForm.kind" class="w-full" :disabled="!!socialEditingKind">
|
||||
<ElOption label="企业微信" value="wecom" />
|
||||
<ElOption label="钉钉" value="dingtalk" />
|
||||
<ElOption label="飞书" value="feishu" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="代码" required><ElInput v-model="socialForm.code" :placeholder="`例如 corp_${socialForm.kind || 'wecom'}`" :disabled="!!socialEditingKind" /></ElFormItem>
|
||||
<ElFormItem label="显示名称" required><ElInput v-model="socialForm.display_name" :placeholder="socialKindName(socialForm.kind)" /></ElFormItem>
|
||||
<ElFormItem v-if="socialForm.kind === 'wecom'" label="CorpID" required><ElInput v-model="socialForm.client_id" placeholder="企业微信 CorpID" /></ElFormItem>
|
||||
<ElFormItem v-else-if="socialForm.kind === 'dingtalk'" label="AppKey" required><ElInput v-model="socialForm.client_id" placeholder="钉钉应用 AppKey" /></ElFormItem>
|
||||
<ElFormItem v-else label="AppID" required><ElInput v-model="socialForm.client_id" placeholder="飞书应用 AppID" /></ElFormItem>
|
||||
<ElFormItem v-if="socialForm.kind === 'wecom'" label="AgentID" required><ElInput v-model="socialForm.agent_id" placeholder="企业微信应用 AgentID" /></ElFormItem>
|
||||
<ElFormItem label="AppSecret" :required="!socialEditingKind">
|
||||
<ElInput v-model="socialForm.secret" type="password" show-password :placeholder="socialEditingKind ? '留空则不修改' : '应用 AppSecret'" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="回调 URL" required>
|
||||
<ElInput v-model="socialForm.redirect_uri" :placeholder="`https://你的域名/api/v1/portal/sso/${socialForm.code || 'corp_wecom'}/callback`" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="门户返回 URL" required><ElInput v-model="socialForm.portal_return_url" placeholder="例如 https://你的域名/#/auth/login" /></ElFormItem>
|
||||
<ElFormItem label="默认部门">
|
||||
<ElSelect v-model="socialForm.default_department_id" clearable class="w-full">
|
||||
<ElOption v-for="department in activeDepartments" :key="department.id" :label="department.name" :value="department.id" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="自动开户"><ElSwitch v-model="socialForm.auto_provision" /></ElFormItem>
|
||||
<ElFormItem label="启用"><ElSwitch v-model="socialForm.enabled" /></ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<ElButton @click="socialDialogVisible = false">取消</ElButton>
|
||||
<ElButton type="primary" :loading="saving" @click="submitSocialProvider">保存</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, FormInstance, FormRules, TabsPaneContext } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox, FormInstance, FormRules, TabsPaneContext } from 'element-plus'
|
||||
import {
|
||||
createDepartment,
|
||||
createIdentityProvider,
|
||||
createSAMLProvider,
|
||||
createIdentity,
|
||||
createSocialProvider,
|
||||
deleteSocialProvider,
|
||||
DepartmentInput,
|
||||
DepartmentRecord,
|
||||
fetchDepartments,
|
||||
fetchIdentityProviders,
|
||||
fetchSAMLProviders,
|
||||
fetchIdentities,
|
||||
fetchSocialProviders,
|
||||
IdentityInput,
|
||||
IdentityKind,
|
||||
IdentityRecord,
|
||||
@@ -280,10 +346,13 @@
|
||||
IdentityProviderRecord,
|
||||
SAMLProviderInput,
|
||||
SAMLProviderRecord,
|
||||
SocialProviderInput,
|
||||
SocialProviderRecord,
|
||||
updateDepartment,
|
||||
updateIdentityProvider,
|
||||
updateSAMLProvider,
|
||||
updateIdentity
|
||||
updateIdentity,
|
||||
updateSocialProvider
|
||||
} from '@/api/identities'
|
||||
|
||||
defineOptions({ name: 'User' })
|
||||
@@ -295,7 +364,7 @@
|
||||
'api_key:read',
|
||||
'api_key:manage'
|
||||
]
|
||||
type ManagementTab = IdentityKind | 'department' | 'oidc' | 'saml'
|
||||
type ManagementTab = IdentityKind | 'department' | 'oidc' | 'saml' | 'social'
|
||||
const activeTab = ref<ManagementTab>('admin')
|
||||
const records = ref<IdentityRecord[]>([])
|
||||
const loading = ref(false)
|
||||
@@ -305,12 +374,15 @@
|
||||
const departments = ref<DepartmentRecord[]>([])
|
||||
const identityProviders = ref<IdentityProviderRecord[]>([])
|
||||
const samlProviders = ref<SAMLProviderRecord[]>([])
|
||||
const socialProviders = ref<SocialProviderRecord[]>([])
|
||||
const departmentDialogVisible = ref(false)
|
||||
const departmentEditingId = ref('')
|
||||
const idpDialogVisible = ref(false)
|
||||
const idpEditingId = ref('')
|
||||
const samlDialogVisible = ref(false)
|
||||
const samlEditingId = ref('')
|
||||
const socialDialogVisible = ref(false)
|
||||
const socialEditingKind = ref('')
|
||||
const formRef = ref<FormInstance>()
|
||||
const departmentFormRef = ref<FormInstance>()
|
||||
const form = reactive<IdentityInput>({
|
||||
@@ -335,6 +407,11 @@
|
||||
portal_return_url: '', email_attribute: 'mail', name_attribute: 'cn',
|
||||
auto_provision: false, default_department_id: undefined, enabled: false
|
||||
})
|
||||
const socialForm = reactive<SocialProviderInput & { kind: string }>({
|
||||
kind: 'wecom', code: '', display_name: '', client_id: '', agent_id: '',
|
||||
secret: '', redirect_uri: '', portal_return_url: '',
|
||||
auto_provision: false, default_department_id: undefined, enabled: false
|
||||
})
|
||||
const activeDepartments = computed(() => departments.value.filter((item) => item.active))
|
||||
const availableParents = computed(() =>
|
||||
activeDepartments.value.filter((item) => item.id !== departmentEditingId.value)
|
||||
@@ -363,6 +440,7 @@
|
||||
if (activeTab.value === 'admin' || activeTab.value === 'portal') records.value = await fetchIdentities(activeTab.value)
|
||||
if (activeTab.value === 'oidc') identityProviders.value = await fetchIdentityProviders()
|
||||
if (activeTab.value === 'saml') samlProviders.value = await fetchSAMLProviders()
|
||||
if (activeTab.value === 'social') socialProviders.value = await fetchSocialProviders()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -399,6 +477,12 @@
|
||||
samlDialogVisible.value = true
|
||||
return
|
||||
}
|
||||
if (activeTab.value === 'social') {
|
||||
socialEditingKind.value = ''
|
||||
Object.assign(socialForm, { kind: 'wecom', code: '', display_name: '', client_id: '', agent_id: '', secret: '', redirect_uri: '', portal_return_url: '', auto_provision: false, default_department_id: undefined, enabled: false })
|
||||
socialDialogVisible.value = true
|
||||
return
|
||||
}
|
||||
editingId.value = ''
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
@@ -499,6 +583,55 @@
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
|
||||
function socialKindName(kind: string) {
|
||||
return ({ wecom: '企业微信', dingtalk: '钉钉', feishu: '飞书' } as Record<string, string>)[kind] || kind
|
||||
}
|
||||
|
||||
function openSocialProviderEdit(record: SocialProviderRecord) {
|
||||
socialEditingKind.value = record.kind
|
||||
Object.assign(socialForm, {
|
||||
kind: record.kind, code: record.code, display_name: record.display_name,
|
||||
client_id: record.client_id, agent_id: record.agent_id || '',
|
||||
secret: '', redirect_uri: record.redirect_uri, portal_return_url: record.portal_return_url,
|
||||
auto_provision: record.auto_provision, default_department_id: record.default_department_id, enabled: record.enabled
|
||||
})
|
||||
socialDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submitSocialProvider() {
|
||||
const form = socialForm
|
||||
if (!form.code || !form.display_name || !form.client_id || !form.redirect_uri || !form.portal_return_url || (form.kind === 'wecom' && !form.agent_id)) {
|
||||
ElMessage.warning('请填写所有必填扫码登录配置')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const payload: SocialProviderInput = {
|
||||
code: form.code, display_name: form.display_name, client_id: form.client_id,
|
||||
agent_id: form.agent_id, secret: form.secret || undefined,
|
||||
redirect_uri: form.redirect_uri, portal_return_url: form.portal_return_url,
|
||||
auto_provision: form.auto_provision, default_department_id: form.default_department_id, enabled: form.enabled
|
||||
}
|
||||
if (!payload.default_department_id) delete payload.default_department_id
|
||||
if (socialEditingKind.value) {
|
||||
if (!payload.secret) delete payload.secret
|
||||
await updateSocialProvider(socialEditingKind.value, payload)
|
||||
} else {
|
||||
await createSocialProvider(form.kind, payload)
|
||||
}
|
||||
ElMessage.success('扫码登录身份源保存成功')
|
||||
socialDialogVisible.value = false
|
||||
await load()
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
|
||||
async function removeSocialProvider(record: SocialProviderRecord) {
|
||||
await ElMessageBox.confirm(`删除后该平台的所有扫码绑定将失效,确定删除 ${socialKindName(record.kind)} 身份源?`, '删除身份源', { type: 'warning' })
|
||||
await deleteSocialProvider(record.kind)
|
||||
socialProviders.value = socialProviders.value.filter((item) => item.kind !== record.kind)
|
||||
ElMessage.success('已删除')
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!(await formRef.value?.validate())) return
|
||||
if (!editingId.value && (!form.password || form.password.length < 12)) {
|
||||
|
||||
@@ -65,3 +65,27 @@ export const fetchMarketplaceInstalled=()=>request.get<MarketItem[]>({url:'/api/
|
||||
export const fetchMarketplaceDetail=(type:string,code:string)=>request.get<{item:MarketItem;detail:Record<string,unknown>}>({url:`/api/v1/portal/marketplace/${type}/${code}`})
|
||||
export const marketplaceInstall=(type:string,code:string)=>request.post<{installed:boolean;created:boolean}>({url:`/api/v1/portal/marketplace/${type}/${code}/install`})
|
||||
export const marketplaceUninstall=(type:string,code:string)=>request.del<{installed:boolean}>({url:`/api/v1/portal/marketplace/${type}/${code}/install`})
|
||||
|
||||
// --- 通用聊天 ---
|
||||
export interface ChatModel { provider_code:string;model:string;approved_at:string }
|
||||
export interface ChatMessage { sequence:number;role:'user'|'assistant';content:string;created_at:string }
|
||||
export interface ChatSession { id:string;title:string;provider_code:string;model:string;status:string;messages?:ChatMessage[];created_at:string;updated_at:string }
|
||||
export const fetchChatModels=()=>request.get<ChatModel[]>({url:'/api/v1/portal/chat/models'})
|
||||
export const chatOnce=(params:{provider_code:string;model:string;message:string})=>request.post<Record<string,unknown>>({url:'/api/v1/portal/chat/completions',params})
|
||||
export const fetchChatSessions=()=>request.get<ChatSession[]>({url:'/api/v1/portal/chat/sessions'})
|
||||
export const createChatSession=(params:{provider_code:string;model:string})=>request.post<ChatSession>({url:'/api/v1/portal/chat/sessions',params})
|
||||
export const renameChatSession=(id:string,title:string)=>request.put<ChatSession>({url:`/api/v1/portal/chat/sessions/${id}`,params:{title}})
|
||||
export const deleteChatSession=(id:string)=>request.del({url:`/api/v1/portal/chat/sessions/${id}`})
|
||||
export const fetchChatSession=(id:string)=>request.get<ChatSession>({url:`/api/v1/portal/chat/sessions/${id}`})
|
||||
export const appendChatMessage=(id:string,message:string)=>request.post<Record<string,unknown>>({url:`/api/v1/portal/chat/sessions/${id}/messages`,params:{message}})
|
||||
|
||||
// --- 账号安全 ---
|
||||
export interface SessionView { id:string;ip:string;user_agent:string;issued_at:number;current:boolean }
|
||||
export interface ProviderBinding { kind:'wecom'|'dingtalk'|'feishu';provider_uid:string;created_at:string }
|
||||
export const fetchMySessions=()=>request.get<SessionView[]>({url:'/api/v1/portal/sessions'})
|
||||
export const revokeSession=(id:string)=>request.post({url:`/api/v1/portal/sessions/${id}/revoke`})
|
||||
export const fetchSecurityPrefs=()=>request.get<{login_notify:boolean}>({url:'/api/v1/portal/security/prefs'})
|
||||
export const setSecurityPrefs=(login_notify:boolean)=>request.put<{login_notify:boolean}>({url:'/api/v1/portal/security/prefs',params:{login_notify}})
|
||||
export const fetchProviderBindings=()=>request.get<ProviderBinding[]>({url:'/api/v1/portal/social/bindings'})
|
||||
export const startSocialBind=(kind:string)=>request.post<{redirect_url:string}>({url:`/api/v1/portal/social/${kind}/bind/start`})
|
||||
export const unbindSocial=(kind:string)=>request.del({url:`/api/v1/portal/social/${kind}/bind`})
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { HttpError } from '@/utils/http/error'
|
||||
import { exchangeSSOCode, fetchLogin, fetchSSOProviders, fetchTOTPLogin, SSOProvider } from '@/api/auth'
|
||||
import { ElMessageBox, ElNotification, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox, ElNotification, type FormInstance, type FormRules } from 'element-plus'
|
||||
|
||||
defineOptions({ name: 'Login' })
|
||||
|
||||
@@ -196,14 +196,21 @@
|
||||
}
|
||||
try {
|
||||
ssoProviders.value = await fetchSSOProviders()
|
||||
const ssoCode = route.query.sso_code as string
|
||||
// 回调 302 把参数放在 # 之前的 query(SPA hash 路由看不到),这里合并读取。
|
||||
const pathQuery = new URLSearchParams(window.location.search)
|
||||
const ssoCode = (route.query.sso_code as string) || pathQuery.get('sso_code') || ''
|
||||
const ssoError = (route.query.sso_error as string) || pathQuery.get('sso_error') || ''
|
||||
if (ssoCode) {
|
||||
loading.value = true
|
||||
const result = await exchangeSSOCode(ssoCode)
|
||||
if (!result.token) throw new Error('SSO exchange returned no token')
|
||||
userStore.setToken(result.token, result.refreshToken || '')
|
||||
userStore.setLoginStatus(true)
|
||||
history.replaceState(null, '', window.location.pathname + window.location.hash)
|
||||
await router.replace('/')
|
||||
} else if (ssoError) {
|
||||
ElMessage.error(ssoError)
|
||||
history.replaceState(null, '', window.location.pathname + window.location.hash)
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<div class="page-content">
|
||||
<div class="mb-5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">通用聊天</h2>
|
||||
<p class="text-g-500 mt-1 text-sm">直接与已获批准接入的模型对话,用量与审计归属到你的账号;新模型请先在「模型权限」申请</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<ElSelect v-model="selectedModel" placeholder="选择模型" class="w-72" filterable @change="onModelChange">
|
||||
<ElOptionGroup v-for="group in modelGroups" :key="group.provider" :label="group.provider">
|
||||
<ElOption v-for="m in group.models" :key="m.model" :label="m.model" :value="`${m.provider_code}\n${m.model}`" />
|
||||
</ElOptionGroup>
|
||||
</ElSelect>
|
||||
<ElButton type="primary" :disabled="!selectedModel" @click="newChat">新会话</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElEmpty v-if="!loading && !models.length" description="暂无已批准的模型,请先在「模型权限」页申请">
|
||||
<ElButton type="primary" @click="$router.push('/portal/access')">去申请</ElButton>
|
||||
</ElEmpty>
|
||||
|
||||
<div v-else class="flex gap-4" style="height: calc(100vh - 220px)">
|
||||
<!-- 会话列表 -->
|
||||
<div class="w-64 shrink-0 overflow-auto rounded-lg border border-g-200 bg-white">
|
||||
<div class="border-b border-g-100 px-3 py-2 text-sm font-medium text-g-500">会话历史</div>
|
||||
<div
|
||||
v-for="session in sessions"
|
||||
:key="session.id"
|
||||
class="group cursor-pointer border-b border-g-100 px-3 py-2 hover:bg-g-50"
|
||||
:class="currentId === session.id ? 'bg-primary-50' : ''"
|
||||
@click="openSession(session)"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm">{{ session.title || session.model }}</div>
|
||||
<div class="text-g-400 mt-0.5 truncate text-xs">{{ session.model }}</div>
|
||||
</div>
|
||||
<div class="hidden shrink-0 gap-1 group-hover:flex">
|
||||
<ElButton link size="small" @click.stop="rename(session)">改名</ElButton>
|
||||
<ElButton link size="small" type="danger" @click.stop="remove(session)">删除</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ElEmpty v-if="!sessions.length" description="暂无会话" :image-size="60" />
|
||||
</div>
|
||||
|
||||
<!-- 对话区 -->
|
||||
<div class="flex min-w-0 flex-1 flex-col rounded-lg border border-g-200 bg-white">
|
||||
<div class="flex-1 space-y-4 overflow-auto p-4" ref="scrollRef">
|
||||
<ElEmpty v-if="!messages.length" description="开始你的第一轮对话吧" :image-size="80" />
|
||||
<div v-for="message in messages" :key="message.sequence" class="flex" :class="message.role === 'user' ? 'justify-end' : 'justify-start'">
|
||||
<div
|
||||
class="max-w-[80%] whitespace-pre-wrap break-words rounded-lg px-3 py-2 text-sm"
|
||||
:class="message.role === 'user' ? 'bg-primary-600 text-white' : 'bg-g-100 text-g-800'"
|
||||
>{{ message.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-g-100 p-3">
|
||||
<ElInput
|
||||
v-model="draft"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="100000"
|
||||
resize="none"
|
||||
placeholder="输入消息,Enter 发送,Shift+Enter 换行"
|
||||
@keydown.enter.exact.prevent="send"
|
||||
/>
|
||||
<div class="mt-2 flex items-center justify-between">
|
||||
<span class="text-g-400 text-xs">{{ sending ? '模型思考中…' : `${draft.length} / 100000` }}</span>
|
||||
<ElButton type="primary" :loading="sending" :disabled="!draft.trim()" @click="send">发送</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
ChatMessage, ChatModel, ChatSession,
|
||||
appendChatMessage, createChatSession, deleteChatSession, fetchChatModels,
|
||||
fetchChatSession, fetchChatSessions, renameChatSession
|
||||
} from '@/api/portal'
|
||||
|
||||
const loading = ref(false)
|
||||
const sending = ref(false)
|
||||
const models = ref<ChatModel[]>([])
|
||||
const sessions = ref<ChatSession[]>([])
|
||||
const currentId = ref('')
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
const draft = ref('')
|
||||
const selectedModel = ref('')
|
||||
const scrollRef = ref<HTMLElement>()
|
||||
|
||||
const modelGroups = computed(() => {
|
||||
const groups: { provider: string; models: ChatModel[] }[] = []
|
||||
const index = new Map<string, ChatModel[]>()
|
||||
for (const m of models.value) {
|
||||
if (!index.has(m.provider_code)) index.set(m.provider_code, [])
|
||||
index.get(m.provider_code)!.push(m)
|
||||
}
|
||||
for (const [provider, list] of index) groups.push({ provider, models: list })
|
||||
return groups
|
||||
})
|
||||
|
||||
function modelOf(value: string): { provider_code: string; model: string } {
|
||||
const [provider_code, model] = value.split('\n')
|
||||
return { provider_code: provider_code || '', model: model || '' }
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [modelList, sessionList] = await Promise.all([fetchChatModels(), fetchChatSessions()])
|
||||
models.value = modelList
|
||||
sessions.value = sessionList
|
||||
if (models.value.length && !selectedModel.value) {
|
||||
selectedModel.value = `${models.value[0].provider_code}\n${models.value[0].model}`
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onModelChange() {
|
||||
currentId.value = ''
|
||||
messages.value = []
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
nextTick(() => scrollRef.value?.scrollTo({ top: scrollRef.value.scrollHeight }))
|
||||
}
|
||||
|
||||
async function newChat() {
|
||||
if (!selectedModel.value) return
|
||||
const { provider_code, model } = modelOf(selectedModel.value)
|
||||
sending.value = true
|
||||
try {
|
||||
const session = await createChatSession({ provider_code, model })
|
||||
sessions.value.unshift(session)
|
||||
currentId.value = session.id
|
||||
messages.value = []
|
||||
draft.value = ''
|
||||
ElMessage.success('已创建新会话')
|
||||
} catch (error) {
|
||||
ElMessage.error('创建会话失败,请确认模型已批准且可用')
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openSession(session: ChatSession) {
|
||||
currentId.value = session.id
|
||||
const detail = await fetchChatSession(session.id)
|
||||
messages.value = detail.messages || []
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const text = draft.value.trim()
|
||||
if (!text || sending.value) return
|
||||
if (!currentId.value) {
|
||||
if (!selectedModel.value) {
|
||||
ElMessage.warning('请先选择模型')
|
||||
return
|
||||
}
|
||||
await newChat()
|
||||
if (!currentId.value) return
|
||||
}
|
||||
sending.value = true
|
||||
const id = currentId.value
|
||||
messages.value.push({ sequence: messages.value.length + 1, role: 'user', content: text, created_at: '' })
|
||||
draft.value = ''
|
||||
scrollToBottom()
|
||||
try {
|
||||
const response = await appendChatMessage(id, text)
|
||||
const choices = (response.choices as Array<{ message?: { content?: string } }>) || []
|
||||
const answer = choices[0]?.message?.content || ''
|
||||
messages.value.push({ sequence: messages.value.length + 1, role: 'assistant', content: answer, created_at: '' })
|
||||
const session = sessions.value.find((item) => item.id === id)
|
||||
if (session && !session.title) session.title = text.slice(0, 60)
|
||||
} catch (error) {
|
||||
const message = (error as Error)?.message || '调用失败'
|
||||
ElMessage.error(message)
|
||||
} finally {
|
||||
sending.value = false
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
async function rename(session: ChatSession) {
|
||||
const { value } = await ElMessageBox.prompt('输入新的会话标题', '重命名会话', { inputValue: session.title || session.model, inputValidator: (v: string) => (v.trim() ? true : '标题不能为空') })
|
||||
if (value) {
|
||||
await renameChatSession(session.id, value.trim())
|
||||
session.title = value.trim()
|
||||
ElMessage.success('已重命名')
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(session: ChatSession) {
|
||||
await ElMessageBox.confirm('删除后会话记录不可恢复,确定删除?', '删除会话', { type: 'warning' })
|
||||
await deleteChatSession(session.id)
|
||||
sessions.value = sessions.value.filter((item) => item.id !== session.id)
|
||||
if (currentId.value === session.id) {
|
||||
currentId.value = ''
|
||||
messages.value = []
|
||||
}
|
||||
ElMessage.success('已删除')
|
||||
}
|
||||
|
||||
onMounted(loadAll)
|
||||
</script>
|
||||
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<div class="page-content">
|
||||
<div class="mb-5">
|
||||
<h2 class="text-xl font-semibold">账号安全</h2>
|
||||
<p class="text-g-500 mt-1 text-sm">管理登录设备、新设备登录提醒与扫码登录绑定</p>
|
||||
</div>
|
||||
|
||||
<ElCard shadow="never" class="mb-5">
|
||||
<template #header><div class="font-medium">登录设备</div></template>
|
||||
<ElTable v-loading="loading" :data="sessions">
|
||||
<ElTableColumn label="当前" width="80">
|
||||
<template #default="{ row }"><ElTag v-if="row.current" type="success">当前</ElTag></template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="ip" label="IP 地址" width="160" />
|
||||
<ElTableColumn prop="user_agent" label="设备 / 浏览器" min-width="240">
|
||||
<template #default="{ row }">{{ row.user_agent || '未知设备' }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="登录时间" width="180">
|
||||
<template #default="{ row }">{{ formatTime(row.issued_at) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<ElButton link type="danger" :disabled="row.current" @click="revoke(row)">下线</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
<ElEmpty v-if="!sessions.length && !loading" description="暂无其他登录设备" />
|
||||
</ElCard>
|
||||
|
||||
<ElCard shadow="never" class="mb-5">
|
||||
<template #header><div class="font-medium">登录提醒</div></template>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-sm">新设备登录通知</div>
|
||||
<p class="text-g-500 mt-1 text-sm">账号在新设备登录时通过站内消息提醒,可及时发现异常登录</p>
|
||||
</div>
|
||||
<ElSwitch v-model="loginNotify" :loading="savingPrefs" @change="savePrefs" />
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<ElCard shadow="never">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-medium">扫码登录绑定</div>
|
||||
<span class="text-g-400 text-xs">绑定后可用企业微信 / 钉钉 / 飞书扫码直接登录</span>
|
||||
</div>
|
||||
</template>
|
||||
<ElTable v-loading="loading" :data="bindingRows">
|
||||
<ElTableColumn label="平台" width="140">
|
||||
<template #default="{ row }">{{ providerName(row.kind) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="provider_uid" label="企业账号" min-width="200" />
|
||||
<ElTableColumn label="绑定时间" width="180">
|
||||
<template #default="{ row }">{{ row.created_at }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.bound">
|
||||
<ElButton link type="primary" @click="reBind(row.kind)">重新绑定</ElButton>
|
||||
<ElButton link type="danger" @click="unbind(row.kind)">解绑</ElButton>
|
||||
</template>
|
||||
<ElButton v-else link type="primary" @click="bind(row.kind)">绑定</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
<p class="text-g-400 mt-3 text-xs">绑定需使用平台 App 扫码授权;同一平台账号只能绑定到一个本系统账号。未配置的身份源请在管理端「系统管理 → 身份源」中启用。</p>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
ProviderBinding, SessionView, fetchMySessions, fetchProviderBindings, fetchSecurityPrefs,
|
||||
revokeSession, setSecurityPrefs, startSocialBind, unbindSocial
|
||||
} from '@/api/portal'
|
||||
|
||||
const loading = ref(false)
|
||||
const savingPrefs = ref(false)
|
||||
const sessions = ref<SessionView[]>([])
|
||||
const loginNotify = ref(true)
|
||||
const bindings = ref<ProviderBinding[]>([])
|
||||
|
||||
const providerKinds = ['wecom', 'dingtalk', 'feishu']
|
||||
const providerNames: Record<string, string> = { wecom: '企业微信', dingtalk: '钉钉', feishu: '飞书' }
|
||||
|
||||
const bindingRows = computed(() =>
|
||||
providerKinds.map((kind) => {
|
||||
const item = bindings.value.find((b) => b.kind === kind)
|
||||
return { kind, bound: !!item, provider_uid: item?.provider_uid || '—', created_at: item?.created_at || '—' }
|
||||
})
|
||||
)
|
||||
|
||||
function providerName(kind: string) {
|
||||
return providerNames[kind] || kind
|
||||
}
|
||||
|
||||
function formatTime(epochSeconds: number) {
|
||||
if (!epochSeconds) return '—'
|
||||
return new Date(epochSeconds * 1000).toLocaleString()
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [sessionList, pref, bindingList] = await Promise.all([fetchMySessions(), fetchSecurityPrefs(), fetchProviderBindings()])
|
||||
sessions.value = sessionList
|
||||
loginNotify.value = pref.login_notify
|
||||
bindings.value = bindingList
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(row: SessionView) {
|
||||
await ElMessageBox.confirm('下线该设备后,其登录状态将立即失效。确定下线?', '下线设备', { type: 'warning' })
|
||||
await revokeSession(row.id)
|
||||
sessions.value = sessions.value.filter((item) => item.id !== row.id)
|
||||
ElMessage.success('已下线')
|
||||
}
|
||||
|
||||
async function savePrefs(value: string | number | boolean) {
|
||||
const enabled = value === true || value === 'true' || value === 1
|
||||
savingPrefs.value = true
|
||||
try {
|
||||
await setSecurityPrefs(enabled)
|
||||
ElMessage.success(enabled ? '已开启登录提醒' : '已关闭登录提醒')
|
||||
} catch {
|
||||
loginNotify.value = !enabled
|
||||
ElMessage.error('保存失败')
|
||||
} finally {
|
||||
savingPrefs.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function bind(kind: string) {
|
||||
const { redirect_url } = await startSocialBind(kind)
|
||||
const win = window.open(redirect_url, '_blank', 'width=720,height=600')
|
||||
if (win) {
|
||||
// 扫码完成后回调窗口会带 bind_result 跳回门户;本窗口聚焦时刷新绑定状态。
|
||||
const timer = window.setInterval(async () => {
|
||||
if (win.closed) {
|
||||
window.clearInterval(timer)
|
||||
await loadAll()
|
||||
}
|
||||
}, 1000)
|
||||
window.addEventListener('focus', () => {
|
||||
window.clearInterval(timer)
|
||||
loadAll()
|
||||
}, { once: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function reBind(kind: string) {
|
||||
await bind(kind)
|
||||
}
|
||||
|
||||
async function unbind(kind: string) {
|
||||
await ElMessageBox.confirm(`解绑后该平台将无法扫码登录此账号,确定解绑?`, '解除绑定', { type: 'warning' })
|
||||
await unbindSocial(kind)
|
||||
await loadAll()
|
||||
ElMessage.success('已解绑')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAll()
|
||||
// 扫码绑定回调 302 回门户时携带 bind_result(位于 # 之前的 query)。
|
||||
const pathQuery = new URLSearchParams(window.location.search)
|
||||
const result = pathQuery.get('bind_result')
|
||||
if (result) {
|
||||
history.replaceState(null, '', window.location.pathname + window.location.hash)
|
||||
if (result === 'ok') ElMessage.success('扫码登录绑定成功')
|
||||
else if (result === 'conflict') ElMessage.error('该企业账号已被其他账号绑定')
|
||||
else ElMessage.error('扫码绑定失败或已取消')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user