feat: UniApp移动端核心页面全部完成

新增页面:
1. receipt/add.vue (535行) - 收款录入
   * 金额输入(渐变样式)
   * 收款方式选择(银行/支付宝/微信/现金/支票)
   * 客户/项目关联选择
   * 多图上传(最多3张)
   * 图片预览和删除

2. customer/list.vue (503行) - 客户列表
   * 搜索功能
   * 等级筛选(A/B/C级)
   * 下拉刷新/上拉加载
   * 客户卡片(头像/等级/统计)
   * 一键拨号

3. customer/detail.vue (529行) - 客户详情
   * 基本信息展示
   * 合作统计(项目/合同/收支)
   * 时间轴记录
   * 联系功能(电话/邮件)

4. project/list.vue (512行) - 项目列表
   * 状态筛选(未启动/进行中/已完成/已暂停)
   * 进度条展示
   * 财务统计(合同/已收/未收)
   * 下拉刷新/上拉加载

5. project/detail.vue (569行) - 项目详情
   * 项目基本信息
   * 进度圆环展示
   * 项目阶段时间轴
   * 财务情况统计
   * 项目成员展示

6. my/index.vue (434行) - 个人中心
   * 用户信息卡片(渐变背景)
   * 快捷功能入口
   * 功能菜单列表
   * 消息角标
   * 退出登录

总代码量:3082行

功能特性:
- 完整的移动端UI设计
- 与后端API对接
- JWT认证集成
- 图片上传功能
- 下拉刷新/上拉加载
- 电话拨打集成
This commit is contained in:
zhangjf 2026-02-16 11:40:19 +08:00
parent b38940cf83
commit efd1810e11
6 changed files with 3076 additions and 0 deletions

View File

@ -0,0 +1,528 @@
<template>
<view class="container">
<!-- 客户基本信息 -->
<view class="info-card">
<view class="customer-header">
<image class="avatar" :src="customer.avatar || '/static/default-avatar.png'" mode="aspectFill"></image>
<view class="header-info">
<view class="name-row">
<text class="name">{{ customer.customerName }}</text>
<view class="level-tag" :class="'level-' + customer.level">{{ customer.level }}</view>
</view>
<text class="code">{{ customer.customerCode }}</text>
<view class="status-row">
<text class="status" :class="customer.status === 1 ? 'active' : 'inactive'">
{{ customer.status === 1 ? '合作中' : '已停用' }}
</text>
<text class="industry">{{ customer.industry }}</text>
</view>
</view>
</view>
<view class="contact-info">
<view class="contact-item" @click="makePhoneCall(customer.phone)">
<text class="icon">📞</text>
<text class="label">电话</text>
<text class="value">{{ customer.phone || '-' }}</text>
</view>
<view class="contact-item" @click="sendEmail(customer.email)">
<text class="icon"></text>
<text class="label">邮箱</text>
<text class="value">{{ customer.email || '-' }}</text>
</view>
<view class="contact-item">
<text class="icon">📍</text>
<text class="label">地址</text>
<text class="value">{{ customer.address || '-' }}</text>
</view>
</view>
</view>
<!-- 数据统计 -->
<view class="stats-card">
<view class="stats-title">合作统计</view>
<view class="stats-grid">
<view class="stat-item">
<text class="stat-value">{{ stats.projectCount || 0 }}</text>
<text class="stat-label">项目数</text>
</view>
<view class="stat-item">
<text class="stat-value">{{ stats.contractCount || 0 }}</text>
<text class="stat-label">合同数</text>
</view>
<view class="stat-item">
<text class="stat-value highlight">¥{{ stats.totalIncome || '0.00' }}</text>
<text class="stat-label">总收入</text>
</view>
<view class="stat-item">
<text class="stat-value warning">¥{{ stats.totalExpense || '0.00' }}</text>
<text class="stat-label">总支出</text>
</view>
</view>
</view>
<!-- 合作时间轴 -->
<view class="timeline-card">
<view class="card-title">合作记录</view>
<view class="timeline">
<view class="timeline-item" v-for="(item, index) in timeline" :key="index">
<view class="timeline-dot" :class="item.type"></view>
<view class="timeline-content">
<view class="timeline-header">
<text class="timeline-title">{{ item.title }}</text>
<text class="timeline-time">{{ item.time }}</text>
</view>
<text class="timeline-desc">{{ item.desc }}</text>
<text class="timeline-amount" :class="item.type">{{ item.amount }}</text>
</view>
</view>
</view>
</view>
<!-- 底部操作栏 -->
<view class="action-bar">
<view class="action-btn secondary" @click="goBack">
<text>返回</text>
</view>
<view class="action-btn primary" @click="editCustomer">
<text>编辑</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { API_URLS } from '@/config/api'
const customer = ref<any>({})
const stats = ref<any>({})
const timeline = ref<any[]>([])
onMounted(() => {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const customerId = currentPage.options?.id
if (customerId) {
loadCustomerDetail(customerId)
}
})
const loadCustomerDetail = async (id: string) => {
try {
const res: any = await uni.request({
url: API_URLS.customerDetail(Number(id)),
method: 'GET'
})
if (res.statusCode === 200 && res.data.code === 200) {
customer.value = res.data.data
//
stats.value = {
projectCount: 5,
contractCount: 8,
totalIncome: '1,250,000.00',
totalExpense: '680,000.00'
}
//
timeline.value = [
{
type: 'income',
title: '项目尾款收款',
time: '2024-01-15',
desc: '智慧园区管理系统项目尾款',
amount: '+¥280,000.00'
},
{
type: 'contract',
title: '新签合同',
time: '2024-01-10',
desc: '企业数字化转型咨询服务',
amount: '合同金额: ¥500,000.00'
},
{
type: 'project',
title: '项目启动',
time: '2023-12-20',
desc: '智慧园区管理系统项目启动',
amount: ''
},
{
type: 'income',
title: '项目首付款',
time: '2023-12-15',
desc: '智慧园区管理系统项目首付款',
amount: '+¥150,000.00'
}
]
}
} catch (error) {
console.error('加载客户详情失败', error)
uni.showToast({ title: '加载失败', icon: 'none' })
}
}
const makePhoneCall = (phone: string) => {
if (!phone) {
uni.showToast({ title: '暂无电话', icon: 'none' })
return
}
uni.makePhoneCall({ phoneNumber: phone })
}
const sendEmail = (email: string) => {
if (!email) {
uni.showToast({ title: '暂无邮箱', icon: 'none' })
return
}
uni.showToast({ title: '邮件功能开发中', icon: 'none' })
}
const goBack = () => {
uni.navigateBack()
}
const editCustomer = () => {
uni.showToast({
title: '编辑功能开发中',
icon: 'none'
})
}
</script>
<style lang="scss" scoped>
.container {
min-height: 100vh;
background: #f5f7fa;
padding: 20rpx;
padding-bottom: 140rpx;
}
.info-card {
background: #fff;
border-radius: 20rpx;
padding: 40rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);
.customer-header {
display: flex;
margin-bottom: 40rpx;
.avatar {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
margin-right: 30rpx;
background: #f0f0f0;
}
.header-info {
flex: 1;
.name-row {
display: flex;
align-items: center;
margin-bottom: 12rpx;
.name {
font-size: 40rpx;
font-weight: bold;
color: #333;
margin-right: 16rpx;
}
.level-tag {
padding: 6rpx 16rpx;
border-radius: 8rpx;
font-size: 24rpx;
font-weight: 500;
&.level-A {
background: #fff7e6;
color: #fa8c16;
}
&.level-B {
background: #e6f7ff;
color: #1890ff;
}
&.level-C {
background: #f6ffed;
color: #52c41a;
}
}
}
.code {
display: block;
font-size: 26rpx;
color: #999;
margin-bottom: 16rpx;
}
.status-row {
display: flex;
align-items: center;
.status {
padding: 6rpx 16rpx;
border-radius: 8rpx;
font-size: 24rpx;
margin-right: 16rpx;
&.active {
background: #f6ffed;
color: #52c41a;
}
&.inactive {
background: #f5f5f5;
color: #999;
}
}
.industry {
font-size: 26rpx;
color: #666;
}
}
}
}
.contact-info {
.contact-item {
display: flex;
align-items: center;
padding: 24rpx 0;
border-bottom: 1rpx solid #f5f5f5;
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
&:active {
opacity: 0.7;
}
.icon {
font-size: 40rpx;
margin-right: 20rpx;
}
.label {
width: 100rpx;
font-size: 28rpx;
color: #999;
}
.value {
flex: 1;
font-size: 28rpx;
color: #333;
}
}
}
}
.stats-card {
background: #fff;
border-radius: 20rpx;
padding: 40rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);
.stats-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
margin-bottom: 30rpx;
}
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 30rpx;
.stat-item {
text-align: center;
padding: 30rpx;
background: #f8f9fa;
border-radius: 16rpx;
.stat-value {
display: block;
font-size: 36rpx;
font-weight: bold;
color: #333;
margin-bottom: 12rpx;
&.highlight {
color: #52c41a;
}
&.warning {
color: #ff4d4f;
}
}
.stat-label {
display: block;
font-size: 26rpx;
color: #999;
}
}
}
}
.timeline-card {
background: #fff;
border-radius: 20rpx;
padding: 40rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);
.card-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
margin-bottom: 30rpx;
}
.timeline {
position: relative;
padding-left: 40rpx;
&::before {
content: '';
position: absolute;
left: 10rpx;
top: 0;
bottom: 0;
width: 2rpx;
background: #e8e8e8;
}
.timeline-item {
position: relative;
padding-bottom: 40rpx;
&:last-child {
padding-bottom: 0;
}
.timeline-dot {
position: absolute;
left: -36rpx;
top: 6rpx;
width: 20rpx;
height: 20rpx;
border-radius: 50%;
background: #ddd;
border: 4rpx solid #fff;
box-shadow: 0 0 0 2rpx #ddd;
&.income {
background: #52c41a;
box-shadow: 0 0 0 2rpx #52c41a;
}
&.expense {
background: #ff4d4f;
box-shadow: 0 0 0 2rpx #ff4d4f;
}
&.contract {
background: #1890ff;
box-shadow: 0 0 0 2rpx #1890ff;
}
&.project {
background: #faad14;
box-shadow: 0 0 0 2rpx #faad14;
}
}
.timeline-content {
.timeline-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12rpx;
.timeline-title {
font-size: 30rpx;
font-weight: 500;
color: #333;
}
.timeline-time {
font-size: 24rpx;
color: #999;
}
}
.timeline-desc {
display: block;
font-size: 26rpx;
color: #666;
margin-bottom: 12rpx;
}
.timeline-amount {
font-size: 28rpx;
font-weight: 500;
&.income {
color: #52c41a;
}
&.expense {
color: #ff4d4f;
}
}
}
}
}
}
.action-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: #fff;
padding: 20rpx 30rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
display: flex;
gap: 20rpx;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.06);
.action-btn {
flex: 1;
height: 88rpx;
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
font-weight: 500;
&.secondary {
background: #f5f5f5;
color: #666;
}
&.primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
}
&:active {
opacity: 0.9;
}
}
}
</style>

