提交工作更新

This commit is contained in:
super
2026-04-13 13:02:41 +08:00
parent 62d30ec190
commit cd84def61d
20 changed files with 932 additions and 60 deletions

File diff suppressed because one or more lines are too long

View File

@@ -1,19 +1,16 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"> <html lang="zh-CN">
<head>
<head> <meta charset="UTF-8" />
<meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>删除品牌 - 数富AI</title>
<title>删除品牌 - 数富AI</title> <script type="module" crossorigin src="/assets/delete-brand.js"></script>
<script type="module" crossorigin src="/assets/delete-brand.js"></script> <link rel="modulepreload" crossorigin href="/assets/pywebview-D808cNhy.js">
<link rel="modulepreload" crossorigin href="/assets/pywebview-D808cNhy.js"> <link rel="modulepreload" crossorigin href="/assets/brand-DRn3N4FA.js">
<link rel="modulepreload" crossorigin href="/assets/brand-DRn3N4FA.js"> <link rel="stylesheet" crossorigin href="/assets/pywebview-BLoeiUcF.css">
<link rel="stylesheet" crossorigin href="/assets/pywebview-BLoeiUcF.css"> <link rel="stylesheet" crossorigin href="/assets/delete-brand-DfRLfrtm.css">
<link rel="stylesheet" crossorigin href="/assets/delete-brand-DfRLfrtm.css"> </head>
</head> <body>
<div id="app"></div>
<body> </body>
<div id="app"></div>
</body>
</html> </html>

View File

