ouyang 1 день назад
Родитель
Сommit
551f515927
1 измененных файлов с 981 добавлено и 0 удалено
  1. 981 0
      hdApp/src/admin/goods/components/category-ad-setting.vue

+ 981 - 0
hdApp/src/admin/goods/components/category-ad-setting.vue

@@ -0,0 +1,981 @@
+<!--
+  分类广告设置组件
+  用途:在分类管理页第三 Tab 内嵌编辑,最多 3 张轮播,不跳转独立页
+  数据存 xhCategorySetting.advertisement JSON:[{type,img,value}] type 1图文 2商品 3分类
+-->
+<template>
+  <view class="ad-setting">
+    <view class="ad-intro">
+      <text class="ad-intro-title">分类页轮播广告</text>
+      <text class="ad-intro-sub">共 3 个广告位,点表格即可设置图片和跳转</text>
+    </view>
+
+    <!-- 3 个广告位各一张表,行高加大方便点按 -->
+    <view class="ad-table" v-for="(item, index) in adList" :key="item.slotKey">
+      <view class="ad-table-head">
+        <text class="ad-table-title">广告位 {{ index + 1 }}</text>
+        <text class="ad-status" :class="isSlotSet(item) ? 'is-set' : 'no-set'">
+          {{ isSlotSet(item) ? '已设置' : '未设置' }}
+        </text>
+      </view>
+
+      <view class="ad-table-row ad-table-row-img" @click="chooseImage(index)">
+        <text class="ad-label">广告图</text>
+        <view class="ad-value ad-value-img">
+          <block v-if="item.img">
+            <image class="ad-thumb" :src="imgFullUrl(item.img)" mode="aspectFill" />
+            <view class="ad-thumb-remove" @click.stop="removeImage(index)">×</view>
+          </block>
+          <view v-else class="ad-upload-box">
+            <text class="ad-upload-plus">+</text>
+            <text class="ad-upload-text">点击上传</text>
+          </view>
+        </view>
+      </view>
+
+      <view class="ad-table-row">
+        <text class="ad-label">关联类型</text>
+        <view class="ad-value">
+          <view class="type-tabs">
+            <view
+              class="type-tab"
+              v-for="opt in typeOptions"
+              :key="opt.value"
+              :class="{ active: item.type === opt.value }"
+              @click.stop="changeType(index, opt.value)"
+            >{{ opt.label }}</view>
+          </view>
+        </view>
+      </view>
+
+      <view class="ad-table-row ad-table-row-last" @click="openLinkSelect(index)">
+        <text class="ad-label">关联内容</text>
+        <view class="ad-value ad-value-link">
+          <text
+            class="link-text"
+            :class="{ placeholder: !getActiveLink(item).valueLabel }"
+          >{{ getActiveLink(item).valueLabel || '点击选择' }}</text>
+          <text class="link-arrow">›</text>
+        </view>
+      </view>
+    </view>
+
+    <view class="ad-bottom-space"></view>
+
+    <!-- 分类多选弹层:支持搜索,分类较多时便于查找 -->
+    <view v-if="categoryPickerShow" class="picker-overlay" @touchmove.stop.prevent="preventTouchMove">
+      <view class="picker-mask" @tap.stop="closeCategoryPicker"></view>
+      <view class="picker-panel category-picker-panel">
+        <view class="picker-head">
+          <text class="picker-title">选择分类</text>
+          <text class="picker-count">已选 {{ categoryPickCount }} 个</text>
+          <text class="picker-close" @tap.stop="closeCategoryPicker">×</text>
+        </view>
+        <view class="picker-search-row">
+          <view class="picker-search-box">
+            <text class="picker-search-icon">⌕</text>
+            <input
+              v-model="categorySearchText"
+              class="picker-search-input"
+              placeholder="搜索分类名称"
+              confirm-type="search"
+            />
+          </view>
+          <text
+            v-if="categorySearchText"
+            class="picker-search-clear"
+            @tap.stop="clearCategorySearch"
+          >清空</text>
+        </view>
+        <scroll-view scroll-y class="picker-body">
+          <block v-if="filteredBouquetCategories.length">
+            <view
+              class="picker-item"
+              v-for="cat in filteredBouquetCategories"
+              :key="cat.id"
+              :data-id="cat.id"
+              @tap.stop="toggleCategoryPickByEvent"
+            >
+              <view class="check-box" :class="{ checked: !!categoryPickMap[String(cat.id)] }">
+                <text v-if="categoryPickMap[String(cat.id)]" class="check-mark">✓</text>
+              </view>
+              <text class="picker-item-name">{{ cat.categoryName }}</text>
+            </view>
+          </block>
+          <view v-else class="picker-empty">
+            <text>{{ categorySearchText ? '未找到匹配的分类' : '暂无分类' }}</text>
+          </view>
+        </scroll-view>
+        <view class="picker-footer">
+          <button class="picker-btn cancel" hover-class="none" @click="closeCategoryPicker">取消</button>
+          <button class="picker-btn confirm" hover-class="none" @click="confirmCategoryPick">确定</button>
+        </view>
+      </view>
+    </view>
+
+    <!-- 已上传广告图预览 -->
+    <view v-if="previewShow" class="picker-overlay" @touchmove.stop.prevent="preventTouchMove">
+      <view class="picker-mask" @click.stop="previewShow = false"></view>
+      <view class="preview-panel">
+        <swiper class="preview-swiper" indicator-dots circular>
+          <swiper-item v-for="slide in previewSlides" :key="slide.slideKey">
+            <image class="preview-img" :src="slide.url" mode="aspectFit" />
+          </swiper-item>
+        </swiper>
+        <button class="preview-close" hover-class="none" @click="previewShow = false">关闭预览</button>
+      </view>
+    </view>
+
+    <view class="ad-bottom-bar">
+      <button class="bar-btn bar-btn-preview" hover-class="none" @click="previewAd">预览</button>
+      <button class="bar-btn bar-btn-save" hover-class="none" @click="saveAd">保存</button>
+    </view>
+  </view>
+</template>
+
+<script>
+import { APIHOST } from '@/config';
+import { categoryListInfo } from '@/api/goods';
+import { categorySettingDetail, categorySettingSaveAdvertisement } from '@/api/category-setting';
+
+const EMPTY_LINK = () => ({ value: '', valueLabel: '' });
+
+const EMPTY_SLOT = (index) => ({
+  slotKey: `ad-${index}`,
+  type: 1,
+  img: '',
+  // 各关联类型独立缓存,切换类型时不丢失已选内容
+  links: {
+    1: EMPTY_LINK(),
+    2: EMPTY_LINK(),
+    3: EMPTY_LINK()
+  }
+});
+
+export default {
+  name: 'category-ad-setting',
+  data() {
+    return {
+      constant: this.$constant,
+      settingId: 0,
+      adList: [EMPTY_SLOT(0), EMPTY_SLOT(1), EMPTY_SLOT(2)],
+      typeOptions: [
+        { label: '图文', value: 1 },
+        { label: '商品', value: 2 },
+        { label: '分类', value: 3 }
+      ],
+      bouquetCategories: [],
+      categoryPickerShow: false,
+      categoryPickIndex: 0,
+      categoryPickMap: {},
+      categorySearchText: '',
+      previewShow: false,
+      saving: false
+    };
+  },
+  computed: {
+    previewSlides() {
+      return this.adList
+        .filter((item) => item.img)
+        .map((item, index) => ({
+          slideKey: item.slotKey || `preview-${index}`,
+          url: this.imgFullUrl(item.img)
+        }));
+    },
+    /** 分类弹层内已选数量 */
+    categoryPickCount() {
+      return Object.keys(this.categoryPickMap).length;
+    },
+    /** 按关键词过滤花束分类,分类多时可快速定位 */
+    filteredBouquetCategories() {
+      const keyword = (this.categorySearchText || '').trim().toLowerCase();
+      if (!keyword) return this.bouquetCategories;
+      return this.bouquetCategories.filter((cat) => {
+        const name = (cat.categoryName || '').toLowerCase();
+        return name.indexOf(keyword) > -1;
+      });
+    }
+  },
+  mounted() {
+    this.reload();
+    this.loadBouquetCategories();
+    // 选图文/商品后从子页返回,用全局事件回填当前广告位
+    uni.$on('categoryAdPicTextSelected', this.onPicTextSelected);
+    uni.$on('categoryAdGoodsSelected', this.onGoodsSelected);
+  },
+  beforeDestroy() {
+    uni.$off('categoryAdPicTextSelected', this.onPicTextSelected);
+    uni.$off('categoryAdGoodsSelected', this.onGoodsSelected);
+  },
+  methods: {
+    preventTouchMove() {},
+    /** 图片和关联内容都有才算已设置,给表头状态用 */
+    isSlotSet(item) {
+      const link = this.getActiveLink(item);
+      return !!(item.img && link.value);
+    },
+    imgFullUrl(path) {
+      if (!path) return '';
+      if (path.indexOf('http') === 0) return path;
+      const base = (this.constant.imgUrl || '').replace(/\/$/, '');
+      return `${base}/${path.replace(/^\//, '')}`;
+    },
+    /** 父页下拉刷新或切到广告 Tab 时重新拉 advertisement */
+    reload() {
+      return this.loadSetting();
+    },
+    loadSetting() {
+      return categorySettingDetail().then((res) => {
+        if (res.code != 1) return;
+        const data = res.data || {};
+        this.settingId = data.id || 0;
+        this.adList = this.parseAdvertisement(data.advertisement);
+      });
+    },
+    loadBouquetCategories() {
+      categoryListInfo().then((res) => {
+        const list = res.data || [];
+        this.bouquetCategories = list;
+        // 分类名称依赖列表,加载后刷新各 slot 分类类型展示文案
+        this.adList = this.adList.map((item) => this.refreshSlotLinkLabels(item));
+      });
+    },
+    /** 取当前关联类型下的内容与展示文案 */
+    getActiveLink(item) {
+      const type = item.type || 1;
+      return (item.links && item.links[type]) || EMPTY_LINK();
+    },
+    /** 写入指定类型的关联内容 */
+    setSlotLink(item, type, value, valueLabel) {
+      if (!item.links) {
+        item.links = { 1: EMPTY_LINK(), 2: EMPTY_LINK(), 3: EMPTY_LINK() };
+      }
+      this.$set(item.links, type, { value: value || '', valueLabel: valueLabel || '' });
+    },
+    /** 分类列表就绪后,重算 slot 内各类型展示文案 */
+    refreshSlotLinkLabels(item) {
+      const links = item.links || { 1: EMPTY_LINK(), 2: EMPTY_LINK(), 3: EMPTY_LINK() };
+      [1, 2, 3].forEach((type) => {
+        const link = links[type] || EMPTY_LINK();
+        if (link.value) {
+          links[type] = {
+            value: link.value,
+            valueLabel: this.buildValueLabel(type, link.value, link.valueLabel)
+          };
+        }
+      });
+      return { ...item, links };
+    },
+    parseAdvertisement(raw) {
+      const slots = [EMPTY_SLOT(0), EMPTY_SLOT(1), EMPTY_SLOT(2)];
+      if (!raw) return slots;
+      let arr = [];
+      try {
+        arr = typeof raw === 'string' ? JSON.parse(raw) : raw;
+      } catch (e) {
+        return slots;
+      }
+      if (!Array.isArray(arr)) return slots;
+      arr.slice(0, 3).forEach((item, index) => {
+        const type = parseInt(item.type, 10) || 1;
+        const value = item.value ? String(item.value) : '';
+        const links = {
+          1: EMPTY_LINK(),
+          2: EMPTY_LINK(),
+          3: EMPTY_LINK()
+        };
+        links[type] = {
+          value,
+          valueLabel: this.buildValueLabel(type, value)
+        };
+        slots[index] = {
+          slotKey: `ad-${index}`,
+          type,
+          img: item.img || '',
+          links
+        };
+      });
+      return slots;
+    },
+    buildValueLabel(type, value, fallbackLabel) {
+      if (!value) return '';
+      const ids = value.split(',').filter(Boolean);
+      if (type === 1) {
+        if (fallbackLabel && fallbackLabel.indexOf('图文ID:') !== 0) return fallbackLabel;
+        return ids.length ? `图文ID:${ids.join(',')}` : '';
+      }
+      if (type === 2) return `已选${ids.length}个商品`;
+      if (type === 3) {
+        const names = ids.map((id) => {
+          const cat = this.bouquetCategories.find((c) => String(c.id) === String(id));
+          return cat ? cat.categoryName : id;
+        });
+        return names.join('、') || `已选${ids.length}个分类`;
+      }
+      return '';
+    },
+    /** 仅切换展示类型,不清空各类型已缓存的关联内容 */
+    changeType(index, type) {
+      const item = this.adList[index];
+      if (item.type === type) return;
+      item.type = type;
+    },
+    openLinkSelect(index) {
+      const item = this.adList[index];
+      const link = this.getActiveLink(item);
+      if (item.type === 1) {
+        uni.navigateTo({
+          url: `/admin/picText/picTextList?mode=selectAd&slotIndex=${index}&picTextId=${link.value || 0}`
+        });
+        return;
+      }
+      if (item.type === 2) {
+        uni.navigateTo({
+          url: `/admin/goods/ad-goods-select?slotIndex=${index}&selectedIds=${link.value || ''}`
+        });
+        return;
+      }
+      if (item.type === 3) {
+        this.categoryPickIndex = index;
+        this.categorySearchText = '';
+        this.categoryPickMap = {};
+        const ids = link.value ? link.value.split(',').filter(Boolean) : [];
+        ids.forEach((id) => {
+          this.$set(this.categoryPickMap, String(id), true);
+        });
+        this.categoryPickerShow = true;
+      }
+    },
+    onPicTextSelected(payload) {
+      const index = payload.slotIndex;
+      if (index === undefined || !this.adList[index]) return;
+      const valueLabel = payload.title || `图文ID:${payload.picTextId}`;
+      this.setSlotLink(this.adList[index], 1, String(payload.picTextId || ''), valueLabel);
+    },
+    onGoodsSelected(payload) {
+      const index = payload.slotIndex;
+      if (index === undefined || !this.adList[index]) return;
+      const ids = (payload.goodsIds || []).map(String);
+      const value = ids.join(',');
+      this.setSlotLink(
+        this.adList[index],
+        2,
+        value,
+        ids.length ? `已选${ids.length}个商品` : ''
+      );
+    },
+    /** 从 data-id 切换分类勾选,兼容小程序 tap 传参 */
+    toggleCategoryPickByEvent(e) {
+      const id = e.currentTarget.dataset.id;
+      if (id === undefined || id === null || id === '') return;
+      const key = String(id);
+      if (this.categoryPickMap[key]) {
+        this.$delete(this.categoryPickMap, key);
+      } else {
+        this.$set(this.categoryPickMap, key, true);
+      }
+    },
+    clearCategorySearch() {
+      this.categorySearchText = '';
+    },
+    closeCategoryPicker() {
+      this.categoryPickerShow = false;
+      this.categorySearchText = '';
+    },
+    confirmCategoryPick() {
+      const index = this.categoryPickIndex;
+      const ids = Object.keys(this.categoryPickMap);
+      const value = ids.join(',');
+      this.setSlotLink(this.adList[index], 3, value, this.buildValueLabel(3, value));
+      this.categoryPickerShow = false;
+      this.categorySearchText = '';
+    },
+    chooseImage(index) {
+      uni.chooseImage({
+        count: 1,
+        sizeType: ['compressed'],
+        sourceType: ['album', 'camera'],
+        success: (res) => {
+          const filePath = res.tempFilePaths[0];
+          this.uploadImageFile(filePath, index);
+        }
+      });
+    },
+    /** 通过 multipart 上传广告图,存相对路径 shortUrl 供保存与展示 */
+    uploadImageFile(filePath, index) {
+      uni.showLoading({ title: '上传中', mask: true });
+      const token = uni.getStorageSync('token') || '';
+      uni.uploadFile({
+        url: `${APIHOST}/upload/save-file`,
+        filePath,
+        name: 'file',
+        fileType: 'image',
+        header: { token },
+        success: (uploadRes) => {
+          uni.hideLoading();
+          try {
+            const res = JSON.parse(uploadRes.data || '{}');
+            if (res.code == 1 && res.data) {
+              // 保存相对路径,展示时由 imgFullUrl 拼接域名
+              this.adList[index].img = res.data.shortUrl || res.data.url || '';
+              if (!this.adList[index].img) {
+                this.$msg('上传失败');
+              }
+              return;
+            }
+            this.$msg(res.message || '上传失败');
+          } catch (e) {
+            this.$msg('上传失败');
+          }
+        },
+        fail: () => {
+          uni.hideLoading();
+          this.$msg('上传失败');
+        }
+      });
+    },
+    removeImage(index) {
+      this.adList[index].img = '';
+    },
+    previewAd() {
+      if (!this.previewSlides.length) {
+        this.$msg('请先上传广告图');
+        return;
+      }
+      this.previewShow = true;
+    },
+    saveAd() {
+      const payload = [];
+      for (let i = 0; i < this.adList.length; i++) {
+        const item = this.adList[i];
+        const link = this.getActiveLink(item);
+        if (!item.img && !link.value) continue;
+        if (item.img && !link.value) {
+          this.$msg(`请完善广告位${i + 1}的关联内容`);
+          return;
+        }
+        if (link.value && !item.img) {
+          this.$msg(`请上传广告位${i + 1}的图片`);
+          return;
+        }
+        payload.push({
+          type: item.type,
+          img: item.img,
+          value: link.value
+        });
+      }
+      if (this.saving) return;
+      this.saving = true;
+      categorySettingSaveAdvertisement({
+        advertisement: JSON.stringify(payload)
+      }).then((res) => {
+        if (res.code == 1) {
+          this.$msg('保存成功');
+        }
+      }).finally(() => {
+        this.saving = false;
+      });
+    }
+  }
+};
+</script>
+
+<style lang="scss" scoped>
+.ad-setting {
+  padding: 20upx 20upx 0;
+  box-sizing: border-box;
+}
+
+.ad-intro {
+  padding: 8upx 8upx 20upx;
+}
+
+.ad-intro-title {
+  display: block;
+  font-size: 32upx;
+  color: #161819;
+  font-weight: 700;
+  line-height: 44upx;
+}
+
+.ad-intro-sub {
+  display: block;
+  margin-top: 8upx;
+  font-size: 24upx;
+  color: #8a9199;
+  line-height: 34upx;
+}
+
+.ad-table {
+  background: #fff;
+  border-radius: 16upx;
+  overflow: hidden;
+  box-shadow: 0 6upx 20upx rgba(0, 0, 0, 0.04);
+}
+
+.ad-table + .ad-table {
+  margin-top: 24upx;
+}
+
+.ad-table-head {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  padding: 24upx 28upx;
+  background: #f8fbf8;
+  border-bottom: 1upx solid #f0f0f0;
+}
+
+.ad-table-title {
+  font-size: 30upx;
+  color: #161819;
+  font-weight: 700;
+  line-height: 42upx;
+}
+
+.ad-status {
+  min-width: 96upx;
+  height: 44upx;
+  padding: 0 16upx;
+  line-height: 44upx;
+  text-align: center;
+  font-size: 22upx;
+  font-weight: 600;
+  border-radius: 22upx;
+}
+
+.ad-status.is-set {
+  color: #52c41a;
+  background: #f6ffed;
+}
+
+.ad-status.no-set {
+  color: #999;
+  background: #f5f5f5;
+}
+
+.ad-table-row {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  min-height: 112upx;
+  padding: 20upx 28upx;
+  border-bottom: 1upx solid #f3f3f3;
+}
+
+.ad-table-row-last {
+  border-bottom: none;
+}
+
+.ad-table-row-img {
+  align-items: flex-start;
+}
+
+.ad-label {
+  width: 140upx;
+  flex-shrink: 0;
+  font-size: 28upx;
+  color: #666;
+  line-height: 40upx;
+}
+
+.ad-table-row-img .ad-label {
+  padding-top: 12upx;
+}
+
+.ad-value {
+  flex: 1;
+  min-width: 0;
+}
+
+.ad-value-img {
+  position: relative;
+}
+
+.ad-thumb {
+  width: 100%;
+  height: 180upx;
+  border-radius: 12upx;
+  background: #f5f5f5;
+}
+
+.ad-thumb-remove {
+  position: absolute;
+  top: 12upx;
+  right: 12upx;
+  width: 48upx;
+  height: 48upx;
+  line-height: 44upx;
+  text-align: center;
+  background: rgba(0, 0, 0, 0.45);
+  color: #fff;
+  border-radius: 50%;
+  font-size: 32upx;
+}
+
+.ad-upload-box {
+  height: 180upx;
+  border: 2upx dashed #52c41a;
+  border-radius: 12upx;
+  background: #f8fff4;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+}
+
+.ad-upload-plus {
+  font-size: 48upx;
+  color: #52c41a;
+  line-height: 48upx;
+}
+
+.ad-upload-text {
+  margin-top: 8upx;
+  font-size: 26upx;
+  color: #52c41a;
+}
+
+.type-tabs {
+  display: flex;
+  flex-direction: row;
+}
+
+.type-tab {
+  flex: 1;
+  height: 72upx;
+  line-height: 72upx;
+  text-align: center;
+  font-size: 28upx;
+  color: #666;
+  border: 2upx solid #e8e8e8;
+  background: #fff;
+  border-radius: 10upx;
+}
+
+.type-tab + .type-tab {
+  margin-left: 12upx;
+}
+
+.type-tab.active {
+  color: #52c41a;
+  border-color: #52c41a;
+  background: #f6ffed;
+  font-weight: 600;
+}
+
+.ad-value-link {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  min-height: 72upx;
+  padding: 0 20upx;
+  border: 2upx solid #e8e8e8;
+  border-radius: 10upx;
+  background: #fafafa;
+}
+
+.link-text {
+  flex: 1;
+  min-width: 0;
+  font-size: 28upx;
+  color: #333;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.link-text.placeholder {
+  color: #bbb;
+}
+
+.link-arrow {
+  margin-left: 12upx;
+  color: #bbb;
+  font-size: 40upx;
+  line-height: 40upx;
+}
+
+.ad-bottom-space {
+  height: calc(180upx + env(safe-area-inset-bottom));
+}
+
+.ad-bottom-bar {
+  position: fixed;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  padding: 20upx 24upx;
+  padding-bottom: calc(20upx + env(safe-area-inset-bottom));
+  background: #fff;
+  box-shadow: 0 -2upx 10upx rgba(0, 0, 0, 0.06);
+  z-index: 10;
+}
+
+.bar-btn {
+  height: 88upx;
+  line-height: 88upx;
+  margin: 0;
+  padding: 0;
+  font-size: 30upx;
+  font-weight: 600;
+  border-radius: 12upx;
+  text-align: center;
+}
+
+.bar-btn::after {
+  border: none;
+}
+
+.bar-btn-preview {
+  width: 200upx;
+  background: #fff;
+  color: #52c41a;
+  border: 2upx solid #52c41a;
+  margin-right: 16upx;
+}
+
+.bar-btn-save {
+  flex: 1;
+  background: #52c41a;
+  color: #fff;
+  border: 2upx solid #52c41a;
+}
+
+.picker-overlay {
+  position: fixed;
+  left: 0;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 200;
+  display: flex;
+  align-items: flex-end;
+  justify-content: center;
+}
+
+.picker-mask {
+  position: absolute;
+  left: 0;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.5);
+}
+
+.picker-panel {
+  position: relative;
+  z-index: 1;
+  width: 100%;
+  max-height: 70vh;
+  background: #fff;
+  border-radius: 24upx 24upx 0 0;
+  display: flex;
+  flex-direction: column;
+}
+
+.category-picker-panel {
+  max-height: 82vh;
+}
+
+.picker-head {
+  position: relative;
+  height: 88upx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-bottom: 1upx solid #f0f0f0;
+  flex-shrink: 0;
+}
+
+.picker-title {
+  font-size: 32upx;
+  font-weight: 600;
+  color: #333;
+}
+
+.picker-count {
+  position: absolute;
+  left: 24upx;
+  font-size: 24upx;
+  color: #52c41a;
+}
+
+.picker-close {
+  position: absolute;
+  right: 24upx;
+  font-size: 36upx;
+  color: #999;
+  padding: 8upx;
+}
+
+.picker-search-row {
+  display: flex;
+  align-items: center;
+  padding: 16upx 24upx;
+  border-bottom: 1upx solid #f5f5f5;
+  flex-shrink: 0;
+}
+
+.picker-search-box {
+  flex: 1;
+  min-width: 0;
+  height: 68upx;
+  display: flex;
+  align-items: center;
+  padding: 0 20upx;
+  border: 2upx solid #e8e8e8;
+  border-radius: 34upx;
+  background: #fafafa;
+  box-sizing: border-box;
+}
+
+.picker-search-icon {
+  font-size: 28upx;
+  color: #999;
+  margin-right: 12upx;
+  flex-shrink: 0;
+}
+
+.picker-search-input {
+  flex: 1;
+  min-width: 0;
+  height: 64upx;
+  font-size: 26upx;
+  color: #333;
+}
+
+.picker-search-clear {
+  margin-left: 16upx;
+  font-size: 24upx;
+  color: #52c41a;
+  flex-shrink: 0;
+}
+
+.picker-body {
+  flex: 1;
+  min-height: 0;
+  max-height: 46vh;
+  padding: 8upx 24upx 16upx;
+  box-sizing: border-box;
+}
+
+.picker-empty {
+  padding: 80upx 0;
+  text-align: center;
+  font-size: 26upx;
+  color: #999;
+}
+
+.picker-item {
+  display: flex;
+  align-items: center;
+  padding: 22upx 0;
+  border-bottom: 1upx solid #f5f5f5;
+}
+
+.picker-item:last-child {
+  border-bottom: none;
+}
+
+.check-box {
+  width: 36upx;
+  height: 36upx;
+  border: 2upx solid #ccc;
+  border-radius: 6upx;
+  margin-right: 16upx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+}
+
+.check-box.checked {
+  background: #52c41a;
+  border-color: #52c41a;
+}
+
+.check-mark {
+  color: #fff;
+  font-size: 24upx;
+}
+
+.picker-item-name {
+  flex: 1;
+  min-width: 0;
+  font-size: 28upx;
+  color: #333;
+  line-height: 1.4;
+}
+
+.picker-footer {
+  display: flex;
+  padding: 20upx 24upx;
+  padding-bottom: calc(20upx + env(safe-area-inset-bottom));
+  flex-shrink: 0;
+  border-top: 1upx solid #f0f0f0;
+}
+
+.picker-btn {
+  flex: 1;
+  height: 80upx;
+  line-height: 80upx;
+  margin: 0;
+  font-size: 28upx;
+  border-radius: 8upx;
+}
+
+.picker-btn::after {
+  border: none;
+}
+
+.picker-btn.cancel {
+  margin-right: 20upx;
+  background: #fff;
+  color: #333;
+  border: 2upx solid #ddd;
+}
+
+.picker-btn.confirm {
+  background: #52c41a;
+  color: #fff;
+}
+
+.preview-panel {
+  position: relative;
+  z-index: 1;
+  width: 90%;
+  background: #fff;
+  border-radius: 16upx;
+  padding: 24upx;
+  box-sizing: border-box;
+  align-self: center;
+}
+
+.preview-swiper {
+  height: 320upx;
+}
+
+.preview-img {
+  width: 100%;
+  height: 320upx;
+}
+
+.preview-close {
+  margin-top: 20upx;
+  height: 72upx;
+  line-height: 72upx;
+  background: #52c41a;
+  color: #fff;
+  font-size: 28upx;
+  border-radius: 8upx;
+}
+
+.preview-close::after {
+  border: none;
+}
+</style>