feat: 完善双端演示注册与红娘服务闭环

This commit is contained in:
WorkBuddy
2026-10-21 23:41:47 +08:00
parent afee993626
commit 39a18a50ee
63 changed files with 1298 additions and 217 deletions
+1
View File
@@ -10,6 +10,7 @@
"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"
},
+6
View File
@@ -9,6 +9,9 @@ export const authService = {
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 })
},
@@ -22,6 +25,9 @@ export const authService = {
/** 红娘/工作人员端认证(独立会话,Bearer 走 h5.operatorToken) */
export const operatorService = {
registerDemo(payload: { name: string; username: string; password: string }) {
return post('/auth/operator/demo/register', payload)
},
async login(username: string, password: string): Promise<any> {
const data = await post('/auth/operator/login', { username, password })
if (data?.accessToken) {
+9
View File
@@ -68,6 +68,12 @@ export const operatorMatchmakerService = {
serviceDetail(id: string) {
return opGet(`/operator/matchmaker/services/${encodeURIComponent(id)}`)
},
managementServiceDetail(id: string) {
return opGet(`/operator/matchmaker/management/services/${encodeURIComponent(id)}`)
},
assign(id: string, payload: { operatorId: string; reason: string }) {
return opPost(`/operator/matchmaker/services/${encodeURIComponent(id)}/assign`, payload)
},
recommend(serviceId: string, payload: Record<string, any>) {
return opPost(`/operator/matchmaker/services/${encodeURIComponent(serviceId)}/recommend`, payload)
},
@@ -77,6 +83,9 @@ export const operatorMatchmakerService = {
complete(serviceId: string) {
return opPost(`/operator/matchmaker/services/${encodeURIComponent(serviceId)}/complete`, {})
},
start(serviceId: string) {
return opPost(`/operator/matchmaker/services/${encodeURIComponent(serviceId)}/start`, {})
},
myContactAuthorizations() {
return opGet('/operator/matchmaker/contact-authorizations/my')
},
+25 -13
View File
@@ -1,23 +1,31 @@
<template>
<div class="ui-user-card" @click="$emit('tap', user)">
<UiAvatar :src="user.avatarUrl" :name="user.displayName" :size="56" />
<UiAvatar :src="user.avatarUrl" :name="user.displayName" :size="48" />
<div class="ui-user-card__main">
<div class="ui-user-card__title">
<span class="ui-user-card__name">{{ user.displayName || '未设置昵称' }}</span>
<UiTag v-if="user.verified" type="success">已实名</UiTag>
<span v-if="user.online" class="ui-user-card__online">在线</span>
</div>
<div class="ui-user-card__meta">
<span v-if="user.age">{{ user.age }}岁</span>
<span v-if="user.heightCm">{{ user.heightCm }}cm</span>
<span v-if="user.regionName">{{ user.regionName }}</span>
<span v-if="user.heightCm">{{ user.heightCm }}cm · </span>
<span>{{ user.regionName || '地区未设置' }}</span>
<span v-if="educationLabel(user.educationCode)"> · {{ educationLabel(user.educationCode) }}</span>
</div>
<div v-if="occupationLabel(user.occupationCode)" class="ui-user-card__meta">{{ occupationLabel(user.occupationCode) }}</div>
<div v-if="user.recommendationReasons?.length" class="ui-user-card__reasons">
{{ user.recommendationReasons.slice(0, 3).join(' · ') }}
</div>
<div v-if="maritalLabel(user.maritalStatus) || user.hasChildren !== undefined" class="ui-user-card__meta">
<span v-if="maritalLabel(user.maritalStatus)">{{ maritalLabel(user.maritalStatus) }}</span><span v-if="user.hasChildren"> · 有孩</span>
</div>
</div>
<div v-if="showActions" class="ui-user-card__actions">
<van-button size="mini" type="primary" :loading="loading" @click.stop="$emit('like', user)">喜欢</van-button>
<van-button size="mini" plain @click.stop="$emit('pass', user)">略过</van-button>
<van-button class="ui-user-card__like" size="mini" type="primary" :loading="loading" aria-label="表达喜欢" @click.stop="$emit('like', user)">
<img src="/assets/brand/logo-mark.png" alt="" />
</van-button>
<van-button v-if="showPass" size="mini" plain @click.stop="$emit('pass', user)">略过</van-button>
</div>
</div>
</template>
@@ -25,11 +33,13 @@
<script setup lang="ts">
import UiAvatar from './UiAvatar.vue'
import UiTag from './UiTag.vue'
const props = withDefaults(
defineProps<{ user: any; showActions?: boolean; loading?: boolean }>(),
{ showActions: false, loading: false },
defineProps<{ user: any; showActions?: boolean; showPass?: boolean; loading?: boolean }>(),
{ showActions: false, showPass: true, loading: false },
)
const educationLabel = (code?: string) => ({ senior_high: '高中/中专', junior_high: '初中', college: '大专', bachelor: '本科', master: '硕士', doctor: '博士' }[code || ''] || '')
const occupationLabel = (code?: string) => ({ government: '机关事业单位', education: '教育', medical: '医疗', enterprise: '企业职员', technical: '技术人员', self_employed: '个体经营', freelance: '自由职业', service: '服务行业', agriculture: '农业', other: '其他' }[code || ''] || '')
const maritalLabel = (code?: string) => ({ never_married: '未婚', divorced: '离异', widowed: '丧偶' }[code || ''] || '')
defineEmits<{ tap: [any]; like: [any]; pass: [any] }>()
</script>
@@ -37,12 +47,11 @@ defineEmits<{ tap: [any]; like: [any]; pass: [any] }>()
.ui-user-card {
display: flex;
align-items: center;
gap: 12px;
gap: 8px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 12px;
margin-bottom: 10px;
box-shadow: var(--mini-shadow);
min-height: 74px;
padding: 7px 9px;
border-bottom: 1px solid var(--mini-border);
}
.ui-user-card__main {
flex: 1;
@@ -58,6 +67,7 @@ defineEmits<{ tap: [any]; like: [any]; pass: [any] }>()
font-weight: 600;
color: var(--mini-text-strong);
}
.ui-user-card__online { margin-left: auto; color: var(--mini-success, #198754); font-size: 12px; }
.ui-user-card__meta {
display: flex;
gap: 10px;
@@ -78,4 +88,6 @@ defineEmits<{ tap: [any]; like: [any]; pass: [any] }>()
flex-direction: column;
gap: 6px;
}
.ui-user-card__like { width: 36px; height: 36px; padding: 0; border-radius: 50%; }
.ui-user-card__like img { width: 21px; height: 21px; object-fit: contain; filter: brightness(0) invert(1); }
</style>
+3 -3
View File
@@ -10,7 +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="/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>
@@ -18,8 +18,8 @@
</template>
<script setup lang="ts">
// 固定 5 项 TabBar,顺序与 miniapp/app.json 一致。
const cached = ['HomePage', 'DiscoverPage', 'MessagesPage', 'ActivityPage', 'MinePage']
// 用户端只保留小程序的非聊天主导航。
const cached = ['HomePage', 'DiscoverPage', 'ActivityPage', 'MinePage']
</script>
<style scoped>
@@ -0,0 +1,57 @@
<template>
<div class="register">
<van-nav-bar title="演示用户注册" left-arrow @click-left="router.back()" />
<van-cell-group inset>
<van-field v-model="name" label="昵称" placeholder="请输入演示昵称" maxlength="40" />
<van-field v-model="code" label="账号编号" placeholder="如 user_demo_01" maxlength="100" />
<van-field v-model="phone" label="手机号" type="tel" placeholder="演示手机号(可选)" maxlength="11" />
</van-cell-group>
<div class="register__actions">
<van-button type="primary" block :loading="loading" @click="submit">注册并登录</van-button>
<p v-if="error" class="register__error">{{ error }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useUserStore } from '@/stores/user'
import { useAppStore } from '@/stores/app'
const router = useRouter()
const auth = useAuthStore()
const user = useUserStore()
const app = useAppStore()
const name = ref('')
const code = ref('')
const phone = ref('')
const loading = ref(false)
const error = ref('')
async function submit() {
if (loading.value) return
if (!name.value.trim() || !/^[a-zA-Z0-9_-]{1,100}$/.test(code.value.trim()) || (phone.value && !/^1[3-9]\d{9}$/.test(phone.value))) {
error.value = '请填写有效的昵称、账号编号和手机号'
return
}
loading.value = true
error.value = ''
try {
await auth.registerDemo(`demo_openid_${code.value.trim()}`, name.value.trim(), phone.value.trim())
await Promise.all([user.fetchStatus(), app.fetchCapabilities()])
router.replace('/home')
} catch (e: any) {
error.value = e?.message || '注册失败,请更换账号编号后重试'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.register { min-height: 100vh; background: var(--mini-bg); }
.register__actions { padding: 20px 16px; }
.register__error { color: var(--mini-danger); font-size: 13px; }
</style>
+1
View File
@@ -27,6 +27,7 @@
</div>
<van-cell-group inset class="login__ops">
<van-cell title="注册演示用户" is-link icon="edit" to="/register" />
<van-cell title="进入红娘工作台" is-link icon="manager-o" @click="goOperator" />
</van-cell-group>
+1 -1
View File
@@ -80,7 +80,7 @@ const RISKS = [
]
const STEPS = [
{ title: '立即停止资金操作', description: '不要继续转账、充值或提供验证码' },
{ title: '保存相关证据', description: '保留聊天、付款和对方账号信息' },
{ title: '保存相关证据', description: '保留沟通记录、付款和对方账号信息' },
{ title: '及时举报或报警', description: '平台举报不会通知被举报人,紧急情况请拨打 110' },
]
+202 -117
View File
@@ -1,30 +1,28 @@
<template>
<div class="discover">
<!-- 搜索栏 -->
<van-search
v-model="keywordInput"
shape="round"
placeholder="搜索昵称 / 关键词"
@search="onSearch"
@clear="onSearch"
/>
<!-- 快捷筛选条 -->
<div class="discover__chips">
<span
class="discover__chip"
:class="{ 'discover__chip--active': applied.regionCode === '321181' }"
@click="toggleLocal"
>
丹阳本地
</span>
<span
class="discover__chip"
:class="{ 'discover__chip--active': hasActiveFilter }"
@click="showFilter = true"
>
<van-icon name="filter-o" /> 筛选
</span>
<div class="discover__hero">
<div class="discover__nav">
<span class="discover__title">找对象</span>
<button class="discover__search-button" type="button" aria-label="打开筛选" @click="openFilterPanel()">
<van-icon name="search" />
</button>
</div>
<div class="discover__search-bar">
<span class="discover__search-label">搜索</span>
<input
v-model="keywordInput"
class="discover__search-input"
placeholder="姓名、职业或地区"
@keyup.enter="onSearch"
/>
</div>
<div class="discover__filter-scroll">
<button class="discover__filter-chip" :class="{ 'discover__filter-chip--active': !hasActiveFilter }" type="button" @click="resetFilter">推荐</button>
<button class="discover__filter-chip" :class="{ 'discover__filter-chip--active': applied.regionCode === '321181' }" type="button" @click="toggleLocal">丹阳本地</button>
<button class="discover__filter-chip" :class="{ 'discover__filter-chip--active': hasAgeFilter }" type="button" @click="openFilterPanel()">年龄</button>
<button class="discover__filter-chip" :class="{ 'discover__filter-chip--active': !!applied.educationCode }" type="button" @click="openFilterPanel()">学历</button>
<button class="discover__filter-chip" :class="{ 'discover__filter-chip--active': !!applied.maritalStatus }" type="button" @click="openFilterPanel()">婚姻</button>
</div>
</div>
<!-- 列表 -->
@@ -33,10 +31,17 @@
<template v-else>
<UiLoading v-if="firstLoading" />
<template v-else>
<div v-if="users.length || loadingMore" class="discover__result-head">
<div>
<div class="discover__result-title">为你推荐</div>
<div class="discover__result-subtitle">{{ hasActiveFilter ? '已按当前条件筛选' : '认真认识,再做选择' }}</div>
</div>
<span class="discover__result-count">{{ users.length }} 位</span>
</div>
<van-list
v-model:loading="loadingMore"
:finished="finished"
finished-text="没有更多了"
finished-text="真诚相遇,认真选择"
@load="onLoadMore"
>
<UiUserCard
@@ -44,24 +49,36 @@
:key="u.userId"
:user="u"
show-actions
:show-pass="false"
:loading="actionLoading === u.userId"
@tap="goDetail"
@like="onLike"
@pass="onPass"
/>
</van-list>
<UiEmpty v-if="!users.length" description="没有符合筛选条件的人" />
<div v-if="!users.length" class="discover__empty">
<img src="/assets/brand/logo-mark.png" alt="" />
<div class="discover__empty-title">暂无符合条件的人选</div>
<div class="discover__empty-desc">必须符合的条件不会自动放宽,可以调整筛选后再看看</div>
<van-button size="small" plain type="primary" @click="resetFilter">清空筛选</van-button>
</div>
</template>
</template>
</div>
<!-- 筛选面板 -->
<van-popup v-model:show="showFilter" position="bottom" round :style="{ maxHeight: '80%' }">
<van-popup v-model:show="showFilter" position="bottom" round :style="{ maxHeight: '78vh' }">
<div class="filter">
<div class="filter__title">筛选条件</div>
<div class="filter__scroll">
<div class="filter__title">筛选条件</div>
<div class="filter__group">
<div class="filter__label">所在地区</div>
<div class="filter__heading"><div class="filter__label">年龄范围</div><span>{{ temp.minAge }}–{{ temp.maxAge }} 岁</span></div>
<van-slider v-model="temp.minAge" :min="18" :max="60" active-color="#f4005a" @change="onMinAgeChange" />
<van-slider v-model="temp.maxAge" :min="18" :max="60" active-color="#7b63d9" @change="onMaxAgeChange" />
</div>
<div class="filter__group">
<div class="filter__label">地区</div>
<div class="filter__chips">
<span
v-for="r in regions"
@@ -75,15 +92,6 @@
</div>
</div>
<div class="filter__group">
<div class="filter__label">年龄范围</div>
<div class="filter__range">
<van-field v-model="temp.minAge" type="digit" label="" placeholder="最小" />
<span class="filter__range-sep">—</span>
<van-field v-model="temp.maxAge" type="digit" label="" placeholder="最大" />
</div>
</div>
<div class="filter__group">
<div class="filter__label">学历</div>
<div class="filter__chips">
@@ -99,18 +107,19 @@
</div>
</div>
<div class="filter__group">
<div class="filter__label">婚姻状况</div>
<div class="filter__chips">
<span
v-for="m in maritalStatuses"
:key="m.code"
class="filter__chip"
:class="{ 'filter__chip--active': temp.maritalStatus === m.code }"
@click="toggleTemp('maritalStatus', m.code)"
>
{{ m.label }}
</span>
<div class="filter__group">
<div class="filter__label">婚姻状况</div>
<div class="filter__chips">
<span
v-for="m in maritalStatuses"
:key="m.code"
class="filter__chip"
:class="{ 'filter__chip--active': temp.maritalStatus === m.code }"
@click="toggleTemp('maritalStatus', m.code)"
>
{{ m.label }}
</span>
</div>
</div>
</div>
@@ -137,8 +146,8 @@ const router = useRouter()
const matchStore = useMatchStore()
const appStore = useAppStore()
const DEFAULT_MIN_AGE = '18'
const DEFAULT_MAX_AGE = '60'
const DEFAULT_MIN_AGE = 18
const DEFAULT_MAX_AGE = 60
const PAGE_SIZE = 20
const regions = [
@@ -186,6 +195,9 @@ const hasActiveFilter = computed(
!!applied.educationCode ||
!!applied.maritalStatus,
)
const hasAgeFilter = computed(
() => applied.minAge !== DEFAULT_MIN_AGE || applied.maxAge !== DEFAULT_MAX_AGE,
)
let storeCursor: string | null = null
@@ -193,8 +205,8 @@ function buildParams(): Record<string, any> {
const params: Record<string, any> = { pageSize: PAGE_SIZE, page: page.value }
if (applied.keyword) params.keyword = applied.keyword
if (applied.regionCode) params.regionCode = applied.regionCode
if (applied.minAge !== DEFAULT_MIN_AGE) params.minAge = Number(applied.minAge)
if (applied.maxAge !== DEFAULT_MAX_AGE) params.maxAge = Number(applied.maxAge)
if (applied.minAge !== DEFAULT_MIN_AGE) params.minAge = applied.minAge
if (applied.maxAge !== DEFAULT_MAX_AGE) params.maxAge = applied.maxAge
if (applied.educationCode) params.educationCode = applied.educationCode
if (applied.maritalStatus) params.maritalStatus = applied.maritalStatus
return params
@@ -267,6 +279,15 @@ function onSearch() {
reload()
}
function openFilterPanel() {
showFilter.value = true
temp.regionCode = applied.regionCode
temp.minAge = applied.minAge
temp.maxAge = applied.maxAge
temp.educationCode = applied.educationCode
temp.maritalStatus = applied.maritalStatus
}
function toggleLocal() {
applied.regionCode = applied.regionCode === '321181' ? '' : '321181'
temp.regionCode = applied.regionCode
@@ -277,6 +298,14 @@ function toggleTemp(field: 'regionCode' | 'educationCode' | 'maritalStatus', cod
temp[field] = temp[field] === code ? '' : code
}
function onMinAgeChange(value: number) {
if (value > temp.maxAge) temp.minAge = temp.maxAge
}
function onMaxAgeChange(value: number) {
if (value < temp.minAge) temp.maxAge = temp.minAge
}
function applyFilter() {
applied.keyword = (keywordInput.value || '').trim()
applied.regionCode = temp.regionCode
@@ -326,20 +355,6 @@ async function onLike(u: any) {
}
}
async function onPass(u: any) {
if (!u?.userId || actionLoading.value) return
actionLoading.value = u.userId
try {
await matchStore.pass(u.userId)
users.value = users.value.filter((x) => x.userId !== u.userId)
appStore.toastSuccess('已略过')
} catch (err: any) {
appStore.toastError(err?.message || '操作失败')
} finally {
actionLoading.value = ''
}
}
onMounted(reload)
</script>
@@ -348,78 +363,148 @@ onMounted(reload)
min-height: 100vh;
background: var(--mini-bg);
}
.discover__chips {
.discover__hero {
padding: 16px 18px 14px;
color: #fff;
background: linear-gradient(160deg, var(--mini-night-start), var(--mini-night-end));
border-radius: 0 0 18px 18px;
box-shadow: var(--mini-shadow);
}
.discover__nav {
position: relative;
display: flex;
align-items: center;
justify-content: center;
height: 42px;
margin-bottom: 8px;
}
.discover__title {
font-size: 18px;
font-weight: 700;
}
.discover__search-button {
position: absolute;
right: 0;
width: 34px;
height: 34px;
color: #fff;
border: 1px solid rgba(255, 255, 255, .2);
border-radius: 50%;
background: rgba(255, 255, 255, .1);
}
.discover__search-bar {
display: flex;
align-items: center;
height: 42px;
padding: 0 14px;
border: 1px solid rgba(255, 255, 255, .14);
border-radius: 24px;
background: rgba(255, 255, 255, .12);
}
.discover__search-label {
flex: none;
padding-right: 12px;
margin-right: 12px;
border-right: 1px solid rgba(255, 255, 255, .25);
font-size: 13px;
white-space: nowrap;
}
.discover__search-input {
width: 100%;
color: #fff;
border: 0;
outline: 0;
background: transparent;
font-size: 14px;
}
.discover__search-input::placeholder { color: rgba(255, 255, 255, .58); }
.discover__filter-scroll {
display: flex;
gap: 8px;
padding: 0 16px 8px;
overflow-x: auto;
padding-top: 12px;
scrollbar-width: none;
}
.discover__chip {
font-size: 12px;
padding: 5px 12px;
border-radius: 14px;
background: var(--mini-surface);
color: var(--mini-text-secondary);
border: 1px solid var(--mini-border);
.discover__filter-scroll::-webkit-scrollbar { display: none; }
.discover__filter-chip {
flex: none;
padding: 7px 13px;
color: rgba(255, 255, 255, .8);
border: 0;
border-radius: 18px;
background: rgba(255, 255, 255, .1);
font-size: 13px;
}
.discover__chip--active {
.discover__filter-chip--active {
color: var(--mini-tabbar-active);
border-color: var(--mini-tabbar-active);
background: rgba(244, 0, 90, 0.06);
background: #fff;
font-weight: 600;
}
.discover__result-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 4px 10px;
}
.discover__result-title { color: var(--mini-text-strong); font-size: 16px; font-weight: 700; }
.discover__result-subtitle, .discover__result-count { color: var(--mini-text-muted); font-size: 12px; }
.discover__body {
padding: 4px 16px 16px;
}
.discover__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 48px 20px;
text-align: center;
}
.discover__empty img { width: 56px; height: 56px; object-fit: contain; }
.discover__empty-title { color: var(--mini-text-strong); font-size: 16px; font-weight: 600; }
.discover__empty-desc { max-width: 300px; color: var(--mini-text-secondary); font-size: 13px; line-height: 1.6; }
.discover__empty :deep(.van-button) { margin-top: 8px; }
.filter {
padding: 20px 16px 24px;
display: flex;
flex-direction: column;
max-height: 78vh;
padding: 0 18px max(4px, env(safe-area-inset-bottom));
}
.filter__scroll {
min-height: 0;
overflow-y: auto;
padding-top: 20px;
}
.filter__title {
font-size: 16px;
font-weight: 600;
color: var(--mini-text-strong);
margin-bottom: 16px;
}
.filter__group {
font-size: 17px;
font-weight: 700;
margin-bottom: 18px;
}
.filter__label {
font-size: 13px;
color: var(--mini-text-secondary);
margin-bottom: 8px;
}
.filter__chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.filter__group { margin-bottom: 20px; }
.filter__heading { display: flex; justify-content: space-between; align-items: center; }
.filter__heading span { color: var(--mini-text-muted); font-size: 13px; }
.filter__label { color: var(--mini-text-secondary); font-size: 13px; margin-bottom: 9px; }
.filter__chips { display: flex; flex-wrap: wrap; gap: 8px; }
.filter__chip {
font-size: 12px;
padding: 6px 14px;
border-radius: 14px;
background: var(--mini-bg);
padding: 7px 14px;
color: var(--mini-text);
border: 1px solid var(--mini-border);
border-radius: 16px;
background: var(--mini-bg);
font-size: 12px;
}
.filter__chip--active {
color: var(--mini-tabbar-active);
border-color: var(--mini-tabbar-active);
background: rgba(244, 0, 90, 0.06);
}
.filter__range {
display: flex;
align-items: center;
gap: 10px;
}
.filter__range :deep(.van-field) {
flex: 1;
background: var(--mini-bg);
border-radius: 8px;
}
.filter__range-sep {
color: var(--mini-text-muted);
background: rgba(244, 0, 90, .06);
}
.filter__group :deep(.van-slider) { margin: 18px 8px; }
.filter__actions {
flex: none;
display: flex;
gap: 12px;
margin-top: 8px;
padding: 10px 0 4px;
background: var(--mini-surface);
}
.filter__actions > * { flex: 1; }
</style>
+1 -1
View File
@@ -10,7 +10,7 @@
<div class="home__brand-slogan">公益婚恋服务平台</div>
</div>
</div>
<div class="home__notice" @click="router.push('/messages')">
<div class="home__notice" @click="router.push('/notifications')">
<van-icon name="bell" size="18" />
<span>通知</span>
</div>
@@ -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
}
@@ -44,8 +44,8 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
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'
@@ -56,16 +56,22 @@ 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 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)
@@ -87,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() {
@@ -111,12 +125,16 @@ async function load() {
}
async function onMarkRead(item: any) {
if (!item?.id || isRead(item)) return
if (!item?.id) return
try {
await notificationService.markNotificationRead(item.id)
item.readStatus = 'read'
item.read = true
unreadCount.value = Math.max(0, unreadCount.value - 1)
if (!isRead(item)) {
await notificationService.markNotificationRead(item.id)
item.readStatus = 'read'
item.read = true
unreadCount.value = Math.max(0, unreadCount.value - 1)
}
const route = notificationRoute(item)
if (route) router.push({ path: route, query: item.businessId ? { id: item.businessId } : undefined })
} catch (err: any) {
appStore.toastError(err?.message || '操作失败')
}
@@ -71,7 +71,7 @@ const STATUS_META: Record<string, { label: string; desc: string; kind: 'primary'
},
paused: {
label: '已暂停寻找',
desc: '你暂时不会被推荐给他人,也无法收到新的喜欢提示。已有聊天仍可继续使用。',
desc: '你暂时不会被推荐给他人,也无法收到新的喜欢提示。已有互动记录仍会保留。',
kind: 'warning',
},
getting_to_know: {
@@ -86,7 +86,7 @@ const STATUS_META: Record<string, { label: string; desc: string; kind: 'primary'
},
service_exited: {
label: '已退出婚恋服务',
desc: '你已退出婚恋服务,不再出现在推荐和搜索结果中。历史匹配与聊天记录已保留。',
desc: '你已退出婚恋服务,不再出现在推荐和搜索结果中。历史匹配与互动记录已保留。',
kind: 'info',
},
}
@@ -133,7 +133,7 @@ async function onPause() {
try {
await showConfirmDialog({
title: '暂停寻找',
message: '暂停后你将不会被推荐给他人,已有聊天仍可继续使用。是否继续?',
message: '暂停后你将不会被推荐给他人,已有互动记录仍会保留。是否继续?',
})
} catch {
return
+1 -1
View File
@@ -1,7 +1,7 @@
<template>
<PageLayout title="设置">
<div class="settings">
<UiSectionHeader title="通知与消息" />
<UiSectionHeader title="系统通知" />
<van-cell-group inset>
<van-cell
title="通知中心"
+3 -2
View File
@@ -15,11 +15,12 @@ export const APPROVED_ROUTES = new Set([
])
/** 无需登录即可访问 */
export const PUBLIC_ROUTES = new Set(['/login', '/operator/login', '/about', '/help'])
export const PUBLIC_ROUTES = new Set(['/login', '/register', '/operator/login', '/operator/register', '/about', '/help'])
export const routes: RouteRecordRaw[] = [
// ==================== 认证 ====================
{ path: '/login', name: 'Login', component: () => import('@/pages/auth/LoginPage.vue') },
{ path: '/register', name: 'DemoRegister', component: () => import('@/pages/auth/DemoRegisterPage.vue') },
{ path: '/bind-phone', name: 'BindPhone', component: () => import('@/pages/auth/BindPhonePage.vue') },
// ==================== Tab ====================
@@ -30,7 +31,6 @@ 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') },
],
@@ -110,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') },
+7 -1
View File
@@ -33,6 +33,12 @@ export const useAuthStore = defineStore('auth', () => {
return data
}
async function registerDemo(code: string, displayName: string, phone?: string) {
const data = await authService.demoRegister({ code, displayName, phone })
applyTokens(data)
return data
}
function clearSession() {
accessToken.value = null
userId.value = null
@@ -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 }
})
+19
View File
@@ -0,0 +1,19 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const root = path.resolve(__dirname, '..')
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8')
const workbench = read('src/pages/operator/OperatorWorkbenchPage.vue')
const notifications = read('src/pages/settings/NotificationsPage.vue')
assert.match(workbench, /待处理/)
assert.match(workbench, /进行中/)
assert.match(workbench, /已完成/)
assert.match(workbench, /active|assigned|in_service|completed/)
assert.match(workbench, /注册演示用户|创建演示用户/)
assert.match(notifications, /matchmaker|appointment|counseling|premarital|review|account|activity|match|system/)
assert.match(notifications, /businessId|businessType/)
assert.match(notifications, /router\.(push|replace)/)
console.log('PASS: demo flow UI contract')
+19
View File
@@ -0,0 +1,19 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const root = path.resolve(__dirname, '..')
const routes = fs.readFileSync(path.join(root, 'src/router/routes.ts'), 'utf8')
const authPage = fs.readFileSync(path.join(root, 'src/pages/auth/DemoRegisterPage.vue'), 'utf8')
const operatorPage = fs.readFileSync(path.join(root, 'src/pages/operator/OperatorRegisterPage.vue'), 'utf8')
const authApi = fs.readFileSync(path.resolve(root, '../backend/src/main/java/com/danyang/match/platform/auth/controller/AuthController.java'), 'utf8')
const operatorApi = fs.readFileSync(path.resolve(root, '../backend/src/main/java/com/danyang/match/platform/auth/controller/OperatorAuthController.java'), 'utf8')
assert.match(routes, /path: '\/register'/)
assert.match(routes, /path: '\/operator\/register'/)
assert.match(authPage, /auth\.registerDemo\(/)
assert.match(operatorPage, /operatorService\.registerDemo\(/)
assert.match(authApi, /@PostMapping\("\/demo\/register"\)/)
assert.match(operatorApi, /@PostMapping\("\/demo\/register"\)/)
console.log('PASS: demo registration routes and APIs are wired')
+26 -1
View File
@@ -6,10 +6,35 @@ 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.match(tabLayout, /to="\/messages"[^>]*>消息<\/van-tabbar-item>/)
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')