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
@@ -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>