1363 lines
38 KiB
Vue
1363 lines
38 KiB
Vue
<script setup>
|
||
import { ref, reactive, onMounted, computed, watch } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { Delete, Plus, CopyDocument, Grid, Edit } from '@element-plus/icons-vue'
|
||
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 tenantList = ref([])
|
||
const tenantMap = ref({}) // code -> name 映射
|
||
|
||
// 应用列表(从应用管理获取)
|
||
const appList = ref([])
|
||
const appMap = ref({}) // app_code -> app_name 映射
|
||
const appRequireJssdk = ref({}) // app_code -> require_jssdk
|
||
const appBaseUrl = ref({}) // app_code -> base_url
|
||
const appConfigSchema = ref({}) // app_code -> config_schema
|
||
|
||
// 企微应用列表(按租户)
|
||
const wechatAppList = ref([])
|
||
|
||
// 对话框
|
||
const dialogVisible = ref(false)
|
||
const dialogTitle = ref('')
|
||
const editingId = ref(null)
|
||
const formRef = ref(null)
|
||
const form = reactive({
|
||
tenant_id: '',
|
||
app_code: '',
|
||
app_name: '',
|
||
wechat_app_id: null,
|
||
custom_configs: [] // 自定义配置 [{key, value, remark}]
|
||
})
|
||
|
||
// 当前选择的应用是否需要 JS-SDK
|
||
const currentAppRequireJssdk = computed(() => {
|
||
return appRequireJssdk.value[form.app_code] || false
|
||
})
|
||
|
||
// 当前应用的配置项定义
|
||
const currentConfigSchema = computed(() => {
|
||
return appConfigSchema.value[form.app_code] || []
|
||
})
|
||
|
||
// 配置值映射(方便读写)
|
||
const configValues = computed(() => {
|
||
const map = {}
|
||
form.custom_configs.forEach(c => {
|
||
map[c.key] = c
|
||
})
|
||
return map
|
||
})
|
||
|
||
// 获取配置值
|
||
function getConfigValue(key) {
|
||
return configValues.value[key]?.value || ''
|
||
}
|
||
|
||
// 设置配置值
|
||
function setConfigValue(key, value, remark = '') {
|
||
const existing = form.custom_configs.find(c => c.key === key)
|
||
if (existing) {
|
||
existing.value = value
|
||
if (remark) existing.remark = remark
|
||
} else {
|
||
form.custom_configs.push({ key, value, remark })
|
||
}
|
||
}
|
||
|
||
// 获取选项显示名称
|
||
function getOptionLabel(schema, optionValue) {
|
||
if (schema.option_labels && schema.option_labels[optionValue]) {
|
||
return schema.option_labels[optionValue]
|
||
}
|
||
return optionValue
|
||
}
|
||
|
||
// 打开长文本编辑弹窗
|
||
function openTextEdit(index, schema) {
|
||
textEditData.index = index
|
||
textEditData.label = schema.label || schema.key
|
||
textEditData.value = form.custom_configs[index]?.value || ''
|
||
textEditData.placeholder = schema.placeholder || '请输入内容'
|
||
textEditDialogVisible.value = true
|
||
}
|
||
|
||
// 保存长文本编辑
|
||
function saveTextEdit() {
|
||
if (textEditData.index >= 0 && form.custom_configs[textEditData.index]) {
|
||
form.custom_configs[textEditData.index].value = textEditData.value
|
||
}
|
||
textEditDialogVisible.value = false
|
||
}
|
||
|
||
// 获取文本预览(截取前N个字符)
|
||
function getTextPreview(text, maxLength = 80) {
|
||
if (!text) return '(未填写)'
|
||
if (text.length <= maxLength) return text
|
||
return text.slice(0, maxLength) + '...'
|
||
}
|
||
|
||
// 判断文本是否为长文本(超过80字符或多行)
|
||
function isLongText(text) {
|
||
if (!text) return false
|
||
return text.length > 80 || text.includes('\n')
|
||
}
|
||
|
||
// 验证 app_code 必须是有效的应用
|
||
const validateAppCode = (rule, value, callback) => {
|
||
if (!value) {
|
||
callback(new Error('请选择应用'))
|
||
} else if (!appList.value.find(a => a.app_code === value)) {
|
||
callback(new Error('请从列表中选择有效的应用'))
|
||
} else {
|
||
callback()
|
||
}
|
||
}
|
||
|
||
const rules = {
|
||
tenant_id: [{ required: true, message: '请选择租户', trigger: 'change' }],
|
||
app_code: [{ required: true, validator: validateAppCode, trigger: 'change' }]
|
||
}
|
||
|
||
// 监听租户选择变化
|
||
watch(() => form.tenant_id, async (newVal) => {
|
||
if (newVal) {
|
||
await fetchWechatApps(newVal)
|
||
} else {
|
||
wechatAppList.value = []
|
||
}
|
||
form.wechat_app_id = null
|
||
})
|
||
|
||
// 监听应用选择变化,初始化配置默认值
|
||
watch(() => form.app_code, (newVal) => {
|
||
if (newVal && !editingId.value) {
|
||
// 新建时,根据 schema 初始化默认值
|
||
initConfigDefaults()
|
||
}
|
||
})
|
||
|
||
// 查看 Token 对话框
|
||
const tokenDialogVisible = ref(false)
|
||
const currentToken = ref('')
|
||
const currentAppUrl = ref('')
|
||
|
||
// 批量添加对话框
|
||
const batchDialogVisible = ref(false)
|
||
const batchLoading = ref(false)
|
||
const batchForm = reactive({
|
||
tenant_id: '',
|
||
selected_apps: []
|
||
})
|
||
|
||
// 长文本编辑弹窗
|
||
const textEditDialogVisible = ref(false)
|
||
const textEditData = reactive({
|
||
index: -1,
|
||
label: '',
|
||
value: '',
|
||
placeholder: ''
|
||
})
|
||
|
||
// 获取租户尚未订阅的应用列表
|
||
const availableAppsForBatch = computed(() => {
|
||
if (!batchForm.tenant_id) return appList.value
|
||
|
||
// 获取该租户已订阅的应用
|
||
const subscribedApps = new Set(
|
||
tableData.value
|
||
.filter(row => row.tenant_id === batchForm.tenant_id)
|
||
.map(row => row.app_code)
|
||
)
|
||
|
||
// 返回未订阅的应用
|
||
return appList.value.filter(app => !subscribedApps.has(app.app_code))
|
||
})
|
||
|
||
// 是否全选
|
||
const isAllSelected = computed(() => {
|
||
return availableAppsForBatch.value.length > 0 &&
|
||
batchForm.selected_apps.length === availableAppsForBatch.value.length
|
||
})
|
||
|
||
// 是否部分选中
|
||
const isIndeterminate = computed(() => {
|
||
return batchForm.selected_apps.length > 0 &&
|
||
batchForm.selected_apps.length < availableAppsForBatch.value.length
|
||
})
|
||
|
||
async function fetchTenants() {
|
||
try {
|
||
const res = await api.get('/api/tenants', { params: { size: 1000 } })
|
||
tenantList.value = res.data.items || []
|
||
// 构建 code -> name 映射
|
||
const map = {}
|
||
tenantList.value.forEach(t => {
|
||
map[t.code] = t.name
|
||
})
|
||
tenantMap.value = map
|
||
} catch (e) {
|
||
console.error('获取租户列表失败:', e)
|
||
}
|
||
}
|
||
|
||
// 获取租户中文名
|
||
function getTenantName(code) {
|
||
return tenantMap.value[code] || code
|
||
}
|
||
|
||
// 获取应用中文名
|
||
function getAppName(code) {
|
||
return appMap.value[code] || code
|
||
}
|
||
|
||
async function fetchApps() {
|
||
try {
|
||
const res = await api.get('/api/apps', { params: { size: 100 } })
|
||
const apps = res.data.items || []
|
||
appList.value = apps.map(a => ({ app_code: a.app_code, app_name: a.app_name }))
|
||
|
||
// 构建 app_code -> app_name 映射
|
||
const map = {}
|
||
for (const app of apps) {
|
||
map[app.app_code] = app.app_name
|
||
appRequireJssdk.value[app.app_code] = app.require_jssdk || false
|
||
appBaseUrl.value[app.app_code] = app.base_url || ''
|
||
appConfigSchema.value[app.app_code] = app.config_schema || []
|
||
}
|
||
appMap.value = map
|
||
} catch (e) {
|
||
console.error('获取应用列表失败:', e)
|
||
}
|
||
}
|
||
|
||
// 根据 schema 初始化配置默认值
|
||
function initConfigDefaults() {
|
||
const schema = currentConfigSchema.value
|
||
if (!schema.length) return
|
||
|
||
form.custom_configs = schema.map(s => ({
|
||
key: s.key,
|
||
value: s.default || '',
|
||
remark: ''
|
||
}))
|
||
}
|
||
|
||
async function fetchWechatApps(tenantId) {
|
||
if (!tenantId) {
|
||
wechatAppList.value = []
|
||
return
|
||
}
|
||
try {
|
||
const res = await api.get(`/api/tenant-wechat-apps/by-tenant/${tenantId}`)
|
||
wechatAppList.value = res.data || []
|
||
} catch (e) {
|
||
wechatAppList.value = []
|
||
}
|
||
}
|
||
|
||
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: '',
|
||
app_name: '',
|
||
wechat_app_id: null,
|
||
custom_configs: []
|
||
})
|
||
wechatAppList.value = []
|
||
dialogVisible.value = true
|
||
}
|
||
|
||
async function handleEdit(row) {
|
||
editingId.value = row.id
|
||
dialogTitle.value = '编辑应用订阅'
|
||
|
||
// 先获取 schema
|
||
const schema = appConfigSchema.value[row.app_code] || []
|
||
|
||
// 合并已有配置和 schema 默认值
|
||
const existingConfigs = row.custom_configs || []
|
||
const existingMap = {}
|
||
existingConfigs.forEach(c => { existingMap[c.key] = c })
|
||
|
||
// 构建完整的配置列表(包含 schema 中的所有配置项)
|
||
const mergedConfigs = schema.map(s => ({
|
||
key: s.key,
|
||
value: existingMap[s.key]?.value ?? s.default ?? '',
|
||
remark: existingMap[s.key]?.remark ?? ''
|
||
}))
|
||
|
||
// 添加 schema 中没有但已存在的配置(兼容旧数据)
|
||
existingConfigs.forEach(c => {
|
||
if (!schema.find(s => s.key === c.key)) {
|
||
mergedConfigs.push({ ...c })
|
||
}
|
||
})
|
||
|
||
Object.assign(form, {
|
||
tenant_id: row.tenant_id,
|
||
app_code: row.app_code,
|
||
app_name: row.app_name || '',
|
||
wechat_app_id: row.wechat_app_id || null,
|
||
custom_configs: mergedConfigs
|
||
})
|
||
await fetchWechatApps(row.tenant_id)
|
||
dialogVisible.value = true
|
||
}
|
||
|
||
// 自定义配置管理(用于没有 schema 定义时的手动添加)
|
||
function addCustomConfig() {
|
||
form.custom_configs.push({ key: '', value: '', remark: '' })
|
||
}
|
||
|
||
function removeCustomConfig(index) {
|
||
form.custom_configs.splice(index, 1)
|
||
}
|
||
|
||
|
||
async function handleSubmit() {
|
||
await formRef.value.validate()
|
||
|
||
const data = { ...form }
|
||
|
||
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
|
||
showToken(res.data.access_token, form.app_code)
|
||
}
|
||
dialogVisible.value = false
|
||
fetchList()
|
||
} catch (e) {
|
||
// 错误已在拦截器处理
|
||
}
|
||
}
|
||
|
||
async function handleDelete(row) {
|
||
await ElMessageBox.confirm(`确定删除「${row.app_code}」的订阅配置吗?`, '提示', {
|
||
type: 'warning'
|
||
})
|
||
|
||
try {
|
||
await api.delete(`/api/tenant-apps/${row.id}`)
|
||
ElMessage.success('删除成功')
|
||
fetchList()
|
||
} catch (e) {
|
||
// 错误已在拦截器处理
|
||
}
|
||
}
|
||
|
||
async function handleRegenerateToken(row) {
|
||
await ElMessageBox.confirm('重新生成 Token 将使旧 Token 失效,确定继续?', '提示', {
|
||
type: 'warning'
|
||
})
|
||
|
||
try {
|
||
const res = await api.post(`/api/tenant-apps/${row.id}/regenerate-token`)
|
||
showToken(res.data.access_token, row.app_code)
|
||
fetchList()
|
||
} catch (e) {
|
||
// 错误已在拦截器处理
|
||
}
|
||
}
|
||
|
||
function showToken(token, appCode) {
|
||
currentToken.value = token
|
||
currentAppUrl.value = appBaseUrl.value[appCode] || ''
|
||
tokenDialogVisible.value = true
|
||
}
|
||
|
||
function handleCopyToken() {
|
||
navigator.clipboard.writeText(currentToken.value).then(() => {
|
||
ElMessage.success('Token 已复制')
|
||
})
|
||
}
|
||
|
||
function handleCopyUrl() {
|
||
const url = `${currentAppUrl.value}?token=${currentToken.value}`
|
||
navigator.clipboard.writeText(url).then(() => {
|
||
ElMessage.success('链接已复制')
|
||
})
|
||
}
|
||
|
||
async function handleViewToken(row) {
|
||
try {
|
||
const res = await api.get(`/api/tenant-apps/${row.id}/token`)
|
||
currentToken.value = res.data.access_token
|
||
currentAppUrl.value = res.data.base_url || ''
|
||
tokenDialogVisible.value = true
|
||
} catch (e) {
|
||
// 错误已在拦截器处理
|
||
}
|
||
}
|
||
|
||
// 快速选择租户标签
|
||
function selectTenant(code) {
|
||
if (query.tenant_id === code) {
|
||
query.tenant_id = '' // 再次点击取消选中
|
||
} else {
|
||
query.tenant_id = code
|
||
}
|
||
handleSearch()
|
||
}
|
||
|
||
// 复制带 token 的链接
|
||
async function copyTokenLink(row) {
|
||
try {
|
||
const res = await api.get(`/api/tenant-apps/${row.id}/token`)
|
||
const token = res.data.access_token
|
||
const baseUrl = res.data.base_url || appBaseUrl.value[row.app_code] || ''
|
||
|
||
if (!baseUrl) {
|
||
ElMessage.warning('该应用未配置访问地址')
|
||
return
|
||
}
|
||
|
||
const url = `${baseUrl}?token=${token}`
|
||
await navigator.clipboard.writeText(url)
|
||
ElMessage.success('链接已复制')
|
||
} catch (e) {
|
||
ElMessage.error('复制失败')
|
||
}
|
||
}
|
||
|
||
// 打开批量添加对话框
|
||
function handleBatchCreate() {
|
||
batchForm.tenant_id = ''
|
||
batchForm.selected_apps = []
|
||
batchDialogVisible.value = true
|
||
}
|
||
|
||
// 监听批量添加租户变化
|
||
watch(() => batchForm.tenant_id, () => {
|
||
batchForm.selected_apps = []
|
||
})
|
||
|
||
// 全选/取消全选
|
||
function toggleSelectAll(checked) {
|
||
if (checked) {
|
||
batchForm.selected_apps = availableAppsForBatch.value.map(app => app.app_code)
|
||
} else {
|
||
batchForm.selected_apps = []
|
||
}
|
||
}
|
||
|
||
// 批量创建订阅
|
||
async function handleBatchSubmit() {
|
||
if (!batchForm.tenant_id) {
|
||
ElMessage.warning('请选择租户')
|
||
return
|
||
}
|
||
if (batchForm.selected_apps.length === 0) {
|
||
ElMessage.warning('请至少选择一个应用')
|
||
return
|
||
}
|
||
|
||
batchLoading.value = true
|
||
const results = { success: 0, failed: 0 }
|
||
const createdTokens = []
|
||
|
||
try {
|
||
for (const appCode of batchForm.selected_apps) {
|
||
try {
|
||
const res = await api.post('/api/tenant-apps', {
|
||
tenant_id: batchForm.tenant_id,
|
||
app_code: appCode,
|
||
app_name: '',
|
||
custom_configs: []
|
||
})
|
||
results.success++
|
||
|
||
// 收集生成的 token 信息
|
||
if (res.data.access_token) {
|
||
createdTokens.push({
|
||
app_code: appCode,
|
||
app_name: appMap.value[appCode] || appCode,
|
||
token: res.data.access_token,
|
||
base_url: appBaseUrl.value[appCode] || ''
|
||
})
|
||
}
|
||
} catch (e) {
|
||
results.failed++
|
||
console.error(`创建 ${appCode} 订阅失败:`, e)
|
||
}
|
||
}
|
||
|
||
if (results.success > 0) {
|
||
ElMessage.success(`成功创建 ${results.success} 个订阅${results.failed > 0 ? `,${results.failed} 个失败` : ''}`)
|
||
batchDialogVisible.value = false
|
||
fetchList()
|
||
|
||
// 如果有创建成功的,显示批量 Token 结果
|
||
if (createdTokens.length > 0) {
|
||
showBatchTokens(createdTokens)
|
||
}
|
||
} else {
|
||
ElMessage.error('创建失败,可能应用已被订阅')
|
||
}
|
||
} finally {
|
||
batchLoading.value = false
|
||
}
|
||
}
|
||
|
||
// 批量 Token 显示
|
||
const batchTokenDialogVisible = ref(false)
|
||
const batchTokenList = ref([])
|
||
|
||
function showBatchTokens(tokens) {
|
||
batchTokenList.value = tokens
|
||
batchTokenDialogVisible.value = true
|
||
}
|
||
|
||
// 复制单个 Token 链接
|
||
function copyBatchTokenLink(item) {
|
||
if (!item.base_url) {
|
||
ElMessage.warning('该应用未配置访问地址')
|
||
return
|
||
}
|
||
const url = `${item.base_url}?token=${item.token}`
|
||
navigator.clipboard.writeText(url).then(() => {
|
||
ElMessage.success(`${item.app_name} 链接已复制`)
|
||
})
|
||
}
|
||
|
||
// 复制所有 Token 链接
|
||
function copyAllTokenLinks() {
|
||
const links = batchTokenList.value
|
||
.filter(item => item.base_url)
|
||
.map(item => `${item.app_name}: ${item.base_url}?token=${item.token}`)
|
||
.join('\n\n')
|
||
|
||
if (!links) {
|
||
ElMessage.warning('没有可复制的链接')
|
||
return
|
||
}
|
||
|
||
navigator.clipboard.writeText(links).then(() => {
|
||
ElMessage.success('所有链接已复制')
|
||
})
|
||
}
|
||
|
||
onMounted(() => {
|
||
fetchTenants()
|
||
fetchApps()
|
||
fetchList()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="page-container">
|
||
<div class="page-header">
|
||
<div class="title">租户应用订阅</div>
|
||
<div class="header-actions">
|
||
<el-button v-if="authStore.isOperator" type="success" @click="handleBatchCreate">
|
||
<el-icon><Grid /></el-icon>
|
||
批量添加
|
||
</el-button>
|
||
<el-button v-if="authStore.isOperator" type="primary" @click="handleCreate">
|
||
<el-icon><Plus /></el-icon>
|
||
新建订阅
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="page-tip">
|
||
<el-alert type="info" :closable="false">
|
||
为租户订阅应用,生成访问 Token。外部应用可通过 Token 向平台验证身份。
|
||
</el-alert>
|
||
</div>
|
||
|
||
<!-- 租户快速筛选标签 -->
|
||
<div class="tenant-tags">
|
||
<span class="tag-label">租户筛选:</span>
|
||
<el-tag
|
||
v-for="tenant in tenantList"
|
||
:key="tenant.code"
|
||
:type="query.tenant_id === tenant.code ? '' : 'info'"
|
||
:effect="query.tenant_id === tenant.code ? 'dark' : 'plain'"
|
||
class="tenant-tag"
|
||
@click="selectTenant(tenant.code)"
|
||
>
|
||
{{ tenant.name }}
|
||
</el-tag>
|
||
<el-tag
|
||
v-if="query.tenant_id"
|
||
type="danger"
|
||
effect="plain"
|
||
class="tenant-tag clear-tag"
|
||
@click="selectTenant('')"
|
||
>
|
||
清除筛选
|
||
</el-tag>
|
||
</div>
|
||
|
||
<!-- 搜索栏 -->
|
||
<div class="search-bar">
|
||
<el-select v-model="query.app_code" placeholder="选择应用" clearable style="width: 150px">
|
||
<el-option v-for="app in appList" :key="app.app_code" :label="app.app_name" :value="app.app_code" />
|
||
</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 label="租户" width="120">
|
||
<template #default="{ row }">
|
||
<span class="cell-name">{{ getTenantName(row.tenant_id) }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="应用" width="150">
|
||
<template #default="{ row }">
|
||
<span class="cell-name">{{ getAppName(row.app_code) }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="app_name" label="备注" width="120" />
|
||
<el-table-column label="快捷链接" width="100">
|
||
<template #default="{ row }">
|
||
<el-button
|
||
v-if="row.access_token && appBaseUrl[row.app_code]"
|
||
type="primary"
|
||
link
|
||
size="small"
|
||
@click="copyTokenLink(row)"
|
||
>
|
||
<el-icon><CopyDocument /></el-icon>
|
||
复制
|
||
</el-button>
|
||
<span v-else style="color: #c0c4cc; font-size: 12px">-</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="企微应用" width="140">
|
||
<template #default="{ row }">
|
||
<template v-if="row.wechat_app">
|
||
<el-tag type="success" size="small">{{ row.wechat_app.name }}</el-tag>
|
||
</template>
|
||
<el-tag v-else type="info" size="small">未关联</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="配置" width="80">
|
||
<template #default="{ row }">
|
||
<el-tag v-if="row.custom_configs && row.custom_configs.length > 0" type="primary" size="small">
|
||
{{ row.custom_configs.length }} 项
|
||
</el-tag>
|
||
<span v-else style="color: #909399; font-size: 12px">-</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" width="70">
|
||
<template #default="{ row }">
|
||
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
|
||
{{ row.status === 1 ? '启用' : '禁用' }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="240" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button v-if="authStore.isOperator" type="primary" link size="small" @click="handleEdit(row)">编辑</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)">重置</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="750px"
|
||
:lock-scroll="true"
|
||
class="config-dialog"
|
||
>
|
||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" class="config-form">
|
||
<el-form-item label="租户" prop="tenant_id">
|
||
<el-select
|
||
v-model="form.tenant_id"
|
||
:disabled="!!editingId"
|
||
placeholder="请选择租户"
|
||
filterable
|
||
style="width: 100%"
|
||
>
|
||
<el-option
|
||
v-for="tenant in tenantList"
|
||
:key="tenant.code"
|
||
:label="`${tenant.name} (${tenant.code})`"
|
||
:value="tenant.code"
|
||
/>
|
||
</el-select>
|
||
</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} (${app.app_code})`" :value="app.app_code" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="备注名称">
|
||
<el-input v-model="form.app_name" placeholder="可选,用于区分同应用多配置" />
|
||
</el-form-item>
|
||
|
||
<template v-if="currentAppRequireJssdk">
|
||
<el-divider content-position="left">企业微信关联</el-divider>
|
||
|
||
<el-form-item label="关联企微应用">
|
||
<el-select
|
||
v-model="form.wechat_app_id"
|
||
placeholder="选择企微应用"
|
||
clearable
|
||
style="width: 100%"
|
||
>
|
||
<el-option
|
||
v-for="wa in wechatAppList"
|
||
:key="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">
|
||
该租户暂无企微应用,请先在「企微应用」中配置
|
||
</div>
|
||
</el-form-item>
|
||
</template>
|
||
|
||
<!-- 自定义配置 -->
|
||
<template v-if="currentConfigSchema.length > 0 || form.custom_configs.length > 0">
|
||
<el-divider content-position="left">应用配置</el-divider>
|
||
|
||
<div class="custom-configs-section">
|
||
<!-- 根据 schema 渲染表单 -->
|
||
<template v-for="(schema, index) in currentConfigSchema" :key="schema.key">
|
||
<div class="config-item-schema">
|
||
<div class="config-label">
|
||
{{ schema.label }}
|
||
<span v-if="schema.required" class="required-star">*</span>
|
||
</div>
|
||
|
||
<!-- text 类型 - 短文本直接编辑,长文本用弹窗 -->
|
||
<template v-if="schema.type === 'text'">
|
||
<!-- 长文本使用预览+弹窗编辑 -->
|
||
<div
|
||
v-if="isLongText(form.custom_configs[index]?.value) || schema.key?.includes('prompt')"
|
||
class="text-preview-container"
|
||
>
|
||
<div class="text-preview" @click="openTextEdit(index, schema)">
|
||
{{ getTextPreview(form.custom_configs[index]?.value, 100) }}
|
||
</div>
|
||
<el-button
|
||
type="primary"
|
||
size="small"
|
||
@click="openTextEdit(index, schema)"
|
||
>
|
||
<el-icon><Edit /></el-icon>
|
||
编辑
|
||
</el-button>
|
||
</div>
|
||
<!-- 短文本直接编辑 -->
|
||
<el-input
|
||
v-else
|
||
v-model="form.custom_configs[index].value"
|
||
type="textarea"
|
||
:rows="2"
|
||
:autosize="{ minRows: 1, maxRows: 4 }"
|
||
:placeholder="schema.placeholder || '请输入'"
|
||
/>
|
||
</template>
|
||
|
||
<!-- radio 类型 -->
|
||
<template v-else-if="schema.type === 'radio'">
|
||
<el-radio-group v-model="form.custom_configs[index].value">
|
||
<el-radio
|
||
v-for="opt in schema.options"
|
||
:key="opt"
|
||
:value="opt"
|
||
>
|
||
{{ getOptionLabel(schema, opt) }}
|
||
</el-radio>
|
||
</el-radio-group>
|
||
</template>
|
||
|
||
<!-- select 类型 -->
|
||
<template v-else-if="schema.type === 'select'">
|
||
<el-select v-model="form.custom_configs[index].value" placeholder="请选择" style="width: 100%">
|
||
<el-option
|
||
v-for="opt in schema.options"
|
||
:key="opt"
|
||
:label="getOptionLabel(schema, opt)"
|
||
:value="opt"
|
||
/>
|
||
</el-select>
|
||
</template>
|
||
|
||
<!-- switch 类型 -->
|
||
<template v-else-if="schema.type === 'switch'">
|
||
<el-switch
|
||
v-model="form.custom_configs[index].value"
|
||
active-value="true"
|
||
inactive-value="false"
|
||
/>
|
||
</template>
|
||
|
||
<!-- 备注输入 -->
|
||
<el-input
|
||
v-model="form.custom_configs[index].remark"
|
||
placeholder="备注(可选)"
|
||
style="margin-top: 8px; width: 300px"
|
||
size="small"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 没有 schema 定义时显示手动配置 -->
|
||
<template v-if="currentConfigSchema.length === 0">
|
||
<div v-for="(config, index) in form.custom_configs" :key="index" class="config-item">
|
||
<div class="config-row">
|
||
<el-input
|
||
v-model="config.key"
|
||
placeholder="配置键 (如: industry)"
|
||
style="width: 150px"
|
||
/>
|
||
<el-input
|
||
v-model="config.remark"
|
||
placeholder="备注说明"
|
||
style="width: 180px; margin-left: 8px"
|
||
/>
|
||
<el-button
|
||
type="danger"
|
||
:icon="Delete"
|
||
circle
|
||
size="small"
|
||
style="margin-left: 8px"
|
||
@click="removeCustomConfig(index)"
|
||
/>
|
||
</div>
|
||
<el-input
|
||
v-model="config.value"
|
||
type="textarea"
|
||
:rows="3"
|
||
:autosize="{ minRows: 2, maxRows: 10 }"
|
||
placeholder="配置值(支持超长文本,如提示词等)"
|
||
style="margin-top: 8px"
|
||
/>
|
||
</div>
|
||
|
||
<el-button type="primary" plain @click="addCustomConfig" style="margin-top: 8px">
|
||
<el-icon><Plus /></el-icon>
|
||
添加配置项
|
||
</el-button>
|
||
|
||
<div v-if="form.custom_configs.length === 0" class="config-empty-tip">
|
||
该应用暂无配置项定义
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="dialogVisible = false">取消</el-button>
|
||
<el-button type="primary" @click="handleSubmit">确定</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- Token 显示对话框 -->
|
||
<el-dialog v-model="tokenDialogVisible" title="访问 Token" width="600px">
|
||
<div class="token-dialog-content">
|
||
<el-alert type="warning" :closable="false" style="margin-bottom: 16px">
|
||
请妥善保管 Token,它是应用访问平台的凭证
|
||
</el-alert>
|
||
|
||
<div class="token-section">
|
||
<div class="token-label">Access Token:</div>
|
||
<el-input v-model="currentToken" readonly>
|
||
<template #append>
|
||
<el-button @click="handleCopyToken">复制</el-button>
|
||
</template>
|
||
</el-input>
|
||
</div>
|
||
|
||
<div v-if="currentAppUrl" class="token-section">
|
||
<div class="token-label">完整访问链接:</div>
|
||
<el-input :model-value="`${currentAppUrl}?token=${currentToken}`" readonly type="textarea" :rows="2" />
|
||
<el-button type="primary" style="margin-top: 8px" @click="handleCopyUrl">
|
||
<el-icon><CopyDocument /></el-icon>
|
||
复制链接
|
||
</el-button>
|
||
</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">
|
||
外部应用可调用此接口验证 Token 有效性,获取租户和企微配置
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<template #footer>
|
||
<el-button @click="tokenDialogVisible = false">关闭</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 批量添加对话框 -->
|
||
<el-dialog v-model="batchDialogVisible" title="批量添加应用订阅" width="600px">
|
||
<el-form label-width="100px">
|
||
<el-form-item label="选择租户" required>
|
||
<el-select
|
||
v-model="batchForm.tenant_id"
|
||
placeholder="请选择租户"
|
||
filterable
|
||
style="width: 100%"
|
||
>
|
||
<el-option
|
||
v-for="tenant in tenantList"
|
||
:key="tenant.code"
|
||
:label="`${tenant.name} (${tenant.code})`"
|
||
:value="tenant.code"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
|
||
<el-form-item label="选择应用" required>
|
||
<div class="batch-apps-container">
|
||
<div class="batch-apps-header">
|
||
<el-checkbox
|
||
:model-value="isAllSelected"
|
||
:indeterminate="isIndeterminate"
|
||
@change="toggleSelectAll"
|
||
:disabled="!batchForm.tenant_id || availableAppsForBatch.length === 0"
|
||
>
|
||
全选 ({{ batchForm.selected_apps.length }}/{{ availableAppsForBatch.length }})
|
||
</el-checkbox>
|
||
</div>
|
||
|
||
<el-empty
|
||
v-if="!batchForm.tenant_id"
|
||
description="请先选择租户"
|
||
:image-size="60"
|
||
/>
|
||
<el-empty
|
||
v-else-if="availableAppsForBatch.length === 0"
|
||
description="该租户已订阅所有应用"
|
||
:image-size="60"
|
||
/>
|
||
<el-checkbox-group
|
||
v-else
|
||
v-model="batchForm.selected_apps"
|
||
class="batch-apps-list"
|
||
>
|
||
<el-checkbox
|
||
v-for="app in availableAppsForBatch"
|
||
:key="app.app_code"
|
||
:value="app.app_code"
|
||
class="batch-app-item"
|
||
>
|
||
{{ app.app_name }}
|
||
<span class="app-code">({{ app.app_code }})</span>
|
||
</el-checkbox>
|
||
</el-checkbox-group>
|
||
</div>
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="batchDialogVisible = false">取消</el-button>
|
||
<el-button
|
||
type="primary"
|
||
:loading="batchLoading"
|
||
:disabled="!batchForm.tenant_id || batchForm.selected_apps.length === 0"
|
||
@click="handleBatchSubmit"
|
||
>
|
||
创建 {{ batchForm.selected_apps.length }} 个订阅
|
||
</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 批量 Token 结果对话框 -->
|
||
<el-dialog v-model="batchTokenDialogVisible" title="批量创建成功" width="700px">
|
||
<el-alert type="success" :closable="false" style="margin-bottom: 16px">
|
||
已成功创建 {{ batchTokenList.length }} 个应用订阅,以下是访问链接:
|
||
</el-alert>
|
||
|
||
<div class="batch-token-list">
|
||
<div
|
||
v-for="item in batchTokenList"
|
||
:key="item.app_code"
|
||
class="batch-token-item"
|
||
>
|
||
<div class="batch-token-info">
|
||
<span class="batch-token-name">{{ item.app_name }}</span>
|
||
<el-tag v-if="!item.base_url" type="warning" size="small">无链接</el-tag>
|
||
</div>
|
||
<div v-if="item.base_url" class="batch-token-url">
|
||
{{ item.base_url }}?token={{ item.token.slice(0, 20) }}...
|
||
</div>
|
||
<el-button
|
||
v-if="item.base_url"
|
||
type="primary"
|
||
size="small"
|
||
@click="copyBatchTokenLink(item)"
|
||
>
|
||
<el-icon><CopyDocument /></el-icon>
|
||
复制
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<template #footer>
|
||
<el-button @click="copyAllTokenLinks" type="success">
|
||
<el-icon><CopyDocument /></el-icon>
|
||
复制全部链接
|
||
</el-button>
|
||
<el-button @click="batchTokenDialogVisible = false">关闭</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 长文本编辑弹窗 - 接近全屏 -->
|
||
<el-dialog
|
||
v-model="textEditDialogVisible"
|
||
:title="`编辑:${textEditData.label}`"
|
||
width="90vw"
|
||
top="5vh"
|
||
:close-on-click-modal="false"
|
||
:lock-scroll="true"
|
||
:append-to-body="true"
|
||
class="text-edit-dialog"
|
||
>
|
||
<div class="text-edit-wrapper">
|
||
<el-input
|
||
v-model="textEditData.value"
|
||
type="textarea"
|
||
:placeholder="textEditData.placeholder"
|
||
class="text-edit-textarea"
|
||
resize="none"
|
||
/>
|
||
</div>
|
||
<div class="text-edit-stats">
|
||
字符数:{{ textEditData.value?.length || 0 }}
|
||
</div>
|
||
<template #footer>
|
||
<el-button @click="textEditDialogVisible = false">取消</el-button>
|
||
<el-button type="primary" @click="saveTextEdit">保存</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.page-tip {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
/* 租户标签筛选 */
|
||
.tenant-tags {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
margin-bottom: 16px;
|
||
padding: 12px 16px;
|
||
background: #f5f7fa;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.tag-label {
|
||
color: #606266;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
margin-right: 4px;
|
||
}
|
||
|
||
.tenant-tag {
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.tenant-tag:hover {
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.clear-tag {
|
||
margin-left: 8px;
|
||
}
|
||
|
||
.cell-name {
|
||
font-weight: 500;
|
||
color: #303133;
|
||
}
|
||
|
||
.header-actions {
|
||
display: flex;
|
||
gap: 10px;
|
||
}
|
||
|
||
/* 批量添加对话框样式 */
|
||
.batch-apps-container {
|
||
border: 1px solid #dcdfe6;
|
||
border-radius: 8px;
|
||
padding: 12px;
|
||
background: #fafafa;
|
||
}
|
||
|
||
.batch-apps-header {
|
||
padding-bottom: 12px;
|
||
border-bottom: 1px solid #ebeef5;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.batch-apps-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
max-height: 300px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.batch-app-item {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 8px 12px;
|
||
background: white;
|
||
border-radius: 6px;
|
||
border: 1px solid #ebeef5;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.batch-app-item:hover {
|
||
border-color: #409eff;
|
||
}
|
||
|
||
.app-code {
|
||
color: #909399;
|
||
font-size: 12px;
|
||
margin-left: 4px;
|
||
}
|
||
|
||
/* 批量 Token 结果样式 */
|
||
.batch-token-list {
|
||
max-height: 400px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.batch-token-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 12px;
|
||
background: #f5f7fa;
|
||
border-radius: 8px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.batch-token-item:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.batch-token-info {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.batch-token-name {
|
||
font-weight: 500;
|
||
color: #303133;
|
||
}
|
||
|
||
.batch-token-url {
|
||
flex: 1;
|
||
color: #909399;
|
||
font-size: 12px;
|
||
margin: 0 16px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* 长文本预览样式 */
|
||
.text-preview-container {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 12px;
|
||
}
|
||
|
||
.text-preview {
|
||
flex: 1;
|
||
padding: 10px 12px;
|
||
background: #f5f7fa;
|
||
border: 1px solid #dcdfe6;
|
||
border-radius: 6px;
|
||
color: #606266;
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
min-height: 40px;
|
||
max-height: 80px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.text-preview:hover {
|
||
border-color: #409eff;
|
||
background: #ecf5ff;
|
||
}
|
||
|
||
/* 长文本编辑弹窗样式 - 接近全屏 */
|
||
.text-edit-dialog :deep(.el-dialog) {
|
||
max-width: 1200px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.text-edit-dialog :deep(.el-dialog__body) {
|
||
padding: 16px 20px;
|
||
}
|
||
|
||
.text-edit-wrapper {
|
||
height: calc(70vh - 60px);
|
||
}
|
||
|
||
.text-edit-wrapper :deep(.el-textarea) {
|
||
height: 100%;
|
||
}
|
||
|
||
.text-edit-wrapper :deep(.el-textarea__inner) {
|
||
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
|
||
font-size: 14px;
|
||
line-height: 1.7;
|
||
height: 100% !important;
|
||
resize: none;
|
||
padding: 16px;
|
||
}
|
||
|
||
.text-edit-stats {
|
||
margin-top: 12px;
|
||
text-align: right;
|
||
color: #909399;
|
||
font-size: 12px;
|
||
}
|
||
|
||
/* 配置对话框优化 */
|
||
.config-dialog :deep(.el-dialog__body) {
|
||
max-height: 60vh;
|
||
overflow-y: auto;
|
||
padding-right: 20px;
|
||
}
|
||
|
||
.config-form {
|
||
padding-right: 10px;
|
||
}
|
||
|
||
.token-dialog-content {
|
||
padding: 0 10px;
|
||
}
|
||
|
||
.token-section {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.token-label {
|
||
font-weight: 500;
|
||
margin-bottom: 8px;
|
||
color: #303133;
|
||
}
|
||
|
||
/* 自定义配置样式 */
|
||
.custom-configs-section {
|
||
padding: 0 10px;
|
||
}
|
||
|
||
.config-item {
|
||
background: #f5f7fa;
|
||
border-radius: 8px;
|
||
padding: 12px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.config-item-schema {
|
||
background: #f5f7fa;
|
||
border-radius: 8px;
|
||
padding: 16px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.config-label {
|
||
font-weight: 500;
|
||
color: #303133;
|
||
margin-bottom: 10px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.required-star {
|
||
color: #f56c6c;
|
||
margin-left: 4px;
|
||
}
|
||
|
||
.config-row {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.config-empty-tip {
|
||
color: #909399;
|
||
font-size: 13px;
|
||
text-align: center;
|
||
padding: 20px 0;
|
||
}
|
||
</style>
|