@@ -6,12 +6,19 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.List;
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j @Slf4j
public class PermissionMenuSchemaInitializer { public class PermissionMenuSchemaInitializer {
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private static final List<DefaultAdminMenu> DEFAULT_ADMIN_MENUS = List.of(
new DefaultAdminMenu("数据去重总数据", "admin_dedupe_total_data", "dedupe-total-data"),
new DefaultAdminMenu("店铺管理", "admin_shop_manage", "shop-manage"),
new DefaultAdminMenu("跳过跟价ASIN", "admin_skip_price_asin", "skip-price-asin")
);
@PostConstruct @PostConstruct
public void initialize() { public void initialize() {
@@ -27,6 +34,7 @@ public class PermissionMenuSchemaInitializer {
executeQuietly("ALTER TABLE columns ADD COLUMN menu_type VARCHAR(20) NOT NULL DEFAULT 'app' COMMENT '菜单类型: app/admin' AFTER column_key"); executeQuietly("ALTER TABLE columns ADD COLUMN menu_type VARCHAR(20) NOT NULL DEFAULT 'app' COMMENT '菜单类型: app/admin' AFTER column_key");
executeQuietly("ALTER TABLE columns ADD COLUMN route_path VARCHAR(255) NOT NULL DEFAULT '' COMMENT '菜单路由或页面标识' AFTER menu_type"); executeQuietly("ALTER TABLE columns ADD COLUMN route_path VARCHAR(255) NOT NULL DEFAULT '' COMMENT '菜单路由或页面标识' AFTER menu_type");
executeQuietly("UPDATE columns SET menu_type = 'app' WHERE menu_type IS NULL OR menu_type = ''"); executeQuietly("UPDATE columns SET menu_type = 'app' WHERE menu_type IS NULL OR menu_type = ''");
ensureDefaultAdminMenus();
executeQuietly(""" executeQuietly("""
INSERT INTO columns (name, column_key, menu_type, route_path) INSERT INTO columns (name, column_key, menu_type, route_path)
SELECT '店铺管理', 'admin_shop_manage', 'admin', 'shop-manage' SELECT '店铺管理', 'admin_shop_manage', 'admin', 'shop-manage'
@@ -45,6 +53,18 @@ public class PermissionMenuSchemaInitializer {
"""); """);
} }
private void ensureDefaultAdminMenus() {
for (DefaultAdminMenu menu : DEFAULT_ADMIN_MENUS) {
executeQuietly("""
INSERT INTO columns (name, column_key, menu_type, route_path)
SELECT '%s', '%s', 'admin', '%s'
WHERE NOT EXISTS (
SELECT 1 FROM columns WHERE column_key = '%s'
)
""".formatted(menu.name(), menu.columnKey(), menu.routePath(), menu.columnKey()));
}
}
private void executeQuietly(String sql) { private void executeQuietly(String sql) {
try { try {
jdbcTemplate.execute(sql); jdbcTemplate.execute(sql);
@@ -52,4 +72,7 @@ public class PermissionMenuSchemaInitializer {
log.debug("[permission-menu] schema init skipped: {}", ex.getMessage()); log.debug("[permission-menu] schema init skipped: {}", ex.getMessage());
} }
} }
private record DefaultAdminMenu(String name, String columnKey, String routePath) {
}
} }

View File

@@ -0,0 +1,55 @@
package com.nanri.aiimage.modules.shopkey.controller;
import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinCreateRequest;
import com.nanri.aiimage.modules.shopkey.model.vo.SkipPriceAsinItemVo;
import com.nanri.aiimage.modules.shopkey.model.vo.SkipPriceAsinPageVo;
import com.nanri.aiimage.modules.shopkey.service.SkipPriceAsinService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/admin/skip-price-asins")
@Tag(name = "跳过跟价 ASIN", description = "维护店铺按国家跳过跟价的 ASIN")
public class SkipPriceAsinController {
private final SkipPriceAsinService skipPriceAsinService;
@GetMapping
@Operation(summary = "分页查询跳过跟价 ASIN")
public ApiResponse<SkipPriceAsinPageVo> page(
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page,
@Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize,
@Parameter(description = "分组ID") @RequestParam(required = false) Long groupId,
@Parameter(description = "店铺名") @RequestParam(required = false) String shopName,
@Parameter(description = "ASIN") @RequestParam(required = false) String asin) {
return ApiResponse.success(skipPriceAsinService.page(page, pageSize, groupId, shopName, asin));
}
@PostMapping
@Operation(summary = "新增或覆盖跳过跟价 ASIN")
public ApiResponse<SkipPriceAsinItemVo> create(@Valid @RequestBody SkipPriceAsinCreateRequest request) {
return ApiResponse.success("保存成功", skipPriceAsinService.createOrUpdate(request));
}
@DeleteMapping("/{id}/countries/{country}")
@Operation(summary = "删除指定国家的跳过跟价 ASIN")
public ApiResponse<Void> deleteCountry(
@Parameter(description = "主键ID", required = true) @PathVariable Long id,
@Parameter(description = "国家编码", required = true) @PathVariable String country) {
skipPriceAsinService.deleteCountry(id, country);
return ApiResponse.success("删除成功", null);
}
}

View File

@@ -0,0 +1,9 @@
package com.nanri.aiimage.modules.shopkey.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.nanri.aiimage.modules.shopkey.model.entity.SkipPriceAsinEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SkipPriceAsinMapper extends BaseMapper<SkipPriceAsinEntity> {
}

View File

@@ -0,0 +1,24 @@
package com.nanri.aiimage.modules.shopkey.model.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
@Data
public class SkipPriceAsinCreateRequest {
@NotNull(message = "分组不能为空")
private Long groupId;
@NotBlank(message = "店铺名不能为空")
private String shopName;
@NotEmpty(message = "请至少选择一个国家")
private List<String> countries;
@NotBlank(message = "ASIN 不能为空")
private String asin;
}

View File

@@ -0,0 +1,44 @@
package com.nanri.aiimage.modules.shopkey.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("biz_skip_price_asin")
public class SkipPriceAsinEntity {
@TableId(type = IdType.AUTO)
private Long id;
@TableField("group_id")
private Long groupId;
@TableField("shop_name")
private String shopName;
@TableField("asin_de")
private String asinDe;
@TableField("asin_uk")
private String asinUk;
@TableField("asin_fr")
private String asinFr;
@TableField("asin_it")
private String asinIt;
@TableField("asin_es")
private String asinEs;
@TableField("created_at")
private LocalDateTime createdAt;
@TableField("updated_at")
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,21 @@
package com.nanri.aiimage.modules.shopkey.model.vo;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class SkipPriceAsinItemVo {
private Long id;
private Long groupId;
private String groupName;
private String shopName;
private String asinDe;
private String asinUk;
private String asinFr;
private String asinIt;
private String asinEs;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,15 @@
package com.nanri.aiimage.modules.shopkey.model.vo;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class SkipPriceAsinPageVo {
private List<SkipPriceAsinItemVo> items = new ArrayList<>();
private Long total = 0L;
private Long page = 1L;
private Long pageSize = 15L;
}

View File

@@ -0,0 +1,212 @@
package com.nanri.aiimage.modules.shopkey.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.shopkey.mapper.SkipPriceAsinMapper;
import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinCreateRequest;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity;
import com.nanri.aiimage.modules.shopkey.model.entity.SkipPriceAsinEntity;
import com.nanri.aiimage.modules.shopkey.model.vo.SkipPriceAsinItemVo;
import com.nanri.aiimage.modules.shopkey.model.vo.SkipPriceAsinPageVo;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
@Service
@RequiredArgsConstructor
public class SkipPriceAsinService {
private static final List<String> SUPPORTED_COUNTRIES = List.of("DE", "UK", "FR", "IT", "ES");
private final SkipPriceAsinMapper skipPriceAsinMapper;
private final ShopManageGroupService shopManageGroupService;
public SkipPriceAsinPageVo page(long page, long pageSize, Long groupId, String shopName, String asin) {
long safePage = Math.max(page, 1);
long safePageSize = Math.min(Math.max(pageSize, 1), 100);
String safeShopName = normalizeBlank(shopName);
String safeAsin = normalizeBlank(asin);
LambdaQueryWrapper<SkipPriceAsinEntity> query = new LambdaQueryWrapper<SkipPriceAsinEntity>()
.eq(groupId != null && groupId > 0, SkipPriceAsinEntity::getGroupId, groupId)
.like(!safeShopName.isEmpty(), SkipPriceAsinEntity::getShopName, safeShopName)
.and(!safeAsin.isEmpty(), wrapper -> wrapper
.like(SkipPriceAsinEntity::getAsinDe, safeAsin)
.or().like(SkipPriceAsinEntity::getAsinUk, safeAsin)
.or().like(SkipPriceAsinEntity::getAsinFr, safeAsin)
.or().like(SkipPriceAsinEntity::getAsinIt, safeAsin)
.or().like(SkipPriceAsinEntity::getAsinEs, safeAsin))
.orderByDesc(SkipPriceAsinEntity::getId);
Long total = skipPriceAsinMapper.selectCount(query);
List<SkipPriceAsinEntity> rows = skipPriceAsinMapper
.selectList(query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize));
Map<Long, String> groupNameById = buildGroupNameMap(rows.stream()
.map(SkipPriceAsinEntity::getGroupId)
.filter(id -> id != null && id > 0)
.distinct()
.toList());
SkipPriceAsinPageVo vo = new SkipPriceAsinPageVo();
vo.setItems(rows.stream().map(row -> toItemVo(row, groupNameById.get(row.getGroupId()))).toList());
vo.setTotal(total);
vo.setPage(safePage);
vo.setPageSize(safePageSize);
return vo;
}
@Transactional
public SkipPriceAsinItemVo createOrUpdate(SkipPriceAsinCreateRequest request) {
ShopManageGroupEntity group = shopManageGroupService.getById(request.getGroupId());
String shopName = normalizeRequired(request.getShopName(), "店铺名不能为空");
String asin = normalizeAsin(request.getAsin());
Set<String> countries = normalizeCountries(request.getCountries());
SkipPriceAsinEntity entity = skipPriceAsinMapper.selectOne(new LambdaQueryWrapper<SkipPriceAsinEntity>()
.eq(SkipPriceAsinEntity::getGroupId, group.getId())
.eq(SkipPriceAsinEntity::getShopName, shopName)
.last("LIMIT 1"));
if (entity == null) {
entity = new SkipPriceAsinEntity();
entity.setGroupId(group.getId());
entity.setShopName(shopName);
}
for (String country : countries) {
setCountryAsin(entity, country, asin);
}
if (entity.getId() == null) {
skipPriceAsinMapper.insert(entity);
} else {
skipPriceAsinMapper.updateById(entity);
}
SkipPriceAsinEntity saved = getById(entity.getId());
return toItemVo(saved, group.getGroupName());
}
@Transactional
public void deleteCountry(Long id, String country) {
SkipPriceAsinEntity entity = getById(id);
String normalizedCountry = normalizeCountry(country);
setCountryAsin(entity, normalizedCountry, "");
if (isEmptyRow(entity)) {
skipPriceAsinMapper.deleteById(entity.getId());
return;
}
skipPriceAsinMapper.updateById(entity);
}
private SkipPriceAsinEntity getById(Long id) {
SkipPriceAsinEntity entity = skipPriceAsinMapper.selectById(id);
if (entity == null) {
throw new BusinessException("记录不存在");
}
return entity;
}
private SkipPriceAsinItemVo toItemVo(SkipPriceAsinEntity entity, String groupName) {
SkipPriceAsinItemVo vo = new SkipPriceAsinItemVo();
vo.setId(entity.getId());
vo.setGroupId(entity.getGroupId());
vo.setGroupName(groupName == null ? "" : groupName);
vo.setShopName(entity.getShopName());
vo.setAsinDe(blankToEmpty(entity.getAsinDe()));
vo.setAsinUk(blankToEmpty(entity.getAsinUk()));
vo.setAsinFr(blankToEmpty(entity.getAsinFr()));
vo.setAsinIt(blankToEmpty(entity.getAsinIt()));
vo.setAsinEs(blankToEmpty(entity.getAsinEs()));
vo.setCreatedAt(entity.getCreatedAt());
vo.setUpdatedAt(entity.getUpdatedAt());
return vo;
}
private Map<Long, String> buildGroupNameMap(List<Long> groupIds) {
if (groupIds == null || groupIds.isEmpty()) {
return Map.of();
}
LinkedHashMap<Long, String> map = new LinkedHashMap<>();
for (Long groupId : groupIds) {
try {
ShopManageGroupEntity group = shopManageGroupService.getById(groupId);
map.put(groupId, group.getGroupName());
} catch (Exception ignored) {
map.put(groupId, "");
}
}
return map;
}
private Set<String> normalizeCountries(List<String> countries) {
LinkedHashSet<String> normalized = new LinkedHashSet<>();
if (countries != null) {
for (String country : countries) {
normalized.add(normalizeCountry(country));
}
}
if (normalized.isEmpty()) {
throw new BusinessException("请至少选择一个国家");
}
return normalized;
}
private String normalizeCountry(String country) {
String normalized = normalizeBlank(country).toUpperCase(Locale.ROOT);
if (!SUPPORTED_COUNTRIES.contains(normalized)) {
throw new BusinessException("国家参数不支持: " + country);
}
return normalized;
}
private String normalizeAsin(String asin) {
String normalized = normalizeRequired(asin, "ASIN 不能为空").toUpperCase(Locale.ROOT);
if (normalized.length() > 64) {
throw new BusinessException("ASIN 长度不能超过 64");
}
return normalized;
}
private String normalizeRequired(String value, String message) {
String normalized = normalizeBlank(value);
if (normalized.isEmpty()) {
throw new BusinessException(message);
}
return normalized;
}
private String normalizeBlank(String value) {
return value == null ? "" : value.trim();
}
private String blankToEmpty(String value) {
return value == null ? "" : value;
}
private void setCountryAsin(SkipPriceAsinEntity entity, String country, String asin) {
String value = normalizeBlank(asin);
switch (country) {
case "DE" -> entity.setAsinDe(value);
case "UK" -> entity.setAsinUk(value);
case "FR" -> entity.setAsinFr(value);
case "IT" -> entity.setAsinIt(value);
case "ES" -> entity.setAsinEs(value);
default -> throw new BusinessException("国家参数不支持: " + country);
}
}
private boolean isEmptyRow(SkipPriceAsinEntity entity) {
return normalizeBlank(entity.getAsinDe()).isEmpty()
&& normalizeBlank(entity.getAsinUk()).isEmpty()
&& normalizeBlank(entity.getAsinFr()).isEmpty()
&& normalizeBlank(entity.getAsinIt()).isEmpty()
&& normalizeBlank(entity.getAsinEs()).isEmpty();
}
}

View File

@@ -0,0 +1,33 @@
CREATE TABLE IF NOT EXISTS biz_skip_price_asin (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
group_id BIGINT NOT NULL COMMENT '分组ID',
shop_name VARCHAR(128) NOT NULL COMMENT '店铺名',
asin_de VARCHAR(64) NOT NULL DEFAULT '' COMMENT '德国 ASIN',
asin_uk VARCHAR(64) NOT NULL DEFAULT '' COMMENT '英国 ASIN',
asin_fr VARCHAR(64) NOT NULL DEFAULT '' COMMENT '法国 ASIN',
asin_it VARCHAR(64) NOT NULL DEFAULT '' COMMENT '意大利 ASIN',
asin_es VARCHAR(64) NOT NULL DEFAULT '' COMMENT '西班牙 ASIN',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY uk_group_shop_name (group_id, shop_name),
KEY idx_shop_name (shop_name),
KEY idx_created_at (created_at)
) COMMENT='跳过跟价 ASIN';
INSERT INTO `columns` (`name`, `column_key`, `menu_type`, `route_path`)
SELECT '数据去重总数据', 'admin_dedupe_total_data', 'admin', 'dedupe-total-data'
WHERE NOT EXISTS (
SELECT 1 FROM `columns` WHERE `column_key` = 'admin_dedupe_total_data'
);
INSERT INTO `columns` (`name`, `column_key`, `menu_type`, `route_path`)
SELECT '店铺管理', 'admin_shop_manage', 'admin', 'shop-manage'
WHERE NOT EXISTS (
SELECT 1 FROM `columns` WHERE `column_key` = 'admin_shop_manage'
);
INSERT INTO `columns` (`name`, `column_key`, `menu_type`, `route_path`)
SELECT '跳过跟价ASIN', 'admin_skip_price_asin', 'admin', 'skip-price-asin'
WHERE NOT EXISTS (
SELECT 1 FROM `columns` WHERE `column_key` = 'admin_skip_price_asin'
);

View File

@@ -19,6 +19,8 @@ ALLOWED_DEDUPE_TOTAL_DATA_ADMIN_USERNAMES = {'刘丽泓'}
def _can_access_dedupe_total_data(role, current_row): def _can_access_dedupe_total_data(role, current_row):
_, _, denied = _ensure_admin_menu_access('dedupe-total-data')
return denied is None
if role == 'super_admin': if role == 'super_admin':
return True return True
if role != 'admin' or not current_row: if role != 'admin' or not current_row:
@@ -27,6 +29,7 @@ def _can_access_dedupe_total_data(role, current_row):
def _ensure_dedupe_total_data_access(): def _ensure_dedupe_total_data_access():
return _ensure_admin_menu_access('dedupe-total-data')
role, current_row = get_current_admin_role() role, current_row = get_current_admin_role()
if not _can_access_dedupe_total_data(role, current_row): if not _can_access_dedupe_total_data(role, current_row):
return None, None, (jsonify({'success': False, 'error': '无权限访问数据去重总数据'}), 403) return None, None, (jsonify({'success': False, 'error': '无权限访问数据去重总数据'}), 403)
@@ -41,6 +44,24 @@ except ImportError:
admin_api = Blueprint('admin_api', __name__, url_prefix='/api/admin') admin_api = Blueprint('admin_api', __name__, url_prefix='/api/admin')
ADMIN_MENU_ACCESS_CONFIG = {
'dedupe-total-data': {
'column_key': 'admin_dedupe_total_data',
'route_path': 'dedupe-total-data',
'error': '鏃犳潈闄愯闂暟鎹幓閲嶆€绘暟鎹?',
},
'shop-manage': {
'column_key': 'admin_shop_manage',
'route_path': 'shop-manage',
'error': '鏃犳潈闄愯闂簵閾虹鐞嗘ā鍧?',
},
'skip-price-asin': {
'column_key': 'admin_skip_price_asin',
'route_path': 'skip-price-asin',
'error': '鏃犳潈闄愯闂烦杩囪窡浠稟SIN 妯″潡',
},
}
def _safe_version_key(version): def _safe_version_key(version):
"""将版本号转为安全的 OSS 对象名部分""" """将版本号转为安全的 OSS 对象名部分"""
@@ -90,6 +111,47 @@ def _sync_user_column_permissions(user_id, column_ids):
return error_response, status return error_response, status
def _get_current_admin_permission_sets():
user_id = session.get('user_id')
if not user_id:
return set(), set()
conn = get_db()
try:
with conn.cursor() as cur:
cur.execute(
"""SELECT c.column_key, c.route_path
FROM user_column_permission ucp
INNER JOIN columns c ON c.id = ucp.column_id
WHERE ucp.user_id = %s AND c.menu_type = 'admin'""",
(user_id,),
)
rows = cur.fetchall()
finally:
conn.close()
key_set = {(row.get('column_key') or '').strip() for row in rows if (row.get('column_key') or '').strip()}
route_set = {(row.get('route_path') or '').strip() for row in rows if (row.get('route_path') or '').strip()}
return key_set, route_set
def _ensure_admin_menu_access(*menu_names):
role, current_row = get_current_admin_role()
if role == 'super_admin':
return role, current_row, None
if role != 'admin' or not current_row:
return role, current_row, (jsonify({'success': False, 'error': '闇€瑕佺鐞嗗憳鏉冮檺'}), 403)
key_set, route_set = _get_current_admin_permission_sets()
for menu_name in menu_names:
config = ADMIN_MENU_ACCESS_CONFIG.get(menu_name) or {}
column_key = (config.get('column_key') or '').strip()
route_path = (config.get('route_path') or '').strip()
if (column_key and column_key in key_set) or (route_path and route_path in route_set):
return role, current_row, None
fallback_menu = menu_names[0] if menu_names else ''
fallback_config = ADMIN_MENU_ACCESS_CONFIG.get(fallback_menu) or {}
error_message = fallback_config.get('error') or '鏃犳潈闄愯闂綋鍓嶆ā鍧?'
return role, current_row, (jsonify({'success': False, 'error': error_message}), 403)
# ---------- 用户管理 ---------- # ---------- 用户管理 ----------
@admin_api.route('/users') @admin_api.route('/users')
@@ -936,6 +998,9 @@ def delete_dedupe_total_data(item_id):
@admin_api.route('/shop-manages') @admin_api.route('/shop-manages')
@admin_required @admin_required
def list_shop_manages(): def list_shop_manages():
_, _, denied = _ensure_admin_menu_access('shop-manage')
if denied:
return denied
page = max(1, int(request.args.get('page', 1))) page = max(1, int(request.args.get('page', 1)))
page_size = min(100, max(1, int(request.args.get('page_size', 15)))) page_size = min(100, max(1, int(request.args.get('page_size', 15))))
group_id_raw = (request.args.get('group_id') or '').strip() group_id_raw = (request.args.get('group_id') or '').strip()
@@ -986,6 +1051,9 @@ def list_shop_manages():
@admin_api.route('/shop-manage', methods=['POST']) @admin_api.route('/shop-manage', methods=['POST'])
@admin_required @admin_required
def create_shop_manage(): def create_shop_manage():
_, _, denied = _ensure_admin_menu_access('shop-manage')
if denied:
return denied
data = request.get_json() or {} data = request.get_json() or {}
payload = { payload = {
'groupId': data.get('group_id'), 'groupId': data.get('group_id'),
@@ -1022,6 +1090,9 @@ def create_shop_manage():
@admin_api.route('/shop-manage/<int:item_id>', methods=['PUT']) @admin_api.route('/shop-manage/<int:item_id>', methods=['PUT'])
@admin_required @admin_required
def update_shop_manage(item_id): def update_shop_manage(item_id):
_, _, denied = _ensure_admin_menu_access('shop-manage')
if denied:
return denied
data = request.get_json() or {} data = request.get_json() or {}
payload = { payload = {
'groupId': data.get('group_id'), 'groupId': data.get('group_id'),
@@ -1057,6 +1128,9 @@ def update_shop_manage(item_id):
@admin_api.route('/shop-manage/<int:item_id>', methods=['DELETE']) @admin_api.route('/shop-manage/<int:item_id>', methods=['DELETE'])
@admin_required @admin_required
def delete_shop_manage(item_id): def delete_shop_manage(item_id):
_, _, denied = _ensure_admin_menu_access('shop-manage')
if denied:
return denied
result, error_response, status = _proxy_backend_java( result, error_response, status = _proxy_backend_java(
'DELETE', 'DELETE',
f'/api/admin/shop-manages/{item_id}', f'/api/admin/shop-manages/{item_id}',
@@ -1072,6 +1146,9 @@ def delete_shop_manage(item_id):
@admin_api.route('/shop-manage-groups') @admin_api.route('/shop-manage-groups')
@admin_required @admin_required
def list_shop_manage_groups(): def list_shop_manage_groups():
_, _, denied = _ensure_admin_menu_access('shop-manage', 'skip-price-asin')
if denied:
return denied
result, error_response, status = _proxy_backend_java( result, error_response, status = _proxy_backend_java(
'GET', 'GET',
'/api/admin/shop-manages/groups', '/api/admin/shop-manages/groups',
@@ -1093,6 +1170,9 @@ def list_shop_manage_groups():
@admin_api.route('/shop-manage-group', methods=['POST']) @admin_api.route('/shop-manage-group', methods=['POST'])
@admin_required @admin_required
def create_shop_manage_group(): def create_shop_manage_group():
_, _, denied = _ensure_admin_menu_access('shop-manage', 'skip-price-asin')
if denied:
return denied
data = request.get_json() or {} data = request.get_json() or {}
payload = {'groupName': (data.get('group_name') or '').strip()} payload = {'groupName': (data.get('group_name') or '').strip()}
result, error_response, status = _proxy_backend_java( result, error_response, status = _proxy_backend_java(
@@ -1118,6 +1198,9 @@ def create_shop_manage_group():
@admin_api.route('/shop-manage-group/<int:item_id>', methods=['PUT']) @admin_api.route('/shop-manage-group/<int:item_id>', methods=['PUT'])
@admin_required @admin_required
def update_shop_manage_group(item_id): def update_shop_manage_group(item_id):
_, _, denied = _ensure_admin_menu_access('shop-manage', 'skip-price-asin')
if denied:
return denied
data = request.get_json() or {} data = request.get_json() or {}
payload = {'groupName': (data.get('group_name') or '').strip()} payload = {'groupName': (data.get('group_name') or '').strip()}
result, error_response, status = _proxy_backend_java( result, error_response, status = _proxy_backend_java(
@@ -1140,9 +1223,123 @@ def update_shop_manage_group(item_id):
}) })
@admin_api.route('/skip-price-asins')
@admin_required
def list_skip_price_asins():
_, _, denied = _ensure_admin_menu_access('skip-price-asin')
if denied:
return denied
page = max(1, int(request.args.get('page', 1)))
page_size = min(100, max(1, int(request.args.get('page_size', 15))))
group_id_raw = (request.args.get('group_id') or '').strip()
shop_name = (request.args.get('shop_name') or '').strip()
asin = (request.args.get('asin') or '').strip()
params = {'page': page, 'pageSize': page_size}
if group_id_raw:
try:
group_id = int(group_id_raw)
if group_id > 0:
params['groupId'] = group_id
except (TypeError, ValueError):
pass
if shop_name:
params['shopName'] = shop_name
if asin:
params['asin'] = asin
result, error_response, status = _proxy_backend_java(
'GET',
'/api/admin/skip-price-asins',
params=params,
)
if error_response is not None:
return error_response, status
payload = result.get('data') or {}
items = [
{
'id': item.get('id'),
'group_id': item.get('groupId'),
'group_name': item.get('groupName') or '',
'shop_name': item.get('shopName') or '',
'asin_de': item.get('asinDe') or '',
'asin_uk': item.get('asinUk') or '',
'asin_fr': item.get('asinFr') or '',
'asin_it': item.get('asinIt') or '',
'asin_es': item.get('asinEs') or '',
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
}
for item in (payload.get('items') or [])
]
return jsonify({
'success': True,
'items': items,
'total': payload.get('total') or 0,
'page': payload.get('page') or page,
'page_size': payload.get('pageSize') or page_size,
})
@admin_api.route('/skip-price-asin', methods=['POST'])
@admin_required
def create_skip_price_asin():
_, _, denied = _ensure_admin_menu_access('skip-price-asin')
if denied:
return denied
data = request.get_json() or {}
payload = {
'groupId': data.get('group_id'),
'shopName': (data.get('shop_name') or '').strip(),
'countries': data.get('countries') or [],
'asin': (data.get('asin') or '').strip(),
}
result, error_response, status = _proxy_backend_java(
'POST',
'/api/admin/skip-price-asins',
json_data=payload,
)
if error_response is not None:
return error_response, status
item = result.get('data') or {}
return jsonify({
'success': True,
'msg': result.get('message') or '淇濆瓨鎴愬姛',
'item': {
'id': item.get('id'),
'group_id': item.get('groupId'),
'group_name': item.get('groupName') or '',
'shop_name': item.get('shopName') or '',
'asin_de': item.get('asinDe') or '',
'asin_uk': item.get('asinUk') or '',
'asin_fr': item.get('asinFr') or '',
'asin_it': item.get('asinIt') or '',
'asin_es': item.get('asinEs') or '',
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
},
})
@admin_api.route('/skip-price-asin/<int:item_id>/country/<country>', methods=['DELETE'])
@admin_required
def delete_skip_price_asin_country(item_id, country):
_, _, denied = _ensure_admin_menu_access('skip-price-asin')
if denied:
return denied
result, error_response, status = _proxy_backend_java(
'DELETE',
f'/api/admin/skip-price-asins/{item_id}/countries/{country}',
)
if error_response is not None:
return error_response, status
return jsonify({'success': True, 'msg': result.get('message') or '鍒犻櫎鎴愬姛'})
@admin_api.route('/shop-manage-group/<int:item_id>', methods=['DELETE']) @admin_api.route('/shop-manage-group/<int:item_id>', methods=['DELETE'])
@admin_required @admin_required
def delete_shop_manage_group(item_id): def delete_shop_manage_group(item_id):
_, _, denied = _ensure_admin_menu_access('shop-manage', 'skip-price-asin')
if denied:
return denied
result, error_response, status = _proxy_backend_java( result, error_response, status = _proxy_backend_java(
'DELETE', 'DELETE',
f'/api/admin/shop-manages/groups/{item_id}', f'/api/admin/shop-manages/groups/{item_id}',

View File

@@ -114,6 +114,7 @@
<div class="tab" data-tab="dedupe-total-data">数据去重总数据</div> <div class="tab" data-tab="dedupe-total-data">数据去重总数据</div>
<div class="tab" data-tab="shop-keys">店铺密钥管理</div> <div class="tab" data-tab="shop-keys">店铺密钥管理</div>
<div class="tab" data-tab="shop-manage">店铺管理</div> <div class="tab" data-tab="shop-manage">店铺管理</div>
<div class="tab" data-tab="skip-price-asin">跳过跟价 ASIN</div>
<div class="tab" data-tab="history">查看生成记录</div> <div class="tab" data-tab="history">查看生成记录</div>
<div class="tab" data-tab="version">版本管理</div> <div class="tab" data-tab="version">版本管理</div>
</div> </div>
@@ -421,6 +422,79 @@
</div> </div>
<!-- 查看生成记录 --> <!-- 查看生成记录 -->
<div id="panel-skip-price-asin" class="tab-panel">
<div class="form-box">
<h3 style="margin-bottom:16px;font-size:15px;">新增 ASIN</h3>
<div class="form-row">
<div class="form-group" style="min-width:220px;">
<label>分组</label>
<div style="display:flex;gap:8px;align-items:center;">
<select id="skipPriceAsinGroupSelect" style="min-width:150px;">
<option value="">请选择分组</option>
</select>
<button class="btn btn-secondary" id="btnManageSkipPriceAsinGroups" type="button">管理分组</button>
</div>
</div>
<div class="form-group" style="min-width:180px;">
<label>店铺名</label>
<input type="text" id="skipPriceAsinShopName" placeholder="请输入店铺名称">
</div>
<div class="form-group" style="min-width:220px;">
<label>国家</label>
<select id="skipPriceAsinCountries" multiple size="5">
<option value="DE">德国</option>
<option value="UK">英国</option>
<option value="FR">法国</option>
<option value="IT">意大利</option>
<option value="ES">西班牙</option>
</select>
</div>
<div class="form-group" style="min-width:180px;">
<label>ASIN</label>
<input type="text" id="skipPriceAsinValue" placeholder="请输入 ASIN">
</div>
<button class="btn" id="btnCreateSkipPriceAsin">新增 ASIN</button>
</div>
<p class="msg" id="msgSkipPriceAsin"></p>
</div>
<div class="panel-box">
<h3 style="margin-bottom:16px;font-size:15px;">店铺列表</h3>
<div class="form-row" style="margin-bottom:12px;">
<div class="form-group" style="min-width:180px;">
<label>分组</label>
<select id="skipPriceAsinFilterGroupId">
<option value="">全部分组</option>
</select>
</div>
<div class="form-group" style="min-width:220px;">
<label>店铺名</label>
<input type="text" id="skipPriceAsinFilterShopName" placeholder="请输入店铺名">
</div>
<div class="form-group" style="min-width:220px;">
<label>ASIN</label>
<input type="text" id="skipPriceAsinFilterAsin" placeholder="请输入 ASIN">
</div>
<button class="btn" id="btnSearchSkipPriceAsin">查询</button>
</div>
<table>
<thead>
<tr>
<th>序号</th>
<th>分组</th>
<th>店铺名</th>
<th>德国</th>
<th>英国</th>
<th>法国</th>
<th>意大利</th>
<th>西班牙</th>
</tr>
</thead>
<tbody id="skipPriceAsinListBody"></tbody>
</table>
<div class="pagination" id="skipPriceAsinPagination"></div>
</div>
</div>
<div id="panel-history" class="tab-panel"> <div id="panel-history" class="tab-panel">
<div class="form-box"> <div class="form-box">
<h3 style="margin-bottom:16px;font-size:15px;">筛选条件</h3> <h3 style="margin-bottom:16px;font-size:15px;">筛选条件</h3>
@@ -627,6 +701,23 @@
<script> <script>
(function() { (function() {
// Tab 切换 // Tab 切换
var ADMIN_TAB_ACCESS_CONFIG = {
'columns': { superAdminOnly: true },
'shop-keys': { superAdminOnly: true },
'dedupe-total-data': { columnKey: 'admin_dedupe_total_data', routePath: 'dedupe-total-data' },
'shop-manage': { columnKey: 'admin_shop_manage', routePath: 'shop-manage' },
'skip-price-asin': { columnKey: 'admin_skip_price_asin', routePath: 'skip-price-asin' }
};
function runTabLoader(tabName) {
if (tabName === 'users') { loadUsers(1); loadColumnsForPermission(); }
else if (tabName === 'columns') loadColumns();
else if (tabName === 'dedupe-total-data') loadDedupeTotalData(1);
else if (tabName === 'shop-keys') loadShopKeys(1);
else if (tabName === 'shop-manage') loadShopManage(1);
else if (tabName === 'skip-price-asin') loadSkipPriceAsin(1);
else if (tabName === 'history') loadHistory(1);
else if (tabName === 'version') loadVersions();
}
document.querySelectorAll('.tab').forEach(function(t) { document.querySelectorAll('.tab').forEach(function(t) {
t.onclick = function() { t.onclick = function() {
document.querySelectorAll('.tab').forEach(function(x) { x.classList.remove('active'); }); document.querySelectorAll('.tab').forEach(function(x) { x.classList.remove('active'); });
@@ -634,13 +725,7 @@
t.classList.add('active'); t.classList.add('active');
var id = 'panel-' + t.dataset.tab; var id = 'panel-' + t.dataset.tab;
document.getElementById(id).classList.add('active'); document.getElementById(id).classList.add('active');
if (t.dataset.tab === 'users') { loadUsers(1); loadColumnsForPermission(); } runTabLoader(t.dataset.tab);
else if (t.dataset.tab === 'columns') loadColumns();
else if (t.dataset.tab === 'dedupe-total-data') loadDedupeTotalData(1);
else if (t.dataset.tab === 'shop-keys') loadShopKeys(1);
else if (t.dataset.tab === 'shop-manage') loadShopManage(1);
else if (t.dataset.tab === 'history') loadHistory(1);
else if (t.dataset.tab === 'version') loadVersions();
}; };
}); });
@@ -695,7 +780,7 @@
bindUserActions(); bindUserActions();
updateCreateFormByRole(); updateCreateFormByRole();
updateUserFilterByRole(); updateUserFilterByRole();
updateDedupeTotalDataAccess(); refreshAdminTabAccess();
loadCurrentUserAdminPermissions(); loadCurrentUserAdminPermissions();
}) })
.catch(function() { .catch(function() {
@@ -794,39 +879,31 @@
if (usersPanel) usersPanel.classList.add('active'); if (usersPanel) usersPanel.classList.add('active');
} }
} }
function updateAdminOnlyAccess() { function hasAdminTabAccess(tabName) {
var canUse = currentUserRole === 'super_admin'; var config = ADMIN_TAB_ACCESS_CONFIG[tabName];
applyTabAccess('columns', canUse); if (!config) return true;
applyTabAccess('shop-keys', canUse); if (currentUserRole === 'super_admin') return true;
if (config.superAdminOnly) return false;
return !!currentUserAdminPermissionKeys[config.columnKey] ||
!!currentUserAdminPermissionRoutes[config.routePath];
} }
function updateDedupeTotalDataAccess() { function refreshAdminTabAccess() {
var canUse = currentUserRole === 'super_admin' || Object.keys(ADMIN_TAB_ACCESS_CONFIG).forEach(function(tabName) {
!!currentUserAdminPermissionKeys['admin_dedupe_total_data'] || applyTabAccess(tabName, hasAdminTabAccess(tabName));
!!currentUserAdminPermissionRoutes['dedupe-total-data']; });
applyTabAccess('dedupe-total-data', canUse);
}
function updateShopManageAccess() {
var canUse = currentUserRole === 'super_admin' ||
!!currentUserAdminPermissionKeys['admin_shop_manage'] ||
!!currentUserAdminPermissionRoutes['shop-manage'];
applyTabAccess('shop-manage', canUse);
} }
function loadCurrentUserAdminPermissions() { function loadCurrentUserAdminPermissions() {
currentUserAdminPermissionKeys = {}; currentUserAdminPermissionKeys = {};
currentUserAdminPermissionRoutes = {}; currentUserAdminPermissionRoutes = {};
updateAdminOnlyAccess();
if (!currentUserId || currentUserRole === 'super_admin') { if (!currentUserId || currentUserRole === 'super_admin') {
updateDedupeTotalDataAccess(); refreshAdminTabAccess();
updateShopManageAccess();
return; return;
} }
fetch('/api/admin/user/' + currentUserId + '/column-permissions?menu_type=admin') fetch('/api/admin/user/' + currentUserId + '/column-permissions?menu_type=admin')
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(res) { .then(function(res) {
if (!res.success) { if (!res.success) {
updateAdminOnlyAccess(); refreshAdminTabAccess();
updateDedupeTotalDataAccess();
updateShopManageAccess();
return; return;
} }
(res.items || []).forEach(function(item) { (res.items || []).forEach(function(item) {
@@ -835,14 +912,10 @@
if (columnKey) currentUserAdminPermissionKeys[columnKey] = true; if (columnKey) currentUserAdminPermissionKeys[columnKey] = true;
if (routePath) currentUserAdminPermissionRoutes[routePath] = true; if (routePath) currentUserAdminPermissionRoutes[routePath] = true;
}); });
updateAdminOnlyAccess(); refreshAdminTabAccess();
updateDedupeTotalDataAccess();
updateShopManageAccess();
}) })
.catch(function() { .catch(function() {
updateAdminOnlyAccess(); refreshAdminTabAccess();
updateDedupeTotalDataAccess();
updateShopManageAccess();
}); });
} }
var allColumnsList = []; var allColumnsList = [];
@@ -1604,7 +1677,11 @@
var createSel = document.getElementById('shopManageGroupSelect'); var createSel = document.getElementById('shopManageGroupSelect');
var editSel = document.getElementById('editShopManageGroupSelect'); var editSel = document.getElementById('editShopManageGroupSelect');
var filterSel = document.getElementById('shopManageFilterGroupId'); var filterSel = document.getElementById('shopManageFilterGroupId');
var skipCreateSel = document.getElementById('skipPriceAsinGroupSelect');
var skipFilterSel = document.getElementById('skipPriceAsinFilterGroupId');
var selectedFilterId = filterSel ? filterSel.value : ''; var selectedFilterId = filterSel ? filterSel.value : '';
var selectedSkipCreateId = skipCreateSel ? skipCreateSel.value : '';
var selectedSkipFilterId = skipFilterSel ? skipFilterSel.value : '';
var createOpts = ['<option value="">请选择分组</option>']; var createOpts = ['<option value="">请选择分组</option>'];
var filterOpts = ['<option value="">全部分组</option>']; var filterOpts = ['<option value="">全部分组</option>'];
shopManageGroups.forEach(function(g) { shopManageGroups.forEach(function(g) {
@@ -1614,10 +1691,14 @@
}); });
createSel.innerHTML = createOpts.join(''); createSel.innerHTML = createOpts.join('');
editSel.innerHTML = createOpts.join(''); editSel.innerHTML = createOpts.join('');
if (skipCreateSel) skipCreateSel.innerHTML = createOpts.join('');
if (filterSel) filterSel.innerHTML = filterOpts.join(''); if (filterSel) filterSel.innerHTML = filterOpts.join('');
if (skipFilterSel) skipFilterSel.innerHTML = filterOpts.join('');
if (selectedCreateId != null) createSel.value = String(selectedCreateId); if (selectedCreateId != null) createSel.value = String(selectedCreateId);
if (selectedEditId != null) editSel.value = String(selectedEditId); if (selectedEditId != null) editSel.value = String(selectedEditId);
if (filterSel && selectedFilterId) filterSel.value = selectedFilterId; if (filterSel && selectedFilterId) filterSel.value = selectedFilterId;
if (skipCreateSel && selectedSkipCreateId) skipCreateSel.value = selectedSkipCreateId;
if (skipFilterSel && selectedSkipFilterId) skipFilterSel.value = selectedSkipFilterId;
} }
function loadShopManageGroups(selectedCreateId, selectedEditId) { function loadShopManageGroups(selectedCreateId, selectedEditId) {
@@ -1682,6 +1763,7 @@
loadShopManageGroups().then(function() { loadShopManageGroups().then(function() {
renderShopManageGroupRows(); renderShopManageGroupRows();
loadShopManage(shopManagePage); loadShopManage(shopManagePage);
loadSkipPriceAsin(skipPriceAsinPage);
}); });
}); });
}; };
@@ -1799,6 +1881,7 @@
loadShopManageGroups().then(function() { loadShopManageGroups().then(function() {
renderShopManageGroupRows(); renderShopManageGroupRows();
loadShopManage(shopManagePage); loadShopManage(shopManagePage);
loadSkipPriceAsin(skipPriceAsinPage);
}); });
}) })
.catch(function() { .catch(function() {
@@ -1888,6 +1971,143 @@
}; };
// ========== 版本管理 ========== // ========== 版本管理 ==========
// ========== 跳过跟价 ASIN ==========
var skipPriceAsinPage = 1, skipPriceAsinPageSize = 15;
var skipPriceCountryColumns = [
{ code: 'DE', field: 'asin_de', label: '德国' },
{ code: 'UK', field: 'asin_uk', label: '英国' },
{ code: 'FR', field: 'asin_fr', label: '法国' },
{ code: 'IT', field: 'asin_it', label: '意大利' },
{ code: 'ES', field: 'asin_es', label: '西班牙' }
];
function buildSkipPriceAsinQuery(page) {
var query = 'page=' + (page || 1) + '&page_size=' + skipPriceAsinPageSize;
var groupId = (document.getElementById('skipPriceAsinFilterGroupId').value || '').trim();
var shopName = (document.getElementById('skipPriceAsinFilterShopName').value || '').trim();
var asin = (document.getElementById('skipPriceAsinFilterAsin').value || '').trim();
if (groupId) query += '&group_id=' + encodeURIComponent(groupId);
if (shopName) query += '&shop_name=' + encodeURIComponent(shopName);
if (asin) query += '&asin=' + encodeURIComponent(asin);
return query;
}
function renderSkipPriceAsinCell(item, country) {
var value = item[country.field] || '';
if (!value) return '<span style="color:#999;">-</span>';
return '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">' +
'<span>' + value + '</span>' +
'<button class="btn btn-sm btn-danger" data-skip-price-asin-delete="' + item.id + '" data-country="' + country.code + '" data-shop-name="' + (item.shop_name || '').replace(/"/g, '&quot;') + '">删除</button>' +
'</div>';
}
function bindSkipPriceAsinActions() {
document.querySelectorAll('[data-skip-price-asin-delete]').forEach(function(btn) {
btn.onclick = function() {
var shopName = (btn.dataset.shopName || '').replace(/&quot;/g, '"');
var countryCode = btn.dataset.country || '';
if (!confirm('确定删除店铺“' + shopName + '”在 ' + countryCode + ' 的 ASIN 吗?')) return;
fetch('/api/admin/skip-price-asin/' + btn.dataset.skipPriceAsinDelete + '/country/' + countryCode, {
method: 'DELETE'
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.success) loadSkipPriceAsin(skipPriceAsinPage);
else alert(res.error || '删除失败');
});
};
});
}
function loadSkipPriceAsin(page) {
skipPriceAsinPage = page || 1;
fetch('/api/admin/skip-price-asins?' + buildSkipPriceAsinQuery(skipPriceAsinPage))
.then(function(r) { return r.json(); })
.then(function(res) {
var tbody = document.getElementById('skipPriceAsinListBody');
if (!res.success) {
tbody.innerHTML = '<tr><td colspan="8" class="empty-tip">加载失败: ' + (res.error || '') + '</td></tr>';
return;
}
var items = res.items || [];
if (items.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="empty-tip">暂无数据</td></tr>';
} else {
tbody.innerHTML = items.map(function(item, index) {
var rowNo = (skipPriceAsinPage - 1) * skipPriceAsinPageSize + index + 1;
return '<tr><td>' + rowNo + '</td><td>' + (item.group_name || '') + '</td><td>' + (item.shop_name || '') + '</td>' +
skipPriceCountryColumns.map(function(country) {
return '<td>' + renderSkipPriceAsinCell(item, country) + '</td>';
}).join('') +
'</tr>';
}).join('');
}
renderPagination('skipPriceAsinPagination', res.total, res.page, res.page_size, loadSkipPriceAsin);
bindSkipPriceAsinActions();
})
.catch(function() {
document.getElementById('skipPriceAsinListBody').innerHTML = '<tr><td colspan="8" class="empty-tip">请求失败</td></tr>';
});
}
document.getElementById('btnManageSkipPriceAsinGroups').onclick = openShopManageGroupModal;
document.getElementById('btnSearchSkipPriceAsin').onclick = function() {
loadSkipPriceAsin(1);
};
document.getElementById('skipPriceAsinFilterShopName').addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
loadSkipPriceAsin(1);
}
});
document.getElementById('skipPriceAsinFilterAsin').addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
loadSkipPriceAsin(1);
}
});
document.getElementById('btnCreateSkipPriceAsin').onclick = function() {
var groupId = (document.getElementById('skipPriceAsinGroupSelect').value || '').trim();
var shopName = (document.getElementById('skipPriceAsinShopName').value || '').trim();
var asin = (document.getElementById('skipPriceAsinValue').value || '').trim();
var countries = Array.from(document.getElementById('skipPriceAsinCountries').selectedOptions).map(function(option) {
return option.value;
});
var msgEl = document.getElementById('msgSkipPriceAsin');
msgEl.textContent = '';
msgEl.className = 'msg';
if (!groupId || !shopName || !asin || !countries.length) {
msgEl.textContent = '请完整填写分组、店铺名、国家和 ASIN';
msgEl.className = 'msg err';
return;
}
fetch('/api/admin/skip-price-asin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
group_id: Number(groupId),
shop_name: shopName,
countries: countries,
asin: asin
})
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (!res.success) {
msgEl.textContent = res.error || '保存失败';
msgEl.className = 'msg err';
return;
}
document.getElementById('skipPriceAsinGroupSelect').value = '';
document.getElementById('skipPriceAsinShopName').value = '';
document.getElementById('skipPriceAsinValue').value = '';
Array.from(document.getElementById('skipPriceAsinCountries').options).forEach(function(option) {
option.selected = false;
});
msgEl.textContent = res.msg || '保存成功';
msgEl.className = 'msg ok';
loadSkipPriceAsin(1);
})
.catch(function() {
msgEl.textContent = '请求失败';
msgEl.className = 'msg err';
});
};
function loadVersions() { function loadVersions() {
fetch('/api/admin/versions') fetch('/api/admin/versions')
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
@@ -2199,6 +2419,7 @@
loadShopKeys(1); loadShopKeys(1);
loadShopManageGroups(); loadShopManageGroups();
loadShopManage(1); loadShopManage(1);
loadSkipPriceAsin(1);
})(); })();
</script> </script>
</body> </body>

