Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39a18a50ee | ||
|
|
afee993626 |
@@ -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 栈健康后执行一次即可;脚本可重复运行,不会清空数据库:
|
||||
|
||||
@@ -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": {
|
||||
|
||||
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 872 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
},
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
<template>
|
||||
<div class="page-layout">
|
||||
<van-nav-bar
|
||||
class="page-layout__nav"
|
||||
:title="title"
|
||||
left-arrow
|
||||
fixed
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ const RISKS = [
|
||||
]
|
||||
const STEPS = [
|
||||
{ title: '立即停止资金操作', description: '不要继续转账、充值或提供验证码' },
|
||||
{ title: '保存相关证据', description: '保留聊天、付款和对方账号信息' },
|
||||
{ title: '保存相关证据', description: '保留沟通记录、付款和对方账号信息' },
|
||||
{ title: '及时举报或报警', description: '平台举报不会通知被举报人,紧急情况请拨打 110' },
|
||||
]
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
<template>
|
||||
<PageLayout title="设置">
|
||||
<div class="settings">
|
||||
<UiSectionHeader title="通知与消息" />
|
||||
<UiSectionHeader title="系统通知" />
|
||||
<van-cell-group inset>
|
||||
<van-cell
|
||||
title="通知中心"
|
||||
|
||||
@@ -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') },
|
||||
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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-}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
|
||||
@@ -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; }
|
||||
@@ -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' })
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||