This commit is contained in:
Mr.j 2025-08-11 17:10:58 +08:00
commit 33eb03121d
10 changed files with 1377 additions and 227 deletions

View File

@ -156,14 +156,14 @@ export interface ProjectDetailResp extends ProjectCard {
export interface TeamMemberResp { export interface TeamMemberResp {
id: string | number id: string | number
name: string name: string
position: string roleType: string
phone?: string phone?: string
email?: string email?: string
avatar?: string avatar?: string
joinDate?: string joinDate?: string
performance?: number performance?: number
remark?: string remark?: string
status?: 'available' | 'busy' | 'offline' status?: 'ACTIVE' | 'SUSPENDED' | 'INACTIVE'
} }
/** 后端返回的团队成员数据结构 */ /** 后端返回的团队成员数据结构 */
@ -182,7 +182,7 @@ export interface BackendTeamMemberResp {
phone: string | null phone: string | null
email: string | null email: string | null
position: string position: string
status: 'ACTIVE' | 'BUSY' | 'OFFLINE' status: 'ACTIVE' | 'SUSPENDED' | 'INACTIVE'
skills: string skills: string
joinDate: string joinDate: string
remark: string remark: string
@ -201,11 +201,11 @@ export interface BackendTeamMemberResp {
export interface TeamMemberQuery extends PageQuery { export interface TeamMemberQuery extends PageQuery {
projectId: string | number projectId: string | number
name?: string // 姓名搜索 name?: string // 姓名搜索
position?: string // 岗位筛选 position?: string // 项目岗位筛选
status?: string // 状态筛选 status?: string // 状态筛选
joinDateStart?: string // 入职日期开始 joinDateStart?: string // 入职日期开始
joinDateEnd?: string // 入职日期结束 joinDateEnd?: string // 入职日期结束
sortBy?: 'name' | 'position' | 'joinDate' | 'status' // 排序字段 sortBy?: 'name' | 'roleType' | 'joinDate' | 'status' // 排序字段
sortOrder?: 'asc' | 'desc' // 排序方向 sortOrder?: 'asc' | 'desc' // 排序方向
} }
@ -213,33 +213,33 @@ export interface TeamMemberQuery extends PageQuery {
export interface TeamMemberExportQuery { export interface TeamMemberExportQuery {
projectId: string | number projectId: string | number
name?: string // 姓名搜索 name?: string // 姓名搜索
position?: string // 岗位筛选 position?: string // 项目岗位筛选
status?: string // 状态筛选 status?: string // 状态筛选
joinDateStart?: string // 入职日期开始 joinDateStart?: string // 入职日期开始
joinDateEnd?: string // 入职日期结束 joinDateEnd?: string // 入职日期结束
sortBy?: 'name' | 'position' | 'joinDate' | 'status' // 排序字段 sortBy?: 'name' | 'roleType' | 'joinDate' | 'status' // 排序字段
sortOrder?: 'asc' | 'desc' // 排序方向 sortOrder?: 'asc' | 'desc' // 排序方向
} }
/** 创建团队成员表单 */ /** 创建团队成员表单 */
export interface CreateTeamMemberForm { export interface CreateTeamMemberForm {
projectId: string | number projectId: string | number
roleType: string // 项目岗位
name: string name: string
phone: string phone: string
email?: string email?: string
position: string status?: 'ACTIVE' | 'SUSPENDED' | 'INACTIVE'
status?: 'available' | 'busy' | 'offline'
joinDate?: string joinDate?: string
remark?: string remark?: string
} }
/** 更新团队成员表单 */ /** 更新团队成员表单 */
export interface UpdateTeamMemberForm { export interface UpdateTeamMemberForm {
roleType?: string // 项目岗位
name?: string name?: string
phone?: string phone?: string
email?: string email?: string
position?: string status?: 'ACTIVE' | 'SUSPENDED' | 'INACTIVE'
status?: 'available' | 'busy' | 'offline'
joinDate?: string joinDate?: string
remark?: string remark?: string
} }
@ -250,7 +250,7 @@ export interface UpdateTeamMemberForm {
export interface BatchOperationForm { export interface BatchOperationForm {
ids: (string | number)[] ids: (string | number)[]
operation: 'delete' | 'updateStatus' operation: 'delete' | 'updateStatus'
status?: 'available' | 'busy' | 'offline' status?: 'ACTIVE' | 'SUSPENDED' | 'INACTIVE'
} }
/** 导入结果响应 */ /** 导入结果响应 */

View File

@ -3,9 +3,10 @@ import http from '@/utils/http'
const BASE_URL = '/user' const BASE_URL = '/user'
/** @desc 查询用户列表 */ /** @desc 分页查询用户列表 */
export function listUser(query: T.UserPageQuery) { export function listUser(query: T.UserPageQuery) {
return http.get<PageRes<T.UserResp[]>>(`${BASE_URL}`, query) // 后端分页接口为 /user/page
return http.get<PageRes<T.UserResp[]>>(`${BASE_URL}/page`, query)
} }
/** @desc 查询所有用户列表 */ /** @desc 查询所有用户列表 */

View File

@ -70,6 +70,6 @@ declare global {
// for type re-export // for type re-export
declare global { declare global {
// @ts-ignore // @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue' export type { Component, ComponentPublicInstance, ComputedRef, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, VNode, WritableComputedRef } from 'vue'
import('vue') import('vue')
} }

View File

@ -7,8 +7,68 @@ export {}
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
ApprovalAssistant: typeof import('./../components/ApprovalAssistant/index.vue')['default']
Avatar: typeof import('./../components/Avatar/index.vue')['default']
Breadcrumb: typeof import('./../components/Breadcrumb/index.vue')['default']
CellCopy: typeof import('./../components/CellCopy/index.vue')['default']
Chart: typeof import('./../components/Chart/index.vue')['default']
ColumnSetting: typeof import('./../components/GiTable/src/components/ColumnSetting.vue')['default']
CronForm: typeof import('./../components/GenCron/CronForm/index.vue')['default']
CronModal: typeof import('./../components/GenCron/CronModal/index.vue')['default']
DateRangePicker: typeof import('./../components/DateRangePicker/index.vue')['default']
DayForm: typeof import('./../components/GenCron/CronForm/component/day-form.vue')['default']
FilePreview: typeof import('./../components/FilePreview/index.vue')['default']
GiCellAvatar: typeof import('./../components/GiCell/GiCellAvatar.vue')['default']
GiCellGender: typeof import('./../components/GiCell/GiCellGender.vue')['default']
GiCellStatus: typeof import('./../components/GiCell/GiCellStatus.vue')['default']
GiCellTag: typeof import('./../components/GiCell/GiCellTag.vue')['default']
GiCellTags: typeof import('./../components/GiCell/GiCellTags.vue')['default']
GiCodeView: typeof import('./../components/GiCodeView/index.vue')['default']
GiDot: typeof import('./../components/GiDot/index.tsx')['default']
GiEditTable: typeof import('./../components/GiEditTable/GiEditTable.vue')['default']
GiFooter: typeof import('./../components/GiFooter/index.vue')['default']
GiForm: typeof import('./../components/GiForm/src/GiForm.vue')['default']
GiIconBox: typeof import('./../components/GiIconBox/index.vue')['default']
GiIconSelector: typeof import('./../components/GiIconSelector/index.vue')['default']
GiIframe: typeof import('./../components/GiIframe/index.vue')['default']
GiOption: typeof import('./../components/GiOption/index.vue')['default']
GiOptionItem: typeof import('./../components/GiOptionItem/index.vue')['default']
GiPageLayout: typeof import('./../components/GiPageLayout/index.vue')['default']
GiSpace: typeof import('./../components/GiSpace/index.vue')['default']
GiSplitButton: typeof import('./../components/GiSplitButton/index.vue')['default']
GiSplitPane: typeof import('./../components/GiSplitPane/index.vue')['default']
GiSplitPaneFlexibleBox: typeof import('./../components/GiSplitPane/components/GiSplitPaneFlexibleBox.vue')['default']
GiSvgIcon: typeof import('./../components/GiSvgIcon/index.vue')['default']
GiTable: typeof import('./../components/GiTable/src/GiTable.vue')['default']
GiTag: typeof import('./../components/GiTag/index.tsx')['default']
GiThemeBtn: typeof import('./../components/GiThemeBtn/index.vue')['default']
HourForm: typeof import('./../components/GenCron/CronForm/component/hour-form.vue')['default']
Icon403: typeof import('./../components/icons/Icon403.vue')['default']
Icon404: typeof import('./../components/icons/Icon404.vue')['default']
Icon500: typeof import('./../components/icons/Icon500.vue')['default']
IconBorders: typeof import('./../components/icons/IconBorders.vue')['default']
IconTableSize: typeof import('./../components/icons/IconTableSize.vue')['default']
IconTreeAdd: typeof import('./../components/icons/IconTreeAdd.vue')['default']
IconTreeReduce: typeof import('./../components/icons/IconTreeReduce.vue')['default']
ImageImport: typeof import('./../components/ImageImport/index.vue')['default']
ImageImportWizard: typeof import('./../components/ImageImportWizard/index.vue')['default']
IndustrialImageList: typeof import('./../components/IndustrialImageList/index.vue')['default']
JsonPretty: typeof import('./../components/JsonPretty/index.vue')['default']
MinuteForm: typeof import('./../components/GenCron/CronForm/component/minute-form.vue')['default']
MonthForm: typeof import('./../components/GenCron/CronForm/component/month-form.vue')['default']
NotificationCenter: typeof import('./../components/NotificationCenter/index.vue')['default'] NotificationCenter: typeof import('./../components/NotificationCenter/index.vue')['default']
ParentView: typeof import('./../components/ParentView/index.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
SecondForm: typeof import('./../components/GenCron/CronForm/component/second-form.vue')['default']
SplitPanel: typeof import('./../components/SplitPanel/index.vue')['default']
TextCopy: typeof import('./../components/TextCopy/index.vue')['default']
TurbineGrid: typeof import('./../components/TurbineGrid/index.vue')['default']
UserSelect: typeof import('./../components/UserSelect/index.vue')['default']
Verify: typeof import('./../components/Verify/index.vue')['default']
VerifyPoints: typeof import('./../components/Verify/Verify/VerifyPoints.vue')['default']
VerifySlide: typeof import('./../components/Verify/Verify/VerifySlide.vue')['default']
WeekForm: typeof import('./../components/GenCron/CronForm/component/week-form.vue')['default']
YearForm: typeof import('./../components/GenCron/CronForm/component/year-form.vue')['default']
} }
} }

View File

@ -7,8 +7,16 @@
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :span="12"> <a-col :span="12">
<a-form-item field="projectName" label="项目名称"> <a-form-item field="projectId" label="项目">
<a-input v-model="contractData.projectName" /> <a-select v-model="contractData.projectId"
:options="projectOptions"
:loading="projectLoading"
:virtual-list-props="virtualListProps"
placeholder="请选择项目"
allow-search allow-clear
@focus="handleProjectFocus"
@dropdown-visible-change="handleProjectDropdown"
@search="handleProjectSearch" />
</a-form-item> </a-form-item>
</a-col> </a-col>
</a-row> </a-row>
@ -66,8 +74,27 @@
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :span="12"> <a-col :span="12">
<a-form-item field="productService" label="产品或服务"> <a-form-item field="departmentId" label="销售部门">
<a-input v-model="contractData.productService" /> <a-tree-select v-model="contractData.departmentId"
:data="deptTree"
:loading="deptLoading"
placeholder="请选择部门"
allow-search allow-clear
@dropdown-visible-change="handleDeptDropdown"
@focus="handleDeptFocus"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="salespersonId" label="销售人员">
<a-select v-model="contractData.salespersonId"
:options="userOptions"
:loading="userLoading"
:virtual-list-props="userVirtualListProps"
placeholder="请选择业务员"
allow-search allow-clear
@dropdown-visible-change="handleUserDropdown"
@search="handleUserSearch" />
</a-form-item> </a-form-item>
</a-col> </a-col>
</a-row> </a-row>
@ -88,6 +115,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import { listProject, type ProjectResp } from '@/apis/project'
import { getDeptTree } from '@/apis/system/dept'
import { listUser } from '@/apis/system/user'
import type { ContractItem } from './index.vue' import type { ContractItem } from './index.vue'
const props = defineProps<{ const props = defineProps<{
@ -100,6 +131,138 @@ const emit = defineEmits<{
const contractData = ref({ ...props.contractData }) const contractData = ref({ ...props.contractData })
//
const projectOptions = ref<{ label: string; value: string }[]>([])
const projectLoading = ref(false)
const projPage = ref(1)
const pageSize = 20
const hasMore = ref(true)
const currentKeyword = ref('')
let searchTimer: number | undefined
const appendProjects = (list: ProjectResp[]) => {
const items = list.map(p => ({ label: p.projectName, value: String((p as any).projectId ?? (p as any).id) }))
projectOptions.value = projectOptions.value.concat(items)
}
const extractList = (data: any): ProjectResp[] => {
if (Array.isArray(data)) return data as ProjectResp[]
if (Array.isArray(data?.list)) return data.list as ProjectResp[]
if (Array.isArray(data?.rows)) return data.rows as ProjectResp[]
return []
}
const loadProjects = async (keyword = currentKeyword.value, reset = false) => {
try {
if (reset) {
projPage.value = 1
hasMore.value = true
projectOptions.value = []
}
if (!hasMore.value) return
projectLoading.value = true
const resp = await listProject({ page: projPage.value, size: pageSize, projectName: keyword } as any)
const list = extractList((resp as any).data)
appendProjects(list)
if (list.length < pageSize) hasMore.value = false
projPage.value += 1
} catch (e) {
Message.error('获取项目列表失败')
hasMore.value = false
} finally {
projectLoading.value = false
}
}
const virtualListProps = {
height: 240,
onReachBottom: () => {
if (hasMore.value && !projectLoading.value) loadProjects()
},
}
const handleProjectFocus = () => {
if (projectOptions.value.length === 0) loadProjects('', true)
}
const handleProjectDropdown = (visible: boolean) => {
if (visible && projectOptions.value.length === 0) loadProjects('', true)
}
const handleProjectSearch = (val: string) => {
if (searchTimer) window.clearTimeout(searchTimer)
currentKeyword.value = val || ''
searchTimer = window.setTimeout(() => loadProjects(currentKeyword.value, true), 300)
}
//
const deptTree = ref([] as any[])
const deptLoading = ref(false)
const loadDept = async () => {
try {
deptLoading.value = true
const res = await getDeptTree()
const data = (res as any).data || []
// a-tree-select
const toTree = (arr: any[]): any[] => arr.map(i => ({
key: String(i.deptId),
title: i.deptName,
value: String(i.deptId),
children: Array.isArray(i.children) ? toTree(i.children) : [],
}))
deptTree.value = toTree(Array.isArray(data) ? data : (data.list || data.rows || []))
} finally {
deptLoading.value = false
}
}
const handleDeptDropdown = (visible: boolean) => { if (visible && deptTree.value.length === 0) loadDept() }
const handleDeptFocus = () => { if (deptTree.value.length === 0) loadDept() }
// + +
const userOptions = ref<{ label: string; value: string }[]>([])
const userLoading = ref(false)
const userPage = ref(1)
const userHasMore = ref(true)
const userPageSize = 20
const userKeyword = ref('')
const appendUsers = (rows: any[]) => {
userOptions.value = userOptions.value.concat(
rows.map(u => ({ label: u.name || u.nickname || u.username, value: String(u.userId || u.id) }))
)
}
const extractUsers = (data: any): any[] => {
if (Array.isArray(data)) return data
if (Array.isArray(data?.rows)) return data.rows
if (Array.isArray(data?.list)) return data.list
return []
}
const loadUsers = async (reset = false) => {
try {
if (reset) { userOptions.value = []; userPage.value = 1; userHasMore.value = true }
if (!userHasMore.value) return
userLoading.value = true
const resp = await listUser({ page: userPage.value, pageSize: userPageSize, name: userKeyword.value } as any)
const rows = extractUsers((resp as any).data)
appendUsers(rows)
if (rows.length < userPageSize) userHasMore.value = false
userPage.value += 1
} finally {
userLoading.value = false
}
}
const userVirtualListProps = {
height: 240,
onReachBottom: () => { if (userHasMore.value && !userLoading.value) loadUsers() }
}
const handleUserDropdown = (visible: boolean) => { if (visible && userOptions.value.length === 0) { userKeyword.value=''; loadUsers(true) } }
const handleUserSearch = (val: string) => {
if (searchTimer) window.clearTimeout(searchTimer)
userKeyword.value = val || ''
searchTimer = window.setTimeout(() => loadUsers(true), 300)
}
// //
// mounted
// props // props
watch( watch(
() => props.contractData, () => props.contractData,

View File

@ -55,11 +55,9 @@
<template #action="{ record }"> <template #action="{ record }">
<a-space> <a-space>
<a-link @click="viewDetail(record)">详情</a-link> <a-link @click="viewDetail(record)">查看</a-link>
<a-link v-if="record.contractStatus === '未确认'" @click="editRecord(record)">编辑</a-link> <a-link @click="editRecord(record)">编辑</a-link>
<a-link v-if="record.contractStatus === '待审批'" @click="approveContract(record)">审批</a-link> <a-link status="danger" @click="deleteContract(record)">删除</a-link>
<a-link @click="viewPayment(record)">收款记录</a-link>
<a-link v-if="record.contractStatus !== '已签署' && record.contractStatus !== '已完成'" @click="deleteContract(record)">删除</a-link>
</a-space> </a-space>
</template> </template>
</GiTable> </GiTable>
@ -89,6 +87,21 @@
@update:contract-data="handleContractDataUpdate" @update:contract-data="handleContractDataUpdate"
/> />
</a-modal> </a-modal>
<!-- 新建合同弹窗 -->
<a-modal
v-model:visible="showAddModal"
title="新建合同"
:width="800"
@cancel="closeAddModal"
@before-ok="handleAddSubmit"
>
<ContractEdit
v-if="showAddModal"
:contract-data="newContractData"
@update:contract-data="handleNewContractDataUpdate"
/>
</a-modal>
</GiPageLayout> </GiPageLayout>
</template> </template>
@ -135,25 +148,16 @@ interface ContractItem {
// //
const searchForm = reactive({ const searchForm = reactive({
contractName: '',
contractCode: '', contractCode: '',
client: '', client: '',
status: '', status: '',
signDate: '', signDateRange: [] as [string, string] | [],
page: 1, page: 1,
size: 10, size: 10,
}) })
// //
const queryFormColumns = [ const queryFormColumns = [
{
field: 'contractName',
label: '合同名称',
type: 'input' as const,
props: {
placeholder: '请输入合同名称',
},
},
{ {
field: 'client', field: 'client',
label: '客户', label: '客户',
@ -178,6 +182,16 @@ const queryFormColumns = [
], ],
}, },
}, },
{
field: 'signDateRange',
label: '签署时间',
type: 'range-picker' as const,
props: {
placeholder: ['开始时间', '结束时间'],
showTime: true,
format: 'YYYY-MM-DD HH:mm:ss',
},
},
] ]
// //
@ -209,27 +223,43 @@ const fetchContractList = async () => {
const params = { const params = {
page: searchForm.page, page: searchForm.page,
pageSize: searchForm.size, pageSize: searchForm.size,
contractName: searchForm.contractName,
code: searchForm.contractCode, code: searchForm.contractCode,
customer: searchForm.client, customer: searchForm.client,
contractStatus: searchForm.status, contractStatus: searchForm.status,
signDate: searchForm.signDate, signDateStart: Array.isArray(searchForm.signDateRange) && searchForm.signDateRange.length === 2 ? searchForm.signDateRange[0] : undefined,
signDateEnd: Array.isArray(searchForm.signDateRange) && searchForm.signDateRange.length === 2 ? searchForm.signDateRange[1] : undefined,
} }
const response = await http.get('/contract/list', params) const response = await http.get('/contract/list', params)
if (response.code === 200) { if (response.code === 200) {
// "" // ""
const allContracts = response.rows || [] const allContracts = response.rows || []
const revenueContracts = allContracts.filter((item: ContractItem) => item.type === '支出合同') let filtered = allContracts.filter((item: ContractItem) => item.type === '支出合同')
//
const range = Array.isArray(searchForm.signDateRange) && searchForm.signDateRange.length === 2 ? searchForm.signDateRange : null
if (range) {
const [start, end] = range
const startTime = new Date(start as any).getTime()
const endTime = new Date(end as any).getTime()
if (!Number.isNaN(startTime) && !Number.isNaN(endTime)) {
filtered = filtered.filter((item: ContractItem) => {
if (!item.signDate) return false
const t = new Date(item.signDate as any).getTime()
return !Number.isNaN(t) && t >= startTime && t <= endTime
})
}
}
// //
dataList.value = revenueContracts.map((item: ContractItem) => ({ dataList.value = filtered.map((item: ContractItem) => ({
...item, ...item,
pendingAmount: (item.amount || 0) - (item.receivedAmount || 0), pendingAmount: (item.amount || 0) - (item.receivedAmount || 0),
})) }))
pagination.total = Number.parseInt(response.total) || 0 //
pagination.total = dataList.value.length
} else { } else {
Message.error(response.msg || '获取合同列表失败') Message.error(response.msg || '获取合同列表失败')
dataList.value = [] dataList.value = []
@ -277,11 +307,10 @@ const search = async () => {
const reset = () => { const reset = () => {
Object.assign(searchForm, { Object.assign(searchForm, {
contractName: '',
contractCode: '', contractCode: '',
client: '', client: '',
status: '', status: '',
signDate: '', signDateRange: [],
page: 1, page: 1,
size: 10, size: 10,
}) })
@ -305,8 +334,131 @@ const onPageSizeChange = (size: number) => {
} }
// //
const showAddModal = ref(false)
const newContractData = ref<ContractItem>({
contractId: '',
customer: '',
code: '',
projectId: '',
type: '支出合同',
productService: '',
paymentDate: null,
performanceDeadline: null,
paymentAddress: '',
amount: 0,
accountNumber: '',
notes: '',
contractStatus: '未确认',
contractText: '',
projectName: '',
salespersonName: null,
salespersonDeptName: '',
settlementAmount: null,
receivedAmount: null,
contractStatusLabel: null,
createBy: null,
updateBy: null,
createTime: '',
updateTime: '',
page: 1,
pageSize: 10,
signDate: '',
duration: '',
})
const openAddModal = () => { const openAddModal = () => {
Message.info('新建合同功能开发中...') //
newContractData.value = {
contractId: '',
customer: '',
code: '',
projectId: '',
type: '支出合同',
productService: '',
paymentDate: null,
performanceDeadline: null,
paymentAddress: '',
amount: 0,
accountNumber: '',
notes: '',
contractStatus: '未确认',
contractText: '',
projectName: '',
salespersonName: null,
salespersonDeptName: '',
settlementAmount: null,
receivedAmount: null,
contractStatusLabel: null,
createBy: null,
updateBy: null,
createTime: '',
updateTime: '',
page: 1,
pageSize: 10,
signDate: '',
duration: '',
}
showAddModal.value = true
}
const closeAddModal = () => {
showAddModal.value = false
}
const handleNewContractDataUpdate = (data: ContractItem) => {
// watchprops
Object.assign(newContractData.value, data)
}
const handleAddSubmit = async () => {
try {
const payload: any = {
accountNumber: newContractData.value.accountNumber || '',
amount: newContractData.value.amount || 0,
code: newContractData.value.code || '',
contractStatus: newContractData.value.contractStatus || '',
contractText: newContractData.value.contractText || '',
customer: newContractData.value.customer || '',
departmentId: (newContractData.value as any).departmentId || '',
duration: newContractData.value.duration || '',
notes: newContractData.value.notes || '',
paymentAddress: newContractData.value.paymentAddress || '',
paymentDate: newContractData.value.paymentDate || null,
performanceDeadline: newContractData.value.performanceDeadline || null,
productService: newContractData.value.productService || '',
projectId: newContractData.value.projectId || '',
salespersonId: (newContractData.value as any).salespersonId || '',
signDate: newContractData.value.signDate || null,
type: newContractData.value.type || '支出合同',
}
// ID
if (!newContractData.value.contractId) delete payload.contractId
const response = await http.post('/contract', payload)
if ((response as any).status === 200 && response.code === 200) {
Message.success('新建合同成功')
closeAddModal()
search()
return true
}
// code/msg
if (response.code === 200) {
Message.success('新建合同成功')
closeAddModal()
search()
return true
}
Message.error(response.msg || '新建合同失败')
return false
} catch (error: any) {
console.error('新建合同失败:', error)
Message.error('新建合同失败: ' + (error?.message || '请稍后再试'))
return false
}
} }
const exportContract = () => { const exportContract = () => {
@ -339,7 +491,12 @@ const closeEditModal = () => {
} }
const handleContractDataUpdate = (data: ContractItem) => { const handleContractDataUpdate = (data: ContractItem) => {
editedContractData.value = data //
if (!editedContractData.value) {
editedContractData.value = { ...(data as any) } as ContractItem
} else {
Object.assign(editedContractData.value, data)
}
} }
const handleEditSubmit = async () => { const handleEditSubmit = async () => {
@ -391,28 +548,29 @@ const handleEditSubmit = async () => {
} }
// //
const deleteContract = async (record: ContractItem) => { const deleteContract = (record: ContractItem) => {
try { Modal.confirm({
await Modal.confirm({
title: '确认删除', title: '确认删除',
content: `确定要删除合同 "${record.projectName}" 吗?`, content: `确定要删除合同 "${record.projectName}" 吗?`,
}) async onOk() {
try {
const response = await http.delete(`/contract/${record.contractId}`) const response = await http.del(`/contract/${record.contractId}`)
if (response.code === 200) { if (response.code === 200) {
Message.success('合同删除成功') Message.success('合同删除成功')
search() // search()
return true
} else { } else {
Message.error(response.msg || '合同删除失败') Message.error(response.msg || '合同删除失败')
return false
} }
} catch (error) { } catch (error) {
//
if (error !== 'cancel') {
console.error('合同删除失败:', error) console.error('合同删除失败:', error)
Message.error('合同删除失败') Message.error('合同删除失败')
return false
} }
} },
})
} }
// //

View File

@ -7,8 +7,16 @@
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :span="12"> <a-col :span="12">
<a-form-item field="projectName" label="项目名称"> <a-form-item field="projectId" label="项目">
<a-input v-model="contractData.projectName" /> <a-select v-model="contractData.projectId"
:options="projectOptions"
:loading="projectLoading"
:virtual-list-props="virtualListProps"
placeholder="请选择项目"
allow-search allow-clear
@focus="handleProjectFocus"
@dropdown-visible-change="handleProjectDropdown"
@search="handleProjectSearch" />
</a-form-item> </a-form-item>
</a-col> </a-col>
</a-row> </a-row>
@ -66,8 +74,27 @@
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :span="12"> <a-col :span="12">
<a-form-item field="productService" label="产品或服务"> <a-form-item field="departmentId" label="销售部门">
<a-input v-model="contractData.productService" /> <a-tree-select v-model="contractData.departmentId"
:data="deptTree"
:loading="deptLoading"
placeholder="请选择部门"
allow-search allow-clear
@dropdown-visible-change="handleDeptDropdown"
@focus="handleDeptFocus"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="salespersonId" label="销售人员">
<a-select v-model="contractData.salespersonId"
:options="userOptions"
:loading="userLoading"
:virtual-list-props="userVirtualListProps"
placeholder="请选择业务员"
allow-search allow-clear
@dropdown-visible-change="handleUserDropdown"
@search="handleUserSearch" />
</a-form-item> </a-form-item>
</a-col> </a-col>
</a-row> </a-row>
@ -88,6 +115,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import { listProject, type ProjectResp } from '@/apis/project'
import { getDeptTree } from '@/apis/system/dept'
import { listUser } from '@/apis/system/user'
import type { ContractItem } from './index.vue' import type { ContractItem } from './index.vue'
const props = defineProps<{ const props = defineProps<{
@ -100,6 +131,134 @@ const emit = defineEmits<{
const contractData = ref({ ...props.contractData }) const contractData = ref({ ...props.contractData })
//
const projectOptions = ref<{ label: string; value: string }[]>([])
const projectLoading = ref(false)
const projPage = ref(1)
const pageSize = 20
const hasMore = ref(true)
const currentKeyword = ref('')
let searchTimer: number | undefined
const appendProjects = (list: ProjectResp[]) => {
const items = list.map(p => ({ label: p.projectName, value: String((p as any).projectId ?? (p as any).id) }))
projectOptions.value = projectOptions.value.concat(items)
}
const extractList = (data: any): ProjectResp[] => {
if (Array.isArray(data)) return data as ProjectResp[]
if (Array.isArray(data?.list)) return data.list as ProjectResp[]
if (Array.isArray(data?.rows)) return data.rows as ProjectResp[]
return []
}
const loadProjects = async (keyword = currentKeyword.value, reset = false) => {
try {
if (reset) {
projPage.value = 1
hasMore.value = true
projectOptions.value = []
}
if (!hasMore.value) return
projectLoading.value = true
const resp = await listProject({ page: projPage.value, size: pageSize, projectName: keyword } as any)
const list = extractList((resp as any).data)
appendProjects(list)
if (list.length < pageSize) hasMore.value = false
projPage.value += 1
} catch (e) {
Message.error('获取项目列表失败')
hasMore.value = false
} finally {
projectLoading.value = false
}
}
const virtualListProps = {
height: 240,
onReachBottom: () => {
if (hasMore.value && !projectLoading.value) loadProjects()
},
}
const handleProjectFocus = () => {
if (projectOptions.value.length === 0) loadProjects('', true)
}
const handleProjectDropdown = (visible: boolean) => {
if (visible && projectOptions.value.length === 0) loadProjects('', true)
}
const handleProjectSearch = (val: string) => {
if (searchTimer) window.clearTimeout(searchTimer)
currentKeyword.value = val || ''
searchTimer = window.setTimeout(() => loadProjects(currentKeyword.value, true), 300)
}
//
const deptTree = ref([] as any[])
const deptLoading = ref(false)
const loadDept = async () => {
try {
deptLoading.value = true
const res = await getDeptTree()
const data = (res as any).data || []
const toTree = (arr: any[]): any[] => arr.map(i => ({
key: String(i.deptId),
title: i.deptName,
value: String(i.deptId),
children: Array.isArray(i.children) ? toTree(i.children) : [],
}))
deptTree.value = toTree(Array.isArray(data) ? data : (data.list || data.rows || []))
} finally {
deptLoading.value = false
}
}
const handleDeptDropdown = (visible: boolean) => { if (visible && deptTree.value.length === 0) loadDept() }
const handleDeptFocus = () => { if (deptTree.value.length === 0) loadDept() }
// + +
const userOptions = ref<{ label: string; value: string }[]>([])
const userLoading = ref(false)
const userPage = ref(1)
const userHasMore = ref(true)
const userPageSize = 20
const userKeyword = ref('')
const appendUsers = (rows: any[]) => {
userOptions.value = userOptions.value.concat(
rows.map(u => ({ label: u.name || u.nickname || u.username, value: String(u.userId || u.id) }))
)
}
const extractUsers = (data: any): any[] => {
if (Array.isArray(data)) return data
if (Array.isArray(data?.rows)) return data.rows
if (Array.isArray(data?.list)) return data.list
return []
}
const loadUsers = async (reset = false) => {
try {
if (reset) { userOptions.value = []; userPage.value = 1; userHasMore.value = true }
if (!userHasMore.value) return
userLoading.value = true
const resp = await listUser({ page: userPage.value, pageSize: userPageSize, name: userKeyword.value } as any)
const rows = extractUsers((resp as any).data)
appendUsers(rows)
if (rows.length < userPageSize) userHasMore.value = false
userPage.value += 1
} finally {
userLoading.value = false
}
}
const userVirtualListProps = {
height: 240,
onReachBottom: () => { if (userHasMore.value && !userLoading.value) loadUsers() }
}
const handleUserDropdown = (visible: boolean) => { if (visible && userOptions.value.length === 0) { userKeyword.value=''; loadUsers(true) } }
const handleUserSearch = (val: string) => {
if (searchTimer) window.clearTimeout(searchTimer)
userKeyword.value = val || ''
searchTimer = window.setTimeout(() => loadUsers(true), 300)
}
// props // props
watch( watch(
() => props.contractData, () => props.contractData,

View File

@ -55,11 +55,9 @@
<template #action="{ record }"> <template #action="{ record }">
<a-space> <a-space>
<a-link @click="viewDetail(record)">详情</a-link> <a-link @click="viewDetail(record)">查看</a-link>
<a-link v-if="record.contractStatus === '未确认'" @click="editRecord(record)">编辑</a-link> <a-link @click="editRecord(record)">编辑</a-link>
<a-link v-if="record.contractStatus === '待审批'" @click="approveContract(record)">审批</a-link> <a-link status="danger" @click="deleteContract(record)">删除</a-link>
<a-link @click="viewPayment(record)">收款记录</a-link>
<a-link v-if="record.contractStatus !== '已签署' && record.contractStatus !== '已完成'" @click="deleteContract(record)">删除</a-link>
</a-space> </a-space>
</template> </template>
</GiTable> </GiTable>
@ -90,6 +88,21 @@
/> />
</a-modal> </a-modal>
</GiPageLayout> </GiPageLayout>
<!-- 新建合同弹窗 -->
<a-modal
v-model:visible="showAddModal"
title="新建合同"
:width="800"
@cancel="closeAddModal"
@before-ok="handleAddSubmit"
>
<ContractEdit
v-if="showAddModal"
:contract-data="newContractData"
@update:contract-data="handleNewContractDataUpdate"
/>
</a-modal>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -305,14 +318,71 @@ const onPageSizeChange = (size: number) => {
} }
// //
const showAddModal = ref(false)
const newContractData = ref<ContractItem>({
contractId: '', customer: '', code: '', projectId: '', type: '收入合同',
productService: '', paymentDate: null, performanceDeadline: null,
paymentAddress: '', amount: 0, accountNumber: '', notes: '',
contractStatus: '未确认', contractText: '', projectName: '',
salespersonName: null, salespersonDeptName: '', settlementAmount: null,
receivedAmount: null, contractStatusLabel: null, createBy: null, updateBy: null,
createTime: '', updateTime: '', page: 1, pageSize: 10, signDate: '', duration: '',
} as any)
const openAddModal = () => { const openAddModal = () => {
Message.info('新建合同功能开发中...') Object.assign(newContractData.value, {
contractId: '', customer: '', code: '', projectId: '', type: '收入合同',
productService: '', paymentDate: null, performanceDeadline: null,
paymentAddress: '', amount: 0, accountNumber: '', notes: '',
contractStatus: '未确认', contractText: '', projectName: '',
salespersonName: null, salespersonDeptName: '', settlementAmount: null,
receivedAmount: null, contractStatusLabel: null, createBy: null, updateBy: null,
createTime: '', updateTime: '', page: 1, pageSize: 10, signDate: '', duration: '',
})
showAddModal.value = true
} }
const exportContract = () => { const closeAddModal = () => { showAddModal.value = false }
Message.info('导出合同功能开发中...')
const handleNewContractDataUpdate = (data: ContractItem) => { Object.assign(newContractData.value, data) }
const handleAddSubmit = async () => {
try {
const payload: any = {
accountNumber: newContractData.value.accountNumber || '',
amount: newContractData.value.amount || 0,
code: newContractData.value.code || '',
contractStatus: newContractData.value.contractStatus || '',
contractText: newContractData.value.contractText || '',
customer: newContractData.value.customer || '',
departmentId: (newContractData.value as any).departmentId || '',
duration: newContractData.value.duration || '',
notes: newContractData.value.notes || '',
paymentAddress: newContractData.value.paymentAddress || '',
paymentDate: newContractData.value.paymentDate || null,
performanceDeadline: newContractData.value.performanceDeadline || null,
productService: newContractData.value.productService || '',
projectId: newContractData.value.projectId || '',
salespersonId: (newContractData.value as any).salespersonId || '',
signDate: newContractData.value.signDate || null,
type: newContractData.value.type || '收入合同',
}
if (!newContractData.value.contractId) delete payload.contractId
const response = await http.post('/contract', payload)
if ((response as any).status === 200 && response.code === 200) {
Message.success('新建合同成功'); closeAddModal(); search(); return true
}
if (response.code === 200) { Message.success('新建合同成功'); closeAddModal(); search(); return true }
Message.error(response.msg || '新建合同失败'); return false
} catch (error: any) {
console.error('新建合同失败:', error)
Message.error('新建合同失败: ' + (error?.message || '请稍后再试'))
return false
}
} }
const exportContract = () => { Message.info('导出合同功能开发中...') }
// //
const showEditModal = ref(false) const showEditModal = ref(false)
const selectedContractData = ref<ContractItem | null>(null) const selectedContractData = ref<ContractItem | null>(null)
@ -391,28 +461,29 @@ const handleEditSubmit = async () => {
} }
// //
const deleteContract = async (record: ContractItem) => { const deleteContract = (record: ContractItem) => {
try { Modal.confirm({
await Modal.confirm({
title: '确认删除', title: '确认删除',
content: `确定要删除合同 "${record.projectName}" 吗?`, content: `确定要删除合同 "${record.projectName}" 吗?`,
}) async onOk() {
try {
const response = await http.delete(`/contract/${record.contractId}`) const response = await http.del(`/contract/${record.contractId}`)
if (response.code === 200) { if (response.code === 200) {
Message.success('合同删除成功') Message.success('合同删除成功')
search() // search()
return true
} else { } else {
Message.error(response.msg || '合同删除失败') Message.error(response.msg || '合同删除失败')
return false
} }
} catch (error) { } catch (error) {
//
if (error !== 'cancel') {
console.error('合同删除失败:', error) console.error('合同删除失败:', error)
Message.error('合同删除失败') Message.error('合同删除失败')
return false
} }
} },
})
} }
// //

View File

@ -265,7 +265,7 @@
<div class="member-position">{{ member.position || '未设置岗位' }}</div> <div class="member-position">{{ member.position || '未设置岗位' }}</div>
<div class="member-details"> <div class="member-details">
<span class="member-status" :class="member.status"> <span class="member-status" :class="member.status">
{{ member.status === 'available' ? '在线' : '离线' }} {{ member.status === 'ACTIVE' ? '在线' : '离线' }}
</span> </span>
<span class="member-date">入职: {{ member.joinDate || '未设置' }}</span> <span class="member-date">入职: {{ member.joinDate || '未设置' }}</span>
</div> </div>
@ -468,7 +468,7 @@ const mapProjectRespToProjectCard = (projectResp: any): any => {
position: member.roleTypeDesc || member.jobCodeDesc || '未设置岗位', position: member.roleTypeDesc || member.jobCodeDesc || '未设置岗位',
phone: member.phone || '', // phone: member.phone || '', //
email: member.email || '', // email: member.email || '', //
status: member.status === 'ACTIVE' ? 'available' : 'offline', status: member.status === 'ACTIVE' ? 'ACTIVE' : 'INACTIVE',
skills: [], // skills: [], //
joinDate: member.joinDate || '未设置', joinDate: member.joinDate || '未设置',
remark: member.remark || member.jobDesc || '', remark: member.remark || member.jobDesc || '',
@ -1326,13 +1326,13 @@ onMounted(async () => {
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
&.available { &.ACTIVE {
background: linear-gradient(135deg, #e6f7ff 0%, #bae7ff 100%); background: linear-gradient(135deg, #e6f7ff 0%, #bae7ff 100%);
color: #1890ff; color: #1890ff;
border: 1px solid #91d5ff; border: 1px solid #91d5ff;
} }
&.offline { &.INACTIVE {
background: linear-gradient(135deg, #f5f5f5 0%, #e8e8e8 100%); background: linear-gradient(135deg, #f5f5f5 0%, #e8e8e8 100%);
color: #8c8c8c; color: #8c8c8c;
border: 1px solid #d9d9d9; border: 1px solid #d9d9d9;