Files
AUTO-MAS-test/frontend/src/views/Scripts.vue
2025-09-19 21:36:09 +08:00

966 lines
25 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.

<template>
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<a-spin size="large" tip="加载中,请稍候..." />
</div>
<!-- 主要内容 -->
<div class="scripts-header">
<div class="header-left">
<h1 class="page-title">脚本管理</h1>
</div>
<a-space size="middle">
<a-button type="primary" size="large" @click="handleAddScript" class="link">
<template #icon>
<PlusOutlined />
</template>
新建脚本
</a-button>
</a-space>
</div>
<!-- 空状态 -->
<div v-if="scripts.length === 0" class="empty-state">
<div class="empty-content">
<div class="empty-image-container">
<img src="@/assets/NoData.png" alt="暂无数据" class="empty-image" />
</div>
<div class="empty-text-content">
<h3 class="empty-title">暂无脚本</h3>
<p class="empty-description">您还没有创建任何脚本</p>
</div>
</div>
</div>
<ScriptTable
:scripts="scripts"
:active-connections="activeConnections"
@edit="handleEditScript"
@delete="handleDeleteScript"
@add-user="handleAddUser"
@edit-user="handleEditUser"
@delete-user="handleDeleteUser"
@start-maa-config="handleStartMAAConfig"
@save-maa-config="handleSaveMAAConfig"
@toggle-user-status="handleToggleUserStatus"
/>
<!-- 脚本类型选择弹窗 -->
<a-modal
v-model:open="typeSelectVisible"
title="选择脚本类型"
:confirm-loading="addLoading"
@ok="handleConfirmAddScript"
@cancel="typeSelectVisible = false"
class="type-select-modal"
width="500px"
ok-text="确定"
cancel-text="取消"
>
<div class="type-selection">
<a-radio-group v-model:value="selectedType" class="type-radio-group">
<a-radio-button value="MAA" class="type-option">
<div class="type-content">
<div class="type-logo-container">
<img src="@/assets/MAA.png" alt="MAA" class="type-logo" />
</div>
<div class="type-info">
<div class="type-title">MAA脚本</div>
<div class="type-description">明日方舟自动化脚本支持多账号日常代理等功能</div>
</div>
</div>
</a-radio-button>
<a-radio-button value="General" class="type-option">
<div class="type-content">
<div class="type-logo-container">
<img src="@/assets/AUTO-MAS.ico" alt="AUTO-MAS" class="type-logo" />
</div>
<div class="type-info">
<div class="type-title">通用脚本</div>
<div class="type-description">通用自动化脚本适用于所有具备日志文件的脚本</div>
</div>
</div>
</a-radio-button>
</a-radio-group>
</div>
</a-modal>
<!-- 通用脚本创建方式选择弹窗 -->
<a-modal
v-model:open="generalModeSelectVisible"
title="选择创建方式"
:confirm-loading="addLoading"
@ok="handleConfirmGeneralMode"
@cancel="generalModeSelectVisible = false"
class="general-mode-modal"
width="600px"
ok-text="确定"
cancel-text="返回"
>
<div class="mode-selection">
<a-radio-group v-model:value="selectedGeneralMode" class="mode-radio-group">
<a-radio-button value="template" class="mode-option">
<div class="mode-content">
<div class="mode-icon">
<FileTextOutlined />
</div>
<div class="mode-info">
<div class="mode-title">从模板创建</div>
<div class="mode-description">选择现有的配置模板快速创建脚本</div>
</div>
</div>
</a-radio-button>
<a-radio-button value="custom" class="mode-option">
<div class="mode-content">
<div class="mode-icon">
<SettingOutlined />
</div>
<div class="mode-info">
<div class="mode-title">自定义配置</div>
<div class="mode-description">从空白配置开始完全自定义脚本设置</div>
</div>
</div>
</a-radio-button>
</a-radio-group>
</div>
</a-modal>
<!-- 模板选择弹窗 -->
<a-modal
v-model:open="templateSelectVisible"
title="选择配置模板"
:confirm-loading="templateLoading"
@ok="handleConfirmTemplate"
@cancel="handleCancelTemplate"
class="template-select-modal"
width="1000px"
ok-text="使用此模板"
cancel-text="返回"
:ok-button-props="{ disabled: !selectedTemplate }"
>
<div class="template-selection">
<a-spin :spinning="templateLoading">
<div v-if="templates.length === 0 && !templateLoading" class="no-templates">
<div class="no-templates-content">
<FileSearchOutlined class="no-templates-icon" />
<h3>暂无可用模板</h3>
<p>当前没有找到任何配置模板请稍后再试或联系管理员</p>
</div>
</div>
<div v-else class="templates-container">
<div class="templates-header">
<div class="templates-count">
<span class="count-badge">{{ filteredTemplates.length }}</span>
<span class="count-text">个可用模板</span>
</div>
<div class="search-container">
<a-input
v-model:value="searchKeyword"
placeholder="搜索模板名称、作者或描述..."
allow-clear
class="template-search"
>
<template #prefix>
<FileSearchOutlined />
</template>
</a-input>
</div>
</div>
<div class="templates-list">
<div v-if="filteredTemplates.length === 0" class="no-search-results">
<FileSearchOutlined class="no-results-icon" />
<p>未找到匹配的模板</p>
<p class="no-results-tip">请尝试其他关键词</p>
</div>
<div
v-for="template in filteredTemplates"
:key="template.configName"
:class="[
'template-item',
{ selected: selectedTemplate?.configName === template.configName },
]"
@click="selectedTemplate = template"
>
<div class="template-content">
<div class="template-header">
<div class="template-info">
<h3 class="template-name">{{ template.configName }}</h3>
<div class="template-meta">
<span class="template-author">
<UserOutlined />
{{ template.author || '未知作者' }}
</span>
<span class="template-time">
<ClockCircleOutlined />
{{ template.createTime || '未知时间' }}
</span>
</div>
</div>
<!-- <div class="template-selector">-->
<!-- <a-radio :checked="selectedTemplate?.configName === template.configName" />-->
<!-- </div>-->
</div>
<div
class="template-description"
v-html="parseMarkdown(template.description)"
></div>
</div>
</div>
</div>
</div>
</a-spin>
</div>
</a-modal>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import {
ClockCircleOutlined,
FileSearchOutlined,
FileTextOutlined,
PlusOutlined,
SettingOutlined,
UserOutlined,
} from '@ant-design/icons-vue'
import ScriptTable from '@/components/ScriptTable.vue'
import type { Script, ScriptType, User } from '@/types/script'
import { useScriptApi } from '@/composables/useScriptApi'
import { useUserApi } from '@/composables/useUserApi'
import { useWebSocket } from '@/composables/useWebSocket'
import { useTemplateApi, type WebConfigTemplate } from '@/composables/useTemplateApi'
import { Service } from '@/api/services/Service'
import { TaskCreateIn } from '@/api/models/TaskCreateIn'
import MarkdownIt from 'markdown-it'
const router = useRouter()
const { addScript, deleteScript, getScriptsWithUsers, loading } = useScriptApi()
const { updateUser, deleteUser } = useUserApi()
const { subscribe, unsubscribe } = useWebSocket()
const { getWebConfigTemplates, importScriptFromWeb } = useTemplateApi()
// 初始化markdown解析器
const md = new MarkdownIt({
html: false,
linkify: true,
typographer: true,
})
const scripts = ref<Script[]>([])
const typeSelectVisible = ref(false)
const generalModeSelectVisible = ref(false)
const templateSelectVisible = ref(false)
const selectedType = ref<ScriptType>('MAA')
const selectedGeneralMode = ref('template')
const selectedTemplate = ref<WebConfigTemplate | null>(null)
const templates = ref<WebConfigTemplate[]>([])
const addLoading = ref(false)
const templateLoading = ref(false)
const searchKeyword = ref('')
// WebSocket连接管理
const activeConnections = ref<Map<string, string>>(new Map()) // scriptId -> websocketId
// 解析模板描述的markdown
const parseMarkdown = (text: string) => {
if (!text) return '暂无描述信息'
return md.render(text)
}
// 过滤模板
const filteredTemplates = computed(() => {
if (!searchKeyword.value.trim()) {
return templates.value
}
const keyword = searchKeyword.value.toLowerCase()
return templates.value.filter(
template =>
template.configName.toLowerCase().includes(keyword) ||
(template.author && template.author.toLowerCase().includes(keyword)) ||
(template.description && template.description.toLowerCase().includes(keyword))
)
})
onMounted(() => {
loadScripts()
})
const loadScripts = async () => {
try {
const scriptDetails = await getScriptsWithUsers()
// 将 ScriptDetail 转换为 Script 格式(为了兼容现有的表格组件)
scripts.value = scriptDetails.map(detail => ({
id: detail.uid,
type: detail.type,
name: detail.name,
config: detail.config,
users: detail.users || [],
createTime: detail.createTime || new Date().toLocaleString(),
}))
} catch (error) {
console.error('加载脚本列表失败:', error)
message.error('加载脚本列表失败')
}
}
const handleAddScript = () => {
selectedType.value = 'MAA'
typeSelectVisible.value = true
}
const handleConfirmAddScript = async () => {
if (selectedType.value === 'General') {
// 如果选择通用脚本,进入创建方式选择
typeSelectVisible.value = false
generalModeSelectVisible.value = true
return
}
// MAA脚本直接创建
addLoading.value = true
try {
const result = await addScript(selectedType.value)
if (result) {
message.success(result.message)
typeSelectVisible.value = false
// 跳转到编辑页面传递API返回的数据
router.push({
path: `/scripts/${result.scriptId}/edit/maa`,
state: {
scriptData: {
id: result.scriptId,
type: selectedType.value,
config: result.data,
},
},
})
}
} catch (error) {
console.error('添加脚本失败:', error)
} finally {
addLoading.value = false
}
}
const handleConfirmGeneralMode = async () => {
if (selectedGeneralMode.value === 'template') {
// 加载模板列表并打开模板选择弹窗
await loadTemplates()
generalModeSelectVisible.value = false
templateSelectVisible.value = true
} else {
// 自定义配置 - 直接创建通用脚本
generalModeSelectVisible.value = false
addLoading.value = true
try {
const result = await addScript('General')
if (result) {
message.success(result.message)
router.push({
path: `/scripts/${result.scriptId}/edit/general`,
state: {
scriptData: {
id: result.scriptId,
type: 'General',
config: result.data,
},
},
})
}
} catch (error) {
console.error('添加脚本失败:', error)
} finally {
addLoading.value = false
}
}
}
const loadTemplates = async () => {
templateLoading.value = true
try {
templates.value = await getWebConfigTemplates()
selectedTemplate.value = null
} catch (error) {
console.error('加载模板列表失败:', error)
} finally {
templateLoading.value = false
}
}
const handleConfirmTemplate = async () => {
if (!selectedTemplate.value) {
message.warning('请先选择一个模板')
return
}
templateLoading.value = true
try {
// 1. 先创建通用脚本
const createResult = await addScript('General')
if (!createResult) {
return
}
// 2. 使用模板URL导入配置
const importResult = await importScriptFromWeb(
createResult.scriptId,
selectedTemplate.value.downloadUrl
)
if (importResult) {
message.success(`已根据模板 "${selectedTemplate.value.configName}" 创建脚本`)
templateSelectVisible.value = false
selectedTemplate.value = null
// 刷新脚本列表
await loadScripts()
// 跳转到编辑页面不传递state数据让编辑页面从API重新加载最新配置
router.push(`/scripts/${createResult.scriptId}/edit/general`)
}
} catch (error) {
console.error('使用模板创建脚本失败:', error)
} finally {
templateLoading.value = false
}
}
const handleCancelTemplate = () => {
templateSelectVisible.value = false
selectedTemplate.value = null
// 返回到创建方式选择
generalModeSelectVisible.value = true
}
const handleEditScript = (script: Script) => {
// 根据脚本类型跳转到对应的编辑页面
if (script.type === 'MAA') {
router.push(`/scripts/${script.id}/edit/maa`)
} else {
router.push(`/scripts/${script.id}/edit/general`)
}
}
const handleDeleteScript = async (script: Script) => {
const result = await deleteScript(script.id)
if (result) {
message.success('脚本删除成功')
loadScripts()
}
}
const handleAddUser = (script: Script) => {
// 根据条件判断跳转到 MAA 还是通用用户添加页面
if (script.type === 'MAA') {
router.push(`/scripts/${script.id}/users/add/maa`) // 跳转到 MAA 用户添加页面
} else {
router.push(`/scripts/${script.id}/users/add/general`) // 跳转到通用用户添加页面
}
}
const handleEditUser = (user: User) => {
// 从用户数据中找到对应的脚本
const script = scripts.value.find(s => s.users.some(u => u.id === user.id))
if (script) {
// 判断是 MAA 用户还是通用用户
if (user.Info.Server) {
// 跳转到 MAA 用户编辑页面
router.push(`/scripts/${script.id}/users/${user.id}/edit/maa`)
} else {
// 跳转到通用用户编辑页面
router.push(`/scripts/${script.id}/users/${user.id}/edit/general`)
}
} else {
message.error('找不到对应的脚本')
}
}
const handleDeleteUser = async (user: User) => {
// 从用户数据中找到对应的脚本
const script = scripts.value.find(s => s.users.some(u => u.id === user.id))
if (!script) {
message.error('找不到对应的脚本')
return
}
const result = await deleteUser(script.id, user.id)
if (result) {
// 删除成功后,从本地数据中移除用户
const userIndex = script.users.findIndex(u => u.id === user.id)
if (userIndex > -1) {
script.users.splice(userIndex, 1)
}
message.success('用户删除成功')
}
}
const handleStartMAAConfig = async (script: Script) => {
try {
// 检查是否已有连接
const existingConnection = activeConnections.value.get(script.id)
if (existingConnection) {
message.warning('该脚本已在配置中,请先保存配置')
return
}
// 调用启动配置任务API
const response = await Service.addTaskApiDispatchStartPost({
taskId: script.id,
mode: TaskCreateIn.mode.SettingScriptMode,
})
if (response.code === 200) {
// 订阅WebSocket消息
subscribe(response.websocketId, {
onError: error => {
console.error(`脚本 ${script.name} 连接错误:`, error)
message.error(`MAA配置连接失败: ${error}`)
activeConnections.value.delete(script.id)
},
onResult: (data: any) => {
// 处理配置完成消息(兼容任何结构)
if (data.Accomplish) {
message.success(`${script.name} 配置已完成`)
activeConnections.value.delete(script.id)
}
},
})
// 记录连接和websocketId
activeConnections.value.set(script.id, response.websocketId)
message.success(`已启动 ${script.name} 的MAA配置`)
// 设置自动断开连接的定时器30分钟后
setTimeout(
() => {
if (activeConnections.value.has(script.id)) {
const wsId = activeConnections.value.get(script.id)
if (wsId) {
unsubscribe(wsId)
}
activeConnections.value.delete(script.id)
message.info(`${script.name} 配置会话已超时断开`)
}
},
30 * 60 * 1000
) // 30分钟
} else {
message.error(response.message || '启动MAA配置失败')
}
} catch (error) {
console.error('启动MAA配置失败:', error)
message.error('启动MAA配置失败')
}
}
const handleSaveMAAConfig = async (script: Script) => {
try {
const websocketId = activeConnections.value.get(script.id)
if (!websocketId) {
message.error('未找到活动的配置会话')
return
}
// 调用停止配置任务API
const response = await Service.stopTaskApiDispatchStopPost({
taskId: websocketId,
})
if (response.code === 200) {
// 取消订阅
unsubscribe(websocketId)
activeConnections.value.delete(script.id)
message.success(`${script.name} 的配置已保存`)
} else {
message.error(response.message || '保存配置失败')
}
} catch (error) {
console.error('保存MAA配置失败:', error)
message.error('保存MAA配置失败')
}
}
const handleToggleUserStatus = async (user: User) => {
try {
// 找到该用户对应的脚本
const script = scripts.value.find(s => s.users.some(u => u.id === user.id))
if (!script) {
message.error('找不到对应的脚本')
return
}
const newStatus = !user.Info.Status
// 调用 updateUser API
const result = await updateUser(script.id, user.id, {
Info: {
...user.Info,
Status: newStatus,
},
})
if (result) {
// 更新本地数据状态
const targetUser = script.users.find(u => u.id === user.id)
if (targetUser) {
targetUser.Info.Status = newStatus
}
message.success('用户状态更新成功')
}
} catch (error) {
console.error('更新用户状态失败:', error)
message.error('更新用户状态失败')
}
}
</script>
<style scoped>
.loading-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.scripts-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 24px;
padding: 0 4px;
}
.header-left {
flex: 1;
}
.page-title {
margin: 0 0 8px 0;
font-size: 32px;
font-weight: 700;
color: var(--ant-color-text);
background: linear-gradient(135deg, var(--ant-color-primary), var(--ant-color-primary-hover));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.empty-state {
text-align: center;
padding: 40px 20px;
}
.empty-image-container {
margin-bottom: 16px;
}
.empty-title {
font-size: 22px;
font-weight: 500;
margin: 0 0 8px 0;
}
.empty-description {
font-size: 16px;
color: var(--ant-color-text-secondary);
}
/* 模态框通用样式 */
.type-select-modal :deep(.ant-modal-content),
.general-mode-modal :deep(.ant-modal-content),
.template-select-modal :deep(.ant-modal-content) {
border-radius: 12px;
}
.type-select-modal :deep(.ant-modal-header),
.general-mode-modal :deep(.ant-modal-header),
.template-select_modal :deep(.ant-modal-header) {
border-bottom: 1px solid var(--ant-color-border);
padding: 16px 24px;
}
.type-select-modal :deep(.ant-modal-title),
.general-mode_modal :deep(.ant-modal-title),
.template-select-modal :deep(.ant-modal-title) {
font-size: 18px;
font-weight: 600;
}
.type-select-modal :deep(.ant-modal-body),
.general-mode-modal :deep(.ant-modal-body) {
padding: 24px;
}
.template-select-modal :deep(.ant-modal-body) {
padding: 0;
}
/* 选择组样式 */
.type-selection,
.mode-selection {
margin: 16px 0;
}
.type-radio-group,
.mode-radio-group {
width: 100%;
display: flex;
flex-direction: column;
gap: 16px;
}
.type-radio-group :deep(.ant-radio-button-wrapper),
.mode-radio-group :deep(.ant-radio-button-wrapper) {
height: auto;
padding: 0;
border: 1px solid var(--ant-color-border);
border-radius: 8px;
background: var(--ant-color-bg-container);
transition: all 0.3s ease;
text-align: left;
}
.type-radio-group :deep(.ant-radio-button-wrapper:hover),
.mode-radio-group :deep(.ant-radio-button-wrapper:hover) {
border-color: var(--ant-color-primary);
}
.type-radio-group :deep(.ant-radio-button-wrapper-checked),
.mode-radio-group :deep(.ant-radio-button-wrapper-checked) {
border-color: var(--ant-color-primary);
background: var(--ant-color-primary-bg);
color: var(--ant-color-primary);
}
.type-radio-group :deep(.ant-radio-button-wrapper::before),
.mode-radio-group :deep(.ant-radio-button-wrapper::before) {
display: none;
}
.type-radio-group :deep(.ant-radio-button-wrapper .ant-radio-button),
.mode-radio-group :deep(.ant-radio-button-wrapper .ant-radio-button) {
display: none;
}
.type-content {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 20px;
width: 100%;
}
.mode-content {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
}
.type-logo-container {
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
background: var(--ant-color-bg-elevated);
border: 1px solid var(--ant-color-border-secondary);
flex-shrink: 0;
}
.type-logo {
width: 32px;
height: 32px;
object-fit: contain;
}
.mode-icon {
font-size: 24px;
color: var(--ant-color-primary);
flex-shrink: 0;
}
.type-info,
.mode-info {
flex: 1;
}
.type-title,
.mode-title {
font-size: 16px;
font-weight: 600;
color: var(--ant-color-text);
margin-bottom: 4px;
}
.type-description,
.mode-description {
font-size: 14px;
color: var(--ant-color-text-secondary);
line-height: 1.4;
}
/* 模板选择样式 */
.template-selection {
min-height: 400px;
}
.no-templates {
text-align: center;
padding: 60px 20px;
}
.no-templates-content {
color: var(--ant-color-text-secondary);
}
.no-templates-icon {
font-size: 48px;
margin-bottom: 16px;
color: var(--ant-color-text-tertiary);
}
.templates-container {
height: 600px;
display: flex;
flex-direction: column;
}
.templates-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px;
border-bottom: 1px solid var(--ant-color-border);
}
.templates-count {
display: flex;
align-items: center;
gap: 8px;
}
.count-badge {
background: var(--ant-color-primary);
color: white;
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.count-text {
color: var(--ant-color-text-secondary);
}
.search-container {
flex: 1;
max-width: 300px;
margin-left: 16px;
}
.templates-list {
flex: 1;
overflow-y: auto;
padding: 0 24px 24px;
/* 隐藏滚动条 */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
.templates-list::-webkit-scrollbar {
display: none; /* Chrome, Safari and Opera */
}
.no-search-results {
text-align: center;
padding: 60px 20px;
color: var(--ant-color-text-secondary);
}
.no-results-icon {
font-size: 48px;
margin-bottom: 16px;
color: var(--ant-color-text-tertiary);
}
.no-results-tip {
font-size: 14px;
color: var(--ant-color-text-tertiary);
}
.template-item {
border: 1px solid var(--ant-color-border);
border-radius: 8px;
margin-bottom: 16px;
cursor: pointer;
transition: all 0.3s ease;
background: var(--ant-color-bg-container);
}
.template-item:hover {
border-color: var(--ant-color-primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.template-item.selected {
border-color: var(--ant-color-primary);
background: var(--ant-color-primary-bg);
}
.template-content {
padding: 20px;
}
.template-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.template-name {
margin: 0 0 8px 0;
font-size: 16px;
font-weight: 600;
color: var(--ant-color-text);
}
.template-meta {
display: flex;
gap: 16px;
font-size: 12px;
color: var(--ant-color-text-tertiary);
}
.template-author,
.template-time {
display: flex;
align-items: center;
gap: 4px;
}
.template-description {
color: var(--ant-color-text-secondary);
line-height: 1.5;
}
.template-description :deep(p) {
margin: 0 0 8px 0;
}
.template-description :deep(p:last-child) {
margin-bottom: 0;
}
</style>