release: freeze demo v1.1.0 2026-10-19
CI/CD Pipeline / Backend Build & Test (push) Canceled after 0s
CI/CD Pipeline / Admin Web Build (push) Canceled after 0s
CI/CD Pipeline / Mini Program Compile Check (push) Canceled after 0s
CI/CD Pipeline / Migration Validation (push) Canceled after 0s
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Canceled after 0s
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Canceled after 0s
CI/CD Pipeline / E2E (live stack) (push) Canceled after 0s

This commit is contained in:
WorkBuddy
2026-10-19 20:36:22 +08:00
parent 8b505d9133
commit 69ebf33e18
157 changed files with 21111 additions and 72 deletions
+23
View File
@@ -0,0 +1,23 @@
<template>
<router-view />
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useUserStore } from '@/stores/user'
import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
onMounted(() => {
const userStore = useUserStore()
const appStore = useAppStore()
const authStore = useAuthStore()
userStore.hydrate()
appStore.init().catch(() => {})
if (authStore.isLoggedIn) {
userStore.fetchStatus().catch(() => {})
appStore.fetchCapabilities().catch(() => {})
}
})
</script>
+174
View File
@@ -0,0 +1,174 @@
import axios, { type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios'
import { getItem, setItem, removeItem, StorageKeys } from '@/utils/storage'
import { releaseIdempotencyKey, generateRequestIdForHeader } from './idempotency'
export class ApiError extends Error {
code: string
requestId?: string
status?: number
constructor(code: string, message: string, requestId?: string, status?: number) {
super(message)
this.code = code
this.requestId = requestId
this.status = status
}
}
export interface RequestOptions {
idempotencyKey?: string
silent?: boolean
}
/** 会话失效回调(由 authStore 注入)。 */
export let onSessionExpired: (() => void) | null = null
export function setOnSessionExpired(fn: (() => void) | null): void {
onSessionExpired = fn
}
/** 维护态回调(由 appStore 注入)。 */
export let onMaintenance: ((body: any) => void) | null = null
export function setOnMaintenance(fn: ((body: any) => void) | null): void {
onMaintenance = fn
}
const client = axios.create({
baseURL: '/api/v1',
timeout: 15000,
})
function isAuthEndpoint(url?: string): boolean {
return typeof url === 'string' && url.indexOf('/auth/') === 0
}
client.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = getItem(StorageKeys.accessToken)
config.headers = config.headers || {}
config.headers['X-Request-Id'] = generateRequestIdForHeader()
if (token) config.headers['Authorization'] = `Bearer ${token}`
return config
})
let refreshPromise: Promise<boolean> | null = null
/** 单飞刷新:并发 401 共享同一次刷新请求。refresh 只走 body,不依赖 Cookie。 */
async function refreshToken(): Promise<boolean> {
if (refreshPromise) return refreshPromise
refreshPromise = (async () => {
const refreshTokenValue = getItem(StorageKeys.refreshToken)
const userId = getItem(StorageKeys.userId)
if (!refreshTokenValue) return false
try {
const resp = await axios.post('/api/v1/auth/refresh', { userId, refreshToken: refreshTokenValue })
const body = resp.data || {}
if (body.success && body.data?.accessToken) {
setItem(StorageKeys.accessToken, body.data.accessToken)
if (body.data.refreshToken) setItem(StorageKeys.refreshToken, body.data.refreshToken)
return true
}
return false
} catch {
return false
} finally {
refreshPromise = null
}
})()
return refreshPromise
}
client.interceptors.response.use(
(resp) => {
const config = resp.config as InternalAxiosRequestConfig & { __idempotencyKey?: string }
const body = resp.data || {}
if (body.success) {
const data = body.data
// 先取值,再做收尾:释放幂等键失败绝不能升级为业务失败(对齐 miniapp 语义)。
try {
releaseIdempotencyKey(config.__idempotencyKey)
} catch {
/* ignore */
}
return data
}
throw new ApiError(body.code || 'BUSINESS_ERROR', body.message || '操作失败', body.requestId, resp.status)
},
async (error) => {
const resp = error?.response
if (!resp) {
throw new ApiError('NETWORK_ERROR', '网络连接失败,请检查网络后重试', undefined, undefined)
}
const body = resp.data || {}
const config = resp.config as InternalAxiosRequestConfig & { __retried?: boolean }
if (resp.status === 503) {
if (typeof onMaintenance === 'function') onMaintenance(body)
throw new ApiError('MAINTENANCE', '平台正在维护,请稍后再试', body.requestId, 503)
}
if (resp.status === 401) {
// 认证端点自身 401 不触发刷新(否则自我递归);由调用方处理。
if (!isAuthEndpoint(config?.url) && !config?.__retried) {
const ok = await refreshToken()
if (ok) {
config.__retried = true
const token = getItem(StorageKeys.accessToken)
if (token) config.headers['Authorization'] = `Bearer ${token}`
return client.request(config)
}
removeItem(StorageKeys.accessToken)
removeItem(StorageKeys.refreshToken)
if (typeof onSessionExpired === 'function') onSessionExpired()
}
throw new ApiError(body.code || 'UNAUTHORIZED', body.message || '请重新登录', body.requestId, 401)
}
throw new ApiError(body.code || 'SERVER_ERROR', body.message || '服务暂时不可用', body.requestId, resp.status)
},
)
export interface ApiOptions {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
data?: any
headers?: Record<string, string>
idempotencyKey?: string
}
export async function request<T = any>(url: string, options: ApiOptions = {}): Promise<T> {
const config: AxiosRequestConfig & { __idempotencyKey?: string } = {
url,
method: options.method || 'GET',
data: options.data,
headers: options.headers,
}
config.__idempotencyKey = options.idempotencyKey
return client.request(config) as Promise<T>
}
export function get<T = any>(url: string, params?: Record<string, any>): Promise<T> {
return client.get(url, { params }) as Promise<T>
}
export function post<T = any>(url: string, data?: any, opts: RequestOptions = {}): Promise<T> {
const config: AxiosRequestConfig & { __idempotencyKey?: string } = { url, method: 'POST', data }
config.__idempotencyKey = opts.idempotencyKey
return client.request(config) as Promise<T>
}
export function put<T = any>(url: string, data?: any, opts: RequestOptions = {}): Promise<T> {
const config: AxiosRequestConfig & { __idempotencyKey?: string } = { url, method: 'PUT', data }
config.__idempotencyKey = opts.idempotencyKey
return client.request(config) as Promise<T>
}
export function patch<T = any>(url: string, data?: any, opts: RequestOptions = {}): Promise<T> {
const config: AxiosRequestConfig & { __idempotencyKey?: string } = { url, method: 'PATCH', data }
config.__idempotencyKey = opts.idempotencyKey
return client.request(config) as Promise<T>
}
export function del<T = any>(url: string, data?: any, opts: RequestOptions = {}): Promise<T> {
const config: AxiosRequestConfig & { __idempotencyKey?: string } = { url, method: 'DELETE', data }
config.__idempotencyKey = opts.idempotencyKey
return client.request(config) as Promise<T>
}
export default client
+45
View File
@@ -0,0 +1,45 @@
/**
* 幂等键托管(复刻 miniapp/utils/api.js 语义)
*
* 语义:同一次**尚未成功**的逻辑操作复用同一把键;成功后释放,下一次点击才是新的一次。
* 「先 resolve 再收尾」——释放失败绝不能升级为业务失败(见 client.ts)。
* 未成功操作的键保留 10 分钟。
*/
const IDEMPOTENCY_KEY_TTL_MS = 10 * 60 * 1000
interface PendingEntry {
key: string
createdAt: number
}
const pending = new Map<string, PendingEntry>()
function generateRequestId(): string {
return Date.now().toString(36) + Math.random().toString(36).substring(2, 8)
}
export function idempotencyKey(scope: string, subject?: string | number | null): string {
const fingerprint = `${scope}::${subject == null ? '' : subject}`
const now = Date.now()
const cached = pending.get(fingerprint)
if (cached && now - cached.createdAt < IDEMPOTENCY_KEY_TTL_MS) {
return cached.key
}
const key = `${scope}:${subject == null ? '' : subject}:${generateRequestId()}`
pending.set(fingerprint, { key, createdAt: now })
return key
}
export function releaseIdempotencyKey(key?: string): void {
if (!key) return
for (const [fingerprint, entry] of pending.entries()) {
if (entry.key === key) {
pending.delete(fingerprint)
return
}
}
}
export function generateRequestIdForHeader(): string {
return generateRequestId()
}
+89
View File
@@ -0,0 +1,89 @@
import axios, { type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios'
import { getItem, setItem, removeItem, StorageKeys } from '@/utils/storage'
import { ApiError } from './client'
/**
* 红娘/工作人员端独立请求实例。
*
* 与用户端 client 的关键区别(见实施计划 R3):
* - Bearer 取自 h5.operatorToken(与用户 accessToken 隔离);
* - 刷新一律走请求体 `POST /auth/operator/refresh { operatorId, refreshToken }`,
* **绝不读 Cookie** —— 避免与同浏览器内的 admin-web 会话串号。
*/
const operatorClient = axios.create({
baseURL: '/api/v1',
timeout: 15000,
})
operatorClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = getItem(StorageKeys.operatorToken)
config.headers = config.headers || {}
config.headers['X-Request-Id'] = Date.now().toString(36) + Math.random().toString(36).substring(2, 8)
if (token) config.headers['Authorization'] = `Bearer ${token}`
return config
})
let refreshing: Promise<boolean> | null = null
async function refreshOperatorToken(): Promise<boolean> {
if (refreshing) return refreshing
refreshing = (async () => {
const refreshToken = getItem(StorageKeys.refreshToken + '.operator')
const operatorId = getItem(StorageKeys.operatorId)
if (!refreshToken) return false
try {
const resp = await axios.post('/api/v1/auth/operator/refresh', { operatorId, refreshToken })
const body = resp.data || {}
if (body.success && body.data?.accessToken) {
setItem(StorageKeys.operatorToken, body.data.accessToken)
if (body.data.refreshToken) setItem(StorageKeys.refreshToken + '.operator', body.data.refreshToken)
return true
}
return false
} catch {
return false
} finally {
refreshing = null
}
})()
return refreshing
}
operatorClient.interceptors.response.use(
(resp) => {
const body = resp.data || {}
if (body.success) return body.data
throw new ApiError(body.code || 'BUSINESS_ERROR', body.message || '操作失败', body.requestId, resp.status)
},
async (error) => {
const resp = error?.response
if (!resp) throw new ApiError('NETWORK_ERROR', '网络连接失败,请重试')
const body = resp.data || {}
const config = resp.config as InternalAxiosRequestConfig & { __retried?: boolean }
if (resp.status === 401 && !config?.__retried) {
const ok = await refreshOperatorToken()
if (ok) {
config.__retried = true
const token = getItem(StorageKeys.operatorToken)
if (token) config.headers['Authorization'] = `Bearer ${token}`
return operatorClient.request(config)
}
removeItem(StorageKeys.operatorToken)
removeItem(StorageKeys.refreshToken + '.operator')
}
throw new ApiError(body.code || 'SERVER_ERROR', body.message || '服务暂时不可用', body.requestId, resp.status)
},
)
export function opGet<T = any>(url: string, params?: Record<string, any>): Promise<T> {
return operatorClient.get(url, { params }) as Promise<T>
}
export function opPost<T = any>(url: string, data?: any, opts: { idempotencyKey?: string } = {}): Promise<T> {
const config: AxiosRequestConfig & { __idempotencyKey?: string } = { url, method: 'POST', data }
return operatorClient.request(config) as Promise<T>
}
export function opPut<T = any>(url: string, data?: any): Promise<T> {
return operatorClient.put(url, data) as Promise<T>
}
export default operatorClient
+74
View File
@@ -0,0 +1,74 @@
import { get, post, del } from '../client'
import { idempotencyKey } from '../idempotency'
/** 活动列表/详情/报名/我的活动/反馈/签到 */
export const activityService = {
getActivities(params: Record<string, any> = {}) {
return get('/activities', params)
},
getActivityDetail(activityId: string) {
return get(`/activities/${encodeURIComponent(activityId)}`)
},
register(activityId: string, payload: Record<string, any> = {}) {
return post(`/activities/${encodeURIComponent(activityId)}/register`, payload, {
idempotencyKey: idempotencyKey('activity-register', activityId),
})
},
cancelRegistration(activityId: string) {
return del(`/activities/${encodeURIComponent(activityId)}/register`)
},
getMyActivities(params: Record<string, any> = {}) {
// 后端实际路由为 GET /activities/my(原 /my-activities 无对应 handler → 404)
return get('/activities/my', params)
},
submitFeedback(activityId: string, payload: Record<string, any>) {
return post(`/activities/${encodeURIComponent(activityId)}/feedback`, payload, {
idempotencyKey: idempotencyKey('activity-feedback', activityId),
})
},
generateCheckinToken(activityId: string) {
return post(`/activities/${encodeURIComponent(activityId)}/checkin-token`, {}, {
idempotencyKey: idempotencyKey('checkin-token', activityId),
})
},
getMutualMatches(activityId: string) {
return get(`/activities/${encodeURIComponent(activityId)}/interests/mutual`)
},
checkMutualInterest(activityId: string, partnerId: string) {
return get(`/activities/${encodeURIComponent(activityId)}/interests/mutual/${encodeURIComponent(partnerId)}`)
},
}
/**
* 活动互选二阶段同意(INV-014/015):
* 互选结果 → 双方各自 confirm → 才创建 Match/沟通入口。
* 单向意向保密:结果接口只返回双相交集。
*/
export const activityMutualService = {
getResults(activityId: string) {
return get(`/activities/${encodeURIComponent(activityId)}/mutual/results`)
},
createConsent(activityId: string, partnerId: string) {
return post(`/activities/${encodeURIComponent(activityId)}/mutual/consent`, { partnerId }, {
idempotencyKey: idempotencyKey('mutual-consent', `${activityId}:${partnerId}`),
})
},
confirmConnection(activityId: string, partnerId: string) {
return post(`/activities/${encodeURIComponent(activityId)}/mutual/confirm`, { partnerId }, {
idempotencyKey: idempotencyKey('mutual-confirm', `${activityId}:${partnerId}`),
})
},
declineConnection(activityId: string, partnerId: string) {
return post(`/activities/${encodeURIComponent(activityId)}/mutual/decline`, { partnerId }, {
idempotencyKey: idempotencyKey('mutual-decline', `${activityId}:${partnerId}`),
})
},
getMyConsentStatus(activityId: string, partnerId: string) {
return get(`/activities/${encodeURIComponent(activityId)}/mutual/status/${encodeURIComponent(partnerId)}`)
},
confirmInterest(activityId: string, partnerId: string) {
return post(`/activities/${encodeURIComponent(activityId)}/interests/confirm`, { partnerId }, {
idempotencyKey: idempotencyKey('interest-confirm', `${activityId}:${partnerId}`),
})
},
}
+47
View File
@@ -0,0 +1,47 @@
import { get, post } from '../client'
import { setItem, removeItem, StorageKeys } from '@/utils/storage'
/** 用户端认证(对齐 miniapp/services/authService.js) */
export const authService = {
wechatLogin(payload: { code: string }) {
return post('/auth/wechat/login', payload, { idempotencyKey: payload.code })
},
bindPhone(payload: Record<string, any>) {
return post('/auth/phone/bind', payload, { idempotencyKey: payload.phoneCode || payload.encryptedData })
},
refresh(userId?: string | null, refreshToken?: string | null) {
return post('/auth/refresh', { userId, refreshToken })
},
logout() {
return post('/auth/logout', {}, { idempotencyKey: 'logout' })
},
}
/** 红娘/工作人员端认证(独立会话,Bearer 走 h5.operatorToken) */
export const operatorService = {
async login(username: string, password: string): Promise<any> {
const data = await post('/auth/operator/login', { username, password })
if (data?.accessToken) {
setItem(StorageKeys.operatorToken, data.accessToken)
if (data.refreshToken) setItem(StorageKeys.refreshToken + '.operator', data.refreshToken)
if (data.operatorId) setItem(StorageKeys.operatorId, data.operatorId)
if (data.name) setItem(StorageKeys.operatorName, data.name)
}
return data
},
refresh(operatorId?: string | null, refreshToken?: string | null) {
return post('/auth/operator/refresh', { operatorId, refreshToken })
},
logout() {
return post('/auth/operator/logout', {}).catch(() => {})
},
clearSession() {
removeItem(StorageKeys.operatorToken)
removeItem(StorageKeys.refreshToken + '.operator')
removeItem(StorageKeys.operatorId)
removeItem(StorageKeys.operatorName)
},
get token(): string | null {
return localStorage.getItem('h5.' + StorageKeys.operatorToken)
},
}
+14
View File
@@ -0,0 +1,14 @@
export { authService, operatorService } from './auth'
export { userService, profileService, verificationService, localEligibilityService, notificationService, serviceHistoryService } from './me'
export { recommendationService, matchService, safetyService, appealService } from './match'
export { activityService, activityMutualService } from './activity'
export {
serviceAppointmentService,
serviceStationService,
serviceProgramService,
contentService,
counselingService,
} from './service'
export { matchmakerService, operatorMatchmakerService, referralService, contactConsentService } from './matchmaker'
export { homeService, capabilityService, appConfigService } from './misc'
export { ApiError } from '../client'
+103
View File
@@ -0,0 +1,103 @@
import { get, post, del } from '../client'
import { idempotencyKey } from '../idempotency'
/** 推荐与搜索 */
export const recommendationService = {
getRecommendations(params: Record<string, any> = {}) {
return get('/recommendations', params)
},
getRecommendationDetail(userId: string) {
return get(`/recommendations/${encodeURIComponent(userId)}`)
},
discover(params: Record<string, any> = {}) {
return get('/discover', params)
},
getUserDetail(userId: string) {
return get(`/discover/users/${encodeURIComponent(userId)}`)
},
}
/** 关系与匹配(Like/Pass/Block/缘分三榜/Unmatch/成功牵手双确认) */
export const matchService = {
like(userId: string) {
return post('/likes', { targetUserId: userId }, { idempotencyKey: idempotencyKey('like', userId) })
},
unlike(userId: string) {
return del(`/likes/${encodeURIComponent(userId)}`)
},
pass(userId: string) {
return post('/passes', { targetUserId: userId }, { idempotencyKey: idempotencyKey('pass', userId) })
},
block(userId: string, payload: { reason?: string } = {}) {
return post('/blocks', { targetUserId: userId, reason: payload.reason }, { idempotencyKey: idempotencyKey('block', userId) })
},
unblock(userId: string) {
return del(`/blocks/${encodeURIComponent(userId)}`)
},
getMutualList(params: Record<string, any> = {}) {
return get('/relations/fate/mutual', params)
},
getLikedByMe(params: Record<string, any> = {}) {
return get('/relations/fate/liked', params)
},
getLikers(params: Record<string, any> = {}) {
return get('/relations/fate/likers', params)
},
getBlockedList(params: Record<string, any> = {}) {
return get('/blocks', params)
},
getMatches(params: Record<string, any> = {}) {
return get('/matches', params)
},
unmatch(matchId: string) {
return post(`/matches/${encodeURIComponent(matchId)}/unmatch`, {}, { idempotencyKey: idempotencyKey('unmatch', matchId) })
},
requestSuccess(matchId: string) {
return post(`/matches/${encodeURIComponent(matchId)}/success/request`, {}, { idempotencyKey: idempotencyKey('success-req', matchId) })
},
confirmSuccess(matchId: string) {
return post(`/matches/${encodeURIComponent(matchId)}/success/confirm`, {}, { idempotencyKey: idempotencyKey('success-confirm', matchId) })
},
declineSuccess(matchId: string) {
return post(`/matches/${encodeURIComponent(matchId)}/success/decline`, {}, { idempotencyKey: idempotencyKey('success-decline', matchId) })
},
}
/** 举报(不自动 Block) */
export const safetyService = {
submitReport(payload: { targetUserId: string; reportType: string; description: string; evidenceFileIds?: string[] }) {
// 后端真实路由 POST /risk/reports,DTO 字段为 reportedUserId / reasonType(原 /reports + targetUserId/reportType 会导致 404)
return post(
'/risk/reports',
{
reportedUserId: payload.targetUserId,
reasonType: payload.reportType,
description: payload.description,
evidenceFileIds: payload.evidenceFileIds,
},
{ idempotencyKey: idempotencyKey('report', `${payload.targetUserId}:${payload.reportType}`) },
)
},
getMyReports(params: Record<string, any> = {}) {
return get('/me/reports', params)
},
/** 举报详情(GET /risk/reports/{reportId},仅举报人本人可见):返回 { report, evidenceFileKeys, evidenceUrls } */
getReportDetail(reportId: string) {
return get(`/risk/reports/${encodeURIComponent(reportId)}`)
},
/** 举报证据直传凭证(POST /risk/reports/evidence/upload-token):返回 { fileKey, uploadUrl, expiresIn } */
requestEvidenceUploadToken() {
return post('/risk/reports/evidence/upload-token', {})
},
}
/** 申诉 */
export const appealService = {
submitAppeal(payload: Record<string, any>) {
const actionKey = payload.relatedActionId || 'no_action'
return post('/appeals', payload, { idempotencyKey: idempotencyKey('appeal', `${payload.appealType}:${actionKey}`) })
},
getMyAppeals(params: Record<string, any> = {}) {
return get('/me/appeals', params)
},
}
+127
View File
@@ -0,0 +1,127 @@
import { get, post, put } from '../client'
import { opGet, opPost } from '../operatorClient'
import { idempotencyKey } from '../idempotency'
/** 用户端红娘服务(申请/我的服务/详情/推荐) */
export const matchmakerService = {
applyService(payload: Record<string, any>) {
return post('/matchmaker/services', payload, { idempotencyKey: idempotencyKey('matchmaker-apply') })
},
getMyServices(params: Record<string, any> = {}) {
return get('/matchmaker/services', params)
},
async getServiceDetail(serviceId: string) {
// 用户端后端未注册 GET /matchmaker/services/{id}(仅列表接口)——
// 由列表按 id 派生,避免对不存在路由发起 404 请求。
const result: any = await get('/matchmaker/services', { limit: 50 })
const list: any[] = result?.items || result?.records || (Array.isArray(result) ? result : [])
return list.find((s: any) => (s.serviceId || s.id) === serviceId) ?? null
},
confirmService(serviceId: string) {
return post(`/matchmaker/services/${encodeURIComponent(serviceId)}/confirm`, {}, {
idempotencyKey: idempotencyKey('matchmaker-confirm', serviceId),
})
},
withdrawService(serviceId: string) {
return post(`/matchmaker/services/${encodeURIComponent(serviceId)}/withdraw`, {}, {
idempotencyKey: idempotencyKey('matchmaker-withdraw', serviceId),
})
},
getRecommendations(params: Record<string, any> = {}) {
return get('/matchmaker/recommendations', params)
},
markRecommendationViewed(recommendationId: string) {
return put(`/matchmaker/recommendations/${encodeURIComponent(recommendationId)}/viewed`)
},
acceptRecommendation(recommendationId: string) {
return post(`/matchmaker/recommendations/${encodeURIComponent(recommendationId)}/accept`, {}, {
idempotencyKey: idempotencyKey('mm-rec-accept', recommendationId),
})
},
passRecommendation(recommendationId: string) {
return post(`/matchmaker/recommendations/${encodeURIComponent(recommendationId)}/pass`, {}, {
idempotencyKey: idempotencyKey('mm-rec-pass', recommendationId),
})
},
}
/**
* 红娘工作台(Operator 会话)。
* 不变量:红娘只做推荐/跟进,**不可替用户 Like/Pass**(AGENTS 第 5 条)。
*/
export const operatorMatchmakerService = {
dashboard() {
return opGet('/operator/matchmaker/dashboard')
},
myAssignments() {
return opGet('/operator/matchmaker/assignments/my')
},
recommendations() {
return opGet('/operator/matchmaker/recommendations')
},
pending() {
return opGet('/operator/matchmaker/pending')
},
managementServices() {
return opGet('/operator/matchmaker/management/services')
},
serviceDetail(id: string) {
return opGet(`/operator/matchmaker/services/${encodeURIComponent(id)}`)
},
recommend(serviceId: string, payload: Record<string, any>) {
return opPost(`/operator/matchmaker/services/${encodeURIComponent(serviceId)}/recommend`, payload)
},
followup(serviceId: string, payload: Record<string, any>) {
return opPost(`/operator/matchmaker/services/${encodeURIComponent(serviceId)}/followup`, payload)
},
complete(serviceId: string) {
return opPost(`/operator/matchmaker/services/${encodeURIComponent(serviceId)}/complete`, {})
},
myContactAuthorizations() {
return opGet('/operator/matchmaker/contact-authorizations/my')
},
requestContactAuthorization(payload: Record<string, any>) {
return opPost('/operator/matchmaker/contact-authorizations', payload)
},
revokeContactAuthorization(id: string, reason: string) {
return opPost(`/operator/matchmaker/contact-authorizations/${encodeURIComponent(id)}/revoke`, { reason })
},
referralHistory() {
return opGet('/referrals/operator/history')
},
referToServiceStation(payload: Record<string, any>) {
return opPost('/referrals/service-station', payload)
},
referToActivity(payload: Record<string, any>) {
return opPost('/referrals/activity', payload)
},
}
/** 红娘转介(用户侧确认) */
export const referralService = {
getMyReferralHistory() {
return get('/referrals/my-history')
},
confirmReferral(targetId: string, targetType: 'service_station' | 'activity') {
return post(`/referrals/${encodeURIComponent(targetId)}/confirm`, { targetType }, {
idempotencyKey: idempotencyKey('referral-confirm', `${targetId}:${targetType}`),
})
},
}
/** 受控联系授权(用户单独明示同意,INV) */
export const contactConsentService = {
my() {
return get('/contact-authorizations/my')
},
consent(id: string) {
return post(`/contact-authorizations/${encodeURIComponent(id)}/consent`, {}, {
idempotencyKey: idempotencyKey('contact-consent', id),
})
},
decline(id: string) {
return post(`/contact-authorizations/${encodeURIComponent(id)}/decline`, {}, {
idempotencyKey: idempotencyKey('contact-decline', id),
})
},
}
+121
View File
@@ -0,0 +1,121 @@
import { get, post, put, patch, del } from '../client'
import { idempotencyKey } from '../idempotency'
/** 本人全局状态(GET /me/status) */
export const userService = {
getStatus() {
return get('/me/status')
},
}
/** 本人资料 / 择偶条件 / 照片 / 隐私 / 审核 / 关系状态(对齐 miniapp/services/profileService.js) */
export const profileService = {
getProfile() {
return get('/me/profile')
},
updateProfile(payload: Record<string, any>) {
return put('/me/profile', payload, { idempotencyKey: idempotencyKey('profile-put', payload?.userId || 'me') })
},
updateBasicProfile(payload: Record<string, any>) {
return patch('/me/profile/basic', payload, { idempotencyKey: idempotencyKey('profile-basic', 'me') })
},
getCompletion() {
return get('/me/profile/completion')
},
getPreferences() {
return get('/me/preferences')
},
updatePreferences(payload: Record<string, any>) {
return put('/me/preferences', payload, { idempotencyKey: idempotencyKey('preferences-put') })
},
getPhotos() {
return get('/me/photos')
},
deletePhoto(photoId: string) {
return del(`/me/photos/${encodeURIComponent(photoId)}`)
},
setMainPhoto(photoId: string) {
return put(`/me/photos/${encodeURIComponent(photoId)}/main`, {}, { idempotencyKey: idempotencyKey('photo-main', photoId) })
},
requestUploadToken(payload: { fileName: string; contentType?: string }) {
return post('/me/photos/upload-token', payload, { idempotencyKey: payload.fileName })
},
confirmPhoto(payload: { fileKey: string; photoType: string }) {
return post('/me/photos/confirm', payload, { idempotencyKey: idempotencyKey('photo-confirm', payload.fileKey) })
},
getPrivacySettings() {
return get('/me/privacy-settings')
},
updatePrivacySettings(payload: Record<string, any>) {
return put('/me/privacy-settings', payload, { idempotencyKey: idempotencyKey('privacy-put') })
},
getReviewStatus() {
return get('/me/profile/review-status')
},
submitReview() {
return post('/me/profile/submit-review', {}, { idempotencyKey: idempotencyKey('review-submit') })
},
getRelationshipStatus() {
return get('/me/relationship-status')
},
pauseSearching() {
return patch('/me/relationship-status', { status: 'paused' }, { idempotencyKey: idempotencyKey('rel-pause') })
},
resumeSearching() {
return patch('/me/relationship-status', { status: 'searching' }, { idempotencyKey: idempotencyKey('rel-resume') })
},
markGettingToKnow() {
return patch('/me/relationship-status', { status: 'getting_to_know' }, { idempotencyKey: idempotencyKey('rel-gtk') })
},
}
/** 实名认证 */
export const verificationService = {
submit(payload: { realName: string; idCardNumber: string; faceImageUrl?: string }) {
const tail = (payload.idCardNumber || '').slice(-4).toUpperCase()
return post('/verification/submit', payload, { idempotencyKey: idempotencyKey('verification-submit', tail) })
},
getStatus() {
return get('/verification/status')
},
}
/** 本地资格核验 */
export const localEligibilityService = {
getStatus() {
return get('/local-eligibility')
},
declare(payload: Record<string, any>) {
return post('/local-eligibility/declaration', payload)
},
uploadEvidence(payload: Record<string, any>) {
return post('/local-eligibility/evidences', payload)
},
submit() {
return post('/local-eligibility/submit', {}, { idempotencyKey: idempotencyKey('local-eligibility-submit') })
},
}
/** 通知(用户端不提供聊天,仅通知) */
export const notificationService = {
getNotifications(params: Record<string, any> = {}) {
return get('/notifications', params)
},
markNotificationRead(id: string) {
return put(`/notifications/${encodeURIComponent(id)}/read`)
},
markAllNotificationsRead() {
return put('/notifications/read-all')
},
getNotificationsUnreadCount() {
// 后端实际路由为 GET /notifications/unread(返回 {count})
return get('/notifications/unread')
},
}
/** 公益服务记录(我的服务时间线) */
export const serviceHistoryService = {
getMyServiceHistory() {
return get('/me/service-history')
},
}
+27
View File
@@ -0,0 +1,27 @@
import { get } from '../client'
/** 首页聚合(GET /home)—— 段序与 8 入口顺序由后端返回决定(AGENTS 第 14/15 条)。 */
export const homeService = {
getHome() {
return get('/home')
},
}
/** 能力开关(AGENTS 第 26 条:入口可见性只读服务端) */
export const capabilityService = {
getCapabilities() {
return get('/capabilities')
},
}
/** App 运行时配置(数量/阈值等,未确认参数一律配置化) */
export const appConfigService = {
async fetchAppConfig(fallback: Record<string, any> = {}) {
try {
const overrides = await get('/app/config')
return { ...fallback, ...(overrides || {}) }
} catch {
return fallback
}
},
}
+104
View File
@@ -0,0 +1,104 @@
import { get, post } from '../client'
import { idempotencyKey } from '../idempotency'
/** 服务预约 */
export const serviceAppointmentService = {
async getCurrentServiceLocation() {
try {
const home: any = await get('/home')
const location = home?.serviceLocation
if (!location?.id) return null
return { id: location.id, name: location.name || '已选服务站点' }
} catch {
return null
}
},
getMyAppointments() {
return get('/service-appointments')
},
getAppointmentDetail(id: string) {
return get(`/service-appointments/${encodeURIComponent(id)}`)
},
createAppointment(payload: Record<string, any>) {
return post('/service-appointments', {
serviceLocationId: payload.serviceLocationId,
serviceType: payload.serviceType,
preferredDate: payload.preferredDate,
preferredTimeSlot: payload.preferredTimeSlot,
userNote: payload.userNote,
})
},
cancelAppointment(id: string, reason?: string) {
return post(`/service-appointments/${encodeURIComponent(id)}/cancel`, { reason: reason || '' })
},
}
/** 善缘服务站 */
export const serviceStationService = {
async getStation() {
const home: any = await get('/home')
return home?.serviceLocation || null
},
async getVisibleAnnouncements(locationId: string) {
if (!locationId) return []
try {
const list = await get(`/service-locations/${encodeURIComponent(locationId)}/announcements`)
return Array.isArray(list) ? list : []
} catch {
return []
}
},
}
/** 专项公益服务项目 */
export const serviceProgramService = {
getPrograms(params: Record<string, any> = {}) {
return get('/service-programs', params)
},
getProgramDetail(id: string) {
return get(`/service-programs/${encodeURIComponent(id)}`)
},
applyToProgram(id: string) {
return post(`/service-programs/${encodeURIComponent(id)}/apply`, {}, { idempotencyKey: idempotencyKey('program-apply', id) })
},
getMyApplications() {
return get('/service-programs/my')
},
}
/** 公益内容(婚恋课堂 / 反诈) */
export const contentService = {
getCategories() {
return get('/content/categories')
},
getArticles(params: Record<string, any> = {}) {
return get('/content/articles', params)
},
getArticleDetail(id: string) {
return get(`/content/articles/${encodeURIComponent(id)}`)
},
}
/** 婚恋咨询 / 婚前辅导 */
export const counselingService = {
intro() {
return get('/counseling/services/intro')
},
apply(payload: Record<string, any>) {
return post('/counseling/services', payload, { idempotencyKey: idempotencyKey('counseling-apply', payload.serviceType) })
},
myServices(limit = 20) {
return get('/counseling/services', { limit })
},
detail(id: string) {
return get(`/counseling/services/${encodeURIComponent(id)}`)
},
cancel(id: string, reason?: string) {
return post(`/counseling/services/${encodeURIComponent(id)}/cancel`, { reason: reason || '' })
},
confirmCompanion(id: string) {
return post(`/counseling/services/${encodeURIComponent(id)}/companion-confirm`, {}, {
idempotencyKey: idempotencyKey('companion-confirm', id),
})
},
}
+52
View File
@@ -0,0 +1,52 @@
<template>
<div class="ui-avatar" :style="{ width: size + 'px', height: size + 'px', fontSize: size * 0.4 + 'px' }">
<img v-if="src" :src="src" alt="" class="ui-avatar__img" @error="onError" />
<span v-else class="ui-avatar__text">{{ initial }}</span>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
const props = withDefaults(defineProps<{ src?: string | null; name?: string | null; size?: number }>(), {
src: '',
name: '',
size: 48,
})
const broken = ref(false)
watch(
() => props.src,
() => {
broken.value = false
},
)
const initial = computed(() => (props.name ? props.name.slice(0, 1) : '缘'))
const src = computed(() => (props.src && !broken.value ? props.src : ''))
function onError() {
broken.value = true
}
</script>
<style scoped>
.ui-avatar {
border-radius: 50%;
overflow: hidden;
background: var(--mini-brand-gradient);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
flex: none;
}
.ui-avatar__img {
width: 100%;
height: 100%;
object-fit: cover;
}
.ui-avatar__text {
font-weight: 500;
}
</style>
+12
View File
@@ -0,0 +1,12 @@
<template>
<van-empty :image="image" :description="description">
<slot />
</van-empty>
</template>
<script setup lang="ts">
withDefaults(defineProps<{ description?: string; image?: string }>(), {
description: '暂无数据',
image: 'default',
})
</script>
+26
View File
@@ -0,0 +1,26 @@
<template>
<div class="ui-error">
<van-icon name="warning-o" size="40" color="#f0483e" />
<p class="ui-error__text">{{ message }}</p>
<van-button size="small" type="primary" @click="$emit('retry')">重试</van-button>
</div>
</template>
<script setup lang="ts">
withDefaults(defineProps<{ message?: string }>(), { message: '加载失败,请稍后重试' })
defineEmits<{ retry: [] }>()
</script>
<style scoped>
.ui-error {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 48px 16px;
}
.ui-error__text {
color: var(--mini-text-secondary);
margin: 0;
}
</style>
+18
View File
@@ -0,0 +1,18 @@
<template>
<div class="ui-loading">
<van-loading size="22" :vertical="vertical">{{ text }}</van-loading>
</div>
</template>
<script setup lang="ts">
withDefaults(defineProps<{ text?: string; vertical?: boolean }>(), { text: '加载中...', vertical: true })
</script>
<style scoped>
.ui-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 0;
}
</style>
@@ -0,0 +1,32 @@
<template>
<div class="ui-section-header">
<h3 class="ui-section-header__title">{{ title }}</h3>
<span v-if="moreText" class="ui-section-header__more" @click="$emit('more')">
{{ moreText }} <van-icon name="arrow" />
</span>
</div>
</template>
<script setup lang="ts">
defineProps<{ title: string; moreText?: string }>()
defineEmits<{ more: [] }>()
</script>
<style scoped>
.ui-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin: 16px 0 8px;
}
.ui-section-header__title {
font-size: 16px;
font-weight: 600;
color: var(--mini-text-strong);
margin: 0;
}
.ui-section-header__more {
font-size: 12px;
color: var(--mini-text-secondary);
}
</style>
+39
View File
@@ -0,0 +1,39 @@
<template>
<span class="ui-tag" :class="`ui-tag--${type}`"><slot /></span>
</template>
<script setup lang="ts">
withDefaults(defineProps<{ type?: 'primary' | 'success' | 'warning' | 'danger' | 'info' }>(), {
type: 'info',
})
</script>
<style scoped>
.ui-tag {
display: inline-block;
padding: 1px 8px;
border-radius: 10px;
font-size: 11px;
line-height: 18px;
}
.ui-tag--primary {
background: rgba(244, 0, 90, 0.08);
color: var(--mini-tabbar-active);
}
.ui-tag--success {
background: rgba(47, 179, 109, 0.12);
color: var(--mini-success);
}
.ui-tag--warning {
background: rgba(245, 166, 35, 0.14);
color: #b9770e;
}
.ui-tag--danger {
background: rgba(240, 72, 62, 0.12);
color: var(--mini-danger);
}
.ui-tag--info {
background: rgba(107, 112, 133, 0.1);
color: var(--mini-text-secondary);
}
</style>
+81
View File
@@ -0,0 +1,81 @@
<template>
<div class="ui-user-card" @click="$emit('tap', user)">
<UiAvatar :src="user.avatarUrl" :name="user.displayName" :size="56" />
<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>
</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>
</div>
<div v-if="user.recommendationReasons?.length" class="ui-user-card__reasons">
{{ user.recommendationReasons.slice(0, 3).join(' · ') }}
</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>
</div>
</div>
</template>
<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 },
)
defineEmits<{ tap: [any]; like: [any]; pass: [any] }>()
</script>
<style scoped>
.ui-user-card {
display: flex;
align-items: center;
gap: 12px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 12px;
margin-bottom: 10px;
box-shadow: var(--mini-shadow);
}
.ui-user-card__main {
flex: 1;
min-width: 0;
}
.ui-user-card__title {
display: flex;
align-items: center;
gap: 6px;
}
.ui-user-card__name {
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
}
.ui-user-card__meta {
display: flex;
gap: 10px;
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 4px;
}
.ui-user-card__reasons {
font-size: 12px;
color: var(--mini-tabbar-active);
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ui-user-card__actions {
display: flex;
flex-direction: column;
gap: 6px;
}
</style>
+56
View File
@@ -0,0 +1,56 @@
<template>
<div class="operator-layout">
<van-nav-bar
:title="title"
left-arrow
fixed
placeholder
@click-left="onBack"
>
<template #right>
<span class="operator-layout__logout" @click="onLogout">退出</span>
</template>
</van-nav-bar>
<div class="operator-layout__body">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { showConfirmDialog } from 'vant'
import { useOperatorStore } from '@/stores/operator'
withDefaults(defineProps<{ title?: string }>(), { title: '红娘工作台' })
const router = useRouter()
const operatorStore = useOperatorStore()
function onBack() {
router.replace('/operator/workbench')
}
async function onLogout() {
try {
await showConfirmDialog({ title: '退出登录', message: '确定要退出红娘工作台吗?' })
} catch {
return
}
operatorStore.logout()
router.replace('/operator/login')
}
</script>
<style scoped>
.operator-layout {
min-height: 100vh;
background: var(--mini-bg);
}
.operator-layout__body {
min-height: calc(100vh - 46px);
}
.operator-layout__logout {
color: var(--mini-text-secondary);
font-size: 13px;
}
</style>
+40
View File
@@ -0,0 +1,40 @@
<template>
<div class="page-layout">
<van-nav-bar
:title="title"
left-arrow
fixed
placeholder
@click-left="onBack"
>
<template v-if="$slots.right" #right>
<slot name="right" />
</template>
</van-nav-bar>
<div class="page-layout__body">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
withDefaults(defineProps<{ title?: string }>(), { title: '' })
const router = useRouter()
function onBack() {
if (window.history.length > 1) router.back()
else router.replace('/home')
}
</script>
<style scoped>
.page-layout {
min-height: 100vh;
background: var(--mini-bg);
}
.page-layout__body {
min-height: calc(100vh - 46px);
}
</style>
+35
View File
@@ -0,0 +1,35 @@
<template>
<div class="tab-layout">
<div class="tab-layout__body">
<router-view v-slot="{ Component }">
<keep-alive :include="cached">
<component :is="Component" />
</keep-alive>
</router-view>
</div>
<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="/activity" icon="flag-o">活动</van-tabbar-item>
<van-tabbar-item to="/mine" icon="user-o">我的</van-tabbar-item>
</van-tabbar>
</div>
</template>
<script setup lang="ts">
// 固定 4 项 TabBar,顺序与 miniapp/app.json 一致。
const cached = ['HomePage', 'DiscoverPage', 'ActivityPage', 'MinePage']
</script>
<style scoped>
.tab-layout {
min-height: 100vh;
background: var(--mini-bg);
}
.tab-layout__body {
padding-bottom: 60px;
}
.tab-layout__bar {
border-top: 1px solid var(--mini-border);
}
</style>
+35
View File
@@ -0,0 +1,35 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import Vant from 'vant'
import 'vant/lib/index.css'
import './theme/index.css'
import App from './App.vue'
import router from './router'
import { setOnSessionExpired, setOnMaintenance } from './api/client'
import { useAuthStore } from './stores/auth'
import { useUserStore } from './stores/user'
import { useAppStore } from './stores/app'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(Vant)
app.use(router)
// 会话失效:清空内存与本地登录态后回登录页(由 api client 的 401 分支触发)。
setOnSessionExpired(() => {
const authStore = useAuthStore(pinia)
const userStore = useUserStore(pinia)
authStore.clearSession()
userStore.userState = null
router.replace('/login')
})
// 维护态:统一提示。
setOnMaintenance((body) => {
useAppStore(pinia).setMaintenance(body)
})
app.mount('#app')
+19
View File
@@ -0,0 +1,19 @@
<template>
<div class="nf">
<van-empty image="error" description="页面不存在或已下线">
<van-button type="primary" round @click="$router.replace('/home')">返回首页</van-button>
</van-empty>
</div>
</template>
<script setup lang="ts"></script>
<style scoped>
.nf {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--mini-bg);
}
</style>
@@ -0,0 +1,116 @@
<template>
<PageLayout title="退出登录">
<div class="logout">
<div class="logout__banner">
<div class="logout__banner-icon">!</div>
<div class="logout__banner-title">确认退出登录</div>
</div>
<div class="logout__card">
<div class="logout__section-title">退出后:</div>
<ul class="logout__list">
<li>需要重新登录才能继续使用推荐与公益服务</li>
<li>已拉黑、已匹配等关系数据仍会保留</li>
<li>个人资料不会被删除,再次登录即可恢复</li>
</ul>
</div>
<van-button class="logout__btn" type="danger" block round :loading="submitting" @click="onConfirm">
退出登录
</van-button>
<p class="logout__hint">平台不会要求您打电话或邮寄身份证,退出全程在线完成</p>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { showConfirmDialog } from 'vant'
import { useAuthStore } from '@/stores/auth'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
const router = useRouter()
const authStore = useAuthStore()
const appStore = useAppStore()
const submitting = ref(false)
async function onConfirm() {
try {
await showConfirmDialog({ title: '退出登录', message: '确定要退出当前账号吗?' })
} catch {
return
}
submitting.value = true
try {
await authStore.logout()
appStore.toastSuccess('已退出登录')
router.replace('/login?clear=1')
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.logout {
padding: 16px;
}
.logout__banner {
display: flex;
align-items: center;
gap: 10px;
background: rgba(240, 72, 62, 0.08);
border: 1px solid rgba(240, 72, 62, 0.16);
border-radius: var(--mini-radius);
padding: 14px;
}
.logout__banner-icon {
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(240, 72, 62, 0.14);
color: var(--mini-danger);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
flex: none;
}
.logout__banner-title {
font-size: 15px;
font-weight: 600;
color: var(--mini-danger);
}
.logout__card {
margin-top: 14px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 14px 16px;
}
.logout__section-title {
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
}
.logout__list {
margin: 10px 0 0;
padding-left: 18px;
font-size: 13px;
line-height: 1.9;
color: var(--mini-text-secondary);
}
.logout__btn {
margin-top: 24px;
}
.logout__hint {
text-align: center;
font-size: 11px;
color: var(--mini-text-muted);
margin: 12px 0 0;
line-height: 1.6;
}
</style>
@@ -0,0 +1,128 @@
<template>
<PageLayout title="状态明细">
<div class="detail">
<UiLoading v-if="loading" />
<UiErrorState v-else-if="error" @retry="load" />
<template v-else>
<UiSectionHeader title="账号与实名" />
<van-cell-group inset>
<van-cell title="账号状态" :value="accountStatusText" />
<van-cell title="实名认证" :value="verified ? '已实名' : '未实名'" />
<van-cell title="手机号" :value="phoneBound ? '已绑定' : '未绑定'" />
</van-cell-group>
<UiSectionHeader title="资料与本地资格" />
<van-cell-group inset>
<van-cell title="资料状态" :value="reviewStatusText" />
<van-cell title="本地资格" :value="localEligibilityText" />
</van-cell-group>
<UiSectionHeader title="婚恋状态" />
<van-cell-group inset>
<van-cell title="关系状态" :value="relationshipText" />
<van-cell title="推荐开关" :value="recommendationEnabled ? '已开启' : '已关闭'" />
<van-cell title="正在了解的对象" :value="matchingWith ? (matchingWith.displayName || '了解中') : '无'" />
</van-cell-group>
<UiSectionHeader title="相关设置" />
<van-cell-group inset>
<van-cell title="隐私设置" is-link @click="router.push('/settings/privacy')" />
<van-cell title="关系状态" is-link @click="router.push('/settings/relationship')" />
<van-cell title="退出登录" is-link @click="router.push('/account/logout')" />
</van-cell-group>
</template>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { profileService, userService } from '@/api/services'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiErrorState from '@/components/UiErrorState.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
const router = useRouter()
const ACCOUNT_TEXT: Record<string, string> = {
normal: '正常',
banned: '已封禁',
suspended: '临时暂停',
limited: '功能限制',
}
const REVIEW_TEXT: Record<string, string> = {
not_submitted: '未提交',
pending_review: '审核中',
approved: '已审核',
need_edit: '需修改',
rejected: '未通过',
}
const RELATIONSHIP_TEXT: Record<string, string> = {
searching: '正在寻找',
getting_to_know: '正在了解',
paused: '已暂停寻找',
matched_successfully: '已成功牵手',
service_exited: '已退出服务',
}
const LOCAL_ELIGIBILITY_TEXT: Record<string, string> = {
not_declared: '未声明',
pending: '待审核',
verifying: '核验中',
verified: '已核验',
needs_evidence: '需补充佐证',
rejected: '未通过',
}
const loading = ref(true)
const error = ref(false)
const status = ref<any>(null)
const relationship = ref<any>(null)
const accountStatusText = computed(() => ACCOUNT_TEXT[status.value?.accountStatus] || status.value?.accountStatus || '正常')
const verified = computed(() => !!status.value?.verified)
const phoneBound = computed(() => !!status.value?.phoneBound)
const reviewStatusText = computed(() => REVIEW_TEXT[status.value?.reviewStatus || status.value?.profileReviewStatus] || '未提交')
const localEligibilityText = computed(
() => LOCAL_ELIGIBILITY_TEXT[status.value?.localEligibilityStatus] || status.value?.localEligibilityStatus || '未声明',
)
const relationshipText = computed(
() => RELATIONSHIP_TEXT[relationship.value?.status || status.value?.relationshipStatus] || '—',
)
const recommendationEnabled = computed(() => (relationship.value ? relationship.value.recommendationEnabled !== false : status.value?.recommendationEnabled !== false))
const matchingWith = computed(() => relationship.value?.matchingWith || null)
async function load() {
loading.value = true
error.value = false
try {
status.value = await userService.getStatus()
} catch {
error.value = true
loading.value = false
return
}
try {
relationship.value = await profileService.getRelationshipStatus()
} catch {
relationship.value = null
}
loading.value = false
}
onMounted(load)
</script>
<style scoped>
.detail {
padding-bottom: 24px;
}
.detail :deep(.ui-section-header) {
margin-left: 16px;
margin-right: 16px;
}
.detail :deep(.van-cell-group--inset) {
margin: 0 16px;
}
</style>
@@ -0,0 +1,164 @@
<template>
<PageLayout title="账号状态">
<div class="acc">
<UiLoading v-if="loading" />
<UiErrorState v-else-if="error" @retry="load" />
<template v-else>
<!-- 正常 -->
<div v-if="!hasRestriction" class="acc__normal">
<div class="acc__normal-icon">✓</div>
<div class="acc__normal-title">账号状态正常</div>
<p class="acc__normal-hint">您的账号目前无限制,可以正常使用所有功能。</p>
</div>
<!-- 受限 -->
<template v-else>
<div class="acc__banner">
<div class="acc__banner-icon">!</div>
<div class="acc__banner-title">账号受限</div>
<div class="acc__banner-status">{{ statusText }}</div>
</div>
<p class="acc__banner-desc">
如认为处置有误,可提交申诉,工作人员将在 1-3 个工作日内审核。
</p>
<van-button type="primary" block round @click="goAppeal">提交申诉</van-button>
</template>
<van-cell-group inset class="acc__links">
<van-cell title="状态明细" is-link @click="router.push('/account/status/detail')" />
<van-cell title="帮助中心" is-link @click="router.push('/help')" />
</van-cell-group>
<p class="acc__free">本平台为公益服务,所有功能永久免费</p>
</template>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { userService } from '@/api/services'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const router = useRouter()
const STATUS_TEXT: Record<string, string> = {
normal: '正常',
banned: '已封禁',
suspended: '临时暂停',
limited: '功能限制',
}
const loading = ref(true)
const error = ref(false)
const status = ref<any>(null)
const accountStatus = computed(() => status.value?.accountStatus || 'normal')
const hasRestriction = computed(() => accountStatus.value !== 'normal')
const statusText = computed(() => STATUS_TEXT[accountStatus.value] || accountStatus.value)
const appealType = computed(() => (accountStatus.value === 'banned' ? 'ban_appeal' : 'suspend_appeal'))
async function load() {
loading.value = true
error.value = false
try {
status.value = await userService.getStatus()
} catch {
error.value = true
} finally {
loading.value = false
}
}
function goAppeal() {
router.push({ path: '/appeal', query: { type: appealType.value } })
}
onMounted(load)
</script>
<style scoped>
.acc {
padding: 16px;
}
.acc__normal,
.acc__banner {
text-align: center;
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 24px 16px;
}
.acc__normal-icon {
width: 48px;
height: 48px;
margin: 0 auto 10px;
border-radius: 50%;
background: rgba(47, 179, 109, 0.14);
color: var(--mini-success);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
.acc__normal-title {
font-size: 16px;
font-weight: 600;
color: var(--mini-text-strong);
}
.acc__normal-hint {
margin: 8px 0 0;
font-size: 13px;
color: var(--mini-text-secondary);
line-height: 1.7;
}
.acc__banner-icon {
width: 48px;
height: 48px;
margin: 0 auto 10px;
border-radius: 50%;
background: rgba(240, 72, 62, 0.12);
color: var(--mini-danger);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: 700;
}
.acc__banner-title {
font-size: 16px;
font-weight: 600;
color: var(--mini-text-strong);
}
.acc__banner-status {
margin-top: 6px;
display: inline-block;
font-size: 12px;
padding: 2px 10px;
border-radius: 10px;
background: rgba(240, 72, 62, 0.12);
color: var(--mini-danger);
}
.acc__banner-desc {
margin: 12px 0 16px;
font-size: 13px;
color: var(--mini-text-secondary);
line-height: 1.7;
}
.acc__links {
margin-top: 16px;
}
.acc :deep(.van-cell-group--inset) {
margin-left: 0;
margin-right: 0;
}
.acc__free {
text-align: center;
font-size: 11px;
color: var(--mini-text-muted);
margin: 20px 0 0;
}
</style>
@@ -0,0 +1,181 @@
<template>
<PageLayout title="活动签到">
<UiLoading v-if="loading" text="生成签到码中…" />
<UiErrorState v-else-if="error" :message="errorMessage" @retry="load" />
<template v-else>
<div class="ck">
<div class="ck__card">
<div class="ck__activity">
<div class="ck__title">{{ token.activityTitle || '活动签到' }}</div>
<div v-if="token.locationName" class="ck__location">📍 {{ token.locationName }}</div>
</div>
<div class="ck__qr">
<div class="ck__qr-icon">▣</div>
<div class="ck__qr-hint">请向工作人员展示此签到码</div>
</div>
<div class="ck__token">
<div class="ck__token-label">签到码</div>
<div class="ck__token-value">{{ token.token }}</div>
</div>
<div class="ck__expire">有效期至 {{ formatDateTime(token.expiresAt) || '—' }}</div>
</div>
<van-button type="primary" block round @click="copyToken">复制签到码</van-button>
<van-button class="ck__refresh" plain block round :loading="refreshing" @click="refresh">
刷新签到码
</van-button>
</div>
</template>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { activityService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import { formatDateTime } from '@/utils/datetime'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const route = useRoute()
const appStore = useAppStore()
const activityId = computed(() => String(route.query.id || ''))
const token = ref<Record<string, any>>({})
const loading = ref(true)
const refreshing = ref(false)
const error = ref(false)
const errorMessage = ref('生成签到码失败')
async function fetchToken(isRefresh: boolean) {
if (isRefresh) refreshing.value = true
else loading.value = true
error.value = false
try {
if (!activityId.value) {
error.value = true
errorMessage.value = '缺少活动信息'
return
}
token.value = (await activityService.generateCheckinToken(activityId.value)) || {}
} catch (err: any) {
error.value = true
errorMessage.value = err?.message || '生成签到码失败'
if (isRefresh) appStore.toastError(errorMessage.value)
} finally {
loading.value = false
refreshing.value = false
}
}
function load() {
fetchToken(false)
}
function refresh() {
fetchToken(true)
}
async function copyToken() {
const value = token.value?.token
if (!value) return
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value)
} else {
const el = document.createElement('textarea')
el.value = value
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
}
appStore.toastSuccess('签到码已复制')
} catch {
appStore.toastError('复制失败,请长按手动复制')
}
}
onMounted(load)
</script>
<style scoped>
.ck {
padding: 16px 16px 24px;
}
.ck__card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 18px;
box-shadow: var(--mini-shadow);
margin-bottom: 16px;
}
.ck__activity {
text-align: center;
margin-bottom: 16px;
}
.ck__title {
font-size: 16px;
font-weight: 600;
color: var(--mini-text-strong);
}
.ck__location {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 4px;
}
.ck__qr {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 12px 0;
}
.ck__qr-icon {
width: 140px;
height: 140px;
border-radius: 12px;
background: var(--mini-bg);
display: flex;
align-items: center;
justify-content: center;
font-size: 64px;
color: var(--mini-text-muted);
}
.ck__qr-hint {
font-size: 12px;
color: var(--mini-text-secondary);
}
.ck__token {
text-align: center;
padding: 12px;
background: var(--mini-bg);
border-radius: var(--mini-radius);
}
.ck__token-label {
font-size: 12px;
color: var(--mini-text-secondary);
}
.ck__token-value {
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
word-break: break-all;
margin-top: 6px;
-webkit-user-select: all;
user-select: all;
}
.ck__expire {
text-align: center;
font-size: 12px;
color: var(--mini-text-muted);
margin-top: 12px;
}
.ck__refresh {
margin-top: 10px;
}
</style>
@@ -0,0 +1,385 @@
<template>
<PageLayout title="活动详情">
<UiLoading v-if="loading" text="正在加载活动…" />
<UiErrorState v-else-if="error" message="活动信息加载失败" @retry="load" />
<UiEmpty v-else-if="!activity" description="该活动不存在或已下线" />
<template v-else>
<div class="ad">
<div class="ad__hero">
<div class="ad__badge">{{ typeLabel }}</div>
<div class="ad__title">{{ activity.title }}</div>
<div class="ad__slogan">真实相识 · 公益免费 · 尊重意愿</div>
</div>
<div class="ad__block">
<UiSectionHeader title="活动信息" />
<div class="ad__card">
<div v-if="activity.activityId" class="ad__row">
<span class="ad__label">活动编号</span>
<span class="ad__value">{{ activity.activityId }}</span>
</div>
<div class="ad__feature">
<div class="ad__icon">时</div>
<div class="ad__feature-copy">
<div class="ad__feature-title">{{ formatDateTime(activity.startAt) || '时间待定' }}</div>
<div class="ad__feature-desc">请提前 15 分钟到场签到</div>
</div>
</div>
<div class="ad__feature">
<div class="ad__icon">址</div>
<div class="ad__feature-copy">
<div class="ad__feature-title">{{ activity.locationName || '地点待定' }}</div>
<div class="ad__feature-desc">
{{ activity.locationAddress || '报名通过后显示详细地址' }}
</div>
</div>
</div>
<div class="ad__feature">
<div class="ad__icon">额</div>
<div class="ad__feature-copy">
<div class="ad__feature-title">{{ quotaText }}</div>
<div class="ad__feature-desc">报名提交后需等待工作人员审核</div>
</div>
<UiTag type="primary">公益免费</UiTag>
</div>
</div>
</div>
<div v-if="activity.description" class="ad__block">
<UiSectionHeader title="活动介绍" />
<div class="ad__card">
<p class="ad__body">{{ activity.description }}</p>
</div>
</div>
<div class="ad__block">
<UiSectionHeader title="报名须知" />
<div class="ad__card">
<div v-for="(r, i) in requirements" :key="i" class="ad__rule">
<span class="ad__rule-index">{{ i + 1 }}</span>
<span class="ad__rule-text">{{ r }}</span>
</div>
<div class="ad__notice">
不同活动类型的资格要求可能不同,最终以本活动页面和审核结果为准。
</div>
</div>
</div>
<div class="ad__block">
<UiSectionHeader title="主办信息" />
<div class="ad__card">
<div class="ad__feature-title">善缘丹阳公益婚恋服务平台</div>
<div class="ad__feature-desc">主办单位关系不会自动授予用户资料访问权限。</div>
</div>
</div>
</div>
<div class="ad__action">
<template v-if="!isRegistered">
<van-button
type="primary"
block
round
:disabled="!!activity.isFull"
@click="goRegister"
>
{{ activity.isFull ? '名额已满' : '报名参加活动' }}
</van-button>
</template>
<template v-else>
<div class="ad__action-status">
<UiTag :type="statusTag.type">{{ statusTag.text }}</UiTag>
<span class="ad__action-hint">{{ checkinText }}</span>
</div>
<div class="ad__action-btns">
<van-button size="small" plain @click="goMyActivities">我的活动</van-button>
<van-button v-if="canCheckin" size="small" type="primary" @click="goCheckin">签到</van-button>
<van-button size="small" plain type="primary" @click="goFeedback">活动反馈</van-button>
<van-button
v-if="canCancel"
size="small"
plain
type="danger"
:loading="cancelling"
@click="onCancel"
>
取消报名
</van-button>
</div>
</template>
</div>
</template>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { activityService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import { showConfirmDialog } from 'vant'
import { formatDateTime } from '@/utils/datetime'
import PageLayout from '@/layouts/PageLayout.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
import UiTag from '@/components/UiTag.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const activityId = computed(() => String(route.query.id || ''))
const activity = ref<any>(null)
const loading = ref(true)
const error = ref(false)
const cancelling = ref(false)
const TYPE_LABELS: Record<string, string> = {
matchmaking: '公益联谊',
interest_social: '兴趣社交',
relationship_education: '婚恋课堂',
premarital_guidance: '婚前辅导',
marriage_custom_culture: '婚俗文化',
anti_fraud: '反诈宣传',
}
const typeLabel = computed(
() => TYPE_LABELS[activity.value?.activityType] || activity.value?.typeLabel || '公益活动',
)
const registrationStatus = computed(() => String(activity.value?.registrationStatus || ''))
const isRegistered = computed(
() => !!activity.value?.registered || ['pending', 'approved'].includes(registrationStatus.value),
)
const checkinStatus = computed(() => String(activity.value?.checkinStatus || ''))
const canCheckin = computed(
() => registrationStatus.value === 'approved' && checkinStatus.value !== 'checked_in',
)
const canCancel = computed(() => ['pending', 'approved'].includes(registrationStatus.value))
const statusTag = computed<{ type: 'primary' | 'success' | 'warning' | 'danger' | 'info'; text: string }>(
() => {
switch (registrationStatus.value) {
case 'pending':
return { type: 'warning', text: '报名待审核' }
case 'approved':
return { type: 'success', text: '报名已通过' }
case 'rejected':
return { type: 'danger', text: '报名未通过' }
case 'cancelled':
return { type: 'info', text: '已取消报名' }
default:
return { type: 'primary', text: '已报名' }
}
},
)
const checkinText = computed(() => {
if (checkinStatus.value === 'checked_in') return '已签到'
if (registrationStatus.value === 'approved') return '待签到'
return ''
})
const quotaText = computed(() => {
const a = activity.value || {}
if (typeof a.remainingSeats === 'number') return `剩余 ${a.remainingSeats} 个名额`
if (a.quotaText) return a.quotaText
return '名额以审核结果为准'
})
const requirements = computed(() => {
const a = activity.value || {}
const el = a.eligibility || {}
const list: string[] = ['年满 18 周岁的成年单身人士']
if (el.requiresIdentityVerified) list.push('需完成实名认证并通过资料审核')
if (el.requiresLocalEligibility) list.push('需符合丹阳本地资格(户籍 / 工作 / 居住)')
if (a.minAge || a.maxAge) {
list.push(`年龄在 ${a.minAge ?? '不限'}-${a.maxAge ?? '不限'} 周岁之间`)
}
if (a.needReview !== false) list.push('报名提交后需经工作人员审核通过')
return list
})
async function load() {
loading.value = true
error.value = false
try {
if (!activityId.value) {
activity.value = null
return
}
activity.value = await activityService.getActivityDetail(activityId.value)
} catch (err: any) {
error.value = true
appStore.toastError(err?.message || '加载活动失败')
} finally {
loading.value = false
}
}
function goRegister() {
if (!activityId.value) return
router.push({ path: '/activity/register', query: { id: activityId.value } })
}
function goMyActivities() {
router.push('/my/activities')
}
function goCheckin() {
router.push({ path: '/activity/checkin', query: { id: activityId.value } })
}
function goFeedback() {
router.push({ path: '/activity/feedback', query: { id: activityId.value } })
}
async function onCancel() {
if (cancelling.value) return
try {
await showConfirmDialog({ title: '取消报名', message: '确定要取消本次活动报名吗?' })
} catch {
return
}
cancelling.value = true
try {
await activityService.cancelRegistration(activityId.value)
appStore.toastSuccess('已取消报名')
await load()
} catch (err: any) {
appStore.toastError(err?.message || '取消失败')
} finally {
cancelling.value = false
}
}
onMounted(load)
</script>
<style scoped>
.ad {
padding: 0 16px 120px;
}
.ad__hero {
background: var(--mini-brand-gradient);
margin: 0 -16px;
padding: 20px 16px 22px;
color: #fff;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
}
.ad__badge {
display: inline-block;
font-size: 11px;
padding: 2px 10px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.2);
margin-bottom: 10px;
}
.ad__title {
font-size: 19px;
font-weight: 600;
line-height: 1.4;
}
.ad__slogan {
font-size: 12px;
opacity: 0.85;
margin-top: 6px;
}
.ad__block {
margin-top: 4px;
}
.ad__card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 14px;
box-shadow: var(--mini-shadow);
}
.ad__row {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--mini-text-secondary);
margin-bottom: 10px;
}
.ad__value {
color: var(--mini-text);
}
.ad__feature {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 0;
}
.ad__icon {
width: 34px;
height: 34px;
border-radius: 10px;
background: rgba(244, 0, 90, 0.08);
color: var(--mini-tabbar-active);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex: none;
}
.ad__feature-copy {
flex: 1;
min-width: 0;
}
.ad__feature-title {
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
}
.ad__feature-desc {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 2px;
}
.ad__body {
margin: 0;
font-size: 13px;
line-height: 1.7;
color: var(--mini-text);
}
.ad__rule {
display: flex;
gap: 8px;
font-size: 13px;
color: var(--mini-text);
padding: 4px 0;
}
.ad__rule-index {
color: var(--mini-tabbar-active);
font-weight: 600;
}
.ad__notice {
margin-top: 8px;
font-size: 11px;
color: var(--mini-text-muted);
line-height: 1.6;
}
.ad__action {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
background: var(--mini-surface);
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.06);
}
.ad__action-status {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.ad__action-hint {
font-size: 12px;
color: var(--mini-text-secondary);
}
.ad__action-btns {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
</style>
@@ -0,0 +1,277 @@
<template>
<PageLayout title="活动反馈">
<UiLoading v-if="loading" text="正在加载活动…" />
<UiErrorState v-else-if="error" @retry="load" />
<!-- 已提交 -->
<div v-else-if="submitted" class="fb__done">
<div class="fb__done-icon">✓</div>
<div class="fb__done-title">反馈已提交</div>
<div class="fb__done-hint">感谢您的参与!</div>
<div class="fb__done-actions">
<van-button type="primary" round size="small" @click="goMatches">查看活动缘分</van-button>
<van-button plain round size="small" @click="router.replace('/home')">返回首页</van-button>
</div>
</div>
<div v-else class="fb">
<div class="fb__header">
<div class="fb__title">活动反馈</div>
<div class="fb__name">{{ activity?.title || '本次公益活动' }}</div>
</div>
<div class="fb__section">
<div class="fb__section-title">整体体验</div>
<div class="fb__options">
<span
v-for="o in overallOptions"
:key="o.code"
class="fb__option"
:class="{ 'fb__option--active': overallExperience === o.code }"
@click="overallExperience = o.code"
>
{{ o.label }}
</span>
</div>
</div>
<div class="fb__section">
<div class="fb__section-title">是否愿意参加下一次活动</div>
<div class="fb__options">
<span
v-for="o in willingOptions"
:key="o.code"
class="fb__option"
:class="{ 'fb__option--active': willingNextTime === o.code }"
@click="willingNextTime = o.code"
>
{{ o.label }}
</span>
</div>
</div>
<div class="fb__section">
<div class="fb__section-title">是否在活动中认识了想进一步了解的人</div>
<div class="fb__options">
<span
class="fb__option"
:class="{ 'fb__option--active': metSomeone === true }"
@click="metSomeone = true"
>
是
</span>
<span
class="fb__option"
:class="{ 'fb__option--active': metSomeone === false }"
@click="metSomeone = false"
>
否
</span>
</div>
<div class="fb__hint">进一步了解意向将随反馈一并提交,双方互相表达后即可查看缘分。</div>
</div>
<van-button
class="fb__submit"
type="primary"
block
round
:loading="submitting"
:disabled="!overallExperience || !willingNextTime"
@click="onSubmit"
>
提交反馈
</van-button>
<van-button class="fb__skip" plain block @click="router.back()">跳过反馈</van-button>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { activityService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import { showConfirmDialog } from 'vant'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const activityId = computed(() => String(route.query.id || ''))
const activity = ref<any>(null)
const loading = ref(true)
const error = ref(false)
const overallExperience = ref('')
const willingNextTime = ref('')
const metSomeone = ref<boolean | null>(null)
const submitting = ref(false)
const submitted = ref(false)
const overallOptions = [
{ code: 'very_satisfied', label: '😊 非常满意' },
{ code: 'satisfied', label: '🙂 满意' },
{ code: 'normal', label: '😐 一般' },
{ code: 'dissatisfied', label: '😕 不满意' },
]
const willingOptions = [
{ code: 'willing', label: '💚 愿意' },
{ code: 'maybe', label: '🤔 看情况' },
{ code: 'not_now', label: '😶 暂不参加' },
]
async function load() {
loading.value = true
error.value = false
try {
if (!activityId.value) {
activity.value = null
return
}
activity.value = await activityService.getActivityDetail(activityId.value)
} catch (err: any) {
error.value = true
appStore.toastError(err?.message || '加载活动失败')
} finally {
loading.value = false
}
}
function goMatches() {
if (activityId.value) router.push({ path: '/activity/matches', query: { id: activityId.value } })
}
async function onSubmit() {
if (submitting.value) return
if (!overallExperience.value || !willingNextTime.value) {
appStore.toast('请填写必填项')
return
}
submitting.value = true
try {
const result: any = await activityService.submitFeedback(activityId.value, {
overallExperience: overallExperience.value,
willingNextTime: willingNextTime.value,
metSomeoneInteresting: metSomeone.value,
interestTargetUserIds: [],
})
submitted.value = true
const mutuals: any[] = result?.newMutuals || []
if (mutuals.length) {
await showConfirmDialog({
title: '双向匹配成功!',
message: `您与 ${mutuals.length} 位朋友互相表达了进一步了解意愿,可前往活动缘分查看。`,
confirmButtonText: '去查看',
showCancelButton: false,
}).catch(() => undefined)
goMatches()
}
} catch (err: any) {
appStore.toastError(err?.message || '提交失败')
} finally {
submitting.value = false
}
}
onMounted(load)
</script>
<style scoped>
.fb {
padding: 16px 16px 24px;
}
.fb__header {
margin-bottom: 12px;
}
.fb__title {
font-size: 17px;
font-weight: 600;
color: var(--mini-text-strong);
}
.fb__name {
font-size: 13px;
color: var(--mini-text-secondary);
margin-top: 4px;
}
.fb__section {
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 14px;
box-shadow: var(--mini-shadow);
margin-bottom: 12px;
}
.fb__section-title {
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
margin-bottom: 10px;
}
.fb__options {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.fb__option {
font-size: 13px;
padding: 7px 14px;
border-radius: 16px;
background: var(--mini-bg);
color: var(--mini-text);
border: 1px solid var(--mini-border);
}
.fb__option--active {
color: var(--mini-tabbar-active);
border-color: var(--mini-tabbar-active);
background: rgba(244, 0, 90, 0.06);
font-weight: 600;
}
.fb__hint {
font-size: 11px;
color: var(--mini-text-muted);
line-height: 1.6;
margin-top: 10px;
}
.fb__submit {
margin-top: 8px;
}
.fb__skip {
margin-top: 10px;
}
.fb__done {
display: flex;
flex-direction: column;
align-items: center;
padding: 60px 16px;
}
.fb__done-icon {
width: 64px;
height: 64px;
border-radius: 50%;
background: var(--mini-success);
color: #fff;
font-size: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.fb__done-title {
font-size: 17px;
font-weight: 600;
color: var(--mini-text-strong);
margin-top: 16px;
}
.fb__done-hint {
font-size: 13px;
color: var(--mini-text-secondary);
margin-top: 4px;
}
.fb__done-actions {
display: flex;
gap: 12px;
margin-top: 24px;
}
</style>
@@ -0,0 +1,140 @@
<template>
<PageLayout title="活动缘分">
<div class="am">
<div v-if="activityTitle" class="am__activity">{{ activityTitle }}</div>
<UiErrorState v-if="error" @retry="load" />
<UiLoading v-else-if="loading" />
<template v-else>
<div v-if="matches.length">
<div
v-for="m in matches"
:key="m.targetUserId"
class="am__card"
@click="goResult(m)"
>
<UiAvatar :src="avatarOf(m)" :name="m.name" :size="52" />
<div class="am__info">
<div class="am__name">{{ m.name || '新朋友' }}</div>
<div class="am__meta">
<span v-if="m.age">{{ m.age }}岁</span>
<span v-if="m.locationName">{{ m.locationName }}</span>
</div>
</div>
<div class="am__cta">
<span class="am__cta-text">查看缘分</span>
<van-icon name="arrow" color="#9aa0b5" />
</div>
</div>
</div>
<UiEmpty v-else description="暂无双向匹配">
<van-button type="primary" round size="small" @click="router.back()">返回</van-button>
</UiEmpty>
</template>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { activityService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiAvatar from '@/components/UiAvatar.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const activityId = computed(() => String(route.query.id || ''))
const matches = ref<any[]>([])
const activityTitle = ref('')
const loading = ref(true)
const error = ref(false)
function avatarOf(m: any): string {
const raw = m.avatarUrl || m.fileKey
return typeof raw === 'string' && raw.includes('://') ? raw : ''
}
async function load() {
loading.value = true
error.value = false
try {
if (!activityId.value) {
matches.value = []
return
}
const [result, detail]: any[] = await Promise.all([
activityService.getMutualMatches(activityId.value),
activityService.getActivityDetail(activityId.value).catch(() => null),
])
matches.value = result?.items || result?.records || (Array.isArray(result) ? result : [])
activityTitle.value = detail?.title || ''
} catch (err: any) {
error.value = true
appStore.toastError(err?.message || '加载活动缘分失败')
} finally {
loading.value = false
}
}
function goResult(m: any) {
if (!m?.targetUserId) return
router.push({
path: '/activity/mutual/result',
query: { id: activityId.value, partnerId: m.targetUserId },
})
}
onMounted(load)
</script>
<style scoped>
.am {
padding: 12px 16px 16px;
}
.am__activity {
font-size: 13px;
color: var(--mini-text-secondary);
margin-bottom: 10px;
}
.am__card {
display: flex;
align-items: center;
gap: 12px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 12px;
margin-bottom: 10px;
box-shadow: var(--mini-shadow);
}
.am__info {
flex: 1;
min-width: 0;
}
.am__name {
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
}
.am__meta {
display: flex;
gap: 10px;
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 4px;
}
.am__cta {
display: flex;
align-items: center;
gap: 2px;
font-size: 12px;
color: var(--mini-tabbar-active);
flex: none;
}
</style>
@@ -0,0 +1,309 @@
<template>
<PageLayout title="活动互选">
<div class="mr">
<UiErrorState v-if="error" @retry="load" />
<UiLoading v-else-if="loading" />
<!-- 单个互选对象:确认 / 等待 / 已确认 -->
<template v-else-if="hasPartner">
<div class="mr__partner">
<UiAvatar :src="partnerAvatar" :name="partnerName" :size="60" />
<div class="mr__partner-name">{{ partnerName }}</div>
<div class="mr__partner-hint">活动互选一致,双方确认后才可开启沟通</div>
</div>
<UiEmpty v-if="!status.exists && !statusLoaded" description="未找到该活动的互选记录" />
<div v-if="bothConfirmed" class="mr__status mr__status--success">
<div class="mr__status-icon mr__status-icon--success">✓</div>
<div class="mr__status-title">双方已确认</div>
<div class="mr__status-hint">
{{ hasActiveMatch ? '你们已有沟通入口,可直接进入缘分中心查看' : '沟通入口已开启,可前往缘分中心查看' }}
</div>
<van-button type="primary" round @click="goFate">
{{ hasActiveMatch ? '查看已有沟通' : '查看沟通' }}
</van-button>
</div>
<div v-else-if="partnerDeclined" class="mr__status">
<div class="mr__status-icon">💤</div>
<div class="mr__status-title">对方暂不联系</div>
<div class="mr__status-hint">对方已选择暂不开通沟通,您可以稍后再看看其他缘分。</div>
<van-button round plain @click="router.back()">返回</van-button>
</div>
<div v-else-if="myAccepted" class="mr__status">
<div class="mr__status-icon">⏳</div>
<div class="mr__status-title">已发送邀请</div>
<div class="mr__status-hint">对方确认后将收到平台通知</div>
<div class="mr__status-actions">
<van-button type="primary" round @click="goWaiting">查看等待状态</van-button>
<van-button round plain type="danger" :loading="acting === 'decline'" @click="onDecline">
暂不联系
</van-button>
</div>
</div>
<div v-else class="mr__status">
<div class="mr__status-icon">💬</div>
<div class="mr__status-title">向对方表达兴趣</div>
<div class="mr__status-hint">双方确认后才会开启沟通并收到平台通知</div>
<div class="mr__status-actions">
<van-button type="primary" round :loading="acting === 'confirm'" @click="onConfirm">
确认开启沟通
</van-button>
<van-button round plain @click="onDecline" :loading="acting === 'decline'">
暂不联系
</van-button>
</div>
</div>
</template>
<!-- 互选结果列表 -->
<template v-else>
<div v-if="results.length">
<div
v-for="r in results"
:key="r.partnerId"
class="mr__card"
@click="openPartner(r)"
>
<UiAvatar :src="avatarOf(r)" :name="r.partnerName" :size="48" />
<div class="mr__card-main">
<div class="mr__card-name">{{ r.partnerName || '新朋友' }}</div>
<UiTag :type="r.bothConfirmed ? 'success' : 'warning'">
{{ r.bothConfirmed ? '双方已确认' : '等待确认' }}
</UiTag>
</div>
<van-icon name="arrow" color="#9aa0b5" />
</div>
</div>
<UiEmpty v-else description="暂无互选结果">
<span class="mr__empty-hint">参加活动并向感兴趣的人表达意向后,双方一致即可在此查看</span>
</UiEmpty>
</template>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { activityMutualService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiAvatar from '@/components/UiAvatar.vue'
import UiTag from '@/components/UiTag.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const activityId = computed(() => String(route.query.id || ''))
const partnerId = computed(() => String(route.query.partnerId || ''))
const hasPartner = computed(() => !!partnerId.value)
const loading = ref(true)
const error = ref(false)
const results = ref<any[]>([])
const status = ref<any>({ exists: false })
const statusLoaded = ref(false)
const partnerName = ref('新朋友')
const partnerAvatar = ref('')
const hasActiveMatch = ref(false)
const acting = ref('')
const myAccepted = computed(() => status.value?.myStatus === 'accepted')
const bothConfirmed = computed(() => !!status.value?.bothConfirmed)
const partnerDeclined = computed(() => status.value?.partnerStatus === 'declined')
function avatarOf(r: any): string {
const raw = r.partnerAvatar || r.partnerAvatarFileId
return typeof raw === 'string' && raw.includes('://') ? raw : ''
}
async function load() {
loading.value = true
error.value = false
try {
if (!activityId.value) {
results.value = []
return
}
if (hasPartner.value) {
await loadPartner()
} else {
const result: any = await activityMutualService.getResults(activityId.value)
results.value = result?.items || result?.records || (Array.isArray(result) ? result : [])
}
} catch (err: any) {
error.value = true
appStore.toastError(err?.message || '加载互选结果失败')
} finally {
loading.value = false
}
}
async function loadPartner() {
const [statusRes, listRes]: any[] = await Promise.all([
activityMutualService.getMyConsentStatus(activityId.value, partnerId.value).catch(() => null),
activityMutualService.getResults(activityId.value).catch(() => null),
])
status.value = statusRes || { exists: false }
statusLoaded.value = true
const list: any[] = listRes?.items || listRes?.records || (Array.isArray(listRes) ? listRes : [])
const me = list.find((x) => (x.partnerId || x.targetUserId) === partnerId.value)
if (me) {
partnerName.value = me.partnerName || me.name || '新朋友'
partnerAvatar.value = avatarOf({ partnerAvatar: me.partnerAvatar, partnerAvatarFileId: me.partnerAvatarFileId })
hasActiveMatch.value = !!me.hasActiveMatch
}
}
function openPartner(r: any) {
const pid = r.partnerId || r.targetUserId
if (!pid) return
router.push({ path: '/activity/mutual/result', query: { id: activityId.value, partnerId: pid } })
}
function goWaiting() {
router.push({ path: '/activity/mutual/waiting', query: { id: activityId.value, partnerId: partnerId.value } })
}
function goFate() {
router.push('/fate-center')
}
async function onConfirm() {
if (acting.value) return
acting.value = 'confirm'
try {
// 二阶段同意(INV-014/015):先确保同意记录存在,再提交「我方确认」。
if (!status.value?.exists) {
await activityMutualService.createConsent(activityId.value, partnerId.value)
}
const result: any = await activityMutualService.confirmConnection(activityId.value, partnerId.value)
status.value = { ...(status.value || {}), exists: true, ...result }
if (result?.bothConfirmed) {
hasActiveMatch.value = !!result?.hasActiveMatch
appStore.toastSuccess('双方已确认,可查看沟通')
} else {
appStore.toastSuccess('已发送邀请,等待对方确认')
goWaiting()
}
} catch (err: any) {
appStore.toastError(err?.message || '操作失败')
} finally {
acting.value = ''
}
}
async function onDecline() {
if (acting.value) return
acting.value = 'decline'
try {
await activityMutualService.declineConnection(activityId.value, partnerId.value)
appStore.toastSuccess('已选择暂不联系')
router.back()
} catch (err: any) {
appStore.toastError(err?.message || '操作失败')
} finally {
acting.value = ''
}
}
onMounted(load)
</script>
<style scoped>
.mr {
padding: 12px 16px 16px;
}
.mr__partner {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 24px 0 8px;
}
.mr__partner-name {
font-size: 17px;
font-weight: 600;
color: var(--mini-text-strong);
}
.mr__partner-hint {
font-size: 12px;
color: var(--mini-text-secondary);
}
.mr__status {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 24px 16px;
margin-top: 12px;
box-shadow: var(--mini-shadow);
text-align: center;
}
.mr__status--success {
background: rgba(47, 179, 109, 0.08);
}
.mr__status-icon {
font-size: 40px;
}
.mr__status-icon--success {
width: 52px;
height: 52px;
border-radius: 50%;
background: var(--mini-success);
color: #fff;
font-size: 26px;
display: flex;
align-items: center;
justify-content: center;
}
.mr__status-title {
font-size: 16px;
font-weight: 600;
color: var(--mini-text-strong);
}
.mr__status-hint {
font-size: 12px;
color: var(--mini-text-secondary);
line-height: 1.6;
}
.mr__status-actions {
display: flex;
gap: 12px;
margin-top: 6px;
}
.mr__card {
display: flex;
align-items: center;
gap: 12px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 12px;
margin-bottom: 10px;
box-shadow: var(--mini-shadow);
}
.mr__card-main {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.mr__card-name {
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
}
.mr__empty-hint {
font-size: 12px;
color: var(--mini-text-muted);
}
</style>
@@ -0,0 +1,229 @@
<template>
<PageLayout title="等待确认">
<div class="mw">
<UiErrorState v-if="error" @retry="load" />
<UiLoading v-else-if="loading" />
<UiEmpty v-else-if="!status.exists" description="未找到该活动的互选记录" />
<template v-else>
<div class="mw__partner">
<UiAvatar :src="partnerAvatar" :name="partnerName" :size="60" />
<div class="mw__partner-name">{{ partnerName }}</div>
</div>
<div v-if="bothConfirmed" class="mw__panel mw__panel--success">
<div class="mw__icon mw__icon--success">✓</div>
<div class="mw__title">双方已确认!</div>
<div class="mw__hint">
{{ hasActiveMatch ? '你们已有沟通入口,可前往缘分中心查看' : '沟通入口已开启,可前往缘分中心查看' }}
</div>
<van-button type="primary" round @click="goFate">
{{ hasActiveMatch ? '查看已有沟通' : '查看沟通' }}
</van-button>
</div>
<div v-else-if="partnerDeclined" class="mw__panel">
<div class="mw__icon">💤</div>
<div class="mw__title">对方暂不联系</div>
<div class="mw__hint">对方已选择暂不开通沟通,您可以稍后再看看其他缘分。</div>
<van-button round plain @click="router.back()">返回</van-button>
</div>
<div v-else class="mw__panel">
<div class="mw__icon">⏳</div>
<div class="mw__title">已发送邀请,等待对方确认</div>
<div class="mw__hint">对方确认后将收到平台通知;本页面会自动刷新状态</div>
<div class="mw__actions">
<van-button type="primary" round :loading="refreshing" @click="refresh">
刷新状态
</van-button>
<van-button round plain type="danger" :loading="declining" @click="onDecline">
暂不联系
</van-button>
</div>
</div>
</template>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { activityMutualService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiAvatar from '@/components/UiAvatar.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const POLL_MS = 10000
const activityId = computed(() => String(route.query.id || ''))
const partnerId = computed(() => String(route.query.partnerId || ''))
const loading = ref(true)
const refreshing = ref(false)
const declining = ref(false)
const error = ref(false)
const status = ref<any>({ exists: false })
const partnerName = ref('新朋友')
const partnerAvatar = ref('')
const hasActiveMatch = ref(false)
let timer: ReturnType<typeof setInterval> | null = null
const bothConfirmed = computed(() => !!status.value?.bothConfirmed)
const partnerDeclined = computed(() => status.value?.partnerStatus === 'declined')
async function fetchStatus(isRefresh: boolean) {
if (isRefresh) refreshing.value = true
else loading.value = true
error.value = false
try {
if (!activityId.value || !partnerId.value) {
status.value = { exists: false }
return
}
const res: any = await activityMutualService.getMyConsentStatus(activityId.value, partnerId.value)
status.value = res || { exists: false }
// 对方确认后拉取一次结果,用于复用已有沟通(INV-019),并停止轮询
if (res?.bothConfirmed) {
await loadResult()
stopPoll()
}
} catch (err: any) {
error.value = true
if (isRefresh) appStore.toastError(err?.message || '刷新失败')
} finally {
loading.value = false
refreshing.value = false
}
}
async function loadResult() {
try {
const list: any = await activityMutualService.getResults(activityId.value)
const arr: any[] = list?.items || list?.records || (Array.isArray(list) ? list : [])
const me = arr.find((x) => (x.partnerId || x.targetUserId) === partnerId.value)
if (me) {
partnerName.value = me.partnerName || me.name || partnerName.value
hasActiveMatch.value = !!me.hasActiveMatch
}
} catch {
/* 结果信息可选,不影响等待状态 */
}
}
function startPoll() {
if (timer) return
timer = setInterval(() => {
if (bothConfirmed.value) return
fetchStatus(true)
}, POLL_MS)
}
function stopPoll() {
if (timer) {
clearInterval(timer)
timer = null
}
}
function load() {
fetchStatus(false)
}
function refresh() {
fetchStatus(true)
}
async function onDecline() {
if (declining.value) return
declining.value = true
try {
await activityMutualService.declineConnection(activityId.value, partnerId.value)
appStore.toastSuccess('已选择暂不联系')
router.back()
} catch (err: any) {
appStore.toastError(err?.message || '操作失败')
} finally {
declining.value = false
}
}
function goFate() {
router.push('/fate-center')
}
onMounted(() => {
load()
startPoll()
})
onUnmounted(stopPoll)
</script>
<style scoped>
.mw {
padding: 12px 16px 16px;
}
.mw__partner {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 28px 0 12px;
}
.mw__partner-name {
font-size: 17px;
font-weight: 600;
color: var(--mini-text-strong);
}
.mw__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 28px 16px;
box-shadow: var(--mini-shadow);
text-align: center;
}
.mw__panel--success {
background: rgba(47, 179, 109, 0.08);
}
.mw__icon {
font-size: 44px;
}
.mw__icon--success {
width: 52px;
height: 52px;
border-radius: 50%;
background: var(--mini-success);
color: #fff;
font-size: 26px;
display: flex;
align-items: center;
justify-content: center;
}
.mw__title {
font-size: 16px;
font-weight: 600;
color: var(--mini-text-strong);
}
.mw__hint {
font-size: 12px;
color: var(--mini-text-secondary);
line-height: 1.6;
}
.mw__actions {
display: flex;
gap: 12px;
margin-top: 6px;
}
</style>
+235
View File
@@ -0,0 +1,235 @@
<template>
<div class="act">
<div class="act__hero">
<div class="act__hero-top">
<div class="act__title">公益活动</div>
<span class="act__my" @click="router.push('/my/activities')">
<van-icon name="calendar-o" /> 我的活动
</span>
</div>
<div class="act__cats">
<span
v-for="c in categories"
:key="c.code"
class="act__cat"
:class="{ 'act__cat--active': activeCategory === c.code }"
@click="activeCategory = c.code"
>
{{ c.label }}
</span>
</div>
</div>
<div class="act__heading">
<div class="act__heading-title">丹阳公益活动</div>
<div class="act__heading-sub">丰富活动,连接更多可能</div>
</div>
<div class="act__body">
<UiErrorState v-if="error" @retry="load" />
<UiLoading v-else-if="firstLoading" />
<template v-else>
<div v-if="filtered.length">
<div
v-for="a in filtered"
:key="a.id || a.activityId"
class="act__card"
@click="goDetail(a)"
>
<div class="act__cover">{{ (a.title || '活').slice(0, 1) }}</div>
<div class="act__info">
<div class="act__cat-tag">{{ categoryLabel(a) }}</div>
<div class="act__card-title">{{ a.title }}</div>
<div class="act__meta">
<span>{{ formatDateTime(a.activityStartAt || a.startAt) }}</span>
<span v-if="a.locationName || a.location">{{ a.locationName || a.location }}</span>
</div>
</div>
<van-icon name="arrow" color="#9aa0b5" />
</div>
</div>
<UiEmpty v-else description="暂无进行中的活动" />
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { activityService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import { formatDateTime } from '@/utils/datetime'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const router = useRouter()
const appStore = useAppStore()
const categories = [
{ code: 'all', label: '全部' },
{ code: 'matchmaking', label: '公益联谊' },
{ code: 'interest_social', label: '兴趣社交' },
{ code: 'relationship_education', label: '婚恋课堂' },
{ code: 'premarital_guidance', label: '婚前辅导' },
{ code: 'marriage_custom_culture', label: '婚俗文化' },
{ code: 'anti_fraud', label: '反诈宣传' },
]
const activities = ref<any[]>([])
const activeCategory = ref('all')
const firstLoading = ref(true)
const error = ref(false)
const filtered = computed(() => {
if (activeCategory.value === 'all') return activities.value
return activities.value.filter((a) => (a.activityType || a.category) === activeCategory.value)
})
function categoryLabel(a: any) {
const code = a.activityType || a.category
return categories.find((c) => c.code === code)?.label || '公益活动'
}
async function load() {
firstLoading.value = true
error.value = false
try {
const result: any = await activityService.getActivities({ pageSize: 50 })
activities.value = result?.items || result?.records || result || []
} catch (err: any) {
error.value = true
appStore.toastError(err?.message || '加载活动失败')
} finally {
firstLoading.value = false
}
}
function goDetail(a: any) {
const id = a.id || a.activityId
if (id) router.push({ path: '/activity/detail', query: { id } })
}
onMounted(load)
</script>
<style scoped>
.act {
min-height: 100vh;
background: var(--mini-bg);
padding-bottom: 16px;
}
.act__hero {
background: var(--mini-brand-gradient);
padding: 18px 16px 16px;
color: #fff;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
}
.act__hero-top {
display: flex;
align-items: center;
justify-content: space-between;
}
.act__title {
font-size: 18px;
font-weight: 600;
}
.act__my {
font-size: 13px;
background: rgba(255, 255, 255, 0.16);
padding: 6px 12px;
border-radius: 16px;
display: flex;
align-items: center;
gap: 4px;
}
.act__cats {
display: flex;
gap: 8px;
overflow-x: auto;
margin-top: 14px;
padding-bottom: 2px;
}
.act__cat {
flex: none;
font-size: 12px;
padding: 5px 12px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.14);
color: #fff;
}
.act__cat--active {
background: #fff;
color: var(--mini-tabbar-active);
font-weight: 600;
}
.act__heading {
padding: 16px 16px 4px;
}
.act__heading-title {
font-size: 16px;
font-weight: 600;
color: var(--mini-text-strong);
}
.act__heading-sub {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 2px;
}
.act__body {
padding: 8px 16px 0;
}
.act__card {
display: flex;
align-items: center;
gap: 12px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 12px;
margin-bottom: 10px;
box-shadow: var(--mini-shadow);
}
.act__cover {
width: 56px;
height: 56px;
border-radius: 12px;
background: linear-gradient(135deg, #8a6cff 0%, #6244e0 100%);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
font-weight: 600;
flex: none;
}
.act__info {
flex: 1;
min-width: 0;
}
.act__cat-tag {
display: inline-block;
font-size: 10px;
color: var(--mini-tabbar-active);
background: rgba(244, 0, 90, 0.08);
padding: 1px 8px;
border-radius: 8px;
}
.act__card-title {
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.act__meta {
display: flex;
gap: 12px;
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 4px;
}
</style>
@@ -0,0 +1,147 @@
<template>
<PageLayout title="活动报名">
<UiLoading v-if="loading" text="正在加载活动…" />
<UiErrorState v-else-if="error" @retry="load" />
<UiEmpty v-else-if="!activity" description="该活动不存在或已下线" />
<template v-else>
<div class="reg">
<div class="reg__card">
<div class="reg__title">{{ activity.title }}</div>
<div class="reg__meta">
<span>{{ formatDateTime(activity.startAt) || '时间待定' }}</span>
<span v-if="activity.locationName">{{ activity.locationName }}</span>
</div>
</div>
<div class="reg__card">
<div class="reg__label">备注 / 留言(选填)</div>
<van-field
v-model="remark"
type="textarea"
rows="3"
maxlength="200"
show-word-limit
placeholder="可填写方便工作人员联系的信息或特殊说明"
autosize
/>
</div>
<div class="reg__notice">
报名提交后需等待工作人员审核;不同活动类型的资格要求以活动页面和审核结果为准。
</div>
<van-button
class="reg__submit"
type="primary"
block
round
:loading="submitting"
@click="onSubmit"
>
提交报名
</van-button>
</div>
</template>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { activityService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import { formatDateTime } from '@/utils/datetime'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const activityId = computed(() => String(route.query.id || ''))
const activity = ref<any>(null)
const loading = ref(true)
const error = ref(false)
const remark = ref('')
const submitting = ref(false)
async function load() {
loading.value = true
error.value = false
try {
if (!activityId.value) {
activity.value = null
return
}
activity.value = await activityService.getActivityDetail(activityId.value)
} catch (err: any) {
error.value = true
appStore.toastError(err?.message || '加载活动失败')
} finally {
loading.value = false
}
}
async function onSubmit() {
if (submitting.value || !activityId.value) return
submitting.value = true
try {
const payload = remark.value.trim() ? { remark: remark.value.trim() } : {}
await activityService.register(activityId.value, payload)
appStore.toastSuccess('报名已提交,请等待审核')
router.replace({ path: '/my/activities' })
} catch (err: any) {
appStore.toastError(err?.message || '报名失败')
} finally {
submitting.value = false
}
}
onMounted(load)
</script>
<style scoped>
.reg {
padding: 16px 16px 24px;
}
.reg__card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 14px;
box-shadow: var(--mini-shadow);
margin-bottom: 12px;
}
.reg__title {
font-size: 16px;
font-weight: 600;
color: var(--mini-text-strong);
}
.reg__meta {
display: flex;
gap: 12px;
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 6px;
}
.reg__label {
font-size: 13px;
color: var(--mini-text-strong);
margin-bottom: 8px;
}
.reg__card :deep(.van-field) {
background: var(--mini-bg);
border-radius: 8px;
padding: 10px;
}
.reg__notice {
font-size: 12px;
color: var(--mini-text-muted);
line-height: 1.6;
margin: 4px 4px 16px;
}
.reg__submit {
margin-top: 8px;
}
</style>
@@ -0,0 +1,245 @@
<template>
<PageLayout title="我的活动">
<div class="ma">
<div class="ma__filters">
<span
v-for="f in filters"
:key="f.code"
class="ma__chip"
:class="{ 'ma__chip--active': activeFilter === f.code }"
@click="activeFilter = f.code"
>
{{ f.label }}
</span>
</div>
<UiErrorState v-if="error" @retry="load" />
<UiLoading v-else-if="loading" />
<template v-else>
<div v-if="filtered.length">
<div
v-for="a in filtered"
:key="a.activityId"
class="ma__card"
@click="goDetail(a)"
>
<div class="ma__top">
<div class="ma__title">{{ a.title }}</div>
<UiTag :type="statusTag(a.status).type">{{ statusTag(a.status).text }}</UiTag>
</div>
<div class="ma__meta">
<span>{{ formatDateTime(a.startAt) || '时间待定' }}</span>
<span v-if="a.locationName">{{ a.locationName }}</span>
</div>
<div v-if="a.checkinStatus === 'checked_in'" class="ma__badge ma__badge--done">已签到</div>
<div class="ma__actions" @click.stop>
<van-button size="mini" plain @click="goDetail(a)">详情</van-button>
<van-button
v-if="canCheckin(a)"
size="mini"
type="primary"
@click="goCheckin(a)"
>
签到
</van-button>
<van-button
v-if="a.status === 'approved'"
size="mini"
plain
type="primary"
@click="goFeedback(a)"
>
反馈
</van-button>
<van-button
v-if="canCancel(a)"
size="mini"
plain
type="danger"
:loading="cancelling === a.activityId"
@click="onCancel(a)"
>
取消报名
</van-button>
</div>
</div>
</div>
<UiEmpty v-else description="还没有报名任何活动">
<van-button type="primary" round size="small" @click="router.push('/activity')">
去逛公益活动
</van-button>
</UiEmpty>
</template>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { activityService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import { showConfirmDialog } from 'vant'
import { formatDateTime } from '@/utils/datetime'
import PageLayout from '@/layouts/PageLayout.vue'
import UiTag from '@/components/UiTag.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const router = useRouter()
const appStore = useAppStore()
const filters = [
{ code: 'all', label: '全部' },
{ code: 'pending', label: '待审核' },
{ code: 'approved', label: '已通过' },
]
const activeFilter = ref('all')
const list = ref<any[]>([])
const loading = ref(true)
const error = ref(false)
const cancelling = ref('')
const filtered = computed(() => {
if (activeFilter.value === 'all') return list.value
return list.value.filter((a) => a.status === activeFilter.value)
})
function statusTag(status: string): { type: 'primary' | 'success' | 'warning' | 'danger' | 'info'; text: string } {
switch (status) {
case 'pending':
return { type: 'warning', text: '待审核' }
case 'approved':
return { type: 'success', text: '已通过' }
case 'rejected':
return { type: 'danger', text: '未通过' }
case 'cancelled':
return { type: 'info', text: '已取消' }
default:
return { type: 'info', text: '未知状态' }
}
}
function canCheckin(a: any): boolean {
return a.status === 'approved' && a.checkinStatus !== 'checked_in'
}
function canCancel(a: any): boolean {
return a.status === 'pending' || a.status === 'approved'
}
async function load() {
loading.value = true
error.value = false
try {
const result: any = await activityService.getMyActivities({ pageSize: 50 })
list.value = result?.items || result?.records || (Array.isArray(result) ? result : [])
} catch (err: any) {
error.value = true
appStore.toastError(err?.message || '加载我的活动失败')
} finally {
loading.value = false
}
}
function goDetail(a: any) {
if (a?.activityId) router.push({ path: '/activity/detail', query: { id: a.activityId } })
}
function goCheckin(a: any) {
if (a?.activityId) router.push({ path: '/activity/checkin', query: { id: a.activityId } })
}
function goFeedback(a: any) {
if (a?.activityId) router.push({ path: '/activity/feedback', query: { id: a.activityId } })
}
async function onCancel(a: any) {
if (cancelling.value) return
try {
await showConfirmDialog({ title: '取消报名', message: `确定取消「${a.title}」的报名吗?` })
} catch {
return
}
cancelling.value = a.activityId
try {
await activityService.cancelRegistration(a.activityId)
appStore.toastSuccess('已取消报名')
const target = list.value.find((x) => x.activityId === a.activityId)
if (target) target.status = 'cancelled'
} catch (err: any) {
appStore.toastError(err?.message || '取消失败')
} finally {
cancelling.value = ''
}
}
onMounted(load)
</script>
<style scoped>
.ma {
padding: 12px 16px 16px;
}
.ma__filters {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.ma__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);
}
.ma__chip--active {
color: var(--mini-tabbar-active);
border-color: var(--mini-tabbar-active);
background: rgba(244, 0, 90, 0.06);
}
.ma__card {
position: relative;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 14px;
margin-bottom: 10px;
box-shadow: var(--mini-shadow);
}
.ma__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.ma__title {
flex: 1;
min-width: 0;
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ma__meta {
display: flex;
gap: 12px;
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 6px;
}
.ma__badge {
position: absolute;
right: 14px;
top: 44px;
font-size: 11px;
color: var(--mini-success);
}
.ma__actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 12px;
}
</style>
+177
View File
@@ -0,0 +1,177 @@
<template>
<PageLayout title="绑定手机号">
<div class="bind">
<div class="bind__hero">
<div class="bind__icon">机</div>
<div class="bind__title">绑定手机号</div>
<div class="bind__desc">绑定后可用于找回账号、接收平台通知</div>
</div>
<div class="card">
<van-field
v-model="phone"
type="tel"
maxlength="11"
label="手机号"
placeholder="请输入本人手机号"
:border="false"
@update:model-value="error = ''"
/>
<van-field
v-model="code"
type="digit"
maxlength="6"
label="验证码"
placeholder="请输入验证码"
@update:model-value="error = ''"
>
<template #button>
<van-button size="small" type="primary" :disabled="countdown > 0" @click="onSendCode">
{{ countdown > 0 ? countdown + 's' : '获取验证码' }}
</van-button>
</template>
</van-field>
</div>
<van-checkbox v-model="agreed" class="bind__agree" icon-size="16">
我已阅读并同意《用户服务协议》和《隐私政策》
</van-checkbox>
<p v-if="error" class="bind__error">{{ error }}</p>
<div class="bind__actions">
<van-button type="primary" block round :loading="submitting" @click="onSubmit">
确认绑定
</van-button>
</div>
<p class="bind__tip">手机号仅用于账号安全与平台通知,不会向其他用户展示。</p>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue'
import { useRouter } from 'vue-router'
import { authService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
const router = useRouter()
const appStore = useAppStore()
const phone = ref('')
const code = ref('')
const agreed = ref(false)
const error = ref('')
const submitting = ref(false)
const countdown = ref(0)
let timer: ReturnType<typeof setInterval> | null = null
function onSendCode() {
error.value = ''
if (!/^1\d{10}$/.test(phone.value)) {
error.value = '请输入正确的手机号'
return
}
// 演示端不提供短信网关,仅作占位提示
appStore.toast('验证码已发送(演示)')
countdown.value = 60
timer = setInterval(() => {
countdown.value -= 1
if (countdown.value <= 0 && timer) {
clearInterval(timer)
timer = null
}
}, 1000)
}
async function onSubmit() {
error.value = ''
if (!agreed.value) {
error.value = '请先阅读并同意协议'
return
}
if (!/^1\d{10}$/.test(phone.value)) {
error.value = '请输入正确的手机号'
return
}
if (!code.value) {
error.value = '请输入验证码'
return
}
submitting.value = true
try {
await authService.bindPhone({ phone: phone.value, phoneCode: code.value })
appStore.toastSuccess('手机号绑定成功')
router.replace('/home')
} catch (err: any) {
error.value = err?.message || '绑定失败,请稍后重试'
} finally {
submitting.value = false
}
}
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
</script>
<style scoped>
.bind {
padding: 16px;
}
.bind__hero {
text-align: center;
padding: 24px 0 20px;
}
.bind__icon {
width: 56px;
height: 56px;
border-radius: 16px;
margin: 0 auto 12px;
background: var(--mini-brand-gradient);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: 600;
}
.bind__title {
font-size: 18px;
font-weight: 600;
color: var(--mini-text-strong);
}
.bind__desc {
font-size: 13px;
color: var(--mini-text-secondary);
margin-top: 6px;
}
.card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
overflow: hidden;
}
.bind__agree {
margin-top: 14px;
font-size: 12px;
color: var(--mini-text-secondary);
}
.bind__error {
color: var(--mini-danger);
font-size: 13px;
margin: 12px 4px 0;
}
.bind__actions {
margin-top: 20px;
}
.bind__tip {
font-size: 11px;
color: var(--mini-text-muted);
text-align: center;
margin-top: 16px;
line-height: 1.6;
}
</style>
@@ -0,0 +1,231 @@
<template>
<PageLayout title="红娘联系授权">
<div class="cc">
<div class="cc__intro">
<div class="cc__intro-title">红娘联系授权</div>
<div class="cc__intro-text">
红娘经平台审批后会向你发起联系授权申请。你可以单独决定同意或拒绝,同意后在有效期内生效,
到期自动失效,也可随时被撤销。
</div>
</div>
<UiLoading v-if="loading" text="加载中…" />
<UiErrorState v-else-if="error" @retry="load" />
<template v-else>
<!-- 待确认队列 -->
<template v-if="pending.length">
<div class="cc__section-title">待你确认({{ pending.length }})</div>
<div v-for="item in pending" :key="item.id" class="card">
<div class="cc__row">
<span class="cc__label">授权渠道</span>
<span class="cc__value cc__value--strong">{{ channelLabel(item.channel) }}</span>
</div>
<div class="cc__row">
<span class="cc__label">授权目的</span>
<span class="cc__value">{{ item.purpose || '—' }}</span>
</div>
<div class="cc__row">
<span class="cc__label">确认截止</span>
<span class="cc__value">{{ formatDateTime(item.expiresAt) || '—' }}</span>
</div>
<div class="cc__actions">
<van-button size="small" plain :loading="actingId === item.id" @click="onDecline(item)">
拒绝
</van-button>
<van-button size="small" type="primary" :loading="actingId === item.id" @click="onConsent(item)">
同意
</van-button>
</div>
</div>
</template>
<UiEmpty v-else description="暂无待确认的授权申请" />
<!-- 历史记录 -->
<template v-if="list.length">
<div class="cc__section-title">历史记录</div>
<div v-for="item in list" :key="item.id" class="card card--slim">
<div class="cc__row">
<span class="cc__label">渠道</span>
<span class="cc__value">{{ channelLabel(item.channel) }}</span>
</div>
<div class="cc__row">
<span class="cc__label">目的</span>
<span class="cc__value">{{ item.purpose || '—' }}</span>
</div>
<div class="cc__row">
<span class="cc__label">状态</span>
<span class="cc__value">
<UiTag :type="statusTag(item.status)">{{ statusLabel(item.status) }}</UiTag>
</span>
</div>
</div>
</template>
</template>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { showConfirmDialog } from 'vant'
import { contactConsentService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import { formatDateTime } from '@/utils/datetime'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
import UiTag from '@/components/UiTag.vue'
const appStore = useAppStore()
const loading = ref(true)
const error = ref(false)
const pending = ref<any[]>([])
const list = ref<any[]>([])
const actingId = ref('')
const CHANNEL_LABELS: Record<string, string> = {
phone: '电话联系',
offline_meeting: '线下见面',
full_contact: '完整联系方式',
}
const STATUS_LABELS: Record<string, string> = {
pending_approval: '待审批',
pending_consent: '待你确认',
active: '生效中',
rejected: '已拒绝',
expired: '已过期',
revoked: '已撤销',
}
function channelLabel(code: string): string {
return CHANNEL_LABELS[code] || code || '—'
}
function statusLabel(code: string): string {
return STATUS_LABELS[code] || code || '—'
}
function statusTag(code: string): 'primary' | 'success' | 'warning' | 'danger' | 'info' {
if (code === 'active') return 'success'
if (code === 'pending_consent' || code === 'pending_approval') return 'warning'
if (code === 'rejected' || code === 'revoked') return 'danger'
return 'info'
}
async function load() {
loading.value = true
error.value = false
try {
const result: any = await contactConsentService.my()
pending.value = result?.pendingConsent || []
list.value = result?.list || result?.items || result?.records || []
} catch {
error.value = true
} finally {
loading.value = false
}
}
async function onConsent(item: any) {
try {
await showConfirmDialog({
title: '确认同意联系授权',
message: `同意「${channelLabel(item.channel)}」:${item.purpose || ''}。同意后红娘可在有效期内通过该方式联系你。`,
})
} catch {
return
}
actingId.value = item.id
try {
await contactConsentService.consent(item.id)
appStore.toastSuccess('已同意')
await load()
} catch (err: any) {
appStore.toastError(err?.message || '操作失败')
} finally {
actingId.value = ''
}
}
async function onDecline(item: any) {
actingId.value = item.id
try {
await contactConsentService.decline(item.id)
appStore.toast('已拒绝')
await load()
} catch (err: any) {
appStore.toastError(err?.message || '操作失败')
} finally {
actingId.value = ''
}
}
onMounted(load)
</script>
<style scoped>
.cc {
padding: 16px;
}
.cc__intro {
background: rgba(255, 79, 145, 0.06);
border: 1px solid rgba(255, 79, 145, 0.16);
border-radius: var(--mini-radius);
padding: 14px;
margin-bottom: 12px;
}
.cc__intro-title {
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
}
.cc__intro-text {
font-size: 12px;
color: var(--mini-text-secondary);
line-height: 1.7;
margin-top: 6px;
}
.cc__section-title {
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
margin: 16px 0 8px;
}
.card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 14px;
margin-bottom: 10px;
}
.card--slim {
padding: 12px 14px;
}
.cc__row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 4px 0;
}
.cc__label {
font-size: 13px;
color: var(--mini-text-secondary);
flex: none;
}
.cc__value {
font-size: 13px;
color: var(--mini-text);
text-align: right;
}
.cc__value--strong {
font-weight: 600;
color: var(--mini-text-strong);
}
.cc__actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 12px;
}
</style>
+202
View File
@@ -0,0 +1,202 @@
<template>
<div class="login">
<div class="login__hero">
<h1 class="login__brand">善缘丹阳</h1>
<p class="login__slogan">真实相识 · 公益牵线</p>
<p class="login__sub">H5 演示端(对齐小程序用户端 / 红娘端)</p>
</div>
<div class="login__body">
<p class="login__hint">选择一个演示账号登录(无需微信环境)</p>
<div class="login__grid">
<div
v-for="acc in accounts"
:key="acc.code"
class="login__card"
:class="{ 'login__card--active': current === acc.code, 'login__card--loading': loading === acc.code }"
@click="onLogin(acc)"
>
<div class="login__avatar">{{ acc.name.slice(0, 1) }}</div>
<div class="login__meta">
<div class="login__name">{{ acc.name }}</div>
<div class="login__role">{{ acc.desc }}</div>
</div>
<van-loading v-if="loading === acc.code" size="16" />
</div>
</div>
<van-cell-group inset class="login__ops">
<van-cell title="进入红娘工作台" is-link icon="manager-o" @click="goOperator" />
</van-cell-group>
<p v-if="errorText" class="login__error">{{ errorText }}</p>
<p class="login__foot">生产环境走真实微信登录(wechat.login.provider=real)</p>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useUserStore } from '@/stores/user'
import { useAppStore } from '@/stores/app'
interface DemoAccount {
code: string
name: string
desc: string
}
// 演示账号(demo-seed-final.sql 预置用户);状态演示账号另行标注。
const accounts: DemoAccount[] = [
{ code: 'demo_openid_f01', name: '小芳', desc: '女 · 可巡游' },
{ code: 'demo_openid_f02', name: '晓燕', desc: '女 · 可巡游' },
{ code: 'demo_openid_f03', name: '婷婷', desc: '女 · 可巡游' },
{ code: 'demo_openid_f04', name: '美玲', desc: '女 · 暂停寻找' },
{ code: 'demo_openid_f06', name: '秀英', desc: '女 · 待审核' },
{ code: 'demo_openid_m01', name: '张伟', desc: '男 · 可巡游' },
{ code: 'demo_openid_m02', name: '李强', desc: '男 · 可巡游' },
{ code: 'demo_openid_m03', name: '王磊', desc: '男 · 可巡游' },
{ code: 'demo_openid_m05', name: '孙浩', desc: '男 · 可巡游' },
]
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const userStore = useUserStore()
const appStore = useAppStore()
const loading = ref('')
const current = ref('')
const errorText = ref('')
onMounted(async () => {
if (route.query.clear === '1') {
authStore.clearSession()
userStore.userState = null
}
const as = typeof route.query.as === 'string' ? route.query.as : ''
if (as && accounts.some((a) => a.code === as)) {
await onLogin(accounts.find((a) => a.code === as)!)
}
})
async function onLogin(acc: DemoAccount) {
if (loading.value) return
loading.value = acc.code
current.value = acc.code
errorText.value = ''
try {
await authStore.loginByDemoCode(acc.code)
await Promise.all([userStore.fetchStatus(), appStore.fetchCapabilities()])
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/home'
router.replace(redirect)
} catch (err: any) {
errorText.value = err?.message || '登录失败,请确认后端与演示数据已就绪'
current.value = ''
} finally {
loading.value = ''
}
}
function goOperator() {
router.push('/operator/login')
}
</script>
<style scoped>
.login {
min-height: 100vh;
background: var(--mini-bg);
}
.login__hero {
background: var(--mini-brand-gradient);
color: #fff;
padding: 48px 24px 40px;
}
.login__brand {
font-size: 26px;
margin: 0;
letter-spacing: 2px;
}
.login__slogan {
margin: 8px 0 0;
opacity: 0.9;
}
.login__sub {
margin: 16px 0 0;
font-size: 12px;
opacity: 0.75;
}
.login__body {
padding: 20px 16px;
margin-top: -20px;
}
.login__hint {
color: var(--mini-text-secondary);
font-size: 13px;
margin: 0 4px 12px;
}
.login__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.login__card {
display: flex;
align-items: center;
gap: 10px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 12px;
box-shadow: var(--mini-shadow);
border: 1px solid transparent;
}
.login__card--active {
border-color: var(--mini-tabbar-active);
}
.login__card--loading {
opacity: 0.7;
}
.login__avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--mini-brand-gradient);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
flex: none;
}
.login__meta {
flex: 1;
min-width: 0;
}
.login__name {
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
}
.login__role {
font-size: 11px;
color: var(--mini-text-secondary);
}
.login__ops {
margin: 20px 0 0;
}
.login__error {
color: var(--mini-danger);
font-size: 13px;
margin: 12px 4px 0;
}
.login__foot {
color: var(--mini-text-muted);
font-size: 11px;
text-align: center;
margin-top: 24px;
}
</style>
+228
View File
@@ -0,0 +1,228 @@
<template>
<PageLayout title="实名认证">
<div class="vf">
<UiLoading v-if="loading" text="正在读取认证状态…" />
<UiErrorState v-else-if="error" @retry="load" />
<template v-else>
<!-- 已认证 -->
<div v-if="verified" class="vf__done">
<div class="vf__done-icon">✓</div>
<div class="vf__done-title">已完成实名认证</div>
<div class="vf__done-desc">您的身份信息已通过核验,可继续完善资料。</div>
<div class="vf__actions">
<van-button type="primary" block round @click="router.replace('/profile/basic')">
完善资料
</van-button>
</div>
</div>
<!-- 未认证表单 -->
<template v-else>
<div class="vf__hero">
<div class="vf__title">实名认证</div>
<div class="vf__subtitle">真实身份帮助我们提供更安全的公益婚恋服务</div>
</div>
<div class="card">
<van-field
v-model="realName"
label="姓名"
placeholder="请输入真实姓名"
:border="false"
maxlength="20"
@update:model-value="errorText = ''"
/>
<van-field
v-model="idCardNumber"
label="身份证号"
placeholder="请输入18位身份证号码"
maxlength="18"
@update:model-value="onIdInput"
/>
</div>
<van-checkbox v-model="agreed" class="vf__agree" icon-size="16">
我已阅读并同意《用户服务协议》和《隐私政策》
</van-checkbox>
<p v-if="errorText" class="vf__error">{{ errorText }}</p>
<div class="vf__actions">
<van-button
type="primary"
block
round
:loading="submitting"
:disabled="!agreed || submitting"
@click="onSubmit"
>
提交认证
</van-button>
</div>
<div class="vf__tips">
<div class="vf__tips-title">认证说明</div>
<div class="vf__tip">· 身份信息仅用于实名认证,不会向其他用户展示</div>
<div class="vf__tip">· 实名认证是平台安全的重要保障</div>
<div class="vf__tip">· 未满 18 周岁不能使用本平台服务</div>
</div>
</template>
</template>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { verificationService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import { useUserStore } from '@/stores/user'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const router = useRouter()
const appStore = useAppStore()
const userStore = useUserStore()
const loading = ref(true)
const error = ref(false)
const verified = ref(false)
const realName = ref('')
const idCardNumber = ref('')
const agreed = ref(false)
const submitting = ref(false)
const errorText = ref('')
function onIdInput(value: string) {
idCardNumber.value = value.replace(/\s/g, '')
errorText.value = ''
}
async function load() {
loading.value = true
error.value = false
try {
const status: any = await verificationService.getStatus()
verified.value = !!(status?.verified || status?.status === 'verified' || status?.status === 'approved')
} catch {
error.value = true
} finally {
loading.value = false
}
}
async function onSubmit() {
errorText.value = ''
if (!agreed.value) {
errorText.value = '请先阅读并同意协议'
return
}
if (!realName.value || realName.value.length < 2) {
errorText.value = '请输入真实姓名'
return
}
if (idCardNumber.value.length !== 18) {
errorText.value = '请输入正确的18位身份证号码'
return
}
submitting.value = true
try {
await verificationService.submit({ realName: realName.value, idCardNumber: idCardNumber.value })
appStore.toastSuccess('认证成功')
userStore.userState = { ...(userStore.userState || {}), verified: true }
router.replace('/profile/basic')
} catch (err: any) {
errorText.value = err?.message || '认证失败,请重试'
} finally {
submitting.value = false
}
}
onMounted(load)
</script>
<style scoped>
.vf {
padding: 16px;
}
.vf__hero {
padding: 8px 4px 16px;
}
.vf__title {
font-size: 20px;
font-weight: 600;
color: var(--mini-text-strong);
}
.vf__subtitle {
font-size: 13px;
color: var(--mini-text-secondary);
margin-top: 6px;
}
.card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
overflow: hidden;
}
.vf__agree {
margin-top: 14px;
font-size: 12px;
color: var(--mini-text-secondary);
}
.vf__error {
color: var(--mini-danger);
font-size: 13px;
margin: 12px 4px 0;
}
.vf__actions {
margin-top: 20px;
}
.vf__tips {
margin-top: 24px;
background: rgba(79, 140, 255, 0.06);
border: 1px solid rgba(79, 140, 255, 0.14);
border-radius: var(--mini-radius);
padding: 14px;
}
.vf__tips-title {
font-size: 13px;
font-weight: 600;
color: var(--mini-text-strong);
margin-bottom: 8px;
}
.vf__tip {
font-size: 12px;
color: var(--mini-text-secondary);
line-height: 1.8;
}
.vf__done {
text-align: center;
padding: 40px 16px;
}
.vf__done-icon {
width: 64px;
height: 64px;
border-radius: 50%;
margin: 0 auto 16px;
background: var(--mini-success);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 30px;
}
.vf__done-title {
font-size: 18px;
font-weight: 600;
color: var(--mini-text-strong);
}
.vf__done-desc {
font-size: 13px;
color: var(--mini-text-secondary);
margin-top: 8px;
}
</style>
+295
View File
@@ -0,0 +1,295 @@
<template>
<PageLayout title="婚恋反诈">
<div class="fraud">
<div class="fraud__hero">
<div class="fraud__eyebrow">提高警惕 · 守护真心</div>
<div class="fraud__title">婚恋反诈</div>
<div class="fraud__desc">识别风险信号,守护你的真心与财产安全</div>
</div>
<div class="fraud__body">
<UiErrorState v-if="error" message="暂时无法加载反诈内容" @retry="load" />
<UiLoading v-else-if="loading" />
<template v-else>
<UiSectionHeader title="常见风险信号" />
<div class="fraud__grid">
<div v-for="r in RISKS" :key="r.title" class="fraud__risk">
<div class="fraud__risk-icon">!</div>
<div class="fraud__risk-title">{{ r.title }}</div>
<div class="fraud__risk-desc">{{ r.description }}</div>
</div>
</div>
<UiSectionHeader title="遇到可疑情况怎么办" />
<div class="fraud__card">
<div v-for="(s, i) in STEPS" :key="s.title" class="fraud__step">
<div class="fraud__step-index">{{ i + 1 }}</div>
<div class="fraud__step-copy">
<div class="fraud__step-title">{{ s.title }}</div>
<div class="fraud__step-desc">{{ s.description }}</div>
</div>
</div>
</div>
<template v-if="articles.length">
<UiSectionHeader title="反诈科普内容" />
<div
v-for="a in articles"
:key="a.id"
class="fraud__article"
@click="router.push({ path: '/content/detail', query: { id: a.id } })"
>
<div class="fraud__article-cover">{{ (a.title || '反').slice(0, 1) }}</div>
<div class="fraud__article-info">
<div class="fraud__article-title">{{ a.title }}</div>
<div v-if="a.summary" class="fraud__article-summary">{{ a.summary }}</div>
</div>
<van-icon name="arrow" color="#9aa0b5" />
</div>
</template>
</template>
</div>
<div class="fraud__action">
<van-button class="fraud__action-btn" plain @click="requestHelp">安全求助</van-button>
<van-button class="fraud__action-btn" type="primary" @click="report">举报可疑行为</van-button>
</div>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showConfirmDialog } from 'vant'
import { contentService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiErrorState from '@/components/UiErrorState.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
const router = useRouter()
const appStore = useAppStore()
const RISKS = [
{ title: '投资博彩诱导', description: '刚认识便引导投资、博彩或下载陌生应用' },
{ title: '借款资金周转', description: '以生病、工程或临时周转为由反复借款' },
{ title: '索取隐私资料', description: '催促脱离平台并索要验证码、银行卡或证件信息' },
{ title: '冒充平台人员', description: '以认证、保证金或解冻账户为由要求转账' },
]
const STEPS = [
{ title: '立即停止资金操作', description: '不要继续转账、充值或提供验证码' },
{ title: '保存相关证据', description: '保留聊天、付款和对方账号信息' },
{ title: '及时举报或报警', description: '平台举报不会通知被举报人,紧急情况请拨打 110' },
]
const loading = ref(true)
const error = ref(false)
const articles = ref<any[]>([])
async function load() {
loading.value = true
error.value = false
try {
// 反诈栏目的内容按分类 code(anti_fraud)取,避免客户端硬编码分类 id
const catResult: any = await contentService.getCategories()
const categories: any[] = catResult?.items || catResult?.records || (Array.isArray(catResult) ? catResult : [])
const antiFraud = categories.find((c) => c.code === 'anti_fraud')
if (!antiFraud?.id) {
articles.value = []
return
}
const result: any = await contentService.getArticles({ categoryId: antiFraud.id })
articles.value = result?.items || result?.records || (Array.isArray(result) ? result : [])
} catch {
error.value = true
} finally {
loading.value = false
}
}
function report() {
router.push({ path: '/report', query: { type: 'fraud' } })
}
async function requestHelp() {
try {
await showConfirmDialog({
title: '安全求助',
message: '如有财产损失或人身危险,请立即拨打 110;非紧急问题可前往平台帮助中心。',
confirmButtonText: '拨打 110',
cancelButtonText: '帮助中心',
})
window.location.href = 'tel:110'
} catch {
router.push('/help')
}
}
onMounted(load)
</script>
<style scoped>
.fraud {
min-height: 100vh;
background: var(--mini-bg);
padding-bottom: 76px;
}
.fraud__hero {
background: var(--mini-brand-gradient);
color: #fff;
padding: 18px 16px 20px;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
}
.fraud__eyebrow {
font-size: 11px;
opacity: 0.8;
}
.fraud__title {
font-size: 19px;
font-weight: 600;
margin-top: 4px;
}
.fraud__desc {
font-size: 12px;
opacity: 0.85;
margin-top: 6px;
}
.fraud__body {
padding: 0 16px;
}
.fraud__grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.fraud__risk {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 14px;
}
.fraud__risk-icon {
width: 26px;
height: 26px;
border-radius: 8px;
background: var(--mini-danger);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-weight: 700;
}
.fraud__risk-title {
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
margin-top: 8px;
}
.fraud__risk-desc {
font-size: 12px;
color: var(--mini-text-secondary);
line-height: 1.5;
margin-top: 4px;
}
.fraud__card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 4px 14px;
}
.fraud__step {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 0;
border-bottom: 1px solid var(--mini-border);
}
.fraud__step:last-child {
border-bottom: none;
}
.fraud__step-index {
width: 24px;
height: 24px;
border-radius: 50%;
background: rgba(47, 179, 109, 0.14);
color: var(--mini-success);
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
flex: none;
}
.fraud__step-title {
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
}
.fraud__step-desc {
font-size: 12px;
color: var(--mini-text-secondary);
line-height: 1.5;
margin-top: 2px;
}
.fraud__article {
display: flex;
align-items: center;
gap: 12px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 12px;
margin-bottom: 10px;
}
.fraud__article-cover {
width: 46px;
height: 46px;
border-radius: 11px;
background: linear-gradient(135deg, #3fc98a 0%, #199e64 100%);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
flex: none;
}
.fraud__article-info {
flex: 1;
min-width: 0;
}
.fraud__article-title {
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
}
.fraud__article-summary {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 4px;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.fraud__action {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 10px;
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
background: var(--mini-surface);
box-shadow: 0 -4px 16px rgba(23, 42, 114, 0.08);
}
.fraud__action-btn {
flex: 1;
border-radius: var(--mini-radius);
}
</style>
@@ -0,0 +1,206 @@
<template>
<PageLayout title="内容详情">
<div class="cd">
<div class="cd__body">
<UiErrorState v-if="error" :message="errorMessage" @retry="load" />
<UiLoading v-else-if="loading" />
<template v-else-if="article">
<div class="cd__hero">
<div class="cd__eyebrow">善缘丹阳婚恋课堂</div>
<div class="cd__title">{{ article.title }}</div>
<div v-if="article.summary" class="cd__desc">{{ article.summary }}</div>
</div>
<div class="cd__content">
<p v-if="article.summary" class="cd__lead">{{ article.summary }}</p>
<p v-for="(line, index) in paragraphs" :key="index" class="cd__paragraph">{{ line }}</p>
<div class="cd__notice">本文为公益科普内容,不能替代专业咨询或法律意见。</div>
</div>
<UiSectionHeader title="需要进一步帮助?" />
<div class="cd__card">
<div class="cd__row" @click="router.push('/counseling')">
<div class="cd__row-icon tone-blue">咨</div>
<div class="cd__row-copy">
<div class="cd__row-title">了解婚恋咨询</div>
<div class="cd__row-desc">由咨询人员提供关系与情绪支持</div>
</div>
<van-icon name="arrow" color="#9aa0b5" />
</div>
<div class="cd__row" @click="router.push({ path: '/report', query: { type: 'content' } })">
<div class="cd__row-icon tone-amber">报</div>
<div class="cd__row-copy">
<div class="cd__row-title">举报不实内容</div>
<div class="cd__row-desc">发现虚假或违规信息可提交举报</div>
</div>
<van-icon name="arrow" color="#9aa0b5" />
</div>
</div>
</template>
</div>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { contentService } from '@/api/services'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiErrorState from '@/components/UiErrorState.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
const route = useRoute()
const router = useRouter()
const articleId = ref(String(route.query.id || ''))
const loading = ref(true)
const error = ref(false)
const errorMessage = ref('加载失败,请稍后重试')
const article = ref<any>(null)
const paragraphs = ref<string[]>([])
async function load() {
if (!articleId.value) {
loading.value = false
error.value = true
errorMessage.value = '未指定内容'
return
}
loading.value = true
error.value = false
try {
const result: any = await contentService.getArticleDetail(articleId.value)
if (!result) {
loading.value = false
error.value = true
errorMessage.value = '内容不存在或已下架'
return
}
article.value = result
paragraphs.value = String(result.content || '')
.split(/\n+/)
.map((line) => line.trim())
.filter(Boolean)
} catch {
error.value = true
errorMessage.value = '加载失败,请稍后重试'
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<style scoped>
.cd {
min-height: 100vh;
background: var(--mini-bg);
padding-bottom: 24px;
}
.cd__body {
padding: 0 16px;
}
.cd__hero {
background: var(--mini-brand-gradient);
color: #fff;
padding: 18px 16px;
border-radius: var(--mini-radius);
margin-top: 12px;
}
.cd__eyebrow {
font-size: 11px;
opacity: 0.8;
}
.cd__title {
font-size: 18px;
font-weight: 600;
margin-top: 6px;
line-height: 1.4;
}
.cd__desc {
font-size: 12px;
opacity: 0.85;
margin-top: 8px;
line-height: 1.6;
}
.cd__content {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 14px 16px;
margin-top: 12px;
}
.cd__lead {
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
line-height: 1.7;
margin: 0;
}
.cd__paragraph {
font-size: 14px;
color: var(--mini-text);
line-height: 1.8;
margin: 18px 0 0;
}
.cd__notice {
font-size: 12px;
color: var(--mini-text-secondary);
line-height: 1.6;
background: rgba(245, 166, 35, 0.12);
border-radius: var(--mini-radius);
padding: 12px 14px;
margin-top: 20px;
}
.cd__card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
overflow: hidden;
}
.cd__row {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
border-bottom: 1px solid var(--mini-border);
}
.cd__row:last-child {
border-bottom: none;
}
.cd__row-icon {
width: 38px;
height: 38px;
border-radius: 11px;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 600;
flex: none;
}
.cd__row-copy {
flex: 1;
min-width: 0;
}
.cd__row-title {
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
}
.cd__row-desc {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 2px;
}
.tone-blue {
background: linear-gradient(135deg, #4f8cff 0%, #2f63e0 100%);
}
.tone-amber {
background: linear-gradient(135deg, #f5a623 0%, #d98a10 100%);
}
</style>
+224
View File
@@ -0,0 +1,224 @@
<template>
<PageLayout title="婚恋课堂">
<div class="content">
<div class="content__hero">
<div class="content__eyebrow">学习成长 · 认真相遇</div>
<div class="content__title">婚恋课堂</div>
<div class="content__desc">学习成长,收获更好的自己</div>
</div>
<div class="content__cats">
<span
v-for="c in categories"
:key="c.id"
class="content__cat"
:class="{ 'content__cat--active': activeCategoryId === c.id }"
@click="select(c.id)"
>
{{ c.name }}
</span>
</div>
<div class="content__body">
<UiErrorState v-if="error" message="暂时无法加载课堂内容" @retry="load" />
<UiLoading v-else-if="loading" />
<template v-else>
<div v-if="articles.length">
<div
v-for="a in articles"
:key="a.id"
class="content__card"
@click="router.push({ path: '/content/detail', query: { id: a.id } })"
>
<div class="content__cover">{{ (a.title || '课').slice(0, 1) }}</div>
<div class="content__info">
<div class="content__cat-label">{{ categoryName(a) }}</div>
<div class="content__card-title">{{ a.title }}</div>
<div v-if="a.summary" class="content__summary">{{ a.summary }}</div>
<div class="content__meta">
<span>浏览 {{ a.viewCount || 0 }}</span>
<span>{{ formatDate(a.publishAt || a.createdAt) }}</span>
</div>
</div>
<van-icon name="arrow" color="#9aa0b5" />
</div>
</div>
<UiEmpty v-else description="暂无课堂内容" />
</template>
</div>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { contentService } from '@/api/services'
import { formatDate } from '@/utils/datetime'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const router = useRouter()
const categories = ref<any[]>([])
const activeCategoryId = ref('')
const articles = ref<any[]>([])
const loading = ref(true)
const error = ref(false)
function categoryName(a: any): string {
const id = a?.categoryId || ''
return categories.value.find((c) => c.id === id)?.name || '婚恋课堂'
}
function pickArticles(result: any): any[] {
return result?.items || result?.records || (Array.isArray(result) ? result : [])
}
async function load() {
loading.value = true
error.value = false
try {
const [catResult, artResult]: any = await Promise.all([
contentService.getCategories(),
contentService.getArticles(),
])
const list = catResult?.items || catResult?.records || (Array.isArray(catResult) ? catResult : [])
// 「推荐」为默认聚合项(空 id → 不传 categoryId 拉全量)
categories.value = [{ id: '', name: '推荐' }, ...list]
articles.value = pickArticles(artResult)
} catch {
error.value = true
} finally {
loading.value = false
}
}
async function select(categoryId: string) {
if (categoryId === activeCategoryId.value) return
activeCategoryId.value = categoryId
loading.value = true
error.value = false
try {
const result: any = await contentService.getArticles(categoryId ? { categoryId } : {})
articles.value = pickArticles(result)
} catch {
error.value = true
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<style scoped>
.content {
min-height: 100vh;
background: var(--mini-bg);
padding-bottom: 24px;
}
.content__hero {
background: var(--mini-brand-gradient);
color: #fff;
padding: 18px 16px 20px;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
}
.content__eyebrow {
font-size: 11px;
opacity: 0.8;
}
.content__title {
font-size: 19px;
font-weight: 600;
margin-top: 4px;
}
.content__desc {
font-size: 12px;
opacity: 0.85;
margin-top: 6px;
}
.content__cats {
display: flex;
gap: 8px;
overflow-x: auto;
padding: 12px 16px 0;
}
.content__cat {
flex: none;
font-size: 12px;
padding: 6px 14px;
border-radius: 14px;
background: var(--mini-surface);
color: var(--mini-text-secondary);
box-shadow: var(--mini-shadow);
}
.content__cat--active {
background: var(--mini-tabbar-active);
color: #fff;
font-weight: 600;
}
.content__body {
padding: 12px 16px 0;
}
.content__card {
display: flex;
align-items: center;
gap: 12px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 12px;
margin-bottom: 10px;
box-shadow: var(--mini-shadow);
}
.content__cover {
width: 56px;
height: 56px;
border-radius: 12px;
background: linear-gradient(135deg, #f5a623 0%, #e08b00 100%);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
font-weight: 600;
flex: none;
}
.content__info {
flex: 1;
min-width: 0;
}
.content__cat-label {
display: inline-block;
font-size: 10px;
color: var(--mini-tabbar-active);
background: rgba(244, 0, 90, 0.08);
padding: 1px 8px;
border-radius: 8px;
}
.content__card-title {
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
margin-top: 4px;
}
.content__summary {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 4px;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.content__meta {
display: flex;
gap: 12px;
font-size: 11px;
color: var(--mini-text-muted);
margin-top: 6px;
}
</style>
@@ -0,0 +1,256 @@
<template>
<PageLayout title="婚恋咨询">
<div class="couns">
<div class="couns__hero">
<div class="couns__eyebrow">专业陪伴 · 严格保密</div>
<div class="couns__title">婚恋咨询</div>
<div class="couns__desc">倾听你的心声,陪你梳理关系与情绪困扰</div>
</div>
<div class="couns__body">
<UiErrorState v-if="error" @retry="load" />
<UiLoading v-else-if="loading" />
<template v-else>
<div class="couns__status">
<span class="couns__status-label">服务状态</span>
<span class="couns__status-value" :class="{ 'couns__status-value--off': !serviceEnabled }">
{{ serviceEnabled ? '可预约' : disabledReason || '服务暂不可用' }}
</span>
</div>
<UiSectionHeader title="我们可以提供的帮助" />
<div v-if="services.length" class="couns__card">
<div v-for="s in services" :key="s.type" class="couns__topic" @click="selectTopic(s.type)">
<div class="couns__topic-icon" :class="`tone-${toneOf(s.type)}`">{{ iconOf(s.type) }}</div>
<div class="couns__topic-copy">
<div class="couns__topic-title">{{ s.name }}</div>
<div class="couns__topic-desc">{{ s.description }}</div>
</div>
<van-icon name="arrow" color="#9aa0b5" />
</div>
</div>
<UiEmpty v-else description="暂时无法获取咨询方向" />
<div class="couns__privacy">
咨询内容按敏感信息保护。公益红娘不能查看咨询正文,咨询人员仅处理明确分配给自己的服务。
</div>
</template>
</div>
<div class="couns__action">
<van-button class="couns__action-btn" plain @click="router.push('/my/counseling')">我的咨询</van-button>
<van-button
class="couns__action-btn"
type="primary"
:disabled="!serviceEnabled"
@click="book"
>
预约咨询
</van-button>
</div>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { counselingService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
const router = useRouter()
const appStore = useAppStore()
const ICON_MAP: Record<string, string> = {
relationship_guidance: '咨',
emotional_support: '伴',
premarital_guidance: '婚',
anti_fraud_guidance: '盾',
}
const TONE_MAP: Record<string, string> = {
relationship_guidance: 'blue',
emotional_support: 'purple',
premarital_guidance: 'pink',
anti_fraud_guidance: 'green',
}
const loading = ref(true)
const error = ref(false)
const services = ref<any[]>([])
const disabledReason = ref('')
const serviceEnabled = computed(() => services.value.length > 0)
function iconOf(type: string): string {
return ICON_MAP[type] || '咨'
}
function toneOf(type: string): string {
return TONE_MAP[type] || 'blue'
}
async function load() {
loading.value = true
error.value = false
try {
const result: any = await counselingService.intro()
services.value = result?.items || result?.records || (Array.isArray(result) ? result : [])
} catch {
error.value = true
} finally {
loading.value = false
}
}
function selectTopic(type: string) {
if (!serviceEnabled.value) {
appStore.toast(disabledReason.value || '服务暂不可用')
return
}
router.push({ path: '/counseling/request', query: { type } })
}
function book() {
const type = services.value[0]?.type || 'relationship_guidance'
selectTopic(type)
}
onMounted(load)
</script>
<style scoped>
.couns {
min-height: 100vh;
background: var(--mini-bg);
padding-bottom: 76px;
}
.couns__hero {
background: var(--mini-brand-gradient);
color: #fff;
padding: 18px 16px 20px;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
}
.couns__eyebrow {
font-size: 11px;
opacity: 0.8;
}
.couns__title {
font-size: 19px;
font-weight: 600;
margin-top: 4px;
}
.couns__desc {
font-size: 12px;
opacity: 0.85;
margin-top: 6px;
}
.couns__body {
padding: 0 16px;
}
.couns__status {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 12px 14px;
margin-top: 12px;
}
.couns__status-label {
font-size: 13px;
color: var(--mini-text-secondary);
}
.couns__status-value {
font-size: 13px;
font-weight: 600;
color: var(--mini-success);
}
.couns__status-value--off {
color: var(--mini-text-muted);
}
.couns__card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
overflow: hidden;
}
.couns__topic {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
border-bottom: 1px solid var(--mini-border);
}
.couns__topic:last-child {
border-bottom: none;
}
.couns__topic-icon {
width: 42px;
height: 42px;
border-radius: 12px;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 17px;
font-weight: 600;
flex: none;
}
.couns__topic-copy {
flex: 1;
min-width: 0;
}
.couns__topic-title {
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
}
.couns__topic-desc {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 2px;
line-height: 1.5;
}
.couns__privacy {
font-size: 12px;
color: var(--mini-text-secondary);
line-height: 1.6;
background: rgba(47, 179, 109, 0.08);
border-radius: var(--mini-radius);
padding: 12px 14px;
margin-top: 16px;
}
.couns__action {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 10px;
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
background: var(--mini-surface);
box-shadow: 0 -4px 16px rgba(23, 42, 114, 0.08);
}
.couns__action-btn {
flex: 1;
border-radius: var(--mini-radius);
}
.tone-blue {
background: linear-gradient(135deg, #4f8cff 0%, #2f63e0 100%);
}
.tone-purple {
background: linear-gradient(135deg, #b06cff 0%, #8a3ee0 100%);
}
.tone-pink {
background: linear-gradient(135deg, #ff7ab0 0%, #e0559a 100%);
}
.tone-green {
background: linear-gradient(135deg, #3fc98a 0%, #199e64 100%);
}
</style>
@@ -0,0 +1,211 @@
<template>
<PageLayout title="预约咨询">
<div class="creq">
<div class="creq__hero">
<div class="creq__eyebrow">专业陪伴 · 严格保密</div>
<div class="creq__title">提交咨询申请</div>
<div class="creq__desc">提交后工作人员将在 1-3 个工作日内与你联系。</div>
</div>
<div class="creq__body">
<UiErrorState v-if="error" message="加载失败,请稍后重试" @retry="load" />
<UiLoading v-else-if="loading" />
<template v-else>
<div class="creq__card">
<van-field label="服务类型" :model-value="serviceTypeLabel" readonly />
<van-field
label="沟通方式"
:model-value="modeLabel"
placeholder="请选择沟通方式"
readonly
is-link
@click="chooseMode"
/>
<van-field
v-model="preferredTime"
label="期望时间"
placeholder="如:工作日晚上7点后"
maxlength="60"
/>
<van-field
v-model="summary"
label="问题描述"
type="textarea"
rows="4"
maxlength="500"
show-word-limit
placeholder="请简要描述您的需求"
/>
</div>
<div class="creq__privacy">
咨询内容按敏感信息保护,公益红娘不能查看咨询正文,咨询人员仅处理明确分配给自己的服务。
</div>
<van-button
class="creq__submit"
type="primary"
block
round
:loading="submitting"
loading-text="提交中…"
@click="submit"
>
提交申请
</van-button>
<div class="creq__link" @click="router.push('/my/counseling')">查看我的咨询</div>
</template>
</div>
</div>
<van-popup v-model:show="showModePicker" position="bottom" round>
<van-picker :columns="modeColumns" @confirm="onModeConfirm" @cancel="showModePicker = false" />
</van-popup>
</PageLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { counselingService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
/** 与后端 intro / counseling_services.service_type 白名单一致 */
const TYPE_LABEL: Record<string, string> = {
relationship_guidance: '恋爱困惑指导',
emotional_support: '情感支持',
premarital_guidance: '婚前辅导',
anti_fraud_guidance: '婚恋反诈指导',
}
/** 沟通方式(对齐 miniapp:线上视频 / 线下面谈 / 电话沟通) */
const MODES = [
{ value: 'online', label: '线上视频' },
{ value: 'offline', label: '线下面谈' },
{ value: 'phone', label: '电话沟通' },
]
const loading = ref(false)
const error = ref(false)
const submitting = ref(false)
const serviceType = ref('')
const serviceTypeLabel = ref('')
const modeIndex = ref(0)
const preferredTime = ref('')
const summary = ref('')
const modeLabel = ref(MODES[0].label)
const showModePicker = ref(false)
const modeColumns = MODES.map((m) => ({ text: m.label, value: m.value }))
async function load() {
const type = String(route.query.type || '')
serviceType.value = type || 'relationship_guidance'
serviceTypeLabel.value = TYPE_LABEL[serviceType.value] || serviceType.value
}
function chooseMode() {
showModePicker.value = true
}
function onModeConfirm(payload: any) {
const value = payload?.selectedOptions?.[0]?.value
const index = MODES.findIndex((m) => m.value === value)
if (index < 0) {
showModePicker.value = false
return
}
modeIndex.value = index
modeLabel.value = MODES[index].label
showModePicker.value = false
}
async function submit() {
if (!summary.value.trim()) {
appStore.toast('请简要描述您的困扰')
return
}
if (submitting.value) return
submitting.value = true
try {
await counselingService.apply({
serviceType: serviceType.value,
preferredMode: MODES[modeIndex.value].value,
preferredTime: preferredTime.value,
summary: summary.value.trim(),
requestSource: 'user_self',
})
appStore.toastSuccess('申请已提交')
router.replace('/my/counseling')
} catch (err: any) {
appStore.toastError(err?.message || '提交失败,请稍后重试')
} finally {
submitting.value = false
}
}
onMounted(load)
</script>
<style scoped>
.creq {
min-height: 100vh;
background: var(--mini-bg);
padding-bottom: 24px;
}
.creq__hero {
background: var(--mini-brand-gradient);
color: #fff;
padding: 18px 16px 20px;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
}
.creq__eyebrow {
font-size: 11px;
opacity: 0.8;
}
.creq__title {
font-size: 18px;
font-weight: 600;
margin-top: 4px;
}
.creq__desc {
font-size: 12px;
opacity: 0.85;
margin-top: 6px;
}
.creq__body {
padding: 0 16px;
}
.creq__card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
overflow: hidden;
margin-top: 12px;
}
.creq__privacy {
font-size: 12px;
color: var(--mini-text-secondary);
line-height: 1.6;
background: rgba(47, 179, 109, 0.08);
border-radius: var(--mini-radius);
padding: 12px 14px;
margin-top: 12px;
}
.creq__submit {
margin-top: 20px;
}
.creq__link {
text-align: center;
font-size: 13px;
color: var(--mini-text-secondary);
margin-top: 14px;
}
</style>
@@ -0,0 +1,318 @@
<template>
<PageLayout title="我的咨询">
<div class="mc">
<div class="mc__hero">
<div class="mc__eyebrow">我的公益服务</div>
<div class="mc__title">我的咨询</div>
<div class="mc__desc">咨询与婚前辅导申请记录,正文按敏感信息保护。</div>
</div>
<div class="mc__body">
<UiErrorState v-if="error" message="加载失败,请稍后重试" @retry="load" />
<UiLoading v-else-if="loading" />
<template v-else>
<div v-if="items.length">
<div v-for="s in items" :key="s.id" class="mc__card" @click="openDetail(s)">
<div class="mc__card-head">
<span class="mc__card-title">{{ typeLabel(s.serviceType) }}</span>
<UiTag :type="statusTone(s.status)">{{ statusLabel(s.status) }}</UiTag>
</div>
<div class="mc__meta">申请于 {{ formatDateTime(s.createdAt) || '—' }}</div>
<div v-if="s.scheduledStartAt" class="mc__meta">
预约时间:{{ formatDateTime(s.scheduledStartAt) }}
</div>
<div class="mc__actions">
<van-button
v-if="canConfirmCompanion(s)"
size="small"
type="primary"
@click.stop="confirmCompanion(s)"
>
确认参与
</van-button>
<van-button v-if="canCancel(s)" size="small" plain @click.stop="cancel(s)">取消申请</van-button>
</div>
</div>
</div>
<UiEmpty v-else description="暂无咨询记录">
<van-button type="primary" round size="small" @click="router.push('/counseling')">
去申请咨询
</van-button>
</UiEmpty>
<div class="mc__notice">咨询与公益红娘记录彼此分离;工作人员只能查看职责范围内的信息。</div>
</template>
</div>
</div>
<van-popup v-model:show="showDetail" position="bottom" round>
<div class="mc__detail">
<div class="mc__detail-title">咨询详情</div>
<UiLoading v-if="detailLoading" />
<template v-else-if="detail">
<div class="mc__detail-row">
<span class="mc__detail-label">服务类型</span>
<span class="mc__detail-value">{{ typeLabel(detail.serviceType) }}</span>
</div>
<div class="mc__detail-row">
<span class="mc__detail-label">当前状态</span>
<span class="mc__detail-value">{{ statusLabel(detail.status) }}</span>
</div>
<div class="mc__detail-row">
<span class="mc__detail-label">沟通方式</span>
<span class="mc__detail-value">{{ modeLabel(detail.preferredServiceMode) || '待确认' }}</span>
</div>
<div class="mc__detail-row">
<span class="mc__detail-label">期望时间</span>
<span class="mc__detail-value">{{ detail.preferredTime || '待确认' }}</span>
</div>
<div v-if="detail.userRequestSummary" class="mc__detail-row">
<span class="mc__detail-label">问题描述</span>
<span class="mc__detail-value">{{ detail.userRequestSummary }}</span>
</div>
<div v-if="detail.scheduledStartAt" class="mc__detail-row">
<span class="mc__detail-label">已排期</span>
<span class="mc__detail-value">{{ formatDateTime(detail.scheduledStartAt) }}</span>
</div>
<div class="mc__detail-row">
<span class="mc__detail-label">申请时间</span>
<span class="mc__detail-value">{{ formatDateTime(detail.createdAt) || '—' }}</span>
</div>
</template>
<UiErrorState v-else message="咨询详情加载失败" @retry="reloadDetail" />
</div>
</van-popup>
</PageLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showConfirmDialog } from 'vant'
import { counselingService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import { formatDateTime } from '@/utils/datetime'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
import UiTag from '@/components/UiTag.vue'
const router = useRouter()
const appStore = useAppStore()
const TYPE_LABEL: Record<string, string> = {
relationship_guidance: '恋爱困惑指导',
emotional_support: '情感支持',
premarital_guidance: '婚前辅导',
anti_fraud_guidance: '婚恋反诈指导',
premarital: '婚前辅导',
counseling: '婚恋咨询',
}
const STATUS_LABEL: Record<string, string> = {
pending: '待处理',
assigned: '已分配',
scheduled: '已排期',
in_progress: '服务中',
completed: '已完成',
cancelled: '已取消',
closed: '已关闭',
}
const MODE_LABEL: Record<string, string> = {
online: '线上视频',
offline: '线下面谈',
phone: '电话沟通',
}
const loading = ref(true)
const error = ref(false)
const items = ref<any[]>([])
const showDetail = ref(false)
const detailLoading = ref(false)
const detail = ref<any>(null)
const currentId = ref('')
function typeLabel(type: string): string {
return TYPE_LABEL[type] || type || ''
}
function statusLabel(status: string): string {
return STATUS_LABEL[status] || status || ''
}
function modeLabel(mode: string): string {
return MODE_LABEL[mode] || ''
}
function statusTone(status: string): 'primary' | 'success' | 'warning' | 'danger' | 'info' {
if (status === 'completed') return 'success'
if (status === 'cancelled' || status === 'closed') return 'info'
if (status === 'pending') return 'warning'
return 'primary'
}
function canCancel(s: any): boolean {
return s.status === 'pending' || s.status === 'assigned' || s.status === 'scheduled'
}
/** INV-005:仅婚前辅导 + 受邀伴侣本人 + 尚未确认时可操作 */
function canConfirmCompanion(s: any): boolean {
const isPremarital = s.serviceType === 'premarital_guidance' || s.serviceType === 'premarital'
return isPremarital && !!s.companionUserId && !s.userConfirmedAt
}
async function load() {
loading.value = true
error.value = false
try {
const result: any = await counselingService.myServices()
items.value = result?.items || result?.records || (Array.isArray(result) ? result : [])
} catch {
error.value = true
} finally {
loading.value = false
}
}
async function openDetail(s: any) {
if (!s?.id) return
currentId.value = s.id
showDetail.value = true
await reloadDetail()
}
async function reloadDetail() {
if (!currentId.value) return
detailLoading.value = true
detail.value = null
try {
detail.value = await counselingService.detail(currentId.value)
} finally {
detailLoading.value = false
}
}
async function cancel(s: any) {
try {
await showConfirmDialog({ title: '取消申请', message: '确认取消本次咨询申请?' })
} catch {
return
}
try {
await counselingService.cancel(s.id, '用户主动取消')
appStore.toastSuccess('已取消')
load()
} catch (err: any) {
appStore.toastError(err?.message || '取消失败,请稍后重试')
}
}
async function confirmCompanion(s: any) {
try {
await showConfirmDialog({ title: '确认参与婚前辅导', message: '确认作为伴侣参与本次婚前辅导?' })
} catch {
return
}
try {
await counselingService.confirmCompanion(s.id)
appStore.toastSuccess('已确认参与')
load()
} catch (err: any) {
appStore.toastError(err?.message || '确认失败,请稍后重试')
}
}
onMounted(load)
</script>
<style scoped>
.mc {
min-height: 100vh;
background: var(--mini-bg);
padding-bottom: 24px;
}
.mc__hero {
background: var(--mini-brand-gradient);
color: #fff;
padding: 18px 16px 20px;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
}
.mc__eyebrow {
font-size: 11px;
opacity: 0.8;
}
.mc__title {
font-size: 19px;
font-weight: 600;
margin-top: 4px;
}
.mc__desc {
font-size: 12px;
opacity: 0.85;
margin-top: 6px;
}
.mc__body {
padding: 0 16px;
}
.mc__card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 14px;
margin-top: 12px;
}
.mc__card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.mc__card-title {
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
}
.mc__meta {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 6px;
}
.mc__actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
.mc__notice {
font-size: 12px;
color: var(--mini-text-secondary);
line-height: 1.6;
background: rgba(47, 179, 109, 0.08);
border-radius: var(--mini-radius);
padding: 12px 14px;
margin-top: 16px;
}
.mc__detail {
padding: 18px 16px calc(18px + env(safe-area-inset-bottom));
}
.mc__detail-title {
font-size: 16px;
font-weight: 600;
color: var(--mini-text-strong);
margin-bottom: 8px;
}
.mc__detail-row {
display: flex;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid var(--mini-border);
}
.mc__detail-label {
flex: none;
width: 72px;
font-size: 13px;
color: var(--mini-text-secondary);
}
.mc__detail-value {
flex: 1;
font-size: 13px;
color: var(--mini-text-strong);
line-height: 1.5;
}
</style>
+425
View File
@@ -0,0 +1,425 @@
<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>
<!-- 列表 -->
<div class="discover__body">
<UiErrorState v-if="error" @retry="reload" />
<template v-else>
<UiLoading v-if="firstLoading" />
<template v-else>
<van-list
v-model:loading="loadingMore"
:finished="finished"
finished-text="没有更多了"
@load="onLoadMore"
>
<UiUserCard
v-for="u in users"
:key="u.userId"
:user="u"
show-actions
:loading="actionLoading === u.userId"
@tap="goDetail"
@like="onLike"
@pass="onPass"
/>
</van-list>
<UiEmpty v-if="!users.length" description="没有符合筛选条件的人" />
</template>
</template>
</div>
<!-- 筛选面板 -->
<van-popup v-model:show="showFilter" position="bottom" round :style="{ maxHeight: '80%' }">
<div class="filter">
<div class="filter__title">筛选条件</div>
<div class="filter__group">
<div class="filter__label">所在地区</div>
<div class="filter__chips">
<span
v-for="r in regions"
:key="r.code"
class="filter__chip"
:class="{ 'filter__chip--active': temp.regionCode === r.code }"
@click="toggleTemp('regionCode', r.code)"
>
{{ r.label }}
</span>
</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">
<span
v-for="e in educations"
:key="e.code"
class="filter__chip"
:class="{ 'filter__chip--active': temp.educationCode === e.code }"
@click="toggleTemp('educationCode', e.code)"
>
{{ e.label }}
</span>
</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>
</div>
<div class="filter__actions">
<van-button block plain @click="resetFilter">重置</van-button>
<van-button block type="primary" @click="applyFilter">应用筛选</van-button>
</div>
</div>
</van-popup>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { useMatchStore } from '@/stores/match'
import { useAppStore } from '@/stores/app'
import UiUserCard from '@/components/UiUserCard.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const router = useRouter()
const matchStore = useMatchStore()
const appStore = useAppStore()
const DEFAULT_MIN_AGE = '18'
const DEFAULT_MAX_AGE = '60'
const PAGE_SIZE = 20
const regions = [
{ code: '321181', label: '丹阳' },
{ code: '321100', label: '镇江' },
{ code: '320100', label: '南京' },
]
const educations = [
{ code: 'high_school', label: '高中及以下' },
{ code: 'college', label: '大专' },
{ code: 'bachelor', label: '本科' },
{ code: 'master', label: '硕士及以上' },
]
const maritalStatuses = [
{ code: 'never_married', label: '未婚' },
{ code: 'divorced', label: '离异' },
]
const keywordInput = ref('')
const applied = reactive({
keyword: '',
regionCode: '',
minAge: DEFAULT_MIN_AGE,
maxAge: DEFAULT_MAX_AGE,
educationCode: '',
maritalStatus: '',
})
const temp = reactive({ ...applied })
const users = ref<any[]>([])
const page = ref(1)
const firstLoading = ref(false)
const loadingMore = ref(false)
const finished = ref(false)
const error = ref(false)
const actionLoading = ref('')
const showFilter = ref(false)
const hasActiveFilter = computed(
() =>
!!applied.keyword ||
!!applied.regionCode ||
applied.minAge !== DEFAULT_MIN_AGE ||
applied.maxAge !== DEFAULT_MAX_AGE ||
!!applied.educationCode ||
!!applied.maritalStatus,
)
let storeCursor: string | null = null
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.educationCode) params.educationCode = applied.educationCode
if (applied.maritalStatus) params.maritalStatus = applied.maritalStatus
return params
}
async function fetchPage(reset: boolean) {
if (reset) {
page.value = 1
storeCursor = null
finished.value = false
firstLoading.value = true
error.value = false
}
try {
const result: any = await matchStore.discover(buildParams())
const items: any[] = result?.items || result?.records || []
if (reset) users.value = items
else users.value = [...users.value, ...items]
const totalPages = result?.totalPages || 0
const nextCursor = result?.nextCursor
if (nextCursor) {
// 游标分页:后端给出 nextCursor 时以游标续页
finished.value = false
storeCursor = nextCursor
} else if (page.value >= totalPages || items.length < PAGE_SIZE) {
finished.value = true
} else {
page.value += 1
}
} catch (err: any) {
if (reset) error.value = true
else finished.value = true
appStore.toastError(err?.message || '加载失败')
} finally {
firstLoading.value = false
loadingMore.value = false
}
}
function reload() {
fetchPage(true)
}
function onLoadMore() {
if (finished.value || firstLoading.value) return
loadingMore.value = true
if (storeCursor) {
// 使用游标续页
;(async () => {
try {
const result: any = await matchStore.discover({ ...buildParams(), cursor: storeCursor })
const items: any[] = result?.items || result?.records || []
users.value = [...users.value, ...items]
storeCursor = result?.nextCursor || null
if (!storeCursor && items.length < PAGE_SIZE) finished.value = true
} catch (err: any) {
finished.value = true
appStore.toastError(err?.message || '加载更多失败')
} finally {
loadingMore.value = false
}
})()
return
}
fetchPage(false)
}
function onSearch() {
applied.keyword = (keywordInput.value || '').trim()
reload()
}
function toggleLocal() {
applied.regionCode = applied.regionCode === '321181' ? '' : '321181'
temp.regionCode = applied.regionCode
reload()
}
function toggleTemp(field: 'regionCode' | 'educationCode' | 'maritalStatus', code: string) {
temp[field] = temp[field] === code ? '' : code
}
function applyFilter() {
applied.keyword = (keywordInput.value || '').trim()
applied.regionCode = temp.regionCode
applied.minAge = temp.minAge || DEFAULT_MIN_AGE
applied.maxAge = temp.maxAge || DEFAULT_MAX_AGE
applied.educationCode = temp.educationCode
applied.maritalStatus = temp.maritalStatus
showFilter.value = false
storeCursor = null
reload()
}
function resetFilter() {
temp.keyword = ''
temp.regionCode = ''
temp.minAge = DEFAULT_MIN_AGE
temp.maxAge = DEFAULT_MAX_AGE
temp.educationCode = ''
temp.maritalStatus = ''
keywordInput.value = ''
applied.keyword = ''
applied.regionCode = ''
applied.minAge = DEFAULT_MIN_AGE
applied.maxAge = DEFAULT_MAX_AGE
applied.educationCode = ''
applied.maritalStatus = ''
showFilter.value = false
storeCursor = null
reload()
}
function goDetail(u: any) {
if (u?.userId) router.push({ path: '/profile/detail', query: { id: u.userId } })
}
async function onLike(u: any) {
if (!u?.userId || actionLoading.value) return
actionLoading.value = u.userId
try {
const result: any = await matchStore.like(u.userId)
users.value = users.value.filter((x) => x.userId !== u.userId)
appStore.toastSuccess(result?.mutualMatch ? '双向意向匹配成功' : '已表达喜欢')
} catch (err: any) {
appStore.toastError(err?.message || '操作失败')
} finally {
actionLoading.value = ''
}
}
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>
<style scoped>
.discover {
min-height: 100vh;
background: var(--mini-bg);
}
.discover__chips {
display: flex;
gap: 8px;
padding: 0 16px 8px;
}
.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__chip--active {
color: var(--mini-tabbar-active);
border-color: var(--mini-tabbar-active);
background: rgba(244, 0, 90, 0.06);
}
.discover__body {
padding: 4px 16px 16px;
}
.filter {
padding: 20px 16px 24px;
}
.filter__title {
font-size: 16px;
font-weight: 600;
color: var(--mini-text-strong);
margin-bottom: 16px;
}
.filter__group {
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__chip {
font-size: 12px;
padding: 6px 14px;
border-radius: 14px;
background: var(--mini-bg);
color: var(--mini-text);
border: 1px solid var(--mini-border);
}
.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);
}
.filter__actions {
display: flex;
gap: 12px;
margin-top: 8px;
}
</style>
+878
View File
@@ -0,0 +1,878 @@
<template>
<div class="home">
<!-- 品牌区(夜间渐变) -->
<div class="home__hero">
<div class="home__brand">
<div class="home__brand-lock">
<div class="home__logo">善</div>
<div>
<div class="home__brand-name">善缘丹阳</div>
<div class="home__brand-slogan">公益婚恋服务平台</div>
</div>
</div>
<div class="home__notice" @click="router.push('/notifications')">
<van-icon name="bell" size="18" />
<span>通知</span>
</div>
</div>
<!-- 用户状态条 -->
<div class="home__status">
<template v-if="showOnboarding">
<div class="home__status-main">
<div class="home__status-title">完善资料,开启安心相遇</div>
<div class="home__status-desc">{{ onboardingMessage }}</div>
</div>
<van-button size="small" type="primary" round @click="runOnboardingAction">
{{ onboardingAction.text }}
</van-button>
</template>
<template v-else>
<span
v-for="c in statusChecks"
:key="c.label"
class="home__check"
:class="{ 'home__check--done': c.done }"
>
{{ c.done ? '已完成' : '待完成' }} · {{ c.label }}
</span>
</template>
</div>
<!-- Banner(仅内部跳转,AGENTS 第 16 条) -->
<div class="home__banner">
<van-swipe v-if="banners.length" :autoplay="4500" indicator-color="#FF4F91" class="home__swipe">
<van-swipe-item v-for="b in banners" :key="b.id">
<div class="home__banner-card" @click="onBannerTap(b)">
<div class="home__banner-cover">{{ (b.title || '善').slice(0, 1) }}</div>
<div class="home__banner-copy">
<div class="home__banner-kicker">公益 · 本地 · 真实 · 安全</div>
<div class="home__banner-title">{{ b.title }}</div>
<div class="home__banner-sub">{{ b.subtitle }}</div>
</div>
</div>
</van-swipe-item>
</van-swipe>
<div v-else class="home__banner-card">
<div class="home__banner-cover">缘</div>
<div class="home__banner-copy">
<div class="home__banner-kicker">公益 · 本地 · 真实 · 安全</div>
<div class="home__banner-title">在丹阳,遇见更好的你</div>
<div class="home__banner-sub">认真认识,温暖相伴</div>
</div>
</div>
</div>
</div>
<!-- 8 个核心入口(顺序固定,AGENTS 第 14/15 条) -->
<div class="home__entries">
<div
v-for="e in quickEntries"
:key="e.code"
class="home__entry"
:class="{ 'home__entry--disabled': !e.enabled }"
@click="onEntryTap(e)"
>
<div class="home__entry-icon" :class="`tone-${toneOf(e.code)}`">{{ (e.label || '善').slice(0, 1) }}</div>
<div class="home__entry-label">{{ e.label }}</div>
<div class="home__entry-sub">{{ subOf(e.code) }}</div>
</div>
</div>
<div class="home__body">
<UiErrorState v-if="error" @retry="load" />
<UiLoading v-else-if="loading" />
<template v-else>
<!-- 今日推荐 -->
<UiSectionHeader title="今日推荐" more-text="查看更多" @more="router.push('/discover')" />
<div v-if="recommendations.length" class="home__rec">
<div v-for="r in recommendations" :key="r.userId" class="home__rec-row">
<UiAvatar :name="r.displayName || '缘'" :src="r.avatarUrl" :size="48" />
<div
class="home__rec-main"
@click="router.push({ path: '/profile/detail', query: { id: r.userId } })"
>
<div class="home__rec-name">{{ r.displayName || '推荐对象' }}</div>
<div class="home__rec-meta">
<span v-if="r.age">{{ r.age }}岁</span>
<span v-if="r.heightCm">{{ r.heightCm }}cm</span>
<span v-if="r.regionName">{{ r.regionName }}</span>
</div>
<div v-if="r.recommendationReasons && r.recommendationReasons.length" class="home__rec-reasons">
{{ r.recommendationReasons.slice(0, 3).join(' · ') }}
</div>
</div>
<div class="home__rec-actions">
<van-button size="mini" type="primary" :loading="actionLoading === r.userId" @click.stop="onLike(r)">
喜欢
</van-button>
<van-button size="mini" plain @click.stop="onPass(r)">略过</van-button>
</div>
</div>
</div>
<UiEmpty v-else description="暂时没有完全符合双方条件的人" />
<!-- 善缘公益服务 -->
<UiSectionHeader title="善缘公益服务" />
<div v-if="serviceRecommendations.length" class="home__rail">
<div
v-for="s in serviceRecommendations"
:key="s.type"
class="home__mini"
@click="onServiceTap(s.type)"
>
<div class="home__mini-cover" :class="`tone-${toneOf(s.type)}`">{{ (s.title || '善').slice(0, 1) }}</div>
<div class="home__mini-title">{{ s.title }}</div>
<div class="home__mini-sub">{{ s.ctaText }}</div>
</div>
</div>
<div v-else class="home__empty-line">暂无待推荐的公益服务</div>
<!-- 近期公益活动 -->
<UiSectionHeader title="近期公益活动" more-text="全部活动" @more="router.push('/activity')" />
<div v-if="activities.length" class="home__rail">
<div
v-for="a in activities"
:key="a.id"
class="home__act"
@click="router.push({ path: '/activity/detail', query: { id: a.id } })"
>
<div class="home__act-cover">{{ (a.title || '活').slice(0, 1) }}</div>
<div class="home__act-info">
<div class="home__act-tag">公益活动</div>
<div class="home__act-title">{{ a.title }}</div>
<div class="home__act-meta">{{ a.locationName }}</div>
</div>
</div>
</div>
<div v-else class="home__empty-line">近期暂无可报名活动</div>
<!-- 善缘服务站 -->
<UiSectionHeader title="善缘服务站" />
<div v-if="serviceLocation" class="home__station" @click="router.push('/service-station')">
<div class="home__station-icon">站</div>
<div class="home__station-info">
<div class="home__station-name">{{ serviceLocation.name }}</div>
<div class="home__station-desc">
{{ serviceLocation.shortName || serviceLocation.address }}
</div>
<div v-if="serviceLocation.services && serviceLocation.services.length" class="home__station-tags">
<UiTag v-for="s in serviceLocation.services" :key="s" type="primary">{{ s }}</UiTag>
</div>
</div>
<van-icon name="arrow" color="#9aa0b5" />
</div>
<div v-else class="home__empty-line" @click="router.push('/service-station')">
查看线下公益服务阵地
</div>
<!-- 婚恋课堂 -->
<UiSectionHeader title="婚恋课堂" more-text="更多内容" @more="router.push('/content')" />
<div v-if="articles.length" class="home__articles">
<div
v-for="art in articles"
:key="art.id"
class="home__article"
@click="router.push({ path: '/content/detail', query: { id: art.id } })"
>
<div class="home__article-cover">{{ (art.title || '课').slice(0, 1) }}</div>
<div class="home__article-main">
<div class="home__article-title">{{ art.title }}</div>
<div class="home__article-meta">善缘课堂 · 公益内容</div>
</div>
<van-icon name="arrow" color="#9aa0b5" />
</div>
</div>
<div v-else class="home__empty-line">暂无新内容,稍后再来看看</div>
<!-- 善缘安全提醒 -->
<UiSectionHeader title="善缘安全提醒" />
<div class="home__safety" @click="router.push('/anti-fraud')">
<div class="home__safety-icon">盾</div>
<div class="home__safety-text">{{ safetyNotice.text }}</div>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { homeService, recommendationService } from '@/api/services'
import { useMatchStore } from '@/stores/match'
import { useAppStore } from '@/stores/app'
import { useUserStore } from '@/stores/user'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
import UiAvatar from '@/components/UiAvatar.vue'
import UiTag from '@/components/UiTag.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const router = useRouter()
const matchStore = useMatchStore()
const appStore = useAppStore()
const userStore = useUserStore()
/** 入口 code → H5 路由(顺序固定,仅做映射) */
const ENTRY_ROUTES: Record<string, string> = {
find_object: '/discover',
matchmaker: '/matchmaker',
activities: '/activity',
counseling: '/counseling',
classroom: '/content',
premarital: '/premarital',
anti_fraud: '/anti-fraud',
service_station: '/service-station',
}
/** serviceRecommendation.type 也复用同一映射(matchmaker/counseling/premarital) */
const ENTRY_TONES: Record<string, string> = {
find_object: 'rose',
matchmaker: 'orange',
activities: 'violet',
counseling: 'blue',
classroom: 'purple',
premarital: 'pink',
anti_fraud: 'green',
service_station: 'amber',
}
const ENTRY_SUBS: Record<string, string> = {
find_object: '真实邂逅',
matchmaker: '官方牵线',
activities: '线下联谊',
counseling: '专业指导',
classroom: '提升自我',
premarital: '幸福起航',
anti_fraud: '守护真爱',
service_station: '就在身边',
}
/** 固定 8 入口默认值(服务端未返回时兜底;顺序不可变更) */
const DEFAULT_ENTRIES = [
{ code: 'find_object', label: '找对象', enabled: true },
{ code: 'matchmaker', label: '公益红娘', enabled: true },
{ code: 'activities', label: '公益活动', enabled: true },
{ code: 'counseling', label: '婚恋咨询', enabled: true },
{ code: 'classroom', label: '婚恋课堂', enabled: true },
{ code: 'premarital', label: '婚前辅导', enabled: true },
{ code: 'anti_fraud', label: '婚恋反诈', enabled: true },
{ code: 'service_station', label: '善缘服务站', enabled: true },
]
const loading = ref(true)
const error = ref(false)
const actionLoading = ref('')
const home = ref<any>(null)
const banners = computed(() => home.value?.banners || [])
const recommendations = computed<any[]>(() => home.value?.recommendations || [])
const serviceRecommendations = computed<any[]>(() => home.value?.serviceRecommendations || [])
const activities = computed<any[]>(() => home.value?.activities || [])
const articles = computed<any[]>(() => home.value?.articles || [])
const serviceLocation = computed(() => home.value?.serviceLocation || null)
const safetyNotice = computed(
() =>
home.value?.safetyNotice || {
text: '实名认证和人工审核可以提高信息真实性,交往过程中仍请谨慎涉及借款、投资和个人隐私。',
ctaText: '查看婚恋反诈指南',
},
)
const quickEntries = computed<any[]>(() => {
const server = home.value?.quickEntries
if (!Array.isArray(server) || !server.length) return DEFAULT_ENTRIES
return DEFAULT_ENTRIES.map((def) => {
const found = server.find((s: any) => s.code === def.code)
return found ? { ...def, ...found } : { ...def, enabled: false, disabledReason: '服务暂未开放' }
})
})
const userState = computed(() => home.value?.userState || null)
const statusChecks = computed(() => {
const us = userState.value
return [
{ label: '已实名', done: !!us?.verified },
{ label: '本地资格', done: us?.localEligibilityStatus === 'verified' },
{ label: '资料已审核', done: us?.profileStatus === 'approved' || us?.profileStatus === 'completed' },
]
})
const showOnboarding = computed(() => {
const us = userState.value
return !us || !us.verified || (us.profileStatus !== 'approved' && us.profileStatus !== 'completed')
})
const onboardingMessage = computed(() => {
const us = userState.value
if (!us) return '登录后即可开始寻找合适的人'
if (!us.verified) return '完成实名认证后,即可继续完善资料并使用公益婚恋服务'
if (us.profileStatus === 'pending_review') return '资料正在人工审核,请耐心等待审核结果'
if (us.localEligibilityStatus === 'not_declared') return '完成本地资格声明后即可开始寻找合适的人'
if (us.localEligibilityStatus === 'needs_evidence') return '补充本地资格佐证后即可开始寻找合适的人'
return '资料审核通过后即可开始寻找合适的人'
})
const onboardingAction = computed(() => {
const us = userState.value
if (!us) return { text: '去登录', route: '/login' }
if (us.verified !== true) return { text: '去实名认证', route: '/verification' }
if (us.localEligibilityStatus === 'not_declared' || us.localEligibilityStatus === 'needs_evidence') {
return { text: '声明本地资格', route: '/local-eligibility' }
}
if (us.profileStatus !== 'approved' && us.profileStatus !== 'completed') {
return { text: '完善资料', route: '/profile/basic' }
}
return { text: '开始寻找', route: '/discover' }
})
function toneOf(code: string): string {
return ENTRY_TONES[code] || 'blue'
}
function subOf(code: string): string {
return ENTRY_SUBS[code] || ''
}
async function load() {
loading.value = true
error.value = false
try {
home.value = await homeService.getHome()
// 同步用户状态快照,保证与守卫判定一致
const us = home.value?.userState
if (us) userStore.userState = { ...(userStore.userState || {}), ...us }
await enrichRecommendations()
} catch {
error.value = true
} finally {
loading.value = false
}
}
/**
* 推荐卡片需要姓名/年龄/身高/地区等展示字段,而后端 /home 聚合只返回
* userId + recommendationLevel + recommendationReasons,故按 userId 拉取
* 资料详情补齐展示字段;单个失败不影响首页主流程。
*/
async function enrichRecommendations() {
const list = home.value?.recommendations
if (!Array.isArray(list) || !list.length) return
await Promise.all(
list.map(async (r: any) => {
try {
const detail: any = await recommendationService.getUserDetail(r.userId)
if (!detail) return
r.displayName = detail.displayName || r.displayName
r.avatarUrl = detail.avatarUrl || r.avatarUrl
r.age = detail.age
r.heightCm = detail.heightCm
r.regionName = detail.regionName
} catch {
/* 展示层补齐失败不影响首页主流程 */
}
}),
)
}
function runOnboardingAction() {
router.push(onboardingAction.value.route)
}
function onBannerTap(b: any) {
const type = b?.targetType
const value = b?.targetValue
switch (type) {
case 'activity':
if (value) router.push({ path: '/activity/detail', query: { id: value } })
break
case 'matchmaker':
router.push('/matchmaker')
break
case 'counseling':
router.push('/counseling')
break
case 'premarital':
router.push('/premarital')
break
case 'service_location':
router.push('/service-station')
break
case 'article':
router.push({ path: '/content/detail', query: { id: value } })
break
default:
break
}
}
function onEntryTap(e: any) {
if (!e.enabled) {
appStore.toast(e.disabledReason || '暂不可使用此功能')
return
}
const route = ENTRY_ROUTES[e.code]
if (route) router.push(route)
}
function onServiceTap(type: string) {
const route = ENTRY_ROUTES[type]
if (route) router.push(route)
}
async function onLike(r: any) {
if (!r?.userId || actionLoading.value) return
actionLoading.value = r.userId
try {
const result: any = await matchStore.like(r.userId)
home.value.recommendations = recommendations.value.filter((x) => x.userId !== r.userId)
appStore.toastSuccess(result?.mutualMatch ? '双向意向匹配成功,可查看公益服务' : '已表达喜欢')
} catch (err: any) {
appStore.toastError(err?.message || '操作失败')
} finally {
actionLoading.value = ''
}
}
async function onPass(r: any) {
if (!r?.userId || actionLoading.value) return
actionLoading.value = r.userId
try {
await matchStore.pass(r.userId)
home.value.recommendations = recommendations.value.filter((x) => x.userId !== r.userId)
appStore.toastSuccess('已略过')
} catch (err: any) {
appStore.toastError(err?.message || '操作失败')
} finally {
actionLoading.value = ''
}
}
onMounted(load)
</script>
<style scoped>
.home {
min-height: 100vh;
background: var(--mini-bg);
padding-bottom: 16px;
}
/* 品牌区 */
.home__hero {
background: var(--mini-brand-gradient);
padding: 16px 16px 0;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
padding-bottom: 20px;
}
.home__brand {
display: flex;
align-items: center;
justify-content: space-between;
color: #fff;
}
.home__brand-lock {
display: flex;
align-items: center;
gap: 10px;
}
.home__logo {
width: 38px;
height: 38px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.18);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
}
.home__brand-name {
font-size: 17px;
font-weight: 600;
letter-spacing: 1px;
}
.home__brand-slogan {
font-size: 11px;
opacity: 0.8;
}
.home__notice {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
background: rgba(255, 255, 255, 0.16);
padding: 6px 12px;
border-radius: 16px;
}
/* 状态条 */
.home__status {
margin-top: 14px;
background: rgba(255, 255, 255, 0.14);
border-radius: var(--mini-radius);
padding: 12px 14px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
color: #fff;
}
.home__status-main {
flex: 1;
min-width: 0;
}
.home__status-title {
font-size: 14px;
font-weight: 600;
}
.home__status-desc {
font-size: 12px;
opacity: 0.85;
margin-top: 2px;
}
.home__check {
font-size: 12px;
padding: 3px 10px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.16);
opacity: 0.9;
}
.home__check--done {
background: rgba(47, 179, 109, 0.28);
}
/* Banner */
.home__banner {
margin-top: 14px;
}
.home__swipe {
border-radius: var(--mini-radius);
overflow: hidden;
}
.home__banner-card {
position: relative;
height: 150px;
border-radius: var(--mini-radius);
overflow: hidden;
background: linear-gradient(135deg, #2a3a86 0%, #4b2a6b 100%);
display: flex;
align-items: flex-end;
}
.home__banner-cover {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 72px;
font-weight: 700;
color: rgba(255, 255, 255, 0.14);
}
.home__banner-copy {
position: relative;
padding: 16px;
color: #fff;
}
.home__banner-kicker {
font-size: 11px;
opacity: 0.8;
margin-bottom: 4px;
}
.home__banner-title {
font-size: 18px;
font-weight: 600;
}
.home__banner-sub {
font-size: 12px;
opacity: 0.85;
margin-top: 2px;
}
/* 8 入口 */
.home__entries {
margin: -24px 12px 0;
background: var(--mini-surface);
border-radius: var(--mini-radius-lg);
box-shadow: var(--mini-shadow-strong);
padding: 14px 8px;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px 4px;
position: relative;
z-index: 2;
}
.home__entry {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.home__entry--disabled {
opacity: 0.4;
}
.home__entry-icon {
width: 44px;
height: 44px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 18px;
font-weight: 600;
}
.home__entry-label {
font-size: 12px;
color: var(--mini-text-strong);
font-weight: 500;
}
.home__entry-sub {
font-size: 10px;
color: var(--mini-text-muted);
}
/* 内容区 */
.home__body {
padding: 0 16px;
}
.home__rail {
display: flex;
gap: 10px;
overflow-x: auto;
padding-bottom: 4px;
}
.home__mini {
flex: none;
width: 130px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 10px;
box-shadow: var(--mini-shadow);
}
.home__mini-cover {
width: 36px;
height: 36px;
border-radius: 10px;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
margin-bottom: 6px;
}
.home__mini-title {
font-size: 13px;
font-weight: 600;
color: var(--mini-text-strong);
}
.home__mini-sub {
font-size: 11px;
color: var(--mini-text-secondary);
}
.home__rec-row {
display: flex;
align-items: center;
gap: 10px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 12px;
margin-bottom: 10px;
box-shadow: var(--mini-shadow);
}
.home__rec-main {
flex: 1;
min-width: 0;
}
.home__rec-name {
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
}
.home__rec-meta {
display: flex;
gap: 10px;
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 2px;
}
.home__rec-reasons {
font-size: 12px;
color: var(--mini-tabbar-active);
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.home__rec-actions {
display: flex;
flex-direction: column;
gap: 6px;
}
.home__act {
flex: none;
width: 190px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
overflow: hidden;
}
.home__act-cover {
height: 84px;
background: linear-gradient(135deg, #6a5acd 0%, #8367c7 100%);
color: rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
font-size: 30px;
font-weight: 700;
}
.home__act-info {
padding: 8px 10px 10px;
}
.home__act-tag {
font-size: 10px;
color: var(--mini-tabbar-active);
}
.home__act-title {
font-size: 13px;
font-weight: 600;
color: var(--mini-text-strong);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.home__act-meta {
font-size: 11px;
color: var(--mini-text-secondary);
}
.home__station {
display: flex;
align-items: center;
gap: 12px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 14px;
box-shadow: var(--mini-shadow);
}
.home__station-icon {
width: 46px;
height: 46px;
border-radius: 12px;
background: linear-gradient(135deg, #2fb36d 0%, #1f8a52 100%);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
flex: none;
}
.home__station-info {
flex: 1;
min-width: 0;
}
.home__station-name {
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
}
.home__station-desc {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 2px;
}
.home__station-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.home__article {
display: flex;
align-items: center;
gap: 10px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 10px;
margin-bottom: 8px;
box-shadow: var(--mini-shadow);
}
.home__article-cover {
width: 44px;
height: 44px;
border-radius: 10px;
background: linear-gradient(135deg, #f5a623 0%, #e08b00 100%);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex: none;
}
.home__article-main {
flex: 1;
min-width: 0;
}
.home__article-title {
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
}
.home__article-meta {
font-size: 11px;
color: var(--mini-text-secondary);
}
.home__safety {
display: flex;
gap: 10px;
background: rgba(255, 79, 145, 0.06);
border: 1px solid rgba(255, 79, 145, 0.16);
border-radius: var(--mini-radius);
padding: 14px;
margin-bottom: 20px;
}
.home__safety-icon {
width: 30px;
height: 30px;
border-radius: 8px;
background: var(--mini-accent-pink);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex: none;
}
.home__safety-text {
font-size: 12px;
color: var(--mini-text);
line-height: 1.6;
}
.home__empty-line {
font-size: 13px;
color: var(--mini-text-muted);
text-align: center;
padding: 16px 0;
}
/* 入口渐变砖色相(与 8 入口一致) */
.tone-rose {
background: linear-gradient(135deg, #ff5f8f 0%, #e0326a 100%);
}
.tone-orange {
background: linear-gradient(135deg, #ff9a52 0%, #ef6d1f 100%);
}
.tone-violet {
background: linear-gradient(135deg, #8a6cff 0%, #6244e0 100%);
}
.tone-blue {
background: linear-gradient(135deg, #4f8cff 0%, #2f63e0 100%);
}
.tone-purple {
background: linear-gradient(135deg, #b06cff 0%, #8a3ee0 100%);
}
.tone-pink {
background: linear-gradient(135deg, #ff7ab0 0%, #e0559a 100%);
}
.tone-green {
background: linear-gradient(135deg, #3fc98a 0%, #199e64 100%);
}
.tone-amber {
background: linear-gradient(135deg, #f5a623 0%, #d98a10 100%);
}
</style>
@@ -0,0 +1,217 @@
<template>
<PageLayout title="本地资格">
<div class="le">
<!-- 状态卡 -->
<UiLoading v-if="loading" text="正在读取资格状态…" />
<UiErrorState v-else-if="loadError" message="状态加载失败,请重试" @retry="checkStatus" />
<template v-else>
<div class="le__status" :class="`le__status--${statusTone}`">
<span class="le__status-label">当前状态</span>
<UiTag :type="statusTag">{{ statusText }}</UiTag>
</div>
<div class="le__header">
<div class="le__title">为什么需要确认本地资格?</div>
<div class="le__desc">
善缘丹阳主要服务在丹阳工作、居住或拥有丹阳户籍的适龄单身人士。
本地资格确认帮助我们更好地匹配适合你身边的伴侣。
</div>
</div>
<div class="le__section">
<div class="le__section-title">请选择你的本地资格</div>
<div class="le__types">
<div
v-for="t in eligibilityTypes"
:key="t.value"
class="le__type"
:class="{ 'le__type--active': eligibilityType === t.value }"
@click="onTypeSelect(t.value)"
>
<div class="le__type-icon">{{ t.icon }}</div>
<div class="le__type-label">{{ t.label }}</div>
</div>
</div>
</div>
<div class="le__notice">
只需声明一种资格即可。如需补充佐证材料,系统会在审核后告知你。
</div>
<div class="le__actions">
<van-button plain block @click="goToMyEligibility">查看我已有的资格</van-button>
</div>
</template>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { localEligibilityService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiErrorState from '@/components/UiErrorState.vue'
import UiTag from '@/components/UiTag.vue'
const router = useRouter()
const appStore = useAppStore()
const loading = ref(true)
const loadError = ref(false)
const status = ref('not_declared')
const eligibilityType = ref('')
const eligibilityTypes = [
{ value: 'household_registration', label: '丹阳户籍', icon: '户' },
{ value: 'employment', label: '在丹阳工作', icon: '工' },
{ value: 'residence', label: '在丹阳长期居住', icon: '居' },
{ value: 'other', label: '其他符合服务条件', icon: '其' },
]
const STATUS_TEXT: Record<string, string> = {
not_declared: '尚未声明',
declared: '声明已保存',
needs_evidence: '需要补充佐证',
pending_review: '审核中',
verified: '已核验',
rejected: '未通过',
}
const statusText = computed(() => STATUS_TEXT[status.value] || '尚未声明')
const statusTag = computed<'primary' | 'success' | 'warning' | 'danger' | 'info'>(() => {
if (status.value === 'verified') return 'success'
if (status.value === 'rejected') return 'danger'
if (status.value === 'pending_review' || status.value === 'needs_evidence') return 'warning'
if (status.value === 'declared') return 'primary'
return 'info'
})
const statusTone = computed(() => (status.value === 'verified' ? 'ok' : status.value === 'rejected' ? 'bad' : 'warn'))
async function checkStatus() {
loading.value = true
loadError.value = false
try {
const result: any = await localEligibilityService.getStatus()
status.value = result?.verificationStatus || 'not_declared'
eligibilityType.value = result?.eligibilityType || ''
} catch {
loadError.value = true
} finally {
loading.value = false
}
}
function onTypeSelect(type: string) {
router.push({ path: '/local-eligibility/supplement', query: { type } })
}
function goToMyEligibility() {
if (status.value === 'needs_evidence' || status.value === 'rejected' || status.value === 'declared') {
router.push({ path: '/local-eligibility/supplement', query: { type: eligibilityType.value || '' } })
return
}
appStore.toast(statusText.value)
}
onMounted(checkStatus)
</script>
<style scoped>
.le {
padding: 16px;
}
.le__status {
display: flex;
align-items: center;
justify-content: space-between;
border-radius: var(--mini-radius);
padding: 12px 14px;
margin-bottom: 14px;
background: var(--mini-surface);
box-shadow: var(--mini-shadow);
}
.le__status--ok {
background: rgba(47, 179, 109, 0.1);
}
.le__status--warn {
background: rgba(245, 166, 35, 0.12);
}
.le__status--bad {
background: rgba(240, 72, 62, 0.1);
}
.le__status-label {
font-size: 13px;
color: var(--mini-text-secondary);
}
.le__header {
padding: 4px 4px 12px;
}
.le__title {
font-size: 17px;
font-weight: 600;
color: var(--mini-text-strong);
}
.le__desc {
font-size: 13px;
color: var(--mini-text-secondary);
line-height: 1.8;
margin-top: 8px;
}
.le__section-title {
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
margin-bottom: 10px;
}
.le__types {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.le__type {
display: flex;
align-items: center;
gap: 10px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 14px;
box-shadow: var(--mini-shadow);
border: 1px solid transparent;
}
.le__type--active {
border-color: var(--mini-tabbar-active);
}
.le__type-icon {
width: 36px;
height: 36px;
border-radius: 10px;
background: var(--mini-brand-gradient);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 600;
flex: none;
}
.le__type-label {
font-size: 13px;
color: var(--mini-text-strong);
}
.le__notice {
font-size: 12px;
color: var(--mini-text-secondary);
background: rgba(79, 140, 255, 0.06);
border-radius: var(--mini-radius);
padding: 12px 14px;
margin-top: 14px;
line-height: 1.7;
}
.le__actions {
margin-top: 18px;
}
</style>
@@ -0,0 +1,326 @@
<template>
<PageLayout title="本地资格核验">
<div class="les">
<div class="les__header">
<div class="les__title">补充本地资格佐证</div>
<div class="les__desc">
请补充一项能够说明你在丹阳工作或居住的材料。材料仅用于资格审核,不对其他用户展示。
</div>
</div>
<div v-if="eligibilityType" class="les__type-tag">
<UiTag type="primary">{{ typeLabel(eligibilityType) }}</UiTag>
</div>
<div class="les__section">
<div class="les__section-title">选择佐证类型</div>
<div class="les__evidence-list">
<div
v-for="e in evidenceTypes"
:key="e.value"
class="les__evidence"
:class="{ 'les__evidence--active': selectedType === e.value }"
@click="selectedType = e.value"
>
<div class="les__evidence-label">{{ e.label }}</div>
<div class="les__evidence-desc">{{ e.desc }}</div>
</div>
</div>
</div>
<div class="les__section">
<div class="les__section-title">上传佐证材料</div>
<div class="les__upload">
<div class="les__upload-hint">支持 JPG/PNG,最多 {{ maxFiles }} 张</div>
<div class="les__grid">
<div v-for="(f, index) in files" :key="f.fileId" class="les__item">
<img :src="f.previewUrl" class="les__preview" alt="" />
<div class="les__remove" @click="onRemove(index)">×</div>
</div>
<div v-if="files.length < maxFiles" class="les__add" @click="onChooseFile">
<span class="les__add-icon">+</span>
<span class="les__add-text">上传</span>
</div>
</div>
</div>
<input
ref="fileInput"
type="file"
accept="image/*"
class="les__hidden-input"
@change="onFileChange"
/>
</div>
<div class="les__privacy">材料仅用于资格审核,不对其他用户展示</div>
<div class="les__actions">
<van-button
type="primary"
block
round
:loading="submitting"
:disabled="submitting || uploading"
@click="onSubmit"
>
{{ submitting ? '提交中…' : '提交审核' }}
</van-button>
</div>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { localEligibilityService, profileService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiTag from '@/components/UiTag.vue'
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const fileInput = ref<HTMLInputElement | null>(null)
const eligibilityType = ref(typeof route.query.type === 'string' ? route.query.type : '')
const selectedType = ref('')
const files = ref<Array<{ fileId: string; previewUrl: string }>>([])
const uploading = ref(false)
const submitting = ref(false)
const maxFiles = 5
const TYPE_LABELS: Record<string, string> = {
household_registration: '丹阳户籍',
employment: '在丹阳工作',
residence: '在丹阳长期居住',
other: '其他符合服务条件',
}
const evidenceTypes = [
{ value: 'employment_proof', label: '工作证明', desc: '劳动合同、工牌或在职证明等' },
{ value: 'residence_proof', label: '居住证明', desc: '房产证、租赁合同或水电账单等' },
{ value: 'household_proof', label: '户籍证明', desc: '户口本或户籍页等' },
{ value: 'other', label: '其他材料', desc: '能够说明你在丹阳生活或工作的材料' },
]
function typeLabel(code: string): string {
return TYPE_LABELS[code] || code
}
async function loadStatus() {
try {
const result: any = await localEligibilityService.getStatus()
if (!eligibilityType.value && result?.eligibilityType) eligibilityType.value = result.eligibilityType
const evidences = result?.evidences || []
files.value = evidences
.filter((e: any) => e.fileId)
.map((e: any) => ({ fileId: e.fileId, previewUrl: e.url || e.fileUrl || '' }))
} catch {
/* 允许重新提交 */
}
}
function onChooseFile() {
if (!selectedType.value) {
appStore.toast('请先选择佐证类型')
return
}
if (files.value.length >= maxFiles) {
appStore.toast(`最多上传 ${maxFiles} 份材料`)
return
}
fileInput.value?.click()
}
async function onFileChange(evt: Event) {
const input = evt.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
if (files.value.length >= maxFiles) {
appStore.toast(`最多上传 ${maxFiles} 份材料`)
return
}
uploading.value = true
try {
const token: any = await profileService.requestUploadToken({
fileName: `eligibility_${Date.now()}_${file.name}`,
contentType: file.type || 'image/jpeg',
})
await fetch(token.uploadUrl, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type || 'image/jpeg' },
})
await localEligibilityService.uploadEvidence({
evidenceType: selectedType.value,
fileId: token.fileKey,
})
files.value = [...files.value, { fileId: token.fileKey, previewUrl: URL.createObjectURL(file) }]
appStore.toastSuccess('材料已上传')
} catch (err: any) {
appStore.toastError(err?.message || '上传失败,请稍后重试')
} finally {
uploading.value = false
}
}
function onRemove(index: number) {
files.value = files.value.filter((_, i) => i !== index)
}
async function onSubmit() {
if (!eligibilityType.value) {
appStore.toast('请先选择本地资格类型')
return
}
submitting.value = true
try {
await localEligibilityService.declare({ eligibilityType: eligibilityType.value, regionCode: '321181' })
await localEligibilityService.submit()
appStore.toastSuccess('已提交审核')
router.replace('/local-eligibility')
} catch (err: any) {
appStore.toastError(err?.message || '提交失败,请重试')
} finally {
submitting.value = false
}
}
onMounted(loadStatus)
</script>
<style scoped>
.les {
padding: 16px;
}
.les__header {
padding: 4px 4px 12px;
}
.les__title {
font-size: 18px;
font-weight: 600;
color: var(--mini-text-strong);
}
.les__desc {
font-size: 13px;
color: var(--mini-text-secondary);
line-height: 1.8;
margin-top: 8px;
}
.les__type-tag {
margin: 4px 0 12px;
}
.les__section {
margin-top: 12px;
}
.les__section-title {
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
margin-bottom: 10px;
}
.les__evidence-list {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.les__evidence {
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 12px;
box-shadow: var(--mini-shadow);
border: 1px solid transparent;
}
.les__evidence--active {
border-color: var(--mini-tabbar-active);
}
.les__evidence-label {
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
}
.les__evidence-desc {
font-size: 11px;
color: var(--mini-text-secondary);
margin-top: 4px;
line-height: 1.6;
}
.les__upload {
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 12px;
box-shadow: var(--mini-shadow);
}
.les__upload-hint {
font-size: 12px;
color: var(--mini-text-muted);
margin-bottom: 10px;
}
.les__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.les__item {
position: relative;
padding-top: 100%;
border-radius: 10px;
overflow: hidden;
background: var(--mini-bg);
}
.les__preview {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.les__remove {
position: absolute;
top: 2px;
right: 2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 14px;
line-height: 20px;
text-align: center;
}
.les__add {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding-top: 100%;
position: relative;
border: 1px dashed var(--mini-border, #dcdfe6);
border-radius: 10px;
color: var(--mini-text-secondary);
}
.les__add-icon {
position: absolute;
top: 30%;
font-size: 24px;
}
.les__add-text {
position: absolute;
top: 58%;
font-size: 12px;
}
.les__hidden-input {
display: none;
}
.les__privacy {
font-size: 12px;
color: var(--mini-text-muted);
margin-top: 14px;
text-align: center;
}
.les__actions {
margin-top: 18px;
}
</style>
@@ -0,0 +1,126 @@
<template>
<PageLayout title="公益红娘协助授权">
<div class="ma">
<div class="card">
<div class="ma__title">公益红娘协助授权说明</div>
<div class="ma__tag">当前为演示测试稿,正式版本以法务审核文件为准</div>
<div class="ma__content">
<p>
为帮助你更顺利地了解身边的公益服务与线下活动,你同意公益红娘在你授权范围内提供协助:
了解你的婚恋诉求、协助你参与公益活动、并在必要时为你推荐合适的服务。
</p>
<p>
红娘只能在授权范围内提供协助,不能替你进行喜欢/略过等操作,不能查看你的私人聊天,
也不会将你的联系方式提供给任何未获授权的一方。
</p>
<p>
你可随时在「红娘联系授权」中撤回授权,撤回后红娘将停止相应协助。
</p>
</div>
</div>
<div v-if="!targetUserId" class="ma__warn">
未获取到目标用户,无法提交协助申请。
</div>
<div class="ma__actions">
<van-button plain block :disabled="submitting" @click="onCancel">暂不申请</van-button>
<van-button
type="primary"
block
:loading="submitting"
:disabled="submitting || !targetUserId"
@click="onAgree"
>
同意并申请
</van-button>
</div>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { matchmakerService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const targetUserId = computed(() => (typeof route.query.targetUserId === 'string' ? route.query.targetUserId : ''))
const submitting = ref(false)
async function onAgree() {
if (!targetUserId.value || submitting.value) return
submitting.value = true
try {
await matchmakerService.applyService({
serviceType: 'recommendation_assistance',
targetUserId: targetUserId.value,
description: '希望公益红娘协助了解公益活动和线下服务',
authorizationAccepted: true,
})
appStore.toastSuccess('申请已提交')
router.replace('/matchmaker/progress')
} catch (err: any) {
appStore.toastError(err?.message || '申请失败,请稍后重试')
} finally {
submitting.value = false
}
}
function onCancel() {
if (window.history.length > 1) router.back()
else router.replace('/matchmaker')
}
</script>
<style scoped>
.ma {
padding: 16px;
}
.card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 16px;
}
.ma__title {
font-size: 17px;
font-weight: 600;
color: var(--mini-text-strong);
}
.ma__tag {
font-size: 11px;
color: var(--mini-warning, #b9770e);
background: rgba(245, 166, 35, 0.14);
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
margin-top: 8px;
}
.ma__content {
margin-top: 12px;
font-size: 13px;
color: var(--mini-text);
line-height: 1.8;
}
.ma__content p {
margin: 0 0 10px;
}
.ma__warn {
margin-top: 12px;
font-size: 13px;
color: var(--mini-danger);
text-align: center;
}
.ma__actions {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 20px;
}
</style>
+136
View File
@@ -0,0 +1,136 @@
<template>
<PageLayout title="我的缘分">
<van-tabs v-model:active="activeTab" sticky>
<van-tab name="mutual" :title="`互相喜欢${mutualList.length ? '(' + mutualList.length + ')' : ''}`" />
<van-tab name="liked" :title="`我喜欢的${likedList.length ? '(' + likedList.length + ')' : ''}`" />
<van-tab name="likers" :title="`喜欢我的${likersList.length ? '(' + likersList.length + ')' : ''}`" />
</van-tabs>
<div class="fc">
<UiLoading v-if="loading" />
<template v-else>
<!-- 互相喜欢 -->
<template v-if="activeTab === 'mutual'">
<div v-if="mutualList.length">
<UiUserCard v-for="u in mutualList" :key="u.userId" :user="u" @tap="goDetail" />
</div>
<UiEmpty v-else description="还没有互相喜欢的人">
<van-button type="primary" round size="small" @click="router.push('/discover')">去「找对象」</van-button>
</UiEmpty>
</template>
<!-- 我喜欢的 -->
<template v-else-if="activeTab === 'liked'">
<div v-if="likedList.length">
<UiUserCard v-for="u in likedList" :key="u.userId" :user="u" @tap="goDetail" />
</div>
<UiEmpty v-else description="还没有喜欢过任何人">
<van-button type="primary" round size="small" @click="router.push('/discover')">去「找对象」</van-button>
</UiEmpty>
</template>
<!-- 喜欢我的 -->
<template v-else>
<div v-if="likersList.length">
<div v-for="u in likersList" :key="u.userId" class="fc__liker">
<UiUserCard :user="u" @tap="goDetail" />
<van-button
class="fc__liker-btn"
type="primary"
size="mini"
:loading="actionLoading === u.userId"
@click="onLikeBack(u)"
>
喜欢对方
</van-button>
</div>
</div>
<UiEmpty v-else description="还没有人喜欢你">
<van-button type="primary" round size="small" @click="router.push('/profile/photos')">
完善照片
</van-button>
</UiEmpty>
</template>
</template>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { matchService } from '@/api/services'
import { useMatchStore } from '@/stores/match'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiUserCard from '@/components/UiUserCard.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
const route = useRoute()
const router = useRouter()
const matchStore = useMatchStore()
const appStore = useAppStore()
const activeTab = ref(String(route.query.tab || 'mutual'))
const mutualList = ref<any[]>([])
const likedList = ref<any[]>([])
const likersList = ref<any[]>([])
const loading = ref(true)
const actionLoading = ref('')
function pick(result: any): any[] {
return result?.items || result?.records || []
}
async function loadAll() {
loading.value = true
try {
const [mutual, liked, likers]: any = await Promise.all([
matchService.getMutualList({ pageSize: 20 }).catch(() => null),
matchService.getLikedByMe({ pageSize: 20 }).catch(() => null),
matchService.getLikers({ pageSize: 20 }).catch(() => null),
])
mutualList.value = pick(mutual)
likedList.value = pick(liked)
likersList.value = pick(likers)
} finally {
loading.value = false
}
}
function goDetail(u: any) {
if (u?.userId) router.push({ path: '/profile/detail', query: { id: u.userId } })
}
async function onLikeBack(u: any) {
if (!u?.userId || actionLoading.value) return
actionLoading.value = u.userId
try {
const result: any = await matchStore.like(u.userId)
likersList.value = likersList.value.filter((x) => x.userId !== u.userId)
appStore.toastSuccess(result?.mutualMatch ? '双向意向匹配成功' : '已表达喜欢')
if (result?.mutualMatch) loadAll()
} catch (err: any) {
appStore.toastError(err?.message || '操作失败')
} finally {
actionLoading.value = ''
}
}
onMounted(loadAll)
</script>
<style scoped>
.fc {
padding: 12px 16px 16px;
}
.fc__liker {
position: relative;
}
.fc__liker-btn {
position: absolute;
right: 12px;
top: 26px;
}
</style>
@@ -0,0 +1,172 @@
<template>
<PageLayout title="申请红娘服务">
<div class="apply">
<div class="apply__hero">
<div class="apply__title">选择服务类型</div>
<div class="apply__sub">告诉我们你的需求,红娘会提供公益协助</div>
</div>
<div class="apply__body">
<div class="apply__grid">
<div
v-for="t in serviceTypes"
:key="t.code"
class="apply__type"
:class="{ 'apply__type--active': selectedType === t.code }"
@click="selectedType = t.code"
>
<div class="apply__type-icon">{{ t.icon }}</div>
<div class="apply__type-name">{{ t.name }}</div>
</div>
</div>
<div class="apply__field">
<div class="apply__label">补充说明(可选)</div>
<van-field
v-model="description"
type="textarea"
rows="4"
maxlength="500"
show-word-limit
placeholder="请描述您的情况或需求,方便红娘更好地为您服务"
/>
</div>
<p v-if="errorText" class="apply__error">{{ errorText }}</p>
<van-button type="primary" block round :loading="submitting" @click="onSubmit">
提交申请
</van-button>
<p class="apply__tip">红娘仅提供推荐与协助,不会代替你表达喜欢</p>
</div>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { matchmakerService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
const router = useRouter()
const appStore = useAppStore()
/** 服务类型(对齐 miniapp/pages/matchmaker-apply/matchmaker-apply.js) */
const serviceTypes = [
{ code: 'recommend_partner', name: '帮我推荐合适的人', icon: '👤' },
{ code: 'review_preference', name: '帮我看看择偶条件', icon: '📋' },
{ code: 'contact_intention', name: '帮我联系意向对象', icon: '💬' },
{ code: 'join_activity', name: '想参加线下活动', icon: '🎉' },
{ code: 'no_match_help', name: '匹配很久没有结果', icon: '😟' },
{ code: 'chat_help', name: '不知道怎么开始聊天', icon: '✉️' },
{ code: 'other', name: '其他', icon: '❓' },
]
const selectedType = ref('')
const description = ref('')
const submitting = ref(false)
const errorText = ref('')
async function onSubmit() {
if (submitting.value) return
if (!selectedType.value) {
appStore.toast('请选择服务类型')
return
}
submitting.value = true
errorText.value = ''
try {
await matchmakerService.applyService({
serviceType: selectedType.value,
description: description.value,
})
appStore.toastSuccess('申请已提交')
router.replace('/matchmaker/progress')
} catch (err: any) {
errorText.value = err?.message || '提交失败,请稍后重试'
appStore.toastError(errorText.value)
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.apply {
min-height: 100vh;
background: var(--mini-bg);
padding-bottom: 24px;
}
.apply__hero {
background: var(--mini-brand-gradient);
color: #fff;
padding: 20px 16px 22px;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
}
.apply__title {
font-size: 18px;
font-weight: 600;
}
.apply__sub {
font-size: 12px;
opacity: 0.85;
margin-top: 6px;
}
.apply__body {
padding: 16px;
}
.apply__grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.apply__type {
display: flex;
align-items: center;
gap: 10px;
background: var(--mini-surface);
border: 1px solid transparent;
border-radius: var(--mini-radius);
padding: 12px;
box-shadow: var(--mini-shadow);
}
.apply__type--active {
border-color: var(--mini-tabbar-active);
background: rgba(244, 0, 90, 0.04);
}
.apply__type-icon {
font-size: 20px;
flex: none;
}
.apply__type-name {
font-size: 13px;
color: var(--mini-text-strong);
}
.apply__field {
margin-top: 16px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
overflow: hidden;
box-shadow: var(--mini-shadow);
}
.apply__label {
font-size: 13px;
font-weight: 600;
color: var(--mini-text-strong);
padding: 12px 16px 0;
}
.apply__error {
color: var(--mini-danger);
font-size: 13px;
margin: 12px 4px;
}
.apply__tip {
font-size: 11px;
color: var(--mini-text-muted);
text-align: center;
margin-top: 14px;
}
</style>
@@ -0,0 +1,390 @@
<template>
<PageLayout title="公益红娘">
<div class="mm">
<div class="mm__hero">
<div class="mm__eyebrow">专业牵线 · 公益助力</div>
<div class="mm__title">公益红娘</div>
<div class="mm__sub">认真倾听你的期待,帮助你认识更合适的人</div>
<div class="mm__promises">
<div class="mm__promise">
<div class="mm__promise-icon tone-rose">免</div>
<div class="mm__promise-title">公益免费</div>
<div class="mm__promise-desc">不收取服务费用</div>
</div>
<div class="mm__promise">
<div class="mm__promise-icon tone-blue">真</div>
<div class="mm__promise-title">真实匹配</div>
<div class="mm__promise-desc">基于审核资料推荐</div>
</div>
<div class="mm__promise">
<div class="mm__promise-icon tone-green">隐</div>
<div class="mm__promise-title">隐私保护</div>
<div class="mm__promise-desc">由你本人决定意向</div>
</div>
</div>
</div>
<div class="mm__body">
<UiErrorState v-if="error" @retry="load" />
<UiLoading v-else-if="loading" />
<template v-else>
<UiSectionHeader title="申请状态" />
<div v-if="activeService" class="mm__card" @click="goProgress()">
<div class="mm__card-head">
<span class="mm__card-title">公益红娘服务</span>
<UiTag :type="tagType(activeService.status)">{{ statusText(activeService.status) }}</UiTag>
</div>
<div class="mm__card-row">服务类型:{{ typeText(activeService.serviceType) }}</div>
<div v-if="activeService.matchmakerName" class="mm__card-row">红娘:{{ activeService.matchmakerName }}</div>
<div v-if="activeService.assignedAt" class="mm__card-row">
分配时间:{{ formatDateTime(activeService.assignedAt) }}
</div>
<div class="mm__card-actions">
<van-button size="small" plain @click.stop="goProgress()">查看进度</van-button>
<van-button
v-if="showRecommendations"
size="small"
type="primary"
@click.stop="goRecommendations"
>
查看红娘推荐
</van-button>
</div>
</div>
<div v-else class="mm__empty">
<div class="mm__empty-icon">娘</div>
<div class="mm__empty-title">暂未申请红娘服务</div>
<div class="mm__empty-desc">提交需求后,工作人员会根据你的择偶条件提供公益协助</div>
</div>
<UiSectionHeader title="红娘可以如何帮助" />
<div class="mm__caps">
<div v-for="(c, i) in capabilities" :key="i" class="mm__cap">
<span class="mm__cap-dot" />
<span>{{ c }}</span>
</div>
</div>
<template v-if="historyServices.length">
<UiSectionHeader title="历史服务" />
<div
v-for="s in historyServices"
:key="s.serviceId || s.id"
class="mm__history"
@click="goProgress(s)"
>
<div class="mm__history-main">
<div class="mm__history-title">公益红娘服务</div>
<div v-if="s.matchmakerName" class="mm__history-meta">红娘:{{ s.matchmakerName }}</div>
</div>
<span class="mm__history-status">{{ statusText(s.status) }}</span>
<van-icon name="arrow" color="#9aa0b5" />
</div>
</template>
</template>
</div>
<div class="mm__footer">
<van-button type="primary" block round @click="goApply">
{{ activeService ? '提交新的红娘需求' : '申请红娘服务' }}
</van-button>
</div>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { matchmakerService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import { formatDateTime } from '@/utils/datetime'
import PageLayout from '@/layouts/PageLayout.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
import UiTag from '@/components/UiTag.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const router = useRouter()
const appStore = useAppStore()
/** 服务状态文案(对齐 miniapp constants/profileLabels.js) */
const STATUS_LABEL: Record<string, string> = {
submitted: '已提交',
assigned: '已分配',
in_service: '服务中',
waiting_user: '待您确认',
completed: '已完成',
cancelled: '已取消',
}
/** 服务类型文案(对齐后端 chk_matchmaker_service_type 白名单) */
const TYPE_LABEL: Record<string, string> = {
recommendation: '推荐对象',
recommend_partner: '推荐对象',
review_preference: '择偶指导',
contact_intention: '牵线意向',
referral_counseling: '转介咨询',
referral_activity: '转介活动',
referral_station: '转介服务站',
}
const capabilities = [
'推荐合适对象(基于你的择偶条件)',
'调整择偶条件的专业建议',
'协助联系你感兴趣的意向对象',
'推荐参加合适的相亲活动',
'聊天沟通的方法指导',
]
const services = ref<any[]>([])
const loading = ref(true)
const error = ref(false)
const activeService = computed<any>(() => {
return services.value.find((s) => s.status !== 'completed' && s.status !== 'cancelled') || null
})
const historyServices = computed<any[]>(() => {
const active = activeService.value
return active ? services.value.filter((s) => s !== active) : services.value
})
const showRecommendations = computed(() => {
const st = activeService.value?.status
return st === 'in_service' || st === 'waiting_user'
})
function statusText(status?: string): string {
return (status && STATUS_LABEL[status]) || status || ''
}
function typeText(type?: string): string {
return (type && TYPE_LABEL[type]) || '公益红娘服务'
}
function tagType(status?: string): 'primary' | 'success' | 'warning' | 'danger' | 'info' {
const map: Record<string, 'primary' | 'success' | 'warning' | 'info'> = {
submitted: 'warning',
assigned: 'info',
in_service: 'primary',
waiting_user: 'warning',
completed: 'success',
cancelled: 'info',
}
return (status && map[status]) || 'info'
}
async function load() {
loading.value = true
error.value = false
try {
const result: any = await matchmakerService.getMyServices({})
services.value = result?.items || result?.records || (Array.isArray(result) ? result : [])
} catch (err: any) {
error.value = true
appStore.toastError(err?.message || '加载红娘服务失败')
} finally {
loading.value = false
}
}
function goApply() {
router.push('/matchmaker/apply')
}
function goProgress(s?: any) {
const target = s || activeService.value
const id = target?.serviceId || target?.id
if (id) router.push({ path: '/matchmaker/progress', query: { id } })
else router.push('/matchmaker/progress')
}
function goRecommendations() {
router.push('/matchmaker/recommendations')
}
onMounted(load)
</script>
<style scoped>
.mm {
min-height: 100vh;
background: var(--mini-bg);
padding-bottom: 84px;
}
.mm__hero {
background: var(--mini-brand-gradient);
color: #fff;
padding: 20px 16px 22px;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
}
.mm__eyebrow {
font-size: 11px;
opacity: 0.82;
letter-spacing: 1px;
}
.mm__title {
font-size: 22px;
font-weight: 600;
margin-top: 6px;
}
.mm__sub {
font-size: 12px;
opacity: 0.85;
margin-top: 6px;
}
.mm__promises {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-top: 16px;
}
.mm__promise {
text-align: center;
background: rgba(255, 255, 255, 0.12);
border-radius: var(--mini-radius);
padding: 12px 6px;
}
.mm__promise-icon {
width: 34px;
height: 34px;
border-radius: 10px;
margin: 0 auto 6px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
}
.mm__promise-title {
font-size: 13px;
font-weight: 600;
}
.mm__promise-desc {
font-size: 10px;
opacity: 0.82;
margin-top: 2px;
}
.mm__body {
padding: 0 16px;
}
.mm__card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 14px;
box-shadow: var(--mini-shadow);
}
.mm__card-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.mm__card-title {
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
}
.mm__card-row {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 6px;
}
.mm__card-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 12px;
}
.mm__empty {
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 22px 16px;
text-align: center;
box-shadow: var(--mini-shadow);
}
.mm__empty-icon {
width: 44px;
height: 44px;
border-radius: 12px;
margin: 0 auto 10px;
background: var(--mini-brand-gradient);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.mm__empty-title {
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
}
.mm__empty-desc {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 6px;
line-height: 1.6;
}
.mm__caps {
display: flex;
flex-direction: column;
gap: 10px;
}
.mm__cap {
display: flex;
align-items: center;
gap: 8px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 12px 14px;
box-shadow: var(--mini-shadow);
font-size: 13px;
color: var(--mini-text);
}
.mm__cap-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--mini-accent-pink);
flex: none;
}
.mm__history {
display: flex;
align-items: center;
gap: 10px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 12px 14px;
margin-bottom: 8px;
box-shadow: var(--mini-shadow);
}
.mm__history-main {
flex: 1;
min-width: 0;
}
.mm__history-title {
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
}
.mm__history-meta {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 2px;
}
.mm__history-status {
font-size: 12px;
color: var(--mini-text-secondary);
}
.mm__footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
background: var(--mini-surface);
box-shadow: 0 -4px 16px rgba(23, 42, 114, 0.08);
}
.tone-rose {
background: linear-gradient(135deg, #ff5f8f 0%, #e0326a 100%);
}
.tone-blue {
background: linear-gradient(135deg, #4f8cff 0%, #2f63e0 100%);
}
.tone-green {
background: linear-gradient(135deg, #3fc98a 0%, #199e64 100%);
}
</style>
@@ -0,0 +1,369 @@
<template>
<PageLayout title="服务进度">
<div class="pg">
<UiErrorState v-if="error" @retry="load" />
<UiLoading v-else-if="loading" />
<template v-else-if="service">
<div class="pg__status" :class="`pg__status--${statusKey}`">
<span class="pg__status-dot" />
<div>
<div class="pg__status-text">{{ statusText(service.status) }}</div>
<div class="pg__status-sub">{{ statusSub(service.status) }}</div>
</div>
</div>
<div class="pg__card">
<div class="pg__row">
<span class="pg__label">服务编号</span>
<span class="pg__value">{{ service.serviceDisplayId || service.displayId || service.serviceId || service.id }}</span>
</div>
<div class="pg__row">
<span class="pg__label">服务类型</span>
<span class="pg__value">{{ typeText(service.serviceType) }}</span>
</div>
<div v-if="service.description" class="pg__row">
<span class="pg__label">您的需求</span>
<span class="pg__value pg__value--desc">{{ service.description }}</span>
</div>
<div class="pg__row">
<span class="pg__label">提交时间</span>
<span class="pg__value">{{ formatDateTime(service.createdAt) }}</span>
</div>
<div v-if="service.assignedAt" class="pg__row">
<span class="pg__label">分配时间</span>
<span class="pg__value">{{ formatDateTime(service.assignedAt) }}</span>
</div>
<div v-if="service.completedAt" class="pg__row">
<span class="pg__label">完成时间</span>
<span class="pg__value">{{ formatDateTime(service.completedAt) }}</span>
</div>
</div>
<UiSectionHeader title="服务流程" />
<div class="pg__steps">
<div
v-for="(s, i) in steps"
:key="s.key"
class="pg__step"
:class="{ 'pg__step--done': stepStatus(s.key) }"
>
<span class="pg__step-num">{{ i + 1 }}</span>
<span class="pg__step-text">{{ s.text }}</span>
</div>
</div>
<div v-if="service.status === 'waiting_user'" class="pg__confirm">
<div class="pg__confirm-title">红娘已提交服务方案</div>
<div class="pg__confirm-desc">确认后将恢复服务跟进。</div>
<van-button type="primary" block round :loading="confirming" @click="onConfirm">
确认服务方案
</van-button>
</div>
<div v-if="canWithdraw" class="pg__withdraw">
<van-button plain block round :loading="withdrawing" @click="onWithdraw">撤回申请</van-button>
</div>
</template>
<div v-else class="pg__empty">
<UiEmpty description="暂无服务记录">
<van-button size="small" type="primary" @click="goApply">申请红娘服务</van-button>
</UiEmpty>
</div>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showConfirmDialog } from 'vant'
import { matchmakerService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import { formatDateTime } from '@/utils/datetime'
import PageLayout from '@/layouts/PageLayout.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const STATUS_LABEL: Record<string, string> = {
submitted: '已提交,等待分配',
assigned: '已分配红娘',
in_service: '红娘正在服务中',
waiting_user: '等待您确认',
completed: '服务已完成',
cancelled: '已取消',
}
const STATUS_SUB: Record<string, string> = {
submitted: '工作人员会尽快为你分配红娘',
assigned: '红娘会主动与你联系',
in_service: '红娘正在根据你的需求推进服务',
waiting_user: '请确认红娘提交的服务方案',
completed: '本次公益红娘服务已结束',
cancelled: '本次服务申请已取消',
}
const TYPE_LABEL: Record<string, string> = {
recommendation: '推荐对象',
recommend_partner: '推荐对象',
review_preference: '择偶指导',
contact_intention: '牵线意向',
referral_counseling: '转介咨询',
referral_activity: '转介活动',
referral_station: '转介服务站',
}
const STEPS = [
{ key: 'submitted', text: '提交申请' },
{ key: 'assigned', text: '分配红娘' },
{ key: 'in_service', text: '红娘服务' },
{ key: 'completed', text: '服务完成' },
]
const STEP_ORDER: Record<string, number> = {
submitted: 0,
assigned: 1,
in_service: 2,
waiting_user: 2,
completed: 3,
}
const WITHDRAWABLE = ['submitted', 'assigned', 'in_service', 'waiting_user']
const service = ref<any>(null)
const loading = ref(true)
const error = ref(false)
const confirming = ref(false)
const withdrawing = ref(false)
const steps = STEPS
const statusKey = computed(() => service.value?.status || 'submitted')
const canWithdraw = computed(() => WITHDRAWABLE.includes(statusKey.value))
function statusText(status?: string): string {
return (status && STATUS_LABEL[status]) || status || ''
}
function statusSub(status?: string): string {
return (status && STATUS_SUB[status]) || ''
}
function typeText(type?: string): string {
return (type && TYPE_LABEL[type]) || '公益红娘服务'
}
function stepStatus(key: string): boolean {
const current = STEP_ORDER[statusKey.value]
const step = STEP_ORDER[key]
if (current === undefined || step === undefined) return false
return current >= step
}
function serviceIdOf(item: any): string {
return item?.serviceId || item?.id || ''
}
async function load() {
loading.value = true
error.value = false
try {
const queryId = typeof route.query.id === 'string' ? route.query.id : ''
if (queryId) {
service.value = await matchmakerService.getServiceDetail(queryId)
} else {
const result: any = await matchmakerService.getMyServices({})
const list: any[] = result?.items || result?.records || (Array.isArray(result) ? result : [])
const first = list.find((s) => WITHDRAWABLE.includes(s.status)) || list[0]
if (!first) {
service.value = null
} else {
const id = serviceIdOf(first)
service.value = id
? await matchmakerService.getServiceDetail(id).catch(() => first)
: first
}
}
} catch (err: any) {
error.value = true
appStore.toastError(err?.message || '加载服务进度失败')
} finally {
loading.value = false
}
}
async function onConfirm() {
if (confirming.value) return
const id = serviceIdOf(service.value)
if (!id) return
confirming.value = true
try {
await matchmakerService.confirmService(id)
appStore.toastSuccess('已确认')
await load()
} catch (err: any) {
appStore.toastError(err?.message || '确认失败,请稍后重试')
} finally {
confirming.value = false
}
}
async function onWithdraw() {
if (withdrawing.value) return
const id = serviceIdOf(service.value)
if (!id) return
try {
await showConfirmDialog({ title: '撤回申请', message: '确定要撤回本次红娘服务申请吗?' })
} catch {
return
}
withdrawing.value = true
try {
await matchmakerService.withdrawService(id)
appStore.toastSuccess('已撤回')
await load()
} catch (err: any) {
appStore.toastError(err?.message || '撤回失败,请稍后重试')
} finally {
withdrawing.value = false
}
}
function goApply() {
router.push('/matchmaker/apply')
}
onMounted(load)
</script>
<style scoped>
.pg {
min-height: 100vh;
background: var(--mini-bg);
padding: 16px;
}
.pg__status {
display: flex;
align-items: center;
gap: 12px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 16px 14px;
box-shadow: var(--mini-shadow);
}
.pg__status-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--mini-warning);
flex: none;
}
.pg__status--in_service .pg__status-dot,
.pg__status--assigned .pg__status-dot {
background: var(--mini-accent-pink);
}
.pg__status--completed .pg__status-dot {
background: var(--mini-success);
}
.pg__status--cancelled .pg__status-dot {
background: var(--mini-text-muted);
}
.pg__status-text {
font-size: 16px;
font-weight: 600;
color: var(--mini-text-strong);
}
.pg__status-sub {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 2px;
}
.pg__card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 6px 14px;
margin-top: 12px;
box-shadow: var(--mini-shadow);
}
.pg__row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 12px 0;
border-bottom: 1px solid var(--mini-border);
}
.pg__row:last-child {
border-bottom: none;
}
.pg__label {
font-size: 13px;
color: var(--mini-text-secondary);
flex: none;
}
.pg__value {
font-size: 13px;
color: var(--mini-text-strong);
text-align: right;
word-break: break-all;
}
.pg__value--desc {
text-align: left;
}
.pg__steps {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.pg__step {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 12px 4px;
box-shadow: var(--mini-shadow);
opacity: 0.55;
}
.pg__step--done {
opacity: 1;
}
.pg__step-num {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--mini-border-strong);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
}
.pg__step--done .pg__step-num {
background: var(--mini-accent-pink);
}
.pg__step-text {
font-size: 11px;
color: var(--mini-text);
}
.pg__confirm {
background: rgba(245, 166, 35, 0.1);
border: 1px solid rgba(245, 166, 35, 0.28);
border-radius: var(--mini-radius);
padding: 16px;
margin-top: 16px;
}
.pg__confirm-title {
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
}
.pg__confirm-desc {
font-size: 12px;
color: var(--mini-text-secondary);
margin: 4px 0 12px;
}
.pg__withdraw {
margin-top: 16px;
}
.pg__empty {
padding-top: 24px;
}
</style>
@@ -0,0 +1,195 @@
<template>
<PageLayout title="红娘推荐">
<div class="rec">
<div class="rec__intro">
这是红娘根据你的需求提供的候选建议,是否表达喜欢由你本人决定。
</div>
<UiErrorState v-if="error" @retry="load" />
<UiLoading v-else-if="loading" />
<template v-else-if="recommendations.length">
<div v-for="r in recommendations" :key="r.id" class="rec__card">
<div class="rec__head" @click="goDetail(r)">
<UiAvatar :name="r.name" :src="r.avatarUrl" :size="52" />
<div class="rec__main">
<div class="rec__name">{{ r.name }}</div>
<div class="rec__meta">
<span v-if="r.age">{{ r.age }}岁</span>
<span v-if="r.regionName">{{ r.regionName }}</span>
</div>
<div v-if="r.reason" class="rec__reason">{{ r.reason }}</div>
</div>
</div>
<div class="rec__actions">
<van-button
size="small"
plain
:loading="actionId === r.id && actionType === 'pass'"
@click="onPass(r)"
>
暂不考虑
</van-button>
<van-button
size="small"
type="primary"
:loading="actionId === r.id && actionType === 'accept'"
@click="onAccept(r)"
>
表达喜欢
</van-button>
</div>
</div>
</template>
<UiEmpty v-else description="暂时没有红娘推荐" />
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { matchmakerService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiAvatar from '@/components/UiAvatar.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const router = useRouter()
const appStore = useAppStore()
const recommendations = ref<any[]>([])
const loading = ref(true)
const error = ref(false)
const actionId = ref('')
const actionType = ref('')
function normalize(item: any) {
return {
id: item.id || item.recommendationId,
userId: item.targetUserId || item.userId,
name: item.targetDisplayName || item.displayName || '红娘推荐对象',
age: item.targetAge || item.age || 0,
regionName: item.targetRegionName || item.regionName || '',
reason: item.reason || '',
avatarUrl: item.targetAvatarUrl || item.avatarUrl || '',
status: item.status,
}
}
async function load() {
loading.value = true
error.value = false
try {
const result: any = await matchmakerService.getRecommendations({})
const list: any[] = result?.items || result?.records || (Array.isArray(result) ? result : [])
recommendations.value = list.map(normalize)
recommendations.value
.filter((r) => r.status === 'pending_view' && r.id)
.forEach((r) => {
matchmakerService.markRecommendationViewed(r.id).catch(() => {})
})
} catch (err: any) {
error.value = true
appStore.toastError(err?.message || '加载红娘推荐失败')
} finally {
loading.value = false
}
}
async function onAccept(r: any) {
if (!r.id || actionId.value) return
actionId.value = r.id
actionType.value = 'accept'
try {
const result: any = await matchmakerService.acceptRecommendation(r.id)
recommendations.value = recommendations.value.filter((x) => x.id !== r.id)
appStore.toastSuccess(result?.mutualMatch ? '双向意向匹配成功!' : '已表达喜欢')
} catch (err: any) {
appStore.toastError(err?.message || '操作失败')
} finally {
actionId.value = ''
actionType.value = ''
}
}
async function onPass(r: any) {
if (!r.id || actionId.value) return
actionId.value = r.id
actionType.value = 'pass'
try {
await matchmakerService.passRecommendation(r.id)
recommendations.value = recommendations.value.filter((x) => x.id !== r.id)
appStore.toastSuccess('已暂不考虑')
} catch (err: any) {
appStore.toastError(err?.message || '操作失败')
} finally {
actionId.value = ''
actionType.value = ''
}
}
function goDetail(r: any) {
if (r.userId) router.push({ path: '/profile/detail', query: { id: r.userId } })
}
onMounted(load)
</script>
<style scoped>
.rec {
min-height: 100vh;
background: var(--mini-bg);
padding: 12px 16px 24px;
}
.rec__intro {
font-size: 12px;
color: var(--mini-text-secondary);
background: rgba(244, 0, 90, 0.06);
border-radius: var(--mini-radius);
padding: 10px 12px;
line-height: 1.6;
margin-bottom: 12px;
}
.rec__card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 14px;
margin-bottom: 10px;
box-shadow: var(--mini-shadow);
}
.rec__head {
display: flex;
align-items: center;
gap: 12px;
}
.rec__main {
flex: 1;
min-width: 0;
}
.rec__name {
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
}
.rec__meta {
display: flex;
gap: 10px;
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 4px;
}
.rec__reason {
font-size: 12px;
color: var(--mini-tabbar-active);
margin-top: 6px;
line-height: 1.5;
}
.rec__actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 12px;
}
</style>
@@ -0,0 +1,181 @@
<template>
<PageLayout title="待确认的公益服务">
<div class="ref">
<div class="ref__hero">
<div class="ref__hero-title">待确认的公益服务</div>
<div class="ref__hero-desc">红娘提供的服务建议,需要由你本人确认后才会生效。</div>
</div>
<div class="ref__body">
<UiErrorState v-if="error" @retry="load" />
<UiLoading v-else-if="loading" />
<template v-else-if="referrals.length">
<div v-for="item in referrals" :key="item.id" class="ref__card">
<div class="ref__main">
<div class="ref__title">{{ item.title }}</div>
<div class="ref__detail">转介编号:{{ item.referralDisplayId || item.id }}</div>
<div v-if="item.detail" class="ref__detail">{{ item.detail }}</div>
<div class="ref__date">{{ formatDateTime(item.createdAt) }}</div>
</div>
<div class="ref__action">
<UiTag :type="item.confirmed ? 'success' : 'warning'">{{ item.statusText }}</UiTag>
<van-button
v-if="!item.confirmed"
size="mini"
type="primary"
:loading="confirmingId === item.id"
@click="onConfirm(item)"
>
确认转介
</van-button>
</div>
</div>
</template>
<UiEmpty v-else description="暂无待确认的转介" />
</div>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { referralService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import { formatDateTime } from '@/utils/datetime'
import PageLayout from '@/layouts/PageLayout.vue'
import UiTag from '@/components/UiTag.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const route = useRoute()
const appStore = useAppStore()
const STATUS_LABEL: Record<string, string> = {
submitted: '待您确认',
pending_confirmation: '待您确认',
confirmed: '已确认',
cancelled: '已取消',
completed: '已完成',
}
const referrals = ref<any[]>([])
const loading = ref(true)
const error = ref(false)
const confirmingId = ref('')
function normalize(item: any) {
const confirmed = !!item.userConfirmedAt || item.status === 'confirmed'
return {
id: item.id,
referralDisplayId: item.referralDisplayId,
targetType: item.type === 'activity' ? 'activity' : 'service_station',
title: item.type === 'activity' ? '公益活动转介' : '服务站转介',
detail: item.type === 'activity' ? item.activityDisplayId || '公益活动' : item.serviceType || '公益服务',
confirmed,
statusText: confirmed ? '已确认' : STATUS_LABEL[item.status] || '待您确认',
createdAt: item.createdAt || item.registeredAt || '',
}
}
async function load() {
loading.value = true
error.value = false
try {
const result: any = await referralService.getMyReferralHistory()
let list: any[] = result?.items || result?.records || (Array.isArray(result) ? result : [])
list = list.map(normalize)
const queryId = typeof route.query.id === 'string' ? route.query.id : ''
if (queryId) {
const matched = list.filter((x) => x.id === queryId)
if (matched.length) list = matched
}
referrals.value = list
} catch (err: any) {
error.value = true
appStore.toastError(err?.message || '加载失败,请稍后重试')
} finally {
loading.value = false
}
}
async function onConfirm(item: any) {
if (!item.id || confirmingId.value) return
confirmingId.value = item.id
try {
await referralService.confirmReferral(item.id, item.targetType)
appStore.toastSuccess('已确认')
await load()
} catch (err: any) {
appStore.toastError(err?.message || '确认失败,请重试')
} finally {
confirmingId.value = ''
}
}
onMounted(load)
</script>
<style scoped>
.ref {
min-height: 100vh;
background: var(--mini-bg);
}
.ref__hero {
background: var(--mini-brand-gradient);
color: #fff;
padding: 20px 16px 22px;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
}
.ref__hero-title {
font-size: 18px;
font-weight: 600;
}
.ref__hero-desc {
font-size: 12px;
opacity: 0.85;
margin-top: 6px;
line-height: 1.6;
}
.ref__body {
padding: 12px 16px 24px;
}
.ref__card {
display: flex;
align-items: flex-start;
gap: 12px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 14px;
margin-bottom: 10px;
box-shadow: var(--mini-shadow);
}
.ref__main {
flex: 1;
min-width: 0;
}
.ref__title {
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
}
.ref__detail {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 4px;
}
.ref__date {
font-size: 11px;
color: var(--mini-text-muted);
margin-top: 6px;
}
.ref__action {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10px;
flex: none;
}
</style>
+383
View File
@@ -0,0 +1,383 @@
<template>
<div class="mine">
<!-- 头部 -->
<div class="mine__hero">
<div class="mine__top">
<span class="mine__page-title">我的</span>
<van-icon name="setting-o" size="20" @click="router.push('/settings')" />
</div>
<div class="mine__profile">
<UiAvatar :src="profile?.avatarUrl" :name="profile?.displayName || '善缘用户'" :size="60" />
<div class="mine__profile-main">
<div class="mine__name-row">
<span class="mine__name">{{ profile?.displayName || '未填写昵称' }}</span>
<span class="mine__rel">{{ relationshipText }}</span>
</div>
<div class="mine__completeness">资料完整度 {{ completeness }}%</div>
<div v-if="displayNumber" class="mine__number">用户编号:{{ displayNumber }}</div>
<div class="mine__progress"><div class="mine__progress-inner" :style="{ width: completeness + '%' }" /></div>
<div class="mine__badges">
<UiTag :type="verified ? 'success' : 'warning'">{{ verified ? '已实名' : '未实名' }}</UiTag>
<UiTag :type="isProfileApproved ? 'success' : 'warning'">
{{ isProfileApproved ? '资料已审核' : '资料待审核' }}
</UiTag>
</div>
</div>
<span class="mine__edit" @click="router.push('/profile/intro')">编辑</span>
</div>
</div>
<!-- 统计 -->
<div class="mine__stats">
<div class="mine__stat">
<div class="mine__stat-value">{{ completeness }}%</div>
<div class="mine__stat-label">资料完整</div>
</div>
<div class="mine__stat" @click="router.push('/fate-center?tab=likers')">
<div class="mine__stat-value">{{ likersCount }}</div>
<div class="mine__stat-label">喜欢我的</div>
</div>
<div class="mine__stat" @click="router.push('/fate-center')">
<div class="mine__stat-value">{{ mutualCount }}</div>
<div class="mine__stat-label">当前缘分</div>
</div>
<div class="mine__stat">
<div class="mine__stat-value mine__stat-value--text">{{ reviewStatusText }}</div>
<div class="mine__stat-label">资料状态</div>
</div>
</div>
<div class="mine__body">
<!-- 组一:资料与缘分 -->
<UiSectionHeader title="我的资料与缘分" />
<van-cell-group inset>
<van-cell title="我的资料" :value="reviewStatusText" is-link @click="router.push('/profile/intro')" />
<van-cell title="择偶条件" is-link @click="router.push('/profile/preference')" />
<van-cell title="我的缘分" is-link @click="router.push('/fate-center')" />
</van-cell-group>
<!-- 组二:公益服务 -->
<UiSectionHeader title="我的公益服务" />
<van-cell-group inset>
<van-cell title="公益服务中心" value="咨询 · 预约 · 课堂" is-link @click="router.push('/service-center')" />
</van-cell-group>
<div class="mine__grid">
<div class="mine__entry" @click="router.push('/matchmaker')">
<div class="mine__entry-icon tone-rose">娘</div>
<span>我的红娘</span>
</div>
<div class="mine__entry" @click="router.push('/my/counseling')">
<div class="mine__entry-icon tone-blue">咨</div>
<span>我的咨询</span>
</div>
<div class="mine__entry" @click="router.push('/my/activities')">
<div class="mine__entry-icon tone-amber">活</div>
<span>我的活动</span>
</div>
<div class="mine__entry" @click="router.push('/my/appointments')">
<div class="mine__entry-icon tone-green">约</div>
<span>我的预约</span>
</div>
</div>
<van-cell-group inset>
<van-cell title="公益服务记录" value="仅本人可见" is-link @click="router.push('/my/service-history')" />
</van-cell-group>
<!-- 组三:安全与隐私 -->
<UiSectionHeader title="安全与隐私" />
<van-cell-group inset>
<van-cell title="隐私设置" is-link @click="router.push('/settings/privacy')" />
<van-cell title="黑名单" is-link @click="router.push('/settings/blacklist')" />
<van-cell title="举报与安全" is-link @click="router.push('/my/reports')" />
<van-cell title="账号设置" is-link @click="router.push('/settings')" />
</van-cell-group>
<!-- 组四:公益信息 -->
<UiSectionHeader title="公益信息" />
<div class="mine__grid">
<div class="mine__entry" @click="router.push('/content')">
<div class="mine__entry-icon tone-amber">课</div>
<span>婚恋课堂</span>
</div>
<div class="mine__entry" @click="router.push('/anti-fraud')">
<div class="mine__entry-icon tone-rose">诈</div>
<span>婚恋反诈</span>
</div>
<div class="mine__entry" @click="router.push('/service-station')">
<div class="mine__entry-icon tone-green">站</div>
<span>善缘服务站</span>
</div>
<div class="mine__entry" @click="router.push('/about')">
<div class="mine__entry-icon tone-blue">缘</div>
<span>关于善缘丹阳</span>
</div>
</div>
<div class="mine__logout" @click="onLogout">退出登录</div>
<UiSectionHeader title="工作人员" />
<van-cell-group inset>
<van-cell title="红娘工作人员入口" is-link @click="goOperator" />
</van-cell-group>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { profileService, verificationService, matchService } from '@/api/services'
import { useAuthStore } from '@/stores/auth'
import { useOperatorStore } from '@/stores/operator'
import { useAppStore } from '@/stores/app'
import { showConfirmDialog } from 'vant'
import UiAvatar from '@/components/UiAvatar.vue'
import UiTag from '@/components/UiTag.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
const router = useRouter()
const authStore = useAuthStore()
const operatorStore = useOperatorStore()
const appStore = useAppStore()
const RELATIONSHIP_TEXT: Record<string, string> = {
searching: '正在寻找',
getting_to_know: '正在了解',
paused: '已暂停寻找',
matched_successfully: '已成功牵手',
service_exited: '已退出服务',
}
const REVIEW_TEXT: Record<string, string> = {
not_submitted: '未提交',
pending_review: '审核中',
approved: '已审核',
need_edit: '需修改',
rejected: '未通过',
completed: '已审核',
}
const profile = ref<any>(null)
const verification = ref<any>(null)
const likersCount = ref(0)
const mutualCount = ref(0)
const completeness = computed(() => profile.value?.profileCompletion || 0)
const displayNumber = computed(
() => profile.value?.userDisplayNumber || profile.value?.displayNumber || profile.value?.displayId || '',
)
const reviewStatus = computed(() => profile.value?.profileReviewStatus || profile.value?.reviewStatus || 'not_submitted')
const reviewStatusText = computed(() => REVIEW_TEXT[reviewStatus.value] || '未提交')
const relationshipText = computed(() => RELATIONSHIP_TEXT[profile.value?.relationshipStatus] || '')
const isProfileApproved = computed(() => reviewStatus.value === 'approved' || profile.value?.profileStatus === 'approved' || profile.value?.profileStatus === 'completed')
const verified = computed(() => {
const v = verification.value
if (v && typeof v.verified === 'boolean') return v.verified
if (v?.status) return v.status === 'verified'
const p = profile.value
if (p) return !!(p.verified ?? p.isRealNameVerified)
return false
})
async function load() {
try {
const [p, v] = await Promise.all([
profileService.getProfile().catch(() => null),
verificationService.getStatus().catch(() => null),
])
profile.value = p
verification.value = v
} catch {
/* ignore */
}
try {
const [mutual, likers]: any = await Promise.all([
matchService.getMutualList({ pageSize: 1 }).catch(() => null),
matchService.getLikers({ pageSize: 1 }).catch(() => null),
])
mutualCount.value = (mutual?.items || mutual?.records || []).length || mutual?.total || 0
likersCount.value = (likers?.items || likers?.records || []).length || likers?.total || 0
} catch {
/* ignore */
}
}
function goOperator() {
if (operatorStore.isLoggedIn) router.push('/operator/workbench')
else router.push('/operator/login')
}
async function onLogout() {
try {
await showConfirmDialog({ title: '退出登录', message: '确定要退出当前账号吗?' })
} catch {
return
}
await authStore.logout()
appStore.toast('已退出登录')
router.replace('/login?clear=1')
}
onMounted(load)
</script>
<style scoped>
.mine {
min-height: 100vh;
background: var(--mini-bg);
padding-bottom: 16px;
}
.mine__hero {
background: var(--mini-brand-gradient);
padding: 16px 16px 24px;
color: #fff;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
}
.mine__top {
display: flex;
align-items: center;
justify-content: space-between;
}
.mine__page-title {
font-size: 17px;
font-weight: 600;
}
.mine__profile {
display: flex;
gap: 14px;
margin-top: 16px;
}
.mine__profile-main {
flex: 1;
min-width: 0;
}
.mine__name-row {
display: flex;
align-items: center;
gap: 8px;
}
.mine__name {
font-size: 18px;
font-weight: 600;
}
.mine__rel {
font-size: 11px;
background: rgba(255, 255, 255, 0.18);
padding: 2px 8px;
border-radius: 10px;
}
.mine__completeness,
.mine__number {
font-size: 12px;
opacity: 0.85;
margin-top: 4px;
}
.mine__progress {
height: 6px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.24);
margin-top: 8px;
overflow: hidden;
}
.mine__progress-inner {
height: 100%;
background: var(--mini-accent-pink);
border-radius: 3px;
}
.mine__badges {
display: flex;
gap: 6px;
margin-top: 8px;
}
.mine__edit {
align-self: flex-start;
font-size: 12px;
background: rgba(255, 255, 255, 0.18);
padding: 4px 10px;
border-radius: 12px;
}
.mine__stats {
margin: -16px 16px 0;
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow-strong);
display: flex;
padding: 14px 0;
position: relative;
z-index: 2;
}
.mine__stat {
flex: 1;
text-align: center;
}
.mine__stat-value {
font-size: 18px;
font-weight: 700;
color: var(--mini-text-strong);
}
.mine__stat-value--text {
font-size: 13px;
}
.mine__stat-label {
font-size: 11px;
color: var(--mini-text-secondary);
margin-top: 2px;
}
.mine__body {
margin-top: 8px;
}
.mine__body :deep(.van-cell-group--inset) {
margin: 0 16px 8px;
}
.mine__grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
margin: 4px 16px 12px;
}
.mine__entry {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 12px 4px;
box-shadow: var(--mini-shadow);
font-size: 11px;
color: var(--mini-text);
}
.mine__entry-icon {
width: 34px;
height: 34px;
border-radius: 10px;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-weight: 600;
}
.mine__logout {
margin: 16px;
text-align: center;
color: var(--mini-danger);
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 13px;
font-size: 14px;
box-shadow: var(--mini-shadow);
}
.tone-rose {
background: linear-gradient(135deg, #ff5f8f 0%, #e0326a 100%);
}
.tone-blue {
background: linear-gradient(135deg, #4f8cff 0%, #2f63e0 100%);
}
.tone-amber {
background: linear-gradient(135deg, #f5a623 0%, #d98a10 100%);
}
.tone-green {
background: linear-gradient(135deg, #3fc98a 0%, #199e64 100%);
}
</style>
@@ -0,0 +1,289 @@
<template>
<PageLayout title="基础资料">
<div class="pb">
<UiLoading v-if="loading" text="加载中…" />
<template v-else>
<div class="card">
<UiSectionHeader title="基本信息" />
<p class="pb__hint">由实名认证自动生成</p>
<div class="pb__readonly">
<div class="pb__row">
<span class="pb__row-label">性别</span>
<span class="pb__row-value">{{ genderLabel || '未认证' }}</span>
</div>
<div class="pb__row">
<span class="pb__row-label">年龄</span>
<span class="pb__row-value">{{ profile?.age ? `${profile.age}岁` : '未认证' }}</span>
</div>
</div>
</div>
<div class="card">
<UiSectionHeader title="个人资料" />
<p class="pb__hint">请如实填写</p>
<van-field
v-model="regionLabel"
is-link
readonly
label="居住区域"
placeholder="请选择"
:border="false"
@click="openPicker('region')"
/>
<van-field
v-model="form.heightCm"
type="digit"
label="身高(cm)"
placeholder="请输入"
/>
<van-field
v-model="maritalLabel"
is-link
readonly
label="婚姻状况"
placeholder="请选择"
@click="openPicker('marital')"
/>
<van-field
v-model="childrenLabel"
is-link
readonly
label="子女情况"
placeholder="请选择"
@click="openPicker('children')"
/>
<van-field
v-model="educationLabel"
is-link
readonly
label="学历"
placeholder="请选择"
@click="openPicker('education')"
/>
<van-field
v-model="occupationLabel"
is-link
readonly
label="职业类型"
placeholder="请选择"
@click="openPicker('occupation')"
/>
</div>
</template>
<div class="pb__actions">
<van-button type="primary" block round :loading="saving" :disabled="loading" @click="onNext">
下一步
</van-button>
</div>
<van-popup v-model:show="picker.show" position="bottom" round>
<van-picker
:columns="picker.columns"
@cancel="picker.show = false"
@confirm="onPickerConfirm"
/>
</van-popup>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { profileService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
const router = useRouter()
const appStore = useAppStore()
const loading = ref(true)
const saving = ref(false)
const profile = ref<any>(null)
const form = reactive({
regionName: '',
heightCm: '',
maritalStatus: '',
childrenStatus: '',
educationCode: '',
occupationCode: '',
})
const REGION_OPTIONS = [
{ text: '丹阳', value: '丹阳' },
{ text: '镇江市区', value: '镇江市区' },
{ text: '镇江其他辖区', value: '镇江其他辖区' },
{ text: '南京', value: '南京' },
]
const REGION_CODES: Record<string, string> = { 丹阳: '321181', 镇江市区: '321100', 南京: '320100' }
const MARITAL_OPTIONS = [
{ text: '未婚', value: 'never_married' },
{ text: '离异', value: 'divorced' },
{ text: '丧偶', value: 'widowed' },
]
const CHILDREN_OPTIONS = [
{ text: '无子女', value: 'none' },
{ text: '有子女', value: 'yes' },
]
const EDUCATION_OPTIONS = [
{ text: '高中/中专', value: 'senior_high' },
{ text: '大专', value: 'college' },
{ text: '本科', value: 'bachelor' },
{ text: '硕士', value: 'master' },
{ text: '博士', value: 'doctor' },
]
const OCCUPATION_OPTIONS = [
{ text: '机关事业单位', value: 'government' },
{ text: '教育', value: 'education' },
{ text: '医疗', value: 'medical' },
{ text: '企业职员', value: 'enterprise' },
{ text: '技术人员', value: 'technical' },
{ text: '个体经营', value: 'self_employed' },
{ text: '自由职业', value: 'freelance' },
{ text: '服务行业', value: 'service' },
{ text: '农业', value: 'agriculture' },
{ text: '其他', value: 'other' },
]
function labelOf(options: Array<{ text: string; value: string }>, value: string): string {
return options.find((o) => o.value === value)?.text || ''
}
const regionLabel = computed(() => labelOf(REGION_OPTIONS, form.regionName))
const maritalLabel = computed(() => labelOf(MARITAL_OPTIONS, form.maritalStatus))
const childrenLabel = computed(() => labelOf(CHILDREN_OPTIONS, form.childrenStatus))
const educationLabel = computed(() => labelOf(EDUCATION_OPTIONS, form.educationCode))
const occupationLabel = computed(() => labelOf(OCCUPATION_OPTIONS, form.occupationCode))
const genderLabel = computed(() => {
const g = profile.value?.gender
return g === 'male' ? '男' : g === 'female' ? '女' : ''
})
const picker = reactive<{ show: boolean; key: string; columns: any[] }>({ show: false, key: '', columns: [] })
const PICKER_COLUMNS: Record<string, any[]> = {
region: REGION_OPTIONS,
marital: MARITAL_OPTIONS,
children: CHILDREN_OPTIONS,
education: EDUCATION_OPTIONS,
occupation: OCCUPATION_OPTIONS,
}
function openPicker(key: string) {
picker.key = key
picker.columns = PICKER_COLUMNS[key]
picker.show = true
}
function onPickerConfirm({ selectedOptions }: any) {
const value = selectedOptions?.[0]?.value
const key = picker.key
picker.show = false
if (value === undefined) return
if (key === 'region') form.regionName = value
else if (key === 'marital') form.maritalStatus = value
else if (key === 'children') form.childrenStatus = value
else if (key === 'education') form.educationCode = value
else if (key === 'occupation') form.occupationCode = value
}
async function load() {
loading.value = true
try {
const p: any = await profileService.getProfile()
profile.value = p
form.regionName = p?.regionName || ''
form.heightCm = p?.heightCm ? String(p.heightCm) : ''
form.maritalStatus = p?.maritalStatus || ''
form.childrenStatus = p?.childrenStatus || ''
form.educationCode = p?.educationCode || ''
form.occupationCode = p?.occupationCode || ''
} catch {
/* 首次填写无资料 */
} finally {
loading.value = false
}
}
async function onNext() {
if (saving.value) return
if (!form.regionName) return appStore.toast('请选择居住区域')
if (!form.heightCm) return appStore.toast('请填写身高')
if (!form.maritalStatus) return appStore.toast('请选择婚姻状况')
if (!form.childrenStatus) return appStore.toast('请选择子女情况')
if (!form.educationCode) return appStore.toast('请选择学历')
if (!form.occupationCode) return appStore.toast('请选择职业类型')
saving.value = true
try {
await profileService.updateBasicProfile({
regionName: form.regionName,
regionCode: REGION_CODES[form.regionName] || null,
heightCm: Number(form.heightCm),
maritalStatus: form.maritalStatus,
childrenStatus: form.childrenStatus,
educationCode: form.educationCode,
occupationCode: form.occupationCode,
})
router.push('/profile/lifestyle')
} catch (err: any) {
appStore.toastError(err?.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.pb__hint {
font-size: 12px;
color: var(--mini-text-secondary);
margin: 0 0 8px;
line-height: 1.4;
}
.pb {
padding: 16px;
}
.card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 4px 0 8px;
margin-bottom: 12px;
}
.card :deep(.ui-section-header) {
padding: 0 16px;
}
.card :deep(.van-field) {
padding-top: 10px;
padding-bottom: 10px;
}
.pb__readonly {
padding: 4px 16px 4px;
}
.pb__row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid var(--mini-border-weak, #f2f3f5);
}
.pb__row:last-child {
border-bottom: none;
}
.pb__row-label {
font-size: 14px;
color: var(--mini-text-secondary);
}
.pb__row-value {
font-size: 14px;
color: var(--mini-text-strong);
}
.pb__actions {
margin-top: 8px;
}
</style>
@@ -0,0 +1,324 @@
<template>
<PageLayout title="资料确认">
<div class="pc">
<UiLoading v-if="loading" text="加载中…" />
<UiErrorState v-else-if="error" @retry="loadAll" />
<template v-else-if="profile">
<!-- 审核状态 -->
<div class="card">
<div class="pc__status-row">
<span class="pc__status-label">当前审核状态</span>
<UiTag :type="reviewTag">{{ reviewLabel }}</UiTag>
</div>
<div v-if="reviewComment" class="pc__comment">
<span class="pc__comment-title">审核反馈:</span>
<span class="pc__comment-text">{{ reviewComment }}</span>
</div>
</div>
<!-- 完整度 -->
<div class="card">
<UiSectionHeader title="资料完整度" />
<p class="pc__hint">{{ completion }}%</p>
<div class="pc__progress">
<div class="pc__progress-track">
<div class="pc__progress-fill" :style="{ width: completion + '%' }"></div>
</div>
<div class="pc__progress-hint">
{{ completion >= 100 ? '资料已完整,可提交审核' : '部分资料尚未填写,提交前请确认' }}
</div>
</div>
</div>
<!-- 资料概览 -->
<div class="card">
<UiSectionHeader title="资料概览" />
<p class="pc__hint">只读展示,点选对应项可修改</p>
<van-cell-group :border="false">
<van-cell title="昵称" :value="profile.displayName || '未设置'" is-link @click="router.push('/profile/basic')" />
<van-cell title="年龄" :value="profile.age ? profile.age + '岁' : '-'" is-link @click="router.push('/profile/basic')" />
<van-cell title="身高" :value="profile.heightCm ? profile.heightCm + 'cm' : '-'" is-link @click="router.push('/profile/basic')" />
<van-cell title="婚姻状况" :value="maritalLabel || '-'" is-link @click="router.push('/profile/basic')" />
<van-cell title="子女情况" :value="childrenLabel || '-'" is-link @click="router.push('/profile/basic')" />
<van-cell title="学历" :value="educationLabel || '-'" is-link @click="router.push('/profile/basic')" />
<van-cell title="居住区域" :value="profile.regionName || '-'" is-link @click="router.push('/profile/basic')" />
<van-cell title="生活照" :value="`${photoCount}张`" is-link @click="router.push('/profile/photos')" />
</van-cell-group>
</div>
<!-- 自我介绍 -->
<div class="card">
<UiSectionHeader title="自我介绍" more-text="修改" @more="router.push('/profile/intro')" />
<div class="pc__intro">{{ profile.selfIntro || '暂未填写' }}</div>
</div>
<!-- 择偶条件 -->
<div class="card">
<UiSectionHeader title="择偶条件" more-text="修改" @more="router.push('/profile/preference')" />
<div v-if="preferences">
<div class="pc__row">
<span class="pc__row-label">年龄范围</span>
<span class="pc__row-value">{{ preferences.minAge || '-' }} ~ {{ preferences.maxAge || '-' }}岁</span>
</div>
<div class="pc__row">
<span class="pc__row-label">期望婚姻状况</span>
<span class="pc__row-value">
{{ (preferences.maritalStatuses || []).map((c: string) => maritalText(c)).join('、') || '不限' }}
</span>
</div>
<div class="pc__row">
<span class="pc__row-label">最低学历</span>
<span class="pc__row-value">{{ educationText(preferences.minEducationCode) || '不限' }}</span>
</div>
</div>
<UiEmpty v-else description="尚未填写择偶条件" />
</div>
<!-- 提交须知 -->
<div class="pc__notice">
<UiSectionHeader title="提交前请确认" />
<div class="pc__notice-item">· 提交后资料将进入人工审核</div>
<div class="pc__notice-item">· 审核期间你可继续修改,但需重新提交</div>
<div class="pc__notice-item">· 审核通过后即可开始匹配</div>
<div class="pc__notice-item">· 资料须真实有效,禁止虚假信息</div>
</div>
<div class="pc__actions">
<van-button
type="primary"
block
round
:loading="submitting"
:disabled="!resubmitAllowed"
@click="onSubmit"
>
{{ isPending ? '审核中,不可重复提交' : '提交审核' }}
</van-button>
</div>
</template>
<UiEmpty v-else description="资料加载失败,请重试" />
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showConfirmDialog } from 'vant'
import { profileService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
import UiTag from '@/components/UiTag.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
const router = useRouter()
const appStore = useAppStore()
const loading = ref(true)
const error = ref(false)
const submitting = ref(false)
const profile = ref<any>(null)
const preferences = ref<any>(null)
const photoCount = ref(0)
const completion = ref(0)
const reviewStatus = ref('not_submitted')
const reviewComment = ref('')
const MARITAL: Record<string, string> = { never_married: '未婚', divorced: '离异', widowed: '丧偶', single: '未婚' }
const CHILDREN: Record<string, string> = { none: '无子女', has: '有子女', yes: '有子女' }
const EDUCATION: Record<string, string> = {
junior_high: '初中',
senior_high: '高中/中专',
high_school: '高中/中专',
college: '大专',
bachelor: '本科',
master: '硕士',
doctor: '博士',
}
const REVIEW_TEXT: Record<string, string> = {
not_submitted: '未提交',
pending: '审核中',
pending_review: '审核中',
approved: '已通过',
need_edit: '需修改',
rejected: '未通过',
}
function maritalText(code: string): string {
return MARITAL[code] || code
}
function educationText(code?: string): string {
if (!code) return ''
return EDUCATION[code] || code
}
const maritalLabel = computed(() => MARITAL[profile.value?.maritalStatus] || '')
const childrenLabel = computed(() => CHILDREN[profile.value?.childrenStatus] || '')
const educationLabel = computed(() => educationText(profile.value?.educationCode))
const isPending = computed(() => reviewStatus.value === 'pending' || reviewStatus.value === 'pending_review')
const reviewLabel = computed(() => REVIEW_TEXT[reviewStatus.value] || '未提交')
const reviewTag = computed<'primary' | 'success' | 'warning' | 'danger' | 'info'>(() => {
if (reviewStatus.value === 'approved') return 'success'
if (reviewStatus.value === 'need_edit' || isPending.value) return 'warning'
if (reviewStatus.value === 'rejected') return 'danger'
return 'info'
})
const resubmitAllowed = computed(
() => reviewStatus.value === 'not_submitted' || reviewStatus.value === 'need_edit' || reviewStatus.value === 'rejected',
)
async function loadAll() {
loading.value = true
error.value = false
try {
const [p, prefs, review, photosRes, comp] = await Promise.all([
profileService.getProfile().catch(() => null),
profileService.getPreferences().catch(() => null),
profileService.getReviewStatus().catch(() => null),
profileService.getPhotos().catch(() => ({ photos: [] })),
profileService.getCompletion().catch(() => null),
])
profile.value = p
preferences.value = prefs
const photos = (photosRes as any)?.photos || (photosRes as any)?.items || (photosRes as any)?.records || []
photoCount.value = photos.length
reviewStatus.value = (review as any)?.status || 'not_submitted'
const items = (review as any)?.items || []
reviewComment.value = items
.filter((i: any) => i.status === 'need_edit' || i.status === 'rejected')
.map((i: any) => i.reasonText)
.filter(Boolean)
.join(';')
const c = comp as any
const raw = c?.completion ?? c?.profileCompletion ?? (p as any)?.profileCompletion ?? 0
completion.value = Math.max(0, Math.min(100, Math.round(Number(raw) || 0)))
if (!p) error.value = true
} catch {
error.value = true
} finally {
loading.value = false
}
}
async function onSubmit() {
if (submitting.value || !resubmitAllowed.value) return
try {
await showConfirmDialog({
title: '确认提交审核?',
message: '提交后资料将进入人工审核,审核期间你可继续修改,但需重新提交审核。',
})
} catch {
return
}
submitting.value = true
try {
await profileService.submitReview()
appStore.toastSuccess('已提交审核')
router.replace('/review/pending')
} catch (err: any) {
appStore.toastError(err?.message || '提交失败')
} finally {
submitting.value = false
}
}
onMounted(loadAll)
</script>
<style scoped>
.pc__hint {
font-size: 12px;
color: var(--mini-text-secondary);
margin: 0 0 8px;
line-height: 1.4;
}
.pc {
padding: 16px;
}
.card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 4px 16px 14px;
margin-bottom: 12px;
}
.card :deep(.van-cell) {
padding-left: 0;
padding-right: 0;
}
.pc__status-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0 4px;
}
.pc__status-label {
font-size: 14px;
color: var(--mini-text-secondary);
}
.pc__comment {
margin-top: 8px;
padding: 10px 12px;
background: rgba(245, 166, 35, 0.1);
border-radius: 10px;
font-size: 13px;
color: var(--mini-text);
}
.pc__comment-title {
color: #b9770e;
}
.pc__progress {
margin-top: 4px;
}
.pc__progress-track {
height: 8px;
border-radius: 4px;
background: var(--mini-bg);
overflow: hidden;
}
.pc__progress-fill {
height: 100%;
background: var(--mini-accent-pink);
border-radius: 4px;
transition: width 0.3s;
}
.pc__progress-hint {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 8px;
}
.pc__intro {
font-size: 13px;
color: var(--mini-text);
line-height: 1.8;
padding: 4px 0;
}
.pc__row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 8px 0;
}
.pc__row-label {
font-size: 14px;
color: var(--mini-text-secondary);
}
.pc__row-value {
font-size: 14px;
color: var(--mini-text);
text-align: right;
}
.pc__notice-item {
font-size: 12px;
color: var(--mini-text-secondary);
line-height: 1.9;
}
.pc__actions {
margin-top: 4px;
}
</style>
@@ -0,0 +1,213 @@
<template>
<PageLayout title="婚恋态度">
<div class="pt">
<UiLoading v-if="loading" text="加载中…" />
<div class="card">
<UiSectionHeader title="婚恋态度" />
<p class="pt__hint">让我们更了解你的期待</p>
<div class="pt__field pt__field--column">
<div class="pt__label">当前婚恋目标</div>
<textarea
v-model="form.relationshipGoal"
class="pt__textarea"
maxlength="100"
placeholder="寻找以婚姻为目标的稳定关系"
/>
</div>
<van-field
v-model="marriagePlanLabel"
is-link
readonly
label="希望多久进入婚姻"
placeholder="请选择"
:border="false"
@click="openPicker('marriagePlan')"
/>
<van-field
v-model="activityLabel"
is-link
readonly
label="参加公益相亲活动"
placeholder="请选择"
@click="openPicker('activity')"
/>
<van-field
v-model="matchmakerLabel"
is-link
readonly
label="接受公益红娘推荐"
placeholder="请选择"
@click="openPicker('matchmaker')"
/>
</div>
<div class="pt__actions">
<van-button type="primary" block round :loading="saving" @click="onNext">下一步</van-button>
</div>
<van-popup v-model:show="picker.show" position="bottom" round>
<van-picker :columns="picker.columns" @cancel="picker.show = false" @confirm="onPickerConfirm" />
</van-popup>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { profileService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
const router = useRouter()
const appStore = useAppStore()
const DEFAULT_GOAL = '寻找以婚姻为目标的稳定关系'
const loading = ref(true)
const saving = ref(false)
const form = reactive({
relationshipGoal: DEFAULT_GOAL,
marriagePlan: '',
activityWillingness: '',
matchmakerWillingness: '',
})
const MARRIAGE_PLAN_OPTIONS = [
{ text: '半年内', value: 'half_year' },
{ text: '1年内', value: 'one_year' },
{ text: '1~2年', value: 'one_to_two_years' },
{ text: '2年以上', value: 'two_years_plus' },
{ text: '顺其自然', value: 'let_it_be' },
]
const ACTIVITY_OPTIONS = [
{ text: '愿意', value: 'willing' },
{ text: '暂不考虑', value: 'not_now' },
]
const MATCHMAKER_OPTIONS = [
{ text: '接受', value: 'accept' },
{ text: '暂不接受', value: 'not_now' },
]
function labelOf(options: Array<{ text: string; value: string }>, value: string): string {
return options.find((o) => o.value === value)?.text || ''
}
const marriagePlanLabel = computed(() => labelOf(MARRIAGE_PLAN_OPTIONS, form.marriagePlan))
const activityLabel = computed(() => labelOf(ACTIVITY_OPTIONS, form.activityWillingness))
const matchmakerLabel = computed(() => labelOf(MATCHMAKER_OPTIONS, form.matchmakerWillingness))
const picker = reactive<{ show: boolean; key: string; columns: any[] }>({ show: false, key: '', columns: [] })
const PICKER_COLUMNS: Record<string, any[]> = {
marriagePlan: MARRIAGE_PLAN_OPTIONS,
activity: ACTIVITY_OPTIONS,
matchmaker: MATCHMAKER_OPTIONS,
}
function openPicker(key: string) {
picker.key = key
picker.columns = PICKER_COLUMNS[key]
picker.show = true
}
function onPickerConfirm({ selectedOptions }: any) {
const value = selectedOptions?.[0]?.value
const key = picker.key
picker.show = false
if (value === undefined) return
if (key === 'marriagePlan') form.marriagePlan = value
else if (key === 'activity') form.activityWillingness = value
else if (key === 'matchmaker') form.matchmakerWillingness = value
}
async function load() {
loading.value = true
try {
const p: any = await profileService.getProfile()
form.relationshipGoal = p?.relationshipGoal || DEFAULT_GOAL
form.marriagePlan = p?.marriagePlan || ''
form.activityWillingness = p?.activityWillingness || ''
form.matchmakerWillingness = p?.matchmakerWillingness || ''
} catch {
/* 首次填写 */
} finally {
loading.value = false
}
}
async function onNext() {
if (saving.value) return
if (!form.marriagePlan) return appStore.toast('请选择希望多久进入婚姻')
if (!form.activityWillingness) return appStore.toast('请选择是否参加公益活动')
if (!form.matchmakerWillingness) return appStore.toast('请选择是否接受红娘推荐')
saving.value = true
try {
await profileService.updateProfile({
relationshipGoal: form.relationshipGoal,
marriagePlan: form.marriagePlan,
activityWillingness: form.activityWillingness,
matchmakerWillingness: form.matchmakerWillingness,
})
router.push('/profile/intro')
} catch (err: any) {
appStore.toastError(err?.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.pt__hint {
font-size: 12px;
color: var(--mini-text-secondary);
margin: 0 0 8px;
line-height: 1.4;
}
.pt {
padding: 16px;
}
.card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 4px 0 8px;
}
.card :deep(.ui-section-header) {
padding: 0 16px;
}
.card :deep(.van-field) {
padding-top: 10px;
padding-bottom: 10px;
}
.pt__field--column {
padding: 8px 16px 4px;
}
.pt__label {
font-size: 14px;
color: var(--mini-text-secondary);
margin-bottom: 8px;
}
.pt__textarea {
width: 100%;
min-height: 84px;
border: 1px solid var(--mini-border-weak, #f2f3f5);
border-radius: 10px;
padding: 10px 12px;
font-size: 14px;
color: var(--mini-text);
background: var(--mini-bg);
box-sizing: border-box;
resize: none;
font-family: inherit;
}
.pt__actions {
margin-top: 20px;
}
</style>
@@ -0,0 +1,173 @@
<template>
<PageLayout title="兴趣与生活方式">
<div class="pi">
<UiLoading v-if="loading" text="加载中…" />
<div class="card">
<UiSectionHeader title="兴趣" />
<p class="pi__hint">最多选择 {{ MAX_TAGS }} 项({{ selectedCount }}/{{ MAX_TAGS }})</p>
<div class="pi__tags">
<span
v-for="t in interests"
:key="t.code"
class="pi__tag"
:class="{ 'pi__tag--selected': t.selected }"
@click="toggleInterest(t)"
>
{{ t.name }}
</span>
</div>
</div>
<div class="card">
<UiSectionHeader title="生活方式" />
<p class="pi__hint">可选,多选</p>
<div class="pi__tags">
<span
v-for="t in lifestyles"
:key="t.code"
class="pi__tag"
:class="{ 'pi__tag--selected': t.selected }"
@click="t.selected = !t.selected"
>
{{ t.name }}
</span>
</div>
</div>
<div class="pi__actions">
<van-button type="primary" block round :loading="saving" @click="onNext">下一步</van-button>
</div>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { profileService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
const router = useRouter()
const appStore = useAppStore()
const MAX_TAGS = 8
const loading = ref(true)
const saving = ref(false)
const interests = ref([
{ code: 'travel', name: '旅行', selected: false },
{ code: 'reading', name: '阅读', selected: false },
{ code: 'sports', name: '运动', selected: false },
{ code: 'fitness', name: '健身', selected: false },
{ code: 'photography', name: '摄影', selected: false },
{ code: 'movie', name: '电影', selected: false },
{ code: 'music', name: '音乐', selected: false },
{ code: 'food', name: '美食', selected: false },
{ code: 'cooking', name: '烹饪', selected: false },
{ code: 'outdoor', name: '户外', selected: false },
{ code: 'pets', name: '宠物', selected: false },
{ code: 'games', name: '游戏', selected: false },
{ code: 'culture', name: '传统文化', selected: false },
{ code: 'charity', name: '公益活动', selected: false },
{ code: 'other', name: '其他', selected: false },
])
const lifestyles = ref([
{ code: 'quiet', name: '喜欢安静', selected: false },
{ code: 'social', name: '喜欢社交', selected: false },
{ code: 'outdoor', name: '喜欢户外', selected: false },
{ code: 'homebody', name: '居家型', selected: false },
{ code: 'regular_work', name: '工作规律', selected: false },
{ code: 'frequent_travel', name: '经常出差', selected: false },
{ code: 'family_oriented', name: '注重家庭', selected: false },
{ code: 'other', name: '其他', selected: false },
])
const selectedCount = computed(() => interests.value.filter((o) => o.selected).length)
function toggleInterest(t: { code: string; name: string; selected: boolean }) {
if (!t.selected && selectedCount.value >= MAX_TAGS) {
appStore.toast(`最多选择 ${MAX_TAGS} 个兴趣`)
return
}
t.selected = !t.selected
}
async function load() {
loading.value = true
try {
const p: any = await profileService.getProfile()
const savedInterests: string[] = p?.interests || []
const savedLifestyles: string[] = p?.lifestyles || []
interests.value = interests.value.map((o) => ({ ...o, selected: savedInterests.includes(o.code) }))
lifestyles.value = lifestyles.value.map((o) => ({ ...o, selected: savedLifestyles.includes(o.code) }))
} catch {
/* 首次填写 */
} finally {
loading.value = false
}
}
async function onNext() {
if (saving.value) return
saving.value = true
try {
await profileService.updateProfile({
interests: interests.value.filter((o) => o.selected).map((o) => o.code),
lifestyles: lifestyles.value.filter((o) => o.selected).map((o) => o.code),
})
router.push('/profile/intent')
} catch (err: any) {
appStore.toastError(err?.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.pi__hint {
font-size: 12px;
color: var(--mini-text-secondary);
margin: 0 0 8px;
line-height: 1.4;
}
.pi {
padding: 16px;
}
.card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 4px 16px 14px;
margin-bottom: 12px;
}
.pi__tags {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 4px;
}
.pi__tag {
font-size: 13px;
color: var(--mini-text);
background: var(--mini-bg);
border-radius: 16px;
padding: 7px 14px;
border: 1px solid transparent;
}
.pi__tag--selected {
color: var(--mini-tabbar-active);
background: rgba(244, 0, 90, 0.08);
border-color: var(--mini-tabbar-active);
}
.pi__actions {
margin-top: 8px;
}
</style>
@@ -0,0 +1,177 @@
<template>
<PageLayout title="个人介绍">
<div class="pri">
<UiLoading v-if="loading" text="加载中…" />
<div class="card">
<UiSectionHeader title="简单介绍一下自己" />
<p class="pri__hint">{{ MIN_LEN }}~{{ MAX_LEN }} 字</p>
<div class="pri__wrap">
<textarea
v-model="selfIntro"
class="pri__textarea"
:maxlength="MAX_LEN"
placeholder="可以介绍工作、性格、兴趣、生活状态,以及你期待怎样的感情"
/>
<div class="pri__counter">{{ selfIntro.length }}/{{ MAX_LEN }}</div>
</div>
</div>
<div class="card">
<UiSectionHeader title="填写须知" />
<div class="pri__guidelines">
<div v-for="(g, i) in guidelines" :key="i" class="pri__guideline">
<span class="pri__dot"></span>
<span class="pri__guideline-text">{{ g }}</span>
</div>
</div>
</div>
<div v-if="!isWizard" class="card">
<UiSectionHeader title="其他资料入口" />
<p class="pri__hint">点选可跳转对应步骤修改</p>
<van-cell-group :border="false">
<van-cell title="基础资料" is-link @click="router.push('/profile/basic')" />
<van-cell title="职业与生活" is-link @click="router.push('/profile/lifestyle')" />
<van-cell title="头像与照片" is-link @click="router.push('/profile/photos')" />
<van-cell title="兴趣与生活方式" is-link @click="router.push('/profile/interests')" />
<van-cell title="婚恋态度" is-link @click="router.push('/profile/intent')" />
<van-cell title="择偶条件" is-link @click="router.push('/profile/preference')" />
</van-cell-group>
</div>
<div class="pri__actions">
<van-button type="primary" block round :loading="saving" @click="onNext">下一步</van-button>
</div>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { profileService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const MIN_LEN = 20
const MAX_LEN = 500
const loading = ref(true)
const saving = ref(false)
const selfIntro = ref('')
const guidelines = [
'可以介绍工作、性格、兴趣、生活状态',
'以及你期待怎样的感情',
'禁止填写手机号、微信号、二维码',
'禁止广告、色情低俗、投资引流内容',
]
// 带 step=1 进入时为向导模式(隐藏资料入口),否则作为「我的资料」入口展示
const isWizard = computed(() => route.query.step === '1')
async function load() {
loading.value = true
try {
const p: any = await profileService.getProfile()
selfIntro.value = p?.selfIntro || ''
} catch {
/* 首次填写 */
} finally {
loading.value = false
}
}
async function onNext() {
if (saving.value) return
const text = selfIntro.value.trim()
if (text.length < MIN_LEN) return appStore.toast(`至少填写 ${MIN_LEN} 字`)
if (text.length > MAX_LEN) return appStore.toast(`最多填写 ${MAX_LEN} 字`)
saving.value = true
try {
await profileService.updateProfile({ selfIntro: text })
router.push('/profile/preference')
} catch (err: any) {
appStore.toastError(err?.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.pri__hint {
font-size: 12px;
color: var(--mini-text-secondary);
margin: 0 0 8px;
line-height: 1.4;
}
.pri {
padding: 16px;
}
.card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 4px 16px 14px;
margin-bottom: 12px;
}
.pri__wrap {
position: relative;
margin-top: 4px;
}
.pri__textarea {
width: 100%;
min-height: 140px;
border: 1px solid var(--mini-border-weak, #f2f3f5);
border-radius: 10px;
padding: 12px;
font-size: 14px;
color: var(--mini-text);
background: var(--mini-bg);
box-sizing: border-box;
resize: none;
font-family: inherit;
}
.pri__counter {
text-align: right;
font-size: 12px;
color: var(--mini-text-muted);
margin-top: 6px;
}
.pri__guidelines {
margin-top: 4px;
}
.pri__guideline {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
}
.pri__dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--mini-tabbar-active);
flex: none;
}
.pri__guideline-text {
font-size: 13px;
color: var(--mini-text-secondary);
}
.card :deep(.van-cell) {
padding-left: 0;
padding-right: 0;
}
.pri__actions {
margin-top: 8px;
}
</style>
@@ -0,0 +1,242 @@
<template>
<PageLayout title="职业与生活">
<div class="pl">
<UiLoading v-if="loading" text="加载中…" />
<div class="card">
<UiSectionHeader title="职业与生活" />
<p class="pl__hint">完善你的生活状态</p>
<van-field
v-model="occupationLabel"
is-link
readonly
label="职业类别"
placeholder="请选择"
:border="false"
@click="openPicker('occupation')"
/>
<van-field
v-model="workRegionLabel"
is-link
readonly
label="工作区域"
placeholder="请选择"
@click="openPicker('workRegion')"
/>
<van-field
v-model="smokingLabel"
is-link
readonly
label="是否吸烟"
placeholder="请选择"
@click="openPicker('smoking')"
/>
<van-field
v-model="drinkingLabel"
is-link
readonly
label="是否饮酒"
placeholder="请选择"
@click="openPicker('drinking')"
/>
<van-field
v-model="longTermLabel"
is-link
readonly
label="是否长期居住丹阳"
placeholder="请选择"
@click="openPicker('longTerm')"
/>
<van-field
v-model="futureLabel"
is-link
readonly
label="未来是否愿意继续在丹阳"
placeholder="请选择"
@click="openPicker('future')"
/>
</div>
<div class="pl__actions">
<van-button type="primary" block round :loading="saving" @click="onNext">下一步</van-button>
</div>
<van-popup v-model:show="picker.show" position="bottom" round>
<van-picker :columns="picker.columns" @cancel="picker.show = false" @confirm="onPickerConfirm" />
</van-popup>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { profileService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
const router = useRouter()
const appStore = useAppStore()
const loading = ref(true)
const saving = ref(false)
const form = reactive({
occupationCode: '',
workRegionCode: '',
smokingStatus: '',
drinkingStatus: '',
longTermDanyang: '',
futureDanyang: '',
})
const OCCUPATION_OPTIONS = [
{ text: '机关事业单位', value: 'government' },
{ text: '教育', value: 'education' },
{ text: '医疗', value: 'medical' },
{ text: '企业职员', value: 'enterprise' },
{ text: '技术人员', value: 'technical' },
{ text: '个体经营', value: 'self_employed' },
{ text: '自由职业', value: 'freelance' },
{ text: '服务行业', value: 'service' },
{ text: '农业', value: 'agriculture' },
{ text: '其他', value: 'other' },
]
const WORK_REGION_OPTIONS = [
{ text: '丹阳', value: '丹阳' },
{ text: '镇江市区', value: '镇江市区' },
{ text: '镇江其他辖区', value: '镇江其他辖区' },
{ text: '南京', value: '南京' },
{ text: '不限', value: '不限' },
]
const SMOKING_OPTIONS = [
{ text: '不吸烟', value: 'never' },
{ text: '偶尔', value: 'sometimes' },
{ text: '经常', value: 'often' },
]
const DRINKING_OPTIONS = [
{ text: '不饮酒', value: 'never' },
{ text: '偶尔', value: 'sometimes' },
{ text: '经常', value: 'often' },
]
const YES_NO_OPTIONS = [
{ text: '是', value: 'yes' },
{ text: '否', value: 'no' },
]
const FUTURE_OPTIONS = [
{ text: '愿意', value: 'willing' },
{ text: '不确定', value: 'uncertain' },
{ text: '不愿意', value: 'unwilling' },
]
function labelOf(options: Array<{ text: string; value: string }>, value: string): string {
return options.find((o) => o.value === value)?.text || ''
}
const occupationLabel = computed(() => labelOf(OCCUPATION_OPTIONS, form.occupationCode))
const workRegionLabel = computed(() => labelOf(WORK_REGION_OPTIONS, form.workRegionCode))
const smokingLabel = computed(() => labelOf(SMOKING_OPTIONS, form.smokingStatus))
const drinkingLabel = computed(() => labelOf(DRINKING_OPTIONS, form.drinkingStatus))
const longTermLabel = computed(() => labelOf(YES_NO_OPTIONS, form.longTermDanyang))
const futureLabel = computed(() => labelOf(FUTURE_OPTIONS, form.futureDanyang))
const picker = reactive<{ show: boolean; key: string; columns: any[] }>({ show: false, key: '', columns: [] })
const PICKER_COLUMNS: Record<string, any[]> = {
occupation: OCCUPATION_OPTIONS,
workRegion: WORK_REGION_OPTIONS,
smoking: SMOKING_OPTIONS,
drinking: DRINKING_OPTIONS,
longTerm: YES_NO_OPTIONS,
future: FUTURE_OPTIONS,
}
function openPicker(key: string) {
picker.key = key
picker.columns = PICKER_COLUMNS[key]
picker.show = true
}
function onPickerConfirm({ selectedOptions }: any) {
const value = selectedOptions?.[0]?.value
const key = picker.key
picker.show = false
if (value === undefined) return
if (key === 'occupation') form.occupationCode = value
else if (key === 'workRegion') form.workRegionCode = value
else if (key === 'smoking') form.smokingStatus = value
else if (key === 'drinking') form.drinkingStatus = value
else if (key === 'longTerm') form.longTermDanyang = value
else if (key === 'future') form.futureDanyang = value
}
async function load() {
loading.value = true
try {
const p: any = await profileService.getProfile()
form.occupationCode = p?.occupationCode || ''
form.workRegionCode = p?.workRegionCode || ''
form.smokingStatus = p?.smokingStatus || ''
form.drinkingStatus = p?.drinkingStatus || ''
form.longTermDanyang = p?.longTermDanyang || ''
form.futureDanyang = p?.futureDanyang || ''
} catch {
/* 首次填写 */
} finally {
loading.value = false
}
}
async function onNext() {
if (saving.value) return
if (!form.occupationCode) return appStore.toast('请选择职业类别')
if (!form.workRegionCode) return appStore.toast('请选择工作区域')
saving.value = true
try {
await profileService.updateProfile({
occupationCode: form.occupationCode,
workRegionCode: form.workRegionCode,
smokingStatus: form.smokingStatus,
drinkingStatus: form.drinkingStatus,
longTermDanyang: form.longTermDanyang,
futureDanyang: form.futureDanyang,
})
router.push('/profile/photos')
} catch (err: any) {
appStore.toastError(err?.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.pl__hint {
font-size: 12px;
color: var(--mini-text-secondary);
margin: 0 0 8px;
line-height: 1.4;
}
.pl {
padding: 16px;
}
.card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 4px 0 8px;
}
.card :deep(.ui-section-header) {
padding: 0 16px;
}
.card :deep(.van-field) {
padding-top: 10px;
padding-bottom: 10px;
}
.pl__actions {
margin-top: 20px;
}
</style>
@@ -0,0 +1,279 @@
<template>
<PageLayout title="头像与照片">
<div class="pp">
<UiLoading v-if="loading" text="加载照片…" />
<UiErrorState v-else-if="error" @retry="loadPhotos" />
<template v-else>
<div class="card">
<UiSectionHeader title="主头像" />
<p class="pp__hint">必须上传 1 张清晰正面照</p>
<div class="pp__grid">
<div v-for="p in avatarPhotos" :key="p.photoId" class="pp__item" @click="preview(p)">
<img :src="p.url" class="pp__img" alt="" />
<div v-if="p.isMain || p.main" class="pp__main-flag">主头像</div>
<div class="pp__remove" @click.stop="onRemove(p)">×</div>
</div>
<div v-if="avatarPhotos.length === 0" class="pp__add" @click="choose('avatar')">
<span class="pp__add-icon">+</span>
<span class="pp__add-text">上传头像</span>
</div>
</div>
</div>
<div class="card">
<UiSectionHeader title="生活照片" />
<p class="pp__hint">最多 {{ MAX_LIFESTYLE }} 张(可选)</p>
<div class="pp__grid">
<div v-for="p in lifestylePhotos" :key="p.photoId" class="pp__item" @click="preview(p)">
<img :src="p.url" class="pp__img" alt="" />
<div class="pp__remove" @click.stop="onRemove(p)">×</div>
</div>
<div v-if="lifestylePhotos.length < MAX_LIFESTYLE" class="pp__add" @click="choose('lifestyle')">
<span class="pp__add-icon">+</span>
<span class="pp__add-text">上传照片</span>
</div>
</div>
</div>
<div class="card">
<UiSectionHeader title="上传须知" />
<div class="pp__guidelines">
<div v-for="(g, i) in guidelines" :key="i" class="pp__guideline">
<span class="pp__dot"></span>
<span class="pp__guideline-text">{{ g }}</span>
</div>
</div>
</div>
</template>
<div class="pp__actions">
<van-button type="primary" block round :disabled="loading || error" @click="onNext">下一步</van-button>
</div>
<input ref="fileInput" type="file" accept="image/*" class="pp__hidden-input" @change="onFileChange" />
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showConfirmDialog, showImagePreview } from 'vant'
import { profileService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiErrorState from '@/components/UiErrorState.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
const router = useRouter()
const appStore = useAppStore()
const MAX_LIFESTYLE = 5 // 总上限 6(含 1 张头像)减 1
const fileInput = ref<HTMLInputElement | null>(null)
const loading = ref(true)
const error = ref(false)
const uploadTarget = ref<'avatar' | 'lifestyle'>('avatar')
const avatarPhotos = ref<any[]>([])
const lifestylePhotos = ref<any[]>([])
const guidelines = [
'请使用本人近期真实照片',
'禁止网图、二维码、广告、联系方式图片',
'建议真人生活照,避免过度美颜',
]
async function loadPhotos() {
loading.value = true
error.value = false
try {
const result: any = await profileService.getPhotos()
const photos = result?.photos || result?.items || result?.records || []
avatarPhotos.value = photos.filter((p: any) => p.photoType === 'avatar')
lifestylePhotos.value = photos.filter((p: any) => p.photoType === 'lifestyle')
} catch {
error.value = true
} finally {
loading.value = false
}
}
function choose(target: 'avatar' | 'lifestyle') {
if (target === 'lifestyle' && lifestylePhotos.value.length >= MAX_LIFESTYLE) {
appStore.toast(`最多上传 ${MAX_LIFESTYLE} 张生活照`)
return
}
uploadTarget.value = target
fileInput.value?.click()
}
async function onFileChange(evt: Event) {
const input = evt.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
const photoType = uploadTarget.value
try {
const token: any = await profileService.requestUploadToken({
fileName: `photo_${Date.now()}.jpg`,
contentType: file.type || 'image/jpeg',
})
await fetch(token.uploadUrl, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type || 'image/jpeg' },
})
const confirmRes: any = await profileService.confirmPhoto({ fileKey: token.fileKey, photoType })
if ((photoType === 'avatar' || avatarPhotos.value.length === 0) && confirmRes?.photoId) {
await profileService.setMainPhoto(confirmRes.photoId)
}
appStore.toastSuccess('上传成功')
await loadPhotos()
} catch {
appStore.toastError('上传失败,请稍后重试')
}
}
async function onRemove(photo: any) {
try {
await showConfirmDialog({ title: '删除照片', message: '确定删除这张照片?删除后不可恢复。' })
} catch {
return
}
try {
await profileService.deletePhoto(photo.photoId)
appStore.toastSuccess('已删除')
await loadPhotos()
} catch (err: any) {
appStore.toastError(err?.message || '删除失败')
}
}
function preview(photo: any) {
const urls = [...avatarPhotos.value, ...lifestylePhotos.value].map((p) => p.url).filter(Boolean)
if (!urls.length) return
showImagePreview({ images: urls, startPosition: urls.indexOf(photo.url) })
}
function onNext() {
if (avatarPhotos.value.length === 0) {
appStore.toast('请至少上传 1 张主头像')
return
}
router.push('/profile/interests')
}
onMounted(loadPhotos)
</script>
<style scoped>
.pp__hint {
font-size: 12px;
color: var(--mini-text-secondary);
margin: 0 0 8px;
line-height: 1.4;
}
.pp {
padding: 16px;
}
.card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 4px 16px 14px;
margin-bottom: 12px;
}
.pp__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-top: 4px;
}
.pp__item {
position: relative;
padding-top: 100%;
border-radius: 10px;
overflow: hidden;
background: var(--mini-bg);
}
.pp__img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.pp__main-flag {
position: absolute;
left: 0;
bottom: 0;
font-size: 10px;
color: #fff;
background: var(--mini-tabbar-active);
padding: 1px 6px;
border-top-right-radius: 8px;
}
.pp__remove {
position: absolute;
top: 2px;
right: 2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 14px;
line-height: 20px;
text-align: center;
}
.pp__add {
position: relative;
padding-top: 100%;
border: 1px dashed var(--mini-border, #dcdfe6);
border-radius: 10px;
color: var(--mini-text-secondary);
}
.pp__add-icon {
position: absolute;
top: 28%;
left: 0;
right: 0;
text-align: center;
font-size: 24px;
}
.pp__add-text {
position: absolute;
top: 58%;
left: 0;
right: 0;
text-align: center;
font-size: 11px;
}
.pp__guidelines {
margin-top: 4px;
}
.pp__guideline {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
}
.pp__dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--mini-tabbar-active);
flex: none;
}
.pp__guideline-text {
font-size: 13px;
color: var(--mini-text-secondary);
}
.pp__actions {
margin-top: 8px;
}
.pp__hidden-input {
display: none;
}
</style>
@@ -0,0 +1,435 @@
<template>
<PageLayout title="择偶条件">
<div class="pf">
<UiLoading v-if="loading" text="加载中…" />
<!-- 年龄 -->
<div class="card">
<UiSectionHeader title="年龄范围" />
<p class="pf__hint">可设置必须符合 / 最好符合</p>
<div class="pf__range">
<van-field v-model="pref.ageMin" type="digit" placeholder="最小" class="pf__range-field" />
<span class="pf__dash">~</span>
<van-field v-model="pref.ageMax" type="digit" placeholder="最大" class="pf__range-field" />
<span class="pf__unit">岁</span>
</div>
<div class="pf__importance">
<span class="pf__importance-label">重要程度</span>
<span
v-for="o in IMPORTANCE_OPTIONS"
:key="o.value"
class="pf__imp"
:class="{ 'pf__imp--active': pref.agePriority === o.value }"
@click="setImportance('agePriority', o.value)"
>{{ o.text }}</span>
</div>
</div>
<!-- 身高 -->
<div class="card">
<UiSectionHeader title="身高范围" />
<p class="pf__hint">可设置必须符合 / 最好符合</p>
<div class="pf__range">
<van-field v-model="pref.heightMin" type="digit" placeholder="最小" class="pf__range-field" />
<span class="pf__dash">~</span>
<van-field v-model="pref.heightMax" type="digit" placeholder="最大" class="pf__range-field" />
<span class="pf__unit">cm</span>
</div>
<div class="pf__importance">
<span class="pf__importance-label">重要程度</span>
<span
v-for="o in IMPORTANCE_OPTIONS"
:key="o.value"
class="pf__imp"
:class="{ 'pf__imp--active': pref.heightPriority === o.value }"
@click="setImportance('heightPriority', o.value)"
>{{ o.text }}</span>
</div>
</div>
<!-- 婚姻状况 -->
<div class="card">
<UiSectionHeader title="期望婚姻状况" />
<p class="pf__hint">多选</p>
<div class="pf__tags">
<span
v-for="o in MARITAL_OPTIONS"
:key="o.value"
class="pf__tag"
:class="{ 'pf__tag--selected': pref.maritalStatusAccepted.includes(o.value) }"
@click="toggleMarital(o.value)"
>
{{ o.text }}
</span>
</div>
<div class="pf__importance">
<span class="pf__importance-label">重要程度</span>
<span
v-for="o in IMPORTANCE_OPTIONS"
:key="o.value"
class="pf__imp"
:class="{ 'pf__imp--active': pref.maritalPriority === o.value }"
@click="setImportance('maritalPriority', o.value)"
>{{ o.text }}</span>
</div>
</div>
<!-- 学历 -->
<div class="card">
<UiSectionHeader title="期望学历" />
<van-field
v-model="educationLabel"
is-link
readonly
label="最低学历"
placeholder="不限"
:border="false"
@click="openPicker('education')"
/>
<div class="pf__importance">
<span class="pf__importance-label">重要程度</span>
<span
v-for="o in IMPORTANCE_OPTIONS"
:key="o.value"
class="pf__imp"
:class="{ 'pf__imp--active': pref.educationPriority === o.value }"
@click="setImportance('educationPriority', o.value)"
>{{ o.text }}</span>
</div>
</div>
<!-- 更多条件 -->
<div class="card">
<UiSectionHeader title="更多择偶条件" />
<p class="pf__hint">可选,均允许不限</p>
<van-field
v-model="childrenLabel"
is-link
readonly
label="是否接受有子女"
placeholder="不限"
:border="false"
@click="openPicker('children')"
/>
<van-field
v-model="smokingLabel"
is-link
readonly
label="是否吸烟"
placeholder="不限"
@click="openPicker('smoking')"
/>
<van-field
v-model="drinkingLabel"
is-link
readonly
label="是否饮酒"
placeholder="不限"
@click="openPicker('drinking')"
/>
<van-field
v-model="marriagePlanLabel"
is-link
readonly
label="结婚时间计划"
placeholder="不限"
@click="openPicker('marriagePlan')"
/>
</div>
<div class="pf__actions">
<van-button type="primary" block round :loading="saving" @click="onNext">下一步</van-button>
</div>
<van-popup v-model:show="picker.show" position="bottom" round>
<van-picker :columns="picker.columns" @cancel="picker.show = false" @confirm="onPickerConfirm" />
</van-popup>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showConfirmDialog } from 'vant'
import { profileService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
const router = useRouter()
const appStore = useAppStore()
const IMPORTANCE_OPTIONS = [
{ text: '必须符合', value: 'required' },
{ text: '最好符合', value: 'preferred' },
]
const loading = ref(true)
const saving = ref(false)
const pref = reactive({
ageMin: '',
ageMax: '',
agePriority: 'preferred',
heightMin: '',
heightMax: '',
heightPriority: 'preferred',
maritalStatusAccepted: [] as string[],
maritalPriority: 'preferred',
educationCode: '',
educationPriority: 'preferred',
childrenAcceptance: 'any',
smokingPreference: '',
drinkingPreference: '',
marriagePlanPreference: '',
})
const MARITAL_OPTIONS = [
{ text: '未婚', value: 'never_married' },
{ text: '离异', value: 'divorced' },
{ text: '丧偶', value: 'widowed' },
]
const EDUCATION_OPTIONS = [
{ text: '不限', value: '' },
{ text: '高中/中专以上', value: 'senior_high' },
{ text: '大专以上', value: 'college' },
{ text: '本科以上', value: 'bachelor' },
{ text: '研究生以上', value: 'master' },
]
const CHILDREN_OPTIONS = [
{ text: '不限', value: 'any' },
{ text: '可接受', value: 'accept' },
{ text: '不接受', value: 'reject' },
]
const SMOKING_OPTIONS = [
{ text: '不限', value: '' },
{ text: '不吸烟', value: 'never' },
{ text: '偶尔', value: 'sometimes' },
{ text: '经常', value: 'often' },
]
const DRINKING_OPTIONS = [
{ text: '不限', value: '' },
{ text: '不饮酒', value: 'never' },
{ text: '偶尔', value: 'sometimes' },
{ text: '经常', value: 'often' },
]
const MARRIAGE_PLAN_OPTIONS = [
{ text: '不限', value: '' },
{ text: '半年内', value: 'half_year' },
{ text: '1年内', value: 'one_year' },
{ text: '1~2年', value: 'one_to_two_years' },
{ text: '顺其自然', value: 'let_it_be' },
]
function labelOf(options: Array<{ text: string; value: string }>, value: string): string {
return options.find((o) => o.value === value)?.text || ''
}
const educationLabel = computed(() => labelOf(EDUCATION_OPTIONS, pref.educationCode) || '不限')
const childrenLabel = computed(() => labelOf(CHILDREN_OPTIONS, pref.childrenAcceptance) || '不限')
const smokingLabel = computed(() => labelOf(SMOKING_OPTIONS, pref.smokingPreference) || '不限')
const drinkingLabel = computed(() => labelOf(DRINKING_OPTIONS, pref.drinkingPreference) || '不限')
const marriagePlanLabel = computed(() => labelOf(MARRIAGE_PLAN_OPTIONS, pref.marriagePlanPreference) || '不限')
const picker = reactive<{ show: boolean; key: string; columns: any[] }>({ show: false, key: '', columns: [] })
const PICKER_COLUMNS: Record<string, any[]> = {
education: EDUCATION_OPTIONS,
children: CHILDREN_OPTIONS,
smoking: SMOKING_OPTIONS,
drinking: DRINKING_OPTIONS,
marriagePlan: MARRIAGE_PLAN_OPTIONS,
}
function openPicker(key: string) {
picker.key = key
picker.columns = PICKER_COLUMNS[key]
picker.show = true
}
function onPickerConfirm({ selectedOptions }: any) {
const value = selectedOptions?.[0]?.value
const key = picker.key
picker.show = false
if (value === undefined) return
if (key === 'education') pref.educationCode = value
else if (key === 'children') pref.childrenAcceptance = value || 'any'
else if (key === 'smoking') pref.smokingPreference = value
else if (key === 'drinking') pref.drinkingPreference = value
else if (key === 'marriagePlan') pref.marriagePlanPreference = value
}
function toggleMarital(code: string) {
const list = [...pref.maritalStatusAccepted]
const idx = list.indexOf(code)
if (idx >= 0) list.splice(idx, 1)
else list.push(code)
pref.maritalStatusAccepted = list
}
/** Required 不可自动放宽:必须符合 → 最好符合 需二次确认 */
async function setImportance(field: string, value: string) {
if ((pref as any)[field] === 'required' && value === 'preferred') {
try {
await showConfirmDialog({
title: '确认放宽此条件?',
message: '"必须符合"的条件不会被系统放宽。改为"最好符合"后,可能匹配到略不满足此条件的对象。',
})
} catch {
return
}
}
;(pref as any)[field] = value
}
function toApiPayload() {
const num = (v: string) => (v === '' || v === null ? null : Number(v))
return {
minAge: num(pref.ageMin),
maxAge: num(pref.ageMax),
agePriority: pref.agePriority,
minHeightCm: num(pref.heightMin),
maxHeightCm: num(pref.heightMax),
heightPriority: pref.heightPriority,
maritalStatuses: pref.maritalStatusAccepted,
maritalPriority: pref.maritalPriority,
minEducationCode: pref.educationCode || null,
educationPriority: pref.educationPriority,
childrenAcceptance: pref.childrenAcceptance,
smokingPreference: pref.smokingPreference || null,
drinkingPreference: pref.drinkingPreference || null,
marriagePlanPreference: pref.marriagePlanPreference || null,
}
}
function fromApiPayload(p: any) {
if (!p) return
const str = (v: any) => (v === undefined || v === null ? '' : String(v))
pref.ageMin = str(p.minAge)
pref.ageMax = str(p.maxAge)
pref.agePriority = p.agePriority || 'preferred'
pref.heightMin = str(p.minHeightCm)
pref.heightMax = str(p.maxHeightCm)
pref.heightPriority = p.heightPriority || 'preferred'
pref.maritalStatusAccepted = p.maritalStatuses || []
pref.maritalPriority = p.maritalPriority || 'preferred'
pref.educationCode = p.minEducationCode || ''
pref.educationPriority = p.educationPriority || 'preferred'
pref.childrenAcceptance = p.childrenAcceptance || 'any'
pref.smokingPreference = p.smokingPreference || ''
pref.drinkingPreference = p.drinkingPreference || ''
pref.marriagePlanPreference = p.marriagePlanPreference || ''
}
async function load() {
loading.value = true
try {
const p: any = await profileService.getPreferences()
fromApiPayload(p)
} catch {
/* 首次填写 */
} finally {
loading.value = false
}
}
async function onNext() {
if (saving.value) return
if (!pref.ageMin || !pref.ageMax) return appStore.toast('请填写年龄范围')
saving.value = true
try {
await profileService.updatePreferences(toApiPayload())
router.push('/profile/confirm')
} catch (err: any) {
appStore.toastError(err?.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.pf__hint {
font-size: 12px;
color: var(--mini-text-secondary);
margin: 0 0 8px;
line-height: 1.4;
}
.pf {
padding: 16px;
}
.card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 4px 16px 14px;
margin-bottom: 12px;
}
.pf__range {
display: flex;
align-items: center;
gap: 8px;
}
.pf__range-field {
flex: 1;
padding-left: 0;
padding-right: 0;
}
.pf__dash {
color: var(--mini-text-muted);
}
.pf__unit {
font-size: 13px;
color: var(--mini-text-secondary);
}
.pf__tags {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 4px 0 8px;
}
.pf__tag {
font-size: 13px;
color: var(--mini-text);
background: var(--mini-bg);
border-radius: 16px;
padding: 7px 14px;
border: 1px solid transparent;
}
.pf__tag--selected {
color: var(--mini-tabbar-active);
background: rgba(244, 0, 90, 0.08);
border-color: var(--mini-tabbar-active);
}
.pf__importance {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
}
.pf__importance-label {
font-size: 12px;
color: var(--mini-text-muted);
}
.pf__imp {
font-size: 12px;
color: var(--mini-text-secondary);
background: var(--mini-bg);
border-radius: 12px;
padding: 4px 12px;
}
.pf__imp--active {
color: #fff;
background: var(--mini-tabbar-active);
}
.card :deep(.van-field) {
padding-top: 10px;
padding-bottom: 10px;
}
.pf__actions {
margin-top: 8px;
}
</style>
@@ -0,0 +1,140 @@
<template>
<div class="op-login">
<div class="op-login__hero">
<div class="op-login__logo">缘</div>
<div class="op-login__title">善缘丹阳</div>
<div class="op-login__sub">红娘工作台 · 工作人员入口</div>
</div>
<div class="op-login__body">
<div class="op-login__field">
<span class="op-login__label">用户名</span>
<van-field v-model="username" placeholder="请输入用户名" />
</div>
<div class="op-login__field">
<span class="op-login__label">密码</span>
<van-field
v-model="password"
type="password"
placeholder="请输入密码"
@keyup.enter="onLogin"
/>
</div>
<p v-if="errorText" class="op-login__error">{{ errorText }}</p>
<van-button type="primary" block round :loading="loading" @click="onLogin">登 录</van-button>
<div class="op-login__forgot" @click="onForgot">忘记密码?</div>
<p class="op-login__foot">请联系管理员获取账号</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useOperatorStore } from '@/stores/operator'
import { useAppStore } from '@/stores/app'
const router = useRouter()
const operatorStore = useOperatorStore()
const appStore = useAppStore()
const username = ref('')
const password = ref('')
const loading = ref(false)
const errorText = ref('')
async function onLogin() {
if (loading.value) return
if (!username.value || !password.value) {
errorText.value = '请输入用户名和密码'
return
}
loading.value = true
errorText.value = ''
try {
await operatorStore.login(username.value, password.value)
appStore.toastSuccess('登录成功')
router.replace('/operator/workbench')
} catch (err: any) {
errorText.value = err?.message || '登录失败,请确认账号密码'
} finally {
loading.value = false
}
}
function onForgot() {
appStore.toast('请联系平台管理员重置密码')
}
</script>
<style scoped>
.op-login {
min-height: 100vh;
background: var(--mini-bg);
}
.op-login__hero {
background: var(--mini-brand-gradient);
color: #fff;
padding: 56px 24px 44px;
text-align: center;
border-bottom-left-radius: 24px;
border-bottom-right-radius: 24px;
}
.op-login__logo {
width: 60px;
height: 60px;
border-radius: 18px;
margin: 0 auto 14px;
background: rgba(255, 255, 255, 0.18);
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
font-weight: 600;
}
.op-login__title {
font-size: 22px;
font-weight: 600;
letter-spacing: 2px;
}
.op-login__sub {
font-size: 12px;
opacity: 0.82;
margin-top: 8px;
}
.op-login__body {
padding: 24px 20px;
margin-top: -18px;
}
.op-login__field {
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 10px 14px;
margin-bottom: 14px;
box-shadow: var(--mini-shadow);
}
.op-login__label {
font-size: 12px;
color: var(--mini-text-secondary);
}
.op-login__error {
color: var(--mini-danger);
font-size: 13px;
margin: 4px 4px 12px;
}
.op-login__forgot {
text-align: center;
font-size: 13px;
color: var(--mini-text-secondary);
margin-top: 16px;
}
.op-login__foot {
text-align: center;
font-size: 11px;
color: var(--mini-text-muted);
margin-top: 20px;
}
</style>
@@ -0,0 +1,374 @@
<template>
<OperatorLayout title="服务详情">
<div class="osd">
<UiErrorState v-if="error" @retry="load" />
<UiLoading v-else-if="loading" />
<template v-else-if="detail">
<div class="osd__card">
<div class="osd__card-title">服务对象</div>
<div class="osd__row">
<span class="osd__label">服务编号</span>
<span class="osd__value">{{ detail.serviceDisplayId || detail.displayId || detail.serviceId }}</span>
</div>
<div class="osd__row">
<span class="osd__label">服务状态</span>
<span class="osd__value">{{ statusLabel(detail.status) }}</span>
</div>
<div v-if="profile" class="osd__grid">
<div>展示名:{{ profile.displayName || '未设置' }}</div>
<div>年龄:{{ profile.age || '未设置' }}</div>
<div>地区:{{ profile.regionName || '未设置' }}</div>
<div>婚姻状态:{{ profile.maritalStatus || '未设置' }}</div>
</div>
<div v-if="detail.description" class="osd__desc">服务需求:{{ detail.description }}</div>
</div>
<div class="osd__card">
<div class="osd__card-title">推荐候选对象</div>
<div class="osd__hint">仅提供候选建议,是否表达喜欢由用户本人决定(红娘不可代用户操作)。</div>
<van-field v-model="recommendForm.targetUserId" placeholder="候选对象用户编号 / ID" />
<van-field
v-model="recommendForm.reason"
class="osd__mt"
type="textarea"
rows="2"
maxlength="200"
placeholder="推荐理由(可选)"
/>
<van-button
class="osd__mt"
size="small"
type="primary"
block
:loading="recommending"
@click="onRecommend"
>
提交推荐
</van-button>
<div v-if="recommendations.length" class="osd__rec-list">
<div v-for="r in recommendations" :key="r.id" class="osd__rec">
<span>{{ r.targetUserId || '候选对象' }}</span>
<UiTag type="info">{{ recStatusLabel(r.status) }}</UiTag>
</div>
</div>
</div>
<div class="osd__card">
<div class="osd__card-title">记录服务</div>
<div class="osd__field-row">
<span class="osd__label">服务类型</span>
<select v-model="followupForm.followupType" class="osd__select">
<option v-for="t in followupTypes" :key="t.value" :value="t.value">{{ t.label }}</option>
</select>
</div>
<van-field
v-model="followupForm.content"
class="osd__mt"
type="textarea"
rows="3"
maxlength="1000"
placeholder="只记录完成公益服务所必需的信息"
/>
<van-field
v-model="followupForm.nextFollowupAt"
class="osd__mt"
placeholder="下次跟进时间(可选,如 2026-10-14 15:30)"
/>
<van-button
class="osd__mt"
size="small"
type="primary"
block
:loading="submitting"
@click="onFollowup"
>
保存服务记录
</van-button>
</div>
<div class="osd__card">
<div class="osd__card-title">服务历史</div>
<div v-if="followups.length">
<div v-for="(f, i) in followups" :key="f.id || i" class="osd__history">
<div class="osd__history-head">
{{ followupTypeLabel(f.followupType) }} · {{ formatDateTime(f.createdAt) }}
</div>
<div class="osd__history-body">{{ f.content }}</div>
</div>
</div>
<div v-else class="osd__hint">暂无服务记录</div>
</div>
<div v-if="!isCompleted" class="osd__complete">
<van-button plain block round :loading="completing" @click="onComplete">完成服务</van-button>
</div>
</template>
<UiEmpty v-else description="服务不存在或已结束" />
</div>
</OperatorLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { showConfirmDialog } from 'vant'
import { operatorMatchmakerService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import { formatDateTime } from '@/utils/datetime'
import OperatorLayout from '@/layouts/OperatorLayout.vue'
import UiTag from '@/components/UiTag.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const route = useRoute()
const appStore = useAppStore()
const serviceId = typeof route.query.id === 'string' ? route.query.id : ''
const followupTypes = [
{ value: 'followup', label: '跟进记录' },
{ value: 'phone', label: '电话沟通' },
{ value: 'wechat', label: '微信沟通' },
{ value: 'in_person', label: '线下见面' },
{ value: 'other', label: '其他' },
]
const STATUS_LABEL: Record<string, string> = {
submitted: '已提交',
assigned: '已分配',
processing: '服务中',
in_service: '服务中',
waiting_user: '待用户确认',
completed: '已完成',
cancelled: '已取消',
}
const REC_STATUS_LABEL: Record<string, string> = {
pending_view: '待用户查看',
viewed: '用户已查看',
liked: '用户已喜欢',
passed: '用户已跳过',
confirmed_mutual: '已双向确认',
expired: '已过期',
}
const detail = ref<any>(null)
const loading = ref(true)
const error = ref(false)
const recommending = ref(false)
const submitting = ref(false)
const completing = ref(false)
const recommendForm = reactive({ targetUserId: '', reason: '' })
const followupForm = reactive({ followupType: 'followup', content: '', nextFollowupAt: '' })
const profile = computed(() => detail.value?.userProfile || null)
const recommendations = computed<any[]>(() => detail.value?.recommendations || [])
const followups = computed<any[]>(() => detail.value?.followups || [])
const isCompleted = computed(() => detail.value?.status === 'completed' || detail.value?.status === 'cancelled')
function statusLabel(code?: string): string {
return (code && STATUS_LABEL[code]) || code || ''
}
function recStatusLabel(code?: string): string {
return (code && REC_STATUS_LABEL[code]) || code || ''
}
function followupTypeLabel(code?: string): string {
return followupTypes.find((t) => t.value === code)?.label || code || ''
}
async function load() {
if (!serviceId) {
loading.value = false
error.value = true
appStore.toastError('服务记录不存在')
return
}
loading.value = true
error.value = false
try {
detail.value = await operatorMatchmakerService.serviceDetail(serviceId)
} catch (err: any) {
error.value = true
appStore.toastError(err?.message || '加载失败')
} finally {
loading.value = false
}
}
async function onRecommend() {
if (recommending.value) return
if (!recommendForm.targetUserId.trim()) {
appStore.toast('请填写候选对象编号')
return
}
recommending.value = true
try {
await operatorMatchmakerService.recommend(serviceId, {
targetUserId: recommendForm.targetUserId.trim(),
reason: recommendForm.reason.trim(),
})
appStore.toastSuccess('已提交推荐')
recommendForm.targetUserId = ''
recommendForm.reason = ''
await load()
} catch (err: any) {
appStore.toastError(err?.message || '提交失败')
} finally {
recommending.value = false
}
}
async function onFollowup() {
if (submitting.value) return
const content = followupForm.content.trim()
if (!content) {
appStore.toast('请填写服务摘要')
return
}
submitting.value = true
try {
await operatorMatchmakerService.followup(serviceId, {
followupType: followupForm.followupType,
content,
nextFollowupAt: followupForm.nextFollowupAt || undefined,
})
appStore.toastSuccess('已记录服务')
followupForm.content = ''
followupForm.nextFollowupAt = ''
await load()
} catch (err: any) {
appStore.toastError(err?.message || '提交失败,请重试')
} finally {
submitting.value = false
}
}
async function onComplete() {
if (completing.value) return
try {
await showConfirmDialog({ title: '完成服务', message: '确认将该服务标记为已完成吗?' })
} catch {
return
}
completing.value = true
try {
await operatorMatchmakerService.complete(serviceId)
appStore.toastSuccess('服务已完成')
await load()
} catch (err: any) {
appStore.toastError(err?.message || '操作失败')
} finally {
completing.value = false
}
}
onMounted(load)
</script>
<style scoped>
.osd {
min-height: 100vh;
background: var(--mini-bg);
padding: 12px 16px 24px;
}
.osd__card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 14px;
margin-bottom: 12px;
box-shadow: var(--mini-shadow);
}
.osd__card-title {
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
margin-bottom: 10px;
}
.osd__row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 6px 0;
}
.osd__label {
font-size: 13px;
color: var(--mini-text-secondary);
flex: none;
}
.osd__value {
font-size: 13px;
color: var(--mini-text-strong);
text-align: right;
}
.osd__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px 10px;
font-size: 12px;
color: var(--mini-text);
margin-top: 8px;
}
.osd__desc {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 10px;
line-height: 1.6;
}
.osd__hint {
font-size: 12px;
color: var(--mini-text-muted);
margin-bottom: 10px;
line-height: 1.5;
}
.osd__mt {
margin-top: 10px;
}
.osd__field-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.osd__select {
flex: 1;
max-width: 180px;
border: none;
background: var(--mini-bg);
border-radius: 8px;
padding: 8px 10px;
font-size: 13px;
color: var(--mini-text-strong);
}
.osd__rec-list {
margin-top: 12px;
}
.osd__rec {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
color: var(--mini-text);
padding: 8px 0;
border-bottom: 1px solid var(--mini-border);
}
.osd__history {
padding: 10px 0;
border-bottom: 1px solid var(--mini-border);
}
.osd__history:last-child {
border-bottom: none;
}
.osd__history-head {
font-size: 12px;
color: var(--mini-text-secondary);
}
.osd__history-body {
font-size: 13px;
color: var(--mini-text);
margin-top: 4px;
line-height: 1.6;
}
.osd__complete {
margin-top: 4px;
}
</style>
@@ -0,0 +1,700 @@
<template>
<OperatorLayout title="红娘工作台">
<div class="wb">
<div class="wb__header">
<UiAvatar :name="operatorName || '红娘'" :size="48" />
<div class="wb__greeting-main">
<div class="wb__greeting">您好,{{ operatorName || '红娘' }}</div>
<div class="wb__date">{{ currentDate }}</div>
</div>
<UiTag type="primary">公益红娘</UiTag>
</div>
<UiErrorState v-if="error" @retry="load" />
<UiLoading v-else-if="loading" />
<template v-else>
<div class="wb__stats">
<div v-for="s in stats" :key="s.key" class="wb__stat">
<div class="wb__stat-value">{{ s.value }}</div>
<div class="wb__stat-label">{{ s.label }}</div>
</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
v-for="m in managementServices"
:key="m.serviceId || m.id"
class="wb__row"
@click="goService(m)"
>
<div class="wb__row-main">
<div class="wb__row-title">{{ m.userDisplayName || '服务对象' }}</div>
<div class="wb__row-meta">{{ serviceTypeLabel(m.serviceType) }}</div>
</div>
<UiTag :type="m.status === 'completed' ? 'success' : 'info'">
{{ statusLabel(m.status) }}
</UiTag>
</div>
</template>
<UiSectionHeader :title="`我的服务对象分配${assignmentCount ? '(共' + assignmentCount + '人)' : ''}`" />
<div v-if="assignments.length">
<div
v-for="a in assignments"
:key="a.id || a.userId"
class="wb__row"
@click="goService(a)"
>
<div class="wb__row-main">
<div class="wb__row-title">
{{ a.userDisplayName || '未设置姓名' }}
<span v-if="a.userDisplayNumber" class="wb__row-num">{{ a.userDisplayNumber }}</span>
</div>
<div class="wb__row-meta">
<span v-if="a.userAge">{{ a.userAge }}岁</span>
<span>{{ a.userRegionName || '地区未设置' }}</span>
</div>
<div class="wb__row-tags">
<UiTag :type="a.assignmentScope === 'primary' ? 'primary' : 'info'">
{{ scopeLabel(a.assignmentScope) }}
</UiTag>
<UiTag type="info">{{ statusLabel(a.status) }}</UiTag>
</div>
</div>
<van-icon name="arrow" color="#9aa0b5" />
</div>
</div>
<UiEmpty v-else description="暂无服务对象分配" />
<UiSectionHeader title="转介任务" more-text="+发起转介" @more="openReferralDialog" />
<div v-if="referrals.length">
<div v-for="r in referrals" :key="r.id" class="wb__row">
<div class="wb__row-main">
<div class="wb__row-title">{{ r.typeLabel }}</div>
<div class="wb__row-meta">{{ r.targetLabel }}</div>
<div class="wb__row-meta">{{ formatDateTime(r.createdAt || r.registeredAt) }}</div>
</div>
<UiTag :type="r.userConfirmedAt ? 'success' : 'warning'">
{{ r.userConfirmedAt ? '用户已确认' : '待用户确认' }}
</UiTag>
</div>
</div>
<UiEmpty v-else description="暂无转介记录" />
<UiSectionHeader title="推荐进展" />
<div v-if="recommendations.length">
<div v-for="r in recommendations" :key="r.id" class="wb__row">
<div class="wb__row-main">
<div class="wb__row-title">候选建议</div>
<div class="wb__row-meta">
推荐对象:{{ r.targetUserDisplayNumber || '待生成编号' }}
</div>
<div class="wb__row-meta">推荐理由:{{ r.reason || '未填写' }}</div>
</div>
<UiTag type="info">{{ recStatusLabel(r.status) }}</UiTag>
</div>
</div>
<UiEmpty v-else description="暂无推荐进展,用户将自行决定喜欢或暂不考虑" />
<UiSectionHeader title="联系授权(受控联系)" more-text="+申请授权" @more="openAuthDialog" />
<div v-if="contactAuths.length">
<div v-for="c in contactAuths" :key="c.id" class="wb__row">
<div class="wb__row-main">
<div class="wb__row-title">{{ channelLabel(c.channel) }}</div>
<div class="wb__row-meta">{{ c.purpose }}</div>
<div v-if="c.expiresAt" class="wb__row-meta">
有效期至 {{ formatDateTime(c.expiresAt) }}
</div>
</div>
<div class="wb__row-side">
<UiTag type="info">{{ contactStatusLabel(c.status) }}</UiTag>
<van-button
v-if="isRevocable(c)"
size="mini"
plain
type="danger"
@click="openRevokeDialog(c)"
>
撤销
</van-button>
</div>
</div>
</div>
<UiEmpty v-else description="暂无联系授权记录">
<van-button size="small" type="primary" round @click="openAuthDialog">申请联系授权</van-button>
</UiEmpty>
<p class="wb__tip">红娘仅提供推荐与跟进协助,不代替用户表达喜欢(AGENTS 第 5 条)。</p>
</template>
<!-- 受控联系授权:发起申请(须经管理端审批 + 用户明示同意后才生效) -->
<van-popup
:show="showAuthDialog"
position="bottom"
round
class="wb__popup"
@update:show="(v: boolean) => (showAuthDialog = v)"
>
<div class="wb__popup-title">申请受控联系授权</div>
<p class="wb__popup-hint">
红娘仅发起申请,需管理端审批并由服务对象本人单独明示同意后才生效(AGENTS 第 5/7 条)。
</p>
<div class="wb__field-row">
<span class="wb__field-label">服务对象</span>
<select v-model.number="authForm.targetIndex" class="wb__select">
<option v-for="(t, i) in authTargets" :key="t.userId" :value="i">
{{ targetLabel(t) }}
</option>
</select>
</div>
<div class="wb__field-row">
<span class="wb__field-label">联系渠道</span>
<select v-model="authForm.channel" class="wb__select">
<option v-for="c in contactChannels" :key="c.value" :value="c.value">{{ c.label }}</option>
</select>
</div>
<van-field
v-model="authForm.purpose"
class="wb__mt"
type="textarea"
rows="2"
maxlength="200"
placeholder="授权目的(必填,只记录完成公益服务所必需的信息)"
/>
<div class="wb__popup-actions">
<van-button block round plain @click="showAuthDialog = false">取消</van-button>
<van-button block round type="primary" :loading="submittingAuth" @click="submitAuth">
提交申请
</van-button>
</div>
</van-popup>
<!-- 撤销联系授权(理由必填) -->
<van-popup
:show="showRevokeDialog"
position="bottom"
round
class="wb__popup"
@update:show="(v: boolean) => (showRevokeDialog = v)"
>
<div class="wb__popup-title">撤销联系授权</div>
<van-field
v-model="revokeReason"
type="textarea"
rows="2"
maxlength="200"
placeholder="撤销理由(必填)"
/>
<div class="wb__popup-actions">
<van-button block round plain @click="showRevokeDialog = false">取消</van-button>
<van-button block round type="danger" :loading="revoking" @click="submitRevoke">
确认撤销
</van-button>
</div>
</van-popup>
<!-- 转介:服务对象 → 服务站 / 公益活动(提交后由用户在用户端确认) -->
<van-popup
:show="showReferralDialog"
position="bottom"
round
class="wb__popup"
@update:show="(v: boolean) => (showReferralDialog = v)"
>
<div class="wb__popup-title">发起转介</div>
<p class="wb__popup-hint">转介提交后交由用户在用户端确认,红娘不代用户接受服务。</p>
<div class="wb__field-row">
<span class="wb__field-label">服务对象</span>
<select v-model.number="referralForm.targetIndex" class="wb__select">
<option v-for="(t, i) in authTargets" :key="t.userId" :value="i">
{{ targetLabel(t) }}
</option>
</select>
</div>
<div class="wb__field-row">
<span class="wb__field-label">转介类型</span>
<select v-model="referralForm.type" class="wb__select">
<option v-for="t in referralTypes" :key="t.value" :value="t.value">{{ t.label }}</option>
</select>
</div>
<van-field
v-model="referralForm.targetId"
class="wb__mt"
:placeholder="referralForm.type === 'activity' ? '公益活动编号(必填)' : '善缘服务站编号(必填)'"
/>
<van-field
v-model="referralForm.note"
class="wb__mt"
type="textarea"
rows="2"
maxlength="200"
placeholder="转介说明(可选)"
/>
<div class="wb__popup-actions">
<van-button block round plain @click="showReferralDialog = false">取消</van-button>
<van-button block round type="primary" :loading="submittingReferral" @click="submitReferral">
提交转介
</van-button>
</div>
</van-popup>
</div>
</OperatorLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { operatorMatchmakerService } from '@/api/services'
import { useOperatorStore } from '@/stores/operator'
import { useAppStore } from '@/stores/app'
import { formatDateTime } from '@/utils/datetime'
import OperatorLayout from '@/layouts/OperatorLayout.vue'
import UiAvatar from '@/components/UiAvatar.vue'
import UiTag from '@/components/UiTag.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const router = useRouter()
const operatorStore = useOperatorStore()
const appStore = useAppStore()
const operatorName = computed(() => operatorStore.operatorName)
const loading = ref(true)
const error = ref(false)
const dashboard = ref<any>(null)
const assignments = ref<any[]>([])
const referrals = ref<any[]>([])
const recommendations = ref<any[]>([])
const contactAuths = ref<any[]>([])
const pendingServices = ref<any[]>([])
const managementServices = ref<any[]>([])
// ---- 受控联系授权(B1:红娘只发起申请,审批 + 用户明示同意后才生效) ----
const contactChannels = [
{ value: 'phone', label: '电话联系' },
{ value: 'offline_meeting', label: '线下见面' },
{ value: 'full_contact', label: '完整联系方式' },
]
/** 可发起的服务对象 = 本人生效中的分配 */
const authTargets = computed(() => assignments.value.filter((a) => a.userId))
const showAuthDialog = ref(false)
const submittingAuth = ref(false)
const authForm = reactive({ targetIndex: 0, channel: 'phone', purpose: '' })
const showRevokeDialog = ref(false)
const revoking = ref(false)
const revokeTargetId = ref('')
const revokeReason = ref('')
// ---- 转介(服务对象 → 服务站 / 公益活动,由用户在用户端确认) ----
const referralTypes = [
{ value: 'service_station', label: '服务站' },
{ value: 'activity', label: '公益活动' },
]
const showReferralDialog = ref(false)
const submittingReferral = ref(false)
const referralForm = reactive({ targetIndex: 0, type: 'service_station', targetId: '', note: '' })
const currentDate = computed(() => {
const now = new Date()
return `${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日`
})
const stats = computed(() => [
{ key: 'pending', value: dashboard.value?.pendingCount || 0, label: '待分配' },
{ key: 'inProgress', value: dashboard.value?.inProgressCount || 0, label: '进行中' },
{ key: 'completed', value: dashboard.value?.completedCount || 0, label: '已完成' },
{ key: 'today', value: dashboard.value?.todayCount || 0, label: '今日' },
])
const assignmentCount = computed(() => assignments.value.length)
function asList(res: any): any[] {
const data = res?.value ?? res
return data?.items || data?.records || (Array.isArray(data) ? data : data?.list || [])
}
function scopeLabel(code?: string): string {
return { primary: '主责', collaborating: '协同' }[code as string] || code || ''
}
function statusLabel(code?: string): string {
const labels: Record<string, string> = {
active: '生效中',
ended: '已结束',
revoked: '已撤销',
transferred: '已转分配',
expired: '已过期',
}
return (code && labels[code]) || code || ''
}
function recStatusLabel(code?: string): string {
const labels: Record<string, string> = {
pending_view: '待用户查看',
viewed: '用户已查看',
liked: '用户已喜欢',
passed: '用户已跳过',
confirmed_mutual: '已双向确认',
expired: '已过期',
}
return (code && labels[code]) || code || '未知状态'
}
function channelLabel(code?: string): string {
const labels: Record<string, string> = {
phone: '电话联系',
offline_meeting: '线下见面',
full_contact: '完整联系方式',
}
return (code && labels[code]) || code || ''
}
function contactStatusLabel(code?: string): string {
const labels: Record<string, string> = {
pending_approval: '待审批',
pending_consent: '待用户确认',
active: '生效中',
rejected: '用户已拒绝',
expired: '已过期',
revoked: '已撤销',
}
return (code && labels[code]) || code || ''
}
async function load() {
loading.value = true
error.value = false
const [dash, assignment, referral, recommendation, auth, pending, mgmt] = await Promise.allSettled([
operatorMatchmakerService.dashboard(),
operatorMatchmakerService.myAssignments(),
operatorMatchmakerService.referralHistory(),
operatorMatchmakerService.recommendations(),
operatorMatchmakerService.myContactAuthorizations(),
operatorMatchmakerService.pending(),
operatorMatchmakerService.managementServices(),
])
if (dash.status === 'rejected') {
error.value = true
appStore.toastError((dash.reason as any)?.message || '加载工作台失败')
loading.value = false
return
}
dashboard.value = dash.value || null
assignments.value = asList(assignment.status === 'fulfilled' ? assignment.value : null)
referrals.value = (asList(referral.status === 'fulfilled' ? referral.value : null)).map((item) => ({
...item,
typeLabel: item.type === 'activity' ? '公益活动' : '服务站',
targetLabel:
item.type === 'activity'
? '公益活动转介'
: item.serviceType
? serviceTypeLabel(item.serviceType)
: '服务站转介',
}))
recommendations.value = asList(recommendation.status === 'fulfilled' ? recommendation.value : null)
contactAuths.value = asList(auth.status === 'fulfilled' ? auth.value : null)
pendingServices.value = asList(pending.status === 'fulfilled' ? pending.value : null)
managementServices.value = asList(mgmt.status === 'fulfilled' ? mgmt.value : null)
loading.value = false
}
function serviceTypeLabel(code?: string): string {
const labels: Record<string, string> = {
counselor_consultation: '婚恋咨询',
premarital_counseling: '婚前辅导',
legal_consultation: '法律咨询',
}
return (code && labels[code]) || '服务站服务'
}
function goService(a: any) {
const serviceId = a.serviceId
if (serviceId) {
router.push({ path: '/operator/services', query: { id: serviceId } })
} else {
appStore.toast('该分配未关联服务单,请联系组长补建服务记录')
}
}
/** 服务对象文案:编号(姓名) */
function targetLabel(t: any): string {
const num = t.userDisplayNumber ? `${t.userDisplayNumber}(` : ''
const tail = t.userDisplayNumber ? ')' : ''
return `${num}${t.userDisplayName || '服务对象'}${tail}`
}
/** 仅待审批/待人确认/生效中的授权可撤销 */
function isRevocable(c: any): boolean {
return ['pending_approval', 'pending_consent', 'active'].includes(c?.status)
}
function openAuthDialog() {
if (!authTargets.value.length) {
appStore.toast('暂无可申请的服务对象')
return
}
authForm.targetIndex = 0
authForm.channel = 'phone'
authForm.purpose = ''
showAuthDialog.value = true
}
async function submitAuth() {
if (submittingAuth.value) return
const target = authTargets.value[authForm.targetIndex]
if (!target?.userId) {
appStore.toast('请选择服务对象')
return
}
const purpose = authForm.purpose.trim()
if (!purpose) {
appStore.toast('授权目的必填')
return
}
submittingAuth.value = true
try {
await operatorMatchmakerService.requestContactAuthorization({
userId: target.userId,
channel: authForm.channel,
purpose,
})
appStore.toastSuccess('申请已提交,等待管理端审批')
showAuthDialog.value = false
await load()
} catch (err: any) {
appStore.toastError(err?.message || '申请失败')
} finally {
submittingAuth.value = false
}
}
function openRevokeDialog(c: any) {
revokeTargetId.value = c.id
revokeReason.value = ''
showRevokeDialog.value = true
}
async function submitRevoke() {
if (revoking.value) return
const reason = revokeReason.value.trim()
if (!reason) {
appStore.toast('撤销理由必填')
return
}
revoking.value = true
try {
await operatorMatchmakerService.revokeContactAuthorization(revokeTargetId.value, reason)
appStore.toastSuccess('已撤销')
showRevokeDialog.value = false
await load()
} catch (err: any) {
appStore.toastError(err?.message || '撤销失败')
} finally {
revoking.value = false
}
}
function openReferralDialog() {
if (!authTargets.value.length) {
appStore.toast('暂无可转介的服务对象')
return
}
referralForm.targetIndex = 0
referralForm.type = 'service_station'
referralForm.targetId = ''
referralForm.note = ''
showReferralDialog.value = true
}
async function submitReferral() {
if (submittingReferral.value) return
const target = authTargets.value[referralForm.targetIndex]
if (!target?.userId) {
appStore.toast('请选择服务对象')
return
}
const targetId = referralForm.targetId.trim()
if (!targetId) {
appStore.toast(referralForm.type === 'activity' ? '请填写公益活动编号' : '请填写服务站编号')
return
}
submittingReferral.value = true
try {
if (referralForm.type === 'activity') {
await operatorMatchmakerService.referToActivity({ userId: target.userId, activityId: targetId })
} else {
await operatorMatchmakerService.referToServiceStation({
userId: target.userId,
locationId: targetId,
serviceType: 'counselor_consultation',
note: referralForm.note.trim(),
})
}
appStore.toastSuccess('转介已提交,等待用户确认')
showReferralDialog.value = false
await load()
} catch (err: any) {
appStore.toastError(err?.message || '转介提交失败')
} finally {
submittingReferral.value = false
}
}
onMounted(load)
</script>
<style scoped>
.wb {
min-height: 100vh;
background: var(--mini-bg);
padding: 0 16px 24px;
}
.wb__header {
display: flex;
align-items: center;
gap: 12px;
background: var(--mini-brand-gradient);
color: #fff;
border-radius: var(--mini-radius);
padding: 16px 14px;
margin: 12px 0;
}
.wb__greeting-main {
flex: 1;
min-width: 0;
}
.wb__greeting {
font-size: 16px;
font-weight: 600;
}
.wb__date {
font-size: 12px;
opacity: 0.85;
margin-top: 2px;
}
.wb__stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 14px 6px;
box-shadow: var(--mini-shadow);
}
.wb__stat {
text-align: center;
}
.wb__stat-value {
font-size: 18px;
font-weight: 700;
color: var(--mini-text-strong);
}
.wb__stat-label {
font-size: 11px;
color: var(--mini-text-secondary);
margin-top: 2px;
}
.wb__row {
display: flex;
align-items: center;
gap: 10px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 12px 14px;
margin-bottom: 8px;
box-shadow: var(--mini-shadow);
}
.wb__row-main {
flex: 1;
min-width: 0;
}
.wb__row-title {
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
}
.wb__row-num {
font-size: 12px;
font-weight: 400;
color: var(--mini-text-muted);
margin-left: 6px;
}
.wb__row-meta {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 3px;
}
.wb__row-tags {
display: flex;
gap: 6px;
margin-top: 6px;
}
.wb__tip {
font-size: 11px;
color: var(--mini-text-muted);
text-align: center;
margin: 18px 0 0;
line-height: 1.6;
}
.wb__row-side {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
flex: none;
}
.wb__popup {
padding: 18px 16px calc(18px + env(safe-area-inset-bottom));
}
.wb__popup-title {
font-size: 16px;
font-weight: 600;
color: var(--mini-text-strong);
margin-bottom: 6px;
}
.wb__popup-hint {
font-size: 12px;
color: var(--mini-text-muted);
line-height: 1.6;
margin: 0 0 12px;
}
.wb__field-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 10px;
}
.wb__field-label {
font-size: 13px;
color: var(--mini-text-secondary);
flex: none;
}
.wb__select {
flex: 1;
max-width: 210px;
border: none;
background: var(--mini-bg);
border-radius: 8px;
padding: 9px 10px;
font-size: 13px;
color: var(--mini-text-strong);
}
.wb__mt {
margin-top: 10px;
}
.wb__popup-actions {
display: flex;
gap: 10px;
margin-top: 16px;
}
</style>
@@ -0,0 +1,238 @@
<template>
<PageLayout title="婚前辅导">
<div class="prem">
<div class="prem__hero">
<div class="prem__eyebrow">共同成长 · 面向未来</div>
<div class="prem__title">婚前辅导</div>
<div class="prem__desc">在进入婚姻前,把重要的事认真谈清楚</div>
</div>
<div class="prem__body">
<UiErrorState v-if="error" @retry="load" />
<UiLoading v-else-if="loading" />
<template v-else>
<div class="prem__status">
<span class="prem__status-label">服务状态</span>
<span class="prem__status-value" :class="{ 'prem__status-value--off': !serviceEnabled }">
{{ serviceEnabled ? '可预约' : disabledReason || '服务暂不可用' }}
</span>
</div>
<UiSectionHeader title="辅导内容" />
<div class="prem__card">
<div v-for="t in TOPICS" :key="t.title" class="prem__topic">
<div class="prem__topic-icon" :class="`tone-${t.tone}`">{{ t.icon }}</div>
<div class="prem__topic-copy">
<div class="prem__topic-title">{{ t.title }}</div>
<div class="prem__topic-desc">{{ t.description }}</div>
</div>
</div>
</div>
<div class="prem__privacy">
双方共同参与效果更好,但必须基于自愿;不会因为一方申请而自动通知或要求另一方参加。
</div>
</template>
</div>
<div class="prem__action">
<van-button class="prem__action-btn" plain @click="router.push('/my/counseling')">服务记录</van-button>
<van-button
class="prem__action-btn"
type="primary"
:disabled="!serviceEnabled"
@click="apply"
>
预约辅导
</van-button>
</div>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { counselingService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiErrorState from '@/components/UiErrorState.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
const router = useRouter()
const appStore = useAppStore()
const TOPICS = [
{ icon: '通', tone: 'blue', title: '沟通与期望', description: '表达需求、倾听差异,形成共同约定' },
{ icon: '家', tone: 'pink', title: '家庭与责任', description: '讨论家庭角色与双方家庭关系' },
{ icon: '财', tone: 'amber', title: '财务与规划', description: '了解消费习惯并讨论未来安排' },
{ icon: '和', tone: 'green', title: '冲突与修复', description: '学习处理分歧并建立修复方式' },
]
const loading = ref(true)
const error = ref(false)
const availableTypes = ref<string[]>([])
const disabledReason = ref('')
const serviceEnabled = computed(() =>
availableTypes.value.includes('premarital_guidance') || availableTypes.value.includes('premarital'),
)
async function load() {
loading.value = true
error.value = false
try {
const result: any = await counselingService.intro()
const list: any[] = result?.items || result?.records || (Array.isArray(result) ? result : [])
availableTypes.value = list.map((s: any) => s?.type).filter(Boolean)
} catch {
error.value = true
} finally {
loading.value = false
}
}
function apply() {
if (!serviceEnabled.value) {
appStore.toast(disabledReason.value || '服务暂不可用')
return
}
router.push({ path: '/premarital/request', query: { type: 'premarital_guidance' } })
}
onMounted(load)
</script>
<style scoped>
.prem {
min-height: 100vh;
background: var(--mini-bg);
padding-bottom: 76px;
}
.prem__hero {
background: var(--mini-brand-gradient);
color: #fff;
padding: 18px 16px 20px;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
}
.prem__eyebrow {
font-size: 11px;
opacity: 0.8;
}
.prem__title {
font-size: 19px;
font-weight: 600;
margin-top: 4px;
}
.prem__desc {
font-size: 12px;
opacity: 0.85;
margin-top: 6px;
}
.prem__body {
padding: 0 16px;
}
.prem__status {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 12px 14px;
margin-top: 12px;
}
.prem__status-label {
font-size: 13px;
color: var(--mini-text-secondary);
}
.prem__status-value {
font-size: 13px;
font-weight: 600;
color: var(--mini-success);
}
.prem__status-value--off {
color: var(--mini-text-muted);
}
.prem__card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
overflow: hidden;
}
.prem__topic {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
border-bottom: 1px solid var(--mini-border);
}
.prem__topic:last-child {
border-bottom: none;
}
.prem__topic-icon {
width: 42px;
height: 42px;
border-radius: 12px;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 17px;
font-weight: 600;
flex: none;
}
.prem__topic-copy {
flex: 1;
min-width: 0;
}
.prem__topic-title {
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
}
.prem__topic-desc {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 2px;
line-height: 1.5;
}
.prem__privacy {
font-size: 12px;
color: var(--mini-text-secondary);
line-height: 1.6;
background: rgba(47, 179, 109, 0.08);
border-radius: var(--mini-radius);
padding: 12px 14px;
margin-top: 16px;
}
.prem__action {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 10px;
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
background: var(--mini-surface);
box-shadow: 0 -4px 16px rgba(23, 42, 114, 0.08);
}
.prem__action-btn {
flex: 1;
border-radius: var(--mini-radius);
}
.tone-blue {
background: linear-gradient(135deg, #4f8cff 0%, #2f63e0 100%);
}
.tone-pink {
background: linear-gradient(135deg, #ff7ab0 0%, #e0559a 100%);
}
.tone-amber {
background: linear-gradient(135deg, #f5a623 0%, #d98a10 100%);
}
.tone-green {
background: linear-gradient(135deg, #3fc98a 0%, #199e64 100%);
}
</style>
@@ -0,0 +1,203 @@
<template>
<PageLayout title="预约婚前辅导">
<div class="preq">
<div class="preq__hero">
<div class="preq__eyebrow">共同成长 · 面向未来</div>
<div class="preq__title">提交婚前辅导申请</div>
<div class="preq__desc">婚前辅导为公益免费服务,工作人员将在 3 个工作日内与你联系。</div>
</div>
<div class="preq__body">
<UiErrorState v-if="error" message="加载失败,请稍后重试" @retry="load" />
<UiLoading v-else-if="loading" />
<template v-else>
<div class="preq__card">
<van-field
v-model="name"
label="您的姓名"
placeholder="请输入姓名"
maxlength="30"
/>
<van-field
v-model="phone"
label="联系方式"
type="tel"
placeholder="请输入手机号"
maxlength="20"
/>
<van-field label="是否邀请伴侣参与">
<template #input>
<van-switch v-model="hasCompanion" size="20" />
</template>
</van-field>
<van-field
v-if="hasCompanion"
v-model="companionName"
label="伴侣姓名"
placeholder="伴侣姓名(选填)"
maxlength="30"
/>
<van-field
v-model="preferredTime"
label="期望时间"
placeholder="如:本周末下午"
maxlength="60"
/>
<van-field
v-model="summary"
label="补充说明"
type="textarea"
rows="3"
maxlength="300"
show-word-limit
placeholder="如有特殊需求请在此说明"
/>
</div>
<div v-if="hasCompanion" class="preq__hint">
伴侣须本人确认参与,平台不会因一方申请而自动要求另一方参加。
</div>
<div class="preq__privacy">
婚前辅导服务不查看婚恋咨询正文,咨询与红娘记录彼此分离。
</div>
<van-button
class="preq__submit"
type="primary"
block
round
:loading="submitting"
loading-text="提交中…"
@click="submit"
>
提交申请
</van-button>
<div class="preq__link" @click="router.push('/my/counseling')">查看服务记录</div>
</template>
</div>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { counselingService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const router = useRouter()
const appStore = useAppStore()
const loading = ref(false)
const error = ref(false)
const submitting = ref(false)
const name = ref('')
const phone = ref('')
const hasCompanion = ref(false)
const companionName = ref('')
const preferredTime = ref('')
const summary = ref('')
async function load() {
// 申请表单无需远程数据;保留「加载」位以与其余服务页保持一致的页面语义
loading.value = false
}
async function submit() {
if (!name.value.trim() || !phone.value.trim()) {
appStore.toast('请填写姓名和联系方式')
return
}
if (submitting.value) return
submitting.value = true
try {
await counselingService.apply({
serviceType: 'premarital_guidance',
preferredTime: preferredTime.value,
summary:
summary.value.trim() ||
`申请人:${name.value.trim()},电话:${phone.value.trim()}${hasCompanion.value && companionName.value.trim() ? `,伴侣:${companionName.value.trim()}` : ''}`,
requestSource: 'user_self',
// INV-005:伴侣须本人确认,此处不代人确认
companionUserId: null,
})
appStore.toastSuccess(hasCompanion.value ? '已提交,伴侣需本人确认' : '申请已提交')
router.replace('/my/counseling')
} catch (err: any) {
appStore.toastError(err?.message || '提交失败,请稍后重试')
} finally {
submitting.value = false
}
}
onMounted(load)
</script>
<style scoped>
.preq {
min-height: 100vh;
background: var(--mini-bg);
padding-bottom: 24px;
}
.preq__hero {
background: var(--mini-brand-gradient);
color: #fff;
padding: 18px 16px 20px;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
}
.preq__eyebrow {
font-size: 11px;
opacity: 0.8;
}
.preq__title {
font-size: 18px;
font-weight: 600;
margin-top: 4px;
}
.preq__desc {
font-size: 12px;
opacity: 0.85;
margin-top: 6px;
line-height: 1.6;
}
.preq__body {
padding: 0 16px;
}
.preq__card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
overflow: hidden;
margin-top: 12px;
}
.preq__hint {
font-size: 12px;
color: var(--mini-text-secondary);
line-height: 1.6;
margin-top: 10px;
padding-left: 2px;
}
.preq__privacy {
font-size: 12px;
color: var(--mini-text-secondary);
line-height: 1.6;
background: rgba(47, 179, 109, 0.08);
border-radius: var(--mini-radius);
padding: 12px 14px;
margin-top: 12px;
}
.preq__submit {
margin-top: 20px;
}
.preq__link {
text-align: center;
font-size: 13px;
color: var(--mini-text-secondary);
margin-top: 14px;
}
</style>
@@ -0,0 +1,277 @@
<template>
<PageLayout title="用户资料">
<UiLoading v-if="loading" text="正在加载资料…" />
<UiErrorState v-else-if="loadError" :message="loadError" @retry="load" />
<UiEmpty v-else-if="!profile" description="该用户可能已注销或不可见" />
<template v-else>
<div class="pd">
<div class="pd__header">
<UiAvatar :src="profile.avatarUrl" :name="displayName" :size="64" />
<div class="pd__header-info">
<div class="pd__name-row">
<span class="pd__name">{{ displayName }}</span>
<span v-if="ageText" class="pd__age">{{ ageText }}</span>
</div>
<div v-if="metaLine" class="pd__meta">{{ metaLine }}</div>
<div class="pd__badges">
<UiTag v-if="verified" type="success">已实名</UiTag>
<UiTag v-if="reviewed" type="info">资料已审核</UiTag>
</div>
</div>
</div>
<template v-if="basicInfo.length">
<UiSectionHeader title="关于我" />
<div class="pd__info">
<div v-for="i in basicInfo" :key="i.label" class="pd__info-item" :class="{ full: i.full }">
<span class="pd__info-label">{{ i.label }}</span>
<span class="pd__info-value">{{ i.value }}</span>
</div>
</div>
</template>
<template v-if="profile.selfIntro">
<UiSectionHeader title="自我介绍" />
<div class="pd__intro">{{ profile.selfIntro }}</div>
</template>
<template v-if="attitudeInfo.length">
<UiSectionHeader title="婚恋态度" />
<div class="pd__info">
<div v-for="i in attitudeInfo" :key="i.label" class="pd__info-item full">
<span class="pd__info-label">{{ i.label }}</span>
<span class="pd__info-value">{{ i.value }}</span>
</div>
</div>
</template>
<div class="pd__actions">
<van-button plain type="default" size="small" @click="onBlock">拉黑</van-button>
<van-button plain type="danger" size="small" @click="onReport">举报</van-button>
<van-button plain type="primary" size="small" @click="onApplyMatchmaker">申请红娘协助</van-button>
<van-button v-if="!isLiked" type="primary" size="small" :loading="actionLoading" @click="onLike">
喜欢
</van-button>
<van-button v-else type="primary" size="small" disabled>已喜欢</van-button>
</div>
</div>
</template>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { recommendationService } from '@/api/services'
import { useMatchStore } from '@/stores/match'
import { useAppStore } from '@/stores/app'
import { showConfirmDialog } from 'vant'
import PageLayout from '@/layouts/PageLayout.vue'
import UiAvatar from '@/components/UiAvatar.vue'
import UiTag from '@/components/UiTag.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const route = useRoute()
const router = useRouter()
const matchStore = useMatchStore()
const appStore = useAppStore()
const userId = computed(() => String(route.query.id || ''))
const profile = ref<any>(null)
const loading = ref(true)
const loadError = ref('')
const actionLoading = ref(false)
const isLiked = ref(false)
const assistanceSubmitted = ref(false)
const displayName = computed(() => profile.value?.displayName || '用户')
const ageText = computed(() => (profile.value?.age ? `${profile.value.age}岁` : ''))
const verified = computed(() => !!(profile.value?.verified || profile.value?.isRealNameVerified))
const reviewed = computed(
() => !!(profile.value?.reviewed || profile.value?.profileReviewStatus === 'approved'),
)
const metaLine = computed(() => {
const p = profile.value || {}
const parts: string[] = []
if (p.heightCm) parts.push(`${p.heightCm}cm`)
if (p.regionName) parts.push(p.regionName)
if (p.educationLabel) parts.push(p.educationLabel)
if (p.occupationLabel) parts.push(p.occupationLabel)
return parts.join(' · ')
})
const basicInfo = computed(() => {
const p = profile.value || {}
const list: Array<{ label: string; value: string; full?: boolean }> = []
if (p.maritalStatusLabel) list.push({ label: '婚姻状况', value: p.maritalStatusLabel })
if (p.childrenStatusLabel) list.push({ label: '子女情况', value: p.childrenStatusLabel })
if (p.educationLabel) list.push({ label: '学历', value: p.educationLabel })
if (p.occupationLabel) list.push({ label: '职业', value: p.occupationLabel })
if (p.heightCm) list.push({ label: '身高', value: `${p.heightCm}cm` })
if (p.regionName) list.push({ label: '所在地区', value: p.regionName })
return list
})
const attitudeInfo = computed(() => {
const p = profile.value || {}
const list: Array<{ label: string; value: string; full?: boolean }> = []
if (p.relationshipGoal) list.push({ label: '寻找', value: p.relationshipGoal })
if (p.marriagePlan) list.push({ label: '结婚计划', value: p.marriagePlan })
return list
})
async function load() {
if (!userId.value) {
loading.value = false
loadError.value = '用户不存在'
return
}
loading.value = true
loadError.value = ''
try {
const user: any = await recommendationService.getUserDetail(userId.value)
profile.value = user
isLiked.value = user?.relationStatus === 'liked' || user?.relationStatus === 'mutual_match'
} catch (err: any) {
loadError.value = err?.message || '加载失败'
} finally {
loading.value = false
}
}
async function onLike() {
if (!userId.value || actionLoading.value || isLiked.value) return
actionLoading.value = true
try {
const result: any = await matchStore.like(userId.value)
isLiked.value = true
appStore.toastSuccess(result?.mutualMatch ? '双向意向匹配成功,可查看公益服务' : '已表达喜欢')
} catch (err: any) {
appStore.toastError(err?.message || '操作失败')
} finally {
actionLoading.value = false
}
}
function onReport() {
router.push({ path: '/report', query: { targetUserId: userId.value } })
}
function onApplyMatchmaker() {
router.push({ path: '/matchmaker-authorization', query: { targetUserId: userId.value } })
}
async function onBlock() {
if (!userId.value) return
try {
await showConfirmDialog({ title: '拉黑确认', message: `拉黑后聊天停止、推荐排除、搜索不可见。确定拉黑 ${displayName.value} 吗?` })
} catch {
return
}
actionLoading.value = true
try {
await matchStore.block(userId.value, '用户主动拉黑')
appStore.toastSuccess('已拉黑')
router.back()
} catch (err: any) {
appStore.toastError(err?.message || '拉黑失败')
} finally {
actionLoading.value = false
}
}
onMounted(load)
</script>
<style scoped>
.pd {
padding: 16px 16px 100px;
}
.pd__header {
display: flex;
gap: 14px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 16px;
box-shadow: var(--mini-shadow);
}
.pd__header-info {
flex: 1;
min-width: 0;
}
.pd__name-row {
display: flex;
align-items: baseline;
gap: 8px;
}
.pd__name {
font-size: 18px;
font-weight: 700;
color: var(--mini-text-strong);
}
.pd__age {
font-size: 13px;
color: var(--mini-text-secondary);
}
.pd__meta {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 4px;
}
.pd__badges {
display: flex;
gap: 6px;
margin-top: 8px;
}
.pd__info {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 14px;
box-shadow: var(--mini-shadow);
}
.pd__info-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.pd__info-item.full {
grid-column: span 2;
}
.pd__info-label {
font-size: 11px;
color: var(--mini-text-muted);
}
.pd__info-value {
font-size: 14px;
color: var(--mini-text-strong);
}
.pd__intro {
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 14px;
box-shadow: var(--mini-shadow);
font-size: 14px;
line-height: 1.7;
color: var(--mini-text);
}
.pd__actions {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
background: var(--mini-surface);
border-top: 1px solid var(--mini-border);
}
.pd__actions :deep(.van-button) {
flex: 1;
}
</style>
@@ -0,0 +1,238 @@
<template>
<PageLayout title="资料审核中">
<div class="rp">
<UiLoading v-if="loading" text="加载中…" />
<UiErrorState v-else-if="error" @retry="loadStatus" />
<template v-else>
<div class="card rp__hero">
<div class="rp__hero-icon">○</div>
<div class="rp__hero-title">资料审核中</div>
<div class="rp__hero-desc">工作人员正在核验头像、个人资料和婚恋信息。</div>
<div class="rp__hero-hint">资料审核通过后,即可开始使用匹配功能。</div>
</div>
<div class="card">
<div class="rp__status-row">
<span class="rp__status-label">实名认证</span>
<UiTag :type="verificationTag">{{ verificationLabel }}</UiTag>
</div>
<div class="rp__status-row">
<span class="rp__status-label">资料审核</span>
<UiTag :type="reviewTag">{{ reviewLabel }}</UiTag>
</div>
<div v-if="submittedAt" class="rp__meta">提交时间:{{ formatDateTime(submittedAt) }}</div>
</div>
<div class="card">
<div class="rp__steps">
<div
v-for="(s, index) in STEPS"
:key="s.key"
class="rp__step"
:class="{ 'rp__step--active': index <= currentStepIndex }"
>
<div class="rp__step-icon">{{ s.icon }}</div>
<div class="rp__step-label">{{ s.label }}</div>
</div>
</div>
</div>
<div class="rp__actions">
<van-button plain block @click="router.push('/profile/confirm?step=1')">查看我的资料</van-button>
<van-button type="primary" block :loading="refreshing" @click="loadStatus">刷新状态</van-button>
<van-button plain block @click="router.replace('/home')">返回首页</van-button>
</div>
<div class="rp__help">如需帮助,请联系平台工作人员。</div>
</template>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { profileService, verificationService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import { formatDateTime } from '@/utils/datetime'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiErrorState from '@/components/UiErrorState.vue'
import UiTag from '@/components/UiTag.vue'
const router = useRouter()
const appStore = useAppStore()
const STEPS = [
{ key: 'submitted', label: '提交资料', icon: '✓' },
{ key: 'reviewing', label: '人工审核', icon: '◉' },
{ key: 'done', label: '审核完成', icon: '○' },
]
const loading = ref(true)
const error = ref(false)
const refreshing = ref(false)
const verificationStatus = ref('pending')
const reviewStatus = ref('pending')
const submittedAt = ref<any>(null)
const VERIFICATION_TEXT: Record<string, string> = {
not_submitted: '未提交',
pending: '认证中',
approved: '已完成',
verified: '已完成',
rejected: '未通过',
}
const REVIEW_TEXT: Record<string, string> = {
not_submitted: '未提交',
pending: '审核中',
pending_review: '审核中',
approved: '已通过',
need_edit: '需修改',
rejected: '未通过',
}
const verificationLabel = computed(() => VERIFICATION_TEXT[verificationStatus.value] || '认证中')
const verificationTag = computed<'primary' | 'success' | 'warning' | 'danger' | 'info'>(() => {
if (verificationStatus.value === 'approved' || verificationStatus.value === 'verified') return 'success'
if (verificationStatus.value === 'rejected') return 'danger'
if (verificationStatus.value === 'pending') return 'warning'
return 'info'
})
const reviewLabel = computed(() => REVIEW_TEXT[reviewStatus.value] || '审核中')
const reviewTag = computed<'primary' | 'success' | 'warning' | 'danger' | 'info'>(() => {
if (reviewStatus.value === 'approved') return 'success'
if (reviewStatus.value === 'rejected') return 'danger'
if (reviewStatus.value === 'need_edit') return 'warning'
return 'warning'
})
const currentStepIndex = computed(() => {
if (reviewStatus.value === 'approved') return 2
if (reviewStatus.value === 'not_submitted') return 0
return 1
})
async function loadStatus() {
refreshing.value = true
try {
const [review, verification] = await Promise.all([
profileService.getReviewStatus().catch(() => null),
verificationService.getStatus().catch(() => null),
])
const status = (review as any)?.status || 'pending'
reviewStatus.value = status
submittedAt.value = (review as any)?.submittedAt || null
const vStatus = (verification as any)?.status || ((verification as any)?.verified ? 'approved' : 'pending')
verificationStatus.value = vStatus
if (status === 'approved') {
appStore.toastSuccess('资料审核已通过')
router.replace('/home')
return
}
if (status === 'need_edit' || status === 'rejected') {
router.replace('/review/rejected')
return
}
error.value = false
} catch {
error.value = true
} finally {
loading.value = false
refreshing.value = false
}
}
onMounted(loadStatus)
</script>
<style scoped>
.rp {
padding: 16px;
}
.card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 16px;
margin-bottom: 12px;
}
.rp__hero {
text-align: center;
}
.rp__hero-icon {
width: 56px;
height: 56px;
border-radius: 50%;
margin: 8px auto 12px;
background: rgba(245, 166, 35, 0.16);
color: #b9770e;
display: flex;
align-items: center;
justify-content: center;
font-size: 26px;
}
.rp__hero-title {
font-size: 18px;
font-weight: 600;
color: var(--mini-text-strong);
}
.rp__hero-desc {
font-size: 13px;
color: var(--mini-text-secondary);
margin-top: 8px;
line-height: 1.7;
}
.rp__hero-hint {
font-size: 12px;
color: var(--mini-text-muted);
margin-top: 6px;
}
.rp__status-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
}
.rp__status-label {
font-size: 14px;
color: var(--mini-text-secondary);
}
.rp__meta {
font-size: 12px;
color: var(--mini-text-muted);
margin-top: 8px;
}
.rp__steps {
display: flex;
justify-content: space-between;
}
.rp__step {
flex: 1;
text-align: center;
color: var(--mini-text-muted);
}
.rp__step--active {
color: var(--mini-tabbar-active);
}
.rp__step-icon {
font-size: 20px;
}
.rp__step-label {
font-size: 12px;
margin-top: 6px;
}
.rp__actions {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 8px;
}
.rp__help {
font-size: 12px;
color: var(--mini-text-muted);
text-align: center;
margin-top: 20px;
}
</style>
@@ -0,0 +1,267 @@
<template>
<PageLayout title="资料需修改">
<div class="rr">
<UiLoading v-if="loading" text="加载中…" />
<UiErrorState v-else-if="error" @retry="loadReviewResult" />
<template v-else-if="issueItems.length || otherItems.length">
<div class="card rr__hero">
<div class="rr__hero-icon">!</div>
<div class="rr__hero-title">{{ reviewStatusLabel }}</div>
<div class="rr__hero-desc">请根据以下问题修改对应资料,修改完成后可重新提交审核。</div>
</div>
<div class="card">
<UiSectionHeader title="需要修改的项" />
<div v-for="item in issueItems" :key="item.itemType" class="rr__issue">
<div class="rr__issue-head">
<span class="rr__issue-title">{{ item.label }}</span>
<UiTag :type="item.tag">{{ item.statusLabel }}</UiTag>
</div>
<div v-if="item.reasonText" class="rr__issue-reason">
<span class="rr__issue-reason-label">原因</span>
<span class="rr__issue-reason-text">{{ item.reasonText }}</span>
</div>
<div class="rr__issue-action" @click="router.push(item.route)">立即修改 ›</div>
</div>
<UiEmpty v-if="!issueItems.length" description="暂无需修改的项" />
</div>
<div v-if="otherItems.length" class="card">
<div class="rr__toggle" @click="showAllItems = !showAllItems">
<span>查看其他审核结果</span>
<span>{{ showAllItems ? '收起 ▲' : '展开 ▼' }}</span>
</div>
<div v-if="showAllItems" class="rr__other-list">
<div v-for="item in otherItems" :key="item.itemType" class="rr__other-row">
<span class="rr__other-label">{{ item.label }}</span>
<UiTag :type="item.tag">{{ item.statusLabel }}</UiTag>
</div>
</div>
</div>
<div class="rr__actions">
<van-button type="primary" block round @click="router.push('/profile/confirm')">
修改完成,重新提交审核
</van-button>
</div>
<div class="rr__help">如对审核结果有疑问,可联系平台工作人员协助处理。</div>
</template>
<template v-else>
<UiEmpty description="暂无审核反馈">
<van-button size="small" type="primary" @click="router.push('/profile/confirm')">返回资料确认</van-button>
</UiEmpty>
</template>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { profileService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
import UiTag from '@/components/UiTag.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
const router = useRouter()
const appStore = useAppStore()
const loading = ref(true)
const error = ref(false)
const reviewStatus = ref('need_edit')
const issueItems = ref<any[]>([])
const otherItems = ref<any[]>([])
const showAllItems = ref(false)
const ITEM_TYPE_MAP: Record<string, { label: string; route: string }> = {
avatar: { label: '头像', route: '/profile/photos' },
photos: { label: '生活照', route: '/profile/photos' },
self_intro: { label: '个人介绍', route: '/profile/intro' },
basic: { label: '基础资料', route: '/profile/basic' },
region: { label: '居住区域', route: '/profile/basic' },
height: { label: '身高', route: '/profile/basic' },
marital: { label: '婚姻状况', route: '/profile/basic' },
children: { label: '子女情况', route: '/profile/basic' },
education: { label: '学历', route: '/profile/basic' },
occupation: { label: '职业', route: '/profile/basic' },
lifestyle: { label: '生活方式', route: '/profile/lifestyle' },
smoking: { label: '吸烟情况', route: '/profile/lifestyle' },
drinking: { label: '饮酒情况', route: '/profile/lifestyle' },
settlement: { label: '定居意向', route: '/profile/lifestyle' },
intent: { label: '婚恋意愿', route: '/profile/intent' },
relationship_goal: { label: '婚恋目标', route: '/profile/intent' },
marriage_plan: { label: '结婚计划', route: '/profile/intent' },
preference: { label: '择偶条件', route: '/profile/preference' },
interests: { label: '兴趣标签', route: '/profile/interests' },
}
const ITEM_STATUS_MAP: Record<string, { label: string; tag: 'warning' | 'success' | 'danger' | 'info' }> = {
pending: { label: '审核中', tag: 'warning' },
approved: { label: '已通过', tag: 'success' },
need_edit: { label: '需修改', tag: 'warning' },
rejected: { label: '未通过', tag: 'danger' },
}
const REVIEW_LABEL: Record<string, string> = { need_edit: '需要修改', rejected: '审核未通过' }
const reviewStatusLabel = ref('需要修改')
async function loadReviewResult() {
loading.value = true
error.value = false
try {
const review: any = await profileService.getReviewStatus()
const status = review?.status || 'need_edit'
reviewStatus.value = status
if (status === 'approved') {
router.replace('/home')
return
}
if (status === 'pending' || status === 'pending_review') {
router.replace('/review/pending')
return
}
if (status === 'not_submitted') {
router.replace('/profile/confirm')
return
}
reviewStatusLabel.value = REVIEW_LABEL[status] || '需要修改'
const rawItems: any[] = review?.items || []
const issues: any[] = []
const others: any[] = []
rawItems.forEach((raw) => {
const typeMeta = ITEM_TYPE_MAP[raw.itemType] || { label: raw.itemType, route: '/profile/basic' }
const statusMeta = ITEM_STATUS_MAP[raw.status] || { label: raw.status, tag: 'info' as const }
const item = {
itemType: raw.itemType,
label: typeMeta.label,
route: typeMeta.route,
statusLabel: statusMeta.label,
tag: statusMeta.tag,
reasonText: raw.reasonText || '',
}
if (raw.status === 'need_edit' || raw.status === 'rejected') issues.push(item)
else others.push(item)
})
issueItems.value = issues
otherItems.value = others
} catch {
error.value = true
} finally {
loading.value = false
}
}
onMounted(loadReviewResult)
</script>
<style scoped>
.rr {
padding: 16px;
}
.card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 4px 16px 14px;
margin-bottom: 12px;
}
.rr__hero {
text-align: center;
padding: 20px 16px;
}
.rr__hero-icon {
width: 56px;
height: 56px;
border-radius: 50%;
margin: 0 auto 12px;
background: rgba(240, 72, 62, 0.12);
color: var(--mini-danger);
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
font-weight: 700;
}
.rr__hero-title {
font-size: 18px;
font-weight: 600;
color: var(--mini-text-strong);
}
.rr__hero-desc {
font-size: 13px;
color: var(--mini-text-secondary);
margin-top: 8px;
line-height: 1.7;
}
.rr__issue {
padding: 12px 0;
border-bottom: 1px solid var(--mini-border-weak, #f2f3f5);
}
.rr__issue:last-child {
border-bottom: none;
}
.rr__issue-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.rr__issue-title {
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
}
.rr__issue-reason {
margin-top: 8px;
font-size: 13px;
color: var(--mini-text-secondary);
line-height: 1.7;
}
.rr__issue-reason-label {
color: var(--mini-text-muted);
margin-right: 6px;
}
.rr__issue-action {
margin-top: 8px;
font-size: 13px;
color: var(--mini-tabbar-active);
text-align: right;
}
.rr__toggle {
display: flex;
justify-content: space-between;
font-size: 13px;
color: var(--mini-text-secondary);
padding: 12px 0 4px;
}
.rr__other-list {
margin-top: 6px;
}
.rr__other-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
}
.rr__other-label {
font-size: 13px;
color: var(--mini-text);
}
.rr__actions {
margin-top: 8px;
}
.rr__help {
font-size: 12px;
color: var(--mini-text-muted);
text-align: center;
margin-top: 20px;
line-height: 1.7;
}
</style>
+242
View File
@@ -0,0 +1,242 @@
<template>
<PageLayout :title="typeLabel">
<div class="appeal">
<div class="appeal__intro">
<div class="appeal__intro-title">{{ typeLabel }}</div>
<div class="appeal__intro-sub">请如实描述您的情况,工作人员将认真审核</div>
</div>
<UiSectionHeader title="申诉原因(必填,至少 10 字)" />
<div class="appeal__field">
<van-field
v-model="reason"
type="textarea"
rows="4"
maxlength="500"
show-word-limit
placeholder="请详细说明您认为处置不当的原因,以及您希望恢复账号的理由..."
:error-message="reasonError"
/>
</div>
<UiSectionHeader title="联系方式(选填)" />
<div class="appeal__field">
<van-field v-model="contactMethod" placeholder="手机号或邮箱,便于我们联系您" maxlength="100" />
</div>
<van-button class="appeal__submit" type="primary" block round :loading="submitting" @click="submit">
提交申诉
</van-button>
<p class="appeal__hint">提交后工作人员将在 1-3 个工作日内审核,请勿重复提交</p>
<UiSectionHeader title="我的申诉记录" />
<UiLoading v-if="listLoading" />
<UiErrorState v-else-if="listError" @retry="loadAppeals" />
<template v-else>
<template v-if="appeals.length">
<div v-for="item in appeals" :key="item.id" class="appeal__card">
<div class="appeal__card-head">
<span class="appeal__card-type">{{ item.appealTypeText || typeText(item.appealType) }}</span>
<UiTag :type="statusMeta(item.status).type">{{ statusMeta(item.status).text }}</UiTag>
</div>
<p class="appeal__card-reason">{{ item.reason }}</p>
<div class="appeal__card-time">{{ formatDateTime(item.createdAt) }}</div>
<div v-if="item.decisionNote" class="appeal__card-note">处理意见:{{ item.decisionNote }}</div>
</div>
</template>
<UiEmpty v-else description="暂无申诉记录" />
</template>
<p class="appeal__free">本平台为公益服务,申诉受理永久免费</p>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { appealService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import { formatDateTime } from '@/utils/datetime'
import PageLayout from '@/layouts/PageLayout.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
import UiTag from '@/components/UiTag.vue'
type TagType = 'primary' | 'success' | 'warning' | 'danger' | 'info'
const TYPE_LABELS: Record<string, string> = {
ban_appeal: '封禁申诉',
suspend_appeal: '暂停申诉',
}
const STATUS_META: Record<string, { text: string; type: TagType }> = {
submitted: { text: '已提交', type: 'info' },
pending: { text: '待处理', type: 'warning' },
reviewing: { text: '审核中', type: 'warning' },
approved: { text: '已通过', type: 'success' },
rejected: { text: '已驳回', type: 'danger' },
closed: { text: '已关闭', type: 'info' },
}
const MIN_REASON_LEN = 10
const route = useRoute()
const appStore = useAppStore()
const appealType = ref(String(route.query.type || 'ban_appeal'))
const relatedActionId = ref(String(route.query.actionId || ''))
const reason = ref('')
const reasonError = ref('')
const contactMethod = ref('')
const submitting = ref(false)
const listLoading = ref(true)
const listError = ref(false)
const appeals = ref<any[]>([])
const typeLabel = computed(() => TYPE_LABELS[appealType.value] || '申诉')
function typeText(type?: string): string {
return TYPE_LABELS[type || ''] || '申诉'
}
function statusMeta(status?: string) {
return STATUS_META[status || ''] || { text: status || '未知', type: 'info' as TagType }
}
function pick(result: any): any[] {
if (Array.isArray(result)) return result
return result?.items || result?.records || []
}
async function loadAppeals() {
listLoading.value = true
listError.value = false
try {
const data = await appealService.getMyAppeals({})
appeals.value = pick(data)
} catch {
listError.value = true
} finally {
listLoading.value = false
}
}
async function submit() {
if (submitting.value) return
const trimmed = reason.value.trim()
if (!trimmed) {
reasonError.value = '请填写申诉原因'
return
}
if (trimmed.length < MIN_REASON_LEN) {
reasonError.value = `申诉原因请至少 ${MIN_REASON_LEN} 个字`
return
}
reasonError.value = ''
submitting.value = true
try {
await appealService.submitAppeal({
appealType: appealType.value,
relatedActionId: relatedActionId.value || undefined,
reason: trimmed,
evidenceFileIds: [],
contactMethod: contactMethod.value.trim() || undefined,
})
appStore.toastSuccess('申诉已提交')
reason.value = ''
contactMethod.value = ''
await loadAppeals()
} catch (err: any) {
appStore.toastError(err?.message || '提交失败')
} finally {
submitting.value = false
}
}
onMounted(loadAppeals)
</script>
<style scoped>
.appeal {
padding: 12px 16px 24px;
}
.appeal__intro {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 14px 16px;
}
.appeal__intro-title {
font-size: 16px;
font-weight: 600;
color: var(--mini-text-strong);
}
.appeal__intro-sub {
font-size: 13px;
color: var(--mini-text-secondary);
margin-top: 6px;
}
.appeal :deep(.ui-section-header) {
margin-left: 0;
margin-right: 0;
}
.appeal__field {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
overflow: hidden;
}
.appeal__submit {
margin-top: 20px;
}
.appeal__hint {
text-align: center;
font-size: 12px;
color: var(--mini-text-muted);
margin: 10px 0 0;
line-height: 1.6;
}
.appeal__card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 12px 14px;
margin-bottom: 10px;
}
.appeal__card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.appeal__card-type {
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
}
.appeal__card-reason {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.6;
color: var(--mini-text);
}
.appeal__card-time {
font-size: 11px;
color: var(--mini-text-muted);
margin-top: 8px;
}
.appeal__card-note {
margin-top: 8px;
font-size: 13px;
color: var(--mini-success);
}
.appeal__free {
text-align: center;
font-size: 11px;
color: var(--mini-text-muted);
margin: 16px 0 0;
}
</style>
+145
View File
@@ -0,0 +1,145 @@
<template>
<PageLayout title="我的举报">
<div class="reports">
<UiLoading v-if="loading" />
<UiErrorState v-else-if="error" @retry="load" />
<template v-else>
<template v-if="list.length">
<div v-for="item in list" :key="item.id" class="reports__card" @click="openDetail(item.id)">
<div class="reports__head">
<span class="reports__reason">{{ item.reasonText || '举报' }}</span>
<span class="reports__head-right">
<UiTag :type="statusMeta(item.status).type">{{ statusMeta(item.status).text }}</UiTag>
<van-icon name="arrow" color="#9aa0b5" />
</span>
</div>
<p v-if="item.reportedName" class="reports__target">被举报人:{{ item.reportedName }}</p>
<p v-if="item.description" class="reports__desc">{{ item.description }}</p>
<div class="reports__time">{{ formatDateTime(item.createdAt) }}</div>
<div v-if="item.processResult || item.processNote" class="reports__result">
处理结果:{{ item.processResult || item.processNote }}
</div>
</div>
</template>
<UiEmpty v-else description="暂无举报记录" />
<div class="reports__actions">
<van-button size="small" plain round @click="load">刷新</van-button>
</div>
</template>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { safetyService } from '@/api/services'
import { formatDateTime } from '@/utils/datetime'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
import UiTag from '@/components/UiTag.vue'
type TagType = 'primary' | 'success' | 'warning' | 'danger' | 'info'
const STATUS_META: Record<string, { text: string; type: TagType }> = {
pending: { text: '待处理', type: 'warning' },
processing: { text: '处理中', type: 'warning' },
resolved: { text: '已处理', type: 'success' },
rejected: { text: '未受理', type: 'info' },
dismissed: { text: '已驳回', type: 'info' },
}
const loading = ref(true)
const error = ref(false)
const list = ref<any[]>([])
const router = useRouter()
function openDetail(id?: string) {
if (!id) return
router.push({ path: '/my/reports/detail', query: { id } })
}
function pick(result: any): any[] {
if (Array.isArray(result)) return result
return result?.items || result?.records || []
}
function statusMeta(status?: string) {
return STATUS_META[status || ''] || { text: status || '未知', type: 'info' as TagType }
}
async function load() {
loading.value = true
error.value = false
try {
const data = await safetyService.getMyReports({})
list.value = pick(data)
} catch {
error.value = true
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<style scoped>
.reports {
padding: 12px 16px 24px;
}
.reports__card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 12px 14px;
margin-bottom: 10px;
cursor: pointer;
}
.reports__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.reports__reason {
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
}
.reports__head-right {
display: flex;
align-items: center;
gap: 4px;
flex: none;
}
.reports__target {
margin: 6px 0 0;
font-size: 12px;
color: var(--mini-text-secondary);
}
.reports__desc {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.6;
color: var(--mini-text);
}
.reports__time {
font-size: 11px;
color: var(--mini-text-muted);
margin-top: 8px;
}
.reports__result {
margin-top: 8px;
font-size: 13px;
color: var(--mini-success);
}
.reports__actions {
text-align: center;
margin-top: 8px;
}
</style>
@@ -0,0 +1,276 @@
<template>
<PageLayout title="举报详情">
<div class="rd">
<UiLoading v-if="loading" />
<UiErrorState v-else-if="error" :message="errorMessage" @retry="load" />
<template v-else-if="report">
<!-- 状态概要 -->
<div class="rd__hero">
<div class="rd__hero-top">
<span class="rd__reason">{{ report.reasonText || '举报' }}</span>
<UiTag :type="statusMeta(report.status).type">{{ statusMeta(report.status).text }}</UiTag>
</div>
<div v-if="report.reportedName" class="rd__hero-sub">被举报人:{{ report.reportedName }}</div>
<div class="rd__hero-time">提交时间:{{ formatDateTime(report.createdAt) }}</div>
</div>
<UiSectionHeader title="举报内容" />
<div class="rd__card">
<div class="rd__row">
<span class="rd__label">举报原因</span>
<span class="rd__value">{{ report.reasonText || report.reasonType || '—' }}</span>
</div>
<div class="rd__row rd__row--block">
<span class="rd__label">补充说明</span>
<span class="rd__value rd__value--long">{{ report.description || '无' }}</span>
</div>
</div>
<!-- 处理结果:仅在有处理结论时展示 -->
<template v-if="report.processResult || report.processNote">
<UiSectionHeader title="处理结果" />
<div class="rd__card rd__card--result">
<div v-if="report.processResult" class="rd__result">
{{ processMeta(report.processResult) }}
</div>
<p v-if="report.processNote" class="rd__note">{{ report.processNote }}</p>
</div>
</template>
<div v-else class="rd__pending">
工作人员正在核实处理中,请耐心等待。处理结果会通过站内通知告知您。
</div>
<!-- 证据截图:签名 URL 短时有效,点击可放大预览 -->
<template v-if="evidenceUrls.length">
<UiSectionHeader :title="`证据截图(${evidenceUrls.length})`" more-text="点击预览" @more="previewEvidence(0)" />
<div class="rd__evidence">
<div
v-for="(url, i) in evidenceUrls"
:key="i"
class="rd__evidence-item"
@click="previewEvidence(i)"
>
<img :src="url" class="rd__evidence-img" alt="" />
</div>
</div>
</template>
<div class="rd__actions">
<van-button size="small" plain round @click="load">刷新</van-button>
<van-button size="small" plain round @click="goBack">返回</van-button>
</div>
</template>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showImagePreview } from 'vant'
import { safetyService } from '@/api/services'
import { formatDateTime } from '@/utils/datetime'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiErrorState from '@/components/UiErrorState.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
import UiTag from '@/components/UiTag.vue'
type TagType = 'primary' | 'success' | 'warning' | 'danger' | 'info'
const STATUS_META: Record<string, { text: string; type: TagType }> = {
pending: { text: '待处理', type: 'warning' },
processing: { text: '处理中', type: 'warning' },
resolved: { text: '已处理', type: 'success' },
rejected: { text: '未受理', type: 'info' },
dismissed: { text: '已驳回', type: 'info' },
}
/** 处理结论(对齐 ReportProcessDTO.processResult 取值) */
const PROCESS_RESULT_TEXT: Record<string, string> = {
warning: '已向对方发出警示',
feature_limit: '已限制对方部分功能',
temporary_suspend: '已暂时封禁对方账号',
ban: '已永久封禁对方账号',
no_action: '经核实举报不成立,不予处理',
}
const route = useRoute()
const router = useRouter()
const reportId = ref(String(route.query.id || ''))
const loading = ref(true)
const error = ref(false)
const errorMessage = ref('加载失败,请稍后重试')
const report = ref<any>(null)
const evidenceUrls = ref<string[]>([])
function statusMeta(status?: string) {
return STATUS_META[status || ''] || { text: status || '未知', type: 'info' as TagType }
}
function processMeta(result?: string) {
if (!result) return '—'
return PROCESS_RESULT_TEXT[result] || result
}
function previewEvidence(index: number) {
if (!evidenceUrls.value.length) return
showImagePreview({ images: evidenceUrls.value, startPosition: index })
}
async function load() {
if (!reportId.value) {
loading.value = false
error.value = true
errorMessage.value = '未指定举报记录'
return
}
loading.value = true
error.value = false
try {
const data: any = await safetyService.getReportDetail(reportId.value)
const detail = data?.report ? data : (data?.data || {})
report.value = detail?.report || null
evidenceUrls.value = Array.isArray(detail?.evidenceUrls)
? detail.evidenceUrls.filter((u: any) => typeof u === 'string' && u)
: []
if (!report.value) {
error.value = true
errorMessage.value = '举报记录不存在'
}
} catch (err: any) {
error.value = true
errorMessage.value = err?.status === 404 ? '举报记录不存在' : '加载失败,请稍后重试'
} finally {
loading.value = false
}
}
function goBack() {
if (window.history.length > 1) router.back()
else router.replace('/my/reports')
}
onMounted(load)
</script>
<style scoped>
.rd {
padding: 12px 16px 24px;
}
.rd__hero {
background: var(--mini-brand-gradient);
color: #fff;
padding: 16px;
border-radius: var(--mini-radius);
}
.rd__hero-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.rd__reason {
font-size: 17px;
font-weight: 600;
}
.rd__hero-sub {
font-size: 12px;
opacity: 0.9;
margin-top: 8px;
}
.rd__hero-time {
font-size: 11px;
opacity: 0.8;
margin-top: 4px;
}
.rd :deep(.ui-section-header) {
margin-left: 0;
margin-right: 0;
}
.rd__card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 4px 14px;
}
.rd__card--result {
border-left: 3px solid var(--mini-success);
}
.rd__row {
display: flex;
gap: 10px;
padding: 11px 0;
border-bottom: 1px solid var(--mini-border);
}
.rd__row:last-child {
border-bottom: none;
}
.rd__row--block {
flex-direction: column;
gap: 4px;
}
.rd__label {
flex: none;
width: 68px;
font-size: 13px;
color: var(--mini-text-secondary);
}
.rd__value {
flex: 1;
min-width: 0;
font-size: 13px;
color: var(--mini-text-strong);
word-break: break-word;
}
.rd__value--long {
line-height: 1.7;
}
.rd__result {
padding-top: 12px;
font-size: 14px;
font-weight: 600;
color: var(--mini-success);
}
.rd__note {
margin: 6px 0 12px;
font-size: 13px;
line-height: 1.7;
color: var(--mini-text);
}
.rd__pending {
margin-top: 12px;
font-size: 13px;
line-height: 1.7;
color: var(--mini-text-secondary);
background: rgba(245, 166, 35, 0.12);
border-radius: var(--mini-radius);
padding: 12px 14px;
}
.rd__evidence {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.rd__evidence-item {
position: relative;
padding-top: 100%;
border-radius: 10px;
overflow: hidden;
background: var(--mini-bg);
}
.rd__evidence-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.rd__actions {
display: flex;
justify-content: center;
gap: 12px;
margin-top: 20px;
}
</style>
+394
View File
@@ -0,0 +1,394 @@
<template>
<PageLayout title="举报">
<div class="report">
<!-- 提交成功 -->
<div v-if="submitted" class="report__success">
<div class="report__success-icon">✓</div>
<div class="report__success-title">举报已提交</div>
<p class="report__success-hint">工作人员会尽快核实处理,感谢您的监督。</p>
<van-button type="primary" round @click="goBack">返回</van-button>
</div>
<!-- 表单 -->
<template v-else>
<div v-if="targetName" class="report__target">被举报人:{{ targetName }}</div>
<div v-if="!targetUserId" class="report__target report__target--warn">缺少举报对象,无法提交</div>
<UiSectionHeader title="举报原因" more-text="必填" />
<div class="report__reasons">
<div
v-for="r in REPORT_REASONS"
:key="r.value"
class="report__reason"
:class="{ 'report__reason--active': reportType === r.value }"
@click="onReasonChange(r.value)"
>
{{ r.label }}
</div>
</div>
<template v-if="reportType">
<UiSectionHeader title="补充说明" :more-text="highRisk ? '高风险必填' : '选填'" />
<div class="report__field">
<van-field
v-model="description"
type="textarea"
rows="4"
maxlength="500"
show-word-limit
placeholder="请描述您遇到的情况,帮助工作人员核实..."
:error-message="descError"
/>
</div>
</template>
<!-- 证据截图:选图 → 本地预览 → 可删除(上限 9 张,行为对齐 miniapp report.wxml) -->
<UiSectionHeader title="证据截图" :more-text="evidenceFiles.length ? `${evidenceFiles.length}/${MAX_EVIDENCE}` : '选填,最多 9 张'" />
<div class="report__evidence">
<div
v-for="(f, i) in evidenceFiles"
:key="f.url"
class="report__evidence-item"
@click="previewEvidence(i)"
>
<img :src="f.url" class="report__evidence-img" alt="" />
<div class="report__evidence-remove" @click.stop="removeEvidence(i)">×</div>
</div>
<div
v-if="evidenceFiles.length < MAX_EVIDENCE"
class="report__evidence-add"
:class="{ 'report__evidence-add--loading': uploadingEvidence }"
@click="chooseEvidence"
>
<span class="report__evidence-add-icon">{{ uploadingEvidence ? '…' : '+' }}</span>
<span class="report__evidence-add-text">{{ uploadingEvidence ? '上传中' : '添加截图' }}</span>
</div>
<input ref="fileInput" type="file" accept="image/*" multiple class="report__file-input" @change="onEvidenceChange" />
</div>
<van-button
class="report__submit"
type="primary"
block
round
:loading="submitting"
:disabled="!reportType || !targetUserId || uploadingEvidence"
@click="submit"
>
提交举报
</van-button>
<p class="report__hint">提交后工作人员会尽快核实,不会向对方透露举报人信息</p>
<p class="report__free">本平台为公益服务,举报受理永久免费</p>
</template>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onUnmounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showImagePreview } from 'vant'
import { safetyService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const REPORT_REASONS = [
{ value: 'fake_profile', label: '虚假资料' },
{ value: 'fake_marital_status', label: '婚姻状态疑似虚假' },
{ value: 'harassment', label: '骚扰' },
{ value: 'pornography', label: '色情低俗' },
{ value: 'advertising', label: '广告营销' },
{ value: 'fraud', label: '诈骗' },
{ value: 'money_inducement', label: '金钱诱导' },
{ value: 'impersonation', label: '冒充他人' },
{ value: 'other', label: '其他' },
]
const HIGH_RISK_REASONS = new Set(['fraud', 'money_inducement', 'impersonation', 'fake_marital_status'])
const MAX_EVIDENCE = 9
const submitting = ref(false)
const submitted = ref(false)
const descError = ref('')
const targetUserId = ref(String(route.query.targetUserId || ''))
const targetName = ref(String(route.query.targetName || ''))
const reportType = ref('')
const description = ref('')
const evidenceFiles = ref<{ url: string; name: string; fileKey?: string }[]>([])
const fileInput = ref<HTMLInputElement | null>(null)
const uploadingEvidence = ref(false)
const highRisk = computed(() => HIGH_RISK_REASONS.has(reportType.value))
function onReasonChange(value: string) {
reportType.value = value
descError.value = ''
}
// ============ 证据截图 ============
function chooseEvidence() {
if (uploadingEvidence.value) return
if (evidenceFiles.value.length >= MAX_EVIDENCE) {
appStore.toastError(`最多上传 ${MAX_EVIDENCE} 张截图`)
return
}
fileInput.value?.click()
}
// 选图后逐张直传对象存储(凭证 → PUT),成功后记录 fileKey 用于提交
async function onEvidenceChange(evt: Event) {
const input = evt.target as HTMLInputElement
const picked = Array.from(input.files || [])
input.value = ''
if (!picked.length) return
const remain = MAX_EVIDENCE - evidenceFiles.value.length
if (remain <= 0) {
appStore.toastError(`最多上传 ${MAX_EVIDENCE} 张截图`)
return
}
const targets = picked.slice(0, remain)
if (picked.length > remain) {
appStore.toastError(`最多上传 ${MAX_EVIDENCE} 张截图`)
}
uploadingEvidence.value = true
let uploaded = 0
for (const file of targets) {
try {
const token: any = await safetyService.requestEvidenceUploadToken()
const res = await fetch(token.uploadUrl, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type || 'image/jpeg' },
})
if (!res.ok) throw new Error(`上传失败(${res.status})`)
evidenceFiles.value.push({
url: URL.createObjectURL(file),
name: file.name,
fileKey: token.fileKey,
})
uploaded += 1
} catch (err: any) {
appStore.toastError(err?.message || '截图上传失败,请重试')
}
}
uploadingEvidence.value = false
if (uploaded > 0) appStore.toastSuccess(`已添加 ${uploaded} 张截图`)
}
function removeEvidence(index: number) {
const file = evidenceFiles.value[index]
if (file) URL.revokeObjectURL(file.url)
evidenceFiles.value.splice(index, 1)
}
function previewEvidence(index: number) {
const urls = evidenceFiles.value.map((f) => f.url)
if (!urls.length) return
showImagePreview({ images: urls, startPosition: index })
}
onUnmounted(() => {
evidenceFiles.value.forEach((f) => URL.revokeObjectURL(f.url))
})
// ============ 提交 ============
async function submit() {
if (submitting.value) return
if (!targetUserId.value) {
appStore.toastError('缺少举报对象')
return
}
if (!reportType.value) {
appStore.toastError('请选择举报原因')
return
}
const desc = description.value.trim()
if (highRisk.value && !desc) {
descError.value = '该类举报需补充说明(必填)'
return
}
submitting.value = true
try {
await safetyService.submitReport({
targetUserId: targetUserId.value,
reportType: reportType.value,
description: desc,
// 证据截图已直传对象存储,提交已上传成功的 fileKey 列表(后端校验归属与存在性)
evidenceFileIds: evidenceFiles.value
.map((f) => f.fileKey)
.filter((k): k is string => !!k),
})
submitted.value = true
appStore.toastSuccess('举报已提交')
} catch (err: any) {
appStore.toastError(err?.message || '提交失败')
} finally {
submitting.value = false
}
}
function goBack() {
if (window.history.length > 1) router.back()
else router.replace('/my/reports')
}
</script>
<style scoped>
.report {
padding: 12px 16px 24px;
}
.report__target {
font-size: 13px;
color: var(--mini-text-secondary);
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 10px 12px;
margin-bottom: 8px;
}
.report__target--warn {
color: var(--mini-danger);
}
.report :deep(.ui-section-header) {
margin-left: 0;
margin-right: 0;
}
.report__reasons {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.report__reason {
text-align: center;
font-size: 13px;
padding: 10px 4px;
border-radius: var(--mini-radius);
background: var(--mini-surface);
box-shadow: var(--mini-shadow);
color: var(--mini-text);
border: 1px solid transparent;
}
.report__reason--active {
color: var(--mini-tabbar-active);
border-color: var(--mini-tabbar-active);
background: rgba(244, 0, 90, 0.06);
}
.report__field {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
overflow: hidden;
}
.report__evidence {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.report__evidence-item {
position: relative;
padding-top: 100%;
border-radius: 10px;
overflow: hidden;
background: var(--mini-bg);
}
.report__evidence-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.report__evidence-remove {
position: absolute;
top: 2px;
right: 2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 14px;
line-height: 20px;
text-align: center;
}
.report__evidence-add {
position: relative;
padding-top: 100%;
border: 1px dashed var(--mini-border, #dcdfe6);
border-radius: 10px;
color: var(--mini-text-secondary);
}
.report__evidence-add--loading {
opacity: 0.6;
}
.report__evidence-add-icon {
position: absolute;
top: 26%;
left: 0;
right: 0;
text-align: center;
font-size: 24px;
}
.report__evidence-add-text {
position: absolute;
top: 58%;
left: 0;
right: 0;
text-align: center;
font-size: 11px;
}
.report__file-input {
display: none;
}
.report__submit {
margin-top: 20px;
}
.report__hint {
text-align: center;
font-size: 12px;
color: var(--mini-text-muted);
margin: 10px 0 0;
line-height: 1.6;
}
.report__free {
text-align: center;
font-size: 11px;
color: var(--mini-text-muted);
margin: 16px 0 0;
}
.report__success {
text-align: center;
padding: 48px 16px;
}
.report__success-icon {
width: 56px;
height: 56px;
margin: 0 auto 12px;
border-radius: 50%;
background: rgba(47, 179, 109, 0.14);
color: var(--mini-success);
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
}
.report__success-title {
font-size: 16px;
font-weight: 600;
color: var(--mini-text-strong);
}
.report__success-hint {
font-size: 13px;
color: var(--mini-text-secondary);
margin: 8px 0 20px;
line-height: 1.7;
}
</style>
@@ -0,0 +1,268 @@
<template>
<PageLayout title="我的预约">
<div class="ma">
<div class="ma__hero">
<div class="ma__eyebrow">我的公益服务</div>
<div class="ma__title">我的预约</div>
<div class="ma__desc">查看预约状态、到站时间与服务地点。</div>
</div>
<div class="ma__body">
<UiErrorState v-if="error" message="加载失败,请稍后重试" @retry="load" />
<UiLoading v-else-if="loading" />
<template v-else>
<div v-if="items.length">
<div v-for="a in items" :key="a.id" class="ma__card" @click="openDetail(a)">
<div class="ma__card-head">
<span class="ma__card-title">{{ stationName(a) }}</span>
<UiTag :type="statusTone(a.status)">{{ statusLabel(a.status) }}</UiTag>
</div>
<div class="ma__meta">{{ timeText(a) }}</div>
<div v-if="serviceTypeLabel(a.serviceType)" class="ma__meta">{{ serviceTypeLabel(a.serviceType) }}</div>
<div v-if="a.cancellable" class="ma__cancel">
<van-button size="small" plain @click.stop="cancel(a)">取消预约</van-button>
</div>
</div>
</div>
<UiEmpty v-else description="还没有服务预约">
<van-button type="primary" round size="small" @click="router.push('/service/appointment')">
去预约到站服务
</van-button>
</UiEmpty>
</template>
</div>
</div>
<van-popup v-model:show="showDetail" position="bottom" round>
<div class="ma__detail">
<div class="ma__detail-title">预约详情</div>
<UiLoading v-if="detailLoading" />
<template v-else-if="detail">
<div class="ma__detail-row">
<span class="ma__detail-label">服务站点</span>
<span class="ma__detail-value">{{ stationName(detail) }}</span>
</div>
<div class="ma__detail-row">
<span class="ma__detail-label">服务项目</span>
<span class="ma__detail-value">{{ serviceTypeLabel(detail.serviceType) || '以工作人员确认为准' }}</span>
</div>
<div class="ma__detail-row">
<span class="ma__detail-label">预约时间</span>
<span class="ma__detail-value">{{ timeText(detail) }}</span>
</div>
<div class="ma__detail-row">
<span class="ma__detail-label">当前状态</span>
<span class="ma__detail-value">{{ statusLabel(detail.status) }}</span>
</div>
<div v-if="detail.userNote" class="ma__detail-row">
<span class="ma__detail-label">沟通事项</span>
<span class="ma__detail-value">{{ detail.userNote }}</span>
</div>
<div class="ma__detail-row">
<span class="ma__detail-label">提交时间</span>
<span class="ma__detail-value">{{ formatDateTime(detail.createdAt) || '—' }}</span>
</div>
</template>
<UiErrorState v-else message="预约详情加载失败" @retry="reloadDetail" />
</div>
</van-popup>
</PageLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showConfirmDialog } from 'vant'
import { serviceAppointmentService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import { formatDateTime } from '@/utils/datetime'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
import UiTag from '@/components/UiTag.vue'
const router = useRouter()
const appStore = useAppStore()
const STATUS_LABEL: Record<string, string> = {
submitted: '待确认',
pending_confirmation: '待确认',
confirmed: '已确认',
completed: '已完成',
cancelled: '已取消',
}
const SERVICE_TYPE_LABEL: Record<string, string> = {
station_visit: '到站资料协助',
matchmaker: '公益红娘咨询',
counselor_consultation: '婚恋咨询预约',
}
const loading = ref(true)
const error = ref(false)
const items = ref<any[]>([])
const showDetail = ref(false)
const detailLoading = ref(false)
const detail = ref<any>(null)
const currentId = ref('')
function statusLabel(status: string): string {
return STATUS_LABEL[status] || status || ''
}
function statusTone(status: string): 'primary' | 'success' | 'warning' | 'danger' | 'info' {
if (status === 'confirmed') return 'success'
if (status === 'submitted' || status === 'pending_confirmation') return 'warning'
if (status === 'cancelled') return 'info'
return 'primary'
}
function serviceTypeLabel(type: string): string {
return SERVICE_TYPE_LABEL[type] || ''
}
function stationName(a: any): string {
return a?.serviceLocationName || '善缘丹阳公益服务站'
}
function timeText(a: any): string {
const text = [a?.preferredDate, a?.preferredTimeSlot].filter(Boolean).join(' ')
return text || '时间待确认'
}
async function load() {
loading.value = true
error.value = false
try {
const result: any = await serviceAppointmentService.getMyAppointments()
const list: any[] = result?.items || result?.records || (Array.isArray(result) ? result : [])
items.value = list.map((item) => ({
...item,
cancellable: item.status !== 'completed' && item.status !== 'cancelled',
}))
} catch {
error.value = true
} finally {
loading.value = false
}
}
async function openDetail(a: any) {
if (!a?.id) return
currentId.value = a.id
showDetail.value = true
await reloadDetail()
}
async function reloadDetail() {
if (!currentId.value) return
detailLoading.value = true
detail.value = null
try {
detail.value = await serviceAppointmentService.getAppointmentDetail(currentId.value)
} finally {
detailLoading.value = false
}
}
async function cancel(a: any) {
if (!a?.id) return
try {
await showConfirmDialog({ title: '取消预约', message: '确定取消本次服务预约吗?' })
} catch {
return
}
try {
await serviceAppointmentService.cancelAppointment(a.id, '用户取消')
appStore.toastSuccess('已取消')
load()
} catch (err: any) {
appStore.toastError(err?.message || '取消失败,请稍后重试')
}
}
onMounted(load)
</script>
<style scoped>
.ma {
min-height: 100vh;
background: var(--mini-bg);
padding-bottom: 24px;
}
.ma__hero {
background: var(--mini-brand-gradient);
color: #fff;
padding: 18px 16px 20px;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
}
.ma__eyebrow {
font-size: 11px;
opacity: 0.8;
}
.ma__title {
font-size: 19px;
font-weight: 600;
margin-top: 4px;
}
.ma__desc {
font-size: 12px;
opacity: 0.85;
margin-top: 6px;
}
.ma__body {
padding: 0 16px;
}
.ma__card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 14px;
margin-top: 12px;
}
.ma__card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.ma__card-title {
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
}
.ma__meta {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 6px;
}
.ma__cancel {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
.ma__detail {
padding: 18px 16px calc(18px + env(safe-area-inset-bottom));
}
.ma__detail-title {
font-size: 16px;
font-weight: 600;
color: var(--mini-text-strong);
margin-bottom: 8px;
}
.ma__detail-row {
display: flex;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid var(--mini-border);
}
.ma__detail-label {
flex: none;
width: 72px;
font-size: 13px;
color: var(--mini-text-secondary);
}
.ma__detail-value {
flex: 1;
font-size: 13px;
color: var(--mini-text-strong);
line-height: 1.5;
}
</style>
@@ -0,0 +1,197 @@
<template>
<PageLayout title="公益服务记录">
<div class="hist">
<div class="hist__hero">
<div class="hist__eyebrow">公益服务记录</div>
<div class="hist__title">每一次服务,都清楚可查</div>
<div class="hist__desc">这里只展示与你有关的公益服务事实,不展示咨询正文。</div>
</div>
<div class="hist__body">
<UiErrorState v-if="error" message="加载失败,请稍后重试" @retry="load" />
<UiLoading v-else-if="loading" />
<template v-else>
<div v-if="records.length" class="hist__card">
<div v-for="r in records" :key="r.id" class="hist__row">
<div class="hist__icon" :class="`tone-${toneOf(r.category)}`">{{ iconOf(r.category) }}</div>
<div class="hist__copy">
<div class="hist__row-title">{{ r.title }}</div>
<div class="hist__row-date">{{ occurredText(r) }}</div>
</div>
<UiTag :type="statusTone(r.status)">{{ statusLabel(r) }}</UiTag>
</div>
</div>
<UiEmpty v-else description="还没有公益服务记录">
<van-button type="primary" round size="small" @click="router.push('/service/appointment')">
预约到站服务
</van-button>
</UiEmpty>
<div class="hist__notice">咨询与公益红娘记录彼此分离;工作人员只能查看职责范围内的信息。</div>
</template>
</div>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { serviceHistoryService } from '@/api/services'
import { formatDateTime } from '@/utils/datetime'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
import UiTag from '@/components/UiTag.vue'
const router = useRouter()
const CATEGORY_ICON: Record<string, string> = { appointment: '约', counseling: '咨' }
const CATEGORY_TONE: Record<string, string> = { appointment: 'blue', counseling: 'purple' }
const STATUS_LABEL: Record<string, string> = {
submitted: '待确认',
pending_confirmation: '待确认',
confirmed: '已确认',
completed: '已完成',
cancelled: '已取消',
pending: '待处理',
assigned: '已分配',
scheduled: '已排期',
in_progress: '进行中',
closed: '已关闭',
}
const loading = ref(true)
const error = ref(false)
const records = ref<any[]>([])
function iconOf(category: string): string {
return CATEGORY_ICON[category] || '记'
}
function toneOf(category: string): string {
return CATEGORY_TONE[category] || 'green'
}
function statusLabel(r: any): string {
return STATUS_LABEL[r?.status] || r?.status || ''
}
function statusTone(status: string): 'primary' | 'success' | 'warning' | 'danger' | 'info' {
if (status === 'completed') return 'success'
if (status === 'cancelled' || status === 'closed') return 'info'
if (status === 'submitted' || status === 'pending_confirmation' || status === 'pending') return 'warning'
return 'primary'
}
function occurredText(r: any): string {
const text = formatDateTime(r?.occurredAt)
return text || (r?.stationName ? r.stationName : '时间待确认')
}
async function load() {
loading.value = true
error.value = false
try {
const result: any = await serviceHistoryService.getMyServiceHistory()
records.value = result?.items || result?.records || (Array.isArray(result) ? result : [])
} catch {
error.value = true
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<style scoped>
.hist {
min-height: 100vh;
background: var(--mini-bg);
padding-bottom: 24px;
}
.hist__hero {
background: var(--mini-brand-gradient);
color: #fff;
padding: 18px 16px 20px;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
}
.hist__eyebrow {
font-size: 11px;
opacity: 0.8;
}
.hist__title {
font-size: 18px;
font-weight: 600;
margin-top: 4px;
}
.hist__desc {
font-size: 12px;
opacity: 0.85;
margin-top: 6px;
line-height: 1.6;
}
.hist__body {
padding: 0 16px;
}
.hist__card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 4px 14px;
margin-top: 12px;
}
.hist__row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid var(--mini-border);
}
.hist__row:last-child {
border-bottom: none;
}
.hist__icon {
width: 38px;
height: 38px;
border-radius: 11px;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 600;
flex: none;
}
.hist__copy {
flex: 1;
min-width: 0;
}
.hist__row-title {
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
}
.hist__row-date {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 2px;
}
.hist__notice {
font-size: 12px;
color: var(--mini-text-secondary);
line-height: 1.6;
background: rgba(47, 179, 109, 0.08);
border-radius: var(--mini-radius);
padding: 12px 14px;
margin-top: 16px;
}
.tone-blue {
background: linear-gradient(135deg, #4f8cff 0%, #2f63e0 100%);
}
.tone-purple {
background: linear-gradient(135deg, #b06cff 0%, #8a3ee0 100%);
}
.tone-green {
background: linear-gradient(135deg, #3fc98a 0%, #199e64 100%);
}
</style>
@@ -0,0 +1,264 @@
<template>
<PageLayout title="服务预约">
<div class="appt">
<div class="appt__hero">
<div class="appt__eyebrow">服务预约</div>
<div class="appt__title">预约到站服务</div>
<div class="appt__desc">选择合适的时间和服务项目,提交后等待工作人员确认。</div>
</div>
<div class="appt__body">
<UiErrorState v-if="error" message="加载失败,请稍后重试" @retry="load" />
<UiLoading v-else-if="loading" />
<template v-else>
<div class="appt__card">
<van-field
v-if="serviceLocationId"
label="服务站点"
:model-value="serviceLocationName || '已选服务站点'"
readonly
/>
<van-field
label="服务项目"
:model-value="serviceLabel"
placeholder="请选择服务项目"
readonly
is-link
@click="chooseService"
/>
<van-field
label="预约日期"
:model-value="date"
placeholder="请选择日期"
readonly
is-link
@click="showDate = true"
/>
<van-field
v-model="note"
label="希望沟通的事项"
type="textarea"
rows="3"
maxlength="200"
show-word-limit
placeholder="请简要说明,避免填写身份证号等敏感信息"
/>
</div>
<div class="appt__notice">
提交预约不代表已确认。工作人员确认后,预约状态会更新为「已确认」。
</div>
<van-button
class="appt__submit"
type="primary"
block
round
:loading="submitting"
loading-text="提交中…"
@click="submit"
>
提交预约
</van-button>
<div class="appt__link" @click="router.push('/my/appointments')">查看我的预约</div>
</template>
</div>
</div>
<van-popup v-model:show="showDate" position="bottom" round>
<van-date-picker
title="选择预约日期"
:model-value="datePickerValue"
:min-date="minDate"
:max-date="maxDate"
@confirm="onDateConfirm"
@cancel="showDate = false"
/>
</van-popup>
<van-popup v-model:show="showServicePicker" position="bottom" round>
<van-picker
title="选择服务项目"
:columns="serviceColumns"
@confirm="onServiceConfirm"
@cancel="showServicePicker = false"
/>
</van-popup>
</PageLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { serviceAppointmentService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
/** 与 miniapp 对齐的服务项目(标签 → 后端 serviceType) */
const SERVICE_OPTIONS = [
{ label: '到站资料协助', type: 'station_visit' },
{ label: '公益红娘咨询', type: 'matchmaker' },
{ label: '活动报名指导', type: 'station_visit' },
{ label: '婚恋咨询预约', type: 'counselor_consultation' },
]
const loading = ref(true)
const error = ref(false)
const submitting = ref(false)
const serviceLocationId = ref('')
const serviceLocationName = ref('')
const serviceIndex = ref(-1)
const date = ref('')
const note = ref('')
const showDate = ref(false)
const minDate = new Date()
const maxDate = new Date(Date.now() + 1000 * 60 * 60 * 24 * 90)
const datePickerValue = ref<string[]>([...todayParts()])
const serviceLabel = ref('')
const showServicePicker = ref(false)
const serviceColumns = SERVICE_OPTIONS.map((o, i) => ({ text: o.label, value: String(i) }))
function todayParts(): string[] {
const now = new Date()
return [String(now.getFullYear()), String(now.getMonth() + 1).padStart(2, '0'), String(now.getDate()).padStart(2, '0')]
}
async function load() {
loading.value = true
error.value = false
try {
const raw = route.query.serviceLocationId
const id = raw ? String(raw).trim() : ''
// 站点上下文来自 U50 服务站跳转,原样回传保证预约落在该站点
if (id) {
serviceLocationId.value = id
const location: any = await serviceAppointmentService.getCurrentServiceLocation()
serviceLocationName.value = location && location.id === id ? location.name : '已选服务站点'
}
} catch {
error.value = true
} finally {
loading.value = false
}
}
function chooseService() {
showServicePicker.value = true
}
function onServiceConfirm(payload: any) {
const value = String(payload?.selectedOptions?.[0]?.value ?? '')
const index = Number(value)
if (Number.isNaN(index) || !SERVICE_OPTIONS[index]) {
showServicePicker.value = false
return
}
serviceIndex.value = index
serviceLabel.value = SERVICE_OPTIONS[index].label
showServicePicker.value = false
}
function onDateConfirm(payload: any) {
const values: string[] = Array.isArray(payload) ? payload : payload?.selectedValues || []
if (values.length < 3) return
const [y, m, d] = values
date.value = `${y}-${m.padStart(2, '0')}-${d.padStart(2, '0')}`
datePickerValue.value = values
showDate.value = false
}
async function submit() {
if (serviceIndex.value < 0) {
appStore.toast('请选择服务项目')
return
}
if (!date.value) {
appStore.toast('请选择预约日期')
return
}
if (submitting.value) return
submitting.value = true
try {
await serviceAppointmentService.createAppointment({
serviceLocationId: serviceLocationId.value || undefined,
serviceType: SERVICE_OPTIONS[serviceIndex.value].type,
preferredDate: date.value,
userNote: note.value,
})
appStore.toastSuccess('预约已提交,工作人员确认后会通知你')
router.replace('/my/appointments')
} catch (err: any) {
appStore.toastError(err?.message || '提交失败,请稍后重试')
} finally {
submitting.value = false
}
}
onMounted(load)
</script>
<style scoped>
.appt {
min-height: 100vh;
background: var(--mini-bg);
padding-bottom: 24px;
}
.appt__hero {
background: var(--mini-brand-gradient);
color: #fff;
padding: 18px 16px 20px;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
}
.appt__eyebrow {
font-size: 11px;
opacity: 0.8;
}
.appt__title {
font-size: 19px;
font-weight: 600;
margin-top: 4px;
}
.appt__desc {
font-size: 12px;
opacity: 0.85;
margin-top: 6px;
line-height: 1.6;
}
.appt__body {
padding: 0 16px;
}
.appt__card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
overflow: hidden;
margin-top: 12px;
}
.appt__notice {
font-size: 12px;
color: var(--mini-text-secondary);
line-height: 1.6;
background: rgba(245, 166, 35, 0.12);
border-radius: var(--mini-radius);
padding: 12px 14px;
margin-top: 12px;
}
.appt__submit {
margin-top: 20px;
}
.appt__link {
text-align: center;
font-size: 13px;
color: var(--mini-text-secondary);
margin-top: 14px;
}
</style>
@@ -0,0 +1,245 @@
<template>
<PageLayout title="公益服务">
<div class="sc">
<div class="sc__hero">
<div class="sc__eyebrow">善缘公益服务</div>
<div class="sc__title">需要帮助时,我们在这里</div>
<div class="sc__desc">从线下服务站到专业咨询,所有服务尊重个人意愿与隐私。</div>
</div>
<div class="sc__body">
<UiErrorState v-if="error" @retry="load" />
<UiLoading v-else-if="loading" />
<template v-else>
<UiSectionHeader title="选择需要的服务" />
<div class="sc__card">
<div v-for="e in entries" :key="e.code" class="sc__row" @click="router.push(e.route)">
<div class="sc__icon" :class="`tone-${e.tone}`">{{ e.icon }}</div>
<div class="sc__copy">
<div class="sc__row-title">{{ e.title }}</div>
<div class="sc__row-desc">{{ e.desc }}</div>
</div>
<UiTag v-if="e.availability === 'enabled'" type="success">可预约</UiTag>
<UiTag v-else-if="e.availability === 'disabled'" type="info">暂不可用</UiTag>
<van-icon name="arrow" color="#9aa0b5" />
</div>
</div>
<UiSectionHeader title="我的公益服务" />
<div class="sc__card">
<div v-for="m in myEntries" :key="m.code" class="sc__row" @click="router.push(m.route)">
<div class="sc__icon" :class="`tone-${m.tone}`">{{ m.icon }}</div>
<div class="sc__copy">
<div class="sc__row-title">{{ m.title }}</div>
<div class="sc__row-desc">{{ m.desc }}</div>
</div>
<van-icon name="arrow" color="#9aa0b5" />
</div>
</div>
<div class="sc__notice">
咨询与公益红娘记录彼此分离;服务站人员仅处理现场预约与指定服务,不会浏览无关用户资料。
</div>
</template>
</div>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { counselingService } from '@/api/services'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiErrorState from '@/components/UiErrorState.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
import UiTag from '@/components/UiTag.vue'
const router = useRouter()
const loading = ref(true)
const error = ref(false)
/** 咨询服务可用性(intro 返回的服务类型),用于给入口标注「可预约 / 暂不可用」 */
const availableTypes = ref<string[]>([])
const entries = computed(() => [
{
code: 'station',
icon: '站',
tone: 'amber',
title: '善缘服务站',
desc: '线下咨询、资料协助与活动服务',
route: '/service-station',
availability: '',
},
{
code: 'counseling',
icon: '咨',
tone: 'blue',
title: '婚恋咨询',
desc: '由咨询人员提供关系与情绪支持',
route: '/counseling',
availability: availabilityOf(['relationship_guidance', 'emotional_support', 'counseling']),
},
{
code: 'premarital',
icon: '伴',
tone: 'pink',
title: '婚前辅导',
desc: '帮助伴侣认真讨论共同生活',
route: '/premarital',
availability: availabilityOf(['premarital_guidance', 'premarital']),
},
{
code: 'program',
icon: '益',
tone: 'violet',
title: '专项公益服务',
desc: '面向特定群体的公益支持项目',
route: '/service/program',
availability: '',
},
{
code: 'referral',
icon: '确',
tone: 'orange',
title: '待确认服务',
desc: '查看红娘为你发起的服务建议',
route: '/referral/confirmation',
availability: '',
},
])
const myEntries = [
{ code: 'appointments', icon: '约', tone: 'blue', title: '我的预约', desc: '查看预约状态与到站时间', route: '/my/appointments' },
{ code: 'counseling', icon: '记', tone: 'purple', title: '我的咨询', desc: '咨询与婚前辅导申请记录', route: '/my/counseling' },
{ code: 'history', icon: '线', tone: 'green', title: '公益服务记录', desc: '与你有关的公益服务时间线', route: '/my/service-history' },
]
function availabilityOf(types: string[]): string {
if (loading.value || error.value) return ''
return types.some((t) => availableTypes.value.includes(t)) ? 'enabled' : 'disabled'
}
async function load() {
loading.value = true
error.value = false
try {
const result: any = await counselingService.intro()
const list: any[] = Array.isArray(result) ? result : result?.items || result?.records || []
availableTypes.value = list.map((s: any) => s?.type).filter(Boolean)
} catch {
error.value = true
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<style scoped>
.sc {
min-height: 100vh;
background: var(--mini-bg);
padding-bottom: 24px;
}
.sc__hero {
background: var(--mini-brand-gradient);
color: #fff;
padding: 18px 16px 20px;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
}
.sc__eyebrow {
font-size: 11px;
opacity: 0.8;
}
.sc__title {
font-size: 19px;
font-weight: 600;
margin-top: 4px;
}
.sc__desc {
font-size: 12px;
opacity: 0.85;
margin-top: 6px;
line-height: 1.6;
}
.sc__body {
padding: 0 16px;
}
.sc__card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
overflow: hidden;
}
.sc__row {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
border-bottom: 1px solid var(--mini-border);
}
.sc__row:last-child {
border-bottom: none;
}
.sc__icon {
width: 42px;
height: 42px;
border-radius: 12px;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 17px;
font-weight: 600;
flex: none;
}
.sc__copy {
flex: 1;
min-width: 0;
}
.sc__row-title {
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
}
.sc__row-desc {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 2px;
}
.sc__notice {
font-size: 12px;
color: var(--mini-text-secondary);
line-height: 1.6;
background: rgba(47, 179, 109, 0.08);
border-radius: var(--mini-radius);
padding: 12px 14px;
margin-top: 16px;
}
.tone-amber {
background: linear-gradient(135deg, #f5a623 0%, #d98a10 100%);
}
.tone-blue {
background: linear-gradient(135deg, #4f8cff 0%, #2f63e0 100%);
}
.tone-pink {
background: linear-gradient(135deg, #ff7ab0 0%, #e0559a 100%);
}
.tone-violet {
background: linear-gradient(135deg, #8a6cff 0%, #6244e0 100%);
}
.tone-orange {
background: linear-gradient(135deg, #ff9a52 0%, #ef6d1f 100%);
}
.tone-purple {
background: linear-gradient(135deg, #b06cff 0%, #8a3ee0 100%);
}
.tone-green {
background: linear-gradient(135deg, #3fc98a 0%, #199e64 100%);
}
</style>
@@ -0,0 +1,270 @@
<template>
<PageLayout title="专项公益服务">
<div class="prog">
<div class="prog__hero">
<div class="prog__eyebrow">专项公益服务</div>
<div class="prog__title">让需要帮助的人被认真看见</div>
<div class="prog__desc">针对特定群体提供阶段性公益支持,项目条件与服务边界公开透明。</div>
</div>
<div class="prog__body">
<UiErrorState v-if="error" message="加载失败,请稍后重试" @retry="load" />
<UiLoading v-else-if="loading" />
<template v-else-if="isDetail">
<UiEmpty v-if="!detail" description="项目不存在或已下线" />
<div v-else class="prog__card">
<div class="prog__card-head">
<span class="prog__name">{{ detail.name }}</span>
<UiTag type="success">{{ STATUS_LABEL[detail.status] || detail.status }}</UiTag>
</div>
<div class="prog__desc-text">{{ detail.description || '项目详情以工作人员说明为准' }}</div>
<div class="prog__period">
{{ formatDate(detail.startAt) || '—' }} 至 {{ formatDate(detail.endAt) || '长期' }}
</div>
<div class="prog__tags">
<UiTag type="primary">公益免费</UiTag>
<UiTag type="primary">自愿申请</UiTag>
<UiTag type="primary">隐私保护</UiTag>
</div>
<van-button class="prog__apply" type="primary" block round :loading="applying" @click="apply">
申请加入
</van-button>
</div>
</template>
<template v-else>
<div v-if="programs.length">
<div v-for="p in programs" :key="p.id" class="prog__card" @click="goDetail(p)">
<div class="prog__card-head">
<span class="prog__name">{{ p.name }}</span>
<UiTag type="success">{{ STATUS_LABEL[p.status] || p.status }}</UiTag>
</div>
<div class="prog__desc-text">{{ p.description || '项目详情以工作人员说明为准' }}</div>
<div class="prog__tags">
<UiTag type="primary">公益免费</UiTag>
<UiTag type="primary">自愿申请</UiTag>
<UiTag type="primary">隐私保护</UiTag>
</div>
</div>
</div>
<UiEmpty v-else description="暂无可申请的专项服务" />
<template v-if="myApplications.length">
<UiSectionHeader title="我的申请" />
<div class="prog__card prog__app">
<div v-for="m in myApplications" :key="m.id" class="prog__app-row">
<span class="prog__app-name">{{ programName(m.programId) }}</span>
<UiTag type="info">{{ memberStatusLabel(m.status) }}</UiTag>
</div>
</div>
</template>
<div class="prog__notice">项目主办或协作关系不会自动获得用户资料访问权限。</div>
</template>
</div>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showConfirmDialog } from 'vant'
import { serviceProgramService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import { formatDate } from '@/utils/datetime'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
import UiTag from '@/components/UiTag.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const STATUS_LABEL: Record<string, string> = {
draft: '筹备中',
active: '进行中',
ended: '已结束',
cancelled: '已取消',
}
const MEMBER_STATUS_LABEL: Record<string, string> = {
applied: '待审核',
pending: '待审核',
approved: '已通过',
rejected: '未通过',
withdrawn: '已退出',
}
/** 列表与详情共用 /service/program 路由:以 query.id 区分,切换时需重新加载 */
const programId = computed(() => String(route.query.id || ''))
const isDetail = computed(() => !!programId.value)
const loading = ref(true)
const error = ref(false)
const applying = ref(false)
const programs = ref<any[]>([])
const detail = ref<any>(null)
const myApplications = ref<any[]>([])
function memberStatusLabel(status: string): string {
return MEMBER_STATUS_LABEL[status] || status || ''
}
function programName(id: string): string {
return programs.value.find((p) => p.id === id)?.name || '专项公益服务'
}
async function load() {
loading.value = true
error.value = false
try {
if (isDetail.value) {
detail.value = await serviceProgramService.getProgramDetail(programId.value)
return
}
const [listResult, myResult]: any = await Promise.all([
serviceProgramService.getPrograms(),
serviceProgramService.getMyApplications().catch(() => []),
])
programs.value = listResult?.items || listResult?.records || (Array.isArray(listResult) ? listResult : [])
myApplications.value = myResult?.items || myResult?.records || (Array.isArray(myResult) ? myResult : [])
} catch {
error.value = true
} finally {
loading.value = false
}
}
function goDetail(p: any) {
if (p?.id) router.push({ path: '/service/program', query: { id: p.id } })
}
async function apply() {
if (!programId.value || applying.value) return
try {
await showConfirmDialog({
title: '申请说明',
message: '请先完成基本资料,符合项目条件后工作人员会与你联系。',
confirmButtonText: '提交申请',
})
} catch {
return
}
applying.value = true
try {
await serviceProgramService.applyToProgram(programId.value)
appStore.toastSuccess('申请已提交')
load()
} catch (err: any) {
appStore.toastError(err?.message || '申请失败,请稍后重试')
} finally {
applying.value = false
}
}
watch(programId, () => {
detail.value = null
load()
})
onMounted(load)
</script>
<style scoped>
.prog {
min-height: 100vh;
background: var(--mini-bg);
padding-bottom: 24px;
}
.prog__hero {
background: var(--mini-brand-gradient);
color: #fff;
padding: 18px 16px 20px;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
}
.prog__eyebrow {
font-size: 11px;
opacity: 0.8;
}
.prog__title {
font-size: 18px;
font-weight: 600;
margin-top: 4px;
}
.prog__desc {
font-size: 12px;
opacity: 0.85;
margin-top: 6px;
line-height: 1.6;
}
.prog__body {
padding: 0 16px;
}
.prog__card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 14px;
margin-top: 12px;
}
.prog__card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.prog__name {
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
}
.prog__desc-text {
font-size: 13px;
color: var(--mini-text-secondary);
line-height: 1.6;
margin-top: 8px;
}
.prog__period {
font-size: 12px;
color: var(--mini-text-muted);
margin-top: 8px;
}
.prog__tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.prog__apply {
margin-top: 16px;
}
.prog__app {
padding: 4px 14px;
}
.prog__app-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 0;
border-bottom: 1px solid var(--mini-border);
}
.prog__app-row:last-child {
border-bottom: none;
}
.prog__app-name {
font-size: 13px;
color: var(--mini-text-strong);
}
.prog__notice {
font-size: 12px;
color: var(--mini-text-secondary);
line-height: 1.6;
background: rgba(47, 179, 109, 0.08);
border-radius: var(--mini-radius);
padding: 12px 14px;
margin-top: 16px;
}
</style>
@@ -0,0 +1,332 @@
<template>
<PageLayout title="善缘服务站">
<div class="station">
<div class="station__hero">
<div class="station__eyebrow">公益服务 · 就在身边</div>
<div class="station__title">善缘服务站</div>
<div class="station__desc">面对面提供可靠、温暖的本地婚恋服务</div>
</div>
<div class="station__body">
<UiErrorState v-if="error" message="暂时无法加载服务站" @retry="load" />
<UiLoading v-else-if="loading" />
<UiEmpty v-else-if="!station" description="所在区域暂无服务站" />
<template v-else>
<div class="station__name-card">
<div class="station__name-icon">站</div>
<div>
<div class="station__name">{{ station.name }}</div>
<div class="station__short">{{ station.shortName || '善缘丹阳公益婚恋服务阵地' }}</div>
</div>
</div>
<div class="station__info">
<div class="station__info-row">
<span class="station__info-label">地址</span>
<span class="station__info-value">{{ station.address || '以预约确认为准' }}</span>
</div>
<div class="station__info-row station__info-row--tap" @click="call">
<span class="station__info-label">联系电话</span>
<span class="station__info-value">{{ station.contactPhoneDisplay || '以预约确认为准' }}</span>
<van-icon name="phone-o" color="#9aa0b5" />
</div>
<div class="station__info-row">
<span class="station__info-label">开放时间</span>
<span class="station__info-value">{{ station.openingHoursText || '以平台通知为准' }}</span>
</div>
</div>
<template v-if="announcements.length">
<UiSectionHeader title="临时公告" />
<div class="station__card">
<div
v-for="a in announcements"
:key="a.id"
class="station__announcement"
@click="toggle(a.id)"
>
<div class="station__announcement-head">
<span class="station__dot"></span>
<span class="station__announcement-title">{{ a.title }}</span>
<van-icon :name="expandedId === a.id ? 'arrow-up' : 'arrow-down'" color="#9aa0b5" />
</div>
<div v-if="expandedId === a.id" class="station__announcement-body">
{{ a.content || a.title }}
</div>
</div>
</div>
</template>
<UiSectionHeader v-if="station.services && station.services.length" title="服务项目" />
<div v-if="station.services && station.services.length" class="station__services">
<UiTag v-for="s in station.services" :key="s" type="primary">{{ s }}</UiTag>
</div>
<div class="station__privacy">
服务站人员仅处理现场预约与指定服务,不会浏览无关用户资料。
</div>
<van-button class="station__map" plain block @click="openMap">
<van-icon name="location-o" /> 地图定位
</van-button>
</template>
</div>
<div v-if="station" class="station__action">
<van-button class="station__action-btn" plain @click="call">联系服务站</van-button>
<van-button class="station__action-btn" type="primary" @click="book">预约到站服务</van-button>
</div>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { serviceStationService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
import UiTag from '@/components/UiTag.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
const router = useRouter()
const appStore = useAppStore()
const loading = ref(true)
const error = ref(false)
const station = ref<any>(null)
const announcements = ref<any[]>([])
const expandedId = ref('')
async function load() {
loading.value = true
error.value = false
try {
const result: any = await serviceStationService.getStation()
if (!result) {
station.value = null
announcements.value = []
return
}
station.value = result
// 公告是增强信息:失败降级为空数组,不阻断站点主体展示
const list: any = await serviceStationService.getVisibleAnnouncements(String(result.id || ''))
announcements.value = Array.isArray(list) ? list : list?.items || list?.records || []
} catch {
error.value = true
} finally {
loading.value = false
}
}
function toggle(id: string) {
expandedId.value = expandedId.value === id ? '' : id
}
/** 电话可能脱敏(含 *)或位数不足,此时不给可拨打的误导。 */
function call() {
const s = station.value
if (!s) return
const phone = String(s.contactPhone || s.contactPhoneDisplay || '')
const digits = phone.replace(/\D/g, '')
if (!phone || phone.includes('*') || digits.length < 5) {
appStore.toast('联系电话请以预约确认为准')
return
}
window.location.href = `tel:${phone}`
}
async function openMap() {
const s = station.value
if (!s) return
const address = String(s.address || '')
if (!address) {
appStore.toast('暂未提供地图位置')
return
}
try {
await navigator.clipboard.writeText(address)
appStore.toast('地址已复制,可在地图中粘贴查看')
} catch {
appStore.toast(address)
}
}
function book() {
const id = station.value?.id
router.push(id ? { path: '/service/appointment', query: { serviceLocationId: String(id) } } : '/service/appointment')
}
onMounted(load)
</script>
<style scoped>
.station {
min-height: 100vh;
background: var(--mini-bg);
padding-bottom: 76px;
}
.station__hero {
background: var(--mini-brand-gradient);
color: #fff;
padding: 18px 16px 20px;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
}
.station__eyebrow {
font-size: 11px;
opacity: 0.8;
}
.station__title {
font-size: 19px;
font-weight: 600;
margin-top: 4px;
}
.station__desc {
font-size: 12px;
opacity: 0.85;
margin-top: 6px;
}
.station__body {
padding: 0 16px;
}
.station__name-card {
display: flex;
align-items: center;
gap: 12px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 14px;
margin-top: -20px;
position: relative;
}
.station__name-icon {
width: 46px;
height: 46px;
border-radius: 12px;
background: linear-gradient(135deg, #2fb36d 0%, #1f8a52 100%);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
flex: none;
}
.station__name {
font-size: 16px;
font-weight: 600;
color: var(--mini-text-strong);
}
.station__short {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 2px;
}
.station__info {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 4px 14px;
margin-top: 12px;
}
.station__info-row {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 0;
border-bottom: 1px solid var(--mini-border);
}
.station__info-row:last-child {
border-bottom: none;
}
.station__info-row--tap {
cursor: pointer;
}
.station__info-label {
font-size: 13px;
color: var(--mini-text-secondary);
flex: none;
width: 64px;
}
.station__info-value {
flex: 1;
font-size: 13px;
color: var(--mini-text-strong);
line-height: 1.5;
}
.station__card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
overflow: hidden;
}
.station__announcement {
padding: 12px 14px;
border-bottom: 1px solid var(--mini-border);
}
.station__announcement:last-child {
border-bottom: none;
}
.station__announcement-head {
display: flex;
align-items: center;
gap: 8px;
}
.station__dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--mini-accent-pink);
flex: none;
}
.station__announcement-title {
flex: 1;
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
}
.station__announcement-body {
font-size: 12px;
color: var(--mini-text-secondary);
line-height: 1.6;
margin-top: 6px;
padding-left: 15px;
white-space: pre-wrap;
}
.station__services {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.station__privacy {
font-size: 12px;
color: var(--mini-text-secondary);
line-height: 1.6;
background: rgba(47, 179, 109, 0.08);
border-radius: var(--mini-radius);
padding: 12px 14px;
margin-top: 16px;
}
.station__map {
margin-top: 12px;
border-radius: var(--mini-radius);
}
.station__action {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 10px;
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
background: var(--mini-surface);
box-shadow: 0 -4px 16px rgba(23, 42, 114, 0.08);
}
.station__action-btn {
flex: 1;
border-radius: var(--mini-radius);
}
</style>
+118
View File
@@ -0,0 +1,118 @@
<template>
<PageLayout title="关于我们">
<div class="about">
<div class="about__hero">
<div class="about__logo">善</div>
<div class="about__name">善缘丹阳</div>
<div class="about__slogan">公益婚恋服务平台</div>
<div class="about__version">版本 v1.1.0 · 演示端</div>
</div>
<UiSectionHeader title="平台简介" />
<div class="about__card">
<p class="about__text">
善缘丹阳公益婚恋服务平台由丹阳市文明办、团委、妇联等多部门联合推动,面向丹阳市成年单身人士提供实名制、公益性的婚恋交友服务。
</p>
<p class="about__text">
平台采用实名认证 + 人工审核机制,确保用户真实身份;所有服务永久免费,不收取任何费用。
</p>
</div>
<UiSectionHeader title="联系我们" />
<van-cell-group inset>
<van-cell title="客服电话" value="0511-80000000" />
<van-cell title="服务时间" value="工作日 9:00-17:00" />
<van-cell title="隐私政策" is-link @click="router.push('/settings/privacy')" />
<van-cell title="帮助中心" is-link @click="router.push('/help')" />
</van-cell-group>
<p class="about__free">本平台为公益服务,所有功能永久免费</p>
<p class="about__footer">© 2026 丹阳市文明办 · 共青团丹阳市委 · 丹阳市妇联</p>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import PageLayout from '@/layouts/PageLayout.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
const router = useRouter()
</script>
<style scoped>
.about {
padding-bottom: 24px;
}
.about__hero {
background: var(--mini-brand-gradient);
color: #fff;
text-align: center;
padding: 28px 16px 24px;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
}
.about__logo {
width: 56px;
height: 56px;
margin: 0 auto 10px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.18);
display: flex;
align-items: center;
justify-content: center;
font-size: 26px;
font-weight: 600;
}
.about__name {
font-size: 18px;
font-weight: 600;
letter-spacing: 1px;
}
.about__slogan {
font-size: 12px;
opacity: 0.85;
margin-top: 4px;
}
.about__version {
font-size: 11px;
opacity: 0.7;
margin-top: 8px;
}
.about :deep(.ui-section-header) {
margin-left: 16px;
margin-right: 16px;
}
.about__card {
margin: 0 16px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 14px 16px;
}
.about__text {
margin: 0;
font-size: 13px;
line-height: 1.8;
color: var(--mini-text);
}
.about__text + .about__text {
margin-top: 10px;
}
.about :deep(.van-cell-group--inset) {
margin: 0 16px;
}
.about__free {
text-align: center;
font-size: 12px;
color: var(--mini-text-secondary);
margin: 20px 0 0;
}
.about__footer {
text-align: center;
font-size: 11px;
color: var(--mini-text-muted);
margin: 8px 16px 0;
line-height: 1.6;
}
</style>
@@ -0,0 +1,134 @@
<template>
<PageLayout title="黑名单管理">
<div class="blacklist">
<UiLoading v-if="loading" />
<UiErrorState v-else-if="error" @retry="load" />
<template v-else>
<template v-if="list.length">
<div v-for="item in list" :key="item.userId" class="blacklist__item">
<UiAvatar :src="item.avatarUrl" :name="item.userName || '用户'" :size="48" />
<div class="blacklist__info">
<div class="blacklist__name">{{ item.userName || '用户' }}</div>
<div class="blacklist__meta">拉黑于 {{ item.blockedAtText || '未知时间' }}</div>
<div v-if="item.reason" class="blacklist__reason">原因:{{ item.reason }}</div>
</div>
<van-button
size="small"
plain
type="danger"
:loading="actionLoadingUserId === item.userId"
@click="onUnblock(item)"
>
解除拉黑
</van-button>
</div>
</template>
<UiEmpty v-else description="黑名单是空的,你还没有拉黑过任何人" />
<p class="blacklist__hint">拉黑后对方将无法与你互动,解除拉黑后对方可重新与你互动。</p>
</template>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { showConfirmDialog } from 'vant'
import { matchService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
import UiAvatar from '@/components/UiAvatar.vue'
const appStore = useAppStore()
const loading = ref(true)
const error = ref(false)
const actionLoadingUserId = ref('')
const list = ref<any[]>([])
function pick(result: any): any[] {
if (Array.isArray(result)) return result
return result?.items || result?.records || []
}
async function load() {
loading.value = true
error.value = false
try {
const data = await matchService.getBlockedList({ pageSize: 20 })
list.value = pick(data)
} catch {
error.value = true
} finally {
loading.value = false
}
}
async function onUnblock(item: any) {
if (!item?.userId || actionLoadingUserId.value) return
try {
await showConfirmDialog({
title: '解除拉黑',
message: `解除后「${item.userName || '对方'}」可重新与你互动。是否继续?`,
})
} catch {
return
}
actionLoadingUserId.value = item.userId
try {
await matchService.unblock(item.userId)
list.value = list.value.filter((u) => u.userId !== item.userId)
appStore.toastSuccess('已解除拉黑')
} catch (err: any) {
appStore.toastError(err?.message || '操作失败')
} finally {
actionLoadingUserId.value = ''
}
}
onMounted(load)
</script>
<style scoped>
.blacklist {
padding: 12px 16px 24px;
}
.blacklist__item {
display: flex;
align-items: center;
gap: 12px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 12px;
margin-bottom: 10px;
}
.blacklist__info {
flex: 1;
min-width: 0;
}
.blacklist__name {
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
}
.blacklist__meta {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 2px;
}
.blacklist__reason {
font-size: 12px;
color: var(--mini-text-muted);
margin-top: 2px;
}
.blacklist__hint {
margin: 8px 0 0;
font-size: 12px;
color: var(--mini-text-muted);
line-height: 1.6;
}
</style>
@@ -0,0 +1,120 @@
<template>
<PageLayout title="帮助中心">
<div class="help">
<van-tabs v-model:active="activeCategory" sticky>
<van-tab v-for="cat in FAQ_DATA" :key="cat.category" :name="cat.category" :title="cat.category" />
</van-tabs>
<div class="help__body">
<van-collapse v-model="expanded" accordion>
<van-collapse-item
v-for="(faq, idx) in currentItems"
:key="faq.q"
:name="`${activeCategory}-${idx}`"
:title="faq.q"
>
{{ faq.a }}
</van-collapse-item>
</van-collapse>
</div>
<div class="help__footer">
<van-cell-group inset>
<van-cell title="客服电话" value="0511-80000000" />
<van-cell title="服务时间" value="工作日 9:00-17:00" />
</van-cell-group>
<p class="help__free">本平台为公益服务,所有功能及咨询永久免费</p>
</div>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import PageLayout from '@/layouts/PageLayout.vue'
interface FaqItem {
q: string
a: string
}
interface FaqCategory {
category: string
items: FaqItem[]
}
const FAQ_DATA: FaqCategory[] = [
{
category: '账号与登录',
items: [
{ q: '如何注册账号?', a: '点击「微信一键登录」,系统将自动创建账号并完成实名认证。' },
{ q: '忘记登录密码怎么办?', a: '本平台使用微信授权登录,无需设置密码。如遇到问题请重新登录。' },
{ q: '如何绑定手机号?', a: '登录后在「我的」页面完成实名认证即可绑定手机号。' },
],
},
{
category: '推荐与匹配',
items: [
{ q: '推荐列表是怎么生成的?', a: '系统根据您的择偶条件和已有用户资料,按照匹配算法每日生成推荐列表。' },
{ q: '为什么收不到推荐?', a: '请检查您的资料完整度是否高于60%,以及择偶条件是否合理(不宜过窄)。' },
{ q: '如何暂停推荐?', a: '在「设置」中切换关系状态为「已暂停寻找」即可停止推荐。' },
{ q: '可以查看谁喜欢过我吗?', a: '可以,在首页右上角点击「喜欢我的人」即可查看。' },
],
},
{
category: '活动参与',
items: [
{ q: '如何报名参加活动?', a: '在活动详情页点击「立即报名」,填写紧急联系人信息后提交。' },
{ q: '活动报名后能否取消?', a: '可以,在「我的活动」中找到对应活动并取消报名。' },
{ q: '活动是否需要审核?', a: '部分活动需要公益组织审核后参与,审核结果将在活动开始前通知您。' },
],
},
{
category: '隐私与安全',
items: [
{ q: '平台如何保护我的隐私?', a: '所有敏感信息(身份证、手机号)均加密存储,仅授权工作人员在案件上下文中可有限访问。' },
{ q: '如何设置隐私权限?', a: '在「隐私设置」中可以控制是否在推荐中可见、是否允许活动邀请等。' },
{ q: '如何拉黑某人?', a: '在推荐或缘分列表中点击该用户卡片,选择「拉黑」即可。拉黑后对方将不再看到您。' },
],
},
{
category: '公益红娘',
items: [
{ q: '什么是公益红娘服务?', a: '公益红娘是由丹阳市公益组织指派的专业人士,为您提供一对一的婚恋咨询服务。' },
{ q: '如何申请红娘服务?', a: '在首页点击「申请公益红娘」,填写服务需求后等待工作人员联系。' },
{ q: '红娘服务收费吗?', a: '不收费。本平台是公益性质的免费婚恋服务平台。' },
],
},
]
const activeCategory = ref(FAQ_DATA[0].category)
const expanded = ref<string>('')
const currentItems = computed<FaqItem[]>(
() => FAQ_DATA.find((c) => c.category === activeCategory.value)?.items || [],
)
</script>
<style scoped>
.help {
padding-bottom: 24px;
}
.help__body {
padding: 12px 16px 0;
}
.help__body :deep(.van-collapse) {
border-radius: var(--mini-radius);
overflow: hidden;
}
.help__footer {
padding: 16px 0 0;
}
.help__footer :deep(.van-cell-group--inset) {
margin: 0 16px;
}
.help__free {
text-align: center;
font-size: 11px;
color: var(--mini-text-muted);
margin: 16px 0 0;
}
</style>
@@ -0,0 +1,203 @@
<template>
<PageLayout title="通知">
<div class="notif">
<div class="notif__bar">
<span class="notif__count">{{ unreadCount > 0 ? `${unreadCount} 条未读` : '暂无未读' }}</span>
<van-button
v-if="unreadCount > 0"
size="small"
plain
type="primary"
:loading="markingAll"
@click="onMarkAll"
>
全部已读
</van-button>
</div>
<UiLoading v-if="loading" />
<UiErrorState v-else-if="error" @retry="load" />
<template v-else>
<template v-if="list.length">
<div
v-for="item in list"
:key="item.id"
class="notif__item"
:class="{ 'notif__item--unread': !isRead(item) }"
@click="onMarkRead(item)"
>
<span v-if="!isRead(item)" class="notif__dot" />
<div class="notif__main">
<div class="notif__head">
<span class="notif__title">{{ item.title }}</span>
<span class="notif__time">{{ formatDateTime(item.createdAt) }}</span>
</div>
<p class="notif__content">{{ item.content }}</p>
<UiTag v-if="typeText(item.type)" type="info">{{ typeText(item.type) }}</UiTag>
</div>
</div>
</template>
<UiEmpty v-else description="暂无通知" />
</template>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { notificationService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import { formatDateTime } from '@/utils/datetime'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
import UiTag from '@/components/UiTag.vue'
const appStore = useAppStore()
const TYPE_TEXT: Record<string, string> = {
system: '系统通知',
activity: '活动提醒',
match: '匹配消息',
appointment: '预约提醒',
counseling: '咨询提醒',
review: '审核通知',
}
const loading = ref(true)
const error = ref(false)
const markingAll = ref(false)
const list = ref<any[]>([])
const unreadCount = ref(0)
function pick(result: any): any[] {
if (Array.isArray(result)) return result
return result?.items || result?.records || result?.notifications || []
}
function isRead(item: any): boolean {
return item?.read === true || item?.readStatus === 'read'
}
function typeText(type?: string): string {
return type ? TYPE_TEXT[type] || '' : ''
}
const PAGE_SIZE = 20
async function load() {
loading.value = true
error.value = false
try {
const data = await notificationService.getNotifications({ limit: PAGE_SIZE, offset: 0 })
list.value = pick(data)
unreadCount.value = list.value.filter((n) => !isRead(n)).length
try {
const countData: any = await notificationService.getNotificationsUnreadCount()
const count = countData?.count ?? countData?.unreadCount
if (typeof count === 'number') unreadCount.value = count
} catch {
/* 以列表计算值兜底 */
}
} catch {
error.value = true
} finally {
loading.value = false
}
}
async function onMarkRead(item: any) {
if (!item?.id || isRead(item)) return
try {
await notificationService.markNotificationRead(item.id)
item.readStatus = 'read'
item.read = true
unreadCount.value = Math.max(0, unreadCount.value - 1)
} catch (err: any) {
appStore.toastError(err?.message || '操作失败')
}
}
async function onMarkAll() {
if (markingAll.value) return
markingAll.value = true
try {
await notificationService.markAllNotificationsRead()
list.value.forEach((n) => {
n.readStatus = 'read'
n.read = true
})
unreadCount.value = 0
appStore.toastSuccess('已全部标记为已读')
} catch (err: any) {
appStore.toastError(err?.message || '操作失败')
} finally {
markingAll.value = false
}
}
onMounted(load)
</script>
<style scoped>
.notif {
padding: 12px 16px 24px;
}
.notif__bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.notif__count {
font-size: 13px;
color: var(--mini-text-secondary);
}
.notif__item {
display: flex;
gap: 8px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 12px;
margin-bottom: 10px;
}
.notif__item--unread {
border-left: 3px solid var(--mini-accent-pink);
}
.notif__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--mini-accent-pink);
margin-top: 6px;
flex: none;
}
.notif__main {
flex: 1;
min-width: 0;
}
.notif__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.notif__title {
font-size: 14px;
font-weight: 600;
color: var(--mini-text-strong);
}
.notif__time {
font-size: 11px;
color: var(--mini-text-muted);
flex: none;
}
.notif__content {
margin: 6px 0 6px;
font-size: 13px;
line-height: 1.6;
color: var(--mini-text);
}
</style>
@@ -0,0 +1,111 @@
<template>
<PageLayout title="隐私设置">
<div class="privacy">
<UiLoading v-if="loading" />
<UiErrorState v-else-if="error" @retry="load" />
<template v-else>
<van-cell-group inset>
<van-cell title="在推荐中可见" label="开启后您的资料将进入系统推荐池" center>
<template #right-icon>
<van-switch v-model="form.visibleInRecommendations" />
</template>
</van-cell>
<van-cell title="允许活动邀请" label="允许工作人员邀请您参加线下活动" center>
<template #right-icon>
<van-switch v-model="form.allowActivityInvitation" />
</template>
</van-cell>
<van-cell title="显示最近上线" label="对方可以看到您最近一次上线时间" center>
<template #right-icon>
<van-switch v-model="form.showLastOnline" />
</template>
</van-cell>
</van-cell-group>
<p class="privacy__hint">关闭「在推荐中可见」后,您的资料将不再进入系统推荐池。</p>
<div class="privacy__actions">
<van-button type="primary" block round :loading="saving" @click="save">保存设置</van-button>
</div>
</template>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { profileService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const appStore = useAppStore()
const loading = ref(true)
const error = ref(false)
const saving = ref(false)
const form = reactive({
visibleInRecommendations: true,
allowActivityInvitation: true,
showLastOnline: false,
})
async function load() {
loading.value = true
error.value = false
try {
const data: any = await profileService.getPrivacySettings()
form.visibleInRecommendations = data?.visibleInRecommendations !== false
form.allowActivityInvitation = data?.allowActivityInvitation !== false
form.showLastOnline = data?.showLastOnline === true
} catch {
error.value = true
} finally {
loading.value = false
}
}
async function save() {
if (saving.value) return
saving.value = true
try {
const data: any = await profileService.updatePrivacySettings({
visibleInRecommendations: form.visibleInRecommendations,
allowActivityInvitation: form.allowActivityInvitation,
showLastOnline: form.showLastOnline,
})
if (data && typeof data === 'object') {
form.visibleInRecommendations = data.visibleInRecommendations !== false
form.allowActivityInvitation = data.allowActivityInvitation !== false
form.showLastOnline = data.showLastOnline === true
}
appStore.toastSuccess('保存成功')
} catch (err: any) {
appStore.toastError(err?.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.privacy {
padding: 12px 0 24px;
}
.privacy :deep(.van-cell-group--inset) {
margin: 0 16px;
}
.privacy__hint {
margin: 12px 16px 0;
font-size: 12px;
color: var(--mini-text-muted);
line-height: 1.6;
}
.privacy__actions {
padding: 20px 16px 0;
}
</style>
@@ -0,0 +1,237 @@
<template>
<PageLayout title="婚恋状态">
<div class="rel">
<UiLoading v-if="loading" />
<UiErrorState v-else-if="error" @retry="load" />
<template v-else>
<!-- 当前状态 -->
<div class="rel__card">
<div class="rel__card-head">
<span class="rel__card-label">当前状态</span>
<UiTag :type="meta.kind">{{ meta.label }}</UiTag>
</div>
<p class="rel__card-desc">{{ meta.desc }}</p>
</div>
<!-- 正在了解的对象 -->
<template v-if="matchingWith">
<UiSectionHeader title="正在了解的对象" />
<div class="rel__partner">
<UiAvatar :src="matchingWith.avatarUrl" :name="matchingWith.displayName || '对方'" :size="48" />
<div class="rel__partner-main">
<div class="rel__partner-name">{{ matchingWith.displayName || '对方' }}</div>
<div class="rel__partner-hint">你们已互相喜欢,正在了解中</div>
</div>
</div>
</template>
<!-- 操作区 -->
<UiSectionHeader title="状态管理" />
<div class="rel__actions">
<template v-if="status === 'searching'">
<van-button type="primary" block round :loading="actionLoading" @click="onGettingToKnow">
设置为「正在了解」
</van-button>
<van-button plain block round :loading="actionLoading" @click="onPause">暂停寻找</van-button>
</template>
<template v-else-if="status === 'paused'">
<van-button type="primary" block round :loading="actionLoading" @click="onResume">恢复寻找</van-button>
</template>
<template v-else-if="status === 'getting_to_know'">
<van-button plain block round :loading="actionLoading" @click="onResume">恢复寻找</van-button>
</template>
<p v-else class="rel__tip">当前状态无需调整</p>
</div>
<p class="rel__free">本平台为公益服务,所有功能永久免费</p>
</template>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { showConfirmDialog } from 'vant'
import { profileService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiErrorState from '@/components/UiErrorState.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
import UiAvatar from '@/components/UiAvatar.vue'
import UiTag from '@/components/UiTag.vue'
const appStore = useAppStore()
const STATUS_META: Record<string, { label: string; desc: string; kind: 'primary' | 'success' | 'warning' | 'danger' | 'info' }> = {
searching: {
label: '正在寻找',
desc: '你正在被推荐给合适的对象,也会收到新的喜欢提示。',
kind: 'success',
},
paused: {
label: '已暂停寻找',
desc: '你暂时不会被推荐给他人,也无法收到新的喜欢提示。已有聊天仍可继续使用。',
kind: 'warning',
},
getting_to_know: {
label: '正在了解',
desc: '你正在与某位对象互相了解中,平台会适当减少新推荐,让你专注这段关系。',
kind: 'info',
},
matched_successfully: {
label: '已成功牵手',
desc: '恭喜!你与对方已成功牵手,平台将不再为你推荐其他人。如有需要可联系公益红娘。',
kind: 'success',
},
service_exited: {
label: '已退出婚恋服务',
desc: '你已退出婚恋服务,不再出现在推荐和搜索结果中。历史匹配与聊天记录已保留。',
kind: 'info',
},
}
const loading = ref(true)
const error = ref(false)
const actionLoading = ref(false)
const status = ref('searching')
const recommendationEnabled = ref(true)
const matchingWith = ref<any>(null)
const meta = computed(() => STATUS_META[status.value] || STATUS_META.searching)
async function load() {
loading.value = true
error.value = false
try {
const data: any = await profileService.getRelationshipStatus()
status.value = data?.status || 'searching'
recommendationEnabled.value = data?.recommendationEnabled !== false
matchingWith.value = data?.matchingWith || null
} catch {
error.value = true
} finally {
loading.value = false
}
}
async function run(fn: () => Promise<any>) {
if (actionLoading.value) return
actionLoading.value = true
try {
await fn()
await load()
} catch (err: any) {
appStore.toastError(err?.message || '操作失败')
} finally {
actionLoading.value = false
}
}
async function onPause() {
try {
await showConfirmDialog({
title: '暂停寻找',
message: '暂停后你将不会被推荐给他人,已有聊天仍可继续使用。是否继续?',
})
} catch {
return
}
await run(async () => {
await profileService.pauseSearching()
appStore.toastSuccess('已暂停寻找')
})
}
async function onResume() {
await run(async () => {
await profileService.resumeSearching()
appStore.toastSuccess('已恢复寻找')
})
}
async function onGettingToKnow() {
try {
await showConfirmDialog({
title: '设置为「正在了解」',
message: '设置后平台会适当减少新推荐,让你专注这段关系。是否继续?',
})
} catch {
return
}
await run(async () => {
await profileService.markGettingToKnow()
appStore.toastSuccess('已设置为正在了解')
})
}
onMounted(load)
</script>
<style scoped>
.rel {
padding: 12px 16px 24px;
}
.rel__card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 16px;
}
.rel__card-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.rel__card-label {
font-size: 13px;
color: var(--mini-text-secondary);
}
.rel__card-desc {
margin: 10px 0 0;
font-size: 13px;
line-height: 1.7;
color: var(--mini-text);
}
.rel__partner {
display: flex;
align-items: center;
gap: 12px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 12px;
}
.rel__partner-main {
flex: 1;
min-width: 0;
}
.rel__partner-name {
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
}
.rel__partner-hint {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 2px;
}
.rel__actions {
display: flex;
flex-direction: column;
gap: 10px;
}
.rel__tip {
text-align: center;
font-size: 13px;
color: var(--mini-text-muted);
margin: 0;
}
.rel__free {
text-align: center;
font-size: 11px;
color: var(--mini-text-muted);
margin: 20px 0 0;
}
</style>
@@ -0,0 +1,79 @@
<template>
<PageLayout title="设置">
<div class="settings">
<UiSectionHeader title="通知与消息" />
<van-cell-group inset>
<van-cell
title="通知中心"
value="系统通知、活动提醒"
is-link
@click="router.push('/notifications')"
/>
</van-cell-group>
<UiSectionHeader title="关系状态" />
<van-cell-group inset>
<van-cell
title="关系状态"
value="查看并切换当前婚恋状态"
is-link
@click="router.push('/settings/relationship')"
/>
</van-cell-group>
<UiSectionHeader title="安全与隐私" />
<van-cell-group inset>
<van-cell
title="隐私设置"
value="推荐可见、活动邀请、最近上线"
is-link
@click="router.push('/settings/privacy')"
/>
<van-cell title="黑名单管理" is-link @click="router.push('/settings/blacklist')" />
</van-cell-group>
<UiSectionHeader title="账号" />
<van-cell-group inset>
<van-cell title="账号状态" is-link @click="router.push('/account/status')" />
<van-cell title="帮助中心" is-link @click="router.push('/help')" />
<van-cell title="关于善缘丹阳" is-link @click="router.push('/about')" />
</van-cell-group>
<div class="settings__logout" @click="router.push('/account/logout')">退出登录</div>
<p class="settings__free">本平台为公益服务,所有功能永久免费</p>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import PageLayout from '@/layouts/PageLayout.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
const router = useRouter()
</script>
<style scoped>
.settings {
padding: 8px 0 24px;
}
.settings :deep(.van-cell-group--inset) {
margin: 0 16px 8px;
}
.settings__logout {
margin: 16px;
text-align: center;
color: var(--mini-danger);
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 13px;
font-size: 14px;
box-shadow: var(--mini-shadow);
}
.settings__free {
text-align: center;
font-size: 11px;
color: var(--mini-text-muted);
margin: 12px 0 0;
}
</style>
+50
View File
@@ -0,0 +1,50 @@
import type { Router } from 'vue-router'
import { APPROVED_ROUTES, PUBLIC_ROUTES } from './routes'
import { useAuthStore } from '@/stores/auth'
import { useUserStore } from '@/stores/user'
import { useOperatorStore } from '@/stores/operator'
/**
* 路由守卫(同构 miniapp/stores/index.js 的 canAccess):
* - /operator/** 用独立会话(仅校验 h5.operatorToken,不套用户审核守卫);
* - 其它路由需登录;账号异常优先拦截;'approved' 路由需资料审核通过。
* 前端不缓存状态为真相(AGENTS 第 9 条),判定依据 `GET /me/status`。
*/
export function setupGuard(router: Router): void {
router.beforeEach(async (to) => {
if (to.path.startsWith('/operator')) {
const operatorStore = useOperatorStore()
if (to.path !== '/operator/login' && !operatorStore.isLoggedIn) {
return { path: '/operator/login' }
}
return true
}
if (PUBLIC_ROUTES.has(to.path)) return true
const authStore = useAuthStore()
if (!authStore.isLoggedIn) {
return { path: '/login', query: to.fullPath !== '/home' ? { redirect: to.fullPath } : {} }
}
const userStore = useUserStore()
if (!userStore.loaded) {
try {
await userStore.fetchStatus()
} catch {
return { path: '/login' }
}
}
if (userStore.accountStatus !== 'normal' && to.path !== '/account/status' && to.path !== '/account/status/detail') {
return { path: '/account/status' }
}
if (APPROVED_ROUTES.has(to.path) && !userStore.isApproved) {
const status = userStore.reviewStatus
return { path: status === 'need_edit' || status === 'rejected' ? '/review/rejected' : '/review/pending' }
}
return true
})
}
+15
View File
@@ -0,0 +1,15 @@
import { createRouter, createWebHistory } from 'vue-router'
import routes from './routes'
import { setupGuard } from './guard'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
scrollBehavior() {
return { top: 0 }
},
})
setupGuard(router)
export default router
+119
View File
@@ -0,0 +1,119 @@
/**
* H5 演示端路由映射(镜像 miniapp 66 个注册页面,id-only 传参)。
* 受保护路由的审核态要求见 guard.ts 的 ROUTE_ACCESS_REQUIREMENTS。
*/
import type { RouteRecordRaw } from 'vue-router'
import TabLayout from '@/layouts/TabLayout.vue'
/** 需要「资料审核通过」才能访问的路由(对齐 miniapp/stores/index.js) */
export const APPROVED_ROUTES = new Set([
'/discover',
'/fate-center',
'/profile/detail',
'/activity/register',
'/matchmaker/apply',
])
/** 无需登录即可访问 */
export const PUBLIC_ROUTES = new Set(['/login', '/operator/login', '/about', '/help'])
export const routes: RouteRecordRaw[] = [
// ==================== 认证 ====================
{ path: '/login', name: 'Login', component: () => import('@/pages/auth/LoginPage.vue') },
{ path: '/bind-phone', name: 'BindPhone', component: () => import('@/pages/auth/BindPhonePage.vue') },
// ==================== Tab ====================
{
path: '/',
component: TabLayout,
children: [
{ path: '', redirect: '/home' },
{ path: 'home', name: 'HomePage', component: () => import('@/pages/home/HomePage.vue') },
{ path: 'discover', name: 'DiscoverPage', component: () => import('@/pages/discover/DiscoverPage.vue') },
{ path: 'activity', name: 'ActivityPage', component: () => import('@/pages/activity/ActivityPage.vue') },
{ path: 'mine', name: 'MinePage', component: () => import('@/pages/mine/MinePage.vue') },
],
},
// ==================== 启动与身份 ====================
{ path: '/verification', name: 'Verification', component: () => import('@/pages/auth/VerificationPage.vue') },
{ path: '/matchmaker-authorization', name: 'MatchmakerAuthorization', component: () => import('@/pages/local/MatchmakerAuthorizationPage.vue') },
{ path: '/local-eligibility', name: 'LocalEligibility', component: () => import('@/pages/local/LocalEligibilityPage.vue') },
{ path: '/local-eligibility/supplement', name: 'LocalEligibilitySupplement', component: () => import('@/pages/local/LocalEligibilitySupplementPage.vue') },
{ path: '/contact-consent', name: 'ContactConsent', component: () => import('@/pages/auth/ContactConsentPage.vue') },
// ==================== 新手资料(固定 5 步顺序) ====================
{ path: '/profile/basic', name: 'ProfileBasic', component: () => import('@/pages/onboarding/ProfileBasicPage.vue') },
{ path: '/profile/lifestyle', name: 'ProfileLifestyle', component: () => import('@/pages/onboarding/ProfileLifestylePage.vue') },
{ path: '/profile/photos', name: 'ProfilePhotos', component: () => import('@/pages/onboarding/ProfilePhotosPage.vue') },
{ path: '/profile/intent', name: 'ProfileIntent', component: () => import('@/pages/onboarding/ProfileIntentPage.vue') },
{ path: '/profile/preference', name: 'ProfilePreference', component: () => import('@/pages/onboarding/ProfilePreferencePage.vue') },
{ path: '/profile/confirm', name: 'ProfileConfirm', component: () => import('@/pages/onboarding/ProfileConfirmPage.vue') },
{ path: '/profile/intro', name: 'ProfileIntro', component: () => import('@/pages/onboarding/ProfileIntroPage.vue') },
{ path: '/profile/interests', name: 'ProfileInterests', component: () => import('@/pages/onboarding/ProfileInterestsPage.vue') },
// ==================== 审核与账号 ====================
{ path: '/review/pending', name: 'ReviewPending', component: () => import('@/pages/review/ReviewPendingPage.vue') },
{ path: '/review/rejected', name: 'ReviewRejected', component: () => import('@/pages/review/ReviewRejectedPage.vue') },
{ path: '/account/status', name: 'AccountStatus', component: () => import('@/pages/account/AccountStatusPage.vue') },
{ path: '/account/status/detail', name: 'AccountStatusDetail', component: () => import('@/pages/account/AccountStatusDetailPage.vue') },
{ path: '/account/logout', name: 'AccountLogout', component: () => import('@/pages/account/AccountLogoutPage.vue') },
{ path: '/report', name: 'Report', component: () => import('@/pages/safety/ReportPage.vue') },
{ path: '/my/reports', name: 'MyReports', component: () => import('@/pages/safety/MyReportsPage.vue') },
{ path: '/my/reports/detail', name: 'ReportDetail', component: () => import('@/pages/safety/ReportDetailPage.vue') },
{ path: '/appeal', name: 'Appeal', component: () => import('@/pages/safety/AppealPage.vue') },
// ==================== 主业务 ====================
{ path: '/profile/detail', name: 'ProfileDetail', component: () => import('@/pages/profile/ProfileDetailPage.vue') },
{ path: '/fate-center', name: 'FateCenter', component: () => import('@/pages/match/FateCenterPage.vue') },
{ path: '/matchmaker', name: 'Matchmaker', component: () => import('@/pages/matchmaker/MatchmakerPage.vue') },
{ path: '/matchmaker/apply', name: 'MatchmakerApply', component: () => import('@/pages/matchmaker/MatchmakerApplyPage.vue') },
{ path: '/matchmaker/progress', name: 'MatchmakerProgress', component: () => import('@/pages/matchmaker/MatchmakerProgressPage.vue') },
{ path: '/matchmaker/recommendations', name: 'MatchmakerRecommendations', component: () => import('@/pages/matchmaker/MatchmakerRecommendationsPage.vue') },
{ path: '/referral/confirmation', name: 'ReferralConfirmation', component: () => import('@/pages/matchmaker/ReferralConfirmationPage.vue') },
// ==================== 活动 ====================
{ path: '/activity/detail', name: 'ActivityDetail', component: () => import('@/pages/activity/ActivityDetailPage.vue') },
{ path: '/activity/register', name: 'ActivityRegister', component: () => import('@/pages/activity/ActivityRegisterPage.vue') },
{ path: '/my/activities', name: 'MyActivities', component: () => import('@/pages/activity/MyActivitiesPage.vue') },
{ path: '/activity/checkin', name: 'ActivityCheckin', component: () => import('@/pages/activity/ActivityCheckinPage.vue') },
{ path: '/activity/feedback', name: 'ActivityFeedback', component: () => import('@/pages/activity/ActivityFeedbackPage.vue') },
{ path: '/activity/matches', name: 'ActivityMatches', component: () => import('@/pages/activity/ActivityMatchesPage.vue') },
{ path: '/activity/mutual/result', name: 'ActivityMutualResult', component: () => import('@/pages/activity/ActivityMutualResultPage.vue') },
{ path: '/activity/mutual/waiting', name: 'ActivityMutualWaiting', component: () => import('@/pages/activity/ActivityMutualWaitingPage.vue') },
// ==================== 公益服务 ====================
{ path: '/service-center', name: 'ServiceCenter', component: () => import('@/pages/service/ServiceCenterPage.vue') },
{ path: '/service-station', name: 'ServiceStation', component: () => import('@/pages/service/ServiceStationPage.vue') },
{ path: '/service/appointment', name: 'ServiceAppointment', component: () => import('@/pages/service/ServiceAppointmentPage.vue') },
{ path: '/my/appointments', name: 'MyAppointments', component: () => import('@/pages/service/MyAppointmentsPage.vue') },
{ path: '/service/program', name: 'ServiceProgram', component: () => import('@/pages/service/ServiceProgramPage.vue') },
{ path: '/my/service-history', name: 'MyServiceHistory', component: () => import('@/pages/service/MyServiceHistoryPage.vue') },
{ path: '/content', name: 'Content', component: () => import('@/pages/content/ContentPage.vue') },
{ path: '/content/detail', name: 'ContentDetail', component: () => import('@/pages/content/ContentDetailPage.vue') },
{ path: '/anti-fraud', name: 'AntiFraud', component: () => import('@/pages/content/AntiFraudPage.vue') },
{ path: '/counseling', name: 'Counseling', component: () => import('@/pages/counseling/CounselingPage.vue') },
{ path: '/counseling/request', name: 'CounselingRequest', component: () => import('@/pages/counseling/CounselingRequestPage.vue') },
{ path: '/my/counseling', name: 'MyCounseling', component: () => import('@/pages/counseling/MyCounselingPage.vue') },
{ path: '/premarital', name: 'Premarital', component: () => import('@/pages/premarital/PremaritalPage.vue') },
{ path: '/premarital/request', name: 'PremaritalRequest', component: () => import('@/pages/premarital/PremaritalRequestPage.vue') },
// ==================== 设置与辅助 ====================
{ path: '/settings', name: 'Settings', component: () => import('@/pages/settings/SettingsPage.vue') },
{ path: '/settings/privacy', name: 'PrivacySettings', component: () => import('@/pages/settings/PrivacySettingsPage.vue') },
{ path: '/settings/relationship', name: 'RelationshipStatus', component: () => import('@/pages/settings/RelationshipStatusPage.vue') },
{ path: '/settings/blacklist', name: 'Blacklist', component: () => import('@/pages/settings/BlacklistPage.vue') },
{ path: '/notifications', name: 'Notifications', component: () => import('@/pages/settings/NotificationsPage.vue') },
{ path: '/help', name: 'HelpCenter', component: () => import('@/pages/settings/HelpCenterPage.vue') },
{ path: '/about', name: 'About', component: () => import('@/pages/settings/AboutPage.vue') },
// ==================== 红娘端(独立会话) ====================
{ path: '/operator/login', name: 'OperatorLogin', component: () => import('@/pages/operator/OperatorLoginPage.vue') },
{ path: '/operator/workbench', name: 'OperatorWorkbench', component: () => import('@/pages/operator/OperatorWorkbenchPage.vue') },
{ path: '/operator/services', name: 'OperatorServiceDetail', component: () => import('@/pages/operator/OperatorServiceDetailPage.vue') },
// ==================== 兜底 ====================
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('@/pages/NotFoundPage.vue') },
]
export default routes
+52
View File
@@ -0,0 +1,52 @@
import { defineStore } from 'pinia'
import { activityService, activityMutualService } from '@/api/services'
export const useActivityStore = defineStore('activity', () => {
function list(params: Record<string, any> = {}) {
return activityService.getActivities(params)
}
function detail(id: string) {
return activityService.getActivityDetail(id)
}
function register(id: string, remark?: string) {
return activityService.register(id, remark ? { remark } : {})
}
function cancelRegister(id: string) {
return activityService.cancelRegistration(id)
}
function myActivities(params: Record<string, any> = {}) {
return activityService.getMyActivities(params)
}
function submitFeedback(id: string, payload: Record<string, any>) {
return activityService.submitFeedback(id, payload)
}
function checkinToken(id: string) {
return activityService.generateCheckinToken(id)
}
function mutualResults(id: string) {
return activityMutualService.getResults(id)
}
function confirmMutual(id: string, partnerId: string) {
return activityMutualService.confirmConnection(id, partnerId)
}
function declineMutual(id: string, partnerId: string) {
return activityMutualService.declineConnection(id, partnerId)
}
function myMutualStatus(id: string, partnerId: string) {
return activityMutualService.getMyConsentStatus(id, partnerId)
}
return {
list,
detail,
register,
cancelRegister,
myActivities,
submitFeedback,
checkinToken,
mutualResults,
confirmMutual,
declineMutual,
myMutualStatus,
}
})
+61
View File
@@ -0,0 +1,61 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { showToast, showFailToast, showSuccessToast } from 'vant'
import { appConfigService, capabilityService } from '@/api/services'
export interface Capabilities {
canReceiveRecommendations: boolean
canSearchUsers: boolean
canLike: boolean
canChat: boolean
canApplyMatchmakingActivity: boolean
canApplyCounseling: boolean
canApplyMatchmaker: boolean
}
const emptyCapabilities: Capabilities = {
canReceiveRecommendations: false,
canSearchUsers: false,
canLike: false,
canChat: false,
canApplyMatchmakingActivity: false,
canApplyCounseling: false,
canApplyMatchmaker: false,
}
export const useAppStore = defineStore('app', () => {
const appConfig = ref<Record<string, any>>({})
const capabilities = ref<Capabilities>({ ...emptyCapabilities })
const maintenance = ref(false)
async function init(): Promise<void> {
appConfig.value = await appConfigService.fetchAppConfig(appConfig.value)
}
async function fetchCapabilities(): Promise<Capabilities> {
try {
const data = await capabilityService.getCapabilities()
capabilities.value = { ...emptyCapabilities, ...(data || {}) }
} catch {
capabilities.value = { ...emptyCapabilities }
}
return capabilities.value
}
function setMaintenance(body: any): void {
maintenance.value = true
showFailToast((body && body.message) || '平台正在维护,请稍后再试')
}
function toast(message: string): void {
showToast(message)
}
function toastError(message: string): void {
showFailToast(message)
}
function toastSuccess(message: string): void {
showSuccessToast(message)
}
return { appConfig, capabilities, maintenance, init, fetchCapabilities, setMaintenance, toast, toastError, toastSuccess }
})
+55
View File
@@ -0,0 +1,55 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { authService } from '@/api/services'
import { getItem, setItem, removeItem, StorageKeys } from '@/utils/storage'
export const useAuthStore = defineStore('auth', () => {
const accessToken = ref<string | null>(getItem(StorageKeys.accessToken))
const userId = ref<string | null>(getItem(StorageKeys.userId))
const isNewUser = ref(false)
const isLoggedIn = computed(() => !!accessToken.value)
function applyTokens(data: { accessToken?: string; refreshToken?: string; userId?: string; isNewUser?: boolean }) {
if (data.accessToken) {
accessToken.value = data.accessToken
setItem(StorageKeys.accessToken, data.accessToken)
}
if (data.refreshToken) setItem(StorageKeys.refreshToken, data.refreshToken)
if (data.userId) {
userId.value = data.userId
setItem(StorageKeys.userId, data.userId)
}
isNewUser.value = !!data.isNewUser
}
/**
* 演示登录:以 `demo_openid_xx` 作为 code 直连 Mock Provider,
* 无需微信环境即可切换到预置演示用户(见实施计划 D2 / 登录策略)。
*/
async function loginByDemoCode(code: string) {
const data = await authService.wechatLogin({ code })
applyTokens(data)
return data
}
function clearSession() {
accessToken.value = null
userId.value = null
removeItem(StorageKeys.accessToken)
removeItem(StorageKeys.refreshToken)
removeItem(StorageKeys.userId)
removeItem(StorageKeys.userStatusSnapshot)
}
async function logout() {
try {
await authService.logout()
} catch {
/* best-effort */
}
clearSession()
}
return { accessToken, userId, isNewUser, isLoggedIn, loginByDemoCode, applyTokens, clearSession, logout }
})
+35
View File
@@ -0,0 +1,35 @@
import { defineStore } from 'pinia'
import { matchService, recommendationService } from '@/api/services'
export const useMatchStore = defineStore('match', () => {
async function like(userId: string) {
return matchService.like(userId)
}
async function pass(userId: string) {
return matchService.pass(userId)
}
/** Block 优先级最高:调用后调用方须立即从当前列表移除该用户(AGENTS 第 4 条)。 */
async function block(userId: string, reason?: string) {
return matchService.block(userId, { reason })
}
async function unlike(userId: string) {
return matchService.unlike(userId)
}
async function unblock(userId: string) {
return matchService.unblock(userId)
}
async function unmatch(matchId: string) {
return matchService.unmatch(matchId)
}
async function confirmSuccess(matchId: string) {
await matchService.confirmSuccess(matchId)
}
function discover(params: Record<string, any> = {}) {
return recommendationService.discover(params)
}
function getRecommendations(params: Record<string, any> = {}) {
return recommendationService.getRecommendations(params)
}
return { like, pass, block, unlike, unblock, unmatch, confirmSuccess, discover, getRecommendations }
})
+64
View File
@@ -0,0 +1,64 @@
import { defineStore } from 'pinia'
import { matchmakerService, referralService, contactConsentService } from '@/api/services'
export const useMatchmakerStore = defineStore('matchmaker', () => {
function apply(payload: Record<string, any>) {
return matchmakerService.applyService(payload)
}
function myServices(params: Record<string, any> = {}) {
return matchmakerService.getMyServices(params)
}
function serviceDetail(id: string) {
return matchmakerService.getServiceDetail(id)
}
function confirmService(id: string) {
return matchmakerService.confirmService(id)
}
function withdrawService(id: string) {
return matchmakerService.withdrawService(id)
}
function recommendations(params: Record<string, any> = {}) {
return matchmakerService.getRecommendations(params)
}
function acceptRecommendation(id: string) {
return matchmakerService.acceptRecommendation(id)
}
function passRecommendation(id: string) {
return matchmakerService.passRecommendation(id)
}
function markViewed(id: string) {
return matchmakerService.markRecommendationViewed(id)
}
function referralHistory() {
return referralService.getMyReferralHistory()
}
function confirmReferral(id: string, targetType: 'service_station' | 'activity') {
return referralService.confirmReferral(id, targetType)
}
function myContactAuthorizations() {
return contactConsentService.my()
}
function consentContact(id: string) {
return contactConsentService.consent(id)
}
function declineContact(id: string) {
return contactConsentService.decline(id)
}
return {
apply,
myServices,
serviceDetail,
confirmService,
withdrawService,
recommendations,
acceptRecommendation,
passRecommendation,
markViewed,
referralHistory,
confirmReferral,
myContactAuthorizations,
consentContact,
declineContact,
}
})
+27
View File
@@ -0,0 +1,27 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { operatorService } from '@/api/services'
import { getItem, StorageKeys } from '@/utils/storage'
export const useOperatorStore = defineStore('operator', () => {
const operatorToken = ref<string | null>(getItem(StorageKeys.operatorToken))
const operatorName = ref<string | null>(getItem(StorageKeys.operatorName))
const isLoggedIn = computed(() => !!operatorToken.value)
async function login(username: string, password: string) {
const data = await operatorService.login(username, password)
operatorToken.value = data?.accessToken || null
operatorName.value = data?.name || username
return data
}
function logout() {
operatorService.logout()
operatorService.clearSession()
operatorToken.value = null
operatorName.value = null
}
return { operatorToken, operatorName, isLoggedIn, login, logout }
})

Some files were not shown because too many files have changed in this diff Show More