Compare commits
15
Commits
337bcd55bc
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6082aedf03 | ||
|
|
503072e5b1 | ||
|
|
6c0489b66c | ||
|
|
360ce71f0b | ||
|
|
f2fda8202a | ||
|
|
28d8b14bab | ||
|
|
abc2f4d097 | ||
|
|
8a4b1c7ae8 | ||
|
|
0a431bf86e | ||
|
|
406ffe71af | ||
|
|
0de8ee9232 | ||
|
|
9bebd3af82 | ||
|
|
e9866653a9 | ||
|
|
d8313d7822 | ||
|
|
a3c6c77d6b |
@@ -0,0 +1,90 @@
|
||||
# 登山活动 1300 人并发报名压测
|
||||
|
||||
本目录用于本地或已明确授权的测试环境。JMeter 会真实调用登录和报名接口,并向报名表写入数据,禁止直接对生产环境执行。
|
||||
|
||||
## 文件说明
|
||||
|
||||
- `prepare-brand-activity-users.ps1`:从当前项目数据库导出活动范围内、未禁用且尚未报名的人员。
|
||||
- `brand-activity-mountain-signup-1300.jmx`:每线程独立登录,所有线程到达报名步骤后同时提交。
|
||||
- `data/brand-activity-users.csv`:运行准备脚本后生成,不包含密码、手机号、身份证号等敏感数据。
|
||||
|
||||
当前登山活动参数:
|
||||
|
||||
- 活动 ID:`11619eee67574256ad8ccc8a16630023`
|
||||
- 子活动 ID:`986bf4d48a5948e1810857483f9e5af2`
|
||||
- 容量:1500 人
|
||||
|
||||
## 1. 准备人员数据
|
||||
|
||||
在项目根目录执行:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\jmeter\prepare-brand-activity-users.ps1 -UserCount 1300 -PermissionMode AllScope
|
||||
```
|
||||
|
||||
`AllScope` 会导出1300名活动范围人员。根据当前数据库,约290名人员没有报名菜单权限,其报名请求会被权限校验拒绝。
|
||||
|
||||
如果只测试具备权限的人员:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\jmeter\prepare-brand-activity-users.ps1 -UserCount 1010 -PermissionMode Authorized
|
||||
```
|
||||
|
||||
当前数据库只有1010名“有报名权限且尚未报名”的人员,因此 `Authorized` 模式不能生成1300名不同人员。
|
||||
|
||||
## 2. 先进行10人试跑
|
||||
|
||||
启动本地项目,确认80端口和 Redis 6378 可访问,然后执行:
|
||||
|
||||
```powershell
|
||||
jmeter -n -t .\jmeter\brand-activity-mountain-signup-1300.jmx `
|
||||
-Jthreads=10 `
|
||||
-JloginRampSeconds=10 `
|
||||
-Jhost=127.0.0.1 `
|
||||
-Jport=80 `
|
||||
-JusersFile=jmeter/data/brand-activity-users.csv `
|
||||
-l .\jmeter\result-smoke.jtl `
|
||||
-j .\jmeter\jmeter-smoke.log
|
||||
```
|
||||
|
||||
试跑会真实产生报名记录。再次运行前必须重新生成 CSV,以排除已经报名的人员。
|
||||
|
||||
## 3. 执行1300人并发
|
||||
|
||||
确认测试数据库已备份、应用和 Redis 正常、活动仍在报名时间内后执行:
|
||||
|
||||
```powershell
|
||||
jmeter -n -t .\jmeter\brand-activity-mountain-signup-1300.jmx `
|
||||
-Jthreads=1300 `
|
||||
-JloginRampSeconds=60 `
|
||||
-JsyncTimeoutMs=300000 `
|
||||
-Jhost=127.0.0.1 `
|
||||
-Jport=80 `
|
||||
-JusersFile=jmeter/data/brand-activity-users.csv `
|
||||
-l .\jmeter\result-1300.jtl `
|
||||
-j .\jmeter\jmeter-1300.log
|
||||
```
|
||||
|
||||
登录在60秒内逐步完成;所有仍在运行的线程会在“07-集合后并发提交登山活动报名”前等待,然后同时发送报名请求。
|
||||
|
||||
## 4. 生成结果报告
|
||||
|
||||
```powershell
|
||||
jmeter -g .\jmeter\result-1300.jtl -o .\jmeter\report-1300
|
||||
```
|
||||
|
||||
重点查看:
|
||||
|
||||
- `05-人员账号登录`:独立账号登录是否成功。
|
||||
- `06-报名资格预校验`:活动范围、时间、重复报名和名额检查。
|
||||
- `07-集合后并发提交登山活动报名`:真正的并发报名结果。
|
||||
|
||||
报名结果只有返回 `code=0` 且 `msg=报名成功` 才计为成功,避免后端异常分支返回成功状态码但消息为“报名失败”时被误判。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 登录验证码由应用写入本机 Redis,JMX 仅在授权测试环境中从 Redis 读取对应验证码答案。
|
||||
- 当前 `cas.enable=false`。现有登录实现只在 CAS 模式开启时拒绝密码不匹配,因此 JMX 使用测试占位密码;该行为不应复制到生产环境。
|
||||
- Controller 使用单 JVM `ReentrantLock`,单节点下报名请求会排队;多节点下该锁无法跨节点保证一致性。
|
||||
- JMeter 1300线程会消耗较多内存。若出现堆内存不足,可在启动前提高 `HEAP`,例如 `-Xms2g -Xmx4g`。
|
||||
- 不提供自动删除报名数据的脚本,避免误删真实报名记录。
|
||||
@@ -0,0 +1,291 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<jmeterTestPlan version="1.2" properties="5.0" jmeter="5.6.3">
|
||||
<hashTree>
|
||||
<TestPlan guiclass="TestPlanGui" testclass="TestPlan" testname="品牌活动-登山活动1300人并发报名" enabled="true">
|
||||
<stringProp name="TestPlan.comments">每个线程使用独立用户和 Cookie 登录,登录完成后在报名接口前集合并发。仅限本地或经授权的测试环境。</stringProp>
|
||||
<boolProp name="TestPlan.functional_mode">false</boolProp>
|
||||
<boolProp name="TestPlan.tearDown_on_shutdown">true</boolProp>
|
||||
<boolProp name="TestPlan.serialize_threadgroups">false</boolProp>
|
||||
<elementProp name="TestPlan.user_defined_variables" elementType="Arguments" guiclass="ArgumentsPanel" testclass="Arguments" testname="业务参数" enabled="true">
|
||||
<collectionProp name="Arguments.arguments">
|
||||
<elementProp name="activityId" elementType="Argument">
|
||||
<stringProp name="Argument.name">activityId</stringProp>
|
||||
<stringProp name="Argument.value">${__P(activityId,11619eee67574256ad8ccc8a16630023)}</stringProp>
|
||||
<stringProp name="Argument.metadata">=</stringProp>
|
||||
</elementProp>
|
||||
<elementProp name="courseId" elementType="Argument">
|
||||
<stringProp name="Argument.name">courseId</stringProp>
|
||||
<stringProp name="Argument.value">${__P(courseId,986bf4d48a5948e1810857483f9e5af2)}</stringProp>
|
||||
<stringProp name="Argument.metadata">=</stringProp>
|
||||
</elementProp>
|
||||
<elementProp name="redisHost" elementType="Argument">
|
||||
<stringProp name="Argument.name">redisHost</stringProp>
|
||||
<stringProp name="Argument.value">${__P(redisHost,127.0.0.1)}</stringProp>
|
||||
<stringProp name="Argument.metadata">=</stringProp>
|
||||
</elementProp>
|
||||
<elementProp name="redisPort" elementType="Argument">
|
||||
<stringProp name="Argument.name">redisPort</stringProp>
|
||||
<stringProp name="Argument.value">${__P(redisPort,6378)}</stringProp>
|
||||
<stringProp name="Argument.metadata">=</stringProp>
|
||||
</elementProp>
|
||||
<elementProp name="testPassword" elementType="Argument">
|
||||
<stringProp name="Argument.name">testPassword</stringProp>
|
||||
<stringProp name="Argument.value">${__P(testPassword,LoadTestOnly@123)}</stringProp>
|
||||
<stringProp name="Argument.metadata">=</stringProp>
|
||||
</elementProp>
|
||||
</collectionProp>
|
||||
</elementProp>
|
||||
</TestPlan>
|
||||
<hashTree>
|
||||
<CSVDataSet guiclass="TestBeanGUI" testclass="CSVDataSet" testname="读取数据库人员CSV" enabled="true">
|
||||
<stringProp name="delimiter">,</stringProp>
|
||||
<stringProp name="fileEncoding">UTF-8</stringProp>
|
||||
<stringProp name="filename">${__P(usersFile,jmeter/data/brand-activity-users.csv)}</stringProp>
|
||||
<boolProp name="ignoreFirstLine">false</boolProp>
|
||||
<boolProp name="quotedData">true</boolProp>
|
||||
<boolProp name="recycle">false</boolProp>
|
||||
<stringProp name="shareMode">shareMode.all</stringProp>
|
||||
<boolProp name="stopThread">true</boolProp>
|
||||
<stringProp name="variableNames"></stringProp>
|
||||
</CSVDataSet>
|
||||
<hashTree/>
|
||||
<ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup" testname="独立用户登录并并发报名" enabled="true">
|
||||
<stringProp name="ThreadGroup.on_sample_error">continue</stringProp>
|
||||
<elementProp name="ThreadGroup.main_controller" elementType="LoopController" guiclass="LoopControlPanel" testclass="LoopController" testname="循环控制器" enabled="true">
|
||||
<boolProp name="LoopController.continue_forever">false</boolProp>
|
||||
<stringProp name="LoopController.loops">1</stringProp>
|
||||
</elementProp>
|
||||
<stringProp name="ThreadGroup.num_threads">${__P(threads,10)}</stringProp>
|
||||
<stringProp name="ThreadGroup.ramp_time">${__P(loginRampSeconds,30)}</stringProp>
|
||||
<boolProp name="ThreadGroup.scheduler">false</boolProp>
|
||||
<stringProp name="ThreadGroup.duration"></stringProp>
|
||||
<stringProp name="ThreadGroup.delay"></stringProp>
|
||||
<boolProp name="ThreadGroup.same_user_on_next_iteration">true</boolProp>
|
||||
</ThreadGroup>
|
||||
<hashTree>
|
||||
<ConfigTestElement guiclass="HttpDefaultsGui" testclass="ConfigTestElement" testname="HTTP默认配置" enabled="true">
|
||||
<elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" testname="用户定义变量" enabled="true">
|
||||
<collectionProp name="Arguments.arguments"/>
|
||||
</elementProp>
|
||||
<stringProp name="HTTPSampler.domain">${__P(host,127.0.0.1)}</stringProp>
|
||||
<stringProp name="HTTPSampler.port">${__P(port,80)}</stringProp>
|
||||
<stringProp name="HTTPSampler.protocol">${__P(protocol,http)}</stringProp>
|
||||
<stringProp name="HTTPSampler.connect_timeout">${__P(connectTimeout,10000)}</stringProp>
|
||||
<stringProp name="HTTPSampler.response_timeout">${__P(responseTimeout,120000)}</stringProp>
|
||||
<stringProp name="HTTPSampler.contentEncoding">UTF-8</stringProp>
|
||||
</ConfigTestElement>
|
||||
<hashTree/>
|
||||
<CookieManager guiclass="CookiePanel" testclass="CookieManager" testname="每线程独立Cookie" enabled="true">
|
||||
<collectionProp name="CookieManager.cookies"/>
|
||||
<boolProp name="CookieManager.clearEachIteration">true</boolProp>
|
||||
<boolProp name="CookieManager.controlledByThreadGroup">false</boolProp>
|
||||
</CookieManager>
|
||||
<hashTree/>
|
||||
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="请求头" enabled="true">
|
||||
<collectionProp name="HeaderManager.headers">
|
||||
<elementProp name="Content-Type" elementType="Header">
|
||||
<stringProp name="Header.name">Content-Type</stringProp>
|
||||
<stringProp name="Header.value">application/x-www-form-urlencoded;charset=UTF-8</stringProp>
|
||||
</elementProp>
|
||||
<elementProp name="Accept" elementType="Header">
|
||||
<stringProp name="Header.name">Accept</stringProp>
|
||||
<stringProp name="Header.value">application/json, text/plain, */*</stringProp>
|
||||
</elementProp>
|
||||
</collectionProp>
|
||||
</HeaderManager>
|
||||
<hashTree/>
|
||||
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="01-获取图形验证码" enabled="true">
|
||||
<elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" testname="请求参数" enabled="true">
|
||||
<collectionProp name="Arguments.arguments"/>
|
||||
</elementProp>
|
||||
<stringProp name="HTTPSampler.path">/platform/login/captcha</stringProp>
|
||||
<stringProp name="HTTPSampler.method">POST</stringProp>
|
||||
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
|
||||
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
|
||||
<boolProp name="HTTPSampler.postBodyRaw">false</boolProp>
|
||||
</HTTPSamplerProxy>
|
||||
<hashTree>
|
||||
<JSONPostProcessor guiclass="JSONPostProcessorGui" testclass="JSONPostProcessor" testname="提取验证码Key" enabled="true">
|
||||
<stringProp name="JSONPostProcessor.referenceNames">captchaKey</stringProp>
|
||||
<stringProp name="JSONPostProcessor.jsonPathExprs">$.data.key</stringProp>
|
||||
<stringProp name="JSONPostProcessor.match_numbers">1</stringProp>
|
||||
<stringProp name="JSONPostProcessor.defaultValues">NOT_FOUND</stringProp>
|
||||
</JSONPostProcessor>
|
||||
<hashTree/>
|
||||
</hashTree>
|
||||
<JSR223Sampler guiclass="TestBeanGUI" testclass="JSR223Sampler" testname="02-从本地Redis读取压测验证码" enabled="true">
|
||||
<stringProp name="cacheKey">true</stringProp>
|
||||
<stringProp name="filename"></stringProp>
|
||||
<stringProp name="parameters"></stringProp>
|
||||
<stringProp name="scriptLanguage">groovy</stringProp>
|
||||
<stringProp name="script"><![CDATA[
|
||||
def captchaKey = vars.get('captchaKey')
|
||||
if (!captchaKey || captchaKey == 'NOT_FOUND') {
|
||||
throw new IllegalStateException('获取验证码Key失败')
|
||||
}
|
||||
def redisKey = 'budwk5mini:ucenter:captcha:' + captchaKey
|
||||
def socket = new Socket(vars.get('redisHost'), vars.get('redisPort') as int)
|
||||
socket.soTimeout = 3000
|
||||
try {
|
||||
def keyBytes = redisKey.getBytes('UTF-8')
|
||||
def command = '*2\r\n$3\r\nGET\r\n$' + keyBytes.length + '\r\n' + redisKey + '\r\n'
|
||||
socket.outputStream.write(command.getBytes('UTF-8'))
|
||||
socket.outputStream.flush()
|
||||
def reader = new BufferedReader(new InputStreamReader(socket.inputStream, 'UTF-8'))
|
||||
def header = reader.readLine()
|
||||
if (!header || !header.startsWith('$') || header == '$-1') {
|
||||
throw new IllegalStateException('Redis中未找到验证码:' + redisKey)
|
||||
}
|
||||
def captchaCode = reader.readLine()
|
||||
if (!captchaCode) {
|
||||
throw new IllegalStateException('验证码内容为空')
|
||||
}
|
||||
vars.put('captchaCode', captchaCode)
|
||||
} finally {
|
||||
socket.close()
|
||||
}
|
||||
]]></stringProp>
|
||||
</JSR223Sampler>
|
||||
<hashTree/>
|
||||
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="03-获取登录RSA公钥" enabled="true">
|
||||
<elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" testname="请求参数" enabled="true">
|
||||
<collectionProp name="Arguments.arguments"/>
|
||||
</elementProp>
|
||||
<stringProp name="HTTPSampler.path">/platform/login/publicKey</stringProp>
|
||||
<stringProp name="HTTPSampler.method">GET</stringProp>
|
||||
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
|
||||
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
|
||||
</HTTPSamplerProxy>
|
||||
<hashTree>
|
||||
<JSONPostProcessor guiclass="JSONPostProcessorGui" testclass="JSONPostProcessor" testname="提取公钥和KeyId" enabled="true">
|
||||
<stringProp name="JSONPostProcessor.referenceNames">publicKey;keyId</stringProp>
|
||||
<stringProp name="JSONPostProcessor.jsonPathExprs">$.data.publicKey;$.data.keyId</stringProp>
|
||||
<stringProp name="JSONPostProcessor.match_numbers">1;1</stringProp>
|
||||
<stringProp name="JSONPostProcessor.defaultValues">NOT_FOUND;NOT_FOUND</stringProp>
|
||||
</JSONPostProcessor>
|
||||
<hashTree/>
|
||||
</hashTree>
|
||||
<JSR223Sampler guiclass="TestBeanGUI" testclass="JSR223Sampler" testname="04-RSA加密测试密码" enabled="true">
|
||||
<stringProp name="cacheKey">true</stringProp>
|
||||
<stringProp name="filename"></stringProp>
|
||||
<stringProp name="parameters"></stringProp>
|
||||
<stringProp name="scriptLanguage">groovy</stringProp>
|
||||
<stringProp name="script"><![CDATA[
|
||||
import java.security.KeyFactory
|
||||
import java.security.spec.X509EncodedKeySpec
|
||||
import javax.crypto.Cipher
|
||||
|
||||
def publicKeyText = vars.get('publicKey')
|
||||
if (!publicKeyText || publicKeyText == 'NOT_FOUND') {
|
||||
throw new IllegalStateException('获取RSA公钥失败')
|
||||
}
|
||||
def keyBytes = Base64.decoder.decode(publicKeyText)
|
||||
def publicKey = KeyFactory.getInstance('RSA').generatePublic(new X509EncodedKeySpec(keyBytes))
|
||||
def cipher = Cipher.getInstance('RSA/ECB/PKCS1Padding')
|
||||
cipher.init(Cipher.ENCRYPT_MODE, publicKey)
|
||||
def encrypted = cipher.doFinal(vars.get('testPassword').getBytes('UTF-8'))
|
||||
vars.put('encryptedPassword', Base64.encoder.encodeToString(encrypted))
|
||||
]]></stringProp>
|
||||
</JSR223Sampler>
|
||||
<hashTree/>
|
||||
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="05-人员账号登录" enabled="true">
|
||||
<elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" testname="登录参数" enabled="true">
|
||||
<collectionProp name="Arguments.arguments">
|
||||
<elementProp name="username" elementType="HTTPArgument">
|
||||
<boolProp name="HTTPArgument.always_encode">true</boolProp><stringProp name="Argument.name">username</stringProp><stringProp name="Argument.value">${loginname}</stringProp><stringProp name="Argument.metadata">=</stringProp><boolProp name="HTTPArgument.use_equals">true</boolProp>
|
||||
</elementProp>
|
||||
<elementProp name="password" elementType="HTTPArgument">
|
||||
<boolProp name="HTTPArgument.always_encode">true</boolProp><stringProp name="Argument.name">password</stringProp><stringProp name="Argument.value">${encryptedPassword}</stringProp><stringProp name="Argument.metadata">=</stringProp><boolProp name="HTTPArgument.use_equals">true</boolProp>
|
||||
</elementProp>
|
||||
<elementProp name="platformKey" elementType="HTTPArgument">
|
||||
<boolProp name="HTTPArgument.always_encode">true</boolProp><stringProp name="Argument.name">platformKey</stringProp><stringProp name="Argument.value">${captchaKey}</stringProp><stringProp name="Argument.metadata">=</stringProp><boolProp name="HTTPArgument.use_equals">true</boolProp>
|
||||
</elementProp>
|
||||
<elementProp name="platformCaptcha" elementType="HTTPArgument">
|
||||
<boolProp name="HTTPArgument.always_encode">true</boolProp><stringProp name="Argument.name">platformCaptcha</stringProp><stringProp name="Argument.value">${captchaCode}</stringProp><stringProp name="Argument.metadata">=</stringProp><boolProp name="HTTPArgument.use_equals">true</boolProp>
|
||||
</elementProp>
|
||||
<elementProp name="keyId" elementType="HTTPArgument">
|
||||
<boolProp name="HTTPArgument.always_encode">true</boolProp><stringProp name="Argument.name">keyId</stringProp><stringProp name="Argument.value">${keyId}</stringProp><stringProp name="Argument.metadata">=</stringProp><boolProp name="HTTPArgument.use_equals">true</boolProp>
|
||||
</elementProp>
|
||||
</collectionProp>
|
||||
</elementProp>
|
||||
<stringProp name="HTTPSampler.path">/platform/login/doLogin</stringProp>
|
||||
<stringProp name="HTTPSampler.method">POST</stringProp>
|
||||
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
|
||||
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
|
||||
<boolProp name="HTTPSampler.postBodyRaw">false</boolProp>
|
||||
</HTTPSamplerProxy>
|
||||
<hashTree>
|
||||
<JSR223PostProcessor guiclass="TestBeanGUI" testclass="JSR223PostProcessor" testname="断言登录成功" enabled="true">
|
||||
<stringProp name="scriptLanguage">groovy</stringProp>
|
||||
<stringProp name="script"><![CDATA[
|
||||
import groovy.json.JsonSlurper
|
||||
try {
|
||||
def json = new JsonSlurper().parseText(prev.getResponseDataAsString())
|
||||
if (json.code != 0) {
|
||||
prev.setSuccessful(false)
|
||||
prev.setResponseMessage('登录失败:' + json.msg)
|
||||
}
|
||||
} catch (Exception e) {
|
||||
prev.setSuccessful(false)
|
||||
prev.setResponseMessage('登录响应不是有效JSON:' + e.message)
|
||||
}
|
||||
]]></stringProp>
|
||||
</JSR223PostProcessor>
|
||||
<hashTree/>
|
||||
</hashTree>
|
||||
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="06-报名资格预校验" enabled="true">
|
||||
<elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" testname="校验参数" enabled="true">
|
||||
<collectionProp name="Arguments.arguments">
|
||||
<elementProp name="courseId" elementType="HTTPArgument"><boolProp name="HTTPArgument.always_encode">true</boolProp><stringProp name="Argument.name">courseId</stringProp><stringProp name="Argument.value">${courseId}</stringProp><stringProp name="Argument.metadata">=</stringProp><boolProp name="HTTPArgument.use_equals">true</boolProp></elementProp>
|
||||
<elementProp name="currentFamilyNumber" elementType="HTTPArgument"><boolProp name="HTTPArgument.always_encode">true</boolProp><stringProp name="Argument.name">currentFamilyNumber</stringProp><stringProp name="Argument.value">0</stringProp><stringProp name="Argument.metadata">=</stringProp><boolProp name="HTTPArgument.use_equals">true</boolProp></elementProp>
|
||||
</collectionProp>
|
||||
</elementProp>
|
||||
<stringProp name="HTTPSampler.path">/platform/trainSignUp/apply/validateSignUp</stringProp>
|
||||
<stringProp name="HTTPSampler.method">POST</stringProp>
|
||||
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
|
||||
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
|
||||
<boolProp name="HTTPSampler.postBodyRaw">false</boolProp>
|
||||
</HTTPSamplerProxy>
|
||||
<hashTree/>
|
||||
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="07-集合后并发提交登山活动报名" enabled="true">
|
||||
<elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" testname="报名参数" enabled="true">
|
||||
<collectionProp name="Arguments.arguments">
|
||||
<elementProp name="activityId" elementType="HTTPArgument"><boolProp name="HTTPArgument.always_encode">true</boolProp><stringProp name="Argument.name">activityId</stringProp><stringProp name="Argument.value">${activityId}</stringProp><stringProp name="Argument.metadata">=</stringProp><boolProp name="HTTPArgument.use_equals">true</boolProp></elementProp>
|
||||
<elementProp name="courseId" elementType="HTTPArgument"><boolProp name="HTTPArgument.always_encode">true</boolProp><stringProp name="Argument.name">courseId</stringProp><stringProp name="Argument.value">${courseId}</stringProp><stringProp name="Argument.metadata">=</stringProp><boolProp name="HTTPArgument.use_equals">true</boolProp></elementProp>
|
||||
<elementProp name="mobileColumnsValue" elementType="HTTPArgument"><boolProp name="HTTPArgument.always_encode">true</boolProp><stringProp name="Argument.name">mobileColumnsValue</stringProp><stringProp name="Argument.value">[]</stringProp><stringProp name="Argument.metadata">=</stringProp><boolProp name="HTTPArgument.use_equals">true</boolProp></elementProp>
|
||||
</collectionProp>
|
||||
</elementProp>
|
||||
<stringProp name="HTTPSampler.path">/platform/trainSignUp/apply/doSignUp</stringProp>
|
||||
<stringProp name="HTTPSampler.method">POST</stringProp>
|
||||
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
|
||||
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
|
||||
<boolProp name="HTTPSampler.postBodyRaw">false</boolProp>
|
||||
</HTTPSamplerProxy>
|
||||
<hashTree>
|
||||
<SyncTimer guiclass="TestBeanGUI" testclass="SyncTimer" testname="等待本线程组全部人员后同时报名" enabled="true">
|
||||
<intProp name="groupSize">0</intProp>
|
||||
<stringProp name="timeoutInMs">${__P(syncTimeoutMs,180000)}</stringProp>
|
||||
</SyncTimer>
|
||||
<hashTree/>
|
||||
<JSR223PostProcessor guiclass="TestBeanGUI" testclass="JSR223PostProcessor" testname="断言报名成功" enabled="true">
|
||||
<stringProp name="scriptLanguage">groovy</stringProp>
|
||||
<stringProp name="script"><![CDATA[
|
||||
import groovy.json.JsonSlurper
|
||||
try {
|
||||
def json = new JsonSlurper().parseText(prev.getResponseDataAsString())
|
||||
if (json.code != 0 || json.msg != '报名成功') {
|
||||
prev.setSuccessful(false)
|
||||
prev.setResponseMessage('报名未成功;expectedAuthorized=' + vars.get('expectedAuthorized') + ';返回:' + json.msg)
|
||||
}
|
||||
} catch (Exception e) {
|
||||
prev.setSuccessful(false)
|
||||
prev.setResponseMessage('报名响应不是有效JSON;expectedAuthorized=' + vars.get('expectedAuthorized') + ';' + e.message)
|
||||
}
|
||||
]]></stringProp>
|
||||
</JSR223PostProcessor>
|
||||
<hashTree/>
|
||||
</hashTree>
|
||||
</hashTree>
|
||||
</hashTree>
|
||||
</hashTree>
|
||||
</jmeterTestPlan>
|
||||
@@ -0,0 +1,132 @@
|
||||
param(
|
||||
[ValidateRange(1, 1300)]
|
||||
[int]$UserCount = 1300,
|
||||
|
||||
[ValidateSet("AllScope", "Authorized")]
|
||||
[string]$PermissionMode = "AllScope",
|
||||
|
||||
[ValidatePattern("^[0-9a-fA-F]{32}$")]
|
||||
[string]$ActivityId = "11619eee67574256ad8ccc8a16630023",
|
||||
|
||||
[string]$OutputFile = (Join-Path $PSScriptRoot "data\brand-activity-users.csv"),
|
||||
|
||||
[string]$ApplicationConfig = (Join-Path $PSScriptRoot "..\src\main\resources\application.yaml")
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Get-YamlScalar {
|
||||
param(
|
||||
[string[]]$Lines,
|
||||
[string]$Name
|
||||
)
|
||||
|
||||
$matchedLine = $Lines | Where-Object { $_ -match ("^\s*" + [regex]::Escape($Name) + "\s*:\s*(.+?)\s*$") } | Select-Object -First 1
|
||||
if (-not $matchedLine) {
|
||||
throw "未在 application.yaml 中找到配置项:$Name"
|
||||
}
|
||||
return ([regex]::Match($matchedLine, "^\s*[^:]+:\s*(.+?)\s*$")).Groups[1].Value.Trim('"', "'")
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $ApplicationConfig)) {
|
||||
throw "找不到应用配置文件:$ApplicationConfig"
|
||||
}
|
||||
|
||||
$mysqlCommand = Get-Command mysql -ErrorAction SilentlyContinue
|
||||
if (-not $mysqlCommand) {
|
||||
throw "未找到 mysql 命令,请先把 MySQL 客户端 bin 目录加入 PATH。"
|
||||
}
|
||||
|
||||
$configLines = Get-Content -LiteralPath $ApplicationConfig -Encoding UTF8
|
||||
$jdbcUrl = Get-YamlScalar -Lines $configLines -Name "url"
|
||||
$dbUser = Get-YamlScalar -Lines $configLines -Name "username"
|
||||
$dbPassword = Get-YamlScalar -Lines $configLines -Name "password"
|
||||
|
||||
$jdbcMatch = [regex]::Match($jdbcUrl, "jdbc:mysql://(?<host>[^:/?]+)(:(?<port>\d+))?/(?<database>[^?]+)")
|
||||
if (-not $jdbcMatch.Success) {
|
||||
throw "无法解析 JDBC 地址:$jdbcUrl"
|
||||
}
|
||||
|
||||
$dbHost = $jdbcMatch.Groups["host"].Value
|
||||
$dbPort = if ($jdbcMatch.Groups["port"].Success) { $jdbcMatch.Groups["port"].Value } else { "3306" }
|
||||
$database = $jdbcMatch.Groups["database"].Value
|
||||
|
||||
# 仅导出活动范围内、账号未禁用且尚未报名的人员;不读取密码、身份证号、手机号等敏感字段。
|
||||
$permissionCondition = if ($PermissionMode -eq "Authorized") {
|
||||
@"
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM sys_user_role ur
|
||||
JOIN sys_role_menu rm ON rm.roleId = ur.roleId
|
||||
JOIN sys_menu m ON m.id = rm.menuId
|
||||
WHERE ur.userId = u.id
|
||||
AND m.permission IN ('trainSignUp.apply', 'h5.trainSignUp.apply')
|
||||
)
|
||||
"@
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
$query = @"
|
||||
SELECT
|
||||
u.loginname,
|
||||
CASE WHEN EXISTS (
|
||||
SELECT 1
|
||||
FROM sys_user_role ur
|
||||
JOIN sys_role_menu rm ON rm.roleId = ur.roleId
|
||||
JOIN sys_menu m ON m.id = rm.menuId
|
||||
WHERE ur.userId = u.id
|
||||
AND m.permission IN ('trainSignUp.apply', 'h5.trainSignUp.apply')
|
||||
) THEN 1 ELSE 0 END AS expectedAuthorized
|
||||
FROM train_sign_up_activity a
|
||||
JOIN activity_user_scope aus ON aus.groupId = a.activityGroupId
|
||||
JOIN sys_user u ON u.id = aus.userId
|
||||
LEFT JOIN train_sign_up_user su ON su.activityId = a.id AND su.userId = u.id
|
||||
WHERE a.id = '$ActivityId'
|
||||
AND u.disabled = 0
|
||||
AND u.loginname IS NOT NULL
|
||||
AND u.loginname <> ''
|
||||
AND su.id IS NULL
|
||||
$permissionCondition
|
||||
GROUP BY u.id, u.loginname
|
||||
ORDER BY expectedAuthorized DESC, u.loginname
|
||||
LIMIT $UserCount;
|
||||
"@
|
||||
|
||||
$previousMysqlPassword = $env:MYSQL_PWD
|
||||
try {
|
||||
$env:MYSQL_PWD = $dbPassword
|
||||
$rows = @(& $mysqlCommand.Source --batch --raw --skip-column-names -h $dbHost -P $dbPort -u $dbUser $database -e $query)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "查询压测人员失败,mysql 退出码:$LASTEXITCODE"
|
||||
}
|
||||
} finally {
|
||||
$env:MYSQL_PWD = $previousMysqlPassword
|
||||
}
|
||||
|
||||
$users = @($rows | Where-Object { $_ -and $_.Trim() } | ForEach-Object {
|
||||
$columns = $_ -split "`t", 2
|
||||
[pscustomobject]@{
|
||||
loginname = $columns[0]
|
||||
expectedAuthorized = $columns[1]
|
||||
}
|
||||
})
|
||||
|
||||
if ($users.Count -lt $UserCount) {
|
||||
throw "符合条件的人员只有 $($users.Count) 人,少于要求的 $UserCount 人。PermissionMode=$PermissionMode"
|
||||
}
|
||||
|
||||
$outputDirectory = Split-Path -Parent $OutputFile
|
||||
if (-not (Test-Path -LiteralPath $outputDirectory)) {
|
||||
New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null
|
||||
}
|
||||
|
||||
$users | Export-Csv -LiteralPath $OutputFile -NoTypeInformation -Encoding UTF8 -UseQuotes Always
|
||||
|
||||
$authorizedCount = @($users | Where-Object { $_.expectedAuthorized -eq "1" }).Count
|
||||
$unauthorizedCount = $users.Count - $authorizedCount
|
||||
Write-Host "已生成 UTF-8 用户文件:$OutputFile"
|
||||
Write-Host "总人数:$($users.Count),具备报名权限:$authorizedCount,无报名权限:$unauthorizedCount"
|
||||
if ($unauthorizedCount -gt 0) {
|
||||
Write-Warning "无报名权限的线程仍会访问报名接口,但会被权限校验拒绝。"
|
||||
}
|
||||
@@ -20,6 +20,9 @@ public class RedisConstant {
|
||||
//企业微信token
|
||||
public final static String REDIS_KEY_WX_WORK_TOKEN = PLATFORM_REDIS_PREFIX + "wxwork:token:";
|
||||
|
||||
// 学校消息中心钉钉提醒 Token;按 appId、compId 隔离,刷新锁与 Token 使用同一哈希槽。
|
||||
public static final String REDIS_KEY_DING_MESSAGE_TOKEN = PLATFORM_REDIS_PREFIX + "ding:message:token:";
|
||||
|
||||
//健步走小程序TOKEN
|
||||
public final static String REDIS_KEY_WE_APP_ACCESS_TOKEN = PLATFORM_REDIS_PREFIX + "weapp:token:";
|
||||
|
||||
|
||||
@@ -136,4 +136,7 @@ public interface SmsService {
|
||||
* @return
|
||||
*/
|
||||
void recallMsg(String msgId);
|
||||
|
||||
/** 后台群发卡片:工号列表、标题、正文、PC/H5 跳转地址及提交人的工号;无返回值。 */
|
||||
void massSendTextCard(List<String> loginNames, String title, String content, String pcUrl, String mobileUrl, String fromUser);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
package com.budwk.app.base.sms.impl;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.http.HttpRequest;
|
||||
import cn.hutool.http.HttpResponse;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import com.budwk.app.base.constant.RedisConstant;
|
||||
import com.budwk.app.base.sms.SmsService;
|
||||
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
|
||||
import com.budwk.app.web.commons.base.Globals;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.nutz.ioc.loader.annotation.IocBean;
|
||||
import org.nutz.ioc.loader.annotation.Inject;
|
||||
import org.nutz.integration.jedis.RedisService;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* @ClassName SmsServiceImpl
|
||||
* @Author JyuHsin
|
||||
* @Date 2025/11/26 19:32
|
||||
* @Version 1.0
|
||||
* 通过学校消息中心发送钉钉提醒,正文填充钉钉模板变量 #{content}。
|
||||
*/
|
||||
@Slf4j
|
||||
@IocBean
|
||||
public class SmsServiceImpl implements SmsService {
|
||||
|
||||
private static final String BASE_URL = "https://ua.zjipc.cn/messageng/openApi";
|
||||
// 由信息中心提供,templateId 必须对应包含 #{content} 的钉钉模板。
|
||||
private static final String APP_ID = "xy6t2ht3qkkh6jdx9cyhwmg4bhgldjn3";
|
||||
private static final String APP_SECRET = "T_AtDFHS0KHVCvpZK5zpx6bnRuIDNcwu4BgFLOZxXi0w45HrTIto1q_UYmX8lnjf";
|
||||
private static final String COMP_ID = "30c2b301a1759c0ebcb4550635066ac0";
|
||||
private static final String TEMPLATE_ID = "DING_083";
|
||||
// 异步任务无登录上下文,按文档示例使用系统发送者。
|
||||
private static final int REQUEST_TIMEOUT = 5000;
|
||||
// 锁覆盖认证超时窗口,进程退出后自动释放;写缓存时再次校验锁归属。
|
||||
private static final int TOKEN_LOCK_SECONDS = 30;
|
||||
private static final String DELETE_IF_MATCH = "if redis.call('get', KEYS[1]) == ARGV[1] then "
|
||||
+ "return redis.call('del', KEYS[1]) else return 0 end";
|
||||
private static final String CACHE_IF_OWNER = "if redis.call('get', KEYS[1]) == ARGV[1] then "
|
||||
+ "redis.call('setex', KEYS[2], ARGV[2], ARGV[3]); return 1 else return 0 end";
|
||||
|
||||
@Inject
|
||||
private RedisService redisService;
|
||||
|
||||
/** 按工号单发正文,使用默认标题和首页链接,无返回值。 */
|
||||
@Override
|
||||
public void sendSingleMessage(String loginName, String content) {
|
||||
this.sendSingleMessage(loginName, "智慧工会", content);
|
||||
}
|
||||
|
||||
/** 按工号单发指定标题与正文,无返回值,链接默认使用首页。 */
|
||||
@Override
|
||||
public void sendSingleMessage(String loginName, String title, String content) {
|
||||
this.massSendMessage(Collections.singletonList(loginName), title, content);
|
||||
}
|
||||
|
||||
/** 按工号发送正文与 PC/H5 业务链接,使用默认标题,无返回值。 */
|
||||
@Override
|
||||
public void sendSingleTextCard(String loginName, String content, String pcUrl, String mobileUrl) {
|
||||
this.sendSingleTextCard(loginName, "智慧工会", content, pcUrl, mobileUrl);
|
||||
}
|
||||
|
||||
/** 按工号发送指定标题、正文与 PC/H5 业务链接,无返回值。 */
|
||||
@Override
|
||||
public void sendSingleTextCard(String loginName, String title, String content, String pcUrl, String mobileUrl) {
|
||||
this.massSendTextCard(Collections.singletonList(loginName), title, content, pcUrl, mobileUrl);
|
||||
}
|
||||
|
||||
/** 兼容原图文单发参数,使用默认标题,图片限制由群发入口统一处理,无返回值。 */
|
||||
@Override
|
||||
public void sendSinglePicTextCard(String loginName, String content, String pcUrl, String mobileUrl, String picUrl) {
|
||||
this.sendSinglePicTextCard(loginName, "智慧工会", content, pcUrl, mobileUrl, picUrl);
|
||||
}
|
||||
|
||||
/** 按工号接收指定标题的原图文参数,仅发送正文与业务链接,无返回值。 */
|
||||
@Override
|
||||
public void sendSinglePicTextCard(String loginName, String title, String content, String pcUrl, String mobileUrl, String picUrl) {
|
||||
this.massSendPicTextCard(Collections.singletonList(loginName), title, content, pcUrl, mobileUrl, picUrl);
|
||||
}
|
||||
|
||||
/** 向工号列表发送相同正文,默认标题和首页链接,无返回值。 */
|
||||
@Override
|
||||
public void massSendMessage(List<String> loginNames, String content) {
|
||||
this.massSendMessage(loginNames, "智慧工会", content);
|
||||
}
|
||||
|
||||
/** 向工号列表发送指定标题和正文,默认使用首页链接,无返回值。 */
|
||||
@Override
|
||||
public void massSendMessage(List<String> loginNames, String title, String content) {
|
||||
// 普通提醒也必须携带链接,未提供业务链接时使用系统首页。
|
||||
this.doSend(loginNames, title, content, null, null);
|
||||
}
|
||||
|
||||
/** 向工号列表发送正文与 PC/H5 链接,使用默认标题,无返回值。 */
|
||||
@Override
|
||||
public void massSendTextCard(List<String> loginNames, String content, String pcUrl, String mobileUrl) {
|
||||
this.massSendTextCard(loginNames, "智慧工会", content, pcUrl, mobileUrl);
|
||||
}
|
||||
|
||||
/** 向工号列表发送指定标题的卡片提醒,优先 H5 链接,无返回值。 */
|
||||
@Override
|
||||
public void massSendTextCard(List<String> loginNames, String title, String content, String pcUrl, String mobileUrl) {
|
||||
this.doSend(loginNames, title, content, pcUrl, mobileUrl);
|
||||
}
|
||||
|
||||
/** 后台发送使用提交时取得的工号,不依赖工作线程的登录上下文;参数及返回约定见接口。 */
|
||||
@Override
|
||||
public void massSendTextCard(List<String> loginNames, String title, String content, String pcUrl, String mobileUrl, String fromUser) {
|
||||
this.doSend(loginNames, title, content, pcUrl, mobileUrl, fromUser);
|
||||
}
|
||||
|
||||
/** 兼容原图文群发参数,使用默认标题,无返回值。 */
|
||||
@Override
|
||||
public void massSendPicTextCard(List<String> loginNames, String content, String pcUrl, String mobileUrl, String picUrl) {
|
||||
this.massSendPicTextCard(loginNames, "智慧工会", content, pcUrl, mobileUrl, picUrl);
|
||||
}
|
||||
|
||||
/** 接收工号列表、标题、正文、PC/H5 链接和图片 URL;图片不发送,无返回值。 */
|
||||
@Override
|
||||
public void massSendPicTextCard(List<String> loginNames, String title, String content, String pcUrl, String mobileUrl, String picUrl) {
|
||||
// 文档无图片 URL 参数,保留调用入口但按普通卡片发送,不能将图片误填为附件 ID。
|
||||
if (StrUtil.isNotBlank(picUrl)) {
|
||||
log.warn("钉钉消息中心暂不支持图片 URL,本次仅发送正文与链接");
|
||||
}
|
||||
this.doSend(loginNames, title, content, pcUrl, mobileUrl);
|
||||
}
|
||||
|
||||
/** 接收待撤回消息 ID;当前文档无撤回能力,仅记录未执行,无返回值。 */
|
||||
@Override
|
||||
public void recallMsg(String msgId) {
|
||||
// 文档未提供撤回协议,保留接口兼容调用方,不再请求旧企业微信平台。
|
||||
log.warn("钉钉消息中心文档未提供撤回接口,未执行撤回");
|
||||
}
|
||||
|
||||
/**
|
||||
* 接收工号列表、标题、正文和 PC/H5 链接;按文档逐人发送,无返回值。
|
||||
* 正文映射为模板变量 content,单人失败记录后继续其余接收人,沿用原服务行为。
|
||||
*/
|
||||
private void doSend(List<String> loginNames, String title, String content, String pcUrl, String mobileUrl) {
|
||||
this.doSend(loginNames, title, content, pcUrl, mobileUrl, null);
|
||||
}
|
||||
|
||||
/** fromUser 为后台任务传入的发送人工号;为空时沿用当前请求身份。发送结果写入日志。 */
|
||||
private void doSend(List<String> loginNames, String title, String content, String pcUrl, String mobileUrl, String fromUser) {
|
||||
if (!Globals.MyConfig.getBoolean("AppSms", false) || !Globals.sso) {
|
||||
log.info("钉钉消息提醒未发送:消息开关未开启或当前为开发模式");
|
||||
return;
|
||||
}
|
||||
if (StrUtil.hasBlank(APP_ID, APP_SECRET, COMP_ID, TEMPLATE_ID)) {
|
||||
log.warn("钉钉消息提醒未发送:请补充 appId、appSecret、compId、templateId");
|
||||
return;
|
||||
}
|
||||
if (loginNames == null || loginNames.isEmpty() || StrUtil.isBlank(content)) {
|
||||
log.warn("钉钉消息提醒未发送:接收人或正文为空");
|
||||
return;
|
||||
}
|
||||
List<String> receivers = loginNames.stream().filter(StrUtil::isNotBlank)
|
||||
.map(String::trim).distinct().toList();
|
||||
if (receivers.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
final String linkUrl;
|
||||
final String token;
|
||||
try {
|
||||
// 协议仅接受一个链接;优先移动端,其次 PC,普通消息使用系统首页。
|
||||
String target = StrUtil.blankToDefault(mobileUrl, StrUtil.blankToDefault(pcUrl, Globals.AppDomain));
|
||||
URI uri = URI.create(target);
|
||||
URI resolved = uri.isAbsolute() ? uri : URI.create(Globals.AppDomain + "/").resolve(uri);
|
||||
if (!("https".equalsIgnoreCase(resolved.getScheme()) || "http".equalsIgnoreCase(resolved.getScheme()))
|
||||
|| StrUtil.isBlank(resolved.getHost())) {
|
||||
throw new IllegalArgumentException("钉钉提醒链接必须为 HTTP/HTTPS 地址");
|
||||
}
|
||||
linkUrl = resolved.toString();
|
||||
token = getAccessToken();
|
||||
} catch (Exception e) {
|
||||
// HTTP 异常可能带有含凭证的 URL,不输出异常正文或 Token。
|
||||
log.error("钉钉消息提醒准备失败,异常类型:{}", e.getClass().getSimpleName());
|
||||
return;
|
||||
}
|
||||
for (String userId : receivers) {
|
||||
String messageId = UUID.randomUUID().toString().replace("-", "");
|
||||
try {
|
||||
// 外层 content 必须为 JSON 字符串,内层 content 对应模板中的 #{content}。
|
||||
JSONObject params = new JSONObject();
|
||||
params.set("id", messageId);
|
||||
params.set("templateId", TEMPLATE_ID);
|
||||
params.set("title", StrUtil.blankToDefault(title, "智慧工会"));
|
||||
params.set("fromUser", StrUtil.isNotBlank(fromUser) ? fromUser : SecurityUtil.getUserLoginname());
|
||||
params.set("userId", userId);
|
||||
params.set("content", new JSONObject().set("content", content).toString());
|
||||
params.set("detailInfo", new JSONObject().set("linkUrl", linkUrl).set("urlDes", "查看详情"));
|
||||
log.info("钉钉消息提醒参数打印,detailInfo={}", params.toString());
|
||||
JSONObject result = sendMessage(token, params);
|
||||
log.info("钉钉消息提醒请求结果返回,result={}", result.toString());
|
||||
String code = result.getStr("code");
|
||||
// 凭证失效清理缓存并停止批次,下次发送重新获取;网络异常不重发,避免重复提醒。
|
||||
if ("10002".equals(code)) {
|
||||
invalidateToken(token);
|
||||
log.error("钉钉消息提醒认证失效,msgId={},停止本批次发送", messageId);
|
||||
break;
|
||||
}
|
||||
if (Boolean.TRUE.equals(result.getBool("status")) && ("1".equals(code) || "5".equals(code))) {
|
||||
log.info("钉钉消息提醒发送成功,msgId={},code={}", messageId, code);
|
||||
} else {
|
||||
log.error("钉钉消息提醒发送未成功,msgId={},code={}", messageId, code);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("钉钉消息提醒发送异常,msgId={},异常类型:{}", messageId, e.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回认证响应 result.access_token;result.expires_in 为有效秒数。
|
||||
* 优先读取 Redis,多实例缓存未命中时仅允许持锁者申请,其余请求限时等待缓存。
|
||||
*/
|
||||
private String getAccessToken() {
|
||||
String key = tokenCacheKey();
|
||||
String lockKey = key + ":refresh";
|
||||
String owner = UUID.randomUUID().toString();
|
||||
long deadline = System.nanoTime() + REQUEST_TIMEOUT * 2L * 1_000_000L;
|
||||
while (true) {
|
||||
String cached = redisService.get(key);
|
||||
if (StrUtil.isNotBlank(cached)) {
|
||||
return cached;
|
||||
}
|
||||
if ("OK".equals(redisService.set(lockKey, owner, "NX", "EX", TOKEN_LOCK_SECONDS))) {
|
||||
try {
|
||||
// 取锁前其他实例可能刚写入缓存,二次读取避免重复认证。
|
||||
cached = redisService.get(key);
|
||||
return StrUtil.isNotBlank(cached) ? cached : requestAndCacheToken(key, lockKey, owner);
|
||||
} finally {
|
||||
// 仅释放自己的锁;Redis 异常不覆盖认证结果,锁仍会按 TTL 自动过期。
|
||||
try {
|
||||
redisService.eval(DELETE_IF_MATCH, List.of(lockKey), List.of(owner));
|
||||
} catch (Exception e) {
|
||||
log.warn("钉钉 Token 刷新锁释放失败,将自动过期,异常类型:{}", e.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
}
|
||||
if (System.nanoTime() >= deadline) {
|
||||
throw new IllegalStateException("等待钉钉 Token 刷新超时");
|
||||
}
|
||||
try {
|
||||
Thread.sleep(100);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("等待钉钉 Token 刷新被中断");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* key 为 Token 缓存键,lockKey/owner 为当前刷新锁及归属标识,返回可发送消息的 Token。
|
||||
* TTL 按 expires_in 扣除请求耗时及提前过期窗口计算,失败或锁过期时不写入缓存。
|
||||
*/
|
||||
private String requestAndCacheToken(String key, String lockKey, String owner) {
|
||||
long requestedAt = System.nanoTime();
|
||||
try (HttpResponse response = HttpRequest.get(BASE_URL + "/token")
|
||||
.form("appId", APP_ID).form("appSecret", APP_SECRET).form("compId", COMP_ID)
|
||||
.timeout(REQUEST_TIMEOUT).execute()) {
|
||||
if (!response.isOk()) {
|
||||
throw new IllegalStateException("消息中心认证 HTTP 状态异常");
|
||||
}
|
||||
JSONObject result = JSONUtil.parseObj(response.body());
|
||||
JSONObject data = result.getJSONObject("result");
|
||||
if (!Boolean.TRUE.equals(result.getBool("success")) || !"0".equals(result.getStr("code")) || data == null) {
|
||||
throw new IllegalStateException("消息中心认证失败");
|
||||
}
|
||||
String value = data.getStr("access_token");
|
||||
long expiresIn = data.getLong("expires_in", 0L);
|
||||
if (StrUtil.isBlank(value) || expiresIn <= 0) {
|
||||
throw new IllegalStateException("消息中心认证凭证或有效期为空");
|
||||
}
|
||||
long elapsedSeconds = (System.nanoTime() - requestedAt) / 1_000_000_000L + 1;
|
||||
long ttl = expiresIn - Math.min(60, expiresIn / 10) - elapsedSeconds;
|
||||
if (ttl <= 0 || ttl > Integer.MAX_VALUE) {
|
||||
throw new IllegalStateException("消息中心 Token 缓存有效期不合法");
|
||||
}
|
||||
Object saved = redisService.eval(CACHE_IF_OWNER, List.of(lockKey, key),
|
||||
List.of(owner, String.valueOf(ttl), value));
|
||||
if (!Long.valueOf(1).equals(saved)) {
|
||||
throw new IllegalStateException("消息中心 Token 刷新锁已失效");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/** token 为接口报告失效的旧凭证;Lua 原子比较删除,避免误删其他实例刷新的凭证。 */
|
||||
private void invalidateToken(String token) {
|
||||
redisService.eval(DELETE_IF_MATCH, List.of(tokenCacheKey()), List.of(token));
|
||||
}
|
||||
|
||||
/** 按应用与单位隔离凭证;花括号确保 Token 和刷新锁在 Redis Cluster 中属于同一槽。 */
|
||||
private String tokenCacheKey() {
|
||||
return RedisConstant.REDIS_KEY_DING_MESSAGE_TOKEN + "{" + APP_ID + ":" + COMP_ID + "}";
|
||||
}
|
||||
|
||||
/**
|
||||
* token 为 URL 查询凭证,params 为单人消息 JSON;
|
||||
* 返回含 status、code、message、info 的响应对象,调用方继续判断业务发送结果。
|
||||
*/
|
||||
private JSONObject sendMessage(String token, JSONObject params) {
|
||||
String url = BASE_URL + "/message/send?token=" + URLEncoder.encode(token, StandardCharsets.UTF_8);
|
||||
try (HttpResponse response = HttpRequest.post(url).header("Content-Type", "application/json; charset=UTF-8").body(params.toString())
|
||||
.timeout(REQUEST_TIMEOUT).execute()) {
|
||||
if (!response.isOk()) {
|
||||
throw new IllegalStateException("消息中心发送 HTTP 状态异常");
|
||||
}
|
||||
return JSONUtil.parseObj(response.body());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
package com.budwk.app.base.sms.impl.jshvc;
|
||||
|
||||
import com.budwk.app.base.sms.SmsService;
|
||||
import com.budwk.app.todo.platform.TodoPlatformConfig;
|
||||
import com.budwk.app.web.commons.base.Globals;
|
||||
import com.wisedu.casp.sdk.api.mc.ReceiverDto;
|
||||
import com.wisedu.casp.sdk.api.mc.SendMsgRequest;
|
||||
import com.wisedu.casp.sdk.api.mc.SendMsgResponse;
|
||||
import com.wisedu.casp.sdk.api.mc.constants.MsgType_Constants;
|
||||
import com.wisedu.casp.sdk.api.mc.constants.SendType_Constants;
|
||||
import com.wisedu.casp.sdk.api.mc.constants.WxSendType_Constants;
|
||||
import com.wisedu.casp.sdk.api.mc.recallMsg.RecallMsgRequest;
|
||||
import com.wisedu.casp.sdk.api.mc.recallMsg.RecallMsgResponse;
|
||||
import com.wisedu.casp.sdk.core.Client;
|
||||
import com.wisedu.casp.sdk.core.DefaultClient;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.nutz.ioc.loader.annotation.IocBean;
|
||||
import org.nutz.json.Json;
|
||||
import org.nutz.lang.Lang;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @ClassName SmsJshvcServiceImpl
|
||||
* @Author JyuHsin
|
||||
* @Date 2025/11/26 19:32
|
||||
* @Version 1.0
|
||||
* @Description TODO
|
||||
*/
|
||||
@Slf4j
|
||||
@IocBean
|
||||
public class SmsJshvcServiceImpl implements SmsService {
|
||||
|
||||
@Override
|
||||
public void sendSingleMessage(String loginName, String content) {
|
||||
this.sendSingleMessage(loginName, "智慧工会", content);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendSingleMessage(String loginName, String title, String content) {
|
||||
this.massSendMessage(List.of(loginName), title, content);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendSingleTextCard(String loginName, String content, String pcUrl, String mobileUrl) {
|
||||
this.sendSingleTextCard(loginName, "智慧工会", content, pcUrl, mobileUrl);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendSingleTextCard(String loginName, String title, String content, String pcUrl, String mobileUrl) {
|
||||
this.massSendTextCard(List.of(loginName), title, content, pcUrl, mobileUrl);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendSinglePicTextCard(String loginName, String content, String pcUrl, String mobileUrl, String picUrl) {
|
||||
this.sendSinglePicTextCard(loginName, "智慧工会", content, pcUrl, mobileUrl, picUrl);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendSinglePicTextCard(String loginName, String title, String content, String pcUrl, String mobileUrl, String picUrl) {
|
||||
this.massSendPicTextCard(List.of(loginName), title, content, pcUrl, mobileUrl, picUrl);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void massSendMessage(List<String> loginNames, String content) {
|
||||
this.massSendMessage(loginNames, "智慧工会", content);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void massSendMessage(List<String> loginNames, String title, String content) {
|
||||
List<ReceiverDto> list = loginNames.stream().map(loginName -> {
|
||||
ReceiverDto receiverDto = new ReceiverDto();
|
||||
receiverDto.setUserId(loginName);
|
||||
return receiverDto;
|
||||
}).toList();
|
||||
this.doSend(WxSendType_Constants.TEXT, list, title, content, null, null, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void massSendTextCard(List<String> loginNames, String content, String pcUrl, String mobileUrl) {
|
||||
this.massSendTextCard(loginNames, "智慧工会", content, pcUrl, mobileUrl);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void massSendTextCard(List<String> loginNames, String title, String content, String pcUrl, String mobileUrl) {
|
||||
List<ReceiverDto> list = loginNames.stream().map(loginName -> {
|
||||
ReceiverDto receiverDto = new ReceiverDto();
|
||||
receiverDto.setUserId(loginName);
|
||||
return receiverDto;
|
||||
}).toList();
|
||||
this.doSend(WxSendType_Constants.TEXT_CARD, list, title, content, pcUrl, mobileUrl, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void massSendPicTextCard(List<String> loginNames, String content, String pcUrl, String mobileUrl, String picUrl) {
|
||||
this.massSendPicTextCard(loginNames, "智慧工会", content, pcUrl, mobileUrl, picUrl);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void massSendPicTextCard(List<String> loginNames, String title, String content, String pcUrl, String mobileUrl, String picUrl) {
|
||||
List<ReceiverDto> list = loginNames.stream().map(loginName -> {
|
||||
ReceiverDto receiverDto = new ReceiverDto();
|
||||
receiverDto.setUserId(loginName);
|
||||
return receiverDto;
|
||||
}).toList();
|
||||
this.doSend(WxSendType_Constants.NEWS, list, title, content, pcUrl, mobileUrl, picUrl);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void recallMsg(String msgId) {
|
||||
try {
|
||||
Client client = new DefaultClient(TodoPlatformConfig.TODO_PLATFORM_BASE_URL, TodoPlatformConfig.TODO_APP_ID, TodoPlatformConfig.TODO_APP_SECRET);
|
||||
RecallMsgRequest request = new RecallMsgRequest();
|
||||
request.setMsgId(msgId);
|
||||
|
||||
log.info("撤回企业微信消息,请求参数:{}", Json.toJson(request));
|
||||
RecallMsgResponse response = client.execute(request);
|
||||
log.info("撤回企业微信消息,接口响应数据:{}", Json.toJson(response));
|
||||
} catch (Exception e) {
|
||||
log.error("撤回企业微信消息失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void doSend(String sendType,
|
||||
List<ReceiverDto> receivers,
|
||||
String title,
|
||||
String content,
|
||||
String pcUrl,
|
||||
String mobileUrl,
|
||||
String picUrl) {
|
||||
if (!Globals.MyConfig.getBoolean("AppSms", false)) {
|
||||
log.error("【log】消息配置未开启");
|
||||
return;
|
||||
}
|
||||
if (!Globals.sso) {
|
||||
log.error("【log】开发模式不允许发送企业微信通知");
|
||||
return;
|
||||
}
|
||||
|
||||
if (Lang.isEmpty(receivers)) {
|
||||
log.error("send fail by receivers is null");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 创建客户端
|
||||
Client client = new DefaultClient(TodoPlatformConfig.TODO_PLATFORM_BASE_URL, TodoPlatformConfig.TODO_APP_ID, TodoPlatformConfig.TODO_APP_SECRET);
|
||||
SendMsgRequest request = new SendMsgRequest();
|
||||
request.setSubject(title);
|
||||
request.setContent(content);
|
||||
request.setMsgType(MsgType_Constants.COMMON);
|
||||
request.setSendType(SendType_Constants.WECHAT_ENTERPRISE);
|
||||
request.setWxSendType(sendType);
|
||||
request.setReceivers(receivers);
|
||||
|
||||
if (WxSendType_Constants.TEXT_CARD.equals(sendType)) {
|
||||
request.setPcUrl(pcUrl);
|
||||
request.setMobileUrl(mobileUrl);
|
||||
request.setUrlDesc("查看详情");
|
||||
}
|
||||
if (WxSendType_Constants.NEWS.equals(sendType)) {
|
||||
request.setPcUrl(pcUrl);
|
||||
request.setMobileUrl(mobileUrl);
|
||||
request.setUrlDesc("查看详情");
|
||||
request.setQyWeChatImgUrl(picUrl);
|
||||
}
|
||||
|
||||
log.info("发送企业微信通知,请求参数:{}", Json.toJson(request));
|
||||
SendMsgResponse response = client.execute(request);
|
||||
log.info("发送企业微信通知,接口响应数据:{}", Json.toJson(response));
|
||||
|
||||
if (response.isSuccess() && response.getStatus() == 200) {
|
||||
// TODO 消息Id,后面可以存着,撤回要用到
|
||||
String msgId = response.getMsgId();
|
||||
log.info("发送企业微信通知成功: {}, msg: {}", response.getStatus(), response.getMsg());
|
||||
} else {
|
||||
log.info("发送企业微信通知失败:{}", response.getMsg());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("发送企业微信通知失败", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,9 @@ import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.io.RandomAccessRead;
|
||||
import org.apache.pdfbox.io.RandomAccessReadBuffer;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.nutz.aop.interceptor.ioc.TransAop;
|
||||
import org.nutz.dao.Cnd;
|
||||
import org.nutz.ioc.aop.Aop;
|
||||
import org.nutz.ioc.loader.annotation.Inject;
|
||||
import org.nutz.ioc.loader.annotation.IocBean;
|
||||
import org.nutz.mvc.annotation.*;
|
||||
@@ -143,6 +145,43 @@ public class SysFileController {
|
||||
return Result.success().addData(sysFileService.convertHtml(file));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将PDF逐页转换为图片富文本。
|
||||
*
|
||||
* @param file 上传的PDF文件
|
||||
* @return Result,data为按PDF页码顺序组成的图片HTML
|
||||
*/
|
||||
@At
|
||||
@SaCheckLogin
|
||||
@ApiOperation("文件管理-PDF转换图片富文本")
|
||||
@AdaptBy(type = UploadAdaptor.class, args = {"ioc:fileUpload"})
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public Result convertPdfToImageHtml(TempFile file) {
|
||||
if (file == null || !"pdf".equalsIgnoreCase(FileUtil.extName(file.getSubmittedFileName()))) {
|
||||
return Result.error("请上传PDF文件");
|
||||
}
|
||||
return Result.success().addData(sysFileService.convertPdfToImageHtml(file));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将Excel的每个非空页签转换为独立HTML表格。
|
||||
*
|
||||
* @param file 上传的xls或xlsx文件
|
||||
* @return Result,data为按页签顺序组成的标题及HTML表格
|
||||
*/
|
||||
@At
|
||||
@SaCheckLogin
|
||||
@ApiOperation("文件管理-Excel转换表格富文本")
|
||||
@AdaptBy(type = UploadAdaptor.class, args = {"ioc:fileUpload"})
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public Result convertExcelToHtml(TempFile file) {
|
||||
String extension = file == null ? "" : FileUtil.extName(file.getSubmittedFileName());
|
||||
if (!"xls".equalsIgnoreCase(extension) && !"xlsx".equalsIgnoreCase(extension)) {
|
||||
return Result.error("请上传xls或xlsx文件");
|
||||
}
|
||||
return Result.success().addData(sysFileService.convertExcelToHtml(file));
|
||||
}
|
||||
|
||||
|
||||
@At
|
||||
@ApiOperation("文件管理-删除文件")
|
||||
|
||||
@@ -386,4 +386,16 @@ public class Sys_user extends BaseModel implements Serializable {
|
||||
@ColDefine(type = ColType.VARCHAR, width = 50)
|
||||
@DataCenterColumn(name = "党政职务", key = "DZZW")
|
||||
private String position;
|
||||
|
||||
@Column
|
||||
@Comment("人员类别码")
|
||||
@ColDefine(type = ColType.VARCHAR, width = 50)
|
||||
@DataCenterColumn(name = "人员类别码", key = "rylbm")
|
||||
private String rylbm;
|
||||
|
||||
@Column
|
||||
@Comment("在岗情况码")
|
||||
@ColDefine(type = ColType.VARCHAR, width = 50)
|
||||
@DataCenterColumn(name = "在岗情况码", key = "zgqkm")
|
||||
private String zgqkm;
|
||||
}
|
||||
|
||||
@@ -71,4 +71,20 @@ public interface SysFileService extends BaseService<Sys_file> {
|
||||
* 转换为HTML
|
||||
*/
|
||||
String convertHtml(TempFile file);
|
||||
|
||||
/**
|
||||
* 将PDF逐页转换为图片并生成富文本HTML。
|
||||
*
|
||||
* @param file PDF文件,文件内容不能为空
|
||||
* @return 按PDF页码顺序排列的图片HTML
|
||||
*/
|
||||
String convertPdfToImageHtml(TempFile file);
|
||||
|
||||
/**
|
||||
* 将Excel中的非空页签转换为富文本HTML表格。
|
||||
*
|
||||
* @param file xls或xlsx文件,文件内容不能为空
|
||||
* @return 按页签顺序排列的标题及HTML表格
|
||||
*/
|
||||
String convertExcelToHtml(TempFile file);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import com.budwk.app.zhgh.democratic.proposal.models.ProposalConfig;
|
||||
import com.budwk.app.zhgh.staffmanage.member.constant.MemberChangeOrigin;
|
||||
import com.budwk.app.zhgh.staffmanage.member.constant.MemberChangeType;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.nutz.aop.interceptor.ioc.TransAop;
|
||||
import org.nutz.dao.Chain;
|
||||
import org.nutz.dao.Cnd;
|
||||
@@ -182,6 +183,7 @@ public class SysDataUserAllUpdateServiceImpl implements SysDataUserUpdateService
|
||||
List<String> removeMemberUserIds = Collections.synchronizedList(new ArrayList<>());
|
||||
|
||||
List<String> preparedByList = List.of("人才市场派遣", "人力资源服务中心派遣", "海川派遣", "越仕中心", "柔性引进");
|
||||
List<String> rylbmList = List.of("退休返聘", "临聘");
|
||||
|
||||
// 处理每条数据
|
||||
for (Sys_user_source source : sources) {
|
||||
@@ -197,7 +199,9 @@ public class SysDataUserAllUpdateServiceImpl implements SysDataUserUpdateService
|
||||
u.setSalt(salt);
|
||||
u.setPassword(PwdUtil.getPassword(PwdUtil.generate(12), salt));
|
||||
|
||||
if (u.getPersonType().equals("在编") || preparedByList.contains(u.getPreparedBy())) {
|
||||
if (u.getUserState().equals("在职")
|
||||
&& (u.getPersonType().equals("在编") || preparedByList.contains(u.getPreparedBy()))
|
||||
&& (StringUtils.isBlank(u.getRylbm()) || !rylbmList.contains(u.getRylbm()))) {
|
||||
addMemberUserIds.add(u.getId());
|
||||
u.setMember(true);
|
||||
}
|
||||
@@ -208,7 +212,9 @@ public class SysDataUserAllUpdateServiceImpl implements SysDataUserUpdateService
|
||||
u.setMember(null);
|
||||
u.setMemberTime(null);
|
||||
// 更新会员状态
|
||||
if (u.getPersonType().equals("在编") || preparedByList.contains(u.getPreparedBy())) {
|
||||
if (u.getUserState().equals("在职")
|
||||
&& (u.getPersonType().equals("在编") || preparedByList.contains(u.getPreparedBy()))
|
||||
&& (StringUtils.isBlank(u.getRylbm()) || !rylbmList.contains(u.getRylbm()))) {
|
||||
if (!user.getMember()) {
|
||||
addMemberUserIds.add(u.getId());
|
||||
u.setMember(true);
|
||||
|
||||
@@ -23,16 +23,53 @@ import com.budwk.app.sys.services.SysFileService;
|
||||
import com.budwk.app.sys.utils.SysFileLocalUtil;
|
||||
import com.budwk.app.sys.utils.SysFileMinIoUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.cos.COSDictionary;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.rendering.ImageType;
|
||||
import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.apache.poi.hssf.usermodel.HSSFClientAnchor;
|
||||
import org.apache.poi.hssf.usermodel.HSSFPatriarch;
|
||||
import org.apache.poi.hssf.usermodel.HSSFPicture;
|
||||
import org.apache.poi.hssf.usermodel.HSSFShape;
|
||||
import org.apache.poi.hssf.usermodel.HSSFSheet;
|
||||
import org.apache.poi.hssf.util.HSSFColor;
|
||||
import org.apache.poi.ss.usermodel.BorderStyle;
|
||||
import org.apache.poi.ss.usermodel.Cell;
|
||||
import org.apache.poi.ss.usermodel.CellStyle;
|
||||
import org.apache.poi.ss.usermodel.DataFormatter;
|
||||
import org.apache.poi.ss.usermodel.FillPatternType;
|
||||
import org.apache.poi.ss.usermodel.Font;
|
||||
import org.apache.poi.ss.usermodel.FormulaEvaluator;
|
||||
import org.apache.poi.ss.usermodel.HorizontalAlignment;
|
||||
import org.apache.poi.ss.usermodel.PictureData;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.VerticalAlignment;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.apache.poi.ss.usermodel.WorkbookFactory;
|
||||
import org.apache.poi.ss.util.CellRangeAddress;
|
||||
import org.apache.poi.xssf.usermodel.XSSFClientAnchor;
|
||||
import org.apache.poi.xssf.usermodel.XSSFDrawing;
|
||||
import org.apache.poi.xssf.usermodel.XSSFPicture;
|
||||
import org.apache.poi.xssf.usermodel.XSSFShape;
|
||||
import org.apache.poi.xssf.usermodel.XSSFSheet;
|
||||
import org.nutz.aop.interceptor.ioc.TransAop;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.nutz.dao.Cnd;
|
||||
import org.nutz.dao.Dao;
|
||||
import org.nutz.ioc.aop.Aop;
|
||||
import org.nutz.ioc.loader.annotation.IocBean;
|
||||
import org.nutz.json.Json;
|
||||
import org.nutz.lang.Strings;
|
||||
import org.nutz.lang.random.R;
|
||||
import org.nutz.mvc.upload.TempFile;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
@@ -41,7 +78,12 @@ import java.math.RoundingMode;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.Base64;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@@ -199,6 +241,370 @@ public class SysFileServiceImpl extends BaseServiceImpl<Sys_file> implements Sys
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public String convertPdfToImageHtml(TempFile file) {
|
||||
validateFileExtension(file, List.of("pdf"), "请上传PDF文件");
|
||||
try (PDDocument document = Loader.loadPDF(file.getFile())) {
|
||||
validatePdfSafety(document);
|
||||
if (document.getNumberOfPages() < 1) {
|
||||
throw new BaseException("PDF文件中没有可导入的页面");
|
||||
}
|
||||
|
||||
PDFRenderer renderer = new PDFRenderer(document);
|
||||
StringBuilder html = new StringBuilder();
|
||||
for (int pageIndex = 0; pageIndex < document.getNumberOfPages(); pageIndex++) {
|
||||
// 144 DPI兼顾文字清晰度和图片体积,页面顺序与原PDF保持一致。
|
||||
BufferedImage pageImage = renderer.renderImageWithDPI(pageIndex, 144, ImageType.RGB);
|
||||
try (ByteArrayOutputStream imageOutput = new ByteArrayOutputStream()) {
|
||||
ImageIO.write(pageImage, "png", imageOutput);
|
||||
String imageUrl = storageFile(SysFileEngineTypeEnum.MINIO.getValue(),
|
||||
R.UU32() + ".png", imageOutput.toByteArray(), false);
|
||||
html.append("<p style=\"margin:0 0 12px;\">")
|
||||
.append("<img src=\"").append(imageUrl)
|
||||
.append("\" style=\"display:block;max-width:100%;height:auto;margin:0 auto;\">")
|
||||
.append("</p>");
|
||||
} finally {
|
||||
pageImage.flush();
|
||||
}
|
||||
}
|
||||
return html.toString();
|
||||
} catch (BaseException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("PDF转换图片异常", e);
|
||||
throw new BaseException("PDF文件读取失败,请确认文件未损坏");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public String convertExcelToHtml(TempFile file) {
|
||||
validateFileExtension(file, List.of("xls", "xlsx"), "请上传xls或xlsx文件");
|
||||
try (Workbook workbook = WorkbookFactory.create(file.getInputStream())) {
|
||||
DataFormatter formatter = new DataFormatter();
|
||||
FormulaEvaluator evaluator = workbook.getCreationHelper().createFormulaEvaluator();
|
||||
StringBuilder html = new StringBuilder();
|
||||
int tableCount = 0;
|
||||
|
||||
for (int sheetIndex = 0; sheetIndex < workbook.getNumberOfSheets(); sheetIndex++) {
|
||||
Sheet sheet = workbook.getSheetAt(sheetIndex);
|
||||
String tableHtml = buildSheetHtml(workbook, sheet, formatter, evaluator);
|
||||
if (StrUtil.isBlank(tableHtml)) {
|
||||
continue;
|
||||
}
|
||||
tableCount++;
|
||||
html.append("<div style=\"margin-bottom:20px;\">")
|
||||
.append("<h3 style=\"margin:0 0 10px;font-size:16px;\">")
|
||||
.append(Strings.escapeHtml(sheet.getSheetName()))
|
||||
.append("</h3>")
|
||||
.append("<div style=\"max-width:100%;overflow-x:auto;\">")
|
||||
.append(tableHtml)
|
||||
.append("</div></div>");
|
||||
}
|
||||
|
||||
if (tableCount < 1) {
|
||||
throw new BaseException("Excel文件中没有可导入的表格内容");
|
||||
}
|
||||
return html.toString();
|
||||
} catch (BaseException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("Excel转换HTML异常", e);
|
||||
throw new BaseException("Excel文件读取失败,请确认文件格式正确且未损坏");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将单个非空页签转换为HTML表格,合并单元格使用rowspan和colspan还原。
|
||||
*/
|
||||
private String buildSheetHtml(Workbook workbook, Sheet sheet, DataFormatter formatter,
|
||||
FormulaEvaluator evaluator) {
|
||||
List<ExcelEmbeddedImage> embeddedImages = extractSheetImages(sheet);
|
||||
int minRow = Integer.MAX_VALUE;
|
||||
int maxRow = -1;
|
||||
int minColumn = Integer.MAX_VALUE;
|
||||
int maxColumn = -1;
|
||||
|
||||
for (Row row : sheet) {
|
||||
for (Cell cell : row) {
|
||||
if (StrUtil.isBlank(getCellDisplayValue(cell, formatter, evaluator))) {
|
||||
continue;
|
||||
}
|
||||
minRow = Math.min(minRow, cell.getRowIndex());
|
||||
maxRow = Math.max(maxRow, cell.getRowIndex());
|
||||
minColumn = Math.min(minColumn, cell.getColumnIndex());
|
||||
maxColumn = Math.max(maxColumn, cell.getColumnIndex());
|
||||
}
|
||||
}
|
||||
// Excel图片存放在绘图层,即使锚定单元格没有文字,也必须纳入表格范围。
|
||||
for (ExcelEmbeddedImage image : embeddedImages) {
|
||||
minRow = Math.min(minRow, image.rowIndex);
|
||||
maxRow = Math.max(maxRow, image.rowIndex);
|
||||
minColumn = Math.min(minColumn, image.columnIndex);
|
||||
maxColumn = Math.max(maxColumn, image.columnIndex);
|
||||
}
|
||||
if (maxRow < 0 || maxColumn < 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// 内容位于合并区域时,将区域边界纳入表格范围,避免合并内容被截断。
|
||||
for (CellRangeAddress range : sheet.getMergedRegions()) {
|
||||
if (range.getFirstRow() <= maxRow && range.getLastRow() >= minRow
|
||||
&& range.getFirstColumn() <= maxColumn && range.getLastColumn() >= minColumn) {
|
||||
minRow = Math.min(minRow, range.getFirstRow());
|
||||
maxRow = Math.max(maxRow, range.getLastRow());
|
||||
minColumn = Math.min(minColumn, range.getFirstColumn());
|
||||
maxColumn = Math.max(maxColumn, range.getLastColumn());
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, CellRangeAddress> mergedStarts = new HashMap<>();
|
||||
Set<String> mergedCovered = new HashSet<>();
|
||||
for (CellRangeAddress range : sheet.getMergedRegions()) {
|
||||
if (range.getLastRow() < minRow || range.getFirstRow() > maxRow
|
||||
|| range.getLastColumn() < minColumn || range.getFirstColumn() > maxColumn) {
|
||||
continue;
|
||||
}
|
||||
mergedStarts.put(cellKey(range.getFirstRow(), range.getFirstColumn()), range);
|
||||
for (int rowIndex = range.getFirstRow(); rowIndex <= range.getLastRow(); rowIndex++) {
|
||||
for (int columnIndex = range.getFirstColumn(); columnIndex <= range.getLastColumn(); columnIndex++) {
|
||||
if (rowIndex != range.getFirstRow() || columnIndex != range.getFirstColumn()) {
|
||||
mergedCovered.add(cellKey(rowIndex, columnIndex));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, List<String>> imageUrlsByCell = new HashMap<>();
|
||||
for (ExcelEmbeddedImage image : embeddedImages) {
|
||||
int imageRow = image.rowIndex;
|
||||
int imageColumn = image.columnIndex;
|
||||
for (CellRangeAddress range : sheet.getMergedRegions()) {
|
||||
if (range.isInRange(imageRow, imageColumn)) {
|
||||
imageRow = range.getFirstRow();
|
||||
imageColumn = range.getFirstColumn();
|
||||
break;
|
||||
}
|
||||
}
|
||||
imageUrlsByCell.computeIfAbsent(cellKey(imageRow, imageColumn), key -> new ArrayList<>())
|
||||
.add(image.imageUrl);
|
||||
}
|
||||
|
||||
StringBuilder html = new StringBuilder("<table style=\"border-collapse:collapse;width:100%;table-layout:auto;\">");
|
||||
for (int rowIndex = minRow; rowIndex <= maxRow; rowIndex++) {
|
||||
Row row = sheet.getRow(rowIndex);
|
||||
html.append("<tr>");
|
||||
for (int columnIndex = minColumn; columnIndex <= maxColumn; columnIndex++) {
|
||||
String key = cellKey(rowIndex, columnIndex);
|
||||
if (mergedCovered.contains(key)) {
|
||||
continue;
|
||||
}
|
||||
Cell cell = row == null ? null : row.getCell(columnIndex);
|
||||
CellRangeAddress range = mergedStarts.get(key);
|
||||
html.append("<td");
|
||||
if (range != null) {
|
||||
int rowSpan = range.getLastRow() - range.getFirstRow() + 1;
|
||||
int columnSpan = range.getLastColumn() - range.getFirstColumn() + 1;
|
||||
if (rowSpan > 1) {
|
||||
html.append(" rowspan=\"").append(rowSpan).append("\"");
|
||||
}
|
||||
if (columnSpan > 1) {
|
||||
html.append(" colspan=\"").append(columnSpan).append("\"");
|
||||
}
|
||||
}
|
||||
html.append(" style=\"").append(buildCellStyle(workbook, cell)).append("\">")
|
||||
.append(buildCellHtml(cell, formatter, evaluator, imageUrlsByCell.get(key)))
|
||||
.append("</td>");
|
||||
}
|
||||
html.append("</tr>");
|
||||
}
|
||||
return html.append("</table>").toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取页签绘图层中的嵌入图片,图片锚点用于确定其所属单元格。
|
||||
*/
|
||||
private List<ExcelEmbeddedImage> extractSheetImages(Sheet sheet) {
|
||||
List<ExcelEmbeddedImage> images = new ArrayList<>();
|
||||
if (sheet instanceof XSSFSheet xssfSheet) {
|
||||
XSSFDrawing drawing = xssfSheet.getDrawingPatriarch();
|
||||
if (drawing != null) {
|
||||
for (XSSFShape shape : drawing.getShapes()) {
|
||||
if (shape instanceof XSSFPicture picture && picture.getAnchor() instanceof XSSFClientAnchor anchor) {
|
||||
addExcelEmbeddedImage(images, anchor.getRow1(), anchor.getCol1(), picture.getPictureData());
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (sheet instanceof HSSFSheet hssfSheet) {
|
||||
HSSFPatriarch drawing = hssfSheet.getDrawingPatriarch();
|
||||
if (drawing != null) {
|
||||
for (HSSFShape shape : drawing.getChildren()) {
|
||||
if (shape instanceof HSSFPicture picture && picture.getAnchor() instanceof HSSFClientAnchor anchor) {
|
||||
addExcelEmbeddedImage(images, anchor.getRow1(), anchor.getCol1(), picture.getPictureData());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return images;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将Excel嵌入图片写入现有文件存储,并记录图片对应的单元格位置。
|
||||
*/
|
||||
private void addExcelEmbeddedImage(List<ExcelEmbeddedImage> images, int rowIndex, int columnIndex,
|
||||
PictureData pictureData) {
|
||||
if (pictureData == null || pictureData.getData() == null || pictureData.getData().length < 1) {
|
||||
return;
|
||||
}
|
||||
String extension = StrUtil.blankToDefault(pictureData.suggestFileExtension(), "png")
|
||||
.replaceAll("[^a-zA-Z0-9]", "");
|
||||
if (StrUtil.isBlank(extension)) {
|
||||
extension = "png";
|
||||
}
|
||||
String imageUrl = storageFile(SysFileEngineTypeEnum.MINIO.getValue(),
|
||||
R.UU32() + "." + extension.toLowerCase(), pictureData.getData(), false);
|
||||
images.add(new ExcelEmbeddedImage(rowIndex, columnIndex, imageUrl));
|
||||
}
|
||||
|
||||
private String getCellDisplayValue(Cell cell, DataFormatter formatter, FormulaEvaluator evaluator) {
|
||||
if (cell == null) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return formatter.formatCellValue(cell, evaluator);
|
||||
} catch (Exception ignored) {
|
||||
return formatter.formatCellValue(cell);
|
||||
}
|
||||
}
|
||||
|
||||
private String formatCellHtml(Cell cell, DataFormatter formatter, FormulaEvaluator evaluator) {
|
||||
String value = Strings.escapeHtml(getCellDisplayValue(cell, formatter, evaluator));
|
||||
return StrUtil.isBlank(value) ? " " : value.replace("\r\n", "<br>").replace("\n", "<br>");
|
||||
}
|
||||
|
||||
private String buildCellHtml(Cell cell, DataFormatter formatter, FormulaEvaluator evaluator,
|
||||
List<String> imageUrls) {
|
||||
String textHtml = formatCellHtml(cell, formatter, evaluator);
|
||||
if (imageUrls == null || imageUrls.isEmpty()) {
|
||||
return textHtml;
|
||||
}
|
||||
StringBuilder html = new StringBuilder();
|
||||
if (!" ".equals(textHtml)) {
|
||||
html.append("<div style=\"margin-bottom:6px;\">").append(textHtml).append("</div>");
|
||||
}
|
||||
for (String imageUrl : imageUrls) {
|
||||
html.append("<img src=\"").append(imageUrl)
|
||||
.append("\" style=\"display:block;max-width:100%;max-height:240px;height:auto;margin:4px auto;object-fit:contain;\">");
|
||||
}
|
||||
return html.toString();
|
||||
}
|
||||
|
||||
private String buildCellStyle(Workbook workbook, Cell cell) {
|
||||
StringBuilder styleText = new StringBuilder("padding:6px 8px;border:1px solid #dcdfe6;");
|
||||
if (cell == null) {
|
||||
return styleText.toString();
|
||||
}
|
||||
CellStyle cellStyle = cell.getCellStyle();
|
||||
HorizontalAlignment alignment = cellStyle.getAlignment();
|
||||
if (alignment == HorizontalAlignment.CENTER || alignment == HorizontalAlignment.CENTER_SELECTION) {
|
||||
styleText.append("text-align:center;");
|
||||
} else if (alignment == HorizontalAlignment.RIGHT) {
|
||||
styleText.append("text-align:right;");
|
||||
} else {
|
||||
styleText.append("text-align:left;");
|
||||
}
|
||||
VerticalAlignment verticalAlignment = cellStyle.getVerticalAlignment();
|
||||
if (verticalAlignment == VerticalAlignment.TOP) {
|
||||
styleText.append("vertical-align:top;");
|
||||
} else if (verticalAlignment == VerticalAlignment.BOTTOM) {
|
||||
styleText.append("vertical-align:bottom;");
|
||||
} else {
|
||||
styleText.append("vertical-align:middle;");
|
||||
}
|
||||
styleText.append(cellStyle.getWrapText() ? "white-space:pre-wrap;" : "white-space:nowrap;");
|
||||
|
||||
Font font = workbook.getFontAt(cellStyle.getFontIndex());
|
||||
if (font.getBold()) {
|
||||
styleText.append("font-weight:bold;");
|
||||
}
|
||||
if (font.getItalic()) {
|
||||
styleText.append("font-style:italic;");
|
||||
}
|
||||
if (font.getFontHeightInPoints() > 0) {
|
||||
styleText.append("font-size:").append(font.getFontHeightInPoints()).append("pt;");
|
||||
}
|
||||
appendIndexedColor(styleText, "color", font.getColor());
|
||||
if (cellStyle.getFillPattern() != FillPatternType.NO_FILL) {
|
||||
appendIndexedColor(styleText, "background-color", cellStyle.getFillForegroundColor());
|
||||
}
|
||||
if (cellStyle.getBorderTop() != BorderStyle.NONE || cellStyle.getBorderRight() != BorderStyle.NONE
|
||||
|| cellStyle.getBorderBottom() != BorderStyle.NONE || cellStyle.getBorderLeft() != BorderStyle.NONE) {
|
||||
styleText.append("border:1px solid #909399;");
|
||||
}
|
||||
return styleText.toString();
|
||||
}
|
||||
|
||||
private void appendIndexedColor(StringBuilder styleText, String cssName, short colorIndex) {
|
||||
try {
|
||||
HSSFColor color = HSSFColor.getIndexHash().get(colorIndex);
|
||||
if (color != null) {
|
||||
short[] rgb = color.getTriplet();
|
||||
styleText.append(cssName).append(":#")
|
||||
.append(String.format("%02x%02x%02x", rgb[0], rgb[1], rgb[2]))
|
||||
.append(";");
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 自定义主题色无法直接映射时保留浏览器默认颜色,不影响表格内容导入。
|
||||
}
|
||||
}
|
||||
|
||||
private String cellKey(int rowIndex, int columnIndex) {
|
||||
return rowIndex + "_" + columnIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Excel嵌入图片及其左上角锚点信息。
|
||||
*/
|
||||
private static class ExcelEmbeddedImage {
|
||||
private final int rowIndex;
|
||||
private final int columnIndex;
|
||||
private final String imageUrl;
|
||||
|
||||
private ExcelEmbeddedImage(int rowIndex, int columnIndex, String imageUrl) {
|
||||
this.rowIndex = rowIndex;
|
||||
this.columnIndex = columnIndex;
|
||||
this.imageUrl = imageUrl;
|
||||
}
|
||||
}
|
||||
|
||||
private void validateFileExtension(TempFile file, List<String> allowedExtensions, String errorMessage) {
|
||||
if (file == null || file.getFile() == null || !file.getFile().exists()) {
|
||||
throw new BaseException("文件内容为空");
|
||||
}
|
||||
String extension = FileUtil.extName(file.getSubmittedFileName());
|
||||
if (StrUtil.isBlank(extension) || !allowedExtensions.contains(extension.toLowerCase())) {
|
||||
throw new BaseException(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
private void validatePdfSafety(PDDocument document) {
|
||||
COSDictionary catalog = document.getDocumentCatalog().getCOSObject();
|
||||
if (catalog.containsKey(COSName.NAMES)) {
|
||||
COSDictionary names = (COSDictionary) catalog.getDictionaryObject(COSName.NAMES);
|
||||
if (names != null && names.containsKey(COSName.JAVA_SCRIPT)) {
|
||||
throw new BaseException("系统检测到此PDF文件具有一定危险性");
|
||||
}
|
||||
}
|
||||
if (catalog.containsKey(COSName.OPEN_ACTION)) {
|
||||
Object openAction = catalog.getDictionaryObject(COSName.OPEN_ACTION);
|
||||
if (openAction instanceof COSDictionary action
|
||||
&& COSName.JAVA_SCRIPT.equals(action.getDictionaryObject(COSName.S))) {
|
||||
throw new BaseException("系统检测到此PDF文件具有一定危险性");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 存储文件
|
||||
**/
|
||||
|
||||
+50
-5
@@ -14,6 +14,7 @@ import com.budwk.app.sys.models.Sys_unit;
|
||||
import com.budwk.app.zhgh.activity.trainSignUp.models.*;
|
||||
import com.budwk.app.zhgh.activity.trainSignUp.service.TrainSignUpActivityService;
|
||||
import com.budwk.app.zhgh.club.model.SysClub;
|
||||
import com.budwk.app.zhgh.welfare.service.WelfareProjectService;
|
||||
import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -22,6 +23,8 @@ import org.nutz.dao.Cnd;
|
||||
import org.nutz.dao.Dao;
|
||||
import org.nutz.dao.Sqls;
|
||||
import org.nutz.dao.sql.Sql;
|
||||
import org.nutz.aop.interceptor.ioc.TransAop;
|
||||
import org.nutz.ioc.aop.Aop;
|
||||
import org.nutz.ioc.loader.annotation.Inject;
|
||||
import org.nutz.ioc.loader.annotation.IocBean;
|
||||
import org.nutz.lang.util.NutMap;
|
||||
@@ -52,6 +55,8 @@ public class TrainSignUpManageController {
|
||||
private TrainSignUpActivityService trainSignUpActivityManageService;
|
||||
@Inject
|
||||
private Dao dao;
|
||||
@Inject
|
||||
private WelfareProjectService welfareProjectService;
|
||||
|
||||
@At("")
|
||||
@SaCheckPermission("trainSignUp.manage")
|
||||
@@ -90,6 +95,34 @@ public class TrainSignUpManageController {
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
/** 接收主活动和子活动 ID;Result.data 为当前预计接收人数。 */
|
||||
@At
|
||||
@ApiOperation("查询子活动报名提醒人数")
|
||||
@SaCheckPermission("trainSignUp.manage")
|
||||
public Result reminderRecipientCount(@Param("activityId") String activityId, @Param("courseId") String courseId) {
|
||||
try {
|
||||
return Result.success().addData(trainSignUpActivityManageService.countReminderRecipients(activityId, courseId));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Result.error(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 接收主活动、子活动 ID 和提醒正文;成功表示已提交后台发送,data 为本批次人数。 */
|
||||
@At
|
||||
@ApiOperation("提交子活动报名提醒")
|
||||
@SaCheckPermission("trainSignUp.manage")
|
||||
@SLog(tag = "品牌活动-活动管理", msg = "提交子活动报名提醒")
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public Result submitSignupReminder(@Param("activityId") String activityId, @Param("courseId") String courseId,
|
||||
@Param("content") String content) {
|
||||
try {
|
||||
int count = trainSignUpActivityManageService.submitSignupReminder(activityId, courseId, content);
|
||||
return Result.success("已提交发送,接收人数:" + count).addData(count);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Result.error(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@At
|
||||
@ApiOperation("活动状态变更")
|
||||
@SaCheckPermission("trainSignUp.manage")
|
||||
@@ -153,13 +186,25 @@ public class TrainSignUpManageController {
|
||||
@ApiOperation("品牌活动新增/修改")
|
||||
@SaCheckPermission("trainSignUp.manage")
|
||||
@SLog(tag = "品牌活动-活动管理", msg = "新增/修改活动")
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public Result doHandle(TrainSignUpActivity activity) {
|
||||
if (StrUtil.isBlank(activity.getId())) {
|
||||
trainSignUpActivityManageService.add(activity, null);
|
||||
} else {
|
||||
trainSignUpActivityManageService.edit(activity);
|
||||
try {
|
||||
if (StrUtil.isBlank(activity.getId())) {
|
||||
trainSignUpActivityManageService.add(activity, null);
|
||||
} else {
|
||||
trainSignUpActivityManageService.edit(activity);
|
||||
}
|
||||
return Result.success();
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Result.error(99, e.getMessage());
|
||||
}
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
@At
|
||||
@ApiOperation("查询签到扩展可关联的福利项目")
|
||||
@SaCheckPermission("trainSignUp.manage")
|
||||
public Result getWelfareProjects(Integer year) {
|
||||
return Result.success(welfareProjectService.getWelfareList(year));
|
||||
}
|
||||
|
||||
@At
|
||||
|
||||
+69
-32
@@ -10,8 +10,10 @@ import com.budwk.app.base.result.Result;
|
||||
import com.budwk.app.base.utils.DistanceUtil;
|
||||
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
|
||||
import com.budwk.app.zhgh.activity.trainSignUp.models.TrainSignUpCourse;
|
||||
import com.budwk.app.zhgh.activity.trainSignUp.models.TrainSignUpActivity;
|
||||
import com.budwk.app.zhgh.activity.trainSignUp.models.TrainSignUpUserCourse;
|
||||
import com.budwk.app.zhgh.activity.trainSignUp.service.TrainSignUpActivityService;
|
||||
import com.budwk.app.zhgh.map.TencentLocationService;
|
||||
import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -23,10 +25,12 @@ import org.nutz.dao.sql.Sql;
|
||||
import org.nutz.ioc.aop.Aop;
|
||||
import org.nutz.ioc.loader.annotation.Inject;
|
||||
import org.nutz.ioc.loader.annotation.IocBean;
|
||||
import org.nutz.lang.Lang;
|
||||
import org.nutz.lang.util.NutMap;
|
||||
import org.nutz.mvc.annotation.At;
|
||||
import org.nutz.mvc.annotation.Ok;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.Date;
|
||||
@@ -63,10 +67,15 @@ public class TrainsignUpMineController {
|
||||
public void h5Index() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开活动签到页,并按系统开关决定是否加载腾讯地图资源。
|
||||
* @param request 用于向页面及公共布局传递本次签到定位开关
|
||||
*/
|
||||
@At("/scan")
|
||||
@SaCheckPermission("h5.trainSignUp.mine")
|
||||
@Ok("beetl:/platform/zhghh5/activity/trainSignUp/scan/index.html")
|
||||
public void scan() {
|
||||
public void scan(HttpServletRequest request) {
|
||||
request.setAttribute("activitySignTencentMapEnabled", TencentLocationService.isActivitySignEnabled());
|
||||
}
|
||||
|
||||
@At
|
||||
@@ -74,43 +83,63 @@ public class TrainsignUpMineController {
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
@SaCheckPermission(value = {"trainSignUp.mine", "h5.trainSignUp.mine"}, mode = SaMode.OR)
|
||||
@SLog(tag = "品牌活动-活动签到", msg = "主动扫码签到")
|
||||
// courseId为二维码点位ID,lat/lng为腾讯GCJ-02纬度/经度;Result返回签到结果及扩展进度。
|
||||
public Result drivingScan(String courseId, Double lat, Double lng) {
|
||||
|
||||
// 主动扫码签到是用户自己打开扫一扫,扫二维码签到
|
||||
int count = dao.count(TrainSignUpUserCourse.class, Cnd.where("courseId", "=", courseId).and("userId", "=", SecurityUtil.getUserId()));
|
||||
if (count == 0) {
|
||||
return Result.error(99, "未查询到您的报名记录");
|
||||
}
|
||||
|
||||
TrainSignUpCourse course = dao.fetch(TrainSignUpCourse.class, courseId);
|
||||
List<Double> coordinates = course.getTransPosition();
|
||||
int minute = course.getSignUnit() != null ? course.getSignUnit() : 0;
|
||||
int radius = course.getRadius() != null ? course.getRadius() : 100;
|
||||
|
||||
boolean flag = true;
|
||||
// 获取现在的日期,并根据设置的数据
|
||||
List<TrainSignUpUserCourse> timeList = dao.query(TrainSignUpUserCourse.class, Cnd.where("courseId", "=", courseId).and("userId", "=", SecurityUtil.getUserId()));
|
||||
for (TrainSignUpUserCourse userCourse : timeList) {
|
||||
DateTime startTime = DateUtil.offsetMinute(userCourse.getCourseStartTime(), -minute);
|
||||
DateTime endTime = DateUtil.offsetMinute(userCourse.getCourseEndTime(), minute);
|
||||
if(DateUtil.isIn(DateUtil.date(), startTime, endTime)) {
|
||||
userCourse.setAttend(true);
|
||||
userCourse.setAttendTime(DateUtil.date());
|
||||
flag = false;
|
||||
TrainSignUpCourse extensionCourse = dao.fetch(TrainSignUpCourse.class, courseId);
|
||||
TrainSignUpActivity extensionActivity = extensionCourse == null ? null
|
||||
: dao.fetch(TrainSignUpActivity.class, extensionCourse.getActivityId());
|
||||
if (extensionActivity != null && extensionActivity.getSignPointCount() != null
|
||||
&& extensionActivity.getSignPointCount() > 0) {
|
||||
try {
|
||||
return Result.success("签到成功").addData(activityService.handleScanCodeSign(
|
||||
courseId, SecurityUtil.getUserId(), lat, lng));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Result.error(99, e.getMessage());
|
||||
}
|
||||
}
|
||||
if(flag) {
|
||||
return Result.error(99, "未到签到时间");
|
||||
|
||||
try {
|
||||
activityService.handleLegacyScanCodeSign(courseId, SecurityUtil.getUserId(), lat, lng);
|
||||
return Result.success("签到成功");
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Result.error(99, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
double distance = DistanceUtil.getDistance(coordinates.get(0), coordinates.get(1), lat, lng);
|
||||
if(distance > radius) {
|
||||
return Result.error(99, "签到失败,您距离签到点还有%.1f米".formatted(distance - radius));
|
||||
/**
|
||||
* @param courseId 扫码二维码中携带的课程点位ID
|
||||
* @return Result.data为地图点位、签到进度、处理方式和动态表单配置
|
||||
*/
|
||||
@At
|
||||
@ApiOperation("查询签到扩展页面数据")
|
||||
@SaCheckPermission("h5.trainSignUp.mine")
|
||||
public Result getSignData(String courseId) {
|
||||
try {
|
||||
return Result.success().addData(activityService.getScanCodeSignData(courseId, SecurityUtil.getUserId()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Result.error(99, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
dao.update(timeList);
|
||||
|
||||
return Result.success("签到成功");
|
||||
/**
|
||||
* @param courseId 当前扫码课程ID
|
||||
* @param mobileColumnsValue JSON数组,每项包含字段编码和值
|
||||
* @return Result,code为0表示保存成功,99表示业务校验未通过
|
||||
*/
|
||||
@At
|
||||
@ApiOperation("保存签到达标后的填写信息")
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
@SaCheckPermission("h5.trainSignUp.mine")
|
||||
public Result saveDynamicForm(String courseId, String mobileColumnsValue) {
|
||||
if (courseId == null || mobileColumnsValue == null) {
|
||||
return Result.error(99, "请填写签到信息");
|
||||
}
|
||||
try {
|
||||
activityService.saveScanCodeDynamicForm(courseId, SecurityUtil.getUserId(), mobileColumnsValue);
|
||||
return Result.success("保存成功");
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Result.error(99, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@At
|
||||
@@ -217,6 +246,14 @@ public class TrainsignUpMineController {
|
||||
@SaCheckPermission(value = {"trainSignUp.mine", "h5.trainSignUp.mine"}, mode = SaMode.OR)
|
||||
public Result fetchCourse(String courseId) {
|
||||
TrainSignUpCourse course = dao.fetch(TrainSignUpCourse.class, courseId);
|
||||
return Result.success(course);
|
||||
if (course == null) {
|
||||
return Result.error(99, "未找到签到课程");
|
||||
}
|
||||
TrainSignUpActivity activity = dao.fetch(TrainSignUpActivity.class, course.getActivityId());
|
||||
NutMap data = Lang.obj2nutmap(course);
|
||||
data.put("tencentMapEnabled", TencentLocationService.isActivitySignEnabled());
|
||||
data.put("signExtensionEnabled", activity != null && activity.getSignPointCount() != null
|
||||
&& activity.getSignPointCount() > 0);
|
||||
return Result.success(data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,21 @@ public class TrainSignUpActivity extends BaseModel implements Serializable, SysH
|
||||
@ColDefine(type = ColType.MYSQL_JSON)
|
||||
private List<String> helpUnits;
|
||||
|
||||
@Column
|
||||
@ColDefine(type = ColType.INT)
|
||||
@Comment("签到达标点位数,空或0表示未开启签到扩展")
|
||||
private Integer signPointCount;
|
||||
|
||||
@Column
|
||||
@ColDefine(type = ColType.INT)
|
||||
@Comment("签到达标处理方式:1填写信息,2选择福利,3无")
|
||||
private Integer signHandleType;
|
||||
|
||||
@Column
|
||||
@ColDefine(type = ColType.VARCHAR, width = 32)
|
||||
@Comment("签到达标后关联的福利项目ID")
|
||||
private String welfareProjectId;
|
||||
|
||||
@Override
|
||||
public Sys_home_activity covertToSysHomeActivity() {
|
||||
Sys_home_activity sysHomeActivity = new Sys_home_activity();
|
||||
|
||||
+45
@@ -77,6 +77,12 @@ public interface TrainSignUpActivityService extends BaseService<TrainSignUpActiv
|
||||
*/
|
||||
void doSignUp(TrainSignUpUser trainSignUpUser) throws Exception;
|
||||
|
||||
/** activityId、courseId 分别为主活动和子活动 ID;返回该子活动可提醒的未报名人数。 */
|
||||
int countReminderRecipients(String activityId, String courseId);
|
||||
|
||||
/** 根据主活动、子活动 ID 和管理员填写的正文提交钉钉提醒;返回本批次接收人数。 */
|
||||
int submitSignupReminder(String activityId, String courseId, String content);
|
||||
|
||||
/**
|
||||
* 异步插入每个报名成功人员的课程数据
|
||||
*
|
||||
@@ -128,4 +134,43 @@ public interface TrainSignUpActivityService extends BaseService<TrainSignUpActiv
|
||||
List<NutMap> qdInfoByUserId(String userId, String activityId);
|
||||
|
||||
List<TrainSignUpCourse> filterCourseByHostUnion(List<TrainSignUpCourse> courseList);
|
||||
|
||||
/**
|
||||
* 查询扩展签到页面数据。
|
||||
*
|
||||
* @param courseId 扫码进入的课程点位ID
|
||||
* @param userId 当前登录用户ID
|
||||
* @return 地图点位、签到进度、达标处理方式及动态表单字段
|
||||
*/
|
||||
NutMap getScanCodeSignData(String courseId, String userId);
|
||||
|
||||
/**
|
||||
* 完成扩展点位签到。
|
||||
*
|
||||
* @param courseId 扫码进入的课程点位ID
|
||||
* @param userId 当前登录用户ID
|
||||
* @param latitude 腾讯前端定位组件返回的GCJ-02纬度
|
||||
* @param longitude 腾讯前端定位组件返回的GCJ-02经度
|
||||
* @return 最新签到进度及达标后的处理信息
|
||||
*/
|
||||
NutMap handleScanCodeSign(String courseId, String userId, Double latitude, Double longitude);
|
||||
|
||||
/**
|
||||
* 普通活动扫码签到,沿用报名及时间限制,使用腾讯GCJ-02定位校验签到范围。
|
||||
* @param courseId 二维码中的课程点位ID
|
||||
* @param userId 当前登录用户ID
|
||||
* @param latitude 本次GCJ-02纬度
|
||||
* @param longitude 本次GCJ-02经度
|
||||
* @return 无返回值,业务校验失败抛出IllegalArgumentException
|
||||
*/
|
||||
void handleLegacyScanCodeSign(String courseId, String userId, Double latitude, Double longitude);
|
||||
|
||||
/**
|
||||
* 保存签到达标后填写的动态表单。
|
||||
*
|
||||
* @param courseId 当前扫码课程ID
|
||||
* @param userId 当前登录用户ID
|
||||
* @param mobileColumnsValue JSON数组,每项包含columnCode和columnValue
|
||||
*/
|
||||
void saveScanCodeDynamicForm(String courseId, String userId, String mobileColumnsValue);
|
||||
}
|
||||
|
||||
+386
@@ -1,6 +1,9 @@
|
||||
package com.budwk.app.zhgh.activity.trainSignUp.service.impl;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.core.thread.ThreadUtil;
|
||||
import cn.hutool.core.date.DateUtil;
|
||||
import com.budwk.app.base.sms.SmsService;
|
||||
import com.budwk.app.base.page.Pagination;
|
||||
import com.budwk.app.base.param.PageForm;
|
||||
import com.budwk.app.base.service.impl.BaseServiceImpl;
|
||||
@@ -14,6 +17,9 @@ import com.budwk.app.zhgh.activity.family.service.FamilyActivityStatisticsServic
|
||||
import com.budwk.app.zhgh.activity.trainSignUp.models.*;
|
||||
import com.budwk.app.zhgh.activity.trainSignUp.service.TrainSignUpActivityService;
|
||||
import com.budwk.app.zhgh.activity.trainSignUp.service.TrainSignUpActivityStatisticsService;
|
||||
import com.budwk.app.zhgh.welfare.model.WelfareProject;
|
||||
import com.budwk.app.zhgh.welfare.service.WelfareListService;
|
||||
import com.budwk.app.zhgh.map.TencentLocationService;
|
||||
import com.google.common.collect.Sets;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.nutz.aop.interceptor.async.Async;
|
||||
@@ -27,6 +33,7 @@ import org.nutz.dao.util.cri.Static;
|
||||
import org.nutz.ioc.aop.Aop;
|
||||
import org.nutz.ioc.loader.annotation.Inject;
|
||||
import org.nutz.ioc.loader.annotation.IocBean;
|
||||
import org.nutz.json.Json;
|
||||
import org.nutz.lang.Lang;
|
||||
import org.nutz.lang.util.NutMap;
|
||||
|
||||
@@ -45,6 +52,84 @@ public class TrainSignUpActivityServiceImpl extends BaseServiceImpl<TrainSignUpA
|
||||
|
||||
@Inject
|
||||
private TrainSignUpActivityStatisticsService statisticsService;
|
||||
@Inject
|
||||
private WelfareListService welfareListService;
|
||||
@Inject
|
||||
private SmsService smsService;
|
||||
|
||||
/** 按主活动及子活动 ID 筛选可提醒人员,返回去重后的工号数量。 */
|
||||
@Override
|
||||
public int countReminderRecipients(String activityId, String courseId) {
|
||||
return listReminderRecipients(activityId, courseId).size();
|
||||
}
|
||||
|
||||
/** 按主活动、子活动和正文提交后台钉钉提醒,返回本次接收人数,不代表送达数。 */
|
||||
@Override
|
||||
public int submitSignupReminder(String activityId, String courseId, String content) {
|
||||
if (StrUtil.isBlank(content)) {
|
||||
throw new IllegalArgumentException("请输入提醒内容");
|
||||
}
|
||||
// 预览人数可能已变化,发送前重新筛选,不能信任前端提交的接收人。
|
||||
List<String> recipients = listReminderRecipients(activityId, courseId);
|
||||
if (recipients.isEmpty()) {
|
||||
throw new IllegalArgumentException("该子活动没有符合条件的未报名人员");
|
||||
}
|
||||
String sender = SecurityUtil.getUserLoginname();
|
||||
if (StrUtil.isBlank(sender)) {
|
||||
throw new IllegalArgumentException("无法获取发送人工号");
|
||||
}
|
||||
String url = "/platform/trainSignUp/apply/h5?id=" + activityId;
|
||||
String message = content.trim();
|
||||
ThreadUtil.execute(() -> smsService.massSendTextCard(recipients, "品牌活动报名提醒", message, null, url, sender));
|
||||
return recipients.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据主活动范围和子活动报名记录返回工号列表;其他子活动的报名不影响本次筛选。
|
||||
* 停用、过期或子活动归属不匹配时拒绝发送,空人员范围返回空列表。
|
||||
*/
|
||||
private List<String> listReminderRecipients(String activityId, String courseId) {
|
||||
if (StrUtil.isBlank(activityId) || StrUtil.isBlank(courseId)) {
|
||||
throw new IllegalArgumentException("请选择子活动");
|
||||
}
|
||||
TrainSignUpActivity activity = dao().fetch(TrainSignUpActivity.class, activityId);
|
||||
TrainSignUpCourse course = dao().fetch(TrainSignUpCourse.class, courseId);
|
||||
if (activity == null || course == null || !activityId.equals(course.getActivityId())) {
|
||||
throw new IllegalArgumentException("当前活动或子活动不存在");
|
||||
}
|
||||
Date now = new Date();
|
||||
if (activity.isDisabled() || activity.getActivitySignUpEndTime() == null
|
||||
|| !now.before(activity.getActivitySignUpEndTime())) {
|
||||
throw new IllegalArgumentException("活动已停用或报名已截止,无法发送提醒");
|
||||
}
|
||||
if (activity.getActivityGroupId() == null) {
|
||||
return List.of();
|
||||
}
|
||||
// 与子活动对内开放规则一致:开放时间前只提醒承办工会的人员。
|
||||
boolean hostOnly = Boolean.FALSE.equals(course.getOpenOtherUnion())
|
||||
&& StrUtil.isNotBlank(course.getInterTime())
|
||||
&& DateUtil.compare(now, DateUtil.parse(course.getInterTime()), "yyyy-MM-dd HH:mm") < 0;
|
||||
Set<String> allowedUnions = Lang.isEmpty(course.getUnionLimit()) ? Set.of()
|
||||
: course.getUnionLimit().stream().map(v -> v.getString("id")).filter(StrUtil::isNotBlank).collect(Collectors.toSet());
|
||||
Sql sql = Sqls.create("""
|
||||
SELECT DISTINCT u.loginname, u.unionId
|
||||
FROM activity_user_scope s
|
||||
JOIN vw_user u ON u.id = s.userId
|
||||
WHERE s.groupId = @groupId
|
||||
AND u.loginname IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM train_sign_up_user r
|
||||
WHERE r.courseId = @courseId AND r.userId = s.userId
|
||||
)
|
||||
""");
|
||||
sql.setParam("groupId", activity.getActivityGroupId());
|
||||
sql.setParam("courseId", courseId);
|
||||
return listMap(sql).stream()
|
||||
.filter(row -> !hostOnly || Objects.equals(course.getHostUnionId(), row.getString("unionId")))
|
||||
.filter(row -> Lang.isEmpty(course.getUnionLimit()) || allowedUnions.contains(row.getString("unionId")))
|
||||
.map(row -> row.getString("loginname"))
|
||||
.filter(StrUtil::isNotBlank).map(String::trim).distinct().toList();
|
||||
}
|
||||
|
||||
public TrainSignUpActivityServiceImpl(Dao dao) {
|
||||
super(dao);
|
||||
@@ -53,6 +138,7 @@ public class TrainSignUpActivityServiceImpl extends BaseServiceImpl<TrainSignUpA
|
||||
@Override
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public void add(TrainSignUpActivity activity, TrainSignUpCourse course) {
|
||||
validateSignExtension(activity);
|
||||
|
||||
dao().insert(activity);
|
||||
|
||||
@@ -78,6 +164,7 @@ public class TrainSignUpActivityServiceImpl extends BaseServiceImpl<TrainSignUpA
|
||||
@Override
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public void edit(TrainSignUpActivity activity) {
|
||||
validateSignExtension(activity);
|
||||
// 1. 更新主活动
|
||||
update(activity);
|
||||
// 2. 处理类型限制
|
||||
@@ -524,6 +611,305 @@ public class TrainSignUpActivityServiceImpl extends BaseServiceImpl<TrainSignUpA
|
||||
return listMap(sql);
|
||||
}
|
||||
|
||||
@Override
|
||||
public NutMap getScanCodeSignData(String courseId, String userId) {
|
||||
TrainSignUpCourse course = requireScanCodeCourse(courseId, userId);
|
||||
TrainSignUpActivity activity = dao().fetch(TrainSignUpActivity.class, course.getActivityId());
|
||||
return buildScanCodeData(activity, course, userId, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public NutMap handleScanCodeSign(String courseId, String userId, Double latitude, Double longitude) {
|
||||
lockScanCodeUser(userId);
|
||||
TrainSignUpCourse course = requireScanCodeCourse(courseId, userId);
|
||||
TrainSignUpActivity activity = dao().fetch(TrainSignUpActivity.class, course.getActivityId());
|
||||
requireScanCodeLocation(course.getCourseLocationCoordinates(), latitude, longitude, effectiveRadius(course));
|
||||
|
||||
List<TrainSignUpUserCourse> records = dao().query(TrainSignUpUserCourse.class,
|
||||
Cnd.where("activityId", "=", activity.getId()).and("courseId", "=", courseId)
|
||||
.and("userId", "=", userId).asc("courseStartTime"));
|
||||
if (records.stream().anyMatch(TrainSignUpUserCourse::isAttend)) {
|
||||
return buildScanCodeData(activity, course, userId, true)
|
||||
.addv("message", "此点位,您已经签到");
|
||||
}
|
||||
|
||||
Date now = new Date();
|
||||
int signUnit = course.getSignUnit() == null ? 0 : course.getSignUnit();
|
||||
List<TrainSignUpUserCourse> availableRecords = records.stream().filter(item -> item.getCourseStartTime() != null
|
||||
&& item.getCourseEndTime() != null
|
||||
&& !now.before(cn.hutool.core.date.DateUtil.offsetMinute(item.getCourseStartTime(), -signUnit))
|
||||
&& !now.after(cn.hutool.core.date.DateUtil.offsetMinute(item.getCourseEndTime(), signUnit)))
|
||||
.collect(Collectors.toList());
|
||||
if (availableRecords.size() != 1) {
|
||||
throw new IllegalArgumentException(availableRecords.isEmpty() ? "当前不在签到时间范围内" : "当前点位存在多个可签到课次,请联系管理员");
|
||||
}
|
||||
TrainSignUpUserCourse userCourse = availableRecords.get(0);
|
||||
userCourse.setAttend(true);
|
||||
userCourse.setAttendTime(now);
|
||||
dao().update(userCourse);
|
||||
return buildScanCodeData(activity, course, userId, true).addv("message", "签到成功");
|
||||
}
|
||||
|
||||
@Override
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public void saveScanCodeDynamicForm(String courseId, String userId, String mobileColumnsValue) {
|
||||
lockScanCodeUser(userId);
|
||||
TrainSignUpCourse course = requireScanCodeCourse(courseId, userId);
|
||||
TrainSignUpActivity activity = dao().fetch(TrainSignUpActivity.class, course.getActivityId());
|
||||
if (!Integer.valueOf(1).equals(activity.getSignHandleType())) {
|
||||
throw new IllegalArgumentException("当前活动无需填写信息");
|
||||
}
|
||||
if (!buildScanCodeData(activity, course, userId, false).getBoolean("isReachTarget", false)) {
|
||||
throw new IllegalArgumentException("当前活动尚未达到签到点位数");
|
||||
}
|
||||
List<NutMap> inputData;
|
||||
try {
|
||||
inputData = Json.fromJsonAsList(NutMap.class, mobileColumnsValue);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("填写信息格式不正确");
|
||||
}
|
||||
if (inputData == null) {
|
||||
throw new IllegalArgumentException("请填写当前签到信息");
|
||||
}
|
||||
Map<String, NutMap> inputMap = new LinkedHashMap<>();
|
||||
for (NutMap item : inputData) {
|
||||
String code = item == null ? null : item.getString("columnCode");
|
||||
if (StrUtil.isBlank(code) || inputMap.put(code, item) != null) {
|
||||
throw new IllegalArgumentException("填写信息包含无效或重复字段");
|
||||
}
|
||||
}
|
||||
List<TrainMobileSignColumn> columns = getScanCodeColumns(course);
|
||||
List<NutMap> normalized = new ArrayList<>();
|
||||
for (TrainMobileSignColumn column : columns) {
|
||||
NutMap input = inputMap.get(column.getColumnCode());
|
||||
Object value = input == null ? null : input.get("columnValue");
|
||||
if (Boolean.TRUE.equals(column.getIsRequired()) && isEmptyDynamicValue(value)) {
|
||||
throw new IllegalArgumentException("请填写" + column.getColumnName());
|
||||
}
|
||||
normalized.add(NutMap.NEW().addv("columnCode", column.getColumnCode())
|
||||
.addv("columnName", column.getColumnName()).addv("columnFormType", column.getColumnFormType())
|
||||
.addv("columnValue", value));
|
||||
}
|
||||
List<TrainSignUpUser> signUsers = dao().query(TrainSignUpUser.class,
|
||||
Cnd.where("activityId", "=", activity.getId()).and("userId", "=", userId));
|
||||
signUsers.forEach(item -> item.setMobileColumnsValue(normalized));
|
||||
dao().update(signUsers);
|
||||
}
|
||||
|
||||
/** 保存活动前统一校验签到扩展,未开启时清理无效配置,避免历史活动被新逻辑影响。 */
|
||||
private void validateSignExtension(TrainSignUpActivity activity) {
|
||||
if (activity.getSignPointCount() == null || activity.getSignPointCount() <= 0) {
|
||||
activity.setSignPointCount(null);
|
||||
activity.setSignHandleType(3);
|
||||
activity.setWelfareProjectId(null);
|
||||
return;
|
||||
}
|
||||
List<TrainSignUpCourse> courses = activity.getCourseList();
|
||||
if (Lang.isEmpty(courses) || activity.getSignPointCount() > courses.size()) {
|
||||
throw new IllegalArgumentException("签到达标点位数不能超过活动课程数量");
|
||||
}
|
||||
for (TrainSignUpCourse item : courses) {
|
||||
if (!item.isMobileSign() || !Integer.valueOf(1).equals(item.getSignType())) {
|
||||
throw new IllegalArgumentException("开启签到扩展后,所有课程必须启用扫码签到");
|
||||
}
|
||||
if (!validCoordinates(item.getCourseLocationCoordinates()) || !validCoordinates(item.getTransPosition())) {
|
||||
throw new IllegalArgumentException("课程“" + item.getCourseName() + "”未配置有效签到坐标");
|
||||
}
|
||||
if (item.getRadius() != null && item.getRadius() <= 0) {
|
||||
throw new IllegalArgumentException("课程“" + item.getCourseName() + "”签到半径必须大于0");
|
||||
}
|
||||
}
|
||||
Integer handleType = activity.getSignHandleType() == null ? 3 : activity.getSignHandleType();
|
||||
if (!List.of(1, 2, 3).contains(handleType)) {
|
||||
throw new IllegalArgumentException("签到达标处理方式无效");
|
||||
}
|
||||
activity.setSignHandleType(handleType);
|
||||
if (handleType == 1 && (courses.stream().anyMatch(item -> StrUtil.isBlank(item.getCourseType()))
|
||||
|| courses.stream().map(TrainSignUpCourse::getCourseType).distinct().count() != 1)) {
|
||||
throw new IllegalArgumentException("达标后填写信息要求所有课程使用同一种课程类型");
|
||||
}
|
||||
if (handleType == 2) {
|
||||
WelfareProject project = StrUtil.isBlank(activity.getWelfareProjectId()) ? null
|
||||
: dao().fetch(WelfareProject.class, activity.getWelfareProjectId());
|
||||
if (project == null) {
|
||||
throw new IllegalArgumentException("请选择有效的福利项目");
|
||||
}
|
||||
if (Boolean.TRUE.equals(project.getIsDisabled())
|
||||
|| project.getChoiceTimeEnd() != null && project.getChoiceTimeEnd().before(new Date())) {
|
||||
throw new IllegalArgumentException("所选福利项目已禁用或已超过选择截止时间");
|
||||
}
|
||||
} else {
|
||||
activity.setWelfareProjectId(null);
|
||||
}
|
||||
}
|
||||
|
||||
/** 校验扩展签到入口,不改变当前项目“必须先报名才能签到”的原有规则。 */
|
||||
private TrainSignUpCourse requireScanCodeCourse(String courseId, String userId) {
|
||||
TrainSignUpCourse course = StrUtil.isBlank(courseId) ? null : dao().fetch(TrainSignUpCourse.class, courseId);
|
||||
if (course == null || !course.isMobileSign() || !Integer.valueOf(1).equals(course.getSignType())) {
|
||||
throw new IllegalArgumentException("当前课程未开启扫码签到");
|
||||
}
|
||||
TrainSignUpActivity activity = dao().fetch(TrainSignUpActivity.class, course.getActivityId());
|
||||
if (activity == null || activity.getSignPointCount() == null || activity.getSignPointCount() <= 0) {
|
||||
throw new IllegalArgumentException("当前活动未开启签到扩展");
|
||||
}
|
||||
Date now = new Date();
|
||||
if (activity.isDisabled() || activity.getActivityStartTime() == null || activity.getActivityEndTime() == null
|
||||
|| now.before(activity.getActivityStartTime()) || now.after(activity.getActivityEndTime())) {
|
||||
throw new IllegalArgumentException("当前活动未开放或不在活动时间范围内");
|
||||
}
|
||||
List<TrainSignUpUser> signUsers = dao().query(TrainSignUpUser.class,
|
||||
Cnd.where("activityId", "=", activity.getId()).and("courseId", "=", courseId).and("userId", "=", userId));
|
||||
if (signUsers.isEmpty()) {
|
||||
throw new IllegalArgumentException("未查询到您的报名记录");
|
||||
}
|
||||
if (signUsers.stream().noneMatch(item -> Integer.valueOf(1).equals(item.getState()) || Integer.valueOf(3).equals(item.getState()))) {
|
||||
throw new IllegalArgumentException("当前报名状态不能签到");
|
||||
}
|
||||
return course;
|
||||
}
|
||||
|
||||
/** 组装当前点位、全部点位、签到进度及达标处理状态。 */
|
||||
private NutMap buildScanCodeData(TrainSignUpActivity activity, TrainSignUpCourse course, String userId, boolean allowHandle) {
|
||||
List<TrainSignUpUserCourse> attended = dao().query(TrainSignUpUserCourse.class,
|
||||
Cnd.where("activityId", "=", activity.getId()).and("userId", "=", userId).and("isAttend", "=", true));
|
||||
long signCount = attended.stream().map(TrainSignUpUserCourse::getCourseId).filter(StrUtil::isNotBlank).distinct().count();
|
||||
boolean reached = signCount >= activity.getSignPointCount();
|
||||
List<TrainSignUpCourse> courses = dao().query(TrainSignUpCourse.class,
|
||||
Cnd.where("activityId", "=", activity.getId()).asc("orderNum"));
|
||||
List<NutMap> signPoints = courses.stream().map(item -> NutMap.NEW().addv("courseId", item.getId())
|
||||
.addv("courseName", item.getCourseName()).addv("courseLocation", item.getCourseLocation())
|
||||
.addv("signed", attended.stream().anyMatch(record -> Objects.equals(record.getCourseId(), item.getId())))
|
||||
// 同一课程可能有多个课次,展示该点位最早的有效签到时间,兼容历史空时间。
|
||||
.addv("attendTime", attended.stream().filter(record -> Objects.equals(record.getCourseId(), item.getId()))
|
||||
.map(TrainSignUpUserCourse::getAttendTime).filter(Objects::nonNull).min(Date::compareTo).orElse(null)))
|
||||
.collect(Collectors.toList());
|
||||
Map<String, Object> savedValues = new HashMap<>();
|
||||
List<TrainSignUpUser> signUsers = dao().query(TrainSignUpUser.class,
|
||||
Cnd.where("activityId", "=", activity.getId()).and("userId", "=", userId));
|
||||
signUsers.stream().map(TrainSignUpUser::getMobileColumnsValue).filter(Lang::isNotEmpty).findFirst()
|
||||
.ifPresent(values -> values.forEach(value -> savedValues.put(value.getString("columnCode"), value.get("columnValue"))));
|
||||
List<NutMap> dynamicColumns = getScanCodeColumns(course).stream().map(item -> {
|
||||
NutMap map = Lang.obj2nutmap(item);
|
||||
map.put("columnValue", savedValues.containsKey(item.getColumnCode()) ? savedValues.get(item.getColumnCode())
|
||||
: "JSON".equals(item.getColumnType()) ? new ArrayList<>() : "");
|
||||
return map;
|
||||
}).collect(Collectors.toList());
|
||||
NutMap data = NutMap.NEW().addv("activityName", activity.getActivityName()).addv("courseName", course.getCourseName())
|
||||
.addv("courseLocation", course.getCourseLocation()).addv("courseLocationCoordinates", course.getCourseLocationCoordinates())
|
||||
.addv("signRadius", effectiveRadius(course)).addv("currentPointSigned", attended.stream()
|
||||
.anyMatch(item -> Objects.equals(item.getCourseId(), course.getId())))
|
||||
.addv("signCount", signCount).addv("signPointCount", activity.getSignPointCount())
|
||||
.addv("signPoints", signPoints).addv("isReachTarget", reached)
|
||||
.addv("signHandleType", activity.getSignHandleType()).addv("dynamicColumnsData", dynamicColumns)
|
||||
.addv("needDynamicForm", reached && Integer.valueOf(1).equals(activity.getSignHandleType()) && !dynamicColumns.isEmpty())
|
||||
.addv("needWelfareSelect", false).addv("welfareJumpUrl", "")
|
||||
.addv("handleMsg", reached ? "当前活动已达标" : "请继续完成其他点位签到");
|
||||
if (reached && Integer.valueOf(2).equals(activity.getSignHandleType())) {
|
||||
WelfareProject welfareProject = dao().fetch(WelfareProject.class, activity.getWelfareProjectId());
|
||||
if (welfareProject == null || Boolean.TRUE.equals(welfareProject.getIsDisabled())
|
||||
|| welfareProject.getChoiceTimeEnd() != null && welfareProject.getChoiceTimeEnd().before(new Date())) {
|
||||
return data.setv("handleMsg", "关联福利项目已结束,请联系管理员");
|
||||
}
|
||||
if (allowHandle) {
|
||||
welfareListService.ensureWelfareListUser(userId, activity.getWelfareProjectId());
|
||||
}
|
||||
data.setv("needWelfareSelect", true)
|
||||
.setv("welfareJumpUrl", "/platform/h5/welfare/userSelect/index?id=" + activity.getWelfareProjectId())
|
||||
.setv("handleMsg", "签到已达标,请选择福利");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
private List<TrainMobileSignColumn> getScanCodeColumns(TrainSignUpCourse course) {
|
||||
return StrUtil.isBlank(course.getCourseType()) ? new ArrayList<>() : dao().query(TrainMobileSignColumn.class,
|
||||
Cnd.where("typeId", "=", course.getCourseType()).asc("columnIndex"));
|
||||
}
|
||||
|
||||
/** 锁定当前登录用户,避免同一用户并发扫码造成重复达标处理。 */
|
||||
private void lockScanCodeUser(String userId) {
|
||||
if (StrUtil.isBlank(userId)) {
|
||||
throw new IllegalArgumentException("未获取到当前登录用户");
|
||||
}
|
||||
Sql sql = Sqls.create("SELECT id FROM sys_user WHERE id = @userId FOR UPDATE").setParam("userId", userId);
|
||||
sql.setCallback(Sqls.callback.map());
|
||||
dao().execute(sql);
|
||||
NutMap user = sql.getObject(NutMap.class);
|
||||
if (user == null || user.isEmpty()) {
|
||||
throw new IllegalArgumentException("未找到当前用户信息");
|
||||
}
|
||||
}
|
||||
|
||||
private int effectiveRadius(TrainSignUpCourse course) {
|
||||
return course.getRadius() == null ? 100 : course.getRadius();
|
||||
}
|
||||
|
||||
private boolean validCoordinates(List<Double> coordinates) {
|
||||
return coordinates != null && coordinates.size() == 2 && coordinates.get(0) != null && coordinates.get(1) != null
|
||||
&& Double.isFinite(coordinates.get(0)) && Double.isFinite(coordinates.get(1));
|
||||
}
|
||||
|
||||
/**
|
||||
* coordinates为配置的GCJ-02[纬度,经度],latitude/longitude为本次腾讯定位值。
|
||||
* 不与WGS84 transPosition混算;距离超出配置半径即拒绝签到。
|
||||
*/
|
||||
private void requireScanCodeLocation(List<Double> coordinates, Double latitude, Double longitude, int radius) {
|
||||
// 仅跳过定位与距离校验,调用方仍校验报名、课次时间并保留重复签到处理。
|
||||
if (!TencentLocationService.isActivitySignEnabled()) return;
|
||||
if (!validCoordinates(coordinates) || Math.abs(coordinates.get(0)) > 90 || Math.abs(coordinates.get(1)) > 180
|
||||
|| radius <= 0) {
|
||||
throw new IllegalArgumentException("当前点位未配置有效签到坐标");
|
||||
}
|
||||
if (latitude == null || longitude == null || !Double.isFinite(latitude) || !Double.isFinite(longitude)
|
||||
|| Math.abs(latitude) > 90 || Math.abs(longitude) > 180) {
|
||||
throw new IllegalArgumentException("请先获取当前定位后再签到");
|
||||
}
|
||||
double distance = com.budwk.app.base.utils.DistanceUtil.getDistance(coordinates.get(0), coordinates.get(1), latitude, longitude);
|
||||
if (distance > radius) {
|
||||
throw new IllegalArgumentException("签到失败,您距离签到点还有" + String.format("%.1f", distance - radius) + "米");
|
||||
}
|
||||
}
|
||||
|
||||
/** 普通扫码入口接收课程、用户及GCJ-02定位;报名、范围和时间全部通过后才更新签到记录。 */
|
||||
@Override
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public void handleLegacyScanCodeSign(String courseId, String userId, Double latitude, Double longitude) {
|
||||
lockScanCodeUser(userId);
|
||||
TrainSignUpCourse course = dao().fetch(TrainSignUpCourse.class, courseId);
|
||||
if (course == null) {
|
||||
throw new IllegalArgumentException("未找到签到课程");
|
||||
}
|
||||
List<TrainSignUpUserCourse> records = dao().query(TrainSignUpUserCourse.class,
|
||||
Cnd.where("courseId", "=", courseId).and("userId", "=", userId));
|
||||
if (records.isEmpty()) {
|
||||
throw new IllegalArgumentException("未查询到您的报名记录");
|
||||
}
|
||||
requireScanCodeLocation(course.getCourseLocationCoordinates(), latitude, longitude, effectiveRadius(course));
|
||||
Date now = new Date();
|
||||
int minute = course.getSignUnit() == null ? 0 : course.getSignUnit();
|
||||
List<TrainSignUpUserCourse> available = records.stream().filter(record ->
|
||||
record.getCourseStartTime() != null && record.getCourseEndTime() != null
|
||||
&& !now.before(DateUtil.offsetMinute(record.getCourseStartTime(), -minute))
|
||||
&& !now.after(DateUtil.offsetMinute(record.getCourseEndTime(), minute))).toList();
|
||||
if (available.isEmpty()) {
|
||||
throw new IllegalArgumentException("未到签到时间");
|
||||
}
|
||||
for (TrainSignUpUserCourse record : available) {
|
||||
// 重复请求保留首次签到时间,不改写其他课次。
|
||||
if (!record.isAttend()) {
|
||||
record.setAttend(true);
|
||||
record.setAttendTime(now);
|
||||
dao().update(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isEmptyDynamicValue(Object value) {
|
||||
return value == null || value instanceof String && StrUtil.isBlank((String) value)
|
||||
|| value instanceof Collection && ((Collection<?>) value).isEmpty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TrainSignUpCourse> filterCourseByHostUnion(List<TrainSignUpCourse> courseList) {
|
||||
if (Lang.isEmpty(courseList)) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.budwk.app.zhgh.map;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckLogin;
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import cn.dev33.satoken.annotation.SaMode;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.http.HttpUtil;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
@@ -15,9 +16,13 @@ import io.swagger.annotations.ApiOperation;
|
||||
import org.nutz.ioc.loader.annotation.Inject;
|
||||
import org.nutz.ioc.loader.annotation.IocBean;
|
||||
import org.nutz.mvc.annotation.At;
|
||||
import org.nutz.mvc.annotation.GET;
|
||||
import org.nutz.mvc.annotation.Ok;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.validation.Valid;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* @ClassName TMapController
|
||||
@@ -31,10 +36,13 @@ import javax.validation.Valid;
|
||||
@At("/platform/TMap")
|
||||
public class TMapController {
|
||||
|
||||
private static String KEY = "MLLBZ-GQECI-ASXG7-5GNOZ-XW2OF-H5BVH";
|
||||
private static String KEY = "NYNBZ-K62KI-DQJGU-U4NJ5-CSME7-ULFMU";
|
||||
@Inject
|
||||
private SysConfigService configService;
|
||||
|
||||
@Inject
|
||||
private TencentLocationService tencentLocationService;
|
||||
|
||||
@At
|
||||
@SaCheckLogin
|
||||
@ApiOperation("关键词输入提示")
|
||||
@@ -49,4 +57,36 @@ public class TMapController {
|
||||
String response = HttpUtil.get(url);
|
||||
return Result.success(JSONUtil.parse(response));
|
||||
}
|
||||
|
||||
/**
|
||||
* 代理腾讯定位组件的坐标转换请求,异步排队领取共享调用名额。
|
||||
*
|
||||
* @param request 腾讯组件的单点坐标及 JSONP 回调请求
|
||||
* @param response 腾讯协议的异步响应
|
||||
* @throws IOException 即时错误响应写入失败时抛出
|
||||
*/
|
||||
@At("/ws/coord/v1/translate")
|
||||
@GET
|
||||
@Ok("void")
|
||||
@SaCheckLogin
|
||||
@SaCheckPermission(value = {"trainSignUp.mine", "h5.trainSignUp.mine"}, mode = SaMode.OR)
|
||||
public void translate(HttpServletRequest request, HttpServletResponse response) throws IOException {
|
||||
tencentLocationService.proxy(true, KEY, request, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 代理腾讯定位组件的逆地址解析请求,保留腾讯返回状态及请求编号。
|
||||
*
|
||||
* @param request 腾讯组件的 GCJ-02 坐标及 JSONP 回调请求
|
||||
* @param response 腾讯协议的异步响应
|
||||
* @throws IOException 即时错误响应写入失败时抛出
|
||||
*/
|
||||
@At("/ws/geocoder/v1/")
|
||||
@GET
|
||||
@Ok("void")
|
||||
@SaCheckLogin
|
||||
@SaCheckPermission(value = {"trainSignUp.mine", "h5.trainSignUp.mine"}, mode = SaMode.OR)
|
||||
public void geocoder(HttpServletRequest request, HttpServletResponse response) throws IOException {
|
||||
tencentLocationService.proxy(false, KEY, request, response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.budwk.app.zhgh.map;
|
||||
|
||||
import org.nutz.boot.starter.WebFilterFace;
|
||||
import org.nutz.ioc.loader.annotation.IocBean;
|
||||
|
||||
import javax.servlet.DispatcherType;
|
||||
import javax.servlet.Filter;
|
||||
import javax.servlet.FilterRegistration;
|
||||
import javax.servlet.ServletContext;
|
||||
import java.util.EnumSet;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 为定位代理经过的过滤器开启 Servlet 异步支持,保留原有过滤器顺序及鉴权流程。
|
||||
* NutzBoot 的 WebFilterFace 没有异步配置项,需在过滤器注册完成后补充该能力。
|
||||
*/
|
||||
@IocBean
|
||||
public class TencentLocationAsyncSupport implements WebFilterFace {
|
||||
/** @return 定位异步支持的注册名称 */
|
||||
@Override
|
||||
public String getName() {
|
||||
return "tencentLocationAsyncSupport";
|
||||
}
|
||||
|
||||
/** @return 定位代理所在的业务路径 */
|
||||
@Override
|
||||
public String getPathSpec() {
|
||||
return "/platform/TMap/ws/*";
|
||||
}
|
||||
|
||||
/** @return 仅处理原始请求,不重新分派鉴权链 */
|
||||
@Override
|
||||
public EnumSet<DispatcherType> getDispatches() {
|
||||
return EnumSet.of(DispatcherType.REQUEST);
|
||||
}
|
||||
|
||||
/** @return null,仅补充注册配置,不增加或替换业务过滤器 */
|
||||
@Override
|
||||
public Filter getFilter() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @return 无额外过滤器参数 */
|
||||
@Override
|
||||
public Map<String, String> getInitParameters() {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
/** @return 最后执行,确保 Nutz 和登录过滤器均已完成注册 */
|
||||
@Override
|
||||
public int getOrder() {
|
||||
return Integer.MAX_VALUE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在容器启动阶段补齐定位代理过滤器链的异步标记;其他入口仍按原有同步流程执行。
|
||||
*
|
||||
* @param context 当前应用 Servlet 上下文
|
||||
*/
|
||||
@Override
|
||||
public void setServletContext(ServletContext context) {
|
||||
String[] paths = {"/platform/TMap/ws/coord/v1/translate", "/platform/TMap/ws/geocoder/v1/"};
|
||||
for (FilterRegistration registration : context.getFilterRegistrations().values()) {
|
||||
boolean matches = registration.getServletNameMappings().contains("*");
|
||||
for (String pattern : registration.getUrlPatternMappings()) {
|
||||
for (String path : paths) {
|
||||
matches |= pattern.equals(path) || (pattern.endsWith("/*")
|
||||
&& path.startsWith(pattern.substring(0, pattern.length() - 1)));
|
||||
}
|
||||
}
|
||||
if (matches && registration instanceof FilterRegistration.Dynamic) {
|
||||
((FilterRegistration.Dynamic) registration).setAsyncSupported(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
package com.budwk.app.zhgh.map;
|
||||
|
||||
import cn.hutool.crypto.digest.DigestUtil;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import com.budwk.app.web.commons.base.Globals;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.nutz.integration.jedis.RedisService;
|
||||
import org.nutz.ioc.loader.annotation.Inject;
|
||||
import org.nutz.ioc.loader.annotation.IocBean;
|
||||
|
||||
import javax.servlet.AsyncContext;
|
||||
import javax.servlet.AsyncEvent;
|
||||
import javax.servlet.AsyncListener;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 腾讯定位组件的业务代理:按 Key 和接口共享滑动窗口名额,异步等待并调用腾讯服务。
|
||||
* 定位期间不访问业务数据库,只有页面取得有效位置后才进入现有签到事务。
|
||||
*/
|
||||
@Slf4j
|
||||
@IocBean(create = "init", depose = "close")
|
||||
public class TencentLocationService {
|
||||
private static final int MAX_PENDING = 2000;
|
||||
private static final long REQUEST_TIMEOUT_MS = 20000;
|
||||
// Redis 时间统一多实例计时;完成请求不归还名额,避免将每秒限额误当作在途并发数。
|
||||
// Redis 3.0 禁止 TIME 后在同一 Lua 内写入,因此先读取服务端时间,再原子维护窗口。
|
||||
private static final String ACQUIRE = "local now=tonumber(ARGV[2]); "
|
||||
+ "local last=redis.call('ZREVRANGE',KEYS[1],0,0,'WITHSCORES'); "
|
||||
+ "if #last>0 then now=math.max(now,tonumber(last[2])); end; "
|
||||
+ "redis.call('ZREMRANGEBYSCORE',KEYS[1],'-inf',now-1000); "
|
||||
+ "if redis.call('ZCARD',KEYS[1])<80 then "
|
||||
+ "redis.call('ZADD',KEYS[1],now,ARGV[1]); redis.call('PEXPIRE',KEYS[1],2000); return 0; end; "
|
||||
+ "local first=redis.call('ZRANGE',KEYS[1],0,0,'WITHSCORES'); "
|
||||
+ "return math.max(1,1000-now+tonumber(first[2]));";
|
||||
|
||||
@Inject
|
||||
private RedisService redisService;
|
||||
|
||||
private final Map<String, Lane> lanes = new ConcurrentHashMap<>();
|
||||
private final Set<Job> pending = ConcurrentHashMap.newKeySet();
|
||||
private final Semaphore capacity = new Semaphore(MAX_PENDING);
|
||||
private final Object lifecycle = new Object();
|
||||
private volatile boolean closed;
|
||||
private ScheduledExecutorService dispatcher;
|
||||
private ExecutorService httpExecutor;
|
||||
private HttpClient client;
|
||||
|
||||
/** 初始化共享 HTTP 客户端和队列调度器,不为每个等待请求创建线程。 */
|
||||
public void init() {
|
||||
httpExecutor = Executors.newFixedThreadPool(8, runnable -> {
|
||||
Thread thread = new Thread(runnable, "tencent-location-http");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
client = HttpClient.newBuilder().executor(httpExecutor)
|
||||
.connectTimeout(Duration.ofSeconds(3)).build();
|
||||
dispatcher = Executors.newSingleThreadScheduledExecutor(runnable -> {
|
||||
Thread thread = new Thread(runnable, "tencent-location-dispatch");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
dispatcher.scheduleWithFixedDelay(this::drain, 0, 10, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 接收腾讯组件的 JSONP 请求,校验后异步排队;队满或超时返回组件可识别的失败结果。
|
||||
*
|
||||
* @param translate true 为坐标转换,false 为逆地址解析,仅允许这两个固定接口
|
||||
* @param key 服务端配置的腾讯 Key,不接受客户端替换
|
||||
* @param request 已通过登录及签到权限检查的请求
|
||||
* @param response 输出腾讯原始 JSON/JSONP 协议,不包装业务 Result
|
||||
* @throws IOException 无法写入即时错误响应时抛出
|
||||
*/
|
||||
public void proxy(boolean translate, String key, HttpServletRequest request,
|
||||
HttpServletResponse response) throws IOException {
|
||||
String callback = request.getParameter("callback");
|
||||
if (callback != null && !callback.matches("[A-Za-z_$][A-Za-z0-9_$]{0,127}")) {
|
||||
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
|
||||
write(response, null, error(400, "定位回调参数不合法"));
|
||||
return;
|
||||
}
|
||||
// 关闭开关后,旧页面尚未完成的定位请求也不能继续调用腾讯接口。
|
||||
if (!isActivitySignEnabled()) {
|
||||
write(response, callback, error(403, "当前签到无需定位,请刷新页面直接签到"));
|
||||
return;
|
||||
}
|
||||
String coordinate = request.getParameter(translate ? "locations" : "location");
|
||||
String type = request.getParameter("type");
|
||||
if (!validCoordinate(coordinate) || (translate && (type == null || !type.matches("[1-6]")))) {
|
||||
write(response, callback, error(400, "定位坐标或坐标类型不合法"));
|
||||
return;
|
||||
}
|
||||
String endpoint = translate ? "/ws/coord/v1/translate" : "/ws/geocoder/v1/";
|
||||
// 只转发 SDK 实际使用的单点坐标;固定主机、服务端 Key 和 JSON 输出,禁止开放代理。
|
||||
String query = "?output=json&key=" + URLEncoder.encode(key, StandardCharsets.UTF_8)
|
||||
+ (translate ? "&type=" + type + "&locations=" : "&location=")
|
||||
+ URLEncoder.encode(coordinate, StandardCharsets.UTF_8);
|
||||
URI uri = URI.create("https://apis.map.qq.com" + endpoint + query);
|
||||
String rateKey = "tencent:location:rate:" + DigestUtil.sha256Hex(key) + ":" + endpoint;
|
||||
synchronized (lifecycle) {
|
||||
if (closed || !request.isAsyncSupported()) {
|
||||
write(response, callback, error(503, "定位代理暂不可用"));
|
||||
return;
|
||||
}
|
||||
if (!capacity.tryAcquire()) {
|
||||
write(response, callback, error(429, "定位请求排队已满,请稍后重试"));
|
||||
return;
|
||||
}
|
||||
AsyncContext context;
|
||||
try {
|
||||
context = request.startAsync(request, response);
|
||||
} catch (RuntimeException e) {
|
||||
capacity.release();
|
||||
write(response, callback, error(503, "定位异步请求无法启动"));
|
||||
return;
|
||||
}
|
||||
Lane lane = lanes.computeIfAbsent(rateKey, Lane::new);
|
||||
Job job = new Job(context, lane, endpoint, uri, callback);
|
||||
pending.add(job);
|
||||
try {
|
||||
context.setTimeout(REQUEST_TIMEOUT_MS);
|
||||
context.addListener(new AsyncListener() {
|
||||
/**
|
||||
* 容器提前完成时释放队列名额,禁止继续调用上游。
|
||||
* @param event 容器完成事件
|
||||
*/
|
||||
@Override
|
||||
public void onComplete(AsyncEvent event) {
|
||||
finish(job, error(499, "定位请求已结束"), false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 超时同时取消未执行任务及在途 HTTP 请求。
|
||||
* @param event 容器超时事件
|
||||
*/
|
||||
@Override
|
||||
public void onTimeout(AsyncEvent event) {
|
||||
finish(job, error(504, "定位代理等待超时,请重新定位"), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户端连接错误后不继续消耗腾讯调用额度。
|
||||
* @param event 客户端连接错误事件
|
||||
*/
|
||||
@Override
|
||||
public void onError(AsyncEvent event) {
|
||||
finish(job, error(499, "定位连接已断开"), false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 本代理直接完成响应,不执行二次异步分派。
|
||||
* @param event 异步分派事件
|
||||
*/
|
||||
@Override
|
||||
public void onStartAsync(AsyncEvent event) {
|
||||
}
|
||||
});
|
||||
if (!lane.queue.offer(job)) {
|
||||
finish(job, error(429, "定位请求排队已满,请稍后重试"), true);
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
finish(job, error(503, "定位请求排队失败"), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取品牌活动签到定位开关,页面、签到校验和腾讯代理使用相同判断。
|
||||
*
|
||||
* @return 仅明确配置为 false 时关闭,参数缺失时保留原有定位校验
|
||||
*/
|
||||
public static boolean isActivitySignEnabled() {
|
||||
String value = Globals.MyConfig.getString("AppActivitySignTencentMap");
|
||||
return value == null || !"false".equalsIgnoreCase(value.trim());
|
||||
}
|
||||
|
||||
private boolean validCoordinate(String value) {
|
||||
if (value == null || value.length() > 80) return false;
|
||||
String[] parts = value.split(",", -1);
|
||||
if (parts.length != 2) return false;
|
||||
try {
|
||||
double lat = Double.parseDouble(parts[0]);
|
||||
double lng = Double.parseDouble(parts[1]);
|
||||
return Double.isFinite(lat) && Double.isFinite(lng) && Math.abs(lat) <= 90 && Math.abs(lng) <= 180;
|
||||
} catch (NumberFormatException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void drain() {
|
||||
if (closed) return;
|
||||
for (Lane lane : lanes.values()) {
|
||||
if (System.nanoTime() < lane.nextAttempt) continue;
|
||||
// 每个接口只由队头申请名额,避免数百个任务同时轮询 Redis。
|
||||
for (int i = 0; i < 80 && !closed; i++) {
|
||||
Job job = lane.queue.peek();
|
||||
if (job == null) break;
|
||||
if (job.done) {
|
||||
lane.queue.remove(job);
|
||||
continue;
|
||||
}
|
||||
if (elapsedMillis(job.createdAt) >= REQUEST_TIMEOUT_MS) {
|
||||
finish(job, error(504, "定位代理等待超时,请重新定位"), true);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
List<String> time = redisService.time();
|
||||
long now = Long.parseLong(time.get(0)) * 1000 + Long.parseLong(time.get(1)) / 1000;
|
||||
long wait = ((Number) redisService.eval(ACQUIRE, List.of(lane.rateKey),
|
||||
List.of(job.id, Long.toString(now)))).longValue();
|
||||
if (wait > 0) {
|
||||
lane.nextAttempt = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(wait);
|
||||
break;
|
||||
}
|
||||
lane.queue.remove(job);
|
||||
send(job);
|
||||
} catch (Exception e) {
|
||||
// Redis 故障不能退化为直连,否则多个实例会共同击穿腾讯每秒额度。
|
||||
log.warn("腾讯定位调度失败 requestId={} endpoint={} errorType={}",
|
||||
job.id, job.endpoint, e.getClass().getSimpleName());
|
||||
finish(job, error(503, "定位调度暂不可用,请稍后重试"), true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void send(Job job) {
|
||||
synchronized (job) {
|
||||
if (job.done) return;
|
||||
if (!isActivitySignEnabled()) {
|
||||
finish(job, error(403, "当前签到无需定位,请刷新页面直接签到"), true);
|
||||
return;
|
||||
}
|
||||
long remaining = REQUEST_TIMEOUT_MS - elapsedMillis(job.createdAt);
|
||||
if (closed || remaining <= 0) {
|
||||
finish(job, error(504, "定位代理等待超时,请重新定位"), true);
|
||||
return;
|
||||
}
|
||||
job.startedAt = System.nanoTime();
|
||||
HttpRequest request = HttpRequest.newBuilder(job.uri)
|
||||
.timeout(Duration.ofMillis(Math.min(5000, remaining))).GET().build();
|
||||
job.future = client.sendAsync(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
job.future.whenComplete((response, failure) -> {
|
||||
if (failure != null) {
|
||||
finish(job, error(502, "腾讯定位接口调用失败或超时"), true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (response.statusCode() != 200) {
|
||||
finish(job, error(502, "腾讯定位接口响应异常"), true);
|
||||
return;
|
||||
}
|
||||
JSONObject body = JSONUtil.parseObj(response.body());
|
||||
if (body.getInt("status") == null) throw new IllegalArgumentException("Missing status");
|
||||
finish(job, body, true);
|
||||
} catch (RuntimeException e) {
|
||||
finish(job, error(502, "腾讯定位接口返回格式异常"), true);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void finish(Job job, JSONObject body, boolean reply) {
|
||||
CompletableFuture<?> future;
|
||||
synchronized (job) {
|
||||
if (job.done) return;
|
||||
job.done = true;
|
||||
future = job.future;
|
||||
}
|
||||
if (future != null && !future.isDone()) future.cancel(true);
|
||||
job.lane.queue.remove(job);
|
||||
try {
|
||||
long queueMs = job.startedAt == 0 ? elapsedMillis(job.createdAt)
|
||||
: TimeUnit.NANOSECONDS.toMillis(job.startedAt - job.createdAt);
|
||||
// 不记录 Key、请求 URL 或经纬度;腾讯 request_id 可与浏览器定位结果关联。
|
||||
log.info("腾讯定位代理 requestId={} endpoint={} queueMs={} apiMs={} totalMs={} status={} tencentRequestId={}",
|
||||
job.id, job.endpoint, queueMs, job.startedAt == 0 ? 0 : elapsedMillis(job.startedAt),
|
||||
elapsedMillis(job.createdAt), body.getInt("status"), body.getStr("request_id", ""));
|
||||
if (reply) write((HttpServletResponse) job.context.getResponse(), job.callback, body);
|
||||
} catch (IOException | IllegalStateException e) {
|
||||
log.debug("腾讯定位响应连接已结束 requestId={}", job.id);
|
||||
} finally {
|
||||
pending.remove(job);
|
||||
capacity.release();
|
||||
try {
|
||||
job.context.complete();
|
||||
} catch (IllegalStateException ignored) {
|
||||
// 容器的超时或连接关闭回调可能已完成同一请求。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static long elapsedMillis(long start) {
|
||||
return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
|
||||
}
|
||||
|
||||
private static JSONObject error(int status, String message) {
|
||||
return new JSONObject().set("status", status).set("message", message);
|
||||
}
|
||||
|
||||
private static void write(HttpServletResponse response, String callback, JSONObject body) throws IOException {
|
||||
response.setCharacterEncoding("UTF-8");
|
||||
response.setContentType(callback == null ? "application/json;charset=UTF-8" : "application/javascript;charset=UTF-8");
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||
String json = JSONUtil.toJsonStr(body);
|
||||
byte[] bytes = (callback == null ? json : callback + "(" + json + ");").getBytes(StandardCharsets.UTF_8);
|
||||
response.setContentLength(bytes.length);
|
||||
response.getOutputStream().write(bytes);
|
||||
}
|
||||
|
||||
/** 应用关闭时取消排队及在途请求,释放异步响应和线程资源。 */
|
||||
public void close() {
|
||||
Job[] jobs;
|
||||
synchronized (lifecycle) {
|
||||
closed = true;
|
||||
if (dispatcher != null) dispatcher.shutdownNow();
|
||||
jobs = pending.toArray(new Job[0]);
|
||||
}
|
||||
for (Job job : jobs) finish(job, error(503, "定位服务正在关闭"), true);
|
||||
if (httpExecutor != null) httpExecutor.shutdownNow();
|
||||
}
|
||||
|
||||
private static class Lane {
|
||||
private final String rateKey;
|
||||
private final ArrayBlockingQueue<Job> queue = new ArrayBlockingQueue<>(MAX_PENDING);
|
||||
private long nextAttempt;
|
||||
|
||||
private Lane(String rateKey) {
|
||||
this.rateKey = rateKey;
|
||||
}
|
||||
}
|
||||
|
||||
private static class Job {
|
||||
private final String id = UUID.randomUUID().toString();
|
||||
private final long createdAt = System.nanoTime();
|
||||
private final AsyncContext context;
|
||||
private final Lane lane;
|
||||
private final String endpoint;
|
||||
private final URI uri;
|
||||
private final String callback;
|
||||
private volatile boolean done;
|
||||
private long startedAt;
|
||||
private CompletableFuture<HttpResponse<String>> future;
|
||||
|
||||
private Job(AsyncContext context, Lane lane, String endpoint, URI uri, String callback) {
|
||||
this.context = context;
|
||||
this.lane = lane;
|
||||
this.endpoint = endpoint;
|
||||
this.uri = uri;
|
||||
this.callback = callback;
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-8
@@ -6,7 +6,6 @@ import cn.afterturn.easypoi.excel.entity.enmus.ExcelType;
|
||||
import cn.afterturn.easypoi.excel.entity.params.ExcelExportEntity;
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import cn.hutool.core.util.DesensitizedUtil;
|
||||
import com.budwk.app.base.page.Pagination;
|
||||
import com.budwk.app.base.result.Result;
|
||||
import com.budwk.app.base.utils.CommonDownloadUtil;
|
||||
import com.budwk.app.zhgh.staffmanage.member.param.pageform.MemberStatisticsPageForm;
|
||||
@@ -54,13 +53,7 @@ public class MemberComprehensiveController {
|
||||
@At
|
||||
@SaCheckPermission("member.statistics.comprehensive")
|
||||
public Result pageData(MemberStatisticsPageForm pageForm) {
|
||||
Sql sql = memberStatisticsService.getComprehensiveSql(pageForm);
|
||||
Pagination<NutMap> pagination = memberStatisticsService.listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql);
|
||||
for (NutMap row : pagination.getList()) {
|
||||
row.put("mobile", DesensitizedUtil.mobilePhone(row.getString("mobile")));
|
||||
row.put("idCard", DesensitizedUtil.idCardNum(row.getString("idCard"), 3, 4));
|
||||
}
|
||||
return Result.success(pagination);
|
||||
return Result.success(memberStatisticsService.getComprehensivePage(pageForm));
|
||||
}
|
||||
|
||||
|
||||
|
||||
+8
@@ -1,6 +1,7 @@
|
||||
package com.budwk.app.zhgh.staffmanage.member.service;
|
||||
|
||||
import com.budwk.app.base.service.BaseService;
|
||||
import com.budwk.app.base.page.Pagination;
|
||||
import com.budwk.app.sys.models.Sys_user;
|
||||
import com.budwk.app.zhgh.staffmanage.member.param.pageform.MemberStatisticsPageForm;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
@@ -33,6 +34,13 @@ public interface MemberStatisticsService extends BaseService<Sys_user> {
|
||||
*/
|
||||
Sql getComprehensiveSql(MemberStatisticsPageForm pageForm);
|
||||
|
||||
/**
|
||||
* 查询综合查询列表,证件号码是否脱敏由当前登录人的角色决定。
|
||||
* @param pageForm 列表筛选、排序和分页条件,不接受调用方指定脱敏权限
|
||||
* @return 分页结果,list中的mobile始终脱敏;idCard仅系统管理员或校工会管理员可见原值
|
||||
*/
|
||||
Pagination<NutMap> getComprehensivePage(MemberStatisticsPageForm pageForm);
|
||||
|
||||
|
||||
/**
|
||||
* 获取工会分析数据
|
||||
|
||||
+18
@@ -7,6 +7,8 @@ import cn.afterturn.easypoi.excel.entity.params.ExcelExportEntity;
|
||||
import cn.hutool.core.date.DateUtil;
|
||||
import cn.hutool.core.util.NumberUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.core.util.DesensitizedUtil;
|
||||
import com.budwk.app.base.page.Pagination;
|
||||
import com.budwk.app.base.constant.RoleConstant;
|
||||
import com.budwk.app.base.service.impl.BaseServiceImpl;
|
||||
import com.budwk.app.web.commons.auth.utils.AuthUtil;
|
||||
@@ -44,6 +46,22 @@ public class MemberStatisticsServiceImpl extends BaseServiceImpl<Sys_user> imple
|
||||
super(dao);
|
||||
}
|
||||
|
||||
/** 列表沿用原查询范围,只按登录角色调整证件号码展示,不改变手机号及导出规则。 */
|
||||
@Override
|
||||
public Pagination<NutMap> getComprehensivePage(MemberStatisticsPageForm pageForm) {
|
||||
Sql sql = getComprehensiveSql(pageForm);
|
||||
Pagination<NutMap> pagination = listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql);
|
||||
boolean showFullIdCard = AuthUtil.hasRoleOr(RoleConstant.SYSADMIN.name(), RoleConstant.SCHOOL_UNION_ADMIN.name());
|
||||
for (NutMap row : pagination.getList()) {
|
||||
row.put("mobile", DesensitizedUtil.mobilePhone(row.getString("mobile")));
|
||||
// 角色取自服务端会话;同时拥有普通角色的管理员仍按管理员展示原始证件号码。
|
||||
if (!showFullIdCard) {
|
||||
row.put("idCard", DesensitizedUtil.idCardNum(row.getString("idCard"), 3, 4));
|
||||
}
|
||||
}
|
||||
return pagination;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public Cnd getCnd(MemberStatisticsPageForm pageForm) {
|
||||
|
||||
+25
@@ -42,6 +42,31 @@ public class WelfareProjectMangeController {
|
||||
@Inject
|
||||
private WelfareListService welfareListService;
|
||||
|
||||
/** projectId 为福利项目 ID;Result.data 为预计接收人数。 */
|
||||
@At
|
||||
@SaCheckPermission("welfare.project.mange")
|
||||
public Result reminderRecipientCount(@Param("projectId") String projectId) {
|
||||
try {
|
||||
return Result.success().addData(projectService.countReminderRecipients(projectId));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Result.error(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** projectId 为项目 ID,content 为提醒正文;成功仅表示已提交发送,data 为接收人数。 */
|
||||
@At
|
||||
@SaCheckPermission("welfare.project.mange")
|
||||
@SLog(tag = "福利", msg = "提交未选择人员提醒")
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public Result submitSelectionReminder(@Param("projectId") String projectId, @Param("content") String content) {
|
||||
try {
|
||||
int count = projectService.submitSelectionReminder(projectId, content);
|
||||
return Result.success("已提交发送,接收人数:" + count).addData(count);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Result.error(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@At("")
|
||||
@Ok("beetl:/platform/zhgh/welfare/projectMange/index.html")
|
||||
@SaCheckPermission("welfare.project.mange")
|
||||
|
||||
@@ -26,6 +26,14 @@ public interface WelfareListService extends BaseService<WelfareList> {
|
||||
|
||||
void doWelfareListUser(String[] ids,String id);
|
||||
|
||||
/**
|
||||
* 将单个用户加入福利项目名单,已存在时不重复添加。
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param projectId 福利项目ID
|
||||
*/
|
||||
void ensureWelfareListUser(String userId, String projectId);
|
||||
|
||||
/**
|
||||
* 分页查询
|
||||
* @param pageForm
|
||||
|
||||
@@ -47,4 +47,10 @@ public interface WelfareProjectService extends BaseService<WelfareProject> {
|
||||
* @return
|
||||
*/
|
||||
List<WelfareProject> getWelfareList(Integer year);
|
||||
|
||||
/** projectId 为福利项目 ID,返回名单内尚未选择且有工号的去重人数。 */
|
||||
int countReminderRecipients(String projectId);
|
||||
|
||||
/** projectId 为福利项目 ID,content 为提醒正文;返回提交后台发送的人数。 */
|
||||
int submitSelectionReminder(String projectId, String content);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ import org.nutz.dao.Cnd;
|
||||
import org.nutz.dao.Dao;
|
||||
import org.nutz.dao.Sqls;
|
||||
import org.nutz.dao.sql.Sql;
|
||||
import org.nutz.aop.interceptor.ioc.TransAop;
|
||||
import org.nutz.ioc.aop.Aop;
|
||||
import org.nutz.ioc.loader.annotation.IocBean;
|
||||
import org.nutz.json.Json;
|
||||
import org.nutz.lang.Lang;
|
||||
@@ -381,6 +383,29 @@ public class WelfareListServiceImpl extends BaseServiceImpl<WelfareList> impleme
|
||||
insert(welfareLists);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public void ensureWelfareListUser(String userId, String projectId) {
|
||||
if (StrUtil.isBlank(userId) || StrUtil.isBlank(projectId)
|
||||
|| count(Cnd.where("projectId", "=", projectId).and("userId", "=", userId)) > 0) {
|
||||
return;
|
||||
}
|
||||
View_user user = dao().fetch(View_user.class, userId);
|
||||
if (user == null) {
|
||||
throw new IllegalArgumentException("未找到当前用户信息,无法加入福利项目");
|
||||
}
|
||||
WelfareList welfareList = new WelfareList();
|
||||
welfareList.setProjectId(projectId);
|
||||
welfareList.setUserId(user.getId());
|
||||
welfareList.setWelfareUnitId(user.getUnitId());
|
||||
welfareList.setWelfareUnitName(user.getUnitName());
|
||||
welfareList.setWelfareUnionId(user.getUnionId());
|
||||
welfareList.setWelfareUnionName(user.getUnionName());
|
||||
welfareList.setPersonType(user.getPersonType());
|
||||
welfareList.setUserState(user.getUserState());
|
||||
insert(welfareList);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public Pagination pageData(WelfareListPageForm pageForm) {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package com.budwk.app.zhgh.welfare.service.impl;
|
||||
|
||||
import cn.hutool.core.date.DateUtil;
|
||||
import cn.hutool.core.thread.ThreadUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.budwk.app.base.service.impl.BaseServiceImpl;
|
||||
import com.budwk.app.base.sms.SmsService;
|
||||
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
|
||||
import com.budwk.app.sys.models.Sys_home_activity;
|
||||
import com.budwk.app.sys.models.Sys_user;
|
||||
import com.budwk.app.zhgh.welfare.model.WelfareList;
|
||||
@@ -15,6 +19,7 @@ import org.nutz.dao.Dao;
|
||||
import org.nutz.dao.Sqls;
|
||||
import org.nutz.dao.sql.Sql;
|
||||
import org.nutz.ioc.aop.Aop;
|
||||
import org.nutz.ioc.loader.annotation.Inject;
|
||||
import org.nutz.ioc.loader.annotation.IocBean;
|
||||
import org.nutz.lang.Lang;
|
||||
import org.nutz.lang.util.NutMap;
|
||||
@@ -34,10 +39,70 @@ import java.util.stream.Collectors;
|
||||
public class WelfareProjectServiceImpl extends BaseServiceImpl<WelfareProject> implements WelfareProjectService {
|
||||
private static final Logger log = LoggerFactory.getLogger(WelfareProjectServiceImpl.class);
|
||||
|
||||
@Inject
|
||||
private SmsService smsService;
|
||||
|
||||
public WelfareProjectServiceImpl(Dao dao) {
|
||||
super(dao);
|
||||
}
|
||||
|
||||
/** 按福利项目 ID 查询未选择人员,返回去重后的接收人数。 */
|
||||
@Override
|
||||
public int countReminderRecipients(String projectId) {
|
||||
return listReminderRecipients(projectId).size();
|
||||
}
|
||||
|
||||
/** 接收项目 ID 和提醒正文,返回后台任务接收人数,不代表送达结果。 */
|
||||
@Override
|
||||
public int submitSelectionReminder(String projectId, String content) {
|
||||
if (StrUtil.isBlank(content)) {
|
||||
throw new IllegalArgumentException("请输入提醒内容");
|
||||
}
|
||||
// 预览后可能有人完成选择,提交时重新筛选实际接收人。
|
||||
List<String> recipients = listReminderRecipients(projectId);
|
||||
if (recipients.isEmpty()) {
|
||||
throw new IllegalArgumentException("该福利项目没有可提醒的未选择人员");
|
||||
}
|
||||
String sender = SecurityUtil.getUserLoginname();
|
||||
if (StrUtil.isBlank(sender)) {
|
||||
throw new IllegalArgumentException("无法获取发送人工号");
|
||||
}
|
||||
String url = "/platform/h5/welfare/userSelect/index?id=" + projectId;
|
||||
String message = content.trim();
|
||||
ThreadUtil.execute(() -> smsService.massSendTextCard(recipients, "福利选择提醒", message, null, url, sender));
|
||||
return recipients.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回指定项目福利名单中没有有效选项记录的工号;空名单不扩大查询范围。
|
||||
* 项目停用或选择截止时拒绝查询及提交,避免邀请进入无法选择的项目。
|
||||
*/
|
||||
private List<String> listReminderRecipients(String projectId) {
|
||||
WelfareProject project = StrUtil.isBlank(projectId) ? null : fetch(projectId);
|
||||
if (project == null) {
|
||||
throw new IllegalArgumentException("福利项目不存在");
|
||||
}
|
||||
if (Boolean.TRUE.equals(project.getIsDisabled()) || project.getChoiceTimeEnd() == null
|
||||
|| !new Date().before(project.getChoiceTimeEnd())) {
|
||||
throw new IllegalArgumentException("福利项目已停用或选择已截止,无法发送提醒");
|
||||
}
|
||||
Sql sql = Sqls.create("""
|
||||
SELECT DISTINCT u.loginname
|
||||
FROM welfare_list wl
|
||||
JOIN sys_user u ON u.id = wl.userId
|
||||
WHERE wl.projectId = @projectId
|
||||
AND u.loginname IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM welfare_project_user_selection s
|
||||
WHERE s.welfareId = wl.projectId AND s.selectUserId = wl.userId
|
||||
AND s.selectOptionId IS NOT NULL
|
||||
)
|
||||
""");
|
||||
sql.setParam("projectId", projectId);
|
||||
return listMap(sql).stream().map(row -> row.getString("loginname"))
|
||||
.filter(StrUtil::isNotBlank).map(String::trim).distinct().toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public WelfareProject projectInfo(String projectId) {
|
||||
WelfareProject project = fetch(projectId);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
INSERT INTO `sys_config`
|
||||
(`configKey`, `configValue`, `note`, `createdBy`, `createdAt`, `updatedBy`, `updatedAt`, `delFlag`)
|
||||
SELECT
|
||||
'AppActivitySignTencentMap', 'true',
|
||||
'活动签到腾讯位置服务开关:true开启,false关闭',
|
||||
'', UNIX_TIMESTAMP() * 1000, '', UNIX_TIMESTAMP() * 1000, 0
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `sys_config`
|
||||
WHERE `configKey` = 'AppActivitySignTencentMap'
|
||||
);
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
const owners = {}
|
||||
const stack = []
|
||||
const popupHandledKey = "__mobilePopupHandled"
|
||||
let sequence = 0
|
||||
|
||||
function pushHistory(ownerId, popupId) {
|
||||
const token = "popup-" + (++sequence)
|
||||
stack.push({ownerId: ownerId, popupId: popupId, token: token})
|
||||
// 保留当前 PJAX 状态,避免关闭弹框回退时被识别为页面切换并重新加载外层页面。
|
||||
const currentState = window.history.state || {}
|
||||
const popupState = Object.assign({}, currentState, {mobilePopupToken: token})
|
||||
window.history.pushState(popupState, "", window.location.href)
|
||||
}
|
||||
|
||||
function closeTopFromHistory() {
|
||||
const item = stack.pop()
|
||||
if (!item) return false
|
||||
const callback = owners[item.ownerId]
|
||||
if (callback) callback(item.popupId)
|
||||
return true
|
||||
}
|
||||
|
||||
window.addEventListener("popstate", (event) => {
|
||||
if (closeTopFromHistory()) {
|
||||
// 标记本次回退已由弹框处理,供页面中的 PJAX 监听跳过重复处理。
|
||||
event[popupHandledKey] = true
|
||||
event.stopImmediatePropagation()
|
||||
}
|
||||
}, true)
|
||||
|
||||
window.mobilePopupHistory = {
|
||||
register(ownerId, closeCallback) {
|
||||
owners[ownerId] = closeCallback
|
||||
},
|
||||
unregister(ownerId) {
|
||||
delete owners[ownerId]
|
||||
for (let i = stack.length - 1; i >= 0; i--) {
|
||||
if (stack[i].ownerId === ownerId) stack.splice(i, 1)
|
||||
}
|
||||
},
|
||||
open(ownerId, popupId) {
|
||||
if (!stack.some((item) => item.ownerId === ownerId && item.popupId === popupId)) {
|
||||
pushHistory(ownerId, popupId)
|
||||
}
|
||||
},
|
||||
close(ownerId, popupId) {
|
||||
const top = stack[stack.length - 1]
|
||||
if (top && top.ownerId === ownerId && top.popupId === popupId) {
|
||||
window.history.back()
|
||||
} else if (owners[ownerId]) {
|
||||
owners[ownerId](popupId)
|
||||
}
|
||||
},
|
||||
clear(ownerId) {
|
||||
for (let i = stack.length - 1; i >= 0; i--) {
|
||||
if (stack[i].ownerId === ownerId) stack.splice(i, 1)
|
||||
}
|
||||
},
|
||||
hasOpen(ownerId) {
|
||||
return stack.some((item) => item.ownerId === ownerId)
|
||||
},
|
||||
isHandledPopstate(event) {
|
||||
return Boolean(event && event[popupHandledKey])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<van-form ref="form">
|
||||
<template v-for="column in dynamicData">
|
||||
<van-field
|
||||
v-if="column.columnFormType === 'STEPPER'"
|
||||
:key="column.columnCode"
|
||||
:label="column.columnName"
|
||||
:required="column.isRequired"
|
||||
>
|
||||
<template #input>
|
||||
<van-stepper v-model="column.columnValue" :min="1"></van-stepper>
|
||||
</template>
|
||||
</van-field>
|
||||
<van-field
|
||||
v-else-if="column.columnFormType === 'SELECT' && ['DATE', 'DATETIME'].includes(column.columnType)"
|
||||
:key="column.columnCode"
|
||||
:label="column.columnName"
|
||||
:required="column.isRequired"
|
||||
:rules="[{required: column.isRequired, message: '请选择' + column.columnName}]"
|
||||
>
|
||||
<template #input>
|
||||
<input
|
||||
v-model="column.columnValue"
|
||||
:type="column.columnType === 'DATE' ? 'date' : 'datetime-local'"
|
||||
class="native-input"
|
||||
>
|
||||
</template>
|
||||
</van-field>
|
||||
<van-field
|
||||
v-else-if="column.columnFormType !== 'SELECT' && column.columnType !== 'JSON'"
|
||||
:key="column.columnCode"
|
||||
v-model="column.columnValue"
|
||||
:label="column.columnName"
|
||||
:name="column.columnCode"
|
||||
:required="column.isRequired"
|
||||
:type="column.columnType === 'INT' ? 'digit' : 'text'"
|
||||
:placeholder="'请填写' + column.columnName"
|
||||
:rules="[{required: column.isRequired, message: '请填写' + column.columnName}]"
|
||||
></van-field>
|
||||
<van-field
|
||||
v-else-if="column.columnFormType === 'SELECT'"
|
||||
:key="column.columnCode"
|
||||
:label="column.columnName"
|
||||
:required="column.isRequired"
|
||||
:rules="[{required: column.isRequired, message: '请选择' + column.columnName}]"
|
||||
>
|
||||
<template #input>
|
||||
<select v-model="column.columnValue" class="native-select">
|
||||
<option value="">请选择</option>
|
||||
<option v-for="option in column.selectValues" :key="option" :value="option">{{option}}</option>
|
||||
</select>
|
||||
</template>
|
||||
</van-field>
|
||||
<van-field
|
||||
v-else
|
||||
:key="column.columnCode"
|
||||
:label="column.columnName"
|
||||
:required="column.isRequired"
|
||||
:rules="[{required: column.isRequired, message: '请上传' + column.columnName}]"
|
||||
>
|
||||
<template #input>
|
||||
<h5-file-upload
|
||||
:value.sync="column.columnValue"
|
||||
:upload_number="column.fileNumber"
|
||||
upload_mode="file"
|
||||
upload_result_category="array"
|
||||
upload_result_type="url"
|
||||
complete_result
|
||||
></h5-file-upload>
|
||||
</template>
|
||||
</van-field>
|
||||
</template>
|
||||
</van-form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
module.exports = {
|
||||
name: "TrainSignDynamicForm",
|
||||
props: {
|
||||
dynamicData: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
validate() {
|
||||
return this.$refs.form.validate()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.native-select {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #323233;
|
||||
}
|
||||
.native-input {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #323233;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div style="position: relative !important">
|
||||
<div style="margin-bottom: 10px">
|
||||
<!-- 点位搜索暂时隐藏,保留现有实现便于后续恢复。 -->
|
||||
<div v-if="false" style="margin-bottom: 10px">
|
||||
<el-autocomplete
|
||||
v-model="addressValue"
|
||||
style="width: 100%"
|
||||
|
||||
@@ -21,37 +21,63 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
|
||||
content: {
|
||||
immediate: true,
|
||||
handler() {
|
||||
// 详情数据通常在组件创建后异步回填,每次内容变化都重新识别并渲染 PDF。
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
pdf: true,
|
||||
pdfObj: null,
|
||||
previewVersion: 0,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
init() {
|
||||
this.destroyPdf()
|
||||
const currentVersion = ++this.previewVersion
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(this.content, 'text/html');
|
||||
const link = doc.querySelector('a');
|
||||
const href = link.getAttribute('href');
|
||||
if(!href) {
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(this.content || '', 'text/html')
|
||||
const link = doc.querySelector('a')
|
||||
const href = link ? link.getAttribute('href') : ''
|
||||
if (!href) {
|
||||
this.pdf = false
|
||||
return
|
||||
}
|
||||
|
||||
const parsedUrl = new URL(href, window.location.origin)
|
||||
const linkText = (link.textContent || '').trim().toLowerCase()
|
||||
const isPdf = linkText.endsWith('.pdf') || parsedUrl.pathname.toLowerCase().endsWith('.pdf')
|
||||
if (!isPdf) {
|
||||
this.pdf = false
|
||||
return
|
||||
}
|
||||
|
||||
// 系统上传文件可能保存了其他环境的绝对域名,内部下载地址统一使用当前站点。
|
||||
const pdfUrl = parsedUrl.pathname === '/platform/sys/file/download'
|
||||
? parsedUrl.pathname + parsedUrl.search : parsedUrl.href
|
||||
this.pdf = true
|
||||
this.$nextTick(() => {
|
||||
if (currentVersion !== this.previewVersion || !this.pdf) return
|
||||
this.pdfObj = new Pdfh5('#pdf-container', {
|
||||
pdfurl: href,
|
||||
});
|
||||
this.pdfObj.on("complete", function () {
|
||||
const elements = document.querySelectorAll('[class*="canvasImg"]')
|
||||
let classArray = []
|
||||
pdfurl: pdfUrl,
|
||||
// 使用二进制数据直接交给 PDF.js,避免 Blob 转 Base64 后被 CSP 的 connect-src 拦截。
|
||||
responseType: 'arraybuffer',
|
||||
})
|
||||
this.pdfObj.on("complete", () => {
|
||||
if (currentVersion !== this.previewVersion) return
|
||||
const elements = this.$el.querySelectorAll('[class*="canvasImg"]')
|
||||
const classArray = []
|
||||
elements.forEach(element => {
|
||||
classArray.push(element.getAttribute('src'))
|
||||
})
|
||||
elements.forEach((element,index) => {
|
||||
element.addEventListener('click', function() {
|
||||
element.addEventListener('click', () => {
|
||||
vant.ImagePreview({
|
||||
images: classArray,
|
||||
startPosition: index,
|
||||
@@ -65,9 +91,17 @@ module.exports = {
|
||||
this.pdf = false
|
||||
}
|
||||
},
|
||||
destroyPdf() {
|
||||
// 内容切换或组件销毁时释放 PDF 渲染实例,避免重复打开详情产生资源残留。
|
||||
if (this.pdfObj && this.pdfObj.destroy) {
|
||||
this.pdfObj.destroy()
|
||||
}
|
||||
this.pdfObj = null
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.init()
|
||||
beforeDestroy() {
|
||||
this.previewVersion += 1
|
||||
this.destroyPdf()
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,74 +1,128 @@
|
||||
<template>
|
||||
<div>
|
||||
<div ref="editorRef"></div>
|
||||
<input type="file" style="display: none" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const { $, BtnMenu, Panel, Tooltip } = wangEditor
|
||||
|
||||
const showImportLoading = () => {
|
||||
if (window.ELEMENT) {
|
||||
return ELEMENT.Loading.service({
|
||||
lock: true,
|
||||
text: "文件读取中",
|
||||
spinner: "el-icon-loading",
|
||||
background: "rgba(0, 0, 0, 0.7)"
|
||||
})
|
||||
}
|
||||
if (window.vant) {
|
||||
return vant.Toast.loading({
|
||||
message: "文件读取中",
|
||||
forbidClick: true,
|
||||
overlay: true,
|
||||
duration: 0
|
||||
})
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const closeImportLoading = (loading) => {
|
||||
if (loading && loading.close) {
|
||||
loading.close()
|
||||
} else if (window.vant && vant.Toast.clear) {
|
||||
vant.Toast.clear()
|
||||
}
|
||||
}
|
||||
|
||||
const showImportError = (message) => {
|
||||
if (window.ELEMENT && ELEMENT.Message) {
|
||||
ELEMENT.Message.error(message)
|
||||
} else if (window.vant) {
|
||||
vant.Toast(message)
|
||||
} else {
|
||||
alert(message)
|
||||
}
|
||||
}
|
||||
|
||||
const chooseImportFile = (accept, fileHandler) => {
|
||||
const input = document.createElement("input")
|
||||
input.type = "file"
|
||||
input.accept = accept
|
||||
input.style.display = "none"
|
||||
input.addEventListener("change", (event) => {
|
||||
const files = event.target.files
|
||||
if (!files || files.length === 0) {
|
||||
input.remove()
|
||||
return
|
||||
}
|
||||
fileHandler(files[0]).finally(() => {
|
||||
input.remove()
|
||||
})
|
||||
}, {once: true})
|
||||
document.body.appendChild(input)
|
||||
input.click()
|
||||
}
|
||||
|
||||
const importEditorDocument = (editor, file, url, errorMessage) => {
|
||||
const loading = showImportLoading()
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
return axios.post(url, formData, {
|
||||
headers: {"Content-Type": "multipart/form-data", "x-requested-with": "XMLHttpRequest"}
|
||||
}).then((response) => {
|
||||
if (response.data.code === 0) {
|
||||
editor.txt.html(response.data.data || "")
|
||||
} else {
|
||||
showImportError(response.data.msg || errorMessage)
|
||||
}
|
||||
}).catch(() => {
|
||||
showImportError(errorMessage)
|
||||
}).finally(() => {
|
||||
closeImportLoading(loading)
|
||||
})
|
||||
}
|
||||
|
||||
class PDFMenu extends BtnMenu {
|
||||
constructor(editor) {
|
||||
const $elem = wangEditor.$(
|
||||
`<div class="w-e-menu" data-title="上传pdf">
|
||||
<i class="fa fa-file-pdf-o" aria-hidden="true" /></i>
|
||||
</div>`
|
||||
'<div class="w-e-menu" data-title="上传PDF"><i class="fa fa-file-pdf-o" aria-hidden="true"></i></div>'
|
||||
)
|
||||
super($elem, editor)
|
||||
}
|
||||
|
||||
clickHandler() {
|
||||
const _this = this
|
||||
const inputFileElement = document.querySelector('#'+this.editor.textElemId).parentElement.parentElement.nextElementSibling
|
||||
inputFileElement.click()
|
||||
inputFileElement.addEventListener('change', function () {
|
||||
const file = this.files[0];
|
||||
const fileName = file.name
|
||||
|
||||
if (!fileName.toLowerCase().endsWith('.pdf')) {
|
||||
alert('请上传pdf格式的文件')
|
||||
_this.clearInputFile()
|
||||
return
|
||||
chooseImportFile("application/pdf,.pdf", (file) => {
|
||||
if (!file.name.toLowerCase().endsWith(".pdf")) {
|
||||
showImportError("请上传PDF文件")
|
||||
return Promise.resolve()
|
||||
}
|
||||
if (this.editor.config.officeImageImport) {
|
||||
return importEditorDocument(this.editor, file,
|
||||
"/platform/sys/file/convertPdfToImageHtml", "PDF导入失败,请联系管理员")
|
||||
}
|
||||
|
||||
const loading = showImportLoading()
|
||||
const formData = new FormData()
|
||||
formData.append('file', file, file.name)
|
||||
/*axios.post("/platform/sys/file/uploadDynamicReturnUrl", formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data"
|
||||
}
|
||||
})*/
|
||||
axios.post("/platform/sys/file/uploadDynamicReturnUrl", formData, {}).then(response => {
|
||||
console.log(response.data)
|
||||
if (response.data.code === 0) {
|
||||
const html = `<a target="_blank" href="${window.location.origin}${response.data.data}">
|
||||
${file.name}
|
||||
</a>`
|
||||
_this.editor.txt.html(html)
|
||||
} else {
|
||||
alert(response.data.msg + ',上传pdf失败,请联系管理员!')
|
||||
_this.clearInputFile()
|
||||
}
|
||||
}).catch(error => {
|
||||
console.log(error)
|
||||
_this.clearInputFile()
|
||||
alert('上传pdf失败,请联系管理员!')
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
clearInputFile() {
|
||||
const obj = document.getElementById('fileInput');
|
||||
if(obj) {
|
||||
obj.outerHTML = obj.outerHTML
|
||||
}
|
||||
}
|
||||
|
||||
tryChangeActive() {
|
||||
this.active()
|
||||
formData.append("file", file, file.name)
|
||||
return axios.post("/platform/sys/file/uploadDynamicReturnUrl", formData, {})
|
||||
.then((response) => {
|
||||
if (response.data.code === 0) {
|
||||
const html = '<a target="_blank" href="' + window.location.origin
|
||||
+ response.data.data + '">' + file.name + '</a>'
|
||||
this.editor.txt.html(html)
|
||||
} else {
|
||||
showImportError(response.data.msg + ",上传PDF失败,请联系管理员")
|
||||
}
|
||||
}).catch(() => {
|
||||
showImportError("上传PDF失败,请联系管理员")
|
||||
}).finally(() => {
|
||||
closeImportLoading(loading)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
tryChangeActive() {}
|
||||
}
|
||||
|
||||
//注册上传word按钮
|
||||
@@ -83,70 +137,38 @@ class DocMenu extends BtnMenu {
|
||||
}
|
||||
|
||||
clickHandler() {
|
||||
const fileInputId = this.editor.textElemId + "input-file"
|
||||
const existingInput = document.getElementById(fileInputId)
|
||||
if (existingInput) {
|
||||
existingInput.remove()
|
||||
}
|
||||
chooseImportFile("application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,.doc,.docx", (file) => {
|
||||
return importEditorDocument(this.editor, file,
|
||||
"/platform/sys/file/convertHtml", "Word导入失败,请联系管理员")
|
||||
})
|
||||
}
|
||||
|
||||
tryChangeActive() {}
|
||||
}
|
||||
|
||||
const input = document.createElement("input")
|
||||
input.type = "file"
|
||||
input.accept = "application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
input.id = fileInputId
|
||||
input.style.display = "none"
|
||||
input.addEventListener("change", (event) => {
|
||||
const files = event.target.files
|
||||
if (!files || files.length === 0) {
|
||||
return
|
||||
}
|
||||
class ExcelMenu extends BtnMenu {
|
||||
constructor(editor) {
|
||||
const displayStyle = editor.config.officeImageImport ? "" : "display:none;"
|
||||
const $elem = wangEditor.$(
|
||||
'<div class="w-e-menu" data-title="上传Excel" style="' + displayStyle
|
||||
+ '"><i class="fa fa-file-excel-o" aria-hidden="true"></i></div>'
|
||||
)
|
||||
super($elem, editor)
|
||||
}
|
||||
|
||||
let loading = null
|
||||
if (window.ELEMENT) {
|
||||
loading = ELEMENT.Loading.service({
|
||||
lock: true,
|
||||
text: "文件读取中",
|
||||
spinner: "el-icon-loading",
|
||||
background: "rgba(0, 0, 0, 0.7)"
|
||||
})
|
||||
} else if (window.vant) {
|
||||
loading = vant.Toast.loading({
|
||||
message: "文件读取中",
|
||||
forbidClick: true,
|
||||
overlay: true,
|
||||
duration: 0
|
||||
})
|
||||
clickHandler() {
|
||||
if (!this.editor.config.officeImageImport) {
|
||||
return
|
||||
}
|
||||
chooseImportFile("application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,.xls,.xlsx", (file) => {
|
||||
const fileName = file.name.toLowerCase()
|
||||
if (!fileName.endsWith(".xls") && !fileName.endsWith(".xlsx")) {
|
||||
showImportError("请上传xls或xlsx文件")
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append("file", files[0])
|
||||
axios
|
||||
.post("/platform/sys/file/convertHtml", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data", "x-requested-with": "XMLHttpRequest" }
|
||||
})
|
||||
.then((response) => {
|
||||
loading.close()
|
||||
if (response.data.code === 0) {
|
||||
this.editor.txt.html(response.data.data)
|
||||
} else {
|
||||
if (window.ELEMENT) {
|
||||
this.$message.error(response.data.msg)
|
||||
} else if (window.vant) {
|
||||
vant.Toast(response.data.msg)
|
||||
} else {
|
||||
alert(response.data.msg)
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error)
|
||||
alert("上传word失败,请联系管理员!")
|
||||
})
|
||||
.finally(() => {
|
||||
input.remove()
|
||||
})
|
||||
return importEditorDocument(this.editor, file,
|
||||
"/platform/sys/file/convertExcelToHtml", "Excel导入失败,请联系管理员")
|
||||
})
|
||||
input.click()
|
||||
document.body.appendChild(input)
|
||||
}
|
||||
|
||||
tryChangeActive() {}
|
||||
@@ -154,6 +176,7 @@ class DocMenu extends BtnMenu {
|
||||
|
||||
wangEditor.registerMenu("docMenu", DocMenu)
|
||||
wangEditor.registerMenu('pdfMenuKey', PDFMenu)
|
||||
wangEditor.registerMenu("excelMenu", ExcelMenu)
|
||||
module.exports = {
|
||||
name: "textEditor",
|
||||
props: {
|
||||
@@ -170,6 +193,10 @@ module.exports = {
|
||||
type: Number,
|
||||
default: 300
|
||||
},
|
||||
officeImageImport: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
menus: {
|
||||
type: Array,
|
||||
default: () => {
|
||||
@@ -214,7 +241,7 @@ module.exports = {
|
||||
|
||||
watch: {
|
||||
value: {
|
||||
handler: function (newValue) {
|
||||
handler(newValue) {
|
||||
this.$nextTick(() => {
|
||||
const content = newValue || ""
|
||||
if (this.editor.txt.html() !== content) {
|
||||
@@ -237,6 +264,8 @@ module.exports = {
|
||||
let _this = this
|
||||
this.editor = new wangEditor(this.$refs.editorRef)
|
||||
this.editor.config.focus = false
|
||||
// 仅指定业务启用PDF转图片和Excel转表格,其他富文本页面保持原有导入行为。
|
||||
this.editor.config.officeImageImport = this.officeImageImport
|
||||
//内容change回调
|
||||
this.editor.config.onchange = (newHtml) => {
|
||||
this.cursorPos = this.editor.selection.getCursorPos()
|
||||
@@ -244,13 +273,13 @@ module.exports = {
|
||||
range.setStart(range.startContainer, Math.max(this.cursorPos, range.endOffset))
|
||||
this.$emit("input", newHtml)
|
||||
}
|
||||
this.editor.config.linkImgCheck = function (imgSrc, alt, href) {
|
||||
this.editor.config.linkImgCheck = (imgSrc, alt, href) => {
|
||||
// 1. 返回 true ,说明检查通过
|
||||
return true
|
||||
}
|
||||
|
||||
//配置图片上传
|
||||
this.editor.config.customUploadImg = function (resultFiles, insertImgFn) {
|
||||
this.editor.config.customUploadImg = (resultFiles, insertImgFn) => {
|
||||
// resultFiles 是 input 中选中的文件列表
|
||||
// insertImgFn 是获取图片 url 后,插入到编辑器的方法
|
||||
Promise.all(_this.uploadFiles(resultFiles)).then((res) => {
|
||||
@@ -269,7 +298,7 @@ module.exports = {
|
||||
this.editor.create()
|
||||
},
|
||||
uploadFiles(resultFiles) {
|
||||
return resultFiles.map(async (file) => {
|
||||
return resultFiles.map((file) => {
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
return axios.post("/platform/sys/file/uploadDynamicReturnUrl", formData, {
|
||||
|
||||
@@ -347,6 +347,19 @@ module.exports = {
|
||||
|
||||
// 预览图片
|
||||
handlePreview(file) {
|
||||
if (this.upload_mode === "image") {
|
||||
// 回显记录的名称可能是无扩展名的下载地址;图片模式直接预览缩略图地址。
|
||||
const url = file.url || (this.upload_result_type === "url" && file.response?.data)
|
||||
if (!url) {
|
||||
this.$message.warning("暂无可预览的图片")
|
||||
return
|
||||
}
|
||||
const image = new Image()
|
||||
image.src = url
|
||||
const viewer = new Viewer(image, {zIndex: 99999, title: false, hidden: () => viewer.destroy()})
|
||||
viewer.show()
|
||||
return
|
||||
}
|
||||
const suffix = file.name.substring(file.name.lastIndexOf(".") + 1).toLowerCase()
|
||||
const fileId = file.response.data.substring(file.response.data.lastIndexOf("=") + 1)
|
||||
const buildFile = {
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
<script src="${base!}/assets/platform/js/mixin/styleMixin.js"></script>
|
||||
<script src="${base!}/assets/platform/js/tool/businessTool.js"></script>
|
||||
<script src="${base!}/assets/platform/js/util/h5AutoShowError.js"></script>
|
||||
<script src="${base!}/assets/mobile/js/popupHistory.js"></script>
|
||||
|
||||
<!--富文本编辑器-->
|
||||
<link rel="stylesheet" href="${base!}/assets/platform/plugins/wangEditor4/wangEditor.css"/>
|
||||
@@ -76,7 +77,9 @@
|
||||
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>
|
||||
<!--# if (activitySignTencentMapEnabled!true) { #-->
|
||||
<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
|
||||
|
||||
@@ -121,6 +124,11 @@
|
||||
|
||||
// 监听浏览器的前进和后退操作
|
||||
$(window).on("popstate", function (event) {
|
||||
// 弹框关闭产生的回退只用于关闭弹框,不重新加载当前业务页面。
|
||||
if (window.mobilePopupHistory &&
|
||||
window.mobilePopupHistory.isHandledPopstate(event.originalEvent)) {
|
||||
return
|
||||
}
|
||||
if (event.originalEvent.state) {
|
||||
// 如果存在历史记录状态,触发 Pjax 加载对应内容
|
||||
$.pjax({
|
||||
|
||||
@@ -99,7 +99,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 后、使用前插入
|
||||
|
||||
@@ -3,7 +3,175 @@ layout("/layouts/platform.html"){
|
||||
#-->
|
||||
|
||||
<style>
|
||||
|
||||
.activity-introduce-dialog {
|
||||
max-width: calc(100vw - 40px);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.activity-introduce-dialog .el-dialog__header {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
.activity-introduce-dialog .el-dialog__title {
|
||||
color: #303133;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.activity-introduce-dialog .el-dialog__body {
|
||||
padding: 0;
|
||||
}
|
||||
.activity-introduce-dialog .el-dialog__footer {
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid #ebeef5;
|
||||
}
|
||||
.activity-introduce-layout {
|
||||
display: flex;
|
||||
height: calc(86vh - 128px);
|
||||
min-height: 520px;
|
||||
max-height: 650px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.activity-introduce-summary {
|
||||
box-sizing: border-box;
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
padding: 24px;
|
||||
background: #f7faff;
|
||||
border-right: 1px solid #ebeef5;
|
||||
}
|
||||
.activity-introduce-cover {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 152px;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
background: #eef3f8;
|
||||
}
|
||||
.activity-introduce-cover-empty {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #a8abb2;
|
||||
font-size: 40px;
|
||||
}
|
||||
.activity-introduce-name {
|
||||
margin: 16px 0 18px;
|
||||
color: #303133;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.activity-introduce-meta {
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
.activity-introduce-meta-item {
|
||||
display: flex;
|
||||
margin-bottom: 16px;
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.activity-introduce-meta-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.activity-introduce-meta-item > i {
|
||||
width: 20px;
|
||||
margin-top: 4px;
|
||||
color: #909399;
|
||||
font-size: 16px;
|
||||
}
|
||||
.activity-introduce-meta-label {
|
||||
color: #909399;
|
||||
}
|
||||
.activity-introduce-steps {
|
||||
height: 115px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
.activity-introduce-steps .el-step__title {
|
||||
font-size: 14px;
|
||||
}
|
||||
.activity-introduce-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
padding: 24px;
|
||||
}
|
||||
.activity-introduce-main-title {
|
||||
margin-bottom: 16px;
|
||||
padding-left: 12px;
|
||||
border-left: 4px solid var(--color-primary);
|
||||
color: #303133;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
}
|
||||
.activity-introduce-file {
|
||||
display: flex;
|
||||
height: 38px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
padding: 0 14px;
|
||||
border-radius: 4px;
|
||||
background: #f2f4f7;
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
}
|
||||
.activity-introduce-file i {
|
||||
margin-right: 8px;
|
||||
color: #f56c6c;
|
||||
font-size: 16px;
|
||||
}
|
||||
.activity-introduce-preview {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
margin-top: 14px;
|
||||
overflow: hidden;
|
||||
background: #eef1f5;
|
||||
}
|
||||
.activity-introduce-pdf {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
.activity-introduce-content {
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 28px 36px;
|
||||
background: #fff;
|
||||
color: #303133;
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.activity-introduce-empty {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #909399;
|
||||
}
|
||||
.activity-introduce-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.activity-introduce-footer-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.activity-introduce-footer-actions .el-button {
|
||||
min-width: 110px;
|
||||
}
|
||||
.activity-introduce-footer-actions .el-button--primary {
|
||||
min-width: 180px;
|
||||
}
|
||||
.activity-introduce-countdown {
|
||||
color: #e6a23c;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="app" v-cloak>
|
||||
@@ -60,7 +228,7 @@ layout("/layouts/platform.html"){
|
||||
<template v-slot="{row}">
|
||||
<template v-if="$moment().isBefore($moment(row.activitySignUpEndTime))">
|
||||
<el-button @click="onView(row)" size="mini" type="primary">查看介绍</el-button>
|
||||
<el-button @click="onOpen(row)" size="mini" type="primary">去报名</el-button>
|
||||
<el-button @click="onView(row)" size="mini" type="primary">去报名</el-button>
|
||||
</template>
|
||||
<template v-if="$moment().isAfter($moment(row.activitySignUpEndTime))">
|
||||
<el-button size="mini" type="info">已结束</el-button>
|
||||
@@ -76,40 +244,101 @@ layout("/layouts/platform.html"){
|
||||
</template>
|
||||
</guava>
|
||||
|
||||
<el-dialog title="详细信息" :visible.sync="infoVisible" width="60%">
|
||||
<el-dialog title="活动介绍"
|
||||
custom-class="activity-introduce-dialog"
|
||||
:close-on-click-modal="false"
|
||||
:visible.sync="infoVisible"
|
||||
top="7vh"
|
||||
width="1120px">
|
||||
<div class="activity-introduce-layout">
|
||||
<aside class="activity-introduce-summary">
|
||||
<el-image class="activity-introduce-cover" :src="infoRow.cover" fit="cover">
|
||||
<div slot="error" class="activity-introduce-cover-empty">
|
||||
<i class="el-icon-picture-outline"></i>
|
||||
</div>
|
||||
</el-image>
|
||||
<div class="activity-introduce-name">{{infoRow.activityName || '品牌活动'}}</div>
|
||||
<div class="activity-introduce-meta">
|
||||
<div class="activity-introduce-meta-item">
|
||||
<i class="el-icon-date"></i>
|
||||
<div>
|
||||
<div class="activity-introduce-meta-label">报名时间</div>
|
||||
<div>{{$moment(infoRow.activitySignUpStartTime).format('YYYY-MM-DD HH:mm')}}</div>
|
||||
<div>至 {{$moment(infoRow.activitySignUpEndTime).format('YYYY-MM-DD HH:mm')}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="activity-introduce-meta-item">
|
||||
<i class="el-icon-time"></i>
|
||||
<div>
|
||||
<div class="activity-introduce-meta-label">活动时间</div>
|
||||
<div>{{$moment(infoRow.activityStartTime).format('YYYY-MM-DD HH:mm')}}</div>
|
||||
<div>至 {{$moment(infoRow.activityEndTime).format('YYYY-MM-DD HH:mm')}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="activity-introduce-meta-item">
|
||||
<i class="el-icon-user"></i>
|
||||
<div>
|
||||
<div class="activity-introduce-meta-label">面向对象</div>
|
||||
<div>{{infoRow.activityGroupName || '全体工会会员'}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-steps class="activity-introduce-steps" direction="vertical" :active="0">
|
||||
<el-step title="阅读活动介绍"></el-step>
|
||||
<el-step title="选择报名项目"></el-step>
|
||||
</el-steps>
|
||||
</aside>
|
||||
|
||||
<activity-info ref="infoRef"></activity-info>
|
||||
<section class="activity-introduce-main">
|
||||
<div class="activity-introduce-main-title">报名须知</div>
|
||||
<div class="activity-introduce-file">
|
||||
<i :class="activityIntroducePdfUrl ? 'fa fa-file-pdf-o' : 'fa fa-file-text-o'"></i>
|
||||
<span>{{activityIntroduceFileName}}</span>
|
||||
<span v-if="activityIntroducePageCount"> · {{activityIntroducePageCount}}页</span>
|
||||
</div>
|
||||
<div class="activity-introduce-preview">
|
||||
<iframe v-if="activityIntroducePdfUrl"
|
||||
ref="activityPdfFrame"
|
||||
class="activity-introduce-pdf"
|
||||
@load="styleActivityPdf"
|
||||
:src="activityIntroducePdfUrl"></iframe>
|
||||
<div v-else-if="infoRow.introduce"
|
||||
class="activity-introduce-content"
|
||||
v-html="infoRow.introduce"></div>
|
||||
<div v-else class="activity-introduce-empty">暂无活动介绍</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<span slot="footer" class="dialog-footer">
|
||||
<el-statistic
|
||||
v-if="time < 0"
|
||||
format="DD 天 HH 时 mm 分钟 ss 秒"
|
||||
:value="new Date(infoRow.activitySignUpStartTime)"
|
||||
time-indices
|
||||
title="距离开始:"
|
||||
@finish="time = 0"
|
||||
>
|
||||
</el-statistic>
|
||||
<template v-else>
|
||||
<el-button @click="infoVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="onOpen(infoRow)" :disabled="time < 0">
|
||||
去报名
|
||||
</el-button>
|
||||
</template>
|
||||
</span>
|
||||
<div slot="footer" class="activity-introduce-footer">
|
||||
<div>
|
||||
<el-checkbox v-model="activityIntroduceConfirmed" :disabled="time < 0">
|
||||
我已阅读并知晓活动说明
|
||||
</el-checkbox>
|
||||
<span class="activity-introduce-countdown" v-if="time < 0">
|
||||
报名尚未开始
|
||||
</span>
|
||||
</div>
|
||||
<div class="activity-introduce-footer-actions">
|
||||
<el-button @click="closeIntroduce">暂不报名</el-button>
|
||||
<el-button type="primary"
|
||||
:disabled="time < 0 || !activityIntroduceConfirmed"
|
||||
@click="onOpen(infoRow)">
|
||||
确定并选择报名项目
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
</div>
|
||||
|
||||
<script nonce="${cspNonce!}">
|
||||
<!--#include('courseList.js'){}#-->
|
||||
<!--#include('../manage/info.js'){}#-->
|
||||
new Vue({
|
||||
el: "#app",
|
||||
mixins: [initTableMixins],
|
||||
components: {
|
||||
"course-list": courseList,
|
||||
"activity-info": info,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -126,25 +355,89 @@ layout("/layouts/platform.html"){
|
||||
time: 0,
|
||||
infoRow: {},
|
||||
infoVisible: false,
|
||||
activityIntroducePdfUrl: '',
|
||||
activityIntroduceFileName: '活动介绍',
|
||||
activityIntroducePageCount: null,
|
||||
activityIntroduceConfirmed: false,
|
||||
|
||||
id: GetQueryString("id"),
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onView(row) {
|
||||
this.infoRow = row
|
||||
this.time = this.$moment().diff(this.$moment(row.activitySignUpStartTime), 'milliseconds')
|
||||
this.infoVisible = true
|
||||
this.$nextTick(() => {
|
||||
this.$refs.infoRef.initData(row.id)
|
||||
})
|
||||
const introduce = this.resolveActivityIntroduce(row.introduce)
|
||||
this.$set(this.$data, 'infoRow', row)
|
||||
this.$set(this.$data, 'activityIntroducePdfUrl', introduce.pdfUrl)
|
||||
this.$set(this.$data, 'activityIntroduceFileName', introduce.fileName)
|
||||
this.$set(this.$data, 'activityIntroducePageCount', null)
|
||||
this.$set(this.$data, 'activityIntroduceConfirmed', false)
|
||||
this.$set(this.$data, 'time', this.$moment().diff(this.$moment(row.activitySignUpStartTime), 'milliseconds'))
|
||||
this.$set(this.$data, 'infoVisible', true)
|
||||
},
|
||||
resolveActivityIntroduce(introduce) {
|
||||
try {
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(introduce || '', 'text/html')
|
||||
const link = doc.querySelector('a')
|
||||
const href = link ? link.getAttribute('href') : ''
|
||||
if (!href) return {pdfUrl: '', fileName: '活动介绍'}
|
||||
|
||||
const parsedUrl = new URL(href, window.location.origin)
|
||||
const linkText = (link.textContent || '').trim().toLowerCase()
|
||||
const isPdf = linkText.endsWith('.pdf') || parsedUrl.pathname.toLowerCase().endsWith('.pdf')
|
||||
if (!isPdf) return {pdfUrl: '', fileName: '活动介绍'}
|
||||
|
||||
// 系统内部文件使用当前站点地址,避免历史环境域名导致 PDF 无法加载。
|
||||
const pdfUrl = parsedUrl.pathname === '/platform/sys/file/download'
|
||||
? parsedUrl.pathname + parsedUrl.search : parsedUrl.href
|
||||
return {
|
||||
pdfUrl: '/assets/platform/plugins/pdfJs/web/viewer.html?file=' + encodeURIComponent(pdfUrl),
|
||||
fileName: (link.textContent || '').trim() || '活动介绍.pdf'
|
||||
}
|
||||
} catch (e) {
|
||||
// 无法识别为 PDF 时按原有富文本内容展示。
|
||||
return {pdfUrl: '', fileName: '活动介绍'}
|
||||
}
|
||||
},
|
||||
styleActivityPdf() {
|
||||
try {
|
||||
const frame = this.$refs.activityPdfFrame
|
||||
const viewerDocument = frame ? frame.contentDocument : null
|
||||
if (!viewerDocument) return
|
||||
|
||||
// 报名前预览只保留文档阅读区,隐藏 PDF.js 原生工具栏以匹配确认弹框的简洁层级。
|
||||
const toolbar = viewerDocument.getElementById('toolbarContainer')
|
||||
const mainContainer = viewerDocument.getElementById('mainContainer')
|
||||
const viewerContainer = viewerDocument.getElementById('viewerContainer')
|
||||
if (toolbar) toolbar.style.display = 'none'
|
||||
if (mainContainer) mainContainer.style.top = '0'
|
||||
if (viewerContainer) viewerContainer.style.top = '0'
|
||||
|
||||
const viewerApplication = frame.contentWindow.PDFViewerApplication
|
||||
if (viewerApplication && viewerApplication.initializedPromise) {
|
||||
viewerApplication.initializedPromise.then(() => {
|
||||
viewerApplication.eventBus.on('pagesloaded', (event) => {
|
||||
this.$set(this.$data, 'activityIntroducePageCount', event.pagesCount || null)
|
||||
})
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
// PDF 查看器样式隔离失败时保留原始查看器,不影响文档阅读和报名流程。
|
||||
}
|
||||
},
|
||||
closeIntroduce() {
|
||||
this.$set(this.$data, 'infoVisible', false)
|
||||
},
|
||||
onOpen(row) {
|
||||
if(this.$moment().isBefore(this.$moment(row.activitySignUpStartTime))) {
|
||||
this.onView(row)
|
||||
return
|
||||
}
|
||||
this.infoVisible = false
|
||||
if (!this.activityIntroduceConfirmed) {
|
||||
this.$message.warning('请先确认已阅读活动说明')
|
||||
return
|
||||
}
|
||||
this.$set(this.$data, 'infoVisible', false)
|
||||
this.$nextTick(() => {
|
||||
this.$refs.guava.view(() => {
|
||||
this.$refs.courseListRef.onOpen(row)
|
||||
|
||||
@@ -281,14 +281,44 @@ const basicForm = {
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<template v-if="allCourseSignEnabled">
|
||||
<div class="left-span-label" style="margin-top: 20px">签到扩展配置</div>
|
||||
<el-form-item label="签到点位数量">
|
||||
<el-input-number
|
||||
v-model="formData.signPointCount"
|
||||
:min="1"
|
||||
:max="formData.courseList.length"
|
||||
size="small"
|
||||
></el-input-number>
|
||||
<span class="sign-extension-tip">达到该点位数量后执行下方处理;不配置则保留原签到方式</span>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="formData.signPointCount" label="处理方式">
|
||||
<el-radio-group v-model="formData.signHandleType" size="small" @change="signHandleTypeChange">
|
||||
<el-radio-button :label="1">系统自动弹出填写信息</el-radio-button>
|
||||
<el-radio-button :label="2">系统自动选择礼物或福利</el-radio-button>
|
||||
<el-radio-button :label="3">无</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="formData.signPointCount && formData.signHandleType === 2" label="福利项目">
|
||||
<el-select
|
||||
v-model="formData.welfareProjectId"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="请选择达标后可选择的福利项目"
|
||||
style="width: 420px"
|
||||
>
|
||||
<el-option v-for="item in welfareProjectList" :key="item.id" :label="item.name" :value="item.id"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</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">保存</el-button>
|
||||
<el-button type="primary" @click="onSubmit">提交</el-button>
|
||||
<el-button type="primary" :loading="formLoading" :disabled="formLoading" @click="onSave">保存</el-button>
|
||||
<el-button type="primary" :loading="formLoading" :disabled="formLoading" @click="onSubmit">提交</el-button>
|
||||
</div>
|
||||
|
||||
<el-dialog :close-on-click-modal="false" :visible.sync="signUpDialog" title="设置报名限制" append-to-body>
|
||||
@@ -337,6 +367,9 @@ const basicForm = {
|
||||
typeLimits: [],
|
||||
hostUnits: [],
|
||||
helpUnits: [],
|
||||
signPointCount: null,
|
||||
signHandleType: 3,
|
||||
welfareProjectId: "",
|
||||
},
|
||||
historicalActList: [],
|
||||
trainTypeList: [],
|
||||
@@ -350,8 +383,16 @@ const basicForm = {
|
||||
campusList: [],
|
||||
courseTypeList: [],
|
||||
undertakeOptions: [],
|
||||
welfareProjectList: [],
|
||||
formLoading: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
allCourseSignEnabled() {
|
||||
return this.formData.courseList && this.formData.courseList.length > 0
|
||||
&& this.formData.courseList.every((item) => item.isMobileSign === true && item.signType === 1)
|
||||
},
|
||||
},
|
||||
components: {
|
||||
"drawer-user-scope": httpVueLoader("/components/module/activity/DrawerUserScope.vue"),
|
||||
"course-time": courseTime,
|
||||
@@ -468,6 +509,7 @@ const basicForm = {
|
||||
this.formData = resp.data
|
||||
this.typeChange(this.formData.trainType)
|
||||
this.formData.id = ""
|
||||
if (this.formData.signHandleType === 2) this.loadWelfareProjects()
|
||||
}
|
||||
},
|
||||
typeChange(val) {
|
||||
@@ -490,7 +532,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 === ""
|
||||
@@ -499,10 +541,11 @@ const basicForm = {
|
||||
this.$message.warning("请输入活动名称")
|
||||
return
|
||||
}
|
||||
await this.doHandle('保存')
|
||||
if (!this.validateSignExtension()) return
|
||||
this.doHandle('保存')
|
||||
},
|
||||
onSubmit() {
|
||||
this.$refs["form"].validate(async (valid, errMsg) => {
|
||||
this.$refs["form"].validate((valid, errMsg) => {
|
||||
if (valid) {
|
||||
const courseValid = this.formData.courseList.some((v, i) => {
|
||||
const basicValid = v.courseName && v.coursePeopleNumber && v.courseLocation && v.courseInstructor && v.courseType
|
||||
@@ -534,12 +577,13 @@ const basicForm = {
|
||||
this.$message.warning("请设置报名限制")
|
||||
return
|
||||
}
|
||||
if (!this.validateSignExtension()) return
|
||||
this.formData.isDisabled = false
|
||||
await this.doHandle('提交')
|
||||
this.doHandle('提交')
|
||||
}
|
||||
})
|
||||
},
|
||||
async doHandle(type) {
|
||||
doHandle(type) {
|
||||
const cloneData = clone(this.formData)
|
||||
cloneData.activitySignUpStartTime = cloneData.activitySignTime !== undefined ? cloneData.activitySignTime[0] : null
|
||||
cloneData.activitySignUpEndTime = cloneData.activitySignTime !== undefined ? cloneData.activitySignTime[1] : null
|
||||
@@ -552,21 +596,65 @@ 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 + "吗?", "提示", {
|
||||
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(() => {
|
||||
this.formLoading = true
|
||||
this.$axios.post("/platform/trainSignUp/manage/doHandle", cloneData)
|
||||
.then((resp) => {
|
||||
if (resp.code === 0) {
|
||||
this.$message.success(resp.msg)
|
||||
this.step = 1
|
||||
this.$emit('refresh')
|
||||
this.$emit('back')
|
||||
} else {
|
||||
this.$message.warning(resp.msg)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.formLoading = false
|
||||
})
|
||||
}).catch(() => {})
|
||||
},
|
||||
validateSignExtension() {
|
||||
if (!this.formData.signPointCount) return true
|
||||
if (!this.allCourseSignEnabled) {
|
||||
this.$message.warning("开启签到扩展后,所有课程必须启用扫码签到")
|
||||
return false
|
||||
}
|
||||
if (this.formData.signPointCount > this.formData.courseList.length) {
|
||||
this.$message.warning("签到点位数量不能超过课程数量")
|
||||
return false
|
||||
}
|
||||
if (this.formData.signHandleType === 1) {
|
||||
const typeIds = [...new Set(this.formData.courseList.map((item) => item.courseType))]
|
||||
if (typeIds.length !== 1 || !typeIds[0]) {
|
||||
this.$message.warning("填写信息方式要求所有课程使用同一种课程类型")
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (this.formData.signHandleType === 2 && !this.formData.welfareProjectId) {
|
||||
this.$message.warning("请选择达标后关联的福利项目")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
signHandleTypeChange(value) {
|
||||
if (value !== 2) {
|
||||
this.$set(this.formData, "welfareProjectId", "")
|
||||
return
|
||||
}
|
||||
this.loadWelfareProjects()
|
||||
},
|
||||
loadWelfareProjects() {
|
||||
const year = this.formData.year || (this.formData.activitySignTime && this.formData.activitySignTime[0]
|
||||
? new Date(this.formData.activitySignTime[0]).getFullYear() : new Date().getFullYear())
|
||||
this.$axios.post("/platform/trainSignUp/manage/getWelfareProjects", {year: year})
|
||||
.then((resp) => {
|
||||
this.welfareProjectList = resp.code === 0 && resp.data ? resp.data : []
|
||||
})
|
||||
},
|
||||
async getAllType() {
|
||||
const resp = await this.$axios.post("/platform/trainSignUp/type/getAllType", {})
|
||||
@@ -578,6 +666,7 @@ const basicForm = {
|
||||
if (resp.code === 0) {
|
||||
this.formData = resp.data
|
||||
this.typeChange(this.formData.trainType)
|
||||
if (this.formData.signHandleType === 2) this.loadWelfareProjects()
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -594,6 +683,13 @@ const basicForm = {
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
allCourseSignEnabled(value) {
|
||||
if (!value) {
|
||||
this.$set(this.formData, "signPointCount", null)
|
||||
this.$set(this.formData, "signHandleType", 3)
|
||||
this.$set(this.formData, "welfareProjectId", "")
|
||||
}
|
||||
},
|
||||
'formData.restrictLimit'(val) {
|
||||
if (!this.formData.id) {
|
||||
this.formData.limitNum = val === 3 ? 1 : ''
|
||||
@@ -608,6 +704,10 @@ const basicForm = {
|
||||
},
|
||||
},
|
||||
style: /*language=CSS*/ `
|
||||
|
||||
.sign-extension-tip {
|
||||
margin-left: 12px;
|
||||
color: #f56c6c;
|
||||
font-size: 12px;
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ const courseTime = {
|
||||
<el-row style="margin-bottom: 10px">
|
||||
<el-time-select
|
||||
:picker-options="{
|
||||
start: '08:30',
|
||||
start: '00:00',
|
||||
step: '00:05',
|
||||
end: '23:30'
|
||||
}"
|
||||
@@ -28,7 +28,7 @@ const courseTime = {
|
||||
></el-time-select>
|
||||
<el-time-select
|
||||
:picker-options="{
|
||||
start: '08:30',
|
||||
start: '00:00',
|
||||
step: '00:05',
|
||||
end: '23:30'
|
||||
}"
|
||||
@@ -56,7 +56,7 @@ const courseTime = {
|
||||
<template v-slot="{row}">
|
||||
<el-time-select
|
||||
:picker-options="{
|
||||
start: '08:30',
|
||||
start: '00:00',
|
||||
step: '00:05',
|
||||
end: '23:30'
|
||||
}"
|
||||
@@ -70,7 +70,7 @@ const courseTime = {
|
||||
<template v-slot="{row}">
|
||||
<el-time-select
|
||||
:picker-options="{
|
||||
start: '08:30',
|
||||
start: '00:00',
|
||||
step: '00:05',
|
||||
end: '23:30'
|
||||
}"
|
||||
|
||||
@@ -91,6 +91,7 @@ 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="openSignupReminder(row)">提醒未参加人员</el-dropdown-item>
|
||||
<el-dropdown-item @click.native="openEdit(row)">编辑</el-dropdown-item>
|
||||
<el-dropdown-item @click.native="onDelete(row)">删除</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
@@ -111,6 +112,35 @@ layout("/layouts/platform.html"){
|
||||
|
||||
<make-qrcode ref="codeRef"></make-qrcode>
|
||||
|
||||
<el-dialog title="提醒未参加活动人员报名"
|
||||
:visible.sync="reminderVisible"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="!formLoading"
|
||||
:show-close="!formLoading"
|
||||
width="520px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="子活动" required>
|
||||
<el-select :value="reminder.courseId" @change="selectReminderCourse"
|
||||
:disabled="formLoading" placeholder="请选择子活动" style="width: 100%">
|
||||
<el-option v-for="course in reminder.courses" :key="course.id"
|
||||
:label="course.courseName" :value="course.id"></el-option>
|
||||
</el-select>
|
||||
<span v-if="reminder.countLoading">正在查询未报名人数</span>
|
||||
<span v-else-if="reminder.count !== null">预计接收人数:{{ reminder.count }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="提醒内容" required>
|
||||
<el-input type="textarea" :rows="6" :value="reminder.content" :disabled="formLoading"
|
||||
@input="editReminderContent" placeholder="请输入提醒内容"></el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template slot="footer">
|
||||
<el-button :disabled="formLoading" @click="$set($data, 'reminderVisible', false)">取消</el-button>
|
||||
<el-button type="primary" :loading="formLoading"
|
||||
:disabled="reminder.countLoading || !(reminder.count > 0) || !reminder.content.trim()"
|
||||
@click="submitSignupReminder">发送提醒</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
title="活动二维码"
|
||||
:visible.sync="codeDialogVisible"
|
||||
@@ -154,9 +184,84 @@ layout("/layouts/platform.html"){
|
||||
],
|
||||
codeDialogVisible: false,
|
||||
activityUrl: '',
|
||||
reminderVisible: false,
|
||||
reminder: { courses: [], content: "", count: null },
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 列表接口已关联子活动,按序号展示当前活动的选项。
|
||||
openSignupReminder(row) {
|
||||
const courses = (row.courseList || []).slice().sort((a, b) => (a.orderNum || 0) - (b.orderNum || 0))
|
||||
if (!courses.length) {
|
||||
this.$message.warning("当前活动没有子活动")
|
||||
return
|
||||
}
|
||||
this.$set(this.$data, "reminder", {
|
||||
activityId: row.id,
|
||||
deadline: row.activitySignUpEndTime,
|
||||
courses: courses,
|
||||
courseId: "",
|
||||
content: "",
|
||||
edited: false,
|
||||
count: null,
|
||||
countLoading: false
|
||||
})
|
||||
this.$set(this.$data, "reminderVisible", true)
|
||||
},
|
||||
editReminderContent(value) {
|
||||
this.$set(this.reminder, "content", value)
|
||||
this.$set(this.reminder, "edited", true)
|
||||
},
|
||||
selectReminderCourse(courseId) {
|
||||
const form = this.reminder
|
||||
const course = form.courses.find(item => item.id === courseId)
|
||||
if (!course) return
|
||||
this.$set(form, "courseId", courseId)
|
||||
this.$set(form, "count", null)
|
||||
if (!form.edited && form.deadline) {
|
||||
const deadline = this.$moment(form.deadline).format("YYYY年MM月DD日 HH:mm")
|
||||
this.$set(form, "content", "老师您好!诚邀您报名参加「" + course.courseName
|
||||
+ "」。报名截止时间为 " + deadline + ",请点击“查看详情”了解活动安排并报名。期待您的参与!")
|
||||
}
|
||||
// 快速切换子活动时,只接收最新一次查询结果。
|
||||
const request = {}
|
||||
this.$set(form, "countRequest", request)
|
||||
this.$set(form, "countLoading", true)
|
||||
this.$axios.post(loc() + "/reminderRecipientCount", { activityId: form.activityId, courseId: courseId })
|
||||
.then(res => {
|
||||
if (this.reminder !== form || form.countRequest !== request || !this.reminderVisible) return
|
||||
if (res.code === 0) {
|
||||
this.$set(form, "count", res.data)
|
||||
} else {
|
||||
this.$message.warning(res.msg)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (form.countRequest === request) this.$set(form, "countLoading", false)
|
||||
})
|
||||
},
|
||||
submitSignupReminder() {
|
||||
const form = this.reminder
|
||||
const course = form.courses.find(item => item.id === form.courseId)
|
||||
const content = form.content.trim()
|
||||
if (this.formLoading || form.countLoading || !course || !content || !(form.count > 0)) return
|
||||
this.$set(this.$data, "formLoading", true)
|
||||
this.$confirm("确定向「" + course.courseName + "」的未报名人员发送钉钉提醒吗?预计 " + form.count + " 人。",
|
||||
"发送确认", { type: "warning", confirmButtonText: "发送", cancelButtonText: "取消" })
|
||||
.then(() => this.$axios.post(loc() + "/submitSignupReminder", {
|
||||
activityId: form.activityId, courseId: course.id, content: content
|
||||
}))
|
||||
.then(res => {
|
||||
if (res.code === 0) {
|
||||
this.$message.success(res.msg)
|
||||
this.$set(this.$data, "reminderVisible", false)
|
||||
} else {
|
||||
this.$message.warning(res.msg)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => this.$set(this.$data, "formLoading", false))
|
||||
},
|
||||
back() {
|
||||
this.$refs.guava.index()
|
||||
},
|
||||
|
||||
@@ -11,6 +11,13 @@ const info = {
|
||||
<el-descriptions-item label="活动结束时间">{{viewData.activityEndTime}}</el-descriptions-item>
|
||||
<el-descriptions-item label="报名开始时间">{{viewData.activitySignUpStartTime}}</el-descriptions-item>
|
||||
<el-descriptions-item label="报名结束时间">{{viewData.activitySignUpEndTime}}</el-descriptions-item>
|
||||
<template v-if="viewData.signPointCount">
|
||||
<el-descriptions-item label="签到达标点位数">{{viewData.signPointCount}}</el-descriptions-item>
|
||||
<el-descriptions-item label="达标处理方式">{{signHandleTypeName}}</el-descriptions-item>
|
||||
<el-descriptions-item v-if="viewData.signHandleType === 2" :span="2" label="关联福利项目">
|
||||
{{welfareProjectName || '未找到关联项目'}}
|
||||
</el-descriptions-item>
|
||||
</template>
|
||||
</el-descriptions>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="trainType + '信息'">
|
||||
@@ -48,7 +55,7 @@ const info = {
|
||||
</span>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog :visible.sync="viewMapDialog" title="地点" width="60%">
|
||||
<el-dialog :visible.sync="viewMapDialog" @closed="destroyViewMap" title="地点" width="60%">
|
||||
<div id="viewMap" style="width: 100%; height: 500px"></div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
@@ -62,8 +69,17 @@ const info = {
|
||||
courseTimeList: [],
|
||||
|
||||
viewMapDialog: false,
|
||||
viewMap: null,
|
||||
viewMarkerLayer: null,
|
||||
welfareProjectName: '',
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
signHandleTypeName() {
|
||||
const names = {1: '系统自动弹出填写信息', 2: '系统自动选择礼物或福利', 3: '无'}
|
||||
return names[this.viewData.signHandleType] || '无'
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
initData(id) {
|
||||
this.$axios.post("/platform/trainSignUp/manage/findOne", { id: id })
|
||||
@@ -72,36 +88,66 @@ const info = {
|
||||
this.viewData = resp.data
|
||||
const type = this.dict.type.TRAIN_SIGNUP_TYPE.find((o) => o.code === resp.data.trainType)
|
||||
this.trainType = type?.name || "培训班"
|
||||
if (resp.data.signHandleType === 2) this.loadWelfareProjectName()
|
||||
}
|
||||
})
|
||||
},
|
||||
loadWelfareProjectName() {
|
||||
this.$axios.post("/platform/trainSignUp/manage/getWelfareProjects", {year: this.viewData.year})
|
||||
.then((resp) => {
|
||||
const project = resp.code === 0 && resp.data
|
||||
? resp.data.find((item) => item.id === this.viewData.welfareProjectId) : null
|
||||
this.welfareProjectName = project ? project.name : ''
|
||||
})
|
||||
},
|
||||
openViewCourseTime(courseTimeList) {
|
||||
this.courseTimeList = courseTimeList
|
||||
this.courseTimeListDialog = true
|
||||
},
|
||||
openViewMap(point) {
|
||||
if (!Array.isArray(point)) {
|
||||
// 地点坐标按项目现有约定传入 [纬度, 经度],缺失或异常时不打开空白地图。
|
||||
if (!Array.isArray(point) || point.length < 2) {
|
||||
this.$message.warning("没有设置点位,无法通过地图查看")
|
||||
return
|
||||
}
|
||||
const latitude = Number(point[0])
|
||||
const longitude = Number(point[1])
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||
this.$message.warning("地点坐标格式有误,无法通过地图查看")
|
||||
return
|
||||
}
|
||||
// 当前公共布局加载腾讯地图,脚本异常时给出提示,避免地图弹窗直接报错。
|
||||
if (typeof TMap === "undefined") {
|
||||
this.$message.error("地图加载失败,请刷新页面后重试")
|
||||
return
|
||||
}
|
||||
this.viewMapDialog = true
|
||||
this.$nextTick(() => {
|
||||
viewMap = new AMap.Map("viewMap", {
|
||||
resizeEnable: true,
|
||||
center: point,
|
||||
zoom: 16
|
||||
const center = new TMap.LatLng(latitude, longitude)
|
||||
this.viewMap = new TMap.Map("viewMap", {
|
||||
center: center,
|
||||
zoom: 16,
|
||||
})
|
||||
if (viewMarker) {
|
||||
viewMap.remove(viewMarker)
|
||||
}
|
||||
viewMarker = new AMap.Marker({
|
||||
position: point,
|
||||
offset: new AMap.Pixel(-13, -30)
|
||||
this.viewMarkerLayer = new TMap.MultiMarker({
|
||||
map: this.viewMap,
|
||||
geometries: [{
|
||||
id: "view-location",
|
||||
position: center,
|
||||
}],
|
||||
})
|
||||
viewMap.add(viewMarker)
|
||||
viewMap.setFitView(null, false, [150, 60, 100, 60])
|
||||
})
|
||||
},
|
||||
destroyViewMap() {
|
||||
// 弹窗关闭后释放地图覆盖物和实例,避免重复查看地点时残留旧地图资源。
|
||||
if (this.viewMarkerLayer && this.viewMarkerLayer.setMap) {
|
||||
this.viewMarkerLayer.setMap(null)
|
||||
}
|
||||
if (this.viewMap && this.viewMap.destroy) {
|
||||
this.viewMap.destroy()
|
||||
}
|
||||
this.viewMarkerLayer = null
|
||||
this.viewMap = null
|
||||
},
|
||||
},
|
||||
style: /*language=CSS*/ `
|
||||
/deep/ .el-button--text {
|
||||
|
||||
@@ -73,7 +73,7 @@ layout("/layouts/platform.html"){
|
||||
</el-button>
|
||||
<el-dropdown-menu slot="dropdown">
|
||||
<!-- <el-dropdown-item :command="{type:'status',data:row}">{{row.isDisabled?'发布':'关闭'}}</el-dropdown-item>-->
|
||||
<!-- <el-dropdown-item :command="{type:'sendMsg',data:row}">通知未选择人员</el-dropdown-item>-->
|
||||
<el-dropdown-item :command="{type:'sendMsg',data:row}">提醒未选择人员</el-dropdown-item>
|
||||
<el-dropdown-item :command="{type:'createList',data:row}">生成福利名单</el-dropdown-item>
|
||||
<el-dropdown-item :command="{type:'edit',data:row}">编辑</el-dropdown-item>
|
||||
<el-dropdown-item :command="{type:'delete',data:row}">删除</el-dropdown-item>
|
||||
@@ -234,6 +234,25 @@ layout("/layouts/platform.html"){
|
||||
</guava>
|
||||
|
||||
<filter-user ref="filterUserRef"></filter-user>
|
||||
<el-dialog title="提醒未选择人员"
|
||||
:visible.sync="reminderVisible"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="!formLoading"
|
||||
:show-close="!formLoading"
|
||||
width="520px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="提醒内容" required>
|
||||
<el-input type="textarea" :rows="6" :value="reminder.content" :disabled="formLoading"
|
||||
@input="$set(reminder, 'content', $event)" placeholder="请输入提醒内容"></el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template slot="footer">
|
||||
<el-button :disabled="formLoading" @click="$set($data, 'reminderVisible', false)">取消</el-button>
|
||||
<el-button type="primary" :loading="formLoading"
|
||||
:disabled="reminder.countLoading || !(reminder.count > 0) || !reminder.content.trim()"
|
||||
@click="submitSelectionReminder">发送提醒</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
|
||||
<script nonce="${cspNonce!}">
|
||||
@@ -255,6 +274,8 @@ layout("/layouts/platform.html"){
|
||||
festival: [],
|
||||
welfareProvideMode: [],
|
||||
welfareSignMode: [],
|
||||
reminderVisible: false,
|
||||
reminder: { name: "", content: "", count: null, countLoading: false },
|
||||
pageForm: {
|
||||
year: moment().format("YYYY")
|
||||
},
|
||||
@@ -364,7 +385,54 @@ layout("/layouts/platform.html"){
|
||||
this.formData.provideTimeEnd = ""
|
||||
}
|
||||
},
|
||||
sendMsg(row) {},
|
||||
// 打开弹窗只填充文案和查询人数,不提交钉钉提醒。
|
||||
sendMsg(row) {
|
||||
const deadline = row.choiceTimeEnd ? moment(row.choiceTimeEnd).format("YYYY年MM月DD日 HH:mm") : ""
|
||||
this.$set(this.$data, "reminder", {
|
||||
projectId: row.id,
|
||||
name: row.name,
|
||||
content: deadline ? "老师您好!诚邀您参加「" + row.name
|
||||
+ "」的福利选择。选择截止时间为 " + deadline
|
||||
+ ",请点击“查看详情”,选择您心仪的福利。感谢您的参与!" : "",
|
||||
count: null,
|
||||
countLoading: true
|
||||
})
|
||||
const form = this.reminder
|
||||
this.$set(this.$data, "reminderVisible", true)
|
||||
this.$axios.post(loc() + "/reminderRecipientCount", { projectId: form.projectId })
|
||||
.then(res => {
|
||||
// 忽略已关闭或已切换项目的查询结果。
|
||||
if (this.reminder !== form || !this.reminderVisible) return
|
||||
if (res.code === 0) {
|
||||
this.$set(form, "count", res.data)
|
||||
} else {
|
||||
this.$message.warning(res.msg)
|
||||
}
|
||||
})
|
||||
.finally(() => this.$set(form, "countLoading", false))
|
||||
},
|
||||
// 二次确认后才提交项目 ID 和正文,接收名单由后端重新计算。
|
||||
submitSelectionReminder() {
|
||||
const form = this.reminder
|
||||
const content = form.content.trim()
|
||||
if (this.formLoading || form.countLoading || !form.projectId || !content || !(form.count > 0)) return
|
||||
this.$set(this.$data, "formLoading", true)
|
||||
this.$confirm("确定向「" + form.name + "」的未选择人员发送钉钉提醒吗?预计 " + form.count + " 人。",
|
||||
"发送确认", { type: "warning", confirmButtonText: "发送", cancelButtonText: "取消" })
|
||||
.then(() => this.$axios.post(loc() + "/submitSelectionReminder", {
|
||||
projectId: form.projectId, content: content
|
||||
}))
|
||||
.then(res => {
|
||||
if (res.code === 0) {
|
||||
this.$message.success(res.msg)
|
||||
this.$set(this.$data, "reminderVisible", false)
|
||||
} else {
|
||||
this.$message.warning(res.msg)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => this.$set(this.$data, "formLoading", false))
|
||||
},
|
||||
openAdd() {
|
||||
this.formData = {
|
||||
isPushHome: false,
|
||||
|
||||
@@ -55,7 +55,10 @@ const welfareOption = {
|
||||
width="60%"
|
||||
:append-to-body="true"
|
||||
:close-on-click-modal="false">
|
||||
<text-editor v-model="descriptionDialog.content"></text-editor>
|
||||
<text-editor
|
||||
v-model="descriptionDialog.content"
|
||||
:office-image-import="true">
|
||||
</text-editor>
|
||||
<span slot="footer" class="dialog-footer">
|
||||
<el-button @click="descriptionDialog.visible = false">取 消</el-button>
|
||||
<el-button type="primary" @click="saveDescription">确 定</el-button>
|
||||
|
||||
@@ -75,7 +75,11 @@ const optionSelect = {
|
||||
class="option-tag" type="primary" effect="plain">已选择
|
||||
</el-tag>
|
||||
|
||||
<div class="option-image">
|
||||
<div class="option-image" role="button" :tabindex="option.imgUrl ? 0 : -1"
|
||||
aria-label="预览福利图片" :aria-disabled="option.imgUrl ? 'false' : 'true'"
|
||||
@click.stop="previewOptionImage(option.imgUrl)"
|
||||
@keydown.enter.stop.prevent="previewOptionImage(option.imgUrl)"
|
||||
@keydown.space.stop.prevent="previewOptionImage(option.imgUrl)">
|
||||
<el-image :src="option.imgUrl" fit="cover"></el-image>
|
||||
</div>
|
||||
|
||||
@@ -605,6 +609,15 @@ const optionSelect = {
|
||||
}
|
||||
},
|
||||
|
||||
previewOptionImage(url) {
|
||||
if (!url) return
|
||||
// 福利图片地址可能没有扩展名,直接预览原图,不触发福利选择。
|
||||
const image = new Image()
|
||||
image.src = url
|
||||
const viewer = new Viewer(image, {zIndex: 99999, title: false, hidden: () => viewer.destroy()})
|
||||
viewer.show()
|
||||
},
|
||||
|
||||
// 显示选项详情
|
||||
showOptionDetail(option) {
|
||||
// 阻止事件冒泡,避免触发父元素的点击事件
|
||||
@@ -886,6 +899,7 @@ const optionSelect = {
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--primary-color), #66b1ff);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 选项标签 */
|
||||
@@ -894,6 +908,7 @@ const optionSelect = {
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
@@ -918,6 +933,10 @@ const optionSelect = {
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.2), transparent);
|
||||
}
|
||||
|
||||
/deep/ .option-image[aria-disabled="false"] {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
/deep/ .option-image .el-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -10,13 +10,48 @@ layout("/layouts/platform_h5.html"){
|
||||
box-shadow: 0 8px 12px #ebedf0;
|
||||
text-align: center !important;
|
||||
}
|
||||
.info-container {
|
||||
margin-bottom: 60px;
|
||||
.activity-info-sheet {
|
||||
height: 90vh;
|
||||
height: 90dvh;
|
||||
max-height: 90vh;
|
||||
max-height: 90dvh;
|
||||
}
|
||||
.button {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
.activity-info-sheet .van-action-sheet__content {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.activity-info-sheet .info-container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
.activity-info-sheet .info-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
/* PDF内部使用百分比高度,必须提供明确的预览高度;外层正文仍可滚动查看封面和全文。 */
|
||||
.activity-info-sheet .info-preview {
|
||||
height: 60vh;
|
||||
height: 60dvh;
|
||||
min-height: 260px;
|
||||
}
|
||||
.activity-info-sheet .info-preview > div {
|
||||
height: 100%;
|
||||
}
|
||||
.activity-info-sheet .info-preview .content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.activity-info-sheet .button {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
background: #fff;
|
||||
}
|
||||
.info-img {
|
||||
display: block;
|
||||
@@ -27,7 +62,7 @@ layout("/layouts/platform_h5.html"){
|
||||
</style>
|
||||
|
||||
<div id="app">
|
||||
<van-nav-bar title="品牌活动" left-text="返回" left-arrow @click-left="historyBack" fixed placeholder></van-nav-bar>
|
||||
<van-nav-bar title="品牌活动" left-text="返回" left-arrow @click-left="onNavBack" fixed placeholder></van-nav-bar>
|
||||
|
||||
<van-sticky offset-top="46px">
|
||||
<van-dropdown-menu :close-on-click-outside="false" :close-on-click-overlay="false">
|
||||
@@ -67,13 +102,14 @@ layout("/layouts/platform_h5.html"){
|
||||
</template>
|
||||
</table-list>
|
||||
|
||||
<van-action-sheet :close-on-click-overlay="false" title="详细信息" v-model="infoVisible">
|
||||
<van-action-sheet class="activity-info-sheet" :close-on-click-overlay="false" title="详细信息"
|
||||
:value="infoVisible" @input="visible => { if (!visible) closeInfo() }">
|
||||
|
||||
<div class="info-container">
|
||||
<div>
|
||||
<van-image class="info-img" height="186" width="100%" :src="infoRow.cover"></van-image>
|
||||
<div class="info-body">
|
||||
<van-image class="info-img" height="124" width="100%" :src="infoRow.cover"></van-image>
|
||||
<div class="info-title">{{infoRow.activityName}}</div>
|
||||
<pdf-preview :content="infoRow.introduce"></pdf-preview>
|
||||
<div class="info-preview"><pdf-preview :content="infoRow.introduce"></pdf-preview></div>
|
||||
</div>
|
||||
<div class="button">
|
||||
<van-button @click="onApply(infoRow)" type="primary" block>
|
||||
@@ -82,7 +118,7 @@ layout("/layouts/platform_h5.html"){
|
||||
</span>
|
||||
<template v-else>
|
||||
距离开始
|
||||
<van-count-down :time="Math.abs(time)" class="count-down" format="DD 天 HH 时 mm 分 ss 秒" @finish="time = 0"></van-count-down>
|
||||
<van-count-down :time="Math.abs(time)" class="count-down" format="DD 天 HH 时 mm 分 ss 秒" @finish="this.$set(this, 'time', 0)"></van-count-down>
|
||||
</template>
|
||||
</van-button>
|
||||
</div>
|
||||
@@ -100,6 +136,9 @@ layout("/layouts/platform_h5.html"){
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
popupOwnerId: 'train-activity-info-' + Date.now(),
|
||||
infoClosing: false,
|
||||
pageDisposed: false,
|
||||
time: 0,
|
||||
pageForm: {
|
||||
pageNumber: 1,
|
||||
@@ -116,35 +155,45 @@ layout("/layouts/platform_h5.html"){
|
||||
],
|
||||
infoVisible: false,
|
||||
infoRow: {},
|
||||
|
||||
id: GetQueryString('id')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onView(row) {
|
||||
this.infoRow = row
|
||||
this.time = this.$moment().diff(this.$moment(row.activitySignUpStartTime), 'milliseconds')
|
||||
this.infoVisible = true
|
||||
if (this.pageDisposed || this.infoClosing) return
|
||||
// 详情只由列表中的“查看介绍”点击打开,不根据链接参数或报名操作自动打开。
|
||||
this.$set(this, 'infoRow', row)
|
||||
this.$set(this, 'time', this.$moment().diff(this.$moment(row.activitySignUpStartTime), 'milliseconds'))
|
||||
this.$set(this, 'infoVisible', true)
|
||||
// 重复查看仅更新内容,不重复压入历史;关闭和系统返回统一交给全局栈。
|
||||
window.mobilePopupHistory.open(this.popupOwnerId, 'info')
|
||||
},
|
||||
closeInfo() {
|
||||
if (!this.infoVisible || this.infoClosing) return
|
||||
// 回退为异步操作:立即隐藏并锁定本次关闭,防止连点产生多次历史回退。
|
||||
this.$set(this, 'infoClosing', true)
|
||||
this.$set(this, 'infoVisible', false)
|
||||
window.mobilePopupHistory.close(this.popupOwnerId, 'info')
|
||||
},
|
||||
onInfoHistoryClose() {
|
||||
// 系统返回和关闭按钮共用回调,关闭后允许用户再次主动查看。
|
||||
this.$set(this, 'infoVisible', false)
|
||||
this.$set(this, 'infoClosing', false)
|
||||
},
|
||||
onNavBack() {
|
||||
if (window.mobilePopupHistory.hasOpen(this.popupOwnerId)) this.closeInfo()
|
||||
else historyBack()
|
||||
},
|
||||
onApply(row) {
|
||||
if(this.$moment().isBefore(this.$moment(row.activitySignUpStartTime))) {
|
||||
this.onView(row)
|
||||
this.$toast('报名尚未开始')
|
||||
return
|
||||
}
|
||||
window.mobilePopupHistory.clear(this.popupOwnerId)
|
||||
this.$pjaxReplace('/platform/trainSignUp/apply/list/h5?id=' + row.id)
|
||||
},
|
||||
fetchOne() {
|
||||
this.$axios.post('/platform/trainSignUp/manage/findOne', {id: this.id}).then((res) => {
|
||||
if(res.code === 0) {
|
||||
this.onView(res.data)
|
||||
}
|
||||
})
|
||||
},
|
||||
onReady() {
|
||||
// 首次进入和组件重新就绪都只加载列表,不触发详情弹窗。
|
||||
this.doSearch()
|
||||
if(this.id) {
|
||||
this.fetchOne()
|
||||
}
|
||||
},
|
||||
doSearch() {
|
||||
this.$nextTick(() => {
|
||||
@@ -154,8 +203,12 @@ layout("/layouts/platform_h5.html"){
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
created() {
|
||||
window.mobilePopupHistory.register(this.popupOwnerId, () => this.onInfoHistoryClose())
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$set(this, 'pageDisposed', true)
|
||||
window.mobilePopupHistory.unregister(this.popupOwnerId)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,149 +1,863 @@
|
||||
<!--#
|
||||
layout("/layouts/platform_h5.html"){
|
||||
#-->
|
||||
|
||||
<style scoped>
|
||||
.weui-msg {
|
||||
min-height: calc(100vh - 40vh - 46px);
|
||||
<style>
|
||||
/* 沿用点位签到组件的纵向布局:地图伸展,信息和操作保留自身高度,小屏可滚动。 */
|
||||
#app.brand-train-sign-page {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
background: #f4f7fc;
|
||||
}
|
||||
|
||||
.brand-train-sign-page > .van-nav-bar__placeholder {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 仅本页沿用参考签到页的亮蓝主题,不改变其他业务页面的全局 Vant 配色。 */
|
||||
.brand-train-sign-page .van-nav-bar__text,
|
||||
.brand-train-sign-page .van-nav-bar .van-icon,
|
||||
.brand-train-sign-page .van-tag--primary {
|
||||
color: #1989fa;
|
||||
}
|
||||
|
||||
.brand-train-sign-page .van-button--info {
|
||||
border-color: #1989fa;
|
||||
background: #1989fa;
|
||||
}
|
||||
|
||||
.brand-train-sign-page .van-button--info.van-button--plain {
|
||||
background: #fff;
|
||||
color: #1989fa;
|
||||
}
|
||||
|
||||
.brand-train-sign-content {
|
||||
display: flex;
|
||||
flex: 1 0 auto;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.brand-train-sign-map {
|
||||
/* 固定 SDK 容器定位,防止地图控件溢出到信息区和底部按钮。 */
|
||||
position: relative !important;
|
||||
z-index: 0;
|
||||
flex: 1 0 240px;
|
||||
width: 100%;
|
||||
min-height: 240px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.brand-train-sign-details {
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.brand-train-sign-title {
|
||||
color: #263548;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.brand-train-sign-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.brand-train-sign-signed {
|
||||
color: #07c160;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.brand-train-sign-signed .van-icon {
|
||||
margin-right: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.brand-train-sign-location-tools {
|
||||
display: flex;
|
||||
margin-bottom: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.brand-train-sign-location-tools .van-button {
|
||||
flex-shrink: 0;
|
||||
height: 32px;
|
||||
padding: 0 2px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.brand-train-sign-location-status {
|
||||
color: #78869a;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.brand-train-sign-location-status.is-in-range {
|
||||
color: #263548;
|
||||
}
|
||||
|
||||
.brand-train-sign-location-status.is-in-range::before {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-right: 6px;
|
||||
border-radius: 50%;
|
||||
background: #07c160;
|
||||
content: '';
|
||||
}
|
||||
|
||||
.brand-train-sign-text {
|
||||
margin-top: 6px;
|
||||
color: #78869a;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.brand-train-sign-text .van-icon {
|
||||
margin-right: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.brand-train-sign-progress {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin-top: 14px;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
background: #f4f7fc;
|
||||
color: #78869a;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.brand-train-sign-progress-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.brand-train-sign-progress-heading {
|
||||
color: #263548;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.brand-train-sign-record-link {
|
||||
display: inline-flex;
|
||||
min-height: 32px;
|
||||
margin-left: auto;
|
||||
padding: 0;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #1989fa;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.brand-train-sign-count {
|
||||
margin: 0 3px;
|
||||
color: #1989fa;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.brand-train-sign-progress-track {
|
||||
/* 使用容器百分比宽度,避免旧组件缓存像素宽度后在屏幕尺寸变化时越出卡片。 */
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
margin-top: 10px;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: #dce3ee;
|
||||
}
|
||||
|
||||
.brand-train-sign-progress-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
border-radius: inherit;
|
||||
background: #1989fa;
|
||||
transition: width 0.25s ease;
|
||||
}
|
||||
|
||||
.brand-train-sign-progress-message {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.brand-train-sign-map-warning {
|
||||
color: #ee0a24;
|
||||
}
|
||||
|
||||
.brand-train-sign-footer {
|
||||
/* 状态留在信息区,底栏仅容纳操作;双按钮平分宽度,单按钮居中且不铺满屏幕。 */
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 12px 24px;
|
||||
padding-bottom: calc(12px + env(safe-area-inset-bottom));
|
||||
border-top: 1px solid #edf0f5;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.brand-train-sign-footer .van-button {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 40px;
|
||||
padding: 0 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.brand-train-sign-footer .van-button:only-child {
|
||||
flex: 0 1 180px;
|
||||
}
|
||||
|
||||
.brand-train-sign-points-popup {
|
||||
/* 从右侧滑入并覆盖整个视口;导航和底部提示保留高度,点位卡片区域独立滚动。 */
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: 100dvh;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.brand-train-sign-points-popup > .van-nav-bar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-train-sign-points-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.brand-train-sign-point {
|
||||
margin-bottom: 12px;
|
||||
padding: 16px;
|
||||
border: 1px solid #e9edf4;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.brand-train-sign-point:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.brand-train-sign-point .brand-train-sign-title {
|
||||
font-size: 18px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.brand-train-sign-point .brand-train-sign-text {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.brand-train-sign-point-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.brand-train-sign-point-header .brand-train-sign-title-row {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: 4px 8px;
|
||||
}
|
||||
|
||||
.brand-train-sign-point-status {
|
||||
flex-shrink: 0;
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
background: #fff7e9;
|
||||
color: #d98a13;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.brand-train-sign-point-status.is-signed {
|
||||
background: #e8fbf2;
|
||||
color: #07c160;
|
||||
}
|
||||
|
||||
.brand-train-sign-point-status .van-icon {
|
||||
margin-right: 4px;
|
||||
font-size: 14px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.brand-train-sign-point-time {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #edf0f5;
|
||||
color: #78869a;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.brand-train-sign-point-time-value {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: #3b4b60;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.brand-train-sign-points-note {
|
||||
flex-shrink: 0;
|
||||
padding: 12px 16px;
|
||||
padding-bottom: calc(12px + env(safe-area-inset-bottom));
|
||||
border-top: 1px solid #edf0f5;
|
||||
background: #fff;
|
||||
color: #78869a;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-popup { padding: 16px; overflow-y: auto; }
|
||||
.popup-footer { padding: 12px 16px calc(12px + env(safe-area-inset-bottom)); background: #fff; }
|
||||
</style>
|
||||
|
||||
<div id="app">
|
||||
<van-nav-bar title="活动签到" left-text="返回" left-arrow @click-left="historyBack" fixed placeholder></van-nav-bar>
|
||||
|
||||
<div id="mapContainer" style="width: 100%; height: 40vh"></div>
|
||||
<div class="weui-msg">
|
||||
<div class="weui-msg__icon-area">
|
||||
<i v-if="res?.code !== 0" class="weui-icon-warn weui-icon_msg"></i>
|
||||
<i v-if="res?.code === 0" class="weui-icon-success weui-icon_msg"></i>
|
||||
</div>
|
||||
<div class="weui-msg__text-area">
|
||||
<div class="weui-msg__title">温馨提醒</div>
|
||||
<div v-html="res?.msg"></div>
|
||||
</div>
|
||||
<div class="weui-msg__opr-area">
|
||||
<p class="weui-btn-area">
|
||||
<a v-if="res?.code !== 0" @click="res = {}; onSign()" class="weui-btn weui-btn_default">重新签到</a>
|
||||
<a v-if="res?.code === 0" class="weui-btn weui-btn_primary">您已签到</a>
|
||||
</p>
|
||||
<div id="app" class="brand-train-sign-page" v-cloak>
|
||||
<van-nav-bar title="活动签到" left-text="返回" left-arrow @click-left="onNavBack" fixed placeholder></van-nav-bar>
|
||||
<div class="brand-train-sign-content">
|
||||
<div v-if="tencentMapEnabled" id="mapContainer" class="brand-train-sign-map"></div>
|
||||
<section class="brand-train-sign-details">
|
||||
<div v-if="tencentMapEnabled" class="brand-train-sign-location-tools">
|
||||
<span class="brand-train-sign-location-status" role="status"
|
||||
:class="{'brand-train-sign-map-warning': locationFailed || distance !== null && !isInRange,
|
||||
'is-in-range': !locationFailed && !locating && isInRange}">{{locationMessage}}</span>
|
||||
<van-button size="small" plain type="info" icon="aim"
|
||||
:loading="locating || mapLoading" :disabled="signLoading || courseLoading"
|
||||
@click="refreshPosition">{{mapReady ? '重新定位' : '重新加载地图'}}</van-button>
|
||||
</div>
|
||||
<div class="brand-train-sign-title-row">
|
||||
<div class="brand-train-sign-title">{{signData.courseName || row.courseName || '活动签到'}}</div>
|
||||
<span v-if="currentPointSigned" class="brand-train-sign-signed" role="status">
|
||||
<van-icon name="checked"></van-icon>当前点位已签到
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="tencentMapEnabled" class="brand-train-sign-text">签到范围:{{signRadius}} 米</div>
|
||||
<div v-if="tencentMapEnabled && distance !== null" class="brand-train-sign-text">距签到地点约 {{Math.ceil(distance)}} 米</div>
|
||||
<div v-if="extensionEnabled" class="brand-train-sign-progress">
|
||||
<div class="brand-train-sign-progress-header">
|
||||
<span class="brand-train-sign-progress-heading">活动签到进度</span>
|
||||
<van-tag v-if="signData.isReachTarget" plain type="success">活动已达标</van-tag>
|
||||
<button type="button" class="brand-train-sign-record-link" @click="openPopup('points')">
|
||||
点位打卡情况<van-icon name="arrow"></van-icon>
|
||||
</button>
|
||||
</div>
|
||||
<div>已打卡 <strong class="brand-train-sign-count">{{signData.signCount || 0}}</strong> 个 / 达标需 {{signData.signPointCount || 0}} 个</div>
|
||||
<div class="brand-train-sign-progress-track" role="progressbar" aria-label="活动签到进度"
|
||||
aria-valuemin="0" aria-valuemax="100" :aria-valuenow="progressPercentage">
|
||||
<span class="brand-train-sign-progress-fill" :style="{width: progressPercentage + '%'}"></span>
|
||||
</div>
|
||||
<div class="brand-train-sign-progress-message" role="status"
|
||||
:class="{'brand-train-sign-map-warning': messageType === 'error'}">{{progressMessage}}</div>
|
||||
</div>
|
||||
<div v-else class="brand-train-sign-text">{{legacyResult.msg}}</div>
|
||||
</section>
|
||||
<div v-if="showSignButton || showDynamicFormButton || signData.needWelfareSelect" class="brand-train-sign-footer">
|
||||
<van-button v-if="showDynamicFormButton" :plain="showSignButton" type="info"
|
||||
:disabled="signLoading || formLoading" @click="openPopup('form')">填写详细信息</van-button>
|
||||
<van-button v-if="signData.needWelfareSelect" :plain="showSignButton" type="info"
|
||||
:disabled="signLoading || formLoading" @click="goWelfare">去选择福利</van-button>
|
||||
<van-button v-if="showSignButton" type="info" loading-text="签到中..."
|
||||
:loading="signLoading" :disabled="signDisabled" @click="onSign">确认签到</van-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<van-popup :value="pointPopup" class="brand-train-sign-points-popup" position="right"
|
||||
@input="visible => { if (!visible) closePopup('points') }" :close-on-click-overlay="false">
|
||||
<van-nav-bar title="点位打卡情况" left-text="返回" left-arrow @click-left="closePopup('points')"></van-nav-bar>
|
||||
<div ref="signPointsList" class="brand-train-sign-points-list">
|
||||
<van-empty v-if="!displaySignPoints.length" description="暂无点位信息"></van-empty>
|
||||
<div v-for="point in displaySignPoints" :key="point.courseId" class="brand-train-sign-point">
|
||||
<div class="brand-train-sign-point-header">
|
||||
<div class="brand-train-sign-title-row">
|
||||
<div class="brand-train-sign-title">{{point.courseName || '点位名称未填写'}}</div>
|
||||
<van-tag v-if="point.courseId === courseId" plain type="primary">当前点位</van-tag>
|
||||
</div>
|
||||
<span class="brand-train-sign-point-status" :class="{'is-signed': point.signed === true}">
|
||||
<van-icon :name="point.signed === true ? 'checked' : 'clock-o'"></van-icon>{{point.signed === true ? '已打卡' : '未打卡'}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="brand-train-sign-text"><van-icon name="location-o"></van-icon>地点:{{point.courseLocation || '未填写地点名称'}}</div>
|
||||
<div v-if="point.signed === true" class="brand-train-sign-point-time">
|
||||
签到时间
|
||||
<span class="brand-train-sign-point-time-value">{{formatAttendTime(point.attendTime)}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="brand-train-sign-points-note">
|
||||
{{signData.isReachTarget ? '活动已达标,仍可继续打卡其他点位' : '完成 ' + (signData.signPointCount || 0) + ' 个不同点位打卡即可达标'}}
|
||||
</div>
|
||||
</van-popup>
|
||||
<van-popup :value="formPopup" @input="visible => { if (!visible) closePopup('form') }" position="bottom" round :close-on-click-overlay="false" :style="{height: '78vh'}">
|
||||
<van-nav-bar title="填写签到信息" left-text="关闭" left-arrow @click-left="closePopup('form')"></van-nav-bar>
|
||||
<div class="form-popup"><train-sign-dynamic-form ref="dynamicForm" :dynamic-data="signData.dynamicColumnsData || []"></train-sign-dynamic-form></div>
|
||||
<div class="popup-footer"><van-button type="primary" block :loading="formLoading" :disabled="formLoading" @click="saveDynamicForm">保存</van-button></div>
|
||||
</van-popup>
|
||||
</div>
|
||||
|
||||
<script nonce="${cspNonce!}">
|
||||
// 腾讯实例与异步回调不进入Vue响应式系统,销毁时统一释放。
|
||||
const scanRuntime = {map: null, markers: null, circle: null, locator: null,
|
||||
destroyed: false, requestId: 0, cancelLocation: null, scripts: {},
|
||||
nonce: document.currentScript && document.currentScript.nonce}
|
||||
const vue = new Vue({
|
||||
el: "#app",
|
||||
store,
|
||||
components: {
|
||||
|
||||
},
|
||||
components: {"train-sign-dynamic-form": httpVueLoader("/components/module/trainSignUp/TrainSignDynamicForm.vue?v=" + new Date().getTime())},
|
||||
data() {
|
||||
return {
|
||||
courseId: GetQueryString('courseId'),
|
||||
markerLayer: null,
|
||||
map: null,
|
||||
res: {},
|
||||
ownerId: "train-sign-scan-" + Date.now(), courseId: GetQueryString("courseId"), row: {}, signData: {},
|
||||
extensionEnabled: false, legacyResult: {code: 1, msg: "正在获取签到信息"},
|
||||
tencentMapEnabled: ${activitySignTencentMapEnabled!true},
|
||||
statusMessage: "请获取定位后完成签到", messageType: "", signLoading: false, formLoading: false,
|
||||
pointPopup: false, formPopup: false,
|
||||
mapReady: false, mapLoading: false, locating: false, courseLoading: true,
|
||||
locationMessage: "正在加载签到地点", locationFailed: false, currentPosition: null, distance: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
currentPointSigned() {
|
||||
return this.extensionEnabled ? this.signData.currentPointSigned === true : this.legacyResult.code === 0
|
||||
},
|
||||
showSignButton() {
|
||||
return !this.courseLoading && !this.currentPointSigned
|
||||
},
|
||||
showDynamicFormButton() {
|
||||
return this.signData.isReachTarget === true && this.signData.needDynamicForm === true
|
||||
},
|
||||
isInRange() {
|
||||
return this.distance !== null && this.distance <= this.signRadius
|
||||
},
|
||||
// 达标与当前点位签到分别提示;错误优先,保留接口返回的处理失败原因。
|
||||
progressMessage() {
|
||||
if (this.messageType === "error") return this.statusMessage
|
||||
if (this.signData.isReachTarget && !this.currentPointSigned) return "已达标,可继续打卡其他点位。"
|
||||
if (this.signData.isReachTarget) return this.statusMessage
|
||||
return this.currentPointSigned ? "请继续完成其他点位签到。"
|
||||
: this.tencentMapEnabled ? "到达点位后,点击确认签到。" : "请点击确认签到。"
|
||||
},
|
||||
// 未打卡项优先,组内保留接口中的点位序号,不能改动原数组。
|
||||
displaySignPoints() {
|
||||
const points = Array.isArray(this.signData.signPoints) ? this.signData.signPoints : []
|
||||
return points.filter(point => point.signed !== true).concat(points.filter(point => point.signed === true))
|
||||
},
|
||||
// 本项目点位为GCJ-02[纬度,经度],不能使用参考工程的[经度,纬度]下标。
|
||||
sitePosition() {
|
||||
const p = this.row.courseLocationCoordinates
|
||||
return Array.isArray(p) && p.length === 2 && p.every(v => v !== null && v !== "" && Number.isFinite(Number(v)))
|
||||
&& Math.abs(Number(p[0])) <= 90 && Math.abs(Number(p[1])) <= 180
|
||||
? {lat: Number(p[0]), lng: Number(p[1])} : null
|
||||
},
|
||||
signRadius() {
|
||||
const r = this.row.radius == null ? 100 : Number(this.row.radius)
|
||||
return Number.isFinite(r) && r > 0 ? r : 0
|
||||
},
|
||||
signDisabled() {
|
||||
return this.courseLoading || this.mapLoading || this.locating || this.signLoading || !this.row.id
|
||||
|| this.tencentMapEnabled && (!this.mapReady || !this.currentPosition || this.distance === null
|
||||
|| this.distance > this.signRadius)
|
||||
|| this.extensionEnabled && this.signData.currentPointSigned === true
|
||||
},
|
||||
progressPercentage() {
|
||||
if (!this.signData.signPointCount) return 0
|
||||
return Math.min(100, Math.round((this.signData.signCount || 0) * 100 / this.signData.signPointCount))
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
/** attendTime为首次签到时间;返回可读时间,历史数据缺失时显示未记录。 */
|
||||
formatAttendTime(attendTime) {
|
||||
const time = attendTime ? this.$moment(attendTime) : null
|
||||
return time && time.isValid() ? time.format("YYYY-MM-DD HH:mm:ss") : "未记录"
|
||||
},
|
||||
/**
|
||||
* 复用布局中的腾讯地图Key;定位组件仅在扫码页按需加载。
|
||||
* 返回SDK就绪Promise,失败时移除脚本,允许重试并处理页面销毁。
|
||||
*/
|
||||
loadTencentSdk() {
|
||||
const mapScript = Array.from(document.scripts).find(item => item.src.indexOf("map.qq.com/api/gljs") >= 0)
|
||||
const key = mapScript ? new URL(mapScript.src).searchParams.get("key") : ""
|
||||
if (!key) return Promise.reject(new Error("未找到腾讯地图Key配置"))
|
||||
const load = (name, src, ready) => {
|
||||
if (ready()) return Promise.resolve()
|
||||
if (scanRuntime.scripts[name]) return scanRuntime.scripts[name].promise
|
||||
const state = {script: document.createElement("script"), promise: null, cancel: null}
|
||||
scanRuntime.scripts[name] = state
|
||||
state.promise = new Promise((resolve, reject) => {
|
||||
let finished = false
|
||||
let timer
|
||||
const finish = error => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
window.clearTimeout(timer)
|
||||
state.script.onload = null
|
||||
state.script.onerror = null
|
||||
state.cancel = null
|
||||
if (error) {
|
||||
state.script.remove()
|
||||
delete scanRuntime.scripts[name]
|
||||
reject(error)
|
||||
} else resolve()
|
||||
}
|
||||
state.cancel = () => finish(new Error("页面已关闭"))
|
||||
state.script.src = src
|
||||
state.script.async = true
|
||||
if (scanRuntime.nonce) state.script.nonce = scanRuntime.nonce
|
||||
state.script.onload = () => finish(ready() ? null : new Error("腾讯定位组件未正确加载"))
|
||||
state.script.onerror = () => finish(new Error("腾讯地图服务加载失败,请检查网络"))
|
||||
timer = window.setTimeout(() => finish(new Error("腾讯地图服务加载超时")), 15000)
|
||||
document.head.appendChild(state.script)
|
||||
})
|
||||
return state.promise
|
||||
}
|
||||
return Promise.all([
|
||||
load("map", mapScript.src, () => window.TMap && TMap.Map && TMap.MultiMarker && TMap.LatLngBounds),
|
||||
load("location", "https://mapapi.qq.com/web/lbs/h5-components/geolocation/geolocation.min.js",
|
||||
() => window.LBS && LBS.WebComponent && LBS.WebComponent.Geolocation)
|
||||
]).then(() => key)
|
||||
},
|
||||
/** 地图标记使用静态文字区分地点与本人,below决定标签显示在坐标下方或上方。 */
|
||||
markerStyle(label, color, below) {
|
||||
const y = below ? 12 : 36
|
||||
const top = below ? 25 : 1
|
||||
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="80" height="50">'
|
||||
+ '<rect x="2" y="' + top + '" width="76" height="22" rx="8" fill="' + color + '"/>'
|
||||
+ '<text x="40" y="' + (top + 15) + '" text-anchor="middle" font-family="sans-serif" font-size="12" fill="white">'
|
||||
+ label + '</text><circle cx="40" cy="' + y + '" r="6" fill="' + color + '" stroke="white" stroke-width="2"/></svg>'
|
||||
return new TMap.MarkerStyle({width: 80, height: 50, anchor: {x: 40, y: y},
|
||||
src: "data:image/svg+xml;charset=UTF-8," + encodeURIComponent(svg)})
|
||||
},
|
||||
/** 只初始化地图及定位预览,不提交签到。SDK失败可由重新加载按钮重试。 */
|
||||
initMap() {
|
||||
if(!this.row.courseLocationCoordinates) {
|
||||
this.$toast('未设置签到点')
|
||||
if (!this.tencentMapEnabled || scanRuntime.destroyed || this.mapLoading) return Promise.resolve()
|
||||
if (this.mapReady) return this.refreshPosition()
|
||||
if (!this.sitePosition || !this.signRadius) {
|
||||
this.$set(this, "locationFailed", true)
|
||||
this.$set(this, "locationMessage", "当前点位未设置有效坐标或签到半径,请联系管理员")
|
||||
return Promise.resolve()
|
||||
}
|
||||
this.$set(this, "mapLoading", true)
|
||||
return this.loadTencentSdk().then(key => new Promise(resolve => this.$nextTick(() => resolve(key))))
|
||||
.then(key => {
|
||||
if (scanRuntime.destroyed) return
|
||||
const p = this.sitePosition
|
||||
const center = new TMap.LatLng(p.lat, p.lng)
|
||||
scanRuntime.map = new TMap.Map("mapContainer", {zoom: 16, center: center})
|
||||
scanRuntime.markers = new TMap.MultiMarker({
|
||||
map: scanRuntime.map,
|
||||
styles: {target: this.markerStyle("签到地点", "#ee0a24", false),
|
||||
targetBelow: this.markerStyle("签到地点", "#ee0a24", true),
|
||||
current: this.markerStyle("我的位置", "#1989fa", true),
|
||||
currentAbove: this.markerStyle("我的位置", "#1989fa", false)},
|
||||
geometries: [{id: "target", styleId: "target", position: center}]
|
||||
})
|
||||
scanRuntime.circle = new TMap.MultiCircle({map: scanRuntime.map,
|
||||
geometries: [{id: "range", center: center, radius: this.signRadius}]})
|
||||
// 腾讯组件仍负责真实定位,两步 WebService 请求经业务代理统一排队,避免直连突破共享额度。
|
||||
scanRuntime.locator = new LBS.WebComponent.Geolocation({key: key, referer: "zhgh_zjipc-trainSignUp",
|
||||
domain: window.location.origin + "/platform/TMap"})
|
||||
this.$set(this, "mapReady", true)
|
||||
return this.refreshPosition()
|
||||
}).catch(error => {
|
||||
if (scanRuntime.destroyed) return
|
||||
this.destroyMap()
|
||||
this.$set(this, "locationFailed", true)
|
||||
this.$set(this, "locationMessage", error.message || "地图加载失败,请重新加载")
|
||||
}).finally(() => {
|
||||
if (!scanRuntime.destroyed) this.$set(this, "mapLoading", false)
|
||||
})
|
||||
},
|
||||
/** lat/lng均为GCJ-02,本方法返回到签到地点的米数,与后端采用相同地球半径。 */
|
||||
distanceFromSite(coords) {
|
||||
const site = this.sitePosition
|
||||
const rad = value => value * Math.PI / 180
|
||||
const dlat = rad(coords.lat - site.lat)
|
||||
const dlng = rad(coords.lng - site.lng)
|
||||
const a = Math.sin(dlat / 2) ** 2 + Math.cos(rad(site.lat)) * Math.cos(rad(coords.lat)) * Math.sin(dlng / 2) ** 2
|
||||
return 6371000 * 2 * Math.atan2(Math.sqrt(Math.min(1, a)), Math.sqrt(Math.max(0, 1 - a)))
|
||||
},
|
||||
/** coords为GCJ-02 {lat,lng};更新两处标记并适配视野,不改变任何签到记录。 */
|
||||
fitMap(coords) {
|
||||
const p = this.sitePosition
|
||||
// 北侧点的标签向上,南侧点向下,避免近距离签到时两个名称互相遮挡。
|
||||
const currentAbove = coords.lat > p.lat
|
||||
scanRuntime.markers.remove(["target", "current"])
|
||||
scanRuntime.markers.add([
|
||||
{id: "target", styleId: currentAbove ? "targetBelow" : "target", position: new TMap.LatLng(p.lat, p.lng)},
|
||||
{id: "current", styleId: currentAbove ? "currentAbove" : "current", position: new TMap.LatLng(coords.lat, coords.lng)}
|
||||
])
|
||||
// 两点较远时也保留边距,避免本人位置和签到地点标签被地图边缘裁切。
|
||||
const latPad = Math.max(this.signRadius / 111320, Math.abs(p.lat - coords.lat) * 0.2, 0.0003)
|
||||
const lngPad = Math.max(latPad / Math.max(Math.cos(p.lat * Math.PI / 180), 0.000001),
|
||||
Math.abs(p.lng - coords.lng) * 0.2)
|
||||
scanRuntime.map.fitBounds(new TMap.LatLngBounds(
|
||||
new TMap.LatLng(Math.max(-90, Math.min(p.lat, coords.lat) - latPad), Math.max(-180, Math.min(p.lng, coords.lng) - lngPad)),
|
||||
new TMap.LatLng(Math.min(90, Math.max(p.lat, coords.lat) + latPad), Math.min(180, Math.max(p.lng, coords.lng) + lngPad))))
|
||||
},
|
||||
/**
|
||||
* 返回本次GCJ-02 {lat,lng};严格高精度模式不允许IP兜底,失败清除旧位置。
|
||||
* 超时、权限、额度及坐标转换错误给出提示;晚到回调和离页回调不更新页面。
|
||||
*/
|
||||
getUserLocation() {
|
||||
if (!this.mapReady || !scanRuntime.locator) return Promise.reject(new Error("定位组件尚未就绪"))
|
||||
if (this.locating) return Promise.reject(new Error("正在定位,请稍候"))
|
||||
this.$set(this, "locating", true)
|
||||
this.$set(this, "locationFailed", false)
|
||||
this.$set(this, "currentPosition", null)
|
||||
this.$set(this, "distance", null)
|
||||
this.$set(this, "locationMessage", "正在获取您的当前位置")
|
||||
scanRuntime.markers.remove(["current"])
|
||||
const requestId = ++scanRuntime.requestId
|
||||
const locationStarted = performance.now()
|
||||
return new Promise((resolve, reject) => {
|
||||
let completed = false
|
||||
let timer
|
||||
const finish = (error, result) => {
|
||||
if (completed) return
|
||||
completed = true
|
||||
window.clearTimeout(timer)
|
||||
scanRuntime.cancelLocation = null
|
||||
if (scanRuntime.destroyed || requestId !== scanRuntime.requestId) {
|
||||
reject(new Error("页面已关闭")); return
|
||||
}
|
||||
if (error) { reject(error); return }
|
||||
try {
|
||||
if (!result || result.type === "ip" || !Number.isFinite(result.lat) || !Number.isFinite(result.lng)
|
||||
|| Math.abs(result.lat) > 90 || Math.abs(result.lng) > 180) {
|
||||
throw new Error("未获得有效的高精度位置,请重新定位")
|
||||
}
|
||||
const coords = {lat: result.lat, lng: result.lng}
|
||||
const distance = this.distanceFromSite(coords)
|
||||
if (!Number.isFinite(distance)) throw new Error("距离计算失败,请重新定位")
|
||||
this.$set(this, "currentPosition", coords)
|
||||
this.$set(this, "distance", distance)
|
||||
this.$set(this, "locationMessage", distance <= this.signRadius ? "已进入签到范围" : "尚未进入签到范围")
|
||||
this.fitMap(coords)
|
||||
// 定位阶段包含设备取位、坐标转换和逆地址解析,不记录本人经纬度。
|
||||
console.info("签到阶段耗时", {stage: "location", requestId: requestId,
|
||||
elapsedMs: Math.round(performance.now() - locationStarted), success: true,
|
||||
tencentRequestId: result.gcoder_request_id})
|
||||
resolve(coords)
|
||||
} catch (err) { reject(err) }
|
||||
}
|
||||
scanRuntime.cancelLocation = () => finish(new Error("页面已关闭"))
|
||||
if (window.isSecureContext === false) {
|
||||
finish(new Error("当前页面不是安全环境,请使用HTTPS地址定位")); return
|
||||
}
|
||||
// 为共享额度排队预留时间;外层兜底晚于 SDK 截止,保留其具体失败原因。
|
||||
timer = window.setTimeout(() => finish(new Error("定位超时,请检查网络和定位权限后重试")), 30000)
|
||||
try {
|
||||
scanRuntime.locator.getLocation(result => finish(null, result), detail => {
|
||||
const status = Number(detail && detail.status)
|
||||
const message = status === 10101 ? "未获得定位权限,请在手机设置中允许定位"
|
||||
: status === 10002 || status === 10103 ? "定位超时,请重新定位"
|
||||
: "腾讯定位失败,请检查权限、网络及地图服务配置后重试"
|
||||
// 只记录错误诊断,不记录本人经纬度。
|
||||
console.warn("腾讯签到定位失败", {status: status, message: detail && detail.message, logs: detail && detail.logs})
|
||||
finish(new Error(message))
|
||||
}, {timeout: 25000, highAccuracy: true, maximumAge: 0})
|
||||
} catch (error) { finish(new Error("腾讯定位调用失败,请重新加载地图")) }
|
||||
}).catch(error => {
|
||||
console.warn("签到阶段耗时", {stage: "location", requestId: requestId,
|
||||
elapsedMs: Math.round(performance.now() - locationStarted), success: false})
|
||||
if (!scanRuntime.destroyed) {
|
||||
this.$set(this, "locationFailed", true)
|
||||
this.$set(this, "locationMessage", error.message)
|
||||
}
|
||||
throw error
|
||||
}).finally(() => {
|
||||
if (!scanRuntime.destroyed) this.$set(this, "locating", false)
|
||||
})
|
||||
},
|
||||
/** 重新定位仅更新当前位置和距离;地图尚未就绪时重新加载SDK。 */
|
||||
refreshPosition() {
|
||||
if (!this.tencentMapEnabled || scanRuntime.destroyed || this.locating || this.signLoading) return Promise.resolve()
|
||||
if (!this.mapReady) return this.initMap()
|
||||
return this.getUserLocation().catch(() => {})
|
||||
},
|
||||
/** 释放WebGL地图及相关图层,失败重试或离页均调用,不触碰业务数据。 */
|
||||
destroyMap() {
|
||||
if (scanRuntime.map) scanRuntime.map.destroy()
|
||||
scanRuntime.map = null
|
||||
scanRuntime.markers = null
|
||||
scanRuntime.circle = null
|
||||
scanRuntime.locator = null
|
||||
if (!scanRuntime.destroyed) this.$set(this, "mapReady", false)
|
||||
},
|
||||
loadCourse() {
|
||||
if (!this.courseId) {
|
||||
this.$set(this, "courseLoading", false)
|
||||
this.$set(this, "locationMessage", "未获取到签到点位,请重新扫描二维码")
|
||||
return
|
||||
}
|
||||
const posi = this.row.courseLocationCoordinates
|
||||
const center = new TMap.LatLng(posi[0], posi[1])
|
||||
this.map = new TMap.Map('mapContainer', {
|
||||
resizeEnable: true,
|
||||
zoom: 16,
|
||||
center: center
|
||||
})
|
||||
this.markerLayer = new TMap.MultiMarker({
|
||||
map: this.map,
|
||||
styles: {
|
||||
"current": new TMap.MarkerStyle({
|
||||
"src": "https://mapapi.qq.com/web/bundles/lbs-home/prod/assets/markerActive-NhH9NoBV.png",
|
||||
"width": 40, // 点标记样式宽度(像素)
|
||||
})
|
||||
},
|
||||
geometries: []
|
||||
})
|
||||
this.createMarker(center)
|
||||
this.createCircle(center)
|
||||
},
|
||||
createMarker(position, styleId = 'marker', id = 'marker_' + Date.now()) {
|
||||
this.markerLayer.add([
|
||||
{
|
||||
id: id,
|
||||
position: position,
|
||||
styleId: styleId
|
||||
}
|
||||
]);
|
||||
},
|
||||
createCircle(position) {
|
||||
this.circle = new TMap.MultiCircle({
|
||||
map: this.map,
|
||||
geometries: [{
|
||||
center: position,
|
||||
radius: this.row.radius || 100,
|
||||
}],
|
||||
});
|
||||
},
|
||||
getUserLocation() {
|
||||
return new Promise((resolve) => {
|
||||
if (!navigator.geolocation) {
|
||||
vant.Toast('浏览器不支持定位');
|
||||
return resolve(null);
|
||||
}
|
||||
const loading = vant.Toast.loading({ message: '定位中...', duration: 0 });
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
loading.close();
|
||||
resolve({
|
||||
lat: pos.coords.latitude,
|
||||
lng: pos.coords.longitude
|
||||
});
|
||||
if (this.markerLayer) {
|
||||
this.markerLayer.remove(["current"])
|
||||
}
|
||||
const transPosi = this.$coordinateUtil.wgs84ToGcj02(pos.coords.longitude, pos.coords.latitude)
|
||||
console.log(transPosi)
|
||||
const center = new TMap.LatLng(transPosi[0], transPosi[1])
|
||||
this.createMarker(center, 'current', 'current')
|
||||
},
|
||||
(err) => {
|
||||
console.log(err)
|
||||
loading.close();
|
||||
vant.Toast('定位失败,请重试');
|
||||
resolve(null);
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 3000 }
|
||||
);
|
||||
});
|
||||
},
|
||||
async fetchCourse() {
|
||||
const res = await this.$axios.post('/platform/trainSignUp/mine/fetchCourse', {courseId: this.courseId})
|
||||
this.row = res.data
|
||||
},
|
||||
async onSign() {
|
||||
const coords = await this.getUserLocation()
|
||||
if (coords) {
|
||||
this.res = await this.$axios.post('/platform/trainSignUp/mine/drivingScan', {
|
||||
courseId: this.row.id,
|
||||
lat: coords.lat,
|
||||
lng: coords.lng
|
||||
return this.$axios.post("/platform/trainSignUp/mine/fetchCourse", {courseId: this.courseId})
|
||||
.then(resp => {
|
||||
if (scanRuntime.destroyed) return
|
||||
if (resp.code !== 0) {
|
||||
this.$set(this, "legacyResult", resp)
|
||||
this.$set(this, "locationMessage", resp.msg)
|
||||
return
|
||||
}
|
||||
this.$set(this, "row", resp.data)
|
||||
this.$set(this, "tencentMapEnabled", resp.data.tencentMapEnabled !== false)
|
||||
this.$set(this, "extensionEnabled", resp.data.signExtensionEnabled === true)
|
||||
// 普通活动同样只预览,禁止进入页面就自动写入签到。
|
||||
if (this.extensionEnabled) return this.loadSignData()
|
||||
this.$set(this, "legacyResult", {code: 1, msg: this.tencentMapEnabled
|
||||
? "到达点位后,请点击确认签到" : "请点击确认签到"})
|
||||
return this.initMap()
|
||||
}).catch(() => {
|
||||
if (!scanRuntime.destroyed) this.$set(this, "locationMessage", "获取签到信息失败,请重新进入页面")
|
||||
}).finally(() => {
|
||||
if (!scanRuntime.destroyed) this.$set(this, "courseLoading", false)
|
||||
})
|
||||
},
|
||||
loadSignData() {
|
||||
return this.$axios.post("/platform/trainSignUp/mine/getSignData", {courseId: this.courseId}).then((resp) => {
|
||||
if (scanRuntime.destroyed) return
|
||||
if (resp.code !== 0) {
|
||||
this.$set(this, "statusMessage", resp.msg)
|
||||
this.$set(this, "messageType", "error")
|
||||
return
|
||||
}
|
||||
this.$set(this, "signData", resp.data)
|
||||
this.$set(this, "statusMessage", resp.data.handleMsg || "请完成当前点位签到")
|
||||
this.$set(this, "messageType", resp.data.isReachTarget ? "success" : "")
|
||||
return this.initMap()
|
||||
})
|
||||
},
|
||||
onSign() {
|
||||
if (this.signDisabled || scanRuntime.destroyed) return
|
||||
this.$set(this, "signLoading", true)
|
||||
// 开启定位时重新取位;关闭时只提交课次,由后端按同一系统开关处理签到。
|
||||
const location = this.tencentMapEnabled ? this.getUserLocation() : Promise.resolve(null)
|
||||
return location.then((coords) => {
|
||||
if (scanRuntime.destroyed) return null
|
||||
if (coords && this.distance > this.signRadius) throw new Error("尚未进入签到范围,请到达地点后重试")
|
||||
const signStarted = performance.now()
|
||||
const requestId = scanRuntime.requestId
|
||||
const payload = {courseId: this.courseId}
|
||||
if (coords) { payload.lat = coords.lat; payload.lng = coords.lng }
|
||||
return this.$axios.post("/platform/trainSignUp/mine/drivingScan", payload)
|
||||
.then(resp => {
|
||||
console.info("签到阶段耗时", {stage: "sign", requestId: requestId,
|
||||
elapsedMs: Math.round(performance.now() - signStarted), code: resp.code})
|
||||
return resp
|
||||
}, error => {
|
||||
console.warn("签到阶段耗时", {stage: "sign", requestId: requestId,
|
||||
elapsedMs: Math.round(performance.now() - signStarted), success: false})
|
||||
throw error
|
||||
})
|
||||
}).then((resp) => {
|
||||
if (!resp || scanRuntime.destroyed) return
|
||||
if (!this.extensionEnabled) { this.$set(this, "legacyResult", resp); return }
|
||||
if (resp.code !== 0) {
|
||||
this.$set(this, "statusMessage", resp.msg)
|
||||
this.$set(this, "messageType", "error")
|
||||
return
|
||||
}
|
||||
this.$set(this, "signData", resp.data)
|
||||
this.$set(this, "statusMessage", resp.data.message || resp.data.handleMsg || resp.msg)
|
||||
this.$set(this, "messageType", "success")
|
||||
if (resp.data.needDynamicForm) this.openPopup("form")
|
||||
}).catch(error => {
|
||||
if (!scanRuntime.destroyed) this.$toast(error.msg || error.message || "签到失败,请重试")
|
||||
}).finally(() => {
|
||||
if (!scanRuntime.destroyed) this.$set(this, "signLoading", false)
|
||||
})
|
||||
},
|
||||
openPopup(name) {
|
||||
if (name === "points") this.$set(this, "pointPopup", true)
|
||||
if (name === "form") this.$set(this, "formPopup", true)
|
||||
window.mobilePopupHistory.open(this.ownerId, name)
|
||||
if (name === "points") {
|
||||
this.$nextTick(() => { if (this.$refs.signPointsList) this.$refs.signPointsList.scrollTop = 0 })
|
||||
}
|
||||
},
|
||||
closePopup(name) { window.mobilePopupHistory.close(this.ownerId, name) },
|
||||
closePopupFromHistory(name) {
|
||||
if (name === "points") this.$set(this, "pointPopup", false)
|
||||
if (name === "form") this.$set(this, "formPopup", false)
|
||||
},
|
||||
onNavBack() {
|
||||
if (window.mobilePopupHistory.hasOpen(this.ownerId)) window.history.back()
|
||||
else historyBack()
|
||||
},
|
||||
saveDynamicForm() {
|
||||
if (this.formLoading) return
|
||||
this.$refs.dynamicForm.validate().then(() => {
|
||||
this.$set(this, "formLoading", true)
|
||||
return this.$axios.post("/platform/trainSignUp/mine/saveDynamicForm", {
|
||||
courseId: this.courseId, mobileColumnsValue: JSON.stringify(this.signData.dynamicColumnsData || [])
|
||||
}).then((resp) => {
|
||||
if (resp.code === 0) { this.$toast.success(resp.msg || "保存成功"); this.closePopup("form"); this.loadSignData() }
|
||||
else this.$toast.fail(resp.msg)
|
||||
}).finally(() => { if (!scanRuntime.destroyed) this.$set(this, "formLoading", false) })
|
||||
}).catch(() => {})
|
||||
},
|
||||
goWelfare() {
|
||||
window.mobilePopupHistory.clear(this.ownerId)
|
||||
window.location.href = this.signData.welfareJumpUrl
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
await this.fetchCourse()
|
||||
this.initMap()
|
||||
this.onSign()
|
||||
created() {
|
||||
window.mobilePopupHistory.register(this.ownerId, (name) => this.closePopupFromHistory(name))
|
||||
this.loadCourse()
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.mobilePopupHistory.unregister(this.ownerId)
|
||||
scanRuntime.destroyed = true
|
||||
scanRuntime.requestId += 1
|
||||
if (scanRuntime.cancelLocation) scanRuntime.cancelLocation()
|
||||
Object.values(scanRuntime.scripts).forEach(state => { if (state.cancel) state.cancel() })
|
||||
this.destroyMap()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<!--#
|
||||
}
|
||||
#-->
|
||||
|
||||
@@ -255,24 +255,102 @@ layout("/layouts/platform_h5.html"){
|
||||
}
|
||||
|
||||
.welfare-detail-popup {
|
||||
padding: 20px;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
min-height: 300px;
|
||||
height: 82vh;
|
||||
padding: 16px 12px 12px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.welfare-detail-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 15px;
|
||||
margin: 0 40px 10px;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.welfare-detail-toolbar {
|
||||
height: 36px;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.welfare-detail-toolbar .van-button {
|
||||
width: 36px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.welfare-detail-scale {
|
||||
width: 58px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.welfare-detail-viewport {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border-color);
|
||||
background: #fff;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
|
||||
.welfare-detail-stage {
|
||||
position: relative;
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.welfare-detail-content {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
min-width: 100%;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
.welfare-detail-content table {
|
||||
width: max-content !important;
|
||||
min-width: 860px !important;
|
||||
table-layout: auto !important;
|
||||
}
|
||||
|
||||
.welfare-detail-content td,
|
||||
.welfare-detail-content th {
|
||||
min-width: 70px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.welfare-detail-content td img {
|
||||
width: 140px !important;
|
||||
max-width: 180px !important;
|
||||
max-height: 220px !important;
|
||||
height: auto !important;
|
||||
object-fit: contain;
|
||||
margin: 6px auto !important;
|
||||
}
|
||||
|
||||
.welfare-detail-content > img,
|
||||
.welfare-detail-content > p > img,
|
||||
.welfare-detail-content > div > p > img {
|
||||
width: auto;
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.welfare-tag {
|
||||
@@ -545,7 +623,7 @@ layout("/layouts/platform_h5.html"){
|
||||
|
||||
<div id="app" v-cloak>
|
||||
<div class="page-container">
|
||||
<van-nav-bar title="福利选择" left-text="返回" left-arrow @click-left="historyBack" fixed
|
||||
<van-nav-bar title="福利选择" left-text="返回" left-arrow @click-left="handlePageBack" fixed
|
||||
placeholder></van-nav-bar>
|
||||
|
||||
<!-- 项目头部信息 -->
|
||||
@@ -629,9 +707,8 @@ layout("/layouts/platform_h5.html"){
|
||||
:key="option.id"
|
||||
class="welfare-option"
|
||||
:class="{ selected: projectInfo.isCheckBox === 'radio' ? selectedRadioId === option.id : option.selectNum > 0 }"
|
||||
@click="projectInfo.isCheckBox === 'radio' && !isDeadlinePassed ? selectRadioOption(option.id) : null"
|
||||
>
|
||||
<div class="welfare-option-image">
|
||||
<div class="welfare-option-image" @click.stop="previewOptionImage(option.imgUrl)">
|
||||
<van-image :src="option.imgUrl" fit="cover" width="100%" height="100%" radius="4px"></van-image>
|
||||
<div class="welfare-tag"
|
||||
v-if="projectInfo.isCheckBox === 'radio' ? selectedRadioId === option.id : option.selectNum > 0">
|
||||
@@ -639,7 +716,8 @@ layout("/layouts/platform_h5.html"){
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="welfare-option-content">
|
||||
<div class="welfare-option-content"
|
||||
@click="projectInfo.isCheckBox === 'radio' && !isDeadlinePassed ? selectRadioOption(option.id) : null">
|
||||
<div class="welfare-option-title">{{ option.optionName }}</div>
|
||||
|
||||
<!-- 查看详情按钮 -->
|
||||
@@ -768,12 +846,41 @@ layout("/layouts/platform_h5.html"){
|
||||
</div>
|
||||
</van-action-sheet>
|
||||
|
||||
<van-image-preview :value="showOptionImagePreview" :images="optionPreviewImages"
|
||||
closeable :show-index="false" :close-on-popstate="false"
|
||||
@input="visible => { if (!visible) closeOptionImagePreview() }"></van-image-preview>
|
||||
|
||||
<!-- 选项详情弹窗 -->
|
||||
<van-popup v-model="showOptionDetailDialog" round closeable close-icon="close" position="bottom"
|
||||
:style="{ maxHeight: '70%' }">
|
||||
:close-on-click-overlay="false"
|
||||
@click-close-icon="closeOptionDetail"
|
||||
@closed="resetDetailZoom"
|
||||
:style="{ height: '82vh' }">
|
||||
<div class="welfare-detail-popup" v-if="selectedOption">
|
||||
<div class="welfare-detail-title">{{ selectedOption.optionName }}</div>
|
||||
<div class="welfare-detail-content" v-html="selectedOption.description"></div>
|
||||
<div class="welfare-detail-toolbar">
|
||||
<van-button plain round size="small" icon="minus" title="缩小"
|
||||
:disabled="detailScale <= detailMinScale"
|
||||
@click="changeDetailScale(-0.2)"></van-button>
|
||||
<span class="welfare-detail-scale">{{Math.round(detailScale * 100)}}%</span>
|
||||
<van-button plain round size="small" icon="plus" title="放大"
|
||||
:disabled="detailScale >= detailMaxScale"
|
||||
@click="changeDetailScale(0.2)"></van-button>
|
||||
<van-button plain round size="small" icon="replay" title="恢复原始大小"
|
||||
@click="resetDetailZoom"></van-button>
|
||||
</div>
|
||||
<div class="welfare-detail-viewport"
|
||||
ref="detailViewport"
|
||||
@touchstart="onDetailTouchStart"
|
||||
@touchmove="onDetailTouchMove"
|
||||
@touchend="onDetailTouchEnd">
|
||||
<div class="welfare-detail-stage" :style="detailStageStyle">
|
||||
<div class="welfare-detail-content"
|
||||
ref="detailContent"
|
||||
:style="detailContentStyle"
|
||||
v-html="selectedOption.description"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
|
||||
@@ -829,7 +936,17 @@ layout("/layouts/platform_h5.html"){
|
||||
isSubmitting: false,
|
||||
hasSubmittedBefore: false, // 是否之前提交过
|
||||
showOptionDetailDialog: false, // 选项详情弹窗
|
||||
showOptionImagePreview: false,
|
||||
optionPreviewImages: [],
|
||||
selectedOption: null, // 当前选中的选项
|
||||
popupHistoryOwner: "welfare-select-" + Date.now(),
|
||||
detailScale: 1,
|
||||
detailMinScale: 0.5,
|
||||
detailMaxScale: 3,
|
||||
detailContentWidth: 0,
|
||||
detailContentHeight: 0,
|
||||
detailPinchStartDistance: 0,
|
||||
detailPinchStartScale: 1,
|
||||
mobileError: false, // 手机号错误标记
|
||||
userNameError: false, // 收货人错误标记
|
||||
formData: {
|
||||
@@ -890,6 +1007,20 @@ layout("/layouts/platform_h5.html"){
|
||||
const diffHours = (endTime - now) / (1000 * 60 * 60)
|
||||
|
||||
return diffHours > 0 && diffHours < 24
|
||||
},
|
||||
|
||||
detailStageStyle() {
|
||||
return {
|
||||
width: Math.ceil(this.detailContentWidth * this.detailScale) + "px",
|
||||
height: Math.ceil(this.detailContentHeight * this.detailScale) + "px"
|
||||
}
|
||||
},
|
||||
|
||||
detailContentStyle() {
|
||||
return {
|
||||
width: this.detailContentWidth > 0 ? this.detailContentWidth + "px" : "100%",
|
||||
transform: "scale(" + this.detailScale + ")"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1100,6 +1231,7 @@ layout("/layouts/platform_h5.html"){
|
||||
if (res.code === 0) {
|
||||
this.$toast.success("选择成功")
|
||||
setTimeout(() => {
|
||||
this.clearPopupHistory()
|
||||
this.$pjaxReplace("/platform/h5/welfare/userSelect/success")
|
||||
}, 1500)
|
||||
} else {
|
||||
@@ -1195,7 +1327,8 @@ layout("/layouts/platform_h5.html"){
|
||||
this.showConfirmDialog = true
|
||||
},
|
||||
|
||||
async openChoose(row) {
|
||||
openChoose(row) {
|
||||
this.clearPopupHistory()
|
||||
this.$pjaxReplace("/platform/h5/welfare/userChoose/index?id=" + row.id)
|
||||
},
|
||||
|
||||
@@ -1220,11 +1353,146 @@ layout("/layouts/platform_h5.html"){
|
||||
},
|
||||
|
||||
// 显示选项详情
|
||||
previewOptionImage(url) {
|
||||
if (!url || this.showOptionImagePreview) return
|
||||
// 图片预览不修改选项状态,返回键只关闭当前预览层。
|
||||
this.$set(this, "optionPreviewImages", [url])
|
||||
this.$set(this, "showOptionImagePreview", true)
|
||||
window.mobilePopupHistory.open(this.popupHistoryOwner, "optionImage")
|
||||
},
|
||||
|
||||
closeOptionImagePreview() {
|
||||
if (!this.showOptionImagePreview) return
|
||||
// 先隐藏,防止关闭动画期间的重复点击触发多次历史回退。
|
||||
this.$set(this, "showOptionImagePreview", false)
|
||||
window.mobilePopupHistory.close(this.popupHistoryOwner, "optionImage")
|
||||
},
|
||||
|
||||
showOptionDetail(option) {
|
||||
// 阻止事件冒泡,避免触发父元素的点击事件
|
||||
event.stopPropagation()
|
||||
this.selectedOption = option
|
||||
this.showOptionDetailDialog = true
|
||||
this.detailScale = 1
|
||||
this.detailContentWidth = 0
|
||||
this.detailContentHeight = 0
|
||||
this.$nextTick(() => {
|
||||
this.measureDetailContent()
|
||||
})
|
||||
if (window.mobilePopupHistory) {
|
||||
window.mobilePopupHistory.open(this.popupHistoryOwner, "optionDetail")
|
||||
}
|
||||
},
|
||||
|
||||
closeOptionDetail() {
|
||||
if (window.mobilePopupHistory) {
|
||||
window.mobilePopupHistory.close(this.popupHistoryOwner, "optionDetail")
|
||||
} else {
|
||||
this.showOptionDetailDialog = false
|
||||
}
|
||||
},
|
||||
|
||||
closePopupFromHistory(popupId) {
|
||||
if (popupId === "optionImage") {
|
||||
this.$set(this, "showOptionImagePreview", false)
|
||||
}
|
||||
if (popupId === "optionDetail") {
|
||||
this.showOptionDetailDialog = false
|
||||
}
|
||||
},
|
||||
|
||||
handlePageBack() {
|
||||
if (window.mobilePopupHistory && window.mobilePopupHistory.hasOpen(this.popupHistoryOwner)) {
|
||||
window.history.back()
|
||||
return
|
||||
}
|
||||
this.historyBack()
|
||||
},
|
||||
|
||||
clearPopupHistory() {
|
||||
if (window.mobilePopupHistory) {
|
||||
window.mobilePopupHistory.clear(this.popupHistoryOwner)
|
||||
}
|
||||
},
|
||||
|
||||
measureDetailContent() {
|
||||
this.$nextTick(() => {
|
||||
const viewport = this.$refs.detailViewport
|
||||
const content = this.$refs.detailContent
|
||||
if (!viewport || !content) return
|
||||
this.detailContentWidth = Math.max(viewport.clientWidth, content.scrollWidth)
|
||||
this.detailContentHeight = Math.max(viewport.clientHeight, content.scrollHeight)
|
||||
Array.from(content.querySelectorAll("img")).forEach((image) => {
|
||||
if (!image.complete) {
|
||||
image.addEventListener("load", () => {
|
||||
this.measureDetailContent()
|
||||
}, {once: true})
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
resetDetailZoom() {
|
||||
this.detailScale = 1
|
||||
this.detailPinchStartDistance = 0
|
||||
this.$nextTick(() => {
|
||||
this.measureDetailContent()
|
||||
const viewport = this.$refs.detailViewport
|
||||
if (viewport) {
|
||||
viewport.scrollLeft = 0
|
||||
viewport.scrollTop = 0
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
changeDetailScale(delta) {
|
||||
const viewport = this.$refs.detailViewport
|
||||
if (!viewport) return
|
||||
this.setDetailScale(this.detailScale + delta, viewport.clientWidth / 2, viewport.clientHeight / 2)
|
||||
},
|
||||
|
||||
setDetailScale(scale, centerX, centerY) {
|
||||
const viewport = this.$refs.detailViewport
|
||||
if (!viewport) return
|
||||
const oldScale = this.detailScale
|
||||
const newScale = Math.min(this.detailMaxScale, Math.max(this.detailMinScale, scale))
|
||||
if (Math.abs(newScale - oldScale) < 0.001) return
|
||||
const contentX = (viewport.scrollLeft + centerX) / oldScale
|
||||
const contentY = (viewport.scrollTop + centerY) / oldScale
|
||||
this.detailScale = newScale
|
||||
this.$nextTick(() => {
|
||||
viewport.scrollLeft = Math.max(0, contentX * newScale - centerX)
|
||||
viewport.scrollTop = Math.max(0, contentY * newScale - centerY)
|
||||
})
|
||||
},
|
||||
|
||||
getTouchDistance(touches) {
|
||||
const deltaX = touches[0].clientX - touches[1].clientX
|
||||
const deltaY = touches[0].clientY - touches[1].clientY
|
||||
return Math.sqrt(deltaX * deltaX + deltaY * deltaY)
|
||||
},
|
||||
|
||||
onDetailTouchStart(event) {
|
||||
if (event.touches.length !== 2) return
|
||||
this.detailPinchStartDistance = this.getTouchDistance(event.touches)
|
||||
this.detailPinchStartScale = this.detailScale
|
||||
},
|
||||
|
||||
onDetailTouchMove(event) {
|
||||
if (event.touches.length !== 2 || this.detailPinchStartDistance <= 0) return
|
||||
event.preventDefault()
|
||||
const viewport = this.$refs.detailViewport
|
||||
const rect = viewport.getBoundingClientRect()
|
||||
const centerX = (event.touches[0].clientX + event.touches[1].clientX) / 2 - rect.left
|
||||
const centerY = (event.touches[0].clientY + event.touches[1].clientY) / 2 - rect.top
|
||||
const distance = this.getTouchDistance(event.touches)
|
||||
this.setDetailScale(this.detailPinchStartScale * distance / this.detailPinchStartDistance,
|
||||
centerX, centerY)
|
||||
},
|
||||
|
||||
onDetailTouchEnd(event) {
|
||||
if (event.touches.length < 2) {
|
||||
this.detailPinchStartDistance = 0
|
||||
this.detailPinchStartScale = this.detailScale
|
||||
}
|
||||
},
|
||||
|
||||
// 重置签名
|
||||
@@ -1247,6 +1515,7 @@ layout("/layouts/platform_h5.html"){
|
||||
|
||||
// 跳转到地址管理页面
|
||||
goToAddressManage() {
|
||||
this.clearPopupHistory()
|
||||
this.$pjaxReplace("/platform/h5/welfare/address/list")
|
||||
},
|
||||
|
||||
@@ -1291,11 +1560,12 @@ layout("/layouts/platform_h5.html"){
|
||||
},
|
||||
|
||||
// 获取收货地址
|
||||
async getAddress() {
|
||||
const resp = await this.$axios.post("/platform/welfare/addressManage/selectUserAddress")
|
||||
if (resp.code === 0) {
|
||||
this.addressOptions = resp.data
|
||||
}
|
||||
getAddress() {
|
||||
this.$axios.post("/platform/welfare/addressManage/selectUserAddress").then((resp) => {
|
||||
if (resp.code === 0) {
|
||||
this.addressOptions = resp.data
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1337,21 +1607,34 @@ layout("/layouts/platform_h5.html"){
|
||||
}
|
||||
},
|
||||
|
||||
async created() {
|
||||
created() {
|
||||
if (window.mobilePopupHistory) {
|
||||
window.mobilePopupHistory.register(this.popupHistoryOwner, (popupId) => {
|
||||
this.closePopupFromHistory(popupId)
|
||||
})
|
||||
}
|
||||
this.projectId = GetQueryString("id")
|
||||
if (this.projectId) {
|
||||
// 先获取字典数据
|
||||
this.welfareProvideMode = await this.$businessTool.getDictOptions("welfareProvideMode")
|
||||
this.welfareSignMode = await this.$businessTool.getDictOptions("welfareSignMode")
|
||||
|
||||
// 然后获取项目信息和用户选择
|
||||
this.getProjectInfo()
|
||||
Promise.all([
|
||||
this.$businessTool.getDictOptions("welfareProvideMode"),
|
||||
this.$businessTool.getDictOptions("welfareSignMode")
|
||||
]).then((results) => {
|
||||
this.welfareProvideMode = results[0]
|
||||
this.welfareSignMode = results[1]
|
||||
this.getProjectInfo()
|
||||
})
|
||||
} else {
|
||||
this.$toast.fail("参数错误")
|
||||
setTimeout(() => {
|
||||
this.historyBack()
|
||||
}, 1500)
|
||||
}
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
if (window.mobilePopupHistory) {
|
||||
window.mobilePopupHistory.unregister(this.popupHistoryOwner)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user