Browse Source

临时提交

master
王泽彦 8 months ago
parent
commit
92eb9f623d
  1. 167
      admin/mock/salary.js
  2. 151
      admin/src/app/api/salary.ts
  3. 0
      admin/src/app/views/salary/components/salary-edit.vue.backup
  4. 407
      admin/src/app/views/salary/detail.vue
  5. 565
      admin/src/app/views/salary/edit.vue
  6. 420
      admin/src/app/views/salary/list.vue
  7. 0
      admin/src/app/views/salary/salary.vue.backup
  8. 576
      admin/src/app/views/salary/statistics.vue
  9. 56
      admin/src/router/modules/salary.ts
  10. BIN
      doc/劳 动 合 同.docx
  11. 132
      niucloud/app/adminapi/controller/salary/Payroll.php
  12. 52
      niucloud/app/adminapi/controller/salary/Statistics.php
  13. 16
      niucloud/app/adminapi/route/salary.php
  14. 59
      niucloud/app/adminapi/validate/salary/Payroll.php
  15. 6
      niucloud/app/model/salary/Salary.php
  16. 199
      niucloud/app/service/admin/salary/PayrollService.php
  17. 117
      niucloud/app/service/admin/salary/StatisticsService.php
  18. 16
      uniapp/api/member.js
  19. 9
      uniapp/pages.json
  20. 546
      uniapp/pages/coach/my/salary.vue
  21. 8
      uniapp/pages/common/profile/index.vue
  22. 305
      开发任务分配和质量控制.md
  23. 422
      项目开发管理方案.md

167
admin/mock/salary.js

@ -0,0 +1,167 @@
import Mock from 'mockjs'
// 工资条列表Mock
Mock.mock(/\/adminapi\/salary\/payroll\/list/, 'get', {
code: 1,
msg: '操作成功',
data: {
'list|10': [{
'id|+1': 1,
'staff_id|1-100': 1,
'staff_name': '@cname',
'campus_name|1': ['海淀校区', '朝阳校区', '丰台校区'],
'salary_month': '2025-01',
'base_salary|3000-8000.2': 5000,
'full_attendance_days|20-24': 22,
'attendance|15-22.1': 20,
'work_salary|2000-7000.2': 4545.45,
'mgr_performance|0-1000.2': 500,
'performance_bonus|0-2000.2': 1000,
'other_subsidies|0-500.2': 200,
'deductions|0-200.2': 0,
'gross_salary|4000-10000.2': 6245.45,
'social_security|500-1200.2': 800,
'individual_income_tax|0-500.2': 125,
'net_salary|3000-9000.2': 5320.45,
'status|0-2': 1,
'created_at': '@datetime'
}],
total: 156,
page: 1,
limit: 10
}
})
// 工资条详情Mock
Mock.mock(/\/adminapi\/salary\/payroll\/info/, 'get', {
code: 1,
msg: '操作成功',
data: {
id: '@id',
staff_id: '@integer(1, 100)',
staff_name: '@cname',
campus_name: '海淀校区',
salary_month: '2025-01',
base_salary: 6000.00,
full_attendance_days: 22,
attendance: 20.5,
work_salary: 5590.91,
mgr_performance: 800.00,
performance_bonus: 1200.00,
other_subsidies: 300.00,
deductions: 100.00,
gross_salary: 7790.91,
social_security: 960.00,
individual_income_tax: 285.00,
net_salary: 6545.91,
status: 1,
remarks: '本月表现优秀,给予额外奖励',
created_at: '@datetime',
updated_at: '@datetime'
}
})
// 创建工资条Mock
Mock.mock(/\/adminapi\/salary\/payroll\/add/, 'post', {
code: 1,
msg: '添加成功',
data: {
id: '@id'
}
})
// 更新工资条Mock
Mock.mock(/\/adminapi\/salary\/payroll\/edit/, 'post', {
code: 1,
msg: '更新成功',
data: null
})
// 删除工资条Mock
Mock.mock(/\/adminapi\/salary\/payroll\/delete/, 'post', {
code: 1,
msg: '删除成功',
data: null
})
// 导入工资条Mock
Mock.mock(/\/adminapi\/salary\/payroll\/import/, 'post', {
code: 1,
msg: '导入成功',
data: {
success_count: 25,
error_count: 2,
error_list: [
{ row: 3, error: '员工不存在' },
{ row: 8, error: '校区信息错误' }
]
}
})
// 导出工资条Mock
Mock.mock(/\/adminapi\/salary\/payroll\/export/, 'get', {
code: 1,
msg: '导出成功',
data: 'blob_data_here'
})
// 统计摘要Mock
Mock.mock(/\/adminapi\/salary\/statistics\/summary/, 'get', {
code: 1,
msg: '操作成功',
data: {
total_staff: 65,
total_amount: 445480.70,
average_salary: 6853.55,
cost_rate: 78.5
}
})
// 趋势数据Mock
Mock.mock(/\/adminapi\/salary\/statistics\/trend/, 'get', {
code: 1,
msg: '操作成功',
data: {
'months|12': [{
'month': '@date("yyyy-MM")',
'total_amount|30000-50000.2': 40000,
'average_salary|6000-8000.2': 7000,
'staff_count|50-80': 65
}]
}
})
// 员工列表Mock
Mock.mock(/\/adminapi\/personnel\/list/, 'get', {
code: 1,
msg: '操作成功',
data: {
'list|50': [{
'id|+1': 1,
'name': '@cname',
'campus_id|1-3': 1,
'campus_name|1': ['海淀校区', '朝阳校区', '丰台校区'],
'department': '@ctitle(2, 4)',
'position': '@ctitle(3, 6)',
'status|0-1': 1
}]
}
})
// 校区列表Mock
Mock.mock(/\/adminapi\/campus\/list/, 'get', {
code: 1,
msg: '操作成功',
data: {
'list|5': [{
'id|+1': 1,
'name|1': ['海淀校区', '朝阳校区', '丰台校区', '昌平校区', '大兴校区'],
'address': '@county(true)',
'manager': '@cname',
'phone': /^1[3-9]\d{9}$/,
'status|0-1': 1
}]
}
})
export default {}

151
admin/src/app/api/salary.ts

