Browse Source

花掌柜-商品分类改版

ouyang 3 tuần trước cách đây
mục cha
commit
4195926cbd

+ 486 - 0
hdApp/src/admin/goods/ad-goods-select.vue

@@ -0,0 +1,486 @@
+<!--

+  分类广告 - 商品多选页

+  用途:从花束商品中勾选关联商品,返回商品 id 列表给 category-ad

+  基于 manage 花束面板精简:无打标签/库存/编辑/更多/新增,无花材标签筛选

+-->

+<template>

+  <view class="goods-select-page">

+    <view class="search-row">

+      <view class="search-type-wrap">

+        <button class="type-btn" :class="{ active: searchType === 0 }" hover-class="none" @click="changeSearchType(0)">名称</button>

+        <button class="type-btn" :class="{ active: searchType === 1 }" hover-class="none" @click="changeSearchType(1)">编号</button>

+      </view>

+      <view class="search-box">

+        <input v-model="searchText" class="search-input" placeholder="请输入" confirm-type="search" @confirm="searchGoods" />

+      </view>

+      <button class="search-btn" hover-class="none" @click="searchGoods">搜索</button>

+    </view>

+

+    <view class="filter-row">

+      <view class="filter-tabs">

+        <view class="filter-tab" :class="{ active: priceType === -1 }" @click="switchPriceTab(-1)">全部</view>

+        <view class="filter-tab" :class="{ active: priceType === 0 }" @click="switchPriceTab(0)">无价</view>

+        <view class="filter-tab" :class="{ active: priceType === 1 }" @click="switchPriceTab(1)">有价</view>

+      </view>

+      <button class="refresh-btn" hover-class="none" @click="refreshGoods">刷新</button>

+    </view>

+

+    <view class="content-area">

+      <scroll-view scroll-y class="category-list">

+        <view

+          v-for="(item, index) in tabbar"

+          :key="item.id || index"

+          class="category-item"

+          :class="{ active: currentTab === index }"

+          :data-current="index"

+          @tap.stop="switchCategory($event, item)"

+        >

+          <text>{{ item.categoryName || '' }}</text>

+        </view>

+      </scroll-view>

+

+      <scroll-view scroll-y class="list-box" @scrolltolower="goodsReachBottom">

+        <block v-if="!$util.isEmpty(list.data)">

+          <view

+            class="goods-row"

+            v-for="(goodsItem, idx) in list.data"

+            :key="goodsItem.id || idx"

+            :data-id="goodsItem.id"

+            @tap.stop="toggleGoodsByEvent"

+          >

+            <view class="check-box" :class="{ checked: !!selectedMap[String(goodsItem.id)] }">

+              <text v-if="selectedMap[String(goodsItem.id)]" class="check-mark">✓</text>

+            </view>

+            <image class="goods-img" :src="goodsItem.smallCover" mode="aspectFill" />

+            <view class="goods-info">

+              <view class="goods-name">{{ goodsItem.name || '' }}</view>

+              <view class="goods-meta">

+                <text v-if="Number(goodsItem.sn) > 0">#{{ goodsItem.sn }}</text>

+                <text class="price" v-if="goodsItem.priceType == 1">¥{{ goodsItem.price ? parseFloat(goodsItem.price) : 0 }}</text>

+                <text class="price" v-else>---</text>

+              </view>

+            </view>

+          </view>

+        </block>

+        <app-wrapper-empty v-else title="暂无商品" :is-empty="$util.isEmpty(list.data)" />

+      </scroll-view>

+    </view>

+

+    <view class="footer-bar">

+      <text class="selected-tip">已选 {{ selectedCount }} 个</text>

+      <button class="confirm-btn" hover-class="none" @click="confirmSelect">确定</button>

+    </view>

+  </view>

+</template>

+

+<script>

+import AppWrapperEmpty from '@/components/app-wrapper-empty';

+import { list } from '@/mixins';

+import { categoryListInfo, getCategoryToGoods } from '@/api/goods';

+

+export default {

+  name: 'ad-goods-select',

+  components: { AppWrapperEmpty },

+  mixins: [list],

+  data() {

+    return {

+      slotIndex: 0,

+      // 用对象存选中态,小程序端 $set 比数组 splice 更易触发视图更新

+      selectedMap: {},

+      tabbar: [],

+      currentTab: 0,

+      catId: '',

+      catName: '',

+      searchText: '',

+      searchType: 0,

+      lastSearchText: '',

+      priceType: -1,

+      hasStock: -1,

+      autoLoad: false,

+      goodsSelectPageReady: false,

+      selectedIdsParsed: false

+    };

+  },

+  computed: {

+    /** 已选商品数量,供底部栏展示 */

+    selectedCount() {

+      return Object.keys(this.selectedMap).length;

+    }

+  },

+  onLoad(options) {

+    // 兜底解析路由参数;主流程由 globalMixins 触发的 init 执行

+    this.applyPageOptions(options || {});

+    this.ensureLoadCategories();

+  },

+  methods: {

+    /** globalMixins 在 onLoad 会调用 init,需在此拉取分类与商品 */

+    init() {

+      this.applyPageOptions(this.option || {});

+      this.ensureLoadCategories();

+    },

+    /** 解析 slotIndex、已选商品 id,可重复调用但 selectedIds 只回填一次 */

+    applyPageOptions(options) {

+      if (options.slotIndex !== undefined && options.slotIndex !== '') {

+        this.slotIndex = parseInt(options.slotIndex || 0, 10);

+      }

+      if (!this.selectedIdsParsed && options.selectedIds) {

+        this.selectedIdsParsed = true;

+        String(options.selectedIds)

+          .split(',')

+          .filter(Boolean)

+          .forEach((id) => {

+            this.$set(this.selectedMap, String(id), true);

+          });

+      }

+    },

+    /** 避免 init / onLoad 重复请求分类列表 */

+    ensureLoadCategories() {

+      if (this.goodsSelectPageReady) return;

+      this.goodsSelectPageReady = true;

+      this.loadCategories();

+    },

+    /** 只取花束分类 flower=0 */

+    loadCategories() {

+      categoryListInfo().then((res) => {

+        const all = res.data || [];

+        this.tabbar = all.filter((item) => item.flower == 0);

+        if (this.tabbar.length && this.tabbar[0].id) {

+          this.catId = this.tabbar[0].id;

+          this.catName = this.tabbar[0].categoryName;

+          this.refreshGoods();

+        }

+      });

+    },

+    getGoodsList() {

+      return getCategoryToGoods({

+        page: this.list.page,

+        searchText: this.lastSearchText,

+        searchType: this.searchType,

+        catId: this.catId,

+        flowerNum: 0,

+        priceType: this.priceType,

+        hasStock: this.hasStock,

+        requestType: 'goodsList'

+      }).then((res) => {

+        this.completes(res);

+      });

+    },

+    refreshGoods() {

+      this.resetList();

+      this.getGoodsList();

+    },

+    goodsReachBottom() {

+      if (!this.list.finished) {

+        this.getGoodsList();

+      }

+    },

+    /** 切换左侧分类;index 从 data-current 读取,兼容小程序 tap 传参 */

+    switchCategory(e, item) {

+      const index = Number(e.currentTarget.dataset.current);

+      const category = item || this.tabbar[index];

+      if (!category || category.id === undefined || category.id === null) return;

+      if (this.currentTab === index) return;

+      this.currentTab = index;

+      this.catId = category.id;

+      this.catName = category.categoryName || '';

+      this.searchText = '';

+      this.lastSearchText = '';

+      this.refreshGoods();

+    },

+    changeSearchType(i) {

+      this.searchType = i;

+      this.searchGoods();

+    },

+    searchGoods() {

+      this.lastSearchText = this.searchText;

+      if (!this.$util.isEmpty(this.lastSearchText)) {

+        this.catId = 0;

+        this.catName = '花束';

+        this.currentTab = -1;

+      } else if (this.tabbar[0]) {

+        this.currentTab = 0;

+        this.catId = this.tabbar[0].id;

+        this.catName = this.tabbar[0].categoryName;

+      }

+      this.refreshGoods();

+    },

+    switchPriceTab(value) {

+      if (this.priceType === value) return;

+      this.priceType = value;

+      this.refreshGoods();

+    },

+    /** 从 data-id 读取商品 id,避免小程序 tap 回调参数错位 */

+    toggleGoodsByEvent(e) {

+      const id = e.currentTarget.dataset.id;

+      if (id === undefined || id === null || id === '') return;

+      const key = String(id);

+      if (this.selectedMap[key]) {

+        this.$delete(this.selectedMap, key);

+      } else {

+        this.$set(this.selectedMap, key, true);

+      }

+    },

+    confirmSelect() {

+      uni.$emit('categoryAdGoodsSelected', {

+        slotIndex: this.slotIndex,

+        goodsIds: Object.keys(this.selectedMap)

+      });

+      uni.navigateBack();

+    }

+  }

+};

+</script>

+

+<style lang="scss" scoped>

+.goods-select-page {

+  height: 100vh;

+  display: flex;

+  flex-direction: column;

+  background: #fff;

+}

+

+.search-row {

+  display: flex;

+  align-items: center;

+  padding: 16rpx 18rpx;

+  flex-shrink: 0;

+}