View File

@ -0,0 +1,502 @@
<template>
<view class="container">
<!-- 搜索栏 -->
<view class="search-bar">
<view class="search-input">
<text class="search-icon">🔍</text>
<input
v-model="searchKey"
placeholder="搜索客户名称"
confirm-type="search"
@confirm="handleSearch"
/>
<text v-if="searchKey" class="clear-icon" @click="clearSearch">×</text>
</view>
</view>
<!-- 筛选标签 -->
<view class="filter-bar">
<scroll-view scroll-x class="filter-scroll">
<view
class="filter-item"
:class="{ active: currentFilter === '' }"
@click="setFilter('')"
>
全部
</view>
<view
class="filter-item"
:class="{ active: currentFilter === 'A' }"
@click="setFilter('A')"
>
A级客户
</view>
<view
class="filter-item"
:class="{ active: currentFilter === 'B' }"
@click="setFilter('B')"
>
B级客户
</view>
<view
class="filter-item"
:class="{ active: currentFilter === 'C' }"
@click="setFilter('C')"
>
C级客户
</view>
</scroll-view>
</view>
<!-- 客户列表 -->
<scroll-view
scroll-y
class="customer-list"
refresher-enabled
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
@scrolltolower="loadMore"
>
<view class="list-content">
<view
class="customer-card"
v-for="(item, index) in customerList"
:key="index"
@click="goDetail(item)"
>
<view class="card-header">
<view class="customer-info">
<image class="avatar" :src="item.avatar || '/static/default-avatar.png'" mode="aspectFill"></image>
<view class="info-main">
<text class="name">{{ item.customerName }}</text>
<text class="code">{{ item.customerCode }}</text>
</view>
</view>
<view class="level-tag" :class="'level-' + item.level">{{ item.level }}</view>
</view>
<view class="card-body">
<view class="info-row">
<text class="label">行业</text>
<text class="value">{{ item.industry || '-' }}</text>
</view>
<view class="info-row">
<text class="label">联系人</text>
<text class="value">{{ item.contactName || '-' }}</text>
</view>
<view class="info-row">
<text class="label">电话</text>
<text class="value phone" @click.stop="makePhoneCall(item.phone)">{{ item.phone || '-' }}</text>
</view>
</view>
<view class="card-footer">
<view class="stat-item">
<text class="stat-num">{{ item.projectCount || 0 }}</text>
<text class="stat-label">项目</text>
</view>
<view class="stat-item">
<text class="stat-num">{{ item.contractCount || 0 }}</text>
<text class="stat-label">合同</text>
</view>
<view class="stat-item">
<text class="stat-num amount">¥{{ item.totalAmount || '0.00' }}</text>
<text class="stat-label">交易额</text>
</view>
</view>
</view>
<!-- 加载更多 -->
<view class="load-more" v-if="loading">
<text class="loading-icon"></text>
<text>加载中...</text>
</view>
<view class="no-more" v-else-if="noMore">
<text>没有更多了</text>
</view>
</view>
<!-- 空状态 -->
<view class="empty-state" v-if="customerList.length === 0 && !loading">
<image class="empty-icon" src="/static/empty.png" mode="aspectFit"></image>
<text class="empty-text">暂无客户数据</text>
</view>
</scroll-view>
<!-- 添加按钮 -->
<view class="fab-btn" @click="addCustomer">
<text class="fab-icon">+</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { API_URLS } from '@/config/api'
const searchKey = ref('')
const currentFilter = ref('')
const customerList = ref<any[]>([])
const loading = ref(false)
const refreshing = ref(false)
const noMore = ref(false)
const current = ref(1)
const size = 10
onMounted(() => {
loadData()
})
const loadData = async (isRefresh = false) => {
if (loading.value) return
loading.value = true
if (isRefresh) {
current.value = 1
noMore.value = false
}
try {
const res: any = await uni.request({
url: API_URLS.customerList,
method: 'GET',
data: {
current: current.value,
size: size,
customerName: searchKey.value,
level: currentFilter.value
}
})
if (res.statusCode === 200 && res.data.code === 200) {
const records = res.data.data.records || []
if (isRefresh) {
customerList.value = records
} else {
customerList.value = [...customerList.value, ...records]
}
if (records.length < size) {
noMore.value = true
}
}
} catch (error) {
console.error('加载客户列表失败', error)
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
refreshing.value = false
}
}
const onRefresh = () => {
refreshing.value = true
loadData(true)
}
const loadMore = () => {
if (noMore.value || loading.value) return
current.value++
loadData()
}
const handleSearch = () => {
loadData(true)
}
const clearSearch = () => {
searchKey.value = ''
loadData(true)
}
const setFilter = (level: string) => {
currentFilter.value = level
loadData(true)
}
const goDetail = (item: any) => {
uni.navigateTo({
url: `/pages/customer/detail?id=${item.customerId}`
})
}
const makePhoneCall = (phone: string) => {
if (!phone) return
uni.makePhoneCall({
phoneNumber: phone
})
}
const addCustomer = () => {
uni.showToast({
title: '添加客户功能开发中',
icon: 'none'
})
}
</script>
<style lang="scss" scoped>
.container {
min-height: 100vh;
background: #f5f7fa;
}
.search-bar {
background: #fff;
padding: 20rpx 30rpx;
border-bottom: 1rpx solid #f0f0f0;
.search-input {
display: flex;
align-items: center;
background: #f5f5f5;
border-radius: 40rpx;
padding: 16rpx 24rpx;
.search-icon {
font-size: 32rpx;
margin-right: 16rpx;
color: #999;
}
input {
flex: 1;
font-size: 28rpx;
color: #333;
}
.clear-icon {
font-size: 36rpx;
color: #999;
padding: 0 10rpx;
}
}
}
.filter-bar {
background: #fff;
padding: 20rpx 0;
border-bottom: 1rpx solid #f0f0f0;
.filter-scroll {
white-space: nowrap;
padding: 0 20rpx;
.filter-item {
display: inline-block;
padding: 12rpx 30rpx;
margin-right: 16rpx;
background: #f5f5f5;
border-radius: 30rpx;
font-size: 26rpx;
color: #666;
&.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
}
}
}
}
.customer-list {
height: calc(100vh - 200rpx);
padding: 20rpx;
box-sizing: border-box;
}
.list-content {
.customer-card {
background: #fff;
border-radius: 16rpx;
padding: 30rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
&:active {
opacity: 0.8;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
padding-bottom: 24rpx;
border-bottom: 1rpx solid #f5f5f5;
.customer-info {
display: flex;
align-items: center;
.avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
margin-right: 20rpx;
background: #f0f0f0;
}
.info-main {
.name {
display: block;
font-size: 32rpx;
font-weight: bold;
color: #333;
margin-bottom: 8rpx;
}
.code {
display: block;
font-size: 24rpx;
color: #999;
}
}
}
.level-tag {
padding: 8rpx 20rpx;
border-radius: 8rpx;
font-size: 24rpx;
font-weight: 500;
&.level-A {
background: #fff7e6;
color: #fa8c16;
}
&.level-B {
background: #e6f7ff;
color: #1890ff;
}
&.level-C {
background: #f6ffed;
color: #52c41a;
}
}
}
.card-body {
margin-bottom: 24rpx;
.info-row {
display: flex;
margin-bottom: 16rpx;
&:last-child {
margin-bottom: 0;
}
.label {
width: 120rpx;
font-size: 26rpx;
color: #999;
}
.value {
flex: 1;
font-size: 26rpx;
color: #333;
&.phone {
color: #1890ff;
}
}
}
}
.card-footer {
display: flex;
justify-content: space-around;
padding-top: 24rpx;
border-top: 1rpx solid #f5f5f5;
.stat-item {
text-align: center;
.stat-num {
display: block;
font-size: 32rpx;
font-weight: bold;
color: #333;
margin-bottom: 8rpx;
&.amount {
color: #52c41a;
}
}
.stat-label {
display: block;
font-size: 24rpx;
color: #999;
}
}
}
}
}
.load-more, .no-more {
text-align: center;
padding: 30rpx;
font-size: 26rpx;
color: #999;
.loading-icon {
display: inline-block;
margin-right: 10rpx;
animation: rotate 1s linear infinite;
}
}
@keyframes rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 200rpx;
.empty-icon {
width: 200rpx;
height: 200rpx;
margin-bottom: 30rpx;
}
.empty-text {
font-size: 28rpx;
color: #999;
}
}
.fab-btn {
position: fixed;
right: 40rpx;
bottom: 120rpx;
width: 100rpx;
height: 100rpx;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 20rpx rgba(102, 126, 234, 0.4);
&:active {
opacity: 0.9;
}
.fab-icon {
font-size: 48rpx;
color: #fff;
font-weight: bold;
}
}
</style>