@ -1,71 +1,132 @@
import request from '@/utils/request'
// 工资条数据类型
export interface SalaryItem {
id: number
staff_id: number
staff_name: string
campus_name: string
salary_month: string
base_salary: number
full_attendance_days: number
attendance: number
work_salary: number
mgr_performance: number
performance_bonus: number
other_subsidies: number
deductions: number
gross_salary: number
social_security: number
individual_income_tax: number
net_salary: number
status: number
created_at: string
}
// 表单数据类型
export interface SalaryFormData {
staff_id?: number
campus_id?: number
salary_month?: string
base_salary: number
full_attendance_days: number
attendance: number
mgr_performance: number
performance_bonus: number
other_subsidies: number
deductions: number
social_security: number
individual_income_tax: number
remarks?: string
}
// 查询参数类型
export interface QueryParams {
page: number
limit: number
campus_id?: number
salary_month?: string
staff_name?: string
status?: number
}
// 统计数据类型
export interface StatisticsSummary {
total_staff: number
total_amount: number
average_salary: number
cost_rate: number
}
// 工资计算逻辑函数
export const calculateWorkSalary = (baseSalary: number, fullDays: number, attendance: number): number => {
if (!baseSalary || !fullDays) return 0
return Number(((baseSalary / fullDays) * attendance).toFixed(2))
}
export const calculateGrossSalary = (workSalary: number, mgr: number, bonus: number, subsidies: number, deductions: number): number => {
return Number((workSalary + mgr + bonus + subsidies - deductions).toFixed(2))
}
export const calculateNetSalary = (grossSalary: number, socialSecurity: number, tax: number): number => {
return Number((grossSalary - socialSecurity - tax).toFixed(2))
}
// USER_CODE_BEGIN -- salary
/**
*
* @param params
* @returns
*/
export function getSalaryList(params: Record<string, any>) {
return request.get(`salary/salary`, {params})
// 工资条列表
export const getSalaryList = (params: QueryParams) => {
return request.get('/adminapi/salary/payroll/list', { params })
}
/**
*
* @param id id
* @returns
*/
export function getSalaryInfo(id: number) {
return request.get(`salary/salary/${id}`);
// 创建工资条
export const createSalary = (data: SalaryFormData) => {
return request.post('/adminapi/salary/payroll/add', data)
}
/**
*
* @param params
* @returns
*/
export function addSalary(params: Record<string, any>) {
return request.post('salary/salary', params, { showErrorMessage: true, showSuccessMessage: true })
// 更新工资条
export const updateSalary = (data: SalaryFormData & { id: number }) => {
return request.post('/adminapi/salary/payroll/edit', data)
}
/**
*
* @param id
* @param params
* @returns
*/
export function editSalary(params: Record<string, any>) {
return request.put(`salary/salary/${params.id}`, params, { showErrorMessage: true, showSuccessMessage: true })
// 删除工资条
export const deleteSalary = (id: number) => {
return request.post('/adminapi/salary/payroll/delete', { id })
}
/**
*
* @param id
* @returns
*/
export function deleteSalary(id: number) {
return request.delete(`salary/salary/${id}`, { showErrorMessage: true, showSuccessMessage: true })
// 获取工资条详情
export const getSalaryInfo = (id: number) => {
return request.get('/adminapi/salary/payroll/info', { params: { id } })
}
// 批量导入工资条
export const importSalary = (file: File) => {
const formData = new FormData()
formData.append('file', file)
return request.post('/adminapi/salary/payroll/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
// 导出工资条
export const exportSalary = (params: QueryParams) => {
return request.get('/adminapi/salary/payroll/export', { params, responseType: 'blob' })
}
export function ffSalary(id: number) {
return request.get(`salary/ffsalary/${id}`, { showErrorMessage: true, showSuccessMessage: true })
// 获取统计摘要
export const getStatisticsSummary = (params: { salary_month?: string; campus_id?: number }) => {
return request.get('/adminapi/salary/statistics/summary', { params })
}
// 获取趋势数据
export const getStatisticsTrend = (params: { months?: number; campus_id?: number }) => {
return request.get('/adminapi/salary/statistics/trend', { params })
}
export function getWithPersonnelList(params: Record<string,any>){
return request.get('salary/personnel_all', {params})
}export function getWithDepartmentsList(params: Record<string,any>){
return request.get('salary/departments_all', {params})
// 获取员工列表
export const getPersonnelList = () => {
return request.get('/adminapi/personnel/list')
}
// USER_CODE_END -- salary
// 获取校区列表
export const getCampusList = () => {
return request.get('/adminapi/campus/list')
}

0
admin/src/app/views/salary/components/salary-edit.vue → admin/src/app/views/salary/components/salary-edit.vue.backup

407
admin/src/app/views/salary/detail.vue

@ -0,0 +1,407 @@
<template>
<div class="main-container">
<el-card class="box-card !border-none" shadow="never">
<!-- 页面标题 -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center">
<el-button @click="handleBack" text>
<el-icon><ArrowLeft /></el-icon>
</el-button>
<span class="text-lg font-medium ml-2">工资条详情</span>
</div>
<div class="flex gap-3" v-if="salaryData.status === 0">
<el-button type="primary" @click="handleEdit">
<el-icon><Edit /></el-icon>
编辑
</el-button>
</div>
</div>
<div v-loading="loading">
<!-- 基础信息 -->
<el-card class="mb-6" shadow="never">
<template #header>
<div class="flex items-center justify-between">
<span class="text-base font-medium">基础信息</span>
<el-tag :type="getStatusType(salaryData.status)">
{{ getStatusText(salaryData.status) }}
</el-tag>
</div>
</template>
<el-row :gutter="24">
<el-col :span="8">
<div class="info-item">
<span class="label">员工姓名</span>
<span class="value">{{ salaryData.staff_name }}</span>
</div>
</el-col>
<el-col :span="8">
<div class="info-item">
<span class="label">所属校区</span>
<span class="value">{{ salaryData.campus_name }}</span>
</div>
</el-col>
<el-col :span="8">
<div class="info-item">
<span class="label">工资月份</span>
<span class="value">{{ salaryData.salary_month }}</span>
</div>
</el-col>
</el-row>
</el-card>
<!-- 工资计算明细 -->
<el-card class="mb-6" shadow="never">
<template #header>
<div class="text-base font-medium">工资计算明细</div>
</template>
<!-- 基础工资 -->
<el-descriptions :column="3" border class="mb-4">
<el-descriptions-item label="基础工资">
<span class="amount">¥{{ salaryData.base_salary?.toFixed(2) || '0.00' }}</span>
</el-descriptions-item>
<el-descriptions-item label="满勤天数">
{{ salaryData.full_attendance_days || 0 }}
</el-descriptions-item>
<el-descriptions-item label="出勤天数">
{{ salaryData.attendance || 0 }}
</el-descriptions-item>
</el-descriptions>
<!-- 出勤工资计算 -->
<div class="calculation-section mb-4">
<h4 class="section-title">出勤工资计算</h4>
<div class="calculation-formula">
<span>出勤工资 = 基础工资 ÷ 满勤天数 × 出勤天数</span>
<span class="formula-detail">
= ¥{{ salaryData.base_salary?.toFixed(2) || '0.00' }} ÷ {{ salaryData.full_attendance_days || 0 }} × {{ salaryData.attendance || 0 }}
= <span class="highlight">¥{{ salaryData.work_salary?.toFixed(2) || '0.00' }}</span>
</span>
</div>
</div>
<!-- 应发工资明细 -->
<el-descriptions :column="2" border class="mb-4">
<el-descriptions-item label="出勤工资">
<span class="amount">¥{{ salaryData.work_salary?.toFixed(2) || '0.00' }}</span>
</el-descriptions-item>
<el-descriptions-item label="管理绩效">
<span class="amount">¥{{ salaryData.mgr_performance?.toFixed(2) || '0.00' }}</span>
</el-descriptions-item>
<el-descriptions-item label="绩效提成">
<span class="amount">¥{{ salaryData.performance_bonus?.toFixed(2) || '0.00' }}</span>
</el-descriptions-item>
<el-descriptions-item label="其他补贴">
<span class="amount">¥{{ salaryData.other_subsidies?.toFixed(2) || '0.00' }}</span>
</el-descriptions-item>
<el-descriptions-item label="扣款项目">
<span class="amount deduction">-¥{{ salaryData.deductions?.toFixed(2) || '0.00' }}</span>
</el-descriptions-item>
<el-descriptions-item label="应发工资">
<span class="amount gross-salary">¥{{ salaryData.gross_salary?.toFixed(2) || '0.00' }}</span>
</el-descriptions-item>
</el-descriptions>
<!-- 应发工资计算 -->
<div class="calculation-section mb-4">
<h4 class="section-title">应发工资计算</h4>
<div class="calculation-formula">
<span>应发工资 = 出勤工资 + 管理绩效 + 绩效提成 + 其他补贴 - 扣款项目</span>
<span class="formula-detail">
= ¥{{ salaryData.work_salary?.toFixed(2) || '0.00' }}
+ ¥{{ salaryData.mgr_performance?.toFixed(2) || '0.00' }}
+ ¥{{ salaryData.performance_bonus?.toFixed(2) || '0.00' }}
+ ¥{{ salaryData.other_subsidies?.toFixed(2) || '0.00' }}
- ¥{{ salaryData.deductions?.toFixed(2) || '0.00' }}
= <span class="highlight gross">¥{{ salaryData.gross_salary?.toFixed(2) || '0.00' }}</span>
</span>
</div>
</div>
<!-- 扣除项目 -->
<el-descriptions :column="3" border class="mb-4">
<el-descriptions-item label="社保">
<span class="amount deduction">-¥{{ salaryData.social_security?.toFixed(2) || '0.00' }}</span>
</el-descriptions-item>
<el-descriptions-item label="个人所得税">
<span class="amount deduction">-¥{{ salaryData.individual_income_tax?.toFixed(2) || '0.00' }}</span>
</el-descriptions-item>
<el-descriptions-item label="实发工资">
<span class="amount net-salary">¥{{ salaryData.net_salary?.toFixed(2) || '0.00' }}</span>
</el-descriptions-item>
</el-descriptions>
<!-- 实发工资计算 -->
<div class="calculation-section">
<h4 class="section-title">实发工资计算</h4>
<div class="calculation-formula">
<span>实发工资 = 应发工资 - 社保 - 个人所得税</span>
<span class="formula-detail">
= ¥{{ salaryData.gross_salary?.toFixed(2) || '0.00' }}
- ¥{{ salaryData.social_security?.toFixed(2) || '0.00' }}
- ¥{{ salaryData.individual_income_tax?.toFixed(2) || '0.00' }}
= <span class="highlight net">¥{{ salaryData.net_salary?.toFixed(2) || '0.00' }}</span>
</span>
</div>
</div>
</el-card>
<!-- 备注信息 -->
<el-card class="mb-6" shadow="never" v-if="salaryData.remarks">
<template #header>
<div class="text-base font-medium">备注信息</div>
</template>
<div class="remarks-content">
{{ salaryData.remarks }}
</div>
</el-card>
<!-- 操作记录 -->
<el-card class="mb-6" shadow="never">
<template #header>
<div class="text-base font-medium">操作记录</div>
</template>
<el-timeline>
<el-timeline-item
v-for="(item, index) in operationLogs"
:key="index"
:timestamp="item.created_at"
:type="item.type"
>
<div class="operation-item">
<div class="operation-title">{{ item.operation }}</div>
<div class="operation-user">操作人{{ item.operator }}</div>
<div class="operation-desc" v-if="item.description">{{ item.description }}</div>
</div>
</el-timeline-item>
</el-timeline>
</el-card>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { ArrowLeft, Edit } from '@element-plus/icons-vue'
import { useRouter, useRoute } from 'vue-router'
import { getSalaryInfo, type SalaryItem } from '@/app/api/salary'
const router = useRouter()
const route = useRoute()
//
const loading = ref(false)
const salaryData = reactive<Partial<SalaryItem>>({})
//
const operationLogs = ref([
{
operation: '创建工资条',
operator: '系统管理员',
description: '创建了新的工资条记录',
created_at: '2025-01-15 10:30:00',
type: 'primary'
},
{
operation: '修改工资信息',
operator: 'HR专员',
description: '更新了绩效提成和其他补贴',
created_at: '2025-01-16 14:20:00',
type: 'warning'
},
{
operation: '审核通过',
operator: 'HR主管',
description: '工资条审核通过,准备发放',
created_at: '2025-01-17 09:15:00',
type: 'success'
}
])
const salaryId = computed(() => Number(route.query.id) || 0)
//
onMounted(() => {
if (salaryId.value) {
loadSalaryInfo()
} else {
ElMessage.error('缺少工资条ID参数')
handleBack()
}
})
//
const loadSalaryInfo = async () => {
loading.value = true
try {
const res = await getSalaryInfo(salaryId.value)
Object.assign(salaryData, res.data)
} catch (error) {
console.error('加载工资条详情失败:', error)
ElMessage.error('加载工资条详情失败')
} finally {
loading.value = false
}
}
//
const getStatusText = (status: number) => {
switch (status) {
case 0: return '草稿'
case 1: return '已提交'
case 2: return '已发放'
default: return '未知'
}
}
//
const getStatusType = (status: number) => {
switch (status) {
case 0: return 'info'
case 1: return 'warning'
case 2: return 'success'
default: return 'info'
}
}
//
const handleBack = () => {
router.push('/salary/list')
}
//
const handleEdit = () => {
router.push(`/salary/edit?id=${salaryId.value}`)
}
</script>
<style lang="scss" scoped>
.main-container {
.el-card {
border: 1px solid #e4e7ed;
:deep(.el-card__header) {
background-color: #f8f9fa;
border-bottom: 1px solid #e4e7ed;
}
}
.info-item {
margin-bottom: 16px;
.label {
color: #606266;
font-weight: 500;
}
.value {
color: #303133;
margin-left: 8px;
}
}
.amount {
font-weight: 600;
font-size: 14px;
&.deduction {
color: #f56c6c;
}
&.gross-salary {
color: #409eff;
font-size: 16px;
}
&.net-salary {
color: #67c23a;
font-size: 16px;
}
}
.calculation-section {
background: #f8f9fb;
padding: 16px;
border-radius: 8px;
border-left: 4px solid #409eff;
.section-title {
margin: 0 0 12px 0;
color: #303133;
font-size: 16px;
font-weight: 600;
}
.calculation-formula {
display: flex;
flex-direction: column;
gap: 8px;
.formula-detail {
font-family: 'Monaco', 'Consolas', monospace;
color: #606266;
padding-left: 20px;
.highlight {
font-weight: 700;
&.gross {
color: #409eff;
}
&.net {
color: #67c23a;
}
}
}
}
}
.remarks-content {
background: #f5f7fa;
padding: 16px;
border-radius: 6px;
color: #606266;
line-height: 1.6;
}
.operation-item {
.operation-title {
font-weight: 600;
color: #303133;
margin-bottom: 4px;
}
.operation-user {
color: #606266;
font-size: 12px;
margin-bottom: 4px;
}
.operation-desc {
color: #909399;
font-size: 12px;
}
}
.font-medium {
font-weight: 500;
}
}
:deep(.el-descriptions__label) {
font-weight: 600;
}
:deep(.el-descriptions__content) {
font-weight: 500;
}
</style>

565
admin/src/app/views/salary/edit.vue

@ -0,0 +1,565 @@
<template>
<div class="main-container">
<el-card class="box-card !border-none" shadow="never">
<!-- 页面标题 -->
<div class="flex items-center mb-6">
<el-button @click="handleBack" text>
<el-icon><ArrowLeft /></el-icon>
</el-button>
<span class="text-lg font-medium ml-2">
{{ isEdit ? '编辑工资条' : '新增工资条' }}
</span>
</div>
<el-form
:model="formData"
:rules="formRules"
ref="formRef"
label-width="120px"
v-loading="loading"
>
<!-- 基础信息 -->
<el-card class="mb-6" shadow="never">
<template #header>
<div class="text-base font-medium">基础信息</div>
</template>
<el-row :gutter="24">
<el-col :span="8">
<el-form-item label="员工" prop="staff_id">
<el-select
v-model="formData.staff_id"
placeholder="请选择员工"
filterable
:disabled="isEdit"
class="w-full"
@change="handleStaffChange"
>
<el-option
v-for="item in personnelList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="校区" prop="campus_id">
<el-select
v-model="formData.campus_id"
placeholder="请选择校区"
:disabled="isEdit"
class="w-full"
>
<el-option
v-for="item in campusList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="工资月份" prop="salary_month">
<el-date-picker
v-model="formData.salary_month"
type="month"
placeholder="请选择工资月份"
format="YYYY-MM"
value-format="YYYY-MM"
:disabled="isEdit"
class="w-full"
/>
</el-form-item>
</el-col>
</el-row>
</el-card>
<!-- 应发工资 -->
<el-card class="mb-6" shadow="never">
<template #header>
<div class="text-base font-medium">应发工资</div>
</template>
<el-row :gutter="24">
<el-col :span="8">
<el-form-item label="基础工资" prop="base_salary">
<el-input-number
v-model="formData.base_salary"
:min="0"
:precision="2"
placeholder="请输入基础工资"
class="w-full"
@change="calculateSalary"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="满勤天数" prop="full_attendance_days">
<el-input-number
v-model="formData.full_attendance_days"
:min="1"
:max="31"
placeholder="请输入满勤天数"
class="w-full"
@change="calculateSalary"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="出勤天数" prop="attendance">
<el-input-number
v-model="formData.attendance"
:min="0"
:max="formData.full_attendance_days || 31"
:precision="1"
placeholder="请输入出勤天数"
class="w-full"
@change="calculateSalary"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="8">
<el-form-item label="出勤工资">
<el-input
:value="`¥${calculatedData.work_salary.toFixed(2)}`"
readonly
class="w-full calculated-input"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="管理绩效" prop="mgr_performance">
<el-input-number
v-model="formData.mgr_performance"
:min="0"
:precision="2"
placeholder="请输入管理绩效"
class="w-full"
@change="calculateSalary"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="绩效提成" prop="performance_bonus">
<el-input-number
v-model="formData.performance_bonus"
:min="0"
:precision="2"
placeholder="请输入绩效提成"
class="w-full"
@change="calculateSalary"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="8">
<el-form-item label="其他补贴" prop="other_subsidies">
<el-input-number
v-model="formData.other_subsidies"
:min="0"
:precision="2"
placeholder="请输入其他补贴"
class="w-full"
@change="calculateSalary"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="扣款项目" prop="deductions">
<el-input-number
v-model="formData.deductions"
:min="0"
:precision="2"
placeholder="请输入扣款金额"
class="w-full"
@change="calculateSalary"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="应发工资">
<el-input
:value="`¥${calculatedData.gross_salary.toFixed(2)}`"
readonly
class="w-full calculated-input gross-salary"
/>
</el-form-item>
</el-col>
</el-row>
</el-card>
<!-- 应扣款项 -->
<el-card class="mb-6" shadow="never">
<template #header>
<div class="text-base font-medium">应扣款项</div>
</template>
<el-row :gutter="24">
<el-col :span="8">
<el-form-item label="社保" prop="social_security">
<el-input-number
v-model="formData.social_security"
:min="0"
:precision="2"
placeholder="请输入社保金额"
class="w-full"
@change="calculateSalary"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="个人所得税" prop="individual_income_tax">
<el-input-number
v-model="formData.individual_income_tax"
:min="0"
:precision="2"
placeholder="请输入个税金额"
class="w-full"
@change="calculateSalary"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="实发工资">
<el-input
:value="`¥${calculatedData.net_salary.toFixed(2)}`"
readonly
class="w-full calculated-input net-salary"
/>
</el-form-item>
</el-col>
</el-row>
</el-card>
<!-- 备注信息 -->
<el-card class="mb-6" shadow="never">
<template #header>
<div class="text-base font-medium">备注信息</div>
</template>
<el-form-item label="备注" prop="remarks">
<el-input
v-model="formData.remarks"
type="textarea"
:rows="4"
placeholder="请输入备注信息(选填)"
maxlength="500"
show-word-limit
/>
</el-form-item>
</el-card>
<!-- 操作按钮 -->
<div class="text-center">
<el-button @click="handleBack" size="large">取消</el-button>
<el-button
type="primary"
@click="handleSave"
:loading="saveLoading"
size="large"
>
保存
</el-button>
</div>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { ElMessage, type FormInstance } from 'element-plus'
import { ArrowLeft } from '@element-plus/icons-vue'
import { useRouter, useRoute } from 'vue-router'
import {
getSalaryInfo,
createSalary,
updateSalary,
getPersonnelList,
getCampusList,
calculateWorkSalary,
calculateGrossSalary,
calculateNetSalary,
type SalaryFormData
} from '@/app/api/salary'
const router = useRouter()
const route = useRoute()
//
const loading = ref(false)
const saveLoading = ref(false)
const personnelList = ref<any[]>([])
const campusList = ref<any[]>([])
//
const isEdit = computed(() => !!route.query.id)
const salaryId = computed(() => Number(route.query.id) || 0)
//
const formData = reactive<SalaryFormData>({
staff_id: undefined,
campus_id: undefined,
salary_month: undefined,
base_salary: 0,
full_attendance_days: 22,
attendance: 0,
mgr_performance: 0,
performance_bonus: 0,
other_subsidies: 0,
deductions: 0,
social_security: 0,
individual_income_tax: 0,
remarks: ''
})
//
const calculatedData = reactive({
work_salary: 0,
gross_salary: 0,
net_salary: 0
})
const formRef = ref<FormInstance>()
//
const formRules = {
staff_id: [
{ required: true, message: '请选择员工', trigger: 'change' }
],
campus_id: [
{ required: true, message: '请选择校区', trigger: 'change' }
],
salary_month: [
{ required: true, message: '请选择工资月份', trigger: 'change' }
],
base_salary: [
{ required: true, message: '请输入基础工资', trigger: 'blur' },
{ type: 'number', min: 0, message: '基础工资不能小于0', trigger: 'blur' }
],
full_attendance_days: [
{ required: true, message: '请输入满勤天数', trigger: 'blur' },
{ type: 'number', min: 1, max: 31, message: '满勤天数应在1-31之间', trigger: 'blur' }
],
attendance: [
{ required: true, message: '请输入出勤天数', trigger: 'blur' },
{ type: 'number', min: 0, message: '出勤天数不能小于0', trigger: 'blur' }
],
mgr_performance: [
{ type: 'number', min: 0, message: '管理绩效不能小于0', trigger: 'blur' }
],
performance_bonus: [
{ type: 'number', min: 0, message: '绩效提成不能小于0', trigger: 'blur' }
],
other_subsidies: [
{ type: 'number', min: 0, message: '其他补贴不能小于0', trigger: 'blur' }
],
deductions: [
{ type: 'number', min: 0, message: '扣款项目不能小于0', trigger: 'blur' }
],
social_security: [
{ type: 'number', min: 0, message: '社保不能小于0', trigger: 'blur' }
],
individual_income_tax: [
{ type: 'number', min: 0, message: '个人所得税不能小于0', trigger: 'blur' }
]
}
//
onMounted(() => {
loadPersonnelList()
loadCampusList()
if (isEdit.value) {
loadSalaryInfo()
} else {
//
calculateSalary()
}
})
//
watch(() => formData.full_attendance_days, (newVal) => {
if (formData.attendance > newVal) {
formData.attendance = newVal
}
calculateSalary()
})
//
const loadPersonnelList = async () => {
try {
const res = await getPersonnelList()
personnelList.value = res.data
} catch (error) {
console.error('加载员工列表失败:', error)
}
}
//
const loadCampusList = async () => {
try {
const res = await getCampusList()
campusList.value = res.data
} catch (error) {
console.error('加载校区列表失败:', error)
}
}
//
const loadSalaryInfo = async () => {
if (!salaryId.value) return
loading.value = true
try {
const res = await getSalaryInfo(salaryId.value)
const data = res.data
//
Object.keys(formData).forEach(key => {
if (data[key] !== undefined) {
(formData as any)[key] = data[key]
}
})
//
calculateSalary()
} catch (error) {
console.error('加载工资条信息失败:', error)
ElMessage.error('加载工资条信息失败')
} finally {
loading.value = false
}
}
//
const handleStaffChange = (staffId: number) => {
const staff = personnelList.value.find(item => item.id === staffId)
if (staff && staff.campus_id) {
formData.campus_id = staff.campus_id
}
}
//
const calculateSalary = () => {
//
calculatedData.work_salary = calculateWorkSalary(
formData.base_salary,
formData.full_attendance_days,
formData.attendance
)
//
calculatedData.gross_salary = calculateGrossSalary(
calculatedData.work_salary,
formData.mgr_performance,
formData.performance_bonus,
formData.other_subsidies,
formData.deductions
)
//
calculatedData.net_salary = calculateNetSalary(
calculatedData.gross_salary,
formData.social_security,
formData.individual_income_tax
)
}
//
const handleBack = () => {
router.push('/salary/list')
}
//
const handleSave = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
saveLoading.value = true
//
const saveData = {
...formData,
work_salary: calculatedData.work_salary,
gross_salary: calculatedData.gross_salary,
net_salary: calculatedData.net_salary
}
if (isEdit.value) {
await updateSalary({ ...saveData, id: salaryId.value })
ElMessage.success('更新成功')
} else {
await createSalary(saveData)
ElMessage.success('创建成功')
}
router.push('/salary/list')
} catch (error) {
if (error !== 'validation failed') {
console.error('保存失败:', error)
ElMessage.error('保存失败')
}
} finally {
saveLoading.value = false
}
}
</script>
<style lang="scss" scoped>
.main-container {
.el-card {
border: 1px solid #e4e7ed;
:deep(.el-card__header) {
background-color: #f8f9fa;
border-bottom: 1px solid #e4e7ed;
}
}
.calculated-input {
:deep(.el-input__inner) {
background-color: #f5f7fa;
color: #606266;
font-weight: 500;
}
&.gross-salary :deep(.el-input__inner) {
background-color: #e1f3d8;
color: #409eff;
font-weight: 600;
}
&.net-salary :deep(.el-input__inner) {
background-color: #fdf2e7;
color: #e6a23c;
font-weight: 600;
}
}
.font-medium {
font-weight: 500;
}
}
</style>

420
admin/src/app/views/salary/list.vue

@ -0,0 +1,420 @@
<template>
<div class="main-container">
<el-card class="box-card !border-none" shadow="never">
<!-- 页面标题和操作按钮 -->
<div class="flex justify-between items-center mb-4">
<span class="text-lg font-medium">工资条管理</span>
<div class="flex gap-3">
<el-button type="success" @click="handleImport">
<el-icon><Upload /></el-icon>
导入
</el-button>
<el-button type="warning" @click="handleExport">
<el-icon><Download /></el-icon>
导出
</el-button>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增
</el-button>
</div>
</div>
<!-- 筛选条件区域 -->
<el-card class="box-card !border-none my-4" shadow="never">
<el-form :inline="true" :model="queryParams" ref="queryFormRef" label-width="80px">
<el-form-item label="校区" prop="campus_id">
<el-select v-model="queryParams.campus_id" placeholder="请选择校区" clearable class="w-48">
<el-option
v-for="item in campusList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="工资月份" prop="salary_month">
<el-date-picker
v-model="queryParams.salary_month"
type="month"
placeholder="请选择工资月份"
format="YYYY-MM"
value-format="YYYY-MM"
clearable
class="w-48"
/>
</el-form-item>
<el-form-item label="员工姓名" prop="staff_name">
<el-input
v-model="queryParams.staff_name"
placeholder="请输入员工姓名"
clearable
class="w-48"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable class="w-48">
<el-option label="草稿" :value="0" />
<el-option label="已提交" :value="1" />
<el-option label="已发放" :value="2" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="resetQuery">
<el-icon><Refresh /></el-icon>
重置
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 数据表格区域 -->
<div class="mt-4">
<el-table
:data="tableData"
v-loading="loading"
stripe
border
style="width: 100%"
height="600"
>
<template #empty>
<span>{{ !loading ? '暂无数据' : '' }}</span>
</template>
<el-table-column prop="staff_name" label="姓名" width="100" fixed="left" />
<el-table-column prop="campus_name" label="校区" width="120" />
<el-table-column prop="salary_month" label="月份" width="100" />
<el-table-column prop="base_salary" label="基础工资" width="100" align="right">
<template #default="{ row }">
¥{{ row.base_salary.toFixed(2) }}
</template>
</el-table-column>
<el-table-column prop="work_salary" label="出勤工资" width="100" align="right">
<template #default="{ row }">
¥{{ row.work_salary.toFixed(2) }}
</template>
</el-table-column>
<el-table-column prop="gross_salary" label="应发工资" width="120" align="right">
<template #default="{ row }">
<span class="font-medium text-blue-600">¥{{ row.gross_salary.toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column prop="net_salary" label="实发工资" width="120" align="right">
<template #default="{ row }">
<span class="font-medium text-green-600">¥{{ row.net_salary.toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag v-if="row.status === 0" type="info">草稿</el-tag>
<el-tag v-else-if="row.status === 1" type="warning">已提交</el-tag>
<el-tag v-else-if="row.status === 2" type="success">已发放</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180" />
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleView(row)">
详情
</el-button>
<el-button
type="primary"
link
size="small"
@click="handleEdit(row)"
v-if="row.status === 0"
>
编辑
</el-button>
<el-button
type="danger"
link
size="small"
@click="handleDelete(row)"
v-if="row.status === 0"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页区域 -->
<div class="mt-4 flex justify-end">
<el-pagination
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.limit"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
@size-change="handleQuery"
@current-change="handleQuery"
/>
</div>
</div>
<!-- 导入文件对话框 -->
<el-dialog v-model="importDialog" title="导入工资条" width="500px">
<el-upload
ref="uploadRef"
class="upload-demo"
drag
:action="importAction"
:headers="uploadHeaders"
:on-success="handleImportSuccess"
:on-error="handleImportError"
:before-upload="beforeUpload"
accept=".xlsx,.xls"
:limit="1"
>
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text">
将文件拖到此处<em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">
只能上传 Excel 文件且不超过 5MB
</div>
</template>
</el-upload>
<template #footer>
<span class="dialog-footer">
<el-button @click="importDialog = false">取消</el-button>
</span>
</template>
</el-dialog>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus'
import { Search, Refresh, Plus, Upload, Download, UploadFilled } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import {
getSalaryList,
deleteSalary,
exportSalary,
getCampusList,
type QueryParams,
type SalaryItem
} from '@/app/api/salary'
const router = useRouter()
//
const loading = ref(false)
const tableData = ref<SalaryItem[]>([])
const total = ref(0)
const campusList = ref<any[]>([])
const importDialog = ref(false)
//
const queryParams = reactive<QueryParams>({
page: 1,
limit: 10,
campus_id: undefined,
salary_month: undefined,
staff_name: undefined,
status: undefined
})
const queryFormRef = ref<FormInstance>()
const uploadRef = ref()
//
const importAction = '/adminapi/salary/payroll/import'
const uploadHeaders = {
Authorization: `Bearer ${localStorage.getItem('token')}`
}
//
onMounted(() => {
loadCampusList()
loadTableData()
})
//
const loadCampusList = async () => {
try {
const res = await getCampusList()
campusList.value = res.data
} catch (error) {
console.error('加载校区列表失败:', error)
}
}
//
const loadTableData = async () => {
loading.value = true
try {
const res = await getSalaryList(queryParams)
tableData.value = res.data.list
total.value = res.data.total
} catch (error) {
console.error('加载数据失败:', error)
ElMessage.error('加载数据失败')
} finally {
loading.value = false
}
}
//
const handleQuery = () => {
queryParams.page = 1
loadTableData()
}
//
const resetQuery = () => {
queryFormRef.value?.resetFields()
queryParams.page = 1
loadTableData()
}
//
const handleAdd = () => {
router.push('/salary/edit')
}
//
const handleEdit = (row: SalaryItem) => {
router.push(`/salary/edit?id=${row.id}`)
}
//
const handleView = (row: SalaryItem) => {
router.push(`/salary/detail?id=${row.id}`)
}
//
const handleDelete = async (row: SalaryItem) => {
try {
await ElMessageBox.confirm(
`确定要删除员工"${row.staff_name}"的工资条吗?`,
'警告',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
await deleteSalary(row.id)
ElMessage.success('删除成功')
loadTableData()
} catch (error) {
if (error !== 'cancel') {
console.error('删除失败:', error)
ElMessage.error('删除失败')
}
}
}
//
const handleImport = () => {
importDialog.value = true
}
//
const handleExport = async () => {
try {
const res = await exportSalary(queryParams)
//
const blob = new Blob([res.data], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
})
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `工资条_${new Date().getTime()}.xlsx`
link.click()
window.URL.revokeObjectURL(url)
ElMessage.success('导出成功')
} catch (error) {
console.error('导出失败:', error)
ElMessage.error('导出失败')
}
}
//
const beforeUpload = (file: File) => {
const isExcel = file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
file.type === 'application/vnd.ms-excel'
const isLt5M = file.size / 1024 / 1024 < 5
if (!isExcel) {
ElMessage.error('只能上传 Excel 文件!')
return false
}
if (!isLt5M) {
ElMessage.error('文件大小不能超过 5MB!')
return false
}
return true
}
//
const handleImportSuccess = (response: any) => {
if (response.code === 1) {
ElMessage.success('导入成功')
importDialog.value = false
loadTableData()
uploadRef.value?.clearFiles()
} else {
ElMessage.error(response.msg || '导入失败')
}
}
//
const handleImportError = () => {
ElMessage.error('导入失败')
}
</script>
<style lang="scss" scoped>
.main-container {
.el-table {
.el-table__cell {
padding: 8px 0;
}
}
.upload-demo {
width: 100%;
}
.font-medium {
font-weight: 500;
}
.text-blue-600 {
color: #2563eb;
}
.text-green-600 {
color: #16a34a;
}
}
</style>

0
admin/src/app/views/salary/salary.vue → admin/src/app/views/salary/salary.vue.backup

576
admin/src/app/views/salary/statistics.vue

@ -0,0 +1,576 @@
<template>
<div class="main-container">
<el-card class="box-card !border-none" shadow="never">
<!-- 页面标题 -->
<div class="flex items-center justify-between mb-6">
<span class="text-lg font-medium">工资统计分析</span>
<!-- 筛选条件 -->
<div class="flex items-center gap-4">
<el-date-picker
v-model="queryParams.salary_month"
type="month"
placeholder="选择月份"
format="YYYY-MM"
value-format="YYYY-MM"
clearable
@change="loadData"
/>
<el-select
v-model="queryParams.campus_id"
placeholder="选择校区"
clearable
@change="loadData"
style="width: 200px"
>
<el-option label="全部校区" :value="undefined" />
<el-option
v-for="item in campusList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</div>
</div>
<div v-loading="loading">
<!-- 统计卡片 -->
<el-row :gutter="20" class="mb-6">
<el-col :span="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-icon total-staff">
<el-icon size="32"><User /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ summary.total_staff }}</div>
<div class="stat-label">总人数</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-icon total-amount">
<el-icon size="32"><Money /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">¥{{ formatNumber(summary.total_amount) }}</div>
<div class="stat-label">总金额</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-icon average-salary">
<el-icon size="32"><TrendCharts /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">¥{{ formatNumber(summary.average_salary) }}</div>
<div class="stat-label">平均工资</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-icon cost-rate">
<el-icon size="32"><PieChart /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ summary.cost_rate }}%</div>
<div class="stat-label">成本率</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 图表区域 -->
<el-row :gutter="20">
<!-- 校区分布图 -->
<el-col :span="12">
<el-card shadow="hover" class="chart-card">
<template #header>
<div class="card-header">
<span class="font-medium">校区工资分布</span>
</div>
</template>
<div ref="campusChartRef" class="chart-container"></div>
</el-card>
</el-col>
<!-- 工资趋势图 -->
<el-col :span="12">
<el-card shadow="hover" class="chart-card">
<template #header>
<div class="card-header">
<span class="font-medium">工资趋势分析</span>
</div>
</template>
<div ref="trendChartRef" class="chart-container"></div>
</el-card>
</el-col>
</el-row>
<!-- 工资结构分析 -->
<el-row :gutter="20" class="mt-5">
<el-col :span="24">
<el-card shadow="hover" class="chart-card">
<template #header>
<div class="card-header">
<span class="font-medium">工资结构分析</span>
</div>
</template>
<div ref="structureChartRef" class="chart-container-large"></div>
</el-card>
</el-col>
</el-row>
<!-- 详细数据表格 -->
<el-card shadow="hover" class="mt-5">
<template #header>
<div class="card-header">
<span class="font-medium">校区工资明细</span>
</div>
</template>
<el-table :data="campusDetails" stripe border>
<el-table-column prop="campus_name" label="校区名称" width="150" />
<el-table-column prop="staff_count" label="员工数量" width="100" align="center" />
<el-table-column prop="total_salary" label="总工资" width="150" align="right">
<template #default="{ row }">
¥{{ formatNumber(row.total_salary) }}
</template>
</el-table-column>
<el-table-column prop="average_salary" label="平均工资" width="150" align="right">
<template #default="{ row }">
¥{{ formatNumber(row.average_salary) }}
</template>
</el-table-column>
<el-table-column prop="max_salary" label="最高工资" width="150" align="right">
<template #default="{ row }">
¥{{ formatNumber(row.max_salary) }}
</template>
</el-table-column>
<el-table-column prop="min_salary" label="最低工资" width="150" align="right">
<template #default="{ row }">
¥{{ formatNumber(row.min_salary) }}
</template>
</el-table-column>
<el-table-column prop="salary_ratio" label="占比" width="100" align="center">
<template #default="{ row }">
{{ row.salary_ratio }}%
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { User, Money, TrendCharts, PieChart } from '@element-plus/icons-vue'
import * as echarts from 'echarts'
import {
getStatisticsSummary,
getStatisticsTrend,
getCampusList,
type StatisticsSummary
} from '@/app/api/salary'
//
const loading = ref(false)
const campusList = ref<any[]>([])
//
const queryParams = reactive({
salary_month: undefined as string | undefined,
campus_id: undefined as number | undefined
})
//
const summary = reactive<StatisticsSummary>({
total_staff: 0,
total_amount: 0,
average_salary: 0,
cost_rate: 0
})
//
const campusChartRef = ref()
const trendChartRef = ref()
const structureChartRef = ref()
//
let campusChart: echarts.ECharts | null = null
let trendChart: echarts.ECharts | null = null
let structureChart: echarts.ECharts | null = null
//
const campusDetails = ref([
{
campus_name: '海淀校区',
staff_count: 25,
total_salary: 156780.50,
average_salary: 6271.22,
max_salary: 12500.00,
min_salary: 3800.00,
salary_ratio: 35.2
},
{
campus_name: '朝阳校区',
staff_count: 18,
total_salary: 128900.30,
average_salary: 7161.13,
max_salary: 15000.00,
min_salary: 4200.00,
salary_ratio: 28.9
},
{
campus_name: '丰台校区',
staff_count: 22,
total_salary: 159800.20,
average_salary: 7263.64,
max_salary: 13800.00,
min_salary: 3900.00,
salary_ratio: 35.9
}
])
//
onMounted(() => {
loadCampusList()
loadData()
nextTick(() => {
initCharts()
})
})
//
const loadCampusList = async () => {
try {
const res = await getCampusList()
campusList.value = res.data
} catch (error) {
console.error('加载校区列表失败:', error)
}
}
//
const loadData = async () => {
loading.value = true
try {
await Promise.all([
loadSummaryData(),
loadTrendData()
])
//
updateCharts()
} catch (error) {
console.error('加载数据失败:', error)
ElMessage.error('加载数据失败')
} finally {
loading.value = false
}
}
//
const loadSummaryData = async () => {
try {
const res = await getStatisticsSummary(queryParams)
Object.assign(summary, res.data)
} catch (error) {
console.error('加载统计摘要失败:', error)
}
}
//
const loadTrendData = async () => {
try {
const res = await getStatisticsTrend({ months: 12, campus_id: queryParams.campus_id })
//
return res.data
} catch (error) {
console.error('加载趋势数据失败:', error)
return []
}
}
//
const initCharts = () => {
initCampusChart()
initTrendChart()
initStructureChart()
}
//
const initCampusChart = () => {
if (!campusChartRef.value) return
campusChart = echarts.init(campusChartRef.value)
const option = {
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: ¥{c} ({d}%)'
},
legend: {
orient: 'vertical',
left: 'left'
},
series: [
{
name: '校区工资分布',
type: 'pie',
radius: '70%',
data: [
{ value: 156780.50, name: '海淀校区' },
{ value: 128900.30, name: '朝阳校区' },
{ value: 159800.20, name: '丰台校区' }
],
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
}
}
]
}
campusChart.setOption(option)
}
//
const initTrendChart = () => {
if (!trendChartRef.value) return
trendChart = echarts.init(trendChartRef.value)
const option = {
tooltip: {
trigger: 'axis'
},
legend: {
data: ['总金额', '平均工资']
},
xAxis: {
type: 'category',
data: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']
},
yAxis: [
{
type: 'value',
name: '总金额(万元)',
position: 'left'
},
{
type: 'value',
name: '平均工资(元)',
position: 'right'
}
],
series: [
{
name: '总金额',
type: 'bar',
data: [35, 38, 42, 45, 41, 39, 44, 46, 43, 47, 49, 52],
itemStyle: { color: '#409eff' }
},
{
name: '平均工资',
type: 'line',
yAxisIndex: 1,
data: [6800, 7200, 7100, 7300, 6900, 7500, 7400, 7600, 7200, 7800, 7900, 8100],
itemStyle: { color: '#67c23a' }
}
]
}
trendChart.setOption(option)
}
//
const initStructureChart = () => {
if (!structureChartRef.value) return
structureChart = echarts.init(structureChartRef.value)
const option = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
legend: {
data: ['基础工资', '绩效工资', '补贴', '扣除项目']
},
xAxis: {
type: 'category',
data: ['海淀校区', '朝阳校区', '丰台校区']
},
yAxis: {
type: 'value',
name: '金额(元)'
},
series: [
{
name: '基础工资',
type: 'bar',
stack: '总量',
data: [85000, 72000, 89000],
itemStyle: { color: '#409eff' }
},
{
name: '绩效工资',
type: 'bar',
stack: '总量',
data: [45000, 38000, 42000],
itemStyle: { color: '#67c23a' }
},
{
name: '补贴',
type: 'bar',
stack: '总量',
data: [18000, 15000, 20000],
itemStyle: { color: '#e6a23c' }
},
{
name: '扣除项目',
type: 'bar',
stack: '总量',
data: [-8780, -6100, -11200],
itemStyle: { color: '#f56c6c' }
}
]
}
structureChart.setOption(option)
}
//
const updateCharts = () => {
//
//
}
//
const formatNumber = (num: number): string => {
if (!num) return '0'
return num.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
//
window.addEventListener('resize', () => {
campusChart?.resize()
trendChart?.resize()
structureChart?.resize()
})
</script>
<style lang="scss" scoped>
.main-container {
.stat-card {
height: 120px;
:deep(.el-card__body) {
padding: 20px;
height: 100%;
}
.stat-content {
display: flex;
align-items: center;
height: 100%;
.stat-icon {
width: 60px;
height: 60px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 16px;
&.total-staff {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
&.total-amount {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
}
&.average-salary {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
}
&.cost-rate {
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
color: white;
}
}
.stat-info {
flex: 1;
.stat-value {
font-size: 24px;
font-weight: 600;
color: #303133;
line-height: 1;
margin-bottom: 8px;
}
.stat-label {
font-size: 14px;
color: #606266;
}
}
}
}
.chart-card {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.chart-container {
height: 300px;
width: 100%;
}
.chart-container-large {
height: 400px;
width: 100%;
}
}
.font-medium {
font-weight: 500;
}
}
</style>

56
admin/src/router/modules/salary.ts

@ -0,0 +1,56 @@
export default [
{
path: '/salary',
component: () => import('@/layout/default/index.vue'),
redirect: '/salary/list',
meta: {
title: '工资管理',
icon: 'Money',
sort: 50
},
children: [
{
path: 'list',
name: 'SalaryList',
component: () => import('@/app/views/salary/list.vue'),
meta: {
title: '工资条管理',
icon: 'List',
activeMenu: '/salary/list'
}
},
{
path: 'edit',
name: 'SalaryEdit',
component: () => import('@/app/views/salary/edit.vue'),
meta: {
title: '编辑工资条',
icon: 'Edit',
activeMenu: '/salary/list',
hidden: true
}
},
{
path: 'detail',
name: 'SalaryDetail',
component: () => import('@/app/views/salary/detail.vue'),
meta: {
title: '工资条详情',
icon: 'Document',
activeMenu: '/salary/list',
hidden: true
}
},
{
path: 'statistics',
name: 'SalaryStatistics',
component: () => import('@/app/views/salary/statistics.vue'),
meta: {
title: '工资统计',
icon: 'TrendCharts',
activeMenu: '/salary/statistics'
}
}
]
}
]

BIN
doc/劳 动 合 同.docx

Binary file not shown.

132
niucloud/app/adminapi/controller/salary/Payroll.php

@ -0,0 +1,132 @@
<?php
// +----------------------------------------------------------------------
// | Niucloud-admin 企业快速开发的多应用管理平台
// +----------------------------------------------------------------------
// | 官方网址:https://www.niucloud.com
// +----------------------------------------------------------------------
// | niucloud团队 版权所有 开源版本可自由商用
// +----------------------------------------------------------------------
// | Author: Niucloud Team
// +----------------------------------------------------------------------
namespace app\adminapi\controller\salary;
use app\service\admin\salary\PayrollService;
use core\base\BaseAdminController;
/**
* 工资条管理控制器
* Class Payroll
* @package app\adminapi\controller\salary
*/
class Payroll extends BaseAdminController
{
/**
* 获取工资条列表
* @return \think\Response
*/
public function list()
{
$data = $this->request->params([
['page', 1],
['limit', 20],
['campus_id', ''],
['salary_month', ''],
['staff_name', ''],
['status', '']
]);
return success('操作成功', (new PayrollService())->getPage($data));
}
/**
* 获取工资条详情
* @param int $id
* @return \think\Response
*/
public function info(int $id)
{
return success('操作成功', (new PayrollService())->getInfo($id));
}
/**
* 创建工资条
* @return \think\Response
*/
public function add()
{
$data = $this->request->params([
['staff_id', 0],
['campus_id', 0],
['salary_month', ''],
['base_salary', 0],
['full_attendance_days', 22],
['attendance', 0],
['mgr_performance', 0],
['performance_bonus', 0],
['other_subsidies', 0],
['deductions', 0],
['social_security', 0],
['individual_income_tax', 0],
['remarks', '']
]);
$this->validate($data, 'app\adminapi\validate\salary\Payroll.add');
$id = (new PayrollService())->add($data);
return success('创建成功', ['id' => $id]);
}
/**
* 更新工资条
* @return \think\Response
*/
public function edit()
{
$data = $this->request->params([
['id', 0],
['staff_id', 0],
['campus_id', 0],
['salary_month', ''],
['base_salary', 0],
['full_attendance_days', 22],
['attendance', 0],
['mgr_performance', 0],
['performance_bonus', 0],
['other_subsidies', 0],
['deductions', 0],
['social_security', 0],
['individual_income_tax', 0],
['remarks', '']
]);
$this->validate($data, 'app\adminapi\validate\salary\Payroll.edit');
(new PayrollService())->edit($data['id'], $data);
return success('更新成功');
}
/**
* 删除工资条
* @return \think\Response
*/
public function delete()
{
$data = $this->request->params([
['id', 0]
]);
(new PayrollService())->del($data['id']);
return success('删除成功');
}
/**
* 批量导入工资条
* @return \think\Response
*/
public function import()
{
// TODO: 后续实现Excel导入功能
return success('导入功能开发中');
}
}

52
niucloud/app/adminapi/controller/salary/Statistics.php

@ -0,0 +1,52 @@
<?php
// +----------------------------------------------------------------------
// | Niucloud-admin 企业快速开发的多应用管理平台
// +----------------------------------------------------------------------
// | 官方网址:https://www.niucloud.com
// +----------------------------------------------------------------------
// | niucloud团队 版权所有 开源版本可自由商用
// +----------------------------------------------------------------------
// | Author: Niucloud Team
// +----------------------------------------------------------------------
namespace app\adminapi\controller\salary;
use app\service\admin\salary\StatisticsService;
use core\base\BaseAdminController;
/**
* 工资统计分析控制器
* Class Statistics
* @package app\adminapi\controller\salary
*/
class Statistics extends BaseAdminController
{
/**
* 工资统计摘要
* @return \think\Response
*/
public function summary()
{
$data = $this->request->params([
['campus_id', ''],
['salary_month', '']
]);
return success('操作成功', (new StatisticsService())->getSummary($data));
}
/**
* 工资趋势数据
* @return \think\Response
*/
public function trend()
{
$data = $this->request->params([
['campus_id', ''],
['start_month', ''],
['end_month', '']
]);
return success('操作成功', (new StatisticsService())->getTrend($data));
}
}

16
niucloud/app/adminapi/route/salary.php

@ -40,6 +40,22 @@ Route::group('salary', function () {
Route::get('departments_all','salary.Salary/getDepartmentsAll');
// 工资条管理
Route::group('payroll', function () {
Route::get('list', 'salary.Payroll/list');
Route::get('info/:id', 'salary.Payroll/info');
Route::post('add', 'salary.Payroll/add');
Route::post('edit', 'salary.Payroll/edit');
Route::post('delete', 'salary.Payroll/delete');
Route::post('import', 'salary.Payroll/import');
});
// 统计分析
Route::group('statistics', function () {
Route::get('summary', 'salary.Statistics/summary');
Route::get('trend', 'salary.Statistics/trend');
});
})->middleware([
AdminCheckToken::class,
AdminCheckRole::class,

59
niucloud/app/adminapi/validate/salary/Payroll.php

@ -0,0 +1,59 @@
<?php
// +----------------------------------------------------------------------
// | Niucloud-admin 企业快速开发的多应用管理平台
// +----------------------------------------------------------------------
// | 官方网址:https://www.niucloud.com
// +----------------------------------------------------------------------
// | niucloud团队 版权所有 开源版本可自由商用
// +----------------------------------------------------------------------
// | Author: Niucloud Team
// +----------------------------------------------------------------------
namespace app\adminapi\validate\salary;
use core\base\BaseValidate;
/**
* 工资条验证器
* Class Payroll
* @package app\adminapi\validate\salary
*/
class Payroll extends BaseValidate
{
protected $rule = [
'staff_id' => 'require|integer|gt:0',
'salary_month' => 'require|date',
'base_salary' => 'require|float|egt:0',
'full_attendance_days' => 'require|integer|between:1,31',
'attendance' => 'require|float|egt:0',
'mgr_performance' => 'float|egt:0',
'performance_bonus' => 'float|egt:0',
'other_subsidies' => 'float|egt:0',
'deductions' => 'float|egt:0',
'social_security' => 'float|egt:0',
'individual_income_tax' => 'float|egt:0'
];
protected $message = [
'staff_id.require' => '请选择员工',
'staff_id.gt' => '请选择有效的员工',
'salary_month.require' => '请选择工资月份',
'salary_month.date' => '工资月份格式不正确',
'base_salary.require' => '请输入基础工资',
'base_salary.egt' => '基础工资不能小于0',
'full_attendance_days.require' => '请输入满勤天数',
'full_attendance_days.between' => '满勤天数必须在1-31之间',
'attendance.require' => '请输入出勤天数',
'attendance.egt' => '出勤天数不能小于0'
];
protected $scene = [
'add' => ['staff_id', 'salary_month', 'base_salary', 'full_attendance_days', 'attendance'],
'edit' => ['staff_id', 'salary_month', 'base_salary', 'full_attendance_days', 'attendance']
];
public function sceneEdit()
{
return $this->append('id', 'require|integer|gt:0');
}
}

6
niucloud/app/model/salary/Salary.php

@ -17,8 +17,8 @@ use think\model\relation\HasMany;
use think\model\relation\HasOne;
use app\model\personnel\Personnel;
use app\model\departments\Departments;
use app\model\campus\Campus;
/**
* 工资模型
@ -113,4 +113,8 @@ class Salary extends BaseModel
return $this->hasOne(Departments::class, 'id', 'department_id')->joinType('left')->withField('department_name,id')->bind(['department_id_name'=>'department_name']);
}
public function campus(){
return $this->hasOne(Campus::class, 'id', 'campus_id')->joinType('left')->withField('campus_name,id')->bind(['campus_name'=>'campus_name']);
}
}

199
niucloud/app/service/admin/salary/PayrollService.php

@ -0,0 +1,199 @@
<?php
// +----------------------------------------------------------------------
// | Niucloud-admin 企业快速开发的多应用管理平台
// +----------------------------------------------------------------------
// | 官方网址:https://www.niucloud.com
// +----------------------------------------------------------------------
// | niucloud团队 版权所有 开源版本可自由商用
// +----------------------------------------------------------------------
// | Author: Niucloud Team
// +----------------------------------------------------------------------
namespace app\service\admin\salary;
use app\model\salary\Salary;
use app\model\personnel\Personnel;
use app\model\campus\Campus;
use core\base\BaseAdminService;
use core\exception\AdminException;
/**
* 工资条服务类
* Class PayrollService
* @package app\service\admin\salary
*/
class PayrollService extends BaseAdminService
{
public function __construct()
{
parent::__construct();
$this->model = new Salary();
}
/**
* 获取工资条分页列表
* @param array $where
* @return array
*/
public function getPage(array $where = [])
{
$field = 's.*, p.name as staff_name, c.campus_name as campus_name';
$search_model = $this->model
->alias('s')
->join('school_personnel p', 's.staff_id = p.id', 'left')
->leftJoin('school_campus_person_role cpr', 'p.id = cpr.person_id')
->leftJoin('school_campus c', 'cpr.campus_id = c.id')
->field($field)
->order('s.created_at desc');
// 筛选条件
if (!empty($where['campus_id'])) {
$search_model->where('cpr.campus_id', $where['campus_id']);
}
if (!empty($where['salary_month'])) {
$search_model->where('s.salary_month', 'like', $where['salary_month'] . '%');
}
if (!empty($where['staff_name'])) {
$search_model->where('p.name', 'like', '%' . $where['staff_name'] . '%');
}
if (!empty($where['status'])) {
$search_model->where('s.status', $where['status']);
}
return $this->pageQuery($search_model);
}
/**
* 获取工资条详情
* @param int $id
* @return array
*/
public function getInfo(int $id)
{
$info = $this->model
->alias('s')
->join('school_personnel p', 's.staff_id = p.id', 'left')
->leftJoin('school_campus_person_role cpr', 'p.id = cpr.person_id')
->leftJoin('school_campus c', 'cpr.campus_id = c.id')
->field('s.*, p.name as staff_name, c.campus_name as campus_name')
->where('s.id', $id)
->findOrEmpty()
->toArray();
if (empty($info)) {
throw new AdminException('工资条不存在');
}
return $info;
}
/**
* 添加工资条
* @param array $data
* @return int
*/
public function add(array $data)
{
// 获取员工校区信息
$campusInfo = $this->getStaffCampus($data['staff_id']);
$data['campus_id'] = $campusInfo['campus_id'];
// 计算工资
$calculated = $this->calculateSalary($data);
$data = array_merge($data, $calculated);
$data['created_at'] = date('Y-m-d H:i:s');
$data['updated_at'] = date('Y-m-d H:i:s');
$res = $this->model->create($data);
return $res->id;
}
/**
* 编辑工资条
* @param int $id
* @param array $data
* @return bool
*/
public function edit(int $id, array $data)
{
// 获取员工校区信息
$campusInfo = $this->getStaffCampus($data['staff_id']);
$data['campus_id'] = $campusInfo['campus_id'];
// 计算工资
$calculated = $this->calculateSalary($data);
$data = array_merge($data, $calculated);
$data['updated_at'] = date('Y-m-d H:i:s');
unset($data['id']); // 移除ID字段,避免更新主键
$this->model->where('id', $id)->update($data);
return true;
}
/**
* 删除工资条
* @param int $id
* @return bool
*/
public function del(int $id)
{
$this->model->where('id', $id)->delete();
return true;
}
/**
* 获取员工校区信息
* @param int $staffId
* @return array
*/
private function getStaffCampus(int $staffId)
{
$campusInfo = Personnel::alias('p')
->leftJoin('school_campus_person_role cpr', 'p.id = cpr.person_id')
->leftJoin('school_campus c', 'cpr.campus_id = c.id')
->field('IFNULL(cpr.campus_id, 0) as campus_id, CASE WHEN cpr.campus_id IS NULL OR cpr.campus_id = 0 THEN "总部" ELSE c.campus_name END as campus_name')
->where('p.id', $staffId)
->findOrEmpty()
->toArray();
return $campusInfo;
}
/**
* 工资计算
* @param array $data
* @return array
*/
private function calculateSalary(array $data): array
{
// 计算出勤工资
$workSalary = round(($data['base_salary'] / $data['full_attendance_days']) * $data['attendance'], 2);
// 计算应发工资
$grossSalary = round(
$workSalary +
($data['mgr_performance'] ?? 0) +
($data['performance_bonus'] ?? 0) +
($data['other_subsidies'] ?? 0) -
($data['deductions'] ?? 0),
2
);
// 计算实发工资
$netSalary = round(
$grossSalary -
($data['social_security'] ?? 0) -
($data['individual_income_tax'] ?? 0),
2
);
return [
'work_salary' => $workSalary,
'gross_salary' => $grossSalary,
'net_salary' => $netSalary
];
}
}

117
niucloud/app/service/admin/salary/StatisticsService.php

@ -0,0 +1,117 @@
<?php
// +----------------------------------------------------------------------
// | Niucloud-admin 企业快速开发的多应用管理平台
// +----------------------------------------------------------------------
// | 官方网址:https://www.niucloud.com
// +----------------------------------------------------------------------
// | niucloud团队 版权所有 开源版本可自由商用
// +----------------------------------------------------------------------
// | Author: Niucloud Team
// +----------------------------------------------------------------------
namespace app\service\admin\salary;
use app\model\salary\Salary;
use core\base\BaseAdminService;
/**
* 工资统计分析服务类
* Class StatisticsService
* @package app\service\admin\salary
*/
class StatisticsService extends BaseAdminService
{
/**
* 获取工资统计摘要
* @param array $where
* @return array
*/
public function getSummary(array $where)
{
$query = Salary::alias('s')
->leftJoin('school_campus_person_role cpr', 's.staff_id = cpr.person_id')
->leftJoin('school_campus c', 'cpr.campus_id = c.id');
// 筛选条件
if (!empty($where['campus_id'])) {
$query->where('cpr.campus_id', $where['campus_id']);
}
if (!empty($where['salary_month'])) {
$query->where('s.salary_month', 'like', $where['salary_month'] . '%');
}
// 总体统计
$summary = $query->field([
'COUNT(s.id) as total_employees',
'SUM(s.net_salary) as total_amount',
'AVG(s.net_salary) as average_salary'
])->find();
// 各校区统计
$campusStats = Salary::alias('s')
->leftJoin('school_campus_person_role cpr', 's.staff_id = cpr.person_id')
->leftJoin('school_campus c', 'cpr.campus_id = c.id')
->field([
'IFNULL(cpr.campus_id, 0) as campus_id',
'CASE WHEN cpr.campus_id IS NULL OR cpr.campus_id = 0 THEN "总部" ELSE c.campus_name END as campus_name',
'COUNT(s.id) as employee_count',
'SUM(s.net_salary) as total_amount'
]);
// 筛选条件
if (!empty($where['campus_id'])) {
$campusStats->where('cpr.campus_id', $where['campus_id']);
}
if (!empty($where['salary_month'])) {
$campusStats->where('s.salary_month', 'like', $where['salary_month'] . '%');
}
$campusStats = $campusStats->group('cpr.campus_id')->select();
return [
'total_employees' => $summary['total_employees'] ?? 0,
'total_amount' => $summary['total_amount'] ?? 0,
'average_salary' => round($summary['average_salary'] ?? 0, 2),
'cost_rate' => 65.2, // 这里需要根据实际业务计算
'campus_stats' => $campusStats
];
}
/**
* 获取工资趋势数据
* @param array $where
* @return array
*/
public function getTrend(array $where)
{
$query = Salary::alias('s')
->leftJoin('school_campus_person_role cpr', 's.staff_id = cpr.person_id');
if (!empty($where['campus_id'])) {
$query->where('cpr.campus_id', $where['campus_id']);
}
// 默认查询近12个月
if (empty($where['start_month'])) {
$where['start_month'] = date('Y-m', strtotime('-11 months'));
}
if (empty($where['end_month'])) {
$where['end_month'] = date('Y-m');
}
$trend = $query->field([
'DATE_FORMAT(s.salary_month, "%Y-%m") as month',
'SUM(s.net_salary) as total_amount',
'COUNT(s.id) as employee_count'
])
->where('s.salary_month', 'between', [
$where['start_month'] . '-01',
$where['end_month'] . '-31'
])
->group('DATE_FORMAT(s.salary_month, "%Y-%m")')
->order('month')
->select();
return $trend;
}
}

16
uniapp/api/member.js

@ -46,6 +46,22 @@ export default {
return res;
})
},
//获取员工工资列表
getSalaryList(data = {}) {
let url = '/personnel/salary/list'
return http.get(url, data).then(res => {
return res;
})
},
//获取员工工资详情
getSalaryInfo(data) {
let url = `/personnel/salary/info`
return http.get(url, data).then(res => {
return res;
})
},
//修改学员信息
member_edit(data) {
let url = '/member/member_edit'

9
uniapp/pages.json

@ -296,6 +296,15 @@
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/coach/my/salary",
"style": {
"navigationBarTitleText": "我的工资",
"navigationStyle": "default",
"navigationBarBackgroundColor": "#29d3b4",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/market/clue/add_clues",
"style": {

546
uniapp/pages/coach/my/salary.vue

@ -0,0 +1,546 @@
<!--员工-我的工资-->
<template>
<view class="main_box">
<view class="main_section">
<!-- 筛选条件 -->
<view class="filter_section">
<view class="filter_item">
<picker mode="date" fields="month" :value="selectedMonth" @change="onMonthChange">
<view class="picker_display">
<text>{{ selectedMonth || '选择月份' }}</text>
<text class="picker_arrow">></text>
</view>
</picker>
</view>
<view class="filter_btn" @click="loadSalaryList">查询</view>
</view>
<!-- 工资条列表 -->
<view class="salary_list" v-if="salaryList.length > 0">
<view
class="salary_item"
v-for="(item, index) in salaryList"
:key="item.id"
@click="viewSalaryDetail(item)"
>
<view class="salary_header">
<view class="salary_month">{{ formatMonth(item.salary_month) }}</view>
<view class="salary_status" :class="getStatusClass(item.status)">
{{ getStatusText(item.status) }}
</view>
</view>
<view class="salary_content">
<view class="salary_row">
<text class="label">基础工资:</text>
<text class="value">¥{{ formatMoney(item.base_salary) }}</text>
</view>
<view class="salary_row">
<text class="label">出勤工资:</text>
<text class="value">¥{{ formatMoney(item.work_salary) }}</text>
</view>
<view class="salary_row">
<text class="label">应发工资:</text>
<text class="value highlight">¥{{ formatMoney(item.gross_salary) }}</text>
</view>
<view class="salary_row">
<text class="label">实发工资:</text>
<text class="value net_salary">¥{{ formatMoney(item.net_salary) }}</text>
</view>
</view>
<view class="salary_footer">
<text class="view_detail">点击查看详情 ></text>
</view>
</view>
</view>
<!-- 暂无数据 -->
<view class="no_data" v-else-if="!loading">
<image class="no_data_icon" src="/static/images/no_data.png" mode="aspectFit"></image>
<text class="no_data_text">暂无工资数据</text>
</view>
<!-- 加载中 -->
<view class="loading" v-if="loading">
<text>加载中...</text>
</view>
</view>
<!-- 工资详情弹窗 -->
<view class="salary_modal" v-if="showDetail" @click="closeDetail">
<view class="modal_content" @click.stop>
<view class="modal_header">
<text class="modal_title">工资详情</text>
<text class="modal_close" @click="closeDetail">×</text>
</view>
<view class="modal_body" v-if="salaryDetail">
<view class="detail_section">
<view class="section_title">基础信息</view>
<view class="detail_row">
<text class="detail_label">工资月份:</text>
<text class="detail_value">{{ formatMonth(salaryDetail.salary_month) }}</text>
</view>
<view class="detail_row">
<text class="detail_label">基础工资:</text>
<text class="detail_value">¥{{ formatMoney(salaryDetail.base_salary) }}</text>
</view>
<view class="detail_row">
<text class="detail_label">满勤天数:</text>
<text class="detail_value">{{ salaryDetail.full_attendance_days }}</text>
</view>
<view class="detail_row">
<text class="detail_label">出勤天数:</text>
<text class="detail_value">{{ salaryDetail.attendance }}</text>
</view>
</view>
<view class="detail_section">
<view class="section_title">收入明细</view>
<view class="detail_row">
<text class="detail_label">出勤工资:</text>
<text class="detail_value">¥{{ formatMoney(salaryDetail.work_salary) }}</text>
</view>
<view class="detail_row" v-if="salaryDetail.mgr_performance > 0">
<text class="detail_label">管理绩效:</text>
<text class="detail_value">¥{{ formatMoney(salaryDetail.mgr_performance) }}</text>
</view>
<view class="detail_row" v-if="salaryDetail.performance_bonus > 0">
<text class="detail_label">销售提成:</text>
<text class="detail_value">¥{{ formatMoney(salaryDetail.performance_bonus) }}</text>
</view>
<view class="detail_row" v-if="salaryDetail.other_subsidies > 0">
<text class="detail_label">其他补贴:</text>
<text class="detail_value">¥{{ formatMoney(salaryDetail.other_subsidies) }}</text>
</view>
</view>
<view class="detail_section">
<view class="section_title">扣除明细</view>
<view class="detail_row" v-if="salaryDetail.deductions > 0">
<text class="detail_label">其他扣款:</text>
<text class="detail_value text_red">-¥{{ formatMoney(salaryDetail.deductions) }}</text>
</view>
<view class="detail_row" v-if="salaryDetail.social_security > 0">
<text class="detail_label">社保:</text>
<text class="detail_value text_red">-¥{{ formatMoney(salaryDetail.social_security) }}</text>
</view>
<view class="detail_row" v-if="salaryDetail.individual_income_tax > 0">
<text class="detail_label">个税:</text>
<text class="detail_value text_red">-¥{{ formatMoney(salaryDetail.individual_income_tax) }}</text>
</view>
</view>
<view class="detail_section">
<view class="section_title">工资汇总</view>
<view class="detail_row highlight_row">
<text class="detail_label">应发工资:</text>
<text class="detail_value highlight">¥{{ formatMoney(salaryDetail.gross_salary) }}</text>
</view>
<view class="detail_row net_salary_row">
<text class="detail_label">实发工资:</text>
<text class="detail_value net_salary">¥{{ formatMoney(salaryDetail.net_salary) }}</text>
</view>
</view>
<view class="detail_section" v-if="salaryDetail.remarks">
<view class="section_title">备注</view>
<view class="detail_remarks">{{ salaryDetail.remarks }}</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import memberApi from '@/api/member.js';
export default {
data() {
return {
selectedMonth: '',
salaryList: [],
loading: false,
showDetail: false,
salaryDetail: null,
}
},
onLoad() {
//
const now = new Date();
this.selectedMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
this.loadSalaryList();
},
methods: {
//
onMonthChange(e) {
this.selectedMonth = e.detail.value;
},
//
async loadSalaryList() {
this.loading = true;
try {
const params = {};
if (this.selectedMonth) {
params.salary_month = this.selectedMonth;
}
const res = await memberApi.getSalaryList(params);
if (res.code === 1) {
this.salaryList = res.data.list || [];
} else {
uni.showToast({
title: res.msg || '获取数据失败',
icon: 'none'
});
}
} catch (error) {
console.error('获取工资列表失败:', error);
uni.showToast({
title: '网络错误,请稍后重试',
icon: 'none'
});
} finally {
this.loading = false;
}
},
//
async viewSalaryDetail(item) {
try {
const res = await memberApi.getSalaryInfo({ id: item.id });
if (res.code === 1) {
this.salaryDetail = res.data;
this.showDetail = true;
} else {
uni.showToast({
title: res.msg || '获取详情失败',
icon: 'none'
});
}
} catch (error) {
console.error('获取工资详情失败:', error);
uni.showToast({
title: '网络错误,请稍后重试',
icon: 'none'
});
}
},
//
closeDetail() {
this.showDetail = false;
this.salaryDetail = null;
},
//
formatMonth(month) {
if (!month) return '';
return month.substring(0, 7).replace('-', '年') + '月';
},
//
formatMoney(amount) {
if (!amount && amount !== 0) return '0.00';
return Number(amount).toFixed(2);
},
//
getStatusText(status) {
switch (status) {
case 1: return '未发放';
case 2: return '已发放';
default: return '未知';
}
},
//
getStatusClass(status) {
switch (status) {
case 1: return 'status_pending';
case 2: return 'status_paid';
default: return '';
}
}
}
}
</script>
<style lang="less" scoped>
.main_box {
background: #292929;
min-height: 100vh;
}
.main_section {
padding: 20rpx;
padding-bottom: 150rpx;
}
/* 筛选条件 */
.filter_section {
display: flex;
align-items: center;
background: #434544;
border-radius: 10rpx;
padding: 20rpx;
margin-bottom: 20rpx;
.filter_item {
flex: 1;
.picker_display {
display: flex;
justify-content: space-between;
align-items: center;
color: #D7D7D7;
font-size: 28rpx;
.picker_arrow {
color: #25a18b;
font-size: 24rpx;
}
}
}
.filter_btn {
background: #25a18b;
color: #fff;
padding: 15rpx 30rpx;
border-radius: 8rpx;
font-size: 26rpx;
margin-left: 20rpx;
}
}
/* 工资条列表 */
.salary_list {
.salary_item {
background: #434544;
border-radius: 10rpx;
padding: 30rpx;
margin-bottom: 20rpx;
.salary_header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
padding-bottom: 15rpx;
border-bottom: 1rpx solid #555;
.salary_month {
color: #fff;
font-size: 32rpx;
font-weight: bold;
}
.salary_status {
padding: 8rpx 16rpx;
border-radius: 20rpx;
font-size: 24rpx;
&.status_pending {
background: rgba(255, 165, 0, 0.2);
color: #FFA500;
}
&.status_paid {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
}
}
.salary_content {
.salary_row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15rpx;
.label {
color: #D7D7D7;
font-size: 26rpx;
}
.value {
color: #fff;
font-size: 28rpx;
&.highlight {
color: #25a18b;
font-weight: bold;
}
&.net_salary {
color: #22c55e;
font-weight: bold;
font-size: 30rpx;
}
}
}
}
.salary_footer {
text-align: center;
margin-top: 20rpx;
padding-top: 15rpx;
border-top: 1rpx solid #555;
.view_detail {
color: #25a18b;
font-size: 24rpx;
}
}
}
}
/* 暂无数据 */
.no_data {
text-align: center;
padding: 100rpx 0;
color: #888;
.no_data_icon {
width: 150rpx;
height: 150rpx;
margin-bottom: 30rpx;
}
.no_data_text {
font-size: 28rpx;
}
}
/* 加载中 */
.loading {
text-align: center;
padding: 50rpx 0;
color: #888;
font-size: 28rpx;
}
/* 工资详情弹窗 */
.salary_modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
.modal_content {
background: #434544;
border-radius: 15rpx;
width: 90%;
max-height: 80%;
overflow: hidden;
.modal_header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx;
border-bottom: 1rpx solid #555;
.modal_title {
color: #fff;
font-size: 32rpx;
font-weight: bold;
}
.modal_close {
color: #888;
font-size: 40rpx;
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
}
}
.modal_body {
max-height: 60vh;
overflow-y: auto;
padding: 30rpx;
.detail_section {
margin-bottom: 40rpx;
.section_title {
color: #25a18b;
font-size: 28rpx;
font-weight: bold;
margin-bottom: 20rpx;
padding-bottom: 10rpx;
border-bottom: 1rpx solid #555;
}
.detail_row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15rpx;
.detail_label {
color: #D7D7D7;
font-size: 26rpx;
}
.detail_value {
color: #fff;
font-size: 26rpx;
&.highlight {
color: #25a18b;
font-weight: bold;
}
&.net_salary {
color: #22c55e;
font-weight: bold;
}
&.text_red {
color: #f56565;
}
}
}
&.highlight_row {
background: rgba(37, 161, 139, 0.1);
padding: 15rpx;
border-radius: 8rpx;
}
&.net_salary_row {
background: rgba(34, 197, 94, 0.1);
padding: 15rpx;
border-radius: 8rpx;
}
.detail_remarks {
background: #555;
padding: 20rpx;
border-radius: 8rpx;
color: #D7D7D7;
font-size: 26rpx;
line-height: 1.6;
}
}
}
}
}
</style>

8
uniapp/pages/common/profile/index.vue

@ -116,11 +116,9 @@
});
},
viewSalaryInfo() {
//
uni.showModal({
title: '工资明细',
content: '工资明细页面开发中,将显示base_salary、performance_bonus、deductions、net_salary等字段,只能查看不能修改',
showCancel: false
//
uni.navigateTo({
url: '/pages/coach/my/salary'
});
}
}

305
开发任务分配和质量控制.md

@ -0,0 +1,305 @@
# Word合同模板系统开发任务分配和质量控制
## 🎯 项目管理者严格质量要求
### 核心质量原则
1. **数据一致性第一**:页面显示数据与数据库数据必须100%一致
2. **功能完整性第一**:每个功能都要完整实现,不允许半成品
3. **用户体验第一**:每个交互都要符合预期,不允许异常
4. **代码质量第一**:不合格代码绝不允许合并
## 📋 详细任务分配
### 🔧 后端开发任务(PHP开发者)
#### 第一阶段:数据库和基础架构(3天)
**严格验收标准:**
- [ ] 数据库表结构100%正确,字段类型、长度、索引完整
- [ ] 模型类关联关系正确,查询结果与预期完全一致
- [ ] 基础API框架搭建完成,路由配置正确
**具体任务:**
1. **创建数据库表**
```sql
-- 必须严格按照设计创建以下表
CREATE TABLE `school_document_data_source_config` (...)
CREATE TABLE `school_document_generate_log` (...)
-- 为现有表添加必要字段
ALTER TABLE `school_contract_sign` ADD COLUMN `signature_image` varchar(500) DEFAULT NULL COMMENT '签名图片路径';
```
2. **创建模型类**
```php
// app/model/document/DocumentDataSourceConfig.php
// app/model/document/DocumentGenerateLog.php
// 每个模型必须有完整的关联关系和搜索器
```
3. **基础服务类框架**
```php
// app/service/admin/document/DocumentTemplateService.php
// app/service/admin/contract/ContractDistributionService.php
// app/service/api/contract/ContractService.php
```
**质量检查项:**
- 数据库表创建成功,字段完整
- 模型类查询结果正确
- 服务类基础方法可正常调用
#### 第二阶段:Word模板处理(4天)
**严格验收标准:**
- [ ] Word文档上传功能完整,支持.docx格式
- [ ] 占位符解析100%准确,不能遗漏任何占位符
- [ ] 数据源配置功能完整,支持各种字段类型
- [ ] 模板预览功能正常,显示内容与实际模板一致
**具体任务:**
1. **Word文档处理服务**
```php
class DocumentTemplateService {
// 上传Word模板
public function uploadTemplate(array $data): array
// 解析占位符
public function parsePlaceholders(string $filePath): array
// 配置数据源
public function configDataSource(int $contractId, array $config): bool
// 预览模板
public function previewTemplate(int $contractId): array
}
```
2. **文件存储集成**
- 腾讯云存储上传
- 文件路径管理
- 安全验证
**质量检查项:**
- 上传的Word文件能正确存储到腾讯云
- 占位符解析结果与手动检查结果一致
- 数据源配置能正确保存到数据库
- 模板预览显示正确的占位符信息
#### 第三阶段:合同分发系统(3天)
**严格验收标准:**
- [ ] 手动分发功能完整,支持批量分发
- [ ] 自动分发事件监听器正常工作
- [ ] 分发记录完整保存,状态更新正确
- [ ] 与支付系统集成无异常
**具体任务:**
1. **合同分发服务**
```php
class ContractDistributionService {
// 手动分发合同
public function manualDistribute(int $contractId, array $personnelIds): bool
// 自动分发合同(课程购买触发)
public function autoDistribute(array $orderData): bool
// 获取分发记录
public function getDistributionRecords(array $params): array
}
```
2. **事件监听器**
```php
class ContractDistributionListener {
public function handle(array $params): void
}
```
**质量检查项:**
- 手动分发后数据库记录正确
- 课程购买后自动分发正常触发
- 分发状态更新正确
- 分发记录查询结果准确
#### 第四阶段:文档生成系统(4天)
**严格验收标准:**
- [ ] 队列任务处理正常,支持异步生成
- [ ] Word文档生成100%正确,占位符全部替换
- [ ] 生成状态跟踪准确,错误信息详细
- [ ] 文件下载功能正常
**具体任务:**
1. **文档生成Job**
```php
class DocumentGenerateJob extends BaseJob {
public function doJob(array $data): bool
}
```
2. **文档生成服务**
```php
class DocumentGenerateService {
// 生成Word文档
public function generateDocument(int $contractSignId): bool
// 获取生成状态
public function getGenerateStatus(int $logId): array
// 下载生成的文档
public function downloadDocument(int $logId): array
}
```
**质量检查项:**
- 队列任务能正常执行
- 生成的Word文档占位符全部正确替换
- 生成状态实时更新
- 文件下载链接有效
### 🎨 前端管理界面任务(Vue3开发者)
#### 第一阶段:基础框架(2天)
**严格验收标准:**
- [ ] 页面路由配置正确,所有页面可正常访问
- [ ] API请求封装完整,错误处理机制完善
- [ ] 基础布局组件符合设计规范
**具体任务:**
1. **路由配置**
```javascript
// 合同模板管理路由
// 合同分发管理路由
// 生成记录管理路由
```
2. **API封装**
```javascript
// api/contract.js
export const contractApi = {
uploadTemplate,
getTemplateList,
configDataSource,
distributeContract,
getGenerateLog
}
```
**质量检查项:**
- 所有路由可正常访问
- API请求返回数据格式正确
- 错误处理提示用户友好
#### 第二阶段:模板管理界面(4天)
**严格验收标准:**
- [ ] 模板列表显示数据与数据库完全一致
- [ ] 模板上传功能完整,进度提示正确
- [ ] 占位符配置界面操作流畅,数据保存正确
- [ ] 模板预览功能正常,显示内容准确
**具体任务:**
1. **模板列表页面**
```vue
<template>
<!-- 模板列表表格 -->
<!-- 搜索筛选功能 -->
<!-- 操作按钮 -->
</template>
```
2. **模板上传组件**
```vue
<template>
<!-- 文件上传组件 -->
<!-- 上传进度显示 -->
<!-- 上传结果反馈 -->
</template>
```
3. **占位符配置组件**
```vue
<template>
<!-- 占位符列表 -->
<!-- 数据源配置表单 -->
<!-- 配置保存确认 -->
</template>
```
**质量检查项:**
- 模板列表数据与数据库一致
- 上传功能正常,文件正确保存
- 占位符配置保存成功
- 预览功能显示正确
#### 第三阶段:合同管理界面(3天)
**严格验收标准:**
- [ ] 合同分发界面操作简单明了
- [ ] 分发记录列表数据准确
- [ ] 生成状态监控实时更新
**具体任务:**
1. **合同分发组件**
2. **分发记录组件**
3. **生成状态监控组件**
**质量检查项:**
- 分发操作成功,数据库记录正确
- 分发记录显示准确
- 生成状态实时更新
### 📱 UniApp小程序任务(UniApp开发者)
#### 第一阶段:基础页面(3天)
**严格验收标准:**
- [ ] 严格保持暗黑主题,颜色不允许偏差
- [ ] 合同列表数据与数据库完全一致
- [ ] 用户身份验证正确
**具体任务:**
1. **合同列表页面**
```vue
<template>
<view class="contract-list" style="background-color: #181A20;">
<!-- 严格按照暗黑主题设计 -->
</view>
</template>
```
2. **合同详情页面**
3. **用户身份验证**
**质量检查项:**
- 页面主题颜色严格符合规范
- 合同列表数据正确
- 用户身份验证正常
#### 第二阶段:数据收集功能(4天)
**严格验收标准:**
- [ ] 动态表单生成正确,字段类型匹配
- [ ] 数据验证完整,提交成功
- [ ] 手写签名组件正常工作
- [ ] 离线状态处理完善
**具体任务:**
1. **动态表单组件**
2. **手写签名组件**
3. **数据提交处理**
**质量检查项:**
- 表单字段与配置一致
- 数据验证规则正确
- 签名功能正常
- 数据提交成功
## 🔍 严格的质量控制流程
### 每日质量检查
1. **代码审查**:每行代码都要检查
2. **功能测试**:每个功能都要测试
3. **数据验证**:页面数据与数据库数据对比
4. **性能监控**:API响应时间和页面加载速度
### 阶段验收标准
- **功能完整性**:100%实现,无异常
- **数据一致性**:前后端数据完全一致
- **用户体验**:操作流畅,符合预期
- **代码质量**:规范、安全、高效
### 不合格处理
- 立即回退不合格代码
- 要求重新开发,不允许修补
- 详细问题分析报告
- 重新走完整审查流程
---
**项目管理者承诺:严格把控每个环节,确保交付高质量的产品!**

422
项目开发管理方案.md

@ -0,0 +1,422 @@
# Word合同模板系统开发管理方案
## 项目管理总览
作为项目管理者,我将严格把控开发质量,确保代码规范、功能完整、性能优良。本方案将明确资源需求、开发规范、任务分配和质量控制流程。
## 一、关键资源确认清单
### 🔴 需要您提供的资源支持
#### 1. 数据库相关
- [✅] **数据库访问权限**:开发者是否有数据库读写权限?
- [ ✅] **数据库连接信息**:开发环境的数据库配置
数据库配置信息如下:
TYPE = mysql
HOSTNAME = mysql
DATABASE = niucloud
USERNAME = niucloud
PASSWORD = niucloud123
HOSTPORT = 3306
PREFIX = school_
CHARSET = utf8mb4
DEBUG = false
- [ ✅] **现有表结构**:确认以下表是否已存在及其完整结构
- `school_contract`
- `school_contract_sign`
- `school_document_data_source_config`
- `school_document_generate_log`
- `school_personnel`
- `school_customer_resources`
数据库字段可能不是很完善例如用户签名的图片现在就没有字段存储需要新增字段。
#### 2. 文件存储配置
- [✅] **腾讯云存储配置**:系统已支持腾讯云存储
- 配置位置:`school_sys_config`表,`config_key=STORAGE`
- 配置参数:SECRET_ID、SECRET_KEY、REGION、BUCKET、DOMAIN
- 获取方式:通过`CoreStorageService`服务获取配置
- [✅] **存储路径规范**:已确认路径规范
- 模板文件:`contract/2025/07/01/id_begin.docx`
- 已签署文件:`contract/{1/2}/personnel_id/2025/07/01/id_begin.docx`
- 其中{1/2}表示内部/外部合同类型
- [✅] **CDN配置**:不使用CDN配置,直接使用腾讯云存储域名
#### 3. 测试资源
- [✅] **测试Word模板**:提供标准的Word模板文件(包含占位符)
doc/副本(时间卡)体能课学员课程协议.docx外部人员签的合同
doc/劳 动 合 同.docx内部人员签的合同
- [✅] **测试数据**:提供测试用的人员、客户、课程数据
内部人员使用school_personnel中 id=7的
外部人员使用school_customer_resources中 id=63的
课程数据就使用school_course中 id=1的模版解析以后要把合同的 id 写入到这个表的contract_id中
- [✅] **测试环境**:独立的开发测试环境配置
docker 开发环境可以参考PRPs/docker_development_setup.md这个文档
#### 4. UniApp主题样式
- [✅] **暗黑主题文件**:已确认暗黑主题规范
- 背景色:`#181A20`
- 文字颜色:`#fff`
- 主题色:`rgb(41, 211, 180)`
- 页面标题栏:背景`#181A20`,文字`#fff`
- [✅] **UI组件库**:使用firstUI组件库
- [✅] **设计规范**:严格保持现有暗黑主题风格,不随意改变
#### 5. 现有系统集成
- [✅] **支付成功事件**:已确认事件触发机制
- 支付成功触发:`PaySuccess`事件
- 课程购买触发:`Student`事件(在`PayService::qrcodeNotify`中)
- 事件配置文件:`app/event.php`
- [✅] **用户权限系统**:已确认权限控制机制
- 管理端权限:`AdminCheckRole`中间件
- API端权限:`ApiCheckToken`中间件
- 员工端权限:`ApiPersonnelCheckToken`中间件
- [✅] **队列系统配置**:workerman队列系统已配置
- 队列命令:`php think workerman start`
- 队列配置:基于Redis,支持延迟处理
- Job基类:`BaseJob`,支持异步和同步执行
## 二、开发规范和质量标准
### 📋 代码质量标准
#### 后端开发规范(PHP)
```php
// 1. 严格遵循PSR-4自动加载规范
// 2. 所有类必须有完整的注释
// 3. 方法必须有参数和返回值类型声明
// 4. 必须进行异常处理和参数验证
/**
* 示例:标准的Service类
*/
class DocumentTemplateService extends BaseAdminService
{
/**
* 上传Word模板
* @param array $data 上传数据
* @return array 返回结果
* @throws \Exception
*/
public function uploadTemplate(array $data): array
{
// 参数验证
$this->validateUploadData($data);
try {
// 业务逻辑
return $this->processUpload($data);
} catch (\Exception $e) {
// 异常处理
throw new \Exception('模板上传失败:' . $e->getMessage());
}
}
}
```
#### 前端开发规范(Vue3)
```javascript
// 1. 使用Composition API
// 2. TypeScript类型声明
// 3. 统一的错误处理
// 4. 组件必须有完整的props和emits声明
<script setup lang="ts">
interface Props {
templateId: number
readonly?: boolean
}
interface Emits {
(e: 'update', data: any): void
(e: 'delete', id: number): void
}
const props = withDefaults(defineProps<Props>(), {
readonly: false
})
const emit = defineEmits<Emits>()
</script>
```
#### UniApp开发规范
```vue
<!-- 1. 严格遵循暗黑主题样式 -->
<!-- 2. 使用uni-app官方组件 -->
<!-- 3. 响应式设计适配不同屏幕 -->
<template>
<view class="contract-page">
<!-- 保持暗黑主题 -->
<view class="header dark-header">
<text class="title dark-text">合同详情</text>
</view>
</view>
</template>
<style lang="scss">
.contract-page {
background-color: #1a1a1a; // 暗黑背景
min-height: 100vh;
}
.dark-header {
background-color: #2d2d2d;
color: #ffffff;
}
.dark-text {
color: #e0e0e0;
}
</style>
```
### 🔍 代码审查标准
#### 必须通过的检查项
1. **功能完整性**:所有功能点必须完整实现
2. **错误处理**:必须有完善的异常处理机制
3. **性能优化**:数据库查询优化、前端渲染优化
4. **安全性**:SQL注入防护、XSS防护、文件上传安全
5. **代码规范**:符合团队编码规范
6. **测试覆盖**:关键功能必须有测试用例
## 三、详细任务分配
### 🔧 后端开发任务(PHP开发者)
#### 阶段一:基础架构搭建(3天)
**任务负责人**:后端开发智能体
**交付标准**:
- [ ] 数据库表结构创建和验证
- [ ] 基础Model类创建(Contract, ContractSign, DocumentDataSourceConfig, DocumentGenerateLog)
- [ ] 基础Service类框架搭建
- [ ] API路由配置
**具体任务**:
1. **数据库设计实现**
```sql
-- 创建所有必需的表
-- 添加索引优化
-- 设置外键约束
```
2. **模型类开发**
```php
// app/model/contract/Contract.php
// app/model/contract_sign/ContractSign.php
// app/model/document/DocumentDataSourceConfig.php
// app/model/document/DocumentGenerateLog.php
```
3. **基础服务类**
```php
// app/service/admin/document/DocumentTemplateService.php
// app/service/admin/contract/ContractDistributionService.php
// app/service/api/contract/ContractService.php
```
#### 阶段二:Word模板处理(4天)
**交付标准**:
- [ ] Word文档上传功能
- [ ] 占位符自动解析功能
- [ ] 数据源配置API
- [ ] 模板预览功能
**具体任务**:
1. **Word文档处理**
```php
// 使用phpoffice/phpword
// 实现占位符提取
// 支持.docx格式
```
2. **文件存储集成**
```php
// 腾讯云存储集成
// 文件上传安全验证
// 文件路径管理
```
#### 阶段三:合同分发系统(3天)
**交付标准**:
- [ ] 手动分发API
- [ ] 自动分发事件监听器
- [ ] 分发记录管理
- [ ] 与支付系统集成
#### 阶段四:文档生成系统(4天)
**交付标准**:
- [ ] 队列任务处理
- [ ] Word文档生成
- [ ] 生成状态跟踪
- [ ] 文件下载API
### 🎨 前端管理界面任务(Vue3开发者)
#### 阶段一:基础框架(2天)
**任务负责人**:前端开发智能体
**交付标准**:
- [ ] 页面路由配置
- [ ] 基础布局组件
- [ ] API请求封装
- [ ] 错误处理机制
#### 阶段二:模板管理界面(4天)
**交付标准**:
- [ ] 模板列表页面
- [ ] 模板上传组件
- [ ] 占位符配置界面
- [ ] 模板预览功能
#### 阶段三:合同管理界面(3天)
**交付标准**:
- [ ] 合同分发管理
- [ ] 分发记录列表
- [ ] 生成状态监控
### 📱 UniApp小程序任务(UniApp开发者)
#### 阶段一:基础页面(3天)
**任务负责人**:UniApp开发智能体
**交付标准**:
- [ ] 保持现有暗黑主题样式
- [ ] 合同列表页面
- [ ] 合同详情页面
- [ ] 用户身份验证
#### 阶段二:数据收集功能(4天)
**交付标准**:
- [ ] 动态表单生成
- [ ] 数据验证和提交
- [ ] 手写签名组件
- [ ] 离线状态处理
## 四、严格质量控制流程
### 🔥 **零容忍质量标准**
#### 核心原则
- **数据一致性**:页面显示数据必须与数据库完全一致
- **功能完整性**:不允许任何功能缺失或异常
- **用户体验**:每个交互都必须符合预期
- **代码质量**:不合格代码绝不允许合并
### 📊 **严格的验收标准**
#### 每日强制检查项
- [x] **代码提交质量**:每行代码都要有注释和类型声明
- [x] **数据库一致性**:页面数据与数据库数据100%匹配
- [x] **功能完整性测试**:每个功能点都要有完整的测试用例
- [x] **API响应验证**:所有API返回数据格式和内容验证
- [x] **前端渲染验证**:页面显示内容与API数据完全一致
- [x] **错误处理测试**:异常情况处理必须完善
- [x] **性能指标监控**:API响应<1秒页面加载<3秒
#### 阶段性验收标准(必须100%通过)
1. **功能完整性**:每个功能点都要有详细测试报告
2. **数据一致性**:前后端数据流转完全正确
3. **代码质量**:通过静态分析+人工审查
4. **性能标准**:API响应<1秒复杂查询<2秒
5. **安全标准**:SQL注入、XSS、文件上传安全测试
6. **兼容性**:多浏览器、多设备测试通过
### 🔍 **详细的代码审查流程**
#### 后端代码审查清单
- [x] **数据库操作**:每个查询都要验证返回数据的正确性
- [x] **API接口**:返回数据格式、字段完整性、错误处理
- [x] **业务逻辑**:每个业务流程都要有完整的测试用例
- [x] **安全验证**:参数验证、权限检查、SQL注入防护
- [x] **异常处理**:所有可能的异常情况都要有处理机制
#### 前端代码审查清单
- [x] **数据渲染**:页面显示数据与API返回数据完全一致
- [x] **用户交互**:每个按钮、表单、弹窗都要测试
- [x] **状态管理**:数据状态变化要正确反映到页面
- [x] **错误提示**:用户操作错误要有明确提示
- [x] **加载状态**:异步操作要有加载提示
#### UniApp代码审查清单
- [x] **主题一致性**:严格保持暗黑主题,不允许颜色偏差
- [x] **数据同步**:小程序数据与后端数据实时同步
- [x] **用户体验**:每个页面跳转、数据加载都要流畅
- [x] **离线处理**:网络异常时的用户提示和数据保存
### 🚨 **零容忍的质量控制措施**
#### 代码合并标准
- **功能测试**:必须通过完整的功能测试
- **数据验证**:页面数据与数据库数据100%一致
- **性能测试**:API响应时间和页面加载速度达标
- **安全测试**:通过安全漏洞扫描
- **代码审查**:至少2人审查通过
#### 不合格代码处理
- **立即回退**:发现问题立即回退代码
- **重新开发**:不允许修修补补,要求重新开发
- **详细报告**:问题分析和改进措施报告
- **再次审查**:修复后必须重新走完整审查流程
## 五、开发环境和工具
### 必需的开发工具
- **后端**:PHP 7.4+, Composer, phpoffice/phpword, ThinkPHP框架
- **前端**:Node.js 16+, Vue3, Element Plus, TypeScript
- **UniApp**:HBuilderX, uni-app CLI, firstUI组件库
- **数据库**:MySQL 8.0+(已配置:niucloud数据库)
- **队列系统**:workerman + Redis
- **文件存储**:腾讯云COS(已配置)
- **版本控制**:Git
- **代码质量**:ESLint, PHPStan
## 六、资源确认完成情况
### ✅ 已确认的资源
- **数据库配置**:MySQL连接信息已确认
- **现有表结构**:Contract、ContractSign模型已存在
- **腾讯云存储**:配置方式和存储路径已确认
- **UniApp主题**:暗黑主题规范已明确
- **系统集成**:支付事件、权限系统、队列系统已确认
### 🔄 需要开发的数据库表
基于现有模型分析,需要创建以下表:
- `school_document_data_source_config`(数据源配置表)
- `school_document_generate_log`(文档生成记录表)
- 为现有表添加签名图片字段等
## 七、开发任务分配确认
所有关键资源已确认完毕,系统架构清晰,可以开始分发开发任务。
### 📋 准备就绪的开发环境
- **后端环境**:ThinkPHP + MySQL + workerman队列
- **前端环境**:Vue3 + Element Plus
- **小程序环境**:UniApp + firstUI + 暗黑主题
- **存储环境**:腾讯云COS
- **开发工具**:Docker开发环境(参考PRPs/docker_development_setup.md)
---
## 🎯 **项目管理者质量承诺**
### **严格把控标准**
1. **数据一致性**:页面显示的每一个数据都必须与数据库完全一致
2. **功能完整性**:不允许任何功能缺失、异常或不符合预期
3. **用户体验**:每个交互流程都要完整、流畅、符合预期
4. **代码质量**:不合格代码绝对不允许合并到主分支
### **质量控制措施**
- **每日代码审查**:每天检查代码质量和功能实现
- **数据验证测试**:确保前后端数据流转100%正确
- **完整功能测试**:每个功能都要有详细的测试用例
- **性能监控**:API响应和页面加载性能持续监控
### **不合格处理**
- 发现任何质量问题立即要求重新开发
- 不允许"先上线后修复"的做法
- 每个功能必须达到生产环境标准才能通过
**✅ 在严格质量标准下,确认可以开始分发开发任务**
Loading…
Cancel
Save