初始化

This commit is contained in:
2026-03-03 09:53:54 +08:00
commit 3f349a35a4
437 changed files with 65639 additions and 0 deletions

View File

@@ -0,0 +1,188 @@
/**
* 插件管理 API
*
* 提供插件安装、卸载、上传等功能接口
*
* @module api/tool/saipackage
*/
import request from '@/utils/http'
export interface AppInfo {
app: string
title: string
about: string
author: string
version: string
support?: string
website?: string
state: number
npm_dependent_wait_install?: number
composer_dependent_wait_install?: number
}
export interface VersionInfo {
saiadmin_version?: {
describe: string
notes: string
state: string
}
saipackage_version?: {
describe: string
notes: string
state: string
}
}
export interface AppListResponse {
data: AppInfo[]
version: VersionInfo
}
export interface StoreApp {
id: number
title: string
about: string
logo: string
version: string
price: string
avatar?: string
username: string
sales_num: number
content?: string
screenshots?: string[]
}
export interface StoreUser {
nickname?: string
username: string
avatar?: string
}
export interface PurchasedApp {
id: number
app_id: number
title: string
logo: string
version: string
developer: string
about: string
}
export interface AppVersion {
id: number
version: string
create_time: string
remark: string
}
export default {
/**
* 获取已安装的插件列表
*/
getAppList() {
return request.get<AppListResponse>({ url: '/app/saipackage/install/index' })
},
/**
* 上传插件包
*/
uploadApp(data: FormData) {
return request.post<AppInfo>({ url: '/app/saipackage/install/upload', data })
},
/**
* 安装插件
*/
installApp(data: { appName: string }) {
return request.post<any>({ url: '/app/saipackage/install/install', data })
},
/**
* 卸载插件
*/
uninstallApp(data: { appName: string }) {
return request.post<any>({ url: '/app/saipackage/install/uninstall', data })
},
/**
* 重载后端
*/
reloadBackend() {
return request.post<any>({ url: '/app/saipackage/install/reload' })
},
/**
* 获取在线商店应用列表
*/
getOnlineAppList(params: {
page?: number
limit?: number
price?: string
type?: string | number
keywords?: string
}) {
return request.get<{ data: StoreApp[]; total: number }>({
url: '/tool/install/online/appList',
params
})
},
/**
* 获取验证码
*/
getStoreCaptcha() {
return request.get<{ image: string; uuid: string }>({
url: '/tool/install/online/storeCaptcha'
})
},
/**
* 商店登录
*/
storeLogin(data: { username: string; password: string; code: string; uuid: string }) {
return request.post<{ access_token: string }>({
url: '/tool/install/online/storeLogin',
data
})
},
/**
* 获取商店用户信息
*/
getStoreUserInfo(token: string) {
return request.get<StoreUser>({
url: '/tool/install/online/storeUserInfo',
params: { token }
})
},
/**
* 获取已购应用列表
*/
getPurchasedApps(token: string) {
return request.get<PurchasedApp[]>({
url: '/tool/install/online/storePurchasedApps',
params: { token }
})
},
/**
* 获取应用版本列表
*/
getAppVersions(token: string, app_id: number) {
return request.get<AppVersion[]>({
url: '/tool/install/online/storeAppVersions',
params: { token, app_id }
})
},
/**
* 下载应用
*/
downloadApp(data: { token: string; id: number }) {
return request.post<any>({
url: '/tool/install/online/storeDownloadApp',
data
})
}
}

View File

