feat: Support batch operations for image tags (#10597)

This commit is contained in:
ssongliu 2025-10-10 17:23:59 +08:00 committed by GitHub
parent 02c002bda6
commit 222181baff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 101 additions and 43 deletions

View file

@ -36,8 +36,8 @@ type ImagePull struct {
}
type ImageTag struct {
SourceID string `json:"sourceID" validate:"required"`
TargetName string `json:"targetName" validate:"required"`
SourceID string `json:"sourceID" validate:"required"`
Tags []string `json:"tags" validate:"required"`
}
type ImagePush struct {

View file

@ -363,9 +363,39 @@ func (u *ImageService) ImageTag(req dto.ImageTag) error {
}
defer client.Close()
if err := client.ImageTag(context.TODO(), req.SourceID, req.TargetName); err != nil {
imageItem, err := client.ImageInspect(context.Background(), req.SourceID)
if err != nil {
return err
}
for _, tag := range req.Tags {
isNew := true
for _, tagOld := range imageItem.RepoTags {
if tag == tagOld {
isNew = false
break
}
}
if isNew {
if err := client.ImageTag(context.TODO(), req.SourceID, tag); err != nil {
return err
}
}
}
for _, tagOld := range imageItem.RepoTags {
isDel := true
for _, tag := range req.Tags {
if tag == tagOld {
isDel = false
break
}
}
if isDel {
if _, err := client.ImageRemove(context.TODO(), tagOld, image.RemoveOptions{}); err != nil {
return err
}
}
}
return nil
}

View file

@ -191,7 +191,7 @@ export namespace Container {
}
export interface ImageTag {
sourceID: string;
targetName: string;
tags: Array<string>;
}
export interface ImagePush {
taskID: string;

View file

@ -882,7 +882,6 @@ const message = {
imagePushHelper:
'Detected that this image has multiple tags. Please confirm that the image name used for pushing is: {0}',
imageDelete: 'Image delete',
imageTagDeleteHelper: 'Remove other tags associated with this image ID',
repoName: 'Container registry',
imageName: 'Image name',
pull: 'Pull',
@ -893,6 +892,7 @@ const message = {
pathSelect: 'Path',
label: 'Label',
imageTag: 'Image tag',
imageTagHelper: 'Supports setting multiple image tags, press Enter after entering each tag to continue',
push: 'Push',
fileName: 'Filename',
export: 'Export',

View file

@ -884,7 +884,6 @@ const message = {
imagePushHelper:
'Detected that this image has multiple tags. Please confirm that the image name used for pushing is: {0}',
imageDelete: 'Eliminar imagen',
imageTagDeleteHelper: 'Eliminar otras etiquetas asociadas con este ID de imagen',
repoName: 'Repositorio de contenedores',
imageName: 'Nombre de la imagen',
pull: 'Descargar',
@ -895,6 +894,8 @@ const message = {
pathSelect: 'Ruta',
label: 'Etiqueta',
imageTag: 'Etiqueta de imagen',
imageTagHelper:
'Admite configurar múltiples etiquetas de imagen, presione Enter después de ingresar cada etiqueta para continuar',
push: 'Subir',
fileName: 'Nombre de archivo',
export: 'Exportar',

View file

@ -858,7 +858,6 @@ const message = {
imagePushHelper:
'このイメージに複数のタグが存在することが検出されましたプッシュ時に使用するイメージ名が以下であることを確認してください{0}',
imageDelete: '画像削除',
imageTagDeleteHelper: 'この画像IDに関連付けられた他のタグを削除します',
repoName: 'コンテナレジストリ',
imageName: '画像名',
pull: '引く',
@ -869,6 +868,7 @@ const message = {
pathSelect: 'パス',
label: 'ラベル',
imageTag: '画像タグ',
imageTagHelper: '複数のイメージタグの設定をサポートし各タグ入力後にEnterキーを押して続行します',
push: '押す',
fileName: 'ファイル名',
export: '輸出',

View file

@ -850,7 +850,6 @@ const message = {
imagePushHelper:
' 이미지에 여러 태그가 있는 것으로 감지되었습니다. 푸시 사용할 이미지 이름이 다음인지 확인하세요: {0}',
imageDelete: '이미지 삭제',
imageTagDeleteHelper: ' 이미지 ID와 관련된 다른 태그를 제거합니다.',
repoName: '컨테이너 저장소 이름',
imageName: '이미지 이름',
pull: '풀',
@ -861,6 +860,7 @@ const message = {
pathSelect: '경로',
label: '레이블',
imageTag: '이미지 태그',
imageTagHelper: '여러 이미지 태그 설정을 지원하며, 태그 입력 Enter 키를 눌러 계속합니다',
push: '푸시',
fileName: '파일 이름',
export: '내보내기',

View file

@ -875,7 +875,6 @@ const message = {
imagePushHelper:
'Terdapat pengesahan bahawa imej ini mempunyai beberapa tag. Sila pastikan nama imej yang digunakan untuk menolak adalah: {0}',
imageDelete: 'Padam imej',
imageTagDeleteHelper: 'Buang tag lain yang berkaitan dengan ID imej ini',
repoName: 'Pendaftaran kontena',
imageName: 'Nama imej',
pull: 'Tarik',
@ -886,6 +885,8 @@ const message = {
pathSelect: 'Laluan',
label: 'Label',
imageTag: 'Tag imej',
imageTagHelper:
'Menyokong penetapan berbilang tag imej, tekan Enter selepas memasukkan setiap tag untuk teruskan',
push: 'Tekan',
fileName: 'Nama fail',
export: 'Eksport',

View file

@ -870,7 +870,6 @@ const message = {
imagePushHelper:
'Detectado que esta imagem possui múltiplas tags. Por favor, confirme que o nome da imagem usada para push é: {0}',
imageDelete: 'Excluir imagem',
imageTagDeleteHelper: 'Remover outras tags associadas a este ID de imagem',
repoName: 'Registro de contêiner',
imageName: 'Nome da imagem',
pull: 'Puxar',
@ -881,6 +880,8 @@ const message = {
pathSelect: 'Caminho',
label: 'Etiqueta',
imageTag: 'Tag de imagem',
imageTagHelper:
'Suporta definir múltiplas tags de imagem, pressione Enter após inserir cada tag para continuar',
push: 'Enviar',
fileName: 'Nome do arquivo',
export: 'Exportar',

View file

@ -873,7 +873,6 @@ const message = {
imagePushHelper:
'Обнаружено, что у этого образа несколько тегов. Подтвердите, что имя образа, используемое для отправки: {0}',
imageDelete: 'Удалить образ',
imageTagDeleteHelper: 'Удалить другие теги, связанные с этим ID образа',
repoName: 'Реестр контейнеров',
imageName: 'Имя образа',
pull: 'Загрузить',
@ -884,6 +883,8 @@ const message = {
pathSelect: 'Путь',
label: 'Метка',
imageTag: 'Тег образа',
imageTagHelper:
'Поддерживает установку нескольких тегов образов, нажмите Enter после ввода каждого тега для продолжения',
push: 'Отправить',
fileName: 'Имя файла',
export: 'Экспорт',

View file

@ -891,7 +891,6 @@ const message = {
imagePushHelper:
'Bu imgenin birden fazla etiketi olduğu tespit edildi. Lütfen gönderimde kullanılan imge adının şu olduğunu onaylayın: {0}',
imageDelete: 'İmaj sil',
imageTagDeleteHelper: 'Bu imaj IDsi ile ilişkili diğer etiketleri kaldır',
repoName: 'Konteyner kayıt defteri',
imageName: 'İmaj adı',
pull: 'Çek',
@ -902,6 +901,8 @@ const message = {
pathSelect: 'Yol',
label: 'Etiket',
imageTag: 'İmaj etiketi',
imageTagHelper:
"Birden fazla görüntü etiketi ayarlamayı destekler, her etiket girdikten sonra Enter'a basarak devam edin",
push: 'Gönder',
fileName: 'Dosya adı',
export: 'Dışa aktar',

View file

@ -845,7 +845,6 @@ const message = {
imagePush: '推送鏡像',
imagePushHelper: '檢測到該映像存在多個標籤請確認推送時使用的映像名稱為{0}',
imageDelete: '刪除鏡像',
imageTagDeleteHelper: '移除與該映像 ID 相關聯的其他標籤',
repoName: '倉庫名',
imageName: '鏡像名',
httpRepo: 'http 倉庫新增授信需要重啟 docker 服務',
@ -859,6 +858,7 @@ const message = {
pathSelect: '路徑選擇',
label: '標籤',
imageTag: '鏡像標籤',
imageTagHelper: '支援設定多個映像標籤輸入一個標籤後回車繼續',
push: '推送',
fileName: '檔案名',
export: '匯出',

View file

@ -844,7 +844,6 @@ const message = {
imagePush: '推送镜像',
imagePushHelper: '检测到该镜像存在多个标签请确认推送时使用的镜像名称为{0}',
imageDelete: '删除镜像',
imageTagDeleteHelper: '移除与该镜像 ID 相关联的其他标签',
repoName: '仓库名',
imageName: '镜像名',
httpRepo: 'http 仓库添加授信需要重启 docker 服务',
@ -858,6 +857,7 @@ const message = {
pathSelect: '路径选择',
label: '标签',
imageTag: '镜像标签',
imageTagHelper: '支持设置多个镜像 tag输入一个 tag 后回车继续',
push: '推送',
fileName: '文件名',
export: '导出',

View file

@ -1,6 +1,6 @@
<template>
<DrawerPro v-model="drawerVisible" :header="$t('container.imageTag')" @close="handleClose" size="large">
<el-form v-loading="loading" label-position="top" ref="formRef" :model="form" label-width="80px">
<el-form v-loading="loading" label-position="top" ref="formRef" :model="form" :rules="rules" label-width="80px">
<el-form-item :label="$t('app.source')">
<el-checkbox v-model="form.fromRepo">{{ $t('container.imageRepo') }}</el-checkbox>
</el-form-item>
@ -14,19 +14,15 @@
<el-option v-for="item in repos" :key="item.id" :value="item.name" :label="item.name" />
</el-select>
</el-form-item>
<el-form-item :label="$t('container.imageTag')" :rules="Rules.imageName" prop="targetName">
<el-input v-model="form.targetName" />
</el-form-item>
<el-form-item>
<div class="w-full">
<el-checkbox v-model="form.deleteTag">
{{ $t('container.imageTagDeleteHelper') }}
</el-checkbox>
</div>
<el-checkbox-group v-if="form.deleteTag" v-model="form.deleteTags">
<el-checkbox v-for="item in tags" :key="item" :value="item" :label="item" />
</el-checkbox-group>
<el-form-item :label="$t('container.imageTag')" prop="tags">
<el-input-tag ref="inputTagRef" @add-tag="handleAdd" v-model="form.tags">
<template #tag="{ value }">
<el-button @click="setInputValue(value)" size="small" link type="info">
{{ value }}
</el-button>
</template>
</el-input-tag>
<span class="input-help">{{ $t('container.imageTagHelper') }}</span>
</el-form-item>
</el-form>
@ -48,25 +44,44 @@ import { reactive, ref } from 'vue';
import { Rules } from '@/global/form-rules';
import i18n from '@/lang';
import { ElForm } from 'element-plus';
import { imageRemove, imageTag } from '@/api/modules/container';
import { imageTag } from '@/api/modules/container';
import { Container } from '@/api/interface/container';
import { MsgSuccess } from '@/utils/message';
const loading = ref(false);
const inputTagRef = ref();
const drawerVisible = ref(false);
const repos = ref();
const tags = ref();
const form = reactive({
imageID: '',
fromRepo: false,
repo: '',
originName: '',
targetName: '',
deleteTag: false,
deleteTags: [],
tags: [],
});
const rules = reactive({
tags: [{ validator: checkTags, trigger: 'blur', required: true }],
});
function checkTags(rule: any, value: any, callback: any) {
if (value.length === 0) {
return callback(new Error(i18n.global.t('commons.rule.requiredInput')));
}
for (const item of value) {
if (item === '' || typeof item === 'undefined' || item == null) {
return callback(new Error(i18n.global.t('commons.rule.imageName')));
} else {
const reg = /^[a-zA-Z0-9]{1}[a-z:@A-Z0-9_/.-]{0,255}$/;
if (!reg.test(item) && item !== '') {
return callback(new Error(i18n.global.t('commons.rule.imageName')));
} else {
return callback();
}
}
}
callback();
}
interface DialogProps {
repos: Array<Container.RepoOptions>;
@ -78,13 +93,10 @@ const acceptParams = async (params: DialogProps): Promise<void> => {
drawerVisible.value = true;
form.imageID = params.imageID;
form.originName = params.tags?.length !== 0 ? params.tags[0] : '';
form.targetName = params.tags?.length !== 0 ? params.tags[0] : '';
form.tags = params.tags || [];
form.fromRepo = false;
form.repo = '';
form.deleteTag = false;
form.deleteTags = [];
repos.value = params.repos;
tags.value = params.tags;
};
const emit = defineEmits<{ (e: 'search'): void }>();
@ -95,21 +107,33 @@ const handleClose = () => {
type FormInstance = InstanceType<typeof ElForm>;
const formRef = ref<FormInstance>();
const handleAdd = (val: string) => {
form.tags = form.tags?.filter((item) => item !== val);
form.tags.push(val);
};
const setInputValue = async (text) => {
await nextTick();
const inputEl = inputTagRef.value?.$el?.querySelector('input');
if (!inputEl) return;
inputEl.value = text;
inputEl.dispatchEvent(new Event('input', { bubbles: true }));
inputEl.dispatchEvent(new Event('change', { bubbles: true }));
inputEl.setSelectionRange(text.length, text.length);
};
const onSubmit = async (formEl: FormInstance | undefined) => {
if (!formEl) return;
formEl.validate(async (valid) => {
if (!valid) return;
let params = {
sourceID: form.imageID,
targetName: form.targetName,
tags: form.tags,
};
loading.value = true;
await imageTag(params)
.then(async () => {
loading.value = false;
if (form.deleteTag && form.deleteTags.length !== 0) {
await imageRemove({ names: form.deleteTags });
}
drawerVisible.value = false;
emit('search');
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
@ -122,12 +146,11 @@ const onSubmit = async (formEl: FormInstance | undefined) => {
const changeRepo = (val) => {
if (val === 'Docker Hub') {
form.targetName = form.originName;
return;
}
for (const item of repos.value) {
if (item.name == val) {
form.targetName = item.downloadUrl + '/' + form.originName;
form.tags.push(item.downloadUrl + '/' + form.originName);
return;
}
}