Files
000-platform/frontend/src/views/app-config/index.vue
Admin 104487f082
All checks were successful
continuous-integration/drone/push Build is passing
feat: 实现定时任务系统
- 新增 platform_scheduled_tasks, platform_task_logs, platform_script_vars, platform_secrets 数据库表
- 实现 ScriptSDK 提供 AI/通知/DB/HTTP/变量存储/参数获取等功能
- 实现安全的脚本执行器,支持沙箱环境和禁止危险操作
- 实现 APScheduler 调度服务,支持简单时间点和 CRON 表达式
- 新增定时任务 API 路由,包含 CRUD、执行、日志、密钥管理
- 新增定时任务前端页面,支持脚本编辑、测试运行、日志查看
2026-01-28 16:38:19 +08:00

448 lines
14 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 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 appList = ref([])
const appRequireJssdk = ref({}) // app_code -> require_jssdk
const appBaseUrl = ref({}) // app_code -> base_url
// 企微应用列表(按租户)
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
})
// 当前选择的应用是否需要 JS-SDK
const currentAppRequireJssdk = computed(() => {
return appRequireJssdk.value[form.app_code] || false
})
// 验证 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
})
// 查看 Token 对话框
const tokenDialogVisible = ref(false)
const currentToken = ref('')
const currentAppUrl = ref('')
async function fetchTenants() {
try {
const res = await api.get('/api/tenants', { params: { size: 1000 } })
tenantList.value = res.data.items || []
} catch (e) {
console.error('获取租户列表失败:', e)
}
}
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 }))
for (const app of apps) {
appRequireJssdk.value[app.app_code] = app.require_jssdk || false
appBaseUrl.value[app.app_code] = app.base_url || ''
}
} catch (e) {
console.error('获取应用列表失败:', e)
}
}
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
})
wechatAppList.value = []
dialogVisible.value = true
}
async 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_app_id: row.wechat_app_id || null
})
await fetchWechatApps(row.tenant_id)
dialogVisible.value = true
}
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) {
// 这里需要后端返回真实 token暂时用 placeholder
// 实际生产中可能需要单独 API 获取
showToken(row.access_token === '******' ? '需要调用API获取' : row.access_token, row.app_code)
}
onMounted(() => {
fetchTenants()
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="page-tip">
<el-alert type="info" :closable="false">
为租户订阅应用生成访问 Token外部应用可通过 Token 向平台验证身份
</el-alert>
</div>
<!-- 搜索栏 -->
<div class="search-bar">
<el-select v-model="query.tenant_id" placeholder="选择租户" clearable filterable style="width: 200px">
<el-option
v-for="tenant in tenantList"
:key="tenant.code"
:label="`${tenant.name} (${tenant.code})`"
:value="tenant.code"
/>
</el-select>
<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 prop="tenant_id" label="租户ID" width="120" />
<el-table-column prop="app_code" label="应用代码" width="150" />
<el-table-column prop="app_name" label="备注名称" width="150" />
<el-table-column label="企微应用" width="180">
<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="Token 状态" width="120">
<template #default="{ row }">
<el-tag v-if="row.access_token" type="success" size="small">已生成</el-tag>
<el-tag v-else type="danger" size="small">未生成</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<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="280" 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)">重置Token</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="550px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<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>
</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>
</div>
</template>
<style scoped>
.page-tip {
margin-bottom: 16px;
}
.token-dialog-content {
padding: 0 10px;
}
.token-section {
margin-bottom: 16px;
}
.token-label {
font-weight: 500;
margin-bottom: 8px;
color: #303133;
}
</style>