feat: 完善双端演示注册与红娘服务闭环
This commit is contained in:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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 ====================
|
||||
@@ -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') },
|
||||
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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,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')
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user