This commit is contained in:
zlwu committed 2026-07-18 09:14:02 +08:00
1 parent a903fcf6d4
commit 259c55cc23
80 files changed
+6090 -123

No files matched your search

+23
View File
@@ -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>
+3
View File
@@ -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,
+1 -1
View File
@@ -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' },
+8
View File
@@ -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 }
+1 -4
View File
@@ -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">
+1 -4
View File
@@ -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">
+1 -4
View File
@@ -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">
+1 -4
View File
@@ -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">
+1 -4
View File
@@ -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">
+1 -3
View File
@@ -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">
+1 -4
View File
@@ -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">
+1 -4
View File
@@ -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">
+1 -3
View File
@@ -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">
+1 -4
View File
@@ -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">
+1 -3
View File
@@ -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">
+1 -4
View File
@@ -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">
+1 -4
View File
@@ -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>
+1 -4
View File
@@ -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">
+1 -4
View File
@@ -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">
+2 -6
View File
@@ -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"
+1 -4
View File
@@ -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">
+1 -4
View File
@@ -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">
+1 -4
View File
@@ -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">
+1 -3
View File
@@ -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>
+1 -4
View File
@@ -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">
+1 -4
View File
@@ -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">
+4 -4
View File
@@ -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'">
+1 -3
View File
@@ -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>
+1 -4
View File
@@ -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)">
+150 -3
View File
@@ -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);
}
+2 -6
View File
@@ -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()