This commit is contained in:
zlwu committed 2026-06-30 23:44:22 +08:00
1 parent d84f4b944c
commit 7c838cfb2d
249 files changed
+50600 -18556

No files matched your search

+2 -2
View File
@@ -22,7 +22,7 @@ function unwrapData(res: any) {
function mapListQuery(query: ClueQuery = {}): Record<string, any> {
const body: Record<string, any> = {
page: query.pageNum ?? 1,
pageSize: query.pageSize ?? 10
pageSize: query.pageSize ?? 20
}
if (query.clueName) body.clueName = query.clueName
if (query.company) body.company = query.company
@@ -197,7 +197,7 @@ export async function listClueFollow(query: ClueFollowQuery) {
data: {
clueId: String(query.clueId),
page: query.pageNum ?? 1,
pageSize: query.pageSize ?? 10
pageSize: query.pageSize ?? 20
}
})
const data = unwrapData(res)
+1 -1
View File
@@ -13,7 +13,7 @@ function unwrapData(res: any) {
function mapListQuery(query: ApprovalQuery = {}): Record<string, any> {
const params: Record<string, any> = {
pageNum: query.pageNum ?? 1,
pageSize: query.pageSize ?? 10
pageSize: query.pageSize ?? 20
}
;[
'contractNo',
+1 -1
View File
@@ -13,7 +13,7 @@ function unwrapData(res: any) {
function mapListQuery(query: ChangeQuery = {}): Record<string, any> {
const params: Record<string, any> = {
pageNum: query.pageNum ?? 1,
pageSize: query.pageSize ?? 10
pageSize: query.pageSize ?? 20
}
;['contractNo', 'contractName', 'changeType', 'approvalStatus', 'beginTime', 'endTime'].forEach((key) => {
const val = (query as any)[key]
+1 -1
View File
@@ -13,7 +13,7 @@ function unwrapData(res: any) {
function mapListQuery(query: ContractQuery = {}): Record<string, any> {
const params: Record<string, any> = {
pageNum: query.pageNum ?? 1,
pageSize: query.pageSize ?? 10
pageSize: query.pageSize ?? 20
}
const fields: (keyof ContractQuery)[] = [
'contractNo',
+1 -1
View File
@@ -13,7 +13,7 @@ function unwrapData(res: any) {
function mapListQuery(query: TemplateQuery = {}): Record<string, any> {
const params: Record<string, any> = {
pageNum: query.pageNum ?? 1,
pageSize: query.pageSize ?? 10
pageSize: query.pageSize ?? 20
}
;['templateNo', 'templateName', 'templateType', 'beginTime', 'endTime'].forEach((key) => {
const val = (query as any)[key]
+3 -3
View File
@@ -25,7 +25,7 @@ function unwrapData(res: any) {
function mapListQuery(query: OpportunityQuery = {}): Record<string, any> {
const body: Record<string, any> = {
page: query.pageNum ?? 1,
pageSize: query.pageSize ?? 10
pageSize: query.pageSize ?? 20
}
if (query.opportunityName) body.opportunityName = query.opportunityName
if (query.customerName) body.customerName = query.customerName
@@ -177,7 +177,7 @@ export async function listOpportunityProduct(query?: OpportunityProductQuery) {
url: `${BASE}/product/list`,
data: {
page: query?.pageNum ?? 1,
pageSize: query?.pageSize ?? 10,
pageSize: query?.pageSize ?? 20,
opportunityId: query?.opportunityId != null ? String(query.opportunityId) : undefined,
productId: query?.productId != null ? String(query.productId) : undefined
}
@@ -232,7 +232,7 @@ export async function listOpportunityFollow(query?: OpportunityFollowQuery) {
url: `${BASE}/follow/list`,
data: {
page: query?.pageNum ?? 1,
pageSize: query?.pageSize ?? 10,
pageSize: query?.pageSize ?? 20,
opportunityId: query?.opportunityId != null ? String(query.opportunityId) : undefined,
followType: query?.followType,
followBy: query?.followBy != null ? String(query.followBy) : undefined
+1 -1
View File
@@ -22,7 +22,7 @@ function unwrapData(res: any) {
function mapListQuery(query: QuoteQuery = {}): Record<string, any> {
const body: Record<string, any> = {
page: query.pageNum ?? 1,
pageSize: query.pageSize ?? 10
pageSize: query.pageSize ?? 20
}
const fields: (keyof QuoteQuery)[] = [
'keyword',
+2 -2
View File
@@ -48,7 +48,7 @@ export async function listSupplierInfo(query: SupplierInfoQuery = {}) {
url: `${BASE}/list`,
data: {
page: query.pageNum ?? 1,
pageSize: query.pageSize ?? 10,
pageSize: query.pageSize ?? 20,
supplierName: query.supplierName,
status: query.status
}
@@ -92,7 +92,7 @@ export async function exportSupplierInfo(query: SupplierInfoQuery) {
url: `${BASE}/export`,
data: {
page: query.pageNum ?? 1,
pageSize: query.pageSize ?? 10,
pageSize: query.pageSize ?? 20,
supplierName: query.supplierName,
status: query.status
},
+2
View File
@@ -0,0 +1,2 @@
/** Demo 模板页 API 统一出口(Mock,无后端) */
export * from './mock-api'
+389
View File
@@ -0,0 +1,389 @@
/**
* Demo 模板页 Mock API(本地内存,不依赖后端)
* 供 views/demo/* 与 product-view-dialog 使用
*/
import { fetchUserList } from '@/api/admin'
import {
demoCategories,
demoOrders,
demoProducts,
demoRegions,
demoUnits
} from './mock-data'
import { DEFAULT_LIST_PAGE_SIZE } from '@/utils/table/tableConfig'
type PageParams = { page?: number; pageSize?: number; keyword?: string }
const delay = (ms = 120) => new Promise((resolve) => setTimeout(resolve, ms))
function paginate<T>(list: T[], page = 1, pageSize = DEFAULT_LIST_PAGE_SIZE) {
const p = Math.max(1, page)
const ps = Math.max(1, pageSize)
const start = (p - 1) * ps
const slice = list.slice(start, start + ps)
return {
list: slice,
total: list.length,
page: p,
pageSize: ps,
totalPages: Math.max(1, Math.ceil(list.length / ps))
}
}
function filterKeyword<T extends Record<string, any>>(
list: T[],
keys: string[],
keyword?: string
) {
const kw = (keyword || '').trim().toLowerCase()
if (!kw) return list
return list.filter((row) => keys.some((k) => String(row[k] ?? '').toLowerCase().includes(kw)))
}
const units = [...demoUnits]
const categories = [...demoCategories]
const products = [...demoProducts]
const orders = [...demoOrders]
function newId(prefix: string) {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
// ─── 物品单位 ─────────────────────────────────────────
export async function fetchItemUnitList(params?: PageParams) {
await delay()
const filtered = filterKeyword(units, ['unitName', 'unitCode'], params?.keyword)
return paginate(filtered, params?.page, params?.pageSize)
}
export async function fetchCreateItemUnit(data: {
unitCode: string
unitName: string
orderNum?: number
remark?: string
}) {
await delay()
const row: Api.ItemUnit.ItemUnitItem = {
id: newId('demo-unit'),
unitCode: data.unitCode,
unitName: data.unitName,
tenantNo: 'demo',
orderNum: data.orderNum ?? 0,
remark: data.remark || '',
createTime: new Date().toISOString().slice(0, 19).replace('T', ' '),
updateTime: new Date().toISOString().slice(0, 19).replace('T', ' ')
}
units.unshift(row)
return row
}
export async function fetchUpdateItemUnit(
id: string,
data: Partial<{ unitCode: string; unitName: string; orderNum: number; remark: string }>
) {
await delay()
const idx = units.findIndex((u) => u.id === id)
if (idx >= 0) units[idx] = { ...units[idx], ...data }
return units[idx]
}
export async function fetchDeleteItemUnit(id: string) {
await delay()
const idx = units.findIndex((u) => u.id === id)
if (idx >= 0) units.splice(idx, 1)
return { success: true }
}
// ─── 分类 ───────────────────────────────────────────
export async function fetchCategoryList(params?: PageParams & { isActive?: boolean }) {
await delay()
let list = [...categories]
if (params?.isActive != null) list = list.filter((c) => c.isActive === params.isActive)
list = filterKeyword(list, ['name'], params?.keyword)
return paginate(list, params?.page, params?.pageSize)
}
export async function fetchCreateCategory(data: {
name: string
parentId?: string
sortOrder?: number
imageUrl?: string
}) {
await delay()
const row: Api.Category.CategoryItem = {
id: newId('demo-cat'),
name: data.name,
parentId: data.parentId || '0',
sortOrder: data.sortOrder ?? 0,
isActive: true,
imageUrl: data.imageUrl || '',
createAt: new Date().toISOString().slice(0, 19).replace('T', ' ')
}
categories.push(row)
return row
}
export async function fetchUpdateCategory(id: string, data: Partial<Api.Category.CategoryItem>) {
await delay()
const idx = categories.findIndex((c) => c.id === id)
if (idx >= 0) categories[idx] = { ...categories[idx], ...data }
return categories[idx]
}
export async function fetchDeleteCategory(id: string) {
await delay()
const idx = categories.findIndex((c) => c.id === id)
if (idx >= 0) categories.splice(idx, 1)
return { success: true }
}
export async function fetchUploadCategoryImage(_data: FormData) {
await delay()
return { fileUrl: '', fileName: 'demo.png' }
}
export async function fetchProductParameterSimple() {
await delay()
return { list: [] as Api.Product.ProductParameterSimple[], total: 0 }
}
export async function fetchCategoryParameters(_categoryId: string) {
await delay()
return { list: [] as Api.Product.CategoryParameter[], total: 0 }
}
export async function fetchUpdateCategoryParameters(_categoryId: string, _items: unknown[]) {
await delay()
return { success: true }
}
// ─── 商品单位(Unit 命名,商品页用) ─────────────────
export async function fetchUnitList(params?: PageParams) {
await delay()
const list: Api.Unit.UnitItem[] = units.map((u) => ({
id: u.id,
name: u.unitName,
code: u.unitCode,
isActive: true,
createAt: u.createTime
}))
const filtered = filterKeyword(list, ['name', 'code'], params?.keyword)
return paginate(filtered, params?.page, params?.pageSize)
}
// ─── 商品 ───────────────────────────────────────────
export async function fetchProductList(params?: PageParams & { categoryId?: string }) {
await delay()
let list = [...products]
if (params?.categoryId) list = list.filter((p) => p.categoryId === params.categoryId)
list = filterKeyword(list, ['name'], params?.keyword)
return paginate(list, params?.page, params?.pageSize)
}
export async function fetchProductDetail(id: string) {
await delay()
const base = products.find((p) => p.id === id)
if (!base) throw new Error('商品不存在')
return {
...base,
updateAt: base.createAt,
updateBy: 'demo',
skuCode: 'SKU-DEMO',
measureValue: 1,
detailContent: ''
} as Api.Product.ProductDetail & { skuCode?: string; measureValue?: number; detailContent?: string }
}
export async function fetchCreateProduct(data: Partial<Api.Product.ProductItem> & { name: string }) {
await delay()
const row: Api.Product.ProductItem = {
id: newId('demo-prod'),
name: data.name,
categoryId: data.categoryId,
categoryName: categories.find((c) => c.id === data.categoryId)?.name,
unitId: data.unitId,
unitName: units.find((u) => u.id === data.unitId)?.unitName,
description: data.description || '',
imageUrl: data.imageUrl || '',
isActive: data.isActive ?? true,
createAt: new Date().toISOString().slice(0, 19).replace('T', ' ')
}
products.unshift(row)
return row
}
export async function fetchUpdateProduct(id: string, data: Partial<Api.Product.ProductItem>) {
await delay()
const idx = products.findIndex((p) => p.id === id)
if (idx >= 0) products[idx] = { ...products[idx], ...data }
return products[idx]
}
export async function fetchDeleteProduct(id: string) {
await delay()
const idx = products.findIndex((p) => p.id === id)
if (idx >= 0) products.splice(idx, 1)
return { success: true }
}
export async function fetchProductBannerList(_productId: string) {
await delay()
return { list: [] as Api.Product.ProductBanner[], total: 0 }
}
export async function fetchCreateProductBanners(_productId: string, _items: unknown[]) {
await delay()
return { success: true }
}
export async function fetchUpdateProductBannerSort(_productId: string, _items: unknown[]) {
await delay()
return { success: true }
}
export async function fetchDeleteProductBanner(_id: string) {
await delay()
return { success: true }
}
export async function fetchProductDetailMediaList(_productId: string) {
await delay()
return { list: [] as Api.Product.ProductDetailMedia[], total: 0 }
}
export async function fetchCreateProductDetailMedia(_productId: string, _items: unknown[]) {
await delay()
return { success: true }
}
export async function fetchUpdateProductDetailMediaSort(_productId: string, _items: unknown[]) {
await delay()
return { success: true }
}
export async function fetchDeleteProductDetailMedia(_id: string) {
await delay()
return { success: true }
}
export async function fetchProductParameterValues(_productId: string) {
await delay()
return { list: [] as Api.Product.ProductParameterValue[], total: 0 }
}
export async function fetchSaveProductParameterValues(_productId: string, _values: unknown[]) {
await delay()
return { success: true }
}
export async function fetchProductRegionPrices(_productId: string) {
await delay()
return {
list: demoRegions.map((r) => ({
regionId: r.id,
regionName: r.name,
price: 100,
unitId: null,
unitName: null
}))
}
}
// ─── 区域 / 用户(订单页) ───────────────────────────
export async function fetchNotDeletedRegions() {
await delay()
return [...demoRegions]
}
export { fetchUserList }
// ─── business.ts 订单等 ─────────────────────────────
export async function fetchOrderList(params?: PageParams & Record<string, unknown>) {
await delay()
let list = [...orders]
list = filterKeyword(list, ['orderNo', 'clientName'], params?.keyword as string | undefined)
return paginate(list, params?.page, params?.pageSize)
}
export async function fetchOrderDetail(id: string) {
await delay()
const base = orders.find((o) => o.id === id)
if (!base) throw new Error('订单不存在')
return {
...base,
items: [],
remark: 'Demo 订单详情'
} as Api.Order.OrderDetail
}
export async function fetchAssignDelivery(_data: unknown) {
await delay()
return { success: true }
}
export async function fetchReviewCancel(_data: unknown) {
await delay()
return { success: true }
}
export async function fetchCancelOrder(_data: unknown) {
await delay()
return { success: true }
}
export async function fetchOrderItems(_orderId: string) {
await delay()
return { list: [] }
}
export async function fetchAdjustOrder(_data: unknown) {
await delay()
return { success: true }
}
export async function fetchOrderAdjusts(_orderId: string) {
await delay()
return { list: [] }
}
export async function fetchPriceVersionDetail(_id: string) {
await delay()
return { id: _id, name: 'Demo 价目表' }
}
export async function fetchUploadAttachment(_data: FormData) {
await delay()
return {
id: newId('att'),
fileName: 'demo.pdf',
fileUrl: '#',
fileSize: 0
}
}
export async function fetchUploadProductImage(_data: FormData) {
await delay()
return { fileUrl: '', fileName: 'demo.png' }
}
export async function fetchSystemConfig() {
await delay()
return {
orderCancelReasons: ['客户取消', '库存不足'],
paymentMethods: [
{ value: 'cash', label: '现金' },
{ value: 'transfer', label: '转账' }
]
}
}
export async function fetchAttachmentDownloadUrl(_data: { fileUrl: string; fileName?: string }) {
await delay()
return { downloadUrl: '#' }
}
+141
View File
@@ -0,0 +1,141 @@
/** Demo 模板页本地种子数据(无后端依赖) */
export const demoUnits: Api.ItemUnit.ItemUnitItem[] = [
{
id: 'demo-unit-1',
unitCode: 'KG',
unitName: '千克',
tenantNo: 'demo',
orderNum: 1,
remark: 'Demo 示例',
createTime: '2026-01-01 10:00:00',
updateTime: '2026-01-01 10:00:00'
},
{
id: 'demo-unit-2',
unitCode: 'TON',
unitName: '吨',
tenantNo: 'demo',
orderNum: 2,
remark: '',
createTime: '2026-01-02 10:00:00',
updateTime: '2026-01-02 10:00:00'
}
]
export const demoCategories: Api.Category.CategoryItem[] = [
{
id: 'demo-cat-1',
name: '化工原料',
parentId: '0',
sortOrder: 1,
isActive: true,
imageUrl: '',
createAt: '2026-01-01 10:00:00'
},
{
id: 'demo-cat-2',
name: '树脂类',
parentId: 'demo-cat-1',
sortOrder: 1,
isActive: true,
imageUrl: '',
createAt: '2026-01-01 10:00:00'
},
{
id: 'demo-cat-3',
name: '溶剂类',
parentId: 'demo-cat-1',
sortOrder: 2,
isActive: true,
imageUrl: '',
createAt: '2026-01-01 10:00:00'
}
]
export const demoProducts: Api.Product.ProductItem[] = [
{
id: 'demo-prod-1',
name: '环氧树脂 E-51',
categoryId: 'demo-cat-2',
categoryName: '树脂类',
unitId: 'demo-unit-1',
unitName: '千克',
description: 'Demo 商品示例',
imageUrl: '',
isActive: true,
createAt: '2026-01-03 10:00:00'
},
{
id: 'demo-prod-2',
name: '丙酮',
categoryId: 'demo-cat-3',
categoryName: '溶剂类',
unitId: 'demo-unit-2',
unitName: '吨',
description: '',
imageUrl: '',
isActive: true,
createAt: '2026-01-04 10:00:00'
}
]
export const demoOrders: Api.Order.OrderItem[] = [
{
id: 'demo-order-1',
orderNo: 'DD202601001',
clientId: 'demo-client-1',
clientName: '示例客户 A',
regionId: 'demo-region-1',
regionName: '华东区',
priceListId: 'demo-price-1',
totalAmount: '12800.00',
paidAmount: '12800.00',
receivableAmount: '0.00',
refundAmount: '0.00',
status: 'completed',
paymentMethod: 'transfer',
contactPerson: '张三',
contactPhone: '13800000001',
deliveryAddress: '浙江省杭州市示例路 1 号',
createAt: '2026-01-10 09:30:00'
},
{
id: 'demo-order-2',
orderNo: 'DD202601002',
clientId: 'demo-client-2',
clientName: '示例客户 B',
regionId: 'demo-region-2',
regionName: '华南区',
priceListId: 'demo-price-1',
totalAmount: '5600.00',
paidAmount: '0.00',
receivableAmount: '5600.00',
refundAmount: '0.00',
status: 'pending_payment',
paymentMethod: 'cash',
contactPerson: '李四',
contactPhone: '13800000002',
deliveryAddress: '广东省广州市示例路 2 号',
createAt: '2026-01-11 14:20:00'
}
]
export const demoRegions: Api.Region.RegionItem[] = [
{
id: 'demo-region-1',
name: '华东区',
code: 'EAST',
isActive: true,
sortOrder: 1,
createAt: '2026-01-01 10:00:00'
},
{
id: 'demo-region-2',
name: '华南区',
code: 'SOUTH',
isActive: true,
sortOrder: 2,
createAt: '2026-01-01 10:00:00'
}
]
+40 -6
View File
@@ -2,11 +2,18 @@
* 运输计划管理 API
*/
import request from '@/utils/http/bx'
import { pickBxList, pickBxPageResult } from '@/utils/http/bx'
import type { TransportResourceSnapshot } from '@/utils/pl-transport-resource-snapshot'
// =============================================================================
// 基础数据 API
// =============================================================================
export interface PlListResponse<T> {
total: number
list: T[]
}
// 承运商
export interface Carrier {
id: string
@@ -32,10 +39,13 @@ export interface CarrierQuery {
q?: string
status?: string
auditStatus?: string
page?: number
pageSize?: number
}
export const carrierApi = {
list: (params?: CarrierQuery) => request.get<Carrier[]>('/transport/carrier', { params }),
list: (params?: CarrierQuery) =>
request.get<PlListResponse<Carrier>>('/transport/carrier', { params }),
get: (id: string) => request.get<Carrier>(`/transport/carrier/${id}`),
create: (data: Partial<Carrier>) => request.post<Carrier>('/transport/carrier', data),
update: (id: string, data: Partial<Carrier>) => request.put<Carrier>(`/transport/carrier/${id}`, data),
@@ -116,10 +126,13 @@ export interface VehicleQuery {
carrierId?: string
vehicleStatus?: string
excludeIneligibleCarrier?: boolean
page?: number
pageSize?: number
}
export const vehicleApi = {
list: (params?: VehicleQuery) => request.get<Vehicle[]>('/transport/vehicle', { params }),
list: (params?: VehicleQuery) =>
request.get<PlListResponse<Vehicle>>('/transport/vehicle', { params }),
get: (id: string) => request.get<VehicleDetail>(`/transport/vehicle/${id}`),
create: (data: VehiclePayload) => request.post<VehicleDetail>('/transport/vehicle', data),
update: (id: string, data: Partial<VehiclePayload>) =>
@@ -157,10 +170,13 @@ export interface PersonnelPayload {
export interface PersonnelQuery {
q?: string
status?: string
page?: number
pageSize?: number
}
export const personnelApi = {
list: (params?: PersonnelQuery) => request.get<Personnel[]>('/transport/personnel', { params }),
list: (params?: PersonnelQuery) =>
request.get<PlListResponse<Personnel>>('/transport/personnel', { params }),
get: (id: string) => request.get<PersonnelDetail>(`/transport/personnel/${id}`),
create: (data: PersonnelPayload) => request.post<PersonnelDetail>('/transport/personnel', data),
update: (id: string, data: Partial<PersonnelPayload>) =>
@@ -186,6 +202,8 @@ export interface Driver {
export interface DriverQuery {
q?: string
page?: number
pageSize?: number
}
function personnelToDriver(p: Personnel): Driver {
@@ -211,9 +229,19 @@ function driverToPersonnelPayload(data: Partial<Driver>): PersonnelPayload {
export const driverApi = {
list: async (params?: DriverQuery) => {
const res = await personnelApi.list({ q: params?.q })
const rows = (res.data ?? res) as Personnel[]
return { ...res, data: rows.map(personnelToDriver) }
const res = await personnelApi.list({
q: params?.q,
page: params?.page,
pageSize: params?.pageSize
})
const { list, total } = pickBxPageResult<Personnel>(res)
return {
...res,
data: {
total,
list: list.map(personnelToDriver)
}
}
},
get: async (id: string) => {
const res = await personnelApi.get(id)
@@ -304,8 +332,11 @@ export interface Plan {
destinationNotes?: string
vehicleId?: string
licensePlate?: string
trailerNo?: string
driverId?: string
driverName?: string
personnelSummary?: string
resourceSnapshot?: TransportResourceSnapshot
transportId?: string
transportNo?: string
totalAmount: number
@@ -455,9 +486,11 @@ export interface Transport {
transportNo: string
vehicleId?: string
licensePlate?: string
trailerNo?: string
driverId?: string
driverName?: string
driverPhone?: string
resourceSnapshot?: TransportResourceSnapshot
carrierId?: string
carrierName?: string
transportMedium?: string
@@ -719,6 +752,7 @@ export const freightApi = {
request.post<FreightSettlement>('/transport/freight/', data),
update: (id: string, data: Partial<FreightSettlement>) =>
request.put<FreightSettlement>(`/transport/freight/${id}`, data),
cancel: (id: string) => request.post<FreightSettlement>(`/transport/freight/${id}/cancel`),
delete: (id: string) => request.delete(`/transport/freight/${id}`),
submitApproval: (id: string, data: FreightSettlementSubmitBody) =>
request.post<FreightSettlement>(`/transport/freight/${id}/submit-approval`, data),
+462 -27
View File
@@ -430,6 +430,232 @@ body {
}
}
// 面板级胶囊分段 Tab(薄荷绿渐变轨道 + 主色选中,对齐 zkxw 生产工单等页)
.panel-pill-tabs {
// 自定义按钮结构(生产工单 TabBar 等)
&:not(.el-tabs) {
display: flex;
justify-content: center;
margin: 12px 24px 4px;
}
&--start:not(.el-tabs) {
justify-content: flex-start;
}
&--lg:not(.el-tabs) {
margin: 16px 24px 8px;
.panel-pill-tabs__track {
gap: 6px;
padding: 5px;
border-radius: 10px;
}
.panel-pill-tabs__btn {
min-width: 140px;
padding: 10px 28px;
border-radius: 8px;
font-size: 14px;
}
}
&__track {
display: inline-flex;
gap: 4px;
padding: 4px;
background: linear-gradient(180deg, var(--primary-bg, #ecfdf5) 0%, #fff 100%);
border: 1px solid #d1fae5;
border-radius: 8px;
}
&__btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 108px;
padding: 8px 20px;
border: none;
border-radius: 6px;
background: transparent;
font-size: 13px;
font-weight: 500;
line-height: 1.4;
text-align: center;
color: var(--mint-text-muted, #64748b);
cursor: pointer;
white-space: nowrap;
transition:
background 0.15s,
color 0.15s,
box-shadow 0.15s;
&:hover:not(.is-active) {
color: #065f46;
}
&.is-active {
background: var(--primary, #10b981);
color: #fff;
box-shadow: 0 1px 3px rgba(16, 185, 129, 0.28);
font-weight: 500;
&:hover {
color: #fff;
background: var(--primary, #10b981);
}
}
}
&__badge {
position: absolute;
top: -5px;
right: -4px;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
background: #d97706;
color: #fff;
font-size: 11px;
font-weight: 600;
line-height: 1;
border: 2px solid #d1fae5;
pointer-events: none;
box-sizing: border-box;
}
&__btn.is-active &__badge {
border-color: var(--primary, #10b981);
}
&__badge--zero {
background: #94a3b8;
}
&__badge--dual {
min-width: 34px;
padding: 0 6px;
font-size: 10px;
letter-spacing: -0.02em;
}
&__btn.is-active &__badge--zero {
background: rgba(255, 255, 255, 0.92);
color: var(--primary-dark, #059669);
border-color: var(--primary, #10b981);
}
// el-tabs 结构
&.el-tabs {
> .el-tabs__header {
display: flex;
justify-content: center;
margin: 12px 24px 4px;
padding: 0;
border: none;
}
.el-tabs__nav-wrap {
flex: 0 1 auto;
&::after {
display: none;
}
}
.el-tabs__nav-scroll {
display: flex;
justify-content: center;
}
.el-tabs__nav {
display: inline-flex;
gap: 4px;
float: none;
padding: 4px;
background: linear-gradient(180deg, var(--primary-bg, #ecfdf5) 0%, #fff 100%);
border: 1px solid #d1fae5;
border-radius: 8px;
}
.el-tabs__active-bar {
display: none;
}
.el-tabs__item {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
box-sizing: border-box;
min-width: 108px;
height: auto !important;
margin: 0 !important;
padding: 8px 20px !important;
border: none !important;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
line-height: 1.4;
text-align: center !important;
color: var(--mint-text-muted, #64748b);
transition:
background 0.15s,
color 0.15s,
box-shadow 0.15s;
&:hover:not(.is-active) {
color: #065f46;
}
&.is-active {
background: var(--primary, #10b981);
color: #fff;
box-shadow: 0 1px 3px rgba(16, 185, 129, 0.28);
font-weight: 500;
&:hover {
color: #fff;
background: var(--primary, #10b981);
}
}
}
}
&--start.el-tabs {
> .el-tabs__header {
justify-content: flex-start;
}
.el-tabs__nav-scroll {
justify-content: flex-start;
}
}
&--lg.el-tabs {
> .el-tabs__header {
margin: 16px 24px 8px;
}
.el-tabs__nav {
gap: 6px;
padding: 5px;
border-radius: 10px;
}
.el-tabs__item {
min-width: 140px;
padding: 10px 28px !important;
border-radius: 8px;
font-size: 14px;
}
}
}
.page-container .panel-tabs {
border-bottom: 1px solid var(--mint-border);
@@ -789,7 +1015,8 @@ body {
}
.filter-actions-end,
.toolbar-actions {
.toolbar-actions,
.list-toolbar-end {
display: flex;
align-items: center;
gap: 8px;
@@ -797,6 +1024,44 @@ body {
margin-left: auto;
}
.list-toolbar-end {
flex-shrink: 0;
padding-left: 8px;
}
.list-toolbar-end-btn.el-button,
.list-toolbar-end-btn.btn-ghost.el-button {
--el-button-border-color: transparent;
--el-button-hover-border-color: transparent;
--el-button-active-border-color: transparent;
width: 36px;
min-width: 36px;
height: 36px;
padding: 0;
border-radius: 8px;
color: var(--mint-text-muted);
border: none !important;
box-shadow: none;
background: transparent;
.el-icon {
font-size: 16px;
}
&:hover,
&:focus-visible {
color: var(--mint-primary-dark);
border: none !important;
box-shadow: none;
background: var(--mint-primary-bg);
}
&:active {
border: none !important;
box-shadow: none;
}
}
.list-toolbar,
.toolbar {
padding: 0;
@@ -819,6 +1084,7 @@ body {
.toolbar .el-button + .el-button,
.toolbar-right .el-button + .el-button,
.toolbar-actions .el-button + .el-button,
.list-toolbar-end .el-button + .el-button,
.filter-actions-end .el-button + .el-button {
margin-left: 0 !important;
}
@@ -858,6 +1124,11 @@ body {
.pagination.list-pager-bar,
.list-footer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px 12px;
margin-top: 0;
padding: 10px 16px 12px;
border-top: 1px solid var(--mint-border);
@@ -865,6 +1136,12 @@ body {
border-radius: 0 0 var(--mint-radius) var(--mint-radius);
}
/* 分页条左侧说明文案(BX range / 分类 total-count 等)与右侧控件区分 */
.pagination.list-pager-bar .list-range,
.pagination.list-pager-bar .total-count {
margin-right: auto;
}
.pagination {
display: flex;
justify-content: flex-end;
@@ -1034,6 +1311,26 @@ body {
flex-shrink: 0;
}
/* 标准 CRUD / 大表单弹窗遮罩:顶对齐留白,避免 align-center 高弹窗与顶栏贴齐(protocol-dialog-mask 同构,全局兜底) */
.protocol-dialog-mask,
.lab-test-item-dialog-mask,
.el-overlay:has(.el-dialog.dialog-form-layout),
.el-overlay:has(.el-dialog.system-view-dialog),
.el-overlay:has(.el-dialog.order-module-dialog) {
align-items: flex-start !important;
backdrop-filter: none !important;
.el-overlay-dialog {
margin-top: 16px;
margin-bottom: auto;
}
.el-dialog.dialog-form-layout,
.el-dialog.order-module-dialog {
max-width: calc(100vw - 48px);
}
}
/* 弹窗异步加载:骨架占位,减轻先小后大 */
.dialog-scroll.is-view-loading,
.dialog-form-body.is-dialog-loading,
@@ -1074,7 +1371,22 @@ body {
border-bottom: 1px solid var(--mint-border);
}
/* 弹窗 / 抽屉内键值展示:上下布局、黑色文案 */
/* Portal 一体化顶栏(对齐 zkxw TOP_LEFT 顶栏 56px + Fresh Mint) */
#app-portal-header .portal-header {
background: var(--mint-surface) !important;
}
.app-layout-portal #app-worktab .worktab-strip {
margin-bottom: 0 !important;
padding: 0 24px 4px !important;
}
.app-layout-portal #app-sidebar .layout-sidebar {
border-right: 1px solid var(--mint-border) !important;
background: var(--mint-surface);
}
/* 弹窗 / 抽屉内键值展示:上下布局、深色正文(对齐 zkxw Token 链) */
:is(.el-dialog, .system-view-dialog, .el-drawer) {
:is(.view-kv, [class*='-view-kv']:not([class*='-grid']), .pl-plan-kv) {
display: flex !important;
@@ -1088,14 +1400,14 @@ body {
margin: 0;
font-size: 13px;
font-weight: 400 !important;
color: #000 !important;
color: var(--mint-text-muted) !important;
line-height: 1.4;
}
dd {
margin: 0;
font-weight: 400;
color: #000 !important;
color: var(--mint-text) !important;
word-break: break-word;
line-height: 1.5;
}
@@ -1104,7 +1416,7 @@ body {
.view-field-label {
font-size: 13px;
font-weight: 400 !important;
color: #000 !important;
color: var(--mint-text-muted) !important;
line-height: 1.4;
}
@@ -1122,22 +1434,22 @@ body {
.el-dialog.dialog-form-layout {
.el-form-item__label {
color: #000 !important;
color: var(--mint-text) !important;
font-weight: 400 !important;
}
.el-input__inner,
.el-textarea__inner,
.el-select__selected-item {
color: #000 !important;
color: var(--mint-text) !important;
font-weight: 400 !important;
}
.el-input.is-disabled .el-input__inner,
.el-input.is-disabled .el-input__wrapper,
.el-textarea.is-disabled .el-textarea__inner {
color: #000 !important;
-webkit-text-fill-color: #000 !important;
color: var(--mint-text) !important;
-webkit-text-fill-color: var(--mint-text) !important;
font-weight: 400 !important;
}
}
@@ -1211,12 +1523,12 @@ body {
--el-table-border-color: var(--mint-border);
--el-table-row-hover-bg-color: var(--mint-row-hover);
--el-table-text-color: var(--mint-text);
--el-table-header-text-color: var(--mint-text);
--el-table-header-text-color: var(--mint-text-muted);
font-size: 14px;
width: 100%;
th.el-table__cell {
color: var(--mint-text);
color: var(--mint-text-muted);
font-size: 13px;
font-weight: 600;
}
@@ -1239,21 +1551,144 @@ body {
}
}
/* 全局:业务界面文字统一主文本色(消除 muted 灰字) */
.text-muted {
color: var(--mint-text) !important;
}
html:not(.dark) {
.text-g-500,
.text-g-600,
.text-g-700,
.text-g-800 {
color: var(--mint-text) !important;
}
}
/* 筛选区 label:13px 次要色(对齐 zkxw filter-form 实践;filter-hint 仍为 11px) */
.page-container .filter-block .el-form-item__label,
.console-container .filter-block .el-form-item__label,
.page-container .filter-form .el-form-item__label,
.console-container .filter-form .el-form-item__label {
color: var(--mint-text) !important;
.console-container .filter-form .el-form-item__label,
.el-dialog .filter-block .el-form-item__label,
.el-dialog .filter-form .el-form-item__label {
font-size: 13px !important;
font-weight: 500;
color: var(--mint-text-muted) !important;
}
/* label 在上(如 order 筛选) */
.page-container .filter-form.el-form--label-top .el-form-item__label,
.console-container .filter-form.el-form--label-top .el-form-item__label,
.el-dialog .filter-form.el-form--label-top .el-form-item__label {
padding: 0 0 4px !important;
line-height: 1.2 !important;
height: auto !important;
}
/* label 与输入左右布局:label 右侧留间距 */
.page-container .filter-form:not(.el-form--label-top) .el-form-item__label,
.console-container .filter-form:not(.el-form--label-top) .el-form-item__label,
.el-dialog .filter-form:not(.el-form--label-top) .el-form-item__label,
.page-container .filter-block:not(:has(.el-form--label-top)) .el-form-item__label,
.console-container .filter-block:not(:has(.el-form--label-top)) .el-form-item__label,
.el-dialog .filter-block:not(:has(.el-form--label-top)) .el-form-item__label {
padding: 0 8px 0 0 !important;
line-height: 36px !important;
height: 36px !important;
}
/* 筛选栅格:表单项撑满列宽,控件全宽 */
.page-container .filter-form,
.console-container .filter-form,
.el-dialog .filter-form {
margin: 0;
}
.page-container .filter-grid .el-form-item,
.console-container .filter-grid .el-form-item,
.el-dialog .filter-grid .el-form-item {
margin-bottom: 0;
min-width: 0;
width: 100%;
}
.page-container .filter-form:not(.el-form--label-top) .el-form-item,
.console-container .filter-form:not(.el-form--label-top) .el-form-item,
.el-dialog .filter-form:not(.el-form--label-top) .el-form-item {
align-items: center;
}
.page-container .filter-form .el-form-item__content,
.console-container .filter-form .el-form-item__content,
.el-dialog .filter-form .el-form-item__content {
flex: 1;
min-width: 0;
}
.page-container .filter-form .el-form-item__content > .el-input,
.page-container .filter-form .el-form-item__content > .el-select,
.page-container .filter-form .el-form-item__content > .el-date-editor,
.page-container .filter-form .el-form-item__content > .el-tree-select,
.page-container .filter-form .el-form-item__content > .el-cascader,
.page-container .filter-form .el-form-item__content > .el-input-number,
.console-container .filter-form .el-form-item__content > .el-input,
.console-container .filter-form .el-form-item__content > .el-select,
.console-container .filter-form .el-form-item__content > .el-date-editor,
.console-container .filter-form .el-form-item__content > .el-tree-select,
.console-container .filter-form .el-form-item__content > .el-cascader,
.console-container .filter-form .el-form-item__content > .el-input-number,
.el-dialog .filter-form .el-form-item__content > .el-input,
.el-dialog .filter-form .el-form-item__content > .el-select,
.el-dialog .filter-form .el-form-item__content > .el-date-editor,
.el-dialog .filter-form .el-form-item__content > .el-tree-select,
.el-dialog .filter-form .el-form-item__content > .el-cascader,
.el-dialog .filter-form .el-form-item__content > .el-input-number {
width: 100%;
}
.page-container .filter-form .el-select .el-select__wrapper,
.page-container .filter-form .el-tree-select .el-select__wrapper,
.console-container .filter-form .el-select .el-select__wrapper,
.console-container .filter-form .el-tree-select .el-select__wrapper,
.el-dialog .filter-form .el-select .el-select__wrapper,
.el-dialog .filter-form .el-tree-select .el-select__wrapper {
width: 100%;
}
.page-container .filter-block .filter-hint,
.console-container .filter-block .filter-hint {
color: var(--mint-text-muted);
}
.list-column-setting-popover.el-popper {
padding: 12px 14px 10px;
}
.list-column-setting {
display: flex;
flex-direction: column;
gap: 10px;
}
.list-column-setting-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.list-column-setting-title {
font-size: 13px;
font-weight: 600;
color: var(--mint-text);
}
.list-column-setting-reset {
font-size: 12px;
padding: 0 4px;
}
.list-column-setting-check-all {
padding-bottom: 8px;
border-bottom: 1px solid var(--mint-border);
}
.list-column-setting-list {
display: flex;
flex-direction: column;
gap: 6px;
padding-top: 2px;
}
.list-column-setting-item {
display: flex;
margin-right: 0;
width: 100%;
}
@@ -7,7 +7,7 @@
'white': #ffffff,
'black': #000000,
'success': (
'base': #10b981
'base': #059669
),
'warning': (
'base': #ffae1f
+23 -9
View File
@@ -4,14 +4,14 @@
// 系统主色
--main-color: var(--mint-primary);
--el-color-primary: var(--mint-primary) !important;
--el-color-primary-light-3: #34d399 !important;
--el-color-primary-light-5: #6ee7b7 !important;
--el-color-primary-light-7: #a7f3d0 !important;
--el-color-primary-light-8: #d1fae5 !important;
--el-color-primary-light-3: #0d9d6e !important;
--el-color-primary-light-5: #34a88a !important;
--el-color-primary-light-7: #6bb99a !important;
--el-color-primary-light-8: #a8d4c4 !important;
--el-color-primary-light-9: var(--mint-primary-bg) !important;
--el-color-primary-dark-2: var(--mint-primary-dark) !important;
--el-border-color: var(--mint-border) !important;
--el-border-color-light: #eaf4ef !important;
--el-border-color-light: #dfece6 !important;
--el-fill-color-light: var(--mint-table-head) !important;
--el-bg-color: var(--mint-surface) !important;
--el-bg-color-page: var(--mint-bg-page) !important;
@@ -20,7 +20,7 @@
// regular 用于大量控件正文(input value / select value / 普通文案)
--el-text-color-regular: var(--mint-text) !important;
// secondary 用于次级信息(说明/弱提示)
--el-text-color-secondary: var(--mint-text) !important;
--el-text-color-secondary: var(--mint-text-muted) !important;
// placeholder 全局占位符颜色(含弹窗 Teleport 场景)
--el-text-color-placeholder: var(--mint-text-placeholder) !important;
--el-mask-color: rgba(15, 23, 42, 0.35) !important;
@@ -743,13 +743,13 @@ body .el-table thead {
--el-table-border-color: var(--mint-border);
--el-table-row-hover-bg-color: var(--mint-row-hover);
--el-table-text-color: var(--mint-text);
--el-table-header-text-color: var(--mint-text);
--el-table-header-text-color: var(--mint-text-muted);
font-size: 14px;
}
.page-container .el-table th.el-table__cell,
.console-container .el-table th.el-table__cell {
color: var(--mint-text);
color: var(--mint-text-muted);
font-size: 12px;
font-weight: 600;
}
@@ -1204,13 +1204,27 @@ textarea::placeholder {
border-bottom: none !important;
}
// 列表与分页之间:保留列表底部线,去掉分页顶部线
// 列表与分页之间:保留列表底部线,去掉分页顶部线;分页控件右对齐(§7.9)
.page-container .pagination.list-pager-bar,
.console-container .pagination.list-pager-bar,
.el-dialog .pagination.list-pager-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px 12px;
border-top: none !important;
}
.page-container .pagination.list-pager-bar .list-range,
.page-container .pagination.list-pager-bar .total-count,
.console-container .pagination.list-pager-bar .list-range,
.console-container .pagination.list-pager-bar .total-count,
.el-dialog .pagination.list-pager-bar .list-range,
.el-dialog .pagination.list-pager-bar .total-count {
margin-right: auto;
}
.page-container .el-button.auto-icon-plus,
.console-container .el-button.auto-icon-plus {
--auto-icon-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='black' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M12 5v14M5 12h14'/%3E%3C/svg%3E");
@@ -1,11 +1,12 @@
// Fresh Mint 设计令牌(与 test/fresh-mint-green.html、docs/web-admin-ui-spec-fresh-mint.md 一致)
// Fresh Mint 设计令牌(与 docs/web-admin-ui-spec-fresh-mint.md 一致;Step1 对齐 zkxw 语义色)
:root {
--mint-primary: #10b981;
--mint-primary-dark: #059669;
--mint-primary-bg: #ecfdf5;
--mint-text: #1e293b;
--mint-text-muted: #64748b;
--mint-text-placeholder: #94a3b8;
/* 略浅于主文字:label / 说明 / 表头等次要文字,可读且保留层次(对齐 zkxw 实践) */
--mint-text-muted: #334155;
--mint-text-placeholder: #9ca3af;
--mint-border: #d9e8e2;
--mint-bg-page: #fefefe;
--mint-surface: #ffffff;
@@ -15,4 +16,36 @@
--mint-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
--mint-radius: 10px;
--mint-content-padding: 24px;
/* 成功 / 启用等语义色(深一档于主色,便于与 primary Tag 区分) */
--mint-success: #059669;
--mint-success-dark: #047857;
--mint-success-bg: #d1fae5;
--mint-success-text: #047857;
--primary: var(--mint-primary);
--primary-dark: var(--mint-primary-dark);
--primary-bg: var(--mint-primary-bg);
/* bx/db 模块沿用的语义别名(避免在模块 SCSS 重复 :root) */
--text: var(--mint-text);
--text-muted: var(--mint-text-muted);
--border: var(--mint-border);
--bg-page: var(--mint-bg-page);
--surface: var(--mint-surface);
--table-head: var(--mint-table-head);
--row-alt: var(--mint-row-alt);
--row-hover: var(--mint-row-hover);
--shadow: var(--mint-shadow);
--radius: var(--mint-radius);
--radius-md: 8px;
--control-h: 36px;
--success: var(--mint-success);
--success-bg: var(--mint-success-bg);
--warning: #d97706;
--warning-bg: #fffbeb;
--danger: #dc2626;
--danger-bg: #fef2f2;
--info: #64748b;
--info-bg: #f8fafc;
}
+1 -1
View File
@@ -155,7 +155,7 @@ watch(dialogVisible, (val) => {
const queryParams = ref<ContactQuery>({
pageNum: 1,
pageSize: 10,
pageSize: 20,
customerId: props.customerId,
contactName: undefined,
mobile: undefined,
@@ -155,7 +155,7 @@ watch(dialogVisible, (val) => {
const queryParams = ref<Record<string, any>>({
pageNum: 1,
pageSize: 10,
pageSize: 20,
customerName: undefined,
customerNo: undefined,
customerType: undefined,
@@ -14,6 +14,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { DEFAULT_LIST_PAGE_SIZE } from '@/utils/table/tableConfig'
const props = defineProps<{
total: number
@@ -33,7 +34,7 @@ const pageModel = computed({
})
const limitModel = computed({
get: () => props.limit ?? 10,
get: () => props.limit ?? DEFAULT_LIST_PAGE_SIZE,
set: (val: number) => emit('update:limit', val)
})
@@ -171,7 +171,7 @@ watch(dialogVisible, (val) => {
const queryParams = ref<OpportunityQuery>({
pageNum: 1,
pageSize: 10,
pageSize: 20,
opportunityName: undefined,
opportunityNo: undefined,
customerId: props.customerId,
+1 -1
View File
@@ -181,7 +181,7 @@ watch(dialogVisible, (val) => {
const queryParams = ref<QuoteQuery>({
pageNum: 1,
pageSize: 10,
pageSize: 20,
quoteNo: undefined,
quoteTitle: undefined,
customerName: undefined,
+1 -1
View File
@@ -114,7 +114,7 @@ watch(dialogVisible, (val) => {
const queryParams = ref({
pageNum: 1,
pageSize: 10,
pageSize: 20,
keyword: undefined as string | undefined
})
@@ -207,7 +207,7 @@ const planCountMap = ref<Record<string, number>>({})
const queryParams = reactive<ContractQuery>({
pageNum: 1,
pageSize: 10,
pageSize: 20,
contractNo: '',
contractName: '',
customerName: '',
@@ -7,7 +7,11 @@
:loading="loading"
width="1200px"
modal-class="order-module-dialog-mask"
/>
>
<template #hero-actions>
<slot name="hero-actions" />
</template>
</SystemViewDialog>
</template>
<script setup lang="ts">
@@ -7,7 +7,11 @@
:loading="loading"
width="1200px"
modal-class="order-module-dialog-mask"
/>
>
<template #hero-actions>
<slot name="hero-actions" />
</template>
</SystemViewDialog>
</template>
<script setup lang="ts">
@@ -7,7 +7,11 @@
:loading="loading"
width="1200px"
modal-class="order-module-dialog-mask"
/>
>
<template #hero-actions>
<slot name="hero-actions" />
</template>
</SystemViewDialog>
</template>
<script setup lang="ts">
@@ -0,0 +1,156 @@
<!-- 列表工具栏右侧:刷新 + 列显隐(勾选持久化由 usePersistedListColumns 负责) -->
<template>
<div class="list-toolbar-end">
<el-tooltip content="刷新列表" placement="top">
<el-button
class="list-toolbar-end-btn"
:loading="loading"
aria-label="刷新列表"
@click="emit('refresh')"
>
<el-icon><Refresh /></el-icon>
</el-button>
</el-tooltip>
<el-popover
v-if="showColumns"
placement="bottom-end"
trigger="click"
:width="300"
popper-class="list-column-setting-popover"
>
<template #reference>
<el-button class="list-toolbar-end-btn" aria-label="列设置">
<el-icon><Setting /></el-icon>
</el-button>
</template>
<div class="list-column-setting">
<div class="list-column-setting-head">
<span class="list-column-setting-title">列展示</span>
<button type="button" class="btn-link list-column-setting-reset" @click="handleReset">
恢复默认
</button>
</div>
<el-checkbox
v-model="checkAllProxy"
:indeterminate="isIndeterminate"
class="list-column-setting-check-all"
>
全选
</el-checkbox>
<el-scrollbar max-height="320px" class="list-column-setting-scroll">
<div class="list-column-setting-list">
<el-checkbox
v-for="col in columnDefs"
:key="col.prop"
:model-value="visibleColumns.includes(col.prop)"
:disabled="col.disabled"
class="list-column-setting-item"
@update:model-value="(val) => handleColumnToggle(col.prop, !!val)"
>
{{ col.label }}
</el-checkbox>
</div>
</el-scrollbar>
</div>
</el-popover>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Refresh, Setting } from '@element-plus/icons-vue'
import type { ListColumnDef } from '@/composables/usePersistedListColumns'
defineOptions({ name: 'ListToolbarEndActions' })
const props = withDefaults(
defineProps<{
loading?: boolean
columnDefs?: ListColumnDef[]
visibleColumns?: string[]
checkAll?: boolean
isIndeterminate?: boolean
showColumns?: boolean
}>(),
{
loading: false,
columnDefs: () => [],
visibleColumns: () => [],
checkAll: false,
isIndeterminate: false,
showColumns: undefined
}
)
const emit = defineEmits<{
refresh: []
'update:visibleColumns': [value: string[]]
'update:checkAll': [value: boolean]
resetColumns: []
}>()
const showColumns = computed(() => {
if (props.showColumns !== undefined) return props.showColumns
return props.columnDefs.length > 0
})
const checkAllProxy = computed({
get: () => props.checkAll,
set: (value: boolean) => emit('update:checkAll', value)
})
const handleColumnToggle = (prop: string, visible: boolean) => {
const next = new Set(props.visibleColumns)
if (visible) {
next.add(prop)
} else {
next.delete(prop)
}
const ordered = props.columnDefs
.filter((col) => col.disabled || next.has(col.prop))
.map((col) => col.prop)
emit('update:visibleColumns', ordered)
}
const handleReset = () => {
emit('resetColumns')
}
</script>
<style scoped lang="scss">
.list-toolbar-end-btn.el-button {
--el-button-border-color: transparent;
--el-button-hover-border-color: transparent;
--el-button-active-border-color: transparent;
width: 36px;
min-width: 36px;
height: 36px;
padding: 0;
border-radius: 8px;
color: var(--mint-text-muted);
border: none !important;
box-shadow: none;
background: transparent;
.el-icon {
font-size: 16px;
}
&:hover,
&:focus-visible {
color: var(--mint-primary-dark);
border: none !important;
box-shadow: none;
background: var(--mint-primary-bg);
}
&:active {
border: none !important;
box-shadow: none;
}
}
</style>
@@ -13,6 +13,9 @@
@closed="handleClosed"
>
<div class="carrier-view-body" v-loading="loading">
<div v-if="$slots['hero-actions']" class="carrier-view-hero-actions">
<slot name="hero-actions" />
</div>
<section class="carrier-view-top">
<h3 class="carrier-view-block-title">承运商信息</h3>
<ViewKvTable v-if="carrier" :fields="carrierViewFields" mode="triple" />
@@ -78,6 +81,7 @@
<script setup lang="ts">
import { Close } from '@element-plus/icons-vue'
import { carrierApi, vehicleApi, type Carrier } from '@/api/pl'
import { pickBxList } from '@/utils/http/bx'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import PlVehicleViewPanel from '@/components/business/pl-vehicle-view-panel/index.vue'
import ViewKvTable from '@/components/business/system-view-dialog/ViewKvTable.vue'
@@ -170,10 +174,10 @@ async function loadDetail(carrierId: string) {
try {
const [carrierRes, vehicleListRes] = await Promise.all([
carrierApi.get(carrierId),
vehicleApi.list({ carrierId })
vehicleApi.list({ carrierId, page: 1, pageSize: 100 })
])
carrier.value = ((carrierRes.data ?? carrierRes) || null) as Carrier | null
const vehicles = ((vehicleListRes.data ?? vehicleListRes) || []) as { id: string }[]
const vehicles = pickBxList<{ id: string }>(vehicleListRes)
vehicleBundles.value = await loadVehicleViewBundles(vehicles.map(item => item.id))
activeTab.value = vehicleBundles.value[0]?.detail.id || ''
} catch {
@@ -199,6 +203,16 @@ function handleClosed() {
min-height: 120px;
}
.carrier-view-hero-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding-bottom: 16px;
border-bottom: 1px solid #e2e8f0;
}
.carrier-view-block-title {
margin: 0 0 12px;
font-size: 15px;
@@ -81,7 +81,7 @@ function buildCertViewFields(cert: CertRead): ViewField[] {
.view-empty-hint {
margin: 0;
font-size: 13px;
color: #000;
color: var(--mint-text-muted);
line-height: 1.5;
}
@@ -11,15 +11,20 @@
>
<div v-loading="loading" class="view-dialog-body">
<div v-if="settlement" class="view-hero">
<div class="view-hero-main">
<h3 class="view-hero-title">{{ settlement.settlementNo }}</h3>
<el-tag :type="getSettlementStatusTagType(settlement.settlementStatus)" size="small">
{{ getSettlementStatusLabel(settlement.settlementStatus) }}
</el-tag>
<div class="view-hero-top">
<div class="view-hero-main">
<h3 class="view-hero-title">{{ settlement.settlementNo }}</h3>
<el-tag :type="getSettlementStatusTagType(settlement.settlementStatus)" size="small">
{{ getSettlementStatusLabel(settlement.settlementStatus) }}
</el-tag>
</div>
<div class="view-hero-summary">
<span class="view-hero-label">运费合计</span>
<span class="view-hero-amount">{{ formatAmount(settlement.totalFreight) }}</span>
</div>
</div>
<div class="view-hero-summary">
<span class="view-hero-label">运费合计</span>
<span class="view-hero-amount">{{ formatAmount(settlement.totalFreight) }}</span>
<div v-if="$slots['hero-actions']" class="view-hero-actions">
<slot name="hero-actions" />
</div>
</div>
@@ -225,9 +230,8 @@ defineExpose({ open })
.view-hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-direction: column;
gap: 12px;
padding: 16px;
margin-bottom: 16px;
border-radius: 10px;
@@ -235,6 +239,21 @@ defineExpose({ open })
border: 1px solid #a7f3d0;
}
.view-hero-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.view-hero-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.view-hero-main {
display: flex;
align-items: center;
@@ -681,7 +681,7 @@ function resetState() {
async function loadMasterData() {
const [vehicleRes, carrierRes] = await Promise.all([
vehicleApi.list({ vehicleStatus: 'available', excludeIneligibleCarrier: true }),
carrierApi.list({ status: '1' })
carrierApi.list({ status: '1', page: 1, pageSize: 100 })
])
vehicleList.value = pickBxList<Vehicle>(vehicleRes)
carrierList.value = pickBxList<Carrier>(carrierRes)
@@ -792,7 +792,7 @@ async function handleSubmit() {
const certMessage = buildMissingHazardCertMessage(products.value)
if (certMessage) {
await ElMessageBox.alert(`${certMessage}。请先上传购买证和运输证。`, '无法安排运输', {
await ElMessageBox.alert(`${certMessage}。请先上传购买证和合法使用证明。`, '无法安排运输', {
type: 'warning'
})
return
@@ -19,7 +19,7 @@
type="info"
:closable="false"
show-icon
title="易制毒或易制爆产品须上传购买证、运输证;安排运输时将校验是否齐全。"
title="易制毒或易制爆产品须上传购买证、合法使用证明;安排运输时将校验是否齐全。"
class="cert-tip"
/>
@@ -40,12 +40,12 @@
hint="支持图片、PDF 等,上传购买证"
/>
</el-form-item>
<el-form-item label="运输证" required>
<el-form-item label="合法使用证明" required>
<AttachmentUploadField
v-model="item.transportCertUrl"
folder="pl/plan-cert"
:limit="1"
hint="支持图片、PDF 等,上传运输证"
hint="支持图片、PDF 等,上传合法使用证明"
/>
</el-form-item>
</div>
@@ -133,7 +133,7 @@ async function handleSubmit() {
p => !p.purchaseCertUrl?.trim() || !p.transportCertUrl?.trim()
)
if (incomplete.length) {
ElMessage.warning('请为每个危化品产品上传购买证和运输证')
ElMessage.warning('请为每个危化品产品上传购买证和合法使用证明')
return
}
@@ -793,7 +793,7 @@ async function loadMasterData() {
try {
const [vehicleRes, carrierRes] = await Promise.all([
vehicleApi.list({ vehicleStatus: 'available', excludeIneligibleCarrier: true }),
carrierApi.list({ status: '1' })
carrierApi.list({ status: '1', page: 1, pageSize: 100 })
])
vehicleList.value = (vehicleRes.data ?? vehicleRes) || []
carrierList.value = (carrierRes.data ?? carrierRes) || []
@@ -27,7 +27,7 @@
disabled
/>
<span v-else class="pl-plan-list-cert-cell__missing">未上传</span>
<span class="pl-plan-list-cert-cell__cert-label">运输证</span>
<span class="pl-plan-list-cert-cell__cert-label">合法使用证明</span>
</div>
</div>
</div>
@@ -52,6 +52,10 @@
</template>
</section>
<div v-if="$slots['hero-actions']" class="pl-plan-view-hero-actions">
<slot name="hero-actions" />
</div>
<PlPlanViewTabs v-if="planId" ref="tabsRef" :plan-id="planId" />
<DbPoundSlipQrcodeDialog
@@ -174,6 +178,15 @@ function handleClosed() {
justify-content: flex-end;
}
.pl-plan-view-hero-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
margin-bottom: 12px;
}
.pl-plan-arrange-summary {
margin-bottom: 12px;
padding: 12px 16px;
@@ -38,7 +38,7 @@
</dd>
</div>
<div class="pl-plan-kv">
<dt>运输证</dt>
<dt>合法使用证明</dt>
<dd>
<AttachmentUploadField
v-if="item.transportCertUrl"
@@ -781,14 +781,14 @@
.pl-plan-form-empty-hint {
margin: 0;
font-size: 13px;
color: #64748b;
color: var(--mint-text-muted);
}
.pl-plan-view-remark {
margin: 0;
font-size: 13px;
font-weight: 600;
color: #000;
color: var(--mint-text);
line-height: 1.5;
word-break: break-word;
white-space: pre-wrap;
@@ -810,7 +810,7 @@
dt {
margin: 0 0 4px;
font-size: 13px;
color: #64748b;
color: var(--mint-text-muted);
}
dd {
@@ -943,7 +943,7 @@
margin-bottom: 8px;
font-size: 13px;
font-weight: 500;
color: #64748b;
color: var(--mint-text-muted);
}
.pl-plan-cert-block {
@@ -1010,7 +1010,7 @@
.pl-plan-site-feedback-batch {
font-size: 12px;
color: #64748b;
color: var(--mint-text-muted);
}
.pl-plan-site-feedback-attachments {
@@ -1042,7 +1042,7 @@
margin: 12px 0 8px;
font-size: 12px;
font-weight: 600;
color: #64748b;
color: var(--mint-text-muted);
}
.pl-plan-dibang-pending {
File diff suppressed because it is too large. Load diff
@@ -69,15 +69,15 @@
dt {
margin: 0;
font-size: 13px;
font-weight: 400;
color: #000;
font-weight: 500;
color: var(--mint-text-muted);
line-height: 1.4;
}
dd {
margin: 0;
font-weight: 600;
color: #000;
color: var(--mint-text);
word-break: break-word;
line-height: 1.5;
}
@@ -403,7 +403,7 @@
.view-hero-meta {
margin: 0;
font-size: 13px;
color: #000;
color: var(--mint-text-muted);
line-height: 1.5;
}
@@ -429,8 +429,8 @@
.view-field-label {
margin: 0 0 8px;
font-size: 13px;
font-weight: 400;
color: #000;
font-weight: 500;
color: var(--mint-text-muted);
line-height: 1.4;
}
@@ -459,7 +459,7 @@
.view-field-group .view-empty-hint {
margin: 0;
font-size: 13px;
color: #000;
color: var(--mint-text-muted);
line-height: 1.5;
}
@@ -3,7 +3,7 @@
height: 100vh;
user-select: none;
scrollbar-width: none;
border-right: none;
border-right: 1px solid var(--mint-border, #d9e8e2);
background: var(--mint-surface, #fff);
&.layout-sidebar-portal {
@@ -106,18 +106,18 @@
overflow: hidden;
background: var(--mint-surface, #fff);
// 长菜单名完整展示:侧栏宽度不变,内容区可横向滚动
// 长菜单名在侧栏内省略展示(对齐 zkxw Fresh Mint 侧栏)
:deep(.el-scrollbar__wrap) {
overflow-x: auto !important;
overflow-x: hidden !important;
}
:deep(.el-scrollbar__view) {
width: max-content;
min-width: 100%;
width: 100%;
min-width: 0;
}
:deep(.el-scrollbar__bar.is-horizontal) {
height: 4px;
display: none;
}
@media only screen and (width <= 640px) {
@@ -10,16 +10,23 @@ $popup-menu-padding: 8px;
$popup-menu-margin: 5px;
$popup-menu-radius: 6px;
// 通用菜单项样式(min-width 保证短标题仍铺满侧栏;width: max-content 允许长标题撑开以配合横向滚动)
// 通用菜单项样式(宽度铺满侧栏,长标题省略)
@mixin menu-item-base {
width: max-content;
min-width: calc(100% - 12px);
margin-left: 6px;
width: 100%;
min-width: 0;
margin-left: 0;
border-radius: 8px;
.menu-icon {
margin-left: -5px;
}
.menu-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
}
// 通用 hover 样式
@@ -31,17 +38,26 @@ $popup-menu-radius: 6px;
}
// 通用选中样式
@mixin menu-active($color, $bg-color, $icon-color: var(--theme-color)) {
@mixin menu-active($color, $bg-color, $icon-color: $color) {
.el-menu-item.is-active {
color: $color !important;
background-color: $bg-color;
font-weight: 600;
&:hover {
background-color: $bg-color !important;
}
.menu-icon {
.art-svg-icon {
color: $icon-color !important;
}
}
span,
.menu-name {
color: $color !important;
}
}
}
@@ -107,16 +123,8 @@ $popup-menu-radius: 6px;
// 菜单折叠样式
.menu-left-close {
.header {
.header-brand {
justify-content: center;
}
.header-brand-title {
display: none;
}
.logo {
margin: 0;
margin: 0 auto;
}
}
}
@@ -149,28 +157,44 @@ $popup-menu-radius: 6px;
// 菜单高度
.el-sub-menu__title,
.el-menu-item {
position: relative;
display: flex !important;
align-items: center;
min-width: 0;
height: $menu-height !important;
margin-bottom: 2px;
line-height: $menu-height !important;
overflow: visible !important;
overflow: hidden !important;
.menu-title-wrap {
position: relative;
display: flex;
flex: 1;
align-items: center;
min-width: 0;
padding-right: 28px;
}
.menu-name {
flex-shrink: 0;
flex-shrink: 1;
min-width: 0;
font-size: $menu-font-size !important;
font-weight: 500;
// Fresh Mint:菜单文字使用主文本色
color: var(--mint-text, #1e293b);
// Fresh Mint:默认菜单项为中性色(demo `.nav a`)
color: var(--mint-text-muted, #64748b);
text-rendering: geometricprecision;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.el-sub-menu > .el-sub-menu__title {
font-size: 12px !important;
font-weight: 700;
color: var(--mint-text, #1e293b) !important;
color: var(--mint-text-muted, #64748b) !important;
letter-spacing: 0.02em;
}
@@ -184,46 +208,48 @@ $popup-menu-radius: 6px;
.el-sub-menu .el-menu {
margin: 2px 0 6px 10px;
padding: 2px 0 2px 8px !important;
border-left: 2px solid var(--mint-border, #d9e8e2);
border-left: 2px solid var(--mint-border, #c5e0d4);
}
// 菜单折叠
.el-menu--collapse {
.el-sub-menu.is-active {
.el-sub-menu__title {
background-color: var(--primary, #10b981);
.menu-icon {
.art-svg-icon {
// 选中菜单图标颜色
color: var(--theme-color) !important;
color: #fff !important;
}
}
}
}
.el-menu-item.is-active {
background-color: var(--primary, #10b981);
color: #fff !important;
span,
.menu-name,
.menu-icon .art-svg-icon {
color: #fff !important;
}
}
}
// ---------------------- Design theme menu ----------------------
.el-menu-design {
@include theme-menu-base;
@include menu-active(
var(--mint-primary-dark, #059669),
var(--mint-primary-bg, #ecfdf5),
var(--mint-primary-dark, #059669)
);
@include menu-active(#fff, var(--primary, #10b981), #fff);
@include menu-hover($hover-bg-color);
.el-menu-item,
.el-sub-menu__title {
color: var(--mint-text, #1e293b) !important;
}
// 选中态:以浅底+图标强化为主,文字保持中性色(对齐 fresh-mint demo)
.el-menu-item.is-active span {
color: var(--mint-primary-dark, #059669) !important;
font-weight: 600 !important;
color: var(--mint-text-muted, #334155) !important;
}
.el-sub-menu__icon-arrow {
color: var(--mint-text);
color: var(--mint-text-muted, #64748b);
}
}
@@ -248,24 +274,25 @@ $popup-menu-radius: 6px;
}
.el-menu-item.is-active {
background-color: var(--mint-primary-bg, #ecfdf5);
color: var(--mint-primary-dark, #059669) !important;
background-color: var(--primary, #10b981);
color: #fff !important;
font-weight: 600;
span {
color: var(--mint-primary-dark, #059669) !important;
span,
.menu-name {
color: #fff !important;
font-weight: 600 !important;
}
.art-svg-icon {
color: var(--mint-primary-dark, #059669) !important;
color: #fff !important;
}
}
@include menu-hover($hover-bg-color);
.el-sub-menu__icon-arrow {
color: var(--mint-text);
color: var(--mint-text-muted, #64748b);
}
}
}
@@ -290,6 +317,22 @@ $popup-menu-radius: 6px;
@include popup-menu-base(var(--art-gray-200), var(--art-gray-900), var(--art-gray-200));
}
.menu-left-design-popper,
.menu-left-light-popper {
.el-menu--popup {
.el-menu-item.is-active {
color: #fff !important;
background-color: var(--primary, #10b981) !important;
span,
.menu-name,
.art-svg-icon {
color: #fff !important;
}
}
}
}
// 暗黑模式菜单样式
.dark {
.el-menu--vertical,
@@ -304,8 +347,19 @@ $popup-menu-radius: 6px;
color: var(--art-gray-800) !important;
}
// 选中的文字颜色跟图标颜色
.el-menu-item.is-active {
// 选中的文字颜色跟图标颜色(design / light 为深绿底白字)
.el-menu-design,
.el-menu-light {
.el-menu-item.is-active {
span,
.menu-name,
.menu-icon .art-svg-icon {
color: #fff !important;
}
}
}
.el-menu-dark .el-menu-item.is-active {
span,
.menu-icon .art-svg-icon {
color: var(--theme-color) !important;
@@ -49,22 +49,25 @@
.portal-header {
box-sizing: border-box;
display: flex;
gap: 16px;
gap: 12px;
align-items: center;
width: 100%;
height: 72px;
padding: 0 20px;
height: 56px;
padding: 0 var(--mint-content-padding, 24px);
user-select: none;
background: var(--default-bg-color, #fff);
border-bottom: 1px solid var(--art-card-border, #d9e8e2);
background: var(--mint-surface, #fff);
border-bottom: 1px solid var(--mint-border, #d9e8e2);
}
.portal-header-brand {
display: flex;
flex-shrink: 0;
gap: 10px;
gap: 12px;
align-items: center;
margin-right: 12px;
padding-right: 16px;
cursor: pointer;
border-right: 1px solid var(--mint-border, #d9e8e2);
}
.portal-header-logo {
@@ -72,18 +75,19 @@
}
.portal-header-title {
max-width: 180px;
margin: 0;
font-size: 18px;
font-weight: 600;
line-height: 1.2;
font-size: 16px;
font-weight: 700;
line-height: 1.35;
color: var(--mint-text, #1e293b);
white-space: nowrap;
white-space: normal;
}
.portal-header-nav {
flex: 1;
min-width: 0;
margin-left: 24px;
margin-left: 8px;
}
.portal-header-actions {
@@ -95,9 +95,9 @@
.portal-top-nav-list {
display: flex;
flex-wrap: nowrap;
gap: 10px;
gap: 4px;
align-items: center;
min-height: 56px;
min-height: 48px;
padding: 4px 0;
}
@@ -106,11 +106,13 @@
display: inline-flex;
flex-direction: column;
flex-shrink: 0;
gap: 4px;
gap: 2px;
align-items: center;
justify-content: center;
min-width: 72px;
padding: 8px 12px;
min-width: 64px;
height: 48px;
padding: 4px 10px;
margin: 0 2px;
cursor: pointer;
background: transparent;
border: none;
@@ -120,27 +122,24 @@
color 0.2s ease;
&__icon {
font-size: 22px;
font-size: 18px;
line-height: 1;
color: var(--mint-text-muted, #64748b);
}
&__label {
max-width: 88px;
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
line-height: 1.3;
color: var(--mint-text, #1e293b);
font-size: 13px;
line-height: 1.2;
color: var(--mint-text-muted, #64748b);
text-align: center;
white-space: nowrap;
}
&:hover:not(.is-active) {
background: var(--primary-bg, #ecfdf5);
.portal-top-nav-card__icon,
.portal-top-nav-card__label {
color: var(--primary-dark, #059669);
}
}
&.is-active {
@@ -149,6 +148,7 @@
.portal-top-nav-card__icon,
.portal-top-nav-card__label {
color: #fff;
font-weight: 600;
}
}
}
@@ -480,7 +480,7 @@
<style scoped>
.worktab-strip {
position: relative;
padding: 0 24px 12px !important;
padding: 0 24px 4px !important;
background: var(--mint-surface);
overflow: visible !important;
}
@@ -495,7 +495,7 @@
z-index: 1;
max-width: 220px;
padding: 10px 16px !important;
margin-bottom: -11px;
margin-bottom: -7px;
overflow: hidden;
font-size: 13px;
font-weight: 600;
@@ -0,0 +1,39 @@
<template>
<div
class="filter-date-shortcuts list-toolbar-date-shortcuts"
role="group"
:aria-label="ariaLabel"
>
<el-button
v-for="item in shortcutItems"
:key="item.type"
size="small"
class="btn-ghost date-shortcut-btn"
:class="{ 'is-active': active === item.type }"
@click="emit('change', item.type)"
>
{{ item.label }}
</el-button>
</div>
</template>
<script setup lang="ts">
import type { DateShortcutType } from '@/composables/useListDateShortcut'
defineProps<{
active: DateShortcutType
ariaLabel?: string
}>()
const emit = defineEmits<{
change: [type: DateShortcutType]
}>()
const shortcutItems: Array<{ type: DateShortcutType; label: string }> = [
{ type: 'all', label: '全部' },
{ type: 'yesterday', label: '昨日' },
{ type: 'today', label: '今日' },
{ type: 'tomorrow', label: '明日' },
{ type: 'week', label: '本周' }
]
</script>
@@ -169,7 +169,7 @@
// 默认分页常量
const DEFAULT_PAGINATION_OPTIONS: PaginationOptions = {
pageSizes: [10, 20, 30, 50, 100],
align: 'center',
align: 'right',
background: true,
layout: layout.value,
hideOnSinglePage: false,
@@ -0,0 +1,55 @@
import type { Ref } from 'vue'
export type DateShortcutType = 'all' | 'today' | 'tomorrow' | 'yesterday' | 'week'
function formatYmd(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
/**
* 列表工具栏计划日期快捷筛选(全部 / 昨日 / 今日 / 明日 / 本周)
*/
export function useListDateShortcut(dateRange: Ref<string[]>, onSearch: () => void) {
const activeDateShortcut = ref<DateShortcutType>('all')
function handleDateShortcut(type: DateShortcutType) {
activeDateShortcut.value = type
const today = new Date()
if (type === 'all') {
dateRange.value = []
} else if (type === 'today') {
const d = formatYmd(today)
dateRange.value = [d, d]
} else if (type === 'tomorrow') {
const t = new Date(today)
t.setDate(t.getDate() + 1)
const d = formatYmd(t)
dateRange.value = [d, d]
} else if (type === 'yesterday') {
const y = new Date(today)
y.setDate(y.getDate() - 1)
const d = formatYmd(y)
dateRange.value = [d, d]
} else if (type === 'week') {
const start = new Date(today)
const day = start.getDay() || 7
start.setDate(start.getDate() - day + 1)
const end = new Date(start)
end.setDate(start.getDate() + 6)
dateRange.value = [formatYmd(start), formatYmd(end)]
}
onSearch()
}
function resetDateShortcut() {
activeDateShortcut.value = 'all'
dateRange.value = []
}
return { activeDateShortcut, handleDateShortcut, resetDateShortcut }
}
@@ -0,0 +1,136 @@
import { computed, ref, watch } from 'vue'
export interface ListColumnDef {
prop: string
label: string
/** 默认是否显示;未指定时为 true */
defaultVisible?: boolean
/** 为 true 时不可取消勾选(如固定展示列) */
disabled?: boolean
}
export interface UsePersistedListColumnsOptions {
/** 默认可见列 prop 列表;未指定时取 defaultVisible !== false 的列 */
defaultVisibleProps?: string[]
}
function resolveDefaultVisibleProps(
columns: ListColumnDef[],
options?: UsePersistedListColumnsOptions
): string[] {
if (options?.defaultVisibleProps?.length) {
return [...options.defaultVisibleProps]
}
return columns.filter((col) => col.defaultVisible !== false).map((col) => col.prop)
}
function loadVisibleColumns(
storageKey: string,
columns: ListColumnDef[],
defaultVisibleProps: string[]
): string[] {
const allowed = new Set(columns.map((col) => col.prop))
if (typeof localStorage === 'undefined') {
return defaultVisibleProps.filter((prop) => allowed.has(prop))
}
const saved = localStorage.getItem(storageKey)
if (!saved) {
return defaultVisibleProps.filter((prop) => allowed.has(prop))
}
try {
const parsed = JSON.parse(saved)
if (!Array.isArray(parsed)) {
return defaultVisibleProps.filter((prop) => allowed.has(prop))
}
return parsed.filter((prop): prop is string => typeof prop === 'string' && allowed.has(prop))
} catch {
return defaultVisibleProps.filter((prop) => allowed.has(prop))
}
}
function persistVisibleColumns(storageKey: string, visibleColumns: string[]) {
if (typeof localStorage === 'undefined') return
try {
localStorage.setItem(storageKey, JSON.stringify(visibleColumns))
} catch {
// ignore quota / private mode errors
}
}
/**
* 列表可选列显隐 + localStorage 持久化。
* 勾选变化会自动写入 storageKey 对应键。
*/
export function usePersistedListColumns(
storageKey: string,
columns: ListColumnDef[],
options?: UsePersistedListColumnsOptions
) {
const columnDefs = columns
const defaultVisibleProps = resolveDefaultVisibleProps(columns, options)
const visibleColumns = ref<string[]>(
loadVisibleColumns(storageKey, columns, defaultVisibleProps)
)
watch(
visibleColumns,
(value) => {
persistVisibleColumns(storageKey, value)
},
{ deep: true }
)
const toggleableColumns = computed(() => columnDefs.filter((col) => !col.disabled))
const checkAll = computed({
get() {
const toggleable = toggleableColumns.value
if (!toggleable.length) return false
return toggleable.every((col) => visibleColumns.value.includes(col.prop))
},
set(checked: boolean) {
visibleColumns.value = checked
? toggleableColumns.value.map((col) => col.prop)
: columnDefs.filter((col) => col.disabled).map((col) => col.prop)
}
})
const isIndeterminate = computed(() => {
const toggleable = toggleableColumns.value
if (!toggleable.length) return false
const checkedCount = toggleable.filter((col) =>
visibleColumns.value.includes(col.prop)
).length
return checkedCount > 0 && checkedCount < toggleable.length
})
const isColumnVisible = (prop: string) => visibleColumns.value.includes(prop)
const setColumnVisible = (prop: string, visible: boolean) => {
const target = columnDefs.find((col) => col.prop === prop)
if (!target || target.disabled) return
const next = new Set(visibleColumns.value)
if (visible) {
next.add(prop)
} else {
next.delete(prop)
}
visibleColumns.value = columnDefs
.filter((col) => col.disabled || next.has(col.prop))
.map((col) => col.prop)
}
const resetToDefault = () => {
visibleColumns.value = [...defaultVisibleProps]
}
return {
columnDefs,
visibleColumns,
checkAll,
isIndeterminate,
isColumnVisible,
setColumnVisible,
resetToDefault
}
}
@@ -18,6 +18,7 @@ import {
} from '@/api/pl'
import type { PlanFormProductRow } from '@/composables/usePlContractToPlan'
import { loadVehicleViewBundle, type VehicleViewBundle } from '@/composables/usePlVehicleViewDetail'
import { snapshotToVehicleViewBundle } from '@/utils/pl-transport-resource-snapshot'
import { enrichPlanProductsHazardFromCrm } from '@/composables/usePlToxicProductCertCheck'
import { pickBxData } from '@/utils/http/bx'
@@ -235,7 +236,9 @@ export function usePlPlanViewDetail() {
)
}
if (data?.vehicleId) {
if (data?.resourceSnapshot) {
vehicleBundle.value = snapshotToVehicleViewBundle(data.resourceSnapshot)
} else if (data?.vehicleId) {
detailTasks.push(
loadVehicleViewBundle(data.vehicleId)
.then((bundle) => {
@@ -108,5 +108,5 @@ export function buildMissingHazardCertMessage(products: PlanProduct[]) {
const missing = findMissingHazardCerts(products)
if (!missing.length) return ''
const names = missing.map(p => `「${p.productName}」`).join('、')
return `以下产品须上传购买证和运输证:${names}`
return `以下产品须上传购买证和合法使用证明:${names}`
}
+3 -1
View File
@@ -1,5 +1,7 @@
import { computed, ref, type ComputedRef, type Ref } from 'vue'
import { DEFAULT_LIST_PAGE_SIZE } from '@/utils/table/tableConfig'
type Source<T> = Ref<T[]> | ComputedRef<T[]> | (() => T[])
interface UsePagedTableOptions {
@@ -9,7 +11,7 @@ interface UsePagedTableOptions {
export function usePagedTable<T>(source: Source<T>, options: UsePagedTableOptions = {}) {
const currentPage = ref(options.initialPage ?? 1)
const pageSize = ref(options.initialPageSize ?? 10)
const pageSize = ref(options.initialPageSize ?? DEFAULT_LIST_PAGE_SIZE)
const sourceRows = computed(() => {
if (typeof source === 'function') return source()
+3 -1
View File
@@ -1,6 +1,8 @@
import { ref, computed, watch } from 'vue'
import type { Ref } from 'vue'
import { DEFAULT_LIST_PAGE_SIZE } from '@/utils/table/tableConfig'
interface UseServerPagedOptions {
initialPage?: number
initialPageSize?: number
@@ -11,7 +13,7 @@ export function useServerPaged<T>(
options: UseServerPagedOptions = {},
) {
const currentPage = ref(options.initialPage ?? 1)
const pageSize = ref(options.initialPageSize ?? 10)
const pageSize = ref(options.initialPageSize ?? DEFAULT_LIST_PAGE_SIZE)
const total = ref(0)
const rows: Ref<T[]> = ref([]) as Ref<T[]>
const loading = ref(false)
+6 -6
View File
@@ -34,7 +34,7 @@ import {
createSmartDebounce,
createErrorHandler
} from '../../utils/table/tableUtils'
import { tableConfig } from '../../utils/table/tableConfig'
import { tableConfig, DEFAULT_LIST_PAGE_SIZE } from '../../utils/table/tableConfig'
// 类型推导工具类型
type InferApiParams<T> = T extends (params: infer P) => any ? P : never
@@ -205,7 +205,7 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
Object.assign(
{
[pageKey]: 1,
[sizeKey]: 10
[sizeKey]: DEFAULT_LIST_PAGE_SIZE
},
apiParams || {}
) as TParams
@@ -214,7 +214,7 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
// 分页配置
const pagination = reactive<Api.Common.PaginationParams>({
current: ((searchParams as Record<string, unknown>)[pageKey] as number) || 1,
size: ((searchParams as Record<string, unknown>)[sizeKey] as number) || 10,
size: ((searchParams as Record<string, unknown>)[sizeKey] as number) || DEFAULT_LIST_PAGE_SIZE,
total: 0
})
@@ -394,7 +394,7 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
if (err instanceof Error && err.message === '请求已取消') {
// 请求被取消,回到 idle 状态
loadingState.value = 'idle'
return { records: [], total: 0, current: 1, size: 10 }
return { records: [], total: 0, current: 1, size: DEFAULT_LIST_PAGE_SIZE }
}
// 状态机:请求失败,进入 error 状态
@@ -448,7 +448,7 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
const paramsRecord = searchParams as Record<string, unknown>
const defaultPagination = {
[pageKey]: 1,
[sizeKey]: (paramsRecord[sizeKey] as number) || 10
[sizeKey]: (paramsRecord[sizeKey] as number) || DEFAULT_LIST_PAGE_SIZE
}
// 清空所有搜索参数
@@ -482,7 +482,7 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
// 替换搜索参数:适用于表单查询,避免旧字段残留
const replaceSearchParams = (params?: Partial<TParams>): void => {
const paramsRecord = searchParams as Record<string, unknown>
const currentSize = pagination.size || ((paramsRecord[sizeKey] as number) ?? 10)
const currentSize = pagination.size || ((paramsRecord[sizeKey] as number) ?? DEFAULT_LIST_PAGE_SIZE)
Object.keys(searchParams).forEach((key) => {
if (key !== pageKey && key !== sizeKey) {
+82
View File
@@ -0,0 +1,82 @@
import type { AppRouteRecord } from '@/types/router'
/**
* Demo 模板页(Fresh Mint UI 参考)
* Hash:/#/demo/console、/#/demo/unit 等
*/
export const demoRoutes: AppRouteRecord[] = [
{
id: 'DEMO00000000000000000000000001',
name: 'DemoGroup',
path: '/demo',
component: '/index/index',
meta: {
title: 'Demo',
icon: 'ri:palette-line',
keepAlive: false,
roles: ['admin']
},
children: [
{
id: 'DEMO00000000000000000000000002',
name: 'DemoConsole',
path: 'console',
component: '/dashboard/console/index',
meta: {
title: '工作台',
keepAlive: false
}
},
{
id: 'DEMO00000000000000000000000003',
name: 'DemoUnit',
path: 'unit',
component: '/demo/unit/index',
meta: {
title: '标准列表(单位)',
keepAlive: true
}
},
{
id: 'DEMO00000000000000000000000004',
name: 'DemoOrder',
path: 'order',
component: '/demo/order/index',
meta: {
title: '复杂列表(订单)',
keepAlive: true
}
},
{
id: 'DEMO00000000000000000000000005',
name: 'DemoProduct',
path: 'product',
component: '/demo/product/index',
meta: {
title: '商品列表',
keepAlive: true
}
},
{
id: 'DEMO00000000000000000000000006',
name: 'DemoCategory',
path: 'category',
component: '/demo/category/index',
meta: {
title: '树形主数据(分类)',
keepAlive: true
}
},
{
id: 'DEMO00000000000000000000000007',
name: 'DemoUser',
path: 'user',
component: '/system/user/index',
meta: {
title: '用户 CRUD',
keepAlive: true
}
}
]
}
]
+51 -1
View File
@@ -27,6 +27,7 @@
* - 固定标签页保护(不可关闭)
* - KeepAlive 缓存排除管理
* - 路由有效性验证
* - 同时最多打开 10 个标签(超出时自动关闭最早的可关闭标签)
* - 首页自动保留
*
* ## 持久化
@@ -52,6 +53,9 @@ interface WorktabState {
keepAliveExclude: string[]
}
/** 同时打开的工作标签页数量上限(固定标签不计入淘汰,但计入总数) */
const MAX_OPEN_TABS = 10
/**
* 工作台标签页管理 Store
*/
@@ -167,7 +171,11 @@ export const useWorktabStore = defineStore(
}
if (existingIndex === -1) {
// 新增标签页
// 新增标签页:超出上限时淘汰最早的可关闭标签
if (!ensureCapacityForNewTab()) {
return
}
const insertIndex = tab.fixedTab ? findFixedTabInsertIndex() : opened.value.length
const newTab = { ...tab }
@@ -400,6 +408,46 @@ export const useWorktabStore = defineStore(
keepAliveExclude.value = keepAliveExclude.value.filter((item) => item !== name)
}
/**
* 关闭最早打开的可关闭标签(数组中最靠前的非 fixedTab)
*/
const evictOldestClosableTab = (): boolean => {
const index = opened.value.findIndex(isTabClosable)
if (index === -1) {
return false
}
const [removed] = opened.value.splice(index, 1)
if (removed?.name) {
addKeepAliveExclude(removed)
}
return true
}
/**
* 新开标签前:保证 opened.length < MAX_OPEN_TABS
*/
const ensureCapacityForNewTab = (): boolean => {
while (opened.value.length >= MAX_OPEN_TABS) {
if (!evictOldestClosableTab()) {
console.warn(`工作标签已达上限 ${MAX_OPEN_TABS} 个,且均为固定标签,无法打开新页面`)
return false
}
}
return true
}
/**
* 持久化恢复或校验后:将数量压回上限以内
*/
const trimTabsToMaxOpen = (): void => {
while (opened.value.length > MAX_OPEN_TABS) {
if (!evictOldestClosableTab()) {
break
}
}
}
/**
* 将传入的一组选项卡的组件名称标记为排除缓存
*/
@@ -480,6 +528,8 @@ export const useWorktabStore = defineStore(
opened.value = validTabs
}
trimTabsToMaxOpen()
// 验证当前激活标签的有效性
const isCurrentValid =
current.value && !shouldIgnoreTab(current.value) && isTabRouteValid(current.value)
+17 -17
View File
@@ -11,9 +11,9 @@
.upload-card {
width: 140px;
height: 96px;
border: 1px dashed #a7f3d0;
border-radius: 8px;
background: #f0fdf4;
border: 1px dashed var(--el-color-primary-light-7, #a7f3d0);
border-radius: var(--radius-md, 8px);
background: var(--primary-bg);
display: flex;
flex-direction: column;
align-items: center;
@@ -22,15 +22,15 @@
cursor: pointer;
&:hover {
border-color: #10b981;
border-color: var(--primary);
border-style: solid;
background: #ecfdf5;
background: var(--primary-bg);
}
}
.upload-card-text {
font-size: 12px;
color: #10b981;
color: var(--primary);
font-weight: 500;
line-height: 1;
}
@@ -38,16 +38,16 @@
.upload-hint {
display: block;
font-size: 11px;
color: #1e293b;
color: var(--text-muted);
margin-top: 8px;
line-height: 1.4;
}
.attachment-item {
width: 140px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
border: 1px solid var(--border);
border-radius: var(--radius-md, 8px);
background: var(--surface);
overflow: hidden;
&.is-uploading {
@@ -56,7 +56,7 @@
}
&:hover {
border-color: #10b981;
border-color: var(--primary);
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.1);
}
}
@@ -67,7 +67,7 @@
display: flex;
align-items: center;
justify-content: center;
background: #f8fafc;
background: var(--table-head);
overflow: hidden;
cursor: pointer;
@@ -110,7 +110,7 @@
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 11px;
color: #1e293b;
color: var(--text);
line-height: 1.4;
word-break: break-all;
}
@@ -126,17 +126,17 @@
width: 22px;
height: 22px;
border-radius: 4px;
background: #f1f5f9;
color: #1e293b;
background: var(--row-hover);
color: var(--text-muted);
cursor: pointer;
&:hover {
background: #10b981;
background: var(--primary);
color: #fff;
}
&--danger:hover {
background: #ef4444;
background: var(--danger);
color: #fff;
}
}
File diff suppressed because it is too large. Load diff
+25 -25
View File
@@ -32,7 +32,7 @@
flex-shrink: 0;
padding-right: 8px;
margin-right: 4px;
border-right: 1px solid var(--mint-border, #d9e8e2);
border-right: 1px solid var(--border);
}
.list-toolbar-date-shortcuts .date-shortcut-btn.el-button {
@@ -42,14 +42,14 @@
}
.list-toolbar-date-shortcuts .date-shortcut-btn.is-active.el-button {
color: var(--mint-primary-dark, #059669) !important;
background: var(--mint-primary-bg, #ecfdf5) !important;
color: var(--primary-dark) !important;
background: var(--primary-bg) !important;
border-color: rgba(16, 185, 129, 0.35) !important;
--el-button-text-color: var(--mint-primary-dark, #059669);
--el-button-bg-color: var(--mint-primary-bg, #ecfdf5);
--el-button-text-color: var(--primary-dark);
--el-button-bg-color: var(--primary-bg);
--el-button-border-color: rgba(16, 185, 129, 0.35);
--el-button-hover-text-color: var(--mint-primary-dark, #059669);
--el-button-hover-bg-color: var(--mint-primary-bg, #ecfdf5);
--el-button-hover-text-color: var(--primary-dark);
--el-button-hover-bg-color: var(--primary-bg);
--el-button-hover-border-color: rgba(16, 185, 129, 0.45);
}
@@ -138,21 +138,21 @@
padding-left: 10px;
font-size: 14px;
font-weight: 600;
color: var(--text, #1e293b);
border-left: 3px solid var(--primary, #10b981);
color: var(--text);
border-left: 3px solid var(--primary);
line-height: 1;
}
.db-related-table-dialog .view-subtable-wrap {
border: 1px solid #e2e8f0;
border-radius: 8px;
border: 1px solid var(--border);
border-radius: var(--radius-md, 8px);
overflow: hidden;
}
.db-related-table-dialog .view-subtable-wrap .el-table.view-subtable {
--el-table-header-bg-color: #f8fafc;
--el-table-header-text-color: #1e293b;
--el-table-border-color: #e2e8f0;
--el-table-header-bg-color: var(--table-head);
--el-table-header-text-color: var(--text);
--el-table-border-color: var(--border);
width: 100%;
.el-table__header th.el-table__cell,
@@ -163,21 +163,21 @@
.el-table__header th.el-table__cell .cell {
font-weight: 500;
color: #1e293b;
color: var(--text);
}
.el-table__body td.el-table__cell .cell {
color: #1e293b;
color: var(--text);
}
}
/* 仪表盘图表卡片(避免嵌套 panel-top 与页面主标题重复) */
.db-chart-card {
height: 100%;
background: var(--surface, #fff);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
border-radius: var(--radius, 10px);
box-shadow: var(--shadow);
overflow: hidden;
}
@@ -221,8 +221,8 @@
padding: 20px 12px;
background: var(--primary-bg);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
border-radius: var(--radius, 10px);
box-shadow: var(--shadow);
.db-stat-title {
margin: 0 0 10px;
@@ -410,12 +410,12 @@
}
.pound-slip-capture-inline .capture-row .label {
color: #1e293b;
color: var(--text);
min-width: 82px;
}
.pound-slip-capture-inline .capture-row .value {
color: #111827;
color: var(--text);
flex: 1;
}
@@ -481,7 +481,7 @@
.pound-slip-capture-inline .capture-id {
font-size: 18px;
font-weight: 700;
color: #111827;
color: var(--text);
margin-bottom: 6px;
}
@@ -499,6 +499,6 @@
.pound-slip-capture-inline .capture-desc {
font-size: 9px;
font-weight: 500;
color: #374151;
color: var(--text-muted);
margin-top: 4px;
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { fetchCategoryList } from '@/api/admin'
import { fetchCategoryList } from '@/api/demo'
import { fetchAllPagedRecords } from '@/utils/fetch-paged-list'
import { buildTreePathLabelFromFlat } from '@/utils/tree-path'
+19
View File
@@ -25,6 +25,25 @@ export function pickBxData<T>(res: unknown): T {
return res as T
}
/** 解析分页列表响应 */
export function pickBxPageResult<T>(res: unknown): { list: T[]; total: number } {
const payload = pickBxData<{ list?: T[]; rows?: T[]; total?: number } | T[]>(res)
if (Array.isArray(payload)) {
return { list: payload, total: payload.length }
}
if (payload && typeof payload === 'object') {
const record = payload as { list?: T[]; rows?: T[]; total?: number }
const list = Array.isArray(record.list)
? record.list
: Array.isArray(record.rows)
? record.rows
: []
const total = typeof record.total === 'number' ? record.total : list.length
return { list, total }
}
return { list: [], total: 0 }
}
/** 解析列表类响应,保证始终返回数组 */
export function pickBxList<T>(res: unknown): T[] {
const payload = pickBxData<T[] | { list?: T[]; rows?: T[] }>(res)
@@ -0,0 +1,64 @@
import type { CertRead, VehicleDetail } from '@/api/pl'
import type { VehiclePersonnelViewRow } from '@/components/business/pl-vehicle-personnel-view-list/index.vue'
import type { VehicleViewBundle } from '@/composables/usePlVehicleViewDetail'
export interface TransportResourceSnapshotCert extends CertRead {}
export interface TransportResourceSnapshotPersonnel {
personnelId?: string
personnelName: string
phoneNo?: string
age?: number | null
idCardNo?: string
role: string
isDefault?: number
remark?: string
certList?: TransportResourceSnapshotCert[]
}
export interface TransportResourceSnapshot {
snapshottedAt?: string
vehicleId: string
vehicleType: string
carrierId?: string
carrierName?: string
licensePlate: string
trailerNo?: string
loadCapacity?: number | string | null
vehicleStatus: string
certList?: TransportResourceSnapshotCert[]
personnelList?: TransportResourceSnapshotPersonnel[]
personnelSummary?: string
selectedDriverId?: string
}
/** 将计划/运输单上的资源快照转为车辆查看组件可消费的 bundle */
export function snapshotToVehicleViewBundle(
snapshot: TransportResourceSnapshot
): VehicleViewBundle {
const detail: VehicleDetail = {
id: snapshot.vehicleId,
vehicleType: snapshot.vehicleType,
carrierId: snapshot.carrierId,
carrierName: snapshot.carrierName,
licensePlate: snapshot.licensePlate,
trailerNo: snapshot.trailerNo,
loadCapacity: snapshot.loadCapacity != null ? Number(snapshot.loadCapacity) : undefined,
vehicleStatus: snapshot.vehicleStatus,
certList: (snapshot.certList || []) as CertRead[],
createTime: snapshot.snapshottedAt || ''
}
const personnelList: VehiclePersonnelViewRow[] = (snapshot.personnelList || []).map(person => ({
personnelId: person.personnelId,
personnelName: person.personnelName,
phoneNo: person.phoneNo,
age: person.age,
role: person.role,
isDefault: person.isDefault,
remark: person.remark,
certList: person.certList || []
}))
return { detail, personnelList }
}
+8 -1
View File
@@ -33,6 +33,10 @@
* @module utils/table/tableConfig
* @author Art Design Pro Team
*/
/** 列表分页默认每页条数 */
export const DEFAULT_LIST_PAGE_SIZE = 20
export const tableConfig = {
// 响应数据字段映射配置,系统会从接口返回数据中按顺序查找这些字段
// 列表数据
@@ -51,5 +55,8 @@ export const tableConfig = {
current: 'current',
// 每页大小
size: 'size'
}
},
/** 列表分页默认每页条数 */
defaultPageSize: DEFAULT_LIST_PAGE_SIZE
}
+103 -35
View File
@@ -91,15 +91,34 @@
<el-button class="btn-main" type="primary" @click="openAdd">
<el-icon><Plus /></el-icon>新增备用金申请
</el-button>
<el-button class="btn-ghost" @click="handleToolbarEdit">
<el-button
class="btn-ghost"
:disabled="selectedRows.length !== 1 || !isDraftEditable(selectedRows[0])"
@click="handleToolbarEdit"
>
<el-icon><EditPen /></el-icon>编辑
</el-button>
<el-button
class="btn-ghost"
type="danger"
:disabled="
selectedRows.length === 0 ||
!selectedRows.every(isDraftEditable) ||
!allRowsDeletableByCreator(selectedRows)
"
:title="allRowsDeletableByCreator(selectedRows) ? '' : CREATOR_DELETE_TIP"
@click="handleToolbarDelete"
>
<el-icon><Delete /></el-icon>删除
</el-button>
<el-button class="btn-ghost" :loading="loadingMap.submit" @click="handleToolbarSubmit">
<el-icon><Upload /></el-icon>提交审批
</el-button>
<el-button class="btn-ghost" @click="openFlowDialog()">
<el-icon><View /></el-icon>查看审批流
</el-button>
<ListToolbarEndActions :loading="loading" @refresh="handleSearch" />
</div>
</div>
@@ -114,6 +133,7 @@
:summary-method="summaryMethod"
:max-height="'min(520px, calc(100vh - 300px))'"
@selection-change="onSelectionChange"
@row-dblclick="openView"
>
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
@@ -132,7 +152,11 @@
align="center"
header-align="center"
show-overflow-tooltip
/>
>
<template #default="{ row }">
<el-link type="primary" @click="openView(row)">{{ row.applyCode }}</el-link>
</template>
</el-table-column>
<el-table-column
prop="applicantName"
label="申请人"
@@ -202,38 +226,6 @@
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="操作" min-width="360" fixed="right" align="center" header-align="center">
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px; flex-wrap: nowrap">
<el-button class="btn-op-item" @click="openView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-button
v-if="row.status === 'draft' || row.status === 'rejected'"
class="btn-op-item"
@click="openEdit(row)"
>
<el-icon><EditPen /></el-icon>编辑
</el-button>
<el-button
v-if="row.status === 'draft' || row.status === 'rejected'"
class="btn-op-item"
:loading="loadingMap[`submit_${row.id}`]"
@click="handleSubmit(row)"
>
<el-icon><Upload /></el-icon>提交
</el-button>
<el-button
v-if="(row.status === 'draft' || row.status === 'rejected') && canDeleteByCreator(row)"
class="btn-op-item"
:loading="loadingMap[`del_${row.id}`]"
@click="handleDelete(row)"
>
<el-icon><Delete /></el-icon>删除
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
@@ -312,6 +304,27 @@
<p class="dlg-view-meta">
申请金额(元):<b class="money-em">{{ formatMoneyYuan(viewData.amount || 0) }}</b>
</p>
<div v-if="isDraftEditable(viewData)" class="dlg-view-hero-actions">
<el-button class="btn-main" type="primary" @click="openEdit(viewData)">
<el-icon><EditPen /></el-icon>编辑
</el-button>
<el-button
class="btn-ghost"
:loading="loadingMap[`submit_${viewData.id}`]"
@click="handleViewSubmit"
>
<el-icon><Upload /></el-icon>提交
</el-button>
<el-button
v-if="canDeleteByCreator(viewData)"
class="btn-ghost"
type="danger"
:loading="loadingMap[`del_${viewData.id}`]"
@click="handleViewDelete"
>
<el-icon><Delete /></el-icon>删除
</el-button>
</div>
</div>
<div class="dlg-view-section">
@@ -416,6 +429,7 @@
</template>
<script setup lang="ts">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import { reactive, ref, computed, onMounted, nextTick, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance } from 'element-plus'
@@ -433,7 +447,7 @@ import {
ArrowDownBold
} from '@element-plus/icons-vue'
import { advanceApi, paymentApi } from '@/api/bx'
import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import { canDeleteByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import {
statusLabel,
statusTagType,
@@ -602,6 +616,11 @@ function handleReset() {
search()
}
function isDraftEditable(row?: any) {
if (!row) return false
return row.status === 'draft' || row.status === 'rejected'
}
function handleToolbarEdit() {
if (selectedRows.value.length === 0) {
ElMessage.warning('请先勾选要编辑的申请')
@@ -615,6 +634,55 @@ function handleToolbarEdit() {
openEdit(row)
}
async function handleToolbarDelete() {
if (selectedRows.value.length === 0) {
ElMessage.warning('请先勾选要删除的申请')
return
}
const targets = selectedRows.value.filter(isDraftEditable)
if (targets.length !== selectedRows.value.length) {
ElMessage.warning('仅草稿或已驳回的申请可删除')
return
}
if (!allRowsDeletableByCreator(targets)) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
try {
await ElMessageBox.confirm(`确定删除选中的 ${targets.length} 条申请吗?删除后不可恢复。`, '删除确认', {
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning'
})
for (const row of targets) {
await advanceApi.delete(row.id)
}
ElMessage.success('删除成功')
await refreshListAndStats()
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e?.message || '删除失败')
}
}
}
async function handleViewSubmit() {
if (!viewData.value?.id) return
await handleSubmit(viewData.value)
try {
const res = await advanceApi.getById(viewData.value.id)
viewData.value = res.data || res
} catch {
dialogs.view = false
}
}
async function handleViewDelete() {
if (!viewData.value?.id) return
await handleDelete(viewData.value)
dialogs.view = false
}
function openAdd() {
Object.assign(addForm, { reason: '', amount: '', remark: '' })
editingId.value = null
@@ -14,8 +14,11 @@
<h2 class="title-bar-heading">备用金审批</h2>
</div>
<div class="panel-tabs">
<el-tabs v-model="activeTab" @tab-change="handleTabChange">
<el-tabs
v-model="activeTab"
class="panel-pill-tabs panel-pill-tabs--lg"
@tab-change="handleTabChange"
>
<!-- 待我审批 -->
<el-tab-pane label="待我审批" name="pending">
<div class="filter-block">
@@ -70,6 +73,8 @@
<el-button class="btn-ghost" @click="handleBatchReject">
<el-icon><Close /></el-icon>驳回
</el-button>
<ListToolbarEndActions :loading="pendingLoading" @refresh="refreshPending" />
</div>
</div>
@@ -84,6 +89,7 @@
:summary-method="pendingSummaryMethod"
:max-height="'min(520px, calc(100vh - 300px))'"
@selection-change="onSelectionChange"
@row-dblclick="openView"
>
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
@@ -102,7 +108,11 @@
align="center"
header-align="center"
show-overflow-tooltip
/>
>
<template #default="{ row }">
<el-link type="primary" @click="openView(row)">{{ row.applyCode }}</el-link>
</template>
</el-table-column>
<el-table-column prop="applicantName" label="申请人" min-width="100" align="center" header-align="center" />
<el-table-column
prop="reason"
@@ -133,21 +143,6 @@
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="操作" min-width="280" fixed="right" align="center" header-align="center">
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px; flex-wrap: nowrap">
<el-button class="btn-op-item" @click="openView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-button class="btn-op-item" @click="openApproveDialog([row], 'approve')">
<el-icon><Check /></el-icon>通过
</el-button>
<el-button class="btn-op-item" @click="openApproveDialog([row], 'reject')">
<el-icon><Close /></el-icon>驳回
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
@@ -218,6 +213,7 @@
show-summary
:summary-method="approvedSummaryMethod"
:max-height="'min(520px, calc(100vh - 300px))'"
@row-dblclick="openView"
>
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column
@@ -235,7 +231,11 @@
align="center"
header-align="center"
show-overflow-tooltip
/>
>
<template #default="{ row }">
<el-link type="primary" @click="openView(row)">{{ row.applyCode }}</el-link>
</template>
</el-table-column>
<el-table-column prop="applicantName" label="申请人" min-width="100" align="center" header-align="center" />
<el-table-column
prop="reason"
@@ -273,13 +273,6 @@
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="操作" min-width="120" fixed="right" align="center" header-align="center">
<template #default="{ row }">
<el-button class="btn-op-item" @click="openView(row)">
<el-icon><View /></el-icon>查看
</el-button>
</template>
</el-table-column>
</el-table>
</div>
@@ -297,7 +290,6 @@
</div>
</el-tab-pane>
</el-tabs>
</div>
</section>
<!-- 详情 -->
@@ -322,6 +314,14 @@
<p class="dlg-view-meta">
申请金额(元):<b class="money-em">{{ formatMoneyYuan(viewData.amount || 0) }}</b>
</p>
<div v-if="activeTab === 'pending'" class="dlg-view-hero-actions">
<el-button class="btn-main" type="primary" @click="openApproveDialog([viewData], 'approve')">
<el-icon><Check /></el-icon>通过
</el-button>
<el-button class="btn-ghost" @click="openApproveDialog([viewData], 'reject')">
<el-icon><Close /></el-icon>驳回
</el-button>
</div>
</div>
<div class="dlg-view-section">
@@ -393,8 +393,9 @@
<script setup lang="ts">
import { reactive, ref, computed, onMounted } from 'vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import { ElMessage } from 'element-plus'
import { Check, Close, View, Search, RefreshLeft } from '@element-plus/icons-vue'
import { Check, Close, Search, RefreshLeft } from '@element-plus/icons-vue'
import { advanceApi } from '@/api/bx'
import { advanceApproverStats, refreshApproveMenuBadges } from '@/hooks/bx/useApproveMenuBadge'
import { useMenuStore } from '@/store/modules/menu'
+28 -25
View File
@@ -14,8 +14,11 @@
<h2 class="title-bar-heading">备用金发放</h2>
</div>
<div class="panel-tabs">
<el-tabs v-model="activeTab" @tab-change="handleTabChange">
<el-tabs
v-model="activeTab"
class="panel-pill-tabs panel-pill-tabs--lg"
@tab-change="handleTabChange"
>
<el-tab-pane label="待发放" name="pending">
<div class="filter-block">
<el-form :model="filterForm" class="filter-form" @submit.prevent>
@@ -69,6 +72,8 @@
<el-button class="btn-ghost" :loading="loadingMap.export" @click="handleExport">
<el-icon><Download /></el-icon>导出
</el-button>
<ListToolbarEndActions :loading="loading" @refresh="handleSearch" />
</div>
</div>
@@ -83,6 +88,7 @@
:summary-method="advanceIssueSummary"
:max-height="'min(520px, calc(100vh - 360px))'"
@selection-change="onSelectionChange"
@row-dblclick="openView"
>
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
@@ -101,7 +107,11 @@
align="center"
header-align="center"
show-overflow-tooltip
/>
>
<template #default="{ row }">
<el-link type="primary" @click="openView(row)">{{ row.applyCode }}</el-link>
</template>
</el-table-column>
<el-table-column prop="applicantName" label="申请人" min-width="100" align="center" header-align="center" />
<el-table-column
prop="reason"
@@ -154,18 +164,6 @@
header-align="center"
show-overflow-tooltip
/>
<el-table-column label="操作" min-width="280" fixed="right" align="center" header-align="center">
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px; flex-wrap: nowrap">
<el-button class="btn-op-item" @click="openIssueDialog(row)">
<el-icon><Check /></el-icon>标记发放
</el-button>
<el-button class="btn-op-item" @click="openView(row)">
<el-icon><View /></el-icon>查看
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
@@ -230,6 +228,8 @@
<el-button class="btn-ghost" :loading="loadingMap.export" @click="handleExport">
<el-icon><Download /></el-icon>导出
</el-button>
<ListToolbarEndActions :loading="loading" @refresh="handleSearch" />
</div>
</div>
@@ -243,6 +243,7 @@
show-summary
:summary-method="advanceIssueSummary"
:max-height="'min(520px, calc(100vh - 360px))'"
@row-dblclick="openView"
>
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column
@@ -260,7 +261,11 @@
align="center"
header-align="center"
show-overflow-tooltip
/>
>
<template #default="{ row }">
<el-link type="primary" @click="openView(row)">{{ row.applyCode }}</el-link>
</template>
</el-table-column>
<el-table-column prop="applicantName" label="申请人" min-width="100" align="center" header-align="center" />
<el-table-column
prop="reason"
@@ -307,13 +312,6 @@
</el-table-column>
<el-table-column prop="issueTime" label="发放时间" min-width="140" align="center" header-align="center" />
<el-table-column prop="issueUserName" label="发放操作人" min-width="110" align="center" header-align="center" />
<el-table-column label="操作" min-width="120" fixed="right" align="center" header-align="center">
<template #default="{ row }">
<el-button class="btn-op-item" @click="openView(row)">
<el-icon><View /></el-icon>查看
</el-button>
</template>
</el-table-column>
</el-table>
</div>
@@ -331,7 +329,6 @@
</div>
</el-tab-pane>
</el-tabs>
</div>
</section>
<!-- 标记发放 -->
@@ -404,6 +401,11 @@
&nbsp;&nbsp;实际发放金额(元):<b class="money-em">{{ formatMoneyYuan(viewData.actualAmount) }}</b>
</template>
</p>
<div v-if="activeTab === 'pending'" class="dlg-view-hero-actions">
<el-button class="btn-main" type="primary" @click="openIssueDialog(viewData)">
<el-icon><Check /></el-icon>标记发放
</el-button>
</div>
</div>
<div class="dlg-view-section">
@@ -460,9 +462,10 @@
</template>
<script setup lang="ts">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import { reactive, ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Check, Download, View, Close, Search, RefreshLeft } from '@element-plus/icons-vue'
import { Check, Download, Close, Search, RefreshLeft } from '@element-plus/icons-vue'
import { paymentApi, advanceApi } from '@/api/bx'
import { type BxAttachmentItem, loadBxAttachments } from '@/utils/bx/attachment'
import { formatSumMoney, formatMoney, formatMoneyYuan, moneyTableFormatter } from '@/utils/bx/format'
+61 -27
View File
@@ -46,17 +46,45 @@
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">0</span> 项
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="openAddDialog">
<el-icon><Plus /></el-icon>新增一级项目
</el-button>
<el-button
class="btn-ghost"
:disabled="selectedRows.length !== 1"
:loading="loadingMap.rowEdit"
@click="handleToolbarEdit"
>
<el-icon><EditPen /></el-icon>编辑
</el-button>
<el-button
v-if="selectedRows.length === 1 && selectedRows[0]?.level === 1"
class="btn-ghost"
:loading="loadingMap.rowAddChild"
@click="handleToolbarAddChild"
>
<el-icon><Plus /></el-icon>新增子项
</el-button>
<el-button
class="btn-ghost"
type="danger"
:disabled="selectedRows.length !== 1 || !canDeleteByCreator(selectedRows[0])"
:title="selectedRows.length === 1 && !canDeleteByCreator(selectedRows[0]) ? CREATOR_DELETE_TIP : ''"
:loading="loadingMap.rowDelete"
@click="handleToolbarDelete"
>
<el-icon><Delete /></el-icon>删除
</el-button>
<el-button class="btn-ghost" @click="expandAll">
<el-icon><SortDown /></el-icon>展开全部
</el-button>
<el-button class="btn-ghost" @click="collapseAll">
<el-icon><SortUp /></el-icon>收起全部
</el-button>
<ListToolbarEndActions :loading="loading" @refresh="handleSearch" />
</div>
</div>
@@ -69,7 +97,9 @@
stripe
row-key="id"
:tree-props="{ children: 'children', hasChildren: 'childCount > 0' }"
@selection-change="onSelectionChange"
>
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column
prop="itemName"
@@ -110,32 +140,6 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" min-width="280" fixed="right" align="center" header-align="center">
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px; flex-wrap: nowrap">
<el-button class="btn-op-item" :loading="loadingMap.rowEdit" @click="openEditDialog(row)">
<el-icon><EditPen /></el-icon>编辑
</el-button>
<el-button
v-if="row.level === 1"
class="btn-op-item"
:loading="loadingMap.rowAddChild"
@click="openAddChildDialog(row)"
>
<el-icon><Plus /></el-icon>新增子项
</el-button>
<el-button
class="btn-op-item"
:disabled="!canDeleteByCreator(row)"
:title="canDeleteByCreator(row) ? '' : CREATOR_DELETE_TIP"
:loading="loadingMap.rowDelete"
@click="handleDelete(row)"
>
<el-icon><Delete /></el-icon>删除
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</section>
@@ -316,6 +320,7 @@
</template>
<script setup lang="ts">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import { reactive, ref, onMounted, computed, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance } from 'element-plus'
@@ -353,6 +358,7 @@ const searchForm = reactive({
const expenseTree = ref<any[]>([])
const loading = ref(false)
const selectedRows = ref<any[]>([])
const formData = reactive({
id: 0 as number,
@@ -447,6 +453,34 @@ function openAddDialog() {
dialogs.addProject = true
}
function onSelectionChange(rows: any[]) {
selectedRows.value = rows
}
function handleToolbarEdit() {
if (selectedRows.value.length !== 1) {
ElMessage.warning('请先勾选一条费用项目')
return
}
openEditDialog(selectedRows.value[0])
}
function handleToolbarAddChild() {
if (selectedRows.value.length !== 1 || selectedRows.value[0]?.level !== 1) {
ElMessage.warning('请先勾选一条一级项目')
return
}
openAddChildDialog(selectedRows.value[0])
}
function handleToolbarDelete() {
if (selectedRows.value.length !== 1) {
ElMessage.warning('请先勾选一条费用项目')
return
}
handleDelete(selectedRows.value[0])
}
function openEditDialog(row: any) {
Object.assign(formData, {
id: row.id,
+28 -42
View File
@@ -14,8 +14,11 @@
<h2 class="title-bar-heading">支付管理</h2>
</div>
<div class="panel-tabs">
<el-tabs v-model="activeTab" @tab-change="handleTabChange">
<el-tabs
v-model="activeTab"
class="panel-pill-tabs panel-pill-tabs--lg"
@tab-change="handleTabChange"
>
<!-- 待处理 -->
<el-tab-pane label="待处理" name="pending">
<div class="filter-block">
@@ -70,6 +73,8 @@
<el-button class="btn-ghost" @click="handleToolbarOffset">
<el-icon><Switch /></el-icon>冲抵备用金
</el-button>
<ListToolbarEndActions :loading="loading" @refresh="handleSearch" />
</div>
</div>
@@ -84,6 +89,7 @@
:summary-method="summaryMethod"
:max-height="'min(520px, calc(100vh - 360px))'"
@selection-change="onSelectionChange"
@row-dblclick="openView"
>
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
@@ -102,7 +108,11 @@
align="center"
header-align="center"
show-overflow-tooltip
/>
>
<template #default="{ row }">
<el-link type="primary" @click="openView(row)">{{ row.applyCode }}</el-link>
</template>
</el-table-column>
<el-table-column prop="applicantName" label="申请人" min-width="100" align="center" header-align="center" />
<el-table-column
prop="title"
@@ -135,21 +145,6 @@
}}
</template>
</el-table-column>
<el-table-column label="操作" min-width="280" fixed="right" align="center" header-align="center">
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px; flex-wrap: nowrap">
<el-button class="btn-op-item" @click="openView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-button class="btn-op-item" @click="openIssueDialog(row)">
<el-icon><Money /></el-icon>发放
</el-button>
<el-button class="btn-op-item" @click="openOffsetDialog(row)">
<el-icon><Switch /></el-icon>冲抵
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
@@ -230,6 +225,7 @@
show-summary
:summary-method="processedSummaryMethod"
:max-height="'min(520px, calc(100vh - 300px))'"
@row-dblclick="openProcessedView"
>
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column
@@ -247,7 +243,11 @@
align="center"
header-align="center"
show-overflow-tooltip
/>
>
<template #default="{ row }">
<el-link type="primary" @click="openProcessedView(row)">{{ row.applyCode }}</el-link>
</template>
</el-table-column>
<el-table-column prop="applicantName" label="申请人" min-width="100" align="center" header-align="center" />
<el-table-column
prop="title"
@@ -292,13 +292,6 @@
{{ row.payTime || '-' }}
</template>
</el-table-column>
<el-table-column label="操作" min-width="120" fixed="right" align="center" header-align="center">
<template #default="{ row }">
<el-button class="btn-op-item" @click="openProcessedView(row)">
<el-icon><View /></el-icon>查看
</el-button>
</template>
</el-table-column>
</el-table>
</div>
@@ -316,7 +309,6 @@
</div>
</el-tab-pane>
</el-tabs>
</div>
</section>
<!-- 单据详情 -->
@@ -341,6 +333,14 @@
<p class="dlg-view-meta">
金额(元):<b class="money-em">{{ formatMoneyYuan(viewData.amount || 0) }}</b>
</p>
<div v-if="activeTab === 'pending'" class="dlg-view-hero-actions">
<el-button class="btn-main" type="primary" @click="openIssueDialog(viewData)">
<el-icon><Money /></el-icon>发放
</el-button>
<el-button class="btn-ghost" @click="openOffsetDialog(viewData)">
<el-icon><Switch /></el-icon>冲抵备用金
</el-button>
</div>
</div>
<div class="dlg-view-section">
<h4 class="dlg-view-section-title">基础信息</h4>
@@ -599,6 +599,7 @@
</template>
<script setup lang="ts">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import { reactive, ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
@@ -1174,18 +1175,3 @@ function processedStatusTagType(
return 'info'
}
</script>
<style scoped>
.dialog-section-title {
margin: 0 0 12px;
font-size: 14px;
font-weight: 600;
color: var(--text, #1e293b);
}
.offset-pager {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
</style>
+113 -114
View File
@@ -63,12 +63,34 @@
<el-button class="btn-main" type="primary" @click="openAddDialog">
<el-icon><Plus /></el-icon>新增报销申请
</el-button>
<el-button
class="btn-ghost"
:disabled="selectedRows.length !== 1 || !isDraftEditable(selectedRows[0])"
@click="handleToolbarEdit"
>
<el-icon><EditPen /></el-icon>编辑
</el-button>
<el-button
class="btn-ghost"
type="danger"
:disabled="
selectedRows.length === 0 ||
!selectedRows.every(isDraftEditable) ||
!allRowsDeletableByCreator(selectedRows)
"
:title="allRowsDeletableByCreator(selectedRows) ? '' : CREATOR_DELETE_TIP"
@click="handleToolbarDelete"
>
<el-icon><Delete /></el-icon>删除
</el-button>
<el-button class="btn-ghost" :loading="loadingMap.submit" @click="handleToolbarSubmit">
<el-icon><Upload /></el-icon>提交审批
</el-button>
<el-button class="btn-ghost" @click="openFlowDialog()">
<el-icon><View /></el-icon>查看审批流
</el-button>
<ListToolbarEndActions :loading="loading" @refresh="handleSearch" />
</div>
</div>
@@ -83,6 +105,7 @@
:summary-method="summaryMethod"
:max-height="'min(520px, calc(100vh - 300px))'"
@selection-change="onSelectionChange"
@row-dblclick="openView"
>
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
@@ -101,7 +124,11 @@
align="center"
header-align="center"
show-overflow-tooltip
/>
>
<template #default="{ row }">
<el-link type="primary" @click="openView(row)">{{ row.applyCode }}</el-link>
</template>
</el-table-column>
<el-table-column
prop="title"
label="报销标题"
@@ -146,38 +173,6 @@
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="操作" min-width="360" fixed="right" align="center" header-align="center">
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px; flex-wrap: nowrap">
<el-button class="btn-op-item" @click="openView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-button
v-if="row.status === 'draft' || row.status === 'rejected'"
class="btn-op-item"
@click="openEditDialog(row)"
>
<el-icon><EditPen /></el-icon>编辑
</el-button>
<el-button
v-if="row.status === 'draft' || row.status === 'rejected'"
class="btn-op-item"
:loading="loadingMap[`submit_${row.id}`]"
@click="handleSubmit(row)"
>
<el-icon><Upload /></el-icon>提交
</el-button>
<el-button
v-if="(row.status === 'draft' || row.status === 'rejected') && canDeleteByCreator(row)"
class="btn-op-item"
:loading="loadingMap[`del_${row.id}`]"
@click="handleDelete(row)"
>
<el-icon><Delete /></el-icon>删除
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
@@ -342,6 +337,27 @@
<p class="dlg-view-meta">
报销金额(元):<b class="money-em">{{ formatMoneyYuan(viewData.amount || 0) }}</b>
</p>
<div v-if="isDraftEditable(viewData)" class="dlg-view-hero-actions">
<el-button class="btn-main" type="primary" @click="openEditDialog(viewData)">
<el-icon><EditPen /></el-icon>编辑
</el-button>
<el-button
class="btn-ghost"
:loading="loadingMap[`submit_${viewData.id}`]"
@click="handleViewSubmit"
>
<el-icon><Upload /></el-icon>提交
</el-button>
<el-button
v-if="canDeleteByCreator(viewData)"
class="btn-ghost"
type="danger"
:loading="loadingMap[`del_${viewData.id}`]"
@click="handleViewDelete"
>
<el-icon><Delete /></el-icon>删除
</el-button>
</div>
</div>
<div class="dlg-view-section">
@@ -459,6 +475,7 @@
</template>
<script setup lang="ts">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import { reactive, ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
@@ -474,7 +491,7 @@ import {
} from '@element-plus/icons-vue'
import { reimburseApi, attachmentApi } from '@/api/bx'
import { type BxAttachmentItem, loadBxAttachments } from '@/utils/bx/attachment'
import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import { canDeleteByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import { useExpenseItemTree, type ExpenseItem } from '@/hooks/bx/useExpenseItemTree'
import {
statusLabel,
@@ -676,6 +693,68 @@ async function openAddDialog() {
await refreshGeneratedTitle()
}
function isDraftEditable(row?: any) {
if (!row) return false
return row.status === 'draft' || row.status === 'rejected'
}
function handleToolbarEdit() {
if (selectedRows.value.length !== 1) {
ElMessage.warning('请先勾选一条报销单')
return
}
openEditDialog(selectedRows.value[0])
}
async function handleToolbarDelete() {
if (selectedRows.value.length === 0) {
ElMessage.warning('请先勾选要删除的报销单')
return
}
const targets = selectedRows.value.filter(isDraftEditable)
if (targets.length !== selectedRows.value.length) {
ElMessage.warning('仅草稿或已驳回的报销单可删除')
return
}
if (!allRowsDeletableByCreator(targets)) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
try {
await ElMessageBox.confirm(`确定删除选中的 ${targets.length} 条报销单吗?删除后不可恢复。`, '删除确认', {
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning'
})
for (const row of targets) {
await reimburseApi.delete(row.id)
}
ElMessage.success('删除成功')
await refreshListAndStats()
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e?.message || '删除失败')
}
}
}
async function handleViewSubmit() {
if (!viewData.value?.id) return
await handleSubmit(viewData.value)
try {
const res = await reimburseApi.getById(viewData.value.id)
viewData.value = res.data || res
} catch {
dialogs.view = false
}
}
async function handleViewDelete() {
if (!viewData.value?.id) return
await handleDelete(viewData.value)
dialogs.view = false
}
async function openEditDialog(row: any) {
try {
const res: any = await reimburseApi.getById(row.id)
@@ -960,83 +1039,3 @@ function viewDetailSummary({ columns, data }: { columns: any[]; data: any[] }) {
return sums
}
</script>
<style scoped>
.detail-table-wrap {
overflow-x: auto;
}
.detail-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.detail-table th {
background: var(--mint-table-head, #f8fafc);
font-weight: 600;
text-align: left;
padding: 8px 10px;
border-bottom: 1px solid var(--mint-border, #d9e8e2);
color: var(--mint-text);
font-size: 12px;
white-space: nowrap;
}
.detail-table td {
padding: 6px;
border-bottom: 1px solid var(--mint-border, #d9e8e2);
vertical-align: middle;
}
.detail-table .col-index {
width: 56px;
text-align: center;
color: var(--mint-text);
}
.detail-table .col-expense {
min-width: 180px;
}
.detail-table .col-amount {
width: 120px;
}
.detail-table .col-remark {
min-width: 120px;
}
.th-required {
color: var(--el-color-danger);
margin-left: 2px;
font-weight: 600;
}
.detail-table .col-action {
width: 72px;
text-align: center;
}
.detail-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
padding: 0 2px;
}
.detail-total {
font-size: 14px;
color: var(--mint-text, #1e293b);
}
.detail-total b {
color: var(--danger, #dc2626);
font-size: 16px;
}
.view-detail-table :deep(.el-table__footer-wrapper) {
font-weight: 600;
}
</style>
@@ -14,8 +14,11 @@
<h2 class="title-bar-heading">报销审批</h2>
</div>
<div class="panel-tabs">
<el-tabs v-model="activeTab" @tab-change="handleTabChange">
<el-tabs
v-model="activeTab"
class="panel-pill-tabs panel-pill-tabs--lg"
@tab-change="handleTabChange"
>
<!-- 待我审批 -->
<el-tab-pane label="待我审批" name="pending">
<div class="filter-block">
@@ -119,6 +122,8 @@
<el-button class="btn-ghost" @click="handleBatchReject">
<el-icon><Close /></el-icon>驳回
</el-button>
<ListToolbarEndActions :loading="pendingLoading" @refresh="refreshPending" />
</div>
</div>
@@ -133,6 +138,7 @@
:summary-method="pendingSummaryMethod"
:max-height="'min(520px, calc(100vh - 300px))'"
@selection-change="onSelectionChange"
@row-dblclick="openView"
>
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
@@ -151,7 +157,11 @@
align="center"
header-align="center"
show-overflow-tooltip
/>
>
<template #default="{ row }">
<el-link type="primary" @click="openView(row)">{{ row.applyCode }}</el-link>
</template>
</el-table-column>
<el-table-column prop="applicantName" label="申请人" min-width="100" align="center" header-align="center" />
<el-table-column
prop="title"
@@ -190,21 +200,6 @@
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="操作" min-width="280" fixed="right" align="center" header-align="center">
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px; flex-wrap: nowrap">
<el-button class="btn-op-item" @click="openView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-button class="btn-op-item" @click="openApproveDialog([row], 'approve')">
<el-icon><Check /></el-icon>通过
</el-button>
<el-button class="btn-op-item" @click="openApproveDialog([row], 'reject')">
<el-icon><Close /></el-icon>驳回
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
@@ -324,6 +319,7 @@
show-summary
:summary-method="approvedSummaryMethod"
:max-height="'min(520px, calc(100vh - 300px))'"
@row-dblclick="openView"
>
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column
@@ -341,7 +337,11 @@
align="center"
header-align="center"
show-overflow-tooltip
/>
>
<template #default="{ row }">
<el-link type="primary" @click="openView(row)">{{ row.applyCode }}</el-link>
</template>
</el-table-column>
<el-table-column prop="applicantName" label="申请人" min-width="100" align="center" header-align="center" />
<el-table-column
prop="title"
@@ -387,13 +387,6 @@
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="操作" min-width="120" fixed="right" align="center" header-align="center">
<template #default="{ row }">
<el-button class="btn-op-item" @click="openView(row)">
<el-icon><View /></el-icon>查看
</el-button>
</template>
</el-table-column>
</el-table>
</div>
@@ -411,7 +404,6 @@
</div>
</el-tab-pane>
</el-tabs>
</div>
</section>
<!-- 详情 -->
@@ -436,6 +428,14 @@
<p class="dlg-view-meta">
金额(元):<b class="money-em">{{ formatMoneyYuan(viewData.amount || 0) }}</b>
</p>
<div v-if="activeTab === 'pending'" class="dlg-view-hero-actions">
<el-button class="btn-main" type="primary" @click="openApproveDialog([viewData], 'approve')">
<el-icon><Check /></el-icon>通过
</el-button>
<el-button class="btn-ghost" @click="openApproveDialog([viewData], 'reject')">
<el-icon><Close /></el-icon>驳回
</el-button>
</div>
</div>
<div class="dlg-view-section">
@@ -537,6 +537,7 @@
<script setup lang="ts">
import { reactive, ref, computed, onMounted } from 'vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import { ElMessage } from 'element-plus'
import {
View,
@@ -855,9 +856,3 @@ function viewDetailSummary({ columns, data }: { columns: any[]; data: any[] }) {
return sums
}
</script>
<style scoped>
.view-detail-table :deep(.el-table__footer-wrapper) {
font-weight: 600;
}
</style>
+87 -16
View File
@@ -91,6 +91,20 @@
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="loadData"
@reset-columns="resetToDefault"
/>
</div>
</div>
<div class="list-body list-wrap">
<div class="table-scroll table-page-summary">
<el-table
@@ -104,6 +118,7 @@
>
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column
v-if="isColumnVisible('applyTime')"
prop="applyTime"
label="申请时间"
min-width="160"
@@ -118,16 +133,35 @@
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column prop="bizType" label="业务类型" min-width="100" align="center" header-align="center">
>
<template #default="{ row }">
<el-link type="primary" @click="openViewDetail(row)">{{ row.applyCode }}</el-link>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('bizType')"
prop="bizType"
label="业务类型"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag :type="bizTypeTagType(row.bizType)" size="small">
{{ row.bizType }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="applicantName" label="申请人" min-width="100" align="center" header-align="center" />
<el-table-column
v-if="isColumnVisible('applicantName')"
prop="applicantName"
label="申请人"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
v-if="isColumnVisible('title')"
prop="title"
label="申请标题"
min-width="180"
@@ -136,6 +170,7 @@
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('amount')"
prop="amount"
label="申请金额(元)"
:formatter="moneyTableFormatter"
@@ -144,14 +179,28 @@
align="right"
header-align="center"
/>
<el-table-column prop="status" label="状态" min-width="110" align="center" header-align="center">
<el-table-column
v-if="isColumnVisible('status')"
prop="status"
label="状态"
min-width="110"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">
{{ statusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="currentNode" label="当前节点" min-width="120" align="center" header-align="center">
<el-table-column
v-if="isColumnVisible('currentNode')"
prop="currentNode"
label="当前节点"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag
v-if="shouldShowWorkflowNodeTag(row.currentNode, row.status)"
@@ -163,13 +212,6 @@
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="操作" min-width="120" fixed="right" align="center" header-align="center">
<template #default="{ row }">
<el-button class="btn-op-item" @click="openViewDetail(row)">
<el-icon><View /></el-icon>查看
</el-button>
</template>
</el-table-column>
</el-table>
</div>
@@ -241,9 +283,11 @@
</template>
<script setup lang="ts">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { View, Close, Search, RefreshLeft, ArrowUpBold, ArrowDownBold } from '@element-plus/icons-vue'
import { Close, Search, RefreshLeft, ArrowUpBold, ArrowDownBold } from '@element-plus/icons-vue'
import { advanceApi, reimburseApi } from '@/api/bx'
import {
bizTypeTagType,
@@ -276,6 +320,24 @@ const dialogs = reactive<Record<DialogKey, boolean>>({
const mergedRows = ref<any[]>([])
const { applicantOptions, loadApplicantOptions } = useApplicantOptions()
const listColumnDefs = [
{ prop: 'applyTime', label: '申请时间' },
{ prop: 'bizType', label: '业务类型' },
{ prop: 'applicantName', label: '申请人' },
{ prop: 'title', label: '申请标题' },
{ prop: 'amount', label: '申请金额(元)' },
{ prop: 'status', label: '状态' },
{ prop: 'currentNode', label: '当前节点', defaultVisible: false }
]
const {
visibleColumns,
checkAll,
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('bx-view-all-list-columns', listColumnDefs)
const filterForm = reactive({
code: '',
bizType: '' as string,
@@ -333,7 +395,7 @@ function filterMergedRows(rows: any[]) {
const filteredMergedRows = computed(() => filterMergedRows(mergedRows.value))
const pager = usePagedTable(() => filteredMergedRows.value, { initialPageSize: 10 })
const pager = usePagedTable(() => filteredMergedRows.value, { initialPageSize: 20 })
const currentRow = ref<any>(null)
const viewDetailKvFields = computed<ViewField[]>(() => {
@@ -446,9 +508,18 @@ function handleReset() {
pager.currentPage.value = 1
}
function summaryMethod(params: { data: Array<{ amount?: number }> }) {
function summaryMethod(params: { columns: { property?: string; type?: string }[]; data: Array<{ amount?: number }> }) {
const totalAmount = params.data.reduce((sum, row) => sum + Number(row.amount || 0), 0)
return ['', `本页合计(${params.data.length}单)`, '', '', '', '', formatSumMoney(totalAmount), '', '', '']
let labelPlaced = false
return params.columns.map((col) => {
if (col.type === 'index') return ''
if (!labelPlaced) {
labelPlaced = true
return `本页合计(${params.data.length}单)`
}
if (col.property === 'amount') return formatSumMoney(totalAmount)
return ''
})
}
async function openViewDetail(row: any) {
+67 -13
View File
@@ -83,6 +83,20 @@
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<ListToolbarEndActions
:loading="tableLoading"
:column-defs="listColumnDefs"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="loadData"
@reset-columns="resetToDefault"
/>
</div>
</div>
<div class="list-body list-wrap">
<div class="table-scroll">
<el-table
@@ -94,6 +108,7 @@
>
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column
v-if="isColumnVisible('applyTime')"
prop="applyTime"
label="申请时间"
min-width="160"
@@ -108,8 +123,19 @@
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column prop="bizType" label="业务类型" min-width="100" align="center" header-align="center">
>
<template #default="{ row }">
<el-link type="primary" @click="openViewDetail(row)">{{ row.applyCode }}</el-link>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('bizType')"
prop="bizType"
label="业务类型"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag :type="bizTypeTagType(row.bizType)" size="small">
{{ row.bizType }}
@@ -117,6 +143,7 @@
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('title')"
prop="title"
label="申请标题"
min-width="180"
@@ -125,6 +152,7 @@
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('amount')"
prop="amount"
label="申请金额(元)"
:formatter="moneyTableFormatter"
@@ -133,14 +161,28 @@
align="right"
header-align="center"
/>
<el-table-column prop="status" label="状态" min-width="110" align="center" header-align="center">
<el-table-column
v-if="isColumnVisible('status')"
prop="status"
label="状态"
min-width="110"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">
{{ statusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="currentNode" label="当前节点" min-width="120" align="center" header-align="center">
<el-table-column
v-if="isColumnVisible('currentNode')"
prop="currentNode"
label="当前节点"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag
v-if="shouldShowWorkflowNodeTag(row.currentNode, row.status)"
@@ -152,13 +194,6 @@
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="操作" min-width="120" fixed="right" align="center" header-align="center">
<template #default="{ row }">
<el-button class="btn-op-item" @click="openViewDetail(row)">
<el-icon><View /></el-icon>查看
</el-button>
</template>
</el-table-column>
</el-table>
</div>
@@ -229,9 +264,11 @@
</template>
<script setup lang="ts">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { computed, onActivated, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { View, Close, Search, RefreshLeft, ArrowUpBold, ArrowDownBold } from '@element-plus/icons-vue'
import { Close, Search, RefreshLeft, ArrowUpBold, ArrowDownBold } from '@element-plus/icons-vue'
import { advanceApi, reimburseApi } from '@/api/bx'
import {
bizTypeTagType,
@@ -262,6 +299,23 @@ const dialogs = reactive<Record<DialogKey, boolean>>({
const mergedRows = ref<any[]>([])
const listColumnDefs = [
{ prop: 'applyTime', label: '申请时间' },
{ prop: 'bizType', label: '业务类型' },
{ prop: 'title', label: '申请标题' },
{ prop: 'amount', label: '申请金额(元)' },
{ prop: 'status', label: '状态' },
{ prop: 'currentNode', label: '当前节点', defaultVisible: false }
]
const {
visibleColumns,
checkAll,
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('bx-view-mine-list-columns', listColumnDefs)
const filterForm = reactive({
code: '',
bizType: '' as string,
@@ -310,7 +364,7 @@ const {
pageSize,
rangeText,
onSizeChange
} = usePagedTable(() => filteredMergedRows.value, { initialPageSize: 10 })
} = usePagedTable(() => filteredMergedRows.value, { initialPageSize: 20 })
const currentRow = ref<any>(null)
const viewDetailKvFields = computed<ViewField[]>(() => {
+27 -22
View File
@@ -93,6 +93,8 @@
<el-button class="btn-ghost" :loading="loadingMap.batchReject" @click="handleBatchReject">
<el-icon><Close /></el-icon>批量驳回
</el-button>
<ListToolbarEndActions :loading="tableLoading" @refresh="loadData" />
</div>
</div>
@@ -107,6 +109,7 @@
:summary-method="summaryMethod"
:max-height="'min(520px, calc(100vh - 300px))'"
@selection-change="onSelectionChange"
@row-dblclick="openViewDetailFromRow"
>
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
@@ -125,7 +128,11 @@
align="center"
header-align="center"
show-overflow-tooltip
/>
>
<template #default="{ row }">
<el-link type="primary" @click="openViewDetail(row)">{{ row.applyCode }}</el-link>
</template>
</el-table-column>
<el-table-column prop="bizType" label="业务类型" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag :type="bizTypeTagType(row.bizType)" size="small">
@@ -163,25 +170,6 @@
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="操作" min-width="280" fixed="right" align="center" header-align="center">
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px; flex-wrap: nowrap">
<el-button class="btn-op-item" @click="openViewDetail(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-button
class="btn-op-item"
:loading="loadingMap[`pass_${row.id}`]"
@click="handleApprove(row)"
>
<el-icon><Check /></el-icon>通过
</el-button>
<el-button class="btn-op-item" @click="openRejectDialog(row)">
<el-icon><Close /></el-icon>驳回
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
@@ -228,6 +216,19 @@
金额(元):<b class="money-em">{{ formatMoneyYuan(currentRow?.amount || 0) }}</b>
· 申请人:{{ currentRow?.applicantName || '-' }}
</p>
<div v-if="currentRow" class="dlg-view-hero-actions">
<el-button
class="btn-main"
type="primary"
:loading="loadingMap[`pass_${currentRow.id}`]"
@click="handleApprove(currentRow)"
>
<el-icon><Check /></el-icon>通过
</el-button>
<el-button class="btn-ghost" @click="openRejectDialog(currentRow)">
<el-icon><Close /></el-icon>驳回
</el-button>
</div>
</div>
<div class="dlg-view-section">
<h4 class="dlg-view-section-title">基础信息</h4>
@@ -284,10 +285,10 @@
</template>
<script setup lang="ts">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import { computed, onActivated, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import {
View,
Check,
Close,
Search,
@@ -376,7 +377,7 @@ const {
pageSize,
rangeText,
onSizeChange
} = usePagedTable(() => filteredMergedRows.value, { initialPageSize: 10 })
} = usePagedTable(() => filteredMergedRows.value, { initialPageSize: 20 })
const currentRow = ref<any>(null)
const viewDetailKvFields = computed<ViewField[]>(() => {
@@ -602,6 +603,10 @@ function summaryMethod(params: { data: Array<{ amount?: number }> }) {
return ['', '', `本页合计(${params.data.length}单)`, '', '', '', '', formatSumMoney(totalAmount), '', '']
}
function openViewDetailFromRow(row: any) {
openViewDetail(row)
}
async function openViewDetail(row: any) {
currentRow.value = row
viewFlowNodes.value = []
@@ -20,19 +20,51 @@
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">0</span> 项
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="openAddDialog">
<el-icon><Plus /></el-icon>新增节点
</el-button>
<el-button
class="btn-ghost"
:disabled="selectedRows.length !== 1"
@click="handleToolbarEdit"
>
<el-icon><EditPen /></el-icon>编辑
</el-button>
<el-button
class="btn-ghost"
type="danger"
:disabled="selectedRows.length !== 1 || !canDeleteByCreator(selectedRows[0])"
:title="selectedRows.length === 1 && !canDeleteByCreator(selectedRows[0]) ? CREATOR_DELETE_TIP : ''"
:loading="loadingMap.rowDelete"
@click="handleToolbarDelete"
>
<el-icon><Delete /></el-icon>删除
</el-button>
<el-button class="btn-ghost" :disabled="selectedRows.length !== 1" @click="handleToolbarMoveUp">
<el-icon><ArrowUp /></el-icon>上移
</el-button>
<el-button class="btn-ghost" :disabled="selectedRows.length !== 1" @click="handleToolbarMoveDown">
<el-icon><ArrowDown /></el-icon>下移
</el-button>
<el-button class="btn-ghost" :loading="loadingMap.save" @click="saveNodes">
<el-icon><Upload /></el-icon>保存配置
</el-button>
<ListToolbarEndActions :loading="loading" @refresh="loadWfDef" />
</div>
</div>
<div class="list-body list-wrap">
<el-table class="list-table" :data="nodes" v-loading="loading" stripe>
<el-table
class="list-table"
:data="nodes"
v-loading="loading"
stripe
@selection-change="onSelectionChange"
>
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column
prop="nodeName"
@@ -58,30 +90,6 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" min-width="360" fixed="right" align="center" header-align="center">
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px; flex-wrap: nowrap">
<el-button class="btn-op-item" @click="openEditDialog(row)">
<el-icon><EditPen /></el-icon>编辑
</el-button>
<el-button
class="btn-op-item"
:disabled="!canDeleteByCreator(row)"
:title="canDeleteByCreator(row) ? '' : CREATOR_DELETE_TIP"
:loading="loadingMap.rowDelete"
@click="deleteNode(row)"
>
<el-icon><Delete /></el-icon>删除
</el-button>
<el-button class="btn-op-item" @click="moveUp(row)">
<el-icon><ArrowUp /></el-icon>上移
</el-button>
<el-button class="btn-op-item" @click="moveDown(row)">
<el-icon><ArrowDown /></el-icon>下移
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</section>
@@ -186,6 +194,7 @@
<script setup lang="ts">
import { reactive, ref, computed, onMounted } from 'vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance } from 'element-plus'
@@ -212,6 +221,7 @@ const dialogs = reactive<Record<DialogKey, boolean>>({
})
const loading = ref(false)
const selectedRows = ref<Record<string, any>[]>([])
const wfDef = ref<Record<string, any>>({})
const nodes = computed(() => wfDef.value.nodes || [])
const userOptions = ref<
@@ -269,6 +279,36 @@ onMounted(() => {
loadUsers()
})
function onSelectionChange(rows: Record<string, any>[]) {
selectedRows.value = rows
}
function handleToolbarEdit() {
if (selectedRows.value.length !== 1) {
ElMessage.warning('请先勾选一条节点')
return
}
openEditDialog(selectedRows.value[0])
}
function handleToolbarDelete() {
if (selectedRows.value.length !== 1) {
ElMessage.warning('请先勾选一条节点')
return
}
deleteNode(selectedRows.value[0])
}
function handleToolbarMoveUp() {
if (selectedRows.value.length !== 1) return
moveUp(selectedRows.value[0])
}
function handleToolbarMoveDown() {
if (selectedRows.value.length !== 1) return
moveDown(selectedRows.value[0])
}
function buildNodePayload(list: Record<string, any>[]) {
return list.map((n, i) => ({
nodeName: n.nodeName,
@@ -20,19 +20,51 @@
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">0</span> 项
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="openAddDialog">
<el-icon><Plus /></el-icon>新增节点
</el-button>
<el-button
class="btn-ghost"
:disabled="selectedRows.length !== 1"
@click="handleToolbarEdit"
>
<el-icon><EditPen /></el-icon>编辑
</el-button>
<el-button
class="btn-ghost"
type="danger"
:disabled="selectedRows.length !== 1 || !canDeleteByCreator(selectedRows[0])"
:title="selectedRows.length === 1 && !canDeleteByCreator(selectedRows[0]) ? CREATOR_DELETE_TIP : ''"
:loading="loadingMap.rowDelete"
@click="handleToolbarDelete"
>
<el-icon><Delete /></el-icon>删除
</el-button>
<el-button class="btn-ghost" :disabled="selectedRows.length !== 1" @click="handleToolbarMoveUp">
<el-icon><ArrowUp /></el-icon>上移
</el-button>
<el-button class="btn-ghost" :disabled="selectedRows.length !== 1" @click="handleToolbarMoveDown">
<el-icon><ArrowDown /></el-icon>下移
</el-button>
<el-button class="btn-ghost" :loading="loadingMap.save" @click="saveNodes">
<el-icon><Upload /></el-icon>保存配置
</el-button>
<ListToolbarEndActions :loading="loading" @refresh="loadWfDef" />
</div>
</div>
<div class="list-body list-wrap">
<el-table class="list-table" :data="nodes" v-loading="loading" stripe>
<el-table
class="list-table"
:data="nodes"
v-loading="loading"
stripe
@selection-change="onSelectionChange"
>
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column
prop="nodeName"
@@ -58,30 +90,6 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" min-width="360" fixed="right" align="center" header-align="center">
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px; flex-wrap: nowrap">
<el-button class="btn-op-item" @click="openEditDialog(row)">
<el-icon><EditPen /></el-icon>编辑
</el-button>
<el-button
class="btn-op-item"
:disabled="!canDeleteByCreator(row)"
:title="canDeleteByCreator(row) ? '' : CREATOR_DELETE_TIP"
:loading="loadingMap.rowDelete"
@click="deleteNode(row)"
>
<el-icon><Delete /></el-icon>删除
</el-button>
<el-button class="btn-op-item" @click="moveUp(row)">
<el-icon><ArrowUp /></el-icon>上移
</el-button>
<el-button class="btn-op-item" @click="moveDown(row)">
<el-icon><ArrowDown /></el-icon>下移
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</section>
@@ -186,6 +194,7 @@
<script setup lang="ts">
import { reactive, ref, computed, onMounted } from 'vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance } from 'element-plus'
@@ -212,6 +221,7 @@ const dialogs = reactive<Record<DialogKey, boolean>>({
})
const loading = ref(false)
const selectedRows = ref<Record<string, any>[]>([])
const wfDef = ref<Record<string, any>>({})
const nodes = computed(() => wfDef.value.nodes || [])
const userOptions = ref<
@@ -269,6 +279,36 @@ onMounted(() => {
loadUsers()
})
function onSelectionChange(rows: Record<string, any>[]) {
selectedRows.value = rows
}
function handleToolbarEdit() {
if (selectedRows.value.length !== 1) {
ElMessage.warning('请先勾选一条节点')
return
}
openEditDialog(selectedRows.value[0])
}
function handleToolbarDelete() {
if (selectedRows.value.length !== 1) {
ElMessage.warning('请先勾选一条节点')
return
}
deleteNode(selectedRows.value[0])
}
function handleToolbarMoveUp() {
if (selectedRows.value.length !== 1) return
moveUp(selectedRows.value[0])
}
function handleToolbarMoveDown() {
if (selectedRows.value.length !== 1) return
moveDown(selectedRows.value[0])
}
function buildNodePayload(list: Record<string, any>[]) {
return list.map((n, i) => ({
nodeName: n.nodeName,
@@ -172,7 +172,7 @@
const followQuery = ref<ClueFollowQuery>({
clueId: '',
pageNum: 1,
pageSize: 10
pageSize: 20
})
const clueFormRef = ref<InstanceType<typeof ClueForm>>()
+104 -194
View File
@@ -3,18 +3,20 @@
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2>线索管理</h2>
<h2 class="title-bar-heading">线索管理</h2>
</div>
<div class="panel-tabs">
<el-tabs v-model="activeView" @tab-change="handleViewChange">
<el-tab-pane label="我的线索" name="mine" />
<el-tab-pane label="全部线索" name="all" />
<el-tab-pane label="待跟进" name="toFollow" />
<el-tab-pane label="今日新增" name="today" />
<el-tab-pane label="已转化" name="converted" />
</el-tabs>
</div>
<el-tabs
v-model="activeView"
class="panel-pill-tabs panel-pill-tabs--lg"
@tab-change="handleViewChange"
>
<el-tab-pane label="我的线索" name="mine" />
<el-tab-pane label="全部线索" name="all" />
<el-tab-pane label="待跟进" name="toFollow" />
<el-tab-pane label="今日新增" name="today" />
<el-tab-pane label="已转化" name="converted" />
</el-tabs>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
@@ -157,6 +159,40 @@
<el-button v-if="hasCluePerm('add')" class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>新增线索
</el-button>
<el-button
v-if="hasCluePerm('edit')"
class="btn-ghost"
:disabled="single || selectedRows[0]?.isConverted === 1"
@click="handleEdit()"
>
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button
v-if="hasCluePerm('follow')"
class="btn-ghost"
:disabled="
single ||
selectedRows[0]?.isConverted === 1 ||
selectedRows[0]?.status === 'invalid'
"
@click="handleAddFollow()"
>
<el-icon><ChatLineRound /></el-icon>跟进
</el-button>
<el-button
v-if="hasCluePerm('remove')"
class="btn-ghost"
type="danger"
:disabled="
multiple ||
selectedRows[0]?.isConverted === 1 ||
!allRowsDeletableByCreator(selectedRows)
"
:title="allRowsDeletableByCreator(selectedRows) ? '' : CREATOR_DELETE_TIP"
@click="handleDelete()"
>
<el-icon><Delete /></el-icon>删除
</el-button>
<el-button
v-if="hasCluePerm('assign')"
class="btn-ghost"
@@ -181,6 +217,16 @@
>
<el-icon><CircleClose /></el-icon>标为无效
</el-button>
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="getList"
@reset-columns="resetToDefault"
/>
</div>
</div>
@@ -367,54 +413,6 @@
header-align="center"
show-overflow-tooltip
/>
<el-table-column
label="操作"
min-width="400"
align="center"
header-align="center"
fixed="right"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button v-if="hasCluePerm('query')" class="btn-op-item" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-button
v-if="hasCluePerm('edit')"
class="btn-op-item primary"
:disabled="row.isConverted === 1"
@click="handleEdit(row)"
>
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button
v-if="hasCluePerm('follow')"
class="btn-op-item primary"
:disabled="row.isConverted === 1 || row.status === 'invalid'"
@click="handleAddFollow(row)"
>
<el-icon><ChatLineRound /></el-icon>跟进
</el-button>
<el-button
v-if="hasCluePerm('invalid')"
class="btn-op-item op-warn"
:disabled="row.isConverted === 1 || row.status === 'invalid'"
@click="handleInvalid(row)"
>
<el-icon><CircleClose /></el-icon>无效
</el-button>
<el-button
v-if="hasCluePerm('remove')"
class="btn-op-item danger"
:disabled="row.isConverted === 1 || !canDeleteByCreator(row)"
:title="canDeleteByCreator(row) ? '' : CREATOR_DELETE_TIP"
@click="handleDelete(row)"
>
<el-icon><Delete /></el-icon>删除
</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
@@ -438,45 +436,6 @@
<InvalidForm ref="invalidFormRef" @success="getList" />
<DetailDrawer ref="detailDrawerRef" @success="getList" />
<el-dialog
v-model="columnSettingDialog.visible"
title="列设置"
width="600px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<div class="column-setting-content">
<el-checkbox
v-model="checkAll"
:indeterminate="isIndeterminate"
@change="handleCheckAllChange"
>
全选
</el-checkbox>
<el-divider />
<el-checkbox-group v-model="visibleColumns" @change="handleCheckedColumnsChange">
<div v-for="col in tableColumns" :key="col.prop" class="column-item">
<el-checkbox :label="col.prop" :disabled="col.fixed">{{ col.label }}</el-checkbox>
</div>
</el-checkbox-group>
</div>
<template #footer>
<div class="dialog-footer">
<el-button class="btn-ghost" @click="columnSettingDialog.visible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button class="btn-ghost" @click="handleResetColumns">
<el-icon><RefreshLeft /></el-icon>恢复默认
</el-button>
<el-button class="btn-main" type="primary" @click="handleSaveColumnSetting">
<el-icon><Check /></el-icon>保存
</el-button>
</div>
</template>
</el-dialog>
<!-- 导入弹窗 -->
<el-dialog
v-model="importDialogVisible"
@@ -536,7 +495,6 @@
Check,
Search,
RefreshLeft,
View,
User,
ArrowUpBold,
ArrowDownBold,
@@ -550,7 +508,7 @@
import type { UploadFile } from 'element-plus'
import { useDictStore } from '@/store/modules/dict'
import { useCluePerm } from '@/hooks/business/useCluePerm'
import { canDeleteByCreator, filterDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import {
listClue,
listMyClue,
@@ -567,6 +525,8 @@
import AssignForm from './assignForm.vue'
import InvalidForm from './invalidForm.vue'
import DetailDrawer from './detailDrawer.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
const dictStore = useDictStore()
const { loadPerms, hasCluePerm } = useCluePerm()
@@ -589,7 +549,7 @@
const clueList = ref<any[]>([])
const loading = ref(false)
const selectedRows = ref<any[]>([])
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const pagination = reactive({ page: 1, pageSize: 20, total: 0 })
const single = ref(true)
const multiple = ref(true)
@@ -602,38 +562,44 @@
const invalidFormRef = ref<InstanceType<typeof InvalidForm>>()
const detailDrawerRef = ref<InstanceType<typeof DetailDrawer>>()
const COLUMN_STORAGE_KEY = 'crm_clue_visible_columns'
const tableColumns = ref([
{ prop: 'company', label: '公司名称', fixed: false },
{ prop: 'customerType', label: '客户类型', fixed: false },
{ prop: 'mobile', label: '手机号', fixed: false },
{ prop: 'source', label: '线索来源', fixed: false },
{ prop: 'level', label: '线索等级', fixed: false },
{ prop: 'status', label: '线索状态', fixed: false },
{ prop: 'industry', label: '所属行业', fixed: false },
{ prop: 'parkArea', label: '所在园区', fixed: false },
{ prop: 'ownerUserName', label: '负责人', fixed: false },
{ prop: 'followCount', label: '跟进次数', fixed: false },
{ prop: 'lastTime', label: '最后跟进时间', fixed: false },
{ prop: 'nextTime', label: '下次联系时间', fixed: false },
{ prop: 'createTime', label: '创建时间', fixed: false }
])
const defaultVisibleColumns = [
'company',
'customerType',
'mobile',
'source',
'level',
'status',
'parkArea',
'ownerUserName',
'followCount',
'lastTime',
'nextTime',
'createTime'
const listColumnDefs = [
{ prop: 'company', label: '公司名称' },
{ prop: 'customerType', label: '客户类型' },
{ prop: 'mobile', label: '手机号' },
{ prop: 'source', label: '线索来源' },
{ prop: 'level', label: '线索等级' },
{ prop: 'status', label: '线索状态' },
{ prop: 'industry', label: '所属行业' },
{ prop: 'parkArea', label: '所在园区' },
{ prop: 'ownerUserName', label: '负责人' },
{ prop: 'followCount', label: '跟进次数' },
{ prop: 'lastTime', label: '最后跟进时间' },
{ prop: 'nextTime', label: '下次联系时间' },
{ prop: 'createTime', label: '创建时间' }
]
const visibleColumns = ref<string[]>([])
const columnSettingDialog = reactive({ visible: false })
const {
visibleColumns,
checkAll,
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('crm_clue_visible_columns', listColumnDefs, {
defaultVisibleProps: [
'company',
'customerType',
'mobile',
'source',
'level',
'status',
'parkArea',
'ownerUserName',
'followCount',
'lastTime',
'nextTime',
'createTime'
]
})
const importDialogVisible = ref(false)
const importFileList = ref<UploadFile[]>([])
@@ -641,8 +607,6 @@
const importUpdateSupport = ref(false)
const templateLoading = ref(false)
const exportLoading = ref(false)
const checkAll = ref(false)
const isIndeterminate = ref(false)
const canConvertSelected = computed(() => {
if (selectedRows.value.length !== 1) return false
@@ -747,8 +711,13 @@
clueFormRef.value?.open()
}
function handleEdit(row: any) {
clueFormRef.value?.open(row.id)
function handleEdit(row?: any) {
if (!row && ids.value.length !== 1) {
ElMessage.warning('请选择一条数据进行编辑')
return
}
const id = row?.id || ids.value[0]
clueFormRef.value?.open(id)
}
function handleView(row?: any) {
@@ -857,55 +826,6 @@
await getList()
}
function initColumnSetting() {
const saved = localStorage.getItem(COLUMN_STORAGE_KEY)
if (saved) {
try {
visibleColumns.value = JSON.parse(saved)
} catch {
visibleColumns.value = [...defaultVisibleColumns]
}
} else {
visibleColumns.value = [...defaultVisibleColumns]
}
updateCheckAllStatus()
}
function isColumnVisible(prop: string) {
return visibleColumns.value.includes(prop)
}
function handleColumnSetting() {
columnSettingDialog.visible = true
}
function handleCheckAllChange(val: boolean) {
visibleColumns.value = val ? tableColumns.value.map((col) => col.prop) : []
isIndeterminate.value = false
}
function handleCheckedColumnsChange() {
updateCheckAllStatus()
}
function updateCheckAllStatus() {
const checkedCount = visibleColumns.value.length
const totalCount = tableColumns.value.length
checkAll.value = checkedCount === totalCount
isIndeterminate.value = checkedCount > 0 && checkedCount < totalCount
}
function handleResetColumns() {
visibleColumns.value = [...defaultVisibleColumns]
updateCheckAllStatus()
}
function handleSaveColumnSetting() {
localStorage.setItem(COLUMN_STORAGE_KEY, JSON.stringify(visibleColumns.value))
ElMessage.success('列设置保存成功')
columnSettingDialog.visible = false
}
function handleImport() {
importFileList.value = []
importUpdateSupport.value = false
@@ -991,7 +911,6 @@
onMounted(() => {
loadPerms()
initColumnSetting()
getList()
})
</script>
@@ -1005,13 +924,4 @@
.quick-search-icon {
cursor: pointer;
}
.column-setting-content {
max-height: 400px;
overflow-y: auto;
}
.column-item {
margin-bottom: 8px;
}
</style>
@@ -2,7 +2,7 @@
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2>线索统计</h2>
<h2 class="title-bar-heading">线索统计</h2>
</div>
<div class="filter-block">
@@ -1,35 +1,46 @@
<!-- 合同审批记录 -->
<template>
<div class="app-container">
<!-- 查询表单 -->
<el-card shadow="hover" v-show="showSearch" class="mb-[10px]">
<el-form :model="queryParams" ref="queryFormRef" label-width="90px">
<el-row :gutter="20">
<el-col :xl="4" :lg="6" :md="8" :sm="12" :xs="24">
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">合同审批记录</h2>
</div>
<div class="filter-block">
<el-form ref="queryFormRef" :model="queryParams" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="合同编号" prop="contractNo">
<el-input v-model="queryParams.contractNo" placeholder="请输入合同编号" clearable @keyup.enter="handleQuery" />
<el-input
v-model="queryParams.contractNo"
placeholder="合同编号"
clearable
@keyup.enter="handleQuery"
/>
</el-form-item>
</el-col>
<el-col :xl="4" :lg="6" :md="8" :sm="12" :xs="24">
<el-form-item label="合同名称" prop="contractName">
<el-input v-model="queryParams.contractName" placeholder="请输入合同名称" clearable @keyup.enter="handleQuery" />
<el-input
v-model="queryParams.contractName"
placeholder="合同名称"
clearable
@keyup.enter="handleQuery"
/>
</el-form-item>
</el-col>
<el-col :xl="4" :lg="6" :md="8" :sm="12" :xs="24">
<el-form-item label="审批人" prop="approverName">
<el-input v-model="queryParams.approverName" placeholder="请输入审批人" clearable @keyup.enter="handleQuery" />
<el-input
v-model="queryParams.approverName"
placeholder="审批人"
clearable
@keyup.enter="handleQuery"
/>
</el-form-item>
</el-col>
<el-col :xl="4" :lg="6" :md="8" :sm="12" :xs="24">
<el-form-item label="审批结果" prop="approvalResult">
<el-select v-model="queryParams.approvalResult" placeholder="请选择审批结果" clearable style="width: 100%">
<el-select v-model="queryParams.approvalResult" placeholder="全部" clearable>
<el-option label="通过" value="approved" />
<el-option label="驳回" value="rejected" />
<el-option label="待审批" value="pending" />
</el-select>
</el-form-item>
</el-col>
<el-col :xl="8" :lg="12" :md="16" :sm="24" :xs="24">
<el-form-item label="审批时间">
<el-form-item label="审批时间" class="form-col-span-2">
<el-date-picker
v-model="dateRange"
type="daterange"
@@ -40,113 +51,188 @@
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="24" style="text-align: right">
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-col>
</el-row>
</el-form>
</el-card>
</div>
<div class="filter-actions-row">
<span class="filter-hint">按合同、审批人、结果及时间筛选。</span>
<div class="filter-actions-end">
<el-button class="btn-ghost" :loading="loading" @click="resetQuery">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" :loading="loading" @click="handleQuery">
<el-icon><Search /></el-icon>搜索
</el-button>
</div>
</div>
</el-form>
</div>
<!-- 工具栏 -->
<el-row :gutter="10" class="mb8">
<el-col v-if="hasContractPerm('approvalExport')" :span="1.5">
<el-button type="success" icon="Download" @click="handleExport">导出</el-button>
</el-col>
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
</el-row>
<!-- 数据表格 -->
<el-table v-loading="loading" :data="approvalList" border>
<el-table-column label="合同编号" align="center" prop="contractNo" width="150" :show-overflow-tooltip="true">
<template #default="scope">
<el-link type="primary" @click="handleViewContract(scope.row)">{{ scope.row.contractNo }}</el-link>
</template>
</el-table-column>
<el-table-column label="合同名称" prop="contractName" :show-overflow-tooltip="true" min-width="200" />
<el-table-column label="审批级别" align="center" prop="step" width="100">
<template #default="scope"> 第{{ scope.row.step }}级 </template>
</el-table-column>
<el-table-column label="审批人" align="center" prop="approverName" width="120" />
<el-table-column label="审批结果" align="center" prop="approvalResult" width="100">
<template #default="scope">
<el-tag v-if="scope.row.approvalResult === 'approved'" type="success">通过</el-tag>
<el-tag v-else-if="scope.row.approvalResult === 'rejected'" type="danger">驳回</el-tag>
<el-tag v-else type="warning">待审批</el-tag>
</template>
</el-table-column>
<el-table-column label="审批意见" prop="approvalOpinion" :show-overflow-tooltip="true" min-width="200" />
<el-table-column label="审批时间" align="center" prop="approvalTime" width="160">
<template #default="scope">
<span>{{ parseTime(scope.row.approvalTime) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="150" fixed="right">
<template #default="scope">
<el-tooltip v-if="hasContractPerm('approvalQuery')" content="查看" placement="top">
<el-button link type="primary" icon="View" @click="handleView(scope.row)" />
</el-tooltip>
<el-tooltip
v-if="hasContractPerm('approvalApprove') && scope.row.approvalResult === 'pending'"
content="审批"
placement="top"
<div class="list-toolbar">
<div class="list-toolbar-row">
<el-button
v-if="hasContractPerm('approvalExport')"
class="btn-ghost"
:loading="exportLoading"
@click="handleExport"
>
<el-button link type="success" icon="CircleCheck" @click="handleApprove(scope.row)" />
</el-tooltip>
</template>
</el-table-column>
</el-table>
<el-icon><Download /></el-icon>导出
</el-button>
<!-- 分页 -->
<pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
<!-- 查看详情 -->
<SystemViewDialog
v-model:visible="viewDialog.visible"
title="审批详情"
:icon="Document"
:code="viewDialog.data.contractNo"
code-label="合同编号"
:meta="viewDialog.data.contractName"
:sections="approvalViewSections"
append-to-body
/>
<!-- 审批对话框 -->
<el-dialog title="审批" v-model="approvalDialog.visible" width="600px" append-to-body>
<el-form ref="approvalFormRef" :model="approvalForm" :rules="approvalRules" label-width="100px">
<el-form-item label="审批结果" prop="approvalResult">
<el-radio-group v-model="approvalForm.approvalResult">
<el-radio value="approved">通过</el-radio>
<el-radio value="rejected">驳回</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="审批意见" prop="approvalOpinion">
<el-input
v-model="approvalForm.approvalOpinion"
type="textarea"
:rows="5"
:placeholder="approvalForm.approvalResult === 'rejected' ? '请输入驳回原因' : '请输入审批意见(可选)'"
/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="approvalDialog.visible = false">取 消</el-button>
<el-button type="primary" @click="submitApproval">确 定</el-button>
<ListToolbarEndActions :loading="loading" @refresh="getList" />
</div>
</template>
</el-dialog>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
v-loading="loading"
:data="approvalList"
stripe
row-key="id"
@row-dblclick="handleView"
>
<el-table-column
prop="contractNo"
label="合同编号"
min-width="150"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<el-link type="primary" @click="handleViewContract(row)">{{ row.contractNo }}</el-link>
</template>
</el-table-column>
<el-table-column
prop="contractName"
label="合同名称"
min-width="200"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column label="审批级别" min-width="100" align="center" header-align="center">
<template #default="{ row }">第{{ row.step }}级</template>
</el-table-column>
<el-table-column
prop="approverName"
label="审批人"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column label="审批结果" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag v-if="row.approvalResult === 'approved'" type="success" size="small">通过</el-tag>
<el-tag v-else-if="row.approvalResult === 'rejected'" type="danger" size="small">驳回</el-tag>
<el-tag v-else type="warning" size="small">待审批</el-tag>
</template>
</el-table-column>
<el-table-column
prop="approvalOpinion"
label="审批意见"
min-width="200"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column label="审批时间" min-width="160" align="center" header-align="center">
<template #default="{ row }">{{ parseTime(row.approvalTime) }}</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
v-model:current-page="queryParams.pageNum"
v-model:page-size="queryParams.pageSize"
:page-sizes="[10, 20, 30, 50]"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
@size-change="getList"
@current-change="getList"
/>
</div>
</div>
<SystemViewDialog
v-model:visible="viewDialog.visible"
title="审批详情"
:icon="Document"
:code="viewDialog.data.contractNo"
code-label="合同编号"
:meta="viewDialog.data.contractName"
:sections="approvalViewSections"
append-to-body
>
<template #hero-actions>
<div class="approval-view-hero-actions">
<el-button
v-if="hasContractPerm('approvalApprove') && viewDialog.data.approvalResult === 'pending'"
class="btn-main"
type="primary"
@click="handleApprove(viewDialog.data)"
>
<el-icon><EditPen /></el-icon>审批
</el-button>
</div>
</template>
</SystemViewDialog>
<el-dialog
v-model="approvalDialog.visible"
title="审批"
width="600px"
class="dialog-form-layout order-module-dialog-mask"
align-center
destroy-on-close
:close-on-click-modal="false"
append-to-body
>
<el-form
ref="approvalFormRef"
:model="approvalForm"
:rules="approvalRules"
label-position="top"
scroll-to-error
>
<div class="form-grid-2">
<el-form-item label="审批结果" prop="approvalResult" class="form-col-span-2">
<el-radio-group v-model="approvalForm.approvalResult">
<el-radio value="approved">通过</el-radio>
<el-radio value="rejected">驳回</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="审批意见" prop="approvalOpinion" class="form-col-span-2">
<el-input
v-model="approvalForm.approvalOpinion"
type="textarea"
:rows="5"
:placeholder="
approvalForm.approvalResult === 'rejected'
? '请输入驳回原因'
: '请输入审批意见(可选)'
"
/>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="approvalDialog.visible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button class="btn-main" type="primary" :loading="submitLoading" @click="submitApproval">
<el-icon><Check /></el-icon>确定
</el-button>
</template>
</el-dialog>
</section>
</div>
</template>
<script setup lang="ts" name="ContractApproval">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { Document } from '@element-plus/icons-vue'
import { Document, Download, Search, RefreshLeft, Close, Check, EditPen } from '@element-plus/icons-vue'
import SystemViewDialog, {
type ViewField,
type ViewSection
@@ -168,54 +254,50 @@ import { useContractPerm } from '@/hooks/business/useContractPerm'
const router = useRouter()
const { loadPerms, hasContractPerm } = useContractPerm()
// 查询参数
const queryParams = ref<ApprovalQuery>({
pageNum: 1,
pageSize: 10,
pageSize: 20,
contractNo: undefined,
contractName: undefined,
approverName: undefined,
approvalResult: undefined,
beginTime: undefined,
endTime: undefined
});
})
// 审批表单
const approvalForm = ref({
id: undefined as number | undefined,
approvalResult: 'approved',
approvalOpinion: ''
});
})
// 审批表单校验规则
const approvalRules = ref({
approvalResult: [{ required: true, message: '请选择审批结果', trigger: 'change' }],
approvalOpinion: [
{
validator: (rule: any, value: any, callback: any) => {
validator: (_rule: unknown, value: string, callback: (err?: Error) => void) => {
if (approvalForm.value.approvalResult === 'rejected' && !value) {
callback(new Error('驳回时必须填写驳回原因'));
callback(new Error('驳回时必须填写驳回原因'))
} else {
callback();
callback()
}
},
trigger: 'blur'
}
]
});
})
// 数据列表
const approvalList = ref<ApprovalVO[]>([]);
const loading = ref(true);
const total = ref(0);
const showSearch = ref(false);
const dateRange = ref<[string, string]>();
const approvalList = ref<ApprovalVO[]>([])
const loading = ref(true)
const submitLoading = ref(false)
const exportLoading = ref(false)
const total = ref(0)
const dateRange = ref<[string, string]>()
// 查看详情对话框
const viewDialog = reactive({
visible: false,
data: {} as ApprovalVO
});
})
function approvalResultTag(result?: string) {
if (result === 'approved') return { label: '通过', type: 'success' as const }
@@ -252,101 +334,93 @@ const approvalViewSections = computed<ViewSection[]>(() => {
]
})
// 审批对话框
const approvalDialog = reactive({
visible: false
});
})
const queryFormRef = ref<ElFormInstance>();
const approvalFormRef = ref<ElFormInstance>();
const queryFormRef = ref<ElFormInstance>()
const approvalFormRef = ref<ElFormInstance>()
/** 查询列表 */
const getList = async () => {
loading.value = true;
loading.value = true
try {
// 处理日期范围
if (dateRange.value && dateRange.value.length === 2) {
queryParams.value.beginTime = dateRange.value[0];
queryParams.value.endTime = dateRange.value[1];
queryParams.value.beginTime = dateRange.value[0]
queryParams.value.endTime = dateRange.value[1]
} else {
queryParams.value.beginTime = undefined;
queryParams.value.endTime = undefined;
queryParams.value.beginTime = undefined
queryParams.value.endTime = undefined
}
const res = await listApproval(queryParams.value);
approvalList.value = res.rows;
total.value = res.total;
const res = await listApproval(queryParams.value)
approvalList.value = res.rows
total.value = res.total
} finally {
loading.value = false;
loading.value = false
}
};
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.value.pageNum = 1;
getList();
};
queryParams.value.pageNum = 1
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
dateRange.value = undefined;
queryFormRef.value?.resetFields();
handleQuery();
};
dateRange.value = undefined
queryFormRef.value?.resetFields()
handleQuery()
}
/** 查看详情 */
const handleView = async (row: ApprovalVO) => {
const res = await getApproval(row.id);
viewDialog.data = res.data;
viewDialog.visible = true;
};
const res = await getApproval(row.id)
viewDialog.data = res.data
viewDialog.visible = true
}
/** 查看合同 */
const handleViewContract = (row: ApprovalVO) => {
router.push({
path: '/crm/contract/detail',
query: { id: row.contractId }
});
};
})
}
/** 审批操作 */
const handleApprove = (row: ApprovalVO) => {
approvalForm.value = {
id: row.id,
approvalResult: 'approved',
approvalOpinion: ''
};
approvalDialog.visible = true;
};
}
approvalDialog.visible = true
}
/** 提交审批 */
const submitApproval = () => {
approvalFormRef.value?.validate(async (valid) => {
if (valid) {
try {
if (approvalForm.value.approvalResult === 'approved') {
await approvePass({
id: approvalForm.value.id!,
approvalOpinion: approvalForm.value.approvalOpinion
});
$modal.msgSuccess('审批通过')
} else {
await approveReject({
id: approvalForm.value.id!,
approvalOpinion: approvalForm.value.approvalOpinion
})
$modal.msgSuccess('已驳回')
}
approvalDialog.visible = false;
await getList();
} catch (error) {
console.error(error);
if (!valid) return
submitLoading.value = true
try {
if (approvalForm.value.approvalResult === 'approved') {
await approvePass({
id: approvalForm.value.id!,
approvalOpinion: approvalForm.value.approvalOpinion
})
$modal.msgSuccess('审批通过')
} else {
await approveReject({
id: approvalForm.value.id!,
approvalOpinion: approvalForm.value.approvalOpinion
})
$modal.msgSuccess('已驳回')
}
approvalDialog.visible = false
await getList()
} catch (error) {
console.error(error)
} finally {
submitLoading.value = false
}
});
};
})
}
/** 导出按钮操作 */
const handleExport = async () => {
if (dateRange.value && dateRange.value.length === 2) {
queryParams.value.beginTime = dateRange.value[0]
@@ -355,6 +429,7 @@ const handleExport = async () => {
queryParams.value.beginTime = undefined
queryParams.value.endTime = undefined
}
exportLoading.value = true
try {
const res = await exportApproval(queryParams.value)
const blob =
@@ -372,6 +447,8 @@ const handleExport = async () => {
} catch (error) {
console.error(error)
$modal.msgError('导出失败')
} finally {
exportLoading.value = false
}
}
@@ -380,10 +457,3 @@ onMounted(async () => {
getList()
})
</script>
<style scoped lang="scss">
.text-content {
white-space: pre-wrap;
word-break: break-all;
}
</style>
+419 -304
View File
@@ -1,22 +1,32 @@
<!-- 合同变更记录 -->
<template>
<div class="app-container">
<!-- 查询表单 -->
<el-card shadow="hover" v-show="showSearch" class="mb-[10px]">
<el-form :model="queryParams" ref="queryFormRef" label-width="90px">
<el-row :gutter="20">
<el-col :xl="4" :lg="6" :md="8" :sm="12" :xs="24">
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">合同变更</h2>
</div>
<div class="filter-block">
<el-form ref="queryFormRef" :model="queryParams" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="合同编号" prop="contractNo">
<el-input v-model="queryParams.contractNo" placeholder="请输入合同编号" clearable @keyup.enter="handleQuery" />
<el-input
v-model="queryParams.contractNo"
placeholder="合同编号"
clearable
@keyup.enter="handleQuery"
/>
</el-form-item>
</el-col>
<el-col :xl="4" :lg="6" :md="8" :sm="12" :xs="24">
<el-form-item label="合同名称" prop="contractName">
<el-input v-model="queryParams.contractName" placeholder="请输入合同名称" clearable @keyup.enter="handleQuery" />
<el-input
v-model="queryParams.contractName"
placeholder="合同名称"
clearable
@keyup.enter="handleQuery"
/>
</el-form-item>
</el-col>
<el-col :xl="4" :lg="6" :md="8" :sm="12" :xs="24">
<el-form-item label="变更类型" prop="changeType">
<el-select v-model="queryParams.changeType" placeholder="请选择变更类型" clearable style="width: 100%">
<el-select v-model="queryParams.changeType" placeholder="全部" clearable>
<el-option
v-for="dict in dictStore.getOptions('crm_contract_change_type')"
:key="dict.value"
@@ -25,18 +35,14 @@
/>
</el-select>
</el-form-item>
</el-col>
<el-col :xl="4" :lg="6" :md="8" :sm="12" :xs="24">
<el-form-item label="审批状态" prop="approvalStatus">
<el-select v-model="queryParams.approvalStatus" placeholder="请选择审批状态" clearable style="width: 100%">
<el-select v-model="queryParams.approvalStatus" placeholder="全部" clearable>
<el-option label="待审批" value="pending" />
<el-option label="已通过" value="approved" />
<el-option label="已驳回" value="rejected" />
</el-select>
</el-form-item>
</el-col>
<el-col :xl="8" :lg="12" :md="16" :sm="24" :xs="24">
<el-form-item label="变更时间">
<el-form-item label="变更时间" class="form-col-span-2">
<el-date-picker
v-model="dateRange"
type="daterange"
@@ -47,173 +53,308 @@
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="24" style="text-align: right">
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-col>
</el-row>
</el-form>
</el-card>
</div>
<div class="filter-actions-row">
<span class="filter-hint">按合同、变更类型、审批状态及时间筛选。</span>
<div class="filter-actions-end">
<el-button class="btn-ghost" :loading="loading" @click="resetQuery">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" :loading="loading" @click="handleQuery">
<el-icon><Search /></el-icon>搜索
</el-button>
</div>
</div>
</el-form>
</div>
<!-- 工具栏 -->
<el-row :gutter="10" class="mb8">
<el-col v-if="hasContractPerm('changeAdd')" :span="1.5">
<el-button type="primary" icon="Plus" @click="handleAdd">新增</el-button>
</el-col>
<el-col v-if="hasContractPerm('changeRemove')" :span="1.5">
<el-button type="danger" icon="Delete" :disabled="multiple || !allRowsDeletableByCreator(changeList.filter((item) => ids.includes(item.id)))" :title="allRowsDeletableByCreator(changeList.filter((item) => ids.includes(item.id))) ? '' : CREATOR_DELETE_TIP" @click="handleDelete">删除</el-button>
</el-col>
<el-col v-if="hasContractPerm('changeExport')" :span="1.5">
<el-button type="success" icon="Download" @click="handleExport">导出</el-button>
</el-col>
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
</el-row>
<!-- 数据表格 -->
<el-table v-loading="loading" :data="changeList" @selection-change="handleSelectionChange" border>
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="合同编号" align="center" prop="contractNo" width="150" :show-overflow-tooltip="true">
<template #default="scope">
<el-link type="primary" @click="handleViewContract(scope.row)">{{ scope.row.contractNo }}</el-link>
</template>
</el-table-column>
<el-table-column label="合同名称" prop="contractName" :show-overflow-tooltip="true" min-width="180" />
<el-table-column label="变更类型" align="center" prop="changeType" width="120">
<template #default="scope">
<DictTag dict-type="crm_contract_change_type" :value="scope.row.changeType" />
</template>
</el-table-column>
<el-table-column label="变更原因" prop="changeReason" :show-overflow-tooltip="true" min-width="200" />
<el-table-column label="变更金额" align="right" prop="changeAmount" width="130">
<template #default="scope">
<span v-if="scope.row.changeAmount">¥{{ scope.row.changeAmount.toLocaleString() }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="审批状态" align="center" prop="approvalStatus" width="100">
<template #default="scope">
<el-tag v-if="scope.row.approvalStatus === 'pending'" type="warning">待审批</el-tag>
<el-tag v-else-if="scope.row.approvalStatus === 'approved'" type="success">已通过</el-tag>
<el-tag v-else-if="scope.row.approvalStatus === 'rejected'" type="danger">已驳回</el-tag>
</template>
</el-table-column>
<el-table-column label="审批人" align="center" prop="approverName" width="100" />
<el-table-column label="创建时间" align="center" prop="createTime" width="160">
<template #default="scope">
<span>{{ parseTime(scope.row.createTime) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="200" fixed="right">
<template #default="scope">
<el-tooltip v-if="hasContractPerm('changeQuery')" content="查看" placement="top">
<el-button link type="primary" icon="View" @click="handleView(scope.row)" />
</el-tooltip>
<el-dropdown
v-if="hasContractPerm('changeApprove') || hasContractPerm('changeRemove')"
@command="(command) => handleCommand(command, scope.row)"
trigger="click"
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button
v-if="hasContractPerm('changeAdd')"
class="btn-main"
type="primary"
@click="handleAdd"
>
<el-button link type="primary" icon="MoreFilled" />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-if="hasContractPerm('changeApprove') && scope.row.approvalStatus === 'pending'"
command="submit"
icon="Promotion"
>提交审批</el-dropdown-item>
<el-dropdown-item
v-if="hasContractPerm('changeApprove') && scope.row.approvalStatus === 'approved'"
command="execute"
icon="VideoPlay"
>执行变更</el-dropdown-item>
<el-dropdown-item
v-if="hasContractPerm('changeApprove') && scope.row.approvalStatus === 'pending'"
command="withdraw"
icon="RefreshLeft"
>撤回</el-dropdown-item>
<el-dropdown-item
v-if="hasContractPerm('changeRemove') && scope.row.approvalStatus === 'pending' && canDeleteByCreator(scope.row)"
command="delete"
icon="Delete"
divided
>删除</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</el-table-column>
</el-table>
<el-icon><Plus /></el-icon>新增变更
</el-button>
<el-button
v-if="hasContractPerm('changeRemove')"
class="btn-ghost"
type="danger"
:disabled="selectedRows.length === 0 || !allRowsDeletableByCreator(selectedRows)"
:title="allRowsDeletableByCreator(selectedRows) ? '' : CREATOR_DELETE_TIP"
@click="handleDelete()"
>
<el-icon><Delete /></el-icon>删除
</el-button>
<el-button
v-if="hasContractPerm('changeApprove')"
class="btn-ghost"
:disabled="selectedRows.length !== 1 || selectedRows[0]?.approvalStatus !== 'pending'"
@click="handleSubmitApproval(selectedRows[0])"
>
<el-icon><Promotion /></el-icon>提交审批
</el-button>
<el-button
v-if="hasContractPerm('changeApprove')"
class="btn-ghost"
:disabled="selectedRows.length !== 1 || selectedRows[0]?.approvalStatus !== 'approved'"
@click="handleExecute(selectedRows[0])"
>
<el-icon><VideoPlay /></el-icon>执行变更
</el-button>
<el-button
v-if="hasContractPerm('changeApprove')"
class="btn-ghost"
:disabled="selectedRows.length !== 1 || selectedRows[0]?.approvalStatus !== 'pending'"
@click="handleWithdraw(selectedRows[0])"
>
<el-icon><RefreshLeft /></el-icon>撤回
</el-button>
<el-button
v-if="hasContractPerm('changeExport')"
class="btn-ghost"
:loading="exportLoading"
@click="handleExport"
>
<el-icon><Download /></el-icon>导出
</el-button>
<!-- 分页 -->
<pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
<!-- 添加或修改对话框 -->
<el-dialog :title="dialog.title" v-model="dialog.visible" width="800px" append-to-body>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="合同" prop="contractId">
<el-select v-model="form.contractId" placeholder="请选择合同" filterable style="width: 100%">
<!-- TODO: 动态加载合同列表 -->
<el-option label="合同1" :value="1" />
</el-select>
</el-form-item>
<el-form-item label="变更类型" prop="changeType">
<el-select v-model="form.changeType" placeholder="请选择变更类型" style="width: 100%">
<el-option
v-for="dict in dictStore.getOptions('crm_contract_change_type')"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item label="变更原因" prop="changeReason">
<el-input v-model="form.changeReason" type="textarea" :rows="3" placeholder="请输入变更原因" />
</el-form-item>
<el-form-item label="变更内容" prop="changeContent">
<el-input v-model="form.changeContent" type="textarea" :rows="2" placeholder="请输入变更内容描述" />
</el-form-item>
<el-form-item label="变更前值" prop="oldValue">
<el-input v-model="form.oldValue" type="textarea" :rows="2" placeholder="请输入变更前的值" />
</el-form-item>
<el-form-item label="变更后值" prop="newValue">
<el-input v-model="form.newValue" type="textarea" :rows="2" placeholder="请输入变更后的值" />
</el-form-item>
<el-form-item label="变更金额" prop="changeAmount">
<el-input-number v-model="form.changeAmount" :precision="2" :min="0" controls-position="right" style="width: 100%" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="请输入备注" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="cancel">取 消</el-button>
<el-button type="primary" @click="submitForm">确 定</el-button>
<ListToolbarEndActions :loading="loading" @refresh="getList" />
</div>
</template>
</el-dialog>
</div>
<!-- 查看详情 -->
<SystemViewDialog
v-model:visible="viewDialog.visible"
title="变更详情"
:icon="Document"
:code="viewDialog.data.contractNo"
code-label="合同编号"
:meta="viewDialog.data.contractName"
:sections="changeViewSections"
append-to-body
/>
<div class="list-body list-wrap">
<el-table
class="list-table"
v-loading="loading"
:data="changeList"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
prop="contractNo"
label="合同编号"
min-width="150"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<el-link type="primary" @click="handleViewContract(row)">{{ row.contractNo }}</el-link>
</template>
</el-table-column>
<el-table-column
prop="contractName"
label="合同名称"
min-width="180"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column label="变更类型" min-width="120" align="center" header-align="center">
<template #default="{ row }">
<el-tag
:type="dictStore.getTagType('crm_contract_change_type', row.changeType)"
size="small"
>
{{ dictStore.getLabel('crm_contract_change_type', row.changeType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="changeReason"
label="变更原因"
min-width="200"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column label="变更金额" min-width="130" align="center" header-align="center">
<template #default="{ row }">
<span v-if="row.changeAmount">¥{{ row.changeAmount.toLocaleString() }}</span>
<span v-else>—</span>
</template>
</el-table-column>
<el-table-column label="审批状态" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag v-if="row.approvalStatus === 'pending'" type="warning" size="small">待审批</el-tag>
<el-tag v-else-if="row.approvalStatus === 'approved'" type="success" size="small">已通过</el-tag>
<el-tag v-else-if="row.approvalStatus === 'rejected'" type="danger" size="small">已驳回</el-tag>
</template>
</el-table-column>
<el-table-column
prop="approverName"
label="审批人"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column label="创建时间" min-width="160" align="center" header-align="center">
<template #default="{ row }">{{ parseTime(row.createTime) }}</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
v-model:current-page="queryParams.pageNum"
v-model:page-size="queryParams.pageSize"
:page-sizes="[10, 20, 30, 50]"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
@size-change="getList"
@current-change="getList"
/>
</div>
</div>
<el-dialog
v-model="dialog.visible"
:title="dialog.title"
width="800px"
class="dialog-form-layout order-module-dialog-mask"
align-center
destroy-on-close
:close-on-click-modal="false"
append-to-body
@close="cancel"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="合同" prop="contractId" class="form-col-span-2">
<el-select v-model="form.contractId" placeholder="请选择合同" filterable style="width: 100%">
<!-- TODO: 动态加载合同列表 -->
<el-option label="合同1" :value="1" />
</el-select>
</el-form-item>
<el-form-item label="变更类型" prop="changeType">
<el-select v-model="form.changeType" placeholder="请选择变更类型" style="width: 100%">
<el-option
v-for="dict in dictStore.getOptions('crm_contract_change_type')"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item label="变更金额" prop="changeAmount">
<el-input-number
v-model="form.changeAmount"
:precision="2"
:min="0"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="变更原因" prop="changeReason" class="form-col-span-2">
<el-input v-model="form.changeReason" type="textarea" :rows="3" placeholder="请输入变更原因" />
</el-form-item>
<el-form-item label="变更内容" prop="changeContent" class="form-col-span-2">
<el-input v-model="form.changeContent" type="textarea" :rows="2" placeholder="请输入变更内容描述" />
</el-form-item>
<el-form-item label="变更前值" prop="oldValue" class="form-col-span-2">
<el-input v-model="form.oldValue" type="textarea" :rows="2" placeholder="请输入变更前的值" />
</el-form-item>
<el-form-item label="变更后值" prop="newValue" class="form-col-span-2">
<el-input v-model="form.newValue" type="textarea" :rows="2" placeholder="请输入变更后的值" />
</el-form-item>
<el-form-item label="备注" prop="remark" class="form-col-span-2">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="请输入备注" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="cancel">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button class="btn-main" type="primary" :loading="submitLoading" @click="submitForm">
<el-icon><Check /></el-icon>确定
</el-button>
</template>
</el-dialog>
<SystemViewDialog
v-model:visible="viewDialog.visible"
title="变更详情"
:icon="Document"
:code="viewDialog.data.contractNo"
code-label="合同编号"
:meta="viewDialog.data.contractName"
:sections="changeViewSections"
append-to-body
>
<template #hero-actions>
<div class="change-view-hero-actions">
<el-button
v-if="hasContractPerm('changeApprove') && viewDialog.data.approvalStatus === 'pending'"
class="btn-ghost"
@click="handleSubmitApproval(viewDialog.data)"
>
<el-icon><Promotion /></el-icon>提交审批
</el-button>
<el-button
v-if="hasContractPerm('changeApprove') && viewDialog.data.approvalStatus === 'approved'"
class="btn-ghost"
@click="handleExecute(viewDialog.data)"
>
<el-icon><VideoPlay /></el-icon>执行变更
</el-button>
<el-button
v-if="hasContractPerm('changeApprove') && viewDialog.data.approvalStatus === 'pending'"
class="btn-ghost"
@click="handleWithdraw(viewDialog.data)"
>
<el-icon><RefreshLeft /></el-icon>撤回
</el-button>
<el-button
v-if="
hasContractPerm('changeRemove') &&
viewDialog.data.approvalStatus === 'pending' &&
canDeleteByCreator(viewDialog.data)
"
class="btn-ghost"
type="danger"
@click="handleDetailDelete"
>
<el-icon><Delete /></el-icon>删除
</el-button>
</div>
</template>
</SystemViewDialog>
</section>
</div>
</template>
<script setup lang="ts" name="ContractChange">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { Document } from '@element-plus/icons-vue'
import {
Document,
Plus,
Delete,
Download,
Search,
RefreshLeft,
Close,
Check,
Promotion,
VideoPlay
} from '@element-plus/icons-vue'
import SystemViewDialog, {
type ViewField,
type ViewSection
@@ -235,28 +376,30 @@ import {
} from '@/api/crm/contract/change'
import { parseTime } from '@/utils/format'
import { useDictStore } from '@/store/modules/dict'
import DictTag from '@/components/DictTag/index.vue'
import { $modal } from '@/utils/modal'
import { useContractPerm } from '@/hooks/business/useContractPerm'
import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import {
canDeleteByCreator,
filterDeletableByCreator,
allRowsDeletableByCreator,
CREATOR_DELETE_TIP
} from '@/utils/creator-delete'
const router = useRouter()
const dictStore = useDictStore()
const { loadPerms, hasContractPerm } = useContractPerm()
// 查询参数
const queryParams = ref<ChangeQuery>({
pageNum: 1,
pageSize: 10,
pageSize: 20,
contractNo: undefined,
contractName: undefined,
changeType: undefined,
approvalStatus: undefined,
beginTime: undefined,
endTime: undefined
});
})
// 表单数据
const form = ref<ChangeForm>({
contractId: 0,
changeType: '',
@@ -266,37 +409,33 @@ const form = ref<ChangeForm>({
newValue: '',
changeAmount: 0,
remark: ''
});
})
// 表单校验规则
const rules = ref({
contractId: [{ required: true, message: '请选择合同', trigger: 'change' }],
changeType: [{ required: true, message: '请选择变更类型', trigger: 'change' }],
changeReason: [{ required: true, message: '请输入变更原因', trigger: 'blur' }],
newValue: [{ required: true, message: '请输入变更后的值', trigger: 'blur' }]
});
})
// 数据列表
const changeList = ref<ChangeVO[]>([]);
const loading = ref(true);
const total = ref(0);
const showSearch = ref(false);
const single = ref(true);
const multiple = ref(true);
const ids = ref<number[]>([]);
const dateRange = ref<[string, string]>();
const changeList = ref<ChangeVO[]>([])
const selectedRows = ref<ChangeVO[]>([])
const loading = ref(true)
const submitLoading = ref(false)
const exportLoading = ref(false)
const total = ref(0)
const ids = ref<number[]>([])
const dateRange = ref<[string, string]>()
// 对话框
const dialog = reactive<DialogOption>({
visible: false,
title: ''
});
})
// 查看详情对话框
const viewDialog = reactive({
visible: false,
data: {} as ChangeVO
});
})
function changeStatusTag(status?: string) {
if (status === 'pending') return { label: '待审批', type: 'warning' as const }
@@ -358,105 +497,87 @@ const changeViewSections = computed<ViewSection[]>(() => {
]
})
const queryFormRef = ref<ElFormInstance>();
const formRef = ref<ElFormInstance>();
const queryFormRef = ref<ElFormInstance>()
const formRef = ref<ElFormInstance>()
/** 查询列表 */
const getList = async () => {
loading.value = true;
loading.value = true
try {
// 处理日期范围
if (dateRange.value && dateRange.value.length === 2) {
queryParams.value.beginTime = dateRange.value[0];
queryParams.value.endTime = dateRange.value[1];
queryParams.value.beginTime = dateRange.value[0]
queryParams.value.endTime = dateRange.value[1]
} else {
queryParams.value.beginTime = undefined;
queryParams.value.endTime = undefined;
queryParams.value.beginTime = undefined
queryParams.value.endTime = undefined
}
const res = await listChange(queryParams.value);
changeList.value = res.rows;
total.value = res.total;
const res = await listChange(queryParams.value)
changeList.value = res.rows
total.value = res.total
} finally {
loading.value = false;
loading.value = false
}
};
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.value.pageNum = 1;
getList();
};
queryParams.value.pageNum = 1
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
dateRange.value = undefined;
queryFormRef.value?.resetFields();
handleQuery();
};
dateRange.value = undefined
queryFormRef.value?.resetFields()
handleQuery()
}
/** 多选框选中数据 */
const handleSelectionChange = (selection: ChangeVO[]) => {
ids.value = selection.map((item) => item.id);
single.value = selection.length !== 1;
multiple.value = !selection.length;
};
selectedRows.value = selection
ids.value = selection.map((item) => item.id)
}
/** 新增按钮操作 */
const handleAdd = () => {
reset();
dialog.visible = true;
dialog.title = '添加变更记录';
};
reset()
dialog.visible = true
dialog.title = '添加变更记录'
}
/** 修改按钮操作 */
const handleUpdate = async (row: ChangeVO) => {
reset();
const res = await getChange(row.id);
Object.assign(form.value, res.data);
dialog.visible = true;
dialog.title = '修改变更记录';
};
/** 查看详情 */
const handleView = async (row: ChangeVO) => {
const res = await getChange(row.id);
viewDialog.data = res.data;
viewDialog.visible = true;
};
const res = await getChange(row.id)
viewDialog.data = res.data
viewDialog.visible = true
}
/** 查看合同 */
const handleViewContract = (row: ChangeVO) => {
router.push({
path: '/crm/contract/detail',
query: { id: row.contractId }
});
};
})
}
/** 提交表单 */
const submitForm = () => {
formRef.value?.validate(async (valid) => {
if (valid) {
try {
if (form.value.id) {
await updateChange(form.value);
$modal.msgSuccess('修改成功')
} else {
await addChange(form.value)
$modal.msgSuccess('新增成功')
}
dialog.visible = false;
await getList();
} catch (error) {
console.error(error);
if (!valid) return
submitLoading.value = true
try {
if (form.value.id) {
await updateChange(form.value)
$modal.msgSuccess('修改成功')
} else {
await addChange(form.value)
$modal.msgSuccess('新增成功')
}
dialog.visible = false
await getList()
} catch (error) {
console.error(error)
} finally {
submitLoading.value = false
}
});
};
})
}
/** 删除按钮操作 */
const handleDelete = async (row?: ChangeVO) => {
const targets = row ? [row] : changeList.value.filter((item) => ids.value.includes(item.id))
const targets = row ? [row] : selectedRows.value
const deletable = filterDeletableByCreator(targets)
if (!deletable.length) {
$modal.msgWarning(CREATOR_DELETE_TIP)
@@ -466,57 +587,57 @@ const handleDelete = async (row?: ChangeVO) => {
$modal.msgWarning(CREATOR_DELETE_TIP)
return
}
const idList = row?.id ? [row.id] : ids.value;
const idList = row?.id ? [row.id] : ids.value
await $modal.confirm('是否确认删除选中的变更记录?')
await delChange(idList)
$modal.msgSuccess('删除成功')
await getList();
};
await getList()
}
async function handleDetailDelete() {
if (!viewDialog.data?.id) return
await handleDelete(viewDialog.data as ChangeVO)
viewDialog.visible = false
}
/** 下拉菜单命令处理 */
const handleCommand = async (command: string, row: ChangeVO) => {
switch (command) {
case 'submit':
await handleSubmitApproval(row);
break;
await handleSubmitApproval(row)
break
case 'execute':
await handleExecute(row);
break;
await handleExecute(row)
break
case 'withdraw':
await handleWithdraw(row);
break;
await handleWithdraw(row)
break
case 'delete':
await handleDelete(row);
break;
await handleDelete(row)
break
}
};
}
/** 提交审批 */
const handleSubmitApproval = async (row: ChangeVO) => {
await $modal.confirm('是否确认提交该变更记录进行审批?')
// TODO: 选择审批人
await submitChangeApproval({ id: row.id, approverIds: [] })
$modal.msgSuccess('提交成功')
await getList();
};
await getList()
}
/** 执行变更 */
const handleExecute = async (row: ChangeVO) => {
await $modal.confirm('是否确认执行该变更?执行后将无法撤销')
await executeChange(row.id)
$modal.msgSuccess('执行成功')
await getList();
};
await getList()
}
/** 撤回变更 */
const handleWithdraw = async (row: ChangeVO) => {
await $modal.confirm('是否确认撤回该变更记录?')
await withdrawChange(row.id)
$modal.msgSuccess('撤回成功')
await getList();
};
await getList()
}
/** 导出按钮操作 */
const handleExport = async () => {
if (dateRange.value && dateRange.value.length === 2) {
queryParams.value.beginTime = dateRange.value[0]
@@ -525,6 +646,7 @@ const handleExport = async () => {
queryParams.value.beginTime = undefined
queryParams.value.endTime = undefined
}
exportLoading.value = true
try {
const res = await exportChange(queryParams.value)
const blob =
@@ -542,16 +664,16 @@ const handleExport = async () => {
} catch (error) {
console.error(error)
$modal.msgError('导出失败')
} finally {
exportLoading.value = false
}
}
/** 取消按钮 */
const cancel = () => {
dialog.visible = false;
reset();
};
dialog.visible = false
reset()
}
/** 表单重置 */
const reset = () => {
form.value = {
id: undefined,
@@ -563,19 +685,12 @@ const reset = () => {
newValue: '',
changeAmount: 0,
remark: ''
};
formRef.value?.resetFields();
};
}
formRef.value?.resetFields()
}
onMounted(async () => {
await loadPerms()
getList()
})
</script>
<style scoped lang="scss">
.text-content {
white-space: pre-wrap;
word-break: break-all;
}
</style>
@@ -362,7 +362,13 @@
</div>
<div class="list-body list-wrap">
<el-table :data="detailPaymentList" class="list-table" stripe v-loading="detailPaymentLoading" empty-text="暂无回款记录">
<el-table-column label="回款单号" prop="paymentNo" min-width="150" align="center" header-align="center" />
<el-table-column label="回款单号" prop="paymentNo" min-width="150" align="center" header-align="center">
<template #default="scope">
<el-link type="primary" @click="handleDetailViewPayment(scope.row)">
{{ scope.row.paymentNo }}
</el-link>
</template>
</el-table-column>
<el-table-column label="回款金额" prop="amount" min-width="130" align="right">
<template #default="scope">
<span class="text-success font-weight-bold"
@@ -376,13 +382,6 @@
<el-table-column label="创建人" prop="createBy" width="100" align="center" />
<el-table-column label="创建时间" prop="createTime" width="160" align="center">
<template #default="scope">{{ parseTime(scope.row.createTime) }}</template>
</el-table-column>
<el-table-column label="操作" min-width="120" align="center" header-align="center" fixed="right" class-name="col-actions">
<template #default="scope">
<el-button link type="success" @click="handleDetailViewPayment(scope.row)">
<el-icon><View /></el-icon>查看
</el-button>
</template>
</el-table-column>
</el-table>
</div>
@@ -403,7 +402,13 @@
</div>
<div class="list-body list-wrap">
<el-table :data="detailInvoiceList" class="list-table" stripe v-loading="detailInvoiceLoading" empty-text="暂无开票记录">
<el-table-column label="发票号码" prop="invoiceNo" min-width="150" align="center" header-align="center" />
<el-table-column label="发票号码" prop="invoiceNo" min-width="150" align="center" header-align="center">
<template #default="scope">
<el-link type="primary" @click="handleDetailViewInvoice(scope.row)">
{{ scope.row.invoiceNo }}
</el-link>
</template>
</el-table-column>
<el-table-column label="发票类型" prop="invoiceType" width="120" align="center" />
<el-table-column label="开票金额" prop="amount" min-width="130" align="right">
<template #default="scope">
@@ -417,13 +422,6 @@
<el-table-column label="创建人" prop="createBy" width="100" align="center" />
<el-table-column label="创建时间" prop="createTime" width="160" align="center">
<template #default="scope">{{ parseTime(scope.row.createTime) }}</template>
</el-table-column>
<el-table-column label="操作" min-width="120" align="center" header-align="center" fixed="right" class-name="col-actions">
<template #default="scope">
<el-button link type="success" @click="handleDetailViewInvoice(scope.row)">
<el-icon><View /></el-icon>查看
</el-button>
</template>
</el-table-column>
</el-table>
</div>
@@ -188,7 +188,7 @@ watch(dialogVisible, (val) => {
// 查询参数
const queryParams = ref({
pageNum: 1,
pageSize: 10,
pageSize: 20,
productName: undefined,
categoryId: undefined,
status: 'on' // on-上架 off-下架
+93 -73
View File
@@ -1,84 +1,81 @@
<template>
<div class="app-container">
<el-page-header @back="goBack" content="合同详情" />
<el-card class="mt-4" v-loading="loading">
<!-- 头部操作按钮 -->
<template #header>
<div class="card-header">
<div class="header-left">
<span class="card-title">{{ contract?.contractNo }} - {{ contract?.contractName }}</span>
<div class="page-container contract-detail-page">
<section class="panel" v-loading="loading">
<div class="panel-top panel-top-with-actions">
<div class="panel-top-main">
<h2 class="title-bar-heading">合同详情</h2>
<div v-if="contract" class="header-left header-tags">
<el-tag
v-if="contract?.status"
:type="dictStore.getTagType('crm_contract_status', contract.status)"
size="large"
class="ml-2"
size="small"
>{{ dictStore.getLabel('crm_contract_status', contract.status) }}</el-tag>
<el-tag
v-if="contract?.approvalStatus"
:type="dictStore.getTagType('crm_approval_status', contract.approvalStatus)"
size="large"
class="ml-2"
size="small"
>{{ dictStore.getLabel('crm_approval_status', contract.approvalStatus) }}</el-tag>
</div>
<div>
<el-button
type="primary"
icon="Edit"
@click="handleEdit"
v-if="hasContractPerm('edit') && contract?.status === 'draft'"
>修改</el-button>
<el-button
v-if="hasContractPerm('remove') && canDeleteByCreator(contract)"
type="danger"
icon="Delete"
@click="handleDelete"
>删除</el-button>
<el-button
type="warning"
icon="Promotion"
@click="handleSubmit"
v-if="hasContractPerm('submit') && contract?.status === 'draft'"
>提交审批</el-button>
<el-button
type="success"
icon="VideoPlay"
@click="handleExecute"
v-if="hasContractPerm('execute') && contract?.status === 'approved' && contract?.approvalStatus === 'approved'"
>开始执行</el-button
>
<el-button
type="primary"
icon="CircleCheck"
@click="handleComplete"
v-if="hasContractPerm('finish') && contract?.status === 'executing'"
>完成合同</el-button>
<el-button
type="danger"
icon="CircleClose"
@click="handleInvalid"
v-if="hasContractPerm('invalid') && ['draft', 'approving', 'approved', 'executing'].includes(contract?.status || '')"
>作废</el-button
>
<el-button
icon="Printer"
@click="handlePrint"
v-if="hasContractPerm('print')"
:disabled="contract?.contractTemplate === 'customer'"
>打印</el-button>
<!-- <el-button
type="warning"
icon="RefreshRight"
@click="handleRenew"
v-if="['executing', 'finished'].includes(contract?.status || '')"
>发起续签</el-button
> -->
</div>
</div>
</template>
<div class="detail-toolbar">
<el-button class="btn-ghost" @click="goBack">
<el-icon><Back /></el-icon>返回
</el-button>
<el-button
type="primary"
@click="handleEdit"
v-if="hasContractPerm('edit') && contract?.status === 'draft'"
>
<el-icon><Edit /></el-icon>修改
</el-button>
<el-button
v-if="hasContractPerm('remove') && canDeleteByCreator(contract)"
type="danger"
@click="handleDelete"
>
<el-icon><Delete /></el-icon>删除
</el-button>
<el-button
type="warning"
@click="handleSubmit"
v-if="hasContractPerm('submit') && contract?.status === 'draft'"
>
<el-icon><Promotion /></el-icon>提交审批
</el-button>
<el-button
type="success"
@click="handleExecute"
v-if="hasContractPerm('execute') && contract?.status === 'approved' && contract?.approvalStatus === 'approved'"
>
<el-icon><VideoPlay /></el-icon>开始执行
</el-button>
<el-button
type="primary"
@click="handleComplete"
v-if="hasContractPerm('finish') && contract?.status === 'executing'"
>
<el-icon><CircleCheck /></el-icon>完成合同
</el-button>
<el-button
type="danger"
@click="handleInvalid"
v-if="hasContractPerm('invalid') && ['draft', 'approving', 'approved', 'executing'].includes(contract?.status || '')"
>
<el-icon><CircleClose /></el-icon>作废
</el-button>
<el-button
@click="handlePrint"
v-if="hasContractPerm('print')"
:disabled="contract?.contractTemplate === 'customer'"
>
<el-icon><Printer /></el-icon>打印
</el-button>
</div>
</div>
<el-tabs v-model="activeTab">
<p v-if="contract" class="detail-subtitle">{{ contract.contractNo }} — {{ contract.contractName }}</p>
<el-tabs v-model="activeTab" class="panel-pill-tabs panel-pill-tabs--lg">
<!-- 合同信息 -->
<el-tab-pane label="合同信息" name="contract">
<el-form label-width="120px" v-if="contract">
@@ -956,7 +953,7 @@
<el-empty v-if="operLogList.length === 0 && !logLoading" description="暂无操作日志" />
</el-tab-pane>
</el-tabs>
</el-card>
</section>
<!-- 审批对话框 -->
<ApprovalDialog ref="approvalDialogRef" @success="loadContractDetail" />
@@ -1009,7 +1006,7 @@ import { $modal } from '@/utils/modal';
import { useDictStore } from '@/store/modules/dict';
import DictTag from '@/components/DictTag/index.vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { CircleCheck, CircleClose, View, ChatLineSquare, Clock, InfoFilled, VideoPlay, Document, Paperclip, Download } from '@element-plus/icons-vue';
import { CircleCheck, CircleClose, View, ChatLineSquare, Clock, InfoFilled, VideoPlay, Document, Paperclip, Download, Back, Edit, Delete, Promotion, Printer } from '@element-plus/icons-vue';
import ApprovalDialog from './components/ApprovalDialog.vue';
import ExecuteDialog from './components/ExecuteDialog.vue';
import InvalidDialog from './components/InvalidDialog.vue';
@@ -1064,7 +1061,7 @@ const logLoading = ref(false);
const logTotal = ref(0);
const logQuery = ref<OperLogQuery>({
pageNum: 1,
pageSize: 10
pageSize: 20
} as OperLogQuery);
// 组件引用
@@ -2649,6 +2646,29 @@ const handleGeneratePdfFile = async () => {
</script>
<style scoped lang="scss">
.contract-detail-page {
.detail-subtitle {
margin: 0;
padding: 0 16px 12px;
font-size: 13px;
color: var(--mint-text-muted, #64748b);
}
.header-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.detail-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
}
.card-header {
display: flex;
justify-content: space-between;
@@ -2697,7 +2717,7 @@ const handleGeneratePdfFile = async () => {
font-weight: bold;
&.primary {
color: #409eff;
color: var(--primary, #10b981);
}
}
}
+13 -14
View File
@@ -1,14 +1,12 @@
<template>
<div class="app-container">
<el-card class="box-card">
<template #header>
<div class="card-header">
<span class="card-title">{{ formTitle }}</span>
<el-button style="float: right" @click="handleBack">返回</el-button>
</div>
</template>
<!-- 步骤条已移除 -->
<div class="page-container contract-form-page">
<section class="panel">
<div class="panel-top panel-top-with-actions">
<h2 class="title-bar-heading">{{ formTitle }}</h2>
<el-button class="btn-ghost" @click="handleBack">
<el-icon><Back /></el-icon>返回
</el-button>
</div>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<!-- 合同信息(融合基本信息+合同条款+产品明细) -->
@@ -535,7 +533,7 @@
<el-button @click="handleBack">取消</el-button>
</el-form-item>
</el-form>
</el-card>
</section>
<!-- 产品选择器 -->
<ProductSelector :visible="productSelectorVisible" @update:visible="productSelectorVisible = $event" @confirm="handleProductSelected" />
@@ -583,7 +581,7 @@ import {
import { useDictStore } from '@/store/modules/dict';
import { useUserStore } from '@/store/modules/user';
import { $modal } from '@/utils/modal';
import { UploadFilled, Document, Loading, Download, Delete, ZoomIn, Sort, Plus } from '@element-plus/icons-vue';
import { UploadFilled, Document, Loading, Download, Delete, ZoomIn, Sort, Plus, Back } from '@element-plus/icons-vue';
import ImagePreview from '@/components/ImagePreview/index.vue';
import {
buildUploadFileListFromField,
@@ -2494,8 +2492,9 @@ onMounted(async () => {
.total-section {
margin-top: 20px;
padding: 15px;
background-color: #f5f7fa;
border-radius: 4px;
background-color: var(--mint-row-hover, #f1f5f9);
border-radius: 8px;
border: 1px solid var(--mint-border, #d9e8e2);
}
.total-item {
+103 -198
View File
@@ -5,17 +5,19 @@
<h2 class="title-bar-heading">合同管理</h2>
</div>
<div class="panel-tabs">
<el-tabs v-model="activeView" @tab-change="handleViewChange">
<el-tab-pane label="我的合同" name="my" />
<el-tab-pane label="全部合同" name="all" />
<el-tab-pane label="待审批" name="submitted" />
<el-tab-pane label="已审批" name="approved" />
<el-tab-pane label="执行中" name="executing" />
<el-tab-pane label="已完成" name="finished" />
<el-tab-pane label="已作废" name="invalid" />
</el-tabs>
</div>
<el-tabs
v-model="activeView"
class="panel-pill-tabs panel-pill-tabs--lg"
@tab-change="handleViewChange"
>
<el-tab-pane label="我的合同" name="my" />
<el-tab-pane label="全部合同" name="all" />
<el-tab-pane label="待审批" name="submitted" />
<el-tab-pane label="已审批" name="approved" />
<el-tab-pane label="执行中" name="executing" />
<el-tab-pane label="已完成" name="finished" />
<el-tab-pane label="已作废" name="invalid" />
</el-tabs>
<div class="filter-block">
<el-form ref="queryFormRef" :model="queryParams" class="filter-form" @submit.prevent>
@@ -155,6 +157,28 @@
<el-button v-if="hasContractPerm('add')" class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>新增
</el-button>
<el-button
v-if="hasContractPerm('edit')"
class="btn-ghost"
:disabled="single || selectedRows[0]?.status !== 'draft'"
@click="handleUpdate()"
>
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button
v-if="hasContractPerm('remove')"
class="btn-ghost"
type="danger"
:disabled="
multiple ||
!selectedRows.every((row) => row.status === 'draft') ||
!allRowsDeletableByCreator(selectedRows)
"
:title="allRowsDeletableByCreator(selectedRows) ? '' : CREATOR_DELETE_TIP"
@click="handleDelete()"
>
<el-icon><Delete /></el-icon>删除
</el-button>
<el-button
v-if="hasContractPerm('submit') && activeView === 'my'"
class="btn-ghost"
@@ -190,12 +214,16 @@
<el-button v-if="hasContractPerm('export')" class="btn-ghost" :loading="exportLoading" @click="handleExport">
<el-icon><Download /></el-icon>导出
</el-button>
<el-button class="btn-ghost" @click="handleColumnSetting">
<el-icon><Setting /></el-icon>列设置
</el-button>
<el-button class="btn-ghost" :loading="loading" @click="getList">
<el-icon><Refresh /></el-icon>刷新
</el-button>
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="getList"
@reset-columns="resetToDefault"
/>
</div>
</div>
@@ -443,23 +471,6 @@
show-overflow-tooltip
/>
<el-table-column v-if="isColumnVisible('remark')" label="备注" align="center" prop="remark" min-width="200" show-overflow-tooltip />
<el-table-column label="操作" min-width="260" align="center" header-align="center" fixed="right" class-name="col-actions">
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button v-if="hasContractPerm('query')" class="btn-op-item" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-button
v-if="hasContractPerm('edit')"
class="btn-op-item primary"
:disabled="row.status !== 'draft'"
@click="handleUpdate(row)"
>
<el-icon><Edit /></el-icon>编辑
</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
@@ -492,38 +503,6 @@
<ContractDetailDialog ref="contractDetailRef" @success="getList" />
<!-- 列设置对话框 -->
<el-dialog
v-model="columnSettingDialog.visible"
title="列设置"
width="600px"
align-center
append-to-body
class="dialog-form-layout"
modal-class="order-module-dialog-mask"
>
<div class="column-setting-content">
<el-checkbox v-model="checkAll" :indeterminate="isIndeterminate" @change="handleCheckAllChange">全选</el-checkbox>
<el-divider />
<el-checkbox-group v-model="visibleColumns" @change="handleCheckedColumnsChange">
<div v-for="col in tableColumns" :key="col.prop" class="column-item">
<el-checkbox :label="col.prop" :disabled="col.fixed">{{ col.label }}</el-checkbox>
</div>
</el-checkbox-group>
</div>
<template #footer>
<el-button class="btn-ghost" @click="handleResetColumns">
<el-icon><RefreshLeft /></el-icon>恢复默认
</el-button>
<el-button @click="columnSettingDialog.visible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button type="primary" @click="handleSaveColumnSetting">
<el-icon><Check /></el-icon>保存
</el-button>
</template>
</el-dialog>
<!-- 合同表单对话框 -->
<ContractFormDialog
v-model:visible="formDialogVisible"
@@ -546,7 +525,6 @@ import {
import {
ArrowDownBold,
ArrowUpBold,
Check,
CircleCheck,
CircleClose,
Clock,
@@ -556,13 +534,10 @@ import {
Download,
Edit,
Plus,
Refresh,
RefreshLeft,
Search,
Select,
Setting,
Upload,
View
Upload
} from '@element-plus/icons-vue'
import { useDictStore } from '@/store/modules/dict'
const dictStore = useDictStore()
@@ -571,7 +546,7 @@ import { $modal } from '@/utils/modal'
import { parseTime } from '@/utils/format'
import { useUserStore } from '@/store/modules/user'
import { useContractPerm } from '@/hooks/business/useContractPerm'
import { filterDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import { filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import ApprovalDialog from './components/ApprovalDialog.vue'
import ExecuteDialog from './components/ExecuteDialog.vue'
import InvalidDialog from './components/InvalidDialog.vue'
@@ -586,7 +561,9 @@ import {
getCrmAttachmentCategoryLabel,
isCrmImageFile,
resolveCrmAttachmentFiles
} from '@/utils/crm-attachment';
} from '@/utils/crm-attachment'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
const userStore = useUserStore();
const exportLoading = ref(false);
@@ -615,135 +592,64 @@ const formDialogEditId = ref<string | null>(null)
const { loadPerms, hasContractPerm } = useContractPerm()
// 列配置相关
const COLUMN_STORAGE_KEY = 'crm_contract_visible_columns';
// 列配置
const listColumnDefs = [
{ prop: 'customerName', label: '客户名称' },
{ prop: 'contactName', label: '联系人' },
{ prop: 'contactMobile', label: '联系人手机' },
{ prop: 'contractType', label: '合同类型' },
{ prop: 'renewalCount', label: '续签次数' },
{ prop: 'attachmentFiles', label: '合同附件' },
{ prop: 'amount', label: '合同金额(元)' },
{ prop: 'receivedAmount', label: '已回款' },
{ prop: 'unreceivedAmount', label: '未回款' },
{ prop: 'receivedRate', label: '回款进度' },
{ prop: 'invoiceAmount', label: '已开票金额' },
{ prop: 'uninvoiceAmount', label: '未开票金额' },
{ prop: 'status', label: '合同状态' },
{ prop: 'approvalStatus', label: '审批状态' },
{ prop: 'signDate', label: '签订日期' },
{ prop: 'startDate', label: '开始日期' },
{ prop: 'endDate', label: '结束日期' },
{ prop: 'paymentMethod', label: '付款方式' },
{ prop: 'partyA', label: '甲方' },
{ prop: 'partyB', label: '乙方' },
{ prop: 'settlementMethod', label: '结算方式' },
{ prop: 'ownerUserName', label: '负责人' },
{ prop: 'createDeptName', label: '创建部门' },
{ prop: 'createByName', label: '创建人' },
{ prop: 'createTime', label: '创建时间' },
{ prop: 'updateByName', label: '更新人' },
{ prop: 'updateTime', label: '更新时间' },
{ prop: 'remark', label: '备注' }
]
// 定义所有列配置(顺序代表显示顺序)
const tableColumns = ref([
{ prop: 'customerName', label: '客户名称', fixed: false },
{ prop: 'contactName', label: '联系人', fixed: false },
{ prop: 'contactMobile', label: '联系人手机', fixed: false },
{ prop: 'contractType', label: '合同类型', fixed: false },
{ prop: 'renewalCount', label: '续签次数', fixed: false },
{ prop: 'attachmentFiles', label: '合同附件', fixed: false },
{ prop: 'amount', label: '合同金额(元)', fixed: false },
{ prop: 'receivedAmount', label: '已回款', fixed: false },
{ prop: 'unreceivedAmount', label: '未回款', fixed: false },
{ prop: 'receivedRate', label: '回款进度', fixed: false },
{ prop: 'invoiceAmount', label: '已开票金额', fixed: false },
{ prop: 'uninvoiceAmount', label: '未开票金额', fixed: false },
{ prop: 'status', label: '合同状态', fixed: false },
{ prop: 'approvalStatus', label: '审批状态', fixed: false },
{ prop: 'signDate', label: '签订日期', fixed: false },
{ prop: 'startDate', label: '开始日期', fixed: false },
{ prop: 'endDate', label: '结束日期', fixed: false },
{ prop: 'paymentMethod', label: '付款方式', fixed: false },
{ prop: 'partyA', label: '甲方', fixed: false },
{ prop: 'partyB', label: '乙方', fixed: false },
{ prop: 'settlementMethod', label: '结算方式', fixed: false },
{ prop: 'ownerUserName', label: '负责人', fixed: false },
{ prop: 'createDeptName', label: '创建部门', fixed: false },
{ prop: 'createByName', label: '创建人', fixed: false },
{ prop: 'createTime', label: '创建时间', fixed: false },
{ prop: 'updateByName', label: '更新人', fixed: false },
{ prop: 'updateTime', label: '更新时间', fixed: false },
{ prop: 'remark', label: '备注', fixed: false }
]);
// 默认显示的列(现有字段)
const defaultVisibleColumns = [
'customerName',
'contractType',
'attachmentFiles',
'amount',
'receivedAmount',
'receivedRate',
'status',
'approvalStatus',
'ownerUserName',
'signDate',
'createTime'
];
// 当前显示的列
const visibleColumns = ref<string[]>([]);
// 列设置对话框
const columnSettingDialog = reactive({
visible: false
});
// 全选状态
const checkAll = ref(false);
const isIndeterminate = ref(false);
/** 初始化列配置 */
const initColumnSetting = () => {
const savedColumns = localStorage.getItem(COLUMN_STORAGE_KEY);
if (savedColumns) {
try {
visibleColumns.value = JSON.parse(savedColumns);
} catch (error) {
console.error('解析列配置失败:', error);
visibleColumns.value = [...defaultVisibleColumns];
}
} else {
visibleColumns.value = [...defaultVisibleColumns];
}
updateCheckAllStatus();
};
/** 判断列是否显示 */
const isColumnVisible = (prop: string) => {
return visibleColumns.value.includes(prop);
};
/** 打开列设置对话框 */
const handleColumnSetting = () => {
columnSettingDialog.visible = true;
};
/** 全选/取消全选 */
const handleCheckAllChange = (val: boolean) => {
visibleColumns.value = val ? tableColumns.value.map((col) => col.prop) : [];
isIndeterminate.value = false;
};
/** 列选择变化 */
const handleCheckedColumnsChange = () => {
updateCheckAllStatus();
};
/** 更新全选状态 */
const updateCheckAllStatus = () => {
const checkedCount = visibleColumns.value.length;
const totalCount = tableColumns.value.length;
checkAll.value = checkedCount === totalCount;
isIndeterminate.value = checkedCount > 0 && checkedCount < totalCount;
};
/** 恢复默认列设置 */
const handleResetColumns = () => {
visibleColumns.value = [...defaultVisibleColumns];
updateCheckAllStatus();
};
/** 保存列设置 */
const handleSaveColumnSetting = () => {
try {
localStorage.setItem(COLUMN_STORAGE_KEY, JSON.stringify(visibleColumns.value));
$modal.msgSuccess('列设置保存成功');
columnSettingDialog.visible = false;
} catch (error) {
console.error('保存列配置失败:', error);
$modal.msgError('保存列设置失败');
}
};
const {
visibleColumns,
checkAll,
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('crm_contract_visible_columns', listColumnDefs, {
defaultVisibleProps: [
'customerName',
'contractType',
'attachmentFiles',
'amount',
'receivedAmount',
'receivedRate',
'status',
'approvalStatus',
'ownerUserName',
'signDate',
'createTime'
]
})
// 查询参数
const queryParams = ref<ContractQuery>({
pageNum: 1,
pageSize: 10,
pageSize: 20,
contractNo: undefined,
contractName: undefined,
customerName: undefined,
@@ -1340,7 +1246,6 @@ watchDebounced(
onMounted(async () => {
await loadPerms()
initColumnSetting();
getList();
});
</script>
@@ -1,17 +1,24 @@
<!-- 合同模板管理 -->
<template>
<div class="app-container">
<!-- 查询表单 -->
<el-card shadow="hover" v-show="showSearch" class="mb-[10px]">
<el-form :model="queryParams" ref="queryFormRef" label-width="90px">
<el-row :gutter="20">
<el-col :xl="4" :lg="6" :md="8" :sm="12" :xs="24">
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">合同模板</h2>
</div>
<div class="filter-block">
<el-form ref="queryFormRef" :model="queryParams" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="模板名称" prop="templateName">
<el-input v-model="queryParams.templateName" placeholder="请输入模板名称" clearable @keyup.enter="handleQuery" />
<el-input
v-model="queryParams.templateName"
placeholder="模板名称"
clearable
@keyup.enter="handleQuery"
/>
</el-form-item>
</el-col>
<el-col :xl="4" :lg="6" :md="8" :sm="12" :xs="24">
<el-form-item label="模板类型" prop="templateType">
<el-select v-model="queryParams.templateType" placeholder="请选择模板类型" clearable style="width: 100%">
<el-select v-model="queryParams.templateType" placeholder="全部" clearable>
<el-option
v-for="dict in dictStore.getOptions('crm_contract_type')"
:key="dict.value"
@@ -20,99 +27,180 @@
/>
</el-select>
</el-form-item>
</el-col>
<el-col :xl="4" :lg="6" :md="8" :sm="12" :xs="24">
<el-form-item label="状态" prop="isActive">
<el-select v-model="queryParams.isActive" placeholder="请选择状态" clearable style="width: 100%">
<el-select v-model="queryParams.isActive" placeholder="全部" clearable>
<el-option label="启用" :value="1" />
<el-option label="停用" :value="0" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="24" style="text-align: right">
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-col>
</el-row>
</el-form>
</el-card>
</div>
<div class="filter-actions-row">
<span class="filter-hint">按模板名称、类型筛选。</span>
<div class="filter-actions-end">
<el-button class="btn-ghost" :loading="loading" @click="resetQuery">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" :loading="loading" @click="handleQuery">
<el-icon><Search /></el-icon>搜索
</el-button>
</div>
</div>
</el-form>
</div>
<!-- 工具栏 -->
<el-row :gutter="10" class="mb8">
<el-col v-if="hasContractPerm('templateAdd')" :span="1.5">
<el-button type="primary" icon="Plus" @click="handleAdd">新增</el-button>
</el-col>
<el-col v-if="hasContractPerm('templateRemove')" :span="1.5">
<el-button type="danger" icon="Delete" :disabled="multiple || !allRowsDeletableByCreator(templateList.filter((item) => ids.includes(item.id)))" :title="allRowsDeletableByCreator(templateList.filter((item) => ids.includes(item.id))) ? '' : CREATOR_DELETE_TIP" @click="handleDelete">删除</el-button>
</el-col>
<el-col v-if="hasContractPerm('templateExport')" :span="1.5">
<el-button type="success" icon="Download" @click="handleExport">导出</el-button>
</el-col>
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
</el-row>
<!-- 数据表格 -->
<el-table v-loading="loading" :data="templateList" @selection-change="handleSelectionChange" border>
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="模板名称" prop="templateName" :show-overflow-tooltip="true" min-width="200">
<template #default="scope">
<el-link type="primary" @click="handleView(scope.row)">{{ scope.row.templateName }}</el-link>
</template>
</el-table-column>
<el-table-column label="模板类型" align="center" prop="templateType" width="120">
<template #default="scope">
<DictTag dict-type="crm_contract_type" :value="scope.row.templateType" />
</template>
</el-table-column>
<el-table-column label="模板编号" align="center" prop="templateNo" width="150" :show-overflow-tooltip="true" />
<el-table-column label="状态" align="center" prop="isActive" width="100">
<template #default="scope">
<el-switch
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button
v-if="hasContractPerm('templateAdd')"
class="btn-main"
type="primary"
@click="handleAdd"
>
<el-icon><Plus /></el-icon>新增模板
</el-button>
<el-button
v-if="hasContractPerm('templateEdit')"
v-model="scope.row.isActive"
:active-value="1"
:inactive-value="0"
@change="handleStatusChange(scope.row)"
class="btn-ghost"
:disabled="selectedRows.length !== 1"
@click="handleToolbarEdit"
>
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button
v-if="hasContractPerm('templateRemove')"
class="btn-ghost"
type="danger"
:disabled="
selectedRows.length === 0 ||
!allRowsDeletableByCreator(selectedRows)
"
:title="allRowsDeletableByCreator(selectedRows) ? '' : CREATOR_DELETE_TIP"
@click="handleDelete()"
>
<el-icon><Delete /></el-icon>删除
</el-button>
<el-button
v-if="hasContractPerm('templateExport')"
class="btn-ghost"
:loading="exportLoading"
@click="handleExport"
>
<el-icon><Download /></el-icon>导出
</el-button>
<ListToolbarEndActions :loading="loading" @refresh="getList" />
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
v-loading="loading"
:data="templateList"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
prop="templateName"
label="模板名称"
min-width="200"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<el-link type="primary" @click="handleView(row)">{{ row.templateName }}</el-link>
</template>
</el-table-column>
<el-table-column label="模板类型" min-width="120" align="center" header-align="center">
<template #default="{ row }">
<el-tag
:type="dictStore.getTagType('crm_contract_type', row.templateType)"
size="small"
>
{{ dictStore.getLabel('crm_contract_type', row.templateType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="templateNo"
label="模板编号"
min-width="150"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-tag v-else :type="scope.row.isActive === 1 ? 'success' : 'info'" size="small">
{{ scope.row.isActive === 1 ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="使用次数" align="center" prop="usageCount" width="100" />
<el-table-column label="排序" align="center" prop="sort" width="80" />
<el-table-column label="创建时间" align="center" prop="createTime" width="160">
<template #default="scope">
<span>{{ parseTime(scope.row.createTime) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="180" fixed="right">
<template #default="scope">
<el-tooltip v-if="hasContractPerm('templateEdit')" content="编辑" placement="top">
<el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)" />
</el-tooltip>
<el-tooltip v-if="hasContractPerm('templateRemove') && canDeleteByCreator(scope.row)" content="删除" placement="top">
<el-button link type="danger" icon="Delete" @click="handleDelete(scope.row)" />
</el-tooltip>
</template>
</el-table-column>
</el-table>
<el-table-column label="状态" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-switch
v-if="hasContractPerm('templateEdit')"
v-model="row.isActive"
:active-value="1"
:inactive-value="0"
@change="handleStatusChange(row)"
/>
<el-tag v-else :type="row.isActive === 1 ? 'success' : 'info'" size="small">
{{ row.isActive === 1 ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="usageCount"
label="使用次数"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column prop="sort" label="排序" min-width="80" align="center" header-align="center" />
<el-table-column label="创建时间" min-width="160" align="center" header-align="center">
<template #default="{ row }">
{{ parseTime(row.createTime) }}
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
<div class="pagination list-pager-bar">
<el-pagination
v-model:current-page="queryParams.pageNum"
v-model:page-size="queryParams.pageSize"
:page-sizes="[10, 20, 30, 50]"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
@size-change="getList"
@current-change="getList"
/>
</div>
</div>
<!-- 添加或修改对话框 -->
<el-dialog :title="dialog.title" v-model="dialog.visible" width="900px" append-to-body>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-row :gutter="20">
<el-col :span="12">
<el-dialog
v-model="dialog.visible"
:title="dialog.title"
width="900px"
class="dialog-form-layout order-module-dialog-mask"
align-center
destroy-on-close
:close-on-click-modal="false"
append-to-body
@close="cancel"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="模板名称" prop="templateName">
<el-input v-model="form.templateName" placeholder="请输入模板名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="模板类型" prop="templateType">
<el-select v-model="form.templateType" placeholder="请选择模板类型" style="width: 100%">
<el-option
@@ -123,63 +211,98 @@
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="状态" prop="isActive">
<el-radio-group v-model="form.isActive">
<el-radio :value="1">启用</el-radio>
<el-radio :value="0">停用</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="排序" prop="sort">
<el-input-number v-model="form.sort" :min="0" :max="9999" controls-position="right" style="width: 100%" />
<el-input-number
v-model="form.sort"
:min="0"
:max="9999"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="合同条款" prop="contractTerms">
<el-input v-model="form.contractTerms" type="textarea" :rows="4" placeholder="请输入合同条款" />
</el-form-item>
<el-form-item label="付款条款" prop="paymentTerms">
<el-input v-model="form.paymentTerms" type="textarea" :rows="3" placeholder="请输入付款条款" />
</el-form-item>
<el-form-item label="交付条款" prop="deliveryTerms">
<el-input v-model="form.deliveryTerms" type="textarea" :rows="3" placeholder="请输入交付条款" />
</el-form-item>
<el-form-item label="模板内容" prop="templateContent">
<el-input v-model="form.templateContent" type="textarea" :rows="6" placeholder="请输入模板内容" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="请输入备注" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="cancel">取 消</el-button>
<el-button type="primary" @click="submitForm">确 定</el-button>
</div>
</template>
</el-dialog>
<el-form-item label="合同条款" prop="contractTerms" class="form-col-span-2">
<el-input v-model="form.contractTerms" type="textarea" :rows="4" placeholder="请输入合同条款" />
</el-form-item>
<el-form-item label="付款条款" prop="paymentTerms" class="form-col-span-2">
<el-input v-model="form.paymentTerms" type="textarea" :rows="3" placeholder="请输入付款条款" />
</el-form-item>
<el-form-item label="交付条款" prop="deliveryTerms" class="form-col-span-2">
<el-input v-model="form.deliveryTerms" type="textarea" :rows="3" placeholder="请输入交付条款" />
</el-form-item>
<el-form-item label="模板内容" prop="templateContent" class="form-col-span-2">
<el-input v-model="form.templateContent" type="textarea" :rows="6" placeholder="请输入模板内容" />
</el-form-item>
<el-form-item label="备注" prop="remark" class="form-col-span-2">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="请输入备注" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="cancel">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button class="btn-main" type="primary" :loading="submitLoading" @click="submitForm">
<el-icon><Check /></el-icon>确定
</el-button>
</template>
</el-dialog>
<!-- 查看详情 -->
<SystemViewDialog
v-model:visible="viewDialog.visible"
:title="viewDialog.data.templateName || '模板详情'"
:icon="Document"
:code="viewDialog.data.templateNo"
code-label="模板编号"
:sections="templateViewSections"
append-to-body
/>
<SystemViewDialog
v-model:visible="viewDialog.visible"
:title="viewDialog.data.templateName || '模板详情'"
:icon="Document"
:code="viewDialog.data.templateNo"
code-label="模板编号"
:sections="templateViewSections"
append-to-body
>
<template #hero-actions>
<div class="template-view-hero-actions">
<el-button
v-if="hasContractPerm('templateEdit')"
class="btn-main"
type="primary"
@click="handleDetailEdit"
>
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button
v-if="hasContractPerm('templateRemove')"
class="btn-ghost"
type="danger"
:disabled="!canDeleteByCreator(viewDialog.data)"
:title="canDeleteByCreator(viewDialog.data) ? '' : CREATOR_DELETE_TIP"
@click="handleDetailDelete"
>
<el-icon><Delete /></el-icon>删除
</el-button>
</div>
</template>
</SystemViewDialog>
</section>
</div>
</template>
<script setup lang="ts" name="ContractTemplate">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { Document } from '@element-plus/icons-vue'
import {
Document,
Plus,
Edit,
Delete,
Download,
Search,
RefreshLeft,
Close,
Check
} from '@element-plus/icons-vue'
import SystemViewDialog, {
type ViewField,
type ViewSection
@@ -200,25 +323,27 @@ import {
} from '@/api/crm/contract/template'
import { parseTime } from '@/utils/format'
import { useDictStore } from '@/store/modules/dict'
import DictTag from '@/components/DictTag/index.vue'
import { $modal } from '@/utils/modal'
import { useContractPerm } from '@/hooks/business/useContractPerm'
import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import {
canDeleteByCreator,
filterDeletableByCreator,
allRowsDeletableByCreator,
CREATOR_DELETE_TIP
} from '@/utils/creator-delete'
const dictStore = useDictStore()
const { loadPerms, hasContractPerm } = useContractPerm()
// 查询参数
const queryParams = ref<TemplateQuery>({
pageNum: 1,
pageSize: 10,
pageSize: 20,
templateNo: undefined,
templateName: undefined,
templateType: undefined,
isActive: undefined
});
})
// 表单数据
const form = ref<TemplateForm>({
templateName: '',
templateType: '',
@@ -229,35 +354,31 @@ const form = ref<TemplateForm>({
isActive: 1,
sort: 0,
remark: ''
});
})
// 表单校验规则
const rules = ref({
templateName: [{ required: true, message: '模板名称不能为空', trigger: 'blur' }],
templateType: [{ required: true, message: '模板类型不能为空', trigger: 'change' }],
templateContent: [{ required: true, message: '模板内容不能为空', trigger: 'blur' }]
});
})
// 数据列表
const templateList = ref<TemplateVO[]>([]);
const loading = ref(true);
const total = ref(0);
const showSearch = ref(false);
const single = ref(true);
const multiple = ref(true);
const ids = ref<number[]>([]);
const templateList = ref<TemplateVO[]>([])
const selectedRows = ref<TemplateVO[]>([])
const loading = ref(true)
const submitLoading = ref(false)
const exportLoading = ref(false)
const total = ref(0)
const ids = ref<number[]>([])
// 对话框
const dialog = reactive<DialogOption>({
visible: false,
title: ''
});
})
// 查看详情对话框
const viewDialog = reactive({
visible: false,
data: {} as TemplateVO
});
})
const templateViewSections = computed<ViewSection[]>(() => {
const d = viewDialog.data
@@ -286,87 +407,99 @@ const templateViewSections = computed<ViewSection[]>(() => {
return sections
})
const queryFormRef = ref<ElFormInstance>();
const formRef = ref<ElFormInstance>();
const queryFormRef = ref<ElFormInstance>()
const formRef = ref<ElFormInstance>()
/** 查询列表 */
const getList = async () => {
loading.value = true;
try {
const res = await listTemplate(queryParams.value);
templateList.value = res.rows;
total.value = res.total;
} finally {
loading.value = false;
function handleToolbarEdit() {
if (selectedRows.value.length !== 1) {
$modal.msgWarning('请选择一条数据进行编辑')
return
}
};
handleUpdate(selectedRows.value[0])
}
async function handleDetailEdit() {
if (!viewDialog.data?.id) return
viewDialog.visible = false
await handleUpdate(viewDialog.data as TemplateVO)
}
async function handleDetailDelete() {
if (!viewDialog.data?.id) return
await handleDelete(viewDialog.data as TemplateVO)
viewDialog.visible = false
}
const getList = async () => {
loading.value = true
try {
const res = await listTemplate(queryParams.value)
templateList.value = res.rows
total.value = res.total
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.value.pageNum = 1;
getList();
};
queryParams.value.pageNum = 1
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value?.resetFields();
handleQuery();
};
queryFormRef.value?.resetFields()
handleQuery()
}
/** 多选框选中数据 */
const handleSelectionChange = (selection: TemplateVO[]) => {
ids.value = selection.map((item) => item.id);
single.value = selection.length !== 1;
multiple.value = !selection.length;
};
selectedRows.value = selection
ids.value = selection.map((item) => item.id)
}
/** 新增按钮操作 */
const handleAdd = () => {
reset();
dialog.visible = true;
dialog.title = '添加合同模板';
};
reset()
dialog.visible = true
dialog.title = '添加合同模板'
}
/** 修改按钮操作 */
const handleUpdate = async (row: TemplateVO) => {
reset();
const res = await getTemplate(row.id);
Object.assign(form.value, res.data);
dialog.visible = true;
dialog.title = '修改合同模板';
};
reset()
const res = await getTemplate(row.id)
Object.assign(form.value, res.data)
dialog.visible = true
dialog.title = '修改合同模板'
}
/** 查看详情 */
const handleView = async (row: TemplateVO) => {
const res = await getTemplate(row.id);
viewDialog.data = res.data;
viewDialog.visible = true;
};
const res = await getTemplate(row.id)
viewDialog.data = res.data
viewDialog.visible = true
}
/** 提交表单 */
const submitForm = () => {
formRef.value?.validate(async (valid) => {
if (valid) {
try {
if (form.value.id) {
await updateTemplate(form.value);
$modal.msgSuccess('修改成功')
} else {
await addTemplate(form.value)
$modal.msgSuccess('新增成功')
}
dialog.visible = false;
await getList();
} catch (error) {
console.error(error);
if (!valid) return
submitLoading.value = true
try {
if (form.value.id) {
await updateTemplate(form.value)
$modal.msgSuccess('修改成功')
} else {
await addTemplate(form.value)
$modal.msgSuccess('新增成功')
}
dialog.visible = false
await getList()
} catch (error) {
console.error(error)
} finally {
submitLoading.value = false
}
});
};
})
}
/** 删除按钮操作 */
const handleDelete = async (row?: TemplateVO) => {
const targets = row ? [row] : templateList.value.filter((item) => ids.value.includes(item.id))
const targets = row ? [row] : selectedRows.value
const deletable = filterDeletableByCreator(targets)
if (!deletable.length) {
$modal.msgWarning(CREATOR_DELETE_TIP)
@@ -376,17 +509,16 @@ const handleDelete = async (row?: TemplateVO) => {
$modal.msgWarning(CREATOR_DELETE_TIP)
return
}
const idList = row?.id ? [row.id] : ids.value;
const idList = row?.id ? [row.id] : ids.value
await $modal.confirm('是否确认删除选中的合同模板?')
await delTemplate(idList)
$modal.msgSuccess('删除成功')
await getList();
};
await getList()
}
/** 状态切换 */
const handleStatusChange = async (row: TemplateVO) => {
try {
const text = row.isActive === 1 ? '启用' : '停用';
const text = row.isActive === 1 ? '启用' : '停用'
await $modal.confirm(`确认要${text}"${row.templateName}"吗?`)
if (row.isActive === 1) {
await enableTemplate(row.id)
@@ -394,14 +526,14 @@ const handleStatusChange = async (row: TemplateVO) => {
await disableTemplate(row.id)
}
$modal.msgSuccess(`${text}成功`)
await getList();
await getList()
} catch {
row.isActive = row.isActive === 0 ? 1 : 0;
row.isActive = row.isActive === 0 ? 1 : 0
}
};
}
/** 导出按钮操作 */
const handleExport = async () => {
exportLoading.value = true
try {
const res = await exportTemplate(queryParams.value)
const blob =
@@ -419,16 +551,16 @@ const handleExport = async () => {
} catch (error) {
console.error(error)
$modal.msgError('导出失败')
} finally {
exportLoading.value = false
}
}
/** 取消按钮 */
const cancel = () => {
dialog.visible = false;
reset();
};
dialog.visible = false
reset()
}
/** 表单重置 */
const reset = () => {
form.value = {
id: undefined,
@@ -441,19 +573,12 @@ const reset = () => {
isActive: 1,
sort: 0,
remark: ''
};
formRef.value?.resetFields();
};
}
formRef.value?.resetFields()
}
onMounted(async () => {
await loadPerms()
getList()
})
</script>
<style scoped lang="scss">
.text-content {
white-space: pre-wrap;
word-break: break-all;
}
</style>
@@ -235,7 +235,13 @@
align="center"
header-align="center"
show-overflow-tooltip
/>
>
<template #default="{ row }">
<el-link type="primary" @click="handleViewOpportunity(row.id)">
{{ row.opportunityName }}
</el-link>
</template>
</el-table-column>
<el-table-column label="商机阶段" min-width="120" align="center" header-align="center">
<template #default="{ row }">
<el-tag
@@ -267,20 +273,6 @@
align="center"
header-align="center"
/>
<el-table-column
label="操作"
fixed="right"
min-width="120"
align="center"
header-align="center"
class-name="col-actions"
>
<template #default="{ row }">
<el-button link type="primary" @click="handleViewOpportunity(row.id)">
<el-icon><View /></el-icon>查看
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
@@ -310,7 +302,13 @@
align="center"
header-align="center"
show-overflow-tooltip
/>
>
<template #default="{ row }">
<el-link type="primary" @click="handleViewContract(row.id)">
{{ row.contractName }}
</el-link>
</template>
</el-table-column>
<el-table-column label="合同类型" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag
@@ -348,20 +346,6 @@
align="center"
header-align="center"
/>
<el-table-column
label="操作"
fixed="right"
min-width="120"
align="center"
header-align="center"
class-name="col-actions"
>
<template #default="{ row }">
<el-button link type="primary" @click="handleViewContract(row.id)">
<el-icon><View /></el-icon>查看
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
@@ -523,7 +507,7 @@ const followTotal = ref(0)
const followQuery = ref<CustomerFollowQuery>({
customerId: undefined,
pageNum: 1,
pageSize: 10
pageSize: 20
})
const contactList = ref<ContactVO[]>([])
@@ -536,7 +520,7 @@ const operLogList = ref<OperLogVO[]>([])
const operLogListFull = ref<OperLogVO[]>([])
const logTotal = ref(0)
const logLoading = ref(false)
const logQuery = ref({ pageNum: 1, pageSize: 10 })
const logQuery = ref({ pageNum: 1, pageSize: 20 })
const activeTab = ref('follow')
const customerFormRef = ref<InstanceType<typeof CustomerForm>>()
@@ -910,8 +894,8 @@ function open(id: number | string) {
operLogList.value = []
operLogListFull.value = []
activeTab.value = 'follow'
followQuery.value = { customerId: id, pageNum: 1, pageSize: 10 }
logQuery.value = { pageNum: 1, pageSize: 10 }
followQuery.value = { customerId: id, pageNum: 1, pageSize: 20 }
logQuery.value = { pageNum: 1, pageSize: 20 }
getCustomerInfo()
getFollowList()
}
+119 -217
View File
@@ -3,17 +3,19 @@
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2>客户列表</h2>
<h2 class="title-bar-heading">客户列表</h2>
</div>
<div class="panel-tabs">
<el-tabs v-model="activeView" @tab-change="handleViewChange">
<el-tab-pane label="我的客户" name="mine" />
<el-tab-pane label="全部客户" name="all" />
<el-tab-pane label="待跟进" name="toFollow" />
<el-tab-pane label="重点客户" name="key" />
</el-tabs>
</div>
<el-tabs
v-model="activeView"
class="panel-pill-tabs panel-pill-tabs--lg"
@tab-change="handleViewChange"
>
<el-tab-pane label="我的客户" name="mine" />
<el-tab-pane label="全部客户" name="all" />
<el-tab-pane label="待跟进" name="toFollow" />
<el-tab-pane label="重点客户" name="key" />
</el-tabs>
<!-- 筛选区 -->
<div class="filter-block">
@@ -162,10 +164,38 @@
<el-icon><Plus /></el-icon>
新增客户
</el-button>
<el-button
class="btn-ghost"
:disabled="selectedRows.length !== 1"
@click="handleToolbarEdit"
>
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button
class="btn-ghost"
type="danger"
:disabled="selectedRows.length === 0 || !allRowsDeletableByCreator(selectedRows)"
:title="allRowsDeletableByCreator(selectedRows) ? '' : CREATOR_DELETE_TIP"
@click="handleDelete()"
>
<el-icon><Delete /></el-icon>
删除
</el-button>
<el-button class="btn-ghost" :disabled="multiple" @click="handleIntoPool()">
<el-icon><DocumentCopy /></el-icon>
放入公海
</el-button>
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="getList"
@reset-columns="resetToDefault"
/>
</div>
</div>
@@ -178,6 +208,7 @@
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
@@ -591,33 +622,6 @@
header-align="center"
show-overflow-tooltip
/>
<el-table-column
label="操作"
min-width="320"
fixed="right"
align="center"
header-align="center"
class-name="col-actions"
>
<template #default="{ row }">
<div class="col-actions-inner">
<el-button class="btn-op-item" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-button class="btn-op-item" @click="handleEdit(row)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button
class="btn-op-item btn-op-danger"
:disabled="!canDeleteByCreator(row)"
:title="canDeleteByCreator(row) ? '' : CREATOR_DELETE_TIP"
@click="handleDelete(row)"
>
<el-icon><Delete /></el-icon>删除
</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
@@ -649,46 +653,6 @@
<!-- 客户成交对话框 -->
<deal-form ref="dealFormRef" @success="getList" />
<!-- 列设置对话框 -->
<el-dialog
v-model="columnSettingDialog.visible"
title="列设置"
width="600px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<div class="column-setting-content">
<el-checkbox
v-model="checkAll"
:indeterminate="isIndeterminate"
@change="handleCheckAllChange"
>
全选
</el-checkbox>
<el-divider />
<el-checkbox-group v-model="visibleColumns" @change="handleCheckedColumnsChange">
<div v-for="col in tableColumns" :key="col.prop" class="column-item">
<el-checkbox :label="col.prop" :disabled="col.fixed">{{ col.label }}</el-checkbox>
</div>
</el-checkbox-group>
</div>
<template #footer>
<div class="dialog-footer">
<el-button class="btn-ghost" @click="columnSettingDialog.visible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button class="btn-ghost" @click="handleResetColumns">
<el-icon><RefreshLeft /></el-icon>恢复默认
</el-button>
<el-button class="btn-main" type="primary" @click="handleSaveColumnSetting">
<el-icon><Check /></el-icon>保存
</el-button>
</div>
</template>
</el-dialog>
</section>
</div>
</template>
@@ -697,18 +661,20 @@
import {
Plus,
Delete,
Close,
Check,
Search,
RefreshLeft,
View,
DocumentCopy,
ArrowUpBold,
ArrowDownBold,
Edit,
} from '@element-plus/icons-vue'
import { useDictStore } from '@/store/modules/dict'
import { canDeleteByCreator, filterDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import {
canDeleteByCreator,
filterDeletableByCreator,
allRowsDeletableByCreator,
CREATOR_DELETE_TIP
} from '@/utils/creator-delete'
import {
fetchCustomerList,
fetchCustomerDetail,
@@ -729,6 +695,8 @@ import TransferForm from './TransferDialog.vue'
import IntoPoolForm from './IntoPoolDialog.vue'
import FollowForm from './FollowFormDialog.vue'
import DealForm from './DealDialog.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
const dictStore = useDictStore()
@@ -755,75 +723,76 @@ const activeView = ref('mine')
const customerList = ref<any[]>([])
const loading = ref(false)
const selectedRows = ref<any[]>([])
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const pagination = reactive({ page: 1, pageSize: 20, total: 0 })
// 多选状态
const single = ref(true)
const multiple = ref(true)
const ids = ref<(number | string)[]>([])
// 列配置相关
const COLUMN_STORAGE_KEY = 'crm_customer_visible_columns'
const tableColumns = ref([
{ prop: 'shortName', label: '客户简称', fixed: false },
{ prop: 'customerType', label: '客户类型', fixed: false },
{ prop: 'mobile', label: '手机号', fixed: false },
{ prop: 'telephone', label: '固定电话', fixed: false },
{ prop: 'email', label: '邮箱', fixed: false },
{ prop: 'website', label: '客户网站', fixed: false },
{ prop: 'level', label: '客户级别', fixed: false },
{ prop: 'source', label: '客户来源', fixed: false },
{ prop: 'industry', label: '所属行业', fixed: false },
{ prop: 'parkArea', label: '所在园区', fixed: false },
{ prop: 'scale', label: '企业规模', fixed: false },
{ prop: 'province', label: '省', fixed: false },
{ prop: 'city', label: '市', fixed: false },
{ prop: 'district', label: '区', fixed: false },
{ prop: 'address', label: '详细地址', fixed: false },
{ prop: 'status', label: '客户状态', fixed: false },
{ prop: 'dealStatus', label: '成交状态', fixed: false },
{ prop: 'ownerUserName', label: '负责人', fixed: false },
{ prop: 'followCount', label: '跟进次数', fixed: false },
{ prop: 'lastTime', label: '最后跟进时间', fixed: false },
{ prop: 'nextTime', label: '下次联系时间', fixed: false },
{ prop: 'dealTime', label: '成交时间', fixed: false },
{ prop: 'dealAmount', label: '成交金额', fixed: false },
{ prop: 'contactCount', label: '联系人数量', fixed: false },
{ prop: 'opportunityCount', label: '商机数量', fixed: false },
{ prop: 'contractCount', label: '合同数量', fixed: false },
{ prop: 'intoPoolTime', label: '进入公海时间', fixed: false },
{ prop: 'claimTime', label: '领取时间', fixed: false },
{ prop: 'protectTime', label: '保护到期时间', fixed: false },
{ prop: 'createDeptName', label: '创建部门', fixed: false },
{ prop: 'createByName', label: '创建人', fixed: false },
{ prop: 'createTime', label: '创建时间', fixed: false },
{ prop: 'updateByName', label: '更新人', fixed: false },
{ prop: 'updateTime', label: '更新时间', fixed: false },
{ prop: 'remark', label: '备注', fixed: false },
])
const defaultVisibleColumns = [
'customerType',
'mobile',
'level',
'source',
'industry',
'parkArea',
'status',
'dealStatus',
'ownerUserName',
'followCount',
'dealAmount',
'lastTime',
'nextTime',
'createTime',
// 列配置
const listColumnDefs = [
{ prop: 'shortName', label: '客户简称' },
{ prop: 'customerType', label: '客户类型' },
{ prop: 'mobile', label: '手机号' },
{ prop: 'telephone', label: '固定电话' },
{ prop: 'email', label: '邮箱' },
{ prop: 'website', label: '客户网站' },
{ prop: 'level', label: '客户级别' },
{ prop: 'source', label: '客户来源' },
{ prop: 'industry', label: '所属行业' },
{ prop: 'parkArea', label: '所在园区' },
{ prop: 'scale', label: '企业规模' },
{ prop: 'province', label: '省' },
{ prop: 'city', label: '市' },
{ prop: 'district', label: '区' },
{ prop: 'address', label: '详细地址' },
{ prop: 'status', label: '客户状态' },
{ prop: 'dealStatus', label: '成交状态' },
{ prop: 'ownerUserName', label: '负责人' },
{ prop: 'followCount', label: '跟进次数' },
{ prop: 'lastTime', label: '最后跟进时间' },
{ prop: 'nextTime', label: '下次联系时间' },
{ prop: 'dealTime', label: '成交时间' },
{ prop: 'dealAmount', label: '成交金额' },
{ prop: 'contactCount', label: '联系人数量' },
{ prop: 'opportunityCount', label: '商机数量' },
{ prop: 'contractCount', label: '合同数量' },
{ prop: 'intoPoolTime', label: '进入公海时间' },
{ prop: 'claimTime', label: '领取时间' },
{ prop: 'protectTime', label: '保护到期时间' },
{ prop: 'createDeptName', label: '创建部门' },
{ prop: 'createByName', label: '创建人' },
{ prop: 'createTime', label: '创建时间' },
{ prop: 'updateByName', label: '更新人' },
{ prop: 'updateTime', label: '更新时间' },
{ prop: 'remark', label: '备注' },
]
const visibleColumns = ref<string[]>([])
const columnSettingDialog = reactive({ visible: false })
const checkAll = ref(false)
const isIndeterminate = ref(false)
const {
visibleColumns,
checkAll,
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('crm_customer_visible_columns', listColumnDefs, {
defaultVisibleProps: [
'customerType',
'mobile',
'level',
'source',
'industry',
'parkArea',
'status',
'dealStatus',
'ownerUserName',
'followCount',
'dealAmount',
'lastTime',
'nextTime',
'createTime',
]
})
// 子组件引用
const customerFormRef = ref<InstanceType<typeof CustomerForm>>()
@@ -905,6 +874,14 @@ function handleViewChange() {
getList()
}
function handleToolbarEdit() {
if (selectedRows.value.length !== 1) {
ElMessage.warning('请选择一条数据进行编辑')
return
}
handleEdit(selectedRows.value[0])
}
/** 编辑客户 */
function handleEdit(row: any) {
customerFormRef.value?.open(row.id)
@@ -976,8 +953,7 @@ function handleDeal(row?: any) {
/** 更多操作命令 */
function handleMoreCommand(cmd: string, row: any) {
if (cmd === 'view') handleView(row)
else if (cmd === 'edit') handleEdit(row)
if (cmd === 'edit') handleEdit(row)
else if (cmd === 'transfer') handleTransfer(row)
else if (cmd === 'intoPool') handleIntoPool(row)
else if (cmd === 'delete') handleDelete(row)
@@ -1029,61 +1005,6 @@ async function handleExport() {
}
}
// ── 列设置 ──
function initColumnSetting() {
const savedColumns = localStorage.getItem(COLUMN_STORAGE_KEY)
if (savedColumns) {
try {
visibleColumns.value = JSON.parse(savedColumns)
} catch {
visibleColumns.value = [...defaultVisibleColumns]
}
} else {
visibleColumns.value = [...defaultVisibleColumns]
}
updateCheckAllStatus()
}
function isColumnVisible(prop: string) {
return visibleColumns.value.includes(prop)
}
function handleColumnSetting() {
columnSettingDialog.visible = true
}
function handleCheckAllChange(val: boolean) {
visibleColumns.value = val ? tableColumns.value.map((col) => col.prop) : []
isIndeterminate.value = false
}
function handleCheckedColumnsChange() {
updateCheckAllStatus()
}
function updateCheckAllStatus() {
const checkedCount = visibleColumns.value.length
const totalCount = tableColumns.value.length
checkAll.value = checkedCount === totalCount
isIndeterminate.value = checkedCount > 0 && checkedCount < totalCount
}
function handleResetColumns() {
visibleColumns.value = [...defaultVisibleColumns]
updateCheckAllStatus()
}
function handleSaveColumnSetting() {
try {
localStorage.setItem(COLUMN_STORAGE_KEY, JSON.stringify(visibleColumns.value))
ElMessage.success('列设置保存成功')
columnSettingDialog.visible = false
} catch {
ElMessage.error('保存列设置失败')
}
}
watchDebounced(
() => ({ ...searchForm, dateRange: dateRange.value }),
() => {
@@ -1094,7 +1015,6 @@ watchDebounced(
)
onMounted(() => {
initColumnSetting()
getList()
})
</script>
@@ -1111,24 +1031,6 @@ onMounted(() => {
margin: 0;
}
.column-setting-content {
max-height: 400px;
overflow-y: auto;
.column-item {
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
:deep(.el-checkbox) {
width: 100%;
}
}
}
.col-actions-inner {
display: flex;
justify-content: center;
@@ -3,7 +3,7 @@
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2>公海池规则</h2>
<h2 class="title-bar-heading">公海池规则</h2>
</div>
<!-- 筛选区 -->
@@ -34,10 +34,10 @@
<div class="filter-actions-row">
<span class="filter-hint">按规则名称模糊查询。</span>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset">
<el-button class="btn-ghost" :loading="loading" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" @click="handleSearch">
<el-button class="btn-main" type="primary" :loading="loading" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
</div>
@@ -56,6 +56,36 @@
<el-icon><Plus /></el-icon>
新增规则
</el-button>
<el-button
class="btn-ghost"
v-auth="'edit'"
:disabled="selectedRows.length !== 1"
@click="handleToolbarEdit"
>
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button
class="btn-ghost"
v-auth="'edit'"
:disabled="selectedRows.length !== 1"
@click="handleToolbarToggle"
>
<el-icon>
<CircleClose v-if="selectedRows[0]?.status === 'enable'" />
<CircleCheck v-else />
</el-icon>
{{ selectedRows[0]?.status === 'enable' ? '禁用' : '启用' }}
</el-button>
<el-button
class="btn-ghost"
type="danger"
v-auth="'delete'"
:disabled="selectedRows.length === 0 || !allRowsDeletableByCreator(selectedRows)"
:title="allRowsDeletableByCreator(selectedRows) ? '' : CREATOR_DELETE_TIP"
@click="handleDelete()"
>
<el-icon><Delete /></el-icon>删除
</el-button>
<el-button
class="btn-ghost"
type="warning"
@@ -65,6 +95,8 @@
<el-icon><VideoPlay /></el-icon>
手动执行
</el-button>
<ListToolbarEndActions :loading="loading" @refresh="getList" />
</div>
</div>
@@ -150,41 +182,6 @@
header-align="center"
show-overflow-tooltip
/>
<el-table-column
label="操作"
min-width="200"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item primary" v-auth="'edit'" @click="handleEdit(row)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button
class="btn-op-item"
:class="row.status === 'enable' ? 'warning' : 'success'"
v-auth="'edit'"
@click="handleToggle(row)"
>
<el-icon>
<CircleClose v-if="row.status === 'enable'" />
<CircleCheck v-else />
</el-icon>
{{ row.status === 'enable' ? '禁用' : '启用' }}
</el-button>
<el-button
class="btn-op-item danger"
v-auth="'delete'"
v-if="canDeleteByCreator(row)"
@click="handleDelete(row)"
>
<el-icon><Delete /></el-icon>
</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
@@ -206,6 +203,7 @@
</template>
<script setup lang="ts">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import {
Plus,
Close,
@@ -238,7 +236,7 @@ const searchForm = reactive({
const ruleList = ref<any[]>([])
const loading = ref(false)
const selectedRows = ref<any[]>([])
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const pagination = reactive({ page: 1, pageSize: 20, total: 0 })
const ruleDialogRef = ref()
@@ -296,6 +294,22 @@ const handleAdd = () => {
ruleDialogRef.value?.open()
}
const handleToolbarEdit = () => {
if (selectedRows.value.length !== 1) {
ElMessage.warning('请选择一条数据进行编辑')
return
}
handleEdit(selectedRows.value[0])
}
const handleToolbarToggle = () => {
if (selectedRows.value.length !== 1) {
ElMessage.warning('请选择一条数据进行操作')
return
}
handleToggle(selectedRows.value[0])
}
const handleEdit = (row: any) => {
ruleDialogRef.value?.open(row)
}
@@ -315,12 +329,30 @@ const handleToggle = async (row: any) => {
}
}
const handleDelete = async (row: any) => {
const handleDelete = async (row?: any) => {
const targets = row ? [row] : selectedRows.value
if (!targets.length) {
ElMessage.warning('请至少选择一条数据进行删除')
return
}
const deletable = filterDeletableByCreator(targets)
if (!deletable.length) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
if (deletable.length < targets.length) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
try {
await ElMessageBox.confirm(`确认删除规则「${row.poolName}」吗?`, '提示', {
const label =
deletable.length === 1 ? `规则「${deletable[0].poolName}」` : `选中的 ${deletable.length} 条规则`
await ElMessageBox.confirm(`确认删除${label}吗?`, '提示', {
type: 'warning',
})
await fetchDeletePoolRule({ id: row.id })
for (const item of deletable) {
await fetchDeletePoolRule({ id: item.id })
}
ElMessage.success('删除成功')
getList()
} catch {
+72 -204
View File
@@ -3,7 +3,7 @@
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2>公海池</h2>
<h2 class="title-bar-heading">公海池</h2>
</div>
<!-- 统计信息 -->
@@ -60,10 +60,10 @@
<div class="filter-actions-row">
<span class="filter-hint">按客户名称、编号模糊查询。</span>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset">
<el-button class="btn-ghost" :loading="loading" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" @click="handleSearch">
<el-button class="btn-main" type="primary" :loading="loading" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
</div>
@@ -91,10 +91,16 @@
<el-icon><User /></el-icon>
分配客户
</el-button>
<el-button class="btn-ghost" @click="handleColumnSetting">
<el-icon><Setting /></el-icon>
列设置
</el-button>
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="getList"
@reset-columns="resetToDefault"
/>
</div>
</div>
@@ -107,6 +113,7 @@
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
@@ -501,36 +508,6 @@
header-align="center"
show-overflow-tooltip
/>
<el-table-column
label="操作"
min-width="200"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item primary" @click="handleClaim(row)">
<el-icon><Select /></el-icon>领取
</el-button>
<el-dropdown trigger="hover" @command="(cmd: string) => handleMoreCommand(cmd, row)">
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="view">
<el-icon><View /></el-icon>查看
</el-dropdown-item>
<el-dropdown-item command="assign">
<el-icon><User /></el-icon>分配
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
@@ -553,64 +530,22 @@
<!-- 分配客户对话框 -->
<assign-dialog ref="assignDialogRef" @success="getList" />
<!-- 列设置对话框 -->
<el-dialog
v-model="columnSettingDialog.visible"
title="列设置"
width="600px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<div class="column-setting-content">
<el-checkbox
v-model="checkAll"
:indeterminate="isIndeterminate"
@change="handleCheckAllChange"
>
全选
</el-checkbox>
<el-divider />
<el-checkbox-group v-model="visibleColumns" @change="handleCheckedColumnsChange">
<div v-for="col in tableColumns" :key="col.prop" class="column-item">
<el-checkbox :label="col.prop" :disabled="col.fixed">{{ col.label }}</el-checkbox>
</div>
</el-checkbox-group>
</div>
<template #footer>
<div class="dialog-footer">
<el-button class="btn-ghost" @click="columnSettingDialog.visible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button class="btn-ghost" @click="handleResetColumns">
<el-icon><RefreshLeft /></el-icon>恢复默认
</el-button>
<el-button class="btn-main" type="primary" @click="handleSaveColumnSetting">
<el-icon><Check /></el-icon>保存
</el-button>
</div>
</template>
</el-dialog>
</section>
</div>
</template>
<script setup lang="ts">
import {
Close,
Check,
Search,
RefreshLeft,
Select,
User,
Setting,
More,
View,
} from '@element-plus/icons-vue'
import { useDictStore } from '@/store/modules/dict'
import { fetchPoolCustomerList } from '@/api/crm/pool'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import ClaimDialog from './components/ClaimDialog.vue'
import AssignDialog from './components/AssignDialog.vue'
import CustomerDetail from '../CustomerDetailDialog.vue'
@@ -628,7 +563,7 @@ const searchForm = reactive({
const customerList = ref<any[]>([])
const loading = ref(false)
const selectedRows = ref<any[]>([])
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const pagination = reactive({ page: 1, pageSize: 20, total: 0 })
// 多选状态
const single = ref(true)
@@ -644,113 +579,67 @@ const poolStatistics = reactive({
})
// 列配置
const COLUMN_STORAGE_KEY = 'crm_pool_visible_columns'
const tableColumns = ref([
{ prop: 'shortName', label: '客户简称', fixed: false },
{ prop: 'customerType', label: '客户类型', fixed: false },
{ prop: 'mobile', label: '手机号', fixed: false },
{ prop: 'telephone', label: '固定电话', fixed: false },
{ prop: 'email', label: '邮箱', fixed: false },
{ prop: 'website', label: '客户网站', fixed: false },
{ prop: 'level', label: '客户级别', fixed: false },
{ prop: 'source', label: '客户来源', fixed: false },
{ prop: 'industry', label: '所属行业', fixed: false },
{ prop: 'parkArea', label: '所在园区', fixed: false },
{ prop: 'scale', label: '企业规模', fixed: false },
{ prop: 'province', label: '省', fixed: false },
{ prop: 'city', label: '市', fixed: false },
{ prop: 'district', label: '区', fixed: false },
{ prop: 'address', label: '详细地址', fixed: false },
{ prop: 'status', label: '客户状态', fixed: false },
{ prop: 'dealStatus', label: '成交状态', fixed: false },
{ prop: 'ownerUserName', label: '原负责人', fixed: false },
{ prop: 'followCount', label: '跟进次数', fixed: false },
{ prop: 'lastTime', label: '最后跟进时间', fixed: false },
{ prop: 'nextTime', label: '下次联系时间', fixed: false },
{ prop: 'dealTime', label: '成交时间', fixed: false },
{ prop: 'dealAmount', label: '成交金额', fixed: false },
{ prop: 'contactCount', label: '联系人数量', fixed: false },
{ prop: 'opportunityCount', label: '商机数量', fixed: false },
{ prop: 'contractCount', label: '合同数量', fixed: false },
{ prop: 'intoPoolTime', label: '进入公海时间', fixed: false },
{ prop: 'claimTime', label: '领取时间', fixed: false },
{ prop: 'protectTime', label: '保护到期时间', fixed: false },
{ prop: 'createDeptName', label: '创建部门', fixed: false },
{ prop: 'createByName', label: '创建人', fixed: false },
{ prop: 'createTime', label: '创建时间', fixed: false },
{ prop: 'updateByName', label: '更新人', fixed: false },
{ prop: 'updateTime', label: '更新时间', fixed: false },
{ prop: 'remark', label: '备注', fixed: false },
])
const defaultVisibleColumns = [
'customerType',
'mobile',
'level',
'industry',
'followCount',
'lastTime',
'intoPoolTime',
'ownerUserName',
const listColumnDefs = [
{ prop: 'shortName', label: '客户简称' },
{ prop: 'customerType', label: '客户类型' },
{ prop: 'mobile', label: '手机号' },
{ prop: 'telephone', label: '固定电话' },
{ prop: 'email', label: '邮箱' },
{ prop: 'website', label: '客户网站' },
{ prop: 'level', label: '客户级别' },
{ prop: 'source', label: '客户来源' },
{ prop: 'industry', label: '所属行业' },
{ prop: 'parkArea', label: '所在园区' },
{ prop: 'scale', label: '企业规模' },
{ prop: 'province', label: '省' },
{ prop: 'city', label: '市' },
{ prop: 'district', label: '区' },
{ prop: 'address', label: '详细地址' },
{ prop: 'status', label: '客户状态' },
{ prop: 'dealStatus', label: '成交状态' },
{ prop: 'ownerUserName', label: '原负责人' },
{ prop: 'followCount', label: '跟进次数' },
{ prop: 'lastTime', label: '最后跟进时间' },
{ prop: 'nextTime', label: '下次联系时间' },
{ prop: 'dealTime', label: '成交时间' },
{ prop: 'dealAmount', label: '成交金额' },
{ prop: 'contactCount', label: '联系人数量' },
{ prop: 'opportunityCount', label: '商机数量' },
{ prop: 'contractCount', label: '合同数量' },
{ prop: 'intoPoolTime', label: '进入公海时间' },
{ prop: 'claimTime', label: '领取时间' },
{ prop: 'protectTime', label: '保护到期时间' },
{ prop: 'createDeptName', label: '创建部门' },
{ prop: 'createByName', label: '创建人' },
{ prop: 'createTime', label: '创建时间' },
{ prop: 'updateByName', label: '更新人' },
{ prop: 'updateTime', label: '更新时间' },
{ prop: 'remark', label: '备注' },
]
const visibleColumns = ref<string[]>([])
const columnSettingDialog = reactive({ visible: false })
const checkAll = ref(false)
const isIndeterminate = ref(false)
const {
visibleColumns,
checkAll,
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('crm_pool_visible_columns', listColumnDefs, {
defaultVisibleProps: [
'customerType',
'mobile',
'level',
'industry',
'followCount',
'lastTime',
'intoPoolTime',
'ownerUserName',
]
})
const claimDialogRef = ref()
const assignDialogRef = ref()
const customerDetailRef = ref()
const initColumnSetting = () => {
const saved = localStorage.getItem(COLUMN_STORAGE_KEY)
if (saved) {
try {
visibleColumns.value = JSON.parse(saved)
} catch {
visibleColumns.value = [...defaultVisibleColumns]
}
} else {
visibleColumns.value = [...defaultVisibleColumns]
}
updateCheckAllStatus()
}
const isColumnVisible = (prop: string) => visibleColumns.value.includes(prop)
const handleColumnSetting = () => {
columnSettingDialog.visible = true
}
const handleCheckAllChange = (val: boolean) => {
visibleColumns.value = val ? tableColumns.value.map((col) => col.prop) : []
isIndeterminate.value = false
}
const handleCheckedColumnsChange = () => {
updateCheckAllStatus()
}
const updateCheckAllStatus = () => {
const checkedCount = visibleColumns.value.length
const totalCount = tableColumns.value.length
checkAll.value = checkedCount === totalCount
isIndeterminate.value = checkedCount > 0 && checkedCount < totalCount
}
const handleResetColumns = () => {
visibleColumns.value = [...defaultVisibleColumns]
updateCheckAllStatus()
}
const handleSaveColumnSetting = () => {
localStorage.setItem(COLUMN_STORAGE_KEY, JSON.stringify(visibleColumns.value))
ElMessage.success('列设置保存成功')
columnSettingDialog.visible = false
}
const getList = async () => {
loading.value = true
try {
@@ -826,9 +715,7 @@ const handleAssign = (row?: any) => {
}
const handleMoreCommand = (cmd: string, row: any) => {
if (cmd === 'view') {
handleView(row)
} else if (cmd === 'assign') {
if (cmd === 'assign') {
handleAssign(row)
}
}
@@ -843,7 +730,6 @@ watchDebounced(
)
onMounted(() => {
initColumnSetting()
getList()
})
</script>
@@ -877,22 +763,4 @@ onMounted(() => {
margin-top: 4px;
}
}
.column-setting-content {
max-height: 400px;
overflow-y: auto;
.column-item {
padding: 8px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
&:last-child {
border-bottom: none;
}
:deep(.el-checkbox) {
width: 100%;
}
}
}
</style>
+261 -258
View File
@@ -1,18 +1,18 @@
<template>
<div class="app-container opportunity-funnel">
<!-- 筛选条件 -->
<el-card shadow="never" class="filter-card">
<el-form :model="queryParams" ref="queryFormRef" label-width="90px">
<el-row :gutter="20">
<el-col :xl="4" :lg="6" :md="8" :sm="12" :xs="24">
<div class="page-container opportunity-funnel-page">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">销售漏斗</h2>
</div>
<div class="filter-block">
<el-form ref="queryFormRef" class="filter-form" :model="queryParams" @submit.prevent>
<div class="filter-grid">
<el-form-item label="负责人" prop="ownerUserId">
<el-select v-model="queryParams.ownerUserId" placeholder="请选择负责人" clearable style="width: 100%">
<!-- TODO: 从用户列表获取 -->
<el-select v-model="queryParams.ownerUserId" placeholder="请选择负责人" clearable>
<el-option label="全部" :value="undefined" />
</el-select>
</el-form-item>
</el-col>
<el-col :xl="8" :lg="12" :md="16" :sm="24" :xs="24">
<el-form-item label="时间范围" prop="timeRange">
<el-date-picker
v-model="queryParams.timeRange"
@@ -24,198 +24,185 @@
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="24" style="text-align: right">
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-col>
</el-row>
</el-form>
</el-card>
<!-- 概览统计 -->
<el-row :gutter="16" class="summary-row">
<el-col :span="6">
<el-card shadow="hover" class="summary-card">
<div class="card-icon" style="background: #e6f7ff">
<el-icon :size="32" color="#1890ff"><Opportunity /></el-icon>
</div>
<div class="card-content">
<div class="card-value">{{ summary.totalOpportunities }}</div>
<div class="card-label">商机总数</div>
<div class="filter-actions">
<div class="filter-actions-end">
<el-button class="btn-ghost" :loading="loading" @click="resetQuery">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" :loading="loading" @click="handleQuery">
<el-icon><Search /></el-icon>查询
</el-button>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="summary-card">
<div class="card-icon" style="background: #f0f9ff">
<el-icon :size="32" color="#67c23a"><CircleCheck /></el-icon>
</div>
<div class="card-content">
<div class="card-value success">{{ summary.wonOpportunities }}</div>
<div class="card-label">赢单数量</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="summary-card">
<div class="card-icon" style="background: #fff7e6">
<el-icon :size="32" color="#e6a23c"><TrendCharts /></el-icon>
</div>
<div class="card-content">
<div class="card-value warning">{{ summary.overallConversionRate }}%</div>
<div class="card-label">总转化率</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="summary-card">
<div class="card-icon" style="background: #f0f9ff">
<el-icon :size="32" color="#409eff"><Money /></el-icon>
</div>
<div class="card-content">
<div class="card-value primary">¥{{ formatAmount(summary.totalAmount) }}</div>
<div class="card-label">商机总金额</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 漏斗图 -->
<el-card shadow="never" class="funnel-card">
<template #header>
<span class="card-title">销售漏斗分析</span>
</template>
<div v-loading="loading" class="funnel-container">
<div ref="funnelChartRef" class="funnel-chart"></div>
</el-form>
</div>
</el-card>
<!-- 阶段明细表格 -->
<el-card shadow="never" class="detail-card">
<template #header>
<span class="card-title">阶段转化明细</span>
</template>
<el-table :data="funnelData" border>
<el-table-column label="商机阶段" prop="stageName" align="center" />
<el-table-column label="商机数量" prop="count" align="center">
<template #default="scope">
<el-tag type="primary">{{ scope.row.count }}</el-tag>
</template>
</el-table-column>
<el-table-column label="商机金额" prop="totalAmount" align="right" header-align="right">
<template #default="scope">
<span class="amount-text">¥{{ formatAmount(scope.row.totalAmount) }}</span>
</template>
</el-table-column>
<el-table-column label="平均赢单率" prop="avgWinRate" align="center">
<template #default="scope"> {{ scope.row.avgWinRate }}% </template>
</el-table-column>
<el-table-column label="转化率" prop="conversionRate" align="center">
<template #default="scope">
<el-progress :percentage="parseFloat(scope.row.conversionRate)" :color="getProgressColor(scope.row.conversionRate)" />
</template>
</el-table-column>
</el-table>
</el-card>
<div class="stat-grid">
<div class="stat-card">
<div class="stat-icon primary">
<el-icon><Opportunity /></el-icon>
</div>
<div class="stat-content">
<div class="stat-title">商机总数</div>
<div class="stat-value">{{ summary.totalOpportunities }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon success">
<el-icon><CircleCheck /></el-icon>
</div>
<div class="stat-content">
<div class="stat-title">赢单数量</div>
<div class="stat-value">{{ summary.wonOpportunities }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon warning">
<el-icon><TrendCharts /></el-icon>
</div>
<div class="stat-content">
<div class="stat-title">总转化率</div>
<div class="stat-value">{{ summary.overallConversionRate }}%</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon primary">
<el-icon><Money /></el-icon>
</div>
<div class="stat-content">
<div class="stat-title">商机总金额</div>
<div class="stat-value money">¥{{ formatAmount(summary.totalAmount) }}</div>
</div>
</div>
</div>
<div class="chart-panel chart-panel-full">
<div class="chart-panel-head">
<span class="chart-title">销售漏斗分析</span>
</div>
<div v-loading="loading" class="chart-container chart-container-tall">
<div ref="funnelChartRef" class="funnel-chart"></div>
</div>
</div>
<div class="chart-panel chart-panel-full">
<div class="chart-panel-head">
<span class="chart-title">阶段转化明细</span>
</div>
<div class="list-body list-wrap">
<el-table class="list-table" :data="funnelData" stripe>
<el-table-column label="商机阶段" prop="stageName" min-width="120" align="center" />
<el-table-column label="商机数量" prop="count" min-width="100" align="center">
<template #default="scope">
<el-tag type="primary" size="small">{{ scope.row.count }}</el-tag>
</template>
</el-table-column>
<el-table-column label="商机金额" prop="totalAmount" min-width="140" align="right" class-name="col-money">
<template #default="scope">¥{{ formatAmount(scope.row.totalAmount) }}</template>
</el-table-column>
<el-table-column label="平均赢单率" prop="avgWinRate" min-width="110" align="center">
<template #default="scope">{{ scope.row.avgWinRate }}%</template>
</el-table-column>
<el-table-column label="转化率" prop="conversionRate" min-width="180" align="center">
<template #default="scope">
<el-progress
:percentage="parseFloat(scope.row.conversionRate)"
:color="getProgressColor(scope.row.conversionRate)"
/>
</template>
</el-table-column>
</el-table>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts" name="OpportunityFunnel">
import { ref, reactive, onMounted, nextTick } from 'vue';
import { getSalesFunnel } from '@/api/crm/opportunity';
import * as echarts from 'echarts';
import type { ECharts } from 'echarts';
import { ElMessage } from 'element-plus';
import { Opportunity, CircleCheck, TrendCharts, Money } from '@element-plus/icons-vue';
import { ref, reactive, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { getSalesFunnel } from '@/api/crm/opportunity'
import * as echarts from 'echarts'
import type { ECharts } from 'echarts'
import { ElMessage } from 'element-plus'
import { Opportunity, CircleCheck, TrendCharts, Money, Search, RefreshLeft } from '@element-plus/icons-vue'
const loading = ref(false);
const queryFormRef = ref();
const funnelChartRef = ref();
let funnelChart: ECharts | null = null;
const MINT_DARK = '#059669'
const MINT_PALETTE = ['#10b981', '#34d399', '#059669', '#047857', '#6ee7b7', '#0d9488', '#14b8a6']
const loading = ref(false)
const queryFormRef = ref()
const funnelChartRef = ref<HTMLElement>()
let funnelChart: ECharts | null = null
// 查询参数
const queryParams = ref<{
ownerUserId?: number;
timeRange?: string[];
ownerUserId?: number
timeRange?: string[]
}>({
timeRange: []
});
})
// 汇总数据
const summary = reactive({
totalOpportunities: 0,
wonOpportunities: 0,
overallConversionRate: '0.00',
totalAmount: 0
});
})
// 漏斗数据
const funnelData = ref<any[]>([]);
const funnelData = ref<any[]>([])
/** 加载漏斗数据 */
const loadFunnelData = async () => {
loading.value = true;
loading.value = true
try {
const params: any = {
ownerUserId: queryParams.value.ownerUserId
};
if (queryParams.value.timeRange && queryParams.value.timeRange.length === 2) {
params.beginTime = queryParams.value.timeRange[0];
params.endTime = queryParams.value.timeRange[1];
}
const res = await getSalesFunnel(params);
if (queryParams.value.timeRange && queryParams.value.timeRange.length === 2) {
params.beginTime = queryParams.value.timeRange[0]
params.endTime = queryParams.value.timeRange[1]
}
// 更新汇总数据
Object.assign(summary, res.data.summary || {});
const res = await getSalesFunnel(params)
Object.assign(summary, res.data.summary || {})
funnelData.value = res.data.funnelData || []
// 更新漏斗数据
funnelData.value = res.data.funnelData || [];
// 渲染漏斗图
await nextTick();
renderFunnelChart();
await nextTick()
renderFunnelChart()
} catch (error) {
console.error('加载漏斗数据失败:', error);
ElMessage.error('加载漏斗数据失败');
console.error('加载漏斗数据失败:', error)
ElMessage.error('加载漏斗数据失败')
} finally {
loading.value = false;
loading.value = false
}
};
}
/** 渲染漏斗图 */
const renderFunnelChart = () => {
if (!funnelChartRef.value) return;
if (!funnelChartRef.value) return
// 初始化图表
if (!funnelChart) {
funnelChart = echarts.init(funnelChartRef.value);
funnelChart = echarts.init(funnelChartRef.value)
}
// 准备数据
const chartData = funnelData.value.map((item) => ({
name: `${item.stageName} (${item.count})`,
value: item.count
}));
}))
// 配置项
const option = {
funnelChart.setOption({
title: {
text: '商机阶段转化漏斗',
left: 'center',
textStyle: {
fontSize: 16,
fontWeight: 600
fontWeight: 600,
color: '#1e293b'
}
},
tooltip: {
trigger: 'item',
formatter: (params: any) => {
const data = funnelData.value[params.dataIndex];
const data = funnelData.value[params.dataIndex]
return `
<div style="padding: 8px;">
<div style="font-weight: 600; margin-bottom: 8px;">${data.stageName}</div>
@@ -224,12 +211,10 @@ const renderFunnelChart = () => {
<div>平均赢单率: ${data.avgWinRate}%</div>
<div>转化率: ${data.conversionRate}%</div>
</div>
`;
`
}
},
legend: {
show: false
},
legend: { show: false },
series: [
{
name: '商机漏斗',
@@ -253,152 +238,170 @@ const renderFunnelChart = () => {
},
labelLine: {
length: 10,
lineStyle: {
width: 1,
type: 'solid'
}
lineStyle: { width: 1, type: 'solid' }
},
itemStyle: {
borderColor: '#fff',
borderWidth: 1
},
emphasis: {
label: {
fontSize: 16
}
label: { fontSize: 16 }
},
data: chartData,
// 颜色渐变
color: ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#3ba272', '#fc8452']
color: MINT_PALETTE
}
]
};
})
}
funnelChart.setOption(option);
// 响应式
window.addEventListener('resize', () => {
funnelChart?.resize();
});
};
/** 搜索按钮操作 */
const handleQuery = () => {
loadFunnelData();
};
loadFunnelData()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value?.resetFields();
loadFunnelData();
};
queryFormRef.value?.resetFields()
loadFunnelData()
}
/** 格式化金额 */
const formatAmount = (amount: number) => {
if (!amount) return '0.00';
if (!amount) return '0.00'
return amount.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
};
})
}
/** 获取进度条颜色 */
const getProgressColor = (rate: number) => {
const numRate = parseFloat(rate);
if (numRate >= 70) return '#67c23a';
if (numRate >= 40) return '#e6a23c';
return '#f56c6c';
};
const getProgressColor = (rate: number | string) => {
const numRate = parseFloat(String(rate))
if (numRate >= 70) return MINT_DARK
if (numRate >= 40) return '#d97706'
return '#dc2626'
}
const handleResize = () => {
funnelChart?.resize()
}
onMounted(() => {
loadFunnelData();
});
loadFunnelData()
window.addEventListener('resize', handleResize)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
funnelChart?.dispose()
})
</script>
<style scoped lang="scss">
.opportunity-funnel {
.filter-card {
margin-bottom: 16px;
.opportunity-funnel-page {
.stat-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
padding: 0 16px 16px;
@media (max-width: 1100px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (max-width: 560px) {
grid-template-columns: 1fr;
}
}
.summary-row {
margin-bottom: 16px;
.stat-card {
display: flex;
align-items: center;
padding: 20px;
background: var(--mint-surface, #fff);
border: 1px solid var(--mint-border, #d9e8e2);
border-radius: var(--mint-radius, 10px);
}
.summary-card {
:deep(.el-card__body) {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
}
.stat-icon {
width: 52px;
height: 52px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
margin-right: 14px;
.card-icon {
width: 64px;
height: 64px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}
&.primary {
background: var(--primary-bg, #ecfdf5);
color: var(--primary, #10b981);
}
.card-content {
flex: 1;
&.success {
background: rgba(16, 185, 129, 0.12);
color: var(--primary-dark, #059669);
}
.card-value {
font-size: 28px;
font-weight: 600;
color: #303133;
margin-bottom: 4px;
&.warning {
background: rgba(217, 119, 6, 0.12);
color: #d97706;
}
}
&.success {
color: #67c23a;
}
.stat-content {
flex: 1;
min-width: 0;
&.warning {
color: #e6a23c;
}
.stat-title {
font-size: 13px;
color: var(--mint-text-muted, #64748b);
margin-bottom: 6px;
}
&.primary {
color: #409eff;
}
}
.stat-value {
font-size: 26px;
font-weight: 700;
color: var(--mint-text, #1e293b);
line-height: 1.2;
.card-label {
font-size: 14px;
color: #1e293b;
}
&.money {
font-size: 22px;
}
}
}
.funnel-card {
margin-bottom: 16px;
.card-title {
font-size: 16px;
font-weight: 600;
}
.funnel-container {
min-height: 500px;
.funnel-chart {
width: 100%;
height: 500px;
}
}
.chart-panel {
background: var(--mint-surface, #fff);
border: 1px solid var(--mint-border, #d9e8e2);
border-radius: var(--mint-radius, 10px);
overflow: hidden;
margin: 0 16px 16px;
}
.detail-card {
.card-title {
font-size: 16px;
font-weight: 600;
}
.chart-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--mint-border, #d9e8e2);
}
.amount-text {
color: #67c23a;
font-weight: 600;
}
.chart-title {
font-size: 14px;
font-weight: 600;
color: var(--mint-text, #1e293b);
}
.chart-container {
min-height: 360px;
padding: 8px 12px 16px;
}
.chart-container-tall {
min-height: 500px;
}
.funnel-chart {
width: 100%;
height: 480px;
}
}
</style>
+96 -201
View File
@@ -6,12 +6,14 @@
<h2 class="title-bar-heading">商机管理</h2>
</div>
<div class="panel-tabs">
<el-tabs v-model="activeView" @tab-change="handleViewChange">
<el-tab-pane label="我的商机" name="mine" />
<el-tab-pane label="全部商机" name="all" />
</el-tabs>
</div>
<el-tabs
v-model="activeView"
class="panel-pill-tabs panel-pill-tabs--lg"
@tab-change="handleViewChange"
>
<el-tab-pane label="我的商机" name="mine" />
<el-tab-pane label="全部商机" name="all" />
</el-tabs>
<div class="filter-block">
<el-form ref="queryFormRef" :model="queryParams" class="filter-form" @submit.prevent>
@@ -119,6 +121,28 @@
>
<el-icon><Plus /></el-icon>新增商机
</el-button>
<el-button
v-if="hasOpportunityPerm('edit')"
class="btn-ghost"
:disabled="
selectedRows.length !== 1 ||
selectedRows[0]?.status === 'won' ||
selectedRows[0]?.status === 'lost'
"
@click="handleUpdate()"
>
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button
v-if="hasOpportunityPerm('remove')"
class="btn-ghost"
type="danger"
:disabled="selectedRows.length !== 1 || !canDeleteByCreator(selectedRows[0])"
:title="canDeleteByCreator(selectedRows[0]) ? '' : CREATOR_DELETE_TIP"
@click="handleDelete()"
>
<el-icon><Delete /></el-icon>删除
</el-button>
<el-button
v-if="hasOpportunityPerm('assign')"
class="btn-ghost"
@@ -127,6 +151,16 @@
>
<el-icon><User /></el-icon>批量分配
</el-button>
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="getList"
@reset-columns="resetToDefault"
/>
</div>
</div>
@@ -314,23 +348,6 @@
show-overflow-tooltip
/>
<el-table-column v-if="isColumnVisible('remark')" label="备注" align="center" header-align="center" prop="remark" min-width="200" show-overflow-tooltip />
<el-table-column label="操作" min-width="200" align="center" header-align="center" fixed="right">
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button v-if="hasOpportunityPerm('query')" class="btn-op-item" @click="handleDetail(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-button
v-if="hasOpportunityPerm('edit')"
class="btn-op-item primary"
:disabled="row.status === 'won' || row.status === 'lost'"
@click="handleUpdate(row)"
>
<el-icon><Edit /></el-icon>编辑
</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
@@ -402,46 +419,12 @@
<!-- 商机详情弹窗 -->
<OpportunityDetailDrawer v-model="detailVisible" :opportunity-id="currentId" @success="getList" />
<!-- 列设置对话框 -->
<el-dialog
v-model="columnSettingDialog.visible"
title="列设置"
width="600px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<div class="column-setting-content">
<el-checkbox v-model="checkAll" :indeterminate="isIndeterminate" @change="handleCheckAllChange">全选</el-checkbox>
<el-divider />
<el-checkbox-group v-model="visibleColumns" @change="handleCheckedColumnsChange">
<div v-for="col in tableColumns" :key="col.prop" class="column-item">
<el-checkbox :label="col.prop" :disabled="col.fixed">{{ col.label }}</el-checkbox>
</div>
</el-checkbox-group>
</div>
<template #footer>
<div class="dialog-footer">
<el-button class="btn-ghost" @click="columnSettingDialog.visible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button class="btn-ghost" @click="handleResetColumns">
<el-icon><RefreshLeft /></el-icon>恢复默认
</el-button>
<el-button class="btn-main" type="primary" @click="handleSaveColumnSetting">
<el-icon><Check /></el-icon>保存
</el-button>
</div>
</template>
</el-dialog>
</section>
</div>
</template>
<script setup lang="ts" name="CrmOpportunity">
import { ref, reactive, onMounted } from 'vue'
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { ElForm } from 'element-plus'
@@ -454,12 +437,12 @@ import {
User,
ArrowUpBold,
ArrowDownBold,
View,
Edit
Edit,
Delete
} from '@element-plus/icons-vue'
import { useDictStore } from '@/store/modules/dict'
import { useOpportunityPerm } from '@/hooks/business/useOpportunityPerm'
import { filterDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import { filterDeletableByCreator, canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import { fetchUserOptions } from '@/api/admin'
import { listOpportunity, listMyOpportunity, delOpportunity, assignOpportunity, exportOpportunity } from '@/api/crm/opportunity'
import type { OpportunityQuery, OpportunityVO } from '@/api/crm/opportunity/types'
@@ -471,6 +454,8 @@ import FollowDialog from './components/FollowDialog.vue'
import BatchUpdateStageDialog from './components/BatchUpdateStageDialog.vue'
import BatchUpdateDateDialog from './components/BatchUpdateDateDialog.vue'
import OpportunityDetailDrawer from './components/OpportunityDetailDrawer.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
const dictStore = useDictStore()
const router = useRouter()
@@ -481,127 +466,56 @@ const selectedRows = ref<OpportunityVO[]>([])
const exportLoading = ref(false)
const assignLoading = ref(false)
// 列配置相关
const COLUMN_STORAGE_KEY = 'crm_opportunity_visible_columns';
// 列配置
const listColumnDefs = [
{ prop: 'customerName', label: '客户名称' },
{ prop: 'contactName', label: '联系人' },
{ prop: 'amount', label: '商机金额' },
{ prop: 'stage', label: '商机阶段' },
{ prop: 'stagePercent', label: '阶段进度' },
{ prop: 'status', label: '商机状态' },
{ prop: 'winRate', label: '赢单率' },
{ prop: 'expectDealDate', label: '预计成交日期' },
{ prop: 'actualDealDate', label: '实际成交日期' },
{ prop: 'source', label: '商机来源' },
{ prop: 'type', label: '商机类型' },
{ prop: 'description', label: '商机描述' },
{ prop: 'competitors', label: '竞争对手' },
{ prop: 'keyFactors', label: '关键决策因素' },
{ prop: 'lostReason', label: '输单原因' },
{ prop: 'ownerUserName', label: '负责人' },
{ prop: 'productCount', label: '产品数量' },
{ prop: 'followCount', label: '跟进次数' },
{ prop: 'lastTime', label: '最后跟进时间' },
{ prop: 'nextTime', label: '下次联系时间' },
{ prop: 'stageChangeTime', label: '阶段变更时间' },
{ prop: 'createDeptName', label: '创建部门' },
{ prop: 'createByName', label: '创建人' },
{ prop: 'createTime', label: '创建时间' },
{ prop: 'updateByName', label: '更新人' },
{ prop: 'updateTime', label: '更新时间' },
{ prop: 'remark', label: '备注' }
]
// 定义所有列配置(顺序代表显示顺序)
const tableColumns = ref([
{ prop: 'customerName', label: '客户名称', fixed: false },
{ prop: 'contactName', label: '联系人', fixed: false },
{ prop: 'amount', label: '商机金额', fixed: false },
{ prop: 'stage', label: '商机阶段', fixed: false },
{ prop: 'stagePercent', label: '阶段进度', fixed: false },
{ prop: 'status', label: '商机状态', fixed: false },
{ prop: 'winRate', label: '赢单率', fixed: false },
{ prop: 'expectDealDate', label: '预计成交日期', fixed: false },
{ prop: 'actualDealDate', label: '实际成交日期', fixed: false },
{ prop: 'source', label: '商机来源', fixed: false },
{ prop: 'type', label: '商机类型', fixed: false },
{ prop: 'description', label: '商机描述', fixed: false },
{ prop: 'competitors', label: '竞争对手', fixed: false },
{ prop: 'keyFactors', label: '关键决策因素', fixed: false },
{ prop: 'lostReason', label: '输单原因', fixed: false },
{ prop: 'ownerUserName', label: '负责人', fixed: false },
{ prop: 'productCount', label: '产品数量', fixed: false },
{ prop: 'followCount', label: '跟进次数', fixed: false },
{ prop: 'lastTime', label: '最后跟进时间', fixed: false },
{ prop: 'nextTime', label: '下次联系时间', fixed: false },
{ prop: 'stageChangeTime', label: '阶段变更时间', fixed: false },
{ prop: 'createDeptName', label: '创建部门', fixed: false },
{ prop: 'createByName', label: '创建人', fixed: false },
{ prop: 'createTime', label: '创建时间', fixed: false },
{ prop: 'updateByName', label: '更新人', fixed: false },
{ prop: 'updateTime', label: '更新时间', fixed: false },
{ prop: 'remark', label: '备注', fixed: false }
]);
// 默认显示的列(现有字段)
const defaultVisibleColumns = [
'customerName',
'stage',
'stagePercent',
'winRate',
'expectDealDate',
'status',
'ownerUserName',
'followCount',
'createTime'
];
// 当前显示的列
const visibleColumns = ref<string[]>([]);
// 列设置对话框
const columnSettingDialog = reactive({
visible: false
});
// 全选状态
const checkAll = ref(false);
const isIndeterminate = ref(false);
/** 初始化列配置 */
const initColumnSetting = () => {
const savedColumns = localStorage.getItem(COLUMN_STORAGE_KEY);
if (savedColumns) {
try {
visibleColumns.value = JSON.parse(savedColumns);
} catch (error) {
console.error('解析列配置失败:', error);
visibleColumns.value = [...defaultVisibleColumns];
}
} else {
visibleColumns.value = [...defaultVisibleColumns];
}
updateCheckAllStatus();
};
/** 判断列是否显示 */
const isColumnVisible = (prop: string) => {
return visibleColumns.value.includes(prop);
};
/** 打开列设置对话框 */
const handleColumnSetting = () => {
columnSettingDialog.visible = true;
};
/** 全选/取消全选 */
const handleCheckAllChange = (val: boolean) => {
visibleColumns.value = val ? tableColumns.value.map((col) => col.prop) : [];
isIndeterminate.value = false;
};
/** 列选择变化 */
const handleCheckedColumnsChange = () => {
updateCheckAllStatus();
};
/** 更新全选状态 */
const updateCheckAllStatus = () => {
const checkedCount = visibleColumns.value.length;
const totalCount = tableColumns.value.length;
checkAll.value = checkedCount === totalCount;
isIndeterminate.value = checkedCount > 0 && checkedCount < totalCount;
};
/** 恢复默认列设置 */
const handleResetColumns = () => {
visibleColumns.value = [...defaultVisibleColumns];
updateCheckAllStatus();
};
/** 保存列设置 */
const handleSaveColumnSetting = () => {
try {
localStorage.setItem(COLUMN_STORAGE_KEY, JSON.stringify(visibleColumns.value));
ElMessage.success('列设置保存成功')
columnSettingDialog.visible = false;
} catch (error) {
console.error('保存列配置失败:', error);
ElMessage.error('保存列设置失败')
}
};
const {
visibleColumns,
checkAll,
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('crm_opportunity_visible_columns', listColumnDefs, {
defaultVisibleProps: [
'customerName',
'stage',
'stagePercent',
'winRate',
'expectDealDate',
'status',
'ownerUserName',
'followCount',
'createTime'
]
})
// 当前视图
const activeView = ref('mine');
@@ -612,7 +526,7 @@ const viewMode = ref<'list' | 'kanban'>('list');
// 查询参数
const queryParams = ref<OpportunityQuery>({
pageNum: 1,
pageSize: 10,
pageSize: 20,
opportunityName: undefined,
customerName: undefined,
stage: undefined,
@@ -1010,7 +924,6 @@ watchDebounced(
// 初始化加载数据
onMounted(async () => {
await loadPerms()
initColumnSetting();
getList();
});
</script>
@@ -1024,22 +937,4 @@ onMounted(async () => {
.quick-search-icon {
cursor: pointer;
}
.column-setting-content {
max-height: 400px;
overflow-y: auto;
.column-item {
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
:deep(.el-checkbox) {
width: 100%;
}
}
}
</style>
+273 -282
View File
@@ -1,20 +1,20 @@
<template>
<div class="app-container opportunity-kanban">
<!-- 查询筛选栏 -->
<el-card shadow="hover" class="mb-[10px]">
<el-form :model="queryParams" ref="queryFormRef" label-width="90px">
<el-row :gutter="20">
<el-col :xl="4" :lg="6" :md="8" :sm="12" :xs="24">
<div class="page-container opportunity-kanban-page">
<section class="panel kanban-panel">
<div class="panel-top">
<h2 class="title-bar-heading">商机看板</h2>
</div>
<div class="filter-block">
<el-form ref="queryFormRef" class="filter-form" :model="queryParams" @submit.prevent>
<div class="filter-grid">
<el-form-item label="负责人" prop="ownerUserId">
<el-select v-model="queryParams.ownerUserId" placeholder="请选择负责人" clearable style="width: 100%">
<!-- TODO: 从用户列表获取 -->
<el-select v-model="queryParams.ownerUserId" placeholder="请选择负责人" clearable>
<el-option label="全部" :value="undefined" />
</el-select>
</el-form-item>
</el-col>
<el-col :xl="4" :lg="6" :md="8" :sm="12" :xs="24">
<el-form-item label="商机来源" prop="source">
<el-select v-model="queryParams.source" placeholder="请选择商机来源" clearable style="width: 100%">
<el-select v-model="queryParams.source" placeholder="请选择商机来源" clearable>
<el-option label="全部" :value="undefined" />
<el-option label="电话咨询" value="phone" />
<el-option label="网络推广" value="online" />
@@ -24,78 +24,82 @@
<el-option label="其他" value="other" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="24" style="text-align: right">
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-col>
</el-row>
</el-form>
</el-card>
<!-- 看板区域 -->
<div class="kanban-container" v-loading="loading">
<div v-for="stage in stageColumns" :key="stage.key" class="kanban-column">
<!-- 列头 -->
<div class="column-header">
<div class="header-title">
<span class="stage-name">{{ stage.name }}</span>
<el-tag size="small" type="info">{{ stage.count }}</el-tag>
</div>
<div class="header-amount">¥{{ formatAmount(stage.totalAmount) }}</div>
</div>
<!-- 卡片列表 -->
<div class="column-content">
<div v-for="opp in stage.opportunities" :key="opp.id" class="opportunity-card" @click="handleViewDetail(opp.id)">
<div class="card-header">
<span class="card-title">{{ opp.opportunityName }}</span>
<el-dropdown trigger="click" @command="(cmd) => handleCardAction(cmd, opp)">
<el-icon class="card-more"><MoreFilled /></el-icon>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="edit">修改</el-dropdown-item>
<el-dropdown-item command="stage">推进阶段</el-dropdown-item>
<el-dropdown-item command="follow">添加跟进</el-dropdown-item>
<el-dropdown-item command="won" divided>标记赢单</el-dropdown-item>
<el-dropdown-item command="lost">标记输单</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<div class="card-info">
<div class="info-row">
<el-icon><User /></el-icon>
<span>{{ opp.customerName }}</span>
</div>
<div class="info-row amount">
<el-icon><Money /></el-icon>
<span>¥{{ formatAmount(opp.amount) }}</span>
</div>
<div class="info-row">
<el-icon><Calendar /></el-icon>
<span>{{ formatDate(opp.expectDealDate) }}</span>
</div>
</div>
<div class="card-footer">
<el-progress :percentage="opp.stagePercent" :stroke-width="4" :show-text="false" />
<span class="win-rate">赢单率: {{ opp.winRate }}%</span>
<div class="filter-actions">
<div class="filter-actions-end">
<el-button class="btn-ghost" :loading="loading" @click="resetQuery">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" :loading="loading" @click="handleQuery">
<el-icon><Search /></el-icon>查询
</el-button>
</div>
</div>
</el-form>
</div>
<!-- 空状态 -->
<div v-if="stage.opportunities.length === 0" class="column-empty">
<el-empty description="暂无商机" :image-size="80" />
<div class="kanban-container" v-loading="loading">
<div v-for="stage in stageColumns" :key="stage.key" class="kanban-column">
<div class="column-header">
<div class="header-title">
<span class="stage-name">{{ stage.name }}</span>
<el-tag size="small" type="info">{{ stage.count }}</el-tag>
</div>
<div class="header-amount">¥{{ formatAmount(stage.totalAmount) }}</div>
</div>
<div class="column-content">
<div
v-for="opp in stage.opportunities"
:key="opp.id"
class="opportunity-card"
@click="handleViewDetail(opp.id)"
>
<div class="card-header">
<span class="card-title">{{ opp.opportunityName }}</span>
<el-dropdown trigger="click" @command="(cmd) => handleCardAction(cmd, opp)">
<el-icon class="card-more" @click.stop><MoreFilled /></el-icon>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="edit">修改</el-dropdown-item>
<el-dropdown-item command="stage">推进阶段</el-dropdown-item>
<el-dropdown-item command="follow">添加跟进</el-dropdown-item>
<el-dropdown-item command="won" divided>标记赢单</el-dropdown-item>
<el-dropdown-item command="lost">标记输单</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<div class="card-info">
<div class="info-row">
<el-icon><User /></el-icon>
<span>{{ opp.customerName }}</span>
</div>
<div class="info-row amount">
<el-icon><Money /></el-icon>
<span>¥{{ formatAmount(opp.amount) }}</span>
</div>
<div class="info-row">
<el-icon><Calendar /></el-icon>
<span>{{ formatDate(opp.expectDealDate) }}</span>
</div>
</div>
<div class="card-footer">
<el-progress :percentage="opp.stagePercent" :stroke-width="4" :show-text="false" />
<span class="win-rate">赢单率: {{ opp.winRate }}%</span>
</div>
</div>
<div v-if="stage.opportunities.length === 0" class="column-empty">
<el-empty description="暂无商机" :image-size="80" />
</div>
</div>
</div>
</div>
</div>
</section>
<!-- 对话框组件 -->
<ChangeStageDialog ref="changeStageDialogRef" @success="loadKanbanData" />
<MarkWonDialog ref="markWonDialogRef" @success="loadKanbanData" />
<MarkLostDialog ref="markLostDialogRef" @success="loadKanbanData" />
@@ -104,7 +108,7 @@
</template>
<script setup lang="ts" name="OpportunityKanban">
import { ref, reactive, onMounted } from 'vue'
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useDictStore } from '@/store/modules/dict'
import { getKanbanData } from '@/api/crm/opportunity'
@@ -114,140 +118,123 @@ import MarkWonDialog from './components/MarkWonDialog.vue'
import MarkLostDialog from './components/MarkLostDialog.vue'
import FollowDialog from './components/FollowDialog.vue'
import { ElMessage } from 'element-plus'
import { MoreFilled, User, Money, Calendar } from '@element-plus/icons-vue'
import { MoreFilled, User, Money, Calendar, Search, RefreshLeft } from '@element-plus/icons-vue'
const dictStore = useDictStore()
const router = useRouter()
const loading = ref(false)
const queryFormRef = ref()
// 查询参数
const queryParams = ref<OpportunityQuery>({});
const queryParams = ref<OpportunityQuery>({})
// 阶段颜色配置(补充字典中没有的颜色信息)
const stageColors: Record<string, string> = {
initial: '#909399',
requirement: '#409EFF',
solution: '#67C23A',
quote: '#E6A23C',
negotiate: '#F56C6C',
contract: '#9C27B0'
};
// 看板列数据
interface StageColumn {
key: string;
name: string;
color: string;
count: number;
totalAmount: number;
opportunities: OpportunityVO[];
initial: '#64748b',
requirement: '#10b981',
solution: '#059669',
quote: '#d97706',
negotiate: '#047857',
contract: '#0d9488'
}
const stageColumns = ref<StageColumn[]>([]);
interface StageColumn {
key: string
name: string
color: string
count: number
totalAmount: number
opportunities: OpportunityVO[]
}
// 对话框引用
const changeStageDialogRef = ref();
const markWonDialogRef = ref();
const markLostDialogRef = ref();
const followDialogRef = ref();
const stageColumns = ref<StageColumn[]>([])
const changeStageDialogRef = ref()
const markWonDialogRef = ref()
const markLostDialogRef = ref()
const followDialogRef = ref()
/** 加载看板数据 */
const loadKanbanData = async () => {
loading.value = true;
loading.value = true
try {
const res = await getKanbanData(queryParams.value);
// 转换数据格式,使用字典数据
stageColumns.value = dictStore.getOptions('crm_opportunity_stage')
.filter((stage: any) => stage.value !== 'won') // 排除赢单阶段(已完成的商机)
const res = await getKanbanData(queryParams.value)
stageColumns.value = dictStore
.getOptions('crm_opportunity_stage')
.filter((stage: any) => stage.value !== 'won')
.map((stage: any) => {
const stageData = res.data[stage.value] || {};
const stageData = res.data[stage.value] || {}
return {
key: stage.value,
name: stage.label,
color: stageColors[stage.value] || '#909399',
color: stageColors[stage.value] || '#64748b',
count: stageData.count || 0,
totalAmount: stageData.totalAmount || 0,
opportunities: stageData.opportunities || []
};
});
}
})
} catch (error) {
console.error('加载看板数据失败:', error);
ElMessage.error('加载看板数据失败');
console.error('加载看板数据失败:', error)
ElMessage.error('加载看板数据失败')
} finally {
loading.value = false;
loading.value = false
}
};
}
/** 搜索按钮操作 */
const handleQuery = () => {
loadKanbanData();
};
loadKanbanData()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value?.resetFields();
loadKanbanData();
};
queryFormRef.value?.resetFields()
loadKanbanData()
}
/** 查看详情 */
const handleViewDetail = (id: number) => {
router.push({ path: '/crm/opportunity/detail', query: { id } });
};
router.push({ path: '/crm/opportunity/detail', query: { id } })
}
/** 卡片操作 */
const handleCardAction = (command: string, opp: OpportunityVO) => {
switch (command) {
case 'edit':
router.push({ path: '/crm/opportunity', query: { id: opp.id, action: 'edit' } });
break;
router.push({ path: '/crm/opportunity', query: { id: opp.id, action: 'edit' } })
break
case 'stage':
changeStageDialogRef.value?.open(opp.id);
break;
changeStageDialogRef.value?.open(opp.id)
break
case 'follow':
followDialogRef.value?.open(opp.id);
break;
followDialogRef.value?.open(opp.id)
break
case 'won':
markWonDialogRef.value?.open(opp.id);
break;
markWonDialogRef.value?.open(opp.id)
break
case 'lost':
markLostDialogRef.value?.open(opp.id);
break;
markLostDialogRef.value?.open(opp.id)
break
}
};
}
/** 格式化金额 */
const formatAmount = (amount: number) => {
if (!amount) return '0.00';
if (!amount) return '0.00'
return amount.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
};
})
}
/** 格式化日期 */
const formatDate = (date: string) => {
if (!date) return '-';
return date.split(' ')[0];
};
if (!date) return '-'
return date.split(' ')[0]
}
onMounted(() => {
loadKanbanData();
});
loadKanbanData()
})
</script>
<style scoped lang="scss">
.opportunity-kanban {
height: calc(100vh - 120px);
display: flex;
flex-direction: column;
.kanban-filter {
background: #fff;
padding: 16px;
margin-bottom: 16px;
border-radius: 4px;
.opportunity-kanban-page {
.kanban-panel {
display: flex;
flex-direction: column;
min-height: calc(100vh - 140px);
}
.kanban-container {
@@ -255,141 +242,145 @@ onMounted(() => {
display: flex;
gap: 16px;
overflow-x: auto;
padding-bottom: 16px;
padding: 16px;
min-height: 420px;
}
.kanban-column {
flex: 0 0 300px;
background: #f5f7fa;
border-radius: 4px;
.kanban-column {
flex: 0 0 300px;
background: var(--mint-row-hover, #f1f5f9);
border: 1px solid var(--mint-border, #d9e8e2);
border-radius: var(--mint-radius, 10px);
display: flex;
flex-direction: column;
min-height: 400px;
}
.column-header {
padding: 14px 16px;
background: var(--mint-surface, #fff);
border-radius: 10px 10px 0 0;
border-bottom: 1px solid var(--mint-border, #d9e8e2);
.header-title {
display: flex;
flex-direction: column;
height: fit-content;
min-height: 400px;
align-items: center;
gap: 8px;
margin-bottom: 8px;
.column-header {
padding: 16px;
background: #fff;
border-radius: 4px 4px 0 0;
border-bottom: 1px solid #ebeef5;
.stage-name {
font-size: 15px;
font-weight: 600;
color: var(--mint-text, #1e293b);
}
}
.header-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
.header-amount {
font-size: 14px;
color: var(--primary-dark, #059669);
font-weight: 600;
}
}
.stage-name {
font-size: 16px;
font-weight: 600;
color: #303133;
}
}
.column-content {
flex: 1;
padding: 12px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.header-amount {
font-size: 14px;
color: #67c23a;
font-weight: 600;
}
.opportunity-card {
background: var(--mint-surface, #fff);
border: 1px solid var(--mint-border, #d9e8e2);
border-radius: 8px;
padding: 12px;
cursor: pointer;
transition: box-shadow 0.2s ease, transform 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: var(--mint-shadow, 0 1px 3px rgba(0, 0, 0, 0.05));
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 12px;
.card-title {
font-size: 14px;
font-weight: 600;
color: var(--mint-text, #1e293b);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.column-content {
flex: 1;
padding: 12px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
.card-more {
font-size: 16px;
color: var(--mint-text-muted, #64748b);
cursor: pointer;
.opportunity-card {
background: #fff;
border-radius: 4px;
padding: 12px;
cursor: pointer;
transition: all 0.3s;
&:hover {
transform: translateY(-2px);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
.card-title {
font-size: 14px;
font-weight: 600;
color: #303133;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-more {
font-size: 16px;
color: #1e293b;
cursor: pointer;
&:hover {
color: #409eff;
}
}
}
.card-info {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
.info-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #606266;
.el-icon {
font-size: 14px;
color: #1e293b;
}
&.amount {
color: #67c23a;
font-weight: 600;
}
}
}
.card-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
:deep(.el-progress) {
flex: 1;
}
.win-rate {
font-size: 12px;
color: #1e293b;
white-space: nowrap;
}
}
}
.column-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
&:hover {
color: var(--primary, #10b981);
}
}
}
.card-info {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
.info-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--mint-text-muted, #64748b);
.el-icon {
font-size: 14px;
color: var(--mint-text-muted, #64748b);
}
&.amount {
color: var(--primary-dark, #059669);
font-weight: 600;
}
}
}
.card-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
:deep(.el-progress) {
flex: 1;
}
.win-rate {
font-size: 12px;
color: var(--mint-text-muted, #64748b);
white-space: nowrap;
}
}
}
.column-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
}
}
</style>
File diff suppressed because it is too large. Load diff
@@ -4,7 +4,7 @@
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2>产品分类</h2>
<h2 class="title-bar-heading">产品分类</h2>
</div>
<!-- 筛选区 -->
@@ -33,10 +33,10 @@
<div class="filter-actions-row">
<span class="filter-hint">按分类名称模糊查询。</span>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset">
<el-button class="btn-ghost" :loading="loading" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" @click="handleSearch">
<el-button class="btn-main" type="primary" :loading="loading" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
</div>
@@ -82,6 +82,8 @@
<el-icon><Delete /></el-icon>
删除
</el-button>
<ListToolbarEndActions :loading="loading" @refresh="getList" />
</div>
</div>
@@ -220,6 +222,7 @@
</template>
<script setup lang="ts">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import { ref, reactive, onMounted, nextTick, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance } from 'element-plus'
+115 -42
View File
@@ -4,7 +4,7 @@
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2>产品管理</h2>
<h2 class="title-bar-heading">产品管理</h2>
</div>
<!-- 筛选区 -->
@@ -43,10 +43,10 @@
<div class="filter-actions-row">
<span class="filter-hint">按产品名称、编号模糊查询。</span>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset">
<el-button class="btn-ghost" :loading="loading" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" @click="handleSearch">
<el-button class="btn-main" type="primary" :loading="loading" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
</div>
@@ -64,6 +64,24 @@
<el-icon><Plus /></el-icon>
新增产品
</el-button>
<el-button
class="btn-ghost"
:disabled="selectedRows.length !== 1"
@click="handleToolbarEdit"
>
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button
class="btn-ghost"
type="danger"
:disabled="selectedRows.length === 0 || !allRowsDeletableByCreator(selectedRows)"
:title="allRowsDeletableByCreator(selectedRows) ? '' : CREATOR_DELETE_TIP"
@click="handleDelete()"
>
<el-icon><Delete /></el-icon>
删除
</el-button>
<el-button
class="btn-ghost"
:disabled="selectedRows.length === 0"
@@ -88,6 +106,16 @@
<el-icon><Download /></el-icon>
导出
</el-button>
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="getList"
@reset-columns="resetToDefault"
/>
</div>
</div>
@@ -100,9 +128,11 @@
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
v-if="isColumnVisible('productNo')"
prop="productNo"
label="产品编号"
min-width="130"
@@ -140,6 +170,7 @@
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('categoryName')"
prop="categoryName"
label="产品分类"
min-width="120"
@@ -148,6 +179,7 @@
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('model')"
prop="model"
label="规格型号"
min-width="120"
@@ -156,6 +188,7 @@
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('unit')"
prop="unit"
label="单位"
min-width="80"
@@ -167,6 +200,7 @@
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('price')"
prop="price"
label="标准价(元)"
min-width="120"
@@ -178,6 +212,7 @@
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('cost')"
prop="cost"
label="成本价(元)"
min-width="120"
@@ -189,6 +224,7 @@
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('status')"
prop="status"
label="状态"
min-width="80"
@@ -204,36 +240,6 @@
</el-tag>
</template>
</el-table-column>
<el-table-column
label="操作"
min-width="160"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item primary" @click="handleUpdate(row)">
编辑
</el-button>
<el-dropdown trigger="hover" @command="(cmd: string) => handleMoreCommand(cmd, row)">
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="view">
<el-icon><View /></el-icon>查看
</el-dropdown-item>
<el-dropdown-item command="delete" divided :disabled="!canDeleteByCreator(row)">
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
</div>
@@ -403,6 +409,22 @@
modal-class="order-module-dialog-mask"
append-to-body
>
<template #hero-actions>
<div class="product-view-hero-actions">
<el-button class="btn-main" type="primary" @click="handleDetailEdit">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button
class="btn-ghost"
type="danger"
:disabled="!canDeleteByCreator(detailData)"
:title="canDeleteByCreator(detailData) ? '' : CREATOR_DELETE_TIP"
@click="handleDetailDelete"
>
<el-icon><Delete /></el-icon>删除
</el-button>
</div>
</template>
<template #images>
<div v-if="productImageList.length" class="product-images">
<ImagePreview
@@ -470,6 +492,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, UploadFile } from 'element-plus'
import {
Plus,
Edit,
Delete,
Close,
Check,
@@ -477,8 +500,6 @@ import {
RefreshLeft,
Download,
Upload,
View,
More,
Top,
Bottom,
Box
@@ -490,8 +511,15 @@ import SystemViewDialog, {
import { crmDictField, crmField, crmLongField, crmTagField } from '@/utils/crm/view-fields'
import { useDictStore } from '@/store/modules/dict'
import { formatMoneyDisplay } from '@/utils/money-format'
import { canDeleteByCreator, filterDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import {
canDeleteByCreator,
filterDeletableByCreator,
allRowsDeletableByCreator,
CREATOR_DELETE_TIP
} from '@/utils/creator-delete'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import {
fetchCategoryTree,
fetchProductList,
@@ -519,8 +547,27 @@ function getHazardFlagTagType(value: unknown) {
const productList = ref<any[]>([])
const categoryOptions = ref<any[]>([])
const loading = ref(false)
const listColumnDefs = [
{ prop: 'productNo', label: '产品编号' },
{ prop: 'categoryName', label: '产品分类' },
{ prop: 'model', label: '规格型号' },
{ prop: 'unit', label: '单位' },
{ prop: 'price', label: '标准价(元)' },
{ prop: 'cost', label: '成本价(元)', defaultVisible: false },
{ prop: 'status', label: '状态' }
]
const {
visibleColumns,
checkAll,
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('crm-product-list-columns', listColumnDefs)
const selectedRows = ref<any[]>([])
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const pagination = reactive({ page: 1, pageSize: 20, total: 0 })
const searchForm = reactive({
keyword: '',
@@ -691,6 +738,25 @@ function handleAdd() {
formDialogVisible.value = true
}
function handleToolbarEdit() {
if (selectedRows.value.length !== 1) {
ElMessage.warning('请选择一条数据进行编辑')
return
}
handleUpdate(selectedRows.value[0])
}
async function handleDetailEdit() {
if (!detailData.value) return
await handleUpdate(detailData.value)
}
async function handleDetailDelete() {
if (!detailData.value) return
await handleDelete(detailData.value)
detailDialogVisible.value = false
}
async function handleUpdate(row: any) {
isEdit.value = true
resetFormData()
@@ -726,11 +792,6 @@ async function handleView(row: any) {
detailDialogVisible.value = true
}
function handleMoreCommand(cmd: string, row: any) {
if (cmd === 'view') handleView(row)
else if (cmd === 'delete') handleDelete(row)
}
async function handleDelete(row?: any) {
if (!row && selectedRows.value.length === 0) {
ElMessage.warning('请至少选择一条数据进行删除')
@@ -816,6 +877,10 @@ async function submitForm() {
}
formDialogVisible.value = false
await getList()
if (detailDialogVisible.value && formData.id) {
const res = await fetchProductDetail({ id: formData.id })
detailData.value = (res as any)?.data || res || null
}
} finally {
submitLoading.value = false
}
@@ -958,4 +1023,12 @@ onMounted(() => {
flex-wrap: wrap;
gap: 8px;
}
.product-view-hero-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
</style>
@@ -171,7 +171,7 @@ watch(dialogVisible, (val) => {
// 查询参数
const queryParams = ref({
pageNum: 1,
pageSize: 10,
pageSize: 20,
productName: undefined,
categoryId: undefined,
status: 'on' // on-上架 off-下架
+1 -1
View File
@@ -29,7 +29,7 @@
<p v-if="quote?.quoteNo" class="detail-subtitle">{{ quote.quoteNo }} — {{ quote.quoteTitle }}</p>
<el-tabs v-model="activeTab" class="panel-tabs">
<el-tabs v-model="activeTab" class="panel-pill-tabs panel-pill-tabs--lg">
<!-- 基本信息 -->
<el-tab-pane label="基本信息" name="basic">
<el-descriptions :column="2" border>
+92 -200
View File
@@ -5,14 +5,16 @@
<h2 class="title-bar-heading">报价单管理</h2>
</div>
<div class="panel-tabs">
<el-tabs v-model="activeView" @tab-change="handleViewChange">
<el-tab-pane label="我的报价" name="my" />
<el-tab-pane label="全部报价" name="all" />
<el-tab-pane label="待审批" name="submitted" />
<el-tab-pane label="已审批" name="accepted" />
</el-tabs>
</div>
<el-tabs
v-model="activeView"
class="panel-pill-tabs panel-pill-tabs--lg"
@tab-change="handleViewChange"
>
<el-tab-pane label="我的报价" name="my" />
<el-tab-pane label="全部报价" name="all" />
<el-tab-pane label="待审批" name="submitted" />
<el-tab-pane label="已审批" name="accepted" />
</el-tabs>
<div class="filter-block">
<el-form ref="queryFormRef" :model="queryParams" class="filter-form" @submit.prevent>
@@ -139,6 +141,24 @@
<el-button v-if="hasQuotePerm('add')" class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>新增
</el-button>
<el-button
v-if="hasQuotePerm('edit')"
class="btn-ghost"
:disabled="single || selectedRows[0]?.status !== 'draft'"
@click="handleUpdate()"
>
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button
v-if="hasQuotePerm('remove')"
class="btn-ghost"
type="danger"
:disabled="single || selectedRows[0]?.status !== 'draft' || !canDeleteByCreator(selectedRows[0])"
:title="canDeleteByCreator(selectedRows[0]) ? '' : CREATOR_DELETE_TIP"
@click="handleDelete()"
>
<el-icon><Delete /></el-icon>删除
</el-button>
<el-button v-if="hasQuotePerm('add')" class="btn-ghost" :disabled="single" @click="handleCopyAdd()">
<el-icon><DocumentCopy /></el-icon>复制新增
</el-button>
@@ -164,12 +184,16 @@
<el-button v-if="hasQuotePerm('edit')" class="btn-ghost" :disabled="multiple" @click="handleInvalid()">
<el-icon><CircleClose /></el-icon>作废
</el-button>
<el-button class="btn-ghost" @click="handleColumnSetting">
<el-icon><Setting /></el-icon>列设置
</el-button>
<el-button class="btn-ghost" :loading="loading" @click="getList">
<el-icon><Refresh /></el-icon>刷新
</el-button>
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="getList"
@reset-columns="resetToDefault"
/>
</div>
</div>
@@ -399,23 +423,6 @@
show-overflow-tooltip
/>
<el-table-column v-if="isColumnVisible('remark')" label="备注" align="center" prop="remark" min-width="200" show-overflow-tooltip />
<el-table-column label="操作" min-width="260" align="center" header-align="center" fixed="right">
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button v-if="hasQuotePerm('query')" class="btn-op-item" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-button
v-if="hasQuotePerm('edit')"
class="btn-op-item primary"
:disabled="row.status !== 'draft'"
@click="handleUpdate(row)"
>
<el-icon><Edit /></el-icon>编辑
</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
@@ -714,38 +721,6 @@
@edit="handleEditFromDetail"
/>
<!-- 列设置对话框 -->
<el-dialog
v-model="columnSettingDialog.visible"
title="列设置"
width="600px"
align-center
append-to-body
class="dialog-form-layout"
modal-class="order-module-dialog-mask"
>
<div class="column-setting-content">
<el-checkbox v-model="checkAll" :indeterminate="isIndeterminate" @change="handleCheckAllChange">全选</el-checkbox>
<el-divider />
<el-checkbox-group v-model="visibleColumns" @change="handleCheckedColumnsChange">
<div v-for="col in tableColumns" :key="col.prop" class="column-item">
<el-checkbox :label="col.prop" :disabled="col.fixed">{{ col.label }}</el-checkbox>
</div>
</el-checkbox-group>
</div>
<template #footer>
<el-button class="btn-ghost" @click="handleResetColumns">
<el-icon><RefreshLeft /></el-icon>恢复默认
</el-button>
<el-button @click="columnSettingDialog.visible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button type="primary" @click="handleSaveColumnSetting">
<el-icon><Check /></el-icon>保存
</el-button>
</template>
</el-dialog>
<QuoteFormDialog ref="quoteFormDialogRef" @success="getList" />
</div>
</template>
@@ -767,12 +742,9 @@ import {
Edit,
Plus,
Printer,
Refresh,
RefreshLeft,
Search,
Select,
Setting,
View
Select
} from '@element-plus/icons-vue'
import { useDictStore } from '@/store/modules/dict'
const dictStore = useDictStore()
@@ -816,9 +788,11 @@ import html2canvas from 'html2canvas';
import jsPDF from 'jspdf';
import { useQuotePerm } from '@/hooks/business/useQuotePerm'
import { filterDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import { filterDeletableByCreator, canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import QuoteFormDialog from './components/QuoteFormDialog.vue'
import QuoteDetailDialog from './components/QuoteDetailDialog.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
const { loadPerms, hasQuotePerm } = useQuotePerm()
const router = useRouter()
@@ -874,117 +848,55 @@ const getApprovalTimelineColor = (result?: string) => {
return '#94a3b8';
};
// 列配置相关
const COLUMN_STORAGE_KEY = 'crm_quote_visible_columns';
// 列配置
const listColumnDefs = [
{ prop: 'quoteType', label: '报价类型' },
{ prop: 'customerName', label: '客户名称' },
{ prop: 'opportunityName', label: '商机名称' },
{ prop: 'contactName', label: '联系人' },
{ prop: 'contactMobile', label: '联系人手机' },
{ prop: 'contactEmail', label: '联系人邮箱' },
{ prop: 'version', label: '版本号' },
{ prop: 'totalAmount', label: '总金额' },
{ prop: 'discountRate', label: '折扣率' },
{ prop: 'discountAmount', label: '折扣金额' },
{ prop: 'finalAmount', label: '最终金额' },
{ prop: 'status', label: '状态' },
{ prop: 'validityDays', label: '有效期' },
{ prop: 'quoteDate', label: '报价日期' },
{ prop: 'expireDate', label: '到期日期' },
{ prop: 'rejectReason', label: '拒绝原因' },
{ prop: 'viewCount', label: '查看次数' },
{ prop: 'lastViewTime', label: '最后查看时间' },
{ prop: 'ownerUserName', label: '负责人' },
{ prop: 'paymentTerms', label: '付款条款' },
{ prop: 'deliveryTerms', label: '交付条款' },
{ prop: 'createDeptName', label: '创建部门' },
{ prop: 'createByName', label: '创建人' },
{ prop: 'createTime', label: '创建时间' },
{ prop: 'updateByName', label: '更新人' },
{ prop: 'updateTime', label: '更新时间' },
{ prop: 'remark', label: '备注' }
]
// 定义所有列配置(顺序代表显示顺序)
const tableColumns = ref([
{ prop: 'quoteType', label: '报价类型', fixed: false },
{ prop: 'customerName', label: '客户名称', fixed: false },
{ prop: 'opportunityName', label: '商机名称', fixed: false },
{ prop: 'contactName', label: '联系人', fixed: false },
{ prop: 'contactMobile', label: '联系人手机', fixed: false },
{ prop: 'contactEmail', label: '联系人邮箱', fixed: false },
{ prop: 'version', label: '版本号', fixed: false },
{ prop: 'totalAmount', label: '总金额', fixed: false },
{ prop: 'discountRate', label: '折扣率', fixed: false },
{ prop: 'discountAmount', label: '折扣金额', fixed: false },
{ prop: 'finalAmount', label: '最终金额', fixed: false },
{ prop: 'status', label: '状态', fixed: false },
{ prop: 'validityDays', label: '有效期', fixed: false },
{ prop: 'quoteDate', label: '报价日期', fixed: false },
{ prop: 'expireDate', label: '到期日期', fixed: false },
{ prop: 'rejectReason', label: '拒绝原因', fixed: false },
{ prop: 'viewCount', label: '查看次数', fixed: false },
{ prop: 'lastViewTime', label: '最后查看时间', fixed: false },
{ prop: 'ownerUserName', label: '负责人', fixed: false },
{ prop: 'paymentTerms', label: '付款条款', fixed: false },
{ prop: 'deliveryTerms', label: '交付条款', fixed: false },
{ prop: 'createDeptName', label: '创建部门', fixed: false },
{ prop: 'createByName', label: '创建人', fixed: false },
{ prop: 'createTime', label: '创建时间', fixed: false },
{ prop: 'updateByName', label: '更新人', fixed: false },
{ prop: 'updateTime', label: '更新时间', fixed: false },
{ prop: 'remark', label: '备注', fixed: false }
]);
// 默认显示的列(现有字段)
const defaultVisibleColumns = ['quoteType', 'customerName', 'finalAmount', 'status', 'validityDays', 'ownerUserName', 'quoteDate', 'createTime'];
// 当前显示的列
const visibleColumns = ref<string[]>([]);
// 列设置对话框
const columnSettingDialog = reactive({
visible: false
});
// 全选状态
const checkAll = ref(false);
const isIndeterminate = ref(false);
/** 初始化列配置 */
const initColumnSetting = () => {
const savedColumns = localStorage.getItem(COLUMN_STORAGE_KEY);
if (savedColumns) {
try {
visibleColumns.value = JSON.parse(savedColumns);
} catch (error) {
console.error('解析列配置失败:', error);
visibleColumns.value = [...defaultVisibleColumns];
}
} else {
visibleColumns.value = [...defaultVisibleColumns];
}
updateCheckAllStatus();
};
/** 判断列是否显示 */
const isColumnVisible = (prop: string) => {
return visibleColumns.value.includes(prop);
};
/** 打开列设置对话框 */
const handleColumnSetting = () => {
columnSettingDialog.visible = true;
};
/** 全选/取消全选 */
const handleCheckAllChange = (val: boolean) => {
visibleColumns.value = val ? tableColumns.value.map((col) => col.prop) : [];
isIndeterminate.value = false;
};
/** 列选择变化 */
const handleCheckedColumnsChange = () => {
updateCheckAllStatus();
};
/** 更新全选状态 */
const updateCheckAllStatus = () => {
const checkedCount = visibleColumns.value.length;
const totalCount = tableColumns.value.length;
checkAll.value = checkedCount === totalCount;
isIndeterminate.value = checkedCount > 0 && checkedCount < totalCount;
};
/** 恢复默认列设置 */
const handleResetColumns = () => {
visibleColumns.value = [...defaultVisibleColumns];
updateCheckAllStatus();
};
/** 保存列设置 */
const handleSaveColumnSetting = () => {
try {
localStorage.setItem(COLUMN_STORAGE_KEY, JSON.stringify(visibleColumns.value));
$modal.msgSuccess('列设置保存成功');
columnSettingDialog.visible = false;
} catch (error) {
console.error('保存列配置失败:', error);
$modal.msgError('保存列设置失败');
}
};
const {
visibleColumns,
checkAll,
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('crm_quote_visible_columns', listColumnDefs, {
defaultVisibleProps: [
'quoteType',
'customerName',
'finalAmount',
'status',
'validityDays',
'ownerUserName',
'quoteDate',
'createTime'
]
})
type QuoteViewName = 'my' | 'all' | 'submitted' | 'accepted'
@@ -998,7 +910,7 @@ interface TabFilterState {
const createDefaultTabFilter = (): TabFilterState => ({
queryParams: {
pageNum: 1,
pageSize: 10,
pageSize: 20,
keyword: undefined,
quoteNo: undefined,
quoteTitle: undefined,
@@ -1023,7 +935,7 @@ const tabFilterMap = reactive<Record<QuoteViewName, TabFilterState>>({
// 查询参数(当前 tab 的检索条件)
const queryParams = ref<QuoteQuery>({
pageNum: 1,
pageSize: 10,
pageSize: 20,
keyword: undefined,
quoteNo: undefined,
quoteTitle: undefined,
@@ -2514,7 +2426,6 @@ watchDebounced(
onMounted(() => {
loadPerms()
initColumnSetting();
getList();
});
</script>
@@ -2780,25 +2691,6 @@ onMounted(() => {
min-height: 32px !important;
}
/* 列设置对话框样式 */
.column-setting-content {
max-height: 400px;
overflow-y: auto;
.column-item {
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
:deep(.el-checkbox) {
width: 100%;
}
}
}
.quote-product-expand {
padding: 12px 16px;
background-color: var(--mint-table-head, #f8fafc);
+26 -12
View File
@@ -1,14 +1,16 @@
<!-- 精简工作台:无业务统计接口,仅作入口与 UI 规范说明 -->
<!-- Demo 工作台:Fresh Mint 模板页入口 -->
<template>
<div class="console-container console-lite">
<section class="panel">
<div class="panel-top">
<h2>快速入口</h2>
<h2 class="title-bar-heading">Demo 模板页</h2>
</div>
<div class="console-lite__body">
<p class="console-lite__hint"
>详见仓库根目录 `.cursorrules` 中「页面级 UI 模板(固定五页)」。</p
>
<p class="console-lite__hint">
以下页面为 Fresh Mint UI 参考模板,数据为本地 Mock,不影响业务模块。详见
<code>docs/web-admin-ui-spec-fresh-mint.md</code> 与
<code>docs/web-admin-ui-pattern-index.md</code>。
</p>
<div class="console-lite__links">
<el-button
v-for="item in links"
@@ -17,6 +19,7 @@
type="primary"
@click="go(item.path)"
>
<el-icon><Link /></el-icon>
{{ item.label }}
</el-button>
</div>
@@ -27,17 +30,18 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { Link } from '@element-plus/icons-vue'
defineOptions({ name: 'Console' })
defineOptions({ name: 'DemoConsole' })
const router = useRouter()
const links = [
{ label: '商品单位', path: '/system/unit' },
{ label: '用户管理', path: '/system/user' },
{ label: '商品分类', path: '/system/category' },
{ label: '租户管理', path: '/system/tenant' },
{ label: '客户管理', path: '/basic-info/customer' }
{ label: '标准列表(单位)', path: '/demo/unit' },
{ label: '复杂列表(订单)', path: '/demo/order' },
{ label: '商品列表', path: '/demo/product' },
{ label: '树形主数据(分类)', path: '/demo/category' },
{ label: '用户 CRUD', path: '/demo/user' }
]
const go = (path: string) => {
@@ -54,12 +58,22 @@
margin: 0 0 16px;
font-size: 13px;
line-height: 1.5;
color: #1e293b;
color: var(--mint-text-muted, #64748b);
code {
font-size: 12px;
}
}
.console-lite__links {
display: flex;
flex-wrap: wrap;
gap: 12px;
.el-button {
:deep(.el-icon) {
margin-right: 6px;
}
}
}
</style>
+175 -55
View File
@@ -139,14 +139,65 @@
<el-icon><Plus /></el-icon>
新增记录
</el-button>
<el-button
class="btn-ghost"
:disabled="selectedRows.length !== 1"
@click="handleToolbarEdit"
>
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button
v-if="selectedRows.length === 1 && selectedRows[0]?.purchasePlanId"
class="btn-ghost"
:disabled="selectedRows[0]?.hasWeighingSlip"
@click="handleGenerateWeighing(selectedRows[0])"
>
<el-icon><Plus /></el-icon>
{{ selectedRows[0]?.hasWeighingSlip ? '已生成地磅单' : '生成地磅单' }}
</el-button>
<el-button
v-if="selectedRows.length === 1 && selectedRows[0]?.purchasePlanId"
class="btn-ghost"
@click="handleCopyFillLink(selectedRows[0])"
>
<el-icon><Link /></el-icon>
磅单二维码
</el-button>
<el-button
class="btn-ghost"
type="danger"
:disabled="
selectedRows.length !== 1 ||
!canDeleteRecord(selectedRows[0]) ||
!canDeleteByCreator(selectedRows[0])
"
:title="
selectedRows.length === 1 && !canDeleteRecord(selectedRows[0])
? '仅支持删除当天及之后日期的记录'
: selectedRows.length === 1 && !canDeleteByCreator(selectedRows[0])
? CREATOR_DELETE_TIP
: ''
"
@click="handleToolbarDelete"
>
<el-icon><Delete /></el-icon>
删除
</el-button>
<el-button class="btn-ghost" :loading="exportLoading" @click="handleExport">
<el-icon><Download /></el-icon>
导出 Excel
</el-button>
<el-button class="btn-ghost" :loading="loading" @click="loadData">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="loadData"
@reset-columns="resetToDefault"
/>
</div>
</div>
@@ -161,10 +212,12 @@
:summary-method="getSummary"
@sort-change="handleSortChange"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" />
<el-table-column type="index" label="序号" min-width="60" align="center" header-align="center" />
<el-table-column
v-if="isColumnVisible('id')"
prop="id"
label="ID"
min-width="100"
@@ -175,6 +228,7 @@
:sort-order="getSortOrder('id')"
/>
<el-table-column
v-if="isColumnVisible('planNo')"
prop="planNo"
label="计划号"
min-width="140"
@@ -189,8 +243,13 @@
show-overflow-tooltip
sortable="custom"
:sort-order="getSortOrder('batchNo')"
/>
>
<template #default="{ row }">
<el-link type="primary" @click="handleView(row)">{{ row.batchNo }}</el-link>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('date')"
prop="date"
label="日期"
min-width="120"
@@ -198,6 +257,7 @@
:sort-order="getSortOrder('date')"
/>
<el-table-column
v-if="isColumnVisible('partnerCompany')"
prop="partnerCompany"
label="往来单位"
min-width="150"
@@ -206,6 +266,7 @@
:sort-order="getSortOrder('partnerCompany')"
/>
<el-table-column
v-if="isColumnVisible('businessType')"
prop="businessType"
label="业务类型"
min-width="100"
@@ -219,6 +280,7 @@
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('status')"
prop="status"
label="状态"
min-width="110"
@@ -232,6 +294,7 @@
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('product')"
prop="product"
label="产品"
min-width="120"
@@ -240,6 +303,7 @@
:sort-order="getSortOrder('product')"
/>
<el-table-column
v-if="isColumnVisible('specification')"
prop="specification"
label="规格"
min-width="120"
@@ -248,6 +312,7 @@
:sort-order="getSortOrder('specification')"
/>
<el-table-column
v-if="isColumnVisible('vehicleNo')"
prop="vehicleNo"
label="车牌号"
min-width="120"
@@ -255,6 +320,7 @@
:sort-order="getSortOrder('vehicleNo')"
/>
<el-table-column
v-if="isColumnVisible('quantity')"
prop="quantity"
label="数量(吨)"
min-width="110"
@@ -262,6 +328,7 @@
:sort-order="getSortOrder('quantity')"
/>
<el-table-column
v-if="isColumnVisible('netWeight')"
prop="netWeight"
label="净重(吨)"
min-width="110"
@@ -273,6 +340,7 @@
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('grossWeight')"
prop="grossWeight"
label="毛重(吨)"
min-width="110"
@@ -284,6 +352,7 @@
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('grossWeightTime')"
prop="grossWeightTime"
label="毛重时间"
min-width="160"
@@ -295,6 +364,7 @@
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('tareWeight')"
prop="tareWeight"
label="皮重(吨)"
min-width="110"
@@ -306,6 +376,7 @@
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('tareWeightTime')"
prop="tareWeightTime"
label="皮重时间"
min-width="160"
@@ -317,6 +388,7 @@
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('deduction')"
prop="deduction"
label="扣杂(吨)"
min-width="110"
@@ -328,6 +400,7 @@
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('remarks')"
prop="remarks"
label="备注"
min-width="150"
@@ -335,52 +408,6 @@
sortable="custom"
:sort-order="getSortOrder('remarks')"
/>
<el-table-column
label="操作"
min-width="520"
align="center"
header-align="center"
fixed="right"
>
<template #default="{ row }">
<div class="db-op-actions">
<el-button class="btn-op-item" @click="handleView(row)">
<el-icon><View /></el-icon>
查看
</el-button>
<el-button class="btn-op-item primary" @click="handleEdit(row)">
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button
v-if="row.purchasePlanId"
class="btn-op-item"
:disabled="row.hasWeighingSlip"
@click="handleGenerateWeighing(row)"
>
<el-icon><Plus /></el-icon>
{{ row.hasWeighingSlip ? '已生成地磅单' : '生成地磅单' }}
</el-button>
<el-button
v-if="row.purchasePlanId"
class="btn-op-item"
@click="handleCopyFillLink(row)"
>
<el-icon><Link /></el-icon>
磅单二维码
</el-button>
<el-button
class="btn-op-item danger"
:disabled="!canDeleteRecord(row) || !canDeleteByCreator(row)"
:title="!canDeleteRecord(row) ? '仅支持删除当天及之后日期的记录' : canDeleteByCreator(row) ? '' : CREATOR_DELETE_TIP"
@click="handleDelete(row)"
>
<el-icon><Delete /></el-icon>
删除
</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
@@ -485,7 +512,47 @@
v-model:visible="relatedModalOpen"
:loading="loadingRelated"
:data="relatedData"
/>
>
<template v-if="viewingRecord" #hero-actions>
<el-button class="btn-main" type="primary" @click="handleEdit(viewingRecord)">
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button
v-if="viewingRecord.purchasePlanId"
class="btn-ghost"
:disabled="viewingRecord.hasWeighingSlip"
@click="handleGenerateWeighing(viewingRecord)"
>
<el-icon><Plus /></el-icon>
{{ viewingRecord.hasWeighingSlip ? '已生成地磅单' : '生成地磅单' }}
</el-button>
<el-button
v-if="viewingRecord.purchasePlanId"
class="btn-ghost"
@click="handleCopyFillLink(viewingRecord)"
>
<el-icon><Link /></el-icon>
磅单二维码
</el-button>
<el-button
class="btn-ghost"
type="danger"
:disabled="!canDeleteRecord(viewingRecord) || !canDeleteByCreator(viewingRecord)"
:title="
!canDeleteRecord(viewingRecord)
? '仅支持删除当天及之后日期的记录'
: canDeleteByCreator(viewingRecord)
? ''
: CREATOR_DELETE_TIP
"
@click="handleViewDelete"
>
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</DbInventoryRelatedViewDialog>
<DbPoundSlipQrcodeDialog v-model:visible="qrcodeModalOpen" :inventory-id="qrcodeInventoryId" />
</div>
@@ -508,12 +575,12 @@
Edit,
Link,
Plus,
Refresh,
RefreshLeft,
Search,
View
} from '@element-plus/icons-vue'
import DbCrmCustomerSelect from '@/components/business/db/DbCrmCustomerSelect.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import DbCrmProductSelect from '@/components/business/db/DbCrmProductSelect.vue'
import DbInventoryRelatedViewDialog from '@/components/business/db/DbInventoryRelatedViewDialog.vue'
@@ -535,13 +602,42 @@
const router = useRouter()
const loading = ref(false)
const listColumnDefs = [
{ prop: 'id', label: 'ID', defaultVisible: false },
{ prop: 'planNo', label: '计划号' },
{ prop: 'date', label: '日期' },
{ prop: 'partnerCompany', label: '往来单位' },
{ prop: 'businessType', label: '业务类型' },
{ prop: 'status', label: '状态' },
{ prop: 'product', label: '产品' },
{ prop: 'specification', label: '规格' },
{ prop: 'vehicleNo', label: '车牌号' },
{ prop: 'quantity', label: '数量(吨)' },
{ prop: 'netWeight', label: '净重(吨)' },
{ prop: 'grossWeight', label: '毛重(吨)' },
{ prop: 'grossWeightTime', label: '毛重时间' },
{ prop: 'tareWeight', label: '皮重(吨)' },
{ prop: 'tareWeightTime', label: '皮重时间' },
{ prop: 'deduction', label: '扣杂(吨)' },
{ prop: 'remarks', label: '备注' }
]
const {
visibleColumns,
checkAll,
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('db-inventory-list-columns', listColumnDefs)
const exportLoading = ref(false)
const saving = ref(false)
const tableData = ref<InventoryRecord[]>([])
const allRecords = ref<InventoryRecord[]>([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(10)
const pageSize = ref(20)
const activeDateShortcut = ref<'all' | 'today' | 'tomorrow' | 'yesterday' | 'week' | ''>('today')
const searchKeyword = ref('')
const selectedRows = ref<InventoryRecord[]>([])
@@ -559,6 +655,7 @@
const relatedModalOpen = ref(false)
const loadingRelated = ref(false)
const relatedData = ref<InventoryRelatedData>({ plan: null, weighing: null })
const viewingRecord = ref<InventoryRecord | null>(null)
const filters = reactive({
batchNo: '',
@@ -845,6 +942,7 @@
}
const handleView = async (record: InventoryRecord) => {
viewingRecord.value = record
loadingRelated.value = true
try {
relatedData.value = await inventoryApi.getRelated(record.id)
@@ -856,6 +954,28 @@
}
}
function handleToolbarEdit() {
if (selectedRows.value.length !== 1) {
$modal.msgWarning('请先勾选一条记录')
return
}
handleEdit(selectedRows.value[0])
}
function handleToolbarDelete() {
if (selectedRows.value.length !== 1) {
$modal.msgWarning('请先勾选一条记录')
return
}
handleDelete(selectedRows.value[0])
}
async function handleViewDelete() {
if (!viewingRecord.value) return
await handleDelete(viewingRecord.value)
relatedModalOpen.value = false
}
const handleEdit = (record: InventoryRecord) => {
editingRecord.value = record
Object.assign(formData, {
Loaded 100 of 249 files, more files were not shown because too many files have changed in this diff. Show more