This commit is contained in:
jhd
2026-06-12 13:55:39 +08:00
41 changed files with 4238 additions and 0 deletions

View File

@@ -0,0 +1,28 @@
import request from '@/utils/request'
/**
* 上传合同/简历进行 AI 审核
* @param {FormData} data 包含 file, reviewType(contract|resume), position(可选)
*/
export function analyzeAiReview (data) {
return request({
url: '/oa/aiReview/analyze',
method: 'post',
data,
headers: { 'Content-Type': 'multipart/form-data' },
// 推理模型 + 长文档,单次可能较慢,放宽到 5 分钟
timeout: 300000
})
}
export function listAiReview (query) {
return request({ url: '/oa/aiReview/list', method: 'get', params: query })
}
export function getAiReview (id) {
return request({ url: '/oa/aiReview/' + id, method: 'get' })
}
export function delAiReview (ids) {
return request({ url: '/oa/aiReview/' + ids, method: 'delete' })
}

View File

@@ -0,0 +1,44 @@
import request from '@/utils/request'
// 查询到货明细列表
export function listArrivalDetail(query) {
return request({
url: '/oa/arrivalDetail/list',
method: 'get',
params: query
})
}
// 查询到货明细详细
export function getArrivalDetail(detailId) {
return request({
url: '/oa/arrivalDetail/' + detailId,
method: 'get'
})
}
// 新增到货明细
export function addArrivalDetail(data) {
return request({
url: '/oa/arrivalDetail',
method: 'post',
data: data
})
}
// 修改到货明细
export function updateArrivalDetail(data) {
return request({
url: '/oa/arrivalDetail',
method: 'put',
data: data
})
}
// 删除到货明细
export function delArrivalDetail(detailId) {
return request({
url: '/oa/arrivalDetail/' + detailId,
method: 'delete'
})
}

View File

@@ -0,0 +1,21 @@
import request from '@/utils/request'
export function listMeetingMinutes (query) {
return request({ url: '/oa/meetingMinutes/list', method: 'get', params: query })
}
export function getMeetingMinutes (id) {
return request({ url: '/oa/meetingMinutes/' + id, method: 'get' })
}
export function addMeetingMinutes (data) {
return request({ url: '/oa/meetingMinutes', method: 'post', data })
}
export function updateMeetingMinutes (data) {
return request({ url: '/oa/meetingMinutes', method: 'put', data })
}
export function delMeetingMinutes (ids) {
return request({ url: '/oa/meetingMinutes/' + ids, method: 'delete' })
}

View File

