|
@@ -1,171 +1,179 @@
|
|
|
<template>
|
|
<template>
|
|
|
<MpWebviewShell @ready="onShellReady">
|
|
<MpWebviewShell @ready="onShellReady">
|
|
|
- <header class="mp-wv-header">
|
|
|
|
|
- <div class="mp-wv-header__row">
|
|
|
|
|
- <button class="mp-wv-header__back" type="button" aria-label="返回" @click="goBack">
|
|
|
|
|
- ←
|
|
|
|
|
- </button>
|
|
|
|
|
- <h1 class="mp-wv-header__title">产品分类</h1>
|
|
|
|
|
- <button class="mp-cat-header-add" type="button" @click="openAdd()">新增</button>
|
|
|
|
|
- </div>
|
|
|
|
|
- </header>
|
|
|
|
|
-
|
|
|
|
|
- <div
|
|
|
|
|
- v-if="loading && !tree.length"
|
|
|
|
|
- class="mp-plan-form-loading mp-plan-form-loading--page"
|
|
|
|
|
- role="status"
|
|
|
|
|
- aria-live="polite"
|
|
|
|
|
- aria-busy="true"
|
|
|
|
|
- >
|
|
|
|
|
- <div class="mp-plan-form-loading__orb" aria-hidden="true">
|
|
|
|
|
- <span class="mp-plan-form-loading__dot" />
|
|
|
|
|
- </div>
|
|
|
|
|
- <p class="mp-plan-form-loading__title">正在加载分类</p>
|
|
|
|
|
- <p class="mp-plan-form-loading__desc">请稍候,正在获取产品分类树…</p>
|
|
|
|
|
- <div class="mp-plan-form-loading__skel" aria-hidden="true">
|
|
|
|
|
- <div class="mp-wv-skel mp-wv-skel--block" />
|
|
|
|
|
- <div class="mp-wv-skel mp-wv-skel--mid" />
|
|
|
|
|
- <div class="mp-wv-skel mp-wv-skel--line" />
|
|
|
|
|
- <div class="mp-wv-skel mp-wv-skel--short" />
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div v-else-if="!flatNodes.length" class="mp-wv-empty">
|
|
|
|
|
- <div class="mp-wv-empty__icon" aria-hidden="true">📁</div>
|
|
|
|
|
- <div class="mp-wv-empty__title">暂无分类</div>
|
|
|
|
|
- <div class="mp-wv-empty__desc">点击右上角「新增」创建顶级分类</div>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div v-else class="mp-cat-list">
|
|
|
|
|
- <div
|
|
|
|
|
- v-for="item in flatNodes"
|
|
|
|
|
- :key="item.node.id"
|
|
|
|
|
- class="mp-cat-row"
|
|
|
|
|
- :style="{ paddingLeft: `${12 + item.depth * 16}px` }"
|
|
|
|
|
- >
|
|
|
|
|
|
|
+ <div class="mp-product-page">
|
|
|
|
|
+ <MpBusinessHeader title="产品分类" back-label="返回产品列表" @back="goBack" />
|
|
|
|
|
+ <div class="mp-cat-header-tools">
|
|
|
<button
|
|
<button
|
|
|
- class="mp-cat-row__toggle"
|
|
|
|
|
|
|
+ v-if="flatNodes.length"
|
|
|
|
|
+ class="mp-cat-header-btn"
|
|
|
type="button"
|
|
type="button"
|
|
|
- :class="{ 'is-leaf': !item.hasChildren }"
|
|
|
|
|
- :aria-label="item.expanded ? '折叠' : '展开'"
|
|
|
|
|
- :disabled="!item.hasChildren"
|
|
|
|
|
- @click="toggleExpand(item.node.id)"
|
|
|
|
|
|
|
+ @click="toggleExpandAll"
|
|
|
>
|
|
>
|
|
|
- <span v-if="item.hasChildren">{{ item.expanded ? '▼' : '▶' }}</span>
|
|
|
|
|
|
|
+ {{ expandAllLabel }}
|
|
|
</button>
|
|
</button>
|
|
|
- <div class="mp-cat-row__main" @click="openNodeActions(item.node)">
|
|
|
|
|
- <div class="mp-cat-row__name">{{ item.node.categoryName || '—' }}</div>
|
|
|
|
|
- <div class="mp-cat-row__meta">
|
|
|
|
|
- <el-tag
|
|
|
|
|
- :type="dictStore.getTagType('sys_normal_disable', item.node.status)"
|
|
|
|
|
- size="small"
|
|
|
|
|
- >
|
|
|
|
|
- {{ dictStore.getLabel('sys_normal_disable', item.node.status) }}
|
|
|
|
|
- </el-tag>
|
|
|
|
|
|
|
+ <button class="mp-cat-header-btn" type="button" @click="openAdd()">新增</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div v-if="loading && !tree.length" class="mp-product-loading">
|
|
|
|
|
+ <van-loading vertical>正在加载分类</van-loading>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <van-empty v-else-if="!flatNodes.length" description="暂无分类,点击右上角新增">
|
|
|
|
|
+ <van-button round type="primary" @click="openAdd()">新增分类</van-button>
|
|
|
|
|
+ </van-empty>
|
|
|
|
|
+ <div v-else class="mp-cat-tree">
|
|
|
|
|
+ <div
|
|
|
|
|
+ v-for="item in flatNodes"
|
|
|
|
|
+ :key="item.node.id"
|
|
|
|
|
+ class="mp-cat-row"
|
|
|
|
|
+ :class="{
|
|
|
|
|
+ 'is-l2': item.depth === 1,
|
|
|
|
|
+ 'is-l3': item.depth >= 2
|
|
|
|
|
+ }"
|
|
|
|
|
+ :style="{ paddingLeft: `${12 + item.depth * 16}px` }"
|
|
|
|
|
+ >
|
|
|
|
|
+ <button
|
|
|
|
|
+ class="mp-cat-row__toggle"
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ :class="{ 'is-leaf': !item.hasChildren, 'is-open': item.expanded }"
|
|
|
|
|
+ :aria-label="item.expanded ? '折叠' : '展开'"
|
|
|
|
|
+ :disabled="!item.hasChildren"
|
|
|
|
|
+ @click="toggleExpand(item.node.id)"
|
|
|
|
|
+ >
|
|
|
|
|
+ <span v-if="item.hasChildren">▶</span>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <div class="mp-cat-row__main" @click="openNodeActions(item.node)">
|
|
|
|
|
+ <div class="mp-cat-row__name">{{ item.node.categoryName || '—' }}</div>
|
|
|
|
|
+ <div class="mp-cat-row__meta">
|
|
|
|
|
+ <span
|
|
|
|
|
+ class="status-tag"
|
|
|
|
|
+ :class="`status-tag--${statusTone(item.node.status)}`"
|
|
|
|
|
+ >
|
|
|
|
|
+ {{ dictStore.getLabel('sys_normal_disable', item.node.status) || '—' }}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span>排序 {{ item.node.orderNum ?? 0 }}</span>
|
|
|
|
|
+ <span>{{ categoryChildMetaLabel(item.node) }}</span>
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+ <button
|
|
|
|
|
+ class="mp-cat-row__add"
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ aria-label="新增子级"
|
|
|
|
|
+ @click="openAdd(item.node)"
|
|
|
|
|
+ >
|
|
|
|
|
+ +
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button
|
|
|
|
|
+ class="mp-cat-row__more"
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ aria-label="操作"
|
|
|
|
|
+ @click="openNodeActions(item.node)"
|
|
|
|
|
+ >
|
|
|
|
|
+ ⋯
|
|
|
|
|
+ </button>
|
|
|
</div>
|
|
</div>
|
|
|
- <button
|
|
|
|
|
- class="mp-cat-row__more"
|
|
|
|
|
- type="button"
|
|
|
|
|
- aria-label="操作"
|
|
|
|
|
- @click="openNodeActions(item.node)"
|
|
|
|
|
- >
|
|
|
|
|
- ⋯
|
|
|
|
|
- </button>
|
|
|
|
|
</div>
|
|
</div>
|
|
|
- </div>
|
|
|
|
|
|
|
|
|
|
- <MpBottomSheet v-model="actionOpen" title="分类操作" half>
|
|
|
|
|
- <button class="mp-wv-filter-opt" type="button" @click="onActionAddChild">新增子级</button>
|
|
|
|
|
- <button class="mp-wv-filter-opt" type="button" @click="onActionEdit">编辑</button>
|
|
|
|
|
- <button
|
|
|
|
|
- class="mp-wv-filter-opt is-danger"
|
|
|
|
|
- type="button"
|
|
|
|
|
- :disabled="!canDeleteActionNode"
|
|
|
|
|
- :title="canDeleteActionNode ? '' : CREATOR_DELETE_TIP"
|
|
|
|
|
- @click="onActionDelete"
|
|
|
|
|
- >
|
|
|
|
|
- 删除
|
|
|
|
|
- </button>
|
|
|
|
|
- </MpBottomSheet>
|
|
|
|
|
-
|
|
|
|
|
- <MpBottomSheet v-model="formOpen" :title="formTitle">
|
|
|
|
|
- <el-form
|
|
|
|
|
- ref="formRef"
|
|
|
|
|
- class="mp-cat-form"
|
|
|
|
|
- :model="formData"
|
|
|
|
|
- :rules="formRules"
|
|
|
|
|
- label-position="top"
|
|
|
|
|
- scroll-to-error
|
|
|
|
|
- :scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
|
|
|
|
|
|
|
+ <van-action-sheet
|
|
|
|
|
+ v-model:show="actionOpen"
|
|
|
|
|
+ teleport="body"
|
|
|
|
|
+ :actions="actionSheetActions"
|
|
|
|
|
+ cancel-text="取消"
|
|
|
|
|
+ close-on-click-action
|
|
|
|
|
+ @select="onActionSelect"
|
|
|
|
|
+ />
|
|
|
|
|
+
|
|
|
|
|
+ <van-popup
|
|
|
|
|
+ :show="formOpen"
|
|
|
|
|
+ position="bottom"
|
|
|
|
|
+ round
|
|
|
|
|
+ teleport="body"
|
|
|
|
|
+ class="mp-customer-vant-popup"
|
|
|
|
|
+ :style="{ height: '82%' }"
|
|
|
|
|
+ :close-on-click-overlay="!submitLoading"
|
|
|
|
|
+ @update:show="onFormShow"
|
|
|
>
|
|
>
|
|
|
- <el-form-item label="上级分类">
|
|
|
|
|
- <el-tree-select
|
|
|
|
|
- v-model="formData.parentId"
|
|
|
|
|
- :data="categoryTreeOptions"
|
|
|
|
|
- node-key="id"
|
|
|
|
|
- :props="{ label: 'categoryName', children: 'children' }"
|
|
|
|
|
- value-key="id"
|
|
|
|
|
- check-strictly
|
|
|
|
|
- filterable
|
|
|
|
|
- clearable
|
|
|
|
|
- placeholder="选择上级分类(留空为顶级)"
|
|
|
|
|
- style="width: 100%"
|
|
|
|
|
|
|
+ <div class="mp-customer-pool-vant">
|
|
|
|
|
+ <van-nav-bar
|
|
|
|
|
+ :title="formTitle"
|
|
|
|
|
+ left-text="关闭"
|
|
|
|
|
+ :left-disabled="submitLoading"
|
|
|
|
|
+ @click-left="closeForm"
|
|
|
/>
|
|
/>
|
|
|
- </el-form-item>
|
|
|
|
|
- <el-form-item label="分类名称" prop="categoryName">
|
|
|
|
|
- <el-input v-model="formData.categoryName" placeholder="请输入分类名称" />
|
|
|
|
|
- </el-form-item>
|
|
|
|
|
- <el-form-item label="排序号">
|
|
|
|
|
- <el-input-number
|
|
|
|
|
- v-model="formData.orderNum"
|
|
|
|
|
- controls-position="right"
|
|
|
|
|
- :min="0"
|
|
|
|
|
- style="width: 100%"
|
|
|
|
|
- />
|
|
|
|
|
- </el-form-item>
|
|
|
|
|
- <el-form-item label="状态">
|
|
|
|
|
- <el-select v-model="formData.status" placeholder="请选择状态" style="width: 100%">
|
|
|
|
|
- <el-option
|
|
|
|
|
- v-for="opt in dictStore.getOptions('sys_normal_disable')"
|
|
|
|
|
- :key="opt.value"
|
|
|
|
|
- :label="opt.label"
|
|
|
|
|
- :value="opt.value"
|
|
|
|
|
- />
|
|
|
|
|
- </el-select>
|
|
|
|
|
- </el-form-item>
|
|
|
|
|
- <el-form-item label="备注">
|
|
|
|
|
- <el-input v-model="formData.remark" type="textarea" placeholder="请输入备注" :rows="3" />
|
|
|
|
|
- </el-form-item>
|
|
|
|
|
- </el-form>
|
|
|
|
|
- <template #footer>
|
|
|
|
|
- <button
|
|
|
|
|
- class="mp-wv-btn mp-cat-foot-btn"
|
|
|
|
|
- type="button"
|
|
|
|
|
- :disabled="submitLoading"
|
|
|
|
|
- @click="formOpen = false"
|
|
|
|
|
- >
|
|
|
|
|
- 取消
|
|
|
|
|
- </button>
|
|
|
|
|
- <button
|
|
|
|
|
- class="mp-wv-btn mp-wv-btn--primary mp-cat-foot-btn"
|
|
|
|
|
- type="button"
|
|
|
|
|
- :disabled="submitLoading"
|
|
|
|
|
- @click="submitForm"
|
|
|
|
|
- >
|
|
|
|
|
- <span v-if="submitLoading" class="mp-wv-spinner mp-wv-spinner--sm" aria-hidden="true" />
|
|
|
|
|
- <span>{{ submitLoading ? '提交中…' : '确定' }}</span>
|
|
|
|
|
- </button>
|
|
|
|
|
- </template>
|
|
|
|
|
- </MpBottomSheet>
|
|
|
|
|
|
|
+ <div class="mp-customer-pool-vant__body">
|
|
|
|
|
+ <van-cell-group inset>
|
|
|
|
|
+ <van-field
|
|
|
|
|
+ is-link
|
|
|
|
|
+ readonly
|
|
|
|
|
+ label="上级分类"
|
|
|
|
|
+ :model-value="parentLabel"
|
|
|
|
|
+ placeholder="选择上级分类(留空为顶级)"
|
|
|
|
|
+ @click="parentPickerOpen = true"
|
|
|
|
|
+ />
|
|
|
|
|
+ <van-field
|
|
|
|
|
+ v-model="formData.categoryName"
|
|
|
|
|
+ label="分类名称"
|
|
|
|
|
+ required
|
|
|
|
|
+ placeholder="请输入分类名称"
|
|
|
|
|
+ :error-message="nameError"
|
|
|
|
|
+ />
|
|
|
|
|
+ <van-field
|
|
|
|
|
+ v-model="orderNumText"
|
|
|
|
|
+ type="digit"
|
|
|
|
|
+ label="排序号"
|
|
|
|
|
+ placeholder="数值越小越靠前"
|
|
|
|
|
+ />
|
|
|
|
|
+ <van-field
|
|
|
|
|
+ is-link
|
|
|
|
|
+ readonly
|
|
|
|
|
+ label="状态"
|
|
|
|
|
+ :model-value="statusLabel"
|
|
|
|
|
+ placeholder="请选择状态"
|
|
|
|
|
+ @click="statusPickerOpen = true"
|
|
|
|
|
+ />
|
|
|
|
|
+ <van-field
|
|
|
|
|
+ v-model="formData.remark"
|
|
|
|
|
+ rows="3"
|
|
|
|
|
+ autosize
|
|
|
|
|
+ type="textarea"
|
|
|
|
|
+ label="备注"
|
|
|
|
|
+ placeholder="请输入备注"
|
|
|
|
|
+ />
|
|
|
|
|
+ </van-cell-group>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="mp-customer-pool-vant__foot">
|
|
|
|
|
+ <van-button round :disabled="submitLoading" @click="closeForm">取消</van-button>
|
|
|
|
|
+ <van-button round type="primary" :loading="submitLoading" @click="submitForm"
|
|
|
|
|
+ >确定</van-button
|
|
|
|
|
+ >
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </van-popup>
|
|
|
|
|
+
|
|
|
|
|
+ <van-popup v-model:show="parentPickerOpen" position="bottom" round teleport="body">
|
|
|
|
|
+ <van-picker
|
|
|
|
|
+ :columns="parentPickerColumns"
|
|
|
|
|
+ :model-value="[formData.parentId || '0']"
|
|
|
|
|
+ @confirm="onParentConfirm"
|
|
|
|
|
+ @cancel="parentPickerOpen = false"
|
|
|
|
|
+ />
|
|
|
|
|
+ </van-popup>
|
|
|
|
|
+
|
|
|
|
|
+ <van-popup v-model:show="statusPickerOpen" position="bottom" round teleport="body">
|
|
|
|
|
+ <van-picker
|
|
|
|
|
+ :columns="statusPickerColumns"
|
|
|
|
|
+ :model-value="[formData.status || '0']"
|
|
|
|
|
+ @confirm="onStatusConfirm"
|
|
|
|
|
+ @cancel="statusPickerOpen = false"
|
|
|
|
|
+ />
|
|
|
|
|
+ </van-popup>
|
|
|
|
|
+ </div>
|
|
|
</MpWebviewShell>
|
|
</MpWebviewShell>
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
<script setup lang="ts">
|
|
|
- import { computed, reactive, ref } from 'vue'
|
|
|
|
|
- import { useRouter } from 'vue-router'
|
|
|
|
|
- import { ElMessage, ElMessageBox } from 'element-plus'
|
|
|
|
|
- import type { FormInstance } from 'element-plus'
|
|
|
|
|
|
|
+ import { showConfirmDialog, showFailToast, showSuccessToast } from 'vant'
|
|
|
import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
|
|
import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
|
|
|
- import MpBottomSheet from '@/components/mp/MpBottomSheet.vue'
|
|
|
|
|
|
|
+ import MpBusinessHeader from '@/components/mp/MpBusinessHeader.vue'
|
|
|
import {
|
|
import {
|
|
|
fetchCategoryTree,
|
|
fetchCategoryTree,
|
|
|
fetchCreateCategory,
|
|
fetchCreateCategory,
|
|
@@ -174,8 +182,14 @@
|
|
|
} from '@/api/crm/product'
|
|
} from '@/api/crm/product'
|
|
|
import { useDictStore } from '@/store/modules/dict'
|
|
import { useDictStore } from '@/store/modules/dict'
|
|
|
import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
|
|
import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
|
|
|
|
|
+ import {
|
|
|
|
|
+ areAllExpandableExpanded,
|
|
|
|
|
+ categoryChildMetaLabel,
|
|
|
|
|
+ collectExpandIds
|
|
|
|
|
+ } from '@/utils/mp-crm-product-category-tree'
|
|
|
import { MP_CRM_PRODUCT_LIST_PATH } from '@/utils/mp-webview'
|
|
import { MP_CRM_PRODUCT_LIST_PATH } from '@/utils/mp-webview'
|
|
|
import '@/styles/mp-webview.scss'
|
|
import '@/styles/mp-webview.scss'
|
|
|
|
|
+ import './product.scss'
|
|
|
|
|
|
|
|
type CategoryNode = {
|
|
type CategoryNode = {
|
|
|
id: string
|
|
id: string
|
|
@@ -209,8 +223,10 @@
|
|
|
const formOpen = ref(false)
|
|
const formOpen = ref(false)
|
|
|
const formTitle = ref('')
|
|
const formTitle = ref('')
|
|
|
const submitLoading = ref(false)
|
|
const submitLoading = ref(false)
|
|
|
- const formRef = ref<FormInstance>()
|
|
|
|
|
- const categoryTreeOptions = ref<CategoryNode[]>([])
|
|
|
|
|
|
|
+ const nameError = ref('')
|
|
|
|
|
+ const parentPickerOpen = ref(false)
|
|
|
|
|
+ const statusPickerOpen = ref(false)
|
|
|
|
|
+ const flatParentOptions = ref<Array<{ text: string; value: string }>>([])
|
|
|
const formData = reactive({
|
|
const formData = reactive({
|
|
|
id: '',
|
|
id: '',
|
|
|
parentId: '' as string,
|
|
parentId: '' as string,
|
|
@@ -219,14 +235,51 @@
|
|
|
status: '0',
|
|
status: '0',
|
|
|
remark: ''
|
|
remark: ''
|
|
|
})
|
|
})
|
|
|
- const formRules = {
|
|
|
|
|
- categoryName: [{ required: true, message: '分类名称不能为空', trigger: 'blur' }]
|
|
|
|
|
- }
|
|
|
|
|
|
|
+
|
|
|
|
|
+ const orderNumText = computed({
|
|
|
|
|
+ get: () => String(formData.orderNum ?? 0),
|
|
|
|
|
+ set: (v: string) => {
|
|
|
|
|
+ formData.orderNum = Number(v || 0)
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
|
|
|
const canDeleteActionNode = computed(() =>
|
|
const canDeleteActionNode = computed(() =>
|
|
|
canDeleteByCreator(actionNode.value as Record<string, unknown> | null)
|
|
canDeleteByCreator(actionNode.value as Record<string, unknown> | null)
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
+ const actionSheetActions = computed(() => [
|
|
|
|
|
+ { name: '新增子级', value: 'add' },
|
|
|
|
|
+ { name: '编辑', value: 'edit' },
|
|
|
|
|
+ {
|
|
|
|
|
+ name: '删除',
|
|
|
|
|
+ value: 'delete',
|
|
|
|
|
+ color: '#dc2626',
|
|
|
|
|
+ disabled: !canDeleteActionNode.value
|
|
|
|
|
+ }
|
|
|
|
|
+ ])
|
|
|
|
|
+
|
|
|
|
|
+ const parentPickerColumns = computed(() => [
|
|
|
|
|
+ { text: '顶级分类', value: '0' },
|
|
|
|
|
+ ...flatParentOptions.value
|
|
|
|
|
+ ])
|
|
|
|
|
+
|
|
|
|
|
+ const statusPickerColumns = computed(() =>
|
|
|
|
|
+ dictStore.getOptions('sys_normal_disable').map((o) => ({
|
|
|
|
|
+ text: o.label,
|
|
|
|
|
+ value: String(o.value)
|
|
|
|
|
+ }))
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ const parentLabel = computed(() => {
|
|
|
|
|
+ const id = formData.parentId || '0'
|
|
|
|
|
+ if (id === '0' || !id) return '顶级分类'
|
|
|
|
|
+ return parentPickerColumns.value.find((c) => c.value === id)?.text || id
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ const statusLabel = computed(() => {
|
|
|
|
|
+ return dictStore.getLabel('sys_normal_disable', formData.status) || ''
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
const flatNodes = computed((): FlatItem[] => {
|
|
const flatNodes = computed((): FlatItem[] => {
|
|
|
const out: FlatItem[] = []
|
|
const out: FlatItem[] = []
|
|
|
const walk = (nodes: CategoryNode[], depth: number) => {
|
|
const walk = (nodes: CategoryNode[], depth: number) => {
|
|
@@ -242,27 +295,29 @@
|
|
|
return out
|
|
return out
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- function collectExpandIds(nodes: CategoryNode[], acc: Record<string, boolean> = {}) {
|
|
|
|
|
- for (const node of nodes) {
|
|
|
|
|
- const children = Array.isArray(node.children) ? node.children : []
|
|
|
|
|
- if (children.length) {
|
|
|
|
|
- acc[node.id] = true
|
|
|
|
|
- collectExpandIds(children, acc)
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- return acc
|
|
|
|
|
|
|
+ function statusTone(status?: string) {
|
|
|
|
|
+ if (String(status) === '1') return 'danger'
|
|
|
|
|
+ return 'success'
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- function findNodeById(nodes: CategoryNode[], id: string): CategoryNode | null {
|
|
|
|
|
- for (const node of nodes) {
|
|
|
|
|
- if (node.id === id) return node
|
|
|
|
|
- const children = Array.isArray(node.children) ? node.children : []
|
|
|
|
|
- if (children.length) {
|
|
|
|
|
- const found = findNodeById(children, id)
|
|
|
|
|
- if (found) return found
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ function flattenParents(nodes: CategoryNode[], parents: string[] = []): Array<{ text: string; value: string }> {
|
|
|
|
|
+ return nodes.flatMap((node) => {
|
|
|
|
|
+ const path = [...parents, String(node.categoryName || '')].filter(Boolean)
|
|
|
|
|
+ const current = node.id ? [{ text: path.join(' / '), value: String(node.id) }] : []
|
|
|
|
|
+ return current.concat(flattenParents(node.children || [], path))
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const expandAllLabel = computed(() =>
|
|
|
|
|
+ areAllExpandableExpanded(tree.value, expandedIds.value) ? '全部折叠' : '全部展开'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ function toggleExpandAll() {
|
|
|
|
|
+ if (areAllExpandableExpanded(tree.value, expandedIds.value)) {
|
|
|
|
|
+ expandedIds.value = {}
|
|
|
|
|
+ return
|
|
|
}
|
|
}
|
|
|
- return null
|
|
|
|
|
|
|
+ expandedIds.value = collectExpandIds(tree.value)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function loadTree() {
|
|
async function loadTree() {
|
|
@@ -277,17 +332,10 @@
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- async function getCategoryTreeSelect() {
|
|
|
|
|
|
|
+ async function refreshParentOptions() {
|
|
|
const res = await fetchCategoryTree({})
|
|
const res = await fetchCategoryTree({})
|
|
|
const data = ((res as { data?: CategoryNode[] }).data || res || []) as CategoryNode[]
|
|
const data = ((res as { data?: CategoryNode[] }).data || res || []) as CategoryNode[]
|
|
|
- categoryTreeOptions.value = [
|
|
|
|
|
- {
|
|
|
|
|
- id: '0',
|
|
|
|
|
- categoryName: '顶级分类',
|
|
|
|
|
- parentId: '0',
|
|
|
|
|
- children: Array.isArray(data) ? data : []
|
|
|
|
|
- }
|
|
|
|
|
- ]
|
|
|
|
|
|
|
+ flatParentOptions.value = flattenParents(Array.isArray(data) ? data : [])
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function toggleExpand(id: string) {
|
|
function toggleExpand(id: string) {
|
|
@@ -312,16 +360,14 @@
|
|
|
formData.orderNum = 0
|
|
formData.orderNum = 0
|
|
|
formData.status = '0'
|
|
formData.status = '0'
|
|
|
formData.remark = ''
|
|
formData.remark = ''
|
|
|
- formRef.value?.resetFields()
|
|
|
|
|
|
|
+ nameError.value = ''
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function openAdd(parentRow?: CategoryNode) {
|
|
async function openAdd(parentRow?: CategoryNode) {
|
|
|
actionOpen.value = false
|
|
actionOpen.value = false
|
|
|
resetForm()
|
|
resetForm()
|
|
|
- await getCategoryTreeSelect()
|
|
|
|
|
- if (parentRow?.id) {
|
|
|
|
|
- formData.parentId = parentRow.id
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ await refreshParentOptions()
|
|
|
|
|
+ if (parentRow?.id) formData.parentId = parentRow.id
|
|
|
formTitle.value = '新增产品分类'
|
|
formTitle.value = '新增产品分类'
|
|
|
formOpen.value = true
|
|
formOpen.value = true
|
|
|
}
|
|
}
|
|
@@ -329,7 +375,7 @@
|
|
|
async function openEdit(node: CategoryNode) {
|
|
async function openEdit(node: CategoryNode) {
|
|
|
actionOpen.value = false
|
|
actionOpen.value = false
|
|
|
resetForm()
|
|
resetForm()
|
|
|
- await getCategoryTreeSelect()
|
|
|
|
|
|
|
+ await refreshParentOptions()
|
|
|
formData.id = node.id
|
|
formData.id = node.id
|
|
|
formData.parentId = node.parentId || ''
|
|
formData.parentId = node.parentId || ''
|
|
|
formData.categoryName = node.categoryName || ''
|
|
formData.categoryName = node.categoryName || ''
|
|
@@ -345,68 +391,79 @@
|
|
|
actionOpen.value = true
|
|
actionOpen.value = true
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- function onActionAddChild() {
|
|
|
|
|
|
|
+ function onActionSelect(action: { value?: string }) {
|
|
|
const node = actionNode.value
|
|
const node = actionNode.value
|
|
|
if (!node) return
|
|
if (!node) return
|
|
|
- void openAdd(node)
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- function onActionEdit() {
|
|
|
|
|
- const node = actionNode.value
|
|
|
|
|
- if (!node) return
|
|
|
|
|
- void openEdit(node)
|
|
|
|
|
|
|
+ if (action.value === 'add') void openAdd(node)
|
|
|
|
|
+ else if (action.value === 'edit') void openEdit(node)
|
|
|
|
|
+ else if (action.value === 'delete') void onActionDelete()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function onActionDelete() {
|
|
async function onActionDelete() {
|
|
|
const node = actionNode.value
|
|
const node = actionNode.value
|
|
|
if (!node?.id) return
|
|
if (!node?.id) return
|
|
|
if (!canDeleteByCreator(node as Record<string, unknown>)) {
|
|
if (!canDeleteByCreator(node as Record<string, unknown>)) {
|
|
|
- ElMessage.warning(CREATOR_DELETE_TIP)
|
|
|
|
|
|
|
+ showFailToast(CREATOR_DELETE_TIP)
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
try {
|
|
try {
|
|
|
- await ElMessageBox.confirm('是否确认删除该产品分类?', '提示', {
|
|
|
|
|
- confirmButtonText: '确定',
|
|
|
|
|
- cancelButtonText: '取消',
|
|
|
|
|
- type: 'warning'
|
|
|
|
|
- })
|
|
|
|
|
|
|
+ await showConfirmDialog({ title: '提示', message: '是否确认删除该产品分类?' })
|
|
|
} catch {
|
|
} catch {
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
actionOpen.value = false
|
|
actionOpen.value = false
|
|
|
try {
|
|
try {
|
|
|
await fetchDeleteCategory({ id: node.id })
|
|
await fetchDeleteCategory({ id: node.id })
|
|
|
- ElMessage.success('删除成功')
|
|
|
|
|
|
|
+ showSuccessToast('删除成功')
|
|
|
actionNode.value = null
|
|
actionNode.value = null
|
|
|
await loadTree()
|
|
await loadTree()
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
- ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
|
|
|
|
|
|
+ showFailToast(error instanceof Error ? error.message : '删除失败')
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ function onFormShow(show: boolean) {
|
|
|
|
|
+ if (!submitLoading.value) formOpen.value = show
|
|
|
|
|
+ }
|
|
|
|
|
+ function closeForm() {
|
|
|
|
|
+ if (!submitLoading.value) formOpen.value = false
|
|
|
|
|
+ }
|
|
|
|
|
+ function onParentConfirm({ selectedValues }: { selectedValues: string[] }) {
|
|
|
|
|
+ const v = selectedValues[0] || '0'
|
|
|
|
|
+ formData.parentId = v === '0' ? '' : v
|
|
|
|
|
+ parentPickerOpen.value = false
|
|
|
|
|
+ }
|
|
|
|
|
+ function onStatusConfirm({ selectedValues }: { selectedValues: string[] }) {
|
|
|
|
|
+ formData.status = selectedValues[0] || '0'
|
|
|
|
|
+ statusPickerOpen.value = false
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
async function submitForm() {
|
|
async function submitForm() {
|
|
|
- const valid = await formRef.value?.validate().catch(() => false)
|
|
|
|
|
- if (!valid) return
|
|
|
|
|
|
|
+ if (!formData.categoryName.trim()) {
|
|
|
|
|
+ nameError.value = '分类名称不能为空'
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ nameError.value = ''
|
|
|
submitLoading.value = true
|
|
submitLoading.value = true
|
|
|
try {
|
|
try {
|
|
|
const payload = {
|
|
const payload = {
|
|
|
parentId: formData.parentId || '0',
|
|
parentId: formData.parentId || '0',
|
|
|
- categoryName: formData.categoryName,
|
|
|
|
|
|
|
+ categoryName: formData.categoryName.trim(),
|
|
|
orderNum: formData.orderNum,
|
|
orderNum: formData.orderNum,
|
|
|
status: formData.status,
|
|
status: formData.status,
|
|
|
remark: formData.remark
|
|
remark: formData.remark
|
|
|
}
|
|
}
|
|
|
if (formData.id) {
|
|
if (formData.id) {
|
|
|
await fetchUpdateCategory({ id: formData.id, ...payload })
|
|
await fetchUpdateCategory({ id: formData.id, ...payload })
|
|
|
- ElMessage.success('修改成功')
|
|
|
|
|
|
|
+ showSuccessToast('修改成功')
|
|
|
} else {
|
|
} else {
|
|
|
await fetchCreateCategory(payload)
|
|
await fetchCreateCategory(payload)
|
|
|
- ElMessage.success('新增成功')
|
|
|
|
|
|
|
+ showSuccessToast('新增成功')
|
|
|
}
|
|
}
|
|
|
formOpen.value = false
|
|
formOpen.value = false
|
|
|
await loadTree()
|
|
await loadTree()
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
- ElMessage.error(error instanceof Error ? error.message : '保存失败')
|
|
|
|
|
|
|
+ showFailToast(error instanceof Error ? error.message : '保存失败')
|
|
|
} finally {
|
|
} finally {
|
|
|
submitLoading.value = false
|
|
submitLoading.value = false
|
|
|
}
|
|
}
|
|
@@ -418,17 +475,24 @@
|
|
|
</script>
|
|
</script>
|
|
|
|
|
|
|
|
<style scoped lang="scss">
|
|
<style scoped lang="scss">
|
|
|
|
|
+ .mp-product-page {
|
|
|
|
|
+ position: relative;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
.mp-cat-header-add {
|
|
.mp-cat-header-add {
|
|
|
- flex-shrink: 0;
|
|
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ top: calc(8px + env(safe-area-inset-top, 0px));
|
|
|
|
|
+ right: 16px;
|
|
|
|
|
+ z-index: 60;
|
|
|
height: 32px;
|
|
height: 32px;
|
|
|
padding: 0 12px;
|
|
padding: 0 12px;
|
|
|
- border: 1px solid rgba(255, 255, 255, 0.75);
|
|
|
|
|
- border-radius: 8px;
|
|
|
|
|
- background: rgba(255, 255, 255, 0.15);
|
|
|
|
|
- color: #fff;
|
|
|
|
|
font-size: 13px;
|
|
font-size: 13px;
|
|
|
font-weight: 600;
|
|
font-weight: 600;
|
|
|
|
|
+ color: #fff;
|
|
|
cursor: pointer;
|
|
cursor: pointer;
|
|
|
|
|
+ background: rgb(255 255 255 / 15%);
|
|
|
|
|
+ border: 1px solid rgb(255 255 255 / 75%);
|
|
|
|
|
+ border-radius: 8px;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
.mp-cat-header-add:active {
|
|
.mp-cat-header-add:active {
|
|
@@ -439,13 +503,13 @@
|
|
|
display: flex;
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
flex-direction: column;
|
|
|
gap: 8px;
|
|
gap: 8px;
|
|
|
- padding-bottom: 24px;
|
|
|
|
|
|
|
+ padding: 12px 10px 24px;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
.mp-cat-row {
|
|
.mp-cat-row {
|
|
|
display: flex;
|
|
display: flex;
|
|
|
- align-items: center;
|
|
|
|
|
gap: 6px;
|
|
gap: 6px;
|
|
|
|
|
+ align-items: center;
|
|
|
min-height: 52px;
|
|
min-height: 52px;
|
|
|
padding: 8px 10px 8px 0;
|
|
padding: 8px 10px 8px 0;
|
|
|
background: #fff;
|
|
background: #fff;
|
|
@@ -458,13 +522,13 @@
|
|
|
width: 28px;
|
|
width: 28px;
|
|
|
height: 28px;
|
|
height: 28px;
|
|
|
padding: 0;
|
|
padding: 0;
|
|
|
- border: none;
|
|
|
|
|
- background: transparent;
|
|
|
|
|
- color: #0a9f6d;
|
|
|
|
|
font-size: 11px;
|
|
font-size: 11px;
|
|
|
line-height: 28px;
|
|
line-height: 28px;
|
|
|
|
|
+ color: #0a9f6d;
|
|
|
text-align: center;
|
|
text-align: center;
|
|
|
cursor: pointer;
|
|
cursor: pointer;
|
|
|
|
|
+ background: transparent;
|
|
|
|
|
+ border: none;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
.mp-cat-row__toggle.is-leaf {
|
|
.mp-cat-row__toggle.is-leaf {
|
|
@@ -493,28 +557,35 @@
|
|
|
flex-shrink: 0;
|
|
flex-shrink: 0;
|
|
|
width: 36px;
|
|
width: 36px;
|
|
|
height: 36px;
|
|
height: 36px;
|
|
|
- border: none;
|
|
|
|
|
- border-radius: 8px;
|
|
|
|
|
- background: #e9f9f2;
|
|
|
|
|
- color: #087f57;
|
|
|
|
|
font-size: 18px;
|
|
font-size: 18px;
|
|
|
line-height: 1;
|
|
line-height: 1;
|
|
|
|
|
+ color: #087f57;
|
|
|
cursor: pointer;
|
|
cursor: pointer;
|
|
|
|
|
+ background: #e9f9f2;
|
|
|
|
|
+ border: none;
|
|
|
|
|
+ border-radius: 8px;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- .mp-cat-form {
|
|
|
|
|
- padding: 4px 8px 0;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- .mp-cat-form :deep(.el-form-item) {
|
|
|
|
|
- margin-bottom: 12px;
|
|
|
|
|
|
|
+ .status-tag {
|
|
|
|
|
+ display: inline-flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ min-height: 22px;
|
|
|
|
|
+ padding: 2px 8px;
|
|
|
|
|
+ font-size: 11px;
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+ border: 1px solid transparent;
|
|
|
|
|
+ border-radius: 999px;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- .mp-cat-foot-btn {
|
|
|
|
|
- flex: 1;
|
|
|
|
|
|
|
+ .status-tag--success {
|
|
|
|
|
+ color: #087f57;
|
|
|
|
|
+ background: #e9f9f2;
|
|
|
|
|
+ border-color: #b7ebce;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- .is-danger {
|
|
|
|
|
- color: #dc2626 !important;
|
|
|
|
|
|
|
+ .status-tag--danger {
|
|
|
|
|
+ color: #b91c1c;
|
|
|
|
|
+ background: #fef2f2;
|
|
|
|
|
+ border-color: #fecaca;
|
|
|
}
|
|
}
|
|
|
</style>
|
|
</style>
|