feat: 同步地大功能至2026年10月20日

This commit is contained in:
2026-10-21 09:26:25 +08:00
parent 6b5d27c68d
commit 88d624172c
198 changed files with 19375 additions and 2128 deletions
+212
View File
@@ -0,0 +1,212 @@
-- 人员数据治理模块初始化脚本(MySQL 8.0,UTF-8)。
-- 人员变更直接复用现有 member_change_record、sys_user_history 及 WF 流程,本脚本不创建替代表。
SET NAMES utf8mb4;
SET @add_person_flag = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sys_user' AND COLUMN_NAME = 'flag') = 0,
'ALTER TABLE sys_user ADD COLUMN flag int DEFAULT 0 COMMENT ''人员治理同步保护标识:1忽略同步,0或空正常同步''',
'SELECT 1'
);
PREPARE add_person_flag_stmt FROM @add_person_flag;
EXECUTE add_person_flag_stmt;
DEALLOCATE PREPARE add_person_flag_stmt;
CREATE TABLE IF NOT EXISTS person_api_config (
id varchar(32) NOT NULL,
configName varchar(100), sourceType varchar(20), requestUrl varchar(500), requestMethod varchar(10),
timeoutSeconds int, sourceTable varchar(100), targetTable varchar(100), syncMode varchar(20),
identityField varchar(100), bodyType varchar(30), bodyContent text,
tokenRequestUrl varchar(500), tokenRequestMethod varchar(10), tokenTimeoutSeconds int,
tokenBodyType varchar(30), tokenBodyContent text, tokenValuePath varchar(200), tokenExpiresPath varchar(200),
tokenExpiryUnit varchar(20), tokenQueryResult text, dataQueryResult text,
responseCodePath varchar(200), responseSuccessValue varchar(100), responseMessagePath varchar(200),
responseDataPath varchar(200), responseTotalPath varchar(200), enabled tinyint(1), taskCron varchar(100),
matchMemberRules tinyint(1), lastSyncTime datetime, taskId varchar(32), remark varchar(500),
createdBy varchar(32), createdAt bigint, updatedBy varchar(32), updatedAt bigint,
delFlag tinyint(1), opBy varchar(128), opAt varchar(128),
PRIMARY KEY (id), UNIQUE KEY UK_PERSON_API_CONFIG_NAME (configName)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS person_api_field_mapping (
id varchar(32) NOT NULL, configId varchar(32), sourceField varchar(100), sourceComment varchar(200),
targetField varchar(100), targetComment varchar(200), required tinyint(1), identityFlag tinyint(1), sort int,
PRIMARY KEY (id), UNIQUE KEY UK_PERSON_API_MAPPING_TARGET (configId, targetField)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS person_api_request_param (
id varchar(32) NOT NULL, configId varchar(32), requestScope varchar(20), paramLocation varchar(20),
paramName varchar(100), paramType varchar(30), paramValue text, required tinyint(1),
description varchar(300), sensitiveFlag int, sort int, PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS person_api_test_case (
id varchar(32) NOT NULL, configId varchar(32), requestScope varchar(20), caseName varchar(100),
requestSnapshot text, responseStatus int, responseTime bigint, responseBody text, success tinyint(1),
executedAt datetime, createdBy varchar(32), createdAt bigint, updatedBy varchar(32), updatedAt bigint,
delFlag tinyint(1), opBy varchar(128), opAt varchar(128), PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS person_sync_batch (
id varchar(32) NOT NULL, configId varchar(32), batchNo varchar(50), triggerType varchar(20),
startedAt datetime, finishedAt datetime, status varchar(20), sourceCount int, successCount int,
newCount int, changeCount int, exceptionCount int, errorMessage text, rawFileReid varchar(32),
createdBy varchar(32), createdAt bigint, updatedBy varchar(32), updatedAt bigint,
delFlag tinyint(1), opBy varchar(128), opAt varchar(128),
PRIMARY KEY (id), UNIQUE KEY UK_PERSON_SYNC_BATCH_NO (batchNo)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS person_source_relation (
id varchar(32) NOT NULL, configId varchar(32), sourcePersonKey varchar(150), userId varchar(32),
personNo varchar(120), lastSeenBatchId varchar(32), lastSeenTime datetime, presentFlag tinyint(1),
sourceDataHash varchar(64), createdBy varchar(32), createdAt bigint, updatedBy varchar(32), updatedAt bigint,
delFlag tinyint(1), opBy varchar(128), opAt varchar(128),
PRIMARY KEY (id), UNIQUE KEY UK_PERSON_SOURCE_KEY (configId, sourcePersonKey)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS person_member_match_config (
id varchar(32) NOT NULL, configName varchar(100), apiConfigId varchar(32), copiedFromId varchar(32),
enabled tinyint(1), groupCount int, lastMatchCount int, lastExecuteTime datetime,
createdBy varchar(32), createdAt bigint, updatedBy varchar(32), updatedAt bigint,
delFlag tinyint(1), opBy varchar(128), opAt varchar(128),
PRIMARY KEY (id), UNIQUE KEY UK_PERSON_MEMBER_API_CONFIG (apiConfigId)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS person_member_match_group (
id varchar(32) NOT NULL, matchConfigId varchar(32), parentGroupId varchar(32),
ruleType varchar(30) DEFAULT 'UNION_MEMBER', groupNo int, joinLogic varchar(3), sort int,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS person_member_match_condition (
id varchar(32) NOT NULL, groupId varchar(32), dictCode varchar(50), dictItemId varchar(32),
dictItemCode varchar(50), dictItemName varchar(100), mappedField varchar(100), operator varchar(30),
matchValue varchar(500), joinLogic varchar(3), sort int, PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS person_exception (
id varchar(32) NOT NULL, configId varchar(32), relationId varchar(32), discoverBatchId varchar(32),
userId varchar(32), personNo varchar(120), personName varchar(100), unitId varchar(32), unitName varchar(150),
exceptionType varchar(30), exceptionReason varchar(500), lastSyncTime datetime, discoverTime datetime,
status varchar(30), handleResult varchar(30), handleOpinion varchar(500), handlerId varchar(32),
handlerName varchar(100), handleTime datetime, rawFileReid varchar(32),
createdBy varchar(32), createdAt bigint, updatedBy varchar(32), updatedAt bigint,
delFlag tinyint(1), opBy varchar(128), opAt varchar(128), PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- PC 端独立应用菜单。完整页面跳转由布局按 /platform/personGovernance/ 路径处理。
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled,
permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag,
platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT '76725022b0cc11f196a000ff8d9db2e0', '', LPAD(IFNULL(MAX(CAST(path AS UNSIGNED)), 0) + 1, 4, '0'),
'人员数据治理', 'PersonGovernance', 'menu', '', '', 'ti-id-badge', 1, 0,
'data.person.governance', '人员数据接入、会员匹配、人员变更及异常处理', 694, 1,
'', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0,
'PC', NULL, NULL, 'r', 0, 0
FROM sys_menu WHERE (parentId = '' OR parentId IS NULL) AND CHAR_LENGTH(path) = 4
HAVING NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance') m);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled,
permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag,
platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT '7673d831b0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0001'), 'API配置', 'ApiConfig', 'menu',
'/platform/personGovernance/apiConfig', '', '', 1, 0, 'data.person.governance.api', NULL, 1, 0,
'', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'a', 0, 0
FROM sys_menu p WHERE p.permission = 'data.person.governance'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.api') m);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled,
permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag,
platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT '76749371b0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0002'), '会员匹配', 'MemberMatch', 'menu',
'/platform/personGovernance/memberMatch', '', '', 1, 0, 'data.person.governance.member', NULL, 2, 0,
'', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'h', 0, 0
FROM sys_menu p WHERE p.permission = 'data.person.governance'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.member') m);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled,
permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag,
platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT '7674b630b0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0003'), '人员变更', 'PersonChange', 'menu',
'/platform/personGovernance/personChange', '', '', 1, 0, 'data.person.governance.change', NULL, 3, 0,
'', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'r', 0, 0
FROM sys_menu p WHERE p.permission = 'data.person.governance'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.change') m);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, href, target, icon, showit, disabled,
permission, note, location, hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag,
platform, moduleId, picIcon, initialPinyinName, isRecommendApp, isRecommendService)
SELECT '7674d34eb0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0004'), '人员异常', 'PersonException', 'menu',
'/platform/personGovernance/personException', '', '', 1, 0, 'data.person.governance.exception', NULL, 4, 0,
'', UNIX_TIMESTAMP(NOW()) * 1000, '', UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, NULL, 'r', 0, 0
FROM sys_menu p WHERE p.permission = 'data.person.governance'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.exception') m);
-- 页面按钮权限;type=data 不在左侧菜单展示。
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, showit, disabled, permission, location,
hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId,
initialPinyinName, isRecommendApp, isRecommendService)
SELECT '7674fb04b0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0001'), '保存API配置', 'Edit', 'data',
0, 0, 'data.person.governance.api.edit', 1, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '',
UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, 'b', 0, 0 FROM sys_menu p
WHERE p.permission = 'data.person.governance.api' AND NOT EXISTS
(SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.api.edit') m);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, showit, disabled, permission, location,
hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId,
initialPinyinName, isRecommendApp, isRecommendService)
SELECT '76750400b0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0002'), '删除API配置', 'Delete', 'data',
0, 0, 'data.person.governance.api.delete', 2, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '',
UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, 's', 0, 0 FROM sys_menu p
WHERE p.permission = 'data.person.governance.api' AND NOT EXISTS
(SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.api.delete') m);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, showit, disabled, permission, location,
hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId,
initialPinyinName, isRecommendApp, isRecommendService)
SELECT '76750e42b0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0003'), '测试及同步', 'Test', 'data',
0, 0, 'data.person.governance.api.test', 3, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '',
UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, 'c', 0, 0 FROM sys_menu p
WHERE p.permission = 'data.person.governance.api' AND NOT EXISTS
(SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.api.test') m);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, showit, disabled, permission, location,
hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId,
initialPinyinName, isRecommendApp, isRecommendService)
SELECT '767518e8b0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0001'), '保存会员匹配', 'Edit', 'data',
0, 0, 'data.person.governance.member.edit', 1, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '',
UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, 'b', 0, 0 FROM sys_menu p
WHERE p.permission = 'data.person.governance.member' AND NOT EXISTS
(SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.member.edit') m);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, showit, disabled, permission, location,
hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId,
initialPinyinName, isRecommendApp, isRecommendService)
SELECT '767523deb0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0002'), '删除会员匹配', 'Delete', 'data',
0, 0, 'data.person.governance.member.delete', 2, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '',
UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, 's', 0, 0 FROM sys_menu p
WHERE p.permission = 'data.person.governance.member' AND NOT EXISTS
(SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.member.delete') m);
INSERT INTO sys_menu (id, parentId, path, name, aliasName, type, showit, disabled, permission, location,
hasChildren, createdBy, createdAt, updatedBy, updatedAt, delFlag, platform, moduleId,
initialPinyinName, isRecommendApp, isRecommendService)
SELECT '76752eacb0cc11f196a000ff8d9db2e0', p.id, CONCAT(p.path, '0001'), '处理人员异常', 'Handle', 'data',
0, 0, 'data.person.governance.exception.handle', 1, 0, '', UNIX_TIMESTAMP(NOW()) * 1000, '',
UNIX_TIMESTAMP(NOW()) * 1000, 0, 'PC', NULL, 'c', 0, 0 FROM sys_menu p
WHERE p.permission = 'data.person.governance.exception' AND NOT EXISTS
(SELECT 1 FROM (SELECT id FROM sys_menu WHERE permission = 'data.person.governance.exception.handle') m);
-- 给系统管理员默认授权,其他角色可在角色管理中按需分配。
INSERT INTO sys_role_menu (roleId, menuId)
SELECT r.id, m.id
FROM sys_role r
JOIN sys_menu m ON m.permission IN (
'data.person.governance', 'data.person.governance.api', 'data.person.governance.member',
'data.person.governance.api.edit', 'data.person.governance.api.delete', 'data.person.governance.api.test',
'data.person.governance.member.edit', 'data.person.governance.member.delete',
'data.person.governance.change', 'data.person.governance.exception',
'data.person.governance.exception.handle')
LEFT JOIN sys_role_menu rm ON rm.roleId = r.id AND rm.menuId = m.id
WHERE r.code = 'SYSADMIN'
AND rm.roleId IS NULL;
@@ -0,0 +1,19 @@
-- 社团缩略图保存系统文件下载地址;保留可空以兼容未上传图片的历史社团。
-- 字段存在时跳过,避免重复执行迁移时报错。
SET @club_thumbnail_column_count = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sys_club'
AND COLUMN_NAME = 'thumbnail'
);
SET @club_thumbnail_ddl = IF(
@club_thumbnail_column_count = 0,
'ALTER TABLE sys_club ADD COLUMN thumbnail VARCHAR(1000) NULL COMMENT ''社团缩略图'' AFTER clubName',
'SELECT 1'
);
PREPARE club_thumbnail_stmt FROM @club_thumbnail_ddl;
EXECUTE club_thumbnail_stmt;
DEALLOCATE PREPARE club_thumbnail_stmt;
@@ -0,0 +1,38 @@
-- 社团活动属于报销项目的正式字典配置,两端沿用现有字典接口读取。
-- 仅补充缺失记录;已有记录(包括已禁用记录)不覆盖,不在查询接口中添加兜底项。
INSERT INTO sys_dict (
id, parentId, path, name, remark, code, disabled, location, hasChildren,
createdBy, createdAt, updatedBy, updatedAt, delFlag
)
SELECT
REPLACE(UUID(), '-', ''),
parent.id,
CONCAT(parent.path, LPAD(IFNULL((
SELECT MAX(CAST(RIGHT(child.path, 4) AS UNSIGNED))
FROM sys_dict child
WHERE child.path LIKE CONCAT(parent.path, '____')
), 0) + 1, 4, '0')),
'社团活动',
'',
'UNION_REIMBURSE_PROJECT_5',
0,
IFNULL((SELECT MAX(child.location) FROM sys_dict child WHERE child.parentId = parent.id), 0) + 1,
0,
'', UNIX_TIMESTAMP(NOW()) * 1000,
'', UNIX_TIMESTAMP(NOW()) * 1000,
0
FROM sys_dict parent
WHERE parent.code = 'UNION_REIMBURSE_PROJECT'
AND NOT EXISTS (
SELECT 1
FROM (SELECT code FROM sys_dict WHERE code = 'UNION_REIMBURSE_PROJECT_5') existing_item
);
UPDATE sys_dict parent
SET parent.hasChildren = 1
WHERE parent.code = 'UNION_REIMBURSE_PROJECT'
AND EXISTS (
SELECT 1
FROM (SELECT parentId FROM sys_dict WHERE code = 'UNION_REIMBURSE_PROJECT_5') club_activity
WHERE club_activity.parentId = parent.id
);
-79
View File
@@ -54,86 +54,7 @@
<!--
#################################################################################
# #
# debug、info、error分文件输出 start #
# #
#################################################################################
-->
<!-- DEBUG级别日志文件配置 -->
<!--<appender name="DEBUG_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/debug.log</file>
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>DEBUG</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_HOME}/debug-%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>300</maxHistory>
</rollingPolicy>
</appender>
&lt;!&ndash; INFO级别日志文件配置 &ndash;&gt;
<appender name="INFO_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/info.log</file>
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>INFO</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_HOME}/info-%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>300</maxHistory>
</rollingPolicy>
</appender>
&lt;!&ndash; ERROR级别日志文件配置 &ndash;&gt;
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/error.log</file>
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>ERROR</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_HOME}/error-%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>300</maxHistory>
</rollingPolicy>
</appender>
&lt;!&ndash; 框架日志级别 &ndash;&gt;
&lt;!&ndash; <logger name="java" additivity="false" />&ndash;&gt;
<logger name="org.eclipse.jetty" level="INFO"/>
<logger name="org.quartz" level="INFO"/>
<logger name="org.nutz" level="DEBUG"/>
&lt;!&ndash; 日志级别和appender的关联 &ndash;&gt;
<root level="DEBUG">
<appender-ref ref="STDOUT"/>
<appender-ref ref="DEBUG_FILE"/>
<appender-ref ref="INFO_FILE"/>
<appender-ref ref="ERROR_FILE"/>
</root>-->
<!--
#################################################################################
# #
# debug、info、error分文件输出 end #
# #
#################################################################################
-->
</configuration>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

@@ -0,0 +1,768 @@
/* 人员数据治理列表统一占满内容区,数据超出时只滚动表格主体。 */
.person-governance-page-transition {
visibility: hidden;
opacity: 0;
}
.person-governance-page-transition.is-ready {
visibility: visible;
opacity: 1;
}
.person-governance-page-transition.is-leaving {
pointer-events: none;
opacity: 0;
}
.person-governance-list-page {
box-sizing: border-box;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
padding: 10px;
}
.person-governance-search-card {
flex: none;
}
.person-governance-list-card {
flex: 1;
min-height: 0;
margin-top: 20px;
}
/* 会员匹配列表的查询区与列表区间距缩小为原来的一半。 */
.person-governance-compact-gap .person-governance-list-card {
margin-top: 10px;
}
.person-governance-list-card > .el-card__body {
box-sizing: border-box;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.person-governance-table-wrap {
flex: 1;
min-height: 0;
overflow: hidden;
}
.person-governance-table-wrap > .el-table {
height: 100%;
}
.person-governance-list-footer,
.person-governance-list-card .el-pagination-container {
flex: none;
}
/* 人员变更列表位于 Guava 默认插槽内,列表视图仍按查询区、表格区、分页区分配固定高度。 */
.person-governance-change-page > .guava-main-content,
.person-governance-change-page > .guava-main-content > .transition-item {
height: 100%;
min-height: 0;
overflow: hidden;
}
.person-governance-change-page > .guava-main-content > .transition-item {
display: flex;
flex-direction: column;
}
.person-governance-change-list-card {
flex: 1;
min-height: 0;
}
.person-governance-change-list-card > .el-card__body {
box-sizing: border-box;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.person-governance-change-table-wrap {
flex: 1;
min-height: 0;
overflow: hidden;
}
.person-governance-change-table-wrap > .el-table {
height: 100%;
}
.person-governance-change-list-card .el-pagination-container {
flex: none;
}
/* 配置页顶部字段根据内容区宽度自动分列,空间不足时自然换行。 */
.person-governance-form-page {
box-sizing: border-box;
display: flex;
flex-direction: column;
height: 100%;
max-width: 100%;
min-height: 0;
overflow-x: hidden;
overflow-y: hidden;
padding: 10px;
}
/* PageHeader 提供统一返回入口,并与基本配置保持紧凑间距。 */
.person-governance-page-header {
flex: none;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
padding: 16px 20px;
background-color: #fff;
}
/* 表单页只保留返回入口,移除 PageHeader 空内容对应的分隔线和占位间距。 */
.person-governance-page-header .el-page-header__left {
margin-right: 0;
}
.person-governance-page-header .el-page-header__left::after,
.person-governance-page-header .el-page-header__content {
display: none;
}
.person-governance-form-basic-card {
flex: none;
}
/* 第三方接口按Token与数据分区管理,一级页签保持紧凑并独立于各自的子页签。 */
.person-governance-endpoint-tabs-card {
flex: none;
margin-bottom: 10px;
}
.person-governance-endpoint-tabs-card > .el-card__body {
padding: 0 20px;
}
.person-governance-endpoint-tabs-card .el-tabs__header {
margin-bottom: 0;
}
.person-governance-endpoint-tabs-card .el-tabs__content {
display: none;
}
/* 顶部基本配置横向铺满卡片,并压缩默认表单项产生的底部空白。 */
.person-governance-form-basic-card > .el-card__body {
padding: 20px 10px;
}
.person-governance-form-basic-card .el-form-item {
margin-bottom: 0;
}
/* 两类会员规则使用独立页签,页签本身不参与内容区滚动。 */
.person-governance-member-type-tabs {
flex: none;
margin-top: 10px;
padding: 0 20px;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 4px;
}
.person-governance-member-type-tabs .el-tabs__header {
margin: 0;
}
.person-governance-member-type-tabs .el-tabs__content {
display: none;
}
/* 主要配置板块占满剩余高度,内容过多时仅在板块内部滚动。 */
.person-governance-form-content-card {
flex: 1;
min-height: 0;
margin-top: 10px;
}
.person-governance-form-content-card > .el-card__body {
box-sizing: border-box;
height: 100%;
overflow: auto;
}
.person-governance-form-content-row {
display: flex;
flex: 1;
min-height: 0;
margin-top: 10px;
}
.person-governance-form-content-row > .el-col,
.person-governance-form-content-row > .el-col > .el-card {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
}
.person-governance-form-content-row > .el-col {
float: none;
}
.person-governance-form-content-row > .el-col > .el-card {
flex: 1;
}
.person-governance-form-content-row > .el-col > .el-card > .el-card__body {
box-sizing: border-box;
height: 100%;
overflow: auto;
}
/* 匹配规则标题固定,条件组占用剩余高度并独立滚动。 */
.person-governance-form-content-row > .el-col > .person-governance-member-rules > .el-card__body {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.person-governance-member-rules-header {
flex: none;
}
.person-governance-member-rules-content {
flex: 1;
min-height: 0;
overflow: auto;
padding-right: 8px;
}
/* 会员预览固定标题和操作区,表格独占剩余高度,长列表只滚动表格内部。 */
.person-governance-form-content-row > .el-col > .person-governance-member-preview > .el-card__body {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.person-governance-member-preview h3,
.person-governance-member-preview p,
.person-governance-member-preview > .el-card__body > .el-button {
flex: none;
}
.person-governance-member-preview > .el-card__body > .el-button {
align-self: flex-start;
}
.person-governance-member-preview-table {
flex: 1;
min-height: 0;
margin-top: 20px;
}
.person-governance-member-preview-pagination {
flex: none;
padding-top: 10px;
text-align: right;
}
.person-governance-member-preview-pagination .el-pagination {
padding: 0;
white-space: normal;
}
.person-governance-member-binding-hint {
margin-bottom: 16px;
}
.person-governance-member-condition-relation > td {
padding: 6px 0;
text-align: center;
background-color: #fafafa;
}
/* 每层条件组对应一层括号语义,通过边框和缩进直观展示父子层级。 */
.person-governance-member-group {
margin-top: 20px;
padding: 14px;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #fff;
}
.person-governance-member-group.is-nested {
margin: 0 0 0 20px;
border-color: #b3d8ff;
background: #f8fbff;
}
.person-governance-member-group-header {
min-height: 28px;
margin-bottom: 10px;
}
.person-governance-member-group-actions {
margin-top: 10px;
}
.person-governance-member-group-relation {
margin: 28px 0;
}
.person-governance-member-child-relation {
margin-left: 20px;
}
.person-governance-form-footer {
display: flex;
align-items: center;
justify-content: flex-end;
flex: none;
margin-top: 10px;
}
.person-governance-form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
column-gap: 20px;
align-items: start;
width: 100%;
}
.person-governance-form-grid .el-form-item {
margin-right: 0;
}
.person-governance-form-grid .el-select,
.person-governance-form-grid .el-input-number {
width: 100%;
}
/* API配置顶部字段按业务宽度靠左排列,同步方式具体值单独放在页首右侧。 */
.person-governance-sync-type {
color: #303133;
line-height: 24px;
}
.person-governance-api-basic-grid {
/* 按业务宽度紧凑靠左,剩余空间留在右侧;窄屏只允许整组字段换行。 */
display: flex;
flex-wrap: wrap;
gap: 18px 16px;
justify-content: flex-start;
align-items: center;
}
.person-governance-api-basic-grid > .el-form-item {
display: flex;
align-items: center;
flex: 0 1 280px;
min-width: 0;
max-width: 100%;
}
.person-governance-api-basic-grid .el-form-item__label {
flex: none;
float: none;
white-space: nowrap;
}
.person-governance-api-basic-grid .el-form-item__content {
flex: 1;
min-width: 0;
}
.person-governance-api-basic-grid > .person-governance-api-request-address {
flex-basis: 620px;
}
.person-governance-api-basic-grid > .person-governance-api-timeout {
flex-basis: 270px;
}
.person-governance-api-basic-grid > .person-governance-query-action {
flex: none;
}
.person-governance-request-address-control,
.person-governance-timeout-control {
display: flex;
align-items: center;
}
.person-governance-request-address-control {
width: 100%;
}
.person-governance-request-address-control .el-select {
flex: none;
width: 100px;
}
.person-governance-request-address-control > .el-input {
flex: 1;
min-width: 0;
margin-left: 8px;
}
.person-governance-timeout-control .el-input-number {
flex: none;
width: 200px;
min-width: 200px;
}
.person-governance-timeout-unit {
flex: none;
margin-left: 8px;
color: #606266;
}
/* API配置页签占满内容卡片,返回结果文本域随剩余高度伸展。 */
.person-governance-api-content-card > .el-card__body {
overflow: hidden;
}
.person-governance-api-content-card > .el-card__body > .el-tabs {
display: flex;
flex-direction: column;
height: 100%;
}
.person-governance-api-content-card > .el-card__body > .el-tabs > .el-tabs__header {
flex: none;
}
.person-governance-api-content-card > .el-card__body > .el-tabs > .el-tabs__content {
flex: 1;
min-height: 0;
}
.person-governance-api-content-card > .el-card__body > .el-tabs > .el-tabs__content > .el-tab-pane {
box-sizing: border-box;
height: 100%;
overflow: auto;
}
/* 系统表配置不显示单一的字段映射页签标题,映射内容直接占满卡片。 */
.person-governance-system-table-card > .el-card__body > .el-tabs > .el-tabs__header {
display: none;
}
.person-governance-system-table-card > .el-card__body > .el-tabs > .el-tabs__content,
.person-governance-system-table-card .person-governance-mapping-pane {
height: 100%;
}
.person-governance-system-table-card .person-governance-mapping-pane {
overflow: hidden !important;
}
.person-governance-result-pane {
display: flex;
flex-direction: column;
overflow: hidden !important;
}
.person-governance-result-textarea {
flex: 1;
min-height: 0;
height: 100%;
}
/* 返回结果下方的映射配置固定保留,长响应只在文本域内滚动。 */
.person-governance-response-fields {
display: flex;
flex-wrap: wrap;
flex: none;
gap: 10px 20px;
padding-top: 12px;
}
.person-governance-response-fields > div {
display: flex;
align-items: center;
flex: 1 1 280px;
min-width: 0;
gap: 10px;
}
.person-governance-response-fields label,
.person-governance-task-cron label {
white-space: nowrap;
flex: none;
}
.person-governance-response-fields .el-input,
.person-governance-response-fields .el-select {
flex: 1;
min-width: 0;
}
.person-governance-task-cron {
flex: 0 1 420px;
max-width: 420px;
}
.person-governance-cron-hint,
.person-governance-json-hint {
flex: none;
color: #909399;
font-size: 12px;
line-height: 20px;
}
.person-governance-parameter-pane .person-governance-body-json {
flex: 1;
min-height: 80px;
height: auto;
}
.person-governance-result-textarea .el-textarea__inner,
.person-governance-body-json .el-textarea__inner {
height: 100%;
resize: none;
}
.person-governance-result-actions {
flex: none;
margin-top: 10px;
text-align: right;
}
.person-governance-body-json {
height: calc(100% - 52px);
}
/* 字段映射与请求参数表格在可见页签内直接按完整宽高渲染。 */
.person-governance-mapping-table {
width: 100%;
}
.person-governance-mapping-pane {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden !important;
}
.person-governance-mapping-table-wrap {
flex: 1;
min-height: 0;
margin-top: 20px;
}
.person-governance-mapping-table-wrap > .el-table {
height: 100%;
}
.person-governance-mapping-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px 24px;
}
.person-governance-mapping-toolbar-item {
display: flex;
align-items: center;
min-width: 0;
}
/* 两种数据来源共用右侧清空入口,窄屏换行后仍靠右,不挤压周期控件。 */
.person-governance-mapping-toolbar > .person-governance-clear-mappings {
flex: none;
margin-left: auto;
}
.person-governance-mapping-toolbar-item > label {
flex: none;
margin-right: 10px;
color: #303133;
white-space: nowrap;
}
.person-governance-mapping-table-select {
flex: 0 1 360px;
max-width: 360px;
}
.person-governance-mapping-table-select .el-select {
flex: 1;
min-width: 180px;
}
.person-governance-mapping-switch {
flex: none;
}
/* 系统表配置项按配置名称、来源表、目标表、同步开关的顺序自适应排列。 */
.person-governance-system-table-toolbar {
flex: none;
}
.person-governance-system-table-toolbar .el-form-item {
/* 标签与控件作为一组排列,仅允许表单项在工具栏中整体换行。 */
display: flex;
align-items: center;
flex: 1 1 280px;
min-width: 0;
margin: 0;
}
.person-governance-system-table-toolbar .person-governance-system-target-table {
flex: 0 1 300px;
}
.person-governance-system-table-toolbar .person-governance-system-task-cron {
flex: 0 1 430px;
}
.person-governance-system-table-toolbar .person-governance-system-member-match {
flex: none;
column-gap: 12px;
margin-left: 16px;
}
/* 长标签按文字实际宽度展示,通过固定列间距避免文字与开关重叠或距离过大。 */
.person-governance-system-table-toolbar .person-governance-system-member-match .el-form-item__label {
width: auto !important;
padding-right: 0;
}
.person-governance-system-table-toolbar .person-governance-system-member-match .el-form-item__content {
margin-left: 0 !important;
}
.person-governance-system-table-toolbar .el-form-item__label {
flex: none;
float: none;
white-space: nowrap;
}
.person-governance-system-table-toolbar .el-form-item__content {
flex: 1;
min-width: 0;
width: auto;
}
.person-governance-system-table-toolbar .el-input,
.person-governance-system-table-toolbar .el-select {
width: 100%;
}
.person-governance-parameter-pane {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden !important;
}
.person-governance-parameter-pane > .el-tabs,
.person-governance-parameter-pane > .el-radio-group {
flex: none;
}
.person-governance-parameter-table-wrap {
flex: 1;
min-height: 0;
margin-top: 10px;
}
.person-governance-parameter-table-wrap > .el-table {
width: 100%;
height: 100%;
}
/* 参数表头按文字与加号按钮的实际高度紧凑展示。 */
.person-governance-parameter-table .el-table__header-wrapper th,
.person-governance-mapping-table .el-table__header-wrapper th {
padding: 6px 0;
}
.person-governance-parameter-table .el-table__header-wrapper th .cell,
.person-governance-mapping-table .el-table__header-wrapper th .cell {
line-height: 20px;
white-space: nowrap;
}
.person-governance-add-column .el-button {
padding: 0;
font-size: 16px;
line-height: 20px;
}
/* 弹窗和抽屉沿用 Element UI 过渡,并为本模块调整为更柔和的缓动。 */
.dialog-fade-enter-active .person-governance-dialog,
.dialog-fade-leave-active .person-governance-dialog {
transition:
opacity 260ms ease,
transform 260ms ease;
}
.dialog-fade-enter .person-governance-dialog,
.dialog-fade-leave-to .person-governance-dialog {
opacity: 0;
transform: translateY(-10px) scale(0.99);
}
/* 弹窗挂载body避开页面过渡层,短屏内部滚动,窄屏保留周期编辑器的完整操作区域。 */
.person-governance-cron-dialog {
max-width: calc(100vw - 32px);
}
.person-governance-cron-dialog .el-dialog__body {
max-height: 78vh;
overflow: auto;
padding: 10px 20px 20px;
}
.person-governance-cron-dialog .popup-main {
min-width: 640px;
}
.person-governance-drawer {
transition: box-shadow 260ms ease;
}
.person-governance-drawer.rtl,
.el-drawer__open .person-governance-drawer.rtl {
animation-duration: 340ms;
animation-timing-function: cubic-bezier(0.22, 0.61, 0.36, 1);
}
@media (max-width: 767px) {
.person-governance-list-page,
.person-governance-form-page {
padding: 8px;
}
.person-governance-form-grid {
grid-template-columns: 1fr;
}
.person-governance-page-header {
align-items: flex-start;
}
.person-governance-timeout-control .el-input-number {
flex: 1;
width: 100%;
min-width: 0;
}
.person-governance-mapping-table-select {
flex-basis: 100%;
max-width: none;
}
}
@@ -70,7 +70,8 @@ const initTableMixins = {
pageData(data = null) {
const address = this.pageDataUrl ? this.pageDataUrl : loc() + "/pageData"
this.tableLoading = true
this.$axios.post(address, data ? data : this.pageForm).then((res) => {
// 返回请求 Promise,供页面在列表数据加载结束后再显示主体内容。
return this.$axios.post(address, data ? data : this.pageForm).then((res) => {
if (res.code === 0) {
this.tableData = res.data.list
this.pageForm.totalCount = res.data.totalCount
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -1,12 +1,14 @@
<template>
<div>
<van-action-sheet
v-model="visible"
<van-popup
:value="visible"
class="activity-schedule-calendar"
:style="{ height: '78%' }"
:close-on-click-overlay="true"
title="活动日历"
closeable>
position="right"
:style="{ width: '100%', height: '100%' }"
:close-on-click-overlay="false"
:close-on-popstate="false">
<van-nav-bar title="课程日历" left-text="返回" left-arrow
@click-left="closeSignPopup('calendar')"></van-nav-bar>
<div class="activity-schedule-calendar__body">
<div class="activity-schedule-calendar__month-bar">
<button type="button" class="activity-schedule-calendar__month-btn" @click="changeMonth(-1)">
@@ -30,7 +32,10 @@
:class="{
'is-other-month': !day.currentMonth,
'is-selected': day.key === selectedDate,
'is-today': day.isToday
'is-today': day.isToday,
'is-signed': day.status === 'signed',
'is-available': day.status === 'available',
'is-pending': day.status === 'pending'
}"
@click="selectDay(day)">
<span>
@@ -42,13 +47,11 @@
</div>
<div class="activity-schedule-calendar__legend">
<span><i class="is-solid"></i>已签到</span>
<span><i class="is-outline"></i>可签到</span>
<span><i class="is-muted"></i>未开始</span>
<span><i class="is-signed"></i>已签到</span>
<span><i class="is-available"></i>可签到</span>
<span><i class="is-pending"></i>未开始</span>
</div>
<div v-if="showMap" :id="mapContainerId" class="activity-schedule-calendar__map"></div>
<section class="activity-schedule-calendar__schedule-card">
<div class="activity-schedule-calendar__schedule-title">
{{ selectedDate ? selectedDate + ' 的活动时段' : '活动时段' }}
@@ -72,6 +75,7 @@
v-else-if="canSignItem(item)"
type="button"
class="activity-schedule-calendar__sign-btn"
:disabled="formLoading"
@click.stop="onSign(item)">签到</button>
<span v-else class="activity-schedule-calendar__status" :class="{
'is-ended': getScheduleStatus(item) === '已结束',
@@ -88,16 +92,53 @@
</div>
</section>
</div>
</van-action-sheet>
</van-popup>
<van-popup
v-model="signVisible"
:value="positionConfirmVisible"
class="activity-schedule-position-popup"
position="right"
:style="{ width: '100%', height: '100%' }"
:close-on-click-overlay="false"
:close-on-popstate="false"
@opened="initMap"
@closed="onPositionPopupClosed">
<div class="activity-schedule-position">
<van-nav-bar title="定位签到" left-text="返回" left-arrow
@click-left="closeSignPopup('position-confirm')"></van-nav-bar>
<div :id="mapContainerId" class="activity-schedule-calendar__map"></div>
<div class="activity-schedule-position__details">
<div class="activity-schedule-position__location-tools">
<span class="activity-schedule-position__location-status" role="status">{{ locationMessage }}</span>
<van-button size="small" plain type="info" icon="aim" :loading="locationLoading"
:disabled="formLoading || !!closingPopupKey" @click="refreshLocation">重新定位</van-button>
</div>
<div class="activity-schedule-position__title">{{ row ? row.courseName : '' }}</div>
<div v-if="selectCourseTime" class="activity-schedule-position__text">
{{ dateKey(selectCourseTime) }} {{ formatTime(selectCourseTime.courseStartTime) }} - {{ formatTime(selectCourseTime.courseEndTime) }}
</div>
<div v-if="row && row.courseLocation" class="activity-schedule-position__text">
<van-icon name="location-o"></van-icon>{{ row.courseLocation }}
</div>
<div class="activity-schedule-position__text">签到范围:{{ row ? (row.radius || 100) : 100 }} 米</div>
</div>
<div class="activity-schedule-position__footer">
<van-button type="info" block round :loading="formLoading" :disabled="formLoading || locationLoading || !!closingPopupKey"
loading-text="签到中..." @click="onConfirmPositionSign">确认签到</van-button>
</div>
</div>
</van-popup>
<van-popup
:value="signVisible"
round
closeable
:safe-area-inset-bottom="true"
:close-on-click-overlay="false"
:close-on-popstate="false"
:style="{ width: '80%', height: '66%' }"
get-container="#app"
@click-close-icon="closeSignPopup('scan')"
@close="onSignClose">
<scan-code ref="scanCodeRef"></scan-code>
</van-popup>
@@ -126,7 +167,17 @@ module.exports = {
weekdays: ["日", "一", "二", "三", "四", "五", "六"],
selectCourseTime: null,
signVisible: false,
signCodePreview: null,
positionConfirmVisible: false,
formLoading: false,
popupHistoryToken: "",
calendarHistoryOpen: false,
closingPopupKey: "",
markerLayer: null,
userMarkerLayer: null,
locationLoading: false,
locationMessage: "",
locationRequestId: 0,
map: null,
circle: null,
mapContainerId: "activityScheduleMap_" + Math.random().toString(36).slice(2)
@@ -181,9 +232,11 @@ module.exports = {
this.currentMonth = date.month() + 1
this.selectedDate = initialDate
this.visible = true
this.$nextTick(() => {
if (this.showMap) this.initMap()
})
// 所有签到模式的日历均入栈;上层扫码、二维码或定位弹框单独入栈,返回时逐层关闭。
if (!this.calendarHistoryOpen) {
this.calendarHistoryOpen = true
window.h5PopupHistory.open(this.popupHistoryToken, "calendar")
}
},
dateKey(item) {
const value = item && (item.courseDate || item.courseStartTime)
@@ -230,18 +283,43 @@ module.exports = {
}
},
onSign(courseTime) {
// 确认框关闭或定位提交期间不再切换时段,避免重复打卡和签到对象错位。
if (this.formLoading || this.positionConfirmVisible || this.signVisible || this.signCodePreview || this.closingPopupKey) return
this.selectCourseTime = courseTime
if (this.row.signType === 1) {
this.signVisible = true
this.$nextTick(() => this.$refs.scanCodeRef.init())
window.h5PopupHistory.open(this.popupHistoryToken, "scan")
this.$nextTick(() => {
if (this.signVisible) this.$refs.scanCodeRef.init()
})
} else if (this.row.signType === 2) {
this.makeCode()
} else if (this.row.signType === 3 && this.moduleType === "brand") {
this.positionSign()
this.positionConfirmVisible = true
window.h5PopupHistory.open(this.popupHistoryToken, "position-confirm")
} else {
this.$toast("此签到模式正在升级中")
}
},
/**
* popupKey 为 calendar(日历)、scan(扫码)、sign-code(二维码)或 position-confirm(定位确认);无返回值。
* 仅请求关闭栈顶弹层,由全局历史回调同步显示状态,防止连续点击造成多次返回。
*/
closeSignPopup(popupKey) {
if (this.closingPopupKey) return
if (window.h5PopupHistory.close(this.popupHistoryToken, popupKey)) {
this.closingPopupKey = popupKey
}
},
// 预览定位不提交签到;点击确认时重新获取位置,失败时保留弹框和当前时段供重试。
onConfirmPositionSign() {
if (this.formLoading || this.locationLoading || this.closingPopupKey || !this.positionConfirmVisible) return
this.positionSign()
},
// 退场动画完成后释放地图;快速重新打开时保留新弹框正在使用的实例。
onPositionPopupClosed() {
if (!this.positionConfirmVisible) this.destroyMap()
},
onSignClose() {
if (this.$refs.scanCodeRef) this.$refs.scanCodeRef.closeScan()
},
@@ -250,90 +328,214 @@ module.exports = {
? "/platform/family/mine/passiveScan"
: "/platform/trainSignUp/mine/passiveScan"
const content = jrQrcode.getQrBase64(baseUrl + "?id=" + this.selectCourseTime.id)
vant.ImagePreview([content])
// 二维码预览关闭时同步回退历史;历史回调先释放引用,避免再次触发返回。
const preview = vant.ImagePreview({
images: [content],
closeOnPopstate: false,
onClose: () => {
if (this.signCodePreview === preview) this.closeSignPopup("sign-code")
}
})
this.signCodePreview = preview
window.h5PopupHistory.open(this.popupHistoryToken, "sign-code")
},
/**
* 确认后提交所选时段的点位签到,无方法参数、无返回值。
* 接口参数:timeId 为报名时段 ID,courseId 为课程 ID,lat/lng 为定位经纬度。
* 返回对象的 code 为 0 表示成功,msg 为失败提示;定位或请求结束后释放提交状态。
*/
positionSign() {
this.getLocation(coords => {
if (!coords) return
if (this.formLoading || this.locationLoading) return
const courseTime = this.selectCourseTime
const course = this.row
const requestId = ++this.locationRequestId
this.formLoading = true
this.locationMessage = "正在获取当前位置..."
this.getLocation((coords, errorMessage) => {
// 定位失败或等待定位时已退出地图弹框,不再发起签到请求。
if (!this.visible || !this.positionConfirmVisible || requestId !== this.locationRequestId || this.row !== course || this.selectCourseTime !== courseTime) {
this.formLoading = false
return
}
this.showCurrentLocation(coords, errorMessage)
if (!coords) {
this.formLoading = false
return
}
// 地图展示使用转换后的坐标,签到请求仍传手机返回的原始 WGS-84 经纬度。
this.$axios.post("/platform/trainSignUp/mine/positionSign", {
timeId: this.selectCourseTime.id,
courseId: this.row.id,
timeId: courseTime.id,
courseId: course.id,
lat: coords.lat,
lng: coords.lng
}).then(res => {
if (res.code === 0) {
this.selectCourseTime.isAttend = true
this.$set(courseTime, "isAttend", true)
this.$toast("签到成功")
// 签到完成后清理本组件的弹框历史,返回时不再出现已完成的确认框或日历。
return window.h5PopupHistory.clear(this.popupHistoryToken)
} else {
this.$toast(res.msg)
}
}).finally(() => {
this.formLoading = false
})
})
},
/**
* callback 接收浏览器 WGS-84 定位对象 {lat, lng, accuracy} 和可选失败提示;无返回值。
* lat/lng 为纬度/经度,accuracy 为精度半径(米);失败时坐标为 null。
* 预览和确认签到均重新定位,不使用上一次定位缓存,是否提交由调用方决定。
*/
getLocation(callback) {
if (!navigator.geolocation) {
this.$toast("当前浏览器不支持定位功能")
callback(null)
callback(null, "当前浏览器不支持定位功能")
return
}
const loading = vant.Toast.loading({ message: "获取定位中...", forbidClick: false, duration: 0 })
navigator.geolocation.getCurrentPosition(position => {
loading.close()
callback({ lat: position.coords.latitude, lng: position.coords.longitude })
callback({ lat: position.coords.latitude, lng: position.coords.longitude, accuracy: position.coords.accuracy })
}, error => {
loading.close()
const messageMap = {
1: "请允许浏览器获取位置信息",
2: "无法获取当前位置",
3: "定位超时,请重试"
}
this.$toast(messageMap[error.code] || "定位失败,请稍后重试")
callback(null)
}, { enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 })
callback(null, messageMap[error.code] || "定位失败,请稍后重试")
}, { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 })
},
// 打开地图和点击重新定位只刷新预览;请求编号隔离已关闭或已重新打开的弹框。
refreshLocation() {
if (!this.map || !this.positionConfirmVisible || this.locationLoading || this.formLoading || this.closingPopupKey) return
const requestId = ++this.locationRequestId
this.locationLoading = true
this.locationMessage = "正在获取当前位置..."
this.getLocation((coords, errorMessage) => {
if (requestId !== this.locationRequestId || !this.map || !this.positionConfirmVisible) return
this.locationLoading = false
this.showCurrentLocation(coords, errorMessage)
})
},
/**
* coords 为手机原始定位对象,errorMessage 为失败提示;无返回值,不提交签到。
* 国内坐标复用项目工具转换为腾讯地图使用的 GCJ-02,境外保留原坐标。
* 定位失败时移除旧位置点,避免将过期点位误认为当前位置。
*/
showCurrentLocation(coords, errorMessage) {
if (!coords) {
if (this.userMarkerLayer) this.userMarkerLayer.setGeometries([])
this.locationMessage = errorMessage || "暂未获取到当前位置"
this.$toast(this.locationMessage)
return
}
this.locationMessage = Number.isFinite(coords.accuracy)
? "定位精度约 " + Math.ceil(coords.accuracy) + " 米"
: "已显示我的位置"
if (!this.map || !this.userMarkerLayer) return
const converted = this.$coordinateUtil.outOfChina(coords.lng, coords.lat)
? [coords.lat, coords.lng]
: this.$coordinateUtil.wgs84ToGcj02(coords.lng, coords.lat)
const userPosition = new TMap.LatLng(converted[0], converted[1])
this.userMarkerLayer.setGeometries([{ id: "current-user", styleId: "user", position: userPosition }])
const activityPosition = this.markerLayer.getGeometries()[0].position
// 同时显示签到点和我的位置,并留出边距;两点重合时仍保留可辨识的地图范围。
const latPadding = Math.max(Math.abs(activityPosition.getLat() - converted[0]) * 0.3, 0.0003)
const lngPadding = Math.max(Math.abs(activityPosition.getLng() - converted[1]) * 0.3, 0.0003)
const bounds = new TMap.LatLngBounds(
new TMap.LatLng(Math.min(activityPosition.getLat(), converted[0]) - latPadding, Math.min(activityPosition.getLng(), converted[1]) - lngPadding),
new TMap.LatLng(Math.max(activityPosition.getLat(), converted[0]) + latPadding, Math.max(activityPosition.getLng(), converted[1]) + lngPadding)
)
this.map.fitBounds(bounds)
},
// 仅在侧滑弹框完全展开后创建地图,保证容器已有可用尺寸,避免动画期间地图空白。
initMap() {
if (!this.row.courseLocationCoordinates || typeof TMap === "undefined") return
if (!this.positionConfirmVisible || !this.showMap || !this.row.courseLocationCoordinates || typeof TMap === "undefined") return
const position = JSON.parse(this.row.courseLocationCoordinates)
const center = new TMap.LatLng(position[0], position[1])
if (this.map) this.map.destroy()
this.destroyMap()
this.map = new TMap.Map(this.mapContainerId, { zoom: 16, center })
this.markerLayer = new TMap.MultiMarker({
map: this.map,
geometries: [{ id: "activity", position: center }]
})
// 蓝点与“我的位置”标签独立于红色签到点;内嵌 SVG 不依赖外部图片请求。
const userIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="80" height="48" viewBox="0 0 80 48">'
+ '<rect x="2" y="1" width="76" height="22" rx="11" fill="#1989fa"/>'
+ '<text x="40" y="16" text-anchor="middle" font-family="sans-serif" font-size="12" fill="white">我的位置</text>'
+ '<circle cx="40" cy="36" r="11" fill="#1989fa" fill-opacity="0.2"/>'
+ '<circle cx="40" cy="36" r="6" fill="#1989fa" stroke="white" stroke-width="2"/></svg>'
this.userMarkerLayer = new TMap.MultiMarker({
map: this.map,
zIndex: 2,
styles: { user: new TMap.MarkerStyle({ width: 80, height: 48, anchor: { x: 40, y: 36 }, src: "data:image/svg+xml;charset=UTF-8," + encodeURIComponent(userIcon) }) },
geometries: []
})
this.circle = new TMap.MultiCircle({
map: this.map,
geometries: [{ center, radius: this.row.radius || 100 }]
})
this.refreshLocation()
},
// 关闭弹框或销毁组件时释放地图及覆盖物引用,避免重复打开后残留地图实例。
destroyMap() {
// 使尚未返回的定位请求失效,避免旧结果写入下一次打开的地图。
this.locationRequestId += 1
this.locationLoading = false
this.locationMessage = ""
if (this.map) this.map.destroy()
this.map = null
this.markerLayer = null
this.userMarkerLayer = null
this.circle = null
}
},
created() {
// 复用布局已加载的全局历史管理器,不在组件内注册 popstate 事件。
this.popupHistoryToken = window.h5PopupHistory.register((popupKey) => {
this.closingPopupKey = ""
if (popupKey === "calendar") {
this.calendarHistoryOpen = false
this.visible = false
} else if (popupKey === "scan") {
this.signVisible = false
} else if (popupKey === "sign-code") {
const preview = this.signCodePreview
this.signCodePreview = null
if (preview) preview.close()
} else if (popupKey === "position-confirm") {
this.positionConfirmVisible = false
this.locationRequestId += 1
this.locationLoading = false
}
})
},
beforeDestroy() {
// 页面离开时停止扫码并释放地图,再清理历史并注销回调,避免摄像头继续占用。
if (this.signVisible) this.onSignClose()
this.positionConfirmVisible = false
this.destroyMap()
window.h5PopupHistory.clear(this.popupHistoryToken).then(() => {
window.h5PopupHistory.unregister(this.popupHistoryToken)
})
}
}
</script>
<style scoped>
.activity-schedule-calendar {
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 18px 18px 0 0;
background: #f4f7fc;
}
.activity-schedule-calendar /deep/ .van-action-sheet__header {
height: 50px;
color: #1f2f43;
background: #fff;
font-size: 16px;
line-height: 50px;
}
.activity-schedule-calendar /deep/ .van-action-sheet__close {
top: 14px;
color: #7e899a;
font-size: 21px;
.activity-schedule-calendar > .van-nav-bar {
flex-shrink: 0;
}
.activity-schedule-calendar__body {
height: 100%;
padding: 14px 14px 24px;
flex: 1;
min-height: 0;
padding: 18px 14px calc(24px + env(safe-area-inset-bottom));
overflow-y: auto;
overflow-anchor: none;
box-sizing: border-box;
@@ -383,8 +585,9 @@ module.exports = {
.activity-schedule-calendar__day {
position: relative;
display: flex;
width: 38px;
height: 48px;
width: 100%;
max-width: 44px;
height: 52px;
margin: 1px auto;
padding: 0;
align-items: center;
@@ -400,13 +603,13 @@ module.exports = {
.activity-schedule-calendar__day > span {
position: absolute;
top: 4px;
top: 50%;
left: 50%;
transform: translateX(-50%);
transform: translate(-50%, -50%);
z-index: 1;
display: flex;
width: 40px;
height: 40px;
width: 34px;
height: 34px;
align-items: center;
justify-content: center;
border-radius: 50%;
@@ -436,15 +639,27 @@ module.exports = {
color: #bdc5d0;
}
.activity-schedule-calendar__day.is-signed > span {
color: #148a52;
}
.activity-schedule-calendar__day.is-available > span {
color: #1675d1;
}
.activity-schedule-calendar__day.is-pending > span {
color: #66758a;
}
/* 选中蓝底与外层状态环独立:切换日期时仍能分辨已签到、可签到和未开始。 */
.activity-schedule-calendar__day.is-selected > span {
flex: 0 0 40px;
width: 40px;
min-width: 40px;
height: 40px;
min-height: 40px;
color: #fff;
background: #1989fa;
box-shadow: 0 5px 12px rgba(25, 137, 250, .22);
box-shadow: 0 0 0 2px #fff;
}
.activity-schedule-calendar__day.is-today .activity-schedule-calendar__day-number {
margin-top: 4px;
}
.activity-schedule-calendar__day.is-selected .activity-schedule-calendar__today-label {
@@ -454,60 +669,150 @@ module.exports = {
.activity-schedule-calendar__day-status {
position: absolute;
bottom: -3px;
top: 50%;
left: 50%;
width: 6px;
height: 6px;
transform: translateX(-50%);
width: 42px;
height: 42px;
transform: translate(-50%, -50%);
border-radius: 50%;
box-sizing: border-box;
pointer-events: none;
}
.activity-schedule-calendar__day-status.is-signed {
background: #1989fa;
.activity-schedule-calendar__day-status.is-signed,
.activity-schedule-calendar__legend .is-signed {
border: 2px solid #19a965;
background: #eaf8f1;
}
.activity-schedule-calendar__day-status.is-available {
border: 1px solid #1989fa;
.activity-schedule-calendar__day-status.is-available,
.activity-schedule-calendar__legend .is-available {
border: 2px solid #1989fa;
background: #fff;
}
.activity-schedule-calendar__day-status.is-pending {
background: #d8dde5;
.activity-schedule-calendar__day-status.is-pending,
.activity-schedule-calendar__legend .is-pending {
border: 2px dashed #98a4b5;
}
.activity-schedule-calendar__legend {
display: flex;
margin: 12px 4px 16px;
margin: 16px 0 20px;
align-items: center;
justify-content: space-around;
color: #78869a;
font-size: 11px;
justify-content: space-between;
gap: 6px;
color: #58677b;
font-size: 12px;
}
.activity-schedule-calendar__legend span {
display: inline-flex;
padding: 8px;
align-items: center;
gap: 5px;
gap: 6px;
border-radius: 18px;
background: #fff;
white-space: nowrap;
}
.activity-schedule-calendar__legend i {
width: 9px;
height: 9px;
flex-shrink: 0;
width: 16px;
height: 16px;
border-radius: 50%;
box-sizing: border-box;
}
.activity-schedule-calendar__legend .is-solid { background: #1989fa; }
.activity-schedule-calendar__legend .is-outline { border: 1px solid #1989fa; }
.activity-schedule-calendar__legend .is-muted { background: #d8dde5; }
/* 小屏幕每列不足 42px 时缩小圆环,保留日期间距和选中态的白色间隔。 */
@media (max-width: 350px) {
.activity-schedule-calendar__day-status {
width: 38px;
height: 38px;
}
.activity-schedule-calendar__day > span {
width: 30px;
height: 30px;
}
.activity-schedule-calendar__today-label,
.activity-schedule-calendar__day.is-selected .activity-schedule-calendar__today-label {
top: 0;
}
}
.activity-schedule-position-popup {
background: #f4f7fc;
}
.activity-schedule-position {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
overflow-y: auto;
}
.activity-schedule-position > .van-nav-bar {
flex-shrink: 0;
}
.activity-schedule-calendar__map {
flex: 1 0 240px;
width: 100%;
height: 150px;
margin-bottom: 12px;
min-height: 240px;
overflow: hidden;
border-radius: 12px;
}
.activity-schedule-position__details {
flex-shrink: 0;
padding: 16px;
background: #fff;
}
.activity-schedule-position__title {
color: #263548;
font-size: 16px;
font-weight: 600;
line-height: 24px;
}
.activity-schedule-position__location-tools {
display: flex;
margin-bottom: 12px;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.activity-schedule-position__location-tools .van-button {
flex-shrink: 0;
}
.activity-schedule-position__location-status {
color: #78869a;
font-size: 12px;
line-height: 18px;
}
.activity-schedule-position__text {
margin-top: 6px;
color: #78869a;
font-size: 13px;
line-height: 20px;
overflow-wrap: anywhere;
}
.activity-schedule-position__text .van-icon {
margin-right: 4px;
vertical-align: middle;
}
.activity-schedule-position__footer {
flex-shrink: 0;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
background: #fff;
}
.activity-schedule-calendar__schedule-card {
@@ -8,9 +8,12 @@
placeholder="请输入关键词查询地址"
@select="handleSelect"
:trigger-on-focus="false"
:disabled="!map"
/>
</div>
<div id="mapContainer"></div>
<el-alert v-if="mapError" :title="mapError" type="error" :closable="false" show-icon></el-alert>
<div :id="mapContainerId" class="tmap-container" v-loading="mapLoading"></div>
<slot></slot>
</div>
</template>
@@ -33,9 +36,12 @@ module.exports = {
data() {
return {
map: null,
// Vue 实例编号区分同页多个地图,避免地图挂到其他弹窗的容器中。
mapContainerId: 'tmap-container-' + this._uid,
mapLoading: false,
mapError: '',
mapDisposed: false,
poi: this.position,
appMapCenterPointX: 0,
appMapCenterPointY: 0,
markerLayer: null,
addressValue: '',
circle: null,
@@ -43,7 +49,7 @@ module.exports = {
},
methods: {
handleSelect(item) {
if (item.location) {
if (this.map && item.location) {
// 定位到中心点
const center = new TMap.LatLng(item.location.lat, item.location.lng)
this.map.setCenter(center)
@@ -52,41 +58,84 @@ module.exports = {
this.poi = [item.location.lat, item.location.lng]
}
},
async querySearchAsync(queryString, cb) {
// queryString 为用户输入的地址关键词;接口 data.data 是地点数组,cb 接收带 value 的候选项数组。
querySearchAsync(queryString, cb) {
if (!queryString) {
cb([])
return
}
const res = await this.$axios.post("/platform/TMap/suggestion", {
return this.$axios.post("/platform/TMap/suggestion", {
keyword: queryString,
}).then((res) => {
if (this.mapDisposed) return
if (res.code === 0 && res.data.status === 0) {
res.data.data.forEach((item) => {
item.value = item.title + "【详细地址:" + item.address + "】"
})
cb(res.data.data)
} else {
this.$message.error((res.data && res.data.message) || res.msg || '地点搜索失败')
cb([])
}
}).catch(() => {
if (!this.mapDisposed) cb([])
})
if (res.code === 0 && res.data.status === 0) {
res.data.data.forEach((item) => {
item.value = item.title + "【详细地址:" + item.address + "】"
})
cb(res.data.data)
} else {
this.$message.error(res.msg)
cb([])
}
},
initMap() {
const center = new TMap.LatLng(this.appMapCenterPointY, this.appMapCenterPointX)
this.map = new TMap.Map('mapContainer', {
resizeEnable: true,
// 接收 [纬度, 经度],返回数值坐标或 null;拒绝空配置,防止空字符串被当成 0。
normalizePosition(position) {
if (!Array.isArray(position) || position.length !== 2) return null
if (position.some(value => !['number', 'string'].includes(typeof value) || String(value).trim() === '')) return null
const point = position.map(value => Number(value))
if (!point.every(value => Number.isFinite(value)) || Math.abs(point[0]) > 90 || Math.abs(point[1]) > 180) return null
return point
},
// 已设置的签到点直接用于回显;仅无有效点位时读取默认中心,避免配置请求阻断已有点位。
loadMap() {
if (this.mapDisposed || this.map || this.mapLoading) return
this.mapLoading = true
this.mapError = ''
return Promise.resolve().then(() => {
if (this.mapDisposed) return null
if (typeof TMap === 'undefined' || !TMap.Map || !TMap.LatLng) {
throw new Error('腾讯地图脚本未加载成功,请刷新页面后重试')
}
const point = this.normalizePosition(this.poi)
if (point) return point
return Promise.all([
this.getConfigKey('AppMapCenterPointY'),
this.getConfigKey('AppMapCenterPointX')
]).then(values => {
const center = this.normalizePosition(values)
if (!center) throw new Error('地图默认中心未配置或无效,请检查系统配置 AppMapCenterPointX(经度)、AppMapCenterPointY(纬度)')
return center
})
}).then(point => {
// 弹窗关闭后可能仍收到配置响应,禁止在已销毁的容器中创建地图。
if (!this.mapDisposed && point) this.initMap(point)
}).catch(error => {
if (this.mapDisposed) return
this.mapError = (error && (error.message || error.msg)) || '地图初始化失败,请刷新页面后重试'
this.destroyMap()
}).finally(() => {
if (!this.mapDisposed) this.mapLoading = false
})
},
// point 为经过校验的 [纬度, 经度];只设置显示中心,不将默认中心自动保存为签到点。
initMap(point) {
const center = new TMap.LatLng(point[0], point[1])
this.map = new TMap.Map(this.mapContainerId, {
zoom: 16,
center: center
})
// 初始化
// 标记和签到半径共用同一个中心,点击选点后向表单回传腾讯地图坐标。
this.markerLayer = new TMap.MultiMarker({
map: this.map,
geometries: []
});
if (this.poi && this.poi.length > 0) {
const posi = new TMap.LatLng(this.poi[0], this.poi[1])
this.createMarker(posi)
this.map.setCenter(posi)
} else {
this.createMarker(center)
// 默认中心仅用于浏览地图;只有已配置的有效坐标才回显签到标记和范围圈。
const selectedPoint = this.normalizePosition(this.poi)
if (selectedPoint) {
this.createMarker(new TMap.LatLng(selectedPoint[0], selectedPoint[1]))
}
this.map.on("click", (event) => {
if (!this.view) {
@@ -113,17 +162,24 @@ module.exports = {
position: position
}
]);
this.circle = new TMap.MultiCircle({
map: this.map,
geometries: [{
center: position,
radius: this.radius,
}],
});
const geometries = [{center: position, radius: this.radius}]
// 重复选点复用半径图层,避免每次点击遗留一个空图层。
if (this.circle) {
this.circle.setGeometries(geometries)
} else {
this.circle = new TMap.MultiCircle({map: this.map, geometries})
}
},
async getConfigKey(key) {
const resp = await this.$axios.post("/open/common/getConfigKey", { key })
return resp.data
// key 传系统配置名 AppMapCenterPointX/Y;返回 Promise,值为 Result.data 中的经纬度配置字符串。
getConfigKey(key) {
return this.$axios.post("/open/common/getConfigKey", { key }).then(resp => resp.data)
},
// 弹窗关闭或初始化失败时释放地图及其图层、事件和 WebGL 资源。
destroyMap() {
if (this.map) this.map.destroy()
this.map = null
this.markerLayer = null
this.circle = null
}
},
watch: {
@@ -134,19 +190,19 @@ module.exports = {
this.$emit("update:position", newVal)
}
},
async mounted() {
// DOM初始化完成进行地图初始化
this.$nextTick(async () => {
this.appMapCenterPointX = await this.getConfigKey("AppMapCenterPointX")
this.appMapCenterPointY = await this.getConfigKey("AppMapCenterPointY")
this.initMap()
})
mounted() {
// 父弹窗显示完成后挂载组件,再等待当前容器完成渲染。
this.$nextTick(() => this.loadMap())
},
beforeDestroy() {
this.mapDisposed = true
this.destroyMap()
}
}
</script>
<style scoped>
#mapContainer {
.tmap-container {
padding: 0;
margin: 0;
width: 100%;
@@ -399,6 +399,14 @@ layout("/layouts/v4/baseLayout.html"){
// 默认选中
getMenuOwnerPath() {
const pathname = window.location.pathname
// API配置表单不是独立菜单,直接刷新表单地址时仍归属API配置菜单。
if (pathname === "/platform/personGovernance/apiConfig/form") {
return "/platform/personGovernance/apiConfig"
}
// 会员匹配表单不是独立菜单,直接刷新表单地址时仍归属会员匹配菜单。
if (pathname === "/platform/personGovernance/memberMatch/form") {
return "/platform/personGovernance/memberMatch"
}
if (pathname === "/platform/qsv/survey/reportPage") {
return "/platform/qsv/survey"
}
@@ -409,6 +417,12 @@ layout("/layouts/v4/baseLayout.html"){
},
getMenuOwnerFullPath() {
if (window.location.pathname === "/platform/personGovernance/apiConfig/form") {
return "/platform/personGovernance/apiConfig"
}
if (window.location.pathname === "/platform/personGovernance/memberMatch/form") {
return "/platform/personGovernance/memberMatch"
}
if (window.location.pathname === "/platform/qsv/survey/reportPage") {
return "/platform/qsv/survey"
}
@@ -77,7 +77,7 @@
src="https://webapi.amap.com/maps?v=2.0&key=57f0d098ba1b881ecc436c4cfd23bbbf&plugin=AMap.PolyEditor,AMap.Geolocation"
></script>
<script src="https://map.qq.com/api/gljs?v=2.exp&key=MLLBZ-GQECI-ASXG7-5GNOZ-XW2OF-H5BVH"></script>
<script src="https://map.qq.com/api/gljs?v=2.exp&key=NYNBZ-K62KI-DQJGU-U4NJ5-CSME7-ULFMU"></script>
<script nonce="${cspNonce!}">
Vue.config.devtools = true
@@ -323,7 +323,8 @@
let h5GlobalLoading = null
let h5PendingRequestCount = 0
function showH5GlobalLoading(icon = "/assets/mobile/img/common/smart-union-loading-red.svg") {
// 更新资源版本,避免继续命中缺少 SVG 响应类型的旧缓存。
function showH5GlobalLoading(icon = "/assets/mobile/img/common/smart-union-loading-red.svg?v=20261014") {
if (!h5GlobalLoading) {
h5GlobalLoading = vant.Toast.loading({
icon: icon,
@@ -375,7 +376,7 @@
})
}
function createListLoading(icon = "/assets/mobile/img/common/smart-union-loading-red.svg") {
function createListLoading(icon = "/assets/mobile/img/common/smart-union-loading-red.svg?v=20261014") {
showH5GlobalLoading(icon)
const closeLoading = function() {
@@ -25,6 +25,10 @@
<link rel="stylesheet" href="${base!}/assets/platform/plugins/element-ui/lib/theme-chalk/index_custom.css" />
<link rel="stylesheet" href="${base!}/assets/platform/plugins/element-ui/lib/theme-chalk/override.css" />
<!-- 人员数据治理的接口配置表单使用 Cron 可视化选择器。 -->
<link rel="stylesheet" href="${base!}/assets/platform/plugins/vue-cron/vcrontab.css" />
<script src="${base!}/assets/platform/plugins/vue-cron/vcrontab.umd.min.js"></script>
<link rel="stylesheet" href="${base!}/assets/platform/plugins/fullcalendar/fullcalendar.css">
<!-- import common css -->
@@ -99,7 +103,7 @@
src="https://webapi.amap.com/maps?v=2.0&key=b4fa2e2bc10a725ab007b98a3aa447cc&plugin=AMap.PolyEditor,AMap.Geolocation"
></script>-->
<script src="https://map.qq.com/api/gljs?v=2.exp&key=MLLBZ-GQECI-ASXG7-5GNOZ-XW2OF-H5BVH"></script>
<script src="https://map.qq.com/api/gljs?v=2.exp&key=NYNBZ-K62KI-DQJGU-U4NJ5-CSME7-ULFMU"></script>
<script nonce="${cspNonce!}">
// 在加载 lodash 后、使用前插入
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,202 @@
<!--# layout("/layouts/platform.html"){ #-->
<link rel="stylesheet" href="${base!}/assets/platform/css/personGovernance.css?v=1.0.22" />
<div
id="app"
v-cloak
class="person-governance-list-page person-governance-page-transition"
:class="{'is-ready':pageReady,'is-leaving':pageLeaving}"
>
<el-card shadow="never" class="person-governance-search-card">
<el-form :inline="true" :model="pageForm">
<el-form-item label="配置名称"><el-input v-model="pageForm.configName" clearable></el-input></el-form-item>
<el-form-item label="同步方式">
<el-select v-model="pageForm.sourceType" clearable filterable>
<el-option label="请求第三方接口" value="HTTP_API"></el-option>
<el-option label="读取系统表数据" value="SYSTEM_TABLE"></el-option>
</el-select>
</el-form-item>
<el-form-item label="启用状态">
<el-select v-model="pageForm.enabled" clearable filterable>
<el-option label="启用" :value="true"></el-option>
<el-option label="停用" :value="false"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="doSearch">查询</el-button>
<el-button @click="resetSearch">重置</el-button>
</el-form-item>
<el-button class="pull-right" type="primary" icon="el-icon-plus" @click="openType">新增</el-button>
</el-form>
</el-card>
<el-card shadow="never" class="person-governance-list-card">
<div class="person-governance-table-wrap">
<el-table ref="dataTable" :data="tableData" v-loading="tableLoading" height="100%">
<el-table-column
type="index"
:index="indexMethod"
label="序号"
width="70"
align="center"
></el-table-column>
<el-table-column prop="configName" label="配置名称" min-width="160"></el-table-column>
<el-table-column label="同步方式" width="150">
<template v-slot="{row}">
{{row.sourceType==='HTTP_API'?'请求第三方接口':'读取系统表数据'}}
</template>
</el-table-column>
<el-table-column prop="targetTable" label="目标系统表" min-width="150"></el-table-column>
<el-table-column prop="lastSyncTime" label="最近同步时间" width="180"></el-table-column>
<el-table-column label="是否启用" width="150" align="center">
<template v-slot="{row}">
{{row.enabled ? '是' : '否'}}
</template>
</el-table-column>
<el-table-column label="操作" width="130">
<template v-slot="{row}">
<el-button type="text" @click="edit(row)">编辑</el-button>
<el-button type="text" class="text-danger" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
<el-dialog
title="选择API配置类型"
:visible.sync="typeVisible"
:append-to-body="true"
:close-on-click-modal="false"
width="520px"
custom-class="person-governance-dialog"
@closed="handleTypeClosed"
>
<el-alert title="请选择配置的数据来源,选择后直接进入对应配置页面" type="info" :closable="false"></el-alert>
<el-row :gutter="16" class="mt20">
<el-col :span="12">
<el-card shadow="hover" @click.native="create('HTTP_API')">
<h4>请求第三方接口</h4>
<p>通过GET、POST等方式获取第三方人员数据</p>
<el-link type="primary">选择</el-link>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="hover" @click.native="create('SYSTEM_TABLE')">
<h4>读取系统表数据</h4>
<p>读取第三方已推送到系统表中的人员数据</p>
<el-link type="primary">选择</el-link>
</el-card>
</el-col>
</el-row>
<span slot="footer"><el-button @click="closeType">取消</el-button></span>
</el-dialog>
</div>
<script>
// 使用独立作用域承载页面实例,避免列表页与表单页通过 PJAX 反复切换时重复声明顶层变量。
;(() => {
new Vue({
el: '#app',
mixins: [initTableMixins],
data() {
return {
pageReady: false,
pageLeaving: false,
typeVisible: false,
pendingCreateType: '',
pageForm: { configName: '', sourceType: '', enabled: '', pageNumber: 1, pageSize: 10, totalCount: 0 },
}
},
methods: {
pageData() {
this.$set(this, 'tableLoading', true)
return $.post(loc() + '/pageData', this.pageForm)
.then((res) => {
if (res.code === 0) {
this.$set(this, 'tableData', res.data.list)
this.$set(this.pageForm, 'totalCount', res.data.totalCount)
} else {
this.notifyWarning(res.msg)
}
})
.always(() => {
this.$set(this, 'tableLoading', false)
})
},
openType() {
if (this.pendingCreateType || this.pageLeaving) {
return
}
this.$set(this, 'typeVisible', true)
},
closeType() {
this.$set(this, 'pendingCreateType', '')
this.$set(this, 'typeVisible', false)
},
// 等待弹窗退出动画完成后再离开列表,避免body上的弹窗和遮罩在跳转期间残留。
handleTypeClosed() {
const type = this.pendingCreateType
this.$set(this, 'pendingCreateType', '')
if (!type || this.pageLeaving) {
return
}
this.leavePage('/platform/personGovernance/apiConfig/form?sourceType=' + type)
},
resetSearch() {
this.$set(this.pageForm, 'configName', '')
this.$set(this.pageForm, 'sourceType', '')
this.$set(this.pageForm, 'enabled', '')
this.doSearch()
},
showPage() {
this.$nextTick(() => {
if (this.$refs.dataTable && this.$refs.dataTable.doLayout) {
this.$refs.dataTable.doLayout()
}
window.requestAnimationFrame(() => {
this.$set(this, 'pageReady', true)
})
})
},
leavePage(url) {
this.$set(this, 'pageLeaving', true)
window.setTimeout(() => {
// 仅替换右侧内容区域,保留左侧导航实例及已经加载的菜单数据。
commonUtil.pjaxPush(url)
}, 180)
},
// 弹窗关闭后通过 PJAX 打开表单页,避免整个后台布局重新加载。
create(type) {
// 关闭期间忽略重复选择;仅记录目标类型,由closed事件统一触发跳转。
if (!this.typeVisible || this.pendingCreateType || this.pageLeaving) {
return
}
this.$set(this, 'pendingCreateType', type)
this.$set(this, 'typeVisible', false)
},
edit(row) {
this.leavePage('/platform/personGovernance/apiConfig/form?id=' + row.id)
},
remove(row) {
this.$confirm('删除后不可继续同步,确认删除吗?', '提示').then(() => {
$.post(loc() + '/delete', { id: row.id })
.then((res) => {
if (res.code === 0) {
this.notifySuccess('删除成功')
this.pageData()
} else {
this.notifyWarning(res.msg)
}
})
.always(() => {})
})
},
},
created() {
this.pageData().always(() => {
this.showPage()
})
},
})
})()
</script>
<!--# } #-->
@@ -0,0 +1,643 @@
<!--# layout("/layouts/platform.html"){ #-->
<link rel="stylesheet" href="${base!}/assets/platform/css/personGovernance.css?v=1.0.24" />
<div
id="app"
v-cloak
class="person-governance-form-page person-governance-page-transition"
:class="{'is-ready':pageReady,'is-leaving':pageLeaving}"
>
<div class="person-governance-page-header">
<el-page-header @back="back"></el-page-header>
</div>
<el-card shadow="never" class="person-governance-form-basic-card">
<el-alert
class="person-governance-member-binding-hint"
title="每个API配置仅可绑定一个会员匹配配置。"
type="info"
:closable="false"
></el-alert>
<el-form :model="form" label-width="110px">
<div class="person-governance-form-grid">
<el-form-item label="配置名称"><el-input v-model="form.configName"></el-input></el-form-item>
<el-form-item label="选择API配置">
<el-select
v-model="form.apiConfigId"
:disabled="!!form.id"
clearable
filterable
style="width: 100%"
@change="apiChanged"
>
<el-option
v-for="a in apiConfigs"
:key="a.id"
:disabled="!a.selectable"
:label="a.selectable?a.configName:a.configName+'(已绑定:'+a.matchConfigName+')'"
:value="a.id"
></el-option>
</el-select>
<small v-if="form.id">该API配置已与当前会员匹配配置绑定,编辑时不可修改</small>
</el-form-item>
<el-form-item v-if="!form.id" label="沿用历史匹配">
<el-select v-model="copyId" clearable filterable @change="copyHistory">
<el-option v-for="h in histories" :key="h.id" :label="h.configName" :value="h.id"></el-option>
</el-select>
</el-form-item>
<el-form-item label="启用"><el-switch v-model="form.enabled"></el-switch></el-form-item>
</div>
</el-form>
</el-card>
<el-tabs
v-model="activeRuleType"
class="person-governance-member-type-tabs"
@tab-click="ruleTabChanged"
>
<el-tab-pane label="工会会员匹配规则配置" name="UNION_MEMBER"></el-tab-pane>
<el-tab-pane label="福利会员匹配规则配置" name="WELFARE_MEMBER"></el-tab-pane>
</el-tabs>
<el-row :gutter="20" class="person-governance-form-content-row">
<el-col :span="16">
<el-card shadow="never" class="person-governance-member-rules">
<div class="person-governance-member-rules-header">
<h3>{{activeRuleTitle}}</h3>
<el-alert
title="条件及子条件组之间可切换且/或;每个条件组(含子条件组)都作为独立括号。"
type="info"
:closable="false"
></el-alert>
</div>
<div class="person-governance-member-rules-content">
<div v-for="(g,gi) in groups" :key="gi">
<el-divider v-if="gi>0" class="person-governance-member-group-relation">
<el-radio-group
:value="g.joinLogic"
size="mini"
aria-label="与上一条件组的关系"
@input="setGroupLogic(g, $event)"
>
<el-radio-button label="AND">且</el-radio-button>
<el-radio-button label="OR">或</el-radio-button>
</el-radio-group>
</el-divider>
<member-condition-group
:group="g"
:group-path="String(gi+1)"
:depth="0"
:fields="fields"
:create-condition="emptyCondition"
@load-dict="loadDict"
@dict-changed="dictChanged"
@copy-self="copyGroup(gi)"
@delete-self="removeGroup(gi)"
></member-condition-group>
</div>
<el-button class="mt20" type="primary" plain style="width: 100%" @click="addGroup">
新增条件组
</el-button>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="never" class="person-governance-member-preview">
<h3>{{activePreviewTitle}}</h3>
<p>
预计匹配
<strong>{{previewTotal}}</strong>
人
</p>
<el-button
type="primary"
:disabled="!form.id || !groups.length"
:loading="previewLoading"
@click="calculate"
>
试算匹配
</el-button>
<div class="person-governance-member-preview-table">
<el-table ref="previewTable" :data="previewRows" height="100%" v-loading="previewLoading">
<el-table-column prop="loginname" label="工号" show-overflow-tooltip></el-table-column>
<el-table-column prop="username" label="姓名" show-overflow-tooltip></el-table-column>
<el-table-column prop="unitName" label="单位" show-overflow-tooltip></el-table-column>
<el-table-column prop="unionName" label="工会" show-overflow-tooltip></el-table-column>
</el-table>
</div>
<div class="person-governance-member-preview-pagination">
<el-pagination
@current-change="previewPageNumberChange"
:current-page="previewPageForm.pageNumber"
:page-size="previewPageForm.pageSize"
layout="total, prev, pager, next"
:total="previewPageForm.totalCount"
></el-pagination>
</div>
</el-card>
</el-col>
</el-row>
<div class="text-right person-governance-form-footer">
<el-button @click="back">取消</el-button>
<el-button type="primary" :loading="formLoading" @click="save">保存</el-button>
</div>
</div>
<script type="text/x-template" id="member-condition-group-template">
<div class="person-governance-member-group" :class="{'is-nested':depth>0}">
<div class="person-governance-member-group-header">
<strong>条件组 {{groupPath}}</strong>
<span class="pull-right">
<el-button type="text" @click="$emit('copy-self')">复制条件组</el-button>
<el-button type="text" class="text-danger" @click="$emit('delete-self')">删除条件组</el-button>
</span>
</div>
<el-table
v-if="group.conditions.length"
:data="conditionRows"
:span-method="conditionSpan"
:row-class-name="conditionRowClass"
border
>
<el-table-column label="字典编码">
<template v-slot="{row}">
<el-radio-group
v-if="row.isRelation"
:value="row.condition.joinLogic"
size="mini"
aria-label="与上一条件的关系"
@input="setConditionLogic(row.condition, $event)"
>
<el-radio-button label="AND">且</el-radio-button>
<el-radio-button label="OR">或</el-radio-button>
</el-radio-group>
<el-input v-else v-model="row.dictCode" @change="$emit('load-dict', row)">
<el-button slot="append" @click="$emit('load-dict', row)">加载</el-button>
</el-input>
</template>
</el-table-column>
<el-table-column label="字典数据">
<template v-slot="{row}">
<el-select
v-model="row.dictItemId"
clearable
filterable
@change="$emit('dict-changed', row)"
>
<el-option
v-for="d in row.dictItems||[]"
:key="d.id"
:label="d.name+'('+d.code+')'"
:value="d.id"
></el-option>
</el-select>
</template>
</el-table-column>
<el-table-column label="映射字段">
<template v-slot="{row}">
<el-select v-model="row.mappedField" clearable filterable>
<el-option
v-for="f in fields"
:key="f.targetField"
:label="f.targetComment+'('+f.targetField+')'"
:value="f.targetField"
></el-option>
</el-select>
</template>
</el-table-column>
<el-table-column label="匹配方式">
<template v-slot="{row}">
<el-select v-model="row.operator" clearable filterable>
<el-option label="等于" value="EQ"></el-option>
<el-option label="不等于" value="NE"></el-option>
<el-option label="包含" value="CONTAINS"></el-option>
<el-option label="在集合中" value="IN"></el-option>
</el-select>
</template>
</el-table-column>
<el-table-column label="匹配值">
<template v-slot="{row}"><el-input v-model="row.matchValue"></el-input></template>
</el-table-column>
<el-table-column label="操作" width="70">
<template v-slot="{$index}">
<el-button type="text" class="text-danger" @click="removeCondition($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="person-governance-member-group-actions">
<el-button type="primary" plain @click="addCondition">新增条件</el-button>
<el-button type="primary" plain @click="addChildGroup">新增子条件组</el-button>
</div>
<div v-for="(child,childIndex) in group.groups" :key="childIndex">
<el-divider
v-if="group.conditions.length||childIndex>0"
class="person-governance-member-group-relation person-governance-member-child-relation"
>
<el-radio-group
:value="child.joinLogic"
size="mini"
aria-label="与上一条件或条件组的关系"
@input="setGroupLogic(child, $event)"
>
<el-radio-button label="AND">且</el-radio-button>
<el-radio-button label="OR">或</el-radio-button>
</el-radio-group>
</el-divider>
<member-condition-group
:group="child"
:group-path="groupPath+'.'+(childIndex+1)"
:depth="depth+1"
:fields="fields"
:create-condition="createCondition"
@load-dict="$emit('load-dict', $event)"
@dict-changed="$emit('dict-changed', $event)"
@copy-self="copyChildGroup(childIndex)"
@delete-self="deleteChildGroup(childIndex)"
></member-condition-group>
</div>
</div>
</script>
<script>
// 使用独立作用域承载页面常量、组件和实例,避免 PJAX 多次进入时重复声明。
;(() => {
const apiBase = '/platform/personGovernance/apiConfig'
const memberBase = '/platform/personGovernance/memberMatch'
Vue.component('member-condition-group', {
template: '#member-condition-group-template',
props: {
group: { type: Object, required: true },
groupPath: { type: String, required: true },
depth: { type: Number, required: true },
fields: { type: Array, required: true },
createCondition: { type: Function, required: true },
},
computed: {
conditionRows() {
const rows = []
this.group.conditions.forEach((condition, index) => {
if (index > 0) rows.push({ isRelation: true, condition: condition })
rows.push(condition)
})
return rows
},
},
methods: {
conditionSpan({ row, columnIndex }) {
if (row.isRelation) return columnIndex === 0 ? [1, 6] : [0, 0]
return [1, 1]
},
conditionRowClass({ row }) {
return row.isRelation ? 'person-governance-member-condition-relation' : ''
},
setConditionLogic(condition, value) {
this.$set(condition, 'joinLogic', value)
},
setGroupLogic(group, value) {
this.$set(group, 'joinLogic', value)
},
addCondition() {
this.group.conditions.push(this.createCondition())
},
// 表格包含连接行,实际条件索引为展示行索引的一半。
removeCondition(rowIndex) {
this.group.conditions.splice(rowIndex / 2, 1)
},
addChildGroup() {
this.group.groups.push({
joinLogic: 'OR',
conditions: [this.createCondition()],
groups: [],
})
},
copyChildGroup(index) {
this.group.groups.splice(index + 1, 0, JSON.parse(JSON.stringify(this.group.groups[index])))
},
deleteChildGroup(index) {
this.group.groups.splice(index, 1)
},
},
})
new Vue({
el: '#app',
mixins: [initTableMixins],
data() {
return {
pageReady: false,
pageLeaving: false,
form: { id: '', configName: '', apiConfigId: '', enabled: true },
copyId: '',
apiConfigs: [],
histories: [],
fields: [],
activeRuleType: 'UNION_MEMBER',
ruleGroups: {
UNION_MEMBER: [],
WELFARE_MEMBER: [],
},
previewByType: {
UNION_MEMBER: {
rows: [],
total: 0,
loading: false,
pageForm: { pageNumber: 1, pageSize: 10, totalCount: 0 },
},
WELFARE_MEMBER: {
rows: [],
total: 0,
loading: false,
pageForm: { pageNumber: 1, pageSize: 10, totalCount: 0 },
},
},
}
},
computed: {
groups() {
return this.ruleGroups[this.activeRuleType]
},
currentPreview() {
return this.previewByType[this.activeRuleType]
},
previewRows() {
return this.currentPreview.rows
},
previewTotal() {
return this.currentPreview.total
},
previewLoading() {
return this.currentPreview.loading
},
previewPageForm() {
return this.currentPreview.pageForm
},
activeRuleTitle() {
return this.activeRuleType === 'WELFARE_MEMBER'
? '福利会员匹配规则配置'
: '工会会员匹配规则配置'
},
activePreviewTitle() {
return this.activeRuleType === 'WELFARE_MEMBER'
? '福利会员匹配结果预览'
: '工会会员匹配结果预览'
},
},
methods: {
emptyCondition() {
return {
dictCode: '',
dictItemId: '',
dictItemCode: '',
dictItemName: '',
dictItems: [],
mappedField: '',
operator: 'EQ',
matchValue: '',
joinLogic: 'AND',
}
},
addGroup() {
this.groups.push({ joinLogic: 'OR', conditions: [this.emptyCondition()], groups: [] })
},
copyGroup(i) {
this.groups.splice(i + 1, 0, JSON.parse(JSON.stringify(this.groups[i])))
},
removeGroup(i) {
this.groups.splice(i, 1)
},
setGroupLogic(group, value) {
this.$set(group, 'joinLogic', value)
},
// 历史记录没有父子关系时保持顶层结构;新数据递归恢复所有子条件组。
restoreGroups(rows) {
return (rows || []).map((row) => {
const sourceGroup = row.group || row
return {
joinLogic: sourceGroup.joinLogic || 'OR',
conditions: (row.conditions || []).map((condition) => ({
...condition,
joinLogic: condition.joinLogic || 'AND',
})),
groups: this.restoreGroups(row.groups),
}
})
},
// 对工会、福利两棵规则树执行相同处理,避免只遍历顶层条件组。
walkGroups(groups, callback) {
(groups || []).forEach((group) => {
callback(group)
this.walkGroups(group.groups, callback)
})
},
loadApis(current) {
return $.post(apiBase + '/selectableConfigs', { currentMatchConfigId: current || '' })
.then((res) => {
if (res.code === 0) {
this.$set(this, 'apiConfigs', res.data)
}
})
.always(() => {})
},
loadHistories() {
return $.post(memberBase + '/historyConfigs')
.then((res) => {
if (res.code === 0) {
this.$set(this, 'histories', res.data)
}
})
.always(() => {})
},
apiChanged(id) {
if (!id) {
this.$set(this, 'fields', [])
Object.keys(this.ruleGroups).forEach((ruleType) => {
this.walkGroups(this.ruleGroups[ruleType], (group) => {
group.conditions.forEach((condition) => {
this.$set(condition, 'mappedField', '')
})
})
})
return $.when()
}
return $.post(apiBase + '/detail', { id: id })
.then((res) => {
if (res.code === 0) {
this.$set(this, 'fields', res.data.mappings || [])
}
})
.always(() => {})
},
copyHistory(id) {
if (!id) {
return
}
$.post(memberBase + '/detail', { id: id })
.then((res) => {
if (res.code === 0) {
this.$set(
this.ruleGroups,
'UNION_MEMBER',
this.restoreGroups(res.data.unionGroups || res.data.groups),
)
this.$set(
this.ruleGroups,
'WELFARE_MEMBER',
this.restoreGroups(res.data.welfareGroups),
)
this.loadGroupDicts()
}
})
.always(() => {})
},
loadDict(row) {
if (!row.dictCode) {
return
}
$.post(memberBase + '/dictItems', { dictCode: row.dictCode })
.then((res) => {
if (res.code === 0) {
this.$set(row, 'dictItems', res.data)
} else this.notifyWarning(res.msg)
})
.always(() => {})
},
dictChanged(row) {
const d = (row.dictItems || []).find((v) => v.id === row.dictItemId)
if (d) {
this.$set(row, 'dictItemCode', d.code)
this.$set(row, 'dictItemName', d.name)
if (!row.matchValue) {
this.$set(row, 'matchValue', d.code)
}
return
}
const previousCode = row.dictItemCode
this.$set(row, 'dictItemCode', '')
this.$set(row, 'dictItemName', '')
if (row.matchValue === previousCode) {
this.$set(row, 'matchValue', '')
}
},
save() {
if (!this.form.configName || !this.form.apiConfigId) {
this.notifyWarning('请填写配置名称并选择API配置')
return
}
this.$set(this, 'formLoading', true)
$.post(memberBase + '/save', {
data: JSON.stringify({
config: this.form,
unionGroups: this.ruleGroups.UNION_MEMBER,
welfareGroups: this.ruleGroups.WELFARE_MEMBER,
}),
})
.then((res) => {
if (res.code === 0) {
this.notifySuccess('保存成功')
this.$set(this.form, 'id', res.data.id)
} else this.notifyWarning(res.msg)
})
.always(() => {
this.$set(this, 'formLoading', false)
})
},
calculate(resetPage) {
if (!this.form.id) {
return
}
if (resetPage) {
this.$set(this.currentPreview.pageForm, 'pageNumber', 1)
}
const ruleType = this.activeRuleType
const preview = this.previewByType[ruleType]
this.$set(preview, 'loading', true)
$.post(memberBase + '/calculate', {
id: this.form.id,
ruleType: ruleType,
pageNumber: preview.pageForm.pageNumber,
pageSize: preview.pageForm.pageSize,
})
.then((res) => {
if (res.code === 0) {
this.$set(preview, 'rows', res.data.list)
this.$set(preview, 'total', res.data.totalCount)
this.$set(preview.pageForm, 'totalCount', res.data.totalCount)
} else this.notifyWarning(res.msg)
})
.always(() => {
this.$set(preview, 'loading', false)
this.$nextTick(() => {
if (this.$refs.previewTable) this.$refs.previewTable.doLayout()
})
})
},
// 匹配预览分页仅切换当前页,查询条件和每页条数保持不变。
previewPageNumberChange(number) {
this.$set(this.currentPreview.pageForm, 'pageNumber', number)
this.calculate()
},
load(id) {
return $.post(memberBase + '/detail', { id: id })
.then((res) => {
if (res.code === 0) {
this.$set(this, 'form', res.data.config)
this.$set(
this.ruleGroups,
'UNION_MEMBER',
this.restoreGroups(res.data.unionGroups || res.data.groups),
)
this.$set(
this.ruleGroups,
'WELFARE_MEMBER',
this.restoreGroups(res.data.welfareGroups),
)
this.loadGroupDicts()
return this.apiChanged(this.form.apiConfigId)
}
return $.when()
})
.always(() => {})
},
showPage() {
this.$nextTick(() => {
if (this.$refs.previewTable) this.$refs.previewTable.doLayout()
window.requestAnimationFrame(() => {
this.$set(this, 'pageReady', true)
})
})
},
// 两类规则分别恢复字典候选项,切换页签时不重新覆盖另一类规则的数据。
loadGroupDicts() {
Object.keys(this.ruleGroups).forEach((ruleType) => {
this.walkGroups(this.ruleGroups[ruleType], (group) => {
group.conditions.forEach((condition) => this.loadDict(condition))
})
})
},
ruleTabChanged() {
this.$nextTick(() => {
if (this.$refs.previewTable) this.$refs.previewTable.doLayout()
})
},
leavePage(url) {
this.$set(this, 'pageLeaving', true)
window.setTimeout(() => {
// 仅替换右侧内容区域,返回列表时不销毁或重新请求左侧菜单。
commonUtil.pjaxPush(url)
}, 180)
},
// 使用 PJAX 返回列表,并保留跳转前的页面淡出效果。
back() {
this.leavePage(memberBase)
},
},
created() {
const id = new URLSearchParams(window.location.search).get('id')
const requests = [this.loadApis(id), this.loadHistories()]
if (id) {
requests.push(this.load(id))
} else {
this.addGroup()
}
$.when.apply($, requests).always(() => {
this.showPage()
})
},
})
})()
</script>
<!--# } #-->
@@ -0,0 +1,197 @@
<!--# layout("/layouts/platform.html"){ #-->
<link rel="stylesheet" href="${base!}/assets/platform/css/personGovernance.css?v=1.0.23" />
<div
id="app"
v-cloak
class="person-governance-list-page person-governance-compact-gap person-governance-page-transition"
:class="{'is-ready':pageReady,'is-leaving':pageLeaving}"
>
<el-card shadow="never" class="person-governance-search-card">
<el-form :inline="true" :model="pageForm">
<el-form-item label="配置名称"><el-input v-model="pageForm.configName" clearable></el-input></el-form-item>
<el-form-item label="API配置">
<el-select v-model="pageForm.apiConfigId" clearable filterable>
<el-option v-for="a in apiConfigs" :key="a.id" :label="a.configName" :value="a.id"></el-option>
</el-select>
</el-form-item>
<el-form-item label="启用状态">
<el-select v-model="pageForm.enabled" clearable filterable>
<el-option label="启用" :value="true"></el-option>
<el-option label="停用" :value="false"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="doSearch">查询</el-button>
<el-button @click="resetSearch">重置</el-button>
</el-form-item>
<el-button class="pull-right" type="primary" icon="el-icon-plus" @click="create">新增</el-button>
</el-form>
</el-card>
<el-card shadow="never" class="person-governance-list-card">
<div class="person-governance-table-wrap">
<el-table ref="dataTable" :data="tableData" v-loading="tableLoading" height="100%">
<el-table-column
type="index"
:index="indexMethod"
label="序号"
width="70"
align="center"
></el-table-column>
<el-table-column prop="configName" label="配置名称"></el-table-column>
<el-table-column prop="apiConfigName" label="API配置"></el-table-column>
<el-table-column prop="lastExecuteTime" label="最近执行时间" width="180"></el-table-column>
<el-table-column label="是否启用" width="100">
<template v-slot="{row}">
<el-switch v-model="row.enabled" @change="toggle(row)"></el-switch>
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template v-slot="{row}">
<el-button type="text" @click="edit(row)">编辑</el-button>
<!-- <el-button type="text" @click="preview(row)">试算匹配</el-button>-->
<el-button type="text" class="text-danger" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
<el-drawer title="匹配结果预览" :visible.sync="previewVisible" size="55%" custom-class="person-governance-drawer">
<div style="padding: 0 20px">
<p>
预计匹配
<strong>{{previewTotal}}</strong>
人
</p>
<el-table :data="previewRows">
<el-table-column
v-for="c in previewColumns"
:key="c"
:prop="c"
:label="c"
show-overflow-tooltip
></el-table-column>
</el-table>
</div>
</el-drawer>
</div>
<script>
// 使用独立作用域承载页面实例,支持列表与表单通过 PJAX 反复切换。
;(() => {
new Vue({
el: '#app',
mixins: [initTableMixins],
data() {
return {
pageReady: false,
pageLeaving: false,
apiConfigs: [],
previewVisible: false,
previewRows: [],
previewColumns: [],
previewTotal: 0,
pageForm: { configName: '', apiConfigId: '', enabled: '', pageNumber: 1, pageSize: 10, totalCount: 0 },
}
},
methods: {
pageData() {
this.$set(this, 'tableLoading', true)
return $.post(loc() + '/pageData', this.pageForm)
.then((res) => {
if (res.code === 0) {
this.$set(this, 'tableData', res.data.list)
this.$set(this.pageForm, 'totalCount', res.data.totalCount)
} else this.notifyWarning(res.msg)
})
.always(() => {
this.$set(this, 'tableLoading', false)
})
},
loadApis() {
return $.post('/platform/personGovernance/apiConfig/selectableConfigs')
.then((res) => {
if (res.code === 0) {
this.$set(this, 'apiConfigs', res.data)
}
})
.always(() => {})
},
resetSearch() {
this.$set(this.pageForm, 'configName', '')
this.$set(this.pageForm, 'apiConfigId', '')
this.$set(this.pageForm, 'enabled', '')
this.doSearch()
},
showPage() {
this.$nextTick(() => {
if (this.$refs.dataTable && this.$refs.dataTable.doLayout) {
this.$refs.dataTable.doLayout()
}
window.requestAnimationFrame(() => {
this.$set(this, 'pageReady', true)
})
})
},
leavePage(url) {
this.$set(this, 'pageLeaving', true)
window.setTimeout(() => {
// 仅替换右侧内容区域,避免重新初始化平台布局和左侧菜单。
commonUtil.pjaxPush(url)
}, 180)
},
// 进入新增或编辑页时保留人员数据治理模块的左侧菜单。
create() {
this.leavePage('/platform/personGovernance/memberMatch/form')
},
edit(row) {
this.leavePage('/platform/personGovernance/memberMatch/form?id=' + row.id)
},
toggle(row) {
$.post(loc() + '/updateEnabled', { id: row.id, enabled: row.enabled })
.then((res) => {
if (res.code !== 0) {
this.$set(row, 'enabled', !row.enabled)
this.notifyWarning(res.msg)
}
})
.always(() => {})
},
preview(row) {
$.post(loc() + '/calculate', { id: row.id, pageNumber: 1, pageSize: 10 })
.then((res) => {
if (res.code === 0) {
this.$set(this, 'previewRows', res.data.list)
this.$set(this, 'previewTotal', res.data.totalCount)
this.$set(
this,
'previewColumns',
res.data.list.length ? Object.keys(res.data.list[0]).slice(0, 6) : [],
)
this.$set(this, 'previewVisible', true)
} else this.notifyWarning(res.msg)
})
.always(() => {})
},
remove(row) {
this.$confirm('确认删除该会员匹配配置吗?', '提示').then(() => {
$.post(loc() + '/delete', { id: row.id })
.then((res) => {
if (res.code === 0) {
this.notifySuccess('删除成功')
this.pageData()
} else this.notifyWarning(res.msg)
})
.always(() => {})
})
},
},
created() {
$.when(this.loadApis(), this.pageData()).always(() => {
this.showPage()
})
},
})
})()
</script>
<!--# } #-->
@@ -0,0 +1,114 @@
const PERSON_GOVERNANCE_MEMBER_ALL_CHANGE_INFO = {
template: /*language=HTML*/ `
<div>
<el-tabs v-model="activeName">
<el-tab-pane label="基础信息" name="basicInfo">
<el-descriptions :column="3" border class="descriptions-form">
<el-descriptions-item label="工号">{{ viewData.loginname }}</el-descriptions-item>
<el-descriptions-item label="姓名">{{ viewData.username }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ viewData.sex }}</el-descriptions-item>
<el-descriptions-item label="民族">{{ viewData.nation }}</el-descriptions-item>
<el-descriptions-item label="出生日期">{{ viewData.birthday ? $moment(viewData.birthday).format("YYYY年MM月DD日") : "" }}</el-descriptions-item>
<el-descriptions-item label="政治面貌">{{ viewData.political }}</el-descriptions-item>
<el-descriptions-item label="学历">{{ viewData.education }}</el-descriptions-item>
<el-descriptions-item label="学位">{{ viewData.academicDegree }}</el-descriptions-item>
<el-descriptions-item label="党政职务">{{ viewData.position }}</el-descriptions-item>
<el-descriptions-item label="工作单位">{{ viewData.unitName }}</el-descriptions-item>
<el-descriptions-item label="所属工会">{{ viewData.unionName }}</el-descriptions-item>
<el-descriptions-item label="所属校区">{{ viewData.campusName }}</el-descriptions-item>
<el-descriptions-item label="身份证号码">{{ viewData.idCard }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ viewData.mobile }}</el-descriptions-item>
<el-descriptions-item label="电子邮箱">{{ viewData.email }}</el-descriptions-item>
<el-descriptions-item label="在职状态">{{ viewData.userState }}</el-descriptions-item>
<el-descriptions-item label="人员类型">{{ viewData.personType }}</el-descriptions-item>
<template v-if="viewData.userState == '退休'">
<el-descriptions-item label="退休日期">{{ viewData.retireDate ? $moment(viewData.retireDate).format("YYYY-MM-DD") : '暂无' }}</el-descriptions-item>
<el-descriptions-item label="福利享受截止日期">{{ $moment(viewData.welfareStopDate).format("YYYY-MM-DD") }}</el-descriptions-item>
<el-descriptions-item></el-descriptions-item>
</template>
<el-descriptions-item label="会员状态">{{ viewData.member ? '会员' : '非会员' }}</el-descriptions-item>
<el-descriptions-item label="福利会员状态">{{ viewData.welfareMember ? '福利会员' : '非福利会员' }}</el-descriptions-item>
<el-descriptions-item></el-descriptions-item>
<template v-if="viewData.loginname == $store.state.user.loginname">
<el-descriptions-item label="家庭主要成员" :span="3">
<el-table v-if="viewData.families&&viewData.families.length"
:data="viewData.families" size="mini" border
:header-cell-style="{background:'#eff3f6'}"
style="width: 100%">
<el-table-column prop="relation" label="与本人关系" align="center" header-align="center">
</el-table-column>
<el-table-column prop="name" label="姓名" align="center" header-align="center">
</el-table-column>
<el-table-column prop="unit" label="单位" align="center" header-align="center">
</el-table-column>
<el-table-column prop="remark" label="备注" align="center" header-align="center">
</el-table-column>
</el-table>
<el-empty v-else :image-size="50" description="暂无家庭成员信息"></el-empty>
</el-descriptions-item>
<el-descriptions-item label="个人简况" :span="3">
<template slot="label">个人简况</template>
<div v-if="viewData.vita" class="text-left" v-html="viewData.vita"></div>
<span v-else>无数据</span>
</el-descriptions-item>
</template>
</el-descriptions>
</el-tab-pane>
<el-tab-pane label="历史变更记录" name="changeInfo" v-if="viewData.allChangeInfo">
<el-timeline>
<el-timeline-item placement="top" v-for="(changeData, index) in viewData.allChangeInfo"
:key="index" :timestamp="changeData.timestamp">
<el-card>
<h4>{{ changeData.changeOriginName }}</h4>
<el-table :data="changeData.changeInfos" size="mini" ref="table" row-key="id" style="width: 100%">
<el-table-column align="center" header-align="center"
label="序号" type="index" width="80px"></el-table-column>
<el-table-column prop="fieldName" label="变更字段" header-align="center"></el-table-column>
<el-table-column prop="sourceValue" label="原数据" header-align="center">
<template slot-scope="{row}">
<div v-html="row.sourceValue"></div>
</template>
</el-table-column>
<el-table-column prop="newValue" label="新数据" header-align="center">
<template slot-scope="{row}">
<div v-html="row.newValue"></div>
</template>
</el-table-column>
</el-table>
</el-card>
</el-timeline-item>
</el-timeline>
</el-tab-pane>
</el-tabs>
<slot></slot>
</div>
`,
store,
data(){
return{
viewData: {},
activeName: "basicInfo"
}
},
methods:{
onOpen(userId){
this.$axios.post('/platform/personGovernance/personChange/getAllChangeMemberInfo', {userId})
.then((resp) => {
if (resp.code === 0) {
this.$set(this, "viewData", resp.data)
} else {
this.$message.warning(resp.msg)
}
})
.finally(() => {})
}
}
}
@@ -0,0 +1,599 @@
const PERSON_GOVERNANCE_MEMBER_CHANGE = {
template: /*language=HTML*/ `
<el-card shadow="never">
<snaker-start slot="header" label="会员变更" define_key="HYBG"></snaker-start>
<el-form :model="formData" ref="formRef" :rules="formRules" label-width="0" label-suffix=":"
class="flow-task-form">
<el-descriptions :column="3" border>
<el-descriptions-item label="工号">
<el-form-item prop="loginname">
<el-input v-model="formData.loginname" readonly size="small"></el-input>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="姓名">
<el-form-item prop="username">
<el-input v-model="formData.username" readonly size="small"></el-input>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="性别">
<el-form-item prop="sex">
<el-radio-group :disabled="allowFields('sex')" v-model="formData.sex" size="small">
<el-radio border label="男">男</el-radio>
<el-radio border label="女">女</el-radio>
</el-radio-group>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="民族">
<el-form-item prop="nation">
<dict-select style="width: 100%" placeholder="请选择民族" v-model="formData.nation"
:disabled="allowFields('nation')" code="USER_NATION"></dict-select>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="出生日期">
<el-form-item prop="birthday">
<el-date-picker v-model="formData.birthday"
type="date"
:disabled="allowFields('birthday')"
placeholder="请选择出生日期"
format="yyyy-MM-dd"
value-format="yyyy-MM-dd"
style="width: 100%"></el-date-picker>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="政治面貌">
<el-form-item prop="political">
<dict-select style="width: 100%" placeholder="请选择政治面貌" v-model="formData.political"
:disabled="allowFields('political')" code="USER_POLITICAL"></dict-select>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="学历">
<el-form-item prop="education">
<dict-select style="width: 100%" placeholder="请选择学历" v-model="formData.education"
:disabled="allowFields('education')" code="USER_EDUCATION"></dict-select>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="学位">
<el-form-item prop="academicDegree">
<dict-select style="width: 100%" placeholder="请选择学位" v-model="formData.academicDegree"
:disabled="allowFields('academicDegree')"
code="USER_ACADEMIC_DEGREE"></dict-select>
</el-form-item>
</el-descriptions-item>
<!-- <el-descriptions-item label="党政职务">-->
<!-- <el-form-item prop="position">-->
<!-- <el-input v-model="formData.position" placeholder="请输入党政职务"-->
<!-- :disabled="allowFields('position')" maxlength="50"></el-input>-->
<!-- </el-form-item>-->
<!-- </el-descriptions-item>-->
<!--<template v-if="$auth.hasRoleOr('SYSADMIN, SCHOOL_UNION_ADMIN, SCHOOL_UNION_MEMBER_ADMIN')">
<el-descriptions-item label="工作单位">
<el-form-item prop="unitId">
<el-select clearable filterable placeholder="请选择工作单位" style="width: 100%"
v-model="formData.unitId">
<el-option :key="item.id" :label="item.name" :value="item.id"
v-for="item in units"></el-option>
</el-select>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="所属工会">
<el-form-item prop="unionId">
<el-select clearable filterable placeholder="请选择所属工会" style="width: 100%"
v-model="formData.unionId">
<el-option :key="item.id" :label="item.name" :value="item.id"
v-for="item in unions"></el-option>
</el-select>
</el-form-item>
</el-descriptions-item>
</template>-->
<template>
<el-descriptions-item label="工作单位">
<el-form-item prop="unitId">
<el-input v-model="formData.unitName" disabled placeholder="暂无工作单位"></el-input>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="所属工会">
<el-form-item prop="unionName">
<el-input v-model="formData.unionName" disabled placeholder="请输入所属工会"></el-input>
</el-form-item>
</el-descriptions-item>
</template>
<el-descriptions-item v-if="showThreeUnit" label="三级单位">
<el-form-item prop="threeUnitId">
<el-select v-model="formData.threeUnitId" clearable filterable
placeholder="请选择三级单位" style="width: 100%">
<el-option v-for="item in threeUnits" :key="item.id" :label="item.name"
:value="item.id"></el-option>
</el-select>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item v-else label="所属校区">
<el-form-item prop="campus">
<dict-select style="width: 100%" placeholder="请选择所属校区" v-model="formData.campus"
:disabled="allowFields('campus')" code="USER_CAMPUS"></dict-select>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="身份证号码">
<el-form-item prop="idCard">
<el-input v-model="formData.idCard" :disabled="allowFields('idCard')"
placeholder="请输入身份证号码" maxlength="18"></el-input>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="联系电话">
<el-form-item prop="mobile">
<el-input v-model="formData.mobile" :disabled="allowFields('mobile')"
placeholder="请输入联系电话" maxlength="15"></el-input>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="电子邮箱">
<el-form-item prop="email">
<el-input v-model="formData.email" :disabled="allowFields('email')"
placeholder="请输入电子邮箱" maxlength="50"></el-input>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="在职状态">
<el-form-item prop="userState">
<dict-select v-model="formData.userState" code="USER_STATE"
@change="handleRetiredStateChange"
:disabled="allowFields('userState')" style="width: 100%"></dict-select>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="人员类型">
<el-form-item prop="personType">
<dict-select v-model="formData.personType" code="USER_PERSON_TYPE"
:disabled="allowFields('personType')" style="width: 100%"></dict-select>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="人员属性">
<el-form-item prop="userAttribute">
<dict-select v-model="formData.userAttribute" code="USER_ATTRIBUTE"
style="width: 100%"></dict-select>
</el-form-item>
</el-descriptions-item>
<!-- <el-descriptions-item label="人员分类">-->
<!-- <el-form-item prop="aidFundMemberUserType">-->
<!-- <dict-select v-model="formData.aidFundMemberUserType" code="AIDFUND_MEMBER_USER_TYPE"-->
<!-- style="width: 100%"></dict-select>-->
<!-- </el-form-item>-->
<!-- </el-descriptions-item>-->
<template v-if="formData.userState == '退休'">
<el-descriptions-item label="退休日期">
<el-form-item prop="retireDate">
<el-date-picker v-model="formData.retireDate"
type="date"
placeholder="请选择退休日期"
format="yyyy-MM-dd"
value-format="yyyy-MM-dd"
style="width: 100%"></el-date-picker>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="福利享受截止日期">
<el-form-item prop="welfareStopDate">
<el-date-picker v-model="formData.welfareStopDate"
type="date"
placeholder="请选择福利享受截止日期"
format="yyyy-MM-dd"
value-format="yyyy-MM-dd"
style="width: 100%"></el-date-picker>
</el-form-item>
</el-descriptions-item>
<!-- <el-descriptions-item></el-descriptions-item>-->
</template>
<el-descriptions-item label="会员状态" >
<el-form-item prop="member">
<el-radio-group v-model="formData.member" size="small">
<el-radio border :label="true">会员</el-radio>
<el-radio border :label="false">非会员</el-radio>
</el-radio-group>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="福利会员状态">
<el-form-item prop="welfareMember">
<el-radio-group v-model="formData.welfareMember" size="small" class="welfare-member-radio-group"
style="display: flex; align-items: center; flex-wrap: nowrap; white-space: nowrap;">
<el-radio border :label="true" style="flex-shrink: 0;">福利会员</el-radio>
<el-radio border :label="false" style="flex-shrink: 0;">非福利会员</el-radio>
</el-radio-group>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="入校时间">
<el-form-item>
<el-date-picker v-model="formData.arrivalAtSchoolDate"
type="date"
placeholder="请选择入校时间"
format="yyyy-MM-dd"
value-format="yyyy-MM-dd"></el-date-picker>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item></el-descriptions-item>
<el-descriptions-item></el-descriptions-item>
<template v-if="formData.loginname == $store.state.user.loginname">
<el-descriptions-item label="家庭主要成员" :span="3">
<el-form-item prop="families">
<el-table :data="formData.families" border size="small">
<el-table-column label="关系" prop="relation">
<template slot-scope="{row}">
<el-input v-model="row.relation" maxlength="50"
placeholder="请输入与本人关系"></el-input>
</template>
</el-table-column>
<el-table-column label="姓名" prop="name">
<template slot-scope="{row}">
<el-input v-model="row.name" maxlength="50"
placeholder="请输入姓名"></el-input>
</template>
</el-table-column>
<el-table-column label="工作单位" prop="unit">
<template slot-scope="{row}">
<el-input v-model="row.unit" maxlength="100"
placeholder="请输入工作单位"></el-input>
</template>
</el-table-column>
<el-table-column label="备注" prop="remark">
<template slot-scope="{row}">
<el-input v-model="row.remark" maxlength="100"
placeholder="请输入备注"></el-input>
</template>
</el-table-column>
<el-table-column width="100px">
<template slot="header" slot-scope="scope">
<el-button type="primary" size="mini" :disabled="allowFields('families')"
@click="formData.families.push({})">添加
</el-button>
</template>
<template slot-scope="scope">
<el-button
type="danger"
icon="el-icon-delete"
size="mini"
:disabled="formData.families.length===0 || allowFields('families')"
@click="formData.families.splice(scope.$index,1)"
></el-button>
</template>
</el-table-column>
</el-table>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="个人简历" :span="3">
<el-form-item prop="personalData">
<text-editor v-model="formData.personalData"></text-editor>
</el-form-item>
</el-descriptions-item>
</template>
</el-descriptions>
</el-form>
<el-row type="flex" justify="end" class="mt20">
<el-button type="primary" plain @click="$emit('do-back')">返回</el-button>
<el-button type="primary" @click="onSubmit">提交变更</el-button>
</el-row>
<!-- <el-row justify="end" type="flex" class="mt10">-->
<!-- <el-button @click="$emit('do-back')" v-if="formData.id || !isShow" type="primary">返 回</el-button>-->
<!-- <template v-if="isShow">-->
<!-- <el-button @click="doSave" type="primary">保存变更</el-button>-->
<!-- </template>-->
<!-- <el-button @click="doSubmit" type="primary">提交变更</el-button>-->
<!-- </el-row>-->
</el-card>
`,
props: {
id: { type: String, default: '' },
showThreeUnit: { type: Boolean, default: false },
},
mixins: [initTableMixins],
store,
data(){
return{
isShow: true,
user: {},
units: [],
unions: [],
threeUnits: [],
// 允许变更的字段
allowChangeFields: [],
formData: {
families: [],
},
formRules: {
username: [{required: false, message: "必填", trigger: ["change", "blur"]}],
sex: [{required: false, message: "必填", trigger: ["change", "blur"]}],
/*email: [
{
validator: (rule, value, callback) => {
if (!value) {
callback();
} else if (/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(value)) {
callback();
} else {
callback(new Error("邮箱格式错误"));
}
},
trigger: ["change", "blur"]
}
],
idCard: [
{
validator: (rule, value, callback) => {
if (!value) {
// 如果值为空,直接通过校验
callback();
} else if (/^[1-9]\d{5}(18|19|20)?\d{2}((0[1-9])|(10|11|12))(([0|1|2][0-9])|10|20|30|31)\d{3}(\d|[Xx])$/.test(value)) {
// 18位身份证号码格式正确
callback();
} else if (/^[1-9]\d{7}((0[1-9])|(10|11|12))(([0|1|2][0-9])|10|20|30|31)\d{3}$/.test(value)) {
// 15位身份证号码格式正确
callback();
} else if (/^[1-9]\d{5}(18|19|20)?\d{2}((0[1-9])|(10|11|12))(([0|1|2][0-9])|10|20|30|31)\d{3}$/.test(value)) {
// 外国人身份证号码格式正确(18位)
callback();
} else if (/^[1-9]\d{7}((0[1-9])|(10|11|12))(([0|1|2][0-9])|10|20|30|31)\d{3}$/.test(value)) {
// 外国人身份证号码格式正确(15位)
callback();
} else {
// 格式错误,返回错误信息
callback(new Error("身份证号码格式错误"));
}
},
trigger: ["change", "blur"]
}
],
mobile: [
{
validator: (rule, value, callback) => {
if (!value) {
// 如果值为空,直接通过校验
callback();
} else if (/^[1][345789][0-9]{9}$/.test(value)) {
// 手机号格式正确
callback();
} else if (/^\d+-\d+$/.test(value) && value.length <= 16) {
// 座机号格式正确
callback();
} else {
// 格式错误,返回错误信息
callback(new Error(value.includes('-') ? '座机号码格式错误' : '手机号码格式错误'));
}
},
trigger: ["change", "blur"]
}
]*/
},
welfareProjectList: []
}
},
methods: {
getUnionName(val){
const data = this.units.find(v => String(v.id) === String(val))
this.$set(this.formData, 'unitName', data ? data.name : '')
this.$set(this.formData, 'unionName', data ? (data.unionName || data.unionname || '') : '')
},
normalizeUnitField() {
if (!this.formData || !this.formData.unitId) {
return
}
const unitId = String(this.formData.unitId)
this.$set(this.formData, "unitId", unitId)
let unit = this.units.find(v => String(v.id) === unitId)
if (unit) {
this.$set(this.formData, "unitName", unit.name)
this.$set(this.formData, "unionName", unit.unionName || unit.unionname || this.formData.unionName || "")
}
},
allowFields(prop) {
return !this.allowChangeFields.map(v => v.code).includes(prop)
},
/** 在职状态选择退休时,存在“离退休人员”字典项才自动设置对应人员类型编码。 */
handleRetiredStateChange(userState) {
if (userState !== "退休") {
return
}
this.$businessTool.getDictOptions("USER_PERSON_TYPE").then((options) => {
const retiredOption = (options || []).find((item) => item.name === "离退休人员")
if (retiredOption) {
this.$set(this.formData, "personType", retiredOption.code)
}
})
},
// 管理页仅查询当前被编辑人员所属二级单位的直属三级单位,避免越级选择其他单位数据。
initThreeUnits() {
this.$set(this, "threeUnits", [])
if (!this.showThreeUnit) {
return Promise.resolve()
}
if (!this.formData.unitId) {
this.$message.warning("当前人员未配置所属单位,无法查询三级单位")
return Promise.resolve()
}
return this.$axios.post("/platform/sys/unit/child", {pid: this.formData.unitId})
.then((resp) => {
if (resp.code === 0) {
this.$set(this, "threeUnits", resp.data || [])
if (this.threeUnits.length === 0) {
this.$message.warning("当前所属单位未配置三级单位")
}
} else {
this.$message.error(resp.msg || "三级单位查询失败")
}
})
.catch(() => {
this.$message.error("三级单位查询失败,请稍后重试")
})
.finally(() => {})
},
doSave(){
this.$confirm("保存后可在我的申请里面再次编辑,您确定要保存吗?", "提示", { type: "warning" })
.then(() => {
const formData = this.formData
if (formData.families) {
formData.families = JSON.stringify(this.formData.families)
}
this.$emit('do-save', formData)
})
.catch()
},
onSubmit() {
this.$refs.formRef.validate((valid) => {
if (valid) {
this.$confirm("提交后将进入审核流程,无法再进行编辑,您确定要提交申请吗?", "提示", { type: "warning" })
.then(() => {
const formData = this.formData
if (formData.families) {
formData.families = JSON.stringify(this.formData.families)
}
this.$emit('do-submit', formData)
})
.catch()
}
})
},
init(userId){
let userPromise = Promise.resolve()
if (userId) {
this.$set(this, "isShow", false)
userPromise = this.getUserById(userId)
} else {
this.$set(this, "isShow", true)
this.$set(this, "user", this.$store.state.user)
}
return userPromise
.then(() => this.getFormDataValue(this.id))
.then(() => {
const requests = [
this.$businessTool.getDictOptions("ALLOW_CHANGE_FIELDS"),
this.$businessTool.listUnit()
]
if (this.$auth.hasRoleOr('SYSADMIN, SCHOOL_UNION_MEMBER_ADMIN, SCHOOL_UNION_ADMIN')) {
requests.push(this.$businessTool.listUnion())
}
return Promise.all(requests)
})
.then((result) => {
this.$set(this, "allowChangeFields", result[0])
this.$set(this, "units", result[1])
if (result.length > 2) {
this.$set(this, "unions", result[2])
}
this.normalizeUnitField()
this.initThreeUnits()
})
},
getUserById(userId){
return $.post("/platform/personGovernance/personChange/getUserByIdForMemberChange", { userId })
.then((resp) => {
if (resp.code === 0) {
this.$set(this, "user", resp.data)
}
})
.always(() => {})
},
getFormDataValue(id){
if (id) {
return $.post("/platform/personGovernance/personChange/findChangeById", { id }).then((res) => {
if (res.code === 0) {
this.$set(this, "formData", res.data)
this.$set(this.formData, "unionid", this.user.unionid)
if (this.formData.families) {
this.$set(this.formData, "families", JSON.parse(this.formData.families))
} else {
this.$set(this.formData, "families", [])
}
}
}).always(() => {})
} else {
const {
id,
username,
loginname,
sex,
member,
welfareMember,
nation,
birthday,
political,
education,
academicDegree,
position,
unit,
union,
campus,
threeUnitId,
userState,
retireDate,
welfareStopDate,
personType,
preparedBy,
userAttribute,
aidFundMemberUserType,
idCard,
mobile,
email,
families,
personalData,
arrivalAtSchoolDate
} = this.user
this.$set(this.formData, "userId", id)
this.$set(this.formData, "username", username)
this.$set(this.formData, "loginname", loginname)
this.$set(this.formData, "birthday", birthday)
this.$set(this.formData, "sex", sex)
this.$set(this.formData, "member", member === 1)
this.$set(this.formData, "welfareMember", welfareMember === 1)
this.$set(this.formData, "idCard", idCard)
this.$set(this.formData, "nation", nation)
this.$set(this.formData, "political", political)
this.$set(this.formData, "position", position)
this.$set(this.formData, "education", education)
this.$set(this.formData, "academicDegree", academicDegree)
this.$set(this.formData, "campus", campus)
this.$set(this.formData, "threeUnitId", threeUnitId)
this.$set(this.formData, "userState", userState)
// 日期选择器使用 yyyy-MM-dd 字符串,避免接口日期值无法在组件中正确回显。
this.$set(this.formData, "retireDate", retireDate
? this.$moment(retireDate).format("YYYY-MM-DD") : "")
this.$set(this.formData, "welfareStopDate", welfareStopDate
? this.$moment(welfareStopDate).format("YYYY-MM-DD") : "")
this.$set(this.formData, "personType", personType)
this.$set(this.formData, "preparedBy", preparedBy)
this.$set(this.formData, "userAttribute", userAttribute)
this.$set(this.formData, "aidFundMemberUserType", aidFundMemberUserType)
this.$set(this.formData, "unitName", unit ? unit.name : null)
this.$set(this.formData, "unitId", unit ? String(unit.id) : null)
this.$set(this.formData, "unionName", union ? union.name : null)
this.$set(this.formData, "unionId", union ? union.id : null)
this.$set(this.formData, "mobile", mobile)
this.$set(this.formData, "email", email)
this.$set(this.formData, "families", families ? families : [])
this.$set(this.formData, "personalData", personalData)
this.$set(this.formData, "arrivalAtSchoolDate", arrivalAtSchoolDate)
}
}
},
created(){
this.init()
},
style: /*language=CSS*/ `
.el-descriptions-item__label {
width: 15% !important;
}
.welfare-member-radio-group {
display: flex;
align-items: center;
flex-wrap: nowrap;
}
.welfare-member-radio-group .el-radio {
margin-right: 12px;
}
`
}
@@ -0,0 +1,108 @@
const PERSON_GOVERNANCE_MEMBER_INFO = {
template: /*language=HTML*/ `
<div>
<!-- 基本信息 -->
<div class="process-title">基本信息</div>
<el-descriptions :column="3" border class="descriptions-form">
<!-- 第 1 行 -->
<el-descriptions-item label="姓名工号">{{ viewData.username + '(' + viewData.loginname + ')' }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ viewData.sex }}</el-descriptions-item>
<el-descriptions-item label="出生日期">{{ viewData.birthday ? $moment(viewData.birthday).format('YYYY-MM-DD') : null }}</el-descriptions-item>
<!-- 第 2 行 -->
<el-descriptions-item label="国籍">{{ viewData.nationality }}</el-descriptions-item>
<el-descriptions-item label="民族">{{ viewData.nation }}</el-descriptions-item>
<el-descriptions-item label="政治面貌">{{ viewData.political }}</el-descriptions-item>
<!-- 第 3 行 -->
<el-descriptions-item label="证件类型">{{ viewData.idCardType }}</el-descriptions-item>
<el-descriptions-item label="身份证号码">{{ viewData.idCard }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ viewData.mobile }}</el-descriptions-item>
<!-- 第 4 行 -->
<el-descriptions-item label="学历">{{ viewData.education }}</el-descriptions-item>
<el-descriptions-item label="学位">{{ viewData.academicDegree }}</el-descriptions-item>
<el-descriptions-item label="婚姻状况">{{ viewData.marriage }}</el-descriptions-item>
<!-- 第 5 行 -->
<el-descriptions-item label="会员状态">{{ viewData.member ? '会员' : '非会员' }}</el-descriptions-item>
<el-descriptions-item label="福利会员状态">{{ viewData.welfareMember ? '福利会员' : '非福利会员' }}</el-descriptions-item>
<el-descriptions-item></el-descriptions-item>
<!-- 退休补充 -->
<template v-if="viewData.userState == '退休'">
<el-descriptions-item label="退休日期">{{ viewData.retireDate ? $moment(viewData.retireDate).format("YYYY-MM-DD") : '暂无' }}</el-descriptions-item>
<el-descriptions-item label="福利享受截止日期">{{ viewData.welfareStopDate ? $moment(viewData.welfareStopDate).format("YYYY-MM-DD") : '暂无' }}</el-descriptions-item>
<el-descriptions-item></el-descriptions-item>
</template>
<!-- 仅本人可见 -->
<template v-if="viewData.loginname === $store.state.user.loginname">
<el-descriptions-item label="家庭主要成员" :span="3">
<el-table v-if="viewData.families && viewData.families.length" :data="viewData.families" size="mini" border :header-cell-style="{background:'#eff3f6'}" style="width: 100%">
<el-table-column prop="relation" label="与本人关系" align="center"/>
<el-table-column prop="name" label="姓名" align="center"/>
<el-table-column prop="unit" label="单位" align="center"/>
<el-table-column prop="remark" label="备注" align="center"/>
</el-table>
<el-empty v-else :image-size="50" description="暂无家庭成员信息"/>
</el-descriptions-item>
<el-descriptions-item label="个人简况" :span="3">
<div v-if="viewData.personalData" class="text-left" v-html="viewData.personalData"/>
<span v-else>无数据</span>
</el-descriptions-item>
</template>
</el-descriptions>
<!-- 工作信息 -->
<div class="process-title">工作信息</div>
<el-descriptions :column="3" border>
<!-- 第 1 行 -->
<el-descriptions-item label="工作单位">{{ viewData.unitName }}</el-descriptions-item>
<el-descriptions-item label="所属工会">{{ viewData.unionName }}</el-descriptions-item>
<el-descriptions-item label="所属校区">{{ viewData.campus }}</el-descriptions-item>
<!-- 第 2 行 -->
<el-descriptions-item label="入校时间">{{ viewData.arrivalAtSchoolDate ? $moment(viewData.arrivalAtSchoolDate).format('YYYY-MM-DD') : null }}</el-descriptions-item>
<el-descriptions-item label="从教年月">{{ viewData.teachingTime ? $moment(viewData.teachingTime).format('YYYY-MM-DD') : null }}</el-descriptions-item>
<el-descriptions-item label="预/已离校时间">{{ viewData.expectedLeaveSchoolDate ? $moment(viewData.expectedLeaveSchoolDate).format('YYYY-MM-DD') : null }}</el-descriptions-item>
<!-- 第 3 行 -->
<el-descriptions-item label="在职状态">{{ viewData.userState }}</el-descriptions-item>
<el-descriptions-item label="人员类型">{{ viewData.personType }}</el-descriptions-item>
<!-- 第 4 行 -->
<el-descriptions-item label="人员属性">{{ viewData.userAttribute }}</el-descriptions-item>
<el-descriptions-item label="职称">{{ viewData.professionalTitle }}</el-descriptions-item>
<el-descriptions-item label="职级">{{ viewData.professionalLevel }}</el-descriptions-item>
<!-- 第 5 行 -->
<el-descriptions-item label="岗位系列">{{ viewData.positionSeries }}</el-descriptions-item>
<el-descriptions-item label="行政级别(管理岗位)">{{ viewData.administrativeLevel }}</el-descriptions-item>
<el-descriptions-item label="岗级">{{ viewData.positionLevel }}</el-descriptions-item>
<el-descriptions-item label="行政岗级">{{ viewData.administrativePositionLevel }}</el-descriptions-item>
<el-descriptions-item label="人员分类" v-if="false">{{ viewData.aidFundMemberUserType }}</el-descriptions-item>
</el-descriptions>
<slot></slot>
</div>
`,
store,
data() {
return {
viewData: {}
}
},
methods: {
onOpen(id) {
this.$axios.post("/platform/personGovernance/personChange/findOne", { id })
.then((res) => {
if (res.code === 0) {
this.$set(this, "viewData", res.data)
}
})
.finally(() => {})
}
}
};
@@ -0,0 +1,944 @@
<!--#
layout("/layouts/platform.html"){
#-->
<link rel="stylesheet" href="${base!}/assets/platform/css/personGovernance.css?v=1.0.22" />
<div
id="app"
v-cloak
class="person-governance-list-page person-governance-change-page person-governance-page-transition"
:class="{'is-ready':pageReady}"
>
<guava ref="guava">
<el-card shadow="never" class="person-governance-search-card">
<search @search="doSearch">
<search-item label="姓名/工号">
<el-input placeholder="请输入姓名或工号" clearable v-model="pageForm.searchKeyword"></el-input>
</search-item>
<search-item label="所属工会">
<el-select @change="flushUnits" @clear="flushUnits" clearable filterable
placeholder="请选择所属工会" style="width: 100%;" v-model="pageForm.unionId">
<el-option :key="item.id" :label="item.name" :value="item.id"
v-for="item in unions"></el-option>
</el-select>
</search-item>
<search-item label="所属单位">
<el-select @change="flushThreeUnits" @clear="flushThreeUnits" clearable filterable
placeholder="请选择所属单位" style="width: 100%" v-model="pageForm.unitId">
<el-option :key="item.id" :label="item.name" :value="item.id"
v-for="item in units"></el-option>
</el-select>
</search-item>
<search-item label="三级单位">
<el-select clearable filterable placeholder="请选择三级单位" style="width: 100%"
v-model="pageForm.threeUnitId">
<el-option :key="item.id" :label="item.name" :value="item.id"
v-for="item in threeUnits"></el-option>
</el-select>
</search-item>
<search-item label="性别">
<dict-select v-model="pageForm.sex" placeholder="性别" @change="doSearch"
code="USER_SEX"></dict-select>
</search-item>
<search-item label="在职状态">
<dict-select v-model="pageForm.userState" placeholder="请选择在职状态" @change="doSearch"
code="USER_STATE"></dict-select>
</search-item>
<search-item label="人员类型">
<dict-select v-model="pageForm.personType" placeholder="请选择人员类型" @change="doSearch"
code="USER_PERSON_TYPE"></dict-select>
</search-item>
<!-- <search-item label="编制类别">-->
<!-- <dict-select v-model="pageForm.preparedBy" placeholder="请选择编制类别" @change="doSearch"-->
<!-- code="USER_PREPARED_BY_TYPE"></dict-select>-->
<!-- </search-item>-->
<!-- 临时屏蔽“人员分类”查询字段,不删除配置,后续需要可恢复。-->
<!-- <search-item label="人员分类">-->
<!-- <dict-select v-model="pageForm.aidFundMemberUserType" placeholder="请选择人员分类" @change="doSearch"-->
<!-- code="AIDFUND_MEMBER_USER_TYPE"></dict-select>-->
<!-- </search-item>-->
<search-item label="人员属性">
<dict-select v-model="pageForm.userAttributes" placeholder="请选择人员属性" @change="doSearch"
code="USER_ATTRIBUTE" multiple collapse-tags clearable></dict-select>
</search-item>
<search-item label="更新日期">
<el-date-picker :picker-options="pickerOptions" @change="changeDateRangeChange"
align="right" end-placeholder="结束日期" format="yyyy-MM-dd"
range-separator="-" start-placeholder="开始日期" style="width: 100%"
type="datetimerange" v-model="pageForm.changeDateRange"
value-format="yyyy-MM-dd"></el-date-picker>
</search-item>
</search>
</el-card>
<el-card shadow="never" class="mt10 person-governance-change-list-card">
<table-tool label="人员列表(工会小组或分工会提交的变更还需校工会审核)">
<el-button :disabled="!checkUsers.length" @click="openBatchEditMember" class="ml5" icon="el-icon-edit"
size="small" type="primary">
设置会员
</el-button>
<el-button :disabled="!checkUsers.length" @click="openBatchEditAidFundType" class="ml5" icon="el-icon-edit"
size="small" type="primary">
设置人员分类
</el-button>
<el-button :disabled="!checkUsers.length" @click="openBatchEditUserAttribute" class="ml5" icon="el-icon-edit"
size="small" type="primary">
设置人员属性
</el-button>
<el-button :disabled="!checkUsers.length" @click="openBatchEditUserState" class="ml5" icon="el-icon-edit"
size="small" type="primary">
设置在职状态
</el-button>
<el-button :disabled="!checkUsers.length" @click="openUnitMove" class="ml5" icon="el-icon-edit"
size="small" type="primary">
单位调整
</el-button>
<el-radio-group @change="doSearch" class="mr5"
size="small"
v-model="pageForm.isMember">
<el-radio-button :label="null">全部教工</el-radio-button>
<el-radio-button :label="1">会员</el-radio-button>
<el-radio-button :label="0">非会员</el-radio-button>
</el-radio-group>
<span class="text-danger">人员异动:</span>
<el-radio-group @change="doSearch" class="mr5" size="small"
v-model="pageForm.changed">
<el-radio-button :label="2">全部</el-radio-button>
<el-radio-button :label="1">异动</el-radio-button>
<el-radio-button :label="0">未异动</el-radio-button>
</el-radio-group>
<!--<div v-if="pageForm.changed == '1'" class="div-enum">
<dict-select v-model="pageForm.USER_CHANGE_TYPE" placeholder="异动类型" @change="doSearch"
code="UserType"></dict-select>
</div>-->
<!--<el-button type="primary" size="small" icon="el-icon-printer" @click="exportExcel">导出会员名单</el-button>-->
<el-popover
placement="bottom"
trigger="click"
width="200">
<div style="padding:4px 0;border-bottom:1px solid #eee;">
<el-button size="mini" @click="checkAll">全选</el-button>
<el-button size="mini" @click="invertCheck">反选</el-button>
</div>
<div style="max-height:40vh;overflow-y:auto;padding:6px 0;">
<el-checkbox-group v-model="checkedFields">
<el-checkbox
v-for="f in tableColumns"
:key="f.prop"
:label="f.prop"
style="display:block;margin:6px 0;">
{{ f.label }}
</el-checkbox>
</el-checkbox-group>
</div>
<el-button
slot="reference"
type="text"
icon="el-icon-s-operation"
size="small"
style="margin-left:12px;">
列设置
</el-button>
</el-popover>
</table-tool>
<div class="person-governance-change-table-wrap">
<el-table :data="tableData" :size="tableSize" @selection-change="tableHandleSelectionChange" @sort-change="pageOrder"
class="vi-table" ref="table" row-key="id" style="width: 100%" height="100%">
<el-table-column :reserve-selection="true" type="selection"></el-table-column>
<el-table-column :index="indexMethod" align="center" header-align="center"
label="序号" type="index" width="80px" fixed="left"></el-table-column>
<el-table-column :label="column.label" :prop="column.prop"
:sortable="column.sortable" :width="column.width"
align="center" header-align="center" min-width="100px"
v-if="checkedFields.includes(column.prop)" :fixed="column.fixed"
show-overflow-tooltip v-for="column in tableColumns">
<template scope="{row}" v-if="column.prop=='loginname'">
<el-link @click="openView(row.id)" type="primary">{{row.loginname}}</el-link>
</template>
<!-- 出生年月仅格式化表格显示值,保留原始数据供其他业务使用。 -->
<template scope="{row}" v-if="column.prop=='birthday'">
{{row.birthday ? $moment(row.birthday).format('YYYY-MM-DD') : ''}}
</template>
</el-table-column>
<el-table-column header-align="center" label="异动信息">
<el-table-column align="center" header-align="center" label="更新时间"
min-width="145px" prop="changeTime"
show-overflow-tooltip sortable>
</el-table-column>
<el-table-column align="center" header-align="center" label="异动类型"
prop="changeType" show-overflow-tooltip sortable>
<template scope="{row}">
<span style="color: #0e78c5;cursor: pointer" @click="openUserChangeInfo(row)">
{{getChangeTypes(row.changeTypes)}}
</span>
</template>
</el-table-column>
</el-table-column>
<el-table-column align="center" header-align="center" label="会员" prop="member"
show-overflow-tooltip sortable>
<template scope="{row}">
<span class="text-success " v-if="row.member==1">
<i class="fa fa-circle ml5"></i> 是
</span>
<span class="text-danger" v-else>
<i class="fa fa-circle ml5"></i> 否
</span>
</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="福利会员" prop="member"
show-overflow-tooltip sortable>
<template scope="{row}">
<span class="text-success " v-if="row.welfareMember==1">
<i class="fa fa-circle ml5"></i> 是
</span>
<span class="text-danger" v-else>
<i class="fa fa-circle ml5"></i> 否
</span>
</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="操作"
prop="userOnline" width="100px" fixed="right">
<template scope="{row}">
<el-dropdown @command="dropdownCommand">
<el-button plain size="mini">
<i class="ti-settings"></i>
<span class="ti-angle-down"></span>
</el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item :command="{type:'view',data:row}">
查看
</el-dropdown-item>
<el-dropdown-item
:command="{type:'change',data:row}"
v-if="$auth.hasRoleOr(['SYSADMIN','SCHOOL_UNION_ADMIN','BRANCH_UNION_CHAIRMAN','BRANCH_UNION_CHAIRMAN','BRANCH_UNION_GROUP_LEADER'])">
变更
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</template>
</el-table-column>
</el-table>
</div>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
<template #view>
<member-info ref="memberInfoRef"></member-info>
</template>
<template #public>
<member-change ref="memberChangeRef" :show-three-unit="true" @do-back="$refs.guava.index()" @do-submit="doSubmit"></member-change>
</template>
<template #edit>
<member-all-change ref="memberAllChangeRef"></member-all-change>
</template>
<el-drawer
:before-close="handleClose"
:visible.sync="batchEditUserAttributeVisible"
size="1000px" title="批量设置人员属性">
<el-timeline style="margin-right: 40px">
<el-timeline-item placement="top" timestamp="">
<el-form :model="batchFormDataUserAttribute" label-width="100px" ref="batchFormUserAttribute">
<el-row :gutter="60">
<el-col :span="12">
<el-form-item label="人员属性">
<dict-select
@change="checkUsers.map(v => $set(v, 'userAttribute', batchFormDataUserAttribute.userAttribute))"
clearable code="USER_ATTRIBUTE"
placeholder="请选择人员属性"
style="width: 300px"
v-model="batchFormDataUserAttribute.userAttribute"></dict-select>
</el-form-item>
</el-col>
</el-row>
</el-form>
</el-timeline-item>
<el-timeline-item placement="top" timestamp="">
<el-table :data="checkUsers" class="vi-table" height="70vh" row-key="id" style="width: 100%">
<el-table-column align="center" header-align="center" label="工号" prop="loginname" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="姓名" prop="username" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="所属单位" prop="unitName" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="所属工会" prop="unionName" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="人员属性" prop="userAttribute" show-overflow-tooltip width="300px">
<template scope="{row}">
<dict-select clearable code="USER_ATTRIBUTE" placeholder="请选择人员属性" style="width: 200px"
v-model="row.userAttribute"></dict-select>
</template>
</el-table-column>
</el-table>
</el-timeline-item>
</el-timeline>
<span style="position: absolute;bottom: 20px;right: 20px">
<el-button @click="batchEditUserAttributeVisible = false;checkUsers=[];$refs.table.clearSelection()">取消</el-button>
<el-button :loading="batchEditLoading" @click="doBatchEditUserAttribute" type="primary">确定</el-button>
</span>
</el-drawer>
<el-drawer
:before-close="handleClose"
:visible.sync="batchEditMemberVisible"
size="1000px" title="批量设置会员">
<el-timeline style="margin-right: 40px">
<el-timeline-item placement="top" timestamp="">
<el-form :model="batchFormDataMember" label-width="100px" ref="batchFormMember">
<el-row :gutter="60">
<el-col :span="12">
<el-form-item label="是否会员">
<el-switch
@change="checkUsers.map(v => $set(v, 'member', batchFormDataMember.member))"
v-model="batchFormDataMember.member"
active-text="是"
inactive-text="否">
</el-switch>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="是否福利会员">
<el-switch
@change="checkUsers.map(v => $set(v, 'welfareMember', batchFormDataMember.welfareMember))"
v-model="batchFormDataMember.welfareMember"
active-text="是"
inactive-text="否">
</el-switch>
</el-form-item>
</el-col>
</el-row>
</el-form>
</el-timeline-item>
<el-timeline-item placement="top" timestamp="">
<el-table :data="checkUsers" class="vi-table" height="70vh" row-key="id" style="width: 100%">
<el-table-column align="center" header-align="center" label="工号" prop="loginname" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="姓名" prop="username" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="所属单位" prop="unitName" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="所属工会" prop="unionName" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="是否会员" prop="member" show-overflow-tooltip width="150px">
<template scope="{row}">
<el-switch v-model="row.member" active-text="是" inactive-text="否"></el-switch>
</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="是否福利会员" prop="welfareMember" show-overflow-tooltip width="150px">
<template scope="{row}">
<el-switch v-model="row.welfareMember" active-text="是" inactive-text="否"></el-switch>
</template>
</el-table-column>
</el-table>
</el-timeline-item>
</el-timeline>
<span style="position: absolute;bottom: 20px;right: 20px">
<el-button @click="batchEditMemberVisible = false;checkUsers=[];$refs.table.clearSelection()">取消</el-button>
<el-button :loading="batchEditLoading" @click="doBatchEditMember" type="primary">确定</el-button>
</span>
</el-drawer>
<el-drawer
:before-close="handleClose"
:visible.sync="batchEditAidFundTypeVisible"
size="1000px" title="批量设置人员分类">
<el-timeline style="margin-right: 40px">
<el-timeline-item placement="top" timestamp="">
<el-form :model="batchFormDataAidFundType" label-width="100px" ref="batchFormAidFundType">
<el-row :gutter="60">
<el-col :span="12">
<el-form-item label="人员分类">
<dict-select
@change="checkUsers.map(v => $set(v, 'aidFundMemberUserType', batchFormDataAidFundType.aidFundMemberUserType))"
clearable code="AIDFUND_MEMBER_USER_TYPE"
placeholder="请选择人员分类"
style="width: 300px"
v-model="batchFormDataAidFundType.aidFundMemberUserType"></dict-select>
</el-form-item>
</el-col>
</el-row>
</el-form>
</el-timeline-item>
<el-timeline-item placement="top" timestamp="">
<el-table :data="checkUsers" class="vi-table" height="70vh" row-key="id" style="width: 100%">
<el-table-column align="center" header-align="center" label="工号" prop="loginname" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="姓名" prop="username" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="所属单位" prop="unitName" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="所属工会" prop="unionName" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="人员分类" prop="aidFundMemberUserType" show-overflow-tooltip width="300px">
<template scope="{row}">
<dict-select clearable code="AIDFUND_MEMBER_USER_TYPE" placeholder="请选择人员分类" style="width: 200px"
v-model="row.aidFundMemberUserType"></dict-select>
</template>
</el-table-column>
</el-table>
</el-timeline-item>
</el-timeline>
<span style="position: absolute;bottom: 20px;right: 20px">
<el-button @click="batchEditAidFundTypeVisible = false;checkUsers=[];$refs.table.clearSelection()">取消</el-button>
<el-button :loading="batchEditLoading" @click="doBatchEditAidFundType" type="primary">确定</el-button>
</span>
</el-drawer>
<el-drawer
:before-close="handleClose"
:visible.sync="batchEditUserStateVisible"
size="1000px" title="批量设置在职状态">
<el-timeline style="margin-right: 40px">
<el-timeline-item placement="top" timestamp="">
<el-form :model="batchFormDataUserState" label-width="100px" ref="batchFormUserState">
<el-row :gutter="60">
<el-col :span="12">
<el-form-item label="在职状态">
<dict-select
@change="checkUsers.map(v => $set(v, 'userState', batchFormDataUserState.userState))"
clearable code="USER_STATE"
placeholder="请选择在职状态"
style="width: 300px"
v-model="batchFormDataUserState.userState"></dict-select>
</el-form-item>
</el-col>
</el-row>
</el-form>
</el-timeline-item>
<el-timeline-item placement="top" timestamp="">
<el-table :data="checkUsers" class="vi-table" height="70vh" row-key="id" style="width: 100%">
<el-table-column align="center" header-align="center" label="工号" prop="loginname" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="姓名" prop="username" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="所属单位" prop="unitName" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="所属工会" prop="unionName" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="在职状态" prop="userState" show-overflow-tooltip width="300px">
<template scope="{row}">
<dict-select clearable code="USER_STATE" placeholder="请选择在职状态" style="width: 200px"
v-model="row.userState"></dict-select>
</template>
</el-table-column>
</el-table>
</el-timeline-item>
</el-timeline>
<span style="position: absolute;bottom: 20px;right: 20px">
<el-button @click="batchEditUserStateVisible = false;checkUsers=[];$refs.table.clearSelection()">取消</el-button>
<el-button :loading="batchEditLoading" @click="doBatchEditUserState" type="primary">确定</el-button>
</span>
</el-drawer>
<el-drawer
:before-close="handleClose"
:visible.sync="unitMoveVisible"
size="1000px" title="批量单位调整">
<el-timeline style="margin-right: 40px">
<el-timeline-item placement="top" timestamp="">
<el-form :model="unitMoveFormData" label-width="100px" ref="unitMoveForm">
<el-row :gutter="60">
<el-col :span="12">
<el-form-item label="所属工会">
<el-select clearable filterable placeholder="请选择所属工会" style="width: 300px"
v-model="unitMoveFormData.unionId" @change="moveUnionsChange">
<el-option v-for="item in unitMoveUnions" :key="item.id" :label="item.name" :value="item.id"></el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="所属单位">
<el-select clearable filterable placeholder="请选择所属单位" style="width: 300px"
v-model="unitMoveFormData.unitId" @change="moveUnitChange">
<el-option v-for="item in unitMoveUnits" :key="item.id" :label="item.name" :value="item.id"></el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
</el-form>
</el-timeline-item>
<el-timeline-item placement="top" timestamp="">
<el-table :data="checkUsers" class="vi-table" height="70vh" row-key="id" style="width: 100%">
<el-table-column align="center" header-align="center" label="工号" prop="loginname" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="姓名" prop="username" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="当前单位" prop="unitName" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="当前工会" prop="unionName" show-overflow-tooltip></el-table-column>
<el-table-column align="center" header-align="center" label="调整后工会" show-overflow-tooltip>
<template>{{ unitMoveFormData.unionName }}</template>
</el-table-column>
<el-table-column align="center" header-align="center" label="调整后单位" show-overflow-tooltip>
<template>{{ unitMoveFormData.unitName }}</template>
</el-table-column>
</el-table>
</el-timeline-item>
</el-timeline>
<span style="position: absolute;bottom: 20px;right: 20px">
<el-button @click="unitMoveVisible = false;checkUsers=[];$refs.table.clearSelection()">取消</el-button>
<el-button :loading="batchEditLoading" @click="doUnitMove" type="primary">确定</el-button>
</span>
</el-drawer>
</guava>
</div>
<script nonce="${cspNonce!}">
<!--#include("common/memberInfo.js"){}#-->
<!--#include("common/memberChange.js"){}#-->
<!--#include("common/memberAllChangeInfo.js"){}#-->
new Vue({
el: "#app",
store,
mixins: [initTableMixins],
data: {
pageReady: false,
pickerOptions: {
shortcuts: [{
text: '最近一周',
onClick(picker) {
const end = new Date();
const start = new Date();
start.setTime(start.getTime() - 3600 * 1000 * 24 * 7);
picker.$emit('pick', [start, end]);
}
}, {
text: '最近一个月',
onClick(picker) {
const end = new Date();
const start = new Date();
start.setTime(start.getTime() - 3600 * 1000 * 24 * 30);
picker.$emit('pick', [start, end]);
}
}, {
text: '最近三个月',
onClick(picker) {
const end = new Date();
const start = new Date();
start.setTime(start.getTime() - 3600 * 1000 * 24 * 90);
picker.$emit('pick', [start, end]);
}
}]
},
pageForm: {
unionId: "",
unitId: "",
threeUnitId: "",
sex: "",
searchKeyword: "",
changed: 2,
isMember: 1,
changeTypes: [],
aidFundMemberUserType: "",
userAttributes: [],
},
unions: [],
units: [],
threeUnits: [],
id: "",
checkUsers: [],
batchEditLoading: false,
batchEditUserAttributeVisible: false,
batchEditMemberVisible: false,
batchEditAidFundTypeVisible: false,
batchEditUserStateVisible: false,
unitMoveVisible: false,
batchFormDataUserAttribute: {},
batchFormDataMember: {member: false, welfareMember: false},
batchFormDataAidFundType: {},
batchFormDataUserState: {},
unitMoveFormData: {
unitId: "",
unionId: "",
unitName: "",
unionName: ""
},
unitMoveUnions: [],
unitMoveUnits: [],
tableColumns: [
{ prop: "loginname", label: "工号", width: 120, fixed: "left"},
{ prop: "username", label: "姓名", width: 120, fixed: "left" },
{ prop: "sex", label: "性别", width: 120},
{ prop: "birthday", label: "出生年月", width: 120, sortable: true },
{ prop: "nationality", label: "国籍", width: 120, sortable: true, checked: 0 },
// 临时屏蔽“民族”列表字段,不删除配置,后续需要可恢复。
// { prop: "nation", label: "民族", width: 120, sortable: true},
{ prop: "mobile", label: "联系电话", width: 120 },
// 临时屏蔽“政治面貌”列表字段,不删除配置,后续需要可恢复。
// { prop: "political", label: "政治面貌", width: 120, sortable: true},
{ prop: "userState", label: "在职状态", width: 120, sortable: true },
// 临时隐藏原人员类型位置,人员类型显示到原“人员分类”位置。
// { prop: "personType", label: "人员类型", width: 120, sortable: true, checked: 0 },
// { prop: "preparedBy", label: "编制类别", width: 120, sortable: true },
{ prop: "postDoctoralJoinDate", label: "进站时间", sortable: true, checked: 0 },
{ prop: "unionName", label: "所属工会", width: 120, sortable: true },
{ prop: "unitName", label: "所属单位", width: 120, sortable: true },
{ prop: "personType", label: "人员类型", width: 120, sortable: true },
// 临时屏蔽“人员分类”列表字段,不删除配置,后续需要可恢复。
// { prop: "aidFundMemberUserType", label: "人员分类", width: 120, sortable: true },
{ prop: "userAttribute", label: "人员属性", width: 120, sortable: true },
{ prop: "idCardType", label: "证件类型", width: 120, sortable: true, checked: 0 },
{ prop: "idCard", label: "证件号码", width: 120, sortable: true, checked: 0 },
{ prop: "marriage", label: "婚姻状况", width: 120, sortable: true, checked: 0 },
{ prop: "jobCategory", label: "岗位类别", width: 120, sortable: true, checked: 0 },
{ prop: "professionalTitle", label: "职称", width: 120, sortable: true, checked: 0 },
{ prop: "professionalLevel", label: "职级", width: 120, sortable: true, checked: 0 },
{ prop: "employeeSource", label: "职工来源", width: 120, sortable: true, checked: 0 },
{ prop: "administrativeLevel", label: "行政级别(管理岗位)", width: 120, sortable: true, checked: 0 },
{ prop: "positionSeries", label: "岗位系列", width: 120, sortable: true, checked: 0 },
{ prop: "positionLevel", label: "岗级", width: 120, sortable: true, checked: 0 },
{ prop: "administrativePositionLevel", label: "行政岗级", width: 120, sortable: true, checked: 0 },
{ prop: "arrivalAtSchoolDate", label: "来校时间", width: 120, sortable: true, checked: 0 },
{ prop: "teachingTime", label: "从教年月", width: 120, sortable: true, checked: 0 },
{ prop: "expectedLeaveSchoolDate", label: "预计/最后离校时间", width: 120, sortable: true, checked: 0 },
],
changeTypeData: [],
checkedFields: [],
allowChangeFields: []
},
components: {
"member-info": PERSON_GOVERNANCE_MEMBER_INFO,
"member-change": PERSON_GOVERNANCE_MEMBER_CHANGE,
"member-all-change": PERSON_GOVERNANCE_MEMBER_ALL_CHANGE_INFO,
},
methods: {
// 分页和导出共用该参数构建方法,保证人员属性多选值始终按 JSON 数组提交。
buildQueryPageForm(source = this.pageForm) {
const pageForm = clone(source)
pageForm.userAttributes = JSON.stringify(source.userAttributes || [])
return pageForm
},
// 覆盖公共分页方法,使后端按人员属性集合执行查询。
pageData(data = null) {
return initTableMixins.methods.pageData.call(this, this.buildQueryPageForm(data || this.pageForm))
},
showPage() {
this.$nextTick(() => {
if (this.$refs.table && this.$refs.table.doLayout) {
this.$refs.table.doLayout()
}
window.requestAnimationFrame(() => {
this.$set(this, "pageReady", true)
})
})
},
dropdownCommand(command) {
const {type, data} = command
if (type === 'view') {
this.openView(data.id)
} else if (type === 'change') {
this.openChange(data.id)
}
},
openChange(id) {
this.$refs.guava.public(() => {
this.$refs.memberChangeRef.init(id)
})
},
openView(id) {
this.$refs.guava.view(() => {
this.$refs.memberInfoRef.onOpen(id)
})
},
openUserChangeInfo(row) {
this.$refs.guava.edit(() => {
this.$refs.memberAllChangeRef.onOpen(row.id)
})
},
doSubmit(data) {
const loading = createLoading('正在提交中')
this.$axios.post("/platform/personGovernance/personChange/doSubmitChange", data)
.then((resp) => {
if (resp.code === 0) {
this.$message.success(resp.msg)
this.$refs.guava.index()
this.pageData()
} else {
this.$message.error(resp.msg)
}
})
.finally(() => {
loading.close()
})
},
getChangeTypes(changeTypes){
if (!changeTypes) return null
const changeTypesList = JSON.parse(changeTypes)
if (changeTypesList) {
if (!changeTypesList || changeTypesList.length === 0 || !this.changeTypeData || this.changeTypeData.length === 0) return null
return changeTypesList
.map((v) => {
return this.changeTypeData.find((item) => item.code === v).changeTypeName
})
.join(",")
}
return null
},
changeDateRangeChange(val) {
if (val && val.length > 0) {
this.$set(this.pageForm, "changeDateBefore", val[0])
this.$set(this.pageForm, "changeDateEnd", val[1])
}
},
/**
* 导出会员名单
*/
exportExcel() {
const pageForm = this.buildQueryPageForm()
const data = this.tableColumns.filter(item => this.checkedFields.includes(item.prop)).map(item => {
return {prop: item.prop, label: item.label}
})
pageForm.columns = JSON.stringify(data)
this.$downLoad("/platform/personGovernance/personChange/doExport", pageForm)
},
initData() {
const isAdmin = this.$auth.hasRoleOr(["SYSADMIN", "SCHOOL_UNION_ADMIN"])
const unionId = isAdmin ? this.pageForm.unionId : this.$store.state.user.union.id
return Promise.all([
this.$businessTool.listUnion(unionId),
this.$businessTool.getEnumOptions("MemberChangeType"),
this.$businessTool.getDictOptions("ALLOW_CHANGE_FIELDS")
]).then((result) => {
this.$set(this, "unions", result[0])
this.$set(this, "changeTypeData", result[1])
this.$set(this, "allowChangeFields", result[2])
})
},
flushUnits() {
this.$set(this.pageForm, "unitId", null)
this.$set(this.pageForm, "threeUnitId", null)
this.$set(this, "units", [])
this.$set(this, "threeUnits", [])
if (this.pageForm.unionId) {
this.$businessTool.listUnit(this.pageForm.unionId).then(data => {
this.$set(this, "units", data)
})
}
},
// 所属单位变化时只加载其直属三级单位,避免跨单位筛选。
flushThreeUnits(unitId) {
this.$set(this.pageForm, "threeUnitId", null)
this.$set(this, "threeUnits", [])
if (!unitId) {
return
}
this.$axios.post("/platform/sys/unit/child", {pid: unitId})
.then((res) => {
if (res.code === 0) {
this.$set(this, "threeUnits", res.data || [])
} else {
this.$message.error(res.msg || "三级单位查询失败")
}
})
.catch(() => {
this.$message.error("三级单位查询失败,请稍后重试")
})
.finally(() => {})
},
checkAll() {
this.$set(this, "checkedFields", this.tableColumns.map(c => c.prop));
},
invertCheck() {
const all = this.tableColumns.map(c => c.prop);
this.$set(this, "checkedFields", all.filter(p => !this.checkedFields.includes(p)));
},
tableHandleSelectionChange(val) {
this.$set(this, "checkUsers", JSON.parse(JSON.stringify([...val])))
},
handleClose(done) {
this.$confirm("确认关闭?").then(() => {
done()
this.$set(this, "checkUsers", [])
this.$refs.table.clearSelection()
}).catch(() => {})
},
openBatchEditUserAttribute() {
this.$set(this, "batchFormDataUserAttribute", {})
this.$set(this, "batchEditUserAttributeVisible", true)
},
openBatchEditMember() {
this.$set(this, "batchFormDataMember", {member: false, welfareMember: false})
this.$set(this, "batchEditMemberVisible", true)
},
openBatchEditAidFundType() {
this.$set(this, "batchFormDataAidFundType", {})
this.$set(this, "batchEditAidFundTypeVisible", true)
},
openBatchEditUserState() {
this.$set(this, "batchFormDataUserState", {})
this.$set(this, "batchEditUserStateVisible", true)
},
openUnitMove() {
this.$set(this, "unitMoveFormData", {
unitId: "",
unionId: "",
unitName: "",
unionName: ""
})
this.$set(this, "unitMoveUnits", [])
this.$set(this, "unitMoveVisible", true)
const isAdmin = this.$auth.hasRoleOr(["SYSADMIN", "SCHOOL_UNION_ADMIN"])
const unionId = isAdmin ? this.pageForm.unionId : this.$store.state.user.union.id
this.$businessTool.listUnion(unionId).then((data) => {
this.$set(this, "unitMoveUnions", data)
})
},
moveUnionsChange(val) {
if (val) {
this.$businessTool.listUnit(val).then((data) => {
this.$set(this, "unitMoveUnits", data)
const union = this.unitMoveUnions.find(v => v.id === val)
this.$set(this.unitMoveFormData, "unionName", union ? union.name : "")
this.$set(this.unitMoveFormData, "unitId", "")
this.$set(this.unitMoveFormData, "unitName", "")
})
} else {
this.$set(this.unitMoveFormData, "unitId", "")
this.$set(this.unitMoveFormData, "unitName", "")
this.$set(this.unitMoveFormData, "unionName", "")
this.$set(this, "unitMoveUnits", [])
}
},
moveUnitChange(val) {
if (val) {
const unit = this.unitMoveUnits.find(v => v.id === val)
this.$set(this.unitMoveFormData, "unitName", unit ? unit.name : "")
} else {
this.$set(this.unitMoveFormData, "unitName", "")
}
},
doBatchEditUserAttribute() {
const batch = this.checkUsers.map(v => {
return {id: v.id, userAttribute: v.userAttribute}
})
this.$set(this, "batchEditLoading", true)
this.$axios.post("/platform/personGovernance/personChange/batchUpdateUserAttribute", {users: JSON.stringify(batch)})
.then((resp) => {
if (resp.code === 0) {
this.$message.success(resp.msg)
this.pageData()
this.$refs.table.clearSelection()
this.$set(this, "batchEditUserAttributeVisible", false)
this.$set(this, "checkUsers", [])
} else {
this.$message.error(resp.msg)
}
}).finally(() => {
this.$set(this, "batchEditLoading", false)
})
},
doBatchEditMember() {
const batch = this.checkUsers.map(v => {
return {id: v.id, member: v.member, welfareMember: v.welfareMember}
})
this.$set(this, "batchEditLoading", true)
this.$axios.post("/platform/personGovernance/personChange/batchUpdateMember", {users: JSON.stringify(batch)})
.then((resp) => {
if (resp.code === 0) {
this.$message.success(resp.msg)
this.pageData()
this.$refs.table.clearSelection()
this.$set(this, "batchEditMemberVisible", false)
this.$set(this, "checkUsers", [])
} else {
this.$message.error(resp.msg)
}
}).finally(() => {
this.$set(this, "batchEditLoading", false)
})
},
doBatchEditAidFundType() {
const batch = this.checkUsers.map(v => {
return {id: v.id, aidFundMemberUserType: v.aidFundMemberUserType}
})
this.$set(this, "batchEditLoading", true)
this.$axios.post("/platform/personGovernance/personChange/batchUpdateAidFundType", {users: JSON.stringify(batch)})
.then((resp) => {
if (resp.code === 0) {
this.$message.success(resp.msg)
this.pageData()
this.$refs.table.clearSelection()
this.$set(this, "batchEditAidFundTypeVisible", false)
this.$set(this, "checkUsers", [])
} else {
this.$message.error(resp.msg)
}
}).finally(() => {
this.$set(this, "batchEditLoading", false)
})
},
doBatchEditUserState() {
const batch = this.checkUsers.map(v => {
return {id: v.id, userState: v.userState}
})
this.$set(this, "batchEditLoading", true)
this.$axios.post("/platform/personGovernance/personChange/batchUpdateUserState", {users: JSON.stringify(batch)})
.then((resp) => {
if (resp.code === 0) {
this.$message.success(resp.msg)
this.pageData()
this.$refs.table.clearSelection()
this.$set(this, "batchEditUserStateVisible", false)
this.$set(this, "checkUsers", [])
} else {
this.$message.error(resp.msg)
}
}).finally(() => {
this.$set(this, "batchEditLoading", false)
})
},
doUnitMove() {
if (!this.unitMoveFormData.unitId) {
this.$message.warning("请选择新单位!")
return
}
const params = {
unitId: this.unitMoveFormData.unitId,
userIds: JSON.stringify(this.checkUsers.map(v => v.id))
}
this.$set(this, "batchEditLoading", true)
this.$axios.post("/platform/personGovernance/personChange/unitMove", params)
.then((resp) => {
if (resp.code === 0) {
this.$message.success(resp.msg)
this.pageData()
this.$refs.table.clearSelection()
this.$set(this, "unitMoveVisible", false)
this.$set(this, "checkUsers", [])
} else {
this.$message.error(resp.msg)
}
}).finally(() => {
this.$set(this, "batchEditLoading", false)
})
},
},
computed: {
showColumns() {
return this.tableColumns.filter(c => this.checkedFields.includes(c.prop));
}
},
mounted() {
this.$set(this, "checkedFields", this.tableColumns.filter(c => c.checked !== 0).map(c => c.prop));
},
created() {
// 单位、字典和列表请求全部结束后再显示页面,避免初始化过程中的布局跳动。
$.when(this.initData(), this.pageData()).always(() => {
this.showPage()
})
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,414 @@
<!--# layout("/layouts/platform.html"){ #-->
<link rel="stylesheet" href="${base!}/assets/platform/css/personGovernance.css?v=1.0.22" />
<div
id="app"
v-cloak
class="person-governance-list-page person-governance-page-transition"
:class="{'is-ready':pageReady}"
>
<el-card shadow="never" class="person-governance-search-card">
<el-tabs v-model="activeTab" @tab-click="switchTab">
<el-tab-pane label="待确认" name="PENDING"></el-tab-pane>
<el-tab-pane label="已确认" name="PROCESSED"></el-tab-pane>
</el-tabs>
<el-alert
v-if="activeTab==='PENDING'"
title="以下人员在第三方来源中已不存在,但仍保留在本系统,请确认处理。"
type="warning"
:closable="false"
></el-alert>
<el-form class="mt20" :inline="true" :model="pageForm">
<el-form-item>
<el-input
v-model="pageForm.searchKeyword"
clearable
placeholder="请输入内容"
@keyup.enter.native="doSearch"
>
<el-select v-model="pageForm.searchName" slot="prepend" style="width: 100px">
<el-option label="姓名" value="personName"></el-option>
<el-option label="人员编号" value="personNo"></el-option>
</el-select>
</el-input>
</el-form-item>
<el-form-item label="所属部门">
<el-select v-model="pageForm.unitId" clearable filterable>
<el-option v-for="u in units" :key="u.id" :label="u.name" :value="u.id"></el-option>
</el-select>
</el-form-item>
<el-form-item v-if="activeTab==='PROCESSED'" label="处理结果">
<el-select v-model="pageForm.handleResult" clearable filterable>
<el-option label="已停用" value="DISABLE"></el-option>
<el-option label="继续保留" value="KEEP"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="doSearch">查询</el-button>
<el-button @click="resetSearch">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never" class="person-governance-list-card">
<div class="person-governance-table-wrap">
<el-table
:key="'person-exception-table-' + activeTab"
ref="dataTable"
:data="tableData"
v-loading="tableLoading"
height="100%"
>
<el-table-column v-if="activeTab==='PENDING'" type="selection" width="45"></el-table-column>
<el-table-column prop="personNo" label="工号"></el-table-column>
<el-table-column prop="personName" label="名称"></el-table-column>
<el-table-column prop="unitName" label="单位"></el-table-column>
<el-table-column label="异常类型"><template>第三方不存在</template></el-table-column>
<el-table-column prop="discoverTime" label="发现时间" width="180"></el-table-column>
<el-table-column label="当前状态">
<template v-slot="{row}">{{row.status==='PENDING'?'待确认':'已确认'}}</template>
</el-table-column>
<el-table-column label="操作" width="120">
<template v-slot="{row}">
<el-button
v-if="activeTab==='PENDING'"
type="primary"
plain
size="small"
@click="view(row, $event)"
>确认</el-button>
<el-button v-else type="text" @click="view(row)">查看</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="person-governance-list-footer">
<!--#include("/layouts/pagination.html"){}#-->
</div>
</el-card>
<el-drawer
:title="activeTab==='PENDING'?'异常人员确认':'异常处理详情'"
:visible.sync="drawerVisible"
:append-to-body="true"
size="85%"
custom-class="person-governance-drawer"
>
<div class="person-exception-handle-page" v-loading="detailLoading">
<el-form
v-if="detail.exception"
:model="handleForm"
label-width="0"
label-suffix=":"
class="person-exception-handle-form"
>
<template v-if="detail.user">
<vi-title title="人员信息"></vi-title>
<el-descriptions :column="3" border>
<el-descriptions-item label="工号"><el-input :value="detail.user.loginname" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="姓名"><el-input :value="detail.user.username" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="性别"><el-input :value="detail.user.sex" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="民族"><el-input :value="detail.user.nation" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="出生日期"><el-input :value="detail.user.birthday" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="政治面貌"><el-input :value="detail.user.political" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="学历"><el-input :value="detail.user.education" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="学位"><el-input :value="detail.user.academicDegree" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="党政职务"><el-input :value="detail.user.position" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="工作单位"><el-input :value="detail.user.unitName" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="所属工会"><el-input :value="detail.user.unionName" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="本地人员状态"><el-input :value="detail.user.disabled ? '已停用' : '在用'" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="身份证号码"><el-input :value="detail.user.idcard" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="联系电话"><el-input :value="detail.user.mobile" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="电子邮箱"><el-input :value="detail.user.email" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="在职状态">
<el-select
v-if="activeTab==='PENDING'"
v-model="handleForm.userState"
clearable
filterable
size="small"
style="width: 100%"
>
<el-option
v-for="item in userStateOptions"
:key="item.code"
:label="item.name"
:value="item.code"
></el-option>
</el-select>
<el-input v-else :value="detail.user.userState" readonly size="small"></el-input>
</el-descriptions-item>
<el-descriptions-item label="人员类型">
<el-select
v-if="activeTab==='PENDING'"
v-model="handleForm.personType"
clearable
filterable
size="small"
style="width: 100%"
>
<el-option
v-for="item in personTypeOptions"
:key="item.code"
:label="item.name"
:value="item.code"
></el-option>
</el-select>
<el-input v-else :value="detail.user.personType" readonly size="small"></el-input>
</el-descriptions-item>
<el-descriptions-item label="会员状态">
<el-select
v-if="activeTab==='PENDING'"
v-model="handleForm.member"
clearable
filterable
size="small"
style="width: 100%"
>
<el-option label="会员" :value="1"></el-option>
<el-option label="非会员" :value="0"></el-option>
</el-select>
<el-input v-else :value="detail.user.member == 1 ? '会员' : '非会员'" readonly size="small"></el-input>
</el-descriptions-item>
<el-descriptions-item label="福利会员状态">
<el-select
v-if="activeTab==='PENDING'"
v-model="handleForm.welfareMember"
clearable
filterable
size="small"
style="width: 100%"
>
<el-option label="福利会员" :value="1"></el-option>
<el-option label="非福利会员" :value="0"></el-option>
</el-select>
<el-input v-else :value="detail.user.welfareMember == 1 ? '福利会员' : '非福利会员'" readonly size="small"></el-input>
</el-descriptions-item>
</el-descriptions>
</template>
<vi-title :class="{'mt20': detail.user}" title="异常信息"></vi-title>
<el-descriptions :column="3" border>
<el-descriptions-item label="人员姓名"><el-input :value="detail.exception.personName" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="人员编号"><el-input :value="detail.exception.personNo" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="所属部门"><el-input :value="detail.exception.unitName" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="异常类型"><el-input value="第三方不存在" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="最后同步时间"><el-input :value="detail.exception.lastSyncTime" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="异常发现时间"><el-input :value="detail.exception.discoverTime" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="异常原因" :span="3"><el-input :value="detail.exception.exceptionReason" readonly size="small"></el-input></el-descriptions-item>
</el-descriptions>
<template v-if="activeTab==='PENDING'">
<vi-title class="mt20" title="处理信息"></vi-title>
<el-descriptions :column="3" border>
<el-descriptions-item label="处理建议" :span="1">
<el-radio-group v-model="handleForm.result" size="small">
<el-radio border label="DISABLE">建议停用</el-radio>
<el-radio border label="KEEP">继续保留</el-radio>
</el-radio-group>
</el-descriptions-item>
<el-descriptions-item label="确认建议" :span="2">
<el-input v-model="handleForm.opinion" type="textarea" :rows="4" maxlength="500" show-word-limit></el-input>
</el-descriptions-item>
</el-descriptions>
<div class="person-exception-handle-actions">
<el-button @click="closeDrawer">暂存</el-button>
<el-button type="primary" :loading="formLoading" @click="submitHandle">确认处理</el-button>
</div>
</template>
<template v-else>
<vi-title class="mt20" title="处理结果"></vi-title>
<el-descriptions :column="3" border>
<el-descriptions-item label="处理建议"><el-input :value="detail.exception.handleResult==='DISABLE'?'建议停用':'继续保留'" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="处理人"><el-input :value="detail.exception.handlerName" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="处理时间"><el-input :value="detail.exception.handleTime" readonly size="small"></el-input></el-descriptions-item>
<el-descriptions-item label="确认建议" :span="3"><el-input :value="detail.exception.handleOpinion" type="textarea" :rows="3" readonly></el-input></el-descriptions-item>
</el-descriptions>
</template>
</el-form>
</div>
</el-drawer>
</div>
<style>
.person-exception-handle-page {
box-sizing: border-box;
min-height: 240px;
padding: 0 24px 24px;
}
.person-exception-handle-form .el-form-item {
margin-bottom: 0;
}
.person-exception-handle-form .el-input__inner,
.person-exception-handle-form .el-textarea__inner {
background-color: #fff;
color: #606266;
}
.person-exception-handle-actions {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
</style>
<script>
const vue = new Vue({
el: '#app',
mixins: [initTableMixins],
data() {
return {
pageReady: false,
activeTab: 'PENDING',
units: [],
personTypeOptions: [],
userStateOptions: [],
drawerVisible: false,
detailLoading: false,
detail: {},
currentIds: [],
handleForm: {
result: 'DISABLE',
opinion: '',
personType: '',
userState: '',
member: null,
welfareMember: null,
},
pageForm: {
status: 'PENDING',
searchName: 'personName',
searchKeyword: '',
unitId: '',
handleResult: '',
pageNumber: 1,
pageSize: 10,
totalCount: 0,
},
}
},
methods: {
pageData() {
this.$set(this.pageForm, 'status', this.activeTab)
this.$set(this, 'tableLoading', true)
return $.post(loc() + '/pageData', this.pageForm)
.then((res) => {
if (res.code === 0) {
this.$set(this, 'tableData', res.data.list)
this.$set(this.pageForm, 'totalCount', res.data.totalCount)
} else this.notifyWarning(res.msg)
})
.always(() => {
this.$set(this, 'tableLoading', false)
})
},
switchTab() {
this.$set(this.pageForm, 'pageNumber', 1)
this.pageData()
},
resetSearch() {
this.$set(this.pageForm, 'searchName', 'personName')
this.$set(this.pageForm, 'searchKeyword', '')
this.$set(this.pageForm, 'unitId', '')
this.$set(this.pageForm, 'handleResult', '')
this.doSearch()
},
view(row, event) {
// 点击确认后立即释放按钮焦点,避免 plain 按钮持续显示实心蓝色背景。
if (event && event.currentTarget && event.currentTarget.blur) {
event.currentTarget.blur()
}
this.$set(this, 'detail', {})
this.$set(this, 'drawerVisible', true)
this.$set(this, 'detailLoading', true)
$.post(loc() + '/detail', { id: row.id })
.then((res) => {
if (res.code === 0) {
const user = res.data.user || {}
this.$set(this, 'detail', res.data)
this.$set(this, 'currentIds', [row.id])
this.$set(this, 'handleForm', {
result: 'DISABLE',
opinion: '',
personType: user.personType || '',
userState: user.userState || '',
member: user.member === null || typeof user.member === 'undefined'
? null
: Number(user.member),
welfareMember: user.welfareMember === null || typeof user.welfareMember === 'undefined'
? null
: Number(user.welfareMember),
})
} else {
this.notifyWarning(res.msg)
this.$set(this, 'drawerVisible', false)
}
})
.always(() => {
this.$set(this, 'detailLoading', false)
})
},
closeDrawer() {
this.$set(this, 'drawerVisible', false)
},
submitHandle() {
if (!this.handleForm.opinion) {
this.notifyWarning('请填写确认建议')
return
}
if (this.handleForm.member === null || this.handleForm.member === '') {
this.notifyWarning('请选择会员状态')
return
}
if (this.handleForm.welfareMember === null || this.handleForm.welfareMember === '') {
this.notifyWarning('请选择福利会员状态')
return
}
this.$set(this, 'formLoading', true)
$.post(loc() + '/handle', {
ids: JSON.stringify(this.currentIds),
handleResult: this.handleForm.result,
opinion: this.handleForm.opinion,
personType: this.handleForm.personType,
userState: this.handleForm.userState,
member: this.handleForm.member,
welfareMember: this.handleForm.welfareMember,
})
.then((res) => {
if (res.code === 0) {
this.notifySuccess('处理完成')
this.$set(this, 'drawerVisible', false)
this.pageData()
} else this.notifyWarning(res.msg)
})
.always(() => {
this.$set(this, 'formLoading', false)
})
},
showPage() {
this.$nextTick(() => {
if (this.$refs.dataTable && this.$refs.dataTable.doLayout) {
this.$refs.dataTable.doLayout()
}
window.requestAnimationFrame(() => {
this.$set(this, 'pageReady', true)
})
})
},
},
created() {
// 当前项目通过 businessTool 获取单位和人员字典,避免依赖原项目 v.js 中不存在的全局方法。
const unitRequest = this.$businessTool.listUnit().then((rows) => {
this.$set(this, 'units', rows)
})
const personTypeRequest = this.$businessTool.getDictOptions('USER_PERSON_TYPE').then((rows) => {
this.$set(this, 'personTypeOptions', rows)
})
const userStateRequest = this.$businessTool.getDictOptions('USER_STATE').then((rows) => {
this.$set(this, 'userStateOptions', rows)
})
$.when(this.pageData(), unitRequest, personTypeRequest, userStateRequest).always(() => {
this.showPage()
})
},
})
</script>
<!--# } #-->
@@ -35,6 +35,14 @@ layout("/layouts/platform.html"){
<el-table-column label="字典代码" header-align="center" prop="code" :show-overflow-tooltip="true"></el-table-column>
<el-table-column label="人员类型" header-align="center" prop="personType" :show-overflow-tooltip="true">
<template slot-scope="scope">
{{personTypeNameMap[scope.row.personType]||scope.row.personType}}
</template>
</el-table-column>
<el-table-column label="编码" header-align="center" prop="userAttributeCode" :show-overflow-tooltip="true"></el-table-column>
<el-table-column label="启用状态" header-align="center" prop="disabled" align="center" :show-overflow-tooltip="true">
<template slot-scope="scope">
<i v-if="scope.row.disabled" class="fa fa-circle text-danger ml5"></i>
@@ -50,12 +58,12 @@ layout("/layouts/platform.html"){
<span class="ti-angle-down"></span>
</el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item :command="{type:'add',id:scope.row.id,name:scope.row.name}">添加子级</el-dropdown-item>
<el-dropdown-item :command="{type:'add',id:scope.row.id,name:scope.row.name,row:scope.row}">添加子级</el-dropdown-item>
<el-dropdown-item divided :command="{type:'enable',id:scope.row.id,name:scope.row.name,row:scope.row}">
启用
</el-dropdown-item>
<el-dropdown-item :command="{type:'disable',id:scope.row.id,name:scope.row.name,row:scope.row}">禁用</el-dropdown-item>
<el-dropdown-item divided :command="{type:'edit',id:scope.row.id,name:scope.row.name}">修改</el-dropdown-item>
<el-dropdown-item divided :command="{type:'edit',id:scope.row.id,name:scope.row.name,row:scope.row}">修改</el-dropdown-item>
<el-dropdown-item :command="{type:'delete',id:scope.row.id,name:scope.row.name}">删除</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
@@ -77,12 +85,20 @@ layout("/layouts/platform.html"){
></el-cascader>
<el-input v-if="isAddFromSub" type="text" v-model="formData.parentName" :disabled="true"></el-input>
</el-form-item>
<el-form-item v-if="showUserAttributeFields" prop="personType" label="人员类型">
<el-select v-model="formData.personType" clearable filterable placeholder="请选择人员类型" style="width: 100%">
<el-option v-for="item in personTypeOptions" :key="item.code" :label="item.name" :value="item.code"></el-option>
</el-select>
</el-form-item>
<el-form-item prop="name" label="字典名称">
<el-input maxlength="100" placeholder="字典名称" v-model="formData.name" auto-complete="off" tabindex="3" type="text"></el-input>
</el-form-item>
<el-form-item prop="code" label="字典代码">
<el-input maxlength="100" placeholder="字典代码" v-model="formData.code" auto-complete="off" tabindex="5" type="text"></el-input>
</el-form-item>
<el-form-item v-if="showUserAttributeFields" prop="userAttributeCode" label="编码">
<el-input maxlength="100" placeholder="请输入人员属性编码" v-model="formData.userAttributeCode" auto-complete="off" type="text"></el-input>
</el-form-item>
<el-form-item prop="remark" label="字典备注">
<el-input maxlength="100" placeholder="字典备注" v-model="formData.remark" auto-complete="off" tabindex="2" type="text"></el-input>
</el-form-item>
@@ -91,18 +107,26 @@ layout("/layouts/platform.html"){
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="addDialogVisible = false">取 消</el-button>
<el-button type="primary" @click="doAdd">确 定</el-button>
<el-button @click="closeAddDialog">取 消</el-button>
<el-button type="primary" :loading="formLoading" @click="doAdd">确 定</el-button>
</span>
</el-dialog>
<el-dialog title="修改字典" :visible.sync="editDialogVisible" :close-on-click-modal="false" width="50%">
<el-form :model="formData" ref="editForm" :rules="formRules" size="small" label-width="80px">
<el-form-item v-if="showUserAttributeFields" prop="personType" label="人员类型">
<el-select v-model="formData.personType" clearable filterable placeholder="请选择人员类型" style="width: 100%">
<el-option v-for="item in personTypeOptions" :key="item.code" :label="item.name" :value="item.code"></el-option>
</el-select>
</el-form-item>
<el-form-item prop="name" label="字典名称">
<el-input maxlength="100" placeholder="字典名称" v-model="formData.name" auto-complete="off" tabindex="1" type="text"></el-input>
</el-form-item>
<el-form-item prop="code" label="字典代码">
<el-input maxlength="100" placeholder="字典代码" v-model="formData.code" auto-complete="off" tabindex="2" type="text"></el-input>
</el-form-item>
<el-form-item v-if="showUserAttributeFields" prop="userAttributeCode" label="编码">
<el-input maxlength="100" placeholder="请输入人员属性编码" v-model="formData.userAttributeCode" auto-complete="off" type="text"></el-input>
</el-form-item>
<el-form-item prop="remark" label="字典备注">
<el-input maxlength="100" placeholder="字典备注" v-model="formData.remark" auto-complete="off" tabindex="2" type="text"></el-input>
</el-form-item>
@@ -111,8 +135,8 @@ layout("/layouts/platform.html"){
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="editDialogVisible = false">取 消</el-button>
<el-button type="primary" @click="doEdit">确 定</el-button>
<el-button @click="closeEditDialog">取 消</el-button>
<el-button type="primary" :loading="formLoading" @click="doEdit">确 定</el-button>
</span>
</el-dialog>
<el-dialog title="字典排序" :visible.sync="sortDialogVisible" width="50%">
@@ -136,12 +160,19 @@ layout("/layouts/platform.html"){
editDialogVisible: false,
sortDialogVisible: false,
isAddFromSub: false,
showUserAttributeFields: false,
formLoading: false,
userAttributeDictId: "",
personTypeOptions: [],
personTypeNameMap: {},
tableData: [], //后台取出的数据
tableKey: "",
formData: {
id: "",
parentId: "",
parentName: "",
personType: "",
userAttributeCode: "",
type: "menu",
target: "data-pjax",
children: "false"
@@ -244,83 +275,86 @@ layout("/layouts/platform.html"){
},
openAdd: function () {
var self = this
self.isAddFromSub = false
self.addDialogVisible = true
self.parentMenu = []
self.menuOptions = []
self.$set(self, "isAddFromSub", false)
self.$set(self, "showUserAttributeFields", false)
self.$set(self, "addDialogVisible", true)
self.$set(self, "parentMenu", [])
self.$set(self, "menuOptions", [])
//表单还原为初始值
self.formData = {
self.$set(self, "formData", {
id: "",
parentId: "",
parentName: "",
personType: "",
userAttributeCode: "",
type: "menu",
target: "data-pjax",
children: "false"
}
})
if (this.$refs["addForm"]) this.$refs["addForm"].resetFields()
},
closeAddDialog() {
this.$set(this, "addDialogVisible", false)
},
closeEditDialog() {
this.$set(this, "editDialogVisible", false)
},
// 标记 USER_ATTRIBUTE 的直属子项,供编辑弹窗判断是否展示人员类型和业务编码。
markUserAttributeChildren(rows, parentCode) {
const children = rows || []
children.forEach((item) => {
this.$set(item, "isUserAttributeChild", parentCode === "USER_ATTRIBUTE")
})
return children
},
doAdd: function () {
var self = this
var url = base + "/platform/sys/dict/addDo"
self.$refs["addForm"].validate(function (valid) {
if (valid) {
//表单验证通过
if (!self.isAddFromSub) {
//如果不是添加子菜单,则从级联选择框取父节点ID
self.formData.parentId = self.parentMenu[self.parentMenu.length - 1] || ""
}
$.post(
url,
self.formData,
function (data) {
if (data.code == 0) {
self.$message({
message: data.msg,
type: "success"
})
self.addDialogVisible = false
self.loadChildByExpandedKeys()
// self.initTreeTable();
} else {
self.$message({
message: data.msg,
type: "error"
})
}
},
"json"
)
self.$refs["addForm"].validate((valid) => {
if (!valid) {
return
}
// 顶级或级联新增时,从级联选择结果获取父节点;子级快捷新增沿用当前父节点。
if (!self.isAddFromSub) {
self.$set(self.formData, "parentId", self.parentMenu[self.parentMenu.length - 1] || "")
}
self.$set(self, "formLoading", true)
$.post(url, self.formData)
.then((data) => {
if (data.code === 0) {
self.$message({ message: data.msg, type: "success" })
self.$set(self, "addDialogVisible", false)
self.loadChildByExpandedKeys()
} else {
self.$message({ message: data.msg, type: "error" })
}
})
.always(() => {
self.$set(self, "formLoading", false)
})
})
},
doEdit: function () {
var self = this
var url = base + "/platform/sys/dict/editDo"
self.$refs["editForm"].validate(function (valid) {
if (valid) {
//表单验证通过
$.post(
url,
self.formData,
function (data) {
if (data.code == 0) {
self.$message({
message: data.msg,
type: "success"
})
self.editDialogVisible = false
self.loadChildByExpandedKeys()
// self.initTreeTable()
} else {
self.$message({
message: data.msg,
type: "error"
})
}
},
"json"
)
self.$refs["editForm"].validate((valid) => {
if (!valid) {
return
}
self.$set(self, "formLoading", true)
$.post(url, self.formData)
.then((data) => {
if (data.code === 0) {
self.$message({ message: data.msg, type: "success" })
self.$set(self, "editDialogVisible", false)
self.loadChildByExpandedKeys()
} else {
self.$message({ message: data.msg, type: "error" })
}
})
.always(() => {
self.$set(self, "formLoading", false)
})
})
},
initTreeTable: function () {
@@ -331,18 +365,22 @@ layout("/layouts/platform.html"){
{ pid: "" },
function (data) {
if (data.code == 0) {
self.tableData = data.data
self.tableKey = +new Date()
self.$set(self, "tableData", data.data)
self.$set(self, "tableKey", +new Date())
const userAttributeRoot = (data.data || []).find((item) => item.code === "USER_ATTRIBUTE")
self.$set(self, "userAttributeDictId", userAttributeRoot ? userAttributeRoot.id : "")
}
},
"json"
)
},
loadChild: function (tree, treeNode, resolve) {
var self = this
// 在之前声明的全局变量中,增加一个key为 本条数据的id,id可替换为你数据中的任意唯一值
this.tableTreeRefreshTool[tree.id] = {}
// 重要!保存resolve方法,以便后续使用
this.tableTreeRefreshTool[tree.id].resolve = resolve
this.tableTreeRefreshTool[tree.id].parentCode = tree.code
// 记录展开次数,具体作用后续介绍
this.tableTreeRefreshTool[tree.id].expandCount = 0
var url = base + "/platform/sys/dict/child"
@@ -351,7 +389,8 @@ layout("/layouts/platform.html"){
{ pid: tree.id },
function (data) {
if (data.code == 0) {
resolve(data.data)
// 直接根据当前展开的父字典识别直属子项,避免依赖顶级字典异步初始化结果。
resolve(self.markUserAttributeChildren(data.data, tree.code))
}
},
"json"
@@ -362,9 +401,10 @@ layout("/layouts/platform.html"){
var self = this
if ("add" == command.type) {
self.openAdd()
self.formData.parentId = command.id
self.formData.parentName = command.name
self.isAddFromSub = true
self.$set(self.formData, "parentId", command.id)
self.$set(self.formData, "parentName", command.name)
self.$set(self, "isAddFromSub", true)
self.$set(self, "showUserAttributeFields", command.row && command.row.code === "USER_ATTRIBUTE")
}
if ("edit" == command.type) {
$.post(
@@ -372,8 +412,9 @@ layout("/layouts/platform.html"){
{},
function (data) {
if (data.code == 0) {
self.formData = data.data //加载后台表单数据
self.editDialogVisible = true //打开编辑窗口
self.$set(self, "formData", data.data) //加载后台表单数据
self.$set(self, "showUserAttributeFields", Boolean(command.row && command.row.isUserAttributeChild))
self.$set(self, "editDialogVisible", true) //打开编辑窗口
} else {
self.$message({
message: data.msg,
@@ -460,15 +501,28 @@ layout("/layouts/platform.html"){
const curr = this.tableTreeRefreshTool[v]
// api请求
$.get("/platform/sys/dict/child", { pid: v }).then((res) => {
curr.resolve(res.data)
curr.resolve(this.markUserAttributeChildren(res.data, curr.parentCode))
})
})
} else {
this.initTreeTable()
}
},
// 加载启用的人员类型,同时生成列表展示使用的编码名称映射。
loadPersonTypes() {
return this.$businessTool.getDictOptions("USER_PERSON_TYPE").then((options) => {
const rows = options || []
const nameMap = {}
rows.forEach((item) => {
nameMap[item.code] = item.name
})
this.$set(this, "personTypeOptions", rows)
this.$set(this, "personTypeNameMap", nameMap)
})
}
},
created: function () {
created() {
this.loadPersonTypes()
this.initTreeTable()
}
})
+486 -109
View File
@@ -31,11 +31,17 @@
<div class="login-form">
<div class="my-3 text-6xl font-semibold">${AppName!}${base!}</div>
<div class="flx-center-center">
<div class="p-12 bg-white rounded-3xl" style="box-sizing: border-box; width: 100%">
<el-form ref="loginForm" :model="loginForm" :rules="loginRules" auto-complete="on" label-position="left">
<div class="login-card p-12 bg-white rounded-3xl" style="box-sizing: border-box">
<div class="login-tabs" role="tablist" aria-label="登录方式">
<button type="button" role="tab" :aria-selected="loginMode === 'password'" :class="{ active: loginMode === 'password' }" :disabled="formLoading || smsSending || captchaVisible" @click="switchLoginMode('password')">账号密码</button>
<button v-if="smsLoginVisible" type="button" role="tab" :aria-selected="loginMode === 'sms'" :class="{ active: loginMode === 'sms' }" :disabled="formLoading || smsSending || captchaVisible" @click="switchLoginMode('sms')">手机验证码</button>
</div>
<el-form v-show="loginMode === 'password'" ref="loginForm" :model="loginForm" :rules="loginRules" auto-complete="on" label-position="left">
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
:value="loginForm.username"
@input="$set(loginForm, 'username', $event)"
:disabled="formLoading || captchaVisible"
type="text"
auto-complete="off"
placeholder="账号"
@@ -44,7 +50,9 @@
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
:value="loginForm.password"
@input="$set(loginForm, 'password', $event)"
:disabled="formLoading || captchaVisible"
type="password"
auto-complete="off"
placeholder="密码"
@@ -52,37 +60,58 @@
prefix-icon="el-icon-lock"
></el-input>
</el-form-item>
<el-form-item prop="platformCaptcha">
<el-input
v-model="loginForm.platformCaptcha"
auto-complete="off"
placeholder="验证码"
style="width: 60%"
@keyup.enter.native="handleLogin"
prefix-icon="el-icon-key"
></el-input>
<div class="login-code">
<img :src="codeUrl" @click="getCode" />
</div>
</el-form-item>
<el-form-item style="width: 100%">
<el-button
:loading="loading"
:loading="formLoading"
size="medium"
type="primary"
style="width: 100%; --color-primary: #1867b0"
@click.native.prevent="handleLogin"
>
<span v-if="!loading">登 录</span>
<span v-if="!formLoading">登 录</span>
<span v-else>登 录 中...</span>
</el-button>
</el-form-item>
</el-form>
<el-form v-show="smsLoginVisible && loginMode === 'sms'" ref="smsForm" :model="smsForm" :rules="smsRules" auto-complete="off" label-position="left">
<el-form-item prop="mobile">
<el-input :value="smsForm.mobile" @input="handleMobileChange" :disabled="formLoading || smsSending || captchaVisible" maxlength="11" inputmode="tel" placeholder="请输入系统绑定的手机号" prefix-icon="el-icon-mobile-phone"></el-input>
</el-form-item>
<el-form-item prop="smsCode">
<el-input :value="smsForm.smsCode" @input="$set(smsForm, 'smsCode', $event)" :disabled="formLoading || captchaVisible" maxlength="6" inputmode="numeric" autocomplete="one-time-code" placeholder="6位短信验证码" @keyup.enter.native="handleSmsLogin" prefix-icon="el-icon-message">
<el-button slot="append" :loading="smsSending" :disabled="formLoading || smsCountdown > 0 || captchaVisible" @click="sendSmsCode">{{ smsCountdown > 0 ? smsCountdown + '秒后重发' : '获取验证码' }}</el-button>
</el-input>
</el-form-item>
<el-form-item style="width: 100%">
<el-button :loading="formLoading" :disabled="smsSending || captchaVisible" size="medium" type="primary" style="width: 100%; --color-primary: #1867b0" @click.native.prevent="handleSmsLogin">
<span v-if="!formLoading">登 录</span>
<span v-else>登 录 中...</span>
</el-button>
</el-form-item>
<p class="login-tip">请使用系统账号绑定的手机号</p>
</el-form>
</div>
</div>
</div>
</div>
</div>
<!-- 两种登录方式共用滑块弹框;系统返回键只关闭当前弹框。 -->
<el-dialog title="安全验证" custom-class="captcha-dialog" :visible="captchaVisible" :before-close="closeCaptcha" :close-on-click-modal="false" append-to-body>
<div class="captcha-panel" v-loading="captchaLoading">
<div class="captcha-heading">
<span>拖动滑块,完成拼图</span>
<el-button type="text" :disabled="captchaLoading || captchaChecking" @click="loadSlider">换一张</el-button>
</div>
<div class="captcha-picture">
<template v-if="sliderData">
<img :key="sliderData.challengeId + '-background'" class="captcha-background" :src="sliderData.background" alt="拼图背景" @load="onSliderImageLoad('background', $event)" @error="onSliderImageError" />
<img :key="sliderData.challengeId + '-piece'" class="captcha-piece" :src="sliderData.piece" :style="pieceStyle" alt="待移动拼图" @load="onSliderImageLoad('piece', $event)" @error="onSliderImageError" />
</template>
</div>
<input type="range" class="captcha-range" min="0" :max="sliderMax" step="1" :value="sliderOffset" :disabled="!sliderReady || captchaChecking || captchaLoading" aria-label="拖动滑块使拼图对齐缺口" @input="onSliderInput" @change="checkSlider" />
<div class="captcha-hint" role="status">{{ captchaChecking ? '正在验证…' : captchaHint }}</div>
</div>
</el-dialog>
</div>
</body>
<!-- import Vue before Element -->
@@ -97,18 +126,12 @@
<script src="${base!}/assets/platform/plugins/sm-crypto/sm2.js"></script>
<!-- import commonUtil -->
<script src="${base!}/assets/platform/js/util/commonUtil.js"></script>
<script src="${base!}/assets/platform/js/util/h5PopupHistory.js"></script>
<!-- import jsencrypt-->
<script src="${base!}/assets/platform/plugins/jsencrypt/jsencrypt.min.js"></script>
<script nonce="${cspNonce!}">
// 在加载 lodash 后、使用前插入
const originalDefaultsDeep = window._.defaultsDeep;
window._.defaultsDeep = function(...args) {
// 先对所有参数做原型污染清洗
const cleanArgs = args.map(arg => sanitizeForPrototypePollution(arg));
return originalDefaultsDeep.apply(window._, cleanArgs);
};
function sanitizeForPrototypePollution(obj) {
// 登录页未加载 lodash 时跳过补丁,避免在 Vue 初始化前中断脚本。
const sanitizeForPrototypePollution = (obj) => {
if (obj === null || typeof obj !== 'object') return obj;
if (Array.isArray(obj)) return obj.map(sanitizeForPrototypePollution);
@@ -122,29 +145,64 @@
}
return clean;
}
if (window._ && typeof window._.defaultsDeep === "function") {
const originalDefaultsDeep = window._.defaultsDeep;
window._.defaultsDeep = (...args) => {
const cleanArgs = args.map(arg => sanitizeForPrototypePollution(arg));
return originalDefaultsDeep.apply(window._, cleanArgs);
};
}
</script>
<script nonce="${cspNonce!}">
// 本页沿用原 axios 响应结构(res.data),不使用会解包响应的全局拦截器。
Vue.prototype.$axios = axios
new Vue({
el: "#app",
data() {
return {
codeUrl: "",
loginMode: "password",
// 服务端仅输出布尔值;参数缺失时默认隐藏短信入口。
smsLoginVisible: "${smsLoginVisible!false}" === "true",
loginForm: {
username: "",
password: "",
platformKey: "",
platformCaptcha: "",
grantType: "captcha"
},
loginRules: {
username: [{ required: true, trigger: ["blur", "change"], message: "账号不能为空" }],
password: [{ required: true, trigger: ["blur", "change"], message: "密码不能为空" }],
platformCaptcha: [{ required: true, trigger: ["blur", "change"], message: "请输入验证码" }]
},
loading: false,
smsForm: {
mobile: "",
smsCode: ""
},
smsRules: {
mobile: [
{ required: true, trigger: "blur", message: "请输入手机号" },
{ pattern: /^1[3-9]\d{9}$/, trigger: "blur", message: "请输入正确的11位手机号" }
],
smsCode: [
{ required: true, trigger: "blur", message: "请输入短信验证码" },
{ pattern: /^\d{6}$/, trigger: "blur", message: "请输入6位数字短信验证码" }
]
},
formLoading: false,
smsSending: false,
captchaVisible: false,
captchaLoading: false,
captchaChecking: false,
captchaHint: '拖动下方滑块,将拼图移到缺口处',
sliderData: null,
sliderOffset: 0,
sliderImages: { background: false, piece: false },
sliderVersion: 0,
sliderTimer: null,
sliderContext: null,
popupToken: null,
pageShowHandler: null,
smsCountdown: 0,
smsTimer: null,
rasParams: {
publicKey: '',
keyId: '',
@@ -152,69 +210,251 @@
},
}
},
computed: {
sliderMax() {
return this.sliderData ? this.sliderData.width - this.sliderData.pieceSize : 270
},
sliderReady() {
return !!this.sliderData && this.sliderImages.background && this.sliderImages.piece
},
pieceStyle() {
if (!this.sliderData) return {}
return {
width: this.sliderData.pieceSize / this.sliderData.width * 100 + '%',
left: this.sliderOffset / this.sliderData.width * 100 + '%',
top: this.sliderData.pieceY / this.sliderData.height * 100 + '%'
}
}
},
methods: {
getCode() {
axios.post("/platform/login/captcha").then((resp) => {
if (resp.data.code === 0) {
this.codeUrl = resp.data.data.codeUrl
this.loginForm.platformKey = resp.data.data.key
}
switchLoginMode(mode) {
if (mode === 'sms' && !this.smsLoginVisible) return
if (this.formLoading || this.smsSending || this.captchaVisible || this.loginMode === mode) return
this.$set(this, 'loginMode', mode)
this.$nextTick(() => {
this.$refs.loginForm.clearValidate()
this.$refs.smsForm.clearValidate()
})
},
handleMobileChange(mobile) {
if (mobile !== this.smsForm.mobile) {
this.$set(this.smsForm, 'mobile', mobile)
// 短信验证码只适用于发送时的号码;改号后清空输入,保留发送倒计时。
this.$set(this.smsForm, 'smsCode', '')
}
},
// Element 按字段回调校验结果,所有字段完成后返回是否允许提交。
validateSmsFields(fields) {
return new Promise((resolve) => {
let remaining = fields.length
let valid = true
this.$refs.smsForm.validateField(fields, (message) => {
valid = valid && !message
remaining -= 1
if (remaining === 0) resolve(valid)
})
})
},
handleLogin() {
this.$refs.loginForm.validate(async (valid) => {
if (valid) {
await this.ensurePublicKey()
const { publicKey, keyId } = this.rasParams
if (!publicKey || !keyId) {
this.$message({
message: "Login key unavailable, please refresh and try again",
type: "error"
})
return
}
// 加密密码
const encrypt = new JSEncrypt()
encrypt.setPublicKey(publicKey)
const encryptedPassword = encrypt.encrypt(this.loginForm.password)
axios
.post("/platform/login/doLogin", null, {
params: {
...this.loginForm,
//password: window.btoa(this.loginForm.password)
password: encryptedPassword,
keyId: keyId
}
})
.then((resp) => {
if (resp.data.code === 0) {
this.$message.success("登录成功")
const redirect = new URLSearchParams(window.location.search).get("redirect")
if (redirect) {
window.location.href = "${base!}" + redirect || ""
} else {
window.location.href = "${base!}/platform/home"
}
} else {
this.getCode()
this.$message({
message: resp.data.msg,
type: "error"
})
}
})
.catch((error) => {
console.log(error)
})
} else {
console.log("cccccc")
}
if (this.formLoading || this.smsSending || this.captchaVisible || this.loginMode !== 'password') return
this.$refs.loginForm.validate((valid) => {
if (valid) this.openSlider('password')
})
},
// 获取公钥
sendSmsCode() {
if (this.smsSending || this.formLoading || this.captchaVisible || this.smsCountdown > 0) return
this.validateSmsFields(['mobile']).then((valid) => {
if (valid && !this.captchaVisible && !this.formLoading && !this.smsSending && this.smsCountdown === 0) this.openSlider('sms')
})
},
// 固定本次验证的用途及输入,避免异步返回时把凭证用于另一个账号或号码。
openSlider(scene) {
this.$set(this, 'sliderContext', {
scene: scene,
subject: scene === 'password' ? this.loginForm.username : this.smsForm.mobile,
password: scene === 'password' ? this.loginForm.password : ''
})
this.$set(this, 'captchaVisible', true)
window.h5PopupHistory.open(this.popupToken, 'login-slider')
this.loadSlider()
},
// scene/subject 绑定操作;返回图片、挑战编号和原图尺寸,不包含正确横坐标。
loadSlider() {
if (!this.captchaVisible || !this.sliderContext || this.captchaLoading || this.captchaChecking) return
clearTimeout(this.sliderTimer)
this.$set(this, 'sliderVersion', this.sliderVersion + 1)
const version = this.sliderVersion
this.$set(this, 'sliderData', null)
this.$set(this, 'sliderOffset', 0)
this.$set(this, 'sliderImages', { background: false, piece: false })
this.$set(this, 'captchaLoading', true)
this.$set(this, 'captchaHint', '图片加载中…')
return this.$axios.post('${base!}/platform/login/sliderCaptcha', null, {
params: { scene: this.sliderContext.scene, subject: this.sliderContext.subject }
}).then((resp) => {
if (version !== this.sliderVersion || !this.captchaVisible) return
if (resp.data.code !== 0 || !resp.data.data) throw new Error(resp.data.msg || '滑块加载失败')
this.$set(this, 'sliderData', resp.data.data)
this.$set(this, 'captchaHint', '拖动下方滑块,将拼图移到缺口处')
this.$set(this, 'sliderTimer', setTimeout(() => {
if (version !== this.sliderVersion) return
this.$set(this, 'sliderData', null)
this.$set(this, 'captchaHint', '拼图已过期,请点击“换一张”重新验证')
}, Math.max(1, Number(resp.data.data.expiresIn) || 180) * 1000))
}).catch((error) => {
if (version !== this.sliderVersion || !this.captchaVisible) return
this.$set(this, 'captchaHint', error.message || '加载失败,请点击“换一张”重试')
}).finally(() => {
if (version === this.sliderVersion) this.$set(this, 'captchaLoading', false)
})
},
onSliderImageLoad(kind, event) {
// 旧图片延迟触发 load 时不能把新一轮挑战提前标记为可拖动。
if (this.sliderData && event.target.getAttribute('src') === this.sliderData[kind]) {
this.$set(this.sliderImages, kind, true)
}
},
onSliderImageError() {
this.$set(this, 'sliderImages', { background: false, piece: false })
this.$set(this, 'captchaHint', '图片加载失败,请点击“换一张”重试')
},
onSliderInput(event) {
if (!this.sliderReady || this.captchaChecking) return
this.$set(this, 'sliderOffset', Math.max(0, Math.min(this.sliderMax, Number(event.target.value))))
},
// offset 使用原图像素;服务器返回一次性凭证,关闭弹框历史后再提交登录或发送短信。
checkSlider() {
if (!this.sliderReady || this.captchaChecking || !this.captchaVisible) return
const version = this.sliderVersion
const context = Object.assign({}, this.sliderContext)
this.$set(this, 'captchaChecking', true)
return this.$axios.post('${base!}/platform/login/checkSliderCaptcha', null, {
params: { challengeId: this.sliderData.challengeId, offset: this.sliderOffset }
}).then((resp) => {
if (version !== this.sliderVersion || !this.captchaVisible) return
if (resp.data.code !== 0 || !resp.data.data) throw new Error(resp.data.msg || '验证失败,请重试')
const ticket = resp.data.data
if (context.scene === 'password' && (context.subject !== this.loginForm.username || context.password !== this.loginForm.password)) {
throw new Error('账号信息已变化,请重新验证')
}
if (context.scene === 'sms' && context.subject !== this.smsForm.mobile) throw new Error('手机号已变化,请重新验证')
const loadingField = context.scene === 'password' ? 'formLoading' : 'smsSending'
this.$set(this, loadingField, true)
// clear 会同步触发关闭回调,因此使用局部快照保留本次请求参数。
return window.h5PopupHistory.clear(this.popupToken).then(() => {
return context.scene === 'password' ? this.submitPassword(context, ticket) : this.submitSms(context, ticket)
}).finally(() => {
this.$set(this, loadingField, false)
})
}).catch((error) => {
if (version !== this.sliderVersion || !this.captchaVisible) return
this.$set(this, 'sliderData', null)
this.$set(this, 'captchaHint', (error.message || '验证失败') + ',请点击“换一张”')
}).finally(() => {
if (version === this.sliderVersion) this.$set(this, 'captchaChecking', false)
})
},
closeCaptcha() {
// 关闭按钮、ESC 与系统返回共用全局历史管理器,不单独注册 popstate。
if (!window.h5PopupHistory.close(this.popupToken, 'login-slider')) this.resetSlider()
},
resetSlider() {
clearTimeout(this.sliderTimer)
this.$set(this, 'sliderVersion', this.sliderVersion + 1)
this.$set(this, 'captchaVisible', false)
this.$set(this, 'captchaLoading', false)
this.$set(this, 'captchaChecking', false)
this.$set(this, 'sliderData', null)
this.$set(this, 'sliderContext', null)
this.$set(this, 'sliderOffset', 0)
},
// 沿用 RSA 公钥加密;只提交服务器签发的滑块凭证,不提交滑块横坐标作为登录凭证。
submitPassword(context, ticket) {
return this.ensurePublicKey().then(() => {
const encrypt = new JSEncrypt()
encrypt.setPublicKey(this.rasParams.publicKey)
const encryptedPassword = encrypt.encrypt(context.password)
if (!encryptedPassword) throw new Error('密码加密失败,请刷新页面后重试')
return this.$axios.post('${base!}/platform/login/doLogin', null, {
params: { username: context.subject, password: encryptedPassword, keyId: this.rasParams.keyId,
platformKey: ticket.platformKey, platformCaptcha: ticket.platformCaptcha, grantType: 'captcha' }
})
}).then((resp) => {
this.handleLoginResult(resp.data)
}).catch((error) => {
this.$message.error(error.message || '登录失败,请重新验证后重试')
})
},
// mobile 为绑定号码,两个 platform 参数为滑块凭证;返回重发等待时间及短信有效时间。
submitSms(context, ticket) {
return this.$axios.post('${base!}/platform/login/mobileCaptcha', null, {
params: { mobile: context.subject, platformKey: ticket.platformKey, platformCaptcha: ticket.platformCaptcha }
}).then((resp) => {
if (resp.data.code !== 0) throw new Error(resp.data.msg || '短信验证码发送失败')
this.$set(this.smsForm, 'smsCode', '')
// 优先使用后端重发等待秒数,缺失时按五分钟倒计时。
this.startSmsCountdown(Number((resp.data.data || {}).retryAfter) || 300)
this.$message.success('验证码已发送,5分钟内有效')
}).catch((error) => {
this.$message.error(error.message || '短信验证码发送失败,请重新验证后重试')
})
},
startSmsCountdown(seconds) {
if (this.smsTimer) {
clearInterval(this.smsTimer)
}
const retryAt = Date.now() + Math.max(1, seconds) * 1000
this.$set(this, "smsCountdown", Math.ceil(seconds))
this.$set(this, "smsTimer", setInterval(() => {
this.$set(this, "smsCountdown", Math.max(0, Math.ceil((retryAt - Date.now()) / 1000)))
if (this.smsCountdown === 0) {
clearInterval(this.smsTimer)
this.$set(this, "smsTimer", null)
}
}, 1000))
},
handleSmsLogin() {
if (this.formLoading || this.smsSending || this.captchaVisible || this.loginMode !== "sms") {
return
}
// mobile 为当前手机号,smsCode 为6位短信码;接口返回标准 Result,code=0 时按原路径进入系统。
this.$set(this, "formLoading", true)
this.validateSmsFields(["mobile", "smsCode"])
.then((valid) => {
if (!valid) {
return
}
return this.$axios.post("${base!}/platform/login/doSmsLogin", null, {
params: { mobile: this.smsForm.mobile, smsCode: this.smsForm.smsCode }
}).then((resp) => {
this.handleLoginResult(resp.data)
})
})
.catch((error) => {
this.$message.error(error.message || "登录失败,请稍后重试")
})
.finally(() => {
this.$set(this, "formLoading", false)
})
},
// 两种登录方式共用原有成功提示与登录后跳转,保持 redirect 参数行为。
handleLoginResult(result) {
if (result.code !== 0) {
throw new Error(result.msg || "登录失败,请稍后重试")
}
this.$message.success("登录成功")
const redirect = new URLSearchParams(window.location.search).get("redirect")
window.h5PopupHistory.clear(this.popupToken).then(() => {
window.location.href = redirect ? "${base!}" + redirect : "${base!}/platform/home"
})
},
// 公钥接口沿用原 GET 方式;返回 publicKey、keyId、expireAt 供密码登录加密和过期判断。
fetchPublicKey() {
return axios.get("/platform/login/publicKey").then(res => {
this.rasParams = res.data.data
return this.$axios.get("${base!}/platform/login/publicKey").then((res) => {
if (res.data.code !== 0 || !res.data.data || !res.data.data.publicKey || !res.data.data.keyId) {
throw new Error(res.data.msg || "登录公钥获取失败,请稍后重试")
}
this.$set(this, "rasParams", res.data.data)
})
},
ensurePublicKey() {
@@ -226,8 +466,18 @@
},
},
created() {
this.fetchPublicKey()
this.getCode()
this.$set(this, 'popupToken', window.h5PopupHistory.register(() => this.resetSlider()))
this.$set(this, 'pageShowHandler', (event) => {
// 从浏览器往返缓存恢复时丢弃旧挑战,避免显示已过期凭证或遗留弹框。
if (event.persisted) window.h5PopupHistory.clear(this.popupToken).then(() => this.resetSlider())
})
window.addEventListener('pageshow', this.pageShowHandler)
},
beforeDestroy() {
clearInterval(this.smsTimer)
clearTimeout(this.sliderTimer)
window.removeEventListener('pageshow', this.pageShowHandler)
window.h5PopupHistory.clear(this.popupToken).then(() => window.h5PopupHistory.unregister(this.popupToken))
}
})
</script>
@@ -260,6 +510,8 @@
}
.login-container {
position: relative;
z-index: 1;
width: 100vw;
height: 100vh;
display: grid;
@@ -288,6 +540,121 @@
width: 500px;
}
.login-card {
width: 100%;
}
.login-tabs {
display: flex;
gap: 32px;
justify-content: center;
margin-bottom: 28px;
border-bottom: 1px solid #edf0f5;
}
.login-tabs button {
padding: 0 0 14px;
border: 0;
border-bottom: 3px solid transparent;
background: transparent;
color: #7c8798;
font: inherit;
font-size: 17px;
cursor: pointer;
}
.login-tabs button.active {
border-bottom-color: #1867b0;
color: #1867b0;
font-weight: 600;
}
.login-tabs button:disabled {
cursor: default;
}
.captcha-dialog {
width: 400px;
max-width: calc(100vw - 32px);
border-radius: 6px;
}
.captcha-dialog .el-dialog__body {
padding: 10px 20px 20px;
}
.captcha-panel {
padding: 12px;
border: 1px solid #dcdfe6;
border-radius: 6px;
background: #f7faff;
line-height: 1.5;
}
.captcha-heading {
display: flex;
justify-content: space-between;
align-items: center;
color: #606266;
margin-bottom: 8px;
}
.captcha-picture {
position: relative;
padding-top: 50%;
overflow: hidden;
border-radius: 4px;
background: #e9eef5;
}
.captcha-background {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.captcha-piece {
position: absolute;
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, .5));
}
.captcha-picture img {
pointer-events: none;
user-select: none;
}
.captcha-range {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 40px;
margin: 12px 0 0;
border-radius: 5px;
background: #e4eaf2;
cursor: ew-resize;
touch-action: pan-y;
}
.captcha-range::-webkit-slider-thumb {
-webkit-appearance: none;
width: 42px;
height: 40px;
border-radius: 5px;
border: 2px solid #fff;
background: #409eff;
box-shadow: 0 1px 4px rgba(0, 0, 0, .2);
}
.captcha-range::-moz-range-thumb {
width: 38px;
height: 36px;
border-radius: 5px;
border: 2px solid #fff;
background: #409eff;
}
.captcha-range:focus-visible { outline: 2px solid #409eff; outline-offset: 3px; }
.captcha-range:disabled { opacity: .55; cursor: wait; }
.captcha-hint { margin-top: 6px; color: #909399; font-size: 12px; text-align: center; }
.login-tip {
margin: -4px 0 0;
color: #8a94a4;
font-size: 12px;
line-height: 20px;
}
.font-semibold {
font-weight: 600;
}
@@ -314,21 +681,6 @@
border-radius: 1.5rem;
}
.login-code {
display: inline-block;
float: right;
width: 38%;
height: 38px;
}
.login-code img {
width: 100%;
height: 40px;
object-fit: contain;
vertical-align: middle;
cursor: pointer;
}
.login-bg-img {
position: fixed;
width: 100%;
@@ -344,7 +696,32 @@
}
.login-container .login-box .login-form {
width: 100vw !important;
width: 100% !important;
padding: 24px 20px;
box-sizing: border-box;
}
.login-card {
padding: 30px 22px;
}
.text-6xl {
font-size: 24px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.login-tabs {
gap: 26px;
}
.login-tabs button {
font-size: 16px;
}
.el-input-group__append {
padding: 0 14px;
}
}
</style>
@@ -1,7 +1,7 @@
const branchUnionManage = {
template: `
<div>
<el-card shadow="never" style="border: 1px solid var(--border-color-lighter); margin-top: 10px">
<div class="branch-union-list">
<el-card class="branch-union-search-card" shadow="never" style="border: 1px solid var(--border-color-lighter); margin-top: 10px">
<el-row :gutter="10" type="flex">
<el-input placeholder="请输入分工会名称" clearable size="small" style="width: 300px" v-model="pageForm.searchKeyword"></el-input>
@@ -10,8 +10,9 @@ const branchUnionManage = {
<el-button @click="openAdd" icon="ti-plus" type="primary" size="small" v-if="$auth.hasPermission('sys.manager.union.add')">新建分工会</el-button>
</el-row>
</el-card>
<el-card shadow="never" style="border: 1px solid var(--border-color-lighter); margin-top: 10px">
<el-table :data="tableData" border>
<el-card class="branch-union-table-card" shadow="never" style="border: 1px solid var(--border-color-lighter); margin-top: 10px">
<div class="branch-union-table-wrapper">
<el-table :data="tableData" border height="100%">
<el-table-column label="序号" type="index" width="50" :index="indexMethod"></el-table-column>
<el-table-column prop="name" label="工会名称" show-overflow-tooltip></el-table-column>
<el-table-column prop="unionCode" label="工会编码"></el-table-column>
@@ -25,6 +26,7 @@ const branchUnionManage = {
</template>
</el-table-column>
</el-table>
</div>
<el-row class="el-pagination-container mt20">
<el-pagination
@size-change="pageSizeChange"
@@ -2,10 +2,59 @@
layout("/layouts/platform.html"){
#-->
<style>
/* 分工会列表占满可用高度,分页固定在底部,数据在表格内滚动。 */
.union-branch-list-panel,
.union-branch-list-panel > .el-card__body,
.union-branch-list-panel .el-tabs,
.union-branch-list-panel .el-tabs__content,
.union-branch-list-panel .el-tab-pane {
height: 100%;
min-height: 0;
box-sizing: border-box;
}
.union-branch-list-panel .el-tabs {
display: flex;
flex-direction: column;
}
.union-branch-list-panel .el-tabs__header {
flex-shrink: 0;
}
.union-branch-list-panel .el-tabs__content {
flex: 1;
}
.union-branch-list-panel .branch-union-list {
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
}
.union-branch-list-panel .branch-union-search-card {
flex-shrink: 0;
}
.union-branch-list-panel .branch-union-table-card {
flex: 1;
min-height: 0;
}
.union-branch-list-panel .branch-union-table-card > .el-card__body {
height: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
.union-branch-list-panel .branch-union-table-wrapper {
flex: 1;
min-height: 0;
}
.union-branch-list-panel .el-pagination-container {
flex-shrink: 0;
}
</style>
<div id="app" v-cloak>
<guava ref="guava">
<template>
<el-row type="flex" :gutter="20" style="height: calc(100vh - 126px)">
<el-row type="flex" :gutter="20" style="height: calc(100vh - 94px)">
<el-col :span="5">
<el-card shadow="never" style="height: 100%" body-style="{ height: '100%' }">
<el-input placeholder="输入关键字进行查找" v-model="filterText" clearable></el-input>
@@ -35,7 +84,7 @@ layout("/layouts/platform.html"){
</el-card>
</el-col>
<el-col :span="19">
<el-card shadow="never">
<el-card shadow="never" :class="{'union-branch-list-panel': (!currentTreeNode || currentTreeNode.level===1) && schoolUnionTabActive==='branchUnionManage'}">
<el-tabs v-model="schoolUnionTabActive" type="card" v-if="currentTreeNode==null || currentTreeNode.level===1">
<el-tab-pane name="branchUnionManage">
<span slot="label">
@@ -1,9 +1,10 @@
<!--#include('/platform/zhgh/activity/trainSignUp/manage/courseTime.js'){}#-->
<!--#include('/platform/zhgh/activity/trainSignUp/manage/customForm.js'){}#-->
<!--#include('/platform/zhgh/activity/trainSignUp/manage/responsibleForm.js'){}#-->
const basicForm = {
template: /*language=HTML*/ `
<div>
<el-form :model="formData" label-width="110px" ref="form">
<el-form :model="formData" :disabled="formLoading" label-width="110px" ref="form">
<div v-show="step === 1">
<el-row :gutter="20" type="flex" v-if="!formData.id">
<el-col :span="12">
@@ -235,13 +236,11 @@ const basicForm = {
<el-table-column :label="trainType + '负责人'" prop="courseInstructor">
<template v-slot="{row}">
<el-input size="small" v-model="row.courseInstructor" :placeholder="'请输入' + trainType + '负责人'"></el-input>
</template>
</el-table-column>
<el-table-column label="手机号" prop="courseInstructorMobile">
<template v-slot="{row}">
<el-input size="small" v-model="row.courseInstructorMobile" placeholder="请输入手机号"></el-input>
<el-button @click="openSetUpResponsible(row)" :disabled="formLoading" type="text"
:title="getResponsibleLabel(row)"
style="max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">
{{ getResponsibleLabel(row) }}
</el-button>
</template>
</el-table-column>
@@ -290,11 +289,11 @@ const basicForm = {
</div>
</el-form>
<div style="float: right; margin: 20px 0">
<el-button @click="$emit('back')">取消</el-button>
<el-button v-if="step === 2" type="primary" @click="step = 1">上一步</el-button>
<el-button v-if="step === 1" type="primary" @click="step = 2">下一步</el-button>
<el-button type="primary" @click="onSave">{{ copyTemplateMode ? "另存为" : "保存" }}</el-button>
<el-button type="primary" @click="onSubmit">{{ copyTemplateMode ? "新活动提交" : "提交" }}</el-button>
<el-button :disabled="formLoading" @click="$emit('back')">取消</el-button>
<el-button v-if="step === 2" :disabled="formLoading" type="primary" @click="step = 1">上一步</el-button>
<el-button v-if="step === 1" :disabled="formLoading" type="primary" @click="step = 2">下一步</el-button>
<el-button :loading="formLoading" type="primary" @click="onSave">{{ copyTemplateMode ? "另存为" : "保存" }}</el-button>
<el-button :loading="formLoading" type="primary" @click="onSubmit">{{ copyTemplateMode ? "新活动提交" : "提交" }}</el-button>
</div>
<el-dialog :close-on-click-modal="false" :visible.sync="signUpDialog" title="设置报名限制" append-to-body>
@@ -322,6 +321,7 @@ const basicForm = {
<course-time ref="courseTimeRef"></course-time>
<custom-form ref="customFormRef"></custom-form>
<responsible-form ref="responsibleFormRef"></responsible-form>
</div>
`,
dicts: ["USER_CAMPUS", "TRAIN_SIGNUP_TYPE"],
@@ -329,12 +329,13 @@ const basicForm = {
return {
signUpDialog: false,
step: 1,
formLoading: false,
copyTemplateMode: false,
copyTemplateName: "",
formData: {
notice: false,
courseList: [
{ orderNum: 1, courseName: "", courseInstructorMobile: "", courseTimeList: [], isMobileSign: false, isReceiveGift: false, reserveMode: 1, courseIsLimitApply: false },
{ orderNum: 1, courseName: "", courseInstructorMobile: "", responsibleList: [], courseTimeList: [], isMobileSign: false, isReceiveGift: false, reserveMode: 1, courseIsLimitApply: false },
],
conditionStructure: {
method: "AND",
@@ -364,6 +365,7 @@ const basicForm = {
"drawer-user-scope": httpVueLoader("/components/module/activity/DrawerUserScope.vue"),
"course-time": courseTime,
"custom-form": customForm,
"responsible-form": responsibleForm,
},
methods: {
async validNumber(row, old) {
@@ -464,11 +466,23 @@ const basicForm = {
openSetUpCourseTime(index) {
this.$refs.courseTimeRef.onOpen(this.formData, index)
},
// 只显示新方式设置的负责人姓名;没有负责人关系时显示设置入口,不读取历史手填姓名。
getResponsibleLabel(row) {
if (Array.isArray(row.responsibleList)) {
const names = row.responsibleList.map(item => (item.username || '').trim()).filter(name => name)
return names.length ? names.join('、') : '设置负责人'
}
return '设置负责人'
},
// 和设置时间一样从当前子活动行打开弹框,未保存的新增子活动也可以设置负责人。
openSetUpResponsible(row) {
this.$refs.responsibleFormRef.onOpen(row)
},
courseOrderNumChange() {
this.formData.courseList = this.formData.courseList.sort((a, b) => a.orderNum - b.orderNum)
},
addCourse() {
this.formData.courseList.push({ courseInstructorMobile: "", courseTimeList: [], isMobileSign: false, isReceiveGift: false, reserveMode: 1, courseIsLimitApply: false })
this.formData.courseList.push({ courseInstructorMobile: "", responsibleList: [], courseTimeList: [], isMobileSign: false, isReceiveGift: false, reserveMode: 1, courseIsLimitApply: false })
},
async historicalActChange(val) {
const resp = await this.$axios.post("/platform/trainSignUp/manage/findOne", {id: val})
@@ -510,6 +524,13 @@ const basicForm = {
course.updatedBy = null
course.updatedAt = null
course.delFlag = null
// 沿用负责人选择和联系方式,关系ID及所属活动由后端在新活动中重新生成。
if (Array.isArray(course.responsibleList)) {
this.$set(course, 'responsibleList', course.responsibleList.map(item => ({
userId: item.userId, username: item.username, loginname: item.loginname,
mobile: item.mobile, orderNum: item.orderNum
})))
}
if (Array.isArray(course.courseTimeList)) {
course.courseTimeList = course.courseTimeList.map((courseTime) => {
courseTime.id = null
@@ -543,7 +564,7 @@ const basicForm = {
const resp = await this.$axios.post("/platform/trainSignUp/manage/getHistoricalActList", {})
return resp.data
},
async onSave() {
onSave() {
let valid = false
this.$refs.form.validateField("activityName", (errMsg) => {
valid = errMsg === ""
@@ -555,16 +576,19 @@ const basicForm = {
if (!this.validateCopyTemplateName()) {
return
}
await this.doHandle(this.copyTemplateMode ? '另存为' : '保存')
this.doHandle(this.copyTemplateMode ? '另存为' : '保存')
},
onSubmit() {
this.$refs["form"].validate(async (valid, errMsg) => {
this.$refs["form"].validate((valid, errMsg) => {
if (valid) {
if (!this.validateCopyTemplateName()) {
return
}
const courseValid = this.formData.courseList.some((v, i) => {
const basicValid = v.courseName && v.coursePeopleNumber && v.courseLocation && v.courseInstructor && v.courseType
// 新活动通过多选关系设置负责人;未重新设置的旧活动仍允许保留历史联系人。
const hasResponsible = Array.isArray(v.responsibleList)
? v.responsibleList.length > 0 : !!v.courseInstructor
const basicValid = v.courseName && v.coursePeopleNumber && v.courseLocation && hasResponsible && v.courseType
const timeValid =
v.courseTimeList.length > 0 &&
v.courseTimeList.every((x) => {
@@ -578,8 +602,10 @@ const basicForm = {
this.$message.warning("第" + (i + 1) + "行信息填写有误,请核查")
return true
}
if (v.courseInstructorMobile && !/^1[3-9]\d{9}$/.test(v.courseInstructorMobile)) {
this.$message.warning("第" + (i + 1) + "行手机号格式填写有误,请核查")
// 联系方式允许手机号或办公电话,逐人校验,不能只检查兼容展示的第一位。
if (Array.isArray(v.responsibleList) && v.responsibleList.some(item =>
!item.userId || !item.mobile || !item.mobile.trim() || item.mobile.trim().length > 20)) {
this.$message.warning("第" + (i + 1) + "行负责人联系方式未填写或超过20个字符,请核查")
return true
}
if (v.isMobileSign === true && v.signType === 3 && (v.courseLocationCoordinates === undefined || v.courseLocationCoordinates.length < 2)) {
@@ -598,7 +624,7 @@ const basicForm = {
return
}
this.formData.isDisabled = false
await this.doHandle(this.copyTemplateMode ? '新活动提交' : '提交')
this.doHandle(this.copyTemplateMode ? '新活动提交' : '提交')
} else {
if(Object.keys(errMsg).length > 0) {
this.$message.warning(errMsg[Object.keys(errMsg)[0]][0].message)
@@ -620,7 +646,10 @@ const basicForm = {
}
return true
},
async doHandle(type) {
doHandle(type) {
if (this.formLoading) {
return
}
const cloneData = clone(this.formData)
if (this.copyTemplateMode) {
this.cleanCopyTemplateData(cloneData)
@@ -636,21 +665,32 @@ const basicForm = {
cloneData.courseList = JSON.stringify(cloneData.courseList)
cloneData.hostUnits = JSON.stringify(cloneData.hostUnits)
cloneData.helpUnits = JSON.stringify(cloneData.helpUnits)
const confirm = await this.$confirm("您确定要" + type + "吗?", "提示", {
// courseList 以JSON提交,每个负责人仅需 userId、mobile;姓名及关系归属由后端确认。
// 返回 Result,code=0 表示保存成功;finally 在失败或网络异常时也解除提交加载状态。
return this.$confirm("您确定要" + type + "吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
})
if (confirm !== "confirm") return
const resp = await this.$axios.post("/platform/trainSignUp/manage/doHandle", cloneData)
if (resp.code === 0) {
this.$message.success(resp.msg)
this.step = 1
this.$emit('refresh')
this.$emit('back')
} else {
this.$message.warning(resp.msg)
}
}).then(() => {
if (this.formLoading) {
return
}
this.$set(this, 'formLoading', true)
return this.$axios.post("/platform/trainSignUp/manage/doHandle", cloneData)
.then((resp) => {
if (resp.code === 0) {
this.$message.success(resp.msg)
this.$set(this, 'step', 1)
this.$emit('refresh')
this.$emit('back')
} else {
this.$message.warning(resp.msg)
}
})
.finally(() => {
this.$set(this, 'formLoading', false)
})
}, () => {})
},
async getAllType() {
const resp = await this.$axios.post("/platform/trainSignUp/type/getAllType", {})
@@ -0,0 +1,74 @@
<!--#include('courseManageForm.js'){}#-->
const courseManage = {
template: /*language=HTML*/ `
<div>
<el-card shadow="never">
<div class="left-span-label">子活动管理</div>
<div class="mb10">所属活动:{{ activity.activityName }}</div>
<div class="text-info mb10">{{ canEditBasic ? '可管理当前活动的全部子活动' : '仅显示您负责的子活动' }}</div>
<search @search="doSearch">
<search-item label="子活动名称">
<el-input :value="pageForm.courseName" clearable placeholder="请输入子活动名称"
@input="(value) => { this.$set(pageForm, 'courseName', value) }"
@keyup.enter.native="doSearch"></el-input>
</search-item>
</search>
<el-table :data="tableData" :size="tableSize" v-loading="tableLoading"
empty-text="暂无可管理的子活动,请检查负责人设置" class="mt20">
<el-table-column :index="indexMethod" label="序号" type="index" width="70"></el-table-column>
<el-table-column :label="trainType + '名称'" prop="courseName" min-width="160" show-overflow-tooltip></el-table-column>
<el-table-column label="类型" prop="courseTypeName" min-width="110" show-overflow-tooltip></el-table-column>
<el-table-column label="人数" prop="coursePeopleNumber" width="80"></el-table-column>
<el-table-column label="预留名额" prop="courseReservedNumber" width="90"></el-table-column>
<el-table-column label="校区" prop="campus" min-width="120" show-overflow-tooltip></el-table-column>
<el-table-column label="地点" prop="courseLocation" min-width="160" show-overflow-tooltip></el-table-column>
<el-table-column label="负责人" min-width="120" show-overflow-tooltip>
<template v-slot="{row}">{{ row.responsibleNames || row.courseInstructor || '未设置' }}</template>
</el-table-column>
<el-table-column label="分类标识" prop="assort" width="100" show-overflow-tooltip></el-table-column>
<el-table-column label="活动时间" prop="courseTimeName" min-width="200" show-overflow-tooltip></el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template v-slot="{row}">
<el-button size="mini" type="primary" @click="openManage(row)">管理</el-button>
</template>
</el-table-column>
</el-table>
<!--#include('/layouts/pagination.html'){}#-->
</el-card>
<course-manage-form ref="manageFormRef" @refresh="pageData"></course-manage-form>
<slot></slot>
</div>
`,
mixins: [initTableMixins],
dicts: ['TRAIN_SIGNUP_TYPE'],
components: {'course-manage-form': courseManageForm},
data() {
return {
activity: {},
pageDataUrl: '/platform/trainSignUp/manage/courseManagePageData',
pageForm: {activityId: '', courseName: ''},
}
},
computed: {
canEditBasic() {
return this.$auth.hasRoleOr(['SYSADMIN', 'SCHOOL_UNION_ADMIN', 'SCHOOL_UNION_ACTIVITY_ADMIN'])
},
trainType() {
const type = (this.dict.type.TRAIN_SIGNUP_TYPE || []).find(item => item.code === this.activity.trainType)
return type ? type.name : '子活动'
},
},
methods: {
// row 为主活动列表记录;仅传主活动ID和名称查询条件,负责人范围由服务端按登录人确定。
initData(row) {
this.$set(this, 'activity', row)
this.$set(this.pageForm, 'activityId', row.id)
this.$set(this.pageForm, 'courseName', '')
this.doSearch()
},
// 详情另行加载并校验权限,不能将列表数据直接作为可写表单提交。
openManage(row) {
this.$refs.manageFormRef.initData(this.activity.id, row.id, this.trainType)
},
},
}
@@ -0,0 +1,286 @@
// courseTime、responsibleForm 已由首页先 include 的 basicForm.js 引入,沿用原组件,避免重复声明。
const courseManageForm = {
template: /*language=HTML*/ `
<div>
<el-drawer :title="'子活动管理 — ' + (formData.courseName || '')" :visible.sync="drawerVisible"
size="55%" custom-class="train-course-manage-drawer" append-to-body
:wrapper-closable="false" :before-close="beforeClose">
<div class="train-course-manage-body" v-loading="detailLoading">
<el-alert :title="canEditBasic ? '当前为管理员模式,可修改活动信息和签到设置' : '当前为负责人模式,可修改签到设置;活动关键信息只读'"
type="info" :closable="false" show-icon></el-alert>
<div class="left-span-label mt20">基本信息
<el-tag v-if="!canEditBasic" size="mini" type="info">只读</el-tag>
</div>
<el-form v-if="canEditBasic" :model="formData" :disabled="formLoading || detailLoading"
label-width="110px" ref="basicFormRef">
<el-form-item :label="trainType + '名称'" prop="courseName" :rules="{required: true, message: '请输入名称', trigger: 'blur'}">
<el-input :value="formData.courseName" maxlength="50" @input="(value) => { this.$set(formData, 'courseName', value) }"></el-input>
</el-form-item>
<el-form-item label="类型" prop="courseType" :rules="{required: true, message: '请选择类型', trigger: 'change'}">
<el-select :value="formData.courseType" @change="(value) => { this.$set(formData, 'courseType', value) }" style="width: 100%">
<el-option v-for="item in courseTypes" :key="item.id" :label="item.typeName" :value="item.id"></el-option>
</el-select>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12"><el-form-item label="人数">
<el-input-number :value="formData.coursePeopleNumber" :min="0" :max="9999" :precision="0" :controls="false" style="width: 100%"
@change="(value) => { this.$set(formData, 'coursePeopleNumber', value) }"></el-input-number>
</el-form-item></el-col>
<el-col :span="12"><el-form-item label="预留名额">
<el-input-number :value="formData.courseReservedNumber" :min="0" :max="1000" :precision="0" :controls="false" style="width: 100%"
@change="(value) => { this.$set(formData, 'courseReservedNumber', value) }"></el-input-number>
</el-form-item></el-col>
</el-row>
<el-form-item label="校区" prop="campus" :rules="{required: true, message: '请选择校区', trigger: 'change'}">
<el-select :value="formData.campus" @change="(value) => { this.$set(formData, 'campus', value) }" style="width: 100%">
<el-option v-for="item in dict.type.USER_CAMPUS" :key="item.code" :label="item.name" :value="item.name"></el-option>
</el-select>
</el-form-item>
<el-form-item label="地点" prop="courseLocation" :rules="{required: true, message: '请输入地点', trigger: 'blur'}">
<el-input :value="formData.courseLocation" maxlength="50" @input="(value) => { this.$set(formData, 'courseLocation', value) }"></el-input>
</el-form-item>
<el-form-item label="负责人"><el-button type="text" @click="openResponsible">{{ responsibleNames || '设置负责人' }}</el-button></el-form-item>
<el-form-item label="分类标识"><el-input :value="formData.assort" maxlength="30" @input="(value) => { this.$set(formData, 'assort', value) }"></el-input></el-form-item>
<el-form-item label="活动时间"><el-button type="text" @click="openTimes">设置活动时间</el-button></el-form-item>
</el-form>
<el-descriptions v-else :column="2" border class="table_fixed">
<el-descriptions-item :label="trainType + '名称'">{{ formData.courseName }}</el-descriptions-item>
<el-descriptions-item label="类型">{{ formData.courseTypeName }}</el-descriptions-item>
<el-descriptions-item label="人数">{{ formData.coursePeopleNumber }}</el-descriptions-item>
<el-descriptions-item label="预留名额">{{ formData.courseReservedNumber }}</el-descriptions-item>
<el-descriptions-item label="校区">{{ formData.campus }}</el-descriptions-item>
<el-descriptions-item label="地点">{{ formData.courseLocation }}</el-descriptions-item>
<el-descriptions-item label="负责人">{{ responsibleNames || formData.courseInstructor || '未设置' }}</el-descriptions-item>
<el-descriptions-item label="分类标识">{{ formData.assort || '—' }}</el-descriptions-item>
<el-descriptions-item label="活动时间" :span="2"><el-button type="text" @click="openTimes">查看时段({{ (formData.courseTimeList || []).length }})</el-button></el-descriptions-item>
</el-descriptions>
<el-divider></el-divider>
<div class="left-span-label">签到设置</div>
<el-form :model="formData" label-width="110px" :disabled="formLoading || detailLoading">
<el-form-item label="是否签到">
<el-radio-group :value="formData.isMobileSign" size="small" @input="(value) => { this.$set(formData, 'isMobileSign', value) }">
<el-radio border :label="true">签到</el-radio><el-radio border :label="false">不签到</el-radio>
</el-radio-group>
</el-form-item>
<template v-if="formData.isMobileSign">
<el-form-item label="签到方式">
<el-radio-group :value="formData.signType" size="small" @input="(value) => { this.$set(formData, 'signType', value) }">
<el-radio border :label="1">扫描二维码</el-radio><el-radio border :label="2">被扫</el-radio><el-radio border :label="3">GPS定位签到</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="usesSignLocation" label="签到时间误差">
<el-input :value="formData.signUnit" type="number" min="0" step="1" placeholder="请输入签到时间误差"
@input="(value) => { this.$set(formData, 'signUnit', value) }"><template slot="append">分钟</template></el-input>
</el-form-item>
<el-form-item v-if="usesSignLocation" label="签到半径">
<el-input :value="formData.radius" type="number" min="1" step="1" placeholder="请输入签到半径"
@input="(value) => { this.$set(formData, 'radius', value) }"><template slot="append">米</template></el-input>
</el-form-item>
<el-form-item v-if="usesSignLocation" label="地点坐标">
<el-button type="primary" size="small" @click="openMap">设置签到点</el-button>
<span :class="hasPosition ? 'text-success' : 'text-danger'" style="margin-left: 12px">{{ hasPosition ? '已设置' : '未设置' }}</span>
</el-form-item>
</template>
</el-form>
</div>
<div class="train-course-manage-footer">
<el-button :disabled="formLoading" @click="closeDrawer">取消</el-button>
<el-button type="primary" :loading="formLoading" :disabled="detailLoading || !detailReady" @click="onSave">保存</el-button>
</div>
</el-drawer>
<el-dialog title="位置信息" :visible.sync="mapVisible" :close-on-click-modal="false" append-to-body
@opened="onMapOpened" @close="onMapClosed">
<div style="min-height: 550px">
<map-container v-if="mapVisible && mapOpened" :position="mapPosition" :radius="Number(formData.radius) || 100"
@update:position="(value) => { this.$set(this, 'mapPosition', value) }"></map-container>
</div>
<span slot="footer"><el-button @click="closeMap">取消</el-button><el-button type="primary" @click="confirmMap">确定</el-button></span>
</el-dialog>
<el-dialog title="活动时段" :visible.sync="timesVisible" append-to-body width="60%">
<el-table :data="formData.courseTimeList || []">
<el-table-column label="日期" prop="courseDate"></el-table-column>
<el-table-column label="开始时间" prop="courseStartTime"></el-table-column>
<el-table-column label="结束时间" prop="courseEndTime"></el-table-column>
</el-table>
<span slot="footer"><el-button @click="closeTimes">关闭</el-button></span>
</el-dialog>
<course-time v-if="canEditBasic" ref="courseTimeRef"></course-time>
<responsible-form v-if="canEditBasic" ref="responsibleRef"></responsible-form>
<slot></slot>
</div>
`,
dicts: ['USER_CAMPUS'],
components: {
'course-time': courseTime,
'responsible-form': responsibleForm,
'map-container': httpVueLoader('/components/plugins/mapContainer/TMap.vue?v=1.0.8'),
},
data() {
return {
drawerVisible: false, detailLoading: false, detailReady: false, formLoading: false,
canEditBasic: false, formData: {}, courseTypes: [], activityTime: [], trainType: '子活动',
mapVisible: false, mapOpened: false, mapPosition: [], timesVisible: false, requestVersion: 0,
originalTimes: '',
}
},
computed: {
// 扫码和GPS均校验时间及位置;被扫使用固定时间规则,关闭签到时也不使用这些配置。
usesSignLocation() {
return this.formData.isMobileSign === true && [1, 3].includes(this.formData.signType)
},
responsibleNames() {
return (this.formData.responsibleList || []).map(item => item.username).filter(item => item).join('、')
},
hasPosition() {
return Array.isArray(this.formData.courseLocationCoordinates) && this.formData.courseLocationCoordinates.length === 2
},
},
methods: {
// activityId/courseId 分别为主、子活动ID;data.course 为详情,canEditBasic 为服务端确认的字段权限。
// 每次打开清空旧数据并使用请求序号,避免连续打开不同子活动后旧响应覆盖当前表单。
initData(activityId, courseId, trainType) {
const version = ++this.requestVersion
this.$set(this, 'formData', {})
this.$set(this, 'canEditBasic', false)
this.$set(this, 'detailReady', false)
this.$set(this, 'trainType', trainType)
this.$set(this, 'drawerVisible', true)
this.$set(this, 'detailLoading', true)
return this.$axios.post('/platform/trainSignUp/manage/courseManageDetail', {activityId, courseId})
.then((res) => {
if (version !== this.requestVersion) return
if (res.code !== 0) {
this.$message.warning(res.msg)
return
}
this.$set(this, 'formData', res.data.course)
this.$set(this, 'canEditBasic', res.data.canEditBasic === true)
this.$set(this, 'courseTypes', res.data.courseTypes || [])
this.$set(this, 'activityTime', res.data.activityTime || [])
this.$set(this, 'originalTimes', this.timeSnapshot())
this.$set(this, 'detailReady', true)
}).finally(() => {
if (version === this.requestVersion) this.$set(this, 'detailLoading', false)
})
},
// 仅实际更改时段或按时段限额时提交同步标志;普通签到保存不重建关联签到记录。
timeSnapshot() {
return JSON.stringify({times: this.formData.courseTimeList || [], limit: this.formData.courseIsLimitApply})
},
openResponsible() {
if (this.canEditBasic && !this.formLoading) this.$refs.responsibleRef.onOpen(this.formData)
},
openTimes() {
if (this.canEditBasic) {
if (!this.formLoading) this.$refs.courseTimeRef.onOpen({activityTime: this.activityTime, courseList: [this.formData]}, 0)
} else {
this.$set(this, 'timesVisible', true)
}
},
closeTimes() {
this.$set(this, 'timesVisible', false)
},
// 地图使用副本,取消定位不改变原配置;确认后同时更新腾讯地图坐标及签到使用的WGS-84坐标。
openMap() {
this.$set(this, 'mapOpened', false)
this.$set(this, 'mapPosition', clone(this.formData.courseLocationCoordinates || []))
this.$set(this, 'mapVisible', true)
},
closeMap() {
this.$set(this, 'mapVisible', false)
},
// 显示动画结束后才挂载地图,避免在隐藏容器中初始化;关闭时释放组件。
onMapOpened() {
this.$set(this, 'mapOpened', this.mapVisible)
},
onMapClosed() {
this.$set(this, 'mapOpened', false)
},
confirmMap() {
if (this.mapPosition.length !== 2) {
this.$message.warning('请在地图上选择签到点')
return
}
this.$set(this.formData, 'courseLocationCoordinates', clone(this.mapPosition))
this.$set(this.formData, 'transPosition', this.$coordinateUtil.gcj02ToWgs84(this.mapPosition[1], this.mapPosition[0]))
this.closeMap()
},
onSave() {
if (this.formLoading || this.detailLoading || !this.detailReady) return
if (this.canEditBasic) {
this.$refs.basicFormRef.validate((valid) => { if (valid) this.saveCourse() })
} else {
this.saveCourse()
}
},
// 普通负责人只提交签到白名单字段;管理员额外提交基本信息和实际修改后的负责人、时段。
// 返回 Result,code=0 才关闭抽屉并刷新当前页;finally 在网络或业务失败时均释放加载状态。
saveCourse() {
if (this.formLoading) return
if (this.formData.isMobileSign && ![1, 2, 3].includes(this.formData.signType)) {
this.$message.warning('请选择签到方式')
return
}
const params = {
id: this.formData.id, activityId: this.formData.activityId,
isMobileSign: this.formData.isMobileSign,
}
if (this.formData.isMobileSign) {
params.signType = this.formData.signType
}
// 隐藏字段保留在表单中供切换回来回显,但不校验、不提交,服务端同样保留数据库原值。
if (this.usesSignLocation) {
const signUnit = this.formData.signUnit === '' || this.formData.signUnit == null ? 0 : Number(this.formData.signUnit)
const radius = this.formData.radius === '' || this.formData.radius == null ? 100 : Number(this.formData.radius)
if (!Number.isInteger(signUnit) || signUnit < 0 || signUnit > 2147483647 || !Number.isInteger(radius) || radius <= 0 || radius > 2147483647) {
this.$message.warning('签到时间误差应为非负整数,签到半径应为正整数')
return
}
if (!this.hasPosition) {
this.$message.warning('请设置签到点')
return
}
params.signUnit = signUnit
params.radius = radius
params.courseLocationCoordinates = JSON.stringify(this.formData.courseLocationCoordinates)
params.transPosition = JSON.stringify(this.$coordinateUtil.gcj02ToWgs84(
this.formData.courseLocationCoordinates[1], this.formData.courseLocationCoordinates[0]))
}
if (this.canEditBasic) {
const fields = ['courseName', 'courseType', 'coursePeopleNumber', 'courseReservedNumber', 'courseLocation', 'campus', 'assort']
fields.forEach(field => { params[field] = this.formData[field] })
if (Array.isArray(this.formData.responsibleList)) params.responsibleList = JSON.stringify(this.formData.responsibleList)
params.updateCourseTimes = this.originalTimes !== this.timeSnapshot()
if (params.updateCourseTimes) {
params.courseTimeList = JSON.stringify(this.formData.courseTimeList)
params.courseIsLimitApply = this.formData.courseIsLimitApply
}
}
this.$set(this, 'formLoading', true)
return this.$axios.post('/platform/trainSignUp/manage/saveManagedCourse', params).then((res) => {
if (res.code === 0) {
this.$message.success('保存成功')
this.$set(this, 'drawerVisible', false)
this.$emit('refresh')
} else {
this.$message.warning(res.msg)
}
}).finally(() => {
this.$set(this, 'formLoading', false)
})
},
// 提交期间禁止退出;取消关闭仅丢弃本地副本,并使仍在途中的详情响应失效。
beforeClose(done) {
if (this.formLoading) return
this.requestVersion += 1
this.$set(this, 'detailLoading', false)
done()
},
closeDrawer() {
this.beforeClose(() => { this.$set(this, 'drawerVisible', false) })
},
},
beforeDestroy() {
this.requestVersion += 1
},
}
@@ -177,10 +177,14 @@ const customForm = {
</div>
</el-drawer>
<el-dialog :close-on-click-modal="false" :visible.sync="mapDialog" title="位置信息" :append-to-body="true">
<map-container v-if="mapDialog"
:radius="formData.courseList[moreInfoIndex].radius ? Number(formData.courseList[moreInfoIndex].radius) : 100"
:position.sync="formData.courseList[mapIndex].courseLocationCoordinates"></map-container>
<el-dialog :close-on-click-modal="false" :visible.sync="mapDialog" title="位置信息" :append-to-body="true"
@opened="onMapOpened" @close="onMapClosed">
<div style="min-height: 550px">
<map-container v-if="mapDialog && mapOpened"
:radius="formData.courseList[moreInfoIndex].radius ? Number(formData.courseList[moreInfoIndex].radius) : 100"
:position="formData.courseList[mapIndex].courseLocationCoordinates"
@update:position="(value) => { this.$set(formData.courseList[mapIndex], 'courseLocationCoordinates', value) }"></map-container>
</div>
<span slot="footer" class="dialog-footer">
<el-button type="primary" @click="onMap">确 定</el-button>
</span>
@@ -199,18 +203,27 @@ const customForm = {
moreInfoIndex: 0,
mapDialog: false,
mapOpened: false,
mapIndex: 0,
}
},
components: {
"union-form": unionForm,
"map-container": httpVueLoader("/components/plugins/mapContainer/TMap.vue?v=1.0.6")
"map-container": httpVueLoader("/components/plugins/mapContainer/TMap.vue?v=1.0.8")
},
methods: {
openMap(index) {
this.mapOpened = false
this.mapIndex = index
this.mapDialog = true
},
// 等弹窗动画结束、容器可见后挂载地图;关闭时卸载,重复打开创建新的实例。
onMapOpened() {
this.mapOpened = this.mapDialog
},
onMapClosed() {
this.mapOpened = false
},
openSetUpUnionLimit(index) {
this.$refs.unionFormRef.onOpen(this.formData, index)
},
@@ -45,6 +45,33 @@ layout("/layouts/platform.html"){
#app .train-sign-up-list-card .el-pagination-container {
flex: 0 0 auto;
}
/* 子活动抽屉沿用平台表单组件,仅内容区滚动,底部操作区始终可见。 */
.train-course-manage-drawer {
min-width: 720px;
max-width: 100vw;
}
.train-course-manage-drawer .el-drawer__body {
display: flex;
flex-direction: column;
min-height: 0;
padding: 0;
overflow: hidden;
}
.train-course-manage-body {
flex: 1;
min-height: 0;
padding: 20px;
overflow-y: auto;
}
.train-course-manage-footer {
flex-shrink: 0;
padding: 16px 20px;
border-top: 1px solid #ebeef5;
text-align: right;
}
@media (max-width: 720px) {
.train-course-manage-drawer { min-width: 100vw; }
}
</style>
<div id="app" v-cloak>
@@ -102,6 +129,7 @@ layout("/layouts/platform.html"){
>
<template v-slot="{ row }" v-if="column.prop === 'isDisabled'">
<el-switch
:disabled="!canManageActivity"
:active-value="false"
:inactive-value="true"
@change="(val) => { activityStatusChange(row.notice, val,row.id) }"
@@ -130,14 +158,16 @@ layout("/layouts/platform.html"){
<el-dropdown-item @click.native="openActivityCode(row.id)">活动二维码</el-dropdown-item>
<el-dropdown-item @click.native="makeCode(row)">签到二维码</el-dropdown-item>
<el-dropdown-item @click.native="onView(row)">查看</el-dropdown-item>
<el-dropdown-item @click.native="openEdit(row)">编辑</el-dropdown-item>
<!-- 校工会管理角色显示编辑;其余人员须为当前活动负责人,两个入口互斥。 -->
<el-dropdown-item v-if="!canManageActivity && row.canManageCourse === true" @click.native="openCourseManage(row)">子活动管理</el-dropdown-item>
<el-dropdown-item v-if="canManageActivity" @click.native="openEdit(row)">编辑</el-dropdown-item>
<el-dropdown-item
v-if="canManageTemplate && row.templateStatus !== 1"
@click.native="setTemplate(row)">设为模板</el-dropdown-item>
<el-dropdown-item
v-if="canManageTemplate && row.templateStatus === 1"
@click.native="cancelTemplate(row)">取消模板</el-dropdown-item>
<el-dropdown-item :disabled="row.templateStatus === 1" @click.native="onDelete(row)">删除</el-dropdown-item>
<el-dropdown-item v-if="canManageActivity" :disabled="row.templateStatus === 1" @click.native="onDelete(row)">删除</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</template>
@@ -156,6 +186,10 @@ layout("/layouts/platform.html"){
<info ref="infoRef"></info>
</template>
<template #public>
<course-manage ref="courseManageRef"></course-manage>
</template>
<make-qrcode ref="codeRef"></make-qrcode>
<el-dialog
@@ -177,6 +211,7 @@ layout("/layouts/platform.html"){
<script nonce="${cspNonce!}">
<!--#include('info.js'){}#-->
<!--#include('basicForm.js'){}#-->
<!--#include('courseManage.js'){}#-->
<!--#include('makeQrcode.js'){}#-->
new Vue({
el: "#app",
@@ -187,6 +222,7 @@ layout("/layouts/platform.html"){
"info": info,
"basic-form": basicForm,
"make-qrcode": makeQrcode,
"course-manage": courseManage,
},
data() {
return {
@@ -208,6 +244,10 @@ layout("/layouts/platform.html"){
}
},
computed: {
// 与后端关键内容编辑角色保持一致,负责人通过子活动管理维护签到。
canManageActivity() {
return this.$auth.hasRoleOr(["SYSADMIN", "SCHOOL_UNION_ADMIN", "SCHOOL_UNION_ACTIVITY_ADMIN"])
},
// 页签沿用“设为模板”按钮的角色范围,不新增权限标识。
canManageTemplate() {
return this.$auth.hasRoleOr(["SYSADMIN", "SCHOOL_UNION_ADMIN"])
@@ -218,7 +258,6 @@ layout("/layouts/platform.html"){
const address = this.pageDataUrl ? this.pageDataUrl : loc() + "/pageData"
this.tableLoading = true
return this.$axios.post(address, data ? data : this.pageForm).then((res) => {
this.tableLoading = false
if (res.code === 0) {
this.tableData = res.data.list
this.pageForm.totalCount = res.data.totalCount
@@ -227,6 +266,8 @@ layout("/layouts/platform.html"){
})
}
return res
}).finally(() => {
this.tableLoading = false
})
},
// 切换页签时按活动表中的模板状态查询,并重置到第一页。
@@ -281,10 +322,20 @@ layout("/layouts/platform.html"){
})
},
openEdit(row) {
if (!this.canManageActivity) {
this.$message.warning('活动关键信息仅管理员可修改,请使用子活动管理')
return
}
this.$refs.guava.edit(() => {
this.$refs.formRef.initData(row)
})
},
// 使用 guava 的现有公共页面插槽,返回后保留主列表的筛选条件和页码。
openCourseManage(row) {
this.$refs.guava.public(() => {
this.$refs.courseManageRef.initData(row)
})
},
openUrlEdit() {
if (this.urlEditInitialized) {
return
@@ -0,0 +1,133 @@
const responsibleForm = {
template: /*language=HTML*/ `
<div>
<el-dialog title="设置负责人" :visible.sync="dialogVisible" width="60%"
:close-on-click-modal="false" append-to-body>
<el-form label-width="90px">
<el-form-item label="负责人">
<el-select :value="selectedIds" multiple filterable remote reserve-keyword
:remote-method="queryUsers" :loading="tableLoading" @change="responsibleChange"
placeholder="请输入姓名或工号,多选负责人" style="width: 100%">
<el-option v-for="item in userOptions" :key="item.id" :value="item.id"
:label="item.username + (item.loginname ? '(' + item.loginname + ')' : '')"></el-option>
</el-select>
</el-form-item>
</el-form>
<el-table :data="responsibleList">
<el-table-column label="序号" type="index" width="70"></el-table-column>
<el-table-column label="负责人" prop="username" show-overflow-tooltip></el-table-column>
<el-table-column label="工号" prop="loginname" show-overflow-tooltip></el-table-column>
<el-table-column label="联系方式">
<template v-slot="{row}">
<el-input :value="row.mobile" maxlength="20" placeholder="请输入手机号或办公电话"
@input="(value) => { this.$set(row, 'mobile', value) }"></el-input>
</template>
</el-table-column>
</el-table>
<div class="mt10 text-warning">确定后回填当前表单,保存或提交活动后生效。</div>
<span slot="footer" class="dialog-footer">
<el-button @click="closeDialog">取消</el-button>
<el-button type="primary" @click="onConfirm">确定</el-button>
</span>
</el-dialog>
</div>
`,
data() {
return {
dialogVisible: false,
tableLoading: false,
course: null,
selectedIds: [],
responsibleList: [],
userOptions: [],
queryVersion: 0,
}
},
methods: {
// 打开当前子活动的负责人副本,关闭或取消弹框不会更改业务表单。
onOpen(course) {
this.queryVersion += 1
this.$set(this, 'course', course)
const list = clone(course.responsibleList || [])
this.$set(this, 'responsibleList', list)
this.$set(this, 'selectedIds', list.map(item => item.userId))
this.$set(this, 'userOptions', list.map(item => ({
id: item.userId, username: item.username, loginname: item.loginname, mobile: item.mobile
})))
this.$set(this, 'tableLoading', false)
this.$set(this, 'dialogVisible', true)
},
// query 为姓名或工号;返回 Result.data 用户数组,使用 id、username、loginname、mobile。
// 合并已选项以保持多人回显,并忽略过期请求,避免快速搜索覆盖最新结果。
queryUsers(query) {
const version = ++this.queryVersion
if (!query || !query.trim()) {
this.$set(this, 'tableLoading', false)
return
}
this.$set(this, 'tableLoading', true)
return this.$axios.post('/open/common/userOptions', {query: query.trim()})
.then((res) => {
if (version !== this.queryVersion || !this.dialogVisible) {
return
}
if (res.code !== 0) {
this.$message.warning(res.msg)
return
}
const selected = this.responsibleList.map(item => ({
id: item.userId, username: item.username, loginname: item.loginname, mobile: item.mobile
}))
const options = new Map(selected.map(item => [item.id, item]))
const results = res.data || []
results.forEach(item => options.set(item.id, item))
this.$set(this, 'userOptions', Array.from(options.values()))
})
.finally(() => {
if (version === this.queryVersion) {
this.$set(this, 'tableLoading', false)
}
})
},
// 多选结果一人一行,已选用户保留手工修改的联系方式,新选用户带出系统联系电话。
responsibleChange(ids) {
const oldRows = new Map(this.responsibleList.map(item => [item.userId, item]))
const options = new Map(this.userOptions.map(item => [item.id, item]))
const rows = ids.map((id) => {
if (oldRows.has(id)) {
return oldRows.get(id)
}
const user = options.get(id)
return {userId: id, username: user.username, loginname: user.loginname, mobile: user.mobile || ''}
})
this.$set(this, 'selectedIds', ids)
this.$set(this, 'responsibleList', rows)
},
// 联系方式按每个人校验;使用新数组回填,弹框副本不再与业务表单共用对象。
onConfirm() {
const missing = this.responsibleList.find(item => !item.mobile || !item.mobile.trim())
if (missing) {
this.$message.warning('请填写' + missing.username + '的联系方式')
return
}
if (this.responsibleList.some(item => item.mobile.trim().length > 20)) {
this.$message.warning('负责人联系方式不能超过20个字符')
return
}
const list = this.responsibleList.map((item, index) => ({
userId: item.userId, username: item.username, loginname: item.loginname,
mobile: item.mobile.trim(), orderNum: index + 1
}))
this.$set(this.course, 'responsibleList', list)
// 旧页面保留第一位联系人展示;完整多人数据通过 responsibleList 保存。
this.$set(this.course, 'courseInstructor', list.length ? list[0].username : '')
this.$set(this.course, 'courseInstructorMobile', list.length ? list[0].mobile : '')
this.closeDialog()
},
closeDialog() {
this.queryVersion += 1
this.$set(this, 'dialogVisible', false)
this.$set(this, 'tableLoading', false)
},
},
}
@@ -8,6 +8,12 @@ const REGISTER_INFO_COMPONENT = {
<el-link type="primary" @click="openChart">点击查看流程图</el-link>
</div>
<el-descriptions :column="3" border class="table_fixed">
<el-descriptions-item label="社团缩略图" :span="3">
<el-image v-if="viewData.thumbnail" :src="viewData.thumbnail" :preview-src-list="[viewData.thumbnail]" fit="contain" style="width: 180px; height: 120px">
<span slot="error">暂无图片</span>
</el-image>
<span v-else>暂无图片</span>
</el-descriptions-item>
<el-descriptions-item label="社团名称">
{{viewData.clubName}}
</el-descriptions-item>
@@ -2,9 +2,19 @@
layout("/layouts/platform.html"){
#-->
<div id="app" v-cloak>
<style>
.club-join-apply-page .custom-card > .card-header { padding: 12px 24px; }
.club-join-apply-page .club-apply-back-bar { display: flex; align-items: center; }
.club-join-apply-page .club-apply-back-bar .el-button { padding: 0; font-size: 16px; }
.club-join-apply-page .club-apply-title { margin-bottom: 20px; }
</style>
<div id="app" v-cloak class="club-join-apply-page">
<custom-card>
<snaker-start slot="header" label="文体社团会员申请" define_key="XHRH"></snaker-start>
<div slot="header" class="club-apply-back-bar">
<el-button type="text" icon="el-icon-back" @click="backToClubList">返回</el-button>
</div>
<snaker-start class="club-apply-title" label="文体社团会员申请" define_key="XHRH"></snaker-start>
<el-form :model="formData" ref="formRef" :rules="formRules" label-width="0" label-suffix=":" class="flow-task-form">
<el-descriptions border class="descriptions-form">
<el-descriptions-item label="社团" :span="3">
@@ -156,6 +166,9 @@ layout("/layouts/platform.html"){
}
},
methods: {
backToClubList() {
commonUtil.pjaxPush('/platform/club/join/list');
},
onSave() {
this.$confirm("您确定保存吗?", "提示", {
confirmButtonText: "确定",
@@ -224,6 +237,16 @@ layout("/layouts/platform.html"){
this.$axios.post("/platform/club/common/listClub").then((res) => {
if (res.code === 0) {
this.clubOptions = res.data
// 从卡片进入新申请时预选社团,编辑已有申请仍使用原记录。
const clubId = GetQueryString("clubId")
if (!this.bizId && clubId) {
const club = this.clubOptions.find(item => item.id === clubId)
if (club) {
this.$set(this.formData, "clubId", club.id)
this.$set(this.formData, "clubName", club.clubName)
this.checkApplyClub(club.id)
}
}
}
})
},
@@ -11,9 +11,36 @@ layout("/layouts/platform.html"){
.el-tabs__header {
margin: 0;
}
.club-directory .club-card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 20px; }
.club-directory .club-card { display: flex; flex-direction: column; min-width: 0; overflow: hidden; border: 1px solid #e4edf8; border-radius: 10px; background: #fff; box-shadow: 0 7px 20px rgba(55, 85, 126, .08); }
.club-directory .club-card-main { flex: 1; min-width: 0; }
.club-directory .club-card-cover { width: 100%; height: 180px; overflow: hidden; background: #edf4fc; }
.club-directory .club-card-cover .el-image { width: 100%; height: 100%; }
.club-directory .club-image-empty { display: flex; flex-direction: column; gap: 8px; height: 100%; align-items: center; justify-content: center; color: #8da1ba; font-size: 13px; background: #edf4fc; }
.club-directory .club-image-empty i { font-size: 34px; }
.club-directory .club-card-info { min-width: 0; padding: 14px 16px; }
.club-directory .club-card-name { margin: 0; color: #303846; font-size: 16px; font-weight: 400; line-height: 24px; overflow-wrap: anywhere; }
.club-directory .club-card-meta { margin: 5px 0 0; color: #98a2b2; font-size: 13px; line-height: 20px; overflow-wrap: anywhere; }
.club-directory .club-card-tags { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 10px; padding-top: 10px; border-top: 1px solid #edf1f6; }
.club-directory .club-card-tag { display: inline-flex; align-items: center; padding: 3px 8px; border-radius: 4px; background: #edf5ff; color: #397de0; font-size: 12px; line-height: 18px; }
.club-directory .club-card-tag-label { margin-right: 4px; color: #8aa7cf; }
.club-directory .club-card-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 0 14px; margin: 0 16px; border-top: 1px solid #edf1f6; }
.club-directory .club-card-actions .el-button { height: 32px; margin: 0; padding: 0 14px; border: 0; border-radius: 16px; font-size: 13px; }
.club-directory .club-card-actions .el-button--primary { width: 108px; color: #fff; background: #1989fa; box-shadow: 0 5px 12px rgba(25, 137, 250, .18); }
.club-directory .club-card-actions .el-button--primary.is-plain { width: auto; color: #397de0; background: #edf5ff; box-shadow: none; }
.club-directory .club-card-actions .el-button--danger { width: 108px; color: #fff; background: #ee0a24; box-shadow: 0 5px 12px rgba(238, 10, 36, .18); }
.club-directory .club-card-sort { display: flex; justify-content: flex-end; margin-bottom: 16px; }
.club-directory .club-card-sort .el-select { width: 168px; }
.club-directory .club-detail-cover { width: 160px; max-width: 100%; height: 110px; overflow: hidden; border-radius: 8px; background: #f8fafc; }
.club-directory .club-detail-cover .el-image { display: block; width: 100%; height: 100%; border-radius: 8px; }
@media (max-width: 640px) {
.club-directory .club-card-grid { grid-template-columns: minmax(0, 1fr); }
.club-directory .club-card-cover { height: 150px; }
.club-directory .club-detail-cover { width: 120px; height: 90px; }
}
</style>
<div id="app" v-cloak>
<div id="app" v-cloak class="club-directory">
<guava ref="guava">
<template>
<el-card shadow="never">
@@ -38,29 +65,56 @@ layout("/layouts/platform.html"){
<el-radio-button :label="true">{{'已加入社团' + hasJoinCount + '(退会)'}}</el-radio-button>
</el-radio-group>
</table-tool>
<el-table :data="tableData" row-key="id" style="width: 100%">
<el-table-column :index="indexMethod" label="序号" type="index" width="80px"></el-table-column>
<el-table-column label="社团编码" sortable prop="clubCode"></el-table-column>
<el-table-column label="社团名称" prop="clubName" show-overflow-tooltip width="200px"></el-table-column>
<el-table-column label="成立时间" prop="foundTime"></el-table-column>
<el-table-column label="会长" prop="clubLeader" show-overflow-tooltip></el-table-column>
<el-table-column label="秘书长" prop="clubSecretary" show-overflow-tooltip></el-table-column>
<el-table-column sortable label="当前人数" show-overflow-tooltip prop="currentPeopleNum"></el-table-column>
<el-table-column label="操作" width="250px">
<template slot-scope="scope">
<el-button size="mini" type="primary" @click="openDetail(scope.row)">查看</el-button>
<el-button size="mini" v-if="hasJoin === false" type="primary" @click="doSubmit(scope.row)">申请加入</el-button>
<el-button size="mini" v-else type="danger" @click="onExit(scope.row)">申请退出</el-button>
</template>
</el-table-column>
</el-table>
<div class="club-card-sort">
<el-select v-model="clubSort" size="small" aria-label="社团排序" @change="changeClubSort">
<el-option label="社团编码升序" value="clubCode:ascending"></el-option>
<el-option label="社团编码降序" value="clubCode:descending"></el-option>
<el-option label="当前人数升序" value="currentPeopleNum:ascending"></el-option>
<el-option label="当前人数降序" value="currentPeopleNum:descending"></el-option>
</el-select>
</div>
<div class="club-card-grid" v-loading="tableLoading">
<article v-for="row in tableData" :key="row.id" class="club-card">
<div class="club-card-main">
<div class="club-card-cover">
<el-image v-if="row.thumbnail" :src="row.thumbnail" :alt="row.clubName + '缩略图'" fit="cover" :preview-src-list="[row.thumbnail]">
<div slot="error" class="club-image-empty"><i class="el-icon-picture-outline"></i><span>暂无图片</span></div>
</el-image>
<div v-else class="club-image-empty"><i class="el-icon-picture-outline"></i><span>暂无图片</span></div>
</div>
<div class="club-card-info">
<h3 class="club-card-name">{{row.clubName}}</h3>
<p class="club-card-meta">成立时间:{{row.foundTime || '--'}}</p>
<div class="club-card-tags">
<span class="club-card-tag"><span class="club-card-tag-label">会长</span>{{row.clubLeader || '--'}}</span>
<span class="club-card-tag"><span class="club-card-tag-label">社团类型</span>{{formatClubType(row.clubType)}}</span>
<span class="club-card-tag"><span class="club-card-tag-label">会费标准</span>{{row.due == null || row.due === '' ? '--' : row.due + ' 元/年'}}</span>
</div>
</div>
</div>
<div class="club-card-actions">
<el-button size="mini" type="primary" plain icon="el-icon-view" @click="openDetail(row)">查看</el-button>
<el-button size="mini" v-if="hasJoin === false" type="primary" icon="el-icon-right" @click="doSubmit(row)">申请加入</el-button>
<el-button size="mini" v-else type="danger" icon="el-icon-switch-button" @click="onExit(row)">申请退出</el-button>
</div>
</article>
</div>
<el-empty v-if="!tableLoading && !tableData.length" description="暂无社团"></el-empty>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
</template>
<template #view>
<el-descriptions class="margin-top" title="" :column="2" border>
<el-descriptions-item span="2">
<el-descriptions-item label="社团图片">
<div class="club-detail-cover">
<el-image v-if="viewData.thumbnail" :src="viewData.thumbnail" :alt="viewData.clubName + '缩略图'" fit="contain" :preview-src-list="[viewData.thumbnail]">
<div slot="error" class="club-image-empty">暂无图片</div>
</el-image>
<div v-else class="club-image-empty">暂无图片</div>
</div>
</el-descriptions-item>
<el-descriptions-item>
<template slot="label">社团名称</template>
<div v-html="viewData.clubName"></div>
</el-descriptions-item>
@@ -103,6 +157,7 @@ layout("/layouts/platform.html"){
new Vue({
el: "#app",
store,
dicts: ['CLUB_REGISTER_TYPE'],
mixins: [initTableMixins],
components: {},
data() {
@@ -111,10 +166,21 @@ layout("/layouts/platform.html"){
hasJoin: false,
hasJoinCount: 0,
noJoinCount: 0,
clubSort: 'clubCode:ascending',
clickRow: {}
}
},
methods: {
formatClubType(value) {
const option = (this.dict.type.CLUB_REGISTER_TYPE || []).find((item) => String(item.code) === String(value));
return option ? option.name : '--';
},
changeClubSort(value) {
const [prop, order] = value.split(':')
this.pageForm.pageOrderName = prop
this.pageForm.pageOrderBy = order
this.doSearch()
},
onExit(row) {
this.$confirm("您确定要申请退出" + row.clubName + "吗?", "提示", {
confirmButtonText: "确定",
@@ -162,9 +228,14 @@ layout("/layouts/platform.html"){
},
pageData() {
this.pageForm.hasJoin = this.hasJoin
this.$axios.post("/platform/club/join/list/pageData", this.pageForm).then((res) => {
this.tableData = res.data.list
this.pageForm.totalCount = res.data.totalCount
this.tableLoading = true
return this.$axios.post("/platform/club/join/list/pageData", this.pageForm).then((res) => {
if (res.code === 0) {
this.tableData = res.data.list
this.pageForm.totalCount = res.data.totalCount
}
}).finally(() => {
this.tableLoading = false
})
}
},
@@ -58,6 +58,12 @@ layout("/layouts/platform.html"){
<el-tabs tab-position="top" v-model="activeName">
<el-tab-pane label="社团基础信息" name="1">
<el-descriptions style="margin-top: 20px;" class="margin-top" title="" :column="2" border>
<el-descriptions-item label="社团缩略图" :span="2">
<el-image v-if="viewData.thumbnail" :src="viewData.thumbnail" :preview-src-list="[viewData.thumbnail]" fit="contain" style="width: 180px; height: 120px">
<span slot="error">暂无图片</span>
</el-image>
<span v-else>暂无图片</span>
</el-descriptions-item>
<el-descriptions-item label="社团名称">{{viewData.clubName}}</el-descriptions-item>
<el-descriptions-item label="成立时间">{{viewData.foundTime}}</el-descriptions-item>
<el-descriptions-item label="社团编码">{{viewData.clubCode}}</el-descriptions-item>
@@ -79,6 +79,20 @@ const CLUB_FORM_TEMPLATE = {
</el-col>
</el-row>
<el-form-item label="社团缩略图" prop="thumbnail">
<file-upload
:value.sync="formData.thumbnail"
:upload_number="1"
:upload_size="2 * 1024 * 1024"
upload_mode="image"
upload_result_category="interval"
upload_result_type="url"
upload_dynamic_return_url_api="/platform/club/common/uploadThumbnail"
accept=".jpg,.jpeg,.png,.gif"
></file-upload>
<div style="color: #909399; font-size: 12px">支持 JPG、PNG、GIF 图片,大小不超过 2MB</div>
</el-form-item>
<el-form-item label="社团介绍及宗旨" prop="introduce">
<text-editor v-model="formData.introduce"></text-editor>
</el-form-item>
@@ -156,7 +170,8 @@ const CLUB_FORM_TEMPLATE = {
return {
formData: {
clubName: "",
clubType: ""
clubType: "",
thumbnail: ""
},
formRules: {
clubName: [{ required: true, message: "请填写社团名称", trigger: ["blur", "change"] }],
@@ -341,32 +341,42 @@ layout("/layouts/platform.html"){
this.noSelectOptionId = null
this.selectByAdminDialogVisible = true
},
async yearChange() {
const resp = await this.$axios.post("/platform/healthCheckup/project/mange/getHealthCheckupProject", {year: this.pageForm.year})
if (resp.code === 0) {
this.projectOptions = resp.data
if (resp.data && resp.data.length > 0) {
this.$set(this.pageForm, "projectId", resp.data[0].id)
} else {
this.$set(this.pageForm, "projectId", null)
yearChange() {
// 年度切换时先清理旧项目,避免无项目年度继续显示上一年度的数据。
this.$set(this.pageForm, "projectId", null)
this.$set(this, "projectOptions", [])
this.getHealthCheckupSubject()
return this.$axios.post("/platform/healthCheckup/project/mange/getHealthCheckupProject", {year: this.pageForm.year}).then((resp) => {
if (resp.code === 0) {
this.$set(this, "projectOptions", resp.data || [])
if (this.projectOptions.length > 0) {
this.$set(this.pageForm, "projectId", this.projectOptions[0].id)
}
return this.getHealthCheckupSubject()
}
await this.getHealthCheckupSubject()
} else {
this.notifyError(resp.msg)
}
})
},
async getHealthCheckupSubject() {
this.projectSubjectOptions = []
const resp = await this.$axios.post("/platform/healthCheckup/project/mange/findOne", {id: this.pageForm.projectId})
if (resp.code === 0) {
if (resp.data) {
this.projectInfo = resp.data
this.projectSubjectOptions = resp.data.healthCheckupProjectSubjects
}
} else {
this.notifyError(resp.msg)
getHealthCheckupSubject() {
this.$set(this, "projectInfo", {})
this.$set(this, "projectSubjectOptions", [])
this.$set(this, "tableData", [])
// 未选项目时只刷新空表结构,不请求项目详情或人数统计。
if (!this.pageForm.projectId) {
return this.pageData()
}
this.doSearch()
return this.$axios.post("/platform/healthCheckup/project/mange/findOne", {id: this.pageForm.projectId}).then((resp) => {
if (resp.code === 0) {
if (resp.data) {
this.$set(this, "projectInfo", resp.data)
this.$set(this, "projectSubjectOptions", resp.data.healthCheckupProjectSubjects || [])
} else {
this.$set(this.pageForm, "projectId", null)
}
return this.pageData()
}
this.notifyError(resp.msg)
})
},
async queryCampus() {
const resp = await this.$axios.post("/platform/healthCheckup/campus/queryCampus")
@@ -377,14 +387,8 @@ layout("/layouts/platform.html"){
}
},
async pageData() {
pageData() {
this.tableColumns = []
const loading = this.$loading({
lock: true,
text: '努力加载中。。。',
spinner: 'el-icon-loading',
background: 'rgba(0, 0, 0, 0.7)'
});
this.tableColumns.push(
{prop: 'unionName', label: '所属工会'},
@@ -400,14 +404,26 @@ layout("/layouts/platform.html"){
{prop: "unselectedTeacherSum", label: "未选人数"}
)
if (!this.pageForm.projectId) {
this.tableData = []
this.$set(this, "tableData", [])
return
}
const resp = await this.$axios.post(loc() + '/pageData', this.pageForm)
if (resp.code === 0) {
this.tableData = resp.data.tableList
}
loading.close()
this.tableLoading = true
const loading = this.$loading({
lock: true,
text: '努力加载中。。。',
spinner: 'el-icon-loading',
background: 'rgba(0, 0, 0, 0.7)'
})
return this.$axios.post(loc() + '/pageData', this.pageForm).then((resp) => {
if (resp.code === 0) {
this.$set(this, "tableData", resp.data.tableList)
}
}).finally(() => {
// 业务错误或网络异常也必须释放遮罩,保证页面可以重新查询。
this.tableLoading = false
loading.close()
})
},
pageNumberChange(val) {
this.pageFormUnion.pageNumber = val;
@@ -57,8 +57,10 @@ layout("/layouts/platform.html"){
min-width="50"
>
<template v-slot="{row}" v-if="column.prop=='surplusQuota'">
<span v-if="!row.totalQuota">{{row.totalQuota}}元</span>
<span v-else>{{(row.totalQuota-row.usedQuota).toFixed(2)}}</span>
<span>{{(Number(row.totalQuota || 0) - Number(row.usedQuota || 0)).toFixed(2)}}</span>
</template>
<template v-slot="{row}" v-else-if="column.prop=='usedQuota'">
<span>{{Number(row.usedQuota || 0).toFixed(2)}}</span>
</template>
</el-table-column>
@@ -7,6 +7,11 @@ layout("/layouts/platform.html"){
margin-top: 10px;
}
/* 社团表单改为两组字段后,左侧标签仍保持原三组字段表单的六分之一宽度。 */
.club-reimbursement-form .el-descriptions-item__label:first-child {
width: calc(100% / 6);
}
.invoice-toolbar {
display: flex;
align-items: center;
@@ -60,17 +65,21 @@ layout("/layouts/platform.html"){
日常活动申请</h3>
<h3 style="color: rgb(24, 103, 176);" v-else-if="formData.reimburseProject === 'UNION_REIMBURSE_PROJECT_4'">
专项活动申请</h3>
<h3 style="color: rgb(24, 103, 176);" v-else-if="formData.reimburseProject === 'UNION_REIMBURSE_PROJECT_5'">
社团活动申请</h3>
</template>
<el-form :model="formData" ref="formRef" :rules="formRules" label-width="0" label-suffix=":"
class="flow-task-form">
<el-tabs class="reimbursement-tabs" v-model="activeTabName">
<el-tab-pane label="申请基本信息" name="basic">
<el-descriptions :column="3" border>
<el-descriptions-item label="经费来源" span="3">
<!-- 来源或项目切换时重建表单项,避免动态布局复用旧字段的校验状态。 -->
<el-descriptions :column="formData.reimburseFundSource === 'UNION_REIMBURSE_FUND_SOURCE_3' ? 2 : 3" :class="{'club-reimbursement-form': formData.reimburseFundSource === 'UNION_REIMBURSE_FUND_SOURCE_3'}" border :key="formData.reimburseFundSource + ':' + formData.reimburseProject">
<el-descriptions-item label="经费来源" :span="formData.reimburseFundSource === 'UNION_REIMBURSE_FUND_SOURCE_3' ? 2 : 3">
<el-form-item label="经费来源" prop="reimburseFundSource">
<!-- 在表单重建前同步切换报销项目,避免延迟的 change 事件丢失。 -->
<el-radio-group v-model="formData.reimburseFundSource" size="medium"
@change="selectFundSource">
@input="selectFundSource">
<el-radio v-if="$auth.hasPermission('unionReimburse.xgh')"
label="UNION_REIMBURSE_FUND_SOURCE_1"
border>校工会经费
@@ -88,11 +97,11 @@ layout("/layouts/platform.html"){
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="报销项目" :span="3">
<el-descriptions-item label="报销项目" :span="formData.reimburseFundSource === 'UNION_REIMBURSE_FUND_SOURCE_3' ? 2 : 3">
<el-form-item label="报销项目" prop="reimburseProject">
<el-radio-group v-model="formData.reimburseProject" size="medium"
@change="handleReimburseProjectChange">
<el-radio :label="item.code" border v-for="item in reimburseProjectList">
<el-radio :label="item.code" border v-for="item in availableReimburseProjects" :key="item.code">
{{item.name}}
</el-radio>
</el-radio-group>
@@ -104,24 +113,6 @@ layout("/layouts/platform.html"){
<el-descriptions-item label="经办人">{{formData.userName}}({{formData.loginName}})
</el-descriptions-item>
<el-descriptions-item label="所属协会"
v-if="formData.reimburseFundSource === 'UNION_REIMBURSE_FUND_SOURCE_3'">
<el-form-item label="所属协会" prop="clubId">
<el-select clearable
placeholder="请选择协会"
style="width: 100%;"
v-model="formData.clubId"
@change="clubChange">
<el-option
:key="item.id"
:label="item.clubName"
:value="item.id"
v-for="item in clubOptions">
</el-option>
</el-select>
</el-form-item>
</el-descriptions-item>
<!-- <el-descriptions-item label="报销类别">-->
<!-- <el-form-item label="报销类别" prop="reimburseType">-->
<!-- <el-radio-group v-model="formData.reimburseType" size="medium">-->
@@ -149,7 +140,7 @@ layout("/layouts/platform.html"){
placeholder="请输入联系方式"></el-input>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="余额" v-if="formData.reimburseFundSource">
<el-descriptions-item label="余额" v-if="formData.reimburseFundSource && formData.reimburseFundSource !== 'UNION_REIMBURSE_FUND_SOURCE_3'">
<el-form-item label="余额">
<el-input :value="canViewFundBalance ? fundBalanceText : '******'" readonly></el-input>
</el-form-item>
@@ -273,7 +264,34 @@ layout("/layouts/platform.html"){
</el-date-picker>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="" :span="2"
<el-descriptions-item label="报销金额" v-if="isManualReimburseAmount">
<el-form-item label="报销金额" prop="money">
<el-input v-model="formData.money" placeholder="请输入报销金额,最多两位小数"></el-input>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="所属协会"
v-if="formData.reimburseFundSource === 'UNION_REIMBURSE_FUND_SOURCE_3'">
<el-form-item label="所属协会" prop="clubId">
<el-select clearable
placeholder="请选择协会"
style="width: 100%;"
v-model="formData.clubId"
@change="clubChange">
<el-option
:key="item.id"
:label="item.clubName"
:value="item.id"
v-for="item in clubOptions">
</el-option>
</el-select>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="余额" v-if="formData.reimburseFundSource === 'UNION_REIMBURSE_FUND_SOURCE_3'">
<el-form-item label="余额">
<el-input :value="canViewFundBalance ? fundBalanceText : '******'" readonly></el-input>
</el-form-item>
</el-descriptions-item>
<el-descriptions-item label="" :span="isManualReimburseAmount ? 1 : 2"
v-if="formData.reimburseProject !== 'UNION_REIMBURSE_PROJECT_1' && formData.reimburseFundSource !== 'UNION_REIMBURSE_FUND_SOURCE_3'">
</el-descriptions-item>
@@ -399,7 +417,7 @@ layout("/layouts/platform.html"){
</el-form-item>
</el-descriptions-item>
<el-descriptions-item :span="3">
<el-descriptions-item :span="formData.reimburseFundSource === 'UNION_REIMBURSE_FUND_SOURCE_3' ? 2 : 3">
<template slot="label">
附件
</template>
@@ -438,7 +456,7 @@ layout("/layouts/platform.html"){
<el-col :span="12">
<el-form-item label="报销金额" prop="money">
<div class="invoice-field-title">报销金额</div>
<el-input v-model="formData.money" placeholder="有发票按发票汇总,无发票按支付信息汇总" readonly></el-input>
<el-input v-model="formData.money" :placeholder="isManualReimburseAmount ? '请在申请基本信息填写报销金额' : '有发票按发票汇总,无发票按支付信息汇总'" readonly></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
@@ -587,7 +605,7 @@ layout("/layouts/platform.html"){
<div>
<div class="invoice-toolbar-title">发票明细列表</div>
<div class="invoice-summary-tip">
发票选填;无发票时按支付信息汇总报销金额。上传后须通过验真,每条明细对应一张发票图片或单页 PDF。
{{ isManualReimburseAmount ? '发票选填;有发票时报销金额不得超过发票总额,无发票仍可提交。' : '发票选填;无发票时按支付信息汇总报销金额。' }}上传后须通过验真,每条明细对应一张发票图片或单页 PDF。
</div>
</div>
<div>
@@ -821,7 +839,13 @@ layout("/layouts/platform.html"){
// invoiceNumber: [{required: true, message: "请填写发票张数", trigger: ["change", "blur"]}],
// invoice: [{required: true, message: "请填写发票号码", trigger: ["change", "blur"]}],
activityName: [{required: true, message: "请填写活动名称", trigger: ["change", "blur"]}],
money: [{required: true, message: "请填写报销金额", trigger: ["change", "blur"]}],
money: [{required: true, message: "请填写报销金额", trigger: ["change", "blur"]}, {
validator: (rule, value, callback) => {
const message = this.getReimburseAmountMessage()
callback(message ? new Error(message) : undefined)
},
trigger: ["change", "blur"]
}],
condolenceRelationship: [{required: true, message: "与被慰问人关系", trigger: ["change", "blur"]}],
marryTime: [{required: true, message: "结婚时间", trigger: ["change", "blur"]}],
fertilityTime: [{required: true, message: "生育时间", trigger: ["change", "blur"]}],
@@ -835,8 +859,8 @@ layout("/layouts/platform.html"){
],
files: [{
validator: (rule, value, callback) => {
// 慰问项目不展示附件上传,提交时无需校验附件。
if (this.formData.reimburseProject === "UNION_REIMBURSE_PROJECT_1") {
// 慰问项目无需校验附件,文体、日常和社团活动附件选填。
if (["UNION_REIMBURSE_PROJECT_1", "UNION_REIMBURSE_PROJECT_2", "UNION_REIMBURSE_PROJECT_3", "UNION_REIMBURSE_PROJECT_5"].includes(this.formData.reimburseProject)) {
callback()
return
}
@@ -910,6 +934,16 @@ layout("/layouts/platform.html"){
}
},
computed: {
isManualReimburseAmount() {
return ["UNION_REIMBURSE_PROJECT_2", "UNION_REIMBURSE_PROJECT_3", "UNION_REIMBURSE_PROJECT_4"]
.includes(this.formData.reimburseProject)
},
// 社团经费固定对应社团活动,复用文体活动的基本信息和发票表单。
availableReimburseProjects() {
const clubFund = this.formData.reimburseFundSource === "UNION_REIMBURSE_FUND_SOURCE_3"
return this.reimburseProjectList.filter(item =>
clubFund ? item.code === "UNION_REIMBURSE_PROJECT_5" : item.code !== "UNION_REIMBURSE_PROJECT_5")
},
// 按选中类型的业务编码控制字段,类型列表异步加载及旧申请回显后自动重新计算。
condolenceTypeCode() {
if (this.formData.reimburseProject !== "UNION_REIMBURSE_PROJECT_1") return ""
@@ -917,9 +951,11 @@ layout("/layouts/platform.html"){
const selectedType = this.typeOptions.find(item => item.id === typeId)
return selectedType ? selectedType.code : ""
},
// 仅系统管理员和校工会管理员可查看真实余额,其他账号统一显示余额遮罩。
// 社团经费按菜单授权查看年度剩余额度,其他经费沿用原余额可见范围。
canViewFundBalance() {
return this.$auth.hasRoleOr(["SYSADMIN", "SCHOOL_UNION_ADMIN"])
|| (this.formData.reimburseFundSource === "UNION_REIMBURSE_FUND_SOURCE_3"
&& this.$auth.hasPermission("unionReimburse.club"))
},
showInvoiceTab() {
return !!this.formData.reimburseProject
@@ -1549,6 +1585,27 @@ layout("/layouts/platform.html"){
this.rebuildInvoiceSummary()
})
},
// 手填金额按分比较票面总额;未上传发票时仍允许申请。
getReimburseAmountMessage() {
if (!this.isManualReimburseAmount) return ""
const value = String(this.formData.money == null ? "" : this.formData.money)
if (!value) return "请填写报销金额"
const cents = this.moneyToCents(value)
if (!/^\d+(\.\d{1,2})?$/.test(value) || cents === null || cents <= 0) {
return "报销金额必须为正数,且最多两位小数"
}
const invoices = this.formData.invoiceDetails || []
let invoiceCents = 0
for (let i = 0; i < invoices.length; i++) {
const amount = this.moneyToCents(invoices[i] && invoices[i].invoiceAmount)
if (amount === null || amount <= 0) return "请填写正确的发票金额"
invoiceCents += amount
}
if (invoices.length && cents > invoiceCents) {
return "报销金额不能超过发票总额(" + (invoiceCents / 100).toFixed(2) + "元)"
}
return ""
},
rebuildInvoiceSummary() {
let totalMoney = 0
let fileCount = 0
@@ -1573,10 +1630,18 @@ layout("/layouts/platform.html"){
})
totalMoney = paymentCents / 100
}
this.$set(this.formData, "money", totalMoney ? totalMoney.toFixed(2) : "0.00")
// 三类活动保留手填申请金额,发票及支付明细变化只更新张数。
if (!this.isManualReimburseAmount) {
this.$set(this.formData, "money", totalMoney ? totalMoney.toFixed(2) : "0.00")
}
this.$set(this.formData, "invoiceNumber", String(fileCount))
},
validateInvoiceDetailsBeforeSubmit() {
const amountMessage = this.getReimburseAmountMessage()
if (amountMessage) {
this.$message.warning(amountMessage)
return false
}
const invoiceDetails = this.formData.invoiceDetails || []
// 发票可不上传;已经添加的发票仍须填写完整并沿用验真流程。
for (let i = 0; i < invoiceDetails.length; i++) {
@@ -1904,6 +1969,7 @@ layout("/layouts/platform.html"){
},
// 根据报销项目获取对应的附件说明
getFileDescByProject(projectCode) {
if (projectCode === "UNION_REIMBURSE_PROJECT_5") return "社团活动附件说明"
if (!this.descOptions || !Array.isArray(this.descOptions)) {
return '';
}
@@ -1918,21 +1984,20 @@ layout("/layouts/platform.html"){
const project = this.descOptions.find(item => item.reimburseProject === projectCode);
return project ? project.projectTip : '';
},
// 新建申请默认优先选择分工会经费;若当前用户没有对应权限,则按页面可见权限回退到其它经费来源。
// 默认来源按按钮菜单权限选择,保持分工会、协会、校工会的优先顺序。
getDefaultFundSource() {
if (this.$auth.hasPermission("unionReimburse.fgh")) {
return "UNION_REIMBURSE_FUND_SOURCE_2"
}
if (this.$auth.hasPermission("unionReimburse.xgh")) {
return "UNION_REIMBURSE_FUND_SOURCE_1"
}
if (this.$auth.hasPermission("unionReimburse.club")) {
return "UNION_REIMBURSE_FUND_SOURCE_3"
}
if (this.$auth.hasPermission("unionReimburse.fgh")) return "UNION_REIMBURSE_FUND_SOURCE_2"
if (this.$auth.hasPermission("unionReimburse.club")) return "UNION_REIMBURSE_FUND_SOURCE_3"
if (this.$auth.hasPermission("unionReimburse.xgh")) return "UNION_REIMBURSE_FUND_SOURCE_1"
return ""
},
selectFundSource(code) {
this.$set(this.formData, "reimburseFundSource", code)
if (code === "UNION_REIMBURSE_FUND_SOURCE_3") {
this.$set(this.formData, "reimburseProject", "UNION_REIMBURSE_PROJECT_5")
} else if (this.formData.reimburseProject === "UNION_REIMBURSE_PROJECT_5") {
this.$set(this.formData, "reimburseProject", "UNION_REIMBURSE_PROJECT_2")
}
if (code !== "UNION_REIMBURSE_FUND_SOURCE_3") {
this.$set(this.formData, "clubId", null)
this.$set(this.formData, "clubName", null)
@@ -1986,9 +2051,13 @@ layout("/layouts/platform.html"){
this.fundBalanceText = "查询中..."
this.$axios.post("/platform/unionReimburse/apply/getBudgetBalance", params)
.then(response => {
// 切换社团后忽略旧请求,避免显示上一社团的余额。
if (fundSource !== this.formData.reimburseFundSource
|| (params.clubId && params.clubId !== this.formData.clubId)) return
if (response.code === 0) {
this.$set(this.formData, "fundBalance", response.data || 0)
this.fundBalanceText = (response.data || 0).toString()
this.fundBalanceText = fundSource === "UNION_REIMBURSE_FUND_SOURCE_3"
? Number(response.data || 0).toFixed(2) : (response.data || 0).toString()
} else {
this.$set(this.formData, "fundBalance", null)
this.fundBalanceText = response.msg || "查询失败"
@@ -1996,6 +2065,8 @@ layout("/layouts/platform.html"){
}
})
.catch(error => {
if (fundSource !== this.formData.reimburseFundSource
|| (params.clubId && params.clubId !== this.formData.clubId)) return
console.error("经费余额查询失败:", error)
this.$set(this.formData, "fundBalance", null)
this.fundBalanceText = "查询失败"
@@ -2016,7 +2087,8 @@ layout("/layouts/platform.html"){
this.formData.hospitalizationEndTime
]);
}
if (this.$auth.hasRoleOr(["SCHOOL_OUTLAY_ADMIN", "SCHOOL_UNION_ADMIN"])) {
if (this.$auth.hasRoleOr(["SCHOOL_OUTLAY_ADMIN", "SCHOOL_UNION_ADMIN"])
&& !this.$auth.hasRoleOr(["BRANCH_UNION_CHAIRMAN", "BRANCH_UNION_ADMIN", "CLUB_MANAGER", "CLUB_PRESIDENT"])) {
this.budgetTypeOption.map(v => {
if (["UNION_REIMBURSE_FUND_SOURCE_1"].includes(v.code)) {
budgetTypeOption.push(v)
@@ -2030,7 +2102,7 @@ layout("/layouts/platform.html"){
}
})
}
if (this.$auth.hasRoleOr(["CLUB_MANAGER", "CLUB_PRESIDENT"])) {
if (this.$auth.hasPermission("unionReimburse.club")) {
this.budgetTypeOption.map(v => {
if (["UNION_REIMBURSE_FUND_SOURCE_3"].includes(v.code)) {
budgetTypeOption.push(v)
@@ -2039,11 +2111,23 @@ layout("/layouts/platform.html"){
}
this.budgetTypeOption = budgetTypeOption
}
// 社团经费按菜单权限过滤,默认来源与按钮可见范围保持一致。
this.budgetTypeOption = this.budgetTypeOption.filter(item =>
item.code !== "UNION_REIMBURSE_FUND_SOURCE_3"
|| this.$auth.hasPermission("unionReimburse.club"))
this.reimburseProjectList = await this.$businessTool.getDictOptions("UNION_REIMBURSE_PROJECT")
// 与协会经费按钮的显示条件一致,不依赖经费字典列表是否包含该项。
if (this.$auth.hasPermission("unionReimburse.club")) {
const clubs = await this.$axios.post("/platform/unionReimburse/apply/listReimburseClubs")
this.clubOptions = clubs.data || []
}
if (this.bizId) {
this.$axios.post("/platform/unionReimburse/apply/info", {id: this.bizId}).then(async (res) => {
if (res.code === 0) {
this.formData = this.ensureFormData(res.data)
if (this.formData.reimburseFundSource === "UNION_REIMBURSE_FUND_SOURCE_3") {
this.$set(this.formData, "reimburseProject", "UNION_REIMBURSE_PROJECT_5")
}
this.appendCurrentPayerOption()
this.formData.paymentDetails.forEach(row => {
if (row.payer) {
@@ -2066,7 +2150,8 @@ layout("/layouts/platform.html"){
unionId: union?.id,
unionName: union?.name,
// reimburseType: "UNION_REIMBURSE_TYPE_1",
reimburseProject: 'UNION_REIMBURSE_PROJECT_1',
reimburseProject: this.getDefaultFundSource() === "UNION_REIMBURSE_FUND_SOURCE_3"
? "UNION_REIMBURSE_PROJECT_5" : "UNION_REIMBURSE_PROJECT_1",
reimburseFundSource: this.getDefaultFundSource(),
paymentWay: 'UNION_REIMBURSE_PAYMENT_WAY_2',
paymentDetails: [this.buildDefaultPaymentDetail({
@@ -2076,7 +2161,7 @@ layout("/layouts/platform.html"){
userId: id,
})
if (this.formData.reimburseFundSource) {
this.reimburseFundSourceChange()
this.selectFundSource(this.formData.reimburseFundSource)
}
}
}
@@ -2085,17 +2170,7 @@ layout("/layouts/platform.html"){
this.init()
this.queryFileDesc()
this.queryCondolenceType()
//社团查询
this.$businessTool.listClubByRole().then((res) => {
this.$set(this, "clubOptions", res || [])
if (res && res.length === 1 && !this.formData.clubId) {
this.$set(this.formData, "clubId", res[0].id)
this.$set(this.formData, "clubName", res[0].clubName)
}
if (this.formData.reimburseFundSource) {
this.reimburseFundSourceChange()
}
})
}
})
</script>
@@ -51,10 +51,10 @@ layout("/layouts/platform.html"){
</table-tool>
<el-table :data="tableData" @sort-change="pageOrder" style="width: 100%">
<el-table-column type="index" width="50px" label="序号" :index="indexMethod"></el-table-column>
<el-table-column prop="serialNo" label="流水号" width="120"></el-table-column>
<el-table-column prop="loginName" label="经办人工号" sortable="custom"></el-table-column>
<el-table-column prop="userName" label="姓名"></el-table-column>
<el-table-column prop="unionName" label="所属工会" sortable="custom"></el-table-column>
<el-table-column prop="unitName" label="所属单位" sortable="custom"></el-table-column>
<el-table-column prop="createTime" label="申请时间" sortable="custom">
<template slot-scope="{row}">
{{ formatCreateTime(row.createTime) }}
@@ -62,7 +62,7 @@ layout("/layouts/platform.html"){
</el-table-column>
<el-table-column prop="reimburseProject" label="报销项目" sortable="custom">
<template slot-scope="{row}">
<el-select v-if="isAdmin && row.reimburseProject !== 'UNION_REIMBURSE_PROJECT_1'"
<el-select v-if="isAdmin && row.reimburseProject !== 'UNION_REIMBURSE_PROJECT_1' && row.reimburseFundSource !== 'UNION_REIMBURSE_FUND_SOURCE_3'"
:value="row.reimburseProject"
size="mini"
style="width: 120px"
@@ -35,7 +35,7 @@ const unionReimburseInfo = {
<el-descriptions-item label="联系方式">
<span>{{ viewData.mobile }}</span>
</el-descriptions-item>
<el-descriptions-item></el-descriptions-item>
<el-descriptions-item v-if="viewData.reimburseFundSource !== 'UNION_REIMBURSE_FUND_SOURCE_3'"></el-descriptions-item>
<el-descriptions-item v-if="!viewData.reimburseFundSource"></el-descriptions-item>
<el-descriptions-item label="支付信息" :span="3">
<el-table :data="paymentInfoRows" border empty-text="暂无支付信息" style="width: 100%">
@@ -63,6 +63,7 @@ layout("/layouts/platform.html"){
<span v-else-if="row.reimburseProject === 'UNION_REIMBURSE_PROJECT_2'">文体活动</span>
<span v-else-if="row.reimburseProject === 'UNION_REIMBURSE_PROJECT_3'">日常活动</span>
<span v-else-if="row.reimburseProject === 'UNION_REIMBURSE_PROJECT_4'">专项活动</span>
<span v-else-if="row.reimburseProject === 'UNION_REIMBURSE_PROJECT_5'">社团活动</span>
<span v-else>{{ row.reimburseProject }}</span>
</template>
</el-table-column>
@@ -58,10 +58,10 @@ layout("/layouts/platform.html"){
<el-table-column v-if="!pageForm.approval" type="selection" width="55"
:reserve-selection="true" :selectable="(row) => row.stateId == 2"></el-table-column>
<el-table-column type="index" width="50px" label="序号" :index="indexMethod"></el-table-column>
<el-table-column prop="serialNo" label="流水号" width="120"></el-table-column>
<el-table-column prop="loginName" label="经办人工号" sortable="custom"></el-table-column>
<el-table-column prop="userName" label="姓名"></el-table-column>
<el-table-column prop="unionName" label="所属工会" sortable="custom"></el-table-column>
<el-table-column prop="unitName" label="所属单位" sortable="custom"></el-table-column>
<el-table-column prop="createTime" label="申请时间" sortable="custom">
<template slot-scope="{row}">
{{ formatCreateTime(row.createTime) }}
@@ -73,6 +73,7 @@ layout("/layouts/platform.html"){
<span v-else-if="row.reimburseProject === 'UNION_REIMBURSE_PROJECT_2'">文体活动</span>
<span v-else-if="row.reimburseProject === 'UNION_REIMBURSE_PROJECT_3'">日常活动</span>
<span v-else-if="row.reimburseProject === 'UNION_REIMBURSE_PROJECT_4'">专项活动</span>
<span v-else-if="row.reimburseProject === 'UNION_REIMBURSE_PROJECT_5'">社团活动</span>
<span v-else>{{ row.reimburseProject }}</span>
</template>
</el-table-column>
@@ -16,17 +16,22 @@
:value="item.id"></el-option>
</el-select>
</el-form-item>
<el-form-item prop="userId" label="代表">
<user-select v-model="formData.userId"
ref="userSelectRef"
v-if="dialogFormVisible"
api="/platform/teacherCongress/delegate/manage/adjustUserList"
:api_params="{sessionId:formData.sessionId}"
api_input_key_name="keyWord"
placeholder="请选择代表"
:option_label_func="(item)=>{return item.userName + item.loginName + '(' + item.unitName + ')'}"
></user-select>
<el-button icon="el-icon-plus" type="primary" @click="move">添加到待调整列表</el-button>
<el-form-item prop="userId" label="代表" style="flex: 1; min-width: 0; margin-left: 24px;">
<!-- 选择框占用剩余空间,添加按钮保持在右侧并与输入框垂直对齐。 -->
<div style="display: flex; align-items: center; gap: 12px;">
<div style="flex: 1; min-width: 0;">
<user-select v-model="formData.userId"
ref="userSelectRef"
v-if="dialogFormVisible"
api="/platform/teacherCongress/delegate/manage/adjustUserList"
:api_params="{sessionId:formData.sessionId}"
api_input_key_name="keyWord"
placeholder="请选择代表"
:option_label_func="(item)=>{return item.userName + item.loginName + '(' + item.unitName + ')'}"
></user-select>
</div>
<el-button icon="el-icon-plus" type="primary" style="flex-shrink: 0;" @click="move">添加到待调整列表</el-button>
</div>
</el-form-item>
</el-row>
@@ -0,0 +1,122 @@
<!--#
layout("/layouts/platform.html"){
#-->
<div id="app" v-cloak>
<guava ref="guava">
<template>
<el-card shadow="never">
<search @search="doSearch">
<search-item label="员工信息">
<el-input @keyup.enter.native="doSearch" clearable placeholder="请输入工号或姓名" v-model="pageForm.searchKeyword"></el-input>
</search-item>
</search>
</el-card>
<el-card class="mt10" shadow="never">
<table-tool :app="this" label="华住员工管理">
<el-button :loading="formLoading" :disabled="tableLoading" @click="syncSelected" icon="el-icon-refresh" size="small" type="primary">同步选中员工</el-button>
</table-tool>
<el-table :data="tableData" :size="tableSize" @selection-change="selectionChange" class="vi-table" v-loading="tableLoading || formLoading">
<el-table-column type="selection" width="55"></el-table-column>
<el-table-column :index="indexMethod" label="序号" type="index" width="80"></el-table-column>
<el-table-column label="工号" min-width="140" prop="loginname"></el-table-column>
<el-table-column label="姓名" min-width="120" prop="username"></el-table-column>
<el-table-column label="单位" min-width="180" prop="unitName" show-overflow-tooltip></el-table-column>
<el-table-column fixed="right" label="操作" width="130">
<template slot-scope="{row}"><el-button :disabled="tableLoading || formLoading" @click="queryStatus(row)" size="mini" type="primary">查询状态</el-button></template>
</el-table-column>
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
<el-dialog :visible="statusDialogVisible" @update:visible="value => this.$set(this, 'statusDialogVisible', value)" title="华住同步状态" width="520px">
<el-descriptions :column="1" border size="small" v-if="statusData">
<el-descriptions-item label="状态码">{{statusData.statusCode || "--"}}</el-descriptions-item>
<el-descriptions-item label="工号">{{statusData.userNumber || "--"}}</el-descriptions-item>
<el-descriptions-item label="姓名">{{statusData.userName || "--"}}</el-descriptions-item>
<el-descriptions-item label="部门">{{statusData.userDep || "--"}}</el-descriptions-item>
<el-descriptions-item label="成本中心">{{statusData.costCenter || "--"}}</el-descriptions-item>
<el-descriptions-item label="职级">{{statusData.rank || "--"}}</el-descriptions-item>
<el-descriptions-item label="直属领导">{{statusData.leader || "--"}}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{statusData.email || "--"}}</el-descriptions-item>
</el-descriptions>
</el-dialog>
</template>
</guava>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
mixins: [initTableMixins],
data() {
// 分页、查询和 loading 复用 initTableMixins,只声明本业务的选择与状态弹框数据。
return {selectedRows: [], statusDialogVisible: false, statusData: null}
},
methods: {
selectionChange(rows) {
this.$set(this, "selectedRows", rows)
},
// userIds 为本系统主键;逐人 success=true/false/null 对应成功、失败、待核实。
syncSelected() {
if (this.formLoading || this.tableLoading) return
if (!this.selectedRows.length) {
this.$message.warning("请先选择需要同步的员工")
return
}
const userIds = this.selectedRows.map((row) => row.id)
this.$confirm("确定同步选中的 " + userIds.length + " 名员工到华住吗?", "提示", {type: "warning"}).then(() => {
if (this.formLoading) return
this.$set(this, "formLoading", true)
return this.$axios.post("/platform/huazhu/syncUsers", {userIds: userIds}).then((res) => {
if (res.code !== 0) {
this.$message.warning(res.msg || "同步失败")
return
}
const results = res.data || []
const successCount = results.filter((item) => item.success === true).length
const failedCount = results.filter((item) => item.success === false).length
const pendingCount = results.length - successCount - failedCount
const summary = "成功 " + successCount + " 人,失败 " + failedCount + " 人,待核实 " + pendingCount + " 人"
if (failedCount || pendingCount) {
this.$message.warning(summary)
const details = results.filter((item) => item.success !== true).map((item) => {
return (item.userName || "员工") + "(" + (item.loginName || item.userId) + "):" + item.message
}).join("\n")
this.$alert(details, "同步结果明细").catch(() => {})
} else {
this.$message.success(summary)
}
}).finally(() => {
this.$set(this, "formLoading", false)
}).catch(() => {
// 网络中断无法确认华住是否已处理,提示先查询状态,避免盲目重复同步。
this.$message.warning("请求未完成,请先查询华住状态再决定是否重试")
})
}).catch(() => {
// 取消确认不发送同步请求。
})
},
// userId 是本系统主键;成功 data 为该员工的华住信息,statusCode 按原值展示。
queryStatus(row) {
if (this.tableLoading || this.formLoading) return
this.$set(this, "tableLoading", true)
return this.$axios.post("/platform/huazhu/queryStatus", {userId: row.id}).then((res) => {
if (res.code === 0 && res.data) {
this.$set(this, "statusData", res.data)
this.$set(this, "statusDialogVisible", true)
} else {
this.$message.warning(res.msg || "查询失败")
}
}).finally(() => {
this.$set(this, "tableLoading", false)
}).catch(() => {
// 公共 Axios 拦截器提示请求异常,保留页面供用户重试。
})
}
},
created() {
this.pageData().catch(() => {})
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,187 @@
<!--#
layout("/layouts/platform.html"){
#-->
<div id="app" v-cloak>
<guava ref="guava">
<template>
<el-card shadow="never">
<search @search="doSearch">
<search-item label="年度">
<el-date-picker :clearable="true" placeholder="请选择年度" style="width: 100%" type="year" v-model="pageForm.year" value-format="yyyy"></el-date-picker>
</search-item>
<search-item label="标题">
<el-input @keyup.enter.native="doSearch" clearable placeholder="请输入标题" v-model="pageForm.title"></el-input>
</search-item>
</search>
</el-card>
<el-card class="mt10" shadow="never">
<table-tool :app="this">
<el-button @click="openAdd" icon="el-icon-plus" size="small" type="primary">新增</el-button>
</table-tool>
<el-table :data="tableData" :size="tableSize" class="vi-table" v-loading="tableLoading">
<el-table-column :index="indexMethod" label="序号" type="index" width="80"></el-table-column>
<el-table-column label="标题" min-width="180" prop="title" show-overflow-tooltip></el-table-column>
<el-table-column label="标签类型" prop="tagTypeName" width="130">
<template slot-scope="{row}"><el-tag v-if="row.tagTypeName" size="small">{{row.tagTypeName}}</el-tag><span v-else>--</span></template>
</el-table-column>
<el-table-column label="摘要" min-width="180" prop="summary" show-overflow-tooltip></el-table-column>
<el-table-column label="封面图" width="110">
<template slot-scope="{row}"><el-image v-if="row.cover" :preview-src-list="[row.cover]" :src="row.cover" fit="cover" style="height: 36px; width: 72px"></el-image><span v-else>--</span></template>
</el-table-column>
<el-table-column label="创建时间" width="170">
<template slot-scope="{row}">{{formatDate(row.createdAt)}}</template>
</el-table-column>
<el-table-column label="发布状态" width="100">
<template slot-scope="{row}"><span :class="row.status === 1 ? 'text-success' : 'text-muted'">{{row.statusName}}</span></template>
</el-table-column>
<el-table-column fixed="right" label="操作" width="220">
<template slot-scope="{row}">
<el-button @click="openEdit(row)" size="mini" type="primary">编辑</el-button>
<el-button @click="changeStatus(row, row.status === 1 ? 0 : 1)" size="mini">{{row.status === 1 ? '下架' : '发布'}}</el-button>
<el-button @click="deleteRow(row)" size="mini" type="danger">删除</el-button>
</template>
</el-table-column>
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
</template>
<template #edit>
<el-form :model="formData" :rules="formRules" label-width="90px" ref="form" v-loading="formLoading">
<el-form-item label="标题" prop="title"><el-input maxlength="100" placeholder="请输入标题" v-model="formData.title"></el-input></el-form-item>
<el-form-item label="摘要" prop="summary"><el-input maxlength="500" placeholder="请输入摘要" rows="3" type="textarea" v-model="formData.summary"></el-input></el-form-item>
<el-form-item label="标签类型" prop="tagType">
<el-select placeholder="请先维护标签类型字典" style="width: 100%" v-model="formData.tagType"><el-option :key="item.code" :label="item.label" :value="item.code" v-for="item in dict.type.INCLUSIVE_BENEFIT_TYPE || []"></el-option></el-select>
</el-form-item>
<el-form-item label="内容" prop="content"><text-editor v-model="formData.content"></text-editor></el-form-item>
<el-form-item label="性别限制" prop="sexLimit"><el-radio-group v-model="formData.sexLimit"><el-radio :label="0">全部开放</el-radio><el-radio :label="1">男性</el-radio><el-radio :label="2">女性</el-radio></el-radio-group></el-form-item>
<el-form-item label="封面图" prop="cover">
<file-upload :upload_number="1" :value.sync="formData.cover" upload_mode="image" upload_result_category="interval" upload_result_type="url"></file-upload>
</el-form-item>
<el-form-item label="链接" prop="linkUrl"><el-input maxlength="500" placeholder="请输入外链地址" v-model="formData.linkUrl"></el-input></el-form-item>
<el-form-item><el-button @click="back">取消</el-button><el-button :loading="formLoading" @click="saveForm(0)" type="primary">保存草稿</el-button><el-button :loading="formLoading" @click="saveForm(1)" type="primary">发布</el-button></el-form-item>
</el-form>
</template>
</guava>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
dicts: ["INCLUSIVE_BENEFIT_TYPE"],
mixins: [initTableMixins],
data() {
return {
// 公共分页参数、列表请求及加载状态由 initTableMixins 提供,仅补充业务筛选项。
pageForm: {year: "", title: ""},
formRules: {
title: [{required: true, message: "请输入标题", trigger: "blur"}],
summary: [{required: true, message: "请输入摘要", trigger: "blur"}],
tagType: [{required: true, message: "请选择标签类型", trigger: "change"}],
content: [{required: true, message: "请输入内容", trigger: "change"}],
cover: [{required: true, message: "请上传封面图", trigger: "change"}]
}
}
},
methods: {
// 新内容归入查询年度;未选年度时使用当前年度,性别配置沿用原模块默认值。
emptyForm() {
return {id: "", year: this.pageForm.year || new Date().getFullYear() + "", title: "",
summary: "", tagType: "", content: "", sexLimit: 0, cover: "", linkUrl: ""}
},
formatDate(value) {
return value ? moment(value).format("YYYY-MM-DD HH:mm") : "--"
},
back() {
this.$refs.guava.index()
},
openAdd() {
this.$set(this, "formData", this.emptyForm())
this.$refs.guava.edit(() => this.$refs.form.clearValidate())
},
// id 为所选内容主键;成功响应 data 为详情对象,回显时将年度转换为表单字符串。
openEdit(row) {
if (this.formLoading) return
this.$set(this, "formLoading", true)
this.$axios.post("/platform/inclusive/findOne", {id: row.id}).then((res) => {
if (res.code === 0 && res.data) {
const formData = Object.assign(this.emptyForm(), res.data)
this.$set(formData, "year", formData.year == null ? "" : formData.year + "")
this.$set(this, "formData", formData)
this.$refs.guava.edit(() => this.$refs.form.clearValidate())
} else {
this.$message.warning(res.msg || "未查询到数据")
}
}).finally(() => {
this.$set(this, "formLoading", false)
}).catch(() => {
// 公共 Axios 拦截器已展示失败原因;保留当前页面供用户重试。
})
},
// status=0 保存草稿,status=1 发布;code=0 表示成功,返回列表后复用 mixin 刷新查询。
saveForm(status) {
if (this.formLoading) return
this.$refs.form.validate((valid) => {
if (!valid) return
this.$set(this, "formLoading", true)
const params = Object.assign({}, this.formData, {status: status})
this.$axios.post("/platform/inclusive/save", params).then((res) => {
if (res.code === 0) {
this.$message.success("保存成功")
this.$refs.guava.index()
this.doSearch()
} else {
this.$message.warning(res.msg || "保存失败")
}
}).finally(() => {
this.$set(this, "formLoading", false)
}).catch(() => {
// 保存失败保留表单,错误提示由公共 Axios 拦截器负责。
})
})
},
// 发布/下架只提交 id 和目标 status;确认取消不会发送写请求。
changeStatus(row, status) {
if (this.formLoading) return
this.$confirm("确定要" + (status === 1 ? "发布" : "下架") + "该幸福工荟吗?", "提示", {type: "warning"}).then(() => {
this.$set(this, "formLoading", true)
this.$axios.post("/platform/inclusive/updateStatus", {id: row.id, status: status}).then((res) => {
if (res.code === 0) {
this.$message.success("操作成功")
this.doSearch()
} else {
this.$message.warning(res.msg || "操作失败")
}
}).finally(() => {
this.$set(this, "formLoading", false)
}).catch(() => {
// 发布或下架失败时保留现有列表状态。
})
}).catch(() => {})
},
// 删除仅提交内容主键,后端执行逻辑删除;成功后刷新分页总数。
deleteRow(row) {
if (this.formLoading) return
this.$confirm("确定要删除该幸福工荟吗?", "提示", {type: "warning"}).then(() => {
this.$set(this, "formLoading", true)
this.$axios.post("/platform/inclusive/delete", {id: row.id}).then((res) => {
if (res.code === 0) {
this.$message.success("删除成功")
this.doSearch()
} else {
this.$message.warning(res.msg || "删除失败")
}
}).finally(() => {
this.$set(this, "formLoading", false)
}).catch(() => {
// 删除失败时保留当前记录,公共拦截器已提示原因。
})
}).catch(() => {})
}
},
created() {
this.pageData()
}
})
</script>
<!--#
}
#-->
@@ -54,6 +54,7 @@ layout("/layouts/platform.html"){
<el-card shadow="never" class="mt10">
<table-tool label="基金会员">
<el-button @click="exportChangeRecord" size="small" icon="el-icon-download" type="primary">导出</el-button>
</table-tool>
<el-table :data="tableData" :size="tableSize" @sort-change="pageOrder"
@@ -71,7 +72,11 @@ layout("/layouts/platform.html"){
show-overflow-tooltip
v-for="column in tableColumns"
>
<template v-slot="{row}" v-if="column.prop=='changeType'">
<template v-slot="{row}" v-if="column.prop=='aidFundMemberUserType'">
<dict-tag :options="dict.type.AIDFUND_MEMBER_USER_TYPE"
:value="row.aidFundMemberUserType"></dict-tag>
</template>
<template v-slot="{row}" v-else-if="column.prop=='changeType'">
<dict-tag :options="dict.type.AIDFUND_MEMBER_CHANGE_TYPE"
:value="row.changeType"></dict-tag>
</template>
@@ -131,6 +136,10 @@ layout("/layouts/platform.html"){
'info': APPLY_INFO,
},
methods: {
exportChangeRecord() {
// 传入页面筛选和排序参数,服务端按相同条件导出全部匹配记录。
this.$downLoad(loc() + "/exportChangeRecord", this.pageForm)
},
onView(row) {
this.$refs.guava.view(() => {
this.$refs.infoRef.onOpen(row)
@@ -11,9 +11,14 @@ layout("/layouts/platform.html"){
justify-content: center;
font-size: 30px;
}
#app .calculate-money-card > .card-body,
#calcSubsidyTable .el-table__body-wrapper {
overscroll-behavior-y: contain;
}
</style>
<div id="app">
<custom-card>
<custom-card class="calculate-money-card" ref="calculateCard" @hook:mounted="initTableScroll">
<div id="calcSubsidyTable" v-loading="formLoading">
<div class="process-title">1.输入金额(元)</div>
<el-row :gutter="20">
@@ -129,7 +134,7 @@ layout("/layouts/platform.html"){
</el-col>
</el-row>
<el-table :data="applyListData" ref="table" row-key="id" style="width: 100%"
:summary-method="getSummaries" show-summary>
:height="tableHeight" :summary-method="getSummaries" show-summary>
<el-table-column type="index" width="50px" label="序号"
fixed="left"></el-table-column>
<el-table-column
@@ -139,6 +144,7 @@ layout("/layouts/platform.html"){
:width="column.width"
:fixed="column.fixed"
:sortable="column.sortable"
:formatter="formatSubsidyMoney"
show-overflow-tooltip
sortable
v-for="column in tableColumns"
@@ -191,6 +197,7 @@ layout("/layouts/platform.html"){
ratio: 60,
medicalSetting: {},
applyListData: [],
tableHeight: null,
tableSummaries: [],
tableColumns: [
{prop: 'loginname', label: '工号', fixed: "left"},
@@ -211,10 +218,16 @@ layout("/layouts/platform.html"){
{prop: 'bxyMoney$', label: '非住院肾衰竭治疗、靶向药金额', width: "120"},
{prop: 'totalMoney', label: '合计金额', width: "120"},
{prop: 'loveSubsidyMoney', label: '爱心补助', width: "120"},
{prop: 'forecastSubsidyMoney', label: '当前补助金额', width: "120"},
{prop: 'subsidyMoney', label: '当前补助金额', width: "120"},
],
}
},
watch: {
applyListData() {
// 空列表切换为有数据时也重排固定列和合计,避免底部合计与数据行重叠。
this.$nextTick(() => this.updateTableHeight())
},
},
computed: {
//申请费用总额
@@ -245,7 +258,72 @@ layout("/layouts/platform.html"){
},
},
methods: {
async grantOrIntoChange(key, val) {
initTableScroll() {
this.$nextTick(() => {
const card = this.$refs.calculateCard
const scrollBody = card && card.$el.querySelector('.card-body')
if (!scrollBody || !this.$refs.table || this._calculateScrollBody === scrollBody) {
return
}
// custom-card 异步挂载后再绑定,滚动仅限当前卡片;销毁时解除监听。
this._calculateScrollBody = scrollBody
scrollBody.addEventListener('wheel', this.handleTableWheel, {passive: false})
window.addEventListener('resize', this.updateTableHeight)
if (window.ResizeObserver) {
this._calculateResizeObserver = new ResizeObserver(() => this.updateTableHeight())
this._calculateResizeObserver.observe(scrollBody)
}
this.updateTableHeight()
})
},
updateTableHeight() {
const scrollBody = this._calculateScrollBody
const table = this.$refs.table
if (!scrollBody || !table) {
return
}
// 列表占满卡片可用高度,表头、合计和底部操作按钮保留在可见区域。
const style = window.getComputedStyle(scrollBody)
const height = Math.max(0, Math.floor(scrollBody.clientHeight
- parseFloat(style.paddingTop) - parseFloat(style.paddingBottom)))
if (this.tableHeight !== height) {
this.$set(this, 'tableHeight', height)
}
this.$nextTick(() => table.doLayout())
},
handleTableWheel(event) {
const scrollBody = this._calculateScrollBody
const table = this.$refs.table
if (!scrollBody || !table || !table.bodyWrapper || event.ctrlKey
|| Math.abs(event.deltaX) > Math.abs(event.deltaY) || !event.deltaY) {
return
}
const style = window.getComputedStyle(scrollBody)
const tableTop = scrollBody.scrollTop + table.$el.getBoundingClientRect().top
- scrollBody.getBoundingClientRect().top - scrollBody.clientTop - parseFloat(style.paddingTop)
const outerLimit = Math.max(0, Math.min(tableTop, scrollBody.scrollHeight - scrollBody.clientHeight))
const listBody = table.bodyWrapper
const innerLimit = Math.max(0, listBody.scrollHeight - listBody.clientHeight)
const unit = event.deltaMode === 1 ? 16 : (event.deltaMode === 2 ? scrollBody.clientHeight : 1)
let delta = event.deltaY * unit
event.preventDefault()
if (delta > 0) {
// 先将列表滚到卡片顶部,再把同次滚轮的剩余距离交给列表内容。
const outerMove = Math.min(delta, Math.max(0, outerLimit - scrollBody.scrollTop))
scrollBody.scrollTop += outerMove
delta -= outerMove
listBody.scrollTop = Math.min(innerLimit, listBody.scrollTop + delta)
} else {
// 反向滚动先回到列表第一行,再返回上方金额和比例区域;边界不带动整页。
if (scrollBody.scrollTop >= outerLimit - 1) {
const innerMove = Math.min(-delta, listBody.scrollTop)
listBody.scrollTop -= innerMove
delta += innerMove
}
scrollBody.scrollTop = Math.max(0, scrollBody.scrollTop + delta)
}
},
grantOrIntoChange(key, val) {
if (val === '') {
if (key === 'grants') {
this.calcData.schoolGrants = 0
@@ -253,7 +331,11 @@ layout("/layouts/platform.html"){
this.calcData.unionInto = 0
}
}
const resp = await this.$axios.post(loc() + '/setGrantsOrUnionInto', {keys: key, value: val})
return this.$axios.post(loc() + '/setGrantsOrUnionInto', {keys: key, value: val})
},
// 已保存补助去掉末尾的零;历史非零小数保持真实值,需完成分配后才变为整数。
formatSubsidyMoney(row, column, value) {
return column.property === 'subsidyMoney' && value != null && value !== '' ? Number(value) : value
},
getSummaries(param) {
const {columns, data} = param
@@ -266,7 +348,13 @@ layout("/layouts/platform.html"){
const values = data.map(i => Number(i[column.property]))
if (values && values.length > 0) {
const everyColumnSum = values.reduce((a, b) => a + b)
sums[index] = isNaN(everyColumnSum) ? 0 : everyColumnSum.toFixed(2)
// 测算合计显示整数;已保存合计仅去掉末尾零,保留历史金额中的真实角分。
if (column.property === 'subsidyMoney') {
sums[index] = isNaN(everyColumnSum) ? 0 : Number(everyColumnSum.toFixed(2))
} else {
const digits = column.property === 'forecastSubsidyMoney' ? 0 : 2
sums[index] = isNaN(everyColumnSum) ? 0 : everyColumnSum.toFixed(digits)
}
}
}
})
@@ -279,13 +367,18 @@ layout("/layouts/platform.html"){
ratio: this.ratio})
},
doHandle() {
this.$confirm('确定要按此比例来补助吗?', '提示', {
return this.$confirm('确定要按此比例来补助吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
this.$axios.post("/platform/medicalMutualAid/medical/calculateMoney/doHandle", {
data: JSON.stringify(this.applyListData),
// 仅提交本次分配所需的申请标识和金额,保存及刷新结束后统一解除按钮加载状态。
this.formLoading = true
return this.$axios.post("/platform/medicalMutualAid/medical/calculateMoney/doHandle", {
data: JSON.stringify(this.applyListData.map(row => ({
id: row.id,
forecastSubsidyMoney: row.forecastSubsidyMoney
}))),
ratio: this.ratio,
aidFund: this.calcData.aidFundMoney,
inComeSum: this.inComeSum,
@@ -293,28 +386,28 @@ layout("/layouts/platform.html"){
}).then(res => {
if (res.code === 0) {
this.$message.success("操作成功")
this.getData()
this.getApplyData()
return this.getData()
}
}).finally(() => {
this.formLoading = false
})
})
},
async getMedicalSetting() {
try {
const res = await this.$axios.post("/platform/medicalMutualAid/medical/basicSetting/getSetting")
getMedicalSetting() {
return this.$axios.post("/platform/medicalMutualAid/medical/basicSetting/getSetting").then(res => {
if (res.code === 0) {
this.medicalSetting = res.data
}
} catch (e) {
}).catch(e => {
this.$message.error(e.message)
}
})
},
getData() {
this.formLoading = true
this.$axios.post("/platform/medicalMutualAid/medical/calculateMoney/getData").then(res => {
return this.$axios.post("/platform/medicalMutualAid/medical/calculateMoney/getData").then(res => {
if (res.code === 0) {
if (res.data.ratio) {
this.ratio = res.data.ratio
@@ -322,14 +415,14 @@ layout("/layouts/platform.html"){
this.ratio = this.medicalSetting.subsidyRatio
}
this.calcData = res.data
this.getApplyData()
return this.getApplyData()
}
}).finally(()=>{
this.formLoading=false
})
},
getApplyData() {
this.$axios.post("/platform/medicalMutualAid/medical/calculateMoney/getApplyData", {ratio: this.ratio}).then(res => {
return this.$axios.post("/platform/medicalMutualAid/medical/calculateMoney/getApplyData", {ratio: this.ratio}).then(res => {
if (res.code === 0) {
this.applyListData = res.data
}
@@ -337,9 +430,20 @@ layout("/layouts/platform.html"){
},
},
async created() {
await this.getMedicalSetting()
this.getData()
created() {
this.getMedicalSetting().then(() => this.getData())
},
mounted() {
this.initTableScroll()
},
beforeDestroy() {
if (this._calculateScrollBody) {
this._calculateScrollBody.removeEventListener('wheel', this.handleTableWheel)
}
if (this._calculateResizeObserver) {
this._calculateResizeObserver.disconnect()
}
window.removeEventListener('resize', this.updateTableHeight)
}
})
@@ -2,9 +2,62 @@
layout("/layouts/platform.html"){
#-->
<style>
/* 查询区保持可见,申请列表使用页面剩余高度。 */
.medical-statistics-layout {
height: calc(100vh - 84px);
min-height: 0;
overflow: hidden;
}
.medical-statistics-layout > .transition-item {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
gap: 15px;
overflow: hidden;
}
.medical-statistics-layout .medical-statistics-search-card {
flex: 0 0 auto;
}
.medical-statistics-layout .medical-statistics-table-card {
flex: 1 1 0;
min-height: 0;
margin-top: 0;
overflow: hidden;
}
.medical-statistics-layout .medical-statistics-table-card > .el-card__body {
height: 100%;
min-height: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
}
.medical-statistics-layout .medical-statistics-table-card .ele-table-tool {
flex: 0 0 auto;
}
.medical-statistics-layout .medical-statistics-table-container {
flex: 1 1 0;
min-height: 0;
overflow: hidden;
}
.medical-statistics-layout .medical-statistics-main-table .el-table__body-wrapper {
overflow-y: auto;
}
</style>
<div id="app">
<guava ref="guava">
<el-card shadow="never">
<guava ref="guava" class="medical-statistics-layout"
@hook:mounted="initTableLayout" @vchange="initTableLayout">
<el-card shadow="never" class="medical-statistics-search-card">
<search @search="doSearch">
<search-item label="年度">
<el-date-picker
@@ -53,7 +106,7 @@ layout("/layouts/platform.html"){
</search>
</el-card>
<el-card shadow="never">
<el-card shadow="never" class="medical-statistics-table-card">
<table-tool label="申请列表">
<el-dropdown @command="doExport">
<el-button type="primary" size="small">导出 <i class="el-icon-arrow-down el-icon--right"></i></el-button>
@@ -63,47 +116,51 @@ layout("/layouts/platform.html"){
</el-dropdown-menu>
</el-dropdown>
</table-tool>
<el-table :data="tableData" :size="tableSize" @sort-change="pageOrder"
ref="table" row-key="id" style="width: 100%"
v-loading="tableLoading"
:summary-method="getSummaries" :show-summary="showSummary">
<el-table-column type="index" width="50px" label="序号" :index="indexMethod"
fixed="left"></el-table-column>
<el-table-column
:label="column.label"
:prop="column.prop"
:key="column.prop"
:width="column.width"
:fixed="column.fixed"
:sortable="column.sortable"
show-overflow-tooltip
sortable
v-for="column in tableColumns"
>
<template v-if="column.prop==='isMajorDiseases'" v-slot="{row}">
<span :class="[row.isMajorDiseases?'text-danger':'text-info']">
<i class="fa fa-circle ml5"></i>
{{row.isMajorDiseases?'是':'否'}}({{row.applyNum}})
</span>
</template>
<template v-slot="{ row }" v-else-if="column.prop === 'instanceState'">
<enum-tag :value="row.instanceState" name="ProcessInstanceStateEnum" label_key="message"
size="small"></enum-tag>
</template>
<template v-slot="{ row }" v-else-if="column.prop === 'curTaskName'">
{{row.curTaskName?row.curTaskName:row.taskName}}
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="100">
<template slot-scope="{row}">
<el-button @click="onView(row)" size="mini" type="primary">查看</el-button>
<el-button v-if="row.taskKey === 'startTask' || !row.instanceId" @click="onDelete(row.id)"
size="mini" type="danger">
删除
</el-button>
<div ref="tableContainer" class="medical-statistics-table-container">
<el-table class="medical-statistics-main-table" :height="tableHeight"
:data="tableData" :size="tableSize" @sort-change="pageOrder"
ref="table" row-key="id" style="width: 100%"
v-loading="tableLoading"
:summary-method="getSummaries" :show-summary="showSummary">
<el-table-column type="index" width="50px" label="序号" :index="indexMethod"
fixed="left"></el-table-column>
<el-table-column
:label="column.label"
:prop="column.prop"
:key="column.prop"
:width="column.width"
:fixed="column.fixed"
:sortable="column.sortable"
:formatter="formatSubsidyMoney"
show-overflow-tooltip
sortable
v-for="column in tableColumns"
>
<template v-if="column.prop==='isMajorDiseases'" v-slot="{row}">
<span :class="[row.isMajorDiseases?'text-danger':'text-info']">
<i class="fa fa-circle ml5"></i>
{{row.isMajorDiseases?'是':'否'}}({{row.applyNum}})
</span>
</template>
<template v-slot="{ row }" v-else-if="column.prop === 'instanceState'">
<enum-tag :value="row.instanceState" name="ProcessInstanceStateEnum" label_key="message"
size="small"></enum-tag>
</template>
<template v-slot="{ row }" v-else-if="column.prop === 'curTaskName'">
{{row.curTaskName?row.curTaskName:row.taskName}}
</template>
</el-table-column>
</el-table>
<el-table-column label="操作" fixed="right" width="100">
<template slot-scope="{row}">
<el-button @click="onView(row)" size="mini" type="primary">查看</el-button>
<el-button v-if="row.taskKey === 'startTask' || !row.instanceId" @click="onDelete(row.id)"
size="mini" type="danger">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
<template #edit>
@@ -276,13 +333,18 @@ layout("/layouts/platform.html"){
pageSize: 9999,
isMajorDiseases: null
},
medicalSetting: {}
medicalSetting: {},
tableHeight: 300
}
},
components: {
"medical-info": MEDICAL_INFO
},
watch: {
// 年度切换可能使合计行从隐藏变为显示,待数据渲染后重新测量表头、合计行和固定列。
tableData() {
this.$nextTick(() => this.updateTableHeight())
},
"localPageForm": {
handler(val) {
this.pageForm = {...this.pageForm, ...val}
@@ -291,6 +353,34 @@ layout("/layouts/platform.html"){
},
},
methods: {
// guava 异步挂载后才存在列表容器;重复调用时复用监听,避免首次进入漏掉初始化。
initTableLayout() {
this.$nextTick(() => {
const container = this.$refs.tableContainer
if (!container || !this.$refs.table) return
if (!this._tableResizeObserver) {
this._tableResizeObserver = new ResizeObserver(() => this.updateTableHeight())
this._tableResizeObserver.observe(container)
}
this.updateTableHeight()
})
},
// 独立容器占用工具栏下方的剩余空间,隐藏列表时保留高度,返回列表后再刷新布局。
updateTableHeight() {
const container = this.$refs.tableContainer
const table = this.$refs.table
if (!container || !table || container.clientHeight <= 0) return
const height = container.clientHeight
if (this.tableHeight !== height) {
this.$set(this, "tableHeight", height)
}
// 空数据变为有数据时高度可能不变,仍需重算合计行占用空间。
this.$nextTick(() => {
if (this.$refs.table) this.$refs.table.doLayout()
})
},
onDelete(id) {
this.$confirm("您确定要删除吗?", "提示", {
confirmButtonText: "确定",
@@ -305,6 +395,10 @@ layout("/layouts/platform.html"){
})
})
},
// 整数补助不显示 .00;尚未重新分配的历史小数保留原值,避免显示金额与数据库不符。
formatSubsidyMoney(row, column, value) {
return column.property === 'subsidyMoney' && value != null && value !== '' ? Number(value) : value
},
getSummaries(param) {
const {columns, data} = param
const sums = []
@@ -314,7 +408,9 @@ layout("/layouts/platform.html"){
const values = data.map(i => Number(i[column.property]))
if (values && values.length > 0) {
const everyColumnSum = values.reduce((a, b) => a + b)
sums[index] = isNaN(everyColumnSum) ? 0 : everyColumnSum.toFixed(2)
// 仅补助合计去掉末尾零;爱心补助和医疗费用仍按两位小数显示。
const total = isNaN(everyColumnSum) ? 0 : everyColumnSum.toFixed(2)
sums[index] = column.property === 'subsidyMoney' ? Number(total) : total
}
}
})
@@ -403,6 +499,14 @@ layout("/layouts/platform.html"){
this.getDiseaseList()
this.unionList = await this.$businessTool.listUnion()
this.unitList = await this.$businessTool.listUnit()
},
mounted() {
this.initTableLayout()
},
beforeDestroy() {
if (this._tableResizeObserver) {
this._tableResizeObserver.disconnect()
}
}
})
</script>
@@ -29,6 +29,11 @@ layout("/layouts/platform.html"){
margin-right: 8px;
}
.pending-list-toolbar-actions .pending-import-upload {
display: inline-block;
margin-right: 8px;
}
/* 在列表标题旁说明待确认人员的来源及可能情况。 */
.pending-confirm-tip {
color: #f56c6c;
@@ -128,6 +133,16 @@ layout("/layouts/platform.html"){
<el-button class="pending-batch-change-button" type="primary"
icon="el-icon-download" size="small"
@click="exportExcel">导出</el-button>
<el-upload class="pending-import-upload"
v-if="pageForm.pendingConfirmStatus === 1"
action="#"
accept=".xls,.xlsx"
:show-file-list="false"
:before-upload="validateImportFile"
:http-request="importChange">
<el-button type="primary" icon="el-icon-upload2" size="small"
:loading="importLoading" :disabled="importLoading">导入变更</el-button>
</el-upload>
<el-button class="pending-batch-change-button" type="primary"
icon="el-icon-edit" size="small"
v-if="pageForm.pendingConfirmStatus === 1"
@@ -275,6 +290,7 @@ layout("/layouts/platform.html"){
selectedPendingUsers: [],
batchDialogVisible: false,
batchSubmitting: false,
importLoading: false,
batchChangeForm: {
personType: "",
userState: "",
@@ -378,6 +394,33 @@ layout("/layouts/platform.html"){
exportExcel() {
this.$downLoad("/platform/sourcechange/pending/exportExcel", this.buildQueryPageForm())
},
/** 只接收 Excel 文件,避免无效文件进入后端解析。 */
validateImportFile(file) {
const fileName = file && file.name ? file.name.toLowerCase() : ""
const valid = fileName.endsWith(".xls") || fileName.endsWith(".xlsx")
if (!valid) {
this.$message.warning("只能上传xls或xlsx文件")
}
return valid
},
/** 上传填写了编码的列表文件,后端仅按工号和编码执行字典映射变更。 */
importChange(uploadRequest) {
const formData = new FormData()
formData.append("file", uploadRequest.file, uploadRequest.file.name)
this.importLoading = true
this.$axios.post("/platform/sourcechange/pending/importChange", formData).then((resp) => {
if (resp.code === 0) {
this.$message.success(resp.msg)
this.doSearch()
} else {
this.$message.error(resp.msg)
}
}).catch(() => {
this.$message.error("导入变更失败,请稍后重试")
}).finally(() => {
this.importLoading = false
})
},
/** 保存当前页勾选的待确认人员,供批量变更使用。 */
handleSelectionChange(selection) {
this.selectedPendingUsers = (selection || []).filter((item) => item.pendingConfirmStatus === 1)
@@ -29,7 +29,7 @@ layout("/layouts/platform_h5.html"){
.club-apply-actions__agree .van-checkbox__icon { margin-top: 2px; }
.club-apply-actions__agree .van-checkbox__label { color: #65778e; font-size: 12px; line-height: 18px; }
.club-apply-actions__buttons { display: flex; gap: 10px; }
.club-apply-actions .van-button { flex: 1; height: 40px; border-radius: 8px; font-size: 14px; font-weight: 500; }
.club-apply-actions .van-button { flex: 1; height: 40px; border-radius: 20px; font-size: 14px; font-weight: 500; }
.club-apply-actions .van-button--info { background: var(--club-primary); border-color: var(--club-primary); }
.club-apply-actions .van-button--info.van-button--plain { color: var(--club-primary); background: #fff; }
</style>
@@ -41,7 +41,7 @@ layout("/layouts/platform_h5.html"){
<section class="club-apply-section">
<h2 class="club-apply-section__title">基本信息</h2>
<van-cell-group :border="false">
<van-field :rules="[{ required: true, message: '请选择社团' }]" v-model="formData.clubName" label="社团" placeholder="请选择社团" required is-link readonly name="clubName" @click="openClubPopup"></van-field>
<van-field :rules="[{ required: true, message: '请从社团列表选择社团后进入申请' }]" v-model="formData.clubName" label="社团" placeholder="从社团列表带入" required readonly name="clubName"></van-field>
<van-field label="姓名" :rules="[{ required: true }]" v-model="formData.userName" readonly required name="userName"></van-field>
<van-field label="工号" :rules="[{ required: true }]" v-model="formData.loginName" readonly required name="loginName"></van-field>
<van-field label="性别" :rules="[{ required: true }]" v-model="formData.sex" readonly required></van-field>
@@ -98,10 +98,6 @@ layout("/layouts/platform_h5.html"){
</div>
</div>
<van-popup v-model="showClubPopup" position="bottom" get-container="#app" @click-overlay="closeClubPopup">
<van-picker show-toolbar :columns="clubOptions.map((item) => item.clubName)" @confirm="onClubConfirm" @cancel="closeClubPopup"></van-picker>
</van-popup>
<van-dialog :value="confirmVisible" title="提示" :message="confirmMessage" show-cancel-button @confirm="confirmPendingAction" @cancel="closeConfirm"></van-dialog>
</div>
@@ -118,7 +114,6 @@ layout("/layouts/platform_h5.html"){
clubId: "", clubName: "", userName: "", loginName: "", sex: "", birthday: "", mobile: "", email: "", unionName: "", unitName: "", position: "", technicalTitle: "", education: "", academicDegree: "", sameTimeJoinOtherClubSituation: "", awardsExperience: "", avatar: "", signature: ""
},
clubOptions: [],
showClubPopup: false,
isAgree: false,
formLoading: false,
confirmVisible: false,
@@ -136,7 +131,6 @@ layout("/layouts/platform_h5.html"){
if (manager) {
manager.ensureRegistered('club-apply');
this.historyLayerUnsubscribe = manager.subscribe((layers) => {
this.$set(this, 'showClubPopup', layers.includes('club-apply-club-picker'));
this.$set(this, 'confirmVisible', layers.includes('club-apply-confirm'));
});
}
@@ -161,25 +155,6 @@ layout("/layouts/platform_h5.html"){
}
historyBack();
},
openClubPopup() {
const manager = window.h5HistoryLayerManager;
if (manager) {
manager.open('club-apply-club-picker');
return;
}
this.$set(this, 'showClubPopup', true);
},
closeClubPopup(afterClose) {
const manager = window.h5HistoryLayerManager;
if (manager) {
manager.close('club-apply-club-picker', afterClose);
return;
}
this.$set(this, 'showClubPopup', false);
if (afterClose) {
afterClose();
}
},
openConfirm(action) {
this.$set(this, 'pendingAction', action);
const manager = window.h5HistoryLayerManager;
@@ -271,19 +246,20 @@ layout("/layouts/platform_h5.html"){
}
});
},
onClubConfirm(value, index) {
const club = this.clubOptions[index];
if (club) {
this.$set(this.formData, 'clubId', club.id);
this.$set(this.formData, 'clubName', value);
this.checkApplyClub(club.id);
}
this.closeClubPopup();
},
listClub() {
this.$axios.post('/platform/club/common/listClub').then((res) => {
if (res.code === 0) {
this.$set(this, 'clubOptions', res.data || []);
// 从社团卡片进入新申请时预选社团,编辑已有申请仍使用原记录。
const clubId = GetQueryString('clubId');
if (!this.bizId && clubId) {
const club = this.clubOptions.find((item) => item.id === clubId);
if (club) {
this.$set(this.formData, 'clubId', club.id);
this.$set(this.formData, 'clubName', club.clubName);
this.checkApplyClub(club.id);
}
}
}
});
},
@@ -0,0 +1,349 @@
<!--#
layout("/layouts/platform_h5.html"){
#-->
<style>
<!--#include('../common/clubUserJoin.css'){}#-->
.club-directory-page { min-height: 100vh; padding-bottom: calc(24px + env(safe-area-inset-bottom)); box-sizing: border-box; background: #f3f7fd; }
.club-directory-page .van-nav-bar { background: #fff; }
.club-directory-page .van-nav-bar__title { color: #1f2937; font-size: 16px; }
.club-directory-page .van-nav-bar .van-icon, .club-directory-page .van-nav-bar__text { color: #397fbd; }
.club-directory-content { padding: 12px 12px 0; }
.club-directory-hero { position: relative; height: 134px; overflow: hidden; border-radius: 10px; color: #fff; background: #247fe8; box-shadow: 0 8px 20px rgba(51, 117, 219, .18); }
.club-directory-hero__image { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover; object-position: center; }
.club-directory-hero__copy { position: relative; z-index: 1; display: flex; width: 64%; height: 100%; padding-left: 16px; flex-direction: column; justify-content: center; box-sizing: border-box; background: linear-gradient(90deg, rgba(22, 112, 232, .98) 0%, rgba(22, 112, 232, .88) 52%, rgba(22, 112, 232, 0) 100%); }
.club-directory-hero__title { font-size: 25px; line-height: 34px; font-weight: 700; letter-spacing: 1px; text-shadow: 0 2px 6px rgba(17, 86, 182, .18); }
.club-directory-hero__subtitle { margin-top: 4px; font-size: 13px; line-height: 20px; letter-spacing: .5px; opacity: .95; }
.club-directory-sticky { padding: 10px 0 1px; background: #f3f7fd; }
.club-directory-page .van-sticky--fixed .club-directory-sticky { padding-right: 12px; padding-left: 12px; box-sizing: border-box; }
.club-directory-search { margin: 0 0 8px; padding: 0; overflow: hidden; border-radius: 12px; background: #fff; box-shadow: 0 5px 16px rgba(43, 73, 112, .1); }
.club-directory-search .van-search__content { height: 42px; padding-left: 12px; align-items: center; border-radius: 12px; background: #fff; }
.club-directory-search .van-field__control { color: #263548; font-size: 14px; }
.club-directory-search .van-field__control::placeholder { color: #a7b0bf; }
.club-directory-tabs { margin: 0 0 10px; overflow: hidden; border-radius: 10px; background: #fff; box-shadow: 0 5px 16px rgba(43, 73, 112, .08); }
.club-directory-tabs .van-tab { color: #66758a; font-size: 13px; }
.club-directory-tabs .van-tab--active { color: #1989fa; }
.club-directory-list { padding: 0; }
.club-directory-page .table-list-container { margin-top: 0; }
.club-directory-page .table-list-container .table-list-item { margin-bottom: 10px; padding: 0; border: 0; border-radius: 10px; background: #fff; box-shadow: 0 7px 20px rgba(55, 85, 126, .08); }
.club-directory-card { width: 100%; }
.club-directory-cover { width: 100%; height: 150px; overflow: hidden; background: #edf4fc; }
.club-directory-cover .van-image, .club-directory-detail-cover .van-image { display: block; width: 100%; height: 100%; }
.club-directory-image-empty { display: flex; width: 100%; height: 100%; flex-direction: column; row-gap: 8px; align-items: center; justify-content: center; background: #edf4fc; color: #8da1ba; font-size: 13px; }
.club-directory-image-empty .van-icon { font-size: 34px; }
.club-directory-info { min-width: 0; padding: 12px 14px 14px; }
.club-directory-name { margin: 0; color: #303846; font-size: 14px; font-weight: 400; line-height: 21px; overflow-wrap: anywhere; }
.club-directory-meta { margin: 4px 0 0; color: #98a2b2; font-size: 12px; line-height: 17px; }
.club-directory-tags { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 9px; }
.club-directory-info .club-directory-tags { margin-top: 10px; padding-top: 9px; border-top: 1px solid #edf1f6; }
.club-directory-tag { display: inline-flex; padding: 3px 8px; align-items: center; border-radius: 4px; color: #397de0; background: #edf5ff; font-size: 11px; line-height: 16px; }
.club-directory-tag-label { margin-right: 4px; color: #8aa7cf; }
.club-directory-page .table-list-container .table-list-item .item-actions { margin: 0 14px; padding: 10px 0 14px; border-top-color: #edf1f6; }
.club-directory-page .item-actions .van-button { height: 28px; padding: 0 12px; border-radius: 14px; font-size: 12px; }
.club-directory-page .item-actions .club-directory-card-button { height: 32px; padding: 0 14px; border: 0; border-radius: 16px; color: #397de0; background-color: #edf5ff; font-size: 13px; }
.club-directory-page .item-actions .club-directory-card-button i { margin-right: 5px; font-size: 13px; }
.club-directory-page .item-actions .club-directory-card-button--join { width: 108px; color: #fff; background-color: #1989fa; box-shadow: 0 5px 12px rgba(25, 137, 250, .18); }
.club-directory-page .item-actions .club-directory-card-button--exit { width: 108px; color: #fff; background-color: #ee0a24; box-shadow: 0 5px 12px rgba(238, 10, 36, .18); }
.club-directory-loading { padding: 50px 0; text-align: center; }
.club-directory-detail-cover { height: 230px; flex: none; background: #f2f7fc; }
.club-directory-page .club-user-join-detail__content { position: relative; margin-top: -16px; padding: 0 0 18px; }
.club-directory-detail-heading { padding: 16px 14px 12px; }
.club-directory-detail-heading .club-directory-name { margin-bottom: 6px; font-size: 18px; font-weight: 600; }
.club-directory-detail-meta { display: flex; align-items: center; gap: 5px; margin-top: 6px; color: #8b9bb0; font-size: 12px; line-height: 20px; }
.club-directory-detail-meta .van-icon { color: #1989fa; font-size: 14px; }
.club-directory-detail-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0 14px; padding: 16px 0; border-top: 1px solid #eef2f7; }
.club-directory-detail-stat { min-width: 0; padding: 0 8px; text-align: center; }
.club-directory-detail-stat + .club-directory-detail-stat { border-left: 1px solid #eef2f7; }
.club-directory-detail-stat span { display: block; margin-bottom: 6px; color: #8b9bb0; font-size: 12px; line-height: 18px; }
.club-directory-detail-stat strong { display: block; color: #40526a; font-size: 14px; font-weight: 400; line-height: 20px; overflow-wrap: anywhere; }
.club-directory-page .club-user-join-card { border-radius: 16px; }
.club-directory-page .club-user-join-info-card .van-cell__value { font-size: 13px; }
.club-directory-page .club-user-join-info-long-cell img { max-width: 100%; height: auto; }
.club-directory-material-preview { position: relative; height: 72px; overflow: hidden; }
.club-directory-material-preview.is-expanded { height: auto; overflow: visible; }
.club-directory-material-preview.is-truncated::after { position: absolute; right: 0; bottom: 0; padding-left: 18px; background: linear-gradient(90deg, transparent, #fff 18px); content: '…'; }
.club-directory-material-content { display: flow-root; overflow-wrap: anywhere; }
.club-directory-material-rich { font-size: 16px; line-height: 1.6; }
.club-directory-material-rich, .club-directory-material-rich * { max-width: 100% !important; box-sizing: border-box; }
.club-directory-material-rich p, .club-directory-material-rich h1, .club-directory-material-rich h2, .club-directory-material-rich h3, .club-directory-material-rich h4, .club-directory-material-rich h5, .club-directory-material-rich h6 { margin: 0; }
.club-directory-material-rich img, .club-directory-material-rich video { height: auto !important; }
.club-directory-material-rich table { width: 100% !important; table-layout: fixed; }
.club-directory-material-toggle { height: 24px; margin-top: 4px; text-align: right; }
.club-directory-material-toggle button { padding: 0; border: 0; background: transparent; color: #1989fa; font-size: 12px; line-height: 24px; cursor: pointer; }
.club-directory-detail-actions { display: flex; flex: none; gap: 12px; padding: 10px 15px calc(10px + env(safe-area-inset-bottom)); background: #fff; border-top: 1px solid #edf2f8; }
.club-directory-detail-actions .van-button { flex: 1; height: 40px; border-radius: 20px; }
.club-directory-detail-actions .van-button--info { border-color: #1989fa; background-color: #1989fa; }
.club-directory-detail-actions .van-button--info.van-button--plain { color: #1989fa; background-color: #fff; }
.club-directory-detail-actions .van-button--danger { color: #fff; border-color: #ee0a24; background-color: #ee0a24; }
.club-directory-detail-actions .van-button--danger i { margin-right: 5px; }
.club-directory-page .empty-state { padding: 0; }
.club-directory-empty { display: flex; min-height: 260px; padding: 26px 18px 32px; align-items: center; justify-content: center; flex-direction: column; box-sizing: border-box; text-align: center; }
.club-directory-empty img { display: block; width: 82%; max-width: 280px; height: auto; border-radius: 12px; object-fit: contain; }
.club-directory-empty__title { margin-top: 14px; color: #50627a; font-size: 15px; line-height: 22px; font-weight: 600; }
.club-directory-empty__hint { margin-top: 5px; color: #9aa7b8; font-size: 12px; line-height: 18px; }
.club-directory-page .van-list__finished-text, .club-directory-page .van-list__loading { color: #9aa7b8; font-size: 13px; line-height: 44px; }
</style>
<div id="app" v-cloak class="club-directory-page">
<van-nav-bar title="社团列表" left-text="返回" left-arrow fixed placeholder @click-left="onNavBack"></van-nav-bar>
<div class="club-directory-content">
<section class="club-directory-hero">
<img class="club-directory-hero__image" src="/assets/mobile/img/club/club-hero-v5.png" alt="社团入会插画">
<div class="club-directory-hero__copy">
<div class="club-directory-hero__title">社团入会</div>
<div class="club-directory-hero__subtitle">以兴趣相聚 · 让生活精彩</div>
</div>
</section>
<van-sticky :offset-top="46">
<div class="club-directory-sticky">
<van-search v-model="pageForm.clubName" class="club-directory-search" placeholder="请输入社团名称搜索" shape="round" clearable @search="doSearch" @clear="doSearch"></van-search>
<van-tabs v-model="activeTab" class="club-directory-tabs" color="#1989fa" @change="joinChange">
<van-tab :title="'可加入社团(' + noJoinCount + ')'" name="available"></van-tab>
<van-tab :title="'已加入社团(' + hasJoinCount + ')'" name="joined"></van-tab>
</van-tabs>
</div>
</van-sticky>
<main class="club-directory-list">
<table-list api="/platform/club/join/list/pageData" :page_form.sync="pageForm" ref="tableListRef" :show_loading_overlay="false" @ready="doSearch" @loading-change="onLoadingChange">
<template #header><div></div></template>
<template v-slot="{row}">
<div v-if="row" class="club-directory-card">
<div class="club-directory-cover">
<van-image v-if="row.thumbnail" :src="row.thumbnail" :alt="row.clubName + '缩略图'" fit="cover"><template #error><div class="club-directory-image-empty"><van-icon name="photo-o"></van-icon><span>暂无图片</span></div></template></van-image>
<div v-else class="club-directory-image-empty"><van-icon name="photo-o"></van-icon><span>暂无图片</span></div>
</div>
<div class="club-directory-info">
<h2 class="club-directory-name">{{ row.clubName }}</h2>
<p class="club-directory-meta">成立时间:{{ row.foundTime || '--' }}</p>
<div class="club-directory-tags">
<span class="club-directory-tag"><span class="club-directory-tag-label">会长</span>{{ row.clubLeader || '--' }}</span>
<span class="club-directory-tag"><span class="club-directory-tag-label">社团类型</span>{{ formatClubType(row.clubType) }}</span>
<span class="club-directory-tag"><span class="club-directory-tag-label">会费标准</span>{{ row.due == null || row.due === '' ? '--' : row.due + ' 元/年' }}</span>
</div>
</div>
</div>
</template>
<template #actions="{row}">
<template v-if="row">
<van-button class="club-directory-card-button" native-type="button" size="small" type="default" @click="onView(row)"><i class="fa fa-eye"></i>查看</van-button>
<van-button v-if="!pageForm.hasJoin" class="club-directory-card-button club-directory-card-button--join" native-type="button" size="small" type="default" :loading="formLoading" @click="onJoin(row)"><i class="fa fa-sign-in"></i>申请加入</van-button>
<van-button v-else class="club-directory-card-button club-directory-card-button--exit" native-type="button" size="small" type="danger" :disabled="formLoading" @click="onExit(row)"><i class="fa fa-sign-out"></i>申请退出</van-button>
</template>
</template>
<template #empty>
<div v-if="listLoading" class="club-directory-loading"><van-loading size="24px" vertical>加载中</van-loading></div>
<div v-else class="club-directory-empty"><img src="/assets/mobile/img/club/club-approval-empty.png" alt="社团空状态插画"><div class="club-directory-empty__title">暂无社团</div><div class="club-directory-empty__hint">当前筛选条件下暂时没有可展示的社团</div></div>
</template>
</table-list>
</main>
</div>
<van-popup v-model="clubDetailVisible" position="right" :style="{ width: '100%', height: '100%' }" get-container="#app" class="club-user-join-popup" :close-on-click-overlay="false" @opened="observeDetailMaterials">
<div class="club-user-join-detail">
<van-nav-bar title="社团详情" left-text="返回" left-arrow fixed @click-left="closeClubDetail"></van-nav-bar>
<div class="club-user-join-detail__scroll">
<div class="club-directory-detail-cover">
<van-image v-if="clubDetail.thumbnail" :src="clubDetail.thumbnail" :alt="clubDetail.clubName + '缩略图'" fit="cover"><template #error><span class="club-directory-image-empty">暂无图片</span></template></van-image>
<div v-else class="club-directory-image-empty">暂无图片</div>
</div>
<main class="club-user-join-detail__content">
<section class="club-user-join-card club-user-join-info-card">
<div class="club-directory-detail-heading">
<h2 class="club-directory-name">{{ clubDetail.clubName }}</h2>
<div class="club-directory-detail-meta">成立时间:{{ clubDetail.foundTime || '--' }}</span></div>
</div>
<div class="club-directory-detail-stats">
<div class="club-directory-detail-stat"><span>社团类型</span><strong>{{ formatClubType(clubDetail.clubType) }}</strong></div>
<div class="club-directory-detail-stat"><span>当前人数</span><strong>{{ clubDetail.currentPeopleNum || 0 }} 人</strong></div>
<div class="club-directory-detail-stat"><span>会费标准</span><strong>{{ clubDetail.due == null || clubDetail.due === '' ? '--' : clubDetail.due + ' 元/年' }}</strong></div>
</div>
</section>
<section class="club-user-join-card club-user-join-info-card">
<h3 class="club-user-join-card__title">人员及联系信息</h3>
<van-cell-group :border="false">
<van-cell title="会长">{{ clubDetail.clubLeader || '--' }}</van-cell>
<van-cell title="秘书长">{{ clubDetail.clubSecretary || '--' }}</van-cell>
<van-cell title="发起人">{{ clubDetail.sponsorName || '--' }}</van-cell>
<van-cell title="报名联系人">{{ clubDetail.concatPersonName || '--' }}</van-cell>
<van-cell title="联系人电话">{{ clubDetail.concatPersonMobile || '--' }}</van-cell>
<van-cell title="联系人邮箱">{{ clubDetail.concatPersonEmail || '--' }}</van-cell>
</van-cell-group>
</section>
<section class="club-user-join-card club-user-join-info-card">
<h3 class="club-user-join-card__title">社团信息</h3>
<van-cell-group :border="false">
<van-cell v-for="section in detailSections" :key="section.key" :title="section.title" class="club-user-join-info-long-cell">
<div class="club-directory-material-preview" :class="{ 'is-expanded': expandedMaterials[section.key], 'is-truncated': overflowingMaterials[section.key] && !expandedMaterials[section.key] }">
<div ref="detailMaterials" :data-material-key="section.key" class="club-directory-material-content">
<template v-if="section.files">
<file-preview v-if="clubDetail[section.key] && clubDetail[section.key].length" :files="clubDetail[section.key]" complete_result></file-preview><span v-else>暂无</span>
</template>
<div v-else-if="clubDetail[section.key]" class="club-directory-material-rich" v-html="clubDetail[section.key]" v-mobile-rich-text="clubDetail[section.key]"></div><span v-else>暂无</span>
</div>
</div>
<div class="club-directory-material-toggle">
<button v-if="overflowingMaterials[section.key]" type="button" :aria-expanded="!!expandedMaterials[section.key]" @click="$set(expandedMaterials, section.key, !expandedMaterials[section.key])">{{ expandedMaterials[section.key] ? '收起' : '展开' }}<van-icon :name="expandedMaterials[section.key] ? 'arrow-up' : 'arrow-down'"></van-icon></button>
</div>
</van-cell>
</van-cell-group>
</section>
</main>
</div>
<div class="club-directory-detail-actions">
<van-button native-type="button" type="info" plain @click="closeClubDetail">返回列表</van-button>
<van-button v-if="!pageForm.hasJoin" native-type="button" type="info" :loading="formLoading" @click="onJoin(clubDetail)">申请加入</van-button>
<van-button v-else native-type="button" type="danger" :disabled="formLoading" @click="onExit(clubDetail)"><i class="fa fa-sign-out"></i>申请退出</van-button>
</div>
</div>
</van-popup>
<van-dialog :value="confirmVisible" title="提示" :message="confirmMessage" show-cancel-button :confirm-button-loading="formLoading" @confirm="confirmExit" @cancel="closeConfirm"></van-dialog>
</div>
<script nonce="${cspNonce!}">
// 仅适配预览字号,保留原始富文本;先读取全部字号,避免父元素缩放后影响子元素的相对字号。
function adaptClubMobileRichText(element, binding) {
if (element._clubMobileRichText === binding.value) return;
element.style.removeProperty('font-size');
element.style.removeProperty('line-height');
const sizes = [element, ...element.querySelectorAll('*')].map((node) => {
const style = window.getComputedStyle(node);
const fontSize = parseFloat(style.fontSize) || 16;
const lineHeight = parseFloat(style.lineHeight) / fontSize || 1.6;
return {node, fontSize, lineHeight};
});
sizes.forEach(({node, fontSize, lineHeight}) => {
// 正文 16px 映射为 14px,大字号适度压缩层级,限制在移动端可读的 10~32px 范围。
const mobileSize = Math.min(32, Math.max(10, 14 * Math.pow(fontSize / 16, 0.75)));
node.style.setProperty('font-size', mobileSize.toFixed(2) + 'px', 'important');
node.style.setProperty('line-height', (mobileSize * Math.min(1.8, Math.max(1.3, lineHeight))).toFixed(2) + 'px', 'important');
});
element._clubMobileRichText = binding.value;
}
new Vue({
el: "#app",
store,
dicts: ['CLUB_REGISTER_TYPE'],
directives: {
'mobile-rich-text': {inserted: adaptClubMobileRichText, componentUpdated: adaptClubMobileRichText}
},
data() {
return {
pageForm: {pageNumber: 1, pageSize: 10, totalCount: 0, clubName: '', hasJoin: false, pageOrderName: 'clubCode', pageOrderBy: 'ascending'},
activeTab: 'available', hasJoinCount: 0, noJoinCount: 0, listLoading: true, formLoading: false,
detailSections: [{key: 'introduce', title: '社团介绍'}, {key: 'purpose', title: '社团宗旨'}, {key: 'rulesFile', title: '社团章程', files: true}, {key: 'yearPlanFile', title: '年度活动计划', files: true}],
expandedMaterials: {}, overflowingMaterials: {},
clubDetailVisible: false, clubDetail: {}, confirmVisible: false, currentRow: null, historyLayerUnsubscribe: null
}
},
computed: {
confirmMessage() { return this.currentRow ? '您确定要申请退出“' + this.currentRow.clubName + '”吗?' : '您确定要申请退出该社团吗?'; }
},
created() {
// 详情和退会确认分别入栈,手机返回时只关闭当前弹层。
const manager = window.h5HistoryLayerManager;
if (manager) {
manager.ensureRegistered('club-directory');
this.historyLayerUnsubscribe = manager.subscribe((layers) => {
this.clubDetailVisible = layers.includes('club-directory-detail');
this.confirmVisible = layers.includes('club-directory-confirm');
});
}
},
beforeDestroy() {
if (this.detailMaterialObserver) this.detailMaterialObserver.disconnect();
if (this.historyLayerUnsubscribe) this.historyLayerUnsubscribe();
const manager = window.h5HistoryLayerManager;
if (manager) manager.unregister('club-directory');
},
methods: {
observeDetailMaterials() {
if (this.detailMaterialObserver) this.detailMaterialObserver.disconnect();
this.$nextTick(() => {
// 富文本图片和附件异步渲染后高度会变化,按实际高度决定是否显示展开入口。
this.detailMaterialObserver = new ResizeObserver((entries) => {
entries.forEach((entry) => {
this.$set(this.overflowingMaterials, entry.target.dataset.materialKey, entry.target.scrollHeight > 72);
});
});
(this.$refs.detailMaterials || []).forEach((element) => this.detailMaterialObserver.observe(element));
});
},
formatClubType(value) {
const option = (this.dict.type.CLUB_REGISTER_TYPE || []).find((item) => String(item.code) === String(value));
return option ? option.name : '--';
},
onNavBack() { const manager = window.h5HistoryLayerManager; if (manager && manager.stack.length) { manager.close(); return; } historyBack(); },
onLoadingChange(state) { this.listLoading = state.loading; },
joinChange(name) { this.$set(this.pageForm, 'hasJoin', name === 'joined'); this.doSearch(); },
doSearch() {
this.$nextTick(() => {
this.$set(this.pageForm, 'pageNumber', 1);
this.$set(this.pageForm, 'totalCount', 0);
this.$refs.tableListRef.doSearch();
});
this.$axios.post('/platform/club/join/list/getCount').then((res) => {
if (res.code === 0) { this.hasJoinCount = res.data.hasJoinCount; this.noJoinCount = res.data.noJoinCount; }
});
},
onView(row) {
this.$axios.post('/platform/club/join/list/findOne', {id: row.id}).then((res) => {
if (res.code !== 0) { this.$toast(res.msg); return; }
this.clubDetail = res.data;
this.expandedMaterials = {};
this.overflowingMaterials = {};
const manager = window.h5HistoryLayerManager;
if (manager) { manager.open('club-directory-detail'); return; }
this.clubDetailVisible = true;
});
},
closeClubDetail() {
const manager = window.h5HistoryLayerManager;
if (manager) { manager.close('club-directory-detail'); return; }
this.clubDetailVisible = false;
},
onJoin(row) {
if (this.formLoading) return;
this.formLoading = true;
this.$axios.post('/platform/club/join/apply/checkApplyClub', {clubId: row.id}).then((res) => {
if (res.code === 0) this.goToPage('/platform/club/join/apply/h5?clubId=' + encodeURIComponent(row.id));
else this.$toast(res.msg);
}).finally(() => { this.formLoading = false; });
},
onExit(row) {
if (this.formLoading) return;
this.currentRow = row;
const manager = window.h5HistoryLayerManager;
if (manager) { manager.open('club-directory-confirm'); return; }
this.confirmVisible = true;
},
closeConfirm(afterClose) {
const manager = window.h5HistoryLayerManager;
if (manager) { manager.close('club-directory-confirm', afterClose); return; }
this.confirmVisible = false;
if (typeof afterClose === 'function') afterClose();
},
confirmExit() {
if (this.formLoading || !this.currentRow) return;
const row = this.currentRow;
this.formLoading = true;
this.closeConfirm(() => {
// 列表返回 clubId,详情返回社团 id;统一按退会接口要求传递 clubId。
this.$axios.post('/platform/club/join/apply/submit', {data: JSON.stringify({clubId: row.clubId || row.id}), mode: false}).then((res) => {
this.$toast(res.msg);
if (res.code === 0) this.goToPage('/platform/club/join/mine/h5');
}).finally(() => { this.formLoading = false; });
});
},
goToPage(url) {
const manager = window.h5HistoryLayerManager;
if (manager) { manager.closeAllAndNavigate(url); return; }
this.$pjaxReplace(url);
}
}
})
</script>
<!--#
}
#-->
@@ -70,6 +70,10 @@ layout("/layouts/platform_h5.html"){
<section class="club-user-join-card club-user-join-info-card">
<h3 class="club-user-join-card__title">社团基础信息</h3>
<van-cell-group :border="false">
<van-cell title="社团缩略图" class="club-user-join-info-long-cell">
<van-image v-if="clubDetail.thumbnail" :src="clubDetail.thumbnail" width="100%" height="180" fit="contain"><template #error>暂无图片</template></van-image>
<span v-else>暂无图片</span>
</van-cell>
<van-cell title="社团名称">{{ clubDetail.clubName || '--' }}</van-cell>
<van-cell title="成立时间">{{ clubDetail.foundTime || '--' }}</van-cell>
<van-cell title="社团编码">{{ clubDetail.clubCode || '--' }}</van-cell>
@@ -3,8 +3,52 @@ layout("/layouts/platform_h5.html"){
#-->
<style>
.health-checkup-cards .table-list-item .img-container {
border-radius: 10px;
overflow: hidden;
}
.health-checkup-cards .table-list-item .img-container img {
border-radius: 10px;
}
/* 仅调整体检卡片,避免窄屏下时间和标签被通用图片区域高度截断。 */
.health-checkup-cards .table-list-content {
max-height: none !important;
overflow: visible !important;
}
.health-checkup-card-title {
margin-bottom: 10px;
font-size: 15px;
line-height: 22px;
font-weight: 600;
color: #263548;
overflow-wrap: anywhere;
}
.health-checkup-card-details {
width: 100%;
min-width: 0;
}
.health-checkup-card-time {
display: flex;
gap: 6px;
margin-bottom: 6px;
color: #56657a;
font-size: 12px;
line-height: 18px;
}
.health-checkup-card-time > span:first-child {
flex-shrink: 0;
color: #98a3b3;
}
.health-checkup-card-time > span:last-child {
min-width: 0;
overflow-wrap: anywhere;
}
.health-checkup-card-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
</style>
<div id="app">
@@ -26,15 +70,27 @@ layout("/layouts/platform_h5.html"){
</van-tabs>
</van-sticky>
<table-list api="/platform/healthCheckup/h5/pageData" :page_form.sync="pageForm" ref="tableListRef"
<table-list class="health-checkup-cards" api="/platform/healthCheckup/h5/pageData" :page_form.sync="pageForm" ref="tableListRef"
@ready="doSearch" img="cover">
<template #header>
<div></div>
</template>
<template v-slot="{index,row}">
<table-column label="开始时间">
{{$moment(row.choiceTimeStart).format('MM/DD HH:mm')}}
</table-column>
<table-column label="结束时间">
{{$moment(row.choiceTimeEnd).format('MM/DD HH:mm')}}
</table-column>
<div class="health-checkup-card-details">
<div class="health-checkup-card-title">{{row.name}}</div>
<div class="health-checkup-card-time">
<span>开始时间</span>
<span>{{row.choiceTimeStart ? $moment(row.choiceTimeStart).format('YYYY-MM-DD HH:mm') : '—'}}</span>
</div>
<div class="health-checkup-card-time">
<span>结束时间</span>
<span>{{row.choiceTimeEnd ? $moment(row.choiceTimeEnd).format('YYYY-MM-DD HH:mm') : '—'}}</span>
</div>
<div class="health-checkup-card-tags">
<van-tag plain type="primary">{{row.healthCheckupType === 'jzg' ? '教职工' : row.healthCheckupType === 'js' ? '家属' : (row.healthCheckupType || '未设置对象类型')}}</van-tag>
<van-tag plain type="success">{{row.subjectCount}} 个体检套餐</van-tag>
</div>
</div>
</template>
<template #actions="{index,row}">
<template v-if="$moment().isBefore($moment(row.choiceTimeEnd))">
@@ -2,6 +2,48 @@
layout("/layouts/platform_h5.html"){
#-->
<style>
/* 仅调整体检卡片,避免窄屏下时间和标签被通用图片区域高度截断。 */
.health-checkup-cards .table-list-content {
max-height: none !important;
overflow: visible !important;
}
.health-checkup-card-title {
margin-bottom: 10px;
font-size: 15px;
line-height: 22px;
font-weight: 600;
color: #263548;
overflow-wrap: anywhere;
}
.health-checkup-card-details {
width: 100%;
min-width: 0;
}
.health-checkup-card-time {
display: flex;
gap: 6px;
margin-bottom: 6px;
color: #56657a;
font-size: 12px;
line-height: 18px;
}
.health-checkup-card-time > span:first-child {
flex-shrink: 0;
color: #98a3b3;
}
.health-checkup-card-time > span:last-child {
min-width: 0;
overflow-wrap: anywhere;
}
.health-checkup-card-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
</style>
<div id="app">
<van-nav-bar @click-left="historyBack" left-arrow left-text="返回" title="我的体检" placeholder fixed></van-nav-bar>
<van-sticky offset-top="46px">
@@ -13,14 +55,27 @@ layout("/layouts/platform_h5.html"){
</van-sticky>
<table-list api="/platform/healthCheckup/h5/mineData" :page_form.sync="pageForm" ref="tableListRef"
@ready="doSearch" title="healthCheckupName">
<table-list class="health-checkup-cards" api="/platform/healthCheckup/h5/mineData" :page_form.sync="pageForm" ref="tableListRef"
@ready="doSearch" img="cover">
<template #header>
<div></div>
</template>
<template v-slot="{index,row}">
<table-column label="项目名称">{{row.name}}</table-column>
<table-column label="开始时间">{{row.choiceTimeStart}}</table-column>
<table-column label="结束时间">{{row.choiceTimeEnd}}</table-column>
<table-column label="选择医院">{{row.optionName}}</table-column>
<table-column label="邮寄地址">{{row.receiveAddress}}</table-column>
<div class="health-checkup-card-details">
<div class="health-checkup-card-title">{{row.name}}</div>
<div class="health-checkup-card-time">
<span>开始时间</span>
<span>{{row.choiceTimeStart ? $moment(row.choiceTimeStart).format('YYYY-MM-DD HH:mm') : '—'}}</span>
</div>
<div class="health-checkup-card-time">
<span>结束时间</span>
<span>{{row.choiceTimeEnd ? $moment(row.choiceTimeEnd).format('YYYY-MM-DD HH:mm') : '—'}}</span>
</div>
<div class="health-checkup-card-tags">
<van-tag plain type="primary">{{row.healthCheckupType === 'jzg' ? '教职工' : row.healthCheckupType === 'js' ? '家属' : (row.healthCheckupType || '未设置对象类型')}}</van-tag>
<van-tag plain type="success">{{row.subjectCount}} 个体检套餐</van-tag>
</div>
</div>
</template>
<template #actions="{index,row}">
<div class="action-btn" @click="edit(row)" v-if="new Date().getTime() < new Date(row.choiceTimeEnd).getTime()">
@@ -30,6 +30,25 @@ layout("/layouts/platform_h5.html"){
</button>
</div>
<!-- 年度组件已由 platform_h5.html 注册,两个荣誉分类共用所选年度。 -->
<div class="honor-year-filter">
<div class="honor-year-filter__label">
<van-icon name="calendar-o"></van-icon>
<span>荣誉年度</span>
</div>
<van-dropdown-menu class="honor-year-filter__menu" active-color="#bd0018">
<year-van-dropdown-item
ref="yearFilter"
:num="10"
:value="currentYear"
@input="setCurrentYear"
@change="listHonor"
@open="onYearFilterOpen"
@close="onYearFilterClose">
</year-van-dropdown-item>
</van-dropdown-menu>
</div>
<section class="honor-intro">
<div class="honor-intro__corner honor-intro__corner--left"></div>
<div class="honor-intro__corner honor-intro__corner--right"></div>
@@ -38,7 +57,7 @@ layout("/layouts/platform_h5.html"){
<p>致敬劳模先进,礼赞奋斗精神。</p>
</section>
<van-loading v-if="loading" class="honor-loading" color="#fff">加载中...</van-loading>
<van-loading v-if="tableLoading" class="honor-loading" color="#fff">加载中...</van-loading>
<div v-else class="honor-list">
<article v-for="item in honorList" :key="item.id" class="honor-card">
@@ -57,7 +76,10 @@ layout("/layouts/platform_h5.html"){
</div>
</article>
<van-empty v-if="honorList.length === 0" image="search" description="暂无当年荣誉"></van-empty>
<van-empty v-if="honorList.length === 0" class="honor-empty" image="search"
:description="'暂无' + currentYear + '年度荣誉'">
<div class="honor-empty__hint">可切换年度查看往年荣誉</div>
</van-empty>
</div>
</div>
</div>
@@ -65,7 +87,7 @@ layout("/layouts/platform_h5.html"){
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
data: function () {
data() {
return {
activeTab: "labor",
currentYear: new Date().getFullYear(),
@@ -75,14 +97,32 @@ layout("/layouts/platform_h5.html"){
studio: null
},
honorList: [],
loading: false
tableLoading: false,
honorRequestId: 0,
popupHistoryToken: "",
yearFilterOpened: false,
yearHistoryClosing: false
}
},
methods: {
goBack: function () {
// 年度下拉展开时,导航返回只消费弹层历史,不离开当前荣誉页面。
goBack() {
if (this.yearHistoryClosing) {
return
}
if (window.h5PopupHistory.close(this.popupHistoryToken, "honor-year")) {
this.$set(this, "yearHistoryClosing", true)
return
}
this.returnHome()
},
returnHome: function () {
returnHome() {
// 页面跳转前先清理年度弹层历史,避免返回后恢复已关闭的下拉框。
window.h5PopupHistory.clear(this.popupHistoryToken).then(() => {
this.navigateHome()
})
},
navigateHome() {
if (window.store && typeof window.store.commit === "function") {
window.store.commit("setActiveTarBar", "home")
}
@@ -90,13 +130,13 @@ layout("/layouts/platform_h5.html"){
window.location.replace("/platform/h5/home")
return
}
var fallbackTimer = window.setTimeout(function () {
var fallbackTimer = window.setTimeout(() => {
window.location.replace("/platform/h5/home")
}, 1200)
$(document).one("pjax:complete", function () {
$(document).one("pjax:complete", () => {
window.clearTimeout(fallbackTimer)
})
$(document).one("pjax:error pjax:timeout", function () {
$(document).one("pjax:error pjax:timeout", () => {
window.clearTimeout(fallbackTimer)
window.location.replace("/platform/h5/home")
})
@@ -109,11 +149,43 @@ layout("/layouts/platform_h5.html"){
timeout: 8000
})
},
switchTab: function (tab) {
// 两个分类共用一个年度值,切换分类时保留当前选择。
setCurrentYear(year) {
// 公共年度组件返回 Number;先更新年度,再由 change 事件查询对应记录。
this.$set(this, "currentYear", year)
},
// 浏览器历史回退完成前不重复打开,避免快速点击残留额外历史记录。
onYearFilterOpen() {
if (this.yearHistoryClosing) {
this.closeYearFilter()
return
}
this.$set(this, "yearFilterOpened", true)
window.h5PopupHistory.open(this.popupHistoryToken, "honor-year")
},
// 选择年份、点击遮罩或再次点击标题关闭时,同步回退对应弹层历史。
onYearFilterClose() {
if (!this.yearFilterOpened) {
return
}
this.$set(this, "yearFilterOpened", false)
if (!this.yearHistoryClosing) {
this.$set(this, "yearHistoryClosing",
window.h5PopupHistory.close(this.popupHistoryToken, "honor-year"))
}
},
closeYearFilter() {
// 公共年度组件的根组件为 van-dropdown-item,复用其 toggle 关闭能力。
const yearFilter = this.$refs.yearFilter
if (yearFilter && yearFilter.$children[0]) {
yearFilter.$children[0].toggle(false)
}
},
switchTab(tab) {
if (this.activeTab === tab) {
return
}
this.activeTab = tab
this.$set(this, "activeTab", tab)
this.listHonor()
},
getDisplayName: function (item) {
@@ -143,33 +215,46 @@ layout("/layouts/platform_h5.html"){
}
return target || null
},
listHonor: function () {
var self = this
var type = this.activeTab === "studio" ? this.honorTypeMap.studio : this.honorTypeMap.labor
/**
* 按当前年度和分类请求荣誉:year 为年度数字,honorType 为分类 ID,
* pageNumber/pageSize 固定为第一页 50 条;返回 res.data.list 为荣誉对象数组。
* 请求序号用于避免快速切换年度、分类时旧列表或旧图片覆盖最新查询结果。
*/
listHonor() {
const requestId = this.honorRequestId + 1
this.$set(this, "honorRequestId", requestId)
const type = this.activeTab === "studio" ? this.honorTypeMap.studio : this.honorTypeMap.labor
this.$set(this, "honorList", [])
if (!type) {
this.honorList = []
this.$set(this, "tableLoading", false)
return
}
this.loading = true
this.tableLoading = true
this.$axios.post("/platform/honor/manage/h5Data", {
year: this.currentYear,
honorType: type.id,
pageNumber: 1,
pageSize: 50
}).then(function (res) {
}).then((res) => {
if (requestId !== this.honorRequestId || this._isDestroyed) {
return
}
if (res.code === 0) {
var list = (res.data && res.data.list) || []
self.honorList = list.map(function (item) {
item.cover = ""
item.photoId = self.getFirstPhotoId(item.photoFiles)
return item
})
self.loadPhotoMap()
} else {
self.honorList = []
const list = (res.data && res.data.list) || []
this.$set(this, "honorList", list.map((item) => {
return Object.assign({}, item, {
cover: "",
photoId: this.getFirstPhotoId(item.photoFiles)
})
}))
this.loadPhotoMap(requestId)
}
}).catch(() => {
// 错误由全局 axios 拦截器提示;保留当前年度空列表,不显示上一年度记录。
}).finally(() => {
if (requestId === this.honorRequestId && !this._isDestroyed) {
this.tableLoading = false
}
}).finally(function () {
self.loading = false
})
},
getFirstPhotoId: function (photoFiles) {
@@ -194,37 +279,57 @@ layout("/layouts/platform_h5.html"){
data = String(data)
return data.indexOf("=") > -1 ? data.substring(data.lastIndexOf("=") + 1) : data
},
loadPhotoMap: function () {
var self = this
var ids = this.honorList.map(function (item) {
// ids 为照片 ID 的 JSON 数组字符串,返回 res.data 为含 id、thumbnail、downloadPath 的文件数组。
loadPhotoMap(requestId) {
var ids = this.honorList.map((item) => {
return item.photoId
}).filter(function (id) {
}).filter((id) => {
return !!id
})
if (!ids.length) {
return
}
this.$axios.post("/platform/sys/file/previewFileData", { ids: JSON.stringify(ids) }).then(function (res) {
if (res.code !== 0) {
this.$axios.post("/platform/sys/file/previewFileData", { ids: JSON.stringify(ids) }).then((res) => {
if (res.code !== 0 || requestId !== this.honorRequestId || this._isDestroyed) {
return
}
var fileMap = {}
;(res.data || []).forEach(function (file) {
fileMap[file.id] = file.thumbnail || file.downloadPath || ""
})
self.honorList = self.honorList.map(function (item) {
item.cover = fileMap[item.photoId] || ""
return item
;(res.data || []).forEach((file) => {
// 封面优先使用原图,避免放大 100×100 缩略图;原图地址缺失时才回退缩略图。
fileMap[file.id] = file.downloadPath || file.thumbnail || ""
})
this.$set(this, "honorList", this.honorList.map((item) => {
return Object.assign({}, item, { cover: fileMap[item.photoId] || "" })
}))
}).catch(() => {
// 照片加载失败沿用卡片已有占位内容,错误提示交给全局拦截器。
})
}
},
created: function () {
var self = this
this.getHonorTypes().then(function () {
self.listHonor()
created() {
// 全局历史管理器统一处理系统返回;回调先同步状态,避免 close 事件重复回退。
this.$set(this, "popupHistoryToken", window.h5PopupHistory.register(() => {
this.$set(this, "yearFilterOpened", false)
this.$set(this, "yearHistoryClosing", false)
this.closeYearFilter()
}))
this.getHonorTypes().then(() => {
if (!this._isDestroyed) {
this.listHonor()
}
})
},
mounted() {
// PJAX 替换页面不会自动销毁根 Vue,替换前注销年度弹层回调及页面监听。
$(document).one("pjax:beforeReplace.honorYear", () => {
this.$destroy()
})
},
beforeDestroy() {
window.h5PopupHistory.unregister(this.popupHistoryToken)
$(document).off("pjax:beforeReplace.honorYear")
},
// 本项目将 /deep/ 直接替换为作用域属性,须紧贴页面元素;伪元素须放在 /deep/ 后。
style: /*language=CSS*/ `
body {
background: #c80019;
@@ -281,7 +386,7 @@ layout("/layouts/platform_h5.html"){
color: #fff6d9;
}
.honor-tab.active::after {
.honor-tab.active/deep/::after {
content: "";
position: absolute;
left: 50%;
@@ -294,6 +399,82 @@ layout("/layouts/platform_h5.html"){
box-shadow: 0 2px 8px rgba(255, 230, 163, .38);
}
.honor-year-filter {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 52px;
margin-bottom: 12px;
padding: 6px 10px 6px 14px;
box-sizing: border-box;
border: 1px solid rgba(255, 230, 163, .14);
border-radius: 8px;
color: #ffe6a3;
background: rgba(255, 255, 255, .12);
}
.honor-year-filter__label {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
font-weight: 400;
white-space: nowrap;
}
.honor-year-filter__label/deep/ .van-icon {
font-size: 24px;
}
.honor-year-filter/deep/ .honor-year-filter__menu {
flex: 0 0 106px;
}
.honor-year-filter/deep/ .van-dropdown-menu__bar {
height: 38px;
border: 1px solid #ffe6a3;
border-radius: 24px;
background: rgba(151, 0, 17, .48);
box-shadow: none;
}
.honor-year-filter/deep/ .van-dropdown-menu__title {
color: #ffe6a3 !important;
font-size: 15px;
font-weight: 400;
line-height: 24px;
padding: 0 18px 0 4px;
}
.honor-year-filter/deep/ .van-dropdown-menu__title::after {
right: 4px;
border-color: transparent transparent #ffe6a3 #ffe6a3;
opacity: 1;
}
.honor-year-filter/deep/ .van-dropdown-item__content {
max-height: 55vh;
border-radius: 0 0 12px 12px;
}
.honor-list/deep/ .honor-empty .van-empty__description {
padding: 0;
color: #fff1dc;
font-size: 14px;
font-weight: 400;
}
.honor-list/deep/ .honor-empty .van-empty__bottom {
margin-top: 8px;
}
.honor-list/deep/ .honor-empty__hint {
color: rgba(255, 233, 189, .68);
font-size: 12px;
font-weight: 400;
line-height: 20px;
}
.honor-intro {
position: relative;
margin-bottom: 18px;
@@ -337,7 +337,7 @@ layout("/layouts/platform_h5.html"){
</template>
</van-cell>
<van-popup position="bottom" round :close-on-click-overlay="true" v-model:show="showReimburseProjectPicker" class="union-half-popup">
<van-picker :columns="reimburseProjectColumns" @cancel="closePopup('showReimburseProjectPicker')"
<van-picker :columns="availableReimburseProjectColumns" @cancel="closePopup('showReimburseProjectPicker')"
@confirm="onReimburseProjectConfirm" show-toolbar></van-picker>
</van-popup>
<van-field
@@ -680,6 +680,12 @@ layout("/layouts/platform_h5.html"){
required
is-link
></van-field>
<van-field v-if="isManualReimburseAmount"
label="报销金额" name="money"
v-model="formData.money"
placeholder="请输入报销金额,最多两位小数"
:rules="[{ required: true, message: '请填写报销金额' }, { validator: () => !getReimburseAmountMessage(), message: getReimburseAmountMessage }]"
required></van-field>
<van-popup position="bottom" round :close-on-click-overlay="true" v-model:show="showActivityTimePicker" class="union-half-popup">
<van-datetime-picker type="date" title="选择活动时间" :min-date="minDate" :max-date="maxDate"
@cancel="closePopup('showActivityTimePicker')"
@@ -692,8 +698,9 @@ layout("/layouts/platform_h5.html"){
<section class="form-section">
<h2 class="form-section__title">附件信息</h2>
<van-cell-group :border="false">
<van-field class="more-text reimburse-upload-field" name="files" required
label="" :rules="[{ required: true, message: '请上传附件' }]">
<van-field class="more-text reimburse-upload-field" name="files"
:required="!['UNION_REIMBURSE_PROJECT_2', 'UNION_REIMBURSE_PROJECT_3', 'UNION_REIMBURSE_PROJECT_5'].includes(formData.reimburseProject)"
label="" :rules="['UNION_REIMBURSE_PROJECT_2', 'UNION_REIMBURSE_PROJECT_3', 'UNION_REIMBURSE_PROJECT_5'].includes(formData.reimburseProject) ? [] : [{ required: true, message: '请上传附件' }]">
<template #input>
<h5-file-upload
slot="input"
@@ -706,6 +713,11 @@ layout("/layouts/platform_h5.html"){
></h5-file-upload>
</template>
</van-field>
<van-cell v-if="formData.reimburseProject === 'UNION_REIMBURSE_PROJECT_5'">
<template #title>
<span style="color: #ee0a24;">社团活动附件说明</span>
</template>
</van-cell>
</van-cell-group>
</section>
<van-cell-group class="form-section">
@@ -762,10 +774,10 @@ layout("/layouts/platform_h5.html"){
</van-cell-group>
<van-cell-group class="form-section" v-if="formData.reimburseProject">
<div class="form-section__title">电子发票(选填)</div>
<div class="invoice-tip">无发票时按支付信息汇总报销金额;上传的发票仍须通过验真。</div>
<div class="invoice-tip">{{ isManualReimburseAmount ? '有发票时报销金额不得超过发票总额,无发票仍可提交;上传的发票仍须通过验真。' : '无发票时按支付信息汇总报销金额;上传的发票仍须通过验真。' }}</div>
<van-field label="报销金额"
v-model="formData.money"
placeholder="有发票按发票汇总,无发票按支付信息汇总"
:placeholder="isManualReimburseAmount ? '请在报销信息填写报销金额' : '有发票按发票汇总,无发票按支付信息汇总'"
readonly></van-field>
<van-field label="发票张数"
v-model="formData.invoiceNumber"
@@ -847,6 +859,11 @@ layout("/layouts/platform_h5.html"){
></h5-file-upload>
</template>
</van-field>
<van-cell v-if="formData.reimburseProject === 'UNION_REIMBURSE_PROJECT_5'">
<template #title>
<span style="color: #ee0a24;">社团活动附件说明</span>
</template>
</van-cell>
<van-field label="发票号码"
v-model="detailForm.invoiceNo" readonly
placeholder="请输入发票号码"
@@ -1136,6 +1153,10 @@ layout("/layouts/platform_h5.html"){
}
},
computed: {
isManualReimburseAmount() {
return ["UNION_REIMBURSE_PROJECT_2", "UNION_REIMBURSE_PROJECT_3", "UNION_REIMBURSE_PROJECT_4"]
.includes(this.formData.reimburseProject)
},
// 发票或支付明细变化时同步展示超额提示。
paymentAmountLimitMessage() {
return this.getPaymentAmountLimitMessage(this.formData.paymentDetails || [])
@@ -1147,9 +1168,17 @@ layout("/layouts/platform_h5.html"){
const selectedType = this.typeOptions.find(item => item.id === typeId)
return selectedType ? selectedType.code : ""
},
// 仅系统管理员和校工会管理员可查看真实余额,其他账号统一显示余额遮罩。
// 社团经费按菜单授权查看年度剩余额度,其他经费沿用原余额可见范围。
canViewFundBalance() {
return this.$auth.hasRoleOr(["SYSADMIN", "SCHOOL_UNION_ADMIN"])
|| (this.formData.reimburseFundSource === "UNION_REIMBURSE_FUND_SOURCE_3"
&& this.$auth.hasPermission("unionReimburse.club"))
},
// 社团经费仅可选择社团活动,其他经费不展示社团活动。
availableReimburseProjectColumns() {
const clubFund = this.formData.reimburseFundSource === "UNION_REIMBURSE_FUND_SOURCE_3"
return this.reimburseProjectColumns.filter(item =>
clubFund ? item.value === "UNION_REIMBURSE_PROJECT_5" : item.value !== "UNION_REIMBURSE_PROJECT_5")
},
paymentInfoRows() {
return this.formData.paymentDetails || []
@@ -1233,6 +1262,9 @@ layout("/layouts/platform_h5.html"){
},
"formData.reimburseProject"(newVal) {
this.handleReimburseProjectChange(newVal)
},
"formData.reimburseFundSource"() {
this.syncReimburseProject()
}
},
@@ -1852,6 +1884,27 @@ layout("/layouts/platform_h5.html"){
this.rebuildInvoiceSummary()
})
},
// 手填金额按分比较票面总额;未上传发票时仍允许申请。
getReimburseAmountMessage() {
if (!this.isManualReimburseAmount) return ""
const value = String(this.formData.money == null ? "" : this.formData.money)
if (!value) return "请填写报销金额"
const cents = this.moneyToCents(value)
if (!/^\d+(\.\d{1,2})?$/.test(value) || cents === null || cents <= 0) {
return "报销金额必须为正数,且最多两位小数"
}
const invoices = this.formData.invoiceDetails || []
let invoiceCents = 0
for (let i = 0; i < invoices.length; i++) {
const amount = this.moneyToCents(invoices[i] && invoices[i].invoiceAmount)
if (amount === null || amount <= 0) return "请填写正确的发票金额"
invoiceCents += amount
}
if (invoices.length && cents > invoiceCents) {
return "报销金额不能超过发票总额(" + (invoiceCents / 100).toFixed(2) + "元)"
}
return ""
},
rebuildInvoiceSummary() {
let totalMoney = 0
let fileCount = 0
@@ -1876,10 +1929,18 @@ layout("/layouts/platform_h5.html"){
})
totalMoney = paymentCents / 100
}
this.$set(this.formData, "money", totalMoney ? totalMoney.toFixed(2) : "0.00")
// 三类活动保留手填申请金额,发票及支付明细变化只更新张数。
if (!this.isManualReimburseAmount) {
this.$set(this.formData, "money", totalMoney ? totalMoney.toFixed(2) : "0.00")
}
this.$set(this.formData, "invoiceNumber", String(fileCount))
},
validateInvoiceDetailsBeforeSubmit() {
const amountMessage = this.getReimburseAmountMessage()
if (amountMessage) {
this.$toast.fail(amountMessage)
return false
}
const invoiceDetails = this.formData.invoiceDetails || []
// 发票可不上传;已经添加的发票仍须填写完整并沿用验真流程。
for (let i = 0; i < invoiceDetails.length; i++) {
@@ -2366,6 +2427,15 @@ layout("/layouts/platform_h5.html"){
}
this.closePopup("showPaymentWayPicker")
},
// 新建、切换来源及编辑回显都保持社团经费与社团活动一致。
syncReimburseProject() {
const clubFund = this.formData.reimburseFundSource === "UNION_REIMBURSE_FUND_SOURCE_3"
if (!clubFund && this.formData.reimburseProject !== "UNION_REIMBURSE_PROJECT_5") return
const projectCode = clubFund ? "UNION_REIMBURSE_PROJECT_5" : "UNION_REIMBURSE_PROJECT_2"
const project = this.reimburseProjectColumns.find(item => item.value === projectCode)
this.$set(this.formData, "reimburseProject", project ? project.value : "")
this.$set(this.formData, "reimburseProjectName", project ? project.text : "")
},
onReimburseProjectConfirm(o) {
this.$set(this.formData, "reimburseProjectName", o.text) // 显示名称
this.$set(this.formData, "reimburseProject", o.value) // 字典值
@@ -2441,16 +2511,10 @@ layout("/layouts/platform_h5.html"){
},
// 新建申请默认优先选择分工会经费;若当前用户没有对应权限,则按页面可见权限回退到其它经费来源。
getDefaultFundSource() {
if (this.$auth.hasPermission("unionReimburse.fgh")) {
return "UNION_REIMBURSE_FUND_SOURCE_2"
}
if (this.$auth.hasPermission("unionReimburse.xgh")) {
return "UNION_REIMBURSE_FUND_SOURCE_1"
}
if (this.$auth.hasPermission("unionReimburse.club")) {
return "UNION_REIMBURSE_FUND_SOURCE_3"
}
return ""
const option = this.reimburseFundSourceColumns.find(item => item.value === "UNION_REIMBURSE_FUND_SOURCE_2")
|| this.reimburseFundSourceColumns.find(item => item.value === "UNION_REIMBURSE_FUND_SOURCE_3")
|| this.reimburseFundSourceColumns[0]
return option ? option.value : ""
},
getReimburseFundSourceName(code) {
const matchedFundSource = this.reimburseFundSourceColumns.find(item => item.value === code)
@@ -2485,7 +2549,7 @@ layout("/layouts/platform_h5.html"){
this.$set(this, "reimburseProjectList", reimburseProjectData)
return this.$businessTool.getDictOptions("UNION_REIMBURSE_FUND_SOURCE")
}).then((reimburseFundSourceData) => {
this.$set(this, "reimburseFundSourceColumns", reimburseFundSourceData.map(item => ({value: item.code, text: item.name})))
this.$set(this, "reimburseFundSourceColumns", reimburseFundSourceData.map(item => ({value: item.code, text: item.code === "UNION_REIMBURSE_FUND_SOURCE_3" ? "协会经费" : item.name})))
this.filterReimburseFundSources()
})
},
@@ -2567,7 +2631,8 @@ layout("/layouts/platform_h5.html"){
// 查询社团
queryClubs() {
return this.$businessTool.listClubByRole().then((res) => {
return this.$axios.post("/platform/unionReimburse/apply/listReimburseClubs").then((response) => {
const res = response.data || []
this.$set(this, "clubOptions", res)
this.$set(this, "clubColumns", res.map(item => {
return { text: item.clubName, value: item.id }
@@ -2601,7 +2666,7 @@ layout("/layouts/platform_h5.html"){
url: "/platform/unionReimburse/apply/getUnionBalance"
},
"UNION_REIMBURSE_FUND_SOURCE_3": {
url: "/platform/unionReimburse/apply/getClubBudget"
url: "/platform/unionReimburse/apply/getBudgetBalance"
}
}
@@ -2630,14 +2695,20 @@ layout("/layouts/platform_h5.html"){
// 发送请求
this.$axios.post(apiInfo.url, params)
.then(response => {
// 切换来源或社团后,旧请求不得覆盖当前余额。
if (fundSource !== this.formData.reimburseFundSource
|| (params.clubId && params.clubId !== this.formData.clubId)) return
if (response.code === 0) {
// 显示经费余额(只显示数字,不显示"元")
this.$set(this.formData, "fundBalance", response.data || "0.00")
this.$set(this.formData, "fundBalance", fundSource === "UNION_REIMBURSE_FUND_SOURCE_3"
? Number(response.data || 0).toFixed(2) : (response.data || "0.00"))
} else {
this.$set(this.formData, "fundBalance", "查询失败")
}
})
.catch(error => {
if (fundSource !== this.formData.reimburseFundSource
|| (params.clubId && params.clubId !== this.formData.clubId)) return
console.error("经费余额查询失败:", error)
this.$set(this.formData, "fundBalance", "查询失败")
this.$toast.fail("经费余额查询失败")
@@ -2807,12 +2878,11 @@ layout("/layouts/platform_h5.html"){
this.$set(this.formData, "unionId", user.union.id)
this.$set(this.formData, "unionName", user.union.name)
const defaultFundSource = this.getDefaultFundSource()
this.$set(this.formData, "reimburseFundSource", defaultFundSource)
this.$set(this.formData, "reimburseFundSourceName", this.getReimburseFundSourceName(defaultFundSource))
this.$set(this.formData, "paymentDetails", [])
if (this.formData.reimburseFundSource) {
this.queryFundBalance()
}
this.onReimburseFundSourceConfirm({
value: defaultFundSource,
text: this.getReimburseFundSourceName(defaultFundSource)
})
}
}
},
@@ -632,18 +632,28 @@ layout("/layouts/platform_h5.html"){
<!-- 保存、首次提交和重新提交继续共用原业务入口。 -->
<div class="proposal-actions" v-if="isWriteTime">
<van-button plain type="info" :loading="formLoading && loadingAction === 'save'" :disabled="formLoading" @click="onSave">
<van-button plain type="info" :loading="formLoading && loadingAction === 'save'" :disabled="formLoading || !isDelegate" @click="onSave">
保存草稿
</van-button>
<van-button type="primary" :loading="formLoading && loadingAction === 'submit'" :disabled="formLoading" @click="onSubmit" v-if="!taskId">
<van-button type="primary" :loading="formLoading && loadingAction === 'submit'" :disabled="formLoading || !isDelegate" @click="onSubmit" v-if="!taskId">
提交提案
</van-button>
<van-button type="primary" :loading="formLoading && loadingAction === 'submitAgain'" :disabled="formLoading" @click="onSubmitAgain" v-else>
<van-button type="primary" :loading="formLoading && loadingAction === 'submitAgain'" :disabled="formLoading || !isDelegate" @click="onSubmitAgain" v-else>
重新提交
</van-button>
</div>
</div>
<!-- 非代表提示由统一历史栈控制;确认先清理弹层历史,再返回上一业务页面。 -->
<van-dialog :value="delegateDeniedVisible"
title="提示"
message="当前用户不是代表,无法撰写提案"
confirm-button-text="确认"
:close-on-click-overlay="false"
:close-on-popstate="false"
@confirm="confirmDelegateDenied">
</van-dialog>
<!-- 提案撰写须知弹窗 -->
<van-action-sheet v-model="noticeDialogVisible"
title="提案撰写须知"
@@ -684,6 +694,8 @@ layout("/layouts/platform_h5.html"){
proposalConfig: {},
noticeDialogVisible: false,
delegateDeniedVisible: false,
isDelegate: false,
isWriteTime: true,
formLoading: false,
@@ -699,6 +711,38 @@ layout("/layouts/platform_h5.html"){
}
},
methods: {
// 确认非代表提示后,等待弹层历史回退完成再使用统一页面返回入口。
confirmDelegateDenied() {
window.h5HistoryLayerManager.closeAll(() => historyBack());
},
/**
* 按当前表单的 sessionId 查询登录用户身份,不传客户端用户ID。
* @return {Promise<Boolean>} data 为 true 时允许继续初始化;false 时展示非代表提示。
* 请求异常沿用全局错误提示并保持禁止提交,不将网络或权限错误当作非代表。
*/
checkDelegateIdentity() {
const sessionId = this.formData.sessionId;
this.$set(this, "isDelegate", false);
return this.$axios.post("/platform/h5/proposal/common/isCurrentUserDelegate", {sessionId: sessionId})
.then((res) => {
// 页面退出或届次发生变化后,丢弃旧查询结果,避免在其他页面打开提示。
if (this._isDestroyed || window.h5HistoryLayerManager.pageKey !== this.historyLayerPageKey ||
this.formData.sessionId !== sessionId) {
return false;
}
if (res.code !== 0 || typeof res.data !== "boolean") {
this.$toast("代表身份查询失败,请稍后重试");
throw new Error("代表身份查询结果异常");
}
this.$set(this, "isDelegate", res.data);
if (!res.data) {
window.h5HistoryLayerManager.open("delegate-denied");
}
return res.data;
});
},
handlePageBack() {
if (window.h5HistoryLayerManager.stack.length) {
window.h5HistoryLayerManager.close();
@@ -722,6 +766,8 @@ layout("/layouts/platform_h5.html"){
noticeDialogVisible: "notice"
}[stateName];
if (visible) {
// 初始化或保存、提交期间禁止切换届次,避免并发请求混用不同届次的身份结果。
if (this.formLoading) return;
window.h5HistoryLayerManager.open(layerName);
return;
}
@@ -736,18 +782,22 @@ layout("/layouts/platform_h5.html"){
this.$set(this, "showCommitteePicker", stack.includes("committee-picker"));
this.$set(this, "showSuggestionUnitPicker", stack.includes("suggestion-unit-picker"));
this.$set(this, "noticeDialogVisible", stack.includes("notice"));
this.$set(this, "delegateDeniedVisible", stack.includes("delegate-denied"));
});
},
// 选择器确认事件
onSessionConfirm(value) {
this.formData.sessionId = value.value;
if (this.formLoading) return;
this.$set(this.formData, 'sessionId', value.value);
const selectedOption = this.sessionOptions.find(item => item.id === value.value);
if (selectedOption) {
this.$set(this.formData, 'sessionName', selectedOption.fullName);
}
this.setVisibleState("showSessionPicker", false);
this.meetingChange(value.value);
this.$set(this, "isDelegate", false);
this.formLoading = true;
// 等待选择器对应历史回退完成后再查询,防止新提示被此次返回一起关闭。
window.h5HistoryLayerManager.close("session-picker", () => this.meetingChange(value.value));
},
onSourceConfirm(value) {
@@ -810,7 +860,7 @@ layout("/layouts/platform_h5.html"){
// 保存草稿:校验当前表单后保存 ProposalInfo,接口成功时返回提案数据并回到我的提案。
onSave() {
if (this.formLoading) {
if (this.formLoading || !this.isDelegate) {
return;
}
this.$refs.formRef.validate().then(() => {
@@ -862,7 +912,7 @@ layout("/layouts/platform_h5.html"){
return true;
},
// 提交成功后等待提示和 PJAX 跳转完成,避免跳转期间提前解除按钮的 loading 状态。
// 提交成功后先清理弹层历史,再等待 PJAX 跳转完成,避免残留弹层记录或提前解除 loading。
navigateToMine(delay = 0) {
return new Promise((resolve, reject) => {
const navigate = () => {
@@ -880,16 +930,16 @@ layout("/layouts/platform_h5.html"){
}
};
if (delay > 0) {
setTimeout(navigate, delay);
setTimeout(() => window.h5HistoryLayerManager.closeAll(navigate), delay);
} else {
navigate();
window.h5HistoryLayerManager.closeAll(navigate);
}
});
},
// 首次提交:info 为完整提案 JSON,成功后进入提案流程并返回我的提案。
onSubmit() {
if (this.formLoading) {
if (this.formLoading || !this.isDelegate) {
return;
}
this.$refs.formRef.validate().then(() => {
@@ -934,7 +984,7 @@ layout("/layouts/platform_h5.html"){
// 重新提交:除完整提案 JSON 外携带 taskId,用于继续当前退回任务。
onSubmitAgain() {
if (this.formLoading) {
if (this.formLoading || !this.isDelegate) {
return;
}
this.$refs.formRef.validate().then(() => {
@@ -1002,8 +1052,9 @@ layout("/layouts/platform_h5.html"){
});
},
// 切换教代会后清空跨届次数据,并按类型、代表团、提案方式、撰写时间的顺序重新加载。
// 切换教代会后先校验代表身份,通过后再加载该届次的业务选项。
meetingChange(val) {
this.formLoading = true;
this.$set(this.formData, "typeId", null);
this.$set(this.formData, "typeName", "");
this.typeOptions = [];
@@ -1011,11 +1062,19 @@ layout("/layouts/platform_h5.html"){
this.$set(this.formData, "committeeId", null);
this.$set(this.formData, "delegationName", '');
this.$set(this.formData, "committeeName", '');
return this.listProposalType()
.then(() => this.listDelegation())
.then(() => this.searchMineDelegation())
.then(() => this.listSource())
.then(() => this.checkWriteTime());
return this.checkDelegateIdentity().then((allowed) => {
if (!allowed) return;
return this.listProposalType()
.then(() => this.listDelegation())
.then(() => this.searchMineDelegation())
.then(() => this.listSource())
.then(() => this.checkWriteTime());
}).catch(() => {
// 统一请求拦截器已提示失败;禁止使用未完整加载的届次数据提交。
this.$set(this, "isDelegate", false);
}).finally(() => {
this.formLoading = false;
});
},
// 获取代表团
@@ -1044,20 +1103,33 @@ layout("/layouts/platform_h5.html"){
if (!isModify && this.sessionOptions && this.sessionOptions.length > 0) {
this.$set(this.formData, "sessionId", this.sessionOptions[0].id);
this.$set(this.formData, "sessionName", this.sessionOptions[0].fullName);
}
// 无届次不能查询代表身份,也不能当作非代表提示。
if (!this.formData.sessionId) {
this.$toast("没有可用的教代会,暂时无法撰写提案");
return Promise.resolve();
}
return this.checkDelegateIdentity().then((allowed) => {
if (!allowed) return;
if (isModify) {
// 编辑以提案原届次校验身份,保留已有表单和选择器回显。
return this.listDelegation()
.then(() => this.listSource())
.then(() => this.listProposalType())
.then(() => this.echoPicker());
}
return this.listProposalType()
.then(() => this.listDelegation())
.then(() => this.listSource())
.then(() => this.searchMineDelegation())
.then(() => this.checkWriteTime());
}
// 没有开启的教代会时不继续请求依赖 sessionId 的接口。
if (!this.formData.sessionId) {
return Promise.resolve();
}
return this.listDelegation()
.then(() => this.listSource())
.then(() => this.listProposalType())
.then(() => this.echoPicker());
.then(() => this.checkWriteTime())
.then(() => {
// 初始化请求完成前可能已离开页面,此时不能给新页面追加撰写须知历史。
if (!this._isDestroyed && window.h5HistoryLayerManager.pageKey === this.historyLayerPageKey) {
window.h5HistoryLayerManager.open("notice");
}
});
});
}
});
},
@@ -1118,18 +1190,15 @@ layout("/layouts/platform_h5.html"){
init() {
if (this.bizId) {
this.$axios.post("/platform/proposal/write/detail", {id: this.bizId}).then((res) => {
return this.$axios.post("/platform/proposal/write/detail", {id: this.bizId}).then((res) => {
if (res.code === 0) {
res.data.brief=removeHTMLTag(res.data.brief)
res.data.measures=removeHTMLTag(res.data.measures)
this.$set(this, "formData", res.data);
this.listOpenSession(true);
return this.listOpenSession(true);
}
});
} else {
this.setVisibleState("noticeDialogVisible", true);
this.listOpenSession(false);
// 设置用户信息
this.$set(this.formData, "createUserId", this.$store.state.user.id);
this.$set(this.formData, "createUserName", this.$store.state.user.username);
@@ -1137,12 +1206,20 @@ layout("/layouts/platform_h5.html"){
this.$set(this.formData, "createTime", this.$moment().format("YYYY-MM-DD"));
this.$set(this.formData, "mobile", this.$store.state.user.mobile);
this.$set(this.formData, "unitName", this.$store.state.user.unit?.name);
return this.listOpenSession(false);
}
}
},
created() {
this.registerHistoryLayers();
this.init();
// 从确定届次到完成身份检查及表单初始化期间,保持保存、提交按钮禁用。
this.formLoading = true;
this.init().catch(() => {
// 网络、权限或初始化失败由统一拦截器提示,不放行尚未完成初始化的表单。
this.$set(this, "isDelegate", false);
}).finally(() => {
this.formLoading = false;
});
this.listSuggestUnit();
this.getProposalConfig();
},
@@ -0,0 +1,249 @@
<!--#
layout("/layouts/platform_h5.html"){
#-->
<style>
.inclusive-page { background: #f6f7f9; min-height: 100vh; }
.inclusive-tab-wrap { padding: 10px 12px 8px; background: #fff; box-shadow: 0 2px 8px rgba(8, 23, 118, .08); }
.inclusive-tabs { background: transparent; border-radius: 0; padding: 0; box-shadow: none; }
.inclusive-tabs .van-tabs__wrap { height: 32px; overflow: hidden; }
.inclusive-tabs .van-tabs__nav { display: flex; overflow-x: auto; overflow-y: hidden; flex-wrap: nowrap; -webkit-overflow-scrolling: touch; padding-right: 10px; background: transparent; }
.inclusive-tabs .van-tabs__nav::-webkit-scrollbar { display: none; }
.inclusive-tabs .van-tab { flex: 0 0 auto; padding: 0 5px; }
.inclusive-tabs .van-tab__text { padding: 7px 12px; border-radius: 9px; background: #f2f5f8; color: #303846; font-size: 12px; line-height: 18px; white-space: nowrap; font-weight: 600; }
.inclusive-tabs .van-tab--active .van-tab__text { color: #fff; background: #081776; }
.inclusive-tabs .van-tabs__line { display: none; }
.inclusive-list { padding: 12px 12px calc(22px + env(safe-area-inset-bottom)); }
.inclusive-card { overflow: hidden; margin-bottom: 14px; background: #fff; border-radius: 12px; box-shadow: 0 3px 10px rgba(31, 45, 61, .14); }
.inclusive-cover { width: 100%; height: 150px; overflow: hidden; background: #e8eef6; }
.inclusive-cover img { width: 100%; height: 100%; display: block; }
.inclusive-card-main { display: flex; flex-direction: column; min-width: 0; min-height: 132px; position: relative; padding: 12px 42px 14px 14px; }
.inclusive-tag { display: inline-block; max-width: 120px; padding: 3px 9px; border-radius: 5px; color: #0b63d8; background: #eaf3ff; font-size: 12px; line-height: 18px; }
.inclusive-title { margin-top: 8px; color: #111827; font-size: 16px; line-height: 24px; font-weight: bold; min-height: 24px; }
.inclusive-summary { margin-top: 5px; color: #53657f; font-size: 13px; line-height: 19px; min-height: 38px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.inclusive-date { margin-top: auto; color: #6b7c96; font-size: 12px; display: flex; align-items: center; gap: 4px; }
.inclusive-arrow { color: #8a96a8; position: absolute; right: 14px; bottom: 16px; font-size: 22px; }
.inclusive-empty { min-height: 320px; display: flex; align-items: center; justify-content: center; flex-direction: column; color: #909399; font-size: 14px; background: transparent; }
.inclusive-empty-icon { width: 120px; height: 120px; margin-bottom: 10px; opacity: .56; }
.inclusive-detail-popup { width: 100%; height: 100%; background: #fff; }
.inclusive-detail-popup .van-nav-bar { position: sticky; top: 0; z-index: 1; }
.inclusive-detail-loading { padding: 32px; text-align: center; }
.inclusive-content { padding: 16px; line-height: 1.8; word-break: break-word; }
.inclusive-content img { height: auto; max-width: 100%; }
.inclusive-footer { padding: 12px 12px calc(12px + env(safe-area-inset-bottom)); }
</style>
<div class="inclusive-page" id="app" v-cloak>
<van-nav-bar @click-left="back" fixed left-arrow left-text="返回" placeholder title="幸福工荟"></van-nav-bar>
<van-sticky :offset-top="46">
<div class="inclusive-tab-wrap">
<van-tabs @change="tagChange" class="inclusive-tabs" v-model="activeTag">
<van-tab name="" title="全部"></van-tab>
<van-tab :key="item.value" :name="item.value" :title="item.text" v-for="item in tagOptions"></van-tab>
</van-tabs>
</div>
</van-sticky>
<div class="inclusive-list">
<van-pull-refresh @refresh="onRefresh" v-model="refreshing">
<van-list :immediate-check="false" :error.sync="loadError" error-text="加载失败,点击重试" :finished="finished" :finished-text="tableData.length ? '没有更多了' : ''" @load="pageData" v-model="loading">
<div :key="item.id" @click="openDetail(item)" class="inclusive-card" v-for="item in tableData">
<div class="inclusive-cover"><img :src="previewUrl(item.cover)" v-if="item.cover"></div>
<div class="inclusive-card-main">
<div class="inclusive-tag van-ellipsis">{{item.tagTypeName || '幸福工荟'}}</div>
<div class="inclusive-title van-multi-ellipsis--l2">{{item.title}}</div>
<div class="inclusive-summary">{{item.summary}}</div>
<div class="inclusive-date"><van-icon name="underway-o"></van-icon><span>{{formatDate(item.publishTime || item.createdAt)}}</span></div>
<van-icon class="inclusive-arrow" name="arrow"></van-icon>
</div>
</div>
</van-list>
</van-pull-refresh>
<div class="inclusive-empty" v-if="!loading && tableData.length === 0">
<img class="inclusive-empty-icon" src="https://img01.yzcdn.cn/vant/empty-image-default.png">
<div>暂无数据</div>
</div>
</div>
<!-- 详情在当前列表内从右侧滑入,关闭后保留筛选、分页和列表滚动位置。 -->
<van-popup :value="detailVisible" position="right" class="inclusive-detail-popup" :close-on-click-overlay="false" :close-on-popstate="false">
<van-nav-bar @click-left="closeDetail" left-arrow left-text="返回" title="幸福工荟详情"></van-nav-bar>
<div class="inclusive-detail-loading" v-if="formLoading"><van-loading>加载中...</van-loading></div>
<template v-else>
<van-image :src="previewUrl(detail.cover)" fit="cover" height="220" v-if="detail.cover" width="100%"></van-image>
<van-cell-group v-if="detail.id">
<van-cell :title="detail.title"></van-cell>
<van-cell :value="detail.tagTypeName || '幸福工荟'" title="标签"></van-cell>
<van-cell :value="formatDate(detail.publishTime || detail.createdAt)" title="发布时间"></van-cell>
<van-cell :label="detail.summary" title="摘要"></van-cell>
</van-cell-group>
<van-empty v-if="!detail.id" description="内容不存在或已下架"></van-empty>
<div class="inclusive-content" v-html="detail.content"></div>
<div class="inclusive-footer" v-if="hasLink"><van-button @click="openLink" block type="primary">查看活动页面</van-button></div>
</template>
</van-popup>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
data() {
return {
activeTag: "", tagOptions: [], tableData: [], loading: false, refreshing: false,
finished: false, loadError: false, requesting: false, loadSequence: 0,
detailVisible: false, detailClosing: false, detail: {}, formLoading: false,
detailSequence: 0, popupHistoryToken: "",
pageForm: {pageNumber: 1, pageSize: 6, totalCount: 0, tagType: ""}
}
},
computed: {
hasLink() { return !!(this.detail.linkUrl && this.detail.linkUrl.trim()) }
},
methods: {
previewUrl(cover) { return cover || "" },
formatDate(value) { return value ? moment(value).format("YYYY-MM-DD") : "" },
back() {
if (this.detailVisible) {
this.closeDetail()
return
}
this.historyBack()
},
// 无参数;data 数组中的 text/value 分别对应标签名称和标签编码。
getTagOptions() {
this.$axios.post("/platform/h5/inclusive/tagOptions", {}).then((res) => {
if (res.code === 0) {
this.$set(this, "tagOptions", res.data || [])
} else {
this.$toast(res.msg || "标签加载失败")
}
}).catch(() => {})
},
// 参数为页码、每页数量及标签编码;data.list 是当前页,totalCount 是过滤后的总数。
pageData() {
if (this.requesting) return
this.$set(this, "requesting", true)
this.$set(this, "loading", true)
this.$set(this, "loadError", false)
this.$set(this, "loadSequence", this.loadSequence + 1)
const sequence = this.loadSequence
const params = Object.assign({}, this.pageForm, {tagType: this.activeTag})
this.$axios.post("/platform/h5/inclusive/pageData", params).then((res) => {
// 切换标签或刷新后,旧请求的响应不再追加到新列表。
if (sequence !== this.loadSequence) return
if (res.code === 0) {
const data = res.data || {}
const list = data.list || []
this.$set(this, "tableData", this.tableData.concat(list))
this.$set(this.pageForm, "totalCount", data.totalCount || 0)
this.$set(this, "finished", list.length === 0 || this.tableData.length >= this.pageForm.totalCount)
if (!this.finished) this.$set(this.pageForm, "pageNumber", params.pageNumber + 1)
} else {
this.$set(this, "loadError", true)
this.$toast(res.msg || "查询失败")
}
}, () => {
if (sequence === this.loadSequence) this.$set(this, "loadError", true)
}).finally(() => {
if (sequence === this.loadSequence) {
this.$set(this, "loading", false)
this.$set(this, "refreshing", false)
this.$set(this, "requesting", false)
}
})
},
// 重置分页并使在途响应失效,防止不同标签的数据混在同一个列表中。
resetList() {
this.$set(this, "loadSequence", this.loadSequence + 1)
this.$set(this, "requesting", false)
this.$set(this, "tableData", [])
this.$set(this, "finished", false)
this.$set(this, "loadError", false)
this.$set(this.pageForm, "pageNumber", 1)
this.$set(this.pageForm, "totalCount", 0)
},
tagChange(value) {
this.$set(this, "activeTag", value)
this.resetList()
this.pageData()
},
onRefresh() {
this.resetList()
this.$set(this, "refreshing", true)
this.pageData()
},
// item 为点击的列表记录,id 为内容主键;打开弹框时只增加当前地址的历史记录。
openDetail(item) {
if (!item || !item.id || this.detailVisible) return
this.$set(this, "detail", {})
this.$set(this, "detailVisible", true)
window.h5PopupHistory.open(this.popupHistoryToken, "inclusiveDetail")
this.findOne(item.id)
},
// id 传内容主键;接口返回 Result,data 为已发布详情对象,含正文、封面、标签和活动链接。
findOne(id) {
this.$set(this, "detailSequence", this.detailSequence + 1)
const sequence = this.detailSequence
this.$set(this, "formLoading", true)
this.$axios.post("/platform/h5/inclusive/findOne", {id: id}).then((res) => {
// 关闭或重新打开详情后,旧请求不得覆盖当前弹框内容。
if (sequence !== this.detailSequence) return
this.$set(this, "detail", res.code === 0 && res.data ? res.data : {})
if (res.code !== 0 || !res.data) this.$toast(res.msg || "未查询到活动详情")
}).catch(() => {
// 网络及权限错误由公共拦截器提示;仅清空当前请求对应的详情。
if (sequence === this.detailSequence) this.$set(this, "detail", {})
}).finally(() => {
if (sequence === this.detailSequence) this.$set(this, "formLoading", false)
})
},
// 返回按钮交给全局历史栈处理;防止连续点击触发多次 history.back。
closeDetail() {
if (!this.detailVisible || this.detailClosing) return
this.$set(this, "detailClosing", true)
if (!window.h5PopupHistory.close(this.popupHistoryToken, "inclusiveDetail")) {
this.closeDetailState()
}
},
// 历史回退回调只同步弹框状态,使在途请求失效,不再额外回退历史。
closeDetailState() {
this.$set(this, "detailVisible", false)
this.$set(this, "detailClosing", false)
this.$set(this, "detailSequence", this.detailSequence + 1)
this.$set(this, "formLoading", false)
},
// 先保存目标地址,再清理当前页面弹框历史,避免外链返回后残留空弹框历史。
openLink() {
if (!this.hasLink || this.detailClosing) return
const url = this.detail.linkUrl.trim()
this.$set(this, "detailClosing", true)
window.h5PopupHistory.clear(this.popupHistoryToken).then(() => {
const navigate = () => { window.location.href = url }
if (window.h5HistoryLayerManager) {
window.h5HistoryLayerManager.closeAll(navigate)
} else {
navigate()
}
})
},
// PJAX 直接替换 DOM 不会自动销毁 Vue,主动触发生命周期以注销页面回调。
destroyPage() {
this.$destroy()
}
},
created() {
this.$set(this, "popupHistoryToken", window.h5PopupHistory.register((popupKey) => {
if (popupKey === "inclusiveDetail") this.closeDetailState()
}))
this.getTagOptions()
this.pageData()
},
mounted() {
$(document).on("pjax:beforeReplace.inclusiveDetail", this.destroyPage)
},
beforeDestroy() {
this.$set(this, "loadSequence", this.loadSequence + 1)
this.$set(this, "detailSequence", this.detailSequence + 1)
window.h5PopupHistory.unregister(this.popupHistoryToken)
$(document).off("pjax:beforeReplace.inclusiveDetail", this.destroyPage)
}
})
</script>
<!--#
}
#-->
@@ -3,105 +3,316 @@ layout("/layouts/platform_h5.html"){
#-->
<style>
#app.my-study-page { min-height: 100%; height: auto; box-sizing: border-box; padding-bottom: 24px; padding-bottom: calc(24px + env(safe-area-inset-bottom, 0px)); background: #f7fbff linear-gradient(180deg, #cfe8ff 0, #e5f3ff 160px, #f1f8ff 320px, #f7fbff 480px) no-repeat; color: #26364c; }
.my-study-hero { padding: 24px 16px 18px; background: transparent; }
.my-study-head { display: flex; align-items: center; gap: 8px; }
.my-study-back { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 36px; width: 36px; height: 36px; padding: 0; border: 0; border-radius: 50%; background: #fff; color: #1989fa; font-size: 20px; }
.my-study-heading { margin: 0 0 0 auto; text-align: right; font-size: 22px; line-height: 1.5; font-weight: 700; }
.my-study-subtitle { margin: 4px 0 18px; text-align: right; color: #8192a9; font-size: 12px; }
.my-study-overview { padding: 18px 16px 14px; border-radius: 16px; background: #fff; box-shadow: 0 6px 20px rgba(45, 111, 188, .04); }
.my-study-time-heading { position: relative; min-height: 80px; padding-right: 100px; }
.my-study-book { position: absolute; top: 0; right: 0; width: 92px; height: 80px; background-color: #c9e5ff; -webkit-mask: url("${base!}/assets/platform/img/svg/ant-design_read-filled.svg") center / contain no-repeat; mask: url("${base!}/assets/platform/img/svg/ant-design_read-filled.svg") center / contain no-repeat; transform: rotate(-15deg); pointer-events: none; }
.my-study-time-heading .my-study-time { flex-wrap: wrap; }
.my-study-time-label { display: flex; align-items: center; gap: 6px; color: #7a8ca6; font-size: 12px; }
.my-study-time-label .van-icon { color: #1989fa; font-size: 16px; }
.my-study-time { display: flex; align-items: baseline; gap: 6px; margin-top: 8px; color: #8192a9; font-size: 12px; }
.my-study-time strong { color: #1989fa; font-size: 30px; font-weight: 700; line-height: 1.3; font-variant-numeric: tabular-nums; }
.my-study-time strong:not(:first-child) { margin-left: 8px; }
.my-study-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); padding-top: 16px; margin-top: 16px; border-top: 1px solid #edf2f8; text-align: center; }
.my-study-stat + .my-study-stat { border-left: 1px solid #edf2f8; }
.my-study-stat-value { font-size: 20px; line-height: 1.3; font-weight: 700; font-variant-numeric: tabular-nums; }
.my-study-stat-label { margin-top: 4px; color: #8192a9; font-size: 11px; }
.my-study-main { padding: 0 16px; }
.my-study-list-header { position: sticky; top: 0; z-index: 10; margin: 0 -16px; padding: 12px 16px 14px; background: #f7fbff; }
.my-study-list-header .my-study-tabs { margin-bottom: 0; }
.my-study-section-head { display: flex; align-items: baseline; justify-content: space-between; margin: 2px 0 12px; }
.my-study-section-title { margin: 0; font-size: 17px; font-weight: 700; }
.my-study-section-count { color: #8b9ab0; font-size: 12px; }
.my-study-tabs { display: flex; gap: 8px; margin-bottom: 14px; }
.my-study-tab { flex: 1; padding: 8px 4px; border: 0; border-radius: 9px; background: #eaf1fa; color: #7a8ca6; font-size: 12px; line-height: 1.5; }
.my-study-tab.active { background: #1989fa; color: #fff; font-weight: 600; }
.my-study-list-card { margin-bottom: 12px; padding: 12px; border-radius: 14px; background: #fff; }
.my-study-list-card:last-child { margin-bottom: 0; }
.my-study-course-top { display: flex; align-items: flex-start; gap: 12px; width: 100%; padding: 0; border: 0; background: transparent; text-align: left; color: inherit; }
.my-study-list-cover { position: relative; flex: 0 0 112px; width: 112px; height: 84px; aspect-ratio: 4 / 3; border-radius: 8px; overflow: hidden; background: #edf5ff; }
.my-study-list-cover img { display: block; width: 100%; height: 100%; object-fit: cover; }
.my-study-cover-empty { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; color: #98bde9; font-size: 30px; }
.my-study-list-info { min-width: 0; flex: 1; }
.my-study-list-title { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; color: #26364c; font-size: 14px; font-weight: 600; line-height: 1.5; }
.my-study-status { display: inline-flex; align-items: center; gap: 3px; margin-top: 6px; padding: 2px 6px; border-radius: 5px; color: #1989fa; background: #edf6ff; font-size: 10px; }
.my-study-status.completed { color: #28b583; background: #eafaf3; }
.my-study-progress { display: flex; align-items: center; gap: 10px; margin-top: 14px; }
.my-study-progress .van-progress { flex: 1; }
.my-study-progress-value { flex-shrink: 0; color: #1989fa; font-size: 12px; font-weight: 600; }
.my-study-course-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 10px; }
.my-study-list-meta { min-width: 0; color: #8b9ab0; font-size: 11px; line-height: 1.7; }
.my-study-list-meta span { display: block; }
.my-study-continue { display: inline-flex; align-items: center; gap: 4px; flex-shrink: 0; padding: 7px 12px; border: 0; border-radius: 16px; background: #edf6ff; color: #1989fa; font-size: 12px; line-height: 1.5; }
.my-study-loading { padding: 40px 0; text-align: center; }
.my-study-page button:focus-visible { outline: 2px solid #1989fa; outline-offset: 2px; }
@media (max-width: 359px) { .my-study-main { padding: 0 12px; } .my-study-list-header { margin-right: -12px; margin-left: -12px; padding-right: 12px; padding-left: 12px; } .my-study-list-cover { flex-basis: 96px; width: 96px; height: 72px; } .my-study-course-top { gap: 9px; } }
.my-study-page {
min-height: 100vh;
background: #f3f7fb;
color: #202733;
}
.my-study-hero {
position: relative;
padding: 86px 20px 72px;
color: #ffffff;
background: linear-gradient(135deg, #0ca7dd 0%, #0699d6 58%, #0f8fd7 100%);
overflow: hidden;
}
.my-study-hero::after {
content: "";
position: absolute;
right: -74px;
bottom: -108px;
width: 260px;
height: 260px;
border-radius: 50%;
background: rgba(255, 255, 255, .08);
}
.my-study-back {
position: absolute;
top: 10px;
left: 8px;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 50%;
padding: 0;
border: 0;
background: rgba(15, 23, 42, .22);
color: #ffffff;
font: inherit;
font-size: 24px;
cursor: pointer;
}
.my-study-status {
display: block;
position: relative;
z-index: 1;
}
.my-study-time {
font-size: 15px;
color: rgba(255, 255, 255, .82);
}
.my-study-time strong {
margin: 0 3px;
color: #ffffff;
font-size: 26px;
font-weight: 800;
}
.my-study-stats {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0;
margin-top: 26px;
text-align: center;
}
.my-study-stat-value {
font-size: 23px;
font-weight: 800;
line-height: 1.2;
}
.my-study-stat-label {
margin-top: 7px;
color: rgba(255, 255, 255, .78);
font-size: 13px;
line-height: 1.25;
}
.my-study-main {
position: relative;
margin-top: -36px;
padding: 0 20px 28px;
z-index: 2;
}
.my-study-assistant {
display: flex;
align-items: center;
gap: 14px;
min-height: 70px;
padding: 13px 14px;
border-radius: 8px;
background: #eef6ff;
box-sizing: border-box;
box-shadow: 0 8px 18px rgba(17, 101, 166, .08);
}
.my-study-ai {
flex-shrink: 0;
font-size: 27px;
font-weight: 900;
font-style: italic;
color: #1a75ff;
letter-spacing: 0;
}
.my-study-ai span {
color: #6b46ff;
}
.my-study-assistant-text {
flex: 1;
min-width: 0;
color: #30445c;
font-size: 15px;
line-height: 1.35;
}
.my-study-assistant-btn {
flex-shrink: 0;
padding: 7px 16px;
border-radius: 18px;
color: #5d8fd8;
font-size: 14px;
font-weight: 700;
background: #ffffff;
}
.my-study-section {
margin-top: 20px;
padding: 16px 0;
border-radius: 8px;
background: #ffffff;
}
.my-study-section-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 14px 14px;
}
.my-study-section-title {
font-size: 20px;
font-weight: 800;
}
.my-study-all {
display: inline-flex;
align-items: center;
color: #9aa3b1;
font-size: 13px;
}
.my-study-course-scroll {
display: flex;
gap: 14px;
overflow-x: auto;
overflow-y: hidden;
padding: 0 14px;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.my-study-course-scroll::-webkit-scrollbar {
display: none;
}
.my-study-course-card {
flex: 0 0 154px;
width: 154px;
overflow: hidden;
border-radius: 5px;
background: #f7fbff;
}
.my-study-cover {
width: 154px;
height: 88px;
background: linear-gradient(135deg, #8657f2, #56c4ec);
}
.my-study-cover img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.my-study-cover-empty {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: 10px;
color: #ffffff;
font-size: 14px;
font-weight: 700;
text-align: center;
box-sizing: border-box;
}
.my-study-course-name {
height: 48px;
padding: 8px 10px 0;
color: #293241;
font-size: 15px;
font-weight: 700;
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.my-study-course-foot {
padding: 8px 10px 10px;
color: #b5bdc8;
font-size: 13px;
text-align: right;
}
.my-study-list {
margin-top: 18px;
}
.my-study-list-card {
display: flex;
gap: 12px;
margin-bottom: 12px;
padding: 12px;
border-radius: 8px;
background: #ffffff;
}
.my-study-list-cover {
flex: 0 0 108px;
width: 108px;
height: 66px;
border-radius: 5px;
overflow: hidden;
background: linear-gradient(135deg, #8657f2, #56c4ec);
}
.my-study-list-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.my-study-list-info {
min-width: 0;
flex: 1;
}
.my-study-list-title {
color: #202733;
font-size: 16px;
font-weight: 700;
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.my-study-list-meta {
margin-top: 10px;
color: #8f99a8;
font-size: 13px;
}
</style>
<div id="app" class="my-study-page" v-cloak>
<div class="my-study-hero">
<div class="my-study-head">
<button type="button" class="my-study-back" aria-label="返回" @click="goBack"><van-icon name="arrow-left"></van-icon></button>
<h1 class="my-study-heading">我的学习</h1>
</div>
<p class="my-study-subtitle">记录学习点滴,见证每一次进步</p>
<div class="my-study-overview">
<div class="my-study-time-heading">
<div class="my-study-time-label"><van-icon name="clock-o"></van-icon>累计学习时长</div>
<div class="my-study-time"><strong>{{ summary.studyHour || 0 }}</strong>小时<strong>{{ summary.studyMinute || 0 }}</strong>分钟</div>
<span class="my-study-book" aria-hidden="true"></span>
<button type="button" class="my-study-back" @click="historyBack()" aria-label="返回上一页">
<van-icon name="arrow-left"></van-icon>
</button>
<div class="my-study-status">
<div class="my-study-time">
累计学习时长 <strong>{{ summary.studyHour || 0 }}</strong> 小时 <strong>{{ summary.studyMinute || 0 }}</strong> 分钟
</div>
<div class="my-study-stats">
<div v-for="item in statItems" :key="item.label" class="my-study-stat">
<div class="my-study-stat-value">{{ item.value }}</div>
<div class="my-study-stat-label">{{ item.label }}</div>
</div>
</div>
<div class="my-study-stats">
<div v-for="item in statItems" :key="item.label">
<div class="my-study-stat-value">{{ item.value }}</div>
<div class="my-study-stat-label">{{ item.label }}</div>
</div>
</div>
</div>
<div class="my-study-main">
<div class="my-study-list-header">
<div class="my-study-section-head"><h2 class="my-study-section-title">我学习的课程</h2><span class="my-study-section-count">共 {{ courses.length }} 门</span></div>
<nav class="my-study-tabs" aria-label="学习状态">
<button v-for="tab in statusTabs" :key="tab.value" type="button" class="my-study-tab" :class="{ active: activeStatus === tab.value }" :aria-pressed="activeStatus === tab.value" @click="activeStatus = tab.value">{{ tab.label }}</button>
</nav>
<div class="my-study-assistant">
<div class="my-study-ai"><span>AI</span>学助手</div>
<div class="my-study-assistant-text">来问问AI学习助手,获取专属学习计划</div>
<div class="my-study-assistant-btn">查看</div>
</div>
<div v-if="loading" class="my-study-loading"><van-loading size="22px" color="#1989fa">加载中…</van-loading></div>
<van-empty v-else-if="loadError" description="学习记录加载失败"><van-button round size="small" type="info" @click="loadData">重新加载</van-button></van-empty>
<div v-else class="my-study-list">
<article v-for="course in visibleCourses" :key="course.courseId" class="my-study-list-card">
<button type="button" class="my-study-course-top" @click="openCourse(course)">
<div class="my-study-list-cover">
<div class="my-study-section">
<div class="my-study-section-head">
<div class="my-study-section-title">我学习的课程</div>
<div class="my-study-all" @click="scrollToList">全部 <van-icon name="arrow"></van-icon></div>
</div>
<div class="my-study-course-scroll">
<div
v-for="course in courses"
:key="course.courseId"
class="my-study-course-card"
@click="openCourse(course)">
<div class="my-study-cover">
<img v-if="getCoverUrl(course.cover)" :src="getCoverUrl(course.cover)" :alt="course.courseName">
<div v-else class="my-study-cover-empty"><van-icon name="description"></van-icon></div>
<div v-else class="my-study-cover-empty">{{ course.courseName || '课程' }}</div>
</div>
<div class="my-study-list-info">
<div class="my-study-list-title">{{ course.courseName || '未命名课程' }}</div>
<span class="my-study-status" :class="{ completed: isCompleted(course) }"><van-icon :name="isCompleted(course) ? 'checked' : 'clock-o'"></van-icon>{{ isCompleted(course) ? '已完成' : '学习中' }}</span>
</div>
</button>
<div v-if="Number(course.outlineCount || 0) > 0" class="my-study-progress"><van-progress :percentage="courseProgress(course)" :show-pivot="false" stroke-width="5" :color="isCompleted(course) ? '#2fc78c' : '#1989fa'"></van-progress><span class="my-study-progress-value">{{ courseProgress(course) }}%</span></div>
<div class="my-study-course-foot">
<div class="my-study-list-meta"><span>已完成 {{ course.completedOutlineCount || 0 }} 个章节 · 已学 {{ course.studyTimeText || '0秒' }}</span><span v-if="studyDate(course.latestStudyTime)">最近学习 {{ studyDate(course.latestStudyTime) }}</span></div>
<button type="button" class="my-study-continue" @click="openCourse(course)"><van-icon name="play"></van-icon>{{ isCompleted(course) ? '再次学习' : '继续学习' }}</button>
<div class="my-study-course-name">{{ course.courseName || '未命名课程' }}</div>
<div class="my-study-course-foot">已学完{{ course.completedOutlineCount || 0 }}讲</div>
</div>
</article>
<van-empty v-if="!visibleCourses.length" :description="activeStatus === 'completed' ? '暂无已完成课程' : activeStatus === 'studying' ? '暂无学习中的课程' : '暂无学习课程'"></van-empty>
</div>
<van-empty v-if="!loading && !courses.length" description="暂无学习课程"></van-empty>
</div>
<div ref="courseList" class="my-study-list">
<div
v-for="course in courses"
:key="'list_' + course.courseId"
class="my-study-list-card"
@click="openCourse(course)">
<div class="my-study-list-cover">
<img v-if="getCoverUrl(course.cover)" :src="getCoverUrl(course.cover)" :alt="course.courseName">
<div v-else class="my-study-cover-empty">{{ course.courseName || '课程' }}</div>
</div>
<div class="my-study-list-info">
<div class="my-study-list-title">{{ course.courseName || '未命名课程' }}</div>
<div class="my-study-list-meta">学习进度 {{ course.progressPercent || 0 }}% · {{ course.studyTimeText || '0秒' }}</div>
</div>
</div>
</div>
</div>
</div>
@@ -114,75 +325,35 @@ layout("/layouts/platform_h5.html"){
apiBase: "/platform/learning/study/record",
summary: {},
courses: [],
loading: false,
loadError: false,
activeStatus: "all",
statusTabs: [
{ value: "all", label: "全部" },
{ value: "studying", label: "学习中" },
{ value: "completed", label: "已完成" }
]
loading: false
}
},
computed: {
completedCount() {
return this.courses.filter(course => this.isCompleted(course)).length
},
statItems() {
return [
{ label: "已学课程", value: this.courses.length },
{ label: "学习中", value: this.courses.length - this.completedCount },
{ label: "已完成", value: this.completedCount }
{ label: "已学完课程", value: this.summary.completedCourseCount || 0 },
{ label: "学习专题", value: 0 }
]
},
visibleCourses() {
if (this.activeStatus === "completed") return this.courses.filter(course => this.isCompleted(course))
if (this.activeStatus === "studying") return this.courses.filter(course => !this.isCompleted(course))
return this.courses
}
},
methods: {
goBack() {
if (window.history.length > 1) {
window.history.back()
return
}
pjaxReplace("/platform/learning/course/h5")
},
async loadData() {
this.loading = true
this.loadError = false
try {
const [summaryResp, courseResp] = await Promise.all([
this.$axios.post(this.apiBase + "/h5Summary"),
this.$axios.post(this.apiBase + "/h5Courses")
])
if (summaryResp.code !== 0 || courseResp.code !== 0) {
this.loadError = true
return
if (summaryResp.code === 0) {
this.summary = summaryResp.data || {}
}
if (courseResp.code === 0) {
this.courses = courseResp.data || []
}
this.summary = summaryResp.data || {}
this.courses = courseResp.data || []
} catch (e) {
this.loadError = true
} finally {
this.loading = false
}
},
courseProgress(course) {
const value = Number(course.progressPercent || 0)
return Number.isFinite(value) ? Math.max(0, Math.min(100, Math.round(value))) : 0
},
isCompleted(course) {
return this.courseProgress(course) === 100 && Number(course.outlineCount || 0) > 0
&& Number(course.completedOutlineCount || 0) >= Number(course.outlineCount)
},
studyDate(value) {
if (!value) return ""
const date = new Date(/^\d+$/.test(String(value)) ? Number(value) : String(value).replace(/-/g, "/"))
if (Number.isNaN(date.getTime())) return ""
return date.getFullYear() + "-" + String(date.getMonth() + 1).padStart(2, "0") + "-" + String(date.getDate()).padStart(2, "0")
},
getCoverUrl(cover) {
if (!cover) return ""
if (Array.isArray(cover)) {
@@ -205,7 +376,10 @@ layout("/layouts/platform_h5.html"){
},
openCourse(course) {
if (!course || !course.courseId) return
pjaxReplace("/platform/learning/course/h5/study?id=" + encodeURIComponent(course.courseId))
pjaxReplace("/platform/learning/course/h5/study?id=" + course.courseId)
},
scrollToList() {
this.$refs.courseList && this.$refs.courseList.scrollIntoView({ behavior: "smooth", block: "start" })
}
},
mounted() {
@@ -54,6 +54,54 @@ layout("/layouts/platform_h5.html"){
.member-voluntary__paragraph + .member-voluntary__paragraph {
margin-top: 6px;
}
.member-h5-page.has-fixed-actions {
padding-bottom: calc(120px + env(safe-area-inset-bottom));
}
.member-form-actions {
flex-direction: column;
}
.member-action-buttons,
.member-notice-actions {
display: flex;
gap: 9px;
}
.member-notice-actions .van-button {
height: 42px;
border-radius: 14px;
font-size: 14px;
}
.member-notice-consent {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 5px;
color: #596779;
font-size: 12px;
line-height: 22px;
}
.member-notice-link {
padding: 0;
border: 0;
background: transparent;
color: #1989fa;
font-size: inherit;
}
.member-notice-popup {
box-sizing: border-box;
max-height: 80vh;
padding: 20px 16px calc(16px + env(safe-area-inset-bottom));
}
.member-notice-title {
margin: 0 0 12px;
text-align: center;
font-size: 18px;
color: #263548;
}
.member-notice-content {
max-height: 50vh;
overflow-y: auto;
margin-bottom: 18px;
}
</style>
<div id="app" v-cloak>
<div class="member-h5-page" :class="{'has-fixed-actions': showActionButtons}">
@@ -341,29 +389,36 @@ layout("/layouts/platform_h5.html"){
</van-cell-group>
</section>
<section class="form-section">
<h2 class="member-form-section__title">入会意愿</h2>
<van-cell-group :border="false">
<van-checkbox class="member-voluntary" icon-size="22px"
v-model="formData.isVoluntary" shape="square">
<div class="member-voluntary__paragraph">
<span :style="'color:' + (formData.isVoluntary ? '#1989fa' : '#F56C6C') ">
我自愿申请加入学校工会,并委托学校按规定代为扣缴工会会员会费。
</span>
</div>
<div class="member-voluntary__paragraph">
<span :style="'color:' + (formData.isVoluntary ? '#1989fa' : '#F56C6C') ">
我将严格遵守工会章程,认真执行工会决议,积极参与工会活动,主动融入“学校健康幸福家”建设,为营造温暖、和谐、奋进的校园氛围贡献力量,以实际行动助力学校各项事业发展。
</span>
</div>
</van-checkbox>
</van-cell-group>
</section>
<div v-if="showActionButtons" class="member-form-actions">
<van-button block plain class="member-save-button" :loading="formLoading && loadingAction === 'save'" :disabled="formLoading" @click.submit="onSave">保存草稿</van-button>
<van-button v-if="!taskId" block type="primary" class="member-submit-button" :loading="formLoading && loadingAction === 'submit'" :disabled="formLoading" @click.submit="onSubmit">提交申请</van-button>
<van-button v-else block type="primary" class="member-submit-button" :loading="formLoading && loadingAction === 'submitAgain'" :disabled="formLoading" @click.submit="onFinishTask">重新提交</van-button>
<div class="member-notice-consent">
<van-checkbox :value="hasReadNotice" :disabled="formLoading" shape="square" icon-size="18px"
aria-label="同意入会意愿" @input="onNoticeCheckChange"></van-checkbox>
<span>我已阅读并同意</span>
<button type="button" class="member-notice-link" :disabled="formLoading" @click="openMembershipNotice">《入会意愿》</button>
</div>
<div class="member-action-buttons">
<van-button block plain native-type="button" class="member-save-button" :loading="formLoading && loadingAction === 'save'" :disabled="formLoading" @click="onSave">保存草稿</van-button>
<van-button v-if="!taskId" block type="primary" native-type="button" class="member-submit-button" :loading="formLoading && loadingAction === 'submit'" :disabled="formLoading || !hasReadNotice" @click="onSubmit">提交申请</van-button>
<van-button v-else block type="primary" native-type="button" class="member-submit-button" :loading="formLoading && loadingAction === 'submitAgain'" :disabled="formLoading || !hasReadNotice" @click="onFinishTask">重新提交</van-button>
</div>
</div>
<van-popup :value="showMembershipNotice" position="bottom" round class="member-notice-popup"
:close-on-click-overlay="false" :close-on-popstate="false">
<h2 class="member-notice-title">入会意愿</h2>
<div class="member-voluntary member-notice-content">
<div class="member-voluntary__paragraph">
我自愿申请加入学校工会,并委托学校按规定代为扣缴工会会员会费。
</div>
<div class="member-voluntary__paragraph">
我将严格遵守工会章程,认真执行工会决议,积极参与工会活动,主动融入“学校健康幸福家”建设,为营造温暖、和谐、奋进的校园氛围贡献力量,以实际行动助力学校各项事业发展。
</div>
</div>
<div class="member-notice-actions">
<van-button block native-type="button" @click="cancelMembershipNotice">取消</van-button>
<van-button block native-type="button" type="primary" :disabled="noticeCountdown > 0"
@click="confirmMembershipNotice">{{noticeCountdown > 0 ? '我已知晓(' + noticeCountdown + '秒)' : '我已知晓'}}</van-button>
</div>
</van-popup>
</van-form>
</main>
</div>
@@ -377,12 +432,18 @@ layout("/layouts/platform_h5.html"){
return {
id: GetQueryString("bizId"),
taskId: GetQueryString("taskId"),
// 当前登录人已是会员时,不显示保存和提交按钮。
showActionButtons: true,
// 已入会或申请处理中仍显示操作栏,保存和提交限制由申请状态单独控制。
showActionButtons: false,
applicationBlockMessage: "",
pageLoading: true,
formLoading: false,
loadingAction: "",
historyLayerPageKey: "member-apply-submit",
showMembershipNotice: false,
noticeAcknowledged: false,
noticeCloseRequested: false,
noticeCountdown: 5,
noticeTimer: null,
formData: {
families: []
},
@@ -404,7 +465,51 @@ layout("/layouts/platform_h5.html"){
}
},
methods: {
openMembershipNotice() {
if (this.formLoading || this.showMembershipNotice) return
window.h5HistoryLayerManager.open("membership-notice")
},
onNoticeCheckChange(checked) {
if (checked) {
this.openMembershipNotice()
} else {
this.noticeAcknowledged = false
this.$set(this.formData, "isVoluntary", false)
}
},
// 每次打开都重新计时,只有本次阅读确认后才能勾选,旧草稿不能跳过确认。
startNoticeCountdown() {
this.clearNoticeTimer()
this.noticeAcknowledged = false
this.$set(this.formData, "isVoluntary", false)
this.noticeCountdown = 5
this.noticeTimer = setInterval(() => {
this.noticeCountdown = Math.max(0, this.noticeCountdown - 1)
if (this.noticeCountdown === 0) this.clearNoticeTimer()
}, 1000)
},
clearNoticeTimer() {
if (this.noticeTimer !== null) {
clearInterval(this.noticeTimer)
this.noticeTimer = null
}
},
confirmMembershipNotice() {
if (!this.showMembershipNotice || this.noticeCountdown > 0) return
this.noticeAcknowledged = true
this.$set(this.formData, "isVoluntary", true)
this.noticeCloseRequested = true
window.h5HistoryLayerManager.close("membership-notice")
},
cancelMembershipNotice() {
if (!this.showMembershipNotice) return
this.noticeAcknowledged = false
this.$set(this.formData, "isVoluntary", false)
this.noticeCloseRequested = true
window.h5HistoryLayerManager.close("membership-notice")
},
handlePageBack() {
if (this.showMembershipNotice) return
if (window.h5HistoryLayerManager.stack.length) {
window.h5HistoryLayerManager.close()
return
@@ -433,7 +538,7 @@ layout("/layouts/platform_h5.html"){
},
registerHistoryLayers() {
// 六个选择器共用页面级历史栈,手机返回键只关闭当前最上层选择器。
// 入会意愿仅允许通过底部按钮关闭;其他选择器沿用原返回栈行为。
window.h5HistoryLayerManager.register(this.historyLayerPageKey, (stack) => {
this.$set(this, "showNationPicker", stack.includes("nation-picker"))
this.$set(this, "showDatePicker", stack.includes("birthday-picker"))
@@ -441,6 +546,21 @@ layout("/layouts/platform_h5.html"){
this.$set(this, "showEducationPicker", stack.includes("education-picker"))
this.$set(this, "showAcademicDegreePicker", stack.includes("degree-picker"))
this.$set(this, "showMarriagePicker", stack.includes("marriage-picker"))
const noticeVisible = stack.includes("membership-notice")
// 系统返回时恢复当前弹层历史,保留阅读状态及倒计时,须由底部按钮关闭。
if (!noticeVisible && this.showMembershipNotice && !this.noticeCloseRequested) {
window.h5HistoryLayerManager.open("membership-notice")
return
}
if (noticeVisible && !this.showMembershipNotice) {
this.noticeCloseRequested = false
this.startNoticeCountdown()
} else if (!noticeVisible && this.showMembershipNotice) {
this.clearNoticeTimer()
this.noticeCloseRequested = false
if (!this.noticeAcknowledged) this.$set(this.formData, "isVoluntary", false)
}
this.$set(this, "showMembershipNotice", noticeVisible)
})
},
@@ -479,7 +599,11 @@ layout("/layouts/platform_h5.html"){
},
onSave() {
if (this.formLoading) return
if (this.formLoading || !this.showActionButtons) return
if (this.applicationBlockMessage) {
this.$dialog.alert({ title: "提示", message: this.applicationBlockMessage })
return
}
this.$dialog.confirm({
title: "提示",
message: "您确定要保存吗?",
@@ -504,7 +628,15 @@ layout("/layouts/platform_h5.html"){
},
onSubmit() {
if (this.formLoading) return
if (this.formLoading || !this.showActionButtons) return
if (this.applicationBlockMessage) {
this.$dialog.alert({ title: "提示", message: this.applicationBlockMessage })
return
}
if (!this.hasReadNotice) {
this.$toast.fail("请阅读并勾选入会意愿")
return
}
this.$refs.formRef.validate().then(() => {
if (!this.formData.personalData) {
this.$toast.fail("请填写个人学习及工作经历")
@@ -527,8 +659,7 @@ layout("/layouts/platform_h5.html"){
this.$toast.success(res.msg)
this.$pjaxReplace("/platform/member/apply/mine/h5")
} else {
this.$toast.fail("操作失败")
console.log(res.msg)
this.$dialog.alert({ title: "提示", message: res.msg || "操作失败" })
}
}).finally(() => {
this.formLoading = false
@@ -539,7 +670,15 @@ layout("/layouts/platform_h5.html"){
},
onFinishTask() {
if (this.formLoading) return
if (this.formLoading || !this.showActionButtons) return
if (this.applicationBlockMessage) {
this.$dialog.alert({ title: "提示", message: this.applicationBlockMessage })
return
}
if (!this.hasReadNotice) {
this.$toast.fail("请阅读并勾选入会意愿")
return
}
this.$refs.formRef.validate().then(() => {
if (!this.formData.personalData) {
this.$toast.fail("请填写个人学习及工作经历")
@@ -563,8 +702,7 @@ layout("/layouts/platform_h5.html"){
this.$toast.success(res.msg)
this.$pjaxReplace("/platform/member/apply/mine/h5")
} else {
this.$toast.fail("操作失败")
console.log(res.msg)
this.$dialog.alert({ title: "提示", message: res.msg || "操作失败" })
}
}).finally(() => {
this.formLoading = false
@@ -580,12 +718,21 @@ layout("/layouts/platform_h5.html"){
},
init() {
// 无参数获取当前登录人资料;有 id 时改查已有申请,接口均返回对应的用户/申请对象。
return this.$axios.post("/platform/member/apply/submit/getSelfUserInfo", {}, {h5SkeletonLoading: true})
// 使用服务端实时状态,已有申请携带任务信息以保留合法重新提交。
return this.$axios.post("/platform/member/apply/submit/getSelfUserInfo", {
id: this.id || null,
taskId: this.taskId || null
}, {h5SkeletonLoading: true})
.then((resp) => {
const user = resp.code === 0 && resp.data ? resp.data : this.$store.state.user
// 页面按钮是否展示,以当前登录人的会员状态为准。
this.showActionButtons = !(user && user.member)
if (resp.code !== 0 || !resp.data) {
return this.$dialog.alert({ title: "提示", message: resp.msg || "无法获取申请状态,请稍后重试" })
}
const user = resp.data
this.applicationBlockMessage = user.applicationBlockMessage || ""
this.showActionButtons = true
if (user.applicationBlockMessage) {
this.$dialog.alert({ title: "提示", message: user.applicationBlockMessage })
}
if (this.id) {
return this.$axios.post("/platform/member/apply/submit/findApplyById", { id: this.id }, {h5SkeletonLoading: true})
@@ -666,6 +813,10 @@ layout("/layouts/platform_h5.html"){
this.$set(this.formData, "campusName", "")
}
})
.catch(() => {
this.showActionButtons = false
this.$dialog.alert({ title: "提示", message: "无法获取申请状态,请稍后重试" })
})
},
toChinesNum(num) {
let changeNum = ["零", "一", "二", "三", "四", "五", "六", "七", "八", "九"]
@@ -708,6 +859,9 @@ layout("/layouts/platform_h5.html"){
}
},
computed: {
hasReadNotice() {
return this.noticeAcknowledged && this.formData.isVoluntary === true
},
nationColumns() {
if (this.dict && this.dict.type && this.dict.type.USER_NATION) {
@@ -749,6 +903,7 @@ layout("/layouts/platform_h5.html"){
})
},
beforeDestroy() {
this.clearNoticeTimer()
window.h5HistoryLayerManager.unregister(this.historyLayerPageKey)
}
})
@@ -37,7 +37,7 @@ const home = {
<div class="quick-entry-item"
v-for="item in quickEntries"
@click="menuClick(item)"
:key="item.id">
:key="item.id">
<div class="quick-entry-icon">
<img v-if="getQuickEntryIcon(item)" :src="getQuickEntryIcon(item)" alt="">
<van-icon v-else name="apps-o" size="36"></van-icon>
@@ -51,12 +51,12 @@ const home = {
<!--娲诲姩鍒楄〃-->
<div class="feature-panel">
<div
class="feature-card"
:class="[item.className, { 'feature-card--primary': index === 0 }]"
:style="{ backgroundImage: 'url(' + item.asset + ')' }"
v-for="(item, index) in featuredEntries"
:key="item.name"
@click="featureClick(item)">
class="feature-card"
:class="[item.className, { 'feature-card--primary': index === 0 }]"
:style="{ backgroundImage: 'url(' + item.asset + ')' }"
v-for="(item, index) in featuredEntries"
:key="item.name"
@click="featureClick(item)">
<div class="feature-card__text">
<div class="feature-card__title">{{ item.name }}</div>
<div class="feature-card__desc">{{ item.desc }}</div>
@@ -70,12 +70,25 @@ const home = {
<div class="union-news-panel">
<div class="union-news-panel__head">
<div class="union-news-panel__title">工会动态</div>
<button type="button" class="union-news-panel__title" @click="openSpreadMenu"
:disabled="spreadSections.length < 2" :aria-expanded="spreadMenuVisible">
{{ (spreadSections[spreadActiveIndex] || {}).label || '工会动态' }}
<van-icon v-if="spreadSections.length > 1" name="arrow-down"></van-icon>
</button>
<div class="union-news-panel__more" @click="enterSpreadList">
<span>全部动态</span>
<van-icon name="arrow"></van-icon>
</div>
</div>
<div v-show="spreadMenuVisible" class="union-news-menu-dismiss" @click="closeSpreadMenu"></div>
<div v-show="spreadMenuVisible" class="union-news-menu" role="menu" @keydown.esc="closeSpreadMenu">
<button type="button" role="menuitem" v-for="(section, index) in spreadSections"
:key="section.label + '-' + index" @click="chooseSpreadSection(index)"
:class="{'is-selected': spreadActiveIndex === index}" :aria-current="spreadActiveIndex === index ? 'true' : null">
<span>{{ section.label }}</span>
<van-icon v-if="spreadActiveIndex === index" name="success" color="#1989fa"></van-icon>
</button>
</div>
<div v-if="spreadLoading && !spreadDynamics.length" class="union-news-list union-news-list--skeleton">
<div class="union-news-item union-news-item--skeleton"
v-for="index in 2"
@@ -95,15 +108,19 @@ const home = {
</div>
</div>
</div>
<div class="union-news-list" v-else-if="spreadDynamics.length">
<div class="union-news-list" ref="spreadViewport" v-else-if="spreadDynamics.length">
<div class="union-news-item"
v-for="item in spreadDynamics"
:key="item.id"
:key="item.href || item.title"
@click="enterSpreadDetail(item)">
<img v-if="getSpreadCover(item.thumbUrl)"
<img v-if="item.thumbUrl && !item.imageFailed"
class="union-news-item__cover"
:src="getSpreadCover(item.thumbUrl)"
:src="item.thumbUrl" referrerpolicy="no-referrer"
@error="$set(item, 'imageFailed', true)"
:alt="item.title">
<div v-else-if="spreadHasImages" class="union-news-item__cover union-news-item__placeholder">
<van-icon name="photo-o"></van-icon>
</div>
<div class="union-news-item__content">
<div class="union-news-item__tags">
<span class="union-news-item__tag"
@@ -117,9 +134,10 @@ const home = {
</div>
<div class="union-news-item__meta">
<span>{{ formatSpreadDate(item.publishedAt || item.createdAt) }}</span>
<span class="union-news-item__views">
<span v-if="item.viewCount !== null && item.viewCount !== undefined"
class="union-news-item__views">
<van-icon name="eye-o"></van-icon>
{{ item.viewCount || 0 }}
{{ item.viewCount }}
</span>
</div>
</div>
@@ -210,8 +228,8 @@ const home = {
<van-tag class="act-item-wrapper-tag" v-if="$moment().unix() > $moment(item.endDate).unix()">
已结束
</van-tag>
<van-tag class="act-item-wrapper-tag" type="success"
<van-tag class="act-item-wrapper-tag" type="success"
v-else-if="$moment().unix() > $moment(item.startDate).unix() && $moment().unix() < $moment(item.endDate).unix()">
进行中
</van-tag>
@@ -219,7 +237,7 @@ const home = {
<van-tag class="act-item-wrapper-tag" type="success" v-else-if="$moment().unix() < $moment(item.startDate).unix()">
即将开始
</van-tag>
<div class="act-item-cover">
<van-image v-if="item.cover" width="120" height="84" :src="item.cover"></van-image>
</div>
@@ -247,6 +265,10 @@ const home = {
classroomRecommendOptions: [],
showHomeSummary: false,
homeScrollContainer: null,
spreadSections: [],
spreadMenuVisible: false,
spreadHistoryToken: null,
spreadActiveIndex: 0,
spreadDynamics: [],
spreadLoading: true,
spreadLoadSeq: 0,
@@ -282,7 +304,7 @@ const home = {
action: "查看信息",
asset: "/assets/mobile/img/home/v4/feature-inclusive-v8.png",
className: "feature-card--inclusive",
href: ""
href: "/platform/h5/inclusive"
}
],
appLogo: "",
@@ -311,6 +333,10 @@ const home = {
}
},
computed: {
spreadHasImages() {
// 按当前栏目统一保留图片列;异步补图及加载失败都会重新计算,全部无图时文字占满行宽。
return this.spreadDynamics.some(item => item.thumbUrl && !item.imageFailed)
},
currentBanner() {
return this.bannerList[this.activeBannerIndex] || this.bannerList[0] || {}
}
@@ -516,18 +542,29 @@ const home = {
listSpreadHomeContent() {
const loadSeq = ++this.spreadLoadSeq
this.spreadLoading = true
this.$axios.post("/platform/h5/spread/pageData", {
sortType: "latest",
pageNumber: 1,
pageSize: 10
}).then((res) => {
this.$axios.post("/platform/home/getNews").then((res) => {
if (loadSeq === this.spreadLoadSeq && res.code === 0) {
const data = res.data || {}
this.spreadDynamics = data.list || []
// 接口按栏目返回 href/time/text,移动端保存全部栏目供切换,并映射为现有动态卡片字段。
const sections = (Array.isArray(res.data) ? res.data : []).map(section => ({
label: section.label || "工会动态",
value: (Array.isArray(section.value) ? section.value : []).slice(0, 10).map(item => ({
href: item.href || "",
thumbUrl: /^https?:\/\//i.test(item.thumbUrl || "") ? item.thumbUrl : "",
imageFailed: false,
imageRequested: false,
title: item.text || "",
publishedAt: item.time || "",
categoryName: section.label || "工会动态"
})).filter(item => item.title)
}))
const firstAvailableIndex = sections.findIndex(section => section.value.length)
this.$set(this, "spreadSections", sections)
this.selectSpreadSection(firstAvailableIndex >= 0 ? firstAvailableIndex : 0)
}
}).catch(() => {
if (loadSeq === this.spreadLoadSeq) {
this.spreadDynamics = []
this.$set(this, "spreadSections", [])
this.$set(this, "spreadDynamics", [])
}
}).finally(() => {
if (loadSeq === this.spreadLoadSeq) {
@@ -535,6 +572,48 @@ const home = {
}
})
},
selectSpreadSection(index) {
// 切换栏目后回到横向卡片列表开头,等待新栏目列表渲染完成。
// 新闻样式锚定 home-page 根节点作用域,动态行无需等待 styleMixin 异步补属性。
const section = this.spreadSections[index] || {}
this.$set(this, "spreadActiveIndex", index)
this.$set(this, "spreadDynamics", section.value || [])
this.$nextTick(() => {
if (this.$refs.spreadViewport) this.$refs.spreadViewport.scrollLeft = 0
})
this.loadSpreadImages()
},
loadSpreadImages() {
// href 传原始新闻链接;data 为正文首图地址。每条只补取一次,回填原对象避免快速切换串图。
const missing = this.spreadDynamics.filter(item => !item.thumbUrl && !item.imageRequested)
missing.forEach(item => this.$set(item, "imageRequested", true))
// 串行补图限制外站并发;切换后仍可缓存到对应栏目,不阻塞新闻展示。
missing.reduce((pending, item) => pending.then(() => {
return this.$axios.post("/platform/home/getNewsImage", {href: item.href}).then(res => {
if (res.code === 0 && /^https?:\/\//i.test(res.data || "")) {
this.$set(item, "thumbUrl", res.data)
this.$set(item, "imageFailed", false)
}
}).catch(() => {})
}), Promise.resolve())
},
openSpreadMenu() {
// 栏目选择弹层交由全局历史栈管理,系统返回键仅关闭当前弹层。
if (this.spreadMenuVisible) {
this.closeSpreadMenu()
return
}
if (this.spreadSections.length < 2) return
window.h5PopupHistory.open(this.spreadHistoryToken, "news-section")
this.$set(this, "spreadMenuVisible", true)
},
closeSpreadMenu() {
if (this.spreadMenuVisible) window.h5PopupHistory.close(this.spreadHistoryToken, "news-section")
},
chooseSpreadSection(index) {
this.selectSpreadSection(index)
this.closeSpreadMenu()
},
getSpreadCategoryLabels(item) {
const labels = [item.parentCategoryName, item.childCategoryName].filter(Boolean)
return labels.length ? labels : (item.categoryName ? [item.categoryName] : [])
@@ -561,13 +640,29 @@ const home = {
return first && (first.src || first.url || first.data || (first.response && first.response.data)) || ""
},
formatSpreadDate(value) {
return value ? this.$moment(Number(value)).format("YYYY-MM-DD") : ""
if (!value) {
return ""
}
const numberValue = Number(value)
return Number.isNaN(numberValue) ? String(value) : this.$moment(numberValue).format("YYYY-MM-DD")
},
enterSpreadDetail(item) {
this.$pjaxReplace("/platform/h5/spread/detail?id=" + encodeURIComponent(item.id) + "&from=home")
const url = this.resolveUnionNewsUrl(item.href)
if (url) {
window.location.href = url
}
},
enterSpreadList() {
this.$pjaxReplace("/platform/h5/spread")
window.location.href = "https://xgh.cug.edu.cn/"
},
resolveUnionNewsUrl(href) {
if (!href) {
return ""
}
if (/^https?:\/\//i.test(href)) {
return href
}
return new URL(href, "https://xgh.cug.edu.cn/").href
},
menuClick(item) {
this.$pjaxReplace(item.href)
@@ -606,6 +701,10 @@ const home = {
}
},
created() {
// 注册弹层返回回调,关闭状态由历史回退同步,避免留下多余历史记录。
this.spreadHistoryToken = window.h5PopupHistory.register(() => {
this.$set(this, "spreadMenuVisible", false)
})
this.listHomeBrand()
this.listRecommendApp()
this.listClassroomCourses()
@@ -623,6 +722,8 @@ const home = {
}
},
beforeDestroy() {
const token = this.spreadHistoryToken
window.h5PopupHistory.clear(token).then(() => window.h5PopupHistory.unregister(token))
if (this.homeScrollContainer) {
this.homeScrollContainer.removeEventListener("scroll", this.handleHomeScroll)
}
@@ -1383,7 +1484,8 @@ const home = {
font-size: 14px;
}
.union-news-panel {
.home-page/deep/ .union-news-panel {
position: relative;
margin: 15px 12px 7px;
padding: 13px 12px 8px;
border-radius: 10px;
@@ -1391,21 +1493,29 @@ const home = {
box-sizing: border-box;
}
.union-news-panel__head {
.home-page/deep/ .union-news-panel__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.union-news-panel__title {
.home-page/deep/ .union-news-panel__title {
position: relative;
z-index: 42;
padding: 0;
border: 0;
background: transparent;
display: inline-flex;
align-items: center;
gap: 6px;
color: #202124;
font-size: 15px;
line-height: 21px;
font-weight: 600;
}
.union-news-panel__more {
.home-page/deep/ .union-news-panel__more {
display: flex;
align-items: center;
color: #969799;
@@ -1413,12 +1523,51 @@ const home = {
line-height: 18px;
}
.union-news-panel__more .van-icon {
.home-page/deep/ .union-news-panel__more .van-icon {
margin-left: 4px;
font-size: 13px;
}
.union-news-list {
.home-page/deep/ .union-news-menu-dismiss {
position: fixed;
inset: 0;
z-index: 40;
background: transparent;
}
.home-page/deep/ .union-news-menu {
position: absolute;
top: 40px;
left: 12px;
width: 124px;
padding: 5px;
z-index: 43;
border: 1px solid #e8edf3;
border-radius: 8px;
background: #fff;
box-shadow: 0 5px 16px rgba(35, 55, 80, .12);
}
.home-page/deep/ .union-news-menu button {
width: 100%;
height: 34px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 9px;
border: 0;
border-radius: 4px;
background: #fff;
color: #444;
font-size: 13px;
}
.home-page/deep/ .union-news-menu button.is-selected {
color: #1989fa;
background: #f0f7ff;
}
.home-page/deep/ .union-news-list {
margin-top: 7px;
padding-bottom: 3px;
display: flex;
@@ -1429,42 +1578,42 @@ const home = {
-webkit-overflow-scrolling: touch;
}
.union-news-list::-webkit-scrollbar {
.home-page/deep/ .union-news-list::-webkit-scrollbar {
display: none;
}
.union-news-item--skeleton {
height: 96px;
min-height: 96px;
.home-page/deep/ .union-news-item--skeleton {
height: 100px;
min-height: 100px;
}
.union-news-skeleton__cover {
width: 80px;
height: 48px;
margin-right: 10px;
.home-page/deep/ .union-news-skeleton__cover {
width: 104px;
height: 78px;
margin-right: 12px;
flex-shrink: 0;
border-radius: 4px;
border-radius: 6px;
}
.union-news-skeleton__content {
.home-page/deep/ .union-news-skeleton__content {
min-width: 0;
flex: 1;
}
.union-news-skeleton__tags { height: 15px; margin-bottom: 4px; display: flex; gap: 4px; }
.union-news-skeleton__tag { width: 48px; height: 15px; border-radius: 999px; }
.union-news-skeleton__tag--short { width: 38px; }
.union-news-skeleton__line { height: 10px; border-radius: 3px; }
.union-news-skeleton__line--title { width: 88%; }
.union-news-skeleton__line--summary { width: 72%; margin-top: 7px; }
.union-news-skeleton__meta { margin-top: 8px; display: flex; align-items: center; justify-content: space-between; }
.union-news-skeleton__meta span { width: 55px; height: 8px; border-radius: 3px; }
.union-news-skeleton__meta span:last-child { width: 28px; }
.home-page/deep/ .union-news-skeleton__tags { height: 15px; margin-bottom: 4px; display: flex; gap: 4px; }
.home-page/deep/ .union-news-skeleton__tag { width: 48px; height: 15px; border-radius: 999px; }
.home-page/deep/ .union-news-skeleton__tag--short { width: 38px; }
.home-page/deep/ .union-news-skeleton__line { height: 10px; border-radius: 3px; }
.home-page/deep/ .union-news-skeleton__line--title { width: 88%; }
.home-page/deep/ .union-news-skeleton__line--summary { width: 72%; margin-top: 7px; }
.home-page/deep/ .union-news-skeleton__meta { margin-top: 8px; display: flex; align-items: center; justify-content: space-between; }
.home-page/deep/ .union-news-skeleton__meta span { width: 55px; height: 8px; border-radius: 3px; }
.home-page/deep/ .union-news-skeleton__meta span:last-child { width: 28px; }
.union-news-item {
.home-page/deep/ .union-news-item {
width: calc(100% - 32px);
min-width: calc(100% - 32px);
min-height: 96px;
min-height: 100px;
padding: 10px;
flex-shrink: 0;
display: flex;
@@ -1475,36 +1624,45 @@ const home = {
box-sizing: border-box;
}
.union-news-item + .union-news-item {
.home-page/deep/ .union-news-item + .union-news-item {
border-top: 1px solid #edf1f6;
}
.union-news-item:active {
.home-page/deep/ .union-news-item:active {
background-color: #f7faff;
}
.union-news-item__cover {
width: 80px;
height: 48px;
margin-right: 10px;
.home-page/deep/ .union-news-item__cover {
width: 104px;
height: 78px;
margin-right: 12px;
flex-shrink: 0;
border-radius: 4px;
border-radius: 6px;
object-fit: cover;
}
.union-news-item__content {
.home-page/deep/ .union-news-item__placeholder {
display: flex;
align-items: center;
justify-content: center;
background: #f0f4f8;
color: #9aaabe;
font-size: 26px;
}
.home-page/deep/ .union-news-item__content {
min-width: 0;
flex: 1;
}
.union-news-item__tags {
.home-page/deep/ .union-news-item__tags {
margin-bottom: 4px;
display: flex;
gap: 4px;
overflow: hidden;
}
.union-news-item__tag {
.home-page/deep/ .union-news-item__tag {
max-width: 46%;
padding: 1px 6px;
overflow: hidden;
@@ -1517,12 +1675,12 @@ const home = {
background-color: #eaf4ff;
}
.union-news-item__tag--secondary {
.home-page/deep/ .union-news-item__tag--secondary {
color: #627ea4;
background-color: #f0f4f9;
}
.union-news-item__title {
.home-page/deep/ .union-news-item__title {
overflow: hidden;
color: #202124;
font-size: 13px;
@@ -1532,7 +1690,7 @@ const home = {
white-space: nowrap;
}
.union-news-item__summary {
.home-page/deep/ .union-news-item__summary {
margin-top: 2px;
overflow: hidden;
color: #7d8797;
@@ -1542,7 +1700,7 @@ const home = {
white-space: nowrap;
}
.union-news-item__meta {
.home-page/deep/ .union-news-item__meta {
margin-top: 3px;
display: flex;
align-items: center;
@@ -1552,17 +1710,17 @@ const home = {
line-height: 14px;
}
.union-news-item__views {
.home-page/deep/ .union-news-item__views {
display: inline-flex;
align-items: center;
}
.union-news-item__views .van-icon {
.home-page/deep/ .union-news-item__views .van-icon {
margin-right: 3px;
font-size: 11px;
}
.union-news-empty {
.home-page/deep/ .union-news-empty {
display: flex;
padding: 12px 0 16px;
flex-direction: column;
@@ -1573,7 +1731,7 @@ const home = {
text-align: center;
}
.union-news-empty__image {
.home-page/deep/ .union-news-empty__image {
display: block;
width: 82%;
max-width: 280px;
@@ -1581,7 +1739,7 @@ const home = {
object-fit: contain;
}
.union-news-empty__text {
.home-page/deep/ .union-news-empty__text {
margin-top: 2px;
}