View File

@@ -181,10 +181,27 @@ function loadTaskSnapshotsFromStorage() { try { const raw = window.localStorage.
function saveTaskSnapshotsToStorage() { setStorageJson(taskSnapshotsStorageKey(), taskSnapshots.value, Object.keys(taskSnapshots.value).length === 0) } function saveTaskSnapshotsToStorage() { setStorageJson(taskSnapshotsStorageKey(), taskSnapshots.value, Object.keys(taskSnapshots.value).length === 0) }
function loadMatchedItemsFromStorage() { try { const raw = window.localStorage.getItem(matchedItemsStorageKey()); matchedItems.value = raw ? JSON.parse(raw) : [] } catch { matchedItems.value = [] } } function loadMatchedItemsFromStorage() { try { const raw = window.localStorage.getItem(matchedItemsStorageKey()); matchedItems.value = raw ? JSON.parse(raw) : [] } catch { matchedItems.value = [] } }
function saveMatchedItemsToStorage() { setStorageJson(matchedItemsStorageKey(), matchedItems.value, matchedItems.value.length === 0) } function saveMatchedItemsToStorage() { setStorageJson(matchedItemsStorageKey(), matchedItems.value, matchedItems.value.length === 0) }
function isTaskMissingError(error: unknown) { const message = error instanceof Error ? error.message : String(error || ''); return message.includes('任务不存在') }
function syncPollingIdsWithTaskState() { const nextIds = pollingTaskIds.value.filter((taskId) => (taskDetails.value[taskId] || taskSnapshots.value[taskId]?.task?.status) === 'RUNNING'); if (nextIds.length === pollingTaskIds.value.length) return; pollingTaskIds.value = nextIds; savePollingIds() } function syncPollingIdsWithTaskState() { const nextIds = pollingTaskIds.value.filter((taskId) => (taskDetails.value[taskId] || taskSnapshots.value[taskId]?.task?.status) === 'RUNNING'); if (nextIds.length === pollingTaskIds.value.length) return; pollingTaskIds.value = nextIds; savePollingIds() }
function addPollingTask(taskId: number) { if (!pollingTaskIds.value.includes(taskId)) { pollingTaskIds.value = [...pollingTaskIds.value, taskId]; savePollingIds() } } function addPollingTask(taskId: number) { if (!pollingTaskIds.value.includes(taskId)) { pollingTaskIds.value = [...pollingTaskIds.value, taskId]; savePollingIds() } }
function clearDispatchTimer(taskId: number) { const timer = dispatchTimers.get(taskId); if (timer) { window.clearTimeout(timer); dispatchTimers.delete(taskId) } } function clearDispatchTimer(taskId: number) { const timer = dispatchTimers.get(taskId); if (timer) { window.clearTimeout(timer); dispatchTimers.delete(taskId) } }
function removePollingTask(taskId: number) { clearDispatchTimer(taskId); pollingTaskIds.value = pollingTaskIds.value.filter((id) => id !== taskId); delete taskDetails.value[taskId]; savePollingIds(); saveTaskDetailsToStorage() } function removePollingTask(taskId: number) {
clearDispatchTimer(taskId)
pollingTaskIds.value = pollingTaskIds.value.filter((id) => id !== taskId)
delete taskDetails.value[taskId]
if (taskSnapshots.value[taskId]) {
const nextSnapshots = { ...taskSnapshots.value }
delete nextSnapshots[taskId]
taskSnapshots.value = nextSnapshots
saveTaskSnapshotsToStorage()
}
savePollingIds()
saveTaskDetailsToStorage()
}
function removeTaskLocally(taskId: number) {
removePollingTask(taskId)
historyItems.value = historyItems.value.filter((row) => normalizeTaskId(row.taskId) !== taskId)
}
function stopPollingTask(taskId: number, keepStatus = true) { pollingTaskIds.value = pollingTaskIds.value.filter((id) => id !== taskId); if (!keepStatus) delete taskDetails.value[taskId]; savePollingIds(); saveTaskDetailsToStorage() } function stopPollingTask(taskId: number, keepStatus = true) { pollingTaskIds.value = pollingTaskIds.value.filter((id) => id !== taskId); if (!keepStatus) delete taskDetails.value[taskId]; savePollingIds(); saveTaskDetailsToStorage() }
function isTaskTerminalStatus(status?: string) { return status === 'SUCCESS' || status === 'FAILED' || status === 'COMPLETED' } function isTaskTerminalStatus(status?: string) { return status === 'SUCCESS' || status === 'FAILED' || status === 'COMPLETED' }
function isTaskTerminalById(taskId?: number) { if (!taskId) return false; const status = taskDetails.value[taskId] || taskSnapshots.value[taskId]?.task?.status; return isTaskTerminalStatus(status) } function isTaskTerminalById(taskId?: number) { if (!taskId) return false; const status = taskDetails.value[taskId] || taskSnapshots.value[taskId]?.task?.status; return isTaskTerminalStatus(status) }
@@ -230,12 +247,12 @@ function restoreScheduledDispatches() { const taskIds = new Set<number>(pollingT
function startScheduleHeartbeat() { if (scheduleHeartbeatTimer) return; scheduleHeartbeatTimer = window.setInterval(() => { void pumpScheduledQueue() }, 1000) } function startScheduleHeartbeat() { if (scheduleHeartbeatTimer) return; scheduleHeartbeatTimer = window.setInterval(() => { void pumpScheduledQueue() }, 1000) }
function stopScheduleHeartbeat() { if (!scheduleHeartbeatTimer) return; window.clearInterval(scheduleHeartbeatTimer); scheduleHeartbeatTimer = null } function stopScheduleHeartbeat() { if (!scheduleHeartbeatTimer) return; window.clearInterval(scheduleHeartbeatTimer); scheduleHeartbeatTimer = null }
function handleScheduleRecovery() { restoreScheduledDispatches(); if (pollingTaskIds.value.length) ensurePolling(true) } function handleScheduleRecovery() { restoreScheduledDispatches(); if (pollingTaskIds.value.length) ensurePolling(true) }
async function refreshTaskBatch() { if (!pollingTaskIds.value.length) return { settledTaskIds: [] as number[] }; try { const batch = await getShopMatchTasksBatch(pollingTaskIds.value); const nextSnapshots = { ...taskSnapshots.value }; const settledTaskIds = new Set<number>(); for (const missingId of batch.missingTaskIds || []) { removePollingTask(missingId); delete nextSnapshots[missingId] } for (const detail of batch.items || []) { const taskId = detail.task?.id; if (!taskId) continue; nextSnapshots[taskId] = detail; const status = detail.task?.status || ''; if (!status) continue; taskDetails.value[taskId] = status; if (isTaskTerminalStatus(status)) { removePollingTask(taskId); settledTaskIds.add(taskId); continue } if (status === 'SCHEDULED') { stopPollingTask(taskId); settledTaskIds.add(taskId) } } taskSnapshots.value = nextSnapshots; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); restoreScheduledDispatches(); return { settledTaskIds: Array.from(settledTaskIds) } } catch { return { settledTaskIds: [] as number[] } } } async function refreshTaskBatch() { if (!pollingTaskIds.value.length) return { settledTaskIds: [] as number[] }; try { const batch = await getShopMatchTasksBatch(pollingTaskIds.value); const nextSnapshots = { ...taskSnapshots.value }; const settledTaskIds = new Set<number>(); for (const missingId of batch.missingTaskIds || []) { removeTaskLocally(missingId); delete nextSnapshots[missingId]; settledTaskIds.add(missingId) } for (const detail of batch.items || []) { const taskId = detail.task?.id; if (!taskId) continue; nextSnapshots[taskId] = detail; const status = detail.task?.status || ''; if (!status) continue; taskDetails.value[taskId] = status; if (isTaskTerminalStatus(status)) { removePollingTask(taskId); settledTaskIds.add(taskId); continue } if (status === 'SCHEDULED') { stopPollingTask(taskId); settledTaskIds.add(taskId) } } taskSnapshots.value = nextSnapshots; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); restoreScheduledDispatches(); return { settledTaskIds: Array.from(settledTaskIds) } } catch { return { settledTaskIds: [] as number[] } } }
function getPollIntervalMs() { return document.visibilityState === 'visible' ? 4000 : 10000 } function getPollIntervalMs() { return document.visibilityState === 'visible' ? 4000 : 10000 }
function scheduleNextPoll(immediate = false) { if (pollTimer.value) { if (!immediate) return; window.clearTimeout(pollTimer.value); pollTimer.value = null } const run = async () => { pollTimer.value = null; if (pollingInFlight.value || !pollingTaskIds.value.length) { if (pollingTaskIds.value.length) scheduleNextPoll(); return } pollingInFlight.value = true; try { const { settledTaskIds } = await refreshTaskBatch(); if (settledTaskIds.length) await Promise.allSettled([loadHistory(), loadDashboard()]) } finally { pollingInFlight.value = false } if (pollingTaskIds.value.length) pollTimer.value = window.setTimeout(run, getPollIntervalMs()) }; if (immediate) void run(); else pollTimer.value = window.setTimeout(run, getPollIntervalMs()) } function scheduleNextPoll(immediate = false) { if (pollTimer.value) { if (!immediate) return; window.clearTimeout(pollTimer.value); pollTimer.value = null } const run = async () => { pollTimer.value = null; if (pollingInFlight.value || !pollingTaskIds.value.length) { if (pollingTaskIds.value.length) scheduleNextPoll(); return } pollingInFlight.value = true; try { const { settledTaskIds } = await refreshTaskBatch(); if (settledTaskIds.length) await Promise.allSettled([loadHistory(), loadDashboard()]) } finally { pollingInFlight.value = false } if (pollingTaskIds.value.length) pollTimer.value = window.setTimeout(run, getPollIntervalMs()) }; if (immediate) void run(); else pollTimer.value = window.setTimeout(run, getPollIntervalMs()) }
function ensurePolling(immediate = false) { scheduleNextPoll(immediate) } function ensurePolling(immediate = false) { scheduleNextPoll(immediate) }
function stopPolling() { if (pollTimer.value) { window.clearTimeout(pollTimer.value); pollTimer.value = null } } function stopPolling() { if (pollTimer.value) { window.clearTimeout(pollTimer.value); pollTimer.value = null } }
async function waitForTaskTerminal(taskId: number) { while (true) { const batch = await getShopMatchTasksBatch([taskId]); const detail = (batch.items || []).find((item) => item.task?.id === taskId); const status = detail?.task?.status || ''; if (detail) { taskSnapshots.value = { ...taskSnapshots.value, [taskId]: detail }; saveTaskSnapshotsToStorage() } if (status) { taskDetails.value[taskId] = status; saveTaskDetailsToStorage() } if (isTaskTerminalStatus(status)) { removePollingTask(taskId); await loadHistory(); await loadDashboard(); restoreScheduledDispatches(); return status } await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) }) } } async function waitForTaskTerminal(taskId: number) { while (true) { const batch = await getShopMatchTasksBatch([taskId]); if ((batch.missingTaskIds || []).includes(taskId)) { removeTaskLocally(taskId); await loadHistory(); await loadDashboard(); restoreScheduledDispatches(); return 'FAILED' } const detail = (batch.items || []).find((item) => item.task?.id === taskId); const status = detail?.task?.status || ''; if (detail) { taskSnapshots.value = { ...taskSnapshots.value, [taskId]: detail }; saveTaskSnapshotsToStorage() } if (status) { taskDetails.value[taskId] = status; saveTaskDetailsToStorage() } if (isTaskTerminalStatus(status)) { removePollingTask(taskId); await loadHistory(); await loadDashboard(); restoreScheduledDispatches(); return status } await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) }) } }
function resolvedTaskStatus(item: ShopMatchHistoryItem) { const taskId = normalizeTaskId(item.taskId); if (!taskId) return item.taskStatus || ''; return taskDetails.value[taskId] || taskSnapshots.value[taskId]?.task?.status || item.taskStatus || '' } function resolvedTaskStatus(item: ShopMatchHistoryItem) { const taskId = normalizeTaskId(item.taskId); if (!taskId) return item.taskStatus || ''; return taskDetails.value[taskId] || taskSnapshots.value[taskId]?.task?.status || item.taskStatus || '' }
function taskSnapshotOf(item: ShopMatchHistoryItem) { const taskId = normalizeTaskId(item.taskId); return taskId ? taskSnapshots.value[taskId] : undefined } function taskSnapshotOf(item: ShopMatchHistoryItem) { const taskId = normalizeTaskId(item.taskId); return taskId ? taskSnapshots.value[taskId] : undefined }
function currentTaskStageText(item: ShopMatchHistoryItem) { const snapshot = taskSnapshotOf(item); const task = snapshot?.task; const stages = task?.scheduleStages || []; if (!stages.length) return ''; const total = stages.length; const activeIndex = typeof task?.activeStageIndex === 'number' ? task.activeStageIndex : undefined; const currentIndex = typeof task?.currentStageIndex === 'number' ? task.currentStageIndex : undefined; if (typeof activeIndex === 'number') return `执行进度: 第 ${activeIndex + 1}/${total} 次执行中`; if (typeof currentIndex === 'number') return `执行进度: 等待第 ${currentIndex + 1}/${total}`; return `执行进度: 共 ${total}`} function currentTaskStageText(item: ShopMatchHistoryItem) { const snapshot = taskSnapshotOf(item); const task = snapshot?.task; const stages = task?.scheduleStages || []; if (!stages.length) return ''; const total = stages.length; const activeIndex = typeof task?.activeStageIndex === 'number' ? task.activeStageIndex : undefined; const currentIndex = typeof task?.currentStageIndex === 'number' ? task.currentStageIndex : undefined; if (typeof activeIndex === 'number') return `执行进度: 第 ${activeIndex + 1}/${total} 次执行中`; if (typeof currentIndex === 'number') return `执行进度: 等待第 ${currentIndex + 1}/${total}`; return `执行进度: 共 ${total}`}
@@ -250,10 +267,7 @@ async function deleteTaskRecord(item: ShopMatchHistoryItem) {
try { try {
if (taskId > 0) { if (taskId > 0) {
await deleteShopMatchTask(taskId) await deleteShopMatchTask(taskId)
removePollingTask(taskId) removeTaskLocally(taskId)
historyItems.value = historyItems.value.filter((row) => normalizeTaskId(row.taskId) !== taskId)
delete taskSnapshots.value[taskId]
saveTaskSnapshotsToStorage()
} else if (resultId > 0) { } else if (resultId > 0) {
await deleteShopMatchHistory(resultId) await deleteShopMatchHistory(resultId)
historyItems.value = historyItems.value.filter((row) => Number(row.resultId || 0) !== resultId) historyItems.value = historyItems.value.filter((row) => Number(row.resultId || 0) !== resultId)
@@ -262,7 +276,14 @@ async function deleteTaskRecord(item: ShopMatchHistoryItem) {
throw new Error('缺少可删除的任务标识') throw new Error('缺少可删除的任务标识')
} }
await Promise.allSettled([loadHistory(), loadCandidates(), loadDashboard()]) await Promise.allSettled([loadHistory(), loadCandidates(), loadDashboard()])
ElMessage.success('删除成功')
} catch (error) { } catch (error) {
if (taskId > 0 && isTaskMissingError(error)) {
removeTaskLocally(taskId)
await Promise.allSettled([loadHistory(), loadCandidates(), loadDashboard()])
ElMessage.success('任务已在服务端删除,本地记录已同步清理')
return
}
ElMessage.error(error instanceof Error ? error.message : '删除失败') ElMessage.error(error instanceof Error ? error.message : '删除失败')
} }
} }