feat: 生产部署 + 会议全链路 (执行方分配/费用结算/签到脱敏/H5相机)
- 生产 nginx 三路径: ry-vue3 /hg/, ry-h5 /camera/, API /hg-api, .env 分环境 - 签约链接/摄像头 base-url 走 yml, 不再硬编码 /hg 与 localhost - 新增 biz_project_executor_assign (镜像 sponsor_assign) 执行方项目级分配 - 会议费用后台汇总 FeeCalcScheduler + 结算回写项目金额 + 状态流转调度 - 签到表拍照高斯模糊脱敏 extra_oss_url, 新增 PosterService/StageDeriver - 清理 biz_support_intent 旧表 (6 Java/XML 删除) - ry-h5 相机黑屏修复: 显式 video.play() + 动态 apiBase 上传 - 资源: simhei 字体 / logo.png / qrcode_1.png / favicon.ico Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
<!--
|
||||
扫码拍照上传控件 (会议现场照片: 签到表 / 前全景 / 后全景)
|
||||
区别于 OssFileUploader: 不本地选文件, 而是点按钮弹出二维码,
|
||||
手机扫二维码进入 ry-h5 相机页拍照, 拍照后直传 OSS 并回传 URL 到后端
|
||||
(后端 upsert 到 biz_meeting_material), 本控件轮询 material 列表,
|
||||
一旦该 subType 的 ossUrl 出现即回写 v-model (同步到页面 r.url).
|
||||
|
||||
用法:
|
||||
<CameraQrUpload
|
||||
v-model="r.url"
|
||||
:meeting-id="meetingId"
|
||||
:sub-type="r.subType"
|
||||
:label="r.label"
|
||||
:readonly="isSponsor"
|
||||
class="file-uploader"
|
||||
/>
|
||||
-->
|
||||
<template>
|
||||
<div class="camera-qr-upload" :class="{ readonly }">
|
||||
<div class="camera-row">
|
||||
<el-button type="primary" size="small" :disabled="readonly" @click="openDialog">
|
||||
<el-icon><CameraFilled /></el-icon> 扫码拍照
|
||||
</el-button>
|
||||
<a v-if="modelValue" :href="modelValue" target="_blank" class="cam-link">查看照片</a>
|
||||
<span v-else class="cam-empty">未上传</span>
|
||||
<span v-if="polling" class="cam-polling">等待手机回传…</span>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="`扫码拍照 - ${label}`"
|
||||
width="400px"
|
||||
align-center
|
||||
:close-on-click-modal="false"
|
||||
@closed="onClosed"
|
||||
>
|
||||
<div class="qr-wrap">
|
||||
<div v-if="qrLoading" class="qr-loading" v-loading="true"></div>
|
||||
<img v-else-if="qrUrl" :src="qrUrl" class="qr-img" />
|
||||
<div v-else class="qr-error">二维码生成失败, 请关闭重试</div>
|
||||
<div class="qr-tip">用手机相机扫一扫, 进入「{{ label }}」拍照页</div>
|
||||
<div class="qr-tip-sub">拍完点「使用」, 照片自动回传到本会议</div>
|
||||
<div v-if="polling" class="qr-status">等待照片回传, 请保持本弹窗打开…</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onBeforeUnmount } from 'vue'
|
||||
import request from '@/utils/request'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { CameraFilled } from '@element-plus/icons-vue'
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
meetingId: { type: [Number, String], default: '' },
|
||||
subType: { type: String, required: true },
|
||||
label: { type: String, default: '现场照片' },
|
||||
readonly: { type: Boolean, default: false }
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
/** subType → ry-h5 页面路由 (ry-h5 hash router, base=/camera/) */
|
||||
const PAGE_PATH = {
|
||||
L_SIGN_IN: '/pages/signin/index',
|
||||
L_PANORAMA_FRONT: '/pages/panorama/index',
|
||||
L_PANORAMA_BACK: '/pages/panorama/index'
|
||||
}
|
||||
|
||||
const visible = ref(false)
|
||||
const qrUrl = ref('')
|
||||
const qrLoading = ref(false)
|
||||
const polling = ref(false)
|
||||
let pollTimer = null
|
||||
let baseline = ''
|
||||
|
||||
/**
|
||||
* 生成二维码并开始轮询.
|
||||
* 目标 URL = baseUrl (后端 yml ruoyi.camera.base-url) + '#' + ry-h5 路由 + query.
|
||||
* apiBase 用当前站点 origin + VITE_APP_BASE_API (与 request.js 一致), 手机端据此调后端接口.
|
||||
* ⚠️ 开发环境要求: 执行方须用「局域网 IP」打开 ry-vue3 (如 http://192.168.x.x:5173),
|
||||
* 否则二维码里的 apiBase 是 localhost, 手机扫出来 localhost 指向手机自身, 无法回传.
|
||||
*/
|
||||
async function openDialog() {
|
||||
baseline = props.modelValue || ''
|
||||
visible.value = true
|
||||
qrLoading.value = true
|
||||
qrUrl.value = ''
|
||||
try {
|
||||
const cfg = await request.get('/common/camera/config')
|
||||
const base = (cfg && cfg.baseUrl) || ''
|
||||
if (!base) throw new Error('相机网页地址未配置 (ruoyi.camera.base-url)')
|
||||
const page = PAGE_PATH[props.subType] || '/pages/index/index'
|
||||
const apiBase = encodeURIComponent(window.location.origin + import.meta.env.VITE_APP_BASE_API)
|
||||
const target = `${base}#${page}?meetingId=${props.meetingId}&subType=${props.subType}&apiBase=${apiBase}`
|
||||
qrUrl.value = await QRCode.toDataURL(target, {
|
||||
width: 240, margin: 2, color: { dark: '#1a1a1a', light: '#ffffff' }
|
||||
})
|
||||
startPolling()
|
||||
} catch (e) {
|
||||
console.error('[camera-qr] gen qr failed', e)
|
||||
ElMessage.error(e?.msg || e?.message || '二维码生成失败')
|
||||
visible.value = false
|
||||
} finally {
|
||||
qrLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling()
|
||||
polling.value = true
|
||||
pollTimer = setInterval(pollOnce, 3000)
|
||||
pollOnce()
|
||||
}
|
||||
|
||||
/** 拉 material 列表, 该 subType 出现新 ossUrl (≠ 打开时基线) 即回写并关闭 */
|
||||
async function pollOnce() {
|
||||
try {
|
||||
const resp = await request.get(`/business/meetingMaterial/${props.meetingId}`)
|
||||
const list = (resp && (resp.data || resp)) || []
|
||||
const row = (Array.isArray(list) ? list : []).find(m => m.subType === props.subType)
|
||||
const url = row && row.ossUrl ? row.ossUrl : ''
|
||||
if (url && url !== baseline) {
|
||||
emit('update:modelValue', url)
|
||||
ElMessage.success(`${props.label}已回传`)
|
||||
stopPolling()
|
||||
visible.value = false
|
||||
}
|
||||
} catch (e) {
|
||||
/* 静默重试, 弹窗保持打开 */
|
||||
}
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||
polling.value = false
|
||||
}
|
||||
|
||||
function onClosed() {
|
||||
stopPolling()
|
||||
}
|
||||
|
||||
onBeforeUnmount(stopPolling)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.camera-qr-upload { width: 100%; min-width: 0; }
|
||||
.camera-row {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
min-height: 36px;
|
||||
}
|
||||
.cam-link { color: var(--brand-primary); text-decoration: none; font-size: 13px; }
|
||||
.cam-link:hover { text-decoration: underline; }
|
||||
.cam-empty { color: #c0c4cc; font-size: 13px; }
|
||||
.cam-polling { color: #e6a23c; font-size: 12px; }
|
||||
|
||||
.qr-wrap { text-align: center; padding: 8px 0 4px; }
|
||||
.qr-loading { width: 240px; height: 240px; margin: 0 auto; }
|
||||
.qr-img { width: 240px; height: 240px; }
|
||||
.qr-error { width: 240px; height: 240px; margin: 0 auto; display: flex; align-items: center; justify-content: center; color: #f56c6c; font-size: 13px; }
|
||||
.qr-tip { font-size: 14px; color: #1a1a1a; margin-top: 14px; }
|
||||
.qr-tip-sub { font-size: 12px; color: #999; margin-top: 6px; }
|
||||
.qr-status { font-size: 12px; color: #e6a23c; margin-top: 10px; }
|
||||
</style>
|
||||
@@ -64,8 +64,8 @@ const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
dir: { type: String, default: 'ry8080/idcard/' },
|
||||
readonly: { type: Boolean, default: false },
|
||||
frontPlaceholder: { type: String, default: '/images/id-front.png' },
|
||||
backPlaceholder: { type: String, default: '/images/id-back.png' }
|
||||
frontPlaceholder: { type: String, default: import.meta.env.BASE_URL + 'images/id-front.png' },
|
||||
backPlaceholder: { type: String, default: import.meta.env.BASE_URL + 'images/id-back.png' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
<!--
|
||||
共享通知列表组件
|
||||
------------------------------------------------------------
|
||||
自包含: 数据拉取 / SSE 实时刷新 / 详情弹窗 / 全部已读 全部封装在内.
|
||||
任何角色页面 (workbench / home / 独立 messages 页) 只需:
|
||||
<NoticeList :limit="5" /> // 嵌入式小列表 (默认有"全部已读"顶部按钮)
|
||||
<NoticeList :limit="50" :show-category="true" /> // 完整页列表 (显示分类标签)
|
||||
<NoticeList :limit="5" :show-header="false" /> // 嵌入式,父 section-title 自己带按钮
|
||||
|
||||
Props:
|
||||
limit Number 拉取条数,默认 5
|
||||
showCategory Boolean 是否显示 [通知/待办/系统] 分类标签,默认 false
|
||||
showHeader Boolean 是否显示组件自己的"全部已读"顶部按钮,默认 true
|
||||
emptyText String 空列表文案,默认 '暂无通知'
|
||||
|
||||
复用: 调用方不再写 notice-item / dot / SSE 订阅 / 详情 dialog,3 处变 1 处.
|
||||
-->
|
||||
<template>
|
||||
<div class="notice-list-wrap">
|
||||
<div v-if="showHeader" class="notice-list-header">
|
||||
<el-button v-if="hasUnread" link size="small" type="primary" @click="markAllRead">全部已读</el-button>
|
||||
</div>
|
||||
<ul class="notice-list">
|
||||
<li
|
||||
v-for="n in rows"
|
||||
:key="n.id"
|
||||
class="notice-item"
|
||||
:class="{ read: n.read }"
|
||||
@click="openDetail(n)"
|
||||
>
|
||||
<div class="dot"></div>
|
||||
<div class="body">
|
||||
<div class="title">
|
||||
<span v-if="showCategory && n.category" class="cat" :class="catClass(n)">{{ n.category }}</span>
|
||||
{{ n.title }}
|
||||
</div>
|
||||
<div class="text">{{ n.text }}</div>
|
||||
</div>
|
||||
<span class="time">{{ n.time }}</span>
|
||||
</li>
|
||||
<li v-if="!rows.length" class="empty">{{ emptyText }}</li>
|
||||
</ul>
|
||||
|
||||
<el-dialog
|
||||
v-model="detailOpen"
|
||||
:title="detail.title || '消息详情'"
|
||||
width="520px"
|
||||
align-center
|
||||
destroy-on-close
|
||||
>
|
||||
<div class="detail-body">
|
||||
<div class="meta">
|
||||
<span v-if="detail.category" class="cat" :class="catClass(detail)">{{ detail.category }}</span>
|
||||
<span class="time">{{ detail.time }}</span>
|
||||
</div>
|
||||
<div class="text">{{ detail.content || detail.text || '-' }}</div>
|
||||
<div v-if="detailLink" class="detail-link">
|
||||
<img v-if="detailQrUrl" :src="detailQrUrl" class="detail-qr" alt="链接二维码" />
|
||||
<div class="detail-link-row">
|
||||
<el-link type="primary" :href="detailLink" target="_blank" :underline="false">打开链接</el-link>
|
||||
<el-link type="primary" :underline="false" @click="copyDetailLink">复制链接</el-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="detailOpen = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import QRCode from 'qrcode'
|
||||
import { listMyMessages, markMessageRead, markAllMessagesRead } from '@/api/public'
|
||||
import { onGlobal } from '@/utils/sseClient'
|
||||
|
||||
const props = defineProps({
|
||||
limit: { type: Number, default: 5 },
|
||||
showCategory: { type: Boolean, default: false },
|
||||
showHeader: { type: Boolean, default: true },
|
||||
emptyText: { type: String, default: '暂无通知' }
|
||||
})
|
||||
|
||||
const rows = ref([])
|
||||
const detailOpen = ref(false)
|
||||
const detail = ref({})
|
||||
const detailLink = ref('')
|
||||
const detailQrUrl = ref('')
|
||||
const hasUnread = computed(() => rows.value.some(n => !n.read))
|
||||
|
||||
let unsubscribeNewMessage = null
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const r = await listMyMessages({ limit: props.limit })
|
||||
rows.value = r.rows || []
|
||||
} catch (e) {
|
||||
rows.value = []
|
||||
}
|
||||
}
|
||||
|
||||
/** SSE 触发的静默重拉 (失败不打扰用户, 下次手动刷新可见) */
|
||||
async function refresh() {
|
||||
try {
|
||||
const r = await listMyMessages({ limit: props.limit })
|
||||
rows.value = r.rows || []
|
||||
} catch (e) { /* swallow */ }
|
||||
}
|
||||
|
||||
/** 从消息正文里抽取第一个 http(s) 链接, 去尾随标点 */
|
||||
function extractLink(content) {
|
||||
if (!content) return ''
|
||||
const m = String(content).match(/https?:\/\/[^\s"'<>]+/)
|
||||
if (!m) return ''
|
||||
return m[0].replace(/[。,、;:,.!?;:))\]]+$/, '')
|
||||
}
|
||||
|
||||
/** 点列表项: 打开详情 + 乐观本地标已读 + 后端 markRead (后端会再推 SSE, navbar 角标自动减) */
|
||||
function openDetail(n) {
|
||||
detail.value = n
|
||||
detailOpen.value = true
|
||||
const link = extractLink(n.content)
|
||||
detailLink.value = link
|
||||
detailQrUrl.value = ''
|
||||
if (link) {
|
||||
QRCode.toDataURL(link, { width: 200, margin: 2, color: { dark: '#1a1a1a', light: '#ffffff' } })
|
||||
.then(url => { detailQrUrl.value = url })
|
||||
.catch(() => { detailQrUrl.value = '' })
|
||||
}
|
||||
if (!n.read) {
|
||||
n.read = true
|
||||
markMessageRead(n.id).catch(() => { n.read = false })
|
||||
}
|
||||
}
|
||||
|
||||
/** 复制消息正文中的链接 */
|
||||
async function copyDetailLink() {
|
||||
if (!detailLink.value) return
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(detailLink.value)
|
||||
} else {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = detailLink.value
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.opacity = '0'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
ElMessage.success('链接已复制')
|
||||
} catch (e) {
|
||||
ElMessage.error('复制失败, 请手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
/** 全部已读: 后端 markAllRead (后端会推 silent=true SSE, 角标自动清零) + 本地乐观更新 */
|
||||
async function markAllRead() {
|
||||
try {
|
||||
await markAllMessagesRead()
|
||||
rows.value = rows.value.map(n => ({ ...n, read: true }))
|
||||
} catch (e) {
|
||||
ElMessage.error('全部已读失败, 请重试')
|
||||
}
|
||||
}
|
||||
|
||||
/** type 字段 (后端 BizMessage.msgType): '1'通知 '2'待办 '3'系统 */
|
||||
function catClass(n) {
|
||||
if (n.type === '1') return 'cat-notif'
|
||||
if (n.type === '2') return 'cat-todo'
|
||||
if (n.type === '3') return 'cat-sys'
|
||||
return ''
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
// SSE 全局总线 (AdminLayout 维护连接, 这里订阅刷新本组件列表即可)
|
||||
// toast 由 AdminLayout 统一弹, 这里只负责本组件 list 实时刷新
|
||||
unsubscribeNewMessage = onGlobal(() => {
|
||||
refresh()
|
||||
})
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (unsubscribeNewMessage) unsubscribeNewMessage()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notice-list-wrap { display: flex; flex-direction: column; }
|
||||
.notice-list-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 4px 0 8px;
|
||||
}
|
||||
|
||||
/* 列表 (跟 doctor/Home.vue 旧内联样式保持像素一致) */
|
||||
.notice-list { list-style: none; border-top: 1px solid #f0f0f0; }
|
||||
.notice-item {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.notice-item:last-child { border-bottom: none; }
|
||||
.notice-item:hover .title { color: var(--brand-primary); }
|
||||
.notice-item .dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--brand-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.notice-item.read .dot { background: transparent; border: 1px solid #d9d9d9; }
|
||||
.notice-item .body { flex: 1; min-width: 0; }
|
||||
.notice-item .title {
|
||||
font-size: 13px;
|
||||
color: #1a1a1a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.notice-item.read .title { color: #8c8c8c; }
|
||||
.notice-item .text {
|
||||
font-size: 12px;
|
||||
color: #595959;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.notice-item .time {
|
||||
font-size: 12px;
|
||||
color: #bfbfbf;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 分类标签 (按后端 msgType 染色: 1=通知蓝 2=待办橙 3=系统紫) */
|
||||
.cat {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 2px;
|
||||
background: #f0f0f0;
|
||||
color: #595959;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cat-sys { background: #f9f0ff; color: #722ed1; }
|
||||
.cat-todo { background: #fff7e6; color: #fa8c16; }
|
||||
.cat-notif { background: #e6f7ff; color: var(--brand-primary); }
|
||||
|
||||
.empty {
|
||||
padding: 24px 0;
|
||||
text-align: center;
|
||||
color: #bfbfbf;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 详情 dialog */
|
||||
.detail-body .meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.detail-body .meta .time {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
.detail-body .text {
|
||||
font-size: 14px;
|
||||
color: #1a1a1a;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.detail-link {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
text-align: center;
|
||||
}
|
||||
.detail-link .detail-qr {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
display: block;
|
||||
margin: 0 auto 12px;
|
||||
}
|
||||
.detail-link-row {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -15,6 +15,9 @@
|
||||
<span class="placeholder-text">{{ placeholder }}</span>
|
||||
<span class="placeholder-hint" v-if="hint">{{ hint }}</span>
|
||||
</div>
|
||||
<div v-else-if="!modelValue && readonly" class="ht-file-placeholder readonly-empty">
|
||||
<span class="placeholder-text">未上传</span>
|
||||
</div>
|
||||
<div v-else-if="modelValue" class="ht-file-info">
|
||||
<el-icon class="file-icon" :size="22"><svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 7V3.5L18.5 9H13z"/>
|
||||
|
||||
@@ -78,9 +78,7 @@
|
||||
</div>
|
||||
<div class="footer-qr">
|
||||
<div class="qr-image">
|
||||
<svg width="52" height="52" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 11h8V3H3v8zm2-6h4v4H5V5zm8-2v8h8V3h-8zm6 6h-4V5h4v4zM3 21h8v-8H3v8zm2-6h4v4H5v-4zm13-2h-2v2h2v-2zm-2 2h-2v2h2v-2zm2 2h-2v2h2v-2zm2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm0 0h-2v2h2v-2z"/>
|
||||
</svg>
|
||||
<img src="/qrcode_1.png" alt="公众号二维码" style="width:100%;height:100%;object-fit:contain;" />
|
||||
</div>
|
||||
<div class="qr-label">公众号二维码</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<!--
|
||||
项目级角色多选控件 (带"其他"逃生口)
|
||||
|
||||
数据来源: 父组件传入 props.roles (biz_project.role_labor JSON, 形如 [{role, customName, amount}])
|
||||
v-model = 逗号分隔字符串 (如 "主席,主持,特约嘉宾"), "其他"自定义角色作为普通项存储.
|
||||
|
||||
行为契约:
|
||||
- 勾选项目角色 → 该项进入 modelValue (用角色名 label)
|
||||
- 勾选 "其他" (控件自己虚拟追加) → 下方 el-input 出现 → 输入内容作为普通项进入 modelValue
|
||||
- 回填: modelValue 里不在项目角色列表里的项 → 自动按 "其他 + input" 显示
|
||||
- 兼容旧数据: modelValue 若为 JSON 数组字符串 (["主席","__other__:xx"]) 也能解析
|
||||
|
||||
用法:
|
||||
<project-role-multi-select v-model="attendeeDialog.form.laborForm" :roles="projectRoles" />
|
||||
-->
|
||||
<div class="project-role-multi">
|
||||
<el-checkbox-group v-model="selectedKeys" @change="onToggle">
|
||||
<el-checkbox v-for="o in displayOptions" :key="o.key" :value="o.key" :label="o.label" />
|
||||
</el-checkbox-group>
|
||||
<el-input
|
||||
v-if="showOtherInput"
|
||||
v-model="otherText"
|
||||
placeholder="请输入其他角色"
|
||||
class="other-input"
|
||||
maxlength="50"
|
||||
@input="emitValue"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
/** 项目级角色数组: [{role, customName, amount}] (来自 biz_project.role_labor JSON 解析) */
|
||||
roles: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
// "其他" 虚拟项内部 key — 不暴露给父组件, 仅用于区分选中状态
|
||||
const OTHER_KEY = '__other__'
|
||||
|
||||
// 把 props.roles 投影成 {key, label} 数组 (label = role 或"其他"的 customName)
|
||||
const projectOptions = computed(() => {
|
||||
return (props.roles || [])
|
||||
.filter(r => r && (r.role || r.customName))
|
||||
.map(r => {
|
||||
if (r.role === '其他') {
|
||||
const customName = (r.customName || '').trim()
|
||||
return { key: customName, label: customName || '其他' }
|
||||
}
|
||||
return { key: r.role, label: r.role }
|
||||
})
|
||||
// 同 key 去重 (防御性: 项目里手填了两个相同 role)
|
||||
.filter((o, i, arr) => arr.findIndex(x => x.key === o.key) === i)
|
||||
// 去掉与 OTHER_KEY 冲突的 (理论上不会, 但防御)
|
||||
.filter(o => o.key !== OTHER_KEY)
|
||||
})
|
||||
|
||||
const selectedKeys = ref([])
|
||||
const otherText = ref('')
|
||||
|
||||
const showOtherInput = computed(() => selectedKeys.value.includes(OTHER_KEY))
|
||||
const displayOptions = computed(() => [...projectOptions.value, { key: OTHER_KEY, label: '其他' }])
|
||||
|
||||
/** 构建逗号分隔字符串 ("其他"自定义内容作为普通项) */
|
||||
function buildValue() {
|
||||
return selectedKeys.value
|
||||
.map(k => (k === OTHER_KEY ? (otherText.value || '').trim() : k))
|
||||
.filter(x => x !== '')
|
||||
.join(',')
|
||||
}
|
||||
|
||||
/** 解析 modelValue (兼容 JSON 数组 + 逗号分隔) → selectedKeys + otherText */
|
||||
function syncFromModelValue(v) {
|
||||
let items = []
|
||||
if (typeof v === 'string' && v.trim().startsWith('[')) {
|
||||
try { const p = JSON.parse(v); if (Array.isArray(p)) items = p } catch (e) { items = [v] }
|
||||
} else if (v) {
|
||||
items = String(v).split(',').map(s => s.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
const selected = []
|
||||
let other = ''
|
||||
const known = new Set(projectOptions.value.map(o => o.key))
|
||||
items.forEach(it => {
|
||||
const s = String(it)
|
||||
if (s === OTHER_KEY || s.startsWith(OTHER_KEY + ':')) {
|
||||
if (!selected.includes(OTHER_KEY)) selected.push(OTHER_KEY)
|
||||
if (s.startsWith(OTHER_KEY + ':')) other = s.substring((OTHER_KEY + ':').length)
|
||||
} else if (known.has(s)) {
|
||||
if (!selected.includes(s)) selected.push(s)
|
||||
} else {
|
||||
// 不在项目角色列表里 → "其他"自定义
|
||||
if (!selected.includes(OTHER_KEY)) selected.push(OTHER_KEY)
|
||||
other = s
|
||||
}
|
||||
})
|
||||
selectedKeys.value = selected
|
||||
otherText.value = other
|
||||
}
|
||||
|
||||
const lastEmitted = ref('')
|
||||
function emitValue() {
|
||||
const v = buildValue()
|
||||
lastEmitted.value = v
|
||||
emit('update:modelValue', v)
|
||||
emit('change', v)
|
||||
}
|
||||
function onToggle() { emitValue() }
|
||||
|
||||
// 初始化 + 响应外部变化 (跳过自己 emit 的回显, 避免重解析覆盖用户输入)
|
||||
syncFromModelValue(props.modelValue)
|
||||
watch(() => props.modelValue, v => {
|
||||
if (v === lastEmitted.value) return
|
||||
syncFromModelValue(v)
|
||||
})
|
||||
// 项目角色列表变化后重做一次同步 (例如 load() 后异步拿到 roles)
|
||||
watch(() => props.roles, () => syncFromModelValue(props.modelValue), { deep: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.project-role-multi { display: flex; flex-direction: column; gap: 6px; width: 100%; }
|
||||
.other-input { width: 100%; }
|
||||
</style>
|
||||
@@ -1,153 +0,0 @@
|
||||
<template>
|
||||
<!--
|
||||
项目级角色下拉控件 (带"其他"逃生口)
|
||||
|
||||
数据来源: 父组件传入 props.roles (来自 biz_project.role_labor JSON, 形如 [{role, customName, amount}])
|
||||
- role === '其他' → 显示 customName 作为 label
|
||||
- role !== '其他' → 显示 role 作为 label
|
||||
|
||||
行为契约 (前端控件自管理, 父组件无感知):
|
||||
- 标准项目角色: 用户从下拉选 → v-model = 该角色名
|
||||
- 选 "其他" (控件自己虚拟追加, 不来自项目定义): 下方出现 el-input → v-model = input 内容
|
||||
- 回填: modelValue 不在项目角色列表里 → 自动按 "其他 + input" 显示, input 预填原值
|
||||
|
||||
用法:
|
||||
<project-role-select v-model="attendeeDialog.form.laborForm" :roles="projectRoles" />
|
||||
-->
|
||||
<div class="project-role-select">
|
||||
<el-select
|
||||
v-model="selectedKey"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:clearable="clearable"
|
||||
:filterable="filterable"
|
||||
style="width: 100%"
|
||||
@change="handleSelectChange"
|
||||
@clear="handleClear"
|
||||
>
|
||||
<el-option
|
||||
v-for="o in displayOptions"
|
||||
:key="o.key"
|
||||
:label="o.label"
|
||||
:value="o.key"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input
|
||||
v-if="showOtherInput"
|
||||
v-model="otherText"
|
||||
:placeholder="otherPlaceholder"
|
||||
class="other-input"
|
||||
maxlength="50"
|
||||
@input="emitValue"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
/** 项目级角色数组: [{role, customName, amount}] (来自 biz_project.role_labor JSON 解析) */
|
||||
roles: { type: Array, default: () => [] },
|
||||
placeholder: { type: String, default: '请选择角色' },
|
||||
otherPlaceholder: { type: String, default: '请输入角色名' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
clearable: { type: Boolean, default: true },
|
||||
filterable: { type: Boolean, default: true }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
// "其他" 虚拟项的内部 key — 不暴露给父组件, 仅用于区分 selectedKey 状态
|
||||
const OTHER_KEY = '__other_role__'
|
||||
|
||||
// 把 props.roles 投影成 {key, label} 数组
|
||||
// - role === '其他' → label = customName (项目里实际填的"自定义角色名")
|
||||
// - role !== '其他' → label = role
|
||||
const projectOptions = computed(() => {
|
||||
return (props.roles || [])
|
||||
.filter(r => r && (r.role || r.customName))
|
||||
.map(r => {
|
||||
if (r.role === '其他') {
|
||||
const customName = (r.customName || '').trim()
|
||||
return { key: customName, label: customName || '其他' }
|
||||
}
|
||||
return { key: r.role, label: r.role }
|
||||
})
|
||||
// 同 key 去重 (防御性: 项目里手填了两个相同 role)
|
||||
.filter((o, i, arr) => arr.findIndex(x => x.key === o.key) === i)
|
||||
// 去掉与 OTHER_KEY 冲突的 (理论上不会, 但防御)
|
||||
.filter(o => o.key !== OTHER_KEY)
|
||||
})
|
||||
|
||||
const selectedKey = ref(props.modelValue || null)
|
||||
const otherText = ref(props.modelValue || '')
|
||||
|
||||
const showOtherInput = computed(() => selectedKey.value === OTHER_KEY)
|
||||
|
||||
// 给 el-select 渲染的 options = 项目角色 + 末尾虚拟"其他"
|
||||
const displayOptions = computed(() => [
|
||||
...projectOptions.value,
|
||||
{ key: OTHER_KEY, label: '其他' }
|
||||
])
|
||||
|
||||
function isValueInOptions(v) {
|
||||
if (v == null || v === '') return false
|
||||
return projectOptions.value.some(o => o.key === v)
|
||||
}
|
||||
|
||||
function syncFromModelValue(v) {
|
||||
if (v == null || v === '') {
|
||||
selectedKey.value = null
|
||||
otherText.value = ''
|
||||
return
|
||||
}
|
||||
if (isValueInOptions(v)) {
|
||||
selectedKey.value = v
|
||||
otherText.value = ''
|
||||
} else {
|
||||
// 不在项目角色列表里 → 走 "其他" 路径
|
||||
selectedKey.value = OTHER_KEY
|
||||
otherText.value = v
|
||||
}
|
||||
}
|
||||
|
||||
function emitValue() {
|
||||
let v
|
||||
if (selectedKey.value === OTHER_KEY) {
|
||||
v = otherText.value || ''
|
||||
} else if (selectedKey.value == null) {
|
||||
v = ''
|
||||
} else {
|
||||
v = selectedKey.value
|
||||
}
|
||||
emit('update:modelValue', v)
|
||||
emit('change', v)
|
||||
}
|
||||
|
||||
function handleSelectChange(key) {
|
||||
if (key === OTHER_KEY && selectedKey.value !== OTHER_KEY) {
|
||||
otherText.value = ''
|
||||
}
|
||||
selectedKey.value = key
|
||||
emitValue()
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
selectedKey.value = null
|
||||
otherText.value = ''
|
||||
emitValue()
|
||||
}
|
||||
|
||||
// 初始化 + 响应外部变化
|
||||
syncFromModelValue(props.modelValue)
|
||||
watch(() => props.modelValue, v => syncFromModelValue(v))
|
||||
// 项目角色列表变化后重做一次同步 (例如 load() 后异步拿到 roles)
|
||||
watch(() => props.roles, () => syncFromModelValue(props.modelValue), { deep: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.project-role-select { display: flex; flex-direction: column; gap: 6px; width: 100%; }
|
||||
.other-input { width: 100%; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user