提交
This commit is contained in:
1 parent
2df2ef0808
commit
683f14828f
388 files changed
+2738488
-81306
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
|
||||
/** 根据文件名/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 {
|
||||
|
||||
@@ -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('下载失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Reference in new issue
Block a user