+

+.search-type-wrap {

+  display: flex;

+  margin-right: 10rpx;

+}

+

+.type-btn {

+  width: 92rpx;

+  height: 64rpx;

+  line-height: 64rpx;

+  padding: 0;

+  margin: 0 8rpx 0 0;

+  font-size: 24rpx;

+  background: #f5f5f5;

+  color: #333;

+  border-radius: 8rpx;

+}

+

+.type-btn::after {

+  border: none;

+}

+

+.type-btn.active {

+  background: #52c41a;

+  color: #fff;

+}

+

+.search-box {

+  flex: 1;

+  height: 64rpx;

+  border: 2rpx solid #e2e2e2;

+  border-radius: 34rpx;

+  padding: 0 24rpx;

+  box-sizing: border-box;

+  min-width: 0;

+  margin-right: 10rpx;

+}

+

+.search-input {

+  height: 60rpx;

+  font-size: 26rpx;

+}

+

+.search-btn {

+  width: 108rpx;

+  height: 64rpx;

+  line-height: 64rpx;

+  padding: 0;

+  margin: 0;

+  font-size: 26rpx;

+  background: #52c41a;

+  color: #fff;

+  border-radius: 8rpx;

+}

+

+.search-btn::after {

+  border: none;

+}

+

+.filter-row {

+  display: flex;

+  align-items: center;

+  justify-content: space-between;

+  padding: 0 18rpx 16rpx;

+  flex-shrink: 0;

+}

+

+.filter-tabs {

+  display: flex;

+}

+

+.filter-tab {

+  margin-right: 40rpx;

+  font-size: 26rpx;

+  color: #111;

+  font-weight: 600;

+  position: relative;

+}

+

+.filter-tab.active {

+  color: #22a500;

+}

+

+.filter-tab.active::after {

+  content: '';

+  position: absolute;

+  left: 0;

+  right: 0;

+  bottom: -6rpx;

+  height: 4rpx;

+  background: #22a500;

+}

+

+.refresh-btn {

+  height: 56rpx;

+  line-height: 56rpx;

+  padding: 0 20rpx;

+  margin: 0;

+  font-size: 24rpx;

+  background: #f5f5f5;

+  color: #333;

+  border-radius: 8rpx;

+}

+

+.refresh-btn::after {

+  border: none;

+}

+

+.content-area {

+  flex: 1;

+  min-height: 0;

+  display: flex;

+  overflow: hidden;

+}

+

+.category-list {

+  width: 202rpx;

+  height: 100%;

+  flex-shrink: 0;

+  background: #fafafa;

+}

+

+.category-item {

+  min-height: 92rpx;

+  padding: 18rpx 10rpx 18rpx 24rpx;

+  font-size: 24rpx;

+  color: #111;

+  font-weight: 600;

+}

+

+.category-item.active {

+  color: #189b00;

+  background: #fff;

+}

+

+.list-box {

+  flex: 1;

+  height: 100%;

+  min-width: 0;

+  padding: 0 18rpx 120rpx;

+  box-sizing: border-box;

+}

+

+.goods-row {

+  display: flex;

+  align-items: center;

+  padding: 20rpx 0;

+  border-bottom: 1rpx solid #f0f0f0;

+}

+

+.check-box {

+  width: 36rpx;

+  height: 36rpx;

+  border: 2rpx solid #ccc;

+  border-radius: 6rpx;

+  margin-right: 16rpx;

+  flex-shrink: 0;

+  display: flex;

+  align-items: center;

+  justify-content: center;

+}

+

+.check-box.checked {

+  background: #52c41a;

+  border-color: #52c41a;

+}

+

+.check-mark {

+  color: #fff;

+  font-size: 24rpx;

+}

+

+.goods-img {

+  width: 120rpx;

+  height: 120rpx;

+  border-radius: 8rpx;

+  margin-right: 16rpx;

+  flex-shrink: 0;

+}

+

+.goods-info {

+  flex: 1;

+  min-width: 0;

+}

+

+.goods-name {

+  font-size: 28rpx;

+  color: #333;

+  line-height: 1.4;

+  margin-bottom: 8rpx;

+}

+

+.goods-meta {

+  font-size: 24rpx;

+  color: #999;

+}

+

+.price {

+  margin-left: 12rpx;

+  color: #e4393c;

+}

+

+.footer-bar {

+  position: fixed;

+  left: 0;

+  right: 0;

+  bottom: 0;

+  display: flex;

+  align-items: center;

+  justify-content: space-between;

+  padding: 20rpx 30rpx;

+  padding-bottom: calc(20rpx + env(safe-area-inset-bottom));

+  background: #fff;

+  box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.06);

+  z-index: 10;

+}

+

+.selected-tip {

+  font-size: 28rpx;

+  color: #666;

+}

+

+.confirm-btn {

+  width: 240rpx;

+  height: 72rpx;

+  line-height: 72rpx;

+  margin: 0;

+  padding: 0;

+  background: #52c41a;

+  color: #fff;

+  font-size: 28rpx;

+  border-radius: 8rpx;

+}

+

+.confirm-btn::after {

+  border: none;

+}

+</style>

+

+ 994 - 0
hdApp/src/admin/goods/category-ad.vue

