Compare commits
10 Commits
99f792f6de
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 07d8193e80 | |||
| 39a6daf8fe | |||
| cff4fe8da9 | |||
| 5d3c7d8692 | |||
| 14c78d54f5 | |||
| b5ba6912eb | |||
| cac0fdf858 | |||
| 373d7f8201 | |||
| 219fa566ef | |||
| 0a8a7c8046 |
16
Dockerfile
@@ -1,25 +1,27 @@
|
|||||||
FROM eclipse-temurin:17-jre
|
FROM --platform=linux/amd64 swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/eclipse-temurin:17-jre
|
||||||
|
|
||||||
ENV LANG=C.UTF-8
|
ENV LANG=C.UTF-8
|
||||||
ENV LC_ALL=C.UTF-8
|
ENV LC_ALL=C.UTF-8
|
||||||
ENV TZ=Asia/Shanghai
|
ENV TZ=Asia/Shanghai
|
||||||
ENV JAVA_OPTS=""
|
ENV JAVA_OPTS=""
|
||||||
ENV EASYFLOW_JAR_PATH=/app/easyflow.jar
|
ENV EASYFLOW_JAR_PATH=/app/artifacts/easyflow.jar
|
||||||
ENV EASYFLOW_CONFIG_PATH=file:/app/application.yml
|
ENV EASYFLOW_CONFIG_PATH=file:/app/application.yml
|
||||||
ENV EASYFLOW_LOG_FILE=/app/logs/app.log
|
ENV EASYFLOW_LOG_FILE=/app/logs/app.log
|
||||||
|
ENV EASYFLOW_JAR_RESTART_GRACE_SECONDS=30
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN useradd --system --create-home easyflow && \
|
RUN useradd --system --create-home easyflow && \
|
||||||
apt-get update && \
|
apt-get update && \
|
||||||
apt-get install -y --no-install-recommends python3 && \
|
apt-get install -y --no-install-recommends python3 inotify-tools tini && \
|
||||||
rm -rf /var/lib/apt/lists/* && \
|
rm -rf /var/lib/apt/lists/* && \
|
||||||
mkdir -p /app/logs && \
|
mkdir -p /app/logs /app/artifacts /app/data && \
|
||||||
chown -R easyflow:easyflow /app
|
chown -R easyflow:easyflow /app
|
||||||
|
|
||||||
USER easyflow
|
COPY docker-entrypoint.sh /usr/local/bin/easyflow-entrypoint.sh
|
||||||
|
RUN chmod 755 /usr/local/bin/easyflow-entrypoint.sh
|
||||||
|
|
||||||
VOLUME ["/app/logs"]
|
VOLUME ["/app/logs", "/app/data"]
|
||||||
EXPOSE 8111
|
EXPOSE 8111
|
||||||
|
|
||||||
ENTRYPOINT ["sh", "-c", "if [ ! -f \"${EASYFLOW_JAR_PATH}\" ]; then echo \"ERROR: easyflow jar not found: ${EASYFLOW_JAR_PATH}\"; exit 1; fi; java ${JAVA_OPTS} -Djava.security.egd=file:/dev/./urandom -jar \"${EASYFLOW_JAR_PATH}\" --spring.config.location=\"${EASYFLOW_CONFIG_PATH}\" --logging.file.name=\"${EASYFLOW_LOG_FILE}\""]
|
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/easyflow-entrypoint.sh"]
|
||||||
|
|||||||
175
docker-entrypoint.sh
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
JAR_PATH="${EASYFLOW_JAR_PATH:-/app/artifacts/easyflow.jar}"
|
||||||
|
CONFIG_PATH="${EASYFLOW_CONFIG_PATH:-file:/app/application.yml}"
|
||||||
|
LOG_FILE="${EASYFLOW_LOG_FILE:-/app/logs/app.log}"
|
||||||
|
RESTART_GRACE_SECONDS="${EASYFLOW_JAR_RESTART_GRACE_SECONDS:-30}"
|
||||||
|
ENTRYPOINT_PRIVS_DROPPED="${EASYFLOW_ENTRYPOINT_PRIVS_DROPPED:-0}"
|
||||||
|
|
||||||
|
JAVA_PID=""
|
||||||
|
WATCHER_PID=""
|
||||||
|
STOP_REQUESTED=0
|
||||||
|
RESTART_REASON_FILE="/tmp/easyflow-restart-reason"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_runtime_permissions() {
|
||||||
|
for path in /app/logs /app/data; do
|
||||||
|
mkdir -p "$path"
|
||||||
|
chown -R easyflow:easyflow "$path"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
drop_privileges_if_needed() {
|
||||||
|
if [ "$(id -u)" -ne 0 ] || [ "$ENTRYPOINT_PRIVS_DROPPED" = "1" ]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
ensure_runtime_permissions
|
||||||
|
export EASYFLOW_ENTRYPOINT_PRIVS_DROPPED=1
|
||||||
|
exec setpriv --reuid=easyflow --regid=easyflow --init-groups "$0" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_prerequisites() {
|
||||||
|
if ! command -v inotifywait >/dev/null 2>&1; then
|
||||||
|
log "ERROR: inotifywait not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$JAR_PATH" ]; then
|
||||||
|
log "ERROR: easyflow jar not found: $JAR_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$LOG_FILE")"
|
||||||
|
}
|
||||||
|
|
||||||
|
compute_jar_digest() {
|
||||||
|
sha256sum "$JAR_PATH" | awk '{print $1}'
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_watcher() {
|
||||||
|
if [ -n "$WATCHER_PID" ] && kill -0 "$WATCHER_PID" 2>/dev/null; then
|
||||||
|
kill "$WATCHER_PID" 2>/dev/null || true
|
||||||
|
wait "$WATCHER_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
WATCHER_PID=""
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_java_process() {
|
||||||
|
if [ -z "$JAVA_PID" ] || ! kill -0 "$JAVA_PID" 2>/dev/null; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
kill -TERM "$JAVA_PID" 2>/dev/null || true
|
||||||
|
|
||||||
|
remaining="$RESTART_GRACE_SECONDS"
|
||||||
|
while kill -0 "$JAVA_PID" 2>/dev/null; do
|
||||||
|
if [ "$remaining" -le 0 ]; then
|
||||||
|
log "java process did not stop within ${RESTART_GRACE_SECONDS}s, forcing shutdown"
|
||||||
|
kill -KILL "$JAVA_PID" 2>/dev/null || true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
remaining=$((remaining - 1))
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
handle_shutdown() {
|
||||||
|
STOP_REQUESTED=1
|
||||||
|
printf '%s' "shutdown" >"$RESTART_REASON_FILE"
|
||||||
|
stop_watcher
|
||||||
|
stop_java_process
|
||||||
|
}
|
||||||
|
|
||||||
|
trap 'handle_shutdown' TERM INT
|
||||||
|
|
||||||
|
start_java() {
|
||||||
|
log "starting jar: $JAR_PATH"
|
||||||
|
# JAVA_OPTS needs shell word splitting to preserve user-provided JVM args.
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
java ${JAVA_OPTS:-} -Djava.security.egd=file:/dev/./urandom -jar "$JAR_PATH" \
|
||||||
|
--spring.config.location="$CONFIG_PATH" \
|
||||||
|
--logging.file.name="$LOG_FILE" &
|
||||||
|
JAVA_PID=$!
|
||||||
|
}
|
||||||
|
|
||||||
|
start_watcher() {
|
||||||
|
jar_digest="$1"
|
||||||
|
jar_dir="$(dirname "$JAR_PATH")"
|
||||||
|
jar_name="$(basename "$JAR_PATH")"
|
||||||
|
|
||||||
|
(
|
||||||
|
while true; do
|
||||||
|
event_line="$(inotifywait --quiet --event close_write,moved_to --format '%e %f' "$jar_dir")" || exit 0
|
||||||
|
event_name="${event_line%% *}"
|
||||||
|
event_file="${event_line#* }"
|
||||||
|
|
||||||
|
if [ "$event_file" != "$jar_name" ]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$JAR_PATH" ]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
new_digest="$(compute_jar_digest)"
|
||||||
|
if [ "$new_digest" = "$jar_digest" ]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "detected jar update (${event_name}), restarting java process"
|
||||||
|
printf '%s' "jar_changed" >"$RESTART_REASON_FILE"
|
||||||
|
stop_java_process
|
||||||
|
exit 0
|
||||||
|
done
|
||||||
|
) &
|
||||||
|
|
||||||
|
WATCHER_PID=$!
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
drop_privileges_if_needed "$@"
|
||||||
|
ensure_prerequisites
|
||||||
|
|
||||||
|
current_digest="$(compute_jar_digest)"
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
rm -f "$RESTART_REASON_FILE"
|
||||||
|
start_java
|
||||||
|
start_watcher "$current_digest"
|
||||||
|
|
||||||
|
set +e
|
||||||
|
wait "$JAVA_PID"
|
||||||
|
java_status=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
stop_watcher
|
||||||
|
JAVA_PID=""
|
||||||
|
|
||||||
|
restart_reason=""
|
||||||
|
if [ -f "$RESTART_REASON_FILE" ]; then
|
||||||
|
restart_reason="$(cat "$RESTART_REASON_FILE")"
|
||||||
|
rm -f "$RESTART_REASON_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$STOP_REQUESTED" -eq 1 ] || [ "$restart_reason" = "shutdown" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$restart_reason" = "jar_changed" ]; then
|
||||||
|
ensure_prerequisites
|
||||||
|
current_digest="$(compute_jar_digest)"
|
||||||
|
log "restarting java with updated jar"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "java process exited unexpectedly with code $java_status"
|
||||||
|
exit "$java_status"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -92,13 +92,6 @@ public class ModelController extends BaseCurdController<ModelService, Model> {
|
|||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/updateByEntity")
|
|
||||||
@SaCheckPermission("/api/v1/model/save")
|
|
||||||
public Result<?> updateByEntity(@RequestBody Model entity) {
|
|
||||||
modelService.updateByEntity(entity);
|
|
||||||
return Result.ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/selectLlmByProviderCategory")
|
@GetMapping("/selectLlmByProviderCategory")
|
||||||
@SaCheckPermission("/api/v1/model/query")
|
@SaCheckPermission("/api/v1/model/query")
|
||||||
public Result<Map<String, List<Model>>> selectLlmByProviderCategory(Model entity, String sortKey, String sortType) {
|
public Result<Map<String, List<Model>>> selectLlmByProviderCategory(Model entity, String sortKey, String sortType) {
|
||||||
@@ -129,19 +122,6 @@ public class ModelController extends BaseCurdController<ModelService, Model> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 添加所有模型
|
|
||||||
*
|
|
||||||
* @param entity
|
|
||||||
* @return
|
|
||||||
*/
|
|
||||||
@PostMapping("addAllLlm")
|
|
||||||
public Result<?> addAllLlm(@JsonBody Model entity) {
|
|
||||||
QueryWrapper queryWrapper = QueryWrapper.create().eq(Model::getProviderId, entity.getProviderId());
|
|
||||||
service.update(entity, queryWrapper);
|
|
||||||
return Result.ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/selectLlmList")
|
@GetMapping("/selectLlmList")
|
||||||
@SaCheckPermission("/api/v1/model/query")
|
@SaCheckPermission("/api/v1/model/query")
|
||||||
public Result<List<Model>> selectLlmList(Model entity, Boolean asTree, String sortKey, String sortType) {
|
public Result<List<Model>> selectLlmList(Model entity, Boolean asTree, String sortKey, String sortType) {
|
||||||
@@ -154,15 +134,6 @@ public class ModelController extends BaseCurdController<ModelService, Model> {
|
|||||||
return Result.ok(totalList);
|
return Result.ok(totalList);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
protected Result<?> onSaveOrUpdateBefore(Model entity, boolean isSave) {
|
|
||||||
if (isSave) {
|
|
||||||
entity.setWithUsed(true);
|
|
||||||
}
|
|
||||||
return super.onSaveOrUpdateBefore(entity, isSave);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@PostMapping("removeLlmByIds")
|
@PostMapping("removeLlmByIds")
|
||||||
@Transactional
|
@Transactional
|
||||||
public Result<?> removeLlm(@JsonBody(value = "id", required = true) Serializable id) {
|
public Result<?> removeLlm(@JsonBody(value = "id", required = true) Serializable id) {
|
||||||
@@ -171,4 +142,4 @@ public class ModelController extends BaseCurdController<ModelService, Model> {
|
|||||||
service.remove(queryWrapper);
|
service.remove(queryWrapper);
|
||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,32 @@
|
|||||||
package tech.easyflow.admin.controller.system;
|
package tech.easyflow.admin.controller.system;
|
||||||
|
|
||||||
import tech.easyflow.common.constant.enums.EnumAccountType;
|
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||||
import tech.easyflow.common.constant.enums.EnumDataStatus;
|
|
||||||
import tech.easyflow.common.domain.Result;
|
|
||||||
import tech.easyflow.common.entity.LoginAccount;
|
|
||||||
import tech.easyflow.common.util.StringUtil;
|
|
||||||
|
|
||||||
import tech.easyflow.common.web.controller.BaseCurdController;
|
|
||||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
|
||||||
import tech.easyflow.log.annotation.LogRecord;
|
|
||||||
import tech.easyflow.system.entity.SysAccount;
|
|
||||||
import tech.easyflow.system.service.SysAccountService;
|
|
||||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
|
||||||
import cn.dev33.satoken.stp.StpUtil;
|
import cn.dev33.satoken.stp.StpUtil;
|
||||||
import cn.hutool.crypto.digest.BCrypt;
|
import cn.hutool.crypto.digest.BCrypt;
|
||||||
import com.mybatisflex.core.paginate.Page;
|
import com.mybatisflex.core.paginate.Page;
|
||||||
import com.mybatisflex.core.query.QueryWrapper;
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import org.springframework.dao.DuplicateKeyException;
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import tech.easyflow.common.constant.enums.EnumAccountType;
|
||||||
|
import tech.easyflow.common.constant.enums.EnumDataStatus;
|
||||||
|
import tech.easyflow.common.domain.Result;
|
||||||
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
|
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||||
|
import tech.easyflow.common.util.StringUtil;
|
||||||
|
import tech.easyflow.common.web.controller.BaseCurdController;
|
||||||
|
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||||
|
import tech.easyflow.log.annotation.LogRecord;
|
||||||
|
import tech.easyflow.system.entity.SysAccount;
|
||||||
|
import tech.easyflow.system.entity.vo.SysAccountImportResultVo;
|
||||||
|
import tech.easyflow.system.service.SysAccountService;
|
||||||
|
import tech.easyflow.system.util.SysPasswordPolicy;
|
||||||
|
|
||||||
|
import java.net.URLEncoder;
|
||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
@@ -62,13 +67,21 @@ public class SysAccountController extends BaseCurdController<SysAccountService,
|
|||||||
return Result.fail(1, "用户名已存在");
|
return Result.fail(1, "用户名已存在");
|
||||||
}
|
}
|
||||||
String password = entity.getPassword();
|
String password = entity.getPassword();
|
||||||
if (StringUtil.hasText(password)) {
|
if (!StringUtil.hasText(password)) {
|
||||||
entity.setPassword(BCrypt.hashpw(password));
|
return Result.fail(1, "密码不能为空");
|
||||||
}
|
}
|
||||||
|
SysPasswordPolicy.validateStrongPassword(password);
|
||||||
|
entity.setPassword(BCrypt.hashpw(password));
|
||||||
Integer status = entity.getStatus();
|
Integer status = entity.getStatus();
|
||||||
if (status == null) {
|
if (status == null) {
|
||||||
entity.setStatus(EnumDataStatus.AVAILABLE.getCode());
|
entity.setStatus(EnumDataStatus.AVAILABLE.getCode());
|
||||||
}
|
}
|
||||||
|
if (entity.getAccountType() == null) {
|
||||||
|
entity.setAccountType(EnumAccountType.NORMAL.getCode());
|
||||||
|
}
|
||||||
|
if (entity.getPasswordResetRequired() == null) {
|
||||||
|
entity.setPasswordResetRequired(false);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
SysAccount record = service.getById(entity.getId());
|
SysAccount record = service.getById(entity.getId());
|
||||||
// 如果修改了部门,就将用户踢下线,避免用户操作数据造成数据错误
|
// 如果修改了部门,就将用户踢下线,避免用户操作数据造成数据错误
|
||||||
@@ -149,15 +162,40 @@ public class SysAccountController extends BaseCurdController<SysAccountService,
|
|||||||
if (!newPassword.equals(confirmPassword)) {
|
if (!newPassword.equals(confirmPassword)) {
|
||||||
return Result.fail(2, "两次密码不一致");
|
return Result.fail(2, "两次密码不一致");
|
||||||
}
|
}
|
||||||
|
SysPasswordPolicy.validateStrongPassword(newPassword);
|
||||||
SysAccount update = new SysAccount();
|
SysAccount update = new SysAccount();
|
||||||
update.setId(loginAccountId);
|
update.setId(loginAccountId);
|
||||||
update.setPassword(BCrypt.hashpw(newPassword));
|
update.setPassword(BCrypt.hashpw(newPassword));
|
||||||
|
update.setPasswordResetRequired(false);
|
||||||
update.setModified(new Date());
|
update.setModified(new Date());
|
||||||
update.setModifiedBy(loginAccountId);
|
update.setModifiedBy(loginAccountId);
|
||||||
service.updateById(update);
|
service.updateById(update);
|
||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/resetPassword")
|
||||||
|
@SaCheckPermission("/api/v1/sysAccount/save")
|
||||||
|
public Result<Void> resetPassword(@JsonBody(value = "id", required = true) BigInteger id) {
|
||||||
|
service.resetPassword(id, SaTokenUtil.getLoginAccount().getId());
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/importExcel")
|
||||||
|
@SaCheckPermission("/api/v1/sysAccount/save")
|
||||||
|
public Result<SysAccountImportResultVo> importExcel(MultipartFile file) {
|
||||||
|
return Result.ok(service.importAccounts(file, SaTokenUtil.getLoginAccount()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/downloadImportTemplate")
|
||||||
|
@SaCheckPermission("/api/v1/sysAccount/query")
|
||||||
|
public void downloadImportTemplate(HttpServletResponse response) throws Exception {
|
||||||
|
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||||
|
response.setCharacterEncoding("utf-8");
|
||||||
|
String fileName = URLEncoder.encode("user_import_template", "UTF-8").replaceAll("\\+", "%20");
|
||||||
|
response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx");
|
||||||
|
service.writeImportTemplate(response.getOutputStream());
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@PostMapping("save")
|
@PostMapping("save")
|
||||||
public Result<?> save(@JsonBody SysAccount entity) {
|
public Result<?> save(@JsonBody SysAccount entity) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import tech.easyflow.common.satoken.util.SaTokenUtil;
|
|||||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||||
import tech.easyflow.system.entity.SysAccount;
|
import tech.easyflow.system.entity.SysAccount;
|
||||||
import tech.easyflow.system.service.SysAccountService;
|
import tech.easyflow.system.service.SysAccountService;
|
||||||
|
import tech.easyflow.system.util.SysPasswordPolicy;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
@@ -80,12 +81,14 @@ public class UcSysAccountController {
|
|||||||
if (!newPassword.equals(confirmPassword)) {
|
if (!newPassword.equals(confirmPassword)) {
|
||||||
return Result.fail(2, "两次密码不一致");
|
return Result.fail(2, "两次密码不一致");
|
||||||
}
|
}
|
||||||
|
SysPasswordPolicy.validateStrongPassword(newPassword);
|
||||||
SysAccount update = new SysAccount();
|
SysAccount update = new SysAccount();
|
||||||
update.setId(loginAccountId);
|
update.setId(loginAccountId);
|
||||||
update.setPassword(BCrypt.hashpw(newPassword));
|
update.setPassword(BCrypt.hashpw(newPassword));
|
||||||
|
update.setPasswordResetRequired(false);
|
||||||
update.setModified(new Date());
|
update.setModified(new Date());
|
||||||
update.setModifiedBy(loginAccountId);
|
update.setModifiedBy(loginAccountId);
|
||||||
service.updateById(update);
|
service.updateById(update);
|
||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.mybatisflex.annotation.Column;
|
|||||||
import com.mybatisflex.annotation.Id;
|
import com.mybatisflex.annotation.Id;
|
||||||
import com.mybatisflex.annotation.KeyType;
|
import com.mybatisflex.annotation.KeyType;
|
||||||
import com.mybatisflex.core.handler.FastjsonTypeHandler;
|
import com.mybatisflex.core.handler.FastjsonTypeHandler;
|
||||||
|
|
||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -103,12 +104,6 @@ public class ModelBase implements Serializable {
|
|||||||
@Column(comment = "模型类型: chatModel/embeddingModel/rerankModel/orc..")
|
@Column(comment = "模型类型: chatModel/embeddingModel/rerankModel/orc..")
|
||||||
private String modelType;
|
private String modelType;
|
||||||
|
|
||||||
/**
|
|
||||||
* 是否使用
|
|
||||||
*/
|
|
||||||
@Column(comment = "是否使用")
|
|
||||||
private Boolean withUsed;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 是否支持推理
|
* 是否支持推理
|
||||||
*/
|
*/
|
||||||
@@ -277,14 +272,6 @@ public class ModelBase implements Serializable {
|
|||||||
this.modelType = modelType;
|
this.modelType = modelType;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Boolean getWithUsed() {
|
|
||||||
return withUsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setWithUsed(Boolean withUsed) {
|
|
||||||
this.withUsed = withUsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Boolean getSupportThinking() {
|
public Boolean getSupportThinking() {
|
||||||
return supportThinking;
|
return supportThinking;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
package tech.easyflow.ai.service;
|
package tech.easyflow.ai.service;
|
||||||
|
|
||||||
import tech.easyflow.ai.entity.Model;
|
|
||||||
import com.mybatisflex.core.service.IService;
|
import com.mybatisflex.core.service.IService;
|
||||||
import tech.easyflow.common.domain.Result;
|
import tech.easyflow.ai.entity.Model;
|
||||||
|
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -25,6 +24,4 @@ public interface ModelService extends IService<Model> {
|
|||||||
void removeByEntity(Model entity);
|
void removeByEntity(Model entity);
|
||||||
|
|
||||||
Model getModelInstance(BigInteger modelId);
|
Model getModelInstance(BigInteger modelId);
|
||||||
|
|
||||||
void updateByEntity(Model entity);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
package tech.easyflow.ai.service.impl;
|
package tech.easyflow.ai.service.impl;
|
||||||
|
|
||||||
import cn.hutool.core.util.StrUtil;
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
import com.alicp.jetcache.Cache;
|
||||||
import com.easyagents.core.document.Document;
|
import com.easyagents.core.document.Document;
|
||||||
import com.easyagents.core.model.chat.ChatModel;
|
import com.easyagents.core.model.chat.ChatModel;
|
||||||
import com.easyagents.core.model.chat.ChatOptions;
|
import com.easyagents.core.model.chat.ChatOptions;
|
||||||
import com.easyagents.core.model.embedding.EmbeddingModel;
|
import com.easyagents.core.model.embedding.EmbeddingModel;
|
||||||
import com.easyagents.core.model.rerank.RerankModel;
|
import com.easyagents.core.model.rerank.RerankModel;
|
||||||
import com.easyagents.core.store.VectorData;
|
import com.easyagents.core.store.VectorData;
|
||||||
import com.alicp.jetcache.Cache;
|
|
||||||
import com.mybatisflex.core.query.QueryWrapper;
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import com.mybatisflex.spring.service.impl.ServiceImpl;
|
import com.mybatisflex.spring.service.impl.ServiceImpl;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -21,7 +21,6 @@ import tech.easyflow.ai.entity.ModelProvider;
|
|||||||
import tech.easyflow.ai.mapper.ModelMapper;
|
import tech.easyflow.ai.mapper.ModelMapper;
|
||||||
import tech.easyflow.ai.service.ModelProviderService;
|
import tech.easyflow.ai.service.ModelProviderService;
|
||||||
import tech.easyflow.ai.service.ModelService;
|
import tech.easyflow.ai.service.ModelService;
|
||||||
import tech.easyflow.common.domain.Result;
|
|
||||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
@@ -92,7 +91,6 @@ public class ModelServiceImpl extends ServiceImpl<ModelMapper, Model> implements
|
|||||||
|
|
||||||
QueryWrapper queryWrapper = new QueryWrapper()
|
QueryWrapper queryWrapper = new QueryWrapper()
|
||||||
.eq(Model::getProviderId, entity.getProviderId());
|
.eq(Model::getProviderId, entity.getProviderId());
|
||||||
queryWrapper.eq(Model::getWithUsed, entity.getWithUsed());
|
|
||||||
List<Model> totalList = modelMapper.selectListWithRelationsByQuery(queryWrapper);
|
List<Model> totalList = modelMapper.selectListWithRelationsByQuery(queryWrapper);
|
||||||
for (String modelType : Model.MODEL_TYPES) {
|
for (String modelType : Model.MODEL_TYPES) {
|
||||||
Map<String, List<Model>> groupMap = groupLlmByGroupName(totalList, modelType);
|
Map<String, List<Model>> groupMap = groupLlmByGroupName(totalList, modelType);
|
||||||
@@ -212,13 +210,4 @@ public class ModelServiceImpl extends ServiceImpl<ModelMapper, Model> implements
|
|||||||
|
|
||||||
return model;
|
return model;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public void updateByEntity(Model entity) {
|
|
||||||
QueryWrapper queryWrapper = QueryWrapper.create().eq(Model::getProviderId, entity.getProviderId())
|
|
||||||
.eq(Model::getGroupName, entity.getGroupName());
|
|
||||||
Model model = new Model();
|
|
||||||
model.setWithUsed(entity.getWithUsed());
|
|
||||||
modelMapper.updateByQuery(model, queryWrapper);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ public class LoginVO {
|
|||||||
*/
|
*/
|
||||||
private String avatar;
|
private String avatar;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否强制修改密码
|
||||||
|
*/
|
||||||
|
private boolean forceChangePassword;
|
||||||
|
|
||||||
public String getToken() {
|
public String getToken() {
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
@@ -38,4 +43,12 @@ public class LoginVO {
|
|||||||
public void setAvatar(String avatar) {
|
public void setAvatar(String avatar) {
|
||||||
this.avatar = avatar;
|
this.avatar = avatar;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean isForceChangePassword() {
|
||||||
|
return forceChangePassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setForceChangePassword(boolean forceChangePassword) {
|
||||||
|
this.forceChangePassword = forceChangePassword;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ public class AuthServiceImpl implements AuthService, StpInterface {
|
|||||||
res.setToken(StpUtil.getTokenValue());
|
res.setToken(StpUtil.getTokenValue());
|
||||||
res.setNickname(record.getNickname());
|
res.setNickname(record.getNickname());
|
||||||
res.setAvatar(record.getAvatar());
|
res.setAvatar(record.getAvatar());
|
||||||
|
res.setForceChangePassword(Boolean.TRUE.equals(record.getPasswordResetRequired()));
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ public class SysAccountBase extends DateEntity implements Serializable {
|
|||||||
@Column(comment = "密码")
|
@Column(comment = "密码")
|
||||||
private String password;
|
private String password;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否需要重置密码
|
||||||
|
*/
|
||||||
|
@Column(comment = "是否需要重置密码")
|
||||||
|
private Boolean passwordResetRequired;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 账户类型
|
* 账户类型
|
||||||
*/
|
*/
|
||||||
@@ -149,6 +155,14 @@ public class SysAccountBase extends DateEntity implements Serializable {
|
|||||||
this.password = password;
|
this.password = password;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Boolean getPasswordResetRequired() {
|
||||||
|
return passwordResetRequired;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPasswordResetRequired(Boolean passwordResetRequired) {
|
||||||
|
this.passwordResetRequired = passwordResetRequired;
|
||||||
|
}
|
||||||
|
|
||||||
public Integer getAccountType() {
|
public Integer getAccountType() {
|
||||||
return accountType;
|
return accountType;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package tech.easyflow.system.entity.vo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户导入失败行。
|
||||||
|
*/
|
||||||
|
public class SysAccountImportErrorRowVo {
|
||||||
|
|
||||||
|
private Integer rowNumber;
|
||||||
|
|
||||||
|
private String deptCode;
|
||||||
|
|
||||||
|
private String loginName;
|
||||||
|
|
||||||
|
private String reason;
|
||||||
|
|
||||||
|
public Integer getRowNumber() {
|
||||||
|
return rowNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRowNumber(Integer rowNumber) {
|
||||||
|
this.rowNumber = rowNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDeptCode() {
|
||||||
|
return deptCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeptCode(String deptCode) {
|
||||||
|
this.deptCode = deptCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLoginName() {
|
||||||
|
return loginName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLoginName(String loginName) {
|
||||||
|
this.loginName = loginName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getReason() {
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setReason(String reason) {
|
||||||
|
this.reason = reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package tech.easyflow.system.entity.vo;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户导入结果。
|
||||||
|
*/
|
||||||
|
public class SysAccountImportResultVo {
|
||||||
|
|
||||||
|
private int successCount = 0;
|
||||||
|
|
||||||
|
private int errorCount = 0;
|
||||||
|
|
||||||
|
private int totalCount = 0;
|
||||||
|
|
||||||
|
private List<SysAccountImportErrorRowVo> errorRows = new ArrayList<>();
|
||||||
|
|
||||||
|
public int getSuccessCount() {
|
||||||
|
return successCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSuccessCount(int successCount) {
|
||||||
|
this.successCount = successCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getErrorCount() {
|
||||||
|
return errorCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setErrorCount(int errorCount) {
|
||||||
|
this.errorCount = errorCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getTotalCount() {
|
||||||
|
return totalCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTotalCount(int totalCount) {
|
||||||
|
this.totalCount = totalCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<SysAccountImportErrorRowVo> getErrorRows() {
|
||||||
|
return errorRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setErrorRows(List<SysAccountImportErrorRowVo> errorRows) {
|
||||||
|
this.errorRows = errorRows;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
package tech.easyflow.system.service;
|
package tech.easyflow.system.service;
|
||||||
|
|
||||||
import com.mybatisflex.core.service.IService;
|
import com.mybatisflex.core.service.IService;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
import tech.easyflow.system.entity.SysAccount;
|
import tech.easyflow.system.entity.SysAccount;
|
||||||
|
import tech.easyflow.system.entity.vo.SysAccountImportResultVo;
|
||||||
|
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户表 服务层。
|
* 用户表 服务层。
|
||||||
@@ -14,4 +20,10 @@ public interface SysAccountService extends IService<SysAccount> {
|
|||||||
void syncRelations(SysAccount entity);
|
void syncRelations(SysAccount entity);
|
||||||
|
|
||||||
SysAccount getByUsername(String userKey);
|
SysAccount getByUsername(String userKey);
|
||||||
|
|
||||||
|
void resetPassword(BigInteger accountId, BigInteger operatorId);
|
||||||
|
|
||||||
|
SysAccountImportResultVo importAccounts(MultipartFile file, LoginAccount loginAccount);
|
||||||
|
|
||||||
|
void writeImportTemplate(OutputStream outputStream);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,54 @@
|
|||||||
package tech.easyflow.system.service.impl;
|
package tech.easyflow.system.service.impl;
|
||||||
|
|
||||||
|
import cn.dev33.satoken.stp.StpUtil;
|
||||||
|
import cn.hutool.crypto.digest.BCrypt;
|
||||||
|
import cn.idev.excel.EasyExcel;
|
||||||
|
import cn.idev.excel.FastExcel;
|
||||||
|
import cn.idev.excel.context.AnalysisContext;
|
||||||
|
import cn.idev.excel.metadata.data.ReadCellData;
|
||||||
|
import cn.idev.excel.read.listener.ReadListener;
|
||||||
import com.mybatisflex.core.query.QueryWrapper;
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import com.mybatisflex.spring.service.impl.ServiceImpl;
|
import com.mybatisflex.spring.service.impl.ServiceImpl;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import tech.easyflow.common.cache.RedisLockExecutor;
|
import tech.easyflow.common.cache.RedisLockExecutor;
|
||||||
|
import tech.easyflow.common.constant.enums.EnumAccountType;
|
||||||
|
import tech.easyflow.common.constant.enums.EnumDataStatus;
|
||||||
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
|
import tech.easyflow.common.util.StringUtil;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
import tech.easyflow.system.entity.SysAccount;
|
import tech.easyflow.system.entity.SysAccount;
|
||||||
import tech.easyflow.system.entity.SysAccountPosition;
|
import tech.easyflow.system.entity.SysAccountPosition;
|
||||||
import tech.easyflow.system.entity.SysAccountRole;
|
import tech.easyflow.system.entity.SysAccountRole;
|
||||||
|
import tech.easyflow.system.entity.SysDept;
|
||||||
|
import tech.easyflow.system.entity.SysPosition;
|
||||||
|
import tech.easyflow.system.entity.SysRole;
|
||||||
|
import tech.easyflow.system.entity.vo.SysAccountImportErrorRowVo;
|
||||||
|
import tech.easyflow.system.entity.vo.SysAccountImportResultVo;
|
||||||
import tech.easyflow.system.mapper.SysAccountMapper;
|
import tech.easyflow.system.mapper.SysAccountMapper;
|
||||||
import tech.easyflow.system.mapper.SysAccountPositionMapper;
|
import tech.easyflow.system.mapper.SysAccountPositionMapper;
|
||||||
import tech.easyflow.system.mapper.SysAccountRoleMapper;
|
import tech.easyflow.system.mapper.SysAccountRoleMapper;
|
||||||
|
import tech.easyflow.system.mapper.SysDeptMapper;
|
||||||
|
import tech.easyflow.system.mapper.SysPositionMapper;
|
||||||
import tech.easyflow.system.mapper.SysRoleMapper;
|
import tech.easyflow.system.mapper.SysRoleMapper;
|
||||||
import tech.easyflow.system.service.SysAccountService;
|
import tech.easyflow.system.service.SysAccountService;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
import java.time.Duration;
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
|
import java.time.Duration;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,6 +63,18 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
|
|||||||
private static final String ACCOUNT_RELATION_LOCK_KEY_PREFIX = "easyflow:lock:sys:account:relation:";
|
private static final String ACCOUNT_RELATION_LOCK_KEY_PREFIX = "easyflow:lock:sys:account:relation:";
|
||||||
private static final Duration LOCK_WAIT_TIMEOUT = Duration.ofSeconds(2);
|
private static final Duration LOCK_WAIT_TIMEOUT = Duration.ofSeconds(2);
|
||||||
private static final Duration LOCK_LEASE_TIMEOUT = Duration.ofSeconds(10);
|
private static final Duration LOCK_LEASE_TIMEOUT = Duration.ofSeconds(10);
|
||||||
|
private static final String DEFAULT_RESET_PASSWORD = "123456";
|
||||||
|
private static final long MAX_IMPORT_FILE_SIZE_BYTES = 10L * 1024 * 1024;
|
||||||
|
private static final int MAX_IMPORT_ROWS = 5000;
|
||||||
|
private static final String IMPORT_HEAD_DEPT_CODE = "部门编码";
|
||||||
|
private static final String IMPORT_HEAD_LOGIN_NAME = "登录账号";
|
||||||
|
private static final String IMPORT_HEAD_NICKNAME = "昵称";
|
||||||
|
private static final String IMPORT_HEAD_MOBILE = "手机号";
|
||||||
|
private static final String IMPORT_HEAD_EMAIL = "邮箱";
|
||||||
|
private static final String IMPORT_HEAD_STATUS = "状态";
|
||||||
|
private static final String IMPORT_HEAD_ROLE_KEYS = "角色编码";
|
||||||
|
private static final String IMPORT_HEAD_POSITION_CODES = "岗位编码";
|
||||||
|
private static final String IMPORT_HEAD_REMARK = "备注";
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private SysAccountRoleMapper sysAccountRoleMapper;
|
private SysAccountRoleMapper sysAccountRoleMapper;
|
||||||
@@ -42,7 +83,13 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
|
|||||||
@Resource
|
@Resource
|
||||||
private SysRoleMapper sysRoleMapper;
|
private SysRoleMapper sysRoleMapper;
|
||||||
@Resource
|
@Resource
|
||||||
|
private SysPositionMapper sysPositionMapper;
|
||||||
|
@Resource
|
||||||
|
private SysDeptMapper sysDeptMapper;
|
||||||
|
@Resource
|
||||||
private RedisLockExecutor redisLockExecutor;
|
private RedisLockExecutor redisLockExecutor;
|
||||||
|
@Resource
|
||||||
|
private PlatformTransactionManager transactionManager;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
@@ -55,7 +102,6 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
|
|||||||
LOCK_WAIT_TIMEOUT,
|
LOCK_WAIT_TIMEOUT,
|
||||||
LOCK_LEASE_TIMEOUT,
|
LOCK_LEASE_TIMEOUT,
|
||||||
() -> {
|
() -> {
|
||||||
//sync roleIds
|
|
||||||
List<BigInteger> roleIds = entity.getRoleIds();
|
List<BigInteger> roleIds = entity.getRoleIds();
|
||||||
if (roleIds != null) {
|
if (roleIds != null) {
|
||||||
QueryWrapper delW = QueryWrapper.create();
|
QueryWrapper delW = QueryWrapper.create();
|
||||||
@@ -79,7 +125,6 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//sync positionIds
|
|
||||||
List<BigInteger> positionIds = entity.getPositionIds();
|
List<BigInteger> positionIds = entity.getPositionIds();
|
||||||
if (positionIds != null) {
|
if (positionIds != null) {
|
||||||
QueryWrapper delW = QueryWrapper.create();
|
QueryWrapper delW = QueryWrapper.create();
|
||||||
@@ -112,4 +157,483 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
|
|||||||
w.eq(SysAccount::getLoginName, userKey);
|
w.eq(SysAccount::getLoginName, userKey);
|
||||||
return getOne(w);
|
return getOne(w);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void resetPassword(BigInteger accountId, BigInteger operatorId) {
|
||||||
|
SysAccount record = getById(accountId);
|
||||||
|
if (record == null) {
|
||||||
|
throw new BusinessException("用户不存在");
|
||||||
|
}
|
||||||
|
Integer accountType = record.getAccountType();
|
||||||
|
if (EnumAccountType.SUPER_ADMIN.getCode().equals(accountType)) {
|
||||||
|
throw new BusinessException("不能重置超级管理员密码");
|
||||||
|
}
|
||||||
|
if (EnumAccountType.TENANT_ADMIN.getCode().equals(accountType)) {
|
||||||
|
throw new BusinessException("不能重置租户管理员密码");
|
||||||
|
}
|
||||||
|
SysAccount update = new SysAccount();
|
||||||
|
update.setId(accountId);
|
||||||
|
update.setPassword(BCrypt.hashpw(DEFAULT_RESET_PASSWORD));
|
||||||
|
update.setPasswordResetRequired(true);
|
||||||
|
update.setModified(new Date());
|
||||||
|
update.setModifiedBy(operatorId);
|
||||||
|
updateById(update);
|
||||||
|
StpUtil.kickout(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SysAccountImportResultVo importAccounts(MultipartFile file, LoginAccount loginAccount) {
|
||||||
|
validateImportFile(file);
|
||||||
|
List<SysAccountImportRow> rows = parseImportRows(file);
|
||||||
|
SysAccountImportResultVo result = new SysAccountImportResultVo();
|
||||||
|
result.setTotalCount(rows.size());
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, SysDept> deptMap = buildDeptCodeMap();
|
||||||
|
Map<String, SysRole> roleMap = buildRoleKeyMap();
|
||||||
|
Map<String, SysPosition> positionMap = buildPositionCodeMap();
|
||||||
|
for (SysAccountImportRow row : rows) {
|
||||||
|
try {
|
||||||
|
executeInRowTransaction(() -> importSingleRow(row, loginAccount, deptMap, roleMap, positionMap));
|
||||||
|
result.setSuccessCount(result.getSuccessCount() + 1);
|
||||||
|
} catch (Exception e) {
|
||||||
|
result.setErrorCount(result.getErrorCount() + 1);
|
||||||
|
appendImportError(result, row, extractImportErrorMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void writeImportTemplate(OutputStream outputStream) {
|
||||||
|
EasyExcel.write(outputStream)
|
||||||
|
.head(buildImportHeadList())
|
||||||
|
.sheet("模板")
|
||||||
|
.doWrite(new ArrayList<>());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void executeInRowTransaction(Runnable action) {
|
||||||
|
TransactionTemplate template = new TransactionTemplate(transactionManager);
|
||||||
|
template.executeWithoutResult(status -> {
|
||||||
|
try {
|
||||||
|
action.run();
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
status.setRollbackOnly();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void importSingleRow(SysAccountImportRow row,
|
||||||
|
LoginAccount loginAccount,
|
||||||
|
Map<String, SysDept> deptMap,
|
||||||
|
Map<String, SysRole> roleMap,
|
||||||
|
Map<String, SysPosition> positionMap) {
|
||||||
|
String deptCode = trimToNull(row.getDeptCode());
|
||||||
|
String loginName = trimToNull(row.getLoginName());
|
||||||
|
String nickname = trimToNull(row.getNickname());
|
||||||
|
if (deptCode == null) {
|
||||||
|
throw new BusinessException("部门编码不能为空");
|
||||||
|
}
|
||||||
|
if (loginName == null) {
|
||||||
|
throw new BusinessException("登录账号不能为空");
|
||||||
|
}
|
||||||
|
if (nickname == null) {
|
||||||
|
throw new BusinessException("昵称不能为空");
|
||||||
|
}
|
||||||
|
SysDept dept = deptMap.get(deptCode);
|
||||||
|
if (dept == null) {
|
||||||
|
throw new BusinessException("部门编码不存在: " + deptCode);
|
||||||
|
}
|
||||||
|
ensureLoginNameNotExists(loginName);
|
||||||
|
|
||||||
|
List<BigInteger> roleIds = resolveRoleIds(row.getRoleKeys(), roleMap);
|
||||||
|
List<BigInteger> positionIds = resolvePositionIds(row.getPositionCodes(), positionMap);
|
||||||
|
|
||||||
|
SysAccount entity = new SysAccount();
|
||||||
|
entity.setDeptId(dept.getId());
|
||||||
|
entity.setTenantId(loginAccount.getTenantId());
|
||||||
|
entity.setLoginName(loginName);
|
||||||
|
entity.setPassword(BCrypt.hashpw(DEFAULT_RESET_PASSWORD));
|
||||||
|
entity.setPasswordResetRequired(true);
|
||||||
|
entity.setAccountType(EnumAccountType.NORMAL.getCode());
|
||||||
|
entity.setNickname(nickname);
|
||||||
|
entity.setMobile(trimToNull(row.getMobile()));
|
||||||
|
entity.setEmail(trimToNull(row.getEmail()));
|
||||||
|
entity.setStatus(parseStatus(row.getStatus()));
|
||||||
|
entity.setRemark(trimToNull(row.getRemark()));
|
||||||
|
entity.setCreated(new Date());
|
||||||
|
entity.setCreatedBy(loginAccount.getId());
|
||||||
|
entity.setModified(new Date());
|
||||||
|
entity.setModifiedBy(loginAccount.getId());
|
||||||
|
entity.setRoleIds(roleIds);
|
||||||
|
entity.setPositionIds(positionIds);
|
||||||
|
save(entity);
|
||||||
|
syncRelations(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureLoginNameNotExists(String loginName) {
|
||||||
|
QueryWrapper wrapper = QueryWrapper.create();
|
||||||
|
wrapper.eq(SysAccount::getLoginName, loginName);
|
||||||
|
if (count(wrapper) > 0) {
|
||||||
|
throw new BusinessException("登录账号已存在: " + loginName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Integer parseStatus(String rawStatus) {
|
||||||
|
String status = trimToNull(rawStatus);
|
||||||
|
if (status == null) {
|
||||||
|
return EnumDataStatus.AVAILABLE.getCode();
|
||||||
|
}
|
||||||
|
if ("1".equals(status) || "已启用".equals(status) || "启用".equals(status)) {
|
||||||
|
return EnumDataStatus.AVAILABLE.getCode();
|
||||||
|
}
|
||||||
|
if ("0".equals(status) || "未启用".equals(status) || "停用".equals(status) || "禁用".equals(status)) {
|
||||||
|
return EnumDataStatus.UNAVAILABLE.getCode();
|
||||||
|
}
|
||||||
|
throw new BusinessException("状态不合法,仅支持 1/0 或 已启用/未启用");
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<BigInteger> resolveRoleIds(String roleKeysText, Map<String, SysRole> roleMap) {
|
||||||
|
List<String> roleKeys = splitCodes(roleKeysText);
|
||||||
|
if (roleKeys.isEmpty()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
List<BigInteger> roleIds = new ArrayList<>(roleKeys.size());
|
||||||
|
for (String roleKey : roleKeys) {
|
||||||
|
SysRole role = roleMap.get(roleKey);
|
||||||
|
if (role == null) {
|
||||||
|
throw new BusinessException("角色编码不存在: " + roleKey);
|
||||||
|
}
|
||||||
|
roleIds.add(role.getId());
|
||||||
|
}
|
||||||
|
return roleIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<BigInteger> resolvePositionIds(String positionCodesText, Map<String, SysPosition> positionMap) {
|
||||||
|
List<String> positionCodes = splitCodes(positionCodesText);
|
||||||
|
if (positionCodes.isEmpty()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
List<BigInteger> positionIds = new ArrayList<>(positionCodes.size());
|
||||||
|
for (String positionCode : positionCodes) {
|
||||||
|
SysPosition position = positionMap.get(positionCode);
|
||||||
|
if (position == null) {
|
||||||
|
throw new BusinessException("岗位编码不存在: " + positionCode);
|
||||||
|
}
|
||||||
|
positionIds.add(position.getId());
|
||||||
|
}
|
||||||
|
return positionIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> splitCodes(String rawCodes) {
|
||||||
|
String codes = trimToNull(rawCodes);
|
||||||
|
if (codes == null) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
String[] values = codes.split("[,,]");
|
||||||
|
List<String> result = new ArrayList<>();
|
||||||
|
Set<String> uniqueValues = new LinkedHashSet<>();
|
||||||
|
for (String value : values) {
|
||||||
|
String trimmed = trimToNull(value);
|
||||||
|
if (trimmed != null && uniqueValues.add(trimmed)) {
|
||||||
|
result.add(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, SysDept> buildDeptCodeMap() {
|
||||||
|
List<SysDept> deptList = sysDeptMapper.selectListByQuery(QueryWrapper.create());
|
||||||
|
Map<String, SysDept> deptMap = new HashMap<>();
|
||||||
|
for (SysDept dept : deptList) {
|
||||||
|
String deptCode = trimToNull(dept.getDeptCode());
|
||||||
|
if (deptCode != null) {
|
||||||
|
deptMap.putIfAbsent(deptCode, dept);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deptMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, SysRole> buildRoleKeyMap() {
|
||||||
|
List<SysRole> roleList = sysRoleMapper.selectListByQuery(QueryWrapper.create());
|
||||||
|
Map<String, SysRole> roleMap = new HashMap<>();
|
||||||
|
for (SysRole role : roleList) {
|
||||||
|
String roleKey = trimToNull(role.getRoleKey());
|
||||||
|
if (roleKey != null) {
|
||||||
|
roleMap.putIfAbsent(roleKey, role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return roleMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, SysPosition> buildPositionCodeMap() {
|
||||||
|
List<SysPosition> positionList = sysPositionMapper.selectListByQuery(QueryWrapper.create());
|
||||||
|
Map<String, SysPosition> positionMap = new HashMap<>();
|
||||||
|
for (SysPosition position : positionList) {
|
||||||
|
String positionCode = trimToNull(position.getPositionCode());
|
||||||
|
if (positionCode != null) {
|
||||||
|
positionMap.putIfAbsent(positionCode, position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return positionMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<SysAccountImportRow> parseImportRows(MultipartFile file) {
|
||||||
|
try (InputStream inputStream = file.getInputStream()) {
|
||||||
|
SysAccountExcelReadListener listener = new SysAccountExcelReadListener();
|
||||||
|
FastExcel.read(inputStream, listener)
|
||||||
|
.sheet()
|
||||||
|
.doRead();
|
||||||
|
return listener.getRows();
|
||||||
|
} catch (BusinessException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new BusinessException("用户导入文件解析失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateImportFile(MultipartFile file) {
|
||||||
|
if (file == null || file.isEmpty()) {
|
||||||
|
throw new BusinessException("导入文件不能为空");
|
||||||
|
}
|
||||||
|
if (file.getSize() > MAX_IMPORT_FILE_SIZE_BYTES) {
|
||||||
|
throw new BusinessException("导入文件大小不能超过10MB");
|
||||||
|
}
|
||||||
|
String fileName = file.getOriginalFilename();
|
||||||
|
String lowerName = fileName == null ? "" : fileName.toLowerCase();
|
||||||
|
if (!(lowerName.endsWith(".xlsx") || lowerName.endsWith(".xls"))) {
|
||||||
|
throw new BusinessException("仅支持 xlsx/xls 文件");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendImportError(SysAccountImportResultVo result, SysAccountImportRow row, String reason) {
|
||||||
|
SysAccountImportErrorRowVo errorRow = new SysAccountImportErrorRowVo();
|
||||||
|
errorRow.setRowNumber(row.getRowNumber());
|
||||||
|
errorRow.setDeptCode(row.getDeptCode());
|
||||||
|
errorRow.setLoginName(row.getLoginName());
|
||||||
|
errorRow.setReason(reason);
|
||||||
|
result.getErrorRows().add(errorRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractImportErrorMessage(Exception e) {
|
||||||
|
if (e == null) {
|
||||||
|
return "导入失败";
|
||||||
|
}
|
||||||
|
if (e.getCause() != null && StringUtil.hasText(e.getCause().getMessage())) {
|
||||||
|
return e.getCause().getMessage();
|
||||||
|
}
|
||||||
|
if (StringUtil.hasText(e.getMessage())) {
|
||||||
|
return e.getMessage();
|
||||||
|
}
|
||||||
|
return "导入失败";
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<List<String>> buildImportHeadList() {
|
||||||
|
List<List<String>> headList = new ArrayList<>(9);
|
||||||
|
headList.add(Collections.singletonList(IMPORT_HEAD_DEPT_CODE));
|
||||||
|
headList.add(Collections.singletonList(IMPORT_HEAD_LOGIN_NAME));
|
||||||
|
headList.add(Collections.singletonList(IMPORT_HEAD_NICKNAME));
|
||||||
|
headList.add(Collections.singletonList(IMPORT_HEAD_MOBILE));
|
||||||
|
headList.add(Collections.singletonList(IMPORT_HEAD_EMAIL));
|
||||||
|
headList.add(Collections.singletonList(IMPORT_HEAD_STATUS));
|
||||||
|
headList.add(Collections.singletonList(IMPORT_HEAD_ROLE_KEYS));
|
||||||
|
headList.add(Collections.singletonList(IMPORT_HEAD_POSITION_CODES));
|
||||||
|
headList.add(Collections.singletonList(IMPORT_HEAD_REMARK));
|
||||||
|
return headList;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String trimToNull(String value) {
|
||||||
|
if (!StringUtil.hasText(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class SysAccountImportRow {
|
||||||
|
private Integer rowNumber;
|
||||||
|
private String deptCode;
|
||||||
|
private String loginName;
|
||||||
|
private String nickname;
|
||||||
|
private String mobile;
|
||||||
|
private String email;
|
||||||
|
private String status;
|
||||||
|
private String roleKeys;
|
||||||
|
private String positionCodes;
|
||||||
|
private String remark;
|
||||||
|
|
||||||
|
public Integer getRowNumber() {
|
||||||
|
return rowNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRowNumber(Integer rowNumber) {
|
||||||
|
this.rowNumber = rowNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDeptCode() {
|
||||||
|
return deptCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeptCode(String deptCode) {
|
||||||
|
this.deptCode = deptCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLoginName() {
|
||||||
|
return loginName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLoginName(String loginName) {
|
||||||
|
this.loginName = loginName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getNickname() {
|
||||||
|
return nickname;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNickname(String nickname) {
|
||||||
|
this.nickname = nickname;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMobile() {
|
||||||
|
return mobile;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMobile(String mobile) {
|
||||||
|
this.mobile = mobile;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getEmail() {
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEmail(String email) {
|
||||||
|
this.email = email;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStatus(String status) {
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRoleKeys() {
|
||||||
|
return roleKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRoleKeys(String roleKeys) {
|
||||||
|
this.roleKeys = roleKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPositionCodes() {
|
||||||
|
return positionCodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPositionCodes(String positionCodes) {
|
||||||
|
this.positionCodes = positionCodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRemark() {
|
||||||
|
return remark;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRemark(String remark) {
|
||||||
|
this.remark = remark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class SysAccountExcelReadListener implements ReadListener<LinkedHashMap<Integer, Object>> {
|
||||||
|
|
||||||
|
private final Map<String, Integer> headIndex = new HashMap<>();
|
||||||
|
private final List<SysAccountImportRow> rows = new ArrayList<>();
|
||||||
|
private int sheetRowNo;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void invoke(LinkedHashMap<Integer, Object> data, AnalysisContext context) {
|
||||||
|
sheetRowNo++;
|
||||||
|
String deptCode = getCellValue(data, IMPORT_HEAD_DEPT_CODE);
|
||||||
|
String loginName = getCellValue(data, IMPORT_HEAD_LOGIN_NAME);
|
||||||
|
String nickname = getCellValue(data, IMPORT_HEAD_NICKNAME);
|
||||||
|
String mobile = getCellValue(data, IMPORT_HEAD_MOBILE);
|
||||||
|
String email = getCellValue(data, IMPORT_HEAD_EMAIL);
|
||||||
|
String status = getCellValue(data, IMPORT_HEAD_STATUS);
|
||||||
|
String roleKeys = getCellValue(data, IMPORT_HEAD_ROLE_KEYS);
|
||||||
|
String positionCodes = getCellValue(data, IMPORT_HEAD_POSITION_CODES);
|
||||||
|
String remark = getCellValue(data, IMPORT_HEAD_REMARK);
|
||||||
|
if (!StringUtil.hasText(deptCode)
|
||||||
|
&& !StringUtil.hasText(loginName)
|
||||||
|
&& !StringUtil.hasText(nickname)
|
||||||
|
&& !StringUtil.hasText(mobile)
|
||||||
|
&& !StringUtil.hasText(email)
|
||||||
|
&& !StringUtil.hasText(status)
|
||||||
|
&& !StringUtil.hasText(roleKeys)
|
||||||
|
&& !StringUtil.hasText(positionCodes)
|
||||||
|
&& !StringUtil.hasText(remark)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (rows.size() >= MAX_IMPORT_ROWS) {
|
||||||
|
throw new BusinessException("单次最多导入5000个用户");
|
||||||
|
}
|
||||||
|
SysAccountImportRow row = new SysAccountImportRow();
|
||||||
|
row.setRowNumber(sheetRowNo + 1);
|
||||||
|
row.setDeptCode(deptCode);
|
||||||
|
row.setLoginName(loginName);
|
||||||
|
row.setNickname(nickname);
|
||||||
|
row.setMobile(mobile);
|
||||||
|
row.setEmail(email);
|
||||||
|
row.setStatus(status);
|
||||||
|
row.setRoleKeys(roleKeys);
|
||||||
|
row.setPositionCodes(positionCodes);
|
||||||
|
row.setRemark(remark);
|
||||||
|
rows.add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void invokeHead(Map<Integer, ReadCellData<?>> headMap, AnalysisContext context) {
|
||||||
|
for (Map.Entry<Integer, ReadCellData<?>> entry : headMap.entrySet()) {
|
||||||
|
String headValue = entry.getValue() == null ? null : entry.getValue().getStringValue();
|
||||||
|
String header = trimToNull(headValue);
|
||||||
|
if (header != null) {
|
||||||
|
headIndex.put(header, entry.getKey());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<String> requiredHeads = List.of(
|
||||||
|
IMPORT_HEAD_DEPT_CODE,
|
||||||
|
IMPORT_HEAD_LOGIN_NAME,
|
||||||
|
IMPORT_HEAD_NICKNAME,
|
||||||
|
IMPORT_HEAD_MOBILE,
|
||||||
|
IMPORT_HEAD_EMAIL,
|
||||||
|
IMPORT_HEAD_STATUS,
|
||||||
|
IMPORT_HEAD_ROLE_KEYS,
|
||||||
|
IMPORT_HEAD_POSITION_CODES,
|
||||||
|
IMPORT_HEAD_REMARK
|
||||||
|
);
|
||||||
|
for (String requiredHead : requiredHeads) {
|
||||||
|
if (!headIndex.containsKey(requiredHead)) {
|
||||||
|
throw new BusinessException("导入模板表头不正确,必须包含:" + String.join("、", requiredHeads));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void doAfterAllAnalysed(AnalysisContext context) {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<SysAccountImportRow> getRows() {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getCellValue(Map<Integer, Object> row, String headName) {
|
||||||
|
Integer index = headIndex.get(headName);
|
||||||
|
if (index == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Object value = row.get(index);
|
||||||
|
return value == null ? null : String.valueOf(value).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package tech.easyflow.system.util;
|
||||||
|
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户密码策略。
|
||||||
|
*/
|
||||||
|
public final class SysPasswordPolicy {
|
||||||
|
|
||||||
|
public static final String STRONG_PASSWORD_MESSAGE = "密码必须至少8位,且包含大写字母、小写字母、数字和特殊字符";
|
||||||
|
|
||||||
|
private static final Pattern STRONG_PASSWORD_PATTERN = Pattern.compile(
|
||||||
|
"^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[^A-Za-z\\d]).{8,}$"
|
||||||
|
);
|
||||||
|
|
||||||
|
private SysPasswordPolicy() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isStrongPassword(String password) {
|
||||||
|
return password != null && STRONG_PASSWORD_PATTERN.matcher(password).matches();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void validateStrongPassword(String password) {
|
||||||
|
if (!isStrongPassword(password)) {
|
||||||
|
throw new BusinessException(STRONG_PASSWORD_MESSAGE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package tech.easyflow.system.util;
|
||||||
|
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
|
public class SysPasswordPolicyTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldAcceptStrongPassword() {
|
||||||
|
Assert.assertTrue(SysPasswordPolicy.isStrongPassword("Abcd1234!"));
|
||||||
|
SysPasswordPolicy.validateStrongPassword("Abcd1234!");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(expected = BusinessException.class)
|
||||||
|
public void shouldRejectWeakPassword() {
|
||||||
|
Assert.assertFalse(SysPasswordPolicy.isStrongPassword("123456"));
|
||||||
|
SysPasswordPolicy.validateStrongPassword("123456");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,9 @@ spring:
|
|||||||
activate:
|
activate:
|
||||||
on-profile: prod
|
on-profile: prod
|
||||||
datasource:
|
datasource:
|
||||||
url: jdbc:mysql://127.0.0.1:3306/easyflow?useInformationSchema=true&characterEncoding=utf-8
|
url: jdbc:mysql://127.0.0.1:23306/easyflow?useInformationSchema=true&characterEncoding=utf-8
|
||||||
username: easyflow
|
username: easyflow
|
||||||
password: 123456
|
password: root
|
||||||
data:
|
data:
|
||||||
redis:
|
redis:
|
||||||
host: 127.0.0.1
|
host: 127.0.0.1
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# public path
|
# public path
|
||||||
VITE_BASE=/
|
VITE_BASE=/flow/
|
||||||
|
|
||||||
# Basic interface address SPA
|
# Basic interface address SPA
|
||||||
VITE_GLOB_API_URL=/api
|
VITE_GLOB_API_URL=/flow
|
||||||
|
|
||||||
VITE_VISUALIZER=true
|
VITE_VISUALIZER=true
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# 端口号
|
# 端口号
|
||||||
VITE_PORT=5090
|
VITE_PORT=5090
|
||||||
|
|
||||||
VITE_BASE=/
|
VITE_BASE=/flow/
|
||||||
|
|
||||||
# 接口地址
|
# 接口地址
|
||||||
VITE_GLOB_API_URL=http://127.0.0.1:8111
|
VITE_GLOB_API_URL=/flow
|
||||||
|
|
||||||
# 是否打开 devtools,true 为打开,false 为关闭
|
# 是否打开 devtools,true 为打开,false 为关闭
|
||||||
VITE_DEVTOOLS=false
|
VITE_DEVTOOLS=false
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
VITE_BASE=/
|
VITE_BASE=/flow/
|
||||||
|
|
||||||
# 接口地址
|
# 接口地址
|
||||||
VITE_GLOB_API_URL=
|
VITE_GLOB_API_URL=/flow
|
||||||
|
|
||||||
# 是否开启压缩,可以设置为 none, brotli, gzip
|
# 是否开启压缩,可以设置为 none, brotli, gzip
|
||||||
VITE_COMPRESS=none
|
VITE_COMPRESS=none
|
||||||
|
|||||||
BIN
easyflow-ui-admin/app/public/model-providers/aliyun.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
easyflow-ui-admin/app/public/model-providers/deepseek.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
easyflow-ui-admin/app/public/model-providers/kimi.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
easyflow-ui-admin/app/public/model-providers/minimax.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
easyflow-ui-admin/app/public/model-providers/ollama.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
1
easyflow-ui-admin/app/public/model-providers/openai.ico
Normal file
10
easyflow-ui-admin/app/public/model-providers/openai.svg
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="146 227 268 266"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M249.176 323.434V298.276C249.176 296.158 249.971 294.569 251.825 293.509L302.406 264.381C309.29 260.409 317.5 258.555 325.973 258.555C357.75 258.555 377.877 283.185 377.877 309.399C377.877 311.253 377.877 313.371 377.611 315.49L325.178 284.771C322.001 282.919 318.822 282.919 315.645 284.771L249.176 323.434ZM367.283 421.415V361.301C367.283 357.592 365.694 354.945 362.516 353.092L296.048 314.43L317.763 301.982C319.617 300.925 321.206 300.925 323.058 301.982L373.639 331.112C388.205 339.586 398.003 357.592 398.003 375.069C398.003 395.195 386.087 413.733 367.283 421.412V421.415ZM233.553 368.452L211.838 355.742C209.986 354.684 209.19 353.095 209.19 350.975V292.718C209.19 264.383 230.905 242.932 260.301 242.932C271.423 242.932 281.748 246.641 290.49 253.26L238.321 283.449C235.146 285.303 233.555 287.951 233.555 291.659V368.455L233.553 368.452ZM280.292 395.462L249.176 377.985V340.913L280.292 323.436L311.407 340.913V377.985L280.292 395.462ZM300.286 475.968C289.163 475.968 278.837 472.259 270.097 465.64L322.264 435.449C325.441 433.597 327.03 430.949 327.03 427.239V350.445L349.011 363.155C350.865 364.213 351.66 365.802 351.66 367.922V426.179C351.66 454.514 329.679 475.965 300.286 475.965V475.968ZM237.525 416.915L186.944 387.785C172.378 379.31 162.582 361.305 162.582 343.827C162.582 323.436 174.763 305.164 193.563 297.485V357.861C193.563 361.571 195.154 364.217 198.33 366.071L264.535 404.467L242.82 416.915C240.967 417.972 239.377 417.972 237.525 416.915ZM234.614 460.343C204.689 460.343 182.71 437.833 182.71 410.028C182.71 407.91 182.976 405.792 183.238 403.672L235.405 433.863C238.582 435.715 241.763 435.715 244.938 433.863L311.407 395.466V420.622C311.407 422.742 310.612 424.331 308.758 425.389L258.179 454.519C251.293 458.491 243.083 460.343 234.611 460.343H234.614ZM300.286 491.854C332.329 491.854 359.073 469.082 365.167 438.892C394.825 431.211 413.892 403.406 413.892 375.073C413.892 356.535 405.948 338.529 391.648 325.552C392.972 319.991 393.766 314.43 393.766 308.87C393.766 271.003 363.048 242.666 327.562 242.666C320.413 242.666 313.528 243.723 306.644 246.109C294.725 234.457 278.307 227.042 260.301 227.042C228.258 227.042 201.513 249.815 195.42 280.004C165.761 287.685 146.694 315.49 146.694 343.824C146.694 362.362 154.638 380.368 168.938 393.344C167.613 398.906 166.819 404.467 166.819 410.027C166.819 447.894 197.538 476.231 233.024 476.231C240.172 476.231 247.058 475.173 253.943 472.788C265.859 484.441 282.278 491.854 300.286 491.854Z"
|
||||||
|
fill="#111827"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="96" height="96" rx="28" fill="#475569"/>
|
||||||
|
<path d="M26 35C26 31.6863 28.6863 29 32 29H64C67.3137 29 70 31.6863 70 35V42C70 45.3137 67.3137 48 64 48H32C28.6863 48 26 45.3137 26 42V35Z" fill="white" fill-opacity="0.95"/>
|
||||||
|
<path d="M30 40H66" stroke="#475569" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<path d="M37 58C37 55.7909 38.7909 54 41 54H55C57.2091 54 59 55.7909 59 58V63C59 65.2091 57.2091 67 55 67H41C38.7909 67 37 65.2091 37 63V58Z" fill="white" fill-opacity="0.9"/>
|
||||||
|
<path d="M48 48V54" stroke="white" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<circle cx="48" cy="60.5" r="2.5" fill="#475569"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 729 B |
BIN
easyflow-ui-admin/app/public/model-providers/siliconflow.png
Normal file
|
After Width: | Height: | Size: 583 B |
BIN
easyflow-ui-admin/app/public/model-providers/zhipu.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
@@ -1,10 +1,19 @@
|
|||||||
import { api } from '#/api/request.js';
|
import {api} from '#/api/request.js';
|
||||||
|
|
||||||
// 获取LLM供应商
|
// 获取LLM供应商
|
||||||
export async function getLlmProviderList() {
|
export async function getLlmProviderList() {
|
||||||
return api.get('/api/v1/modelProvider/list');
|
return api.get('/api/v1/modelProvider/list');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ModelListQuery {
|
||||||
|
modelType?: string;
|
||||||
|
providerId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getModelList(params: ModelListQuery = {}) {
|
||||||
|
return api.get('/api/v1/model/list', { params });
|
||||||
|
}
|
||||||
|
|
||||||
// 保存LLM
|
// 保存LLM
|
||||||
export async function saveLlm(data: string) {
|
export async function saveLlm(data: string) {
|
||||||
return api.post('/api/v1/model/save', data);
|
return api.post('/api/v1/model/save', data);
|
||||||
@@ -20,6 +29,10 @@ export async function updateLlm(data: any) {
|
|||||||
return api.post(`/api/v1/model/update`, data);
|
return api.post(`/api/v1/model/update`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function verifyModelConfig(id: string) {
|
||||||
|
return api.get('/api/v1/model/verifyLlmConfig', { params: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
// 一键添加LLM
|
// 一键添加LLM
|
||||||
export async function quickAddLlm(data: any) {
|
export async function quickAddLlm(data: any) {
|
||||||
return api.post(`/api/v1/model/quickAdd`, data);
|
return api.post(`/api/v1/model/quickAdd`, data);
|
||||||
@@ -33,7 +46,6 @@ export interface llmType {
|
|||||||
providerName: string;
|
providerName: string;
|
||||||
providerType: string;
|
providerType: string;
|
||||||
};
|
};
|
||||||
withUsed: boolean;
|
|
||||||
llmModel: string;
|
llmModel: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export namespace AuthApi {
|
|||||||
/** 登录接口返回值 */
|
/** 登录接口返回值 */
|
||||||
export interface LoginResult {
|
export interface LoginResult {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
|
forceChangePassword?: boolean;
|
||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
import { createApp, watchEffect } from 'vue';
|
import { createApp, watchEffect } from 'vue';
|
||||||
import {
|
|
||||||
BubbleList,
|
|
||||||
Conversations,
|
|
||||||
Sender,
|
|
||||||
Thinking,
|
|
||||||
XMarkdown,
|
|
||||||
} from 'vue-element-plus-x';
|
|
||||||
|
|
||||||
import { registerAccessDirective } from '@easyflow/access';
|
import { registerAccessDirective } from '@easyflow/access';
|
||||||
import {
|
import {
|
||||||
@@ -49,12 +42,6 @@ async function bootstrap(namespace: string) {
|
|||||||
// 注册Element Plus提供的v-loading指令
|
// 注册Element Plus提供的v-loading指令
|
||||||
app.directive('loading', ElLoading.directive);
|
app.directive('loading', ElLoading.directive);
|
||||||
|
|
||||||
app.component('ElBubbleList', BubbleList);
|
|
||||||
app.component('ElConversations', Conversations);
|
|
||||||
app.component('ElSender', Sender);
|
|
||||||
app.component('ElXMarkdown', XMarkdown);
|
|
||||||
app.component('ElThinking', Thinking);
|
|
||||||
|
|
||||||
// 注册EasyFlow提供的v-loading和v-spinning指令
|
// 注册EasyFlow提供的v-loading和v-spinning指令
|
||||||
registerLoadingDirective(app, {
|
registerLoadingDirective(app, {
|
||||||
loading: false, // EasyFlow提供的v-loading指令和Element Plus提供的v-loading指令二选一即可,此处false表示不注册EasyFlow提供的v-loading指令
|
loading: false, // EasyFlow提供的v-loading指令和Element Plus提供的v-loading指令二选一即可,此处false表示不注册EasyFlow提供的v-loading指令
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Sender } from 'vue-element-plus-x';
|
|
||||||
import type {
|
import type {
|
||||||
BubbleListInstance,
|
BubbleListInstance,
|
||||||
BubbleListProps,
|
BubbleListProps,
|
||||||
@@ -11,6 +10,10 @@ import type { BotInfo, ChatMessage } from '@easyflow/types';
|
|||||||
|
|
||||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch, watchEffect } from 'vue';
|
import { nextTick, onBeforeUnmount, onMounted, ref, watch, watchEffect } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import ElBubbleList from 'vue-element-plus-x/es/BubbleList/index.js';
|
||||||
|
import ElSender from 'vue-element-plus-x/es/Sender/index.js';
|
||||||
|
import ElThinking from 'vue-element-plus-x/es/Thinking/index.js';
|
||||||
|
import ElXMarkdown from 'vue-element-plus-x/es/XMarkdown/index.js';
|
||||||
|
|
||||||
import { IconifyIcon } from '@easyflow/icons';
|
import { IconifyIcon } from '@easyflow/icons';
|
||||||
import { $t } from '@easyflow/locales';
|
import { $t } from '@easyflow/locales';
|
||||||
@@ -85,7 +88,7 @@ const bubbleListRef = ref<BubbleListInstance>();
|
|||||||
const messageContainerRef = ref<HTMLElement | null>(null);
|
const messageContainerRef = ref<HTMLElement | null>(null);
|
||||||
const bubbleListScrollElement = ref<HTMLElement | null>(null);
|
const bubbleListScrollElement = ref<HTMLElement | null>(null);
|
||||||
const showBackToBottomButton = ref(false);
|
const showBackToBottomButton = ref(false);
|
||||||
const senderRef = ref<InstanceType<typeof Sender>>();
|
const senderRef = ref<InstanceType<typeof ElSender>>();
|
||||||
const senderValue = ref('');
|
const senderValue = ref('');
|
||||||
const sending = ref(false);
|
const sending = ref(false);
|
||||||
const BACK_TO_BOTTOM_THRESHOLD = 160;
|
const BACK_TO_BOTTOM_THRESHOLD = 160;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const emits = defineEmits(['delete']);
|
const emits = defineEmits(['delete']);
|
||||||
|
const fallbackAvatarUrl = `${import.meta.env.BASE_URL || '/'}favicon.svg`;
|
||||||
const handleDelete = (item: any) => {
|
const handleDelete = (item: any) => {
|
||||||
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
|
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
|
||||||
confirmButtonText: $t('button.confirm'),
|
confirmButtonText: $t('button.confirm'),
|
||||||
@@ -40,7 +41,7 @@ const handleDelete = (item: any) => {
|
|||||||
<div class="el-list-item-container">
|
<div class="el-list-item-container">
|
||||||
<div class="flex-center">
|
<div class="flex-center">
|
||||||
<ElAvatar :src="item.icon" v-if="item.icon" />
|
<ElAvatar :src="item.icon" v-if="item.icon" />
|
||||||
<ElAvatar v-else src="/favicon.svg" shape="circle" />
|
<ElAvatar v-else :src="fallbackAvatarUrl" shape="circle" />
|
||||||
</div>
|
</div>
|
||||||
<div class="el-list-item-content">
|
<div class="el-list-item-content">
|
||||||
<div class="title">{{ item[titleKey] }}</div>
|
<div class="title">{{ item[titleKey] }}</div>
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ const emit = defineEmits(['getData', 'buttonClick']);
|
|||||||
const dialogVisible = ref(false);
|
const dialogVisible = ref(false);
|
||||||
const pageDataRef = ref();
|
const pageDataRef = ref();
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const fallbackAvatarUrl = `${import.meta.env.BASE_URL || '/'}favicon.svg`;
|
||||||
const selectedIds = ref<(number | string)[]>([]);
|
const selectedIds = ref<(number | string)[]>([]);
|
||||||
// 存储上一级id与选中tool.name的关联关系
|
// 存储上一级id与选中tool.name的关联关系
|
||||||
const selectedToolMap = ref<Record<number | string, SelectedMcpTool[]>>({});
|
const selectedToolMap = ref<Record<number | string, SelectedMcpTool[]>>({});
|
||||||
@@ -231,7 +232,11 @@ const handleSearch = (query: string) => {
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<ElAvatar :src="item.icon" v-if="item.icon" />
|
<ElAvatar :src="item.icon" v-if="item.icon" />
|
||||||
<ElAvatar v-else src="/favicon.svg" shape="circle" />
|
<ElAvatar
|
||||||
|
v-else
|
||||||
|
:src="fallbackAvatarUrl"
|
||||||
|
shape="circle"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="title-right-container">
|
<div class="title-right-container">
|
||||||
<ElText truncated class="title">
|
<ElText truncated class="title">
|
||||||
@@ -326,7 +331,11 @@ const handleSearch = (query: string) => {
|
|||||||
<div class="content-sec-left-container">
|
<div class="content-sec-left-container">
|
||||||
<div>
|
<div>
|
||||||
<ElAvatar :src="item.icon" v-if="item.icon" />
|
<ElAvatar :src="item.icon" v-if="item.icon" />
|
||||||
<ElAvatar v-else src="/favicon.svg" shape="circle" />
|
<ElAvatar
|
||||||
|
v-else
|
||||||
|
:src="fallbackAvatarUrl"
|
||||||
|
shape="circle"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="title-sec-right-container">
|
<div class="title-sec-right-container">
|
||||||
<ElText truncated class="title">
|
<ElText truncated class="title">
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
ElAvatar,
|
ElAvatar,
|
||||||
ElButton,
|
ElButton,
|
||||||
ElCard,
|
ElCard,
|
||||||
ElDivider,
|
|
||||||
ElDropdown,
|
ElDropdown,
|
||||||
ElDropdownItem,
|
ElDropdownItem,
|
||||||
ElDropdownMenu,
|
ElDropdownMenu,
|
||||||
@@ -17,14 +16,31 @@ import {
|
|||||||
ElText,
|
ElText,
|
||||||
} from 'element-plus';
|
} from 'element-plus';
|
||||||
|
|
||||||
|
export type ActionPlacement = 'inline' | 'menu';
|
||||||
|
export type ActionTone = 'danger' | 'default';
|
||||||
|
|
||||||
export interface ActionButton {
|
export interface ActionButton {
|
||||||
icon: any;
|
icon?: any;
|
||||||
text: string;
|
text: string;
|
||||||
className: string;
|
className?: string;
|
||||||
permission: string;
|
permission?: string;
|
||||||
|
placement?: ActionPlacement;
|
||||||
|
tone?: ActionTone;
|
||||||
onClick: (row: any) => void;
|
onClick: (row: any) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CardPrimaryAction {
|
||||||
|
icon?: any;
|
||||||
|
text: string;
|
||||||
|
permission?: string;
|
||||||
|
onClick: (row: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedActionButton extends ActionButton {
|
||||||
|
placement: ActionPlacement;
|
||||||
|
tone: ActionTone;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CardListProps {
|
export interface CardListProps {
|
||||||
iconField?: string;
|
iconField?: string;
|
||||||
titleField?: string;
|
titleField?: string;
|
||||||
@@ -32,125 +48,185 @@ export interface CardListProps {
|
|||||||
actions?: ActionButton[];
|
actions?: ActionButton[];
|
||||||
defaultIcon: any;
|
defaultIcon: any;
|
||||||
data: any[];
|
data: any[];
|
||||||
|
primaryAction?: CardPrimaryAction;
|
||||||
tagField?: string;
|
tagField?: string;
|
||||||
tagMap?: Record<string, string>;
|
tagMap?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<CardListProps>(), {
|
const props = withDefaults(defineProps<CardListProps>(), {
|
||||||
iconField: 'icon',
|
iconField: 'icon',
|
||||||
titleField: 'title',
|
titleField: 'title',
|
||||||
descField: 'description',
|
descField: 'description',
|
||||||
actions: () => [],
|
actions: () => [],
|
||||||
|
primaryAction: undefined,
|
||||||
tagField: '',
|
tagField: '',
|
||||||
tagMap: () => ({}),
|
tagMap: () => ({}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { hasAccessByCodes } = useAccess();
|
const { hasAccessByCodes } = useAccess();
|
||||||
const filterActions = computed(() => {
|
|
||||||
return props.actions.filter((action) => {
|
function hasPermission(permission?: string) {
|
||||||
return hasAccessByCodes([action.permission]);
|
return !permission || hasAccessByCodes([permission]);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
const resolvedPrimaryAction = computed(() => {
|
||||||
|
if (!props.primaryAction || !hasPermission(props.primaryAction.permission)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return props.primaryAction;
|
||||||
});
|
});
|
||||||
const visibleActions = computed(() => {
|
|
||||||
return filterActions.value.length <= 3
|
const resolvedActions = computed<ResolvedActionButton[]>(() => {
|
||||||
? filterActions.value
|
return props.actions
|
||||||
: filterActions.value.slice(0, 3);
|
.filter((action) => hasPermission(action.permission))
|
||||||
|
.map((action, index) => ({
|
||||||
|
...action,
|
||||||
|
placement:
|
||||||
|
action.placement ||
|
||||||
|
(resolvedPrimaryAction.value ? 'menu' : index < 3 ? 'inline' : 'menu'),
|
||||||
|
tone:
|
||||||
|
action.tone ||
|
||||||
|
(action.className?.includes('danger') ? 'danger' : 'default'),
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
const hiddenActions = computed(() => {
|
|
||||||
return filterActions.value.length > 3 ? filterActions.value.slice(3) : [];
|
const inlineActions = computed(() => {
|
||||||
|
return resolvedActions.value.filter((action) => action.placement === 'inline');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const menuActions = computed(() => {
|
||||||
|
return resolvedActions.value.filter((action) => action.placement === 'menu');
|
||||||
|
});
|
||||||
|
|
||||||
|
const showFooter = computed(() => {
|
||||||
|
return Boolean(
|
||||||
|
resolvedPrimaryAction.value ||
|
||||||
|
inlineActions.value.length ||
|
||||||
|
menuActions.value.length,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function handlePrimaryAction(item: any) {
|
||||||
|
resolvedPrimaryAction.value?.onClick(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleActionClick(event: Event, action: ActionButton, item: any) {
|
||||||
|
event.stopPropagation();
|
||||||
|
action.onClick(item);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="card-grid">
|
<div class="card-grid">
|
||||||
<ElCard
|
<ElCard
|
||||||
v-for="(item, index) in props.data"
|
v-for="(item, index) in props.data"
|
||||||
:key="index"
|
:key="item.id ?? index"
|
||||||
shadow="hover"
|
shadow="never"
|
||||||
footer-class="foot-c"
|
:class="['card-item', { 'card-item--interactive': resolvedPrimaryAction }]"
|
||||||
:style="{
|
:role="resolvedPrimaryAction ? 'button' : undefined"
|
||||||
'--el-box-shadow-light': '0px 2px 12px 0px rgb(100 121 153 10%)',
|
:tabindex="resolvedPrimaryAction ? 0 : undefined"
|
||||||
}"
|
@click="handlePrimaryAction(item)"
|
||||||
|
@keydown.enter.prevent="handlePrimaryAction(item)"
|
||||||
|
@keydown.space.prevent="handlePrimaryAction(item)"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-3">
|
<div class="card-content">
|
||||||
<div class="flex items-center gap-3">
|
<div class="card-header">
|
||||||
<ElAvatar
|
<ElAvatar
|
||||||
class="shrink-0"
|
class="card-avatar shrink-0"
|
||||||
:src="item[iconField] || defaultIcon"
|
:src="item[iconField] || defaultIcon"
|
||||||
:size="36"
|
:size="44"
|
||||||
/>
|
/>
|
||||||
<div class="title-row">
|
<div class="card-meta">
|
||||||
<ElText truncated size="large" class="font-medium">
|
<div class="title-row">
|
||||||
{{ item[titleField] }}
|
<ElText truncated size="large" class="card-title">
|
||||||
|
{{ item[titleField] }}
|
||||||
|
</ElText>
|
||||||
|
<ElTag
|
||||||
|
v-if="tagField && item[tagField]"
|
||||||
|
size="small"
|
||||||
|
effect="plain"
|
||||||
|
type="info"
|
||||||
|
>
|
||||||
|
{{ tagMap[item[tagField]] || item[tagField] }}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
<ElText line-clamp="2" class="item-desc w-full">
|
||||||
|
{{ item[descField] }}
|
||||||
</ElText>
|
</ElText>
|
||||||
<ElTag
|
|
||||||
v-if="tagField && item[tagField]"
|
|
||||||
size="small"
|
|
||||||
effect="plain"
|
|
||||||
type="info"
|
|
||||||
>
|
|
||||||
{{ tagMap[item[tagField]] || item[tagField] }}
|
|
||||||
</ElTag>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ElText line-clamp="2" class="item-desc w-full">
|
|
||||||
{{ item[descField] }}
|
|
||||||
</ElText>
|
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
|
||||||
<div :class="visibleActions.length > 2 ? 'footer-div' : ''">
|
<template v-if="showFooter" #footer>
|
||||||
<template v-for="(action, idx) in visibleActions" :key="idx">
|
<div class="card-footer">
|
||||||
|
<div v-if="resolvedPrimaryAction" class="card-primary-hint">
|
||||||
|
<div class="primary-label">
|
||||||
|
<ElIcon v-if="resolvedPrimaryAction.icon" class="primary-icon">
|
||||||
|
<IconifyIcon
|
||||||
|
v-if="typeof resolvedPrimaryAction.icon === 'string'"
|
||||||
|
:icon="resolvedPrimaryAction.icon"
|
||||||
|
/>
|
||||||
|
<component v-else :is="resolvedPrimaryAction.icon" />
|
||||||
|
</ElIcon>
|
||||||
|
<span class="primary-text">{{ resolvedPrimaryAction.text }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="inlineActions.length || menuActions.length"
|
||||||
|
class="card-actions"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
<ElButton
|
<ElButton
|
||||||
|
v-for="action in inlineActions"
|
||||||
|
:key="action.text"
|
||||||
:icon="typeof action.icon === 'string' ? undefined : action.icon"
|
:icon="typeof action.icon === 'string' ? undefined : action.icon"
|
||||||
size="small"
|
size="small"
|
||||||
:style="{
|
class="card-action-btn"
|
||||||
'--el-button-text-color': 'hsl(220deg 9.68% 63.53%)',
|
:class="{ 'card-action-btn--danger': action.tone === 'danger' }"
|
||||||
'--el-button-font-weight': 400,
|
|
||||||
}"
|
|
||||||
link
|
link
|
||||||
@click="action.onClick(item)"
|
@click.stop="handleActionClick($event, action, item)"
|
||||||
>
|
>
|
||||||
<template v-if="typeof action.icon === 'string'" #icon>
|
<template v-if="typeof action.icon === 'string'" #icon>
|
||||||
<IconifyIcon :icon="action.icon" />
|
<IconifyIcon :icon="action.icon" />
|
||||||
</template>
|
</template>
|
||||||
{{ action.text }}
|
{{ action.text }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
<ElDivider
|
|
||||||
v-if="
|
|
||||||
filterActions.length <= 3
|
|
||||||
? idx < filterActions.length - 1
|
|
||||||
: true
|
|
||||||
"
|
|
||||||
direction="vertical"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<ElDropdown v-if="hiddenActions.length > 0" trigger="click">
|
<ElDropdown
|
||||||
<ElButton
|
v-if="menuActions.length > 0"
|
||||||
:style="{
|
trigger="click"
|
||||||
'--el-button-text-color': 'hsl(220deg 9.68% 63.53%)',
|
placement="bottom-end"
|
||||||
'--el-button-font-weight': 400,
|
>
|
||||||
}"
|
<ElButton
|
||||||
:icon="MoreFilled"
|
class="card-action-btn card-action-btn--menu"
|
||||||
link
|
:icon="MoreFilled"
|
||||||
/>
|
link
|
||||||
<template #dropdown>
|
@click.stop
|
||||||
<ElDropdownMenu>
|
/>
|
||||||
<ElDropdownItem
|
<template #dropdown>
|
||||||
v-for="(action, idx) in hiddenActions"
|
<ElDropdownMenu>
|
||||||
:key="idx"
|
<ElDropdownItem
|
||||||
@click="action.onClick(item)"
|
v-for="action in menuActions"
|
||||||
>
|
:key="action.text"
|
||||||
<template #default>
|
:class="{ 'card-menu-item--danger': action.tone === 'danger' }"
|
||||||
<div :class="`${action.className} handle-div`">
|
@click="action.onClick(item)"
|
||||||
|
>
|
||||||
|
<div class="menu-action-content">
|
||||||
<ElIcon v-if="action.icon">
|
<ElIcon v-if="action.icon">
|
||||||
<component :is="action.icon" />
|
<IconifyIcon
|
||||||
|
v-if="typeof action.icon === 'string'"
|
||||||
|
:icon="action.icon"
|
||||||
|
/>
|
||||||
|
<component v-else :is="action.icon" />
|
||||||
</ElIcon>
|
</ElIcon>
|
||||||
{{ action.text }}
|
<span>{{ action.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</ElDropdownItem>
|
||||||
</ElDropdownItem>
|
</ElDropdownMenu>
|
||||||
</ElDropdownMenu>
|
</template>
|
||||||
</template>
|
</ElDropdown>
|
||||||
</ElDropdown>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ElCard>
|
</ElCard>
|
||||||
@@ -158,68 +234,184 @@ const hiddenActions = computed(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* 响应式调整 */
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.card-grid {
|
.card-grid {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.card-grid {
|
.card-grid {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
min-width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.card-grid {
|
.card-grid {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-card__footer) {
|
|
||||||
border-top: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-div {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 8px 20px;
|
|
||||||
background-color: hsl(var(--background-deep));
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.handle-div {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-grid {
|
.card-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
min-width: max(100%, 600px); /* 确保至少显示2个卡片 */
|
min-width: max(100%, 600px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-desc {
|
.card-item {
|
||||||
height: 40px;
|
background: hsl(var(--card));
|
||||||
font-size: clamp(8px, 1vw, 14px);
|
border: 1px solid hsl(var(--border));
|
||||||
line-height: 20px;
|
border-radius: 18px;
|
||||||
color: #75808d;
|
transition:
|
||||||
|
transform 0.18s ease,
|
||||||
|
box-shadow 0.18s ease,
|
||||||
|
border-color 0.18s ease,
|
||||||
|
background-color 0.18s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-danger {
|
.card-item--interactive {
|
||||||
color: var(--el-color-danger);
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-item--interactive:hover {
|
||||||
|
border-color: hsl(var(--primary) / 20%);
|
||||||
|
box-shadow: var(--shadow-subtle);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-item--interactive:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
border-color: hsl(var(--primary) / 32%);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 4px hsl(var(--primary) / 12%),
|
||||||
|
var(--shadow-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-card__body) {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-card__footer) {
|
||||||
|
padding: 0 18px 18px;
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
display: flex;
|
||||||
|
min-height: 116px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-avatar {
|
||||||
|
background: hsl(var(--surface-subtle));
|
||||||
|
border: 1px solid hsl(var(--line-subtle));
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-meta {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-row {
|
.title-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--text-strong));
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-desc {
|
||||||
|
min-height: 44px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 22px;
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid hsl(var(--divider-faint) / 85%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-primary-hint {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-label {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-icon {
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-text {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: hsl(var(--text-strong));
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-action-btn {
|
||||||
|
--el-button-font-weight: 500;
|
||||||
|
--el-button-text-color: hsl(var(--text-muted));
|
||||||
|
--el-button-hover-text-color: hsl(var(--text-strong));
|
||||||
|
--el-button-active-text-color: hsl(var(--text-strong));
|
||||||
|
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-action-btn--danger {
|
||||||
|
--el-button-text-color: hsl(var(--destructive));
|
||||||
|
--el-button-hover-text-color: hsl(var(--destructive));
|
||||||
|
--el-button-active-text-color: hsl(var(--destructive));
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-action-btn--menu {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-action-content {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.card-menu-item--danger) {
|
||||||
|
color: hsl(var(--destructive));
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ interface Props {
|
|||||||
contentPadding?: number | string;
|
contentPadding?: number | string;
|
||||||
dense?: boolean;
|
dense?: boolean;
|
||||||
stickyToolbar?: boolean;
|
stickyToolbar?: boolean;
|
||||||
surface?: 'panel' | 'subtle';
|
surface?: 'panel' | 'plain' | 'subtle';
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@@ -141,6 +141,18 @@ const contentStyle = computed((): CSSProperties => {
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-page-shell.is-plain .list-page-shell__content {
|
||||||
|
background: hsl(var(--surface-panel) / 0.96);
|
||||||
|
border: 1px solid hsl(var(--divider-faint) / 0.58);
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: none;
|
||||||
|
backdrop-filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-page-shell.is-plain .list-page-shell__content::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.list-page-shell__content::before {
|
.list-page-shell__content::before {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0 0 auto;
|
inset: 0 0 auto;
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import {mount} from '@vue/test-utils';
|
||||||
|
|
||||||
|
import {describe, expect, it, vi} from 'vitest';
|
||||||
|
import CardList from '../CardList.vue';
|
||||||
|
|
||||||
|
const { hasAccessByCodes } = vi.hoisted(() => ({
|
||||||
|
hasAccessByCodes: vi.fn((codes: string[]) => codes[0] !== 'blocked'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@easyflow/access', () => ({
|
||||||
|
useAccess: () => ({
|
||||||
|
hasAccessByCodes,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('CardList', () => {
|
||||||
|
function mountCardList(props: Record<string, unknown>) {
|
||||||
|
return mount(CardList, {
|
||||||
|
props: {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 'bot-1',
|
||||||
|
title: '演示卡片',
|
||||||
|
description: '用于验证主次交互是否正常工作',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
defaultIcon: '/favicon.svg',
|
||||||
|
...props,
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
IconifyIcon: {
|
||||||
|
props: ['icon'],
|
||||||
|
template: '<span class="iconify-icon">{{ icon }}</span>',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('点击卡片空白区域时触发主动作', async () => {
|
||||||
|
const primaryAction = vi.fn();
|
||||||
|
const wrapper = mountCardList({
|
||||||
|
primaryAction: {
|
||||||
|
text: '进入设置',
|
||||||
|
onClick: primaryAction,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await wrapper.get('.card-item').trigger('click');
|
||||||
|
|
||||||
|
expect(primaryAction).toHaveBeenCalledTimes(1);
|
||||||
|
expect(primaryAction).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: 'bot-1' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('点击次级按钮时不会冒泡触发主动作', async () => {
|
||||||
|
const primaryAction = vi.fn();
|
||||||
|
const inlineAction = vi.fn();
|
||||||
|
const wrapper = mountCardList({
|
||||||
|
primaryAction: {
|
||||||
|
text: '进入设置',
|
||||||
|
onClick: primaryAction,
|
||||||
|
},
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
text: '编辑',
|
||||||
|
placement: 'inline',
|
||||||
|
onClick: inlineAction,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await wrapper.get('.card-action-btn').trigger('click');
|
||||||
|
|
||||||
|
expect(inlineAction).toHaveBeenCalledTimes(1);
|
||||||
|
expect(primaryAction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('键盘 Enter 可以触发主动作', async () => {
|
||||||
|
const primaryAction = vi.fn();
|
||||||
|
const wrapper = mountCardList({
|
||||||
|
primaryAction: {
|
||||||
|
text: '进入设计',
|
||||||
|
onClick: primaryAction,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await wrapper.get('.card-item').trigger('keydown.enter');
|
||||||
|
|
||||||
|
expect(primaryAction).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('未提供主动作时保持旧卡片模式,不会把卡片变成可点击入口', async () => {
|
||||||
|
const legacyAction = vi.fn();
|
||||||
|
const wrapper = mountCardList({
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
text: '编辑',
|
||||||
|
onClick: legacyAction,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await wrapper.get('.card-item').trigger('click');
|
||||||
|
await wrapper.get('.card-action-btn').trigger('click');
|
||||||
|
|
||||||
|
expect(wrapper.get('.card-item').attributes('role')).toBeUndefined();
|
||||||
|
expect(legacyAction).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -58,7 +58,6 @@
|
|||||||
"button": {
|
"button": {
|
||||||
"management": "Management",
|
"management": "Management",
|
||||||
"test": "Test",
|
"test": "Test",
|
||||||
"addAllLlm": "Add models from the list",
|
|
||||||
"RetrieveAgain": "Retrieve the model list again"
|
"RetrieveAgain": "Retrieve the model list again"
|
||||||
},
|
},
|
||||||
"all": "All",
|
"all": "All",
|
||||||
|
|||||||
@@ -23,5 +23,23 @@
|
|||||||
"newPwd": "NewPassword",
|
"newPwd": "NewPassword",
|
||||||
"confirmPwd": "ConfirmPassword",
|
"confirmPwd": "ConfirmPassword",
|
||||||
"repeatPwd": "Please confirm your password again",
|
"repeatPwd": "Please confirm your password again",
|
||||||
"notSamePwd": "The two passwords are inconsistent"
|
"notSamePwd": "The two passwords are inconsistent",
|
||||||
|
"passwordStrongTip": "Password must be at least 8 characters and include uppercase, lowercase, numbers, and special characters",
|
||||||
|
"forceChangePasswordNavigateTip": "For account security, please change your password before visiting other pages.",
|
||||||
|
"resetPassword": "Reset Password",
|
||||||
|
"resetPasswordConfirm": "Reset this account password to 123456? The user will be required to change it on next login.",
|
||||||
|
"resetPasswordSuccess": "Password has been reset to 123456 and must be changed on next login",
|
||||||
|
"importTitle": "Import Users",
|
||||||
|
"importUploadTitle": "Drag the Excel file here, or click to select a file",
|
||||||
|
"importUploadDesc": "Only .xlsx / .xls files are supported. Import only creates users and duplicate accounts will fail.",
|
||||||
|
"importSelectFileRequired": "Please select a file to import",
|
||||||
|
"downloadTemplate": "Download Template",
|
||||||
|
"importFinished": "User import completed",
|
||||||
|
"importResultTitle": "Import Result",
|
||||||
|
"importTotalCount": "Total",
|
||||||
|
"importSuccessCount": "Success",
|
||||||
|
"importErrorCount": "Failed",
|
||||||
|
"importRowNumber": "Row",
|
||||||
|
"importDeptCode": "Dept Code",
|
||||||
|
"importReason": "Reason"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,6 @@
|
|||||||
"button": {
|
"button": {
|
||||||
"management": "管理",
|
"management": "管理",
|
||||||
"test": "检测",
|
"test": "检测",
|
||||||
"addAllLlm": "添加列表中的所有模型",
|
|
||||||
"RetrieveAgain": "重新获取模型列表"
|
"RetrieveAgain": "重新获取模型列表"
|
||||||
},
|
},
|
||||||
"all": "全部",
|
"all": "全部",
|
||||||
|
|||||||
@@ -24,5 +24,23 @@
|
|||||||
"newPwd": "新密码",
|
"newPwd": "新密码",
|
||||||
"confirmPwd": "确认密码",
|
"confirmPwd": "确认密码",
|
||||||
"repeatPwd": "请再次输入密码",
|
"repeatPwd": "请再次输入密码",
|
||||||
"notSamePwd": "两次输入的密码不一致"
|
"notSamePwd": "两次输入的密码不一致",
|
||||||
|
"passwordStrongTip": "密码必须至少 8 位,且包含大写字母、小写字母、数字和特殊字符",
|
||||||
|
"forceChangePasswordNavigateTip": "为了账户安全,请先修改密码后再继续访问其他页面",
|
||||||
|
"resetPassword": "重置密码",
|
||||||
|
"resetPasswordConfirm": "确认将该用户密码重置为 123456 吗?重置后用户下次登录必须先修改密码。",
|
||||||
|
"resetPasswordSuccess": "密码已重置为 123456,用户下次登录需修改密码",
|
||||||
|
"importTitle": "导入用户",
|
||||||
|
"importUploadTitle": "拖拽 Excel 文件到此处,或点击选择文件",
|
||||||
|
"importUploadDesc": "仅支持 .xlsx / .xls,导入只新增用户,重复账号会报错",
|
||||||
|
"importSelectFileRequired": "请先选择要导入的文件",
|
||||||
|
"downloadTemplate": "下载导入模板",
|
||||||
|
"importFinished": "用户导入完成",
|
||||||
|
"importResultTitle": "导入结果",
|
||||||
|
"importTotalCount": "总条数",
|
||||||
|
"importSuccessCount": "成功数",
|
||||||
|
"importErrorCount": "失败数",
|
||||||
|
"importRowNumber": "行号",
|
||||||
|
"importDeptCode": "部门编码",
|
||||||
|
"importReason": "失败原因"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ import {
|
|||||||
removeDevLoginQuery,
|
removeDevLoginQuery,
|
||||||
shouldAttemptDevLogin,
|
shouldAttemptDevLogin,
|
||||||
} from './dev-login';
|
} from './dev-login';
|
||||||
|
import {
|
||||||
|
buildForcePasswordRoute,
|
||||||
|
isForcePasswordRoute,
|
||||||
|
notifyForcePasswordChange,
|
||||||
|
shouldForcePasswordChange,
|
||||||
|
} from '#/utils/password-reset';
|
||||||
|
|
||||||
interface NetworkConnectionLike {
|
interface NetworkConnectionLike {
|
||||||
effectiveType?: string;
|
effectiveType?: string;
|
||||||
@@ -183,6 +189,10 @@ function setupAccessGuard(router: Router) {
|
|||||||
// 基本路由,这些路由不需要进入权限拦截
|
// 基本路由,这些路由不需要进入权限拦截
|
||||||
if (coreRouteNames.includes(to.name as string)) {
|
if (coreRouteNames.includes(to.name as string)) {
|
||||||
if (to.path === LOGIN_PATH && accessStore.accessToken) {
|
if (to.path === LOGIN_PATH && accessStore.accessToken) {
|
||||||
|
const currentUser = userStore.userInfo || (await authStore.fetchUserInfo());
|
||||||
|
if (shouldForcePasswordChange(currentUser)) {
|
||||||
|
return buildForcePasswordRoute();
|
||||||
|
}
|
||||||
return decodeURIComponent(
|
return decodeURIComponent(
|
||||||
(to.query?.redirect as string) ||
|
(to.query?.redirect as string) ||
|
||||||
userStore.userInfo?.homePath ||
|
userStore.userInfo?.homePath ||
|
||||||
@@ -219,6 +229,14 @@ function setupAccessGuard(router: Router) {
|
|||||||
return to;
|
return to;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userInfo = userStore.userInfo || (await authStore.fetchUserInfo());
|
||||||
|
if (shouldForcePasswordChange(userInfo) && !isForcePasswordRoute(to)) {
|
||||||
|
if (from.name) {
|
||||||
|
notifyForcePasswordChange();
|
||||||
|
}
|
||||||
|
return buildForcePasswordRoute();
|
||||||
|
}
|
||||||
|
|
||||||
// 是否已经生成过动态路由
|
// 是否已经生成过动态路由
|
||||||
if (accessStore.isAccessChecked) {
|
if (accessStore.isAccessChecked) {
|
||||||
return true;
|
return true;
|
||||||
@@ -226,7 +244,6 @@ function setupAccessGuard(router: Router) {
|
|||||||
|
|
||||||
// 生成路由表
|
// 生成路由表
|
||||||
// 当前登录用户拥有的角色标识列表
|
// 当前登录用户拥有的角色标识列表
|
||||||
const userInfo = userStore.userInfo || (await authStore.fetchUserInfo());
|
|
||||||
const userRoles = userInfo.roles ?? [];
|
const userRoles = userInfo.roles ?? [];
|
||||||
|
|
||||||
// 生成菜单和路由
|
// 生成菜单和路由
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ import {
|
|||||||
logoutApi,
|
logoutApi,
|
||||||
} from '#/api';
|
} from '#/api';
|
||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
|
import {
|
||||||
|
buildForcePasswordRoute,
|
||||||
|
shouldForcePasswordChange,
|
||||||
|
} from '#/utils/password-reset';
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const accessStore = useAccessStore();
|
const accessStore = useAccessStore();
|
||||||
@@ -28,6 +32,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
|
|
||||||
async function finalizeLogin(
|
async function finalizeLogin(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
|
forceChangePassword?: boolean,
|
||||||
options: {
|
options: {
|
||||||
notify?: boolean;
|
notify?: boolean;
|
||||||
onSuccess?: () => Promise<void> | void;
|
onSuccess?: () => Promise<void> | void;
|
||||||
@@ -47,8 +52,17 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
userStore.setUserInfo(userInfo);
|
userStore.setUserInfo(userInfo);
|
||||||
accessStore.setAccessCodes(accessCodes);
|
accessStore.setAccessCodes(accessCodes);
|
||||||
|
|
||||||
|
const forcePasswordChange = shouldForcePasswordChange(
|
||||||
|
userInfo,
|
||||||
|
forceChangePassword,
|
||||||
|
);
|
||||||
|
|
||||||
if (accessStore.loginExpired) {
|
if (accessStore.loginExpired) {
|
||||||
accessStore.setLoginExpired(false);
|
accessStore.setLoginExpired(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (forcePasswordChange) {
|
||||||
|
await router.push(buildForcePasswordRoute());
|
||||||
} else if (!options.skipRedirect) {
|
} else if (!options.skipRedirect) {
|
||||||
const homePath =
|
const homePath =
|
||||||
userInfo.homePath || preferences.app.defaultHomePath || '/';
|
userInfo.homePath || preferences.app.defaultHomePath || '/';
|
||||||
@@ -81,10 +95,12 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
loginLoading.value = true;
|
loginLoading.value = true;
|
||||||
const { token: accessToken } = await loginApi(params);
|
const { forceChangePassword, token: accessToken } = await loginApi(params);
|
||||||
|
|
||||||
if (accessToken) {
|
if (accessToken) {
|
||||||
return await finalizeLogin(accessToken, { onSuccess });
|
return await finalizeLogin(accessToken, forceChangePassword, {
|
||||||
|
onSuccess,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loginLoading.value = false;
|
loginLoading.value = false;
|
||||||
@@ -96,13 +112,15 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function authDevLogin(account: string) {
|
async function authDevLogin(account: string) {
|
||||||
const { token: accessToken } = await devLoginApi({ account });
|
const { forceChangePassword, token: accessToken } = await devLoginApi({
|
||||||
|
account,
|
||||||
|
});
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
return {
|
return {
|
||||||
userInfo: null,
|
userInfo: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return finalizeLogin(accessToken, {
|
return finalizeLogin(accessToken, forceChangePassword, {
|
||||||
notify: false,
|
notify: false,
|
||||||
skipRedirect: true,
|
skipRedirect: true,
|
||||||
});
|
});
|
||||||
|
|||||||
6
easyflow-ui-admin/app/src/types/vue-element-plus-x.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
declare module 'vue-element-plus-x/es/*/index.js' {
|
||||||
|
import type { DefineComponent } from 'vue';
|
||||||
|
|
||||||
|
const component: DefineComponent<Record<string, any>, Record<string, any>, any>;
|
||||||
|
export default component;
|
||||||
|
}
|
||||||
6
easyflow-ui-admin/app/src/utils/password-policy.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export const strongPasswordPattern =
|
||||||
|
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z\d]).{8,}$/;
|
||||||
|
|
||||||
|
export function isStrongPassword(value?: string) {
|
||||||
|
return !!value && strongPasswordPattern.test(value);
|
||||||
|
}
|
||||||
52
easyflow-ui-admin/app/src/utils/password-reset.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import type { Router } from 'vue-router';
|
||||||
|
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
|
||||||
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
|
const FORCE_PASSWORD_NOTICE_INTERVAL = 1500;
|
||||||
|
|
||||||
|
let lastForcePasswordNoticeAt = 0;
|
||||||
|
|
||||||
|
export function buildForcePasswordRoute() {
|
||||||
|
return {
|
||||||
|
name: 'Profile',
|
||||||
|
query: {
|
||||||
|
force: '1',
|
||||||
|
tab: 'password',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isForcePasswordRoute(
|
||||||
|
route: Pick<{ name?: string | symbol | null; query?: Record<string, any> }, 'name' | 'query'>,
|
||||||
|
) {
|
||||||
|
return route.name === 'Profile' && route.query?.tab === 'password';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldForcePasswordChange(
|
||||||
|
userInfo?: null | Record<string, any>,
|
||||||
|
forceChangePassword?: boolean,
|
||||||
|
) {
|
||||||
|
return !!forceChangePassword || !!userInfo?.passwordResetRequired;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveForcePasswordPath(router: Router) {
|
||||||
|
return router.resolve(buildForcePasswordRoute()).fullPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyForcePasswordChange() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastForcePasswordNoticeAt < FORCE_PASSWORD_NOTICE_INTERVAL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastForcePasswordNoticeAt = now;
|
||||||
|
ElMessage({
|
||||||
|
message: $t('sysAccount.forceChangePasswordNavigateTip'),
|
||||||
|
type: 'warning',
|
||||||
|
duration: 2200,
|
||||||
|
grouping: true,
|
||||||
|
plain: true,
|
||||||
|
showClose: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,14 +1,11 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { EasyFlowFormSchema } from '@easyflow/common-ui';
|
import type {EasyFlowFormSchema} from '@easyflow/common-ui';
|
||||||
|
import {AuthenticationLogin, z} from '@easyflow/common-ui';
|
||||||
|
|
||||||
import { computed, onMounted } from 'vue';
|
import {computed, onMounted} from 'vue';
|
||||||
|
import {useAppConfig} from '@easyflow/hooks';
|
||||||
import { AuthenticationLogin, z } from '@easyflow/common-ui';
|
import {$t} from '@easyflow/locales';
|
||||||
import { useAppConfig } from '@easyflow/hooks';
|
import {useAuthStore} from '#/store';
|
||||||
import { $t } from '@easyflow/locales';
|
|
||||||
import { preferences } from '@easyflow/preferences';
|
|
||||||
|
|
||||||
import { useAuthStore } from '#/store';
|
|
||||||
|
|
||||||
defineOptions({ name: 'Login' });
|
defineOptions({ name: 'Login' });
|
||||||
onMounted(() => {});
|
onMounted(() => {});
|
||||||
@@ -16,9 +13,10 @@ onMounted(() => {});
|
|||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
|
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
|
||||||
|
const assetBase = import.meta.env.BASE_URL || '/';
|
||||||
|
const captchaAssetBase = `${assetBase}tac`;
|
||||||
|
const captchaButtonUrl = `${assetBase}tac-btn.png`;
|
||||||
|
|
||||||
const title = computed(() => preferences.auth.welcomeBack);
|
|
||||||
const subTitle = computed(() => preferences.auth.loginSubtitle);
|
|
||||||
const formSchema = computed((): EasyFlowFormSchema[] => {
|
const formSchema = computed((): EasyFlowFormSchema[] => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -76,11 +74,11 @@ function onSubmit(values: any) {
|
|||||||
const style = {
|
const style = {
|
||||||
logoUrl: null, // 去除logo
|
logoUrl: null, // 去除logo
|
||||||
// logoUrl: "/xx/xx/xxx.png" // 替换成自定义的logo
|
// logoUrl: "/xx/xx/xxx.png" // 替换成自定义的logo
|
||||||
btnUrl: '/tac-btn.png',
|
btnUrl: captchaButtonUrl,
|
||||||
};
|
};
|
||||||
window
|
window
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
.initTAC('/tac', config, style)
|
.initTAC(captchaAssetBase, config, style)
|
||||||
.then((tac: any) => {
|
.then((tac: any) => {
|
||||||
tac.init(); // 调用init则显示验证码
|
tac.init(); // 调用init则显示验证码
|
||||||
})
|
})
|
||||||
@@ -91,23 +89,38 @@ function onSubmit(values: any) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="login-view">
|
||||||
<AuthenticationLogin
|
<AuthenticationLogin
|
||||||
:form-schema="formSchema"
|
:form-schema="formSchema"
|
||||||
:loading="authStore.loginLoading"
|
:loading="authStore.loginLoading"
|
||||||
:title="title"
|
:show-header="false"
|
||||||
:sub-title="subTitle"
|
|
||||||
@submit="onSubmit"
|
@submit="onSubmit"
|
||||||
/>
|
>
|
||||||
<div id="captcha-box" class="captcha-div"></div>
|
<template #overlay>
|
||||||
|
<div id="captcha-box" class="captcha-anchor"></div>
|
||||||
|
</template>
|
||||||
|
</AuthenticationLogin>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.captcha-div {
|
.login-view {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captcha-anchor {
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 30vh;
|
z-index: 30;
|
||||||
left: 21vh;
|
}
|
||||||
|
|
||||||
|
.captcha-anchor:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captcha-anchor :deep(*) {
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.platform-icon {
|
.platform-icon {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
import { Profile } from '@easyflow/common-ui';
|
import { Profile } from '@easyflow/common-ui';
|
||||||
@@ -17,22 +17,42 @@ const userStore = useUserStore();
|
|||||||
|
|
||||||
const tabsValue = ref<string>('basic');
|
const tabsValue = ref<string>('basic');
|
||||||
|
|
||||||
const tabs = [
|
const forcePasswordChange = computed(() => {
|
||||||
{
|
return !!userStore.userInfo?.passwordResetRequired || route.query.force === '1';
|
||||||
label: $t('settingsConfig.basic'),
|
|
||||||
value: 'basic',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: $t('settingsConfig.updatePwd'),
|
|
||||||
value: 'password',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (route.query.tab) {
|
|
||||||
tabsValue.value = route.query.tab as string;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const tabs = computed(() => {
|
||||||
|
if (forcePasswordChange.value) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: $t('settingsConfig.updatePwd'),
|
||||||
|
value: 'password',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: $t('settingsConfig.basic'),
|
||||||
|
value: 'basic',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: $t('settingsConfig.updatePwd'),
|
||||||
|
value: 'password',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [route.query.force, route.query.tab, userStore.userInfo?.passwordResetRequired],
|
||||||
|
() => {
|
||||||
|
if (forcePasswordChange.value) {
|
||||||
|
tabsValue.value = 'password';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tabsValue.value = (route.query.tab as string) || 'basic';
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<Profile
|
<Profile
|
||||||
@@ -43,8 +63,8 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
<template #content>
|
<template #content>
|
||||||
<ProfileBase v-if="tabsValue === 'basic'" />
|
<ProfileBase v-if="tabsValue === 'basic'" />
|
||||||
<ProfileSecuritySetting v-if="tabsValue === 'security'" />
|
|
||||||
<ProfilePasswordSetting v-if="tabsValue === 'password'" />
|
<ProfilePasswordSetting v-if="tabsValue === 'password'" />
|
||||||
|
<ProfileSecuritySetting v-if="tabsValue === 'security'" />
|
||||||
<ProfileNotificationSetting v-if="tabsValue === 'notice'" />
|
<ProfileNotificationSetting v-if="tabsValue === 'notice'" />
|
||||||
</template>
|
</template>
|
||||||
</Profile>
|
</Profile>
|
||||||
|
|||||||
@@ -2,15 +2,28 @@
|
|||||||
import type { EasyFlowFormSchema } from '#/adapter/form';
|
import type { EasyFlowFormSchema } from '#/adapter/form';
|
||||||
|
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
import { ProfilePasswordSetting, z } from '@easyflow/common-ui';
|
import { ProfilePasswordSetting, z } from '@easyflow/common-ui';
|
||||||
|
import { preferences } from '@easyflow/preferences';
|
||||||
|
import { useUserStore } from '@easyflow/stores';
|
||||||
|
|
||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
|
|
||||||
import { api } from '#/api/request';
|
import { api } from '#/api/request';
|
||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
|
import { useAuthStore } from '#/store';
|
||||||
|
import { isStrongPassword } from '#/utils/password-policy';
|
||||||
|
|
||||||
const profilePasswordSettingRef = ref();
|
const profilePasswordSettingRef = ref();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
|
||||||
|
const isForcedPasswordChange = computed(() => {
|
||||||
|
return !!userStore.userInfo?.passwordResetRequired || route.query.force === '1';
|
||||||
|
});
|
||||||
|
|
||||||
const formSchema = computed((): EasyFlowFormSchema[] => {
|
const formSchema = computed((): EasyFlowFormSchema[] => {
|
||||||
return [
|
return [
|
||||||
@@ -30,6 +43,17 @@ const formSchema = computed((): EasyFlowFormSchema[] => {
|
|||||||
passwordStrength: true,
|
passwordStrength: true,
|
||||||
placeholder: $t('sysAccount.newPwd') + $t('common.isRequired'),
|
placeholder: $t('sysAccount.newPwd') + $t('common.isRequired'),
|
||||||
},
|
},
|
||||||
|
renderComponentContent() {
|
||||||
|
return {
|
||||||
|
strengthText: () => $t('sysAccount.passwordStrongTip'),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
rules: z
|
||||||
|
.string({ required_error: $t('sysAccount.newPwd') + $t('common.isRequired') })
|
||||||
|
.min(1, { message: $t('sysAccount.newPwd') + $t('common.isRequired') })
|
||||||
|
.refine((value) => isStrongPassword(value), {
|
||||||
|
message: $t('sysAccount.passwordStrongTip'),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
fieldName: 'confirmPassword',
|
fieldName: 'confirmPassword',
|
||||||
@@ -56,14 +80,22 @@ const formSchema = computed((): EasyFlowFormSchema[] => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const updateLoading = ref(false);
|
const updateLoading = ref(false);
|
||||||
function handleSubmit(values: any) {
|
async function handleSubmit(values: any) {
|
||||||
updateLoading.value = true;
|
updateLoading.value = true;
|
||||||
api.post('/api/v1/sysAccount/updatePassword', values).then((res) => {
|
try {
|
||||||
updateLoading.value = false;
|
const res = await api.post('/api/v1/sysAccount/updatePassword', values);
|
||||||
if (res.errorCode === 0) {
|
if (res.errorCode === 0) {
|
||||||
ElMessage.success($t('message.success'));
|
ElMessage.success($t('message.success'));
|
||||||
|
const userInfo = await authStore.fetchUserInfo();
|
||||||
|
if (isForcedPasswordChange.value) {
|
||||||
|
await router.replace(
|
||||||
|
userInfo?.homePath || preferences.app.defaultHomePath || '/',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
} finally {
|
||||||
|
updateLoading.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import type { FormInstance } from 'element-plus';
|
|||||||
|
|
||||||
import type { BotInfo } from '@easyflow/types';
|
import type { BotInfo } from '@easyflow/types';
|
||||||
|
|
||||||
import type { ActionButton } from '#/components/page/CardList.vue';
|
import type {
|
||||||
|
ActionButton,
|
||||||
|
CardPrimaryAction,
|
||||||
|
} from '#/components/page/CardList.vue';
|
||||||
|
|
||||||
import { computed, markRaw, onMounted, ref } from 'vue';
|
import { computed, markRaw, onMounted, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
@@ -70,36 +73,35 @@ const headerButtons = [
|
|||||||
function resolveNavTitle(row: BotInfo) {
|
function resolveNavTitle(row: BotInfo) {
|
||||||
return (row as Record<string, any>)?.title || row?.name || '';
|
return (row as Record<string, any>)?.title || row?.name || '';
|
||||||
}
|
}
|
||||||
|
const primaryAction: CardPrimaryAction = {
|
||||||
|
icon: Setting,
|
||||||
|
text: $t('button.setting'),
|
||||||
|
onClick(row: BotInfo) {
|
||||||
|
router.push({
|
||||||
|
path: `/ai/bots/setting/${row.id}`,
|
||||||
|
query: {
|
||||||
|
pageKey: '/ai/bots',
|
||||||
|
navTitle: resolveNavTitle(row),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const actions: ActionButton[] = [
|
const actions: ActionButton[] = [
|
||||||
{
|
{
|
||||||
icon: Edit,
|
icon: Edit,
|
||||||
text: $t('button.edit'),
|
text: $t('button.edit'),
|
||||||
className: '',
|
placement: 'inline',
|
||||||
permission: '',
|
|
||||||
onClick(row: BotInfo) {
|
onClick(row: BotInfo) {
|
||||||
modalRef.value?.open('edit', row);
|
modalRef.value?.open('edit', row);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
icon: Setting,
|
|
||||||
text: $t('button.setting'),
|
|
||||||
className: '',
|
|
||||||
permission: '',
|
|
||||||
onClick(row: BotInfo) {
|
|
||||||
router.push({
|
|
||||||
path: '/ai/bots/setting/' + row.id,
|
|
||||||
query: {
|
|
||||||
pageKey: '/ai/bots',
|
|
||||||
navTitle: resolveNavTitle(row),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
icon: Delete,
|
icon: Delete,
|
||||||
text: $t('button.delete'),
|
text: $t('button.delete'),
|
||||||
className: 'item-danger',
|
tone: 'danger',
|
||||||
permission: '/api/v1/bot/remove',
|
permission: '/api/v1/bot/remove',
|
||||||
|
placement: 'inline',
|
||||||
onClick(row: BotInfo) {
|
onClick(row: BotInfo) {
|
||||||
removeBot(row);
|
removeBot(row);
|
||||||
},
|
},
|
||||||
@@ -298,6 +300,7 @@ const getSideList = async () => {
|
|||||||
<CardList
|
<CardList
|
||||||
:default-icon="defaultAvatar"
|
:default-icon="defaultAvatar"
|
||||||
:data="pageList"
|
:data="pageList"
|
||||||
|
:primary-action="primaryAction"
|
||||||
:actions="actions"
|
:actions="actions"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { BotInfo, Session } from '@easyflow/types';
|
|||||||
|
|
||||||
import { onMounted, ref, watchEffect } from 'vue';
|
import { onMounted, ref, watchEffect } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import ElConversations from 'vue-element-plus-x/es/Conversations/index.js';
|
||||||
|
|
||||||
import { IconifyIcon } from '@easyflow/icons';
|
import { IconifyIcon } from '@easyflow/icons';
|
||||||
import { preferences } from '@easyflow/preferences';
|
import { preferences } from '@easyflow/preferences';
|
||||||
|
|||||||
@@ -1,29 +1,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { FormInstance } from 'element-plus';
|
import type {FormInstance} from 'element-plus';
|
||||||
|
import {ElForm, ElFormItem, ElInput, ElInputNumber, ElMessage, ElMessageBox,} from 'element-plus';
|
||||||
|
|
||||||
import type { ActionButton } from '#/components/page/CardList.vue';
|
import type {ActionButton, CardPrimaryAction,} from '#/components/page/CardList.vue';
|
||||||
|
import CardPage from '#/components/page/CardList.vue';
|
||||||
|
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import {computed, onMounted, ref} from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import {useRouter} from 'vue-router';
|
||||||
|
|
||||||
import { EasyFlowFormModal } from '@easyflow/common-ui';
|
import {EasyFlowFormModal} from '@easyflow/common-ui';
|
||||||
import { $t } from '@easyflow/locales';
|
import {$t} from '@easyflow/locales';
|
||||||
|
|
||||||
import { Delete, Edit, Notebook, Plus, Search } from '@element-plus/icons-vue';
|
import {Delete, Edit, Notebook, Plus, Search} from '@element-plus/icons-vue';
|
||||||
import {
|
import {tryit} from 'radash';
|
||||||
ElForm,
|
|
||||||
ElFormItem,
|
|
||||||
ElInput,
|
|
||||||
ElInputNumber,
|
|
||||||
ElMessage,
|
|
||||||
ElMessageBox,
|
|
||||||
} from 'element-plus';
|
|
||||||
import { tryit } from 'radash';
|
|
||||||
|
|
||||||
import { api } from '#/api/request';
|
import {api} from '#/api/request';
|
||||||
import defaultIcon from '#/assets/ai/knowledge/book.svg';
|
import defaultIcon from '#/assets/ai/knowledge/book.svg';
|
||||||
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
|
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
|
||||||
import CardPage from '#/components/page/CardList.vue';
|
|
||||||
import PageData from '#/components/page/PageData.vue';
|
import PageData from '#/components/page/PageData.vue';
|
||||||
import PageSide from '#/components/page/PageSide.vue';
|
import PageSide from '#/components/page/PageSide.vue';
|
||||||
import DocumentCollectionModal from '#/views/ai/documentCollection/DocumentCollectionModal.vue';
|
import DocumentCollectionModal from '#/views/ai/documentCollection/DocumentCollectionModal.vue';
|
||||||
@@ -37,6 +30,17 @@ const collectionTypeLabelMap = {
|
|||||||
function resolveNavTitle(row: Record<string, any>) {
|
function resolveNavTitle(row: Record<string, any>) {
|
||||||
return row?.title || row?.name || '';
|
return row?.title || row?.name || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openKnowledgeDetail(row: { id: string; name?: string; title?: string }) {
|
||||||
|
router.push({
|
||||||
|
path: '/ai/documentCollection/document',
|
||||||
|
query: {
|
||||||
|
id: row.id,
|
||||||
|
pageKey: '/ai/documentCollection',
|
||||||
|
navTitle: resolveNavTitle(row),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
interface FieldDefinition {
|
interface FieldDefinition {
|
||||||
// 字段名称
|
// 字段名称
|
||||||
prop: string;
|
prop: string;
|
||||||
@@ -49,38 +53,29 @@ interface FieldDefinition {
|
|||||||
// 占位符
|
// 占位符
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
// 操作按钮配置
|
const primaryAction: CardPrimaryAction = {
|
||||||
|
icon: Notebook,
|
||||||
|
text: $t('documentCollection.actions.knowledge'),
|
||||||
|
permission: '/api/v1/documentCollection/save',
|
||||||
|
onClick(row) {
|
||||||
|
openKnowledgeDetail(row);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const actions: ActionButton[] = [
|
const actions: ActionButton[] = [
|
||||||
{
|
{
|
||||||
icon: Edit,
|
icon: Edit,
|
||||||
text: $t('button.edit'),
|
text: $t('button.edit'),
|
||||||
className: '',
|
|
||||||
permission: '/api/v1/documentCollection/save',
|
permission: '/api/v1/documentCollection/save',
|
||||||
|
placement: 'inline',
|
||||||
onClick(row) {
|
onClick(row) {
|
||||||
aiKnowledgeModalRef.value.openDialog(row);
|
aiKnowledgeModalRef.value.openDialog(row);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
icon: Notebook,
|
|
||||||
text: $t('documentCollection.actions.knowledge'),
|
|
||||||
className: '',
|
|
||||||
permission: '/api/v1/documentCollection/save',
|
|
||||||
onClick(row) {
|
|
||||||
router.push({
|
|
||||||
path: '/ai/documentCollection/document',
|
|
||||||
query: {
|
|
||||||
id: row.id,
|
|
||||||
pageKey: '/ai/documentCollection',
|
|
||||||
navTitle: resolveNavTitle(row),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
icon: Search,
|
icon: Search,
|
||||||
text: $t('documentCollection.actions.retrieve'),
|
text: $t('documentCollection.actions.retrieve'),
|
||||||
className: '',
|
placement: 'inline',
|
||||||
permission: '',
|
|
||||||
onClick(row) {
|
onClick(row) {
|
||||||
router.push({
|
router.push({
|
||||||
path: '/ai/documentCollection/document',
|
path: '/ai/documentCollection/document',
|
||||||
@@ -96,8 +91,9 @@ const actions: ActionButton[] = [
|
|||||||
{
|
{
|
||||||
text: $t('button.delete'),
|
text: $t('button.delete'),
|
||||||
icon: Delete,
|
icon: Delete,
|
||||||
className: 'item-danger',
|
tone: 'danger',
|
||||||
permission: '/api/v1/documentCollection/remove',
|
permission: '/api/v1/documentCollection/remove',
|
||||||
|
placement: 'inline',
|
||||||
onClick(row) {
|
onClick(row) {
|
||||||
handleDelete(row);
|
handleDelete(row);
|
||||||
},
|
},
|
||||||
@@ -314,10 +310,11 @@ function changeCategory(category: any) {
|
|||||||
<template #default="{ pageList }">
|
<template #default="{ pageList }">
|
||||||
<CardPage
|
<CardPage
|
||||||
:default-icon="defaultIcon"
|
:default-icon="defaultIcon"
|
||||||
title-key="title"
|
title-field="title"
|
||||||
avatar-key="icon"
|
icon-field="icon"
|
||||||
description-key="description"
|
desc-field="description"
|
||||||
:data="pageList"
|
:data="pageList"
|
||||||
|
:primary-action="primaryAction"
|
||||||
:actions="actions"
|
:actions="actions"
|
||||||
tag-field="collectionType"
|
tag-field="collectionType"
|
||||||
:tag-map="collectionTypeLabelMap"
|
:tag-map="collectionTypeLabelMap"
|
||||||
|
|||||||
@@ -0,0 +1,774 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type {llmType} from '#/api';
|
||||||
|
import type {ModelAbilityItem} from '#/views/ai/model/modelUtils/model-ability';
|
||||||
|
import {getDefaultModelAbility} from '#/views/ai/model/modelUtils/model-ability';
|
||||||
|
|
||||||
|
import {computed, onMounted, reactive, ref} from 'vue';
|
||||||
|
|
||||||
|
import {CircleCheck, CircleClose, Delete, Edit, Loading, Select,} from '@element-plus/icons-vue';
|
||||||
|
import {
|
||||||
|
ElButton,
|
||||||
|
ElEmpty,
|
||||||
|
ElIcon,
|
||||||
|
ElInput,
|
||||||
|
ElMessage,
|
||||||
|
ElMessageBox,
|
||||||
|
ElOption,
|
||||||
|
ElSelect,
|
||||||
|
ElTable,
|
||||||
|
ElTableColumn,
|
||||||
|
ElTag,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
|
import {deleteLlm, getModelList, verifyModelConfig,} from '#/api/ai/llm';
|
||||||
|
import {$t} from '#/locales';
|
||||||
|
import ModelProviderBadge from '#/views/ai/model/ModelProviderBadge.vue';
|
||||||
|
import {mapLlmToModelAbility} from '#/views/ai/model/modelUtils/model-ability-utils';
|
||||||
|
|
||||||
|
interface ProviderOption {
|
||||||
|
id: string;
|
||||||
|
providerName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterState {
|
||||||
|
keyword: string;
|
||||||
|
modelType: string;
|
||||||
|
providerId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BatchDeleteResult {
|
||||||
|
failed: Array<{ id: string; message: string }>;
|
||||||
|
successIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
providers: ProviderOption[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'createModel', modelType?: string): void;
|
||||||
|
(e: 'editModel', id: string): void;
|
||||||
|
(e: 'refreshProviderStats'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const isActionLoading = ref(false);
|
||||||
|
const modelRows = ref<llmType[]>([]);
|
||||||
|
const selectedRows = ref<llmType[]>([]);
|
||||||
|
const lastErrorMessage = ref('');
|
||||||
|
type VerifyButtonStatus = 'error' | 'idle' | 'loading' | 'success';
|
||||||
|
const verifyStatusMap = ref<Record<string, VerifyButtonStatus>>({});
|
||||||
|
|
||||||
|
const filterState = reactive<FilterState>({
|
||||||
|
keyword: '',
|
||||||
|
modelType: '',
|
||||||
|
providerId: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const modelTypeOptions = [
|
||||||
|
{
|
||||||
|
label: $t('llmProvider.chatModel'),
|
||||||
|
value: 'chatModel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: $t('llmProvider.embeddingModel'),
|
||||||
|
value: 'embeddingModel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: $t('llmProvider.rerankModel'),
|
||||||
|
value: 'rerankModel',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const modelTypeLabelMap: Record<string, string> = {
|
||||||
|
chatModel: $t('llmProvider.chatModel'),
|
||||||
|
embeddingModel: $t('llmProvider.embeddingModel'),
|
||||||
|
rerankModel: $t('llmProvider.rerankModel'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProviderName = (row: llmType) =>
|
||||||
|
row.modelProvider?.providerName || row.aiLlmProvider?.providerName || '-';
|
||||||
|
|
||||||
|
const getProviderType = (row: llmType) =>
|
||||||
|
row.modelProvider?.providerType || row.aiLlmProvider?.providerType || '';
|
||||||
|
|
||||||
|
const getProviderIcon = (row: llmType) =>
|
||||||
|
row.modelProvider?.icon || row.aiLlmProvider?.icon || '';
|
||||||
|
|
||||||
|
const getModelName = (row: llmType) =>
|
||||||
|
row.llmModel || (row as any).modelName || '-';
|
||||||
|
|
||||||
|
const getModelTypeLabel = (row: llmType) =>
|
||||||
|
modelTypeLabelMap[row.modelType] || row.modelType || '-';
|
||||||
|
|
||||||
|
const canVerify = (row: llmType) => row.modelType !== 'rerankModel';
|
||||||
|
const getModelId = (row: llmType) =>
|
||||||
|
String((row as any).id || (row as any).llmId || (row as any).modelId || '');
|
||||||
|
const getVerifyStatus = (row: llmType): VerifyButtonStatus =>
|
||||||
|
verifyStatusMap.value[getModelId(row)] || 'idle';
|
||||||
|
const setVerifyStatus = (id: string, status: VerifyButtonStatus) => {
|
||||||
|
if (!id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
verifyStatusMap.value[id] = status;
|
||||||
|
};
|
||||||
|
const isVerifying = (row: llmType) => getVerifyStatus(row) === 'loading';
|
||||||
|
const getVerifyButtonText = (row: llmType) => {
|
||||||
|
const status = getVerifyStatus(row);
|
||||||
|
if (status === 'loading') {
|
||||||
|
return '验证中';
|
||||||
|
}
|
||||||
|
if (status === 'success') {
|
||||||
|
return '验证成功';
|
||||||
|
}
|
||||||
|
if (status === 'error') {
|
||||||
|
return '验证失败';
|
||||||
|
}
|
||||||
|
return '验证配置';
|
||||||
|
};
|
||||||
|
const getVerifyButtonIcon = (row: llmType) => {
|
||||||
|
const status = getVerifyStatus(row);
|
||||||
|
if (status === 'loading') {
|
||||||
|
return Loading;
|
||||||
|
}
|
||||||
|
if (status === 'success') {
|
||||||
|
return CircleCheck;
|
||||||
|
}
|
||||||
|
if (status === 'error') {
|
||||||
|
return CircleClose;
|
||||||
|
}
|
||||||
|
return Select;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAbilityTags = (row: llmType): ModelAbilityItem[] =>
|
||||||
|
mapLlmToModelAbility(row, getDefaultModelAbility()).filter(
|
||||||
|
(tag) => tag.selected,
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalCount = computed(() => modelRows.value.length);
|
||||||
|
|
||||||
|
const filteredRows = computed(() => {
|
||||||
|
const keyword = filterState.keyword.trim().toLowerCase();
|
||||||
|
|
||||||
|
return modelRows.value.filter((item) => {
|
||||||
|
if (
|
||||||
|
filterState.providerId &&
|
||||||
|
String((item as any).providerId) !== filterState.providerId
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterState.modelType && item.modelType !== filterState.modelType) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!keyword) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchTargets = [
|
||||||
|
item.title,
|
||||||
|
getModelName(item),
|
||||||
|
item.groupName,
|
||||||
|
getProviderName(item),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((text) => String(text).toLowerCase());
|
||||||
|
|
||||||
|
return searchTargets.some((text) => text.includes(keyword));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectableRowCount = computed(() => selectedRows.value.length);
|
||||||
|
|
||||||
|
const loadModels = async () => {
|
||||||
|
isLoading.value = true;
|
||||||
|
lastErrorMessage.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await getModelList();
|
||||||
|
|
||||||
|
if (res.errorCode === 0) {
|
||||||
|
modelRows.value = res.data || [];
|
||||||
|
verifyStatusMap.value = {};
|
||||||
|
} else {
|
||||||
|
modelRows.value = [];
|
||||||
|
verifyStatusMap.value = {};
|
||||||
|
lastErrorMessage.value =
|
||||||
|
res.message || $t('ui.actionMessage.operationFailed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
modelRows.value = [];
|
||||||
|
verifyStatusMap.value = {};
|
||||||
|
lastErrorMessage.value =
|
||||||
|
(error as Error)?.message || $t('ui.actionMessage.operationFailed');
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reloadAndNotify = async () => {
|
||||||
|
await loadModels();
|
||||||
|
emit('refreshProviderStats');
|
||||||
|
selectedRows.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectionChange = (rows: llmType[]) => {
|
||||||
|
selectedRows.value = rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (row: llmType) => {
|
||||||
|
const modelId = getModelId(row);
|
||||||
|
if (!modelId) {
|
||||||
|
ElMessage.warning('当前模型缺少ID,无法编辑');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('editModel', modelId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (row: llmType) => {
|
||||||
|
const modelId = getModelId(row);
|
||||||
|
if (!modelId) {
|
||||||
|
ElMessage.warning('当前模型缺少ID,无法删除');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确认删除模型「${row.title}」吗?该操作不可恢复。`,
|
||||||
|
$t('message.noticeTitle'),
|
||||||
|
{
|
||||||
|
cancelButtonText: $t('message.cancel'),
|
||||||
|
confirmButtonText: $t('message.ok'),
|
||||||
|
type: 'warning',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isActionLoading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await deleteLlm({ id: modelId });
|
||||||
|
|
||||||
|
if (res.errorCode === 0) {
|
||||||
|
ElMessage.success(res.message || '模型已删除');
|
||||||
|
await reloadAndNotify();
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.message || $t('ui.actionMessage.operationFailed'));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isActionLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVerify = async (row: llmType) => {
|
||||||
|
const modelId = getModelId(row);
|
||||||
|
|
||||||
|
if (!canVerify(row) || !modelId || isVerifying(row)) {
|
||||||
|
if (!modelId) {
|
||||||
|
ElMessage.warning('当前模型缺少ID,无法验证配置');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVerifyStatus(modelId, 'loading');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await verifyModelConfig(modelId);
|
||||||
|
|
||||||
|
if (res.errorCode === 0) {
|
||||||
|
setVerifyStatus(modelId, 'success');
|
||||||
|
if (row.modelType === 'embeddingModel' && res?.data?.dimension) {
|
||||||
|
ElMessage.success(`验证成功,向量维度:${res.data.dimension}`);
|
||||||
|
} else {
|
||||||
|
ElMessage.success('验证成功');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setVerifyStatus(modelId, 'error');
|
||||||
|
if (!res.message) {
|
||||||
|
ElMessage.error($t('ui.actionMessage.operationFailed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setVerifyStatus(modelId, 'error');
|
||||||
|
// error toast is already handled by global response interceptors
|
||||||
|
} finally {
|
||||||
|
// keep final status to show explicit success/failure state
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runBatchDelete = async (
|
||||||
|
ids: string[],
|
||||||
|
concurrency = 5,
|
||||||
|
): Promise<BatchDeleteResult> => {
|
||||||
|
const queue = [...ids];
|
||||||
|
const successIds: string[] = [];
|
||||||
|
const failed: Array<{ id: string; message: string }> = [];
|
||||||
|
|
||||||
|
const worker = async () => {
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const id = queue.shift();
|
||||||
|
if (!id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await deleteLlm({ id });
|
||||||
|
if (res.errorCode === 0) {
|
||||||
|
successIds.push(id);
|
||||||
|
} else {
|
||||||
|
failed.push({ id, message: res.message || '删除失败' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
failed.push({ id, message: (error as Error)?.message || '网络错误' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const workerCount = Math.max(1, Math.min(concurrency, ids.length || 1));
|
||||||
|
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
||||||
|
|
||||||
|
return {
|
||||||
|
failed,
|
||||||
|
successIds,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBatchDelete = async () => {
|
||||||
|
const ids = selectedRows.value
|
||||||
|
.map((item) => getModelId(item))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (ids.length === 0) {
|
||||||
|
ElMessage.warning('请先选择要删除的模型');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确认批量删除 ${ids.length} 个模型吗?该操作不可恢复。`,
|
||||||
|
$t('message.noticeTitle'),
|
||||||
|
{
|
||||||
|
cancelButtonText: $t('message.cancel'),
|
||||||
|
confirmButtonText: $t('message.ok'),
|
||||||
|
type: 'warning',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isActionLoading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await runBatchDelete(ids, 5);
|
||||||
|
const successCount = result.successIds.length;
|
||||||
|
const failCount = result.failed.length;
|
||||||
|
|
||||||
|
if (failCount === 0) {
|
||||||
|
ElMessage.success(`批量删除完成,共 ${successCount} 个模型`);
|
||||||
|
} else {
|
||||||
|
ElMessage.warning(
|
||||||
|
`批量删除完成,成功 ${successCount} 个,失败 ${failCount} 个`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await reloadAndNotify();
|
||||||
|
} finally {
|
||||||
|
isActionLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
filterState.keyword = '';
|
||||||
|
filterState.providerId = '';
|
||||||
|
filterState.modelType = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(loadModels);
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
async reloadData() {
|
||||||
|
await loadModels();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="active-workspace">
|
||||||
|
<header class="active-workspace__header">
|
||||||
|
<div class="active-workspace__summary">
|
||||||
|
<h3>已配置模型</h3>
|
||||||
|
<p>共 {{ totalCount }} 个模型</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="active-workspace__filters">
|
||||||
|
<ElInput
|
||||||
|
v-model.trim="filterState.keyword"
|
||||||
|
clearable
|
||||||
|
placeholder="搜索模型名、模型ID、服务商、分组"
|
||||||
|
/>
|
||||||
|
<ElSelect
|
||||||
|
v-model="filterState.providerId"
|
||||||
|
clearable
|
||||||
|
placeholder="全部服务商"
|
||||||
|
>
|
||||||
|
<ElOption
|
||||||
|
v-for="provider in props.providers"
|
||||||
|
:key="provider.id"
|
||||||
|
:label="provider.providerName"
|
||||||
|
:value="provider.id"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
<ElSelect
|
||||||
|
v-model="filterState.modelType"
|
||||||
|
clearable
|
||||||
|
placeholder="全部模型类型"
|
||||||
|
>
|
||||||
|
<ElOption
|
||||||
|
v-for="type in modelTypeOptions"
|
||||||
|
:key="type.value"
|
||||||
|
:label="type.label"
|
||||||
|
:value="type.value"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
<ElButton @click="resetFilters">重置</ElButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectableRowCount > 0" class="active-workspace__batch-bar">
|
||||||
|
<span>已选 {{ selectableRowCount }} 项</span>
|
||||||
|
<div class="active-workspace__batch-actions">
|
||||||
|
<ElButton
|
||||||
|
class="is-danger"
|
||||||
|
:disabled="isActionLoading"
|
||||||
|
@click="handleBatchDelete"
|
||||||
|
>
|
||||||
|
批量删除
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isLoading" class="active-workspace__state">
|
||||||
|
正在加载模型数据...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="lastErrorMessage" class="active-workspace__state is-error">
|
||||||
|
{{ lastErrorMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="modelRows.length === 0" class="active-workspace__empty">
|
||||||
|
<ElEmpty description="还没有模型,先添加一个模型开始使用。">
|
||||||
|
<ElButton type="primary" @click="emit('createModel', 'chatModel')">
|
||||||
|
添加模型
|
||||||
|
</ElButton>
|
||||||
|
</ElEmpty>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="filteredRows.length === 0" class="active-workspace__empty">
|
||||||
|
<ElEmpty description="没有符合筛选条件的模型,试试调整筛选项。" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElTable
|
||||||
|
v-else
|
||||||
|
row-key="id"
|
||||||
|
:data="filteredRows"
|
||||||
|
class="active-workspace__table"
|
||||||
|
@selection-change="handleSelectionChange"
|
||||||
|
>
|
||||||
|
<ElTableColumn type="selection" width="48" />
|
||||||
|
|
||||||
|
<ElTableColumn label="模型名称" min-width="220">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="active-workspace__name-with-logo">
|
||||||
|
<ModelProviderBadge
|
||||||
|
:icon="getProviderIcon(row)"
|
||||||
|
:provider-name="getProviderName(row)"
|
||||||
|
:provider-type="getProviderType(row)"
|
||||||
|
:size="30"
|
||||||
|
/>
|
||||||
|
<div class="active-workspace__name-cell">
|
||||||
|
<strong>{{ row.title }}</strong>
|
||||||
|
<span>{{ getModelName(row) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
|
||||||
|
<ElTableColumn label="服务商" min-width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ getProviderName(row) }}
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
|
||||||
|
<ElTableColumn label="类型" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ getModelTypeLabel(row) }}
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
|
||||||
|
<ElTableColumn label="分组" min-width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.groupName || '-' }}
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
|
||||||
|
<ElTableColumn label="能力" min-width="220">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="active-workspace__ability">
|
||||||
|
<ElTag
|
||||||
|
v-for="tag in getAbilityTags(row)"
|
||||||
|
:key="tag.value"
|
||||||
|
effect="plain"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ tag.label }}
|
||||||
|
</ElTag>
|
||||||
|
<span v-if="getAbilityTags(row).length === 0">-</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
|
||||||
|
<ElTableColumn label="操作" min-width="290" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="active-workspace__actions">
|
||||||
|
<ElButton
|
||||||
|
v-if="canVerify(row)"
|
||||||
|
text
|
||||||
|
size="small"
|
||||||
|
class="active-workspace__verify-btn"
|
||||||
|
:class="`is-${getVerifyStatus(row)}`"
|
||||||
|
:disabled="
|
||||||
|
isVerifying(row) || isActionLoading || !getModelId(row)
|
||||||
|
"
|
||||||
|
@click="handleVerify(row)"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<ElIcon
|
||||||
|
class="active-workspace__verify-icon"
|
||||||
|
:class="`is-${getVerifyStatus(row)}`"
|
||||||
|
>
|
||||||
|
<component :is="getVerifyButtonIcon(row)" />
|
||||||
|
</ElIcon>
|
||||||
|
</template>
|
||||||
|
{{ getVerifyButtonText(row) }}
|
||||||
|
</ElButton>
|
||||||
|
<ElButton text size="small" :icon="Edit" @click="handleEdit(row)">
|
||||||
|
编辑
|
||||||
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
text
|
||||||
|
size="small"
|
||||||
|
class="is-danger"
|
||||||
|
:icon="Delete"
|
||||||
|
@click="handleDelete(row)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
</ElTable>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.active-workspace {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-workspace__header {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid hsl(var(--divider-faint) / 58%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-workspace__summary h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--text-strong));
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-workspace__summary p,
|
||||||
|
.active-workspace__state {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-workspace__filters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 1.6fr) repeat(2, minmax(0, 1fr)) auto;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-workspace__batch-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: hsl(var(--surface-contrast-soft) / 68%);
|
||||||
|
border: 1px solid hsl(var(--divider-faint) / 58%);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-workspace__batch-bar span {
|
||||||
|
font-size: 13px;
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-workspace__batch-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-workspace__batch-actions .is-danger,
|
||||||
|
.active-workspace__actions .is-danger {
|
||||||
|
color: hsl(var(--destructive));
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-workspace__state {
|
||||||
|
padding: 14px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-workspace__state.is-error {
|
||||||
|
color: hsl(var(--destructive));
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-workspace__empty {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-workspace__empty :deep(.el-empty) {
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-workspace__table {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-workspace__name-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-workspace__name-with-logo {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-workspace__name-cell strong {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--text-strong));
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-workspace__name-cell span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-workspace__ability {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-workspace__actions {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-workspace__verify-btn.is-idle {
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-workspace__verify-btn.is-loading {
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-workspace__verify-btn.is-success {
|
||||||
|
color: hsl(var(--success));
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-workspace__verify-btn.is-error {
|
||||||
|
color: hsl(var(--destructive));
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-workspace__verify-icon {
|
||||||
|
transition:
|
||||||
|
color 0.24s ease,
|
||||||
|
transform 0.24s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-workspace__verify-icon.is-loading {
|
||||||
|
animation: active-workspace-verify-spin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-workspace__verify-icon.is-success,
|
||||||
|
.active-workspace__verify-icon.is-error {
|
||||||
|
animation: active-workspace-verify-pop 0.32s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes active-workspace-verify-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes active-workspace-verify-pop {
|
||||||
|
0% {
|
||||||
|
opacity: 0.72;
|
||||||
|
transform: scale(0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.active-workspace__filters {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.active-workspace__header {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-workspace__filters {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-workspace__batch-bar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,31 +1,31 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ModelAbilityItem } from '#/views/ai/model/modelUtils/model-ability';
|
import type {ModelAbilityItem} from '#/views/ai/model/modelUtils/model-ability';
|
||||||
|
|
||||||
import { reactive, ref, watch } from 'vue';
|
|
||||||
|
|
||||||
import { EasyFlowFormModal } from '@easyflow/common-ui';
|
|
||||||
|
|
||||||
import { ElForm, ElFormItem, ElInput, ElMessage, ElTag } from 'element-plus';
|
|
||||||
|
|
||||||
import { api } from '#/api/request';
|
|
||||||
import { $t } from '#/locales';
|
|
||||||
import {
|
import {
|
||||||
getDefaultModelAbility,
|
getDefaultModelAbility,
|
||||||
handleTagClick as handleTagClickUtil,
|
|
||||||
syncTagSelectedStatus as syncTagSelectedStatusUtil,
|
syncTagSelectedStatus as syncTagSelectedStatusUtil,
|
||||||
} from '#/views/ai/model/modelUtils/model-ability';
|
} from '#/views/ai/model/modelUtils/model-ability';
|
||||||
|
|
||||||
|
import {computed, reactive, ref, watch} from 'vue';
|
||||||
|
|
||||||
|
import {EasyFlowFormModal} from '@easyflow/common-ui';
|
||||||
|
|
||||||
|
import {ArrowDown, ArrowUp} from '@element-plus/icons-vue';
|
||||||
|
import {ElForm, ElFormItem, ElIcon, ElInput, ElMessage} from 'element-plus';
|
||||||
|
|
||||||
|
import {api} from '#/api/request';
|
||||||
|
import {$t} from '#/locales';
|
||||||
import {
|
import {
|
||||||
generateFeaturesFromModelAbility,
|
generateFeaturesFromModelAbility,
|
||||||
resetModelAbility,
|
resetModelAbility,
|
||||||
} from '#/views/ai/model/modelUtils/model-ability-utils';
|
} from '#/views/ai/model/modelUtils/model-ability-utils';
|
||||||
|
|
||||||
interface FormData {
|
interface FormData {
|
||||||
|
id?: string;
|
||||||
modelType: string;
|
modelType: string;
|
||||||
title: string;
|
title: string;
|
||||||
modelName: string;
|
modelName: string;
|
||||||
groupName: string;
|
groupName: string;
|
||||||
providerId: string;
|
providerId: string;
|
||||||
provider: string;
|
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
requestPath: string;
|
requestPath: string;
|
||||||
@@ -37,12 +37,6 @@ interface FormData {
|
|||||||
supportVideo: boolean;
|
supportVideo: boolean;
|
||||||
supportImageB64Only: boolean;
|
supportImageB64Only: boolean;
|
||||||
supportToolMessage: boolean;
|
supportToolMessage: boolean;
|
||||||
options: {
|
|
||||||
chatPath: string;
|
|
||||||
embedPath: string;
|
|
||||||
llmEndpoint: string;
|
|
||||||
rerankPath: string;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -55,7 +49,6 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['reload']);
|
const emit = defineEmits(['reload']);
|
||||||
const selectedProviderId = ref<string>(props.providerId ?? '');
|
const selectedProviderId = ref<string>(props.providerId ?? '');
|
||||||
|
|
||||||
// 监听 providerId 的变化
|
|
||||||
watch(
|
watch(
|
||||||
() => props.providerId,
|
() => props.providerId,
|
||||||
(newVal) => {
|
(newVal) => {
|
||||||
@@ -69,15 +62,15 @@ watch(
|
|||||||
const formDataRef = ref();
|
const formDataRef = ref();
|
||||||
const isAdd = ref(true);
|
const isAdd = ref(true);
|
||||||
const dialogVisible = ref(false);
|
const dialogVisible = ref(false);
|
||||||
|
const btnLoading = ref(false);
|
||||||
|
const showAdvanced = ref(false);
|
||||||
|
|
||||||
// 表单数据
|
|
||||||
const formData = reactive<FormData>({
|
const formData = reactive<FormData>({
|
||||||
modelType: '',
|
modelType: '',
|
||||||
title: '',
|
title: '',
|
||||||
modelName: '',
|
modelName: '',
|
||||||
groupName: '',
|
groupName: '',
|
||||||
providerId: '',
|
providerId: '',
|
||||||
provider: '',
|
|
||||||
apiKey: '',
|
apiKey: '',
|
||||||
endpoint: '',
|
endpoint: '',
|
||||||
requestPath: '',
|
requestPath: '',
|
||||||
@@ -88,87 +81,104 @@ const formData = reactive<FormData>({
|
|||||||
supportFree: false,
|
supportFree: false,
|
||||||
supportVideo: false,
|
supportVideo: false,
|
||||||
supportImageB64Only: false,
|
supportImageB64Only: false,
|
||||||
supportToolMessage: false,
|
supportToolMessage: true,
|
||||||
options: {
|
|
||||||
llmEndpoint: '',
|
|
||||||
chatPath: '',
|
|
||||||
embedPath: '',
|
|
||||||
rerankPath: '',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 使用抽取的函数获取模型能力配置
|
|
||||||
const modelAbility = ref<ModelAbilityItem[]>(getDefaultModelAbility());
|
const modelAbility = ref<ModelAbilityItem[]>(getDefaultModelAbility());
|
||||||
|
type SelectableModelType = '' | 'embeddingModel' | 'rerankModel';
|
||||||
|
|
||||||
|
const selectedModelType = ref<SelectableModelType>('');
|
||||||
|
const modelTypeAbilityOptions = [
|
||||||
|
{
|
||||||
|
label: $t('llmProvider.embeddingModel'),
|
||||||
|
value: 'embeddingModel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: $t('llmProvider.rerankModel'),
|
||||||
|
value: 'rerankModel',
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
const hasSpecialModelType = computed(() => Boolean(selectedModelType.value));
|
||||||
|
|
||||||
/**
|
|
||||||
* 同步标签选中状态与formData中的布尔字段
|
|
||||||
*/
|
|
||||||
const syncTagSelectedStatus = () => {
|
const syncTagSelectedStatus = () => {
|
||||||
syncTagSelectedStatusUtil(modelAbility.value, formData);
|
syncTagSelectedStatusUtil(modelAbility.value, formData);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
const resetAbilitySelection = () => {
|
||||||
* 处理标签点击事件
|
resetModelAbility(modelAbility.value);
|
||||||
*/
|
syncTagSelectedStatus();
|
||||||
const handleTagClick = (item: ModelAbilityItem) => {
|
|
||||||
// handleTagClickUtil(modelAbility.value, item, formData);
|
|
||||||
handleTagClickUtil(item, formData);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 打开新增弹窗
|
const handleTagClick = (item: ModelAbilityItem) => {
|
||||||
defineExpose({
|
if (hasSpecialModelType.value) {
|
||||||
openAddDialog(modelType: string) {
|
return;
|
||||||
isAdd.value = true;
|
}
|
||||||
if (formDataRef.value) {
|
item.selected = !item.selected;
|
||||||
formDataRef.value.resetFields();
|
formData[item.field] = item.selected;
|
||||||
}
|
};
|
||||||
|
|
||||||
// 重置表单数据
|
const handleModelTypeChipClick = (
|
||||||
Object.assign(formData, {
|
modelType: Exclude<SelectableModelType, ''>,
|
||||||
id: '',
|
) => {
|
||||||
modelType,
|
const nextType = selectedModelType.value === modelType ? '' : modelType;
|
||||||
title: '',
|
selectedModelType.value = nextType;
|
||||||
modelName: '',
|
if (nextType) {
|
||||||
groupName: '',
|
resetAbilitySelection();
|
||||||
provider: '',
|
}
|
||||||
endPoint: '',
|
};
|
||||||
providerId: '',
|
|
||||||
supportThinking: false,
|
const isAbilityChipDisabled = () => hasSpecialModelType.value;
|
||||||
supportTool: false,
|
|
||||||
supportAudio: false,
|
const resolveModelType = (): FormData['modelType'] => {
|
||||||
supportVideo: false,
|
return selectedModelType.value || 'chatModel';
|
||||||
supportImage: false,
|
};
|
||||||
supportImageB64Only: false,
|
|
||||||
supportFree: false,
|
const resetFormData = () => {
|
||||||
supportToolMessage: true,
|
Object.assign(formData, {
|
||||||
options: {
|
id: '',
|
||||||
llmEndpoint: '',
|
modelType: '',
|
||||||
chatPath: '',
|
title: '',
|
||||||
embedPath: '',
|
modelName: '',
|
||||||
rerankPath: '',
|
groupName: '',
|
||||||
},
|
providerId: '',
|
||||||
});
|
apiKey: '',
|
||||||
showMoreFields.value = false;
|
endpoint: '',
|
||||||
// 重置标签状态
|
requestPath: '',
|
||||||
resetModelAbility(modelAbility.value);
|
supportThinking: false,
|
||||||
syncTagSelectedStatus();
|
supportTool: false,
|
||||||
|
supportAudio: false,
|
||||||
|
supportVideo: false,
|
||||||
|
supportImage: false,
|
||||||
|
supportImageB64Only: false,
|
||||||
|
supportFree: false,
|
||||||
|
supportToolMessage: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
openAddDialog() {
|
||||||
|
isAdd.value = true;
|
||||||
|
formDataRef.value?.resetFields();
|
||||||
|
resetFormData();
|
||||||
|
showAdvanced.value = false;
|
||||||
|
selectedModelType.value = '';
|
||||||
|
resetAbilitySelection();
|
||||||
dialogVisible.value = true;
|
dialogVisible.value = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
openEditDialog(item: any) {
|
openEditDialog(item: any) {
|
||||||
dialogVisible.value = true;
|
dialogVisible.value = true;
|
||||||
isAdd.value = false;
|
isAdd.value = false;
|
||||||
|
resetFormData();
|
||||||
// 填充表单数据
|
|
||||||
Object.assign(formData, {
|
Object.assign(formData, {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
modelType: item.modelType || '',
|
modelType: item.modelType || '',
|
||||||
title: item.title || '',
|
title: item.title || '',
|
||||||
modelName: item.modelName || '',
|
modelName: item.modelName || '',
|
||||||
groupName: item.groupName || '',
|
groupName: item.groupName || '',
|
||||||
provider: item.provider || '',
|
|
||||||
endpoint: item.endpoint || '',
|
endpoint: item.endpoint || '',
|
||||||
requestPath: item.requestPath || '',
|
requestPath: item.requestPath || '',
|
||||||
|
apiKey: item.apiKey || '',
|
||||||
supportThinking: item.supportThinking || false,
|
supportThinking: item.supportThinking || false,
|
||||||
supportAudio: item.supportAudio || false,
|
supportAudio: item.supportAudio || false,
|
||||||
supportImage: item.supportImage || false,
|
supportImage: item.supportImage || false,
|
||||||
@@ -176,17 +186,21 @@ defineExpose({
|
|||||||
supportVideo: item.supportVideo || false,
|
supportVideo: item.supportVideo || false,
|
||||||
supportTool: item.supportTool || false,
|
supportTool: item.supportTool || false,
|
||||||
supportFree: item.supportFree || false,
|
supportFree: item.supportFree || false,
|
||||||
supportToolMessage: item.supportToolMessage || false,
|
supportToolMessage:
|
||||||
options: {
|
item.supportToolMessage === undefined ? true : item.supportToolMessage,
|
||||||
llmEndpoint: item.options?.llmEndpoint || '',
|
|
||||||
chatPath: item.options?.chatPath || '',
|
|
||||||
embedPath: item.options?.embedPath || '',
|
|
||||||
rerankPath: item.options?.rerankPath || '',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
showMoreFields.value = false;
|
selectedModelType.value =
|
||||||
// 同步标签状态
|
item.modelType === 'embeddingModel' || item.modelType === 'rerankModel'
|
||||||
syncTagSelectedStatus();
|
? item.modelType
|
||||||
|
: '';
|
||||||
|
showAdvanced.value = Boolean(
|
||||||
|
formData.apiKey || formData.endpoint || formData.requestPath,
|
||||||
|
);
|
||||||
|
if (selectedModelType.value) {
|
||||||
|
resetAbilitySelection();
|
||||||
|
} else {
|
||||||
|
syncTagSelectedStatus();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -195,76 +209,53 @@ const closeDialog = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const rules = {
|
const rules = {
|
||||||
title: [
|
title: [{ required: true, message: $t('message.required'), trigger: 'blur' }],
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
message: $t('message.required'),
|
|
||||||
trigger: 'blur',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
modelName: [
|
modelName: [
|
||||||
{
|
{ required: true, message: $t('message.required'), trigger: 'blur' },
|
||||||
required: true,
|
|
||||||
message: $t('message.required'),
|
|
||||||
trigger: 'blur',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
groupName: [
|
groupName: [
|
||||||
{
|
{ required: true, message: $t('message.required'), trigger: 'blur' },
|
||||||
required: true,
|
|
||||||
message: $t('message.required'),
|
|
||||||
trigger: 'blur',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
provider: [
|
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
message: $t('message.required'),
|
|
||||||
trigger: 'blur',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const btnLoading = ref(false);
|
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
btnLoading.value = true;
|
btnLoading.value = true;
|
||||||
|
const modelType = resolveModelType();
|
||||||
// 使用工具函数从模型能力生成features
|
|
||||||
const features = generateFeaturesFromModelAbility(modelAbility.value);
|
const features = generateFeaturesFromModelAbility(modelAbility.value);
|
||||||
|
|
||||||
|
if (modelType !== 'chatModel') {
|
||||||
|
for (const key of Object.keys(features) as Array<keyof typeof features>) {
|
||||||
|
features[key] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await formDataRef.value.validate();
|
await formDataRef.value.validate();
|
||||||
const submitData = { ...formData, ...features };
|
const submitData = {
|
||||||
|
...formData,
|
||||||
|
...features,
|
||||||
|
modelType,
|
||||||
|
providerId: isAdd.value ? selectedProviderId.value : formData.providerId,
|
||||||
|
};
|
||||||
|
|
||||||
if (isAdd.value) {
|
const url = isAdd.value ? '/api/v1/model/save' : '/api/v1/model/update';
|
||||||
submitData.providerId = selectedProviderId.value;
|
const res = await api.post(url, submitData);
|
||||||
const res = await api.post('/api/v1/model/save', submitData);
|
|
||||||
if (res.errorCode === 0) {
|
if (res.errorCode === 0) {
|
||||||
ElMessage.success(res.message);
|
ElMessage.success(res.message);
|
||||||
emit('reload');
|
emit('reload');
|
||||||
closeDialog();
|
closeDialog();
|
||||||
} else {
|
|
||||||
ElMessage.error(res.message || $t('ui.actionMessage.operationFailed'));
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const res = await api.post('/api/v1/model/update', submitData);
|
ElMessage.error(res.message || $t('ui.actionMessage.operationFailed'));
|
||||||
if (res.errorCode === 0) {
|
|
||||||
ElMessage.success(res.message);
|
|
||||||
emit('reload');
|
|
||||||
closeDialog();
|
|
||||||
} else {
|
|
||||||
ElMessage.error(res.message || $t('ui.actionMessage.operationFailed'));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Save model error:', error);
|
if (!(error as any)?.fields) {
|
||||||
ElMessage.error($t('ui.actionMessage.operationFailed'));
|
ElMessage.error($t('ui.actionMessage.operationFailed'));
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
btnLoading.value = false;
|
btnLoading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const showMoreFields = ref(false);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -272,91 +263,240 @@ const showMoreFields = ref(false);
|
|||||||
v-model:open="dialogVisible"
|
v-model:open="dialogVisible"
|
||||||
:centered="true"
|
:centered="true"
|
||||||
:closable="!btnLoading"
|
:closable="!btnLoading"
|
||||||
:title="isAdd ? $t('button.add') : $t('button.edit')"
|
:title="isAdd ? '添加模型' : '编辑模型'"
|
||||||
:before-close="closeDialog"
|
:before-close="closeDialog"
|
||||||
width="482"
|
width="640"
|
||||||
:confirm-loading="btnLoading"
|
:confirm-loading="btnLoading"
|
||||||
:confirm-text="$t('button.save')"
|
:confirm-text="$t('button.save')"
|
||||||
:submitting="btnLoading"
|
:submitting="btnLoading"
|
||||||
@confirm="save"
|
@confirm="save"
|
||||||
>
|
>
|
||||||
<ElForm
|
<div class="model-modal">
|
||||||
ref="formDataRef"
|
<ElForm
|
||||||
:model="formData"
|
ref="formDataRef"
|
||||||
status-icon
|
:model="formData"
|
||||||
:rules="rules"
|
status-icon
|
||||||
label-position="top"
|
:rules="rules"
|
||||||
class="easyflow-modal-form easyflow-modal-form--compact"
|
label-position="top"
|
||||||
>
|
class="model-modal__form"
|
||||||
<ElFormItem prop="title" :label="$t('llm.title')">
|
>
|
||||||
<ElInput v-model.trim="formData.title" />
|
<div class="model-modal__section">
|
||||||
</ElFormItem>
|
<div class="model-modal__section-head">
|
||||||
<ElFormItem prop="modelName" :label="$t('llm.llmModel')">
|
<h4>基础信息</h4>
|
||||||
<ElInput v-model.trim="formData.modelName" />
|
<p>这些字段决定模型在列表里的展示与组织方式。</p>
|
||||||
</ElFormItem>
|
</div>
|
||||||
<ElFormItem prop="groupName" :label="$t('llm.groupName')">
|
|
||||||
<ElInput v-model.trim="formData.groupName" />
|
<ElFormItem prop="title" :label="$t('llm.title')">
|
||||||
</ElFormItem>
|
<ElInput
|
||||||
<ElFormItem prop="ability" :label="$t('llm.ability')">
|
v-model.trim="formData.title"
|
||||||
<div class="model-ability">
|
placeholder="例如:生产主模型"
|
||||||
<ElTag
|
/>
|
||||||
class="model-ability-tag"
|
</ElFormItem>
|
||||||
v-for="item in modelAbility"
|
<ElFormItem prop="modelName" :label="$t('llm.llmModel')">
|
||||||
:key="item.value"
|
<ElInput
|
||||||
:type="item.selected ? item.activeType : item.defaultType"
|
v-model.trim="formData.modelName"
|
||||||
@click="handleTagClick(item)"
|
placeholder="例如:gpt-4.1 / glm-4.5 / qwen3:8b"
|
||||||
:class="{ 'tag-selected': item.selected }"
|
/>
|
||||||
>
|
</ElFormItem>
|
||||||
{{ item.label }}
|
<ElFormItem prop="groupName" :label="$t('llm.groupName')">
|
||||||
</ElTag>
|
<ElInput
|
||||||
|
v-model.trim="formData.groupName"
|
||||||
|
placeholder="例如:默认组"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
</div>
|
</div>
|
||||||
</ElFormItem>
|
|
||||||
<ElFormItem label=" " v-if="!showMoreFields">
|
<div class="model-modal__section">
|
||||||
<ElButton @click="showMoreFields = !showMoreFields" type="primary">
|
<div class="model-modal__section-head">
|
||||||
{{ showMoreFields ? $t('button.hide') : $t('button.more') }}
|
<h4>{{ $t('llm.ability') }}</h4>
|
||||||
</ElButton>
|
<p>可选嵌入或重排模型类型;选择后其余能力会自动锁定。</p>
|
||||||
</ElFormItem>
|
</div>
|
||||||
<ElFormItem
|
<div class="model-modal__ability">
|
||||||
prop="apiKey"
|
<button
|
||||||
:label="$t('llmProvider.apiKey')"
|
v-for="item in modelTypeAbilityOptions"
|
||||||
v-show="showMoreFields"
|
:key="item.value"
|
||||||
>
|
type="button"
|
||||||
<ElInput v-model.trim="formData.apiKey" />
|
class="model-modal__ability-chip"
|
||||||
</ElFormItem>
|
:class="{ 'is-active': selectedModelType === item.value }"
|
||||||
<ElFormItem
|
@click="handleModelTypeChipClick(item.value)"
|
||||||
prop="endpoint"
|
>
|
||||||
:label="$t('llmProvider.endpoint')"
|
{{ item.label }}
|
||||||
v-show="showMoreFields"
|
</button>
|
||||||
>
|
</div>
|
||||||
<ElInput v-model.trim="formData.endpoint" />
|
<div class="model-modal__ability">
|
||||||
</ElFormItem>
|
<button
|
||||||
<ElFormItem
|
v-for="item in modelAbility"
|
||||||
prop="requestPath"
|
:key="item.value"
|
||||||
:label="$t('llm.requestPath')"
|
type="button"
|
||||||
v-show="showMoreFields"
|
class="model-modal__ability-chip"
|
||||||
>
|
:class="{
|
||||||
<ElInput v-model.trim="formData.requestPath" />
|
'is-active': item.selected,
|
||||||
</ElFormItem>
|
'is-disabled': isAbilityChipDisabled(),
|
||||||
</ElForm>
|
}"
|
||||||
|
:disabled="isAbilityChipDisabled()"
|
||||||
|
@click="handleTagClick(item)"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="model-modal__section">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="model-modal__advanced-toggle"
|
||||||
|
@click="showAdvanced = !showAdvanced"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h4>高级设置</h4>
|
||||||
|
<p>仅在需要覆写服务商默认配置时填写。</p>
|
||||||
|
</div>
|
||||||
|
<ElIcon>
|
||||||
|
<ArrowUp v-if="showAdvanced" />
|
||||||
|
<ArrowDown v-else />
|
||||||
|
</ElIcon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="showAdvanced" class="model-modal__advanced-grid">
|
||||||
|
<ElFormItem prop="apiKey" :label="$t('llmProvider.apiKey')">
|
||||||
|
<ElInput
|
||||||
|
v-model.trim="formData.apiKey"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
placeholder="可选,单独覆写模型密钥"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem prop="endpoint" :label="$t('llmProvider.endpoint')">
|
||||||
|
<ElInput
|
||||||
|
v-model.trim="formData.endpoint"
|
||||||
|
placeholder="可选,单独覆写 endpoint"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem prop="requestPath" :label="$t('llm.requestPath')">
|
||||||
|
<ElInput
|
||||||
|
v-model.trim="formData.requestPath"
|
||||||
|
placeholder="可选,单独覆写 requestPath"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElForm>
|
||||||
|
</div>
|
||||||
</EasyFlowFormModal>
|
</EasyFlowFormModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.model-ability {
|
.model-modal {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-modal__section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 20px;
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
hsl(var(--surface-panel) / 98%) 0%,
|
||||||
|
hsl(var(--surface-contrast-soft) / 94%) 100%
|
||||||
|
);
|
||||||
|
border: 1px solid hsl(var(--divider-faint) / 50%);
|
||||||
|
border-radius: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-modal__section-head h4,
|
||||||
|
.model-modal__advanced-toggle h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--text-strong));
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-modal__section-head p,
|
||||||
|
.model-modal__advanced-toggle p {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-modal__form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-modal__ability {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-modal__ability-chip {
|
||||||
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 4px;
|
justify-content: center;
|
||||||
}
|
padding: 8px 14px;
|
||||||
|
font-size: 13px;
|
||||||
.model-ability-tag {
|
color: hsl(var(--text-muted));
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
background: hsl(var(--surface-panel));
|
||||||
|
border: 1px solid hsl(var(--divider-faint) / 68%);
|
||||||
|
border-radius: 999px;
|
||||||
|
transition:
|
||||||
|
border-color 0.2s ease,
|
||||||
|
color 0.2s ease,
|
||||||
|
background 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-selected {
|
.model-modal__ability-chip:hover:not(:disabled),
|
||||||
font-weight: bold;
|
.model-modal__ability-chip:focus-visible:not(:disabled) {
|
||||||
transform: scale(1.05);
|
color: hsl(var(--text-strong));
|
||||||
|
border-color: hsl(var(--divider-faint));
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-modal__ability-chip.is-active {
|
||||||
|
color: hsl(var(--text-strong));
|
||||||
|
background: hsl(var(--primary) / 8%);
|
||||||
|
border-color: hsl(var(--primary) / 38%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-modal__ability-chip.is-disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.56;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-modal__advanced-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
text-align: left;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-modal__advanced-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-modal__advanced-grid :deep(.el-form-item:last-child) {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.model-modal__advanced-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-modal__advanced-grid :deep(.el-form-item:last-child) {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,41 +1,25 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref } from 'vue';
|
import {computed, reactive, ref} from 'vue';
|
||||||
|
|
||||||
import { EasyFlowFormModal } from '@easyflow/common-ui';
|
import {EasyFlowFormModal} from '@easyflow/common-ui';
|
||||||
|
|
||||||
import {
|
import {ArrowDown, ArrowUp} from '@element-plus/icons-vue';
|
||||||
ElForm,
|
import {ElForm, ElFormItem, ElIcon, ElInput, ElMessage, ElOption, ElSelect,} from 'element-plus';
|
||||||
ElFormItem,
|
|
||||||
ElInput,
|
|
||||||
ElMessage,
|
|
||||||
ElOption,
|
|
||||||
ElSelect,
|
|
||||||
} from 'element-plus';
|
|
||||||
|
|
||||||
import { api } from '#/api/request';
|
import {api} from '#/api/request';
|
||||||
import UploadAvatar from '#/components/upload/UploadAvatar.vue';
|
import UploadAvatar from '#/components/upload/UploadAvatar.vue';
|
||||||
import { $t } from '#/locales';
|
import {$t} from '#/locales';
|
||||||
import providerList from '#/views/ai/model/modelUtils/providerList.json';
|
import ModelProviderBadge from '#/views/ai/model/ModelProviderBadge.vue';
|
||||||
|
import {getProviderPresetByValue, providerPresets,} from '#/views/ai/model/modelUtils/defaultIcon';
|
||||||
|
|
||||||
const emit = defineEmits(['reload']);
|
const emit = defineEmits(['reload']);
|
||||||
|
|
||||||
const formDataRef = ref();
|
const formDataRef = ref();
|
||||||
|
|
||||||
defineExpose({
|
|
||||||
openAddDialog() {
|
|
||||||
formDataRef.value?.resetFields();
|
|
||||||
dialogVisible.value = true;
|
|
||||||
},
|
|
||||||
openEditDialog(item: any) {
|
|
||||||
dialogVisible.value = true;
|
|
||||||
isAdd.value = false;
|
|
||||||
Object.assign(formData, item);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const providerOptions =
|
|
||||||
ref<Array<{ label: string; options: any; value: string }>>(providerList);
|
|
||||||
const isAdd = ref(true);
|
|
||||||
const dialogVisible = ref(false);
|
const dialogVisible = ref(false);
|
||||||
|
const btnLoading = ref(false);
|
||||||
|
const isAdd = ref(true);
|
||||||
|
const showAdvanced = ref(false);
|
||||||
|
|
||||||
const formData = reactive({
|
const formData = reactive({
|
||||||
id: '',
|
id: '',
|
||||||
icon: '',
|
icon: '',
|
||||||
@@ -47,9 +31,64 @@ const formData = reactive({
|
|||||||
embedPath: '',
|
embedPath: '',
|
||||||
rerankPath: '',
|
rerankPath: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const selectedPreset = computed(() =>
|
||||||
|
getProviderPresetByValue(formData.providerType),
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasLegacyProvider = computed(
|
||||||
|
() => !selectedPreset.value && !!formData.providerType,
|
||||||
|
);
|
||||||
|
|
||||||
|
const getModeLabel = (mode?: 'hosted' | 'self-hosted') =>
|
||||||
|
mode === 'self-hosted' ? '自部署' : '云服务';
|
||||||
|
|
||||||
|
const resetFormData = () => {
|
||||||
|
Object.assign(formData, {
|
||||||
|
id: '',
|
||||||
|
icon: '',
|
||||||
|
providerName: '',
|
||||||
|
providerType: '',
|
||||||
|
apiKey: '',
|
||||||
|
endpoint: '',
|
||||||
|
chatPath: '',
|
||||||
|
embedPath: '',
|
||||||
|
rerankPath: '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
openAddDialog() {
|
||||||
|
isAdd.value = true;
|
||||||
|
dialogVisible.value = true;
|
||||||
|
showAdvanced.value = false;
|
||||||
|
formDataRef.value?.resetFields();
|
||||||
|
resetFormData();
|
||||||
|
},
|
||||||
|
openEditDialog(item: any) {
|
||||||
|
isAdd.value = false;
|
||||||
|
dialogVisible.value = true;
|
||||||
|
showAdvanced.value = false;
|
||||||
|
formDataRef.value?.clearValidate();
|
||||||
|
resetFormData();
|
||||||
|
Object.assign(formData, {
|
||||||
|
...item,
|
||||||
|
icon: item.icon || '',
|
||||||
|
providerName: item.providerName || '',
|
||||||
|
providerType: item.providerType || '',
|
||||||
|
apiKey: item.apiKey || '',
|
||||||
|
endpoint: item.endpoint || '',
|
||||||
|
chatPath: item.chatPath || '',
|
||||||
|
embedPath: item.embedPath || '',
|
||||||
|
rerankPath: item.rerankPath || '',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const closeDialog = () => {
|
const closeDialog = () => {
|
||||||
dialogVisible.value = false;
|
dialogVisible.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const rules = {
|
const rules = {
|
||||||
providerName: [
|
providerName: [
|
||||||
{
|
{
|
||||||
@@ -59,6 +98,20 @@ const rules = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
providerType: [
|
providerType: [
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: $t('message.required'),
|
||||||
|
trigger: 'change',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
endpoint: [
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: $t('message.required'),
|
||||||
|
trigger: 'blur',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
chatPath: [
|
||||||
{
|
{
|
||||||
required: true,
|
required: true,
|
||||||
message: $t('message.required'),
|
message: $t('message.required'),
|
||||||
@@ -66,48 +119,47 @@ const rules = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const btnLoading = ref(false);
|
|
||||||
|
const applyPreset = (value: string) => {
|
||||||
|
const preset = getProviderPresetByValue(value);
|
||||||
|
if (!preset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
formData.providerType = preset.value;
|
||||||
|
formData.providerName = preset.label;
|
||||||
|
formData.endpoint = preset.options.llmEndpoint || '';
|
||||||
|
formData.chatPath = preset.options.chatPath || '';
|
||||||
|
formData.embedPath = preset.options.embedPath || '';
|
||||||
|
formData.rerankPath = preset.options.rerankPath || '';
|
||||||
|
};
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
btnLoading.value = true;
|
btnLoading.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!isAdd.value) {
|
|
||||||
api.post('/api/v1/modelProvider/update', formData).then((res) => {
|
|
||||||
if (res.errorCode === 0) {
|
|
||||||
ElMessage.success(res.message);
|
|
||||||
emit('reload');
|
|
||||||
closeDialog();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await formDataRef.value.validate();
|
await formDataRef.value.validate();
|
||||||
api.post('/api/v1/modelProvider/save', formData).then((res) => {
|
|
||||||
if (res.errorCode === 0) {
|
const url = isAdd.value
|
||||||
ElMessage.success(res.message);
|
? '/api/v1/modelProvider/save'
|
||||||
emit('reload');
|
: '/api/v1/modelProvider/update';
|
||||||
closeDialog();
|
const res = await api.post(url, formData);
|
||||||
}
|
|
||||||
});
|
if (res.errorCode === 0) {
|
||||||
|
ElMessage.success(res.message || '服务商已保存');
|
||||||
|
emit('reload');
|
||||||
|
closeDialog();
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.message || $t('ui.actionMessage.operationFailed'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error as any)?.fields) {
|
||||||
|
ElMessage.error($t('ui.actionMessage.operationFailed'));
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
btnLoading.value = false;
|
btnLoading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleChangeProvider = (val: string) => {
|
|
||||||
const tempProvider = providerList.find((item) => item.value === val);
|
|
||||||
if (!tempProvider) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
formData.providerName = tempProvider.label;
|
|
||||||
formData.endpoint = providerOptions.value.find(
|
|
||||||
(item) => item.value === val,
|
|
||||||
)?.options.llmEndpoint;
|
|
||||||
formData.chatPath = providerOptions.value.find(
|
|
||||||
(item) => item.value === val,
|
|
||||||
)?.options.chatPath;
|
|
||||||
formData.embedPath = providerOptions.value.find(
|
|
||||||
(item) => item.value === val,
|
|
||||||
)?.options.embedPath;
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -115,9 +167,9 @@ const handleChangeProvider = (val: string) => {
|
|||||||
v-model:open="dialogVisible"
|
v-model:open="dialogVisible"
|
||||||
:centered="true"
|
:centered="true"
|
||||||
:closable="!btnLoading"
|
:closable="!btnLoading"
|
||||||
:title="isAdd ? $t('button.add') : $t('button.edit')"
|
:title="isAdd ? '添加服务商' : '编辑服务商'"
|
||||||
:before-close="closeDialog"
|
:before-close="closeDialog"
|
||||||
width="482"
|
width="680"
|
||||||
:confirm-loading="btnLoading"
|
:confirm-loading="btnLoading"
|
||||||
:confirm-text="$t('button.save')"
|
:confirm-text="$t('button.save')"
|
||||||
:submitting="btnLoading"
|
:submitting="btnLoading"
|
||||||
@@ -129,63 +181,259 @@ const handleChangeProvider = (val: string) => {
|
|||||||
status-icon
|
status-icon
|
||||||
:rules="rules"
|
:rules="rules"
|
||||||
label-position="top"
|
label-position="top"
|
||||||
class="easyflow-modal-form easyflow-modal-form--compact"
|
class="provider-modal"
|
||||||
>
|
>
|
||||||
<ElFormItem
|
<section class="provider-modal__section">
|
||||||
prop="icon"
|
<div class="provider-modal__section-head">
|
||||||
style="display: flex; align-items: center"
|
<h3>服务商预设</h3>
|
||||||
:label="$t('llmProvider.icon')"
|
<p>先选择预设,系统会自动回填推荐网关与默认路径。</p>
|
||||||
>
|
</div>
|
||||||
<UploadAvatar v-model="formData.icon" />
|
|
||||||
</ElFormItem>
|
<ElFormItem prop="providerType" :label="$t('llmProvider.apiType')">
|
||||||
<ElFormItem prop="providerName" :label="$t('llmProvider.providerName')">
|
<ElSelect
|
||||||
<ElInput v-model.trim="formData.providerName" />
|
v-model="formData.providerType"
|
||||||
</ElFormItem>
|
filterable
|
||||||
<ElFormItem prop="provider" :label="$t('llmProvider.apiType')">
|
placeholder="选择一个服务商预设"
|
||||||
<ElSelect
|
popper-class="provider-preset-select-dropdown"
|
||||||
v-model="formData.providerType"
|
@change="applyPreset"
|
||||||
@change="handleChangeProvider"
|
>
|
||||||
>
|
<template #prefix>
|
||||||
<ElOption
|
<ModelProviderBadge
|
||||||
v-for="item in providerOptions"
|
v-if="selectedPreset"
|
||||||
:key="item.value"
|
:provider-name="selectedPreset.label"
|
||||||
:label="item.label"
|
:provider-type="selectedPreset.value"
|
||||||
:value="item.value || ''"
|
:size="20"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<ElOption
|
||||||
|
v-for="item in providerPresets"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
>
|
||||||
|
<div class="provider-preset-option">
|
||||||
|
<div class="provider-preset-option__meta">
|
||||||
|
<ModelProviderBadge
|
||||||
|
:provider-name="item.label"
|
||||||
|
:provider-type="item.value"
|
||||||
|
:size="24"
|
||||||
|
/>
|
||||||
|
<strong>{{ item.label }}</strong>
|
||||||
|
</div>
|
||||||
|
<span>{{ getModeLabel(item.mode) }}</span>
|
||||||
|
</div>
|
||||||
|
</ElOption>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<div v-if="hasLegacyProvider" class="provider-modal__legacy">
|
||||||
|
当前类型为
|
||||||
|
{{
|
||||||
|
formData.providerType
|
||||||
|
}},该类型不在最新预设列表中,但仍可继续编辑。
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="provider-modal__section">
|
||||||
|
<div class="provider-modal__section-head">
|
||||||
|
<h3>基础接入</h3>
|
||||||
|
<p>先完成主流程字段:名称、密钥、Endpoint、对话路径。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="provider-modal__grid">
|
||||||
|
<ElFormItem
|
||||||
|
prop="providerName"
|
||||||
|
:label="$t('llmProvider.providerName')"
|
||||||
|
>
|
||||||
|
<ElInput
|
||||||
|
v-model.trim="formData.providerName"
|
||||||
|
placeholder="例如:OpenAI 生产环境"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem prop="apiKey" :label="$t('llmProvider.apiKey')">
|
||||||
|
<ElInput
|
||||||
|
v-model.trim="formData.apiKey"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
placeholder="填写服务商密钥"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElFormItem prop="endpoint" :label="$t('llmProvider.endpoint')">
|
||||||
|
<ElInput
|
||||||
|
v-model.trim="formData.endpoint"
|
||||||
|
placeholder="填写网关地址"
|
||||||
/>
|
/>
|
||||||
</ElSelect>
|
</ElFormItem>
|
||||||
</ElFormItem>
|
|
||||||
<ElFormItem prop="apiKey" :label="$t('llmProvider.apiKey')">
|
<ElFormItem prop="chatPath" :label="$t('llmProvider.chatPath')">
|
||||||
<ElInput v-model.trim="formData.apiKey" />
|
<ElInput
|
||||||
</ElFormItem>
|
v-model.trim="formData.chatPath"
|
||||||
<ElFormItem prop="endpoint" :label="$t('llmProvider.endpoint')">
|
placeholder="对话模型请求路径"
|
||||||
<ElInput v-model.trim="formData.endpoint" />
|
/>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
<ElFormItem prop="chatPath" :label="$t('llmProvider.chatPath')">
|
</section>
|
||||||
<ElInput v-model.trim="formData.chatPath" />
|
|
||||||
</ElFormItem>
|
<section class="provider-modal__section">
|
||||||
<ElFormItem prop="rerankPath" :label="$t('llmProvider.rerankPath')">
|
<button
|
||||||
<ElInput v-model.trim="formData.rerankPath" />
|
type="button"
|
||||||
</ElFormItem>
|
class="provider-modal__advanced-toggle"
|
||||||
<ElFormItem prop="embedPath" :label="$t('llmProvider.embedPath')">
|
@click="showAdvanced = !showAdvanced"
|
||||||
<ElInput v-model.trim="formData.embedPath" />
|
>
|
||||||
</ElFormItem>
|
<div>
|
||||||
|
<h3>高级设置</h3>
|
||||||
|
<p>包含向量/重排路径、图标上传和预设说明,默认收起。</p>
|
||||||
|
</div>
|
||||||
|
<ElIcon>
|
||||||
|
<ArrowUp v-if="showAdvanced" />
|
||||||
|
<ArrowDown v-else />
|
||||||
|
</ElIcon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="showAdvanced" class="provider-modal__advanced">
|
||||||
|
<div class="provider-modal__grid">
|
||||||
|
<ElFormItem prop="embedPath" :label="$t('llmProvider.embedPath')">
|
||||||
|
<ElInput
|
||||||
|
v-model.trim="formData.embedPath"
|
||||||
|
placeholder="向量模型请求路径"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem prop="rerankPath" :label="$t('llmProvider.rerankPath')">
|
||||||
|
<ElInput
|
||||||
|
v-model.trim="formData.rerankPath"
|
||||||
|
placeholder="Rerank 请求路径"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="provider-modal__advanced-grid">
|
||||||
|
<div class="provider-modal__icon-panel">
|
||||||
|
<h4>图标上传</h4>
|
||||||
|
<p>用于区分同一厂商在测试、预发、生产等不同环境。</p>
|
||||||
|
<ElFormItem prop="icon" :label="$t('llmProvider.icon')">
|
||||||
|
<UploadAvatar v-model="formData.icon" />
|
||||||
|
</ElFormItem>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</ElForm>
|
</ElForm>
|
||||||
</EasyFlowFormModal>
|
</EasyFlowFormModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.headers-container-reduce {
|
.provider-modal {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-modal__section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
background: hsl(var(--surface-panel) / 92%);
|
||||||
|
border: 1px solid hsl(var(--divider-faint) / 54%);
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-modal__section-head h3,
|
||||||
|
.provider-modal__advanced-toggle h3,
|
||||||
|
.provider-modal__preset-head h4,
|
||||||
|
.provider-modal__icon-panel h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--text-strong));
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-modal__section-head p,
|
||||||
|
.provider-modal__advanced-toggle p,
|
||||||
|
.provider-modal__legacy,
|
||||||
|
.provider-modal__preset-head p,
|
||||||
|
.provider-modal__icon-panel p {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-preset-option {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-preset-option__meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.addHeadersBtn {
|
|
||||||
|
.provider-preset-option strong {
|
||||||
|
font-size: 14px;
|
||||||
|
color: hsl(var(--text-strong));
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-preset-option span {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
background: hsl(var(--surface-contrast-soft));
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-modal__legacy {
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: hsl(var(--warning) / 7%);
|
||||||
|
border: 1px solid hsl(var(--warning) / 26%);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-modal__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-modal__advanced-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-style: dashed;
|
padding: 0;
|
||||||
border-color: var(--el-color-primary);
|
text-align: left;
|
||||||
border-radius: 8px;
|
background: transparent;
|
||||||
margin-top: 8px;
|
border: none;
|
||||||
}
|
}
|
||||||
.head-con-content {
|
|
||||||
margin-bottom: 8px;
|
.provider-modal__advanced {
|
||||||
align-items: center;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-modal__advanced-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-modal__icon-panel {
|
||||||
|
padding: 12px;
|
||||||
|
background: hsl(var(--surface-contrast-soft) / 70%);
|
||||||
|
border: 1px solid hsl(var(--divider-faint) / 54%);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.provider-modal__grid,
|
||||||
|
.provider-modal__advanced-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,350 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { nextTick, reactive, ref } from 'vue';
|
|
||||||
|
|
||||||
import { EasyFlowPanelModal } from '@easyflow/common-ui';
|
|
||||||
|
|
||||||
import {
|
|
||||||
CirclePlus,
|
|
||||||
Loading,
|
|
||||||
Minus,
|
|
||||||
RefreshRight,
|
|
||||||
} from '@element-plus/icons-vue';
|
|
||||||
import {
|
|
||||||
ElCollapse,
|
|
||||||
ElCollapseItem,
|
|
||||||
ElForm,
|
|
||||||
ElFormItem,
|
|
||||||
ElIcon,
|
|
||||||
ElInput,
|
|
||||||
ElMessageBox,
|
|
||||||
ElTabPane,
|
|
||||||
ElTabs,
|
|
||||||
ElTooltip,
|
|
||||||
} from 'element-plus';
|
|
||||||
|
|
||||||
import { api } from '#/api/request';
|
|
||||||
import { $t } from '#/locales';
|
|
||||||
import ModelViewItemOperation from '#/views/ai/model/ModelViewItemOperation.vue';
|
|
||||||
|
|
||||||
const emit = defineEmits(['reload']);
|
|
||||||
const tabList = ref<any>([]);
|
|
||||||
const isLoading = ref(false);
|
|
||||||
const chatModelTabList = [
|
|
||||||
// {
|
|
||||||
// label: $t('llm.all'),
|
|
||||||
// name: 'all',
|
|
||||||
// },
|
|
||||||
{
|
|
||||||
label: $t('llmProvider.chatModel'),
|
|
||||||
name: 'chatModel',
|
|
||||||
},
|
|
||||||
// {
|
|
||||||
// label: $t('llm.modelAbility.free'),
|
|
||||||
// name: 'supportFree',
|
|
||||||
// },
|
|
||||||
];
|
|
||||||
const embeddingModelTabList = [
|
|
||||||
{
|
|
||||||
label: $t('llmProvider.embeddingModel'),
|
|
||||||
name: 'embeddingModel',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const rerankModelTabList = [
|
|
||||||
{
|
|
||||||
label: $t('llmProvider.rerankModel'),
|
|
||||||
name: 'rerankModel',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const formDataRef = ref();
|
|
||||||
const providerInfo = ref<any>();
|
|
||||||
const getProviderInfo = (id: string) => {
|
|
||||||
api.get(`/api/v1/modelProvider/detail?id=${id}`).then((res) => {
|
|
||||||
if (res.errorCode === 0) {
|
|
||||||
providerInfo.value = res.data;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const modelList = ref<any>([]);
|
|
||||||
const getLlmList = (providerId: string, modelType: string) => {
|
|
||||||
isLoading.value = true;
|
|
||||||
const url =
|
|
||||||
modelType === ''
|
|
||||||
? `/api/v1/model/selectLlmByProviderAndModelType?providerId=${providerId}&modelType=${modelType}&supportFree=true`
|
|
||||||
: `/api/v1/model/selectLlmByProviderAndModelType?providerId=${providerId}&modelType=${modelType}&selectText=${searchFormDada.searchText}`;
|
|
||||||
api.get(url).then((res) => {
|
|
||||||
if (res.errorCode === 0) {
|
|
||||||
const chatModelMap = res.data || {};
|
|
||||||
modelList.value = Object.entries(chatModelMap).map(
|
|
||||||
([groupName, llmList]) => ({
|
|
||||||
groupName,
|
|
||||||
llmList,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
isLoading.value = false;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const selectedProviderId = ref('');
|
|
||||||
defineExpose({
|
|
||||||
// providerId: 供应商id, clickModelType 父组件点击的是什么类型的模型 可以是chatModel or embeddingModel
|
|
||||||
openDialog(providerId: string, clickModelType: string) {
|
|
||||||
switch (clickModelType) {
|
|
||||||
case 'chatModel': {
|
|
||||||
tabList.value = [...chatModelTabList];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'embeddingModel': {
|
|
||||||
tabList.value = [...embeddingModelTabList];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'rerankModel': {
|
|
||||||
tabList.value = [...rerankModelTabList];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// No default
|
|
||||||
}
|
|
||||||
selectedProviderId.value = providerId;
|
|
||||||
formDataRef.value?.resetFields();
|
|
||||||
modelList.value = [];
|
|
||||||
activeName.value = tabList.value[0]?.name;
|
|
||||||
getProviderInfo(providerId);
|
|
||||||
getLlmList(providerId, clickModelType);
|
|
||||||
dialogVisible.value = true;
|
|
||||||
},
|
|
||||||
openEditDialog(item: any) {
|
|
||||||
dialogVisible.value = true;
|
|
||||||
isAdd.value = false;
|
|
||||||
formData.icon = item.icon;
|
|
||||||
formData.providerName = item.providerName;
|
|
||||||
formData.provider = item.provider;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const isAdd = ref(true);
|
|
||||||
const dialogVisible = ref(false);
|
|
||||||
const formData = reactive({
|
|
||||||
icon: '',
|
|
||||||
providerName: '',
|
|
||||||
provider: '',
|
|
||||||
apiKey: '',
|
|
||||||
endPoint: '',
|
|
||||||
chatPath: '',
|
|
||||||
embedPath: '',
|
|
||||||
});
|
|
||||||
const closeDialog = () => {
|
|
||||||
dialogVisible.value = false;
|
|
||||||
};
|
|
||||||
const handleTabClick = async () => {
|
|
||||||
await nextTick();
|
|
||||||
getLlmList(providerInfo.value.id, activeName.value);
|
|
||||||
};
|
|
||||||
const activeName = ref('all');
|
|
||||||
const handleGroupNameDelete = (groupName: string) => {
|
|
||||||
ElMessageBox.confirm(
|
|
||||||
$t('message.deleteModelGroupAlert'),
|
|
||||||
$t('message.noticeTitle'),
|
|
||||||
{
|
|
||||||
confirmButtonText: $t('message.ok'),
|
|
||||||
cancelButtonText: $t('message.cancel'),
|
|
||||||
type: 'warning',
|
|
||||||
},
|
|
||||||
).then(() => {
|
|
||||||
api
|
|
||||||
.post(`/api/v1/model/removeByEntity`, {
|
|
||||||
groupName,
|
|
||||||
providerId: selectedProviderId.value,
|
|
||||||
})
|
|
||||||
.then((res) => {
|
|
||||||
if (res.errorCode === 0) {
|
|
||||||
getLlmList(providerInfo.value.id, activeName.value);
|
|
||||||
emit('reload');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const handleDeleteLlm = (id: any) => {
|
|
||||||
ElMessageBox.confirm(
|
|
||||||
$t('message.deleteModelAlert'),
|
|
||||||
$t('message.noticeTitle'),
|
|
||||||
{
|
|
||||||
confirmButtonText: $t('message.ok'),
|
|
||||||
cancelButtonText: $t('message.cancel'),
|
|
||||||
type: 'warning',
|
|
||||||
},
|
|
||||||
).then(() => {
|
|
||||||
api.post(`/api/v1/model/removeLlmByIds`, { id }).then((res) => {
|
|
||||||
if (res.errorCode === 0) {
|
|
||||||
getLlmList(providerInfo.value.id, activeName.value);
|
|
||||||
emit('reload');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const handleAddLlm = (id: string) => {
|
|
||||||
api
|
|
||||||
.post(`/api/v1/model/update`, {
|
|
||||||
id,
|
|
||||||
withUsed: true,
|
|
||||||
})
|
|
||||||
.then((res) => {
|
|
||||||
if (res.errorCode === 0) {
|
|
||||||
getLlmList(providerInfo.value.id, activeName.value);
|
|
||||||
emit('reload');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const searchFormDada = reactive({
|
|
||||||
searchText: '',
|
|
||||||
});
|
|
||||||
const handleAddAllLlm = () => {
|
|
||||||
api
|
|
||||||
.post(`/api/v1/model/addAllLlm`, {
|
|
||||||
providerId: selectedProviderId.value,
|
|
||||||
withUsed: true,
|
|
||||||
})
|
|
||||||
.then((res) => {
|
|
||||||
if (res.errorCode === 0) {
|
|
||||||
getLlmList(providerInfo.value.id, activeName.value);
|
|
||||||
emit('reload');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const handleRefresh = () => {
|
|
||||||
if (isLoading.value) return;
|
|
||||||
getLlmList(providerInfo.value.id, activeName.value);
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<EasyFlowPanelModal
|
|
||||||
v-model:open="dialogVisible"
|
|
||||||
:centered="true"
|
|
||||||
:title="`${providerInfo?.providerName}${$t('llmProvider.model')}`"
|
|
||||||
:before-close="closeDialog"
|
|
||||||
width="762"
|
|
||||||
:show-footer="false"
|
|
||||||
>
|
|
||||||
<div class="manage-llm-container">
|
|
||||||
<div class="form-container">
|
|
||||||
<ElForm ref="formDataRef" :model="searchFormDada" status-icon>
|
|
||||||
<ElFormItem prop="searchText">
|
|
||||||
<div class="search-container">
|
|
||||||
<ElInput
|
|
||||||
v-model.trim="searchFormDada.searchText"
|
|
||||||
@input="handleRefresh"
|
|
||||||
:placeholder="$t('llm.searchTextPlaceholder')"
|
|
||||||
/>
|
|
||||||
<ElTooltip
|
|
||||||
:content="$t('llm.button.addAllLlm')"
|
|
||||||
placement="top"
|
|
||||||
effect="dark"
|
|
||||||
>
|
|
||||||
<ElIcon
|
|
||||||
size="20"
|
|
||||||
@click="handleAddAllLlm"
|
|
||||||
class="cursor-pointer"
|
|
||||||
>
|
|
||||||
<CirclePlus />
|
|
||||||
</ElIcon>
|
|
||||||
</ElTooltip>
|
|
||||||
<ElTooltip
|
|
||||||
:content="$t('llm.button.RetrieveAgain')"
|
|
||||||
placement="top"
|
|
||||||
effect="dark"
|
|
||||||
>
|
|
||||||
<ElIcon size="20" @click="handleRefresh" class="cursor-pointer">
|
|
||||||
<RefreshRight />
|
|
||||||
</ElIcon>
|
|
||||||
</ElTooltip>
|
|
||||||
</div>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElForm>
|
|
||||||
</div>
|
|
||||||
<div class="llm-table-container">
|
|
||||||
<ElTabs v-model="activeName" @tab-click="handleTabClick">
|
|
||||||
<ElTabPane
|
|
||||||
:label="item.label"
|
|
||||||
:name="item.name"
|
|
||||||
v-for="item in tabList"
|
|
||||||
default-active="all"
|
|
||||||
:key="item.name"
|
|
||||||
>
|
|
||||||
<div v-if="isLoading" class="collapse-loading">
|
|
||||||
<ElIcon class="is-loading" size="24">
|
|
||||||
<Loading />
|
|
||||||
</ElIcon>
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
<ElCollapse
|
|
||||||
expand-icon-position="left"
|
|
||||||
v-if="modelList.length > 0"
|
|
||||||
>
|
|
||||||
<ElCollapseItem
|
|
||||||
v-for="group in modelList"
|
|
||||||
:key="group.groupName"
|
|
||||||
:title="group.groupName"
|
|
||||||
:name="group.groupName"
|
|
||||||
>
|
|
||||||
<template #title>
|
|
||||||
<div class="flex items-center justify-between pr-2">
|
|
||||||
<span>{{ group.groupName }}</span>
|
|
||||||
<span>
|
|
||||||
<ElIcon
|
|
||||||
@click.stop="handleGroupNameDelete(group.groupName)"
|
|
||||||
>
|
|
||||||
<Minus />
|
|
||||||
</ElIcon>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<ModelViewItemOperation
|
|
||||||
:need-hidden-setting-icon="true"
|
|
||||||
:llm-list="group.llmList"
|
|
||||||
@delete-llm="handleDeleteLlm"
|
|
||||||
@add-llm="handleAddLlm"
|
|
||||||
:is-management="true"
|
|
||||||
/>
|
|
||||||
</ElCollapseItem>
|
|
||||||
</ElCollapse>
|
|
||||||
</div>
|
|
||||||
</ElTabPane>
|
|
||||||
</ElTabs>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</EasyFlowPanelModal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.manage-llm-container {
|
|
||||||
height: 540px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.form-container {
|
|
||||||
height: 30px;
|
|
||||||
}
|
|
||||||
.search-container {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
.llm-table-container {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.collapse-loading {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 300px;
|
|
||||||
gap: 12px;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
}
|
|
||||||
:deep(.el-tabs__nav-wrap::after) {
|
|
||||||
height: 1px !important;
|
|
||||||
background-color: #e4e7ed !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
155
easyflow-ui-admin/app/src/views/ai/model/ModelProviderBadge.vue
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {computed} from 'vue';
|
||||||
|
|
||||||
|
import {ElImage} from 'element-plus';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getIconByValue,
|
||||||
|
getProviderBadgeText,
|
||||||
|
isSvgString,
|
||||||
|
} from '#/views/ai/model/modelUtils/defaultIcon';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
icon?: string;
|
||||||
|
providerName?: string;
|
||||||
|
providerType?: string;
|
||||||
|
size?: number;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
icon: '',
|
||||||
|
providerName: '',
|
||||||
|
providerType: '',
|
||||||
|
size: 40,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const presetIcon = computed(() => getIconByValue(props.providerType));
|
||||||
|
|
||||||
|
const resolvedSvg = computed(() => {
|
||||||
|
if (presetIcon.value && isSvgString(presetIcon.value)) {
|
||||||
|
return presetIcon.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isSvgString(props.icon) ? props.icon : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolvedImage = computed(() => {
|
||||||
|
if (props.icon && !isSvgString(props.icon)) {
|
||||||
|
return props.icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
return presetIcon.value && !isSvgString(presetIcon.value)
|
||||||
|
? presetIcon.value
|
||||||
|
: '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const badgeText = computed(() =>
|
||||||
|
getProviderBadgeText(props.providerName, props.providerType),
|
||||||
|
);
|
||||||
|
|
||||||
|
const badgeStyle = computed(() => ({
|
||||||
|
width: `${props.size}px`,
|
||||||
|
height: `${props.size}px`,
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="provider-badge" :style="badgeStyle" :aria-hidden="true">
|
||||||
|
<ElImage
|
||||||
|
v-if="resolvedImage"
|
||||||
|
:src="resolvedImage"
|
||||||
|
fit="contain"
|
||||||
|
class="provider-badge__image"
|
||||||
|
/>
|
||||||
|
<!-- eslint-disable vue/no-v-html -->
|
||||||
|
<div
|
||||||
|
v-else-if="resolvedSvg"
|
||||||
|
class="provider-badge__svg"
|
||||||
|
v-html="resolvedSvg"
|
||||||
|
></div>
|
||||||
|
<!-- eslint-enable vue/no-v-html -->
|
||||||
|
<div v-else class="provider-badge__fallback" :title="badgeText">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle cx="7" cy="7" r="2.5" />
|
||||||
|
<circle cx="17" cy="7" r="2.5" />
|
||||||
|
<circle cx="12" cy="17" r="2.5" />
|
||||||
|
<path d="M8.9 8.4L10.6 12" />
|
||||||
|
<path d="M15.1 8.4L13.4 12" />
|
||||||
|
<path d="M9.2 16h5.6" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.provider-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(
|
||||||
|
145deg,
|
||||||
|
hsl(var(--surface-contrast-soft) / 96%) 0%,
|
||||||
|
hsl(var(--surface-panel) / 98%) 100%
|
||||||
|
);
|
||||||
|
border: 1px solid hsl(var(--glass-border) / 58%);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 12px 26px -22px hsl(var(--foreground) / 30%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-badge__image,
|
||||||
|
.provider-badge__svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-badge__image {
|
||||||
|
padding: 10%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-badge__image :deep(img) {
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-badge__svg {
|
||||||
|
padding: 16%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-badge__svg :deep(svg) {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-badge__fallback {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
color: hsl(var(--text-strong));
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-badge__fallback svg {
|
||||||
|
width: 58%;
|
||||||
|
height: 58%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-badge__fallback circle,
|
||||||
|
.provider-badge__fallback path {
|
||||||
|
stroke: currentcolor;
|
||||||
|
stroke-width: 1.8;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-badge__fallback circle {
|
||||||
|
fill: hsl(var(--surface-panel));
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-badge__fallback path {
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,44 +1,61 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref } from 'vue';
|
import {computed, reactive, ref} from 'vue';
|
||||||
|
|
||||||
import { EasyFlowFormModal } from '@easyflow/common-ui';
|
import {EasyFlowFormModal} from '@easyflow/common-ui';
|
||||||
|
|
||||||
import {
|
import {CircleCheckFilled, WarningFilled} from '@element-plus/icons-vue';
|
||||||
ElForm,
|
import {ElForm, ElFormItem, ElMessage, ElOption, ElSelect,} from 'element-plus';
|
||||||
ElFormItem,
|
|
||||||
ElMessage,
|
|
||||||
ElOption,
|
|
||||||
ElSelect,
|
|
||||||
} from 'element-plus';
|
|
||||||
|
|
||||||
import { api } from '#/api/request';
|
import {api} from '#/api/request';
|
||||||
import { $t } from '#/locales';
|
import {$t} from '#/locales';
|
||||||
|
|
||||||
|
type VerifyStatus = 'error' | 'idle' | 'success';
|
||||||
|
|
||||||
const options = ref<any[]>([]);
|
const options = ref<any[]>([]);
|
||||||
const getLlmList = (providerId: string) => {
|
|
||||||
api.get(`/api/v1/model/list?providerId=${providerId}`, {}).then((res) => {
|
|
||||||
if (res.errorCode === 0) {
|
|
||||||
options.value = res.data;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const modelType = ref('');
|
const modelType = ref('');
|
||||||
const vectorDimension = ref('');
|
const vectorDimension = ref('');
|
||||||
|
const verifyStatus = ref<VerifyStatus>('idle');
|
||||||
|
const verifyMessage = ref('');
|
||||||
const formDataRef = ref();
|
const formDataRef = ref();
|
||||||
const dialogVisible = ref(false);
|
const dialogVisible = ref(false);
|
||||||
defineExpose({
|
const btnLoading = ref(false);
|
||||||
openDialog(providerId: string) {
|
|
||||||
formDataRef.value?.resetFields();
|
|
||||||
modelType.value = '';
|
|
||||||
vectorDimension.value = '';
|
|
||||||
getLlmList(providerId);
|
|
||||||
dialogVisible.value = true;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const formData = reactive({
|
const formData = reactive({
|
||||||
llmId: '',
|
llmId: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const resultTitle = computed(() => {
|
||||||
|
if (verifyStatus.value === 'success') {
|
||||||
|
return '验证成功';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verifyStatus.value === 'error') {
|
||||||
|
return '验证失败';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '等待验证';
|
||||||
|
});
|
||||||
|
|
||||||
|
const getLlmList = async (providerId: string) => {
|
||||||
|
const res = await api.get(`/api/v1/model/list?providerId=${providerId}`, {});
|
||||||
|
if (res.errorCode === 0) {
|
||||||
|
options.value = res.data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
async openDialog(providerId: string) {
|
||||||
|
formDataRef.value?.resetFields();
|
||||||
|
formData.llmId = '';
|
||||||
|
modelType.value = '';
|
||||||
|
vectorDimension.value = '';
|
||||||
|
verifyStatus.value = 'idle';
|
||||||
|
verifyMessage.value = '请选择一个模型并开始验证。';
|
||||||
|
await getLlmList(providerId);
|
||||||
|
dialogVisible.value = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const rules = {
|
const rules = {
|
||||||
llmId: [
|
llmId: [
|
||||||
{
|
{
|
||||||
@@ -49,28 +66,51 @@ const rules = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getModelInfo = (id: string) => {
|
||||||
|
const current = options.value.find((item: any) => item.id === id);
|
||||||
|
modelType.value = current?.modelType || '';
|
||||||
|
vectorDimension.value = '';
|
||||||
|
verifyStatus.value = 'idle';
|
||||||
|
verifyMessage.value = '已更新待验证模型,点击确认开始检测配置。';
|
||||||
|
};
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
btnLoading.value = true;
|
btnLoading.value = true;
|
||||||
await formDataRef.value.validate();
|
verifyStatus.value = 'idle';
|
||||||
api
|
verifyMessage.value = '正在连接模型服务,请稍候...';
|
||||||
.get(`/api/v1/model/verifyLlmConfig?id=${formData.llmId}`, {})
|
|
||||||
.then((res) => {
|
try {
|
||||||
if (res.errorCode === 0) {
|
await formDataRef.value.validate();
|
||||||
ElMessage.success($t('llm.testSuccess'));
|
const res = await api.get(
|
||||||
if (modelType.value === 'embeddingModel' && res?.data?.dimension) {
|
`/api/v1/model/verifyLlmConfig?id=${formData.llmId}`,
|
||||||
vectorDimension.value = res?.data?.dimension;
|
{},
|
||||||
}
|
);
|
||||||
|
|
||||||
|
if (res.errorCode === 0) {
|
||||||
|
verifyStatus.value = 'success';
|
||||||
|
verifyMessage.value = $t('llm.testSuccess');
|
||||||
|
ElMessage.success($t('llm.testSuccess'));
|
||||||
|
if (modelType.value === 'embeddingModel' && res?.data?.dimension) {
|
||||||
|
vectorDimension.value = res.data.dimension;
|
||||||
}
|
}
|
||||||
btnLoading.value = false;
|
} else {
|
||||||
});
|
verifyStatus.value = 'error';
|
||||||
};
|
verifyMessage.value =
|
||||||
const btnLoading = ref(false);
|
res.message || $t('ui.actionMessage.operationFailed');
|
||||||
const getModelInfo = (id: string) => {
|
ElMessage.error(verifyMessage.value);
|
||||||
options.value.forEach((item: any) => {
|
|
||||||
if (item.id === id) {
|
|
||||||
modelType.value = item.modelType;
|
|
||||||
}
|
}
|
||||||
});
|
} catch (error: any) {
|
||||||
|
if (error?.fields) {
|
||||||
|
verifyMessage.value = '请选择一个模型并开始验证。';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyStatus.value = 'error';
|
||||||
|
verifyMessage.value =
|
||||||
|
error?.message || $t('ui.actionMessage.operationFailed');
|
||||||
|
} finally {
|
||||||
|
btnLoading.value = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -80,54 +120,147 @@ const getModelInfo = (id: string) => {
|
|||||||
:centered="true"
|
:centered="true"
|
||||||
:closable="!btnLoading"
|
:closable="!btnLoading"
|
||||||
:title="$t('llm.verifyLlmTitle')"
|
:title="$t('llm.verifyLlmTitle')"
|
||||||
width="482"
|
width="560"
|
||||||
:confirm-loading="btnLoading"
|
:confirm-loading="btnLoading"
|
||||||
:confirm-text="$t('button.confirm')"
|
:confirm-text="$t('button.confirm')"
|
||||||
:submitting="btnLoading"
|
:submitting="btnLoading"
|
||||||
@confirm="save"
|
@confirm="save"
|
||||||
>
|
>
|
||||||
<ElForm
|
<div class="verify-modal">
|
||||||
ref="formDataRef"
|
<section class="verify-modal__section">
|
||||||
:model="formData"
|
<div class="verify-modal__section-head">
|
||||||
status-icon
|
<h3>1. 选择待验证模型</h3>
|
||||||
:rules="rules"
|
<p>
|
||||||
label-position="top"
|
会用当前保存的服务商配置发起一次真实请求,帮助你确认密钥和路径是否正确。
|
||||||
class="easyflow-modal-form easyflow-modal-form--compact"
|
</p>
|
||||||
>
|
</div>
|
||||||
<ElFormItem prop="llmId" :label="$t('llm.modelToBeTested')">
|
|
||||||
<ElSelect v-model="formData.llmId" @change="getModelInfo">
|
<ElForm
|
||||||
<ElOption
|
ref="formDataRef"
|
||||||
v-for="item in options"
|
:model="formData"
|
||||||
:key="item.id"
|
status-icon
|
||||||
:label="item.title"
|
:rules="rules"
|
||||||
:value="item.id || ''"
|
label-position="top"
|
||||||
/>
|
class="verify-modal__form"
|
||||||
</ElSelect>
|
>
|
||||||
</ElFormItem>
|
<ElFormItem prop="llmId" :label="$t('llm.modelToBeTested')">
|
||||||
<ElFormItem
|
<ElSelect
|
||||||
v-if="modelType === 'embeddingModel' && vectorDimension"
|
v-model="formData.llmId"
|
||||||
:label="$t('documentCollection.dimensionOfVectorModel')"
|
filterable
|
||||||
label-width="100px"
|
placeholder="选择一个模型"
|
||||||
>
|
@change="getModelInfo"
|
||||||
{{ vectorDimension }}
|
>
|
||||||
</ElFormItem>
|
<ElOption
|
||||||
</ElForm>
|
v-for="item in options"
|
||||||
|
:key="item.id"
|
||||||
|
:label="item.title"
|
||||||
|
:value="item.id || ''"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElForm>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="verify-modal__section verify-modal__section--result">
|
||||||
|
<div class="verify-modal__section-head">
|
||||||
|
<h3>2. 查看验证结果</h3>
|
||||||
|
<p>成功后会返回可用状态;如果是向量模型,还会展示向量维度。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="verify-result-card"
|
||||||
|
:class="{
|
||||||
|
'is-success': verifyStatus === 'success',
|
||||||
|
'is-error': verifyStatus === 'error',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="verify-result-card__icon">
|
||||||
|
<CircleCheckFilled v-if="verifyStatus === 'success'" />
|
||||||
|
<WarningFilled v-else />
|
||||||
|
</div>
|
||||||
|
<div class="verify-result-card__content">
|
||||||
|
<h4>{{ resultTitle }}</h4>
|
||||||
|
<p>{{ verifyMessage }}</p>
|
||||||
|
<div
|
||||||
|
v-if="modelType === 'embeddingModel' && vectorDimension"
|
||||||
|
class="verify-result-card__meta"
|
||||||
|
>
|
||||||
|
向量维度:{{ vectorDimension }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</EasyFlowFormModal>
|
</EasyFlowFormModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.headers-container-reduce {
|
.verify-modal {
|
||||||
align-items: center;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
}
|
}
|
||||||
.addHeadersBtn {
|
|
||||||
width: 100%;
|
.verify-modal__section {
|
||||||
border-style: dashed;
|
display: flex;
|
||||||
border-color: var(--el-color-primary);
|
flex-direction: column;
|
||||||
border-radius: 8px;
|
gap: 14px;
|
||||||
margin-top: 8px;
|
padding: 20px;
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
hsl(var(--surface-panel) / 98%) 0%,
|
||||||
|
hsl(var(--surface-contrast-soft) / 94%) 100%
|
||||||
|
);
|
||||||
|
border: 1px solid hsl(var(--divider-faint) / 50%);
|
||||||
|
border-radius: 24px;
|
||||||
}
|
}
|
||||||
.head-con-content {
|
|
||||||
margin-bottom: 8px;
|
.verify-modal__section-head h3,
|
||||||
|
.verify-result-card__content h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--text-strong));
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-modal__section-head p,
|
||||||
|
.verify-result-card__content p,
|
||||||
|
.verify-result-card__meta {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-result-card {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 18px;
|
||||||
|
background: hsl(var(--surface-panel) / 90%);
|
||||||
|
border: 1px solid hsl(var(--divider-faint) / 56%);
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-result-card.is-success {
|
||||||
|
background: hsl(var(--success) / 6%);
|
||||||
|
border-color: hsl(var(--success) / 36%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-result-card.is-error {
|
||||||
|
background: hsl(var(--destructive) / 5%);
|
||||||
|
border-color: hsl(var(--destructive) / 36%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-result-card__icon {
|
||||||
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
font-size: 18px;
|
||||||
|
color: hsl(var(--text-strong));
|
||||||
|
background: hsl(var(--surface-contrast-soft));
|
||||||
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,201 +1,377 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { PropType } from 'vue';
|
import type {PropType} from 'vue';
|
||||||
|
import {ref} from 'vue';
|
||||||
|
|
||||||
import type { llmType } from '#/api';
|
import type {llmType} from '#/api';
|
||||||
import type { ModelAbilityItem } from '#/views/ai/model/modelUtils/model-ability';
|
import type {ModelAbilityItem} from '#/views/ai/model/modelUtils/model-ability';
|
||||||
|
import {getDefaultModelAbility} from '#/views/ai/model/modelUtils/model-ability';
|
||||||
|
|
||||||
import { Minus, Plus, Setting } from '@element-plus/icons-vue';
|
import {CircleCheck, CircleClose, Delete, Edit, Loading, Select,} from '@element-plus/icons-vue';
|
||||||
import { ElIcon, ElImage, ElTag } from 'element-plus';
|
import {ElButton, ElIcon, ElMessage, ElTag} from 'element-plus';
|
||||||
|
|
||||||
import { getIconByValue } from '#/views/ai/model/modelUtils/defaultIcon';
|
import {verifyModelConfig} from '#/api/ai/llm';
|
||||||
import { getDefaultModelAbility } from '#/views/ai/model/modelUtils/model-ability';
|
import ModelProviderBadge from '#/views/ai/model/ModelProviderBadge.vue';
|
||||||
import { mapLlmToModelAbility } from '#/views/ai/model/modelUtils/model-ability-utils';
|
import {mapLlmToModelAbility} from '#/views/ai/model/modelUtils/model-ability-utils';
|
||||||
|
|
||||||
defineProps({
|
const props = defineProps({
|
||||||
llmList: {
|
llmList: {
|
||||||
type: Array as PropType<llmType[]>,
|
type: Array as PropType<llmType[]>,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
},
|
},
|
||||||
icon: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
needHiddenSettingIcon: {
|
needHiddenSettingIcon: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
isManagement: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['deleteLlm', 'editLlm', 'addLlm', 'updateWithUsed']);
|
const emit = defineEmits(['deleteLlm', 'editLlm']);
|
||||||
|
type VerifyButtonStatus = 'error' | 'idle' | 'loading' | 'success';
|
||||||
|
const verifyStatusMap = ref<Record<string, VerifyButtonStatus>>({});
|
||||||
|
const getModelId = (llm: llmType) =>
|
||||||
|
String((llm as any).id || (llm as any).llmId || (llm as any).modelId || '');
|
||||||
|
const getVerifyStatus = (llm: llmType): VerifyButtonStatus =>
|
||||||
|
verifyStatusMap.value[getModelId(llm)] || 'idle';
|
||||||
|
const isVerifying = (llm: llmType) => getVerifyStatus(llm) === 'loading';
|
||||||
|
const setVerifyStatus = (id: string, status: VerifyButtonStatus) => {
|
||||||
|
if (!id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
verifyStatusMap.value[id] = status;
|
||||||
|
};
|
||||||
|
const getVerifyButtonText = (llm: llmType) => {
|
||||||
|
const status = getVerifyStatus(llm);
|
||||||
|
if (status === 'loading') {
|
||||||
|
return '验证中';
|
||||||
|
}
|
||||||
|
if (status === 'success') {
|
||||||
|
return '验证成功';
|
||||||
|
}
|
||||||
|
if (status === 'error') {
|
||||||
|
return '验证失败';
|
||||||
|
}
|
||||||
|
return '验证配置';
|
||||||
|
};
|
||||||
|
const getVerifyButtonIcon = (llm: llmType) => {
|
||||||
|
const status = getVerifyStatus(llm);
|
||||||
|
if (status === 'loading') {
|
||||||
|
return Loading;
|
||||||
|
}
|
||||||
|
if (status === 'success') {
|
||||||
|
return CircleCheck;
|
||||||
|
}
|
||||||
|
if (status === 'error') {
|
||||||
|
return CircleClose;
|
||||||
|
}
|
||||||
|
return Select;
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeleteLlm = (id: string) => {
|
const handleDeleteLlm = (id: string) => {
|
||||||
emit('deleteLlm', id);
|
emit('deleteLlm', id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddLlm = (id: string) => {
|
|
||||||
emit('addLlm', id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditLlm = (id: string) => {
|
const handleEditLlm = (id: string) => {
|
||||||
emit('editLlm', id);
|
emit('editLlm', id);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 修改该模型为未使用状态,修改数据库的with_used字段为false
|
const canVerify = (llm: llmType) => llm.modelType !== 'rerankModel';
|
||||||
const handleUpdateWithUsedLlm = (id: string) => {
|
|
||||||
emit('updateWithUsed', id);
|
const handleVerifyLlm = async (llm: llmType) => {
|
||||||
|
const modelId = getModelId(llm);
|
||||||
|
if (!modelId || isVerifying(llm)) {
|
||||||
|
if (!modelId) {
|
||||||
|
ElMessage.warning('当前模型缺少ID,无法验证配置');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVerifyStatus(modelId, 'loading');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await verifyModelConfig(modelId);
|
||||||
|
|
||||||
|
if (res.errorCode === 0) {
|
||||||
|
setVerifyStatus(modelId, 'success');
|
||||||
|
if (llm.modelType === 'embeddingModel' && res?.data?.dimension) {
|
||||||
|
ElMessage.success(`验证成功,向量维度:${res.data.dimension}`);
|
||||||
|
} else {
|
||||||
|
ElMessage.success('验证成功');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setVerifyStatus(modelId, 'error');
|
||||||
|
if (!res.message) {
|
||||||
|
ElMessage.error('验证失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setVerifyStatus(modelId, 'error');
|
||||||
|
// error toast is already handled by global response interceptors
|
||||||
|
} finally {
|
||||||
|
// keep final status for explicit visual feedback
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取LLM支持的选中的能力标签
|
|
||||||
* 只返回 selected 为 true 的标签
|
|
||||||
*/
|
|
||||||
const getSelectedAbilityTagsForLlm = (llm: llmType): ModelAbilityItem[] => {
|
const getSelectedAbilityTagsForLlm = (llm: llmType): ModelAbilityItem[] => {
|
||||||
const defaultAbility = getDefaultModelAbility();
|
const defaultAbility = getDefaultModelAbility();
|
||||||
const allTags = mapLlmToModelAbility(llm, defaultAbility);
|
const allTags = mapLlmToModelAbility(llm, defaultAbility);
|
||||||
return allTags.filter((tag) => tag.selected);
|
return allTags.filter((tag) => tag.selected);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getModelMeta = (llm: llmType) => {
|
||||||
|
const providerName =
|
||||||
|
llm?.modelProvider?.providerName || llm?.aiLlmProvider?.providerName || '';
|
||||||
|
const modelName = llm.llmModel || llm.title;
|
||||||
|
|
||||||
|
return `${providerName} · ${modelName}`;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-for="llm in llmList" :key="llm.id" class="container">
|
<div class="llm-list">
|
||||||
<div class="llm-item">
|
<article v-for="llm in props.llmList" :key="llm.id" class="llm-item">
|
||||||
<div class="start">
|
<div class="llm-item__main">
|
||||||
<ElImage
|
<ModelProviderBadge
|
||||||
v-if="llm.modelProvider.icon"
|
:icon="llm.modelProvider?.icon"
|
||||||
:src="llm.modelProvider.icon"
|
:provider-name="llm.modelProvider?.providerName"
|
||||||
style="width: 21px; height: 21px"
|
:provider-type="llm.modelProvider?.providerType"
|
||||||
|
:size="40"
|
||||||
/>
|
/>
|
||||||
|
<div class="llm-item__content">
|
||||||
<div
|
<div class="llm-item__headline">
|
||||||
v-else
|
<h4 class="llm-item__title">{{ llm.title }}</h4>
|
||||||
v-html="getIconByValue(llm.modelProvider.providerType)"
|
<div
|
||||||
:style="{
|
v-if="getSelectedAbilityTagsForLlm(llm).length > 0"
|
||||||
width: '21px',
|
class="llm-item__tags"
|
||||||
height: '21px',
|
>
|
||||||
display: 'flex',
|
<ElTag
|
||||||
alignItems: 'center',
|
v-for="tag in getSelectedAbilityTagsForLlm(llm)"
|
||||||
justifyContent: 'center',
|
:key="tag.value"
|
||||||
overflow: 'hidden',
|
effect="plain"
|
||||||
}"
|
size="small"
|
||||||
class="svg-container"
|
class="llm-item__tag"
|
||||||
></div>
|
>
|
||||||
|
{{ tag.label }}
|
||||||
<div>{{ llm?.modelProvider?.providerName }}/{{ llm.title }}</div>
|
</ElTag>
|
||||||
|
</div>
|
||||||
<!-- 模型能力 -->
|
</div>
|
||||||
<div
|
<p class="llm-item__meta">{{ getModelMeta(llm) }}</p>
|
||||||
v-if="getSelectedAbilityTagsForLlm(llm).length > 0"
|
<p v-if="llm.description" class="llm-item__description">
|
||||||
class="ability-tags"
|
{{ llm.description }}
|
||||||
>
|
</p>
|
||||||
<ElTag
|
|
||||||
v-for="tag in getSelectedAbilityTagsForLlm(llm)"
|
|
||||||
:key="tag.value"
|
|
||||||
class="ability-tag"
|
|
||||||
:type="tag.activeType"
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
{{ tag.label }}
|
|
||||||
</ElTag>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="end">
|
|
||||||
<ElIcon
|
|
||||||
v-if="!needHiddenSettingIcon"
|
|
||||||
size="16"
|
|
||||||
@click="handleEditLlm(llm.id)"
|
|
||||||
style="cursor: pointer"
|
|
||||||
>
|
|
||||||
<Setting />
|
|
||||||
</ElIcon>
|
|
||||||
<template v-if="!isManagement">
|
|
||||||
<ElIcon
|
|
||||||
size="16"
|
|
||||||
@click="handleUpdateWithUsedLlm(llm.id)"
|
|
||||||
style="cursor: pointer"
|
|
||||||
>
|
|
||||||
<Minus />
|
|
||||||
</ElIcon>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-if="isManagement">
|
<div class="llm-item__actions">
|
||||||
<ElIcon
|
<ElButton
|
||||||
v-if="llm.withUsed"
|
v-if="canVerify(llm)"
|
||||||
size="16"
|
text
|
||||||
@click="handleDeleteLlm(llm.id)"
|
size="small"
|
||||||
style="cursor: pointer"
|
class="llm-item__verify-btn"
|
||||||
>
|
:class="`is-${getVerifyStatus(llm)}`"
|
||||||
<Minus />
|
:disabled="isVerifying(llm) || !getModelId(llm)"
|
||||||
</ElIcon>
|
@click="handleVerifyLlm(llm)"
|
||||||
<ElIcon
|
>
|
||||||
v-else
|
<template #icon>
|
||||||
size="16"
|
<ElIcon
|
||||||
@click="handleAddLlm(llm.id)"
|
class="llm-item__verify-icon"
|
||||||
style="cursor: pointer"
|
:class="`is-${getVerifyStatus(llm)}`"
|
||||||
>
|
>
|
||||||
<Plus />
|
<component :is="getVerifyButtonIcon(llm)" />
|
||||||
</ElIcon>
|
</ElIcon>
|
||||||
</template>
|
</template>
|
||||||
|
{{ getVerifyButtonText(llm) }}
|
||||||
|
</ElButton>
|
||||||
|
|
||||||
|
<ElButton
|
||||||
|
v-if="!needHiddenSettingIcon"
|
||||||
|
text
|
||||||
|
size="small"
|
||||||
|
:icon="Edit"
|
||||||
|
@click="handleEditLlm(llm.id)"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</ElButton>
|
||||||
|
|
||||||
|
<ElButton
|
||||||
|
text
|
||||||
|
size="small"
|
||||||
|
:icon="Delete"
|
||||||
|
class="llm-item__danger"
|
||||||
|
@click="handleDeleteLlm(llm.id)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</ElButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.llm-item {
|
.llm-list {
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
height: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 18px;
|
||||||
|
background: hsl(var(--surface-panel) / 90%);
|
||||||
|
border: 1px solid hsl(var(--divider-faint) / 54%);
|
||||||
|
border-radius: 18px;
|
||||||
|
transition:
|
||||||
|
border-color 0.2s ease,
|
||||||
|
transform 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-item:hover {
|
||||||
|
border-color: hsl(var(--primary) / 28%);
|
||||||
|
box-shadow: 0 18px 30px -28px hsl(var(--foreground) / 34%);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-item__main {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: flex-start;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-item__content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-item__headline {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 12px 18px;
|
|
||||||
border-bottom: 1px solid #e4e7ed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.start {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.end {
|
.llm-item__title {
|
||||||
display: flex;
|
margin: 0;
|
||||||
align-items: center;
|
font-size: 15px;
|
||||||
gap: 12px;
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: hsl(var(--text-strong));
|
||||||
}
|
}
|
||||||
|
|
||||||
.ability-tags {
|
.llm-item__meta,
|
||||||
|
.llm-item__description {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-item__description {
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-item__tags {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ability-tag {
|
.llm-item__tag {
|
||||||
cursor: default;
|
color: hsl(var(--text-strong));
|
||||||
user-select: none;
|
background: hsl(var(--primary) / 8%);
|
||||||
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.svg-container {
|
.llm-item__actions {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.svg-container :deep(svg) {
|
.llm-item__actions :deep(.el-button) {
|
||||||
width: 21px;
|
border-radius: 10px;
|
||||||
height: 21px;
|
}
|
||||||
|
|
||||||
|
.llm-item__verify-btn.is-idle {
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-item__verify-btn.is-loading {
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-item__verify-btn.is-success {
|
||||||
|
color: hsl(var(--success));
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-item__verify-btn.is-error {
|
||||||
|
color: hsl(var(--destructive));
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-item__verify-icon {
|
||||||
|
transition:
|
||||||
|
color 0.24s ease,
|
||||||
|
transform 0.24s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-item__verify-icon.is-loading {
|
||||||
|
animation: llm-item-verify-spin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-item__verify-icon.is-success,
|
||||||
|
.llm-item__verify-icon.is-error {
|
||||||
|
animation: llm-item-verify-pop 0.32s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-item__danger {
|
||||||
|
color: hsl(var(--danger) / 90%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-item__danger:hover,
|
||||||
|
.llm-item__danger:focus-visible {
|
||||||
|
color: hsl(var(--danger));
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes llm-item-verify-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes llm-item-verify-pop {
|
||||||
|
0% {
|
||||||
|
opacity: 0.72;
|
||||||
|
transform: scale(0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.llm-item {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-item__actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import {describe, expect, it} from 'vitest';
|
||||||
|
|
||||||
|
import {getProviderBadgeText, getProviderPresetByValue} from '../defaultIcon';
|
||||||
|
|
||||||
|
describe('defaultIcon helpers', () => {
|
||||||
|
it('可以读取新的服务商预设', () => {
|
||||||
|
const preset = getProviderPresetByValue('self-hosted');
|
||||||
|
|
||||||
|
expect(preset?.label).toBe('自部署');
|
||||||
|
expect(preset?.mode).toBe('self-hosted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('未知服务商类型会回退到 providerName 文本', () => {
|
||||||
|
expect(getProviderBadgeText('历史服务商', 'legacy')).toBe('历史');
|
||||||
|
expect(getProviderBadgeText('', 'legacy-provider')).toBe('LE');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import {describe, expect, it} from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createProviderDraft,
|
||||||
|
getProviderConfigMetrics,
|
||||||
|
isProviderDraftDirty,
|
||||||
|
} from '../providerDraft';
|
||||||
|
|
||||||
|
describe('providerDraft helpers', () => {
|
||||||
|
it('相同配置不会被识别为脏数据', () => {
|
||||||
|
const provider = {
|
||||||
|
apiKey: 'key',
|
||||||
|
endpoint: 'https://api.example.com',
|
||||||
|
chatPath: '/v1/chat/completions',
|
||||||
|
embedPath: '/v1/embeddings',
|
||||||
|
rerankPath: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(isProviderDraftDirty(provider, createProviderDraft(provider))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('修改任一关键字段后会被识别为脏数据', () => {
|
||||||
|
const provider = {
|
||||||
|
apiKey: 'key',
|
||||||
|
endpoint: 'https://api.example.com',
|
||||||
|
chatPath: '/v1/chat/completions',
|
||||||
|
embedPath: '',
|
||||||
|
rerankPath: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isProviderDraftDirty(provider, {
|
||||||
|
...createProviderDraft(provider),
|
||||||
|
apiKey: 'next-key',
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('会根据预设路径计算配置完成度', () => {
|
||||||
|
const metrics = getProviderConfigMetrics(
|
||||||
|
{
|
||||||
|
apiKey: 'key',
|
||||||
|
endpoint: 'https://api.example.com',
|
||||||
|
chatPath: '/v1/chat/completions',
|
||||||
|
embedPath: '/v1/embeddings',
|
||||||
|
rerankPath: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '示例',
|
||||||
|
value: 'demo',
|
||||||
|
description: '',
|
||||||
|
icon: '',
|
||||||
|
mode: 'hosted',
|
||||||
|
options: {
|
||||||
|
chatPath: '/v1/chat/completions',
|
||||||
|
embedPath: '/v1/embeddings',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(metrics.completed).toBe(4);
|
||||||
|
expect(metrics.total).toBe(4);
|
||||||
|
expect(metrics.complete).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,27 +1,88 @@
|
|||||||
import { ref } from 'vue';
|
|
||||||
|
|
||||||
import providerList from './providerList.json';
|
import providerList from './providerList.json';
|
||||||
|
|
||||||
const providerOptions =
|
const assetBase = import.meta.env.BASE_URL || '/';
|
||||||
ref<Array<{ icon: string; label: string; options: any; value: string }>>(
|
|
||||||
providerList,
|
export interface ProviderModelPreset {
|
||||||
);
|
description: string;
|
||||||
|
label: string;
|
||||||
|
llmModel: string;
|
||||||
|
supportChat?: boolean;
|
||||||
|
supportEmbed?: boolean;
|
||||||
|
supportFunctionCalling?: boolean;
|
||||||
|
supportRerank?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderPreset {
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
mode: 'hosted' | 'self-hosted';
|
||||||
|
options: {
|
||||||
|
chatPath?: string;
|
||||||
|
embedPath?: string;
|
||||||
|
llmEndpoint?: string;
|
||||||
|
modelList?: ProviderModelPreset[];
|
||||||
|
rerankPath?: string;
|
||||||
|
};
|
||||||
|
tags?: string[];
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerOptions = providerList as ProviderPreset[];
|
||||||
|
|
||||||
|
const normalizeAssetUrl = (url?: string) => {
|
||||||
|
if (!url || !url.startsWith('/')) {
|
||||||
|
return url || '';
|
||||||
|
}
|
||||||
|
return `${assetBase}${url.slice(1)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getProviderPresetByValue = (targetValue?: string) => {
|
||||||
|
const preset = providerOptions.find((item) => item.value === targetValue);
|
||||||
|
|
||||||
|
if (!preset) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...preset,
|
||||||
|
icon: normalizeAssetUrl(preset.icon),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据传入的value,返回对应的icon属性
|
* 根据传入的value,返回对应的icon属性
|
||||||
* @param targetValue 要匹配的value值
|
* @param targetValue 要匹配的value值
|
||||||
* @returns 匹配到的icon字符串,未匹配到返回空字符串
|
* @returns 匹配到的icon字符串,未匹配到返回空字符串
|
||||||
*/
|
*/
|
||||||
export const getIconByValue = (targetValue: string): string => {
|
export const getIconByValue = (targetValue: string): string =>
|
||||||
const matchItem = providerOptions.value.find(
|
getProviderPresetByValue(targetValue)?.icon || '';
|
||||||
(item) => item.value === targetValue,
|
|
||||||
);
|
|
||||||
|
|
||||||
return matchItem?.icon || '';
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isSvgString = (icon: any) => {
|
export const isSvgString = (icon: any) => {
|
||||||
if (typeof icon !== 'string') return false;
|
if (typeof icon !== 'string') return false;
|
||||||
// 简单判断:是否包含 SVG 根标签
|
// 简单判断:是否包含 SVG 根标签
|
||||||
return icon.trim().startsWith('<svg') && icon.trim().endsWith('</svg>');
|
return icon.trim().startsWith('<svg') && icon.trim().endsWith('</svg>');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getProviderBadgeText = (
|
||||||
|
providerName?: string,
|
||||||
|
providerType?: string,
|
||||||
|
) => {
|
||||||
|
const preset = getProviderPresetByValue(providerType);
|
||||||
|
const source = providerName || preset?.label || providerType || 'AI';
|
||||||
|
const ascii = source
|
||||||
|
.replaceAll(/[^a-z]/gi, '')
|
||||||
|
.slice(0, 2)
|
||||||
|
.toUpperCase();
|
||||||
|
|
||||||
|
if (ascii) {
|
||||||
|
return ascii;
|
||||||
|
}
|
||||||
|
|
||||||
|
return source.replaceAll(/\s+/g, '').slice(0, 2).toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const providerPresets = providerOptions.map((item) => ({
|
||||||
|
...item,
|
||||||
|
icon: normalizeAssetUrl(item.icon),
|
||||||
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type {ProviderPreset} from './defaultIcon';
|
||||||
|
|
||||||
|
export const PROVIDER_EDITABLE_FIELDS = [
|
||||||
|
'apiKey',
|
||||||
|
'endpoint',
|
||||||
|
'chatPath',
|
||||||
|
'embedPath',
|
||||||
|
'rerankPath',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ProviderEditableField = (typeof PROVIDER_EDITABLE_FIELDS)[number];
|
||||||
|
|
||||||
|
export interface ProviderDraft {
|
||||||
|
apiKey: string;
|
||||||
|
endpoint: string;
|
||||||
|
chatPath: string;
|
||||||
|
embedPath: string;
|
||||||
|
rerankPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProviderLike = Partial<Record<ProviderEditableField, string>> & {
|
||||||
|
providerType?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createProviderDraft = (
|
||||||
|
provider?: null | ProviderLike,
|
||||||
|
): ProviderDraft => ({
|
||||||
|
apiKey: provider?.apiKey || '',
|
||||||
|
endpoint: provider?.endpoint || '',
|
||||||
|
chatPath: provider?.chatPath || '',
|
||||||
|
embedPath: provider?.embedPath || '',
|
||||||
|
rerankPath: provider?.rerankPath || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const isProviderDraftDirty = (
|
||||||
|
provider?: null | ProviderLike,
|
||||||
|
draft?: null | ProviderLike,
|
||||||
|
) =>
|
||||||
|
PROVIDER_EDITABLE_FIELDS.some(
|
||||||
|
(field) => (provider?.[field] || '') !== (draft?.[field] || ''),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getProviderConfigMetrics = (
|
||||||
|
provider?: null | ProviderLike,
|
||||||
|
preset?: ProviderPreset,
|
||||||
|
) => {
|
||||||
|
const requiredFields: ProviderEditableField[] = [
|
||||||
|
'apiKey',
|
||||||
|
'endpoint',
|
||||||
|
'chatPath',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (preset?.options?.embedPath || provider?.embedPath) {
|
||||||
|
requiredFields.push('embedPath');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preset?.options?.rerankPath || provider?.rerankPath) {
|
||||||
|
requiredFields.push('rerankPath');
|
||||||
|
}
|
||||||
|
|
||||||
|
const completed = requiredFields.filter((field) =>
|
||||||
|
Boolean((provider?.[field] || '').trim()),
|
||||||
|
).length;
|
||||||
|
const total = requiredFields.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
completed,
|
||||||
|
total,
|
||||||
|
ratio: total === 0 ? 0 : Math.round((completed / total) * 100),
|
||||||
|
complete: total > 0 && completed === total,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,11 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { FormInstance } from 'element-plus';
|
import type {FormInstance} from 'element-plus';
|
||||||
|
|
||||||
import { onMounted, ref } from 'vue';
|
|
||||||
|
|
||||||
import { EasyFlowFormModal } from '@easyflow/common-ui';
|
|
||||||
|
|
||||||
import { Plus, Remove } from '@element-plus/icons-vue';
|
|
||||||
import {
|
import {
|
||||||
ElForm,
|
ElForm,
|
||||||
ElFormItem,
|
ElFormItem,
|
||||||
@@ -18,13 +12,20 @@ import {
|
|||||||
ElSelect,
|
ElSelect,
|
||||||
} from 'element-plus';
|
} from 'element-plus';
|
||||||
|
|
||||||
import { api } from '#/api/request';
|
import {onMounted, ref} from 'vue';
|
||||||
|
|
||||||
|
import {EasyFlowFormModal} from '@easyflow/common-ui';
|
||||||
|
|
||||||
|
import {Plus, Remove} from '@element-plus/icons-vue';
|
||||||
|
|
||||||
|
import {api} from '#/api/request';
|
||||||
import UploadAvatar from '#/components/upload/UploadAvatar.vue';
|
import UploadAvatar from '#/components/upload/UploadAvatar.vue';
|
||||||
import { $t } from '#/locales';
|
import {$t} from '#/locales';
|
||||||
|
|
||||||
const emit = defineEmits(['reload']);
|
const emit = defineEmits(['reload']);
|
||||||
const embeddingLlmList = ref<any>([]);
|
const embeddingLlmList = ref<any>([]);
|
||||||
const rerankerLlmList = ref<any>([]);
|
const rerankerLlmList = ref<any>([]);
|
||||||
|
const categoryList = ref<any[]>([]);
|
||||||
interface headersType {
|
interface headersType {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -46,6 +47,11 @@ onMounted(() => {
|
|||||||
api.get('/api/v1/model/list?supportRerankerLlmList=true').then((res) => {
|
api.get('/api/v1/model/list?supportRerankerLlmList=true').then((res) => {
|
||||||
rerankerLlmList.value = res.data;
|
rerankerLlmList.value = res.data;
|
||||||
});
|
});
|
||||||
|
api.get('/api/v1/pluginCategory/list').then((res) => {
|
||||||
|
if (res.errorCode === 0) {
|
||||||
|
categoryList.value = res.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
defineExpose({
|
defineExpose({
|
||||||
openDialog,
|
openDialog,
|
||||||
@@ -57,6 +63,7 @@ const isAdd = ref(true);
|
|||||||
const tempAddHeaders = ref<headersType[]>([]);
|
const tempAddHeaders = ref<headersType[]>([]);
|
||||||
const entity = ref<any>({
|
const entity = ref<any>({
|
||||||
alias: '',
|
alias: '',
|
||||||
|
categoryIds: [],
|
||||||
deptId: '',
|
deptId: '',
|
||||||
icon: '',
|
icon: '',
|
||||||
title: '',
|
title: '',
|
||||||
@@ -91,6 +98,7 @@ const rules = ref({
|
|||||||
|
|
||||||
// functions
|
// functions
|
||||||
function openDialog(row: any) {
|
function openDialog(row: any) {
|
||||||
|
tempAddHeaders.value = [];
|
||||||
if (row.id) {
|
if (row.id) {
|
||||||
isAdd.value = false;
|
isAdd.value = false;
|
||||||
if (row.headers) {
|
if (row.headers) {
|
||||||
@@ -100,26 +108,62 @@ function openDialog(row: any) {
|
|||||||
entity.value = {
|
entity.value = {
|
||||||
...row,
|
...row,
|
||||||
authType: row.authType || 'none',
|
authType: row.authType || 'none',
|
||||||
|
categoryIds: row.categoryIds?.map((item: any) => item.id) || [],
|
||||||
};
|
};
|
||||||
dialogVisible.value = true;
|
dialogVisible.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function syncPluginCategories(pluginId: string, categoryIds: string[]) {
|
||||||
|
if (!pluginId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const relationRes = await api.post(
|
||||||
|
'/api/v1/pluginCategoryMapping/updateRelation',
|
||||||
|
{
|
||||||
|
pluginId,
|
||||||
|
categoryIds,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (relationRes.errorCode !== 0) {
|
||||||
|
throw new Error(relationRes.message || 'sync categories failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function save() {
|
function save() {
|
||||||
saveForm.value?.validate((valid) => {
|
saveForm.value?.validate((valid) => {
|
||||||
if (valid) {
|
if (valid) {
|
||||||
|
btnLoading.value = true;
|
||||||
const plainEntity = { ...entity.value };
|
const plainEntity = { ...entity.value };
|
||||||
const plainHeaders = [...tempAddHeaders.value];
|
const plainHeaders = [...tempAddHeaders.value];
|
||||||
|
const categoryIds = [...(plainEntity.categoryIds || [])];
|
||||||
|
delete plainEntity.categoryIds;
|
||||||
if (isAdd.value) {
|
if (isAdd.value) {
|
||||||
api
|
api
|
||||||
.post('/api/v1/plugin/plugin/save', {
|
.post('/api/v1/plugin/plugin/save', {
|
||||||
...plainEntity,
|
...plainEntity,
|
||||||
headers: plainHeaders,
|
headers: plainHeaders,
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then(async (res) => {
|
||||||
if (res.errorCode === 0) {
|
if (res.errorCode === 0) {
|
||||||
|
const pluginId =
|
||||||
|
res.data?.id ||
|
||||||
|
res.data ||
|
||||||
|
plainEntity.id ||
|
||||||
|
entity.value.id ||
|
||||||
|
'';
|
||||||
|
await syncPluginCategories(pluginId, categoryIds);
|
||||||
dialogVisible.value = false;
|
dialogVisible.value = false;
|
||||||
ElMessage.success($t('message.saveOkMessage'));
|
ElMessage.success($t('message.saveOkMessage'));
|
||||||
emit('reload');
|
emit('reload');
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.message);
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
ElMessage.error(error?.message || $t('message.saveFailMessage'));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
btnLoading.value = false;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
api
|
api
|
||||||
@@ -127,12 +171,21 @@ function save() {
|
|||||||
...plainEntity,
|
...plainEntity,
|
||||||
headers: plainHeaders,
|
headers: plainHeaders,
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then(async (res) => {
|
||||||
if (res.errorCode === 0) {
|
if (res.errorCode === 0) {
|
||||||
|
await syncPluginCategories(entity.value.id, categoryIds);
|
||||||
dialogVisible.value = false;
|
dialogVisible.value = false;
|
||||||
ElMessage.success($t('message.updateOkMessage'));
|
ElMessage.success($t('message.updateOkMessage'));
|
||||||
emit('reload');
|
emit('reload');
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.message);
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
ElMessage.error(error?.message || $t('message.saveFailMessage'));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
btnLoading.value = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,6 +194,7 @@ function save() {
|
|||||||
function closeDialog() {
|
function closeDialog() {
|
||||||
saveForm.value?.resetFields();
|
saveForm.value?.resetFields();
|
||||||
isAdd.value = true;
|
isAdd.value = true;
|
||||||
|
tempAddHeaders.value = [];
|
||||||
entity.value = {};
|
entity.value = {};
|
||||||
dialogVisible.value = false;
|
dialogVisible.value = false;
|
||||||
}
|
}
|
||||||
@@ -199,6 +253,22 @@ function removeHeader(index: number) {
|
|||||||
:placeholder="$t('plugin.placeholder.description')"
|
:placeholder="$t('plugin.placeholder.description')"
|
||||||
/>
|
/>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
|
<ElFormItem prop="categoryIds" :label="$t('plugin.category')">
|
||||||
|
<ElSelect
|
||||||
|
v-model="entity.categoryIds"
|
||||||
|
multiple
|
||||||
|
collapse-tags
|
||||||
|
collapse-tags-tooltip
|
||||||
|
:max-collapse-tags="3"
|
||||||
|
>
|
||||||
|
<ElOption
|
||||||
|
v-for="item in categoryList"
|
||||||
|
:key="item.id"
|
||||||
|
:label="item.name"
|
||||||
|
:value="item.id"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
<ElFormItem prop="Headers" label="Headers">
|
<ElFormItem prop="Headers" label="Headers">
|
||||||
<div
|
<div
|
||||||
class="headers-container-reduce flex flex-row gap-4"
|
class="headers-container-reduce flex flex-row gap-4"
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ActionButton } from '#/components/page/CardList.vue';
|
import type {
|
||||||
|
ActionButton,
|
||||||
|
CardPrimaryAction,
|
||||||
|
} from '#/components/page/CardList.vue';
|
||||||
|
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
@@ -19,13 +22,11 @@ import {
|
|||||||
import { api } from '#/api/request';
|
import { api } from '#/api/request';
|
||||||
import defaultPluginIcon from '#/assets/ai/plugin/defaultPluginIcon.png';
|
import defaultPluginIcon from '#/assets/ai/plugin/defaultPluginIcon.png';
|
||||||
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
|
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
|
||||||
import CategorizeIcon from '#/components/icons/CategorizeIcon.vue';
|
|
||||||
import PluginToolIcon from '#/components/icons/PluginToolIcon.vue';
|
import PluginToolIcon from '#/components/icons/PluginToolIcon.vue';
|
||||||
import CardPage from '#/components/page/CardList.vue';
|
import CardPage from '#/components/page/CardList.vue';
|
||||||
import PageData from '#/components/page/PageData.vue';
|
import PageData from '#/components/page/PageData.vue';
|
||||||
import PageSide from '#/components/page/PageSide.vue';
|
import PageSide from '#/components/page/PageSide.vue';
|
||||||
import AddPluginModal from '#/views/ai/plugin/AddPluginModal.vue';
|
import AddPluginModal from '#/views/ai/plugin/AddPluginModal.vue';
|
||||||
import CategoryPluginModal from '#/views/ai/plugin/CategoryPluginModal.vue';
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -52,47 +53,42 @@ function resolveNavTitle(item: PluginRecord) {
|
|||||||
return (item.title as string) || (item.name as string) || '';
|
return (item.title as string) || (item.name as string) || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 操作按钮配置
|
function openPluginTools(item: PluginRecord) {
|
||||||
|
router.push({
|
||||||
|
path: '/ai/plugin/tools',
|
||||||
|
query: {
|
||||||
|
id: item.id,
|
||||||
|
pageKey: '/ai/plugin',
|
||||||
|
navTitle: resolveNavTitle(item),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryAction: CardPrimaryAction = {
|
||||||
|
icon: PluginToolIcon,
|
||||||
|
text: $t('plugin.button.tools'),
|
||||||
|
permission: '/api/v1/plugin/save',
|
||||||
|
onClick(item) {
|
||||||
|
openPluginTools(item);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const actions: ActionButton[] = [
|
const actions: ActionButton[] = [
|
||||||
{
|
{
|
||||||
icon: Edit,
|
icon: Edit,
|
||||||
text: $t('button.edit'),
|
text: $t('button.edit'),
|
||||||
className: '',
|
|
||||||
permission: '/api/v1/plugin/save',
|
permission: '/api/v1/plugin/save',
|
||||||
|
placement: 'inline',
|
||||||
onClick(item) {
|
onClick(item) {
|
||||||
aiPluginModalRef.value.openDialog(item);
|
aiPluginModalRef.value.openDialog(item);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
icon: PluginToolIcon,
|
|
||||||
text: $t('plugin.button.tools'),
|
|
||||||
className: '',
|
|
||||||
permission: '/api/v1/plugin/save',
|
|
||||||
onClick(item) {
|
|
||||||
router.push({
|
|
||||||
path: '/ai/plugin/tools',
|
|
||||||
query: {
|
|
||||||
id: item.id,
|
|
||||||
pageKey: '/ai/plugin',
|
|
||||||
navTitle: resolveNavTitle(item),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: CategorizeIcon,
|
|
||||||
text: $t('plugin.button.categorize'),
|
|
||||||
className: '',
|
|
||||||
permission: '/api/v1/plugin/save',
|
|
||||||
onClick(item) {
|
|
||||||
categoryCategoryModal.value.openDialog(item);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
icon: Delete,
|
icon: Delete,
|
||||||
text: $t('button.delete'),
|
text: $t('button.delete'),
|
||||||
className: 'item-danger',
|
tone: 'danger',
|
||||||
permission: '/api/v1/plugin/remove',
|
permission: '/api/v1/plugin/remove',
|
||||||
|
placement: 'inline',
|
||||||
onClick(item) {
|
onClick(item) {
|
||||||
handleDelete(item);
|
handleDelete(item);
|
||||||
},
|
},
|
||||||
@@ -162,7 +158,6 @@ const handleDelete = (item: PluginRecord) => {
|
|||||||
|
|
||||||
const pageDataRef = ref();
|
const pageDataRef = ref();
|
||||||
const aiPluginModalRef = ref();
|
const aiPluginModalRef = ref();
|
||||||
const categoryCategoryModal = ref();
|
|
||||||
const headerButtons = [
|
const headerButtons = [
|
||||||
{
|
{
|
||||||
key: 'add',
|
key: 'add',
|
||||||
@@ -267,10 +262,11 @@ const handleClickCategory = (item: PluginCategory) => {
|
|||||||
>
|
>
|
||||||
<template #default="{ pageList }">
|
<template #default="{ pageList }">
|
||||||
<CardPage
|
<CardPage
|
||||||
title-key="title"
|
title-field="title"
|
||||||
avatar-key="icon"
|
icon-field="icon"
|
||||||
description-key="description"
|
desc-field="description"
|
||||||
:data="pageList"
|
:data="pageList"
|
||||||
|
:primary-action="primaryAction"
|
||||||
:actions="actions"
|
:actions="actions"
|
||||||
:default-icon="defaultPluginIcon"
|
:default-icon="defaultPluginIcon"
|
||||||
/>
|
/>
|
||||||
@@ -279,7 +275,6 @@ const handleClickCategory = (item: PluginCategory) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AddPluginModal ref="aiPluginModalRef" @reload="handleSearch" />
|
<AddPluginModal ref="aiPluginModalRef" @reload="handleSearch" />
|
||||||
<CategoryPluginModal ref="categoryCategoryModal" @reload="handleSearch" />
|
|
||||||
<EasyFlowFormModal
|
<EasyFlowFormModal
|
||||||
:title="isEdit ? `${$t('button.edit')}` : `${$t('button.add')}`"
|
:title="isEdit ? `${$t('button.edit')}` : `${$t('button.add')}`"
|
||||||
v-model:open="dialogVisible"
|
v-model:open="dialogVisible"
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { FormInstance } from 'element-plus';
|
import type {FormInstance} from 'element-plus';
|
||||||
|
import {ElForm, ElFormItem, ElInput, ElInputNumber, ElMessage, ElMessageBox,} from 'element-plus';
|
||||||
|
|
||||||
import type { ActionButton } from '#/components/page/CardList.vue';
|
import type {ActionButton, CardPrimaryAction,} from '#/components/page/CardList.vue';
|
||||||
|
import CardList from '#/components/page/CardList.vue';
|
||||||
|
|
||||||
import { computed, markRaw, onMounted, ref } from 'vue';
|
import {computed, markRaw, onMounted, ref} from 'vue';
|
||||||
|
|
||||||
import { EasyFlowFormModal } from '@easyflow/common-ui';
|
import {EasyFlowFormModal} from '@easyflow/common-ui';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CopyDocument,
|
CopyDocument,
|
||||||
@@ -17,27 +19,18 @@ import {
|
|||||||
Upload,
|
Upload,
|
||||||
VideoPlay,
|
VideoPlay,
|
||||||
} from '@element-plus/icons-vue';
|
} from '@element-plus/icons-vue';
|
||||||
import {
|
import {tryit} from 'radash';
|
||||||
ElForm,
|
|
||||||
ElFormItem,
|
|
||||||
ElInput,
|
|
||||||
ElInputNumber,
|
|
||||||
ElMessage,
|
|
||||||
ElMessageBox,
|
|
||||||
} from 'element-plus';
|
|
||||||
import { tryit } from 'radash';
|
|
||||||
|
|
||||||
import { api } from '#/api/request';
|
import {api} from '#/api/request';
|
||||||
import workflowIcon from '#/assets/ai/workflow/workflowIcon.png';
|
import workflowIcon from '#/assets/ai/workflow/workflowIcon.png';
|
||||||
// import workflowSvg from '#/assets/workflow.svg';
|
// import workflowSvg from '#/assets/workflow.svg';
|
||||||
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
|
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
|
||||||
import DesignIcon from '#/components/icons/DesignIcon.vue';
|
import DesignIcon from '#/components/icons/DesignIcon.vue';
|
||||||
import CardList from '#/components/page/CardList.vue';
|
|
||||||
import PageData from '#/components/page/PageData.vue';
|
import PageData from '#/components/page/PageData.vue';
|
||||||
import PageSide from '#/components/page/PageSide.vue';
|
import PageSide from '#/components/page/PageSide.vue';
|
||||||
import { $t } from '#/locales';
|
import {$t} from '#/locales';
|
||||||
import { router } from '#/router';
|
import {router} from '#/router';
|
||||||
import { useDictStore } from '#/store';
|
import {useDictStore} from '#/store';
|
||||||
|
|
||||||
import WorkflowModal from './WorkflowModal.vue';
|
import WorkflowModal from './WorkflowModal.vue';
|
||||||
|
|
||||||
@@ -54,30 +47,29 @@ interface FieldDefinition {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const primaryAction: CardPrimaryAction = {
|
||||||
|
icon: DesignIcon,
|
||||||
|
text: $t('button.design'),
|
||||||
|
permission: '/api/v1/workflow/save',
|
||||||
|
onClick: (row: any) => {
|
||||||
|
toDesignPage(row);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const actions: ActionButton[] = [
|
const actions: ActionButton[] = [
|
||||||
{
|
{
|
||||||
icon: Edit,
|
icon: Edit,
|
||||||
text: $t('button.edit'),
|
text: $t('button.edit'),
|
||||||
className: '',
|
|
||||||
permission: '/api/v1/workflow/save',
|
permission: '/api/v1/workflow/save',
|
||||||
|
placement: 'inline',
|
||||||
onClick: (row: any) => {
|
onClick: (row: any) => {
|
||||||
showDialog(row);
|
showDialog(row);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
icon: DesignIcon,
|
|
||||||
text: $t('button.design'),
|
|
||||||
className: '',
|
|
||||||
permission: '/api/v1/workflow/save',
|
|
||||||
onClick: (row: any) => {
|
|
||||||
toDesignPage(row);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
icon: VideoPlay,
|
icon: VideoPlay,
|
||||||
text: $t('button.run'),
|
text: $t('button.run'),
|
||||||
className: '',
|
placement: 'inline',
|
||||||
permission: '',
|
|
||||||
onClick: (row: any) => {
|
onClick: (row: any) => {
|
||||||
router.push({
|
router.push({
|
||||||
name: 'RunPage',
|
name: 'RunPage',
|
||||||
@@ -90,8 +82,8 @@ const actions: ActionButton[] = [
|
|||||||
{
|
{
|
||||||
icon: Tickets,
|
icon: Tickets,
|
||||||
text: $t('aiWorkflowExecRecord.moduleName'),
|
text: $t('aiWorkflowExecRecord.moduleName'),
|
||||||
className: '',
|
|
||||||
permission: '/api/v1/workflow/save',
|
permission: '/api/v1/workflow/save',
|
||||||
|
placement: 'menu',
|
||||||
onClick: (row: any) => {
|
onClick: (row: any) => {
|
||||||
router.push({
|
router.push({
|
||||||
name: 'ExecRecord',
|
name: 'ExecRecord',
|
||||||
@@ -104,8 +96,7 @@ const actions: ActionButton[] = [
|
|||||||
{
|
{
|
||||||
icon: Download,
|
icon: Download,
|
||||||
text: $t('button.export'),
|
text: $t('button.export'),
|
||||||
className: '',
|
placement: 'menu',
|
||||||
permission: '',
|
|
||||||
onClick: (row: any) => {
|
onClick: (row: any) => {
|
||||||
exportJson(row);
|
exportJson(row);
|
||||||
},
|
},
|
||||||
@@ -113,8 +104,7 @@ const actions: ActionButton[] = [
|
|||||||
{
|
{
|
||||||
icon: CopyDocument,
|
icon: CopyDocument,
|
||||||
text: $t('button.copy'),
|
text: $t('button.copy'),
|
||||||
className: '',
|
placement: 'menu',
|
||||||
permission: '',
|
|
||||||
onClick: (row: any) => {
|
onClick: (row: any) => {
|
||||||
showDialog({
|
showDialog({
|
||||||
title: `${row.title}Copy`,
|
title: `${row.title}Copy`,
|
||||||
@@ -125,8 +115,8 @@ const actions: ActionButton[] = [
|
|||||||
{
|
{
|
||||||
icon: Delete,
|
icon: Delete,
|
||||||
text: $t('button.delete'),
|
text: $t('button.delete'),
|
||||||
className: 'item-danger',
|
tone: 'danger',
|
||||||
permission: '',
|
placement: 'inline',
|
||||||
onClick: (row: any) => {
|
onClick: (row: any) => {
|
||||||
remove(row);
|
remove(row);
|
||||||
},
|
},
|
||||||
@@ -402,6 +392,7 @@ function handleHeaderButtonClick(data: any) {
|
|||||||
<CardList
|
<CardList
|
||||||
:default-icon="workflowIcon"
|
:default-icon="workflowIcon"
|
||||||
:data="pageList"
|
:data="pageList"
|
||||||
|
:primary-action="primaryAction"
|
||||||
:actions="actions"
|
:actions="actions"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {ServerSentEventMessage} from 'fetch-event-stream';
|
|||||||
|
|
||||||
import {computed, nextTick, onBeforeUnmount, onMounted, ref, watch} from 'vue';
|
import {computed, nextTick, onBeforeUnmount, onMounted, ref, watch} from 'vue';
|
||||||
import {useRoute} from 'vue-router';
|
import {useRoute} from 'vue-router';
|
||||||
|
import ElXMarkdown from 'vue-element-plus-x/es/XMarkdown/index.js';
|
||||||
|
|
||||||
import {IconifyIcon} from '@easyflow/icons';
|
import {IconifyIcon} from '@easyflow/icons';
|
||||||
import {$t} from '@easyflow/locales';
|
import {$t} from '@easyflow/locales';
|
||||||
|
|||||||
@@ -0,0 +1,362 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { UploadFile } from 'element-plus';
|
||||||
|
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import { EasyFlowPanelModal } from '@easyflow/common-ui';
|
||||||
|
import { downloadFileFromBlob } from '@easyflow/utils';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CircleCloseFilled,
|
||||||
|
Document,
|
||||||
|
Download,
|
||||||
|
SuccessFilled,
|
||||||
|
UploadFilled,
|
||||||
|
WarningFilled,
|
||||||
|
} from '@element-plus/icons-vue';
|
||||||
|
import {
|
||||||
|
ElButton,
|
||||||
|
ElIcon,
|
||||||
|
ElMessage,
|
||||||
|
ElTable,
|
||||||
|
ElTableColumn,
|
||||||
|
ElUpload,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
|
import { api } from '#/api/request';
|
||||||
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
|
const emit = defineEmits(['reload']);
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
openDialog,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dialogVisible = ref(false);
|
||||||
|
const fileList = ref<any[]>([]);
|
||||||
|
const currentFile = ref<File | null>(null);
|
||||||
|
const submitLoading = ref(false);
|
||||||
|
const downloadLoading = ref(false);
|
||||||
|
const importResult = ref<any>(null);
|
||||||
|
|
||||||
|
const hasErrors = computed(() => (importResult.value?.errorCount || 0) > 0);
|
||||||
|
const selectedFileName = computed(() => currentFile.value?.name || '');
|
||||||
|
|
||||||
|
function openDialog() {
|
||||||
|
dialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDialog() {
|
||||||
|
if (submitLoading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dialogVisible.value = false;
|
||||||
|
resetDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetDialog() {
|
||||||
|
fileList.value = [];
|
||||||
|
currentFile.value = null;
|
||||||
|
submitLoading.value = false;
|
||||||
|
downloadLoading.value = false;
|
||||||
|
importResult.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileChange(uploadFile: UploadFile) {
|
||||||
|
currentFile.value = uploadFile.raw || null;
|
||||||
|
fileList.value = uploadFile.raw ? [uploadFile] : [];
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelectedFile() {
|
||||||
|
currentFile.value = null;
|
||||||
|
fileList.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadTemplate() {
|
||||||
|
downloadLoading.value = true;
|
||||||
|
try {
|
||||||
|
const blob = await api.download('/api/v1/sysAccount/downloadImportTemplate');
|
||||||
|
downloadFileFromBlob({
|
||||||
|
fileName: 'user_import_template.xlsx',
|
||||||
|
source: blob,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
downloadLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImport() {
|
||||||
|
if (!currentFile.value) {
|
||||||
|
ElMessage.warning($t('sysAccount.importSelectFileRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', currentFile.value);
|
||||||
|
submitLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await api.postFile('/api/v1/sysAccount/importExcel', formData, {
|
||||||
|
timeout: 10 * 60 * 1000,
|
||||||
|
});
|
||||||
|
if (res.errorCode === 0) {
|
||||||
|
importResult.value = res.data;
|
||||||
|
ElMessage.success($t('sysAccount.importFinished'));
|
||||||
|
emit('reload');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
submitLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<EasyFlowPanelModal
|
||||||
|
v-model:open="dialogVisible"
|
||||||
|
width="min(960px, 92vw)"
|
||||||
|
:title="$t('sysAccount.importTitle')"
|
||||||
|
:before-close="closeDialog"
|
||||||
|
:show-cancel-button="false"
|
||||||
|
:show-confirm-button="false"
|
||||||
|
>
|
||||||
|
<div class="sys-account-import-dialog">
|
||||||
|
<ElUpload
|
||||||
|
:file-list="fileList"
|
||||||
|
drag
|
||||||
|
action="#"
|
||||||
|
accept=".xlsx,.xls"
|
||||||
|
:auto-upload="false"
|
||||||
|
:on-change="onFileChange"
|
||||||
|
:limit="1"
|
||||||
|
:show-file-list="false"
|
||||||
|
class="sys-account-upload-area"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center justify-center gap-2 px-8 py-10 text-center"
|
||||||
|
>
|
||||||
|
<ElIcon class="text-4xl text-[var(--el-text-color-secondary)]">
|
||||||
|
<UploadFilled />
|
||||||
|
</ElIcon>
|
||||||
|
<div class="text-[15px] font-semibold text-[var(--el-text-color-primary)]">
|
||||||
|
{{ $t('sysAccount.importUploadTitle') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-[13px] text-[var(--el-text-color-secondary)]">
|
||||||
|
{{ $t('sysAccount.importUploadDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElUpload>
|
||||||
|
|
||||||
|
<div v-if="selectedFileName" class="selected-file-card">
|
||||||
|
<div class="selected-file-main">
|
||||||
|
<ElIcon class="selected-file-icon"><Document /></ElIcon>
|
||||||
|
<div class="selected-file-name" :title="selectedFileName">
|
||||||
|
{{ selectedFileName }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ElButton
|
||||||
|
link
|
||||||
|
:icon="CircleCloseFilled"
|
||||||
|
class="remove-file-btn"
|
||||||
|
@click="clearSelectedFile"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="importResult" class="result-wrap">
|
||||||
|
<div class="result-head">
|
||||||
|
<div class="result-title-wrap">
|
||||||
|
<ElIcon
|
||||||
|
v-if="hasErrors"
|
||||||
|
class="result-state-icon text-[var(--el-color-warning)]"
|
||||||
|
>
|
||||||
|
<WarningFilled />
|
||||||
|
</ElIcon>
|
||||||
|
<ElIcon
|
||||||
|
v-else
|
||||||
|
class="result-state-icon text-[var(--el-color-success)]"
|
||||||
|
>
|
||||||
|
<SuccessFilled />
|
||||||
|
</ElIcon>
|
||||||
|
<span class="result-title-text">
|
||||||
|
{{ $t('sysAccount.importResultTitle') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="result-stats">
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-label">{{ $t('sysAccount.importTotalCount') }}</div>
|
||||||
|
<div class="stat-value">{{ importResult.totalCount || 0 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-label">{{ $t('sysAccount.importSuccessCount') }}</div>
|
||||||
|
<div class="stat-value success-text">
|
||||||
|
{{ importResult.successCount || 0 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-label">{{ $t('sysAccount.importErrorCount') }}</div>
|
||||||
|
<div class="stat-value" :class="hasErrors ? 'danger-text' : ''">
|
||||||
|
{{ importResult.errorCount || 0 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElTable
|
||||||
|
v-if="hasErrors"
|
||||||
|
:data="importResult.errorRows || []"
|
||||||
|
size="small"
|
||||||
|
class="result-error-table"
|
||||||
|
>
|
||||||
|
<ElTableColumn
|
||||||
|
prop="rowNumber"
|
||||||
|
:label="$t('sysAccount.importRowNumber')"
|
||||||
|
width="96"
|
||||||
|
/>
|
||||||
|
<ElTableColumn
|
||||||
|
prop="deptCode"
|
||||||
|
:label="$t('sysAccount.importDeptCode')"
|
||||||
|
min-width="140"
|
||||||
|
show-overflow-tooltip
|
||||||
|
/>
|
||||||
|
<ElTableColumn
|
||||||
|
prop="loginName"
|
||||||
|
:label="$t('sysAccount.loginName')"
|
||||||
|
min-width="160"
|
||||||
|
show-overflow-tooltip
|
||||||
|
/>
|
||||||
|
<ElTableColumn
|
||||||
|
prop="reason"
|
||||||
|
:label="$t('sysAccount.importReason')"
|
||||||
|
min-width="260"
|
||||||
|
show-overflow-tooltip
|
||||||
|
/>
|
||||||
|
</ElTable>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<ElButton @click="closeDialog">
|
||||||
|
{{ $t('button.cancel') }}
|
||||||
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
:icon="Download"
|
||||||
|
:disabled="downloadLoading"
|
||||||
|
@click="downloadTemplate"
|
||||||
|
>
|
||||||
|
{{ $t('sysAccount.downloadTemplate') }}
|
||||||
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
type="primary"
|
||||||
|
:loading="submitLoading"
|
||||||
|
:disabled="submitLoading"
|
||||||
|
@click="handleImport"
|
||||||
|
>
|
||||||
|
{{ $t('button.startImport') }}
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</EasyFlowPanelModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sys-account-import-dialog {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-account-upload-area :deep(.el-upload-dragger) {
|
||||||
|
border-radius: 18px;
|
||||||
|
border-color: hsl(var(--border) / 0.68);
|
||||||
|
background: hsl(var(--surface) / 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-file-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border: 1px solid hsl(var(--border) / 0.72);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: hsl(var(--surface-subtle) / 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-file-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-file-icon {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-file-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-wrap {
|
||||||
|
border: 1px solid hsl(var(--border) / 0.72);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 18px;
|
||||||
|
background: hsl(var(--surface-subtle) / 0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-head {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-title-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: hsl(var(--surface) / 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-text {
|
||||||
|
color: var(--el-color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-text {
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,7 +3,13 @@ import type { FormInstance } from 'element-plus';
|
|||||||
|
|
||||||
import { markRaw, onMounted, ref } from 'vue';
|
import { markRaw, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
import { Delete, MoreFilled, Plus } from '@element-plus/icons-vue';
|
import {
|
||||||
|
Delete,
|
||||||
|
Lock,
|
||||||
|
MoreFilled,
|
||||||
|
Plus,
|
||||||
|
Upload,
|
||||||
|
} from '@element-plus/icons-vue';
|
||||||
import {
|
import {
|
||||||
ElAvatar,
|
ElAvatar,
|
||||||
ElButton,
|
ElButton,
|
||||||
@@ -24,6 +30,7 @@ import PageData from '#/components/page/PageData.vue';
|
|||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
import { useDictStore } from '#/store';
|
import { useDictStore } from '#/store';
|
||||||
|
|
||||||
|
import SysAccountImportModal from './SysAccountImportModal.vue';
|
||||||
import SysAccountModal from './SysAccountModal.vue';
|
import SysAccountModal from './SysAccountModal.vue';
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -31,6 +38,7 @@ onMounted(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const pageDataRef = ref();
|
const pageDataRef = ref();
|
||||||
|
const importDialog = ref();
|
||||||
const saveDialog = ref();
|
const saveDialog = ref();
|
||||||
const dictStore = useDictStore();
|
const dictStore = useDictStore();
|
||||||
const headerButtons = [
|
const headerButtons = [
|
||||||
@@ -42,6 +50,13 @@ const headerButtons = [
|
|||||||
data: { action: 'create' },
|
data: { action: 'create' },
|
||||||
permission: '/api/v1/sysAccount/save',
|
permission: '/api/v1/sysAccount/save',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'import',
|
||||||
|
text: $t('button.import'),
|
||||||
|
icon: markRaw(Upload),
|
||||||
|
data: { action: 'import' },
|
||||||
|
permission: '/api/v1/sysAccount/save',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function initDict() {
|
function initDict() {
|
||||||
@@ -57,6 +72,16 @@ function reset(formEl?: FormInstance) {
|
|||||||
function showDialog(row: any) {
|
function showDialog(row: any) {
|
||||||
saveDialog.value.openDialog({ ...row });
|
saveDialog.value.openDialog({ ...row });
|
||||||
}
|
}
|
||||||
|
function openImportDialog() {
|
||||||
|
importDialog.value.openDialog();
|
||||||
|
}
|
||||||
|
function handleHeaderButtonClick(payload: any) {
|
||||||
|
if (payload?.key === 'import') {
|
||||||
|
openImportDialog();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showDialog({});
|
||||||
|
}
|
||||||
function remove(row: any) {
|
function remove(row: any) {
|
||||||
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
|
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
|
||||||
confirmButtonText: $t('message.ok'),
|
confirmButtonText: $t('message.ok'),
|
||||||
@@ -84,6 +109,36 @@ function remove(row: any) {
|
|||||||
},
|
},
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
function resetPassword(row: any) {
|
||||||
|
ElMessageBox.confirm(
|
||||||
|
$t('sysAccount.resetPasswordConfirm'),
|
||||||
|
$t('message.noticeTitle'),
|
||||||
|
{
|
||||||
|
confirmButtonText: $t('message.ok'),
|
||||||
|
cancelButtonText: $t('message.cancel'),
|
||||||
|
type: 'warning',
|
||||||
|
beforeClose: (action, instance, done) => {
|
||||||
|
if (action === 'confirm') {
|
||||||
|
instance.confirmButtonLoading = true;
|
||||||
|
api
|
||||||
|
.post('/api/v1/sysAccount/resetPassword', { id: row.id })
|
||||||
|
.then((res) => {
|
||||||
|
instance.confirmButtonLoading = false;
|
||||||
|
if (res.errorCode === 0) {
|
||||||
|
ElMessage.success($t('sysAccount.resetPasswordSuccess'));
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
instance.confirmButtonLoading = false;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
).catch(() => {});
|
||||||
|
}
|
||||||
function isAdmin(data: any) {
|
function isAdmin(data: any) {
|
||||||
return data?.accountType === 1 || data?.accountType === 99;
|
return data?.accountType === 1 || data?.accountType === 99;
|
||||||
}
|
}
|
||||||
@@ -91,13 +146,14 @@ function isAdmin(data: any) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex h-full flex-col gap-6 p-6">
|
<div class="flex h-full flex-col gap-6 p-6">
|
||||||
|
<SysAccountImportModal ref="importDialog" @reload="reset" />
|
||||||
<SysAccountModal ref="saveDialog" @reload="reset" />
|
<SysAccountModal ref="saveDialog" @reload="reset" />
|
||||||
<ListPageShell>
|
<ListPageShell>
|
||||||
<template #filters>
|
<template #filters>
|
||||||
<HeaderSearch
|
<HeaderSearch
|
||||||
:buttons="headerButtons"
|
:buttons="headerButtons"
|
||||||
@search="handleSearch"
|
@search="handleSearch"
|
||||||
@button-click="showDialog({})"
|
@button-click="handleHeaderButtonClick"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<PageData
|
<PageData
|
||||||
@@ -193,6 +249,13 @@ function isAdmin(data: any) {
|
|||||||
|
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<ElDropdownMenu>
|
<ElDropdownMenu>
|
||||||
|
<div v-access:code="'/api/v1/sysAccount/save'">
|
||||||
|
<ElDropdownItem @click="resetPassword(row)">
|
||||||
|
<ElButton type="primary" :icon="Lock" link>
|
||||||
|
{{ $t('sysAccount.resetPassword') }}
|
||||||
|
</ElButton>
|
||||||
|
</ElDropdownItem>
|
||||||
|
</div>
|
||||||
<div v-access:code="'/api/v1/sysAccount/remove'">
|
<div v-access:code="'/api/v1/sysAccount/remove'">
|
||||||
<ElDropdownItem @click="remove(row)">
|
<ElDropdownItem @click="remove(row)">
|
||||||
<ElButton type="danger" :icon="Delete" link>
|
<ElButton type="danger" :icon="Delete" link>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { FormInstance } from 'element-plus';
|
|||||||
|
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
|
|
||||||
import { EasyFlowFormModal } from '@easyflow/common-ui';
|
import { EasyFlowFormModal, EasyFlowInputPassword } from '@easyflow/common-ui';
|
||||||
|
|
||||||
import { ElForm, ElFormItem, ElInput, ElMessage } from 'element-plus';
|
import { ElForm, ElFormItem, ElInput, ElMessage } from 'element-plus';
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ import DictSelect from '#/components/dict/DictSelect.vue';
|
|||||||
// import Cropper from '#/components/upload/Cropper.vue';
|
// import Cropper from '#/components/upload/Cropper.vue';
|
||||||
import UploadAvatar from '#/components/upload/UploadAvatar.vue';
|
import UploadAvatar from '#/components/upload/UploadAvatar.vue';
|
||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
|
import { isStrongPassword } from '#/utils/password-policy';
|
||||||
|
|
||||||
const emit = defineEmits(['reload']);
|
const emit = defineEmits(['reload']);
|
||||||
// vue
|
// vue
|
||||||
@@ -23,21 +24,36 @@ const saveForm = ref<FormInstance>();
|
|||||||
// variables
|
// variables
|
||||||
const dialogVisible = ref(false);
|
const dialogVisible = ref(false);
|
||||||
const isAdd = ref(true);
|
const isAdd = ref(true);
|
||||||
const entity = ref<any>({
|
function createDefaultEntity() {
|
||||||
deptId: '',
|
return {
|
||||||
loginName: '',
|
deptId: '',
|
||||||
password: '',
|
loginName: '',
|
||||||
accountType: '',
|
password: '',
|
||||||
nickname: '',
|
accountType: '',
|
||||||
mobile: '',
|
nickname: '',
|
||||||
email: '',
|
mobile: '',
|
||||||
avatar: '',
|
email: '',
|
||||||
dataScope: '',
|
avatar: '',
|
||||||
deptIdList: '',
|
dataScope: '',
|
||||||
status: '',
|
deptIdList: '',
|
||||||
remark: '',
|
status: 1,
|
||||||
positionIds: [],
|
remark: '',
|
||||||
});
|
positionIds: [],
|
||||||
|
roleIds: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const entity = ref<any>(createDefaultEntity());
|
||||||
|
const validateStrongPassword = (_rule: any, value: string, callback: any) => {
|
||||||
|
if (!value) {
|
||||||
|
callback(new Error($t('message.required')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isStrongPassword(value)) {
|
||||||
|
callback(new Error($t('sysAccount.passwordStrongTip')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback();
|
||||||
|
};
|
||||||
const btnLoading = ref(false);
|
const btnLoading = ref(false);
|
||||||
const rules = ref({
|
const rules = ref({
|
||||||
deptId: [
|
deptId: [
|
||||||
@@ -50,7 +66,7 @@ const rules = ref({
|
|||||||
{ required: true, message: $t('message.required'), trigger: 'blur' },
|
{ required: true, message: $t('message.required'), trigger: 'blur' },
|
||||||
],
|
],
|
||||||
password: [
|
password: [
|
||||||
{ required: true, message: $t('message.required'), trigger: 'blur' },
|
{ required: true, validator: validateStrongPassword, trigger: 'blur' },
|
||||||
],
|
],
|
||||||
status: [
|
status: [
|
||||||
{ required: true, message: $t('message.required'), trigger: 'blur' },
|
{ required: true, message: $t('message.required'), trigger: 'blur' },
|
||||||
@@ -58,10 +74,17 @@ const rules = ref({
|
|||||||
});
|
});
|
||||||
// functions
|
// functions
|
||||||
function openDialog(row: any) {
|
function openDialog(row: any) {
|
||||||
if (row.id) {
|
isAdd.value = !row?.id;
|
||||||
isAdd.value = false;
|
entity.value = {
|
||||||
|
...createDefaultEntity(),
|
||||||
|
...row,
|
||||||
|
};
|
||||||
|
if (!Array.isArray(entity.value.roleIds)) {
|
||||||
|
entity.value.roleIds = [];
|
||||||
|
}
|
||||||
|
if (!Array.isArray(entity.value.positionIds)) {
|
||||||
|
entity.value.positionIds = [];
|
||||||
}
|
}
|
||||||
entity.value = row;
|
|
||||||
dialogVisible.value = true;
|
dialogVisible.value = true;
|
||||||
}
|
}
|
||||||
function save() {
|
function save() {
|
||||||
@@ -90,7 +113,7 @@ function save() {
|
|||||||
function closeDialog() {
|
function closeDialog() {
|
||||||
saveForm.value?.resetFields();
|
saveForm.value?.resetFields();
|
||||||
isAdd.value = true;
|
isAdd.value = true;
|
||||||
entity.value = {};
|
entity.value = createDefaultEntity();
|
||||||
dialogVisible.value = false;
|
dialogVisible.value = false;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -129,7 +152,15 @@ function closeDialog() {
|
|||||||
prop="password"
|
prop="password"
|
||||||
:label="$t('sysAccount.password')"
|
:label="$t('sysAccount.password')"
|
||||||
>
|
>
|
||||||
<ElInput v-model.trim="entity.password" />
|
<div class="w-full">
|
||||||
|
<EasyFlowInputPassword
|
||||||
|
v-model="entity.password"
|
||||||
|
password-strength
|
||||||
|
/>
|
||||||
|
<div class="mt-2 text-xs text-[var(--el-text-color-secondary)]">
|
||||||
|
{{ $t('sysAccount.passwordStrongTip') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
<ElFormItem prop="nickname" :label="$t('sysAccount.nickname')">
|
<ElFormItem prop="nickname" :label="$t('sysAccount.nickname')">
|
||||||
<ElInput v-model.trim="entity.nickname" />
|
<ElInput v-model.trim="entity.nickname" />
|
||||||
|
|||||||
@@ -20,13 +20,17 @@ export default defineConfig(async () => {
|
|||||||
],
|
],
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/flow/api': {
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
rewrite: (path) => path.replace(/^\/flow/, ''),
|
||||||
// mock代理目标地址
|
target: 'http://127.0.0.1:8111',
|
||||||
target: 'http://localhost:5320/api',
|
|
||||||
ws: true,
|
ws: true,
|
||||||
},
|
},
|
||||||
|
'/flow/userCenter': {
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/flow/, ''),
|
||||||
|
target: 'http://127.0.0.1:8111',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ async function viteInjectAppLoadingPlugin(
|
|||||||
const { version } = await readPackageJSON(process.cwd());
|
const { version } = await readPackageJSON(process.cwd());
|
||||||
const envRaw = isBuild ? 'prod' : 'dev';
|
const envRaw = isBuild ? 'prod' : 'dev';
|
||||||
const cacheName = `'${env.VITE_APP_NAMESPACE}-${version}-${envRaw}-preferences-theme'`;
|
const cacheName = `'${env.VITE_APP_NAMESPACE}-${version}-${envRaw}-preferences-theme'`;
|
||||||
|
const appBase = JSON.stringify(ensureTrailingSlash(env.VITE_BASE || '/'));
|
||||||
|
|
||||||
// 获取缓存的主题
|
// 获取缓存的主题
|
||||||
// 保证黑暗主题下,刷新页面时,loading也是黑暗主题
|
// 保证黑暗主题下,刷新页面时,loading也是黑暗主题
|
||||||
@@ -29,7 +30,7 @@ async function viteInjectAppLoadingPlugin(
|
|||||||
document.documentElement.classList.toggle('dark', /dark/.test(theme));
|
document.documentElement.classList.toggle('dark', /dark/.test(theme));
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (/dark/.test(theme)) {
|
if (/dark/.test(theme)) {
|
||||||
document.querySelector('#__app-loading__ img').src = '/logoDark.svg';
|
document.querySelector('#__app-loading__ img').src = ${appBase} + 'logoDark.svg';
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -68,4 +69,8 @@ async function getLoadingRawByHtmlTemplate(loadingTemplate: string) {
|
|||||||
return await fsp.readFile(appLoadingPath, 'utf8');
|
return await fsp.readFile(appLoadingPath, 'utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureTrailingSlash(path: string) {
|
||||||
|
return path.endsWith('/') ? path : `${path}/`;
|
||||||
|
}
|
||||||
|
|
||||||
export { viteInjectAppLoadingPlugin };
|
export { viteInjectAppLoadingPlugin };
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { Preferences } from './types';
|
import type { Preferences } from './types';
|
||||||
|
|
||||||
|
const assetBase = import.meta.env.BASE_URL || '/';
|
||||||
|
|
||||||
const defaultPreferences: Preferences = {
|
const defaultPreferences: Preferences = {
|
||||||
app: {
|
app: {
|
||||||
accessMode: 'frontend',
|
accessMode: 'frontend',
|
||||||
@@ -65,10 +67,10 @@ const defaultPreferences: Preferences = {
|
|||||||
logo: {
|
logo: {
|
||||||
enable: true,
|
enable: true,
|
||||||
fit: 'contain',
|
fit: 'contain',
|
||||||
source: '/logo.svg',
|
source: `${assetBase}logo.svg`,
|
||||||
sourceDark: '/logoDark.svg',
|
sourceDark: `${assetBase}logoDark.svg`,
|
||||||
sourceMini: '/logoMini.svg',
|
sourceMini: `${assetBase}logoMini.svg`,
|
||||||
sourceMiniDark: '/logoMiniDark.svg',
|
sourceMiniDark: `${assetBase}logoMiniDark.svg`,
|
||||||
},
|
},
|
||||||
navigation: {
|
navigation: {
|
||||||
accordion: true,
|
accordion: true,
|
||||||
@@ -140,7 +142,7 @@ const defaultPreferences: Preferences = {
|
|||||||
timezone: false,
|
timezone: false,
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
sloganImage: '/slogan.svg',
|
sloganImage: `${assetBase}slogan.svg`,
|
||||||
pageTitle: '',
|
pageTitle: '',
|
||||||
pageDescription: '',
|
pageDescription: '',
|
||||||
welcomeBack: '',
|
welcomeBack: '',
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import {mount} from '@vue/test-utils';
|
||||||
|
import {nextTick} from 'vue';
|
||||||
|
|
||||||
|
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import AuthenticationLogin from '../login.vue';
|
||||||
|
|
||||||
|
const { formApi, routerPush } = vi.hoisted(() => ({
|
||||||
|
formApi: {
|
||||||
|
getValues: vi.fn(),
|
||||||
|
setFieldValue: vi.fn(),
|
||||||
|
validate: vi.fn(),
|
||||||
|
},
|
||||||
|
routerPush: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('vue-router', () => ({
|
||||||
|
useRouter: () => ({
|
||||||
|
push: routerPush,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@easyflow/locales', () => ({
|
||||||
|
$t: (key: string) => key,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@easyflow-core/form-ui', async () => {
|
||||||
|
const vue = await import('vue');
|
||||||
|
|
||||||
|
return {
|
||||||
|
useEasyFlowForm: () => [
|
||||||
|
vue.defineComponent({
|
||||||
|
name: 'MockEasyFlowForm',
|
||||||
|
setup() {
|
||||||
|
return () => vue.h('div', { class: 'mock-form' });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
formApi,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@easyflow-core/shadcn-ui', async (importOriginal) => {
|
||||||
|
const vue = await import('vue');
|
||||||
|
const actual = await importOriginal<typeof import('@easyflow-core/shadcn-ui')>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
EasyFlowButton: vue.defineComponent({
|
||||||
|
name: 'EasyFlowButton',
|
||||||
|
props: {
|
||||||
|
loading: {
|
||||||
|
default: false,
|
||||||
|
type: Boolean,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emits: ['click'],
|
||||||
|
setup(props, { attrs, emit, slots }) {
|
||||||
|
return () =>
|
||||||
|
vue.h(
|
||||||
|
'button',
|
||||||
|
{
|
||||||
|
...attrs,
|
||||||
|
'data-loading': String(props.loading),
|
||||||
|
type: 'button',
|
||||||
|
onClick: () => emit('click'),
|
||||||
|
},
|
||||||
|
slots.default?.(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
EasyFlowCheckbox: vue.defineComponent({
|
||||||
|
name: 'EasyFlowCheckbox',
|
||||||
|
props: {
|
||||||
|
modelValue: {
|
||||||
|
default: false,
|
||||||
|
type: Boolean,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
default: '',
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
setup(props, { emit, slots }) {
|
||||||
|
return () =>
|
||||||
|
vue.h('label', [
|
||||||
|
vue.h('input', {
|
||||||
|
checked: props.modelValue,
|
||||||
|
name: props.name,
|
||||||
|
type: 'checkbox',
|
||||||
|
onChange: (event: Event) => {
|
||||||
|
emit(
|
||||||
|
'update:modelValue',
|
||||||
|
(event.target as HTMLInputElement).checked,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
slots.default?.(),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AuthenticationLogin', () => {
|
||||||
|
const rememberKey = `REMEMBER_ME_ACCOUNT_${location.hostname}`;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
formApi.validate.mockResolvedValue({ valid: true });
|
||||||
|
formApi.getValues.mockResolvedValue({ account: 'admin' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores remembered account into the account field', async () => {
|
||||||
|
localStorage.setItem(rememberKey, 'remembered-user');
|
||||||
|
|
||||||
|
mount(AuthenticationLogin, {
|
||||||
|
props: {
|
||||||
|
formSchema: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(formApi.setFieldValue).toHaveBeenCalledWith(
|
||||||
|
'account',
|
||||||
|
'remembered-user',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists remembered account using the account field and renders overlay slot', async () => {
|
||||||
|
localStorage.setItem(rememberKey, 'remembered-user');
|
||||||
|
|
||||||
|
const wrapper = mount(AuthenticationLogin, {
|
||||||
|
props: {
|
||||||
|
formSchema: [],
|
||||||
|
showRememberMe: true,
|
||||||
|
},
|
||||||
|
slots: {
|
||||||
|
overlay: '<div id="captcha-box"></div>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
await wrapper.get('button').trigger('click');
|
||||||
|
|
||||||
|
expect(localStorage.getItem(rememberKey)).toBe('admin');
|
||||||
|
expect(wrapper.emitted('submit')?.[0]).toEqual([{ account: 'admin' }]);
|
||||||
|
expect(wrapper.find('#captcha-box').exists()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="auth-title mb-9 sm:mx-auto sm:w-full sm:max-w-md">
|
<div class="auth-title mb-8 w-full">
|
||||||
<h2
|
<h2
|
||||||
class="text-foreground mb-2.5 text-3xl font-semibold leading-[1.22] tracking-[-0.015em] lg:text-[2.15rem]"
|
class="text-foreground mb-2.5 text-[1.9rem] font-semibold leading-[1.12] tracking-[-0.03em] sm:text-[2.1rem]"
|
||||||
>
|
>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p class="text-muted-foreground text-[0.95rem] leading-7 lg:text-base">
|
<p class="text-muted-foreground max-w-[34rem] text-[0.97rem] leading-7 sm:text-[1rem]">
|
||||||
<slot name="desc"></slot>
|
<slot name="desc"></slot>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Recordable } from '@easyflow/types';
|
import type {Recordable} from '@easyflow/types';
|
||||||
|
|
||||||
import type { EasyFlowFormSchema } from '@easyflow-core/form-ui';
|
import type {EasyFlowFormSchema} from '@easyflow-core/form-ui';
|
||||||
|
import {useEasyFlowForm} from '@easyflow-core/form-ui';
|
||||||
|
|
||||||
import { computed, reactive } from 'vue';
|
import {computed, reactive} from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import {useRouter} from 'vue-router';
|
||||||
|
|
||||||
import { $t } from '@easyflow/locales';
|
import {$t} from '@easyflow/locales';
|
||||||
|
import {EasyFlowButton} from '@easyflow-core/shadcn-ui';
|
||||||
import { useEasyFlowForm } from '@easyflow-core/form-ui';
|
|
||||||
import { EasyFlowButton } from '@easyflow-core/shadcn-ui';
|
|
||||||
|
|
||||||
import Title from './auth-title.vue';
|
import Title from './auth-title.vue';
|
||||||
|
|
||||||
@@ -89,10 +88,10 @@ defineExpose({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="auth-code-login">
|
||||||
<Title>
|
<Title>
|
||||||
<slot name="title">
|
<slot name="title">
|
||||||
{{ title || $t('authentication.welcomeBack') }} 📲
|
{{ title || $t('authentication.welcomeBack') }}
|
||||||
</slot>
|
</slot>
|
||||||
<template #desc>
|
<template #desc>
|
||||||
<span class="text-muted-foreground">
|
<span class="text-muted-foreground">
|
||||||
@@ -102,13 +101,15 @@ defineExpose({
|
|||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</Title>
|
</Title>
|
||||||
<Form />
|
<div class="auth-form-group">
|
||||||
|
<Form />
|
||||||
|
</div>
|
||||||
<EasyFlowButton
|
<EasyFlowButton
|
||||||
:class="{
|
:class="{
|
||||||
'cursor-wait': loading,
|
'cursor-wait': loading,
|
||||||
}"
|
}"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
class="w-full"
|
class="mt-6 h-11 w-full rounded-xl text-base font-medium"
|
||||||
@click="handleSubmit"
|
@click="handleSubmit"
|
||||||
>
|
>
|
||||||
<slot name="submitButtonText">
|
<slot name="submitButtonText">
|
||||||
@@ -117,7 +118,7 @@ defineExpose({
|
|||||||
</EasyFlowButton>
|
</EasyFlowButton>
|
||||||
<EasyFlowButton
|
<EasyFlowButton
|
||||||
v-if="showBack"
|
v-if="showBack"
|
||||||
class="mt-4 w-full"
|
class="mt-3 h-11 w-full rounded-xl"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@click="goToLogin()"
|
@click="goToLogin()"
|
||||||
>
|
>
|
||||||
@@ -125,3 +126,9 @@ defineExpose({
|
|||||||
</EasyFlowButton>
|
</EasyFlowButton>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.auth-form-group :deep(.easyflow-form-ui + .easyflow-form-ui) {
|
||||||
|
margin-top: 0.95rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { EasyFlowFormSchema } from '@easyflow-core/form-ui';
|
import type {EasyFlowFormSchema} from '@easyflow-core/form-ui';
|
||||||
|
import {useEasyFlowForm} from '@easyflow-core/form-ui';
|
||||||
|
|
||||||
import { computed, reactive } from 'vue';
|
import {computed, reactive} from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import {useRouter} from 'vue-router';
|
||||||
|
|
||||||
import { $t } from '@easyflow/locales';
|
import {$t} from '@easyflow/locales';
|
||||||
|
import {EasyFlowButton} from '@easyflow-core/shadcn-ui';
|
||||||
import { useEasyFlowForm } from '@easyflow-core/form-ui';
|
|
||||||
import { EasyFlowButton } from '@easyflow-core/shadcn-ui';
|
|
||||||
|
|
||||||
import Title from './auth-title.vue';
|
import Title from './auth-title.vue';
|
||||||
|
|
||||||
@@ -82,10 +81,10 @@ defineExpose({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="auth-forget-password">
|
||||||
<Title>
|
<Title>
|
||||||
<slot name="title">
|
<slot name="title">
|
||||||
{{ title || $t('authentication.forgetPassword') }} 🤦🏻♂️
|
{{ title || $t('authentication.forgetPassword') }}
|
||||||
</slot>
|
</slot>
|
||||||
<template #desc>
|
<template #desc>
|
||||||
<slot name="subTitle">
|
<slot name="subTitle">
|
||||||
@@ -93,7 +92,9 @@ defineExpose({
|
|||||||
</slot>
|
</slot>
|
||||||
</template>
|
</template>
|
||||||
</Title>
|
</Title>
|
||||||
<Form />
|
<div class="auth-form-group">
|
||||||
|
<Form />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<EasyFlowButton
|
<EasyFlowButton
|
||||||
@@ -101,16 +102,26 @@ defineExpose({
|
|||||||
'cursor-wait': loading,
|
'cursor-wait': loading,
|
||||||
}"
|
}"
|
||||||
aria-label="submit"
|
aria-label="submit"
|
||||||
class="mt-2 w-full"
|
class="mt-6 h-11 w-full rounded-xl text-base font-medium"
|
||||||
@click="handleSubmit"
|
@click="handleSubmit"
|
||||||
>
|
>
|
||||||
<slot name="submitButtonText">
|
<slot name="submitButtonText">
|
||||||
{{ submitButtonText || $t('authentication.sendResetLink') }}
|
{{ submitButtonText || $t('authentication.sendResetLink') }}
|
||||||
</slot>
|
</slot>
|
||||||
</EasyFlowButton>
|
</EasyFlowButton>
|
||||||
<EasyFlowButton class="mt-4 w-full" variant="outline" @click="goToLogin()">
|
<EasyFlowButton
|
||||||
|
class="mt-3 h-11 w-full rounded-xl"
|
||||||
|
variant="outline"
|
||||||
|
@click="goToLogin()"
|
||||||
|
>
|
||||||
{{ $t('common.back') }}
|
{{ $t('common.back') }}
|
||||||
</EasyFlowButton>
|
</EasyFlowButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.auth-form-group :deep(.easyflow-form-ui + .easyflow-form-ui) {
|
||||||
|
margin-top: 0.95rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Recordable } from '@easyflow/types';
|
import type {Recordable} from '@easyflow/types';
|
||||||
|
|
||||||
import type { EasyFlowFormSchema } from '@easyflow-core/form-ui';
|
import type {EasyFlowFormSchema} from '@easyflow-core/form-ui';
|
||||||
|
import {useEasyFlowForm} from '@easyflow-core/form-ui';
|
||||||
|
|
||||||
import type { AuthenticationProps } from './types';
|
import type {AuthenticationProps} from './types';
|
||||||
|
|
||||||
import { computed, onMounted, reactive, ref } from 'vue';
|
import {computed, onMounted, reactive, ref} from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import {useRouter} from 'vue-router';
|
||||||
|
|
||||||
import { $t } from '@easyflow/locales';
|
import {$t} from '@easyflow/locales';
|
||||||
|
import {EasyFlowButton, EasyFlowCheckbox} from '@easyflow-core/shadcn-ui';
|
||||||
import { useEasyFlowForm } from '@easyflow-core/form-ui';
|
|
||||||
import { EasyFlowButton, EasyFlowCheckbox } from '@easyflow-core/shadcn-ui';
|
|
||||||
|
|
||||||
import Title from './auth-title.vue';
|
import Title from './auth-title.vue';
|
||||||
import ThirdPartyLogin from './third-party-login.vue';
|
import ThirdPartyLogin from './third-party-login.vue';
|
||||||
@@ -33,6 +32,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
registerPath: '/auth/register',
|
registerPath: '/auth/register',
|
||||||
showCodeLogin: false,
|
showCodeLogin: false,
|
||||||
showForgetPassword: false,
|
showForgetPassword: false,
|
||||||
|
showHeader: true,
|
||||||
showQrcodeLogin: false,
|
showQrcodeLogin: false,
|
||||||
showRegister: false,
|
showRegister: false,
|
||||||
showRememberMe: false,
|
showRememberMe: false,
|
||||||
@@ -58,11 +58,9 @@ const [Form, formApi] = useEasyFlowForm(
|
|||||||
);
|
);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const REMEMBER_ME_KEY = `REMEMBER_ME_USERNAME_${location.hostname}`;
|
const REMEMBER_ME_KEY = `REMEMBER_ME_ACCOUNT_${location.hostname}`;
|
||||||
|
const localAccount = localStorage.getItem(REMEMBER_ME_KEY) || '';
|
||||||
const localUsername = localStorage.getItem(REMEMBER_ME_KEY) || '';
|
const rememberMe = ref(!!localAccount);
|
||||||
|
|
||||||
const rememberMe = ref(!!localUsername);
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
const { valid } = await formApi.validate();
|
const { valid } = await formApi.validate();
|
||||||
@@ -70,7 +68,7 @@ async function handleSubmit() {
|
|||||||
if (valid) {
|
if (valid) {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
REMEMBER_ME_KEY,
|
REMEMBER_ME_KEY,
|
||||||
rememberMe.value ? values?.username : '',
|
rememberMe.value ? (values?.account ?? '') : '',
|
||||||
);
|
);
|
||||||
emit('submit', values);
|
emit('submit', values);
|
||||||
}
|
}
|
||||||
@@ -81,8 +79,8 @@ function handleGo(path: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (localUsername) {
|
if (localAccount) {
|
||||||
formApi.setFieldValue('username', localUsername);
|
formApi.setFieldValue('account', localAccount);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -93,65 +91,69 @@ defineExpose({
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="auth-login" @keydown.enter.prevent="handleSubmit">
|
<div class="auth-login" @keydown.enter.prevent="handleSubmit">
|
||||||
<slot name="title">
|
<template v-if="showHeader">
|
||||||
<Title>
|
<slot name="title">
|
||||||
<slot name="title">
|
<Title>
|
||||||
{{ title || $t('authentication.welcomeBack') }}
|
<slot name="title">
|
||||||
</slot>
|
{{ title || $t('authentication.welcomeBack') }}
|
||||||
<template #desc>
|
</slot>
|
||||||
<span class="text-muted-foreground">
|
<template v-if="subTitle || $t('authentication.loginSubtitle')" #desc>
|
||||||
<slot name="subTitle">
|
<span class="text-muted-foreground">
|
||||||
{{ subTitle || $t('authentication.loginSubtitle') }}
|
<slot name="subTitle">
|
||||||
</slot>
|
{{ subTitle || $t('authentication.loginSubtitle') }}
|
||||||
</span>
|
</slot>
|
||||||
</template>
|
</span>
|
||||||
</Title>
|
</template>
|
||||||
</slot>
|
</Title>
|
||||||
|
</slot>
|
||||||
|
</template>
|
||||||
|
|
||||||
<Form />
|
<div class="auth-form-group">
|
||||||
|
<Form />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="showRememberMe || showForgetPassword"
|
v-if="showRememberMe || showForgetPassword"
|
||||||
class="auth-login-options mb-7 mt-2 flex justify-between"
|
class="auth-login-options mt-1 flex items-center justify-between gap-3"
|
||||||
>
|
>
|
||||||
<div class="flex-center">
|
<EasyFlowCheckbox
|
||||||
<EasyFlowCheckbox
|
v-if="showRememberMe"
|
||||||
v-if="showRememberMe"
|
v-model="rememberMe"
|
||||||
v-model="rememberMe"
|
class="auth-checkbox"
|
||||||
name="rememberMe"
|
name="rememberMe"
|
||||||
>
|
>
|
||||||
{{ $t('authentication.rememberMe') }}
|
{{ $t('authentication.rememberMe') }}
|
||||||
</EasyFlowCheckbox>
|
</EasyFlowCheckbox>
|
||||||
</div>
|
|
||||||
|
|
||||||
<span
|
<button
|
||||||
v-if="showForgetPassword"
|
v-if="showForgetPassword"
|
||||||
class="easyflow-link text-sm font-normal"
|
class="auth-inline-action"
|
||||||
|
type="button"
|
||||||
@click="handleGo(forgetPasswordPath)"
|
@click="handleGo(forgetPasswordPath)"
|
||||||
>
|
>
|
||||||
{{ $t('authentication.forgetPassword') }}
|
{{ $t('authentication.forgetPassword') }}
|
||||||
</span>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EasyFlowButton
|
<EasyFlowButton
|
||||||
:class="{
|
:class="{ 'cursor-wait': loading }"
|
||||||
'cursor-wait': loading,
|
|
||||||
}"
|
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
aria-label="login"
|
aria-label="login"
|
||||||
class="auth-submit-button h-11 w-full rounded-xl text-base font-medium"
|
class="auth-submit-button auth-brand-submit mt-6 h-11 w-full rounded-xl text-base font-medium"
|
||||||
@click="handleSubmit"
|
@click="handleSubmit"
|
||||||
>
|
>
|
||||||
{{ submitButtonText || $t('common.login') }}
|
{{ submitButtonText || $t('common.login') }}
|
||||||
</EasyFlowButton>
|
</EasyFlowButton>
|
||||||
|
|
||||||
|
<slot name="overlay"></slot>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="showCodeLogin || showQrcodeLogin"
|
v-if="showCodeLogin || showQrcodeLogin"
|
||||||
class="auth-login-quick mb-2 mt-5 flex items-center justify-between"
|
class="auth-alt-modes mt-5 grid gap-3 sm:grid-cols-2"
|
||||||
>
|
>
|
||||||
<EasyFlowButton
|
<EasyFlowButton
|
||||||
v-if="showCodeLogin"
|
v-if="showCodeLogin"
|
||||||
class="w-1/2"
|
class="auth-secondary-button h-11 w-full rounded-xl"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@click="handleGo(codeLoginPath)"
|
@click="handleGo(codeLoginPath)"
|
||||||
>
|
>
|
||||||
@@ -159,7 +161,7 @@ defineExpose({
|
|||||||
</EasyFlowButton>
|
</EasyFlowButton>
|
||||||
<EasyFlowButton
|
<EasyFlowButton
|
||||||
v-if="showQrcodeLogin"
|
v-if="showQrcodeLogin"
|
||||||
class="ml-4 w-1/2"
|
class="auth-secondary-button h-11 w-full rounded-xl"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@click="handleGo(qrCodeLoginPath)"
|
@click="handleGo(qrCodeLoginPath)"
|
||||||
>
|
>
|
||||||
@@ -172,21 +174,113 @@ defineExpose({
|
|||||||
</slot>
|
</slot>
|
||||||
|
|
||||||
<slot name="to-register">
|
<slot name="to-register">
|
||||||
<div v-if="showRegister" class="mt-4 text-center text-sm">
|
<div v-if="showRegister" class="auth-footer-copy mt-5 text-center text-sm">
|
||||||
{{ $t('authentication.accountTip') }}
|
{{ $t('authentication.accountTip') }}
|
||||||
<span
|
<button
|
||||||
class="easyflow-link text-sm font-normal"
|
class="auth-inline-action"
|
||||||
|
type="button"
|
||||||
@click="handleGo(registerPath)"
|
@click="handleGo(registerPath)"
|
||||||
>
|
>
|
||||||
{{ $t('authentication.createAccount') }}
|
{{ $t('authentication.createAccount') }}
|
||||||
</span>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.auth-login :deep(.easyflow-form-ui + .easyflow-form-ui) {
|
.auth-login {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form-group :deep(.easyflow-form-ui + .easyflow-form-ui) {
|
||||||
margin-top: 0.95rem;
|
margin-top: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-form-group :deep(.easyflow-form-ui .text-destructive) {
|
||||||
|
font-size: 0.84rem;
|
||||||
|
margin-top: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-login-options {
|
||||||
|
min-height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-checkbox {
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-inline-action {
|
||||||
|
color: hsl(var(--nav-item-active-foreground));
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: opacity 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-inline-action:hover {
|
||||||
|
opacity: 0.76;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-secondary-button {
|
||||||
|
background: hsl(var(--surface-elevated));
|
||||||
|
border-color: hsl(var(--line-subtle));
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-brand-submit {
|
||||||
|
background:
|
||||||
|
linear-gradient(
|
||||||
|
120deg,
|
||||||
|
rgb(11 111 211) 0%,
|
||||||
|
rgb(22 159 200) 38%,
|
||||||
|
rgb(38 199 193) 62%,
|
||||||
|
rgb(11 111 211) 100%
|
||||||
|
);
|
||||||
|
background-size: 200% 200%;
|
||||||
|
border: none;
|
||||||
|
box-shadow:
|
||||||
|
0 22px 34px -22px rgb(11 111 211 / 0.56),
|
||||||
|
inset 0 1px 0 rgb(255 255 255 / 0.24);
|
||||||
|
color: rgb(255 255 255);
|
||||||
|
transition:
|
||||||
|
transform 180ms ease,
|
||||||
|
box-shadow 180ms ease,
|
||||||
|
filter 180ms ease;
|
||||||
|
animation: auth-brand-gradient 6s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-brand-submit:hover {
|
||||||
|
box-shadow:
|
||||||
|
0 24px 38px -22px rgb(11 111 211 / 0.62),
|
||||||
|
inset 0 1px 0 rgb(255 255 255 / 0.28);
|
||||||
|
filter: saturate(1.04);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-brand-submit:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-brand-submit:focus-visible {
|
||||||
|
outline: 2px solid rgb(78 176 255 / 0.8);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-footer-copy {
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes auth-brand-gradient {
|
||||||
|
0% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
background-position: 100% 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import {ref} from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import {useRouter} from 'vue-router';
|
||||||
|
|
||||||
import { $t } from '@easyflow/locales';
|
import {$t} from '@easyflow/locales';
|
||||||
|
|
||||||
import { EasyFlowButton } from '@easyflow-core/shadcn-ui';
|
import {EasyFlowButton} from '@easyflow-core/shadcn-ui';
|
||||||
|
|
||||||
import { useQRCode } from '@vueuse/integrations/useQRCode';
|
import {useQRCode} from '@vueuse/integrations/useQRCode';
|
||||||
|
|
||||||
import Title from './auth-title.vue';
|
import Title from './auth-title.vue';
|
||||||
|
|
||||||
@@ -70,10 +70,10 @@ function goToLogin() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="auth-qrcode-login">
|
||||||
<Title>
|
<Title>
|
||||||
<slot name="title">
|
<slot name="title">
|
||||||
{{ title || $t('authentication.welcomeBack') }} 📱
|
{{ title || $t('authentication.welcomeBack') }}
|
||||||
</slot>
|
</slot>
|
||||||
<template #desc>
|
<template #desc>
|
||||||
<span class="text-muted-foreground">
|
<span class="text-muted-foreground">
|
||||||
@@ -84,9 +84,11 @@ function goToLogin() {
|
|||||||
</template>
|
</template>
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
<div class="flex-col-center mt-6">
|
<div class="auth-qrcode-panel mt-6">
|
||||||
<img :src="qrcode" alt="qrcode" class="w-1/2" />
|
<div class="auth-qrcode-frame">
|
||||||
<p class="text-muted-foreground mt-4 text-sm">
|
<img :src="qrcode" alt="qrcode" class="auth-qrcode-image" />
|
||||||
|
</div>
|
||||||
|
<p class="text-muted-foreground mt-4 text-sm leading-6">
|
||||||
<slot name="description">
|
<slot name="description">
|
||||||
{{ description || $t('authentication.qrcodePrompt') }}
|
{{ description || $t('authentication.qrcodePrompt') }}
|
||||||
</slot>
|
</slot>
|
||||||
@@ -95,7 +97,7 @@ function goToLogin() {
|
|||||||
|
|
||||||
<EasyFlowButton
|
<EasyFlowButton
|
||||||
v-if="showBack"
|
v-if="showBack"
|
||||||
class="mt-4 w-full"
|
class="mt-6 h-11 w-full rounded-xl"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@click="goToLogin()"
|
@click="goToLogin()"
|
||||||
>
|
>
|
||||||
@@ -103,3 +105,32 @@ function goToLogin() {
|
|||||||
</EasyFlowButton>
|
</EasyFlowButton>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.auth-qrcode-panel {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-qrcode-frame {
|
||||||
|
align-items: center;
|
||||||
|
background: linear-gradient(180deg, rgb(255 255 255 / 0.92), rgb(244 249 255 / 0.96));
|
||||||
|
border: 1px solid hsl(var(--line-subtle));
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.6);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 17rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-qrcode-image {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 1rem;
|
||||||
|
width: min(100%, 13rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .auth-qrcode-frame {
|
||||||
|
background: linear-gradient(180deg, rgb(14 22 36 / 0.92), rgb(11 19 31 / 0.96));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Recordable } from '@easyflow/types';
|
import type {Recordable} from '@easyflow/types';
|
||||||
|
|
||||||
import type { EasyFlowFormSchema } from '@easyflow-core/form-ui';
|
import type {EasyFlowFormSchema} from '@easyflow-core/form-ui';
|
||||||
|
import {useEasyFlowForm} from '@easyflow-core/form-ui';
|
||||||
|
|
||||||
import { computed, reactive } from 'vue';
|
import {computed, reactive} from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import {useRouter} from 'vue-router';
|
||||||
|
|
||||||
import { $t } from '@easyflow/locales';
|
import {$t} from '@easyflow/locales';
|
||||||
|
import {EasyFlowButton} from '@easyflow-core/shadcn-ui';
|
||||||
import { useEasyFlowForm } from '@easyflow-core/form-ui';
|
|
||||||
import { EasyFlowButton } from '@easyflow-core/shadcn-ui';
|
|
||||||
|
|
||||||
import Title from './auth-title.vue';
|
import Title from './auth-title.vue';
|
||||||
|
|
||||||
@@ -85,10 +84,10 @@ defineExpose({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="auth-register">
|
||||||
<Title>
|
<Title>
|
||||||
<slot name="title">
|
<slot name="title">
|
||||||
{{ title || $t('authentication.createAnAccount') }} 🚀
|
{{ title || $t('authentication.createAnAccount') }}
|
||||||
</slot>
|
</slot>
|
||||||
<template #desc>
|
<template #desc>
|
||||||
<slot name="subTitle">
|
<slot name="subTitle">
|
||||||
@@ -96,7 +95,9 @@ defineExpose({
|
|||||||
</slot>
|
</slot>
|
||||||
</template>
|
</template>
|
||||||
</Title>
|
</Title>
|
||||||
<Form />
|
<div class="auth-form-group">
|
||||||
|
<Form />
|
||||||
|
</div>
|
||||||
|
|
||||||
<EasyFlowButton
|
<EasyFlowButton
|
||||||
:class="{
|
:class="{
|
||||||
@@ -104,18 +105,35 @@ defineExpose({
|
|||||||
}"
|
}"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
aria-label="register"
|
aria-label="register"
|
||||||
class="mt-2 w-full"
|
class="mt-6 h-11 w-full rounded-xl text-base font-medium"
|
||||||
@click="handleSubmit"
|
@click="handleSubmit"
|
||||||
>
|
>
|
||||||
<slot name="submitButtonText">
|
<slot name="submitButtonText">
|
||||||
{{ submitButtonText || $t('authentication.signUp') }}
|
{{ submitButtonText || $t('authentication.signUp') }}
|
||||||
</slot>
|
</slot>
|
||||||
</EasyFlowButton>
|
</EasyFlowButton>
|
||||||
<div class="mt-4 text-center text-sm">
|
<div class="mt-5 text-center text-sm text-[hsl(var(--text-muted))]">
|
||||||
{{ $t('authentication.alreadyHaveAccount') }}
|
{{ $t('authentication.alreadyHaveAccount') }}
|
||||||
<span class="easyflow-link text-sm font-normal" @click="goToLogin()">
|
<button class="auth-inline-action" type="button" @click="goToLogin()">
|
||||||
{{ $t('authentication.goToLogin') }}
|
{{ $t('authentication.goToLogin') }}
|
||||||
</span>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.auth-form-group :deep(.easyflow-form-ui + .easyflow-form-ui) {
|
||||||
|
margin-top: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-inline-action {
|
||||||
|
color: hsl(var(--nav-item-active-foreground));
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: opacity 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-inline-action:hover {
|
||||||
|
opacity: 0.76;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ interface AuthenticationProps {
|
|||||||
*/
|
*/
|
||||||
showThirdPartyLogin?: boolean;
|
showThirdPartyLogin?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh_CN 是否显示登录头部
|
||||||
|
*/
|
||||||
|
showHeader?: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh_CN 登录框子标题
|
* @zh_CN 登录框子标题
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ToolbarType } from './types';
|
import type {ToolbarType} from './types';
|
||||||
|
|
||||||
import { computed, ref, watch } from 'vue';
|
import {computed, onBeforeUnmount, onMounted, ref} from 'vue';
|
||||||
|
import {useRoute} from 'vue-router';
|
||||||
|
|
||||||
import { preferences, usePreferences } from '@easyflow/preferences';
|
import {$t} from '@easyflow/locales';
|
||||||
|
import {preferences, usePreferences} from '@easyflow/preferences';
|
||||||
|
|
||||||
import { Copyright } from '../basic/copyright';
|
import {Copyright} from '../basic/copyright';
|
||||||
import AuthenticationFormView from './form.vue';
|
import AuthenticationFormView from './form.vue';
|
||||||
import SloganIcon from './icons/slogan.vue';
|
|
||||||
import Toolbar from './toolbar.vue';
|
import Toolbar from './toolbar.vue';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -16,7 +17,6 @@ interface Props {
|
|||||||
logoDark?: string;
|
logoDark?: string;
|
||||||
pageTitle?: string;
|
pageTitle?: string;
|
||||||
pageDescription?: string;
|
pageDescription?: string;
|
||||||
sloganImage?: string;
|
|
||||||
toolbar?: boolean;
|
toolbar?: boolean;
|
||||||
copyright?: boolean;
|
copyright?: boolean;
|
||||||
toolbarList?: ToolbarType[];
|
toolbarList?: ToolbarType[];
|
||||||
@@ -30,20 +30,14 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
logoDark: '',
|
logoDark: '',
|
||||||
pageDescription: '',
|
pageDescription: '',
|
||||||
pageTitle: '',
|
pageTitle: '',
|
||||||
sloganImage: '',
|
|
||||||
toolbar: true,
|
toolbar: true,
|
||||||
toolbarList: () => ['color', 'language', 'layout', 'theme'],
|
toolbarList: () => ['language', 'theme'],
|
||||||
clickLogo: () => {},
|
clickLogo: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { authPanelCenter, authPanelLeft, authPanelRight, isDark } =
|
const { isDark } = usePreferences();
|
||||||
usePreferences();
|
const route = useRoute();
|
||||||
|
|
||||||
const isSloganLoadError = ref(false);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @zh_CN 根据主题选择合适的 logo 图标
|
|
||||||
*/
|
|
||||||
const logoSrc = computed(() => {
|
const logoSrc = computed(() => {
|
||||||
if (isDark.value && props.logoDark) {
|
if (isDark.value && props.logoDark) {
|
||||||
return props.logoDark;
|
return props.logoDark;
|
||||||
@@ -51,272 +45,338 @@ const logoSrc = computed(() => {
|
|||||||
return props.logo;
|
return props.logo;
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
const activeCapabilityIndex = ref(0);
|
||||||
() => props.sloganImage,
|
let capabilityTimer: null | number = null;
|
||||||
() => {
|
const currentHour = ref(new Date().getHours());
|
||||||
isSloganLoadError.value = false;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
function handleSloganError() {
|
const isLoginRoute = computed(() => route.path === '/auth/login');
|
||||||
isSloganLoadError.value = true;
|
const capabilityLabels = computed(() => [
|
||||||
}
|
$t('authentication.capabilityModel'),
|
||||||
|
$t('authentication.capabilityAgent'),
|
||||||
|
$t('authentication.capabilityWorkflow'),
|
||||||
|
$t('authentication.capabilityKnowledge'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const stageGreeting = computed(() => {
|
||||||
|
if (currentHour.value >= 5 && currentHour.value < 11) {
|
||||||
|
return $t('authentication.greetingMorning');
|
||||||
|
}
|
||||||
|
if (currentHour.value >= 11 && currentHour.value < 14) {
|
||||||
|
return $t('authentication.greetingNoon');
|
||||||
|
}
|
||||||
|
if (currentHour.value >= 14 && currentHour.value < 18) {
|
||||||
|
return $t('authentication.greetingAfternoon');
|
||||||
|
}
|
||||||
|
return $t('authentication.greetingEvening');
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
capabilityTimer = window.setInterval(() => {
|
||||||
|
activeCapabilityIndex.value =
|
||||||
|
(activeCapabilityIndex.value + 1) % capabilityLabels.value.length;
|
||||||
|
}, 2400);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (capabilityTimer) {
|
||||||
|
clearInterval(capabilityTimer);
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
:class="[isDark ? 'dark' : '']"
|
:class="[isDark ? 'dark' : '']"
|
||||||
class="auth-shell relative flex min-h-full flex-1 select-none overflow-x-hidden"
|
class="auth-shell relative flex min-h-full flex-1 select-none overflow-hidden"
|
||||||
>
|
>
|
||||||
|
<div class="auth-shell-grid absolute inset-0"></div>
|
||||||
|
<div class="auth-shell-noise absolute inset-0"></div>
|
||||||
|
<div class="auth-glow auth-glow-primary"></div>
|
||||||
|
<div class="auth-glow auth-glow-secondary"></div>
|
||||||
|
<div class="auth-glow auth-glow-tertiary"></div>
|
||||||
|
|
||||||
<template v-if="toolbar">
|
<template v-if="toolbar">
|
||||||
<slot name="toolbar">
|
<slot name="toolbar">
|
||||||
<Toolbar :toolbar-list="toolbarList" />
|
<Toolbar :toolbar-list="toolbarList" />
|
||||||
</slot>
|
</slot>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<AuthenticationFormView
|
|
||||||
v-if="authPanelLeft"
|
|
||||||
class="auth-panel-form min-h-full w-full flex-1 lg:w-[42%] lg:flex-initial"
|
|
||||||
data-side="left"
|
|
||||||
>
|
|
||||||
<template v-if="copyright" #copyright>
|
|
||||||
<slot name="copyright">
|
|
||||||
<Copyright
|
|
||||||
v-if="preferences.copyright.enable"
|
|
||||||
v-bind="preferences.copyright"
|
|
||||||
/>
|
|
||||||
</slot>
|
|
||||||
</template>
|
|
||||||
</AuthenticationFormView>
|
|
||||||
|
|
||||||
<slot name="logo">
|
<slot name="logo">
|
||||||
<div
|
<div
|
||||||
v-if="logoSrc || appName"
|
v-if="logoSrc || appName"
|
||||||
class="absolute left-0 top-0 z-20 flex flex-1"
|
class="absolute left-0 top-0 z-20 flex flex-1"
|
||||||
@click="clickLogo"
|
@click="clickLogo"
|
||||||
>
|
>
|
||||||
<div
|
<div class="auth-brand-chip text-foreground ml-4 mt-4 flex items-center sm:ml-6 sm:mt-6">
|
||||||
class="text-foreground ml-4 mt-4 flex flex-1 items-center sm:left-6 sm:top-6"
|
|
||||||
>
|
|
||||||
<img
|
<img
|
||||||
v-if="logoSrc"
|
v-if="logoSrc"
|
||||||
:key="logoSrc"
|
:key="logoSrc"
|
||||||
:alt="appName"
|
:alt="appName"
|
||||||
:src="logoSrc"
|
:src="logoSrc"
|
||||||
class="mr-2"
|
class="mr-2.5"
|
||||||
width="120"
|
width="112"
|
||||||
/>
|
/>
|
||||||
|
<span v-else class="auth-brand-name">{{ appName }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</slot>
|
</slot>
|
||||||
|
|
||||||
<div
|
<main class="auth-stage relative z-10 flex min-h-full w-full items-center justify-center px-6 pb-10 pt-28 sm:px-10 sm:pt-32">
|
||||||
v-if="!authPanelCenter"
|
<div class="auth-stage-inner mx-auto flex w-full max-w-[1080px] flex-col items-center">
|
||||||
class="auth-hero relative hidden min-h-full w-0 flex-1 overflow-hidden lg:block"
|
<div
|
||||||
>
|
v-if="isLoginRoute"
|
||||||
<div class="auth-hero-base absolute inset-0"></div>
|
class="auth-stage-copy w-full max-w-[780px] text-center"
|
||||||
<div class="auth-orb auth-orb-left"></div>
|
>
|
||||||
<div class="auth-orb auth-orb-right"></div>
|
<h1 class="auth-page-title text-foreground">
|
||||||
<div class="auth-orb auth-orb-bottom"></div>
|
{{ stageGreeting }}
|
||||||
|
</h1>
|
||||||
|
<div class="auth-stage-switcher text-muted-foreground" aria-label="同一入口能力切换">
|
||||||
|
<span class="auth-stage-switcher-label">在同一入口管理</span>
|
||||||
|
<span class="auth-stage-pill" aria-live="polite">
|
||||||
|
<Transition mode="out-in" name="auth-pill">
|
||||||
|
<span
|
||||||
|
:key="capabilityLabels[activeCapabilityIndex]"
|
||||||
|
class="auth-stage-pill-text"
|
||||||
|
>
|
||||||
|
{{ capabilityLabels[activeCapabilityIndex] }}
|
||||||
|
</span>
|
||||||
|
</Transition>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<AuthenticationFormView
|
||||||
:key="authPanelLeft ? 'left' : authPanelRight ? 'right' : 'center'"
|
:class="[
|
||||||
class="auth-hero-content flex-col-center relative h-full px-12"
|
'auth-window-host w-full',
|
||||||
:class="{
|
isLoginRoute ? 'max-w-[25rem] sm:max-w-[26rem]' : 'max-w-[31rem]',
|
||||||
'enter-x': authPanelLeft,
|
isLoginRoute ? 'mt-8 sm:mt-10' : 'mt-0 sm:mt-4',
|
||||||
'-enter-x': authPanelRight,
|
]"
|
||||||
}"
|
data-side="bottom"
|
||||||
>
|
>
|
||||||
<div class="auth-hero-visual">
|
<template v-if="copyright" #copyright>
|
||||||
<template v-if="sloganImage && !isSloganLoadError">
|
<slot name="copyright">
|
||||||
<img
|
<Copyright
|
||||||
:alt="appName"
|
v-if="preferences.copyright.enable"
|
||||||
:src="sloganImage"
|
v-bind="preferences.copyright"
|
||||||
class="auth-hero-image"
|
/>
|
||||||
@error="handleSloganError"
|
</slot>
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
<SloganIcon v-else :alt="appName" class="auth-hero-fallback" />
|
</AuthenticationFormView>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="auth-page-title text-foreground">
|
|
||||||
{{ pageTitle }}
|
|
||||||
</div>
|
|
||||||
<div class="auth-page-desc text-muted-foreground">
|
|
||||||
{{ pageDescription }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
|
|
||||||
<div v-if="authPanelCenter" class="auth-center relative w-full">
|
|
||||||
<div class="auth-hero-base absolute inset-0"></div>
|
|
||||||
<div class="auth-orb auth-orb-left"></div>
|
|
||||||
<div class="auth-orb auth-orb-right"></div>
|
|
||||||
<AuthenticationFormView
|
|
||||||
class="auth-center-card shadow-float w-full rounded-3xl pb-20 md:w-2/3 lg:w-1/2 xl:w-[36%]"
|
|
||||||
data-side="bottom"
|
|
||||||
>
|
|
||||||
<template v-if="copyright" #copyright>
|
|
||||||
<slot name="copyright">
|
|
||||||
<Copyright
|
|
||||||
v-if="preferences.copyright.enable"
|
|
||||||
v-bind="preferences.copyright"
|
|
||||||
/>
|
|
||||||
</slot>
|
|
||||||
</template>
|
|
||||||
</AuthenticationFormView>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AuthenticationFormView
|
|
||||||
v-if="authPanelRight"
|
|
||||||
class="auth-panel-form min-h-full w-full flex-1 lg:w-[42%] lg:flex-initial"
|
|
||||||
data-side="right"
|
|
||||||
>
|
|
||||||
<template v-if="copyright" #copyright>
|
|
||||||
<slot name="copyright">
|
|
||||||
<Copyright
|
|
||||||
v-if="preferences.copyright.enable"
|
|
||||||
v-bind="preferences.copyright"
|
|
||||||
/>
|
|
||||||
</slot>
|
|
||||||
</template>
|
|
||||||
</AuthenticationFormView>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.auth-shell {
|
.auth-shell {
|
||||||
background: linear-gradient(180deg, #f8fbff 0%, #f1f6ff 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-panel-form {
|
|
||||||
backdrop-filter: blur(6px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-hero {
|
|
||||||
border-inline: 1px solid rgb(29 108 255 / 6%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-hero-base {
|
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 22% 18%, rgb(56 131 255 / 14%) 0, transparent 42%),
|
radial-gradient(circle at top, rgb(255 255 255 / 78%), rgb(255 255 255 / 0) 36%),
|
||||||
radial-gradient(circle at 82% 16%, rgb(88 179 255 / 12%) 0, transparent 35%),
|
linear-gradient(180deg, #f7faff 0%, #eef4fd 55%, #edf3fb 100%);
|
||||||
radial-gradient(circle at 70% 86%, rgb(112 146 255 / 10%) 0, transparent 40%),
|
|
||||||
linear-gradient(145deg, #eef5ff 0%, #f4f8ff 45%, #eef4ff 100%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-hero-content {
|
.auth-shell-grid {
|
||||||
z-index: 2;
|
background-image:
|
||||||
|
linear-gradient(rgb(13 74 160 / 0.06) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgb(13 74 160 / 0.06) 1px, transparent 1px);
|
||||||
|
background-position: center center;
|
||||||
|
background-size: 120px 120px;
|
||||||
|
mask-image: linear-gradient(180deg, rgb(0 0 0 / 0.28), transparent 75%);
|
||||||
|
opacity: 0.42;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-hero-visual {
|
.auth-shell-noise {
|
||||||
width: min(860px, 90%);
|
background-image:
|
||||||
max-width: 920px;
|
radial-gradient(circle at 20% 20%, rgb(255 255 255 / 0.35) 0 0.9px, transparent 1.2px),
|
||||||
|
radial-gradient(circle at 80% 30%, rgb(255 255 255 / 0.22) 0 1px, transparent 1.3px),
|
||||||
|
radial-gradient(circle at 40% 70%, rgb(11 111 211 / 0.08) 0 1px, transparent 1.4px);
|
||||||
|
background-size: 180px 180px, 240px 240px, 200px 200px;
|
||||||
|
mix-blend-mode: soft-light;
|
||||||
|
opacity: 0.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-hero-image {
|
.auth-glow {
|
||||||
width: 100%;
|
border-radius: 9999px;
|
||||||
height: auto;
|
pointer-events: none;
|
||||||
object-fit: contain;
|
position: absolute;
|
||||||
filter: drop-shadow(0 24px 48px rgb(24 78 173 / 10%));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-hero-fallback {
|
.auth-glow-primary {
|
||||||
width: 100%;
|
background: radial-gradient(circle, rgb(87 150 255 / 0.24) 0%, rgb(87 150 255 / 0) 68%);
|
||||||
height: auto;
|
height: 26rem;
|
||||||
|
left: 50%;
|
||||||
|
top: 4rem;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 26rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-glow-secondary {
|
||||||
|
background: radial-gradient(circle, rgb(36 189 211 / 0.18) 0%, rgb(36 189 211 / 0) 72%);
|
||||||
|
height: 20rem;
|
||||||
|
left: 14%;
|
||||||
|
top: 46%;
|
||||||
|
width: 20rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-glow-tertiary {
|
||||||
|
background: radial-gradient(circle, rgb(66 116 255 / 0.14) 0%, rgb(66 116 255 / 0) 74%);
|
||||||
|
bottom: 8%;
|
||||||
|
height: 22rem;
|
||||||
|
right: 10%;
|
||||||
|
width: 22rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-brand-chip {
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
background: rgb(255 255 255 / 0.72);
|
||||||
|
border: 1px solid rgb(255 255 255 / 0.84);
|
||||||
|
border-radius: 9999px;
|
||||||
|
box-shadow: 0 20px 44px -32px rgb(11 59 132 / 0.26);
|
||||||
|
min-height: 3rem;
|
||||||
|
padding: 0.45rem 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-brand-name {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-page-title {
|
.auth-page-title {
|
||||||
margin-top: 1.2rem;
|
font-size: clamp(2.1rem, 4vw, 3.7rem);
|
||||||
font-size: clamp(1.9rem, 2.5vw, 2.45rem);
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.04em;
|
||||||
text-align: center;
|
line-height: 1.05;
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 16ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-page-desc {
|
.auth-stage-switcher {
|
||||||
margin-top: 0.9rem;
|
|
||||||
max-width: 42rem;
|
|
||||||
font-size: clamp(0.96rem, 1.1vw, 1.12rem);
|
|
||||||
line-height: 1.7;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-center {
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.7rem;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-center-card {
|
.auth-stage-switcher-label {
|
||||||
position: relative;
|
font-size: 0.98rem;
|
||||||
z-index: 2;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-orb {
|
.auth-stage-pill {
|
||||||
|
align-items: center;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
background: rgb(255 255 255 / 0.74);
|
||||||
|
border: 1px solid rgb(255 255 255 / 0.9);
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
position: absolute;
|
box-shadow: 0 18px 34px -28px rgb(14 61 132 / 0.3);
|
||||||
|
color: hsl(var(--nav-item-active-foreground));
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 6.5rem;
|
||||||
|
padding: 0.55rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-stage-pill-text {
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-pill-enter-active,
|
||||||
|
.auth-pill-leave-active {
|
||||||
|
transition:
|
||||||
|
opacity 180ms ease,
|
||||||
|
transform 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-pill-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-pill-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-window-host {
|
||||||
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-orb-left {
|
|
||||||
background: rgb(101 149 255 / 18%);
|
|
||||||
height: 18rem;
|
|
||||||
left: -4rem;
|
|
||||||
top: 4.5rem;
|
|
||||||
width: 18rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-orb-right {
|
|
||||||
background: rgb(124 188 255 / 16%);
|
|
||||||
height: 10rem;
|
|
||||||
right: 3rem;
|
|
||||||
top: 5rem;
|
|
||||||
width: 10rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-orb-bottom {
|
|
||||||
background: rgb(93 154 255 / 12%);
|
|
||||||
bottom: 2rem;
|
|
||||||
height: 14rem;
|
|
||||||
right: 5rem;
|
|
||||||
width: 14rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark.auth-shell {
|
.dark.auth-shell {
|
||||||
background: linear-gradient(180deg, #05080f 0%, #070b14 100%);
|
background:
|
||||||
|
radial-gradient(circle at top, rgb(35 66 114 / 0.32), rgb(15 22 35 / 0) 40%),
|
||||||
|
linear-gradient(180deg, #06101b 0%, #08111d 52%, #09131f 100%);
|
||||||
|
|
||||||
.auth-hero {
|
.auth-shell-grid {
|
||||||
border-inline: 1px solid rgb(125 168 255 / 10%);
|
background-image:
|
||||||
|
linear-gradient(rgb(118 160 241 / 0.08) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgb(118 160 241 / 0.08) 1px, transparent 1px);
|
||||||
|
opacity: 0.36;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-hero-base {
|
.auth-shell-noise {
|
||||||
background:
|
opacity: 0.38;
|
||||||
radial-gradient(circle at 20% 18%, rgb(63 115 255 / 22%) 0, transparent 45%),
|
|
||||||
radial-gradient(circle at 82% 16%, rgb(56 149 255 / 18%) 0, transparent 38%),
|
|
||||||
radial-gradient(circle at 72% 86%, rgb(96 124 255 / 20%) 0, transparent 42%),
|
|
||||||
linear-gradient(150deg, #0a1326 0%, #091022 45%, #0b1529 100%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-page-desc {
|
.auth-brand-chip {
|
||||||
color: rgb(181 194 226 / 82%);
|
background: rgb(11 19 31 / 0.68);
|
||||||
|
border-color: rgb(138 174 255 / 0.18);
|
||||||
|
box-shadow: 0 24px 48px -34px rgb(0 0 0 / 0.52);
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-hero-image {
|
.auth-stage-switcher-label {
|
||||||
filter: drop-shadow(0 26px 52px rgb(8 19 42 / 48%));
|
color: rgb(195 206 230 / 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-orb-left {
|
.auth-stage-pill {
|
||||||
background: rgb(81 126 255 / 24%);
|
background: rgb(11 19 31 / 0.7);
|
||||||
|
border-color: rgb(138 174 255 / 0.18);
|
||||||
|
box-shadow: 0 18px 34px -28px rgb(0 0 0 / 0.46);
|
||||||
|
color: rgb(144 196 255 / 0.92);
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-orb-right {
|
.auth-glow-primary {
|
||||||
background: rgb(59 140 248 / 22%);
|
background: radial-gradient(circle, rgb(70 120 255 / 0.26) 0%, rgb(70 120 255 / 0) 70%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-orb-bottom {
|
.auth-glow-secondary {
|
||||||
background: rgb(94 105 239 / 20%);
|
background: radial-gradient(circle, rgb(41 170 201 / 0.18) 0%, rgb(41 170 201 / 0) 72%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-glow-tertiary {
|
||||||
|
background: radial-gradient(circle, rgb(95 128 255 / 0.16) 0%, rgb(95 128 255 / 0) 74%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.auth-page-title {
|
||||||
|
max-width: 14ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.auth-stage {
|
||||||
|
padding-top: 6.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-stage-copy {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-page-title {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-stage-switcher {
|
||||||
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import {computed} from 'vue';
|
||||||
|
import {useRoute} from 'vue-router';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'AuthenticationFormView',
|
name: 'AuthenticationFormView',
|
||||||
});
|
});
|
||||||
@@ -6,29 +9,38 @@ defineOptions({
|
|||||||
defineProps<{
|
defineProps<{
|
||||||
dataSide?: 'bottom' | 'left' | 'right' | 'top';
|
dataSide?: 'bottom' | 'left' | 'right' | 'top';
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const isLoginRoute = computed(() => route.path === '/auth/login');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="auth-form-wrap flex-col-center bg-background dark:bg-background-deep relative min-h-full px-6 py-12 lg:px-10"
|
class="auth-form-wrap relative min-h-full"
|
||||||
>
|
>
|
||||||
<slot></slot>
|
<div :class="['auth-window-shell', { 'auth-window-shell-plain': isLoginRoute }]">
|
||||||
|
<template v-if="!isLoginRoute">
|
||||||
|
<div class="auth-window-edge auth-window-edge-top"></div>
|
||||||
|
<div class="auth-window-edge auth-window-edge-bottom"></div>
|
||||||
|
</template>
|
||||||
|
<slot></slot>
|
||||||
|
|
||||||
<RouterView v-slot="{ Component, route }">
|
<RouterView v-slot="{ Component, route }">
|
||||||
<Transition appear mode="out-in" name="slide-right">
|
<Transition appear mode="out-in" name="slide-right">
|
||||||
<KeepAlive :include="['Login']">
|
<KeepAlive :include="['Login']">
|
||||||
<component
|
<component
|
||||||
:is="Component"
|
:is="Component"
|
||||||
:key="route.fullPath"
|
:key="route.fullPath"
|
||||||
class="side-content mt-8 w-full sm:mx-auto md:max-w-md"
|
class="side-content w-full"
|
||||||
:data-side="dataSide"
|
:data-side="dataSide"
|
||||||
/>
|
/>
|
||||||
</KeepAlive>
|
</KeepAlive>
|
||||||
</Transition>
|
</Transition>
|
||||||
</RouterView>
|
</RouterView>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="text-muted-foreground absolute bottom-4 flex text-center text-xs"
|
class="auth-copyright text-muted-foreground absolute left-1/2 flex -translate-x-1/2 text-center text-xs"
|
||||||
>
|
>
|
||||||
<slot name="copyright"> </slot>
|
<slot name="copyright"> </slot>
|
||||||
</div>
|
</div>
|
||||||
@@ -37,12 +49,91 @@ defineProps<{
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.auth-form-wrap {
|
.auth-form-wrap {
|
||||||
background-image:
|
padding: 0 0 4.5rem;
|
||||||
linear-gradient(180deg, rgb(255 255 255 / 98%) 0%, rgb(247 250 255 / 98%) 100%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .auth-form-wrap {
|
.auth-window-shell {
|
||||||
background-image:
|
backdrop-filter: blur(22px);
|
||||||
linear-gradient(180deg, rgb(13 20 34 / 96%) 0%, rgb(9 16 29 / 98%) 100%);
|
background:
|
||||||
|
linear-gradient(180deg, rgb(255 255 255 / 0.96) 0%, rgb(248 251 255 / 0.98) 100%);
|
||||||
|
border: 1px solid rgb(255 255 255 / 0.82);
|
||||||
|
border-radius: 2rem;
|
||||||
|
box-shadow:
|
||||||
|
0 40px 80px -48px rgb(13 61 132 / 0.38),
|
||||||
|
0 18px 36px -26px rgb(13 61 132 / 0.18);
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 1.25rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-window-shell-plain {
|
||||||
|
backdrop-filter: none;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
overflow: visible;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-window-edge {
|
||||||
|
border-radius: 9999px;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-window-edge-top {
|
||||||
|
background: linear-gradient(90deg, rgb(11 111 211 / 0.18), rgb(22 159 200 / 0.08));
|
||||||
|
height: 10rem;
|
||||||
|
left: -4rem;
|
||||||
|
top: -6rem;
|
||||||
|
width: 14rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-window-edge-bottom {
|
||||||
|
background: radial-gradient(circle, rgb(84 132 255 / 0.14) 0%, rgb(84 132 255 / 0) 72%);
|
||||||
|
bottom: -4rem;
|
||||||
|
height: 11rem;
|
||||||
|
right: -4rem;
|
||||||
|
width: 11rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-copyright {
|
||||||
|
bottom: 0.55rem;
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .auth-window-shell {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgb(10 18 30 / 0.92) 0%, rgb(9 17 29 / 0.96) 100%);
|
||||||
|
border-color: rgb(136 168 235 / 0.16);
|
||||||
|
box-shadow:
|
||||||
|
0 42px 84px -50px rgb(0 0 0 / 0.64),
|
||||||
|
0 18px 36px -28px rgb(0 0 0 / 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .auth-window-shell-plain {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .auth-window-edge-top {
|
||||||
|
background: linear-gradient(90deg, rgb(69 120 255 / 0.22), rgb(28 155 197 / 0.08));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .auth-window-edge-bottom {
|
||||||
|
background: radial-gradient(circle, rgb(92 136 255 / 0.16) 0%, rgb(92 136 255 / 0) 72%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.auth-form-wrap {
|
||||||
|
padding-bottom: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-window-shell {
|
||||||
|
border-radius: 1.6rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ToolbarType } from './types';
|
import type {ToolbarType} from './types';
|
||||||
|
|
||||||
import { computed } from 'vue';
|
import {computed} from 'vue';
|
||||||
|
|
||||||
import { preferences } from '@easyflow/preferences';
|
import {preferences} from '@easyflow/preferences';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AuthenticationColorToggle,
|
AuthenticationColorToggle,
|
||||||
@@ -21,7 +21,7 @@ defineOptions({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
toolbarList: () => ['color', 'language', 'layout', 'theme'],
|
toolbarList: () => ['language', 'theme'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const showColor = computed(() => props.toolbarList.includes('color'));
|
const showColor = computed(() => props.toolbarList.includes('color'));
|
||||||
@@ -35,7 +35,7 @@ const showTheme = computed(() => props.toolbarList.includes('theme'));
|
|||||||
:class="{
|
:class="{
|
||||||
'auth-toolbar': toolbarList.length > 1,
|
'auth-toolbar': toolbarList.length > 1,
|
||||||
}"
|
}"
|
||||||
class="flex-center absolute right-3 top-4 z-20"
|
class="flex-center absolute right-4 top-4 z-20 sm:right-6 sm:top-6"
|
||||||
>
|
>
|
||||||
<div class="hidden md:flex">
|
<div class="hidden md:flex">
|
||||||
<AuthenticationColorToggle v-if="showColor" />
|
<AuthenticationColorToggle v-if="showColor" />
|
||||||
@@ -49,16 +49,16 @@ const showTheme = computed(() => props.toolbarList.includes('theme'));
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.auth-toolbar {
|
.auth-toolbar {
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
background: rgb(255 255 255 / 72%);
|
background: rgb(255 255 255 / 0.72);
|
||||||
border: 1px solid rgb(29 108 255 / 10%);
|
border: 1px solid rgb(255 255 255 / 0.78);
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
box-shadow: 0 12px 26px rgb(30 72 152 / 12%);
|
box-shadow: 0 20px 42px -30px rgb(14 55 124 / 0.28);
|
||||||
padding: 0.25rem 0.72rem;
|
padding: 0.25rem 0.58rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.dark) .auth-toolbar {
|
:deep(.dark) .auth-toolbar {
|
||||||
background: rgb(11 19 34 / 66%);
|
background: rgb(11 19 34 / 66%);
|
||||||
border-color: rgb(122 167 255 / 22%);
|
border-color: rgb(122 167 255 / 18%);
|
||||||
box-shadow: 0 12px 28px rgb(0 0 0 / 32%);
|
box-shadow: 0 20px 40px -28px rgb(0 0 0 / 0.46);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,18 +1,6 @@
|
|||||||
<svg width="1em" height="1em" viewBox="0 0 16.0000059 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||||
<title>用户-fill备份 2</title>
|
<g fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
<circle cx="6.2" cy="5.3" r="2"/>
|
||||||
<g id="icon未选中备份" transform="translate(-26, -494)">
|
<path d="M2.9 11.8c.4-1.8 1.8-2.9 3.3-2.9s2.9 1.1 3.3 2.9M11.2 5.6h2.2M11.2 8h2.2M10.4 10.4h3"/>
|
||||||
<g id="编组-8" transform="translate(12, 65)">
|
</g>
|
||||||
<g id="用户-fill备份-2" transform="translate(14, 429)">
|
|
||||||
<rect id="矩形" fill="currentColor" fill-rule="nonzero" opacity="0" x="0" y="0" width="16" height="16"></rect>
|
|
||||||
<g id="角色管理" transform="translate(0.7172, 1.1935)" fill-rule="nonzero" stroke="currentColor">
|
|
||||||
<path d="M7.63436374,7.09716249 C6.97158297,7.5295722 6.17862079,7.78078822 5.32525423,7.78078822 C4.47444262,7.78078822 3.68403539,7.53122545 3.02378266,7.1020962 C1.23538529,7.94880797 0,9.74848216 0,11.8314871 C0,14.0355699 10.6666719,14.0050954 10.6666719,11.8314871 C10.6658247,9.74354844 9.42788446,7.94140089 7.63436374,7.09716249 Z M5.32525423,0 C7.01005692,0 8.37498787,1.34306163 8.37498787,3.0006976 C8.37498787,4.65763598 7.01005692,6 5.32525423,6 C3.6404401,6 2.27552059,4.65763598 2.27552059,3.0006976 C2.27552059,1.34375923 3.64045154,0 5.32525423,0 Z" id="形状"></path>
|
|
||||||
</g>
|
|
||||||
<rect id="矩形" fill="currentColor" x="12.0000059" y="4.00000195" width="4" height="1.00000049" rx="0.500000244"></rect>
|
|
||||||
<rect id="矩形备份" fill="currentColor" x="12.0000059" y="7.33333691" width="4" height="1.00000049" rx="0.500000244"></rect>
|
|
||||||
<rect id="矩形备份-2" fill="currentColor" x="12.0000059" y="10.6666719" width="4" height="1.00000049" rx="0.500000244"></rect>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 321 B |
@@ -1,20 +1,7 @@
|
|||||||
<svg width="1em" height="1em" viewBox="0 0 14.7175881 14.4349443" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||||
<title>编组 12</title>
|
<g fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
<circle cx="5.2" cy="8.2" r="1.8"/>
|
||||||
<g id="icon未选中备份" transform="translate(-26.4814, -971.1325)">
|
<path d="M6.8 8.2h6.3M10.1 8.2v2.1M12.3 8.2v2.1"/>
|
||||||
<g id="编组-8" transform="translate(12, 65)">
|
<path d="M3.5 9.8 2.2 11.1M3.5 6.6 2.2 5.3"/>
|
||||||
<g id="编组-12" transform="translate(14, 905)">
|
</g>
|
||||||
<rect id="矩形" x="0" y="0" width="16" height="16"></rect>
|
|
||||||
<g id="编组-21" transform="translate(-1, -0.6)" fill="currentColor">
|
|
||||||
<path d="M14.8829769,3.11702313 C16.6044612,4.83850746 16.6044612,7.62958504 14.8829769,9.35106938 L12.6565318,11.5775145 L6.42248553,5.34346822 L8.64893062,3.11702313 C10.370415,1.39553879 13.1614925,1.39553879 14.8829769,3.11702313 Z M9.3560374,3.82412991 L7.83648553,5.343 L12.6564855,10.163 L14.1758701,8.6439626 C15.4592959,7.36053684 15.5051325,5.30817178 14.31338,3.96982179 L14.1758701,3.82412991 C12.84491,2.49316986 10.6869974,2.49316986 9.3560374,3.82412991 Z" id="矩形" fill-rule="nonzero"></path>
|
|
||||||
<path d="M8.03493007,6.91746623 L9.76867625,8.63339428 L9.7847344,8.64936969 C11.4973499,10.3619852 11.4973499,13.1386837 9.7847344,14.8512992 C8.06065189,16.5753817 5.26759456,16.582595 3.53463,14.8674405 L1.80088382,13.1515125 L8.03493007,6.91746623 Z M8.03888382,8.32746623 L3.21888382,13.1474662 L4.23807508,14.1566909 C5.53201273,15.4373304 7.58921174,15.4778741 8.93150143,14.2821392 L9.07762761,14.1441924 C10.3997189,12.8221012 10.3997189,10.6785677 9.07762761,9.35647647 C9.07144001,9.35029951 9.07144001,9.35029951 9.06523118,9.3441439 L8.03888382,8.32746623 Z" id="矩形备份-9" fill-rule="nonzero" transform="translate(6.435, 11.5333) rotate(90) translate(-6.435, -11.5333)"></path>
|
|
||||||
<path d="M8.18832598,8.17649777 C8.42381039,8.41198218 8.42381039,8.79377784 8.18832598,9.02926225 L7.6766673,9.54092094 C7.44118289,9.77640535 7.05938723,9.77640535 6.82390282,9.54092094 C6.58841841,9.30543653 6.58841841,8.92364087 6.82390282,8.68815646 L7.33556151,8.17649777 C7.57104591,7.94101336 7.95284158,7.94101336 8.18832598,8.17649777 Z" id="矩形"></path>
|
|
||||||
<path d="M9.55274915,9.54092094 C9.78823356,9.77640535 9.78823356,10.158201 9.55274915,10.3936854 L9.04109046,10.9053441 C8.80560605,11.1408285 8.42381039,11.1408285 8.18832598,10.9053441 C7.95284158,10.6698597 7.95284158,10.288064 8.18832598,10.0525796 L8.69998467,9.54092094 C8.93546908,9.30543653 9.31726474,9.30543653 9.55274915,9.54092094 Z" id="矩形备份-10"></path>
|
|
||||||
<path d="M16.0145327,1.91693902 C16.260459,2.16286535 16.260459,2.56159072 16.0145327,2.80751706 L15.1239547,3.69809509 C14.8780283,3.94402143 14.479303,3.94402143 14.2333766,3.69809509 C13.9874503,3.45216876 13.9874503,3.05344339 14.2333766,2.80751706 L15.1239547,1.91693902 C15.369881,1.67101269 15.7686064,1.67101269 16.0145327,1.91693902 Z" id="矩形备份-12"></path>
|
|
||||||
<path d="M3.44699017,14.1605689 C3.69291651,14.4064952 3.69291651,14.8052206 3.44699017,15.0511469 L2.55641214,15.9417249 C2.3104858,16.1876513 1.91176043,16.1876513 1.6658341,15.9417249 C1.41990777,15.6957986 1.41990777,15.2970732 1.6658341,15.0511469 L2.55641214,14.1605689 C2.80233847,13.9146425 3.20106384,13.9146425 3.44699017,14.1605689 Z" id="矩形备份-13"></path>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 327 B |
@@ -1,18 +1,6 @@
|
|||||||
<svg width="1em" height="1em" viewBox="0 0 14.5454909 15.3333333" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||||
<title>编组 11</title>
|
<g fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
<ellipse cx="8" cy="3.4" rx="4.9" ry="1.9"/>
|
||||||
<g id="icon未选中备份" transform="translate(-26.5, -403.5)">
|
<path d="M3.1 3.4v3.1c0 1 2.2 1.9 4.9 1.9s4.9-.9 4.9-1.9V3.4M3.1 6.5v3.1c0 1 2.2 1.9 4.9 1.9s4.9-.9 4.9-1.9V6.5M3.1 9.6v3c0 1 2.2 1.9 4.9 1.9s4.9-.9 4.9-1.9v-3"/>
|
||||||
<g id="编组-8" transform="translate(12, 65)">
|
</g>
|
||||||
<g id="编组-11" transform="translate(14, 338)">
|
|
||||||
<rect id="矩形" x="0" y="0" width="16" height="16"></rect>
|
|
||||||
<g id="编组" transform="translate(1, 1)" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M13.5454909,2.33333333 C13.5454909,2.33333333 13.5454909,11.446897 13.5454909,12.1515152 C13.5454909,13.356497 11.2893455,14.3333333 7.27272727,14.3333333 C3.25610909,14.3333333 0,13.356497 0,12.1515152 C0,11.4759879 0,2.33333333 0,2.33333333" id="路径"></path>
|
|
||||||
<path d="M13.5454909,8.33333333 C13.5454909,9.4379 10.3485667,10.3333333 6.66666667,10.3333333 C2.98476667,10.3333333 0,9.4379 0,8.33333333" id="路径"></path>
|
|
||||||
<path d="M13.5454909,5.33333333 C13.5454909,6.4379 10.3485667,7.33333333 6.66666667,7.33333333 C2.98476667,7.33333333 0,6.4379 0,5.33333333" id="路径"></path>
|
|
||||||
<path d="M6.5,4 C10.0898509,4 13.5454909,3.1045695 13.5454909,2 C13.5454909,0.8954305 10.0898509,0 6.5,0 C2.91014913,0 0,0.8954305 0,2 C0,3.1045695 2.91014913,4 6.5,4 Z" id="椭圆形"></path>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 398 B |
@@ -1,19 +1,6 @@
|
|||||||
<svg width="1em" height="1em" viewBox="0 0 14.6000655 13.800012" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||||
<title>编组 22</title>
|
<g fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
<path d="M2.8 13.8V3.8l4.8-1.6v11.6M7.6 5h5.6a.8.8 0 0 1 .8.8v8"/>
|
||||||
<g id="icon未选中备份" transform="translate(-27.1, -639.1)">
|
<path d="M4.5 6.3h1.4M4.5 8.8h1.4M10 7.5h1.6"/>
|
||||||
<g id="编组-8" transform="translate(12, 65)">
|
</g>
|
||||||
<g id="编组-22" transform="translate(14, 573)">
|
|
||||||
<rect id="矩形" x="0" y="0" width="16" height="16"></rect>
|
|
||||||
<g id="编组" transform="translate(1.6, 1.6)">
|
|
||||||
<path d="M8.26223301,2.00063943 L12.8,4 C13.3049945,4.29550615 13.6000512,4.73314602 13.6000512,5.6 L13.6000512,12 C13.6032153,12.2939261 13.1562904,12.7991551 12.8,12.8 L7,12.8" id="路径" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
|
|
||||||
<path d="M1.1415274,12.8 C0.512798815,12.8 0,12.2707139 0,11.6228941 L0,3.13096528 C0,2.58933194 0.346943093,2.1267211 0.850433679,1.99090119 L6.6020732,0.0301555958 C6.93209224,-0.0480437458 7.27903533,0.0276861429 7.54474297,0.235943337 C7.83245187,0.46066355 8,0.804740653 8,1.17762804 L8,11.6212478 C7.99746139,12.2715371 7.48635498,12.7983537 6.8576264,12.7983537 L1.1415274,12.8 Z" id="形状结合" stroke="currentColor"></path>
|
|
||||||
<path d="M11.170388,8.2530546 L10.2126215,8.2530546 C9.86584368,8.25083028 9.58660899,8.02729706 9.58802281,7.7530546 C9.58802281,7.47677427 9.86837089,7.2530546 10.2126215,7.2530546 L11.170388,7.2530546 C11.5146386,7.2530546 11.7941501,7.47744813 11.7941501,7.7530546 C11.79549,7.88482379 11.7304843,8.01160423 11.6134605,8.10541221 C11.4964367,8.19922019 11.3370165,8.25234283 11.170388,8.2530546 Z" id="路径" fill="currentColor"></path>
|
|
||||||
<path d="M2.24982313,5.48557263 C1.90304532,5.48334832 1.62381063,5.2598151 1.62521911,4.98557263 C1.62521911,4.70929231 1.90557253,4.48557263 2.24982313,4.48557263 L5.12141847,4.48557263 C5.46481697,4.48557263 5.74517859,4.70996617 5.74517859,4.98557263 C5.74704825,5.2599665 5.46754133,5.48371763 5.12056637,5.48557263 L2.24982313,5.48557263 Z" id="路径" fill="currentColor"></path>
|
|
||||||
<path d="M2.24982313,8.2530546 C1.90304532,8.25083028 1.62381063,8.02729706 1.62521911,7.7530546 C1.62521911,7.47677427 1.90557253,7.2530546 2.24982313,7.2530546 L5.12141847,7.2530546 C5.343818,7.2530546 5.55002751,7.34806807 5.66165333,7.50238074 C5.77353222,7.65754607 5.77353222,7.84788927 5.66165333,8.0030546 C5.55107144,8.15703384 5.34448712,8.25222533 5.12056637,8.25238074 L2.24982313,8.2530546 Z" id="路径" fill="currentColor"></path>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 305 B |
@@ -1,17 +1,7 @@
|
|||||||
<svg width="1em" height="1em" viewBox="0 0 14.0379421 14.3151752" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||||
<title>编组 10</title>
|
<g fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
<path d="M8 4.1c-1.3-1-2.9-1.2-4.6-.6v8.5c1.7-.6 3.3-.4 4.6.6V4.1Z"/>
|
||||||
<g id="icon未选中备份" transform="translate(-26.7954, -258.8515)">
|
<path d="M8 4.1c1.3-1 2.9-1.2 4.6-.6v8.5c-1.7-.6-3.3-.4-4.6.6V4.1Z"/>
|
||||||
<g id="编组-8" transform="translate(12, 65)">
|
<path d="M8 3v9.7M5 6.3h1.6M9.4 6.3H11M5 8.3h1.8M9.2 8.3H11"/>
|
||||||
<g id="编组-10" transform="translate(14, 193)">
|
</g>
|
||||||
<rect id="矩形" x="0" y="0" width="16" height="16"></rect>
|
|
||||||
<g id="编组" transform="translate(1.2954, 1.3515)" stroke="currentColor" stroke-linejoin="round">
|
|
||||||
<path d="M0.0277100066,11.3151752 C0.0277100066,8.74740849 0.0277100066,2 0.0277100066,2 C0.0277100066,0.89543 0.923140007,0 2.02771001,0 L11.0379421,0 L11.0379421,9.31517516 C11.0379421,9.31517516 3.77191001,9.31517516 2.02771001,9.31517516 C0.927710007,9.31517516 0.0277100066,10.2099085 0.0277100066,11.3151752 Z" id="路径"></path>
|
|
||||||
<path d="M8.65194112,9.31517516 C6.43462741,9.31517516 4.21731371,9.31517516 2,9.31517516 C0.89543,9.31517516 0,10.2106085 0,11.3151752 C0,12.4197418 0.89543,13.3151752 2,13.3151752 C5.01264735,13.3151752 8.0252947,13.3151752 11.0379421,13.3151752 C12.1425116,13.3151752 13.0379421,12.4197447 13.0379421,11.3151752 L13.0379421,1.31517516 L13.0379421,1.31517516" id="路径" stroke-linecap="round"></path>
|
|
||||||
<line x1="3.03794206" y1="11.3151752" x2="9.70460872" y2="11.3151752" id="路径" stroke-linecap="round"></line>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 397 B |
@@ -1,18 +1,7 @@
|
|||||||
<svg width="1em" height="1em" viewBox="0 0 14.7149751 16.0000001" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||||
<title>编组 17</title>
|
<g fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
<path d="m8 1.8 4.8 2.8v5.5L8 13 3.2 10.1V4.6L8 1.8Z"/>
|
||||||
<g id="icon未选中备份" transform="translate(-26.6425, -354)">
|
<circle cx="8" cy="7.4" r="1"/>
|
||||||
<g id="编组-8" transform="translate(12, 65)">
|
<path d="M8 4.8v1.6M5.8 8.7l1.4-.8M10.2 8.7l-1.4-.8"/>
|
||||||
<g id="编组-17" transform="translate(14, 289)">
|
</g>
|
||||||
<rect id="矩形" x="0" y="0" width="16" height="15.9999997"></rect>
|
|
||||||
<g id="编组-16" transform="translate(1.6425, 1)">
|
|
||||||
<path d="M6.35746925,-0.5 C6.8278663,-0.5 7.29826346,-0.377408123 7.71898835,-0.132224476 L9.77959976,1.06864503 L11.8533982,2.25477788 C12.2760935,2.49654319 12.6174583,2.84262067 12.852659,3.24999394 C13.0878603,3.65736827 13.2168969,4.1260387 13.2149349,4.6130027 L13.2052379,6.99797052 L13.214935,9.38701302 C13.2168969,9.87396191 13.08786,10.342632 12.8526584,10.7500061 C12.6174573,11.1573793 12.2760921,11.5034568 11.8533971,11.7452219 L9.78310774,12.9293304 L7.71898727,14.1322242 C7.29826251,14.3774081 6.82786565,14.5 6.35746879,14.5 C5.88707193,14.5 5.41667507,14.3774081 4.99595089,14.1322246 L2.93533838,12.9313556 L0.861540861,11.7452221 C0.43884564,11.5034569 0.0974811362,11.1573797 -0.137719486,10.7500067 C-0.372920756,10.3426327 -0.501957426,9.87396249 -0.499995882,9.38699834 L-0.490300299,7.00202917 L-0.499995944,4.61298652 C-0.501957426,4.12603716 -0.372920783,3.65736708 -0.137719618,3.24999321 C0.0974810057,2.84262027 0.438845544,2.4965432 0.861540701,2.25477797 L2.9318304,1.07067047 L4.99595031,-0.132224245 C5.41667504,-0.377408064 5.88707208,-0.5 6.35746925,-0.5 Z" id="Fill-270" stroke="currentColor"></path>
|
|
||||||
<path d="M6.85746919,4.33333372 C6.85746919,4.05719173 6.63361186,3.83333373 6.35746919,3.83333373 C6.08132653,3.83333373 5.85746919,4.05719173 5.85746919,4.33333372 L6.85746919,4.33333372 Z M5.85746919,7.47058899 C5.85746919,7.74673165 6.08132653,7.97058898 6.35746919,7.97058898 C6.63361186,7.97058898 6.85746919,7.74673165 6.85746919,7.47058899 L5.85746919,7.47058899 Z M6.35746919,4.33333372 L5.85746919,4.33333372 L5.85746919,7.47058899 L6.35746919,7.47058899 L6.85746919,7.47058899 L6.85746919,4.33333372 L6.35746919,4.33333372 Z" id="Fill-271" fill="currentColor"></path>
|
|
||||||
<path d="M3.43729253,8.6083643 C3.19927586,8.74837363 3.11982586,9.05482496 3.25983519,9.29284162 C3.39984519,9.53085761 3.70629586,9.61030828 3.94431253,9.47029828 L3.43729253,8.6083643 Z M6.61097919,7.90167098 C6.84899586,7.76166098 6.92844586,7.45521032 6.78843653,7.21719366 C6.64842653,6.979177 6.34197586,6.899727 6.10395919,7.039737 L6.61097919,7.90167098 Z M3.69080253,9.03933096 L3.94431253,9.47029828 L6.61097919,7.90167098 L6.35746919,7.47070366 L6.10395919,7.039737 L3.43729253,8.6083643 L3.69080253,9.03933096 Z" id="Fill-273" fill="currentColor"></path>
|
|
||||||
<path d="M8.77062586,9.47029828 C9.00864253,9.61030828 9.31509319,9.53085761 9.45510319,9.29284162 C9.59511253,9.05482496 9.51566253,8.74837363 9.27764586,8.6083643 L8.77062586,9.47029828 Z M6.61097919,7.039737 C6.37296253,6.899727 6.06651186,6.979177 5.92650186,7.21719366 C5.78649253,7.45521032 5.86594253,7.76166098 6.10395919,7.90167098 L6.61097919,7.039737 Z M9.02413586,9.03933096 L9.27764586,8.6083643 L6.61097919,7.039737 L6.35746919,7.47070366 L6.10395919,7.90167098 L8.77062586,9.47029828 L9.02413586,9.03933096 Z" id="Fill-272" fill="currentColor"></path>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 337 B |
@@ -1,17 +1,6 @@
|
|||||||
<svg width="1em" height="1em" viewBox="0 0 14 14" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||||
<title>编组 24</title>
|
<g fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
<rect x="2.2" y="2.2" width="11.6" height="11.6" rx="2"/>
|
||||||
<g id="icon未选中备份" transform="translate(-29, -831)">
|
<path d="M5 5.6h6M5 8h6M5 10.4h3.4"/>
|
||||||
<g id="编组-8" transform="translate(12, 65)">
|
</g>
|
||||||
<g id="编组-24" transform="translate(16, 765)">
|
|
||||||
<rect id="矩形" x="0" y="0" width="16" height="16"></rect>
|
|
||||||
<g id="编组-2" transform="translate(1, 1)">
|
|
||||||
<path d="M11.480315,0.5 C12.0380356,0.5 12.5429568,0.726060947 12.908448,1.09155205 C13.2739391,1.45704315 13.5,1.96196441 13.5,2.51968503 L13.5,11.480315 C13.5,12.0380356 13.2739391,12.5429568 12.908448,12.908448 C12.5429568,13.2739391 12.0380356,13.5 11.480315,13.5 L2.51968503,13.5 C1.96196441,13.5 1.45704315,13.2739391 1.09155205,12.908448 C0.726060947,12.5429568 0.5,12.0380356 0.5,11.480315 L0.5,2.51968503 C0.5,1.96196441 0.726060947,1.45704315 1.09155205,1.09155205 C1.45704315,0.726060947 1.96196441,0.5 2.51968503,0.5 Z" id="形状结合" stroke="currentColor"></path>
|
|
||||||
<rect id="矩形备份" fill="currentColor" x="1.85301837" y="7.48600884" width="5.03937008" height="1" rx="0.5"></rect>
|
|
||||||
<rect id="矩形备份-2" fill="currentColor" x="1.85301837" y="4.41875307" width="8.18897638" height="1" rx="0.5"></rect>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 286 B |
@@ -1,18 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||||
<svg width="14px" height="14px" viewBox="0 0 14 14" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
<g fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<title>编组 24备份</title>
|
<rect x="2.1" y="2.1" width="11.8" height="11.8" rx="2.2"/>
|
||||||
<g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
<path d="M5.2 5.6h5.6M5.2 8h5.6M6.4 10.4h3.2"/>
|
||||||
<g id="画板" transform="translate(-171, -121)">
|
<path d="m8 2.1 1.2 1.2M8 13.9l-1.2-1.2"/>
|
||||||
<g id="编组-24备份" transform="translate(170, 120)">
|
|
||||||
<rect id="矩形" x="0" y="0" width="16" height="16"></rect>
|
|
||||||
<g id="编组-2" transform="translate(1, 1)">
|
|
||||||
<rect id="形状结合" stroke="currentColor" x="0.5" y="0.5" width="13" height="13" rx="2.51968503"></rect>
|
|
||||||
<rect id="矩形备份-3" fill="currentColor" x="3" y="5" width="8" height="1" rx="0.5"></rect>
|
|
||||||
<rect id="矩形备份-4" fill="currentColor" x="3" y="8" width="8" height="1" rx="0.5"></rect>
|
|
||||||
<path d="M6.68855607,2.24987802 L5.6139466,3.1095656 C5.39831563,3.28207037 5.36335494,3.59671656 5.53585972,3.81234752 C5.63074581,3.93095514 5.77440226,4 5.92629412,4 L8.07551306,4 C8.35165544,4 8.57551306,3.77614237 8.57551306,3.5 C8.57551306,3.34810814 8.5064682,3.20445169 8.38786059,3.1095656 L7.31325112,2.24987802 C7.13064179,2.10379056 6.87116539,2.10379056 6.68855607,2.24987802 Z" id="路径-2" fill="currentColor"></path>
|
|
||||||
<path d="M6.68855607,10.249878 L5.6139466,11.1095656 C5.39831563,11.2820704 5.36335494,11.5967166 5.53585972,11.8123475 C5.63074581,11.9309551 5.77440226,12 5.92629412,12 L8.07551306,12 C8.35165544,12 8.57551306,11.7761424 8.57551306,11.5 C8.57551306,11.3481081 8.5064682,11.2044517 8.38786059,11.1095656 L7.31325112,10.249878 C7.13064179,10.1037906 6.87116539,10.1037906 6.68855607,10.249878 Z" id="路径-2备份" fill="currentColor" transform="translate(7.0009, 11) scale(1, -1) translate(-7.0009, -11)"></path>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 345 B |
@@ -1,15 +1,8 @@
|
|||||||
<svg width="1em" height="1em" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||||
<title>菜单</title>
|
<g fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
<rect x="2.2" y="2.2" width="4.8" height="4.8" rx="1.2"/>
|
||||||
<g id="icon未选中备份" transform="translate(-26, -590)" fill-rule="nonzero">
|
<rect x="9" y="2.2" width="4.8" height="4.8" rx="1.2"/>
|
||||||
<g id="编组-8" transform="translate(12, 65)">
|
<rect x="2.2" y="9" width="4.8" height="4.8" rx="1.2"/>
|
||||||
<g id="菜单" transform="translate(14, 525)">
|
<rect x="9" y="9" width="4.8" height="4.8" rx="1.2"/>
|
||||||
<rect id="矩形" fill="currentColor" opacity="0" x="0" y="0" width="16" height="16"></rect>
|
</g>
|
||||||
<g id="编组" transform="translate(0.8, 0.8)" stroke="currentColor">
|
|
||||||
<path d="M1.71559633,6 L6,6 L6,1.71559633 C6,0.770642202 5.2293578,0 4.28440367,0 L1.71559633,0 C0.770642202,0 0,0.770642202 0,1.71559633 L0,4.28440367 C0,5.2293578 0.770642202,6 1.71559633,6 Z M1.71559633,8.4 L6,8.4 L6,12.6844037 C6,13.6293578 5.2293578,14.4 4.28440367,14.4 L1.71559633,14.4 C0.770642202,14.4 0,13.6293578 0,12.6844037 L0,10.1155963 C0,9.1706422 0.770642202,8.4 1.71559633,8.4 Z M8.4,6 L12.6844037,6 C13.6293578,6 14.4,5.2293578 14.4,4.28440367 L14.4,1.71559633 C14.4,0.770642202 13.6293578,0 12.6844037,0 L10.1155963,0 C9.1706422,0 8.4,0.770642202 8.4,1.71559633 L8.4,6 Z M8.4,8.4 L12.6844037,8.4 C13.6293578,8.4 14.4,9.1706422 14.4,10.1155963 L14.4,12.6844037 C14.4,13.6293578 13.6293578,14.4 12.6844037,14.4 L10.1155963,14.4 C9.1706422,14.4 8.4,13.6293578 8.4,12.6844037 L8.4,8.4 Z" id="形状"></path>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 422 B |
@@ -1,14 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||||
<svg width="1em" height="1em" viewBox="0 0 15.0344828 14.4140854" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
<g fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<title>编组 9</title>
|
<path d="M5.5 3.1h2v1.7h1V3.1h2a1.4 1.4 0 0 1 1.4 1.4v1.9h1.6v2.1h-1.6v2a1.4 1.4 0 0 1-1.4 1.4H8.5v1.7h-2v-1.7h-2a1.4 1.4 0 0 1-1.4-1.4v-2H1.6V6.4h1.5V4.5a1.4 1.4 0 0 1 1.4-1.4Z"/>
|
||||||
<g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
</g>
|
||||||
<g id="icon未选中备份" transform="translate(-25.4655, -161.5)">
|
|
||||||
<g id="编组-8" transform="translate(12, 65)">
|
|
||||||
<g id="编组-9" transform="translate(13, 96)">
|
|
||||||
<rect id="矩形" x="0" y="0" width="16" height="16"></rect>
|
|
||||||
<path d="M5.07448664,3.55045142 L2.96551724,3.55045142 C1.86094774,3.55045142 0.965517241,4.44588192 0.965517241,5.55045142 L0.965517241,12.4140854 C0.965517241,13.5186549 1.86094774,14.4140854 2.96551724,14.4140854 C3.66850704,14.4140854 4.37149684,14.4140854 5.07448664,14.4140854 C5.07448664,13.9457515 5.07448664,13.4774176 5.07448664,13.0090837 C5.07448664,12.2012255 5.72938511,11.5463271 6.53724332,11.5463271 C7.34510153,11.5463271 8,12.2012255 8,13.0090837 L8,14.4140854 L8,14.4140854 L10.4534534,14.4140854 C11.5580229,14.4140854 12.4534534,13.5186549 12.4534534,12.4140854 L12.4534534,10.3620717 L12.4534534,10.3620717 L13.4810155,10.3620717 C14.3199275,10.3620717 15,9.68199916 15,8.84308717 C15,8.00417519 14.3199275,7.32410266 13.4810155,7.32410266 L12.4534534,7.32410266 L12.4534534,7.32410266 L12.4534534,5.55045142 C12.4534534,4.44588192 11.5580229,3.55045142 10.4534534,3.55045142 L8.03300467,3.55045142 L8.03300467,3.55045142 L8.03300467,2.47925902 C8.03300467,1.66228682 7.37071785,1 6.55374565,1 C5.73677346,1 5.07448664,1.66228682 5.07448664,2.47925902 L5.07448664,3.55045142 L5.07448664,3.55045142 Z" id="路径-11" stroke="currentColor"></path>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 367 B |
@@ -1,20 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||||
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
<g fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<title>岗位组备份</title>
|
<rect x="2" y="3" width="12" height="10" rx="2"/>
|
||||||
<g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
<circle cx="5.6" cy="8" r="1.2"/>
|
||||||
<g id="icon未选中备份" transform="translate(-26, -686)">
|
<path d="M3.8 11c.3-1.1 1-1.8 1.8-1.8.8 0 1.5.7 1.8 1.8M9.6 7.1h2.7M9.6 9.5h2.1"/>
|
||||||
<g id="编组-23" transform="translate(12, 13)">
|
</g>
|
||||||
<g id="编组-8" transform="translate(0, 52)">
|
|
||||||
<g id="岗位组备份" transform="translate(14, 621)">
|
|
||||||
<rect id="矩形" fill="currentColor" fill-rule="nonzero" opacity="0" x="0" y="0" width="16" height="16"></rect>
|
|
||||||
<path d="M13.6799893,3.1 L8.4,3.1 L6.88000712,1.6 L2.32001069,1.6 C1.91688043,1.6 1.53025971,1.75802808 1.2452019,2.03933233 C0.960144092,2.32063658 0.8,2.70216899 0.8,3.1 L0.8,12.9000044 C0.8,13.297831 0.960144092,13.6793634 1.2452019,13.9606677 C1.53025971,14.2419719 1.91688043,14.4 2.32001069,14.4 L13.6799893,14.4 C14.0831196,14.4 14.4697403,14.2419719 14.7547981,13.9606677 C15.0398559,13.6793634 15.2,13.297831 15.2,12.9000044 L15.2,4.6 C15.2,4.2021646 15.0398559,3.82063218 14.7547981,3.53932793 C14.4697403,3.25802368 14.0831196,3.1 13.6799893,3.1 Z" id="形状结合" stroke="#495363" fill-rule="nonzero"></path>
|
|
||||||
<g id="用户-fill备份-2" transform="translate(3.0253, 5)"></g>
|
|
||||||
<path d="M4.75599236,8.36203629 C3.86179367,8.78539218 3.02525408,10.1376741 3.02525408,11.1791766 C3.02525408,12.2812179 8.35859002,12.2659807 8.35859002,11.1791766 C8.35816643,10.1352072 7.49670616,8.78415549 6.5999458,8.36203629 M5.68788119,5.00000016 C6.67822666,5.00000016 7.48054802,5.78946649 7.48054802,6.76384307 C7.48054802,7.73780959 6.67822666,8.52686586 5.68788119,8.52686586 C4.697529,8.52686586 3.89521437,7.73780959 3.89521437,6.76384307 C3.89521437,5.78987655 4.69753573,5.00000016 5.68788119,5.00000016 Z" id="形状" stroke="#495363" fill-rule="nonzero"></path>
|
|
||||||
<rect id="矩形" fill="#495363" fill-rule="nonzero" x="9.33333626" y="6.66666764" width="2.66666667" height="1" rx="0.5"></rect>
|
|
||||||
<rect id="矩形备份-2" fill="#495363" fill-rule="nonzero" x="9.33333626" y="9.33333594" width="4" height="1" rx="0.5"></rect>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 361 B |