feat: 移动端首页重构与业务模块完善

1. 首页布局调整
   - 保留今日概览板块
   - 快捷操作板块:新增需求工单、新增应收款、新增支出、新增项目、新增客户
   - 新增业务服务板块:需求工单、应收款管理、支出管理、项目管理、客户管理入口

2. 新增页面
   - 需求工单:列表页(支持搜索)、新增页
   - 支出管理:列表页(支持搜索)、保留新增页
   - 应收款:新增页、列表页添加搜索功能
   - 项目:新增页、列表页优化搜索参数
   - 客户:新增页、列表页优化搜索参数

3. API更新
   - 新增需求工单相关API(getRequirementList、getRequirementById、createRequirement)
   - 新增项目新增API(createProject)
   - 新增客户新增API(createCustomer)
   - 新增应收款新增API(createReceivable)
   - 更新搜索参数为统一的keyword格式

4. 路由更新
   - 新增需求工单列表/新增路由
   - 新增支出管理列表路由
   - 新增应收款新增路由
   - 新增项目新增路由
   - 新增客户新增路由
This commit is contained in:
zhangjf 2026-02-23 11:51:52 +08:00
parent 5e782ac8cc
commit d3a77c23f1
13 changed files with 1653 additions and 52 deletions

View File

@ -11,7 +11,11 @@ declare module 'vue' {
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
Tabbar: typeof import('./src/components/Tabbar.vue')['default'] Tabbar: typeof import('./src/components/Tabbar.vue')['default']
VanButton: typeof import('vant/es')['Button']
VanCellGroup: typeof import('vant/es')['CellGroup']
VanDatePicker: typeof import('vant/es')['DatePicker'] VanDatePicker: typeof import('vant/es')['DatePicker']
VanField: typeof import('vant/es')['Field']
VanForm: typeof import('vant/es')['Form']
VanIcon: typeof import('vant/es')['Icon'] VanIcon: typeof import('vant/es')['Icon']
VanList: typeof import('vant/es')['List'] VanList: typeof import('vant/es')['List']
VanNavBar: typeof import('vant/es')['NavBar'] VanNavBar: typeof import('vant/es')['NavBar']

View File

@ -16,7 +16,7 @@ export function logout() {
// ===================== 项目管理 ===================== // ===================== 项目管理 =====================
export function getProjectList(params?: { pageNum: number; pageSize: number; projectName?: string }) { export function getProjectList(params?: { pageNum: number; pageSize: number; keyword?: string }) {
return request.get('/project/page', { params }) return request.get('/project/page', { params })
} }
@ -24,19 +24,45 @@ export function getProjectById(id: number) {
return request.get(`/project/${id}`) return request.get(`/project/${id}`)
} }
export function createProject(data: any) {
return request.post('/project', data)
}
// ===================== 需求工单管理 =====================
export function getRequirementList(params?: { pageNum: number; pageSize: number; keyword?: string }) {
return request.get('/requirement/page', { params })
}
export function getRequirementById(id: number) {
return request.get(`/requirement/${id}`)
}
export function createRequirement(data: any) {
return request.post('/requirement', data)
}
// ===================== 客户管理 ===================== // ===================== 客户管理 =====================
export function getCustomerList(params?: { pageNum: number; pageSize: number; customerName?: string }) { export function getCustomerList(params?: { pageNum: number; pageSize: number; keyword?: string }) {
return request.get('/customer/page', { params }) return request.get('/customer/page', { params })
} }
export function getCustomerById(id: number) {
return request.get(`/customer/${id}`)
}
export function createCustomer(data: any) {
return request.post('/customer', data)
}
// ===================== 支出管理 ===================== // ===================== 支出管理 =====================
export function createExpense(data: any) { export function createExpense(data: any) {
return request.post('/exp/expense', data) return request.post('/exp/expense', data)
} }
export function getExpenseList(params: { pageNum: number; pageSize: number }) { export function getExpenseList(params?: { pageNum: number; pageSize: number; title?: string }) {
return request.get('/exp/expense/page', { params }) return request.get('/exp/expense/page', { params })
} }
@ -50,10 +76,18 @@ export function getTodayExpense() {
// ===================== 应收款管理 ===================== // ===================== 应收款管理 =====================
export function getReceivableList(params: { pageNum: number; pageSize: number; status?: string }) { export function getReceivableList(params?: { pageNum: number; pageSize: number; status?: string; keyword?: string }) {
return request.get('/receipt/receivable/page', { params }) return request.get('/receipt/receivable/page', { params })
} }
export function getReceivableById(id: number) {
return request.get(`/receipt/receivable/${id}`)
}
export function createReceivable(data: any) {
return request.post('/receipt/receivable', data)
}
export function getUpcomingDueList(daysWithin: number = 7) { export function getUpcomingDueList(daysWithin: number = 7) {
return request.get(`/receipt/receivable/upcoming-due?daysWithin=${daysWithin}`) return request.get(`/receipt/receivable/upcoming-due?daysWithin=${daysWithin}`)
} }

View File

@ -10,36 +10,79 @@ const router = createRouter({
component: () => import('@/views/Home.vue'), component: () => import('@/views/Home.vue'),
meta: { title: '首页', requiresAuth: true } meta: { title: '首页', requiresAuth: true }
}, },
// 需求工单
{
path: '/requirement',
name: 'RequirementList',
component: () => import('@/views/requirement/List.vue'),
meta: { title: '需求工单', requiresAuth: true }
},
{
path: '/requirement/add',
name: 'RequirementAdd',
component: () => import('@/views/requirement/Add.vue'),
meta: { title: '新增需求工单', requiresAuth: true }
},
// 支出管理
{
path: '/expense',
name: 'ExpenseList',
component: () => import('@/views/expense/List.vue'),
meta: { title: '支出管理', requiresAuth: true }
},
{ {
path: '/expense/add', path: '/expense/add',
name: 'ExpenseAdd', name: 'ExpenseAdd',
component: () => import('@/views/expense/Add.vue'), component: () => import('@/views/expense/Add.vue'),
meta: { title: '新增支出', requiresAuth: true } meta: { title: '新增支出', requiresAuth: true }
}, },
// 应收款管理
{ {
path: '/receivable', path: '/receivable',
name: 'ReceivableList', name: 'ReceivableList',
component: () => import('@/views/receivable/List.vue'), component: () => import('@/views/receivable/List.vue'),
meta: { title: '应收款列表', requiresAuth: true } meta: { title: '应收款管理', requiresAuth: true }
}, },
{
path: '/receivable/add',
name: 'ReceivableAdd',
component: () => import('@/views/receivable/Add.vue'),
meta: { title: '新增应收款', requiresAuth: true }
},
// 项目管理
{ {
path: '/project', path: '/project',
name: 'ProjectList', name: 'ProjectList',
component: () => import('@/views/project/List.vue'), component: () => import('@/views/project/List.vue'),
meta: { title: '项目列表', requiresAuth: true } meta: { title: '项目管理', requiresAuth: true }
}, },
{
path: '/project/add',
name: 'ProjectAdd',
component: () => import('@/views/project/Add.vue'),
meta: { title: '新增项目', requiresAuth: true }
},
// 客户管理
{ {
path: '/customer', path: '/customer',
name: 'CustomerList', name: 'CustomerList',
component: () => import('@/views/customer/List.vue'), component: () => import('@/views/customer/List.vue'),
meta: { title: '客户列表', requiresAuth: true } meta: { title: '客户管理', requiresAuth: true }
}, },
{
path: '/customer/add',
name: 'CustomerAdd',
component: () => import('@/views/customer/Add.vue'),
meta: { title: '新增客户', requiresAuth: true }
},
// 我的
{ {
path: '/my', path: '/my',
name: 'My', name: 'My',
component: () => import('@/views/my/Index.vue'), component: () => import('@/views/my/Index.vue'),
meta: { title: '我的', requiresAuth: true } meta: { title: '我的', requiresAuth: true }
}, },
// 登录
{ {
path: '/login', path: '/login',
name: 'Login', name: 'Login',

View File

@ -43,41 +43,100 @@
</div> </div>
</div> </div>
<!-- 快捷入口卡片 --> <!-- 快捷操作卡片 -->
<div class="quick-card mac-card fade-in-up delay-1"> <div class="quick-card mac-card fade-in-up delay-1">
<div class="card-header"> <div class="card-header">
<span class="card-title">快捷操作</span> <span class="card-title">快捷操作</span>
</div> </div>
<div class="quick-grid"> <div class="quick-grid">
<div class="quick-item" @click="$router.push('/requirement/add')">
<div class="quick-icon requirement">
<van-icon name="description" />
</div>
<div class="quick-text">新增需求</div>
</div>
<div class="quick-item" @click="$router.push('/receivable/add')">
<div class="quick-icon receivable">
<van-icon name="balance-list-o" />
</div>
<div class="quick-text">新增应收款</div>
</div>
<div class="quick-item" @click="$router.push('/expense/add')"> <div class="quick-item" @click="$router.push('/expense/add')">
<div class="quick-icon"> <div class="quick-icon expense">
<van-icon name="plus" /> <van-icon name="gold-coin-o" />
</div> </div>
<div class="quick-text">新增支出</div> <div class="quick-text">新增支出</div>
</div> </div>
<div class="quick-item" @click="$router.push('/receivable')"> <div class="quick-item" @click="$router.push('/project/add')">
<div class="quick-icon"> <div class="quick-icon project">
<van-icon name="balance-list-o" />
</div>
<div class="quick-text">应收款</div>
</div>
<div class="quick-item" @click="$router.push('/project')">
<div class="quick-icon">
<van-icon name="todo-list-o" /> <van-icon name="todo-list-o" />
</div> </div>
<div class="quick-text">项目</div> <div class="quick-text">新增项目</div>
</div> </div>
<div class="quick-item" @click="$router.push('/customer')"> <div class="quick-item" @click="$router.push('/customer/add')">
<div class="quick-icon"> <div class="quick-icon customer">
<van-icon name="friends-o" /> <van-icon name="friends-o" />
</div> </div>
<div class="quick-text">客户</div> <div class="quick-text">新增客户</div>
</div> </div>
<div class="quick-item" @click="$router.push('/my')"> </div>
<div class="quick-icon"> </div>
<van-icon name="user-o" />
<!-- 业务服务卡片 -->
<div class="service-card mac-card fade-in-up delay-2">
<div class="card-header">
<span class="card-title">业务服务</span>
</div>
<div class="service-list">
<div class="service-item" @click="$router.push('/requirement')">
<div class="service-icon requirement">
<van-icon name="description" />
</div> </div>
<div class="quick-text">我的</div> <div class="service-content">
<div class="service-name">需求工单</div>
<div class="service-desc">管理需求工单流程</div>
</div>
<van-icon name="arrow" class="service-arrow" />
</div>
<div class="service-item" @click="$router.push('/receivable')">
<div class="service-icon receivable">
<van-icon name="balance-list-o" />
</div>
<div class="service-content">
<div class="service-name">应收款管理</div>
<div class="service-desc">跟踪应收款项</div>
</div>
<van-icon name="arrow" class="service-arrow" />
</div>
<div class="service-item" @click="$router.push('/expense')">
<div class="service-icon expense">
<van-icon name="gold-coin-o" />
</div>
<div class="service-content">
<div class="service-name">支出管理</div>
<div class="service-desc">管理支出审批流程</div>
</div>
<van-icon name="arrow" class="service-arrow" />
</div>
<div class="service-item" @click="$router.push('/project')">
<div class="service-icon project">
<van-icon name="todo-list-o" />
</div>
<div class="service-content">
<div class="service-name">项目管理</div>
<div class="service-desc">项目信息管理</div>
</div>
<van-icon name="arrow" class="service-arrow" />
</div>
<div class="service-item" @click="$router.push('/customer')">
<div class="service-icon customer">
<van-icon name="friends-o" />
</div>
<div class="service-content">
<div class="service-name">客户管理</div>
<div class="service-desc">客户信息管理</div>
</div>
<van-icon name="arrow" class="service-arrow" />
</div> </div>
</div> </div>
</div> </div>
@ -240,23 +299,25 @@ onMounted(() => {
color: var(--mac-text-secondary); color: var(--mac-text-secondary);
} }
/* 快捷操作样式 */
.quick-card { .quick-card {
margin-bottom: 16px;
padding: 20px; padding: 20px;
} }
.quick-grid { .quick-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(5, 1fr);
gap: 16px; gap: 8px;
} }
.quick-item { .quick-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding: 16px 8px; padding: 12px 4px;
background: rgba(0, 0, 0, 0.02); background: rgba(0, 0, 0, 0.02);
border-radius: 16px; border-radius: 12px;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
@ -267,22 +328,121 @@ onMounted(() => {
} }
.quick-icon { .quick-icon {
width: 48px; width: 40px;
height: 48px; height: 40px;
border-radius: 14px; border-radius: 12px;
background: linear-gradient(135deg, var(--mac-primary), #5AC8FA); display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: #fff;
margin-bottom: 8px;
}
.quick-icon.requirement {
background: linear-gradient(135deg, #5856D6, #AF52DE);
}
.quick-icon.receivable {
background: linear-gradient(135deg, #34C759, #30D158);
}
.quick-icon.expense {
background: linear-gradient(135deg, #FF3B30, #FF453A);
}
.quick-icon.project {
background: linear-gradient(135deg, #007AFF, #5AC8FA);
}
.quick-icon.customer {
background: linear-gradient(135deg, #FF9500, #FF9F0A);
}
.quick-text {
font-size: 11px;
font-weight: 500;
color: var(--mac-text);
text-align: center;
}
/* 业务服务样式 */
.service-card {
padding: 20px;
margin-bottom: 16px;
}
.service-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.service-item {
display: flex;
align-items: center;
padding: 14px;
background: rgba(0, 0, 0, 0.02);
border-radius: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.service-item:active {
background: rgba(0, 122, 255, 0.08);
}
.service-icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 22px; font-size: 22px;
color: #fff; color: #fff;
margin-bottom: 10px; margin-right: 14px;
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.25); flex-shrink: 0;
} }
.quick-text { .service-icon.requirement {
font-size: 13px; background: linear-gradient(135deg, #5856D6, #AF52DE);
font-weight: 500; }
.service-icon.receivable {
background: linear-gradient(135deg, #34C759, #30D158);
}
.service-icon.expense {
background: linear-gradient(135deg, #FF3B30, #FF453A);
}
.service-icon.project {
background: linear-gradient(135deg, #007AFF, #5AC8FA);
}
.service-icon.customer {
background: linear-gradient(135deg, #FF9500, #FF9F0A);
}
.service-content {
flex: 1;
}
.service-name {
font-size: 15px;
font-weight: 600;
color: var(--mac-text); color: var(--mac-text);
margin-bottom: 4px;
}
.service-desc {
font-size: 12px;
color: var(--mac-text-secondary);
}
.service-arrow {
color: var(--mac-text-secondary);
font-size: 14px;
} }
</style> </style>

View File

@ -0,0 +1,171 @@
<template>
<div class="page customer-add">
<van-nav-bar title="新增客户" left-arrow @click-left="$router.back()" />
<van-form @submit="onSubmit">
<van-cell-group inset>
<van-field
v-model="form.customerName"
name="customerName"
label="客户名称"
placeholder="请输入客户名称"
:rules="[{ required: true, message: '请输入客户名称' }]"
required
/>
<van-field
v-model="form.customerShort"
name="customerShort"
label="简称"
placeholder="请输入客户简称"
/>
<van-field
v-model="form.phone"
name="phone"
label="联系电话"
type="tel"
placeholder="请输入联系电话"
/>
<van-field
v-model="form.email"
name="email"
label="邮箱"
type="email"
placeholder="请输入邮箱"
/>
<van-field
v-model="levelText"
is-link
readonly
name="level"
label="客户等级"
placeholder="请选择等级"
@click="showLevelPicker = true"
/>
<van-field
v-model="form.industry"
name="industry"
label="所属行业"
placeholder="请输入所属行业"
/>
<van-field
v-model="form.address"
name="address"
label="地址"
placeholder="请输入地址"
/>
<van-field
v-model="form.remark"
name="remark"
label="备注"
type="textarea"
rows="2"
autosize
placeholder="请输入备注"
/>
</van-cell-group>
<div class="submit-btn">
<van-button round block type="primary" native-type="submit" :loading="submitting">
提交
</van-button>
</div>
</van-form>
<!-- 等级选择器 -->
<van-popup v-model:show="showLevelPicker" position="bottom" round>
<van-picker
:columns="levelOptions"
@confirm="onLevelConfirm"
@cancel="showLevelPicker = false"
/>
</van-popup>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import { createCustomer } from '@/api'
const router = useRouter()
const submitting = ref(false)
const showLevelPicker = ref(false)
const form = ref({
customerName: '',
customerShort: '',
phone: '',
email: '',
level: 'C',
industry: '',
address: '',
remark: ''
})
const levelOptions = [
{ text: 'A类重点客户', value: 'A' },
{ text: 'B类一般客户', value: 'B' },
{ text: 'C类普通客户', value: 'C' },
{ text: 'D类潜在客户', value: 'D' }
]
const levelText = computed(() => {
const item = levelOptions.find(l => l.value === form.value.level)
return item?.text || ''
})
const onLevelConfirm = ({ selectedOptions }: any) => {
form.value.level = selectedOptions[0].value
showLevelPicker.value = false
}
const onSubmit = async () => {
if (!form.value.customerName) {
showToast('请输入客户名称')
return
}
submitting.value = true
try {
await createCustomer({
customerName: form.value.customerName,
customerShort: form.value.customerShort,
phone: form.value.phone,
email: form.value.email,
level: form.value.level,
industry: form.value.industry,
address: form.value.address,
remark: form.value.remark
})
showSuccessToast('提交成功')
router.back()
} catch (e: any) {
showToast(e.message || '提交失败')
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.customer-add {
background: #f5f5f5;
min-height: 100vh;
}
.van-cell-group {
margin: 12px;
}
.submit-btn {
margin: 24px 16px;
}
</style>

View File

@ -77,7 +77,7 @@ const loadData = async () => {
const res: any = await getCustomerList({ const res: any = await getCustomerList({
pageNum: pageNum.value, pageNum: pageNum.value,
pageSize, pageSize,
customerName: searchText.value || undefined keyword: searchText.value || undefined
}) })
const records = res.data?.records || [] const records = res.data?.records || []
if (pageNum.value === 1) { if (pageNum.value === 1) {

View File

@ -0,0 +1,224 @@
<template>
<div class="page expense-list">
<van-nav-bar title="支出管理" left-arrow @click-left="$router.back()" />
<!-- 搜索栏 -->
<div class="search-bar">
<van-search v-model="searchText" placeholder="搜索支出标题" @search="handleSearch" />
</div>
<!-- 支出列表 -->
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="onLoad"
>
<div class="expense-card" v-for="item in list" :key="item.expenseId">
<div class="expense-header">
<span class="expense-title">{{ item.title }}</span>
<van-tag :type="getStatusType(item.status)">{{ getStatusText(item.status) }}</van-tag>
</div>
<div class="expense-info">
<div class="info-item">
<van-icon name="apps-o" />
<span>{{ item.typeName || '-' }}</span>
</div>
<div class="info-item">
<van-icon name="clock-o" />
<span>{{ item.expenseDate }}</span>
</div>
</div>
<div class="expense-amount">
<div class="amount-item">
<span class="label">支出金额</span>
<span class="value expense-value">¥{{ item.amount?.toLocaleString() }}</span>
</div>
<div class="amount-item" v-if="item.paidAmount">
<span class="label">已支付</span>
<span class="value">¥{{ item.paidAmount?.toLocaleString() }}</span>
</div>
</div>
</div>
</van-list>
</van-pull-refresh>
<!-- 新增按钮 -->
<div class="add-btn" @click="$router.push('/expense/add')">
<van-icon name="plus" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { getExpenseList } from '@/api'
const searchText = ref('')
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
const list = ref<any[]>([])
const pageNum = ref(1)
const pageSize = 10
const getStatusType = (status: string): 'primary' | 'success' | 'warning' | 'danger' | 'default' => {
const map: Record<string, 'primary' | 'success' | 'warning' | 'danger' | 'default'> = {
'pending': 'warning',
'approved': 'primary',
'paid': 'success',
'rejected': 'danger'
}
return map[status] || 'default'
}
const getStatusText = (status: string) => {
const map: Record<string, string> = {
'pending': '待审批',
'approved': '已审批',
'paid': '已支付',
'rejected': '已拒绝'
}
return map[status] || status
}
const loadData = async () => {
try {
const res: any = await getExpenseList({
pageNum: pageNum.value,
pageSize,
title: searchText.value || undefined
})
const records = res.data?.records || []
if (pageNum.value === 1) {
list.value = records
} else {
list.value.push(...records)
}
finished.value = records.length < pageSize
} catch (e) {
console.error(e)
finished.value = true
} finally {
loading.value = false
refreshing.value = false
}
}
const onLoad = () => {
pageNum.value++
loadData()
}
const onRefresh = () => {
pageNum.value = 1
finished.value = false
loadData()
}
const handleSearch = () => {
pageNum.value = 1
finished.value = false
list.value = []
loadData()
}
</script>
<style scoped>
.expense-list {
background: #f5f5f5;
min-height: 100vh;
padding-bottom: 80px;
}
.search-bar {
background: #fff;
padding: 8px 0;
}
.expense-card {
background: #fff;
margin: 12px;
padding: 16px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.expense-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.expense-title {
font-size: 16px;
font-weight: 600;
color: #333;
}
.expense-info {
display: flex;
gap: 16px;
margin-bottom: 12px;
}
.info-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: #666;
}
.expense-amount {
display: flex;
gap: 24px;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
}
.amount-item {
display: flex;
flex-direction: column;
}
.amount-item .label {
font-size: 12px;
color: #999;
}
.amount-item .value {
font-size: 15px;
font-weight: 600;
color: #333;
margin-top: 4px;
}
.expense-value {
color: #FF3B30 !important;
}
.add-btn {
position: fixed;
bottom: 24px;
right: 24px;
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, #FF3B30, #FF453A);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 28px;
box-shadow: 0 4px 12px rgba(255, 59, 48, 0.4);
cursor: pointer;
transition: transform 0.2s ease;
}
.add-btn:active {
transform: scale(0.95);
}
</style>

View File

@ -0,0 +1,233 @@
<template>
<div class="page project-add">
<van-nav-bar title="新增项目" left-arrow @click-left="$router.back()" />
<van-form @submit="onSubmit">
<van-cell-group inset>
<van-field
v-model="form.projectName"
name="projectName"
label="项目名称"
placeholder="请输入项目名称"
:rules="[{ required: true, message: '请输入项目名称' }]"
required
/>
<van-field
v-model="form.customerName"
is-link
readonly
name="customer"
label="关联客户"
placeholder="请选择客户"
@click="showCustomerPicker = true"
/>
<van-field
v-model="form.contractAmount"
name="contractAmount"
label="合同金额"
type="number"
placeholder="请输入合同金额"
>
<template #button>
<span></span>
</template>
</van-field>
<van-field
v-model="form.budgetAmount"
name="budgetAmount"
label="预算金额"
type="number"
placeholder="请输入预算金额"
>
<template #button>
<span></span>
</template>
</van-field>
<van-field
v-model="form.startDate"
is-link
readonly
name="startDate"
label="开始日期"
placeholder="请选择日期"
@click="showStartPicker = true"
/>
<van-field
v-model="statusText"
is-link
readonly
name="status"
label="项目状态"
placeholder="请选择状态"
@click="showStatusPicker = true"
/>
<van-field
v-model="form.description"
name="description"
label="项目描述"
type="textarea"
rows="2"
autosize
placeholder="请输入项目描述"
/>
</van-cell-group>
<div class="submit-btn">
<van-button round block type="primary" native-type="submit" :loading="submitting">
提交
</van-button>
</div>
</van-form>
<!-- 开始日期选择器 -->
<van-popup v-model:show="showStartPicker" position="bottom" round>
<van-date-picker
v-model="selectedStartDate"
@confirm="onStartDateConfirm"
@cancel="showStartPicker = false"
/>
</van-popup>
<!-- 状态选择器 -->
<van-popup v-model:show="showStatusPicker" position="bottom" round>
<van-picker
:columns="statusOptions"
@confirm="onStatusConfirm"
@cancel="showStatusPicker = false"
/>
</van-popup>
<!-- 客户选择器 -->
<van-popup v-model:show="showCustomerPicker" position="bottom" round>
<van-picker
:columns="customerOptions"
@confirm="onCustomerConfirm"
@cancel="showCustomerPicker = false"
/>
</van-popup>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import { createProject, getCustomerList } from '@/api'
const router = useRouter()
const submitting = ref(false)
const showStartPicker = ref(false)
const showStatusPicker = ref(false)
const showCustomerPicker = ref(false)
const form = ref({
projectName: '',
customerId: null as number | null,
customerName: '',
contractAmount: '',
budgetAmount: '',
startDate: '',
status: 'preparing',
description: ''
})
const selectedStartDate = ref([
new Date().getFullYear().toString(),
(new Date().getMonth() + 1).toString().padStart(2, '0'),
new Date().getDate().toString().padStart(2, '0')
])
const statusOptions = [
{ text: '筹备中', value: 'preparing' },
{ text: '进行中', value: 'ongoing' },
{ text: '已完成', value: 'completed' },
{ text: '已归档', value: 'archived' },
{ text: '已取消', value: 'cancelled' }
]
const statusText = computed(() => {
const item = statusOptions.find(s => s.value === form.value.status)
return item?.text || ''
})
const customerOptions = ref<{ text: string; value: number }[]>([])
const onStartDateConfirm = ({ selectedValues }: any) => {
form.value.startDate = selectedValues.join('-')
showStartPicker.value = false
}
const onStatusConfirm = ({ selectedOptions }: any) => {
form.value.status = selectedOptions[0].value
showStatusPicker.value = false
}
const onCustomerConfirm = ({ selectedOptions }: any) => {
form.value.customerId = selectedOptions[0].value
form.value.customerName = selectedOptions[0].text
showCustomerPicker.value = false
}
const loadOptions = async () => {
try {
const customerRes = await getCustomerList({ pageNum: 1, pageSize: 100 })
customerOptions.value = ((customerRes as any).data?.records || []).map((item: any) => ({
text: item.customerName,
value: item.customerId
}))
} catch (e) {
console.error('加载选项失败', e)
}
}
const onSubmit = async () => {
if (!form.value.projectName) {
showToast('请输入项目名称')
return
}
submitting.value = true
try {
await createProject({
projectName: form.value.projectName,
customerId: form.value.customerId,
contractAmount: form.value.contractAmount ? parseFloat(form.value.contractAmount) : null,
budgetAmount: form.value.budgetAmount ? parseFloat(form.value.budgetAmount) : null,
startDate: form.value.startDate || null,
status: form.value.status,
description: form.value.description
})
showSuccessToast('提交成功')
router.back()
} catch (e: any) {
showToast(e.message || '提交失败')
} finally {
submitting.value = false
}
}
onMounted(() => {
loadOptions()
})
</script>
<style scoped>
.project-add {
background: #f5f5f5;
min-height: 100vh;
}
.van-cell-group {
margin: 12px;
}
.submit-btn {
margin: 24px 16px;
}
</style>

View File

@ -92,7 +92,7 @@ const loadData = async () => {
const res: any = await getProjectList({ const res: any = await getProjectList({
pageNum: pageNum.value, pageNum: pageNum.value,
pageSize, pageSize,
projectName: searchText.value || undefined keyword: searchText.value || undefined
}) })
const records = res.data?.records || [] const records = res.data?.records || []
if (pageNum.value === 1) { if (pageNum.value === 1) {

View File

@ -0,0 +1,221 @@
<template>
<div class="page receivable-add">
<van-nav-bar title="新增应收款" left-arrow @click-left="$router.back()" />
<van-form @submit="onSubmit">
<van-cell-group inset>
<van-field
v-model="form.customerName"
is-link
readonly
name="customer"
label="关联客户"
placeholder="请选择客户"
:rules="[{ required: true, message: '请选择客户' }]"
required
@click="showCustomerPicker = true"
/>
<van-field
v-model="form.projectName"
is-link
readonly
name="project"
label="关联项目"
placeholder="请选择项目"
@click="showProjectPicker = true"
/>
<van-field
v-model="form.receivableAmount"
name="amount"
label="应收金额"
type="number"
placeholder="请输入应收金额"
:rules="[{ required: true, message: '请输入应收金额' }]"
required
>
<template #button>
<span></span>
</template>
</van-field>
<van-field
v-model="form.receivableDate"
is-link
readonly
name="date"
label="应收日期"
placeholder="请选择日期"
:rules="[{ required: true, message: '请选择应收日期' }]"
required
@click="showDatePicker = true"
/>
<van-field
v-model="form.description"
name="description"
label="备注"
type="textarea"
rows="2"
autosize
placeholder="请输入备注"
/>
</van-cell-group>
<div class="submit-btn">
<van-button round block type="primary" native-type="submit" :loading="submitting">
提交
</van-button>
</div>
</van-form>
<!-- 日期选择器 -->
<van-popup v-model:show="showDatePicker" position="bottom" round>
<van-date-picker
v-model="selectedDate"
@confirm="onDateConfirm"
@cancel="showDatePicker = false"
/>
</van-popup>
<!-- 客户选择器 -->
<van-popup v-model:show="showCustomerPicker" position="bottom" round>
<van-picker
:columns="customerOptions"
@confirm="onCustomerConfirm"
@cancel="showCustomerPicker = false"
/>
</van-popup>
<!-- 项目选择器 -->
<van-popup v-model:show="showProjectPicker" position="bottom" round>
<van-picker
:columns="projectOptions"
@confirm="onProjectConfirm"
@cancel="showProjectPicker = false"
/>
</van-popup>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import { createReceivable, getCustomerList, getProjectList } from '@/api'
const router = useRouter()
const submitting = ref(false)
const showDatePicker = ref(false)
const showCustomerPicker = ref(false)
const showProjectPicker = ref(false)
const form = ref({
customerId: null as number | null,
customerName: '',
projectId: null as number | null,
projectName: '',
receivableAmount: '',
receivableDate: '',
description: ''
})
const selectedDate = ref([
new Date().getFullYear().toString(),
(new Date().getMonth() + 1).toString().padStart(2, '0'),
new Date().getDate().toString().padStart(2, '0')
])
const customerOptions = ref<{ text: string; value: number }[]>([])
const projectOptions = ref<{ text: string; value: number }[]>([])
const onDateConfirm = ({ selectedValues }: any) => {
form.value.receivableDate = selectedValues.join('-')
showDatePicker.value = false
}
const onCustomerConfirm = ({ selectedOptions }: any) => {
form.value.customerId = selectedOptions[0].value
form.value.customerName = selectedOptions[0].text
showCustomerPicker.value = false
}
const onProjectConfirm = ({ selectedOptions }: any) => {
form.value.projectId = selectedOptions[0].value
form.value.projectName = selectedOptions[0].text
showProjectPicker.value = false
}
const loadOptions = async () => {
try {
const [customerRes, projectRes] = await Promise.all([
getCustomerList({ pageNum: 1, pageSize: 100 }),
getProjectList({ pageNum: 1, pageSize: 100 })
])
customerOptions.value = ((customerRes as any).data?.records || []).map((item: any) => ({
text: item.customerName,
value: item.customerId
}))
projectOptions.value = ((projectRes as any).data?.records || []).map((item: any) => ({
text: item.projectName,
value: item.projectId
}))
} catch (e) {
console.error('加载选项失败', e)
}
}
const onSubmit = async () => {
if (!form.value.customerId) {
showToast('请选择客户')
return
}
if (!form.value.receivableAmount) {
showToast('请输入应收金额')
return
}
if (!form.value.receivableDate) {
showToast('请选择应收日期')
return
}
submitting.value = true
try {
await createReceivable({
customerId: form.value.customerId,
projectId: form.value.projectId,
receivableAmount: parseFloat(form.value.receivableAmount),
receivableDate: form.value.receivableDate,
description: form.value.description
})
showSuccessToast('提交成功')
router.back()
} catch (e: any) {
showToast(e.message || '提交失败')
} finally {
submitting.value = false
}
}
onMounted(() => {
loadOptions()
})
</script>
<style scoped>
.receivable-add {
background: #f5f5f5;
min-height: 100vh;
}
.van-cell-group {
margin: 12px;
}
.submit-btn {
margin: 24px 16px;
}
</style>

View File

@ -4,10 +4,15 @@
<div class="back-btn" @click="$router.back()"> <div class="back-btn" @click="$router.back()">
<van-icon name="arrow-left" /> <van-icon name="arrow-left" />
</div> </div>
<span class="header-title">应收款列表</span> <span class="header-title">应收款管理</span>
<div class="placeholder"></div> <div class="placeholder"></div>
</div> </div>
<!-- 搜索栏 -->
<div class="search-bar">
<van-search v-model="searchText" placeholder="搜索客户名称" @search="handleSearch" />
</div>
<van-pull-refresh v-model="refreshing" @refresh="onRefresh"> <van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list v-model:loading="loading" :finished="finished" finished-text="没有更多了" @load="onLoad"> <van-list v-model:loading="loading" :finished="finished" finished-text="没有更多了" @load="onLoad">
<div class="list-container"> <div class="list-container">
@ -37,6 +42,11 @@
</div> </div>
</van-list> </van-list>
</van-pull-refresh> </van-pull-refresh>
<!-- 新增按钮 -->
<div class="add-btn" @click="$router.push('/receivable/add')">
<van-icon name="plus" />
</div>
</div> </div>
</template> </template>
@ -45,6 +55,7 @@ import { ref, onMounted } from 'vue'
import { showToast } from 'vant' import { showToast } from 'vant'
import { getReceivableList } from '@/api' import { getReceivableList } from '@/api'
const searchText = ref('')
const loading = ref(false) const loading = ref(false)
const refreshing = ref(false) const refreshing = ref(false)
const finished = ref(false) const finished = ref(false)
@ -72,20 +83,34 @@ const getStatusName = (status: string) => {
return map[status] || status return map[status] || status
} }
const onLoad = async () => { const loadData = async () => {
try { try {
const res: any = await getReceivableList({ pageNum: pageNum.value, pageSize }) const res: any = await getReceivableList({
pageNum: pageNum.value,
pageSize,
keyword: searchText.value || undefined
})
const records = res.data?.records || [] const records = res.data?.records || []
list.value.push(...records) if (pageNum.value === 1) {
loading.value = false list.value = records
if (records.length < pageSize) {
finished.value = true
} else { } else {
pageNum.value++ list.value.push(...records)
} }
finished.value = records.length < pageSize
loading.value = false
} catch (e: any) { } catch (e: any) {
showToast(e.message || '加载失败') showToast(e.message || '加载失败')
loading.value = false loading.value = false
finished.value = true
}
}
const onLoad = () => {
if (pageNum.value === 1 && list.value.length === 0) {
loadData()
} else {
pageNum.value++
loadData()
} }
} }
@ -94,17 +119,25 @@ const onRefresh = () => {
pageNum.value = 1 pageNum.value = 1
finished.value = false finished.value = false
refreshing.value = false refreshing.value = false
onLoad() loadData()
}
const handleSearch = () => {
pageNum.value = 1
finished.value = false
list.value = []
loadData()
} }
onMounted(() => { onMounted(() => {
onLoad() loadData()
}) })
</script> </script>
<style scoped> <style scoped>
.receivable-list { .receivable-list {
padding: 0 16px; padding: 0 16px;
padding-bottom: 80px;
} }
.header-bar { .header-bar {
@ -112,7 +145,7 @@ onMounted(() => {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 16px 20px; padding: 16px 20px;
margin: 12px -16px 16px; margin: 12px -16px 0;
border-radius: 0; border-radius: 0;
position: sticky; position: sticky;
top: 0; top: 0;
@ -122,6 +155,12 @@ onMounted(() => {
-webkit-backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
} }
.search-bar {
background: #fff;
margin: 0 -16px 12px;
padding: 8px 16px;
}
.back-btn { .back-btn {
width: 36px; width: 36px;
height: 36px; height: 36px;
@ -220,4 +259,27 @@ onMounted(() => {
font-size: 12px; font-size: 12px;
color: var(--mac-text-secondary); color: var(--mac-text-secondary);
} }
.add-btn {
position: fixed;
bottom: 24px;
right: 24px;
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, #34C759, #30D158);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 28px;
box-shadow: 0 4px 12px rgba(52, 199, 89, 0.4);
cursor: pointer;
transition: transform 0.2s ease;
z-index: 100;
}
.add-btn:active {
transform: scale(0.95);
}
</style> </style>

View File

@ -0,0 +1,209 @@
<template>
<div class="page requirement-add">
<van-nav-bar title="新增需求工单" left-arrow @click-left="$router.back()" />
<van-form @submit="onSubmit">
<van-cell-group inset>
<van-field
v-model="form.title"
name="title"
label="需求标题"
placeholder="请输入需求标题"
:rules="[{ required: true, message: '请输入需求标题' }]"
required
/>
<van-field
v-model="form.customerName"
is-link
readonly
name="customer"
label="关联客户"
placeholder="请选择客户"
@click="showCustomerPicker = true"
/>
<van-field
v-model="form.projectName"
is-link
readonly
name="project"
label="关联项目"
placeholder="请选择项目"
@click="showProjectPicker = true"
/>
<van-field
v-model="priorityText"
is-link
readonly
name="priority"
label="优先级"
placeholder="请选择优先级"
@click="showPriorityPicker = true"
/>
<van-field
v-model="form.description"
name="description"
label="需求描述"
type="textarea"
rows="3"
autosize
placeholder="请输入需求描述"
/>
</van-cell-group>
<div class="submit-btn">
<van-button round block type="primary" native-type="submit" :loading="submitting">
提交
</van-button>
</div>
</van-form>
<!-- 优先级选择器 -->
<van-popup v-model:show="showPriorityPicker" position="bottom" round>
<van-picker
:columns="priorityOptions"
@confirm="onPriorityConfirm"
@cancel="showPriorityPicker = false"
/>
</van-popup>
<!-- 客户选择器 -->
<van-popup v-model:show="showCustomerPicker" position="bottom" round>
<van-picker
:columns="customerOptions"
@confirm="onCustomerConfirm"
@cancel="showCustomerPicker = false"
/>
</van-popup>
<!-- 项目选择器 -->
<van-popup v-model:show="showProjectPicker" position="bottom" round>
<van-picker
:columns="projectOptions"
@confirm="onProjectConfirm"
@cancel="showProjectPicker = false"
/>
</van-popup>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import { createRequirement, getCustomerList, getProjectList } from '@/api'
const router = useRouter()
const submitting = ref(false)
const showPriorityPicker = ref(false)
const showCustomerPicker = ref(false)
const showProjectPicker = ref(false)
const form = ref({
title: '',
customerId: null as number | null,
customerName: '',
projectId: null as number | null,
projectName: '',
priority: 'medium',
description: ''
})
const priorityOptions = [
{ text: '高', value: 'high' },
{ text: '中', value: 'medium' },
{ text: '低', value: 'low' }
]
const priorityText = computed(() => {
const item = priorityOptions.find(p => p.value === form.value.priority)
return item?.text || ''
})
const customerOptions = ref<{ text: string; value: number }[]>([])
const projectOptions = ref<{ text: string; value: number }[]>([])
const onPriorityConfirm = ({ selectedOptions }: any) => {
form.value.priority = selectedOptions[0].value
showPriorityPicker.value = false
}
const onCustomerConfirm = ({ selectedOptions }: any) => {
form.value.customerId = selectedOptions[0].value
form.value.customerName = selectedOptions[0].text
showCustomerPicker.value = false
}
const onProjectConfirm = ({ selectedOptions }: any) => {
form.value.projectId = selectedOptions[0].value
form.value.projectName = selectedOptions[0].text
showProjectPicker.value = false
}
const loadOptions = async () => {
try {
const [customerRes, projectRes] = await Promise.all([
getCustomerList({ pageNum: 1, pageSize: 100 }),
getProjectList({ pageNum: 1, pageSize: 100 })
])
customerOptions.value = ((customerRes as any).data?.records || []).map((item: any) => ({
text: item.customerName,
value: item.customerId
}))
projectOptions.value = ((projectRes as any).data?.records || []).map((item: any) => ({
text: item.projectName,
value: item.projectId
}))
} catch (e) {
console.error('加载选项失败', e)
}
}
const onSubmit = async () => {
if (!form.value.title) {
showToast('请输入需求标题')
return
}
submitting.value = true
try {
await createRequirement({
title: form.value.title,
customerId: form.value.customerId,
projectId: form.value.projectId,
priority: form.value.priority,
description: form.value.description
})
showSuccessToast('提交成功')
router.back()
} catch (e: any) {
showToast(e.message || '提交失败')
} finally {
submitting.value = false
}
}
onMounted(() => {
loadOptions()
})
</script>
<style scoped>
.requirement-add {
background: #f5f5f5;
min-height: 100vh;
}
.van-cell-group {
margin: 12px;
}
.submit-btn {
margin: 24px 16px;
}
</style>

View File

@ -0,0 +1,240 @@
<template>
<div class="page requirement-list">
<van-nav-bar title="需求工单" left-arrow @click-left="$router.back()" />
<!-- 搜索栏 -->
<div class="search-bar">
<van-search v-model="searchText" placeholder="搜索需求标题" @search="handleSearch" />
</div>
<!-- 需求列表 -->
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="onLoad"
>
<div class="requirement-card" v-for="item in list" :key="item.requirementId">
<div class="requirement-header">
<span class="requirement-title">{{ item.title }}</span>
<van-tag :type="getStatusType(item.status)">{{ getStatusText(item.status) }}</van-tag>
</div>
<div class="requirement-info">
<div class="info-item">
<van-icon name="user-o" />
<span>{{ item.customerName || '-' }}</span>
</div>
<div class="info-item">
<van-icon name="todo-list-o" />
<span>{{ item.projectName || '-' }}</span>
</div>
</div>
<div class="requirement-detail">
<div class="detail-item">
<span class="label">优先级</span>
<van-tag :type="getPriorityType(item.priority)">{{ getPriorityText(item.priority) }}</van-tag>
</div>
<div class="detail-item">
<span class="label">创建时间</span>
<span class="value">{{ item.createTime }}</span>
</div>
</div>
</div>
</van-list>
</van-pull-refresh>
<!-- 新增按钮 -->
<div class="add-btn" @click="$router.push('/requirement/add')">
<van-icon name="plus" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { getRequirementList } from '@/api'
const searchText = ref('')
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
const list = ref<any[]>([])
const pageNum = ref(1)
const pageSize = 10
const getStatusType = (status: string): 'primary' | 'success' | 'warning' | 'danger' | 'default' => {
const map: Record<string, 'primary' | 'success' | 'warning' | 'danger' | 'default'> = {
'pending': 'warning',
'processing': 'primary',
'completed': 'success',
'cancelled': 'default'
}
return map[status] || 'default'
}
const getStatusText = (status: string) => {
const map: Record<string, string> = {
'pending': '待处理',
'processing': '处理中',
'completed': '已完成',
'cancelled': '已取消'
}
return map[status] || status
}
const getPriorityType = (priority: string): 'primary' | 'success' | 'warning' | 'danger' | 'default' => {
const map: Record<string, 'primary' | 'success' | 'warning' | 'danger' | 'default'> = {
'high': 'danger',
'medium': 'warning',
'low': 'success'
}
return map[priority] || 'default'
}
const getPriorityText = (priority: string) => {
const map: Record<string, string> = {
'high': '高',
'medium': '中',
'low': '低'
}
return map[priority] || priority
}
const loadData = async () => {
try {
const res: any = await getRequirementList({
pageNum: pageNum.value,
pageSize,
keyword: searchText.value || undefined
})
const records = res.data?.records || []
if (pageNum.value === 1) {
list.value = records
} else {
list.value.push(...records)
}
finished.value = records.length < pageSize
} catch (e) {
console.error(e)
finished.value = true
} finally {
loading.value = false
refreshing.value = false
}
}
const onLoad = () => {
pageNum.value++
loadData()
}
const onRefresh = () => {
pageNum.value = 1
finished.value = false
loadData()
}
const handleSearch = () => {
pageNum.value = 1
finished.value = false
list.value = []
loadData()
}
</script>
<style scoped>
.requirement-list {
background: #f5f5f5;
min-height: 100vh;
padding-bottom: 80px;
}
.search-bar {
background: #fff;
padding: 8px 0;
}
.requirement-card {
background: #fff;
margin: 12px;
padding: 16px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.requirement-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.requirement-title {
font-size: 16px;
font-weight: 600;
color: #333;
flex: 1;
margin-right: 8px;
line-height: 1.4;
}
.requirement-info {
display: flex;
gap: 16px;
margin-bottom: 12px;
}
.info-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: #666;
}
.requirement-detail {
display: flex;
justify-content: space-between;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
}
.detail-item {
display: flex;
align-items: center;
gap: 6px;
}
.detail-item .label {
font-size: 12px;
color: #999;
}
.detail-item .value {
font-size: 12px;
color: #666;
}
.add-btn {
position: fixed;
bottom: 24px;
right: 24px;
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, #5856D6, #AF52DE);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 28px;
box-shadow: 0 4px 12px rgba(88, 86, 214, 0.4);
cursor: pointer;
transition: transform 0.2s ease;
}
.add-btn:active {
transform: scale(0.95);
}
</style>