@@ -0,0 +1,994 @@
+<!--
+  分类广告设置页
+  用途:配置最多3张轮播图,数据存 xhCategorySetting.advertisement JSON
+  格式:[{"type":1,"img":"path","value":"id1,id2"}] type:1图文 2商品 3分类
+-->
+<template>
+  <view class="page-container">
+    <view class="page-header">
+      <text class="page-title">分类广告图片</text>
+      <text class="page-sub">最多支持上传3张轮播图</text>
+    </view>
+
+    <view class="ad-card" v-for="(item, index) in adList" :key="index">
+      <view class="card-title">轮播图{{ index + 1 }}</view>
+
+      <view class="upload-area" @click="chooseImage(index)">
+        <block v-if="item.img">
+          <image class="banner-img" :src="imgFullUrl(item.img)" mode="aspectFill" />
+          <view class="remove-btn" @click.stop="removeImage(index)">×</view>
+        </block>
+        <block v-else>
+          <view class="upload-placeholder">
+            <text class="upload-icon">☁</text>
+            <text class="upload-text">点击上传图片</text>
+            <text class="upload-tip">建议尺寸:750*300,支持 jpg/png</text>
+          </view>
+        </block>
+      </view>
+
+      <view class="field-row">
+        <text class="field-label">关联类型</text>
+        <view class="type-tabs">
+          <view
+            class="type-tab"
+            v-for="opt in typeOptions"
+            :key="opt.value"
+            :class="{ active: item.type === opt.value }"
+            @click="changeType(index, opt.value)"
+          >{{ opt.label }}</view>
+        </view>
+      </view>
+
+      <view class="field-row field-row-link" @click="openLinkSelect(index)">
+        <text class="field-label">关联内容</text>
+        <view class="link-select">
+          <text class="link-text" :class="{ placeholder: !getActiveLink(item).valueLabel }">{{ getActiveLink(item).valueLabel || '选择关联内容' }}</text>
+          <text class="link-arrow">></text>
+        </view>
+      </view>
+    </view>
+
+    <view class="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
+          v-if="selectedCategoryItems.length"
+          scroll-x
+          class="picker-selected-row"
+          :show-scrollbar="false"
+        >
+          <view class="picker-selected-wrap">
+            <view
+              class="picker-selected-chip"
+              v-for="item in selectedCategoryItems"
+              :key="item.id"
+            >
+              <text class="chip-name">{{ item.name }}</text>
+              <text class="chip-remove" :data-id="item.id" @tap.stop="removeCategoryPickByEvent">×</text>
+            </view>
+          </view>
+        </scroll-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, idx) in previewSlides" :key="idx">
+            <image class="preview-img" :src="slide" mode="aspectFit" />
+          </swiper-item>
+        </swiper>
+        <button class="preview-close" hover-class="none" @click="previewShow = false">关闭预览</button>
+      </view>
+    </view>
+
+    <view class="bottom-bar">
+      <button class="bar-btn bar-btn-preview" hover-class="none" @click="previewAd">
+        <text class="preview-eye">👁</text>
+        <text>预览</text>
+      </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 = () => ({
+  type: 1,
+  img: '',
+  // 各关联类型独立缓存,切换类型时不丢失已选内容
+  links: {
+    1: EMPTY_LINK(),
+    2: EMPTY_LINK(),
+    3: EMPTY_LINK()
+  }
+});
+
+export default {
+  name: 'category-ad',
+  data() {
+    return {
+      constant: this.$constant,
+      settingId: 0,
+      adList: [EMPTY_SLOT(), EMPTY_SLOT(), EMPTY_SLOT()],
+      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) => 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;
+      });
+    },
+    /** 已选分类标签,便于回显与快速取消 */
+    selectedCategoryItems() {
+      return Object.keys(this.categoryPickMap).map((id) => {
+        const cat = this.bouquetCategories.find((c) => String(c.id) === String(id));
+        return {
+          id,
+          name: cat ? cat.categoryName : id
+        };
+      });
+    }
+  },
+  onLoad() {
+    this.loadSetting();
+    this.loadBouquetCategories();
+    uni.$on('categoryAdPicTextSelected', this.onPicTextSelected);
+    uni.$on('categoryAdGoodsSelected', this.onGoodsSelected);
+  },
+  onUnload() {
+    uni.$off('categoryAdPicTextSelected', this.onPicTextSelected);
+    uni.$off('categoryAdGoodsSelected', this.onGoodsSelected);
+  },
+  methods: {
+    preventTouchMove() {},
+    imgFullUrl(path) {
+      if (!path) return '';
+      if (path.indexOf('http') === 0) return path;
+      const base = (this.constant.imgUrl || '').replace(/\/$/, '');
+      return `${base}/${path.replace(/^\//, '')}`;
+    },
+    loadSetting() {
+      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.filter((item) => item.flower == 0);
+        // 分类名称依赖列表,加载后刷新各 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(), EMPTY_SLOT(), EMPTY_SLOT()];
+      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] = {
+          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);
+      }
+    },
+    /** 从已选标签移除单个分类 */
+    removeCategoryPickByEvent(e) {
+      const id = e.currentTarget.dataset.id;
+      if (id === undefined || id === null || id === '') return;
+      this.$delete(this.categoryPickMap, String(id));
+    },
+    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('保存成功');
+          setTimeout(() => uni.navigateBack(), 500);
+        }
+      }).finally(() => {
+        this.saving = false;
+      });
+    }
+  }
+};
+</script>
+
+<style lang="scss" scoped>
+.page-container {
+  min-height: 100vh;
+  background: #f5f5f5;
+  padding-bottom: 160rpx;
+}
+
+.page-header {
+  padding: 24rpx 30rpx 8rpx;
+}
+
+.page-title {
+  display: block;
+  font-size: 30rpx;
+  color: #333;
+  font-weight: 600;
+  margin-bottom: 8rpx;
+}
+
+.page-sub {
+  font-size: 24rpx;
+  color: #999;
+}
+
+.ad-card {
+  margin: 20rpx 24rpx 0;
+  padding: 24rpx;
+  background: #fff;
+  border-radius: 12rpx;
+  border: 1rpx solid #eee;
+}
+
+.card-title {
+  font-size: 28rpx;
+  color: #333;
+  font-weight: 600;
+  margin-bottom: 20rpx;
+}
+
+.upload-area {
+  position: relative;
+  width: 100%;
+  height: 240rpx;
+  border-radius: 8rpx;
+  overflow: hidden;
+  margin-bottom: 24rpx;
+}
+
+.banner-img {
+  width: 100%;
+  height: 100%;
+}
+
+.remove-btn {
+  position: absolute;
+  top: 12rpx;
+  right: 12rpx;
+  width: 44rpx;
+  height: 44rpx;
+  line-height: 40rpx;
+  text-align: center;
+  background: rgba(0, 0, 0, 0.45);
+  color: #fff;
+  border-radius: 50%;
+  font-size: 32rpx;
+}
+
+.upload-placeholder {
+  width: 100%;
+  height: 100%;
+  border: 2rpx dashed #52c41a;
+  border-radius: 8rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  background: #fafafa;
+}
+
+.upload-icon {
+  font-size: 48rpx;
+  color: #52c41a;
+  margin-bottom: 12rpx;
+}
+
+.upload-text {
+  font-size: 28rpx;
+  color: #52c41a;
+  margin-bottom: 8rpx;
+}
+
+.upload-tip {
+  font-size: 22rpx;
+  color: #999;
+}
+
+.field-row {
+  margin-bottom: 20rpx;
+}
+
+.field-row-link {
+  margin-bottom: 0;
+}
+
+.field-label {
+  display: block;
+  font-size: 26rpx;
+  color: #333;
+  margin-bottom: 12rpx;
+}
+
+.type-tabs {
+  display: flex;
+  flex-direction: row;
+}
+
+.type-tab {
+  flex: 1;
+  height: 64rpx;
+  line-height: 64rpx;
+  text-align: center;
+  font-size: 26rpx;
+  color: #666;
+  border: 2rpx solid #e5e5e5;
+  background: #fff;
+  margin-right: 16rpx;
+  border-radius: 8rpx;
+}
+
+.type-tab:last-child {
+  margin-right: 0;
+}
+
+.type-tab.active {
+  color: #52c41a;
+  border-color: #52c41a;
+  background: #f6ffed;
+}
+
+.link-select {
+  display: flex;
+  align-items: center;
+  height: 72rpx;
+  padding: 0 20rpx;
+  border: 1rpx solid #e5e5e5;
+  border-radius: 8rpx;
+  background: #fff;
+}
+
+.link-text {
+  flex: 1;
+  font-size: 26rpx;
+  color: #333;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.link-text.placeholder {
+  color: #bbb;
+}
+
+.link-arrow {
+  color: #ccc;
+  font-size: 28rpx;
+  margin-left: 12rpx;
+}
+
+.bottom-space {
+  height: 40rpx;
+}
+
+.bottom-bar {
+  position: fixed;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  padding: 20rpx 24rpx;
+  padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
+  background: #fff;
+  box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.06);
+  z-index: 100;
+}
+
+.bar-btn {
+  height: 80rpx;
+  line-height: 80rpx;
+  margin: 0;
+  padding: 0;
+  font-size: 28rpx;
+  border-radius: 8rpx;
+  text-align: center;
+}
+
+.bar-btn::after {
+  border: none;
+}
+
+.bar-btn-preview {
+  width: 200rpx;
+  background: #fff;
+  color: #52c41a;
+  border: 2rpx solid #ddd;
+  margin-right: 16rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  line-height: 1;
+}
+
+.preview-eye {
+  margin-right: 6rpx;
+  font-size: 28rpx;
+}
+
+.bar-btn-save {
+  flex: 1;
+  background: #52c41a;
+  color: #fff;
+}
+
+.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: 24rpx 24rpx 0 0;
+  display: flex;
+  flex-direction: column;
+}
+
+.category-picker-panel {
+  max-height: 82vh;
+}
+
+.picker-head {
+  position: relative;
+  height: 88rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-bottom: 1rpx solid #f0f0f0;
+  flex-shrink: 0;
+}
+
+.picker-title {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #333;
+}
+
+.picker-count {
+  position: absolute;
+  left: 24rpx;
+  font-size: 24rpx;
+  color: #52c41a;
+}
+
+.picker-close {
+  position: absolute;
+  right: 24rpx;
+  font-size: 36rpx;
+  color: #999;
+  padding: 8rpx;
+}
+
+.picker-search-row {
+  display: flex;
+  align-items: center;
+  padding: 16rpx 24rpx;
+  border-bottom: 1rpx solid #f5f5f5;
+  flex-shrink: 0;
+}
+
+.picker-search-box {
+  flex: 1;
+  min-width: 0;
+  height: 68rpx;
+  display: flex;
+  align-items: center;
+  padding: 0 20rpx;
+  border: 2rpx solid #e8e8e8;
+  border-radius: 34rpx;
+  background: #fafafa;
+  box-sizing: border-box;
+}
+
+.picker-search-icon {
+  font-size: 28rpx;
+  color: #999;
+  margin-right: 12rpx;
+  flex-shrink: 0;
+}
+
+.picker-search-input {
+  flex: 1;
+  min-width: 0;
+  height: 64rpx;
+  font-size: 26rpx;
+  color: #333;
+}
+
+.picker-search-clear {
+  margin-left: 16rpx;
+  font-size: 24rpx;
+  color: #52c41a;
+  flex-shrink: 0;
+}
+
+.picker-selected-row {
+  width: 100%;
+  white-space: nowrap;
+  border-bottom: 1rpx solid #f5f5f5;
+  flex-shrink: 0;
+}
+
+.picker-selected-wrap {
+  display: flex;
+  flex-direction: row;
+  padding: 16rpx 24rpx;
+}
+
+.picker-selected-chip {
+  display: inline-flex;
+  align-items: center;
+  max-width: 280rpx;
+  height: 52rpx;
+  padding: 0 12rpx 0 16rpx;
+  margin-right: 12rpx;
+  background: #f6ffed;
+  border: 1rpx solid #b7eb8f;
+  border-radius: 26rpx;
+  flex-shrink: 0;
+}
+
+.chip-name {
+  max-width: 220rpx;
+  font-size: 24rpx;
+  color: #389e0d;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.chip-remove {
+  width: 32rpx;
+  height: 32rpx;
+  line-height: 30rpx;
+  text-align: center;
+  margin-left: 8rpx;
+  font-size: 28rpx;
+  color: #999;
+}
+
+.picker-body {
+  flex: 1;
+  min-height: 0;
+  max-height: 46vh;
+  padding: 8rpx 24rpx 16rpx;
+  box-sizing: border-box;
+}
+
+.picker-empty {
+  padding: 80rpx 0;
+  text-align: center;
+  font-size: 26rpx;
+  color: #999;
+}
+
+.picker-item {
+  display: flex;
+  align-items: center;
+  padding: 22rpx 0;
+  border-bottom: 1rpx solid #f5f5f5;
+}
+
+.picker-item:last-child {
+  border-bottom: none;
+}
+
+.check-box {
+  width: 36rpx;
+  height: 36rpx;
+  border: 2rpx solid #ccc;
+  border-radius: 6rpx;
+  margin-right: 16rpx;
+  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: 24rpx;
+}
+
+.picker-item-name {
+  flex: 1;
+  min-width: 0;
+  font-size: 28rpx;
+  color: #333;
+  line-height: 1.4;
+}
+
+.picker-footer {
+  display: flex;
+  padding: 20rpx 24rpx;
+  padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
+  flex-shrink: 0;
+  border-top: 1rpx solid #f0f0f0;
+}
+
+.picker-btn {
+  flex: 1;
+  height: 80rpx;
+  line-height: 80rpx;
+  margin: 0;
+  font-size: 28rpx;
+  border-radius: 8rpx;
+}
+
+.picker-btn::after {
+  border: none;
+}
+
+.picker-btn.cancel {
+  margin-right: 20rpx;
+  background: #fff;
+  color: #333;
+  border: 2rpx solid #ddd;
+}
+
+.picker-btn.confirm {
+  background: #52c41a;
+  color: #fff;
+}
+
+.preview-panel {
+  position: relative;
+  z-index: 1;
+  width: 90%;
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  box-sizing: border-box;
+}
+
+.preview-swiper {
+  height: 320rpx;
+}
+
+.preview-img {
+  width: 100%;
+  height: 320rpx;
+}
+
+.preview-close {
+  margin-top: 20rpx;
+  height: 72rpx;
+  line-height: 72rpx;
+  background: #52c41a;
+  color: #fff;
+  font-size: 28rpx;
+  border-radius: 8rpx;
+}
+
+.preview-close::after {
+  border: none;
+}
+</style>