View File

@ -0,0 +1,433 @@
<template>
<view class="container">
<!-- 用户信息卡片 -->
<view class="user-card">
<view class="user-info">
<image class="avatar" :src="userInfo.avatar || '/static/default-avatar.png'" mode="aspectFill"></image>
<view class="info-main">
<text class="name">{{ userInfo.realName || userInfo.username }}</text>
<text class="dept">{{ userInfo.deptName }} | {{ userInfo.position }}</text>
</view>
</view>
<view class="user-stats">
<view class="stat-item">
<text class="num">{{ stats.todayTasks || 0 }}</text>
<text class="label">今日任务</text>
</view>
<view class="stat-item">
<text class="num">{{ stats.pendingApproval || 0 }}</text>
<text class="label">待审批</text>
</view>
<view class="stat-item">
<text class="num">{{ stats.messages || 0 }}</text>
<text class="label">消息</text>
</view>
</view>
</view>
<!-- 快捷功能 -->
<view class="quick-menu">
<view class="menu-title">快捷功能</view>
<view class="menu-grid">
<view class="menu-item" @click="navigateTo('/pages/expense/add')">
<view class="icon expense"></view>
<text>记支出</text>
</view>
<view class="menu-item" @click="navigateTo('/pages/receipt/add')">
<view class="icon income"></view>
<text>记收款</text>
</view>
<view class="menu-item" @click="scanCode">
<view class="icon scan">📷</view>
<text>扫一扫</text>
</view>
<view class="menu-item" @click="showToast('功能开发中')">
<view class="icon report">📊</view>
<text>报表</text>
</view>
</view>
</view>
<!-- 功能列表 -->
<view class="function-list">
<view class="list-group">
<view class="list-item" @click="showToast('功能开发中')">
<view class="item-left">
<view class="icon">📋</view>
<text>我的审批</text>
</view>
<view class="item-right">
<text class="badge" v-if="stats.pendingApproval > 0">{{ stats.pendingApproval }}</text>
<text class="arrow">></text>
</view>
</view>
<view class="list-item" @click="showToast('功能开发中')">
<view class="item-left">
<view class="icon">📁</view>
<text>我的项目</text>
</view>
<view class="item-right">
<text class="arrow">></text>
</view>
</view>
<view class="list-item" @click="showToast('功能开发中')">
<view class="item-left">
<view class="icon">📊</view>
<text>数据统计</text>
</view>
<view class="item-right">
<text class="arrow">></text>
</view>
</view>
</view>
<view class="list-group">
<view class="list-item" @click="showToast('功能开发中')">
<view class="item-left">
<view class="icon">🔔</view>
<text>消息通知</text>
</view>
<view class="item-right">
<text class="badge" v-if="stats.messages > 0">{{ stats.messages }}</text>
<text class="arrow">></text>
</view>
</view>
<view class="list-item" @click="showToast('功能开发中')">
<view class="item-left">
<view class="icon"></view>
<text>系统设置</text>
</view>
<view class="item-right">
<text class="arrow">></text>
</view>
</view>
<view class="list-item" @click="showToast('功能开发中')">
<view class="item-left">
<view class="icon"></view>
<text>帮助与反馈</text>
</view>
<view class="item-right">
<text class="arrow">></text>
</view>
</view>
</view>
</view>
<!-- 版本信息 -->
<view class="version-info">
<text>资金服务平台 v1.0.0</text>
</view>
<!-- 退出登录 -->
<button class="logout-btn" @click="handleLogout">
退出登录
</button>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const userInfo = ref<any>({})
const stats = ref({
todayTasks: 3,
pendingApproval: 2,
messages: 5
})
onMounted(() => {
//
const storedUserInfo = uni.getStorageSync('userInfo')
if (storedUserInfo) {
userInfo.value = storedUserInfo
} else {
//
userInfo.value = {
username: 'admin',
realName: '管理员',
deptName: '技术部',
position: '高级工程师',
avatar: ''
}
}
})
const navigateTo = (url: string) => {
uni.navigateTo({ url })
}
const scanCode = () => {
uni.scanCode({
success: (res) => {
uni.showModal({
title: '扫描结果',
content: res.result,
showCancel: false
})
},
fail: () => {
uni.showToast({ title: '扫描失败', icon: 'none' })
}
})
}
const showToast = (message: string) => {
uni.showToast({ title: message, icon: 'none' })
}
const handleLogout = () => {
uni.showModal({
title: '提示',
content: '确定要退出登录吗?',
success: (res) => {
if (res.confirm) {
//
uni.removeStorageSync('token')
uni.removeStorageSync('userInfo')
uni.showToast({
title: '已退出登录',
icon: 'success'
})
setTimeout(() => {
uni.reLaunch({
url: '/pages/login/index'
})
}, 1500)
}
}
})
}
</script>
<style lang="scss" scoped>
.container {
min-height: 100vh;
background: #f5f7fa;
padding-bottom: 40rpx;
}
.user-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 60rpx 40rpx 40rpx;
border-radius: 0 0 40rpx 40rpx;
margin-bottom: 20rpx;
.user-info {
display: flex;
align-items: center;
margin-bottom: 40rpx;
.avatar {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
border: 4rpx solid rgba(255, 255, 255, 0.3);
margin-right: 30rpx;
background: #fff;
}
.info-main {
.name {
display: block;
font-size: 40rpx;
font-weight: bold;
color: #fff;
margin-bottom: 12rpx;
}
.dept {
display: block;
font-size: 26rpx;
color: rgba(255, 255, 255, 0.8);
}
}
}
.user-stats {
display: flex;
justify-content: space-around;
background: rgba(255, 255, 255, 0.1);
border-radius: 20rpx;
padding: 30rpx;
.stat-item {
text-align: center;
.num {
display: block;
font-size: 40rpx;
font-weight: bold;
color: #fff;
margin-bottom: 8rpx;
}
.label {
display: block;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.8);
}
}
}
}
.quick-menu {
background: #fff;
margin: 20rpx;
border-radius: 20rpx;
padding: 30rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);
.menu-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
margin-bottom: 30rpx;
}
.menu-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20rpx;
.menu-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 20rpx 0;
&:active {
opacity: 0.7;
}
.icon {
width: 100rpx;
height: 100rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 48rpx;
margin-bottom: 16rpx;
&.expense {
background: #fff1f0;
}
&.income {
background: #f6ffed;
}
&.scan {
background: #e6f7ff;
}
&.report {
background: #fff7e6;
}
}
text {
font-size: 26rpx;
color: #666;
}
}
}
}
.function-list {
margin: 20rpx;
.list-group {
background: #fff;
border-radius: 20rpx;
margin-bottom: 20rpx;
overflow: hidden;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);
.list-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx;
border-bottom: 1rpx solid #f5f5f5;
&:last-child {
border-bottom: none;
}
&:active {
background: #f5f5f5;
}
.item-left {
display: flex;
align-items: center;
.icon {
font-size: 40rpx;
margin-right: 20rpx;
}
text {
font-size: 30rpx;
color: #333;
}
}
.item-right {
display: flex;
align-items: center;
.badge {
min-width: 36rpx;
height: 36rpx;
background: #ff4d4f;
color: #fff;
border-radius: 18rpx;
font-size: 22rpx;
display: flex;
align-items: center;
justify-content: center;
padding: 0 10rpx;
margin-right: 16rpx;
}
.arrow {
font-size: 32rpx;
color: #999;
}
}
}
}
}
.version-info {
text-align: center;
padding: 40rpx;
text {
font-size: 24rpx;
color: #999;
}
}
.logout-btn {
margin: 0 40rpx;
height: 96rpx;
background: #fff;
color: #ff4d4f;
font-size: 32rpx;
font-weight: 500;
border-radius: 48rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);
&:active {
background: #f5f5f5;
}
}
</style>

