Files
ApiServer-Web-admin_dashboa…/src/views/acs/images/ImageForm.vue
T
lin f0e89695f4
Build and Deploy Vue3 / build (push) Successful in 4m9s
Build and Deploy Vue3 / deploy (push) Successful in 1m3s
fix: 修改新增用户商品的配置项逻辑
2026-04-06 18:44:11 +08:00

826 lines
20 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 class="image-form-container">
<!-- 顶部导航 -->
<div class="page-header">
<div class="header-left">
<el-button @click="goBack" class="back-btn" circle>
<el-icon><ArrowLeft /></el-icon>
</el-button>
<div class="header-title-area">
<h1 class="page-title">{{ isEdit ? '编辑镜像' : '添加镜像' }}</h1>
<span class="page-subtitle">{{ isEdit ? '修改现有镜像的配置信息' : '上传并配置新的虚拟机镜像' }}</span>
</div>
</div>
<div class="header-actions">
<el-button @click="goBack" size="large">取消</el-button>
<el-button type="primary" @click="submitForm" :loading="submitting" size="large" class="submit-btn">
{{ isEdit ? '保存修改' : '立即创建' }}
</el-button>
</div>
</div>
<!-- 主表单区域 -->
<div class="form-wrapper">
<el-form :model="form" label-position="top" :rules="rules" ref="formRef" class="main-form" size="large">
<!-- 左侧主要配置 -->
<div class="form-main-col">
<el-card class="premium-card" shadow="never">
<div class="section-header">
<div class="section-icon"><el-icon><Monitor /></el-icon></div>
<div class="section-info">
<h3>基础信息</h3>
<p>配置镜像的基本标识与文件信息</p>
</div>
</div>
<div class="form-grid-2">
<el-form-item label="镜像名称" prop="name">
<el-input v-model="form.name" placeholder="请输入镜像名称" />
</el-form-item>
<el-form-item label="展示名称" prop="show_name">
<el-input v-model="form.show_name" placeholder="请输入展示名称" />
</el-form-item>
</div>
<el-form-item label="文件路径" prop="path">
<el-input v-model="form.path" placeholder="请输入镜像文件在服务器上的绝对路径">
<template #prefix><el-icon><Folder /></el-icon></template>
</el-input>
</el-form-item>
<el-form-item label="镜像描述" prop="description">
<el-input
v-model="form.description"
type="textarea"
:rows="3"
placeholder="请输入关于此镜像的详细描述"
resize="none"
/>
</el-form-item>
<el-divider />
<div class="section-header">
<div class="section-icon"><el-icon><SetUp /></el-icon></div>
<div class="section-info">
<h3>分类与版本</h3>
<p>管理镜像的分类归属与版本信息</p>
</div>
</div>
<div class="form-grid-2">
<el-form-item label="所属分类" prop="class_id">
<el-select
v-model="form.class_id"
placeholder="请选择分类"
clearable
style="width: 100%"
@change="handleCategoryChange"
>
<el-option v-for="item in categoryList" :key="item.class_id" :label="item.name" :value="item.class_id" />
<el-option label="+ 创建新分类" value="" class="create-new-option" />
</el-select>
<div class="new-category-input" v-if="showNewCategoryInput">
<el-input
v-model="form.class_name"
placeholder="输入新分类名称"
>
<template #append>
<el-button @click="createNewCategory">创建</el-button>
</template>
</el-input>
</div>
</el-form-item>
<el-form-item label="版本号" prop="vm_gen">
<el-input v-model="form.vm_gen" placeholder="例如:v1.0.0" />
</el-form-item>
</div>
<el-form-item label="关联套餐" prop="plan_id">
<el-select v-model="form.plan_id" placeholder="请选择适用的套餐" style="width: 100%">
<el-option v-for="item in planList" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
</el-card>
</div>
<!-- 右侧图标与高级 -->
<div class="form-side-col">
<el-card class="premium-card" shadow="never">
<div class="section-header small">
<div class="section-info">
<h3>镜像图标</h3>
</div>
</div>
<div class="icon-uploader">
<div class="icon-preview" v-if="form.image_ico">
<img :src="mainUrl + form.image_ico" />
<div class="icon-actions">
<el-button size="small" circle @click="form.image_ico = ''"><el-icon><Delete /></el-icon></el-button>
</div>
</div>
<div class="upload-area" v-else>
<div class="upload-placeholder">
<el-icon class="upload-icon"><Picture /></el-icon>
<div class="upload-text">点击上传或选择图标</div>
</div>
<div class="upload-buttons">
<el-button type="primary" size="small" @click="$refs.fileInput.click()">本地上传</el-button>
<el-button size="small" @click="openPicLibrary">素材库</el-button>
</div>
<input ref="fileInput" type="file" style="display: none" @change="onFileSelected" accept="image/*" />
</div>
</div>
</el-card>
</div>
</el-form>
</div>
<!-- 素材库对话框 -->
<el-dialog v-model="picSwitch" title="选择图标" width="800px" append-to-body>
<div class="pic-search">
<el-input
v-model="picPagin.key"
placeholder="搜索图标..."
prefix-icon="Search"
clearable
@change="getpicList"
/>
</div>
<div class="pic-grid" v-loading="picLoading">
<div
v-for="(item, index) in picList"
:key="index"
class="pic-item"
:class="{ active: currentIndex === index }"
@click="selectImage(index)"
>
<img :src="`${mainUrl}/v1/attachment/get_attachment?aid=${item.attachment_id}`" />
<div class="pic-name">{{ item.title || '未命名' }}</div>
<div class="pic-check" v-if="currentIndex === index"><el-icon><Check /></el-icon></div>
</div>
</div>
<div class="pagination-wrapper">
<el-pagination
background
layout="prev, pager, next"
:total="total"
:current-page="picPagin.page"
:page-size="picPagin.count"
@current-change="CurrentPageChange"
/>
</div>
<template #footer>
<el-button @click="picSwitch = false">取消</el-button>
<el-button type="primary" @click="confirmPicSelection" :disabled="currentIndex === null">确定选择</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElNotification } from 'element-plus'
import {
ArrowLeft, Monitor, Folder, SetUp, Picture, Delete,
Search, Check
} from '@element-plus/icons-vue'
import { getServerPlan } from '@/utils/acs/server'
import {
editMirror, addVirtualMirror, getImageTypeList, createImageType, getUserMirrorList
} from '@/utils/acs/mirror'
import { uploadFile, getFileList } from '@/utils/acs/message'
import { mainUrl } from '@/utils/request'
import { useTagsViewStore } from '@/store/tagsViewStore'
const route = useRoute()
const router = useRouter()
const tagsViewStore = useTagsViewStore()
const formRef = ref(null)
const submitting = ref(false)
const goBack = () => {
tagsViewStore.delVisitedView(route)
router.back()
}
const isEdit = computed(() => !!route.query.id)
const serverId = computed(() => route.query.server_id)
const form = reactive({
id: '',
name: '',
show_name: '',
description: '',
server_type: 'hyperV',
plan_id: '',
image_ico: '',
server_id: '',
path: '',
class_id: '',
class_name: '',
vm_gen: ''
})
const rules = {
name: [{ required: true, message: '请输入镜像名称', trigger: 'blur' }],
path: [{ required: true, message: '请输入文件路径', trigger: 'blur' }],
show_name: [{ required: true, message: '请输入展示名称', trigger: 'blur' }]
}
const categoryList = ref([])
const planList = ref([])
const showNewCategoryInput = ref(false)
// 素材库相关
const picSwitch = ref(false)
const picLoading = ref(false)
const picPagin = reactive({
count: 10,
page: 1,
key: '',
user_type: 1
})
const picList = ref([])
const total = ref(0)
const currentIndex = ref(null)
// 重置表单
const resetForm = () => {
Object.assign(form, {
id: '',
name: '',
show_name: '',
description: '',
server_type: 'hyperV',
plan_id: '',
image_ico: '',
server_id: '',
path: '',
class_id: '',
class_name: '',
vm_gen: ''
})
}
// 初始化数据
const initData = async () => {
resetForm()
if (!serverId.value) {
ElMessage.error('缺少服务器ID参数')
return
}
form.server_id = serverId.value
try {
// 获取套餐列表
const planRes = await getServerPlan({ server_id: serverId.value })
if (planRes.data.code === 200) {
planList.value = planRes.data.data.map(item => ({
name: item.name,
id: item.plan_id
}))
}
// 获取分类列表
await fetchCategoryList()
// 如果是编辑模式,填充数据
if (isEdit.value) {
const id = route.query.id
// 尝试从 history.state 获取数据
const stateData = history.state.params ? JSON.parse(JSON.stringify(history.state.params)) : null
if (stateData && stateData.id == id) {
Object.keys(form).forEach(key => {
if (key in stateData) {
form[key] = stateData[key]
}
})
// 确保 ID 类型一致性 (有些时候 API 返回的是数字,有些时候是字符串)
if (form.plan_id) form.plan_id = Number(form.plan_id) || form.plan_id
if (form.class_id) form.class_id = Number(form.class_id) || form.class_id
} else {
// Fallback: fetch list and find item
const listRes = await getUserMirrorList({
server_id: serverId.value,
count: 10,
page: 1
})
if (listRes.data.code === 200) {
const found = listRes.data.data.find(item => item.id == id)
if (found) {
Object.keys(form).forEach(key => {
if (key in found) form[key] = found[key]
})
if (form.plan_id) form.plan_id = Number(form.plan_id) || form.plan_id
if (form.class_id) form.class_id = Number(form.class_id) || form.class_id
}
}
}
}
} catch (error) {
console.error('初始化数据失败:', error)
ElMessage.error('数据加载失败')
}
}
const fetchCategoryList = async () => {
try {
const res = await getImageTypeList(serverId.value)
if (res.data.code === 200) {
categoryList.value = res.data.data || []
}
} catch (error) {
console.error('获取分类失败:', error)
}
}
const handleCategoryChange = (val) => {
if (val === '') {
// 选择了创建新分类
form.class_id = ''
showNewCategoryInput.value = true
} else {
showNewCategoryInput.value = false
form.class_name = ''
}
}
const createNewCategory = async () => {
if (!form.class_name.trim()) {
ElMessage.warning('请输入分类名称')
return
}
try {
const res = await createImageType(serverId.value, form.class_name.trim(), '')
if (res.data.code === 200) {
ElMessage.success('分类创建成功')
await fetchCategoryList()
// 选中新创建的分类
const newCat = categoryList.value.find(c => c.name === form.class_name.trim())
if (newCat) {
form.class_id = newCat.class_id
showNewCategoryInput.value = false
form.class_name = ''
}
} else {
ElMessage.error(res.data.msg || '创建失败')
}
} catch (error) {
ElMessage.error('创建分类失败')
}
}
// 图片上传与选择
const onFileSelected = async (event) => {
const file = event.target.files[0]
if (!file) return
try {
const res = await uploadFile({ file })
if (res.data.code === 200) {
form.image_ico = '/v1/attachment/get_attachment?aid=' + res.data.data.attachment_id
ElMessage.success('上传成功')
} else {
ElMessage.error('上传失败')
}
} catch (error) {
ElMessage.error('上传出错')
}
}
const openPicLibrary = () => {
picSwitch.value = true
getpicList()
}
const getpicList = async () => {
picLoading.value = true
try {
const res = await getFileList(picPagin)
if (res.data.code === 200) {
picList.value = res.data.data
total.value = res.data.count
}
} finally {
picLoading.value = false
}
}
const selectImage = (index) => {
currentIndex.value = index
}
const confirmPicSelection = () => {
if (currentIndex.value !== null) {
const item = picList.value[currentIndex.value]
form.image_ico = `/v1/attachment/get_attachment?aid=${item.attachment_id}`
picSwitch.value = false
}
}
const CurrentPageChange = (page) => {
picPagin.page = page
getpicList()
}
const submitForm = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (valid) {
submitting.value = true
try {
const submitData = { ...form }
// 处理分类逻辑
if (submitData.class_id) {
submitData.class_name = ''
} else if (submitData.class_name) {
submitData.class_id = ''
} else {
submitData.class_id = ''
submitData.class_name = ''
}
let res
if (isEdit.value) {
submitData.image_id = submitData.id
delete submitData.id
res = await editMirror(submitData)
} else {
res = await addVirtualMirror(submitData)
}
if (res.data.code === 200) {
ElNotification({
title: '操作成功',
message: isEdit.value ? '镜像更新成功' : '镜像创建成功',
type: 'success'
})
goBack()
} else {
ElMessage.error(res.data.msg || '操作失败')
}
} catch (error) {
console.error(error)
ElMessage.error('提交失败')
} finally {
submitting.value = false
}
}
})
}
onMounted(() => {
initData()
})
</script>
<style scoped>
.image-form-container {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
}
/* 顶部导航 */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
background: #ffffff;
padding: 20px 32px;
border-radius: 12px;
border: 1px solid #e4e7ed;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.02);
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.back-btn {
border: none;
background: #f2f3f5;
color: #606266;
width: 36px;
height: 36px;
transition: all 0.3s;
}
.back-btn:hover {
background-color: #e6e8eb;
color: #303133;
}
.header-title-area {
display: flex;
flex-direction: column;
justify-content: center;
}
.page-title {
margin: 0;
font-size: 20px;
font-weight: 700;
color: #1a1a1a;
line-height: 1.2;
}
.page-subtitle {
font-size: 13px;
color: #909399;
margin-top: 4px;
}
.submit-btn {
padding: 10px 24px;
font-weight: 600;
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
}
/* 表单布局 */
.form-wrapper {
display: flex;
gap: 24px;
align-items: flex-start;
}
.main-form {
display: flex;
width: 100%;
gap: 24px;
}
.form-main-col {
flex: 1;
min-width: 0;
}
.form-side-col {
width: 320px;
flex-shrink: 0;
}
/* 卡片样式 */
.premium-card {
border: 1px solid #e4e7ed;
border-radius: 12px;
background: #ffffff;
transition: all 0.3s;
}
.premium-card:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.04);
}
.premium-card :deep(.el-card__body) {
padding: 32px;
}
/* 章节标题 */
.section-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #f0f2f5;
}
.section-header.small {
margin-bottom: 20px;
padding-bottom: 12px;
}
.section-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: linear-gradient(135deg, #ecf5ff 0%, #d9ecff 100%);
color: #409EFF;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.section-info h3 {
margin: 0;
font-size: 16px;
font-weight: 700;
color: #303133;
}
.section-info p {
margin: 2px 0 0 0;
font-size: 12px;
color: #909399;
}
/* 表单网格 */
.form-grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
/* 新分类输入 */
.new-category-input {
margin-top: 12px;
padding: 12px;
background: #f8f9fa;
border-radius: 8px;
border: 1px dashed #dcdfe6;
}
/* 图标上传器 */
.icon-uploader {
width: 100%;
}
.icon-preview {
position: relative;
width: 100%;
height: 160px;
border-radius: 8px;
overflow: hidden;
border: 1px solid #e4e7ed;
display: flex;
align-items: center;
justify-content: center;
background: #f8f9fa;
}
.icon-preview img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.icon-actions {
position: absolute;
top: 8px;
right: 8px;
}
.upload-area {
border: 2px dashed #e4e7ed;
border-radius: 8px;
padding: 24px;
text-align: center;
transition: all 0.3s;
}
.upload-area:hover {
border-color: #409EFF;
background: #f2f6fc;
}
.upload-placeholder {
margin-bottom: 16px;
}
.upload-icon {
font-size: 32px;
color: #909399;
margin-bottom: 8px;
}
.upload-text {
font-size: 13px;
color: #606266;
}
.upload-buttons {
display: flex;
justify-content: center;
gap: 12px;
}
/* 素材库网格 */
.pic-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 16px;
margin: 20px 0;
max-height: 400px;
overflow-y: auto;
}
.pic-item {
position: relative;
border: 1px solid #e4e7ed;
border-radius: 8px;
padding: 12px;
cursor: pointer;
transition: all 0.2s;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.pic-item:hover {
border-color: #409EFF;
transform: translateY(-2px);
}
.pic-item.active {
border-color: #409EFF;
background: #ecf5ff;
}
.pic-item img {
width: 48px;
height: 48px;
object-fit: contain;
}
.pic-name {
font-size: 12px;
color: #606266;
text-align: center;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pic-check {
position: absolute;
top: 4px;
right: 4px;
color: #409EFF;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
/* 响应式 */
@media screen and (max-width: 992px) {
.main-form {
flex-direction: column;
}
.form-side-col {
width: 100%;
}
}
@media screen and (max-width: 768px) {
.image-form-container {
padding: 16px;
}
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
padding: 16px;
}
.header-actions {
width: 100%;
display: flex;
gap: 12px;
}
.header-actions .el-button {
flex: 1;
}
.form-grid-2 {
grid-template-columns: 1fr;
}
.pic-grid {
grid-template-columns: repeat(3, 1fr);
}
}
</style>