BannerManage.vue 6.57 KB
<template>
  <el-card shadow="never" class="box">
    <div class="toolbar">
      <el-button type="primary" @click="openEdit()">新增轮播</el-button>
    </div>
    <el-table v-loading="loading" :data="list" stripe>
      <el-table-column label="预览" width="100">
        <template #default="{ row }">
          <el-image :src="fullUrl(row.image_url)" fit="cover" class="thumb" />
        </template>
      </el-table-column>
      <el-table-column prop="title" label="标题" min-width="120" />
      <el-table-column prop="subtitle" label="副标题" min-width="140" show-overflow-tooltip />
      <el-table-column prop="sort" label="排序" width="80" />
      <el-table-column label="状态" width="90">
        <template #default="{ row }">
          <el-tag :type="row.status === 1 ? 'success' : 'info'">
            {{ row.status === 1 ? "显示" : "隐藏" }}
          </el-tag>
        </template>
      </el-table-column>
      <el-table-column label="操作" width="160" fixed="right">
        <template #default="{ row }">
          <el-button type="primary" link @click="openEdit(row)">编辑</el-button>
          <el-button type="danger" link @click="onDelete(row)">删除</el-button>
        </template>
      </el-table-column>
    </el-table>

    <el-dialog
      v-model="visible"
      :title="form.id ? '编辑轮播' : '新增轮播'"
      width="520px"
      align-center
      destroy-on-close
    >
      <el-form ref="formRef" :model="form" :rules="rules" label-width="96px">
        <el-form-item label="轮播图片" prop="image_url">
          <div class="banner-upload">
            <el-image
              v-if="form.image_url"
              :src="fullUrl(form.image_url)"
              fit="cover"
              class="banner-preview"
            />
            <div v-else class="banner-preview banner-preview--empty">暂无图片</div>
            <el-upload
              :show-file-list="false"
              accept="image/jpeg,image/jpg,image/png,image/webp,image/gif"
              :http-request="handleBannerUpload"
            >
              <el-button type="primary" :loading="uploading">上传图片</el-button>
            </el-upload>
            <p class="banner-upload-tip">支持 jpg / png / webp / gif,最大 10MB</p>
          </div>
        </el-form-item>
        <el-form-item label="标题" prop="title">
          <el-input v-model="form.title" />
        </el-form-item>
        <el-form-item label="副标题" prop="subtitle">
          <el-input v-model="form.subtitle" />
        </el-form-item>
        <el-form-item label="排序" prop="sort">
          <el-input-number v-model="form.sort" :min="0" />
        </el-form-item>
        <el-form-item label="状态" prop="status">
          <el-radio-group v-model="form.status">
            <el-radio :value="1">显示</el-radio>
            <el-radio :value="0">隐藏</el-radio>
          </el-radio-group>
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button @click="visible = false">取消</el-button>
        <el-button type="primary" :loading="saving" @click="submit">保存</el-button>
      </template>
    </el-dialog>
  </el-card>
</template>

<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import type { FormInstance, FormRules } from "element-plus";
import { ElMessageBox } from "element-plus";
import {
  deleteBanner,
  fetchBanners,
  saveBanner,
  uploadAdminImage,
  type BannerRow,
} from "@/api/admin";
import type { UploadRequestOptions } from "element-plus";

const loading = ref(false);
const list = ref<BannerRow[]>([]);
const visible = ref(false);
const saving = ref(false);
const uploading = ref(false);
const formRef = ref<FormInstance>();

const form = reactive({
  id: 0,
  image_url: "",
  title: "",
  subtitle: "",
  sort: 0,
  status: 1,
});

const rules: FormRules = {
  image_url: [{ required: true, message: "请上传图片", trigger: "change" }],
};

async function handleBannerUpload(options: UploadRequestOptions) {
  uploading.value = true;
  try {
    const res = await uploadAdminImage(options.file);
    form.image_url = res.data.url;
    await formRef.value?.validateField("image_url").catch(() => undefined);
    options.onSuccess(res);
  } catch (e) {
    options.onError(e as Error);
  } finally {
    uploading.value = false;
  }
}

function apiOrigin(): string {
  const v = import.meta.env.VITE_API_BASE;
  if (typeof v === "string" && v.trim()) {
    return v.trim().replace(/\/+$/, "");
  }
  return "";
}

function fullUrl(path: string): string {
  if (!path) return "";
  if (path.startsWith("http")) return path;
  if (path.startsWith("/uploads/")) {
    const o = apiOrigin();
    // 配置了完整 API 域名则拼绝对地址;未配置时(如 npm run dev)用同源相对路径,由 Vite 代理 /uploads
    return o ? `${o}${path}` : path;
  }
  return path;
}

async function load() {
  loading.value = true;
  try {
    const res = await fetchBanners();
    list.value = res.data.list;
  } finally {
    loading.value = false;
  }
}

function openEdit(row?: BannerRow) {
  if (row) {
    form.id = row.id;
    form.image_url = row.image_url;
    form.title = row.title;
    form.subtitle = row.subtitle;
    form.sort = row.sort;
    form.status = row.status;
  } else {
    form.id = 0;
    form.image_url = "";
    form.title = "";
    form.subtitle = "";
    form.sort = list.value.length + 1;
    form.status = 1;
  }
  visible.value = true;
}

async function submit() {
  await formRef.value?.validate().catch(() => Promise.reject());
  saving.value = true;
  try {
    await saveBanner({
      id: form.id,
      image_url: form.image_url,
      title: form.title,
      subtitle: form.subtitle,
      sort: form.sort,
      status: form.status,
    });
    visible.value = false;
    await load();
  } finally {
    saving.value = false;
  }
}

async function onDelete(row: BannerRow) {
  await ElMessageBox.confirm("确认删除该轮播?", "提示", { type: "warning" });
  await deleteBanner(row.id);
  await load();
}

onMounted(() => {
  void load();
});
</script>

<style scoped>
.box {
  border-radius: 12px;
}

.toolbar {
  margin-bottom: 16px;
}

.thumb {
  width: 72px;
  height: 40px;
  border-radius: 6px;
}

.banner-upload {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 12px;
}

.banner-preview {
  width: 280px;
  height: 120px;
  border-radius: 8px;
  background: #f3f4f6;
}

.banner-preview--empty {
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 13px;
  color: #9ca3af;
  border: 1px dashed #e5e7eb;
}

.banner-upload-tip {
  margin: 0;
  font-size: 12px;
  color: #9ca3af;
}
</style>