Merge remote-tracking branch 'origin/feature_民主管理、人员管理' into release_20261030问题整改

# Conflicts:
#	src/main/resources/static/components/plugins/sysSignature/pc.vue
This commit is contained in:
2026-10-31 08:46:38 +08:00
63 changed files with 1165 additions and 504 deletions
+71 -26
View File
@@ -10,22 +10,23 @@ const act = {
<div ref="activitySwiper" class="swiper activity-swiper">
<div class="swiper-wrapper">
<div class="swiper-slide" v-for="(activity, index) in actList" :key="activity.id">
<div class="item" @click="go(activity)">
<div class="item" role="button" tabindex="0" @click="go(activity)" @keydown.enter="go(activity)" @keydown.space.prevent="go(activity)">
<div class="img-box">
<img :src="activity.cover || 'https://www.ncu.edu.cn/__local/8/52/2C/644986B4C9A030F7B65300178A6_8679CB08_18467.jpg'"
alt="">
<span class="status">
<el-image :src="activity.cover" fit="contain" :alt="activity.name">
<div slot="error" class="portal-cover-empty"><i class="fa fa-picture-o" aria-hidden="true"></i><span>暂无图片</span></div>
</el-image>
<span class="status" :class="{'status-ended': getStatusText(activity) === '已结束', 'status-upcoming': getStatusText(activity) === '未开始'}">
{{getStatusText(activity)}}
</span>
</div>
<h4 class="title">
<h4 class="title" :title="activity.name">
{{ activity.name}}
</h4>
<!--报名时间-->
<div class="time">
<p>
<p :title="[activity.startDate, activity.endDate].filter(Boolean).join(' 至 ')">
<i class="fa fa-clock-o"></i>
时间:{{ activity.startDate }}至{{ activity.endDate }}
{{ formatActivityDate(activity.startDate) }} 至 {{ formatActivityDate(activity.endDate) }}
</p>
</div>
</div>
@@ -34,20 +35,21 @@ const act = {
</div>
<!-- 导航按钮 -->
<div ref="nextButton" class="swiper-button-next"></div>
<div ref="prevButton" class="swiper-button-prev"></div>
<button type="button" ref="nextButton" class="swiper-button-next" aria-label="下一组活动"></button>
<button type="button" ref="prevButton" class="swiper-button-prev" aria-label="上一组活动"></button>
<!-- 进度条 -->
<div ref="pagination" class="swiper-pagination"></div>
</div>
<div v-else class="no-activity-placeholder">
<div class="icon">📅</div>
<div class="icon"><i class="fa fa-calendar" aria-hidden="true"></i></div>
<p>暂无近期活动</p>
<p class="subtext">敬请关注后续通知</p>
</div>
</div>
<slot></slot>
</div>
`,
data() {
@@ -57,19 +59,57 @@ const act = {
}
},
methods: {
/*查询活动*/
// 轮播初始化或尺寸变化后,将左右箭头对齐当前封面的垂直中心。
updateNavigationPosition() {
this.syncBrowseState()
const swiperElement = this.$refs.activitySwiper
const cover = swiperElement && swiperElement.querySelector('.img-box')
if (!cover) return
// 按实际封面高度定位,避免标题换行或响应式列数变化导致箭头偏移。
const container = swiperElement.parentElement
const top = cover.getBoundingClientRect().top - container.getBoundingClientRect().top + cover.offsetHeight / 2
;[this.$refs.prevButton, this.$refs.nextButton].forEach(button => {
if (button) button.style.top = top + 'px'
})
},
// value 为接口返回的活动日期;无日期时显示待定,避免展示 Invalid date。
formatActivityDate(value) {
return value ? this.$moment(value).format('YYYY-MM-DD') : '待定'
},
// 按轮播实际停靠点判断是否还有内容,尺寸改变后同步首页按钮文案和可用状态。
syncBrowseState() {
const enabled = !!(this.swiper && !this.swiper.destroyed && !this.swiper.isLocked && this.swiper.snapGrid.length > 1)
this.$emit('browse-state', {
enabled: enabled,
label: enabled ? '浏览更多' : (this.actList.length ? '已展示全部活动' : '暂无活动')
})
},
// 点击前刷新尺寸,避免异步布局导致轮播状态过期;到末组后返回首组。
browseNext() {
if (!this.swiper || this.swiper.destroyed) this.initSwiper()
if (!this.swiper) return
this.swiper.update()
this.syncBrowseState()
if (this.swiper.isLocked || this.swiper.snapGrid.length <= 1) return
this.swiper.isEnd ? this.swiper.slideTo(0) : this.swiper.slideNext()
},
// 无请求参数;data 为当前用户可见且尚未结束的活动数组,保留本校筛选规则。
listAct() {
this.$axios.post("/platform/home/listHomeActivity").then((res) => {
if (res.code === 0) {
// for (let i = 0; i < 10; i++) {
// this.actList = this.actList.concat(res.data)
// }
this.actList = res.data
this.actList = res.data || []
// 数据加载完成后初始化Swiper
this.$nextTick(() => {
this.initSwiper()
})
} else {
this.$emit('browse-state', {enabled: false, label: '活动加载失败'})
}
}).catch(() => {
this.$emit('browse-state', {enabled: false, label: '活动加载失败'})
})
},
/*初始化Swiper*/
@@ -80,6 +120,7 @@ const act = {
}
const swiperElement = this.$refs.activitySwiper
if (!swiperElement) {
this.syncBrowseState()
return
}
// 仅操作当前组件的轮播节点,避免页面其他轮播或尺寸观察器影响卡片宽度。
@@ -89,13 +130,22 @@ const act = {
centeredSlides: false,
loop: false,
watchOverflow: true,
// 双栏中的活动按容器宽度排布,避免用整个窗口宽度挤出过多卡片。
breakpointsBase: 'container',
observer: false,
observeParents: false,
resizeObserver: false,
// 页面内容加载后可能出现滚动条,需重新计算列宽,避免末张封面及圆角被裁切。
resizeObserver: true,
roundLengths: true,
on: {
init: () => this.$nextTick(() => this.updateNavigationPosition()),
resize: () => this.$nextTick(() => this.updateNavigationPosition()),
slidesUpdated: () => this.$nextTick(() => this.updateNavigationPosition()),
},
// 鼠标停留在活动区域时可横向浏览,到首尾后释放页面滚动。
mousewheel: {
enabled: true,
forceToAxis: false,
forceToAxis: true,
releaseOnEdges: true,
sensitivity: 0.8,
},
@@ -108,17 +158,15 @@ const act = {
type: 'progressbar',
},
breakpoints: {
768: {
480: {
slidesPerView: 2,
},
1024: {
slidesPerView: 4,
},
1200: {
slidesPerView: 5,
740: {
slidesPerView: 3,
}
}
})
this.syncBrowseState()
},
// 活动状态
@@ -308,12 +356,9 @@ const act = {
height: 100% !important;
max-width: 100% !important;
max-height: 128px !important;
object-fit: fill !important;
transition: transform 0.3s ease;
}
.activity-swiper .swiper-slide .item:hover .img-box img {
transform: scale(1.1);
/* 活动海报含有文字,等比完整显示且悬停时不放大,避免内容被裁切。 */
object-fit: contain !important;
object-position: center;
}
.activity-swiper .swiper-slide .item h4 {
@@ -1,18 +1,19 @@
const entry = {
template: /*language=HTML*/ `
<div class="entry-wrapper">
<div :class="['entry-section', 'entry-section-' + section.key]" v-for="section in entrySections" :key="section.key">
<div class="entry-section-header">
<div class="entry-section-title">
<i :class="section.icon"></i>
<span>{{ section.title }}</span>
<em>/Applications</em>
</div>
<div class="portal-entry-header">
<div class="portal-entry-tabs" role="tablist" aria-label="推荐入口">
<button v-for="section in entrySections" :key="section.key"
type="button" role="tab" :aria-selected="activeSection === section.key"
:class="{active: activeSection === section.key}" @click="switchSection(section.key)">{{ section.title }}</button>
</div>
<a class="portal-more" href="/platform/v4/apps">全部应用 <i class="fa fa-arrow-right" aria-hidden="true"></i></a>
</div>
<div :class="['entry-section', 'entry-section-' + section.key]" v-for="section in entrySections" :key="section.key" v-show="activeSection === section.key" role="tabpanel">
<div class="apps-container">
<div class="app-grid">
<div class="app-item"
<div class="app-grid" tabindex="0" :aria-label="section.title + ',可横向滚动浏览'">
<button type="button" class="app-item"
v-for="(item, index) in section.list"
:key="item.id || index"
@click="openService(item, section.key)">
@@ -20,8 +21,8 @@ const entry = {
<img v-if="item.picIcon" class="app-icon-img" :src="item.picIcon" alt="" />
<i v-else class="fa fa-cube app-icon-fallback"></i>
</div>
<div class="app-name">{{ item.name }}</div>
</div>
<div class="app-name" :title="item.name">{{ item.name }}</div>
</button>
<el-empty class="app-empty"
v-if="!section.list || section.list.length === 0"
description="暂无数据"
@@ -30,10 +31,12 @@ const entry = {
</div>
</div>
</div>
<slot></slot>
</div>
`,
data() {
return {
activeSection: 'serv',
hasMore: true,
entries: {
serv: [],
@@ -57,8 +60,12 @@ const entry = {
this.getFavorite()
},
methods: {
// 查询推荐服务
async getRecommendService() {
// sectionKey 为 serv(推荐服务)、app(推荐应用)或 fav(我的收藏);通过组件方法更新栏目,避免内联事件中 this 指向错误。
switchSection(sectionKey) {
this.$set(this, 'activeSection', sectionKey)
},
// platform 固定传 PC;Result.data 为当前用户有权限的推荐服务数组。
getRecommendService() {
this.$axios.post('/platform/home/listRecommendService', {platform: "PC"}).then(res => {
if (res.code === 0) {
this.entries.serv = res.data
@@ -66,8 +73,8 @@ const entry = {
})
},
// 获取推荐应用
async getRecommendApp() {
// platform 固定传 PC;Result.data 为当前用户可进入的推荐应用数组。
getRecommendApp() {
this.$axios.post('/platform/home/listRecommendApp', {platform: "PC"}).then(res => {
if (res.code === 0) {
this.entries.app = res.data
@@ -75,8 +82,8 @@ const entry = {
})
},
// 获取我的收藏
async getFavorite() {
// platform 固定传 PC;Result.data 为当前用户收藏且有权限的菜单数组。
getFavorite() {
this.$axios.post('/platform/home/listFavorite', {platform: "PC"}).then(res => {
if (res.code === 0) {
this.entries.fav = res.data
@@ -127,11 +134,6 @@ const entry = {
margin-top: 22px;
}
/* 临时屏蔽“我的收藏”分组,保留原节点和数据逻辑便于恢复 */
.entry-section-fav {
display: none;
}
.entry-section-header {
display: flex;
align-items: center;
@@ -181,42 +181,65 @@ layout("/layouts/v4/baseLayout.html"){
</style>
<div class="v4-container" id="v4-home-app">
<link rel="stylesheet" href="${base!}/assets/platform/css/home-portal.css?v=20261030-1" />
<!-- 登录资料仅在 HTML 属性中输出并转义;放在 Vue 挂载区域外,避免名称被当作模板表达式解析。 -->
<div id="v4-home-profile-data" hidden
data-username="${@auth.getPrincipalProperty('username'),escape}"
data-loginname="${@auth.getPrincipalProperty('loginname'),escape}"
<!--#if(!isEmpty(@auth.getPrincipalProperty('unit'))){#-->
data-unit-name="${@auth.getPrincipalProperty('unit').getName(),escape}"
<!--#}#-->
<!--#if(!isEmpty(@auth.getPrincipalProperty('union'))){#-->
data-union-name="${@auth.getPrincipalProperty('union').getName(),escape}"
<!--#}#-->
></div>
<div class="v4-container home-portal" id="v4-home-app">
<!-- 首页banner -->
<div class="section-banner">
<div class="banner-img">
<img :src="homeBannerList[0] || ''" alt=""/>
<stats></stats>
<div class="portal-top">
<div class="portal-hero">
<!-- 使用参考项目的本地背景资源,保留横幅文字及渐变遮罩。 -->
<img class="portal-hero-background" src="${base!}/assets/platform/img/home/service-portal-banner-campus-v4.png" alt="" />
<div class="portal-hero-inner">
<div class="portal-hero-copy">
<div class="portal-hero-eyebrow">教职工一站式服务平台</div>
<h1>智慧工会系统</h1>
<p>情系教职工,服务零距离</p>
<div class="portal-values">
<span><i class="fa fa-heart" aria-hidden="true"></i>服务职工</span>
<span><i class="fa fa-users" aria-hidden="true"></i>凝聚职工</span>
<span><i class="fa fa-leaf" aria-hidden="true"></i>关爱职工</span>
<span><i class="fa fa-file-text" aria-hidden="true"></i>助力发展</span>
</div>
</div>
</div>
</div>
<user class="portal-member" :profile-only="true" :profile="memberProfile"></user>
</div>
<div class="" style="padding-bottom: 20px">
<user></user>
<div class="home-grid">
<div class="portal-content">
<div class="entry-card">
<entry></entry>
</div>
</div>
<div class="home-grid">
<div class="home-section">
<div class="portal-columns">
<div class="home-section portal-panel portal-activities">
<div class="home-section-header">
<div class="home-section-title">
<i class="fa fa-calendar"></i>
<span>最新活动</span>
<em>/Activities</em>
</div>
</div>
<button type="button" class="portal-more" :disabled="!activityBrowse.enabled" @click="$refs.homeActivities.browseNext()">{{ activityBrowse.label }} <i v-if="activityBrowse.enabled" class="fa fa-arrow-right" aria-hidden="true"></i></button>
</div>
<div class="act-card">
<act></act>
<act ref="homeActivities" @browse-state="updateActivityBrowse"></act>
</div>
</div>
<user ref="homeTodo" class="portal-todo"></user>
<work-template class="portal-templates" v-if="$auth.hasRoleOr(['SYSADMIN','SCHOOL_UNION_ADMIN'])"></work-template>
<jcdt class="portal-news" :class="{'portal-news-wide': !$auth.hasRoleOr(['SYSADMIN','SCHOOL_UNION_ADMIN'])}" :list="websiteNews"></jcdt>
</div>
<work-template v-if="$auth.hasRoleOr(['SYSADMIN','SCHOOL_UNION_ADMIN'])"></work-template>
<jcdt :list="websiteNews"></jcdt>
</div>
</div>
@@ -233,9 +256,17 @@ layout("/layouts/v4/baseLayout.html"){
new Vue({
el: '#v4-home-app',
data(){
// 浏览器读取属性时还原 HTML 转义;未配置单位或工会时使用空字符串,不访问空对象。
const profileData = document.getElementById('v4-home-profile-data').dataset
return{
memberProfile: {
username: profileData.username || '',
loginname: profileData.loginname || '',
unitName: profileData.unitName || '',
unionName: profileData.unionName || ''
},
websiteNews: [],
homeBannerList: "${config.AppHomeImg!}".split(",").map(item => item.trim()).filter(item => item)
activityBrowse: {enabled: false, label: '活动加载中'}
}
},
components: {
@@ -250,10 +281,14 @@ layout("/layouts/v4/baseLayout.html"){
this.getWebSiteNews()
},
methods: {
// state 由活动组件发布,enabled 表示可以翻页,label 说明加载或全部展示状态。
updateActivityBrowse(state) {
this.$set(this, 'activityBrowse', state)
},
getWebSiteNews(){
this.$axios.post('/platform/home/getNews').then(res => {
if (res.code === 0) {
this.websiteNews = res.data
this.$set(this, 'websiteNews', res.data || [])
}
})
}
@@ -2,44 +2,72 @@ const jcdt = {
template: /*language=HTML*/ `
<div class="jcdt-wrapper">
<div class="jcdt-container">
<el-carousel height="360px" :interval="10000" indicator-position="none">
<el-carousel-item v-for="item,index in list" :key="index">
<div class="jcdt-title">
<span>{{ item.label }}</span>
</div>
<div class="jcdt-content">
<div class="jcdt-list">
<div class="jcdt-item" v-for="o,i in item.value" :key="i" @click="onLink(o)">
<div class="date" v-if="o.time">
<i class="el-icon-alarm-clock"></i>
<span>{{o.time}}</span>
</div>
<div style="display: flex; align-items: center">
<div>
</div>
<div class="title">{{o.text}}</div>
</div>
</div>
</div>
</div>
</el-carousel-item>
</el-carousel>
<div class="portal-news-header">
<el-dropdown @command="selectSection" trigger="click">
<button type="button" class="portal-news-title"><i class="fa fa-bullhorn" aria-hidden="true"></i> {{ currentNews.label }} <i v-if="list.length > 1" class="fa fa-angle-down" aria-hidden="true"></i></button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item v-for="(section, index) in list" :key="index" :command="index">{{ section.label }}</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
<div v-if="pageCount > 1" class="portal-news-pager" aria-label="动态分页">
<span class="portal-news-count">共 {{ currentNews.value.length }} 条</span>
<button type="button" :disabled="pageNumber === 1" title="上一页动态" aria-label="上一页动态" @click="changePage(-1)"><i class="fa fa-angle-left" aria-hidden="true"></i></button>
<span class="portal-news-page" aria-live="polite">{{ pageNumber }} / {{ pageCount }}</span>
<button type="button" :disabled="pageNumber === pageCount" title="下一页动态" aria-label="下一页动态" @click="changePage(1)"><i class="fa fa-angle-right" aria-hidden="true"></i></button>
</div>
</div>
<div class="portal-news-list">
<button type="button" class="portal-news-row" v-for="(item, index) in visibleNews" :key="index" @click="onLink(item)">
<time v-if="item.time" :title="item.time">{{ item.time.slice(5) }}</time>
<span :title="item.text">{{ item.text }}</span>
</button>
<div v-if="!currentNews.value.length" class="portal-empty">暂无动态</div>
</div>
</div>
<slot></slot>
</div>
`,
data() {
return {}
return {activeIndex: 0, pageNumber: 1, pageSize: 3}
},
props: {
list: {
type: Array,
default: []
default: () => []
}
},
computed: {
currentNews() {
// 默认遵循接口栏目顺序,不再按栏目名称优先选择。
const section = this.list[this.activeIndex] || this.list[0]
return section ? {label: section.label, value: section.value || []} : {label: '最新动态', value: []}
},
visibleNews() {
const start = (this.pageNumber - 1) * this.pageSize
return this.currentNews.value.slice(start, start + this.pageSize)
},
pageCount() {
return Math.max(1, Math.ceil(this.currentNews.value.length / this.pageSize))
}
},
watch: {
list() {
this.selectSection(0)
}
},
methods: {
// step 为翻页方向(-1 上一页、1 下一页);在组件方法中更新页码,避免事件中的 this 指向错误。
changePage(step) {
const page = Math.max(1, Math.min(this.pageCount, this.pageNumber + step))
this.$set(this, 'pageNumber', page)
},
// index 为接口栏目数组下标;切换栏目时回到第一页,避免沿用上一栏的末页。
selectSection(index) {
this.activeIndex = index
this.pageNumber = 1
},
onLink(item) {
// 接口返回文章完整地址;相对地址按当前学校工会域名解析。
// 完整新闻地址直接打开,相对地址仍以重庆工贸工会网站为基准。
const url = this.isHttpOrHttps(item.href) ? item.href : new URL(item.href, "https://gh.cqgmy.edu.cn/").href
window.open(url)
},
@@ -48,6 +76,15 @@ const jcdt = {
}
},
style: /*language=CSS*/ `
/* 每页固定三行,不因展开或末页条数变化撑高相邻的模板卡片。 */
#v4-home-app .portal-news-list { min-height: 132px; }
#v4-home-app .portal-news-list .portal-news-row { height: 44px; padding: 9px 0; }
#v4-home-app .portal-news-pager { display: flex; flex-shrink: 0; align-items: center; gap: 8px; }
#v4-home-app .portal-news-count { margin-right: 4px; color: #8a9aac; font-size: 12px; }
#v4-home-app .portal-news-page { min-width: 32px; color: #637b96; text-align: center; font-size: 12px; font-variant-numeric: tabular-nums; }
#v4-home-app .portal-news-pager button { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; padding: 0; border: 1px solid #e4edf7; border-radius: 6px; background: #fff; color: #1687f5; font-size: 18px; cursor: pointer; }
#v4-home-app .portal-news-pager button:hover:not(:disabled) { border-color: #b9d8fb; background: #f0f7ff; }
#v4-home-app .portal-news-pager button:disabled { border-color: #eef2f7; background: #f8fafc; color: #c3cedb; cursor: default; }
.jcdt-wrapper {
width: 100%;
background-size: cover;
@@ -5,21 +5,22 @@ const workTemplate = {
<div class="work-template-title">
<i class="fa fa-clipboard"></i>
<span>工作模板</span>
<em>/Templates</em>
</div>
<button type="button" class="portal-more" @click="browseTemplates">更多模板 <i class="fa fa-arrow-right" aria-hidden="true"></i></button>
</div>
<div class="work-template-card">
<div v-if="templateList && templateList.length > 0" class="work-template-grid">
<div class="work-template-item"
<div ref="templateGrid" v-if="templateList && templateList.length > 0" class="work-template-grid">
<button type="button" class="work-template-item"
v-for="template in templateList"
:key="template.id"
@click="go(template)">
<div class="work-template-icon">
<img :src="template.templateIcon || template.cover || 'https://www.ncu.edu.cn/__local/8/52/2C/644986B4C9A030F7B65300178A6_8679CB08_18467.jpg'"
alt="">
<el-image :src="template.templateIcon || template.cover" fit="contain">
<i slot="error" class="fa fa-clipboard" aria-hidden="true"></i>
</el-image>
</div>
<div class="work-template-name">{{ template.templateName || template.name }}</div>
</div>
<div class="work-template-name" :title="template.templateName || template.name">{{ template.templateName || template.name }}</div>
</button>
</div>
<div v-else class="no-activity-placeholder">
@@ -28,6 +29,7 @@ const workTemplate = {
<p class="subtext">敬请关注后续更新</p>
</div>
</div>
<slot></slot>
</div>
`,
data() {
@@ -36,6 +38,15 @@ const workTemplate = {
}
},
methods: {
// 每次滚动一屏模板,到末尾后回到开头;无模板时不执行。
browseTemplates() {
const grid = this.$refs.templateGrid
if (!grid) return
// 模板仍使用全部真实数据,横向翻页不截断管理员可用的入口。
const atEnd = grid.scrollLeft + grid.clientWidth >= grid.scrollWidth - 2
grid.scrollTo({left: atEnd ? 0 : grid.scrollLeft + grid.clientWidth, behavior: 'smooth'})
},
// 无请求参数;Result.data 为当前管理员可用的工作模板数组。
listTemplate() {
this.$axios.post("/platform/home/listHomeTemplate").then((res) => {
if (res.code === 0) {
+131 -134
View File
@@ -4,40 +4,37 @@ const user = {
<!-- 用户容器:左右布局 -->
<div class="user-container">
<!-- 左侧:用户信息卡片 -->
<div class="user-info-card">
<div v-if="profileOnly" class="user-info-card">
<div class="portal-member-date"><i class="fa fa-calendar-o" aria-hidden="true"></i>{{ $moment().format('YYYY年MM月DD日') }} {{ ['星期日','星期一','星期二','星期三','星期四','星期五','星期六'][$moment().day()] }}</div>
<div class="user-details">
<div class="user-name">${@auth.getPrincipalProperty('username')}</div>
<div class="portal-member-heading">
<span class="portal-member-avatar"><i class="fa fa-user" aria-hidden="true"></i></span>
<div><div class="portal-member-welcome">欢迎回来</div><div class="user-name">{{ profile.username }}</div></div>
</div>
<div class="user-meta-grid">
<div class="meta-row">
<span class="meta-icon">🆔</span>
<span class="meta-icon"><i class="fa fa-id-card-o" aria-hidden="true"></i></span>
<div>
<span class="meta-label">工号</span>
<span class="meta-value">${@auth.getPrincipalProperty('loginname')}</span>
<span class="meta-value">{{ profile.loginname }}</span>
</div>
</div>
<div class="meta-row">
<span class="meta-icon">🏢</span>
<span class="meta-icon"><i class="fa fa-building-o" aria-hidden="true"></i></span>
<div>
<span class="meta-label">单位信息</span>
<span class="meta-value">
<!--#if(!isEmpty(@auth.getPrincipalProperty('unit'))){#-->
${@auth.getPrincipalProperty('unit').getName()}
<!--#}#-->
(
<!--#if(!isEmpty(@auth.getPrincipalProperty('union'))){#-->
${@auth.getPrincipalProperty('union').getName()}
<!--#}#-->
)
{{ profile.unitName }} ({{ profile.unionName }})
</span>
</div>
</div>
<div class="meta-row">
<span class="meta-icon">🎭</span>
<div class="meta-row meta-row-roles">
<span class="meta-icon"><i class="fa fa-shield" aria-hidden="true"></i></span>
<div>
<span class="meta-label">角色</span>
<span class="meta-value">{{ roleNames || '—' }}</span>
<div class="portal-member-roles" tabindex="0" role="region" aria-label="角色列表,可滚动查看全部角色"><span v-for="(role, index) in roleTags" :key="index" class="portal-member-role">{{ role }}</span><span v-if="!roleTags.length" class="meta-value">—</span></div>
</div>
</div>
</div>
@@ -63,10 +60,13 @@ const user = {
</div>
<!-- 右侧:待办中心 -->
<div class="todo-center">
<div v-if="!profileOnly" class="todo-center">
<!-- 标题和标签 -->
<div class="header">
<h3>待办中心</h3>
<div class="portal-todo-heading">
<h3><i class="fa fa-list-ul" aria-hidden="true"></i> 待办中心</h3>
<a class="portal-more" :href="'/flow/todoCenter?status=' + activeTab" target="_blank">全部待办 <i class="fa fa-arrow-right" aria-hidden="true"></i></a>
</div>
<el-tabs v-model="activeTab" type="card" @tab-click="handleTabClick">
<el-tab-pane :label="tabLabel('待办事宜', taskStats.todoCount)" name="todo"></el-tab-pane>
<el-tab-pane :label="tabLabel('已办事宜', taskStats.doneCount)" name="done"></el-tab-pane>
@@ -75,55 +75,44 @@ const user = {
</el-tabs>
</div>
<!-- 表格 -->
<el-table
:data="todoList"
style="width: 100%"
border
stripe
v-loading="loading"
:cell-style="{ padding: '8px' }"
:header-cell-style="{ background: '#f8f9fa', color: '#444', fontWeight: '500' }"
>
<el-table-column label="序号" type="index" :index="indexMethod" width="100px"></el-table-column>
<el-table-column prop="categoryName" label="流程分类" width="100"></el-table-column>
<el-table-column prop="title" label="流程名称" min-width="300">
<template slot-scope="scope">
<el-tooltip effect="dark" :content="scope.row.instanceName" placement="top">
<span class="table-title">{{ scope.row.instanceName }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="sender" label="申请人" width="120">
<template slot-scope="{row}">{{row.variable.initiatorName}}</template>
</el-table-column>
<el-table-column prop="taskName" label="当前流程节点" width="140" show-overflow-tooltip></el-table-column>
<el-table-column prop="date" label="发起日期" width="120">
<template slot-scope="{row}">
{{$moment(row.createdAt).format('YYYY-MM-DD')}}
</template>
</el-table-column>
<el-table-column label="操作" width="100px" fixed="right">
<template slot-scope="scope">
<el-button type="primary" size="mini" @click="openView(scope.row)">查看</el-button>
</template>
</el-table-column>
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
<div class="portal-task-list" v-loading="tableLoading">
<div class="portal-task-row" v-for="(task, index) in tableData" :key="task.taskId || task.instanceId || index">
<i class="fa fa-file-text-o portal-task-icon" aria-hidden="true"></i>
<div class="portal-task-content">
<div class="portal-task-title" :title="task.instanceName">{{ task.instanceName }}</div>
<div class="portal-task-meta">
<span :title="task.variable && task.variable.initiatorName">申请人:{{ (task.variable && task.variable.initiatorName) || '—' }}</span>
<span class="portal-task-node" :title="[task.categoryName, task.taskName].filter(Boolean).join(' · ')">{{ [task.categoryName, task.taskName].filter(Boolean).join(' · ') }}</span>
</div>
</div>
<time class="portal-task-date">{{ task.createdAt ? $moment(task.createdAt).format('YYYY-MM-DD') : '—' }}</time>
<el-button type="primary" size="mini" @click="openView(task)">查看</el-button>
</div>
<div v-if="!tableLoading && !tableData.length" class="portal-empty"><i class="fa fa-check-circle-o" aria-hidden="true"></i><span>暂无{{ activeTab === 'todo' ? '待办事项' : '相关记录' }}</span></div>
<slot></slot>
</div>
<el-pagination v-if="pageForm.totalCount > pageForm.pageSize" class="portal-task-pagination"
@current-change="pageNumberChange" :current-page="pageForm.pageNumber" :page-size="pageForm.pageSize"
layout="total, prev, next" :total="pageForm.totalCount"></el-pagination>
</div>
</div>
</div>
`,
mixins: [initTableMixins],
// 同一组件分别承载会员卡和待办卡,各自只加载需要的数据。
props: {
profileOnly: { type: Boolean, default: false },
// 首页传入显示资料:username 姓名、loginname 工号、unitName 单位、unionName 工会;缺项显示空文本。
profile: {
type: Object,
default: () => ({username: '', loginname: '', unitName: '', unionName: ''})
}
},
data() {
return {
activeTab: 'todo',
todoList: [],
loading: false,
pageForm: {
pageNum: 1,
pageSize: 5,
totalCount: 0
pageSize: 3
},
taskStats: {
todoCount: 0,
@@ -140,6 +129,12 @@ const user = {
};
},
computed: {
roleTags() {
// 兼容角色接口已有的中英文分隔符,仅改变展示,不改变角色权限。
return String(this.roleNames || '').split(/[,,、;;]/).map(role => role.trim()).filter(Boolean)
}
},
methods: {
getWeatherInfo(iconCode, temp) {
const info = {
@@ -189,91 +184,60 @@ const user = {
return info;
},
async getConfigKey(key) {
const resp = await this.$axios.post("/open/common/getConfigKey", { key })
return resp.data
// key 为系统配置名称;返回 Promise,值为 Result.data 中的经度或纬度。
getConfigKey(key) {
return this.$axios.post("/open/common/getConfigKey", {key}).then(res => res.data)
},
// 🔆 获取天气
async fetchWeather() {
try {
// 存localStorage 112.13,32.01 广州 113.27,23.14
const AppMapCenterPointX = await this.getConfigKey("AppMapCenterPointX")
const AppMapCenterPointY = await this.getConfigKey("AppMapCenterPointY")
if (!AppMapCenterPointX || !AppMapCenterPointY){
return
}
// 1. 先通过城市名获取 locationId
this.$axios.get("https://mx5rk62kvj.re.qweatherapi.com/geo/v2/city/lookup?location=" + AppMapCenterPointX + "," + AppMapCenterPointY + "&key=" + this.QWEATHER_KEY).then((geoRes) => {
if (geoRes.code !== '200' || !geoRes.location?.[0]) {
console.log('城市未找到')
}
const locationId = geoRes.location[0].id;
// 2. 获取当前天气
this.$axios.get("https://mx5rk62kvj.re.qweatherapi.com/v7/weather/now?location=" + locationId + "&key=" + this.QWEATHER_KEY).then((weatherRes) => {
if (weatherRes.code !== '200') {
console.warn('天气数据异常:', weatherRes);
}
const weatherInfo = this.getWeatherInfo(weatherRes.now.icon, weatherRes.now.temp);
this.weatherData = {
...weatherInfo,
temp: weatherRes.now.temp,
text: weatherRes.now.text,
name: geoRes.location[0].name
};
this.weatherError = false;
})
// 使用本校配置经纬度查询城市及天气,缺少配置时不请求外部天气接口。
fetchWeather() {
Promise.all([this.getConfigKey("AppMapCenterPointX"), this.getConfigKey("AppMapCenterPointY")])
.then(coordinates => {
if (!coordinates[0] || !coordinates[1]) return
return this.$axios.get("https://mx5rk62kvj.re.qweatherapi.com/geo/v2/city/lookup?location=" + coordinates.join(",") + "&key=" + this.QWEATHER_KEY)
.then(geoRes => {
if (geoRes.code !== '200' || !geoRes.location || !geoRes.location.length) {
throw new Error('城市未找到')
}
const city = geoRes.location[0]
return this.$axios.get("https://mx5rk62kvj.re.qweatherapi.com/v7/weather/now?location=" + city.id + "&key=" + this.QWEATHER_KEY)
.then(weatherRes => {
if (weatherRes.code !== '200' || !weatherRes.now) throw new Error('天气数据异常')
this.weatherData = Object.assign({}, this.getWeatherInfo(weatherRes.now.icon, weatherRes.now.temp), {
temp: weatherRes.now.temp, text: weatherRes.now.text, name: city.name
})
this.weatherError = false
})
})
})
.catch(() => {
this.weatherError = true
this.weatherData = null
})
} catch (err) {
console.warn('天气加载失败:', err);
this.weatherError = true;
this.weatherData = null;
}
},
// 获取任务列表
tabLabel(title, count) {
return title + "(" + (count || 0) + ")"
},
async getStatistics() {
try {
const res = await $.post("/flow/todoCenter/statistics")
if (res.code === 0) {
this.taskStats = Object.assign({}, this.taskStats, res.data)
}
} catch (error) {
console.error("获取待办统计失败:", error)
}
},
async getTasks() {
this.loading = true
try {
const res = await $.post("/flow/todoCenter/" + this.activeTab, this.pageForm)
if (res.code === 0) {
this.todoList = res.data.list
this.pageForm.totalCount = res.data.totalCount
}
} catch (error) {
this.$message.error("获取任务列表失败")
console.error("获取任务列表失败:", error)
} finally {
this.loading = false
}
// 无请求参数;data 的四个 Count 字段分别为当前用户各状态流程数量。
getStatistics() {
this.$axios.post("/flow/todoCenter/statistics").then(res => {
if (res.code === 0) this.taskStats = Object.assign({}, this.taskStats, res.data)
}).catch(() => {
this.$message.error("获取待办统计失败")
})
},
// WF 状态由 activeTab 决定;pageForm 传分页参数,data.list/totalCount 为记录及总数。
// 列表加载和翻页由 initTableMixins 统一处理,首页只补充状态地址和数量统计。
pageData() {
this.getTasks()
this.pageDataUrl = "/flow/todoCenter/" + this.activeTab
initTableMixins.methods.pageData.call(this)
this.getStatistics()
},
handleTabClick(tab) {
this.pageData()
console.log('切换到:', tab.name);
handleTabClick() {
this.doSearch()
},
// 处理任务
async openView(task) {
console.log(task)
openView(task) {
const {taskId, taskKey, taskState, instanceId, businessNo, formKey} = task
if (!formKey) {
this.$message.warning("当前流程没有配置地址")
@@ -281,8 +245,9 @@ const user = {
}
window.open(formKey + "?taskId=" + taskId + "&bizId=" + businessNo + "&taskKey=" + taskKey + "&tab=" + this.activeTab)
},
// 无请求参数;Result.data.roleNames 为当前登录人的角色名称字符串。
getRoleNames() {
$.post("/platform/sys/role/getRoleNames").then(res => {
this.$axios.post("/platform/sys/role/getRoleNames").then(res => {
if (res.code === 0) {
this.roleNames = res.data.roleNames
}
@@ -291,13 +256,45 @@ const user = {
}
},
created() {
this.pageData()
this.getRoleNames()
if (this.profileOnly) {
this.getRoleNames()
} else {
this.pageData()
}
},
mounted() {
this.fetchWeather()
if (this.profileOnly) this.fetchWeather()
},
style: /*language=CSS*/ `
/* 会员卡样式随组件注入,确保与卡片结构同步加载,避免旧外链样式造成排版缺失。 */
#v4-home-app .portal-member { min-width: 0; }
#v4-home-app .portal-member .user-info-card { height: 100%; padding: 20px; gap: 14px; border: 1px solid #e3edf8; border-radius: 12px; background: linear-gradient(180deg, #f1f7ff 0, #fff 110px); box-shadow: 0 3px 14px rgba(30,66,108,.035); }
#v4-home-app .portal-member-date { display: flex; align-items: center; gap: 8px; color: #71839b; font-size: 12px; line-height: 20px; }
#v4-home-app .portal-member-heading { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; padding-bottom: 14px; border-bottom: 1px solid #eaf0f7; }
#v4-home-app .portal-member-heading > div { min-width: 0; }
#v4-home-app .portal-member-avatar { display: flex; align-items: center; justify-content: center; flex: 0 0 46px; height: 46px; border-radius: 50%; background: #e1eeff; color: #1687f5; font-size: 25px; }
#v4-home-app .portal-member-welcome { margin-bottom: 3px; color: #8797a9; font-size: 12px; line-height: 18px; }
#v4-home-app .portal-member .user-name { margin: 0; color: #163c61; text-align: left; font-size: 18px; line-height: 24px; overflow-wrap: anywhere; }
#v4-home-app .portal-member .user-meta-grid { gap: 12px; margin: 0; }
#v4-home-app .portal-member .meta-row { gap: 8px; }
#v4-home-app .portal-member .meta-icon { min-width: 18px; color: #81a4c9; font-size: 14px; }
#v4-home-app .portal-member .meta-row > div { display: grid; grid-template-columns: 54px minmax(0, 1fr); gap: 8px; min-width: 0; }
#v4-home-app .portal-member .meta-label { margin: 0; color: #8995a5; font-size: 12px; line-height: 20px; }
#v4-home-app .portal-member .meta-value { color: #283c54; font-size: 12px; font-weight: 400; line-height: 20px; overflow-wrap: anywhere; }
#v4-home-app .portal-member .meta-row-roles > div { display: block; }
/* 角色最多占用三行标签的高度,超出时内部滚动,避免撑高首页横幅。 */
#v4-home-app .portal-member-roles { display: flex; flex-wrap: wrap; align-content: flex-start; gap: 5px; margin-top: 5px; max-height: 88px; overflow-y: auto; overflow-x: hidden; scrollbar-width: thin; scrollbar-color: #c5d9ee transparent; }
#v4-home-app .portal-member-roles::-webkit-scrollbar { width: 4px; }
#v4-home-app .portal-member-roles::-webkit-scrollbar-thumb { background: #c5d9ee; border-radius: 4px; }
#v4-home-app .portal-member-role { max-width: 100%; display: inline-block; padding: 3px 8px; border: 1px solid #e1ebf7; border-radius: 5px; background: #f3f7fc; color: #46617f; font-size: 12px; line-height: 18px; overflow-wrap: anywhere; }
#v4-home-app .portal-member .weather-section { margin-top: auto; padding: 8px 10px; border-radius: 8px; background: #f0f7ff; color: #527494; }
#v4-home-app .portal-member .weather-header { gap: 6px; }
#v4-home-app .portal-member .weather-location,
#v4-home-app .portal-member .weather-text { font-size: 12px; }
#v4-home-app .portal-member .weather-emoji { font-size: 20px; }
#v4-home-app .portal-member .weather-temp { font-size: 18px; }
#v4-home-app .portal-member .weather-tip { margin-top: 4px; font-size: 11px; line-height: 18px; }
#v4-home-app .portal-member .weather-error { margin-top: auto; padding: 8px; font-size: 12px; }
.user-wrapper {
width: 100%;
background-color: #f5f7fa;