This commit is contained in:
zlwu committed 2026-08-08 20:43:48 +08:00
1 parent 2df2ef0808
commit 683f14828f
388 files changed
+2738488 -81306

No files matched your search

+18 -2
View File
@@ -13,6 +13,7 @@ export type BxAttachmentItem = {
const IMAGE_EXT = /\.(png|jpe?g|gif|webp|bmp|svg)$/i
const VIDEO_EXT = /\.(mp4|webm|ogg|mov|avi)$/i
const PDF_EXT = /\.pdf$/i
export function isBxImageFile(file: { fileName?: string; fileType?: string }) {
const name = String(file.fileName || '').toLowerCase()
@@ -26,9 +27,15 @@ export function isBxVideoFile(file: { fileName?: string; fileType?: string }) {
return type.startsWith('video/') || VIDEO_EXT.test(name)
}
export function isBxPdfFile(file: { fileName?: string; fileType?: string }) {
const name = String(file.fileName || '').toLowerCase()
const type = String(file.fileType || '').toLowerCase()
return type === 'application/pdf' || type.includes('pdf') || PDF_EXT.test(name)
}
export function canBxPreview(file: BxAttachmentItem) {
if (file._uploading) return false
return isBxImageFile(file) || isBxVideoFile(file)
return isBxImageFile(file) || isBxVideoFile(file) || isBxPdfFile(file)
}
export async function fillBxAttachmentPreviews(
@@ -102,6 +109,15 @@ export async function previewBxAttachment(file: BxAttachmentItem) {
throw error
}
}
await downloadBxAttachment(file)
if (isBxPdfFile(file)) {
try {
const res: any = await attachmentApi.preview(file.id)
return { type: 'pdf' as const, url: res.data?.url || '', fileName: file.fileName }
} catch (error: any) {
ElMessage.error(error?.message || '预览失败')
throw error
}
}
ElMessage.warning('该文件类型暂不支持在线预览,请使用下载查看')
return null
}
+27 -2
View File
@@ -1,7 +1,10 @@
/**
* CRM 附件解析与下载 — 门户 MinIO(fileUrl 即存储键/URL,不再走 CRM /resource/oss/listByIds)
*/
import { downloadAttachmentFile } from '@/utils/download-attachment'
import {
downloadAttachmentFile,
openAttachmentPreviewInNewTab
} from '@/utils/download-attachment'
export type CrmAttachmentRef = {
ossId: string
@@ -178,7 +181,29 @@ export async function downloadCrmAttachment(file: {
const fileUrl = String(file.url || file.ossId || '').trim()
if (!fileUrl) return
const fileName = String(file.fileName || file.name || '').trim() || crmAttachmentDisplayName(fileUrl)
await downloadAttachmentFile({ fileUrl, fileName })
await downloadAttachmentFile({ fileUrl, fileName }, { fullFileUrl: resolveCrmAttachmentUrl })
}
/** CRM 附件在新标签页预览(图片 HTML 内嵌 / PDF 预签名直开) */
export async function openCrmAttachmentPreviewInNewTab(file: {
ossId?: string
url?: string
name?: string
fileName?: string
}) {
const fileUrl = String(file.url || file.ossId || '').trim()
if (!fileUrl) return false
const fileName =
String(file.fileName || file.name || '').trim() || crmAttachmentDisplayName(fileUrl)
const kind = isCrmImageFile({ name: fileName, url: fileUrl, ossId: fileUrl })
? 'image'
: isCrmPdfFile({ name: fileName, url: fileUrl, ossId: fileUrl })
? 'pdf'
: 'other'
return openAttachmentPreviewInNewTab(
{ fileUrl, fileName },
{ fullFileUrl: resolveCrmAttachmentUrl, kind }
)
}
/** el-upload 回显:从 attachmentFiles 字符串构建 file-list */
@@ -0,0 +1,24 @@
/** 报价单 / 合同负责人候选(按真实姓名匹配用户列表) */
export const CRM_OWNER_CANDIDATE_NAMES = ['方金强', '吴九华'] as const
export const CRM_DEFAULT_OWNER_NAME = '方金强'
type OwnerLike = { nickName?: string }
/** 按固定顺序筛选负责人候选 */
export function filterOwnerCandidates<T extends OwnerLike>(users: T[]): T[] {
const byName = new Map<string, T>()
for (const user of users) {
const name = (user.nickName || '').trim()
if (name && !byName.has(name)) {
byName.set(name, user)
}
}
return CRM_OWNER_CANDIDATE_NAMES.map((name) => byName.get(name)).filter(Boolean) as T[]
}
/** 默认负责人:方金强 */
export function findDefaultOwnerUser<T extends OwnerLike>(users: T[]): T | undefined {
const name = CRM_DEFAULT_OWNER_NAME
return users.find((user) => (user.nickName || '').trim() === name)
}
+102
View File
@@ -6,6 +6,8 @@ export type AttachmentDownloadItem = {
fileName?: string
}
export type AttachmentPreviewKind = 'image' | 'pdf' | 'other'
/** 将 JSON 数组或逗号分隔的附件 URL 统一为逗号分隔字符串 */
export function normalizeAttachmentUrls(raw?: string | null): string {
if (!raw) return ''
@@ -34,6 +36,44 @@ function triggerAnchorDownload(href: string, fileName: string) {
document.body.removeChild(a)
}
function escapeHtmlAttr(value: string) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
/** 根据文件名/URL 推断预览类型 */
export function detectAttachmentPreviewKind(file: AttachmentDownloadItem): AttachmentPreviewKind {
const name = String(file.fileName || '').toLowerCase()
const url = String(file.fileUrl || '')
.split('?')[0]
.toLowerCase()
if (/\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i.test(name) || /\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i.test(url)) {
return 'image'
}
if (/\.pdf$/i.test(name) || /\.pdf$/i.test(url)) {
return 'pdf'
}
return 'other'
}
/** 图片用 HTML 内嵌展示,避免 Content-Type 为 octet-stream 时顶级导航触发下载 */
function openImagePreviewDocument(previewWindow: Window | null, href: string, title: string) {
const safeSrc = escapeHtmlAttr(href)
const safeTitle = escapeHtmlAttr(title || '图片预览')
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${safeTitle}</title><style>html,body{margin:0;height:100%;background:#0f172a}body{display:flex;align-items:center;justify-content:center}img{max-width:100%;max-height:100vh;object-fit:contain}</style></head><body><img src="${safeSrc}" alt="${safeTitle}"/></body></html>`
if (previewWindow) {
previewWindow.document.open()
previewWindow.document.write(html)
previewWindow.document.close()
return
}
const blobUrl = URL.createObjectURL(new Blob([html], { type: 'text/html;charset=utf-8' }))
window.open(blobUrl, '_blank', 'noopener,noreferrer')
}
/** 附件预览:优先走后端预签名 inline URL,供 PDF iframe 等内联展示。 */
export async function resolveAttachmentPreviewUrl(
file: AttachmentDownloadItem,
@@ -53,6 +93,68 @@ export async function resolveAttachmentPreviewUrl(
return resolveUrl(fileUrl)
}
/**
* 在浏览器新标签页打开附件预览(图片 HTML 内嵌 / PDF 直开)。
* 不支持在线预览的类型会提示并返回 false。
*/
export async function openAttachmentPreviewInNewTab(
file: AttachmentDownloadItem,
options?: {
fullFileUrl?: (url: string) => string
kind?: AttachmentPreviewKind
}
): Promise<boolean> {
const fileUrl = String(file.fileUrl || '').trim()
if (!fileUrl) {
ElMessage.error('无法获取文件预览地址')
return false
}
const kind = options?.kind || detectAttachmentPreviewKind(file)
if (kind === 'other') {
ElMessage.warning('该文件类型暂不支持在线预览,请使用下载查看')
return false
}
const previewWindow = window.open('about:blank', '_blank')
try {
const previewUrl = await resolveAttachmentPreviewUrl(file, {
fullFileUrl: options?.fullFileUrl
})
const href =
previewUrl || (options?.fullFileUrl ? options.fullFileUrl(fileUrl) : fileUrl)
if (!href) {
previewWindow?.close()
ElMessage.error('无法获取文件预览地址')
return false
}
if (kind === 'image') {
openImagePreviewDocument(previewWindow, href, file.fileName || '图片预览')
return true
}
if (previewWindow) {
previewWindow.location.href = href
} else {
window.open(href, '_blank', 'noopener,noreferrer')
}
return true
} catch (error) {
console.error('打开附件预览失败:', error)
const fallback = options?.fullFileUrl ? options.fullFileUrl(fileUrl) : fileUrl
if (fallback && kind === 'image') {
openImagePreviewDocument(previewWindow, fallback, file.fileName || '图片预览')
return true
}
if (fallback && previewWindow) {
previewWindow.location.href = fallback
return true
}
previewWindow?.close()
ElMessage.error('打开预览失败,请稍后重试或使用下载')
return false
}
}
/** 附件下载:优先走后端预签名 URL,保证跨域场景下保留原始文件名与后缀。 */
export async function downloadAttachmentFile(
file: AttachmentDownloadItem,
@@ -7,12 +7,15 @@ import { collectPortalHomeSections, findHomeCardByPath } from '@/utils/router/po
const PREOPEN_FLAG_KEY = 'portal-favorites-preopened-user'
/**
* 冷登录:按收藏预开 Tab,当前仍停在首页。
* 冷登录:按收藏预开 Tab。
* 有有效收藏时激活第一个收藏页;否则落点首页。
* 同用户刷新且已有业务 Tab 时跳过(依赖 worktab 本地持久化)。
*
* @returns 落点 path;未执行预开则返回 null
*/
/** @returns 是否执行了冷登录预开(调用方应落点首页) */
export async function preopenFavoriteTabsIfNeeded(userId: string): Promise<boolean> {
if (!userId) return false
export async function preopenFavoriteTabsIfNeeded(userId: string): Promise<string | null> {
const uid = String(userId || '').trim()
if (!uid) return null
const home = HOME_PAGE_PATH || '/home'
const worktab = useWorktabStore()
@@ -21,12 +24,12 @@ export async function preopenFavoriteTabsIfNeeded(userId: string): Promise<boole
)
// 刷新恢复:已有业务 Tab 则不再按收藏重开
if (hasBusinessTabs) {
sessionStorage.setItem(PREOPEN_FLAG_KEY, userId)
return false
sessionStorage.setItem(PREOPEN_FLAG_KEY, uid)
return null
}
// 同会话已预开过(仅首页等)则跳过
if (sessionStorage.getItem(PREOPEN_FLAG_KEY) === userId) {
return false
if (sessionStorage.getItem(PREOPEN_FLAG_KEY) === uid) {
return null
}
const favStore = useMenuFavoriteStore()
@@ -45,6 +48,7 @@ export async function preopenFavoriteTabsIfNeeded(userId: string): Promise<boole
{ activate: true }
)
let firstFavPath: string | null = null
for (const item of [...favStore.items]) {
const card = findHomeCardByPath(sections, item.path)
if (!card) {
@@ -59,6 +63,10 @@ export async function preopenFavoriteTabsIfNeeded(userId: string): Promise<boole
if (!resolved.matched.length) {
continue
}
const isFirst = firstFavPath == null
if (isFirst) {
firstFavPath = card.path
}
worktab.openTab(
{
path: card.path,
@@ -67,12 +75,12 @@ export async function preopenFavoriteTabsIfNeeded(userId: string): Promise<boole
fixedTab: false,
keepAlive: true
},
{ activate: false }
{ activate: isFirst }
)
}
sessionStorage.setItem(PREOPEN_FLAG_KEY, userId)
return true
sessionStorage.setItem(PREOPEN_FLAG_KEY, uid)
return firstFavPath || home
}
export function clearFavoritePreopenFlag(): void {
+46 -1
View File
@@ -1,4 +1,9 @@
import { normalizeAttachmentUrls } from '@/utils/download-attachment'
import { ElMessage } from 'element-plus'
import {
downloadAttachmentFile,
normalizeAttachmentUrls,
openAttachmentPreviewInNewTab
} from '@/utils/download-attachment'
/** 取逗号分隔附件中的第一张 URL(相对或绝对) */
export function firstPhotoUrl(raw?: string | null): string {
@@ -15,3 +20,43 @@ export function resolvePhotoDisplayUrl(url?: string | null): string {
const base = String(import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
return base ? `${base}/${trimmed.replace(/^\//, '')}` : trimmed
}
function photoFileName(url: string) {
return (
String(url || '')
.split('?')[0]
.split('/')
.pop() || '照片'
)
}
/** 列表照片列:新标签页预览首图 */
export async function previewListPhoto(raw?: string | null) {
const fileUrl = firstPhotoUrl(raw)
if (!fileUrl) {
ElMessage.error('无法获取照片地址')
return
}
await openAttachmentPreviewInNewTab(
{ fileUrl, fileName: photoFileName(fileUrl) },
{ fullFileUrl: resolvePhotoDisplayUrl, kind: 'image' }
)
}
/** 列表照片列:下载首图 */
export async function downloadListPhoto(raw?: string | null) {
const fileUrl = firstPhotoUrl(raw)
if (!fileUrl) {
ElMessage.error('无法获取照片地址')
return
}
try {
await downloadAttachmentFile(
{ fileUrl, fileName: photoFileName(fileUrl) },
{ fullFileUrl: resolvePhotoDisplayUrl }
)
ElMessage.success('下载成功')
} catch {
ElMessage.error('下载失败,请稍后重试')
}
}
+65 -6
View File
@@ -1,9 +1,6 @@
import type { AppRouteRecord } from '@/types/router'
import { getFirstMenuPath, getFirstSecondLevelMenuPath, isIframe } from '@/utils/navigation/route'
import {
filterSidebarRoutes,
isSidebarNavigableRoute
} from '@/utils/router/sidebar-menu-filter'
import { filterSidebarRoutes, isSidebarNavigableRoute } from '@/utils/router/sidebar-menu-filter'
/** 大面板一行:二级标题 + 三级可点链接 */
export interface PortalMegaPanelRow {
@@ -167,8 +164,7 @@ export function resolvePortalSubMenus(
/** 门户默认首页:优先 CRM 管理模块首个可访问页 */
export function getPortalDefaultHomePath(menuList: AppRouteRecord[]): string {
const tops = filterPortalTopModules(menuList)
const defaultModule =
tops.find((item) => item.path === PORTAL_DEFAULT_MODULE_PATH) || tops[0]
const defaultModule = tops.find((item) => item.path === PORTAL_DEFAULT_MODULE_PATH) || tops[0]
if (defaultModule) {
return getFirstAccessiblePath(defaultModule)
}
@@ -280,3 +276,66 @@ export function isMegaPanelLinkActive(item: AppRouteRecord, routePath: string):
}
return activePath === itemPath || activePath.startsWith(`${itemPath}/`)
}
/** 顶栏二级平铺项间距(与 `.portal-flat-nav__bar` gap 一致) */
export const PORTAL_FLAT_NAV_ITEM_GAP = 2
/**
* 按可用宽度计算可平铺的二级数量;其余应收入「更多」。
* @param itemWidths 各二级项实测宽度(含 caret)
* @param moreWidth 「更多」按钮宽度
* @param availableWidth 中间导航槽可用宽度
*/
export function calcPortalFlatNavVisibleCount(
itemWidths: number[],
moreWidth: number,
availableWidth: number,
gap: number = PORTAL_FLAT_NAV_ITEM_GAP
): number {
if (!itemWidths.length || availableWidth <= 0) {
return 0
}
const total = itemWidths.reduce((sum, width, index) => sum + width + (index ? gap : 0), 0)
if (total <= availableWidth) {
return itemWidths.length
}
let used = 0
let count = 0
for (let i = 0; i < itemWidths.length; i++) {
const next = used + (count ? gap : 0) + itemWidths[i]
const needMore = i < itemWidths.length - 1
const limit = needMore ? availableWidth - moreWidth - gap : availableWidth
if (next <= limit + 0.5) {
used = next
count++
} else {
break
}
}
return Math.max(0, count)
}
/** 二级是否存在真实子级(需展开下拉,不可直接跳转) */
export function l2HasExpandableChildren(l2: AppRouteRecord): boolean {
return filterSidebarRoutes(l2.children || []).length > 0
}
/**
* 当前一级模块下可展示的二级列表。
* 仅保留至少有一个可导航目标(真实三级或虚拟自指)的二级。
*/
export function listPortalFlatNavL2Items(l1Module: AppRouteRecord | undefined): AppRouteRecord[] {
if (!l1Module) {
return []
}
const l2List = filterSidebarRoutes(l1Module.children || [])
return l2List.filter((l2) => resolveMegaPanelL3Links(l2).length > 0)
}
/** 二级(含其下三级)是否匹配当前路由 */
export function isPortalL2Active(l2: AppRouteRecord, routePath: string): boolean {
return resolveMegaPanelL3Links(l2).some((link) => isMegaPanelLinkActive(link, routePath))
}