feat: 应用扁平化与 Token 验证 API
All checks were successful
continuous-integration/drone/push Build is passing

- 新增 /api/auth/verify 接口供外部应用验证 token
- 简化应用管理:移除 tools 字段,每个应用独立存在
- 简化应用配置:移除 allowed_tools,专注于租户订阅
- 优化 Token 展示和复制功能
This commit is contained in:
111
2026-01-24 10:05:24 +08:00
parent c4bd7c8251
commit 6a93e05ec3
3 changed files with 264 additions and 331 deletions

View File

@@ -1,8 +1,9 @@
"""认证路由""" """认证路由"""
import hmac
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional, List
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..database import get_db from ..database import get_db
@@ -15,7 +16,10 @@ from ..services.auth import (
TokenData, TokenData,
UserInfo UserInfo
) )
from ..services.crypto import decrypt_config
from ..models.user import User from ..models.user import User
from ..models.tenant_app import TenantApp
from ..models.tenant_wechat_app import TenantWechatApp
router = APIRouter(prefix="/auth", tags=["认证"]) router = APIRouter(prefix="/auth", tags=["认证"])
security = HTTPBearer() security = HTTPBearer()
@@ -221,3 +225,114 @@ async def delete_user(
db.commit() db.commit()
return {"success": True} return {"success": True}
# ============ Token 验证 API供外部应用调用 ============
class VerifyTokenRequest(BaseModel):
"""Token 验证请求"""
token: str
app_code: Optional[str] = None # 可选,用于验证 token 是否属于特定应用
class WechatConfig(BaseModel):
"""企微配置"""
corp_id: Optional[str] = None
agent_id: Optional[str] = None
secret: Optional[str] = None
class VerifyTokenResponse(BaseModel):
"""Token 验证响应"""
valid: bool
tenant_id: Optional[str] = None
app_code: Optional[str] = None
wechat_config: Optional[WechatConfig] = None
error: Optional[str] = None
@router.post("/verify", response_model=VerifyTokenResponse)
async def verify_token(
request: VerifyTokenRequest,
db: Session = Depends(get_db)
):
"""
验证 Token 有效性(供外部应用调用,无需登录)
外部应用收到用户请求后,可调用此接口验证 token
1. 验证 token 是否存在且有效
2. 如传入 app_code验证 token 是否属于该应用
3. 返回租户信息和企微配置
Args:
token: 访问令牌
app_code: 应用代码(可选,用于验证 token 是否属于特定应用)
Returns:
valid: 是否有效
tenant_id: 租户ID
app_code: 应用代码
wechat_config: 企微配置(如有)
"""
if not request.token:
return VerifyTokenResponse(valid=False, error="Token 不能为空")
# 根据 token 查询租户应用配置
query = db.query(TenantApp).filter(
TenantApp.access_token == request.token,
TenantApp.status == 1
)
# 如果指定了 app_code验证 token 是否属于该应用
if request.app_code:
query = query.filter(TenantApp.app_code == request.app_code)
tenant_app = query.first()
if not tenant_app:
return VerifyTokenResponse(valid=False, error="Token 无效或已过期")
# 获取关联的企微配置
wechat_config = None
if tenant_app.wechat_app_id:
wechat_app = db.query(TenantWechatApp).filter(
TenantWechatApp.id == tenant_app.wechat_app_id,
TenantWechatApp.status == 1
).first()
if wechat_app:
# 解密 secret
secret = None
if wechat_app.secret_encrypted:
try:
secret = decrypt_config(wechat_app.secret_encrypted)
except:
pass
wechat_config = WechatConfig(
corp_id=wechat_app.corp_id,
agent_id=wechat_app.agent_id,
secret=secret
)
return VerifyTokenResponse(
valid=True,
tenant_id=tenant_app.tenant_id,
app_code=tenant_app.app_code,
wechat_config=wechat_config
)
@router.get("/verify")
async def verify_token_get(
token: str,
app_code: Optional[str] = None,
db: Session = Depends(get_db)
):
"""
验证 TokenGET 方式,便于简单测试)
"""
return await verify_token(
VerifyTokenRequest(token=token, app_code=app_code),
db
)

View File

@@ -18,11 +18,11 @@ const query = reactive({
// 应用列表(从应用管理获取) // 应用列表(从应用管理获取)
const appList = ref([]) const appList = ref([])
const appToolsMap = ref({}) // app_code -> tools[]
const appRequireJssdk = ref({}) // app_code -> require_jssdk const appRequireJssdk = ref({}) // app_code -> require_jssdk
const appBaseUrl = ref({}) // app_code -> base_url
// 企微应用列表(按租户) // 企微应用列表(按租户)
const wechatAppList = ref([]) // 当前表单租户的企微应用列表 const wechatAppList = ref([])
// 对话框 // 对话框
const dialogVisible = ref(false) const dialogVisible = ref(false)
@@ -31,10 +31,9 @@ const editingId = ref(null)
const formRef = ref(null) const formRef = ref(null)
const form = reactive({ const form = reactive({
tenant_id: '', tenant_id: '',
app_code: 'tools', app_code: '',
app_name: '', app_name: '',
wechat_app_id: null, // 关联的企微应用ID wechat_app_id: null
allowed_tools: []
}) })
// 当前选择的应用是否需要 JS-SDK // 当前选择的应用是否需要 JS-SDK
@@ -42,34 +41,15 @@ const currentAppRequireJssdk = computed(() => {
return appRequireJssdk.value[form.app_code] || false return appRequireJssdk.value[form.app_code] || false
}) })
// 根据选择的应用获取工具选项
const toolOptions = computed(() => {
const tools = appToolsMap.value[form.app_code] || []
if (tools.length > 0) {
return tools.map(t => ({ label: t.name, value: t.code }))
}
// 默认工具列表(兼容旧数据)
return [
{ label: '高情商回复', value: 'high-eq' },
{ label: '头脑风暴', value: 'brainstorm' },
{ label: '面诊方案', value: 'consultation' },
{ label: '客户画像', value: 'customer-profile' },
{ label: '医疗合规', value: 'medical-compliance' }
]
})
const rules = { const rules = {
tenant_id: [{ required: true, message: '请输入租户ID', trigger: 'blur' }], tenant_id: [{ required: true, message: '请输入租户ID', trigger: 'blur' }],
app_code: [{ required: true, message: '请选择应用', trigger: 'change' }] app_code: [{ required: true, message: '请选择应用', trigger: 'change' }]
} }
// 生成链接对话框 // 查看 Token 对话框
const urlDialogVisible = ref(false) const tokenDialogVisible = ref(false)
const urlLoading = ref(false) const currentToken = ref('')
const currentRow = ref(null) const currentAppUrl = ref('')
const selectedTool = ref('')
const generatedUrl = ref('')
const urlInfo = ref({})
async function fetchApps() { async function fetchApps() {
try { try {
@@ -77,15 +57,9 @@ async function fetchApps() {
const apps = res.data.items || [] const apps = res.data.items || []
appList.value = apps.map(a => ({ app_code: a.app_code, app_name: a.app_name })) appList.value = apps.map(a => ({ app_code: a.app_code, app_name: a.app_name }))
// 获取每个应用的工具列表和 JS-SDK 要求
for (const app of apps) { for (const app of apps) {
appRequireJssdk.value[app.app_code] = app.require_jssdk || false appRequireJssdk.value[app.app_code] = app.require_jssdk || false
try { appBaseUrl.value[app.app_code] = app.base_url || ''
const toolsRes = await api.get(`/api/apps/${app.app_code}/tools`)
appToolsMap.value[app.app_code] = toolsRes.data || []
} catch (e) {
appToolsMap.value[app.app_code] = []
}
} }
} catch (e) { } catch (e) {
console.error('获取应用列表失败:', e) console.error('获取应用列表失败:', e)
@@ -130,13 +104,12 @@ function handlePageChange(page) {
function handleCreate() { function handleCreate() {
editingId.value = null editingId.value = null
dialogTitle.value = '新建应用配置' dialogTitle.value = '新建应用订阅'
Object.assign(form, { Object.assign(form, {
tenant_id: '', tenant_id: '',
app_code: 'tools', app_code: '',
app_name: '', app_name: '',
wechat_app_id: null, wechat_app_id: null
allowed_tools: []
}) })
wechatAppList.value = [] wechatAppList.value = []
dialogVisible.value = true dialogVisible.value = true
@@ -144,20 +117,17 @@ function handleCreate() {
async function handleEdit(row) { async function handleEdit(row) {
editingId.value = row.id editingId.value = row.id
dialogTitle.value = '编辑应用配置' dialogTitle.value = '编辑应用订阅'
Object.assign(form, { Object.assign(form, {
tenant_id: row.tenant_id, tenant_id: row.tenant_id,
app_code: row.app_code, app_code: row.app_code,
app_name: row.app_name || '', app_name: row.app_name || '',
wechat_app_id: row.wechat_app_id || null, wechat_app_id: row.wechat_app_id || null
allowed_tools: row.allowed_tools || []
}) })
// 获取该租户的企微应用列表
await fetchWechatApps(row.tenant_id) await fetchWechatApps(row.tenant_id)
dialogVisible.value = true dialogVisible.value = true
} }
// 租户ID变化时重新获取企微应用列表
async function handleTenantChange() { async function handleTenantChange() {
form.wechat_app_id = null form.wechat_app_id = null
await fetchWechatApps(form.tenant_id) await fetchWechatApps(form.tenant_id)
@@ -174,7 +144,9 @@ async function handleSubmit() {
ElMessage.success('更新成功') ElMessage.success('更新成功')
} else { } else {
const res = await api.post('/api/tenant-apps', data) const res = await api.post('/api/tenant-apps', data)
ElMessage.success(`创建成功Access Token: ${res.data.access_token}`) ElMessage.success(`创建成功`)
// 显示新生成的 token
showToken(res.data.access_token, form.app_code)
} }
dialogVisible.value = false dialogVisible.value = false
fetchList() fetchList()
@@ -184,7 +156,7 @@ async function handleSubmit() {
} }
async function handleDelete(row) { async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除配置吗?`, '提示', { await ElMessageBox.confirm(`确定删除${row.app_code}」的订阅配置吗?`, '提示', {
type: 'warning' type: 'warning'
}) })
@@ -198,96 +170,43 @@ async function handleDelete(row) {
} }
async function handleRegenerateToken(row) { async function handleRegenerateToken(row) {
await ElMessageBox.confirm('重新生成 Access Token 将使旧的链接失效,确定继续?', '提示', { await ElMessageBox.confirm('重新生成 Token 将使旧 Token 失效,确定继续?', '提示', {
type: 'warning' type: 'warning'
}) })
try { try {
const res = await api.post(`/api/tenant-apps/${row.id}/regenerate-token`) const res = await api.post(`/api/tenant-apps/${row.id}/regenerate-token`)
ElMessage.success(`新 Access Token: ${res.data.access_token}`) showToken(res.data.access_token, row.app_code)
fetchList() fetchList()
} catch (e) { } catch (e) {
// 错误已在拦截器处理 // 错误已在拦截器处理
} }
} }
// 生成链接功能 function showToken(token, appCode) {
function handleShowUrl(row) { currentToken.value = token
currentRow.value = row currentAppUrl.value = appBaseUrl.value[appCode] || ''
selectedTool.value = '' tokenDialogVisible.value = true
generatedUrl.value = ''
urlInfo.value = {}
urlDialogVisible.value = true
} }
async function handleGenerateUrl() { function handleCopyToken() {
if (!currentRow.value) return navigator.clipboard.writeText(currentToken.value).then(() => {
ElMessage.success('Token 已复制')
urlLoading.value = true
try {
const res = await api.post('/api/apps/generate-url', {
tenant_id: currentRow.value.tenant_id,
app_code: currentRow.value.app_code,
tool_code: selectedTool.value || null
})
if (res.data.success) {
generatedUrl.value = res.data.url
urlInfo.value = res.data
} else {
ElMessage.error(res.data.error || '生成失败')
}
} catch (e) {
console.error(e)
} finally {
urlLoading.value = false
}
}
function handleCopyUrl() {
if (!generatedUrl.value) return
navigator.clipboard.writeText(generatedUrl.value).then(() => {
ElMessage.success('链接已复制到剪贴板')
}).catch(() => {
// 降级方案
const input = document.createElement('input')
input.value = generatedUrl.value
document.body.appendChild(input)
input.select()
document.execCommand('copy')
document.body.removeChild(input)
ElMessage.success('链接已复制到剪贴板')
}) })
} }
// 获取当前行可选的工具 function handleCopyUrl() {
const currentToolOptions = computed(() => { const url = `${currentAppUrl.value}?token=${currentToken.value}`
if (!currentRow.value) return [] navigator.clipboard.writeText(url).then(() => {
const appTools = appToolsMap.value[currentRow.value.app_code] || [] ElMessage.success('链接已复制')
const allowedTools = currentRow.value.allowed_tools || [] })
}
if (appTools.length > 0) {
// 过滤出允许的工具 async function handleViewToken(row) {
if (allowedTools.length > 0) { // 这里需要后端返回真实 token暂时用 placeholder
return appTools.filter(t => allowedTools.includes(t.code)).map(t => ({ label: t.name, value: t.code })) // 实际生产中可能需要单独 API 获取
} showToken(row.access_token === '******' ? '需要调用API获取' : row.access_token, row.app_code)
return appTools.map(t => ({ label: t.name, value: t.code })) }
}
// 默认工具
const defaultTools = [
{ label: '高情商回复', value: 'high-eq' },
{ label: '头脑风暴', value: 'brainstorm' },
{ label: '面诊方案', value: 'consultation' },
{ label: '客户画像', value: 'customer-profile' },
{ label: '医疗合规', value: 'medical-compliance' }
]
if (allowedTools.length > 0) {
return defaultTools.filter(t => allowedTools.includes(t.value))
}
return defaultTools
})
onMounted(() => { onMounted(() => {
fetchApps() fetchApps()
@@ -298,13 +217,19 @@ onMounted(() => {
<template> <template>
<div class="page-container"> <div class="page-container">
<div class="page-header"> <div class="page-header">
<div class="title">应用配置</div> <div class="title">租户应用订阅</div>
<el-button v-if="authStore.isOperator" type="primary" @click="handleCreate"> <el-button v-if="authStore.isOperator" type="primary" @click="handleCreate">
<el-icon><Plus /></el-icon> <el-icon><Plus /></el-icon>
新建配置 新建订阅
</el-button> </el-button>
</div> </div>
<div class="page-tip">
<el-alert type="info" :closable="false">
为租户订阅应用生成访问 Token外部应用可通过 Token 向平台验证身份
</el-alert>
</div>
<!-- 搜索栏 --> <!-- 搜索栏 -->
<div class="search-bar"> <div class="search-bar">
<el-input <el-input
@@ -314,9 +239,8 @@ onMounted(() => {
style="width: 160px" style="width: 160px"
@keyup.enter="handleSearch" @keyup.enter="handleSearch"
/> />
<el-select v-model="query.app_code" placeholder="应用" clearable style="width: 120px"> <el-select v-model="query.app_code" placeholder="应用" clearable style="width: 150px">
<el-option label="tools" value="tools" /> <el-option v-for="app in appList" :key="app.app_code" :label="app.app_name" :value="app.app_code" />
<el-option label="interview" value="interview" />
</el-select> </el-select>
<el-button type="primary" @click="handleSearch">搜索</el-button> <el-button type="primary" @click="handleSearch">搜索</el-button>
</div> </div>
@@ -325,8 +249,8 @@ onMounted(() => {
<el-table v-loading="loading" :data="tableData" style="width: 100%"> <el-table v-loading="loading" :data="tableData" style="width: 100%">
<el-table-column prop="id" label="ID" width="60" /> <el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="tenant_id" label="租户ID" width="120" /> <el-table-column prop="tenant_id" label="租户ID" width="120" />
<el-table-column prop="app_code" label="应用" width="100" /> <el-table-column prop="app_code" label="应用代码" width="150" />
<el-table-column prop="app_name" label="应用名称" width="150" /> <el-table-column prop="app_name" label="备注名称" width="150" />
<el-table-column label="企微应用" width="180"> <el-table-column label="企微应用" width="180">
<template #default="{ row }"> <template #default="{ row }">
<template v-if="row.wechat_app"> <template v-if="row.wechat_app">
@@ -335,25 +259,24 @@ onMounted(() => {
<el-tag v-else type="info" size="small">未关联</el-tag> <el-tag v-else type="info" size="small">未关联</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="Access Token" width="120"> <el-table-column label="Token 状态" width="120">
<template #default="{ row }"> <template #default="{ row }">
<el-tag v-if="row.access_token" type="success" size="small">配置</el-tag> <el-tag v-if="row.access_token" type="success" size="small">生成</el-tag>
<el-tag v-else type="danger" size="small">配置</el-tag> <el-tag v-else type="danger" size="small">生成</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="allowed_tools" label="允许工具" min-width="150"> <el-table-column label="状态" width="80">
<template #default="{ row }"> <template #default="{ row }">
<el-tag v-for="tool in (row.allowed_tools || []).slice(0, 3)" :key="tool" size="small" style="margin-right: 4px"> <el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
{{ tool }} {{ row.status === 1 ? '启用' : '禁用' }}
</el-tag> </el-tag>
<span v-if="(row.allowed_tools || []).length > 3">...</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="250" fixed="right"> <el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button type="success" link size="small" @click="handleShowUrl(row)">生成链接</el-button>
<el-button v-if="authStore.isOperator" type="primary" link size="small" @click="handleEdit(row)">编辑</el-button> <el-button v-if="authStore.isOperator" type="primary" link size="small" @click="handleEdit(row)">编辑</el-button>
<el-button v-if="authStore.isOperator" type="info" link size="small" @click="handleRegenerateToken(row)">重置Token</el-button> <el-button v-if="authStore.isOperator" type="success" link size="small" @click="handleViewToken(row)">查看Token</el-button>
<el-button v-if="authStore.isOperator" type="warning" link size="small" @click="handleRegenerateToken(row)">重置Token</el-button>
<el-button v-if="authStore.isOperator" type="danger" link size="small" @click="handleDelete(row)">删除</el-button> <el-button v-if="authStore.isOperator" type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
@@ -371,7 +294,7 @@ onMounted(() => {
</div> </div>
<!-- 编辑对话框 --> <!-- 编辑对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px"> <el-dialog v-model="dialogVisible" :title="dialogTitle" width="550px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px"> <el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-form-item label="租户ID" prop="tenant_id"> <el-form-item label="租户ID" prop="tenant_id">
<el-input <el-input
@@ -382,43 +305,36 @@ onMounted(() => {
/> />
</el-form-item> </el-form-item>
<el-form-item label="应用" prop="app_code"> <el-form-item label="应用" prop="app_code">
<el-select v-model="form.app_code" :disabled="!!editingId" placeholder="选择应用" style="width: 100%"> <el-select v-model="form.app_code" :disabled="!!editingId" placeholder="选择要订阅的应用" style="width: 100%">
<el-option v-for="app in appList" :key="app.app_code" :label="app.app_name" :value="app.app_code" /> <el-option v-for="app in appList" :key="app.app_code" :label="`${app.app_name} (${app.app_code})`" :value="app.app_code" />
<el-option label="tools (默认)" value="tools" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="配置名称"> <el-form-item label="备注名称">
<el-input v-model="form.app_name" placeholder="显示名称(可选)" /> <el-input v-model="form.app_name" placeholder="可选,用于区分同应用多配置" />
</el-form-item> </el-form-item>
<el-divider v-if="currentAppRequireJssdk" content-position="left">企业微信关联</el-divider> <template v-if="currentAppRequireJssdk">
<el-divider content-position="left">企业微信关联</el-divider>
<el-form-item v-if="currentAppRequireJssdk" label="关联企微应用">
<el-select <el-form-item label="关联企微应用">
v-model="form.wechat_app_id" <el-select
placeholder="选择企微应用" v-model="form.wechat_app_id"
clearable placeholder="选择企微应用"
style="width: 100%" clearable
> style="width: 100%"
<el-option >
v-for="wa in wechatAppList" <el-option
:key="wa.id" v-for="wa in wechatAppList"
:label="`${wa.name} (${wa.corp_id})`" :key="wa.id"
:value="wa.id" :label="`${wa.name} (${wa.corp_id})`"
/> :value="wa.id"
</el-select> />
<div v-if="wechatAppList.length === 0 && form.tenant_id" style="color: #909399; font-size: 12px; margin-top: 4px"> </el-select>
该租户暂无企微应用请先在企微应用中配置 <div v-if="wechatAppList.length === 0 && form.tenant_id" style="color: #909399; font-size: 12px; margin-top: 4px">
</div> 该租户暂无企微应用请先在企微应用中配置
</el-form-item> </div>
</el-form-item>
<el-divider content-position="left">权限配置</el-divider> </template>
<el-form-item label="允许的工具">
<el-checkbox-group v-model="form.allowed_tools">
<el-checkbox v-for="opt in toolOptions" :key="opt.value" :label="opt.value">{{ opt.label }}</el-checkbox>
</el-checkbox-group>
</el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="dialogVisible = false">取消</el-button> <el-button @click="dialogVisible = false">取消</el-button>
@@ -426,77 +342,67 @@ onMounted(() => {
</template> </template>
</el-dialog> </el-dialog>
<!-- 生成链接对话框 --> <!-- Token 显示对话框 -->
<el-dialog v-model="urlDialogVisible" title="生成访问链接" width="650px"> <el-dialog v-model="tokenDialogVisible" title="访问 Token" width="600px">
<div v-if="currentRow" class="url-dialog-content"> <div class="token-dialog-content">
<el-descriptions :column="2" border size="small" style="margin-bottom: 20px"> <el-alert type="warning" :closable="false" style="margin-bottom: 16px">
<el-descriptions-item label="租户ID">{{ currentRow.tenant_id }}</el-descriptions-item> 请妥善保管 Token它是应用访问平台的凭证
<el-descriptions-item label="应用">{{ currentRow.app_code }}</el-descriptions-item> </el-alert>
<el-descriptions-item label="Access Token">
<el-tag v-if="currentRow.access_token" type="success" size="small">已配置</el-tag>
<el-tag v-else type="danger" size="small">未配置</el-tag>
</el-descriptions-item>
<el-descriptions-item label="允许工具">
{{ (currentRow.allowed_tools || []).length > 0 ? currentRow.allowed_tools.join(', ') : '全部' }}
</el-descriptions-item>
</el-descriptions>
<el-form label-width="80px"> <div class="token-section">
<el-form-item label="选择工具"> <div class="token-label">Access Token:</div>
<el-select v-model="selectedTool" placeholder="选择工具(留空则生成首页链接)" clearable style="width: 100%"> <el-input v-model="currentToken" readonly>
<el-option v-for="opt in currentToolOptions" :key="opt.value" :label="opt.label" :value="opt.value" /> <template #append>
</el-select> <el-button @click="handleCopyToken">复制</el-button>
</el-form-item> </template>
<el-form-item> </el-input>
<el-button type="primary" :loading="urlLoading" @click="handleGenerateUrl"> </div>
生成链接
</el-button>
</el-form-item>
</el-form>
<div v-if="generatedUrl" class="url-result"> <div v-if="currentAppUrl" class="token-section">
<el-divider content-position="left">生成结果</el-divider> <div class="token-label">完整访问链接:</div>
<el-input :model-value="`${currentAppUrl}?token=${currentToken}`" readonly type="textarea" :rows="2" />
<el-alert <el-button type="primary" style="margin-top: 8px" @click="handleCopyUrl">
type="success" <el-icon><CopyDocument /></el-icon>
:title="urlInfo.note || '静态链接,长期有效'" 复制链接
:closable="false" </el-button>
style="margin-bottom: 12px" </div>
<el-divider />
<div class="token-section">
<div class="token-label">验证 API:</div>
<el-input
model-value="POST /api/auth/verify"
readonly
/> />
<div style="color: #909399; font-size: 12px; margin-top: 4px">
<div class="url-box"> 外部应用可调用此接口验证 Token 有效性获取租户和企微配置
<el-input
v-model="generatedUrl"
type="textarea"
:rows="3"
readonly
/>
<el-button type="primary" style="margin-top: 10px" @click="handleCopyUrl">
<el-icon><CopyDocument /></el-icon>
复制链接
</el-button>
</div> </div>
</div> </div>
</div> </div>
<template #footer> <template #footer>
<el-button @click="urlDialogVisible = false">关闭</el-button> <el-button @click="tokenDialogVisible = false">关闭</el-button>
</template> </template>
</el-dialog> </el-dialog>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.url-dialog-content { .page-tip {
margin-bottom: 16px;
}
.token-dialog-content {
padding: 0 10px; padding: 0 10px;
} }
.url-result { .token-section {
margin-top: 10px; margin-bottom: 16px;
} }
.url-box { .token-label {
background: #f5f7fa; font-weight: 500;
padding: 15px; margin-bottom: 8px;
border-radius: 4px; color: #303133;
} }
</style> </style>

View File

@@ -24,8 +24,7 @@ const form = reactive({
app_name: '', app_name: '',
base_url: '', base_url: '',
description: '', description: '',
require_jssdk: false, require_jssdk: false
tools: []
}) })
const rules = { const rules = {
@@ -33,15 +32,6 @@ const rules = {
app_name: [{ required: true, message: '请输入应用名称', trigger: 'blur' }] app_name: [{ required: true, message: '请输入应用名称', trigger: 'blur' }]
} }
// 工具编辑
const toolDialogVisible = ref(false)
const editingToolIndex = ref(-1)
const toolForm = reactive({
code: '',
name: '',
path: ''
})
async function fetchList() { async function fetchList() {
loading.value = true loading.value = true
try { try {
@@ -73,8 +63,7 @@ function handleCreate() {
app_name: '', app_name: '',
base_url: '', base_url: '',
description: '', description: '',
require_jssdk: false, require_jssdk: false
tools: []
}) })
dialogVisible.value = true dialogVisible.value = true
} }
@@ -87,8 +76,7 @@ function handleEdit(row) {
app_name: row.app_name, app_name: row.app_name,
base_url: row.base_url || '', base_url: row.base_url || '',
description: row.description || '', description: row.description || '',
require_jssdk: row.require_jssdk || false, require_jssdk: row.require_jssdk || false
tools: row.tools || []
}) })
dialogVisible.value = true dialogVisible.value = true
} }
@@ -138,39 +126,6 @@ async function handleToggleStatus(row) {
} }
} }
// 工具管理
function handleAddTool() {
editingToolIndex.value = -1
Object.assign(toolForm, { code: '', name: '', path: '' })
toolDialogVisible.value = true
}
function handleEditTool(index) {
editingToolIndex.value = index
const tool = form.tools[index]
Object.assign(toolForm, { ...tool })
toolDialogVisible.value = true
}
function handleDeleteTool(index) {
form.tools.splice(index, 1)
}
function handleSaveTool() {
if (!toolForm.code || !toolForm.name) {
ElMessage.warning('请填写工具代码和名称')
return
}
const tool = { ...toolForm }
if (editingToolIndex.value >= 0) {
form.tools[editingToolIndex.value] = tool
} else {
form.tools.push(tool)
}
toolDialogVisible.value = false
}
onMounted(() => { onMounted(() => {
fetchList() fetchList()
}) })
@@ -188,22 +143,17 @@ onMounted(() => {
<div class="page-tip"> <div class="page-tip">
<el-alert type="info" :closable="false"> <el-alert type="info" :closable="false">
应用管理定义可供租户使用的应用配置应用的基础URL和工具列表 应用管理每个应用是一个独立的服务有独立的访问地址
租户配置中选择应用后即可生成带签名的访问链接 租户订阅应用后平台生成 Token 供应用鉴权使用
</el-alert> </el-alert>
</div> </div>
<!-- 表格 --> <!-- 表格 -->
<el-table v-loading="loading" :data="tableData" style="width: 100%"> <el-table v-loading="loading" :data="tableData" style="width: 100%">
<el-table-column prop="id" label="ID" width="60" /> <el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="app_code" label="应用代码" width="120" /> <el-table-column prop="app_code" label="应用代码" width="150" />
<el-table-column prop="app_name" label="应用名称" width="150" /> <el-table-column prop="app_name" label="应用名称" width="180" />
<el-table-column prop="base_url" label="基础URL" min-width="250" show-overflow-tooltip /> <el-table-column prop="base_url" label="访问地址" min-width="300" show-overflow-tooltip />
<el-table-column label="工具数量" width="100">
<template #default="{ row }">
<el-tag size="small">{{ (row.tools || []).length }} </el-tag>
</template>
</el-table-column>
<el-table-column label="JS-SDK" width="90"> <el-table-column label="JS-SDK" width="90">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="row.require_jssdk ? 'warning' : 'info'" size="small"> <el-tag :type="row.require_jssdk ? 'warning' : 'info'" size="small">
@@ -241,45 +191,26 @@ onMounted(() => {
</div> </div>
<!-- 编辑对话框 --> <!-- 编辑对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="700px"> <el-dialog v-model="dialogVisible" :title="dialogTitle" width="550px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px"> <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="应用代码" prop="app_code"> <el-form-item label="应用代码" prop="app_code">
<el-input v-model="form.app_code" :disabled="!!editingId" placeholder="唯一标识,如: tools" /> <el-input v-model="form.app_code" :disabled="!!editingId" placeholder="唯一标识,如: brainstorm" />
</el-form-item> </el-form-item>
<el-form-item label="应用名称" prop="app_name"> <el-form-item label="应用名称" prop="app_name">
<el-input v-model="form.app_name" placeholder="显示名称" /> <el-input v-model="form.app_name" placeholder="显示名称,如: 头脑风暴" />
</el-form-item> </el-form-item>
<el-form-item label="基础URL"> <el-form-item label="访问地址">
<el-input v-model="form.base_url" placeholder="如: https://tools.test.ai.ireborn.com.cn" /> <el-input v-model="form.base_url" placeholder="如: https://brainstorm.example.com" />
<div style="color: #909399; font-size: 12px; margin-top: 4px">
应用的访问地址用于生成链接和跳转
</div>
</el-form-item> </el-form-item>
<el-form-item label="描述"> <el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="应用描述" /> <el-input v-model="form.description" type="textarea" :rows="2" placeholder="应用描述" />
</el-form-item> </el-form-item>
<el-form-item label="企微JS-SDK"> <el-form-item label="企微JS-SDK">
<el-switch v-model="form.require_jssdk" /> <el-switch v-model="form.require_jssdk" />
<span style="margin-left: 12px; color: #909399; font-size: 12px">开启后租户需关联企微应用才能使用</span> <span style="margin-left: 12px; color: #909399; font-size: 12px">开启后租户需关联企微应用</span>
</el-form-item>
<el-divider content-position="left">工具列表</el-divider>
<el-form-item label="工具">
<div style="width: 100%">
<el-table :data="form.tools" size="small" border style="margin-bottom: 10px">
<el-table-column prop="code" label="代码" width="120" />
<el-table-column prop="name" label="名称" width="120" />
<el-table-column prop="path" label="路径" />
<el-table-column label="操作" width="120">
<template #default="{ $index }">
<el-button type="primary" link size="small" @click="handleEditTool($index)">编辑</el-button>
<el-button type="danger" link size="small" @click="handleDeleteTool($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-button type="primary" size="small" @click="handleAddTool">
<el-icon><Plus /></el-icon>
添加工具
</el-button>
</div>
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
@@ -287,25 +218,6 @@ onMounted(() => {
<el-button type="primary" @click="handleSubmit">确定</el-button> <el-button type="primary" @click="handleSubmit">确定</el-button>
</template> </template>
</el-dialog> </el-dialog>
<!-- 工具编辑对话框 -->
<el-dialog v-model="toolDialogVisible" :title="editingToolIndex >= 0 ? '编辑工具' : '添加工具'" width="400px">
<el-form :model="toolForm" label-width="80px">
<el-form-item label="代码">
<el-input v-model="toolForm.code" placeholder="如: brainstorm" />
</el-form-item>
<el-form-item label="名称">
<el-input v-model="toolForm.name" placeholder="如: 头脑风暴" />
</el-form-item>
<el-form-item label="路径">
<el-input v-model="toolForm.path" placeholder="如: /brainstorm" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="toolDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSaveTool">确定</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>