Files
000-platform/frontend/src/views/app-config/index.vue
Admin e45fe8128c
All checks were successful
continuous-integration/drone/push Build is passing
fix: 弹窗打开时锁定背景页面滚动
2026-01-28 11:14:16 +08:00

1363 lines
38 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, 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>