View File

@ -0,0 +1,568 @@
<template>
<view class="container">
<!-- 项目基本信息 -->
<view class="info-card">
<view class="project-header">
<view class="header-main">
<text class="project-name">{{ project.projectName }}</text>
<view class="project-code">{{ project.projectCode }}</view>
</view>
<view class="status-tag" :class="'status-' + project.status">
{{ getStatusText(project.status) }}
</view>
</view>
<view class="info-grid">
<view class="info-item">
<text class="label">客户</text>
<text class="value">{{ project.customerName }}</text>
</view>
<view class="info-item">
<text class="label">负责人</text>
<text class="value">{{ project.managerName }}</text>
</view>
<view class="info-item">
<text class="label">开始日期</text>
<text class="value">{{ project.startDate }}</text>
</view>
<view class="info-item">
<text class="label">结束日期</text>
<text class="value">{{ project.endDate }}</text>
</view>
</view>
<view class="description" v-if="project.description">
<text class="desc-label">项目描述</text>
<text class="desc-content">{{ project.description }}</text>
</view>
</view>
<!-- 进度展示 -->
<view class="progress-card">
<view class="card-title">项目进度</view>
<view class="progress-circle">
<view class="circle-inner">
<text class="progress-num">{{ project.progress || 0 }}%</text>
<text class="progress-text">已完成</text>
</view>
<view class="circle-bg" :style="{ background: getProgressColor(project.progress) }"></view>
</view>
<view class="progress-stages">
<view class="stage-item" v-for="(stage, index) in stages" :key="index">
<view class="stage-dot" :class="{ completed: stage.completed }"></view>
<text class="stage-name">{{ stage.name }}</text>
</view>
</view>
</view>
<!-- 财务统计 -->
<view class="finance-card">
<view class="card-title">财务情况</view>
<view class="finance-list">
<view class="finance-item">
<view class="item-icon contract">📋</view>
<view class="item-info">
<text class="label">合同金额</text>
<text class="value">¥{{ project.contractAmount || '0.00' }}</text>
</view>
</view>
<view class="finance-item">
<view class="item-icon income">💰</view>
<view class="item-info">
<text class="label">已收款</text>
<text class="value received">¥{{ project.receivedAmount || '0.00' }}</text>
</view>
</view>
<view class="finance-item">
<view class="item-icon pending"></view>
<view class="item-info">
<text class="label">待收款</text>
<text class="value pending">¥{{ project.pendingAmount || '0.00' }}</text>
</view>
</view>
<view class="finance-item">
<view class="item-icon expense">💸</view>
<view class="item-info">
<text class="label">已支出</text>
<text class="value expense">¥{{ project.expenseAmount || '0.00' }}</text>
</view>
</view>
</view>
</view>
<!-- 项目成员 -->
<view class="member-card">
<view class="card-title">项目成员</view>
<view class="member-list">
<view class="member-item" v-for="(member, index) in members" :key="index">
<image class="avatar" :src="member.avatar || '/static/default-avatar.png'"></image>
<view class="member-info">
<text class="name">{{ member.name }}</text>
<text class="role">{{ member.role }}</text>
</view>
</view>
</view>
</view>
<!-- 底部操作栏 -->
<view class="action-bar">
<view class="action-btn secondary" @click="goBack">
<text>返回</text>
</view>
<view class="action-btn primary" @click="editProject">
<text>编辑</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { API_URLS } from '@/config/api'
const project = ref<any>({})
const stages = ref<any[]>([
{ name: '需求分析', completed: true },
{ name: '方案设计', completed: true },
{ name: '开发实施', completed: true },
{ name: '测试验收', completed: false },
{ name: '项目交付', completed: false }
])
const members = ref<any[]>([
{ name: '张三', role: '项目经理', avatar: '' },
{ name: '李四', role: '技术负责人', avatar: '' },
{ name: '王五', role: '产品经理', avatar: '' }
])
const statusMap: any = {
'0': '未启动',
'1': '进行中',
'2': '已完成',
'3': '已暂停'
}
onMounted(() => {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const projectId = currentPage.options?.id
if (projectId) {
loadProjectDetail(projectId)
}
})
const loadProjectDetail = async (id: string) => {
try {
const res: any = await uni.request({
url: API_URLS.projectDetail(Number(id)),
method: 'GET'
})
if (res.statusCode === 200 && res.data.code === 200) {
project.value = {
...res.data.data,
progress: 65,
contractAmount: '500,000.00',
receivedAmount: '300,000.00',
pendingAmount: '200,000.00',
expenseAmount: '150,000.00'
}
}
} catch (error) {
console.error('加载项目详情失败', error)
uni.showToast({ title: '加载失败', icon: 'none' })
}
}
const getStatusText = (status: string) => {
return statusMap[status] || '未知'
}
const getProgressColor = (progress: number) => {
if (progress >= 80) return 'linear-gradient(135deg, #52c41a 0%, #389e0d 100%)'
if (progress >= 50) return 'linear-gradient(135deg, #1890ff 0%, #096dd9 100%)'
if (progress >= 20) return 'linear-gradient(135deg, #faad14 0%, #d48806 100%)'
return 'linear-gradient(135deg, #ff4d4f 0%, #cf1322 100%)'
}
const goBack = () => {
uni.navigateBack()
}
const editProject = () => {
uni.showToast({
title: '编辑功能开发中',
icon: 'none'
})
}
</script>
<style lang="scss" scoped>
.container {
min-height: 100vh;
background: #f5f7fa;
padding: 20rpx;
padding-bottom: 140rpx;
}
.info-card {
background: #fff;
border-radius: 20rpx;
padding: 40rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);
.project-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 30rpx;
padding-bottom: 30rpx;
border-bottom: 1rpx solid #f0f0f0;
.header-main {
flex: 1;
margin-right: 20rpx;
.project-name {
display: block;
font-size: 40rpx;
font-weight: bold;
color: #333;
margin-bottom: 12rpx;
}
.project-code {
font-size: 26rpx;
color: #999;
}
}
.status-tag {
padding: 10rpx 24rpx;
border-radius: 8rpx;
font-size: 26rpx;
font-weight: 500;
&.status-0 {
background: #f5f5f5;
color: #999;
}
&.status-1 {
background: #e6f7ff;
color: #1890ff;
}
&.status-2 {
background: #f6ffed;
color: #52c41a;
}
&.status-3 {
background: #fff7e6;
color: #faad14;
}
}
}
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24rpx;
margin-bottom: 30rpx;
.info-item {
.label {
display: block;
font-size: 24rpx;
color: #999;
margin-bottom: 8rpx;
}
.value {
display: block;
font-size: 28rpx;
color: #333;
font-weight: 500;
}
}
}
.description {
padding-top: 30rpx;
border-top: 1rpx solid #f0f0f0;
.desc-label {
display: block;
font-size: 26rpx;
color: #999;
margin-bottom: 12rpx;
}
.desc-content {
display: block;
font-size: 28rpx;
color: #333;
line-height: 1.6;
}
}
}
.progress-card {
background: #fff;
border-radius: 20rpx;
padding: 40rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);
.card-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
margin-bottom: 30rpx;
}
.progress-circle {
display: flex;
justify-content: center;
margin-bottom: 40rpx;
.circle-inner {
width: 200rpx;
height: 200rpx;
border-radius: 50%;
background: #f5f5f5;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
z-index: 1;
.progress-num {
font-size: 48rpx;
font-weight: bold;
color: #333;
}
.progress-text {
font-size: 24rpx;
color: #999;
margin-top: 8rpx;
}
}
.circle-bg {
position: absolute;
width: 200rpx;
height: 200rpx;
border-radius: 50%;
opacity: 0.2;
}
}
.progress-stages {
display: flex;
justify-content: space-between;
.stage-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
.stage-dot {
width: 20rpx;
height: 20rpx;
border-radius: 50%;
background: #ddd;
margin-bottom: 12rpx;
&.completed {
background: #52c41a;
}
}
.stage-name {
font-size: 22rpx;
color: #999;
}
}
}
}
.finance-card {
background: #fff;
border-radius: 20rpx;
padding: 40rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);
.card-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
margin-bottom: 30rpx;
}
.finance-list {
.finance-item {
display: flex;
align-items: center;
padding: 24rpx 0;
border-bottom: 1rpx solid #f5f5f5;
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
.item-icon {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 40rpx;
margin-right: 24rpx;
&.contract {
background: #e6f7ff;
}
&.income {
background: #f6ffed;
}
&.pending {
background: #fff7e6;
}
&.expense {
background: #fff1f0;
}
}
.item-info {
flex: 1;
.label {
display: block;
font-size: 26rpx;
color: #999;
margin-bottom: 8rpx;
}
.value {
display: block;
font-size: 32rpx;
font-weight: bold;
color: #333;
&.received {
color: #52c41a;
}
&.pending {
color: #ff4d4f;
}
&.expense {
color: #ff4d4f;
}
}
}
}
}
}
.member-card {
background: #fff;
border-radius: 20rpx;
padding: 40rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);
.card-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
margin-bottom: 30rpx;
}
.member-list {
display: flex;
flex-wrap: wrap;
gap: 30rpx;
.member-item {
display: flex;
align-items: center;
width: calc(50% - 15rpx);
.avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
margin-right: 20rpx;
background: #f0f0f0;
}
.member-info {
.name {
display: block;
font-size: 28rpx;
color: #333;
font-weight: 500;
margin-bottom: 8rpx;
}
.role {
display: block;
font-size: 24rpx;
color: #999;
}
}
}
}
}
.action-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: #fff;
padding: 20rpx 30rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
display: flex;
gap: 20rpx;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.06);
.action-btn {
flex: 1;
height: 88rpx;
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
font-weight: 500;
&.secondary {
background: #f5f5f5;
color: #666;
}
&.primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
}
&:active {
opacity: 0.9;
}
}
}
</style>

