This commit is contained in:
zlwu committed 2026-08-09 21:07:28 +08:00
1 parent b45ce2c32c
commit a5210a6a9e
30 files changed
+11989 -5917

No files matched your search

+10 -5
View File
@@ -60,10 +60,12 @@ const emit = defineEmits<{
}>()
const fileList = ref<UploadFile[]>([])
/** 本地 URL 列表,避免多文件并行上传完成时基于 stale props 互相覆盖 */
const localUrls = ref<string[]>([])
const isDirectUrl = (value: string) => /^https?:\/\//i.test(value)
const displayItems = computed(() => parseValue(props.modelValue))
const displayItems = computed(() => localUrls.value)
const fileAccept = computed(() => props.fileType.map((type) => `.${type}`).join(','))
@@ -83,7 +85,7 @@ function listToString(items: string[]) {
}
function syncFileListFromModel() {
fileList.value = displayItems.value.map((url, index) => ({
fileList.value = localUrls.value.map((url, index) => ({
name: url.split('/').pop() || `file-${index + 1}`,
url,
status: 'success'
@@ -92,7 +94,8 @@ function syncFileListFromModel() {
watch(
() => props.modelValue,
() => {
(value) => {
localUrls.value = parseValue(value)
if (props.disabled) return
syncFileListFromModel()
},
@@ -124,7 +127,8 @@ async function handleUploadRequest(options: UploadRequestOptions) {
if (!fileUrl) {
throw new Error('上传失败')
}
const next = [...displayItems.value, fileUrl]
const next = [...localUrls.value, fileUrl]
localUrls.value = next
emit('update:modelValue', listToString(next))
options.onSuccess?.(res)
} catch (error: any) {
@@ -136,7 +140,8 @@ async function handleUploadRequest(options: UploadRequestOptions) {
function handleRemove(uploadFile: UploadFile) {
const removedUrl = uploadFile.url || (uploadFile.response as { fileUrl?: string })?.fileUrl
if (!removedUrl) return
const next = displayItems.value.filter((url) => url !== removedUrl)
const next = localUrls.value.filter((url) => url !== removedUrl)
localUrls.value = next
emit('update:modelValue', listToString(next))
}
@@ -123,7 +123,7 @@
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
Plus,
Loading,
@@ -177,13 +177,23 @@
const isPointerInside = ref(false)
const isDragOver = ref(false)
let dragEnterDepth = 0
let clearUploadScheduled = false
/**
* 本地缓冲:el-upload 多选会同步连续触发 @change;
* 若基于尚未回写的 props.modelValue 展开累加,会只留下最后一个文件。
*/
const files = ref<BxAttachmentItem[]>([])
const files = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
watch(
() => props.modelValue,
(val) => {
files.value = Array.isArray(val) ? val.slice() : []
},
{ immediate: true }
)
function syncFiles(next: BxAttachmentItem[]) {
files.value = next
emit('update:modelValue', next)
}
@@ -256,12 +266,19 @@
}
}
async function handleAttachmentChange(uploadFile: UploadFile) {
const rawFile = uploadFile.raw
if (!rawFile) return
await uploadRawFile(rawFile)
async function scheduleClearUploadFiles() {
if (clearUploadScheduled) return
clearUploadScheduled = true
await nextTick()
uploadRef.value?.clearFiles()
clearUploadScheduled = false
}
function handleAttachmentChange(uploadFile: UploadFile) {
const rawFile = uploadFile.raw
if (!rawFile) return
void uploadRawFile(rawFile)
void scheduleClearUploadFiles()
}
function handlePointerEnter() {
@@ -95,12 +95,16 @@
const isPointerInside = ref(false)
const isDragOver = ref(false)
let dragEnterDepth = 0
let clearUploadScheduled = false
const previewUrlMap = ref<Record<string, string>>({})
const files = computed(() => props.modelValue || [])
/**
* 本地缓冲:el-upload 多选时会同步连续触发多次 @change,
* 若每次都基于尚未回写的 props.modelValue 累加,最终只会留下最后一个文件。
*/
const localFiles = ref<File[]>([])
const displayItems = computed<DisplayItem[]>(() =>
files.value.map((file, index) => {
localFiles.value.map((file, index) => {
const key = `${file.name}-${file.size}-${file.lastModified}-${index}`
return {
key,
@@ -119,7 +123,7 @@
function rebuildPreviews() {
revokeAllPreviews()
const next: Record<string, string> = {}
files.value.forEach((file, index) => {
localFiles.value.forEach((file, index) => {
if (!file.type.startsWith('image/')) return
const key = `${file.name}-${file.size}-${file.lastModified}-${index}`
next[key] = URL.createObjectURL(file)
@@ -129,12 +133,17 @@
watch(
() => props.modelValue,
() => rebuildPreviews(),
{ deep: true, immediate: true }
(val) => {
localFiles.value = Array.isArray(val) ? val.slice() : []
rebuildPreviews()
},
{ immediate: true }
)
function syncFiles(next: File[]) {
localFiles.value = next
emit('update:modelValue', next)
rebuildPreviews()
}
function isFileAccepted(file: File) {
@@ -181,7 +190,7 @@
function appendFiles(incoming: File[]) {
if (!incoming.length || props.disabled) return
const next = [...files.value]
const next = [...localFiles.value]
for (const raw of incoming) {
if (next.length >= props.limit) {
ElMessage.warning(`最多上传 ${props.limit} 个文件`)
@@ -196,16 +205,23 @@
syncFiles(next)
}
async function onChange(uploadFile: UploadFile) {
async function scheduleClearUploadFiles() {
if (clearUploadScheduled) return
clearUploadScheduled = true
await nextTick()
uploadRef.value?.clearFiles()
clearUploadScheduled = false
}
function onChange(uploadFile: UploadFile) {
const raw = uploadFile.raw
if (!raw) return
appendFiles([raw])
await nextTick()
uploadRef.value?.clearFiles()
void scheduleClearUploadFiles()
}
function removeAt(index: number) {
const next = [...files.value]
const next = [...localFiles.value]
next.splice(index, 1)
syncFiles(next)
}
@@ -21,6 +21,7 @@
type="button"
class="invoice-attach-card"
:class="{ 'is-selected': selectedKeys.has(item.key) }"
:style="{ '--invoice-group-color': invoiceGroupColor(item.invoiceId) }"
@click="toggleOne(item.key)"
>
<span class="invoice-attach-card__index">{{ item.serial }}</span>
@@ -48,7 +49,7 @@
</span>
<div class="invoice-attach-card__footer">
<span class="invoice-attach-card__amount">
{{ formatMoneyYuan(item.amount) }} 元
整票 {{ formatMoneyYuan(item.amount) }} 元
</span>
<el-icon
class="invoice-attach-card__download"
@@ -179,6 +180,25 @@
return `${y}年${Number(m)}月`
}
/** 同一发票(同一次上传)共用边框色,不同发票互相区分 */
const INVOICE_GROUP_COLORS = [
'#0891b2',
'#7c3aed',
'#d97706',
'#db2777',
'#2563eb',
'#059669',
'#ea580c',
'#4f46e5'
]
function invoiceGroupColor(invoiceId: string | number) {
const s = String(invoiceId || '')
let hash = 0
for (let i = 0; i < s.length; i++) hash = (hash * 31 + s.charCodeAt(i)) >>> 0
return INVOICE_GROUP_COLORS[hash % INVOICE_GROUP_COLORS.length]
}
function invoiceAttachments(row: any): BxAttachmentItem[] {
const list = Array.isArray(row?.attachments) ? row.attachments : []
if (list.length) {
@@ -314,37 +334,34 @@
position: relative;
width: 140px;
padding: 0;
border: 1px solid var(--border, #d9e8e2);
border: 2px solid var(--invoice-group-color, var(--border, #d9e8e2));
border-radius: 8px;
background: var(--surface, #fff);
overflow: hidden;
cursor: pointer;
text-align: left;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
transition: box-shadow 0.15s ease;
&:hover {
border-color: var(--primary, #10b981);
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.12);
}
&.is-selected {
border-color: var(--primary, #10b981);
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2);
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08);
}
}
.invoice-attach-card__index {
position: absolute;
z-index: 1;
top: 6px;
left: 8px;
font-size: 12px;
z-index: 2;
top: 0;
left: 0;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 8px 0 8px 0;
background: #ef4444;
color: #fff;
font-size: 11px;
font-weight: 600;
line-height: 1.2;
color: var(--primary, #10b981);
background: none;
line-height: 20px;
text-align: center;
}
.invoice-attach-card__preview {
+239 -213
View File
@@ -2,7 +2,7 @@ import type { AppRouteRecord } from '@/types/router'
/**
* 前端路由(报销管理、CRM 管理、地磅管理、工作流管理、系统管理;运输见 pl.ts)
* 与后端菜单对齐时请同步 `sys_menu`(bx 见 docs/sql/20260606_bx_menu.sql;db 见 docs/sql/20260607_db_menu.sql)。
* 与后端菜单对齐时请同步 `sys_menu`(bx / db 见历史迁移;系统管理二级分组见 docs/sql/migrations/2026-08-09-system-menu-groups.sql)。
*/
export const sqxpAdminRoutes: AppRouteRecord[] = [
// ──────────────────────────────────────────────
@@ -608,222 +608,248 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
},
children: [
{
id: '101',
name: 'Tenant',
path: 'tenant',
component: '/system/tenant/index',
id: 'SYSM000000000000000000000000000010',
name: 'SystemInfo',
path: 'info',
meta: {
title: '租户管理',
keepAlive: true,
menuCode: 'system:tenant:list'
}
title: '系统信息',
keepAlive: false
},
children: [
{
id: '101',
name: 'Tenant',
path: 'tenant',
component: '/system/tenant/index',
meta: {
title: '租户管理',
keepAlive: true,
menuCode: 'system:tenant:list'
}
},
{
id: '102',
name: 'Dict',
path: 'dict',
component: '/system/dict/index',
meta: {
title: '字典管理',
keepAlive: true,
menuCode: 'system:dict:list'
}
},
{
id: '106',
name: 'Menu',
path: 'menu',
component: '/system/menu/index',
meta: {
title: '菜单管理',
keepAlive: true,
menuCode: 'system:menu:list'
}
},
{
id: '103',
name: 'Dept',
path: 'dept',
component: '/system/dept/index',
meta: {
title: '组织管理',
keepAlive: true,
menuCode: 'system:dept:list'
}
},
{
id: '105',
name: 'Role',
path: 'role',
component: '/system/role/index',
meta: {
title: '角色管理',
keepAlive: true,
menuCode: 'system:role:list'
}
},
{
id: '104',
name: 'User',
path: 'user',
component: '/system/user/index',
meta: {
title: '用户管理',
keepAlive: true,
menuCode: 'system:user:list'
}
},
{
id: '108',
name: 'Post',
path: 'post',
component: '/system/post/index',
meta: {
title: '职位管理',
keepAlive: true,
menuCode: 'system:post:list'
}
},
{
id: 'SYS_OPERLOG',
name: 'SystemOperlog',
path: 'operlog',
component: '/system/operlog/index',
meta: {
title: '操作日志',
keepAlive: true,
menuCode: 'system:operlog:list'
}
},
{
id: '10000000000000000000000000000008',
name: 'UserDetail',
path: 'user/detail',
component: '/system/user/detail',
meta: { title: '用户详情', isHide: true, keepAlive: true }
}
]
},
{
id: '102',
name: 'Dict',
path: 'dict',
component: '/system/dict/index',
id: 'SYSM000000000000000000000000000011',
name: 'SystemTemp',
path: 'temp',
meta: {
title: '字典管理',
keepAlive: true,
menuCode: 'system:dict:list'
}
},
{
id: '106',
name: 'Menu',
path: 'menu',
component: '/system/menu/index',
meta: {
title: '菜单管理',
keepAlive: true,
menuCode: 'system:menu:list'
}
},
{
id: '103',
name: 'Dept',
path: 'dept',
component: '/system/dept/index',
meta: {
title: '组织管理',
keepAlive: true,
menuCode: 'system:dept:list'
}
},
{
id: '105',
name: 'Role',
path: 'role',
component: '/system/role/index',
meta: {
title: '角色管理',
keepAlive: true,
menuCode: 'system:role:list'
}
},
{
id: '104',
name: 'User',
path: 'user',
component: '/system/user/index',
meta: {
title: '用户管理',
keepAlive: true,
menuCode: 'system:user:list'
}
},
{
id: '108',
name: 'Post',
path: 'post',
component: '/system/post/index',
meta: {
title: '职位管理',
keepAlive: true,
menuCode: 'system:post:list'
}
},
{
id: 'SYS_OPERLOG',
name: 'SystemOperlog',
path: 'operlog',
component: '/system/operlog/index',
meta: {
title: '操作日志',
keepAlive: true,
menuCode: 'system:operlog:list'
}
},
{
id: 'SYS_APPROVER_SETTING',
name: 'SystemApproverSetting',
path: 'approver-setting',
component: '/system/approver-setting/index',
meta: {
title: '报价、合同审批人设置',
keepAlive: true,
menuCode: 'system:approverSetting'
}
},
{
id: 'CRM1000000000000000000000000010',
name: 'CrmClue',
path: 'clue',
component: '/crm/clue/index',
meta: { title: '线索管理', keepAlive: true, menuCode: 'crm:clue:list' }
},
{
id: 'CRM1000000000000000000000000011',
name: 'CrmClueDetail',
path: 'clue/detail/:id',
component: '/crm/clue/detail',
meta: {
title: '线索详情',
keepAlive: false,
isHide: true,
menuCode: 'crm:clue:query'
}
},
{
id: 'CRM1000000000000000000000000012',
name: 'CrmClueStatistics',
path: 'clue/statistics',
component: '/crm/clue/statistics/index',
meta: {
title: '线索统计',
keepAlive: true,
isHide: true,
menuCode: 'crm:clue:statistics'
}
},
{
id: 'CRM2000000000000000000000000010',
name: 'CrmOpportunity',
path: 'opportunity',
component: '/crm/opportunity/index',
meta: { title: '商机管理', keepAlive: true, menuCode: 'crm:opportunity:list' }
},
{
id: 'CRM2000000000000000000000000011',
name: 'CrmOpportunityDetail',
path: 'opportunity/detail/:id',
component: '/crm/opportunity/detail',
meta: {
title: '商机详情',
keepAlive: false,
isHide: true,
menuCode: 'crm:opportunity:query'
}
},
{
id: 'CRM2000000000000000000000000012',
name: 'CrmOpportunityKanban',
path: 'opportunity/kanban',
component: '/crm/opportunity/kanban',
meta: {
title: '商机看板',
keepAlive: true,
isHide: true,
menuCode: 'crm:opportunity:list'
}
},
{
id: 'CRM2000000000000000000000000013',
name: 'CrmOpportunityFunnel',
path: 'opportunity/funnel',
component: '/crm/opportunity/funnel',
meta: {
title: '销售漏斗',
keepAlive: true,
isHide: true,
menuCode: 'crm:opportunity:list'
}
},
{
id: 'CRM2000000000000000000000000014',
name: 'CrmOpportunityStatistics',
path: 'opportunity/statistics',
component: '/crm/opportunity/statistics',
meta: {
title: '商机统计',
keepAlive: true,
isHide: true,
menuCode: 'crm:opportunity:list'
}
},
{
id: 'CRM1000000000000000000000000003',
name: 'CrmCustomerPool',
path: 'customer-pool',
component: '/crm/customer/pool/index',
meta: { title: '公海池', keepAlive: true, menuCode: 'crm:customerPool:list' }
},
{
id: 'CRM1000000000000000000000000004',
name: 'CrmPoolRule',
path: 'pool-rule',
component: '/crm/customer/pool/RuleIndex',
meta: {
title: '公海池规则',
keepAlive: true,
isHide: true,
menuCode: 'crm:poolRule:list'
}
},
{
id: 'CRM3000000000000000000000000040',
name: 'CrmSupplierHazard',
path: 'supplier-hazard',
component: '/crm/supplier-hazard/index',
meta: { title: '主体危化品能力', keepAlive: true, menuCode: 'crm:supplierHazard:list' }
},
{
id: '10000000000000000000000000000008',
name: 'UserDetail',
path: 'user/detail',
component: '/system/user/detail',
meta: { title: '用户详情', isHide: true, keepAlive: true }
title: '临时存放',
keepAlive: false
},
children: [
{
id: 'SYS_APPROVER_SETTING',
name: 'SystemApproverSetting',
path: 'approver-setting',
component: '/system/approver-setting/index',
meta: {
title: '报价、合同审批人设置',
keepAlive: true,
menuCode: 'system:approverSetting'
}
},
{
id: 'CRM3000000000000000000000000040',
name: 'CrmSupplierHazard',
path: 'supplier-hazard',
component: '/crm/supplier-hazard/index',
meta: {
title: '主体危化品能力',
keepAlive: true,
menuCode: 'crm:supplierHazard:list'
}
},
{
id: 'CRM1000000000000000000000000010',
name: 'CrmClue',
path: 'clue',
component: '/crm/clue/index',
meta: { title: '线索管理', keepAlive: true, menuCode: 'crm:clue:list' }
},
{
id: 'CRM1000000000000000000000000011',
name: 'CrmClueDetail',
path: 'clue/detail/:id',
component: '/crm/clue/detail',
meta: {
title: '线索详情',
keepAlive: false,
isHide: true,
menuCode: 'crm:clue:query'
}
},
{
id: 'CRM1000000000000000000000000012',
name: 'CrmClueStatistics',
path: 'clue/statistics',
component: '/crm/clue/statistics/index',
meta: {
title: '线索统计',
keepAlive: true,
isHide: true,
menuCode: 'crm:clue:statistics'
}
},
{
id: 'CRM2000000000000000000000000010',
name: 'CrmOpportunity',
path: 'opportunity',
component: '/crm/opportunity/index',
meta: { title: '商机管理', keepAlive: true, menuCode: 'crm:opportunity:list' }
},
{
id: 'CRM2000000000000000000000000011',
name: 'CrmOpportunityDetail',
path: 'opportunity/detail/:id',
component: '/crm/opportunity/detail',
meta: {
title: '商机详情',
keepAlive: false,
isHide: true,
menuCode: 'crm:opportunity:query'
}
},
{
id: 'CRM2000000000000000000000000012',
name: 'CrmOpportunityKanban',
path: 'opportunity/kanban',
component: '/crm/opportunity/kanban',
meta: {
title: '商机看板',
keepAlive: true,
isHide: true,
menuCode: 'crm:opportunity:list'
}
},
{
id: 'CRM2000000000000000000000000013',
name: 'CrmOpportunityFunnel',
path: 'opportunity/funnel',
component: '/crm/opportunity/funnel',
meta: {
title: '销售漏斗',
keepAlive: true,
isHide: true,
menuCode: 'crm:opportunity:list'
}
},
{
id: 'CRM2000000000000000000000000014',
name: 'CrmOpportunityStatistics',
path: 'opportunity/statistics',
component: '/crm/opportunity/statistics',
meta: {
title: '商机统计',
keepAlive: true,
isHide: true,
menuCode: 'crm:opportunity:list'
}
},
{
id: 'CRM1000000000000000000000000003',
name: 'CrmCustomerPool',
path: 'customer-pool',
component: '/crm/customer/pool/index',
meta: { title: '公海池', keepAlive: true, menuCode: 'crm:customerPool:list' }
},
{
id: 'CRM1000000000000000000000000004',
name: 'CrmPoolRule',
path: 'pool-rule',
component: '/crm/customer/pool/RuleIndex',
meta: {
title: '公海池规则',
keepAlive: true,
isHide: true,
menuCode: 'crm:poolRule:list'
}
}
]
}
]
}
+1 -1
View File
@@ -102,7 +102,7 @@ export const useWorktabStore = defineStore(
/**
* 过滤不应展示在 worktab 中的父级占位路由。
* `/system` 仅用于承载与重定向,真实标签页为其子路由(如 `/system/tenant`)。
* `/system` 仅用于承载与重定向,真实标签页为其子路由(如 `/system/info/tenant`)。
*/
const shouldIgnoreTab = (tab: Partial<WorkTab>): boolean => {
if (tab.path === '/system') {
+2
View File
@@ -58,6 +58,8 @@ export const SIDEBAR_MENU_ICON_MAP: Record<string, string> = {
// ── 系统 ──
SystemManage: 'ri:settings-3-line',
SystemInfo: 'ri:information-line',
SystemTemp: 'ri:inbox-archive-line',
Tenant: 'ri:building-line',
Dict: 'ri:book-2-line',
Menu: 'ri:menu-line',
+82 -16
View File
@@ -11,11 +11,25 @@ export interface PortalHomeCardItem {
menuCode?: string
}
/** 一级模块下按二级菜单分组的一行 */
export interface PortalHomeGroup {
/** 二级菜单 path;叶子直属合并组用空串 */
groupPath: string
/** 二级菜单标题;空串表示不展示分组标题(一级下的叶子页) */
groupTitle: string
cards: PortalHomeCardItem[]
/** 为 true 时不渲染二级标题(CRM 等扁平二级叶子) */
hideTitle?: boolean
}
export interface PortalHomeSection {
/** 一级模块 path */
modulePath: string
moduleTitle: string
/** 扁平卡片(收藏 / 角标 / 检索共用) */
cards: PortalHomeCardItem[]
/** 按二级菜单分行展示 */
groups: PortalHomeGroup[]
}
/** 首页菜单目录条目(含拼音检索元数据) */
@@ -75,40 +89,92 @@ function toCard(item: AppRouteRecord): PortalHomeCardItem | null {
}
}
/** 从一级模块收集可点卡片(跳过不可导航二级标题) */
export function collectCardsForModule(module: AppRouteRecord): PortalHomeCardItem[] {
/** 从一级模块按二级收集分组与扁平卡片(跳过不可导航二级标题) */
export function collectGroupsForModule(module: AppRouteRecord): {
groups: PortalHomeGroup[]
cards: PortalHomeCardItem[]
} {
const l2List = filterSidebarRoutes(module.children || [])
const groups: PortalHomeGroup[] = []
const cards: PortalHomeCardItem[] = []
const seen = new Set<string>()
/** 一级下直接挂的叶子页,按菜单顺序合并为无标题行,避免「产品管理 / 产品管理」重复 */
let pendingFlat: PortalHomeCardItem[] | null = null
const push = (item: AppRouteRecord) => {
const pushUnique = (item: AppRouteRecord): PortalHomeCardItem | null => {
const card = toCard(item)
if (!card || seen.has(card.path)) return
if (!card || seen.has(card.path)) return null
seen.add(card.path)
cards.push(card)
return card
}
const flushFlat = () => {
if (!pendingFlat?.length) {
pendingFlat = null
return
}
groups.push({
groupPath: '',
groupTitle: '',
cards: pendingFlat,
hideTitle: true
})
pendingFlat = null
}
for (const l2 of l2List) {
const links = resolveMegaPanelL3Links(l2)
for (const link of links) {
push(link)
}
const hasKids = filterSidebarRoutes(l2.children || []).length > 0
if (hasKids && isSidebarNavigableRoute(l2)) {
push(l2)
const links = resolveMegaPanelL3Links(l2)
// 无真实子级:侧栏里的「二级」实际是一级下的叶子页(如 CRM 产品管理)
if (!hasKids) {
if (!pendingFlat) pendingFlat = []
for (const link of links) {
const card = pushUnique(link)
if (card) pendingFlat.push(card)
}
continue
}
flushFlat()
const groupCards: PortalHomeCardItem[] = []
for (const link of links) {
const card = pushUnique(link)
if (card) groupCards.push(card)
}
if (isSidebarNavigableRoute(l2)) {
const card = pushUnique(l2)
if (card) groupCards.push(card)
}
if (!groupCards.length) continue
groups.push({
groupPath: String(l2.path || ''),
groupTitle: routeTitle(l2),
cards: groupCards
})
}
return cards
flushFlat()
return { groups, cards }
}
/** 从一级模块收集可点卡片(跳过不可导航二级标题) */
export function collectCardsForModule(module: AppRouteRecord): PortalHomeCardItem[] {
return collectGroupsForModule(module).cards
}
export function collectPortalHomeSections(menuList: AppRouteRecord[]): PortalHomeSection[] {
const tops = filterPortalTopModules(menuList)
return tops
.map((mod) => ({
modulePath: String(mod.path || ''),
moduleTitle: routeTitle(mod),
cards: collectCardsForModule(mod)
}))
.map((mod) => {
const { groups, cards } = collectGroupsForModule(mod)
return {
modulePath: String(mod.path || ''),
moduleTitle: routeTitle(mod),
cards,
groups
}
})
.filter((s) => s.cards.length > 0)
}
@@ -358,6 +358,7 @@
v-for="pick in selectedInvoicePicks"
:key="pick.key"
class="invoice-selected-card"
:style="{ '--invoice-group-color': invoiceGroupColor(pick.invoiceId) }"
>
<button
type="button"
@@ -388,7 +389,7 @@
</span>
<div class="invoice-selected-card__footer">
<span class="invoice-selected-card__amount">
{{ formatMoneyYuan(pick.amount) }} 元
整票 {{ formatMoneyYuan(pick.amount) }} 元
</span>
<el-icon
class="invoice-selected-card__download"
@@ -761,6 +762,25 @@ const walletBalance = ref(0)
const editingId = ref<number | null>(null)
const titleAutoLinked = ref(true)
/** 同一发票共用边框色(与 BxInvoiceSelectDialog / bx-mobile 一致) */
const INVOICE_GROUP_COLORS = [
'#0891b2',
'#7c3aed',
'#d97706',
'#db2777',
'#2563eb',
'#059669',
'#ea580c',
'#4f46e5'
]
function invoiceGroupColor(invoiceId: string | number) {
const s = String(invoiceId || '')
let hash = 0
for (let i = 0; i < s.length; i++) hash = (hash * 31 + s.charCodeAt(i)) >>> 0
return INVOICE_GROUP_COLORS[hash % INVOICE_GROUP_COLORS.length]
}
/** 关联发票固定展示顺序:上传/创建时间从新到旧,同时间按附件 ID */
function sortInvoicePicksByFixedOrder<T extends { createTime?: string; attachmentId?: string | number; id?: string | number; key?: string }>(
items: T[]
@@ -1384,7 +1404,7 @@ function viewDetailSummary({ columns, data }: { columns: any[]; data: any[] }) {
.invoice-selected-card {
position: relative;
width: 96px;
border: 1px solid var(--border, #d9e8e2);
border: 2px solid var(--invoice-group-color, var(--border, #d9e8e2));
border-radius: 6px;
background: var(--surface, #fff);
overflow: hidden;
+1 -1
View File
@@ -20,7 +20,7 @@
const detailDrawerRef = ref<InstanceType<typeof DetailDrawer>>()
function goBack() {
router.push({ path: '/system/clue' })
router.push({ path: '/system/temp/clue' })
}
onMounted(() => {
+1 -1
View File
@@ -2236,7 +2236,7 @@ const handleViewCustomer = () => {
/** 查看商机 */
const handleViewOpportunity = () => {
router.push({
path: '/system/opportunity/detail',
path: '/system/temp/opportunity/detail',
query: { id: contract.value?.opportunityId }
});
};
@@ -23,7 +23,7 @@
const opportunityId = ref<string>('')
function goBack() {
router.push({ path: '/system/opportunity' })
router.push({ path: '/system/temp/opportunity' })
}
onMounted(() => {
@@ -1029,7 +1029,7 @@
/** 切换视图模式 */
const switchView = (mode: 'list' | 'kanban') => {
if (mode === 'kanban') {
router.push('/system/opportunity/kanban')
router.push('/system/temp/opportunity/kanban')
} else {
viewMode.value = mode
}
@@ -1037,7 +1037,7 @@
/** 跳转到漏斗分析 */
const goToFunnel = () => {
router.push('/system/opportunity/funnel')
router.push('/system/temp/opportunity/funnel')
}
watchDebounced(
@@ -185,13 +185,13 @@ const resetQuery = () => {
}
const handleViewDetail = (id: number) => {
router.push({ path: '/system/opportunity/detail', query: { id } })
router.push({ path: '/system/temp/opportunity/detail', query: { id } })
}
const handleCardAction = (command: string, opp: OpportunityVO) => {
switch (command) {
case 'edit':
router.push({ path: '/system/opportunity', query: { id: opp.id, action: 'edit' } })
router.push({ path: '/system/temp/opportunity', query: { id: opp.id, action: 'edit' } })
break
case 'stage':
changeStageDialogRef.value?.open(opp.id)
@@ -239,7 +239,7 @@ let winRateChart: echarts.ECharts | null = null
let ownerChart: echarts.ECharts | null = null
const goBack = () => {
router.push('/system/opportunity')
router.push('/system/temp/opportunity')
}
const handleQuery = () => {
+79 -48
View File
@@ -78,49 +78,62 @@
<p v-else class="portal-home-empty">暂无收藏,可在各模块菜单卡片上点星标添加</p>
</section>
<section v-else class="portal-home-block">
<div class="portal-home-grid">
<button
v-for="card in activeCards"
:key="card.path"
type="button"
class="portal-home-card"
@click="openCard(card.path)"
>
<span
v-if="pendingCountFor(card) > 0"
class="portal-home-card__pending"
:aria-label="`${pendingCountFor(card)} 条待审批`"
>
{{ formatPendingCount(pendingCountFor(card)) }}
</span>
<span
class="portal-home-card__badge"
:data-tone="toneIndex(activeModule)"
aria-hidden="true"
>
{{ titleAbbr(card.title) }}
</span>
<span class="portal-home-card__body">
<span class="portal-home-card__title">{{ card.title }}</span>
<span class="portal-home-card__sub">{{ panelTitle }}</span>
</span>
<template v-else>
<section
v-for="(group, gIdx) in activeGroups"
:key="group.groupPath || `flat-${gIdx}`"
class="portal-home-block"
:class="{ 'portal-home-block--flat': group.hideTitle }"
>
<div v-if="!group.hideTitle && group.groupTitle" class="portal-home-block__head">
<h3 class="title-bar-heading">{{ group.groupTitle }}</h3>
<span class="portal-home-block__tip">{{ group.cards.length }} 项</span>
</div>
<div class="portal-home-grid">
<button
v-for="card in group.cards"
:key="card.path"
type="button"
class="portal-home-card__star"
:class="{ 'is-on': favoritePathSet.has(card.path) }"
:aria-label="favoritePathSet.has(card.path) ? '取消收藏' : '加入收藏'"
@click.stop="onToggleStar(card.path)"
class="portal-home-card"
@click="openCard(card.path)"
>
<ElIcon>
<StarFilled v-if="favoritePathSet.has(card.path)" />
<Star v-else />
</ElIcon>
<span
v-if="pendingCountFor(card) > 0"
class="portal-home-card__pending"
:aria-label="`${pendingCountFor(card)} 条待审批`"
>
{{ formatPendingCount(pendingCountFor(card)) }}
</span>
<span
class="portal-home-card__badge"
:data-tone="toneIndex(group.groupPath || activeModule)"
aria-hidden="true"
>
{{ titleAbbr(card.title) }}
</span>
<span class="portal-home-card__body">
<span class="portal-home-card__title">{{ card.title }}</span>
<span class="portal-home-card__sub">{{
group.hideTitle || !group.groupTitle ? panelTitle : group.groupTitle
}}</span>
</span>
<button
type="button"
class="portal-home-card__star"
:class="{ 'is-on': favoritePathSet.has(card.path) }"
:aria-label="favoritePathSet.has(card.path) ? '取消收藏' : '加入收藏'"
@click.stop="onToggleStar(card.path)"
>
<ElIcon>
<StarFilled v-if="favoritePathSet.has(card.path)" />
<Star v-else />
</ElIcon>
</button>
</button>
</button>
</div>
<p v-if="!activeCards.length" class="portal-home-empty">该模块暂无可用菜单</p>
</section>
</div>
</section>
<p v-if="!activeGroups.length" class="portal-home-empty">该模块暂无可用菜单</p>
</template>
</div>
</div>
@@ -190,19 +203,22 @@
const isFavView = computed(() => activeModule.value === FAV_KEY)
const activeCards = computed(() => {
if (isFavView.value) return []
const sec = modules.value.find((m) => m.modulePath === activeModule.value)
return sec?.cards || []
const activeSection = computed(() => {
if (isFavView.value) return undefined
return modules.value.find((m) => m.modulePath === activeModule.value)
})
const activeGroups = computed(() => activeSection.value?.groups || [])
const activeCards = computed(() => activeSection.value?.cards || [])
const panelTitle = computed(() => {
if (isFavView.value) return '收藏'
return modules.value.find((m) => m.modulePath === activeModule.value)?.moduleTitle || ''
return activeSection.value?.moduleTitle || ''
})
const panelDesc = computed(() =>
isFavView.value ? '可拖拽调整顺序,星标取消收藏' : '点击卡片进入功能,星标加入收藏'
isFavView.value ? '可拖拽调整顺序,星标取消收藏' : '按二级菜单分组展示,点击卡片进入功能'
)
const panelCount = computed(() =>
@@ -217,6 +233,11 @@
const favSubTitle = (path: string) => {
for (const sec of modules.value) {
for (const group of sec.groups || []) {
if (!group.cards.some((c) => c.path === path)) continue
if (group.hideTitle || !group.groupTitle) return sec.moduleTitle
return `${sec.moduleTitle} / ${group.groupTitle}`
}
if (sec.cards.some((c) => c.path === path)) return sec.moduleTitle
}
return '收藏入口'
@@ -494,15 +515,23 @@
}
.portal-home-block {
margin: 0 18px 18px;
margin: 14px 18px 8px;
&:last-child {
&:last-of-type {
margin-bottom: 4px;
}
&--fav {
margin-top: 14px;
}
&--flat {
margin-top: 14px;
.portal-home-grid {
margin-top: 0;
}
}
}
.portal-home-block__head {
@@ -510,11 +539,13 @@
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: space-between;
}
.portal-home-block__tip {
font-size: 12px;
font-weight: 400;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: var(--mint-text-muted, #94a3b8);
}
+1 -1
View File
@@ -408,7 +408,7 @@
// 返回列表
const goBack = () => {
router.push('/system/user')
router.push('/system/info/user')
}
// 加载数据