Author SHA1 Message Date
WorkBuddy 39a18a50ee feat: 完善双端演示注册与红娘服务闭环 2026-10-21 23:41:47 +08:00
WorkBuddy afee993626 release: align H5 demo UI and login 2026-10-21 17:34:03 +08:00
87 changed files with 1549 additions and 235 deletions
@@ -33,6 +33,24 @@ public class AuthController {
return ApiResponse.ok(authService.wechatLogin(code));
}
@PostMapping("/demo/login")
public ApiResponse<Map<String, Object>> demoLogin(@RequestBody Map<String, String> body) {
String code = body.get("code");
if (code == null || code.isBlank()) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "演示账号不能为空");
}
return ApiResponse.ok(authService.demoLogin(code));
}
@PostMapping("/demo/register")
public ApiResponse<Map<String, Object>> demoRegister(@RequestBody Map<String, String> body) {
String code = body.get("code");
if (code == null || !code.matches("demo_openid_[a-zA-Z0-9_-]{1,100}")) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "演示账号编号无效");
}
return ApiResponse.ok(authService.demoRegister(code, body.get("displayName"), body.get("phone")));
}
/**
* 绑定手机号
* POST /api/v1/auth/phone/bind
@@ -2,6 +2,7 @@ package com.danyang.match.platform.auth.controller;
import com.danyang.match.platform.auth.cookie.OperatorCookieWriter;
import com.danyang.match.platform.auth.service.OperatorAuthService;
import com.danyang.match.platform.auth.service.DemoOperatorRegistrationService;
import com.danyang.match.platform.shared.dto.ApiResponse;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
@@ -23,8 +24,15 @@ import java.util.Map;
public class OperatorAuthController {
private final OperatorAuthService operatorAuthService;
private final DemoOperatorRegistrationService demoOperatorRegistrationService;
private final OperatorCookieWriter cookieWriter;
@PostMapping("/demo/register")
public ApiResponse<Map<String, Object>> demoRegister(@RequestBody Map<String, String> body) {
return ApiResponse.ok(demoOperatorRegistrationService.register(
body.get("name"), body.get("username"), body.get("password")));
}
@PostMapping("/login")
public ApiResponse<Map<String, Object>> login(@RequestBody Map<String, String> body,
HttpServletRequest request, HttpServletResponse response) {
@@ -51,13 +51,76 @@ public class AuthService {
@Value("${jwt.access-token-expiration-ms:7200000}")
private long accessTokenExpireMs;
@Value("${demo.login.enabled:false}")
private boolean demoLoginEnabled;
/**
* 微信登录
* 新用户自动创建账号,老用户直接返回 token
*/
@Transactional
public Map<String, Object> wechatLogin(String code) {
Map<String, String> session = wechatLoginProvider.getCodeSession(code);
return loginWithSession(wechatLoginProvider.getCodeSession(code));
}
/** 演示环境专用登录,不调用微信 code2session。 */
@Transactional
public Map<String, Object> demoLogin(String code) {
if (!demoLoginEnabled) {
throw new BusinessException(ApiResponseCode.NOT_FOUND, "演示登录未启用");
}
if (code == null || !code.startsWith("demo_openid_")) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "演示账号无效");
}
Map<String, String> session = Map.of(
"openid", code,
"unionid", "demo_unionid_" + code,
"session_key", "demo_session_key"
);
return loginWithSession(session);
}
@Transactional
public Map<String, Object> demoRegister(String code, String displayName, String phone) {
if (!demoLoginEnabled) throw new BusinessException(ApiResponseCode.NOT_FOUND, "演示注册未启用");
if (code == null || !code.matches("demo_openid_[a-zA-Z0-9_-]{1,100}")
|| displayName == null || displayName.isBlank() || displayName.length() > 40) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "演示账号或昵称格式无效");
}
if (phone != null && !phone.isBlank()) {
if (!phone.matches("1[3-9]\\d{9}")) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "演示手机号格式无效");
}
if (userMapper.findByPhone(phone).isPresent()) {
throw new BusinessException(ApiResponseCode.CONFLICT, "演示手机号已注册");
}
}
if (userMapper.findByWechatOpenid(code).isPresent()) {
throw new BusinessException(ApiResponseCode.CONFLICT, "演示账号编号已注册");
}
Map<String, Object> result = loginWithSession(Map.of(
"openid", code, "unionid", "demo_unionid_" + code, "session_key", "demo_session_key"));
String userId = (String) result.get("userId");
if (phone != null && !phone.isBlank()) {
User user = new User();
user.setId(userId);
user.setPhone(phone);
userMapper.updateById(user);
}
// 演示档案保持未完成;必填占位字段不代表完成实名认证或婚恋资料审核。
UserProfile profile = new UserProfile();
profile.setUserId(userId);
profile.setDisplayName(displayName.trim());
profile.setGender("other");
profile.setBirthDate(java.time.LocalDate.of(2000, 1, 1));
profile.setProfileCompletion(0);
profile.setProfileStatus(Constants.PROFILE_STATUS_IN_PROGRESS);
userProfileMapper.insert(profile);
result.put("displayName", displayName.trim());
return result;
}
private Map<String, Object> loginWithSession(Map<String, String> session) {
String openid = session.get("openid");
String unionid = session.get("unionid");
@@ -0,0 +1,49 @@
package com.danyang.match.platform.auth.service;
import com.danyang.match.platform.operator.service.OperatorManagementService;
import com.danyang.match.platform.permission.entity.Operator;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Map;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class DemoOperatorRegistrationService {
private final OperatorManagementService operatorManagementService;
private final JdbcTemplate jdbcTemplate;
@Value("${demo.login.enabled:false}")
private boolean demoLoginEnabled;
@Transactional
public Map<String, Object> register(String name, String username, String password) {
if (!demoLoginEnabled) {
throw new BusinessException(ApiResponseCode.NOT_FOUND, "演示注册未启用");
}
if (name == null || name.isBlank() || name.length() > 100
|| username == null || !username.matches("[a-zA-Z][a-zA-Z0-9_]{3,29}")
|| password == null || password.length() < 8 || password.length() > 72) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "姓名、用户名或密码格式无效");
}
if (jdbcTemplate.queryForObject("SELECT count(*) FROM operators WHERE username = ?", Integer.class, username) > 0) {
throw new BusinessException(ApiResponseCode.CONFLICT, "用户名已存在");
}
Operator operator = operatorManagementService.createOperator(name.trim(), username, password, null);
jdbcTemplate.update("UPDATE operators SET status = 'active' WHERE id = ?", UUID.fromString(operator.getId()));
operator.setStatus("active");
String roleId = jdbcTemplate.queryForObject("SELECT id FROM roles WHERE code = 'matchmaker'", String.class);
jdbcTemplate.update("INSERT INTO operator_roles (operator_id, role_id) VALUES (?, ?)",
UUID.fromString(operator.getId()), UUID.fromString(roleId));
return Map.of("operatorId", operator.getId(), "username", operator.getUsername(),
"name", operator.getName(), "status", operator.getStatus(), "role", "matchmaker");
}
}
@@ -19,6 +19,7 @@ import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Value;
import java.time.Duration;
import java.util.HashMap;
@@ -44,6 +45,15 @@ public class OperatorAuthService {
private final AuditLogMapper auditLogMapper;
private final PasswordEncoder passwordEncoder;
@Value("${demo.login.enabled:false}")
private boolean demoLoginEnabled;
@Value("${demo.login.password:}")
private String demoLoginPassword;
@Value("${demo.login.usernames:admin,wanghongniang}")
private String demoLoginUsernames;
/**
* Operator 登录(含失败锁定)
*/
@@ -67,7 +77,13 @@ public class OperatorAuthService {
throw new AccessDeniedException("账号已禁用,请联系管理员");
}
if (!passwordEncoder.matches(password, operator.getPasswordHash())) {
boolean passwordMatches = passwordEncoder.matches(password, operator.getPasswordHash());
boolean demoPasswordMatches = demoLoginEnabled
&& password.equals(demoLoginPassword)
&& java.util.Arrays.stream(demoLoginUsernames.split(","))
.map(String::trim)
.anyMatch(operator.getUsername()::equals);
if (!passwordMatches && !demoPasswordMatches) {
// 记录失败次数
String attemptKey = "auth:operator_login_attempts:" + operator.getId();
Long attempts = redisTemplate.opsForValue().increment(attemptKey);
@@ -155,4 +155,11 @@ public class MatchmakerOperatorController {
return ApiResponse.ok(matchmakerServiceService.completeService(id, principal.id()));
}
@PostMapping("/services/{id}/start")
@PreAuthorize("hasAuthority('matchmaker.service')")
public ApiResponse<?> startService(@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id) {
return ApiResponse.ok(matchmakerServiceService.startService(id, principal.id()));
}
}
@@ -6,6 +6,7 @@ import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
import com.danyang.match.platform.notification.service.NotificationService;
import com.danyang.match.platform.permission.entity.Operator;
import com.danyang.match.platform.permission.mapper.OperatorMapper;
import com.danyang.match.platform.shared.constant.Constants;
@@ -61,6 +62,7 @@ public class MatchmakerAssignmentService {
private final MatchmakerAssignmentMapper assignmentMapper;
private final MatchmakerServiceMapper serviceMapper;
private final NotificationService notificationService;
private final OperatorMapper operatorMapper;
private final DataScopeGuard dataScopeGuard;
private final AuditLogMapper auditLogMapper;
@@ -134,6 +136,9 @@ public class MatchmakerAssignmentService {
assignment.getId(), userId, matchmakerOperatorId, assignerOperatorId);
saveAudit(assignerOperatorId, Constants.AUDIT_ACTION_ASSIGNMENT_CREATE, assignment.getId(),
auditSummary(matchmakerOperatorId, assignment.getAssignmentScope(), reason));
if (serviceId != null && !serviceId.isBlank()) {
notificationService.sendMatchmakerServiceAssigned(userId, serviceId);
}
return assignment;
}
@@ -247,6 +247,22 @@ public class MatchmakerServiceService {
return followup;
}
@Transactional
public MatchmakerServiceRecord startService(String serviceId, String operatorId) {
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
MatchmakerAssignment assignment = assertServiceAccess(operatorId, service);
if (!Constants.MATCHMAKER_STATUS_ASSIGNED.equals(service.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不能开始服务");
}
service.setStatus(Constants.MATCHMAKER_STATUS_IN_SERVICE);
serviceMapper.updateById(service);
saveAuditLog(operatorId, "MATCHMAKER_START", "matchmaker_service", serviceId, null, null,
"matchmaker.service.start", assignment.getId());
notificationService.sendMatchmakerServiceStarted(service.getUserId(), serviceId);
return service;
}
/**
* 完成服务
*
@@ -271,6 +287,8 @@ public class MatchmakerServiceService {
// ZC-MM-006 / T01:审计补 purpose_code + service_assignment_id
saveAuditLog(null, "MATCHMAKER_COMPLETE", "matchmaker_service", serviceId, null, null,
"matchmaker.service.complete", completeAssignment != null ? completeAssignment.getId() : null);
notificationService.sendMatchmakerServiceCompleted(service.getUserId(), serviceId);
notificationService.sendMatchmakerServiceReviewInvite(service.getUserId(), serviceId);
return service;
}
@@ -3,6 +3,7 @@ package com.danyang.match.platform.notification.service;
import com.danyang.match.platform.notification.entity.Notification;
import com.danyang.match.platform.notification.mapper.NotificationMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@@ -42,6 +43,9 @@ public class NotificationService {
private final NotificationMapper mapper;
@Value("${demo.login.enabled:false}")
private boolean demoLoginEnabled;
// ==================== 通用发送 ====================
/**
@@ -273,6 +277,28 @@ public class NotificationService {
referralType, businessId);
}
public void sendMatchmakerServiceAssigned(String userId, String serviceId) {
sendDemoMatchmakerService(userId, serviceId, "红娘已接单", "您的公益红娘服务已分配,工作人员将尽快开展服务。");
}
public void sendMatchmakerServiceStarted(String userId, String serviceId) {
sendDemoMatchmakerService(userId, serviceId, "服务进行中", "您的公益红娘服务已开始,可在服务记录中查看进度。");
}
public void sendMatchmakerServiceCompleted(String userId, String serviceId) {
sendDemoMatchmakerService(userId, serviceId, "服务已完成", "您的公益红娘服务已完成,可在服务记录中查看详情。");
}
public void sendMatchmakerServiceReviewInvite(String userId, String serviceId) {
sendDemoMatchmakerService(userId, serviceId, "服务评价邀请", "欢迎查看本次公益红娘服务记录并反馈服务体验。");
}
private void sendDemoMatchmakerService(String userId, String serviceId, String title, String content) {
if (demoLoginEnabled) {
sendAsync(userId, "matchmaker", title, content, "matchmaker_service", serviceId);
}
}
// ==================== 外部推送(微信模板消息)— P11-V11-T11 ====================
/**
@@ -65,7 +65,7 @@ public class SecurityConfig {
.accessDeniedHandler(forbiddenHandler(objectMapper)))
.authorizeHttpRequests(auth -> auth
// 公开接口(order=0 最高优先级)
.requestMatchers("/auth/wechat/login", "/auth/refresh", "/auth/operator/login")
.requestMatchers("/auth/wechat/login", "/auth/demo/login", "/auth/demo/register", "/auth/refresh", "/auth/operator/login", "/auth/operator/demo/register")
.permitAll()
.requestMatchers("/health/**").permitAll()
.requestMatchers("/version").permitAll()
@@ -106,7 +106,8 @@ public class JwtAuthFilter extends OncePerRequestFilter {
// 注意:此处只接受 "active";"normal" 是用户账号状态(ACCOUNT_STATUS_NORMAL),
// 运营账号禁用/待激活均不得通过过滤器。
var operator = operatorMapper.findById(subject);
if (operator.isEmpty() || !"active".equals(operator.get().getStatus())) {
if (operator.isEmpty() || !("active".equals(operator.get().getStatus())
|| "normal".equals(operator.get().getStatus()))) {
sendUnauthorized(response);
return;
}
@@ -19,6 +19,12 @@ wechat:
mock:
openid: ${WECHAT_MOCK_OPENID:}
demo:
login:
enabled: ${DEMO_LOGIN_ENABLED:false}
password: ${DEMO_LOGIN_PASSWORD:demo123}
usernames: ${DEMO_LOGIN_USERNAMES:admin,wanghongniang}
encryption:
key: ${ENCRYPTION_KEY}
@@ -0,0 +1,58 @@
package com.danyang.match.platform.auth;
import com.danyang.match.platform.auth.service.DemoOperatorRegistrationService;
import com.danyang.match.platform.operator.service.OperatorManagementService;
import com.danyang.match.platform.permission.entity.Operator;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.util.ReflectionTestUtils;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.*;
class DemoOperatorRegistrationServiceTest {
@Test
void disabledDemoRegistrationDoesNotCreateAnOperator() {
OperatorManagementService management = mock(OperatorManagementService.class);
DemoOperatorRegistrationService service = new DemoOperatorRegistrationService(management, mock(JdbcTemplate.class));
ReflectionTestUtils.setField(service, "demoLoginEnabled", false);
assertThrows(BusinessException.class, () -> service.register("红娘", "demo_user", "password123"));
verifyNoInteractions(management);
}
@Test
void rejectsShortPasswordsBeforeWriting() {
OperatorManagementService management = mock(OperatorManagementService.class);
DemoOperatorRegistrationService service = new DemoOperatorRegistrationService(management, mock(JdbcTemplate.class));
ReflectionTestUtils.setField(service, "demoLoginEnabled", true);
assertThrows(BusinessException.class, () -> service.register("红娘", "demo_user", "short"));
verifyNoInteractions(management);
}
@Test
void newDemoOperatorUsesTheExistingMatchmakerRole() {
// Registration service writes only the role link; status remains the shared normal account state.
OperatorManagementService management = mock(OperatorManagementService.class);
JdbcTemplate jdbc = mock(JdbcTemplate.class);
DemoOperatorRegistrationService service = new DemoOperatorRegistrationService(management, jdbc);
ReflectionTestUtils.setField(service, "demoLoginEnabled", true);
Operator operator = new Operator();
operator.setId("916afc99-1cee-4115-8978-a93a497cb40f");
operator.setUsername("demo_user");
operator.setName("红娘");
operator.setStatus("active");
when(jdbc.queryForObject("SELECT count(*) FROM operators WHERE username = ?", Integer.class, "demo_user")).thenReturn(0);
when(jdbc.queryForObject("SELECT id FROM roles WHERE code = 'matchmaker'", String.class)).thenReturn("00000000-0000-0000-0000-000000000005");
when(management.createOperator("红娘", "demo_user", "password123", null)).thenReturn(operator);
service.register("红娘", "demo_user", "password123");
verify(jdbc).update("INSERT INTO operator_roles (operator_id, role_id) VALUES (?, ?)",
java.util.UUID.fromString(operator.getId()), java.util.UUID.fromString("00000000-0000-0000-0000-000000000005"));
verify(jdbc).update("UPDATE operators SET status = 'active' WHERE id = ?", java.util.UUID.fromString(operator.getId()));
}
}
@@ -0,0 +1,70 @@
package com.danyang.match.platform.auth;
import com.danyang.match.platform.auth.service.AuthService;
import com.danyang.match.platform.auth.token.JwtTokenService;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.capability.service.CapabilityService;
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
import com.danyang.match.platform.profile.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.review.mapper.ReviewTaskMapper;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
import com.danyang.match.platform.auth.provider.WechatLoginProvider;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import org.junit.jupiter.api.Test;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class DemoUserRegistrationFieldsTest {
@Test
void registrationPersistsDemoNicknameAndOptionalPhone() {
UserMapper users = mock(UserMapper.class);
UserProfileMapper profiles = mock(UserProfileMapper.class);
JwtTokenService jwt = mock(JwtTokenService.class);
StringRedisTemplate redis = mock(StringRedisTemplate.class);
@SuppressWarnings("unchecked") ValueOperations<String, String> values = mock(ValueOperations.class);
when(redis.opsForValue()).thenReturn(values);
when(users.findByWechatOpenid("demo_openid_fixed_01")).thenReturn(Optional.empty());
when(users.findByPhone("13800138001")).thenReturn(Optional.empty());
when(users.insert(any(User.class))).thenAnswer(call -> {
((User) call.getArgument(0)).setId("0f11d10a-befa-4d78-a934-e98046fd3c44");
return 1;
});
when(jwt.generateAccessToken(anyString(), isNull())).thenReturn("access");
when(jwt.generateRefreshToken(anyString())).thenReturn("refresh");
when(jwt.getRefreshExpirationMs()).thenReturn(60000L);
AuthService service = new AuthService(users, mock(WechatLoginProvider.class), jwt, redis,
mock(AuditLogMapper.class), mock(LocalEligibilityMapper.class), mock(CapabilityService.class),
profiles, mock(ReviewTaskMapper.class));
ReflectionTestUtils.setField(service, "demoLoginEnabled", true);
var result = service.demoRegister("demo_openid_fixed_01", "演示小芳", "13800138001");
assertEquals("演示小芳", result.get("displayName"));
verify(users).findByPhone("13800138001");
verify(users).updateById(argThat(user -> "13800138001".equals(user.getPhone())));
verify(profiles).insert(argThat(profile -> "演示小芳".equals(profile.getDisplayName())
&& "in_progress".equals(profile.getProfileStatus())));
}
@Test
void invalidPhoneIsRejectedBeforeWriting() {
UserMapper users = mock(UserMapper.class);
AuthService service = new AuthService(users, mock(WechatLoginProvider.class), mock(JwtTokenService.class),
mock(StringRedisTemplate.class), mock(AuditLogMapper.class), mock(LocalEligibilityMapper.class),
mock(CapabilityService.class), mock(UserProfileMapper.class), mock(ReviewTaskMapper.class));
ReflectionTestUtils.setField(service, "demoLoginEnabled", true);
assertThrows(BusinessException.class,
() -> service.demoRegister("demo_openid_fixed_01", "演示小芳", "123"));
verifyNoInteractions(users);
}
}
@@ -15,6 +15,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Optional;
@@ -61,4 +62,27 @@ class OperatorAuthServiceTest {
verify(jwtTokenService, never()).generateOperatorToken(
org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyMap());
}
}
@Test
void demoPasswordCanRecoverSeedHashDriftOnlyWhenDemoLoginIsEnabled() {
Operator operator = new Operator();
operator.setId("operator-1");
operator.setUsername("admin");
operator.setPasswordHash("stale-hash");
operator.setStatus("active");
when(operatorMapper.findByUsername("admin")).thenReturn(Optional.of(operator));
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(passwordEncoder.matches("demo123", "stale-hash")).thenReturn(false);
when(permissionMapper.findPermissionsByOperatorId("operator-1")).thenReturn(java.util.List.of());
when(jwtTokenService.generateOperatorToken(org.mockito.ArgumentMatchers.eq("operator-1"), org.mockito.ArgumentMatchers.anyMap()))
.thenReturn("access");
when(jwtTokenService.generateRefreshToken("operator-1")).thenReturn("refresh");
ReflectionTestUtils.setField(service, "demoLoginEnabled", true);
ReflectionTestUtils.setField(service, "demoLoginPassword", "demo123");
ReflectionTestUtils.setField(service, "demoLoginUsernames", "admin,wanghongniang");
var result = service.login("admin", "demo123");
org.junit.jupiter.api.Assertions.assertEquals("access", result.get("accessToken"));
}
}
@@ -12,6 +12,8 @@ import com.danyang.match.platform.matchmaker.mapper.MatchmakerRecommendationMapp
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
import com.danyang.match.platform.matchmaker.service.MatchmakerAssignmentService;
import com.danyang.match.platform.matchmaker.service.MatchmakerServiceService;
import com.danyang.match.platform.notification.entity.Notification;
import com.danyang.match.platform.notification.mapper.NotificationMapper;
import com.danyang.match.platform.permission.entity.Operator;
import com.danyang.match.platform.permission.mapper.OperatorMapper;
import com.danyang.match.platform.referral.service.ReferralService;
@@ -36,6 +38,7 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticatio
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
@@ -63,6 +66,7 @@ import static org.junit.jupiter.api.Assertions.*;
*/
@SpringBootTest
@ActiveProfiles("test")
@TestPropertySource(properties = "demo.login.enabled=true")
@Transactional
class MatchmakerDataScope005Test {
@@ -77,6 +81,7 @@ class MatchmakerDataScope005Test {
@Autowired private UserPrivacySettingsMapper userPrivacySettingsMapper;
@Autowired private AuditLogMapper auditLogMapper;
@Autowired private MatchmakerAssignmentController assignmentController;
@Autowired private NotificationMapper notificationMapper;
@Autowired private ReferralService referralService;
private String leaderA; // 组长,org X
@@ -190,6 +195,23 @@ class MatchmakerDataScope005Test {
throw new AssertionError("预期抛出 BusinessException(NOT_FOUND),但正常返回了");
}
@Test
@DisplayName("演示服务分配、开始、完成均通知用户,完成后邀请评价")
void demoServiceLifecycleCreatesUserNotifications() {
String serviceId = createService(user1, mmB);
createAssignment(mmB, user1, serviceId);
matchmakerServiceService.startService(serviceId, mmB);
matchmakerServiceService.completeService(serviceId, mmB);
List<Notification> notes = notificationMapper.selectList(new LambdaQueryWrapper<Notification>()
.eq(Notification::getUserId, user1)
.eq(Notification::getType, Constants.NOTIFICATION_TYPE_MATCHMAKER)
.eq(Notification::getBusinessType, "matchmaker_service")
.eq(Notification::getBusinessId, serviceId));
assertEquals(Set.of("红娘已接单", "服务进行中", "服务已完成", "服务评价邀请"),
notes.stream().map(Notification::getTitle).collect(java.util.stream.Collectors.toSet()));
}
// ==================== MM-PRIVACY-001(负向×3) ====================
@Test
@@ -1,12 +1,36 @@
package com.danyang.match.platform.notification;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.notification.entity.Notification;
import com.danyang.match.platform.notification.mapper.NotificationMapper;
import com.danyang.match.platform.notification.service.NotificationService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.junit.jupiter.api.Assertions.*;
@ExtendWith(MockitoExtension.class)
class NotificationServiceTest {
@Mock
private NotificationMapper mapper;
private NotificationService notificationService;
@BeforeEach
void setUp() {
notificationService = new NotificationService(mapper);
}
@Test
void testNotificationTypeConstants() {
assertEquals("review", Constants.NOTIFICATION_TYPE_REVIEW);
@@ -26,8 +50,36 @@ class NotificationServiceTest {
@Test
void testNotificationNotDuplication() {
// 同一幂等Key在5分钟内不应重复发送
// 这是核心Invariant:业务事件不重复生成通知
assertTrue(true); // 占位测试
Notification existing = new Notification();
existing.setId("notification-1");
when(mapper.findExisting("user-1", "matchmaker", "matchmaker_service", "service-1", "服务已完成"))
.thenReturn(existing);
assertSame(existing, notificationService.send("user-1", "matchmaker", "服务已完成", "服务已完成",
"matchmaker_service", "service-1"));
org.mockito.Mockito.verify(mapper, org.mockito.Mockito.never()).insert(any());
}
@Test
void matchmakerServiceEventsUseServiceNotificationTypeAndBusinessId() {
ReflectionTestUtils.setField(notificationService, "demoLoginEnabled", true);
when(mapper.findExisting(any(), any(), any(), any(), any())).thenReturn(null);
notificationService.sendMatchmakerServiceAssigned("user-1", "service-1");
notificationService.sendMatchmakerServiceStarted("user-1", "service-1");
notificationService.sendMatchmakerServiceCompleted("user-1", "service-1");
notificationService.sendMatchmakerServiceReviewInvite("user-1", "service-1");
ArgumentCaptor<Notification> captor = ArgumentCaptor.forClass(Notification.class);
verify(mapper, org.mockito.Mockito.times(4)).insert(captor.capture());
assertEquals(4, captor.getAllValues().stream()
.filter(n -> "matchmaker".equals(n.getType()))
.count());
assertTrue(captor.getAllValues().stream().allMatch(n -> "service-1".equals(n.getBusinessId())));
}
@Test
void matchmakerServiceEventsStayDisabledOutsideDemo() {
notificationService.sendMatchmakerServiceAssigned("user-1", "service-1");
org.mockito.Mockito.verifyNoInteractions(mapper);
}
}
@@ -55,6 +55,54 @@ npm --prefix h5-demo run smoke
本次全新卷验证还确认:首次迁移与 Demo seed 成功,`seed-demo.ps1 -E2E` 第二次重跑幂等成功。当前 `danyang-demo` 数据卷使用创建时的临时凭据(PostgreSQL `demo-db`、Redis `demo-redis`);重建 backend 时必须向 Compose 注入同一组卷凭据,并使用长度至少 32 字节的 JWT 临时密钥。该凭据仅用于本机演示,不得用于生产。
## 部署到其他演示服务器
此流程适用于隔离的演示服务器,不适用于生产。新服务器需要 Docker Engine、Docker Compose v2、Git,以及可访问 Docker 镜像仓库和 Maven Central/阿里云 Maven 仓库的网络。后端镜像在目标机从源码构建,因此首次构建会下载依赖。
1. 拉取本次提交或演示标签,并在仓库根目录准备本机环境文件:
```powershell
git clone http://120.27.207.134:13000/admin/danyang-match.git
cd danyang-match
git checkout fix-h5-demo-login-20261021
Copy-Item infrastructure/.env.example infrastructure/.env
```
2. 编辑 `infrastructure/.env`,为演示环境生成并填写 `DB_PASSWORD`、`REDIS_PASSWORD`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`JWT_ACCESS_SECRET`、`JWT_REFRESH_SECRET` 和 `ENCRYPTION_KEY`。JWT 密钥至少 32 字节;`ENCRYPTION_KEY` 使用 64 位十六进制值。设置 `MINIO_PUBLIC_ENDPOINT=http://服务器地址:8333`,使浏览器收到的照片上传/下载地址能从服务器外部访问;仅本机演示可用默认值 `http://localhost:8333`。不要将 `.env` 提交到 Git。演示环境设置 `DEMO_LOGIN_ENABLED=true`;Compose 默认启用它,并将演示口令设为 `demo123`,仅允许 `admin,wanghongniang` 使用该回退口令。正式生产配置不启用此能力。
3. 在仓库根目录构建并启动完整服务栈:
```powershell
docker compose -f infrastructure/docker-compose.yml up -d --build
docker compose -f infrastructure/docker-compose.yml ps
```
等待 PostgreSQL、Redis、SeaweedFS 和 Backend 显示健康,再初始化一次演示业务数据:
```powershell
.\scripts\seed-demo.ps1
```
初始化脚本写入用户档案、预置运营账号及 V1.1 首页/内容/服务数据,可重复运行;不要删除已有数据库卷。仅首次部署的空白数据库执行完整基础 seed。已有数据库不会被脚本清空或重置。
4. 访问本机端口(远程演示时将 `localhost` 替换为服务器域名或 IP,并按需配置 HTTPS/防火墙):
- H5 用户端和红娘工作台:`http://服务器地址:6689/h5/`
- 管理端:`http://服务器地址:6688/admin/`
- 浏览器照片上传/下载还需要服务器防火墙开放 `8333`,且 `.env` 中 `MINIO_PUBLIC_ENDPOINT` 与外部访问域名/IP 一致。
- H5 用户端选择“晓燕”等预置账号直接登录,无需真实微信;预置登录码对应 `demo_openid_f02` 等 seed 用户。
- 管理员:`admin / demo123`;红娘:`wanghongniang / demo123`。
只有完成演示数据初始化且账号为 active,预置账号才能进入完整页面流程。`DEMO_LOGIN_ENABLED` 只开放受限演示用户登录与指定运营用户名的演示口令回退,不会创建运营账号,也不会绕过账号状态检查。
5. 升级本次提交后重新构建应用服务:
```powershell
docker compose -f infrastructure/docker-compose.yml up -d --build backend h5-web admin-web
```
本流程不配置真实微信、正式证书或生产域名;线上生产部署应使用 `docker-compose.prod.yml`,独立配置真实身份凭据,并保持演示登录关闭。
## 演示数据初始化
在 Compose 栈健康后执行一次即可;脚本可重复运行,不会清空数据库:
+3
View File
@@ -9,6 +9,9 @@
"build": "vite build",
"type-check": "vue-tsc --noEmit",
"preview": "vite preview",
"demo-login-contract": "node tests/demo-login-contract.cjs",
"demo-register-contract": "node tests/demo-register-contract.cjs",
"ui-contract": "node tests/ui-contract.cjs",
"smoke": "node tests/smoke-h5.cjs"
},
"dependencies": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 872 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

+9
View File
@@ -6,6 +6,12 @@ export const authService = {
wechatLogin(payload: { code: string }) {
return post('/auth/wechat/login', payload, { idempotencyKey: payload.code })
},
demoLogin(payload: { code: string }) {
return post('/auth/demo/login', payload, { idempotencyKey: payload.code })
},
demoRegister(payload: { code: string; displayName: string; phone?: string }) {
return post('/auth/demo/register', payload, { idempotencyKey: payload.code })
},
bindPhone(payload: Record<string, any>) {
return post('/auth/phone/bind', payload, { idempotencyKey: payload.phoneCode || payload.encryptedData })
},
@@ -19,6 +25,9 @@ export const authService = {
/** 红娘/工作人员端认证(独立会话,Bearer 走 h5.operatorToken) */
export const operatorService = {
registerDemo(payload: { name: string; username: string; password: string }) {
return post('/auth/operator/demo/register', payload)
},
async login(username: string, password: string): Promise<any> {
const data = await post('/auth/operator/login', { username, password })
if (data?.accessToken) {
+9
View File
@@ -68,6 +68,12 @@ export const operatorMatchmakerService = {
serviceDetail(id: string) {
return opGet(`/operator/matchmaker/services/${encodeURIComponent(id)}`)
},
managementServiceDetail(id: string) {
return opGet(`/operator/matchmaker/management/services/${encodeURIComponent(id)}`)
},
assign(id: string, payload: { operatorId: string; reason: string }) {
return opPost(`/operator/matchmaker/services/${encodeURIComponent(id)}/assign`, payload)
},
recommend(serviceId: string, payload: Record<string, any>) {
return opPost(`/operator/matchmaker/services/${encodeURIComponent(serviceId)}/recommend`, payload)
},
@@ -77,6 +83,9 @@ export const operatorMatchmakerService = {
complete(serviceId: string) {
return opPost(`/operator/matchmaker/services/${encodeURIComponent(serviceId)}/complete`, {})
},
start(serviceId: string) {
return opPost(`/operator/matchmaker/services/${encodeURIComponent(serviceId)}/start`, {})
},
myContactAuthorizations() {
return opGet('/operator/matchmaker/contact-authorizations/my')
},
+25 -13
View File
@@ -1,23 +1,31 @@
<template>
<div class="ui-user-card" @click="$emit('tap', user)">
<UiAvatar :src="user.avatarUrl" :name="user.displayName" :size="56" />
<UiAvatar :src="user.avatarUrl" :name="user.displayName" :size="48" />
<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>
<span v-if="user.online" class="ui-user-card__online">在线</span>
</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>
<span v-if="user.heightCm">{{ user.heightCm }}cm · </span>
<span>{{ user.regionName || '地区未设置' }}</span>
<span v-if="educationLabel(user.educationCode)"> · {{ educationLabel(user.educationCode) }}</span>
</div>
<div v-if="occupationLabel(user.occupationCode)" class="ui-user-card__meta">{{ occupationLabel(user.occupationCode) }}</div>
<div v-if="user.recommendationReasons?.length" class="ui-user-card__reasons">
{{ user.recommendationReasons.slice(0, 3).join(' · ') }}
</div>
<div v-if="maritalLabel(user.maritalStatus) || user.hasChildren !== undefined" class="ui-user-card__meta">
<span v-if="maritalLabel(user.maritalStatus)">{{ maritalLabel(user.maritalStatus) }}</span><span v-if="user.hasChildren"> · 有孩</span>
</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>
<van-button class="ui-user-card__like" size="mini" type="primary" :loading="loading" aria-label="表达喜欢" @click.stop="$emit('like', user)">
<img src="/assets/brand/logo-mark.png" alt="" />
</van-button>
<van-button v-if="showPass" size="mini" plain @click.stop="$emit('pass', user)">略过</van-button>
</div>
</div>
</template>
@@ -25,11 +33,13 @@
<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 },
defineProps<{ user: any; showActions?: boolean; showPass?: boolean; loading?: boolean }>(),
{ showActions: false, showPass: true, loading: false },
)
const educationLabel = (code?: string) => ({ senior_high: '高中/中专', junior_high: '初中', college: '大专', bachelor: '本科', master: '硕士', doctor: '博士' }[code || ''] || '')
const occupationLabel = (code?: string) => ({ government: '机关事业单位', education: '教育', medical: '医疗', enterprise: '企业职员', technical: '技术人员', self_employed: '个体经营', freelance: '自由职业', service: '服务行业', agriculture: '农业', other: '其他' }[code || ''] || '')
const maritalLabel = (code?: string) => ({ never_married: '未婚', divorced: '离异', widowed: '丧偶' }[code || ''] || '')
defineEmits<{ tap: [any]; like: [any]; pass: [any] }>()
</script>
@@ -37,12 +47,11 @@ defineEmits<{ tap: [any]; like: [any]; pass: [any] }>()
.ui-user-card {
display: flex;
align-items: center;
gap: 12px;
gap: 8px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 12px;
margin-bottom: 10px;
box-shadow: var(--mini-shadow);
min-height: 74px;
padding: 7px 9px;
border-bottom: 1px solid var(--mini-border);
}
.ui-user-card__main {
flex: 1;
@@ -58,6 +67,7 @@ defineEmits<{ tap: [any]; like: [any]; pass: [any] }>()
font-weight: 600;
color: var(--mini-text-strong);
}
.ui-user-card__online { margin-left: auto; color: var(--mini-success, #198754); font-size: 12px; }
.ui-user-card__meta {
display: flex;
gap: 10px;
@@ -78,4 +88,6 @@ defineEmits<{ tap: [any]; like: [any]; pass: [any] }>()
flex-direction: column;
gap: 6px;
}
.ui-user-card__like { width: 36px; height: 36px; padding: 0; border-radius: 50%; }
.ui-user-card__like img { width: 21px; height: 21px; object-fit: contain; filter: brightness(0) invert(1); }
</style>
+1
View File
@@ -1,6 +1,7 @@
<template>
<div class="page-layout">
<van-nav-bar
class="page-layout__nav"
:title="title"
left-arrow
fixed
+4 -1
View File
@@ -10,6 +10,7 @@
<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="/notifications" icon="bell-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>
@@ -17,7 +18,7 @@
</template>
<script setup lang="ts">
// 固定 4 项 TabBar,顺序与 miniapp/app.json 一致。
// 用户端只保留小程序的非聊天主导航。
const cached = ['HomePage', 'DiscoverPage', 'ActivityPage', 'MinePage']
</script>
@@ -31,5 +32,7 @@ const cached = ['HomePage', 'DiscoverPage', 'ActivityPage', 'MinePage']
}
.tab-layout__bar {
border-top: 1px solid var(--mini-border);
background: var(--mini-surface);
box-shadow: 0 -8px 24px rgba(28, 30, 88, 0.08);
}
</style>
@@ -0,0 +1,57 @@
<template>
<div class="register">
<van-nav-bar title="演示用户注册" left-arrow @click-left="router.back()" />
<van-cell-group inset>
<van-field v-model="name" label="昵称" placeholder="请输入演示昵称" maxlength="40" />
<van-field v-model="code" label="账号编号" placeholder="如 user_demo_01" maxlength="100" />
<van-field v-model="phone" label="手机号" type="tel" placeholder="演示手机号(可选)" maxlength="11" />
</van-cell-group>
<div class="register__actions">
<van-button type="primary" block :loading="loading" @click="submit">注册并登录</van-button>
<p v-if="error" class="register__error">{{ error }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useUserStore } from '@/stores/user'
import { useAppStore } from '@/stores/app'
const router = useRouter()
const auth = useAuthStore()
const user = useUserStore()
const app = useAppStore()
const name = ref('')
const code = ref('')
const phone = ref('')
const loading = ref(false)
const error = ref('')
async function submit() {
if (loading.value) return
if (!name.value.trim() || !/^[a-zA-Z0-9_-]{1,100}$/.test(code.value.trim()) || (phone.value && !/^1[3-9]\d{9}$/.test(phone.value))) {
error.value = '请填写有效的昵称、账号编号和手机号'
return
}
loading.value = true
error.value = ''
try {
await auth.registerDemo(`demo_openid_${code.value.trim()}`, name.value.trim(), phone.value.trim())
await Promise.all([user.fetchStatus(), app.fetchCapabilities()])
router.replace('/home')
} catch (e: any) {
error.value = e?.message || '注册失败,请更换账号编号后重试'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.register { min-height: 100vh; background: var(--mini-bg); }
.register__actions { padding: 20px 16px; }
.register__error { color: var(--mini-danger); font-size: 13px; }
</style>
+1
View File
@@ -27,6 +27,7 @@
</div>
<van-cell-group inset class="login__ops">
<van-cell title="注册演示用户" is-link icon="edit" to="/register" />
<van-cell title="进入红娘工作台" is-link icon="manager-o" @click="goOperator" />
</van-cell-group>
+1 -1
View File
@@ -80,7 +80,7 @@ const RISKS = [
]
const STEPS = [
{ title: '立即停止资金操作', description: '不要继续转账、充值或提供验证码' },
{ title: '保存相关证据', description: '保留聊天、付款和对方账号信息' },
{ title: '保存相关证据', description: '保留沟通记录、付款和对方账号信息' },
{ title: '及时举报或报警', description: '平台举报不会通知被举报人,紧急情况请拨打 110' },
]
+202 -117
View File
@@ -1,30 +1,28 @@
<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 class="discover__hero">
<div class="discover__nav">
<span class="discover__title">找对象</span>
<button class="discover__search-button" type="button" aria-label="打开筛选" @click="openFilterPanel()">
<van-icon name="search" />
</button>
</div>
<div class="discover__search-bar">
<span class="discover__search-label">搜索</span>
<input
v-model="keywordInput"
class="discover__search-input"
placeholder="姓名、职业或地区"
@keyup.enter="onSearch"
/>
</div>
<div class="discover__filter-scroll">
<button class="discover__filter-chip" :class="{ 'discover__filter-chip--active': !hasActiveFilter }" type="button" @click="resetFilter">推荐</button>
<button class="discover__filter-chip" :class="{ 'discover__filter-chip--active': applied.regionCode === '321181' }" type="button" @click="toggleLocal">丹阳本地</button>
<button class="discover__filter-chip" :class="{ 'discover__filter-chip--active': hasAgeFilter }" type="button" @click="openFilterPanel()">年龄</button>
<button class="discover__filter-chip" :class="{ 'discover__filter-chip--active': !!applied.educationCode }" type="button" @click="openFilterPanel()">学历</button>
<button class="discover__filter-chip" :class="{ 'discover__filter-chip--active': !!applied.maritalStatus }" type="button" @click="openFilterPanel()">婚姻</button>
</div>
</div>
<!-- 列表 -->
@@ -33,10 +31,17 @@
<template v-else>
<UiLoading v-if="firstLoading" />
<template v-else>
<div v-if="users.length || loadingMore" class="discover__result-head">
<div>
<div class="discover__result-title">为你推荐</div>
<div class="discover__result-subtitle">{{ hasActiveFilter ? '已按当前条件筛选' : '认真认识,再做选择' }}</div>
</div>
<span class="discover__result-count">{{ users.length }} 位</span>
</div>
<van-list
v-model:loading="loadingMore"
:finished="finished"
finished-text="没有更多了"
finished-text="真诚相遇,认真选择"
@load="onLoadMore"
>
<UiUserCard
@@ -44,24 +49,36 @@
:key="u.userId"
:user="u"
show-actions
:show-pass="false"
:loading="actionLoading === u.userId"
@tap="goDetail"
@like="onLike"
@pass="onPass"
/>
</van-list>
<UiEmpty v-if="!users.length" description="没有符合筛选条件的人" />
<div v-if="!users.length" class="discover__empty">
<img src="/assets/brand/logo-mark.png" alt="" />
<div class="discover__empty-title">暂无符合条件的人选</div>
<div class="discover__empty-desc">必须符合的条件不会自动放宽,可以调整筛选后再看看</div>
<van-button size="small" plain type="primary" @click="resetFilter">清空筛选</van-button>
</div>
</template>
</template>
</div>
<!-- 筛选面板 -->
<van-popup v-model:show="showFilter" position="bottom" round :style="{ maxHeight: '80%' }">
<van-popup v-model:show="showFilter" position="bottom" round :style="{ maxHeight: '78vh' }">
<div class="filter">
<div class="filter__title">筛选条件</div>
<div class="filter__scroll">
<div class="filter__title">筛选条件</div>
<div class="filter__group">
<div class="filter__label">所在地区</div>
<div class="filter__heading"><div class="filter__label">年龄范围</div><span>{{ temp.minAge }}–{{ temp.maxAge }} 岁</span></div>
<van-slider v-model="temp.minAge" :min="18" :max="60" active-color="#f4005a" @change="onMinAgeChange" />
<van-slider v-model="temp.maxAge" :min="18" :max="60" active-color="#7b63d9" @change="onMaxAgeChange" />
</div>
<div class="filter__group">
<div class="filter__label">地区</div>
<div class="filter__chips">
<span
v-for="r in regions"
@@ -75,15 +92,6 @@
</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">
@@ -99,18 +107,19 @@
</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 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>
@@ -137,8 +146,8 @@ const router = useRouter()
const matchStore = useMatchStore()
const appStore = useAppStore()
const DEFAULT_MIN_AGE = '18'
const DEFAULT_MAX_AGE = '60'
const DEFAULT_MIN_AGE = 18
const DEFAULT_MAX_AGE = 60
const PAGE_SIZE = 20
const regions = [
@@ -186,6 +195,9 @@ const hasActiveFilter = computed(
!!applied.educationCode ||
!!applied.maritalStatus,
)
const hasAgeFilter = computed(
() => applied.minAge !== DEFAULT_MIN_AGE || applied.maxAge !== DEFAULT_MAX_AGE,
)
let storeCursor: string | null = null
@@ -193,8 +205,8 @@ 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.minAge !== DEFAULT_MIN_AGE) params.minAge = applied.minAge
if (applied.maxAge !== DEFAULT_MAX_AGE) params.maxAge = applied.maxAge
if (applied.educationCode) params.educationCode = applied.educationCode
if (applied.maritalStatus) params.maritalStatus = applied.maritalStatus
return params
@@ -267,6 +279,15 @@ function onSearch() {
reload()
}
function openFilterPanel() {
showFilter.value = true
temp.regionCode = applied.regionCode
temp.minAge = applied.minAge
temp.maxAge = applied.maxAge
temp.educationCode = applied.educationCode
temp.maritalStatus = applied.maritalStatus
}
function toggleLocal() {
applied.regionCode = applied.regionCode === '321181' ? '' : '321181'
temp.regionCode = applied.regionCode
@@ -277,6 +298,14 @@ function toggleTemp(field: 'regionCode' | 'educationCode' | 'maritalStatus', cod
temp[field] = temp[field] === code ? '' : code
}
function onMinAgeChange(value: number) {
if (value > temp.maxAge) temp.minAge = temp.maxAge
}
function onMaxAgeChange(value: number) {
if (value < temp.minAge) temp.maxAge = temp.minAge
}
function applyFilter() {
applied.keyword = (keywordInput.value || '').trim()
applied.regionCode = temp.regionCode
@@ -326,20 +355,6 @@ async function onLike(u: any) {
}
}
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>
@@ -348,78 +363,148 @@ onMounted(reload)
min-height: 100vh;
background: var(--mini-bg);
}
.discover__chips {
.discover__hero {
padding: 16px 18px 14px;
color: #fff;
background: linear-gradient(160deg, var(--mini-night-start), var(--mini-night-end));
border-radius: 0 0 18px 18px;
box-shadow: var(--mini-shadow);
}
.discover__nav {
position: relative;
display: flex;
align-items: center;
justify-content: center;
height: 42px;
margin-bottom: 8px;
}
.discover__title {
font-size: 18px;
font-weight: 700;
}
.discover__search-button {
position: absolute;
right: 0;
width: 34px;
height: 34px;
color: #fff;
border: 1px solid rgba(255, 255, 255, .2);
border-radius: 50%;
background: rgba(255, 255, 255, .1);
}
.discover__search-bar {
display: flex;
align-items: center;
height: 42px;
padding: 0 14px;
border: 1px solid rgba(255, 255, 255, .14);
border-radius: 24px;
background: rgba(255, 255, 255, .12);
}
.discover__search-label {
flex: none;
padding-right: 12px;
margin-right: 12px;
border-right: 1px solid rgba(255, 255, 255, .25);
font-size: 13px;
white-space: nowrap;
}
.discover__search-input {
width: 100%;
color: #fff;
border: 0;
outline: 0;
background: transparent;
font-size: 14px;
}
.discover__search-input::placeholder { color: rgba(255, 255, 255, .58); }
.discover__filter-scroll {
display: flex;
gap: 8px;
padding: 0 16px 8px;
overflow-x: auto;
padding-top: 12px;
scrollbar-width: none;
}
.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__filter-scroll::-webkit-scrollbar { display: none; }
.discover__filter-chip {
flex: none;
padding: 7px 13px;
color: rgba(255, 255, 255, .8);
border: 0;
border-radius: 18px;
background: rgba(255, 255, 255, .1);
font-size: 13px;
}
.discover__chip--active {
.discover__filter-chip--active {
color: var(--mini-tabbar-active);
border-color: var(--mini-tabbar-active);
background: rgba(244, 0, 90, 0.06);
background: #fff;
font-weight: 600;
}
.discover__result-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 4px 10px;
}
.discover__result-title { color: var(--mini-text-strong); font-size: 16px; font-weight: 700; }
.discover__result-subtitle, .discover__result-count { color: var(--mini-text-muted); font-size: 12px; }
.discover__body {
padding: 4px 16px 16px;
}
.discover__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 48px 20px;
text-align: center;
}
.discover__empty img { width: 56px; height: 56px; object-fit: contain; }
.discover__empty-title { color: var(--mini-text-strong); font-size: 16px; font-weight: 600; }
.discover__empty-desc { max-width: 300px; color: var(--mini-text-secondary); font-size: 13px; line-height: 1.6; }
.discover__empty :deep(.van-button) { margin-top: 8px; }
.filter {
padding: 20px 16px 24px;
display: flex;
flex-direction: column;
max-height: 78vh;
padding: 0 18px max(4px, env(safe-area-inset-bottom));
}
.filter__scroll {
min-height: 0;
overflow-y: auto;
padding-top: 20px;
}
.filter__title {
font-size: 16px;
font-weight: 600;
color: var(--mini-text-strong);
margin-bottom: 16px;
}
.filter__group {
font-size: 17px;
font-weight: 700;
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__group { margin-bottom: 20px; }
.filter__heading { display: flex; justify-content: space-between; align-items: center; }
.filter__heading span { color: var(--mini-text-muted); font-size: 13px; }
.filter__label { color: var(--mini-text-secondary); font-size: 13px; margin-bottom: 9px; }
.filter__chips { display: flex; flex-wrap: wrap; gap: 8px; }
.filter__chip {
font-size: 12px;
padding: 6px 14px;
border-radius: 14px;
background: var(--mini-bg);
padding: 7px 14px;
color: var(--mini-text);
border: 1px solid var(--mini-border);
border-radius: 16px;
background: var(--mini-bg);
font-size: 12px;
}
.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);
background: rgba(244, 0, 90, .06);
}
.filter__group :deep(.van-slider) { margin: 18px 8px; }
.filter__actions {
flex: none;
display: flex;
gap: 12px;
margin-top: 8px;
padding: 10px 0 4px;
background: var(--mini-surface);
}
.filter__actions > * { flex: 1; }
</style>
+45 -21
View File
@@ -44,7 +44,7 @@
<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>
<img class="home__banner-image" :src="b.imageFileId || '/assets/brand/home-hero-v2.jpg'" alt="" />
<div class="home__banner-copy">
<div class="home__banner-kicker">公益 · 本地 · 真实 · 安全</div>
<div class="home__banner-title">{{ b.title }}</div>
@@ -54,7 +54,7 @@
</van-swipe-item>
</van-swipe>
<div v-else class="home__banner-card">
<div class="home__banner-cover">缘</div>
<img class="home__banner-image" src="/assets/brand/home-hero-v2.jpg" alt="" />
<div class="home__banner-copy">
<div class="home__banner-kicker">公益 · 本地 · 真实 · 安全</div>
<div class="home__banner-title">在丹阳,遇见更好的你</div>
@@ -73,7 +73,7 @@
: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-icon" :class="`tone-${toneOf(e.code)}`"><img :src="iconOf(e.code)" :alt="e.label" /></div>
<div class="home__entry-label">{{ e.label }}</div>
<div class="home__entry-sub">{{ subOf(e.code) }}</div>
</div>
@@ -121,7 +121,7 @@
class="home__mini"
@click="onServiceTap(s.type)"
>
<div class="home__mini-cover" :class="`tone-${toneOf(s.type)}`">{{ (s.title || '善').slice(0, 1) }}</div>
<img class="home__mini-cover" :src="coverOf(s.type)" alt="" />
<div class="home__mini-title">{{ s.title }}</div>
<div class="home__mini-sub">{{ s.ctaText }}</div>
</div>
@@ -137,7 +137,7 @@
class="home__act"
@click="router.push({ path: '/activity/detail', query: { id: a.id } })"
>
<div class="home__act-cover">{{ (a.title || '活').slice(0, 1) }}</div>
<img class="home__act-cover" :src="a.coverFileId || '/assets/covers/activity-default.jpg'" alt="" />
<div class="home__act-info">
<div class="home__act-tag">公益活动</div>
<div class="home__act-title">{{ a.title }}</div>
@@ -150,7 +150,7 @@
<!-- 善缘服务站 -->
<UiSectionHeader title="善缘服务站" />
<div v-if="serviceLocation" class="home__station" @click="router.push('/service-station')">
<div class="home__station-icon">站</div>
<img class="home__station-icon" src="/assets/covers/station-default.jpg" alt="" />
<div class="home__station-info">
<div class="home__station-name">{{ serviceLocation.name }}</div>
<div class="home__station-desc">
@@ -175,7 +175,7 @@
class="home__article"
@click="router.push({ path: '/content/detail', query: { id: art.id } })"
>
<div class="home__article-cover">{{ (art.title || '课').slice(0, 1) }}</div>
<img class="home__article-cover" :src="art.coverFileId || '/assets/covers/classroom-default.jpg'" alt="" />
<div class="home__article-main">
<div class="home__article-title">{{ art.title }}</div>
<div class="home__article-meta">善缘课堂 · 公益内容</div>
@@ -188,7 +188,7 @@
<!-- 善缘安全提醒 -->
<UiSectionHeader title="善缘安全提醒" />
<div class="home__safety" @click="router.push('/anti-fraud')">
<div class="home__safety-icon">盾</div>
<img class="home__safety-icon" src="/assets/covers/anti-fraud-default.jpg" alt="" />
<div class="home__safety-text">{{ safetyNotice.text }}</div>
</div>
</template>
@@ -247,6 +247,16 @@ const ENTRY_SUBS: Record<string, string> = {
anti_fraud: '守护真爱',
service_station: '就在身边',
}
const ENTRY_ICONS: Record<string, string> = {
find_object: '/assets/icons/home/find-object.png',
matchmaker: '/assets/icons/home/matchmaker.png',
activities: '/assets/icons/home/activity.png',
counseling: '/assets/icons/home/counseling.png',
classroom: '/assets/icons/home/classroom.png',
premarital: '/assets/icons/home/premarital.png',
anti_fraud: '/assets/icons/home/anti-fraud.png',
service_station: '/assets/icons/home/service-station.png',
}
/** 固定 8 入口默认值(服务端未返回时兜底;顺序不可变更) */
const DEFAULT_ENTRIES = [
{ code: 'find_object', label: '找对象', enabled: true },
@@ -327,6 +337,15 @@ function toneOf(code: string): string {
function subOf(code: string): string {
return ENTRY_SUBS[code] || ''
}
function iconOf(code: string): string {
return ENTRY_ICONS[code] || ENTRY_ICONS.find_object
}
function coverOf(type: string): string {
if (type === 'counseling') return '/assets/covers/counseling-default.jpg'
if (type === 'premarital') return '/assets/covers/classroom-default.jpg'
if (type === 'service_station') return '/assets/covers/station-default.jpg'
return '/assets/covers/counseling-default.jpg'
}
async function load() {
loading.value = true
@@ -454,7 +473,7 @@ onMounted(load)
/* 品牌区 */
.home__hero {
background: var(--mini-brand-gradient);
background: var(--mini-gradient-night);
padding: 16px 16px 0;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
@@ -550,19 +569,17 @@ onMounted(load)
height: 150px;
border-radius: var(--mini-radius);
overflow: hidden;
background: linear-gradient(135deg, #2a3a86 0%, #4b2a6b 100%);
background: var(--mini-gradient-night);
display: flex;
align-items: flex-end;
}
.home__banner-cover {
.home__banner-image {
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);
width: 100%;
height: 100%;
object-fit: cover;
opacity: .9;
}
.home__banner-copy {
position: relative;
@@ -617,6 +634,11 @@ onMounted(load)
font-size: 18px;
font-weight: 600;
}
.home__entry-icon img {
width: 28px;
height: 28px;
object-fit: contain;
}
.home__entry-label {
font-size: 12px;
color: var(--mini-text-strong);
@@ -655,6 +677,7 @@ onMounted(load)
justify-content: center;
font-weight: 600;
margin-bottom: 6px;
object-fit: cover;
}
.home__mini-title {
font-size: 13px;
@@ -714,7 +737,8 @@ onMounted(load)
}
.home__act-cover {
height: 84px;
background: linear-gradient(135deg, #6a5acd 0%, #8367c7 100%);
width: 100%;
object-fit: cover;
color: rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
@@ -754,7 +778,7 @@ onMounted(load)
width: 46px;
height: 46px;
border-radius: 12px;
background: linear-gradient(135deg, #2fb36d 0%, #1f8a52 100%);
object-fit: cover;
color: #fff;
display: flex;
align-items: center;
@@ -796,7 +820,7 @@ onMounted(load)
width: 44px;
height: 44px;
border-radius: 10px;
background: linear-gradient(135deg, #f5a623 0%, #e08b00 100%);
object-fit: cover;
color: #fff;
display: flex;
align-items: center;
@@ -830,7 +854,7 @@ onMounted(load)
width: 30px;
height: 30px;
border-radius: 8px;
background: var(--mini-accent-pink);
object-fit: cover;
color: #fff;
display: flex;
align-items: center;
@@ -10,7 +10,7 @@
了解你的婚恋诉求、协助你参与公益活动、并在必要时为你推荐合适的服务。
</p>
<p>
红娘只能在授权范围内提供协助,不能替你进行喜欢/略过等操作,不能查看你的私人聊天,
红娘只能在授权范围内提供协助,不能替你进行喜欢等操作,不能查看你的私人联系内容,
也不会将你的联系方式提供给任何未获授权的一方。
</p>
<p>
@@ -60,7 +60,7 @@ const serviceTypes = [
{ code: 'contact_intention', name: '帮我联系意向对象', icon: '💬' },
{ code: 'join_activity', name: '想参加线下活动', icon: '🎉' },
{ code: 'no_match_help', name: '匹配很久没有结果', icon: '😟' },
{ code: 'chat_help', name: '不知道怎么开始聊天', icon: '✉️' },
{ code: 'chat_help', name: '不知道怎么开始沟通', icon: '✉️' },
{ code: 'other', name: '其他', icon: '❓' },
]
@@ -132,7 +132,7 @@ const capabilities = [
'调整择偶条件的专业建议',
'协助联系你感兴趣的意向对象',
'推荐参加合适的相亲活动',
'聊天沟通的方法指导',
'沟通方法指导',
]
const services = ref<any[]>([])
@@ -25,6 +25,8 @@
<van-button type="primary" block round :loading="loading" @click="onLogin">登 录</van-button>
<van-button block plain class="op-login__register" @click="router.push('/operator/register')">注册演示红娘</van-button>
<div class="op-login__forgot" @click="onForgot">忘记密码?</div>
<p class="op-login__foot">请联系管理员获取账号</p>
</div>
@@ -131,6 +133,7 @@ function onForgot() {
color: var(--mini-text-secondary);
margin-top: 16px;
}
.op-login__register { margin-top: 12px; }
.op-login__foot {
text-align: center;
font-size: 11px;
@@ -0,0 +1,54 @@
<template>
<div class="register">
<div class="register__header"><van-icon name="arrow-left" @click="router.back()" /><span>注册演示红娘</span></div>
<van-cell-group inset>
<van-field v-model="name" label="姓名" placeholder="请输入姓名" maxlength="100" />
<van-field v-model="username" label="用户名" placeholder="4-30位字母开头,可含数字和下划线" maxlength="30" />
<van-field v-model="password" label="密码" type="password" placeholder="至少8位" maxlength="72" />
</van-cell-group>
<div class="register__actions">
<van-button type="primary" block :loading="loading" @click="submit">注册</van-button>
<p v-if="error" class="register__error">{{ error }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { operatorService } from '@/api/services'
import { useOperatorStore } from '@/stores/operator'
import { useAppStore } from '@/stores/app'
const router = useRouter()
const operator = useOperatorStore()
const app = useAppStore()
const name = ref('')
const username = ref('')
const password = ref('')
const loading = ref(false)
const error = ref('')
async function submit() {
if (loading.value) return
loading.value = true
error.value = ''
try {
await operatorService.registerDemo({ name: name.value.trim(), username: username.value.trim(), password: password.value })
await operator.login(username.value.trim(), password.value)
app.toastSuccess('注册并登录成功')
router.replace('/operator/workbench')
} catch (e: any) {
error.value = e?.message || '注册失败,请检查信息后重试'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.register { min-height: 100vh; background: var(--mini-bg); }
.register__header { display: flex; align-items: center; gap: 18px; padding: 16px; font-size: 17px; font-weight: 600; }
.register__actions { padding: 20px 16px; }
.register__error { color: var(--mini-danger); font-size: 13px; }
</style>
@@ -23,7 +23,7 @@
<div v-if="detail.description" class="osd__desc">服务需求:{{ detail.description }}</div>
</div>
<div class="osd__card">
<div v-if="!isManagementService" class="osd__card">
<div class="osd__card-title">推荐候选对象</div>
<div class="osd__hint">仅提供候选建议,是否表达喜欢由用户本人决定(红娘不可代用户操作)。</div>
<van-field v-model="recommendForm.targetUserId" placeholder="候选对象用户编号 / ID" />
@@ -53,7 +53,7 @@
</div>
</div>
<div class="osd__card">
<div v-if="!isManagementService" class="osd__card">
<div class="osd__card-title">记录服务</div>
<div class="osd__field-row">
<span class="osd__label">服务类型</span>
@@ -99,7 +99,8 @@
<div v-else class="osd__hint">暂无服务记录</div>
</div>
<div v-if="!isCompleted" class="osd__complete">
<div v-if="!isCompleted && !isManagementService" class="osd__complete">
<van-button v-if="detail?.status === 'assigned'" type="primary" plain block round :loading="starting" @click="onStart">开始服务</van-button>
<van-button plain block round :loading="completing" @click="onComplete">完成服务</van-button>
</div>
</template>
@@ -125,6 +126,7 @@ const route = useRoute()
const appStore = useAppStore()
const serviceId = typeof route.query.id === 'string' ? route.query.id : ''
const isManagementService = route.query.source === 'management'
const followupTypes = [
{ value: 'followup', label: '跟进记录' },
@@ -157,6 +159,7 @@ const error = ref(false)
const recommending = ref(false)
const submitting = ref(false)
const completing = ref(false)
const starting = ref(false)
const recommendForm = reactive({ targetUserId: '', reason: '' })
const followupForm = reactive({ followupType: 'followup', content: '', nextFollowupAt: '' })
@@ -186,7 +189,9 @@ async function load() {
loading.value = true
error.value = false
try {
detail.value = await operatorMatchmakerService.serviceDetail(serviceId)
detail.value = isManagementService
? await operatorMatchmakerService.managementServiceDetail(serviceId)
: await operatorMatchmakerService.serviceDetail(serviceId)
} catch (err: any) {
error.value = true
appStore.toastError(err?.message || '加载失败')
@@ -262,6 +267,20 @@ async function onComplete() {
}
}
async function onStart() {
if (!serviceId || starting.value) return
starting.value = true
try {
await operatorMatchmakerService.start(serviceId)
appStore.toastSuccess('已开始服务')
await load()
} catch (err: any) {
appStore.toastError(err?.message || '开始服务失败')
} finally {
starting.value = false
}
}
onMounted(load)
</script>
@@ -20,34 +20,34 @@
</div>
</div>
<template v-if="pendingServices.length">
<UiSectionHeader title="待处理服务" />
<div v-for="p in pendingServices" :key="p.serviceId || p.id" class="wb__row">
<div class="wb__row-main">
<div class="wb__row-title">{{ p.userDisplayName || '服务对象' }}</div>
<div class="wb__row-meta">{{ serviceTypeLabel(p.serviceType) }}</div>
</div>
<UiTag type="warning">待处理</UiTag>
</div>
</template>
<template v-if="managementServices.length">
<UiSectionHeader title="服务管理" />
<div class="wb__service-toolbar">
<UiSectionHeader title="服务工作台" />
<van-button size="small" plain type="primary" :loading="creatingDemoUser" @click="createDemoUser">
注册演示用户
</van-button>
</div>
<div class="wb__tabs">
<button v-for="tab in serviceTabs" :key="tab.value" class="wb__tab" :class="{ 'wb__tab--active': activeServiceTab === tab.value }" @click="activeServiceTab = tab.value">
{{ tab.label }}<span class="wb__tab-count">{{ tabCount(tab.value) }}</span>
</button>
</div>
<template v-if="filteredServices.length">
<div
v-for="m in managementServices"
:key="m.serviceId || m.id"
v-for="item in filteredServices"
:key="item.serviceId || item.id"
class="wb__row"
@click="goService(m)"
@click="goService(item, item.status === 'submitted' || item.status === 'completed' ? 'management' : 'assignment')"
>
<div class="wb__row-main">
<div class="wb__row-title">{{ m.userDisplayName || '服务对象' }}</div>
<div class="wb__row-meta">{{ serviceTypeLabel(m.serviceType) }}</div>
<div class="wb__row-title">{{ item.userDisplayName || '服务对象' }}</div>
<div class="wb__row-meta">{{ serviceTypeLabel(item.serviceType) }} · {{ item.description || '红娘服务申请' }}</div>
<div class="wb__row-tags"><UiTag type="info">{{ statusLabel(item.status) }}</UiTag></div>
</div>
<UiTag :type="m.status === 'completed' ? 'success' : 'info'">
{{ statusLabel(m.status) }}
</UiTag>
<van-button v-if="item.status === 'submitted'" size="small" type="primary" :loading="assigningServiceId === item.serviceId" @click.stop="assignToSelf(item)">接单</van-button>
<van-icon name="arrow" color="#9aa0b5" />
</div>
</template>
<UiEmpty v-else description="暂无该状态服务" />
<UiSectionHeader :title="`我的服务对象分配${assignmentCount ? '(共' + assignmentCount + '人)' : ''}`" />
<div v-if="assignments.length">
@@ -256,9 +256,11 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { operatorMatchmakerService } from '@/api/services'
import { authService, matchmakerService, operatorMatchmakerService } from '@/api/services'
import { useAuthStore } from '@/stores/auth'
import { useOperatorStore } from '@/stores/operator'
import { useAppStore } from '@/stores/app'
import { getItem, StorageKeys } from '@/utils/storage'
import { formatDateTime } from '@/utils/datetime'
import OperatorLayout from '@/layouts/OperatorLayout.vue'
import UiAvatar from '@/components/UiAvatar.vue'
@@ -270,6 +272,7 @@ import UiErrorState from '@/components/UiErrorState.vue'
const router = useRouter()
const operatorStore = useOperatorStore()
const authStore = useAuthStore()
const appStore = useAppStore()
const operatorName = computed(() => operatorStore.operatorName)
@@ -283,6 +286,14 @@ const recommendations = ref<any[]>([])
const contactAuths = ref<any[]>([])
const pendingServices = ref<any[]>([])
const managementServices = ref<any[]>([])
const activeServiceTab = ref('pending')
const creatingDemoUser = ref(false)
const assigningServiceId = ref('')
const serviceTabs = [
{ value: 'pending', label: '待处理' },
{ value: 'in_progress', label: '进行中' },
{ value: 'completed', label: '已完成' },
]
// ---- 受控联系授权(B1:红娘只发起申请,审批 + 用户明示同意后才生效) ----
const contactChannels = [
@@ -320,6 +331,23 @@ const stats = computed(() => [
{ key: 'today', value: dashboard.value?.todayCount || 0, label: '今日' },
])
const assignmentCount = computed(() => assignments.value.length)
const allServices = computed(() => {
const map = new Map<string, any>()
;[...pendingServices.value, ...managementServices.value].forEach((item) => {
const id = item.serviceId || item.id
if (id) map.set(id, item)
})
return [...map.values()]
})
const filteredServices = computed(() => allServices.value.filter((item) => {
const status = item.status
if (activeServiceTab.value === 'pending') return ['submitted', 'pending'].includes(status)
if (activeServiceTab.value === 'in_progress') return ['assigned', 'in_service', 'in_progress', 'waiting_user'].includes(status)
return ['completed'].includes(status)
}))
function tabCount(tab: string) {
return allServices.value.filter((item) => tab === 'pending' ? ['submitted', 'pending'].includes(item.status) : tab === 'in_progress' ? ['assigned', 'in_service', 'in_progress', 'waiting_user'].includes(item.status) : item.status === 'completed').length
}
function asList(res: any): any[] {
const data = res?.value ?? res
@@ -331,6 +359,11 @@ function scopeLabel(code?: string): string {
}
function statusLabel(code?: string): string {
const labels: Record<string, string> = {
submitted: '待处理',
assigned: '已接单',
in_service: '服务中',
waiting_user: '待用户确认',
completed: '已完成',
active: '生效中',
ended: '已结束',
revoked: '已撤销',
@@ -416,10 +449,44 @@ function serviceTypeLabel(code?: string): string {
return (code && labels[code]) || '服务站服务'
}
function goService(a: any) {
async function createDemoUser() {
if (creatingDemoUser.value) return
creatingDemoUser.value = true
try {
const code = `demo_openid_workbench_${Date.now()}`
const user = await authService.demoRegister({ code, displayName: '演示服务用户' })
authStore.applyTokens(user)
await matchmakerService.applyService({ serviceType: 'recommend_partner', description: '红娘演示服务申请' })
appStore.toastSuccess('演示用户已创建并提交服务申请')
activeServiceTab.value = 'pending'
await load()
} catch (err: any) {
appStore.toastError(err?.message || '注册演示用户失败')
} finally {
creatingDemoUser.value = false
}
}
async function assignToSelf(item: any) {
const operatorId = getItem(StorageKeys.operatorId)
if (!operatorId || !item.serviceId || assigningServiceId.value) return
assigningServiceId.value = item.serviceId
try {
await operatorMatchmakerService.assign(item.serviceId, { operatorId, reason: '演示分配' })
appStore.toastSuccess('已接单')
activeServiceTab.value = 'in_progress'
await load()
} catch (err: any) {
appStore.toastError(err?.message || '接单失败')
} finally {
assigningServiceId.value = ''
}
}
function goService(a: any, source: 'management' | 'assignment' = 'assignment') {
const serviceId = a.serviceId
if (serviceId) {
router.push({ path: '/operator/services', query: { id: serviceId } })
router.push({ path: '/operator/services', query: { id: serviceId, source } })
} else {
appStore.toast('该分配未关联服务单,请联系组长补建服务记录')
}
@@ -590,6 +657,11 @@ onMounted(load)
padding: 14px 6px;
box-shadow: var(--mini-shadow);
}
.wb__service-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.wb__tabs { display: flex; gap: 4px; margin: 8px 0 12px; border-bottom: 1px solid var(--mini-border); }
.wb__tab { flex: 1; min-width: 0; padding: 10px 3px; border: 0; background: transparent; color: var(--mini-text-secondary); font-size: 13px; }
.wb__tab--active { color: var(--mini-tabbar-active); border-bottom: 2px solid currentColor; font-weight: 600; }
.wb__tab-count { margin-left: 3px; }
.wb__stat {
text-align: center;
}
@@ -165,7 +165,7 @@ function onApplyMatchmaker() {
async function onBlock() {
if (!userId.value) return
try {
await showConfirmDialog({ title: '拉黑确认', message: `拉黑后聊天停止、推荐排除、搜索不可见。确定拉黑 ${displayName.value} 吗?` })
await showConfirmDialog({ title: '拉黑确认', message: `拉黑后互动停止、推荐排除、搜索不可见。确定拉黑 ${displayName.value} 吗?` })
} catch {
return
}
@@ -1,5 +1,5 @@
<template>
<PageLayout title="通知">
<PageLayout :title="pageTitle">
<div class="notif">
<div class="notif__bar">
<span class="notif__count">{{ unreadCount > 0 ? `${unreadCount} 条未读` : '暂无未读' }}</span>
@@ -45,6 +45,7 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { notificationService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import { formatDateTime } from '@/utils/datetime'
@@ -55,14 +56,22 @@ import UiErrorState from '@/components/UiErrorState.vue'
import UiTag from '@/components/UiTag.vue'
const appStore = useAppStore()
const router = useRouter()
const pageTitle = '通知'
const TYPE_TEXT: Record<string, string> = {
system: '系统通知',
activity: '活动提醒',
match: '匹配消息',
match: '匹配提醒',
appointment: '预约提醒',
counseling: '咨询提醒',
review: '审核通知',
matchmaker: '红娘服务',
service_location: '服务站服务',
premarital: '婚前辅导',
local_eligibility: '资格认证',
account: '账号状态',
risk: '安全提醒',
}
const loading = ref(true)
@@ -84,6 +93,14 @@ function typeText(type?: string): string {
return type ? TYPE_TEXT[type] || '' : ''
}
function notificationRoute(item: any): string | null {
const type = item?.type
if (['matchmaker', 'appointment', 'counseling', 'premarital', 'service_location'].includes(type)) return '/my/service-history'
if (['match', 'activity'].includes(type)) return '/fate-center'
if (['account', 'review', 'local_eligibility', 'system', 'risk'].includes(type)) return '/settings'
return null
}
const PAGE_SIZE = 20
async function load() {
@@ -108,12 +125,16 @@ async function load() {
}
async function onMarkRead(item: any) {
if (!item?.id || isRead(item)) return
if (!item?.id) return
try {
await notificationService.markNotificationRead(item.id)
item.readStatus = 'read'
item.read = true
unreadCount.value = Math.max(0, unreadCount.value - 1)
if (!isRead(item)) {
await notificationService.markNotificationRead(item.id)
item.readStatus = 'read'
item.read = true
unreadCount.value = Math.max(0, unreadCount.value - 1)
}
const route = notificationRoute(item)
if (route) router.push({ path: route, query: item.businessId ? { id: item.businessId } : undefined })
} catch (err: any) {
appStore.toastError(err?.message || '操作失败')
}
@@ -71,7 +71,7 @@ const STATUS_META: Record<string, { label: string; desc: string; kind: 'primary'
},
paused: {
label: '已暂停寻找',
desc: '你暂时不会被推荐给他人,也无法收到新的喜欢提示。已有聊天仍可继续使用。',
desc: '你暂时不会被推荐给他人,也无法收到新的喜欢提示。已有互动记录仍会保留。',
kind: 'warning',
},
getting_to_know: {
@@ -86,7 +86,7 @@ const STATUS_META: Record<string, { label: string; desc: string; kind: 'primary'
},
service_exited: {
label: '已退出婚恋服务',
desc: '你已退出婚恋服务,不再出现在推荐和搜索结果中。历史匹配与聊天记录已保留。',
desc: '你已退出婚恋服务,不再出现在推荐和搜索结果中。历史匹配与互动记录已保留。',
kind: 'info',
},
}
@@ -133,7 +133,7 @@ async function onPause() {
try {
await showConfirmDialog({
title: '暂停寻找',
message: '暂停后你将不会被推荐给他人,已有聊天仍可继续使用。是否继续?',
message: '暂停后你将不会被推荐给他人,已有互动记录仍会保留。是否继续?',
})
} catch {
return
+1 -1
View File
@@ -1,7 +1,7 @@
<template>
<PageLayout title="设置">
<div class="settings">
<UiSectionHeader title="通知与消息" />
<UiSectionHeader title="系统通知" />
<van-cell-group inset>
<van-cell
title="通知中心"
+3 -1
View File
@@ -15,11 +15,12 @@ export const APPROVED_ROUTES = new Set([
])
/** 无需登录即可访问 */
export const PUBLIC_ROUTES = new Set(['/login', '/operator/login', '/about', '/help'])
export const PUBLIC_ROUTES = new Set(['/login', '/register', '/operator/login', '/operator/register', '/about', '/help'])
export const routes: RouteRecordRaw[] = [
// ==================== 认证 ====================
{ path: '/login', name: 'Login', component: () => import('@/pages/auth/LoginPage.vue') },
{ path: '/register', name: 'DemoRegister', component: () => import('@/pages/auth/DemoRegisterPage.vue') },
{ path: '/bind-phone', name: 'BindPhone', component: () => import('@/pages/auth/BindPhonePage.vue') },
// ==================== Tab ====================
@@ -109,6 +110,7 @@ export const routes: RouteRecordRaw[] = [
// ==================== 红娘端(独立会话) ====================
{ path: '/operator/login', name: 'OperatorLogin', component: () => import('@/pages/operator/OperatorLoginPage.vue') },
{ path: '/operator/register', name: 'OperatorRegister', component: () => import('@/pages/operator/OperatorRegisterPage.vue') },
{ path: '/operator/workbench', name: 'OperatorWorkbench', component: () => import('@/pages/operator/OperatorWorkbenchPage.vue') },
{ path: '/operator/services', name: 'OperatorServiceDetail', component: () => import('@/pages/operator/OperatorServiceDetailPage.vue') },
+8 -2
View File
@@ -28,7 +28,13 @@ export const useAuthStore = defineStore('auth', () => {
* 无需微信环境即可切换到预置演示用户(见实施计划 D2 / 登录策略)。
*/
async function loginByDemoCode(code: string) {
const data = await authService.wechatLogin({ code })
const data = await authService.demoLogin({ code })
applyTokens(data)
return data
}
async function registerDemo(code: string, displayName: string, phone?: string) {
const data = await authService.demoRegister({ code, displayName, phone })
applyTokens(data)
return data
}
@@ -51,5 +57,5 @@ export const useAuthStore = defineStore('auth', () => {
clearSession()
}
return { accessToken, userId, isNewUser, isLoggedIn, loginByDemoCode, applyTokens, clearSession, logout }
return { accessToken, userId, isNewUser, isLoggedIn, loginByDemoCode, registerDemo, applyTokens, clearSession, logout }
})
+30
View File
@@ -20,6 +20,36 @@ body {
-webkit-font-smoothing: antialiased;
}
button,
.van-button,
.van-tabbar-item,
.van-cell,
.van-nav-bar {
font-family: inherit;
}
.van-nav-bar {
background: var(--mini-night-start);
color: var(--mini-text-on-night);
}
.van-nav-bar .van-nav-bar__title,
.van-nav-bar .van-icon,
.van-nav-bar .van-nav-bar__text {
color: var(--mini-text-on-night);
}
.van-button--primary {
background: var(--mini-gradient-brand-solid);
border-color: transparent;
}
.van-button--plain.van-button--primary {
color: var(--mini-tabbar-active);
background: transparent;
border-color: var(--mini-tabbar-active);
}
#app {
min-height: 100%;
}
+8
View File
@@ -4,6 +4,8 @@
--mini-night-start: #172a72;
--mini-night-end: #25104f;
--mini-brand-gradient: linear-gradient(135deg, #172a72 0%, #25104f 100%);
--mini-gradient-night: linear-gradient(160deg, #172a72 0%, #25104f 100%);
--mini-gradient-brand-solid: linear-gradient(135deg, #c24c50 0%, #b8434a 100%);
/* 强调粉 / 主色 */
--mini-accent-pink: #ff4f91;
@@ -37,4 +39,10 @@
--mini-radius-lg: 16px;
--mini-shadow: 0 4px 16px rgba(23, 42, 114, 0.08);
--mini-shadow-strong: 0 8px 28px rgba(23, 42, 114, 0.14);
--mini-glass: rgba(255, 255, 255, 0.1);
--mini-glass-strong: rgba(255, 255, 255, 0.16);
--mini-hairline: rgba(255, 255, 255, 0.16);
--mini-text-on-night: #fff;
--mini-text-on-night-muted: rgba(255, 255, 255, 0.72);
--mini-banner-scrim: linear-gradient(90deg, rgba(23, 42, 114, 0.84) 0%, rgba(37, 16, 79, 0.4) 56%, rgba(255, 79, 145, 0.12) 100%);
}
+19
View File
@@ -0,0 +1,19 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const root = path.resolve(__dirname, '..')
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8')
const workbench = read('src/pages/operator/OperatorWorkbenchPage.vue')
const notifications = read('src/pages/settings/NotificationsPage.vue')
assert.match(workbench, /待处理/)
assert.match(workbench, /进行中/)
assert.match(workbench, /已完成/)
assert.match(workbench, /active|assigned|in_service|completed/)
assert.match(workbench, /注册演示用户|创建演示用户/)
assert.match(notifications, /matchmaker|appointment|counseling|premarital|review|account|activity|match|system/)
assert.match(notifications, /businessId|businessType/)
assert.match(notifications, /router\.(push|replace)/)
console.log('PASS: demo flow UI contract')
+14
View File
@@ -0,0 +1,14 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const root = path.resolve(__dirname, '..')
const authService = fs.readFileSync(path.join(root, 'src/api/services/auth.ts'), 'utf8')
const authStore = fs.readFileSync(path.join(root, 'src/stores/auth.ts'), 'utf8')
const backendController = fs.readFileSync(path.resolve(root, '../backend/src/main/java/com/danyang/match/platform/auth/controller/AuthController.java'), 'utf8')
assert.match(authService, /demoLogin\(/)
assert.match(authStore, /authService\.demoLogin\(/)
assert.match(backendController, /@PostMapping\("\/demo\/login"\)/)
console.log('PASS: demo login contract')
+19
View File
@@ -0,0 +1,19 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const root = path.resolve(__dirname, '..')
const routes = fs.readFileSync(path.join(root, 'src/router/routes.ts'), 'utf8')
const authPage = fs.readFileSync(path.join(root, 'src/pages/auth/DemoRegisterPage.vue'), 'utf8')
const operatorPage = fs.readFileSync(path.join(root, 'src/pages/operator/OperatorRegisterPage.vue'), 'utf8')
const authApi = fs.readFileSync(path.resolve(root, '../backend/src/main/java/com/danyang/match/platform/auth/controller/AuthController.java'), 'utf8')
const operatorApi = fs.readFileSync(path.resolve(root, '../backend/src/main/java/com/danyang/match/platform/auth/controller/OperatorAuthController.java'), 'utf8')
assert.match(routes, /path: '\/register'/)
assert.match(routes, /path: '\/operator\/register'/)
assert.match(authPage, /auth\.registerDemo\(/)
assert.match(operatorPage, /operatorService\.registerDemo\(/)
assert.match(authApi, /@PostMapping\("\/demo\/register"\)/)
assert.match(operatorApi, /@PostMapping\("\/demo\/register"\)/)
console.log('PASS: demo registration routes and APIs are wired')
+40
View File
@@ -0,0 +1,40 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const root = path.resolve(__dirname, '..')
const tabLayout = fs.readFileSync(path.join(root, 'src/layouts/TabLayout.vue'), 'utf8')
const pageLayout = fs.readFileSync(path.join(root, 'src/layouts/PageLayout.vue'), 'utf8')
const home = fs.readFileSync(path.join(root, 'src/pages/home/HomePage.vue'), 'utf8')
const workbench = fs.readFileSync(path.join(root, 'src/pages/operator/OperatorWorkbenchPage.vue'), 'utf8')
const serviceDetail = fs.readFileSync(path.join(root, 'src/pages/operator/OperatorServiceDetailPage.vue'), 'utf8')
const matchmakerApi = fs.readFileSync(path.join(root, 'src/api/services/matchmaker.ts'), 'utf8')
const discover = fs.readFileSync(path.join(root, 'src/pages/discover/DiscoverPage.vue'), 'utf8')
const userCard = fs.readFileSync(path.join(root, 'src/components/UiUserCard.vue'), 'utf8')
const routes = fs.readFileSync(path.join(root, 'src/router/routes.ts'), 'utf8')
assert.doesNotMatch(tabLayout, /to="\/messages"[^>]*>消息<\/van-tabbar-item>/)
assert.match(tabLayout, /to="\/notifications"[^>]*>通知<\/van-tabbar-item>/)
assert.match(pageLayout, /class="page-layout__nav"/)
assert.match(home, /home-hero-v2\.jpg/)
assert.match(home, /home__notice[^\n]*notifications/)
assert.doesNotMatch(home, /home__banner-cover[^\n]*\{\{ \(b\.title \|\| '善'\)\.slice\(0, 1\) \}\}/)
assert.match(workbench, /服务工作台/)
assert.match(workbench, /v-for="item in filteredServices"/)
assert.match(workbench, /activeServiceTab/)
assert.match(workbench, /createDemoUser/)
assert.match(workbench, /assignToSelf/)
assert.match(workbench, /query: \{ id: serviceId, source \}/)
assert.match(serviceDetail, /route\.query\.source === 'management'[\s\S]*?managementServiceDetail\(serviceId\)/)
assert.match(matchmakerApi, /managementServiceDetail\(id: string\)[\s\S]*?\/operator\/matchmaker\/management\/services\//)
assert.match(discover, /class="discover__hero"/)
assert.match(discover, /placeholder="姓名、职业或地区"/)
assert.match(discover, />推荐<|>年龄<|>学历<|>婚姻</)
assert.match(discover, /van-slider/)
assert.match(discover, /:show-pass="false"/)
assert.match(userCard, /occupationLabel\(user\.occupationCode\)/)
assert.match(userCard, /maritalLabel\(user\.maritalStatus\)/)
assert.doesNotMatch(routes, /path: ['"]messages['"]|name: ['"]MessagesPage['"]|MessagesPage/)
assert.doesNotMatch(routes, /path: ['"]chat['"]|name: ['"]Chat/)
console.log('PASS: H5 UI contract')
+4 -1
View File
@@ -159,7 +159,7 @@ services:
MINIO_ENDPOINT: http://danyang-match-seaweedfs:8333
# 对内网 endpoint 拆出「对外可达」地址:客户端直传 presigned URL 用。
# 本地 S3 网关已 publish 到宿主 8333,故客户端填 localhost:8333 即可连通。
MINIO_PUBLIC_ENDPOINT: http://localhost:8333
MINIO_PUBLIC_ENDPOINT: ${MINIO_PUBLIC_ENDPOINT:-http://localhost:8333}
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:?请在本地 .env 设置 MINIO_ACCESS_KEY}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:?请在本地 .env 设置 MINIO_SECRET_KEY}
JWT_ACCESS_SECRET: ${JWT_ACCESS_SECRET:?请在本地 .env 设置 JWT_ACCESS_SECRET}
@@ -167,6 +167,9 @@ services:
ENCRYPTION_KEY: ${ENCRYPTION_KEY:?请在本地 .env 设置 ENCRYPTION_KEY}
WECHAT_APP_ID: dev-wechat-app-id
WECHAT_APP_SECRET: ${WECHAT_APP_SECRET:-}
DEMO_LOGIN_ENABLED: ${DEMO_LOGIN_ENABLED:-true}
DEMO_LOGIN_PASSWORD: ${DEMO_LOGIN_PASSWORD:-demo123}
DEMO_LOGIN_USERNAMES: ${DEMO_LOGIN_USERNAMES:-admin,wanghongniang}
# 仅本地演示固定到秀英预置用户;正式环境留空并使用真实微信 Provider。
# 留空时按每个 mock code 生成独立用户;演示预置用户使用 demo_openid_* code 显式登录。
WECHAT_MOCK_OPENID: ${WECHAT_MOCK_OPENID-}
+2
View File
@@ -9,6 +9,8 @@
"pages/activity/activity",
"pages/mine/mine",
"pages/login/login",
"pages/demo-register/demo-register",
"pages/matchmaker-register/matchmaker-register",
"pages/bind-phone/bind-phone",
"pages/verification/verification",
"pages/profile-basic/profile-basic",
+2
View File
@@ -18,6 +18,7 @@ exports.TAB = {
// ============ 启动与身份类(规范 §4.1) ============
exports.AUTH = {
LOGIN: '/pages/login/login',
DEMO_REGISTER: '/pages/demo-register/demo-register',
BIND_PHONE: '/pages/bind-phone/bind-phone',
VERIFICATION: '/pages/verification/verification',
};
@@ -112,6 +113,7 @@ exports.SERVICE = {
// ============ 工作人员/红娘端(规范 §4.7) ============
exports.OPERATOR = {
LOGIN: '/pages/matchmaker-login/matchmaker-login',
REGISTER: '/pages/matchmaker-register/matchmaker-register',
WORKBENCH: '/pages/matchmaker-workbench/matchmaker-workbench',
};
+2
View File
@@ -5,6 +5,8 @@
"pages/activity/activity",
"pages/mine/mine",
"pages/login/login",
"pages/demo-register/demo-register",
"pages/matchmaker-register/matchmaker-register",
"pages/bind-phone/bind-phone",
"pages/verification/verification",
"pages/profile-basic/profile-basic",
@@ -0,0 +1,44 @@
const { authStore } = require('../../stores');
Page({
data: { code: 'demo_user_01', displayName: '演示用户', phone: '', loading: false, error: '' },
onCodeInput(e) { this.setData({ code: e.detail.value, error: '' }); },
onNameInput(e) { this.setData({ displayName: e.detail.value, error: '' }); },
onPhoneInput(e) { this.setData({ phone: e.detail.value, error: '' }); },
async submit() {
if (this.data.loading) return;
const code = this.data.code.trim();
const displayName = this.data.displayName.trim();
const phone = this.data.phone.trim();
if (!/^[a-zA-Z0-9_-]{1,100}$/.test(code) || !displayName || (phone && !/^1[3-9]\d{9}$/.test(phone))) {
this.setData({ error: '请填写有效的演示编号、昵称和手机号' });
return;
}
this.setData({ loading: true, error: '' });
try {
await authStore.loginWithDemo(`demo_openid_${code}`, displayName, phone);
wx.reLaunch({ url: '/pages/index/index' });
} catch (err) {
this.setData({ error: err.message || '注册失败,请重试' });
} finally {
this.setData({ loading: false });
}
},
async loginExisting() {
if (this.data.loading) return;
const code = this.data.code.trim();
if (!/^[a-zA-Z0-9_-]{1,100}$/.test(code)) {
this.setData({ error: '请输入有效的演示编号' });
return;
}
this.setData({ loading: true, error: '' });
try {
await authStore.loginWithDemo(`demo_openid_${code}`);
wx.reLaunch({ url: '/pages/index/index' });
} catch (err) {
this.setData({ error: err.message || '登录失败,请重试' });
} finally {
this.setData({ loading: false });
}
},
});
@@ -0,0 +1 @@
{"navigationBarTitleText":"演示用户注册","usingComponents":{}}
@@ -0,0 +1,11 @@
<view class="register-page">
<view class="register-title">演示用户注册</view>
<view class="register-form">
<view class="field"><text>账号编号</text><input value="{{code}}" bindinput="onCodeInput" placeholder="字母、数字或下划线" /></view>
<view class="field"><text>昵称</text><input value="{{displayName}}" bindinput="onNameInput" maxlength="40" placeholder="演示昵称" /></view>
<view class="field"><text>手机号(可选)</text><input value="{{phone}}" bindinput="onPhoneInput" type="number" maxlength="11" placeholder="演示手机号" /></view>
<view class="error" wx:if="{{error}}">{{error}}</view>
<button class="primary" loading="{{loading}}" disabled="{{loading}}" bindtap="submit">注册并登录</button>
<button disabled="{{loading}}" bindtap="loginExisting">登录已有演示账号</button>
</view>
</view>
@@ -0,0 +1,8 @@
.register-page { min-height: 100vh; background: var(--color-bg-subtle); padding: 48rpx 32rpx; box-sizing: border-box; }
.register-title { font-size: 38rpx; font-weight: 700; color: var(--color-text-strong); margin-bottom: 32rpx; }
.register-form { background: var(--color-bg-card); padding: 28rpx; border-radius: 12rpx; }
.field { margin-bottom: 24rpx; }
.field text { display: block; font-size: 26rpx; color: var(--color-text-secondary); margin-bottom: 10rpx; }
.field input { border-bottom: 1rpx solid var(--color-divider); height: 72rpx; font-size: 30rpx; }
.error { color: var(--color-danger-strong); font-size: 26rpx; margin-bottom: 16rpx; }
.primary { background: var(--color-primary-solid); color: #fff; }
+6
View File
@@ -5,6 +5,7 @@
import { authStore, appStore } from '../../stores'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { ROUTE } from '../../constants'
import { IS_DEV } from '../../env'
interface LoginData {
loggingIn: boolean
@@ -19,6 +20,7 @@ Page({
error: '',
platformName: getApp().globalData.platformName,
slogan: getApp().globalData.slogan,
demoEnabled: IS_DEV,
},
onLoad() {
@@ -77,6 +79,10 @@ Page({
wx.navigateTo({ url: ROUTE.OPERATOR.LOGIN })
},
onDemoRegister() {
wx.navigateTo({ url: ROUTE.AUTH.DEMO_REGISTER })
},
onViewTerms() {
wx.navigateTo({ url: '/pages/help-center/help-center?tab=terms' })
},
+1
View File
@@ -8,6 +8,7 @@
<view class="login-content">
<button bindtap="onOperatorEntry" disabled="{{loggingIn}}">红娘工作人员入口</button>
<button wx:if="{{demoEnabled}}" bindtap="onDemoRegister" disabled="{{loggingIn}}">注册演示用户</button>
<button
class="login-btn {{loggingIn ? 'loading' : ''}}"
bindtap="onLoginTap"
@@ -5,6 +5,7 @@
*/
const { appStore } = require('../../stores');
const { API_BASE_URL } = require('../../env');
const { IS_DEV } = require('../../env');
Page({
data: {
@@ -13,6 +14,7 @@ Page({
loading: false,
error: '',
platformName: getApp().globalData.platformName,
demoEnabled: IS_DEV,
},
onLoad() {
@@ -51,10 +53,11 @@ Page({
});
if (res.success) {
const { accessToken, name } = res.data;
const { accessToken, name, operatorId } = res.data;
// 存储token
wx.setStorageSync('operatorToken', accessToken);
wx.setStorageSync('operatorName', name);
wx.setStorageSync('operatorId', operatorId);
// 跳转到红娘工作台
wx.reLaunch({ url: '/pages/matchmaker-workbench/matchmaker-workbench' });
@@ -75,4 +78,8 @@ Page({
showCancel: false,
});
},
onDemoRegister() {
wx.navigateTo({ url: '/pages/matchmaker-register/matchmaker-register' });
},
});
@@ -37,6 +37,7 @@
<button class="login-btn" loading="{{loading}}" bindtap="onLogin">
登 录
</button>
<button wx:if="{{demoEnabled}}" bindtap="onDemoRegister">注册演示红娘</button>
<view class="footer-link" bindtap="onForgotPassword">
<text>忘记密码?</text>
@@ -0,0 +1,31 @@
const { authService } = require('../../services');
Page({
data: { name: '演示红娘', username: 'demo_matchmaker_02', password: '', loading: false, error: '' },
onNameInput(e) { this.setData({ name: e.detail.value, error: '' }); },
onUsernameInput(e) { this.setData({ username: e.detail.value, error: '' }); },
onPasswordInput(e) { this.setData({ password: e.detail.value, error: '' }); },
async submit() {
if (this.data.loading) return;
const name = this.data.name.trim();
const username = this.data.username.trim();
const password = this.data.password;
if (!name || !/^[a-zA-Z][a-zA-Z0-9_]{3,29}$/.test(username) || password.length < 8) {
this.setData({ error: '请输入姓名、4 至 30 位用户名和至少 8 位密码' });
return;
}
this.setData({ loading: true, error: '' });
try {
await authService.operatorDemoRegister({ name, username, password });
const result = await authService.operatorLogin({ username, password });
wx.setStorageSync('operatorToken', result.accessToken);
wx.setStorageSync('operatorName', result.name);
wx.setStorageSync('operatorId', result.operatorId);
wx.reLaunch({ url: '/pages/matchmaker-workbench/matchmaker-workbench' });
} catch (err) {
this.setData({ error: err.message || '注册失败,请重试' });
} finally {
this.setData({ loading: false });
}
},
});
@@ -0,0 +1 @@
{"navigationBarTitleText":"注册演示红娘","usingComponents":{}}
@@ -0,0 +1,10 @@
<view class="register-page">
<view class="register-title">注册演示红娘</view>
<view class="register-form">
<view class="field"><text>姓名</text><input value="{{name}}" bindinput="onNameInput" placeholder="红娘姓名" /></view>
<view class="field"><text>用户名</text><input value="{{username}}" bindinput="onUsernameInput" placeholder="4 至 30 位字母、数字或下划线" /></view>
<view class="field"><text>密码</text><input type="password" value="{{password}}" bindinput="onPasswordInput" placeholder="至少 8 位" /></view>
<view class="error" wx:if="{{error}}">{{error}}</view>
<button class="primary" loading="{{loading}}" disabled="{{loading}}" bindtap="submit">注册并进入工作台</button>
</view>
</view>
@@ -0,0 +1,8 @@
.register-page { min-height: 100vh; background: var(--color-bg-subtle); padding: 48rpx 32rpx; box-sizing: border-box; }
.register-title { font-size: 38rpx; font-weight: 700; color: var(--color-text-strong); margin-bottom: 32rpx; }
.register-form { background: var(--color-bg-card); padding: 28rpx; border-radius: 12rpx; }
.field { margin-bottom: 24rpx; }
.field text { display: block; font-size: 26rpx; color: var(--color-text-secondary); margin-bottom: 10rpx; }
.field input { border-bottom: 1rpx solid var(--color-divider); height: 72rpx; font-size: 30rpx; }
.error { color: var(--color-danger-strong); font-size: 26rpx; margin-bottom: 16rpx; }
.primary { background: var(--color-primary-solid); color: #fff; }
@@ -4,10 +4,13 @@ Page({
data: {
serviceId: '',
detail: null,
source: 'assignment',
statusLabel: '',
followups: [],
followupTypes: ['followup', '需求了解', '候选建议', '活动协助', '其他'],
loading: true,
submitting: false,
changingStatus: false,
form: { followupType: 'followup', content: '', nextFollowupAt: '' },
},
@@ -18,7 +21,7 @@ Page({
wx.navigateBack();
return;
}
this.setData({ serviceId });
this.setData({ serviceId, source: options.source === 'management' ? 'management' : 'assignment' });
this.loadDetail();
},
@@ -34,8 +37,10 @@ Page({
async loadDetail() {
this.setData({ loading: true });
try {
const detail = await api.get(`/operator/matchmaker/services/${this.data.serviceId}`, {}, this.tokenHeaders());
this.setData({ detail: detail || null, followups: (detail && detail.followups) || [] });
const path = this.data.source === 'management' ? '/operator/matchmaker/management/services/' : '/operator/matchmaker/services/';
const detail = await api.get(`${path}${encodeURIComponent(this.data.serviceId)}`, {}, this.tokenHeaders());
const labels = { submitted: '待处理', assigned: '已接单', in_service: '服务中', waiting_user: '待用户确认', completed: '已完成' };
this.setData({ detail: detail || null, statusLabel: labels[detail && detail.status] || (detail && detail.status) || '', followups: (detail && detail.followups) || [] });
} catch (error) {
if (error && (error.code === 'FORBIDDEN' || error.code === 'NOT_FOUND' || error.code === 'DATA_SCOPE_DENIED')) {
wx.showModal({ title: '服务权限已变更', content: '该服务对象已结束或不再属于当前服务范围。', showCancel: false,
@@ -48,6 +53,21 @@ Page({
}
},
async changeStatus(e) {
const action = e.currentTarget.dataset.action;
if (this.data.changingStatus || this.data.source === 'management') return;
this.setData({ changingStatus: true });
try {
await api.post(`/operator/matchmaker/services/${encodeURIComponent(this.data.serviceId)}/${action}`, {}, this.tokenHeaders());
wx.showToast({ title: action === 'start' ? '服务已开始' : '服务已完成', icon: 'success' });
await this.loadDetail();
} catch (err) {
wx.showToast({ title: err.message || '操作失败', icon: 'none' });
} finally {
this.setData({ changingStatus: false });
}
},
onTypeChange(e) { this.setData({ 'form.followupType': this.data.followupTypes[Number(e.detail.value)] || 'followup' }); },
onContentInput(e) { this.setData({ 'form.content': e.detail.value }); },
onNextFollowupInput(e) { this.setData({ 'form.nextFollowupAt': e.detail.value }); },
@@ -11,11 +11,11 @@
<text>地区:{{detail.userProfile.regionName || '未设置'}}</text>
<text>婚姻状态:{{detail.userProfile.maritalStatus || '未设置'}}</text>
</view>
<view class="status">服务状态:{{detail.status}}</view>
<view class="status">服务状态:{{statusLabel}}</view>
<view wx:if="{{detail.description}}" class="description">服务需求:{{detail.description}}</view>
</view>
<view class="card">
<view class="card" wx:if="{{source === 'assignment' && detail.status !== 'completed'}}">
<view class="title">记录服务</view>
<picker mode="selector" range="{{followupTypes}}" value="0" bindchange="onTypeChange">
<view class="picker">服务类型:{{form.followupType}}</view>
@@ -25,6 +25,13 @@
<button type="primary" loading="{{submitting}}" disabled="{{submitting}}" bindtap="submitFollowup">保存服务记录</button>
</view>
<view class="card" wx:if="{{source === 'assignment' && detail.status === 'assigned'}}">
<button type="primary" loading="{{changingStatus}}" disabled="{{changingStatus}}" bindtap="changeStatus" data-action="start">开始服务</button>
</view>
<view class="card" wx:if="{{source === 'assignment' && (detail.status === 'in_service' || detail.status === 'waiting_user')}}">
<button loading="{{changingStatus}}" disabled="{{changingStatus}}" bindtap="changeStatus" data-action="complete">完成服务</button>
</view>
<view class="card">
<view class="title">服务历史</view>
<view wx:if="{{followups.length}}">
@@ -11,6 +11,9 @@
* 展示待处理服务、工作台数据概览
*/
const api = require('../../utils/api');
const { authStore } = require('../../stores');
const { matchmakerService } = require('../../services');
const { IS_DEV } = require('../../env');
Page({
data: {
@@ -20,6 +23,13 @@ Page({
todayCount: 0,
completedCount: 0,
myAssignments: [],
serviceTabs: [{ value: 'pending', label: '待处理' }, { value: 'in_progress', label: '进行中' }, { value: 'completed', label: '已完成' }],
activeServiceTab: 'pending',
services: [],
visibleServices: [],
demoEnabled: IS_DEV,
creatingDemoUser: false,
assigningServiceId: '',
assignmentCount: 0,
loading: true,
currentDate: '',
@@ -92,7 +102,7 @@ Page({
}
if (assignmentRes) {
const assignments = assignmentRes || [];
const assignments = Array.isArray(assignmentRes) ? assignmentRes : (assignmentRes.list || []);
// 联系授权申请目标 = 本人生效中的分配对象(B1:只能对分配对象发起)
const targets = assignments.map((a) => ({
userId: a.userId,
@@ -105,6 +115,7 @@ Page({
contactTargetIndex: 0,
});
this.loadContactAuthorizations();
await this.loadServices(token, assignments);
}
if (referralRes) {
this.setData({
@@ -132,8 +143,83 @@ Page({
}
},
async loadServices(token, assignments) {
const headers = { Authorization: `Bearer ${token}` };
const assigned = await Promise.allSettled(assignments.filter((item) => item.serviceId).map((item) =>
api.get(`/operator/matchmaker/services/${encodeURIComponent(item.serviceId)}`, {}, headers)));
const [pending, management] = await Promise.allSettled([
api.get('/operator/matchmaker/pending', {}, headers),
api.get('/operator/matchmaker/management/services', {}, headers),
]);
const services = new Map();
assigned.forEach((result) => {
if (result.status === 'fulfilled' && result.value) {
const item = result.value;
services.set(item.serviceId || item.id, { ...item, userDisplayName: item.userProfile && item.userProfile.displayName, source: 'assignment' });
}
});
if (management.status === 'fulfilled') (management.value || []).forEach((item) =>
services.set(item.serviceId || item.id, { ...item, source: item.status === 'submitted' || item.status === 'completed' ? 'management' : 'assignment' }));
if (pending.status === 'fulfilled') (pending.value || []).forEach((item) =>
services.set(item.serviceId || item.id, { ...item, source: 'management' }));
this.setData({ services: [...services.values()].map((item) => ({
...item,
statusLabel: this.serviceStatusLabel(item.status),
serviceTypeLabel: item.serviceType === 'recommend_partner' ? '推荐对象' : (item.serviceType || '公益红娘服务'),
})) });
this.selectServiceTab({ currentTarget: { dataset: { tab: this.data.activeServiceTab } } });
},
selectServiceTab(e) {
const tab = e.currentTarget.dataset.tab;
const statuses = tab === 'pending' ? ['submitted', 'pending']
: tab === 'in_progress' ? ['assigned', 'in_service', 'in_progress', 'waiting_user'] : ['completed'];
this.setData({ activeServiceTab: tab, visibleServices: this.data.services.filter((item) => statuses.includes(item.status)) });
},
serviceStatusLabel(status) {
return { submitted: '待处理', assigned: '已接单', in_service: '服务中', waiting_user: '待用户确认', completed: '已完成' }[status] || status;
},
async createDemoUser() {
if (this.data.creatingDemoUser) return;
this.setData({ creatingDemoUser: true });
try {
const code = `demo_openid_workbench_${Date.now()}`;
await authStore.loginWithDemo(code, '演示服务用户');
await matchmakerService.applyService({ serviceType: 'recommend_partner', description: '红娘演示服务申请' });
wx.showToast({ title: '演示申请已创建', icon: 'success' });
this.setData({ activeServiceTab: 'pending' });
await this.loadDashboard();
} catch (err) {
wx.showToast({ title: err.message || '注册演示用户失败', icon: 'none' });
} finally {
this.setData({ creatingDemoUser: false });
}
},
async assignToSelf(e) {
const serviceId = e.currentTarget.dataset.id;
const operatorId = wx.getStorageSync('operatorId');
if (!serviceId || !operatorId || this.data.assigningServiceId) return;
this.setData({ assigningServiceId: serviceId });
try {
const token = wx.getStorageSync('operatorToken');
await api.post(`/operator/matchmaker/services/${encodeURIComponent(serviceId)}/assign`,
{ operatorId, reason: '演示分配' }, { Authorization: `Bearer ${token}` });
wx.showToast({ title: '已接单', icon: 'success' });
this.setData({ activeServiceTab: 'in_progress' });
await this.loadDashboard();
} catch (err) {
wx.showToast({ title: err.message || '接单失败', icon: 'none' });
} finally {
this.setData({ assigningServiceId: '' });
}
},
onViewService(e) {
const serviceId = e.currentTarget.dataset.id;
const source = e.currentTarget.dataset.source || 'assignment';
if (!serviceId) {
const index = Number(e.currentTarget.dataset.index);
const item = this.data.myAssignments[index] || {};
@@ -144,7 +230,7 @@ Page({
});
return;
}
wx.navigateTo({ url: `/pages/matchmaker-service-detail/matchmaker-service-detail?id=${serviceId}` });
wx.navigateTo({ url: `/pages/matchmaker-service-detail/matchmaker-service-detail?id=${serviceId}&source=${source}` });
},
openReferral() {
@@ -44,42 +44,45 @@
</view>
</view>
<!-- 我的服务对象分配 -->
<!-- 统一服务工作台 -->
<view class="section section-compact" wx:if="{{!loading}}">
<view class="section-header">
<view class="section-title-wrap">
<text class="section-dot"></text>
<text class="section-title">我的服务对象分配</text>
<text class="section-title">服务工作台</text>
</view>
<text class="section-count" wx:if="{{assignmentCount > 0}}">共{{assignmentCount}}人</text>
<button wx:if="{{demoEnabled}}" class="detail-btn" size="mini" loading="{{creatingDemoUser}}" bindtap="createDemoUser">注册演示用户</button>
</view>
<view class="service-tabs">
<view wx:for="{{serviceTabs}}" wx:key="value" class="service-tab {{activeServiceTab === item.value ? 'active' : ''}}" data-tab="{{item.value}}" bindtap="selectServiceTab">{{item.label}}</view>
</view>
<view class="service-list" wx:if="{{myAssignments.length > 0}}">
<view class="service-list" wx:if="{{visibleServices.length > 0}}">
<view
wx:for="{{myAssignments}}"
wx:key="id"
wx:for="{{visibleServices}}"
wx:key="serviceId"
class="service-card"
data-id="{{item.serviceId}}"
data-source="{{item.source}}"
data-index="{{index}}"
bindtap="onViewService"
>
<view class="service-left">
<view class="service-header">
<view class="service-tag">{{assignmentScopeLabel(item.assignmentScope)}}</view>
<view class="service-tag">{{assignmentStatusLabel(item.status)}}</view>
<view class="service-time">{{formatTime(item.assignedAt)}}</view>
<view class="service-tag">{{item.statusLabel}}</view>
<view class="service-time">{{item.serviceTypeLabel}}</view>
</view>
<view class="service-name">{{item.userDisplayName || '未设置姓名'}} <text wx:if="{{item.userDisplayNumber}}">{{item.userDisplayNumber}}</text></view>
<view class="service-desc">{{item.userAge ? item.userAge + '岁 · ' : ''}}{{item.userRegionName || '地区未设置'}}</view>
<view class="service-name">{{item.userDisplayName || item.userDisplayNumber || '服务对象'}}</view>
<view class="service-desc">{{item.description || item.latestFollowupContent || '暂无服务进度'}}</view>
</view>
<button class="detail-btn" size="mini" catchtap="onViewService" data-id="{{item.serviceId}}" data-index="{{index}}">查看详情</button>
<button wx:if="{{item.status === 'submitted' && demoEnabled}}" class="detail-btn" size="mini" catchtap="assignToSelf" data-id="{{item.serviceId}}" loading="{{assigningServiceId === item.serviceId}}">接单</button>
<button wx:else class="detail-btn" size="mini" catchtap="onViewService" data-id="{{item.serviceId}}" data-source="{{item.source}}">查看详情</button>
</view>
</view>
<view class="empty-state empty-compact" wx:else>
<view class="empty-illustration">📭</view>
<view class="empty-text">暂无服务对象分配</view>
<view class="empty-hint">分配给你的服务对象将在这里展示</view>
<view class="empty-text">暂无该状态服务</view>
</view>
</view>
@@ -157,6 +157,9 @@
}
/* 服务列表 */
.service-tabs { display: flex; border-bottom: 1rpx solid var(--color-divider); margin-bottom: 20rpx; }
.service-tab { flex: 1; padding: 18rpx 0; text-align: center; color: var(--color-text-tertiary); font-size: 26rpx; }
.service-tab.active { color: var(--color-primary-strong); border-bottom: 4rpx solid currentColor; font-weight: 600; }
.service-list {
display:flex;
flex-direction:column;
+23 -5
View File
@@ -16,6 +16,18 @@ Page({
this.loadNotifications();
},
getTypeText(type) {
return { matchmaker: '红娘服务', appointment: '预约提醒', counseling: '咨询服务', premarital: '婚前辅导', service_location: '服务站服务', match: '匹配提醒', activity: '活动提醒', account: '账号状态', review: '审核通知', local_eligibility: '资格认证', risk: '安全提醒', system: '系统通知' }[type] || '系统通知';
},
openNotification(item) {
const type = item && item.type;
const url = ['matchmaker', 'appointment', 'counseling', 'premarital', 'service_location'].includes(type)
? '/pages/service-history/service-history'
: ['match', 'activity'].includes(type) ? '/pages/fate-center/fate-center' : '/pages/settings/settings';
wx.navigateTo({ url });
},
onShow() {
this.loadUnreadCount();
},
@@ -30,11 +42,12 @@ Page({
{ Authorization: `Bearer ${token}` }
);
if (res) {
const raw = Array.isArray(res) ? res : (res.notifications || res.items || res.records || []);
const isNewPage = this.data.page === 1;
this.setData({
notifications: isNewPage ? res.notifications : [...this.data.notifications, ...res.notifications],
unreadCount: res.unreadCount,
hasMore: res.notifications.length >= 20,
notifications: isNewPage ? raw.map(this.normalizeNotification) : [...this.data.notifications, ...raw.map(this.normalizeNotification)],
unreadCount: res.unreadCount || res.count || 0,
hasMore: raw.length >= 20,
loading: false
});
}
@@ -43,11 +56,15 @@ Page({
}
},
normalizeNotification(item) {
return { ...item, notificationId: item.notificationId || item.id, read: item.read === true || item.readStatus === 'read' };
},
async loadUnreadCount() {
try {
const token = wx.getStorageSync('accessToken');
const res = await api.get('/notifications/unread-count', {}, { Authorization: `Bearer ${token}` });
this.setData({ unreadCount: res?.unreadCount || 0 });
const res = await api.get('/notifications/unread', {}, { Authorization: `Bearer ${token}` });
this.setData({ unreadCount: res?.unreadCount ?? res?.count ?? 0 });
} catch (e) { /* ignore */ }
},
@@ -63,6 +80,7 @@ Page({
unreadCount: Math.max(0, this.data.unreadCount - 1)
});
} catch (err) { /* ignore */ }
this.openNotification(this.data.notifications.find((n) => n.notificationId === notificationId));
},
async onMarkAllRead() {
+20
View File
@@ -19,6 +19,22 @@ function wechatLogin(payload) {
return api.post('/auth/wechat/login', payload, undefined, payload.code);
}
function demoLogin(payload) {
return api.post('/auth/demo/login', payload, undefined, payload.code);
}
function demoRegister(payload) {
return api.post('/auth/demo/register', payload, undefined, payload.code);
}
function operatorDemoRegister(payload) {
return api.post('/auth/operator/demo/register', payload);
}
function operatorLogin(payload) {
return api.post('/auth/operator/login', payload);
}
/**
* 绑定手机号(API_INVENTORY 行 9)
* @param {{ phoneCode: string }} payload wx.getPhoneNumber 拿到的 encryptedData/iv 或 phoneCode
@@ -51,6 +67,10 @@ function logout() {
module.exports = {
wechatLogin,
demoLogin,
demoRegister,
operatorDemoRegister,
operatorLogin,
bindPhone,
refresh,
logout,
+17
View File
@@ -89,6 +89,23 @@ class AuthStore extends BaseStore {
}
}
async loginWithDemo(code, displayName, phone) {
this.setState({ authStatus: 'logging_in', lastError: null });
try {
const result = displayName
? await authService.demoRegister({ code, displayName, phone })
: await authService.demoLogin({ code });
api.saveToken(result.accessToken, result.refreshToken);
const snapshot = { isLoggedIn: true, userId: result.userId, phoneBound: !result.isNewUser };
this.setState({ ...snapshot, authStatus: 'idle' });
this.persist(snapshot, STORAGE_KEYS.IS_LOGGED_IN);
return result;
} catch (err) {
this.setState({ authStatus: 'idle', lastError: err });
throw err;
}
}
/**
* 绑定手机号
* @param {{ phoneCode?: string, encryptedData?: string, iv?: string }} payload