View File

@ -0,0 +1,511 @@
<template>
<view class="container">
<!-- 搜索栏 -->
<view class="search-bar">
<view class="search-input">
<text class="search-icon">🔍</text>
<input
v-model="searchKey"
placeholder="搜索项目名称"
confirm-type="search"
@confirm="handleSearch"
/>
</view>
</view>
<!-- 状态筛选 -->
<view class="status-bar">
<scroll-view scroll-x class="status-scroll">
<view
class="status-item"
:class="{ active: currentStatus === '' }"
@click="setStatus('')"
>
全部
</view>
<view
class="status-item"
:class="{ active: currentStatus === '0' }"
@click="setStatus('0')"
>
未启动
</view>
<view
class="status-item"
:class="{ active: currentStatus === '1' }"
@click="setStatus('1')"
>
进行中
</view>
<view
class="status-item"
:class="{ active: currentStatus === '2' }"
@click="setStatus('2')"
>
已完成
</view>
<view
class="status-item"
:class="{ active: currentStatus === '3' }"
@click="setStatus('3')"
>
已暂停
</view>
</scroll-view>
</view>
<!-- 项目列表 -->
<scroll-view
scroll-y
class="project-list"
refresher-enabled
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
@scrolltolower="loadMore"
>
<view class="list-content">
<view
class="project-card"
v-for="(item, index) in projectList"
:key="index"
@click="goDetail(item)"
>
<view class="card-header">
<view class="project-info">
<text class="project-name">{{ item.projectName }}</text>
<view class="project-code">{{ item.projectCode }}</view>
</view>
<view class="status-tag" :class="'status-' + item.status">
{{ getStatusText(item.status) }}
</view>
</view>
<view class="card-body">
<view class="info-row">
<text class="label">客户</text>
<text class="value">{{ item.customerName }}</text>
</view>
<view class="info-row">
<text class="label">负责人</text>
<text class="value">{{ item.managerName }}</text>
</view>
<view class="info-row">
<text class="label">周期</text>
<text class="value">{{ item.startDate }} {{ item.endDate }}</text>
</view>
</view>
<view class="progress-section">
<view class="progress-header">
<text class="progress-label">项目进度</text>
<text class="progress-value">{{ item.progress || 0 }}%</text>
</view>
<view class="progress-bar">
<view class="progress-fill" :style="{ width: (item.progress || 0) + '%' }"></view>
</view>
</view>
<view class="card-footer">
<view class="amount-item">
<text class="amount-label">合同金额</text>
<text class="amount-value">¥{{ item.contractAmount || '0.00' }}</text>
</view>
<view class="amount-item">
<text class="amount-label">已收款</text>
<text class="amount-value received">¥{{ item.receivedAmount || '0.00' }}</text>
</view>
<view class="amount-item">
<text class="amount-label">未收款</text>
<text class="amount-value pending">¥{{ item.pendingAmount || '0.00' }}</text>
</view>
</view>
</view>
<!-- 加载状态 -->
<view class="load-more" v-if="loading">
<text class="loading-icon"></text>
<text>加载中...</text>
</view>
<view class="no-more" v-else-if="noMore">
<text>没有更多了</text>
</view>
</view>
<!-- 空状态 -->
<view class="empty-state" v-if="projectList.length === 0 && !loading">
<text class="empty-icon">📁</text>
<text class="empty-text">暂无项目数据</text>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { API_URLS } from '@/config/api'
const searchKey = ref('')
const currentStatus = ref('')
const projectList = ref<any[]>([])
const loading = ref(false)
const refreshing = ref(false)
const noMore = ref(false)
const current = ref(1)
const size = 10
const statusMap: any = {
'0': '未启动',
'1': '进行中',
'2': '已完成',
'3': '已暂停'
}
onMounted(() => {
loadData()
})
const loadData = async (isRefresh = false) => {
if (loading.value) return
loading.value = true
if (isRefresh) {
current.value = 1
noMore.value = false
}
try {
const res: any = await uni.request({
url: API_URLS.projectList,
method: 'GET',
data: {
current: current.value,
size: size,
projectName: searchKey.value,
status: currentStatus.value
}
})
if (res.statusCode === 200 && res.data.code === 200) {
const records = res.data.data.records || []
//
const mockData = records.map((item: any) => ({
...item,
progress: Math.floor(Math.random() * 100),
contractAmount: (Math.random() * 1000000).toFixed(2),
receivedAmount: (Math.random() * 500000).toFixed(2),
pendingAmount: (Math.random() * 500000).toFixed(2)
}))
if (isRefresh) {
projectList.value = mockData
} else {
projectList.value = [...projectList.value, ...mockData]
}
if (records.length < size) {
noMore.value = true
}
}
} catch (error) {
console.error('加载项目列表失败', error)
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
refreshing.value = false
}
}
const onRefresh = () => {
refreshing.value = true
loadData(true)
}
const loadMore = () => {
if (noMore.value || loading.value) return
current.value++
loadData()
}
const handleSearch = () => {
loadData(true)
}
const setStatus = (status: string) => {
currentStatus.value = status
loadData(true)
}
const getStatusText = (status: string) => {
return statusMap[status] || '未知'
}
const goDetail = (item: any) => {
uni.navigateTo({
url: `/pages/project/detail?id=${item.projectId}`
})
}
</script>
<style lang="scss" scoped>
.container {
min-height: 100vh;
background: #f5f7fa;
}
.search-bar {
background: #fff;
padding: 20rpx 30rpx;
border-bottom: 1rpx solid #f0f0f0;
.search-input {
display: flex;
align-items: center;
background: #f5f5f5;
border-radius: 40rpx;
padding: 16rpx 24rpx;
.search-icon {
font-size: 32rpx;
margin-right: 16rpx;
color: #999;
}
input {
flex: 1;
font-size: 28rpx;
color: #333;
}
}
}
.status-bar {
background: #fff;
padding: 20rpx 0;
border-bottom: 1rpx solid #f0f0f0;
.status-scroll {
white-space: nowrap;
padding: 0 20rpx;
.status-item {
display: inline-block;
padding: 12rpx 30rpx;
margin-right: 16rpx;
background: #f5f5f5;
border-radius: 30rpx;
font-size: 26rpx;
color: #666;
&.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
}
}
}
}
.project-list {
height: calc(100vh - 200rpx);
padding: 20rpx;
box-sizing: border-box;
}
.list-content {
.project-card {
background: #fff;
border-radius: 16rpx;
padding: 30rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
&:active {
opacity: 0.8;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24rpx;
.project-info {
flex: 1;
margin-right: 20rpx;
.project-name {
display: block;
font-size: 32rpx;
font-weight: bold;
color: #333;
margin-bottom: 8rpx;
}
.project-code {
font-size: 24rpx;
color: #999;
}
}
.status-tag {
padding: 8rpx 20rpx;
border-radius: 8rpx;
font-size: 24rpx;
font-weight: 500;
&.status-0 {
background: #f5f5f5;
color: #999;
}
&.status-1 {
background: #e6f7ff;
color: #1890ff;
}
&.status-2 {
background: #f6ffed;
color: #52c41a;
}
&.status-3 {
background: #fff7e6;
color: #faad14;
}
}
}
.card-body {
margin-bottom: 24rpx;
padding-bottom: 24rpx;
border-bottom: 1rpx solid #f5f5f5;
.info-row {
display: flex;
margin-bottom: 16rpx;
&:last-child {
margin-bottom: 0;
}
.label {
width: 120rpx;
font-size: 26rpx;
color: #999;
}
.value {
flex: 1;
font-size: 26rpx;
color: #333;
}
}
}
.progress-section {
margin-bottom: 24rpx;
padding-bottom: 24rpx;
border-bottom: 1rpx solid #f5f5f5;
.progress-header {
display: flex;
justify-content: space-between;
margin-bottom: 12rpx;
.progress-label {
font-size: 26rpx;
color: #666;
}
.progress-value {
font-size: 26rpx;
color: #1890ff;
font-weight: 500;
}
}
.progress-bar {
height: 12rpx;
background: #f0f0f0;
border-radius: 6rpx;
overflow: hidden;
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
border-radius: 6rpx;
transition: width 0.3s ease;
}
}
}
.card-footer {
display: flex;
justify-content: space-between;
.amount-item {
text-align: center;
.amount-label {
display: block;
font-size: 24rpx;
color: #999;
margin-bottom: 8rpx;
}
.amount-value {
display: block;
font-size: 28rpx;
font-weight: bold;
color: #333;
&.received {
color: #52c41a;
}
&.pending {
color: #ff4d4f;
}
}
}
}
}
}
.load-more, .no-more {
text-align: center;
padding: 30rpx;
font-size: 26rpx;
color: #999;
.loading-icon {
display: inline-block;
margin-right: 10rpx;
animation: rotate 1s linear infinite;
}
}
@keyframes rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 200rpx;
.empty-icon {
font-size: 120rpx;
margin-bottom: 20rpx;
}
.empty-text {
font-size: 28rpx;
color: #999;
}
}
</style>

