场地预约整改:预约时间优化

拉取数据bug整改
This commit is contained in:
2026-09-11 14:13:35 +08:00
parent ce773afea6
commit 1906371506
10 changed files with 593 additions and 234 deletions
@@ -5,7 +5,7 @@ import io.v.nutz.sys.services.SysTaskService;
import io.v.nutz.task.services.TaskPlatformService;
import io.v.nutz.web.commons.slog.annotation.SLog;
import io.v.nutz.base.utils.PageUtil;
import io.v.nutz.base.result.Result;;
import io.v.nutz.base.result.Result;
import io.v.nutz.web.commons.utils.ShiroUtil;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.nutz.dao.Cnd;
@@ -7,6 +7,12 @@ import io.v.nutz.sys.models.Sys_unit;
* Created by wizzer on 2016/12/22.
*/
public interface SysUnitService extends ViService<Sys_unit> {
/**
* 无需参数,从数据中心同步全部单位,供单位维护及人员更新共同使用。
* 返回 void;同步失败抛出异常,由调用接口返回失败提示。
*/
void syncSourceUnits();
/**
* 保存单位
*
@@ -1,5 +1,11 @@
package io.v.nutz.sys.services.impl;
import cn.hutool.core.bean.BeanUtil;
import io.v.nutz.zhgh.data.constant.SourceData;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import io.v.nutz.base.constant.RedisConstant;
import io.v.nutz.base.service.impl.ViServiceImpl;
import io.v.nutz.sys.models.Sys_unit;
@@ -25,6 +31,47 @@ public class SysUnitServiceImpl extends ViServiceImpl<Sys_unit> implements SysUn
super(dao);
}
/**
* 同步数据中心单位,无入参、无返回值;异常交由调用方处理。
* 保留现有层级规则及根单位映射,重复同步按映射后的主键更新。
*/
@Override
@Aop(TransAop.READ_COMMITTED)
public void syncSourceUnits() {
List<Sys_unit> units = SourceData.units();
Set<String> ids = query().stream().map(Sys_unit::getId).collect(Collectors.toSet());
for (Sys_unit unit : units) {
if (Strings.isBlank(unit.getId()) || Strings.isBlank(unit.getUnitcode())) {
throw new IllegalStateException("单位同步失败:源单位编号为空,请核对单位源数据。");
}
// 源根单位 100 在本系统保存为 1,必须先映射再判断是否已存在。
if ("100".equals(unit.getId())) {
unit.setId("1");
unit.setUnitcode("1");
unit.setUnitlevel(1);
}
if (ids.contains(unit.getId())) {
updateIgnoreNull(unit);
} else {
Map<String, Object> beanMap = BeanUtil.beanToMap(unit);
if (unit.getUnitcode().length() == 6) {
beanMap.put("unitlevel", 2);
beanMap.put("parentId", 1);
}
beanMap.remove("child");
insert("sys_unit", Chain.from(beanMap));
ids.add(unit.getId());
}
}
// 沿用原入口对子单位标志的维护规则。
for (Sys_unit unit : query()) {
if (count(Cnd.where("parentId", "=", unit.getId())) > 0) {
unit.setHasChildren(true);
update(unit);
}
}
}
/**
* 新增单位
*
@@ -36,7 +36,9 @@ public class SiteInfoServiceImpl extends BaseServiceImpl<ActivitySiteInfo> imple
*/
@Aop(TransAop.READ_COMMITTED)
public void saveManagedSite(ActivitySiteInfo form, boolean editing) {
if (form == null || !"职工之家".equals(form.getName())) throw new IllegalArgumentException("场地名称请选择职工之家");
// 名称支持选择或自定义输入,拒绝空白及超过实体字段100字符的值,类型关联仍沿用现有规则。
if (form == null || Strings.isBlank(form.getName())) throw new IllegalArgumentException("请选择或输入场地名称");
if (form.getName().length() > 100) throw new IllegalArgumentException("场地名称不能超过100字符");
if (Arrays.asList("青山湖科创中心", "不固定校区").contains(form.getCampus()) || Strings.isBlank(form.getCampus()) || form.getCampus().length() > 100
|| dao().count("sys_dq", Cnd.where("dq_name", "=", form.getCampus())) == 0)
throw new IllegalArgumentException("请选择有效校区");
@@ -146,33 +146,51 @@ public interface SourceData {
}};
/**
* 获取单位
* 分页获取全部源单位,无入参;响应异常或空页时抛出异常,阻止不完整同步。
*
* @return units
* @return 单位列表,DWH 映射为 id/unitcodeDWMC 为名称,SJDWH 为父级编号
*/
static List<Sys_unit> units() {
List<Sys_unit> units = new ArrayList<>();
int page = 1;
String body = HttpUtil.createPost(DATA_URL).body(JSON.toJSONString(new NutMap().addv("address", "rsxt_dwjbsj").addv("params", new NutMap())
.addv("token", "3318a5a9-c2f2-4c8b-a8ea-e99dd68c165c").addv("pageIndex", page).addv("pageSize", 100))).execute().body();
NutMap map = Json.fromJson(NutMap.class, body);
// checkSuccess(map);
List<NutMap> data = map.getAsList("data", NutMap.class);
while (true) {
String body = HttpUtil.createPost(DATA_URL).body(JSON.toJSONString(new NutMap().addv("address", "rsxt_dwjbsj").addv("params", new NutMap())
.addv("token", "3318a5a9-c2f2-4c8b-a8ea-e99dd68c165c").addv("pageIndex", page).addv("pageSize", 100))).execute().body();
NutMap map = Json.fromJson(NutMap.class, body);
// 缺少数据或明确返回失败时不能当作同步成功,避免用不完整单位继续更新人员。
if (map == null || (map.containsKey("success") && !map.getBoolean("success"))
|| !map.containsKey("data") || !map.containsKey("totalCount")) {
throw new IllegalStateException("单位同步失败:数据中心响应异常,请核对单位源接口。");
}
List<NutMap> data = map.getAsList("data", NutMap.class);
int totalCount = map.getInt("totalCount");
if (data == null || data.isEmpty() || totalCount <= 0) {
throw new IllegalStateException("单位同步失败:数据中心返回空页或总数异常,第 " + page + " 页。");
}
for (NutMap row : data) {
Map entity = new HashMap(5);
row.forEach((k, v) -> {
if (UNIT_FIELD_RELATION.containsKey(k)) {
Object value = v;
/* if (UNIT_FIELD_PLUGIN.containsKey(k)) {
value = UNIT_FIELD_PLUGIN.get(k).run(v);
}*/
for (String key : UNIT_FIELD_RELATION.get(k)) {
entity.put(key, value);
}
for (NutMap row : data) {
if (row == null) {
throw new IllegalStateException("单位同步失败:源单位记录为空。");
}
});
units.add(BeanUtil.mapToBean(entity, Sys_unit.class, true));
Map entity = new HashMap(5);
row.forEach((k, v) -> {
if (UNIT_FIELD_RELATION.containsKey(k)) {
Object value = v;
/* if (UNIT_FIELD_PLUGIN.containsKey(k)) {
value = UNIT_FIELD_PLUGIN.get(k).run(v);
}*/
for (String key : UNIT_FIELD_RELATION.get(k)) {
entity.put(key, value);
}
}
});
units.add(BeanUtil.mapToBean(entity, Sys_unit.class, true));
}
// 根据源接口总数逐页拉取,避免原先只读取前 100 个单位。
if (units.size() >= totalCount) {
break;
}
page++;
}
return units;
}
@@ -1,17 +1,17 @@
package io.v.nutz.zhgh.data.controller;
import cn.hutool.core.bean.BeanUtil;
import cn.wizzer.framework.base.Result;
import io.v.nutz.base.annontation.ViReturn;
import io.v.nutz.base.dao.CndPlus;
import io.v.nutz.base.query.PageForm;
import io.v.nutz.base.service.AsyncService;
import io.v.nutz.base.service.ViService;
import io.v.nutz.sys.services.SysUnitService;
import io.v.nutz.sys.models.Sys_unit;
import io.v.nutz.sys.services.SysDqService;
import io.v.nutz.sys.services.SysGxService;
import io.v.nutz.sys.services.SysUnitClassService;
import io.v.nutz.zhgh.data.constant.SourceData;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.ioc.aop.Aop;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.nutz.dao.Chain;
import org.nutz.dao.Cnd;
@@ -25,8 +25,6 @@ import org.nutz.mvc.annotation.Ok;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@At("/platform/data/unit")
@Ok("json:full")
@@ -35,8 +33,8 @@ public class UpdateUnitController {
private static final Log log = Logs.get();
@Inject("Sys_unit")
private ViService<Sys_unit> sysUnitService;
@Inject
private SysUnitService sysUnitService;
@Inject
private SysUnitClassService sysUnitClassService;
@@ -61,45 +59,16 @@ public class UpdateUnitController {
/**
* 单位数据更新
*
* @return {@link Object}
* 无入参,调用 service 同步单位;返回 null,由 ViReturn 转为 code/msg 响应。
* 同步异常由 ViReturn 返回失败提示。
* @return 同步成功返回 null
*/
@At
@ViReturn
@RequiresPermissions("sys.data.unit")
@Aop(TransAop.READ_COMMITTED)
public Object dataUpdate() {
List<Sys_unit> sys_units = sysUnitService.query();
List<String> list = sys_units.stream().map(Sys_unit::getId).collect(Collectors.toList());
// 从源数据中心拉取所有的单位
List<Sys_unit> units = SourceData.units();
for (Sys_unit unit : units) {
if (list.contains(unit.getId())) {
sysUnitService.updateIgnoreNull(unit);
} else {
Map<String, Object> beanMap = BeanUtil.beanToMap(unit);
String unitcode = beanMap.get("unitcode").toString();
if (unitcode.length() == 6) {
beanMap.put("unitlevel", 2);
beanMap.put("parentId", 1);
}
if (beanMap.get("id").equals("100")) {
beanMap.put("unitlevel", 1);
beanMap.put("id", 1);
beanMap.put("unitcode", 1);
}
beanMap.remove("child");
sysUnitService.insert("sys_unit", Chain.from(beanMap));
}
}
sys_units = sysUnitService.query();
sys_units.forEach(v -> {
if (sysUnitService.count(Cnd.where("parentId", "=", v.getId())) > 0) { //查询此单位是否有父级单位
v.setHasChildren(true); //设置子级菜单
sysUnitService.update(v);
}
});
sysUnitService.syncSourceUnits();
return null;
}
@@ -9,6 +9,8 @@ import cn.hutool.http.HtmlUtil;
import io.v.nutz.base.utils.ManyAddOrRenewUtil;
import io.v.nutz.base.utils.Roles;
import io.v.nutz.sys.models.Sys_user_role;
import io.v.nutz.sys.models.Sys_unit;
import io.v.nutz.sys.services.SysUnitService;
import io.v.nutz.sys.services.SysDictService;
import io.v.nutz.sys.services.SysUserRoleService;
import io.v.nutz.zhgh.activity.models.ActivityUserScope;
@@ -83,6 +85,8 @@ public class SourceUserServiceImpl extends ViServiceImpl<UserSource> implements
@Inject
private SysUserService sysUserService;
@Inject
private SysUnitService sysUnitService;
@Inject
private HistoryUserService historyUserService;
@Inject
private UserPartUpService userPatUpService;
@@ -109,10 +113,22 @@ public class SourceUserServiceImpl extends ViServiceImpl<UserSource> implements
dao().insert(users);
}
/**
* 先同步单位并校验人员引用,再按现有配置生成记录和更新人员。
* @param pullTime 已拉取人员数据的批次时间
* @param sourceType all 全部更新、add 新增人员、part 按组别及过滤字段更新
* @param columnNames 部分更新时忽略的字段名,可为空
* @param isUpSet 保留现有接口参数,当前方法不使用
* @param partGroupId 更新组别 ID,为空时使用整个批次
* @param isInvert 是否选择组别之外的人员
* @return 无返回值;同步或校验失败抛出异常,由接口返回 code/msg 提示
*/
@Override
@Aop(TransAop.READ_COMMITTED)
public void updateSysUser(String pullTime, String sourceType, String[] columnNames, String isUpSet, String partGroupId, boolean isInvert) {
try {
// 单位与后续校验使用当前事务,单位同步失败时不进入人员更新。
sysUnitService.syncSourceUnits();
List<UserSource> sources = new ArrayList<>();
Map<String, String> userPartMap = new HashMap<>();
if (Strings.isNotBlank(partGroupId)) {
@@ -172,104 +188,89 @@ public class SourceUserServiceImpl extends ViServiceImpl<UserSource> implements
Set<String> userIds = list.stream().map(SpecialStaff::getUserId).collect(Collectors.toSet());
// 创建一个固定大小的线程池 可以根据服务器性能调整线程池大小
int numberOfThreads = Runtime.getRuntime().availableProcessors() * 2;
List<CompletableFuture<Void>> futures = new CopyOnWriteArrayList<>();
// 每批处理的数据量,可以根据实际情况调整
int batchSize = 200;
// 写入人员、角色、历史之前统一校验;记录计算留在当前线程,读取本事务新同步的单位。
validateSourceUnits(sources, userMap, userIds, allowChangeFieldNames);
for (UserSource source : sources) {
//不更新会员和福利会员字段
source.setMember(null);
source.setWelfareMember(null);
Sys_user user = userMap.get(source.getLoginname());
for (int i = 0; i < sources.size(); i += batchSize) {
final int end = Math.min(i + batchSize, sources.size());
List<UserSource> batch = sources.subList(i, end);
Map<String, String> finalUserPartMap = userPartMap;
if (user != null && userIds.contains(user.getId())) {
continue;
}
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
for (UserSource source : batch) {
//不更新会员和福利会员字段
source.setMember(null);
source.setWelfareMember(null);
Sys_user user = userMap.get(source.getLoginname());
if (StrUtil.isBlank(source.getPersonType())) {
continue;
}
if (user != null && userIds.contains(user.getId())) {
continue;
}
// 这里是杭医的不进系统人员的判断,其他学校用不到的话删掉
boolean isNotEnterUser = NOT_ENTERING_PERSON_TYPE.contains(source.getPersonType())
|| NOT_ENTERING_USER_LOGIN_NAME.contains(source.getLoginname())
|| NOT_ENTERING_USER_STATE.contains(source.getUserState());
if (StrUtil.isBlank(source.getPersonType())) {
continue;
}
if (isAuto) {
checkMemberOrWelfareMember(changeConfig, source, user, addMemberUserIds, deleteMemberUserIds,
addWelfareMemberUserIds, deleteWelfareMemberUserIds);
} else {
if (user != null) {
source.setMember(user.getMember());
source.setWelfareMember(user.getWelfareMember());
}
}
// 这里是杭医的不进系统人员的判断,其他学校用不到的话删掉
boolean isNotEnterUser = NOT_ENTERING_PERSON_TYPE.contains(source.getPersonType())
|| NOT_ENTERING_USER_LOGIN_NAME.contains(source.getLoginname())
|| NOT_ENTERING_USER_STATE.contains(source.getUserState());
if (isAuto) {
checkMemberOrWelfareMember(changeConfig, source, user, addMemberUserIds, deleteMemberUserIds,
addWelfareMemberUserIds, deleteWelfareMemberUserIds);
} else {
if (user != null) {
source.setMember(user.getMember());
source.setWelfareMember(user.getWelfareMember());
}
}
Sys_user u = new Sys_user();
BeanUtils.copyProperties(source, u);
switch (sourceType) {
case "all" -> {
// 复制属性到一个新的user对象
u.setId(user == null ? source.getId() : user.getId());
if (user != null) {
needDoUpdateList.add(u);
}
}
case "add" -> {
u.setId(source.getId());
}
case "part" -> {
u.setId(user == null ? source.getId() : user.getId());
if (columnNames != null && columnNames.length > 0) {
String lockedColumn = String.join("|", columnNames);
FieldFilter fieldFilter = FieldFilter.create(Sys_user.class, null, "^" + lockedColumn + "$", true);
filterColumnDao.set(Daos.ext(dao(), fieldFilter));
}
if (Strings.isNotBlank(finalUserPartMap.get(u.getLoginname())) && user != null) {
needDoUpdateList.add(u);
}
}
}
if (isAuto) {
UserHistory history = createHistory(source, user, dictMap, allowChangeFieldNames);
if (Lang.isEmpty(history)) {
continue;
}
if (user != null) {
histories.add(history);
}
if (!isNotEnterUser && Lang.isNotEmpty(history.getChangeTypes()) && history.getChangeTypes().contains(MemberChangeType.NEW.getType())) {
needInitUserList.add(u);
histories.add(history);
}
} else {
SourceChangeMiddleTable table = createSourceChangeMiddleTable(source, user, dictMap, allowChangeFieldNames);
if (Lang.isEmpty(table)) {
continue;
}
if (user != null) {
middleTables.add(table);
}
if (!isNotEnterUser && Lang.isNotEmpty(table.getChangeTypes()) && table.getChangeTypes().contains(MemberChangeType.NEW.getType())) {
needInitUserList.add(u);
middleTables.add(table);
}
Sys_user u = new Sys_user();
BeanUtils.copyProperties(source, u);
switch (sourceType) {
case "all" -> {
// 复制属性到一个新的user对象
u.setId(user == null ? source.getId() : user.getId());
if (user != null) {
needDoUpdateList.add(u);
}
}
}, Executors.newFixedThreadPool(numberOfThreads));
futures.add(future);
}
case "add" -> {
u.setId(source.getId());
}
case "part" -> {
u.setId(user == null ? source.getId() : user.getId());
if (columnNames != null && columnNames.length > 0) {
String lockedColumn = String.join("|", columnNames);
FieldFilter fieldFilter = FieldFilter.create(Sys_user.class, null, "^" + lockedColumn + "$", true);
filterColumnDao.set(Daos.ext(dao(), fieldFilter));
}
if (Strings.isNotBlank(userPartMap.get(u.getLoginname())) && user != null) {
needDoUpdateList.add(u);
}
}
}
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
if (isAuto) {
UserHistory history = createHistory(source, user, dictMap, allowChangeFieldNames);
if (Lang.isEmpty(history)) {
continue;
}
if (user != null) {
histories.add(history);
}
if (!isNotEnterUser && Lang.isNotEmpty(history.getChangeTypes()) && history.getChangeTypes().contains(MemberChangeType.NEW.getType())) {
needInitUserList.add(u);
histories.add(history);
}
} else {
SourceChangeMiddleTable table = createSourceChangeMiddleTable(source, user, dictMap, allowChangeFieldNames);
if (Lang.isEmpty(table)) {
continue;
}
if (user != null) {
middleTables.add(table);
}
if (!isNotEnterUser && Lang.isNotEmpty(table.getChangeTypes()) && table.getChangeTypes().contains(MemberChangeType.NEW.getType())) {
needInitUserList.add(u);
middleTables.add(table);
}
}
}
//如果有新用户,增加到用户表同时增加角色
if (Lang.isNotEmpty(needInitUserList)) {
@@ -322,11 +323,69 @@ public class SourceUserServiceImpl extends ViServiceImpl<UserSource> implements
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException(e.getMessage());
throw new RuntimeException(e.getMessage(), e);
}
}
/**
* 校验本次范围内参与新增、更新或变更记录生成的单位,失败时阻断全部人员写入。
* @param sources 已按数据源时间、组别及反选条件筛选的人员
* @param userMap 以工号为键的现有人员,用于检查变更前单位
* @param excludedUserIds 按现有特殊人员规则不做变动的人员 ID
* @param allowChangeFieldNames 允许记录变更的字段,含 unitId 时核对变更前单位
* @return 无返回值;缺失时抛出包含单位编号、姓名和工号的异常,供接口 msg 展示
*/
private void validateSourceUnits(List<UserSource> sources, Map<String, Sys_user> userMap,
Set<String> excludedUserIds, Set<String> allowChangeFieldNames) {
Map<String, Sys_unit> units = sysUnitService.query().stream()
.collect(Collectors.toMap(Sys_unit::getId, unit -> unit));
List<String> missing = new ArrayList<>();
for (UserSource source : sources) {
Sys_user user = userMap.get(source.getLoginname());
if (StrUtil.isBlank(source.getPersonType()) || (user != null && excludedUserIds.contains(user.getId()))) {
continue;
}
// 不进系统规则只排除新增人员;现有人员仍按原逻辑生成变更记录。
if (user == null && (NOT_ENTERING_PERSON_TYPE.contains(source.getPersonType())
|| NOT_ENTERING_USER_LOGIN_NAME.contains(source.getLoginname())
|| (source.getUserState() != null && NOT_ENTERING_USER_STATE.contains(source.getUserState())))) {
continue;
}
collectMissingUnit(units, source.getUnitid(), "新单位", source, missing);
if (user != null && allowChangeFieldNames.contains("unitId")
&& !Objects.equals(source.getUnitid(), user.getUnitid())) {
collectMissingUnit(units, user.getUnitid(), "原单位", source, missing);
}
}
if (!missing.isEmpty()) {
// 页面提示限制长度,同时告知总数;完整明细写日志便于核对。
log.warn("人员更新单位校验失败:{}", String.join("", missing));
throw new IllegalStateException("单位同步校验后仍存在缺失单位或单位名称为空,本次人员更新已停止。"
+ String.join("", missing.subList(0, Math.min(10, missing.size())))
+ (missing.size() > 10 ? ";共 " + missing.size() + " 项,完整明细请查看服务日志" : "")
+ "。请核对单位源数据。");
}
}
/**
* 将不存在、编号为空或名称为空的单位加入提示明细;仅收集问题,不修改数据。
* @param units 当前事务同步后的单位,键为单位 ID
* @param unitId 人员引用的单位 ID,空值也需提示
* @param label 新单位或原单位,标明问题来源
* @param source 相关人员,提供姓名及工号
* @param missing 校验失败明细,返回结果追加到此集合;方法返回 void
*/
private void collectMissingUnit(Map<String, Sys_unit> units, String unitId, String label,
UserSource source, List<String> missing) {
Sys_unit unit = units.get(unitId);
if (StrUtil.isBlank(unitId) || unit == null || StrUtil.isBlank(unit.getName())) {
missing.add(label + "编号:" + Strings.sBlank(unitId, "未提供")
+ ",相关人员:" + Strings.sBlank(source.getUsername(), "未提供姓名")
+ "(工号:" + Strings.sBlank(source.getLoginname(), "未提供") + "");
}
}
/**
* 判断自动更新的会员和福利会员
* @param config 人员更新的配置
@@ -553,8 +612,9 @@ public class SourceUserServiceImpl extends ViServiceImpl<UserSource> implements
if (Lang.isEmpty(changeList)) {
return null;
}
// 单位缺失由前置校验阻断,其他字段的空值按原业务展示为无数据。
String changeInfos = changeList.stream().map(v -> {
return v.getString("fieldName") + "" + HtmlUtil.cleanHtmlTag(v.getString("sourceValue")) + "—>" + HtmlUtil.cleanHtmlTag(v.getString("newValue"));
return v.getString("fieldName") + "" + HtmlUtil.cleanHtmlTag(Strings.sBlank(v.getString("sourceValue"), "无数据")) + "—>" + HtmlUtil.cleanHtmlTag(Strings.sBlank(v.getString("newValue"), "无数据"));
}).collect(Collectors.joining(""));
// 比较变更数据
@@ -609,8 +669,9 @@ public class SourceUserServiceImpl extends ViServiceImpl<UserSource> implements
if (Lang.isEmpty(changeList)) {
return null;
}
// 单位缺失由前置校验阻断,其他字段的空值按原业务展示为无数据。
String changeInfos = changeList.stream().map(v -> {
return v.getString("fieldName") + "" + HtmlUtil.cleanHtmlTag(v.getString("sourceValue")) + "—>" + HtmlUtil.cleanHtmlTag(v.getString("newValue"));
return v.getString("fieldName") + "" + HtmlUtil.cleanHtmlTag(Strings.sBlank(v.getString("sourceValue"), "无数据")) + "—>" + HtmlUtil.cleanHtmlTag(Strings.sBlank(v.getString("newValue"), "无数据"));
}).collect(Collectors.joining(""));
// 比较变更数据
commonCompareChange(source, user, changeTypes);
@@ -24,7 +24,7 @@ layout("/mobile/platform.html"){
}
.site-reserve-slots .time {
width: 50%;
display: block;
text-align: center;
white-space: nowrap;
}
@@ -32,23 +32,19 @@ layout("/mobile/platform.html"){
/* 长禁用原因由内容撑开行高,避免固定高度导致文字覆盖下一场次。 */
.site-reserve-slots .state {
width: 100%;
min-height: 36px;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
padding: 6px 8px;
line-height: 20px;
display: block;
line-height: 18px;
white-space: normal;
overflow-wrap: anywhere;
text-align: center;
background-color: #f2f2f2;
border-radius: 2px;
color: #929292;
color: #1989fa;
}
.site-reserve-slots {
padding: 10px 0px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px 16px;
padding: 8px 0 66px;
background-color: white;
width: 95%;
margin: 0 auto;
@@ -56,18 +52,53 @@ layout("/mobile/platform.html"){
margin-top: 10px;
}
.site-reserve-slots table {
width: 100%;
table-layout: fixed;
border-collapse: separate;
border-spacing: 0 4px;
/* 单个按钮容纳时间和状态,选中仅改变外观,继续复用原有时段选择逻辑。 */
.site-reserve-slots .slot-button {
min-width: 0;
min-height: 54px;
box-sizing: border-box;
padding: 8px 6px;
border: 1px solid #e6e8ed;
border-radius: 12px;
background: #fff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04);
color: #323233;
font: inherit;
font-size: 13px;
line-height: 18px;
text-align: center;
cursor: pointer;
}
.site-reserve-slots td {
padding: 0 6px;
vertical-align: middle;
.site-reserve-slots .slot-button.is-selected {
background: #ecf6ff;
border-color: #a4d2ff;
color: #1989fa;
box-shadow: 0 4px 12px rgba(25, 137, 250, 0.08);
}
.site-reserve-slots .slot-button:disabled,
.site-reserve-slots .slot-button:disabled .state {
color: #969799;
background: #f7f8fa;
box-shadow: none;
cursor: default;
}
/* 全天候四列时间点:网格单元承载连续底色,按钮端点保留圆角及起终角标。 */
.site-reserve-slots.full-day-slots { grid-template-columns:repeat(4,minmax(0,1fr)); gap:18px 0; }
.full-day-slots .point-cell { min-width:0; padding:0 8px; display:flex; }
.full-day-slots .point-cell.in-range { background:#e2f1ff; padding:0; }
.full-day-slots .point-cell.range-start { padding-left:8px; background:linear-gradient(to right,#fff 8px,#e2f1ff 8px); }
.full-day-slots .point-cell.range-end { padding-right:8px; background:linear-gradient(to left,#fff 8px,#e2f1ff 8px); }
.full-day-slots .slot-button { width:100%; position:relative; padding:8px 2px; }
.full-day-slots .in-range .slot-button { border-radius:0; background:#e2f1ff; border-color:transparent; box-shadow:none; }
.full-day-slots .range-start .slot-button { border-radius:12px 0 0 12px; }
.full-day-slots .range-end .slot-button { border-radius:0 12px 12px 0; }
.full-day-slots .point-cell .range-boundary { border-color:#a4d2ff; color:#1989fa; background:#ecf6ff; }
.full-day-slots .range-mark { position:absolute; color:#fff; background:#1989fa; font-size:11px; line-height:18px; padding:0 4px; }
.full-day-slots .range-mark.start { top:0; left:0; border-radius:10px 0 5px 0; }
.full-day-slots .range-mark.end { bottom:0; right:0; border-radius:5px 0 10px 0; }
.van-action-sheet__content {
padding: 10px;
font-size: 15px;
@@ -195,27 +226,28 @@ layout("/mobile/platform.html"){
</div>
</div>
<div class="site-reserve-slots">
<table>
<tr v-for="item in timeData">
<td class="time">{{item.start_time + '-' + item.end_time}}</td>
<td v-if="item.code !== 1" @click.stop="">
<div class="state" :style="'background-color: ' + item.backColor + ';color:' +item.color">
<!-- 仅缩短过期展示文案,保留接口原始状态和其他禁用原因。 -->
{{item.msg === '预约时段已过期' ? '已过期' : item.msg}}
</div>
</td>
<td v-else @click.stop="chooseTime(item)">
<div class="state"
:style="'background-color: ' + item.backColor + ';color:' +item.color">
<span v-if="!times.map(o => o.fullDay).includes(selectDate.format('YYYY-MM-DD') + ' ' + item.start_time + '-' + item.end_time)">{{item.msg}}</span>
<span v-else>
<van-icon name="passed" size="26" style="line-height: 36px;position: unset"></van-icon>
</span>
</div>
</td>
</tr>
</table>
<div v-if="isFullDay" class="site-reserve-slots full-day-slots">
<div v-for="point in fullDayPoints" :key="point.time" class="point-cell"
:class="{'in-range':point.inRange,'range-start':point.mark==='起' && currentRange.end,'range-end':point.mark==='终'}">
<button type="button" class="slot-button" :class="{'range-boundary':!!point.mark}"
:disabled="slotsLoading || point.disabled" :aria-pressed="point.selected" :title="point.reason"
@click.stop="chooseFullDayPoint(point)">
<span class="time">{{point.time}}</span>
<span class="state">{{point.reason}}</span>
<span v-if="point.mark" class="range-mark" :class="point.mark==='起' ? 'start' : 'end'">{{point.mark}}</span>
</button>
</div>
</div>
<div v-else class="site-reserve-slots">
<button v-for="item in timeData" :key="item.start_time + '-' + item.end_time"
type="button" class="slot-button" :disabled="item.code !== 1"
:class="{'is-selected':item.code === 1 && times.map(o => o.fullDay).includes(selectDate.format('YYYY-MM-DD') + ' ' + item.start_time + '-' + item.end_time)}"
:aria-pressed="item.code === 1 && times.map(o => o.fullDay).includes(selectDate.format('YYYY-MM-DD') + ' ' + item.start_time + '-' + item.end_time)"
@click.stop="chooseTime(item)">
<span class="time">{{item.start_time + ' - ' + item.end_time}}</span>
<!-- 保留原状态文案;选中通过按钮高亮表示,不替换时间和状态内容。 -->
<span class="state">{{item.msg === '预约时段已过期' ? '已过期' : item.msg}}</span>
</button>
</div>
<div style="position: fixed; bottom: 0; width: 100%">
@@ -230,7 +262,7 @@ layout("/mobile/platform.html"){
<van-cell title="所属单位">{{unit}}</van-cell>
<van-cell title="联系电话">{{phone}}</van-cell>
<van-cell title="预约时间">
<div v-for="time in times">
<div v-for="time in confirmationTimes" :key="time.fullDay">
<van-tag closeable size="medium" type="primary" @close="close(time.fullDay)">
{{time.fullDay}}
</van-tag>
@@ -326,6 +358,8 @@ layout("/mobile/platform.html"){
timeData: [],
slotRequestVersion:0, slotsLoading:false,
times: [],
// 每个日期独立保存起止边界;times仍保存原接口要求的逐场次记录。
fullDayRanges: {}, slotsFailed:false,
weekList: [],
joinUser: '',
calendarVisible: false,
@@ -337,6 +371,27 @@ layout("/mobile/platform.html"){
reserve_type() { this.getReserve() }
},
computed: {
isFullDay() { return Number(this.site.reserveTimeType || this.site.reservetimetype || 1) === 2 },
currentRange() { return this.fullDayRanges[this.selectDate.format('YYYY-MM-DD')] || {start:'',end:''} },
// 结束点包括最后一个场次的结束边界,24:00保留原字符串,不转换为次日00:00。
fullDayPoints() {
const range=this.currentRange, choosingEnd=range.start && !range.end
return Array.from(new Set(this.timeData.flatMap(row=>[row.start_time,row.end_time]))).sort().map(time=>{
const row=this.timeData.find(slot=>slot.start_time===time)
const selected=time===range.start || !!range.end && time>=range.start && time<=range.end
const validEnd=choosingEnd && this.fullDaySlots(range.start,time)
const disabled=!selected && (choosingEnd ? !validEnd : !row || row.code!==1)
const reason=disabled ? choosingEnd ? '不可选' : row ? row.msg==='预约时段已过期' ? '已过期' : row.msg : '仅结束时间' : '可预约'
return {time,selected,disabled,reason,inRange:!!range.end && selected,mark:time===range.start ? '起' : time===range.end ? '终' : ''}
})
},
confirmationTimes() {
if (!this.isFullDay) return this.times
return Object.keys(this.fullDayRanges).sort().filter(day=>this.fullDayRanges[day].end).map(day=>{
const range=this.fullDayRanges[day]
return {day,fullDay:day+' '+range.start+'-'+range.end}
})
},
reserveTypeName() {
// 未选择时显示提示,避免读取不存在的选项名称。
const option = this.reserveTypeOptions.find(item => item.value===this.reserve_type)
@@ -399,6 +454,39 @@ layout("/mobile/platform.html"){
this.$set(this,'slotRequestVersion',this.slotRequestVersion+1)
},
methods: {
// start/end为HH:mm边界,返回全部连续可约场次;空范围、禁用或断档返回null。
fullDaySlots(start,end) {
if (!start || !end || end<=start) return null
const rows=this.timeData.filter(row=>row.start_time>=start && row.end_time<=end).slice().sort((a,b)=>a.start_time.localeCompare(b.start_time))
if (!rows.length || rows[0].start_time!==start || rows[rows.length-1].end_time!==end || rows.some((row,i)=>row.code!==1 || i>0 && rows[i-1].end_time!==row.start_time)) return null
return rows
},
// 仅清除指定日期的完整范围及提交记录,其他日期的选择保持不变。
clearFullDay(day) {
this.$set(this.fullDayRanges,day,{start:'',end:''})
this.$set(this,'times',this.times.filter(row=>row.day!==day))
},
chooseFullDayPoint(point) {
if (this.slotsLoading || this.formLoading || point.disabled) return
const day=this.selectDate.format('YYYY-MM-DD'),range=this.currentRange
if (point.selected) { this.clearFullDay(day); return }
if (!range.start || range.end) {
this.clearFullDay(day)
this.$set(this.fullDayRanges,day,{start:point.time,end:''})
return
}
const rows=this.fullDaySlots(range.start,point.time)
if (!rows) return
this.$set(this.fullDayRanges,day,{start:range.start,end:point.time})
this.$set(this,'times',this.times.filter(row=>row.day!==day).concat(rows.map(row=>Object.assign({},row,{day,fullDay:day+' '+row.start_time+'-'+row.end_time}))))
},
// 任一日期仅选起点都不能确认,避免跨日切换后漏提交未完成的范围。
validateFullDay() {
if (this.isFullDay && Object.values(this.fullDayRanges).some(range=>range.start && !range.end)) {
vant.Toast('请选择结束时间或取消未完成的选择'); return false
}
return true
},
// 与PC共用所属分工会接口,页面销毁后的响应不再更新界面。
loadBookingUnion() {
this.$set(this,'unionLoading',true)
@@ -437,6 +525,11 @@ layout("/mobile/platform.html"){
else this.clearPopupHistory(() => pjaxReplace('/mobile/activity/site/info'))
},
close(f) {
if (this.isFullDay) {
if (this.confirmationTimes.length===1) { vant.Toast('必须保留一个时间段'); return }
this.clearFullDay(f.split(' ')[0])
return
}
if (this.times.length === 1) {
vant.Toast('必须保留一个时间段')
return
@@ -447,6 +540,7 @@ layout("/mobile/platform.html"){
onConfirm(date) {
this.selectDate = moment(date)
this.setWeekList(this.selectDate)
this.getReserve()
this.calendarVisible = false
},
//这里要排除掉休息日
@@ -469,6 +563,7 @@ layout("/mobile/platform.html"){
this.getReserve()
},
reserve() {
if (this.slotsLoading || this.slotsFailed || !this.validateFullDay()) return
if (this.times.length === 0) {
vant.Toast('请选择要预约的时段')
return
@@ -477,8 +572,9 @@ layout("/mobile/platform.html"){
},
reserveDo() {
if(this.slotsLoading || this.formLoading)return
if(this.slotsFailed || !this.validateFullDay())return
if(!this.times.length){vant.Toast('请选择预约时段');return}
if(Number(this.site.reserveTimeType || 1)===2){
if(this.isFullDay){
const days=Array.from(new Set(this.times.map(row=>row.day)))
const gaps=days.some(day=>{
const selected=this.times.filter(row=>row.day===day).slice().sort((a,b)=>a.start_time.localeCompare(b.start_time))
@@ -492,7 +588,7 @@ layout("/mobile/platform.html"){
if(this.reserve_type===3 && !this.clubId){vant.Toast('请选择所属协会');return}
this.formLoading=true;
$.post('/mobile/activity/site/info/reserveDo',{siteId:this.site_id,times:JSON.stringify(this.times),message:this.message,reserve_type:this.reserve_type,clubId:this.clubId}).then((res)=>{
if(res.code===0){this.clearPopupHistory(()=>{vant.Toast('预约成功');this.times=[];this.message='';this.getReserve();this.onLoad()})}
if(res.code===0){this.clearPopupHistory(()=>{vant.Toast('预约成功');this.times=[];this.$set(this,'fullDayRanges',{});this.message='';this.getReserve();this.onLoad()})}
else{vant.Toast(res.msg)}
}).always(()=>{this.formLoading=false})
},
@@ -519,13 +615,23 @@ layout("/mobile/platform.html"){
const version=this.slotRequestVersion+1
this.$set(this,'slotRequestVersion',version)
this.$set(this,'slotsLoading',true)
this.$set(this,'slotsFailed',false)
this.$set(this,'timeData',[])
// 未选类型时仍按团体预约规则查询占用,仅用于场次查询,不回填表单类型。
return $.post('/mobile/activity/site/info/getReserve',{siteId:this.site_id,day:moment(this.selectDate).format('YYYY-MM-DD'),reserveType:this.reserve_type===null ? 2 : this.reserve_type}).then(res=>{
if(version!==this.slotRequestVersion)return
if(res && res.code===0)this.$set(this,'timeData',res.data)
else vant.Toast(res && res.msg || '场次加载失败')
},()=>{if(version===this.slotRequestVersion)vant.Toast('场次加载失败,请重试')}).always(()=>{
if(res && res.code===0 && Array.isArray(res.data)) {
this.$set(this,'timeData',res.data)
// 切换日期或类型后重新核验该日范围,保留其他日期;占用变化时整段清空。
if (this.isFullDay) {
const range=this.currentRange
if (range.start && (range.end ? !this.fullDaySlots(range.start,range.end) : !res.data.some(row=>row.start_time===range.start && row.code===1))) {
this.clearFullDay(this.selectDate.format('YYYY-MM-DD'))
vant.Toast('所选时间已失效,请重新选择')
}
}
} else { this.$set(this,'slotsFailed',true); vant.Toast(res && res.msg || '场次加载失败') }
},()=>{if(version===this.slotRequestVersion){this.$set(this,'slotsFailed',true);vant.Toast('场次加载失败,请重试')}}).always(()=>{
if(version===this.slotRequestVersion)this.$set(this,'slotsLoading',false)
})
},
@@ -137,7 +137,9 @@ layout("/layouts/platform.html"){
<el-row :gutter="20">
<el-col :span="12">
<el-form-item prop="name" label="场地名称">
<el-select v-model="formData.name" placeholder="请选择场地名称" style="width:100%">
<!-- 保留常用名称选项,其他名称可输入后选择创建项或按回车确认。 -->
<el-select v-model="formData.name" filterable allow-create default-first-option
placeholder="请选择或输入场地名称" style="width:100%">
<el-option label="职工之家" value="职工之家"></el-option>
</el-select>
</el-form-item>
@@ -319,7 +321,11 @@ layout("/layouts/platform.html"){
year: ""
},
formRules: {
name: [{required: true, message: '请选择场地名称', trigger: ['blur', 'change']}],
// 自定义名称与后端保持一致:必填、拒绝全空格且不超过实体字段的100字符。
name: [
{required: true, whitespace: true, message: '请选择或输入场地名称', trigger: ['blur', 'change']},
{max: 100, message: '场地名称不能超过100字符', trigger: ['blur', 'change']}
],
address: [{required: true, message: '请填写场地地址', trigger: ['blur', 'change']}],
contact_person: [{required: true, message: '请填写场地管理员', trigger: ['blur', 'change']}],
contact_phone: [{required: true, message: '请填写联系电话', trigger: ['blur', 'change']}],
@@ -422,9 +428,9 @@ layout("/layouts/platform.html"){
this.findOneSite(id).then((row)=>{if(row){this.initSchedule(row);this.$set(this, 'formData', row);this.$refs.guava.edit()}});
},
openAdd() {
// 新建只提供职工之家;校区由用户明确选择,不沿用上次编辑值。
// 新建时名称留空,由用户选择或输入;校区由用户明确选择,不沿用上次编辑值。
this.$set(this, 'formData', {
name: '职工之家',
name: '',
campus: '',
state: true,
multiple: true,
@@ -109,6 +109,17 @@ layout("/layouts/platform.html"){
.site-booking-date { flex:0 0 88px; white-space:nowrap; font-weight:500; }
.site-booking-ranges { display:flex; flex-wrap:wrap; gap:2px 12px; }
.site-booking-range { white-space:nowrap; }
/* 时间选择区平铺全部选项,日期和按钮自动换行;禁用原因仅通过悬停提示展示。 */
.site-time-dates { display:flex; flex-wrap:wrap; gap:8px; margin:12px 0 20px; }
.site-time-group { display:flex; gap:16px; margin:20px 0; align-items:flex-start; }
.site-time-heading { flex:0 0 140px; line-height:36px; color:#606266; white-space:nowrap; }
.site-time-options { display:flex; flex-wrap:wrap; gap:8px; flex:1; }
.site-time-options .el-button { margin:0; position:relative; min-width:82px; min-height:36px; }
.site-time-options .is-range { background:#ecf5ff; border-color:#409eff; color:#1672bd; }
.site-time-mark { position:absolute; top:2px; right:3px; font-size:10px; line-height:12px; font-weight:bold; }
.site-time-summary { margin:16px 0; color:#303133; line-height:24px; }
.site-time-help { color:#909399; line-height:24px; }
@media (max-width:800px) { .site-time-group { flex-direction:column; gap:4px; } .site-time-heading { flex-basis:auto; } }
</style>
<div id="app" v-cloak>
<guava ref="guava">
@@ -320,7 +331,7 @@ layout("/layouts/platform.html"){
您正在预约【<span style="color: #409EFF">{{infoViewData.name}}</span>】活动场地
</div>
<div style="color:#909399;">
开放时间段为:<span v-for="(item,index) in infoViewData.open_hours" style="color: #303133">
开放时间段为:<span v-for="(item,index) in displayedOpenHours" style="color: #303133">
<span v-if="infoViewData.workday === true && index === 0">工作日</span>
【{{item.start_time}} - {{item.end_time}}】
</span>
@@ -368,7 +379,32 @@ layout("/layouts/platform.html"){
<el-dialog title="新增场地预约" class="dia" :close-on-click-modal="false" @close="diaClose"
:visible.sync="addDialogVisible" custom-class="ViewDialogClass" width="70%" top="2%">
<vi-title :title="'您已选择的预约日期为:'+ dayArray.toString()" style="margin-left: 48px"></vi-title>
<el-tabs :value="bookingTab" :before-leave="beforeBookingTabLeave" @input="changeBookingTab">
<el-tab-pane label="选择时间" name="time">
<div>已选择的预约日期</div>
<div class="site-time-dates"><el-tag v-for="day in dayArray" :key="day">{{day}}</el-tag></div>
<div class="site-time-help">所有选中日期使用同一组时间,只有全部日期均可预约的时间才能选择。</div>
<div class="site-time-summary">{{isFullDayBooking ? '先点击开始时间,再点击结束时间;再次点击已选时间可取消本次选择。' : '点击时间段选择,再次点击取消,可选择多个时间段。'}}</div>
<div v-loading="slotsLoading" style="min-height:160px">
<div v-if="slotsLoadFailed"><el-alert title="时间加载失败,请重试" type="error" :closable="false"></el-alert><el-button @click="loadBookingSlots">重新加载</el-button></div>
<div v-else-if="!slotsLoading && !bookingSlots.length" class="site-time-help">暂无开放时段</div>
<div v-for="group in bookingTimeGroups" :key="group.label" class="site-time-group">
<div class="site-time-heading">{{group.label}}</div>
<div class="site-time-options">
<el-button v-for="item in group.items" :key="item.key" size="small"
:disabled="slotsLoading || item.disabled" :title="item.reason || item.label"
:type="item.mark ? 'primary' : 'default'" :class="{'is-range':item.selected && !item.mark}"
@click="chooseBookingTime(item)">
{{item.label}}<span v-if="item.mark" class="site-time-mark">{{item.mark}}</span>
</el-button>
</div>
</div>
</div>
<div class="site-time-summary">已选时间:{{bookingTimeSummary}}</div>
<el-button size="small" :disabled="slotsLoading" @click="clearBookingTime">清空选择</el-button>
</el-tab-pane>
<el-tab-pane label="填写信息" name="info">
<div class="site-time-dates"><el-tag v-for="day in dayArray" :key="day">{{day}}</el-tag></div>
<el-form :model="formData" ref="addForm" :rules="formRules" size="small" label-position="right"
label-width="100px">
@@ -427,13 +463,8 @@ layout("/layouts/platform.html"){
</el-form-item>-->
<el-form-item label="预约时间" prop="time">
<el-select v-model="formData.time" style="width: 100%" multiple :disabled="slotsLoading" filterable @change="timeChange"
placeholder="请选择预约时间">
<el-option v-for="item in bookingSlots" :key="item.start_time + item.end_time"
:label="item.disabled ? item.start_time + ' - ' + item.end_time + '' + item.msg + '' : item.start_time + ' - ' + item.end_time"
:disabled="item.disabled"
:value="item.start_time + '-' + item.end_time"></el-option>
</el-select>
<span>{{bookingTimeSummary}}</span>
<el-button type="text" :disabled="formLoading" @click="changeBookingTab('time')">修改时间</el-button>
</el-form-item>
<!--<el-form-item label="预约时间" prop="start_time">
@@ -486,10 +517,14 @@ layout("/layouts/platform.html"){
type="textarea" :autosize="{ minRows: 4, maxRows: 6}"></el-input>
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
<span slot="footer" class="dialog-footer">
<el-button @click="closeAdd">取 消</el-button>
<el-button type="primary" :disabled="subDis" @click="doAdd">确 定</el-button>
<el-button :disabled="formLoading" @click="closeAdd">取 消</el-button>
<el-button v-if="bookingTab==='info'" :disabled="formLoading" @click="changeBookingTab('time')">上一步</el-button>
<el-button v-if="bookingTab==='time'" type="primary" :disabled="slotsLoading" @click="nextBookingTab">下一步</el-button>
<el-button v-else type="primary" :loading="formLoading" :disabled="subDis || slotsLoading" @click="doAdd">确 定</el-button>
</span>
</el-dialog>
@@ -513,6 +548,7 @@ layout("/layouts/platform.html"){
myClubs: [],
bookingUnionName:'', unionLoading:false, unionRequestVersion:0,
bookingSlots:[], slotsLoading:false, slotsVersion:0,
bookingTab:'time', bookingRangeStart:'', bookingRangeEnd:'', slotsLoadFailed:false,
formLoading: false,
slider_width: 0,
single_width: 0,
@@ -569,11 +605,114 @@ layout("/layouts/platform.html"){
'2022-10-01', '2022-10-02', '2022-10-03', '2022-10-04', '2022-10-05', '2022-10-06', '2022-10-07',],
}
},
computed: {
isFullDayBooking() {
return Number(this.infoViewData.reserveTimeType || this.infoViewData.reservetimetype || 1) === 2
},
bookingTimeSummary() {
if (this.isFullDayBooking) return this.bookingRangeEnd ? this.bookingRangeStart + ' - ' + this.bookingRangeEnd : this.bookingRangeStart ? this.bookingRangeStart + ' 起(请选择结束时间)' : '尚未选择'
return (this.formData.time || []).join('、') || '尚未选择'
},
// 全天候展示时间边界(含最后结束点),分段展示完整场次;12:00及之后统一归入下午。
bookingTimeGroups() {
let items
if (this.isFullDayBooking) {
const points = Array.from(new Set(this.bookingSlots.flatMap(row => [row.start_time, row.end_time]))).sort()
const choosingEnd = this.bookingRangeStart && !this.bookingRangeEnd
items = points.map(point => {
const startSlot = this.bookingSlots.find(row => row.start_time === point)
const validEnd = choosingEnd && this.rangeBookingSlots(this.bookingRangeStart, point)
// 已选端点和范围内时间保持可点击,包括仅可作为结束边界的最后时间点,方便取消选择。
const selected = point === this.bookingRangeStart || !!this.bookingRangeEnd && point >= this.bookingRangeStart && point <= this.bookingRangeEnd
const disabled = !selected && (choosingEnd ? !validEnd : !startSlot || startSlot.disabled)
return {key:point, point, label:point, disabled,
reason:disabled ? choosingEnd ? '不可作为结束' : startSlot ? startSlot.msg : '仅结束时间' : '',
selected,
mark:point === this.bookingRangeStart ? '起' : point === this.bookingRangeEnd ? '终' : ''}
})
} else {
items = this.bookingSlots.map(row => ({key:row.start_time + '-' + row.end_time, point:row.start_time,
label:row.start_time + ' - ' + row.end_time, disabled:row.disabled, reason:row.msg,
selected:(this.formData.time || []).includes(row.start_time + '-' + row.end_time), mark:''}))
}
return [{label:'上午(00:00 - 12:00',items:items.filter(row => row.point < '12:00')},
{label:'下午(12:00 - 24:00',items:items.filter(row => row.point >= '12:00')}].filter(group => group.items.length)
},
// 全天候场次仅在标题中合并为完整范围,保留原始时段供选择和提交;旧数据按分段展示。
displayedOpenHours() {
const hours = this.infoViewData.open_hours || []
if (Number(this.infoViewData.reserveTimeType || this.infoViewData.reservetimetype || 1) !== 2 || !hours.length) return hours
// 独立计算最早开始和最晚结束时间,不依赖接口顺序,也不修改原始数组。
return [{
start_time: hours.reduce((start, row) => row.start_time < start ? row.start_time : start, hours[0].start_time),
end_time: hours.reduce((end, row) => row.end_time > end ? row.end_time : end, hours[0].end_time)
}]
}
},
components: {
'guava': httpVueLoader('/components/plugins/Guava.vue'),
'reserve-info': httpVueLoader('/components/activity/site/ReserveInfo.vue?v=20260910-2'),
},
methods: {
// start/end为HH:mm边界,返回可提交场次数组;不连续、跨禁用段或边界不匹配时返回null。
rangeBookingSlots(start, end) {
if (!start || !end || end <= start) return null
const rows = this.bookingSlots.filter(row => row.start_time >= start && row.end_time <= end).slice().sort((a,b) => a.start_time.localeCompare(b.start_time))
if (!rows.length || rows[0].start_time !== start || rows[rows.length-1].end_time !== end || rows.some((row,index) => row.disabled || index > 0 && rows[index-1].end_time !== row.start_time)) return null
return rows.map(row => row.start_time + '-' + row.end_time)
},
clearBookingTime() {
this.$set(this,'bookingRangeStart','')
this.$set(this,'bookingRangeEnd','')
this.$set(this.formData,'time',[])
this.timeChange([])
},
// 全天候两次点击确定起止范围;分段切换单个场次,统一同步原接口使用的time数组。
chooseBookingTime(item) {
if (this.slotsLoading || this.formLoading || item.disabled) return
if (this.isFullDayBooking) {
// 全天候取消的是完整连续范围,不能留下中间缺口;只选起点时也允许再次点击清空。
if (item.point === this.bookingRangeStart || this.bookingRangeEnd && item.point >= this.bookingRangeStart && item.point <= this.bookingRangeEnd) {
this.clearBookingTime()
return
}
if (!this.bookingRangeStart || this.bookingRangeEnd) {
this.clearBookingTime()
this.$set(this,'bookingRangeStart',item.point)
return
}
const times = this.rangeBookingSlots(this.bookingRangeStart,item.point)
if (!times) return
this.$set(this,'bookingRangeEnd',item.point)
this.$set(this.formData,'time',times)
} else {
const times = (this.formData.time || []).slice()
const index = times.indexOf(item.key)
if (index >= 0) times.splice(index,1)
else times.push(item.key)
this.$set(this.formData,'time',times.sort())
}
this.timeChange(this.formData.time)
},
// 切换到表单及提交前共用校验,防止绕过第一步或提交加载失败、已失效的时段。
validateBookingTime(showMessage = true) {
const times = this.formData.time || []
const range = this.isFullDayBooking ? this.rangeBookingSlots(this.bookingRangeStart,this.bookingRangeEnd) : null
const valid = !this.slotsLoading && !this.slotsLoadFailed && times.length > 0
&& times.every(time => this.bookingSlots.some(row => !row.disabled && row.start_time + '-' + row.end_time === time))
&& (!this.isFullDayBooking || range && range.length === times.length && range.every(time => times.includes(time)))
if (!valid && showMessage) this.$message.warning('请选择完整且可预约的时间')
return !!valid
},
beforeBookingTabLeave(name) {
return !this.formLoading && (name !== 'info' || this.validateBookingTime())
},
changeBookingTab(name) {
if (!this.formLoading) this.$set(this,'bookingTab',name)
},
nextBookingTab() {
if (this.validateBookingTime()) this.changeBookingTab('info')
},
// 每次打开表单刷新组织信息;结果只用于展示,提交仍由服务端取得真实组织。
loadBookingUnion() {
const version=this.unionRequestVersion+1
@@ -589,9 +728,11 @@ layout("/layouts/platform.html"){
})
},
diaClose() {
this.$set(this.formData, 'time', [])
this.formData.start_time = ''
this.formData.end_time = ''
// 关闭后令在途时段请求失效,避免旧弹框响应污染下一次选择。
this.$set(this,'slotsVersion',this.slotsVersion+1)
this.$set(this,'slotsLoading',false)
this.$set(this,'bookingTab','time')
this.clearBookingTime()
},
timeChange(values) {
const times=values.slice().sort()
@@ -603,6 +744,7 @@ layout("/layouts/platform.html"){
const version=this.slotsVersion+1
this.$set(this,'slotsVersion',version)
this.$set(this,'slotsLoading',true)
this.$set(this,'slotsLoadFailed',false)
this.$set(this,'bookingSlots',[])
const days=this.dayArray.slice(), results=[]
let pending=days.length, failed=false
@@ -610,7 +752,7 @@ layout("/layouts/platform.html"){
days.forEach(day=>{
$.post('/mobile/activity/site/info/getReserve',{siteId:this.formData.site_id,day,reserveType:this.formData.reserve_type}).then(res=>{
if(version!==this.slotsVersion)return
if(res && res.code===0)results.push(res.data)
if(res && res.code===0 && Array.isArray(res.data))results.push(res.data.map(row => Object.assign({},row,{sourceDay:day})))
else{failed=true;this.$message.error(res && res.msg || '场次加载失败')}
},()=>{if(version===this.slotsVersion){failed=true;this.$message.error('场次加载失败')}}).always(()=>{
if(version!==this.slotsVersion)return
@@ -619,9 +761,16 @@ layout("/layouts/platform.html"){
if(!failed && results.length)this.$set(this,'bookingSlots',results[0].map(row=>{
const blocked=results.map(rows=>rows.find(item=>item.start_time===row.start_time && item.end_time===row.end_time)).find(item=>!item || item.code!==1)
const missing=results.some(rows=>!rows.some(item=>item.start_time===row.start_time && item.end_time===row.end_time))
return Object.assign({},row,{disabled:missing || !!blocked,msg:missing ? '场次已变化' : blocked ? blocked.msg : row.msg})
}))
return Object.assign({},row,{disabled:missing || !!blocked,msg:missing ? '场次已变化' : blocked ? blocked.sourceDay + ' ' + blocked.msg : row.msg})
}).sort((a,b)=>a.start_time.localeCompare(b.start_time)))
this.$set(this,'slotsLoadFailed',failed)
this.$set(this,'slotsLoading',false)
// 类型切换后仅保留仍有效的完整选择;失效则返回时间页重新选择。
if ((this.formData.time.length || this.bookingRangeStart) && !this.validateBookingTime(false)) {
this.clearBookingTime()
this.$set(this,'bookingTab','time')
this.$message.warning('所选时间已失效,请重新选择')
}
}
})
})
@@ -772,19 +921,13 @@ layout("/layouts/platform.html"){
this.$set(this.formData, 'reserve_type', 2)
// 新开申请时清空上次时段;类型切换刷新场次时不重置选择。
this.$set(this.formData, 'time', [])
this.timeChange([])
this.clearBookingTime()
this.$set(this,'bookingTab','time')
this.loadBookingUnion()
this.addDialogVisible = true
this.loadBookingSlots()
this.$nextTick(() => {
this.flushSliderWidth()
$(window).resize(() => {
vue.flushSliderWidth()
})
})
// 原滑块已停用,打开弹框不再读取不存在的滑块节点或注册resize事件。
},
doDelete(id, op) {
@@ -799,7 +942,8 @@ layout("/layouts/platform.html"){
return (moment("1970-01-01 " + hm).valueOf() + HOUR8) / HOUR1
},
doAdd() {
if (this.slotsLoading || !this.formData.time || !this.formData.time.length) { this.$message.warning('请选择预约时间'); return; }
if (this.formLoading) return
if (!this.validateBookingTime()) { this.$set(this,'bookingTab','time'); return; }
if (this.formData.reserve_type===3 && !this.formData.clubId) { this.$message.warning('请选择所属协会'); return; }
const selected=this.formData.time.slice().sort()
if(Number(this.infoViewData.reserveTimeType || this.infoViewData.reservetimetype || 1)===2 && selected.some((time,i)=>i>0 && selected[i-1].split('-')[1]!==time.split('-')[0])){