完成电子版工资单

This commit is contained in:
tangxinyue 2026-07-30 15:12:42 +08:00
parent 5efeb4cb89
commit c2c4485d74
7 changed files with 794 additions and 301 deletions

View File

@ -98,7 +98,7 @@ function open() {
form.code = '' form.code = ''
form.agreed = false form.agreed = false
showError.value = false showError.value = false
fetchQrCode() // fetchQrCode()
} }
async function fetchQrCode() { async function fetchQrCode() {

View File

@ -0,0 +1,205 @@
<template>
<div class="right-box content-box">
<div class="right-title">
<div class="touzi-icon"></div>
<span>{{ title }}</span>
<div class="touzi-icon"></div>
</div>
<div class="from-box">
<div class="form-item" v-for="(field, index) in fields" :key="index">
<span class="label">{{ field.label }}</span>
<template v-if="field.type === 'year-month'">
<el-select class="custom-select" style="margin-right: 4px;width: 90px;" v-model="localFormData[field.keys[0]]" placeholder="年">
<el-option v-for="year in yearOptions" :key="year" :label="year + '年'" :value="year" />
</el-select>
<el-select class="custom-select" style="width: 70px;" v-model="localFormData[field.keys[1]]" placeholder="月">
<el-option v-for="month in monthOptions" :key="month" :label="month + '月'" :value="month" />
</el-select>
</template>
<template v-else>
<el-input class="custom-input" :type="field.type" v-model="localFormData[field.key]" placeholder="请输入" />
</template>
</div>
</div>
<div class="action-buttons">
<button class="btn btn-outline" @click="$emit('saveDraft')">保存草稿</button>
<button class="btn btn-primary" @click="$emit('download')"> </button>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
modelValue: {
type: Object,
required: true
},
fields: {
type: Array,
required: true,
default: () => []
},
title: {
type: String,
default: '编辑'
}
})
const emit = defineEmits(['update:modelValue', 'saveDraft', 'download'])
const localFormData = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const yearOptions = computed(() => {
const current = new Date().getFullYear()
const years = []
for (let i = 0; i < 15; i++) {
years.push((current - i).toString())
}
return years
})
const monthOptions = Array.from({ length: 12 }, (_, i) => (i + 1).toString())
</script>
<style scoped lang="scss">
.right-box {
width: 300px;
height: 600px;
&.content-box {
overflow-y: hidden;
background-color: #FFFFFF;
border-radius: 18px;
box-shadow: 0px 0px 5px 0px rgba(0, 0, 0, 0.1);
padding: 20px 16px;
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.right-title {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 16px;
line-height: 18px;
font-weight: 600;
color: #1A1A1A;
margin-bottom: 24px;
.touzi-icon {
width: 32px;
height: 32px;
}
}
.from-box {
flex: 1;
height: 100px;
overflow-y: auto;
}
.form-item {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
.label {
font-size: 14px;
color: #333333;
width: 90px;
flex-shrink: 0;
}
}
:deep(.custom-input) {
flex: 1;
.el-input__wrapper {
background-color: #F5F6F9;
box-shadow: none;
border-radius: 8px;
padding: 0 12px;
height: 36px;
transition: all 0.3s;
&.is-focus {
box-shadow: 0 0 0 1px #808AFC inset;
background-color: #FFFFFF;
}
}
.el-input__inner {
color: #333333;
font-size: 14px;
&::placeholder {
color: #BDBDBD;
}
}
}
:deep(.custom-select) {
.el-select__wrapper {
background-color: #F5F6F9;
box-shadow: none;
border-radius: 8px;
padding: 0 12px;
height: 36px;
transition: all 0.3s;
&.is-focused {
box-shadow: 0 0 0 1px #808AFC inset;
background-color: #FFFFFF;
}
}
}
.action-buttons {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 24px;
.btn {
width: 100%;
height: 36px;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
letter-spacing: 2px;
}
.btn-outline {
background-color: transparent;
border: 1px dashed #9F82F7;
color: #9F82F7;
&:hover {
background-color: #F7F8FF;
}
}
.btn-primary {
background: linear-gradient(215deg, #A679F4 7.14%, #778CF5 100%);
border: none;
color: #FFFFFF;
box-shadow: 0px 2px 6px rgba(166, 121, 244, 0.3);
&:hover {
opacity: 0.9;
}
}
}
}
</style>

View File

@ -0,0 +1,324 @@
<template>
<div class="electronic-preview-container" ref="mainBoxRef">
<!-- 顶部导航 -->
<div class="nav-bar">
<span class="close-btn"></span>
<span class="nav-title">工资条详情</span>
<span class="more-btn">···</span>
</div>
<!-- 卡片内容区域 -->
<div class="content-card">
<!-- 头部金额 -->
<div class="salary-header">
<div class="amount-wrap">
<span class="currency"></span>
<span class="amount">{{ formData.netSalary }}</span>
</div>
<div class="desc">实发工资</div>
</div>
<div class="divider"></div>
<!-- 详情列表 -->
<div class="detail-list">
<div class="list-item">
<span class="label">姓名</span>
<span class="value">{{ formData.name }}</span>
</div>
<div class="list-item">
<span class="label">任职部门</span>
<span class="value">{{ formData.department }}</span>
</div>
<div class="list-item">
<span class="label">职务</span>
<span class="value">{{ formData.job }}</span>
</div>
<div class="list-item">
<span class="label">专员薪酬标准</span>
<span class="value">{{ formData.standardSalary }}</span>
</div>
<div class="list-item">
<span class="label">应出勤天数</span>
<span class="value">{{ formData.payableAttendanceDays }}</span>
</div>
<div class="list-item">
<span class="label">事假</span>
<span class="value">{{ formData.personalLeaveDays }}</span>
</div>
<div class="list-item">
<span class="label">病假</span>
<span class="value">{{ formData.sickLeaveDays }}</span>
</div>
<div class="list-item">
<span class="label">实出勤天数</span>
<span class="value">{{ formData.actualAttendanceDays }}</span>
</div>
<div class="list-item">
<span class="label">补贴</span>
<span class="value">{{ formData.allowance }}</span>
</div>
<div class="list-item">
<span class="label">绩效</span>
<span class="value">{{ formData.performance }}</span>
</div>
<div class="list-item">
<span class="label">应发合计</span>
<span class="value">{{ formData.totalPayable }}</span>
</div>
<div class="list-item">
<span class="label">个人社保代扣</span>
<span class="value">{{ formData.socialInsurance }}</span>
</div>
<div class="list-item">
<span class="label">个人公积金代扣</span>
<span class="value">{{ formData.housingFund }}</span>
</div>
<div class="list-item">
<span class="label">个人所得税代扣</span>
<span class="value">{{ formData.tax }}</span>
</div>
<div class="list-item">
<span class="label">应扣事假</span>
<span class="value">{{ formData.personalLeaveDeduction }}</span>
</div>
<div class="list-item">
<span class="label">应扣病假</span>
<span class="value">{{ formData.sickLeaveDeduction }}</span>
</div>
<div class="list-item">
<span class="label">其他扣款</span>
<span class="value">{{ formData.otherDeduction }}</span>
</div>
<div class="list-item">
<span class="label">应扣合计</span>
<span class="value">{{ formData.totalDeduction }}</span>
</div>
<div class="list-item">
<span class="label">实发工资</span>
<span class="value">{{ formData.netSalary }}</span>
</div>
<div class="list-item">
<span class="label">备注</span>
<span class="value">{{ formData.remark }}</span>
</div>
</div>
<div class="divider"></div>
<!-- 底部按钮 -->
<div class="action-footer">
<div class="btn outline-btn">对工资有疑问</div>
<div class="btn primary-btn">签名确认</div>
</div>
</div>
<div v-if="!isVip" class="watermark-overlay"></div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'
import { useWatermarkGuard } from '@/hooks/useWatermarkGuard'
const props = defineProps({
formData: {
type: Object,
required: true
}
})
const userStore = useUserStore()
const { isVip } = storeToRefs(userStore)
const mainBoxRef = ref(null)
// VIP
useWatermarkGuard(mainBoxRef, isVip)
// +1234
const watermarkUrl = computed(() => {
const text = (props.formData.name || '') + '1234'
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="140" height="100">
<text x="0" y="50" transform="rotate(340)" fill="rgba(46,46,46,0.25)" font-size="12" font-family="sans-serif">${text}</text>
</svg>`
const encoded = typeof window !== 'undefined' ? btoa(unescape(encodeURIComponent(svg))) : ''
return `url("data:image/svg+xml;base64,${encoded}")`
})
defineExpose({
mainBoxRef
})
</script>
<style scoped lang="scss">
.electronic-preview-container {
width: 375px;
// height: 667px;
background-color: #F6F8FA;
margin: 0 259px;
position: relative;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
margin-bottom: 40px;
// overflow: hidden;
display: flex;
flex-direction: column;
.nav-bar {
height: 44px;
background-color: #EFEFEF;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
flex-shrink: 0;
.close-btn {
font-size: 20px;
color: #333;
font-weight: 300;
}
.nav-title {
font-weight: 500;
font-size: 16px;
color: #373737;
line-height: 20px;
text-shadow: 0 0 0.5px #373737;
}
.more-btn {
font-size: 20px;
color: #333;
font-weight: bold;
letter-spacing: 2px;
}
}
.content-card {
margin: 12px 18px 34px;
background-color: #FFFFFF;
border-radius: 6px;
padding: 24px 12px 21px;
flex: 1;
overflow-y: auto;
/* 隐藏滚动条 */
&::-webkit-scrollbar {
display: none;
}
-ms-overflow-style: none;
scrollbar-width: none;
/* 水印背景 */
background-repeat: repeat;
background-position: center;
.salary-header {
text-align: center;
margin-bottom: 24px;
.amount-wrap {
font-size: 24px;
color: #373737;
line-height: 24px;
font-weight: 700;
.currency {
margin-right: 4px;
}
.amount {
font-size: 28px;
}
}
.desc {
font-size: 12px;
color: #767676;
margin-top: 8px;
line-height: 12px;
}
}
.divider {
height: 1px;
border-top: 2px dotted #F2F2F2;
margin: 0;
}
.detail-list {
padding: 12px 0 24px;
display: flex;
flex-direction: column;
gap: 22px;
.list-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
font-size: 14px;
line-height: 14px;
padding: 0 12px;
color: #373737;
.label {
flex-shrink: 0;
margin-right: 16px;
}
.value {
text-align: right;
word-break: break-all;
}
}
}
.action-footer {
padding-top: 60px;
display: flex;
justify-content: space-between;
gap: 14px;
.btn {
flex: 1;
height: 38px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
line-height: 14px;
}
.outline-btn {
color: #3B9BDF;
border: 1px solid #3B9BDF;
background-color: #FFFFFF;
}
.primary-btn {
color: #FFFFFF;
background-color: #3B9BDF;
border: 1px solid #3B9BDF;
}
}
}
.watermark-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image: url('@/public/common/watermaker.png');
background-size: 125px 125px;
background-repeat: repeat;
pointer-events: none;
z-index: 10;
}
}
</style>

View File

@ -0,0 +1,85 @@
<template>
<div class="main-box" ref="mainBoxRef">
<img class="main-bg" src="@/public/other/gongzidan/gongzidan.png" alt="">
<span class="item-info" style="left: 156px;bottom: 210px;">{{ formData.job }}</span>
<span class="item-info" style="left: 202px;bottom: 210px;">{{ formData.name }}</span>
<span class="item-info" style="left: 248px;bottom: 210px;">{{ formData.basicSalary }}</span>
<span class="item-info" style="left: 288px;bottom: 210px;">{{ formData.performanceSalary }}</span>
<span class="item-info" style="left: 326px;bottom: 210px;">{{ formData.fullAttendanceBonus }}</span>
<span class="item-info" style="left: 366px;bottom: 210px;">{{ formData.otherBonus }}</span>
<span class="item-info" style="left: 408px;bottom: 210px;">{{ formData.totalSalary }}</span>
<span class="item-info" style="left: 452px;bottom: 210px;">{{ formData.socialInsurance }}</span>
<span class="item-info" style="left: 492px;bottom: 210px;">{{ formData.taxDeduction }}</span>
<span class="item-info" style="left: 530px;bottom: 210px;">{{ formData.attendanceDeduction }}</span>
<span class="item-info" style="left: 568px;bottom: 210px;">{{ formData.otherDeduction }}</span>
<span class="item-info" style="left: 607px;bottom: 210px;">{{ formData.totalDeduction }}</span>
<span class="item-info" style="left: 646px;bottom: 210px;">{{ formData.netSalary }}</span>
<span class="item-info time" style="left: 434px; bottom: 298px;">{{ formData.year }}</span>
<span class="item-info time" style="left: 488px; bottom: 298px;">{{ formData.month }}</span>
<div v-if="!isVip" class="watermark-overlay"></div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'
import { useWatermarkGuard } from '@/hooks/useWatermarkGuard'
const props = defineProps({
formData: {
type: Object,
required: true
}
})
const userStore = useUserStore()
const { isVip } = storeToRefs(userStore)
const mainBoxRef = ref(null)
useWatermarkGuard(mainBoxRef, isVip)
defineExpose({
mainBoxRef
})
</script>
<style scoped lang="scss">
.main-box {
position: relative;
margin: 0 54px;
.main-bg {
width: 800px;
height: 600px;
object-fit: contain;
}
.watermark-overlay {
position: absolute;
top: 0;
left: 0;
width: 800px;
height: 600px;
background-image: url('@/public/common/watermaker.png');
background-size: 125px 125px;
background-repeat: repeat;
pointer-events: none;
z-index: 1;
}
.item-info {
position: absolute;
font-size: 11px;
color: #454649;
opacity: 0.9;
transform: translateX(-50%);
text-shadow: 0 0 1px #454649EE;
&.time {
font-size: 16px;
font-weight: 500;
color: #46474C;
}
}
}
</style>

View File

@ -6,110 +6,33 @@
<img class="icon" src="@/public/other/gongzidan/icon.png" alt=""> <img class="icon" src="@/public/other/gongzidan/icon.png" alt="">
<span class="title">工资单</span> <span class="title">工资单</span>
</div> </div>
<div class="menu-item active">纸质版</div> <div class="menu-item" :class="{ active: currentType === 'paper' }" @click="currentType = 'paper'">纸质版
<div class="menu-item">电子版</div> </div>
<div class="menu-item" :class="{ active: currentType === 'electronic' }"
@click="currentType = 'electronic'">电子版</div>
</div> </div>
<img class="stay-tuned-img" src="@/public/common/stay-tuned.png" alt=""> <img class="stay-tuned-img" src="@/public/common/stay-tuned.png" alt="">
</div> </div>
<div class="main-box" ref="mainBoxRef"> <PaperPreview v-show="currentType === 'paper'" :formData="formData" ref="previewRef" />
<img class="main-bg" src="@/public/other/gongzidan/gongzidan.png" alt=""> <ElectronicPreview v-show="currentType === 'electronic'" :formData="electronicFormData"
<span class="item-info" style="left: 156px;bottom: 210px;">{{ formData.job }}</span> ref="electronicPreviewRef" />
<span class="item-info" style="left: 202px;bottom: 210px;">{{ formData.name }}</span> <CommonEdit class="right-box-sticky" title="工资单编辑" v-model="currentFormData" :fields="currentFormFields"
<span class="item-info" style="left: 248px;bottom: 210px;">{{ formData.basicSalary }}</span> @saveDraft="saveDraft" @download="downloadPaper" />
<span class="item-info" style="left: 288px;bottom: 210px;">{{ formData.performanceSalary }}</span>
<span class="item-info" style="left: 326px;bottom: 210px;">{{ formData.fullAttendanceBonus }}</span>
<span class="item-info" style="left: 366px;bottom: 210px;">{{ formData.otherBonus }}</span>
<span class="item-info" style="left: 408px;bottom: 210px;">{{ formData.totalSalary }}</span>
<span class="item-info" style="left: 452px;bottom: 210px;">{{ formData.socialInsurance }}</span>
<span class="item-info" style="left: 492px;bottom: 210px;">{{ formData.taxDeduction }}</span>
<span class="item-info" style="left: 530px;bottom: 210px;">{{ formData.attendanceDeduction }}</span>
<span class="item-info" style="left: 568px;bottom: 210px;">{{ formData.otherDeduction }}</span>
<span class="item-info" style="left: 607px;bottom: 210px;">{{ formData.totalDeduction }}</span>
<span class="item-info" style="left: 646px;bottom: 210px;">{{ formData.netSalary }}</span>
<span class="item-info time" style="left: 434px; bottom: 298px;">{{ formData.year }}</span>
<span class="item-info time" style="left: 488px; bottom: 298px;">{{ formData.month }}</span>
<div v-if="!isVip" class="watermark-overlay"></div>
</div>
<!-- <div class="right-box"></div> -->
<div class="right-box content-box">
<div class="right-title">
<div class="touzi-icon"></div>
<span>工资单编辑</span>
<div class="touzi-icon"></div>
<!-- <img class="touzi-icon" src="@/public/common/touzi.png" alt=""> -->
</div>
<div class="from-box">
<div class="form-item">
<span class="label">时间</span>
<el-select class="custom-select" style="margin-right: 4px;width: 90px;" v-model="formData.year"
placeholder="年">
<el-option v-for="year in yearOptions" :key="year" :label="year + '年'" :value="year" />
</el-select>
<el-select class="custom-select" style="width: 70px;" v-model="formData.month" placeholder="月">
<el-option v-for="month in monthOptions" :key="month" :label="month + '月'" :value="month" />
</el-select>
</div>
<div class="form-item">
<span class="label">岗位</span>
<el-input class="custom-input" v-model="formData.job" placeholder="请输入" />
</div>
<div class="form-item">
<span class="label">姓名</span>
<el-input class="custom-input" v-model="formData.name" placeholder="请输入" />
</div>
<div class="form-item">
<span class="label">基本工资</span>
<el-input class="custom-input" type="number" v-model="formData.basicSalary" placeholder="请输入" />
</div>
<div class="form-item">
<span class="label">绩效工资</span>
<el-input class="custom-input" type="number" v-model="formData.performanceSalary"
placeholder="请输入" />
</div>
<div class="form-item">
<span class="label">全勤奖金</span>
<el-input class="custom-input" type="number" v-model="formData.fullAttendanceBonus"
placeholder="请输入" />
</div>
<div class="form-item">
<span class="label">其它奖金</span>
<el-input class="custom-input" type="number" v-model="formData.otherBonus" placeholder="请输入" />
</div>
<div class="form-item">
<span class="label">社保缴纳</span>
<el-input class="custom-input" type="number" v-model="formData.socialInsurance" placeholder="请输入" />
</div>
<div class="form-item">
<span class="label">个税缴纳</span>
<el-input class="custom-input" type="number" v-model="formData.taxDeduction" placeholder="请输入" />
</div>
<div class="form-item">
<span class="label">考勤扣款</span>
<el-input class="custom-input" type="number" v-model="formData.attendanceDeduction"
placeholder="请输入" />
</div>
<div class="form-item">
<span class="label">其他扣款</span>
<el-input class="custom-input" type="number" v-model="formData.otherDeduction" placeholder="请输入" />
</div>
</div>
<div class="action-buttons">
<button class="btn btn-outline" @click="saveDraft">保存草稿</button>
<button class="btn btn-primary" @click="downloadPaper"> </button>
</div>
</div>
</div> </div>
<WarningFooter /> <WarningFooter />
</template> </template>
<script setup> <script setup>
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { ref, computed, watch, inject, onMounted } from 'vue' import { ref, watch, inject, onMounted, computed } from 'vue'
import WarningFooter from '@/components/common/WarningFooter.vue' import WarningFooter from '@/components/common/WarningFooter.vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import html2canvas from 'html2canvas' import html2canvas from 'html2canvas'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useWatermarkGuard } from '@/hooks/useWatermarkGuard'
import PaperPreview from './components/gongzidan/PaperPreview.vue'
import ElectronicPreview from './components/gongzidan/ElectronicPreview.vue'
import CommonEdit from '@/components/common/CommonEdit.vue'
const showPayment = inject('showPayment') const showPayment = inject('showPayment')
@ -117,7 +40,15 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
const workId = ref('') const workId = ref('')
const originalTitle = ref('') const originalTitle = ref('')
const previewRef = ref(null)
const electronicPreviewRef = ref(null)
const currentType = ref('paper')
/**
* 组件挂载后执行的初始化操作
* 1. URL 路由参数获取当前的草稿 ID (id workId)
* 2. 如果存在 ID则从本地存储 (localStorage) 读取对应的草稿数据
* 3. 将读取到的草稿数据解析并回显至表单模型 (formData) 恢复上一次的编辑状态
*/
onMounted(async () => { onMounted(async () => {
const id = route.query.id || route.query.workId const id = route.query.id || route.query.workId
if (!id) return if (!id) return
@ -135,7 +66,13 @@ onMounted(async () => {
originalTitle.value = work.title || '' originalTitle.value = work.title || ''
if (work.data_json) { if (work.data_json) {
const configData = typeof work.data_json === 'string' ? JSON.parse(work.data_json) : work.data_json const configData = typeof work.data_json === 'string' ? JSON.parse(work.data_json) : work.data_json
Object.assign(formData.value, configData) if (configData._type === 'electronic') {
currentType.value = 'electronic'
Object.assign(electronicFormData.value, configData)
} else {
currentType.value = 'paper'
Object.assign(formData.value, configData)
}
} }
} }
} catch (e) { } catch (e) {
@ -144,25 +81,10 @@ onMounted(async () => {
}) })
const userStore = useUserStore() const userStore = useUserStore()
const { isVip } = storeToRefs(userStore)
const mainBoxRef = ref(null)
useWatermarkGuard(mainBoxRef, isVip)
const currentYear = new Date().getFullYear().toString() const currentYear = new Date().getFullYear().toString()
const currentMonth = (new Date().getMonth() + 1).toString() const currentMonth = (new Date().getMonth() + 1).toString()
const yearOptions = computed(() => {
const current = new Date().getFullYear()
const years = []
for (let i = 0; i < 15; i++) {
years.push((current - i).toString())
}
return years
})
const monthOptions = Array.from({ length: 12 }, (_, i) => (i + 1).toString())
const formData = ref({ const formData = ref({
year: currentYear, year: currentYear,
month: currentMonth, month: currentMonth,
@ -182,6 +104,79 @@ const formData = ref({
netSalary: "12400", netSalary: "12400",
}) })
const formFields = ref([
{ label: '时间', type: 'year-month', keys: ['year', 'month'] },
{ label: '岗位', type: 'text', key: 'job' },
{ label: '姓名', type: 'text', key: 'name' },
{ label: '基本工资', type: 'number', key: 'basicSalary' },
{ label: '绩效工资', type: 'number', key: 'performanceSalary' },
{ label: '全勤奖金', type: 'number', key: 'fullAttendanceBonus' },
{ label: '其它奖金', type: 'number', key: 'otherBonus' },
{ label: '社保缴纳', type: 'number', key: 'socialInsurance' },
{ label: '个税缴纳', type: 'number', key: 'taxDeduction' },
{ label: '考勤扣款', type: 'number', key: 'attendanceDeduction' },
{ label: '其他扣款', type: 'number', key: 'otherDeduction' },
])
const electronicFormData = ref({
name: '肖战',
department: '市场推广部',
job: '推广专员',
standardSalary: "7500",
payableAttendanceDays: "20",
personalLeaveDays: "0",
sickLeaveDays: "0",
actualAttendanceDays: "20",
allowance: "200",
performance: "1000",
socialInsurance: "398.07",
housingFund: "118",
tax: "35.52",
personalLeaveDeduction: "0",
sickLeaveDeduction: "0",
otherDeduction: "0",
totalPayable: "8700",
totalDeduction: "551.59",
netSalary: "8148.41",
remark: '餐饮饭卡补贴200元'
})
const electronicFormFields = ref([
{ label: '姓名', type: 'text', key: 'name' },
{ label: '任职部门', type: 'text', key: 'department' },
{ label: '职务', type: 'text', key: 'job' },
{ label: '专员薪酬标准', type: 'number', key: 'standardSalary' },
{ label: '应出勤天数', type: 'number', key: 'payableAttendanceDays' },
{ label: '事假', type: 'number', key: 'personalLeaveDays' },
{ label: '病假', type: 'number', key: 'sickLeaveDays' },
{ label: '实出勤天数', type: 'number', key: 'actualAttendanceDays' },
{ label: '补贴', type: 'number', key: 'allowance' },
{ label: '绩效', type: 'number', key: 'performance' },
{ label: '社保代扣', type: 'number', key: 'socialInsurance' },
{ label: '公积金代扣', type: 'number', key: 'housingFund' },
{ label: '所得税代扣', type: 'number', key: 'tax' },
{ label: '应扣事假', type: 'number', key: 'personalLeaveDeduction' },
{ label: '应扣病假', type: 'number', key: 'sickLeaveDeduction' },
{ label: '其他扣款', type: 'number', key: 'otherDeduction' },
{ label: '备注', type: 'text', key: 'remark' },
])
const currentFormData = computed({
get: () => currentType.value === 'paper' ? formData.value : electronicFormData.value,
set: (val) => {
if (currentType.value === 'paper') formData.value = val
else electronicFormData.value = val
}
})
const currentFormFields = computed(() => currentType.value === 'paper' ? formFields.value : electronicFormFields.value)
/**
* 监听表单内各个工资款项的变化
* 自动计算总工资 (基本 + 绩效 + 各类奖金)
* 自动计算总扣款 (社保 + 个税 + 各类扣款)
* 最终计算得出实发工资 (总工资 - 总扣款)
*/
watch( watch(
() => [ () => [
formData.value.basicSalary, formData.value.basicSalary,
@ -215,10 +210,48 @@ watch(
{ immediate: true } { immediate: true }
) )
/**
* 监听电子版表单变化自动计算总工资总扣款和实发工资
*/
watch(
() => [
electronicFormData.value.standardSalary,
electronicFormData.value.allowance,
electronicFormData.value.performance,
electronicFormData.value.socialInsurance,
electronicFormData.value.housingFund,
electronicFormData.value.tax,
electronicFormData.value.personalLeaveDeduction,
electronicFormData.value.sickLeaveDeduction,
electronicFormData.value.otherDeduction
],
([std, allow, perf, soc, hf, tax, pLeave, sLeave, other]) => {
const totalP = (parseFloat(std) || 0) + (parseFloat(allow) || 0) + (parseFloat(perf) || 0)
const totalD = (parseFloat(soc) || 0) + (parseFloat(hf) || 0) + (parseFloat(tax) || 0) + (parseFloat(pLeave) || 0) + (parseFloat(sLeave) || 0) + (parseFloat(other) || 0)
// .00
electronicFormData.value.totalPayable = totalP.toFixed(2).replace(/\.00$/, '')
electronicFormData.value.totalDeduction = totalD.toFixed(2).replace(/\.00$/, '')
electronicFormData.value.netSalary = (totalP - totalD).toFixed(2).replace(/\.00$/, '')
},
{ immediate: true }
)
/**
* 随机生成工资单数据待实现
* 可用于快速填充演示数据
*/
const randomGenerate = () => { const randomGenerate = () => {
// TODO: // TODO:
} }
/**
* 保存当前工资单数据为草稿
* 1. 验证用户是否已登录未登录则拦截并打开登录弹窗
* 2. 弹窗提示用户输入作品名称若与现有作品同名则二次确认是否覆盖
* 3. 利用 html2canvas 对左侧预览区进行截图生成作品封面图
* 4. 将表单数据及封面图一并存入本地存储 (localStorage) local_drafts 列表中
* 5. 更新 URL 路由的草稿 ID 并提示保存结果
*/
const saveDraft = async () => { const saveDraft = async () => {
if (!userStore.isLoggedIn) { if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录') ElMessage.warning('请先登录')
@ -226,7 +259,7 @@ const saveDraft = async () => {
return return
} }
try { try {
const defaultTitle = originalTitle.value || '未命名工资单' const defaultTitle = originalTitle.value || '工资单'
const { value: title } = await ElMessageBox.prompt('请输入作品名称', '保存草稿', { const { value: title } = await ElMessageBox.prompt('请输入作品名称', '保存草稿', {
confirmButtonText: '保存', confirmButtonText: '保存',
cancelButtonText: '取消', cancelButtonText: '取消',
@ -264,11 +297,15 @@ const saveDraft = async () => {
} }
let coverImg = '' let coverImg = ''
const el = document.querySelector('.main-box') const el = currentType.value === 'paper' ? previewRef.value?.mainBoxRef : electronicPreviewRef.value?.mainBoxRef
if (el) { if (el) {
let widthSize = 200;
if (currentType.value === 'electronic') {
widthSize = 150;
}
try { try {
const canvas = await html2canvas(el, { const canvas = await html2canvas(el, {
scale: 300 / el.offsetWidth, // 150px scale: widthSize / el.offsetWidth, // 150px
useCORS: true useCORS: true
}) })
coverImg = canvas.toDataURL('image/jpeg', 0.6) coverImg = canvas.toDataURL('image/jpeg', 0.6)
@ -277,12 +314,16 @@ const saveDraft = async () => {
} }
} }
const saveData = currentType.value === 'paper'
? { ...formData.value, _type: 'paper' }
: { ...electronicFormData.value, _type: 'electronic' }
const payload = { const payload = {
id: draftId, id: draftId,
title: title, title: title,
type: 'gongzidan', type: 'gongzidan',
page_router: '/gongzidan', page_router: '/gongzidan',
data_json: JSON.stringify(formData.value), data_json: JSON.stringify(saveData),
cover_img: coverImg, cover_img: coverImg,
update_time: Date.now() update_time: Date.now()
} }
@ -321,6 +362,14 @@ const saveDraft = async () => {
} }
} }
/**
* 下载工资单图片
* 1. 验证用户是否已登录以及是否拥有 VIP 权限 VIP 拦截并呼出支付弹窗
* 2. 获取预览区域的 DOM 节点 (mainBoxRef)
* 3. 利用 html2canvas DOM 进行高清渲染截图 (scale: 2)
* 4. 在渲染副本 (onclone) 临时移除自带的水印节点 (watermark-overlay)
* 5. 渲染完成后转为 Blob 数据创建隐式 <a> 标签触发浏览器下载最后清理临时链接
*/
const downloadPaper = async () => { const downloadPaper = async () => {
if (!userStore.isLoggedIn) { if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录后再下载') ElMessage.warning('请先登录后再下载')
@ -333,7 +382,7 @@ const downloadPaper = async () => {
} }
const el = document.querySelector('.main-box') const el = currentType.value === 'paper' ? previewRef.value?.mainBoxRef : electronicPreviewRef.value?.mainBoxRef
if (!el) { if (!el) {
ElMessage.error('预览未就绪,请稍后重试') ElMessage.error('预览未就绪,请稍后重试')
return return
@ -405,6 +454,19 @@ const downloadPaper = async () => {
margin: 17px auto 0; margin: 17px auto 0;
width: fit-content; width: fit-content;
justify-content: center; justify-content: center;
align-items: flex-start;
.left-box {
position: sticky;
top: 176px;
z-index: 10;
}
.right-box-sticky {
position: sticky;
top: 176px;
z-index: 10;
}
.menu-box { .menu-box {
width: 200px; width: 200px;
@ -449,6 +511,7 @@ const downloadPaper = async () => {
line-height: 37px; line-height: 37px;
padding-left: 12px; padding-left: 12px;
border-radius: 8px; border-radius: 8px;
cursor: pointer;
&:hover { &:hover {
background-color: #F7F8FF; background-color: #F7F8FF;
@ -471,189 +534,5 @@ const downloadPaper = async () => {
box-shadow: 0px 0px 5px 0px rgba(0, 0, 0, 0.1); box-shadow: 0px 0px 5px 0px rgba(0, 0, 0, 0.1);
border-radius: 18px 18px 18px 18px; border-radius: 18px 18px 18px 18px;
} }
.main-box {
position: relative;
margin: 0 54px;
.main-bg {
width: 800px;
height: 600px;
object-fit: contain;
}
.watermark-overlay {
position: absolute;
top: 0;
left: 0;
width: 800px;
height: 600px;
background-image: url('@/public/common/watermaker.png');
background-size: 125px 125px;
background-repeat: repeat;
pointer-events: none;
z-index: 1;
}
.item-info {
position: absolute;
font-size: 11px;
color: #454649;
opacity: 0.9;
transform: translateX(-50%);
text-shadow: 0 0 1px #454649EE;
&.time {
font-size: 16px;
font-weight: 500;
color: #46474C;
}
}
}
.right-box {
width: 300px;
height: 600px;
&.content-box {
overflow-y: hidden;
background-color: #FFFFFF;
border-radius: 18px;
box-shadow: 0px 0px 5px 0px rgba(0, 0, 0, 0.1);
padding: 20px 16px;
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.right-title {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 16px;
line-height: 18px;
font-weight: 600;
color: #1A1A1A;
margin-bottom: 24px;
.touzi-icon {
width: 32px;
height: 32px;
}
}
.from-box {
flex: 1;
height: 100px;
overflow-y: auto;
}
.form-item {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
.label {
font-size: 14px;
color: #333333;
width: 90px;
flex-shrink: 0;
}
.switch-wrapper {
flex: 1;
display: flex;
justify-content: flex-end;
}
.random-dice {
width: 32px;
height: 32px;
border: 1px dashed #BDBDBD;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 20px;
user-select: none;
transition: all 0.2s;
&:hover {
border-color: #808AFC;
background-color: #F7F8FF;
}
}
}
:deep(.custom-input) {
flex: 1;
.el-input__wrapper {
background-color: #F5F6F9;
box-shadow: none;
border-radius: 8px;
padding: 0 12px;
height: 36px;
transition: all 0.3s;
&.is-focus {
box-shadow: 0 0 0 1px #808AFC inset;
background-color: #FFFFFF;
}
}
.el-input__inner {
color: #333333;
font-size: 14px;
&::placeholder {
color: #BDBDBD;
}
}
}
.action-buttons {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 24px;
.btn {
width: 100%;
height: 36px;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
letter-spacing: 2px;
}
.btn-outline {
background-color: transparent;
border: 1px dashed #9F82F7;
color: #9F82F7;
&:hover {
background-color: #F7F8FF;
}
}
.btn-primary {
background: linear-gradient(215deg, #A679F4 7.14%, #778CF5 100%);
border: none;
color: #FFFFFF;
box-shadow: 0px 2px 6px rgba(166, 121, 244, 0.3);
&:hover {
opacity: 0.9;
}
}
}
}
} }
</style> </style>

View File

@ -238,7 +238,7 @@ async function handleSave() {
if (el) { if (el) {
try { try {
const canvas = await html2canvas(el, { const canvas = await html2canvas(el, {
scale: 300 / el.offsetWidth, // 150px scale: 150 / el.offsetWidth, // 150px
useCORS: true, useCORS: true,
onclone: (clonedDoc) => { onclone: (clonedDoc) => {
const box = clonedDoc.querySelector('.phone-box') const box = clonedDoc.querySelector('.phone-box')

View File

@ -794,7 +794,7 @@ async function handleSave() {
if (el) { if (el) {
try { try {
const canvas = await html2canvas(el, { const canvas = await html2canvas(el, {
scale: 300 / el.offsetWidth, // scale: 150 / el.offsetWidth, //
useCORS: true, useCORS: true,
onclone: (clonedDoc) => { onclone: (clonedDoc) => {
const box = clonedDoc.querySelector('.phone-box') const box = clonedDoc.querySelector('.phone-box')