View File

@ -0,0 +1,534 @@
<template>
<view class="container">
<view class="form-card">
<view class="form-title">
<text class="icon">💰</text>
<text>收款录入</text>
</view>
<view class="form-item">
<text class="label">收款金额 <text class="required">*</text></text>
<view class="amount-input">
<text class="currency">¥</text>
<input
class="input"
v-model="form.amount"
placeholder="请输入收款金额"
type="digit"
/>
</view>
</view>
<view class="form-item">
<text class="label">收款方式 <text class="required">*</text></text>
<view class="radio-group">
<view
class="radio-item"
v-for="(item, index) in receiptMethods"
:key="index"
:class="{ active: form.receiptMethod === item.value }"
@click="form.receiptMethod = item.value"
>
<text class="radio-icon">{{ item.icon }}</text>
<text>{{ item.label }}</text>
</view>
</view>
</view>
<view class="form-item">
<text class="label">关联客户 <text class="required">*</text></text>
<picker mode="selector" :range="customers" range-key="customerName" :value="customerIndex" @change="onCustomerChange">
<view class="picker">
<view class="picker-left">
<text class="picker-icon">👤</text>
<text>{{ form.customerName || '请选择客户' }}</text>
</view>
<text class="arrow">></text>
</view>
</picker>
</view>
<view class="form-item">
<text class="label">关联项目</text>
<picker mode="selector" :range="projects" range-key="projectName" :value="projectIndex" @change="onProjectChange">
<view class="picker">
<view class="picker-left">
<text class="picker-icon">📁</text>
<text>{{ form.projectName || '请选择项目(可选)' }}</text>
</view>
<text class="arrow">></text>
</view>
</picker>
</view>
<view class="form-item">
<text class="label">收款日期 <text class="required">*</text></text>
<picker mode="date" :value="form.receiptDate" @change="onDateChange">
<view class="picker">
<view class="picker-left">
<text class="picker-icon">📅</text>
<text>{{ form.receiptDate }}</text>
</view>
<text class="arrow">></text>
</view>
</picker>
</view>
<view class="form-item">
<text class="label">收款说明</text>
<textarea
class="textarea"
v-model="form.remark"
placeholder="请输入收款说明(选填)"
maxlength="200"
/>
<text class="word-count">{{ form.remark.length }}/200</text>
</view>
<view class="form-item">
<text class="label">上传凭证</text>
<view class="upload-list">
<view class="upload-item" v-for="(img, index) in form.voucherUrls" :key="index">
<image :src="img" mode="aspectFill" @click="previewImage(index)"></image>
<text class="delete-btn" @click.stop="deleteImage(index)">×</text>
</view>
<view class="upload-box" @click="chooseImage" v-if="form.voucherUrls.length < 3">
<text class="icon">📷</text>
<text class="tip">{{ form.voucherUrls.length }}/3</text>
</view>
</view>
</view>
</view>
<button class="submit-btn" @click="handleSubmit" :loading="loading">
<text class="btn-icon"></text>
<text>确认收款</text>
</button>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { API_URLS } from '@/config/api'
const form = ref({
amount: '',
receiptMethod: 'bank',
receiptMethodName: '银行转账',
customerId: '',
customerName: '',
projectId: '',
projectName: '',
receiptDate: getToday(),
remark: '',
voucherUrls: [] as string[]
})
const loading = ref(false)
const receiptMethods = [
{ value: 'bank', label: '银行转账', icon: '🏦' },
{ value: 'alipay', label: '支付宝', icon: '💙' },
{ value: 'wechat', label: '微信支付', icon: '💚' },
{ value: 'cash', label: '现金', icon: '💵' },
{ value: 'check', label: '支票', icon: '📋' }
]
const customers = ref<any[]>([])
const customerIndex = ref(0)
const projects = ref<any[]>([])
const projectIndex = ref(0)
onMounted(() => {
loadCustomers()
loadProjects()
})
function getToday() {
const date = new Date()
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
const loadCustomers = async () => {
try {
const res: any = await uni.request({
url: API_URLS.customerList,
method: 'GET',
data: { current: 1, size: 100 }
})
if (res.statusCode === 200 && res.data.code === 200) {
customers.value = res.data.data.records || []
}
} catch (error) {
console.error('加载客户列表失败', error)
}
}
const loadProjects = async () => {
try {
const res: any = await uni.request({
url: API_URLS.projectList,
method: 'GET',
data: { current: 1, size: 100 }
})
if (res.statusCode === 200 && res.data.code === 200) {
projects.value = res.data.data.records || []
}
} catch (error) {
console.error('加载项目列表失败', error)
}
}
const onCustomerChange = (e: any) => {
const index = e.detail.value
customerIndex.value = index
const customer = customers.value[index]
if (customer) {
form.value.customerId = customer.customerId
form.value.customerName = customer.customerName
}
}
const onProjectChange = (e: any) => {
const index = e.detail.value
projectIndex.value = index
const project = projects.value[index]
if (project) {
form.value.projectId = project.projectId
form.value.projectName = project.projectName
}
}
const onDateChange = (e: any) => {
form.value.receiptDate = e.detail.value
}
const chooseImage = () => {
const remainCount = 3 - form.value.voucherUrls.length
uni.chooseImage({
count: remainCount,
sizeType: ['compressed'],
sourceType: ['camera', 'album'],
success: (res: any) => {
const tempFilePaths = res.tempFilePaths
tempFilePaths.forEach((filePath: string) => {
uploadImage(filePath)
})
}
})
}
const uploadImage = async (filePath: string) => {
try {
uni.showLoading({ title: '上传中...' })
const uploadRes: any = await uni.uploadFile({
url: API_URLS.upload,
filePath: filePath,
name: 'file'
})
const data = JSON.parse(uploadRes.data)
if (data.code === 200) {
form.value.voucherUrls.push(data.data.url)
} else {
uni.showToast({ title: '上传失败', icon: 'none' })
}
} catch (error) {
uni.showToast({ title: '上传失败', icon: 'none' })
} finally {
uni.hideLoading()
}
}
const previewImage = (index: number) => {
uni.previewImage({
current: form.value.voucherUrls[index],
urls: form.value.voucherUrls
})
}
const deleteImage = (index: number) => {
uni.showModal({
title: '提示',
content: '确定删除这张图片吗?',
success: (res) => {
if (res.confirm) {
form.value.voucherUrls.splice(index, 1)
}
}
})
}
const handleSubmit = async () => {
if (!form.value.amount) {
uni.showToast({ title: '请输入收款金额', icon: 'none' })
return
}
if (!form.value.customerId) {
uni.showToast({ title: '请选择客户', icon: 'none' })
return
}
loading.value = true
try {
const res: any = await uni.request({
url: API_URLS.receiptSave,
method: 'POST',
data: form.value
})
if (res.statusCode === 200 && res.data.code === 200) {
uni.showToast({
title: '收款成功',
icon: 'success'
})
setTimeout(() => {
uni.navigateBack()
}, 1500)
} else {
uni.showToast({
title: res.data.message || '提交失败',
icon: 'none'
})
}
} catch (error) {
uni.showToast({ title: '网络错误', icon: 'none' })
} finally {
loading.value = false
}
}
</script>
<style lang="scss" scoped>
.container {
min-height: 100vh;
background: #f5f7fa;
padding: 20rpx;
padding-bottom: 40rpx;
}
.form-card {
background: #fff;
border-radius: 20rpx;
padding: 40rpx 30rpx;
margin-bottom: 30rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);
}
.form-title {
display: flex;
align-items: center;
font-size: 40rpx;
font-weight: bold;
color: #333;
margin-bottom: 40rpx;
padding-bottom: 30rpx;
border-bottom: 2rpx solid #f0f0f0;
.icon {
font-size: 48rpx;
margin-right: 16rpx;
}
}
.form-item {
margin-bottom: 40rpx;
.label {
display: block;
font-size: 30rpx;
color: #333;
margin-bottom: 20rpx;
font-weight: 500;
.required {
color: #ff4d4f;
margin-left: 8rpx;
}
}
.amount-input {
display: flex;
align-items: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16rpx;
padding: 30rpx;
.currency {
font-size: 48rpx;
color: #fff;
font-weight: bold;
margin-right: 20rpx;
}
.input {
flex: 1;
font-size: 48rpx;
color: #fff;
font-weight: bold;
background: transparent;
&::placeholder {
color: rgba(255, 255, 255, 0.6);
}
}
}
.radio-group {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
.radio-item {
display: flex;
align-items: center;
padding: 20rpx 30rpx;
background: #f5f5f5;
border-radius: 12rpx;
font-size: 28rpx;
color: #666;
transition: all 0.3s;
&.active {
background: #e6f7ff;
color: #1890ff;
border: 2rpx solid #1890ff;
}
.radio-icon {
font-size: 36rpx;
margin-right: 12rpx;
}
}
}
.picker {
display: flex;
align-items: center;
justify-content: space-between;
height: 100rpx;
background: #f8f9fa;
border-radius: 12rpx;
padding: 0 24rpx;
font-size: 30rpx;
.picker-left {
display: flex;
align-items: center;
.picker-icon {
font-size: 40rpx;
margin-right: 16rpx;
}
}
.arrow {
color: #999;
font-size: 32rpx;
}
}
.textarea {
width: 100%;
height: 200rpx;
background: #f8f9fa;
border-radius: 12rpx;
padding: 24rpx;
font-size: 30rpx;
box-sizing: border-box;
}
.word-count {
display: block;
text-align: right;
font-size: 24rpx;
color: #999;
margin-top: 12rpx;
}
.upload-list {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
.upload-item {
position: relative;
width: 200rpx;
height: 200rpx;
border-radius: 12rpx;
overflow: hidden;
image {
width: 100%;
height: 100%;
}
.delete-btn {
position: absolute;
top: 8rpx;
right: 8rpx;
width: 40rpx;
height: 40rpx;
background: rgba(0, 0, 0, 0.5);
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
}
}
.upload-box {
width: 200rpx;
height: 200rpx;
background: #f8f9fa;
border-radius: 12rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 2rpx dashed #ddd;
.icon {
font-size: 60rpx;
margin-bottom: 12rpx;
}
.tip {
font-size: 24rpx;
color: #999;
}
}
}
}
.submit-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100rpx;
background: linear-gradient(135deg, #52c41a 0%, #389e0d 100%);
color: #fff;
font-size: 36rpx;
font-weight: 500;
border-radius: 50rpx;
box-shadow: 0 8rpx 20rpx rgba(82, 196, 26, 0.3);
&:active {
opacity: 0.9;
transform: translateY(2rpx);
}
.btn-icon {
font-size: 40rpx;
margin-right: 12rpx;
}
}
</style>