Author SHA1 Message Date
WorkBuddy d51c4c6a76 chore: ignore local temporary workspaces
CI/CD Pipeline / Backend Build & Test (push) Waiting to run
CI/CD Pipeline / Admin Web Build (push) Waiting to run
CI/CD Pipeline / Mini Program Compile Check (push) Waiting to run
CI/CD Pipeline / Migration Validation (push) Blocked by required conditions
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Waiting to run
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Waiting to run
CI/CD Pipeline / E2E (live stack) (push) Blocked by required conditions
2026-10-20 14:20:23 +08:00
WorkBuddy 69ebf33e18 release: freeze demo v1.1.0 2026-10-19
CI/CD Pipeline / Backend Build & Test (push) Waiting to run
CI/CD Pipeline / Admin Web Build (push) Waiting to run
CI/CD Pipeline / Mini Program Compile Check (push) Waiting to run
CI/CD Pipeline / Migration Validation (push) Blocked by required conditions
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Waiting to run
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Waiting to run
CI/CD Pipeline / E2E (live stack) (push) Blocked by required conditions
2026-10-19 20:36:22 +08:00
WorkBuddy 8b505d9133 feat: disable user messaging and retain公益 service guidance
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Canceled after 0s
CI/CD Pipeline / Backend Build & Test (push) Canceled after 0s
CI/CD Pipeline / Admin Web Build (push) Canceled after 0s
CI/CD Pipeline / Mini Program Compile Check (push) Canceled after 0s
CI/CD Pipeline / Migration Validation (push) Canceled after 0s
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Canceled after 0s
CI/CD Pipeline / E2E (live stack) (push) Canceled after 0s
2026-10-15 13:38:17 +08:00
190 changed files with 21256 additions and 253 deletions
+3
View File
@@ -61,6 +61,9 @@ tests/fixtures/*.json
.worktrees/
.zcode/
.workbuddy/
.mimosa/
.trae/
.wb_tmp_bcrypt/
project.private.config.json
/nul
/infrastructure/nul
@@ -12,6 +12,8 @@ import com.danyang.match.platform.profile.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.risk.entity.RiskAction;
import com.danyang.match.platform.risk.mapper.RiskActionMapper;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
import com.danyang.match.platform.shared.config.FeatureFlagConfig;
import com.danyang.match.platform.shared.constant.Constants;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -46,6 +48,7 @@ public class AppealService {
private final AppealMapper appealMapper;
private final RiskActionMapper riskActionMapper;
private final UserMapper userMapper;
private final UserProfileMapper userProfileMapper;
private final AuditLogMapper auditLogMapper;
private final NotificationService notificationService;
@@ -281,6 +284,11 @@ public class AppealService {
log.info("申诉批准,已解除处置: appealId={}, actionId={}", appeal.getId(), appeal.getRelatedActionId());
}
// 解除处置后同步账号状态:若该用户已无其他生效中的封禁 / 暂停,把账号恢复为 normal。
// 否则会出现「申诉已通过、账号仍被锁死」的死锁 —— ReportService 处置时写入的
// users.account_status=banned/suspended 无人回滚。
restoreAccountStatusIfNoActiveRestriction(appeal.getUserId());
// 通知用户
notificationService.send(appeal.getUserId(), Constants.NOTIFICATION_TYPE_ACCOUNT,
"申诉审核通过",
@@ -371,6 +379,38 @@ public class AppealService {
return "limited";
}
/**
* 解除处置后同步账号状态:若该用户已无其他生效中的封禁 / 暂停,把 users.account_status 恢复为 normal。
*
* <p>否则会出现「申诉已通过、账号仍被锁死」的死锁 —— ReportService 处置时写入的
* users.account_status=banned/suspended 无人回滚,登录门禁 / 推荐 / 匹配资格仍按旧状态拒绝用户。
*
* <p>仅回滚由处置写入的 banned / suspended,避免误改 closed(注销)等其他业务状态。
*/
private void restoreAccountStatusIfNoActiveRestriction(String userId) {
boolean stillRestricted = riskActionMapper.findActiveByUserId(userId).stream()
.anyMatch(a -> Constants.RISK_ACTION_BAN.equals(a.getActionType())
|| Constants.RISK_ACTION_TEMPORARY_SUSPEND.equals(a.getActionType()));
if (stillRestricted) {
return;
}
User user = userMapper.selectById(userId);
if (user == null) {
return;
}
String oldStatus = user.getAccountStatus();
if (!Constants.ACCOUNT_STATUS_BANNED.equals(oldStatus)
&& !Constants.ACCOUNT_STATUS_SUSPENDED.equals(oldStatus)) {
return;
}
user.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
userMapper.updateById(user);
// audit_logs.before_data/after_data 为 JSONB:必须写合法 JSON,裸字符串会被 PG 拒绝。
saveAuditLog(null, "ACCOUNT_STATUS_RESTORE", "user", userId,
"{\"accountStatus\":\"" + oldStatus + "\"}",
"{\"accountStatus\":\"" + Constants.ACCOUNT_STATUS_NORMAL + "\"}");
}
private void saveAuditLog(String operatorId, String action, String resourceType,
String resourceId, String beforeData, String afterData) {
AuditLog auditLog = new AuditLog();
@@ -378,6 +418,10 @@ public class AppealService {
auditLog.setAction(action);
auditLog.setResourceType(resourceType);
auditLog.setResourceId(resourceId);
// 此前忽略 beforeData/afterData → 账号状态恢复等变更审计丢失前后值;补齐后需注意
// audit_logs 的 before_data/after_data 为 JSONB,调用方必须传合法 JSON 文本。
auditLog.setBeforeData(beforeData);
auditLog.setAfterData(afterData);
auditLogMapper.insert(auditLog);
}
}
@@ -40,6 +40,17 @@ public class MockWechatLoginProvider implements WechatLoginProvider {
public Map<String, String> getCodeSession(String code) {
log.debug("[Mock] 微信登录 code: {}", code);
// 演示直通:H5 演示端在无微信环境下用 `demo_openid_xx` 作为 code 登录预置演示用户。
// 生产环境 provider=real(见 application-production.yml),不会进入本 Provider。
if (code != null && code.startsWith("demo_openid_")) {
return Map.of(
"openid", code,
"unionid", "mock_unionid_" + code,
"session_key", "mock_session_key",
"appid", appId
);
}
// 模拟:相同 code 返回相同 openid
String openid = configuredOpenid != null && !configuredOpenid.isBlank()
? configuredOpenid
@@ -0,0 +1,44 @@
package com.danyang.match.platform.chat.config;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* 产品当前不提供用户之间的即时聊天。
*
* <p>在 HTTP 和 WebSocket 握手进入 Controller/Handler 前统一拒绝,
* 防止只隐藏小程序入口却留下可调用的通信通道。
*/
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class ChatDisabledFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String path = request.getRequestURI();
String context = request.getContextPath();
if (context != null && !context.isEmpty() && path.startsWith(context)) {
path = path.substring(context.length());
}
if (path.equals("/conversations") || path.startsWith("/conversations/")
|| path.equals("/ws/chat") || path.startsWith("/ws/chat/")) {
response.setStatus(HttpServletResponse.SC_GONE);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(
"{\"success\":false,\"code\":\"CHAT_DISABLED\","
+ "\"message\":\"平台未提供用户之间的即时聊天功能\",\"data\":null}");
return;
}
filterChain.doFilter(request, response);
}
}
@@ -1,7 +1,5 @@
package com.danyang.match.platform.match.controller;
import com.danyang.match.platform.chat.entity.Conversation;
import com.danyang.match.platform.chat.service.ChatService;
import com.danyang.match.platform.match.service.RelationService;
import com.danyang.match.platform.match.service.RelationService.LikeResult;
import com.danyang.match.platform.shared.constant.Constants;
@@ -11,7 +9,6 @@ import com.danyang.match.platform.shared.idempotency.Idempotent;
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
@@ -22,18 +19,16 @@ import org.springframework.web.bind.annotation.*;
* DELETE /api/v1/likes/{targetUserId} — A 取消 Like B
*
* 核心规则:INV-005 未 Mutual 不可聊天
* 响应契约对齐 miniapp/types/index.d.ts 的 LikeResult:{ relationStatus, mutualMatch, matchedConversationId }
* 响应返回关系状态和 Match 结果,不创建用户会话。
*
* 说明:原 /relations/{targetUserId}/like(2026-09-10 版)已被本控制器取代,调用方统一走 /likes。
*/
@Slf4j
@RestController
@RequestMapping("/likes")
@RequiredArgsConstructor
public class LikeController {
private final RelationService relationService;
private final ChatService chatService;
@Idempotent
@PostMapping
@@ -44,7 +39,7 @@ public class LikeController {
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "targetUserId不能为空");
}
LikeResult result = relationService.like(principal.id(), req.targetUserId);
return ApiResponse.ok(LikeResponse.from(result, ensureConversation(result, principal.id(), req.targetUserId)));
return ApiResponse.ok(LikeResponse.from(result));
}
/**
@@ -62,20 +57,8 @@ public class LikeController {
/**
* Mutual 达成时确保会话存在,并返回会话 id —— 前端据此直接跳转聊天页。
*
* - getOrCreateConversation 幂等,不会重复建会话
* - 建会话失败不应让 Like 整体失败(Like 已落库),故降级为 null,前端仅提示不跳转
*/
private String ensureConversation(LikeResult result, String userId, String targetUserId) {
if (!result.mutual || result.match == null) return null;
try {
Conversation conv = chatService.getOrCreateConversation(result.match.getId(), userId, targetUserId);
return conv.getId();
} catch (RuntimeException e) {
log.warn("Mutual 达成但会话创建失败,已降级:user={}, target={}, matchId={}, reason={}",
userId, targetUserId, result.match.getId(), e.getMessage());
return null;
}
}
// 当前产品不提供用户间会话;Match 成功后仅展示结果和公益服务入口。
// ==================== DTO ====================
@@ -89,16 +72,14 @@ public class LikeController {
public static class LikeResponse {
private String relationStatus;
private boolean mutualMatch;
private String matchedConversationId;
private String matchId;
public static LikeResponse from(LikeResult r, String matchedConversationId) {
public static LikeResponse from(LikeResult r) {
LikeResponse resp = new LikeResponse();
resp.relationStatus = r.mutual
? Constants.RELATION_STATUS_MUTUAL_MATCH
: Constants.RELATION_STATUS_LIKED;
resp.mutualMatch = r.mutual;
resp.matchedConversationId = matchedConversationId;
resp.matchId = r.match != null ? r.match.getId() : null;
return resp;
}
@@ -310,7 +310,11 @@ public class RecommendationEngineService {
record.setSource(Constants.RECOMMENDATION_SOURCE_DAILY);
record.setScore(java.math.BigDecimal.valueOf(score));
record.setLevel(toLevel(score));
record.setReasons(String.join(";", reasons));
// recommendation_records.reasons 为 jsonb 列:必须写入合法 JSON。
// 此前写入 "a;b;c" 纯文本,仅在推荐结果非空(真正触发写入)时才会
// 触发 PostgreSQL「invalid input syntax for type json」,导致整个
// /home 聚合接口 500。改为序列化为 JSON 数组。
record.setReasons(writeReasonsJson(reasons));
record.setShownAt(Instant.now());
record.setRuleVersion(matchConfig.getRuleVersion());
// MATCH-001:快照当次判定所用阈值(配合 rule_version 追溯历史推荐依据的规则版本)
@@ -342,6 +346,16 @@ public class RecommendationEngineService {
}
}
/** 推荐理由序列化为 JSON 数组字符串,写入 recommendation_records.reasons (jsonb)。 */
private String writeReasonsJson(List<String> reasons) {
try {
return objectMapper.writeValueAsString(reasons == null ? List.of() : reasons);
} catch (Exception e) {
log.warn("序列化推荐理由失败", e);
return "[]";
}
}
/**
* 内部评分结果
*/
@@ -9,6 +9,7 @@ import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.profile.service.UserProfileService;
import com.danyang.match.platform.shared.constant.Constants;
import lombok.RequiredArgsConstructor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@@ -28,6 +29,7 @@ import java.util.stream.Collectors;
public class RecommendationService {
private static final Logger log = LoggerFactory.getLogger(RecommendationService.class);
private final ObjectMapper objectMapper = new ObjectMapper();
private final MatchEligibilityService eligibilityService;
private final UserProfileMapper userProfileMapper;
@@ -61,7 +63,9 @@ public class RecommendationService {
record.setTargetUserId(targetUserId);
record.setSource(Constants.RECOMMENDATION_SOURCE_DAILY);
record.setLevel(level);
record.setReasons(new java.util.ArrayList<>(reasons).toString());
// recommendation_records.reasons 为 jsonb 列,必须写入合法 JSON
// (原 new ArrayList<>(reasons).toString() 产出 [a, b] 亦非合法 JSON)。
record.setReasons(writeReasonsJson(reasons));
record.setShownAt(Instant.now());
record.setRuleVersion(matchConfig.getRuleVersion());
// MATCH-001:快照当次判定所用阈值(配合 rule_version 追溯历史推荐依据的规则版本)
@@ -70,6 +74,16 @@ public class RecommendationService {
log.debug("推荐记录: userId={}, targetUserId={}, level={}", userId, targetUserId, level);
}
/** 推荐理由序列化为 JSON 数组字符串,写入 recommendation_records.reasons (jsonb)。 */
private String writeReasonsJson(List<String> reasons) {
try {
return objectMapper.writeValueAsString(reasons == null ? List.of() : reasons);
} catch (Exception e) {
log.warn("序列化推荐理由失败", e);
return "[]";
}
}
/**
* 记录用户对推荐的响应(Like/Pass/Block)
*/
@@ -54,7 +54,8 @@ public class MatchmakerController {
String serviceType = (req.serviceType == null || req.serviceType.isBlank())
? "recommendation" : req.serviceType;
String description = req.description != null ? req.description : req.requirement;
return ApiResponse.ok(matchmakerService.submitServiceRequest(principal.id(), serviceType, description));
return ApiResponse.ok(matchmakerService.submitServiceRequest(principal.id(), serviceType, description,
req.targetUserId, req.authorizationAccepted));
}
@GetMapping("/services")
@@ -72,6 +73,15 @@ public class MatchmakerController {
return ApiResponse.ok(matchmakerService.confirmService(id, principal.id()));
}
@Idempotent
@PostMapping("/services/{id}/withdraw")
public ApiResponse<Void> withdraw(@PathVariable String id, @RequestBody(required = false) WithdrawRequest req,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
matchmakerService.withdrawService(id, principal.id(), req == null ? null : req.reason);
return ApiResponse.ok();
}
@GetMapping("/recommendations")
public ApiResponse<List<MatchmakerRecommendation>> pendingRecs(@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
@@ -111,5 +121,10 @@ public class MatchmakerController {
private String description;
/** 兼容字段:小程序约定的需求描述(requirement -> description) */
private String requirement;
private String targetUserId;
private Boolean authorizationAccepted;
}
@Data
public static class WithdrawRequest { private String reason; }
}
@@ -25,6 +25,10 @@ public class MatchmakerServiceRecord extends BaseEntity {
private String userId;
private String serviceType;
private String description;
private String targetUserId;
private java.time.Instant authorizationAt;
private java.time.Instant withdrawnAt;
private String withdrawReason;
private String status;
private String organizationId;
private String assignedOperatorId;
@@ -14,13 +14,16 @@ import java.util.Optional;
public interface MatchmakerServiceMapper extends BaseMapper<MatchmakerServiceRecord> {
@Select("<script>SELECT s.id AS \"serviceId\", s.status, s.service_type AS \"serviceType\", " +
"s.description, s.created_at AS \"createdAt\", s.assigned_at AS \"assignedAt\", s.completed_at AS \"completedAt\", " +
"s.description, s.target_user_id AS \"targetUserId\", s.authorization_at AS \"authorizationAt\", " +
"s.created_at AS \"createdAt\", s.assigned_at AS \"assignedAt\", s.completed_at AS \"completedAt\", " +
"u.display_number AS \"userDisplayNumber\", o.display_id AS \"operatorDisplayId\", " +
"tu.display_number AS \"targetUserDisplayNumber\", " +
"(SELECT f.content FROM matchmaker_followups f WHERE f.service_id=s.id AND f.deleted_at IS NULL " +
"ORDER BY f.created_at DESC LIMIT 1) AS \"latestFollowupContent\", " +
"(SELECT f.created_at FROM matchmaker_followups f WHERE f.service_id=s.id AND f.deleted_at IS NULL " +
"ORDER BY f.created_at DESC LIMIT 1) AS \"latestFollowupAt\" " +
"FROM matchmaker_services s LEFT JOIN users u ON u.id=s.user_id " +
"LEFT JOIN users tu ON tu.id=s.target_user_id " +
"LEFT JOIN operators o ON o.id=s.assigned_operator_id AND o.deleted_at IS NULL " +
"WHERE s.deleted_at IS NULL <if test='!global'>AND o.id IS NOT NULL " +
"AND o.organization_id IS NOT DISTINCT FROM #{organizationId}</if> ORDER BY s.created_at DESC</script>")
@@ -83,6 +86,14 @@ public interface MatchmakerServiceMapper extends BaseMapper<MatchmakerServiceRec
@Select("SELECT * FROM matchmaker_services WHERE user_id = #{userId} ORDER BY created_at DESC LIMIT #{limit}")
List<MatchmakerServiceRecord> findByUser(@Param("userId") String userId, @Param("limit") int limit);
@Select("SELECT COUNT(*) FROM matchmaker_services WHERE user_id=#{userId} AND target_user_id=#{targetUserId} " +
"AND service_type='recommendation_assistance' AND status NOT IN ('completed','cancelled') AND deleted_at IS NULL")
long countActiveAssistance(@Param("userId") String userId, @Param("targetUserId") String targetUserId);
@Update("UPDATE matchmaker_services SET status='cancelled', withdrawn_at=NOW(), withdraw_reason=#{reason}, updated_at=NOW() " +
"WHERE id=#{serviceId} AND user_id=#{userId} AND status IN ('submitted','assigned','in_service','waiting_user') AND deleted_at IS NULL")
int withdraw(@Param("serviceId") String serviceId, @Param("userId") String userId, @Param("reason") String reason);
/** 用户确认红娘服务方案:仅允许本人将 waiting_user 恢复为 in_service。 */
@Update("UPDATE matchmaker_services SET status = 'in_service', updated_at = NOW() " +
"WHERE id = #{serviceId} AND user_id = #{userId} AND status = 'waiting_user' AND deleted_at IS NULL")
@@ -12,6 +12,8 @@ import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.security.DataScopeGuard;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
@@ -55,6 +57,7 @@ import java.util.stream.Collectors;
public class MatchmakerAssignmentService {
private static final String RESOURCE_TYPE = "matchmaker_assignment";
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final MatchmakerAssignmentMapper assignmentMapper;
private final MatchmakerServiceMapper serviceMapper;
@@ -87,6 +90,20 @@ public class MatchmakerAssignmentService {
"组长只能分配同组织的红娘(NULL=NULL 平台直属)", matchmakerOperatorId, false);
assignmentMapper.findActivePrimaryByUser(userId).ifPresent(existing -> {
// 历史服务已完成/取消时,旧分配只是残留状态,不应阻塞用户再次申请服务。
// 仍在进行中的服务继续按唯一主责约束返回 409。
MatchmakerServiceRecord linked = existing.getServiceId() == null
? null : serviceMapper.selectById(existing.getServiceId());
if (linked != null && (Constants.MATCHMAKER_STATUS_COMPLETED.equals(linked.getStatus())
|| Constants.MATCHMAKER_STATUS_CANCELLED.equals(linked.getStatus()))) {
existing.setStatus(Constants.ASSIGNMENT_STATUS_ENDED);
existing.setEndedAt(Instant.now());
existing.setEndReason("关联服务已完成或取消,自动结束旧分配");
assignmentMapper.updateById(existing);
saveAudit(assignerOperatorId, Constants.AUDIT_ACTION_ASSIGNMENT_END, existing.getId(),
auditSummary(existing.getMatchmakerOperatorId(), existing.getAssignmentScope(), existing.getEndReason()));
return;
}
throw new BusinessException(ApiResponseCode.CONFLICT,
"该用户已存在生效中的主责分配,请先转分配或撤销");
});
@@ -388,10 +405,15 @@ public class MatchmakerAssignmentService {
/** afterData 摘要:只记 operatorId/scope/reason(D5 审计口径,不落敏感数据)。 */
private String auditSummary(String matchmakerOperatorId, String scope, String reason) {
String summary = "matchmakerOperatorId=" + matchmakerOperatorId
+ ";scope=" + scope
+ ";reason=" + (reason == null ? "" : reason);
return summary.length() > 500 ? summary.substring(0, 500) : summary;
Map<String, String> summary = Map.of(
"matchmakerOperatorId", matchmakerOperatorId == null ? "" : matchmakerOperatorId,
"scope", scope == null ? "" : scope,
"reason", reason == null ? "" : reason);
try {
return OBJECT_MAPPER.writeValueAsString(summary);
} catch (JsonProcessingException e) {
throw new IllegalStateException("audit afterData JSON serialization failed", e);
}
}
private void saveAudit(String operatorId, String action, String resourceId, String afterData) {
@@ -64,17 +64,38 @@ public class MatchmakerService {
*/
@Transactional
public MatchmakerServiceRecord submitServiceRequest(String userId, String serviceType, String description) {
return submitServiceRequest(userId, serviceType, description, null, null);
}
@Transactional
public MatchmakerServiceRecord submitServiceRequest(String userId, String serviceType, String description,
String targetUserId, Boolean authorizationAccepted) {
boolean assistance = "recommendation_assistance".equals(serviceType);
if (assistance) {
if (targetUserId == null || targetUserId.isBlank()) throw new IllegalArgumentException("请选择需要协助的对象");
if (!Boolean.TRUE.equals(authorizationAccepted)) throw new IllegalArgumentException("请先确认授权红娘查看必要公开资料");
if (userId.equals(targetUserId)) throw new IllegalArgumentException("不能申请协助了解自己");
if (!relationService.canInteract(userId, targetUserId)) throw new IllegalStateException("该对象当前不可申请红娘协助");
if (serviceMapper.countActiveAssistance(userId, targetUserId) > 0) throw new IllegalStateException("已提交过该对象的红娘协助申请");
}
MatchmakerServiceRecord svc = new MatchmakerServiceRecord();
svc.setId(UUID.randomUUID().toString());
svc.setUserId(userId);
svc.setServiceType(serviceType);
svc.setDescription(description);
svc.setTargetUserId(targetUserId);
if (assistance) svc.setAuthorizationAt(Instant.now());
svc.setStatus("submitted");
serviceMapper.insert(svc);
log.info("Matchmaker service submitted: userId={}, type={}", userId, serviceType);
return svc;
}
@Transactional
public void withdrawService(String serviceId, String userId, String reason) {
if (serviceMapper.withdraw(serviceId, userId, reason) != 1) throw new IllegalStateException("服务不存在或当前状态不可撤回");
}
/** 查询我的红娘服务历史 */
public List<MatchmakerServiceRecord> myServices(String userId, int limit) {
return serviceMapper.findByUser(userId, Math.min(limit, 50));
@@ -251,16 +251,7 @@ public class RelationshipService {
matchMapper.insert(match);
}
// V1.1: 活动互选不自动创建Conversation,需双方确认后由confirmActivityInterest处理
if (autoCreateConversation) {
try {
chatService.getOrCreateConversation(match.getId(), userId, targetUserId);
log.info("Match后自动创建会话: matchId={}, users={}", match.getId(), userId + "," + targetUserId);
} catch (Exception e) {
log.warn("创建会话失败,Match已创建但聊天可能不可用: matchId={}, error={}",
match.getId(), e.toString(), e);
}
}
// 当前产品不提供用户间聊天;Match 仅用于结果判定和公益服务引导。
return match;
}
@@ -286,7 +277,6 @@ public class RelationshipService {
result.put("matchReused", true);
result.put("matchId", existingMatch.get().getId());
result.put("status", existingMatch.get().getStatus());
result.put("conversationAutoCreated", true);
log.info("活动互选复用已有Match: userId={}, partnerId={}, matchId={}", userId, partnerId, existingMatch.get().getId());
return result;
}
@@ -299,20 +289,10 @@ public class RelationshipService {
// 3. 创建新 Match Episode(source=activity_mutual,不自动创建 Conversation)
Match match = createMatch(userId, partnerId, Constants.MATCH_SOURCE_ACTIVITY_MUTUAL, false);
// V1.1 Phase 16 修复(Gate:互选确认后双方可沟通):双确认后必须开启会话。
// 此前此处未建会话,仅靠首条消息惰性创建,导致「双方可沟通」的入口不可见。
// 现显式开启,保证互选成功后聊天入口立即可见。
try {
chatService.getOrCreateConversation(match.getId(), match.getUserAId(), match.getUserBId());
} catch (Exception e) {
log.warn("活动互选开启会话失败(Match 已创建): matchId={}, error={}", match.getId(), e.toString(), e);
}
Map<String, Object> result = new HashMap<>();
result.put("matchCreated", true);
result.put("matchId", match.getId());
result.put("priorUnmatch", priorUnmatchExists);
result.put("conversationAutoCreated", true);
log.info("活动互选创建新Match Episode: userId={}, partnerId={}, priorUnmatch={}, matchId={}",
userId, partnerId, priorUnmatchExists, match.getId());
return result;
@@ -45,5 +45,26 @@ public class ReportController {
return ApiResponse.ok(reportService.getMyReports(userId));
}
/**
* 获取我的举报详情(仅举报人本人可见,含处理结果与证据签名 URL)
* GET /api/v1/risk/reports/{reportId}
*/
@GetMapping("/{reportId}")
public ApiResponse<Map<String, Object>> getMyReportDetail(
@AuthenticationPrincipal UserPrincipal principal,
@PathVariable String reportId) {
return ApiResponse.ok(reportService.getMyReportDetail(principal.id(), reportId));
}
/**
* 获取举报证据直传凭证(客户端直传对象存储后,将 fileKey 随提交举报上报)
* POST /api/v1/risk/reports/evidence/upload-token
*/
@PostMapping("/evidence/upload-token")
public ApiResponse<Map<String, Object>> getEvidenceUploadToken(
@AuthenticationPrincipal UserPrincipal principal) {
String userId = principal.id();
return ApiResponse.ok(reportService.getEvidenceUploadToken(userId));
}
}
@@ -3,9 +3,12 @@ package com.danyang.match.platform.risk.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.audit.service.AuditLogService;
import com.danyang.match.platform.notification.service.NotificationService;
import com.danyang.match.platform.profile.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
import com.danyang.match.platform.risk.dto.ReportItemDTO;
import com.danyang.match.platform.risk.dto.ReportProcessDTO;
import com.danyang.match.platform.risk.dto.ReportSubmitDTO;
@@ -16,9 +19,16 @@ import com.danyang.match.platform.risk.mapper.ReportMapper;
import com.danyang.match.platform.risk.mapper.RiskActionMapper;
import com.danyang.match.platform.risk.mapper.RiskEventMapper;
import com.danyang.match.platform.shared.constant.Constants;
import lombok.RequiredArgsConstructor;
import com.amazonaws.HttpMethod;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.context.request.RequestContextHolder;
@@ -30,9 +40,11 @@ import java.time.Instant;
import java.util.Map;
import java.util.List;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.exception.ServerErrorException;
/**
* 举报与风控服务
@@ -43,17 +55,72 @@ import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.Busine
* - 封禁需要人工复核,不能自动永久封禁
*/
@Service
@RequiredArgsConstructor
public class ReportService {
private static final Logger log = LoggerFactory.getLogger(ReportService.class);
/** 补充说明字数上限(与前端 maxlength 对齐)。 */
private static final int MAX_DESCRIPTION_LENGTH = 500;
/** 证据截图上限的配置默认值(未获产品正式确认,按「DEFAULT + CONFIG」可配置,见 docs/governance/config-register)。 */
private static final int DEFAULT_MAX_EVIDENCE = 9;
/** 运营端按状态列表查询的单页上限。 */
private static final int STATUS_LIST_LIMIT = 200;
/** 高风险举报原因:必须补充文字说明。 */
private static final Set<String> HIGH_RISK_REASONS = Set.of(
Constants.REPORT_REASON_FRAUD,
Constants.REPORT_REASON_MONEY_INDUCEMENT,
Constants.REPORT_REASON_IMPERSONATION,
Constants.REPORT_REASON_FAKE_MARITAL_STATUS
);
private final ReportMapper reportMapper;
private final RiskEventMapper riskEventMapper;
private final RiskActionMapper riskActionMapper;
private final UserProfileMapper userProfileMapper;
private final UserMapper userMapper;
private final AuditLogMapper auditLogMapper;
private final AuditLogService auditLogService;
private final NotificationService notificationService;
private final ObjectMapper objectMapper;
/** 证据对象的归属/存在性校验走内网客户端。 */
private final AmazonS3 amazonS3;
/** 给客户端签发证据直传与查看签名 URL 的客户端(见 S3Config.amazonS3Signer)。 */
private final AmazonS3 amazonS3Signer;
@Value("${object-storage.bucket-private}")
private String privateBucket;
@Value("${report.evidence.max-count:" + DEFAULT_MAX_EVIDENCE + "}")
private int maxEvidenceCount;
@Autowired
public ReportService(
ReportMapper reportMapper,
RiskEventMapper riskEventMapper,
RiskActionMapper riskActionMapper,
UserProfileMapper userProfileMapper,
UserMapper userMapper,
AuditLogMapper auditLogMapper,
AuditLogService auditLogService,
NotificationService notificationService,
ObjectMapper objectMapper,
@Qualifier("amazonS3") AmazonS3 amazonS3,
@Qualifier("amazonS3Signer") AmazonS3 amazonS3Signer) {
this.reportMapper = reportMapper;
this.riskEventMapper = riskEventMapper;
this.riskActionMapper = riskActionMapper;
this.userProfileMapper = userProfileMapper;
this.userMapper = userMapper;
this.auditLogMapper = auditLogMapper;
this.auditLogService = auditLogService;
this.notificationService = notificationService;
this.objectMapper = objectMapper;
this.amazonS3 = amazonS3;
this.amazonS3Signer = amazonS3Signer;
}
// 风险评分权重
private static final Map<String, Integer> REASON_SCORES = Map.of(
@@ -75,10 +142,30 @@ public class ReportService {
*/
@Transactional
public Map<String, Object> submitReport(String reporterUserId, ReportSubmitDTO dto) {
// 举报对象校验
if (dto.getReportedUserId() == null || dto.getReportedUserId().isBlank()) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "缺少举报对象");
}
// 不允许举报自己
if (reporterUserId.equals(dto.getReportedUserId())) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "不能举报自己");
}
// 举报原因白名单(REASON_SCORES 的 key 即合法枚举)
String reasonType = dto.getReasonType();
if (reasonType == null || !REASON_SCORES.containsKey(reasonType)) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "举报原因不合法");
}
// 补充说明长度 + 高风险必填
String description = dto.getDescription() == null ? "" : dto.getDescription().trim();
if (description.length() > MAX_DESCRIPTION_LENGTH) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR,
"补充说明过长(上限 " + MAX_DESCRIPTION_LENGTH + " 字)");
}
if (HIGH_RISK_REASONS.contains(reasonType) && description.isEmpty()) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "该举报原因需要补充说明");
}
// 证据截图:校验归属与存在性(避免任意 fileKey 注入或引用未完成上传的孤儿对象)
List<String> evidenceFileIds = validateEvidence(reporterUserId, dto.getEvidenceFileIds());
// 检查是否已被封禁
List<RiskAction> activeActions = riskActionMapper.findActiveByUserId(reporterUserId);
@@ -91,34 +178,35 @@ public class ReportService {
Report report = new Report();
report.setReporterUserId(reporterUserId);
report.setReportedUserId(dto.getReportedUserId());
report.setReasonType(dto.getReasonType());
report.setDescription(dto.getDescription());
if (dto.getEvidenceFileIds() != null) {
report.setEvidenceFileIds(java.util.List.of(dto.getEvidenceFileIds()).toString());
}
report.setReasonType(reasonType);
report.setDescription(description.isEmpty() ? null : description);
// evidence_file_ids 列(TEXT)存 JSON 数组:必须序列化为合法 JSON,不能用 List.toString()
report.setEvidenceFileIds(writeEvidenceJson(evidenceFileIds));
report.setStatus(Constants.REPORT_STATUS_PENDING);
reportMapper.insert(report);
// 触发风险事件:被举报用户得分增加
int scoreAdd = REASON_SCORES.getOrDefault(dto.getReasonType(), 5);
int scoreAdd = REASON_SCORES.getOrDefault(reasonType, 5);
RiskEvent riskEvent = new RiskEvent();
riskEvent.setUserId(dto.getReportedUserId());
riskEvent.setEventType(Constants.RISK_EVENT_ABUSE_REPORT);
riskEvent.setScore(scoreAdd);
riskEvent.setSource("report");
riskEvent.setSourceId(report.getId());
riskEvent.setNote("举报触发,原因: " + dto.getReasonType());
riskEvent.setNote("举报触发,原因: " + reasonType);
riskEventMapper.insert(riskEvent);
// 高风险举报自动标记为处理中(诈骗/金钱诱导/冒充)
if (List.of(Constants.REPORT_REASON_FRAUD, Constants.REPORT_REASON_MONEY_INDUCEMENT,
Constants.REPORT_REASON_IMPERSONATION).contains(dto.getReasonType())) {
Constants.REPORT_REASON_IMPERSONATION).contains(reasonType)) {
report.setStatus(Constants.REPORT_STATUS_PROCESSING);
reportMapper.updateById(report);
}
// 审计
saveAuditLog(reporterUserId, "SUBMIT_REPORT", "report", report.getId(), null, null);
// 审计(C 端用户主体:写 subject_user_id 且 operator_id=null)
// 修复:原 saveAuditLog(operatorId=userId) 会让 userId 撞 audit_logs_operator_id_fkey(→operators)
// 触发 FK violation,导致举报提交必然 500、举报→管理端举报中心链路整体不可用。
auditLogService.recordUserAction(reporterUserId, "SUBMIT_REPORT", "report", report.getId(), null, null);
Map<String, Object> result = new HashMap<>();
result.put("reportId", report.getId());
@@ -135,6 +223,45 @@ public class ReportService {
.collect(Collectors.toList());
}
/**
* 用户端举报详情(仅举报人本人可见):返回处理结果与证据签名查看 URL。
* 非本人访问一律按「不存在」处理,避免泄露他人举报存在性。
*/
public Map<String, Object> getMyReportDetail(String userId, String reportId) {
Report report = reportMapper.selectById(reportId);
if (report == null || !userId.equals(report.getReporterUserId())) {
throw new BusinessException(ApiResponseCode.NOT_FOUND, "举报不存在");
}
Map<String, Object> detail = new HashMap<>();
detail.put("report", toDTO(report));
List<String> evidenceFileKeys = parseEvidence(report.getEvidenceFileIds());
detail.put("evidenceFileKeys", evidenceFileKeys);
detail.put("evidenceUrls", evidenceFileKeys.stream()
.map(this::signEvidenceUrl)
.collect(Collectors.toList()));
return detail;
}
/**
* 签发举报证据直传凭证(客户端直传私有桶,服务端不落库)。
* fileKey 固定以 {@code reports/{userId}/evidence/} 开头,提交举报时按此前缀校验归属。
*/
public Map<String, Object> getEvidenceUploadToken(String userId) {
String fileKey = String.format("reports/%s/evidence/%s.jpg",
userId, UUID.randomUUID().toString().replace("-", ""));
Date expiration = new Date(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1));
GeneratePresignedUrlRequest putRequest = new GeneratePresignedUrlRequest(privateBucket, fileKey)
.withMethod(HttpMethod.PUT)
.withExpiration(expiration);
String uploadUrl = amazonS3Signer.generatePresignedUrl(putRequest).toString();
Map<String, Object> token = new HashMap<>();
token.put("fileKey", fileKey);
token.put("uploadUrl", uploadUrl);
token.put("expiresIn", 3600);
return token;
}
// ==================== 管理端举报处理 ====================
/**
@@ -157,6 +284,13 @@ public class ReportService {
Map<String, Object> detail = new HashMap<>();
detail.put("report", toDTO(report));
// 证据截图:向运营端返回文件标识与短时签名查看 URL(查看动作的敏感访问审计由调用方记录)
List<String> evidenceFileKeys = parseEvidence(report.getEvidenceFileIds());
detail.put("evidenceFileKeys", evidenceFileKeys);
detail.put("evidenceUrls", evidenceFileKeys.stream()
.map(this::signEvidenceUrl)
.collect(Collectors.toList()));
// 被举报人风险记录
String reportedUserId = report.getReportedUserId();
if (reportedUserId != null) {
@@ -215,7 +349,10 @@ public class ReportService {
}
// 通知被举报用户
sendProcessNotification(report.getReportedUserId(), result, dto.getProcessNote());
sendProcessNotification(report.getReportedUserId(), result, dto.getProcessNote(), reportId);
// 举报链路闭环:处置完成后通知举报人(仅告知结果,不泄露被举报人处置细节)
sendReporterResultNotification(report.getReporterUserId(), result, reportId);
// 高风险举报(婚姻状态欺诈)额外记录风险事件
if (Constants.REPORT_REASON_FAKE_MARITAL_STATUS.equals(report.getReasonType())) {
@@ -257,8 +394,20 @@ public class ReportService {
return m;
}).collect(java.util.stream.Collectors.toList());
}
// For other statuses, return empty or implement full query
return List.of();
// 按状态真实查询(此前固定返回空列表,导致运营端「处理中 / 已处理」页签恒为空)
return reportMapper.findByStatusPaged(status, STATUS_LIST_LIMIT, 0).stream().map(report -> {
Map<String, Object> m = new java.util.HashMap<>();
m.put("id", report.getId());
m.put("reasonType", report.getReasonType());
m.put("reasonText", getReasonText(report.getReasonType()));
m.put("reportedUserId", report.getReportedUserId());
m.put("status", report.getStatus());
m.put("description", report.getDescription());
m.put("createdAt", report.getCreatedAt());
m.put("processResult", report.getProcessResult());
m.put("processNote", report.getProcessNote());
return m;
}).collect(java.util.stream.Collectors.toList());
}
public Map<String, Object> getReportStats() {
@@ -298,6 +447,83 @@ public class ReportService {
// ==================== 私有辅助方法 ====================
/** 校验证据文件标识:数量上限 + 归属前缀(reports/{userId}/)+ 对象确实已上传。 */
private List<String> validateEvidence(String reporterUserId, List<String> evidenceFileIds) {
if (evidenceFileIds == null || evidenceFileIds.isEmpty()) {
return List.of();
}
if (evidenceFileIds.size() > maxEvidenceCount) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR,
"证据截图最多 " + maxEvidenceCount + " 张");
}
String prefix = "reports/" + reporterUserId + "/";
for (String key : evidenceFileIds) {
if (key == null || !key.startsWith(prefix)) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "非法的证据文件标识");
}
if (!amazonS3.doesObjectExist(privateBucket, key)) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "证据文件尚未上传完成");
}
}
return evidenceFileIds;
}
/** 将证据 fileKey 列表序列化为 JSON 数组,写入 evidence_file_ids(TEXT 列)。 */
private String writeEvidenceJson(List<String> evidenceFileIds) {
if (evidenceFileIds == null || evidenceFileIds.isEmpty()) {
return "[]";
}
try {
return objectMapper.writeValueAsString(evidenceFileIds);
} catch (com.fasterxml.jackson.core.JsonProcessingException e) {
throw new ServerErrorException("证据序列化失败", e);
}
}
/** 解析 evidence_file_ids 的 JSON 数组;历史脏数据(非 JSON 数组)按空列表处理。 */
private List<String> parseEvidence(String json) {
if (json == null || json.isBlank()) {
return List.of();
}
try {
return objectMapper.readValue(json, new TypeReference<List<String>>() {});
} catch (Exception e) {
log.warn("解析举报证据 JSON 失败,按空列表处理: {}", json);
return List.of();
}
}
/** 为证据文件签发短时(10 分钟)只读 URL,供运营端查看。 */
private String signEvidenceUrl(String fileKey) {
Date expiration = new Date(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(10));
return amazonS3Signer.generatePresignedUrl(
new GeneratePresignedUrlRequest(privateBucket, fileKey)
.withMethod(HttpMethod.GET)
.withExpiration(expiration)).toString();
}
/** 取用户展示名(无资料时回退 null,前端可回退为「用户」)。 */
private String displayNameOf(String userId) {
if (userId == null) return null;
return userProfileMapper.findByUserId(userId).map(UserProfile::getDisplayName).orElse(null);
}
/** 处置完成后通知举报人(不通知被举报人处置细节,避免二次伤害/踢皮球)。 */
private void sendReporterResultNotification(String reporterUserId, String result, String reportId) {
if (reporterUserId == null) return;
String title = "举报处理结果";
String content = Constants.PROCESS_RESULT_NO_ACTION.equals(result)
? "您提交的举报经核实未发现问题,感谢您的监督。"
: "您提交的举报已处理完成,感谢您对社区安全的贡献。";
try {
// business_id 列为 UUID:用举报 id 作幂等键(不可传非 UUID 字符串)
notificationService.send(reporterUserId, Constants.NOTIFICATION_TYPE_RISK, title, content,
"report", reportId);
} catch (Exception e) {
log.warn("举报结果通知发送失败: reporter={}", reporterUserId, e);
}
}
private void applyRiskAction(String userId, String result, String note, String operatorId) {
RiskAction action = new RiskAction();
action.setUserId(userId);
@@ -329,6 +555,34 @@ public class ReportService {
action.setEffectiveAt(Instant.now());
action.setReasonDetail(note);
riskActionMapper.insert(action);
// 同步账号状态:封禁 / 暂停必须落到 users.account_status,否则处置不真正生效 ——
// 登录门禁(AuthService.checkAccountStatus)、推荐引擎、匹配资格、能力判定
// 全部以 users.account_status 为准,只写 risk_actions 会让封禁沦为"一条记录"。
String newAccountStatus = switch (result) {
case Constants.PROCESS_RESULT_BAN -> Constants.ACCOUNT_STATUS_BANNED;
case Constants.PROCESS_RESULT_TEMPORARY_SUSPEND -> Constants.ACCOUNT_STATUS_SUSPENDED;
default -> null; // warning / feature_limit 不改变账号状态
};
if (newAccountStatus != null) {
syncAccountStatus(userId, newAccountStatus, operatorId);
}
}
/**
* 把处置结果写入 users.account_status 并留痕。
* 注销(closed)态不属风控处置范围,不可被覆盖;相同状态不重复写。
*/
private void syncAccountStatus(String userId, String newStatus, String operatorId) {
User user = userMapper.selectById(userId);
if (user == null) return;
String oldStatus = user.getAccountStatus();
if (newStatus.equals(oldStatus) || Constants.ACCOUNT_STATUS_CLOSED.equals(oldStatus)) return;
user.setAccountStatus(newStatus);
userMapper.updateById(user);
// audit_logs.before_data/after_data 为 JSONB:必须写合法 JSON,裸字符串(如 normal)会被 PG 拒绝。
saveAuditLog(operatorId, "ACCOUNT_STATUS_CHANGE", "user", userId,
"{\"accountStatus\":\"" + oldStatus + "\"}", "{\"accountStatus\":\"" + newStatus + "\"}");
}
private String calculateRiskLevel(int score) {
@@ -351,6 +605,8 @@ public class ReportService {
dto.setProcessNote(report.getProcessNote());
// 填充显示名称
dto.setReasonText(getReasonText(report.getReasonType()));
dto.setReporterName(displayNameOf(report.getReporterUserId()));
dto.setReportedName(displayNameOf(report.getReportedUserId()));
return dto;
}
@@ -368,7 +624,7 @@ public class ReportService {
};
}
private void sendProcessNotification(String userId, String result, String note) {
private void sendProcessNotification(String userId, String result, String note, String businessId) {
if (userId == null) return;
String title = "账号处置通知";
String content = switch (result) {
@@ -378,8 +634,9 @@ public class ReportService {
case Constants.PROCESS_RESULT_BAN -> "您的账号已被封禁,如有疑问可提交申诉。";
default -> "您的账号相关处置已完成。";
};
// business_id 列为 UUID:用被处置举报 id 作幂等键(历史 defect:曾传非 UUID 字符串导致处置 500,事务回滚)
notificationService.send(userId, Constants.NOTIFICATION_TYPE_RISK, title, content,
"risk_action", null, "risk_action:" + userId + ":" + System.currentTimeMillis());
"risk_action", businessId);
}
private void saveAuditLog(String operatorId, String action, String resourceType,
@@ -65,7 +65,8 @@ public class SecurityConfig {
.accessDeniedHandler(forbiddenHandler(objectMapper)))
.authorizeHttpRequests(auth -> auth
// 公开接口(order=0 最高优先级)
.requestMatchers("/auth/wechat/login", "/auth/refresh", "/auth/operator/login").permitAll()
.requestMatchers("/auth/wechat/login", "/auth/refresh", "/auth/operator/login")
.permitAll()
.requestMatchers("/health/**").permitAll()
.requestMatchers("/version").permitAll()
.requestMatchers("/app/config").permitAll()
@@ -39,11 +39,16 @@ public class JwtAuthFilter extends OncePerRequestFilter {
private static final List<String> PERMITTED_PATHS = List.of(
"/api/v1/auth/**",
"/auth/**",
"/api/v1/health/**",
"/health/**",
"/api/v1/version",
"/version",
"/api/v1/operator/auth/**",
"/api/v1/app/**",
"/api/v1/activities"
"/app/**",
"/api/v1/activities",
"/activities"
);
@Override
@@ -51,11 +56,16 @@ public class JwtAuthFilter extends OncePerRequestFilter {
FilterChain chain) throws ServletException, IOException {
String contextPath = request.getContextPath();
String servletPath = request.getServletPath();
if (servletPath == null || servletPath.isEmpty()) {
servletPath = request.getRequestURI();
}
String effectivePath = (contextPath != null && !contextPath.isEmpty())
? contextPath + servletPath : servletPath;
String method = request.getMethod();
if ("OPTIONS".equals(method) || isPermittedPath(effectivePath)) {
if ("OPTIONS".equals(method)
|| isPermittedPath(effectivePath)
|| isPermittedPath(request.getRequestURI())) {
chain.doFilter(request, response);
return;
}
@@ -136,7 +146,11 @@ public class JwtAuthFilter extends OncePerRequestFilter {
}
private boolean isPermittedPath(String path) {
String normalizedPath = path.startsWith("/api/v1")
? path
: "/api/v1" + (path.startsWith("/") ? path : "/" + path);
return PERMITTED_PATHS.stream()
.anyMatch(pattern -> pathMatcher.match(pattern, path));
.anyMatch(pattern -> pathMatcher.match(pattern, path)
|| pathMatcher.match(pattern, normalizedPath));
}
}
@@ -2,7 +2,6 @@
-- 丹阳公益婚恋服务平台 — Demo Seed 最终版(单次事务,纯 SQL)
-- ============================================================
BEGIN;
SET session_replication_role = 'replica';
-- 1. 组织
INSERT INTO organizations (id, name, type, contact_name, status) VALUES
@@ -11,27 +10,27 @@ INSERT INTO organizations (id, name, type, contact_name, status) VALUES
(gen_random_uuid(), '丹阳市慈善基金会', 'charity_center', '王秀英', 'active');
-- 2. 工作人员(V1.0原有 + V1.1新增)
INSERT INTO operators (id, organization_id, name, username, password_hash, status)
SELECT gen_random_uuid(), id, '陈主任', 'admin', '$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active'
INSERT INTO operators (id, organization_id, display_id, name, username, password_hash, status)
SELECT gen_random_uuid(), id, 'HN20261019001', '陈主任', 'admin', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
FROM organizations WHERE type='women_federation'
UNION ALL
SELECT gen_random_uuid(), id, '红娘小王', 'wanghongniang', '$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active'
SELECT gen_random_uuid(), id, 'HN20261019002', '红娘小王', 'wanghongniang', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
FROM organizations WHERE type='women_federation'
UNION ALL
SELECT gen_random_uuid(), id, '审核员小张', 'reviewer01', '$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active'
SELECT gen_random_uuid(), id, 'HN20261019003', '审核员小张', 'reviewer01', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
FROM organizations WHERE type='charity_center'
UNION ALL
SELECT gen_random_uuid(), id, '活动管理员赵', 'activitymgr','$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active'
SELECT gen_random_uuid(), id, 'HN20261019004', '活动管理员赵', 'activitymgr','$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
FROM organizations WHERE type='trade_union'
-- V1.1 新增演示操作员
UNION ALL
SELECT gen_random_uuid(), id, '服务站小李', 'stationop', '$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active'
SELECT gen_random_uuid(), id, 'HN20261019005', '服务站小李', 'stationop', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
FROM organizations WHERE type='women_federation'
UNION ALL
SELECT gen_random_uuid(), id, '咨询师小周', 'counselor', '$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active'
SELECT gen_random_uuid(), id, 'HN20261019006', '咨询师小周', 'counselor', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
FROM organizations WHERE type='charity_center'
UNION ALL
SELECT gen_random_uuid(), id, '内容编辑小吴', 'contenteditor','$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active'
SELECT gen_random_uuid(), id, 'HN20261019007', '内容编辑小吴', 'contenteditor','$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
FROM organizations WHERE type='women_federation';
-- 操作员角色绑定(V1.0原有 + V1.1新增)
@@ -211,10 +210,11 @@ SELECT gen_random_uuid(), id, 'photos/demo/'||alias||'/lifestyle1.jpg', 'lifesty
-- ============================================================
-- 4. 审核任务
-- ============================================================
INSERT INTO review_tasks (id, user_id, review_type, status, assigned_operator_id, submitted_at, started_at, completed_at, final_comment, version)
INSERT INTO review_tasks (id, user_id, review_type, status, assigned_operator_id, submitted_at, started_at, completed_at, final_comment, version, display_id)
SELECT gen_random_uuid(), ud.id, 'initial_profile', 'approved',
(SELECT id FROM operators WHERE username='reviewer01'),
NOW()-INTERVAL '60 days', NOW()-INTERVAL '59 days', NOW()-INTERVAL '58 days', '资料完整', 1
NOW()-INTERVAL '60 days', NOW()-INTERVAL '59 days', NOW()-INTERVAL '58 days', '资料完整', 1,
'RW'||LPAD(ROW_NUMBER() OVER (ORDER BY ud.alias)::text, 11, '0')
FROM user_data ud WHERE ud.alias IN ('f01','f02','f03','m01','m02','m03','m05');
-- ============================================================
@@ -331,17 +331,17 @@ VALUES
WITH women_org AS (SELECT id FROM organizations WHERE type='women_federation' LIMIT 1),
trade_org AS (SELECT id FROM organizations WHERE type='trade_union' LIMIT 1),
mgr AS (SELECT id FROM operators WHERE username='activitymgr' LIMIT 1)
INSERT INTO activities (id, title, description, organizer_org_id, coorganizer_text,
INSERT INTO activities (id, title, display_id, description, organizer_org_id, coorganizer_text,
start_at, end_at, location_name, location_address,
male_limit, female_limit, total_limit, min_age, max_age,
registration_start_at, registration_end_at, need_review, status, created_by)
VALUES
(gen_random_uuid(), '丹阳公益单身联谊会(夏季场)',
(gen_random_uuid(), '丹阳公益单身联谊会(夏季场)', 'HD20261019001',
'面向丹阳市区及周边乡镇的单身青年,由妇联、工会联合主办。',
(SELECT id FROM women_org), '丹阳市总工会、丹阳市慈善基金会',
'2026-08-15T14:00:00Z', '2026-08-15T17:30:00Z', '丹阳市文化馆多功能厅', '丹阳市丹北路168号',
15, 15, 30, 22, 40, '2026-07-01T00:00:00Z', '2026-08-10T23:59:59Z', true, 'registration_open', (SELECT id FROM mgr)),
(gen_random_uuid(), '七夕特别活动·丹阳缘来是你',
(gen_random_uuid(), '七夕特别活动·丹阳缘来是你', 'HD20261019002',
'七夕节特别策划,浪漫主题相亲活动。',
(SELECT id FROM trade_org), '丹阳市新时代文明实践中心',
'2026-08-29T15:00:00Z', '2026-08-29T18:00:00Z', '丹阳大剧院报告厅', '丹阳市书院路28号',
@@ -442,7 +442,6 @@ VALUES
('manual_review_needed', '需人工复核', 'quality', true, 7)
ON CONFLICT (code) DO NOTHING;
SET session_replication_role = 'origin';
DROP TABLE IF EXISTS _conv_result;
-- ============================================================
@@ -0,0 +1,487 @@
-- ============================================================
-- 丹阳公益婚恋服务平台 — V1.1 增量演示数据(单事务,纯 SQL)
-- ============================================================
-- 目标:为 V1.1 内容/服务域补齐演示数据,使 H5 演示端以下页面不再空白:
-- home_banners(首页轮播)、content_categories / content_articles(婚恋课堂+反诈)、
-- service_locations(善缘服务站)、service_programs(专项公益服务项目)、
-- counseling_services(婚恋咨询 + 婚前辅导)、user_local_eligibilities(本地资格),
-- 以及首页「今日推荐」所需的双向 Required 兼容候选对象(见第 10 节)。
--
-- 约束(与任务一致):
-- 1. 不改任何 Flyway 迁移 / Java / 前端源码;本文件位于 db/ 而非 db/migration/。
-- 2. 可重复执行(幂等):固定 UUID 字面量 + ON CONFLICT (id) DO NOTHING,
-- 唯一约束冲突处用 WHERE NOT EXISTS 守卫。
-- 3. 不使用 SET session_replication_role;所有 NOT NULL 列显式赋值。
-- 4. 外键用子查询解析既有演示用户 / 操作员 / 组织。
-- ============================================================
BEGIN;
-- ============================================================
-- 1. 首页轮播 home_banners(追加 3 条;现有 3 条保持不动)
-- target_type 仅限内部跳转,禁止外部 URL(AGENTS 第 16 条)
-- created_by 引用既有操作员 contenteditor
-- ============================================================
INSERT INTO home_banners
(id, title, subtitle, button_text, target_type, target_value,
audience_scope, sort_order, start_at, end_at, status, created_by)
VALUES
('a1111111-1111-1111-1111-111111110001'::uuid,
'婚前辅导公益开放报名', '专业婚前辅导,帮助你们更从容地走向未来', '了解婚前辅导',
'premarital', NULL, 'all', 4, NOW() - INTERVAL '10 days', NOW() + INTERVAL '180 days', 'active',
(SELECT id FROM operators WHERE username = 'contenteditor')),
('a1111111-1111-1111-1111-111111110002'::uuid,
'婚恋反诈 · 守护你的真心', '识别常见情感骗局,远离财产与隐私陷阱', '查看反诈指南',
'article', 'a3333333-3333-3333-3333-333333330004', 'all', 5,
NOW() - INTERVAL '10 days', NOW() + INTERVAL '180 days', 'active',
(SELECT id FROM operators WHERE username = 'contenteditor')),
('a1111111-1111-1111-1111-111111110003'::uuid,
'婚恋咨询可以预约啦', '一对一的公益婚恋咨询服务,为你答疑解惑', '预约咨询',
'counseling', NULL, 'all', 6, NOW() - INTERVAL '10 days', NOW() + INTERVAL '180 days', 'active',
(SELECT id FROM operators WHERE username = 'contenteditor'))
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 2. 婚恋课堂分类 content_categories(4 个)
-- 生产已由 V027 播过同名 code(marriage_class / anti_fraud / premarital_edu / custom_culture),
-- 此处在缺失时补种;用 WHERE NOT EXISTS 守卫保证幂等,不覆盖既有行。
-- ============================================================
INSERT INTO content_categories (id, name, code, description, sort_order, status)
SELECT v.id, v.name, v.code, v.description, v.sort_order, 'active'
FROM (VALUES
('a2222222-2222-2222-2222-222222220001'::uuid, '婚恋课堂', 'marriage_class',
'恋爱沟通、关系经营与择偶知识', 1),
('a2222222-2222-2222-2222-222222220002'::uuid, '婚恋反诈', 'anti_fraud',
'婚恋诈骗识别与防范知识', 2),
('a2222222-2222-2222-2222-222222220003'::uuid, '婚前辅导', 'premarital_edu',
'婚前准备与关系建立指导', 3),
('a2222222-2222-2222-2222-222222220004'::uuid, '婚俗新风', 'custom_culture',
'传统婚俗与文明新风尚', 4)
) AS v(id, name, code, description, sort_order)
WHERE NOT EXISTS (SELECT 1 FROM content_categories c WHERE c.code = v.code);
-- ============================================================
-- 3. 婚恋课堂文章 content_articles(7 篇,含 2 篇反诈主题)
-- status = published 且 publish_at <= NOW()(公开查询 findPublishedAll 的条件)
-- category_id 按 code 子查询解析;author_operator_id 引用 contenteditor
-- ============================================================
INSERT INTO content_articles
(id, category_id, title, summary, content, author_operator_id, status, publish_at, view_count, sort_order)
VALUES
('a3333333-3333-3333-3333-333333330001'::uuid,
(SELECT id FROM content_categories WHERE code = 'marriage_class'),
'初次见面聊什么?五个不冷场的话题',
'第一次见面不必紧张,从兴趣、家乡、工作日常聊起,自然又真诚。',
'很多人在第一次见面时最怕冷场。其实不必刻意寻找“金句”,从彼此的兴趣、家乡、工作日常聊起,就能自然展开。'
|| E'\n\n' || '一、聊聊兴趣爱好:最近在看的书、在练的运动、在追的剧,都能引出一段轻松对话。'
|| E'\n' || '二、聊聊家乡与成长:丹阳本地人可以从熟悉的地方小吃、老街区聊起。'
|| E'\n' || '三、聊聊工作日常:关注感受而非收入数字,避免变成互相盘问。'
|| E'\n' || '四、聊聊对未来的期待:让对方看到你的真诚与计划。'
|| E'\n' || '五、聊聊最近的开心事:好情绪能迅速拉近距离。'
|| E'\n\n' || '记住:一次见面能愉快结束,就已经是一次成功的相识。',
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-08-01T10:00:00Z', 186, 1),
('a3333333-3333-3333-3333-333333330002'::uuid,
(SELECT id FROM content_categories WHERE code = 'marriage_class'),
'如何判断两个人是否合适',
'合适的两个人,不是条件清单全打勾,而是价值观相似、沟通舒服、节奏一致。',
'判断两个人是否合适,可以从三个维度观察。'
|| E'\n\n' || '一、价值观是否相似:对家庭、金钱、事业、生活方式的根本看法越接近,长期相处越省力。'
|| E'\n' || '二、沟通是否舒服:有分歧时能不能把话说完、愿不愿意换位理解,比从不吵架更重要。'
|| E'\n' || '三、节奏是否一致:对关系推进速度、见面频率的期待相差太远,容易积累失望。'
|| E'\n\n' || '合适的感情会让人感到安心,而不是长期紧绷。如果相处总让你自我怀疑,就要认真审视了。',
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-08-12T10:00:00Z', 154, 2),
('a3333333-3333-3333-3333-333333330003'::uuid,
(SELECT id FROM content_categories WHERE code = 'marriage_class'),
'从喜欢到结婚:健康关系的发展节奏',
'好的感情有其自然节奏:相识、了解、确定关系、见家长、谈婚论嫁,每一步都值得认真对待。',
'从喜欢到结婚,往往要经历几个自然阶段。'
|| E'\n\n' || '相识期:通过平台、活动或朋友认识,先建立基本的信任。'
|| E'\n' || '了解期:在多次见面和沟通中,观察对方真实的生活状态与处事方式。'
|| E'\n' || '确定期:双方对关系的定位达成一致,明确彼此是唯一的选择。'
|| E'\n' || '成长期:一起面对家庭、工作与现实问题,检验是否适合长期共同生活。'
|| E'\n' || '决策期:讨论婚礼、住房、育儿等议题,走向婚姻。'
|| E'\n\n' || '不要因为年龄焦虑而跳过了解期,也不要因为害怕而迟迟不肯确认。健康的节奏,是双方都舒服又都在向前走。',
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-09-05T10:00:00Z', 97, 3),
('a3333333-3333-3333-3333-333333330004'::uuid,
(SELECT id FROM content_categories WHERE code = 'anti_fraud'),
'婚恋反诈指南:识别常见情感骗局',
'刚认识就谈投资、频繁借钱、索取隐私资料,都是危险信号。保护好自己的真心和钱包。',
'婚恋交友中,以下情况需要高度警惕。'
|| E'\n\n' || '一、短期内的经济诉求:以投资理财、博彩平台、工程项目为由,拉你一起“赚钱”。'
|| E'\n' || '二、反复借款:以生病、周转、被冻结为由不断借钱,甚至教你如何贷款。'
|| E'\n' || '三、索取隐私资料:催你脱离平台联系,索要银行卡、验证码、身份证信息。'
|| E'\n' || '四、冒充平台人员:谎称缴纳认证金、保证金、解冻费。'
|| E'\n\n' || '一旦遇到上述情形:立即停止资金操作,保存聊天与转账证据,及时通过平台举报,紧急情况拨打 110。'
|| E'\n\n' || '请记住:正规公益平台不会以任何名义向你收取费用,实名认证和人工审核也不会要求你转账。',
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-09-10T10:00:00Z', 260, 1),
('a3333333-3333-3333-3333-333333330005'::uuid,
(SELECT id FROM content_categories WHERE code = 'anti_fraud'),
'「杀猪盘」套路全解析',
'温柔人设、投资话术、逐步加码——「杀猪盘」的每一步都有迹可循。',
'「杀猪盘」是婚恋诈骗中常见的一类,套路通常分四步。'
|| E'\n\n' || '第一步:建立人设。对方往往形象、职业、谈吐都很完美,且对你的生活格外“上心”。'
|| E'\n' || '第二步:迅速升温。短时间内表达强烈的爱与依赖,推动关系快速确定。'
|| E'\n' || '第三步:引入投资。以“内部消息”“稳赚不赔”为诱饵,让你注册陌生平台并小额获利尝甜头。'
|| E'\n' || '第四步:加大投入后收割。诱导你投入更多资金,随后平台无法提现、对方失联。'
|| E'\n\n' || '防范要点:爱情里没有人会急着带你“赚钱”;凡是引导转账、下载陌生 App 的,立即停止并举报。',
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-09-20T10:00:00Z', 213, 2),
('a3333333-3333-3333-3333-333333330006'::uuid,
(SELECT id FROM content_categories WHERE code = 'premarital_edu'),
'婚前一定要聊清楚的十个问题',
'在走进婚姻之前,把金钱、家务、父母、育儿等关键问题谈清楚,是对彼此负责。',
'走进婚姻前,建议和对方认真聊清以下问题。'
|| E'\n\n' || '一、金钱观:收入如何管理?大额支出怎么商量?'
|| E'\n' || '二、婚后居住:和父母同住还是独立居住?'
|| E'\n' || '三、家务分工:如何共同承担家庭事务?'
|| E'\n' || '四、生育计划:要不要孩子、何时要、如何教育?'
|| E'\n' || '五、双方父母:如何平衡对各自家庭的照顾?'
|| E'\n' || '六、职业规划:是否支持对方的职业发展?'
|| E'\n' || '七、冲突处理:吵架时如何避免伤害对方?'
|| E'\n' || '八、双方底线:哪些事情无法接受?'
|| E'\n' || '九、家庭应急:突发状况如何共同应对?'
|| E'\n' || '十、对婚姻的期待:各自理想中的婚后生活是什么样的?'
|| E'\n\n' || '这些问题没有标准答案,但提前沟通能减少未来的误解与争执。',
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-09-15T10:00:00Z', 88, 1),
('a3333333-3333-3333-3333-333333330007'::uuid,
(SELECT id FROM content_categories WHERE code = 'custom_culture'),
'婚事新办,文明婚俗新风尚',
'低彩礼、简仪式、重情义,让婚礼回归幸福本身。',
'近年来,婚事新办、喜事简办逐渐成为新风尚。'
|| E'\n\n' || '抵制高价彩礼:彩礼不应成为家庭的沉重负担,更不该成为衡量感情的标尺。'
|| E'\n' || '简化仪式流程:把精力放在真诚的相处上,而不是铺张的排场。'
|| E'\n' || '注重情义内涵:一场由亲友见证、温馨朴素的婚礼,同样值得珍藏。'
|| E'\n\n' || '善缘丹阳倡导文明健康的婚恋观,愿每一对新人都能轻装上阵,从容开启新生活。',
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-09-18T10:00:00Z', 64, 1)
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 4. 善缘服务站 service_locations(追加 2 条;现有 1 条保持不动)
-- ============================================================
INSERT INTO service_locations
(id, name, short_name, address, latitude, longitude,
contact_phone_display, opening_hours_text, service_description,
primary_organization_id, status)
VALUES
('a4444444-4444-4444-4444-444444440001'::uuid,
'丹阳市开发区善缘服务站', '开发区站', '丹阳市开发区凤凰路18号',
31.9210000, 119.6090000, '0511-86666888',
'周一至周五 9:00-17:00,周六 9:00-12:00(法定节假日除外)',
'提供公益红娘服务、婚恋咨询预约、婚前辅导与本地资格核验',
(SELECT id FROM organizations WHERE type = 'women_federation' LIMIT 1), 'active'),
('a4444444-4444-4444-4444-444444440002'::uuid,
'丹阳市延陵镇善缘服务站', '延陵镇站', '丹阳市延陵镇人民路36号',
31.8600000, 119.4900000, '0511-86688866',
'周一至周五 9:00-16:30(法定节假日除外)',
'面向延陵镇及周边乡镇提供公益婚恋登记咨询、活动报名与反诈宣传',
(SELECT id FROM organizations WHERE type = 'trade_union' LIMIT 1), 'active')
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 5. 专项公益服务项目 service_programs(3 条:2 个已激活 + 1 个草稿)
-- 用户端公开列表只展示 status = active
-- ============================================================
INSERT INTO service_programs
(id, name, description, organizer_org_id, start_at, end_at, status, created_by)
VALUES
('a5555555-5555-5555-5555-555555550001'::uuid,
'公益红娘一对一服务计划',
'由公益红娘为长期未遇合适对象的单身人士提供一对一牵线与情感陪跑服务。',
(SELECT id FROM organizations WHERE type = 'women_federation' LIMIT 1),
NOW() - INTERVAL '30 days', NOW() + INTERVAL '150 days', 'active',
(SELECT id FROM operators WHERE username = 'admin')),
('a5555555-5555-5555-5555-555555550002'::uuid,
'婚恋反诈宣传进社区',
'联合社区开展婚恋反诈宣讲,帮助居民识别情感诈骗与网络交友陷阱。',
(SELECT id FROM organizations WHERE type = 'charity_center' LIMIT 1),
NOW() - INTERVAL '15 days', NOW() + INTERVAL '120 days', 'active',
(SELECT id FROM operators WHERE username = 'admin')),
('a5555555-5555-5555-5555-555555550003'::uuid,
'婚前辅导公益课堂(筹备中)',
'面向即将步入婚姻的情侣,开展婚前沟通、家庭经营等公益课程。',
(SELECT id FROM organizations WHERE type = 'women_federation' LIMIT 1),
NOW() + INTERVAL '20 days', NOW() + INTERVAL '200 days', 'draft',
(SELECT id FROM operators WHERE username = 'admin'))
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 6. 婚恋咨询 counseling_services(2 条)
-- service_type / status 取值对齐生产 CHECK 约束
-- ============================================================
INSERT INTO counseling_services
(id, user_id, service_type, request_source, user_request_summary,
organization_id, assigned_operator_id, status,
preferred_service_mode, scheduled_start_at, completed_at)
VALUES
('a6666666-6666-6666-6666-666666660001'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f02'),
'relationship_guidance', 'user_self',
'离异后重新开始,希望了解如何建立健康的新关系。',
(SELECT id FROM organizations WHERE type = 'charity_center' LIMIT 1),
(SELECT id FROM operators WHERE username = 'counselor'),
'completed', 'offline', NOW() - INTERVAL '20 days', NOW() - INTERVAL '19 days'),
('a6666666-6666-6666-6666-666666660002'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_m02'),
'emotional_support', 'user_self',
'工作压力较大,希望有人倾听并给出情绪调节建议。',
(SELECT id FROM organizations WHERE type = 'charity_center' LIMIT 1),
(SELECT id FROM operators WHERE username = 'counselor'),
'assigned', 'online', NULL, NULL)
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 7. 婚前辅导 counseling_services(service_type = premarital_guidance,2 条)
-- 婚前辅导复用 counseling_services 表,无独立表
-- ============================================================
INSERT INTO counseling_services
(id, user_id, service_type, request_source, user_request_summary,
organization_id, assigned_operator_id, status,
companion_user_id, scheduled_start_at, user_confirmed_at, completed_at)
VALUES
('a6666666-6666-6666-6666-666666660003'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f05'),
'premarital_guidance', 'user_self',
'计划明年结婚,希望接受婚前辅导,了解婚后相处与家庭经营。',
(SELECT id FROM organizations WHERE type = 'charity_center' LIMIT 1),
(SELECT id FROM operators WHERE username = 'counselor'),
'completed',
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_m05'),
NOW() - INTERVAL '12 days', NOW() - INTERVAL '12 days', NOW() - INTERVAL '11 days'),
('a6666666-6666-6666-6666-666666660004'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_m05'),
'premarital_guidance', 'user_self',
'希望和未婚妻一起参加婚前辅导课堂,为婚后生活做准备。',
(SELECT id FROM organizations WHERE type = 'charity_center' LIMIT 1),
(SELECT id FROM operators WHERE username = 'counselor'),
'scheduled',
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f05'),
NOW() + INTERVAL '5 days', NULL, NULL)
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 8. 本地资格 user_local_eligibilities(3 条)
-- eligibility_type / verification_status 对齐生产 CHECK 约束
-- 生产有 UNIQUE(user_id),用固定 id + ON CONFLICT (id) DO NOTHING 保证幂等
-- ============================================================
INSERT INTO user_local_eligibilities
(id, user_id, eligibility_type, declared_region_code, verification_status, verified_at, verified_by)
VALUES
('a7777777-7777-7777-7777-777777770001'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f01'),
'household_registration', '321181', 'verified',
NOW() - INTERVAL '40 days', (SELECT id FROM operators WHERE username = 'reviewer01')),
('a7777777-7777-7777-7777-777777770002'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_m01'),
'employment', '321181', 'verified',
NOW() - INTERVAL '35 days', (SELECT id FROM operators WHERE username = 'reviewer01')),
('a7777777-7777-7777-7777-777777770003'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f03'),
'residence', '321100', 'pending_review', NULL, NULL)
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 9. 本地资格佐证 local_eligibility_evidences(2 条)
-- ============================================================
INSERT INTO local_eligibility_evidences
(id, local_eligibility_id, file_key, evidence_type, status)
VALUES
('a8888888-8888-8888-8888-888888880001'::uuid,
(SELECT id FROM user_local_eligibilities
WHERE user_id = (SELECT id FROM users WHERE wechat_openid = 'demo_openid_f03')),
'eligibility/demo/f03/residence_proof.jpg', 'residence_proof', 'reviewing'),
('a8888888-8888-8888-8888-888888880002'::uuid,
(SELECT id FROM user_local_eligibilities
WHERE user_id = (SELECT id FROM users WHERE wechat_openid = 'demo_openid_m01')),
'eligibility/demo/m01/employment_proof.jpg', 'employment_proof', 'approved')
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 10. 首页「今日推荐」候选对象(新增 3 位男性演示用户 m07/m08/m09)
-- ------------------------------------------------------------
-- 根因:演示登录账号「晓燕」(demo_openid_f02) 为离异,其 required 婚姻状况为
-- ["never_married"](要求对方未婚);而既有演示男性 m01~m06 的 required 婚姻
-- 状况同样是 ["never_married"],对「晓燕」的 divorced 不满足 → 双向 Required
-- 互斥,RecommendationEngineService 对 f02 恒返回空,「今日推荐」恒空白。
--
-- 这 3 位候选的设计(同时满足 f02 的 required 与自身的 required 反向):
-- * 本人资料:未婚 (never_married)、年龄落在 f02 的 28-40、地区落在
-- ["321181","321100"]、身高落在 f02 偏好的 155-172、学历 >= college;
-- * 自身择偶条件:marital_statuses = ["never_married","divorced"](接受离异),
-- age 范围覆盖 f02 的 33 岁、region 覆盖 321181,故反向 Required 亦满足。
-- * 账号满足 RecommendationEngineService.isParticipantEligible:
-- account_status=normal / relationship_status=searching / recommendation_enabled=true /
-- visible_in_recommendations=true(本地资格未开启强制,无需核验)。
-- ============================================================
INSERT INTO users (id, wechat_openid, account_status, relationship_status, recommendation_enabled, created_at)
VALUES
('a9900000-0000-0000-0000-000000000007'::uuid, 'demo_openid_m07', 'normal', 'searching', true, NOW() - INTERVAL '20 days'),
('a9900000-0000-0000-0000-000000000008'::uuid, 'demo_openid_m08', 'normal', 'searching', true, NOW() - INTERVAL '20 days'),
('a9900000-0000-0000-0000-000000000009'::uuid, 'demo_openid_m09', 'normal', 'searching', true, NOW() - INTERVAL '20 days')
ON CONFLICT DO NOTHING;
INSERT INTO user_profiles (user_id, display_name, gender, birth_date, height_cm,
region_code, region_name, marital_status, children_status,
education_code, occupation_code, smoking_status, drinking_status,
long_term_danyang, future_danyang, relationship_goal, profile_completion, profile_status)
VALUES
('a9900000-0000-0000-0000-000000000007'::uuid, '陈志远', 'male', '1991-03-18', 172,
'321181', '丹阳', 'never_married', 'none', 'bachelor', 'engineer', 'never', 'sometimes',
'是', '是', '结婚', 93, 'approved'),
('a9900000-0000-0000-0000-000000000008'::uuid, '吴凯', 'male', '1994-07-09', 170,
'321181', '丹阳', 'never_married', 'none', 'bachelor', 'teacher', 'never', 'sometimes',
'是', '是', '结婚', 91, 'approved'),
('a9900000-0000-0000-0000-000000000009'::uuid, '郑海', 'male', '1989-11-22', 168,
'321100', '镇江', 'never_married', 'none', 'master', 'architect', 'never', 'sometimes',
'是', '是', '结婚', 94, 'approved')
ON CONFLICT (user_id) DO NOTHING;
-- 择偶条件:marital 接受 never_married + divorced(关键差异点),其余维度与 f02 互容
INSERT INTO user_preferences
(user_id, min_age, max_age, age_priority, min_height_cm, max_height_cm, height_priority,
region_scope, region_priority, marital_statuses, marital_priority, min_education_code, education_priority)
VALUES
('a9900000-0000-0000-0000-000000000007'::uuid, 28, 42, 'required', 155, 180, 'preferred',
'["321181","321100"]'::jsonb, 'required', '["never_married","divorced"]'::jsonb, 'required', NULL, 'preferred'),
('a9900000-0000-0000-0000-000000000008'::uuid, 28, 42, 'required', 155, 180, 'preferred',
'["321181","321100"]'::jsonb, 'required', '["never_married","divorced"]'::jsonb, 'required', NULL, 'preferred'),
('a9900000-0000-0000-0000-000000000009'::uuid, 28, 42, 'required', 155, 180, 'preferred',
'["321181","321100"]'::jsonb, 'required', '["never_married","divorced"]'::jsonb, 'required', NULL, 'preferred')
ON CONFLICT (user_id) DO NOTHING;
-- 隐私:必须允许出现在推荐里,否则 isParticipantEligible 直接排除
INSERT INTO user_privacy_settings
(user_id, visible_in_recommendations, visible_in_search, show_occupation,
show_lifestyle_photos, receive_activity_recommendations, accept_matchmaker_recommendations)
VALUES
('a9900000-0000-0000-0000-000000000007'::uuid, true, true, true, true, true, true),
('a9900000-0000-0000-0000-000000000008'::uuid, true, true, true, true, true, true),
('a9900000-0000-0000-0000-000000000009'::uuid, true, true, true, true, true, true)
ON CONFLICT (user_id) DO NOTHING;
-- 实名认证(mock,与既有演示用户一致,用于演示端「已实名」标识)
INSERT INTO user_identities
(id, user_id, real_name_enc, id_number_enc, id_number_hash, gender, birth_date, verification_status, provider, verified_at)
VALUES
('a9922222-2222-2222-2222-222222220007'::uuid, 'a9900000-0000-0000-0000-000000000007'::uuid,
encode('demo_enc_m07'::bytea, 'escape'), encode('demo_id_m07'::bytea, 'escape'), 'demo_sha256_m07',
'male', '1991-03-18', 'verified', 'mock', NOW() - INTERVAL '1 day'),
('a9922222-2222-2222-2222-222222220008'::uuid, 'a9900000-0000-0000-0000-000000000008'::uuid,
encode('demo_enc_m08'::bytea, 'escape'), encode('demo_id_m08'::bytea, 'escape'), 'demo_sha256_m08',
'male', '1994-07-09', 'verified', 'mock', NOW() - INTERVAL '1 day'),
('a9922222-2222-2222-2222-222222220009'::uuid, 'a9900000-0000-0000-0000-000000000009'::uuid,
encode('demo_enc_m09'::bytea, 'escape'), encode('demo_id_m09'::bytea, 'escape'), 'demo_sha256_m09',
'male', '1989-11-22', 'verified', 'mock', NOW() - INTERVAL '1 day')
ON CONFLICT DO NOTHING;
-- 照片(头像 + 生活照,与既有演示用户一致)
INSERT INTO user_photos (id, user_id, file_key, photo_type, sort_order, review_status)
VALUES
('a9933333-3333-3333-3333-333333330007'::uuid, 'a9900000-0000-0000-0000-000000000007'::uuid, 'photos/demo/m07/avatar.jpg', 'avatar', 0, 'approved'),
('a9933333-3333-3333-3333-333333330017'::uuid, 'a9900000-0000-0000-0000-000000000007'::uuid, 'photos/demo/m07/lifestyle1.jpg', 'lifestyle', 1, 'approved'),
('a9933333-3333-3333-3333-333333330008'::uuid, 'a9900000-0000-0000-0000-000000000008'::uuid, 'photos/demo/m08/avatar.jpg', 'avatar', 0, 'approved'),
('a9933333-3333-3333-3333-333333330018'::uuid, 'a9900000-0000-0000-0000-000000000008'::uuid, 'photos/demo/m08/lifestyle1.jpg', 'lifestyle', 1, 'approved'),
('a9933333-3333-3333-3333-333333330009'::uuid, 'a9900000-0000-0000-0000-000000000009'::uuid, 'photos/demo/m09/avatar.jpg', 'avatar', 0, 'approved'),
('a9933333-3333-3333-3333-333333330019'::uuid, 'a9900000-0000-0000-0000-000000000009'::uuid, 'photos/demo/m09/lifestyle1.jpg', 'lifestyle', 1, 'approved')
ON CONFLICT DO NOTHING;
-- ============================================================
-- 11. 红娘工作台「我的服务对象分配」(matchmaker_assignments)
-- ------------------------------------------------------------
-- 根因:V053 的存量回填发生在演示 seed 之前,故演示服务单的分配记录在
-- matchmaker_assignments 中为空 → GET /operator/matchmaker/assignments/my
-- 返回 [],红娘端工作台既无服务对象,也无法发起「联系授权申请 / 转介」。
-- 这里为既有演示服务单补建分配记录,与 matchmaker_services.assigned_operator_id
-- 保持一致(均指向演示红娘 wanghongniang)。
-- ============================================================
INSERT INTO matchmaker_assignments
(id, user_id, matchmaker_operator_id, service_id, assigned_by,
assignment_scope, assignment_reason, status, assigned_at, created_at, updated_at)
VALUES
-- 晓燕(f02) → 红娘小王,服务单 FW20261018001(in_service),生效中
('a9944444-4444-4444-4444-444444440001'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f02'),
(SELECT id FROM operators WHERE username = 'wanghongniang'),
(SELECT id FROM matchmaker_services WHERE display_id = 'FW20261018001'),
(SELECT id FROM operators WHERE username = 'admin'),
'primary', '演示:红娘小王主责跟进', 'active',
NOW() - INTERVAL '30 days', NOW() - INTERVAL '30 days', NOW() - INTERVAL '30 days'),
-- 丧偶用户(f06) → 红娘小王,服务单 FW20261018002(waiting_user),生效中
('a9944444-4444-4444-4444-444444440002'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f06'),
(SELECT id FROM operators WHERE username = 'wanghongniang'),
(SELECT id FROM matchmaker_services WHERE display_id = 'FW20261018002'),
(SELECT id FROM operators WHERE username = 'admin'),
'primary', '演示:红娘小王主责跟进', 'active',
NOW() - INTERVAL '25 days', NOW() - INTERVAL '25 days', NOW() - INTERVAL '25 days'),
-- 已完结服务(f04) → 分配已结束(终态,便于演示历史分配)
('a9944444-4444-4444-4444-444444440003'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f04'),
(SELECT id FROM operators WHERE username = 'wanghongniang'),
(SELECT id FROM matchmaker_services WHERE display_id = 'FW20261018003'),
(SELECT id FROM operators WHERE username = 'admin'),
'primary', '演示:服务已完结', 'ended',
NOW() - INTERVAL '60 days', NOW() - INTERVAL '60 days', NOW() - INTERVAL '60 days')
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 12. 服务站运营人员数据范围(service_location_operators)
-- ------------------------------------------------------------
-- 根因:该表为空 → 管理端「服务预约」(GET /service-appointments/operator/list)
-- 对服务站运营人员 stationop 恒返回空,红娘转介产生的预约(source =
-- matchmaker_referral)在管理端不可见,红娘端↔管理端的转介联动无法演示。
-- 为演示服务站运营人员 stationop 显式授予 3 个演示服务站的运营范围
-- (组织关系不自动授予数据权限,必须显式站点分配,见 AGENTS 第 24 条 / INV-018)。
-- ============================================================
INSERT INTO service_location_operators (service_location_id, operator_id, created_by, created_at)
VALUES
((SELECT id FROM service_locations WHERE name = '万善公园婚姻登记处善缘服务站'),
(SELECT id FROM operators WHERE username = 'stationop'),
(SELECT id FROM operators WHERE username = 'admin'), NOW()),
((SELECT id FROM service_locations WHERE name = '丹阳市开发区善缘服务站'),
(SELECT id FROM operators WHERE username = 'stationop'),
(SELECT id FROM operators WHERE username = 'admin'), NOW()),
((SELECT id FROM service_locations WHERE name = '丹阳市延陵镇善缘服务站'),
(SELECT id FROM operators WHERE username = 'stationop'),
(SELECT id FROM operators WHERE username = 'admin'), NOW())
ON CONFLICT (service_location_id, operator_id) DO NOTHING;
-- ============================================================
-- 验证
-- ============================================================
DO $$
DECLARE
b_cnt INTEGER; cc_cnt INTEGER; ca_cnt INTEGER; sl_cnt INTEGER;
sp_cnt INTEGER; cs_cnt INTEGER; pm_cnt INTEGER; le_cnt INTEGER; ev_cnt INTEGER;
rec_cnt INTEGER; rec_pref INTEGER; asg_cnt INTEGER; slo_cnt INTEGER;
BEGIN
SELECT COUNT(*) INTO b_cnt FROM home_banners WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO cc_cnt FROM content_categories WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO ca_cnt FROM content_articles WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO sl_cnt FROM service_locations WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO sp_cnt FROM service_programs WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO cs_cnt FROM counseling_services WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO pm_cnt FROM counseling_services
WHERE service_type = 'premarital_guidance' AND deleted_at IS NULL;
SELECT COUNT(*) INTO le_cnt FROM user_local_eligibilities WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO ev_cnt FROM local_eligibility_evidences WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO rec_cnt FROM users
WHERE wechat_openid IN ('demo_openid_m07','demo_openid_m08','demo_openid_m09')
AND deleted_at IS NULL;
SELECT COUNT(*) INTO rec_pref FROM user_preferences p
WHERE p.user_id IN ('a9900000-0000-0000-0000-000000000007'::uuid,
'a9900000-0000-0000-0000-000000000008'::uuid,
'a9900000-0000-0000-0000-000000000009'::uuid);
SELECT COUNT(*) INTO asg_cnt FROM matchmaker_assignments WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO slo_cnt FROM service_location_operators;
RAISE NOTICE '========================================';
RAISE NOTICE 'V1.1 增量演示数据填充完成!';
RAISE NOTICE '========================================';
RAISE NOTICE ' Banner: % 条 | 内容分类: % 个 | 文章: % 篇', b_cnt, cc_cnt, ca_cnt;
RAISE NOTICE ' 服务站: % 个 | 专项服务项目: % 个', sl_cnt, sp_cnt;
RAISE NOTICE ' 咨询服务: % 条(其中婚前辅导 % 条)', cs_cnt, pm_cnt;
RAISE NOTICE ' 本地资格: % 条 | 资格佐证: % 条', le_cnt, ev_cnt;
RAISE NOTICE ' 今日推荐候选(m07/m08/m09): % 位 | 择偶条件: % 条', rec_cnt, rec_pref;
RAISE NOTICE ' 红娘服务对象分配: % 条', asg_cnt;
RAISE NOTICE ' 服务站运营人员范围(slo): % 条', slo_cnt;
RAISE NOTICE '========================================';
END $$;
COMMIT;
@@ -14,13 +14,13 @@ INSERT INTO organizations (id, name, type, contact_name, status) VALUES
INSERT INTO operators (id, organization_id, name, username, password_hash, status) VALUES
('f54eedf5-a281-4289-a63d-57de1ea83fbc', 'c74863d4-be10-4efa-b22c-f3f84e2e4e59', '陈主任', 'admin', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHdBfKYuPIiCTB6MJJDEaK', 'active'),
('f54eedf5-a281-4289-a63d-57de1ea83fbc', 'c74863d4-be10-4efa-b22c-f3f84e2e4e59', '陈主任', 'admin', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'),
('a98c0d97-d272-4577-887e-5c05e27db19b', 'c74863d4-be10-4efa-b22c-f3f84e2e4e59', '红娘小王', 'wanghongniang', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHdBfKYuPIiCTB6MJJDEaK', 'active'),
('a98c0d97-d272-4577-887e-5c05e27db19b', 'c74863d4-be10-4efa-b22c-f3f84e2e4e59', '红娘小王', 'wanghongniang', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'),
('e9312b05-c263-4901-8145-0b38e4d81c45', 'bcd9e5b6-3b66-454c-96d7-3fade6866135', '审核员小张', 'reviewer01', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHdBfKYuPIiCTB6MJJDEaK', 'active'),
('e9312b05-c263-4901-8145-0b38e4d81c45', 'bcd9e5b6-3b66-454c-96d7-3fade6866135', '审核员小张', 'reviewer01', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'),
('b4cdeb91-bbce-4587-be58-22ff55ef89da', 'a35f0043-be09-4bdf-ad5d-05a72ff19c2a', '活动管理员赵', 'activitymgr', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHdBfKYuPIiCTB6MJJDEaK', 'active');
('b4cdeb91-bbce-4587-be58-22ff55ef89da', 'a35f0043-be09-4bdf-ad5d-05a72ff19c2a', '活动管理员赵', 'activitymgr', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active');
INSERT INTO operator_roles (operator_id, role_id) VALUES ('f54eedf5-a281-4289-a63d-57de1ea83fbc', '00000000-0000-0000-0000-000000000001');
@@ -0,0 +1,12 @@
-- 指定对象的公益红娘协助申请(不交换联系方式)
ALTER TABLE matchmaker_services ADD COLUMN IF NOT EXISTS target_user_id UUID;
ALTER TABLE matchmaker_services ADD COLUMN IF NOT EXISTS authorization_at TIMESTAMPTZ;
ALTER TABLE matchmaker_services ADD COLUMN IF NOT EXISTS withdrawn_at TIMESTAMPTZ;
ALTER TABLE matchmaker_services ADD COLUMN IF NOT EXISTS withdraw_reason VARCHAR(500);
ALTER TABLE matchmaker_services DROP CONSTRAINT IF EXISTS chk_matchmaker_service_type;
ALTER TABLE matchmaker_services ADD CONSTRAINT chk_matchmaker_service_type CHECK (service_type IN (
'recommend_partner', 'review_preference', 'contact_intention', 'join_activity',
'no_match_help', 'chat_help', 'other', 'recommendation',
'recommendation_assistance', 'referral_counseling', 'referral_activity', 'referral_station'
));
CREATE INDEX IF NOT EXISTS idx_matchmaker_target_user ON matchmaker_services(target_user_id, created_at DESC);
@@ -73,7 +73,7 @@ class ActivityMutualConsentServiceTest {
@Test
@DisplayName("Gate: 双确认后形成 Match 并开启 Conversation(此前只打日志,Gate 失败)")
void doubleConfirmCreatesMatchAndConversation() {
void doubleConfirmCreatesMatchWithoutConversation() {
// 第一阶段:建立互选同意记录(二阶段同意)
consentService.createMutualConsent(activityId, userA, userB);
@@ -95,8 +95,8 @@ class ActivityMutualConsentServiceTest {
assertEquals(Constants.MATCH_SOURCE_ACTIVITY_MUTUAL, match.getSource());
// 会话必须已开启(Gate:互选确认后双方可沟通)
assertTrue(conversationMapper.findByMatchId(match.getId()).isPresent(),
"双确认后必须已开启 Conversation");
assertTrue(conversationMapper.findByMatchId(match.getId()).isEmpty(),
"双确认后不得创建用户聊天 Conversation");
// 溯源字段:consent 记录应绑定 match_id
ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userA, userB).orElseThrow();
@@ -15,6 +15,8 @@ import com.danyang.match.platform.risk.entity.RiskAction;
import com.danyang.match.platform.risk.mapper.RiskActionMapper;
import com.danyang.match.platform.shared.config.FeatureFlagConfig;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -68,6 +70,8 @@ class AppealPhase18IntegrationTest {
@Autowired
private NotificationMapper notificationMapper;
@Autowired
private UserMapper userMapper;
@Autowired
private FeatureFlagConfig featureFlagConfig;
private boolean originalAppealEnabled;
@@ -201,6 +205,44 @@ class AppealPhase18IntegrationTest {
assertEquals("suspended", status.get("accountStatus"));
}
// ==================== 申诉通过回滚 users.account_status(避免"通过仍锁死") ====================
private User insertUser(String accountStatus) {
User user = new User();
user.setId(UUID.randomUUID().toString());
user.setWechatOpenid("openid-" + UUID.randomUUID());
user.setAccountStatus(accountStatus);
userMapper.insert(user);
return user;
}
@Test
void approveRestoresAccountStatusToNormal() {
User user = insertUser(Constants.ACCOUNT_STATUS_BANNED);
RiskAction ban = insertAction(user.getId(), Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_STATUS_ACTIVE);
String appealId = submit(user.getId(), ban.getId(), Constants.APPEAL_TYPE_BAN);
process(appealId, "approve");
assertEquals(Constants.ACCOUNT_STATUS_NORMAL,
userMapper.selectById(user.getId()).getAccountStatus(),
"申诉通过且该用户已无其他生效封禁/暂停时,必须把 users.account_status 恢复为 normal");
}
@Test
void approveKeepsAccountStatusWhenOtherRestrictionRemains() {
User user = insertUser(Constants.ACCOUNT_STATUS_BANNED);
RiskAction ban = insertAction(user.getId(), Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_STATUS_ACTIVE);
insertAction(user.getId(), Constants.RISK_ACTION_TEMPORARY_SUSPEND, Constants.RISK_ACTION_STATUS_ACTIVE);
String appealId = submit(user.getId(), ban.getId(), Constants.APPEAL_TYPE_BAN);
process(appealId, "approve");
assertEquals(Constants.ACCOUNT_STATUS_BANNED,
userMapper.selectById(user.getId()).getAccountStatus(),
"仍有其他生效处置时,账号状态不得被回滚为 normal");
}
// ==================== Gate B:拒绝后原处置保持有效 ====================
@Test
@@ -71,6 +71,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@SpringBootTest
@ActiveProfiles("test")
@AutoConfigureMockMvc
@Disabled("用户端即时聊天已按产品方案下线")
class ChatPhase09IntegrationTest {
@Autowired private MockMvc mockMvc;
@@ -40,7 +40,8 @@ class MigrationValidationTest {
// (六态状态机 + active 部分唯一索引)
// 用户展示编号:V058 users.display_number 及历史数据回填。
// 公共身份展示编号:V059 operators.display_id 及历史数据回填。
private static final int LATEST_MIGRATION = 60;
// 红娘协助对象与授权字段:V061 matchmaker_services 扩展。
private static final int LATEST_MIGRATION = 61;
private static final String MIGRATION_DIR =
"backend/src/main/resources/db/migration/";
@@ -94,6 +94,7 @@ class P0FixVerificationTest {
// ==================== P0-1: Block不变量验证 ====================
@Test
@Disabled("用户端即时聊天已按产品方案下线")
@DisplayName("INV-003: Block后A不可向B发送消息")
void testBlockPreventsChat() {
relationshipService.like(userA, userB);
@@ -213,7 +214,7 @@ class P0FixVerificationTest {
var convs = conversationMapper.selectList(
new LambdaQueryWrapper<com.danyang.match.platform.chat.entity.Conversation>()
.eq(com.danyang.match.platform.chat.entity.Conversation::getMatchId, match.getId()));
assertFalse(convs.isEmpty(), "Match后应自动创建Conversation");
assertTrue(convs.isEmpty(), "Match 后不得自动创建 Conversation");
}
// ==================== P1-1: Required全字段过滤验证 ====================
@@ -258,12 +258,12 @@ class MatchmakerActionGuardTest {
// ==================== MM-ACTION-002:会话/关系开启无 operator 触达面 ====================
@Test
@DisplayName("MM-ACTION-002(负向): operator token 打会话开启端点 → 403(match/conversation 仅用户路径)")
@DisplayName("MM-ACTION-002(负向): operator token 打已下线会话端点 → 410")
void operatorCannotOpenConversationFromMatch() throws Exception {
// 认证层即拒绝(hasRole("USER")),无需真实 match 存在
// 用户间会话已全局下线,先于角色鉴权返回 CHAT_DISABLED。
mockMvc.perform(post("/conversations/from-match/{matchId}", "no-such-match")
.header("Authorization", "Bearer " + operatorToken))
.andExpect(status().isForbidden());
.andExpect(status().isGone());
}
// ==================== MM-ACTION-003:must-confirm 语义锁定 ====================
@@ -260,11 +260,11 @@ class MatchmakerAssignmentA1Test {
Constants.AUDIT_ACTION_ASSIGNMENT_CREATE.equals(a.getAction())).count());
assertTrue(audits.stream().allMatch(a -> "matchmaker_assignment".equals(a.getResourceType())),
"resourceType 必须是 matchmaker_assignment");
// afterData 摘要口径:只含 matchmakerOperatorId/scope/reason
// afterData 摘要口径:只含 matchmakerOperatorId/scope/reason,且必须是合法 JSON
assertTrue(audits.stream().allMatch(a -> a.getAfterData() != null
&& a.getAfterData().contains("matchmakerOperatorId=")
&& a.getAfterData().contains("scope=")
&& a.getAfterData().contains("reason=")),
&& a.getAfterData().contains("\"matchmakerOperatorId\"")
&& a.getAfterData().contains("\"scope\"")
&& a.getAfterData().contains("\"reason\"")),
"afterData 必须记录 operatorId/scope/reason 摘要");
}
@@ -237,7 +237,7 @@ class RelationshipProgressPhase12Test {
// 旧 Match 有会话
Long oldConvCount = conversationMapper.selectCount(
new LambdaQueryWrapper<Conversation>().eq(Conversation::getMatchId, first.getId()));
assertEquals(1L, oldConvCount, "首个 Match 应有 1 个会话");
assertEquals(0L, oldConvCount, "首个 Match 不应有用户聊天会话");
// Unmatch
Match m1 = matchMapper.findById(first.getId()).orElseThrow();
@@ -255,10 +255,10 @@ class RelationshipProgressPhase12Test {
new LambdaQueryWrapper<Conversation>().eq(Conversation::getMatchId, second.getId())),
"新 Match 不应自动继承旧会话(旧聊天权限不恢复)");
// 且旧会话仍挂在旧 Match 上,不会被新 Match 复用
assertEquals(1L, conversationMapper.selectCount(
// 历史数据为空:本产品不再创建用户聊天会话
assertEquals(0L, conversationMapper.selectCount(
new LambdaQueryWrapper<Conversation>().eq(Conversation::getMatchId, first.getId())),
"旧会话应仍属于旧 Match");
"旧 Match 也不应存在用户聊天会话");
}
// ==================== 拒绝 / 恢复 / 退出 ====================
@@ -17,6 +17,8 @@ import com.danyang.match.platform.risk.service.ReportService;
import com.danyang.match.platform.risk.service.RiskReferralService;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -76,6 +78,8 @@ class ReportAndRiskPhase17IntegrationTest {
@Autowired
private AuditLogMapper auditLogMapper;
@Autowired
private UserMapper userMapper;
@Autowired
private ReportOperatorController reportOperatorController;
@Autowired
private RiskController riskController;
@@ -164,6 +168,64 @@ class ReportAndRiskPhase17IntegrationTest {
assertEquals(auditBefore + 1, auditAfter, "PROCESS_REPORT 审计应新增一条");
}
// ==================== 处置必须真正生效:同步 users.account_status ====================
/**
* 封禁 / 暂停若只写 risk_actions 而不落 users.account_status,则登录门禁、推荐引擎、
* 匹配资格、能力判定全部不认这个处置 —— 被封禁用户仍可登录、仍出现在推荐/发现中。
* 本测试用真实 users 行锁定「处置 → users.account_status」这条链路。
*/
@Test
void processReportSyncsAccountStatusForBanAndSuspendButNotWarning() {
User banUser = insertUser(Constants.ACCOUNT_STATUS_NORMAL);
User suspendUser = insertUser(Constants.ACCOUNT_STATUS_NORMAL);
User warnUser = insertUser(Constants.ACCOUNT_STATUS_NORMAL);
processReportFor(banUser.getId(), Constants.PROCESS_RESULT_BAN);
processReportFor(suspendUser.getId(), Constants.PROCESS_RESULT_TEMPORARY_SUSPEND);
processReportFor(warnUser.getId(), Constants.PROCESS_RESULT_WARNING);
assertEquals(Constants.ACCOUNT_STATUS_BANNED,
userMapper.selectById(banUser.getId()).getAccountStatus(),
"封禁处置必须把 users.account_status 置为 banned,否则封禁不真正生效");
assertEquals(Constants.ACCOUNT_STATUS_SUSPENDED,
userMapper.selectById(suspendUser.getId()).getAccountStatus(),
"暂停处置必须把 users.account_status 置为 suspended");
assertEquals(Constants.ACCOUNT_STATUS_NORMAL,
userMapper.selectById(warnUser.getId()).getAccountStatus(),
"警告处置不改变账号状态");
// 审计 before/after 为 JSONB:必须落合法 JSON 文本(裸字符串在 PG 上会被拒绝)。
AuditLog statusAudit = auditLogMapper.selectOne(new QueryWrapper<AuditLog>()
.eq("action", "ACCOUNT_STATUS_CHANGE").eq("resource_id", banUser.getId()));
assertNotNull(statusAudit, "处置同步账号状态应写入 ACCOUNT_STATUS_CHANGE 审计");
assertTrue(statusAudit.getAfterData().contains("\"accountStatus\":\"banned\""),
"审计 after_data 必须是合法 JSON 且记录新状态,实际: " + statusAudit.getAfterData());
}
private User insertUser(String accountStatus) {
User user = new User();
user.setId(UUID.randomUUID().toString());
user.setWechatOpenid("openid-" + UUID.randomUUID());
user.setAccountStatus(accountStatus);
userMapper.insert(user);
return user;
}
private void processReportFor(String reportedUserId, String processResult) {
ReportSubmitDTO dto = new ReportSubmitDTO();
dto.setReportedUserId(reportedUserId);
dto.setReasonType(Constants.REPORT_REASON_FRAUD);
dto.setDescription("处置同步账号状态测试");
String reportId = (String) reportService.submitReport(UUID.randomUUID().toString(), dto)
.get("reportId");
ReportProcessDTO pdto = new ReportProcessDTO();
pdto.setProcessResult(processResult);
pdto.setProcessNote("处置同步账号状态测试");
reportService.processReport(reportId, pdto, "op-account-sync");
}
// ==================== D2:人工风险标记必须审计 ====================
@Test
+4 -2
View File
@@ -453,7 +453,9 @@ CREATE TABLE activity_staff_assignments (
-- V009: matchmaker_services 红娘服务表
CREATE TABLE matchmaker_services (
id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36) NOT NULL, service_type VARCHAR(32) NOT NULL,
description VARCHAR(1000), status VARCHAR(32) NOT NULL DEFAULT 'submitted',
description VARCHAR(1000), target_user_id VARCHAR(36), authorization_at TIMESTAMP WITH TIME ZONE,
withdrawn_at TIMESTAMP WITH TIME ZONE, withdraw_reason VARCHAR(500),
status VARCHAR(32) NOT NULL DEFAULT 'submitted',
organization_id VARCHAR(36), assigned_operator_id VARCHAR(36),
assigned_at TIMESTAMP WITH TIME ZONE, completed_at TIMESTAMP WITH TIME ZONE,
referral_target_type VARCHAR(32), referral_target_id VARCHAR(36),
@@ -464,7 +466,7 @@ CREATE TABLE matchmaker_services (
CONSTRAINT chk_matchmaker_service_type CHECK (service_type IN (
'recommend_partner', 'review_preference', 'contact_intention',
'join_activity', 'no_match_help', 'chat_help', 'other',
'recommendation',
'recommendation', 'recommendation_assistance',
'referral_counseling', 'referral_activity', 'referral_station'
)),
CONSTRAINT chk_matchmaker_status CHECK (status IN (
+20
View File
@@ -0,0 +1,20 @@
# 用户端无即时通讯边界(2026-10-15)
## 已确认范围
- 小程序全流程不展示微信群二维码,也不展示或交换手机号、微信号、QQ 等个人联系方式。
- 小程序不提供用户之间的私信、聊天、会话列表或 WebSocket 双向通讯。
- 双向意向匹配只产生平台系统通知,并引导用户查看公益活动、公益服务站和公益红娘服务。
- 活动签到二维码属于活动现场核验,不属于群二维码,继续保留。
## 实施边界
- Like 和活动互选不再创建新 Conversation,不返回 `matchedConversationId`。
- 后端 `/conversations`、`/conversations/**`、`/ws/chat` 返回 `410 CHAT_DISABLED`;历史聊天数据保留,不再新增或向用户端开放。
- Nginx `/ws/` 直接返回 `410 CHAT_DISABLED`,避免网关继续代理聊天连接。
- 小程序不注册消息 Tab、消息页和聊天页;历史源码暂保留用于数据迁移和回归兼容,但不在运行路由中暴露。
## 验收
- `tests/miniapp/no_user_im_contract_test.py`:通过。
- 仍需在后端容器完成完整 `mvn test -B`,并验证运行中的 REST 聊天接口返回 410。
+5
View File
@@ -899,3 +899,8 @@ docker run --rm --dns 223.5.5.5 --dns 114.114.114.114 `
- 后端镜像已按当前源码重建成功;健康检查通过,未认证通知接口返回 401。
- 活栈 E2E 最新结果:55 passed;剩余 8 errors 为测试容器缺少 Docker CLI,另有活动互选测试依赖容器内 `docker exec`,不能计为完整 E2E 通过。
- 证据:`.mimosa/core-e2e-postgres-test.log`。
## 2026-10-15 无用户 IM 改造
- 已按确认方案关闭小程序用户私信/聊天入口,不展示微信群二维码。
- 匹配成功保留平台系统通知,并引导公益活动、服务站、红娘服务。
- 后端与 Nginx 聊天入口返回 CHAT_DISABLED/410;完整 Maven 回归与运行态验收待本轮完成。
@@ -0,0 +1,77 @@
# V1.1 演示版基线(2026-10-19)
## 范围
本批次目标是形成可重复启动、可验证的本地演示版本。允许使用 Mock 微信登录和演示账号;不把演示结果当作正式生产发布证据。
本批次明确不验收:真实微信账号、生产证书、正式域名、真实用户 UAT、外部备份介质。
## 当前基线证据
| 门禁 | 当前结果 |
|---|---|
| 后端 Maven 全量 | PASS:919 tests,0 failures,0 errors,17 skipped |
| Admin type-check | PASS |
| Admin build | PASS,但有 CSS URL 解析警告和大 chunk 警告 |
| Miniapp type-check | PASS |
| 空壳测试守卫 | PASS |
| 本地 Compose 解析 | PASS |
| 生产 Compose 解析 | PASS:使用临时演示变量验证通过 |
| 活栈 Compose | PASS:`danyang-demo` 隔离栈健康,PostgreSQL/Redis/SeaweedFS/Backend/Nginx 均就绪 |
| E2E | PASS:全新卷迁移/seed 后真实本地 PostgreSQL 活栈 `72 passed`(2026-10-19 重新验证) |
| UI 契约与空壳守卫 | PASS:TabBar/页面/WXML/WXSS/API 行为契约通过 |
| H5 浏览器冒烟 | PASS:活栈 `/h5` 下未登录守卫、演示用户登录、6 个核心页、Like、红娘工作台均通过(2026-10-19 重新验证) |
## 演示候选范围
- 后端 Java、数据库迁移、后端测试和 E2E fixture。
- `infrastructure/docker-compose.yml`、staging 配置、Nginx 和演示环境变量示例。
- `admin-web` 构建产物和类型检查范围。
- `miniapp` 类型检查及演示路径相关代码。
- 必要的演示验收、部署和运行文档。
## 明确排除
- `.mimosa/`、`.trae/`、`.wb_tmp_bcrypt/` 等机器本地临时目录。
- 未经本批次审查的 H5 或演示新增文件。
- 真实微信 Provider、生产证书、正式域名、真实用户数据和外部备份介质。
- 与演示闭环无关的功能扩展和大范围重构。
## 演示验证命令
```powershell
mvn -B -f backend/pom.xml test
pnpm --dir admin-web type-check
pnpm --dir admin-web build
npm --prefix miniapp run type-check
python scripts/check-no-empty-tests.py
docker compose -p danyang-demo -f infrastructure/docker-compose.yml config --quiet
$env:E2E_BASE_URL='http://localhost:8085/api/v1'
$env:E2E_OPERATOR_PASSWORD='演示环境密码'
python -m pytest tests/e2e -q
$env:DEMO_H5_BASE_URL='http://localhost:6689/h5'
npm --prefix h5-demo run smoke
```
本次全新卷验证还确认:首次迁移与 Demo seed 成功,`seed-demo.ps1 -E2E` 第二次重跑幂等成功。当前 `danyang-demo` 数据卷使用创建时的临时凭据(PostgreSQL `demo-db`、Redis `demo-redis`);重建 backend 时必须向 Compose 注入同一组卷凭据,并使用长度至少 32 字节的 JWT 临时密钥。该凭据仅用于本机演示,不得用于生产。
## 演示数据初始化
在 Compose 栈健康后执行一次即可;脚本可重复运行,不会清空数据库:
```powershell
.\scripts\seed-demo.ps1
```
需要同时准备 E2E 运营账号时:
```powershell
$env:E2E_OPERATOR_PASSWORD='DemoPass123!'
.\scripts\seed-demo.ps1 -E2E
```
## 当前判断
演示版达到 `CONDITIONAL GO (DEMO ONLY)`:可在本机 Docker 环境演示核心用户、运营、红娘、咨询、服务站和活动互选流程。该结论不覆盖真实微信、生产证书、正式域名、真实用户 UAT 或外部备份介质,也不等于正式生产 `GO`。
正式生产仍保持 `NO-GO`,待上述排除项完成后另行执行生产部署、真实集成、UAT、恢复演练和发布审批。
@@ -0,0 +1,349 @@
# V1.1 演示发布修复实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在不依赖真实微信账号、生产证书、正式域名、真实用户 UAT 和外部备份介质的前提下,修复当前代码并形成可重复启动、可验证的 V1.1 演示版本。
**Architecture:** 保留现有模块化单体、Mock 微信 Provider、PostgreSQL、Redis、SeaweedFS、管理端和小程序结构。演示环境与生产环境配置分开;演示验收使用真实 HTTP、真实 PostgreSQL 和真实容器,不把 H2 或静态断言当作完整演示证据。
**Tech Stack:** Java 21 / Spring Boot 3 / Maven / PostgreSQL 16 / Redis 7 / SeaweedFS 4.47 / Docker Compose / Vue 3 / TypeScript / 微信原生小程序 / pytest。
**Spec:** `AGENTS.md`、`README.md`、`docs/PHASE26-GO-LIVE-CHECKLIST.md`、`docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md`。
## Global Constraints
- 演示环境允许 Mock 微信登录,但必须显式使用演示 profile,不能让生产 profile 误启用 Mock。
- PostgreSQL 是 Source of Truth;演示 E2E 必须使用真实 PostgreSQL,不得以 H2 结果替代。
- 活动互选必须经过双方确认后才创建 Match;红娘权限必须由服务端分配范围控制。
- 不修改真实微信、生产证书、正式域名、真实用户 UAT、外部备份介质相关范围;这些作为正式发布前遗留项记录。
- 不清理或回滚用户现有未提交改动;发布候选树必须通过显式文件清单建立。
- 不新增依赖;优先使用已有 Maven、pnpm、npm、pytest 和 Compose 工具链。
- 每个任务完成后先跑该任务的最小测试,再进入下一任务;全量回归最后执行。
---
### Task 1: 建立演示发布基线并隔离工作区变更
**Files:**
- Modify: `docs/STATUS.md`(追加本次演示修复批次状态)
- Modify: `docs/REMAINING_WORK.md`(把真实生产风险与演示阻塞分开)
- Create: `docs/demo/DEMO-RELEASE-BASELINE-2026-10-19.md`
- Inspect: `git status --short`、`git diff --stat`、`VERSION`、现有 release tag
**Interfaces:**
- Produces: 演示候选版本的文件清单、排除清单和验证命令。
- Later tasks consume: 候选版本范围,避免把 `.mimosa/`、`.trae/`、`.wb_tmp_bcrypt/` 和未审查演示文件混入发布。
- [ ] **Step 1: 记录当前失败基线**
记录当前命令及结果:
```powershell
git status --short
git diff --stat
mvn -B test -f backend/pom.xml
pnpm --dir admin-web type-check
pnpm --dir admin-web build
npm --prefix miniapp run type-check
python scripts/check-no-empty-tests.py
```
- [ ] **Step 2: 建立候选文件清单**
将生产代码、测试、迁移、演示 Compose、E2E 和文档分组;明确机器临时目录、H5 演示目录和新增迁移是否纳入本批次。
- [ ] **Step 3: 记录演示版接受边界**
在 `DEMO-RELEASE-BASELINE-2026-10-19.md` 写明:Mock 微信可用、HTTPS/正式域名不验收、演示账号不代表真实用户验收、备份只做本机可恢复性检查。
- [ ] **Step 4: 验证文档不再互相矛盾**
检查 README、STATUS、REMAINING_WORK、PHASE26 清单中的演示状态一致;不得把历史全绿报告写成当前证据。
**验收:** 文档能准确指出当前失败项、演示范围和排除范围;不改业务代码。
---
### Task 2: 修复后端当前 9 个失败门禁
**Files:**
- Modify: `backend/src/main/java/com/danyang/match/platform/shared/config/SecurityConfig.java` 或实际配置匿名路径的文件
- Modify: `backend/src/main/java/com/danyang/match/platform/auth/controller/AuthController.java`(仅在测试证明登录入口路径配置错误时)
- Modify: `backend/src/main/java/com/danyang/match/platform/health/controller/HealthController.java`(仅在映射存在但未放行时)
- Modify: `backend/src/test/java/com/danyang/match/platform/auth/AuthControllerTest.java`
- Modify: `backend/src/test/java/com/danyang/match/platform/auth/HealthControllerTest.java`
- Modify: `backend/src/test/java/com/danyang/match/platform/auth/PublicMiniappApiTest.java`
**Interfaces:**
- Produces: 未认证健康检查和公开小程序接口返回约定的 200;受保护接口仍返回 401/403。
- Preserves: `Authorization: Bearer`、运营端和用户端角色隔离、统一响应体格式。
- [ ] **Step 1: 先定位过滤链原因**
用失败测试和 `SecurityConfig` 的 request matcher 对照,列出每个失败 URI、期望状态、当前 matcher。不要通过降低测试断言来消除失败。
- [ ] **Step 2: 写最小回归断言**
为每个公开 URI 保留匿名 200 用例,并为一个受保护业务 URI 保留匿名 401 用例,确保放行规则没有扩大到用户数据接口。
- [ ] **Step 3: 修改一次共享安全配置**
在统一的 Security matcher 中补齐健康、版本、微信登录和公开小程序资源;不在各 Controller 增加重复鉴权绕过。
- [ ] **Step 4: 运行专项测试**
```powershell
mvn -B -f backend/pom.xml test `
"-Dtest=AuthControllerTest,HealthControllerTest,PublicMiniappApiTest"
```
- [ ] **Step 5: 运行权限回归**
```powershell
mvn -B -f backend/pom.xml test `
"-Dtest=SecurityPathCoverageTest,OperatorEndpointPermissionCoverageTest,V11PermissionMatrixContractTest"
```
**验收:** 上述专项测试全绿;匿名公开接口不泄露用户数据;受保护接口仍不能匿名访问。
---
### Task 3: 修复 V061 迁移与基线守卫
**Files:**
- Modify: `backend/src/main/resources/db/migration/V061__extend_matchmaker_assistance.sql`
- Modify: `backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java`
- Modify: `backend/src/test/resources/schema-h2.sql`(只有实体/测试 SQL 需要同步时才改)
- Modify: `docs/database/MIGRATION_GUIDE.md`
**Interfaces:**
- Produces: 唯一、连续、可被 Flyway 识别的 V061 迁移;测试基线与生产迁移目录一致。
- Preserves: 已部署 V001-V060,不重写历史迁移,不复用已发布版本号。
- [ ] **Step 1: 用现有正则定位失败原因**
现有守卫要求 `V\\d{3}__[^_]+(?:_[^_]+)*\\.sql`;确认 `extend_matchmaker_assistance` 中的双下划线是否导致描述段不符合当前规则,并记录选择。
- [ ] **Step 2: 选择兼容命名方案**
优先把文件名改为满足现有命名规则的单下划线描述;如果业务确实需要允许双下划线,则修改守卫并新增正例/负例,不能只把 `LATEST_MIGRATION` 改成 61。
- [ ] **Step 3: 更新迁移基线说明**
在测试注释和迁移指南中登记 V061 的用途、不可重复执行要求和 `matchmaker_services` 新字段。
- [ ] **Step 4: 做源码和构建产物双重验证**
```powershell
mvn -B -f backend/pom.xml -DskipTests package
mvn -B -f backend/pom.xml test `
"-Dtest=MigrationValidationTest,EntityMigrationParityTest,DeploymentTests"
```
**验收:** V061 命名守卫通过;构建产物中无重复版本;空库 Flyway 可从头执行到 V061。
---
### Task 4: 修复并验证演示 Compose
**Files:**
- Modify: `infrastructure/docker-compose.yml`
- Modify: `infrastructure/docker-compose.staging.yml`
- Modify: `infrastructure/docker-compose.prod.yml`(只修语法和清晰的演示/生产边界,不把生产风险伪装成已解决)
- Modify: `infrastructure/.env.example`
- Modify: `infrastructure/nginx.conf`
- Modify: `h5-demo/`(仅当容器构建或 `/h5/` 路由失败时)
- Create: `infrastructure/.env.demo.example`(若现有 `.env.example` 无法表达演示必需变量)
**Interfaces:**
- Produces: `danyang-demo` 项目可独立启动,端口、容器名、健康检查和演示 Mock 配置一致。
- Preserves: 生产 Compose 使用必需变量和 secret;不把演示密码复制进生产文件。
- [ ] **Step 1: 修正生产 Compose 插值语法**
把 `${MINIO_PUBLIC_ENDPOINT:http://seaweedfs:8333}` 改成 Compose 合法形式 `${MINIO_PUBLIC_ENDPOINT:-http://seaweedfs:8333}`,并扫描同类错误。
- [ ] **Step 2: 固化演示环境变量**
演示 `.env` 至少提供 `DB_PASSWORD`、`REDIS_PASSWORD`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`JWT_ACCESS_SECRET`、`JWT_REFRESH_SECRET`、`ENCRYPTION_KEY` 和 `WECHAT_MOCK_OPENID`;文件中的值只用于本机演示。
- [ ] **Step 3: 隔离 Compose 项目名**
所有演示命令统一使用:
```powershell
docker compose -p danyang-demo -f infrastructure/docker-compose.yml config --quiet
docker compose -p danyang-demo -f infrastructure/docker-compose.yml up -d --build
```
- [ ] **Step 4: 验证容器健康和路由**
检查 PostgreSQL、Redis、SeaweedFS、storage-init、backend、admin-web、h5-web、nginx;验证 `/api/v1/health/live`、`/admin/`、`/h5/`。
- [ ] **Step 5: 清理演示栈**
```powershell
docker compose -p danyang-demo -f infrastructure/docker-compose.yml down
```
仅删除演示项目容器,不使用跨项目的 `--remove-orphans`。
**验收:** 本地 Compose 解析成功;演示栈可重复启动和停止;生产 Compose 至少能在提供必需变量后解析成功。
---
### Task 5: 修复演示 E2E 运行条件并完成核心闭环
**Files:**
- Modify: `tests/e2e/conftest.py`(仅修环境变量、URL 或账号种子问题)
- Modify: `tests/e2e/sql/seed_e2e_operators.sql`
- Modify: `tests/e2e/home_v11_test.py`
- Modify: `tests/e2e/local_eligibility_test.py`
- Modify: `tests/e2e/activity_mutual_consent_test.py`
- Modify: `tests/e2e/service_appointment_test.py`
- Modify: `tests/e2e/referral_v11_test.py`
- Modify: `tests/e2e/service_station_permission_test.py`
- Modify: `tests/e2e/counseling_permission_test.py`
- Modify: `tests/e2e/content_e2e_test.py`
- Inspect: `tests/e2e/run_v11_tests.py`(不得把未收集脚本算作 pytest 通过)
**Interfaces:**
- Produces: 面向演示栈的真实 HTTP + PostgreSQL E2E 结果。
- Preserves: 活栈不可达退出码 4;不使用 skip 伪装未执行;Mock 只替代微信登录。
- [ ] **Step 1: 先验证活栈失败路径**
在未启动栈时运行 `python -m pytest tests/e2e -q`,确认是明确的活栈退出,而不是大量测试被跳过。
- [ ] **Step 2: 启动演示栈并初始化 E2E 账号**
使用隔离 Compose 项目名;通过 SQL seed 创建 `e2e_admin`、`e2e_platform`、`e2e_counselor`、`e2e_station`、`e2e_matchmaker`、`e2e_content`,密码仅通过环境变量传入。
- [ ] **Step 3: 运行 E2E**
```powershell
$env:E2E_BASE_URL='http://localhost:8085/api/v1'
$env:E2E_OPERATOR_PASSWORD='演示环境密码'
python -m pytest tests/e2e -q
```
- [ ] **Step 4: 修复真实失败根因**
按失败类型处理:登录/fixture 配置、权限范围、迁移数据、HTTP 状态码、业务状态机。禁止直接放宽断言或给测试增加 skip。
- [ ] **Step 5: 验证核心跨端路径**
至少保留一条可复现证据:运营端分配红娘服务 → 用户端看到服务记录 → 用户提交服务操作 → Web 查询结果;确认服务端依据 `matchmaker_assignments` 判定范围。
**验收:** `tests/e2e` 在演示活栈中通过;活栈停止时套件明确失败;核心红娘跨端链路有日志和结果记录。
---
### Task 6: 演示功能冒烟与前端构建收口
**Files:**
- Modify: `infrastructure/scripts/smoke-test.sh`(仅补演示端口或健康接口)
- Modify: `docs/DEMO-VALIDATION-2026-10-13.md`(更新为当前命令和结果)
- Modify: `docs/PHASE26-GO-LIVE-CHECKLIST.md`(增加演示版状态,不删除正式发布要求)
- Inspect: `admin-web/src/`、`miniapp/`、`h5-demo/`
**Interfaces:**
- Produces: 管理端、小程序静态检查、H5 演示和后端健康检查的统一演示报告。
- Preserves: 不把 CSS 警告或构建成功误报为完整 UI 验收。
- [ ] **Step 1: 运行前端门禁**
```powershell
pnpm --dir admin-web type-check
pnpm --dir admin-web build
npm --prefix miniapp run type-check
node ui-tests/ui-document-baseline-contracts.mjs
python scripts/check-no-empty-tests.py
```
- [ ] **Step 2: 做演示端人工路径**
用浏览器验证 `/admin/` 登录、首页、红娘工作台、咨询、服务站、内容管理;用微信开发者工具验证 Mock 登录、首页、推荐、活动、服务记录和退出登录。
- [ ] **Step 3: 记录构建警告**
CSS `url(var(--...))` 警告和大 chunk 先作为 P2 记录;只有造成页面空白、资源加载失败或路由不可用时才提升为 P0。
- [ ] **Step 4: 输出演示验收报告**
报告按 PASS / PARTIAL / FAIL / NOT_VERIFIED 分类,附命令、时间、环境、失败截图或日志路径。
**验收:** 所有演示核心路径可操作;未通过项明确列出,不用“构建通过”替代人工路径。
---
### Task 7: 演示版冻结与回归门禁
**Files:**
- Create: `docs/demo/DEMO-RELEASE-REPORT-2026-10-19.md`
- Modify: `VERSION`(仅在验收通过后)
- Inspect: `git diff --check`、`git status --short`、发布 tag 和 bundle
**Interfaces:**
- Produces: 一个干净、可追溯、可重新部署的演示版本。
- Preserves: 不宣称正式生产发布;正式风险继续列在独立的 release blocker 区域。
- [ ] **Step 1: 执行全量回归**
```powershell
mvn -B -f backend/pom.xml test
pnpm --dir admin-web type-check
pnpm --dir admin-web build
npm --prefix miniapp run type-check
python -m pytest tests/e2e -q
docker compose -p danyang-demo -f infrastructure/docker-compose.yml config --quiet
git diff --check
```
- [ ] **Step 2: 检查发布候选树**
只允许本次修复和必要测试/文档进入候选树;临时目录、密码、token、数据库 dump 不得进入。
- [ ] **Step 3: 生成演示 tag 和 bundle**
```powershell
git tag demo-v1.1.0-20261019 <release-commit>
git bundle create demo-v1.1.0-20261019.bundle demo-v1.1.0-20261019
git show demo-v1.1.0-20261019 --stat
```
- [ ] **Step 4: 回读验证 tag 和 bundle**
```powershell
git tag --verify demo-v1.1.0-20261019
git bundle verify demo-v1.1.0-20261019.bundle
git status --short
```
**验收:** 全量门禁满足演示版标准;tag、bundle、报告三者指向同一提交;工作区没有未解释的发布变更。
---
## 依赖关系
```text
Task 1
↓
Task 2 ──┐
Task 3 ──┼─> Task 4 ─> Task 5 ─> Task 6 ─> Task 7
┘
```
## 演示版最终门禁
- **PASS:** 后端全量测试 0 failure / 0 error。
- **PASS:** 演示 Compose 解析、启动、健康检查、停止均成功。
- **PASS:** E2E 使用真实 PostgreSQL,核心场景通过,未使用 skip 伪绿。
- **PASS:** Admin 类型检查和构建、小程序类型检查、UI 契约守卫通过。
- **PASS:** Mock 微信登录只在演示 profile 生效。
- **PASS:** 演示账号、密钥和数据库数据均与生产隔离。
- **NOT_VERIFIED(允许保留):** 真实微信、生产证书、正式域名、真实用户 UAT、外部备份介质。
## 本计划明确跳过的内容
本批次不接入真实微信、不申请或验证生产证书、不配置正式域名、不进行真实用户验收、不验证外部备份介质恢复,也不借此宣称正式生产可发布。
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.mimosa
*.local
+13
View File
@@ -0,0 +1,13 @@
# H5 演示端 Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install --ignore-scripts
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx-h5.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#172A72" />
<title>善缘丹阳</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+49
View File
@@ -0,0 +1,49 @@
# H5 演示端 Nginx 配置(单页应用)
#
# 与 admin-web/nginx-admin.conf 同构:产物挂在 /h5/ 子路径下(vite build base=/h5/)。
# 上游入口(生产 nginx、本地整套栈 nginx)都**不剥离** /h5 前缀地转发到本容器,
# 因此这里必须自己认识这个前缀。
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# 根路径没有内容:H5 产物挂在 /h5/ 子路径下。显式 301 避免"打根路径拿到空白/错页"的假成功。
location = / {
return 301 /h5/;
}
# SPA:/h5/** 映射到产物根目录。
# try_files 兜底写成 URI(/h5/index.html)而不是文件路径:alias + try_files 的已知坑。
location /h5/ {
alias /usr/share/nginx/html/;
try_files $uri $uri/ /h5/index.html;
}
# API proxy —— 演示端与管理端共用同一后端,数据天然一致(联动的关键)。
# 用 resolver + 变量 proxy_pass:避免 nginx 启动时 backend DNS 未就绪就 [emerg] 退出。
resolver 127.0.0.11 valid=30s ipv6=off;
set $backend_upstream "http://backend:8080";
location /api/ {
proxy_pass $backend_upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 用户间即时聊天已下线:与顶层 nginx 一致返回 410,避免演示端误以为可聊。
location /ws/ {
default_type application/json;
return 410 '{"success":false,"code":"CHAT_DISABLED","message":"平台未提供用户之间的即时聊天功能","data":null}';
}
}
+28
View File
@@ -0,0 +1,28 @@
{
"name": "danyang-match-h5-demo",
"version": "1.1.0",
"private": true,
"type": "module",
"description": "善缘丹阳 H5 演示端(小程序用户端 + 红娘端网页版,用于快速演示验证)",
"scripts": {
"dev": "vite",
"build": "vite build",
"type-check": "vue-tsc --noEmit",
"preview": "vite preview",
"smoke": "node tests/smoke-h5.cjs"
},
"dependencies": {
"vue": "^3.4.21",
"vue-router": "^4.3.0",
"pinia": "^2.1.7",
"axios": "^1.6.8",
"dayjs": "^1.11.10",
"vant": "^4.9.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
"typescript": "^5.3.3",
"vite": "^5.1.6",
"vue-tsc": "^2.0.6"
}
}
+23
View File
@@ -0,0 +1,23 @@
<template>
<router-view />
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useUserStore } from '@/stores/user'
import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
onMounted(() => {
const userStore = useUserStore()
const appStore = useAppStore()
const authStore = useAuthStore()
userStore.hydrate()
appStore.init().catch(() => {})
if (authStore.isLoggedIn) {
userStore.fetchStatus().catch(() => {})
appStore.fetchCapabilities().catch(() => {})
}
})
</script>
+174
View File
@@ -0,0 +1,174 @@
import axios, { type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios'
import { getItem, setItem, removeItem, StorageKeys } from '@/utils/storage'
import { releaseIdempotencyKey, generateRequestIdForHeader } from './idempotency'
export class ApiError extends Error {
code: string
requestId?: string
status?: number
constructor(code: string, message: string, requestId?: string, status?: number) {
super(message)
this.code = code
this.requestId = requestId
this.status = status
}
}
export interface RequestOptions {
idempotencyKey?: string
silent?: boolean
}
/** 会话失效回调(由 authStore 注入)。 */
export let onSessionExpired: (() => void) | null = null
export function setOnSessionExpired(fn: (() => void) | null): void {
onSessionExpired = fn
}
/** 维护态回调(由 appStore 注入)。 */
export let onMaintenance: ((body: any) => void) | null = null
export function setOnMaintenance(fn: ((body: any) => void) | null): void {
onMaintenance = fn
}
const client = axios.create({
baseURL: '/api/v1',
timeout: 15000,
})
function isAuthEndpoint(url?: string): boolean {
return typeof url === 'string' && url.indexOf('/auth/') === 0
}
client.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = getItem(StorageKeys.accessToken)
config.headers = config.headers || {}
config.headers['X-Request-Id'] = generateRequestIdForHeader()
if (token) config.headers['Authorization'] = `Bearer ${token}`
return config
})
let refreshPromise: Promise<boolean> | null = null
/** 单飞刷新:并发 401 共享同一次刷新请求。refresh 只走 body,不依赖 Cookie。 */
async function refreshToken(): Promise<boolean> {
if (refreshPromise) return refreshPromise
refreshPromise = (async () => {
const refreshTokenValue = getItem(StorageKeys.refreshToken)
const userId = getItem(StorageKeys.userId)
if (!refreshTokenValue) return false
try {
const resp = await axios.post('/api/v1/auth/refresh', { userId, refreshToken: refreshTokenValue })
const body = resp.data || {}
if (body.success && body.data?.accessToken) {
setItem(StorageKeys.accessToken, body.data.accessToken)
if (body.data.refreshToken) setItem(StorageKeys.refreshToken, body.data.refreshToken)
return true
}
return false
} catch {
return false
} finally {
refreshPromise = null
}
})()
return refreshPromise
}
client.interceptors.response.use(
(resp) => {
const config = resp.config as InternalAxiosRequestConfig & { __idempotencyKey?: string }
const body = resp.data || {}
if (body.success) {
const data = body.data
// 先取值,再做收尾:释放幂等键失败绝不能升级为业务失败(对齐 miniapp 语义)。
try {
releaseIdempotencyKey(config.__idempotencyKey)
} catch {
/* ignore */
}
return data
}
throw new ApiError(body.code || 'BUSINESS_ERROR', body.message || '操作失败', body.requestId, resp.status)
},
async (error) => {
const resp = error?.response
if (!resp) {
throw new ApiError('NETWORK_ERROR', '网络连接失败,请检查网络后重试', undefined, undefined)
}
const body = resp.data || {}
const config = resp.config as InternalAxiosRequestConfig & { __retried?: boolean }
if (resp.status === 503) {
if (typeof onMaintenance === 'function') onMaintenance(body)
throw new ApiError('MAINTENANCE', '平台正在维护,请稍后再试', body.requestId, 503)
}
if (resp.status === 401) {
// 认证端点自身 401 不触发刷新(否则自我递归);由调用方处理。
if (!isAuthEndpoint(config?.url) && !config?.__retried) {
const ok = await refreshToken()
if (ok) {
config.__retried = true
const token = getItem(StorageKeys.accessToken)
if (token) config.headers['Authorization'] = `Bearer ${token}`
return client.request(config)
}
removeItem(StorageKeys.accessToken)
removeItem(StorageKeys.refreshToken)
if (typeof onSessionExpired === 'function') onSessionExpired()
}
throw new ApiError(body.code || 'UNAUTHORIZED', body.message || '请重新登录', body.requestId, 401)
}
throw new ApiError(body.code || 'SERVER_ERROR', body.message || '服务暂时不可用', body.requestId, resp.status)
},
)
export interface ApiOptions {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
data?: any
headers?: Record<string, string>
idempotencyKey?: string
}
export async function request<T = any>(url: string, options: ApiOptions = {}): Promise<T> {
const config: AxiosRequestConfig & { __idempotencyKey?: string } = {
url,
method: options.method || 'GET',
data: options.data,
headers: options.headers,
}
config.__idempotencyKey = options.idempotencyKey
return client.request(config) as Promise<T>
}
export function get<T = any>(url: string, params?: Record<string, any>): Promise<T> {
return client.get(url, { params }) as Promise<T>
}
export function post<T = any>(url: string, data?: any, opts: RequestOptions = {}): Promise<T> {
const config: AxiosRequestConfig & { __idempotencyKey?: string } = { url, method: 'POST', data }
config.__idempotencyKey = opts.idempotencyKey
return client.request(config) as Promise<T>
}
export function put<T = any>(url: string, data?: any, opts: RequestOptions = {}): Promise<T> {
const config: AxiosRequestConfig & { __idempotencyKey?: string } = { url, method: 'PUT', data }
config.__idempotencyKey = opts.idempotencyKey
return client.request(config) as Promise<T>
}
export function patch<T = any>(url: string, data?: any, opts: RequestOptions = {}): Promise<T> {
const config: AxiosRequestConfig & { __idempotencyKey?: string } = { url, method: 'PATCH', data }
config.__idempotencyKey = opts.idempotencyKey
return client.request(config) as Promise<T>
}
export function del<T = any>(url: string, data?: any, opts: RequestOptions = {}): Promise<T> {
const config: AxiosRequestConfig & { __idempotencyKey?: string } = { url, method: 'DELETE', data }
config.__idempotencyKey = opts.idempotencyKey
return client.request(config) as Promise<T>
}
export default client
+45
View File
@@ -0,0 +1,45 @@
/**
* 幂等键托管(复刻 miniapp/utils/api.js 语义)
*
* 语义:同一次**尚未成功**的逻辑操作复用同一把键;成功后释放,下一次点击才是新的一次。
* 「先 resolve 再收尾」——释放失败绝不能升级为业务失败(见 client.ts)。
* 未成功操作的键保留 10 分钟。
*/
const IDEMPOTENCY_KEY_TTL_MS = 10 * 60 * 1000
interface PendingEntry {
key: string
createdAt: number
}
const pending = new Map<string, PendingEntry>()
function generateRequestId(): string {
return Date.now().toString(36) + Math.random().toString(36).substring(2, 8)
}
export function idempotencyKey(scope: string, subject?: string | number | null): string {
const fingerprint = `${scope}::${subject == null ? '' : subject}`
const now = Date.now()
const cached = pending.get(fingerprint)
if (cached && now - cached.createdAt < IDEMPOTENCY_KEY_TTL_MS) {
return cached.key
}
const key = `${scope}:${subject == null ? '' : subject}:${generateRequestId()}`
pending.set(fingerprint, { key, createdAt: now })
return key
}
export function releaseIdempotencyKey(key?: string): void {
if (!key) return
for (const [fingerprint, entry] of pending.entries()) {
if (entry.key === key) {
pending.delete(fingerprint)
return
}
}
}
export function generateRequestIdForHeader(): string {
return generateRequestId()
}
+89
View File
@@ -0,0 +1,89 @@
import axios, { type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios'
import { getItem, setItem, removeItem, StorageKeys } from '@/utils/storage'
import { ApiError } from './client'
/**
* 红娘/工作人员端独立请求实例。
*
* 与用户端 client 的关键区别(见实施计划 R3):
* - Bearer 取自 h5.operatorToken(与用户 accessToken 隔离);
* - 刷新一律走请求体 `POST /auth/operator/refresh { operatorId, refreshToken }`,
* **绝不读 Cookie** —— 避免与同浏览器内的 admin-web 会话串号。
*/
const operatorClient = axios.create({
baseURL: '/api/v1',
timeout: 15000,
})
operatorClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = getItem(StorageKeys.operatorToken)
config.headers = config.headers || {}
config.headers['X-Request-Id'] = Date.now().toString(36) + Math.random().toString(36).substring(2, 8)
if (token) config.headers['Authorization'] = `Bearer ${token}`
return config
})
let refreshing: Promise<boolean> | null = null
async function refreshOperatorToken(): Promise<boolean> {
if (refreshing) return refreshing
refreshing = (async () => {
const refreshToken = getItem(StorageKeys.refreshToken + '.operator')
const operatorId = getItem(StorageKeys.operatorId)
if (!refreshToken) return false
try {
const resp = await axios.post('/api/v1/auth/operator/refresh', { operatorId, refreshToken })
const body = resp.data || {}
if (body.success && body.data?.accessToken) {
setItem(StorageKeys.operatorToken, body.data.accessToken)
if (body.data.refreshToken) setItem(StorageKeys.refreshToken + '.operator', body.data.refreshToken)
return true
}
return false
} catch {
return false
} finally {
refreshing = null
}
})()
return refreshing
}
operatorClient.interceptors.response.use(
(resp) => {
const body = resp.data || {}
if (body.success) return body.data
throw new ApiError(body.code || 'BUSINESS_ERROR', body.message || '操作失败', body.requestId, resp.status)
},
async (error) => {
const resp = error?.response
if (!resp) throw new ApiError('NETWORK_ERROR', '网络连接失败,请重试')
const body = resp.data || {}
const config = resp.config as InternalAxiosRequestConfig & { __retried?: boolean }
if (resp.status === 401 && !config?.__retried) {
const ok = await refreshOperatorToken()
if (ok) {
config.__retried = true
const token = getItem(StorageKeys.operatorToken)
if (token) config.headers['Authorization'] = `Bearer ${token}`
return operatorClient.request(config)
}
removeItem(StorageKeys.operatorToken)
removeItem(StorageKeys.refreshToken + '.operator')
}
throw new ApiError(body.code || 'SERVER_ERROR', body.message || '服务暂时不可用', body.requestId, resp.status)
},
)
export function opGet<T = any>(url: string, params?: Record<string, any>): Promise<T> {
return operatorClient.get(url, { params }) as Promise<T>
}
export function opPost<T = any>(url: string, data?: any, opts: { idempotencyKey?: string } = {}): Promise<T> {
const config: AxiosRequestConfig & { __idempotencyKey?: string } = { url, method: 'POST', data }
return operatorClient.request(config) as Promise<T>
}
export function opPut<T = any>(url: string, data?: any): Promise<T> {
return operatorClient.put(url, data) as Promise<T>
}
export default operatorClient
+74
View File
@@ -0,0 +1,74 @@
import { get, post, del } from '../client'
import { idempotencyKey } from '../idempotency'
/** 活动列表/详情/报名/我的活动/反馈/签到 */
export const activityService = {
getActivities(params: Record<string, any> = {}) {
return get('/activities', params)
},
getActivityDetail(activityId: string) {
return get(`/activities/${encodeURIComponent(activityId)}`)
},
register(activityId: string, payload: Record<string, any> = {}) {
return post(`/activities/${encodeURIComponent(activityId)}/register`, payload, {
idempotencyKey: idempotencyKey('activity-register', activityId),
})
},
cancelRegistration(activityId: string) {
return del(`/activities/${encodeURIComponent(activityId)}/register`)
},
getMyActivities(params: Record<string, any> = {}) {
// 后端实际路由为 GET /activities/my(原 /my-activities 无对应 handler → 404)
return get('/activities/my', params)
},
submitFeedback(activityId: string, payload: Record<string, any>) {
return post(`/activities/${encodeURIComponent(activityId)}/feedback`, payload, {
idempotencyKey: idempotencyKey('activity-feedback', activityId),
})
},
generateCheckinToken(activityId: string) {
return post(`/activities/${encodeURIComponent(activityId)}/checkin-token`, {}, {
idempotencyKey: idempotencyKey('checkin-token', activityId),
})
},
getMutualMatches(activityId: string) {
return get(`/activities/${encodeURIComponent(activityId)}/interests/mutual`)
},
checkMutualInterest(activityId: string, partnerId: string) {
return get(`/activities/${encodeURIComponent(activityId)}/interests/mutual/${encodeURIComponent(partnerId)}`)
},
}
/**
* 活动互选二阶段同意(INV-014/015):
* 互选结果 → 双方各自 confirm → 才创建 Match/沟通入口。
* 单向意向保密:结果接口只返回双相交集。
*/
export const activityMutualService = {
getResults(activityId: string) {
return get(`/activities/${encodeURIComponent(activityId)}/mutual/results`)
},
createConsent(activityId: string, partnerId: string) {
return post(`/activities/${encodeURIComponent(activityId)}/mutual/consent`, { partnerId }, {
idempotencyKey: idempotencyKey('mutual-consent', `${activityId}:${partnerId}`),
})
},
confirmConnection(activityId: string, partnerId: string) {
return post(`/activities/${encodeURIComponent(activityId)}/mutual/confirm`, { partnerId }, {
idempotencyKey: idempotencyKey('mutual-confirm', `${activityId}:${partnerId}`),
})
},
declineConnection(activityId: string, partnerId: string) {
return post(`/activities/${encodeURIComponent(activityId)}/mutual/decline`, { partnerId }, {
idempotencyKey: idempotencyKey('mutual-decline', `${activityId}:${partnerId}`),
})
},
getMyConsentStatus(activityId: string, partnerId: string) {
return get(`/activities/${encodeURIComponent(activityId)}/mutual/status/${encodeURIComponent(partnerId)}`)
},
confirmInterest(activityId: string, partnerId: string) {
return post(`/activities/${encodeURIComponent(activityId)}/interests/confirm`, { partnerId }, {
idempotencyKey: idempotencyKey('interest-confirm', `${activityId}:${partnerId}`),
})
},
}
+47
View File
@@ -0,0 +1,47 @@
import { get, post } from '../client'
import { setItem, removeItem, StorageKeys } from '@/utils/storage'
/** 用户端认证(对齐 miniapp/services/authService.js) */
export const authService = {
wechatLogin(payload: { code: string }) {
return post('/auth/wechat/login', payload, { idempotencyKey: payload.code })
},
bindPhone(payload: Record<string, any>) {
return post('/auth/phone/bind', payload, { idempotencyKey: payload.phoneCode || payload.encryptedData })
},
refresh(userId?: string | null, refreshToken?: string | null) {
return post('/auth/refresh', { userId, refreshToken })
},
logout() {
return post('/auth/logout', {}, { idempotencyKey: 'logout' })
},
}
/** 红娘/工作人员端认证(独立会话,Bearer 走 h5.operatorToken) */
export const operatorService = {
async login(username: string, password: string): Promise<any> {
const data = await post('/auth/operator/login', { username, password })
if (data?.accessToken) {
setItem(StorageKeys.operatorToken, data.accessToken)
if (data.refreshToken) setItem(StorageKeys.refreshToken + '.operator', data.refreshToken)
if (data.operatorId) setItem(StorageKeys.operatorId, data.operatorId)
if (data.name) setItem(StorageKeys.operatorName, data.name)
}
return data
},
refresh(operatorId?: string | null, refreshToken?: string | null) {
return post('/auth/operator/refresh', { operatorId, refreshToken })
},
logout() {
return post('/auth/operator/logout', {}).catch(() => {})
},
clearSession() {
removeItem(StorageKeys.operatorToken)
removeItem(StorageKeys.refreshToken + '.operator')
removeItem(StorageKeys.operatorId)
removeItem(StorageKeys.operatorName)
},
get token(): string | null {
return localStorage.getItem('h5.' + StorageKeys.operatorToken)
},
}
+14
View File
@@ -0,0 +1,14 @@
export { authService, operatorService } from './auth'
export { userService, profileService, verificationService, localEligibilityService, notificationService, serviceHistoryService } from './me'
export { recommendationService, matchService, safetyService, appealService } from './match'
export { activityService, activityMutualService } from './activity'
export {
serviceAppointmentService,
serviceStationService,
serviceProgramService,
contentService,
counselingService,
} from './service'
export { matchmakerService, operatorMatchmakerService, referralService, contactConsentService } from './matchmaker'
export { homeService, capabilityService, appConfigService } from './misc'
export { ApiError } from '../client'
+103
View File
@@ -0,0 +1,103 @@
import { get, post, del } from '../client'
import { idempotencyKey } from '../idempotency'
/** 推荐与搜索 */
export const recommendationService = {
getRecommendations(params: Record<string, any> = {}) {
return get('/recommendations', params)
},
getRecommendationDetail(userId: string) {
return get(`/recommendations/${encodeURIComponent(userId)}`)
},
discover(params: Record<string, any> = {}) {
return get('/discover', params)
},
getUserDetail(userId: string) {
return get(`/discover/users/${encodeURIComponent(userId)}`)
},
}
/** 关系与匹配(Like/Pass/Block/缘分三榜/Unmatch/成功牵手双确认) */
export const matchService = {
like(userId: string) {
return post('/likes', { targetUserId: userId }, { idempotencyKey: idempotencyKey('like', userId) })
},
unlike(userId: string) {
return del(`/likes/${encodeURIComponent(userId)}`)
},
pass(userId: string) {
return post('/passes', { targetUserId: userId }, { idempotencyKey: idempotencyKey('pass', userId) })
},
block(userId: string, payload: { reason?: string } = {}) {
return post('/blocks', { targetUserId: userId, reason: payload.reason }, { idempotencyKey: idempotencyKey('block', userId) })
},
unblock(userId: string) {
return del(`/blocks/${encodeURIComponent(userId)}`)
},
getMutualList(params: Record<string, any> = {}) {
return get('/relations/fate/mutual', params)
},
getLikedByMe(params: Record<string, any> = {}) {
return get('/relations/fate/liked', params)
},
getLikers(params: Record<string, any> = {}) {
return get('/relations/fate/likers', params)
},
getBlockedList(params: Record<string, any> = {}) {
return get('/blocks', params)
},
getMatches(params: Record<string, any> = {}) {
return get('/matches', params)
},
unmatch(matchId: string) {
return post(`/matches/${encodeURIComponent(matchId)}/unmatch`, {}, { idempotencyKey: idempotencyKey('unmatch', matchId) })
},
requestSuccess(matchId: string) {
return post(`/matches/${encodeURIComponent(matchId)}/success/request`, {}, { idempotencyKey: idempotencyKey('success-req', matchId) })
},
confirmSuccess(matchId: string) {
return post(`/matches/${encodeURIComponent(matchId)}/success/confirm`, {}, { idempotencyKey: idempotencyKey('success-confirm', matchId) })
},
declineSuccess(matchId: string) {
return post(`/matches/${encodeURIComponent(matchId)}/success/decline`, {}, { idempotencyKey: idempotencyKey('success-decline', matchId) })
},
}
/** 举报(不自动 Block) */
export const safetyService = {
submitReport(payload: { targetUserId: string; reportType: string; description: string; evidenceFileIds?: string[] }) {
// 后端真实路由 POST /risk/reports,DTO 字段为 reportedUserId / reasonType(原 /reports + targetUserId/reportType 会导致 404)
return post(
'/risk/reports',
{
reportedUserId: payload.targetUserId,
reasonType: payload.reportType,
description: payload.description,
evidenceFileIds: payload.evidenceFileIds,
},
{ idempotencyKey: idempotencyKey('report', `${payload.targetUserId}:${payload.reportType}`) },
)
},
getMyReports(params: Record<string, any> = {}) {
return get('/me/reports', params)
},
/** 举报详情(GET /risk/reports/{reportId},仅举报人本人可见):返回 { report, evidenceFileKeys, evidenceUrls } */
getReportDetail(reportId: string) {
return get(`/risk/reports/${encodeURIComponent(reportId)}`)
},
/** 举报证据直传凭证(POST /risk/reports/evidence/upload-token):返回 { fileKey, uploadUrl, expiresIn } */
requestEvidenceUploadToken() {
return post('/risk/reports/evidence/upload-token', {})
},
}
/** 申诉 */
export const appealService = {
submitAppeal(payload: Record<string, any>) {
const actionKey = payload.relatedActionId || 'no_action'
return post('/appeals', payload, { idempotencyKey: idempotencyKey('appeal', `${payload.appealType}:${actionKey}`) })
},
getMyAppeals(params: Record<string, any> = {}) {
return get('/me/appeals', params)
},
}
+127
View File
@@ -0,0 +1,127 @@
import { get, post, put } from '../client'
import { opGet, opPost } from '../operatorClient'
import { idempotencyKey } from '../idempotency'
/** 用户端红娘服务(申请/我的服务/详情/推荐) */
export const matchmakerService = {
applyService(payload: Record<string, any>) {
return post('/matchmaker/services', payload, { idempotencyKey: idempotencyKey('matchmaker-apply') })
},
getMyServices(params: Record<string, any> = {}) {
return get('/matchmaker/services', params)
},
async getServiceDetail(serviceId: string) {
// 用户端后端未注册 GET /matchmaker/services/{id}(仅列表接口)——
// 由列表按 id 派生,避免对不存在路由发起 404 请求。
const result: any = await get('/matchmaker/services', { limit: 50 })
const list: any[] = result?.items || result?.records || (Array.isArray(result) ? result : [])
return list.find((s: any) => (s.serviceId || s.id) === serviceId) ?? null
},
confirmService(serviceId: string) {
return post(`/matchmaker/services/${encodeURIComponent(serviceId)}/confirm`, {}, {
idempotencyKey: idempotencyKey('matchmaker-confirm', serviceId),
})
},
withdrawService(serviceId: string) {
return post(`/matchmaker/services/${encodeURIComponent(serviceId)}/withdraw`, {}, {
idempotencyKey: idempotencyKey('matchmaker-withdraw', serviceId),
})
},
getRecommendations(params: Record<string, any> = {}) {
return get('/matchmaker/recommendations', params)
},
markRecommendationViewed(recommendationId: string) {
return put(`/matchmaker/recommendations/${encodeURIComponent(recommendationId)}/viewed`)
},
acceptRecommendation(recommendationId: string) {
return post(`/matchmaker/recommendations/${encodeURIComponent(recommendationId)}/accept`, {}, {
idempotencyKey: idempotencyKey('mm-rec-accept', recommendationId),
})
},
passRecommendation(recommendationId: string) {
return post(`/matchmaker/recommendations/${encodeURIComponent(recommendationId)}/pass`, {}, {
idempotencyKey: idempotencyKey('mm-rec-pass', recommendationId),
})
},
}
/**
* 红娘工作台(Operator 会话)。
* 不变量:红娘只做推荐/跟进,**不可替用户 Like/Pass**(AGENTS 第 5 条)。
*/
export const operatorMatchmakerService = {
dashboard() {
return opGet('/operator/matchmaker/dashboard')
},
myAssignments() {
return opGet('/operator/matchmaker/assignments/my')
},
recommendations() {
return opGet('/operator/matchmaker/recommendations')
},
pending() {
return opGet('/operator/matchmaker/pending')
},
managementServices() {
return opGet('/operator/matchmaker/management/services')
},
serviceDetail(id: string) {
return opGet(`/operator/matchmaker/services/${encodeURIComponent(id)}`)
},
recommend(serviceId: string, payload: Record<string, any>) {
return opPost(`/operator/matchmaker/services/${encodeURIComponent(serviceId)}/recommend`, payload)
},
followup(serviceId: string, payload: Record<string, any>) {
return opPost(`/operator/matchmaker/services/${encodeURIComponent(serviceId)}/followup`, payload)
},
complete(serviceId: string) {
return opPost(`/operator/matchmaker/services/${encodeURIComponent(serviceId)}/complete`, {})
},
myContactAuthorizations() {
return opGet('/operator/matchmaker/contact-authorizations/my')
},
requestContactAuthorization(payload: Record<string, any>) {
return opPost('/operator/matchmaker/contact-authorizations', payload)
},
revokeContactAuthorization(id: string, reason: string) {
return opPost(`/operator/matchmaker/contact-authorizations/${encodeURIComponent(id)}/revoke`, { reason })
},
referralHistory() {
return opGet('/referrals/operator/history')
},
referToServiceStation(payload: Record<string, any>) {
return opPost('/referrals/service-station', payload)
},
referToActivity(payload: Record<string, any>) {
return opPost('/referrals/activity', payload)
},
}
/** 红娘转介(用户侧确认) */
export const referralService = {
getMyReferralHistory() {
return get('/referrals/my-history')
},
confirmReferral(targetId: string, targetType: 'service_station' | 'activity') {
return post(`/referrals/${encodeURIComponent(targetId)}/confirm`, { targetType }, {
idempotencyKey: idempotencyKey('referral-confirm', `${targetId}:${targetType}`),
})
},
}
/** 受控联系授权(用户单独明示同意,INV) */
export const contactConsentService = {
my() {
return get('/contact-authorizations/my')
},
consent(id: string) {
return post(`/contact-authorizations/${encodeURIComponent(id)}/consent`, {}, {
idempotencyKey: idempotencyKey('contact-consent', id),
})
},
decline(id: string) {
return post(`/contact-authorizations/${encodeURIComponent(id)}/decline`, {}, {
idempotencyKey: idempotencyKey('contact-decline', id),
})
},
}
+121
View File
@@ -0,0 +1,121 @@
import { get, post, put, patch, del } from '../client'
import { idempotencyKey } from '../idempotency'
/** 本人全局状态(GET /me/status) */
export const userService = {
getStatus() {
return get('/me/status')
},
}
/** 本人资料 / 择偶条件 / 照片 / 隐私 / 审核 / 关系状态(对齐 miniapp/services/profileService.js) */
export const profileService = {
getProfile() {
return get('/me/profile')
},
updateProfile(payload: Record<string, any>) {
return put('/me/profile', payload, { idempotencyKey: idempotencyKey('profile-put', payload?.userId || 'me') })
},
updateBasicProfile(payload: Record<string, any>) {
return patch('/me/profile/basic', payload, { idempotencyKey: idempotencyKey('profile-basic', 'me') })
},
getCompletion() {
return get('/me/profile/completion')
},
getPreferences() {
return get('/me/preferences')
},
updatePreferences(payload: Record<string, any>) {
return put('/me/preferences', payload, { idempotencyKey: idempotencyKey('preferences-put') })
},
getPhotos() {
return get('/me/photos')
},
deletePhoto(photoId: string) {
return del(`/me/photos/${encodeURIComponent(photoId)}`)
},
setMainPhoto(photoId: string) {
return put(`/me/photos/${encodeURIComponent(photoId)}/main`, {}, { idempotencyKey: idempotencyKey('photo-main', photoId) })
},
requestUploadToken(payload: { fileName: string; contentType?: string }) {
return post('/me/photos/upload-token', payload, { idempotencyKey: payload.fileName })
},
confirmPhoto(payload: { fileKey: string; photoType: string }) {
return post('/me/photos/confirm', payload, { idempotencyKey: idempotencyKey('photo-confirm', payload.fileKey) })
},
getPrivacySettings() {
return get('/me/privacy-settings')
},
updatePrivacySettings(payload: Record<string, any>) {
return put('/me/privacy-settings', payload, { idempotencyKey: idempotencyKey('privacy-put') })
},
getReviewStatus() {
return get('/me/profile/review-status')
},
submitReview() {
return post('/me/profile/submit-review', {}, { idempotencyKey: idempotencyKey('review-submit') })
},
getRelationshipStatus() {
return get('/me/relationship-status')
},
pauseSearching() {
return patch('/me/relationship-status', { status: 'paused' }, { idempotencyKey: idempotencyKey('rel-pause') })
},
resumeSearching() {
return patch('/me/relationship-status', { status: 'searching' }, { idempotencyKey: idempotencyKey('rel-resume') })
},
markGettingToKnow() {
return patch('/me/relationship-status', { status: 'getting_to_know' }, { idempotencyKey: idempotencyKey('rel-gtk') })
},
}
/** 实名认证 */
export const verificationService = {
submit(payload: { realName: string; idCardNumber: string; faceImageUrl?: string }) {
const tail = (payload.idCardNumber || '').slice(-4).toUpperCase()
return post('/verification/submit', payload, { idempotencyKey: idempotencyKey('verification-submit', tail) })
},
getStatus() {
return get('/verification/status')
},
}
/** 本地资格核验 */
export const localEligibilityService = {
getStatus() {
return get('/local-eligibility')
},
declare(payload: Record<string, any>) {
return post('/local-eligibility/declaration', payload)
},
uploadEvidence(payload: Record<string, any>) {
return post('/local-eligibility/evidences', payload)
},
submit() {
return post('/local-eligibility/submit', {}, { idempotencyKey: idempotencyKey('local-eligibility-submit') })
},
}
/** 通知(用户端不提供聊天,仅通知) */
export const notificationService = {
getNotifications(params: Record<string, any> = {}) {
return get('/notifications', params)
},
markNotificationRead(id: string) {
return put(`/notifications/${encodeURIComponent(id)}/read`)
},
markAllNotificationsRead() {
return put('/notifications/read-all')
},
getNotificationsUnreadCount() {
// 后端实际路由为 GET /notifications/unread(返回 {count})
return get('/notifications/unread')
},
}
/** 公益服务记录(我的服务时间线) */
export const serviceHistoryService = {
getMyServiceHistory() {
return get('/me/service-history')
},
}
+27
View File
@@ -0,0 +1,27 @@
import { get } from '../client'
/** 首页聚合(GET /home)—— 段序与 8 入口顺序由后端返回决定(AGENTS 第 14/15 条)。 */
export const homeService = {
getHome() {
return get('/home')
},
}
/** 能力开关(AGENTS 第 26 条:入口可见性只读服务端) */
export const capabilityService = {
getCapabilities() {
return get('/capabilities')
},
}
/** App 运行时配置(数量/阈值等,未确认参数一律配置化) */
export const appConfigService = {
async fetchAppConfig(fallback: Record<string, any> = {}) {
try {
const overrides = await get('/app/config')
return { ...fallback, ...(overrides || {}) }
} catch {
return fallback
}
},
}
+104
View File
@@ -0,0 +1,104 @@
import { get, post } from '../client'
import { idempotencyKey } from '../idempotency'
/** 服务预约 */
export const serviceAppointmentService = {
async getCurrentServiceLocation() {
try {
const home: any = await get('/home')
const location = home?.serviceLocation
if (!location?.id) return null
return { id: location.id, name: location.name || '已选服务站点' }
} catch {
return null
}
},
getMyAppointments() {
return get('/service-appointments')
},
getAppointmentDetail(id: string) {
return get(`/service-appointments/${encodeURIComponent(id)}`)
},
createAppointment(payload: Record<string, any>) {
return post('/service-appointments', {
serviceLocationId: payload.serviceLocationId,
serviceType: payload.serviceType,
preferredDate: payload.preferredDate,
preferredTimeSlot: payload.preferredTimeSlot,
userNote: payload.userNote,
})
},
cancelAppointment(id: string, reason?: string) {
return post(`/service-appointments/${encodeURIComponent(id)}/cancel`, { reason: reason || '' })
},
}
/** 善缘服务站 */
export const serviceStationService = {
async getStation() {
const home: any = await get('/home')
return home?.serviceLocation || null
},
async getVisibleAnnouncements(locationId: string) {
if (!locationId) return []
try {
const list = await get(`/service-locations/${encodeURIComponent(locationId)}/announcements`)
return Array.isArray(list) ? list : []
} catch {
return []
}
},
}
/** 专项公益服务项目 */
export const serviceProgramService = {
getPrograms(params: Record<string, any> = {}) {
return get('/service-programs', params)
},
getProgramDetail(id: string) {
return get(`/service-programs/${encodeURIComponent(id)}`)
},
applyToProgram(id: string) {
return post(`/service-programs/${encodeURIComponent(id)}/apply`, {}, { idempotencyKey: idempotencyKey('program-apply', id) })
},
getMyApplications() {
return get('/service-programs/my')
},
}
/** 公益内容(婚恋课堂 / 反诈) */
export const contentService = {
getCategories() {
return get('/content/categories')
},
getArticles(params: Record<string, any> = {}) {
return get('/content/articles', params)
},
getArticleDetail(id: string) {
return get(`/content/articles/${encodeURIComponent(id)}`)
},
}
/** 婚恋咨询 / 婚前辅导 */
export const counselingService = {
intro() {
return get('/counseling/services/intro')
},
apply(payload: Record<string, any>) {
return post('/counseling/services', payload, { idempotencyKey: idempotencyKey('counseling-apply', payload.serviceType) })
},
myServices(limit = 20) {
return get('/counseling/services', { limit })
},
detail(id: string) {
return get(`/counseling/services/${encodeURIComponent(id)}`)
},
cancel(id: string, reason?: string) {
return post(`/counseling/services/${encodeURIComponent(id)}/cancel`, { reason: reason || '' })
},
confirmCompanion(id: string) {
return post(`/counseling/services/${encodeURIComponent(id)}/companion-confirm`, {}, {
idempotencyKey: idempotencyKey('companion-confirm', id),
})
},
}
+52
View File
@@ -0,0 +1,52 @@
<template>
<div class="ui-avatar" :style="{ width: size + 'px', height: size + 'px', fontSize: size * 0.4 + 'px' }">
<img v-if="src" :src="src" alt="" class="ui-avatar__img" @error="onError" />
<span v-else class="ui-avatar__text">{{ initial }}</span>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
const props = withDefaults(defineProps<{ src?: string | null; name?: string | null; size?: number }>(), {
src: '',
name: '',
size: 48,
})
const broken = ref(false)
watch(
() => props.src,
() => {
broken.value = false
},
)
const initial = computed(() => (props.name ? props.name.slice(0, 1) : '缘'))
const src = computed(() => (props.src && !broken.value ? props.src : ''))
function onError() {
broken.value = true
}
</script>
<style scoped>
.ui-avatar {
border-radius: 50%;
overflow: hidden;
background: var(--mini-brand-gradient);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
flex: none;
}
.ui-avatar__img {
width: 100%;
height: 100%;
object-fit: cover;
}
.ui-avatar__text {
font-weight: 500;
}
</style>
+12
View File
@@ -0,0 +1,12 @@
<template>
<van-empty :image="image" :description="description">
<slot />
</van-empty>
</template>
<script setup lang="ts">
withDefaults(defineProps<{ description?: string; image?: string }>(), {
description: '暂无数据',
image: 'default',
})
</script>
+26
View File
@@ -0,0 +1,26 @@
<template>
<div class="ui-error">
<van-icon name="warning-o" size="40" color="#f0483e" />
<p class="ui-error__text">{{ message }}</p>
<van-button size="small" type="primary" @click="$emit('retry')">重试</van-button>
</div>
</template>
<script setup lang="ts">
withDefaults(defineProps<{ message?: string }>(), { message: '加载失败,请稍后重试' })
defineEmits<{ retry: [] }>()
</script>
<style scoped>
.ui-error {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 48px 16px;
}
.ui-error__text {
color: var(--mini-text-secondary);
margin: 0;
}
</style>
+18
View File
@@ -0,0 +1,18 @@
<template>
<div class="ui-loading">
<van-loading size="22" :vertical="vertical">{{ text }}</van-loading>
</div>
</template>
<script setup lang="ts">
withDefaults(defineProps<{ text?: string; vertical?: boolean }>(), { text: '加载中...', vertical: true })
</script>
<style scoped>
.ui-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 0;
}
</style>
@@ -0,0 +1,32 @@
<template>
<div class="ui-section-header">
<h3 class="ui-section-header__title">{{ title }}</h3>
<span v-if="moreText" class="ui-section-header__more" @click="$emit('more')">
{{ moreText }} <van-icon name="arrow" />
</span>
</div>
</template>
<script setup lang="ts">
defineProps<{ title: string; moreText?: string }>()
defineEmits<{ more: [] }>()
</script>
<style scoped>
.ui-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin: 16px 0 8px;
}
.ui-section-header__title {
font-size: 16px;
font-weight: 600;
color: var(--mini-text-strong);
margin: 0;
}
.ui-section-header__more {
font-size: 12px;
color: var(--mini-text-secondary);
}
</style>
+39
View File
@@ -0,0 +1,39 @@
<template>
<span class="ui-tag" :class="`ui-tag--${type}`"><slot /></span>
</template>
<script setup lang="ts">
withDefaults(defineProps<{ type?: 'primary' | 'success' | 'warning' | 'danger' | 'info' }>(), {
type: 'info',
})
</script>
<style scoped>
.ui-tag {
display: inline-block;
padding: 1px 8px;
border-radius: 10px;
font-size: 11px;
line-height: 18px;
}
.ui-tag--primary {
background: rgba(244, 0, 90, 0.08);
color: var(--mini-tabbar-active);
}
.ui-tag--success {
background: rgba(47, 179, 109, 0.12);
color: var(--mini-success);
}
.ui-tag--warning {
background: rgba(245, 166, 35, 0.14);
color: #b9770e;
}
.ui-tag--danger {
background: rgba(240, 72, 62, 0.12);
color: var(--mini-danger);
}
.ui-tag--info {
background: rgba(107, 112, 133, 0.1);
color: var(--mini-text-secondary);
}
</style>
+81
View File
@@ -0,0 +1,81 @@
<template>
<div class="ui-user-card" @click="$emit('tap', user)">
<UiAvatar :src="user.avatarUrl" :name="user.displayName" :size="56" />
<div class="ui-user-card__main">
<div class="ui-user-card__title">
<span class="ui-user-card__name">{{ user.displayName || '未设置昵称' }}</span>
<UiTag v-if="user.verified" type="success">已实名</UiTag>
</div>
<div class="ui-user-card__meta">
<span v-if="user.age">{{ user.age }}岁</span>
<span v-if="user.heightCm">{{ user.heightCm }}cm</span>
<span v-if="user.regionName">{{ user.regionName }}</span>
</div>
<div v-if="user.recommendationReasons?.length" class="ui-user-card__reasons">
{{ user.recommendationReasons.slice(0, 3).join(' · ') }}
</div>
</div>
<div v-if="showActions" class="ui-user-card__actions">
<van-button size="mini" type="primary" :loading="loading" @click.stop="$emit('like', user)">喜欢</van-button>
<van-button size="mini" plain @click.stop="$emit('pass', user)">略过</van-button>
</div>
</div>
</template>
<script setup lang="ts">
import UiAvatar from './UiAvatar.vue'
import UiTag from './UiTag.vue'
const props = withDefaults(
defineProps<{ user: any; showActions?: boolean; loading?: boolean }>(),
{ showActions: false, loading: false },
)
defineEmits<{ tap: [any]; like: [any]; pass: [any] }>()
</script>
<style scoped>
.ui-user-card {
display: flex;
align-items: center;
gap: 12px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 12px;
margin-bottom: 10px;
box-shadow: var(--mini-shadow);
}
.ui-user-card__main {
flex: 1;
min-width: 0;
}
.ui-user-card__title {
display: flex;
align-items: center;
gap: 6px;
}
.ui-user-card__name {
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
}
.ui-user-card__meta {
display: flex;
gap: 10px;
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 4px;
}
.ui-user-card__reasons {
font-size: 12px;
color: var(--mini-tabbar-active);
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ui-user-card__actions {
display: flex;
flex-direction: column;
gap: 6px;
}
</style>
+56
View File
@@ -0,0 +1,56 @@
<template>
<div class="operator-layout">
<van-nav-bar
:title="title"
left-arrow
fixed
placeholder
@click-left="onBack"
>
<template #right>
<span class="operator-layout__logout" @click="onLogout">退出</span>
</template>
</van-nav-bar>
<div class="operator-layout__body">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { showConfirmDialog } from 'vant'
import { useOperatorStore } from '@/stores/operator'
withDefaults(defineProps<{ title?: string }>(), { title: '红娘工作台' })
const router = useRouter()
const operatorStore = useOperatorStore()
function onBack() {
router.replace('/operator/workbench')
}
async function onLogout() {
try {
await showConfirmDialog({ title: '退出登录', message: '确定要退出红娘工作台吗?' })
} catch {
return
}
operatorStore.logout()
router.replace('/operator/login')
}
</script>
<style scoped>
.operator-layout {
min-height: 100vh;
background: var(--mini-bg);
}
.operator-layout__body {
min-height: calc(100vh - 46px);
}
.operator-layout__logout {
color: var(--mini-text-secondary);
font-size: 13px;
}
</style>
+40
View File
@@ -0,0 +1,40 @@
<template>
<div class="page-layout">
<van-nav-bar
:title="title"
left-arrow
fixed
placeholder
@click-left="onBack"
>
<template v-if="$slots.right" #right>
<slot name="right" />
</template>
</van-nav-bar>
<div class="page-layout__body">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
withDefaults(defineProps<{ title?: string }>(), { title: '' })
const router = useRouter()
function onBack() {
if (window.history.length > 1) router.back()
else router.replace('/home')
}
</script>
<style scoped>
.page-layout {
min-height: 100vh;
background: var(--mini-bg);
}
.page-layout__body {
min-height: calc(100vh - 46px);
}
</style>
+35
View File
@@ -0,0 +1,35 @@
<template>
<div class="tab-layout">
<div class="tab-layout__body">
<router-view v-slot="{ Component }">
<keep-alive :include="cached">
<component :is="Component" />
</keep-alive>
</router-view>
</div>
<van-tabbar route fixed placeholder class="tab-layout__bar">
<van-tabbar-item to="/home" icon="wap-home-o">首页</van-tabbar-item>
<van-tabbar-item to="/discover" icon="friends-o">找对象</van-tabbar-item>
<van-tabbar-item to="/activity" icon="flag-o">活动</van-tabbar-item>
<van-tabbar-item to="/mine" icon="user-o">我的</van-tabbar-item>
</van-tabbar>
</div>
</template>
<script setup lang="ts">
// 固定 4 项 TabBar,顺序与 miniapp/app.json 一致。
const cached = ['HomePage', 'DiscoverPage', 'ActivityPage', 'MinePage']
</script>
<style scoped>
.tab-layout {
min-height: 100vh;
background: var(--mini-bg);
}
.tab-layout__body {
padding-bottom: 60px;
}
.tab-layout__bar {
border-top: 1px solid var(--mini-border);
}
</style>
+35
View File
@@ -0,0 +1,35 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import Vant from 'vant'
import 'vant/lib/index.css'
import './theme/index.css'
import App from './App.vue'
import router from './router'
import { setOnSessionExpired, setOnMaintenance } from './api/client'
import { useAuthStore } from './stores/auth'
import { useUserStore } from './stores/user'
import { useAppStore } from './stores/app'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(Vant)
app.use(router)
// 会话失效:清空内存与本地登录态后回登录页(由 api client 的 401 分支触发)。
setOnSessionExpired(() => {
const authStore = useAuthStore(pinia)
const userStore = useUserStore(pinia)
authStore.clearSession()
userStore.userState = null
router.replace('/login')
})
// 维护态:统一提示。
setOnMaintenance((body) => {
useAppStore(pinia).setMaintenance(body)
})
app.mount('#app')
+19
View File
@@ -0,0 +1,19 @@
<template>
<div class="nf">
<van-empty image="error" description="页面不存在或已下线">
<van-button type="primary" round @click="$router.replace('/home')">返回首页</van-button>
</van-empty>
</div>
</template>
<script setup lang="ts"></script>
<style scoped>
.nf {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--mini-bg);
}
</style>
@@ -0,0 +1,116 @@
<template>
<PageLayout title="退出登录">
<div class="logout">
<div class="logout__banner">
<div class="logout__banner-icon">!</div>
<div class="logout__banner-title">确认退出登录</div>
</div>
<div class="logout__card">
<div class="logout__section-title">退出后:</div>
<ul class="logout__list">
<li>需要重新登录才能继续使用推荐与公益服务</li>
<li>已拉黑、已匹配等关系数据仍会保留</li>
<li>个人资料不会被删除,再次登录即可恢复</li>
</ul>
</div>
<van-button class="logout__btn" type="danger" block round :loading="submitting" @click="onConfirm">
退出登录
</van-button>
<p class="logout__hint">平台不会要求您打电话或邮寄身份证,退出全程在线完成</p>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { showConfirmDialog } from 'vant'
import { useAuthStore } from '@/stores/auth'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
const router = useRouter()
const authStore = useAuthStore()
const appStore = useAppStore()
const submitting = ref(false)
async function onConfirm() {
try {
await showConfirmDialog({ title: '退出登录', message: '确定要退出当前账号吗?' })
} catch {
return
}
submitting.value = true
try {
await authStore.logout()
appStore.toastSuccess('已退出登录')
router.replace('/login?clear=1')
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.logout {
padding: 16px;
}
.logout__banner {
display: flex;
align-items: center;
gap: 10px;
background: rgba(240, 72, 62, 0.08);
border: 1px solid rgba(240, 72, 62, 0.16);
border-radius: var(--mini-radius);
padding: 14px;
}
.logout__banner-icon {
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(240, 72, 62, 0.14);
color: var(--mini-danger);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
flex: none;
}
.logout__banner-title {
font-size: 15px;
font-weight: 600;
color: var(--mini-danger);
}
.logout__card {
margin-top: 14px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 14px 16px;
}
.logout__section-title {
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
}
.logout__list {
margin: 10px 0 0;
padding-left: 18px;
font-size: 13px;
line-height: 1.9;
color: var(--mini-text-secondary);
}
.logout__btn {
margin-top: 24px;
}
.logout__hint {
text-align: center;
font-size: 11px;
color: var(--mini-text-muted);
margin: 12px 0 0;
line-height: 1.6;
}
</style>
@@ -0,0 +1,128 @@
<template>
<PageLayout title="状态明细">
<div class="detail">
<UiLoading v-if="loading" />
<UiErrorState v-else-if="error" @retry="load" />
<template v-else>
<UiSectionHeader title="账号与实名" />
<van-cell-group inset>
<van-cell title="账号状态" :value="accountStatusText" />
<van-cell title="实名认证" :value="verified ? '已实名' : '未实名'" />
<van-cell title="手机号" :value="phoneBound ? '已绑定' : '未绑定'" />
</van-cell-group>
<UiSectionHeader title="资料与本地资格" />
<van-cell-group inset>
<van-cell title="资料状态" :value="reviewStatusText" />
<van-cell title="本地资格" :value="localEligibilityText" />
</van-cell-group>
<UiSectionHeader title="婚恋状态" />
<van-cell-group inset>
<van-cell title="关系状态" :value="relationshipText" />
<van-cell title="推荐开关" :value="recommendationEnabled ? '已开启' : '已关闭'" />
<van-cell title="正在了解的对象" :value="matchingWith ? (matchingWith.displayName || '了解中') : '无'" />
</van-cell-group>
<UiSectionHeader title="相关设置" />
<van-cell-group inset>
<van-cell title="隐私设置" is-link @click="router.push('/settings/privacy')" />
<van-cell title="关系状态" is-link @click="router.push('/settings/relationship')" />
<van-cell title="退出登录" is-link @click="router.push('/account/logout')" />
</van-cell-group>
</template>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { profileService, userService } from '@/api/services'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiErrorState from '@/components/UiErrorState.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
const router = useRouter()
const ACCOUNT_TEXT: Record<string, string> = {
normal: '正常',
banned: '已封禁',
suspended: '临时暂停',
limited: '功能限制',
}
const REVIEW_TEXT: Record<string, string> = {
not_submitted: '未提交',
pending_review: '审核中',
approved: '已审核',
need_edit: '需修改',
rejected: '未通过',
}
const RELATIONSHIP_TEXT: Record<string, string> = {
searching: '正在寻找',
getting_to_know: '正在了解',
paused: '已暂停寻找',
matched_successfully: '已成功牵手',
service_exited: '已退出服务',
}
const LOCAL_ELIGIBILITY_TEXT: Record<string, string> = {
not_declared: '未声明',
pending: '待审核',
verifying: '核验中',
verified: '已核验',
needs_evidence: '需补充佐证',
rejected: '未通过',
}
const loading = ref(true)
const error = ref(false)
const status = ref<any>(null)
const relationship = ref<any>(null)
const accountStatusText = computed(() => ACCOUNT_TEXT[status.value?.accountStatus] || status.value?.accountStatus || '正常')
const verified = computed(() => !!status.value?.verified)
const phoneBound = computed(() => !!status.value?.phoneBound)
const reviewStatusText = computed(() => REVIEW_TEXT[status.value?.reviewStatus || status.value?.profileReviewStatus] || '未提交')
const localEligibilityText = computed(
() => LOCAL_ELIGIBILITY_TEXT[status.value?.localEligibilityStatus] || status.value?.localEligibilityStatus || '未声明',
)
const relationshipText = computed(
() => RELATIONSHIP_TEXT[relationship.value?.status || status.value?.relationshipStatus] || '—',
)
const recommendationEnabled = computed(() => (relationship.value ? relationship.value.recommendationEnabled !== false : status.value?.recommendationEnabled !== false))
const matchingWith = computed(() => relationship.value?.matchingWith || null)
async function load() {
loading.value = true
error.value = false
try {
status.value = await userService.getStatus()
} catch {
error.value = true
loading.value = false
return
}
try {
relationship.value = await profileService.getRelationshipStatus()
} catch {
relationship.value = null
}
loading.value = false
}
onMounted(load)
</script>
<style scoped>
.detail {
padding-bottom: 24px;
}
.detail :deep(.ui-section-header) {
margin-left: 16px;
margin-right: 16px;
}
.detail :deep(.van-cell-group--inset) {
margin: 0 16px;
}
</style>
@@ -0,0 +1,164 @@
<template>
<PageLayout title="账号状态">
<div class="acc">
<UiLoading v-if="loading" />
<UiErrorState v-else-if="error" @retry="load" />
<template v-else>
<!-- 正常 -->
<div v-if="!hasRestriction" class="acc__normal">
<div class="acc__normal-icon">✓</div>
<div class="acc__normal-title">账号状态正常</div>
<p class="acc__normal-hint">您的账号目前无限制,可以正常使用所有功能。</p>
</div>
<!-- 受限 -->
<template v-else>
<div class="acc__banner">
<div class="acc__banner-icon">!</div>
<div class="acc__banner-title">账号受限</div>
<div class="acc__banner-status">{{ statusText }}</div>
</div>
<p class="acc__banner-desc">
如认为处置有误,可提交申诉,工作人员将在 1-3 个工作日内审核。
</p>
<van-button type="primary" block round @click="goAppeal">提交申诉</van-button>
</template>
<van-cell-group inset class="acc__links">
<van-cell title="状态明细" is-link @click="router.push('/account/status/detail')" />
<van-cell title="帮助中心" is-link @click="router.push('/help')" />
</van-cell-group>
<p class="acc__free">本平台为公益服务,所有功能永久免费</p>
</template>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { userService } from '@/api/services'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const router = useRouter()
const STATUS_TEXT: Record<string, string> = {
normal: '正常',
banned: '已封禁',
suspended: '临时暂停',
limited: '功能限制',
}
const loading = ref(true)
const error = ref(false)
const status = ref<any>(null)
const accountStatus = computed(() => status.value?.accountStatus || 'normal')
const hasRestriction = computed(() => accountStatus.value !== 'normal')
const statusText = computed(() => STATUS_TEXT[accountStatus.value] || accountStatus.value)
const appealType = computed(() => (accountStatus.value === 'banned' ? 'ban_appeal' : 'suspend_appeal'))
async function load() {
loading.value = true
error.value = false
try {
status.value = await userService.getStatus()
} catch {
error.value = true
} finally {
loading.value = false
}
}
function goAppeal() {
router.push({ path: '/appeal', query: { type: appealType.value } })
}
onMounted(load)
</script>
<style scoped>
.acc {
padding: 16px;
}
.acc__normal,
.acc__banner {
text-align: center;
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 24px 16px;
}
.acc__normal-icon {
width: 48px;
height: 48px;
margin: 0 auto 10px;
border-radius: 50%;
background: rgba(47, 179, 109, 0.14);
color: var(--mini-success);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
.acc__normal-title {
font-size: 16px;
font-weight: 600;
color: var(--mini-text-strong);
}
.acc__normal-hint {
margin: 8px 0 0;
font-size: 13px;
color: var(--mini-text-secondary);
line-height: 1.7;
}
.acc__banner-icon {
width: 48px;
height: 48px;
margin: 0 auto 10px;
border-radius: 50%;
background: rgba(240, 72, 62, 0.12);
color: var(--mini-danger);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: 700;
}
.acc__banner-title {
font-size: 16px;
font-weight: 600;
color: var(--mini-text-strong);
}
.acc__banner-status {
margin-top: 6px;
display: inline-block;
font-size: 12px;
padding: 2px 10px;
border-radius: 10px;
background: rgba(240, 72, 62, 0.12);
color: var(--mini-danger);
}
.acc__banner-desc {
margin: 12px 0 16px;
font-size: 13px;
color: var(--mini-text-secondary);
line-height: 1.7;
}
.acc__links {
margin-top: 16px;
}
.acc :deep(.van-cell-group--inset) {
margin-left: 0;
margin-right: 0;
}
.acc__free {
text-align: center;
font-size: 11px;
color: var(--mini-text-muted);
margin: 20px 0 0;
}
</style>
@@ -0,0 +1,181 @@
<template>
<PageLayout title="活动签到">
<UiLoading v-if="loading" text="生成签到码中…" />
<UiErrorState v-else-if="error" :message="errorMessage" @retry="load" />
<template v-else>
<div class="ck">
<div class="ck__card">
<div class="ck__activity">
<div class="ck__title">{{ token.activityTitle || '活动签到' }}</div>
<div v-if="token.locationName" class="ck__location">📍 {{ token.locationName }}</div>
</div>
<div class="ck__qr">
<div class="ck__qr-icon">▣</div>
<div class="ck__qr-hint">请向工作人员展示此签到码</div>
</div>
<div class="ck__token">
<div class="ck__token-label">签到码</div>
<div class="ck__token-value">{{ token.token }}</div>
</div>
<div class="ck__expire">有效期至 {{ formatDateTime(token.expiresAt) || '—' }}</div>
</div>
<van-button type="primary" block round @click="copyToken">复制签到码</van-button>
<van-button class="ck__refresh" plain block round :loading="refreshing" @click="refresh">
刷新签到码
</van-button>
</div>
</template>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { activityService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import { formatDateTime } from '@/utils/datetime'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const route = useRoute()
const appStore = useAppStore()
const activityId = computed(() => String(route.query.id || ''))
const token = ref<Record<string, any>>({})
const loading = ref(true)
const refreshing = ref(false)
const error = ref(false)
const errorMessage = ref('生成签到码失败')
async function fetchToken(isRefresh: boolean) {
if (isRefresh) refreshing.value = true
else loading.value = true
error.value = false
try {
if (!activityId.value) {
error.value = true
errorMessage.value = '缺少活动信息'
return
}
token.value = (await activityService.generateCheckinToken(activityId.value)) || {}
} catch (err: any) {
error.value = true
errorMessage.value = err?.message || '生成签到码失败'
if (isRefresh) appStore.toastError(errorMessage.value)
} finally {
loading.value = false
refreshing.value = false
}
}
function load() {
fetchToken(false)
}
function refresh() {
fetchToken(true)
}
async function copyToken() {
const value = token.value?.token
if (!value) return
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value)
} else {
const el = document.createElement('textarea')
el.value = value
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
}
appStore.toastSuccess('签到码已复制')
} catch {
appStore.toastError('复制失败,请长按手动复制')
}
}
onMounted(load)
</script>
<style scoped>
.ck {
padding: 16px 16px 24px;
}
.ck__card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 18px;
box-shadow: var(--mini-shadow);
margin-bottom: 16px;
}
.ck__activity {
text-align: center;
margin-bottom: 16px;
}
.ck__title {
font-size: 16px;
font-weight: 600;
color: var(--mini-text-strong);
}
.ck__location {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 4px;
}
.ck__qr {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 12px 0;
}
.ck__qr-icon {
width: 140px;
height: 140px;
border-radius: 12px;
background: var(--mini-bg);
display: flex;
align-items: center;
justify-content: center;
font-size: 64px;
color: var(--mini-text-muted);
}
.ck__qr-hint {
font-size: 12px;
color: var(--mini-text-secondary);
}
.ck__token {
text-align: center;
padding: 12px;
background: var(--mini-bg);
border-radius: var(--mini-radius);
}
.ck__token-label {
font-size: 12px;
color: var(--mini-text-secondary);
}
.ck__token-value {
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
word-break: break-all;
margin-top: 6px;
-webkit-user-select: all;
user-select: all;
}
.ck__expire {
text-align: center;
font-size: 12px;
color: var(--mini-text-muted);
margin-top: 12px;
}
.ck__refresh {
margin-top: 10px;
}
</style>
@@ -0,0 +1,385 @@
<template>
<PageLayout title="活动详情">
<UiLoading v-if="loading" text="正在加载活动…" />
<UiErrorState v-else-if="error" message="活动信息加载失败" @retry="load" />
<UiEmpty v-else-if="!activity" description="该活动不存在或已下线" />
<template v-else>
<div class="ad">
<div class="ad__hero">
<div class="ad__badge">{{ typeLabel }}</div>
<div class="ad__title">{{ activity.title }}</div>
<div class="ad__slogan">真实相识 · 公益免费 · 尊重意愿</div>
</div>
<div class="ad__block">
<UiSectionHeader title="活动信息" />
<div class="ad__card">
<div v-if="activity.activityId" class="ad__row">
<span class="ad__label">活动编号</span>
<span class="ad__value">{{ activity.activityId }}</span>
</div>
<div class="ad__feature">
<div class="ad__icon">时</div>
<div class="ad__feature-copy">
<div class="ad__feature-title">{{ formatDateTime(activity.startAt) || '时间待定' }}</div>
<div class="ad__feature-desc">请提前 15 分钟到场签到</div>
</div>
</div>
<div class="ad__feature">
<div class="ad__icon">址</div>
<div class="ad__feature-copy">
<div class="ad__feature-title">{{ activity.locationName || '地点待定' }}</div>
<div class="ad__feature-desc">
{{ activity.locationAddress || '报名通过后显示详细地址' }}
</div>
</div>
</div>
<div class="ad__feature">
<div class="ad__icon">额</div>
<div class="ad__feature-copy">
<div class="ad__feature-title">{{ quotaText }}</div>
<div class="ad__feature-desc">报名提交后需等待工作人员审核</div>
</div>
<UiTag type="primary">公益免费</UiTag>
</div>
</div>
</div>
<div v-if="activity.description" class="ad__block">
<UiSectionHeader title="活动介绍" />
<div class="ad__card">
<p class="ad__body">{{ activity.description }}</p>
</div>
</div>
<div class="ad__block">
<UiSectionHeader title="报名须知" />
<div class="ad__card">
<div v-for="(r, i) in requirements" :key="i" class="ad__rule">
<span class="ad__rule-index">{{ i + 1 }}</span>
<span class="ad__rule-text">{{ r }}</span>
</div>
<div class="ad__notice">
不同活动类型的资格要求可能不同,最终以本活动页面和审核结果为准。
</div>
</div>
</div>
<div class="ad__block">
<UiSectionHeader title="主办信息" />
<div class="ad__card">
<div class="ad__feature-title">善缘丹阳公益婚恋服务平台</div>
<div class="ad__feature-desc">主办单位关系不会自动授予用户资料访问权限。</div>
</div>
</div>
</div>
<div class="ad__action">
<template v-if="!isRegistered">
<van-button
type="primary"
block
round
:disabled="!!activity.isFull"
@click="goRegister"
>
{{ activity.isFull ? '名额已满' : '报名参加活动' }}
</van-button>
</template>
<template v-else>
<div class="ad__action-status">
<UiTag :type="statusTag.type">{{ statusTag.text }}</UiTag>
<span class="ad__action-hint">{{ checkinText }}</span>
</div>
<div class="ad__action-btns">
<van-button size="small" plain @click="goMyActivities">我的活动</van-button>
<van-button v-if="canCheckin" size="small" type="primary" @click="goCheckin">签到</van-button>
<van-button size="small" plain type="primary" @click="goFeedback">活动反馈</van-button>
<van-button
v-if="canCancel"
size="small"
plain
type="danger"
:loading="cancelling"
@click="onCancel"
>
取消报名
</van-button>
</div>
</template>
</div>
</template>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { activityService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import { showConfirmDialog } from 'vant'
import { formatDateTime } from '@/utils/datetime'
import PageLayout from '@/layouts/PageLayout.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
import UiTag from '@/components/UiTag.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const activityId = computed(() => String(route.query.id || ''))
const activity = ref<any>(null)
const loading = ref(true)
const error = ref(false)
const cancelling = ref(false)
const TYPE_LABELS: Record<string, string> = {
matchmaking: '公益联谊',
interest_social: '兴趣社交',
relationship_education: '婚恋课堂',
premarital_guidance: '婚前辅导',
marriage_custom_culture: '婚俗文化',
anti_fraud: '反诈宣传',
}
const typeLabel = computed(
() => TYPE_LABELS[activity.value?.activityType] || activity.value?.typeLabel || '公益活动',
)
const registrationStatus = computed(() => String(activity.value?.registrationStatus || ''))
const isRegistered = computed(
() => !!activity.value?.registered || ['pending', 'approved'].includes(registrationStatus.value),
)
const checkinStatus = computed(() => String(activity.value?.checkinStatus || ''))
const canCheckin = computed(
() => registrationStatus.value === 'approved' && checkinStatus.value !== 'checked_in',
)
const canCancel = computed(() => ['pending', 'approved'].includes(registrationStatus.value))
const statusTag = computed<{ type: 'primary' | 'success' | 'warning' | 'danger' | 'info'; text: string }>(
() => {
switch (registrationStatus.value) {
case 'pending':
return { type: 'warning', text: '报名待审核' }
case 'approved':
return { type: 'success', text: '报名已通过' }
case 'rejected':
return { type: 'danger', text: '报名未通过' }
case 'cancelled':
return { type: 'info', text: '已取消报名' }
default:
return { type: 'primary', text: '已报名' }
}
},
)
const checkinText = computed(() => {
if (checkinStatus.value === 'checked_in') return '已签到'
if (registrationStatus.value === 'approved') return '待签到'
return ''
})
const quotaText = computed(() => {
const a = activity.value || {}
if (typeof a.remainingSeats === 'number') return `剩余 ${a.remainingSeats} 个名额`
if (a.quotaText) return a.quotaText
return '名额以审核结果为准'
})
const requirements = computed(() => {
const a = activity.value || {}
const el = a.eligibility || {}
const list: string[] = ['年满 18 周岁的成年单身人士']
if (el.requiresIdentityVerified) list.push('需完成实名认证并通过资料审核')
if (el.requiresLocalEligibility) list.push('需符合丹阳本地资格(户籍 / 工作 / 居住)')
if (a.minAge || a.maxAge) {
list.push(`年龄在 ${a.minAge ?? '不限'}-${a.maxAge ?? '不限'} 周岁之间`)
}
if (a.needReview !== false) list.push('报名提交后需经工作人员审核通过')
return list
})
async function load() {
loading.value = true
error.value = false
try {
if (!activityId.value) {
activity.value = null
return
}
activity.value = await activityService.getActivityDetail(activityId.value)
} catch (err: any) {
error.value = true
appStore.toastError(err?.message || '加载活动失败')
} finally {
loading.value = false
}
}
function goRegister() {
if (!activityId.value) return
router.push({ path: '/activity/register', query: { id: activityId.value } })
}
function goMyActivities() {
router.push('/my/activities')
}
function goCheckin() {
router.push({ path: '/activity/checkin', query: { id: activityId.value } })
}
function goFeedback() {
router.push({ path: '/activity/feedback', query: { id: activityId.value } })
}
async function onCancel() {
if (cancelling.value) return
try {
await showConfirmDialog({ title: '取消报名', message: '确定要取消本次活动报名吗?' })
} catch {
return
}
cancelling.value = true
try {
await activityService.cancelRegistration(activityId.value)
appStore.toastSuccess('已取消报名')
await load()
} catch (err: any) {
appStore.toastError(err?.message || '取消失败')
} finally {
cancelling.value = false
}
}
onMounted(load)
</script>
<style scoped>
.ad {
padding: 0 16px 120px;
}
.ad__hero {
background: var(--mini-brand-gradient);
margin: 0 -16px;
padding: 20px 16px 22px;
color: #fff;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
}
.ad__badge {
display: inline-block;
font-size: 11px;
padding: 2px 10px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.2);
margin-bottom: 10px;
}
.ad__title {
font-size: 19px;
font-weight: 600;
line-height: 1.4;
}
.ad__slogan {
font-size: 12px;
opacity: 0.85;
margin-top: 6px;
}
.ad__block {
margin-top: 4px;
}
.ad__card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 14px;
box-shadow: var(--mini-shadow);
}
.ad__row {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--mini-text-secondary);
margin-bottom: 10px;
}
.ad__value {
color: var(--mini-text);
}
.ad__feature {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 0;
}
.ad__icon {
width: 34px;
height: 34px;
border-radius: 10px;
background: rgba(244, 0, 90, 0.08);
color: var(--mini-tabbar-active);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex: none;
}
.ad__feature-copy {
flex: 1;
min-width: 0;
}
.ad__feature-title {
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
}
.ad__feature-desc {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 2px;
}
.ad__body {
margin: 0;
font-size: 13px;
line-height: 1.7;
color: var(--mini-text);
}
.ad__rule {
display: flex;
gap: 8px;
font-size: 13px;
color: var(--mini-text);
padding: 4px 0;
}
.ad__rule-index {
color: var(--mini-tabbar-active);
font-weight: 600;
}
.ad__notice {
margin-top: 8px;
font-size: 11px;
color: var(--mini-text-muted);
line-height: 1.6;
}
.ad__action {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
background: var(--mini-surface);
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.06);
}
.ad__action-status {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.ad__action-hint {
font-size: 12px;
color: var(--mini-text-secondary);
}
.ad__action-btns {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
</style>
@@ -0,0 +1,277 @@
<template>
<PageLayout title="活动反馈">
<UiLoading v-if="loading" text="正在加载活动…" />
<UiErrorState v-else-if="error" @retry="load" />
<!-- 已提交 -->
<div v-else-if="submitted" class="fb__done">
<div class="fb__done-icon">✓</div>
<div class="fb__done-title">反馈已提交</div>
<div class="fb__done-hint">感谢您的参与!</div>
<div class="fb__done-actions">
<van-button type="primary" round size="small" @click="goMatches">查看活动缘分</van-button>
<van-button plain round size="small" @click="router.replace('/home')">返回首页</van-button>
</div>
</div>
<div v-else class="fb">
<div class="fb__header">
<div class="fb__title">活动反馈</div>
<div class="fb__name">{{ activity?.title || '本次公益活动' }}</div>
</div>
<div class="fb__section">
<div class="fb__section-title">整体体验</div>
<div class="fb__options">
<span
v-for="o in overallOptions"
:key="o.code"
class="fb__option"
:class="{ 'fb__option--active': overallExperience === o.code }"
@click="overallExperience = o.code"
>
{{ o.label }}
</span>
</div>
</div>
<div class="fb__section">
<div class="fb__section-title">是否愿意参加下一次活动</div>
<div class="fb__options">
<span
v-for="o in willingOptions"
:key="o.code"
class="fb__option"
:class="{ 'fb__option--active': willingNextTime === o.code }"
@click="willingNextTime = o.code"
>
{{ o.label }}
</span>
</div>
</div>
<div class="fb__section">
<div class="fb__section-title">是否在活动中认识了想进一步了解的人</div>
<div class="fb__options">
<span
class="fb__option"
:class="{ 'fb__option--active': metSomeone === true }"
@click="metSomeone = true"
>
是
</span>
<span
class="fb__option"
:class="{ 'fb__option--active': metSomeone === false }"
@click="metSomeone = false"
>
否
</span>
</div>
<div class="fb__hint">进一步了解意向将随反馈一并提交,双方互相表达后即可查看缘分。</div>
</div>
<van-button
class="fb__submit"
type="primary"
block
round
:loading="submitting"
:disabled="!overallExperience || !willingNextTime"
@click="onSubmit"
>
提交反馈
</van-button>
<van-button class="fb__skip" plain block @click="router.back()">跳过反馈</van-button>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { activityService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import { showConfirmDialog } from 'vant'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const activityId = computed(() => String(route.query.id || ''))
const activity = ref<any>(null)
const loading = ref(true)
const error = ref(false)
const overallExperience = ref('')
const willingNextTime = ref('')
const metSomeone = ref<boolean | null>(null)
const submitting = ref(false)
const submitted = ref(false)
const overallOptions = [
{ code: 'very_satisfied', label: '😊 非常满意' },
{ code: 'satisfied', label: '🙂 满意' },
{ code: 'normal', label: '😐 一般' },
{ code: 'dissatisfied', label: '😕 不满意' },
]
const willingOptions = [
{ code: 'willing', label: '💚 愿意' },
{ code: 'maybe', label: '🤔 看情况' },
{ code: 'not_now', label: '😶 暂不参加' },
]
async function load() {
loading.value = true
error.value = false
try {
if (!activityId.value) {
activity.value = null
return
}
activity.value = await activityService.getActivityDetail(activityId.value)
} catch (err: any) {
error.value = true
appStore.toastError(err?.message || '加载活动失败')
} finally {
loading.value = false
}
}
function goMatches() {
if (activityId.value) router.push({ path: '/activity/matches', query: { id: activityId.value } })
}
async function onSubmit() {
if (submitting.value) return
if (!overallExperience.value || !willingNextTime.value) {
appStore.toast('请填写必填项')
return
}
submitting.value = true
try {
const result: any = await activityService.submitFeedback(activityId.value, {
overallExperience: overallExperience.value,
willingNextTime: willingNextTime.value,
metSomeoneInteresting: metSomeone.value,
interestTargetUserIds: [],
})
submitted.value = true
const mutuals: any[] = result?.newMutuals || []
if (mutuals.length) {
await showConfirmDialog({
title: '双向匹配成功!',
message: `您与 ${mutuals.length} 位朋友互相表达了进一步了解意愿,可前往活动缘分查看。`,
confirmButtonText: '去查看',
showCancelButton: false,
}).catch(() => undefined)
goMatches()
}
} catch (err: any) {
appStore.toastError(err?.message || '提交失败')
} finally {
submitting.value = false
}
}
onMounted(load)
</script>
<style scoped>
.fb {
padding: 16px 16px 24px;
}
.fb__header {
margin-bottom: 12px;
}
.fb__title {
font-size: 17px;
font-weight: 600;
color: var(--mini-text-strong);
}
.fb__name {
font-size: 13px;
color: var(--mini-text-secondary);
margin-top: 4px;
}
.fb__section {
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 14px;
box-shadow: var(--mini-shadow);
margin-bottom: 12px;
}
.fb__section-title {
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
margin-bottom: 10px;
}
.fb__options {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.fb__option {
font-size: 13px;
padding: 7px 14px;
border-radius: 16px;
background: var(--mini-bg);
color: var(--mini-text);
border: 1px solid var(--mini-border);
}
.fb__option--active {
color: var(--mini-tabbar-active);
border-color: var(--mini-tabbar-active);
background: rgba(244, 0, 90, 0.06);
font-weight: 600;
}
.fb__hint {
font-size: 11px;
color: var(--mini-text-muted);
line-height: 1.6;
margin-top: 10px;
}
.fb__submit {
margin-top: 8px;
}
.fb__skip {
margin-top: 10px;
}
.fb__done {
display: flex;
flex-direction: column;
align-items: center;
padding: 60px 16px;
}
.fb__done-icon {
width: 64px;
height: 64px;
border-radius: 50%;
background: var(--mini-success);
color: #fff;
font-size: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.fb__done-title {
font-size: 17px;
font-weight: 600;
color: var(--mini-text-strong);
margin-top: 16px;
}
.fb__done-hint {
font-size: 13px;
color: var(--mini-text-secondary);
margin-top: 4px;
}
.fb__done-actions {
display: flex;
gap: 12px;
margin-top: 24px;
}
</style>
@@ -0,0 +1,140 @@
<template>
<PageLayout title="活动缘分">
<div class="am">
<div v-if="activityTitle" class="am__activity">{{ activityTitle }}</div>
<UiErrorState v-if="error" @retry="load" />
<UiLoading v-else-if="loading" />
<template v-else>
<div v-if="matches.length">
<div
v-for="m in matches"
:key="m.targetUserId"
class="am__card"
@click="goResult(m)"
>
<UiAvatar :src="avatarOf(m)" :name="m.name" :size="52" />
<div class="am__info">
<div class="am__name">{{ m.name || '新朋友' }}</div>
<div class="am__meta">
<span v-if="m.age">{{ m.age }}岁</span>
<span v-if="m.locationName">{{ m.locationName }}</span>
</div>
</div>
<div class="am__cta">
<span class="am__cta-text">查看缘分</span>
<van-icon name="arrow" color="#9aa0b5" />
</div>
</div>
</div>
<UiEmpty v-else description="暂无双向匹配">
<van-button type="primary" round size="small" @click="router.back()">返回</van-button>
</UiEmpty>
</template>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { activityService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiAvatar from '@/components/UiAvatar.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const activityId = computed(() => String(route.query.id || ''))
const matches = ref<any[]>([])
const activityTitle = ref('')
const loading = ref(true)
const error = ref(false)
function avatarOf(m: any): string {
const raw = m.avatarUrl || m.fileKey
return typeof raw === 'string' && raw.includes('://') ? raw : ''
}
async function load() {
loading.value = true
error.value = false
try {
if (!activityId.value) {
matches.value = []
return
}
const [result, detail]: any[] = await Promise.all([
activityService.getMutualMatches(activityId.value),
activityService.getActivityDetail(activityId.value).catch(() => null),
])
matches.value = result?.items || result?.records || (Array.isArray(result) ? result : [])
activityTitle.value = detail?.title || ''
} catch (err: any) {
error.value = true
appStore.toastError(err?.message || '加载活动缘分失败')
} finally {
loading.value = false
}
}
function goResult(m: any) {
if (!m?.targetUserId) return
router.push({
path: '/activity/mutual/result',
query: { id: activityId.value, partnerId: m.targetUserId },
})
}
onMounted(load)
</script>
<style scoped>
.am {
padding: 12px 16px 16px;
}
.am__activity {
font-size: 13px;
color: var(--mini-text-secondary);
margin-bottom: 10px;
}
.am__card {
display: flex;
align-items: center;
gap: 12px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 12px;
margin-bottom: 10px;
box-shadow: var(--mini-shadow);
}
.am__info {
flex: 1;
min-width: 0;
}
.am__name {
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
}
.am__meta {
display: flex;
gap: 10px;
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 4px;
}
.am__cta {
display: flex;
align-items: center;
gap: 2px;
font-size: 12px;
color: var(--mini-tabbar-active);
flex: none;
}
</style>
@@ -0,0 +1,309 @@
<template>
<PageLayout title="活动互选">
<div class="mr">
<UiErrorState v-if="error" @retry="load" />
<UiLoading v-else-if="loading" />
<!-- 单个互选对象:确认 / 等待 / 已确认 -->
<template v-else-if="hasPartner">
<div class="mr__partner">
<UiAvatar :src="partnerAvatar" :name="partnerName" :size="60" />
<div class="mr__partner-name">{{ partnerName }}</div>
<div class="mr__partner-hint">活动互选一致,双方确认后才可开启沟通</div>
</div>
<UiEmpty v-if="!status.exists && !statusLoaded" description="未找到该活动的互选记录" />
<div v-if="bothConfirmed" class="mr__status mr__status--success">
<div class="mr__status-icon mr__status-icon--success">✓</div>
<div class="mr__status-title">双方已确认</div>
<div class="mr__status-hint">
{{ hasActiveMatch ? '你们已有沟通入口,可直接进入缘分中心查看' : '沟通入口已开启,可前往缘分中心查看' }}
</div>
<van-button type="primary" round @click="goFate">
{{ hasActiveMatch ? '查看已有沟通' : '查看沟通' }}
</van-button>
</div>
<div v-else-if="partnerDeclined" class="mr__status">
<div class="mr__status-icon">💤</div>
<div class="mr__status-title">对方暂不联系</div>
<div class="mr__status-hint">对方已选择暂不开通沟通,您可以稍后再看看其他缘分。</div>
<van-button round plain @click="router.back()">返回</van-button>
</div>
<div v-else-if="myAccepted" class="mr__status">
<div class="mr__status-icon">⏳</div>
<div class="mr__status-title">已发送邀请</div>
<div class="mr__status-hint">对方确认后将收到平台通知</div>
<div class="mr__status-actions">
<van-button type="primary" round @click="goWaiting">查看等待状态</van-button>
<van-button round plain type="danger" :loading="acting === 'decline'" @click="onDecline">
暂不联系
</van-button>
</div>
</div>
<div v-else class="mr__status">
<div class="mr__status-icon">💬</div>
<div class="mr__status-title">向对方表达兴趣</div>
<div class="mr__status-hint">双方确认后才会开启沟通并收到平台通知</div>
<div class="mr__status-actions">
<van-button type="primary" round :loading="acting === 'confirm'" @click="onConfirm">
确认开启沟通
</van-button>
<van-button round plain @click="onDecline" :loading="acting === 'decline'">
暂不联系
</van-button>
</div>
</div>
</template>
<!-- 互选结果列表 -->
<template v-else>
<div v-if="results.length">
<div
v-for="r in results"
:key="r.partnerId"
class="mr__card"
@click="openPartner(r)"
>
<UiAvatar :src="avatarOf(r)" :name="r.partnerName" :size="48" />
<div class="mr__card-main">
<div class="mr__card-name">{{ r.partnerName || '新朋友' }}</div>
<UiTag :type="r.bothConfirmed ? 'success' : 'warning'">
{{ r.bothConfirmed ? '双方已确认' : '等待确认' }}
</UiTag>
</div>
<van-icon name="arrow" color="#9aa0b5" />
</div>
</div>
<UiEmpty v-else description="暂无互选结果">
<span class="mr__empty-hint">参加活动并向感兴趣的人表达意向后,双方一致即可在此查看</span>
</UiEmpty>
</template>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { activityMutualService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiAvatar from '@/components/UiAvatar.vue'
import UiTag from '@/components/UiTag.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const activityId = computed(() => String(route.query.id || ''))
const partnerId = computed(() => String(route.query.partnerId || ''))
const hasPartner = computed(() => !!partnerId.value)
const loading = ref(true)
const error = ref(false)
const results = ref<any[]>([])
const status = ref<any>({ exists: false })
const statusLoaded = ref(false)
const partnerName = ref('新朋友')
const partnerAvatar = ref('')
const hasActiveMatch = ref(false)
const acting = ref('')
const myAccepted = computed(() => status.value?.myStatus === 'accepted')
const bothConfirmed = computed(() => !!status.value?.bothConfirmed)
const partnerDeclined = computed(() => status.value?.partnerStatus === 'declined')
function avatarOf(r: any): string {
const raw = r.partnerAvatar || r.partnerAvatarFileId
return typeof raw === 'string' && raw.includes('://') ? raw : ''
}
async function load() {
loading.value = true
error.value = false
try {
if (!activityId.value) {
results.value = []
return
}
if (hasPartner.value) {
await loadPartner()
} else {
const result: any = await activityMutualService.getResults(activityId.value)
results.value = result?.items || result?.records || (Array.isArray(result) ? result : [])
}
} catch (err: any) {
error.value = true
appStore.toastError(err?.message || '加载互选结果失败')
} finally {
loading.value = false
}
}
async function loadPartner() {
const [statusRes, listRes]: any[] = await Promise.all([
activityMutualService.getMyConsentStatus(activityId.value, partnerId.value).catch(() => null),
activityMutualService.getResults(activityId.value).catch(() => null),
])
status.value = statusRes || { exists: false }
statusLoaded.value = true
const list: any[] = listRes?.items || listRes?.records || (Array.isArray(listRes) ? listRes : [])
const me = list.find((x) => (x.partnerId || x.targetUserId) === partnerId.value)
if (me) {
partnerName.value = me.partnerName || me.name || '新朋友'
partnerAvatar.value = avatarOf({ partnerAvatar: me.partnerAvatar, partnerAvatarFileId: me.partnerAvatarFileId })
hasActiveMatch.value = !!me.hasActiveMatch
}
}
function openPartner(r: any) {
const pid = r.partnerId || r.targetUserId
if (!pid) return
router.push({ path: '/activity/mutual/result', query: { id: activityId.value, partnerId: pid } })
}
function goWaiting() {
router.push({ path: '/activity/mutual/waiting', query: { id: activityId.value, partnerId: partnerId.value } })
}
function goFate() {
router.push('/fate-center')
}
async function onConfirm() {
if (acting.value) return
acting.value = 'confirm'
try {
// 二阶段同意(INV-014/015):先确保同意记录存在,再提交「我方确认」。
if (!status.value?.exists) {
await activityMutualService.createConsent(activityId.value, partnerId.value)
}
const result: any = await activityMutualService.confirmConnection(activityId.value, partnerId.value)
status.value = { ...(status.value || {}), exists: true, ...result }
if (result?.bothConfirmed) {
hasActiveMatch.value = !!result?.hasActiveMatch
appStore.toastSuccess('双方已确认,可查看沟通')
} else {
appStore.toastSuccess('已发送邀请,等待对方确认')
goWaiting()
}
} catch (err: any) {
appStore.toastError(err?.message || '操作失败')
} finally {
acting.value = ''
}
}
async function onDecline() {
if (acting.value) return
acting.value = 'decline'
try {
await activityMutualService.declineConnection(activityId.value, partnerId.value)
appStore.toastSuccess('已选择暂不联系')
router.back()
} catch (err: any) {
appStore.toastError(err?.message || '操作失败')
} finally {
acting.value = ''
}
}
onMounted(load)
</script>
<style scoped>
.mr {
padding: 12px 16px 16px;
}
.mr__partner {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 24px 0 8px;
}
.mr__partner-name {
font-size: 17px;
font-weight: 600;
color: var(--mini-text-strong);
}
.mr__partner-hint {
font-size: 12px;
color: var(--mini-text-secondary);
}
.mr__status {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 24px 16px;
margin-top: 12px;
box-shadow: var(--mini-shadow);
text-align: center;
}
.mr__status--success {
background: rgba(47, 179, 109, 0.08);
}
.mr__status-icon {
font-size: 40px;
}
.mr__status-icon--success {
width: 52px;
height: 52px;
border-radius: 50%;
background: var(--mini-success);
color: #fff;
font-size: 26px;
display: flex;
align-items: center;
justify-content: center;
}
.mr__status-title {
font-size: 16px;
font-weight: 600;
color: var(--mini-text-strong);
}
.mr__status-hint {
font-size: 12px;
color: var(--mini-text-secondary);
line-height: 1.6;
}
.mr__status-actions {
display: flex;
gap: 12px;
margin-top: 6px;
}
.mr__card {
display: flex;
align-items: center;
gap: 12px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 12px;
margin-bottom: 10px;
box-shadow: var(--mini-shadow);
}
.mr__card-main {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.mr__card-name {
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
}
.mr__empty-hint {
font-size: 12px;
color: var(--mini-text-muted);
}
</style>
@@ -0,0 +1,229 @@
<template>
<PageLayout title="等待确认">
<div class="mw">
<UiErrorState v-if="error" @retry="load" />
<UiLoading v-else-if="loading" />
<UiEmpty v-else-if="!status.exists" description="未找到该活动的互选记录" />
<template v-else>
<div class="mw__partner">
<UiAvatar :src="partnerAvatar" :name="partnerName" :size="60" />
<div class="mw__partner-name">{{ partnerName }}</div>
</div>
<div v-if="bothConfirmed" class="mw__panel mw__panel--success">
<div class="mw__icon mw__icon--success">✓</div>
<div class="mw__title">双方已确认!</div>
<div class="mw__hint">
{{ hasActiveMatch ? '你们已有沟通入口,可前往缘分中心查看' : '沟通入口已开启,可前往缘分中心查看' }}
</div>
<van-button type="primary" round @click="goFate">
{{ hasActiveMatch ? '查看已有沟通' : '查看沟通' }}
</van-button>
</div>
<div v-else-if="partnerDeclined" class="mw__panel">
<div class="mw__icon">💤</div>
<div class="mw__title">对方暂不联系</div>
<div class="mw__hint">对方已选择暂不开通沟通,您可以稍后再看看其他缘分。</div>
<van-button round plain @click="router.back()">返回</van-button>
</div>
<div v-else class="mw__panel">
<div class="mw__icon">⏳</div>
<div class="mw__title">已发送邀请,等待对方确认</div>
<div class="mw__hint">对方确认后将收到平台通知;本页面会自动刷新状态</div>
<div class="mw__actions">
<van-button type="primary" round :loading="refreshing" @click="refresh">
刷新状态
</van-button>
<van-button round plain type="danger" :loading="declining" @click="onDecline">
暂不联系
</van-button>
</div>
</div>
</template>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { activityMutualService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiAvatar from '@/components/UiAvatar.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const POLL_MS = 10000
const activityId = computed(() => String(route.query.id || ''))
const partnerId = computed(() => String(route.query.partnerId || ''))
const loading = ref(true)
const refreshing = ref(false)
const declining = ref(false)
const error = ref(false)
const status = ref<any>({ exists: false })
const partnerName = ref('新朋友')
const partnerAvatar = ref('')
const hasActiveMatch = ref(false)
let timer: ReturnType<typeof setInterval> | null = null
const bothConfirmed = computed(() => !!status.value?.bothConfirmed)
const partnerDeclined = computed(() => status.value?.partnerStatus === 'declined')
async function fetchStatus(isRefresh: boolean) {
if (isRefresh) refreshing.value = true
else loading.value = true
error.value = false
try {
if (!activityId.value || !partnerId.value) {
status.value = { exists: false }
return
}
const res: any = await activityMutualService.getMyConsentStatus(activityId.value, partnerId.value)
status.value = res || { exists: false }
// 对方确认后拉取一次结果,用于复用已有沟通(INV-019),并停止轮询
if (res?.bothConfirmed) {
await loadResult()
stopPoll()
}
} catch (err: any) {
error.value = true
if (isRefresh) appStore.toastError(err?.message || '刷新失败')
} finally {
loading.value = false
refreshing.value = false
}
}
async function loadResult() {
try {
const list: any = await activityMutualService.getResults(activityId.value)
const arr: any[] = list?.items || list?.records || (Array.isArray(list) ? list : [])
const me = arr.find((x) => (x.partnerId || x.targetUserId) === partnerId.value)
if (me) {
partnerName.value = me.partnerName || me.name || partnerName.value
hasActiveMatch.value = !!me.hasActiveMatch
}
} catch {
/* 结果信息可选,不影响等待状态 */
}
}
function startPoll() {
if (timer) return
timer = setInterval(() => {
if (bothConfirmed.value) return
fetchStatus(true)
}, POLL_MS)
}
function stopPoll() {
if (timer) {
clearInterval(timer)
timer = null
}
}
function load() {
fetchStatus(false)
}
function refresh() {
fetchStatus(true)
}
async function onDecline() {
if (declining.value) return
declining.value = true
try {
await activityMutualService.declineConnection(activityId.value, partnerId.value)
appStore.toastSuccess('已选择暂不联系')
router.back()
} catch (err: any) {
appStore.toastError(err?.message || '操作失败')
} finally {
declining.value = false
}
}
function goFate() {
router.push('/fate-center')
}
onMounted(() => {
load()
startPoll()
})
onUnmounted(stopPoll)
</script>
<style scoped>
.mw {
padding: 12px 16px 16px;
}
.mw__partner {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 28px 0 12px;
}
.mw__partner-name {
font-size: 17px;
font-weight: 600;
color: var(--mini-text-strong);
}
.mw__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 28px 16px;
box-shadow: var(--mini-shadow);
text-align: center;
}
.mw__panel--success {
background: rgba(47, 179, 109, 0.08);
}
.mw__icon {
font-size: 44px;
}
.mw__icon--success {
width: 52px;
height: 52px;
border-radius: 50%;
background: var(--mini-success);
color: #fff;
font-size: 26px;
display: flex;
align-items: center;
justify-content: center;
}
.mw__title {
font-size: 16px;
font-weight: 600;
color: var(--mini-text-strong);
}
.mw__hint {
font-size: 12px;
color: var(--mini-text-secondary);
line-height: 1.6;
}
.mw__actions {
display: flex;
gap: 12px;
margin-top: 6px;
}
</style>
+235
View File
@@ -0,0 +1,235 @@
<template>
<div class="act">
<div class="act__hero">
<div class="act__hero-top">
<div class="act__title">公益活动</div>
<span class="act__my" @click="router.push('/my/activities')">
<van-icon name="calendar-o" /> 我的活动
</span>
</div>
<div class="act__cats">
<span
v-for="c in categories"
:key="c.code"
class="act__cat"
:class="{ 'act__cat--active': activeCategory === c.code }"
@click="activeCategory = c.code"
>
{{ c.label }}
</span>
</div>
</div>
<div class="act__heading">
<div class="act__heading-title">丹阳公益活动</div>
<div class="act__heading-sub">丰富活动,连接更多可能</div>
</div>
<div class="act__body">
<UiErrorState v-if="error" @retry="load" />
<UiLoading v-else-if="firstLoading" />
<template v-else>
<div v-if="filtered.length">
<div
v-for="a in filtered"
:key="a.id || a.activityId"
class="act__card"
@click="goDetail(a)"
>
<div class="act__cover">{{ (a.title || '活').slice(0, 1) }}</div>
<div class="act__info">
<div class="act__cat-tag">{{ categoryLabel(a) }}</div>
<div class="act__card-title">{{ a.title }}</div>
<div class="act__meta">
<span>{{ formatDateTime(a.activityStartAt || a.startAt) }}</span>
<span v-if="a.locationName || a.location">{{ a.locationName || a.location }}</span>
</div>
</div>
<van-icon name="arrow" color="#9aa0b5" />
</div>
</div>
<UiEmpty v-else description="暂无进行中的活动" />
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { activityService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import { formatDateTime } from '@/utils/datetime'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const router = useRouter()
const appStore = useAppStore()
const categories = [
{ code: 'all', label: '全部' },
{ code: 'matchmaking', label: '公益联谊' },
{ code: 'interest_social', label: '兴趣社交' },
{ code: 'relationship_education', label: '婚恋课堂' },
{ code: 'premarital_guidance', label: '婚前辅导' },
{ code: 'marriage_custom_culture', label: '婚俗文化' },
{ code: 'anti_fraud', label: '反诈宣传' },
]
const activities = ref<any[]>([])
const activeCategory = ref('all')
const firstLoading = ref(true)
const error = ref(false)
const filtered = computed(() => {
if (activeCategory.value === 'all') return activities.value
return activities.value.filter((a) => (a.activityType || a.category) === activeCategory.value)
})
function categoryLabel(a: any) {
const code = a.activityType || a.category
return categories.find((c) => c.code === code)?.label || '公益活动'
}
async function load() {
firstLoading.value = true
error.value = false
try {
const result: any = await activityService.getActivities({ pageSize: 50 })
activities.value = result?.items || result?.records || result || []
} catch (err: any) {
error.value = true
appStore.toastError(err?.message || '加载活动失败')
} finally {
firstLoading.value = false
}
}
function goDetail(a: any) {
const id = a.id || a.activityId
if (id) router.push({ path: '/activity/detail', query: { id } })
}
onMounted(load)
</script>
<style scoped>
.act {
min-height: 100vh;
background: var(--mini-bg);
padding-bottom: 16px;
}
.act__hero {
background: var(--mini-brand-gradient);
padding: 18px 16px 16px;
color: #fff;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
}
.act__hero-top {
display: flex;
align-items: center;
justify-content: space-between;
}
.act__title {
font-size: 18px;
font-weight: 600;
}
.act__my {
font-size: 13px;
background: rgba(255, 255, 255, 0.16);
padding: 6px 12px;
border-radius: 16px;
display: flex;
align-items: center;
gap: 4px;
}
.act__cats {
display: flex;
gap: 8px;
overflow-x: auto;
margin-top: 14px;
padding-bottom: 2px;
}
.act__cat {
flex: none;
font-size: 12px;
padding: 5px 12px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.14);
color: #fff;
}
.act__cat--active {
background: #fff;
color: var(--mini-tabbar-active);
font-weight: 600;
}
.act__heading {
padding: 16px 16px 4px;
}
.act__heading-title {
font-size: 16px;
font-weight: 600;
color: var(--mini-text-strong);
}
.act__heading-sub {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 2px;
}
.act__body {
padding: 8px 16px 0;
}
.act__card {
display: flex;
align-items: center;
gap: 12px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 12px;
margin-bottom: 10px;
box-shadow: var(--mini-shadow);
}
.act__cover {
width: 56px;
height: 56px;
border-radius: 12px;
background: linear-gradient(135deg, #8a6cff 0%, #6244e0 100%);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
font-weight: 600;
flex: none;
}
.act__info {
flex: 1;
min-width: 0;
}
.act__cat-tag {
display: inline-block;
font-size: 10px;
color: var(--mini-tabbar-active);
background: rgba(244, 0, 90, 0.08);
padding: 1px 8px;
border-radius: 8px;
}
.act__card-title {
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.act__meta {
display: flex;
gap: 12px;
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 4px;
}
</style>
@@ -0,0 +1,147 @@
<template>
<PageLayout title="活动报名">
<UiLoading v-if="loading" text="正在加载活动…" />
<UiErrorState v-else-if="error" @retry="load" />
<UiEmpty v-else-if="!activity" description="该活动不存在或已下线" />
<template v-else>
<div class="reg">
<div class="reg__card">
<div class="reg__title">{{ activity.title }}</div>
<div class="reg__meta">
<span>{{ formatDateTime(activity.startAt) || '时间待定' }}</span>
<span v-if="activity.locationName">{{ activity.locationName }}</span>
</div>
</div>
<div class="reg__card">
<div class="reg__label">备注 / 留言(选填)</div>
<van-field
v-model="remark"
type="textarea"
rows="3"
maxlength="200"
show-word-limit
placeholder="可填写方便工作人员联系的信息或特殊说明"
autosize
/>
</div>
<div class="reg__notice">
报名提交后需等待工作人员审核;不同活动类型的资格要求以活动页面和审核结果为准。
</div>
<van-button
class="reg__submit"
type="primary"
block
round
:loading="submitting"
@click="onSubmit"
>
提交报名
</van-button>
</div>
</template>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { activityService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import { formatDateTime } from '@/utils/datetime'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const activityId = computed(() => String(route.query.id || ''))
const activity = ref<any>(null)
const loading = ref(true)
const error = ref(false)
const remark = ref('')
const submitting = ref(false)
async function load() {
loading.value = true
error.value = false
try {
if (!activityId.value) {
activity.value = null
return
}
activity.value = await activityService.getActivityDetail(activityId.value)
} catch (err: any) {
error.value = true
appStore.toastError(err?.message || '加载活动失败')
} finally {
loading.value = false
}
}
async function onSubmit() {
if (submitting.value || !activityId.value) return
submitting.value = true
try {
const payload = remark.value.trim() ? { remark: remark.value.trim() } : {}
await activityService.register(activityId.value, payload)
appStore.toastSuccess('报名已提交,请等待审核')
router.replace({ path: '/my/activities' })
} catch (err: any) {
appStore.toastError(err?.message || '报名失败')
} finally {
submitting.value = false
}
}
onMounted(load)
</script>
<style scoped>
.reg {
padding: 16px 16px 24px;
}
.reg__card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 14px;
box-shadow: var(--mini-shadow);
margin-bottom: 12px;
}
.reg__title {
font-size: 16px;
font-weight: 600;
color: var(--mini-text-strong);
}
.reg__meta {
display: flex;
gap: 12px;
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 6px;
}
.reg__label {
font-size: 13px;
color: var(--mini-text-strong);
margin-bottom: 8px;
}
.reg__card :deep(.van-field) {
background: var(--mini-bg);
border-radius: 8px;
padding: 10px;
}
.reg__notice {
font-size: 12px;
color: var(--mini-text-muted);
line-height: 1.6;
margin: 4px 4px 16px;
}
.reg__submit {
margin-top: 8px;
}
</style>
@@ -0,0 +1,245 @@
<template>
<PageLayout title="我的活动">
<div class="ma">
<div class="ma__filters">
<span
v-for="f in filters"
:key="f.code"
class="ma__chip"
:class="{ 'ma__chip--active': activeFilter === f.code }"
@click="activeFilter = f.code"
>
{{ f.label }}
</span>
</div>
<UiErrorState v-if="error" @retry="load" />
<UiLoading v-else-if="loading" />
<template v-else>
<div v-if="filtered.length">
<div
v-for="a in filtered"
:key="a.activityId"
class="ma__card"
@click="goDetail(a)"
>
<div class="ma__top">
<div class="ma__title">{{ a.title }}</div>
<UiTag :type="statusTag(a.status).type">{{ statusTag(a.status).text }}</UiTag>
</div>
<div class="ma__meta">
<span>{{ formatDateTime(a.startAt) || '时间待定' }}</span>
<span v-if="a.locationName">{{ a.locationName }}</span>
</div>
<div v-if="a.checkinStatus === 'checked_in'" class="ma__badge ma__badge--done">已签到</div>
<div class="ma__actions" @click.stop>
<van-button size="mini" plain @click="goDetail(a)">详情</van-button>
<van-button
v-if="canCheckin(a)"
size="mini"
type="primary"
@click="goCheckin(a)"
>
签到
</van-button>
<van-button
v-if="a.status === 'approved'"
size="mini"
plain
type="primary"
@click="goFeedback(a)"
>
反馈
</van-button>
<van-button
v-if="canCancel(a)"
size="mini"
plain
type="danger"
:loading="cancelling === a.activityId"
@click="onCancel(a)"
>
取消报名
</van-button>
</div>
</div>
</div>
<UiEmpty v-else description="还没有报名任何活动">
<van-button type="primary" round size="small" @click="router.push('/activity')">
去逛公益活动
</van-button>
</UiEmpty>
</template>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { activityService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import { showConfirmDialog } from 'vant'
import { formatDateTime } from '@/utils/datetime'
import PageLayout from '@/layouts/PageLayout.vue'
import UiTag from '@/components/UiTag.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const router = useRouter()
const appStore = useAppStore()
const filters = [
{ code: 'all', label: '全部' },
{ code: 'pending', label: '待审核' },
{ code: 'approved', label: '已通过' },
]
const activeFilter = ref('all')
const list = ref<any[]>([])
const loading = ref(true)
const error = ref(false)
const cancelling = ref('')
const filtered = computed(() => {
if (activeFilter.value === 'all') return list.value
return list.value.filter((a) => a.status === activeFilter.value)
})
function statusTag(status: string): { type: 'primary' | 'success' | 'warning' | 'danger' | 'info'; text: string } {
switch (status) {
case 'pending':
return { type: 'warning', text: '待审核' }
case 'approved':
return { type: 'success', text: '已通过' }
case 'rejected':
return { type: 'danger', text: '未通过' }
case 'cancelled':
return { type: 'info', text: '已取消' }
default:
return { type: 'info', text: '未知状态' }
}
}
function canCheckin(a: any): boolean {
return a.status === 'approved' && a.checkinStatus !== 'checked_in'
}
function canCancel(a: any): boolean {
return a.status === 'pending' || a.status === 'approved'
}
async function load() {
loading.value = true
error.value = false
try {
const result: any = await activityService.getMyActivities({ pageSize: 50 })
list.value = result?.items || result?.records || (Array.isArray(result) ? result : [])
} catch (err: any) {
error.value = true
appStore.toastError(err?.message || '加载我的活动失败')
} finally {
loading.value = false
}
}
function goDetail(a: any) {
if (a?.activityId) router.push({ path: '/activity/detail', query: { id: a.activityId } })
}
function goCheckin(a: any) {
if (a?.activityId) router.push({ path: '/activity/checkin', query: { id: a.activityId } })
}
function goFeedback(a: any) {
if (a?.activityId) router.push({ path: '/activity/feedback', query: { id: a.activityId } })
}
async function onCancel(a: any) {
if (cancelling.value) return
try {
await showConfirmDialog({ title: '取消报名', message: `确定取消「${a.title}」的报名吗?` })
} catch {
return
}
cancelling.value = a.activityId
try {
await activityService.cancelRegistration(a.activityId)
appStore.toastSuccess('已取消报名')
const target = list.value.find((x) => x.activityId === a.activityId)
if (target) target.status = 'cancelled'
} catch (err: any) {
appStore.toastError(err?.message || '取消失败')
} finally {
cancelling.value = ''
}
}
onMounted(load)
</script>
<style scoped>
.ma {
padding: 12px 16px 16px;
}
.ma__filters {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.ma__chip {
font-size: 12px;
padding: 5px 12px;
border-radius: 14px;
background: var(--mini-surface);
color: var(--mini-text-secondary);
border: 1px solid var(--mini-border);
}
.ma__chip--active {
color: var(--mini-tabbar-active);
border-color: var(--mini-tabbar-active);
background: rgba(244, 0, 90, 0.06);
}
.ma__card {
position: relative;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 14px;
margin-bottom: 10px;
box-shadow: var(--mini-shadow);
}
.ma__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.ma__title {
flex: 1;
min-width: 0;
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ma__meta {
display: flex;
gap: 12px;
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 6px;
}
.ma__badge {
position: absolute;
right: 14px;
top: 44px;
font-size: 11px;
color: var(--mini-success);
}
.ma__actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 12px;
}
</style>
+177
View File
@@ -0,0 +1,177 @@
<template>
<PageLayout title="绑定手机号">
<div class="bind">
<div class="bind__hero">
<div class="bind__icon">机</div>
<div class="bind__title">绑定手机号</div>
<div class="bind__desc">绑定后可用于找回账号、接收平台通知</div>
</div>
<div class="card">
<van-field
v-model="phone"
type="tel"
maxlength="11"
label="手机号"
placeholder="请输入本人手机号"
:border="false"
@update:model-value="error = ''"
/>
<van-field
v-model="code"
type="digit"
maxlength="6"
label="验证码"
placeholder="请输入验证码"
@update:model-value="error = ''"
>
<template #button>
<van-button size="small" type="primary" :disabled="countdown > 0" @click="onSendCode">
{{ countdown > 0 ? countdown + 's' : '获取验证码' }}
</van-button>
</template>
</van-field>
</div>
<van-checkbox v-model="agreed" class="bind__agree" icon-size="16">
我已阅读并同意《用户服务协议》和《隐私政策》
</van-checkbox>
<p v-if="error" class="bind__error">{{ error }}</p>
<div class="bind__actions">
<van-button type="primary" block round :loading="submitting" @click="onSubmit">
确认绑定
</van-button>
</div>
<p class="bind__tip">手机号仅用于账号安全与平台通知,不会向其他用户展示。</p>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue'
import { useRouter } from 'vue-router'
import { authService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
const router = useRouter()
const appStore = useAppStore()
const phone = ref('')
const code = ref('')
const agreed = ref(false)
const error = ref('')
const submitting = ref(false)
const countdown = ref(0)
let timer: ReturnType<typeof setInterval> | null = null
function onSendCode() {
error.value = ''
if (!/^1\d{10}$/.test(phone.value)) {
error.value = '请输入正确的手机号'
return
}
// 演示端不提供短信网关,仅作占位提示
appStore.toast('验证码已发送(演示)')
countdown.value = 60
timer = setInterval(() => {
countdown.value -= 1
if (countdown.value <= 0 && timer) {
clearInterval(timer)
timer = null
}
}, 1000)
}
async function onSubmit() {
error.value = ''
if (!agreed.value) {
error.value = '请先阅读并同意协议'
return
}
if (!/^1\d{10}$/.test(phone.value)) {
error.value = '请输入正确的手机号'
return
}
if (!code.value) {
error.value = '请输入验证码'
return
}
submitting.value = true
try {
await authService.bindPhone({ phone: phone.value, phoneCode: code.value })
appStore.toastSuccess('手机号绑定成功')
router.replace('/home')
} catch (err: any) {
error.value = err?.message || '绑定失败,请稍后重试'
} finally {
submitting.value = false
}
}
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
</script>
<style scoped>
.bind {
padding: 16px;
}
.bind__hero {
text-align: center;
padding: 24px 0 20px;
}
.bind__icon {
width: 56px;
height: 56px;
border-radius: 16px;
margin: 0 auto 12px;
background: var(--mini-brand-gradient);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: 600;
}
.bind__title {
font-size: 18px;
font-weight: 600;
color: var(--mini-text-strong);
}
.bind__desc {
font-size: 13px;
color: var(--mini-text-secondary);
margin-top: 6px;
}
.card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
overflow: hidden;
}
.bind__agree {
margin-top: 14px;
font-size: 12px;
color: var(--mini-text-secondary);
}
.bind__error {
color: var(--mini-danger);
font-size: 13px;
margin: 12px 4px 0;
}
.bind__actions {
margin-top: 20px;
}
.bind__tip {
font-size: 11px;
color: var(--mini-text-muted);
text-align: center;
margin-top: 16px;
line-height: 1.6;
}
</style>
@@ -0,0 +1,231 @@
<template>
<PageLayout title="红娘联系授权">
<div class="cc">
<div class="cc__intro">
<div class="cc__intro-title">红娘联系授权</div>
<div class="cc__intro-text">
红娘经平台审批后会向你发起联系授权申请。你可以单独决定同意或拒绝,同意后在有效期内生效,
到期自动失效,也可随时被撤销。
</div>
</div>
<UiLoading v-if="loading" text="加载中…" />
<UiErrorState v-else-if="error" @retry="load" />
<template v-else>
<!-- 待确认队列 -->
<template v-if="pending.length">
<div class="cc__section-title">待你确认({{ pending.length }})</div>
<div v-for="item in pending" :key="item.id" class="card">
<div class="cc__row">
<span class="cc__label">授权渠道</span>
<span class="cc__value cc__value--strong">{{ channelLabel(item.channel) }}</span>
</div>
<div class="cc__row">
<span class="cc__label">授权目的</span>
<span class="cc__value">{{ item.purpose || '—' }}</span>
</div>
<div class="cc__row">
<span class="cc__label">确认截止</span>
<span class="cc__value">{{ formatDateTime(item.expiresAt) || '—' }}</span>
</div>
<div class="cc__actions">
<van-button size="small" plain :loading="actingId === item.id" @click="onDecline(item)">
拒绝
</van-button>
<van-button size="small" type="primary" :loading="actingId === item.id" @click="onConsent(item)">
同意
</van-button>
</div>
</div>
</template>
<UiEmpty v-else description="暂无待确认的授权申请" />
<!-- 历史记录 -->
<template v-if="list.length">
<div class="cc__section-title">历史记录</div>
<div v-for="item in list" :key="item.id" class="card card--slim">
<div class="cc__row">
<span class="cc__label">渠道</span>
<span class="cc__value">{{ channelLabel(item.channel) }}</span>
</div>
<div class="cc__row">
<span class="cc__label">目的</span>
<span class="cc__value">{{ item.purpose || '—' }}</span>
</div>
<div class="cc__row">
<span class="cc__label">状态</span>
<span class="cc__value">
<UiTag :type="statusTag(item.status)">{{ statusLabel(item.status) }}</UiTag>
</span>
</div>
</div>
</template>
</template>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { showConfirmDialog } from 'vant'
import { contactConsentService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import { formatDateTime } from '@/utils/datetime'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
import UiTag from '@/components/UiTag.vue'
const appStore = useAppStore()
const loading = ref(true)
const error = ref(false)
const pending = ref<any[]>([])
const list = ref<any[]>([])
const actingId = ref('')
const CHANNEL_LABELS: Record<string, string> = {
phone: '电话联系',
offline_meeting: '线下见面',
full_contact: '完整联系方式',
}
const STATUS_LABELS: Record<string, string> = {
pending_approval: '待审批',
pending_consent: '待你确认',
active: '生效中',
rejected: '已拒绝',
expired: '已过期',
revoked: '已撤销',
}
function channelLabel(code: string): string {
return CHANNEL_LABELS[code] || code || '—'
}
function statusLabel(code: string): string {
return STATUS_LABELS[code] || code || '—'
}
function statusTag(code: string): 'primary' | 'success' | 'warning' | 'danger' | 'info' {
if (code === 'active') return 'success'
if (code === 'pending_consent' || code === 'pending_approval') return 'warning'
if (code === 'rejected' || code === 'revoked') return 'danger'
return 'info'
}
async function load() {
loading.value = true
error.value = false
try {
const result: any = await contactConsentService.my()
pending.value = result?.pendingConsent || []
list.value = result?.list || result?.items || result?.records || []
} catch {
error.value = true
} finally {
loading.value = false
}
}
async function onConsent(item: any) {
try {
await showConfirmDialog({
title: '确认同意联系授权',
message: `同意「${channelLabel(item.channel)}」:${item.purpose || ''}。同意后红娘可在有效期内通过该方式联系你。`,
})
} catch {
return
}
actingId.value = item.id
try {
await contactConsentService.consent(item.id)
appStore.toastSuccess('已同意')
await load()
} catch (err: any) {
appStore.toastError(err?.message || '操作失败')
} finally {
actingId.value = ''
}
}
async function onDecline(item: any) {
actingId.value = item.id
try {
await contactConsentService.decline(item.id)
appStore.toast('已拒绝')
await load()
} catch (err: any) {
appStore.toastError(err?.message || '操作失败')
} finally {
actingId.value = ''
}
}
onMounted(load)
</script>
<style scoped>
.cc {
padding: 16px;
}
.cc__intro {
background: rgba(255, 79, 145, 0.06);
border: 1px solid rgba(255, 79, 145, 0.16);
border-radius: var(--mini-radius);
padding: 14px;
margin-bottom: 12px;
}
.cc__intro-title {
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
}
.cc__intro-text {
font-size: 12px;
color: var(--mini-text-secondary);
line-height: 1.7;
margin-top: 6px;
}
.cc__section-title {
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
margin: 16px 0 8px;
}
.card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 14px;
margin-bottom: 10px;
}
.card--slim {
padding: 12px 14px;
}
.cc__row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 4px 0;
}
.cc__label {
font-size: 13px;
color: var(--mini-text-secondary);
flex: none;
}
.cc__value {
font-size: 13px;
color: var(--mini-text);
text-align: right;
}
.cc__value--strong {
font-weight: 600;
color: var(--mini-text-strong);
}
.cc__actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 12px;
}
</style>
+202
View File
@@ -0,0 +1,202 @@
<template>
<div class="login">
<div class="login__hero">
<h1 class="login__brand">善缘丹阳</h1>
<p class="login__slogan">真实相识 · 公益牵线</p>
<p class="login__sub">H5 演示端(对齐小程序用户端 / 红娘端)</p>
</div>
<div class="login__body">
<p class="login__hint">选择一个演示账号登录(无需微信环境)</p>
<div class="login__grid">
<div
v-for="acc in accounts"
:key="acc.code"
class="login__card"
:class="{ 'login__card--active': current === acc.code, 'login__card--loading': loading === acc.code }"
@click="onLogin(acc)"
>
<div class="login__avatar">{{ acc.name.slice(0, 1) }}</div>
<div class="login__meta">
<div class="login__name">{{ acc.name }}</div>
<div class="login__role">{{ acc.desc }}</div>
</div>
<van-loading v-if="loading === acc.code" size="16" />
</div>
</div>
<van-cell-group inset class="login__ops">
<van-cell title="进入红娘工作台" is-link icon="manager-o" @click="goOperator" />
</van-cell-group>
<p v-if="errorText" class="login__error">{{ errorText }}</p>
<p class="login__foot">生产环境走真实微信登录(wechat.login.provider=real)</p>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useUserStore } from '@/stores/user'
import { useAppStore } from '@/stores/app'
interface DemoAccount {
code: string
name: string
desc: string
}
// 演示账号(demo-seed-final.sql 预置用户);状态演示账号另行标注。
const accounts: DemoAccount[] = [
{ code: 'demo_openid_f01', name: '小芳', desc: '女 · 可巡游' },
{ code: 'demo_openid_f02', name: '晓燕', desc: '女 · 可巡游' },
{ code: 'demo_openid_f03', name: '婷婷', desc: '女 · 可巡游' },
{ code: 'demo_openid_f04', name: '美玲', desc: '女 · 暂停寻找' },
{ code: 'demo_openid_f06', name: '秀英', desc: '女 · 待审核' },
{ code: 'demo_openid_m01', name: '张伟', desc: '男 · 可巡游' },
{ code: 'demo_openid_m02', name: '李强', desc: '男 · 可巡游' },
{ code: 'demo_openid_m03', name: '王磊', desc: '男 · 可巡游' },
{ code: 'demo_openid_m05', name: '孙浩', desc: '男 · 可巡游' },
]
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const userStore = useUserStore()
const appStore = useAppStore()
const loading = ref('')
const current = ref('')
const errorText = ref('')
onMounted(async () => {
if (route.query.clear === '1') {
authStore.clearSession()
userStore.userState = null
}
const as = typeof route.query.as === 'string' ? route.query.as : ''
if (as && accounts.some((a) => a.code === as)) {
await onLogin(accounts.find((a) => a.code === as)!)
}
})
async function onLogin(acc: DemoAccount) {
if (loading.value) return
loading.value = acc.code
current.value = acc.code
errorText.value = ''
try {
await authStore.loginByDemoCode(acc.code)
await Promise.all([userStore.fetchStatus(), appStore.fetchCapabilities()])
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/home'
router.replace(redirect)
} catch (err: any) {
errorText.value = err?.message || '登录失败,请确认后端与演示数据已就绪'
current.value = ''
} finally {
loading.value = ''
}
}
function goOperator() {
router.push('/operator/login')
}
</script>
<style scoped>
.login {
min-height: 100vh;
background: var(--mini-bg);
}
.login__hero {
background: var(--mini-brand-gradient);
color: #fff;
padding: 48px 24px 40px;
}
.login__brand {
font-size: 26px;
margin: 0;
letter-spacing: 2px;
}
.login__slogan {
margin: 8px 0 0;
opacity: 0.9;
}
.login__sub {
margin: 16px 0 0;
font-size: 12px;
opacity: 0.75;
}
.login__body {
padding: 20px 16px;
margin-top: -20px;
}
.login__hint {
color: var(--mini-text-secondary);
font-size: 13px;
margin: 0 4px 12px;
}
.login__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.login__card {
display: flex;
align-items: center;
gap: 10px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 12px;
box-shadow: var(--mini-shadow);
border: 1px solid transparent;
}
.login__card--active {
border-color: var(--mini-tabbar-active);
}
.login__card--loading {
opacity: 0.7;
}
.login__avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--mini-brand-gradient);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
flex: none;
}
.login__meta {
flex: 1;
min-width: 0;
}
.login__name {
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
}
.login__role {
font-size: 11px;
color: var(--mini-text-secondary);
}
.login__ops {
margin: 20px 0 0;
}
.login__error {
color: var(--mini-danger);
font-size: 13px;
margin: 12px 4px 0;
}
.login__foot {
color: var(--mini-text-muted);
font-size: 11px;
text-align: center;
margin-top: 24px;
}
</style>
+228
View File
@@ -0,0 +1,228 @@
<template>
<PageLayout title="实名认证">
<div class="vf">
<UiLoading v-if="loading" text="正在读取认证状态…" />
<UiErrorState v-else-if="error" @retry="load" />
<template v-else>
<!-- 已认证 -->
<div v-if="verified" class="vf__done">
<div class="vf__done-icon">✓</div>
<div class="vf__done-title">已完成实名认证</div>
<div class="vf__done-desc">您的身份信息已通过核验,可继续完善资料。</div>
<div class="vf__actions">
<van-button type="primary" block round @click="router.replace('/profile/basic')">
完善资料
</van-button>
</div>
</div>
<!-- 未认证表单 -->
<template v-else>
<div class="vf__hero">
<div class="vf__title">实名认证</div>
<div class="vf__subtitle">真实身份帮助我们提供更安全的公益婚恋服务</div>
</div>
<div class="card">
<van-field
v-model="realName"
label="姓名"
placeholder="请输入真实姓名"
:border="false"
maxlength="20"
@update:model-value="errorText = ''"
/>
<van-field
v-model="idCardNumber"
label="身份证号"
placeholder="请输入18位身份证号码"
maxlength="18"
@update:model-value="onIdInput"
/>
</div>
<van-checkbox v-model="agreed" class="vf__agree" icon-size="16">
我已阅读并同意《用户服务协议》和《隐私政策》
</van-checkbox>
<p v-if="errorText" class="vf__error">{{ errorText }}</p>
<div class="vf__actions">
<van-button
type="primary"
block
round
:loading="submitting"
:disabled="!agreed || submitting"
@click="onSubmit"
>
提交认证
</van-button>
</div>
<div class="vf__tips">
<div class="vf__tips-title">认证说明</div>
<div class="vf__tip">· 身份信息仅用于实名认证,不会向其他用户展示</div>
<div class="vf__tip">· 实名认证是平台安全的重要保障</div>
<div class="vf__tip">· 未满 18 周岁不能使用本平台服务</div>
</div>
</template>
</template>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { verificationService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import { useUserStore } from '@/stores/user'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const router = useRouter()
const appStore = useAppStore()
const userStore = useUserStore()
const loading = ref(true)
const error = ref(false)
const verified = ref(false)
const realName = ref('')
const idCardNumber = ref('')
const agreed = ref(false)
const submitting = ref(false)
const errorText = ref('')
function onIdInput(value: string) {
idCardNumber.value = value.replace(/\s/g, '')
errorText.value = ''
}
async function load() {
loading.value = true
error.value = false
try {
const status: any = await verificationService.getStatus()
verified.value = !!(status?.verified || status?.status === 'verified' || status?.status === 'approved')
} catch {
error.value = true
} finally {
loading.value = false
}
}
async function onSubmit() {
errorText.value = ''
if (!agreed.value) {
errorText.value = '请先阅读并同意协议'
return
}
if (!realName.value || realName.value.length < 2) {
errorText.value = '请输入真实姓名'
return
}
if (idCardNumber.value.length !== 18) {
errorText.value = '请输入正确的18位身份证号码'
return
}
submitting.value = true
try {
await verificationService.submit({ realName: realName.value, idCardNumber: idCardNumber.value })
appStore.toastSuccess('认证成功')
userStore.userState = { ...(userStore.userState || {}), verified: true }
router.replace('/profile/basic')
} catch (err: any) {
errorText.value = err?.message || '认证失败,请重试'
} finally {
submitting.value = false
}
}
onMounted(load)
</script>
<style scoped>
.vf {
padding: 16px;
}
.vf__hero {
padding: 8px 4px 16px;
}
.vf__title {
font-size: 20px;
font-weight: 600;
color: var(--mini-text-strong);
}
.vf__subtitle {
font-size: 13px;
color: var(--mini-text-secondary);
margin-top: 6px;
}
.card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
overflow: hidden;
}
.vf__agree {
margin-top: 14px;
font-size: 12px;
color: var(--mini-text-secondary);
}
.vf__error {
color: var(--mini-danger);
font-size: 13px;
margin: 12px 4px 0;
}
.vf__actions {
margin-top: 20px;
}
.vf__tips {
margin-top: 24px;
background: rgba(79, 140, 255, 0.06);
border: 1px solid rgba(79, 140, 255, 0.14);
border-radius: var(--mini-radius);
padding: 14px;
}
.vf__tips-title {
font-size: 13px;
font-weight: 600;
color: var(--mini-text-strong);
margin-bottom: 8px;
}
.vf__tip {
font-size: 12px;
color: var(--mini-text-secondary);
line-height: 1.8;
}
.vf__done {
text-align: center;
padding: 40px 16px;
}
.vf__done-icon {
width: 64px;
height: 64px;
border-radius: 50%;
margin: 0 auto 16px;
background: var(--mini-success);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 30px;
}
.vf__done-title {
font-size: 18px;
font-weight: 600;
color: var(--mini-text-strong);
}
.vf__done-desc {
font-size: 13px;
color: var(--mini-text-secondary);
margin-top: 8px;
}
</style>
+295
View File
@@ -0,0 +1,295 @@
<template>
<PageLayout title="婚恋反诈">
<div class="fraud">
<div class="fraud__hero">
<div class="fraud__eyebrow">提高警惕 · 守护真心</div>
<div class="fraud__title">婚恋反诈</div>
<div class="fraud__desc">识别风险信号,守护你的真心与财产安全</div>
</div>
<div class="fraud__body">
<UiErrorState v-if="error" message="暂时无法加载反诈内容" @retry="load" />
<UiLoading v-else-if="loading" />
<template v-else>
<UiSectionHeader title="常见风险信号" />
<div class="fraud__grid">
<div v-for="r in RISKS" :key="r.title" class="fraud__risk">
<div class="fraud__risk-icon">!</div>
<div class="fraud__risk-title">{{ r.title }}</div>
<div class="fraud__risk-desc">{{ r.description }}</div>
</div>
</div>
<UiSectionHeader title="遇到可疑情况怎么办" />
<div class="fraud__card">
<div v-for="(s, i) in STEPS" :key="s.title" class="fraud__step">
<div class="fraud__step-index">{{ i + 1 }}</div>
<div class="fraud__step-copy">
<div class="fraud__step-title">{{ s.title }}</div>
<div class="fraud__step-desc">{{ s.description }}</div>
</div>
</div>
</div>
<template v-if="articles.length">
<UiSectionHeader title="反诈科普内容" />
<div
v-for="a in articles"
:key="a.id"
class="fraud__article"
@click="router.push({ path: '/content/detail', query: { id: a.id } })"
>
<div class="fraud__article-cover">{{ (a.title || '反').slice(0, 1) }}</div>
<div class="fraud__article-info">
<div class="fraud__article-title">{{ a.title }}</div>
<div v-if="a.summary" class="fraud__article-summary">{{ a.summary }}</div>
</div>
<van-icon name="arrow" color="#9aa0b5" />
</div>
</template>
</template>
</div>
<div class="fraud__action">
<van-button class="fraud__action-btn" plain @click="requestHelp">安全求助</van-button>
<van-button class="fraud__action-btn" type="primary" @click="report">举报可疑行为</van-button>
</div>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showConfirmDialog } from 'vant'
import { contentService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiErrorState from '@/components/UiErrorState.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
const router = useRouter()
const appStore = useAppStore()
const RISKS = [
{ title: '投资博彩诱导', description: '刚认识便引导投资、博彩或下载陌生应用' },
{ title: '借款资金周转', description: '以生病、工程或临时周转为由反复借款' },
{ title: '索取隐私资料', description: '催促脱离平台并索要验证码、银行卡或证件信息' },
{ title: '冒充平台人员', description: '以认证、保证金或解冻账户为由要求转账' },
]
const STEPS = [
{ title: '立即停止资金操作', description: '不要继续转账、充值或提供验证码' },
{ title: '保存相关证据', description: '保留聊天、付款和对方账号信息' },
{ title: '及时举报或报警', description: '平台举报不会通知被举报人,紧急情况请拨打 110' },
]
const loading = ref(true)
const error = ref(false)
const articles = ref<any[]>([])
async function load() {
loading.value = true
error.value = false
try {
// 反诈栏目的内容按分类 code(anti_fraud)取,避免客户端硬编码分类 id
const catResult: any = await contentService.getCategories()
const categories: any[] = catResult?.items || catResult?.records || (Array.isArray(catResult) ? catResult : [])
const antiFraud = categories.find((c) => c.code === 'anti_fraud')
if (!antiFraud?.id) {
articles.value = []
return
}
const result: any = await contentService.getArticles({ categoryId: antiFraud.id })
articles.value = result?.items || result?.records || (Array.isArray(result) ? result : [])
} catch {
error.value = true
} finally {
loading.value = false
}
}
function report() {
router.push({ path: '/report', query: { type: 'fraud' } })
}
async function requestHelp() {
try {
await showConfirmDialog({
title: '安全求助',
message: '如有财产损失或人身危险,请立即拨打 110;非紧急问题可前往平台帮助中心。',
confirmButtonText: '拨打 110',
cancelButtonText: '帮助中心',
})
window.location.href = 'tel:110'
} catch {
router.push('/help')
}
}
onMounted(load)
</script>
<style scoped>
.fraud {
min-height: 100vh;
background: var(--mini-bg);
padding-bottom: 76px;
}
.fraud__hero {
background: var(--mini-brand-gradient);
color: #fff;
padding: 18px 16px 20px;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
}
.fraud__eyebrow {
font-size: 11px;
opacity: 0.8;
}
.fraud__title {
font-size: 19px;
font-weight: 600;
margin-top: 4px;
}
.fraud__desc {
font-size: 12px;
opacity: 0.85;
margin-top: 6px;
}
.fraud__body {
padding: 0 16px;
}
.fraud__grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.fraud__risk {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 14px;
}
.fraud__risk-icon {
width: 26px;
height: 26px;
border-radius: 8px;
background: var(--mini-danger);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-weight: 700;
}
.fraud__risk-title {
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
margin-top: 8px;
}
.fraud__risk-desc {
font-size: 12px;
color: var(--mini-text-secondary);
line-height: 1.5;
margin-top: 4px;
}
.fraud__card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 4px 14px;
}
.fraud__step {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 0;
border-bottom: 1px solid var(--mini-border);
}
.fraud__step:last-child {
border-bottom: none;
}
.fraud__step-index {
width: 24px;
height: 24px;
border-radius: 50%;
background: rgba(47, 179, 109, 0.14);
color: var(--mini-success);
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
flex: none;
}
.fraud__step-title {
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
}
.fraud__step-desc {
font-size: 12px;
color: var(--mini-text-secondary);
line-height: 1.5;
margin-top: 2px;
}
.fraud__article {
display: flex;
align-items: center;
gap: 12px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 12px;
margin-bottom: 10px;
}
.fraud__article-cover {
width: 46px;
height: 46px;
border-radius: 11px;
background: linear-gradient(135deg, #3fc98a 0%, #199e64 100%);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
flex: none;
}
.fraud__article-info {
flex: 1;
min-width: 0;
}
.fraud__article-title {
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
}
.fraud__article-summary {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 4px;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.fraud__action {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 10px;
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
background: var(--mini-surface);
box-shadow: 0 -4px 16px rgba(23, 42, 114, 0.08);
}
.fraud__action-btn {
flex: 1;
border-radius: var(--mini-radius);
}
</style>
@@ -0,0 +1,206 @@
<template>
<PageLayout title="内容详情">
<div class="cd">
<div class="cd__body">
<UiErrorState v-if="error" :message="errorMessage" @retry="load" />
<UiLoading v-else-if="loading" />
<template v-else-if="article">
<div class="cd__hero">
<div class="cd__eyebrow">善缘丹阳婚恋课堂</div>
<div class="cd__title">{{ article.title }}</div>
<div v-if="article.summary" class="cd__desc">{{ article.summary }}</div>
</div>
<div class="cd__content">
<p v-if="article.summary" class="cd__lead">{{ article.summary }}</p>
<p v-for="(line, index) in paragraphs" :key="index" class="cd__paragraph">{{ line }}</p>
<div class="cd__notice">本文为公益科普内容,不能替代专业咨询或法律意见。</div>
</div>
<UiSectionHeader title="需要进一步帮助?" />
<div class="cd__card">
<div class="cd__row" @click="router.push('/counseling')">
<div class="cd__row-icon tone-blue">咨</div>
<div class="cd__row-copy">
<div class="cd__row-title">了解婚恋咨询</div>
<div class="cd__row-desc">由咨询人员提供关系与情绪支持</div>
</div>
<van-icon name="arrow" color="#9aa0b5" />
</div>
<div class="cd__row" @click="router.push({ path: '/report', query: { type: 'content' } })">
<div class="cd__row-icon tone-amber">报</div>
<div class="cd__row-copy">
<div class="cd__row-title">举报不实内容</div>
<div class="cd__row-desc">发现虚假或违规信息可提交举报</div>
</div>
<van-icon name="arrow" color="#9aa0b5" />
</div>
</div>
</template>
</div>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { contentService } from '@/api/services'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiErrorState from '@/components/UiErrorState.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
const route = useRoute()
const router = useRouter()
const articleId = ref(String(route.query.id || ''))
const loading = ref(true)
const error = ref(false)
const errorMessage = ref('加载失败,请稍后重试')
const article = ref<any>(null)
const paragraphs = ref<string[]>([])
async function load() {
if (!articleId.value) {
loading.value = false
error.value = true
errorMessage.value = '未指定内容'
return
}
loading.value = true
error.value = false
try {
const result: any = await contentService.getArticleDetail(articleId.value)
if (!result) {
loading.value = false
error.value = true
errorMessage.value = '内容不存在或已下架'
return
}
article.value = result
paragraphs.value = String(result.content || '')
.split(/\n+/)
.map((line) => line.trim())
.filter(Boolean)
} catch {
error.value = true
errorMessage.value = '加载失败,请稍后重试'
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<style scoped>
.cd {
min-height: 100vh;
background: var(--mini-bg);
padding-bottom: 24px;
}
.cd__body {
padding: 0 16px;
}
.cd__hero {
background: var(--mini-brand-gradient);
color: #fff;
padding: 18px 16px;
border-radius: var(--mini-radius);
margin-top: 12px;
}
.cd__eyebrow {
font-size: 11px;
opacity: 0.8;
}
.cd__title {
font-size: 18px;
font-weight: 600;
margin-top: 6px;
line-height: 1.4;
}
.cd__desc {
font-size: 12px;
opacity: 0.85;
margin-top: 8px;
line-height: 1.6;
}
.cd__content {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 14px 16px;
margin-top: 12px;
}
.cd__lead {
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
line-height: 1.7;
margin: 0;
}
.cd__paragraph {
font-size: 14px;
color: var(--mini-text);
line-height: 1.8;
margin: 18px 0 0;
}
.cd__notice {
font-size: 12px;
color: var(--mini-text-secondary);
line-height: 1.6;
background: rgba(245, 166, 35, 0.12);
border-radius: var(--mini-radius);
padding: 12px 14px;
margin-top: 20px;
}
.cd__card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
overflow: hidden;
}
.cd__row {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
border-bottom: 1px solid var(--mini-border);
}
.cd__row:last-child {
border-bottom: none;
}
.cd__row-icon {
width: 38px;
height: 38px;
border-radius: 11px;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 600;
flex: none;
}
.cd__row-copy {
flex: 1;
min-width: 0;
}
.cd__row-title {
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
}
.cd__row-desc {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 2px;
}
.tone-blue {
background: linear-gradient(135deg, #4f8cff 0%, #2f63e0 100%);
}
.tone-amber {
background: linear-gradient(135deg, #f5a623 0%, #d98a10 100%);
}
</style>
+224
View File
@@ -0,0 +1,224 @@
<template>
<PageLayout title="婚恋课堂">
<div class="content">
<div class="content__hero">
<div class="content__eyebrow">学习成长 · 认真相遇</div>
<div class="content__title">婚恋课堂</div>
<div class="content__desc">学习成长,收获更好的自己</div>
</div>
<div class="content__cats">
<span
v-for="c in categories"
:key="c.id"
class="content__cat"
:class="{ 'content__cat--active': activeCategoryId === c.id }"
@click="select(c.id)"
>
{{ c.name }}
</span>
</div>
<div class="content__body">
<UiErrorState v-if="error" message="暂时无法加载课堂内容" @retry="load" />
<UiLoading v-else-if="loading" />
<template v-else>
<div v-if="articles.length">
<div
v-for="a in articles"
:key="a.id"
class="content__card"
@click="router.push({ path: '/content/detail', query: { id: a.id } })"
>
<div class="content__cover">{{ (a.title || '课').slice(0, 1) }}</div>
<div class="content__info">
<div class="content__cat-label">{{ categoryName(a) }}</div>
<div class="content__card-title">{{ a.title }}</div>
<div v-if="a.summary" class="content__summary">{{ a.summary }}</div>
<div class="content__meta">
<span>浏览 {{ a.viewCount || 0 }}</span>
<span>{{ formatDate(a.publishAt || a.createdAt) }}</span>
</div>
</div>
<van-icon name="arrow" color="#9aa0b5" />
</div>
</div>
<UiEmpty v-else description="暂无课堂内容" />
</template>
</div>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { contentService } from '@/api/services'
import { formatDate } from '@/utils/datetime'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const router = useRouter()
const categories = ref<any[]>([])
const activeCategoryId = ref('')
const articles = ref<any[]>([])
const loading = ref(true)
const error = ref(false)
function categoryName(a: any): string {
const id = a?.categoryId || ''
return categories.value.find((c) => c.id === id)?.name || '婚恋课堂'
}
function pickArticles(result: any): any[] {
return result?.items || result?.records || (Array.isArray(result) ? result : [])
}
async function load() {
loading.value = true
error.value = false
try {
const [catResult, artResult]: any = await Promise.all([
contentService.getCategories(),
contentService.getArticles(),
])
const list = catResult?.items || catResult?.records || (Array.isArray(catResult) ? catResult : [])
// 「推荐」为默认聚合项(空 id → 不传 categoryId 拉全量)
categories.value = [{ id: '', name: '推荐' }, ...list]
articles.value = pickArticles(artResult)
} catch {
error.value = true
} finally {
loading.value = false
}
}
async function select(categoryId: string) {
if (categoryId === activeCategoryId.value) return
activeCategoryId.value = categoryId
loading.value = true
error.value = false
try {
const result: any = await contentService.getArticles(categoryId ? { categoryId } : {})
articles.value = pickArticles(result)
} catch {
error.value = true
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<style scoped>
.content {
min-height: 100vh;
background: var(--mini-bg);
padding-bottom: 24px;
}
.content__hero {
background: var(--mini-brand-gradient);
color: #fff;
padding: 18px 16px 20px;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
}
.content__eyebrow {
font-size: 11px;
opacity: 0.8;
}
.content__title {
font-size: 19px;
font-weight: 600;
margin-top: 4px;
}
.content__desc {
font-size: 12px;
opacity: 0.85;
margin-top: 6px;
}
.content__cats {
display: flex;
gap: 8px;
overflow-x: auto;
padding: 12px 16px 0;
}
.content__cat {
flex: none;
font-size: 12px;
padding: 6px 14px;
border-radius: 14px;
background: var(--mini-surface);
color: var(--mini-text-secondary);
box-shadow: var(--mini-shadow);
}
.content__cat--active {
background: var(--mini-tabbar-active);
color: #fff;
font-weight: 600;
}
.content__body {
padding: 12px 16px 0;
}
.content__card {
display: flex;
align-items: center;
gap: 12px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 12px;
margin-bottom: 10px;
box-shadow: var(--mini-shadow);
}
.content__cover {
width: 56px;
height: 56px;
border-radius: 12px;
background: linear-gradient(135deg, #f5a623 0%, #e08b00 100%);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
font-weight: 600;
flex: none;
}
.content__info {
flex: 1;
min-width: 0;
}
.content__cat-label {
display: inline-block;
font-size: 10px;
color: var(--mini-tabbar-active);
background: rgba(244, 0, 90, 0.08);
padding: 1px 8px;
border-radius: 8px;
}
.content__card-title {
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
margin-top: 4px;
}
.content__summary {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 4px;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.content__meta {
display: flex;
gap: 12px;
font-size: 11px;
color: var(--mini-text-muted);
margin-top: 6px;
}
</style>
@@ -0,0 +1,256 @@
<template>
<PageLayout title="婚恋咨询">
<div class="couns">
<div class="couns__hero">
<div class="couns__eyebrow">专业陪伴 · 严格保密</div>
<div class="couns__title">婚恋咨询</div>
<div class="couns__desc">倾听你的心声,陪你梳理关系与情绪困扰</div>
</div>
<div class="couns__body">
<UiErrorState v-if="error" @retry="load" />
<UiLoading v-else-if="loading" />
<template v-else>
<div class="couns__status">
<span class="couns__status-label">服务状态</span>
<span class="couns__status-value" :class="{ 'couns__status-value--off': !serviceEnabled }">
{{ serviceEnabled ? '可预约' : disabledReason || '服务暂不可用' }}
</span>
</div>
<UiSectionHeader title="我们可以提供的帮助" />
<div v-if="services.length" class="couns__card">
<div v-for="s in services" :key="s.type" class="couns__topic" @click="selectTopic(s.type)">
<div class="couns__topic-icon" :class="`tone-${toneOf(s.type)}`">{{ iconOf(s.type) }}</div>
<div class="couns__topic-copy">
<div class="couns__topic-title">{{ s.name }}</div>
<div class="couns__topic-desc">{{ s.description }}</div>
</div>
<van-icon name="arrow" color="#9aa0b5" />
</div>
</div>
<UiEmpty v-else description="暂时无法获取咨询方向" />
<div class="couns__privacy">
咨询内容按敏感信息保护。公益红娘不能查看咨询正文,咨询人员仅处理明确分配给自己的服务。
</div>
</template>
</div>
<div class="couns__action">
<van-button class="couns__action-btn" plain @click="router.push('/my/counseling')">我的咨询</van-button>
<van-button
class="couns__action-btn"
type="primary"
:disabled="!serviceEnabled"
@click="book"
>
预约咨询
</van-button>
</div>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { counselingService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
const router = useRouter()
const appStore = useAppStore()
const ICON_MAP: Record<string, string> = {
relationship_guidance: '咨',
emotional_support: '伴',
premarital_guidance: '婚',
anti_fraud_guidance: '盾',
}
const TONE_MAP: Record<string, string> = {
relationship_guidance: 'blue',
emotional_support: 'purple',
premarital_guidance: 'pink',
anti_fraud_guidance: 'green',
}
const loading = ref(true)
const error = ref(false)
const services = ref<any[]>([])
const disabledReason = ref('')
const serviceEnabled = computed(() => services.value.length > 0)
function iconOf(type: string): string {
return ICON_MAP[type] || '咨'
}
function toneOf(type: string): string {
return TONE_MAP[type] || 'blue'
}
async function load() {
loading.value = true
error.value = false
try {
const result: any = await counselingService.intro()
services.value = result?.items || result?.records || (Array.isArray(result) ? result : [])
} catch {
error.value = true
} finally {
loading.value = false
}
}
function selectTopic(type: string) {
if (!serviceEnabled.value) {
appStore.toast(disabledReason.value || '服务暂不可用')
return
}
router.push({ path: '/counseling/request', query: { type } })
}
function book() {
const type = services.value[0]?.type || 'relationship_guidance'
selectTopic(type)
}
onMounted(load)
</script>
<style scoped>
.couns {
min-height: 100vh;
background: var(--mini-bg);
padding-bottom: 76px;
}
.couns__hero {
background: var(--mini-brand-gradient);
color: #fff;
padding: 18px 16px 20px;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
}
.couns__eyebrow {
font-size: 11px;
opacity: 0.8;
}
.couns__title {
font-size: 19px;
font-weight: 600;
margin-top: 4px;
}
.couns__desc {
font-size: 12px;
opacity: 0.85;
margin-top: 6px;
}
.couns__body {
padding: 0 16px;
}
.couns__status {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 12px 14px;
margin-top: 12px;
}
.couns__status-label {
font-size: 13px;
color: var(--mini-text-secondary);
}
.couns__status-value {
font-size: 13px;
font-weight: 600;
color: var(--mini-success);
}
.couns__status-value--off {
color: var(--mini-text-muted);
}
.couns__card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
overflow: hidden;
}
.couns__topic {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
border-bottom: 1px solid var(--mini-border);
}
.couns__topic:last-child {
border-bottom: none;
}
.couns__topic-icon {
width: 42px;
height: 42px;
border-radius: 12px;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 17px;
font-weight: 600;
flex: none;
}
.couns__topic-copy {
flex: 1;
min-width: 0;
}
.couns__topic-title {
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
}
.couns__topic-desc {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 2px;
line-height: 1.5;
}
.couns__privacy {
font-size: 12px;
color: var(--mini-text-secondary);
line-height: 1.6;
background: rgba(47, 179, 109, 0.08);
border-radius: var(--mini-radius);
padding: 12px 14px;
margin-top: 16px;
}
.couns__action {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 10px;
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
background: var(--mini-surface);
box-shadow: 0 -4px 16px rgba(23, 42, 114, 0.08);
}
.couns__action-btn {
flex: 1;
border-radius: var(--mini-radius);
}
.tone-blue {
background: linear-gradient(135deg, #4f8cff 0%, #2f63e0 100%);
}
.tone-purple {
background: linear-gradient(135deg, #b06cff 0%, #8a3ee0 100%);
}
.tone-pink {
background: linear-gradient(135deg, #ff7ab0 0%, #e0559a 100%);
}
.tone-green {
background: linear-gradient(135deg, #3fc98a 0%, #199e64 100%);
}
</style>
@@ -0,0 +1,211 @@
<template>
<PageLayout title="预约咨询">
<div class="creq">
<div class="creq__hero">
<div class="creq__eyebrow">专业陪伴 · 严格保密</div>
<div class="creq__title">提交咨询申请</div>
<div class="creq__desc">提交后工作人员将在 1-3 个工作日内与你联系。</div>
</div>
<div class="creq__body">
<UiErrorState v-if="error" message="加载失败,请稍后重试" @retry="load" />
<UiLoading v-else-if="loading" />
<template v-else>
<div class="creq__card">
<van-field label="服务类型" :model-value="serviceTypeLabel" readonly />
<van-field
label="沟通方式"
:model-value="modeLabel"
placeholder="请选择沟通方式"
readonly
is-link
@click="chooseMode"
/>
<van-field
v-model="preferredTime"
label="期望时间"
placeholder="如:工作日晚上7点后"
maxlength="60"
/>
<van-field
v-model="summary"
label="问题描述"
type="textarea"
rows="4"
maxlength="500"
show-word-limit
placeholder="请简要描述您的需求"
/>
</div>
<div class="creq__privacy">
咨询内容按敏感信息保护,公益红娘不能查看咨询正文,咨询人员仅处理明确分配给自己的服务。
</div>
<van-button
class="creq__submit"
type="primary"
block
round
:loading="submitting"
loading-text="提交中…"
@click="submit"
>
提交申请
</van-button>
<div class="creq__link" @click="router.push('/my/counseling')">查看我的咨询</div>
</template>
</div>
</div>
<van-popup v-model:show="showModePicker" position="bottom" round>
<van-picker :columns="modeColumns" @confirm="onModeConfirm" @cancel="showModePicker = false" />
</van-popup>
</PageLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { counselingService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
/** 与后端 intro / counseling_services.service_type 白名单一致 */
const TYPE_LABEL: Record<string, string> = {
relationship_guidance: '恋爱困惑指导',
emotional_support: '情感支持',
premarital_guidance: '婚前辅导',
anti_fraud_guidance: '婚恋反诈指导',
}
/** 沟通方式(对齐 miniapp:线上视频 / 线下面谈 / 电话沟通) */
const MODES = [
{ value: 'online', label: '线上视频' },
{ value: 'offline', label: '线下面谈' },
{ value: 'phone', label: '电话沟通' },
]
const loading = ref(false)
const error = ref(false)
const submitting = ref(false)
const serviceType = ref('')
const serviceTypeLabel = ref('')
const modeIndex = ref(0)
const preferredTime = ref('')
const summary = ref('')
const modeLabel = ref(MODES[0].label)
const showModePicker = ref(false)
const modeColumns = MODES.map((m) => ({ text: m.label, value: m.value }))
async function load() {
const type = String(route.query.type || '')
serviceType.value = type || 'relationship_guidance'
serviceTypeLabel.value = TYPE_LABEL[serviceType.value] || serviceType.value
}
function chooseMode() {
showModePicker.value = true
}
function onModeConfirm(payload: any) {
const value = payload?.selectedOptions?.[0]?.value
const index = MODES.findIndex((m) => m.value === value)
if (index < 0) {
showModePicker.value = false
return
}
modeIndex.value = index
modeLabel.value = MODES[index].label
showModePicker.value = false
}
async function submit() {
if (!summary.value.trim()) {
appStore.toast('请简要描述您的困扰')
return
}
if (submitting.value) return
submitting.value = true
try {
await counselingService.apply({
serviceType: serviceType.value,
preferredMode: MODES[modeIndex.value].value,
preferredTime: preferredTime.value,
summary: summary.value.trim(),
requestSource: 'user_self',
})
appStore.toastSuccess('申请已提交')
router.replace('/my/counseling')
} catch (err: any) {
appStore.toastError(err?.message || '提交失败,请稍后重试')
} finally {
submitting.value = false
}
}
onMounted(load)
</script>
<style scoped>
.creq {
min-height: 100vh;
background: var(--mini-bg);
padding-bottom: 24px;
}
.creq__hero {
background: var(--mini-brand-gradient);
color: #fff;
padding: 18px 16px 20px;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
}
.creq__eyebrow {
font-size: 11px;
opacity: 0.8;
}
.creq__title {
font-size: 18px;
font-weight: 600;
margin-top: 4px;
}
.creq__desc {
font-size: 12px;
opacity: 0.85;
margin-top: 6px;
}
.creq__body {
padding: 0 16px;
}
.creq__card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
overflow: hidden;
margin-top: 12px;
}
.creq__privacy {
font-size: 12px;
color: var(--mini-text-secondary);
line-height: 1.6;
background: rgba(47, 179, 109, 0.08);
border-radius: var(--mini-radius);
padding: 12px 14px;
margin-top: 12px;
}
.creq__submit {
margin-top: 20px;
}
.creq__link {
text-align: center;
font-size: 13px;
color: var(--mini-text-secondary);
margin-top: 14px;
}
</style>
@@ -0,0 +1,318 @@
<template>
<PageLayout title="我的咨询">
<div class="mc">
<div class="mc__hero">
<div class="mc__eyebrow">我的公益服务</div>
<div class="mc__title">我的咨询</div>
<div class="mc__desc">咨询与婚前辅导申请记录,正文按敏感信息保护。</div>
</div>
<div class="mc__body">
<UiErrorState v-if="error" message="加载失败,请稍后重试" @retry="load" />
<UiLoading v-else-if="loading" />
<template v-else>
<div v-if="items.length">
<div v-for="s in items" :key="s.id" class="mc__card" @click="openDetail(s)">
<div class="mc__card-head">
<span class="mc__card-title">{{ typeLabel(s.serviceType) }}</span>
<UiTag :type="statusTone(s.status)">{{ statusLabel(s.status) }}</UiTag>
</div>
<div class="mc__meta">申请于 {{ formatDateTime(s.createdAt) || '—' }}</div>
<div v-if="s.scheduledStartAt" class="mc__meta">
预约时间:{{ formatDateTime(s.scheduledStartAt) }}
</div>
<div class="mc__actions">
<van-button
v-if="canConfirmCompanion(s)"
size="small"
type="primary"
@click.stop="confirmCompanion(s)"
>
确认参与
</van-button>
<van-button v-if="canCancel(s)" size="small" plain @click.stop="cancel(s)">取消申请</van-button>
</div>
</div>
</div>
<UiEmpty v-else description="暂无咨询记录">
<van-button type="primary" round size="small" @click="router.push('/counseling')">
去申请咨询
</van-button>
</UiEmpty>
<div class="mc__notice">咨询与公益红娘记录彼此分离;工作人员只能查看职责范围内的信息。</div>
</template>
</div>
</div>
<van-popup v-model:show="showDetail" position="bottom" round>
<div class="mc__detail">
<div class="mc__detail-title">咨询详情</div>
<UiLoading v-if="detailLoading" />
<template v-else-if="detail">
<div class="mc__detail-row">
<span class="mc__detail-label">服务类型</span>
<span class="mc__detail-value">{{ typeLabel(detail.serviceType) }}</span>
</div>
<div class="mc__detail-row">
<span class="mc__detail-label">当前状态</span>
<span class="mc__detail-value">{{ statusLabel(detail.status) }}</span>
</div>
<div class="mc__detail-row">
<span class="mc__detail-label">沟通方式</span>
<span class="mc__detail-value">{{ modeLabel(detail.preferredServiceMode) || '待确认' }}</span>
</div>
<div class="mc__detail-row">
<span class="mc__detail-label">期望时间</span>
<span class="mc__detail-value">{{ detail.preferredTime || '待确认' }}</span>
</div>
<div v-if="detail.userRequestSummary" class="mc__detail-row">
<span class="mc__detail-label">问题描述</span>
<span class="mc__detail-value">{{ detail.userRequestSummary }}</span>
</div>
<div v-if="detail.scheduledStartAt" class="mc__detail-row">
<span class="mc__detail-label">已排期</span>
<span class="mc__detail-value">{{ formatDateTime(detail.scheduledStartAt) }}</span>
</div>
<div class="mc__detail-row">
<span class="mc__detail-label">申请时间</span>
<span class="mc__detail-value">{{ formatDateTime(detail.createdAt) || '—' }}</span>
</div>
</template>
<UiErrorState v-else message="咨询详情加载失败" @retry="reloadDetail" />
</div>
</van-popup>
</PageLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showConfirmDialog } from 'vant'
import { counselingService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import { formatDateTime } from '@/utils/datetime'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
import UiTag from '@/components/UiTag.vue'
const router = useRouter()
const appStore = useAppStore()
const TYPE_LABEL: Record<string, string> = {
relationship_guidance: '恋爱困惑指导',
emotional_support: '情感支持',
premarital_guidance: '婚前辅导',
anti_fraud_guidance: '婚恋反诈指导',
premarital: '婚前辅导',
counseling: '婚恋咨询',
}
const STATUS_LABEL: Record<string, string> = {
pending: '待处理',
assigned: '已分配',
scheduled: '已排期',
in_progress: '服务中',
completed: '已完成',
cancelled: '已取消',
closed: '已关闭',
}
const MODE_LABEL: Record<string, string> = {
online: '线上视频',
offline: '线下面谈',
phone: '电话沟通',
}
const loading = ref(true)
const error = ref(false)
const items = ref<any[]>([])
const showDetail = ref(false)
const detailLoading = ref(false)
const detail = ref<any>(null)
const currentId = ref('')
function typeLabel(type: string): string {
return TYPE_LABEL[type] || type || ''
}
function statusLabel(status: string): string {
return STATUS_LABEL[status] || status || ''
}
function modeLabel(mode: string): string {
return MODE_LABEL[mode] || ''
}
function statusTone(status: string): 'primary' | 'success' | 'warning' | 'danger' | 'info' {
if (status === 'completed') return 'success'
if (status === 'cancelled' || status === 'closed') return 'info'
if (status === 'pending') return 'warning'
return 'primary'
}
function canCancel(s: any): boolean {
return s.status === 'pending' || s.status === 'assigned' || s.status === 'scheduled'
}
/** INV-005:仅婚前辅导 + 受邀伴侣本人 + 尚未确认时可操作 */
function canConfirmCompanion(s: any): boolean {
const isPremarital = s.serviceType === 'premarital_guidance' || s.serviceType === 'premarital'
return isPremarital && !!s.companionUserId && !s.userConfirmedAt
}
async function load() {
loading.value = true
error.value = false
try {
const result: any = await counselingService.myServices()
items.value = result?.items || result?.records || (Array.isArray(result) ? result : [])
} catch {
error.value = true
} finally {
loading.value = false
}
}
async function openDetail(s: any) {
if (!s?.id) return
currentId.value = s.id
showDetail.value = true
await reloadDetail()
}
async function reloadDetail() {
if (!currentId.value) return
detailLoading.value = true
detail.value = null
try {
detail.value = await counselingService.detail(currentId.value)
} finally {
detailLoading.value = false
}
}
async function cancel(s: any) {
try {
await showConfirmDialog({ title: '取消申请', message: '确认取消本次咨询申请?' })
} catch {
return
}
try {
await counselingService.cancel(s.id, '用户主动取消')
appStore.toastSuccess('已取消')
load()
} catch (err: any) {
appStore.toastError(err?.message || '取消失败,请稍后重试')
}
}
async function confirmCompanion(s: any) {
try {
await showConfirmDialog({ title: '确认参与婚前辅导', message: '确认作为伴侣参与本次婚前辅导?' })
} catch {
return
}
try {
await counselingService.confirmCompanion(s.id)
appStore.toastSuccess('已确认参与')
load()
} catch (err: any) {
appStore.toastError(err?.message || '确认失败,请稍后重试')
}
}
onMounted(load)
</script>
<style scoped>
.mc {
min-height: 100vh;
background: var(--mini-bg);
padding-bottom: 24px;
}
.mc__hero {
background: var(--mini-brand-gradient);
color: #fff;
padding: 18px 16px 20px;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
}
.mc__eyebrow {
font-size: 11px;
opacity: 0.8;
}
.mc__title {
font-size: 19px;
font-weight: 600;
margin-top: 4px;
}
.mc__desc {
font-size: 12px;
opacity: 0.85;
margin-top: 6px;
}
.mc__body {
padding: 0 16px;
}
.mc__card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 14px;
margin-top: 12px;
}
.mc__card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.mc__card-title {
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
}
.mc__meta {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 6px;
}
.mc__actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
.mc__notice {
font-size: 12px;
color: var(--mini-text-secondary);
line-height: 1.6;
background: rgba(47, 179, 109, 0.08);
border-radius: var(--mini-radius);
padding: 12px 14px;
margin-top: 16px;
}
.mc__detail {
padding: 18px 16px calc(18px + env(safe-area-inset-bottom));
}
.mc__detail-title {
font-size: 16px;
font-weight: 600;
color: var(--mini-text-strong);
margin-bottom: 8px;
}
.mc__detail-row {
display: flex;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid var(--mini-border);
}
.mc__detail-label {
flex: none;
width: 72px;
font-size: 13px;
color: var(--mini-text-secondary);
}
.mc__detail-value {
flex: 1;
font-size: 13px;
color: var(--mini-text-strong);
line-height: 1.5;
}
</style>
+425
View File
@@ -0,0 +1,425 @@
<template>
<div class="discover">
<!-- 搜索栏 -->
<van-search
v-model="keywordInput"
shape="round"
placeholder="搜索昵称 / 关键词"
@search="onSearch"
@clear="onSearch"
/>
<!-- 快捷筛选条 -->
<div class="discover__chips">
<span
class="discover__chip"
:class="{ 'discover__chip--active': applied.regionCode === '321181' }"
@click="toggleLocal"
>
丹阳本地
</span>
<span
class="discover__chip"
:class="{ 'discover__chip--active': hasActiveFilter }"
@click="showFilter = true"
>
<van-icon name="filter-o" /> 筛选
</span>
</div>
<!-- 列表 -->
<div class="discover__body">
<UiErrorState v-if="error" @retry="reload" />
<template v-else>
<UiLoading v-if="firstLoading" />
<template v-else>
<van-list
v-model:loading="loadingMore"
:finished="finished"
finished-text="没有更多了"
@load="onLoadMore"
>
<UiUserCard
v-for="u in users"
:key="u.userId"
:user="u"
show-actions
:loading="actionLoading === u.userId"
@tap="goDetail"
@like="onLike"
@pass="onPass"
/>
</van-list>
<UiEmpty v-if="!users.length" description="没有符合筛选条件的人" />
</template>
</template>
</div>
<!-- 筛选面板 -->
<van-popup v-model:show="showFilter" position="bottom" round :style="{ maxHeight: '80%' }">
<div class="filter">
<div class="filter__title">筛选条件</div>
<div class="filter__group">
<div class="filter__label">所在地区</div>
<div class="filter__chips">
<span
v-for="r in regions"
:key="r.code"
class="filter__chip"
:class="{ 'filter__chip--active': temp.regionCode === r.code }"
@click="toggleTemp('regionCode', r.code)"
>
{{ r.label }}
</span>
</div>
</div>
<div class="filter__group">
<div class="filter__label">年龄范围</div>
<div class="filter__range">
<van-field v-model="temp.minAge" type="digit" label="" placeholder="最小" />
<span class="filter__range-sep">—</span>
<van-field v-model="temp.maxAge" type="digit" label="" placeholder="最大" />
</div>
</div>
<div class="filter__group">
<div class="filter__label">学历</div>
<div class="filter__chips">
<span
v-for="e in educations"
:key="e.code"
class="filter__chip"
:class="{ 'filter__chip--active': temp.educationCode === e.code }"
@click="toggleTemp('educationCode', e.code)"
>
{{ e.label }}
</span>
</div>
</div>
<div class="filter__group">
<div class="filter__label">婚姻状况</div>
<div class="filter__chips">
<span
v-for="m in maritalStatuses"
:key="m.code"
class="filter__chip"
:class="{ 'filter__chip--active': temp.maritalStatus === m.code }"
@click="toggleTemp('maritalStatus', m.code)"
>
{{ m.label }}
</span>
</div>
</div>
<div class="filter__actions">
<van-button block plain @click="resetFilter">重置</van-button>
<van-button block type="primary" @click="applyFilter">应用筛选</van-button>
</div>
</div>
</van-popup>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { useMatchStore } from '@/stores/match'
import { useAppStore } from '@/stores/app'
import UiUserCard from '@/components/UiUserCard.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const router = useRouter()
const matchStore = useMatchStore()
const appStore = useAppStore()
const DEFAULT_MIN_AGE = '18'
const DEFAULT_MAX_AGE = '60'
const PAGE_SIZE = 20
const regions = [
{ code: '321181', label: '丹阳' },
{ code: '321100', label: '镇江' },
{ code: '320100', label: '南京' },
]
const educations = [
{ code: 'high_school', label: '高中及以下' },
{ code: 'college', label: '大专' },
{ code: 'bachelor', label: '本科' },
{ code: 'master', label: '硕士及以上' },
]
const maritalStatuses = [
{ code: 'never_married', label: '未婚' },
{ code: 'divorced', label: '离异' },
]
const keywordInput = ref('')
const applied = reactive({
keyword: '',
regionCode: '',
minAge: DEFAULT_MIN_AGE,
maxAge: DEFAULT_MAX_AGE,
educationCode: '',
maritalStatus: '',
})
const temp = reactive({ ...applied })
const users = ref<any[]>([])
const page = ref(1)
const firstLoading = ref(false)
const loadingMore = ref(false)
const finished = ref(false)
const error = ref(false)
const actionLoading = ref('')
const showFilter = ref(false)
const hasActiveFilter = computed(
() =>
!!applied.keyword ||
!!applied.regionCode ||
applied.minAge !== DEFAULT_MIN_AGE ||
applied.maxAge !== DEFAULT_MAX_AGE ||
!!applied.educationCode ||
!!applied.maritalStatus,
)
let storeCursor: string | null = null
function buildParams(): Record<string, any> {
const params: Record<string, any> = { pageSize: PAGE_SIZE, page: page.value }
if (applied.keyword) params.keyword = applied.keyword
if (applied.regionCode) params.regionCode = applied.regionCode
if (applied.minAge !== DEFAULT_MIN_AGE) params.minAge = Number(applied.minAge)
if (applied.maxAge !== DEFAULT_MAX_AGE) params.maxAge = Number(applied.maxAge)
if (applied.educationCode) params.educationCode = applied.educationCode
if (applied.maritalStatus) params.maritalStatus = applied.maritalStatus
return params
}
async function fetchPage(reset: boolean) {
if (reset) {
page.value = 1
storeCursor = null
finished.value = false
firstLoading.value = true
error.value = false
}
try {
const result: any = await matchStore.discover(buildParams())
const items: any[] = result?.items || result?.records || []
if (reset) users.value = items
else users.value = [...users.value, ...items]
const totalPages = result?.totalPages || 0
const nextCursor = result?.nextCursor
if (nextCursor) {
// 游标分页:后端给出 nextCursor 时以游标续页
finished.value = false
storeCursor = nextCursor
} else if (page.value >= totalPages || items.length < PAGE_SIZE) {
finished.value = true
} else {
page.value += 1
}
} catch (err: any) {
if (reset) error.value = true
else finished.value = true
appStore.toastError(err?.message || '加载失败')
} finally {
firstLoading.value = false
loadingMore.value = false
}
}
function reload() {
fetchPage(true)
}
function onLoadMore() {
if (finished.value || firstLoading.value) return
loadingMore.value = true
if (storeCursor) {
// 使用游标续页
;(async () => {
try {
const result: any = await matchStore.discover({ ...buildParams(), cursor: storeCursor })
const items: any[] = result?.items || result?.records || []
users.value = [...users.value, ...items]
storeCursor = result?.nextCursor || null
if (!storeCursor && items.length < PAGE_SIZE) finished.value = true
} catch (err: any) {
finished.value = true
appStore.toastError(err?.message || '加载更多失败')
} finally {
loadingMore.value = false
}
})()
return
}
fetchPage(false)
}
function onSearch() {
applied.keyword = (keywordInput.value || '').trim()
reload()
}
function toggleLocal() {
applied.regionCode = applied.regionCode === '321181' ? '' : '321181'
temp.regionCode = applied.regionCode
reload()
}
function toggleTemp(field: 'regionCode' | 'educationCode' | 'maritalStatus', code: string) {
temp[field] = temp[field] === code ? '' : code
}
function applyFilter() {
applied.keyword = (keywordInput.value || '').trim()
applied.regionCode = temp.regionCode
applied.minAge = temp.minAge || DEFAULT_MIN_AGE
applied.maxAge = temp.maxAge || DEFAULT_MAX_AGE
applied.educationCode = temp.educationCode
applied.maritalStatus = temp.maritalStatus
showFilter.value = false
storeCursor = null
reload()
}
function resetFilter() {
temp.keyword = ''
temp.regionCode = ''
temp.minAge = DEFAULT_MIN_AGE
temp.maxAge = DEFAULT_MAX_AGE
temp.educationCode = ''
temp.maritalStatus = ''
keywordInput.value = ''
applied.keyword = ''
applied.regionCode = ''
applied.minAge = DEFAULT_MIN_AGE
applied.maxAge = DEFAULT_MAX_AGE
applied.educationCode = ''
applied.maritalStatus = ''
showFilter.value = false
storeCursor = null
reload()
}
function goDetail(u: any) {
if (u?.userId) router.push({ path: '/profile/detail', query: { id: u.userId } })
}
async function onLike(u: any) {
if (!u?.userId || actionLoading.value) return
actionLoading.value = u.userId
try {
const result: any = await matchStore.like(u.userId)
users.value = users.value.filter((x) => x.userId !== u.userId)
appStore.toastSuccess(result?.mutualMatch ? '双向意向匹配成功' : '已表达喜欢')
} catch (err: any) {
appStore.toastError(err?.message || '操作失败')
} finally {
actionLoading.value = ''
}
}
async function onPass(u: any) {
if (!u?.userId || actionLoading.value) return
actionLoading.value = u.userId
try {
await matchStore.pass(u.userId)
users.value = users.value.filter((x) => x.userId !== u.userId)
appStore.toastSuccess('已略过')
} catch (err: any) {
appStore.toastError(err?.message || '操作失败')
} finally {
actionLoading.value = ''
}
}
onMounted(reload)
</script>
<style scoped>
.discover {
min-height: 100vh;
background: var(--mini-bg);
}
.discover__chips {
display: flex;
gap: 8px;
padding: 0 16px 8px;
}
.discover__chip {
font-size: 12px;
padding: 5px 12px;
border-radius: 14px;
background: var(--mini-surface);
color: var(--mini-text-secondary);
border: 1px solid var(--mini-border);
}
.discover__chip--active {
color: var(--mini-tabbar-active);
border-color: var(--mini-tabbar-active);
background: rgba(244, 0, 90, 0.06);
}
.discover__body {
padding: 4px 16px 16px;
}
.filter {
padding: 20px 16px 24px;
}
.filter__title {
font-size: 16px;
font-weight: 600;
color: var(--mini-text-strong);
margin-bottom: 16px;
}
.filter__group {
margin-bottom: 18px;
}
.filter__label {
font-size: 13px;
color: var(--mini-text-secondary);
margin-bottom: 8px;
}
.filter__chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.filter__chip {
font-size: 12px;
padding: 6px 14px;
border-radius: 14px;
background: var(--mini-bg);
color: var(--mini-text);
border: 1px solid var(--mini-border);
}
.filter__chip--active {
color: var(--mini-tabbar-active);
border-color: var(--mini-tabbar-active);
background: rgba(244, 0, 90, 0.06);
}
.filter__range {
display: flex;
align-items: center;
gap: 10px;
}
.filter__range :deep(.van-field) {
flex: 1;
background: var(--mini-bg);
border-radius: 8px;
}
.filter__range-sep {
color: var(--mini-text-muted);
}
.filter__actions {
display: flex;
gap: 12px;
margin-top: 8px;
}
</style>
+878
View File
@@ -0,0 +1,878 @@
<template>
<div class="home">
<!-- 品牌区(夜间渐变) -->
<div class="home__hero">
<div class="home__brand">
<div class="home__brand-lock">
<div class="home__logo">善</div>
<div>
<div class="home__brand-name">善缘丹阳</div>
<div class="home__brand-slogan">公益婚恋服务平台</div>
</div>
</div>
<div class="home__notice" @click="router.push('/notifications')">
<van-icon name="bell" size="18" />
<span>通知</span>
</div>
</div>
<!-- 用户状态条 -->
<div class="home__status">
<template v-if="showOnboarding">
<div class="home__status-main">
<div class="home__status-title">完善资料,开启安心相遇</div>
<div class="home__status-desc">{{ onboardingMessage }}</div>
</div>
<van-button size="small" type="primary" round @click="runOnboardingAction">
{{ onboardingAction.text }}
</van-button>
</template>
<template v-else>
<span
v-for="c in statusChecks"
:key="c.label"
class="home__check"
:class="{ 'home__check--done': c.done }"
>
{{ c.done ? '已完成' : '待完成' }} · {{ c.label }}
</span>
</template>
</div>
<!-- Banner(仅内部跳转,AGENTS 第 16 条) -->
<div class="home__banner">
<van-swipe v-if="banners.length" :autoplay="4500" indicator-color="#FF4F91" class="home__swipe">
<van-swipe-item v-for="b in banners" :key="b.id">
<div class="home__banner-card" @click="onBannerTap(b)">
<div class="home__banner-cover">{{ (b.title || '善').slice(0, 1) }}</div>
<div class="home__banner-copy">
<div class="home__banner-kicker">公益 · 本地 · 真实 · 安全</div>
<div class="home__banner-title">{{ b.title }}</div>
<div class="home__banner-sub">{{ b.subtitle }}</div>
</div>
</div>
</van-swipe-item>
</van-swipe>
<div v-else class="home__banner-card">
<div class="home__banner-cover">缘</div>
<div class="home__banner-copy">
<div class="home__banner-kicker">公益 · 本地 · 真实 · 安全</div>
<div class="home__banner-title">在丹阳,遇见更好的你</div>
<div class="home__banner-sub">认真认识,温暖相伴</div>
</div>
</div>
</div>
</div>
<!-- 8 个核心入口(顺序固定,AGENTS 第 14/15 条) -->
<div class="home__entries">
<div
v-for="e in quickEntries"
:key="e.code"
class="home__entry"
:class="{ 'home__entry--disabled': !e.enabled }"
@click="onEntryTap(e)"
>
<div class="home__entry-icon" :class="`tone-${toneOf(e.code)}`">{{ (e.label || '善').slice(0, 1) }}</div>
<div class="home__entry-label">{{ e.label }}</div>
<div class="home__entry-sub">{{ subOf(e.code) }}</div>
</div>
</div>
<div class="home__body">
<UiErrorState v-if="error" @retry="load" />
<UiLoading v-else-if="loading" />
<template v-else>
<!-- 今日推荐 -->
<UiSectionHeader title="今日推荐" more-text="查看更多" @more="router.push('/discover')" />
<div v-if="recommendations.length" class="home__rec">
<div v-for="r in recommendations" :key="r.userId" class="home__rec-row">
<UiAvatar :name="r.displayName || '缘'" :src="r.avatarUrl" :size="48" />
<div
class="home__rec-main"
@click="router.push({ path: '/profile/detail', query: { id: r.userId } })"
>
<div class="home__rec-name">{{ r.displayName || '推荐对象' }}</div>
<div class="home__rec-meta">
<span v-if="r.age">{{ r.age }}岁</span>
<span v-if="r.heightCm">{{ r.heightCm }}cm</span>
<span v-if="r.regionName">{{ r.regionName }}</span>
</div>
<div v-if="r.recommendationReasons && r.recommendationReasons.length" class="home__rec-reasons">
{{ r.recommendationReasons.slice(0, 3).join(' · ') }}
</div>
</div>
<div class="home__rec-actions">
<van-button size="mini" type="primary" :loading="actionLoading === r.userId" @click.stop="onLike(r)">
喜欢
</van-button>
<van-button size="mini" plain @click.stop="onPass(r)">略过</van-button>
</div>
</div>
</div>
<UiEmpty v-else description="暂时没有完全符合双方条件的人" />
<!-- 善缘公益服务 -->
<UiSectionHeader title="善缘公益服务" />
<div v-if="serviceRecommendations.length" class="home__rail">
<div
v-for="s in serviceRecommendations"
:key="s.type"
class="home__mini"
@click="onServiceTap(s.type)"
>
<div class="home__mini-cover" :class="`tone-${toneOf(s.type)}`">{{ (s.title || '善').slice(0, 1) }}</div>
<div class="home__mini-title">{{ s.title }}</div>
<div class="home__mini-sub">{{ s.ctaText }}</div>
</div>
</div>
<div v-else class="home__empty-line">暂无待推荐的公益服务</div>
<!-- 近期公益活动 -->
<UiSectionHeader title="近期公益活动" more-text="全部活动" @more="router.push('/activity')" />
<div v-if="activities.length" class="home__rail">
<div
v-for="a in activities"
:key="a.id"
class="home__act"
@click="router.push({ path: '/activity/detail', query: { id: a.id } })"
>
<div class="home__act-cover">{{ (a.title || '活').slice(0, 1) }}</div>
<div class="home__act-info">
<div class="home__act-tag">公益活动</div>
<div class="home__act-title">{{ a.title }}</div>
<div class="home__act-meta">{{ a.locationName }}</div>
</div>
</div>
</div>
<div v-else class="home__empty-line">近期暂无可报名活动</div>
<!-- 善缘服务站 -->
<UiSectionHeader title="善缘服务站" />
<div v-if="serviceLocation" class="home__station" @click="router.push('/service-station')">
<div class="home__station-icon">站</div>
<div class="home__station-info">
<div class="home__station-name">{{ serviceLocation.name }}</div>
<div class="home__station-desc">
{{ serviceLocation.shortName || serviceLocation.address }}
</div>
<div v-if="serviceLocation.services && serviceLocation.services.length" class="home__station-tags">
<UiTag v-for="s in serviceLocation.services" :key="s" type="primary">{{ s }}</UiTag>
</div>
</div>
<van-icon name="arrow" color="#9aa0b5" />
</div>
<div v-else class="home__empty-line" @click="router.push('/service-station')">
查看线下公益服务阵地
</div>
<!-- 婚恋课堂 -->
<UiSectionHeader title="婚恋课堂" more-text="更多内容" @more="router.push('/content')" />
<div v-if="articles.length" class="home__articles">
<div
v-for="art in articles"
:key="art.id"
class="home__article"
@click="router.push({ path: '/content/detail', query: { id: art.id } })"
>
<div class="home__article-cover">{{ (art.title || '课').slice(0, 1) }}</div>
<div class="home__article-main">
<div class="home__article-title">{{ art.title }}</div>
<div class="home__article-meta">善缘课堂 · 公益内容</div>
</div>
<van-icon name="arrow" color="#9aa0b5" />
</div>
</div>
<div v-else class="home__empty-line">暂无新内容,稍后再来看看</div>
<!-- 善缘安全提醒 -->
<UiSectionHeader title="善缘安全提醒" />
<div class="home__safety" @click="router.push('/anti-fraud')">
<div class="home__safety-icon">盾</div>
<div class="home__safety-text">{{ safetyNotice.text }}</div>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { homeService, recommendationService } from '@/api/services'
import { useMatchStore } from '@/stores/match'
import { useAppStore } from '@/stores/app'
import { useUserStore } from '@/stores/user'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
import UiAvatar from '@/components/UiAvatar.vue'
import UiTag from '@/components/UiTag.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const router = useRouter()
const matchStore = useMatchStore()
const appStore = useAppStore()
const userStore = useUserStore()
/** 入口 code → H5 路由(顺序固定,仅做映射) */
const ENTRY_ROUTES: Record<string, string> = {
find_object: '/discover',
matchmaker: '/matchmaker',
activities: '/activity',
counseling: '/counseling',
classroom: '/content',
premarital: '/premarital',
anti_fraud: '/anti-fraud',
service_station: '/service-station',
}
/** serviceRecommendation.type 也复用同一映射(matchmaker/counseling/premarital) */
const ENTRY_TONES: Record<string, string> = {
find_object: 'rose',
matchmaker: 'orange',
activities: 'violet',
counseling: 'blue',
classroom: 'purple',
premarital: 'pink',
anti_fraud: 'green',
service_station: 'amber',
}
const ENTRY_SUBS: Record<string, string> = {
find_object: '真实邂逅',
matchmaker: '官方牵线',
activities: '线下联谊',
counseling: '专业指导',
classroom: '提升自我',
premarital: '幸福起航',
anti_fraud: '守护真爱',
service_station: '就在身边',
}
/** 固定 8 入口默认值(服务端未返回时兜底;顺序不可变更) */
const DEFAULT_ENTRIES = [
{ code: 'find_object', label: '找对象', enabled: true },
{ code: 'matchmaker', label: '公益红娘', enabled: true },
{ code: 'activities', label: '公益活动', enabled: true },
{ code: 'counseling', label: '婚恋咨询', enabled: true },
{ code: 'classroom', label: '婚恋课堂', enabled: true },
{ code: 'premarital', label: '婚前辅导', enabled: true },
{ code: 'anti_fraud', label: '婚恋反诈', enabled: true },
{ code: 'service_station', label: '善缘服务站', enabled: true },
]
const loading = ref(true)
const error = ref(false)
const actionLoading = ref('')
const home = ref<any>(null)
const banners = computed(() => home.value?.banners || [])
const recommendations = computed<any[]>(() => home.value?.recommendations || [])
const serviceRecommendations = computed<any[]>(() => home.value?.serviceRecommendations || [])
const activities = computed<any[]>(() => home.value?.activities || [])
const articles = computed<any[]>(() => home.value?.articles || [])
const serviceLocation = computed(() => home.value?.serviceLocation || null)
const safetyNotice = computed(
() =>
home.value?.safetyNotice || {
text: '实名认证和人工审核可以提高信息真实性,交往过程中仍请谨慎涉及借款、投资和个人隐私。',
ctaText: '查看婚恋反诈指南',
},
)
const quickEntries = computed<any[]>(() => {
const server = home.value?.quickEntries
if (!Array.isArray(server) || !server.length) return DEFAULT_ENTRIES
return DEFAULT_ENTRIES.map((def) => {
const found = server.find((s: any) => s.code === def.code)
return found ? { ...def, ...found } : { ...def, enabled: false, disabledReason: '服务暂未开放' }
})
})
const userState = computed(() => home.value?.userState || null)
const statusChecks = computed(() => {
const us = userState.value
return [
{ label: '已实名', done: !!us?.verified },
{ label: '本地资格', done: us?.localEligibilityStatus === 'verified' },
{ label: '资料已审核', done: us?.profileStatus === 'approved' || us?.profileStatus === 'completed' },
]
})
const showOnboarding = computed(() => {
const us = userState.value
return !us || !us.verified || (us.profileStatus !== 'approved' && us.profileStatus !== 'completed')
})
const onboardingMessage = computed(() => {
const us = userState.value
if (!us) return '登录后即可开始寻找合适的人'
if (!us.verified) return '完成实名认证后,即可继续完善资料并使用公益婚恋服务'
if (us.profileStatus === 'pending_review') return '资料正在人工审核,请耐心等待审核结果'
if (us.localEligibilityStatus === 'not_declared') return '完成本地资格声明后即可开始寻找合适的人'
if (us.localEligibilityStatus === 'needs_evidence') return '补充本地资格佐证后即可开始寻找合适的人'
return '资料审核通过后即可开始寻找合适的人'
})
const onboardingAction = computed(() => {
const us = userState.value
if (!us) return { text: '去登录', route: '/login' }
if (us.verified !== true) return { text: '去实名认证', route: '/verification' }
if (us.localEligibilityStatus === 'not_declared' || us.localEligibilityStatus === 'needs_evidence') {
return { text: '声明本地资格', route: '/local-eligibility' }
}
if (us.profileStatus !== 'approved' && us.profileStatus !== 'completed') {
return { text: '完善资料', route: '/profile/basic' }
}
return { text: '开始寻找', route: '/discover' }
})
function toneOf(code: string): string {
return ENTRY_TONES[code] || 'blue'
}
function subOf(code: string): string {
return ENTRY_SUBS[code] || ''
}
async function load() {
loading.value = true
error.value = false
try {
home.value = await homeService.getHome()
// 同步用户状态快照,保证与守卫判定一致
const us = home.value?.userState
if (us) userStore.userState = { ...(userStore.userState || {}), ...us }
await enrichRecommendations()
} catch {
error.value = true
} finally {
loading.value = false
}
}
/**
* 推荐卡片需要姓名/年龄/身高/地区等展示字段,而后端 /home 聚合只返回
* userId + recommendationLevel + recommendationReasons,故按 userId 拉取
* 资料详情补齐展示字段;单个失败不影响首页主流程。
*/
async function enrichRecommendations() {
const list = home.value?.recommendations
if (!Array.isArray(list) || !list.length) return
await Promise.all(
list.map(async (r: any) => {
try {
const detail: any = await recommendationService.getUserDetail(r.userId)
if (!detail) return
r.displayName = detail.displayName || r.displayName
r.avatarUrl = detail.avatarUrl || r.avatarUrl
r.age = detail.age
r.heightCm = detail.heightCm
r.regionName = detail.regionName
} catch {
/* 展示层补齐失败不影响首页主流程 */
}
}),
)
}
function runOnboardingAction() {
router.push(onboardingAction.value.route)
}
function onBannerTap(b: any) {
const type = b?.targetType
const value = b?.targetValue
switch (type) {
case 'activity':
if (value) router.push({ path: '/activity/detail', query: { id: value } })
break
case 'matchmaker':
router.push('/matchmaker')
break
case 'counseling':
router.push('/counseling')
break
case 'premarital':
router.push('/premarital')
break
case 'service_location':
router.push('/service-station')
break
case 'article':
router.push({ path: '/content/detail', query: { id: value } })
break
default:
break
}
}
function onEntryTap(e: any) {
if (!e.enabled) {
appStore.toast(e.disabledReason || '暂不可使用此功能')
return
}
const route = ENTRY_ROUTES[e.code]
if (route) router.push(route)
}
function onServiceTap(type: string) {
const route = ENTRY_ROUTES[type]
if (route) router.push(route)
}
async function onLike(r: any) {
if (!r?.userId || actionLoading.value) return
actionLoading.value = r.userId
try {
const result: any = await matchStore.like(r.userId)
home.value.recommendations = recommendations.value.filter((x) => x.userId !== r.userId)
appStore.toastSuccess(result?.mutualMatch ? '双向意向匹配成功,可查看公益服务' : '已表达喜欢')
} catch (err: any) {
appStore.toastError(err?.message || '操作失败')
} finally {
actionLoading.value = ''
}
}
async function onPass(r: any) {
if (!r?.userId || actionLoading.value) return
actionLoading.value = r.userId
try {
await matchStore.pass(r.userId)
home.value.recommendations = recommendations.value.filter((x) => x.userId !== r.userId)
appStore.toastSuccess('已略过')
} catch (err: any) {
appStore.toastError(err?.message || '操作失败')
} finally {
actionLoading.value = ''
}
}
onMounted(load)
</script>
<style scoped>
.home {
min-height: 100vh;
background: var(--mini-bg);
padding-bottom: 16px;
}
/* 品牌区 */
.home__hero {
background: var(--mini-brand-gradient);
padding: 16px 16px 0;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
padding-bottom: 20px;
}
.home__brand {
display: flex;
align-items: center;
justify-content: space-between;
color: #fff;
}
.home__brand-lock {
display: flex;
align-items: center;
gap: 10px;
}
.home__logo {
width: 38px;
height: 38px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.18);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
}
.home__brand-name {
font-size: 17px;
font-weight: 600;
letter-spacing: 1px;
}
.home__brand-slogan {
font-size: 11px;
opacity: 0.8;
}
.home__notice {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
background: rgba(255, 255, 255, 0.16);
padding: 6px 12px;
border-radius: 16px;
}
/* 状态条 */
.home__status {
margin-top: 14px;
background: rgba(255, 255, 255, 0.14);
border-radius: var(--mini-radius);
padding: 12px 14px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
color: #fff;
}
.home__status-main {
flex: 1;
min-width: 0;
}
.home__status-title {
font-size: 14px;
font-weight: 600;
}
.home__status-desc {
font-size: 12px;
opacity: 0.85;
margin-top: 2px;
}
.home__check {
font-size: 12px;
padding: 3px 10px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.16);
opacity: 0.9;
}
.home__check--done {
background: rgba(47, 179, 109, 0.28);
}
/* Banner */
.home__banner {
margin-top: 14px;
}
.home__swipe {
border-radius: var(--mini-radius);
overflow: hidden;
}
.home__banner-card {
position: relative;
height: 150px;
border-radius: var(--mini-radius);
overflow: hidden;
background: linear-gradient(135deg, #2a3a86 0%, #4b2a6b 100%);
display: flex;
align-items: flex-end;
}
.home__banner-cover {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 72px;
font-weight: 700;
color: rgba(255, 255, 255, 0.14);
}
.home__banner-copy {
position: relative;
padding: 16px;
color: #fff;
}
.home__banner-kicker {
font-size: 11px;
opacity: 0.8;
margin-bottom: 4px;
}
.home__banner-title {
font-size: 18px;
font-weight: 600;
}
.home__banner-sub {
font-size: 12px;
opacity: 0.85;
margin-top: 2px;
}
/* 8 入口 */
.home__entries {
margin: -24px 12px 0;
background: var(--mini-surface);
border-radius: var(--mini-radius-lg);
box-shadow: var(--mini-shadow-strong);
padding: 14px 8px;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px 4px;
position: relative;
z-index: 2;
}
.home__entry {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.home__entry--disabled {
opacity: 0.4;
}
.home__entry-icon {
width: 44px;
height: 44px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 18px;
font-weight: 600;
}
.home__entry-label {
font-size: 12px;
color: var(--mini-text-strong);
font-weight: 500;
}
.home__entry-sub {
font-size: 10px;
color: var(--mini-text-muted);
}
/* 内容区 */
.home__body {
padding: 0 16px;
}
.home__rail {
display: flex;
gap: 10px;
overflow-x: auto;
padding-bottom: 4px;
}
.home__mini {
flex: none;
width: 130px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 10px;
box-shadow: var(--mini-shadow);
}
.home__mini-cover {
width: 36px;
height: 36px;
border-radius: 10px;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
margin-bottom: 6px;
}
.home__mini-title {
font-size: 13px;
font-weight: 600;
color: var(--mini-text-strong);
}
.home__mini-sub {
font-size: 11px;
color: var(--mini-text-secondary);
}
.home__rec-row {
display: flex;
align-items: center;
gap: 10px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 12px;
margin-bottom: 10px;
box-shadow: var(--mini-shadow);
}
.home__rec-main {
flex: 1;
min-width: 0;
}
.home__rec-name {
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
}
.home__rec-meta {
display: flex;
gap: 10px;
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 2px;
}
.home__rec-reasons {
font-size: 12px;
color: var(--mini-tabbar-active);
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.home__rec-actions {
display: flex;
flex-direction: column;
gap: 6px;
}
.home__act {
flex: none;
width: 190px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
overflow: hidden;
}
.home__act-cover {
height: 84px;
background: linear-gradient(135deg, #6a5acd 0%, #8367c7 100%);
color: rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
font-size: 30px;
font-weight: 700;
}
.home__act-info {
padding: 8px 10px 10px;
}
.home__act-tag {
font-size: 10px;
color: var(--mini-tabbar-active);
}
.home__act-title {
font-size: 13px;
font-weight: 600;
color: var(--mini-text-strong);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.home__act-meta {
font-size: 11px;
color: var(--mini-text-secondary);
}
.home__station {
display: flex;
align-items: center;
gap: 12px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 14px;
box-shadow: var(--mini-shadow);
}
.home__station-icon {
width: 46px;
height: 46px;
border-radius: 12px;
background: linear-gradient(135deg, #2fb36d 0%, #1f8a52 100%);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
flex: none;
}
.home__station-info {
flex: 1;
min-width: 0;
}
.home__station-name {
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
}
.home__station-desc {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 2px;
}
.home__station-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.home__article {
display: flex;
align-items: center;
gap: 10px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 10px;
margin-bottom: 8px;
box-shadow: var(--mini-shadow);
}
.home__article-cover {
width: 44px;
height: 44px;
border-radius: 10px;
background: linear-gradient(135deg, #f5a623 0%, #e08b00 100%);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex: none;
}
.home__article-main {
flex: 1;
min-width: 0;
}
.home__article-title {
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
}
.home__article-meta {
font-size: 11px;
color: var(--mini-text-secondary);
}
.home__safety {
display: flex;
gap: 10px;
background: rgba(255, 79, 145, 0.06);
border: 1px solid rgba(255, 79, 145, 0.16);
border-radius: var(--mini-radius);
padding: 14px;
margin-bottom: 20px;
}
.home__safety-icon {
width: 30px;
height: 30px;
border-radius: 8px;
background: var(--mini-accent-pink);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex: none;
}
.home__safety-text {
font-size: 12px;
color: var(--mini-text);
line-height: 1.6;
}
.home__empty-line {
font-size: 13px;
color: var(--mini-text-muted);
text-align: center;
padding: 16px 0;
}
/* 入口渐变砖色相(与 8 入口一致) */
.tone-rose {
background: linear-gradient(135deg, #ff5f8f 0%, #e0326a 100%);
}
.tone-orange {
background: linear-gradient(135deg, #ff9a52 0%, #ef6d1f 100%);
}
.tone-violet {
background: linear-gradient(135deg, #8a6cff 0%, #6244e0 100%);
}
.tone-blue {
background: linear-gradient(135deg, #4f8cff 0%, #2f63e0 100%);
}
.tone-purple {
background: linear-gradient(135deg, #b06cff 0%, #8a3ee0 100%);
}
.tone-pink {
background: linear-gradient(135deg, #ff7ab0 0%, #e0559a 100%);
}
.tone-green {
background: linear-gradient(135deg, #3fc98a 0%, #199e64 100%);
}
.tone-amber {
background: linear-gradient(135deg, #f5a623 0%, #d98a10 100%);
}
</style>
@@ -0,0 +1,217 @@
<template>
<PageLayout title="本地资格">
<div class="le">
<!-- 状态卡 -->
<UiLoading v-if="loading" text="正在读取资格状态…" />
<UiErrorState v-else-if="loadError" message="状态加载失败,请重试" @retry="checkStatus" />
<template v-else>
<div class="le__status" :class="`le__status--${statusTone}`">
<span class="le__status-label">当前状态</span>
<UiTag :type="statusTag">{{ statusText }}</UiTag>
</div>
<div class="le__header">
<div class="le__title">为什么需要确认本地资格?</div>
<div class="le__desc">
善缘丹阳主要服务在丹阳工作、居住或拥有丹阳户籍的适龄单身人士。
本地资格确认帮助我们更好地匹配适合你身边的伴侣。
</div>
</div>
<div class="le__section">
<div class="le__section-title">请选择你的本地资格</div>
<div class="le__types">
<div
v-for="t in eligibilityTypes"
:key="t.value"
class="le__type"
:class="{ 'le__type--active': eligibilityType === t.value }"
@click="onTypeSelect(t.value)"
>
<div class="le__type-icon">{{ t.icon }}</div>
<div class="le__type-label">{{ t.label }}</div>
</div>
</div>
</div>
<div class="le__notice">
只需声明一种资格即可。如需补充佐证材料,系统会在审核后告知你。
</div>
<div class="le__actions">
<van-button plain block @click="goToMyEligibility">查看我已有的资格</van-button>
</div>
</template>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { localEligibilityService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiErrorState from '@/components/UiErrorState.vue'
import UiTag from '@/components/UiTag.vue'
const router = useRouter()
const appStore = useAppStore()
const loading = ref(true)
const loadError = ref(false)
const status = ref('not_declared')
const eligibilityType = ref('')
const eligibilityTypes = [
{ value: 'household_registration', label: '丹阳户籍', icon: '户' },
{ value: 'employment', label: '在丹阳工作', icon: '工' },
{ value: 'residence', label: '在丹阳长期居住', icon: '居' },
{ value: 'other', label: '其他符合服务条件', icon: '其' },
]
const STATUS_TEXT: Record<string, string> = {
not_declared: '尚未声明',
declared: '声明已保存',
needs_evidence: '需要补充佐证',
pending_review: '审核中',
verified: '已核验',
rejected: '未通过',
}
const statusText = computed(() => STATUS_TEXT[status.value] || '尚未声明')
const statusTag = computed<'primary' | 'success' | 'warning' | 'danger' | 'info'>(() => {
if (status.value === 'verified') return 'success'
if (status.value === 'rejected') return 'danger'
if (status.value === 'pending_review' || status.value === 'needs_evidence') return 'warning'
if (status.value === 'declared') return 'primary'
return 'info'
})
const statusTone = computed(() => (status.value === 'verified' ? 'ok' : status.value === 'rejected' ? 'bad' : 'warn'))
async function checkStatus() {
loading.value = true
loadError.value = false
try {
const result: any = await localEligibilityService.getStatus()
status.value = result?.verificationStatus || 'not_declared'
eligibilityType.value = result?.eligibilityType || ''
} catch {
loadError.value = true
} finally {
loading.value = false
}
}
function onTypeSelect(type: string) {
router.push({ path: '/local-eligibility/supplement', query: { type } })
}
function goToMyEligibility() {
if (status.value === 'needs_evidence' || status.value === 'rejected' || status.value === 'declared') {
router.push({ path: '/local-eligibility/supplement', query: { type: eligibilityType.value || '' } })
return
}
appStore.toast(statusText.value)
}
onMounted(checkStatus)
</script>
<style scoped>
.le {
padding: 16px;
}
.le__status {
display: flex;
align-items: center;
justify-content: space-between;
border-radius: var(--mini-radius);
padding: 12px 14px;
margin-bottom: 14px;
background: var(--mini-surface);
box-shadow: var(--mini-shadow);
}
.le__status--ok {
background: rgba(47, 179, 109, 0.1);
}
.le__status--warn {
background: rgba(245, 166, 35, 0.12);
}
.le__status--bad {
background: rgba(240, 72, 62, 0.1);
}
.le__status-label {
font-size: 13px;
color: var(--mini-text-secondary);
}
.le__header {
padding: 4px 4px 12px;
}
.le__title {
font-size: 17px;
font-weight: 600;
color: var(--mini-text-strong);
}
.le__desc {
font-size: 13px;
color: var(--mini-text-secondary);
line-height: 1.8;
margin-top: 8px;
}
.le__section-title {
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
margin-bottom: 10px;
}
.le__types {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.le__type {
display: flex;
align-items: center;
gap: 10px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 14px;
box-shadow: var(--mini-shadow);
border: 1px solid transparent;
}
.le__type--active {
border-color: var(--mini-tabbar-active);
}
.le__type-icon {
width: 36px;
height: 36px;
border-radius: 10px;
background: var(--mini-brand-gradient);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 600;
flex: none;
}
.le__type-label {
font-size: 13px;
color: var(--mini-text-strong);
}
.le__notice {
font-size: 12px;
color: var(--mini-text-secondary);
background: rgba(79, 140, 255, 0.06);
border-radius: var(--mini-radius);
padding: 12px 14px;
margin-top: 14px;
line-height: 1.7;
}
.le__actions {
margin-top: 18px;
}
</style>
@@ -0,0 +1,326 @@
<template>
<PageLayout title="本地资格核验">
<div class="les">
<div class="les__header">
<div class="les__title">补充本地资格佐证</div>
<div class="les__desc">
请补充一项能够说明你在丹阳工作或居住的材料。材料仅用于资格审核,不对其他用户展示。
</div>
</div>
<div v-if="eligibilityType" class="les__type-tag">
<UiTag type="primary">{{ typeLabel(eligibilityType) }}</UiTag>
</div>
<div class="les__section">
<div class="les__section-title">选择佐证类型</div>
<div class="les__evidence-list">
<div
v-for="e in evidenceTypes"
:key="e.value"
class="les__evidence"
:class="{ 'les__evidence--active': selectedType === e.value }"
@click="selectedType = e.value"
>
<div class="les__evidence-label">{{ e.label }}</div>
<div class="les__evidence-desc">{{ e.desc }}</div>
</div>
</div>
</div>
<div class="les__section">
<div class="les__section-title">上传佐证材料</div>
<div class="les__upload">
<div class="les__upload-hint">支持 JPG/PNG,最多 {{ maxFiles }} 张</div>
<div class="les__grid">
<div v-for="(f, index) in files" :key="f.fileId" class="les__item">
<img :src="f.previewUrl" class="les__preview" alt="" />
<div class="les__remove" @click="onRemove(index)">×</div>
</div>
<div v-if="files.length < maxFiles" class="les__add" @click="onChooseFile">
<span class="les__add-icon">+</span>
<span class="les__add-text">上传</span>
</div>
</div>
</div>
<input
ref="fileInput"
type="file"
accept="image/*"
class="les__hidden-input"
@change="onFileChange"
/>
</div>
<div class="les__privacy">材料仅用于资格审核,不对其他用户展示</div>
<div class="les__actions">
<van-button
type="primary"
block
round
:loading="submitting"
:disabled="submitting || uploading"
@click="onSubmit"
>
{{ submitting ? '提交中…' : '提交审核' }}
</van-button>
</div>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { localEligibilityService, profileService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiTag from '@/components/UiTag.vue'
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const fileInput = ref<HTMLInputElement | null>(null)
const eligibilityType = ref(typeof route.query.type === 'string' ? route.query.type : '')
const selectedType = ref('')
const files = ref<Array<{ fileId: string; previewUrl: string }>>([])
const uploading = ref(false)
const submitting = ref(false)
const maxFiles = 5
const TYPE_LABELS: Record<string, string> = {
household_registration: '丹阳户籍',
employment: '在丹阳工作',
residence: '在丹阳长期居住',
other: '其他符合服务条件',
}
const evidenceTypes = [
{ value: 'employment_proof', label: '工作证明', desc: '劳动合同、工牌或在职证明等' },
{ value: 'residence_proof', label: '居住证明', desc: '房产证、租赁合同或水电账单等' },
{ value: 'household_proof', label: '户籍证明', desc: '户口本或户籍页等' },
{ value: 'other', label: '其他材料', desc: '能够说明你在丹阳生活或工作的材料' },
]
function typeLabel(code: string): string {
return TYPE_LABELS[code] || code
}
async function loadStatus() {
try {
const result: any = await localEligibilityService.getStatus()
if (!eligibilityType.value && result?.eligibilityType) eligibilityType.value = result.eligibilityType
const evidences = result?.evidences || []
files.value = evidences
.filter((e: any) => e.fileId)
.map((e: any) => ({ fileId: e.fileId, previewUrl: e.url || e.fileUrl || '' }))
} catch {
/* 允许重新提交 */
}
}
function onChooseFile() {
if (!selectedType.value) {
appStore.toast('请先选择佐证类型')
return
}
if (files.value.length >= maxFiles) {
appStore.toast(`最多上传 ${maxFiles} 份材料`)
return
}
fileInput.value?.click()
}
async function onFileChange(evt: Event) {
const input = evt.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
if (files.value.length >= maxFiles) {
appStore.toast(`最多上传 ${maxFiles} 份材料`)
return
}
uploading.value = true
try {
const token: any = await profileService.requestUploadToken({
fileName: `eligibility_${Date.now()}_${file.name}`,
contentType: file.type || 'image/jpeg',
})
await fetch(token.uploadUrl, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type || 'image/jpeg' },
})
await localEligibilityService.uploadEvidence({
evidenceType: selectedType.value,
fileId: token.fileKey,
})
files.value = [...files.value, { fileId: token.fileKey, previewUrl: URL.createObjectURL(file) }]
appStore.toastSuccess('材料已上传')
} catch (err: any) {
appStore.toastError(err?.message || '上传失败,请稍后重试')
} finally {
uploading.value = false
}
}
function onRemove(index: number) {
files.value = files.value.filter((_, i) => i !== index)
}
async function onSubmit() {
if (!eligibilityType.value) {
appStore.toast('请先选择本地资格类型')
return
}
submitting.value = true
try {
await localEligibilityService.declare({ eligibilityType: eligibilityType.value, regionCode: '321181' })
await localEligibilityService.submit()
appStore.toastSuccess('已提交审核')
router.replace('/local-eligibility')
} catch (err: any) {
appStore.toastError(err?.message || '提交失败,请重试')
} finally {
submitting.value = false
}
}
onMounted(loadStatus)
</script>
<style scoped>
.les {
padding: 16px;
}
.les__header {
padding: 4px 4px 12px;
}
.les__title {
font-size: 18px;
font-weight: 600;
color: var(--mini-text-strong);
}
.les__desc {
font-size: 13px;
color: var(--mini-text-secondary);
line-height: 1.8;
margin-top: 8px;
}
.les__type-tag {
margin: 4px 0 12px;
}
.les__section {
margin-top: 12px;
}
.les__section-title {
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
margin-bottom: 10px;
}
.les__evidence-list {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.les__evidence {
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 12px;
box-shadow: var(--mini-shadow);
border: 1px solid transparent;
}
.les__evidence--active {
border-color: var(--mini-tabbar-active);
}
.les__evidence-label {
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
}
.les__evidence-desc {
font-size: 11px;
color: var(--mini-text-secondary);
margin-top: 4px;
line-height: 1.6;
}
.les__upload {
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 12px;
box-shadow: var(--mini-shadow);
}
.les__upload-hint {
font-size: 12px;
color: var(--mini-text-muted);
margin-bottom: 10px;
}
.les__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.les__item {
position: relative;
padding-top: 100%;
border-radius: 10px;
overflow: hidden;
background: var(--mini-bg);
}
.les__preview {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.les__remove {
position: absolute;
top: 2px;
right: 2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 14px;
line-height: 20px;
text-align: center;
}
.les__add {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding-top: 100%;
position: relative;
border: 1px dashed var(--mini-border, #dcdfe6);
border-radius: 10px;
color: var(--mini-text-secondary);
}
.les__add-icon {
position: absolute;
top: 30%;
font-size: 24px;
}
.les__add-text {
position: absolute;
top: 58%;
font-size: 12px;
}
.les__hidden-input {
display: none;
}
.les__privacy {
font-size: 12px;
color: var(--mini-text-muted);
margin-top: 14px;
text-align: center;
}
.les__actions {
margin-top: 18px;
}
</style>
@@ -0,0 +1,126 @@
<template>
<PageLayout title="公益红娘协助授权">
<div class="ma">
<div class="card">
<div class="ma__title">公益红娘协助授权说明</div>
<div class="ma__tag">当前为演示测试稿,正式版本以法务审核文件为准</div>
<div class="ma__content">
<p>
为帮助你更顺利地了解身边的公益服务与线下活动,你同意公益红娘在你授权范围内提供协助:
了解你的婚恋诉求、协助你参与公益活动、并在必要时为你推荐合适的服务。
</p>
<p>
红娘只能在授权范围内提供协助,不能替你进行喜欢/略过等操作,不能查看你的私人聊天,
也不会将你的联系方式提供给任何未获授权的一方。
</p>
<p>
你可随时在「红娘联系授权」中撤回授权,撤回后红娘将停止相应协助。
</p>
</div>
</div>
<div v-if="!targetUserId" class="ma__warn">
未获取到目标用户,无法提交协助申请。
</div>
<div class="ma__actions">
<van-button plain block :disabled="submitting" @click="onCancel">暂不申请</van-button>
<van-button
type="primary"
block
:loading="submitting"
:disabled="submitting || !targetUserId"
@click="onAgree"
>
同意并申请
</van-button>
</div>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { matchmakerService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const targetUserId = computed(() => (typeof route.query.targetUserId === 'string' ? route.query.targetUserId : ''))
const submitting = ref(false)
async function onAgree() {
if (!targetUserId.value || submitting.value) return
submitting.value = true
try {
await matchmakerService.applyService({
serviceType: 'recommendation_assistance',
targetUserId: targetUserId.value,
description: '希望公益红娘协助了解公益活动和线下服务',
authorizationAccepted: true,
})
appStore.toastSuccess('申请已提交')
router.replace('/matchmaker/progress')
} catch (err: any) {
appStore.toastError(err?.message || '申请失败,请稍后重试')
} finally {
submitting.value = false
}
}
function onCancel() {
if (window.history.length > 1) router.back()
else router.replace('/matchmaker')
}
</script>
<style scoped>
.ma {
padding: 16px;
}
.card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 16px;
}
.ma__title {
font-size: 17px;
font-weight: 600;
color: var(--mini-text-strong);
}
.ma__tag {
font-size: 11px;
color: var(--mini-warning, #b9770e);
background: rgba(245, 166, 35, 0.14);
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
margin-top: 8px;
}
.ma__content {
margin-top: 12px;
font-size: 13px;
color: var(--mini-text);
line-height: 1.8;
}
.ma__content p {
margin: 0 0 10px;
}
.ma__warn {
margin-top: 12px;
font-size: 13px;
color: var(--mini-danger);
text-align: center;
}
.ma__actions {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 20px;
}
</style>
+136
View File
@@ -0,0 +1,136 @@
<template>
<PageLayout title="我的缘分">
<van-tabs v-model:active="activeTab" sticky>
<van-tab name="mutual" :title="`互相喜欢${mutualList.length ? '(' + mutualList.length + ')' : ''}`" />
<van-tab name="liked" :title="`我喜欢的${likedList.length ? '(' + likedList.length + ')' : ''}`" />
<van-tab name="likers" :title="`喜欢我的${likersList.length ? '(' + likersList.length + ')' : ''}`" />
</van-tabs>
<div class="fc">
<UiLoading v-if="loading" />
<template v-else>
<!-- 互相喜欢 -->
<template v-if="activeTab === 'mutual'">
<div v-if="mutualList.length">
<UiUserCard v-for="u in mutualList" :key="u.userId" :user="u" @tap="goDetail" />
</div>
<UiEmpty v-else description="还没有互相喜欢的人">
<van-button type="primary" round size="small" @click="router.push('/discover')">去「找对象」</van-button>
</UiEmpty>
</template>
<!-- 我喜欢的 -->
<template v-else-if="activeTab === 'liked'">
<div v-if="likedList.length">
<UiUserCard v-for="u in likedList" :key="u.userId" :user="u" @tap="goDetail" />
</div>
<UiEmpty v-else description="还没有喜欢过任何人">
<van-button type="primary" round size="small" @click="router.push('/discover')">去「找对象」</van-button>
</UiEmpty>
</template>
<!-- 喜欢我的 -->
<template v-else>
<div v-if="likersList.length">
<div v-for="u in likersList" :key="u.userId" class="fc__liker">
<UiUserCard :user="u" @tap="goDetail" />
<van-button
class="fc__liker-btn"
type="primary"
size="mini"
:loading="actionLoading === u.userId"
@click="onLikeBack(u)"
>
喜欢对方
</van-button>
</div>
</div>
<UiEmpty v-else description="还没有人喜欢你">
<van-button type="primary" round size="small" @click="router.push('/profile/photos')">
完善照片
</van-button>
</UiEmpty>
</template>
</template>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { matchService } from '@/api/services'
import { useMatchStore } from '@/stores/match'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiUserCard from '@/components/UiUserCard.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
const route = useRoute()
const router = useRouter()
const matchStore = useMatchStore()
const appStore = useAppStore()
const activeTab = ref(String(route.query.tab || 'mutual'))
const mutualList = ref<any[]>([])
const likedList = ref<any[]>([])
const likersList = ref<any[]>([])
const loading = ref(true)
const actionLoading = ref('')
function pick(result: any): any[] {
return result?.items || result?.records || []
}
async function loadAll() {
loading.value = true
try {
const [mutual, liked, likers]: any = await Promise.all([
matchService.getMutualList({ pageSize: 20 }).catch(() => null),
matchService.getLikedByMe({ pageSize: 20 }).catch(() => null),
matchService.getLikers({ pageSize: 20 }).catch(() => null),
])
mutualList.value = pick(mutual)
likedList.value = pick(liked)
likersList.value = pick(likers)
} finally {
loading.value = false
}
}
function goDetail(u: any) {
if (u?.userId) router.push({ path: '/profile/detail', query: { id: u.userId } })
}
async function onLikeBack(u: any) {
if (!u?.userId || actionLoading.value) return
actionLoading.value = u.userId
try {
const result: any = await matchStore.like(u.userId)
likersList.value = likersList.value.filter((x) => x.userId !== u.userId)
appStore.toastSuccess(result?.mutualMatch ? '双向意向匹配成功' : '已表达喜欢')
if (result?.mutualMatch) loadAll()
} catch (err: any) {
appStore.toastError(err?.message || '操作失败')
} finally {
actionLoading.value = ''
}
}
onMounted(loadAll)
</script>
<style scoped>
.fc {
padding: 12px 16px 16px;
}
.fc__liker {
position: relative;
}
.fc__liker-btn {
position: absolute;
right: 12px;
top: 26px;
}
</style>
@@ -0,0 +1,172 @@
<template>
<PageLayout title="申请红娘服务">
<div class="apply">
<div class="apply__hero">
<div class="apply__title">选择服务类型</div>
<div class="apply__sub">告诉我们你的需求,红娘会提供公益协助</div>
</div>
<div class="apply__body">
<div class="apply__grid">
<div
v-for="t in serviceTypes"
:key="t.code"
class="apply__type"
:class="{ 'apply__type--active': selectedType === t.code }"
@click="selectedType = t.code"
>
<div class="apply__type-icon">{{ t.icon }}</div>
<div class="apply__type-name">{{ t.name }}</div>
</div>
</div>
<div class="apply__field">
<div class="apply__label">补充说明(可选)</div>
<van-field
v-model="description"
type="textarea"
rows="4"
maxlength="500"
show-word-limit
placeholder="请描述您的情况或需求,方便红娘更好地为您服务"
/>
</div>
<p v-if="errorText" class="apply__error">{{ errorText }}</p>
<van-button type="primary" block round :loading="submitting" @click="onSubmit">
提交申请
</van-button>
<p class="apply__tip">红娘仅提供推荐与协助,不会代替你表达喜欢</p>
</div>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { matchmakerService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
const router = useRouter()
const appStore = useAppStore()
/** 服务类型(对齐 miniapp/pages/matchmaker-apply/matchmaker-apply.js) */
const serviceTypes = [
{ code: 'recommend_partner', name: '帮我推荐合适的人', icon: '👤' },
{ code: 'review_preference', name: '帮我看看择偶条件', icon: '📋' },
{ code: 'contact_intention', name: '帮我联系意向对象', icon: '💬' },
{ code: 'join_activity', name: '想参加线下活动', icon: '🎉' },
{ code: 'no_match_help', name: '匹配很久没有结果', icon: '😟' },
{ code: 'chat_help', name: '不知道怎么开始聊天', icon: '✉️' },
{ code: 'other', name: '其他', icon: '❓' },
]
const selectedType = ref('')
const description = ref('')
const submitting = ref(false)
const errorText = ref('')
async function onSubmit() {
if (submitting.value) return
if (!selectedType.value) {
appStore.toast('请选择服务类型')
return
}
submitting.value = true
errorText.value = ''
try {
await matchmakerService.applyService({
serviceType: selectedType.value,
description: description.value,
})
appStore.toastSuccess('申请已提交')
router.replace('/matchmaker/progress')
} catch (err: any) {
errorText.value = err?.message || '提交失败,请稍后重试'
appStore.toastError(errorText.value)
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.apply {
min-height: 100vh;
background: var(--mini-bg);
padding-bottom: 24px;
}
.apply__hero {
background: var(--mini-brand-gradient);
color: #fff;
padding: 20px 16px 22px;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
}
.apply__title {
font-size: 18px;
font-weight: 600;
}
.apply__sub {
font-size: 12px;
opacity: 0.85;
margin-top: 6px;
}
.apply__body {
padding: 16px;
}
.apply__grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.apply__type {
display: flex;
align-items: center;
gap: 10px;
background: var(--mini-surface);
border: 1px solid transparent;
border-radius: var(--mini-radius);
padding: 12px;
box-shadow: var(--mini-shadow);
}
.apply__type--active {
border-color: var(--mini-tabbar-active);
background: rgba(244, 0, 90, 0.04);
}
.apply__type-icon {
font-size: 20px;
flex: none;
}
.apply__type-name {
font-size: 13px;
color: var(--mini-text-strong);
}
.apply__field {
margin-top: 16px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
overflow: hidden;
box-shadow: var(--mini-shadow);
}
.apply__label {
font-size: 13px;
font-weight: 600;
color: var(--mini-text-strong);
padding: 12px 16px 0;
}
.apply__error {
color: var(--mini-danger);
font-size: 13px;
margin: 12px 4px;
}
.apply__tip {
font-size: 11px;
color: var(--mini-text-muted);
text-align: center;
margin-top: 14px;
}
</style>
@@ -0,0 +1,390 @@
<template>
<PageLayout title="公益红娘">
<div class="mm">
<div class="mm__hero">
<div class="mm__eyebrow">专业牵线 · 公益助力</div>
<div class="mm__title">公益红娘</div>
<div class="mm__sub">认真倾听你的期待,帮助你认识更合适的人</div>
<div class="mm__promises">
<div class="mm__promise">
<div class="mm__promise-icon tone-rose">免</div>
<div class="mm__promise-title">公益免费</div>
<div class="mm__promise-desc">不收取服务费用</div>
</div>
<div class="mm__promise">
<div class="mm__promise-icon tone-blue">真</div>
<div class="mm__promise-title">真实匹配</div>
<div class="mm__promise-desc">基于审核资料推荐</div>
</div>
<div class="mm__promise">
<div class="mm__promise-icon tone-green">隐</div>
<div class="mm__promise-title">隐私保护</div>
<div class="mm__promise-desc">由你本人决定意向</div>
</div>
</div>
</div>
<div class="mm__body">
<UiErrorState v-if="error" @retry="load" />
<UiLoading v-else-if="loading" />
<template v-else>
<UiSectionHeader title="申请状态" />
<div v-if="activeService" class="mm__card" @click="goProgress()">
<div class="mm__card-head">
<span class="mm__card-title">公益红娘服务</span>
<UiTag :type="tagType(activeService.status)">{{ statusText(activeService.status) }}</UiTag>
</div>
<div class="mm__card-row">服务类型:{{ typeText(activeService.serviceType) }}</div>
<div v-if="activeService.matchmakerName" class="mm__card-row">红娘:{{ activeService.matchmakerName }}</div>
<div v-if="activeService.assignedAt" class="mm__card-row">
分配时间:{{ formatDateTime(activeService.assignedAt) }}
</div>
<div class="mm__card-actions">
<van-button size="small" plain @click.stop="goProgress()">查看进度</van-button>
<van-button
v-if="showRecommendations"
size="small"
type="primary"
@click.stop="goRecommendations"
>
查看红娘推荐
</van-button>
</div>
</div>
<div v-else class="mm__empty">
<div class="mm__empty-icon">娘</div>
<div class="mm__empty-title">暂未申请红娘服务</div>
<div class="mm__empty-desc">提交需求后,工作人员会根据你的择偶条件提供公益协助</div>
</div>
<UiSectionHeader title="红娘可以如何帮助" />
<div class="mm__caps">
<div v-for="(c, i) in capabilities" :key="i" class="mm__cap">
<span class="mm__cap-dot" />
<span>{{ c }}</span>
</div>
</div>
<template v-if="historyServices.length">
<UiSectionHeader title="历史服务" />
<div
v-for="s in historyServices"
:key="s.serviceId || s.id"
class="mm__history"
@click="goProgress(s)"
>
<div class="mm__history-main">
<div class="mm__history-title">公益红娘服务</div>
<div v-if="s.matchmakerName" class="mm__history-meta">红娘:{{ s.matchmakerName }}</div>
</div>
<span class="mm__history-status">{{ statusText(s.status) }}</span>
<van-icon name="arrow" color="#9aa0b5" />
</div>
</template>
</template>
</div>
<div class="mm__footer">
<van-button type="primary" block round @click="goApply">
{{ activeService ? '提交新的红娘需求' : '申请红娘服务' }}
</van-button>
</div>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { matchmakerService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import { formatDateTime } from '@/utils/datetime'
import PageLayout from '@/layouts/PageLayout.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
import UiTag from '@/components/UiTag.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const router = useRouter()
const appStore = useAppStore()
/** 服务状态文案(对齐 miniapp constants/profileLabels.js) */
const STATUS_LABEL: Record<string, string> = {
submitted: '已提交',
assigned: '已分配',
in_service: '服务中',
waiting_user: '待您确认',
completed: '已完成',
cancelled: '已取消',
}
/** 服务类型文案(对齐后端 chk_matchmaker_service_type 白名单) */
const TYPE_LABEL: Record<string, string> = {
recommendation: '推荐对象',
recommend_partner: '推荐对象',
review_preference: '择偶指导',
contact_intention: '牵线意向',
referral_counseling: '转介咨询',
referral_activity: '转介活动',
referral_station: '转介服务站',
}
const capabilities = [
'推荐合适对象(基于你的择偶条件)',
'调整择偶条件的专业建议',
'协助联系你感兴趣的意向对象',
'推荐参加合适的相亲活动',
'聊天沟通的方法指导',
]
const services = ref<any[]>([])
const loading = ref(true)
const error = ref(false)
const activeService = computed<any>(() => {
return services.value.find((s) => s.status !== 'completed' && s.status !== 'cancelled') || null
})
const historyServices = computed<any[]>(() => {
const active = activeService.value
return active ? services.value.filter((s) => s !== active) : services.value
})
const showRecommendations = computed(() => {
const st = activeService.value?.status
return st === 'in_service' || st === 'waiting_user'
})
function statusText(status?: string): string {
return (status && STATUS_LABEL[status]) || status || ''
}
function typeText(type?: string): string {
return (type && TYPE_LABEL[type]) || '公益红娘服务'
}
function tagType(status?: string): 'primary' | 'success' | 'warning' | 'danger' | 'info' {
const map: Record<string, 'primary' | 'success' | 'warning' | 'info'> = {
submitted: 'warning',
assigned: 'info',
in_service: 'primary',
waiting_user: 'warning',
completed: 'success',
cancelled: 'info',
}
return (status && map[status]) || 'info'
}
async function load() {
loading.value = true
error.value = false
try {
const result: any = await matchmakerService.getMyServices({})
services.value = result?.items || result?.records || (Array.isArray(result) ? result : [])
} catch (err: any) {
error.value = true
appStore.toastError(err?.message || '加载红娘服务失败')
} finally {
loading.value = false
}
}
function goApply() {
router.push('/matchmaker/apply')
}
function goProgress(s?: any) {
const target = s || activeService.value
const id = target?.serviceId || target?.id
if (id) router.push({ path: '/matchmaker/progress', query: { id } })
else router.push('/matchmaker/progress')
}
function goRecommendations() {
router.push('/matchmaker/recommendations')
}
onMounted(load)
</script>
<style scoped>
.mm {
min-height: 100vh;
background: var(--mini-bg);
padding-bottom: 84px;
}
.mm__hero {
background: var(--mini-brand-gradient);
color: #fff;
padding: 20px 16px 22px;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
}
.mm__eyebrow {
font-size: 11px;
opacity: 0.82;
letter-spacing: 1px;
}
.mm__title {
font-size: 22px;
font-weight: 600;
margin-top: 6px;
}
.mm__sub {
font-size: 12px;
opacity: 0.85;
margin-top: 6px;
}
.mm__promises {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-top: 16px;
}
.mm__promise {
text-align: center;
background: rgba(255, 255, 255, 0.12);
border-radius: var(--mini-radius);
padding: 12px 6px;
}
.mm__promise-icon {
width: 34px;
height: 34px;
border-radius: 10px;
margin: 0 auto 6px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
}
.mm__promise-title {
font-size: 13px;
font-weight: 600;
}
.mm__promise-desc {
font-size: 10px;
opacity: 0.82;
margin-top: 2px;
}
.mm__body {
padding: 0 16px;
}
.mm__card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 14px;
box-shadow: var(--mini-shadow);
}
.mm__card-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.mm__card-title {
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
}
.mm__card-row {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 6px;
}
.mm__card-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 12px;
}
.mm__empty {
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 22px 16px;
text-align: center;
box-shadow: var(--mini-shadow);
}
.mm__empty-icon {
width: 44px;
height: 44px;
border-radius: 12px;
margin: 0 auto 10px;
background: var(--mini-brand-gradient);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.mm__empty-title {
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
}
.mm__empty-desc {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 6px;
line-height: 1.6;
}
.mm__caps {
display: flex;
flex-direction: column;
gap: 10px;
}
.mm__cap {
display: flex;
align-items: center;
gap: 8px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 12px 14px;
box-shadow: var(--mini-shadow);
font-size: 13px;
color: var(--mini-text);
}
.mm__cap-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--mini-accent-pink);
flex: none;
}
.mm__history {
display: flex;
align-items: center;
gap: 10px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 12px 14px;
margin-bottom: 8px;
box-shadow: var(--mini-shadow);
}
.mm__history-main {
flex: 1;
min-width: 0;
}
.mm__history-title {
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
}
.mm__history-meta {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 2px;
}
.mm__history-status {
font-size: 12px;
color: var(--mini-text-secondary);
}
.mm__footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
background: var(--mini-surface);
box-shadow: 0 -4px 16px rgba(23, 42, 114, 0.08);
}
.tone-rose {
background: linear-gradient(135deg, #ff5f8f 0%, #e0326a 100%);
}
.tone-blue {
background: linear-gradient(135deg, #4f8cff 0%, #2f63e0 100%);
}
.tone-green {
background: linear-gradient(135deg, #3fc98a 0%, #199e64 100%);
}
</style>
@@ -0,0 +1,369 @@
<template>
<PageLayout title="服务进度">
<div class="pg">
<UiErrorState v-if="error" @retry="load" />
<UiLoading v-else-if="loading" />
<template v-else-if="service">
<div class="pg__status" :class="`pg__status--${statusKey}`">
<span class="pg__status-dot" />
<div>
<div class="pg__status-text">{{ statusText(service.status) }}</div>
<div class="pg__status-sub">{{ statusSub(service.status) }}</div>
</div>
</div>
<div class="pg__card">
<div class="pg__row">
<span class="pg__label">服务编号</span>
<span class="pg__value">{{ service.serviceDisplayId || service.displayId || service.serviceId || service.id }}</span>
</div>
<div class="pg__row">
<span class="pg__label">服务类型</span>
<span class="pg__value">{{ typeText(service.serviceType) }}</span>
</div>
<div v-if="service.description" class="pg__row">
<span class="pg__label">您的需求</span>
<span class="pg__value pg__value--desc">{{ service.description }}</span>
</div>
<div class="pg__row">
<span class="pg__label">提交时间</span>
<span class="pg__value">{{ formatDateTime(service.createdAt) }}</span>
</div>
<div v-if="service.assignedAt" class="pg__row">
<span class="pg__label">分配时间</span>
<span class="pg__value">{{ formatDateTime(service.assignedAt) }}</span>
</div>
<div v-if="service.completedAt" class="pg__row">
<span class="pg__label">完成时间</span>
<span class="pg__value">{{ formatDateTime(service.completedAt) }}</span>
</div>
</div>
<UiSectionHeader title="服务流程" />
<div class="pg__steps">
<div
v-for="(s, i) in steps"
:key="s.key"
class="pg__step"
:class="{ 'pg__step--done': stepStatus(s.key) }"
>
<span class="pg__step-num">{{ i + 1 }}</span>
<span class="pg__step-text">{{ s.text }}</span>
</div>
</div>
<div v-if="service.status === 'waiting_user'" class="pg__confirm">
<div class="pg__confirm-title">红娘已提交服务方案</div>
<div class="pg__confirm-desc">确认后将恢复服务跟进。</div>
<van-button type="primary" block round :loading="confirming" @click="onConfirm">
确认服务方案
</van-button>
</div>
<div v-if="canWithdraw" class="pg__withdraw">
<van-button plain block round :loading="withdrawing" @click="onWithdraw">撤回申请</van-button>
</div>
</template>
<div v-else class="pg__empty">
<UiEmpty description="暂无服务记录">
<van-button size="small" type="primary" @click="goApply">申请红娘服务</van-button>
</UiEmpty>
</div>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showConfirmDialog } from 'vant'
import { matchmakerService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import { formatDateTime } from '@/utils/datetime'
import PageLayout from '@/layouts/PageLayout.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const STATUS_LABEL: Record<string, string> = {
submitted: '已提交,等待分配',
assigned: '已分配红娘',
in_service: '红娘正在服务中',
waiting_user: '等待您确认',
completed: '服务已完成',
cancelled: '已取消',
}
const STATUS_SUB: Record<string, string> = {
submitted: '工作人员会尽快为你分配红娘',
assigned: '红娘会主动与你联系',
in_service: '红娘正在根据你的需求推进服务',
waiting_user: '请确认红娘提交的服务方案',
completed: '本次公益红娘服务已结束',
cancelled: '本次服务申请已取消',
}
const TYPE_LABEL: Record<string, string> = {
recommendation: '推荐对象',
recommend_partner: '推荐对象',
review_preference: '择偶指导',
contact_intention: '牵线意向',
referral_counseling: '转介咨询',
referral_activity: '转介活动',
referral_station: '转介服务站',
}
const STEPS = [
{ key: 'submitted', text: '提交申请' },
{ key: 'assigned', text: '分配红娘' },
{ key: 'in_service', text: '红娘服务' },
{ key: 'completed', text: '服务完成' },
]
const STEP_ORDER: Record<string, number> = {
submitted: 0,
assigned: 1,
in_service: 2,
waiting_user: 2,
completed: 3,
}
const WITHDRAWABLE = ['submitted', 'assigned', 'in_service', 'waiting_user']
const service = ref<any>(null)
const loading = ref(true)
const error = ref(false)
const confirming = ref(false)
const withdrawing = ref(false)
const steps = STEPS
const statusKey = computed(() => service.value?.status || 'submitted')
const canWithdraw = computed(() => WITHDRAWABLE.includes(statusKey.value))
function statusText(status?: string): string {
return (status && STATUS_LABEL[status]) || status || ''
}
function statusSub(status?: string): string {
return (status && STATUS_SUB[status]) || ''
}
function typeText(type?: string): string {
return (type && TYPE_LABEL[type]) || '公益红娘服务'
}
function stepStatus(key: string): boolean {
const current = STEP_ORDER[statusKey.value]
const step = STEP_ORDER[key]
if (current === undefined || step === undefined) return false
return current >= step
}
function serviceIdOf(item: any): string {
return item?.serviceId || item?.id || ''
}
async function load() {
loading.value = true
error.value = false
try {
const queryId = typeof route.query.id === 'string' ? route.query.id : ''
if (queryId) {
service.value = await matchmakerService.getServiceDetail(queryId)
} else {
const result: any = await matchmakerService.getMyServices({})
const list: any[] = result?.items || result?.records || (Array.isArray(result) ? result : [])
const first = list.find((s) => WITHDRAWABLE.includes(s.status)) || list[0]
if (!first) {
service.value = null
} else {
const id = serviceIdOf(first)
service.value = id
? await matchmakerService.getServiceDetail(id).catch(() => first)
: first
}
}
} catch (err: any) {
error.value = true
appStore.toastError(err?.message || '加载服务进度失败')
} finally {
loading.value = false
}
}
async function onConfirm() {
if (confirming.value) return
const id = serviceIdOf(service.value)
if (!id) return
confirming.value = true
try {
await matchmakerService.confirmService(id)
appStore.toastSuccess('已确认')
await load()
} catch (err: any) {
appStore.toastError(err?.message || '确认失败,请稍后重试')
} finally {
confirming.value = false
}
}
async function onWithdraw() {
if (withdrawing.value) return
const id = serviceIdOf(service.value)
if (!id) return
try {
await showConfirmDialog({ title: '撤回申请', message: '确定要撤回本次红娘服务申请吗?' })
} catch {
return
}
withdrawing.value = true
try {
await matchmakerService.withdrawService(id)
appStore.toastSuccess('已撤回')
await load()
} catch (err: any) {
appStore.toastError(err?.message || '撤回失败,请稍后重试')
} finally {
withdrawing.value = false
}
}
function goApply() {
router.push('/matchmaker/apply')
}
onMounted(load)
</script>
<style scoped>
.pg {
min-height: 100vh;
background: var(--mini-bg);
padding: 16px;
}
.pg__status {
display: flex;
align-items: center;
gap: 12px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 16px 14px;
box-shadow: var(--mini-shadow);
}
.pg__status-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--mini-warning);
flex: none;
}
.pg__status--in_service .pg__status-dot,
.pg__status--assigned .pg__status-dot {
background: var(--mini-accent-pink);
}
.pg__status--completed .pg__status-dot {
background: var(--mini-success);
}
.pg__status--cancelled .pg__status-dot {
background: var(--mini-text-muted);
}
.pg__status-text {
font-size: 16px;
font-weight: 600;
color: var(--mini-text-strong);
}
.pg__status-sub {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 2px;
}
.pg__card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 6px 14px;
margin-top: 12px;
box-shadow: var(--mini-shadow);
}
.pg__row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 12px 0;
border-bottom: 1px solid var(--mini-border);
}
.pg__row:last-child {
border-bottom: none;
}
.pg__label {
font-size: 13px;
color: var(--mini-text-secondary);
flex: none;
}
.pg__value {
font-size: 13px;
color: var(--mini-text-strong);
text-align: right;
word-break: break-all;
}
.pg__value--desc {
text-align: left;
}
.pg__steps {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.pg__step {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 12px 4px;
box-shadow: var(--mini-shadow);
opacity: 0.55;
}
.pg__step--done {
opacity: 1;
}
.pg__step-num {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--mini-border-strong);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
}
.pg__step--done .pg__step-num {
background: var(--mini-accent-pink);
}
.pg__step-text {
font-size: 11px;
color: var(--mini-text);
}
.pg__confirm {
background: rgba(245, 166, 35, 0.1);
border: 1px solid rgba(245, 166, 35, 0.28);
border-radius: var(--mini-radius);
padding: 16px;
margin-top: 16px;
}
.pg__confirm-title {
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
}
.pg__confirm-desc {
font-size: 12px;
color: var(--mini-text-secondary);
margin: 4px 0 12px;
}
.pg__withdraw {
margin-top: 16px;
}
.pg__empty {
padding-top: 24px;
}
</style>
@@ -0,0 +1,195 @@
<template>
<PageLayout title="红娘推荐">
<div class="rec">
<div class="rec__intro">
这是红娘根据你的需求提供的候选建议,是否表达喜欢由你本人决定。
</div>
<UiErrorState v-if="error" @retry="load" />
<UiLoading v-else-if="loading" />
<template v-else-if="recommendations.length">
<div v-for="r in recommendations" :key="r.id" class="rec__card">
<div class="rec__head" @click="goDetail(r)">
<UiAvatar :name="r.name" :src="r.avatarUrl" :size="52" />
<div class="rec__main">
<div class="rec__name">{{ r.name }}</div>
<div class="rec__meta">
<span v-if="r.age">{{ r.age }}岁</span>
<span v-if="r.regionName">{{ r.regionName }}</span>
</div>
<div v-if="r.reason" class="rec__reason">{{ r.reason }}</div>
</div>
</div>
<div class="rec__actions">
<van-button
size="small"
plain
:loading="actionId === r.id && actionType === 'pass'"
@click="onPass(r)"
>
暂不考虑
</van-button>
<van-button
size="small"
type="primary"
:loading="actionId === r.id && actionType === 'accept'"
@click="onAccept(r)"
>
表达喜欢
</van-button>
</div>
</div>
</template>
<UiEmpty v-else description="暂时没有红娘推荐" />
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { matchmakerService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiAvatar from '@/components/UiAvatar.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const router = useRouter()
const appStore = useAppStore()
const recommendations = ref<any[]>([])
const loading = ref(true)
const error = ref(false)
const actionId = ref('')
const actionType = ref('')
function normalize(item: any) {
return {
id: item.id || item.recommendationId,
userId: item.targetUserId || item.userId,
name: item.targetDisplayName || item.displayName || '红娘推荐对象',
age: item.targetAge || item.age || 0,
regionName: item.targetRegionName || item.regionName || '',
reason: item.reason || '',
avatarUrl: item.targetAvatarUrl || item.avatarUrl || '',
status: item.status,
}
}
async function load() {
loading.value = true
error.value = false
try {
const result: any = await matchmakerService.getRecommendations({})
const list: any[] = result?.items || result?.records || (Array.isArray(result) ? result : [])
recommendations.value = list.map(normalize)
recommendations.value
.filter((r) => r.status === 'pending_view' && r.id)
.forEach((r) => {
matchmakerService.markRecommendationViewed(r.id).catch(() => {})
})
} catch (err: any) {
error.value = true
appStore.toastError(err?.message || '加载红娘推荐失败')
} finally {
loading.value = false
}
}
async function onAccept(r: any) {
if (!r.id || actionId.value) return
actionId.value = r.id
actionType.value = 'accept'
try {
const result: any = await matchmakerService.acceptRecommendation(r.id)
recommendations.value = recommendations.value.filter((x) => x.id !== r.id)
appStore.toastSuccess(result?.mutualMatch ? '双向意向匹配成功!' : '已表达喜欢')
} catch (err: any) {
appStore.toastError(err?.message || '操作失败')
} finally {
actionId.value = ''
actionType.value = ''
}
}
async function onPass(r: any) {
if (!r.id || actionId.value) return
actionId.value = r.id
actionType.value = 'pass'
try {
await matchmakerService.passRecommendation(r.id)
recommendations.value = recommendations.value.filter((x) => x.id !== r.id)
appStore.toastSuccess('已暂不考虑')
} catch (err: any) {
appStore.toastError(err?.message || '操作失败')
} finally {
actionId.value = ''
actionType.value = ''
}
}
function goDetail(r: any) {
if (r.userId) router.push({ path: '/profile/detail', query: { id: r.userId } })
}
onMounted(load)
</script>
<style scoped>
.rec {
min-height: 100vh;
background: var(--mini-bg);
padding: 12px 16px 24px;
}
.rec__intro {
font-size: 12px;
color: var(--mini-text-secondary);
background: rgba(244, 0, 90, 0.06);
border-radius: var(--mini-radius);
padding: 10px 12px;
line-height: 1.6;
margin-bottom: 12px;
}
.rec__card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 14px;
margin-bottom: 10px;
box-shadow: var(--mini-shadow);
}
.rec__head {
display: flex;
align-items: center;
gap: 12px;
}
.rec__main {
flex: 1;
min-width: 0;
}
.rec__name {
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
}
.rec__meta {
display: flex;
gap: 10px;
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 4px;
}
.rec__reason {
font-size: 12px;
color: var(--mini-tabbar-active);
margin-top: 6px;
line-height: 1.5;
}
.rec__actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 12px;
}
</style>
@@ -0,0 +1,181 @@
<template>
<PageLayout title="待确认的公益服务">
<div class="ref">
<div class="ref__hero">
<div class="ref__hero-title">待确认的公益服务</div>
<div class="ref__hero-desc">红娘提供的服务建议,需要由你本人确认后才会生效。</div>
</div>
<div class="ref__body">
<UiErrorState v-if="error" @retry="load" />
<UiLoading v-else-if="loading" />
<template v-else-if="referrals.length">
<div v-for="item in referrals" :key="item.id" class="ref__card">
<div class="ref__main">
<div class="ref__title">{{ item.title }}</div>
<div class="ref__detail">转介编号:{{ item.referralDisplayId || item.id }}</div>
<div v-if="item.detail" class="ref__detail">{{ item.detail }}</div>
<div class="ref__date">{{ formatDateTime(item.createdAt) }}</div>
</div>
<div class="ref__action">
<UiTag :type="item.confirmed ? 'success' : 'warning'">{{ item.statusText }}</UiTag>
<van-button
v-if="!item.confirmed"
size="mini"
type="primary"
:loading="confirmingId === item.id"
@click="onConfirm(item)"
>
确认转介
</van-button>
</div>
</div>
</template>
<UiEmpty v-else description="暂无待确认的转介" />
</div>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { referralService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import { formatDateTime } from '@/utils/datetime'
import PageLayout from '@/layouts/PageLayout.vue'
import UiTag from '@/components/UiTag.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const route = useRoute()
const appStore = useAppStore()
const STATUS_LABEL: Record<string, string> = {
submitted: '待您确认',
pending_confirmation: '待您确认',
confirmed: '已确认',
cancelled: '已取消',
completed: '已完成',
}
const referrals = ref<any[]>([])
const loading = ref(true)
const error = ref(false)
const confirmingId = ref('')
function normalize(item: any) {
const confirmed = !!item.userConfirmedAt || item.status === 'confirmed'
return {
id: item.id,
referralDisplayId: item.referralDisplayId,
targetType: item.type === 'activity' ? 'activity' : 'service_station',
title: item.type === 'activity' ? '公益活动转介' : '服务站转介',
detail: item.type === 'activity' ? item.activityDisplayId || '公益活动' : item.serviceType || '公益服务',
confirmed,
statusText: confirmed ? '已确认' : STATUS_LABEL[item.status] || '待您确认',
createdAt: item.createdAt || item.registeredAt || '',
}
}
async function load() {
loading.value = true
error.value = false
try {
const result: any = await referralService.getMyReferralHistory()
let list: any[] = result?.items || result?.records || (Array.isArray(result) ? result : [])
list = list.map(normalize)
const queryId = typeof route.query.id === 'string' ? route.query.id : ''
if (queryId) {
const matched = list.filter((x) => x.id === queryId)
if (matched.length) list = matched
}
referrals.value = list
} catch (err: any) {
error.value = true
appStore.toastError(err?.message || '加载失败,请稍后重试')
} finally {
loading.value = false
}
}
async function onConfirm(item: any) {
if (!item.id || confirmingId.value) return
confirmingId.value = item.id
try {
await referralService.confirmReferral(item.id, item.targetType)
appStore.toastSuccess('已确认')
await load()
} catch (err: any) {
appStore.toastError(err?.message || '确认失败,请重试')
} finally {
confirmingId.value = ''
}
}
onMounted(load)
</script>
<style scoped>
.ref {
min-height: 100vh;
background: var(--mini-bg);
}
.ref__hero {
background: var(--mini-brand-gradient);
color: #fff;
padding: 20px 16px 22px;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
}
.ref__hero-title {
font-size: 18px;
font-weight: 600;
}
.ref__hero-desc {
font-size: 12px;
opacity: 0.85;
margin-top: 6px;
line-height: 1.6;
}
.ref__body {
padding: 12px 16px 24px;
}
.ref__card {
display: flex;
align-items: flex-start;
gap: 12px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 14px;
margin-bottom: 10px;
box-shadow: var(--mini-shadow);
}
.ref__main {
flex: 1;
min-width: 0;
}
.ref__title {
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
}
.ref__detail {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 4px;
}
.ref__date {
font-size: 11px;
color: var(--mini-text-muted);
margin-top: 6px;
}
.ref__action {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10px;
flex: none;
}
</style>
+383
View File
@@ -0,0 +1,383 @@
<template>
<div class="mine">
<!-- 头部 -->
<div class="mine__hero">
<div class="mine__top">
<span class="mine__page-title">我的</span>
<van-icon name="setting-o" size="20" @click="router.push('/settings')" />
</div>
<div class="mine__profile">
<UiAvatar :src="profile?.avatarUrl" :name="profile?.displayName || '善缘用户'" :size="60" />
<div class="mine__profile-main">
<div class="mine__name-row">
<span class="mine__name">{{ profile?.displayName || '未填写昵称' }}</span>
<span class="mine__rel">{{ relationshipText }}</span>
</div>
<div class="mine__completeness">资料完整度 {{ completeness }}%</div>
<div v-if="displayNumber" class="mine__number">用户编号:{{ displayNumber }}</div>
<div class="mine__progress"><div class="mine__progress-inner" :style="{ width: completeness + '%' }" /></div>
<div class="mine__badges">
<UiTag :type="verified ? 'success' : 'warning'">{{ verified ? '已实名' : '未实名' }}</UiTag>
<UiTag :type="isProfileApproved ? 'success' : 'warning'">
{{ isProfileApproved ? '资料已审核' : '资料待审核' }}
</UiTag>
</div>
</div>
<span class="mine__edit" @click="router.push('/profile/intro')">编辑</span>
</div>
</div>
<!-- 统计 -->
<div class="mine__stats">
<div class="mine__stat">
<div class="mine__stat-value">{{ completeness }}%</div>
<div class="mine__stat-label">资料完整</div>
</div>
<div class="mine__stat" @click="router.push('/fate-center?tab=likers')">
<div class="mine__stat-value">{{ likersCount }}</div>
<div class="mine__stat-label">喜欢我的</div>
</div>
<div class="mine__stat" @click="router.push('/fate-center')">
<div class="mine__stat-value">{{ mutualCount }}</div>
<div class="mine__stat-label">当前缘分</div>
</div>
<div class="mine__stat">
<div class="mine__stat-value mine__stat-value--text">{{ reviewStatusText }}</div>
<div class="mine__stat-label">资料状态</div>
</div>
</div>
<div class="mine__body">
<!-- 组一:资料与缘分 -->
<UiSectionHeader title="我的资料与缘分" />
<van-cell-group inset>
<van-cell title="我的资料" :value="reviewStatusText" is-link @click="router.push('/profile/intro')" />
<van-cell title="择偶条件" is-link @click="router.push('/profile/preference')" />
<van-cell title="我的缘分" is-link @click="router.push('/fate-center')" />
</van-cell-group>
<!-- 组二:公益服务 -->
<UiSectionHeader title="我的公益服务" />
<van-cell-group inset>
<van-cell title="公益服务中心" value="咨询 · 预约 · 课堂" is-link @click="router.push('/service-center')" />
</van-cell-group>
<div class="mine__grid">
<div class="mine__entry" @click="router.push('/matchmaker')">
<div class="mine__entry-icon tone-rose">娘</div>
<span>我的红娘</span>
</div>
<div class="mine__entry" @click="router.push('/my/counseling')">
<div class="mine__entry-icon tone-blue">咨</div>
<span>我的咨询</span>
</div>
<div class="mine__entry" @click="router.push('/my/activities')">
<div class="mine__entry-icon tone-amber">活</div>
<span>我的活动</span>
</div>
<div class="mine__entry" @click="router.push('/my/appointments')">
<div class="mine__entry-icon tone-green">约</div>
<span>我的预约</span>
</div>
</div>
<van-cell-group inset>
<van-cell title="公益服务记录" value="仅本人可见" is-link @click="router.push('/my/service-history')" />
</van-cell-group>
<!-- 组三:安全与隐私 -->
<UiSectionHeader title="安全与隐私" />
<van-cell-group inset>
<van-cell title="隐私设置" is-link @click="router.push('/settings/privacy')" />
<van-cell title="黑名单" is-link @click="router.push('/settings/blacklist')" />
<van-cell title="举报与安全" is-link @click="router.push('/my/reports')" />
<van-cell title="账号设置" is-link @click="router.push('/settings')" />
</van-cell-group>
<!-- 组四:公益信息 -->
<UiSectionHeader title="公益信息" />
<div class="mine__grid">
<div class="mine__entry" @click="router.push('/content')">
<div class="mine__entry-icon tone-amber">课</div>
<span>婚恋课堂</span>
</div>
<div class="mine__entry" @click="router.push('/anti-fraud')">
<div class="mine__entry-icon tone-rose">诈</div>
<span>婚恋反诈</span>
</div>
<div class="mine__entry" @click="router.push('/service-station')">
<div class="mine__entry-icon tone-green">站</div>
<span>善缘服务站</span>
</div>
<div class="mine__entry" @click="router.push('/about')">
<div class="mine__entry-icon tone-blue">缘</div>
<span>关于善缘丹阳</span>
</div>
</div>
<div class="mine__logout" @click="onLogout">退出登录</div>
<UiSectionHeader title="工作人员" />
<van-cell-group inset>
<van-cell title="红娘工作人员入口" is-link @click="goOperator" />
</van-cell-group>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { profileService, verificationService, matchService } from '@/api/services'
import { useAuthStore } from '@/stores/auth'
import { useOperatorStore } from '@/stores/operator'
import { useAppStore } from '@/stores/app'
import { showConfirmDialog } from 'vant'
import UiAvatar from '@/components/UiAvatar.vue'
import UiTag from '@/components/UiTag.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
const router = useRouter()
const authStore = useAuthStore()
const operatorStore = useOperatorStore()
const appStore = useAppStore()
const RELATIONSHIP_TEXT: Record<string, string> = {
searching: '正在寻找',
getting_to_know: '正在了解',
paused: '已暂停寻找',
matched_successfully: '已成功牵手',
service_exited: '已退出服务',
}
const REVIEW_TEXT: Record<string, string> = {
not_submitted: '未提交',
pending_review: '审核中',
approved: '已审核',
need_edit: '需修改',
rejected: '未通过',
completed: '已审核',
}
const profile = ref<any>(null)
const verification = ref<any>(null)
const likersCount = ref(0)
const mutualCount = ref(0)
const completeness = computed(() => profile.value?.profileCompletion || 0)
const displayNumber = computed(
() => profile.value?.userDisplayNumber || profile.value?.displayNumber || profile.value?.displayId || '',
)
const reviewStatus = computed(() => profile.value?.profileReviewStatus || profile.value?.reviewStatus || 'not_submitted')
const reviewStatusText = computed(() => REVIEW_TEXT[reviewStatus.value] || '未提交')
const relationshipText = computed(() => RELATIONSHIP_TEXT[profile.value?.relationshipStatus] || '')
const isProfileApproved = computed(() => reviewStatus.value === 'approved' || profile.value?.profileStatus === 'approved' || profile.value?.profileStatus === 'completed')
const verified = computed(() => {
const v = verification.value
if (v && typeof v.verified === 'boolean') return v.verified
if (v?.status) return v.status === 'verified'
const p = profile.value
if (p) return !!(p.verified ?? p.isRealNameVerified)
return false
})
async function load() {
try {
const [p, v] = await Promise.all([
profileService.getProfile().catch(() => null),
verificationService.getStatus().catch(() => null),
])
profile.value = p
verification.value = v
} catch {
/* ignore */
}
try {
const [mutual, likers]: any = await Promise.all([
matchService.getMutualList({ pageSize: 1 }).catch(() => null),
matchService.getLikers({ pageSize: 1 }).catch(() => null),
])
mutualCount.value = (mutual?.items || mutual?.records || []).length || mutual?.total || 0
likersCount.value = (likers?.items || likers?.records || []).length || likers?.total || 0
} catch {
/* ignore */
}
}
function goOperator() {
if (operatorStore.isLoggedIn) router.push('/operator/workbench')
else router.push('/operator/login')
}
async function onLogout() {
try {
await showConfirmDialog({ title: '退出登录', message: '确定要退出当前账号吗?' })
} catch {
return
}
await authStore.logout()
appStore.toast('已退出登录')
router.replace('/login?clear=1')
}
onMounted(load)
</script>
<style scoped>
.mine {
min-height: 100vh;
background: var(--mini-bg);
padding-bottom: 16px;
}
.mine__hero {
background: var(--mini-brand-gradient);
padding: 16px 16px 24px;
color: #fff;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
}
.mine__top {
display: flex;
align-items: center;
justify-content: space-between;
}
.mine__page-title {
font-size: 17px;
font-weight: 600;
}
.mine__profile {
display: flex;
gap: 14px;
margin-top: 16px;
}
.mine__profile-main {
flex: 1;
min-width: 0;
}
.mine__name-row {
display: flex;
align-items: center;
gap: 8px;
}
.mine__name {
font-size: 18px;
font-weight: 600;
}
.mine__rel {
font-size: 11px;
background: rgba(255, 255, 255, 0.18);
padding: 2px 8px;
border-radius: 10px;
}
.mine__completeness,
.mine__number {
font-size: 12px;
opacity: 0.85;
margin-top: 4px;
}
.mine__progress {
height: 6px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.24);
margin-top: 8px;
overflow: hidden;
}
.mine__progress-inner {
height: 100%;
background: var(--mini-accent-pink);
border-radius: 3px;
}
.mine__badges {
display: flex;
gap: 6px;
margin-top: 8px;
}
.mine__edit {
align-self: flex-start;
font-size: 12px;
background: rgba(255, 255, 255, 0.18);
padding: 4px 10px;
border-radius: 12px;
}
.mine__stats {
margin: -16px 16px 0;
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow-strong);
display: flex;
padding: 14px 0;
position: relative;
z-index: 2;
}
.mine__stat {
flex: 1;
text-align: center;
}
.mine__stat-value {
font-size: 18px;
font-weight: 700;
color: var(--mini-text-strong);
}
.mine__stat-value--text {
font-size: 13px;
}
.mine__stat-label {
font-size: 11px;
color: var(--mini-text-secondary);
margin-top: 2px;
}
.mine__body {
margin-top: 8px;
}
.mine__body :deep(.van-cell-group--inset) {
margin: 0 16px 8px;
}
.mine__grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
margin: 4px 16px 12px;
}
.mine__entry {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 12px 4px;
box-shadow: var(--mini-shadow);
font-size: 11px;
color: var(--mini-text);
}
.mine__entry-icon {
width: 34px;
height: 34px;
border-radius: 10px;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-weight: 600;
}
.mine__logout {
margin: 16px;
text-align: center;
color: var(--mini-danger);
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 13px;
font-size: 14px;
box-shadow: var(--mini-shadow);
}
.tone-rose {
background: linear-gradient(135deg, #ff5f8f 0%, #e0326a 100%);
}
.tone-blue {
background: linear-gradient(135deg, #4f8cff 0%, #2f63e0 100%);
}
.tone-amber {
background: linear-gradient(135deg, #f5a623 0%, #d98a10 100%);
}
.tone-green {
background: linear-gradient(135deg, #3fc98a 0%, #199e64 100%);
}
</style>
@@ -0,0 +1,289 @@
<template>
<PageLayout title="基础资料">
<div class="pb">
<UiLoading v-if="loading" text="加载中…" />
<template v-else>
<div class="card">
<UiSectionHeader title="基本信息" />
<p class="pb__hint">由实名认证自动生成</p>
<div class="pb__readonly">
<div class="pb__row">
<span class="pb__row-label">性别</span>
<span class="pb__row-value">{{ genderLabel || '未认证' }}</span>
</div>
<div class="pb__row">
<span class="pb__row-label">年龄</span>
<span class="pb__row-value">{{ profile?.age ? `${profile.age}岁` : '未认证' }}</span>
</div>
</div>
</div>
<div class="card">
<UiSectionHeader title="个人资料" />
<p class="pb__hint">请如实填写</p>
<van-field
v-model="regionLabel"
is-link
readonly
label="居住区域"
placeholder="请选择"
:border="false"
@click="openPicker('region')"
/>
<van-field
v-model="form.heightCm"
type="digit"
label="身高(cm)"
placeholder="请输入"
/>
<van-field
v-model="maritalLabel"
is-link
readonly
label="婚姻状况"
placeholder="请选择"
@click="openPicker('marital')"
/>
<van-field
v-model="childrenLabel"
is-link
readonly
label="子女情况"
placeholder="请选择"
@click="openPicker('children')"
/>
<van-field
v-model="educationLabel"
is-link
readonly
label="学历"
placeholder="请选择"
@click="openPicker('education')"
/>
<van-field
v-model="occupationLabel"
is-link
readonly
label="职业类型"
placeholder="请选择"
@click="openPicker('occupation')"
/>
</div>
</template>
<div class="pb__actions">
<van-button type="primary" block round :loading="saving" :disabled="loading" @click="onNext">
下一步
</van-button>
</div>
<van-popup v-model:show="picker.show" position="bottom" round>
<van-picker
:columns="picker.columns"
@cancel="picker.show = false"
@confirm="onPickerConfirm"
/>
</van-popup>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { profileService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
const router = useRouter()
const appStore = useAppStore()
const loading = ref(true)
const saving = ref(false)
const profile = ref<any>(null)
const form = reactive({
regionName: '',
heightCm: '',
maritalStatus: '',
childrenStatus: '',
educationCode: '',
occupationCode: '',
})
const REGION_OPTIONS = [
{ text: '丹阳', value: '丹阳' },
{ text: '镇江市区', value: '镇江市区' },
{ text: '镇江其他辖区', value: '镇江其他辖区' },
{ text: '南京', value: '南京' },
]
const REGION_CODES: Record<string, string> = { 丹阳: '321181', 镇江市区: '321100', 南京: '320100' }
const MARITAL_OPTIONS = [
{ text: '未婚', value: 'never_married' },
{ text: '离异', value: 'divorced' },
{ text: '丧偶', value: 'widowed' },
]
const CHILDREN_OPTIONS = [
{ text: '无子女', value: 'none' },
{ text: '有子女', value: 'yes' },
]
const EDUCATION_OPTIONS = [
{ text: '高中/中专', value: 'senior_high' },
{ text: '大专', value: 'college' },
{ text: '本科', value: 'bachelor' },
{ text: '硕士', value: 'master' },
{ text: '博士', value: 'doctor' },
]
const OCCUPATION_OPTIONS = [
{ text: '机关事业单位', value: 'government' },
{ text: '教育', value: 'education' },
{ text: '医疗', value: 'medical' },
{ text: '企业职员', value: 'enterprise' },
{ text: '技术人员', value: 'technical' },
{ text: '个体经营', value: 'self_employed' },
{ text: '自由职业', value: 'freelance' },
{ text: '服务行业', value: 'service' },
{ text: '农业', value: 'agriculture' },
{ text: '其他', value: 'other' },
]
function labelOf(options: Array<{ text: string; value: string }>, value: string): string {
return options.find((o) => o.value === value)?.text || ''
}
const regionLabel = computed(() => labelOf(REGION_OPTIONS, form.regionName))
const maritalLabel = computed(() => labelOf(MARITAL_OPTIONS, form.maritalStatus))
const childrenLabel = computed(() => labelOf(CHILDREN_OPTIONS, form.childrenStatus))
const educationLabel = computed(() => labelOf(EDUCATION_OPTIONS, form.educationCode))
const occupationLabel = computed(() => labelOf(OCCUPATION_OPTIONS, form.occupationCode))
const genderLabel = computed(() => {
const g = profile.value?.gender
return g === 'male' ? '男' : g === 'female' ? '女' : ''
})
const picker = reactive<{ show: boolean; key: string; columns: any[] }>({ show: false, key: '', columns: [] })
const PICKER_COLUMNS: Record<string, any[]> = {
region: REGION_OPTIONS,
marital: MARITAL_OPTIONS,
children: CHILDREN_OPTIONS,
education: EDUCATION_OPTIONS,
occupation: OCCUPATION_OPTIONS,
}
function openPicker(key: string) {
picker.key = key
picker.columns = PICKER_COLUMNS[key]
picker.show = true
}
function onPickerConfirm({ selectedOptions }: any) {
const value = selectedOptions?.[0]?.value
const key = picker.key
picker.show = false
if (value === undefined) return
if (key === 'region') form.regionName = value
else if (key === 'marital') form.maritalStatus = value
else if (key === 'children') form.childrenStatus = value
else if (key === 'education') form.educationCode = value
else if (key === 'occupation') form.occupationCode = value
}
async function load() {
loading.value = true
try {
const p: any = await profileService.getProfile()
profile.value = p
form.regionName = p?.regionName || ''
form.heightCm = p?.heightCm ? String(p.heightCm) : ''
form.maritalStatus = p?.maritalStatus || ''
form.childrenStatus = p?.childrenStatus || ''
form.educationCode = p?.educationCode || ''
form.occupationCode = p?.occupationCode || ''
} catch {
/* 首次填写无资料 */
} finally {
loading.value = false
}
}
async function onNext() {
if (saving.value) return
if (!form.regionName) return appStore.toast('请选择居住区域')
if (!form.heightCm) return appStore.toast('请填写身高')
if (!form.maritalStatus) return appStore.toast('请选择婚姻状况')
if (!form.childrenStatus) return appStore.toast('请选择子女情况')
if (!form.educationCode) return appStore.toast('请选择学历')
if (!form.occupationCode) return appStore.toast('请选择职业类型')
saving.value = true
try {
await profileService.updateBasicProfile({
regionName: form.regionName,
regionCode: REGION_CODES[form.regionName] || null,
heightCm: Number(form.heightCm),
maritalStatus: form.maritalStatus,
childrenStatus: form.childrenStatus,
educationCode: form.educationCode,
occupationCode: form.occupationCode,
})
router.push('/profile/lifestyle')
} catch (err: any) {
appStore.toastError(err?.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.pb__hint {
font-size: 12px;
color: var(--mini-text-secondary);
margin: 0 0 8px;
line-height: 1.4;
}
.pb {
padding: 16px;
}
.card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 4px 0 8px;
margin-bottom: 12px;
}
.card :deep(.ui-section-header) {
padding: 0 16px;
}
.card :deep(.van-field) {
padding-top: 10px;
padding-bottom: 10px;
}
.pb__readonly {
padding: 4px 16px 4px;
}
.pb__row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid var(--mini-border-weak, #f2f3f5);
}
.pb__row:last-child {
border-bottom: none;
}
.pb__row-label {
font-size: 14px;
color: var(--mini-text-secondary);
}
.pb__row-value {
font-size: 14px;
color: var(--mini-text-strong);
}
.pb__actions {
margin-top: 8px;
}
</style>
@@ -0,0 +1,324 @@
<template>
<PageLayout title="资料确认">
<div class="pc">
<UiLoading v-if="loading" text="加载中…" />
<UiErrorState v-else-if="error" @retry="loadAll" />
<template v-else-if="profile">
<!-- 审核状态 -->
<div class="card">
<div class="pc__status-row">
<span class="pc__status-label">当前审核状态</span>
<UiTag :type="reviewTag">{{ reviewLabel }}</UiTag>
</div>
<div v-if="reviewComment" class="pc__comment">
<span class="pc__comment-title">审核反馈:</span>
<span class="pc__comment-text">{{ reviewComment }}</span>
</div>
</div>
<!-- 完整度 -->
<div class="card">
<UiSectionHeader title="资料完整度" />
<p class="pc__hint">{{ completion }}%</p>
<div class="pc__progress">
<div class="pc__progress-track">
<div class="pc__progress-fill" :style="{ width: completion + '%' }"></div>
</div>
<div class="pc__progress-hint">
{{ completion >= 100 ? '资料已完整,可提交审核' : '部分资料尚未填写,提交前请确认' }}
</div>
</div>
</div>
<!-- 资料概览 -->
<div class="card">
<UiSectionHeader title="资料概览" />
<p class="pc__hint">只读展示,点选对应项可修改</p>
<van-cell-group :border="false">
<van-cell title="昵称" :value="profile.displayName || '未设置'" is-link @click="router.push('/profile/basic')" />
<van-cell title="年龄" :value="profile.age ? profile.age + '岁' : '-'" is-link @click="router.push('/profile/basic')" />
<van-cell title="身高" :value="profile.heightCm ? profile.heightCm + 'cm' : '-'" is-link @click="router.push('/profile/basic')" />
<van-cell title="婚姻状况" :value="maritalLabel || '-'" is-link @click="router.push('/profile/basic')" />
<van-cell title="子女情况" :value="childrenLabel || '-'" is-link @click="router.push('/profile/basic')" />
<van-cell title="学历" :value="educationLabel || '-'" is-link @click="router.push('/profile/basic')" />
<van-cell title="居住区域" :value="profile.regionName || '-'" is-link @click="router.push('/profile/basic')" />
<van-cell title="生活照" :value="`${photoCount}张`" is-link @click="router.push('/profile/photos')" />
</van-cell-group>
</div>
<!-- 自我介绍 -->
<div class="card">
<UiSectionHeader title="自我介绍" more-text="修改" @more="router.push('/profile/intro')" />
<div class="pc__intro">{{ profile.selfIntro || '暂未填写' }}</div>
</div>
<!-- 择偶条件 -->
<div class="card">
<UiSectionHeader title="择偶条件" more-text="修改" @more="router.push('/profile/preference')" />
<div v-if="preferences">
<div class="pc__row">
<span class="pc__row-label">年龄范围</span>
<span class="pc__row-value">{{ preferences.minAge || '-' }} ~ {{ preferences.maxAge || '-' }}岁</span>
</div>
<div class="pc__row">
<span class="pc__row-label">期望婚姻状况</span>
<span class="pc__row-value">
{{ (preferences.maritalStatuses || []).map((c: string) => maritalText(c)).join('、') || '不限' }}
</span>
</div>
<div class="pc__row">
<span class="pc__row-label">最低学历</span>
<span class="pc__row-value">{{ educationText(preferences.minEducationCode) || '不限' }}</span>
</div>
</div>
<UiEmpty v-else description="尚未填写择偶条件" />
</div>
<!-- 提交须知 -->
<div class="pc__notice">
<UiSectionHeader title="提交前请确认" />
<div class="pc__notice-item">· 提交后资料将进入人工审核</div>
<div class="pc__notice-item">· 审核期间你可继续修改,但需重新提交</div>
<div class="pc__notice-item">· 审核通过后即可开始匹配</div>
<div class="pc__notice-item">· 资料须真实有效,禁止虚假信息</div>
</div>
<div class="pc__actions">
<van-button
type="primary"
block
round
:loading="submitting"
:disabled="!resubmitAllowed"
@click="onSubmit"
>
{{ isPending ? '审核中,不可重复提交' : '提交审核' }}
</van-button>
</div>
</template>
<UiEmpty v-else description="资料加载失败,请重试" />
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showConfirmDialog } from 'vant'
import { profileService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
import UiTag from '@/components/UiTag.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
const router = useRouter()
const appStore = useAppStore()
const loading = ref(true)
const error = ref(false)
const submitting = ref(false)
const profile = ref<any>(null)
const preferences = ref<any>(null)
const photoCount = ref(0)
const completion = ref(0)
const reviewStatus = ref('not_submitted')
const reviewComment = ref('')
const MARITAL: Record<string, string> = { never_married: '未婚', divorced: '离异', widowed: '丧偶', single: '未婚' }
const CHILDREN: Record<string, string> = { none: '无子女', has: '有子女', yes: '有子女' }
const EDUCATION: Record<string, string> = {
junior_high: '初中',
senior_high: '高中/中专',
high_school: '高中/中专',
college: '大专',
bachelor: '本科',
master: '硕士',
doctor: '博士',
}
const REVIEW_TEXT: Record<string, string> = {
not_submitted: '未提交',
pending: '审核中',
pending_review: '审核中',
approved: '已通过',
need_edit: '需修改',
rejected: '未通过',
}
function maritalText(code: string): string {
return MARITAL[code] || code
}
function educationText(code?: string): string {
if (!code) return ''
return EDUCATION[code] || code
}
const maritalLabel = computed(() => MARITAL[profile.value?.maritalStatus] || '')
const childrenLabel = computed(() => CHILDREN[profile.value?.childrenStatus] || '')
const educationLabel = computed(() => educationText(profile.value?.educationCode))
const isPending = computed(() => reviewStatus.value === 'pending' || reviewStatus.value === 'pending_review')
const reviewLabel = computed(() => REVIEW_TEXT[reviewStatus.value] || '未提交')
const reviewTag = computed<'primary' | 'success' | 'warning' | 'danger' | 'info'>(() => {
if (reviewStatus.value === 'approved') return 'success'
if (reviewStatus.value === 'need_edit' || isPending.value) return 'warning'
if (reviewStatus.value === 'rejected') return 'danger'
return 'info'
})
const resubmitAllowed = computed(
() => reviewStatus.value === 'not_submitted' || reviewStatus.value === 'need_edit' || reviewStatus.value === 'rejected',
)
async function loadAll() {
loading.value = true
error.value = false
try {
const [p, prefs, review, photosRes, comp] = await Promise.all([
profileService.getProfile().catch(() => null),
profileService.getPreferences().catch(() => null),
profileService.getReviewStatus().catch(() => null),
profileService.getPhotos().catch(() => ({ photos: [] })),
profileService.getCompletion().catch(() => null),
])
profile.value = p
preferences.value = prefs
const photos = (photosRes as any)?.photos || (photosRes as any)?.items || (photosRes as any)?.records || []
photoCount.value = photos.length
reviewStatus.value = (review as any)?.status || 'not_submitted'
const items = (review as any)?.items || []
reviewComment.value = items
.filter((i: any) => i.status === 'need_edit' || i.status === 'rejected')
.map((i: any) => i.reasonText)
.filter(Boolean)
.join(';')
const c = comp as any
const raw = c?.completion ?? c?.profileCompletion ?? (p as any)?.profileCompletion ?? 0
completion.value = Math.max(0, Math.min(100, Math.round(Number(raw) || 0)))
if (!p) error.value = true
} catch {
error.value = true
} finally {
loading.value = false
}
}
async function onSubmit() {
if (submitting.value || !resubmitAllowed.value) return
try {
await showConfirmDialog({
title: '确认提交审核?',
message: '提交后资料将进入人工审核,审核期间你可继续修改,但需重新提交审核。',
})
} catch {
return
}
submitting.value = true
try {
await profileService.submitReview()
appStore.toastSuccess('已提交审核')
router.replace('/review/pending')
} catch (err: any) {
appStore.toastError(err?.message || '提交失败')
} finally {
submitting.value = false
}
}
onMounted(loadAll)
</script>
<style scoped>
.pc__hint {
font-size: 12px;
color: var(--mini-text-secondary);
margin: 0 0 8px;
line-height: 1.4;
}
.pc {
padding: 16px;
}
.card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 4px 16px 14px;
margin-bottom: 12px;
}
.card :deep(.van-cell) {
padding-left: 0;
padding-right: 0;
}
.pc__status-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0 4px;
}
.pc__status-label {
font-size: 14px;
color: var(--mini-text-secondary);
}
.pc__comment {
margin-top: 8px;
padding: 10px 12px;
background: rgba(245, 166, 35, 0.1);
border-radius: 10px;
font-size: 13px;
color: var(--mini-text);
}
.pc__comment-title {
color: #b9770e;
}
.pc__progress {
margin-top: 4px;
}
.pc__progress-track {
height: 8px;
border-radius: 4px;
background: var(--mini-bg);
overflow: hidden;
}
.pc__progress-fill {
height: 100%;
background: var(--mini-accent-pink);
border-radius: 4px;
transition: width 0.3s;
}
.pc__progress-hint {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 8px;
}
.pc__intro {
font-size: 13px;
color: var(--mini-text);
line-height: 1.8;
padding: 4px 0;
}
.pc__row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 8px 0;
}
.pc__row-label {
font-size: 14px;
color: var(--mini-text-secondary);
}
.pc__row-value {
font-size: 14px;
color: var(--mini-text);
text-align: right;
}
.pc__notice-item {
font-size: 12px;
color: var(--mini-text-secondary);
line-height: 1.9;
}
.pc__actions {
margin-top: 4px;
}
</style>

Some files were not shown because too many files have changed in this diff Show More