+ 493 - 0
hdApp/src/admin/goods/category-edit.vue

@@ -0,0 +1,493 @@
+<!--
+  商品分类新增/编辑页
+  用途:花束分类、花材分类的新增与编辑,替代原列表页弹窗表单
+  入口:categoryV2 点击「新增分类」或「编辑」
+-->
+<template>
+  <view class="page-container">
+    <view class="form-container">
+      <view class="form-group">
+        <view class="form-label">
+          <text class="label-text">分类名称</text>
+          <text class="required-mark">*</text>
+        </view>
+        <view class="form-input">
+          <input
+            type="text"
+            v-model="form.categoryName"
+            class="input-field"
+            placeholder="请输入分类名称"
+            maxlength="20"
+            @input="onCategoryNameInput"
+          />
+        </view>
+        <view class="form-hint">最多20个字</view>
+      </view>
+
+      <view class="form-group">
+        <view class="form-label">
+          <text class="label-text">分类图标</text>
+          <text class="required-mark">*</text>
+        </view>
+        <view class="icon-tabs">
+          <view class="icon-tab active">默认图标库</view>
+        </view>
+        <view class="icon-grid">
+          <view
+            class="icon-item"
+            v-for="(icon, idx) in defaultIcons"
+            :key="idx"
+            :class="{'selected': form.img === icon.path}"
+            @click="selectIcon(icon.path)"
+          >
+            <image :src="icon.url" mode="aspectFit" class="icon-img"></image>
+            <view class="check-mark" v-if="form.img === icon.path">✓</view>
+          </view>
+        </view>
+      </view>
+
+      <view class="form-group">
+        <view class="form-label">
+          <text class="label-text">排序</text>
+          <text class="required-mark">*</text>
+        </view>
+        <view class="form-input">
+          <input
+            type="number"
+            v-model="form.inTurn"
+            class="input-field"
+            placeholder="请输入排序值"
+          />
+        </view>
+        <view class="form-hint">数值越大排序越靠前</view>
+      </view>
+
+      <view class="form-group">
+        <view class="form-label">
+          <text class="label-text">关联图文 <text class="optional-text">(选填)</text></text>
+        </view>
+        <view class="form-select" @click="handleSelectPicTextForm">
+          <text class="select-text" :class="{'placeholder': !form.picTextId}">{{ form.picTextId ? '已选择图文' : '选择图文 (单选)' }}</text>
+          <text class="select-arrow">></text>
+        </view>
+      </view>
+
+      <view class="form-group">
+        <view class="form-label">
+          <text class="label-text">状态</text>
+          <text class="required-mark">*</text>
+        </view>
+        <view class="radio-group">
+          <view class="radio-item" @click="form.status = 1">
+            <view class="radio-circle" :class="{'checked': form.status == 1}"></view>
+            <text>启用</text>
+          </view>
+          <view class="radio-item" @click="form.status = 0">
+            <view class="radio-circle" :class="{'checked': form.status == 0}"></view>
+            <text>禁用</text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <view class="bottom-toolbar">
+      <button class="toolbar-btn outline-btn" @click="cancel">取消</button>
+      <button class="toolbar-btn primary-btn" @click="save">保存</button>
+    </view>
+  </view>
+</template>
+
+<script>
+import { categoryAddB, categoryUpdateB, categoryDetB } from '@/api/goods';
+
+export default {
+  name: 'category-edit',
+  data() {
+    return {
+      form: {
+        categoryId: 0,
+        categoryName: '',
+        img: '',
+        inTurn: '',
+        status: 1,
+        flower: 0,
+        picTextId: 0
+      },
+      constant: this.$constant,
+      // 默认图标相对路径,提交时只存路径不存完整 URL
+      defaultIconPaths: [
+        'retail/icon/meiguihua.webp',
+        'retail/icon/huashu.webp',
+        'retail/icon/lvzhi.webp'
+      ]
+    };
+  },
+  computed: {
+    defaultIcons() {
+      if (!this.constant) return [];
+      return this.defaultIconPaths.map((path) => ({
+        path,
+        url: this.getIconUrl(path)
+      }));
+    }
+  },
+  onLoad(options) {
+    const categoryId = options.categoryId ? parseInt(options.categoryId) : 0;
+    if (categoryId > 0) {
+      this.loadDetail(categoryId);
+      uni.setNavigationBarTitle({ title: '编辑分类' });
+    } else {
+      this.form.flower = options.flower !== undefined ? parseInt(options.flower) : 0;
+      this.form.inTurn = 10;
+      uni.setNavigationBarTitle({ title: '新增分类' });
+    }
+    uni.$on('updateCategoryIdEvent', this.updateCategoryId);
+  },
+  onUnload() {
+    uni.$off('updateCategoryIdEvent', this.updateCategoryId);
+  },
+  methods: {
+    /** 编辑模式:拉取分类详情回填表单 */
+    loadDetail(categoryId) {
+      categoryDetB({ categoryId }).then((res) => {
+        if (res.code !== 1 || !res.data) {
+          this.$msg(res.msg || '获取分类详情失败');
+          return;
+        }
+        const item = res.data;
+        this.form = {
+          categoryId: item.id,
+          categoryName: item.categoryName,
+          img: this.normalizeImgPath(item.img || item.shortImg || ''),
+          inTurn: item.inTurn,
+          status: item.status,
+          flower: item.flower,
+          picTextId: item.picTextId || 0
+        };
+      });
+    },
+    /** 分类名称输入:限制最多20个字 */
+    onCategoryNameInput(e) {
+      const value = (e.detail && e.detail.value) || '';
+      if (value.length > 20) {
+        this.form.categoryName = value.slice(0, 20);
+      }
+    },
+    /** 选中图标,表单内只存相对路径 */
+    selectIcon(path) {
+      this.form.img = path;
+    },
+    /** 展示用完整图标地址 */
+    getIconUrl(path) {
+      if (!path || !this.constant) return '';
+      if (/^https?:\/\//.test(path)) {
+        return path.split('?')[0];
+      }
+      return `${this.constant.imgUrl}/${path.replace(/^\//, '')}`;
+    },
+    /** 转为相对路径,提交后端只保存 retail/icon/xxx.webp */
+    normalizeImgPath(img) {
+      if (!img) return '';
+      let path = String(img).split('?')[0];
+      if (/^https?:\/\//.test(path) && this.constant && this.constant.imgUrl) {
+        const prefix = `${this.constant.imgUrl}/`;
+        if (path.indexOf(prefix) === 0) {
+          path = path.slice(prefix.length);
+        } else {
+          const match = path.match(/retail\/icon\/[^/?#]+/);
+          if (match) {
+            path = match[0];
+          }
+        }
+      }
+      return path.replace(/^\//, '');
+    },
+    cancel() {
+      uni.navigateBack();
+    },
+    /** 校验并提交新增/编辑 */
+    save() {
+      const categoryName = (this.form.categoryName || '').trim();
+      if (!categoryName) {
+        this.$msg('请输入分类名称');
+        return false;
+      }
+      if (categoryName.length > 20) {
+        this.$msg('分类名称最多20个字');
+        return false;
+      }
+      if (!this.form.img) {
+        this.$msg('请选择分类图标');
+        return false;
+      }
+      if (this.form.inTurn === '') {
+        this.$msg('请输入排序值');
+        return false;
+      }
+
+      const submitData = JSON.parse(JSON.stringify(this.form));
+      submitData.categoryName = categoryName;
+      submitData.img = this.normalizeImgPath(submitData.img);
+      const host = submitData.categoryId > 0 ? categoryUpdateB : categoryAddB;
+
+      host({ ...submitData }).then((res) => {
+        if (res.code === 1) {
+          this.$msg('操作成功!');
+          uni.$emit('refreshCategoryList');
+          setTimeout(() => {
+            uni.navigateBack();
+          }, 1000);
+        } else {
+          this.$msg(res.msg || '操作失败');
+        }
+      });
+    },
+    /** 跳转图文选择页,新增分类需先保存 */
+    handleSelectPicTextForm() {
+      if (this.form.categoryId == 0) {
+        this.$msg('请先保存分类后再设置图文');
+        return;
+      }
+      uni.navigateTo({
+        url: `/admin/picText/picTextList?cId=${this.form.categoryId}&picTextId=${this.form.picTextId}`
+      });
+    },
+    /** 从图文页返回后同步 picTextId */
+    updateCategoryId(data) {
+      if (this.form.categoryId == data.categoryId) {
+        this.form.picTextId = data.picTextId;
+      }
+    }
+  }
+};
+</script>
+
+<style lang="scss" scoped>
+.page-container {
+  min-height: 100vh;
+  background: #f5f5f5;
+  padding-bottom: calc(120rpx + env(safe-area-inset-bottom));
+}
+
+.form-container {
+  background: #fff;
+  padding: 30rpx;
+  margin-top: 20rpx;
+}
+
+.form-group {
+  margin-bottom: 40rpx;
+
+  .form-label {
+    font-size: 28rpx;
+    color: #333;
+    margin-bottom: 16rpx;
+    font-weight: bold;
+
+    .required-mark {
+      color: #FF3B30;
+      margin-left: 4rpx;
+    }
+
+    .optional-text {
+      color: #999;
+      font-weight: normal;
+    }
+  }
+
+  .input-field {
+    width: 100%;
+    height: 80rpx;
+    border: 1rpx solid #eee;
+    border-radius: 8rpx;
+    padding: 0 24rpx;
+    font-size: 28rpx;
+    box-sizing: border-box;
+    background: #fafafa;
+  }
+
+  .form-hint {
+    font-size: 24rpx;
+    color: #999;
+    margin-top: 12rpx;
+  }
+
+  .icon-tabs {
+    display: flex;
+    border-bottom: 1rpx solid #eee;
+    margin-bottom: 24rpx;
+
+    .icon-tab {
+      padding: 12rpx 30rpx;
+      font-size: 28rpx;
+      color: #999;
+      position: relative;
+
+      &.active {
+        color: #52c41a;
+        font-weight: bold;
+
+        &::after {
+          content: '';
+          position: absolute;
+          bottom: -1rpx;
+          left: 30rpx;
+          right: 30rpx;
+          height: 4rpx;
+          background: #52c41a;
+        }
+      }
+    }
+  }
+
+  .icon-grid {
+    display: flex;
+    flex-wrap: wrap;
+
+    .icon-item {
+      width: 120rpx;
+      height: 120rpx;
+      border: 2rpx solid transparent;
+      border-radius: 12rpx;
+      margin-right: 24rpx;
+      margin-bottom: 24rpx;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      position: relative;
+      background: #fafafa;
+
+      &.selected {
+        border-color: #52c41a;
+        background: #f0f9eb;
+      }
+
+      .icon-img {
+        width: 80rpx;
+        height: 80rpx;
+      }
+
+      .check-mark {
+        position: absolute;
+        bottom: 0;
+        right: 0;
+        background: #52c41a;
+        color: #fff;
+        width: 36rpx;
+        height: 36rpx;
+        border-radius: 12rpx 0 12rpx 0;
+        font-size: 22rpx;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+      }
+    }
+  }
+
+  .form-select {
+    height: 80rpx;
+    border: 1rpx solid #eee;
+    border-radius: 8rpx;
+    padding: 0 24rpx;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    background: #fafafa;
+
+    .select-text {
+      font-size: 28rpx;
+      color: #333;
+
+      &.placeholder {
+        color: #999;
+      }
+    }
+
+    .select-arrow {
+      color: #999;
+      font-size: 26rpx;
+      font-family: monospace;
+    }
+  }
+
+  .radio-group {
+    display: flex;
+    align-items: center;
+
+    .radio-item {
+      display: flex;
+      align-items: center;
+      margin-right: 60rpx;
+      font-size: 28rpx;
+      color: #333;
+
+      .radio-circle {
+        width: 40rpx;
+        height: 40rpx;
+        border: 2rpx solid #ccc;
+        border-radius: 50%;
+        margin-right: 16rpx;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        background: #fff;
+
+        &.checked {
+          border-color: #52c41a;
+
+          &::after {
+            content: '';
+            width: 20rpx;
+            height: 20rpx;
+            background: #52c41a;
+            border-radius: 50%;
+          }
+        }
+      }
+    }
+  }
+}
+
+.bottom-toolbar {
+  position: fixed;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  background: #fff;
+  padding: 20rpx 30rpx;
+  padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
+  box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.05);
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  z-index: 10;
+
+  .toolbar-btn {
+    flex: 1;
+    height: 88rpx;
+    border-radius: 44rpx;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    font-size: 32rpx;
+    font-weight: bold;
+    margin: 0 15rpx;
+
+    &.outline-btn {
+      background: #fff;
+      color: #666;
+      border: 2rpx solid #e5e5e5;
+    }
+
+    &.primary-btn {
+      background: #52c41a;
+      color: #fff;
+      border: none;
+    }
+
+    &:active {
+      opacity: 0.8;
+    }
+  }
+}
+</style>

+ 807 - 0
hdApp/src/admin/goods/categoryV2.vue

@@ -0,0 +1,807 @@
+<!--
+  商品分类列表页(新版)
+  用途:花束/花材分类列表展示,新增与编辑跳转 category-edit 独立页面
+-->
+<template>
+  <view class="page-container">
+    <!-- Tabs -->
+    <view class="tabs">
+      <view class="tab-item" :class="{active: currentTab === 0}" @click="switchTab(0)">
+        <text>花束分类</text>
+        <view class="tab-line" v-if="currentTab === 0"></view>
+      </view>
+      <view class="tab-item" :class="{active: currentTab === 1}" @click="switchTab(1)">
+        <text>花材分类</text>
+        <view class="tab-line" v-if="currentTab === 1"></view>
+      </view>
+      <view class="tab-item" :class="{active: currentTab === 2}" @click="switchTab(2)">
+        <text>分类广告</text>
+        <view class="tab-line" v-if="currentTab === 2"></view>
+      </view>
+    </view>
+
+    <!-- 列表头部 -->
+    <view class="list-header" v-if="currentTab !== 2">
+      <view class="col-name">分类名称</view>
+      <view class="col-num">商品数量</view>
+      <view class="col-pic">图文</view>
+      <view class="col-action">操作</view>
+    </view>
+
+    <!-- 分类列表:不用 scroll-view,避免小程序内点击事件被拦截 -->
+    <view class="list-container" v-if="currentTab !== 2">
+      <block v-if="!$util.isEmpty(filteredList)">
+        <view
+          class="list-item"
+          :class="{'is-disabled': item.status != 1}"
+          v-for="(item, index) in filteredList"
+          :key="item.id || index"
+        >
+          <view class="col-name item-name">
+            <image :src="item.img" mode="aspectFit" class="cat-img"></image>
+            <text class="cat-text">{{ item.categoryName }}</text>
+          </view>
+
+          <view class="col-num item-num">
+            <text>{{ item.goodsNum || 0 }}</text>
+          </view>
+
+          <view
+            class="col-pic item-pic"
+            :data-id="item.id"
+            :data-pic-text-id="item.picTextId"
+            @click.stop="selectPicText"
+          >
+            <view class="pic-tag" :class="item.picTextId > 0 ? 'is-set' : 'no-set'">
+              <view class="pic-icon-box">
+                <view class="pic-icon-inner"></view>
+              </view>
+              <text>{{ item.picTextId > 0 ? '已设置' : '未设置' }}</text>
+            </view>
+          </view>
+
+          <view class="col-action item-actions">
+            <button class="action-btn edit-btn" plain hover-class="none" :data-id="item.id" @click.stop="editCategory">编辑</button>
+            <text class="action-divider">|</text>
+            <button class="action-btn delete-btn" plain hover-class="none" v-if="item.default != 1" :data-id="item.id" @click.stop="delCategory">删除</button>
+          </view>
+        </view>
+      </block>
+      <block v-else>
+        <app-wrapper-empty title="暂无分类数据" :is-empty="$util.isEmpty(filteredList)" />
+      </block>
+    </view>
+
+    <!-- 底部操作栏 -->
+    <view class="bottom-toolbar" v-if="currentTab !== 2">
+      <button
+        class="toolbar-btn outline-btn"
+        hover-class="none"
+        v-if="currentTab === 1"
+        @click="shopCategorySet"
+      >
+						<sprite-icon name="write" :size="28" custom-class="member-crown-icon"/>
+        <text class="icon">📝</text>商城分类设置
+      </button>
+      <button class="toolbar-btn primary-btn" hover-class="none" :class="{'full-width': currentTab !== 1}" @click="addCategoryFn">
+        <text class="icon">+</text>新增分类
+      </button>
+    </view>
+
+    <!-- 商城分类设置弹窗(自定义弹层,对齐 UI 设计图) -->
+    <view v-if="settingModal" class="setting-overlay" @touchmove.stop.prevent="preventTouchMove">
+      <view class="setting-mask"></view>
+      <view class="setting-dialog">
+        <view class="setting-modal-head">
+          <text class="setting-modal-title">商城分类设置</text>
+          <text class="setting-modal-close" @click.stop="closeSettingModal">×</text>
+        </view>
+
+        <view class="setting-form-body">
+          <view class="setting-form-row">
+            <text class="setting-form-label">商城分类名称:</text>
+            <view class="setting-form-control">
+              <input
+                class="setting-input"
+                type="text"
+                v-model="settingForm.itemCateName"
+                placeholder="请输入分类名称"
+                maxlength="20"
+              />
+            </view>
+          </view>
+
+          <view class="setting-form-row">
+            <text class="setting-form-label">分类是否置顶:</text>
+            <view class="setting-form-control setting-switch-wrap">
+              <switch
+                class="setting-switch"
+                :checked="settingForm.isItemCateTop == 1"
+                color="#52c41a"
+                @change="onSettingTopChange"
+              />
+            </view>
+          </view>
+
+          <view class="setting-form-row setting-form-row-last">
+            <text class="setting-form-label">商城是否展示:</text>
+            <view class="setting-form-control setting-switch-wrap">
+              <switch
+                class="setting-switch"
+                :checked="settingForm.isItemCateShow == 1"
+                color="#52c41a"
+                @change="onSettingShowChange"
+              />
+            </view>
+          </view>
+        </view>
+
+        <view class="setting-modal-footer">
+          <button class="setting-btn setting-btn-cancel" hover-class="none" @click.stop="closeSettingModal">取消</button>
+          <button class="setting-btn setting-btn-save" hover-class="none" @click.stop="saveCategorySetting">保存</button>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import AppWrapperEmpty from '@/components/app-wrapper-empty';
+import { list } from '@/mixins';
+import { categoryListInfo, categoryDelB } from '@/api/goods';
+import { categorySettingDetail, categorySettingSave } from '@/api/category-setting';
+
+export default {
+  name: 'categoryV2',
+  components: {
+    AppWrapperEmpty
+  },
+  mixins: [list],
+  data() {
+    return {
+      currentTab: 0,
+      constant: this.$constant,
+      settingModal: false,
+      settingSaving: false,
+      settingForm: {
+        id: 0,
+        itemCateName: '',
+        isItemCateTop: 0,
+        isItemCateShow: 0
+      }
+    }
+  },
+  computed: {
+    filteredList() {
+      if (!this.list.data) return [];
+      const list = this.list.data.filter(item => item.flower == this.currentTab);
+      // 启用分类在前,禁用分类在后,同组内保持接口原顺序
+      return list.slice().sort((a, b) => {
+        const aDisabled = a.status == 1 ? 0 : 1;
+        const bDisabled = b.status == 1 ? 0 : 1;
+        return aDisabled - bDisabled;
+      });
+    }
+  },
+  onPullDownRefresh() {
+    this.resetList();
+    this._list().then(() => {
+      uni.stopPullDownRefresh();
+    });
+  },
+  onShow() {
+    uni.$on('updateCategoryIdEvent', this.updateCategoryId);
+    uni.$on('refreshCategoryList', this.init);
+  },
+  onUnload() {
+    uni.$off('updateCategoryIdEvent', this.updateCategoryId);
+    uni.$off('refreshCategoryList', this.init);
+  },
+  onLoad(options) {
+    if (options && options.tab !== undefined) {
+      this.currentTab = parseInt(options.tab) || 0;
+    }
+    this.init();
+  },
+  methods: {
+    switchTab(index) {
+      if (index === 2) {
+        uni.navigateTo({
+          url: '/admin/goods/category-ad'
+        });
+        return;
+      }
+      this.currentTab = index;
+    },
+    async init() {
+      this._list();
+    },
+    _list() {
+      return categoryListInfo().then((res) => {
+        let data = { code: res.code, msg: res.msg, data: { list: res.data } };
+        this.completes(data);
+      });
+    },
+    loadMore() {
+      if (!this.list.finished) {
+        this._list();
+      }
+    },
+    /** 从 dataset 读取分类 id,兼容小程序 button 点击传参 */
+    getCategoryIdFromEvent(e) {
+      const dataset = (e && e.currentTarget && e.currentTarget.dataset) || {};
+      return dataset.id;
+    },
+    /** 跳转编辑页 */
+    editCategory(e) {
+      const categoryId = this.getCategoryIdFromEvent(e);
+      if (!categoryId) {
+        this.$msg('分类信息异常');
+        return;
+      }
+      uni.navigateTo({
+        url: `/admin/goods/category-edit?categoryId=${categoryId}`,
+        fail: () => {
+          this.$msg('跳转失败,请重试');
+        }
+      });
+    },
+    addCategoryFn() {
+      uni.navigateTo({
+        url: `/admin/goods/category-edit?flower=${this.currentTab}`
+      });
+    },
+    delCategory(e) {
+      const categoryId = this.getCategoryIdFromEvent(e);
+      if (!categoryId) {
+        this.$msg('分类信息异常');
+        return;
+      }
+      const that = this;
+      uni.showModal({
+        title: '提示',
+        content: '确认删除?',
+        success: function (res) {
+          if (res.confirm) {
+            categoryDelB({ categoryId }).then((res) => {
+              if (res.code == 1) {
+                that.$msg('已删除');
+                that.resetList();
+                that._list();
+              }
+            });
+          }
+        }
+      });
+    },
+    selectPicText(e) {
+      const dataset = (e && e.currentTarget && e.currentTarget.dataset) || {};
+      const id = dataset.id;
+      const picTextId = dataset.picTextId || 0;
+      if (!id) {
+        return;
+      }
+      uni.navigateTo({
+        url: `/admin/picText/picTextList?cId=${id}&picTextId=${picTextId}`
+      });
+    },
+    shopCategorySet() {
+      categorySettingDetail().then((res) => {
+        if (res.code != 1) {
+          return;
+        }
+        const data = res.data || {};
+        this.settingForm = {
+          id: data.id || 0,
+          itemCateName: data.itemCateName || '',
+          isItemCateTop: data.isItemCateTop == 1 ? 1 : 0,
+          isItemCateShow: data.isItemCateShow == 1 ? 1 : 0
+        };
+        this.settingModal = true;
+      });
+    },
+    closeSettingModal() {
+      this.settingModal = false;
+    },
+    /** 弹窗打开时阻止底层滚动,避免 touchmove 修饰符在小程序产生异常 */
+    preventTouchMove() {},
+    onSettingTopChange(e) {
+      this.settingForm.isItemCateTop = e.detail.value ? 1 : 0;
+    },
+    onSettingShowChange(e) {
+      this.settingForm.isItemCateShow = e.detail.value ? 1 : 0;
+    },
+    saveCategorySetting() {
+      const name = (this.settingForm.itemCateName || '').trim();
+      if (!name) {
+        this.$msg('请输入商城分类名称');
+        return;
+      }
+      if (this.settingSaving) {
+        return;
+      }
+      this.settingSaving = true;
+      categorySettingSave({
+        id: this.settingForm.id,
+        itemCateName: name,
+        isItemCateTop: this.settingForm.isItemCateTop,
+        isItemCateShow: this.settingForm.isItemCateShow
+      }).then((res) => {
+        if (res.code == 1) {
+          this.$msg('保存成功');
+          this.closeSettingModal();
+        }
+      }).finally(() => {
+        this.settingSaving = false;
+      });
+    },
+    updateCategoryId(data) {
+      this.list.data.forEach((item) => {
+        if (item.id == data.categoryId) {
+          item.picTextId = data.picTextId;
+        }
+      });
+    }
+  }
+};
+</script>
+
+<style lang="scss" scoped>
+.page-container {
+  min-height: 100vh;
+  background: #f5f5f5;
+  display: flex;
+  flex-direction: column;
+}
+
+/* Tabs */
+.tabs {
+  display: flex;
+  background: #fff;
+  padding: 0 20rpx;
+  border-bottom: 1rpx solid #eee;
+
+  .tab-item {
+    flex: 1;
+    text-align: center;
+    font-size: 28rpx;
+    color: #666;
+    padding: 24rpx 0;
+    position: relative;
+
+    &.active {
+      color: #52c41a;
+      font-weight: bold;
+    }
+
+    .tab-line {
+      position: absolute;
+      bottom: 0;
+      left: 50%;
+      transform: translateX(-50%);
+      width: 80rpx;
+      height: 4rpx;
+      background: #52c41a;
+      border-radius: 4rpx;
+    }
+  }
+}
+
+/* List Header */
+.list-header {
+  display: flex;
+  align-items: center;
+  padding: 24rpx 30rpx;
+  background: #fdfdfd;
+  margin-top: 20rpx;
+  border-bottom: 1rpx solid #f0f0f0;
+  border-radius: 12rpx 12rpx 0 0;
+  margin: 20rpx 20rpx 0 20rpx;
+
+  view {
+    font-size: 24rpx;
+    color: #333;
+    font-weight: 500;
+    text-align: center;
+  }
+}
+
+.col-name { flex: 3; min-width: 0; text-align: left !important; }
+.col-num { flex: 1.5; flex-shrink: 0; }
+.col-pic { flex: 2; flex-shrink: 0; }
+.col-action { flex: 2; flex-shrink: 0; }
+
+/* List */
+.list-container {
+  flex: 1;
+  background: #fff;
+  margin: 0 20rpx 140rpx 20rpx;
+  border-radius: 0 0 12rpx 12rpx;
+  overflow-y: auto;
+
+  .list-item {
+    display: flex;
+    align-items: flex-start;
+    padding: 24rpx 30rpx;
+    border-bottom: 1rpx solid #f0f0f0;
+
+    &:last-child {
+      border-bottom: none;
+    }
+
+    /* 禁用分类:参考设计图开业乔迁,整行置灰 */
+    &.is-disabled {
+      background: #f7f7f7;
+
+      .cat-img {
+        opacity: 0.4;
+      }
+
+      .cat-text {
+        color: #c8c8c8;
+        font-weight: 400;
+      }
+
+      .item-num {
+        color: #d0d0d0;
+      }
+
+      .pic-tag {
+        &.no-set,
+        &.is-set {
+          color: #d0d0d0;
+
+          .pic-icon-box {
+            border-color: #ddd;
+
+            .pic-icon-inner {
+              background: #ddd;
+            }
+          }
+        }
+      }
+    }
+
+    .item-name {
+      display: flex;
+      align-items: flex-start;
+      min-width: 0;
+
+      .cat-img {
+        width: 60rpx;
+        height: 60rpx;
+        margin-right: 16rpx;
+        margin-top: 4rpx;
+        border-radius: 8rpx;
+        flex-shrink: 0;
+      }
+      .cat-text {
+        flex: 1;
+        min-width: 0;
+        font-size: 28rpx;
+        color: #333;
+        font-weight: 500;
+        line-height: 1.5;
+        white-space: normal;
+        word-wrap: break-word;
+        word-break: break-all;
+      }
+    }
+
+    .item-num,
+    .item-pic,
+    .item-actions {
+      align-self: center;
+    }
+
+    .item-num {
+      font-size: 28rpx;
+      color: #3b5bdf;
+      text-align: center;
+    }
+
+    .item-pic {
+      display: flex;
+      justify-content: center;
+
+      .pic-tag {
+        display: flex;
+        align-items: center;
+        font-size: 22rpx;
+        padding: 4rpx 10rpx;
+        border-radius: 6rpx;
+
+        &.no-set {
+          color: #999;
+          .pic-icon-box {
+            border: 2rpx solid #ccc;
+            .pic-icon-inner { background: #ccc; }
+          }
+        }
+
+        &.is-set {
+          color: #52c41a;
+          .pic-icon-box {
+            border: 2rpx solid #52c41a;
+            .pic-icon-inner { background: #52c41a; }
+          }
+        }
+
+        .pic-icon-box {
+          width: 24rpx;
+          height: 20rpx;
+          border-radius: 4rpx;
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          margin-right: 8rpx;
+
+          .pic-icon-inner {
+            width: 12rpx;
+            height: 10rpx;
+            clip-path: polygon(0 100%, 40% 40%, 60% 70%, 80% 20%, 100% 100%);
+          }
+        }
+      }
+    }
+
+    .item-actions {
+      display: flex;
+      justify-content: center;
+      align-items: center;
+      font-size: 26rpx;
+      white-space: nowrap;
+
+      .action-btn {
+        display: inline-flex;
+        align-items: center;
+        justify-content: center;
+        width: auto;
+        min-width: 0;
+        height: auto;
+        min-height: 0;
+        padding: 0;
+        margin: 0;
+        background: transparent;
+        border: none;
+        line-height: 1.4;
+        font-size: 26rpx;
+        font-weight: normal;
+      }
+
+      .action-btn::after {
+        border: none;
+      }
+
+      .edit-btn {
+        color: #52c41a;
+      }
+      .action-divider {
+        color: #eee;
+        margin: 0 16rpx;
+      }
+      .delete-btn {
+        color: #FF3B30;
+      }
+    }
+  }
+}
+
+/* Bottom Toolbar */
+.bottom-toolbar {
+  position: fixed;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  background: #fff;
+  padding: 20rpx 30rpx;
+  padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
+  box-shadow: 0 -2rpx 10rpx rgba(0,0,0,0.05);
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  z-index: 10;
+
+  .toolbar-btn {
+    flex: 1;
+    height: 80rpx;
+    border-radius: 8rpx;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    font-size: 28rpx;
+    font-weight: bold;
+    margin: 0 10rpx;
+    padding: 0;
+    line-height: 80rpx;
+
+    &::after {
+      border: none;
+    }
+
+    .icon {
+      margin-right: 8rpx;
+      font-size: 32rpx;
+      font-weight: normal;
+    }
+
+    &.full-width {
+      margin: 0;
+    }
+
+    &.outline-btn {
+      background: #fff;
+      color: #52c41a;
+      border: 2rpx solid #52c41a;
+    }
+
+    &.primary-btn {
+      background: #52c41a;
+      color: #fff;
+      border: none;
+    }
+
+    &:active {
+      opacity: 0.8;
+    }
+  }
+}
+
+/* 商城分类设置弹窗 */
+.setting-overlay {
+  position: fixed;
+  left: 0;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 1000;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: transparent;
+}
+
+.setting-mask {
+  position: absolute;
+  left: 0;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 0;
+  background: rgba(0, 0, 0, 0.55);
+}
+
+.setting-dialog {
+  position: relative;
+  z-index: 2;
+  width: 620rpx;
+  max-width: 86%;
+  background: #fff;
+  border-radius: 16rpx;
+  overflow: hidden;
+  box-sizing: border-box;
+}
+
+.setting-modal-head {
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 96rpx;
+  padding: 0 32rpx;
+  border-bottom: 1rpx solid #f0f0f0;
+}
+
+.setting-modal-title {
+  font-size: 32rpx;
+  color: #333;
+  font-weight: 600;
+  line-height: 1.4;
+}
+
+.setting-modal-close {
+  position: absolute;
+  right: 24rpx;
+  top: 50%;
+  transform: translateY(-50%);
+  width: 44rpx;
+  height: 44rpx;
+  line-height: 44rpx;
+  text-align: center;
+  font-size: 36rpx;
+  color: #bbb;
+}
+
+.setting-form-body {
+  padding: 36rpx 32rpx 8rpx;
+}
+
+.setting-form-row {
+  display: flex;
+  align-items: center;
+  min-height: 72rpx;
+  margin-bottom: 24rpx;
+}
+
+.setting-form-row-last {
+  margin-bottom: 0;
+}
+
+.setting-form-label {
+  width: 196rpx;
+  flex-shrink: 0;
+  font-size: 28rpx;
+  color: #333;
+  line-height: 1.4;
+}
+
+.setting-form-control {
+  flex: 1;
+  min-width: 0;
+}
+
+.setting-input {
+  width: 100%;
+  height: 64rpx;
+  padding: 0 20rpx;
+  box-sizing: border-box;
+  border: 1rpx solid #dcdfe6;
+  border-radius: 8rpx;
+  font-size: 28rpx;
+  color: #333;
+  background: #fff;
+}
+
+.setting-switch-wrap {
+  display: flex;
+  align-items: center;
+  justify-content: flex-start;
+  min-height: 64rpx;
+}
+
+.setting-switch {
+  transform: scale(0.82);
+  transform-origin: center left;
+}
+
+.setting-modal-footer {
+  display: flex;
+  flex-direction: row;
+  padding: 28rpx 32rpx 32rpx;
+}
+
+.setting-btn {
+  flex: 1;
+  height: 80rpx;
+  line-height: 80rpx;
+  margin: 0;
+  padding: 0;
+  border-radius: 8rpx;
+  font-size: 30rpx;
+  font-weight: 500;
+  text-align: center;
+  box-sizing: border-box;
+}
+
+.setting-btn::after {
+  border: none;
+}
+
+.setting-btn-cancel {
+  margin-right: 24rpx;
+  color: #52c41a;
+  background: #fff;
+  border: 2rpx solid #52c41a;
+}
+
+.setting-btn-save {
+  color: #fff;
+  background: #52c41a;
+  border: 2rpx solid #52c41a;
+}
+
+.setting-btn-cancel:active,
+.setting-btn-save:active {
+  opacity: 0.85;
+}
+</style>

+ 1 - 1
hdApp/src/admin/goods/img-store.vue

@@ -78,7 +78,7 @@
     <div class="app-footer">
       <button
         class="admin-button-com middle default"
-        @click="pageTo('/admin/goods/category')"
+        @click="pageTo('/admin/goods/categoryV2')"
       >查看分类</button>
       <button class="admin-button-com middle blue" @click="$refs.appUploader.chooseImage()">新增</button>
     </div>

+ 10 - 4
hdApp/src/admin/home/homeMenus.js

@@ -12,7 +12,7 @@ export const APPLY_MENU_GROUPS = [
       { name: '花束', icon: homeIcon('bouquet'), url: '/admin/goods/list', pf: 1 },
       { name: '商品管理', icon: homeIcon('bouquet'), url: '/admin/goods/manage', pf: 1 },
       { name: '新增花束', icon: homeIcon('add-bouquet'), url: '/admin/goods/add', pf: 1 },
-      { name: '分类', icon: homeIcon('category-filled'), url: '/admin/goods/category', pf: 1 },
+      { name: '分类', icon: homeIcon('category-filled'), url: '/admin/goods/categoryV2?tab=0', pf: 1 },
       { name: '排序', icon: homeIcon('sort-filled'), url: '/admin/goods/categorySort', pf: 1 },
       { name: '任务', icon: homeIcon('task-filled'), url: '/admin/work/list', pf: 1 },
       { name: '涨价设置', icon: homeIcon('price-increase'), url: '/admin/goods/price-increase', pf: 1 },
@@ -30,7 +30,7 @@ export const APPLY_MENU_GROUPS = [
       { name: '排序', icon: homeIcon('sort-filled'), url: '/admin/itemClass/sort', pf: 1 },
       { name: '改价', icon: homeIcon('change-price-filled'), url: '/admin/changePrice/list2', pf: 1 },
       { name: '报损', icon: homeIcon('breakage'), url: '/admin/breakage/list', pf: 1 },
-      { name: '分类', icon: homeIcon('category-filled'), url: '/admin/itemClass/list', pf: 1 },
+      { name: '分类', icon: homeIcon('category-filled'), url: '/admin/goods/categoryV2?tab=1', pf: 1 },
       { name: '拆散', icon: homeIcon('disassemble'), url: '/admin/part/list', pf: 1 },
       { name: '调拨出库', icon: homeIcon('allot-out'), url: '/pagesStorehouse/allot/allotEx', pf: 0 },
       { name: '调拨入库', icon: homeIcon('allot-in'), url: '/pagesStorehouse/allot/allotPut', pf: 0 },
@@ -112,10 +112,10 @@ export const HOME_MENU_ITEMS = {
   addFlower: { name: '新增花束', icon: homeIcon('add-bouquet'), url: '/admin/goods/add', pf: 1 },
   order: { name: '订单', icon: homeIcon('order-filled'), url: '/admin/home/order', type: 4, pf: 1 },
   payCode: { name: '收款码', icon: homeIcon('pay-code'), url: '/admin/cg/code', pf: 1 },
-  flowerCategory: { name: '分类', icon: homeIcon('category-filled'), url: '/admin/goods/category', pf: 1 },
+  flowerCategory: { name: '分类', icon: homeIcon('category-filled'), url: '/admin/goods/categoryV2?tab=0', pf: 1 },
   flowerSort: { name: '排序', icon: homeIcon('sort-filled'), url: '/admin/goods/categorySort', pf: 1 },
   plant: { name: '绿植花材', icon: homeIcon('plant-material'), url: '/admin/item/list2', pf: 1 },
-  plantCategory: { name: '分类', icon: homeIcon('category-filled'), url: '/admin/itemClass/list', pf: 1 },
+  plantCategory: { name: '分类', icon: homeIcon('category-filled'), url: '/admin/goods/categoryV2?tab=1', pf: 1 },
   plantSort: { name: '排序', icon: homeIcon('sort-filled'), url: '/admin/itemClass/sort', pf: 1 },
   part: { name: '拆散', icon: homeIcon('disassemble'), url: '/admin/part/list', pf: 1 },
   changePrice: { name: '改价', icon: homeIcon('change-price-filled'), url: '/admin/changePrice/list2', pf: 1 },
@@ -170,6 +170,12 @@ const isRemovedFlowerMenuItem = item => {
 
 export const getMenuByKeys = keys => {
   return keys.map(key => {
+    // 兼容旧的分类路由缓存
+    if (key === '/admin/goods/category') key = 'flowerCategory';
+    if (key === '/admin/itemClass/list') key = 'plantCategory';
+    if (key === '/admin/goods/category-new?tab=1') key = 'plantCategory';
+    if (key === '/admin/goods/category-new?tab=0' || key === '/admin/goods/category-new') key = 'flowerCategory';
+    
     let item = HOME_MENU_ITEMS[key]
     if (!item) {
       APPLY_MENU_GROUPS.some(group => {

+ 22 - 8
hdApp/src/admin/picText/picTextList.vue

@@ -11,11 +11,11 @@
               <view class="app-size-30 app-color-0" style="font-weight: bold; max-width: 420rpx; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ item.title }}</view>
             </view>
 
-            <view v-if="mode === 'selectPicText'">
+            <view v-if="mode === 'selectPicText' || mode === 'selectAd'">
               <view class="icon-wrapper select" v-if="selectedId == item.id">
                 <zui-svg-icon icon="general-selected" :width="40" :height="40" />
               </view>
-              <button style="position: absolute; top: 40upx; right: 40upx" class="admin-button-com middle" @click.stop="selectPicTextFn(item.id, cId)" v-else>
+              <button style="position: absolute; top: 40upx; right: 40upx" class="admin-button-com middle" @click.stop="selectPicTextFn(item)" v-else>
                 选择
               </button>
             </view>
@@ -56,6 +56,7 @@ export default {
     return {
       cId: null, // 分类id, 用于识别是否选择图文模式
       mode: '',
+      slotIndex: 0,
       selectedId: 0
     };
   },
@@ -75,8 +76,12 @@ export default {
     }
   },
   onLoad(option) {
-    console.log('option: ', option);
-    if (option.cId) {
+    if (option.mode === 'selectAd') {
+      this.mode = 'selectAd';
+      this.slotIndex = parseInt(option.slotIndex || 0, 10);
+      this.selectedId = option.picTextId || 0;
+      uni.setNavigationBarTitle({ title: '选择图文' });
+    } else if (option.cId) {
       this.cId = option.cId;
       this.selectedId = option.picTextId;
       this.mode = 'selectPicText';
@@ -137,13 +142,22 @@ export default {
         }
       });
     },
-    selectPicTextFn(id, cId) {
-      UpdateCategoryId({ id: id, categoryId: cId }).then((res) => {
+    selectPicTextFn(item) {
+      const id = item.id;
+      if (this.mode === 'selectAd') {
+        uni.$emit('categoryAdPicTextSelected', {
+          slotIndex: this.slotIndex,
+          picTextId: id,
+          title: item.title || ''
+        });
+        uni.navigateBack({ delta: 1 });
+        return;
+      }
+      UpdateCategoryId({ id: id, categoryId: this.cId }).then((res) => {
         if (res.code == 1) {
           this.$msg('图文变更成功');
           this.selectedId = id;
-          // 把变更的信息发给父页面
-          uni.$emit('updateCategoryIdEvent', { picTextId: id, categoryId: cId });
+          uni.$emit('updateCategoryIdEvent', { picTextId: id, categoryId: this.cId });
           uni.navigateBack({ delta: 1 });
           this.$msg('选择成功');
         }

+ 16 - 0
hdApp/src/api/category-setting/index.js

@@ -0,0 +1,16 @@
+import https from '@/plugins/luch-request_0.0.7/request'
+
+/** 商城分类设置详情 */
+export const categorySettingDetail = data => {
+	return https.get('/category-setting/detail', data)
+}
+
+/** 保存商城分类设置 */
+export const categorySettingSave = data => {
+	return https.post('/category-setting/save', data)
+}
+
+/** 保存分类广告 */
+export const categorySettingSaveAdvertisement = data => {
+	return https.post('/category-setting/save-advertisement', data)
+}

+ 4 - 0
hdApp/src/pages.json

@@ -327,6 +327,10 @@
 				{ "path": "list", "style": { "navigationBarTitleText": "花束" } },
 				{ "path": "manage", "style": { "navigationBarTitleText": "商品管理" } },
 				{ "path": "lz", "style": { "navigationBarTitleText": "绿植" } },
+				{ "path": "categoryV2", "style": { "navigationBarTitleText": "分类管理", "enablePullDownRefresh": true } },
+				{ "path": "category-edit", "style": { "navigationBarTitleText": "编辑分类" } },
+				{ "path": "category-ad", "style": { "navigationBarTitleText": "分类广告" } },
+				{ "path": "ad-goods-select", "style": { "navigationBarTitleText": "选择商品" } },
 				{ "path": "category", "style": { "navigationBarTitleText": "分类" } },
 				{ "path": "kind", "style": { "navigationBarTitleText": "花束品类" } },
 				{ "path": "lzKind", "style": { "navigationBarTitleText": "绿植品类" } },

+ 2 - 2
hdApp/src/utils/mainIndexSprite.js

@@ -17,11 +17,11 @@ const DEFAULT_SHEET_KEY = "main";
  */
 export const SPRITE_SHEETS = {
   main: {
-    path: "/hzg/sprite_icon/main_index.webp",
+    path: "/hzg/sprite_icon/main_index.wbm",
     width: 500,
     height: 500,
     icons: {
-      "dengji-biandong": { x: 14, y: 17, w: 32, h: 28 },
+      "write": { x: 9, y: 4, w: 50, h: 48 },//商城分类设置icon
     }
   },
   /** 进货/供货商专用雪碧图,坐标待设计稿测量后补全 */