Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afee993626 |
@@ -33,6 +33,15 @@ 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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定手机号
|
||||
* POST /api/v1/auth/phone/bind
|
||||
|
||||
@@ -51,13 +51,36 @@ 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);
|
||||
}
|
||||
|
||||
private Map<String, Object> loginWithSession(Map<String, String> session) {
|
||||
String openid = session.get("openid");
|
||||
String unionid = session.get("unionid");
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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/refresh", "/auth/operator/login")
|
||||
.permitAll()
|
||||
.requestMatchers("/health/**").permitAll()
|
||||
.requestMatchers("/version").permitAll()
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,8 @@
|
||||
"build": "vite build",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"preview": "vite preview",
|
||||
"demo-login-contract": "node tests/demo-login-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,9 @@ 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 })
|
||||
},
|
||||
bindPhone(payload: Record<string, any>) {
|
||||
return post('/auth/phone/bind', payload, { idempotencyKey: payload.phoneCode || payload.encryptedData })
|
||||
},
|
||||
|
||||
@@ -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="/messages" icon="chat-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,8 +18,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 固定 4 项 TabBar,顺序与 miniapp/app.json 一致。
|
||||
const cached = ['HomePage', 'DiscoverPage', 'ActivityPage', 'MinePage']
|
||||
// 固定 5 项 TabBar,顺序与 miniapp/app.json 一致。
|
||||
const cached = ['HomePage', 'DiscoverPage', 'MessagesPage', 'ActivityPage', 'MinePage']
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -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>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<div class="home__brand-slogan">公益婚恋服务平台</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="home__notice" @click="router.push('/notifications')">
|
||||
<div class="home__notice" @click="router.push('/messages')">
|
||||
<van-icon name="bell" size="18" />
|
||||
<span>通知</span>
|
||||
</div>
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -44,7 +44,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { notificationService } from '@/api/services'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
@@ -55,6 +56,8 @@ import UiErrorState from '@/components/UiErrorState.vue'
|
||||
import UiTag from '@/components/UiTag.vue'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const route = useRoute()
|
||||
const pageTitle = computed(() => route.path === '/messages' ? '消息' : '通知')
|
||||
|
||||
const TYPE_TEXT: Record<string, string> = {
|
||||
system: '系统通知',
|
||||
|
||||
@@ -30,6 +30,7 @@ export const routes: RouteRecordRaw[] = [
|
||||
{ path: '', redirect: '/home' },
|
||||
{ path: 'home', name: 'HomePage', component: () => import('@/pages/home/HomePage.vue') },
|
||||
{ path: 'discover', name: 'DiscoverPage', component: () => import('@/pages/discover/DiscoverPage.vue') },
|
||||
{ path: 'messages', name: 'MessagesPage', component: () => import('@/pages/settings/NotificationsPage.vue') },
|
||||
{ path: 'activity', name: 'ActivityPage', component: () => import('@/pages/activity/ActivityPage.vue') },
|
||||
{ path: 'mine', name: 'MinePage', component: () => import('@/pages/mine/MinePage.vue') },
|
||||
],
|
||||
|
||||
@@ -28,7 +28,7 @@ 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
|
||||
}
|
||||
|
||||
@@ -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,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,15 @@
|
||||
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')
|
||||
|
||||
assert.match(tabLayout, /to="\/messages"[^>]*>消息<\/van-tabbar-item>/)
|
||||
assert.match(pageLayout, /class="page-layout__nav"/)
|
||||
assert.match(home, /home-hero-v2\.jpg/)
|
||||
assert.doesNotMatch(home, /home__banner-cover[^\n]*\{\{ \(b\.title \|\| '善'\)\.slice\(0, 1\) \}\}/)
|
||||
|
||||
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-}
|
||||
|
||||