提交
This commit is contained in:
1 parent
a903fcf6d4
commit
259c55cc23
80 files changed
+6090
-123
No files matched your search
@@ -213,3 +213,26 @@ export async function listContract(query = {}) {
|
||||
export async function getContract(id) {
|
||||
return get(`/web/crm/contract/${String(id)}`)
|
||||
}
|
||||
|
||||
// ── 产品分类 ──
|
||||
|
||||
/** 产品分类树(不分页,嵌套 children) */
|
||||
export async function listProductCategoryTree(query = {}) {
|
||||
const body = {}
|
||||
if (query.categoryName) body.categoryName = String(query.categoryName).trim()
|
||||
if (query.status != null && query.status !== '') body.status = String(query.status)
|
||||
const data = await post('/web/crm/product-categories/tree', body)
|
||||
return Array.isArray(data) ? data : []
|
||||
}
|
||||
|
||||
export function addProductCategory(data) {
|
||||
return post('/web/crm/product-categories/add', data)
|
||||
}
|
||||
|
||||
export function updateProductCategory(data) {
|
||||
return post('/web/crm/product-categories/update', data)
|
||||
}
|
||||
|
||||
export function deleteProductCategory(id) {
|
||||
return post('/web/crm/product-categories/delete', { id: String(id) })
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 全局空态(方案 B · 插画文件夹)
|
||||
* 用法:
|
||||
* <mp-empty title="暂无产品分类" description="可先新增一个顶级分类" action-text="新增分类" @action="goAdd" />
|
||||
* <mp-empty title="暂无线索" />
|
||||
* <mp-empty compact title="暂无跟进" />
|
||||
*/
|
||||
defineProps({
|
||||
/** 主文案 */
|
||||
title: { type: String, default: '暂无数据' },
|
||||
/** 辅助说明,可选 */
|
||||
description: { type: String, default: '' },
|
||||
/** 主按钮文案;空则不展示按钮 */
|
||||
actionText: { type: String, default: '' },
|
||||
/** 次要按钮文案,可选 */
|
||||
secondaryText: { type: String, default: '' },
|
||||
/** 详情内嵌等紧凑场景 */
|
||||
compact: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['action', 'secondary'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="mp-empty" :class="{ 'mp-empty--compact': compact }">
|
||||
<view class="mp-empty__art" aria-hidden="true">
|
||||
<view class="mp-empty__shadow" />
|
||||
<view class="mp-empty__folder">
|
||||
<view class="mp-empty__folder-tab" />
|
||||
<view class="mp-empty__folder-back" />
|
||||
<view class="mp-empty__folder-front">
|
||||
<view class="mp-empty__lines">
|
||||
<view class="mp-empty__line" />
|
||||
<view class="mp-empty__line mp-empty__line--mid" />
|
||||
<view class="mp-empty__line mp-empty__line--short" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<text class="mp-empty__title">{{ title }}</text>
|
||||
<text v-if="description" class="mp-empty__desc">{{ description }}</text>
|
||||
<view v-if="actionText || secondaryText" class="mp-empty__actions">
|
||||
<view
|
||||
v-if="secondaryText"
|
||||
class="mp-btn mp-btn--ghost"
|
||||
@click="emit('secondary')"
|
||||
>
|
||||
{{ secondaryText }}
|
||||
</view>
|
||||
<view
|
||||
v-if="actionText"
|
||||
class="mp-btn mp-btn--primary"
|
||||
@click="emit('action')"
|
||||
>
|
||||
{{ actionText }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -53,6 +53,8 @@ export const PAGE_PATHS = {
|
||||
crmQuoteDetail: '/pages/crm/quote/detail',
|
||||
crmContractList: '/pages/crm/contract/list',
|
||||
crmContractDetail: '/pages/crm/contract/detail',
|
||||
crmProductCategoryList: '/pages/crm/product-category/list',
|
||||
crmProductCategoryForm: '/pages/crm/product-category/form',
|
||||
bxTodoList: '/pages/bx/todo/list',
|
||||
bxReimburseList: '/pages/bx/reimburse/list',
|
||||
bxReimburseForm: '/pages/bx/reimburse/form',
|
||||
@@ -82,6 +84,7 @@ export const PAGE_PATHS = {
|
||||
|
||||
/** 工作台菜单 code → 页面路径 */
|
||||
export const WORKBENCH_ROUTE_MAP = {
|
||||
'crm-product-category': PAGE_PATHS.crmProductCategoryList,
|
||||
'crm-clue': PAGE_PATHS.crmClueList,
|
||||
'crm-opportunity': PAGE_PATHS.crmOpportunityList,
|
||||
'crm-customer': PAGE_PATHS.crmCustomerList,
|
||||
|
||||
@@ -9,7 +9,7 @@ export const WORKBENCH_MODULES = [
|
||||
desc: '线索 · 商机 · 客户 · 报价 · 合同',
|
||||
iconText: 'CRM',
|
||||
menus: [
|
||||
{ name: '产品分类', enabled: false },
|
||||
{ name: '产品分类', enabled: true, code: 'crm-product-category' },
|
||||
{ name: '产品管理', enabled: false },
|
||||
{ name: '线索管理', enabled: true, code: 'crm-clue' },
|
||||
{ name: '商机管理', enabled: true, code: 'crm-opportunity' },
|
||||
|
||||
@@ -52,6 +52,14 @@
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/crm/product-category/list",
|
||||
"style": { "navigationStyle": "custom" }
|
||||
},
|
||||
{
|
||||
"path": "pages/crm/product-category/form",
|
||||
"style": { "navigationStyle": "custom" }
|
||||
},
|
||||
{
|
||||
"path": "pages/crm/clue/list",
|
||||
"style": { "navigationStyle": "custom", "enablePullDownRefresh": false }
|
||||
|
||||
@@ -60,10 +60,7 @@ onReachBottom(() => loadList(false))
|
||||
</view>
|
||||
|
||||
<view class="mp-section">
|
||||
<view v-if="!list.length && !loading" class="mp-empty">
|
||||
<view class="mp-empty__icon" />
|
||||
<text class="mp-empty__text">暂无借款单</text>
|
||||
</view>
|
||||
<mp-empty v-if="!list.length && !loading" title="暂无借款单" />
|
||||
<view v-else class="mp-list">
|
||||
<view v-for="row in list" :key="row.id" class="mp-card" @click="goDetail(row)">
|
||||
<view class="mp-card__head">
|
||||
|
||||
@@ -103,10 +103,7 @@ onReachBottom(() => loadList(false))
|
||||
</view>
|
||||
|
||||
<view class="mp-section">
|
||||
<view v-if="!list.length && !loading" class="mp-empty">
|
||||
<view class="mp-empty__icon" />
|
||||
<text class="mp-empty__text">暂无待审批</text>
|
||||
</view>
|
||||
<mp-empty v-if="!list.length && !loading" title="暂无待审批" />
|
||||
<view v-else class="mp-list">
|
||||
<view v-for="row in list" :key="row.id" class="mp-card" @click="goDetail(row)">
|
||||
<view class="mp-card__head">
|
||||
|
||||
@@ -113,10 +113,7 @@ onReachBottom(() => loadList(false))
|
||||
</view>
|
||||
|
||||
<view class="mp-section">
|
||||
<view v-if="!list.length && !loading" class="mp-empty">
|
||||
<view class="mp-empty__icon" />
|
||||
<text class="mp-empty__text">暂无单据</text>
|
||||
</view>
|
||||
<mp-empty v-if="!list.length && !loading" title="暂无单据" />
|
||||
<view v-else class="mp-list">
|
||||
<view v-for="row in list" :key="row._biz + '-' + row.id" class="mp-card" @click="goDetail(row)">
|
||||
<view class="mp-card__head">
|
||||
|
||||
@@ -60,10 +60,7 @@ onReachBottom(() => loadList(false))
|
||||
</view>
|
||||
|
||||
<view class="mp-section">
|
||||
<view v-if="!list.length && !loading" class="mp-empty">
|
||||
<view class="mp-empty__icon" />
|
||||
<text class="mp-empty__text">暂无报销单</text>
|
||||
</view>
|
||||
<mp-empty v-if="!list.length && !loading" title="暂无报销单" />
|
||||
<view v-else class="mp-list">
|
||||
<view v-for="row in list" :key="row.id" class="mp-card" @click="goDetail(row)">
|
||||
<view class="mp-card__head">
|
||||
|
||||
@@ -171,10 +171,7 @@ onReachBottom(() => {
|
||||
</view>
|
||||
|
||||
<view class="mp-section">
|
||||
<view v-if="!list.length && !loading" class="mp-empty">
|
||||
<view class="mp-empty__icon" />
|
||||
<text class="mp-empty__text">暂无单据</text>
|
||||
</view>
|
||||
<mp-empty v-if="!list.length && !loading" title="暂无单据" />
|
||||
<view v-else class="mp-list">
|
||||
<view v-for="row in list" :key="row._biz + '-' + row.id" class="mp-card" @click="goDetail(row)">
|
||||
<view class="mp-card__head">
|
||||
|
||||
@@ -151,9 +151,7 @@ onShow(() => load())
|
||||
|
||||
<view class="mp-block">
|
||||
<text class="mp-block__title">跟进记录</text>
|
||||
<view v-if="!follows.length" class="mp-empty" style="padding: 40rpx 0">
|
||||
<text class="mp-empty__text">暂无跟进</text>
|
||||
</view>
|
||||
<mp-empty v-if="!follows.length" compact title="暂无跟进" />
|
||||
<view v-else class="mp-list">
|
||||
<view v-for="f in follows" :key="f.id" class="mp-card">
|
||||
<view class="mp-card__head">
|
||||
|
||||
@@ -119,10 +119,7 @@ onReachBottom(() => loadList(false))
|
||||
</view>
|
||||
|
||||
<view class="mp-section">
|
||||
<view v-if="!list.length && !loading" class="mp-empty">
|
||||
<view class="mp-empty__icon" />
|
||||
<text class="mp-empty__text">暂无线索</text>
|
||||
</view>
|
||||
<mp-empty v-if="!list.length && !loading" title="暂无线索" />
|
||||
<view v-else class="mp-list">
|
||||
<view v-for="row in list" :key="row.id" class="mp-card" @click="goDetail(row)">
|
||||
<view class="mp-card__head">
|
||||
|
||||
@@ -107,10 +107,7 @@ onReachBottom(() => loadList(false))
|
||||
</view>
|
||||
|
||||
<view class="mp-section">
|
||||
<view v-if="!list.length && !loading" class="mp-empty">
|
||||
<view class="mp-empty__icon" />
|
||||
<text class="mp-empty__text">暂无合同</text>
|
||||
</view>
|
||||
<mp-empty v-if="!list.length && !loading" title="暂无合同" />
|
||||
<view v-else class="mp-list">
|
||||
<view v-for="row in list" :key="row.id" class="mp-card" @click="goDetail(row)">
|
||||
<view class="mp-card__head">
|
||||
|
||||
@@ -99,9 +99,7 @@ onShow(() => load())
|
||||
|
||||
<view class="mp-block">
|
||||
<text class="mp-block__title">跟进记录</text>
|
||||
<view v-if="!follows.length" class="mp-empty" style="padding: 40rpx 0">
|
||||
<text class="mp-empty__text">暂无跟进</text>
|
||||
</view>
|
||||
<mp-empty v-if="!follows.length" compact title="暂无跟进" />
|
||||
<view v-else class="mp-list">
|
||||
<view v-for="f in follows" :key="f.id" class="mp-card">
|
||||
<view class="mp-card__head">
|
||||
|
||||
@@ -104,10 +104,7 @@ onReachBottom(() => loadList(false))
|
||||
</view>
|
||||
|
||||
<view class="mp-section">
|
||||
<view v-if="!list.length && !loading" class="mp-empty">
|
||||
<view class="mp-empty__icon" />
|
||||
<text class="mp-empty__text">暂无客户</text>
|
||||
</view>
|
||||
<mp-empty v-if="!list.length && !loading" title="暂无客户" />
|
||||
<view v-else class="mp-list">
|
||||
<view v-for="row in list" :key="row.id" class="mp-card" @click="goDetail(row)">
|
||||
<view class="mp-card__head">
|
||||
|
||||
@@ -98,9 +98,7 @@ onShow(() => load())
|
||||
|
||||
<view class="mp-block">
|
||||
<text class="mp-block__title">跟进记录</text>
|
||||
<view v-if="!follows.length" class="mp-empty" style="padding: 40rpx 0">
|
||||
<text class="mp-empty__text">暂无跟进</text>
|
||||
</view>
|
||||
<mp-empty v-if="!follows.length" compact title="暂无跟进" />
|
||||
<view v-else class="mp-list">
|
||||
<view v-for="f in follows" :key="f.id" class="mp-card">
|
||||
<view class="mp-card__head">
|
||||
|
||||
@@ -106,10 +106,7 @@ onReachBottom(() => loadList(false))
|
||||
</view>
|
||||
|
||||
<view class="mp-section">
|
||||
<view v-if="!list.length && !loading" class="mp-empty">
|
||||
<view class="mp-empty__icon" />
|
||||
<text class="mp-empty__text">暂无商机</text>
|
||||
</view>
|
||||
<mp-empty v-if="!list.length && !loading" title="暂无商机" />
|
||||
<view v-else class="mp-list">
|
||||
<view v-for="row in list" :key="row.id" class="mp-card" @click="goDetail(row)">
|
||||
<view class="mp-card__head">
|
||||
|
||||
@@ -102,10 +102,7 @@ onReachBottom(() => loadList(false))
|
||||
</view>
|
||||
|
||||
<view class="mp-section">
|
||||
<view v-if="!list.length && !loading" class="mp-empty">
|
||||
<view class="mp-empty__icon" />
|
||||
<text class="mp-empty__text">公海暂无客户</text>
|
||||
</view>
|
||||
<mp-empty v-if="!list.length && !loading" title="公海暂无客户" />
|
||||
<view v-else class="mp-list">
|
||||
<view v-for="row in list" :key="row.id" class="mp-card">
|
||||
<view class="mp-card__head" @click="goDetail(row)">
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import {
|
||||
addProductCategory,
|
||||
listProductCategoryTree,
|
||||
updateProductCategory
|
||||
} from '@/api/crm'
|
||||
import { PAGE_PATHS } from '@/config/api'
|
||||
|
||||
const submitting = ref(false)
|
||||
const loading = ref(false)
|
||||
const editId = ref('')
|
||||
const tree = ref([])
|
||||
const showParentSheet = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
parentId: '0',
|
||||
categoryName: '',
|
||||
orderNum: 0,
|
||||
status: '0',
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const statusOptions = [
|
||||
{ value: '0', label: '正常' },
|
||||
{ value: '1', label: '停用' }
|
||||
]
|
||||
|
||||
const pageTitle = computed(() => (editId.value ? '修改产品分类' : '新增产品分类'))
|
||||
|
||||
/** 收集某节点及其全部子孙 id(编辑时禁止选为上级) */
|
||||
function collectDescendantIds(nodes, rootId, out = new Set()) {
|
||||
;(nodes || []).forEach((n) => {
|
||||
if (n.id === rootId) {
|
||||
out.add(n.id)
|
||||
const walk = (kids) => {
|
||||
;(kids || []).forEach((c) => {
|
||||
out.add(c.id)
|
||||
walk(c.children)
|
||||
})
|
||||
}
|
||||
walk(n.children)
|
||||
} else if (n.children?.length) {
|
||||
collectDescendantIds(n.children, rootId, out)
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
function flattenParents(nodes, pathParts, excludeIds, out = []) {
|
||||
;(nodes || []).forEach((n) => {
|
||||
if (excludeIds.has(n.id)) return
|
||||
const path = [...pathParts, n.categoryName]
|
||||
out.push({
|
||||
id: n.id,
|
||||
label: path.join(' / '),
|
||||
name: n.categoryName
|
||||
})
|
||||
if (n.children?.length) {
|
||||
flattenParents(n.children, path, excludeIds, out)
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
const parentOptions = computed(() => {
|
||||
const exclude = editId.value
|
||||
? collectDescendantIds(tree.value, editId.value)
|
||||
: new Set()
|
||||
return [{ id: '0', label: '顶级分类', name: '顶级分类' }].concat(
|
||||
flattenParents(tree.value, [], exclude, [])
|
||||
)
|
||||
})
|
||||
|
||||
const parentLabel = computed(() => {
|
||||
const hit = parentOptions.value.find((o) => o.id === (form.parentId || '0'))
|
||||
return hit?.label || '顶级分类'
|
||||
})
|
||||
|
||||
const statusIndex = computed(() =>
|
||||
Math.max(
|
||||
0,
|
||||
statusOptions.findIndex((o) => o.value === form.status)
|
||||
)
|
||||
)
|
||||
|
||||
const findNodeById = (nodes, id) => {
|
||||
for (const n of nodes || []) {
|
||||
if (n.id === id) return n
|
||||
const found = findNodeById(n.children, id)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
uni.navigateBack({
|
||||
fail: () => uni.navigateTo({ url: PAGE_PATHS.crmProductCategoryList })
|
||||
})
|
||||
}
|
||||
|
||||
const loadTree = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
tree.value = await listProductCategoryTree({})
|
||||
if (editId.value) {
|
||||
const node = findNodeById(tree.value, editId.value)
|
||||
if (!node) {
|
||||
uni.showToast({ title: '分类不存在', icon: 'none' })
|
||||
return
|
||||
}
|
||||
form.parentId = node.parentId && node.parentId !== '0' ? node.parentId : '0'
|
||||
form.categoryName = node.categoryName || ''
|
||||
form.orderNum = node.orderNum ?? 0
|
||||
form.status = node.status != null ? String(node.status) : '0'
|
||||
form.remark = node.remark || ''
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openParentSheet = () => {
|
||||
showParentSheet.value = true
|
||||
}
|
||||
|
||||
const closeParentSheet = () => {
|
||||
showParentSheet.value = false
|
||||
}
|
||||
|
||||
const selectParent = (opt) => {
|
||||
form.parentId = opt.id
|
||||
closeParentSheet()
|
||||
}
|
||||
|
||||
const onStatusChange = (e) => {
|
||||
const idx = Number(e.detail.value)
|
||||
form.status = statusOptions[idx]?.value || '0'
|
||||
}
|
||||
|
||||
const onOrderInput = (e) => {
|
||||
const raw = e.detail.value
|
||||
const n = parseInt(raw, 10)
|
||||
form.orderNum = Number.isFinite(n) && n >= 0 ? n : 0
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.categoryName.trim()) {
|
||||
uni.showToast({ title: '请填写分类名称', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (submitting.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload = {
|
||||
parentId: form.parentId || '0',
|
||||
categoryName: form.categoryName.trim(),
|
||||
orderNum: form.orderNum ?? 0,
|
||||
status: form.status || '0',
|
||||
remark: form.remark || ''
|
||||
}
|
||||
if (editId.value) {
|
||||
await updateProductCategory({ id: editId.value, ...payload })
|
||||
uni.showToast({ title: '修改成功', icon: 'success' })
|
||||
} else {
|
||||
await addProductCategory(payload)
|
||||
uni.showToast({ title: '新增成功', icon: 'success' })
|
||||
}
|
||||
setTimeout(() => goBack(), 400)
|
||||
} catch (e) {
|
||||
uni.showToast({ title: e.message || '保存失败', icon: 'none' })
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
editId.value = query?.id ? String(query.id) : ''
|
||||
if (!editId.value && query?.parentId) {
|
||||
form.parentId = String(query.parentId)
|
||||
}
|
||||
loadTree()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="mp-page mp-page--sub mp-page--with-bottom">
|
||||
<view class="mp-header">
|
||||
<text class="mp-subpage-back" @click="goBack">← 返回</text>
|
||||
<text class="mp-header__title" style="margin-top: 16rpx">{{ pageTitle }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="mp-list-loading">加载中…</view>
|
||||
|
||||
<view v-else class="mp-form-panel">
|
||||
<view class="mp-form-row" @click="openParentSheet">
|
||||
<text class="mp-form-label">上级分类</text>
|
||||
<view class="mp-form-picker mp-form-picker--link">{{ parentLabel }}</view>
|
||||
</view>
|
||||
<view class="mp-form-row">
|
||||
<text class="mp-form-label mp-form-label--req">分类名称</text>
|
||||
<input
|
||||
v-model="form.categoryName"
|
||||
class="mp-form-input"
|
||||
placeholder="请输入分类名称"
|
||||
/>
|
||||
</view>
|
||||
<view class="mp-form-row">
|
||||
<text class="mp-form-label">排序号</text>
|
||||
<input
|
||||
class="mp-form-input"
|
||||
type="number"
|
||||
:value="String(form.orderNum)"
|
||||
placeholder="数值越小越靠前"
|
||||
@input="onOrderInput"
|
||||
/>
|
||||
</view>
|
||||
<view class="mp-form-row">
|
||||
<text class="mp-form-label">状态</text>
|
||||
<picker :range="statusOptions" range-key="label" :value="statusIndex" @change="onStatusChange">
|
||||
<view class="mp-form-picker">
|
||||
{{ statusOptions[statusIndex]?.label || '正常' }}
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="mp-form-row">
|
||||
<text class="mp-form-label">备注</text>
|
||||
<textarea v-model="form.remark" class="mp-form-textarea" placeholder="请输入备注" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="mp-bottom-bar">
|
||||
<view class="mp-btn mp-btn--ghost" @click="goBack">取消</view>
|
||||
<view class="mp-btn mp-btn--primary" @click="submit">
|
||||
{{ submitting ? '提交中…' : '保存' }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="showParentSheet" class="mp-sheet-mask" @click="closeParentSheet">
|
||||
<view class="mp-sheet" @click.stop>
|
||||
<view class="mp-sheet__head">
|
||||
<text class="mp-sheet__title">选择上级分类</text>
|
||||
<text class="mp-sheet__cancel" @click="closeParentSheet">关闭</text>
|
||||
</view>
|
||||
<scroll-view scroll-y class="parent-sheet-body">
|
||||
<view
|
||||
v-for="opt in parentOptions"
|
||||
:key="opt.id"
|
||||
class="parent-opt"
|
||||
:class="{ 'parent-opt--active': form.parentId === opt.id }"
|
||||
@click="selectParent(opt)"
|
||||
>
|
||||
<text class="parent-opt__label">{{ opt.label }}</text>
|
||||
<text v-if="form.parentId === opt.id" class="parent-opt__check">✓</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.parent-sheet-body {
|
||||
max-height: 60vh;
|
||||
padding: 8rpx 0 24rpx;
|
||||
}
|
||||
|
||||
.parent-opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
padding: 28rpx 32rpx;
|
||||
border-bottom: 1rpx solid var(--mp-border-light);
|
||||
}
|
||||
|
||||
.parent-opt--active .parent-opt__label {
|
||||
color: var(--mp-brand-green-dark);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.parent-opt__label {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
color: var(--mp-text);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.parent-opt__check {
|
||||
color: var(--mp-brand-green);
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,702 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import {
|
||||
deleteProductCategory,
|
||||
listProductCategoryTree
|
||||
} from '@/api/crm'
|
||||
import { PAGE_PATHS } from '@/config/api'
|
||||
|
||||
const keyword = ref('')
|
||||
const statusFilter = ref('') // '' | '0' | '1'
|
||||
const tree = ref([])
|
||||
const loading = ref(false)
|
||||
const expandedIds = ref([])
|
||||
const allExpanded = ref(false)
|
||||
|
||||
let loadSeq = 0
|
||||
let searchTimer = null
|
||||
|
||||
const statusChips = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: '0', label: '正常' },
|
||||
{ key: '1', label: '停用' }
|
||||
]
|
||||
|
||||
const statusLabel = (status) =>
|
||||
String(status) === '0'
|
||||
? { text: '正常', tag: 'success' }
|
||||
: { text: '停用', tag: 'muted' }
|
||||
|
||||
const childCount = (node) => (node.children || []).length
|
||||
|
||||
function collectExpandableIds(nodes, out = []) {
|
||||
;(nodes || []).forEach((n) => {
|
||||
if (childCount(n) > 0) {
|
||||
out.push(n.id)
|
||||
collectExpandableIds(n.children, out)
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
function defaultExpandLevel1(nodes) {
|
||||
return (nodes || []).filter((n) => childCount(n) > 0).map((n) => n.id)
|
||||
}
|
||||
|
||||
function flattenVisible(nodes, level, expandedSet, out = []) {
|
||||
;(nodes || []).forEach((n) => {
|
||||
const kids = childCount(n)
|
||||
out.push({
|
||||
id: n.id,
|
||||
parentId: n.parentId,
|
||||
categoryName: n.categoryName,
|
||||
status: n.status,
|
||||
orderNum: n.orderNum ?? 0,
|
||||
level,
|
||||
hasChildren: kids > 0,
|
||||
childCount: kids
|
||||
})
|
||||
if (kids > 0 && expandedSet.has(n.id)) {
|
||||
flattenVisible(n.children, level + 1, expandedSet, out)
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
const visibleRows = computed(() => {
|
||||
const set = new Set(expandedIds.value)
|
||||
return flattenVisible(tree.value, 0, set, [])
|
||||
})
|
||||
|
||||
const goBack = () => {
|
||||
uni.navigateBack({
|
||||
fail: () => uni.switchTab({ url: PAGE_PATHS.workbench })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务端过滤:categoryName / status 写入 POST JSON,经加密中间件解密后由 Pydantic 绑定。
|
||||
* @param {{ categoryName?: string, status?: string }} [override]
|
||||
*/
|
||||
const loadTree = async (override = {}) => {
|
||||
const seq = ++loadSeq
|
||||
loading.value = true
|
||||
try {
|
||||
const kw = String(
|
||||
override.categoryName != null ? override.categoryName : keyword.value
|
||||
).trim()
|
||||
const status =
|
||||
override.status != null ? override.status : statusFilter.value
|
||||
const rows = await listProductCategoryTree({
|
||||
categoryName: kw || undefined,
|
||||
status: status || undefined
|
||||
})
|
||||
if (seq !== loadSeq) return
|
||||
tree.value = rows
|
||||
const expandable = collectExpandableIds(rows)
|
||||
const hasFilter = Boolean(kw) || Boolean(status)
|
||||
if (hasFilter) {
|
||||
expandedIds.value = expandable
|
||||
allExpanded.value = true
|
||||
} else if (allExpanded.value) {
|
||||
expandedIds.value = expandable
|
||||
} else {
|
||||
expandedIds.value = defaultExpandLevel1(rows)
|
||||
}
|
||||
} catch (e) {
|
||||
if (seq !== loadSeq) return
|
||||
tree.value = []
|
||||
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
if (seq === loadSeq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleSearch = () => {
|
||||
clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => {
|
||||
loadTree({ categoryName: keyword.value })
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const onSearch = () => {
|
||||
clearTimeout(searchTimer)
|
||||
loadTree({ categoryName: keyword.value })
|
||||
}
|
||||
|
||||
const clearKeyword = () => {
|
||||
if (!keyword.value) return
|
||||
keyword.value = ''
|
||||
clearTimeout(searchTimer)
|
||||
loadTree({ categoryName: '' })
|
||||
}
|
||||
|
||||
// 即时检索:防抖后走后台 tree 接口过滤(对齐 Web 300ms)
|
||||
watch(keyword, (val, oldVal) => {
|
||||
if (val === oldVal) return
|
||||
scheduleSearch()
|
||||
})
|
||||
|
||||
const switchStatus = (key) => {
|
||||
if (statusFilter.value === key) return
|
||||
statusFilter.value = key
|
||||
clearTimeout(searchTimer)
|
||||
loadTree({ status: key })
|
||||
}
|
||||
|
||||
const toggleExpandAll = () => {
|
||||
allExpanded.value = !allExpanded.value
|
||||
if (allExpanded.value) {
|
||||
expandedIds.value = collectExpandableIds(tree.value)
|
||||
} else {
|
||||
expandedIds.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const toggleNode = (row) => {
|
||||
if (!row.hasChildren) return
|
||||
const set = new Set(expandedIds.value)
|
||||
if (set.has(row.id)) set.delete(row.id)
|
||||
else set.add(row.id)
|
||||
expandedIds.value = Array.from(set)
|
||||
const allIds = collectExpandableIds(tree.value)
|
||||
allExpanded.value = allIds.length > 0 && allIds.every((id) => set.has(id))
|
||||
}
|
||||
|
||||
const goForm = (query = {}) => {
|
||||
const parts = Object.keys(query)
|
||||
.filter((k) => query[k] != null && query[k] !== '')
|
||||
.map((k) => `${k}=${encodeURIComponent(query[k])}`)
|
||||
const qs = parts.length ? `?${parts.join('&')}` : ''
|
||||
uni.navigateTo({ url: `${PAGE_PATHS.crmProductCategoryForm}${qs}` })
|
||||
}
|
||||
|
||||
const goAdd = () => goForm()
|
||||
|
||||
const onEmptyAction = () => {
|
||||
if (keyword.value.trim()) clearKeyword()
|
||||
else goAdd()
|
||||
}
|
||||
|
||||
const goAddChild = (row) => {
|
||||
goForm({ parentId: row.id, parentName: row.categoryName })
|
||||
}
|
||||
|
||||
const goEdit = (row) => {
|
||||
goForm({ id: row.id })
|
||||
}
|
||||
|
||||
const moreVisible = ref(false)
|
||||
const moreTarget = ref(null)
|
||||
|
||||
const closeMore = () => {
|
||||
moreVisible.value = false
|
||||
moreTarget.value = null
|
||||
}
|
||||
|
||||
const confirmDelete = (row) => {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: `是否确认删除「${row.categoryName}」?`,
|
||||
confirmColor: '#d14343',
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return
|
||||
try {
|
||||
await deleteProductCategory(row.id)
|
||||
uni.showToast({ title: '删除成功', icon: 'success' })
|
||||
loadTree()
|
||||
} catch (e) {
|
||||
uni.showToast({ title: e.message || '删除失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const openMore = (row) => {
|
||||
moreTarget.value = row
|
||||
moreVisible.value = true
|
||||
}
|
||||
|
||||
const onMoreAction = (action) => {
|
||||
const row = moreTarget.value
|
||||
if (!row) return
|
||||
closeMore()
|
||||
// 等关闭动画后再跳转/弹窗,避免层级冲突
|
||||
setTimeout(() => {
|
||||
if (action === 'edit') goEdit(row)
|
||||
else if (action === 'addChild') goAddChild(row)
|
||||
else if (action === 'delete') confirmDelete(row)
|
||||
}, 180)
|
||||
}
|
||||
|
||||
onShow(() => loadTree())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="mp-page mp-page--sub">
|
||||
<view class="mp-header">
|
||||
<view class="mp-header__row">
|
||||
<text class="mp-subpage-back" @click="goBack">← 工作台</text>
|
||||
<view class="cat-header-tools">
|
||||
<view class="mp-header__action" @click="toggleExpandAll">
|
||||
{{ allExpanded ? '全部折叠' : '全部展开' }}
|
||||
</view>
|
||||
<view class="mp-header__action" @click="goAdd">+ 新增</view>
|
||||
</view>
|
||||
</view>
|
||||
<text class="mp-header__title" style="margin-top: 16rpx">产品分类</text>
|
||||
<view class="mp-header__search">
|
||||
<view class="mp-header__search-dot" />
|
||||
<input
|
||||
v-model="keyword"
|
||||
class="mp-header__search-input"
|
||||
type="text"
|
||||
placeholder="搜索分类名称"
|
||||
confirm-type="search"
|
||||
@confirm="onSearch"
|
||||
/>
|
||||
<text v-if="keyword" class="cat-search-clear" @click="clearKeyword">清除</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view scroll-x class="cat-chips" :show-scrollbar="false">
|
||||
<view
|
||||
v-for="chip in statusChips"
|
||||
:key="chip.key || 'all'"
|
||||
class="cat-chip"
|
||||
:class="{ 'cat-chip--active': statusFilter === chip.key }"
|
||||
@click="switchStatus(chip.key)"
|
||||
>
|
||||
{{ chip.label }}
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="mp-section">
|
||||
<mp-empty
|
||||
v-if="!visibleRows.length && !loading"
|
||||
:title="keyword.trim() ? '未找到相关分类' : '暂无产品分类'"
|
||||
:description="
|
||||
keyword.trim()
|
||||
? '试试更短的关键词,或清除搜索后查看全部'
|
||||
: '还没有分类数据,可先新增一个顶级分类'
|
||||
"
|
||||
:action-text="keyword.trim() ? '清除搜索' : '新增分类'"
|
||||
@action="onEmptyAction"
|
||||
/>
|
||||
<view v-else class="cat-tree">
|
||||
<view
|
||||
v-for="(row, index) in visibleRows"
|
||||
:key="row.id"
|
||||
class="cat-row"
|
||||
:class="'cat-row--l' + Math.min(row.level, 2)"
|
||||
>
|
||||
<text class="cat-index">{{ index + 1 }}</text>
|
||||
<view
|
||||
class="cat-row__body"
|
||||
:style="{ paddingLeft: row.level * 36 + 'rpx' }"
|
||||
>
|
||||
<view
|
||||
class="cat-chevron"
|
||||
:class="{
|
||||
'cat-chevron--leaf': !row.hasChildren,
|
||||
'cat-chevron--open': expandedIds.includes(row.id)
|
||||
}"
|
||||
@click.stop="toggleNode(row)"
|
||||
>
|
||||
▶
|
||||
</view>
|
||||
<view class="cat-main" @click="goEdit(row)">
|
||||
<text class="cat-name">{{ row.categoryName }}</text>
|
||||
<view class="cat-meta">
|
||||
<text class="mp-tag" :class="'mp-tag--' + statusLabel(row.status).tag">
|
||||
{{ statusLabel(row.status).text }}
|
||||
</text>
|
||||
<text class="cat-meta__text">排序 {{ row.orderNum }}</text>
|
||||
<text v-if="row.hasChildren" class="cat-meta__text">{{ row.childCount }} 个子类</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="cat-actions">
|
||||
<view class="cat-icon-btn" @click.stop="goAddChild(row)">+</view>
|
||||
<view class="cat-icon-btn cat-icon-btn--more" @click.stop="openMore(row)">⋯</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="loading" class="mp-list-loading">加载中…</view>
|
||||
</view>
|
||||
|
||||
<!-- 更多操作:自定义底部菜单(替代系统 ActionSheet) -->
|
||||
<view
|
||||
v-if="moreVisible"
|
||||
class="cat-as-mask"
|
||||
@click="closeMore"
|
||||
@touchmove.stop.prevent
|
||||
>
|
||||
<view class="cat-as" @click.stop>
|
||||
<view class="cat-as__handle" />
|
||||
<view class="cat-as__head">
|
||||
<text class="cat-as__eyebrow">操作分类</text>
|
||||
<text class="cat-as__title">{{ moreTarget?.categoryName || '' }}</text>
|
||||
</view>
|
||||
<view class="cat-as__group">
|
||||
<view class="cat-as__item" @click="onMoreAction('edit')">
|
||||
<view class="cat-as__icon cat-as__icon--edit">编</view>
|
||||
<view class="cat-as__meta">
|
||||
<text class="cat-as__name">编辑</text>
|
||||
<text class="cat-as__desc">修改名称、上级、状态等</text>
|
||||
</view>
|
||||
<text class="cat-as__arrow">›</text>
|
||||
</view>
|
||||
<view class="cat-as__item" @click="onMoreAction('addChild')">
|
||||
<view class="cat-as__icon cat-as__icon--add">子</view>
|
||||
<view class="cat-as__meta">
|
||||
<text class="cat-as__name">新增子分类</text>
|
||||
<text class="cat-as__desc">在此分类下创建下级</text>
|
||||
</view>
|
||||
<text class="cat-as__arrow">›</text>
|
||||
</view>
|
||||
<view class="cat-as__item cat-as__item--danger" @click="onMoreAction('delete')">
|
||||
<view class="cat-as__icon cat-as__icon--del">删</view>
|
||||
<view class="cat-as__meta">
|
||||
<text class="cat-as__name">删除</text>
|
||||
<text class="cat-as__desc">有子分类或关联产品时不可删</text>
|
||||
</view>
|
||||
<text class="cat-as__arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="cat-as__cancel" @click="closeMore">取消</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.cat-header-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.cat-search-clear {
|
||||
flex-shrink: 0;
|
||||
font-size: 24rpx;
|
||||
color: var(--mp-brand-green-dark);
|
||||
padding: 8rpx 4rpx 8rpx 12rpx;
|
||||
}
|
||||
|
||||
.cat-chips {
|
||||
white-space: nowrap;
|
||||
padding: 20rpx 32rpx 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cat-chip {
|
||||
display: inline-block;
|
||||
margin-right: 16rpx;
|
||||
padding: 12rpx 24rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 24rpx;
|
||||
color: var(--mp-muted);
|
||||
background: var(--mp-surface);
|
||||
border: 1rpx solid var(--mp-border);
|
||||
}
|
||||
|
||||
.cat-chip--active {
|
||||
color: var(--mp-brand-green-dark);
|
||||
background: var(--mp-brand-green-soft);
|
||||
border-color: #b7e6d2;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cat-tree {
|
||||
background: var(--mp-surface);
|
||||
border: 1rpx solid var(--mp-border);
|
||||
border-radius: var(--mp-radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
min-height: 104rpx;
|
||||
padding: 20rpx 20rpx 20rpx 16rpx;
|
||||
border-bottom: 1rpx solid var(--mp-border-light);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.cat-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.cat-row--l1 {
|
||||
background: #fbfdff;
|
||||
}
|
||||
|
||||
.cat-row--l2 {
|
||||
background: #fafcfb;
|
||||
}
|
||||
|
||||
.cat-index {
|
||||
flex-shrink: 0;
|
||||
width: 44rpx;
|
||||
text-align: center;
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--mp-muted);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cat-row__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.cat-chevron {
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
border-radius: 12rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18rpx;
|
||||
color: var(--mp-muted);
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.cat-chevron--open {
|
||||
transform: rotate(90deg);
|
||||
color: var(--mp-brand-green);
|
||||
}
|
||||
|
||||
.cat-chevron--leaf {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.cat-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cat-name {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: var(--mp-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cat-meta {
|
||||
margin-top: 6rpx;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.cat-meta__text {
|
||||
font-size: 22rpx;
|
||||
color: var(--mp-muted);
|
||||
}
|
||||
|
||||
.cat-actions {
|
||||
display: flex;
|
||||
gap: 8rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cat-icon-btn {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 16rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: var(--mp-brand-green-dark);
|
||||
background: var(--mp-brand-green-soft);
|
||||
}
|
||||
|
||||
.cat-icon-btn--more {
|
||||
background: #f1f5f3;
|
||||
color: var(--mp-muted);
|
||||
font-size: 28rpx;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ── 更多操作 Action Sheet ── */
|
||||
.cat-as-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
background: rgba(15, 30, 25, 0.48);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
animation: cat-as-fade 0.2s ease;
|
||||
}
|
||||
|
||||
.cat-as {
|
||||
width: 100%;
|
||||
padding: 0 24rpx calc(20rpx + env(safe-area-inset-bottom, 0px));
|
||||
animation: cat-as-up 0.28s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
.cat-as__handle {
|
||||
width: 72rpx;
|
||||
height: 8rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
margin: 0 auto 16rpx;
|
||||
}
|
||||
|
||||
.cat-as__head {
|
||||
background: var(--mp-surface);
|
||||
border-radius: 28rpx 28rpx 0 0;
|
||||
padding: 28rpx 32rpx 20rpx;
|
||||
border-bottom: 1rpx solid var(--mp-border-light);
|
||||
}
|
||||
|
||||
.cat-as__eyebrow {
|
||||
display: block;
|
||||
font-size: 22rpx;
|
||||
color: var(--mp-muted);
|
||||
margin-bottom: 6rpx;
|
||||
}
|
||||
|
||||
.cat-as__title {
|
||||
display: block;
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: var(--mp-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cat-as__group {
|
||||
background: var(--mp-surface);
|
||||
border-radius: 0 0 28rpx 28rpx;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.cat-as__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
min-height: 112rpx;
|
||||
padding: 24rpx 28rpx;
|
||||
border-bottom: 1rpx solid var(--mp-border-light);
|
||||
}
|
||||
|
||||
.cat-as__item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.cat-as__item:active {
|
||||
background: #f7fbf9;
|
||||
}
|
||||
|
||||
.cat-as__icon {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cat-as__icon--edit {
|
||||
color: var(--mp-brand-green-dark);
|
||||
background: var(--mp-brand-green-soft);
|
||||
}
|
||||
|
||||
.cat-as__icon--add {
|
||||
color: #087f57;
|
||||
background: #e9f9f2;
|
||||
}
|
||||
|
||||
.cat-as__icon--del {
|
||||
color: #d14343;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.cat-as__meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cat-as__name {
|
||||
display: block;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: var(--mp-text);
|
||||
}
|
||||
|
||||
.cat-as__item--danger .cat-as__name {
|
||||
color: #d14343;
|
||||
}
|
||||
|
||||
.cat-as__desc {
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
font-size: 22rpx;
|
||||
color: var(--mp-muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.cat-as__arrow {
|
||||
font-size: 36rpx;
|
||||
color: #c5d5cd;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cat-as__cancel {
|
||||
background: var(--mp-surface);
|
||||
border-radius: 28rpx;
|
||||
min-height: 100rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: var(--mp-muted);
|
||||
}
|
||||
|
||||
.cat-as__cancel:active {
|
||||
background: #f3f7f5;
|
||||
}
|
||||
|
||||
@keyframes cat-as-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cat-as-up {
|
||||
from {
|
||||
transform: translateY(24rpx);
|
||||
opacity: 0.6;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -105,10 +105,7 @@ onReachBottom(() => loadList(false))
|
||||
</view>
|
||||
|
||||
<view class="mp-section">
|
||||
<view v-if="!list.length && !loading" class="mp-empty">
|
||||
<view class="mp-empty__icon" />
|
||||
<text class="mp-empty__text">暂无报价</text>
|
||||
</view>
|
||||
<mp-empty v-if="!list.length && !loading" title="暂无报价" />
|
||||
<view v-else class="mp-list">
|
||||
<view v-for="row in list" :key="row.id" class="mp-card" @click="goDetail(row)">
|
||||
<view class="mp-card__head">
|
||||
|
||||
@@ -107,10 +107,7 @@ onShow(() => load())
|
||||
</view>
|
||||
|
||||
<view class="mp-section">
|
||||
<view v-if="!list.length && !loading" class="mp-empty">
|
||||
<view class="mp-empty__icon" />
|
||||
<text class="mp-empty__text">暂无出入库单</text>
|
||||
</view>
|
||||
<mp-empty v-if="!list.length && !loading" title="暂无出入库单" />
|
||||
<view v-else class="mp-list">
|
||||
<view v-for="row in list" :key="row.id" class="mp-card" @click="goDetail(row)">
|
||||
<view class="mp-card__head">
|
||||
|
||||
@@ -100,9 +100,7 @@ onShow(() => load())
|
||||
|
||||
<view class="mp-block">
|
||||
<text class="mp-block__title">关联出入库({{ related.inventories?.length || 0 }})</text>
|
||||
<view v-if="!related.inventories?.length" class="mp-empty" style="padding: 32rpx 0">
|
||||
<text class="mp-empty__text">暂无出入库单</text>
|
||||
</view>
|
||||
<mp-empty v-if="!related.inventories?.length" compact title="暂无出入库单" />
|
||||
<view v-else class="mp-list">
|
||||
<view
|
||||
v-for="inv in related.inventories"
|
||||
@@ -118,9 +116,7 @@ onShow(() => load())
|
||||
|
||||
<view class="mp-block">
|
||||
<text class="mp-block__title">关联地磅单({{ related.weighings?.length || 0 }})</text>
|
||||
<view v-if="!related.weighings?.length" class="mp-empty" style="padding: 32rpx 0">
|
||||
<text class="mp-empty__text">暂无地磅单</text>
|
||||
</view>
|
||||
<mp-empty v-if="!related.weighings?.length" compact title="暂无地磅单" />
|
||||
<view v-else class="mp-list">
|
||||
<view
|
||||
v-for="w in related.weighings"
|
||||
|
||||
@@ -87,10 +87,7 @@ onShow(() => load())
|
||||
</view>
|
||||
|
||||
<view class="mp-section">
|
||||
<view v-if="!list.length && !loading" class="mp-empty">
|
||||
<view class="mp-empty__icon" />
|
||||
<text class="mp-empty__text">暂无计划</text>
|
||||
</view>
|
||||
<mp-empty v-if="!list.length && !loading" title="暂无计划" />
|
||||
<view v-else class="mp-list">
|
||||
<view v-for="row in list" :key="row.id" class="mp-card" @click="goDetail(row)">
|
||||
<view class="mp-card__head">
|
||||
|
||||
@@ -137,10 +137,7 @@ onShow(() => load())
|
||||
</view>
|
||||
|
||||
<view class="mp-section">
|
||||
<view v-if="!list.length && !loading" class="mp-empty">
|
||||
<view class="mp-empty__icon" />
|
||||
<text class="mp-empty__text">暂无地磅单</text>
|
||||
</view>
|
||||
<mp-empty v-if="!list.length && !loading" title="暂无地磅单" />
|
||||
<view v-else class="mp-list">
|
||||
<view v-for="row in list" :key="row.id" class="mp-card" @click="goDetail(row)">
|
||||
<view class="mp-card__head">
|
||||
|
||||
@@ -81,10 +81,7 @@ onShow(() => load(true))
|
||||
</view>
|
||||
|
||||
<view class="mp-section">
|
||||
<view v-if="!list.length && !loading" class="mp-empty">
|
||||
<view class="mp-empty__icon" />
|
||||
<text class="mp-empty__text">暂无已归档计划</text>
|
||||
</view>
|
||||
<mp-empty v-if="!list.length && !loading" title="暂无已归档计划" />
|
||||
<view v-else class="mp-list">
|
||||
<view v-for="row in list" :key="row.id" class="mp-card" @click="goDetail(row)">
|
||||
<view class="mp-card__head">
|
||||
|
||||
@@ -114,9 +114,7 @@ onShow(() => load())
|
||||
|
||||
<view class="mp-block">
|
||||
<text class="mp-block__title">产品购买证</text>
|
||||
<view v-if="!products.length" class="mp-empty" style="padding: 32rpx 0">
|
||||
<text class="mp-empty__text">暂无产品明细</text>
|
||||
</view>
|
||||
<mp-empty v-if="!products.length" compact title="暂无产品明细" />
|
||||
<view v-for="p in products" :key="p.id" class="mp-block__panel" style="margin-bottom: 24rpx">
|
||||
<view class="mp-kv">
|
||||
<text class="mp-kv__label">{{ dash(p.productName) }}</text>
|
||||
|
||||
@@ -118,10 +118,7 @@ onShow(() => load(true))
|
||||
</view>
|
||||
|
||||
<view class="mp-section">
|
||||
<view v-if="!list.length && !loading" class="mp-empty">
|
||||
<view class="mp-empty__icon" />
|
||||
<text class="mp-empty__text">暂无计划</text>
|
||||
</view>
|
||||
<mp-empty v-if="!list.length && !loading" title="暂无计划" />
|
||||
<view v-else class="mp-list">
|
||||
<view v-for="row in list" :key="row.id" class="mp-card" @click="goDetail(row)">
|
||||
<view class="mp-card__head">
|
||||
|
||||
@@ -106,10 +106,7 @@ onShow(() => load(true))
|
||||
</view>
|
||||
|
||||
<view class="mp-section">
|
||||
<view v-if="!list.length && !loading" class="mp-empty">
|
||||
<view class="mp-empty__icon" />
|
||||
<text class="mp-empty__text">暂无审批单据</text>
|
||||
</view>
|
||||
<mp-empty v-if="!list.length && !loading" title="暂无审批单据" />
|
||||
<view v-else class="mp-list">
|
||||
<view v-for="row in list" :key="row.id" class="mp-card" @click="goDetail(row)">
|
||||
<view class="mp-card__head">
|
||||
|
||||
@@ -173,10 +173,10 @@ onShow(() => load(true))
|
||||
</view>
|
||||
|
||||
<view class="mp-section">
|
||||
<view v-if="!list.length && !loading" class="mp-empty">
|
||||
<view class="mp-empty__icon" />
|
||||
<text class="mp-empty__text">{{ activeTab === 'unsettled' ? '暂无待结算计划' : '暂无结算单' }}</text>
|
||||
</view>
|
||||
<mp-empty
|
||||
v-if="!list.length && !loading"
|
||||
:title="activeTab === 'unsettled' ? '暂无待结算计划' : '暂无结算单'"
|
||||
/>
|
||||
<view v-else class="mp-list">
|
||||
<!-- 结算单 -->
|
||||
<template v-if="activeTab === 'bills'">
|
||||
|
||||
@@ -147,9 +147,7 @@ onShow(() => load())
|
||||
|
||||
<view class="mp-block">
|
||||
<text class="mp-block__title">产品明细</text>
|
||||
<view v-if="!products.length" class="mp-empty" style="padding: 32rpx 0">
|
||||
<text class="mp-empty__text">暂无明细</text>
|
||||
</view>
|
||||
<mp-empty v-if="!products.length" compact title="暂无明细" />
|
||||
<view v-else class="mp-block__panel">
|
||||
<view v-for="p in products" :key="p.id" class="mp-kv">
|
||||
<text class="mp-kv__label">{{ dash(p.productName) }}</text>
|
||||
|
||||
@@ -131,10 +131,7 @@ onShow(() => load(true))
|
||||
</view>
|
||||
|
||||
<view class="mp-section">
|
||||
<view v-if="!list.length && !loading" class="mp-empty">
|
||||
<view class="mp-empty__icon" />
|
||||
<text class="mp-empty__text">暂无运输计划</text>
|
||||
</view>
|
||||
<mp-empty v-if="!list.length && !loading" title="暂无运输计划" />
|
||||
<view v-else class="mp-list">
|
||||
<view v-for="row in list" :key="row.id" class="mp-card">
|
||||
<view class="mp-card__head" @click="goDetail(row)">
|
||||
|
||||
@@ -220,20 +220,167 @@
|
||||
padding: 24rpx 0 8rpx;
|
||||
}
|
||||
|
||||
/* 全局空态 · 方案 B 插画文件夹(组件 mp-empty;类名可复用) */
|
||||
.mp-empty {
|
||||
padding: 80rpx 32rpx;
|
||||
padding: 80rpx 40rpx 64rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mp-empty--compact {
|
||||
padding: 40rpx 24rpx 32rpx;
|
||||
}
|
||||
|
||||
.mp-empty__art {
|
||||
position: relative;
|
||||
width: 296rpx;
|
||||
height: 224rpx;
|
||||
margin: 0 auto 36rpx;
|
||||
}
|
||||
|
||||
.mp-empty--compact .mp-empty__art {
|
||||
width: 220rpx;
|
||||
height: 168rpx;
|
||||
margin-bottom: 24rpx;
|
||||
transform: scale(0.92);
|
||||
transform-origin: center top;
|
||||
}
|
||||
|
||||
.mp-empty__shadow {
|
||||
position: absolute;
|
||||
left: 48rpx;
|
||||
right: 48rpx;
|
||||
bottom: 12rpx;
|
||||
height: 24rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(15, 30, 25, 0.08);
|
||||
}
|
||||
|
||||
.mp-empty__folder {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 20rpx;
|
||||
transform: translateX(-50%);
|
||||
width: 216rpx;
|
||||
height: 156rpx;
|
||||
}
|
||||
|
||||
.mp-empty__folder-tab {
|
||||
position: absolute;
|
||||
left: 20rpx;
|
||||
top: -20rpx;
|
||||
width: 76rpx;
|
||||
height: 32rpx;
|
||||
border-radius: 16rpx 16rpx 0 0;
|
||||
background: #87d4b0;
|
||||
}
|
||||
|
||||
.mp-empty__folder-back {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 24rpx 24rpx 28rpx 28rpx;
|
||||
background: linear-gradient(160deg, #b7e6d2, #7dcea0);
|
||||
}
|
||||
|
||||
.mp-empty__folder-front {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 116rpx;
|
||||
border-radius: 20rpx 28rpx 28rpx 28rpx;
|
||||
background: linear-gradient(180deg, #ecfdf5, #dff7ec);
|
||||
border: 2rpx solid rgba(8, 127, 87, 0.18);
|
||||
box-shadow: 0 16rpx 36rpx rgba(10, 159, 109, 0.12);
|
||||
}
|
||||
|
||||
.mp-empty__lines {
|
||||
position: absolute;
|
||||
left: 36rpx;
|
||||
right: 36rpx;
|
||||
top: 36rpx;
|
||||
}
|
||||
|
||||
.mp-empty__line {
|
||||
display: block;
|
||||
height: 12rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #cfe9dc;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.mp-empty__line--mid {
|
||||
width: 70%;
|
||||
}
|
||||
|
||||
.mp-empty__line--short {
|
||||
width: 48%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.mp-empty__title {
|
||||
display: block;
|
||||
font-size: 30rpx;
|
||||
font-weight: 650;
|
||||
color: var(--mp-text);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.mp-empty__desc {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
font-size: 24rpx;
|
||||
color: var(--mp-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.mp-empty__actions {
|
||||
margin-top: 32rpx;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.mp-empty__actions .mp-btn {
|
||||
min-width: 200rpx;
|
||||
}
|
||||
|
||||
/* 兼容旧写法:仅圆点 icon 时也渲染为文件夹插画占位(无内部结构则显示浅绿底) */
|
||||
.mp-empty__icon {
|
||||
width: 140rpx;
|
||||
height: 140rpx;
|
||||
margin: 0 auto 24rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(145deg, #edf5f1, #dcebe4);
|
||||
border-radius: 28rpx;
|
||||
background: linear-gradient(160deg, #b7e6d2, #7dcea0);
|
||||
position: relative;
|
||||
box-shadow: 0 12rpx 28rpx rgba(10, 159, 109, 0.12);
|
||||
}
|
||||
|
||||
.mp-empty__icon::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 20rpx;
|
||||
top: -12rpx;
|
||||
width: 48rpx;
|
||||
height: 20rpx;
|
||||
border-radius: 10rpx 10rpx 0 0;
|
||||
background: #87d4b0;
|
||||
}
|
||||
|
||||
.mp-empty__icon::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 12rpx;
|
||||
right: 12rpx;
|
||||
bottom: 16rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 14rpx 18rpx 18rpx 18rpx;
|
||||
background: linear-gradient(180deg, #ecfdf5, #dff7ec);
|
||||
border: 2rpx solid rgba(8, 127, 87, 0.16);
|
||||
}
|
||||
|
||||
.mp-empty__text {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
color: var(--mp-muted);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { API_BASE, TOKEN_KEY, REFRESH_TOKEN_KEY, USER_INFO_KEY } from '@/config/
|
||||
let clearingAuth = false
|
||||
// TODO(config-hardcoded): 以下加密参数为临时写死,后续应迁移至构建期注入或安全配置下发。
|
||||
// 写死配置:不从环境变量读取,避免小程序端注入差异导致行为不一致。
|
||||
let API_ENCRYPT_ENABLED = true
|
||||
// 移动端(H5 + 微信小程序)统一走明文 JSON,便于联调排查入参;Web 管理端仍可单独开加密。
|
||||
const API_ENCRYPT_ENABLED = false
|
||||
const API_ENCRYPT_FAIL_OPEN = false
|
||||
const API_ENCRYPT_FIXED_SECRET = 'shanxia-web-api-crypto-fixed-secret'
|
||||
// TODO(config-hardcoded): 排除路径清单后续改为集中配置,避免多端重复维护。
|
||||
@@ -14,11 +15,6 @@ const API_ENCRYPT_EXCLUDE_PATHS = String(
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
// 微信小程序运行时不保证 WebCrypto(getRandomValues/subtle),关闭前端加密避免全链路请求失败。
|
||||
API_ENCRYPT_ENABLED = false
|
||||
// #endif
|
||||
|
||||
function appendTimestampQuery(url) {
|
||||
const base = String(url || '')
|
||||
const ts = Date.now()
|
||||
|
||||
Reference in new issue
Block a user