完成consumer端同步

This commit is contained in:
2026-05-14 15:28:09 +08:00
parent 612fb3d360
commit 0ffbc53902
197 changed files with 92657 additions and 7564 deletions

View File

@@ -0,0 +1,192 @@
<template>
<scroll-view class="page" scroll-y="true">
<ServicePanel title="服务申请管理" subtitle="沿用后台列表 + 状态标签 + 操作按钮的组织方式。">
<view class="overview-row">
<view v-for="item in overview" :key="item.id" class="overview-card" :class="'overview-' + item.tone">
<text class="overview-value">{{ item.value }}</text>
<text class="overview-label">{{ item.label }}</text>
</view>
</view>
</ServicePanel>
<ServicePanel title="申请列表" subtitle="首批先展示申请受理、评估和验收前的关键字段。">
<view v-for="item in applications" :key="item.id" class="app-card">
<view class="app-row">
<view>
<text class="app-title">{{ item.elderName }} · {{ item.serviceName }}</text>
<text class="app-meta">{{ item.caseNo }}</text>
</view>
<ServiceStatusTag :text="item.statusText" :tone="item.statusTone"></ServiceStatusTag>
</view>
<text class="app-info">期望时间:{{ item.preferredTime }}</text>
<text class="app-info">评估结果:{{ item.assessmentResult }}</text>
<text class="app-info">调度员:{{ item.dispatcherName }} · 执行人员:{{ item.staffName }}</text>
<ServiceActionRow
:actions="[
{ key: 'assessment', label: '去评估', tone: 'ghost' },
{ key: 'plan', label: '编制方案', tone: 'primary' }
]"
@action="(key: string) => handlePrimaryAction(item.caseId, key)"
></ServiceActionRow>
<ServiceActionRow
:actions="[
{ key: 'rectification', label: '整改处理', tone: 'warn' },
{ key: 'settlement', label: '结算归档', tone: 'success' }
]"
:compact="true"
@action="(key: string) => handleSecondaryAction(item.caseId, key)"
></ServiceActionRow>
</view>
<view class="jump-btn" @click="goDispatch">进入派单调度</view>
</ServicePanel>
</scroll-view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import ServiceActionRow from '@/components/homeService/ServiceActionRow.uvue'
import ServicePanel from '@/components/homeService/ServicePanel.uvue'
import ServiceStatusTag from '@/components/homeService/ServiceStatusTag.uvue'
import { fetchAdminHomeServiceApplications, fetchAdminHomeServiceOverview } from '@/services/homeServiceService.uts'
import { HomeServiceAdminApplicationType, HomeServiceOverviewCardType } from '@/types/home-service.uts'
const applications = ref<Array<HomeServiceAdminApplicationType>>([])
const overview = ref<Array<HomeServiceOverviewCardType>>([])
async function loadData() {
applications.value = await fetchAdminHomeServiceApplications()
overview.value = await fetchAdminHomeServiceOverview()
}
function goDispatch() {
uni.navigateTo({ url: '/pages/mall/admin/home-service/dispatch-center/index' })
}
function goAssessment(caseId: string) {
uni.navigateTo({ url: '/pages/mall/admin/home-service/assessment-form/index?id=' + caseId })
}
function goPlan(caseId: string) {
uni.navigateTo({ url: '/pages/mall/admin/home-service/service-plan/index?id=' + caseId })
}
function goRectification(caseId: string) {
uni.navigateTo({ url: '/pages/mall/admin/home-service/rectification/index?id=' + caseId })
}
function goSettlement(caseId: string) {
uni.navigateTo({ url: '/pages/mall/admin/home-service/settlement-archive/index?id=' + caseId })
}
function handlePrimaryAction(caseId: string, actionKey: string) {
if (actionKey == 'assessment') {
goAssessment(caseId)
} else if (actionKey == 'plan') {
goPlan(caseId)
}
}
function handleSecondaryAction(caseId: string, actionKey: string) {
if (actionKey == 'rectification') {
goRectification(caseId)
} else if (actionKey == 'settlement') {
goSettlement(caseId)
}
}
onLoad(() => {
loadData()
})
onShow(() => {
loadData()
})
</script>
<style scoped>
.page {
min-height: 100vh;
background: #f5f7fb;
padding: 24rpx;
box-sizing: border-box;
}
.overview-row {
flex-direction: row;
flex-wrap: wrap;
gap: 16rpx;
}
.overview-card {
width: 48%;
padding: 24rpx;
border-radius: 20rpx;
}
.overview-primary {
background: #e8f2ff;
}
.overview-warning {
background: #fff4e5;
}
.overview-success {
background: #e8f7ef;
}
.overview-neutral {
background: #eef2f6;
}
.overview-value {
font-size: 40rpx;
font-weight: 700;
color: #16324f;
}
.overview-label {
margin-top: 8rpx;
font-size: 24rpx;
color: #66788a;
}
.app-card {
padding: 24rpx;
border-radius: 20rpx;
background: #f8fbfc;
margin-bottom: 20rpx;
}
.app-row {
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
}
.app-title {
font-size: 30rpx;
font-weight: 700;
color: #16324f;
}
.app-meta,
.app-info {
margin-top: 8rpx;
font-size: 24rpx;
line-height: 36rpx;
color: #66788a;
}
.jump-btn {
margin-top: 8rpx;
padding: 24rpx 0;
text-align: center;
font-size: 28rpx;
font-weight: 700;
color: #ffffff;
background: #1d4ed8;
border-radius: 18rpx;
}
</style>

View File

@@ -0,0 +1,168 @@
<template>
<scroll-view class="page" scroll-y="true">
<view v-if="detail == null" class="empty-box">
<text class="empty-text">未找到评估信息</text>
</view>
<view v-else>
<ServicePanel title="上门评估" subtitle="先用 mock 表单承接风险等级、护理等级和评估结论。">
<ServiceInfoList
:items="[
{ label: '服务单号:', value: detail.caseNo },
{ label: '服务对象:', value: detail.elderName },
{ label: '申请服务:', value: detail.serviceName },
{ label: '预约上门:', value: detail.visitTime }
]"
></ServiceInfoList>
<view class="block">
<text class="label">风险等级</text>
<view class="chip-row">
<view v-for="item in riskOptions" :key="item" class="chip" :class="riskLevel == item ? 'chip-active' : ''" @click="riskLevel = item">{{ item }}</view>
</view>
</view>
<view class="block">
<text class="label">护理等级</text>
<view class="chip-row">
<view v-for="item in careOptions" :key="item" class="chip" :class="careLevel == item ? 'chip-active' : ''" @click="careLevel = item">{{ item }}</view>
</view>
</view>
<view class="block">
<text class="label">评估标签</text>
<view class="tag-row">
<view v-for="item in detail.requirementTags" :key="item" class="tag">{{ item }}</view>
</view>
</view>
<view class="block">
<text class="label">评估结论</text>
<textarea v-model="assessmentSummary" class="textarea" placeholder="填写服务对象能力状态、风险点和护理建议"></textarea>
</view>
<view class="submit-btn" @click="submitAssessment">提交评估</view>
</ServicePanel>
</view>
</scroll-view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import ServiceInfoList from '@/components/homeService/ServiceInfoList.uvue'
import ServicePanel from '@/components/homeService/ServicePanel.uvue'
import { fetchAdminAssessmentDetail, submitAdminAssessment } from '@/services/homeServiceService.uts'
import { HomeServiceAssessmentType } from '@/types/home-service.uts'
const caseId = ref('')
const detail = ref<HomeServiceAssessmentType | null>(null)
const riskLevel = ref('中风险')
const careLevel = ref('护理二级')
const assessmentSummary = ref('行动缓慢,需重点关注晨间血压、跌倒风险和夜间陪护提醒。')
const riskOptions = ['低风险', '中风险', '高风险']
const careOptions = ['护理一级', '护理二级', '护理三级', '随访管理']
onLoad((options) => {
const id = options['id']
if (id != null) {
caseId.value = id as string
fetchAdminAssessmentDetail(caseId.value).then((res) => {
if (res != null) {
detail.value = res
riskLevel.value = res.riskLevel
careLevel.value = res.careLevel
assessmentSummary.value = res.assessmentSummary
}
})
}
})
async function submitAssessment() {
if (caseId.value == '' || assessmentSummary.value == '') {
uni.showToast({ title: '请填写评估结论', icon: 'none' })
return
}
const result = await submitAdminAssessment(caseId.value, riskLevel.value, careLevel.value, assessmentSummary.value)
if (result != null) {
uni.showToast({ title: '评估已提交', icon: 'success' })
setTimeout(() => {
uni.navigateBack()
}, 400)
}
}
</script>
<style scoped>
.page {
min-height: 100vh;
background: #f5f7fb;
padding: 24rpx;
box-sizing: border-box;
}
.label,
.chip,
.tag,
.empty-text {
font-size: 28rpx;
line-height: 40rpx;
color: #16324f;
}
.block {
margin-top: 24rpx;
}
.chip-row,
.tag-row {
margin-top: 16rpx;
flex-direction: row;
flex-wrap: wrap;
gap: 16rpx;
}
.chip,
.tag {
padding: 16rpx 20rpx;
border-radius: 18rpx;
background: #f0f4f8;
}
.chip-active {
background: #e8f2ff;
color: #1d4ed8;
}
.tag {
background: #e8f7ef;
color: #15803d;
}
.textarea {
margin-top: 16rpx;
width: 100%;
height: 260rpx;
padding: 24rpx;
box-sizing: border-box;
background: #f8fbfc;
border-radius: 20rpx;
font-size: 28rpx;
color: #23384d;
}
.submit-btn {
margin-top: 32rpx;
padding: 26rpx 0;
border-radius: 20rpx;
background: #1d4ed8;
text-align: center;
font-size: 30rpx;
font-weight: 700;
color: #ffffff;
}
.empty-box {
padding: 120rpx 0;
align-items: center;
}
</style>

View File

@@ -0,0 +1,113 @@
<template>
<scroll-view class="page" scroll-y="true">
<ServicePanel title="派单调度中心" subtitle="首批只做调度总览和待派单清单,后续再补排班与改派。">
<view v-for="item in applications" :key="item.id" class="dispatch-card">
<view class="dispatch-row">
<view>
<text class="dispatch-title">{{ item.serviceName }}</text>
<text class="dispatch-meta">{{ item.caseNo }} · {{ item.elderName }}</text>
</view>
<ServiceStatusTag :text="item.statusText" :tone="item.statusTone"></ServiceStatusTag>
</view>
<text class="dispatch-info">建议上门时间:{{ item.preferredTime }}</text>
<text class="dispatch-info">评估结论:{{ item.assessmentResult }}</text>
<text class="dispatch-info">当前执行人员:{{ item.staffName }}</text>
<view class="row-actions">
<view class="action ghost" @click="goPlan(item.caseId)">查看方案</view>
<view class="action primary" @click="toastAssign">模拟派单</view>
</view>
</view>
</ServicePanel>
</scroll-view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import ServicePanel from '@/components/homeService/ServicePanel.uvue'
import ServiceStatusTag from '@/components/homeService/ServiceStatusTag.uvue'
import { fetchAdminHomeServiceApplications } from '@/services/homeServiceService.uts'
import { HomeServiceAdminApplicationType } from '@/types/home-service.uts'
const applications = ref<Array<HomeServiceAdminApplicationType>>([])
async function loadData() {
applications.value = await fetchAdminHomeServiceApplications()
}
function goPlan(caseId: string) {
uni.navigateTo({ url: '/pages/mall/admin/home-service/service-plan/index?id=' + caseId })
}
function toastAssign() {
uni.showToast({ title: '已完成 mock 派单演示', icon: 'none' })
}
onLoad(() => {
loadData()
})
</script>
<style scoped>
.page {
min-height: 100vh;
background: #f5f7fb;
padding: 24rpx;
box-sizing: border-box;
}
.dispatch-card {
padding: 24rpx;
border-radius: 20rpx;
background: #f8fbfc;
margin-bottom: 20rpx;
}
.dispatch-row,
.row-actions {
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
}
.dispatch-title {
font-size: 30rpx;
font-weight: 700;
color: #16324f;
}
.dispatch-meta,
.dispatch-info {
margin-top: 8rpx;
font-size: 24rpx;
line-height: 36rpx;
color: #66788a;
}
.row-actions {
margin-top: 20rpx;
gap: 16rpx;
}
.action {
flex: 1;
padding: 22rpx 0;
border-radius: 16rpx;
text-align: center;
font-size: 26rpx;
font-weight: 700;
}
.ghost {
background: #ffffff;
color: #16324f;
border-width: 2rpx;
border-style: solid;
border-color: #d7e0ea;
}
.primary {
background: #1d4ed8;
color: #ffffff;
}
</style>

View File

@@ -0,0 +1,122 @@
<template>
<scroll-view class="page" scroll-y="true">
<view v-if="detail == null" class="empty-box">
<text class="empty-text">未找到整改信息</text>
</view>
<view v-else>
<ServicePanel title="整改处理" subtitle="用于承接家属退回后的整改闭环。">
<ServiceInfoCard
:title="detail.serviceName"
:code="detail.caseNo"
:status-text="detail.statusText"
:items="[
{ label: '责任人', value: detail.ownerName },
{ label: '整改时限', value: detail.deadline }
]"
></ServiceInfoCard>
<ServiceInfoList
:items="[
{ label: '服务对象:', value: detail.elderName },
{ label: '服务项目:', value: detail.serviceName }
]"
></ServiceInfoList>
<view class="block">
<text class="label">问题说明</text>
<textarea v-model="issueSummary" class="textarea" placeholder="填写整改动作、补充说明和复验收提示"></textarea>
</view>
<view class="submit-btn" @click="submitRectification">提交整改结果</view>
</ServicePanel>
</view>
</scroll-view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import ServiceInfoCard from '@/components/homeService/ServiceInfoCard.uvue'
import ServiceInfoList from '@/components/homeService/ServiceInfoList.uvue'
import ServicePanel from '@/components/homeService/ServicePanel.uvue'
import { fetchAdminRectificationDetail, submitAdminRectification } from '@/services/homeServiceService.uts'
import { HomeServiceRectificationType } from '@/types/home-service.uts'
const caseId = ref('')
const detail = ref<HomeServiceRectificationType | null>(null)
const issueSummary = ref('已补充现场留痕与护理动作说明,建议家属重新确认验收。')
onLoad((options) => {
const id = options['id']
if (id != null) {
caseId.value = id as string
fetchAdminRectificationDetail(caseId.value).then((res) => {
if (res != null) {
detail.value = res
issueSummary.value = res.issueSummary
}
})
}
})
async function submitRectification() {
if (caseId.value == '' || issueSummary.value == '') {
uni.showToast({ title: '请填写整改结果', icon: 'none' })
return
}
const result = await submitAdminRectification(caseId.value, issueSummary.value)
if (result != null) {
uni.showToast({ title: '整改已提交', icon: 'success' })
setTimeout(() => {
uni.navigateBack()
}, 400)
}
}
</script>
<style scoped>
.page {
min-height: 100vh;
background: #f5f7fb;
padding: 24rpx;
box-sizing: border-box;
}
.label,
.empty-text {
font-size: 28rpx;
line-height: 40rpx;
color: #16324f;
}
.block {
margin-top: 24rpx;
}
.textarea {
margin-top: 16rpx;
width: 100%;
height: 260rpx;
padding: 24rpx;
box-sizing: border-box;
background: #f8fbfc;
border-radius: 20rpx;
font-size: 28rpx;
color: #23384d;
}
.submit-btn {
margin-top: 32rpx;
padding: 26rpx 0;
border-radius: 20rpx;
background: #b45309;
text-align: center;
font-size: 30rpx;
font-weight: 700;
color: #ffffff;
}
.empty-box {
padding: 120rpx 0;
align-items: center;
}
</style>

View File

@@ -0,0 +1,157 @@
<template>
<scroll-view class="page" scroll-y="true">
<view v-if="detail == null" class="empty-box">
<text class="empty-text">未找到服务方案</text>
</view>
<view v-else>
<ServicePanel title="服务方案" subtitle="先用 mock 数据完成计划频次、周期和执行说明。">
<ServiceInfoList
:items="[
{ label: '服务单号:', value: detail.caseNo },
{ label: '服务对象:', value: detail.elderName },
{ label: '服务类型:', value: detail.serviceName }
]"
></ServiceInfoList>
<view class="block">
<text class="label">方案标题</text>
<input v-model="planTitle" class="input" placeholder="请输入方案标题" />
</view>
<view class="block">
<text class="label">服务频次</text>
<input v-model="serviceFrequency" class="input" placeholder="例如 每周 3 次" />
</view>
<view class="block">
<text class="label">服务周期</text>
<input v-model="serviceCycle" class="input" placeholder="例如 2026-05-14 至 2026-05-21" />
</view>
<view class="block">
<text class="label">执行建议</text>
<view class="advice-box">{{ detail.executorAdvice }}</view>
</view>
<view class="block">
<text class="label">收费说明</text>
<view class="advice-box">{{ detail.billingSummary }}</view>
</view>
<view class="block">
<text class="label">方案摘要</text>
<textarea v-model="planSummary" class="textarea" placeholder="填写服务目标、执行重点和验收口径"></textarea>
</view>
<view class="submit-btn" @click="submitPlan">提交方案</view>
</ServicePanel>
</view>
</scroll-view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import ServiceInfoList from '@/components/homeService/ServiceInfoList.uvue'
import ServicePanel from '@/components/homeService/ServicePanel.uvue'
import { fetchAdminServicePlanDetail, submitAdminServicePlan } from '@/services/homeServiceService.uts'
import { HomeServicePlanType } from '@/types/home-service.uts'
const caseId = ref('')
const detail = ref<HomeServicePlanType | null>(null)
const planTitle = ref('')
const serviceFrequency = ref('')
const serviceCycle = ref('')
const planSummary = ref('')
onLoad((options) => {
const id = options['id']
if (id != null) {
caseId.value = id as string
fetchAdminServicePlanDetail(caseId.value).then((res) => {
if (res != null) {
detail.value = res
planTitle.value = res.planTitle
serviceFrequency.value = res.serviceFrequency
serviceCycle.value = res.serviceCycle
planSummary.value = res.planSummary
}
})
}
})
async function submitPlan() {
if (caseId.value == '' || planTitle.value == '' || serviceFrequency.value == '' || serviceCycle.value == '' || planSummary.value == '') {
uni.showToast({ title: '请补全方案信息', icon: 'none' })
return
}
const result = await submitAdminServicePlan(caseId.value, planTitle.value, serviceFrequency.value, serviceCycle.value, planSummary.value)
if (result != null) {
uni.showToast({ title: '方案已提交', icon: 'success' })
setTimeout(() => {
uni.navigateBack()
}, 400)
}
}
</script>
<style scoped>
.page {
min-height: 100vh;
background: #f5f7fb;
padding: 24rpx;
box-sizing: border-box;
}
.label,
.advice-box,
.empty-text {
font-size: 28rpx;
line-height: 40rpx;
color: #16324f;
}
.block {
margin-top: 24rpx;
}
.input,
.textarea {
margin-top: 16rpx;
width: 100%;
padding: 24rpx;
box-sizing: border-box;
background: #f8fbfc;
border-radius: 20rpx;
font-size: 28rpx;
color: #23384d;
}
.textarea {
height: 260rpx;
}
.advice-box {
margin-top: 16rpx;
padding: 22rpx 24rpx;
background: #eef2f6;
border-radius: 18rpx;
color: #66788a;
}
.submit-btn {
margin-top: 32rpx;
padding: 26rpx 0;
border-radius: 20rpx;
background: #1d4ed8;
text-align: center;
font-size: 30rpx;
font-weight: 700;
color: #ffffff;
}
.empty-box {
padding: 120rpx 0;
align-items: center;
}
</style>

View File

@@ -0,0 +1,121 @@
<template>
<scroll-view class="page" scroll-y="true">
<view v-if="detail == null" class="empty-box">
<text class="empty-text">未找到结算信息</text>
</view>
<view v-else>
<ServicePanel title="结算归档" subtitle="承接已完成服务单的费用确认与档案归集。">
<ServiceInfoCard
:title="detail.serviceName"
:code="detail.caseNo"
:status-text="detail.archiveStatusText"
:items="[
{ label: '账期', value: detail.billingPeriod }
]"
></ServiceInfoCard>
<ServiceInfoList
:items="[
{ label: '服务对象:', value: detail.elderName }
]"
></ServiceInfoList>
<view class="amount-card">
<text class="amount-line">服务总额:{{ detail.totalAmount }}</text>
<text class="amount-line">长护险/补贴:{{ detail.insuranceAmount }}</text>
<text class="amount-line">个人自付:{{ detail.selfPayAmount }}</text>
</view>
<view class="archive-box">
<text class="archive-text">已包含执行记录、验收反馈、结算单与电子档案索引,提交后将标记为已归档。</text>
</view>
<view class="submit-btn" @click="submitArchive">确认归档</view>
</ServicePanel>
</view>
</scroll-view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import ServiceInfoCard from '@/components/homeService/ServiceInfoCard.uvue'
import ServiceInfoList from '@/components/homeService/ServiceInfoList.uvue'
import ServicePanel from '@/components/homeService/ServicePanel.uvue'
import { fetchAdminSettlementDetail, submitAdminSettlementArchive } from '@/services/homeServiceService.uts'
import { HomeServiceSettlementType } from '@/types/home-service.uts'
const caseId = ref('')
const detail = ref<HomeServiceSettlementType | null>(null)
onLoad((options) => {
const id = options['id']
if (id != null) {
caseId.value = id as string
fetchAdminSettlementDetail(caseId.value).then((res) => {
if (res != null) {
detail.value = res
}
})
}
})
async function submitArchive() {
if (caseId.value == '') {
return
}
const result = await submitAdminSettlementArchive(caseId.value)
if (result != null) {
detail.value = result
uni.showToast({ title: '已完成归档', icon: 'success' })
}
}
</script>
<style scoped>
.page {
min-height: 100vh;
background: #f5f7fb;
padding: 24rpx;
box-sizing: border-box;
}
.amount-line,
.archive-text,
.empty-text {
font-size: 28rpx;
line-height: 40rpx;
color: #16324f;
}
.amount-card,
.archive-box {
margin-top: 24rpx;
padding: 24rpx;
border-radius: 20rpx;
background: #f8fbfc;
}
.amount-line + .amount-line {
margin-top: 12rpx;
}
.archive-text {
color: #66788a;
}
.submit-btn {
margin-top: 32rpx;
padding: 26rpx 0;
border-radius: 20rpx;
background: #15803d;
text-align: center;
font-size: 30rpx;
font-weight: 700;
color: #ffffff;
}
.empty-box {
padding: 120rpx 0;
align-items: center;
}
</style>

View File

