This commit is contained in:
jhd
2026-06-17 09:32:07 +08:00
52 changed files with 3006 additions and 105 deletions

View File

@@ -0,0 +1,43 @@
import request from '@/utils/request'
// ===== 审批配置 =====
export function listApprovalConfig() {
return request({ url: '/oa/approval/config/list', method: 'get' })
}
export function getApprovalConfig(businessType) {
return request({ url: '/oa/approval/config/' + businessType, method: 'get' })
}
export function saveApprovalConfig(data) {
return request({ url: '/oa/approval/config', method: 'post', data })
}
export function delApprovalConfig(id) {
return request({ url: '/oa/approval/config/' + id, method: 'delete' })
}
// ===== 我的审批 =====
export function listMyPending(query) {
return request({ url: '/oa/approval/mine/pending', method: 'get', params: query })
}
export function listMyDone(query) {
return request({ url: '/oa/approval/mine/done', method: 'get', params: query })
}
export function listMySubmitted(query) {
return request({ url: '/oa/approval/mine/submitted', method: 'get', params: query })
}
export function listAllApproval(query) {
return request({ url: '/oa/approval/list', method: 'get', params: query })
}
// ===== 操作 =====
export function actApproval(data) {
return request({ url: '/oa/approval/act', method: 'post', data })
}
export function withdrawApproval(instanceId) {
return request({ url: '/oa/approval/withdraw/' + instanceId, method: 'post' })
}
export function getApprovalDetail(instanceId) {
return request({ url: '/oa/approval/detail/' + instanceId, method: 'get' })
}
export function getLatestApproval(businessType, businessId) {
return request({ url: '/oa/approval/latest', method: 'get', params: { businessType, businessId } })
}

View File

