计划任务编辑详情前后端适配

This commit is contained in:
2026-02-05 13:45:39 +08:00
parent b0093ccccb
commit 8f6c9af00f
37 changed files with 2145 additions and 517 deletions

View File

@@ -29,7 +29,8 @@ import quant.rich.emoney.util.SpringContextHolder;
import okhttp3.OkHttpClient; import okhttp3.OkHttpClient;
/** /**
* 益盟操盘手基本请求客户端,提供基本功能 * 益盟操盘手基本请求客户端,提供基本功能,如登录和请求。
* <p>本类一般只提供静态方法,故不能也不该实例化本类。具体请求内容需要自己封装。整个系统共用一套鉴权,所以不要复制本例
* <p><b>请求头顺序</b></p> * <p><b>请求头顺序</b></p>
* <p> * <p>
* <ul> * <ul>
@@ -54,12 +55,13 @@ import okhttp3.OkHttpClient;
@Data @Data
@Slf4j @Slf4j
@Accessors(chain = true) @Accessors(chain = true)
public class EmoneyClient implements Cloneable { public class EmoneyClient {
private static final String MBS_URL = "https://mbs.emoney.cn/"; private static final String MBS_URL = "https://mbs.emoney.cn/";
private static final String STRATEGY_URL = "https://mbs.emoney.cn/strategy/"; private static final String STRATEGY_URL = "https://mbs.emoney.cn/strategy/";
private static final String LOGIN_URL = "https://emapp.emoney.cn/user/auth/login"; private static final String LOGIN_URL = "https://emapp.emoney.cn/user/auth/login";
private static final String RELOGIN_URL = "https://emapp.emoney.cn/user/auth/ReLogin"; private static final String RELOGIN_URL = "https://emapp.emoney.cn/user/auth/ReLogin";
private static final String STRATEGY_X_PROTOCOL_ID = "9400";
private static final String LOGIN_X_PROTOCOL_ID = "user%2Fauth%2Flogin"; private static final String LOGIN_X_PROTOCOL_ID = "user%2Fauth%2Flogin";
private static final String RELOGIN_X_PROTOCOL_ID = "user%2Fauth%2FReLogin"; private static final String RELOGIN_X_PROTOCOL_ID = "user%2Fauth%2FReLogin";
@@ -69,25 +71,28 @@ public class EmoneyClient implements Cloneable {
/** /**
* 根据 protocolId 返回 URL * 根据 protocolId 返回 URL
* <p>益盟操盘手对于不同的 protocolId 有不同的 URL 负责。在进行某类请求之前,请先通过调试 APP 进行确认,否则可能无法获取到相应内容 * <p>益盟操盘手对于不同的 protocolId 有不同的 URL 负责。在进行某类请求之前,请先通过调试 APP 进行确认,否则可能无法获取到相应内容
*
* @param protocolId * @param protocolId
* @return * @return 对应的 URL当所给 protocolId 为 null 或非 String/Integer 类型时,返回为 null使用前需要检查
*/ */
private static String getUrlByProtocolId(Serializable protocolId) { private static String getUrlByProtocolId(Serializable protocolId) {
String strProtocolId;
if (protocolId instanceof Integer intProtocolId) { if (protocolId instanceof Integer intProtocolId) {
switch (intProtocolId) { strProtocolId = String.valueOf(intProtocolId);
case 9400: return STRATEGY_URL;
default: return MBS_URL;
} }
else if (protocolId instanceof String s) {
strProtocolId = s;
}
else {
return null;
} }
else if (protocolId instanceof String strProtocolId) {
switch (strProtocolId) { switch (strProtocolId) {
case STRATEGY_X_PROTOCOL_ID: return STRATEGY_URL;
case LOGIN_X_PROTOCOL_ID: return LOGIN_URL; case LOGIN_X_PROTOCOL_ID: return LOGIN_URL;
case RELOGIN_X_PROTOCOL_ID: return RELOGIN_URL; case RELOGIN_X_PROTOCOL_ID: return RELOGIN_URL;
default: return null; default: return MBS_URL;
} }
} }
return null;
}
/** /**
* 从 Spring 上下文中获取由 RequestInfoService 管理的默认请求配置 * 从 Spring 上下文中获取由 RequestInfoService 管理的默认请求配置
@@ -126,6 +131,9 @@ public class EmoneyClient implements Cloneable {
return requestResponseInspectService; return requestResponseInspectService;
} }
/**
* 不允许外部实例化对象
*/
private EmoneyClient() {} private EmoneyClient() {}
/** /**
@@ -142,7 +150,6 @@ public class EmoneyClient implements Cloneable {
} }
} }
/** /**
* 使用系统管理的用户名密码登录 * 使用系统管理的用户名密码登录
* <p>建议仅在调试时使用,其他情况请用 {@code loginWithManaged()}</p> * <p>建议仅在调试时使用,其他情况请用 {@code loginWithManaged()}</p>
@@ -182,7 +189,7 @@ public class EmoneyClient implements Cloneable {
* 触发重登陆验证 * 触发重登陆验证
* @return * @return
*/ */
public static Boolean relogin() { public static Boolean reloginCheck() {
RequestInfo requestInfo = getDefaultRequestInfo(); RequestInfo requestInfo = getDefaultRequestInfo();
ObjectNode reloginObject = requestInfo.getReloginObject(); ObjectNode reloginObject = requestInfo.getReloginObject();
if (reloginObject == null) { if (reloginObject == null) {
@@ -250,11 +257,9 @@ public class EmoneyClient implements Cloneable {
*/ */
private static Boolean login(ObjectNode formObject) { private static Boolean login(ObjectNode formObject) {
try { try {
//OkHttpClient okHttpClient = new OkHttpClient();
RequestInfo requestInfo = getDefaultRequestInfo(); RequestInfo requestInfo = getDefaultRequestInfo();
OkHttpClient okHttpClient = OkHttpClientProvider.getInstance(); OkHttpClient okHttpClient = OkHttpClientProvider.getInstance();
MediaType type = MediaType.parse("application/json"); MediaType type = MediaType.parse("application/json");
//type.charset(StandardCharsets.UTF_8);
byte[] content = formObject.toString().getBytes("utf-8"); byte[] content = formObject.toString().getBytes("utf-8");
RequestBody body = RequestBody.create( RequestBody body = RequestBody.create(
content, type); content, type);
@@ -273,7 +278,7 @@ public class EmoneyClient implements Cloneable {
.header("Authorization", "") .header("Authorization", "")
.header("X-Android-Agent", requestInfo.getXAndroidAgent()) .header("X-Android-Agent", requestInfo.getXAndroidAgent())
.header("Emapp-ViewMode", requestInfo.getEmappViewMode()); .header("Emapp-ViewMode", requestInfo.getEmappViewMode());
//.header("User-Agent", requestInfo.getOkHttpUserAgent()) //此处 User-Agent 由 ByteBuddy 拦截修改
Request request = requestBuilder.build(); Request request = requestBuilder.build();
@@ -312,8 +317,10 @@ public class EmoneyClient implements Cloneable {
/** /**
* 获取基本返回 Base_Response * 获取基本返回 Base_Response
* *
* @param <T> * @param <T> 请求类型泛型
* @param nanoRequest * @param nanoRequest 请求
* @param xProtocolId 请求对应的 Protocol ID
* @param xRequestId 视具体请求而定。可能是 null也可能是 String.valueOf(System.currentTimeMillis())
* @return * @return
*/ */
protected static <T extends MessageNano> BaseResponse.Base_Response post( protected static <T extends MessageNano> BaseResponse.Base_Response post(
@@ -355,8 +362,8 @@ public class EmoneyClient implements Cloneable {
.header("EM-Sign", EncryptUtils.getEMSign(content, "POST", xProtocolId.toString())) .header("EM-Sign", EncryptUtils.getEMSign(content, "POST", xProtocolId.toString()))
.header("Authorization", requestInfo.getAuthorization()) .header("Authorization", requestInfo.getAuthorization())
.header("X-Android-Agent", requestInfo.getXAndroidAgent()) .header("X-Android-Agent", requestInfo.getXAndroidAgent())
.header("Emapp-ViewMode", requestInfo.getEmappViewMode()) .header("Emapp-ViewMode", requestInfo.getEmappViewMode());
; //此处 User-Agent 由 ByteBuddy 拦截修改
Request request = requestBuilder.build(); Request request = requestBuilder.build();
@@ -377,15 +384,20 @@ public class EmoneyClient implements Cloneable {
} }
/** /**
* 根据指定 clazz 获取返回 * 请求并返回
* * @param <T> 请求类型泛型
* @param <T> * @param <U> 返回类型泛型
* @param <U> * @param nanoRequest 请求
* @param nanoRequest * @param clazz 返回类型
* @param clazz * @param xProtocolId 请求对应的 Protocol ID
* @param xRequestId 视具体请求而定。可能是 null也可能是 String.valueOf(System.currentTimeMillis())
* @return * @return
*/ */
public static <T extends MessageNano, U extends MessageNano> U post(T nanoRequest, Class<U> clazz, Serializable xProtocolId, Serializable xRequestId) { public static <T extends MessageNano, U extends MessageNano> U post(
T nanoRequest,
Class<U> clazz,
Serializable xProtocolId,
Serializable xRequestId) {
BaseResponse.Base_Response baseResponse; BaseResponse.Base_Response baseResponse;
try { try {

View File

@@ -29,6 +29,8 @@ import quant.rich.emoney.util.SpringContextHolder;
/** /**
* OkHttpClient 提供器 * OkHttpClient 提供器
* <p>
* 此处提供的 OkHttpClient 方便使用平台配置的代理,方便是否启用 HTTPS 证书认证等
* @see quant.rich.emoney.entity.config.ProxyConfig * @see quant.rich.emoney.entity.config.ProxyConfig
* @see okhttp3.internal.http.BridgeInterceptor * @see okhttp3.internal.http.BridgeInterceptor
*/ */

View File

@@ -57,7 +57,7 @@ public class RequireAuthAndProxyAspect {
throw new RuntimeException("鉴权登录失败"); throw new RuntimeException("鉴权登录失败");
} }
} }
else if (!EmoneyClient.relogin()) { else if (!EmoneyClient.reloginCheck()) {
throw new RuntimeException("检查重鉴权失败"); throw new RuntimeException("检查重鉴权失败");
} }

View File

@@ -26,7 +26,10 @@ public class SecurityConfig {
@Bean @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http http
.headers(headers -> headers.cacheControl(cache -> cache.disable())) .headers(headers -> headers
.cacheControl(cache -> cache.disable())
.frameOptions(f -> f.sameOrigin())
)
.csrf(csrf -> csrf.disable()) .csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers("/favicon.ico").permitAll() .requestMatchers("/favicon.ico").permitAll()

View File

@@ -29,21 +29,8 @@ public class ErrorPageController implements ErrorController {
@GetMapping(value = ERROR_PATH) @GetMapping(value = ERROR_PATH)
@PostMapping(value = ERROR_PATH) @PostMapping(value = ERROR_PATH)
public String errorHtml(HttpServletRequest request) { public String errorHtml(HttpServletRequest request) {
HttpStatus status = getStatus(request);
String prefix = "error/"; String prefix = "error/";
return prefix + "error_400"; return prefix + "error_400";
// switch (status) {
// case BAD_REQUEST:
// return prefix + "error_400";
// case NOT_FOUND:
// return prefix + "error_404";
// case METHOD_NOT_ALLOWED:
// return prefix + "error_405";
// default:
// return prefix + "error_5xx";
// }
} }
@GetMapping(value = ERROR_PATH, produces = "application/json") @GetMapping(value = ERROR_PATH, produces = "application/json")

View File

@@ -13,6 +13,8 @@ import org.springframework.web.bind.annotation.RestController;
import com.google.code.kaptcha.impl.DefaultKaptcha; import com.google.code.kaptcha.impl.DefaultKaptcha;
import quant.rich.emoney.service.AuthService; import quant.rich.emoney.service.AuthService;
import quant.rich.emoney.util.ArithmeticCaptchaGen;
import quant.rich.emoney.util.ArithmeticCaptchaGen.Captcha;
@RestController @RestController
@RequestMapping("/captcha") @RequestMapping("/captcha")
@@ -20,13 +22,13 @@ public class KaptchaController extends BaseController {
@Autowired @Autowired
DefaultKaptcha kaptcha; DefaultKaptcha kaptcha;
@GetMapping(value = "/get", produces = MediaType.IMAGE_JPEG_VALUE) @GetMapping(value = "/get", produces = MediaType.IMAGE_JPEG_VALUE)
public byte[] getCaptcha() throws Exception { public byte[] getCaptcha() throws Exception {
String createText = kaptcha.createText();
Captcha captcha = ArithmeticCaptchaGen.generate();
ByteArrayOutputStream os = new ByteArrayOutputStream(); ByteArrayOutputStream os = new ByteArrayOutputStream();
session.setAttribute(AuthService.CAPTCHA, createText); session.setAttribute(AuthService.CAPTCHA, captcha.answer());
ImageIO.write(kaptcha.createImage(createText), "jpg", os); ImageIO.write(kaptcha.createImage(captcha.expr()), "jpg", os);
byte[] result = os.toByteArray(); byte[] result = os.toByteArray();
os.close(); os.close();
return result; return result;

View File

@@ -2,20 +2,29 @@ package quant.rich.emoney.controller.manage;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Objects; import java.util.Objects;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import quant.rich.emoney.controller.common.UpdateBoolServiceController; import quant.rich.emoney.controller.common.UpdateBoolServiceController;
import quant.rich.emoney.entity.sqlite.Plan; import quant.rich.emoney.entity.sqlite.Plan;
import quant.rich.emoney.enums.PlanType;
import quant.rich.emoney.exception.PageNotFoundException;
import quant.rich.emoney.exception.RException; import quant.rich.emoney.exception.RException;
import quant.rich.emoney.interfaces.IQueryableEnum; import quant.rich.emoney.interfaces.IQueryableEnum;
import quant.rich.emoney.pojo.dto.LayPageReq; import quant.rich.emoney.pojo.dto.LayPageReq;
@@ -31,6 +40,9 @@ public class PlanControllerV1 extends UpdateBoolServiceController<Plan> {
@Autowired @Autowired
PlanService planService; PlanService planService;
@Autowired
ObjectMapper objectMapper;
@GetMapping({"", "/", "/index"}) @GetMapping({"", "/", "/index"})
public String index() { public String index() {
return "/admin/v1/manage/plan/index"; return "/admin/v1/manage/plan/index";
@@ -48,6 +60,60 @@ public class PlanControllerV1 extends UpdateBoolServiceController<Plan> {
return super.getOne(planId); return super.getOne(planId);
} }
/**
* 通用编辑接口,跳转对应模式的编辑页
* @param planId
* @param ra
* @return
*/
@GetMapping("/edit")
public String edit(Integer planId, RedirectAttributes ra) {
Plan plan = null;
if (planId != null) {
plan = planService.getById(planId);
}
if (plan == null) {
plan = new Plan();
}
if (plan.getPlanId() != null) {
ra.addAttribute("planId", plan.getPlanId());
}
if (plan.getPlanType() == null || plan.getPlanType() == PlanType.SINGLE_INDEX) {
return "redirect:editSingleIndex";
}
else if (plan.getPlanType() == PlanType.MULTI_INDEX) {
return "redirect:editMultiIndex";
}
else if (plan.getPlanType() == PlanType.STOCK_STRATEGY) {
return "redirect:editStockStrategy";
}
throw new PageNotFoundException();
}
@GetMapping("/edit{planTypeString}")
public String editPlanType(@PathVariable String planTypeString, Integer planId) {
Optional<PlanType> optional = tryParseEnum(PlanType.class, planTypeString);
if (optional.isEmpty()) {
throw new PageNotFoundException();
}
Plan plan = null;
if (planId != null) {
plan = planService.getById(planId);
}
if (plan == null) {
plan = new Plan();
}
String planJsonString = "{}";
try {
planJsonString = objectMapper.writeValueAsString(plan);
}
catch (Exception e) {}
request.setAttribute("plan", plan);
request.setAttribute("planType", optional.get().toString());
request.setAttribute("planJsonString", planJsonString);
return "/admin/v1/manage/plan/edit" + planTypeString;
}
@PostMapping("/save") @PostMapping("/save")
@ResponseBody @ResponseBody
public R<?> save(@RequestBody Plan plan) { public R<?> save(@RequestBody Plan plan) {
@@ -120,4 +186,62 @@ public class PlanControllerV1 extends UpdateBoolServiceController<Plan> {
} }
} }
/**
* 将大驼峰转换为大写+下划线分隔形式
* @param s
* @return
*/
public static String toUpperSnake(String s) {
if (s == null) return null;
s = s.trim();
if (s.isEmpty()) return s;
// 先把空格/连字符这类统一成下划线(可按需扩展)
s = s.replace('-', '_').replace(' ', '_');
// 1) fooBar / foo1Bar -> foo_Bar / foo1_Bar
s = s.replaceAll("([a-z0-9])([A-Z])", "$1_$2");
// 2) HTTPServer -> HTTP_Server在 "P" 和 "S" 之间插)
s = s.replaceAll("([A-Z]+)([A-Z][a-z])", "$1_$2");
// 合并多余下划线
s = s.replaceAll("_+", "_");
// 去掉首尾下划线
s = s.replaceAll("^_+|_+$", "");
return s.toUpperCase(Locale.ROOT);
}
public static <E extends Enum<E>> Optional<E> tryParseEnum(Class<E> enumClass, String input) {
if (enumClass == null || input == null) return Optional.empty();
String raw = input.trim();
if (raw.isEmpty()) return Optional.empty();
// 1) 直接按原样尝试(允许用户本来就传了正确的常量名)
E e = tryValueOf(enumClass, raw);
if (e != null) return Optional.of(e);
// 2) 尝试 UPPER_SNAKE_CASE
String upperSnake = toUpperSnake(raw);
e = tryValueOf(enumClass, upperSnake);
if (e != null) return Optional.of(e);
// 3) 兜底:忽略大小写匹配(成本 O(n),但很稳)
for (E constant : enumClass.getEnumConstants()) {
if (constant.name().equalsIgnoreCase(raw) || constant.name().equalsIgnoreCase(upperSnake)) {
return Optional.of(constant);
}
}
return Optional.empty();
}
private static <E extends Enum<E>> E tryValueOf(Class<E> enumClass, String name) {
try {
return Enum.valueOf(enumClass, name);
} catch (IllegalArgumentException ex) {
return null;
}
}
} }

View File

@@ -0,0 +1,20 @@
package quant.rich.emoney.crawler;
import lombok.Data;
import lombok.experimental.Accessors;
/**
* 益盟爬虫爬取成功与否及对应的爬虫配置,方便重新爬取
* @author Barry
*
*/
@Data
@Accessors(chain=true)
public class EmoneyCrawlerResult<T> {
private T callable;
private boolean success;
private Exception exception;
}

View File

@@ -0,0 +1,204 @@
package quant.rich.emoney.crawler;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.function.Function;
import java.util.stream.Collectors;
import lombok.AccessLevel;
import lombok.Data;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import nano.CandleStickNewResponse.CandleStickNew_Response.CandleStick;
import nano.CandleStickNewWithIndexExResponse.CandleStickNewWithIndexEx_Response;
import nano.CandleStickNewWithIndexExResponse.CandleStickNewWithIndexEx_Response.IndLine;
import nano.CandleStickRequest.CandleStick_Request;
import nano.CandleStickWithIndexRequest.CandleStickWithIndex_Request;
import nano.CandleStickWithIndexRequest.CandleStickWithIndex_Request.IndexInfo;
import nano.IndexCalcNewExResponse.IndexCalcNewEx_Response.outputline;
import quant.rich.emoney.client.EmoneyClient;
import quant.rich.emoney.entity.postgre.EmoneyIndex;
import quant.rich.emoney.enums.StockSpan;
import quant.rich.emoney.exception.EmoneyRequestException;
import quant.rich.emoney.util.DateUtils;
import quant.rich.emoney.util.StockCodeUtils;
/**
* 执行爬虫时最终被调用的方法。该方法使用 2422多指标、带 K 线)更新指标数据
*/
@Data
@Slf4j
public class EmoneyIndexCallable implements Callable<EmoneyCrawlerResult<EmoneyIndexCallable>> {
private static final int LIMIT_SIZE = 250;
private EmoneyCrawlerResult<EmoneyIndexCallable> result;
private String tsCode;
private StockSpan stockSpan;
private Long beginPosition;
private Long totalNeeds;
private Function<List<EmoneyIndex>, Boolean> func;
private IndexInfo[] indexInfos;
@Setter(AccessLevel.PRIVATE)
private String indexNames;
public EmoneyIndexCallable setIndexInfos(IndexInfo[] indexInfos) {
this.indexInfos = indexInfos;
indexNames = Arrays.stream(indexInfos)
.map(IndexInfo::getIndexName)
.collect(Collectors.joining(", "));
return this;
}
/**
* 初始化一个 Callable
* @param tsCode
* @param beginPosition 起始位置,一般为当日或当时,如 20230921L
* @param stockSpan 数据粒度
* @param totalNeeds 经过计算需要的总数据数
* @param func 消费 {@code List<EmoneyIndex>} 的方法,返回的布尔值辅助判断本次调用是否成功
* @param indexInfos 指标类型数组
*/
public EmoneyIndexCallable(
String tsCode,
Long beginPosition,
StockSpan stockSpan,
Long totalNeeds,
Function<List<EmoneyIndex>, Boolean> func,
IndexInfo...indexInfos) {
this.tsCode = tsCode;
this.beginPosition = beginPosition;
this.stockSpan = stockSpan;
setIndexInfos(indexInfos);
this.totalNeeds = totalNeeds;
this.func = func;
}
/**
* 从 EmoneyIndexCallable 恢复 Callable
* @param request
*/
public EmoneyIndexCallable(EmoneyIndexCallable other) {
this.tsCode = other.tsCode;
this.beginPosition = other.beginPosition;
this.stockSpan = other.stockSpan;
this.indexInfos = other.indexInfos;
this.indexNames = other.indexNames;
this.totalNeeds = other.totalNeeds;
this.func = other.func;
}
@Override
public EmoneyCrawlerResult<EmoneyIndexCallable> call() throws Exception {
List<EmoneyIndex> emoneyIndexList = new ArrayList<>();
while (totalNeeds > 0) {
// 组装请求
/*
* 2422 CANDLE_STICK_V3
* 该种方式除返回指标和对应时间外,还会返回对应时间粒度的 K 线(蜡烛图),数据量会比 2921 大。
* 使用 beginPosition, 如 20231230L 和 limitSize, 如 400 向前追溯
*/
CandleStickWithIndex_Request request = new CandleStickWithIndex_Request();
CandleStick_Request candleRequest = new CandleStick_Request();
candleRequest.setBeginPosition(beginPosition)
.setDataPeriod(stockSpan.getEmoneyCode())
.setExFlag(0)
.setGoodsId(StockCodeUtils.tsCodeToGoodsId(tsCode))
.setLastVolume(0)
.setLimitSize(Math.min(totalNeeds, LIMIT_SIZE))
.setReqFhsp(false)
.setReqHisShares(false);
request.candleRequest = candleRequest;
/*IndexInfo[] indexInfos = new IndexInfo[indexNames.length];
for (int i = 0; i < indexNames.length; i++) {
IndexInfo indexInfo = new IndexInfo().setIndexName(indexNames[i]);
indexInfos[i] = indexInfo;
}*/
request.indexRequest = indexInfos;
result = new EmoneyCrawlerResult<EmoneyIndexCallable>().setCallable(this).setSuccess(false);
CandleStickNewWithIndexEx_Response response;
try {
// 时间戳作为 xRequestId
response = EmoneyClient.post(
request,
CandleStickNewWithIndexEx_Response.class,
2422,
System.currentTimeMillis());
}
catch (EmoneyRequestException e) {
return result.setSuccess(false).setException(e);
}
log.info("Emoney index [{}] for {} called, totalNeeds left: {}, current received length/limitSize: {}/{}",
indexNames, tsCode, totalNeeds, response.kLines.length, LIMIT_SIZE);
for (int i = 0; i < response.kLines.length; i++) {
CandleStick candleStick = response.kLines[i];
long dateTime = candleStick.getDatetime();
LocalDateTime date = DateUtils.longDatetimeToLocalDateTime(dateTime);
for (IndexInfo indexInfo : indexInfos) {
IndLine indLine = response.indexDatas.get(indexInfo.getIndexName());
for (outputline lineValue : indLine.lineValue) {
EmoneyIndex emoneyIndex = new EmoneyIndex()
.setIndexName(indexInfo.getIndexName())
.setDate(date).setLineName(lineValue.getLineName())
.setLineShape(lineValue.getLineShape())
.setStockSpan(stockSpan)
.setTsCode(tsCode)
.setValue(lineValue.lineData[i]);
emoneyIndexList.add(emoneyIndex);
}
}
}
Thread.sleep(2500);
// 判断数据是否结束了
if (response.kLines.length < LIMIT_SIZE) {
break;
}
totalNeeds -= LIMIT_SIZE;
// 重设起始点为返回数据的最旧时间
beginPosition = response.kLines[0].getDatetime();
}
Boolean b = true;
if (!emoneyIndexList.isEmpty()) {
try {
b = func.apply(emoneyIndexList);
}
catch (Exception e) {
return result.setSuccess(false).setException(e);
}
}
return result.setSuccess(b);
}
}

View File

@@ -1,6 +1,6 @@
package quant.rich.emoney.entity.postgre; package quant.rich.emoney.entity.postgre;
import java.util.Date; import java.time.LocalDateTime;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
@@ -36,7 +36,7 @@ public class EmoneyIndex {
* 时间 * 时间
*/ */
@TableField("trade_date") @TableField("trade_date")
private Date date; private LocalDateTime date;
/** /**
* 指标值 * 指标值
*/ */

View File

@@ -1,14 +1,5 @@
package quant.rich.emoney.entity.sqlite; package quant.rich.emoney.entity.sqlite;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.springframework.util.CollectionUtils;
import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableId;
@@ -17,7 +8,7 @@ import com.fasterxml.jackson.databind.JsonNode;
import lombok.Data; import lombok.Data;
import lombok.experimental.Accessors; import lombok.experimental.Accessors;
import quant.rich.emoney.mybatis.typehandler.CommaListTypeHandler; import quant.rich.emoney.enums.PlanType;
import quant.rich.emoney.mybatis.typehandler.JsonStringTypeHandler; import quant.rich.emoney.mybatis.typehandler.JsonStringTypeHandler;
/** /**
@@ -27,6 +18,7 @@ import quant.rich.emoney.mybatis.typehandler.JsonStringTypeHandler;
* <li>个股策略(/strategy, id=9400)的抓取 * <li>个股策略(/strategy, id=9400)的抓取
* <li>选股策略的抓取 * <li>选股策略的抓取
* </ul> * </ul>
* 选股策略的抓取的筛选器自带股票过滤。其他抓取应该可以选股策略抓取的结果、tushare 的直接结果作为抓取的范围。
* 未来可能存在更多的计划任务, 所以需要考虑如何设计才能更好地兼容后续添加的任务类型 * 未来可能存在更多的计划任务, 所以需要考虑如何设计才能更好地兼容后续添加的任务类型
*/ */
@Data @Data
@@ -38,7 +30,7 @@ public class Plan {
* 键 ID * 键 ID
*/ */
@TableId(type = IdType.AUTO) @TableId(type = IdType.AUTO)
private String planId; private Integer planId;
/** /**
* 计划任务表达式 * 计划任务表达式
@@ -51,21 +43,15 @@ public class Plan {
private String planName; private String planName;
/** /**
* 指标代码 * 计划类型
*/ */
private String indexCode; private PlanType planType;
/**
* 需要抓取的指标周期
*/
@TableField(typeHandler = CommaListTypeHandler.class)
private List<String> periods;
/** /**
* 参数 * 参数
*/ */
@TableField(typeHandler = JsonStringTypeHandler.class) @TableField(typeHandler = JsonStringTypeHandler.class)
private JsonNode params; private JsonNode detail;
/** /**
* 是否启用 * 是否启用
@@ -78,33 +64,4 @@ public class Plan {
*/ */
private Boolean openDayCheck; private Boolean openDayCheck;
/**
* 设置抓取周期并去重
* @param periods
* @return
*/
public Plan setPeriods(List<String> periods) {
if (CollectionUtils.isEmpty(periods)) {
this.periods = Collections.emptyList();
return this;
}
Set<String> hashSet = new HashSet<>();
periods.forEach(s -> {
if (StringUtils.isNotBlank(s)) {
hashSet.add(s.trim());
}
});
this.periods = new ArrayList<>(hashSet);
return this;
}
/**
* 获取抓取周期。已去重
* @return
*/
public List<String> getPeriods() {
setPeriods(periods);
return periods;
}
} }

View File

@@ -0,0 +1,20 @@
package quant.rich.emoney.enums;
/**
* 计划任务类型枚举
*/
public enum PlanType {
/**
* 单指标
*/
SINGLE_INDEX,
/**
* 多指标
*/
MULTI_INDEX,
STOCK_STRATEGY,
STOCK_PICKING
}

View File

@@ -1,6 +1,8 @@
package quant.rich.emoney.mapper.postgre; package quant.rich.emoney.mapper.postgre;
import java.util.Collection;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import com.baomidou.dynamic.datasource.annotation.DS; import com.baomidou.dynamic.datasource.annotation.DS;
@@ -13,4 +15,7 @@ import quant.rich.emoney.entity.postgre.EmoneyIndex;
@DS("postgre") @DS("postgre")
public interface EmoneyIndexMapper extends BaseMapper<EmoneyIndex> { public interface EmoneyIndexMapper extends BaseMapper<EmoneyIndex> {
int insertOrUpdateBatch(
@Param("list") Collection<EmoneyIndex> list,
@Param("batchSize") int batchSize);
} }

View File

@@ -0,0 +1,72 @@
package quant.rich.emoney.pojo;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.springframework.util.CollectionUtils;
import lombok.Data;
import lombok.experimental.Accessors;
@Data
@Accessors(chain=true)
public class MultiIndexPlanDetail {
/**
* 指标
*/
List<MultiIndexPlanPart> indexes;
/**
* 抓取的 K 线粒度
*/
List<Integer> periods;
/**
* 设置抓取周期并去重
* @param periods
* @return
*/
public MultiIndexPlanDetail setPeriods(List<Integer> periods) {
if (CollectionUtils.isEmpty(periods)) {
this.periods = Collections.emptyList();
return this;
}
Set<Integer> hashSet = new HashSet<>();
periods.forEach(period -> {
if (period != null) {
hashSet.add(period);
}
});
this.periods = new ArrayList<>(hashSet);
return this;
}
/**
* 获取抓取周期。已去重
* @return
*/
public List<Integer> getPeriods() {
setPeriods(periods);
return periods;
}
@Data
@Accessors(chain=true)
public static class MultiIndexPlanPart {
/**
* 单指标代码
*/
String indexCode;
/**
* 单指标参数
*/
Map<String, String> params;
}
}

View File

@@ -0,0 +1,62 @@
package quant.rich.emoney.pojo;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.springframework.util.CollectionUtils;
import lombok.Data;
import lombok.experimental.Accessors;
@Data
@Accessors(chain=true)
public class SingleIndexPlanDetail {
/**
* 单指标代码
*/
String indexCode;
/**
* 单指标参数
*/
Map<String, String> params;
/**
* 抓取的 K 线粒度
*/
List<Integer> periods;
/**
* 设置抓取周期并去重
* @param periods
* @return
*/
public SingleIndexPlanDetail setPeriods(List<Integer> periods) {
if (CollectionUtils.isEmpty(periods)) {
this.periods = Collections.emptyList();
return this;
}
Set<Integer> hashSet = new HashSet<>();
periods.forEach(period -> {
if (period != null) {
hashSet.add(period);
}
});
this.periods = new ArrayList<>(hashSet);
return this;
}
/**
* 获取抓取周期。已去重
* @return
*/
public List<Integer> getPeriods() {
setPeriods(periods);
return periods;
}
}

View File

@@ -0,0 +1,20 @@
package quant.rich.emoney.service.postgre;
import java.util.Collection;
import org.springframework.transaction.annotation.Transactional;
import com.baomidou.mybatisplus.extension.toolkit.SqlHelper;
import quant.rich.emoney.config.PostgreMybatisConfig;
import quant.rich.emoney.entity.postgre.EmoneyIndex;
import quant.rich.emoney.mapper.postgre.EmoneyIndexMapper;
public class EmoneyIndexService extends PostgreServiceImpl<EmoneyIndexMapper, EmoneyIndex> {
@Override
@Transactional(transactionManager = PostgreMybatisConfig.POSTGRE_TRANSACTION_MANAGER, rollbackFor = Exception.class)
public boolean saveOrUpdateBatch(Collection<EmoneyIndex> entityList, int batchSize) {
return SqlHelper.retBool(baseMapper.insertOrUpdateBatch(entityList, batchSize));
}
}

View File

@@ -6,10 +6,16 @@ import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import feign.Headers;
/** /**
* 对接 TushareDataService 的 Feign 客户端 * 对接 TushareDataService 的 Feign 客户端
* <p>
* 服务器上配置了 fail2ban 对默认 User-Agent
* 拦截Java/*),所以这里自定义 User-Agent
*/ */
@FeignClient(name="tushare-data-service-client", url="http://localhost:9999") @Headers("User-Agent: At17DataService/1.0")
@FeignClient(name="tushare-data-service-client", url="https://tushare.database.at17.link")
public interface TushareDataServiceClient { public interface TushareDataServiceClient {
@GetMapping("/api/v1/common/stockInfo/list") @GetMapping("/api/v1/common/stockInfo/list")

View File

@@ -0,0 +1,164 @@
package quant.rich.emoney.util;
import java.security.SecureRandom;
public class ArithmeticCaptchaGen {
private static final SecureRandom RND = new SecureRandom();
private static final char[] OPS = new char[]{'+', '-', '×', '÷'};
public static class Captcha {
public final int a;
public final int b;
public final char op;
public Captcha(int a, int b, char op) {
this.a = a;
this.b = b;
this.op = op;
}
public String expr() {
return a + " " + op + " " + b;
}
public int answer() {
return switch (op) {
case '+' -> a + b;
case '-' -> a - b;
case '×' -> a * b;
case '÷' -> a / b; // 保证整除
default -> throw new IllegalStateException("Unexpected op: " + op);
};
}
@Override
public String toString() {
return expr() + " = ?";
}
}
/** 对外:随机生成一题 */
public static Captcha generate() {
char op = OPS[RND.nextInt(OPS.length)];
return generate(op);
}
/** 对外:指定运算符生成 */
public static Captcha generate(char op) {
return switch (op) {
case '+' -> genAddCarryAllowedButIfCarryUnitsZero();
case '-' -> genSubNoBorrowNonNegative();
case '×' -> genMulNoCarryOneDigit();
case '÷' -> genDivExact();
default -> throw new IllegalArgumentException("Unsupported op: " + op);
};
}
// ---------------- +:允许进位;若发生进位,则结果个位必须为 0且和 <= 100 ----------------
private static Captcha genAddCarryAllowedButIfCarryUnitsZero() {
// 仍保留 100 的特殊题(不是必须,但能保证覆盖 100
if (RND.nextInt(20) == 0) { // 5% 概率出 100
if (RND.nextBoolean()) return new Captcha(100, 0, '+');
return new Captcha(0, 100, '+');
}
while (true) {
int a = RND.nextInt(101); // 0..100
int b = RND.nextInt(101); // 0..100
int sum = a + b;
if (sum > 100) continue;
if (carryHappened(a, b)) {
// 发生进位:个位必须为 0
if (sum % 10 == 0) return new Captcha(a, b, '+');
} else {
// 未发生进位:不限制个位
return new Captcha(a, b, '+');
}
}
}
/** 判断十进制逐位相加是否发生过进位(任意一位) */
private static boolean carryHappened(int a, int b) {
int carry = 0;
while (a > 0 || b > 0) {
int da = a % 10;
int db = b % 10;
int s = da + db + carry;
if (s >= 10) return true;
carry = 0; // 因为 s<10 时 carry 必为 0
a /= 10;
b /= 10;
}
return false;
}
// ---------------- -:无退位,差 >= 0 ----------------
private static Captcha genSubNoBorrowNonNegative() {
while (true) {
int a = RND.nextInt(100); // 0..99
int b = RND.nextInt(100); // 0..99
int aT = a / 10, aU = a % 10;
int bT = b / 10, bU = b % 10;
// 无退位:每一位都要 a>=b且整体差>=0
if (aU >= bU && aT >= bT) {
return new Captcha(a, b, '-'); // 此时 a>=b 自然成立
}
}
}
// ---------------- *:无进位;一元为 0..9;另一元每位*d < 10 ----------------
private static Captcha genMulNoCarryOneDigit() {
while (true) {
int d = RND.nextInt(10); // 0..9 其中一个因子
int x = RND.nextInt(100); // 另一元 0..99(你可自行放大范围)
// 如果 d=0任何 x 都满足
if (d == 0) return orderMulOperands(d, x);
// 对 x 的每一位要求digit*d < 10 才不会在该位产生进位
if (allDigitsMulLessThan10(x, d)) {
return orderMulOperands(d, x);
}
}
}
private static Captcha orderMulOperands(int d, int x) {
// 随机决定把 0..9 放左边还是右边
if (RND.nextBoolean()) return new Captcha(d, x, '×');
return new Captcha(x, d, '×');
}
private static boolean allDigitsMulLessThan10(int x, int d) {
// x 是非负
if (x == 0) return true;
int t = x;
while (t > 0) {
int digit = t % 10;
if (digit * d >= 10) return false;
t /= 10;
}
return true;
}
// ---------------- /:必须整除 ----------------
private static Captcha genDivExact() {
// 控制题目大小: dividend <= 100
while (true) {
int b = 1 + RND.nextInt(10); // 除数 1..10
int q = RND.nextInt(21); // 商 0..20
int a = b * q; // 被除数
if (a <= 100) return new Captcha(a, b, '÷');
}
}
// demo
public static void main(String[] args) {
for (int i = 0; i < 100; i++) {
Captcha c = generate();
System.out.println(c.expr() + " = " + c.answer());
}
}
}

View File

@@ -0,0 +1,28 @@
package quant.rich.emoney.util;
import java.util.*;
public class ChunkRandomIter {
final static Random RANDOM = new Random();
public static <T> List<T> splitShuffleAndFlatten(List<T> list, int n) {
int size = list.size();
if (n <= 0) throw new IllegalArgumentException("n must be > 0");
n = Math.min(n, size == 0 ? 1 : size);
List<T> parts = new ArrayList<>(n);
int base = size / n; // 每份至少 base 个
int extra = size % n; // 前 extra 份多一个
int idx = 0;
for (int i = 0; i < n; i++) {
int partSize = base + (i < extra ? 1 : 0);
List<T> part = new ArrayList<>(list.subList(idx, idx + partSize));
Collections.shuffle(part, RANDOM);
parts.addAll(part);
idx += partSize;
}
return parts;
}
}

View File

@@ -1,5 +1,6 @@
package quant.rich.emoney.util; package quant.rich.emoney.util;
import java.time.DateTimeException;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
@@ -55,4 +56,40 @@ public final class DateUtils {
LocalDate date = LocalDate.parse(value, formatter); LocalDate date = LocalDate.parse(value, formatter);
return date; return date;
} }
/**
* 将数字类型日期转换成相应的 LocalDateTime
* <p>
* <li> 2309191130 → 2023-09-19 11:30:00
* <li> 230919 → 2023-09-19
*/
public static LocalDateTime longDatetimeToLocalDateTime(long dateTime) {
if (dateTime <= 0) {
throw new IllegalArgumentException("dateTime must be positive, got: " + dateTime);
}
try {
if (dateTime > 99_999_999L) {
// yyMMddHHmm 例如2309191130 -> 2023-09-19 11:30
int yy = (int) (dateTime / 100_000_000L); // 23
int year = 2000 + yy; // 2023
int month = (int) ((dateTime % 100_000_000L) / 1_000_000L); // 09
int day = (int) ((dateTime % 1_000_000L) / 10_000L); // 19
int hour = (int) ((dateTime % 10_000L) / 100L); // 11
int minute = (int) (dateTime % 100L); // 30
return LocalDateTime.of(year, month, day, hour, minute, 0);
} else {
// yyyyMMdd 例如20230919 -> 2023-09-19 00:00
int year = (int) (dateTime / 10_000L); // 2023
int month = (int) ((dateTime % 10_000L) / 100L); // 09
int day = (int) (dateTime % 100L); // 19
return LocalDateTime.of(year, month, day, 0, 0, 0);
}
} catch (DateTimeException ex) {
// 月/日/时/分越界会进这里(比如 month=13
throw new IllegalArgumentException("Invalid dateTime value: " + dateTime, ex);
}
}
} }

View File

@@ -19,10 +19,7 @@ spring:
devtools: devtools:
restart: restart:
enabled: true enabled: true
additional-exclude: additional-exclude: '**/*.html,**/*.js,**/*.css'
- '**/*.html'
- '**/*.js'
- '**/*.css'
additional-paths: lib/ additional-paths: lib/
jackson: jackson:
date-format: yyyy-MM-dd HH:mm:ss date-format: yyyy-MM-dd HH:mm:ss

Binary file not shown.

View File

@@ -3,11 +3,14 @@
"https://mybatis.org/dtd/mybatis-3-mapper.dtd" > "https://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="quant.rich.emoney.mapper.postgre.EmoneyIndexMapper"> <mapper namespace="quant.rich.emoney.mapper.postgre.EmoneyIndexMapper">
<insert id="insertOrUpdate" parameterType="list"> <insert id="insertOrUpdateBatch" parameterType="list">
<foreach collection="list" item="item" index="idx" separator="">
<if test="idx % batchSize == 0">
<!-- batch 开头用 INSERT INTO 语句 -->
INSERT INTO emoney_index INSERT INTO emoney_index
(ts_code, trade_date, "value", index_param, index_name, line_name, line_shape, data_period) (ts_code, trade_date, "value", index_param, index_name, line_name, line_shape, data_period)
VALUES VALUES
<foreach collection="list" item="item" index="index" separator=","> </if>
(#{item.tsCode}, (#{item.tsCode},
#{item.date}, #{item.date},
#{item.value}, #{item.value},
@@ -17,15 +20,25 @@
#{item.lineShape}, #{item.lineShape},
#{item.dataPeriod} #{item.dataPeriod}
) )
</foreach> <choose>
ON CONFLICT (ts_code, trade_date, data_period, index_name, index_param, line_name ) DO UPDATE SET <when test="(idx + 1) % batchSize == 0 or (idx + 1) == list.size()">
<!-- 每个子 batch 结尾加 -->
ON CONFLICT (ts_code, trade_date, data_period, index_name, index_param, line_name )
DO UPDATE SET
ts_code = EXCLUDED.ts_code, ts_code = EXCLUDED.ts_code,
trade_date = EXCLUDED.trade_date, trade_date = EXCLUDED.trade_date,
"value" = EXCLUDED."value", "value" = EXCLUDED."value",
index_param = EXCLUDED.index_param, index_param = EXCLUDED.index_param,
index_name = EXCLUDED.index_name, index_name = EXCLUDED.index_name,
line_shape = EXCLUDED.line_shape, line_shape = EXCLUDED.line_shape,
data_period = EXCLUDED.data_period data_period = EXCLUDED.data_period;
</when>
<otherwise>
<!-- 其余情况属于 batch 内多 VALUES 分割 -->
,
</otherwise>
</choose>
</foreach>
</insert> </insert>
<select id="getLatestTradeDate" resultType="java.util.Date"> <select id="getLatestTradeDate" resultType="java.util.Date">

View File

@@ -5,9 +5,9 @@
namespace="quant.rich.emoney.mapper.postgre.StockStrategyMapper"> namespace="quant.rich.emoney.mapper.postgre.StockStrategyMapper">
<insert id="insertOrUpdateBatch"> <insert id="insertOrUpdateBatch">
<bind name="total" value="0"/> <foreach collection="list" item="item" index="idx" separator="">
<foreach collection="list" item="item" index="idx" separator=",">
<if test="idx % batchSize == 0"> <if test="idx % batchSize == 0">
<!-- batch 开头用 INSERT INTO 语句 -->
INSERT INTO stock_strategy INSERT INTO stock_strategy
(goods_id, date, pool_id, pool_name, strategy_id, strategy_name, type) (goods_id, date, pool_id, pool_name, strategy_id, strategy_name, type)
VALUES VALUES
@@ -20,15 +20,21 @@
#{item.strategyId}, #{item.strategyId},
#{item.strategyName}, #{item.strategyName},
#{item.type}) #{item.type})
<choose>
<if test="(idx + 1) % batchSize == 0 or (idx + 1) == list.size()"> <when test="(idx + 1) % batchSize == 0 or (idx + 1) == list.size()">
<!-- 每个子 batch 结尾加 -->
ON CONFLICT (goods_id, date, pool_id) ON CONFLICT (goods_id, date, pool_id)
DO UPDATE SET DO UPDATE SET
pool_name = EXCLUDED.pool_name, pool_name = EXCLUDED.pool_name,
strategy_id = EXCLUDED.strategy_id, strategy_id = EXCLUDED.strategy_id,
strategy_name = EXCLUDED.strategy_name, strategy_name = EXCLUDED.strategy_name,
type = EXCLUDED.type type = EXCLUDED.type;
</if> </when>
<otherwise>
<!-- 其余情况属于 batch 内多 VALUES 分割 -->
,
</otherwise>
</choose>
</foreach> </foreach>
</insert> </insert>

View File

@@ -1,7 +1,17 @@
if (!window.Helper) { window.Helper = {} } if (!window.Helper) { window.Helper = {} }
window.Helper = { window.Helper = {
/**
* 将 emoney period 转换为可读文字
* @param x emoney period, number 形式, 如 1, 5, 15, ..., 10000, ...
*/
emoneyPeriodToName: x => x < 10000 && `${x} 分钟线` || [null, '日线', '周线', '月线', '季线', '半年线', '年线'][x / 10000], emoneyPeriodToName: x => x < 10000 && `${x} 分钟线` || [null, '日线', '周线', '月线', '季线', '半年线', '年线'][x / 10000],
/**
* 所有支持的 emoney periods
*/
allEmoneyPeriods: [1, 5, 15, 30, 60, 120, 10000, 20000, 30000, 40000, 50000, 60000], allEmoneyPeriods: [1, 5, 15, 30, 60, 120, 10000, 20000, 30000, 40000, 50000, 60000],
/**
* 显示指标详情弹层
*/
showIndexDetailLayer: async function(obj, forceRefresh) { showIndexDetailLayer: async function(obj, forceRefresh) {
// obj: {indexCode: _, indexName: _} // obj: {indexCode: _, indexName: _}
var layer = layui.layer; var layer = layui.layer;
@@ -36,14 +46,14 @@ window.Helper = {
} }
} }
console.log(res.data); console.log(res.data);
layer.open({ top.layui.layer.open({
title: obj.indexName + '指标说明', title: obj.indexName + '指标说明',
content: html.join(''), content: html.join(''),
skin: 'layui-layer-indexDetail', skin: 'layui-layer-indexDetail',
area: ['520px', '320px'], area: ['520px', '320px'],
btn: ['刷新', '确定'], btn: ['刷新', '确定'],
btn1: function(index, _, _) { btn1: function(index, _, _) {
layer.close(index); top.layui.layer.close(index);
Helper.showIndexDetailLayer(obj, !0); Helper.showIndexDetailLayer(obj, !0);
}, },
success: function(layero, _) { success: function(layero, _) {
@@ -61,6 +71,11 @@ window.Helper = {
const pattern = new RegExp(`^[${escaped}]+|[${escaped}]+$`, 'g'); const pattern = new RegExp(`^[${escaped}]+|[${escaped}]+$`, 'g');
return str.replace(pattern, ''); return str.replace(pattern, '');
}, },
/**
* 将指定弹层 (layero) 的指定索引的按钮设置为主按钮
* @param layero 弹层对象
* @param index 指定索引, 以 0 起始。当为负数时, 以 -1 为倒数第一个按钮
*/
setLayerMainBtn: function(layero, index) { setLayerMainBtn: function(layero, index) {
var btns = layero.find('.layui-layer-btn>*'), j = 1; var btns = layero.find('.layui-layer-btn>*'), j = 1;
if (index < 0) index = btns.length + index; if (index < 0) index = btns.length + index;
@@ -73,6 +88,10 @@ window.Helper = {
btn.setAttribute('class', filtered.join(' ')); btn.setAttribute('class', filtered.join(' '));
} }
}, },
/**
* 在右侧展开弹层
* @param option 与 layer 一致的选项
*/
openR: function(option) { openR: function(option) {
const defaultOption = { const defaultOption = {
type: 1, area: '500px', type: 1, area: '500px',
@@ -353,5 +372,165 @@ window.Helper = {
} }
} }
}); });
},
/**
* 从指定根元素(root)下提取表单值
* 覆盖input(含 hidden)、textarea、select(单/多选)、checkbox、radio
*
* @param {Element|string} root - DOM 元素或选择器
* @param {object} [opt]
* @param {boolean} [opt.includeDisabled=false] - 是否包含 disabled 控件
* @param {"auto"|"string"|"array"} [opt.checkboxMode="auto"]
* checkboxMode:
* - auto: - 同名多个 => array
* - 单个 => boolean(无 value 时)或 string(有 value 时)
* - string: - 永远输出 string未选中则不输出该字段
* - array: - 永远输出 array未选中则 []
* - checked: - 单个 checkbox => boolean
* - 同名多个 checkbox => boolean[](按 DOM 顺序)
* @param {"last"|"array"} [opt.duplicateMode="array"]
* duplicateMode:
* - array: 同名非 checkbox/radio 也会聚合成 array
* - last: 取最后一个
* @param {"byName"|"entries"} [opt.output="byName"]
* output:
* - byName: {name: value}
* - entries: [{name, type, value, el}]
*
* @returns {object|Array}
*/
getFormValues(root, opt = {}) {
function cssEscape(s) {
if (window.CSS && typeof window.CSS.escape === "function") return window.CSS.escape(s);
return String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
const {
includeDisabled = false,
checkboxMode = "auto", // "auto" | "string" | "array" | "checked"
duplicateMode = "array",
output = "byName", // "byName" | "entries"
} = opt;
const $root = typeof root === "string" ? document.querySelector(root) : root;
if (!$root) throw new Error("root not found");
const controls = Array.from($root.querySelectorAll("input, textarea, select"));
const byName = Object.create(null);
const entries = [];
const pushValue = (name, value, meta) => {
if (!name) return;
if (output === "entries") {
entries.push({ name, ...meta, value });
return;
}
if (!(name in byName)) {
byName[name] = value;
return;
}
if (duplicateMode === "last") {
byName[name] = value;
return;
}
const cur = byName[name];
byName[name] = Array.isArray(cur) ? cur.concat([value]) : [cur, value];
};
// 用于 checkboxMode="checked":确保同名组只处理一次
const processedCheckboxNames = new Set();
for (const el of controls) {
if (!includeDisabled && el.disabled) continue;
const tag = el.tagName.toLowerCase();
const name = el.name || el.getAttribute("name") || "";
if (tag === "select") {
const type = el.multiple ? "select-multiple" : "select-one";
if (el.multiple) {
const vals = Array.from(el.selectedOptions).map(o => o.value);
pushValue(name, vals, { type, el });
} else {
pushValue(name, el.value, { type, el });
}
continue;
}
if (tag === "textarea") {
pushValue(name, el.value, { type: "textarea", el });
continue;
}
const inputType = (el.type || "text").toLowerCase();
if (inputType === "radio") {
if (!el.checked) continue;
pushValue(name, el.value, { type: "radio", el });
continue;
}
if (inputType === "checkbox") {
// === 新增模式checked ===
if (checkboxMode === "checked") {
if (!name) continue; // 没 name 没法聚合;如你要支持无 name也可以改成 entries 模式收集
if (processedCheckboxNames.has(name)) continue;
const group = Array.from(
$root.querySelectorAll(`input[type="checkbox"][name="${cssEscape(name)}"]`)
).filter(x => includeDisabled || !x.disabled);
if (group.length <= 1) {
const one = group[0] || el; // 理论上 group[0] 就是 el
pushValue(name, !!one.checked, { type: "checkbox", el: one });
} else {
const states = group.map(x => !!x.checked);
pushValue(name, states, { type: "checkbox", el: group[0] });
}
processedCheckboxNames.add(name);
continue;
}
// === 原有模式逻辑 ===
const hasValueAttr = el.hasAttribute("value");
const valueAttr = el.value;
const checked = el.checked;
const sameNameCheckboxes = name
? $root.querySelectorAll(`input[type="checkbox"][name="${cssEscape(name)}"]`)
: [];
const isGroup = name && sameNameCheckboxes.length > 1;
if (checkboxMode === "array" || (checkboxMode === "auto" && isGroup)) {
if (checked) pushValue(name, valueAttr, { type: "checkbox", el });
else if (checkboxMode === "array" && !(name in byName) && output !== "entries") {
byName[name] = [];
}
} else if (checkboxMode === "string") {
if (checked) pushValue(name, valueAttr, { type: "checkbox", el });
} else {
if (hasValueAttr) {
if (checked) pushValue(name, valueAttr, { type: "checkbox", el });
} else {
pushValue(name, checked, { type: "checkbox", el });
}
}
continue;
}
if (inputType === "file") {
continue;
}
// hidden/text/number/password/date... 都取 value包含 hidden
pushValue(name, el.value, { type: inputType, el });
}
return output === "entries" ? entries : byName;
} }
} }

File diff suppressed because one or more lines are too long

View File

@@ -179,6 +179,7 @@
</script> </script>
</div> </div>
<th:block th:fragment="head-script"> <th:block th:fragment="head-script">
<th:block th:fragment="head-script-ref-only">
<script type="text/javascript" src="/admin/v1/static/js/helper.js"></script> <script type="text/javascript" src="/admin/v1/static/js/helper.js"></script>
<script th:src="@{/admin/v1/static/layui/layui.js}"></script> <script th:src="@{/admin/v1/static/layui/layui.js}"></script>
<script <script
@@ -188,7 +189,7 @@
defer></script> defer></script>
<script th:src="@{/admin/v1/static/giggity/toast.js}"></script> <script th:src="@{/admin/v1/static/giggity/toast.js}"></script>
<script th:src="@{/admin/v1/static/js/dog.js}"></script> <script th:src="@{/admin/v1/static/js/dog.js}"></script>
</th:block>
<script> <script>
let refreshTimer = null; let refreshTimer = null;

View File

@@ -0,0 +1,204 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<th:block th:fragment="commonFormStart">
<input type="hidden" id="planJsonString" th:value="${planJsonString}">
<style>
.layui-form-select dl{
max-height: 160px
}
body {
background: transparent
}
</style>
<div class="layui-form" style="margin:0 15px;padding: 10px 0" id="editPlanForm" lay-filter="editPlanForm">
<div class="layui-form-item">
<input type="hidden" name="planId" th:value="${plan.planId}"/>
<label class="layui-form-label">计划名称<span>*</span></label>
<div class="layui-input-block">
<input type="text"
th:value="${plan.planName}"
lay-verify="required"
name="planName"
placeholder=""
autocomplete="off"
class="layui-input"/>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">计划表达式<span>*</span></label>
<div class="layui-input-block">
<input type="text"
th:value="${plan.cronExpr}"
lay-verify="required"
name="cronExpr"
placeholder=""
autocomplete="off"
class="layui-input"/>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">更新类型<span>*</span></label>
<div class="layui-input-block">
<input type="radio" name="planType" value="SINGLE_INDEX" lay-filter="planTypeRadio" title="单指标" th:checked="${planType == 'SINGLE_INDEX'}">
<input type="radio" name="planType" value="MULTI_INDEX" lay-filter="planTypeRadio" title="多指标" th:checked="${planType == 'MULTI_INDEX'}">
<input type="radio" name="planType" value="STOCK_STRATEGY" lay-filter="planTypeRadio" title="个股策略" th:checked="${planType == 'STOCK_STRATEGY'}">
<input type="radio" name="planType" value="STOCK_PICKING" lay-filter="planTypeRadio" title="策略选股" th:checked="${planType == 'STOCK_PICKING'}">
</div>
</div>
<div id="planSettings">
</th:block>
<th:block th:fragment="commonFormEnd">
</div>
<div class="layui-form-item">
<label class="layui-form-label">启用<span>*</span></label>
<div class="layui-input-block">
<input type="checkbox" name="enabled" th:attr="checked=${plan.enabled}" th:value="${plan.enabled}" lay-skin="switch" lay-filter="enabled" lay-text="ON|OFF">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">交易日校验<span>*</span></label>
<div class="layui-input-block">
<input type="checkbox" name="openDayCheck" th:attr="checked=${plan.openDayCheck}" th:value="${plan.openDayCheck}" lay-skin="switch" lay-filter="openDayCheck" lay-text="ON|OFF">
</div>
</div>
<div style="display:none" class="layui-form-item submit">
<div class="layui-input-block">
<button class="layui-btn" lay-submit="*" lay-filter="submitPlan">提交</button>
</div>
</div>
</div>
<script type="text/html" id="periodsTemplet">
<div class="layui-form-item">
<label class="layui-form-label">更新粒度<span>*</span></label>
<div class="layui-input-block">
<div id="xmSelectPeriod" style="width: 100%" class="layui-inline"></div>
</div>
</div>
</script>
<script type="text/html" id="paramTemplet">
<div class="layui-form-item">
<label class="layui-form-label">{{d.pName}}<span>*</span></label>
<div class="layui-input-block">
<input type="number"
min="{{d.pMin}}" max="{{d.pMax}}"
name="params.{{d.pName}}"
lay-verify="required|min:{{d.pMin}}|max:{{d.pMax}}|number"
placeholder="范围:{{d.pMin}} ~ {{d.pMax}}"
value="{{d.pValue}}" autocomplete="off" class="layui-input"/>
</div>
</div>
</script>
</th:block>
<th:block th:fragment="commonScript">
<script>
layui
.extend({
xmSelect: '/admin/v1/static/layuiadmin/lib/xm-select',
cron: '/admin/v1/static/layuiadmin/lib/cron'
}).use(['table', 'form', 'dropdown', 'layer', 'xmSelect', 'cron'], async function (){
// 渲染计划任务表达式组件
layui.cron.render({
elem: '[name="cronExpr"]',
btns: ['confirm'],
cssPath: '/admin/v1/static/css/cron.css'
});
// 绑定 radio 切换更新类型选择
layui.form.on('radio(planTypeRadio)', function (obj) {
const planType = obj.value;
const planId = document.querySelector('[name="planId"]').value;
let url = '/admin/v1/manage/plan/edit';
if (planType == 'SINGLE_INDEX') {
url += 'SingleIndex';
}
else if (planType == 'MULTI_INDEX') {
url += 'MultiIndex';
}
else if (planType == 'STOCK_STRATEGY') {
url += 'StockStrategy'
}
else {
Dog.error({msg: '未知的计划任务类型'});
return;
}
if (planId) {
url += '?planId=' + planId;
}
location.href = url;
});
});
if (window == top) {
// 如果不是处在 layer 层中则显示提交按钮
document.querySelector('.submit').style.display = '';
}
// 绑定开关事件修改值
const editPlanForm = document.getElementById('editPlanForm');
editPlanForm.querySelectorAll('[lay-skin="switch"][lay-filter]').forEach(el => {
layui.form.on('switch(' + el.getAttribute('lay-filter') + ')', obj => {
obj.elem.value = obj.elem.checked;
})
});
// 获取 configIndOnline
async function getConfigIndOnline() {
if (window.configIndOnline) {
return window.configIndOnline;
}
const loadConfigLayer = layui.layer.load(2);
try {
const json = await (await fetch('/admin/v1/manage/indexInfo/getFields?fields=configIndOnline')).json();
if (json.ok) {
window.configIndOnline = json.data.configIndOnline;
return window.configIndOnline;
}
Dog.error({msg: json.data || '获取指标配置错误'});
}
catch (e) {
console.error(e);
Dog.error({msg: `获取指标配置错误 ${e.message}`});
}
finally {
layui.layer.close(loadConfigLayer);
}
return null;
}
function showIndexDetailLayer(el, event) {
event.preventDefault();
Helper.showIndexDetailLayer({
indexCode: el.dataset.indexCode,
indexName: el.dataset.indexName
});
}
/**
* 保存 data 到 plan 并关闭弹窗、刷新表格
*/
function savePlan(data) {
$.ajax({
url: '/admin/v1/manage/plan/save',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify(data),
success: function (r) {
Dog.success({
onClose: function () {
if (window != top) {
top.Dog.reloadTable('plans');
if (top.editLayer) {
top.layui.layer.close(top.editLayer);
}
}
else {
location.reload()
}
}
})
}
})
}
</script>
</th:block>
</html>

View File

@@ -0,0 +1,520 @@
<!DOCTYPE html>
<!-- 多指标抓取配置 -->
<html xmlns:th="http://www.thymeleaf.org">
<head th:insert="~{admin/v1/include::head}" th:with="title='多指标抓取配置'">
</head>
<style>
/* 表格内的表单样式 */
.params .layui-form-label {
padding: 9px 0;
width: 60px;
text-align: left
}
.params .layui-input-block {
margin-left: 60px
}
.params > .layui-form-item:last-of-type {
margin-bottom: 0
}
#multiIndexTable td {
vertical-align: top;
}
#multiIndexTable {
background-color: #fbfbfb;
transition: .25s ease-in-out
}
#multiIndexTable tbody>tr:hover {
background-color: #f2f2f2;
}
#multiIndexTable tr:not(:last-of-type) {
cursor: move;
}
.indexCodeWrap {
display: flex;
width: 100%;
}
.indexCode {
width: 90%;
margin-right: 9px;
}
a.showIndexDetailLayer {
vertical-align: middle;
margin: auto;
}
</style>
<body>
<th:block th:replace="~{admin/v1/include::head-script-ref-only}"></th:block>
<th:block th:replace="~{admin/v1/manage/plan/editCommon::commonFormStart}"></th:block>
<div class="layui-form-item">
<label class="layui-form-label">更新指标<span>*</span></label>
<div class="layui-input-block">
<table id="multiIndexTable" class="layui-table" lay-skin="line">
<thead>
<tr>
<th width="270">指标</th>
<th>参数</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
<div id="periods"></div>
<th:block th:replace="~{admin/v1/manage/plan/editCommon::commonFormEnd}"></th:block>
<th:block th:replace="~{admin/v1/manage/plan/editCommon::commonScript}"></th:block>
<script th:src="@{/admin/v1/static/sortable/Sortable.min.js}"></script>
<script>
function isLastRow(tr) {
return tr && tr.parentElement && tr === tr.parentElement.lastElementChild;
}
const sortableInstance = new Sortable(
document.querySelector('#multiIndexTable tbody'), {
animation: 150,
draggable: 'tr',
handle: 'td',
filter: (evt, target) => {
// 配合 preventOnFilter
// 返回 true 则被 filter 过滤
const tr = target.closest('tr');
return isLastRow(tr);
},
preventOnFilter: true,
onMove: (evt) => {
const dragged = evt.dragged; // 正在拖的 tr
const related = evt.related; // 鼠标附近将要插入位置参考的 tr
// 最后一行不能被拖
if (isLastRow(dragged)) return false;
// 不允许把任何行插到“最后一行”前面或后面(拖到末尾时 related 通常就是最后一行)
if (isLastRow(related)) return false;
return true;
}
});
/**
* 取 options period 的交集
*/
function getPeriods(options, commonPeriods) {
options.forEach(option => {
console.debug(option.name, 'period: ', option.indInfo?.supportPeriod)
})
// 取出所有带 supportPeriod 的数组
const periodsList = options
.map(o => o?.indInfo?.supportPeriod)
.filter(Array.isArray);
// 没有任何 supportPeriod => 返回 commonPeriods不改原数组
if (periodsList.length === 0) return commonPeriods.slice();
// 交集:从第一组开始逐步 intersect
let inter = new Set(periodsList[0]);
for (let i = 1; i < periodsList.length; i++) {
const cur = new Set(periodsList[i]);
inter = new Set([...inter].filter(x => cur.has(x)));
if (inter.size === 0) break; // 提前结束
}
// 如果你想保持 commonPeriods 的顺序(且过滤掉不在 commonPeriods 的值)
const interSet = inter;
return commonPeriods.filter(x => interSet.has(x));
}
/**
* 两个数组是否有交集
*/
function hasIntersection(a, b) {
if (!a?.length || !b?.length) return false;
const setB = new Set(b);
return a.some(x => setB.has(x));
}
/**
* period 数组转多选选项
*/
function periodArrayToMultiSelectOptions(periods) {
const periodData = [];
for (var i = 0; i < periods.length; i++) {
const period = periods[i];
periodData.push({
name: Helper.emoneyPeriodToName(period),
value: period
})
}
return periodData;
}
/**
* 更新所有 indexCode 下拉框的 disable
* 由于 on 方法内选择后有所延迟, 获取的 multiSelect 的 value 还没更改过来,
* 所以在此若提供了 currentSelect 和 currentOption 则用来代替找寻到的 select
* 及其选择值
*/
function updateIndexCodeDisabled(currentSelect, currentOption) {
// 更新除本例以外的下拉框的指标为禁选
const allIndexCodeSelects = layui.xmSelect.getWithSelector('.indexCode');
const codes =
allIndexCodeSelects.length ?
allIndexCodeSelects.map(x => x.getValue()[0]).map(x => x?.value):
[];
// 用 currentValue 替代
if (currentSelect != null) {
const currentSelectIndex = allIndexCodeSelects.indexOf(currentSelect);
if (currentSelectIndex != -1) {
codes[currentSelectIndex] = currentOption?.value;
}
}
// 遍历这些 select, 如果有值选中则配置 showIndexDetailLayer
codes.forEach((code, i) => {
const showIndexLayerDetailEl = allIndexCodeSelects[i].options.dom.nextSibling;
if (code !== undefined) {
const s = allIndexCodeSelects[i].options.data.find(d => d.value == code);
showIndexLayerDetailEl.dataset.indexCode = code;
showIndexLayerDetailEl.dataset.indexName = s.indName;
showIndexLayerDetailEl.style.display = '';
}
else {
showIndexLayerDetailEl.style.display = 'none';
}
});
// 遍历这些 select, 禁用处于 codes 内的且非本 select 选择的选项
allIndexCodeSelects.forEach((select, i) => {
const disableds = codes.filter((_, j) => j !== i).filter(v => v !== undefined);
select.options.data.forEach(option => {
const exists = disableds.indexOf(option.value) !== -1;
option.disabled = exists || option.disabledForPeriodConflict;
option.disabledForExists = exists;
})
});
updateIndexCodeSelectTips();
}
/**
* 更新 period 下拉框的 disable
* 由于 on 方法内选择后有所延迟, 获取的 multiSelect 的 value 还没更改过来,
* 所以在此若提供了 currentSelect 和 currentOption 则用来代替找寻到的 select
* 及其选择值.
* 返回:可选的 period
*/
function updateAfterPeriodsDisabled(currentSelect, currentOption) {
const allIndexCodeSelects = layui.xmSelect.getWithSelector('.indexCode');
const options =
allIndexCodeSelects.length ?
allIndexCodeSelects.map(x => x.getValue()[0]).filter(x => x !== undefined):
[];
const xmSelectPeriod = layui.xmSelect.get('#xmSelectPeriod', true);
// 用 currentValue 替代
if (currentSelect != null) {
const currentSelectIndex = allIndexCodeSelects.indexOf(currentSelect);
if (currentSelectIndex != -1) {
options[currentSelectIndex] = currentOption;
}
}
if (!options.length) {
// 没有任何选择, 清空 period
xmSelectPeriod.update({
data: []
})
return;
}
const unconflictPeriods = getPeriods(options, Helper.allEmoneyPeriods);
console.log(unconflictPeriods);
const newData = periodArrayToMultiSelectOptions(unconflictPeriods);
const oldData = xmSelectPeriod.options.data;
// 把 newData 中在 oldData 内选中的也设置成选中
newData.filter(x => oldData.find(y => y.value == x.value && y.selected)).forEach(x => x.selected = true)
const initPlanEl = document.getElementById('planJsonString');
if (!initPlanEl.inited) {
initPlanEl.inited = true;
const initPlan = JSON.parse(initPlanEl.value || '{}');
const initPeriods = initPlan.detail?.periods || [];
newData.filter(x => initPeriods.indexOf(x.value) !== -1).forEach(x => x.selected = true);
}
// 更新 periods 选框数据
xmSelectPeriod.update({
data: newData
});
// 更新所有 indexCode 选框选项, 把 period 冲突的 disable
allIndexCodeSelects.forEach(select => {
select.options.data.forEach(option => {
const conflict = !option.selected && option.indInfo.supportPeriod && !hasIntersection(unconflictPeriods, option.indInfo.supportPeriod)
option.disabled = conflict || option.disabledForExists;
option.disabledForPeriodConflict = conflict;
});
});
updateIndexCodeSelectTips();
return unconflictPeriods;
}
/**
* 更新 indexCode 下拉框禁用提示
*/
function updateIndexCodeSelectTips() {
const allIndexCodeSelects = layui.xmSelect.getWithSelector('.indexCode');
const options =
allIndexCodeSelects.length ?
allIndexCodeSelects.map(x => x.getValue()[0]).filter(x => x !== undefined):
[];
// 更新所有 indexCode 选框选项, 把 period 冲突的 disable
allIndexCodeSelects.forEach(select => {
for (let i = 0; i < select.options.data.length; i++) {
const option = select.options.data[i];
const title = [];
if (option.disabledForPeriodConflict) {
title.push('更新粒度冲突');
}
if (option.disabledForExists) {
title.push('已被其他选中');
}
if (title.length) {
select.options.dom.querySelector(`[value="${option.value}"]`).title = `${title.join("、")}而被禁用`;
}
}
});
}
layui.use(['xmSelect'], async function() {
// 为 xmSelect hack 筛选方法
layui.xmSelect.getWithSelector = selector => {
return layui.xmSelect.get().filter(x => x.options.dom.matches(selector));
};
const initPlan = JSON.parse(document.getElementById('planJsonString')?.value || '{}');
const periodsTemplet = document.getElementById('periodsTemplet');
const periodsEl = document.getElementById('periods');
const paramTemplet = document.getElementById('paramTemplet');
periodsEl.insertAdjacentHTML('beforeend', periodsTemplet.textContent);
// 先渲染表格
const initIndexes = initPlan.detail?.indexes || [];
// 获取指标信息
const configIndOnline = await getConfigIndOnline();
if (configIndOnline == null) {
throw new Error();
}
const indMap = configIndOnline.indMap;
addEmptyLine();
// 为“增加”按钮绑定点击事件,事件包含行添加删除、其中下拉框渲染逻辑
const addBtn = document.querySelector('#multiIndexTable .add');
function addEmptyLine() {
var element = $([
'<tr>',
'<td>',
'<div class="indexCodeWrap">',
'<div class="indexCode"></div>',
'<a style="display: none" href="#" class="showIndexDetailLayer" title="查看指标详情" onclick="javascript:showIndexDetailLayer(this, event)"><i class="fa fa-solid fa-circle-question"></i></a>',
'</div>',
'</td>',
'<td class="params"></td>',
'</tr>',
].join(''));
// 指标多选框数据填充
const indexCodeEl = element.find('.indexCode')[0];
const selectData = [];
// 先找寻有无其他指标选择框,如果有,要从中 disable
const allIndexCodeSelects = layui.xmSelect.getWithSelector('.indexCode');
let expectCodes = [];
if (allIndexCodeSelects.length) {
expectCodes = allIndexCodeSelects.map(x => x.getValue()[0]).filter(x => x).map(x => x.value);
}
// 初始化的数据, 或 undefined
const initIndex = initIndexes.shift();
Object.keys(indMap).forEach(key => {
const indInfo = indMap[key], indName = configIndOnline.indIdNameConfig[key];
if (indInfo.isCalc) {
// 算数型指标
return;
}
if (indInfo.supportStockMethod && indInfo.supportStockMethod.indexOf('isA') == -1) {
// 明确非 A 股指标
return;
}
const data = {};
if (expectCodes.indexOf(key) != -1) {
console.debug(indName, '已被其他下拉框选中, 将禁用');
data.disabled = true;
data.tips = ['被其他下拉框选中'];
}
data.name = `${key} ${indName}`;
data.value = key;
data.indInfo = indInfo;
data.indName = indName;
data.selected = key == initIndex?.indexCode;
selectData.push(data);
});
const indexCodeSelect = layui.xmSelect.render({
el: indexCodeEl,
radio: true,
clickClose: true,
filterable: true,
model: { label: { type: 'text' } },
data: selectData,
on: function (data) {
console.log(data);
const row = indexCodeEl.closest('tr');
const rows = document.querySelectorAll('#multiIndexTable tbody>tr');
const rowsCount = rows.length;
if (!data.arr.length) {
if (rowsCount > 1) {
row.remove();
updateIndexCodeDisabled();
updateAfterPeriodsDisabled();
return;
}
}
else if (Array.from(rows).indexOf(row) == rowsCount - 1){
addEmptyLine();
}
const paramsEl = row.querySelector('.params');
paramsEl.textContent = ''; // 清除
const value = data.arr[0];
const indInfo = value.indInfo;
updateIndexCodeDisabled(indexCodeSelect, value);
const inconflictPeriods = updateAfterPeriodsDisabled(indexCodeSelect, value);
if (indInfo.indParam) {
// 填充 params
const tpl = layui.laytpl(paramTemplet.textContent);
for (let i = 0; i < indInfo.indParam.pName.length; i++) {
const d = {
pName: indInfo.indParam.pName[i],
pMin: indInfo.indParam.min[i],
pMax: indInfo.indParam.max[i],
pValue: indInfo.indParam.value[i]
}
paramsEl.insertAdjacentHTML('beforeend', tpl.render(d))
}
}
},
done: function () {
if (initIndex) {
const indInfo = indMap[initIndex.indexCode], indName = configIndOnline.indIdNameConfig[initIndex.indexCode];
const row = indexCodeEl.closest('tr');
if (indInfo.indParam) {
// 填充 params
const paramsEl = row.querySelector('.params');
const tpl = layui.laytpl(paramTemplet.textContent);
for (let i = 0; i < indInfo.indParam.pName.length; i++) {
const pName = indInfo.indParam.pName[i];
const d = {
pName,
pMin: indInfo.indParam.min[i],
pMax: indInfo.indParam.max[i],
pValue: initIndex.params[pName]
}
paramsEl.insertAdjacentHTML('beforeend', tpl.render(d))
}
}
addEmptyLine();
}
}
});
$('#multiIndexTable tbody')[initIndex ? 'prepend' : 'append'](element);
}
// 最后渲染 periods
const initPeriods = initPlan.detail?.periods || [];
const periodData = [];
initPeriods.forEach(period => {
periodData.push({
})
});
layui.xmSelect.render({
name: 'periods',
el: '#xmSelectPeriod',
language: 'zn',
data: [],
layVerify: 'required',
autoRow: !0
});
updateIndexCodeDisabled();
updateAfterPeriodsDisabled();
// 提交
layui.form.on('submit(submitPlan)', function (obj) {
// 包装要提交的数据
const field = Helper.getFormValues(
document.querySelector('.layui-form'),
{checkboxMode: 'checked'});
const data = {
detail: {
periods: [],
indexes: []
}
};
function elementIndex(el) {
if (!el || !el.parentElement) return -1;
return Array.prototype.indexOf.call(el.parentElement.children, el);
}
// 找 index 配置
const selects = layui.xmSelect.getWithSelector('.indexCode').filter(x => x.getValue()[0] !== undefined);
if (!selects.length) {
layer.msg('请选择至少一项更新指标', {
icon : 5,
shift : 6,
time : 2000
});
return false;
}
// 根据 select 在表格中的顺序排序
selects.sort((a, b) => {
return elementIndex(a.options.dom.closest('tr')) - elementIndex(b.options.dom.closest('tr'))
});
// 装载 detail
selects.forEach(select => {
const row = select.options.dom.closest('tr');
const index = {indexCode: select.getValue('valueStr'), params: {}};
const paramInputs = row.querySelectorAll('input[name^="params."]');
paramInputs.forEach(paramInput => {
const name = paramInput.name;
index.params[name.substr(7)] = parseInt(paramInput.value);
});
data.detail.indexes.push(index);
});
//console.log(data);
Object.keys(field).forEach(key => {
if (key.indexOf('params') == 0) {
// 动态添加的参数 name="params.XXX", 取 substr(7) 即为 XXX
// data.detail.params[key.substr(7)] = field[key]
}
else if (key == 'periods') {
data.detail.periods = field.periods.split(',').map(x => parseInt(x));
}
else data[key] = field[key]
});
console.log(data);
savePlan(data);
})
});
</script>

View File

@@ -0,0 +1,163 @@
<!DOCTYPE html>
<!-- 单指标抓取配置 -->
<html xmlns:th="http://www.thymeleaf.org">
<head th:insert="~{admin/v1/include::head}" th:with="title='单指标抓取配置'">
</head>
<body>
<th:block th:replace="~{admin/v1/include::head-script-ref-only}"></th:block>
<th:block th:replace="~{admin/v1/manage/plan/editCommon::commonFormStart}"></th:block>
<div class="layui-form-item">
<label class="layui-form-label">更新指标<span>*</span></label>
<div class="layui-input-block">
<div class="layui-inline">
<select name="indexCode" lay-filter="indexCodeFilter" lay-search>
<option value="">选择更新指标</option>
</select>
</div>
<div class="layui-inline">
<a style="display:none" href="#" id="showIndexDetailLayer" title="查看指标详情" onclick="javascript:showIndexDetailLayer(this, event)"><i class="fa fa-solid fa-circle-question"></i></a>
</div>
</div>
</div>
<div id="params"></div>
<div id="periods"></div>
<th:block th:replace="~{admin/v1/manage/plan/editCommon::commonFormEnd}"></th:block>
<th:block th:replace="~{admin/v1/manage/plan/editCommon::commonScript}"></th:block>
<script>
layui.use(['table', 'form', 'dropdown', 'layer', 'xmSelect', 'cron'], async function() {
const initPlanEl = document.getElementById('planJsonString');
const initPlan = JSON.parse(initPlanEl?.value || '{}');
const configIndOnline = await getConfigIndOnline();
if (configIndOnline == null) {
throw new Error();
}
// indexCode 单选控件触发事件,仅当当前更新为单指标更新时有用
layui.form.on('select(indexCodeFilter)', async function (obj) {
console.log(obj);
const paramsEl = document.getElementById('params'); // 参数选择控件
const periodsEl = document.getElementById('periods'); // 时间粒度选择控件
paramsEl.textContent = periodsEl.textContent = ''; // 清除参数和时间粒度选择控件
const dataset = obj.elem.querySelector(`[value="${obj.value}"]`).dataset;
const detailTriggerEl = document.getElementById('showIndexDetailLayer');
if (!dataset || !dataset.indInfo) {
// 未存在 dataset/.indInfo, 则去除可能存在的指标详情按钮
detailTriggerEl.style.display = 'none';
return;
}
detailTriggerEl.style.display = '';
detailTriggerEl.dataset.indexCode = obj.value;
detailTriggerEl.dataset.indexName = dataset.indName;
const indInfo = JSON.parse(dataset.indInfo);
console.log(indInfo);
const paramTemplet = document.getElementById('paramTemplet');
const periodsTemplet = document.getElementById('periodsTemplet');
if (indInfo.indParam) {
var tpl = layui.laytpl(paramTemplet.textContent);
for (var i = 0; i < indInfo.indParam.pName.length; i++) {
var d = {
pName: indInfo.indParam.pName[i],
pMin: indInfo.indParam.min[i],
pMax: indInfo.indParam.max[i],
pValue: indInfo.indParam.value[i]
}
if (obj.params && obj.params[d.pName]) {
d.pValue = obj.params[d.pName]
}
paramsEl.insertAdjacentHTML('beforeend', tpl.render(d));
}
}
let periods = Helper.allEmoneyPeriods;
if (indInfo.supportPeriod) periods = indInfo.supportPeriod;
periodsEl.insertAdjacentHTML('beforeend', periodsTemplet.textContent);
const periodData = [];
for (var i = 0; i < periods.length; i++) {
const period = periods[i];
const planPeriods = initPlan?.detail?.periods || [];
periodData.push({
name: Helper.emoneyPeriodToName(period),
value: period,
selected: planPeriods.indexOf(period) != -1
})
}
layui.xmSelect.render({
name: 'periods',
el: '#xmSelectPeriod',
language: 'zn',
data: periodData,
layVerify: 'required',
autoRow: !0
});
layui.form.render()
}) // indexCode 单选控件触发事件结束
// 渲染指标下拉框内容
const indexNameSelectEl = document.querySelector('[name="indexCode"]');
const indMap = configIndOnline.indMap;
let selectedIndexCode = undefined;
const optionsFragment = document.createDocumentFragment();
Object.keys(indMap).forEach(key => {
const indInfo = indMap[key];
const indName = configIndOnline.indIdNameConfig[key];
if (indInfo.isCalc) {
console.debug(indName, '为算数型指标, 忽略');
return;
}
if (indInfo.supportStockMethod && indInfo.supportStockMethod.indexOf('isA') == -1) {
console.debug(indName, '提供 supportMethod', indInfo.supportStockMethod.join(', '), '非 A 股指标, 忽略');
return;
}
const option = document.createElement('option');
option.value = key;
option.textContent = `${key} ${indName}`;
if (initPlan?.detail?.indexCode == key) {
selectedIndexCode = key;
option.selected = true;
}
option.dataset.indInfo = JSON.stringify(indInfo);
option.dataset.indName = indName;
optionsFragment.appendChild(option);
indexNameSelectEl.appendChild(optionsFragment);
})
if (selectedIndexCode) {
// 如果有选择内容, 手动触发指标选择下拉框,并附带参数
layui.event.call(this, 'form', 'select(indexCodeFilter)', {
elem: indexNameSelectEl,
value: selectedIndexCode,
params: initPlan?.detail?.params
});
}
layui.form.render();
// 提交
layui.form.on('submit(submitPlan)', function (obj) {
// 包装要提交的数据
const field = Helper.getFormValues(
document.querySelector('.layui-form'),
{checkboxMode: 'checked'});
const data = {detail: {params:{}, periods: []}}
Object.keys(field).forEach(key => {
if (key.indexOf('params') == 0) {
// 动态添加的参数 name="params.XXX", 取 substr(7) 即为 XXX
data.detail.params[key.substr(7)] = field[key]
}
else if (key == 'periods') {
data.detail.periods = field.periods.split(',').map(x => parseInt(x))
}
else if (key == 'indexCode') {
data.detail.indexCode = field.indexCode
}
else data[key] = field[key]
})
savePlan(data);
})
}); // layui.use
</script>
</body>
</html>

View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<!-- 单指标抓取配置 -->
<html xmlns:th="http://www.thymeleaf.org">
<head th:insert="~{admin/v1/include::head}" th:with="title='个股策略抓取配置'">
</head>
<body>
<th:block th:replace="~{admin/v1/include::head-script-ref-only}"></th:block>
<th:block th:replace="~{admin/v1/manage/plan/editCommon::commonFormStart}"></th:block>
<th:block th:replace="~{admin/v1/manage/plan/editCommon::commonFormEnd}"></th:block>
<th:block th:replace="~{admin/v1/manage/plan/editCommon::commonScript}"></th:block>
<script>
layui.use(['table', 'form', 'dropdown', 'layer', 'xmSelect', 'cron'], async function() {
layui.form.render();
// 提交
layui.form.on('submit(submitPlan)', function (obj) {
// 包装要提交的数据
const data = Helper.getFormValues(
document.querySelector('.layui-form'),
{checkboxMode: 'checked'});
savePlan(data);
})
}); // layui.use
</script>
</body>
</html>

View File

@@ -1,300 +0,0 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<div th:fragment="planExtra">
<script id="addPlan" type="text/html">
<style>.layui-form-select dl{max-height: 160px}</style>
<div class="layui-form" style="margin:10px 15px" id="editPlanForm" lay-filter="editPlanForm">
<div class="layui-form-item">
<input type="hidden" name="planId"/>
<label class="layui-form-label">计划名称<span>*</span></label>
<div class="layui-input-block">
<input type="text" lay-verify="required" name="planName" placeholder="" autocomplete="off" class="layui-input"/>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">计划表达式<span>*</span></label>
<div class="layui-input-block">
<input type="text" lay-verify="required" name="cronExpr" placeholder="" autocomplete="off" class="layui-input"/>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">更新指标<span>*</span></label>
<div class="layui-input-block">
<div class="layui-inline">
<select name="indexCode" lay-filter="indexCodeFilter" lay-search>
<option value="">选择更新指标</option>
</select>
</div>
<div class="layui-inline">
<a style="display:none" href="#" id="showIndexDetailLayer" onclick="javascript:showIndexDetailLayer(this, event)"><i class="fa fa-solid fa-circle-question"></i></a>
</div>
</div>
</div>
<div id="params">
</div>
<div id="periods">
</div>
<div class="layui-form-item">
<label class="layui-form-label">启用<span>*</span></label>
<div class="layui-input-block">
<input type="checkbox"
name="enabled" lay-skin="switch" lay-filter="enabled" lay-text="ON|OFF">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">交易日校验<span>*</span></label>
<div class="layui-input-block">
<input type="checkbox"
name="openDayCheck" lay-skin="switch" lay-filter="openDayCheck" lay-text="ON|OFF">
</div>
</div>
<div style="display:none" class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn" lay-submit="*" lay-filter="submitPlan">提交</button>
</div>
</div>
</div>
</script>
<script type="text/html" id="periodsTemplet">
<div class="layui-form-item">
<label class="layui-form-label">更新粒度<span>*</span></label>
<div class="layui-input-block">
<div id="period-xm-select" style="width: 100%" class="layui-inline"></div>
</div>
</div>
</script>
<script type="text/html" id="paramTemplet">
<div class="layui-form-item">
<label class="layui-form-label">{{d.pName}}<span>*</span></label>
<div class="layui-input-block">
<input type="number"
min="{{d.pMin}}" max="{{d.pMax}}"
name="params.{{d.pName}}"
lay-verify="required|min:{{d.pMin}}|max:{{d.pMax}}|number"
placeholder="范围:{{d.pMin}} ~ {{d.pMax}}"
value="{{d.pValue}}" autocomplete="off" class="layui-input"/>
</div>
</div>
</script>
<script type="text/javascript">
layui.form.on('submit(submitPlan)', function (obj) {
var field = obj.field, data = {params: {}}
Object.keys(field).forEach(key => {
if (key.indexOf('params') == 0) {
data.params[key.substr(7)] = field[key]
}
else if (key == 'periods') {
data.periods = field.periods.split(',')
}
else {
data[key] = field[key]
}
})
$.ajax({
url: '/admin/v1/manage/plan/save',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify(data),
success: function (r) {
layer.msg('操作成功', {
offset: '15px',
icon: 1,
time: 1000
},
function () {
if (window.editLayer) {
layui.layer.close(window.editLayer);
}
layui.table.reload('plans', {
page: {
curr: $(".layui-laypage-em").next().html() //当前页码值
}
});
}
)
}
})
})
function showIndexDetailLayer(el, event) {
event.preventDefault();
Helper.showIndexDetailLayer({
indexCode: el.dataset.indexCode,
indexName: el.dataset.indexName
});
}
function openEditForm(r) {
if (r && r.ok) {
window.editLayer = layui.layer.open({
type: 1,
title: `${r.data.planId ? '编辑' : '新增'}计划任务`,
btn: ['提交', '关闭'],
yes: function (index, layero) {
layero.find('[lay-filter="submitPlan"]').click()
},
skin: "layui-anim layui-anim-rl layui-layer-adminRight",
area: '500px',
anim: -1,
shadeClose: !0,
closeBtn: !1,
move: !1,
offset: 'r',
content: $('#addPlan').html(),
success: async function (layero, layerIndex) {
var el = $(layero);
['planId', 'planName', 'cronExpr', 'indexCode'].forEach(x => {
const fieldEl = el[0].querySelector(`[name="${x}"]`);
if (!fieldEl) return;
fieldEl.value = r.data[x];
});
['enabled', 'openDayCheck'].forEach(x => {
const fieldEl = el[0].querySelector(`[name="${x}"]`);
if (!fieldEl) return;
fieldEl.checked = !!r.data[x];
fieldEl.value = !!r.data[x];
layui.form.on('switch(' + x + ')', obj => obj.elem.value = obj.elem.checked)
});
layui.cron.render({
elem: '[name="cronExpr"]',
btns: ['confirm'],
cssPath: '/admin/v1/static/css/cron.css'
});
layui.form.on('select(indexCodeFilter)', async function (obj) {
const paramsEl = document.getElementById('params'); // 参数选择控件
const periodsEl = document.getElementById('periods'); // 时间粒度选择控件
paramsEl.textContent = periodsEl.textContent = ''; // 清除参数和时间粒度选择控件
const dataset = obj.elem.querySelector(`[value="${obj.value}"]`).dataset;
const detailTriggerEl = document.getElementById('showIndexDetailLayer');
if (!dataset || !dataset.indInfo) {
// 未存在 dataset/.indInfo, 则去除可能存在的指标详情按钮
detailTriggerEl.style.display = 'none';
return;
}
detailTriggerEl.style.display = '';
detailTriggerEl.dataset.indexCode = obj.value;
detailTriggerEl.dataset.indexName = dataset.indName;
const indInfo =
JSON.parse(dataset.indInfo);
const paramTemplet = document.getElementById('paramTemplet');
const periodsTemplet = document.getElementById('periodsTemplet');
if (indInfo.indParam) {
var tpl = layui.laytpl(paramTemplet.textContent);
for (var i = 0; i < indInfo.indParam.pName.length; i++) {
var d = {
pName: indInfo.indParam.pName[i],
pMin: indInfo.indParam.min[i],
pMax: indInfo.indParam.max[i],
pValue: indInfo.indParam.value[i]
}
if (obj.params && obj.params[d.pName]) {
d.pValue = obj.params[d.pName]
}
paramsEl.insertAdjacentHTML('beforeend', tpl.render(d));
}
}
var periods = Helper.allEmoneyPeriods;
if (indInfo.supportPeriod) periods = indInfo.supportPeriod;
periodsEl.insertAdjacentHTML('beforeend', periodsTemplet.textContent);
var data = [];
for (var i = 0; i < periods.length; i++) {
var period = periods[i];
data.push({
name: Helper.emoneyPeriodToName(period),
value: period,
selected: r.data.periods.indexOf(period + '') != -1
})
}
layui.xmSelect.render({
name: 'periods',
el: '#period-xm-select',
language: 'zn',
data: data,
autoRow: !0
})
layui.form.render()
})
const loadConfigLayer = layui.layer.load(2);
const json = await (await fetch('/admin/v1/manage/indexInfo/getFields?fields=configIndOnline')).json();
if (json.ok) {
const indexNameSelectEl = document.querySelector('[name="indexCode"]');
const jo = json.data.configIndOnline, indMap = jo.indMap;
let selected = undefined;
const optionsFragment = document.createDocumentFragment();
Object.keys(indMap).forEach(key => {
const indInfo = indMap[key];
if (indInfo.isCalc) return;
if (r.data.indexCode == key) selected = key;
const option = document.createElement('option');
option.value = key;
option.textContent = `${key} ${jo.indIdNameConfig[key]}`;
option.selected = r.data.indexCode == key;
option.dataset.indInfo = JSON.stringify(indInfo);
option.dataset.indName = jo.indIdNameConfig[key];
optionsFragment.appendChild(option);
})
indexNameSelectEl.appendChild(optionsFragment);
if (selected)
// 如果有选择内容, 手动触发指标选择下拉框,并附带参数
layui.event.call(this, 'form', 'select(indexCodeFilter)', {
elem: indexNameSelectEl,
value: selected,
params: r.data.params
});
layui.form.render();
}
else {
Dog.error({
msg: json.data || '获取指标配置错误'
});
layui.layer.close(window.editLayer);
}
layui.form.render();
layui.layer.close(loadConfigLayer);
}
})
}
else Dog.error({
msg: r && r.data || '服务器错误'
});
}
async function openNewForm(planId) {
const json = await (await fetch((() => {
const url = '/admin/v1/manage/plan/getOne';
if (planId) return url + '?planId=' + planId;
return url
})())).json();
if (json.ok) {
openEditForm(json)
}
else {
Dog.error()
}
}
layui.table.on('tool(plans)', async function (obj) {
if (obj.event == 'edit') {
openNewForm(obj.data.planId)
}
else if (obj.event == 'del') {
layui.layer.confirm('确定删除该计划任务吗?', function (index) {
layui.layer.close(index);
const load = layui.layer.load(2);
$.ajax({
url: '/admin/v1/manage/plan/delete',
method: 'POST',
data: {planId: obj.data.planId},
success: () => Dog.success({
msg: '删除成功', onClose: () => Dog.reloadTable('plans')
}),
error: res => Dog.error({msg: res}),
complete: () => layui.layer.close(load)
})
})
}
})
</script>
</div>
</html>

View File

@@ -11,9 +11,8 @@
<div> <div>
<h1 class="manage-title"> <h1 class="manage-title">
<i class="fa-fw fa-regular fa-calendar"></i> <i class="fa-fw fa-regular fa-calendar"></i>
<b>计划任务</b><a href="javascript:openNewForm()" class="operate">新增</a> <b>计划任务</b><a href="javascript:openEditForm()" class="operate">新增</a>
</h1> </h1>
</div> </div>
<button class="layui-btn layui-btn-sm operdown"> <button class="layui-btn layui-btn-sm operdown">
<span>选中项<i <span>选中项<i
@@ -38,7 +37,7 @@
xmSelect: '/admin/v1/static/layuiadmin/lib/xm-select', xmSelect: '/admin/v1/static/layuiadmin/lib/xm-select',
cron: '/admin/v1/static/layuiadmin/lib/cron' cron: '/admin/v1/static/layuiadmin/lib/cron'
}) })
.use(['table', 'form', 'dropdown', 'layer', 'xmSelect', 'cron'], function(){ .use(['table', 'form', 'dropdown', 'layer', 'xmSelect', 'cron'], function() {
var dropdown = layui.dropdown, table = layui.table, form = layui.form; var dropdown = layui.dropdown, table = layui.table, form = layui.form;
Helper.renderTable({ Helper.renderTable({
elem: '#plans', elem: '#plans',
@@ -50,6 +49,15 @@
{field:'openDayCheck', title: '交易日校验', width: 95, switchTemplet: true}, {field:'openDayCheck', title: '交易日校验', width: 95, switchTemplet: true},
{field:'planId', hide: true, width: 60, title: 'ID'}, {field:'planId', hide: true, width: 60, title: 'ID'},
{field:'planName', title: '计划名称'}, {field:'planName', title: '计划名称'},
{field:'planType', title: '计划类型', templet: d => {
switch(d.planType) {
case 'SINGLE_INDEX': return '单指标';
case 'MULTI_INDEX': return '多指标';
case 'STOCK_STRATEGY': return '个股策略';
case 'STOCK_PICKING': return '策略选股';
}
return '';
}},
{field:'cronExpr', title: '计划表达式'}, {field:'cronExpr', title: '计划表达式'},
{field:'indexCode', title: '指标代码'}, {field:'indexCode', title: '指标代码'},
{field:'params', title: '请求参数', templet: function(d) { {field:'params', title: '请求参数', templet: function(d) {
@@ -79,9 +87,43 @@
tableFilter: 'plans', tableFilter: 'plans',
idName: 'planId' idName: 'planId'
}); });
layui.table.on('tool(plans)', async function (obj) {
if (obj.event == 'edit') {
openEditForm(obj.data.planId)
}
else if (obj.event == 'del') {
layui.layer.confirm('确定删除该计划任务吗?', function (index) {
layui.layer.close(index);
const load = layui.layer.load(2);
$.ajax({
url: '/admin/v1/manage/plan/delete',
method: 'POST',
data: {planId: obj.data.planId},
success: () => Dog.success({
msg: '删除成功', onClose: () => Dog.reloadTable('plans')
}),
error: res => Dog.error({msg: res}),
complete: () => layui.layer.close(load)
}) })
})
}
})
}); // layui.use
function openEditForm(planId) {
window.editLayer = Helper.openR({
type: 2,
title: '编辑计划任务',
btn: ['提交', '关闭'],
yes: function (index, layero) {
const iframe = document.querySelector('iframe');
const doc = iframe.contentDocument || iframe.contentWindow.document;
doc.querySelector('[lay-filter="submitPlan"]').click();
},
area: '40%',
content: '/admin/v1/manage/plan/edit' + (planId !== undefined ? '?planId=' + planId : '')
});
}
</script> </script>
<th:block th:replace="~{admin/v1/manage/plan/include::planExtra}"></th:block>
</body> </body>
</html> </html>

View File

@@ -25,7 +25,7 @@ class EmoneyAutoApplicationTests {
@Test @Test
void contextLoads() { void contextLoads() {
EmoneyClient.relogin(); EmoneyClient.reloginCheck();
CandleStickWithIndex_Request request = new CandleStickWithIndex_Request(); CandleStickWithIndex_Request request = new CandleStickWithIndex_Request();

View File

@@ -65,7 +65,7 @@ public class EmoneyIndexScraper {
@Test @Test
void test() { void test() {
EmoneyClient.relogin(); EmoneyClient.reloginCheck();
String url = buildUrl(10002800); String url = buildUrl(10002800);

View File

@@ -25,7 +25,7 @@ class EmoneyStrategyMarkTests {
@Test @Test
void contextLoads() { void contextLoads() {
EmoneyClient.relogin(); EmoneyClient.reloginCheck();
StrategyMark_Request request = new StrategyMark_Request(); StrategyMark_Request request = new StrategyMark_Request();
request.setGoodsId(600325); request.setGoodsId(600325);

View File

@@ -0,0 +1,48 @@
package quant.rich.emoney;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import org.junit.jupiter.api.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import quant.rich.emoney.service.postgre.StockStrategyService;
/**
* 测试从本地读取包含 stock strategy 的 json, 转换为列表并更新到数据库中
*/
@SpringBootTest
@ContextConfiguration(classes = EmoneyAutoApplication.class)
@RunWith(SpringJUnit4ClassRunner.class)
@Slf4j
public class StockStrategyUpdateTest {
@Autowired
ObjectMapper objectMapper;
@Autowired
StockStrategyService stockStrategyService;
@Test
void test() throws IOException, InterruptedException, ExecutionException {
// 首先从 tushare-data-service feign 客户端取数据
String str = Files.readString(Path.of("C:\\Users\\Administrator\\Desktop\\stock_strategy.json"));
JsonNode node = objectMapper.readTree(str);
Future<Boolean> future = stockStrategyService.updateByQueryResponseAsync(node);
future.get();
}
}