@@ -1,6 +1,6 @@
<template>
<view class="page-container">
<scroll-view class="address-edit-scroll" scroll-y="true">
<scroll-view class="address-edit-scroll" direction="vertical">
<view class="address-edit-content">
<!-- 基础信息组 -->
<view class="form-group">
@@ -160,9 +160,12 @@ const loadAddress = async (id: string) => {
}
onLoad((options) => {
if (options['id'] != null) {
if (options == null) return
const optionsObj = options as UTSJSONObject
const idValue = optionsObj.getString('id') ?? ''
if (idValue != '') {
isEdit.value = true
addressId.value = options['id'] as string
addressId.value = idValue
loadAddress(addressId.value)
}
})
@@ -343,43 +346,43 @@ const parseSmartInput = () => {
formData.detail = addrText
}
}
const doDeleteAddress = async (): Promise<void> => {
const success = await supabaseService.deleteAddress(addressId.value)
if (success) {
const storedAddresses = uni.getStorageSync('addresses')
if (storedAddresses != null) {
try {
let addresses = JSON.parse(storedAddresses as string) as Address[]
addresses = addresses.filter(item => item.id !== addressId.value)
uni.setStorageSync('addresses', JSON.stringify(addresses))
} catch (e) {
console.error('解析本地地址数据失败', e)
}
}
uni.showToast({
title: '删除成功',
icon: 'success'
})
setTimeout(() => {
uni.navigateBack()
}, 1500)
} else {
console.error('删除地址失败')
uni.showToast({
title: '删除失败',
icon: 'none'
})
}
}
const deleteAddress = () => {
uni.showModal({
title: '提示',
content: '确定要删除该地址吗?',
success: (res: UniShowModalResult) => {
success: (res) => {
if (res.confirm) {
// 调用Supabase服务删除地址
supabaseService.deleteAddress(addressId.value).then((success) => {
if (success) {
// 同时从本地存储中移除
const storedAddresses = uni.getStorageSync('addresses')
if (storedAddresses != null) {
try {
let addresses = JSON.parse(storedAddresses as string) as Address[]
addresses = addresses.filter(item => item.id !== addressId.value)
uni.setStorageSync('addresses', JSON.stringify(addresses))
} catch (e) {
console.error('解析本地地址数据失败', e)
}
}
uni.showToast({
title: '删除成功',
icon: 'success'
})
setTimeout(() => {
uni.navigateBack()
}, 1500)
} else {
console.error('删除地址失败')
uni.showToast({
title: '删除失败',
icon: 'none'
})
}
})
doDeleteAddress()
}
}
})

View File

@@ -98,7 +98,9 @@ const loadAddresses = async () => {
}
onLoad((options) => {
if (options['selectMode'] == 'true') {
if (options == null) return
const optionsObj = options as UTSJSONObject
if ((optionsObj.getString('selectMode') ?? '') == 'true') {
selectionMode.value = true
}
})
@@ -121,34 +123,34 @@ const addAddress = () => {
}
// 删除地址
const doDeleteAddress = async (id: string): Promise<void> => {
const success = await supabaseService.deleteAddress(id)
if (success) {
const index = addresses.value.findIndex(addr => addr.id === id)
if (index !== -1) {
addresses.value.splice(index, 1)
uni.setStorageSync('addresses', JSON.stringify(addresses.value))
uni.showToast({
title: '删除成功',
icon: 'success'
})
}
} else {
console.error('删除地址失败')
uni.showToast({
title: '删除失败',
icon: 'none'
})
}
}
const deleteAddress = (id: string) => {
uni.showModal({
title: '提示',
content: '确定要删除该地址吗?',
success: (res) => {
if (res.confirm) {
// 调用Supabase服务删除地址
supabaseService.deleteAddress(id).then((success) => {
if (success) {
// 从本地列表移除
const index = addresses.value.findIndex(addr => addr.id === id)
if (index !== -1) {
addresses.value.splice(index, 1)
// 更新本地存储缓存
uni.setStorageSync('addresses', JSON.stringify(addresses.value))
uni.showToast({
title: '删除成功',
icon: 'success'
})
}
} else {
console.error('删除地址失败')
uni.showToast({
title: '删除失败',
icon: 'none'
})
}
})
doDeleteAddress(id)
}
}
})

View File

@@ -9,7 +9,7 @@
</view>
<!-- 地址列表 -->
<scroll-view class="address-list" scroll-y>
<scroll-view class="address-list" direction="vertical">
<!-- 地址为空 -->
<view v-if="addressList.length === 0" class="empty-address">
<text class="empty-icon">📍</text>
@@ -68,7 +68,7 @@
<text class="form-close" @click="cancelNewAddress">×</text>
</view>
<scroll-view class="form-content" scroll-y>
<scroll-view class="form-content" direction="vertical">
<view class="form-item">
<text class="form-label">收货人</text>
<input class="form-input" v-model="newAddress.recipient_name"
@@ -133,7 +133,7 @@
<script setup lang="uts">
import { ref, onMounted, onUnmounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import supa from '@/components/supadb/aksupainstance.uts'
import { supabaseService, type UserAddress, type AddAddressParams } from '@/utils/supabaseService.uts'
type AddressType = {
id: string
@@ -149,10 +149,20 @@ type AddressType = {
created_at: string
}
type NewAddressForm = {
recipient_name: string
phone: string
province: string
city: string
district: string
detail: string
is_default: boolean
}
const addressList = ref<Array<AddressType>>([])
const fromSelect = ref<boolean>(false)
const showNewAddressForm = ref<boolean>(false)
const newAddress = ref<any>({
const newAddress = ref<NewAddressForm>({
recipient_name: '',
phone: '',
province: '',
@@ -163,394 +173,31 @@ const newAddress = ref<any>({
})
const smartAddressInput = ref<string>('')
onLoad((options: any) => {
const optObj = (options instanceof UTSJSONObject) ? (options as UTSJSONObject) : (JSON.parse(JSON.stringify(options ?? {})) as UTSJSONObject)
const flag = optObj.getString('fromSelect') ?? ''
fromSelect.value = (flag == '1' || flag == 'true')
})
// 生命周期
onMounted(() => {
loadAddresses()
// 监听地址更新事件从checkout页面或其他页面
uni.$on('addressUpdated', (updatedAddressList: any) => {
addressList.value = updatedAddressList
})
})
// 组件卸载时移除事件监听
onUnmounted(() => {
uni.$off('addressUpdated')
})
// 加载地址列表
const loadAddresses = async () => {
const userId = getCurrentUserId()
if (!userId) {
uni.showToast({
title: '请先登录',
icon: 'none'
})
uni.navigateTo({
url: '/pages/user/login'
})
return
}
try {
const { data, error } = await supa
.from('user_addresses')
.select('*')
.eq('user_id', userId)
.order('is_default', { ascending: false })
.order('created_at', { ascending: false })
if (error !== null) {
console.error('加载地址失败:', error)
return
}
addressList.value = data ?? []
} catch (err) {
console.error('加载地址异常:', err)
function mapAddress(item: UserAddress): AddressType {
return {
id: item.id,
user_id: item.user_id,
recipient_name: item.recipient_name ?? '',
phone: item.phone ?? '',
province: item.province ?? '',
city: item.city ?? '',
district: item.district ?? '',
detail: item.detail_address ?? '',
postal_code: item.postal_code ?? null,
is_default: item.is_default,
created_at: item.created_at ?? ''
}
}
// 获取当前用户ID
const getCurrentUserId = (): string | null => {
const userStore = uni.getStorageSync('userInfo')
if (userStore instanceof UTSJSONObject) {
return userStore.getString('id')
}
const obj = JSON.parse(JSON.stringify(userStore ?? {})) as UTSJSONObject
return obj.getString('id')
function getCurrentUserId(): string {
return supabaseService.getCurrentUserId() ?? ''
}
// 获取完整地址
const getFullAddress = (address: AddressType): string => {
function getFullAddress(address: AddressType): string {
return `${address.province}${address.city}${address.district}${address.detail}`
}
// 选择地址
const selectAddress = (address: AddressType) => {
if (fromSelect.value) {
try {
uni.setStorageSync('selectedAddress', JSON.stringify(address))
} catch (e) {}
uni.$emit('addressSelected', address)
uni.navigateBack()
}
}
// 编辑地址
const editAddress = (address: AddressType) => {
uni.navigateTo({
url: `/pages/mall/consumer/address-edit?id=${address.id}`
})
}
// 删除地址
const deleteAddress = (address: AddressType) => {
uni.showModal({
title: '删除地址',
content: '确定要删除这个收货地址吗?',
success: async (res) => {
if (res.confirm) {
try {
// 如果是默认地址,删除前检查是否还有其他地址
if (address.is_default && addressList.value.length > 1) {
uni.showModal({
title: '提示',
content: '删除默认地址后,系统会自动设置第一个地址为默认地址',
success: async (confirmRes) => {
if (confirmRes.confirm) {
await performDelete(address)
}
}
})
} else {
await performDelete(address)
}
} catch (err) {
console.error('删除地址异常:', err)
}
}
}
})
}
// 执行删除
const performDelete = async (address: AddressType) => {
try {
const { error } = await supa
.from('user_addresses')
.delete()
.eq('id', address.id)
if (error !== null) {
console.error('删除地址失败:', error)
uni.showToast({
title: '删除失败',
icon: 'none'
})
return
}
// 从列表中移除
const index = addressList.value.findIndex(item => item.id === address.id)
if (index !== -1) {
addressList.value.splice(index, 1)
}
// 如果是默认地址被删除,设置第一个地址为默认
if (address.is_default && addressList.value.length > 0) {
const newDefault = addressList.value[0]
await setAsDefault(newDefault)
}
uni.showToast({
title: '删除成功',
icon: 'success'
})
} catch (err) {
console.error('执行删除异常:', err)
}
}
// 设为默认地址
const setDefaultAddress = async (address: AddressType) => {
try {
const userId = getCurrentUserId()
if (!userId) return
// 1. 取消当前所有默认地址
const { error: updateError } = await supa
.from('user_addresses')
.update({ is_default: false })
.eq('user_id', userId)
.eq('is_default', true)
if (updateError !== null) {
console.error('取消默认地址失败:', updateError)
return
}
// 2. 设置新的默认地址
const { error: setError } = await supa
.from('user_addresses')
.update({ is_default: true })
.eq('id', address.id)
if (setError !== null) {
console.error('设置默认地址失败:', setError)
return
}
// 更新本地数据
addressList.value.forEach(item => {
item.is_default = item.id === address.id
})
uni.showToast({
title: '已设为默认地址',
icon: 'success'
})
} catch (err) {
console.error('设置默认地址异常:', err)
}
}
// 设置地址为默认(内部方法)
const setAsDefault = async (address: AddressType) => {
try {
const { error } = await supa
.from('user_addresses')
.update({ is_default: true })
.eq('id', address.id)
if (error !== null) {
console.error('设置默认地址失败:', error)
return
}
address.is_default = true
} catch (err) {
console.error('设置默认地址异常:', err)
}
}
// 解析智能地址
const parseSmartAddress = () => {
const input = smartAddressInput.value.trim()
if (!input) return
// 重置表单
newAddress.value.recipient_name = ''
newAddress.value.phone = ''
newAddress.value.province = ''
newAddress.value.city = ''
newAddress.value.district = ''
newAddress.value.detail = ''
// 尝试匹配手机号码11位数字
const phoneRegex = /(1[3-9]\d{9})/g
const phoneMatches = input.match(phoneRegex)
if (phoneMatches && phoneMatches.length > 0) {
newAddress.value.phone = phoneMatches[0]
}
// 尝试匹配收件人姓名中文姓名2-4个汉字
const nameRegex = /([\u4e00-\u9fa5]{2,4})/g
const nameMatches = input.match(nameRegex)
if (nameMatches && nameMatches.length > 0) {
// 取第一个匹配的中文姓名作为收件人
newAddress.value.recipient_name = nameMatches[0]
}
// 提取地址部分(移除姓名和手机号)
let addressText = input
if (newAddress.value.recipient_name) {
addressText = addressText.replace(newAddress.value.recipient_name, '')
}
if (newAddress.value.phone) {
addressText = addressText.replace(newAddress.value.phone, '')
}
// 清理地址文本(移除多余的空格和标点)
addressText = addressText.replace(/[,;\s]+/g, ' ').trim()
// 地址解析逻辑
const patterns = [
// 匹配格式:省市区详细地址
/^(.*?省)?(.*?市)?(.*?[区县])?(.*)$/,
// 匹配格式:省市详细地址
/^(.*?省)?(.*?市)?(.*)$/
]
for (const pattern of patterns) {
const match = addressText.match(pattern)
if (match) {
const [, province, city, district, detail] = match
if (province) newAddress.value.province = province.replace('省', '').trim()
if (city) newAddress.value.city = city.replace('市', '').trim()
if (district) newAddress.value.district = district.trim()
if (detail) newAddress.value.detail = detail.trim()
// 如果详细地址为空,但还有剩余内容,则作为详细地址
if (!newAddress.value.detail && district && detail) {
newAddress.value.detail = detail.trim()
}
break
}
}
// 如果没有匹配到模式,尝试简单分割
if (!newAddress.value.province && !newAddress.value.city && !newAddress.value.district) {
// 尝试按常见分隔符分割
const parts = addressText.split(/[省市县区]/)
if (parts.length >= 2) {
newAddress.value.province = parts[0] || ''
newAddress.value.city = parts[1] || ''
newAddress.value.detail = parts.slice(2).join('').trim() || addressText
} else {
newAddress.value.detail = addressText
}
}
// 如果地址部分为空,但还有剩余文本,则作为详细地址
if (!newAddress.value.detail && addressText.trim()) {
newAddress.value.detail = addressText.trim()
}
}
// 保存新地址
const saveNewAddress = async () => {
// 验证表单
if (!newAddress.value.recipient_name || !newAddress.value.phone || !newAddress.value.detail) {
uni.showToast({
title: '请填写完整信息',
icon: 'none'
})
return
}
const userId = getCurrentUserId()
if (!userId) {
uni.showToast({
title: '请先登录',
icon: 'none'
})
return
}
try {
const { data, error } = await supa
.from('user_addresses')
.insert({
user_id: userId,
recipient_name: newAddress.value.recipient_name,
phone: newAddress.value.phone,
province: newAddress.value.province,
city: newAddress.value.city,
district: newAddress.value.district,
detail: newAddress.value.detail,
is_default: newAddress.value.is_default,
created_at: new Date().toISOString()
})
.select()
.single()
if (error !== null) {
console.error('保存地址失败:', error)
uni.showToast({
title: '保存失败',
icon: 'none'
})
return
}
// 如果是默认地址,取消其他默认地址
if (newAddress.value.is_default) {
addressList.value.forEach(addr => {
addr.is_default = false
})
// 更新数据库中的其他地址
await supa
.from('user_addresses')
.update({ is_default: false })
.eq('user_id', userId)
.neq('id', data.id)
}
// 添加到列表
addressList.value.unshift(data)
// 发布地址更新事件让checkout页面也能获取到
uni.$emit('addressUpdated', addressList.value)
// 重置表单
resetNewAddressForm()
uni.showToast({
title: '地址保存成功',
icon: 'success'
})
} catch (err) {
console.error('保存地址异常:', err)
uni.showToast({
title: '保存失败',
icon: 'none'
})
}
}
// 重置新建地址表单
const resetNewAddressForm = () => {
function resetNewAddressForm(): void {
showNewAddressForm.value = false
newAddress.value = {
recipient_name: '',
@@ -564,17 +211,275 @@ const resetNewAddressForm = () => {
smartAddressInput.value = ''
}
// 取消新建地址
const cancelNewAddress = () => {
function cancelNewAddress(): void {
resetNewAddressForm()
}
function selectAddress(address: AddressType): void {
if (fromSelect.value) {
try {
uni.setStorageSync('selectedAddress', JSON.stringify(address))
} catch (e) {}
uni.$emit('addressSelected', address)
uni.navigateBack()
}
}
function editAddress(address: AddressType): void {
uni.navigateTo({
url: `/pages/mall/consumer/address-edit?id=${address.id}`
})
}
async function loadAddresses(): Promise<void> {
const userId = getCurrentUserId()
if (userId === '') {
uni.showToast({
title: '请先登录',
icon: 'none'
})
uni.navigateTo({
url: '/pages/user/login'
})
return
}
try {
const items = await supabaseService.getAddresses()
const nextList: AddressType[] = []
for (let i = 0; i < items.length; i++) {
nextList.push(mapAddress(items[i]))
}
addressList.value = nextList
} catch (err) {
console.error('加载地址异常:', err)
addressList.value = []
}
}
async function doDeleteAddress(address: AddressType): Promise<void> {
const success = await supabaseService.deleteAddress(address.id)
if (!success) {
uni.showToast({
title: '删除失败',
icon: 'none'
})
return
}
await loadAddresses()
if (address.is_default && addressList.value.length > 0) {
await supabaseService.setDefaultAddress(addressList.value[0].id)
await loadAddresses()
}
uni.showToast({
title: '删除成功',
icon: 'success'
})
}
function confirmDeleteAddress(address: AddressType): void {
doDeleteAddress(address)
}
function deleteAddress(address: AddressType): void {
uni.showModal({
title: '删除地址',
content: '确定要删除这个收货地址吗?',
success: (res) => {
if (!res.confirm) return
if (address.is_default && addressList.value.length > 1) {
uni.showModal({
title: '提示',
content: '删除默认地址后,系统会自动设置第一个地址为默认地址',
success: (confirmRes) => {
if (confirmRes.confirm) {
confirmDeleteAddress(address)
}
}
})
return
}
confirmDeleteAddress(address)
}
})
}
async function setDefaultAddress(address: AddressType): Promise<void> {
const success = await supabaseService.setDefaultAddress(address.id)
if (!success) {
uni.showToast({
title: '设置失败',
icon: 'none'
})
return
}
await loadAddresses()
uni.showToast({
title: '已设为默认地址',
icon: 'success'
})
}
function parseSmartAddress(): void {
const input = smartAddressInput.value.trim()
if (input === '') return
newAddress.value.recipient_name = ''
newAddress.value.phone = ''
newAddress.value.province = ''
newAddress.value.city = ''
newAddress.value.district = ''
newAddress.value.detail = ''
const phoneRegex = /(1[3-9]\d{9})/g
const phoneMatches = input.match(phoneRegex)
if (phoneMatches != null && phoneMatches.length > 0) {
newAddress.value.phone = phoneMatches[0] ?? ''
}
const nameRegex = /([\u4e00-\u9fa5]{2,4})/g
const nameMatches = input.match(nameRegex)
if (nameMatches != null && nameMatches.length > 0) {
newAddress.value.recipient_name = nameMatches[0] ?? ''
}
let addressText = input
if (newAddress.value.recipient_name !== '') {
addressText = addressText.replace(newAddress.value.recipient_name, '')
}
if (newAddress.value.phone !== '') {
addressText = addressText.replace(newAddress.value.phone, '')
}
addressText = addressText.replace(/[,;\s]+/g, ' ').trim()
const patterns = [
/^(.*?省)?(.*?市)?(.*?[区县])?(.*)$/,
/^(.*?省)?(.*?市)?(.*)$/
]
for (let i = 0; i < patterns.length; i++) {
const match = addressText.match(patterns[i])
if (match != null) {
const province = match.length > 1 ? (match[1] ?? '') : ''
const city = match.length > 2 ? (match[2] ?? '') : ''
const district = match.length > 3 ? (match[3] ?? '') : ''
const detail = match.length > 4 ? (match[4] ?? '') : ''
if (province !== '') newAddress.value.province = province.replace('省', '').trim()
if (city !== '') newAddress.value.city = city.replace('市', '').trim()
if (district !== '') newAddress.value.district = district.trim()
if (detail !== '') newAddress.value.detail = detail.trim()
if (newAddress.value.detail === '' && district !== '' && detail !== '') {
newAddress.value.detail = detail.trim()
}
break
}
}
if (newAddress.value.province === '' && newAddress.value.city === '' && newAddress.value.district === '') {
const parts = addressText.split(/[省市县区]/)
if (parts.length >= 2) {
newAddress.value.province = parts[0] ?? ''
newAddress.value.city = parts[1] ?? ''
const detailCandidate = parts.slice(2).join('').trim()
newAddress.value.detail = detailCandidate !== '' ? detailCandidate : addressText
} else {
newAddress.value.detail = addressText
}
}
if (newAddress.value.detail === '' && addressText.trim() !== '') {
newAddress.value.detail = addressText.trim()
}
}
async function saveNewAddress(): Promise<void> {
if (newAddress.value.recipient_name === '' || newAddress.value.phone === '' || newAddress.value.detail === '') {
uni.showToast({
title: '请填写完整信息',
icon: 'none'
})
return
}
const userId = getCurrentUserId()
if (userId === '') {
uni.showToast({
title: '请先登录',
icon: 'none'
})
return
}
const payload: AddAddressParams = {
recipient_name: newAddress.value.recipient_name,
phone: newAddress.value.phone,
province: newAddress.value.province,
city: newAddress.value.city,
district: newAddress.value.district,
detail_address: newAddress.value.detail,
is_default: newAddress.value.is_default
}
try {
const success = await supabaseService.addAddress(payload)
if (!success) {
uni.showToast({
title: '保存失败',
icon: 'none'
})
return
}
await loadAddresses()
uni.$emit('addressUpdated', addressList.value)
resetNewAddressForm()
uni.showToast({
title: '地址保存成功',
icon: 'success'
})
} catch (err) {
console.error('保存地址异常:', err)
uni.showToast({
title: '保存失败',
icon: 'none'
})
}
}
onLoad((options) => {
const optObj = (options instanceof UTSJSONObject) ? (options as UTSJSONObject) : (JSON.parse(JSON.stringify(options ?? {})) as UTSJSONObject)
const flag = optObj.getString('fromSelect') ?? ''
fromSelect.value = (flag == '1' || flag == 'true')
})
// 生命周期
function handleAddressUpdated(): void {
loadAddresses()
}
onMounted(() => {
loadAddresses()
// 监听地址更新事件从checkout页面或其他页面
uni.$on('addressUpdated', handleAddressUpdated)
})
// 组件卸载时移除事件监听
onUnmounted(() => {
uni.$off('addressUpdated')
})
</script>
<style scoped>
.address-page {
display: flex;
flex-direction: column;
height: 100vh;
height: 100%;
background-color: #f5f5f5;
}
@@ -765,7 +670,7 @@ const cancelNewAddress = () => {
background-color: #ffffff;
width: 90%;
max-width: 500px;
max-height: 80vh;
max-height: 80%;
border-radius: 12px;
display: flex;
flex-direction: column;
@@ -794,7 +699,7 @@ const cancelNewAddress = () => {
.form-content {
flex: 1;
padding: 15px;
max-height: 50vh;
max-height: 50%;
}
.form-item {

View File

@@ -104,32 +104,33 @@ const loadOrderInfo = async () => {
}
onLoad((options) => {
if (options['orderId'] != null) {
orderId.value = options['orderId'] as string
if (options == null) return
const optionsObj = options as UTSJSONObject
const orderIdValue = optionsObj.getString('orderId') ?? ''
if (orderIdValue != '') {
orderId.value = orderIdValue
loadOrderInfo()
}
})
const handleTypeChange = (e: any) => {
// Use bracket notation to access detail property safely on 'any' type in UTS
// The structure is e -> detail -> value
// We need to cast e to UTSJSONObject first if we want to use bracket notation,
// OR we can use JSON.parse/stringify trick if simple casting fails,
// BUT the most standard way for UTS 'any' which is actually a Map/JSONObject at runtime:
const target = e as UTSJSONObject
const detail = target['detail'] as UTSJSONObject
const value = detail['value'] as string
refundType.value = parseInt(value)
const handleTypeChange = (e: UTSJSONObject) => {
const eventObj = e as UTSJSONObject
const detailRaw = eventObj.get('detail')
if (detailRaw == null) return
const detailObj = detailRaw as UTSJSONObject
const valueStr = detailObj.getString('value') ?? '1'
refundType.value = valueStr == '2' ? 2 : 1
}
const handleReasonChange = (e: any) => {
// Use bracket notation to access detail property safely on 'any' type in UTS
const target = e as UTSJSONObject
const detail = target['detail'] as UTSJSONObject
const value = detail['value'] as number
const index = value
refundReason.value = reasonList[index]
const handleReasonChange = (e: UTSJSONObject) => {
const eventObj = e as UTSJSONObject
const detailRaw = eventObj.get('detail')
if (detailRaw == null) return
const detailObj = detailRaw as UTSJSONObject
const index = detailObj.getNumber('value') ?? 0
if (index >= 0 && index < reasonList.length) {
refundReason.value = reasonList[index]
}
}
const submitRefund = async () => {
@@ -146,7 +147,7 @@ const submitRefund = async () => {
const amount = parseFloat(refundAmount.value)
console.log('解析后金额:', amount)
if (isNaN(amount) || amount <= 0 || amount > maxAmount.value) {
if (Number.isNaN(amount) || amount <= 0 || amount > maxAmount.value) {
uni.showToast({ title: '请输入有效的退款金额', icon: 'none' })
return
}

View File

@@ -1,5 +1,5 @@
<template>
<scroll-view class="balance-page" scroll-y>
<scroll-view class="balance-page" direction="vertical">
<view class="balance-header">
<view class="balance-info">
<text class="balance-label">账户余额(元)</text>

View File

@@ -1,160 +1,674 @@
<template>
<view class="add-card-page">
<view class="form-container">
<view class="form-item">
<text class="label">持卡人</text>
<input class="input" type="text" v-model="form.holder_name" placeholder="请输入持卡人姓名" />
<!-- 绑定银行卡 - 添加信息页(拼多多风格) -->
<view class="page">
<scroll-view direction="vertical" class="scroll-wrap">
<!-- ① 提示卡片 -->
<view class="tip-banner">
<text class="tip-icon">🔒</text>
<text class="tip-text">银行卡信息经加密传输,安全有保障</text>
</view>
<view class="form-item">
<text class="label">卡号</text>
<input class="input" type="number" v-model="form.card_no" placeholder="请输入银行卡号" @input="detectBank" maxlength="19" />
<!-- ② 表单白色卡片 -->
<view class="form-card">
<!-- 持卡人 -->
<view class="form-row">
<view class="row-left">
<text class="row-label">持卡人</text>
</view>
<view class="row-right">
<input
class="row-input"
type="text"
v-model="form.holder_name"
placeholder="请输入银行卡开户人姓名"
placeholder-style="color:#c0c4cc;font-size:28rpx"
:maxlength="20"
/>
<text class="row-tip-icon" @click="showHolderTip">ⓘ</text>
</view>
</view>
<view class="divider" />
<!-- 银行卡号 -->
<view class="form-row">
<view class="row-left">
<text class="row-label">银行卡号</text>
</view>
<view class="row-right">
<input
class="row-input"
type="number"
v-model="cardNoDisplay"
placeholder="请输入银行卡号"
placeholder-style="color:#c0c4cc;font-size:28rpx"
:maxlength="23"
@input="onCardNoInput"
/>
<text class="camera-btn" @click="doOCR">📷</text>
</view>
</view>
<view class="divider" />
<!-- 开户银行(选择器) -->
<view class="form-row selector-row" @click="openBankPicker">
<view class="row-left">
<text class="row-label">开户银行</text>
</view>
<view class="row-right">
<view class="selector-value-wrap">
<text
class="selector-value"
:class="{ placeholder: form.bank_name === '' }"
>
{{ form.bank_name ?? '请选择开户银行' }}
</text>
</view>
<text class="arrow-icon"></text>
</view>
</view>
<view class="divider" />
<!-- 开户支行(仅选了银行后显示,非必填) -->
<view v-if="form.bank_name !== ''" class="form-row selector-row" @click="openBranchInput">
<view class="row-left">
<text class="row-label">开户支行</text>
<text class="optional-badge">非必填</text>
</view>
<view class="row-right">
<view class="selector-value-wrap">
<text
class="selector-value"
:class="{ placeholder: form.branch_name === '' }"
>
{{ form.branch_name ?? '请填写开户支行(可选)' }}
</text>
</view>
<text class="arrow-icon"></text>
</view>
</view>
<view v-if="form.bank_name !== ''" class="divider" />
<!-- 手机号 -->
<view class="form-row">
<view class="row-left">
<text class="row-label">手机号码</text>
</view>
<view class="row-right">
<input
class="row-input"
type="number"
v-model="form.phone"
placeholder="请输入该银行卡的预留手机号"
placeholder-style="color:#c0c4cc;font-size:28rpx"
:maxlength="11"
/>
</view>
</view>
</view>
<view class="form-item">
<text class="label">银行</text>
<input class="input" type="text" v-model="form.bank_name" placeholder="自动识别或手动输入" />
<!-- ③ 安全说明 -->
<view class="desc-area">
<text class="desc-text">
手机号码将作为账户安全验证依据,请填写该银行卡在银行预留的手机号。
</text>
</view>
<view class="form-item">
<text class="label">手机号</text>
<input class="input" type="number" v-model="form.phone" placeholder="银行预留手机号" maxlength="11" />
<!-- 底部占位 -->
<view class="bottom-placeholder" />
</scroll-view>
<!-- ④ 底部大按钮(固定) -->
<view class="bottom-bar">
<view
class="next-btn"
:class="canSubmit ? 'next-btn-active' : 'next-btn-disabled'"
@click="goVerify"
>
<text class="next-btn-text">下一步,身份验证</text>
</view>
<view class="form-item switch-item">
<text class="label">设为默认卡</text>
<switch :checked="form.is_default" @change="onSwitchChange" color="#ff5000" />
<view style="height: env(safe-area-inset-bottom); min-height: 8rpx;" />
</view>
<!-- ⑤ 银行选择弹层 -->
<view v-if="bankPickerVisible" class="picker-mask" @click="closeBankPicker">
<view class="picker-panel" @click.stop>
<view class="picker-header">
<text class="picker-title">选择开户银行</text>
<text class="picker-close" @click="closeBankPicker">✕</text>
</view>
<scroll-view direction="vertical" class="picker-scroll">
<view
v-for="bank in bankList"
:key="bank.code"
class="picker-item"
:class="{ 'picker-item-active': form.bank_name === bank.name }"
@click="selectBank(bank)"
>
<view class="bank-logo-placeholder">
<text class="bank-logo-text">{{ bank.shortName.substring(0, 2) }}</text>
</view>
<text class="picker-item-name">{{ bank.name }}</text>
<text v-if="form.bank_name === bank.name" class="picker-check">✓</text>
</view>
</scroll-view>
</view>
</view>
<view class="action-section">
<button class="submit-btn" :class="{ disabled: loading }" :disabled="loading" @click="submit">确认添加</button>
<!-- ⑥ 开户支行输入弹层 -->
<view v-if="branchInputVisible" class="picker-mask" @click="closeBranchInput">
<view class="branch-panel" @click.stop>
<view class="picker-header">
<text class="picker-title">填写开户支行</text>
<text class="picker-close" @click="closeBranchInput">✕</text>
</view>
<view class="branch-input-wrap">
<input
class="branch-input"
type="text"
v-model="branchInputTemp"
placeholder="例:招商银行上海浦东支行"
placeholder-style="color:#c0c4cc"
:maxlength="50"
/>
</view>
<view class="branch-confirm-btn" @click="confirmBranch">
<text class="branch-confirm-text">确认</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, reactive } from 'vue'
import { supabaseService } from '@/utils/supabaseService.uts'
<script lang="uts">
import {
BANK_LIST,
BankItem,
detectBankByBin,
formatCardNo,
cleanCardNo,
luhnCheck,
isValidPhone,
recognizeBankCardFromImage
} from '@/utils/bankUtils.uts'
type BankCardForm = {
holder_name: string
card_no: string
bank_name: string
phone: string
is_default: boolean
type AddCardForm = {
holder_name: string
card_no: string // 纯数字,提交时用
bank_name: string
bank_code: string
branch_name: string
phone: string
}
const loading = ref(false)
const form = reactive({
holder_name: '',
card_no: '',
bank_name: '',
phone: '',
is_default: false
} as BankCardForm)
const onSwitchChange = (e: UniSwitchChangeEvent) => {
form.is_default = e.detail.value
}
// 模拟卡号识别
const detectBank = (e: any) => {
const val = form.card_no
if (val.length >= 6) {
if (val.startsWith('6222')) form.bank_name = '中国工商银行'
else if (val.startsWith('6227')) form.bank_name = '中国建设银行'
else if (val.startsWith('6225')) form.bank_name = '招商银行'
else if (val.startsWith('6228')) form.bank_name = '中国农业银行'
// else form.bank_name = ''
}
}
const submit = async () => {
if (form.holder_name == '' || form.card_no == '' || form.bank_name == '') {
uni.showToast({ title: '请完善卡片信息', icon: 'none' })
return
export default {
data() {
return {
form: {
holder_name: '',
card_no: '',
bank_name: '',
bank_code: '',
branch_name: '',
phone: ''
} as AddCardForm,
cardNoDisplay: '' as string, // 格式化显示(含空格)
bankList: BANK_LIST as BankItem[],
bankPickerVisible: false as boolean,
branchInputVisible: false as boolean,
branchInputTemp: '' as string,
ocrLoading: false as boolean
}
loading.value = true
try {
const cardData = new UTSJSONObject()
cardData.set('holder_name', form.holder_name)
cardData.set('bank_name', form.bank_name)
cardData.set('card_no', form.card_no) // Also save full card no if needed, or just last4
// 截取后4位
const last4 = form.card_no.length > 4 ? form.card_no.slice(-4) : form.card_no
cardData.set('card_no_last4', last4)
cardData.set('phone', form.phone)
cardData.set('is_default', form.is_default)
// 简单推定为储蓄卡
cardData.set('card_type', 'debit')
const success = await supabaseService.addBankCard(cardData)
if (success) {
uni.showToast({ title: '添加成功' })
setTimeout(() => {
uni.navigateBack()
}, 1000)
} else {
uni.showToast({ title: '添加失败', icon: 'none' })
},
computed: {
canSubmit(): boolean {
return (
this.form.holder_name.trim().length > 0 &&
cleanCardNo(this.cardNoDisplay).length >= 16 &&
this.form.bank_name.length > 0 &&
isValidPhone(this.form.phone)
)
}
},
methods: {
// ── 持卡人提示 ──────────────────────────────
showHolderTip() {
uni.showToast({
title: '请填写银行卡开户人姓名,需与实名认证一致',
icon: 'none',
duration: 3000
})
},
// ── 卡号输入:格式化 + BIN 识别 ─────────────
onCardNoInput() {
const raw = cleanCardNo(this.cardNoDisplay)
// 重新格式化
this.cardNoDisplay = formatCardNo(raw)
this.form.card_no = raw
// BIN 识别(输入 6 位后触发)
if (raw.length >= 6) {
const bank = detectBankByBin(raw)
if (bank != null && this.form.bank_name === '') {
this.form.bank_name = bank.name
this.form.bank_code = bank.code
}
} catch (e) {
console.error(e)
uni.showToast({ title: '系统错误', icon: 'none' })
} finally {
loading.value = false
}
},
// ── OCR 识别 ────────────────────────────────
async doOCR() {
if (this.ocrLoading) return
this.ocrLoading = true
try {
const result = await recognizeBankCardFromImage()
if (result.success === true) {
if (result.cardNo != null && result.cardNo !== '') {
this.form.card_no = result.cardNo
this.cardNoDisplay = formatCardNo(result.cardNo)
}
if (result.bankName != null && result.bankName !== '' && this.form.bank_name === '') {
this.form.bank_name = result.bankName
}
uni.showToast({ title: '识别成功', icon: 'success' })
} else if (result.errorMsg !== '取消选择') {
uni.showToast({ title: (result.errorMsg != null && result.errorMsg !== '' ? result.errorMsg : 'OCR 识别失败'), icon: 'none' })
}
} catch (e) {
console.error('[add] OCR 异常:', e)
uni.showToast({ title: 'OCR 出现异常', icon: 'none' })
} finally {
this.ocrLoading = false
}
},
// ── 银行选择弹层 ─────────────────────────────
openBankPicker() {
this.bankPickerVisible = true
},
closeBankPicker() {
this.bankPickerVisible = false
},
selectBank(bank: BankItem) {
this.form.bank_name = bank.name
this.form.bank_code = bank.code
this.bankPickerVisible = false
},
// ── 支行输入弹层 ─────────────────────────────
openBranchInput() {
this.branchInputTemp = this.form.branch_name
this.branchInputVisible = true
},
closeBranchInput() {
this.branchInputVisible = false
},
confirmBranch() {
this.form.branch_name = this.branchInputTemp.trim()
this.branchInputVisible = false
},
// ── 下一步:跳转到身份验证页 ─────────────────
goVerify() {
if (!this.canSubmit) {
if (this.form.holder_name.trim() === '') {
uni.showToast({ title: '请输入持卡人姓名', icon: 'none' })
} else if (cleanCardNo(this.cardNoDisplay).length < 16) {
uni.showToast({ title: '请输入正确的银行卡号', icon: 'none' })
} else if (this.form.bank_name === '') {
uni.showToast({ title: '请选择开户银行', icon: 'none' })
} else if (!isValidPhone(this.form.phone)) {
uni.showToast({ title: '请输入正确的手机号码', icon: 'none' })
}
return
}
// Luhn 校验(可选拦截,不强制)
const rawCardNo = cleanCardNo(this.cardNoDisplay)
if (!luhnCheck(rawCardNo)) {
uni.showModal({
title: '卡号确认',
content: '银行卡号格式可能有误,是否继续?',
success: (res) => {
if (res.confirm) {
this.navigateToVerify()
}
}
})
return
}
this.navigateToVerify()
},
navigateToVerify() {
const rawCardNo = cleanCardNo(this.cardNoDisplay)
const params = encodeURIComponent(JSON.stringify({
holder_name: this.form.holder_name,
card_no: rawCardNo,
bank_name: this.form.bank_name,
bank_code: this.form.bank_code,
branch_name: this.form.branch_name,
phone: this.form.phone
}))
uni.navigateTo({
url: `/pages/mall/consumer/bank-cards/verify?data=${params}`
})
}
}
}
</script>
<style>
.add-card-page {
/* ── 整体结构 ────────────────────────────── */
.page {
flex: 1;
background-color: #f5f5f5;
flex: 1;
display: flex;
flex-direction: column;
}
.form-container {
background-color: #fff;
padding: 0 15px;
.scroll-wrap {
flex: 1;
}
.form-item {
/* ── 提示横幅 ─────────────────────────────── */
.tip-banner {
display: flex;
flex-direction: row;
align-items: center;
background-color: #fff7f0;
padding: 20rpx 32rpx;
margin-bottom: 20rpx;
}
.tip-icon {
font-size: 32rpx;
margin-right: 12rpx;
}
.tip-text {
font-size: 24rpx;
color: #ff6000;
}
/* ── 表单卡片 ────────────────────────────── */
.form-card {
background-color: #ffffff;
border-radius: 16rpx;
margin: 0 24rpx;
overflow: hidden;
}
.form-row {
display: flex;
flex-direction: row;
align-items: center;
min-height: 112rpx;
padding: 0 28rpx;
}
.selector-row {
/* 点击区域整行 */
}
.row-left {
display: flex;
flex-direction: row;
align-items: center;
width: 180rpx;
flex-shrink: 0;
}
.row-label {
font-size: 28rpx;
color: #222222;
font-weight: 500;
}
.optional-badge {
font-size: 20rpx;
color: #999999;
margin-left: 8rpx;
background-color: #f5f5f5;
padding: 2rpx 10rpx;
border-radius: 8rpx;
}
.row-right {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
}
.row-input {
flex: 1;
font-size: 28rpx;
color: #222222;
height: 80rpx;
line-height: 80rpx;
}
.row-tip-icon {
font-size: 36rpx;
color: #aaaaaa;
padding: 0 8rpx;
}
.camera-btn {
font-size: 42rpx;
padding: 0 4rpx;
color: #555555;
}
.selector-value-wrap {
flex: 1;
}
.selector-value {
font-size: 28rpx;
color: #222222;
}
.selector-value.placeholder {
color: #c0c4cc;
}
.arrow-icon {
font-size: 40rpx;
color: #cccccc;
padding-left: 8rpx;
}
.divider {
height: 1rpx;
background-color: #f2f2f2;
margin-left: 28rpx;
}
/* ── 安全说明 ────────────────────────────── */
.desc-area {
padding: 24rpx 32rpx 0;
}
.desc-text {
font-size: 22rpx;
color: #aaaaaa;
line-height: 1.7;
}
.bottom-placeholder {
height: 160rpx;
}
/* ── 底部按钮栏 ──────────────────────────── */
.bottom-bar {
background-color: #ffffff;
padding: 20rpx 32rpx 0;
border-top-width: 1rpx;
border-top-style: solid;
border-top-color: #f0f0f0;
}
.next-btn {
border-radius: 56rpx;
height: 96rpx;
display: flex;
align-items: center;
padding: 15px 0;
border-bottom: 1px solid #eee;
justify-content: center;
}
.form-item:last-child {
border-bottom: none;
.next-btn-active {
background-color: #e23636;
}
.label {
width: 80px;
font-size: 15px;
color: #333;
.next-btn-disabled {
background-color: #f5a0a0;
}
.input {
flex: 1;
font-size: 15px;
.next-btn-text {
font-size: 32rpx;
color: #ffffff;
font-weight: 600;
}
.switch-item {
/* ── 弹层背景蒙层 ────────────────────────── */
.picker-mask {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.45);
z-index: 999;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
/* ── 银行选择面板 ────────────────────────── */
.picker-panel {
background-color: #ffffff;
border-top-left-radius: 24rpx;
border-top-right-radius: 24rpx;
max-height: 75%;
display: flex;
flex-direction: column;
}
.picker-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 32rpx 32rpx 20rpx;
border-bottom-width: 1rpx;
border-bottom-style: solid;
border-bottom-color: #f0f0f0;
}
.action-section {
padding: 30px 15px;
.picker-title {
font-size: 32rpx;
font-weight: bold;
color: #222222;
}
.submit-btn {
background-color: #ff5000;
color: #fff;
border-radius: 25px;
font-size: 16px;
.picker-close {
font-size: 36rpx;
color: #999999;
padding: 8rpx;
}
.submit-btn.disabled {
opacity: 0.6;
.picker-scroll {
flex: 1;
}
.picker-item {
display: flex;
flex-direction: row;
align-items: center;
padding: 28rpx 32rpx;
border-bottom-width: 1rpx;
border-bottom-style: solid;
border-bottom-color: #f7f7f7;
}
.picker-item-active {
background-color: #fff5f5;
}
.bank-logo-placeholder {
width: 72rpx;
height: 72rpx;
border-radius: 36rpx;
background-color: #e23636;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
flex-shrink: 0;
}
.bank-logo-text {
font-size: 22rpx;
color: #ffffff;
font-weight: bold;
}
.picker-item-name {
flex: 1;
font-size: 28rpx;
color: #333333;
}
.picker-check {
font-size: 32rpx;
color: #e23636;
font-weight: bold;
}
/* ── 支行输入面板 ────────────────────────── */
.branch-panel {
background-color: #ffffff;
border-top-left-radius: 24rpx;
border-top-right-radius: 24rpx;
padding-bottom: 40rpx;
}
.branch-input-wrap {
margin: 24rpx 32rpx;
background-color: #f7f7f7;
border-radius: 12rpx;
padding: 0 24rpx;
}
.branch-input {
height: 96rpx;
font-size: 28rpx;
color: #222222;
width: 100%;
}
.branch-confirm-btn {
margin: 0 32rpx;
height: 88rpx;
background-color: #e23636;
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
}
.branch-confirm-text {
font-size: 30rpx;
color: #ffffff;
font-weight: 600;
}
</style>

View File

@@ -105,20 +105,23 @@ const addCard = () => {
})
}
const doDeleteCard = async (card: BankCard): Promise<void> => {
const success = await supabaseService.deleteBankCard(card.id)
if (success) {
uni.showToast({ title: '已删除' })
loadData()
} else {
uni.showToast({ title: '删除失败', icon: 'none' })
}
}
const deleteCard = (card: BankCard) => {
uni.showModal({
title: '删除银行卡',
content: `确认删除尾号${card.card_no_last4}的${card.bank_name}卡片吗?`,
success: (res) => {
if (res.confirm) {
supabaseService.deleteBankCard(card.id).then((success) => {
if (success) {
uni.showToast({ title: '已删除' })
loadData()
} else {
uni.showToast({ title: '删除失败', icon: 'none' })
}
})
doDeleteCard(card)
}
}
})

View File

@@ -0,0 +1,558 @@
<template>
<!-- 身份验证页(绑定银行卡 Step 2 -->
<view class="page">
<scroll-view direction="vertical" class="scroll-wrap">
<!-- ① 银行卡信息卡片 -->
<view class="bank-info-card">
<view class="bank-logo-circle">
<text class="bank-logo-text">{{ bankShortName }}</text>
</view>
<view class="bank-info-body">
<text class="bank-info-name">{{ bankDisplayName }}</text>
<text class="bank-info-no">{{ maskedCardNo }}</text>
</view>
</view>
<!-- ② 验证方式 Tab -->
<view class="verify-method-tabs">
<view class="method-tab method-tab-active">
<text class="method-tab-text method-tab-text-active">短信验证</text>
</view>
<view class="method-tab method-tab-disabled">
<text class="method-tab-text method-tab-text-disabled">转账验证(暂不支持)</text>
</view>
</view>
<!-- ③ 验证表单白卡 -->
<view class="form-card">
<!-- 手机号 + 获取验证码 -->
<view class="form-row">
<view class="row-left">
<text class="row-label">手机号</text>
</view>
<view class="row-right">
<text class="phone-display">{{ maskedPhone }}</text>
</view>
</view>
<view class="divider" />
<!-- 验证码 -->
<view class="form-row">
<view class="row-left">
<text class="row-label">验证码</text>
</view>
<view class="row-right">
<input
class="row-input"
type="number"
v-model="smsCode"
placeholder="请输入验证码"
placeholder-style="color:#c0c4cc;font-size:28rpx"
:maxlength="6"
/>
<view
class="sms-btn"
:class="countdown > 0 ? 'sms-btn-disabled' : 'sms-btn-active'"
@click="getSmsCode"
>
<text class="sms-btn-text">{{ countdown > 0 ? countdown + 's后重发' : '获取验证码' }}</text>
</view>
</view>
</view>
</view>
<!-- ④ 底部说明 -->
<view class="desc-area">
<text class="desc-text">
验证码将发送至银行预留手机号 {{ maskedPhone }}。若手机号有误,请返回修改。
</text>
</view>
<view class="bottom-placeholder" />
</scroll-view>
<!-- ⑤ 底部操作栏 -->
<view class="bottom-bar">
<view
class="confirm-btn"
:class="canConfirm ? 'confirm-btn-active' : 'confirm-btn-disabled'"
@click="doVerifyAndBind"
>
<text class="confirm-btn-text">{{ submitting ? '绑定中...' : '验证并绑定' }}</text>
</view>
<view class="back-modify" @click="goBack">
<text class="back-modify-text">返回修改</text>
</view>
<view style="height: env(safe-area-inset-bottom); min-height: 8rpx;" />
</view>
</view>
</template>
<script lang="uts">
import {
maskCardNo,
isValidPhone,
sendSmsCode,
verifySmsCode
} from '@/utils/bankUtils.uts'
import { supabaseService } from '@/utils/supabaseService.uts'
type CardParams = {
holder_name: string
card_no: string
bank_name: string
bank_code: string
branch_name: string
phone: string
}
export default {
data() {
return {
cardParams: {
holder_name: '',
card_no: '',
bank_name: '',
bank_code: '',
branch_name: '',
phone: ''
} as CardParams,
smsCode: '' as string,
countdown: 0 as number,
countdownTimer: null as number | null,
submitting: false as boolean
}
},
computed: {
maskedCardNo(): string {
const cardNo = this.cardParams.card_no != null ? this.cardParams.card_no : ''
return maskCardNo(cardNo)
},
maskedPhone(): string {
const p = this.cardParams.phone != null ? this.cardParams.phone : ''
if (p.length < 7) return p
return p.substring(0, 3) + '****' + p.substring(7)
},
bankShortName(): string {
const name = this.cardParams.bank_name != null ? this.cardParams.bank_name : ''
if (name === '') return '银行'
// 取前两个汉字作为 logo 占位文字
return name.length >= 2 ? name.substring(0, 2) : name
},
bankDisplayName(): string {
return this.cardParams.bank_name != null ? this.cardParams.bank_name : ''
},
canConfirm(): boolean {
return this.smsCode.length === 6 && !this.submitting
}
},
onLoad(options) {
if (options == null) {
return
}
const optionsObj = options as UTSJSONObject
const dataStr = optionsObj.getString('data') ?? ''
if (dataStr != '') {
try {
const decodedValue = decodeURIComponent(dataStr)
const decoded = decodedValue != null ? decodedValue.toString() : dataStr
const parsedObj = JSON.parse(decoded) as UTSJSONObject
this.cardParams = {
holder_name: parsedObj.getString('holder_name') ?? '',
card_no: parsedObj.getString('card_no') ?? '',
bank_name: parsedObj.getString('bank_name') ?? '',
bank_code: parsedObj.getString('bank_code') ?? '',
branch_name: parsedObj.getString('branch_name') ?? '',
phone: parsedObj.getString('phone') ?? ''
} as CardParams
} catch (e) {
console.error('[verify] 解析参数失败:', e)
uni.showToast({ title: '页面参数异常', icon: 'none' })
}
}
},
onUnmounted() {
this.clearCountdown()
},
methods: {
// ── 获取验证码 ─────────────────────────────
async getSmsCode() {
if (this.countdown > 0) return
if (!isValidPhone(this.cardParams.phone)) {
uni.showToast({ title: '手机号格式有误', icon: 'none' })
return
}
uni.showLoading({ title: '发送中...' })
try {
const result = await sendSmsCode(this.cardParams.phone)
uni.hideLoading()
if (result.success === true) {
uni.showToast({ title: '验证码已发送', icon: 'success' })
this.startCountdown(60)
} else {
uni.showToast({ title: (result.errorMsg != null && result.errorMsg !== '' ? result.errorMsg : '发送失败'), icon: 'none' })
}
} catch (e) {
uni.hideLoading()
console.error('[verify] 发送验证码异常:', e)
uni.showToast({ title: '发送异常,请重试', icon: 'none' })
}
},
startCountdown(seconds: number) {
this.countdown = seconds
this.countdownTimer = setInterval(() => {
if (this.countdown > 0) {
this.countdown--
} else {
this.clearCountdown()
}
}, 1000) as number
},
clearCountdown() {
if (this.countdownTimer != null) {
clearInterval(this.countdownTimer!)
this.countdownTimer = null
}
},
// ── 验证并绑定 ─────────────────────────────
async doVerifyAndBind() {
if (!this.canConfirm) {
if (this.smsCode.length < 6) {
uni.showToast({ title: '请输入6位验证码', icon: 'none' })
}
return
}
this.submitting = true
uni.showLoading({ title: '验证中...' })
try {
// 1. 校验验证码
const verifyResult = await verifySmsCode(this.cardParams.phone, this.smsCode)
if (verifyResult.success !== true) {
uni.hideLoading()
uni.showToast({ title: (verifyResult.errorMsg != null && verifyResult.errorMsg !== '' ? verifyResult.errorMsg : '验证码错误'), icon: 'none' })
this.submitting = false
return
}
// 2. 提交绑卡到数据库
// 只写入 ml_user_bank_cards 表中已确认存在的列:
// holder_name / bank_name / card_no_last4 / phone / card_type / is_default
// 扩展字段bank_code / branch_name / verified_status / verified_at
// 待数据库执行对应 ALTER TABLE 后再取消注释
const rawCardNo = this.cardParams.card_no
const last4 = rawCardNo.length > 4 ? rawCardNo.substring(rawCardNo.length - 4) : rawCardNo
const cardData = new UTSJSONObject()
cardData.set('holder_name', this.cardParams.holder_name)
cardData.set('bank_name', this.cardParams.bank_name)
cardData.set('card_no_last4', last4)
cardData.set('phone', this.cardParams.phone)
cardData.set('card_type', 'debit')
cardData.set('is_default', false)
// ── 扩展字段(取消注释前请先执行 SQL 添加对应列) ──
// cardData.set('bank_code', this.cardParams.bank_code || '')
// if (this.cardParams.branch_name) {
// cardData.set('branch_name', this.cardParams.branch_name)
// }
// cardData.set('verified_status', 'verified')
// cardData.set('verified_at', new Date().toISOString())
const success = await supabaseService.addBankCard(cardData)
uni.hideLoading()
if (success) {
// 写入信号:让 finance-management 的 onShow 能同步读取并立即更新 UI
// 避免因异步 DB 查询时序问题导致页面返回时看到旧状态
uni.setStorageSync('__bank_just_bound__', '1')
uni.setStorageSync('__bound_bank_name__', this.cardParams.bank_name)
uni.setStorageSync('__bound_card_last4__', last4)
uni.showToast({ title: '绑定成功', icon: 'success' })
setTimeout(() => {
// 返回银行卡列表页,并跳过 add 页(跳 2 层)
const pages = getCurrentPages()
if (pages.length >= 2) {
uni.navigateBack({ delta: 2 })
} else {
uni.navigateBack()
}
}, 1200)
} else {
uni.showToast({ title: '绑定失败,请重试', icon: 'none' })
}
} catch (e) {
uni.hideLoading()
console.error('[verify] 绑定异常:', e)
uni.showToast({ title: '系统异常,请重试', icon: 'none' })
} finally {
this.submitting = false
}
},
// ── 返回修改 ───────────────────────────────
goBack() {
uni.navigateBack()
}
}
}
</script>
<style>
/* ── 整体结构 ────────────────────────────── */
.page {
flex: 1;
background-color: #f5f5f5;
display: flex;
flex-direction: column;
}
.scroll-wrap {
flex: 1;
}
/* ── 银行卡信息卡片 ──────────────────────── */
.bank-info-card {
background-color: #ffffff;
margin: 24rpx 24rpx 0;
border-radius: 16rpx;
padding: 32rpx 28rpx;
display: flex;
flex-direction: row;
align-items: center;
}
.bank-logo-circle {
width: 88rpx;
height: 88rpx;
border-radius: 44rpx;
background-color: #e23636;
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
flex-shrink: 0;
}
.bank-logo-text {
font-size: 26rpx;
color: #ffffff;
font-weight: bold;
}
.bank-info-body {
flex: 1;
display: flex;
flex-direction: column;
}
.bank-info-name {
font-size: 30rpx;
color: #222222;
font-weight: 600;
margin-bottom: 8rpx;
}
.bank-info-no {
font-size: 26rpx;
color: #888888;
letter-spacing: 2rpx;
}
/* ── 验证方式 Tabs ───────────────────────── */
.verify-method-tabs {
display: flex;
flex-direction: row;
margin: 20rpx 24rpx 0;
background-color: #f0f0f0;
border-radius: 12rpx;
padding: 6rpx;
}
.method-tab {
flex: 1;
height: 64rpx;
border-radius: 10rpx;
display: flex;
align-items: center;
justify-content: center;
}
.method-tab-active {
background-color: #ffffff;
}
.method-tab-disabled {
background-color: transparent;
}
.method-tab-text {
font-size: 26rpx;
}
.method-tab-text-active {
color: #e23636;
font-weight: 600;
}
.method-tab-text-disabled {
color: #aaaaaa;
}
/* ── 表单卡片 ────────────────────────────── */
.form-card {
background-color: #ffffff;
border-radius: 16rpx;
margin: 20rpx 24rpx 0;
overflow: hidden;
}
.form-row {
display: flex;
flex-direction: row;
align-items: center;
min-height: 112rpx;
padding: 0 28rpx;
}
.row-left {
width: 160rpx;
flex-shrink: 0;
}
.row-label {
font-size: 28rpx;
color: #222222;
font-weight: 500;
}
.row-right {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
}
.phone-display {
font-size: 28rpx;
color: #333333;
flex: 1;
}
.row-input {
flex: 1;
font-size: 28rpx;
color: #222222;
height: 80rpx;
line-height: 80rpx;
}
.divider {
height: 1rpx;
background-color: #f2f2f2;
margin-left: 28rpx;
}
/* ── 获取验证码按钮 ──────────────────────── */
.sms-btn {
height: 64rpx;
padding: 0 24rpx;
border-radius: 32rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.sms-btn-active {
background-color: #e23636;
}
.sms-btn-disabled {
background-color: #f5a0a0;
}
.sms-btn-text {
font-size: 24rpx;
color: #ffffff;
white-space: nowrap;
}
/* ── 说明文字 ────────────────────────────── */
.desc-area {
padding: 24rpx 32rpx 0;
}
.desc-text {
font-size: 22rpx;
color: #aaaaaa;
line-height: 1.7;
}
.bottom-placeholder {
height: 200rpx;
}
/* ── 底部操作栏 ──────────────────────────── */
.bottom-bar {
background-color: #ffffff;
padding: 20rpx 32rpx 0;
border-top-width: 1rpx;
border-top-style: solid;
border-top-color: #f0f0f0;
}
.confirm-btn {
border-radius: 56rpx;
height: 96rpx;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20rpx;
}
.confirm-btn-active {
background-color: #e23636;
}
.confirm-btn-disabled {
background-color: #f5a0a0;
}
.confirm-btn-text {
font-size: 32rpx;
color: #ffffff;
font-weight: 600;
}
.back-modify {
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
}
.back-modify-text {
font-size: 28rpx;
color: #888888;
}
</style>

View File

@@ -0,0 +1,16 @@
<template>
<view />
</template>
<script>
export default {
onLoad(options) {
const qs = options ? Object.keys(options).map(k => `${k}=${encodeURIComponent(options[k])}`).join('&') : '';
const url = '/pages/main/category' + (qs ? `?${qs}` : '');
uni.navigateTo({ url });
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,479 @@
<template>
<view class="channel-detail-page">
<view class="channel-topbar" :style="topbarStyle">
<view class="channel-topbar-row" :style="topbarRowStyle">
<view class="channel-topbar-back" @tap="goBack">
<text class="channel-topbar-back-text"></text>
</view>
<view class="channel-topbar-title-wrap" :style="titleWrapStyle">
<text class="channel-topbar-title">{{ pageTitle }}</text>
</view>
<view class="channel-topbar-space" :style="topbarSpaceStyle"></view>
</view>
</view>
<scroll-view class="channel-detail-scroll" direction="vertical">
<view v-if="channel != null" class="channel-detail-header" :style="{ backgroundColor: channelBgColor }">
<view class="channel-detail-title-row">
<text class="channel-detail-title">{{ channelTitle }}</text>
<text class="channel-detail-badge" :style="{ color: channelThemeColor, borderColor: channelThemeColor }">{{ channelBadge }}</text>
</view>
<text class="channel-detail-subtitle">{{ channelSubtitle }}</text>
</view>
<view v-if="channel != null" class="channel-section-card">
<text class="channel-section-title">频道精选</text>
<view class="channel-product-grid">
<view
v-for="(product, index) in seedProducts"
:key="buildChannelKey('seed', product.id, index)"
class="channel-product-card"
@tap="handleMockProductClick(product)"
>
<image class="channel-product-cover" :src="getChannelProductImage(product)" mode="aspectFill" />
<text class="channel-product-title">{{ product.name }}</text>
<view class="channel-product-price-row">
<text class="channel-product-tag" :style="{ color: channelThemeColor }">{{ product.tag }}</text>
<text class="channel-product-price" :style="{ color: channelThemeColor }">¥{{ formatChannelPrice(product.price) }}</text>
</view>
<text v-if="product.marketPrice > product.price" class="channel-product-market-price">¥{{ formatChannelPrice(product.marketPrice) }}</text>
</view>
</view>
</view>
<view v-if="channel != null && moreProducts.length > 0" class="channel-section-card">
<text class="channel-section-title">更多好物</text>
<view class="channel-product-grid">
<view
v-for="(product, index) in moreProducts"
:key="buildChannelKey('more', product.id, index)"
class="channel-product-card"
@tap="handleMockProductClick(product)"
>
<image class="channel-product-cover" :src="getChannelProductImage(product)" mode="aspectFill" />
<text class="channel-product-title">{{ product.name }}</text>
<view class="channel-product-price-row">
<text class="channel-product-tag" :style="{ color: channelThemeColor }">{{ product.tag }}</text>
<text class="channel-product-price" :style="{ color: channelThemeColor }">¥{{ formatChannelPrice(product.price) }}</text>
</view>
<text v-if="product.marketPrice > product.price" class="channel-product-market-price">¥{{ formatChannelPrice(product.marketPrice) }}</text>
</view>
</view>
</view>
<view v-if="channel == null" class="channel-empty-state">
<text class="channel-empty-title">频道信息暂不可用</text>
<text class="channel-empty-desc">请返回首页重新进入频道</text>
</view>
<view class="channel-safe-area"></view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getChannelDetailData } from '@/utils/mockChannelData.uts'
import type { MarketingChannel, ChannelProduct } from '@/utils/mockChannelData.uts'
const channel = ref<MarketingChannel | null>(null)
const seedProducts = ref<ChannelProduct[]>([])
const moreProducts = ref<ChannelProduct[]>([])
const pageTitle = ref('频道详情')
const channelTitle = ref('频道详情')
const channelSubtitle = ref('')
const channelBadge = ref('')
const channelThemeColor = ref('#ff6b35')
const channelBgColor = ref('#f5f5f5')
const statusBarHeight = ref(0)
const navBarTop = ref(0)
const navBarHeight = ref(44)
const navRightReserve = ref(0)
const topbarStyle = ref('padding-top:0px;height:88rpx;')
const topbarRowStyle = ref('padding-left:20rpx;padding-right:12px;')
const titleWrapStyle = ref('left:0px;right:0px;')
const topbarSpaceStyle = ref('width:64rpx;height:64rpx;')
function syncTopbarStyle(): void {
const top = navBarTop.value > 0 ? navBarTop.value : statusBarHeight.value
const height = navBarHeight.value > 0 ? navBarHeight.value : 44
const rightReserve = navRightReserve.value > 0 ? navRightReserve.value : 12
topbarStyle.value = 'padding-top:' + top + 'px;height:' + (top + height + 12) + 'px;'
topbarRowStyle.value = 'padding-left:20rpx;padding-right:' + rightReserve + 'px;'
titleWrapStyle.value = 'left:0px;right:0px;'
topbarSpaceStyle.value = 'width:' + rightReserve + 'px;height:64rpx;'
}
function initPageLayout(): void {
try {
const windowInfo = uni.getWindowInfo()
statusBarHeight.value = windowInfo.statusBarHeight ?? 0
navBarTop.value = statusBarHeight.value
navBarHeight.value = 44
navRightReserve.value = 12
// #ifdef MP-WEIXIN
try {
const menuBtn = uni.getMenuButtonBoundingClientRect()
if (menuBtn != null && menuBtn.top != null && menuBtn.top > 0) {
navBarTop.value = menuBtn.top
navBarHeight.value = menuBtn.height
navRightReserve.value = (windowInfo.windowWidth - menuBtn.left) + 8
}
} catch (e) {
navBarTop.value = statusBarHeight.value
navBarHeight.value = 44
navRightReserve.value = 12
}
// #endif
} catch (e) {
statusBarHeight.value = 0
navBarTop.value = 0
navBarHeight.value = 44
navRightReserve.value = 12
}
syncTopbarStyle()
}
function buildChannelKey(prefix: string, id: string, index: number): string {
const normalizedId = id !== '' ? id : 'empty'
return prefix + '-' + normalizedId + '-' + index.toString()
}
function formatChannelPrice(price: number): string {
const rounded = Math.round(price)
if (Math.abs(price - rounded) < 0.001) {
return rounded.toString()
}
return price.toFixed(1)
}
function getChannelProductImage(product: ChannelProduct): string {
if (product.image != null && product.image !== '') {
return product.image
}
return '/static/images/default.png'
}
function applyChannel(channelData: MarketingChannel | null): void {
channel.value = channelData
if (channelData == null) {
seedProducts.value = []
moreProducts.value = []
pageTitle.value = '频道详情'
channelTitle.value = '频道详情'
channelSubtitle.value = ''
channelBadge.value = ''
channelThemeColor.value = '#ff6b35'
channelBgColor.value = '#f5f5f5'
return
}
pageTitle.value = channelData.title
channelTitle.value = channelData.title
channelSubtitle.value = channelData.subtitle
channelBadge.value = channelData.badge
channelThemeColor.value = channelData.themeColor
channelBgColor.value = channelData.bgColor
seedProducts.value = channelData.products
moreProducts.value = []
setTimeout(() => {
moreProducts.value = channelData.moreProducts
}, 30)
}
function handleMockProductClick(product: ChannelProduct): void {
uni.showToast({
title: product.shortName + ' 为 mock 商品',
icon: 'none'
})
}
function decodeQueryValue(value: string, fallbackValue: string): string {
if (value === '') {
return fallbackValue
}
try {
const decodedValue = decodeURIComponent(value)
if (decodedValue != null && decodedValue !== '') {
return decodedValue
}
return fallbackValue
} catch (e) {
return value !== '' ? value : fallbackValue
}
}
function normalizeLoadOptions(options: UTSJSONObject | null): UTSJSONObject {
if (options == null) {
return new UTSJSONObject()
}
if (options instanceof UTSJSONObject) {
return options
}
try {
return JSON.parse(JSON.stringify(options)) as UTSJSONObject
} catch (e) {
return new UTSJSONObject()
}
}
function resolveQueryValue(options: UTSJSONObject, key: string): string {
const value = options.getString(key) ?? ''
if (value !== '') {
return value
}
const pages = getCurrentPages()
if (pages.length > 0) {
const currentPage = pages[pages.length - 1]
const rawPageOptions = currentPage.options ?? null
const pageOptions = normalizeLoadOptions(rawPageOptions as UTSJSONObject | null)
return pageOptions.getString(key) ?? ''
}
return ''
}
function goBack(): void {
uni.navigateBack({ delta: 1 })
}
onLoad((options) => {
initPageLayout()
let channelId = 'subsidy'
let routeType = 'subsidy'
let categoryId = 'recommend'
try {
const optionObj = normalizeLoadOptions(options as UTSJSONObject | null)
channelId = decodeQueryValue(resolveQueryValue(optionObj, 'channelId'), 'subsidy')
routeType = decodeQueryValue(resolveQueryValue(optionObj, 'routeType'), 'subsidy')
categoryId = decodeQueryValue(resolveQueryValue(optionObj, 'categoryId'), 'recommend')
} catch (e) {
channelId = 'subsidy'
routeType = 'subsidy'
categoryId = 'recommend'
}
applyChannel(getChannelDetailData(channelId, routeType, categoryId))
})
</script>
<style>
.channel-detail-page {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #f5f5f5;
display: flex;
flex-direction: column;
}
.channel-topbar {
box-sizing: border-box;
background: #f5f5f5;
flex-shrink: 0;
}
.channel-topbar-row {
box-sizing: border-box;
height: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
position: relative;
}
.channel-topbar-back {
flex: 0 0 64rpx;
height: 64rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
}
.channel-topbar-back-text {
font-size: 52rpx;
line-height: 1;
color: #202020;
transform: translateY(-3rpx);
}
.channel-topbar-title-wrap {
position: absolute;
top: 0;
bottom: 0;
width: 100%;
height: 64rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
pointer-events: none;
}
.channel-topbar-title {
font-size: 28rpx;
font-weight: 700;
color: #202020;
line-height: 1.2;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.channel-topbar-space {
flex-shrink: 0;
height: 64rpx;
}
.channel-detail-scroll {
flex: 1;
min-height: 0;
width: 100%;
padding: 20rpx 20rpx 0;
box-sizing: border-box;
}
.channel-detail-header {
border-radius: 28rpx;
padding: 28rpx 24rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 14rpx rgba(0, 0, 0, 0.05);
}
.channel-detail-title-row {
display: flex;
flex-direction: row;
align-items: center;
}
.channel-detail-title {
font-size: 36rpx;
font-weight: 800;
color: #202020;
line-height: 1.2;
margin-right: 10rpx;
}
.channel-detail-badge {
font-size: 20rpx;
line-height: 1.2;
border-width: 1rpx;
border-style: solid;
border-radius: 8rpx;
padding: 4rpx 8rpx;
background: rgba(255, 255, 255, 0.72);
}
.channel-detail-subtitle {
font-size: 24rpx;
color: #666666;
line-height: 1.4;
margin-top: 10rpx;
}
.channel-section-card {
background: #ffffff;
border-radius: 24rpx;
padding: 22rpx;
margin-bottom: 20rpx;
box-shadow: 0 3rpx 10rpx rgba(0, 0, 0, 0.04);
}
.channel-section-title {
font-size: 28rpx;
font-weight: 700;
color: #202020;
line-height: 1.2;
margin-bottom: 16rpx;
}
.channel-product-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
}
.channel-product-card {
width: 48.5%;
background: #fafafa;
border-radius: 18rpx;
padding: 14rpx;
box-sizing: border-box;
margin-bottom: 16rpx;
display: flex;
flex-direction: column;
}
.channel-product-cover {
width: 100%;
height: 220rpx;
border-radius: 16rpx;
background: #ffffff;
}
.channel-product-title {
font-size: 24rpx;
line-height: 1.35;
color: #333333;
height: 66rpx;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 10rpx;
}
.channel-product-price-row {
display: flex;
flex-direction: row;
align-items: center;
margin-top: 8rpx;
}
.channel-product-tag {
font-size: 19rpx;
margin-right: 6rpx;
}
.channel-product-price {
font-size: 28rpx;
font-weight: 800;
line-height: 1.2;
}
.channel-product-market-price {
font-size: 20rpx;
color: #999999;
text-decoration: line-through;
margin-top: 6rpx;
}
.channel-empty-state {
background: #ffffff;
border-radius: 24rpx;
padding: 50rpx 20rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-top: 30rpx;
}
.channel-empty-title {
font-size: 28rpx;
font-weight: 700;
color: #333333;
margin-bottom: 10rpx;
}
.channel-empty-desc {
font-size: 22rpx;
color: #888888;
}
.channel-safe-area {
height: 40rpx;
width: 100%;
}
</style>

View File

@@ -21,7 +21,7 @@
<!-- 聊天内容 -->
<scroll-view
scroll-y="true"
direction="vertical"
class="chat-content"
:scroll-into-view="scrollToView"
:scroll-with-animation="true"
@@ -92,7 +92,7 @@
</view>
<image
class="avatar me"
src="/static/images/default-product.png"
src="/static/images/default.png"
mode="aspectFill"
/>
</view>
@@ -168,7 +168,7 @@ const scrollToView = ref<string>('')
const currentUserId = ref<string>('')
const merchantId = ref<string>('') // 商家ID
const headerTitle = ref<string>('在线客服')
const merchantAvatar = ref<string>('/static/default-shop.png') // 商家头像
const merchantAvatar = ref<string>('/static/images/default.png') // 商家头像
const navPaddingTop = ref<string>('30px') // 默认值,包含状态栏高度+原有内边距
const isInitialLoading = ref<boolean>(true)
let realtimeChannel: AkSupaRealtimeChannel | null = null
@@ -552,7 +552,7 @@ function showImagePicker(): void {
// 处理 tempFilePaths兼容不同平台
let filePath: string = ''
const tempFilePaths = res.tempFilePaths
const tempFilePaths = res.tempFilePaths as string[]
if (tempFilePaths != null) {
if (Array.isArray(tempFilePaths)) {
const arr = tempFilePaths as string[]

View File

@@ -0,0 +1,622 @@
<template>
<view class="chat-page">
<!-- 聊天顶部 -->
<view class="chat-header" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="header-content">
<view class="header-back" @click="goBack">
<text class="back-icon"></text>
</view>
<view class="header-info">
<text class="chat-title">{{ merchantName ?? '在线客服' }}</text>
<text class="chat-status">在线</text>
</view>
<view class="header-actions">
<text class="action-icon" @click="showMoreActions">⋯</text>
</view>
</view>
</view>
<!-- 聊天内容 -->
<scroll-view
scroll-y
class="chat-content"
:scroll-into-view="scrollToView"
scroll-with-animation
@scrolltoupper="loadMoreHistory"
>
<!-- 顶部占位:用于状态栏与标题栏留白 -->
<view :style="{ height: (statusBarHeight + 44) + 'px' }"></view>
<!-- 鑱婂ぉ娑堟伅鍒楄〃 -->
<view class="chat-messages">
<!-- 绯荤粺鎻愮ず -->
<view class="message-item system">
<text class="system-text">已连接到商家,开始会话</text>
</view>
<!-- 娑堟伅椤?-->
<text
v-for="emoji in emojiList"
:key="emoji"
class="emoji-item"
@click="insertEmoji(emoji)">
{{ emoji }}
</text>
<view v-if="shouldShowTime(index)" class="time-divider">
<text>{{ formatTime(message.rawTime) }}</text>
</view>
<!-- 瀵规柟娑堟伅 -->
<view v-if="message.type === 'received'" class="message-wrapper">
<image
class="avatar"
:src="merchantLogo !== '' ? merchantLogo : '/static/logo.png'"
mode="aspectFill"
/>
<view class="message-content-wrapper">
<view class="message-bubble">
<text class="message-text">{{ message.content }}</text>
</view>
</view>
</view>
<!-- 鎴戠殑娑堟伅 -->
<view v-else class="message-wrapper me">
<view class="message-content-wrapper">
<view class="message-bubble me">
<text class="message-text">{{ message.content }}</text>
</view>
</view>
<image
class="avatar me"
:src="userAvatar !== '' ? userAvatar : '/static/images/default-product.png'"
mode="aspectFill"
/>
</view>
</view>
<!-- 底部填充,避免输入框遮挡 -->
<view style="height: 20px;"></view>
<view id="bottom-anchor" style="height: 1px;"></view>
</view>
</scroll-view>
<!-- 聊天输入区 -->
<view class="chat-input-area">
<view class="input-tools">
<text class="tool-icon" @click="toggleEmoji">😊</text>
</view>
<view class="input-wrapper">
<input
class="message-input"
v-model="inputMessage"
placeholder="请输入要发送的消息..."
:adjust-position="true"
confirm-type="send"
@confirm="sendMessage"
/>
<button
class="send-button"
:class="{ active: inputMessage.trim().length > 0 }"
@click="sendMessage"
>
发送
</button>
</view>
<!-- 消息选择器(例如表情) -->
<scroll-view scroll-y v-if="showEmoji" class="emoji-picker">
<view class="emoji-grid">
<text
v-for="emoji in emojiList"
:key="emoji"
class="emoji-item"
@click="insertEmoji(emoji)"
>
{{ emoji }}
</text>
</view>
</scroll-view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onUnmounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import supabaseService from '@/utils/supabaseService.uts'
import type { ChatMessage } from '@/utils/supabaseService.uts'
import supa from '@/components/supadb/aksupainstance.uts'
// 鐣岄潰鐘舵€?
const statusBarHeight = ref(0)
const scrollToView = ref('')
const showEmoji = ref(false)
const inputMessage = ref('')
// 涓氬姟鏁版嵁
const merchantId = ref('')
const merchantName = ref('')
const merchantLogo = ref('')
const userAvatar = ref('')
const currentUserId = ref('')
const messages = ref<any[]>([])
const emojiList = ['😊','🙂','😂','😍','😢','👍','👏','😄','😁','😜','😭','😮','🤔','😎','😅']
let realtimeChannel: any | null = null
onLoad((options: any) => {
// 鑾峰彇鐘舵€佹爮楂樺害
const sys = uni.getSystemInfoSync()
statusBarHeight.value = sys.statusBarHeight ?? 0
// 鑾峰彇鍙傛暟
const optObj = (options instanceof UTSJSONObject) ? (options as UTSJSONObject) : (JSON.parse(JSON.stringify(options ?? {})) as UTSJSONObject)
const mid = optObj.getString('merchantId') ?? ''
if (mid !== '') {
merchantId.value = mid
merchantName.value = optObj.getString('merchantName') ?? '商家'
merchantLogo.value = optObj.getString('merchantLogo') ?? ''
console.log('已初始化会话商家ID:', merchantId.value)
} else {
merchantName.value = '平台客服'
}
// 鑾峰彇褰撳墠鐢ㄦ埛
const uid = supabaseService.getCurrentUserId()
if (uid != null) {
currentUserId.value = uid
// 绠€鍗曡幏鍙栦竴涓嬪ご鍍忥紝瀹為檯搴旇浠嶱rofile鑾峰彇
userAvatar.value = 'https://picsum.photos/100'
}
// 鍔犺浇鍘嗗彶娑堟伅
loadHistory()
// 启动实时订阅
startRealtimeSubscription()
})
onUnmounted(() => {
if (realtimeChannel != null) {
supa.removeChannel(realtimeChannel!)
}
})
// 鍔犺浇鍘嗗彶璁板綍
const loadHistory = async () => {
let rawMsgs: ChatMessage[] = []
if (merchantId.value) {
// 鑾峰彇涓庣壒瀹氬晢瀹剁殑鑱婂ぉ
rawMsgs = await supabaseService.getChatMessages(merchantId.value)
} else {
// 鑾峰彇鎵€鏈夛紙姣斿瀹㈡湇锛?
rawMsgs = await supabaseService.getUserChatMessages()
}
const formatted = rawMsgs.reverse().map((m: ChatMessage) => formatMessage(m))
messages.value = formatted
scrollToBottom()
}
const loadMoreHistory = () => {
// TODO: 瀹炵幇涓嬫媺鍔犺浇鏇村鍘嗗彶
}
// 寮€鍚疄鏃惰闃?
const startRealtimeSubscription = () => {
if (currentUserId.value == '') return
console.log('启动实时订阅')
const filterObj = ({
event: 'INSERT',
schema: 'public',
table: 'ml_chat_messages'
} as UTSJSONObject)
realtimeChannel = supa.channel(`chat_${currentUserId.value}`)
.on(
'postgres_changes',
filterObj,
(payload: any) => {
console.log('收到变更事件', payload)
const payloadObj = (payload instanceof UTSJSONObject) ? (payload as UTSJSONObject) : (JSON.parse(JSON.stringify(payload ?? {})) as UTSJSONObject)
const newAny = payloadObj.get('new')
if (newAny == null) return
const newMsg = (newAny instanceof UTSJSONObject) ? (newAny as UTSJSONObject) : (JSON.parse(JSON.stringify(newAny)) as UTSJSONObject)
// 鍙湁鏉ヨ嚜褰撳墠鑱婂ぉ鐨勫晢瀹剁殑娑堟伅鎵嶆樉绀猴紝鎴栬€呭鏋滄槸鍏ㄥ眬瀹㈡湇妯″紡
const senderId = newMsg.getString('sender_id') ?? ''
if (senderId === merchantId.value || merchantId.value == '') {
const formatted = formatMessage({
id: newMsg.getString('id') ?? '',
content: newMsg.getString('content') ?? '',
msg_type: newMsg.getString('msg_type') ?? '',
sender_id: senderId,
receiver_id: newMsg.getString('receiver_id') ?? '',
is_from_user: false, // 鏀跺埌鐨勪竴瀹氫笉鏄嚜宸卞彂鐨?
created_at: newMsg.getString('created_at') ?? ''
} as ChatMessage)
messages.value.push(formatted)
scrollToBottom()
// 闇囧姩鎻愮ず
uni.vibrateShort({})
}
}
)
.subscribe()
}
// 鏍煎紡鍖栨秷鎭?
const formatMessage = (m: ChatMessage): any => {
// 濡傛灉 sender_id 鏄嚜宸憋紝灏辨槸 'sent'锛屽惁鍒?'received'
// 娉ㄦ剰锛氭暟鎹簱瀛楁 is_from_user 鏈夋椂鍙兘鍙槸鏍囪鏄惁鐢盋绔敤鎴峰彂璧凤紝
// 鏈€鍑嗙‘鐨勬槸瀵规瘮 id
let isMe = false
if (currentUserId.value) {
isMe = m.sender_id === currentUserId.value
} else {
isMe = m.is_from_user === true
}
return {
id: m.id,
type: isMe ? 'sent' : 'received',
content: m.content,
rawTime: m.created_at || new Date().toISOString(),
senderId: m.sender_id
}
}
const sendMessage = async () => {
const text = inputMessage.value.trim()
if (text == '') return
// 涔愯鏇存柊 UI
const tempId = 'temp_' + Date.now()
const tempMsg = {
id: tempId,
type: 'sent',
content: text,
rawTime: new Date().toISOString(),
senderId: currentUserId.value
}
messages.value.push(tempMsg)
inputMessage.value = ''
scrollToBottom()
showEmoji.value = false
// 发送请求
// 娉ㄦ剰锛氬鏋?merchantId 涓虹┖锛宻endChatMessage 绗簩涓弬鏁颁紶 null锛屼細鍙樻垚鏃犱富娑堟伅
const success = await supabaseService.sendChatMessage(text, merchantId.value ? merchantId.value : null)
if (!success) {
uni.showToast({ title: '发送失败', icon: 'none' })
// 这里可以重试或删除临时消息
}
}
const scrollToBottom = () => {
// 寤舵椂婊氬姩浠ョ‘淇濊鍥炬洿鏂?
setTimeout(() => {
scrollToView.value = 'bottom-anchor'
// Hack: 閲嶇疆鍐嶈缃互寮哄埗瑙﹀彂
setTimeout(() => {
scrollToView.value = 'msg-' + (messages.value.length > 0 ? messages.value[messages.value.length-1].id : '')
}, 50)
}, 100)
}
const goBack = () => {
uni.navigateBack({})
}
const formatTime = (isoString: string): string => {
const date = new Date(isoString)
const now = new Date()
// 濡傛灉鏄粖澶╋紝鏄剧ず HH:mm
if (date.toDateString() === now.toDateString()) {
const h = date.getHours().toString().padStart(2, '0')
const m = date.getMinutes().toString().padStart(2, '0')
return `${h}:${m}`
}
// 鍚﹀垯鏄剧ず MM-DD HH:mm
const mo = (date.getMonth() + 1).toString().padStart(2, '0')
const d = date.getDate().toString().padStart(2, '0')
const h = date.getHours().toString().padStart(2, '0')
const m = date.getMinutes().toString().padStart(2, '0')
return `${mo}-${d} ${h}:${m}`
}
const shouldShowTime = (index: number): boolean => {
if (index === 0) return true
const prev = messages.value[index - 1]
const curr = messages.value[index]
const t1 = new Date(prev.rawTime).getTime()
const t2 = new Date(curr.rawTime).getTime()
// 闂撮殧瓒呰繃5鍒嗛挓(300000ms)鏄剧ず鏃堕棿
return (t2 - t1) > 300000
}
const toggleEmoji = () => {
showEmoji.value = !showEmoji.value
if (showEmoji.value) {
scrollToBottom()
}
}
const insertEmoji = (emoji: string) => {
inputMessage.value += emoji
}
const showMoreActions = () => {
uni.showActionSheet({
itemList: ['清空记录', '联系客服'],
success: (res) => {
if (res.tapIndex === 0) {
messages.value = [] // 浠呮湰鍦版竻绌?
}
}
})
}
</script>
<style>
.chat-page {
background-color: #f5f5f5;
height: 100%;
display: flex;
flex-direction: column;
}
.chat-header {
background-color: #fff;
border-bottom: 1px solid #eee;
z-index: 100;
}
.header-content {
height: 44px;
display: flex;
flex-direction: row;
align-items: center;
padding: 0 16px;
justify-content: space-between;
}
.header-back {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: flex-start;
}
.back-icon {
font-size: 32px;
color: #333;
line-height: 1;
}
.header-info {
display: flex;
flex-direction: column;
align-items: center;
}
.chat-title {
font-size: 16px;
font-weight: bold;
color: #333;
}
.chat-status {
font-size: 10px;
color: #4CAF50;
margin-top: 2px;
}
.header-actions {
width: 40px;
display: flex;
justify-content: flex-end;
}
.action-icon {
font-size: 24px;
color: #333;
}
.chat-content {
flex: 1;
/* height: 0; flex grow handles it */
background-color: #f5f5f5;
padding: 10px 0;
}
.chat-messages {
padding: 16px;
padding-bottom: 20px;
display: flex;
flex-direction: column;
}
.time-divider {
display: flex;
justify-content: center;
margin: 16px 0;
}
.time-divider text {
background-color: rgba(0,0,0,0.1);
color: #999;
font-size: 12px;
padding: 4px 8px;
border-radius: 4px;
}
.system {
justify-content: center;
margin-bottom: 16px;
display: flex;
align-items: center;
}
.system-text {
background-color: #e0e0e0;
color: #666;
font-size: 12px;
padding: 4px 12px;
border-radius: 12px;
}
.message-item {
margin-bottom: 16px;
display: flex;
width: 100%;
}
.received {
justify-content: flex-start;
flex-direction: row;
}
.sent {
justify-content: flex-end;
}
.message-wrapper {
display: flex;
flex-direction: row;
max-width: 80%;
align-items: flex-start;
}
.me {
flex-direction: row; /* Keep standard flow but justify-end handles position */
}
.avatar {
width: 40px;
height: 40px;
border-radius: 20px;
background-color: #ddd;
margin-right: 8px;
flex-shrink: 0;
}
.avatar.me {
margin-right: 0;
margin-left: 8px;
}
.message-content-wrapper {
display: flex;
flex-direction: column;
}
.sender-name {
font-size: 12px;
color: #999;
margin-bottom: 4px;
margin-left: 4px;
}
.message-bubble {
background-color: #fff;
padding: 10px 14px;
border-radius: 4px 12px 12px 12px;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
.message-bubble.me {
background-color: #95ec69; /* WeChat green */
border-radius: 12px 4px 12px 12px;
}
.message-text {
font-size: 15px;
color: #333;
line-height: 1.4;
word-break: break-all;
}
.chat-input-area {
background-color: #f9f9f9;
padding: 10px;
border-top: 1px solid #e0e0e0;
padding-bottom: env(safe-area-inset-bottom);
}
.input-wrapper {
display: flex;
flex-direction: row;
align-items: center;
background-color: #fff;
border-radius: 24px;
padding: 8px 12px;
}
.message-input {
flex: 1;
font-size: 16px;
height: 36px;
}
.send-button {
margin-left: 8px;
background-color: #e0e0e0;
color: #999;
font-size: 14px;
padding: 4px 12px;
border-radius: 16px;
border: none;
line-height: 24px;
}
.send-button.active {
background-color: #4CAF50;
color: #fff;
}
.input-tools {
display: flex;
flex-direction: row;
padding-bottom: 8px;
}
.tool-icon {
font-size: 24px;
color: #666;
margin-right: 16px;
padding: 4px;
}
.emoji-picker {
height: 150px;
background-color: #f9f9f9;
border-top: 1px solid #eee;
padding: 10px;
}
.emoji-grid {
display: flex;
flex-wrap: wrap;
flex-direction: row;
}
.emoji-item {
font-size: 24px;
padding: 8px;
margin: 4px;
}
</style>

View File

@@ -1,67 +1,66 @@
<template>
<template>
<view class="chat-page">
<!-- 鑱婂ぉ澶撮儴 -->
<!-- 聊天顶部 -->
<view class="chat-header" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="header-content">
<view class="header-back" @click="goBack">
<text class="back-icon">鈥?/text>
<text class="back-icon"></text>
</view>
<view class="header-info">
<text class="chat-title">{{ merchantName || '鍦ㄧ嚎瀹㈡湇' }}</text>
<text class="chat-status">鍦ㄧ嚎</text>
<text class="chat-title">{{ getDisplayMerchantName() }}</text>
<text class="chat-status">在线</text>
</view>
<view class="header-actions">
<text class="action-icon" @click="showMoreActions">鈰?/text>
<text class="action-icon" @click="showMoreActions">⋯</text>
</view>
</view>
</view>
<!-- 鑱婂ぉ鍐呭 -->
<!-- 聊天内容 -->
<scroll-view
scroll-y
direction="vertical"
class="chat-content"
:scroll-into-view="scrollToView"
scroll-with-animation
@scrolltoupper="loadMoreHistory"
>
<!-- 鍗犱綅锛岄槻姝㈠唴瀹硅澶撮儴閬尅 -->
<!-- 顶部占位:用于状态栏与标题栏留白 -->
<view :style="{ height: (statusBarHeight + 44) + 'px' }"></view>
<!-- 鑱婂ぉ娑堟伅鍒楄〃 -->
<!-- 聊天消息列表 -->
<view class="chat-messages">
<!-- 绯荤粺鎻愮ず -->
<!-- 系统提示 -->
<view class="message-item system">
<text class="system-text">宸茶繛鎺ュ埌鍟嗗锛屽紑濮嬭亰澶╁惂</text>
<text class="system-text">已连接到商家,开始会话</text>
</view>
<!-- 娑堟伅椤?-->
<!-- 消息项 -->
<view
v-for="(message, index) in messages"
:key="message.id"
:class="['message-item', message.type]"
:id="'msg-' + message.id"
>
<!-- 鏃堕棿鏄剧ず閫昏緫锛氭瘡5鍒嗛挓鏄剧ず涓€娆℃椂闂?-->
<view v-if="shouldShowTime(index)" class="time-divider">
<text>{{ formatTime(message.rawTime) }}</text>
</view>
<!-- 瀵规柟娑堟伅 -->
<!-- 对方消息 -->
<view v-if="message.type === 'received'" class="message-wrapper">
<image
class="avatar"
:src="merchantLogo || '/static/logo.png'"
:src="getDisplayMerchantLogo()"
mode="aspectFill"
/>
<view class="message-content-wrapper">
<!-- <text class="sender-name">{{ merchantName }}</text> -->
<view class="message-bubble">
<text class="message-text">{{ message.content }}</text>
</view>
</view>
</view>
<!-- 鎴戠殑娑堟伅 -->
<!-- 我的消息 -->
<view v-else class="message-wrapper me">
<view class="message-content-wrapper">
<view class="message-bubble me">
@@ -70,29 +69,26 @@
</view>
<image
class="avatar me"
:src="userAvatar || '/static/images/default-product.png'"
:src="userAvatar !== '' ? userAvatar : '/static/images/default.png'"
mode="aspectFill"
/>
</view>
</view>
<!-- 搴曢儴濉厖锛岄槻姝㈣杈撳叆妗嗛伄鎸?-->
<!-- 底部填充,避免输入框遮挡 -->
<view style="height: 20px;"></view>
<view id="bottom-anchor" style="height: 1px;"></view>
</view>
</scroll-view>
<!-- 鑱婂ぉ杈撳叆鍖?-->
<view class="chat-input-area">
<view class="input-tools">
<text class="tool-icon" @click="toggleEmoji">馃槉</text>
<!-- <text class="tool-icon" @click="chooseImage">馃摲</text> -->
<text class="tool-icon" @click="toggleEmoji">😊</text>
</view>
<view class="input-wrapper">
<input
class="message-input"
v-model="inputMessage"
placeholder="璇疯緭鍏ユ秷鎭?.."
placeholder="请输入要发送的消息..."
:adjust-position="true"
confirm-type="send"
@confirm="sendMessage"
@@ -102,12 +98,12 @@
:class="{ active: inputMessage.trim().length > 0 }"
@click="sendMessage"
>
鍙戦€?
发送
</button>
</view>
<!-- 琛ㄦ儏閫夋嫨鍣?(绠€鍖栫増) -->
<scroll-view scroll-y v-if="showEmoji" class="emoji-picker">
<!-- 消息选择器(例如表情) -->
<scroll-view direction="vertical" v-if="showEmoji" class="emoji-picker">
<view class="emoji-grid">
<text
v-for="emoji in emojiList"
@@ -124,32 +120,198 @@
</template>
<script setup lang="uts">
import { ref, onMounted, onUnmounted } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { ref, onUnmounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import supabaseService from '@/utils/supabaseService.uts'
import type { ChatMessage } from '@/utils/supabaseService.uts'
import supa from '@/components/supadb/aksupainstance.uts'
// import { getCurrentUser } from '@/utils/store.uts'
import type { AkSupaRealtimeChannel } from '@/components/supadb/aksupa.uts'
// 鐣岄潰鐘舵€?
const statusBarHeight = ref(0)
const scrollToView = ref('')
const showEmoji = ref(false)
const inputMessage = ref('')
type ChatViewMessage = {
id: string
type: string
content: string
rawTime: string
senderId: string
}
// 涓氬姟鏁版嵁
const merchantId = ref('')
const merchantName = ref('')
const merchantLogo = ref('')
const userAvatar = ref('')
const currentUserId = ref('')
const messages = ref<any[]>([])
// 界面状态
const statusBarHeight = ref<number>(0)
const scrollToView = ref<string>('')
const showEmoji = ref<boolean>(false)
const inputMessage = ref<string>('')
const emojiList = ['馃槉', '馃槀', '馃憤', '馃憣', '鉂わ笍', '馃尮', '馃檹', '馃帀', '馃槨', '馃槶', '馃', '馃憢', '馃', '馃拪', '馃彞']
let realtimeChannel: any | null = null
// 业务数据
const merchantId = ref<string>('')
const merchantName = ref<string>('')
const merchantLogo = ref<string>('')
const userAvatar = ref<string>('')
const currentUserId = ref<string>('')
const messages = ref<ChatViewMessage[]>([])
onLoad((options: any) => {
// 鑾峰彇鐘舵€佹爮楂樺害
const emojiList = ['😊','🙂','😂','😍','😢','👍','👏','😄','😁','😜','😭','😮','🤔','😎','😅']
let realtimeChannel: AkSupaRealtimeChannel | null = null
function loadMoreHistory(): void {
// TODO: 实现下拉加载更多历史
}
function scrollToBottom(): void {
// 延时滚动以确保视图更新
setTimeout(() => {
scrollToView.value = 'bottom-anchor'
// Hack: 重置再设置以强制触发
setTimeout(() => {
const lastId = messages.value.length > 0 ? messages.value[messages.value.length - 1].id : ''
scrollToView.value = lastId != '' ? ('msg-' + lastId) : 'bottom-anchor'
}, 50)
}, 100)
}
function emptyChatMessages(): ChatViewMessage[] {
return [] as ChatViewMessage[]
}
function formatMessage(m: ChatMessage): ChatViewMessage {
let isMe = false
if (currentUserId.value != '') {
isMe = m.sender_id === currentUserId.value
} else {
isMe = m.is_from_user === true
}
const rawTime = m.created_at != null && m.created_at != '' ? m.created_at : new Date().toISOString()
const content = m.content != null ? m.content.toString() : ''
const senderId = m.sender_id != null ? m.sender_id.toString() : ''
return {
id: m.id,
type: isMe ? 'sent' : 'received',
content: content,
rawTime: rawTime,
senderId: senderId
}
}
function getDisplayMerchantName(): string {
const name = merchantName.value != null ? merchantName.value.toString() : ''
return name !== '' ? name : '在线客服'
}
function getDisplayMerchantLogo(): string {
const logo = merchantLogo.value != null ? merchantLogo.value.toString() : ''
return logo !== '' ? logo : '/static/logo.png'
}
async function loadHistory(): Promise<void> {
let rawMsgs: ChatMessage[] = []
if (merchantId.value != '') {
rawMsgs = await supabaseService.getChatMessages(merchantId.value)
} else {
rawMsgs = await supabaseService.getUserChatMessages()
}
const formatted = rawMsgs.reverse().map((m: ChatMessage) => formatMessage(m))
messages.value = formatted
scrollToBottom()
}
function startRealtimeSubscription(): void {
if (currentUserId.value == '') return
console.log('启动实时订阅')
const filterObj = ({
event: 'INSERT',
schema: 'public',
table: 'ml_chat_messages'
} as UTSJSONObject)
realtimeChannel = supa.channel(`chat_${currentUserId.value}`)
.on(
'postgres_changes',
filterObj,
(payload: UTSJSONObject) => {
console.log('收到变更事件', payload)
const newAny = payload.get('new')
if (newAny == null) return
const newMsg = (newAny instanceof UTSJSONObject) ? (newAny as UTSJSONObject) : (JSON.parse(JSON.stringify(newAny)) as UTSJSONObject)
const senderId = newMsg.getString('sender_id') ?? ''
if (senderId === merchantId.value || merchantId.value == '') {
const realtimeMessage = {
id: newMsg.getString('id') ?? '',
content: newMsg.getString('content') ?? '',
msg_type: newMsg.getString('msg_type') ?? '',
sender_id: senderId,
receiver_id: newMsg.getString('receiver_id') ?? '',
is_from_user: false,
created_at: newMsg.getString('created_at') ?? ''
} as ChatMessage
const formatted = formatMessage(realtimeMessage)
messages.value.push(formatted)
scrollToBottom()
}
}
)
.subscribe((_status) => {})
}
function goBack(): void {
uni.navigateBack({})
}
function formatTime(isoString: string): string {
const date = new Date(isoString)
const now = new Date()
if (date.toDateString() === now.toDateString()) {
const h = date.getHours().toString().padStart(2, "0")
const m = date.getMinutes().toString().padStart(2, "0")
return `${h}:${m}`
}
const mo = (date.getMonth() + 1).toString().padStart(2, "0")
const d = date.getDate().toString().padStart(2, "0")
const h = date.getHours().toString().padStart(2, "0")
const m = date.getMinutes().toString().padStart(2, "0")
return `${mo}-${d} ${h}:${m}`
}
function shouldShowTime(index: number): boolean {
if (index === 0) return true
const prev = messages.value[index - 1]
const curr = messages.value[index]
const t1 = new Date(prev.rawTime).getTime()
const t2 = new Date(curr.rawTime).getTime()
return (t2 - t1) > 300000
}
function toggleEmoji(): void {
showEmoji.value = !showEmoji.value
if (showEmoji.value) {
scrollToBottom()
}
}
function insertEmoji(emoji: string): void {
inputMessage.value += emoji
}
function showMoreActions(): void {
uni.showActionSheet({
itemList: ['清空记录', '联系客服'],
success: (res) => {
if (res.tapIndex === 0) {
messages.value = emptyChatMessages()
}
}
})
}
onLoad((options) => {
// 获取状态栏高度
const sys = uni.getSystemInfoSync()
statusBarHeight.value = sys.statusBarHeight ?? 0
@@ -158,128 +320,41 @@ onLoad((options: any) => {
const mid = optObj.getString('merchantId') ?? ''
if (mid !== '') {
merchantId.value = mid
merchantName.value = optObj.getString('merchantName') ?? '鍟嗗'
merchantLogo.value = optObj.getString('merchantLogo') ?? ''
console.log('寮€濮嬭亰澶╋紝鍟嗗ID:', merchantId.value)
merchantName.value = optObj.getString('merchantName') ?? '商家'
merchantLogo.value = optObj.getString('merchantLogo') ?? '/static/logo.png'
console.log('已初始化会话,商家ID:', merchantId.value)
} else {
merchantName.value = '骞冲彴瀹㈡湇'
merchantName.value = '平台客服'
}
// 鑾峰彇褰撳墠鐢ㄦ埛
// 获取当前用户
const uid = supabaseService.getCurrentUserId()
if (uid != null) {
currentUserId.value = uid
// 绠€鍗曡幏鍙栦竴涓嬪ご鍍忥紝瀹為檯搴旇浠嶱rofile鑾峰彇
userAvatar.value = 'https://picsum.photos/100'
// 简单获取一下头像,实际应该从 Profile 获取
userAvatar.value = '/static/logo.png'
}
// 鍔犺浇鍘嗗彶娑堟伅
// 加载历史消息
loadHistory()
// 寮€鍚疄鏃惰闃?
// 启动实时订阅
startRealtimeSubscription()
})
onUnmounted(() => {
if (realtimeChannel != null) {
supa.removeChannel(realtimeChannel!)
supa.removeChannel(realtimeChannel as AkSupaRealtimeChannel)
}
})
// 鍔犺浇鍘嗗彶璁板綍
const loadHistory = async () => {
let rawMsgs: ChatMessage[] = []
if (merchantId.value) {
// 鑾峰彇涓庣壒瀹氬晢瀹剁殑鑱婂ぉ
rawMsgs = await supabaseService.getChatMessages(merchantId.value)
} else {
// 鑾峰彇鎵€鏈夛紙姣斿瀹㈡湇锛?
rawMsgs = await supabaseService.getUserChatMessages()
}
const formatted = rawMsgs.reverse().map((m: ChatMessage) => formatMessage(m))
messages.value = formatted
scrollToBottom()
}
const loadMoreHistory = () => {
// TODO: 瀹炵幇涓嬫媺鍔犺浇鏇村鍘嗗彶
}
// 寮€鍚疄鏃惰闃?
const startRealtimeSubscription = () => {
if (currentUserId.value == '') return
console.log('寮€鍚秷鎭洃鍚?..')
const filterObj = ({
event: 'INSERT',
schema: 'public',
table: 'ml_chat_messages'
} as UTSJSONObject)
realtimeChannel = supa.channel(`chat_${currentUserId.value}`)
.on(
'postgres_changes',
filterObj,
(payload: any) => {
console.log('鏀跺埌鏂版秷鎭?', payload)
const payloadObj = (payload instanceof UTSJSONObject) ? (payload as UTSJSONObject) : (JSON.parse(JSON.stringify(payload ?? {})) as UTSJSONObject)
const newAny = payloadObj.get('new')
if (newAny == null) return
const newMsg = (newAny instanceof UTSJSONObject) ? (newAny as UTSJSONObject) : (JSON.parse(JSON.stringify(newAny)) as UTSJSONObject)
// 鍙湁鏉ヨ嚜褰撳墠鑱婂ぉ鐨勫晢瀹剁殑娑堟伅鎵嶆樉绀猴紝鎴栬€呭鏋滄槸鍏ㄥ眬瀹㈡湇妯″紡
const senderId = newMsg.getString('sender_id') ?? ''
if (senderId === merchantId.value || merchantId.value == '') {
const formatted = formatMessage({
id: newMsg.getString('id') ?? '',
content: newMsg.getString('content') ?? '',
msg_type: newMsg.getString('msg_type') ?? '',
sender_id: senderId,
receiver_id: newMsg.getString('receiver_id') ?? '',
is_from_user: false, // 鏀跺埌鐨勪竴瀹氫笉鏄嚜宸卞彂鐨?
created_at: newMsg.getString('created_at') ?? ''
} as ChatMessage)
messages.value.push(formatted)
scrollToBottom()
// 闇囧姩鎻愮ず
uni.vibrateShort({})
}
}
)
.subscribe()
}
// 鏍煎紡鍖栨秷鎭?
const formatMessage = (m: ChatMessage): any => {
// 濡傛灉 sender_id 鏄嚜宸憋紝灏辨槸 'sent'锛屽惁鍒?'received'
// 娉ㄦ剰锛氭暟鎹簱瀛楁 is_from_user 鏈夋椂鍙兘鍙槸鏍囪鏄惁鐢盋绔敤鎴峰彂璧凤紝
// 鏈€鍑嗙‘鐨勬槸瀵规瘮 id
let isMe = false
if (currentUserId.value) {
isMe = m.sender_id === currentUserId.value
} else {
isMe = m.is_from_user === true
}
return {
id: m.id,
type: isMe ? 'sent' : 'received',
content: m.content,
rawTime: m.created_at || new Date().toISOString(),
senderId: m.sender_id
}
}
const sendMessage = async () => {
const text = inputMessage.value.trim()
if (text == '') return
// 涔愯鏇存柊 UI
// 乐观更新 UI
const tempId = 'temp_' + Date.now()
const tempMsg = {
const tempMsg: ChatViewMessage = {
id: tempId,
type: 'sent',
content: text,
@@ -291,87 +366,23 @@ const sendMessage = async () => {
scrollToBottom()
showEmoji.value = false
// 鍙戦€佽姹?
// 娉ㄦ剰锛氬鏋?merchantId 涓虹┖锛宻endChatMessage 绗簩涓弬鏁颁紶 null锛屼細鍙樻垚鏃犱富娑堟伅
const success = await supabaseService.sendChatMessage(text, merchantId.value ? merchantId.value : null)
// 发送请求
// 注意:如果 merchantId 为空sendChatMessage 第二个参数传 null会变成无主消息
const targetMerchantId = merchantId.value != '' ? merchantId.value : null
const success = await supabaseService.sendChatMessage(text, targetMerchantId)
if (!success) {
uni.showToast({ title: '鍙戦€佸け璐?, icon: 'none' })
// 杩欓噷鍙互鍔犱竴涓噸璇曟寜閽垨绉婚櫎娑堟伅
uni.showToast({ title: '发送失败', icon: 'none' })
// 这里可以重试或删除临时消息
}
}
const scrollToBottom = () => {
// 寤舵椂婊氬姩浠ョ‘淇濊鍥炬洿鏂?
setTimeout(() => {
scrollToView.value = 'bottom-anchor'
// Hack: 閲嶇疆鍐嶈缃互寮哄埗瑙﹀彂
setTimeout(() => {
scrollToView.value = 'msg-' + (messages.value.length > 0 ? messages.value[messages.value.length-1].id : '')
}, 50)
}, 100)
}
const goBack = () => {
uni.navigateBack({})
}
const formatTime = (isoString: string): string => {
const date = new Date(isoString)
const now = new Date()
// 濡傛灉鏄粖澶╋紝鏄剧ず HH:mm
if (date.toDateString() === now.toDateString()) {
const h = date.getHours().toString().padStart(2, '0')
const m = date.getMinutes().toString().padStart(2, '0')
return `${h}:${m}`
}
// 鍚﹀垯鏄剧ず MM-DD HH:mm
const mo = (date.getMonth() + 1).toString().padStart(2, '0')
const d = date.getDate().toString().padStart(2, '0')
const h = date.getHours().toString().padStart(2, '0')
const m = date.getMinutes().toString().padStart(2, '0')
return `${mo}-${d} ${h}:${m}`
}
const shouldShowTime = (index: number): boolean => {
if (index === 0) return true
const prev = messages.value[index - 1]
const curr = messages.value[index]
const t1 = new Date(prev.rawTime).getTime()
const t2 = new Date(curr.rawTime).getTime()
// 闂撮殧瓒呰繃5鍒嗛挓(300000ms)鏄剧ず鏃堕棿
return (t2 - t1) > 300000
}
const toggleEmoji = () => {
showEmoji.value = !showEmoji.value
if (showEmoji.value) {
scrollToBottom()
}
}
const insertEmoji = (emoji: string) => {
inputMessage.value += emoji
}
const showMoreActions = () => {
uni.showActionSheet({
itemList: ['娓呯┖璁板綍', '鎶曡瘔鍟嗗'],
success: (res) => {
if (res.tapIndex === 0) {
messages.value = [] // 浠呮湰鍦版竻绌?
}
}
})
}
</script>
<style>
.chat-page {
background-color: #f5f5f5;
height: 100vh;
height: 100%;
display: flex;
flex-direction: column;
}
@@ -620,4 +631,3 @@ const showMoreActions = () => {
}
</style>

View File

@@ -1,4 +1,4 @@
<!-- 结算页面 -->
<!-- 结算页面 -->
<template>
<view class="checkout-page">
<scroll-view class="checkout-content" direction="vertical">
@@ -307,6 +307,7 @@
import { ref, onMounted, computed, watch, onUnmounted, getCurrentInstance } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { supabaseService, type UserAddress as SupabaseUserAddress } from '@/utils/supabaseService.uts'
import { goToLogin as redirectToLogin } from '@/utils/utils.uts'
type CheckoutItemType = {
id: string
@@ -397,7 +398,7 @@ type MockAddress = {
function getObjectKeys(obj: object): string[] {
const keys: string[] = []
// UTS 兼容的对象属性获取方式
const tempObj = obj as Record<string, any>
const tempObj = obj as UTSJSONObject
// 使用 try-catch 安全获取对象属性
try {
@@ -482,7 +483,7 @@ const getGroupTotal = (group: ShopGroupType): string => {
price = item.member_price
}
const quantity = item.quantity
if (isNaN(price) == false && isNaN(quantity) == false) {
if (Number.isNaN(price) == false && Number.isNaN(quantity) == false) {
sum += (price * quantity)
}
})
@@ -509,7 +510,7 @@ const totalAmount = computed(() => {
const quantity = item.quantity
// 验证转换后的数字是否有效
if (isNaN(price) || isNaN(quantity) || price <= 0 || quantity <= 0) {
if (Number.isNaN(price) || Number.isNaN(quantity) || price <= 0 || quantity <= 0) {
console.warn('商品价格或数量无效:', item, 'price:', price, 'quantity:', quantity)
return sum
}
@@ -593,14 +594,14 @@ const processCheckoutItems = async (items: any[]) => {
const priceAny = obj.get('price')
if (priceAny != null) {
const parsed = parseFloat(priceAny.toString())
if (isNaN(parsed) == false) price = parsed
if (Number.isNaN(parsed) == false) price = parsed
}
let quantity = 1
const quantityAny = obj.get('quantity')
if (quantityAny != null) {
const parsedQ = parseInt(quantityAny.toString())
if (isNaN(parsedQ) == false && parsedQ >= 1) quantity = parsedQ
if (Number.isNaN(parsedQ) == false && parsedQ >= 1) quantity = parsedQ
}
const shopId = obj.getString('shop_id') ?? obj.getString('shopId') ?? 'unknown'
@@ -719,19 +720,19 @@ async function loadDefaultAddress(): Promise<void> {
}
// 同时更新本地存储缓存
const localAddresses: any[] = []
const localAddresses: UTSJSONObject[] = []
for (let i = 0; i < supabaseAddresses.length; i++) {
const addr = supabaseAddresses[i]
localAddresses.push({
id: addr.id,
name: addr.recipient_name,
phone: addr.phone,
province: addr.province,
city: addr.city,
district: addr.district,
detail: addr.detail_address,
isDefault: addr.is_default
})
const localItem = new UTSJSONObject()
localItem.set('id', addr.id)
localItem.set('name', addr.recipient_name)
localItem.set('phone', addr.phone)
localItem.set('province', addr.province)
localItem.set('city', addr.city)
localItem.set('district', addr.district)
localItem.set('detail', addr.detail_address)
localItem.set('isDefault', addr.is_default)
localAddresses.push(localItem)
}
uni.setStorageSync('addresses', JSON.stringify(localAddresses))
}
@@ -847,7 +848,7 @@ async function loadAddressList(): Promise<void> {
if (supabaseAddresses != null && supabaseAddresses.length > 0) {
const list: AddressItem[] = []
const localAddresses: any[] = []
const localAddresses: UTSJSONObject[] = []
for (let i = 0; i < supabaseAddresses.length; i++) {
const addr = supabaseAddresses[i]
console.log('[loadAddressList] 地址', i, ':', addr.recipient_name, addr.phone, addr.detail_address)
@@ -861,16 +862,16 @@ async function loadAddressList(): Promise<void> {
detail: addr.detail_address,
is_default: addr.is_default
})
localAddresses.push({
id: addr.id,
name: addr.recipient_name,
phone: addr.phone,
province: addr.province,
city: addr.city,
district: addr.district,
detail: addr.detail_address,
isDefault: addr.is_default
})
const localItem = new UTSJSONObject()
localItem.set('id', addr.id)
localItem.set('name', addr.recipient_name)
localItem.set('phone', addr.phone)
localItem.set('province', addr.province)
localItem.set('city', addr.city)
localItem.set('district', addr.district)
localItem.set('detail', addr.detail_address)
localItem.set('isDefault', addr.is_default)
localAddresses.push(localItem)
}
addressList.value = list
console.log('[loadAddressList] addressList.value 设置完成, 数量:', addressList.value.length)
@@ -1265,8 +1266,8 @@ function formatSpecs(specs: any): string {
const specsStr = JSON.stringify(specs)
if (specsStr == '{}' || specsStr == '[]' || specsStr == '""' || specsStr == '') return ''
// 使用 Record 类型替代 UTSJSONObject 的迭代器方法
const specsObj = JSON.parse(specsStr) as Record<string, any>
// 使用 UTSJSONObject 替代 Record 类型
const specsObj = JSON.parse(specsStr) as UTSJSONObject
const parts: string[] = []
// 遍历已知可能的规格键名
@@ -1347,14 +1348,14 @@ const submitOrder = async () => {
const userId = supabaseService.getCurrentUserId()
if (userId == null || userId == '') {
uni.hideLoading()
uni.showToast({ title: '请先登录', icon: 'none' })
redirectToLogin('/pages/mall/consumer/checkout')
return
}
console.log('[submitOrder] 开始创建订单, userId:', userId)
console.log('[submitOrder] shopGroups数量:', shopGroups.value.length)
const groups: any[] = []
const groups: UTSJSONObject[] = []
for (let i = 0; i < shopGroups.value.length; i++) {
const group = shopGroups.value[i]
console.log(`[submitOrder] 处理店铺组 ${i}:`, {
@@ -1363,29 +1364,29 @@ const submitOrder = async () => {
merchant_id: group.merchant_id,
itemsCount: group.items.length
})
const items: any[] = []
const items: UTSJSONObject[] = []
for (let j = 0; j < group.items.length; j++) {
const item = group.items[j]
items.push({
id: item.id,
product_id: item.product_id,
sku_id: item.sku_id,
quantity: item.quantity,
price: item.price,
member_price: item.member_price,
product_name: item.product_name,
product_image: item.product_image,
specifications: item.sku_specifications
})
const itemObj = new UTSJSONObject()
itemObj.set('id', item.id)
itemObj.set('product_id', item.product_id)
itemObj.set('sku_id', item.sku_id)
itemObj.set('quantity', item.quantity)
itemObj.set('price', item.price)
itemObj.set('member_price', item.member_price)
itemObj.set('product_name', item.product_name)
itemObj.set('product_image', item.product_image)
itemObj.set('specifications', item.sku_specifications)
items.push(itemObj)
}
const finalMerchantId = (group.merchant_id != null && group.merchant_id != '') ? group.merchant_id : group.shopId
console.log(`[submitOrder] 店铺组 ${i} 最终使用的 merchant_id:`, finalMerchantId)
groups.push({
merchant_id: finalMerchantId,
shopId: group.shopId,
shopName: group.shopName,
items: items
})
const groupObj = new UTSJSONObject()
groupObj.set('merchant_id', finalMerchantId)
groupObj.set('shopId', group.shopId)
groupObj.set('shopName', group.shopName)
groupObj.set('items', items)
groups.push(groupObj)
}
console.log('[submitOrder] 准备传递的 groups 数量:', groups.length)
@@ -1452,9 +1453,7 @@ const selectAddress = () => {
// 添加登录跳转方法
const goToLogin = () => {
uni.navigateTo({
url: '/pages/login/login' // 根据实际登录页面路径调整
})
redirectToLogin('/pages/mall/consumer/checkout')
}
</script>

View File

@@ -1,4 +1,4 @@
<template>
<template>
<view class="coupons-page">
<view class="coupon-list">
<view v-if="coupons.length === 0" class="empty-state">

View File

@@ -0,0 +1,22 @@
<template>
<view />
</template>
<script lang="uts">
export default {
onLoad(options) {
let url = '/pages/user/profile'
if (options != null) {
const optObj = options as UTSJSONObject
const userId = optObj.getString('userId') ?? ''
if (userId !== '') {
url = `/pages/user/profile?userId=${encodeURIComponent(userId)}`
}
}
uni.redirectTo({ url })
}
}
</script>
<style scoped>
</style>

View File

@@ -8,7 +8,7 @@
</view>
</view>
<scroll-view class="favorites-content" :scroll-y="true">
<scroll-view class="favorites-content" direction="vertical">
<view v-if="favorites.length === 0 && !isLoading" class="empty-favorites">
<text class="empty-icon">❤️</text>
<text class="empty-text">暂无收藏商品</text>
@@ -63,6 +63,7 @@
<script setup lang="uts">
import { ref, onMounted, computed } from 'vue'
import { supabaseService } from '@/utils/supabaseService.uts'
import { goToLogin } from '@/utils/utils.uts'
type FavoriteType = {
id: string
@@ -105,7 +106,7 @@ const loadFavorites = async () => {
prod = itemObj.get('ml_products')
}
let image = '/static/default-product.png'
let image = '/static/images/default.png'
let id = ''
let name = '未知商品'
let price = 0
@@ -125,7 +126,7 @@ const loadFavorites = async () => {
image = prodObj.getString('main_image_url') ?? image
merchantId = prodObj.getString('merchant_id') ?? ''
if (image === '/static/default-product.png') {
if (image === '/static/images/default.png') {
const imgUrls = prodObj.getString('image_urls')
if (imgUrls != null) {
try {
@@ -285,6 +286,11 @@ const viewProduct = (product: FavoriteType) => {
}
const addToCart = async (product: FavoriteType) => {
const userId = supabaseService.getCurrentUserId()
if (userId == null || userId === '') {
goToLogin('/pages/mall/consumer/favorites')
return
}
uni.showLoading({ title: '检查商品...' })
try {
const merchantId = product.merchant_id ?? ''

View File

@@ -8,7 +8,7 @@
</view>
</view>
<scroll-view class="footprint-content" :scroll-y="true" @scrolltolower="loadMore">
<scroll-view class="footprint-content" direction="vertical" @scrolltolower="loadMore">
<view v-if="footprints.length === 0 && !isLoading" class="empty-footprints">
<text class="empty-icon">👣</text>
<text class="empty-text">暂无浏览记录</text>
@@ -70,6 +70,7 @@
<script setup lang="uts">
import { ref, onMounted, computed } from 'vue'
import { supabaseService } from '@/utils/supabaseService.uts'
import { goToLogin } from '@/utils/utils.uts'
type FootprintType = {
id: string
@@ -170,7 +171,28 @@ const toggleEditMode = () => {
}
}
const clearAll = () => {
async function executeClearAll(): Promise<void> {
uni.showLoading({ title: '清空中...' })
const success = await supabaseService.clearFootprints()
uni.hideLoading()
if (success) {
footprints.value = []
uni.removeStorageSync('footprints')
uni.showToast({
title: '已清空',
icon: 'success'
})
} else {
uni.showToast({
title: '清空失败',
icon: 'none'
})
}
}
function clearAll(): void {
if (footprints.value.length === 0) return
uni.showModal({
@@ -178,26 +200,7 @@ const clearAll = () => {
content: '确定要清空所有浏览记录吗?',
success: (res) => {
if (res.confirm) {
uni.showLoading({ title: '清空中...' })
supabaseService.clearFootprints().then((success) => {
uni.hideLoading()
if (success) {
footprints.value = []
uni.removeStorageSync('footprints')
uni.showToast({
title: '已清空',
icon: 'success'
})
} else {
uni.showToast({
title: '清空失败',
icon: 'none'
})
}
})
executeClearAll()
}
}
})
@@ -236,7 +239,54 @@ const toggleSelectAll = () => {
footprints.value = [...footprints.value]
}
const deleteSelected = () => {
async function executeDeleteSelected(selectedItems: FootprintType[]): Promise<void> {
uni.showLoading({ title: '删除中...' })
const productIds: string[] = []
for (let i = 0; i < selectedItems.length; i++) {
productIds.push(selectedItems[i].id)
}
const success = await supabaseService.deleteFootprints(productIds)
uni.hideLoading()
if (success) {
footprints.value = footprints.value.filter((item): Boolean => item.selected !== true)
const dataToSave: FootprintSaveType[] = []
for (let i = 0; i < footprints.value.length; i++) {
const item = footprints.value[i]
dataToSave.push({
id: item.id,
name: item.name,
price: item.price,
original_price: item.original_price,
image: item.image,
sales: item.sales,
shopId: item.shopId,
shopName: item.shopName,
viewTime: item.viewTime
} as FootprintSaveType)
}
uni.setStorageSync('footprints', JSON.stringify(dataToSave))
uni.showToast({
title: '删除成功',
icon: 'success'
})
if (footprints.value.length === 0) {
isEditMode.value = false
}
} else {
uni.showToast({
title: '删除失败',
icon: 'none'
})
}
}
function deleteSelected(): void {
const selectedItems = footprints.value.filter((item): Boolean => item.selected === true)
if (selectedItems.length === 0) {
uni.showToast({
@@ -251,61 +301,18 @@ const deleteSelected = () => {
content: `确定要删除选中的${selectedItems.length}条记录吗?`,
success: (res) => {
if (res.confirm) {
uni.showLoading({ title: '删除中...' })
// 收集要删除的商品ID
const productIds: string[] = []
for (let i = 0; i < selectedItems.length; i++) {
productIds.push(selectedItems[i].id)
}
// 调用服务层批量删除
supabaseService.deleteFootprints(productIds).then((success) => {
uni.hideLoading()
if (success) {
// 从本地列表中移除
footprints.value = footprints.value.filter((item): Boolean => item.selected !== true)
// 更新本地缓存
const dataToSave: FootprintSaveType[] = []
for (let i = 0; i < footprints.value.length; i++) {
const item = footprints.value[i]
dataToSave.push({
id: item.id,
name: item.name,
price: item.price,
original_price: item.original_price,
image: item.image,
sales: item.sales,
shopId: item.shopId,
shopName: item.shopName,
viewTime: item.viewTime
} as FootprintSaveType)
}
uni.setStorageSync('footprints', JSON.stringify(dataToSave))
uni.showToast({
title: '删除成功',
icon: 'success'
})
if (footprints.value.length === 0) {
isEditMode.value = false
}
} else {
uni.showToast({
title: '删除失败',
icon: 'none'
})
}
})
executeDeleteSelected(selectedItems)
}
}
})
}
const addToCart = async (item: FootprintType) => {
const userId = supabaseService.getCurrentUserId()
if (userId == null || userId === '') {
goToLogin('/pages/mall/consumer/footprint')
return
}
uni.showLoading({ title: '检查商品...' })
try {
const productId = item.id

View File

@@ -0,0 +1,182 @@
<template>
<scroll-view class="page" scroll-y="true">
<ServicePanel title="提交服务申请" subtitle="先使用 mock 数据模拟申请受理流程。">
<view class="form-item">
<text class="label">选择服务</text>
<view class="choice-wrap">
<view
v-for="item in services"
:key="item.id"
class="choice-card"
:class="selectedServiceId == item.id ? 'choice-active' : ''"
@click="selectService(item.id, item.name)"
>
<text class="choice-title">{{ item.name }}</text>
<text class="choice-desc">{{ item.durationText }} · ¥{{ item.price }}</text>
</view>
</view>
</view>
<view class="form-item">
<text class="label">申请人</text>
<input v-model="form.applicantName" class="input" placeholder="请输入申请人姓名" />
</view>
<view class="form-item">
<text class="label">服务对象</text>
<input v-model="form.elderName" class="input" placeholder="请输入老人姓名" />
</view>
<view class="form-item">
<text class="label">年龄</text>
<input v-model="ageText" class="input" type="number" placeholder="请输入老人年龄" />
</view>
<view class="form-item">
<text class="label">联系电话</text>
<input v-model="form.phone" class="input" type="number" placeholder="请输入联系电话" />
</view>
<view class="form-item">
<text class="label">服务地址</text>
<textarea v-model="form.address" class="textarea" placeholder="请输入详细上门地址"></textarea>
</view>
<view class="form-item">
<text class="label">期望时间</text>
<input v-model="form.preferredTime" class="input" placeholder="例如 2026-05-14 上午" />
</view>
<view class="form-item">
<text class="label">需求说明</text>
<textarea v-model="form.demandSummary" class="textarea" placeholder="简要描述照护需求、病情重点、注意事项"></textarea>
</view>
<view class="submit-btn" @click="submitApplication">提交申请</view>
</ServicePanel>
</scroll-view>
</template>
<script setup lang="uts">
import { reactive, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import ServicePanel from '@/components/homeService/ServicePanel.uvue'
import { createHomeServiceApplication, fetchHomeServiceCatalog } from '@/services/homeServiceService.uts'
import { HomeServiceApplicationDraftType, HomeServiceCatalogType } from '@/types/home-service.uts'
const services = ref<Array<HomeServiceCatalogType>>([])
const selectedServiceId = ref('svc-001')
const selectedServiceName = ref('基础上门护理')
const ageText = ref('78')
const form = reactive({
serviceId: 'svc-001',
serviceName: '基础上门护理',
applicantName: '李晓兰',
elderName: '李奶奶',
age: 78,
phone: '13800138000',
address: '梅州市梅江区学海路 18 号 2 栋 602',
preferredTime: '2026-05-14 上午',
demandSummary: '老人需要基础照护、血压监测和跌倒风险提醒。'
} as HomeServiceApplicationDraftType)
async function loadData() {
services.value = await fetchHomeServiceCatalog()
}
function selectService(serviceId: string, serviceName: string) {
selectedServiceId.value = serviceId
selectedServiceName.value = serviceName
form.serviceId = serviceId
form.serviceName = serviceName
}
async function submitApplication() {
if (form.applicantName == '' || form.elderName == '' || form.phone == '' || form.address == '' || form.preferredTime == '') {
uni.showToast({ title: '请补全申请信息', icon: 'none' })
return
}
const parsedAge = parseInt(ageText.value)
form.age = isNaN(parsedAge) ? 0 : parsedAge
const created = await createHomeServiceApplication(form)
uni.showToast({ title: '申请已提交', icon: 'success' })
uni.navigateTo({ url: '/pages/mall/consumer/home-service/order-detail?id=' + created.id })
}
onLoad(() => {
loadData()
})
</script>
<style scoped>
.page {
min-height: 100vh;
background: #f3f7f9;
padding: 24rpx;
box-sizing: border-box;
}
.form-item {
margin-bottom: 24rpx;
}
.label {
font-size: 28rpx;
font-weight: 700;
color: #16324f;
margin-bottom: 12rpx;
}
.input,
.textarea {
width: 100%;
background: #f8fbfc;
border-radius: 18rpx;
padding: 22rpx 24rpx;
font-size: 28rpx;
color: #23384d;
box-sizing: border-box;
}
.textarea {
height: 160rpx;
}
.choice-wrap {
gap: 16rpx;
}
.choice-card {
padding: 22rpx;
background: #f8fbfc;
border-radius: 18rpx;
margin-bottom: 16rpx;
border-width: 2rpx;
border-style: solid;
border-color: transparent;
}
.choice-active {
border-color: #0f766e;
background: #effcf8;
}
.choice-title {
font-size: 30rpx;
font-weight: 700;
color: #16324f;
}
.choice-desc {
margin-top: 8rpx;
font-size: 24rpx;
color: #66788a;
}
.submit-btn {
margin-top: 16rpx;
padding: 26rpx 0;
border-radius: 20rpx;
background: #0f766e;
text-align: center;
font-size: 30rpx;
font-weight: 700;
color: #ffffff;
}
</style>

View File

@@ -0,0 +1,182 @@
<template>
<scroll-view class="page" scroll-y="true">
<view v-if="detail == null" class="empty-box">
<text class="empty-text">未找到验收信息</text>
</view>
<view v-else>
<ServicePanel title="验收反馈" subtitle="家属可确认服务完成情况,也可退回整改。">
<text class="info">服务单号:{{ detail.caseNo }}</text>
<text class="info">服务对象:{{ detail.elderName }}</text>
<text class="info">服务项目:{{ detail.serviceName }}</text>
<text class="info">当前状态:{{ detail.acceptanceStatusText }}</text>
<view class="block">
<text class="label">满意度评分</text>
<view class="rating-row">
<view v-for="score in scores" :key="score" class="rating-item" :class="rating >= score ? 'rating-active' : ''" @click="rating = score">{{ score }}分</view>
</view>
</view>
<view class="block">
<text class="label">评价标签</text>
<view class="tag-row">
<view
v-for="item in allTags"
:key="item"
class="tag-item"
:class="selectedTags.indexOf(item) >= 0 ? 'tag-active' : ''"
@click="toggleTag(item)"
>
{{ item }}
</view>
</view>
</view>
<view class="block">
<text class="label">反馈说明</text>
<textarea v-model="feedback" class="textarea" placeholder="填写验收意见、服务感受或需要整改的问题"></textarea>
</view>
<view class="action-row">
<view class="action ghost" @click="submitResult(false)">退回整改</view>
<view class="action primary" @click="submitResult(true)">确认验收</view>
</view>
</ServicePanel>
</view>
</scroll-view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import ServicePanel from '@/components/homeService/ServicePanel.uvue'
import { fetchConsumerAcceptanceDetail, submitConsumerAcceptance } from '@/services/homeServiceService.uts'
import { HomeServiceAcceptanceType } from '@/types/home-service.uts'
const caseId = ref('')
const detail = ref<HomeServiceAcceptanceType | null>(null)
const rating = ref(5)
const feedback = ref('护理员服务规范,过程说明清楚,老人状态稳定。')
const selectedTags = ref<Array<string>>([])
const scores = [1, 2, 3, 4, 5]
const allTags = ['准时上门', '沟通清楚', '动作规范', '记录完整', '需进一步整改']
onLoad((options) => {
const id = options['id']
if (id != null) {
caseId.value = id as string
fetchConsumerAcceptanceDetail(caseId.value).then((res) => {
if (res != null) {
detail.value = res
rating.value = res.rating
feedback.value = res.feedback
selectedTags.value = res.tags.slice(0)
}
})
}
})
function toggleTag(tag: string) {
const index = selectedTags.value.indexOf(tag)
if (index >= 0) {
selectedTags.value.splice(index, 1)
} else {
selectedTags.value.push(tag)
}
}
async function submitResult(approved: boolean) {
if (caseId.value == '' || feedback.value == '') {
uni.showToast({ title: '请填写反馈说明', icon: 'none' })
return
}
const result = await submitConsumerAcceptance(caseId.value, approved, rating.value, feedback.value, selectedTags.value)
if (result != null) {
uni.showToast({ title: approved ? '已完成验收' : '已退回整改', icon: 'success' })
setTimeout(() => {
uni.navigateBack()
}, 400)
}
}
</script>
<style scoped>
.page {
min-height: 100vh;
background: #f3f7f9;
padding: 24rpx;
box-sizing: border-box;
}
.info,
.label,
.rating-item,
.tag-item,
.empty-text {
font-size: 28rpx;
line-height: 40rpx;
color: #16324f;
}
.block {
margin-top: 24rpx;
}
.rating-row,
.tag-row,
.action-row {
margin-top: 16rpx;
flex-direction: row;
flex-wrap: wrap;
gap: 16rpx;
}
.rating-item,
.tag-item {
padding: 16rpx 20rpx;
border-radius: 18rpx;
background: #eef2f6;
}
.rating-active,
.tag-active {
background: #e8f2ff;
color: #1d4ed8;
}
.textarea {
margin-top: 16rpx;
width: 100%;
height: 240rpx;
padding: 24rpx;
box-sizing: border-box;
background: #f8fbfc;
border-radius: 20rpx;
font-size: 28rpx;
color: #23384d;
}
.action {
flex: 1;
padding: 24rpx 0;
border-radius: 18rpx;
text-align: center;
font-size: 28rpx;
font-weight: 700;
}
.ghost {
background: #fff4e5;
color: #b45309;
}
.primary {
background: #1d4ed8;
color: #ffffff;
}
.empty-box {
padding: 120rpx 0;
align-items: center;
}
</style>

View File

@@ -0,0 +1,161 @@
<template>
<scroll-view class="page" scroll-y="true">
<view class="hero-card">
<text class="hero-title">居家上门服务</text>
<text class="hero-desc">覆盖服务申请、上门评估、执行跟踪与验收反馈,先用 mock 数据跑通前端闭环。</text>
<view class="hero-actions">
<view class="primary-btn" @click="goApply">立即申请</view>
</view>
</view>
<ServicePanel title="推荐服务" subtitle="适老化信息更清晰,入口更聚焦。">
<view v-for="item in services" :key="item.id" class="service-card">
<view class="service-top">
<view>
<text class="service-name">{{ item.name }}</text>
<text class="service-meta">{{ item.category }} · {{ item.durationText }}</text>
</view>
<text class="service-price">¥{{ item.price }}</text>
</view>
<text class="service-summary">{{ item.summary }}</text>
<text class="service-suitable">适用对象:{{ item.suitableFor }}</text>
</view>
</ServicePanel>
<ServicePanel title="我的服务单" subtitle="先展示待派单和服务中的 mock 单据。">
<view v-if="cases.length == 0" class="empty-box">
<text class="empty-text">当前没有服务单</text>
</view>
<view v-for="item in cases" :key="item.id" class="case-card" @click="goDetail(item.id)">
<view class="case-row">
<view>
<text class="case-title">{{ item.serviceName }}</text>
<text class="case-no">{{ item.caseNo }}</text>
</view>
<ServiceStatusTag :text="item.statusText" :tone="item.statusTone"></ServiceStatusTag>
</view>
<text class="case-info">服务对象:{{ item.elderName }}{{ item.age }} 岁</text>
<text class="case-info">上门时间:{{ item.serviceTime }}</text>
<text class="case-info">服务地址:{{ item.address }}</text>
</view>
</ServicePanel>
</scroll-view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import ServicePanel from '@/components/homeService/ServicePanel.uvue'
import ServiceStatusTag from '@/components/homeService/ServiceStatusTag.uvue'
import { fetchConsumerHomeServiceCases, fetchHomeServiceCatalog } from '@/services/homeServiceService.uts'
import { HomeServiceCatalogType, HomeServiceCaseType } from '@/types/home-service.uts'
const services = ref<Array<HomeServiceCatalogType>>([])
const cases = ref<Array<HomeServiceCaseType>>([])
async function loadData() {
services.value = await fetchHomeServiceCatalog()
cases.value = await fetchConsumerHomeServiceCases()
}
function goApply() {
uni.navigateTo({ url: '/pages/mall/consumer/home-service/apply' })
}
function goDetail(caseId: string) {
uni.navigateTo({ url: '/pages/mall/consumer/home-service/order-detail?id=' + caseId })
}
onLoad(() => {
loadData()
})
</script>
<style scoped>
.page {
min-height: 100vh;
background: #f3f7f9;
padding: 24rpx;
box-sizing: border-box;
}
.hero-card {
background: linear-gradient(135deg, #0f766e, #1d4ed8);
border-radius: 28rpx;
padding: 32rpx;
margin-bottom: 24rpx;
}
.hero-title {
font-size: 40rpx;
font-weight: 700;
color: #ffffff;
line-height: 56rpx;
}
.hero-desc {
margin-top: 16rpx;
font-size: 28rpx;
line-height: 40rpx;
color: rgba(255, 255, 255, 0.9);
}
.hero-actions {
margin-top: 28rpx;
}
.primary-btn {
background: #ffffff;
color: #0f3d66;
font-size: 30rpx;
font-weight: 700;
text-align: center;
padding: 24rpx 0;
border-radius: 18rpx;
}
.service-card,
.case-card {
padding: 24rpx;
border-radius: 20rpx;
background: #f8fbfc;
margin-bottom: 20rpx;
}
.service-top,
.case-row {
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
}
.service-name,
.case-title {
font-size: 32rpx;
font-weight: 700;
color: #16324f;
}
.service-meta,
.case-no,
.case-info,
.service-summary,
.service-suitable,
.empty-text {
margin-top: 10rpx;
font-size: 26rpx;
line-height: 38rpx;
color: #66788a;
}
.service-price {
font-size: 34rpx;
font-weight: 700;
color: #0f766e;
}
.empty-box {
padding: 40rpx 0;
align-items: center;
}
</style>

View File

@@ -0,0 +1,111 @@
<template>
<scroll-view class="page" scroll-y="true">
<view v-if="detail == null" class="empty-box">
<text class="empty-text">未找到对应服务单</text>
</view>
<view v-else>
<ServicePanel title="服务进度" subtitle="申请到执行的关键状态先以 mock 流程展示。">
<ServiceInfoCard
:title="detail.serviceName"
:code="detail.caseNo"
:status-text="detail.statusText"
:status-tone="detail.statusTone"
:items="[
{ label: '上门时间', value: detail.serviceTime },
{ label: '当前进度', value: '第 ' + detail.currentStep + ' / ' + detail.totalSteps + ' 步' },
{ label: '执行人员', value: detail.staffName + ' ' + detail.staffPhone }
]"
></ServiceInfoCard>
</ServicePanel>
<ServicePanel title="服务对象信息">
<ServiceInfoList
:items="[
{ label: '申请人:', value: detail.applicantName },
{ label: '服务对象:', value: detail.elderName + '' + detail.age + ' 岁' },
{ label: '联系电话:', value: detail.phone },
{ label: '服务地址:', value: detail.address },
{ label: '需求说明:', value: detail.summary }
]"
></ServiceInfoList>
<view v-if="detail.status == 'pending_acceptance'" class="feedback-btn" @click="goFeedback">去验收反馈</view>
</ServicePanel>
<ServicePanel title="过程留痕" subtitle="后续可替换为真实时间线与上传凭证。">
<ServiceTimeline :items="detail.timeline"></ServiceTimeline>
</ServicePanel>
</view>
</scroll-view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import ServiceInfoCard from '@/components/homeService/ServiceInfoCard.uvue'
import ServiceInfoList from '@/components/homeService/ServiceInfoList.uvue'
import ServicePanel from '@/components/homeService/ServicePanel.uvue'
import ServiceTimeline from '@/components/homeService/ServiceTimeline.uvue'
import { fetchConsumerHomeServiceCaseDetail } from '@/services/homeServiceService.uts'
import { HomeServiceCaseType } from '@/types/home-service.uts'
const caseId = ref('')
const detail = ref<HomeServiceCaseType | null>(null)
async function loadData() {
if (caseId.value == '') {
return
}
detail.value = await fetchConsumerHomeServiceCaseDetail(caseId.value)
}
function goFeedback() {
if (caseId.value == '') {
return
}
uni.navigateTo({ url: '/pages/mall/consumer/home-service/feedback?id=' + caseId.value })
}
onLoad((options) => {
const id = options['id']
if (id != null) {
caseId.value = id as string
loadData()
}
})
onShow(() => {
loadData()
})
</script>
<style scoped>
.page {
min-height: 100vh;
background: #f3f7f9;
padding: 24rpx;
box-sizing: border-box;
}
.empty-text {
margin-top: 10rpx;
font-size: 26rpx;
line-height: 38rpx;
color: #66788a;
}
.feedback-btn {
margin-top: 24rpx;
padding: 24rpx 0;
text-align: center;
font-size: 28rpx;
font-weight: 700;
color: #ffffff;
background: #1d4ed8;
border-radius: 18rpx;
}
.empty-box {
padding: 120rpx 0;
align-items: center;
}
</style>

View File

@@ -0,0 +1,14 @@
<template>
<view />
</template>
<script>
export default {
onLoad() {
uni.switchTab({ url: '/pages/main/index' });
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,14 @@
<template>
<view />
</template>
<script>
export default {
onLoad() {
uni.switchTab({ url: '/pages/main/index' });
}
}
</script>
<style scoped>
</style>

View File

@@ -162,9 +162,10 @@ const loadLogisticsInfo = async () => {
onLoad((options) => {
if (options == null) return
const orderIdValue = options['orderId']
if (orderIdValue != null) {
orderId.value = orderIdValue as string
const optionsObj = options as UTSJSONObject
const orderIdValue = optionsObj.getString('orderId') ?? ''
if (orderIdValue != '') {
orderId.value = orderIdValue
loadLogisticsInfo()
}
})
@@ -176,7 +177,7 @@ onMounted(() => {
<style scoped>
.logistics-page {
/* min-height: 100vh; */
/* min-height: 100%; */
flex: 1;
background-color: #f5f5f5;
padding-bottom: 20px;

View File

@@ -1,5 +1,5 @@
<template>
<scroll-view class="member-page" scroll-y>
<scroll-view class="member-page" direction="vertical">
<view class="member-header">
<view class="member-info">
<view class="level-badge" :class="'level-' + memberInfo.member_level">

View File

@@ -1,5 +1,5 @@
<template>
<scroll-view class="message-detail-page" scroll-y>
<scroll-view class="message-detail-page" direction="vertical">
<view class="message-header">
<text class="message-title">{{ message.title }}</text>
<text class="message-time">{{ formatTime(message.created_at) }}</text>
@@ -165,9 +165,10 @@ const goToLink = () => {
onLoad((options) => {
if (options != null) {
const idVal = options['id']
if (idVal != null) {
loadMessage(idVal as string)
const optionsObj = options as UTSJSONObject
const idVal = optionsObj.getString('id') ?? ''
if (idVal != '') {
loadMessage(idVal)
}
}
})

View File

@@ -1,4 +1,4 @@
<template>
<template>
<view class="my-reviews-page">
<view class="tabs">
<view
@@ -163,7 +163,7 @@ const showAppendModal = ref<boolean>(false)
const appendContent = ref<string>('')
const selectedReview = ref<MyReviewItem | null>(null)
const defaultImage: string = '/static/images/default-product.png'
const defaultImage: string = '/static/images/default.png'
const loadReviews = async (): Promise<void> => {
loading.value = true

View File

@@ -1,7 +1,7 @@
<!-- 消费者端 - 订单详情页 -->
<!-- 消费者端 - 订单详情页 -->
<template>
<view class="order-detail-page">
<scroll-view scroll-y="true" class="scroll-content">
<scroll-view direction="vertical" class="scroll-content">
<!-- 订单状态 -->
<view class="order-status" :class="getStatusClass()">
<view class="status-content">
@@ -65,7 +65,7 @@
<text class="arrow-right"></text>
</view>
<view v-for="item in orderItems" :key="item.id" class="product-item" @click="goToProduct(item.product_id)">
<image :src="item.image_url != null && item.image_url != '' ? item.image_url : '/static/default-product.png'" class="product-image" mode="aspectFill"/>
<image :src="item.image_url != null && item.image_url != '' ? item.image_url : '/static/images/default.png'" class="product-image" mode="aspectFill"/>
<view class="product-info">
<text class="product-name">{{ item.product_name }}</text>
<text v-if="item.specifications" class="product-spec">{{ getSpecText(item.specifications) }}</text>
@@ -170,6 +170,7 @@
import { ref, onMounted, computed } from 'vue'
import { onLoad, onBackPress } from '@dcloudio/uni-app'
import { supabaseService } from '@/utils/supabaseService.uts'
import { goToLogin } from '@/utils/utils.uts'
import supa from '@/components/supadb/aksupainstance.uts'
// 定义订单类型
@@ -342,7 +343,9 @@ const copyText = (text: string) => {
if(text == '') return
uni.setClipboardData({
data: text,
success: () => uni.showToast({ title: '已复制' })
success: () => {
uni.showToast({ title: '已复制' })
}
})
}
@@ -480,6 +483,11 @@ const loadOrderDetail = async () => {
// 动作函数
const contactService = () => {
const userId = supabaseService.getCurrentUserId()
if (userId == null || userId === '') {
goToLogin(`/pages/mall/consumer/order-detail?orderId=${orderId.value}`)
return
}
if (order.value != null && order.value?.merchant_id != '') {
// 跳转到商家的聊天窗口
uni.navigateTo({
@@ -502,6 +510,11 @@ const contactService = () => {
const payOrder = () => {
const totalAmount = order.value?.total_amount ?? 0
const userId = supabaseService.getCurrentUserId()
if (userId == null || userId === '') {
goToLogin(`/pages/mall/consumer/payment?orderId=${orderId.value}&amount=${totalAmount}`)
return
}
uni.navigateTo({
url: `/pages/mall/consumer/payment?orderId=${orderId.value}&amount=${totalAmount}`
})
@@ -606,6 +619,11 @@ const confirmReceive = () => {
}
const rePurchase = async () => {
const userId = supabaseService.getCurrentUserId()
if (userId == null || userId === '') {
goToLogin(`/pages/mall/consumer/order-detail?orderId=${orderId.value}`)
return
}
uni.showLoading({ title: '处理中' })
try {
const items = orderItems.value
@@ -645,6 +663,11 @@ const rePurchase = async () => {
}
const doApplyRefund = async (reason: string) => {
const userId = supabaseService.getCurrentUserId()
if (userId == null || userId === '') {
goToLogin(`/pages/mall/consumer/order-detail?orderId=${orderId.value}`)
return
}
try {
const success = await supabaseService.applyRefund(orderId.value, reason)
if (success) {
@@ -662,6 +685,11 @@ const doApplyRefund = async (reason: string) => {
}
const applyRefund = () => {
const userId = supabaseService.getCurrentUserId()
if (userId == null || userId === '') {
goToLogin(`/pages/mall/consumer/order-detail?orderId=${orderId.value}`)
return
}
uni.showModal({
title: '申请退款',
editable: true,
@@ -760,13 +788,15 @@ onBackPress((_): boolean => {
// 生命周期 - 在所有函数定义之后
onLoad((options) => {
const id = options['id']
const orderIdParam = options['orderId']
if (id != null && id != '') {
orderId.value = id as string
if (options == null) return
const optionsObj = (options instanceof UTSJSONObject) ? (options as UTSJSONObject) : (JSON.parse(JSON.stringify(options ?? {})) as UTSJSONObject)
const id = optionsObj.getString('id') ?? ''
const orderIdParam = optionsObj.getString('orderId') ?? ''
if (id != '') {
orderId.value = id
loadOrderDetail()
} else if (orderIdParam != null && orderIdParam != '') {
orderId.value = orderIdParam as string
} else if (orderIdParam != '') {
orderId.value = orderIdParam
loadOrderDetail()
}
})

File diff suppressed because it is too large Load Diff

View File

@@ -58,19 +58,20 @@ const loadOrderInfo = async () => {
onLoad((options) => {
if (options == null) return
const orderIdValue = options['orderId']
if (orderIdValue != null) {
orderId.value = orderIdValue as string
orderNo.value = orderIdValue as string
const amountValue = options['amount']
if (amountValue != null) {
const amountStr = amountValue.toString()
const optionsObj = options as UTSJSONObject
const orderIdValue = optionsObj.getString('orderId') ?? ''
if (orderIdValue != '') {
orderId.value = orderIdValue
orderNo.value = orderIdValue
const amountValue = optionsObj.getString('amount') ?? ''
if (amountValue != '') {
const amountStr = amountValue
console.log('[payment-success] amountStr:', amountStr)
const parsed = parseFloat(amountStr)
console.log('[payment-success] parsed:', parsed)
if (isNaN(parsed) == false) {
if (Number.isNaN(parsed) == false) {
amount.value = parsed
}
}
@@ -106,7 +107,7 @@ const goHome = () => {
flex-direction: column;
align-items: center;
justify-content: center;
/* height: 100vh; */
/* height: 100%; */
flex: 1;
background-color: #ffffff;
padding: 0 30px;

View File

@@ -110,6 +110,7 @@
<script setup lang="uts">
import { ref, onMounted, watch, computed, onUnmounted } from 'vue'
import { supabaseService } from '@/utils/supabaseService.uts'
import { goToLogin } from '@/utils/utils.uts'
type PaymentMethodType = {
id: string
@@ -119,6 +120,14 @@ type PaymentMethodType = {
enabled: boolean
}
const getOptionString = (options: UTSJSONObject, key: string): string => {
const value = options.getString(key)
if (value != null) {
return value
}
return ''
}
const orderId = ref<string>('')
const orderNo = ref<string>('')
const amount = ref<number>(0)
@@ -129,6 +138,18 @@ const isPaying = ref<boolean>(false)
const showPassword = ref<boolean>(false)
const password = ref<string>('')
const ensureLoggedIn = (): boolean => {
const userId = supabaseService.getCurrentUserId()
if (userId == null || userId === '') {
const redirectUrl = orderId.value !== ''
? `/pages/mall/consumer/payment?orderId=${orderId.value}&amount=${amount.value}`
: '/pages/mall/consumer/payment'
goToLogin(redirectUrl)
return false
}
return true
}
// 价格相关变量
const productAmount = ref<number>(0) // 商品总价
const deliveryFee = ref<number>(0) // 运费
@@ -201,7 +222,7 @@ const updateOrderInStorage = (targetOrderId: string, status: number) => {
try {
// 尝试从 'orders' 读取 (checkout页面写入的key)
const ordersStr = uni.getStorageSync('orders')
let orders: Record<string, any>[] = []
let orders: UTSJSONObject[] = []
if (ordersStr != null && ordersStr !== '') {
const parsed = JSON.parse(ordersStr as string)
if (Array.isArray(parsed)) {
@@ -210,7 +231,7 @@ const updateOrderInStorage = (targetOrderId: string, status: number) => {
const itemStr = JSON.stringify(parsed[i])
const itemParsed = JSON.parse(itemStr)
if (itemParsed != null) {
orders.push(itemParsed as Record<string, any>)
orders.push(itemParsed as UTSJSONObject)
}
}
}
@@ -330,34 +351,38 @@ const loadOrderInfo = async () => {
// 生命周期
onLoad((options) => {
if (!ensureLoggedIn()) {
return
}
if (options != null) {
const orderIdValue = options['orderId']
if (orderIdValue != null) {
orderId.value = orderIdValue as string
const optionsObj = options as UTSJSONObject
const orderIdValue = getOptionString(optionsObj, 'orderId')
if (orderIdValue != '') {
orderId.value = orderIdValue
loadOrderInfo()
}
const amountValue = options['amount']
if (amountValue != null) {
amount.value = parseFloat(amountValue.toString())
const amountValue = getOptionString(optionsObj, 'amount')
if (amountValue != '') {
amount.value = parseFloat(amountValue)
}
// 获取传递的价格详情
const productAmountValue = options['productAmount']
if (productAmountValue != null) {
productAmount.value = parseFloat(productAmountValue.toString())
const productAmountValue = getOptionString(optionsObj, 'productAmount')
if (productAmountValue != '') {
productAmount.value = parseFloat(productAmountValue)
}
const deliveryFeeValue = options['deliveryFee']
if (deliveryFeeValue != null) {
deliveryFee.value = parseFloat(deliveryFeeValue.toString())
const deliveryFeeValue = getOptionString(optionsObj, 'deliveryFee')
if (deliveryFeeValue != '') {
deliveryFee.value = parseFloat(deliveryFeeValue)
}
const discountAmountValue = options['discountAmount']
if (discountAmountValue != null) {
discountAmount.value = parseFloat(discountAmountValue.toString())
const discountAmountValue = getOptionString(optionsObj, 'discountAmount')
if (discountAmountValue != '') {
discountAmount.value = parseFloat(discountAmountValue)
}
// 如果没有传详情,尝试根据总价估算(兼容旧逻辑,但优先使用传参)
if (productAmountValue == null && amount.value > 0) {
if (productAmountValue == '' && amount.value > 0) {
calculatePriceDetails(amount.value)
}
@@ -476,6 +501,7 @@ const getPayButtonText = (): string => {
// 确认支付
const confirmPayment = async () => {
if (isPaying.value) return
if (!ensureLoggedIn()) return
// 余额支付或银行卡支付检查密码
if (selectedMethod.value === 'balance' || selectedMethod.value === 'bankcard') {

View File

@@ -0,0 +1,112 @@
<template>
<view class="pdd-demo-page">
<view class="pdd-demo-header">
<text class="pdd-demo-title">{{ pageTitle }}</text>
<text class="pdd-demo-subtitle">{{ pageSubtitle }}</text>
</view>
<PddWaterfallFeed
class="pdd-demo-feed"
:items="visibleProducts"
:loading="loading"
:noMore="noMore"
@loadMore="handleLoadMore"
@select="handleSelectProduct"
/>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import PddWaterfallFeed from '@/components/consumer/PddWaterfallFeed.uvue'
import { createPddMockProducts } from '@/utils/mock-pdd-waterfall-data.uts'
import type { PddMockProduct } from '@/utils/mock-pdd-waterfall-data.uts'
const allProducts = ref<PddMockProduct[]>([])
const visibleProducts = ref<PddMockProduct[]>([])
const loading = ref(false)
const noMore = ref(false)
const pageTitle = '\u62FC\u591A\u591A\u98CE\u683C\u5546\u54C1\u6D41'
const pageSubtitle = '\u53CC\u5217\u9AD8\u5BC6\u5EA6\u4FE1\u606F\u6D41 / \u61D2\u52A0\u8F7D / \u865A\u62DF\u5217\u8868'
const pageSize = 18
const currentPage = ref(0)
function appendNextPage(): void {
const source = allProducts.value
const nextPage = currentPage.value + 1
const nextEnd = nextPage * pageSize
const nextList = source.slice(0, nextEnd)
visibleProducts.value = nextList
currentPage.value = nextPage
noMore.value = nextList.length >= source.length
}
function initMockFeed(): void {
allProducts.value = createPddMockProducts(72)
visibleProducts.value = []
currentPage.value = 0
noMore.value = false
appendNextPage()
}
function handleLoadMore(): void {
if (loading.value || noMore.value) return
loading.value = true
setTimeout(() => {
appendNextPage()
loading.value = false
}, 260)
}
function handleSelectProduct(product: PddMockProduct): void {
uni.showToast({
title: product.title,
icon: 'none'
})
}
onMounted(() => {
initMockFeed()
})
</script>
<style scoped>
.pdd-demo-page {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #f5f5f5;
display: flex;
flex-direction: column;
}
.pdd-demo-header {
padding: 18rpx 20rpx 12rpx;
background: linear-gradient(180deg, #ffffff 0%, #fff7f3 100%);
border-bottom-width: 1rpx;
border-bottom-style: solid;
border-bottom-color: #f3d7ca;
}
.pdd-demo-title {
font-size: 34rpx;
color: #e1251b;
font-weight: 800;
line-height: 1.2;
}
.pdd-demo-subtitle {
margin-top: 6rpx;
font-size: 22rpx;
color: #8b6a5c;
line-height: 1.3;
}
.pdd-demo-feed {
flex: 1;
min-height: 0;
}
</style>

View File

@@ -170,7 +170,7 @@ const selectedProduct = ref<PointProduct | null>(null)
const exchangeQuantity = ref<number>(1)
const exchanging = ref<boolean>(false)
const defaultImage: string = '/static/images/default-product.png'
const defaultImage: string = '/static/images/default.png'
const filteredProducts = computed((): PointProduct[] => {
if (activeTab.value === 'all') {

File diff suppressed because it is too large Load Diff

View File

@@ -199,7 +199,7 @@ const pageSize = 10
const filterRating = ref<number>(0)
const hasImageFilter = ref<boolean>(false)
const defaultAvatar: string = '/static/images/default-avatar.png'
const defaultAvatar: string = '/static/images/default.png'
const getRatingCount = (rating: number): number => {
return stats.value.rating_distribution.get(rating.toString()) ?? 0
@@ -375,9 +375,10 @@ const formatTime = (timeStr: string | null): string => {
onLoad((options) => {
if (options != null) {
const idVal = options['product_id']
if (idVal != null) {
productId.value = idVal as string
const optionsObj = options as UTSJSONObject
const idVal = optionsObj.getString('product_id') ?? ''
if (idVal != '') {
productId.value = idVal
loadStats()
loadReviews(1)
}

View File

@@ -1,4 +1,4 @@
<template>
<template>
<view class="red-packets-page">
<view class="tab-header" style="position: fixed; top: 0; left: 0; right: 0; z-index: 10;">
<text

View File

@@ -1,4 +1,4 @@
<template>
<template>
<view class="review-page">
<view class="header">
<text class="title">服务评价</text>

View File

@@ -102,6 +102,7 @@
<script setup lang="uts">
import { ref, onMounted, watch } from 'vue'
import { supabaseService } from '@/utils/supabaseService.uts'
import { goToLogin } from '@/utils/utils.uts'
type RefundStatusHistoryItem = {
status: number
@@ -177,9 +178,7 @@ const loadRefunds = async (loadMore: boolean): Promise<void> => {
try {
const userId = getCurrentUserId()
if (userId == '') {
uni.navigateTo({
url: '/pages/user/login'
})
goToLogin('/pages/mall/consumer/refund')
return
}
@@ -206,7 +205,7 @@ const loadRefunds = async (loadMore: boolean): Promise<void> => {
for (let j: number = 0; j < dbItems.length; j++) {
const di = dbItems[j] as UTSJSONObject
const imgRaw = di.get('image_url')
const imgUrl = (imgRaw != null) ? (imgRaw as string) : '/static/default-product.png'
const imgUrl = (imgRaw != null) ? (imgRaw as string) : '/static/images/default.png'
const productInfo: RefundProductInfo = {
images: [imgUrl]
} as RefundProductInfo
@@ -306,23 +305,26 @@ const getStatusClass = (status: number): string => {
// 获取商品图片
const getProductImage = (refund: RefundType): string => {
const firstItem = refund.order?.order_items?.[0]
if (firstItem?.product?.images == null || firstItem?.product?.images.length == 0) {
return '/static/default-product.png'
if (refund.order == null || refund.order.order_items == null || refund.order.order_items.length === 0) {
return '/static/images/default.png'
}
return firstItem.product!.images[0]
const firstItem = refund.order.order_items[0]
if (firstItem == null || firstItem.product == null || firstItem.product.images == null || firstItem.product.images.length === 0) {
return '/static/images/default.png'
}
return firstItem.product.images[0]
}
// 获取商品名称
const getProductName = (refund: RefundType): string => {
const items = refund.order?.order_items ?? []
if (items.length === 0) return '未知商品'
if (refund.order == null || refund.order.order_items == null || refund.order.order_items.length === 0) {
return '未知商品'
}
const items = refund.order.order_items
if (items.length === 1) {
return items[0].product_name
} else {
return `${items[0].product_name}等${items.length}件商品`
}
return `${items[0].product_name}等${items.length}件商品`
}
// 格式化时间
@@ -409,10 +411,10 @@ const viewOrder = (orderId: string) => {
const doCancelRefund = async (refund: RefundType) => {
try {
const result = await supabaseService.createRefund({
id: refund.id,
status: 4
} as any)
const refundObj = new UTSJSONObject()
refundObj.set('id', refund.id)
refundObj.set('status', 4)
const result = await supabaseService.createRefund(refundObj)
if (result.success) {
refund.status = 4
@@ -450,6 +452,11 @@ const cancelRefund = (refund: RefundType) => {
// 联系客服
const contactService = (refund: RefundType) => {
const userId = getCurrentUserId()
if (userId === '') {
goToLogin('/pages/mall/consumer/refund')
return
}
uni.navigateTo({
url: `/pages/mall/service/chat?refundId=${refund.id}`
})
@@ -508,6 +515,11 @@ const deleteRefund = (refund: RefundType) => {
// 申请售后
const applyRefund = () => {
const userId = getCurrentUserId()
if (userId === '') {
goToLogin('/pages/mall/consumer/refund')
return
}
uni.navigateTo({
url: '/pages/mall/consumer/apply-refund'
})

View File

@@ -21,7 +21,7 @@
<view class="products-section">
<view v-for="(item, index) in orderItems" :key="item.id" class="product-review">
<view class="product-header">
<image class="product-image" :src="item.product_image ?? '/static/default-product.png'" />
<image class="product-image" :src="item.product_image ?? '/static/images/default.png'" />
<view class="product-info">
<text class="product-name">{{ item.product_name }}</text>
<text v-if="item.sku_specifications != null" class="product-spec">{{ getSpecText(item.sku_specifications) }}</text>
@@ -49,7 +49,7 @@
v-model="contents[index]"
placeholder="请写下您的使用感受,分享给其他小伙伴吧"
maxlength="500"></textarea>
<text class="word-count">{{ contents[index]?.length || 0 }}/500</text>
<text class="word-count">{{ contents[index]?.length ?? 0 }}/500</text>
</view>
<!-- 图片上传 -->
@@ -163,7 +163,7 @@ type OrderItemType = {
product_id: number
product_name: string
product_image: string
sku_specifications: any | null
sku_specifications: UTSJSONObject | string | null
price: number
quantity: number
}
@@ -225,7 +225,7 @@ const loadOrderData = async (): Promise<void> => {
console.log('[loadOrderData] itemsRaw:', JSON.stringify(itemsRaw))
if (itemsRaw != null) {
const itemsList = itemsRaw as any[]
const itemsList = itemsRaw as Array<UTSJSONObject>
const processedItems: Array<OrderItemType> = []
for (let i: number = 0; i < itemsList.length; i++) {
@@ -241,7 +241,7 @@ const loadOrderData = async (): Promise<void> => {
price: (item.getNumber('price') ?? 0) as number,
quantity: (item.getNumber('quantity') ?? 1) as number,
sku_specifications: skuSpec,
product_image: item.getString('product_image') ?? item.getString('image_url') ?? '/static/default-product.png'
product_image: item.getString('product_image') ?? item.getString('image_url') ?? '/static/images/default.png'
} as OrderItemType)
}
orderItems.value = processedItems
@@ -292,7 +292,7 @@ const canSubmit = computed((): boolean => {
return true
})
onLoad((options: any) => {
onLoad((options) => {
if (options != null) {
const optObj = options as UTSJSONObject
orderId.value = optObj.getString('orderId') ?? ''
@@ -310,14 +310,16 @@ const formatTime = (timeStr?: string): string => {
return `${year}-${month}-${day}`
}
const getSpecText = (specs: any | null): string => {
const getSpecText = (specs: UTSJSONObject | string | null): string => {
if (specs == null) return ''
if (typeof specs === 'string') return specs as string
try {
const specObj = JSON.parse(JSON.stringify(specs)) as UTSJSONObject
const jsonStr = JSON.stringify(specObj)
const jsonStr = JSON.stringify(specs)
if (jsonStr == '{}' || jsonStr == 'null') return ''
if (jsonStr.startsWith('"') && jsonStr.endsWith('"') && jsonStr.length >= 2) {
return jsonStr.substring(1, jsonStr.length - 1)
}
// 简单解析:直接返回 JSON 字符串(去除大括号)
const cleanStr = jsonStr.replace(/^\{|\}$/g, '').replace(/"/g, '').replace(/:/g, ': ').replace(/,/g, '; ')
@@ -359,12 +361,8 @@ const setMerchantRating = (type: string, rating: number) => {
}
// 切换匿名
const toggleAnonymous = (event: any) => {
const eventObj = event as UTSJSONObject
const detailRaw = eventObj.get('detail')
const detail = detailRaw != null ? (detailRaw as UTSJSONObject) : (new UTSJSONObject())
const valueRaw = detail.get('value')
anonymous.value = valueRaw != null ? (valueRaw as boolean) : false
const toggleAnonymous = (event: UniSwitchChangeEvent) => {
anonymous.value = event.detail.value as boolean
}
// 上传图片

File diff suppressed because it is too large Load Diff

View File

@@ -206,9 +206,10 @@
import { ref, onMounted } from 'vue'
import { onBackPress } from '@dcloudio/uni-app'
import supa from '@/components/supadb/aksupainstance.uts'
import { goToLogin } from '@/utils/utils.uts'
// 拦截返回事件,强制跳转到个人中心页
onBackPress((options) => {
onBackPress((_options): boolean => {
// 无论是什么触发的返回系统返回键或导航栏返回按钮都跳转到profile
// 注意onBackPress 只能在 page 中使用component 中无效
uni.switchTab({
@@ -328,6 +329,13 @@ const goToProfile = () => {
// 跳转到地址管理
const goToAddressList = () => {
const userId = userInfo.value.id != null && userInfo.value.id !== ''
? userInfo.value.id
: ((uni.getStorageSync('user_id') as string | null) ?? '')
if (userId === '') {
goToLogin('/pages/mall/consumer/address-list')
return
}
uni.navigateTo({
url: '/pages/mall/consumer/address-list'
})
@@ -484,6 +492,13 @@ const checkUpdate = () => {
// 联系客服
const contactService = () => {
const userId = userInfo.value.id != null && userInfo.value.id !== ''
? userInfo.value.id
: ((uni.getStorageSync('user_id') as string | null) ?? '')
if (userId === '') {
goToLogin('/pages/mall/consumer/settings')
return
}
uni.navigateTo({
url: '/pages/mall/consumer/chat'
})
@@ -505,6 +520,74 @@ const rateApp = () => {
})
}
const doLogout = (): void => {
uni.showLoading({
title: '正在退出...'
})
uni.removeStorageSync('userInfo')
uni.removeStorageSync('user_id')
uni.removeStorageSync('access_token')
uni.hideLoading()
uni.showToast({
title: '已退出登录',
icon: 'success'
})
setTimeout(() => {
uni.reLaunch({
url: '/pages/user/login'
})
}, 1000)
}
const getStoredUserId = (): string => {
if (userInfo.value.id != null && userInfo.value.id !== '') {
return userInfo.value.id
}
const storageId = uni.getStorageSync('user_id')
if (storageId != null) {
return storageId as string
}
return ''
}
const doDeleteAccount = (): void => {
uni.showLoading({
title: '注销中...'
})
const userId = getStoredUserId()
if (userId != '') {
const updateObj: UTSJSONObject = new UTSJSONObject()
updateObj.set('status', 3)
supa
.from('ml_user_profiles')
.update(updateObj)
.eq('user_id', userId)
.execute()
}
uni.removeStorageSync('userInfo')
uni.removeStorageSync('user_id')
uni.removeStorageSync('access_token')
uni.hideLoading()
uni.showToast({
title: '账号已注销',
icon: 'success',
duration: 2000
})
setTimeout(() => {
uni.reLaunch({
url: '/pages/user/login'
})
}, 1500)
}
// 退出登录
const logout = () => {
uni.showModal({
@@ -512,26 +595,7 @@ const logout = () => {
content: '确定要退出登录吗?',
success: (res) => {
if (res.confirm) {
uni.showLoading({
title: '正在退出...'
})
uni.removeStorageSync('userInfo')
uni.removeStorageSync('user_id')
uni.removeStorageSync('access_token')
uni.hideLoading()
uni.showToast({
title: '已退出登录',
icon: 'success'
})
setTimeout(() => {
uni.reLaunch({
url: '/pages/user/login'
})
}, 1000)
doLogout()
}
}
})
@@ -544,42 +608,7 @@ const deleteAccount = () => {
confirmColor: '#ff4757',
success: (res) => {
if (res.confirm) {
uni.showLoading({
title: '注销中...'
})
let userId: string | null = userInfo.value.id
if (userId == null || userId === '') {
const storageId = uni.getStorageSync('user_id')
userId = (storageId != null) ? storageId as string : null
}
if (userId != null) {
const updateObj: UTSJSONObject = new UTSJSONObject()
updateObj.set('status', 3)
supa
.from('ml_user_profiles')
.update(updateObj)
.eq('user_id', userId)
.execute()
}
uni.removeStorageSync('userInfo')
uni.removeStorageSync('user_id')
uni.removeStorageSync('access_token')
uni.hideLoading()
uni.showToast({
title: '账号已注销',
icon: 'success',
duration: 2000
})
setTimeout(() => {
uni.reLaunch({
url: '/pages/user/login'
})
}, 1500)
doDeleteAccount()
}
}
})

View File

@@ -135,7 +135,7 @@ const shareRecord = ref<ShareRecordType>({
const buyers = ref<BuyerType[]>([])
const buyersLoading = ref<boolean>(false)
const defaultImage: string = '/static/images/default-product.png'
const defaultImage: string = '/static/images/default.png'
const loadShareDetail = async (): Promise<void> => {
if (shareId.value === '') return
@@ -255,9 +255,10 @@ const formatTime = (timeStr: string | null): string => {
onLoad((options) => {
if (options != null) {
const idVal = options['id']
if (idVal != null) {
shareId.value = idVal as string
const optionsObj = options as UTSJSONObject
const idVal = optionsObj.getString('id') ?? ''
if (idVal != '') {
shareId.value = idVal
loadShareDetail()
}
}

View File

@@ -88,7 +88,7 @@ type ShareRecord = {
const shares = ref<ShareRecord[]>([])
const loading = ref<boolean>(true)
const showRules = ref<boolean>(false)
const defaultImage: string = '/static/images/default-product.png'
const defaultImage: string = '/static/images/default.png'
const totalShares = computed((): number => shares.value.length)

View File

@@ -1,11 +1,11 @@
<template>
<view class="shop-detail-page">
<scroll-view class="page-scroll" scroll-y="true" @scrolltolower="onScrollToLower" refresher-enabled="true" @refresherrefresh="onRefresherRefresh" :refresher-triggered="isRefresherTriggered">
<scroll-view class="page-scroll" direction="vertical" @scrolltolower="onScrollToLower" refresher-enabled="true" @refresherrefresh="onRefresherRefresh" :refresher-triggered="isRefresherTriggered">
<!-- 店铺头部信息 -->
<view class="shop-header">
<image :src="merchant.shop_banner != '' ? merchant.shop_banner : '/static/default-banner.png'" class="shop-banner" mode="aspectFill" />
<image :src="merchant.shop_banner != '' ? merchant.shop_banner : DEFAULT_IMAGE" class="shop-banner" mode="aspectFill" @error="handleShopBannerError" />
<view class="shop-info-card">
<image :src="merchant.shop_logo != '' ? merchant.shop_logo : '/static/default-shop.png'" class="shop-logo" />
<image :src="merchant.shop_logo != '' ? merchant.shop_logo : DEFAULT_IMAGE" class="shop-logo" @error="handleShopLogoError" />
<view class="shop-basic-info">
<text class="shop-name">{{ merchant.shop_name }}</text>
<view class="shop-stats">
@@ -56,7 +56,7 @@
<view class="results-list">
<view v-for="product in products" :key="product.id" class="result-item" @click="goToProduct(product.id)">
<image :src="product.images[0]" class="product-image" mode="aspectFill" />
<image :src="product.images[0]" class="product-image" mode="aspectFill" @error="() => handleProductImageError(product.id)" />
<text class="product-name" :lines="2">{{ product.name }}</text>
<view class="product-bottom">
<text class="product-price">¥{{ product.price }}</text>
@@ -75,6 +75,7 @@
import { ref, onMounted } from 'vue'
import { MerchantType, ProductType } from '@/types/mall-types.uts'
import { supabaseService } from '@/utils/supabaseService.uts'
import { goToLogin } from '@/utils/utils.uts'
// 优惠券类型定义
type CouponType = {
@@ -93,6 +94,7 @@ const pageSize = ref(6) // 默认显示六个
const hasMore = ref(true)
const isLoading = ref(false)
const currentMerchantId = ref('')
const DEFAULT_IMAGE = '/static/images/default.png'
const merchant = ref<MerchantType>({
id: '',
@@ -114,6 +116,19 @@ const isFollowed = ref(false)
const coupons = ref<CouponType[]>([]) // 新增优惠券
const isRefresherTriggered = ref(false)
const getCurrentPageOptions = (): UTSJSONObject => {
const pages = getCurrentPages()
if (pages.length == 0) {
return new UTSJSONObject()
}
const currentPage = pages[pages.length - 1]
const pageOptions = currentPage.options
if (pageOptions == null) {
return new UTSJSONObject()
}
return pageOptions as UTSJSONObject
}
// 函数定义必须在 onMounted 之前
// checkFollowStatus 必须在 loadShopData 之前定义
const checkFollowStatus = async (shopId: string) => {
@@ -139,8 +154,8 @@ const loadShopData = async (id: string) => {
id: shop.id,
user_id: shop.merchant_id,
shop_name: shop.shop_name,
shop_logo: shop.shop_logo != null ? shop.shop_logo : '/static/default-shop.png',
shop_banner: shop.shop_banner != null ? shop.shop_banner : '/static/default-banner.png',
shop_logo: shop.shop_logo != null ? shop.shop_logo : '/static/images/default.png',
shop_banner: shop.shop_banner != null ? shop.shop_banner : '/static/images/default.png',
shop_description: shop.description != null ? shop.description : '',
contact_name: shop.contact_name != null ? shop.contact_name : '',
contact_phone: shop.contact_phone != null ? shop.contact_phone : '',
@@ -241,8 +256,9 @@ const loadShopProducts = async (id: string) => {
if (arr.length > 0) {
if (images.length == 0) images.push(...arr)
}
} else if (typeof imageUrls === 'string') {
const rawUrl = imageUrls as string
} else {
const rawUrl = itemObj.getString('image_urls') ?? ''
if (rawUrl != '') {
if (rawUrl.startsWith('[')) {
const parsed = JSON.parse(rawUrl)
if (Array.isArray(parsed)) {
@@ -252,6 +268,7 @@ const loadShopProducts = async (id: string) => {
} else {
if (images.indexOf(rawUrl) === -1) images.push(rawUrl)
}
}
}
} catch(e) {
console.error('解析图片数组失败:', e)
@@ -260,7 +277,7 @@ const loadShopProducts = async (id: string) => {
// 没有任何图片则使用默认
if (images.length === 0) {
images.push('/static/default-product.png')
images.push('/static/images/default.png')
}
return {
@@ -303,9 +320,24 @@ const loadShopProducts = async (id: string) => {
uni.stopPullDownRefresh()
}
async function loadInitialShopData(paramId: string): Promise<void> {
await loadShopData(paramId)
const realMerchantId = merchant.value.user_id
if (realMerchantId != null && realMerchantId != '') {
console.log('Chain loading products for Corrected Merchant ID:', realMerchantId)
currentMerchantId.value = realMerchantId
await loadShopProducts(realMerchantId)
await loadCoupons(realMerchantId)
} else {
console.warn('Shop load failed or id empty, fallback using original id:', paramId)
currentMerchantId.value = paramId
await loadShopProducts(paramId)
await loadCoupons(paramId)
}
}
onMounted(() => {
const pages = getCurrentPages()
const options = pages[pages.length - 1].options as UTSJSONObject
const options = getCurrentPageOptions()
// Search传递的是 id (shop_id), 其他地方可能传递 merchantId
const mId = options.get('merchantId')
const pId = options.get('id')
@@ -313,23 +345,7 @@ onMounted(() => {
if (paramId != null && paramId != '') {
console.log('Page mounted with params:', paramId)
// 优先加载店铺信息
loadShopData(paramId).then(() => {
// 加载成功后,使用确定的 merchant_id 来查询关联数据 (商品/优惠券通常是关联在 merchant_id 上的)
const realMerchantId = merchant.value.user_id // 这里 user_id 映射了 DB 中的 merchant_id
if (realMerchantId != null && realMerchantId != '') {
console.log('Chain loading products for Corrected Merchant ID:', realMerchantId)
currentMerchantId.value = realMerchantId // 更新当前上下文ID
loadShopProducts(realMerchantId)
loadCoupons(realMerchantId)
} else {
// 防御性策略:如果没能获取 merchant_id尝试用传入 ID
console.warn('Shop load failed or id empty, fallback using original id:', paramId)
currentMerchantId.value = paramId
loadShopProducts(paramId)
loadCoupons(paramId)
}
})
loadInitialShopData(paramId)
} else {
console.error('No ID passed to shop-detail')
uni.showToast({title: '参数错误', icon: 'error'})
@@ -344,12 +360,8 @@ const onRefresherRefresh = () => {
if (currentMerchantId.value != '') {
const id = currentMerchantId.value
Promise.all([
loadShopData(id),
loadCoupons(id),
loadShopProducts(id)
]).then(() => {
isRefresherTriggered.value = false
loadInitialShopData(id).finally(() => {
isRefresherTriggered.value = false
})
} else {
setTimeout(() => {
@@ -365,6 +377,27 @@ const onScrollToLower = () => {
}
}
const handleShopBannerError = () => {
merchant.value.shop_banner = DEFAULT_IMAGE
}
const handleShopLogoError = () => {
merchant.value.shop_logo = DEFAULT_IMAGE
}
const handleProductImageError = (productId: string) => {
for (let i = 0; i < products.value.length; i++) {
if (products.value[i].id === productId) {
if (products.value[i].images.length === 0) {
products.value[i].images.push(DEFAULT_IMAGE)
} else {
products.value[i].images[0] = DEFAULT_IMAGE
}
return
}
}
}
onPullDownRefresh(() => {
onRefresherRefresh()
})
@@ -408,8 +441,8 @@ const claimCoupon = async (coupon: any) => {
const toggleFollow = async () => {
const userId = supabaseService.getCurrentUserId()
if (userId == null) {
uni.navigateTo({ url: '/pages/auth/login' })
if (userId == null || userId === '') {
goToLogin(`/pages/mall/consumer/shop-detail?id=${merchant.value.id}`)
return
}
@@ -446,8 +479,8 @@ const toggleFollow = async () => {
const contactService = () => {
const currentUser = supabaseService.getCurrentUserId()
if (currentUser == null) {
uni.navigateTo({ url: '/pages/user/login' })
if (currentUser == null || currentUser === '') {
goToLogin(`/pages/mall/consumer/shop-detail?id=${merchant.value.id}`)
return
}
@@ -461,6 +494,11 @@ const contactService = () => {
}
const addToCart = async (product: ProductType) => {
const currentUser = supabaseService.getCurrentUserId()
if (currentUser == null || currentUser === '') {
goToLogin(`/pages/mall/consumer/shop-detail?id=${merchant.value.id}`)
return
}
uni.showLoading({ title: '检查商品...' })
try {

View File

@@ -6,7 +6,7 @@
<view class="shop-list" v-if="shops.length > 0">
<view class="shop-item" v-for="shop in shops" :key="shop.id" @click="goToShop(shop)">
<image :src="shop.shop_logo != null ? shop.shop_logo : '/static/default-shop.png'" class="shop-logo" mode="aspectFill" />
<image :src="shop.shop_logo != null ? shop.shop_logo : '/static/images/default.png'" class="shop-logo" mode="aspectFill" />
<view class="shop-info">
<text class="shop-name">{{ shop.shop_name }}</text>
<text class="shop-desc">{{ shop.description != null ? shop.description : '暂无介绍' }}</text>

View File

@@ -11,31 +11,31 @@
<view v-else class="list">
<view class="card" v-for="s in items" :key="s['id']">
<view class="row between">
<text class="name">{{ s['plan']?.['name'] != null ? s['plan']?.['name'] : '订阅' }}</text>
<text class="status" :class="'st-' + (s['status'] != null ? s['status'] : 'active')">{{ statusText(s['status'] as string) }}</text>
<text class="name">{{ getPlanName(s) }}</text>
<text class="status" :class="'st-' + getSubscriptionStatus(s)">{{ statusText(getSubscriptionStatus(s)) }}</text>
</view>
<view class="row">
<text class="label">周期</text>
<text class="value">{{ (s['plan']?.['billing_period'] === 'yearly') ? '年付' : '月付' }}</text>
<text class="value">{{ getBillingPeriodText(s) }}</text>
</view>
<view class="row">
<text class="label">价格</text>
<text class="value">¥{{ s['plan']?.['price'] }}</text>
<text class="value">¥{{ getPlanPrice(s) }}</text>
</view>
<view class="row">
<text class="label">开始</text>
<text class="value">{{ fmt(s['start_date'] as string) }}</text>
<text class="value">{{ fmt(s.getString('start_date')) }}</text>
</view>
<view class="row">
<text class="label">下次扣费</text>
<text class="value">{{ fmt(s['next_billing_date'] as string) }}</text>
<text class="value">{{ fmt(s.getString('next_billing_date')) }}</text>
</view>
<view class="actions">
<label class="toggle">
<switch :checked="!!s['auto_renew']" @change="e => toggleAutoRenew(s, e.detail.value as boolean)" />
<switch :checked="getAutoRenew(s)" @change="(e: UniSwitchChangeEvent) => toggleAutoRenew(s, e.detail.value as boolean)" />
<text class="toggle-text">自动续费</text>
</label>
<button class="danger" @click="cancelAtPeriodEnd(s)" :disabled="(s['status'] as string) !== 'active'">到期取消</button>
<button class="danger" @click="cancelAtPeriodEnd(s)" :disabled="getSubscriptionStatus(s) !== 'active'">到期取消</button>
</view>
</view>
</view>
@@ -50,17 +50,52 @@ import { getCurrentUserId } from '@/utils/store.uts'
const loading = ref<boolean>(true)
const items = ref<Array<UTSJSONObject>>([])
function getPlanObj(s: UTSJSONObject): UTSJSONObject | null {
const planRaw = s.get('plan')
if (planRaw == null) return null
if (planRaw instanceof UTSJSONObject) return planRaw as UTSJSONObject
return JSON.parse(JSON.stringify(planRaw)) as UTSJSONObject
}
function getPlanName(s: UTSJSONObject): string {
const planObj = getPlanObj(s)
return planObj != null ? (planObj.getString('name') ?? '订阅') : '订阅'
}
function getSubscriptionStatus(s: UTSJSONObject): string {
return s.getString('status') ?? 'active'
}
function getBillingPeriodText(s: UTSJSONObject): string {
const planObj = getPlanObj(s)
const period = planObj != null ? (planObj.getString('billing_period') ?? 'monthly') : 'monthly'
return period === 'yearly' ? '年付' : '月付'
}
function getPlanPrice(s: UTSJSONObject): string {
const planObj = getPlanObj(s)
const price = planObj != null ? (planObj.getNumber('price') ?? 0) : 0
return price.toString()
}
function getAutoRenew(s: UTSJSONObject): boolean {
return s.getBoolean('auto_renew') ?? false
}
const fmt = (s: string | null): string => {
if (s == null || s.length === 0) return '-'
const d = new Date(s)
if (isNaN(d.getTime())) return '-'
return `${d.getFullYear()}-${(d.getMonth()+1).toString().padStart(2,'0')}-${d.getDate().toString().padStart(2,'0')}`
if (Number.isNaN(d.getTime())) return '-'
return `${d.getFullYear()}-${(d.getMonth()+1).toString().padStart(2,"0")}-${d.getDate().toString().padStart(2,"0")}`
}
const statusText = (st: string): string => {
const map: UTSJSONObject = { trial: '试用', active: '生效', past_due: '逾期', canceled: '已取消', expired: '已过期' } as UTSJSONObject
const val = map[st] as string | null
return val != null ? val : st
if (st === 'trial') return '试用'
if (st === 'active') return '生效'
if (st === 'past_due') return '逾期'
if (st === 'canceled') return '已取消'
if (st === 'expired') return '已过期'
return st
}
const loadSubs = async () => {
@@ -89,14 +124,14 @@ const loadSubs = async () => {
const toggleAutoRenew = async (s: UTSJSONObject, value: boolean) => {
try {
const id = (s['id'] ?? '') as string
const id = s.getString('id') ?? ''
const res = await supaClient
.from('ml_user_subscriptions')
.update({ auto_renew: value })
.eq('id', id)
.execute()
if (res.error != null) throw new Error(res.error?.message ?? '未知错误')
s['auto_renew'] = value
s.set('auto_renew', value)
uni.showToast({ title: value ? '已开启自动续费' : '已关闭自动续费', icon: 'success' })
} catch (e) {
console.error('更新自动续费失败:', e)
@@ -106,15 +141,15 @@ const toggleAutoRenew = async (s: UTSJSONObject, value: boolean) => {
const cancelAtPeriodEnd = async (s: UTSJSONObject) => {
try {
const id = (s['id'] ?? '') as string
const id = s.getString('id') ?? ''
const res = await supaClient
.from('ml_user_subscriptions')
.update({ cancel_at_period_end: true })
.eq('id', id)
.execute()
if (res.error != null) throw new Error(res.error?.message ?? '未知错误')
s['cancel_at_period_end'] = true
s['status'] = 'active' // 保持到期前仍为active
s.set('cancel_at_period_end', true)
s.set('status', 'active')
uni.showToast({ title: '已设置到期取消', icon: 'success' })
} catch (e) {
console.error('设置到期取消失败:', e)
@@ -126,7 +161,9 @@ const goPlanList = () => {
uni.navigateTo({ url: '/pages/mall/consumer/subscription/plan-list' })
}
onMounted(loadSubs)
onMounted(() => {
loadSubs()
})
// 注意uni-app x 的 <script setup> 中不支持 onShow使用 onMounted 代替
// 如果需要页面显示时刷新,可以在页面选项中定义 onShow
</script>

View File

@@ -1,4 +1,4 @@
<template>
<template>
<view class="plan-detail">
<view class="header">
<text class="title">订阅方案详情</text>
@@ -6,18 +6,18 @@
<view v-if="loading" class="loading">加载中...</view>
<view v-else-if="plan == null" class="empty">未找到该方案</view>
<view v-else class="card">
<text class="name">{{ plan['name'] }}</text>
<text class="desc">{{ plan['description'] != null && (plan['description'] as string).length > 0 ? plan['description'] : '—' }}</text>
<text class="name">{{ getPlanName() }}</text>
<text class="desc">{{ getPlanDescription() }}</text>
<view class="price-row">
<text class="price">¥{{ plan['price'] }}</text>
<text class="period">/{{ plan['billing_period'] === 'yearly' ? '年' : '月' }}</text>
<text class="price">¥{{ getPlanPrice() }}</text>
<text class="period">/{{ getBillingPeriodText() }}</text>
</view>
<view class="features">
<text class="f-title">包含功能</text>
<view class="f-list">
<text class="f-item" v-for="(v,k) in toFeatureArray(plan['features'])" :key="k">• {{ v }}</text>
<text class="f-item" v-for="(v,k) in toFeatureArray(getPlanFeaturesSource())" :key="k">• {{ v }}</text>
</view>
</view>
@@ -37,22 +37,61 @@ const planId = ref<string>('')
const loading = ref<boolean>(true)
const plan = ref<UTSJSONObject | null>(null)
onLoad((opts: OnLoadOptions) => {
planId.value = (opts['id'] ?? '') as string
function getPlanName(): string {
return plan.value != null ? (plan.value.getString('name') ?? '') : ''
}
function getPlanDescription(): string {
const desc = plan.value != null ? (plan.value.getString('description') ?? '') : ''
return desc !== '' ? desc : '—'
}
function getPlanPrice(): string {
const price = plan.value != null ? (plan.value.getNumber('price') ?? 0) : 0
return price.toString()
}
function getBillingPeriodText(): string {
const period = plan.value != null ? (plan.value.getString('billing_period') ?? 'monthly') : 'monthly'
return period === 'yearly' ? '年' : '月'
}
function getPlanFeatures(): any {
if (plan.value == null) return ''
const features = plan.value.get('features')
return features != null ? features : ''
}
function getPlanFeaturesSource(): any {
const features = getPlanFeatures()
return features != null ? features : ''
}
onLoad((opts) => {
if (opts == null) return
const optObj = opts as UTSJSONObject
planId.value = optObj.getString('id') ?? ''
})
const toFeatureArray = (features: any): Array<string> => {
const arr: Array<string> = []
if (features == null) return arr
if (features instanceof UTSJSONObject) {
const featureMap = (features as UTSJSONObject).toMap()
const entries = featureMap.entries()
for (let i = 0; i < entries.length; i++) {
const entry = entries[i]
const v = entry.value
const vs = typeof v === 'string' ? v : JSON.stringify(v)
arr.push(vs)
const raw = JSON.stringify(features)
if (raw == null || raw === '') return arr
try {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) {
for (let i = 0; i < parsed.length; i++) {
arr.push(JSON.stringify(parsed[i]).replace(/[\[\]\{\}"]/g, ''))
}
return arr
}
if (parsed instanceof UTSJSONObject) {
arr.push(JSON.stringify(parsed))
return arr
}
} catch (e) {
arr.push(raw)
}
return arr
}
@@ -87,11 +126,13 @@ const loadPlan = async () => {
const toCheckout = () => {
if (plan.value == null) return
const id = (plan.value['id'] ?? '') as string
const id = plan.value.getString('id') ?? ''
uni.navigateTo({ url: `/pages/mall/consumer/subscription/subscribe-checkout?planId=${id}` })
}
onMounted(loadPlan)
onMounted(() => {
loadPlan()
})
</script>
<style scoped>

View File

@@ -1,22 +1,22 @@
<template>
<template>
<view class="sub-plan-list">
<view class="header">
<text class="title">软件订阅</text>
</view>
<view class="plan-container" v-if="!loading && plans.length > 0">
<view class="plan-card" v-for="p in plans" :key="p['id']" @click="goPlanDetail(p)">
<view class="plan-card" v-for="p in plans" :key="getPlanId(p)" @click="goPlanDetail(p)">
<view class="plan-header">
<text class="plan-name">{{ p['name'] }}</text>
<text v-if="p['billing_period'] === 'yearly'" class="badge">年付优惠</text>
<text class="plan-name">{{ getPlanName(p) }}</text>
<text v-if="getBillingPeriod(p) === 'yearly'" class="badge">年付优惠</text>
</view>
<text class="plan-desc">{{ p['description'] != null && (p['description'] as string).length > 0 ? p['description'] : '适用于大部分使用场景' }}</text>
<text class="plan-desc">{{ getPlanDescription(p) }}</text>
<view class="price-row">
<text class="price">¥{{ p['price'] }}</text>
<text class="period">/{{ p['billing_period'] === 'yearly' ? '年' : '月' }}</text>
<text class="price">¥{{ getPlanPrice(p) }}</text>
<text class="period">/{{ getBillingPeriod(p) === 'yearly' ? '年' : '月' }}</text>
</view>
<view class="feature-list">
<text class="feature-item" v-for="(v,k) in toFeatureArray(p['features'])" :key="k">• {{ v }}</text>
<text class="feature-item" v-for="(v,k) in toFeatureArray(getPlanFeatures(p))" :key="k">• {{ v }}</text>
</view>
<view class="actions">
<button class="primary" @click.stop="toCheckout(p)">立即订阅</button>
@@ -39,18 +39,60 @@ import supaClient from '@/components/supadb/aksupainstance.uts'
const loading = ref<boolean>(true)
const plans = ref<Array<UTSJSONObject>>([])
function normalizePlanObject(p: any): UTSJSONObject {
if (p instanceof UTSJSONObject) {
return p as UTSJSONObject
}
const raw = JSON.stringify(p)
if (raw == null || raw === '') {
return new UTSJSONObject()
}
return JSON.parse(raw) as UTSJSONObject
}
function getPlanId(p: any): string {
return normalizePlanObject(p).getString('id') ?? ''
}
function getPlanName(p: any): string {
return normalizePlanObject(p).getString('name') ?? ''
}
function getBillingPeriod(p: any): string {
return normalizePlanObject(p).getString('billing_period') ?? 'monthly'
}
function getPlanDescription(p: any): string {
const desc = normalizePlanObject(p).getString('description') ?? ''
return desc !== '' ? desc : '适用于大部分使用场景'
}
function getPlanPrice(p: any): string {
const price = normalizePlanObject(p).getNumber('price') ?? 0
return price.toString()
}
function getPlanFeatures(p: any): any {
const features = normalizePlanObject(p).get('features')
return features != null ? features : ''
}
const toFeatureArray = (features: any): Array<string> => {
const arr: Array<string> = []
if (features == null) return arr
if (features instanceof UTSJSONObject) {
const featureMap = (features as UTSJSONObject).toMap()
const entries = featureMap.entries()
for (let i = 0; i < entries.length; i++) {
const entry = entries[i]
const v = entry.value
const vs = typeof v === 'string' ? v : JSON.stringify(v)
arr.push(vs)
const raw = JSON.stringify(features)
if (raw == null || raw === '') return arr
try {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) {
for (let i = 0; i < parsed.length; i++) {
arr.push(JSON.stringify(parsed[i]).replace(/[\[\]\{\}"]/g, ''))
}
return arr
}
arr.push(raw)
} catch (e) {
arr.push(raw)
}
return arr
}
@@ -77,17 +119,21 @@ const loadPlans = async () => {
}
}
const goPlanDetail = (p: UTSJSONObject) => {
const id = (p['id'] ?? '') as string
const goPlanDetail = (p: any) => {
const planObj = normalizePlanObject(p)
const id = planObj.getString('id') ?? ''
uni.navigateTo({ url: `/pages/mall/consumer/subscription/plan-detail?id=${id}` })
}
const toCheckout = (p: UTSJSONObject) => {
const id = (p['id'] ?? '') as string
const toCheckout = (p: any) => {
const planObj = normalizePlanObject(p)
const id = planObj.getString('id') ?? ''
uni.navigateTo({ url: `/pages/mall/consumer/subscription/subscribe-checkout?planId=${id}` })
}
onMounted(loadPlans)
onMounted(() => {
loadPlans()
})
</script>
<style scoped>

View File

@@ -1,4 +1,4 @@
<template>
<template>
<view class="subscribe-checkout">
<view class="header">
<text class="title">确认订阅</text>
@@ -47,21 +47,16 @@
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import supaClient from '@/components/supadb/aksupainstance.uts'
import { PAYMENT_METHOD } from '@/types/mall-types.uts'
import { goToLogin } from '@/utils/utils.uts'
const planId = ref<string>('')
const loading = ref<boolean>(true)
const plan = ref<UTSJSONObject | null>(null)
const payMethod = ref<number>(PAYMENT_METHOD.WECHAT)
const payMethod = ref<number>(1)
const trialDays = ref<number>(0)
const submitting = ref<boolean>(false)
onLoad(async (opts: OnLoadOptions) => {
planId.value = (opts['planId'] ?? '') as string
await loadPlan()
})
const loadPlan = async () => {
async function loadPlan(): Promise<void> {
try {
loading.value = true
const res = await supaClient
@@ -76,7 +71,7 @@ const loadPlan = async () => {
} else {
plan.value = res.data as UTSJSONObject
}
trialDays.value = (plan.value?.['trial_days'] ?? 0) as number
trialDays.value = plan.value != null ? (plan.value.getNumber('trial_days') ?? 0) : 0
} else {
plan.value = null
}
@@ -87,6 +82,13 @@ const loadPlan = async () => {
}
}
onLoad((opts) => {
if (opts == null) return
const optObj = opts as UTSJSONObject
planId.value = optObj.getString('planId') ?? ''
loadPlan()
})
const selPay = (v: number) => { payMethod.value = v }
// 获取当前用户ID按现有store实现替换
@@ -103,7 +105,7 @@ const confirmSubscribe = async () => {
if (plan.value == null) return
const userId = getCurrentUserId()
if (userId.length === 0) {
uni.showToast({ title: '请先登录', icon: 'none' })
goToLogin(`/pages/mall/consumer/subscription/subscribe-checkout?planId=${planId.value}`)
return
}
@@ -114,30 +116,33 @@ const confirmSubscribe = async () => {
const start = now.toISOString()
// 简单计算下个扣费日
let nextBilling: string | null = null
if ((plan.value?.['billing_period'] ?? 'monthly') === 'yearly') {
const billingPeriod = plan.value.getString('billing_period') ?? 'monthly'
if (billingPeriod === 'yearly') {
nextBilling = new Date(now.getFullYear() + 1, now.getMonth(), now.getDate()).toISOString()
} else {
nextBilling = new Date(now.getFullYear(), now.getMonth() + 1, now.getDate()).toISOString()
}
const body = {
user_id: userId,
plan_id: plan.value['id'],
status: 'active',
start_date: start,
end_date: null,
next_billing_date: nextBilling,
auto_renew: true,
metadata: { pay_method: payMethod.value }
}
const metadata = new UTSJSONObject()
metadata.set('pay_method', payMethod.value)
const body = new UTSJSONObject()
body.set('user_id', userId)
body.set('plan_id', plan.value.getString('id') ?? '')
body.set('status', 'active')
body.set('start_date', start)
body.set('end_date', null)
body.set('next_billing_date', nextBilling)
body.set('auto_renew', true)
body.set('metadata', metadata)
const ins = await supaClient
.from('ml_user_subscriptions')
.insert(body)
.single?.()
.execute()
if (ins != null && ins.error == null) {
uni.showToast({ title: '订阅成功', icon: 'success' })
setTimeout(() => {
uni.redirectTo({ url: '/pages/main/profile' })
uni.switchTab({ url: '/pages/main/profile' })
}, 600)
} else {
uni.showToast({ title: ins?.error?.message ?? '订阅失败', icon: 'none' })

View File

@@ -6,7 +6,7 @@
<text class="back-btn" @click="goBack"></text>
</view>-->
<scroll-view class="wallet-content" scroll-y>
<scroll-view class="wallet-content" direction="vertical">
<view class="dashboard-container">
<!-- 左侧/顶部区域:资产信息 -->
<view class="dashboard-main">

View File

@@ -1,4 +1,4 @@
<template>
<template>
<view class="page-container">
<view class="card">
<view class="section-title">提现至</view>
@@ -49,7 +49,7 @@
<text class="popup-title">选择到账银行卡</text>
<text class="close-btn" @click="showBankPopup = false">×</text>
</view>
<scroll-view scroll-y="true" class="bank-list">
<scroll-view direction="vertical" class="bank-list">
<view
v-for="(item, index) in bankCards"
:key="index"

View File

@@ -612,9 +612,11 @@ export default {
},
contactService() {
// #ifndef MP-WEIXIN
uni.navigateTo({
url: `/pages/mall/service/chat?orderId=${this.order.id}&type=delivery`
})
// #endif
}
}
}

View File

@@ -0,0 +1,122 @@
<template>
<scroll-view class="page" scroll-y="true">
<ServicePanel title="到岗签到" subtitle="先用 mock 方式记录签到结果、到岗说明和留痕提示。">
<text class="info">任务编号:{{ taskNo }}</text>
<text class="info">当前状态:{{ taskStatus }}</text>
<view class="block">
<text class="label">签到确认</text>
<view class="tag-row">
<view class="tag active">GPS 已到位</view>
<view class="tag active">服务对象确认</view>
<view class="tag">拍照凭证待接入</view>
</view>
</view>
<view class="block">
<text class="label">签到说明</text>
<textarea v-model="note" class="textarea" placeholder="可填写到岗时间、现场情况、陪护确认信息"></textarea>
</view>
<view class="submit-btn" @click="submitCheckIn">确认签到</view>
</ServicePanel>
</scroll-view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import ServicePanel from '@/components/homeService/ServicePanel.uvue'
import { fetchWorkerTaskDetail, submitWorkerCheckIn } from '@/services/homeServiceService.uts'
const taskId = ref('')
const taskNo = ref('')
const taskStatus = ref('待上门')
const note = ref('已完成到岗签到,服务对象和家属已确认。')
onLoad((options) => {
const id = options['id']
if (id != null) {
taskId.value = id as string
fetchWorkerTaskDetail(taskId.value).then((res) => {
if (res != null) {
taskNo.value = res.caseNo
taskStatus.value = res.statusText
}
})
}
})
async function submitCheckIn() {
if (taskId.value == '') {
return
}
const result = await submitWorkerCheckIn(taskId.value, note.value)
if (result != null) {
uni.showToast({ title: '签到完成', icon: 'success' })
setTimeout(() => {
uni.navigateBack()
}, 400)
}
}
</script>
<style scoped>
.page {
min-height: 100vh;
background: #f3f7f9;
padding: 24rpx;
box-sizing: border-box;
}
.info,
.label {
font-size: 28rpx;
line-height: 40rpx;
color: #16324f;
}
.block {
margin-top: 24rpx;
}
.tag-row {
margin-top: 16rpx;
flex-direction: row;
flex-wrap: wrap;
gap: 16rpx;
}
.tag {
padding: 12rpx 20rpx;
border-radius: 999rpx;
background: #eef2f6;
font-size: 24rpx;
color: #66788a;
}
.active {
background: #e8f7ef;
color: #15803d;
}
.textarea {
margin-top: 16rpx;
width: 100%;
height: 220rpx;
padding: 24rpx;
box-sizing: border-box;
background: #f8fbfc;
border-radius: 20rpx;
font-size: 28rpx;
color: #23384d;
}
.submit-btn {
margin-top: 32rpx;
padding: 26rpx 0;
border-radius: 20rpx;
background: #0f766e;
text-align: center;
font-size: 30rpx;
font-weight: 700;
color: #ffffff;
}
</style>

View File

@@ -0,0 +1,143 @@
<template>
<scroll-view class="page" scroll-y="true">
<ServicePanel title="异常上报" subtitle="先完成异常类型、说明和调度通知的 mock 处理。">
<text class="info">任务编号:{{ taskNo }}</text>
<view class="block">
<text class="label">异常类型</text>
<view class="type-wrap">
<view
v-for="item in exceptionTypes"
:key="item"
class="type-item"
:class="selectedType == item ? 'active-type' : ''"
@click="selectedType = item"
>
{{ item }}
</view>
</view>
</view>
<view class="block">
<text class="label">异常说明</text>
<textarea v-model="description" class="textarea" placeholder="填写未能执行原因、现场情况、已采取措施"></textarea>
</view>
<view class="block">
<text class="label">处理建议</text>
<view class="suggestion-box">上报后将同步给调度员与管理端,后续可继续补充整改结果。</view>
</view>
<view class="submit-btn warn" @click="submitException">提交异常</view>
</ServicePanel>
</scroll-view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import ServicePanel from '@/components/homeService/ServicePanel.uvue'
import { fetchWorkerTaskDetail, submitWorkerException } from '@/services/homeServiceService.uts'
const taskId = ref('')
const taskNo = ref('')
const selectedType = ref('服务对象不在家')
const description = ref('现场无人响应,已电话联系家属,等待调度重新确认上门时间。')
const exceptionTypes = ['服务对象不在家', '临时身体不适', '环境不具备执行条件', '物资缺失', '其他']
onLoad((options) => {
const id = options['id']
if (id != null) {
taskId.value = id as string
fetchWorkerTaskDetail(taskId.value).then((res) => {
if (res != null) {
taskNo.value = res.caseNo
}
})
}
})
async function submitException() {
if (taskId.value == '' || description.value == '') {
uni.showToast({ title: '请填写异常说明', icon: 'none' })
return
}
const result = await submitWorkerException(taskId.value, selectedType.value, description.value)
if (result != null) {
uni.showToast({ title: '异常已上报', icon: 'success' })
setTimeout(() => {
uni.navigateBack()
}, 400)
}
}
</script>
<style scoped>
.page {
min-height: 100vh;
background: #f3f7f9;
padding: 24rpx;
box-sizing: border-box;
}
.info,
.label,
.suggestion-box,
.type-item {
font-size: 28rpx;
line-height: 40rpx;
color: #16324f;
}
.block {
margin-top: 24rpx;
}
.type-wrap {
margin-top: 16rpx;
flex-direction: row;
flex-wrap: wrap;
gap: 16rpx;
}
.type-item {
padding: 18rpx 22rpx;
border-radius: 18rpx;
background: #f8fbfc;
}
.active-type {
background: #fff4e5;
color: #b45309;
}
.textarea {
margin-top: 16rpx;
width: 100%;
height: 240rpx;
padding: 24rpx;
box-sizing: border-box;
background: #f8fbfc;
border-radius: 20rpx;
font-size: 28rpx;
color: #23384d;
}
.suggestion-box {
margin-top: 16rpx;
padding: 22rpx 24rpx;
background: #f8fbfc;
border-radius: 18rpx;
color: #66788a;
}
.submit-btn {
margin-top: 32rpx;
padding: 26rpx 0;
border-radius: 20rpx;
text-align: center;
font-size: 30rpx;
font-weight: 700;
color: #ffffff;
}
.warn {
background: #b45309;
}
</style>

View File

@@ -0,0 +1,111 @@
<template>
<scroll-view class="page" scroll-y="true">
<ServicePanel title="服务记录" subtitle="本页先记录执行摘要、服务步骤和后续待上传凭证占位。">
<text class="info">任务编号:{{ taskNo }}</text>
<view class="block">
<text class="label">执行步骤</text>
<view class="step-card" v-for="item in steps" :key="item">{{ item }}</view>
</view>
<view class="block">
<text class="label">服务摘要</text>
<textarea v-model="summary" class="textarea" placeholder="记录生命体征、护理动作、家属沟通和后续建议"></textarea>
</view>
<view class="block">
<text class="label">凭证上传</text>
<view class="upload-box">后续接入拍照、音视频和签名凭证</view>
</view>
<view class="submit-btn" @click="submitRecord">保存记录</view>
</ServicePanel>
</scroll-view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import ServicePanel from '@/components/homeService/ServicePanel.uvue'
import { fetchWorkerTaskDetail, submitWorkerServiceRecord } from '@/services/homeServiceService.uts'
const taskId = ref('')
const taskNo = ref('')
const summary = ref('已完成血压监测、基础照护和风险提醒,家属已确认后续随访时间。')
const steps = ['生命体征记录', '基础照护执行', '家属沟通说明']
onLoad((options) => {
const id = options['id']
if (id != null) {
taskId.value = id as string
fetchWorkerTaskDetail(taskId.value).then((res) => {
if (res != null) {
taskNo.value = res.caseNo
summary.value = res.recordSummary
}
})
}
})
async function submitRecord() {
if (taskId.value == '') {
return
}
const result = await submitWorkerServiceRecord(taskId.value, summary.value)
if (result != null) {
uni.showToast({ title: '记录已保存', icon: 'success' })
setTimeout(() => {
uni.navigateBack()
}, 400)
}
}
</script>
<style scoped>
.page {
min-height: 100vh;
background: #f3f7f9;
padding: 24rpx;
box-sizing: border-box;
}
.info,
.label,
.step-card,
.upload-box {
font-size: 28rpx;
line-height: 40rpx;
color: #16324f;
}
.block {
margin-top: 24rpx;
}
.step-card,
.upload-box {
margin-top: 16rpx;
padding: 22rpx 24rpx;
background: #f8fbfc;
border-radius: 18rpx;
}
.textarea {
margin-top: 16rpx;
width: 100%;
height: 260rpx;
padding: 24rpx;
box-sizing: border-box;
background: #f8fbfc;
border-radius: 20rpx;
font-size: 28rpx;
color: #23384d;
}
.submit-btn {
margin-top: 32rpx;
padding: 26rpx 0;
border-radius: 20rpx;
background: #0f766e;
text-align: center;
font-size: 30rpx;
font-weight: 700;
color: #ffffff;
}
</style>

View File

@@ -0,0 +1,154 @@
<template>
<scroll-view class="page" scroll-y="true">
<view v-if="detail == null" class="empty-box">
<text class="empty-text">未找到任务详情</text>
</view>
<view v-else>
<ServicePanel title="任务状态" subtitle="执行端首批支持签到开始与完成提交两个动作。">
<ServiceInfoCard
:title="detail.serviceName"
:code="detail.caseNo"
:status-text="detail.statusText"
:status-tone="detail.statusTone"
:items="[
{ label: '服务对象', value: detail.elderName },
{ label: '上门时间', value: detail.appointmentTime },
{ label: '签到状态', value: detail.checkInStatus },
{ label: '执行人员', value: detail.staffName + ' ' + detail.staffPhone },
{ label: '服务地址', value: detail.address }
]"
></ServiceInfoCard>
<ServiceActionRow
:actions="[
{ key: 'check-in', label: '到岗签到', tone: 'teal' },
{ key: 'record', label: '服务记录', tone: 'ghost' },
{ key: 'exception', label: '异常上报', tone: 'warn' }
]"
@action="handleDetailAction"
></ServiceActionRow>
<view class="primary-btn" @click="handleAdvance">{{ detail.actionText }}</view>
</ServicePanel>
<ServicePanel title="服务记录">
<text class="task-info">记录摘要:{{ detail.recordSummary }}</text>
<text class="task-info">后续将补充签到拍照、服务步骤打卡和上传凭证组件。</text>
</ServicePanel>
<ServicePanel title="过程时间线">
<ServiceTimeline :items="detail.timeline"></ServiceTimeline>
</ServicePanel>
</view>
</scroll-view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import ServiceActionRow from '@/components/homeService/ServiceActionRow.uvue'
import ServiceInfoCard from '@/components/homeService/ServiceInfoCard.uvue'
import ServicePanel from '@/components/homeService/ServicePanel.uvue'
import ServiceTimeline from '@/components/homeService/ServiceTimeline.uvue'
import { advanceWorkerTask, fetchWorkerTaskDetail } from '@/services/homeServiceService.uts'
import { HomeServiceTaskType } from '@/types/home-service.uts'
const taskId = ref('')
const detail = ref<HomeServiceTaskType | null>(null)
async function loadData() {
if (taskId.value == '') {
return
}
detail.value = await fetchWorkerTaskDetail(taskId.value)
}
async function handleAdvance() {
if (taskId.value == '') {
return
}
if (detail.value != null && detail.value.status == 'pending_visit') {
goCheckIn()
return
}
const result = await advanceWorkerTask(taskId.value)
if (result != null) {
detail.value = result
uni.showToast({ title: '任务状态已更新', icon: 'success' })
}
}
function goCheckIn() {
if (taskId.value == '') {
return
}
uni.navigateTo({ url: '/pages/mall/merchant/home-service/check-in?id=' + taskId.value })
}
function goRecord() {
if (taskId.value == '') {
return
}
uni.navigateTo({ url: '/pages/mall/merchant/home-service/service-record?id=' + taskId.value })
}
function goException() {
if (taskId.value == '') {
return
}
uni.navigateTo({ url: '/pages/mall/merchant/home-service/exception-report?id=' + taskId.value })
}
function handleDetailAction(actionKey: string) {
if (actionKey == 'check-in') {
goCheckIn()
} else if (actionKey == 'record') {
goRecord()
} else if (actionKey == 'exception') {
goException()
}
}
onLoad((options) => {
const id = options['id']
if (id != null) {
taskId.value = id as string
loadData()
}
})
onShow(() => {
loadData()
})
</script>
<style scoped>
.page {
min-height: 100vh;
background: #f3f7f9;
padding: 24rpx;
box-sizing: border-box;
}
.task-info,
.empty-text {
margin-top: 10rpx;
font-size: 26rpx;
line-height: 38rpx;
color: #66788a;
}
.primary-btn {
margin-top: 24rpx;
padding: 24rpx 0;
text-align: center;
font-size: 30rpx;
font-weight: 700;
color: #ffffff;
background: #0f766e;
border-radius: 18rpx;
}
.empty-box {
padding: 120rpx 0;
align-items: center;
}
</style>

View File

@@ -0,0 +1,93 @@
<template>
<scroll-view class="page" scroll-y="true">
<ServicePanel title="执行任务" subtitle="按移动执行页风格,先做任务卡片与状态动作。">
<view v-for="item in tasks" :key="item.id" class="task-card" @click="goDetail(item.id)">
<view class="task-top">
<view>
<text class="task-title">{{ item.elderName }} · {{ item.serviceName }}</text>
<text class="task-meta">{{ item.caseNo }}</text>
</view>
<ServiceStatusTag :text="item.statusText" :tone="item.statusTone"></ServiceStatusTag>
</view>
<text class="task-info">预约时间:{{ item.appointmentTime }}</text>
<text class="task-info">签到状态:{{ item.checkInStatus }}</text>
<text class="task-info">服务地址:{{ item.address }}</text>
<view class="action-btn">{{ item.actionText }}</view>
</view>
</ServicePanel>
</scroll-view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import ServicePanel from '@/components/homeService/ServicePanel.uvue'
import ServiceStatusTag from '@/components/homeService/ServiceStatusTag.uvue'
import { fetchWorkerTasks } from '@/services/homeServiceService.uts'
import { HomeServiceTaskType } from '@/types/home-service.uts'
const tasks = ref<Array<HomeServiceTaskType>>([])
async function loadData() {
tasks.value = await fetchWorkerTasks()
}
function goDetail(taskId: string) {
uni.navigateTo({ url: '/pages/mall/merchant/home-service/task-detail?id=' + taskId })
}
onLoad(() => {
loadData()
})
onShow(() => {
loadData()
})
</script>
<style scoped>
.page {
min-height: 100vh;
background: #f3f7f9;
padding: 24rpx;
box-sizing: border-box;
}
.task-card {
padding: 24rpx;
border-radius: 20rpx;
background: #f8fbfc;
margin-bottom: 20rpx;
}
.task-top {
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
}
.task-title {
font-size: 32rpx;
font-weight: 700;
color: #16324f;
}
.task-meta,
.task-info {
margin-top: 10rpx;
font-size: 26rpx;
line-height: 38rpx;
color: #66788a;
}
.action-btn {
margin-top: 20rpx;
padding: 20rpx 0;
text-align: center;
font-size: 28rpx;
font-weight: 700;
color: #ffffff;
background: #0f766e;
border-radius: 16rpx;
}
</style>