@@ -0,0 +1,988 @@
<template>
<div class="art-full-height">
<ElCard class="flex flex-col flex-1 min-h-0 art-table-card" shadow="never">
<!-- 提示警告 -->
<ElAlert type="warning" :closable="false">
仅支持上传由插件市场下载的zip压缩包进行安装请您务必确认插件包文件来自官方渠道或经由官方认证的插件作者
</ElAlert>
<!-- 工具栏 -->
<div class="flex flex-wrap items-center my-2">
<ElButton @click="getList" v-ripple>
<template #icon>
<ArtSvgIcon icon="ri:refresh-line" />
</template>
</ElButton>
<ElButton @click="handleUpload" v-ripple>
<template #icon>
<ArtSvgIcon icon="ri:upload-line" />
</template>
上传插件包
</ElButton>
<ElButton type="danger" @click="handleTerminal" v-ripple>
<template #icon>
<ArtSvgIcon icon="ri:terminal-box-line" />
</template>
</ElButton>
<div class="flex items-center gap-1 ml-auto">
<div class="version-title">saiadmin版本</div>
<div class="version-value">{{ version?.saiadmin_version?.describe }}</div>
<div class="version-title">状态</div>
<div
class="version-value"
:class="[
version?.saiadmin_version?.notes === '正常' ? 'text-green-500' : 'text-red-500'
]"
>
{{ version?.saiadmin_version?.notes }}
</div>
<div class="version-title">saipackage安装器</div>
<div class="version-value">{{ version?.saipackage_version?.describe }}</div>
<div class="version-title">状态</div>
<div
class="version-value"
:class="[
version?.saipackage_version?.notes === '正常' ? 'text-green-500' : 'text-red-500'
]"
>
{{ version?.saipackage_version?.notes }}
</div>
</div>
</div>
<!-- Tab切换 -->
<ElTabs v-model="activeTab" type="border-card">
<!-- 本地安装 Tab -->
<ElTabPane label="本地安装" name="local">
<ArtTable
:loading="loading"
:data="installList"
:columns="columns"
:show-table-header="false"
>
<!-- 插件标识列 -->
<template #app="{ row }">
<ElLink :href="row.website" target="_blank" type="primary">{{ row.app }}</ElLink>
</template>
<!-- 状态列 -->
<template #state="{ row }">
<ElTag v-if="row.state === 0" type="danger">已卸载</ElTag>
<ElTag v-else-if="row.state === 1" type="success">已安装</ElTag>
<ElTag v-else-if="row.state === 2" type="primary">等待安装</ElTag>
<ElTag v-else-if="row.state === 4" type="warning">等待安装依赖</ElTag>
</template>
<!-- 前端依赖列 -->
<template #npm="{ row }">
<ElLink
v-if="row.npm_dependent_wait_install === 1"
type="primary"
@click="handleExecFront(row)"
>
<ArtSvgIcon icon="ri:download-line" class="mr-1" />点击安装
</ElLink>
<ElTag v-else-if="row.state === 2" type="info">-</ElTag>
<ElTag v-else type="success">已安装</ElTag>
</template>
<!-- 后端依赖列 -->
<template #composer="{ row }">
<ElLink
v-if="row.composer_dependent_wait_install === 1"
type="primary"
@click="handleExecBackend(row)"
>
<ArtSvgIcon icon="ri:download-line" class="mr-1" />点击安装
</ElLink>
<ElTag v-else-if="row.state === 2" type="info">-</ElTag>
<ElTag v-else type="success">已安装</ElTag>
</template>
<!-- 操作列 -->
<template #operation="{ row }">
<ElSpace>
<ElPopconfirm
title="确定要安装当前插件吗?"
@confirm="handleInstall(row)"
confirm-button-text="确定"
cancel-button-text="取消"
>
<template #reference>
<ElLink type="warning">
<ArtSvgIcon icon="ri:apps-2-add-line" class="mr-1" />安装
</ElLink>
</template>
</ElPopconfirm>
<ElPopconfirm
title="确定要卸载当前插件吗?"
@confirm="handleUninstall(row)"
confirm-button-text="确定"
cancel-button-text="取消"
>
<template #reference>
<ElLink type="danger">
<ArtSvgIcon icon="ri:delete-bin-5-line" class="mr-1" />卸载
</ElLink>
</template>
</ElPopconfirm>
</ElSpace>
</template>
</ArtTable>
</ElTabPane>
<!-- 在线商店 Tab -->
<ElTabPane label="在线商店" name="online">
<!-- 搜索栏 -->
<div class="flex flex-wrap items-center gap-4 mb-4">
<ElInput
v-model="searchForm.keywords"
placeholder="请输入关键词"
clearable
class="!w-48"
@keyup.enter="fetchOnlineApps"
>
<template #prefix>
<ArtSvgIcon icon="ri:search-line" />
</template>
</ElInput>
<ElSelect v-model="searchForm.type" placeholder="类型" clearable class="!w-32">
<ElOption label="全部" value="" />
<ElOption label="插件" :value="1" />
<ElOption label="系统" :value="2" />
<ElOption label="组件" :value="3" />
<ElOption label="项目" :value="4" />
</ElSelect>
<ElSelect v-model="searchForm.price" placeholder="价格" class="!w-32">
<ElOption label="全部" value="all" />
<ElOption label="免费" value="free" />
<ElOption label="付费" value="paid" />
</ElSelect>
<ElButton type="primary" @click="fetchOnlineApps">搜索</ElButton>
<!-- 商店账号 -->
<div class="ml-auto flex items-center gap-2">
<template v-if="storeUser">
<ElAvatar :size="24">
<img v-if="storeUser.avatar" :src="storeUser.avatar" />
<ArtSvgIcon v-else icon="ri:user-line" />
</ElAvatar>
<span class="font-medium">{{ storeUser.nickname || storeUser.username }}</span>
<ElButton size="small" @click="showPurchasedApps">已购应用</ElButton>
<ElButton size="small" @click="handleLogout">退出</ElButton>
</template>
<template v-else>
<ElButton size="small" @click="handleLogin">登录</ElButton>
<ElButton size="small" @click="handleRegister">注册</ElButton>
<span class="text-sm text-gray-400">来管理已购插件</span>
</template>
</div>
</div>
<!-- 应用网格 -->
<div class="app-grid">
<div
v-for="item in onlineApps"
:key="item.id"
class="app-card"
@click="showDetail(item)"
>
<div class="app-card-header">
<img :src="item.logo" :alt="item.title" class="app-logo" />
<div class="app-info">
<div class="app-title">{{ item.title }}</div>
<div class="app-version">v{{ item.version }}</div>
</div>
<div class="app-price" :class="{ free: item.price === '0.00' }">
{{ item.price === '0.00' ? '免费' : '¥' + item.price }}
</div>
</div>
<div class="app-about">{{ item.about }}</div>
<div class="app-footer">
<div class="app-author">
<img
:src="item.avatar || 'https://via.placeholder.com/24'"
class="author-avatar"
/>
<span>{{ item.username }}</span>
</div>
<div class="app-sales">
<ArtSvgIcon icon="ri:user-line" class="mr-1" />
{{ item.sales_num }} 销量
</div>
</div>
</div>
</div>
<!-- 分页 -->
<div class="flex justify-center mt-4">
<ElPagination
v-model:current-page="onlinePagination.current"
v-model:page-size="onlinePagination.size"
:total="onlinePagination.total"
:page-sizes="[12, 24, 48]"
layout="total, prev, pager, next, sizes"
@size-change="fetchOnlineApps"
@current-change="fetchOnlineApps"
/>
</div>
</ElTabPane>
</ElTabs>
</ElCard>
<!-- 上传插件弹窗 -->
<InstallForm ref="installFormRef" @success="getList" />
<!-- 终端弹窗 -->
<TerminalBox ref="terminalRef" @success="getList" />
<!-- 详情抽屉 -->
<ElDrawer v-model="detailVisible" :size="600" :with-header="true">
<template #header>
<div class="flex items-center gap-3">
<img :src="currentApp?.logo" class="w-9 h-9 rounded-lg" />
<div>
<div class="text-lg font-semibold">{{ currentApp?.title }}</div>
<div class="text-xs text-gray-400">
v{{ currentApp?.version }} · {{ currentApp?.username }}
</div>
</div>
</div>
</template>
<div class="detail-content">
<div class="detail-price" :class="{ free: currentApp?.price === '0.00' }">
{{ currentApp?.price === '0.00' ? '免费' : '¥' + currentApp?.price }}
</div>
<div class="detail-about">{{ currentApp?.about }}</div>
<!-- 截图预览 -->
<div v-if="currentApp?.screenshots?.length" class="mb-6">
<div class="text-base font-semibold mb-3">截图预览</div>
<ElSpace wrap :size="12">
<ElImage
v-for="(img, idx) in currentApp?.screenshots"
:key="idx"
:src="img"
:preview-src-list="currentApp?.screenshots"
:preview-teleported="true"
fit="cover"
class="w-36 h-24 rounded-lg cursor-pointer"
/>
</ElSpace>
</div>
<!-- 详情描述 -->
<div class="detail-desc">
<div class="text-base font-semibold mb-3">详细介绍</div>
<div class="desc-content" v-html="renderMarkdown(currentApp?.content)"></div>
</div>
<!-- 购买按钮 -->
<div class="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<ElButton type="primary" size="large" class="w-full" @click="handleBuy">
<template #icon>
<ArtSvgIcon icon="ri:shopping-cart-line" />
</template>
前往购买
</ElButton>
</div>
</div>
</ElDrawer>
<!-- 登录弹窗 -->
<ElDialog v-model="loginVisible" title="登录应用商店" width="400" :close-on-click-modal="false">
<ElForm :model="loginForm" @submit.prevent="submitLogin" label-position="top">
<ElFormItem label="用户名/邮箱" required>
<ElInput v-model="loginForm.username" placeholder="请输入用户名或邮箱" clearable>
<template #prefix>
<ArtSvgIcon icon="ri:user-line" />
</template>
</ElInput>
</ElFormItem>
<ElFormItem label="密码" required>
<ElInput
v-model="loginForm.password"
type="password"
placeholder="请输入密码"
show-password
clearable
>
<template #prefix>
<ArtSvgIcon icon="ri:lock-line" />
</template>
</ElInput>
</ElFormItem>
<ElFormItem label="验证码" required>
<div class="flex gap-2 w-full">
<ElInput v-model="loginForm.code" placeholder="请输入验证码" clearable class="flex-1">
<template #prefix>
<ArtSvgIcon icon="ri:shield-check-line" />
</template>
</ElInput>
<img
:src="captchaImage"
@click="getCaptcha"
class="h-8 w-24 cursor-pointer rounded"
title="点击刷新"
/>
</div>
</ElFormItem>
<ElFormItem>
<ElButton type="primary" native-type="submit" class="w-full" :loading="loginLoading">
登录
</ElButton>
</ElFormItem>
<div class="text-center text-sm text-gray-400">
还没有账号
<ElLink type="primary" @click="handleRegister">立即注册</ElLink>
</div>
</ElForm>
</ElDialog>
<!-- 已购应用抽屉 -->
<ElDrawer v-model="purchasedVisible" title="已购应用" :size="720">
<div v-loading="purchasedLoading" class="purchased-list">
<div v-for="app in purchasedApps" :key="app.id" class="purchased-card">
<img :src="app.logo" class="purchased-logo" />
<div class="purchased-info">
<div class="purchased-title">{{ app.title }}</div>
<div class="purchased-version">v{{ app.version }} · {{ app.developer }}</div>
<div class="purchased-about">{{ app.about }}</div>
</div>
<div class="gap-2">
<ElButton size="small" @click="viewDocs(app)">
<template #icon>
<ArtSvgIcon icon="ri:book-line" />
</template>
文档
</ElButton>
<ElButton type="primary" size="small" @click="showVersions(app)">
<template #icon>
<ArtSvgIcon icon="ri:download-line" />
</template>
下载
</ElButton>
</div>
</div>
<ElEmpty
v-if="!purchasedLoading && purchasedApps.length === 0"
description="暂无已购应用"
/>
</div>
</ElDrawer>
<!-- 版本选择对话框 -->
<ElDialog
v-model="versionVisible"
:title="'选择版本 - ' + (currentPurchasedApp?.title || '')"
width="500"
>
<div v-loading="versionLoading" class="version-list">
<div v-for="ver in versionList" :key="ver.id" class="version-item">
<div>
<div class="version-info-row">
<span class="version-name">v{{ ver.version }}</span>
<span class="version-date">{{ ver.create_time }}</span>
</div>
<div class="version-remark">{{ ver.remark }}</div>
</div>
<ElButton
type="primary"
size="small"
:loading="downloadingId === ver.id"
@click="downloadVersion(ver)"
>
下载安装
</ElButton>
</div>
<ElEmpty v-if="!versionLoading && versionList.length === 0" description="暂无可用版本" />
</div>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { ColumnOption } from '@/types'
import saipackageApi, {
type AppInfo,
type VersionInfo,
type StoreApp,
type StoreUser,
type PurchasedApp,
type AppVersion
} from '../api/index'
import InstallForm from './install-box.vue'
import TerminalBox from './terminal.vue'
// ========== 基础状态 ==========
const activeTab = ref('local')
const version = ref<VersionInfo>({})
const loading = ref(false)
const installFormRef = ref()
const terminalRef = ref()
const installList = ref<AppInfo[]>([])
// ========== 本地安装相关 ==========
const handleUpload = () => {
installFormRef.value?.open()
}
// 检查版本兼容性
const checkVersionCompatibility = (support: string | undefined): boolean => {
if (!support || !version.value?.saiadmin_version?.describe) {
return false // 如果没有兼容性信息,默认不允许安装
}
const currentVersion = version.value.saiadmin_version.describe
const currentMatch = currentVersion.match(/^(\d+)\./)
if (!currentMatch) {
return true
}
const currentMajor = currentMatch[1]
// support 格式为 "5.x" 或 "5.x|6.x",用 | 分隔多个支持的版本
const supportVersions = support.split('|').map((v) => v.trim())
// 检查当前版本是否匹配任意一个支持的版本
return supportVersions.some((ver) => {
const supportMatch = ver.match(/^(\d+)\.x$/i)
return supportMatch && supportMatch[1] === currentMajor
})
}
const handleInstall = async (record: AppInfo) => {
// 检查
if (version.value?.saipackage_version?.state === 'fail') {
ElMessage.error('插件市场saipackage版本检测失败')
return
}
// 检查版本兼容性
if (!checkVersionCompatibility(record.support)) {
ElMessage.error(
`此插件仅支持 ${record.support} 版本框架,当前框架版本为 ${version.value?.saiadmin_version?.describe},不兼容无法安装`
)
return
}
try {
await saipackageApi.installApp({ appName: record.app })
ElMessage.success('安装成功')
getList()
saipackageApi.reloadBackend()
} catch {
// Error already handled by http utility
}
}
const handleUninstall = async (record: AppInfo) => {
await saipackageApi.uninstallApp({ appName: record.app })
ElMessage.success('卸载成功')
getList()
}
const handleExecFront = (record: AppInfo) => {
const extend = 'module-install:' + record.app
terminalRef.value?.open()
setTimeout(() => {
terminalRef.value?.frontInstall(extend)
}, 500)
}
const handleExecBackend = (record: AppInfo) => {
const extend = 'module-install:' + record.app
terminalRef.value?.open()
setTimeout(() => {
terminalRef.value?.backendInstall(extend)
}, 500)
}
const handleTerminal = () => {
terminalRef.value?.open()
}
const columns: ColumnOption[] = [
{ prop: 'app', label: '插件标识', width: 120, useSlot: true },
{ prop: 'title', label: '插件名称', width: 150 },
{ prop: 'about', label: '插件描述', showOverflowTooltip: true },
{ prop: 'author', label: '作者', width: 120 },
{ prop: 'version', label: '版本', width: 100 },
{ prop: 'support', label: '框架兼容', width: 120, align: 'center' },
{ prop: 'state', label: '插件状态', width: 100, useSlot: true },
{ prop: 'npm', label: '前端依赖', width: 100, useSlot: true },
{ prop: 'composer', label: '后端依赖', width: 100, useSlot: true },
{ prop: 'operation', label: '操作', width: 140, fixed: 'right', useSlot: true }
]
const getList = async () => {
loading.value = true
try {
const resp = await saipackageApi.getAppList()
installList.value = resp?.data || []
version.value = resp?.version || {}
} catch {
// Error already handled by http utility
} finally {
loading.value = false
}
}
// ========== 在线商店相关 ==========
const detailVisible = ref(false)
const currentApp = ref<StoreApp | null>(null)
const storeUser = ref<StoreUser | null>(null)
const storeToken = ref(localStorage.getItem('storeToken') || '')
const onlineApps = ref<StoreApp[]>([])
const onlineLoading = ref(false)
const onlinePagination = reactive({
current: 1,
size: 12,
total: 0
})
// 登录相关
const loginVisible = ref(false)
const loginLoading = ref(false)
const captchaImage = ref('')
const captchaUuid = ref('')
const loginForm = reactive({
username: '',
password: '',
code: ''
})
// 搜索表单
const searchForm = reactive({
keywords: '',
type: '' as string | number,
price: 'all'
})
// 已购应用相关
const purchasedVisible = ref(false)
const purchasedLoading = ref(false)
const purchasedApps = ref<PurchasedApp[]>([])
const versionVisible = ref(false)
const versionLoading = ref(false)
const versionList = ref<AppVersion[]>([])
const currentPurchasedApp = ref<PurchasedApp | null>(null)
const downloadingId = ref<number | null>(null)
const handleLogin = () => {
loginVisible.value = true
getCaptcha()
}
const handleRegister = () => {
window.open('https://saas.saithink.top/register', '_blank')
}
const handleLogout = () => {
storeUser.value = null
storeToken.value = ''
localStorage.removeItem('storeToken')
}
const getCaptcha = async () => {
try {
const response = await saipackageApi.getStoreCaptcha()
captchaImage.value = response?.image || ''
captchaUuid.value = response?.uuid || ''
} catch {
// Error already handled by http utility
}
}
const submitLogin = async () => {
if (!loginForm.username || !loginForm.password || !loginForm.code) {
ElMessage.warning('请填写完整信息')
return
}
loginLoading.value = true
try {
const response = await saipackageApi.storeLogin({
username: loginForm.username,
password: loginForm.password,
code: loginForm.code,
uuid: captchaUuid.value
})
storeToken.value = response?.access_token || ''
localStorage.setItem('storeToken', response?.access_token || '')
loginVisible.value = false
loginForm.username = ''
loginForm.password = ''
loginForm.code = ''
await fetchStoreUser()
ElMessage.success('登录成功')
} catch {
getCaptcha()
// Error already handled by http utility
} finally {
loginLoading.value = false
}
}
const fetchStoreUser = async () => {
if (!storeToken.value) return
try {
const response = await saipackageApi.getStoreUserInfo(storeToken.value)
storeUser.value = response || null
} catch {
handleLogout()
}
}
const fetchOnlineApps = async () => {
onlineLoading.value = true
try {
const response = await saipackageApi.getOnlineAppList({
page: onlinePagination.current,
limit: onlinePagination.size,
price: searchForm.price,
type: searchForm.type,
keywords: searchForm.keywords
})
onlineApps.value = response?.data || []
onlinePagination.total = response?.total || 0
} catch {
// Error already handled by http utility
} finally {
onlineLoading.value = false
}
}
const showDetail = (item: StoreApp) => {
currentApp.value = item
detailVisible.value = true
}
const renderMarkdown = (content?: string) => {
if (!content) return ''
return content
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`(.+?)`/g, '<code>$1</code>')
.replace(/^- (.+)$/gm, '<li>$1</li>')
.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
.replace(/\n/g, '<br/>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>')
}
const handleBuy = () => {
window.open('https://saas.saithink.top/store', '_blank')
}
const showPurchasedApps = async () => {
purchasedVisible.value = true
purchasedLoading.value = true
try {
const response = await saipackageApi.getPurchasedApps(storeToken.value)
purchasedApps.value = response || []
} catch {
// Error already handled by http utility
}
purchasedLoading.value = false
}
const viewDocs = (app: PurchasedApp) => {
window.open(`https://saas.saithink.top/store/docs-${app.app_id}`, '_blank')
}
const showVersions = async (app: PurchasedApp) => {
currentPurchasedApp.value = app
versionVisible.value = true
versionLoading.value = true
try {
const response = await saipackageApi.getAppVersions(storeToken.value, app.app_id)
versionList.value = response || []
} catch {
// Error already handled by http utility
}
versionLoading.value = false
}
const downloadVersion = async (ver: AppVersion) => {
downloadingId.value = ver.id
try {
await saipackageApi.downloadApp({
token: storeToken.value,
id: ver.id
})
ElMessage.success('下载成功,即将刷新插件列表...')
versionVisible.value = false
purchasedVisible.value = false
activeTab.value = 'local'
getList()
} catch {
// Error already handled by http utility
}
downloadingId.value = null
}
// 监听 tab 切换
watch(activeTab, (val) => {
if (val === 'online') {
fetchOnlineApps()
fetchStoreUser()
}
})
onMounted(() => {
getList()
})
</script>
<style lang="scss" scoped>
.version-title {
padding: 5px 10px;
background: var(--el-fill-color-light);
border: 1px solid var(--el-border-color);
font-size: 12px;
}
.version-value {
padding: 5px 10px;
border: 1px solid var(--el-border-color);
font-size: 12px;
}
.app-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
max-height: calc(100vh - 380px);
overflow-y: auto;
}
.app-card {
background: var(--el-bg-color);
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: all 0.3s ease;
border: 1px solid var(--el-border-color);
&:hover {
box-shadow: var(--el-box-shadow-light);
transform: translateY(-2px);
}
}
.app-card-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.app-logo {
width: 48px;
height: 48px;
border-radius: 8px;
object-fit: cover;
}
.app-info {
flex: 1;
}
.app-title {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.app-version {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.app-price {
font-size: 16px;
font-weight: 600;
color: var(--el-color-danger);
&.free {
color: var(--el-color-success);
}
}
.app-about {
font-size: 13px;
color: var(--el-text-color-regular);
line-height: 1.5;
margin-bottom: 12px;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.app-footer {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.app-author {
display: flex;
align-items: center;
gap: 6px;
}
.author-avatar {
width: 20px;
height: 20px;
border-radius: 50%;
}
.app-sales {
display: flex;
align-items: center;
}
.detail-content {
padding: 16px 0;
}
.detail-price {
font-size: 24px;
font-weight: 600;
color: var(--el-color-danger);
margin-bottom: 16px;
&.free {
color: var(--el-color-success);
}
}
.detail-about {
font-size: 14px;
color: var(--el-text-color-regular);
line-height: 1.6;
margin-bottom: 24px;
}
.desc-content {
font-size: 14px;
color: var(--el-text-color-regular);
line-height: 1.8;
:deep(code) {
background: var(--el-fill-color);
padding: 2px 6px;
border-radius: 4px;
font-size: 13px;
}
:deep(a) {
color: var(--el-color-primary);
}
}
.purchased-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.purchased-card {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background: var(--el-bg-color);
border-radius: 8px;
border: 1px solid var(--el-border-color);
}
.purchased-logo {
width: 56px;
height: 56px;
border-radius: 8px;
object-fit: cover;
}
.purchased-info {
flex: 1;
min-width: 0;
}
.purchased-title {
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
margin-bottom: 4px;
}
.purchased-version {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 6px;
}
.purchased-about {
font-size: 13px;
color: var(--el-text-color-regular);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.version-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.version-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px;
background: var(--el-fill-color-light);
border-radius: 6px;
}
.version-info-row {
display: flex;
align-items: center;
gap: 12px;
}
.version-name {
font-weight: 600;
color: var(--el-text-color-primary);
}
.version-date {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.version-remark {
flex: 1;
font-size: 13px;
color: var(--el-text-color-regular);
}
</style>

View File

@@ -0,0 +1,105 @@
<template>
<ElDialog v-model="visible" title="上传插件包-安装插件" width="800" :close-on-click-modal="false">
<div class="flex flex-col items-center mb-6">
<div class="w-[400px]">
<div class="text-lg text-red-500 font-bold mb-2">
请您务必确认模块包文件来自官方渠道或经由官方认证的模块作者否则系统可能被破坏因为
</div>
<div class="text-red-500">1. 模块可以修改和新增系统文件</div>
<div class="text-red-500">2. 模块可以执行sql命令和代码</div>
<div class="text-red-500">3. 模块可以安装新的前后端依赖</div>
</div>
<!-- 已上传的应用信息 -->
<div v-if="appInfo && appInfo.app" class="mt-10 w-[600px]">
<ElDescriptions :column="1" border>
<ElDescriptionsItem label="应用标识">{{ appInfo?.app }}</ElDescriptionsItem>
<ElDescriptionsItem label="应用名称">{{ appInfo?.title }}</ElDescriptionsItem>
<ElDescriptionsItem label="应用描述">{{ appInfo?.about }}</ElDescriptionsItem>
<ElDescriptionsItem label="作者">{{ appInfo?.author }}</ElDescriptionsItem>
<ElDescriptionsItem label="版本">{{ appInfo?.version }}</ElDescriptionsItem>
</ElDescriptions>
</div>
<!-- 上传区域 -->
<div v-else class="mt-10 w-[600px]">
<ElUpload
drag
:http-request="uploadFileHandler"
:show-file-list="false"
accept=".zip,.rar"
class="w-full"
>
<div class="flex flex-col items-center justify-center py-8">
<ArtSvgIcon icon="ri:upload-cloud-line" class="text-4xl text-gray-400 mb-2" />
<div class="text-gray-500">
将插件包文件拖到此处
<span class="text-primary ml-2">点击上传</span>
</div>
</div>
</ElUpload>
</div>
</div>
</ElDialog>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import type { UploadRequestOptions } from 'element-plus'
import saipackageApi, { type AppInfo } from '../api/index'
const emit = defineEmits<{
(e: 'success'): void
}>()
const visible = ref(false)
const loading = ref(false)
const uploadSize = 8 * 1024 * 1024
const initialApp: AppInfo = {
app: '',
title: '',
about: '',
author: '',
version: '',
state: 0
}
const appInfo = reactive<AppInfo>({ ...initialApp })
const uploadFileHandler = async (options: UploadRequestOptions) => {
const file = options.file
if (!file) return
if (file.size > uploadSize) {
ElMessage.warning(file.name + '超出文件大小限制(8MB)')
return
}
loading.value = true
try {
const dataForm = new FormData()
dataForm.append('file', file)
const res = await saipackageApi.uploadApp(dataForm)
if (res) {
Object.assign(appInfo, res)
ElMessage.success('上传成功')
emit('success')
}
} catch {
// Error already handled by http utility
} finally {
loading.value = false
}
}
const open = () => {
visible.value = true
Object.assign(appInfo, initialApp)
}
defineExpose({ open })
</script>

View File

@@ -0,0 +1,390 @@
<template>
<div>
<!-- 终端执行面板 -->
<ElDialog v-model="visible" title="终端执行面板" width="960">
<div>
<ElEmpty v-if="terminal.taskList.length === 0" description="暂无任务" />
<div v-else>
<ElTimeline>
<ElTimelineItem
v-for="(item, idx) in terminal.taskList"
:key="idx"
:timestamp="item.createTime"
placement="top"
>
<ElCollapse :model-value="terminal.taskList.map((_, i) => i)">
<ElCollapseItem :name="idx">
<template #title>
<div class="flex items-center gap-3">
<span class="font-bold text-base">{{ item.command }}</span>
<ElTag :type="getTagType(item.status)" size="small">
{{ getTagText(item.status) }}
</ElTag>
</div>
</template>
<template #icon>
<div class="flex gap-1">
<ElButton
type="warning"
size="small"
circle
@click.stop="terminal.retryTask(idx)"
>
<ArtSvgIcon icon="ri:refresh-line" />
</ElButton>
<ElButton
type="danger"
size="small"
circle
@click.stop="terminal.delTask(idx)"
>
<ArtSvgIcon icon="ri:delete-bin-line" />
</ElButton>
</div>
</template>
<div
v-if="
item.status === 2 ||
item.status === 3 ||
(item.status > 3 && item.showMessage)
"
class="exec-message"
>
<pre
v-for="(msg, index) in item.message"
:key="index"
v-html="ansiToHtml(msg)"
></pre>
</div>
</ElCollapseItem>
</ElCollapse>
</ElTimelineItem>
</ElTimeline>
</div>
<ElDivider />
<div class="flex justify-center flex-wrap gap-2">
<ElButton type="success" @click="testTerminal">
<template #icon>
<ArtSvgIcon icon="ri:play-line" />
</template>
测试命令
</ElButton>
<ElButton @click="handleFronted">
<template #icon>
<ArtSvgIcon icon="ri:refresh-line" />
</template>
前端依赖更新
</ElButton>
<ElButton @click="handleBackend">
<template #icon>
<ArtSvgIcon icon="ri:refresh-line" />
</template>
后端依赖更新
</ElButton>
<ElButton type="warning" @click="webBuild">
<template #icon>
<ArtSvgIcon icon="ri:rocket-line" />
</template>
一键发布
</ElButton>
<ElButton @click="openConfig">
<template #icon>
<ArtSvgIcon icon="ri:settings-line" />
</template>
终端设置
</ElButton>
<ElButton type="danger" @click="terminal.cleanTaskList()">
<template #icon>
<ArtSvgIcon icon="ri:delete-bin-line" />
</template>
清理任务
</ElButton>
</div>
</div>
</ElDialog>
<!-- 终端设置弹窗 -->
<ElDialog v-model="configVisible" title="终端设置" width="500">
<ElForm label-width="120px">
<ElFormItem label="NPM源">
<ElSelect v-model="terminal.npmRegistry" class="w-80" @change="npmRegistryChange">
<ElOption value="npm" label="npm官源" />
<ElOption value="taobao" label="taobao" />
<ElOption value="tencent" label="tencent" />
</ElSelect>
</ElFormItem>
<ElFormItem label="NPM包管理器">
<ElSelect v-model="terminal.packageManager" class="w-80">
<ElOption value="npm" label="npm" />
<ElOption value="yarn" label="yarn" />
<ElOption value="pnpm" label="pnpm" />
</ElSelect>
</ElFormItem>
<ElFormItem label="Composer源">
<ElSelect
v-model="terminal.composerRegistry"
class="w-80"
@change="composerRegistryChange"
>
<ElOption value="composer" label="composer官源" />
<ElOption value="tencent" label="tencent" />
<ElOption value="huawei" label="huawei" />
<ElOption value="kkame" label="kkame" />
</ElSelect>
</ElFormItem>
</ElForm>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useTerminalStore, TaskStatus } from '../store/terminal'
const emit = defineEmits<{
(e: 'success'): void
}>()
const terminal = useTerminalStore()
const visible = ref(false)
const configVisible = ref(false)
const testTerminal = () => {
terminal.addNodeTask('test', '', () => {})
}
const webBuild = () => {
ElMessageBox.confirm('确认重新打包前端并发布项目吗?', '前端打包发布', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
terminal.addNodeTask('web-build', '', () => {
ElMessage.success('前端打包发布成功')
})
})
}
const handleFronted = () => {
ElMessageBox.confirm('确认更新前端Node依赖吗', '前端依赖更新', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
terminal.addNodeTask('web-install', '', () => {
ElMessage.success('前端依赖更新成功')
})
})
}
const handleBackend = () => {
ElMessageBox.confirm('确认更新后端composer包吗', 'composer包更新', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
terminal.addTask('composer.update', '', () => {
ElMessage.success('composer包更新成功')
})
})
}
const frontInstall = (extend = '') => {
terminal.addNodeTask('web-install', extend, () => {
ElMessage.success('前端依赖更新成功')
emit('success')
})
}
const backendInstall = (extend = '') => {
terminal.addTask('composer.update', extend, () => {
ElMessage.success('composer包更新成功')
setTimeout(() => {
emit('success')
}, 500)
})
}
const npmRegistryChange = (val: string) => {
const command = 'set-npm-registry.' + val
configVisible.value = false
terminal.addTask(command, '', () => {
ElMessage.success('NPM源设置成功')
})
}
const composerRegistryChange = (val: string) => {
const command = 'set-composer-registry.' + val
configVisible.value = false
terminal.addTask(command, '', () => {
ElMessage.success('Composer源设置成功')
})
}
const getTagType = (
status: TaskStatus
): 'success' | 'warning' | 'info' | 'danger' | 'primary' => {
switch (status) {
case TaskStatus.WAITING:
return 'info'
case TaskStatus.CONNECTING:
return 'primary'
case TaskStatus.RUNNING:
return 'warning'
case TaskStatus.SUCCESS:
return 'success'
case TaskStatus.FAILED:
return 'danger'
default:
return 'info'
}
}
const getTagText = (status: TaskStatus) => {
switch (status) {
case TaskStatus.WAITING:
return '等待执行'
case TaskStatus.CONNECTING:
return '连接中'
case TaskStatus.RUNNING:
return '执行中'
case TaskStatus.SUCCESS:
return '执行成功'
case TaskStatus.FAILED:
return '执行失败'
default:
return '未知'
}
}
// ESC 字符,用于 ANSI 转义序列
const ESC = String.fromCharCode(0x1b)
const ansiToHtml = (text: string) => {
// 先处理 ANSI 颜色代码
const colorPattern = new RegExp(`${ESC}\\[([0-9;]+)m`, 'g')
let result = text.replace(colorPattern, function (match, codes) {
const codeList = codes.split(';').map((c: string) => parseInt(c, 10))
// 如果是重置代码 (0 或空),返回闭标签
if (codeList.length === 1 && (codeList[0] === 0 || isNaN(codeList[0]))) {
return '</span>'
}
const styles: string[] = []
codeList.forEach((c: number) => {
switch (c) {
case 0:
// 重置 - 不添加样式,在上面已处理
break
case 1:
styles.push('font-weight:bold')
break
case 3:
styles.push('font-style:italic')
break
case 4:
styles.push('text-decoration:underline')
break
case 30:
styles.push('color:black')
break
case 31:
styles.push('color:red')
break
case 32:
styles.push('color:green')
break
case 33:
styles.push('color:yellow')
break
case 34:
styles.push('color:blue')
break
case 35:
styles.push('color:magenta')
break
case 36:
styles.push('color:cyan')
break
case 37:
styles.push('color:white')
break
// 亮色/高亮色
case 90:
styles.push('color:#888')
break
case 91:
styles.push('color:#f55')
break
case 92:
styles.push('color:#5f5')
break
case 93:
styles.push('color:#ff5')
break
case 94:
styles.push('color:#55f')
break
case 95:
styles.push('color:#f5f')
break
case 96:
styles.push('color:#5ff')
break
case 97:
styles.push('color:#fff')
break
}
})
return styles.length ? `<span style="${styles.join(';')}">` : ''
})
// 清理可能残留的其他 ANSI 转义序列 (如光标移动等)
const cleanupPattern = new RegExp(`${ESC}\\[[0-9;]*[A-Za-z]`, 'g')
result = result.replace(cleanupPattern, '')
return result
}
const openConfig = () => {
configVisible.value = true
}
const open = () => {
visible.value = true
}
const close = () => {
visible.value = false
}
defineExpose({ open, close, frontInstall, backendInstall })
</script>
<style lang="scss" scoped>
.exec-message {
font-size: 12px;
line-height: 1.5em;
min-height: 30px;
max-height: 200px;
overflow: auto;
background-color: #000;
color: #c0c0c0;
padding: 8px;
border-radius: 4px;
&::-webkit-scrollbar {
width: 5px;
height: 5px;
}
&::-webkit-scrollbar-thumb {
background: #c8c9cc;
border-radius: 4px;
}
}
</style>

View File

@@ -0,0 +1,345 @@
/**
* 终端状态管理模块 - saipackage插件
*
* 提供终端命令执行任务队列的状态管理
*
* @module store/terminal
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/store/modules/user'
/** 任务状态枚举 */
export enum TaskStatus {
/** 等待执行 */
WAITING = 1,
/** 连接中 */
CONNECTING = 2,
/** 执行中 */
RUNNING = 3,
/** 执行成功 */
SUCCESS = 4,
/** 执行失败 */
FAILED = 5,
/** 未知 */
UNKNOWN = 6
}
/** 终端任务接口 */
export interface TerminalTask {
/** 任务唯一标识 */
uuid: string
/** 命令名称 */
command: string
/** 任务状态 */
status: TaskStatus
/** 执行消息 */
message: string[]
/** 创建时间 */
createTime: string
/** 是否显示消息 */
showMessage: boolean
/** 回调函数 */
callback?: (status: number) => void
/** 扩展参数 */
extend: string
}
// 扩展 window 类型
declare global {
interface Window {
eventSource?: EventSource
}
}
/**
* 生成UUID
*/
const generateUUID = (): string => {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0
const v = c === 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
}
/**
* 格式化日期时间
*/
const formatDateTime = (): string => {
const now = new Date()
return now.toLocaleString('zh-CN', {
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
/**
* 构建终端 WebSocket URL
*/
const buildTerminalUrl = (commandKey: string, uuid: string, extend: string): string => {
const env = import.meta.env
const baseURL = env.VITE_API_URL || ''
const userStore = useUserStore()
const token = userStore.accessToken
const terminalUrl = '/app/saipackage/index/terminal'
return `${baseURL}${terminalUrl}?command=${commandKey}&uuid=${uuid}&extend=${extend}&token=${token}`
}
export const useTerminalStore = defineStore(
'saipackageTerminal',
() => {
// 状态
const show = ref(false)
const taskList = ref<TerminalTask[]>([])
const npmRegistry = ref('npm')
const packageManager = ref('pnpm')
const composerRegistry = ref('composer')
/**
* 设置任务状态
*/
const setTaskStatus = (idx: number, status: TaskStatus) => {
if (taskList.value[idx]) {
taskList.value[idx].status = status
taskList.value[idx].showMessage = true
}
}
/**
* 添加任务消息
*/
const addTaskMessage = (idx: number, message: string) => {
if (taskList.value[idx]) {
taskList.value[idx].message = taskList.value[idx].message.concat(message)
}
}
/**
* 切换任务消息显示
*/
const setTaskShowMessage = (idx: number, val?: boolean) => {
if (taskList.value[idx]) {
taskList.value[idx].showMessage = val ?? !taskList.value[idx].showMessage
}
}
/**
* 清空任务列表
*/
const cleanTaskList = () => {
taskList.value = []
}
/**
* 任务完成回调
*/
const taskCompleted = (idx: number) => {
const task = taskList.value[idx]
if (!task || typeof task.callback !== 'function') return
const status = task.status
if (status === TaskStatus.FAILED || status === TaskStatus.UNKNOWN) {
task.callback(TaskStatus.FAILED)
} else if (status === TaskStatus.SUCCESS) {
task.callback(TaskStatus.SUCCESS)
}
}
/**
* 根据UUID查找任务索引
*/
const findTaskIdxFromUuid = (uuid: string): number | false => {
for (let i = 0; i < taskList.value.length; i++) {
if (taskList.value[i].uuid === uuid) {
return i
}
}
return false
}
/**
* 根据猜测查找任务索引
*/
const findTaskIdxFromGuess = (idx: number): number | false => {
if (!taskList.value[idx]) {
let taskKey = -1
for (let i = 0; i < taskList.value.length; i++) {
if (
taskList.value[i].status === TaskStatus.CONNECTING ||
taskList.value[i].status === TaskStatus.RUNNING
) {
taskKey = i
}
}
return taskKey === -1 ? false : taskKey
}
return idx
}
/**
* 启动EventSource连接
*/
const startEventSource = (taskKey: number) => {
const task = taskList.value[taskKey]
if (!task) return
window.eventSource = new EventSource(buildTerminalUrl(task.command, task.uuid, task.extend))
window.eventSource.onmessage = (e: MessageEvent) => {
try {
const data = JSON.parse(e.data)
if (!data || !data.data) return
const taskIdx = findTaskIdxFromUuid(data.uuid)
if (taskIdx === false) return
if (data.data === 'exec-error') {
setTaskStatus(taskIdx, TaskStatus.FAILED)
window.eventSource?.close()
taskCompleted(taskIdx)
startTask()
} else if (data.data === 'exec-completed') {
window.eventSource?.close()
if (taskList.value[taskIdx].status !== TaskStatus.SUCCESS) {
setTaskStatus(taskIdx, TaskStatus.FAILED)
}
taskCompleted(taskIdx)
startTask()
} else if (data.data === 'connection-success') {
setTaskStatus(taskIdx, TaskStatus.RUNNING)
} else if (data.data === 'exec-success') {
setTaskStatus(taskIdx, TaskStatus.SUCCESS)
} else {
addTaskMessage(taskIdx, data.data)
}
} catch {
// JSON parse error
}
}
window.eventSource.onerror = () => {
window.eventSource?.close()
const taskIdx = findTaskIdxFromGuess(taskKey)
if (taskIdx === false) return
setTaskStatus(taskIdx, TaskStatus.FAILED)
taskCompleted(taskIdx)
}
}
/**
* 添加 Node 相关任务
*/
const addNodeTask = (command: string, extend: string = '', callback?: () => void) => {
const manager = packageManager.value === 'unknown' ? 'npm' : packageManager.value
const fullCommand = `${command}.${manager}`
addTask(fullCommand, extend, callback)
}
/**
* 添加任务
*/
const addTask = (command: string, extend: string = '', callback?: () => void) => {
const task: TerminalTask = {
uuid: generateUUID(),
createTime: formatDateTime(),
status: TaskStatus.WAITING,
command,
message: [],
showMessage: false,
extend,
callback: callback ? () => callback() : undefined
}
taskList.value.push(task)
// 检查是否有已经失败的任务
if (show.value === false) {
for (const t of taskList.value) {
if (t.status === TaskStatus.FAILED || t.status === TaskStatus.UNKNOWN) {
ElMessage.warning('任务列表中存在失败的任务')
break
}
}
}
startTask()
}
/**
* 开始执行任务
*/
const startTask = () => {
let taskKey: number | null = null
// 寻找可以开始执行的命令
for (let i = 0; i < taskList.value.length; i++) {
const task = taskList.value[i]
if (task.status === TaskStatus.WAITING) {
taskKey = i
break
}
if (task.status === TaskStatus.CONNECTING || task.status === TaskStatus.RUNNING) {
break
}
}
if (taskKey !== null) {
setTaskStatus(taskKey, TaskStatus.CONNECTING)
startEventSource(taskKey)
}
}
/**
* 重试任务
*/
const retryTask = (idx: number) => {
if (taskList.value[idx]) {
taskList.value[idx].message = []
setTaskStatus(idx, TaskStatus.WAITING)
startTask()
}
}
/**
* 删除任务
*/
const delTask = (idx: number) => {
const task = taskList.value[idx]
if (task && task.status !== TaskStatus.CONNECTING && task.status !== TaskStatus.RUNNING) {
taskList.value.splice(idx, 1)
}
}
return {
show,
taskList,
npmRegistry,
packageManager,
composerRegistry,
setTaskStatus,
addTaskMessage,
setTaskShowMessage,
cleanTaskList,
addNodeTask,
addTask,
retryTask,
delTask,
startTask
}
},
{
persist: {
key: 'saipackageTerminal',
storage: localStorage,
pick: ['npmRegistry', 'composerRegistry', 'packageManager']
}
}
)
export default useTerminalStore