@@ -3,8 +3,8 @@
<hamburger id="hamburger-container" :is-active="sidebar.opened" class="hamburger-container"
@toggleClick="toggleSideBar" />
<breadcrumb id="breadcrumb-container" class="breadcrumb-container" v-if="!topNav" />
<top-nav id="topmenu-container" class="topmenu-container" v-if="topNav" />
<sub-menu-bar v-if="!topNav" />
<div class="right-menu">
<template v-if="device !== 'mobile'">
@@ -51,6 +51,7 @@ import RuoYiGit from "@/components/RuoYi/Git";
import Screenfull from "@/components/Screenfull";
import SizeSelect from "@/components/SizeSelect";
import TopNav from "@/components/TopNav";
import SubMenuBar from "@/layout/components/SubMenuBar/index.vue";
import AIChat from "@/layout/components/AIChat/index.vue";
import FeedbackEntry from "@/layout/components/FeedbackEntry.vue";
import { parseTime } from "@/utils/ruoyi";
@@ -65,6 +66,7 @@ export default {
// ChatComponent,
Breadcrumb,
TopNav,
SubMenuBar,
Hamburger,
Screenfull,
SizeSelect,

View File

@@ -0,0 +1,95 @@
<template>
<div class="sub-menu-bar" v-if="items.length">
<router-link
v-for="item in items"
:key="item.path"
:to="item.path"
class="sub-menu-item"
:class="{ active: isActive(item) }"
>{{ item.title }}</router-link>
</div>
</template>
<script>
import { mapGetters } from "vuex";
export default {
name: "SubMenuBar",
computed: {
...mapGetters(["sidebarRouters"]),
activeTopPath () {
const path = this.$route.path || "";
if (!path || path === "/") return "";
const seg = path.split("/").filter(Boolean)[0];
return seg ? "/" + seg : "";
},
items () {
const top = this.findTop(this.sidebarRouters, this.activeTopPath);
if (!top) return [];
const children = (top.children || []).filter(c => !c.hidden);
return children.map(c => ({
path: this.resolvePath(top.path, c.path),
title: c.meta?.title
})).filter(it => it.title);
}
},
methods: {
findTop (routes, activePath) {
if (!routes || !activePath) return null;
for (const r of routes) {
if (r.hidden) continue;
const rp = r.path?.startsWith("/") ? r.path : "/" + r.path;
if (rp === activePath) return r;
}
return null;
},
resolvePath (parent, child) {
if (!child) return parent;
if (/^https?:\/\//.test(child)) return child;
if (child.startsWith("/")) return child;
return (parent || "").replace(/\/+$/, "") + "/" + child;
},
isActive (item) {
return this.$route.path === item.path || this.$route.path.startsWith(item.path + "/");
}
}
};
</script>
<style lang="scss" scoped>
.sub-menu-bar {
float: left;
display: flex;
align-items: center;
height: 100%;
padding-left: 12px;
overflow-x: auto;
max-width: calc(100% - 360px);
}
.sub-menu-bar::-webkit-scrollbar {
height: 0;
}
.sub-menu-item {
display: inline-flex;
align-items: center;
height: 100%;
padding: 0 16px;
font-size: 13px;
color: #5a5e66;
white-space: nowrap;
text-decoration: none;
border-bottom: 2px solid transparent;
transition: color 0.15s;
}
.sub-menu-item:hover {
color: #303133;
}
.sub-menu-item.active {
color: #409eff;
border-bottom-color: #409eff;
}
</style>

View File

@@ -4,13 +4,13 @@
<div slot="header" class="card-header">
<span>{{ bizTitle }}</span>
<div class="actions">
<el-button v-if="!preview" size="mini" icon="el-icon-arrow-left" @click="$router.back()">返回</el-button>
<el-button v-if="!isPreview" size="mini" icon="el-icon-arrow-left" @click="$router.back()">返回</el-button>
<el-button size="mini" icon="el-icon-refresh" @click="loadDetail">刷新</el-button>
<el-button v-if="!preview && canApprove" type="success" size="mini" :loading="actionLoading" @click="handleApprove">
<el-button v-if="!isPreview && canApprove" type="success" size="mini" :loading="actionLoading" @click="handleApprove">
通过
</el-button>
<el-button v-if="!preview && canApprove" type="danger" size="mini" :loading="actionLoading" @click="handleReject">
<el-button v-if="!isPreview && canApprove" type="danger" size="mini" :loading="actionLoading" @click="handleReject">
驳回
</el-button>
</div>
@@ -53,7 +53,7 @@
<ProjectInfo :info="detail" />
</el-card>
<div v-if="!preview">
<div v-if="!isPreview">
<div class="block-title">审批操作</div>
<el-card class="inner-card" shadow="never">
<div v-if="currentTask" class="btn-row">
@@ -75,7 +75,7 @@
<span>操作汇报</span>
</div>
<div v-if="!preview" class="comment-form">
<div v-if="!isPreview" class="comment-form">
<editor v-model="commentForm.commentContent" placeholder="填写操作汇报(可选)" />
<file-upload v-model="commentForm.attachments" />
<div class="form-actions">
@@ -89,7 +89,7 @@
<div class="comment-meta">
<span class="comment-operator">{{ item.createByName }}</span>
<span class="comment-time">{{ item.createTime }}</span>
<el-button v-if="!preview && isSelf(item)" type="danger" size="mini" @click="handleDeleteComment(item.commentId)"
<el-button v-if="!isPreview && isSelf(item)" type="danger" size="mini" @click="handleDeleteComment(item.commentId)"
:loading="buttonLoading">删除</el-button>
</div>
</div>
@@ -160,6 +160,9 @@ export default {
}
},
computed: {
isPreview () {
return this.preview || !!this.$route?.query?.preview
},
currentBizId () {
return this.bizId
},

View File

@@ -46,7 +46,7 @@ export default {
if (routePath) {
this.$router.push({
path: routePath,
query: { bizId: bizId }
query: { bizId: bizId, preview: 1 }
})
} else {
this.$message.warning('无法确定申请类型对应的详情页面')

View File

@@ -138,7 +138,28 @@ export default {
},
methods: {
getRowTitle (row) {
return row.title || row.remark || '-'
const t = (row.title || row.remark || '').trim()
if (t) return t
return this.summarizeBiz(row) || '-'
},
summarizeBiz (row) {
const b = row.bizData || {}
switch (row.bizType) {
case 'leave': {
const dur = b.hours ? `${b.hours}h` : ''
return [b.leaveType || '请假', dur, b.reason].filter(Boolean).join(' · ')
}
case 'travel':
return [b.travelType || '出差', b.destination, b.reason].filter(Boolean).join(' · ')
case 'seal':
return [b.sealType || '用印', b.purpose].filter(Boolean).join(' · ')
case 'reimburse':
return [b.reimburseType || '报销', b.totalAmount != null ? '¥' + b.totalAmount : null, b.reason].filter(Boolean).join(' · ')
case 'appropriation':
return [b.appropriationType || '拨款', b.amount != null ? '¥' + b.amount : null, b.reason].filter(Boolean).join(' · ')
default:
return ''
}
},
isTravelCompleted (row) {
if (!row || row.bizType !== 'travel') return false

View File

@@ -50,7 +50,6 @@
stripe
border
@row-dblclick="handleRowClick">
<el-table-column label="编号" prop="instId" width="80" />
<el-table-column label="类型" width="70">
<template slot-scope="scope">
<el-tag :type="getTypeTagType(scope.row.bizType)" size="mini">{{ getTypeText(scope.row.bizType) }}</el-tag>
@@ -80,6 +79,9 @@
<el-table-column label="申请时间" prop="createTime" width="140">
<template slot-scope="scope">{{ formatDate(scope.row.createTime) }}</template>
</el-table-column>
<el-table-column label="结束时间" width="140">
<template slot-scope="scope">{{ formatFinishTime(scope.row) }}</template>
</el-table-column>
<el-table-column label="耗时" width="80">
<template slot-scope="scope">{{ formatElapsed(scope.row) }}</template>
</el-table-column>
@@ -174,7 +176,7 @@ export default {
case 'travel':
return `${b.travelType || '出差'} · ${b.destination || '未填目的地'}${b.startTime ? ' · ' + this.formatDate(b.startTime) : ''}`
case 'seal':
return `${b.sealType || '用印'}${b.fileName ? ' · ' + b.fileName : ''}${b.useReason ? ' · ' + b.useReason : ''}`
return `${b.sealType || '用印'}${b.purpose ? ' · ' + b.purpose : ''}`
case 'reimburse':
return `${b.reimburseType || '报销'} · ¥${b.totalAmount != null ? b.totalAmount : 0}${b.reason ? ' · ' + b.reason : ''}`
case 'appropriation':
@@ -192,9 +194,19 @@ export default {
}
return '-'
},
flowEndTime (row) {
const finalStatus = ['approved', 'rejected', 'revoked', 'finished', 'complete']
if (row.bizType === 'travel' && row.actualEndTime) return row.actualEndTime
if (finalStatus.includes(row.status)) return row.updateTime || null
return null
},
formatFinishTime (row) {
const end = this.flowEndTime(row)
return end ? this.formatDate(end) : '-'
},
formatElapsed (row) {
if (!row.createTime) return '-'
const end = row.finishTime || row.endTime || Date.now()
const end = this.flowEndTime(row) || Date.now()
const ms = new Date(end).getTime() - new Date(row.createTime).getTime()
if (ms <= 0) return '-'
const h = ms / 3600000

View File

@@ -32,8 +32,8 @@
</el-card>
<!-- 盖章操作审批通过后显示 -->
<div v-if="canStamp" class="block-title">盖章操作</div>
<el-card v-if="canStamp" class="inner-card" shadow="never">
<div v-if="canStamp && !$route.query.preview" class="block-title">盖章操作</div>
<el-card v-if="canStamp && !$route.query.preview" class="inner-card" shadow="never">
<div class="stamp-section">
<div class="stamp-config">
<el-form :model="stampForm" label-width="120px" size="small">

View File

@@ -30,6 +30,23 @@
: '评估候选人,分析与目标岗位的匹配度、优势、短板与面试建议。' }}
支持 PDF / Word(.doc/.docx) 20MB
</div>
<!-- 审核重点 / 附加要求 -->
<div class="req-area">
<div class="req-line">
<span class="req-label">审核重点</span>
<el-checkbox-group v-model="checkedItems" size="mini" :disabled="streaming" class="req-items">
<el-checkbox v-for="it in itemOptions" :key="it.value" :label="it.label" border>{{ it.label }}</el-checkbox>
</el-checkbox-group>
<span class="req-tip">可在系统管理字典管理增删审核项</span>
</div>
<div class="req-line">
<span class="req-label">附加要求</span>
<el-input v-model="extraText" type="textarea" :rows="2" :disabled="streaming"
class="req-extra" maxlength="500" show-word-limit
placeholder="可补充本次审核的特殊关注点,例如:重点核查付款比例是否对我方有利、是否有自动续约陷阱…" />
</div>
</div>
</el-card>
<el-row :gutter="12" class="body">
@@ -96,6 +113,7 @@
<script>
import { getToken } from '@/utils/auth'
import { getDicts } from '@/api/system/dict/data'
const marked = require('marked')
export default {
@@ -107,6 +125,11 @@ export default {
rawFile: null,
fileName: '',
// 审核重点(字典) + 附加要求(自由文本)
itemDicts: { contract: [], resume: [] },
checkedItems: [],
extraText: '',
streaming: false,
done: false,
reasoning: '',
@@ -124,15 +147,37 @@ export default {
computed: {
renderedMd () {
try { return marked(this.content) } catch (e) { return this.content }
},
itemOptions () {
return this.itemDicts[this.reviewType] || []
},
requirements () {
const parts = []
if (this.checkedItems.length) parts.push('重点审核项:' + this.checkedItems.join('、'))
if (this.extraText && this.extraText.trim()) parts.push('其他要求:' + this.extraText.trim())
return parts.join('\n')
}
},
watch: {
// 切换类型时清空已选审核项(两套字典不同)
reviewType () { this.checkedItems = [] }
},
created () {
marked.setOptions({ breaks: true })
this.loadItemDicts()
},
beforeDestroy () {
if (this.previewUrl) URL.revokeObjectURL(this.previewUrl)
},
methods: {
loadItemDicts () {
getDicts('oa_ai_review_item_contract').then(res => {
this.itemDicts = { ...this.itemDicts, contract: (res.data || []).map(d => ({ label: d.dictLabel, value: d.dictValue })) }
})
getDicts('oa_ai_review_item_resume').then(res => {
this.itemDicts = { ...this.itemDicts, resume: (res.data || []).map(d => ({ label: d.dictLabel, value: d.dictValue })) }
})
},
goBack () { this.$router.push('/hint/aiReview') },
goDetail () { if (this.savedId) this.$router.push('/hint/aiReview/detail/' + this.savedId) },
riskTagType (r) { return r === '高' ? 'danger' : (r === '中' ? 'warning' : 'success') },
@@ -167,6 +212,7 @@ export default {
fd.append('file', this.rawFile)
fd.append('reviewType', this.reviewType)
if (this.reviewType === 'resume' && this.position) fd.append('position', this.position)
if (this.requirements) fd.append('requirements', this.requirements)
try {
const resp = await fetch(process.env.VUE_APP_BASE_API + '/oa/aiReview/analyzeStream', {
@@ -264,6 +310,18 @@ export default {
}
.bar-hint { font-size: 12px; color: #909399; margin-top: 8px; }
.req-area { margin-top: 10px; border-top: 1px dashed #ebeef5; padding-top: 8px; }
.req-line { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 8px;
&:last-child { margin-bottom: 0; }
}
.req-label { flex: 0 0 56px; font-size: 12px; color: #606266; padding-top: 5px; font-weight: 600; }
.req-items { flex: 1; display: flex; flex-wrap: wrap; gap: 6px 0;
::v-deep .el-checkbox { margin-right: 8px; margin-left: 0; }
::v-deep .el-checkbox.is-bordered { padding: 4px 10px 4px 8px; height: auto; }
}
.req-tip { flex: 0 0 auto; font-size: 11px; color: #c0c4cc; padding-top: 5px; }
.req-extra { flex: 1; }
.body { margin-top: 0; }
.panel { ::v-deep .el-card__header { padding: 9px 14px; } }
.hd { display: flex; justify-content: space-between; align-items: center;

View File

@@ -29,6 +29,9 @@
<el-descriptions-item label="模型">{{ info.model || '—' }}</el-descriptions-item>
<el-descriptions-item label="审核时间">{{ info.createTime }}</el-descriptions-item>
<el-descriptions-item label="审核人">{{ info.createBy || '—' }}</el-descriptions-item>
<el-descriptions-item v-if="info.requirements" label="审核重点 / 附加要求" :span="3">
<span style="white-space: pre-wrap">{{ info.requirements }}</span>
</el-descriptions-item>
</el-descriptions>
<div v-if="info" class="md-body" v-html="renderedMd" />

View File

@@ -0,0 +1,169 @@
<template>
<div class="app-container">
<el-alert
title="配置每个业务的审批人和或签/会签规则。审批人 user_id 多选;保存后立即生效,但只影响新提交的审批单。"
type="info" show-icon :closable="false" style="margin-bottom: 12px;" />
<div style="margin-bottom: 10px;">
<el-button type="primary" icon="el-icon-plus" size="mini" @click="handleAdd">新增业务配置</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="getList">刷新</el-button>
</div>
<el-table v-loading="loading" :data="list">
<el-table-column label="业务类型 key" prop="businessType" width="180" />
<el-table-column label="业务名称" prop="businessName" width="180" />
<el-table-column label="审批人" prop="approverNames" min-width="200" show-overflow-tooltip>
<template slot-scope="{ row }">
<el-tag v-for="(name, i) in (row.approverNames || '').split(',').filter(Boolean)"
:key="i" size="mini" style="margin-right: 4px;">{{ name }}</el-tag>
</template>
</el-table-column>
<el-table-column label="规则" prop="signType" width="100" align="center">
<template slot-scope="{ row }">
<el-tag v-if="row.signType === 1" type="success" size="mini">或签</el-tag>
<el-tag v-else type="warning" size="mini">会签</el-tag>
</template>
</el-table-column>
<el-table-column label="启用" prop="enabled" width="80" align="center">
<template slot-scope="{ row }">
<el-tag v-if="row.enabled === 1" size="mini" type="success">启用</el-tag>
<el-tag v-else size="mini" type="info">停用</el-tag>
</template>
</el-table-column>
<el-table-column label="备注" prop="remark" min-width="160" show-overflow-tooltip />
<el-table-column label="操作" width="180" align="center" fixed="right">
<template slot-scope="{ row }">
<el-button size="mini" type="text" icon="el-icon-edit" @click="handleEdit(row)">改审批人</el-button>
<el-button size="mini" type="text" icon="el-icon-delete" style="color:#f56c6c" @click="handleDel(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog :title="form.id ? '修改审批配置' : '新增审批配置'" :visible.sync="open" width="640px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="120px" size="small">
<el-form-item label="业务类型 key" prop="businessType">
<el-input v-model="form.businessType" :disabled="!!form.id" placeholder="如 purchase_req / contract" />
<div class="form-tip">代码内调用 approvalService.submit 时使用的 key建表后不要修改</div>
</el-form-item>
<el-form-item label="业务名称" prop="businessName">
<el-input v-model="form.businessName" placeholder="如 采购需求" />
</el-form-item>
<el-form-item label="审批人" prop="approverIdList">
<el-select v-model="form.approverIdList" multiple filterable
placeholder="选择一个或多个审批人" style="width: 100%">
<el-option v-for="u in userOptions" :key="u.userId"
:label="u.nickName + (u.userName ? (''+u.userName+'') : '')" :value="u.userId" />
</el-select>
</el-form-item>
<el-form-item label="规则" prop="signType">
<el-radio-group v-model="form.signType">
<el-radio :label="1">或签任一人通过即通过</el-radio>
<el-radio :label="2">会签全部通过才通过</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="启用" prop="enabled">
<el-switch v-model="form.enabled" :active-value="1" :inactive-value="0" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="open = false">取消</el-button>
<el-button type="primary" @click="submit"> </el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { listApprovalConfig, saveApprovalConfig, delApprovalConfig } from '@/api/oa/approval'
import { listUser } from '@/api/system/user'
export default {
name: 'OaApprovalConfig',
data() {
return {
loading: false,
list: [],
userOptions: [],
open: false,
form: this.emptyForm(),
rules: {
businessType: [{ required: true, message: '业务类型不能为空', trigger: 'blur' }],
businessName: [{ required: true, message: '业务名称不能为空', trigger: 'blur' }],
approverIdList: [{ required: true, type: 'array', min: 1, message: '至少选择一个审批人', trigger: 'change' }],
signType: [{ required: true, message: '请选择规则', trigger: 'change' }]
}
}
},
created() {
this.getList()
this.loadUsers()
},
methods: {
emptyForm() {
return { id: null, businessType: '', businessName: '', approverIdList: [], signType: 1, enabled: 1, remark: '' }
},
getList() {
this.loading = true
listApprovalConfig().then(res => {
this.list = res.data || []
}).finally(() => { this.loading = false })
},
loadUsers() {
listUser({ pageNum: 1, pageSize: 1000, status: '0' }).then(res => {
this.userOptions = res.rows || []
})
},
handleAdd() {
this.form = this.emptyForm()
this.open = true
},
handleEdit(row) {
const ids = (row.approverIds || '').split(',').filter(Boolean).map(s => Number(s))
this.form = {
id: row.id,
businessType: row.businessType,
businessName: row.businessName,
approverIdList: ids,
signType: row.signType,
enabled: row.enabled,
remark: row.remark
}
this.open = true
},
submit() {
this.$refs.form.validate(valid => {
if (!valid) return
const payload = {
id: this.form.id,
businessType: this.form.businessType,
businessName: this.form.businessName,
approverIds: (this.form.approverIdList || []).join(','),
signType: this.form.signType,
enabled: this.form.enabled,
remark: this.form.remark
}
saveApprovalConfig(payload).then(() => {
this.$modal.msgSuccess('保存成功')
this.open = false
this.getList()
})
})
},
handleDel(row) {
this.$modal.confirm('确认删除业务【' + row.businessName + '】的审批配置?').then(() =>
delApprovalConfig(row.id)
).then(() => {
this.$modal.msgSuccess('删除成功')
this.getList()
}).catch(() => {})
}
}
}
</script>
<style scoped>
.form-tip { color: #909399; font-size: 12px; line-height: 1.4; margin-top: 4px; }
</style>

View File

@@ -0,0 +1,168 @@
<template>
<div class="app-container">
<el-tabs v-model="tab" @tab-click="onTabClick" class="compact-tabs">
<el-tab-pane name="pending" label="待我审批" />
<el-tab-pane name="done" label="我已审批" />
</el-tabs>
<el-form :model="queryParams" size="mini" :inline="true" class="compact-search">
<el-form-item label="业务类型">
<el-select v-model="queryParams.businessType" clearable placeholder="全部" style="width: 200px;">
<el-option v-for="c in configs" :key="c.businessType"
:label="c.businessName + '(' + c.businessType + ')'" :value="c.businessType" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="reset">重置</el-button>
</el-form-item>
</el-form>
<el-table v-loading="loading" :data="list">
<el-table-column label="业务" width="160">
<template slot-scope="{ row }">
{{ row.businessName || row.businessType }}
</template>
</el-table-column>
<el-table-column label="标题" prop="businessTitle" min-width="200" show-overflow-tooltip />
<el-table-column label="申请人" prop="applyUserName" width="100" />
<el-table-column label="申请时间" prop="applyTime" width="160" />
<el-table-column label="规则" width="80" align="center">
<template slot-scope="{ row }">
<el-tag size="mini" :type="row.signType === 1 ? 'success' : 'warning'">
{{ row.signType === 1 ? '或签' : '会签' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="审批人" prop="approverNames" min-width="160" show-overflow-tooltip />
<el-table-column label="状态" width="80" align="center">
<template slot-scope="{ row }">
<el-tag size="mini" :type="statusTag(row.status)">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center" fixed="right">
<template slot-scope="{ row }">
<el-button v-if="tab === 'pending'" type="success" size="mini" @click="openAct(row, 1)">通过</el-button>
<el-button v-if="tab === 'pending'" type="danger" size="mini" @click="openAct(row, 2)">驳回</el-button>
<el-button type="text" size="mini" icon="el-icon-document" @click="openDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" :total="total"
:page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize"
@pagination="getList" />
<!-- 审批操作弹窗 -->
<el-dialog :title="actForm.action === 1 ? '审批通过' : '审批驳回'" :visible.sync="actOpen" width="500px" append-to-body>
<div style="margin-bottom: 10px;">
<b>{{ actForm.businessName }} - {{ actForm.businessTitle }}</b>
</div>
<el-input v-model="actForm.comment" type="textarea" :rows="3" placeholder="意见(可选)" />
<div slot="footer">
<el-button @click="actOpen = false">取消</el-button>
<el-button :type="actForm.action === 1 ? 'success' : 'danger'" @click="doAct">
{{ actForm.action === 1 ? '确认通过' : '确认驳回' }}
</el-button>
</div>
</el-dialog>
<!-- 详情弹窗 -->
<el-dialog title="审批详情" :visible.sync="detailOpen" width="640px" append-to-body>
<div v-if="detail">
<p><b>业务</b>{{ detail.businessName || detail.businessType }} #{{ detail.businessId }}</p>
<p><b>标题</b>{{ detail.businessTitle }}</p>
<p><b>申请人</b>{{ detail.applyUserName }}{{ detail.applyTime }}</p>
<p><b>审批人</b>{{ detail.approverNames }}{{ detail.signType === 1 ? '或签' : '会签' }}</p>
<p><b>状态</b>
<el-tag size="mini" :type="statusTag(detail.status)">{{ statusText(detail.status) }}</el-tag>
</p>
<el-divider>审批流水</el-divider>
<el-timeline>
<el-timeline-item v-for="r in (detail.records || [])" :key="r.id"
:type="r.action === 1 ? 'success' : 'danger'"
:timestamp="r.opTime">
<b>{{ r.approverName }}</b> {{ r.action === 1 ? '通过' : '驳回' }}
<div v-if="r.comment" style="color:#666;">{{ r.comment }}</div>
</el-timeline-item>
</el-timeline>
</div>
</el-dialog>
</div>
</template>
<script>
import {
listMyPending, listMyDone, listApprovalConfig,
actApproval, getApprovalDetail
} from '@/api/oa/approval'
export default {
name: 'OaApprovalPending',
data() {
return {
tab: 'pending',
loading: false,
total: 0,
list: [],
configs: [],
queryParams: { pageNum: 1, pageSize: 10, businessType: undefined },
actOpen: false,
actForm: { instanceId: null, action: 1, comment: '', businessName: '', businessTitle: '' },
detailOpen: false,
detail: null
}
},
created() {
this.getList()
listApprovalConfig().then(res => { this.configs = res.data || [] })
},
methods: {
statusText(s) {
return ({ 0: '待审', 1: '通过', 2: '驳回', 3: '撤回' })[s] || '-'
},
statusTag(s) {
return ({ 0: 'warning', 1: 'success', 2: 'danger', 3: 'info' })[s] || ''
},
onTabClick() {
this.queryParams.pageNum = 1
this.getList()
},
handleQuery() { this.queryParams.pageNum = 1; this.getList() },
reset() { this.queryParams = { pageNum: 1, pageSize: 10, businessType: undefined }; this.getList() },
getList() {
this.loading = true
const fn = this.tab === 'pending' ? listMyPending : listMyDone
fn(this.queryParams).then(res => {
this.list = res.rows || []
this.total = res.total || 0
}).finally(() => { this.loading = false })
},
openAct(row, action) {
this.actForm = {
instanceId: row.id, action, comment: '',
businessName: row.businessName || row.businessType,
businessTitle: row.businessTitle
}
this.actOpen = true
},
doAct() {
actApproval({
instanceId: this.actForm.instanceId,
action: this.actForm.action,
comment: this.actForm.comment
}).then(() => {
this.$modal.msgSuccess('已提交')
this.actOpen = false
this.getList()
})
},
openDetail(row) {
getApprovalDetail(row.id).then(res => {
this.detail = res.data
this.detailOpen = true
})
}
}
}
</script>

View File

@@ -0,0 +1,112 @@
<template>
<div class="app-container">
<el-form :model="queryParams" size="mini" :inline="true" class="compact-search">
<el-form-item label="业务类型">
<el-select v-model="queryParams.businessType" clearable placeholder="全部" style="width: 200px;">
<el-option v-for="c in configs" :key="c.businessType"
:label="c.businessName + '(' + c.businessType + ')'" :value="c.businessType" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="reset">重置</el-button>
</el-form-item>
</el-form>
<el-table v-loading="loading" :data="list">
<el-table-column label="业务" width="160">
<template slot-scope="{ row }">{{ row.businessName || row.businessType }}</template>
</el-table-column>
<el-table-column label="标题" prop="businessTitle" min-width="200" show-overflow-tooltip />
<el-table-column label="申请时间" prop="applyTime" width="160" />
<el-table-column label="审批人" prop="approverNames" min-width="160" show-overflow-tooltip />
<el-table-column label="状态" width="80" align="center">
<template slot-scope="{ row }">
<el-tag size="mini" :type="statusTag(row.status)">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="完成时间" prop="finishTime" width="160" />
<el-table-column label="操作" width="180" align="center" fixed="right">
<template slot-scope="{ row }">
<el-button v-if="row.status === 0" type="text" size="mini" style="color:#f56c6c"
@click="withdraw(row)">撤回</el-button>
<el-button type="text" size="mini" icon="el-icon-document" @click="openDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" :total="total"
:page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize"
@pagination="getList" />
<el-dialog title="审批详情" :visible.sync="detailOpen" width="640px" append-to-body>
<div v-if="detail">
<p><b>业务</b>{{ detail.businessName || detail.businessType }} #{{ detail.businessId }}</p>
<p><b>标题</b>{{ detail.businessTitle }}</p>
<p><b>申请人</b>{{ detail.applyUserName }}{{ detail.applyTime }}</p>
<p><b>审批人</b>{{ detail.approverNames }}{{ detail.signType === 1 ? '或签' : '会签' }}</p>
<p><b>状态</b>
<el-tag size="mini" :type="statusTag(detail.status)">{{ statusText(detail.status) }}</el-tag>
</p>
<el-divider>审批流水</el-divider>
<el-timeline>
<el-timeline-item v-for="r in (detail.records || [])" :key="r.id"
:type="r.action === 1 ? 'success' : 'danger'"
:timestamp="r.opTime">
<b>{{ r.approverName }}</b> {{ r.action === 1 ? '通过' : '驳回' }}
<div v-if="r.comment" style="color:#666;">{{ r.comment }}</div>
</el-timeline-item>
</el-timeline>
</div>
</el-dialog>
</div>
</template>
<script>
import {
listMySubmitted, listApprovalConfig,
withdrawApproval, getApprovalDetail
} from '@/api/oa/approval'
export default {
name: 'OaApprovalSubmitted',
data() {
return {
loading: false, total: 0, list: [], configs: [],
queryParams: { pageNum: 1, pageSize: 10, businessType: undefined },
detailOpen: false, detail: null
}
},
created() {
this.getList()
listApprovalConfig().then(res => { this.configs = res.data || [] })
},
methods: {
statusText(s) { return ({ 0: '待审', 1: '通过', 2: '驳回', 3: '撤回' })[s] || '-' },
statusTag(s) { return ({ 0: 'warning', 1: 'success', 2: 'danger', 3: 'info' })[s] || '' },
handleQuery() { this.queryParams.pageNum = 1; this.getList() },
reset() { this.queryParams = { pageNum: 1, pageSize: 10, businessType: undefined }; this.getList() },
getList() {
this.loading = true
listMySubmitted(this.queryParams).then(res => {
this.list = res.rows || []
this.total = res.total || 0
}).finally(() => { this.loading = false })
},
withdraw(row) {
this.$modal.confirm('确认撤回审批单?').then(() =>
withdrawApproval(row.id)
).then(() => {
this.$modal.msgSuccess('已撤回')
this.getList()
}).catch(() => {})
},
openDetail(row) {
getApprovalDetail(row.id).then(res => {
this.detail = res.data
this.detailOpen = true
})
}
}
}
</script>

View File

@@ -0,0 +1,278 @@
<template>
<div class="app-container docs-page">
<h1>合同流 · 使用说明</h1>
<el-alert type="info" :closable="false" show-icon
title="合同从「谁起草」到「谁批」到「正式生效」的全过程。本页让业务员、法务、领导都清楚自己在哪一步。"
style="margin-bottom: 20px;" />
<!-- ============ 泳道图 ============ -->
<el-card class="doc-card" shadow="never">
<div slot="header"><b>流程全景泳道图</b></div>
<div class="swimlane-wrap">
<svg viewBox="0 0 1180 560" class="swimlane-svg" xmlns="http://www.w3.org/2000/svg">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#606266" /></marker>
<marker id="arrow-red" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#f56c6c" /></marker>
<marker id="arrow-green" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#67c23a" /></marker>
<marker id="arrow-dash" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#909399" /></marker>
</defs>
<!-- 泳道背景 -->
<g class="lanes">
<rect x="120" y="20" width="1040" height="100" fill="#f9f6ff" stroke="#d3adf7" />
<text x="20" y="76" class="lane-label">管理员</text>
<text x="20" y="92" class="lane-sub">配置审批人</text>
<rect x="120" y="125" width="1040" height="120" fill="#eef9ff" stroke="#91d5ff" />
<text x="20" y="185" class="lane-label">起草人</text>
<text x="20" y="201" class="lane-sub">业务员 / 法务</text>
<rect x="120" y="250" width="1040" height="150" fill="#fff7e6" stroke="#ffd591" />
<text x="20" y="325" class="lane-label">系统</text>
<text x="20" y="341" class="lane-sub">自动派单 / 提醒</text>
<rect x="120" y="405" width="1040" height="135" fill="#f6ffed" stroke="#b7eb8f" />
<text x="20" y="475" class="lane-label">审批人</text>
<text x="20" y="491" class="lane-sub">领导 / 法务 / 财务</text>
</g>
<!-- ===== 管理员 ===== -->
<g class="node node-admin">
<rect x="140" y="40" width="180" height="60" rx="6" />
<text x="230" y="64" class="t1"> 配置谁来批合同</text>
<text x="230" y="82" class="t2">选定审批人</text>
<text x="230" y="96" class="t2">设置或签 / 会签</text>
</g>
<!-- ===== 起草人 ===== -->
<g class="node node-user">
<rect x="140" y="150" width="180" height="70" rx="6" />
<text x="230" y="178" class="t1"> 起草合同</text>
<text x="230" y="196" class="t2">填合同名/甲乙方/金额</text>
<text x="230" y="210" class="t2">/合同管理/新增</text>
</g>
<g class="node node-user">
<rect x="900" y="150" width="210" height="70" rx="6" />
<text x="1005" y="178" class="t1"> 合同生效执行</text>
<text x="1005" y="196" class="t2">签订 / 履约 / 归档</text>
<text x="1005" y="210" class="t2">审批通过后才发出</text>
</g>
<!-- ===== 系统 ===== -->
<g class="node node-sys">
<rect x="350" y="270" width="170" height="70" rx="6" />
<text x="435" y="296" class="t1"> 自动生成审批单</text>
<text x="435" y="314" class="t2">把当前审批人 +</text>
<text x="435" y="328" class="t2">或签/会签规则定下来</text>
</g>
<g class="node node-sys">
<rect x="560" y="270" width="160" height="70" rx="6" />
<text x="640" y="296" class="t1"> 派单给审批人</text>
<text x="640" y="314" class="t2">我的审批</text>
<text x="640" y="328" class="t2">出现待办</text>
</g>
<g class="node node-sys">
<rect x="900" y="350" width="210" height="40" rx="6" />
<text x="1005" y="376" class="t1">' 判定整单结果(或/会签)</text>
</g>
<!-- ===== 审批人 ===== -->
<g class="node node-app">
<rect x="560" y="430" width="180" height="80" rx="6" />
<text x="650" y="456" class="t1">⑤ 审批人处理</text>
<text x="650" y="474" class="t2">通过 ✓ 或 驳回 ✗</text>
<text x="650" y="490" class="t2">可写意见</text>
<text x="650" y="506" class="t2">流水自动留痕</text>
</g>
<!-- 配置 → 生成(虚线) -->
<path d="M 320 80 C 400 80, 400 240, 435 270"
fill="none" stroke="#909399" stroke-width="1.4" stroke-dasharray="5 4" marker-end="url(#arrow-dash)" />
<text x="345" y="160" class="arrow-label dim">仅影响后续新单</text>
<!-- ② → ③ -->
<path d="M 320 200 C 360 200, 360 250, 435 270"
fill="none" stroke="#606266" stroke-width="1.6" marker-end="url(#arrow)" />
<!-- ③ → ④ -->
<line x1="520" y1="305" x2="560" y2="305" stroke="#606266" stroke-width="1.6" marker-end="url(#arrow)" />
<!-- ④ → ⑤ -->
<path d="M 640 340 C 640 380, 650 405, 650 430"
fill="none" stroke="#606266" stroke-width="1.6" marker-end="url(#arrow)" />
<text x="660" y="395" class="arrow-label">收到待办</text>
<!-- ⑤ → ⑥' -->
<path d="M 740 460 C 850 460, 880 410, 950 390"
fill="none" stroke="#606266" stroke-width="1.6" marker-end="url(#arrow)" />
<!-- ' → ⑦ 通过 -->
<path d="M 1005 350 L 1005 220"
fill="none" stroke="#67c23a" stroke-width="2" marker-end="url(#arrow-green)" />
<text x="1018" y="290" class="arrow-label ok">通过</text>
<!-- ⑥' 驳回闭环 -->
<path d="M 900 370 C 700 370, 400 380, 230 240"
fill="none" stroke="#f56c6c" stroke-width="2" stroke-dasharray="6 3" marker-end="url(#arrow-red)" />
<text x="540" y="408" class="arrow-label bad">驳回 改完再提自动开新单</text>
<!-- 完成 -->
<g class="node node-end">
<circle cx="1130" cy="185" r="22" />
<text x="1130" y="190" class="t-end">归档</text>
</g>
<line x1="1110" y1="185" x2="1108" y2="185" stroke="#606266" stroke-width="1.6" marker-end="url(#arrow)" />
</svg>
</div>
<div class="legend">
<span><i class="dot dot-admin"></i>管理员动作</span>
<span><i class="dot dot-user"></i>起草人动作</span>
<span><i class="dot dot-sys"></i>系统自动</span>
<span><i class="dot dot-app"></i>审批人动作</span>
<span><svg width="34" height="10"><line x1="0" y1="5" x2="30" y2="5" stroke="#67c23a" stroke-width="2"/></svg> 通过</span>
<span><svg width="34" height="10"><line x1="0" y1="5" x2="30" y2="5" stroke="#f56c6c" stroke-width="2" stroke-dasharray="6 3"/></svg> 驳回退回</span>
</div>
</el-card>
<!-- ============ 审批状态 ============ -->
<el-card class="doc-card" shadow="never">
<div slot="header"><b>合同列表里审批列的含义</b></div>
<p class="hint">合同管理列表会显示一列审批告诉你这份合同走到流程哪一步了</p>
<el-row :gutter="20">
<el-col :span="12">
<div class="state-card">
<h4>审批<span class="state-owner">合同到底批没批</span></h4>
<div class="state-row"><el-tag size="small" type="info">未提交</el-tag><span>合同审批未启用 / 未配置合同直接生效</span></div>
<div class="state-row"><el-tag size="small" type="warning">待审</el-tag><span>已派给审批人等他们处理</span></div>
<div class="state-row"><el-tag size="small" type="success">通过</el-tag><span>合同可以正式签订 / 发出</span></div>
<div class="state-row"><el-tag size="small" type="danger">驳回</el-tag><span>修改合同内容后再保存系统会自动新开一张审批单</span></div>
<div class="state-row"><el-tag size="small" type="info">撤回</el-tag><span>起草人主动撤回本单作废</span></div>
</div>
</el-col>
<el-col :span="12">
<div class="state-card warn">
<h4> 卡点未审批通过不能标记完结</h4>
<p>合同的字段甲乙方/金额/条款等<b>不受审批拦截</b>可以照常编辑但要把合同状态推进到 <el-tag size="mini" type="success">完结</el-tag> <b>最新审批必须是通过</b>否则系统会拒绝并提示该合同尚未审批通过</p>
<p class="hint">如果整个合同审批没启用 / 未配置列表显示"未提交"则不会有任何拦截完结照常推进</p>
</div>
</el-col>
</el-row>
</el-card>
<!-- ============ 角色操作速查 ============ -->
<el-card class="doc-card" shadow="never">
<div slot="header"><b>不同角色怎么用</b></div>
<el-table :data="roleSteps" border size="small">
<el-table-column label="你是" prop="role" width="100" />
<el-table-column label="去哪儿" prop="menu" width="240" />
<el-table-column label="做什么" prop="action" />
</el-table>
</el-card>
<!-- ============ 或签 vs 会签 ============ -->
<el-card class="doc-card" shadow="never">
<div slot="header"><b>或签 vs 会签在合同场景下怎么选</b></div>
<el-row :gutter="20">
<el-col :span="12">
<div class="state-card">
<h4>或签 <el-tag size="mini" type="success">任一人即可</el-tag></h4>
<p>审批人列表里 <b>任意一人</b> 通过 整张合同审批通过</p>
<p class="hint">适用日常采购合同小额合同找最快有空的领导批即可的场景</p>
</div>
</el-col>
<el-col :span="12">
<div class="state-card">
<h4>会签 <el-tag size="mini" type="warning">必须全员通过</el-tag></h4>
<p>审批人列表里 <b>所有人</b> 都通过 整张合同审批才通过</p>
<p class="hint">适用大额合同 / 跨部门合同 / 重大法务事项 法务 + 财务 + 主管领导都要背书</p>
</div>
</el-col>
</el-row>
</el-card>
<!-- ============ FAQ ============ -->
<el-card class="doc-card" shadow="never">
<div slot="header"><b>常见问题</b></div>
<el-collapse>
<el-collapse-item title="我新建了合同,但不想走审批,怎么办?">
找管理员在审批配置里把合同这一行的启用开关关掉新建合同就不再产生审批单可以直接执行
</el-collapse-item>
<el-collapse-item title="合同已经审批通过了,但条款要改怎么办?">
目前实现不会自动重新发起审批如果改动比较大有原则性变化建议起草人在我的发起撤回旧单如果还能撤回或者重新建一份合同记录走新流程留好痕迹
</el-collapse-item>
<el-collapse-item title="审批被驳回了,合同记录还在吗?">
合同本身不会消失改完字段重新保存时系统会自动新开一张审批单流程图里那条红色虚线
</el-collapse-item>
<el-collapse-item title="谁审过、什么意见去哪看?">
起草人去审批中心 我的发起找到这张合同的审批单点详情里面有完整的审批流水什么时间通过/驳回意见审批人本人在我的审批 我已审批也能看
</el-collapse-item>
<el-collapse-item title="我换了合同审批人,老合同的审批单上人没变?">
单子生成时已经把审批人和或签/会签规则定下来了 <b>不会回溯</b>修改只对之后新提交的合同生效
</el-collapse-item>
</el-collapse>
</el-card>
</div>
</template>
<script>
export default {
name: 'OaDocContractFlow',
data() {
return {
roleSteps: [
{ role: '管理员', menu: '审批中心 → 审批配置', action: '选定合同的审批人;切换或签 / 会签;启用或停用合同审批。修改不影响已发起的旧单。' },
{ role: '起草人', menu: '合同管理 → 新增', action: '填合同信息,保存后系统自动派单给审批人;列表的「审批」列会出现「待审」。' },
{ role: '起草人', menu: '审批中心 → 我的发起', action: '查自己提交过的所有合同审批单 + 流水 + 意见;待审中可撤回。' },
{ role: '审批人', menu: '审批中心 → 我的审批', action: '「待我审批」tab 里点通过 / 驳回并写意见;「我已审批」可回看历史。' }
]
}
}
}
</script>
<style scoped>
.docs-page { max-width: 1240px; }
.docs-page h1 { font-size: 22px; margin: 0 0 16px; }
.doc-card { margin-bottom: 16px; }
.swimlane-wrap { width: 100%; overflow-x: auto; }
.swimlane-svg { width: 100%; min-width: 1100px; height: auto; display: block; }
.swimlane-svg .lane-label { font-size: 15px; font-weight: 600; fill: #303133; }
.swimlane-svg .lane-sub { font-size: 11px; fill: #909399; }
.swimlane-svg .node rect { stroke-width: 1.3; }
.swimlane-svg .node .t1 { font-size: 13px; font-weight: 600; text-anchor: middle; fill: #303133; }
.swimlane-svg .node .t2 { font-size: 11px; text-anchor: middle; fill: #606266; }
.swimlane-svg .node-admin rect { fill: #fff; stroke: #b37feb; }
.swimlane-svg .node-user rect { fill: #fff; stroke: #40a9ff; }
.swimlane-svg .node-sys rect { fill: #fff; stroke: #ffa940; }
.swimlane-svg .node-app rect { fill: #fff; stroke: #73d13d; }
.swimlane-svg .node-end circle { fill: #67c23a; stroke: #67c23a; }
.swimlane-svg .node-end .t-end { font-size: 12px; fill: #fff; font-weight: 600; text-anchor: middle; }
.swimlane-svg .arrow-label { font-size: 11px; fill: #606266; }
.swimlane-svg .arrow-label.dim { fill: #909399; }
.swimlane-svg .arrow-label.ok { fill: #67c23a; font-weight: 600; }
.swimlane-svg .arrow-label.bad { fill: #f56c6c; font-weight: 600; }
.legend { margin-top: 12px; padding: 8px 12px; background: #fafafa; border-radius: 4px;
display: flex; flex-wrap: wrap; gap: 18px; font-size: 12px; color: #606266; align-items: center; }
.legend .dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; margin-right: 6px; vertical-align: middle; }
.legend .dot-sys { background: #ffa940; }
.legend .dot-user { background: #40a9ff; }
.legend .dot-app { background: #73d13d; }
.legend .dot-admin { background: #b37feb; }
.state-card { background: #fafafa; border-radius: 6px; padding: 14px 16px; height: 100%; }
.state-card.warn { background: #fff8e6; }
.state-card h4 { margin: 0 0 10px; font-size: 14px; }
.state-card .state-owner { font-weight: normal; color: #909399; margin-left: 6px; font-size: 12px; }
.state-card .state-row { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; font-size: 13px; color: #606266; }
.state-card p { line-height: 1.7; color: #303133; margin: 6px 0; }
.state-card p.hint { color: #909399; font-size: 12px; }
.docs-page .hint { color: #909399; font-size: 13px; margin-top: 0; }
</style>

View File

@@ -0,0 +1,295 @@
<template>
<div class="app-container docs-page">
<h1>采购流 · 使用说明</h1>
<el-alert type="info" :closable="false" show-icon
title="一张图看懂:采购需求从「谁提」到「谁审」再到「采购完成」全过程。下方的状态对照和速查表帮你弄清自己当下该做什么。"
style="margin-bottom: 20px;" />
<!-- ============ 泳道图 ============ -->
<el-card class="doc-card" shadow="never">
<div slot="header"><b>流程全景泳道图</b></div>
<div class="swimlane-wrap">
<svg viewBox="0 0 1180 560" class="swimlane-svg" xmlns="http://www.w3.org/2000/svg">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#606266" />
</marker>
<marker id="arrow-red" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#f56c6c" />
</marker>
<marker id="arrow-green" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#67c23a" />
</marker>
<marker id="arrow-dash" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#909399" />
</marker>
</defs>
<!-- 泳道背景 -->
<g class="lanes">
<rect x="120" y="20" width="1040" height="100" fill="#f9f6ff" stroke="#d3adf7" />
<text x="20" y="76" class="lane-label">管理员</text>
<text x="20" y="92" class="lane-sub">配置审批人</text>
<rect x="120" y="125" width="1040" height="120" fill="#eef9ff" stroke="#91d5ff" />
<text x="20" y="185" class="lane-label">发起人</text>
<text x="20" y="201" class="lane-sub">提需求 / 推进</text>
<rect x="120" y="250" width="1040" height="150" fill="#fff7e6" stroke="#ffd591" />
<text x="20" y="325" class="lane-label">系统</text>
<text x="20" y="341" class="lane-sub">自动派单 / 提醒</text>
<rect x="120" y="405" width="1040" height="135" fill="#f6ffed" stroke="#b7eb8f" />
<text x="20" y="475" class="lane-label">审批人</text>
<text x="20" y="491" class="lane-sub">通过 / 驳回</text>
</g>
<!-- ===== 管理员 ===== -->
<g class="node node-admin">
<rect x="140" y="40" width="180" height="60" rx="6" />
<text x="230" y="64" class="t1"> 配置谁来审</text>
<text x="230" y="82" class="t2">选定审批人</text>
<text x="230" y="96" class="t2">设置或签 / 会签</text>
</g>
<!-- ===== 发起人 ===== -->
<g class="node node-user">
<rect x="140" y="150" width="170" height="70" rx="6" />
<text x="225" y="178" class="t1"> 新建采购需求</text>
<text x="225" y="196" class="t2">填标题/物料/截止</text>
<text x="225" y="210" class="t2">/任务/采购需求</text>
</g>
<g class="node node-user">
<rect x="900" y="150" width="210" height="70" rx="6" />
<text x="1005" y="178" class="t1"> 推进业务状态</text>
<text x="1005" y="196" class="t2">未采购 采购中 完成</text>
<text x="1005" y="210" class="t2">审批通过后才放开</text>
</g>
<!-- ===== 系统 ===== -->
<g class="node node-sys">
<rect x="350" y="270" width="170" height="70" rx="6" />
<text x="435" y="296" class="t1"> 自动生成审批单</text>
<text x="435" y="314" class="t2">把当前审批人 +</text>
<text x="435" y="328" class="t2">或签/会签规则定下来</text>
</g>
<g class="node node-sys">
<rect x="560" y="270" width="160" height="70" rx="6" />
<text x="640" y="296" class="t1"> 派单给审批人</text>
<text x="640" y="314" class="t2">我的审批</text>
<text x="640" y="328" class="t2">出现待办</text>
</g>
<g class="node node-sys">
<rect x="900" y="350" width="210" height="40" rx="6" />
<text x="1005" y="376" class="t1">' 判定整单结果(或/会签)</text>
</g>
<!-- ===== 审批人 ===== -->
<g class="node node-app">
<rect x="560" y="430" width="180" height="80" rx="6" />
<text x="650" y="456" class="t1">⑤ 审批人处理</text>
<text x="650" y="474" class="t2">通过 ✓ 或 驳回 ✗</text>
<text x="650" y="490" class="t2">可写意见</text>
<text x="650" y="506" class="t2">流水自动留痕</text>
</g>
<!-- ============ 箭头 ============ -->
<!-- 配置 → 审批单生成(虚线,从管理员到 ③) -->
<path d="M 320 80 C 400 80, 400 240, 435 270"
fill="none" stroke="#909399" stroke-width="1.4"
stroke-dasharray="5 4" marker-end="url(#arrow-dash)" />
<text x="345" y="160" class="arrow-label dim">仅影响后续新单</text>
<!-- ② → ③ -->
<path d="M 310 200 C 360 200, 360 250, 435 270"
fill="none" stroke="#606266" stroke-width="1.6" marker-end="url(#arrow)" />
<!-- ③ → ④ -->
<line x1="520" y1="305" x2="560" y2="305" stroke="#606266" stroke-width="1.6" marker-end="url(#arrow)" />
<!-- ④ → ⑤ -->
<path d="M 640 340 C 640 380, 650 405, 650 430"
fill="none" stroke="#606266" stroke-width="1.6" marker-end="url(#arrow)" />
<text x="660" y="395" class="arrow-label">收到待办</text>
<!-- ⑤ → ⑥' 终结判定 -->
<path d="M 740 460 C 850 460, 880 410, 950 390"
fill="none" stroke="#606266" stroke-width="1.6" marker-end="url(#arrow)" />
<!-- ' → ⑦ 通过 -->
<path d="M 1005 350 L 1005 220"
fill="none" stroke="#67c23a" stroke-width="2" marker-end="url(#arrow-green)" />
<text x="1018" y="290" class="arrow-label ok">通过</text>
<!-- ⑥' 驳回闭环 / 红色 -->
<path d="M 900 370 C 700 370, 400 380, 225 240"
fill="none" stroke="#f56c6c" stroke-width="2" stroke-dasharray="6 3"
marker-end="url(#arrow-red)" />
<text x="540" y="408" class="arrow-label bad">驳回 改完再提自动开新单</text>
<!-- 完成 -->
<g class="node node-end">
<circle cx="1130" cy="185" r="22" />
<text x="1130" y="190" class="t-end">完成</text>
</g>
<line x1="1110" y1="185" x2="1108" y2="185" stroke="#606266" stroke-width="1.6" marker-end="url(#arrow)" />
</svg>
</div>
<div class="legend">
<span><i class="dot dot-admin"></i>管理员动作</span>
<span><i class="dot dot-user"></i>发起人动作</span>
<span><i class="dot dot-sys"></i>系统自动</span>
<span><i class="dot dot-app"></i>审批人动作</span>
<span><svg width="34" height="10"><line x1="0" y1="5" x2="30" y2="5" stroke="#67c23a" stroke-width="2"/></svg> 通过</span>
<span><svg width="34" height="10"><line x1="0" y1="5" x2="30" y2="5" stroke="#f56c6c" stroke-width="2" stroke-dasharray="6 3"/></svg> 驳回退回</span>
</div>
</el-card>
<!-- ============ 状态对照 ============ -->
<el-card class="doc-card" shadow="never">
<div slot="header"><b>两类状态对照务必区分</b></div>
<p class="hint">列表里你会看到两列状态左边审批表示这条采购需求 <b>谁来批批了没</b>右边状态表示 <b>采购到哪一步了</b>两者独立但有关联审批没通过业务状态推不动</p>
<el-row :gutter="20">
<el-col :span="12">
<div class="state-card">
<h4>审批<span class="state-owner">谁来批 · 批了没</span></h4>
<div class="state-row"><el-tag size="small" type="info">未提交</el-tag><span>该业务尚未配置审批或审批未启用</span></div>
<div class="state-row"><el-tag size="small" type="warning">待审</el-tag><span>已派给审批人等他们处理</span></div>
<div class="state-row"><el-tag size="small" type="success">通过</el-tag><span>审批结束可以推进业务了</span></div>
<div class="state-row"><el-tag size="small" type="danger">驳回</el-tag><span>审批人不通过改完字段再保存会自动开新单</span></div>
<div class="state-row"><el-tag size="small" type="info">撤回</el-tag><span>发起人自己撤回了本单作废</span></div>
</div>
</el-col>
<el-col :span="12">
<div class="state-card">
<h4>状态<span class="state-owner">采购到哪一步了</span></h4>
<div class="state-row"><el-tag size="small">未采购</el-tag><span>刚新建等审批通过后才能往下推</span></div>
<div class="state-row"><el-tag size="small" type="warning">采购中</el-tag><span>开始下单 / 联系供应商</span></div>
<div class="state-row"><el-tag size="small" type="success">完成</el-tag><span>采购到货闭环结束</span></div>
<div class="state-row"><el-tag size="small" type="info">取消</el-tag><span>不再需要的需求可直接取消无需审批</span></div>
</div>
</el-col>
</el-row>
</el-card>
<!-- ============ 角色操作速查 ============ -->
<el-card class="doc-card" shadow="never">
<div slot="header"><b>不同角色怎么用按你是谁找对应行</b></div>
<el-table :data="roleSteps" border size="small">
<el-table-column label="你是" prop="role" width="100" />
<el-table-column label="去哪儿" prop="menu" width="240" />
<el-table-column label="做什么 / 看什么" prop="action" />
</el-table>
</el-card>
<!-- ============ 或签 vs 会签 ============ -->
<el-card class="doc-card" shadow="never">
<div slot="header"><b>或签 vs 会签</b></div>
<el-row :gutter="20">
<el-col :span="12">
<div class="state-card">
<h4>或签 <el-tag size="mini" type="success">任一人即可</el-tag></h4>
<p>审批人列表里 <b>任意一人</b> 点通过整张单子就通过任一人点驳回整张单子立即驳回</p>
<p class="hint">适用日常事务找最快有空的同事盖章即可</p>
</div>
</el-col>
<el-col :span="12">
<div class="state-card">
<h4>会签 <el-tag size="mini" type="warning">必须全员通过</el-tag></h4>
<p>审批人列表里 <b>所有人</b> 都点通过整张单子才通过只要有一个人驳回整张单子立即驳回</p>
<p class="hint">适用跨部门需要每个负责人都背书的事项</p>
</div>
</el-col>
</el-row>
</el-card>
<!-- ============ 常见问题 ============ -->
<el-card class="doc-card" shadow="never">
<div slot="header"><b>常见问题</b></div>
<el-collapse>
<el-collapse-item title="状态下拉点不动,提示「尚未审批通过」?">
采购需求必须审批通过才能进入采购中/完成审批中心 我的发起看看自己这单是不是还在待审或者已经被驳回
</el-collapse-item>
<el-collapse-item title="我换了审批人,为什么旧单子上的人没变?">
单子在生成那一刻就把当时的审批人定下来了<b>不会回溯</b>这是为了避免审批中途偷偷换人你的修改对后面新提交的单子生效
</el-collapse-item>
<el-collapse-item title="审批被驳回了,我的采购需求还在吗?">
需求本身不会消失你改完字段重新保存系统会自动开一张新审批单就是流程图里那条红色虚线的回流路径
</el-collapse-item>
<el-collapse-item title="我的事项太急,能不走审批吗?">
找管理员在审批配置把对应业务的启用临时关掉新提交的就直接生效不走审批事项处理完再让管理员开回来
</el-collapse-item>
<el-collapse-item title="我发起后又不想审批了,能撤回吗?">
可以审批中心 我的发起找到这张单子只要还是待审状态就能撤回已经被审批人处理过的通过/驳回不能撤回
</el-collapse-item>
<el-collapse-item title="为什么我在「我的审批」里看不到任何待办?">
说明目前没人提交需要你审的单也可能管理员没把你配进审批人列表里去问下管理员
</el-collapse-item>
</el-collapse>
</el-card>
</div>
</template>
<script>
export default {
name: 'OaDocPurchaseFlow',
data() {
return {
roleSteps: [
{ role: '管理员', menu: '审批中心 → 审批配置', action: '选定每个业务的审批人;切换或签 / 会签;启用或停用某个业务的审批。修改不影响已经发起的旧单。' },
{ role: '发起人', menu: '任务 → 采购需求', action: '点「新增」填写需求,保存后系统自动派给审批人;列表里会出现「待审」徽标。' },
{ role: '发起人', menu: '审批中心 → 我的发起', action: '看自己提交过的所有审批单 + 完整流水 + 审批意见;待审中可撤回。' },
{ role: '审批人', menu: '审批中心 → 我的审批', action: '「待我审批」tab 里点通过 / 驳回并写意见;「我已审批」可回看自己处理过的历史。' }
]
}
}
}
</script>
<style scoped>
.docs-page { max-width: 1240px; }
.docs-page h1 { font-size: 22px; margin: 0 0 16px; }
.doc-card { margin-bottom: 16px; }
.swimlane-wrap { width: 100%; overflow-x: auto; }
.swimlane-svg { width: 100%; min-width: 1100px; height: auto; display: block; }
.swimlane-svg .lane-label { font-size: 15px; font-weight: 600; fill: #303133; }
.swimlane-svg .lane-sub { font-size: 11px; fill: #909399; }
.swimlane-svg .node rect { stroke-width: 1.3; }
.swimlane-svg .node .t1 { font-size: 13px; font-weight: 600; text-anchor: middle; fill: #303133; }
.swimlane-svg .node .t2 { font-size: 11px; text-anchor: middle; fill: #606266; }
.swimlane-svg .node-admin rect { fill: #fff; stroke: #b37feb; }
.swimlane-svg .node-user rect { fill: #fff; stroke: #40a9ff; }
.swimlane-svg .node-sys rect { fill: #fff; stroke: #ffa940; }
.swimlane-svg .node-app rect { fill: #fff; stroke: #73d13d; }
.swimlane-svg .node-end circle { fill: #67c23a; stroke: #67c23a; }
.swimlane-svg .node-end .t-end { font-size: 12px; fill: #fff; font-weight: 600; text-anchor: middle; }
.swimlane-svg .arrow-label { font-size: 11px; fill: #606266; }
.swimlane-svg .arrow-label.dim { fill: #909399; }
.swimlane-svg .arrow-label.ok { fill: #67c23a; font-weight: 600; }
.swimlane-svg .arrow-label.bad { fill: #f56c6c; font-weight: 600; }
.legend {
margin-top: 12px; padding: 8px 12px; background: #fafafa; border-radius: 4px;
display: flex; flex-wrap: wrap; gap: 18px; font-size: 12px; color: #606266; align-items: center;
}
.legend .dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; margin-right: 6px; vertical-align: middle; }
.legend .dot-sys { background: #ffa940; }
.legend .dot-user { background: #40a9ff; }
.legend .dot-app { background: #73d13d; }
.legend .dot-admin { background: #b37feb; }
.state-card { background: #fafafa; border-radius: 6px; padding: 14px 16px; height: 100%; }
.state-card h4 { margin: 0 0 10px; font-size: 14px; }
.state-card .state-owner { font-weight: normal; color: #909399; margin-left: 6px; font-size: 12px; }
.state-card .state-row { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; font-size: 13px; color: #606266; }
.state-card p { line-height: 1.7; color: #303133; margin: 6px 0; }
.state-card p.hint { color: #909399; font-size: 12px; }
.docs-page .hint { color: #909399; font-size: 13px; margin-top: 0; }
</style>

View File

@@ -93,6 +93,15 @@
<span>{{ parseTime(scope.row.signTime, '{y}-{m}-{d}') }}</span>
</template>
</el-table-column>
<el-table-column label="审批" align="center" prop="approvalStatus" width="90">
<template slot-scope="{ row }">
<el-tag v-if="row.approvalStatus == null" size="mini" type="info">未提交</el-tag>
<el-tag v-else-if="row.approvalStatus === 0" size="mini" type="warning">待审</el-tag>
<el-tag v-else-if="row.approvalStatus === 1" size="mini" type="success">通过</el-tag>
<el-tag v-else-if="row.approvalStatus === 2" size="mini" type="danger">驳回</el-tag>
<el-tag v-else-if="row.approvalStatus === 3" size="mini" type="info">撤回</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" align="center" prop="contractStatus" width="120">
<template slot-scope="scope">
<dict-tag :options="dict.type.sys_show_hide" :value="scope.row.contractStatus"/>

View File

@@ -42,8 +42,8 @@
<span @click="gotoPurchase" style="cursor: pointer;">如要发放采购相关需求请移步采购需求管理(点击此处快速跳转)</span>
</el-alert>
<el-row :gutter="20" v-loading="loading">
<el-col :xs="24" :sm="12" :md="12" :lg="8" :xl="6" v-for="item in taskList" :key="item.taskId">
<el-row :gutter="10" v-loading="loading">
<el-col class="task-col" v-for="item in taskList" :key="item.taskId">
<el-card class="task-card" shadow="hover" @click.native="handleLookTask(item)">
<!-- 卡片头部 -->
<template slot="header">
@@ -99,6 +99,11 @@
</div>
<span v-else>{{ item.workerNickName }}</span>
</div>
<div class="info-item attachment-row" @click.stop>
<span class="info-label">附件</span>
<file-preview v-if="item.accessory" :value="item.accessory" class="card-attachment" />
<el-tag v-else type="info" size="mini">无附件</el-tag>
</div>
</div>
<!-- 卡片底部操作按钮 -->
@@ -352,11 +357,7 @@
附件
</template>
<div class="attachment-box">
<div v-if="form.accessory" class="attachment-item">
<el-link :href="form.accessory" :underline="false" target="_blank">
<i class="el-icon-document"></i> 查看附件
</el-link>
</div>
<file-preview v-if="form.accessory" v-model="form.accessory" />
<div v-else class="no-attachment">暂无附件...</div>
</div>
</el-descriptions-item>
@@ -374,6 +375,7 @@ import { addTask, delTask, getTask, listTask, updateTask } from "@/api/oa/task";
import { deptTreeSelect, selectUser } from "@/api/system/user";
import UserSelect from "@/components/UserSelect";
import ProjectSelect from "@/components/fad-service/ProjectSelect";
import FilePreview from "@/components/FilePreview";
import OperationLogDrawer from "@/views/oa/project/operationLog/OperationLogDrawer.vue";
@@ -382,6 +384,7 @@ export default {
components: {
UserSelect,
ProjectSelect,
FilePreview,
OperationLogDrawer
},
dicts: ['sys_project_type', 'sys_project_status', 'sys_work_type', 'sys_sort_grade'],
@@ -426,7 +429,7 @@ export default {
// 查询参数
queryParams: {
pageNum: 1,
pageSize: 10,
pageSize: 20,
searchTime: [],
},
userList: [],
@@ -765,12 +768,116 @@ export default {
</script>
<style scoped>
.task-col {
flex: 0 0 20%;
max-width: 20%;
}
.task-card {
margin-bottom: 20px;
height: 320px;
margin-bottom: 12px;
height: 300px;
/* 固定卡片高度 */
display: flex;
flex-direction: column;
font-size: 12px;
}
.task-card >>> .el-card__header {
padding: 10px 12px;
}
.task-card >>> .el-card__body {
padding: 10px 12px;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.task-card .task-title {
font-size: 13px;
}
.task-card .info-label {
font-size: 12px;
min-width: 56px;
}
.task-card .info-item {
margin-bottom: 6px;
}
.task-card .card-footer .el-button {
margin-right: 4px;
padding: 4px 2px;
font-size: 12px;
}
@media (max-width: 1366px) {
.task-col {
flex: 0 0 25%;
max-width: 25%;
}
}
@media (max-width: 992px) {
.task-col {
flex: 0 0 33.3333%;
max-width: 33.3333%;
}
}
@media (max-width: 768px) {
.task-col {
flex: 0 0 50%;
max-width: 50%;
}
}
@media (max-width: 480px) {
.task-col {
flex: 0 0 100%;
max-width: 100%;
}
}
.attachment-row {
align-items: flex-start;
}
.card-attachment {
flex: 1;
min-width: 0;
margin: 0;
}
.card-attachment >>> .el-upload-list__item {
margin: 0;
border: none;
padding: 0;
background: transparent;
line-height: 1.4;
}
.card-attachment >>> .el-upload-list__item:hover {
background: transparent;
}
.card-attachment >>> .ele-upload-list__item-content-action {
display: none;
}
.card-attachment >>> .el-upload-list__item:hover .ele-upload-list__item-content-action {
display: flex;
gap: 4px;
}
.card-attachment >>> .el-icon-document {
color: #409eff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.card-header {
@@ -817,6 +924,7 @@ export default {
padding: 10px 0;
flex: 1;
overflow-y: auto;
min-height: 0;
}
.info-item {

View File

@@ -66,40 +66,25 @@
<!-- 新增提示组件 -->
<el-alert title="提示:列表存在分页,部分信息需翻页查看" type="info" closable show-icon style="margin-bottom: 10px;" />
<el-table v-loading="loading" :data="requirementsList" @selection-change="handleSelectionChange"
@expand-change="onExpandChange">
<el-table-column type="expand" width="36">
<template slot-scope="props">
<div style="padding: 8px 24px; background:#fafafa;">
<div style="font-weight:600; margin-bottom:6px;">
已入库批次{{ (batchMap[props.row.requirementId] || []).length }}
</div>
<el-table v-loading="batchLoading[props.row.requirementId]"
:data="batchMap[props.row.requirementId] || []" size="mini" stripe>
<el-table-column label="入库时间" prop="signTime" width="160">
<template slot-scope="s">{{ parseTime(s.row.signTime, '{y}-{m}-{d} {h}:{i}') }}</template>
</el-table-column>
<el-table-column label="入库单" prop="masterNum" width="160" />
<el-table-column label="入库人" prop="signUser" width="100" />
<el-table-column label="物料概览" prop="summary" min-width="240" show-overflow-tooltip />
<el-table-column label="总数量" prop="totalQty" width="80" align="right" />
<el-table-column label="总金额" width="110" align="right">
<template slot-scope="s">¥{{ Number(s.row.totalAmount || 0).toFixed(2) }}</template>
</el-table-column>
</el-table>
<div v-if="(batchMap[props.row.requirementId] || []).length === 0
&& !batchLoading[props.row.requirementId]"
style="text-align:center; color:#909399; padding:12px 0;">
暂无入库批次
</div>
</div>
</template>
</el-table-column>
<el-table v-loading="loading" :data="requirementsList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="需求标题" align="center" prop="title" min-width="160" show-overflow-tooltip />
<el-table-column label="需求方" align="center" prop="requesterNickName" width="100" show-overflow-tooltip />
<el-table-column label="负责人" align="center" prop="ownerNickName" width="100" show-overflow-tooltip />
<el-table-column label="关联项目" align="center" prop="projectName" min-width="160" show-overflow-tooltip />
<el-table-column label="采购物料" align="left" min-width="240">
<template slot-scope="{ row }">
<template v-if="row.materials && row.materials.length">
<div v-for="m in row.materials" :key="m.id" class="mat-row">
<span class="mat-name">{{ m.name }}<span v-if="m.model" class="mat-model"> / {{ m.model }}</span></span>
<span class="mat-stock" :class="{ low: (m.inventory||0) <= 0 }">
库存 {{ m.inventory == null ? 0 : m.inventory }}{{ m.unit || '' }}
</span>
</div>
</template>
<span v-else style="color:#c0c4cc;">未关联</span>
</template>
</el-table-column>
<el-table-column label="需求描述" align="center" prop="description" min-width="200" show-overflow-tooltip>
<template slot-scope="{ row }">
<span v-if="row.description" class="copyable-text" @click="copyText(row.description)"
@@ -129,6 +114,15 @@
</template>
</el-table-column>
<!-- 表格中的状态列替换原有列 -->
<el-table-column label="审批" align="center" prop="approvalStatus" width="90">
<template slot-scope="{ row }">
<el-tag v-if="row.approvalStatus == null" size="mini" type="info">未提交</el-tag>
<el-tag v-else-if="row.approvalStatus === 0" size="mini" type="warning">待审</el-tag>
<el-tag v-else-if="row.approvalStatus === 1" size="mini" type="success">通过</el-tag>
<el-tag v-else-if="row.approvalStatus === 2" size="mini" type="danger">驳回</el-tag>
<el-tag v-else-if="row.approvalStatus === 3" size="mini" type="info">撤回</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" align="center" prop="status" width="160">
<template slot-scope="{ row }">
<el-select v-model="row.status" size="mini" placeholder="选择状态"
@@ -153,9 +147,6 @@
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<template slot-scope="scope">
<el-button size="mini" type="text" icon="el-icon-truck" style="color:#409eff"
v-if="scope.row.status !== 2 && scope.row.status !== 3"
@click="handleGoToInbound(scope.row)">执行入库</el-button>
<el-button size="mini" type="text" icon="el-icon-check" @click="handleComplete(scope.row)"
v-if="scope.row.status === 1">完成</el-button>
<el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)"
@@ -184,6 +175,25 @@
<el-form-item label="关联项目" prop="projectId">
<project-select v-model="form.projectId" style="width: 100%" />
</el-form-item>
<el-form-item label="采购物料" prop="materialIdArr">
<div class="mat-trigger">
<el-button size="mini" icon="el-icon-search" @click="openMaterialPicker">
选择物料{{ selectedMaterials.length ? `(已选 ${selectedMaterials.length}` : '' }}
</el-button>
<span v-if="!selectedMaterials.length" class="mat-trigger-hint">可多选;留空也可保存,后续再关联</span>
</div>
<div v-if="selectedMaterials.length" class="mat-stock-panel">
<div v-for="m in selectedMaterials" :key="m.id" class="mat-stock-row">
<span class="mat-stock-name">{{ m.name }}<span v-if="m.model" style="color:#909399;"> / {{ m.model }}</span></span>
<span class="mat-stock-tag" :class="{ low: (m.inventory||0) <= 0 }">
库存 {{ m.inventory == null ? 0 : m.inventory }}{{ m.unit || '' }}
</span>
<span v-if="m.brand" class="mat-stock-meta">品牌:{{ m.brand }}</span>
<span v-if="m.specifications" class="mat-stock-meta">规格:{{ m.specifications }}</span>
<i class="el-icon-close mat-remove" title="移除" @click="removeSelectedMaterial(m.id)"></i>
</div>
</div>
</el-form-item>
<el-form-item label="需求描述" prop="description">
<el-input v-model="form.description" type="textarea" placeholder="请输入需求描述" />
</el-form-item>
@@ -205,6 +215,84 @@
</div>
</el-dialog>
<!-- 物料选择器 -->
<el-dialog title="选择采购物料" :visible.sync="materialPickerOpen" width="780px"
append-to-body :close-on-click-modal="false" @open="onPickerOpen">
<div class="picker-toolbar">
<el-input v-model="picker.kw" placeholder="搜索 名称 / 型号 / 品牌 / 规格"
size="mini" clearable prefix-icon="el-icon-search"
style="width: 320px;" @input="onPickerSearch" @clear="onPickerSearch" />
<el-button size="mini" type="primary" plain icon="el-icon-plus"
style="margin-left:8px;" @click="openInlineNew">未找到?新增物料</el-button>
<span class="picker-tip" v-if="picker.tempSelected.length">
已勾选 <b>{{ picker.tempSelected.length }}</b> 项
</span>
</div>
<!-- 内嵌新增物料表单 -->
<el-card v-if="newMat.show" shadow="never" class="new-mat-card">
<div slot="header" class="new-mat-header">
<span><i class="el-icon-plus" /> 新增物料到库存</span>
<el-button type="text" size="mini" icon="el-icon-close" @click="newMat.show = false">收起</el-button>
</div>
<el-form :model="newMat" :rules="newMatRules" ref="newMatForm" size="mini"
label-width="70px" :inline="true">
<el-form-item label="名称" prop="name" required>
<el-input v-model="newMat.name" placeholder="物料名称" style="width:180px;" />
</el-form-item>
<el-form-item label="型号" prop="model">
<el-input v-model="newMat.model" placeholder="可选" style="width:140px;" />
</el-form-item>
<el-form-item label="规格" prop="specifications">
<el-input v-model="newMat.specifications" placeholder="可选" style="width:140px;" />
</el-form-item>
<el-form-item label="品牌" prop="brand">
<el-input v-model="newMat.brand" placeholder="可选" style="width:120px;" />
</el-form-item>
<el-form-item label="单位" prop="unit">
<el-input v-model="newMat.unit" placeholder="//kg" style="width:100px;" />
</el-form-item>
<el-form-item label="初始库存" prop="inventory" required>
<el-input-number v-model="newMat.inventory" :min="0" :step="1" size="mini" />
</el-form-item>
<el-form-item>
<el-button size="mini" type="primary" :loading="newMat.saving" @click="submitNewMat">保存并选中</el-button>
<el-button size="mini" @click="newMat.show = false">取消</el-button>
</el-form-item>
</el-form>
</el-card>
<el-table :data="picker.list" v-loading="picker.loading" size="mini"
ref="pickerTable" border highlight-current-row max-height="380"
row-key="id" :row-class-name="pickerRowClass"
@row-click="onPickerRowClick"
@selection-change="onPickerSelectionChange">
<el-table-column type="selection" width="42" reserve-selection />
<el-table-column label="名称" prop="name" min-width="140" show-overflow-tooltip />
<el-table-column label="型号" prop="model" width="120" show-overflow-tooltip />
<el-table-column label="规格" prop="specifications" width="110" show-overflow-tooltip />
<el-table-column label="品牌" prop="brand" width="100" show-overflow-tooltip />
<el-table-column label="单位" prop="unit" width="60" align="center" />
<el-table-column label="库存" width="90" align="right">
<template slot-scope="s">
<span :style="{color: (s.row.inventory||0) <= 0 ? '#f56c6c' : '#67c23a', fontWeight:600}">
{{ s.row.inventory == null ? 0 : s.row.inventory }}
</span>
</template>
</el-table-column>
</el-table>
<div class="picker-pager" v-if="picker.total > picker.pageSize">
<el-pagination small background layout="prev, pager, next, total"
:total="picker.total" :page-size="picker.pageSize" :current-page.sync="picker.pageNum"
@current-change="loadPickerList" />
</div>
<div slot="footer">
<el-button @click="materialPickerOpen = false">取 消</el-button>
<el-button type="primary" @click="confirmPicker">确定({{ picker.tempSelected.length }}</el-button>
</div>
</el-dialog>
<!-- 需求详情对话框 -->
<el-dialog title="需求详情" :visible.sync="detailDialog" width="600px" append-to-body>
<el-descriptions :column="1" border>
@@ -233,8 +321,9 @@
</template>
<script>
import { addRequirements, delRequirements, getRequirements, getRequirementBatches, listRequirements, updateRequirements } from "@/api/oa/requirement";
import { addRequirements, delRequirements, getRequirements, listRequirements, updateRequirements } from "@/api/oa/requirement";
import { listUser } from "@/api/system/user";
import { listOaWarehouse, getOaWarehouse, addOaWarehouse } from "@/api/oa/warehouse/oaWarehouse";
import FilePreview from '@/components/FilePreview';
import FileUpload from '@/components/FileUpload';
import ProjectSelect from "@/components/fad-service/ProjectSelect";
@@ -244,9 +333,35 @@ export default {
components: { FileUpload, FilePreview, ProjectSelect },
data () {
return {
// 入库批次(按 requirementId 缓存
batchMap: {},
batchLoading: {},
// 当前已选物料明细(用于库存展示
selectedMaterials: [],
// 物料选择器
materialPickerOpen: false,
picker: {
kw: '',
loading: false,
list: [],
total: 0,
pageNum: 1,
pageSize: 10,
tempSelected: [], // 选择器内部勾选项(含完整物料对象)
searchTimer: null,
},
// 新增物料内嵌表单
newMat: {
show: false,
saving: false,
name: '',
model: '',
specifications: '',
brand: '',
unit: '',
inventory: 0,
},
newMatRules: {
name: [{ required: true, message: '物料名称不能为空', trigger: 'blur' }],
inventory: [{ required: true, message: '初始库存不能为空', trigger: 'change' }],
},
// 按钮loading
buttonLoading: false,
// 遮罩层
@@ -324,11 +439,120 @@ export default {
this.refreshStat();
},
methods: {
// 跳到入库明细页面,并预填该采购需求
handleGoToInbound (row) {
this.$router.push({
path: '/step/in',
query: { requirementId: String(row.requirementId), requirementTitle: row.title }
// ====== 物料选择器 ======
openMaterialPicker () {
this.picker.kw = ''
this.picker.pageNum = 1
// 当前已选项作为初始勾选
this.picker.tempSelected = this.selectedMaterials.slice()
this.newMat.show = false
this.materialPickerOpen = true
},
onPickerOpen () {
this.loadPickerList().then(() => this.syncPickerSelectionUI())
},
loadPickerList () {
this.picker.loading = true
const params = {
pageNum: this.picker.pageNum,
pageSize: this.picker.pageSize,
}
if (this.picker.kw && this.picker.kw.trim()) params.name = this.picker.kw.trim()
return listOaWarehouse(params).then(res => {
this.picker.list = res.rows || []
this.picker.total = res.total || 0
this.$nextTick(() => this.syncPickerSelectionUI())
}).finally(() => { this.picker.loading = false })
},
onPickerSearch () {
clearTimeout(this.picker.searchTimer)
this.picker.searchTimer = setTimeout(() => {
this.picker.pageNum = 1
this.loadPickerList()
}, 250)
},
// 表格分页/刷新后把已勾选项重新打勾
syncPickerSelectionUI () {
const tbl = this.$refs.pickerTable
if (!tbl) return
tbl.clearSelection()
const ids = new Set(this.picker.tempSelected.map(m => m.id))
for (const row of this.picker.list) {
if (ids.has(row.id)) tbl.toggleRowSelection(row, true)
}
},
onPickerSelectionChange (rows) {
// 当前页勾选 + 之前其它页保留下来的(不在 list 中的)
const curIds = new Set(this.picker.list.map(m => m.id))
const keepOther = this.picker.tempSelected.filter(m => !curIds.has(m.id))
this.picker.tempSelected = keepOther.concat(rows)
},
onPickerRowClick (row) {
const tbl = this.$refs.pickerTable
if (!tbl) return
const idx = this.picker.tempSelected.findIndex(m => m.id === row.id)
tbl.toggleRowSelection(row, idx === -1)
},
pickerRowClass ({ row }) {
return this.picker.tempSelected.find(m => m.id === row.id) ? 'picker-row-checked' : ''
},
confirmPicker () {
this.selectedMaterials = this.picker.tempSelected.slice()
this.form.materialIdArr = this.selectedMaterials.map(m => m.id)
this.materialPickerOpen = false
},
removeSelectedMaterial (id) {
this.selectedMaterials = this.selectedMaterials.filter(m => m.id !== id)
this.form.materialIdArr = this.selectedMaterials.map(m => m.id)
},
// ====== 新增物料 ======
openInlineNew () {
this.newMat.show = true
this.newMat.name = this.picker.kw || ''
this.newMat.model = ''
this.newMat.specifications = ''
this.newMat.brand = ''
this.newMat.unit = ''
this.newMat.inventory = 0
this.$nextTick(() => { this.$refs.newMatForm && this.$refs.newMatForm.clearValidate() })
},
submitNewMat () {
this.$refs.newMatForm.validate(valid => {
if (!valid) return
this.newMat.saving = true
const payload = {
name: this.newMat.name.trim(),
model: this.newMat.model || undefined,
specifications: this.newMat.specifications || undefined,
brand: this.newMat.brand || undefined,
unit: this.newMat.unit || undefined,
inventory: Number(this.newMat.inventory) || 0,
}
addOaWarehouse(payload).then(res => {
// 后端返回的 data 可能是 id 或对象,统一兜底再 get 一次
const newId = (res && (res.data && (res.data.id || typeof res.data === 'number'))) ? (res.data.id || res.data) : null
const finish = (full) => {
if (!full) return
// 直接放进当前页顶端 + 勾选
if (!this.picker.list.find(m => m.id === full.id)) {
this.picker.list.unshift(full)
}
if (!this.picker.tempSelected.find(m => m.id === full.id)) {
this.picker.tempSelected.push(full)
}
this.syncPickerSelectionUI()
this.newMat.show = false
this.$modal.msgSuccess('已新增并自动选中')
}
if (newId) {
getOaWarehouse(newId).then(r => finish(r.data))
} else {
// 兜底:用刚提交的字段拼一个临时对象
finish({ ...payload, id: Date.now() })
// 重新查一下保证 id 真实
this.loadPickerList()
}
}).finally(() => { this.newMat.saving = false })
})
},
// 后端已联查 sys_oss 拼好字符串 "ossId|name|url,,ossId|name|url"
@@ -368,18 +592,6 @@ export default {
if (file && file.url) window.open(file.url, '_blank')
}
},
// 展开行:加载该需求的入库批次
onExpandChange (row, expanded) {
if (!expanded || !expanded.length) return
const id = row.requirementId
if (this.batchMap[id]) return // 已有缓存
this.$set(this.batchLoading, id, true)
getRequirementBatches(id).then(res => {
this.$set(this.batchMap, id, res.data || [])
}).finally(() => {
this.$set(this.batchLoading, id, false)
})
},
async onStatusChange (row, newVal) {
row._updating = true
// 如果后端需要字符串,可改为 String(newVal)
@@ -453,6 +665,8 @@ export default {
requesterId: undefined,
ownerId: undefined,
projectId: undefined,
materialIds: undefined,
materialIdArr: [],
description: undefined,
deadline: undefined,
status: 0,
@@ -488,6 +702,7 @@ export default {
handleAdd () {
this.reset();
this.form.requesterId = this.$store.state.user.id;
this.selectedMaterials = [];
this.open = true;
this.title = "添加OA 需求";
},
@@ -504,10 +719,21 @@ export default {
handleUpdate (row) {
this.loading = true;
this.reset();
this.selectedMaterials = [];
const requirementId = row.requirementId || this.ids
getRequirements(requirementId).then(response => {
this.loading = false;
this.form = response.data;
const data = response.data || {};
const ids = (data.materialIds || '').split(',').map(s => Number(s)).filter(n => !isNaN(n) && n)
data.materialIdArr = ids
this.form = data;
if (data.materials && data.materials.length) {
this.selectedMaterials = data.materials.slice()
} else if (ids.length) {
// 兜底:逐个拉
Promise.all(ids.map(id => getOaWarehouse(id).then(r => r.data).catch(() => null)))
.then(list => { this.selectedMaterials = list.filter(Boolean) })
}
this.open = true;
this.title = "修改OA 需求";
});
@@ -517,6 +743,8 @@ export default {
this.$refs["form"].validate(valid => {
if (valid) {
this.buttonLoading = true;
const arr = Array.isArray(this.form.materialIdArr) ? this.form.materialIdArr : []
this.form.materialIds = arr.length ? arr.join(',') : null
if (this.form.requirementId != null) {
updateRequirements(this.form).then(response => {
this.$modal.msgSuccess("修改成功");
@@ -652,6 +880,67 @@ export default {
cursor: pointer;
&:hover { color: #409eff; text-decoration: underline; }
}
.mat-trigger {
display: flex; align-items: center; gap: 8px;
.mat-trigger-hint { font-size: 12px; color: #909399; }
}
.mat-remove {
margin-left: auto;
cursor: pointer;
color: #c0c4cc;
&:hover { color: #f56c6c; }
}
.picker-toolbar {
display: flex; align-items: center; margin-bottom: 10px;
.picker-tip { margin-left: auto; font-size: 12px; color: #606266; }
}
.picker-pager { text-align: right; margin-top: 10px; }
.new-mat-card {
margin-bottom: 10px;
::v-deep .el-card__header { padding: 6px 12px; background: #f5f7fa; }
.new-mat-header {
display: flex; align-items: center; justify-content: space-between;
font-size: 13px; color: #303133;
}
}
::v-deep .picker-row-checked > td { background: #ecf5ff !important; }
.mat-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
line-height: 1.6;
& + .mat-row { border-top: 1px dashed #ebeef5; }
.mat-name { flex: 1; color: #303133; }
.mat-model { color: #909399; margin-left: 2px; }
.mat-stock {
flex-shrink: 0;
padding: 0 6px;
border-radius: 3px;
background: #f0f9eb; color: #67c23a;
&.low { background: #fef0f0; color: #f56c6c; }
}
}
.mat-stock-panel {
margin-top: 4px;
padding: 6px 8px;
background: #fafafa;
border-radius: 3px;
.mat-stock-row {
display: flex; flex-wrap: wrap; gap: 6px;
align-items: center;
font-size: 12px;
line-height: 1.8;
& + .mat-stock-row { border-top: 1px dashed #ebeef5; }
}
.mat-stock-name { flex: 1; min-width: 120px; color: #303133; }
.mat-stock-tag {
padding: 0 6px; border-radius: 3px;
background: #f0f9eb; color: #67c23a;
&.low { background: #fef0f0; color: #f56c6c; }
}
.mat-stock-meta { color: #909399; }
}
.accessory-link {
display: inline-block;
max-width: 160px;