Files
000-platform/frontend/src/views/app-config/index.vue
111 39f33d7ac5
All checks were successful
continuous-integration/drone/push Build is passing
feat: 添加应用管理和生成签名链接功能
- 新增 platform_apps 表和 App 模型
- 新增应用管理页面 /apps
- 应用配置页面添加"生成链接"功能
- 支持一键生成带签名的访问 URL
2026-01-23 18:22:17 +08:00

491 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import api from '@/api'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const loading = ref(false)
const tableData = ref([])
const total = ref(0)
const query = reactive({
page: 1,
size: 20,
tenant_id: '',
app_code: ''
})
// 应用列表(从应用管理获取)
const appList = ref([])
const appToolsMap = ref({}) // app_code -> tools[]
// 对话框
const dialogVisible = ref(false)
const dialogTitle = ref('')
const editingId = ref(null)
const formRef = ref(null)
const form = reactive({
tenant_id: '',
app_code: 'tools',
app_name: '',
wechat_corp_id: '',
wechat_agent_id: '',
wechat_secret: '',
token_required: false,
allowed_tools: []
})
// 根据选择的应用获取工具选项
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 = {
tenant_id: [{ required: true, message: '请输入租户ID', trigger: 'blur' }],
app_code: [{ required: true, message: '请选择应用', trigger: 'change' }]
}
// 生成链接对话框
const urlDialogVisible = ref(false)
const urlLoading = ref(false)
const currentRow = ref(null)
const selectedTool = ref('')
const generatedUrl = ref('')
const urlInfo = ref({})
async function fetchApps() {
try {
const res = await api.get('/api/apps/all')
appList.value = res.data || []
// 获取每个应用的工具列表
for (const app of appList.value) {
try {
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) {
console.error('获取应用列表失败:', e)
}
}
async function fetchList() {
loading.value = true
try {
const res = await api.get('/api/tenant-apps', { params: query })
tableData.value = res.data.items || []
total.value = res.data.total || 0
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
function handleSearch() {
query.page = 1
fetchList()
}
function handlePageChange(page) {
query.page = page
fetchList()
}
function handleCreate() {
editingId.value = null
dialogTitle.value = '新建应用配置'
Object.assign(form, {
tenant_id: '',
app_code: 'tools',
app_name: '',
wechat_corp_id: '',
wechat_agent_id: '',
wechat_secret: '',
token_required: false,
allowed_tools: []
})
dialogVisible.value = true
}
function handleEdit(row) {
editingId.value = row.id
dialogTitle.value = '编辑应用配置'
Object.assign(form, {
tenant_id: row.tenant_id,
app_code: row.app_code,
app_name: row.app_name || '',
wechat_corp_id: row.wechat_corp_id || '',
wechat_agent_id: row.wechat_agent_id || '',
wechat_secret: '', // 不回显密钥
token_required: row.token_required,
allowed_tools: row.allowed_tools || []
})
dialogVisible.value = true
}
async function handleSubmit() {
await formRef.value.validate()
const data = { ...form }
// 如果没有输入新密钥,不传这个字段
if (!data.wechat_secret) {
delete data.wechat_secret
}
try {
if (editingId.value) {
await api.put(`/api/tenant-apps/${editingId.value}`, data)
ElMessage.success('更新成功')
} else {
const res = await api.post('/api/tenant-apps', data)
ElMessage.success(`创建成功Token Secret: ${res.data.token_secret}`)
}
dialogVisible.value = false
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除此配置吗?`, '提示', {
type: 'warning'
})
try {
await api.delete(`/api/tenant-apps/${row.id}`)
ElMessage.success('删除成功')
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
async function handleRegenerateToken(row) {
await ElMessageBox.confirm('重新生成 Token Secret 将使旧的签名失效,确定继续?', '提示', {
type: 'warning'
})
try {
const res = await api.post(`/api/tenant-apps/${row.id}/regenerate-token`)
ElMessage.success(`新 Token Secret: ${res.data.token_secret}`)
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
async function handleViewSecret(row) {
try {
const res = await api.get(`/api/tenant-apps/${row.id}/wechat-secret`)
if (res.data.wechat_secret) {
ElMessageBox.alert(res.data.wechat_secret, '微信 Secret', {
confirmButtonText: '关闭'
})
} else {
ElMessage.info('未配置微信 Secret')
}
} catch (e) {
// 错误已在拦截器处理
}
}
// 生成链接功能
function handleShowUrl(row) {
currentRow.value = row
selectedTool.value = ''
generatedUrl.value = ''
urlInfo.value = {}
urlDialogVisible.value = true
}
async function handleGenerateUrl() {
if (!currentRow.value) return
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('链接已复制到剪贴板')
})
}
// 获取当前行可选的工具
const currentToolOptions = computed(() => {
if (!currentRow.value) return []
const appTools = appToolsMap.value[currentRow.value.app_code] || []
const allowedTools = currentRow.value.allowed_tools || []
if (appTools.length > 0) {
// 过滤出允许的工具
if (allowedTools.length > 0) {
return appTools.filter(t => allowedTools.includes(t.code)).map(t => ({ label: t.name, value: t.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(() => {
fetchApps()
fetchList()
})
</script>
<template>
<div class="page-container">
<div class="page-header">
<div class="title">应用配置</div>
<el-button v-if="authStore.isOperator" type="primary" @click="handleCreate">
<el-icon><Plus /></el-icon>
新建配置
</el-button>
</div>
<!-- 搜索栏 -->
<div class="search-bar">
<el-input
v-model="query.tenant_id"
placeholder="租户ID"
clearable
style="width: 160px"
@keyup.enter="handleSearch"
/>
<el-select v-model="query.app_code" placeholder="应用" clearable style="width: 120px">
<el-option label="tools" value="tools" />
<el-option label="interview" value="interview" />
</el-select>
<el-button type="primary" @click="handleSearch">搜索</el-button>
</div>
<!-- 表格 -->
<el-table v-loading="loading" :data="tableData" style="width: 100%">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="tenant_id" label="租户ID" width="120" />
<el-table-column prop="app_code" label="应用" width="100" />
<el-table-column prop="app_name" label="应用名称" width="150" />
<el-table-column prop="wechat_corp_id" label="企业ID" width="150" show-overflow-tooltip />
<el-table-column prop="wechat_agent_id" label="应用ID" width="100" />
<el-table-column label="微信密钥" width="100">
<template #default="{ row }">
<el-tag v-if="row.has_wechat_secret" type="success" size="small">已配置</el-tag>
<el-tag v-else type="info" size="small">未配置</el-tag>
</template>
</el-table-column>
<el-table-column label="Token 验证" width="100">
<template #default="{ row }">
<el-tag :type="row.token_required ? 'warning' : 'info'" size="small">
{{ row.token_required ? '必须' : '可选' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="allowed_tools" label="允许工具" min-width="150">
<template #default="{ row }">
<el-tag v-for="tool in (row.allowed_tools || []).slice(0, 3)" :key="tool" size="small" style="margin-right: 4px">
{{ tool }}
</el-tag>
<span v-if="(row.allowed_tools || []).length > 3">...</span>
</template>
</el-table-column>
<el-table-column label="操作" width="300" fixed="right">
<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="warning" link size="small" @click="handleViewSecret(row)">密钥</el-button>
<el-button v-if="authStore.isOperator" type="info" link size="small" @click="handleRegenerateToken(row)">重置</el-button>
<el-button v-if="authStore.isOperator" type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div style="margin-top: 20px; display: flex; justify-content: flex-end">
<el-pagination
v-model:current-page="query.page"
:page-size="query.size"
:total="total"
layout="total, prev, pager, next"
@current-change="handlePageChange"
/>
</div>
<!-- 编辑对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-form-item label="租户ID" prop="tenant_id">
<el-input v-model="form.tenant_id" :disabled="!!editingId" placeholder="如: tenant_001" />
</el-form-item>
<el-form-item label="应用" prop="app_code">
<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 label="tools (默认)" value="tools" />
</el-select>
</el-form-item>
<el-form-item label="配置名称">
<el-input v-model="form.app_name" placeholder="显示名称(可选)" />
</el-form-item>
<el-divider content-position="left">企业微信配置</el-divider>
<el-form-item label="企业 ID">
<el-input v-model="form.wechat_corp_id" placeholder="ww开头的企业ID" />
</el-form-item>
<el-form-item label="应用 ID">
<el-input v-model="form.wechat_agent_id" placeholder="自建应用的 AgentId" />
</el-form-item>
<el-form-item label="应用 Secret">
<el-input v-model="form.wechat_secret" type="password" show-password :placeholder="editingId ? '留空则不修改' : '应用的 Secret'" />
</el-form-item>
<el-divider content-position="left">鉴权配置</el-divider>
<el-form-item label="强制 Token 验证">
<el-switch v-model="form.token_required" />
<span style="margin-left: 12px; color: #909399; font-size: 12px">开启后 URL 必须携带有效签名</span>
</el-form-item>
<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>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
<!-- 生成链接对话框 -->
<el-dialog v-model="urlDialogVisible" title="生成访问链接" width="650px">
<div v-if="currentRow" class="url-dialog-content">
<el-descriptions :column="2" border size="small" style="margin-bottom: 20px">
<el-descriptions-item label="租户ID">{{ currentRow.tenant_id }}</el-descriptions-item>
<el-descriptions-item label="应用">{{ currentRow.app_code }}</el-descriptions-item>
<el-descriptions-item label="签名要求">
<el-tag :type="currentRow.token_required ? 'warning' : 'success'" size="small">
{{ currentRow.token_required ? '需要签名' : '免签名' }}
</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">
<el-form-item label="选择工具">
<el-select v-model="selectedTool" placeholder="选择工具(留空则生成首页链接)" clearable style="width: 100%">
<el-option v-for="opt in currentToolOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="urlLoading" @click="handleGenerateUrl">
生成链接
</el-button>
</el-form-item>
</el-form>
<div v-if="generatedUrl" class="url-result">
<el-divider content-position="left">生成结果</el-divider>
<el-alert
:type="urlInfo.token_required ? 'warning' : 'success'"
:title="urlInfo.note"
:closable="false"
style="margin-bottom: 12px"
/>
<div class="url-box">
<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>
<template #footer>
<el-button @click="urlDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.url-dialog-content {
padding: 0 10px;
}
.url-result {
margin-top: 10px;
}
.url-box {
background: #f5f7fa;
padding: 15px;
border-radius: 4px;
}
</style>