@@ -65,6 +65,11 @@ body {
.el-range-editor .el-range-separator { line-height: 24px !important; font-size: 12px !important; }
.el-input__icon { line-height: 26px !important; }
.el-input__suffix-inner .el-input__icon { line-height: 26px !important; }
/* 上面的 padding: 0 8px !important 会盖掉 element 给带图标输入框预留的 30px
导致占位文字/内容压在前后缀图标上(如日期选择器、带搜索图标的输入框),这里补回图标位 */
.el-input--prefix .el-input__inner { padding-left: 28px !important; }
.el-input--suffix .el-input__inner { padding-right: 28px !important; }
.el-input--mini .el-input__icon { line-height: 22px !important; }
.el-form-item__content { line-height: 26px; }
/* 按钮:默认 26px 高mini 22pxmedium 28px */

View File

@@ -160,6 +160,37 @@ export const constantRoutes = [
},
],
},
{
path: "/hint",
component: Layout,
hidden: true,
children: [
{
path: "meeting/add",
component: () => import("@/views/oa/meeting/edit"),
name: "addMeetingMinutes",
meta: { title: "新增会议纪要", activeMenu: "/hint/meeting" },
},
{
path: "meeting/edit/:id(\\d+)",
component: () => import("@/views/oa/meeting/edit"),
name: "editMeetingMinutes",
meta: { title: "编辑会议纪要", activeMenu: "/hint/meeting" },
},
{
path: "aiReview/add",
component: () => import("@/views/oa/aiReview/add"),
name: "aiReviewAdd",
meta: { title: "新增审核", activeMenu: "/hint/aiReview" },
},
{
path: "aiReview/detail/:id(\\d+)",
component: () => import("@/views/oa/aiReview/detail"),
name: "aiReviewDetail",
meta: { title: "审核详情", activeMenu: "/hint/aiReview" },
},
],
},
{
path: "/claim",
component: Layout,

View File

@@ -0,0 +1,312 @@
<template>
<div class="app-container ai-review-add">
<!-- 顶部操作栏 -->
<el-card shadow="never" class="topbar">
<div class="bar">
<div class="left">
<el-button size="small" icon="el-icon-back" @click="goBack">返回列表</el-button>
<span class="title"><i class="el-icon-magic-stick" /> 新增 AI 审核</span>
</div>
<div class="right">
<el-radio-group v-model="reviewType" size="small" :disabled="streaming">
<el-radio-button label="contract">合同审核</el-radio-button>
<el-radio-button label="resume">简历审核</el-radio-button>
</el-radio-group>
<el-input v-if="reviewType === 'resume'" v-model="position" size="small"
placeholder="目标岗位(选填)" clearable :disabled="streaming" style="width: 200px" />
<el-upload action="#" :auto-upload="false" :show-file-list="false" :limit="1"
:on-change="onFileChange" accept=".pdf,.doc,.docx" :disabled="streaming">
<el-button size="small" icon="el-icon-paperclip" :disabled="streaming">
{{ fileName || '选择文件' }}
</el-button>
</el-upload>
<el-button size="small" type="primary" icon="el-icon-cpu" :loading="streaming"
@click="start">{{ streaming ? '审核中' : '开始审核' }}</el-button>
</div>
</div>
<div class="bar-hint">
{{ reviewType === 'contract'
? '从“我方”利益角度审查合同,找出不利条款并给出利好我方的修改/补充建议。'
: '评估候选人,分析与目标岗位的匹配度、优势、短板与面试建议。' }}
支持 PDF / Word(.doc/.docx) 20MB
</div>
</el-card>
<el-row :gutter="12" class="body">
<!-- 流式输出 -->
<el-col :span="12" :xs="24">
<el-card shadow="never" class="panel out-panel">
<div slot="header" class="hd">
<span><i class="el-icon-document" /> 审核结果</span>
<div class="hd-tags">
<span v-if="streaming" class="streaming-dot"> 实时生成中</span>
<el-tag v-if="done && reviewType === 'resume' && matchScore != null" size="mini"
type="success" effect="dark">匹配度 {{ matchScore }}</el-tag>
<el-tag v-if="done && reviewType === 'contract' && riskLevel" size="mini"
:type="riskTagType(riskLevel)" effect="dark">{{ riskLevel }}风险</el-tag>
<el-button v-if="done && savedId" type="text" size="mini" icon="el-icon-view"
@click="goDetail">查看详情</el-button>
</div>
</div>
<div ref="outBody" class="out-body">
<div v-if="!streaming && !content && !reasoning" class="placeholder">
<i class="el-icon-cpu" />
<div>选择文件后点击开始审核结果将实时流式输出</div>
</div>
<!-- 思考过程 -->
<div v-if="reasoning" class="reasoning">
<div class="reasoning-hd" @click="showReasoning = !showReasoning">
<i :class="showReasoning ? 'el-icon-arrow-down' : 'el-icon-arrow-right'" />
<i class="el-icon-loading" v-if="streaming && !content" />
思考过程
</div>
<pre v-show="showReasoning" class="reasoning-body">{{ reasoning }}</pre>
</div>
<!-- 正文markdown 实时渲染 -->
<div v-if="content" class="md-body" v-html="renderedMd" />
<div v-else-if="streaming && reasoning" class="thinking-tip">AI 正在分析文档马上输出结论</div>
</div>
</el-card>
</el-col>
<!-- 文档预览 -->
<el-col :span="12" :xs="24">
<el-card shadow="never" class="panel preview-panel">
<div slot="header" class="hd"><span><i class="el-icon-view" /> 文档预览</span></div>
<div class="preview-body">
<iframe v-if="previewUrl && isPdf" :src="previewUrl" class="pdf-frame" />
<div v-else-if="previewUrl && !isPdf" class="preview-placeholder">
<i class="el-icon-document" />
<div>{{ fileName }}</div>
<div class="sub">Word 文档暂不支持在线预览审核结果见左侧</div>
</div>
<div v-else class="preview-placeholder">
<i class="el-icon-picture-outline" />
<div>选择 PDF 文件可在此预览</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script>
import { getToken } from '@/utils/auth'
const marked = require('marked')
export default {
name: 'OaAiReviewAdd',
data () {
return {
reviewType: 'contract',
position: '',
rawFile: null,
fileName: '',
streaming: false,
done: false,
reasoning: '',
content: '',
showReasoning: true,
savedId: null,
matchScore: null,
riskLevel: null,
eventCount: 0,
previewUrl: '',
isPdf: false
}
},
computed: {
renderedMd () {
try { return marked(this.content) } catch (e) { return this.content }
}
},
created () {
marked.setOptions({ breaks: true })
},
beforeDestroy () {
if (this.previewUrl) URL.revokeObjectURL(this.previewUrl)
},
methods: {
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') },
onFileChange (file) {
if (!/\.(pdf|doc|docx)$/i.test(file.name)) {
return this.$modal.msgError('仅支持 PDF / Word(.doc/.docx)')
}
if (file.size > 20 * 1024 * 1024) {
return this.$modal.msgError('文件不能超过 20MB')
}
this.rawFile = file.raw
this.fileName = file.name
// 立即生成本地预览
if (this.previewUrl) URL.revokeObjectURL(this.previewUrl)
this.previewUrl = URL.createObjectURL(file.raw)
this.isPdf = /\.pdf$/i.test(file.name)
},
async start () {
if (!this.rawFile) return this.$modal.msgError('请先选择文件')
this.reasoning = ''
this.content = ''
this.done = false
this.savedId = null
this.matchScore = null
this.riskLevel = null
this.showReasoning = true
this.streaming = true
const fd = new FormData()
fd.append('file', this.rawFile)
fd.append('reviewType', this.reviewType)
if (this.reviewType === 'resume' && this.position) fd.append('position', this.position)
try {
const resp = await fetch(process.env.VUE_APP_BASE_API + '/oa/aiReview/analyzeStream', {
method: 'POST',
headers: { Authorization: 'Bearer ' + getToken(), Accept: 'text/event-stream' },
body: fd
})
// 非流式响应(鉴权失败 / 后端异常被包成 JSON / HTML→ 读出来报错,避免静默
const ct = resp.headers.get('content-type') || ''
if (!resp.ok || !resp.body || ct.indexOf('text/event-stream') === -1) {
let msg = '审核失败HTTP ' + resp.status + ''
try {
const txt = await resp.text()
try { const j = JSON.parse(txt); if (j && (j.msg || j.message)) msg = j.msg || j.message }
catch (e) { if (txt) msg = txt.slice(0, 200) }
} catch (e) {}
this.$modal.msgError(msg)
this.eventCount = 0
this.streaming = false
return
}
const reader = resp.body.getReader()
const decoder = new TextDecoder('utf-8')
let buf = ''
this.eventCount = 0
// eslint-disable-next-line no-constant-condition
while (true) {
const { value, done } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
let idx
while ((idx = buf.indexOf('\n\n')) >= 0) {
const frame = buf.slice(0, idx)
buf = buf.slice(idx + 2)
this.handleFrame(frame)
}
}
if (this.eventCount === 0) {
this.$modal.msgError('未收到任何流式数据,请检查后端是否可访问 AI 服务')
}
} catch (e) {
this.$modal.msgError('连接中断:' + (e.message || e))
} finally {
this.streaming = false
}
},
handleFrame (frame) {
let data = ''
for (const line of frame.split('\n')) {
if (line.startsWith('data:')) data += line.slice(5).trim()
}
if (!data) return
let ev
try { ev = JSON.parse(data) } catch (e) { return }
this.eventCount++
if (ev.type === 'start') {
// 通道已打开,等待解析/生成
} else if (ev.type === 'reasoning') {
this.reasoning += ev.c
this.scrollBottom()
} else if (ev.type === 'content') {
if (this.content === '') this.showReasoning = false // 正文开始时折叠思考过程
this.content += ev.c
this.scrollBottom()
} else if (ev.type === 'done') {
this.done = true
this.savedId = ev.id
this.matchScore = ev.matchScore
this.riskLevel = ev.riskLevel
this.$modal.msgSuccess('审核完成')
} else if (ev.type === 'error') {
this.$modal.msgError(ev.msg || '审核失败')
}
},
scrollBottom () {
this.$nextTick(() => {
const el = this.$refs.outBody
if (el) el.scrollTop = el.scrollHeight
})
}
}
}
</script>
<style scoped lang="scss">
.ai-review-add { padding: 10px; }
.topbar { margin-bottom: 8px; ::v-deep .el-card__body { padding: 10px 14px; } }
.bar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px;
.left { display: flex; align-items: center; gap: 12px;
.title { font-weight: 600; font-size: 15px; color: #303133; i { color: #409eff; margin-right: 4px; } }
}
.right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
}
.bar-hint { font-size: 12px; color: #909399; margin-top: 8px; }
.body { margin-top: 0; }
.panel { ::v-deep .el-card__header { padding: 9px 14px; } }
.hd { display: flex; justify-content: space-between; align-items: center;
font-size: 13px; font-weight: 600; color: #303133; i { color: #409eff; margin-right: 4px; }
.hd-tags { display: flex; align-items: center; gap: 8px; }
.streaming-dot { color: #67c23a; font-size: 12px; animation: blink 1s infinite; }
}
.out-panel, .preview-panel { height: calc(100vh - 180px); ::v-deep .el-card__body { height: calc(100% - 40px); padding: 0; } }
.out-body { height: 100%; overflow-y: auto; padding: 12px 16px; }
.placeholder, .preview-placeholder {
text-align: center; color: #c0c4cc; padding: 90px 20px;
i { font-size: 52px; opacity: .4; display: block; margin-bottom: 14px; }
.sub { font-size: 12px; margin-top: 6px; }
}
.thinking-tip { color: #909399; font-size: 13px; padding: 8px 0; }
.reasoning { margin-bottom: 12px; border: 1px dashed #e0e3e9; border-radius: 6px; background: #fafbfc; }
.reasoning-hd { cursor: pointer; user-select: none; padding: 6px 10px; font-size: 12px; color: #909399;
i { margin-right: 4px; } }
.reasoning-body { margin: 0; padding: 0 12px 10px; font-size: 12px; color: #b0b3b8; line-height: 1.6;
white-space: pre-wrap; word-break: break-word; max-height: 200px; overflow-y: auto; }
.preview-body { height: 100%; }
.pdf-frame { width: 100%; height: 100%; border: none; }
.md-body {
font-size: 14px; color: #2c3e50; line-height: 1.8;
::v-deep {
h1, h2, h3 { color: #303133; margin: 16px 0 9px; font-weight: 600; }
h1 { font-size: 19px; } h2 { font-size: 16px; border-left: 3px solid #409eff; padding-left: 8px; }
h3 { font-size: 14px; }
p { margin: 8px 0; }
ul, ol { padding-left: 22px; margin: 8px 0; }
li { margin: 4px 0; }
strong { color: #c0392b; }
code { background: #f4f7fa; padding: 1px 5px; border-radius: 3px; font-size: 13px; color: #e6a23c; }
table { border-collapse: collapse; width: 100%; margin: 10px 0; }
th, td { border: 1px solid #ebeef5; padding: 6px 10px; font-size: 13px; }
th { background: #f5f7fa; }
blockquote { border-left: 3px solid #dcdfe6; padding-left: 10px; color: #909399; margin: 8px 0; }
}
}
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: .3; } }
</style>

View File

@@ -0,0 +1,110 @@
<template>
<div class="app-container ai-review-detail" v-loading="loading">
<el-card shadow="never" class="panel">
<div slot="header" class="hd">
<div class="hd-left">
<el-button size="small" icon="el-icon-back" @click="goBack">返回列表</el-button>
<span class="title"><i class="el-icon-document-checked" /> 审核详情</span>
</div>
<div v-if="info" class="hd-right">
<el-tag size="small" :type="info.reviewType === 'contract' ? 'warning' : 'success'">
{{ info.reviewType === 'contract' ? '合同审核' : '简历审核' }}
</el-tag>
<el-tag v-if="info.reviewType === 'resume' && info.matchScore != null"
size="small" type="success" effect="dark">匹配度 {{ info.matchScore }}</el-tag>
<el-tag v-if="info.reviewType === 'contract' && info.riskLevel"
size="small" :type="riskTagType(info.riskLevel)" effect="dark">{{ info.riskLevel }}风险</el-tag>
</div>
</div>
<el-descriptions v-if="info" :column="3" size="small" border class="meta">
<el-descriptions-item label="文件名">
<span>{{ info.fileName }}</span>
<el-button v-if="info.fileUrl" type="text" size="mini" icon="el-icon-download"
style="margin-left:8px" @click="downloadFile">下载原件</el-button>
</el-descriptions-item>
<el-descriptions-item v-if="info.reviewType === 'resume'" label="目标岗位">
{{ info.position || '' }}
</el-descriptions-item>
<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>
<div v-if="info" class="md-body" v-html="renderedMd" />
</el-card>
</div>
</template>
<script>
import { getAiReview } from '@/api/oa/aiReview'
const marked = require('marked')
export default {
name: 'OaAiReviewDetail',
data () {
return {
loading: true,
info: null
}
},
computed: {
renderedMd () {
if (!this.info || !this.info.resultMd) return ''
try { return marked(this.info.resultMd) } catch (e) { return this.info.resultMd }
}
},
created () {
marked.setOptions({ breaks: true })
const id = this.$route.params.id
if (id) this.load(id)
else this.loading = false
},
methods: {
load (id) {
this.loading = true
getAiReview(id).then(res => { this.info = res.data })
.finally(() => { this.loading = false })
},
goBack () {
this.$router.push('/hint/aiReview')
},
downloadFile () {
if (this.info && this.info.fileUrl) window.open(this.info.fileUrl, '_blank')
},
riskTagType (r) {
return r === '高' ? 'danger' : (r === '中' ? 'warning' : 'success')
}
}
}
</script>
<style scoped lang="scss">
.ai-review-detail { padding: 10px; }
.panel { ::v-deep .el-card__header { padding: 10px 14px; } }
.hd { display: flex; justify-content: space-between; align-items: center;
.hd-left { display: flex; align-items: center; gap: 12px;
.title { font-weight: 600; font-size: 14px; color: #303133; i { color: #409eff; margin-right: 4px; } }
}
.hd-right { display: flex; align-items: center; gap: 8px; }
}
.meta { margin-bottom: 16px; }
.md-body {
font-size: 14px; color: #2c3e50; line-height: 1.8; padding: 4px 6px;
::v-deep {
h1, h2, h3 { color: #303133; margin: 18px 0 10px; font-weight: 600; }
h1 { font-size: 19px; } h2 { font-size: 16px; border-left: 3px solid #409eff; padding-left: 8px; }
h3 { font-size: 14px; }
p { margin: 8px 0; }
ul, ol { padding-left: 22px; margin: 8px 0; }
li { margin: 4px 0; }
strong { color: #c0392b; }
code { background: #f4f7fa; padding: 1px 5px; border-radius: 3px; font-size: 13px; color: #e6a23c; }
table { border-collapse: collapse; width: 100%; margin: 10px 0; }
th, td { border: 1px solid #ebeef5; padding: 6px 10px; font-size: 13px; }
th { background: #f5f7fa; }
blockquote { border-left: 3px solid #dcdfe6; padding-left: 10px; color: #909399; margin: 8px 0; }
}
}
</style>

View File

@@ -0,0 +1,136 @@
<template>
<div class="app-container">
<!-- 搜索 -->
<el-form :model="query" ref="queryForm" size="small" :inline="true" label-width="68px">
<el-form-item label="类型" prop="reviewType">
<el-select v-model="query.reviewType" placeholder="全部" clearable style="width: 120px" @change="handleQuery">
<el-option label="合同" value="contract" />
<el-option label="简历" value="resume" />
</el-select>
</el-form-item>
<el-form-item label="关键字" prop="keyword">
<el-input v-model="query.keyword" placeholder="文件名 / 岗位" clearable style="width: 220px"
@keyup.enter.native="handleQuery" />
</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="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<!-- 工具栏 -->
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button type="primary" plain icon="el-icon-magic-stick" size="mini" @click="openUpload">新增审核</el-button>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
<!-- 列表 -->
<el-table v-loading="loading" :data="list" @row-dblclick="goDetail">
<el-table-column label="类型" align="center" width="80">
<template slot-scope="scope">
<el-tag size="mini" :type="scope.row.reviewType === 'contract' ? 'warning' : 'success'">
{{ scope.row.reviewType === 'contract' ? '合同' : '简历' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="文件名" align="left" prop="fileName" min-width="180" show-overflow-tooltip />
<el-table-column label="岗位" align="center" prop="position" width="130" show-overflow-tooltip>
<template slot-scope="scope">
<span v-if="scope.row.position">{{ scope.row.position }}</span>
<span v-else style="color:#c0c4cc"></span>
</template>
</el-table-column>
<el-table-column label="结论" align="center" width="100">
<template slot-scope="scope">
<el-tag v-if="scope.row.reviewType === 'resume' && scope.row.matchScore != null"
size="mini" type="success" effect="dark">匹配 {{ scope.row.matchScore }}</el-tag>
<el-tag v-else-if="scope.row.reviewType === 'contract' && scope.row.riskLevel"
size="mini" :type="riskTagType(scope.row.riskLevel)" effect="dark">
{{ scope.row.riskLevel }}风险</el-tag>
<span v-else style="color:#c0c4cc"></span>
</template>
</el-table-column>
<el-table-column label="审核摘要" align="left" prop="summary" min-width="280" show-overflow-tooltip>
<template slot-scope="scope">
<span class="summary-cell">{{ scope.row.summary || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="审核时间" align="center" prop="createTime" width="150" />
<el-table-column label="操作" align="center" width="130" class-name="small-padding fixed-width">
<template slot-scope="scope">
<el-button size="mini" type="text" icon="el-icon-view" @click="goDetail(scope.row)">详情</el-button>
<el-button size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" :total="total"
:page.sync="query.pageNum" :limit.sync="query.pageSize" @pagination="getList" />
</div>
</template>
<script>
import { listAiReview, delAiReview } from '@/api/oa/aiReview'
export default {
name: 'OaAiReview',
data () {
return {
loading: true,
showSearch: true,
total: 0,
list: [],
query: { pageNum: 1, pageSize: 10, keyword: '', reviewType: '' }
}
},
created () {
this.getList()
},
activated () {
this.getList()
},
methods: {
getList () {
this.loading = true
listAiReview(this.query).then(res => {
this.list = res.rows || []
this.total = res.total || 0
}).finally(() => { this.loading = false })
},
handleQuery () {
this.query.pageNum = 1
this.getList()
},
resetQuery () {
this.resetForm('queryForm')
this.query.reviewType = ''
this.handleQuery()
},
goDetail (row) {
this.$router.push('/hint/aiReview/detail/' + row.id)
},
handleDelete (row) {
this.$modal.confirm(`确认删除「${row.fileName}」的审核记录?`).then(() => {
return delAiReview(row.id)
}).then(() => {
this.$modal.msgSuccess('已删除')
this.getList()
}).catch(() => {})
},
openUpload () {
this.$router.push('/hint/aiReview/add')
},
riskTagType (r) {
return r === '高' ? 'danger' : (r === '中' ? 'warning' : 'success')
}
}
}
</script>
<style scoped lang="scss">
.summary-cell { color: #606266; font-size: 12px; }
</style>

View File

@@ -0,0 +1,464 @@
<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="68px">
<el-form-item label="采购需求" prop="requirementId">
<el-select v-model="queryParams.requirementId" placeholder="请选择采购需求, 输入关键字搜索" filterable remote
:remote-method="loadRequirementOptions" :loading="requirementLoading" clearable style="width: 200px">
<el-option v-for="r in requirementOptions" :key="r.requirementId" :label="r.title" :value="r.requirementId">
<span style="float: left">{{ r.title }}</span>
<span style="float: right; color: #8492a6; font-size: 12px">{{ r.projectName || '无项目' }}</span>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="关联项目" prop="projectId">
<project-select v-model="queryParams.projectId" style="width: 200px" @change="onQueryProjectChange" />
</el-form-item>
<el-form-item label="合同编号" prop="contractNo">
<el-input v-model="queryParams.contractNo" placeholder="请输入合同编号" clearable @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="物料名称" prop="goodsName">
<el-input v-model="queryParams.goodsName" placeholder="请输入物料名称" clearable @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="queryParams.description" placeholder="请输入描述" clearable @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="内贸/外贸" prop="tradeType">
<el-select v-model="queryParams.tradeType" placeholder="请选择" clearable style="width: 100px">
<el-option label="内贸" :value="0" />
<el-option label="外贸" :value="1" />
</el-select>
</el-form-item>
<el-form-item label="发货地点" prop="sourceAddress">
<el-input v-model="queryParams.sourceAddress" placeholder="请输入发货地点" clearable
@keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="规划目的地" prop="targetAddress">
<el-input v-model="queryParams.targetAddress" placeholder="请输入规划目的地" clearable
@keyup.enter.native="handleQuery" />
</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="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd">新增</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="success" plain icon="el-icon-edit" size="mini" :disabled="single"
@click="handleUpdate">修改</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="danger" plain icon="el-icon-delete" size="mini" :disabled="multiple"
@click="handleDelete">删除</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="warning" plain icon="el-icon-download" size="mini" @click="handleExport">导出</el-button>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
<el-table v-loading="loading" :data="arrivalDetailList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="采购需求" align="center" min-width="160" show-overflow-tooltip>
<template slot-scope="{ row }">
<span>{{ (row.requirement && row.requirement.title) || row.requirementId }}</span>
</template>
</el-table-column>
<el-table-column label="关联项目" align="center" min-width="160" show-overflow-tooltip>
<template slot-scope="{ row }">
<span>{{ (row.project && row.project.projectName) || row.projectId }}</span>
</template>
</el-table-column>
<el-table-column label="项目类型" align="center" width="80">
<template slot-scope="{ row }">
<el-tag :type="row.tradeType === 0 ? '' : 'warning'" size="small">{{ row.tradeType === 0 ? '内贸' : '外贸'
}}</el-tag>
</template>
</el-table-column>
<el-table-column label="合同编号" align="center" prop="contractNo" />
<el-table-column label="物料名称" align="center" prop="goodsName" />
<el-table-column label="数量" align="center" prop="quantity" />
<el-table-column label="单价" align="center" prop="unitPrice" />
<el-table-column label="描述" align="center" prop="description" />
<el-table-column label="到货类型" align="center" prop="arrivalType" width="80">
<template slot-scope="{ row }">
<el-tag :type="row.arrivalType === 0 ? 'success' : 'warning'" size="small">{{ row.arrivalType === 0 ? '收' :
'发' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="到货截止日期" align="center" prop="deadline" width="180">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.deadline, '{y}-{m}-{d}') }}</span>
</template>
</el-table-column>
<el-table-column label="发货地点" align="center" prop="sourceAddress" />
<el-table-column label="规划目的地" align="center" prop="targetAddress" />
<el-table-column label="状态" align="center" prop="detailStatus" width="140">
<template slot-scope="{ row }">
<el-select v-model="row.detailStatus" size="mini" placeholder="选择状态"
@change="val => onDetailStatusChange(row, val)">
<el-option v-for="opt in statusOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</template>
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="200">
<template slot-scope="scope">
<el-button size="mini" type="text" icon="el-icon-truck" style="color:#409eff"
v-if="scope.row.arrivalType === 0" @click="handleShip(scope.row)">发货</el-button>
<el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)">修改</el-button>
<el-button size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.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="title" :visible.sync="open" width="500px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="80px">
<el-form-item label="采购需求" prop="requirementId">
<el-select v-model="form.requirementId" placeholder="请选择采购需求, 输入关键字搜索" filterable remote
:remote-method="loadRequirementOptions" :loading="requirementLoading" clearable style="width: 100%"
@change="onRequirementChange">
<el-option v-for="r in requirementOptions" :key="r.requirementId" :label="r.title" :value="r.requirementId">
<span style="float: left">{{ r.title }}</span>
<span style="float: right; color: #8492a6; font-size: 12px">{{ r.projectName || '无项目' }}</span>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="关联项目" prop="projectId">
<project-select v-model="form.projectId" style="width: 100%" @change="onFormProjectChange" />
</el-form-item>
<el-form-item label="内贸/外贸" prop="tradeType">
<el-select v-model="form.tradeType" placeholder="请选择" style="width: 100%">
<el-option label="内贸" :value="0" />
<el-option label="外贸" :value="1" />
</el-select>
</el-form-item>
<el-form-item label="合同编号" prop="contractNo">
<el-input v-model="form.contractNo" placeholder="请输入合同编号" />
</el-form-item>
<el-form-item label="物料名称" prop="goodsName">
<el-input v-model="form.goodsName" placeholder="请输入物料名称" />
</el-form-item>
<el-form-item label="数量" prop="quantity">
<el-input v-model="form.quantity" placeholder="请输入数量" />
</el-form-item>
<el-form-item label="单价" prop="unitPrice">
<el-input v-model="form.unitPrice" placeholder="请输入单价" />
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="form.description" type="textarea" placeholder="请输入内容" />
</el-form-item>
<el-form-item label="到货截止日期" prop="deadline">
<el-date-picker clearable v-model="form.deadline" type="datetime" value-format="yyyy-MM-dd HH:mm:ss"
placeholder="请选择到货截止日期">
</el-date-picker>
</el-form-item>
<el-form-item label="发货地点" prop="sourceAddress">
<el-input v-model="form.sourceAddress" type="textarea" placeholder="请输入内容" />
</el-form-item>
<el-form-item label="规划目的地" prop="targetAddress" v-if="form.detailId">
<el-input v-model="form.targetAddress" type="textarea" placeholder="请输入内容" />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button :loading="buttonLoading" type="primary" @click="submitForm"> </el-button>
<el-button @click="cancel"> </el-button>
</div>
</el-dialog>
<!-- 发货对话框 -->
<el-dialog :title="'发货 - ' + shipRow.goodsName" :visible.sync="shipOpen" width="400px" append-to-body>
<el-form ref="shipForm" :model="shipForm" :rules="shipRules" label-width="100px">
<el-form-item label="规划目的地" prop="targetAddress">
<el-input v-model="shipForm.targetAddress" type="textarea" placeholder="请输入规划目的地" />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitShip"> </el-button>
<el-button @click="shipOpen = false"> </el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { listArrivalDetail, getArrivalDetail, delArrivalDetail, addArrivalDetail, updateArrivalDetail } from "@/api/oa/arrivalDetail";
import { listRequirements } from "@/api/oa/requirement";
import ProjectSelect from "@/components/fad-service/ProjectSelect";
export default {
name: "ArrivalDetail",
components: { ProjectSelect },
data() {
return {
// 按钮loading
buttonLoading: false,
// 遮罩层
loading: true,
// 选中数组
ids: [],
// 非单个禁用
single: true,
// 非多个禁用
multiple: true,
// 显示搜索条件
showSearch: true,
// 总条数
total: 0,
// 到货明细表格数据
arrivalDetailList: [],
// 弹出层标题
title: "",
// 是否显示弹出层
open: false,
// 发货弹窗
shipOpen: false,
shipRow: {},
shipForm: {},
shipRules: {
targetAddress: [
{ required: true, message: "规划目的地不能为空", trigger: "blur" }
]
},
// 需求下拉选项
requirementOptions: [],
requirementLoading: false,
// 状态选项
statusOptions: [
{ value: 0, label: '待发货' },
{ value: 1, label: '运输中' },
{ value: 2, label: '已到货' },
{ value: 3, label: '异常/拒收' },
{ value: 4, label: '取消' },
],
// 查询参数
queryParams: {
pageNum: 1,
pageSize: 10,
requirementId: undefined,
projectId: undefined,
tradeType: undefined,
contractNo: undefined,
goodsName: undefined,
quantity: undefined,
unitPrice: undefined,
arrivalType: undefined,
deadline: undefined,
sourceAddress: undefined,
targetAddress: undefined,
detailStatus: undefined,
description: undefined,
},
// 表单参数
form: {},
// 表单校验
rules: {}
};
},
created() {
this.getList();
this.loadRequirementOptions();
},
methods: {
/** 查询到货明细列表 */
getList() {
this.loading = true;
listArrivalDetail(this.queryParams).then(response => {
this.arrivalDetailList = response.rows;
this.total = response.total;
this.loading = false;
});
},
/** 远程搜索采购需求 */
loadRequirementOptions(keyword) {
this.requirementLoading = true;
listRequirements({ pageNum: 1, pageSize: 20, title: keyword || undefined }).then(res => {
this.requirementOptions = res.rows || [];
}).finally(() => {
this.requirementLoading = false;
});
},
/** 选中需求后自动填充关联项目 */
onRequirementChange(requirementId) {
if (!requirementId) return;
const r = this.requirementOptions.find(x => x.requirementId === requirementId);
if (r && r.projectId && !this.form.projectId) {
this.form.projectId = r.projectId;
}
},
/** 搜索表单选择项目后自动填充内外贸类型 */
onQueryProjectChange(val, selectedProject) {
if (selectedProject) {
this.queryParams.tradeType = selectedProject.tradeType;
} else {
this.queryParams.tradeType = undefined;
}
},
/** 弹窗表单选择项目后自动填充内外贸类型 */
onFormProjectChange(val, selectedProject) {
if (selectedProject) {
this.form.tradeType = selectedProject.tradeType;
} else {
this.form.tradeType = undefined;
}
},
// 取消按钮
cancel() {
this.open = false;
this.reset();
},
// 表单重置
reset() {
this.form = {
detailId: undefined,
requirementId: undefined,
projectId: undefined,
tradeType: undefined,
contractNo: undefined,
goodsName: undefined,
quantity: undefined,
unitPrice: undefined,
arrivalType: undefined,
deadline: undefined,
sourceAddress: undefined,
targetAddress: undefined,
detailStatus: undefined,
description: undefined,
remark: undefined,
createBy: undefined,
createTime: undefined,
updateBy: undefined,
updateTime: undefined,
delFlag: undefined
};
this.resetForm("form");
},
/** 搜索按钮操作 */
handleQuery() {
this.queryParams.pageNum = 1;
this.getList();
},
/** 重置按钮操作 */
resetQuery() {
this.resetForm("queryForm");
this.handleQuery();
},
// 多选框选中数据
handleSelectionChange(selection) {
this.ids = selection.map(item => item.detailId)
this.single = selection.length !== 1
this.multiple = !selection.length
},
/** 新增按钮操作 */
handleAdd() {
this.reset();
this.form.arrivalType = 0;
this.form.detailStatus = 0;
this.open = true;
this.title = "添加到货明细";
},
/** 修改按钮操作 */
handleUpdate(row) {
this.loading = true;
this.reset();
const detailId = row.detailId || this.ids
getArrivalDetail(detailId).then(response => {
this.loading = false;
this.form = response.data;
this.open = true;
this.title = "修改到货明细";
});
},
/** 提交按钮 */
submitForm() {
this.$refs["form"].validate(valid => {
if (valid) {
this.buttonLoading = true;
if (this.form.detailId != null) {
updateArrivalDetail(this.form).then(response => {
this.$modal.msgSuccess("修改成功");
this.open = false;
this.getList();
}).finally(() => {
this.buttonLoading = false;
});
} else {
addArrivalDetail(this.form).then(response => {
this.$modal.msgSuccess("新增成功");
this.open = false;
this.getList();
}).finally(() => {
this.buttonLoading = false;
});
}
}
});
},
/** 删除按钮操作 */
handleDelete(row) {
const detailIds = row.detailId || this.ids;
this.$modal.confirm('是否确认删除到货明细编号为"' + detailIds + '"的数据项?').then(() => {
this.loading = true;
return delArrivalDetail(detailIds);
}).then(() => {
this.loading = false;
this.getList();
this.$modal.msgSuccess("删除成功");
}).catch(() => {
}).finally(() => {
this.loading = false;
});
},
/** 表格内状态快捷修改 */
onDetailStatusChange(row, newVal) {
row.detailStatus = Number(newVal);
updateArrivalDetail(row).then(() => {
this.$message.success(`状态已更新为「${this.statusLabel(newVal)}`);
});
},
statusLabel(val) {
const m = { 0: '待发货', 1: '运输中', 2: '已到货', 3: '异常/拒收', 4: '取消' };
return m[val] ?? String(val);
},
/** 发货按钮操作 */
handleShip(row) {
this.shipRow = row;
this.shipForm = { targetAddress: row.targetAddress || '' };
this.shipOpen = true;
this.$nextTick(() => {
if (this.$refs.shipForm) this.$refs.shipForm.clearValidate();
});
},
/** 提交发货 */
submitShip() {
this.$refs["shipForm"].validate(valid => {
if (valid) {
const row = this.shipRow;
row.arrivalType = 1;
row.targetAddress = this.shipForm.targetAddress;
updateArrivalDetail(row).then(() => {
this.$modal.msgSuccess("发货成功");
this.shipOpen = false;
this.getList();
});
}
});
},
/** 导出按钮操作 */
handleExport() {
this.download('oa/arrivalDetail/export', {
...this.queryParams
}, `arrivalDetail_${new Date().getTime()}.xlsx`)
}
}
};
</script>

View File

@@ -0,0 +1,468 @@
<template>
<div class="app-container meeting-edit">
<!-- 顶部操作栏 -->
<el-card shadow="never" class="topbar">
<div class="topbar-inner">
<div class="brand">
<el-button size="small" icon="el-icon-back" @click="goBack">返回列表</el-button>
<span class="brand-title">{{ isEdit ? '编辑会议纪要' : '新增会议纪要' }}</span>
<span v-if="form.meetingCode" class="hd-code">{{ form.meetingCode }}</span>
</div>
<div class="actions">
<el-checkbox v-model="form.syncTask" :true-label="1" :false-label="0" class="sync-chk">
<span style="font-size:12px">保存时同步生成 OA 任务</span>
</el-checkbox>
<el-button size="small" type="primary" icon="el-icon-document-checked"
:loading="saving" @click="cmdSave">保存</el-button>
<el-button size="small" icon="el-icon-download" @click="cmdExport">导出</el-button>
<el-button size="small" icon="el-icon-printer" @click="cmdPrint">打印</el-button>
</div>
</div>
</el-card>
<el-card shadow="never" class="editor-card" v-loading="loadingDetail">
<!-- 元数据 -->
<el-form :model="form" label-width="80px" size="small" class="meta-form">
<el-row :gutter="10">
<el-col :span="8">
<el-form-item label="会议日期" required>
<el-date-picker v-model="form.meetingDate" type="date" value-format="yyyy-MM-dd"
style="width:100%" placeholder="选择日期" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="项目">
<project-select v-model="form.projectId" placeholder="不选则为非项目会议" clearable style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="会议类型">
<el-select v-model="form.meetingType" style="width:100%">
<el-option v-for="t in dict.type.oa_meeting_type" :key="t.value" :value="t.value" :label="t.label" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="16">
<el-form-item label="会议主题" required>
<el-input v-model="form.subject" maxlength="200" placeholder="输入会议主题" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="会议地点">
<el-input v-model="form.location" maxlength="100" placeholder="会议室 / 线上" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="主持人">
<el-tag v-if="form.hostUserId" closable @close="clearHost">
{{ form.hostUserName || ('#' + form.hostUserId) }}
</el-tag>
<el-button type="text" @click="pickHost">{{ form.hostUserId ? '更换' : '点击选择' }}</el-button>
</el-form-item>
</el-col>
<el-col :span="16">
<el-form-item label="参会人员">
<user-select v-model="form.attendeeUserIds" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<!-- 4 结构化区块 -->
<div class="sec-block">
<div class="sec-hd"><span class="sec-num"></span> 会议议题</div>
<el-input type="textarea" :rows="4" v-model="form.topic" placeholder="1.&#10;2.&#10;3." />
</div>
<div class="sec-block">
<div class="sec-hd"><span class="sec-num"></span> 讨论内容</div>
<el-input type="textarea" :rows="5" v-model="form.discussion" placeholder="记录讨论要点和各方意见..." />
</div>
<div class="sec-block">
<div class="sec-hd"><span class="sec-num"></span> 决议事项</div>
<el-input type="textarea" :rows="4" v-model="form.decision" placeholder="记录会议达成的决议和结论..." />
</div>
<div class="sec-block">
<div class="sec-hd">
<span class="sec-num"></span> 待办事项
<span class="sec-tip">填了负责人和内容的待办保存时按上方开关同步为 OA 任务并通知负责人</span>
<el-button type="text" icon="el-icon-plus" class="add-task" @click="addTask">添加待办</el-button>
</div>
<div v-if="form.tasks.length === 0" class="task-empty">尚未添加待办</div>
<div v-for="(t, i) in form.tasks" :key="i" class="task-row">
<div class="task-line">
<div class="tf tf-assignee">
<label>负责人</label>
<div>
<el-tag v-if="t.assigneeUserId" size="small" closable @close="clearAssignee(t)">
{{ t.assigneeName || ('#' + t.assigneeUserId) }}
</el-tag>
<el-button type="text" size="mini" @click="pickAssignee(i)">
{{ t.assigneeUserId ? '更换' : '选择' }}
</el-button>
</div>
</div>
<div class="tf tf-content">
<label>任务内容</label>
<el-input v-model="t.content" size="mini" maxlength="200" placeholder="任务描述..." />
</div>
<div class="tf tf-deadline">
<label>截止日期</label>
<el-date-picker v-model="t.deadline" type="date" size="mini"
value-format="yyyy-MM-dd" style="width:100%" placeholder="日期" />
</div>
<div class="tf tf-status">
<label>状态</label>
<el-select v-model="t.status" size="mini" style="width:100%">
<el-option v-for="o in dict.type.oa_meeting_task_status" :key="o.value"
:value="o.value" :label="o.label" />
</el-select>
</div>
<div class="tf tf-act">
<el-tag v-if="t.taskId" size="mini" type="success">已同步</el-tag>
<el-button type="text" icon="el-icon-delete" style="color:#f56c6c"
@click="removeTask(i)" />
</div>
</div>
</div>
</div>
</el-card>
<!-- 人员单选弹窗主持人 / 待办负责人共用 -->
<user-single-select v-model="userPickerVisible" @onSelected="onUserPicked" />
</div>
</template>
<script>
import {
getMeetingMinutes, addMeetingMinutes, updateMeetingMinutes
} from '@/api/oa/meetingMinutes'
import UserSelect from '@/components/UserSelect'
import UserSingleSelect from '@/components/UserSelect/single'
import ProjectSelect from '@/components/fad-service/ProjectSelect'
function localToday () {
const d = new Date()
const p = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
}
function emptyForm () {
return {
id: null,
meetingCode: '',
meetingDate: localToday(),
projectId: null,
meetingType: 'other',
subject: '',
location: '',
hostUserId: null,
hostUserName: '',
attendeeUserIds: '',
attendeeUserNames: '',
topic: '',
discussion: '',
decision: '',
tasks: [],
syncTask: 1
}
}
export default {
name: 'OaMeetingEdit',
components: { UserSelect, UserSingleSelect, ProjectSelect },
dicts: ['oa_meeting_type', 'oa_meeting_task_status'],
data () {
return {
saving: false,
loadingDetail: false,
form: emptyForm(),
// 人员单选弹窗当前服务对象:'host' 或待办行下标
userPickerVisible: false,
userPickerTarget: 'host'
}
},
computed: {
isEdit () { return !!this.form.id }
},
created () {
const id = this.$route.params.id
if (id) this.loadMinutes(id)
},
watch: {
// 新增/编辑两个路由共用本组件路由切换时组件实例可能被复用created 不会重新触发
'$route' (to) {
if (to.name === 'addMeetingMinutes') {
this.form = emptyForm()
} else if (to.name === 'editMeetingMinutes' && to.params.id
&& String(this.form.id) !== String(to.params.id)) {
this.loadMinutes(to.params.id)
}
}
},
methods: {
goBack () {
this.$router.push('/hint/meeting')
},
// ============ 人员选择 ============
pickHost () {
this.userPickerTarget = 'host'
this.userPickerVisible = true
},
clearHost () {
this.form.hostUserId = null
this.form.hostUserName = ''
},
pickAssignee (i) {
this.userPickerTarget = i
this.userPickerVisible = true
},
clearAssignee (t) {
t.assigneeUserId = null
t.assigneeName = ''
},
onUserPicked (row) {
if (!row) return
if (this.userPickerTarget === 'host') {
this.form.hostUserId = row.userId
this.form.hostUserName = row.nickName
} else {
const t = this.form.tasks[this.userPickerTarget]
if (t) {
t.assigneeUserId = row.userId
t.assigneeName = row.nickName
}
}
},
// ============ 待办 ============
addTask () {
this.form.tasks.push({
assigneeUserId: null, assigneeName: '',
content: '', deadline: '', status: 'pending', taskId: null
})
},
removeTask (i) {
const t = this.form.tasks[i]
if (t.taskId) {
this.$modal.confirm('该待办已同步为 OA 任务,移除后任务本身不会删除,仅与纪要解除关联。继续?')
.then(() => this.form.tasks.splice(i, 1))
.catch(() => {})
} else {
this.form.tasks.splice(i, 1)
}
},
// ============ 保存 ============
async cmdSave () {
if (!this.form.meetingDate) return this.$modal.msgError('请选择会议日期')
if (!this.form.subject) return this.$modal.msgError('请输入会议主题')
const cleanTasks = (this.form.tasks || []).map(t => ({
assigneeUserId: t.assigneeUserId,
assigneeName: t.assigneeName,
content: t.content,
deadline: t.deadline,
status: t.status,
taskId: t.taskId || null
}))
const payload = { ...this.form, tasksJson: JSON.stringify(cleanTasks) }
delete payload.tasks
this.saving = true
try {
let id = this.form.id
if (id) {
await updateMeetingMinutes(payload)
} else {
const res = await addMeetingMinutes(payload)
id = res.data
// 切到编辑路由,刷新/再保存都是更新而不是再次新增(先置 id 避免路由 watch 重复加载)
if (id) {
this.form.id = id
this.$router.replace('/hint/meeting/edit/' + id)
}
}
this.$modal.msgSuccess('纪要已保存' + (this.form.syncTask ? ',待办已同步到 OA 任务' : ''))
if (id) await this.loadMinutes(id)
} finally {
this.saving = false
}
},
// ============ 加载 ============
async loadMinutes (id) {
this.loadingDetail = true
try {
const res = await getMeetingMinutes(id)
const m = res.data
if (!m) return
this.form = {
id: m.id,
meetingCode: m.meetingCode,
meetingDate: m.meetingDate,
projectId: m.projectId,
meetingType: m.meetingType || 'other',
subject: m.subject || '',
location: m.location || '',
hostUserId: m.hostUserId || null,
hostUserName: m.hostUserName || '',
attendeeUserIds: m.attendeeUserIds || '',
attendeeUserNames: m.attendeeUserNames || '',
topic: m.topic || '',
discussion: m.discussion || '',
decision: m.decision || '',
tasks: this.parseTasks(m.tasksJson),
syncTask: m.syncTask == null ? 1 : m.syncTask
}
} finally {
this.loadingDetail = false
}
},
parseTasks (s) {
if (!s) return []
try {
const a = JSON.parse(s)
if (!Array.isArray(a)) return []
return a.map(t => ({
assigneeUserId: t.assigneeUserId || null,
assigneeName: t.assigneeName || '',
content: t.content || '',
deadline: t.deadline || '',
status: t.status || 'pending',
taskId: t.taskId || null
}))
} catch (e) { return [] }
},
dictLabel (dictKey, v) {
const hit = (this.dict.type[dictKey] || []).find(t => t.value === v)
return hit ? hit.label : (v || '-')
},
// ============ 导出 / 打印 ============
cmdExport () {
if (!this.form.subject) return this.$modal.msgError('无内容可导出')
const d = this.form
const lines = []
lines.push('德睿福成套设备有限公司 · 会议纪要')
lines.push('='.repeat(50))
lines.push('编号: ' + (d.meetingCode || '-'))
lines.push('日期: ' + d.meetingDate + ' 类型: ' + this.dictLabel('oa_meeting_type', d.meetingType))
lines.push('主题: ' + d.subject)
lines.push('地点: ' + (d.location || '-'))
lines.push('主持: ' + (d.hostUserName || '-'))
lines.push('参会: ' + (d.attendeeUserNames || '-'))
lines.push('='.repeat(50))
lines.push('')
lines.push('一、会议议题'); lines.push('-'.repeat(30)); lines.push(d.topic || '(无)'); lines.push('')
lines.push('二、讨论内容'); lines.push('-'.repeat(30)); lines.push(d.discussion || '(无)'); lines.push('')
lines.push('三、决议事项'); lines.push('-'.repeat(30)); lines.push(d.decision || '(无)'); lines.push('')
lines.push('四、待办事项'); lines.push('-'.repeat(30))
if (d.tasks && d.tasks.length) {
d.tasks.forEach(t => {
lines.push(' • [' + (t.assigneeName || '未指派') + '] ' + (t.content || '') +
' | 截止:' + (t.deadline || '-') + ' | 状态:' + this.dictLabel('oa_meeting_task_status', t.status))
})
} else { lines.push('(无)') }
lines.push(''); lines.push('='.repeat(50))
lines.push('导出时间: ' + new Date().toLocaleString('zh-CN'))
const blob = new Blob(['' + lines.join('\n')], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url; a.download = '会议纪要_' + d.meetingDate + '_' + d.subject + '.txt'; a.click()
URL.revokeObjectURL(url)
this.$modal.msgSuccess('已导出')
},
cmdPrint () {
if (!this.form.subject) return this.$modal.msgError('无内容')
const d = this.form
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]))
let taskHtml = '(无)'
if (d.tasks && d.tasks.length) {
const rows = d.tasks.map(t =>
`<tr><td>${esc(t.assigneeName || '未指派')}</td><td>${esc(t.content)}</td>` +
`<td>${esc(t.deadline || '-')}</td><td>${esc(this.dictLabel('oa_meeting_task_status', t.status))}</td></tr>`
).join('')
taskHtml = `<table border="1" cellpadding="6" cellspacing="0" style="border-collapse:collapse;width:100%">
<tr style="background:#eee"><th>负责人</th><th>任务内容</th><th>截止</th><th>状态</th></tr>${rows}</table>`
}
const w = window.open('', '', 'width=800,height=700')
w.document.write(
`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>会议纪要</title>
<style>body{font-family:"Microsoft YaHei",sans-serif;padding:40px;max-width:760px;margin:0 auto;line-height:1.8}
h1{text-align:center;font-size:20px}.sub{text-align:center;color:#888;margin-bottom:20px}
.meta{font-size:13px;margin-bottom:18px;border-bottom:1px solid #ddd;padding-bottom:10px}
.meta span{margin-right:18px}.sect{font-size:14px;margin:14px 0 6px;font-weight:700}
.body{white-space:pre-wrap;font-size:13px;margin-bottom:16px}
@media print{body{padding:20px}}</style></head><body>
<h1>德睿福成套设备有限公司</h1><div class="sub">会 议 纪 要 ${esc(d.meetingCode || '')}</div>
<div class="meta"><span>📅 ${esc(d.meetingDate)}</span>
<span>📝 ${esc(d.subject)}</span>
<span>🏷 ${esc(this.dictLabel('oa_meeting_type', d.meetingType))}</span></div>
<div class="meta"><span>📍 ${esc(d.location || '-')}</span>
<span>🎤 主持:${esc(d.hostUserName || '-')}</span></div>
<div class="meta"><span>👥 参会:${esc(d.attendeeUserNames || '-')}</span></div>
<div class="sect">一、会议议题</div><div class="body">${esc(d.topic || '(无)')}</div>
<div class="sect">二、讨论内容</div><div class="body">${esc(d.discussion || '(无)')}</div>
<div class="sect">三、决议事项</div><div class="body">${esc(d.decision || '(无)')}</div>
<div class="sect">四、待办事项</div><div class="body">${taskHtml}</div>
</body></html>`
)
w.document.close()
setTimeout(() => w.print(), 400)
}
}
}
</script>
<style scoped lang="scss">
.meeting-edit { padding: 8px; }
.topbar { margin-bottom: 8px; ::v-deep .el-card__body { padding: 10px 14px; } }
.topbar-inner { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.brand { display: flex; align-items: center; gap: 10px;
.brand-title { font-weight: 600; font-size: 15px; color: #303133; }
.hd-code { font-family: monospace; font-size: 12px; color: #909399; }
}
.actions { display: flex; align-items: center; gap: 6px; }
.sync-chk { margin-right: 6px; }
.editor-card { ::v-deep .el-card__body { padding: 12px 16px; } }
.meta-form {
::v-deep .el-form-item { margin-bottom: 8px; }
}
.sec-block { margin-top: 10px; }
.sec-hd {
display: flex; align-items: center; gap: 6px;
padding: 6px 10px; background: #f4f7fc; border-radius: 4px 4px 0 0;
font-size: 13px; font-weight: 600; color: #409eff;
.sec-num { display: inline-block; min-width: 18px; height: 18px; line-height: 18px;
text-align: center; background: #409eff; color: #fff; border-radius: 3px; font-size: 11px; }
.sec-tip { color: #909399; font-weight: normal; font-size: 11px; margin-left: 8px; }
.add-task { margin-left: auto; }
}
.task-empty {
border: 1px dashed #dcdfe6; border-top: none;
padding: 14px; text-align: center; color: #c0c4cc; font-size: 12px;
border-radius: 0 0 4px 4px;
}
.task-row {
border: 1px solid #ebeef5; border-top: none; padding: 8px 10px;
background: #fff;
&:last-child { border-radius: 0 0 4px 4px; }
}
.task-line {
display: grid;
grid-template-columns: 170px 1fr 140px 110px 100px;
gap: 8px; align-items: start;
.tf {
label { display: block; font-size: 11px; color: #909399; margin-bottom: 2px; }
&.tf-act { display: flex; flex-direction: column; align-items: center; gap: 4px;
padding-top: 16px; }
}
}
</style>

View File

@@ -0,0 +1,157 @@
<template>
<div class="app-container">
<!-- 搜索 -->
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="68px">
<el-form-item label="关键字" prop="keyword">
<el-input v-model="queryParams.keyword" placeholder="编号 / 主题 / 地点" clearable
style="width: 200px" @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="项目" prop="projectId">
<project-select v-model="queryParams.projectId" placeholder="选择项目" clearable style="width: 280px" />
</el-form-item>
<el-form-item label="会议类型" prop="meetingType">
<el-select v-model="queryParams.meetingType" placeholder="全部" clearable style="width: 120px">
<el-option v-for="t in dict.type.oa_meeting_type" :key="t.value" :value="t.value" :label="t.label" />
</el-select>
</el-form-item>
<el-form-item label="会议日期">
<el-date-picker v-model="dateRange" type="daterange" range-separator="-"
start-placeholder="开始日期" end-placeholder="结束日期" value-format="yyyy-MM-dd" style="width: 240px" />
</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="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<!-- 工具栏 -->
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd">新增</el-button>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
<!-- 列表 -->
<el-table v-loading="loading" :data="list" @row-dblclick="handleEdit">
<el-table-column label="会议编号" align="center" prop="meetingCode" width="180" show-overflow-tooltip />
<el-table-column label="会议日期" align="center" prop="meetingDate" width="100" />
<el-table-column label="类型" align="center" width="90">
<template slot-scope="scope">
<dict-tag :options="dict.type.oa_meeting_type" :value="scope.row.meetingType" />
</template>
</el-table-column>
<el-table-column label="会议主题" align="left" prop="subject" min-width="180" show-overflow-tooltip />
<el-table-column label="项目" align="center" prop="projectName" min-width="140" show-overflow-tooltip>
<template slot-scope="scope">
<span v-if="scope.row.projectName">{{ scope.row.projectName }}</span>
<span v-else style="color:#c0c4cc"></span>
</template>
</el-table-column>
<el-table-column label="主持人" align="center" prop="hostUserName" width="90" />
<el-table-column label="地点" align="center" prop="location" width="110" show-overflow-tooltip />
<el-table-column label="待办" align="center" width="90">
<template slot-scope="scope">
<span v-if="taskCount(scope.row)">
{{ syncedCount(scope.row) }}/{{ taskCount(scope.row) }} 已同步
</span>
<span v-else style="color:#c0c4cc"></span>
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="140" class-name="small-padding fixed-width">
<template slot-scope="scope">
<el-button size="mini" type="text" icon="el-icon-edit" @click="handleEdit(scope.row)">编辑</el-button>
<el-button size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.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" />
</div>
</template>
<script>
import { listMeetingMinutes, delMeetingMinutes } from '@/api/oa/meetingMinutes'
import ProjectSelect from '@/components/fad-service/ProjectSelect'
export default {
name: 'OaMeeting',
components: { ProjectSelect },
dicts: ['oa_meeting_type'],
data () {
return {
loading: true,
showSearch: true,
total: 0,
list: [],
dateRange: [],
queryParams: {
pageNum: 1,
pageSize: 10,
keyword: '',
meetingType: '',
projectId: null
}
}
},
created () {
this.getList()
},
activated () {
// 从编辑页返回时刷新
this.getList()
},
methods: {
getList () {
this.loading = true
const q = { ...this.queryParams }
if (this.dateRange && this.dateRange.length === 2) {
q.dateFrom = this.dateRange[0]
q.dateTo = this.dateRange[1]
}
listMeetingMinutes(q).then(res => {
this.list = res.rows || []
this.total = res.total || 0
}).finally(() => { this.loading = false })
},
handleQuery () {
this.queryParams.pageNum = 1
this.getList()
},
resetQuery () {
this.dateRange = []
this.queryParams.projectId = null
this.resetForm('queryForm')
this.handleQuery()
},
taskCount (row) {
return this.parseTasks(row).length
},
syncedCount (row) {
return this.parseTasks(row).filter(t => t.taskId).length
},
parseTasks (row) {
if (!row.tasksJson) return []
try {
const a = JSON.parse(row.tasksJson)
return Array.isArray(a) ? a : []
} catch (e) { return [] }
},
handleAdd () {
this.$router.push('/hint/meeting/add')
},
handleEdit (row) {
this.$router.push('/hint/meeting/edit/' + row.id)
},
handleDelete (row) {
this.$modal.confirm(`确认删除「${row.subject}」?已同步的 OA 任务不受影响。`).then(() => {
return delMeetingMinutes(row.id)
}).then(() => {
this.$modal.msgSuccess('已删除')
this.getList()
}).catch(() => {})
}
}
}
</script>