feat:个人中心完成

This commit is contained in:
李伊帆
2026-09-16 15:21:41 +08:00
parent 56eb59ed5d
commit 8394da7ecb
34 changed files with 3415 additions and 18 deletions

View File

@@ -0,0 +1,229 @@
@import '@/styles/variables.scss';
.contract-center {
display: flex;
flex-direction: column;
min-height: 100vh;
background: $color-bg-section;
}
.contract-center__header {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
padding: calc(var(--status-bar-height, 44px) + 60rpx) 24rpx 20rpx;
background: $color-bg-page;
}
.contract-center__back {
display: flex;
align-items: center;
justify-content: center;
width: 64rpx;
height: 64rpx;
}
.contract-center__back-icon {
width: 40rpx;
height: 40rpx;
}
.contract-center__title {
font-size: 34rpx;
font-weight: 600;
color: $color-text-primary;
}
.contract-center__header-placeholder {
width: 64rpx;
height: 64rpx;
}
.contract-center__toolbar {
flex-shrink: 0;
padding: 0 24rpx 24rpx;
background: $color-bg-page;
}
.contract-center__search {
display: flex;
align-items: center;
height: 72rpx;
padding: 0 24rpx;
background: $color-bg-section;
border-radius: 36rpx;
}
.contract-center__search-icon {
flex-shrink: 0;
width: 32rpx;
height: 32rpx;
margin-right: 12rpx;
}
.contract-center__search-input {
flex: 1;
font-size: 26rpx;
color: $color-text-primary;
}
.contract-center__search-placeholder {
color: $color-text-placeholder;
}
.contract-center__tabs {
display: flex;
margin-top: 24rpx;
overflow: hidden;
border: 2rpx solid $color-primary;
border-radius: 12rpx;
}
.contract-center__tab {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
height: 72rpx;
background: $color-bg-page;
border-right: 2rpx solid $color-primary;
&:last-child {
border-right: none;
}
&--active {
background: $color-primary;
}
}
.contract-center__tab-text {
font-size: 28rpx;
color: $color-primary;
}
.contract-center__tab--active .contract-center__tab-text {
font-weight: 600;
color: #ffffff;
}
.contract-center__scroll {
flex: 1;
height: 0;
}
.contract-center__list {
padding: 0 24rpx calc(32rpx + env(safe-area-inset-bottom));
}
.contract-center__card {
padding: 28rpx 24rpx;
margin-top: 24rpx;
background: $color-bg-page;
border-radius: 20rpx;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.04);
}
.contract-center__card-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16rpx;
}
.contract-center__contract-no {
flex: 1;
min-width: 0;
font-size: 22rpx;
color: $color-text-secondary;
}
.contract-center__badges {
display: flex;
flex-shrink: 0;
gap: 12rpx;
}
.contract-center__badge {
padding: 4rpx 12rpx;
font-size: 20rpx;
border-radius: 6rpx;
&--signed {
color: #16a34a;
background: #ecfdf3;
}
&--in_progress {
color: $color-primary;
background: $color-primary-light;
}
&--pending_sign {
color: $color-stat-orange;
background: $color-stat-orange-bg;
}
}
.contract-center__title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
margin-top: 20rpx;
}
.contract-center__card-title {
flex: 1;
min-width: 0;
font-size: 30rpx;
font-weight: 600;
color: $color-text-primary;
}
.contract-center__detail-link {
flex-shrink: 0;
font-size: 24rpx;
color: $color-text-secondary;
}
.contract-center__related {
display: block;
margin-top: 12rpx;
font-size: 24rpx;
color: $color-text-secondary;
}
.contract-center__actions {
display: flex;
gap: 20rpx;
margin-top: 28rpx;
&--single {
.contract-center__action-btn {
flex: 1;
}
}
}
.contract-center__action-btn {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
height: 80rpx;
font-size: 28rpx;
border-radius: 12rpx;
&--outline {
color: $color-text-primary;
background: $color-bg-page;
border: 1rpx solid #d8dde6;
}
&--primary {
color: #ffffff;
background: $color-primary;
}
}

View File

@@ -0,0 +1,178 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { CONTRACT_LIST, CONTRACT_TABS } from '@/constants/contract'
import type { ContractItem, ContractTab } from '@/types/contract'
import { navigateBackWithFallback } from '@/utils/navigate-back'
const keyword = ref('')
const activeTab = ref<ContractTab>('all')
const contractList = ref(CONTRACT_LIST.map(item => ({ ...item })))
const filteredContracts = computed(() => {
const text = keyword.value.trim()
return contractList.value.filter((item) => {
const matchTab = activeTab.value === 'all'
|| (activeTab.value === 'pending' && item.signStatus === 'pending')
|| (activeTab.value === 'signed' && item.signStatus === 'signed')
const matchKeyword = !text
|| item.title.includes(text)
|| item.type.includes(text)
|| item.contractNo.includes(text)
|| item.relatedValue.includes(text)
return matchTab && matchKeyword
})
})
function handleBack() {
navigateBackWithFallback('/pages/personal-center/index')
}
function badgeClass(type: ContractItem['badges'][number]['type']) {
return `contract-center__badge--${type}`
}
function handleViewDetail(item: ContractItem) {
uni.showToast({ title: `查看${item.title}详情开发中`, icon: 'none' })
}
function handlePreview(item: ContractItem) {
uni.showToast({ title: `预览${item.contractNo}开发中`, icon: 'none' })
}
function handleDownload(item: ContractItem) {
uni.showToast({ title: `下载${item.contractNo}开发中`, icon: 'none' })
}
function handleSign(item: ContractItem) {
uni.showToast({ title: `签署${item.contractNo}开发中`, icon: 'none' })
}
</script>
<template>
<view class="contract-center">
<view class="contract-center__header">
<view
class="contract-center__back"
@tap="handleBack"
>
<image
class="contract-center__back-icon"
src="/static/icons/icon-back.svg"
mode="aspectFit"
/>
</view>
<text class="contract-center__title">
合同中心
</text>
<view class="contract-center__header-placeholder" />
</view>
<view class="contract-center__toolbar">
<view class="contract-center__search">
<image
class="contract-center__search-icon"
src="/static/icons/icon-search.svg"
mode="aspectFit"
/>
<input
v-model="keyword"
class="contract-center__search-input"
placeholder="搜索项目标题、类型..."
placeholder-class="contract-center__search-placeholder"
>
</view>
<view class="contract-center__tabs">
<view
v-for="tab in CONTRACT_TABS"
:key="tab.value"
class="contract-center__tab"
:class="{ 'contract-center__tab--active': activeTab === tab.value }"
@tap="activeTab = tab.value"
>
<text class="contract-center__tab-text">
{{ tab.label }}
</text>
</view>
</view>
</view>
<scroll-view
class="contract-center__scroll"
scroll-y
:show-scrollbar="false"
>
<view class="contract-center__list">
<view
v-for="item in filteredContracts"
:key="item.id"
class="contract-center__card"
>
<view class="contract-center__card-top">
<text class="contract-center__contract-no">
合同编号: {{ item.contractNo }}
</text>
<view class="contract-center__badges">
<text
v-for="badge in item.badges"
:key="badge.label"
class="contract-center__badge"
:class="badgeClass(badge.type)"
>
{{ badge.label }}
</text>
</view>
</view>
<view class="contract-center__title-row">
<text class="contract-center__card-title">
{{ item.title }}
</text>
<text
class="contract-center__detail-link"
@tap="handleViewDetail(item)"
>
查看详情
</text>
</view>
<text class="contract-center__related">
{{ item.relatedLabel }}: {{ item.relatedValue }}
</text>
<view
class="contract-center__actions"
:class="{ 'contract-center__actions--single': !item.showDownload && !item.showSign }"
>
<view
class="contract-center__action-btn contract-center__action-btn--outline"
@tap="handlePreview(item)"
>
预览
</view>
<view
v-if="item.showDownload"
class="contract-center__action-btn contract-center__action-btn--outline"
@tap="handleDownload(item)"
>
下载
</view>
<view
v-if="item.showSign"
class="contract-center__action-btn contract-center__action-btn--primary"
@tap="handleSign(item)"
>
立即签署
</view>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<style scoped lang="scss">
@import './index.scss';
</style>

View File

@@ -2,7 +2,8 @@
import AppTabBar from '@/components/AppTabBar.vue'
import { useGreeting } from '@/hooks/use-greeting'
import { useQuickEntries } from '@/hooks/use-quick-entries'
import type { ProjectItem, ServiceItem } from '@/types/home'
import type { ProjectItem, QuickEntryItem, ServiceItem } from '@/types/home'
import { navigateQuickEntry } from '@/utils/quick-entry-navigate'
import headerBg from '@/static/icons/1.png'
import softwareIllus from '@/static/icons/software.png'
import hardwareIllus from '@/static/icons/hardware.png'
@@ -56,6 +57,10 @@ const services: ServiceItem[] = [
},
]
function handleQuickEntryTap(entry: QuickEntryItem) {
navigateQuickEntry(entry.id, entry.label)
}
const projects: ProjectItem[] = [
{
id: '1',
@@ -163,6 +168,7 @@ const projects: ProjectItem[] = [
v-for="entry in pinnedEntries"
:key="entry.id"
class="home__quick-item"
@tap="handleQuickEntryTap(entry)"
>
<image
class="home__quick-icon"

View File

@@ -0,0 +1,209 @@
@import '@/styles/variables.scss';
.address-page {
display: flex;
flex-direction: column;
min-height: 100vh;
background: $color-bg-section;
}
.address-page__navbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
padding: calc(var(--status-bar-height, 44px) + 60rpx) 24rpx 20rpx;
background: $color-bg-page;
}
.address-page__back {
display: flex;
align-items: center;
justify-content: center;
width: 64rpx;
height: 64rpx;
}
.address-page__back-icon {
width: 40rpx;
height: 40rpx;
}
.address-page__navbar-title {
font-size: 34rpx;
font-weight: 600;
color: $color-text-primary;
}
.address-page__navbar-placeholder {
width: 64rpx;
height: 64rpx;
}
.address-page__scroll {
flex: 1;
height: 0;
}
.address-page__content {
padding: 20rpx 24rpx calc(140rpx + env(safe-area-inset-bottom));
}
.address-page__card {
padding: 28rpx 24rpx;
margin-bottom: 20rpx;
background: $color-bg-page;
border-radius: 20rpx;
&:last-child {
margin-bottom: 0;
}
}
.address-page__card-top {
display: flex;
align-items: center;
}
.address-page__name {
font-size: 30rpx;
font-weight: 600;
color: $color-text-primary;
}
.address-page__phone {
margin-left: 24rpx;
font-size: 30rpx;
font-weight: 600;
color: $color-text-primary;
}
.address-page__address {
display: block;
margin-top: 16rpx;
font-size: 26rpx;
line-height: 1.5;
color: #222222;
}
.address-page__card-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 28rpx;
}
.address-page__default {
display: flex;
align-items: center;
}
.address-page__radio {
display: flex;
align-items: center;
justify-content: center;
width: 28rpx;
height: 28rpx;
border: 2rpx solid #d8dde6;
border-radius: 50%;
&--checked {
border-color: $color-primary;
}
}
.address-page__radio-dot {
width: 14rpx;
height: 14rpx;
background: $color-primary;
border-radius: 50%;
}
.address-page__default-text {
margin-left: 12rpx;
font-size: 24rpx;
color: #222222;
}
.address-page__actions {
display: flex;
gap: 32rpx;
}
.address-page__action {
font-size: 26rpx;
color: $color-text-primary;
}
.address-page__footer {
flex-shrink: 0;
padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));
background: $color-bg-page;
border-top: 1rpx solid #f0f0f0;
}
.address-page__add-btn {
display: flex;
align-items: center;
justify-content: center;
height: 88rpx;
font-size: 30rpx;
font-weight: 600;
color: #ffffff;
background: $color-primary;
border-radius: 12rpx;
}
.address-page__sheet-title {
display: block;
font-size: 32rpx;
font-weight: 600;
color: $color-text-primary;
}
.address-page__form {
margin-top: 8rpx;
}
.address-page__form-row {
display: flex;
align-items: center;
min-height: 96rpx;
border-bottom: 1rpx solid #f0f0f0;
&--last {
border-bottom: none;
}
}
.address-page__form-label {
flex-shrink: 0;
width: 160rpx;
font-size: 28rpx;
color: $color-text-primary;
}
.address-page__form-input {
flex: 1;
min-width: 0;
font-size: 28rpx;
color: $color-text-primary;
text-align: right;
}
.address-page__form-placeholder {
color: $color-text-placeholder;
}
.address-page__sheet-submit {
display: flex;
align-items: center;
justify-content: center;
height: 88rpx;
margin-top: 40rpx;
font-size: 30rpx;
font-weight: 600;
color: #ffffff;
background: $color-primary;
border-radius: 12rpx;
}

View File

@@ -0,0 +1,284 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import BottomSheet from '@/components/BottomSheet.vue'
import { ADDRESS_LIST } from '@/constants/address'
import type { AddressItem } from '@/types/address'
import { navigateBackWithFallback } from '@/utils/navigate-back'
const addressList = ref(ADDRESS_LIST.map(item => ({ ...item })))
const sheetVisible = ref(false)
const editingId = ref<string | null>(null)
const formName = ref('')
const formPhone = ref('')
const formRegion = ref('')
const formDetail = ref('')
const isEditing = computed(() => editingId.value !== null)
const sheetTitle = computed(() => (isEditing.value ? '编辑地址' : '新增地址'))
const sheetSubmitText = computed(() => (isEditing.value ? '保存' : '+ 新增地址'))
function formatAddress(item: AddressItem) {
return `${item.region}${item.detail}`
}
function handleBack() {
navigateBackWithFallback('/pages/personal-center/index')
}
function handleSetDefault(item: AddressItem) {
addressList.value = addressList.value.map(row => ({
...row,
isDefault: row.id === item.id,
}))
}
function handleEdit(item: AddressItem) {
editingId.value = item.id
formName.value = item.name
formPhone.value = item.phone
formRegion.value = item.region
formDetail.value = item.detail
sheetVisible.value = true
}
function handleDelete(item: AddressItem) {
uni.showModal({
title: '删除地址',
content: `确认删除「${item.name}」的地址吗?`,
confirmText: '删除',
confirmColor: '#2563EB',
success: (res) => {
if (!res.confirm)
return
addressList.value = addressList.value.filter(row => row.id !== item.id)
if (addressList.value.length && !addressList.value.some(row => row.isDefault))
addressList.value[0].isDefault = true
uni.showToast({ title: '已删除', icon: 'none' })
},
})
}
function resetForm() {
editingId.value = null
formName.value = ''
formPhone.value = ''
formRegion.value = ''
formDetail.value = ''
}
function handleAdd() {
resetForm()
sheetVisible.value = true
}
function closeSheet() {
sheetVisible.value = false
editingId.value = null
}
function handleSubmit() {
if (!formName.value.trim()) {
uni.showToast({ title: '请输入收货人姓名', icon: 'none' })
return
}
if (!formPhone.value.trim()) {
uni.showToast({ title: '请输入联系电话', icon: 'none' })
return
}
if (!formRegion.value.trim()) {
uni.showToast({ title: '请输入所在地区', icon: 'none' })
return
}
if (!formDetail.value.trim()) {
uni.showToast({ title: '请输入详细地址', icon: 'none' })
return
}
const payload = {
name: formName.value.trim(),
phone: formPhone.value.trim(),
region: formRegion.value.trim(),
detail: formDetail.value.trim(),
}
if (editingId.value) {
addressList.value = addressList.value.map(row => (
row.id === editingId.value
? { ...row, ...payload }
: row
))
closeSheet()
uni.showToast({ title: '保存成功', icon: 'none' })
return
}
const isFirst = addressList.value.length === 0
addressList.value.push({
id: `${Date.now()}`,
...payload,
isDefault: isFirst,
})
closeSheet()
uni.showToast({ title: '新增成功', icon: 'none' })
}
</script>
<template>
<view class="address-page">
<view class="address-page__navbar">
<view
class="address-page__back"
@tap="handleBack"
>
<image
class="address-page__back-icon"
src="/static/icons/icon-back.svg"
mode="aspectFit"
/>
</view>
<text class="address-page__navbar-title">
地址管理
</text>
<view class="address-page__navbar-placeholder" />
</view>
<scroll-view
class="address-page__scroll"
scroll-y
:show-scrollbar="false"
>
<view class="address-page__content">
<view
v-for="item in addressList"
:key="item.id"
class="address-page__card"
>
<view class="address-page__card-top">
<text class="address-page__name">
{{ item.name }}
</text>
<text class="address-page__phone">
{{ item.phone }}
</text>
</view>
<text class="address-page__address">
{{ formatAddress(item) }}
</text>
<view class="address-page__card-footer">
<view
class="address-page__default"
@tap="handleSetDefault(item)"
>
<view
class="address-page__radio"
:class="{ 'address-page__radio--checked': item.isDefault }"
>
<view
v-if="item.isDefault"
class="address-page__radio-dot"
/>
</view>
<text class="address-page__default-text">
默认地址
</text>
</view>
<view class="address-page__actions">
<text
class="address-page__action"
@tap="handleEdit(item)"
>
编辑
</text>
<text
class="address-page__action"
@tap="handleDelete(item)"
>
删除
</text>
</view>
</view>
</view>
</view>
</scroll-view>
<view class="address-page__footer">
<view
class="address-page__add-btn"
@tap="handleAdd"
>
+ 新增地址
</view>
</view>
<BottomSheet
:visible="sheetVisible"
@close="closeSheet"
>
<text class="address-page__sheet-title">
{{ sheetTitle }}
</text>
<view class="address-page__form">
<view class="address-page__form-row">
<text class="address-page__form-label">
收货人
</text>
<input
v-model="formName"
class="address-page__form-input"
placeholder="请输入收货人姓名"
placeholder-class="address-page__form-placeholder"
>
</view>
<view class="address-page__form-row">
<text class="address-page__form-label">
手机号码
</text>
<input
v-model="formPhone"
class="address-page__form-input"
type="number"
placeholder="请输入联系电话"
placeholder-class="address-page__form-placeholder"
>
</view>
<view class="address-page__form-row">
<text class="address-page__form-label">
地区
</text>
<input
v-model="formRegion"
class="address-page__form-input"
placeholder="请输入所在地区"
placeholder-class="address-page__form-placeholder"
>
</view>
<view class="address-page__form-row address-page__form-row--last">
<text class="address-page__form-label">
详细地址
</text>
<input
v-model="formDetail"
class="address-page__form-input"
placeholder="请输入街道、小区门牌等详细地址"
placeholder-class="address-page__form-placeholder"
>
</view>
</view>
<view
class="address-page__sheet-submit"
@tap="handleSubmit"
>
{{ sheetSubmitText }}
</view>
</BottomSheet>
</view>
</template>
<style scoped lang="scss">
@import './index.scss';
</style>

View File

@@ -3,7 +3,6 @@ import AppTabBar from '@/components/AppTabBar.vue'
import {
PERSONAL_CENTER_MENU,
PERSONAL_CENTER_PROFILE,
PERSONAL_CENTER_SERVICE_HOTLINE,
} from '@/constants/personal-center'
import type { PersonalCenterMenuItem } from '@/types/personal-center'
import editIcon from '@/static/icon/edit.svg'
@@ -21,16 +20,6 @@ function handleMenuTap(item: PersonalCenterMenuItem) {
return
}
if (item.key === 'service-hotline') {
uni.makePhoneCall({
phoneNumber: PERSONAL_CENTER_SERVICE_HOTLINE.replace(/-/g, ''),
fail: () => {
uni.showToast({ title: PERSONAL_CENTER_SERVICE_HOTLINE, icon: 'none' })
},
})
return
}
uni.showToast({ title: '功能开发中', icon: 'none' })
}
</script>

View File

@@ -0,0 +1,254 @@
@import '@/styles/variables.scss';
.invoice-list {
display: flex;
flex-direction: column;
min-height: 100vh;
background: $color-bg-section;
}
.invoice-list__navbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
padding: calc(var(--status-bar-height, 44px) + 60rpx) 24rpx 20rpx;
background: $color-bg-page;
}
.invoice-list__back {
display: flex;
align-items: center;
justify-content: center;
width: 64rpx;
height: 64rpx;
}
.invoice-list__back-icon {
width: 40rpx;
height: 40rpx;
}
.invoice-list__navbar-title {
font-size: 34rpx;
font-weight: 600;
color: $color-text-primary;
}
.invoice-list__navbar-placeholder {
width: 64rpx;
height: 64rpx;
}
.invoice-list__scroll {
flex: 1;
height: 0;
}
.invoice-list__content {
padding: 20rpx 24rpx calc(140rpx + env(safe-area-inset-bottom));
}
.invoice-list__card {
padding: 28rpx 24rpx;
margin-bottom: 20rpx;
background: $color-bg-page;
border-radius: 20rpx;
&:last-child {
margin-bottom: 0;
}
}
.invoice-list__card-top {
display: flex;
align-items: center;
}
.invoice-list__card-title {
flex: 1;
min-width: 0;
font-size: 30rpx;
font-weight: 600;
color: $color-text-primary;
}
.invoice-list__tag {
flex-shrink: 0;
margin-left: 16rpx;
padding: 4rpx 14rpx;
font-size: 22rpx;
border-radius: 6rpx;
&--enterprise {
color: $color-primary;
border: 1rpx solid $color-primary;
}
&--personal {
color: $color-stat-orange;
border: 1rpx solid $color-stat-orange;
}
}
.invoice-list__tax-no {
display: block;
margin-top: 16rpx;
font-size: 24rpx;
color: #222222;
word-break: break-all;
}
.invoice-list__card-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 28rpx;
}
.invoice-list__default {
display: flex;
align-items: center;
}
.invoice-list__radio {
display: flex;
align-items: center;
justify-content: center;
width: 28rpx;
height: 28rpx;
border: 2rpx solid #d8dde6;
border-radius: 50%;
&--checked {
border-color: $color-primary;
}
}
.invoice-list__radio-dot {
width: 14rpx;
height: 14rpx;
background: $color-primary;
border-radius: 50%;
}
.invoice-list__default-text {
margin-left: 12rpx;
font-size: 24rpx;
color: #222222;
}
.invoice-list__actions {
display: flex;
gap: 32rpx;
}
.invoice-list__action {
font-size: 26rpx;
color: $color-text-primary;
}
.invoice-list__footer {
flex-shrink: 0;
padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));
background: $color-bg-page;
border-top: 1rpx solid #f0f0f0;
}
.invoice-list__add-btn {
display: flex;
align-items: center;
justify-content: center;
height: 88rpx;
font-size: 30rpx;
font-weight: 600;
color: #ffffff;
background: $color-primary;
border-radius: 12rpx;
}
.invoice-list__sheet-title {
display: block;
font-size: 32rpx;
font-weight: 600;
color: $color-text-primary;
}
.invoice-list__sheet-field {
margin-top: 32rpx;
}
.invoice-list__sheet-label {
display: block;
margin-bottom: 16rpx;
font-size: 28rpx;
font-weight: 500;
color: $color-text-primary;
}
.invoice-list__type-row {
display: flex;
gap: 48rpx;
}
.invoice-list__type-option {
display: flex;
align-items: center;
}
.invoice-list__type-radio {
display: flex;
align-items: center;
justify-content: center;
width: 32rpx;
height: 32rpx;
border: 2rpx solid #d8dde6;
border-radius: 50%;
&--checked {
background: $color-primary;
border-color: $color-primary;
}
}
.invoice-list__type-check {
font-size: 20rpx;
font-weight: 700;
color: #ffffff;
line-height: 1;
}
.invoice-list__type-label {
margin-left: 12rpx;
font-size: 28rpx;
color: $color-text-primary;
}
.invoice-list__sheet-input {
width: 100%;
height: 80rpx;
padding: 0 24rpx;
font-size: 28rpx;
color: $color-text-primary;
background: $color-bg-page;
border: 1rpx solid #e8eaef;
border-radius: 12rpx;
box-sizing: border-box;
}
.invoice-list__sheet-placeholder {
color: $color-text-placeholder;
}
.invoice-list__sheet-submit {
display: flex;
align-items: center;
justify-content: center;
height: 88rpx;
margin-top: 40rpx;
font-size: 30rpx;
font-weight: 600;
color: #ffffff;
background: $color-primary;
border-radius: 12rpx;
}

View File

@@ -0,0 +1,290 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import BottomSheet from '@/components/BottomSheet.vue'
import {
INVOICE_HEADER_FORM_TYPE_OPTIONS,
INVOICE_HEADER_TYPE_LABEL,
} from '@/constants/invoice-list'
import { useInvoiceHeaders } from '@/hooks/use-invoice-headers'
import type { InvoiceHeaderItem } from '@/types/invoice-list'
import { navigateBackWithFallback } from '@/utils/navigate-back'
const { invoiceHeaders: invoiceList } = useInvoiceHeaders()
const sheetVisible = ref(false)
const editingId = ref<string | null>(null)
const headerType = ref<InvoiceHeaderItem['type']>('enterprise')
const headerTitle = ref('')
const taxNo = ref('')
const isEditing = computed(() => editingId.value !== null)
const sheetTitle = computed(() => (isEditing.value ? '编辑抬头' : '新增抬头'))
const sheetSubmitText = computed(() => (isEditing.value ? '保存' : '+ 新增抬头'))
const formTypeOptions = INVOICE_HEADER_FORM_TYPE_OPTIONS
function handleBack() {
navigateBackWithFallback('/pages/personal-center/invoice/index')
}
function typeLabel(type: InvoiceHeaderItem['type']) {
return INVOICE_HEADER_TYPE_LABEL[type]
}
function handleSetDefault(item: InvoiceHeaderItem) {
invoiceList.value = invoiceList.value.map(row => ({
...row,
isDefault: row.id === item.id,
}))
}
function handleEdit(item: InvoiceHeaderItem) {
editingId.value = item.id
headerType.value = item.type
headerTitle.value = item.title
taxNo.value = item.taxNo
sheetVisible.value = true
}
function handleDelete(item: InvoiceHeaderItem) {
uni.showModal({
title: '删除抬头',
content: `确认删除「${item.title}」吗?`,
confirmText: '删除',
confirmColor: '#2563EB',
success: (res) => {
if (!res.confirm)
return
invoiceList.value = invoiceList.value.filter(row => row.id !== item.id)
if (invoiceList.value.length && !invoiceList.value.some(row => row.isDefault))
invoiceList.value[0].isDefault = true
uni.showToast({ title: '已删除', icon: 'none' })
},
})
}
function resetForm() {
editingId.value = null
headerType.value = 'enterprise'
headerTitle.value = ''
taxNo.value = ''
}
function handleAdd() {
resetForm()
sheetVisible.value = true
}
function closeSheet() {
sheetVisible.value = false
editingId.value = null
}
function handleSelectType(type: InvoiceHeaderItem['type']) {
headerType.value = type
}
function handleSubmit() {
if (!headerTitle.value.trim()) {
uni.showToast({ title: '请填写发票抬头', icon: 'none' })
return
}
if (!taxNo.value.trim()) {
uni.showToast({ title: '请输入税号', icon: 'none' })
return
}
const payload = {
title: headerTitle.value.trim(),
type: headerType.value,
taxNo: taxNo.value.trim(),
}
if (editingId.value) {
invoiceList.value = invoiceList.value.map(row => (
row.id === editingId.value
? { ...row, ...payload }
: row
))
closeSheet()
uni.showToast({ title: '保存成功', icon: 'none' })
return
}
const isFirst = invoiceList.value.length === 0
invoiceList.value.push({
id: `${Date.now()}`,
...payload,
isDefault: isFirst,
})
closeSheet()
uni.showToast({ title: '新增成功', icon: 'none' })
}
</script>
<template>
<view class="invoice-list">
<view class="invoice-list__navbar">
<view
class="invoice-list__back"
@tap="handleBack"
>
<image
class="invoice-list__back-icon"
src="/static/icons/icon-back.svg"
mode="aspectFit"
/>
</view>
<text class="invoice-list__navbar-title">
抬头管理
</text>
<view class="invoice-list__navbar-placeholder" />
</view>
<scroll-view
class="invoice-list__scroll"
scroll-y
:show-scrollbar="false"
>
<view class="invoice-list__content">
<view
v-for="item in invoiceList"
:key="item.id"
class="invoice-list__card"
>
<view class="invoice-list__card-top">
<text class="invoice-list__card-title">
{{ item.title }}
</text>
<text
class="invoice-list__tag"
:class="`invoice-list__tag--${item.type}`"
>
{{ typeLabel(item.type) }}
</text>
</view>
<text class="invoice-list__tax-no">
{{ item.taxNo }}
</text>
<view class="invoice-list__card-footer">
<view
class="invoice-list__default"
@tap="handleSetDefault(item)"
>
<view
class="invoice-list__radio"
:class="{ 'invoice-list__radio--checked': item.isDefault }"
>
<view
v-if="item.isDefault"
class="invoice-list__radio-dot"
/>
</view>
<text class="invoice-list__default-text">
默认抬头
</text>
</view>
<view class="invoice-list__actions">
<text
class="invoice-list__action"
@tap="handleEdit(item)"
>
编辑
</text>
<text
class="invoice-list__action"
@tap="handleDelete(item)"
>
删除
</text>
</view>
</view>
</view>
</view>
</scroll-view>
<view class="invoice-list__footer">
<view
class="invoice-list__add-btn"
@tap="handleAdd"
>
+ 新增抬头
</view>
</view>
<BottomSheet
:visible="sheetVisible"
@close="closeSheet"
>
<text class="invoice-list__sheet-title">
{{ sheetTitle }}
</text>
<view class="invoice-list__sheet-field">
<text class="invoice-list__sheet-label">
抬头类型
</text>
<view class="invoice-list__type-row">
<view
v-for="option in formTypeOptions"
:key="option.value"
class="invoice-list__type-option"
@tap="handleSelectType(option.value)"
>
<view
class="invoice-list__type-radio"
:class="{ 'invoice-list__type-radio--checked': headerType === option.value }"
>
<text
v-if="headerType === option.value"
class="invoice-list__type-check"
>
✓
</text>
</view>
<text class="invoice-list__type-label">
{{ option.label }}
</text>
</view>
</view>
</view>
<view class="invoice-list__sheet-field">
<text class="invoice-list__sheet-label">
发票抬头
</text>
<input
v-model="headerTitle"
class="invoice-list__sheet-input"
placeholder="请填写发票抬头"
placeholder-class="invoice-list__sheet-placeholder"
>
</view>
<view class="invoice-list__sheet-field">
<text class="invoice-list__sheet-label">
税号
</text>
<input
v-model="taxNo"
class="invoice-list__sheet-input"
placeholder="请输入税号"
placeholder-class="invoice-list__sheet-placeholder"
>
</view>
<view
class="invoice-list__sheet-submit"
@tap="handleSubmit"
>
{{ sheetSubmitText }}
</view>
</BottomSheet>
</view>
</template>
<style scoped lang="scss">
@import './index.scss';
</style>

View File

@@ -0,0 +1,238 @@
@import '@/styles/variables.scss';
.invoice-page {
display: flex;
flex-direction: column;
min-height: 100vh;
background: $color-bg-section;
}
.invoice-page__navbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
padding: calc(var(--status-bar-height, 44px) + 60rpx) 24rpx 20rpx;
background: $color-bg-page;
}
.invoice-page__back {
display: flex;
align-items: center;
justify-content: center;
width: 64rpx;
height: 64rpx;
}
.invoice-page__back-icon {
width: 40rpx;
height: 40rpx;
}
.invoice-page__navbar-title {
font-size: 34rpx;
font-weight: 600;
color: $color-text-primary;
}
.invoice-page__navbar-placeholder {
width: 64rpx;
height: 64rpx;
}
.invoice-page__scroll {
flex: 1;
height: 0;
}
.invoice-page__content {
padding: 20rpx 24rpx calc(32rpx + env(safe-area-inset-bottom));
}
.invoice-page__section {
margin-bottom: 24rpx;
}
.invoice-page__section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.invoice-page__section-title {
font-size: 30rpx;
font-weight: 600;
color: $color-text-primary;
}
.invoice-page__manage {
display: flex;
align-items: center;
}
.invoice-page__manage-text {
font-size: 26rpx;
color: $color-text-secondary;
}
.invoice-page__manage-arrow {
margin-left: 4rpx;
font-size: 26rpx;
color: $color-text-secondary;
}
.invoice-page__header-card {
padding: 28rpx 24rpx;
background: $color-bg-page;
border-radius: 20rpx;
}
.invoice-page__header-top {
display: flex;
align-items: center;
}
.invoice-page__default-tag {
flex-shrink: 0;
padding: 4rpx 12rpx;
font-size: 22rpx;
color: $color-stat-orange;
background: $color-stat-orange-bg;
border-radius: 6rpx;
}
.invoice-page__header-title {
flex: 1;
min-width: 0;
margin-left: 12rpx;
font-size: 30rpx;
font-weight: 600;
color: $color-text-primary;
}
.invoice-page__header-type {
flex-shrink: 0;
margin-left: 16rpx;
padding: 4rpx 14rpx;
font-size: 22rpx;
border-radius: 6rpx;
&--enterprise {
color: $color-primary;
border: 1rpx solid $color-primary;
}
&--personal {
color: $color-stat-orange;
border: 1rpx solid $color-stat-orange;
}
}
.invoice-page__header-tax {
display: block;
margin-top: 16rpx;
font-size: 24rpx;
color: #222222;
word-break: break-all;
}
.invoice-page__search {
display: flex;
align-items: center;
height: 72rpx;
margin-top: 20rpx;
padding: 0 24rpx;
background: $color-bg-page;
border-radius: 36rpx;
}
.invoice-page__search-icon {
flex-shrink: 0;
width: 32rpx;
height: 32rpx;
margin-right: 12rpx;
}
.invoice-page__search-input {
flex: 1;
font-size: 26rpx;
color: $color-text-primary;
}
.invoice-page__search-placeholder {
color: $color-text-placeholder;
}
.invoice-page__tabs {
display: flex;
margin-top: 24rpx;
background: $color-bg-page;
border-radius: 20rpx;
}
.invoice-page__tab {
position: relative;
display: flex;
flex: 1;
align-items: center;
justify-content: center;
height: 80rpx;
}
.invoice-page__tab-text {
font-size: 28rpx;
color: $color-text-secondary;
}
.invoice-page__tab--active .invoice-page__tab-text {
font-weight: 600;
color: $color-primary;
}
.invoice-page__tab--active::after {
content: '';
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 4rpx;
background: $color-primary;
border-radius: 2rpx;
}
.invoice-page__record-card {
padding: 28rpx 24rpx;
margin-top: 20rpx;
background: $color-bg-page;
border-radius: 20rpx;
}
.invoice-page__record-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16rpx;
}
.invoice-page__record-title {
flex: 1;
min-width: 0;
font-size: 28rpx;
font-weight: 600;
color: $color-text-primary;
}
.invoice-page__record-amount {
flex-shrink: 0;
font-size: 30rpx;
font-weight: 600;
color: $color-text-primary;
}
.invoice-page__record-row {
display: block;
margin-top: 12rpx;
font-size: 24rpx;
color: $color-text-secondary;
}

View File

@@ -0,0 +1,175 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { INVOICE_RECORD_LIST, INVOICE_RECORD_TABS } from '@/constants/invoice'
import { INVOICE_HEADER_TYPE_LABEL } from '@/constants/invoice-list'
import { useInvoiceHeaders } from '@/hooks/use-invoice-headers'
import type { InvoiceRecordStatus } from '@/types/invoice'
import { navigateBackWithFallback } from '@/utils/navigate-back'
const { defaultHeader } = useInvoiceHeaders()
const keyword = ref('')
const activeTab = ref<InvoiceRecordStatus>('all')
const recordList = ref(INVOICE_RECORD_LIST.map(item => ({ ...item })))
const filteredRecords = computed(() => {
const text = keyword.value.trim()
return recordList.value.filter((item) => {
const matchTab = activeTab.value === 'all' || item.status === activeTab.value
const matchKeyword = !text
|| item.title.includes(text)
|| item.relatedProject.includes(text)
|| item.transactionNo.includes(text)
return matchTab && matchKeyword
})
})
function handleBack() {
navigateBackWithFallback('/pages/personal-center/index')
}
function handleManageHeader() {
uni.navigateTo({
url: '/pages/personal-center/invoice-list/index',
})
}
function formatAmount(amount: number) {
return `¥ ${amount}`
}
</script>
<template>
<view class="invoice-page">
<view class="invoice-page__navbar">
<view
class="invoice-page__back"
@tap="handleBack"
>
<image
class="invoice-page__back-icon"
src="/static/icons/icon-back.svg"
mode="aspectFit"
/>
</view>
<text class="invoice-page__navbar-title">
发票列表
</text>
<view class="invoice-page__navbar-placeholder" />
</view>
<scroll-view
class="invoice-page__scroll"
scroll-y
:show-scrollbar="false"
>
<view class="invoice-page__content">
<view class="invoice-page__section">
<view class="invoice-page__section-header">
<text class="invoice-page__section-title">
默认发票抬头
</text>
<view
class="invoice-page__manage"
@tap="handleManageHeader"
>
<text class="invoice-page__manage-text">
管理抬头
</text>
<text class="invoice-page__manage-arrow">
>
</text>
</view>
</view>
<view
v-if="defaultHeader"
class="invoice-page__header-card"
>
<view class="invoice-page__header-top">
<text class="invoice-page__default-tag">
默认
</text>
<text class="invoice-page__header-title">
{{ defaultHeader.title }}
</text>
<text
class="invoice-page__header-type"
:class="`invoice-page__header-type--${defaultHeader.type}`"
>
{{ INVOICE_HEADER_TYPE_LABEL[defaultHeader.type] }}
</text>
</view>
<text class="invoice-page__header-tax">
{{ defaultHeader.taxNo }}
</text>
</view>
</view>
<view class="invoice-page__section">
<text class="invoice-page__section-title">
发票记录
</text>
<view class="invoice-page__search">
<image
class="invoice-page__search-icon"
src="/static/icons/icon-search.svg"
mode="aspectFit"
/>
<input
v-model="keyword"
class="invoice-page__search-input"
placeholder="搜索案例标题、描述、标签..."
placeholder-class="invoice-page__search-placeholder"
>
</view>
<view class="invoice-page__tabs">
<view
v-for="tab in INVOICE_RECORD_TABS"
:key="tab.value"
class="invoice-page__tab"
:class="{ 'invoice-page__tab--active': activeTab === tab.value }"
@tap="activeTab = tab.value"
>
<text class="invoice-page__tab-text">
{{ tab.label }}
</text>
</view>
</view>
<view
v-for="item in filteredRecords"
:key="item.id"
class="invoice-page__record-card"
>
<view class="invoice-page__record-header">
<text class="invoice-page__record-title">
{{ item.title }} {{ item.ratio }}
</text>
<text class="invoice-page__record-amount">
{{ formatAmount(item.amount) }}
</text>
</view>
<text class="invoice-page__record-row">
交易流水号:{{ item.transactionNo }}
</text>
<text class="invoice-page__record-row">
关联项目:{{ item.relatedProject }}
</text>
<text class="invoice-page__record-row">
开票时间:{{ item.invoiceTime }}
</text>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<style scoped lang="scss">
@import './index.scss';
</style>

View File

@@ -0,0 +1,131 @@
@import '@/styles/variables.scss';
.qualification-list {
display: flex;
flex-direction: column;
min-height: 100vh;
background: $color-bg-section;
}
.qualification-list__navbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
padding: calc(var(--status-bar-height, 44px) + 60rpx) 24rpx 20rpx;
background: $color-bg-page;
}
.qualification-list__back {
display: flex;
align-items: center;
justify-content: center;
width: 64rpx;
height: 64rpx;
}
.qualification-list__back-icon {
width: 40rpx;
height: 40rpx;
}
.qualification-list__navbar-title {
font-size: 34rpx;
font-weight: 600;
color: $color-text-primary;
}
.qualification-list__navbar-placeholder {
width: 64rpx;
height: 64rpx;
}
.qualification-list__scroll {
flex: 1;
height: 0;
}
.qualification-list__content {
padding: 20rpx 24rpx calc(140rpx + env(safe-area-inset-bottom));
}
.qualification-list__card {
padding: 28rpx 24rpx;
margin-bottom: 20rpx;
background: $color-bg-page;
border-radius: 20rpx;
&:last-child {
margin-bottom: 0;
}
}
.qualification-list__card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.qualification-list__type {
font-size: 24rpx;
color: $color-text-secondary;
}
.qualification-list__status {
font-size: 24rpx;
color: $color-stat-orange;
}
.qualification-list__name {
display: block;
margin-top: 12rpx;
font-size: 32rpx;
font-weight: 600;
color: $color-text-primary;
}
.qualification-list__row {
display: block;
margin-top: 12rpx;
font-size: 24rpx;
color: #222222;
word-break: break-all;
}
.qualification-list__actions {
display: flex;
justify-content: flex-end;
gap: 32rpx;
margin-top: 24rpx;
}
.qualification-list__action {
font-size: 26rpx;
&--edit {
color: $color-primary;
}
&--delete {
color: #ef4444;
}
}
.qualification-list__footer {
flex-shrink: 0;
padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));
background: $color-bg-page;
border-top: 1rpx solid #f0f0f0;
}
.qualification-list__add-btn {
display: flex;
align-items: center;
justify-content: center;
height: 88rpx;
font-size: 30rpx;
font-weight: 600;
color: #ffffff;
background: $color-primary;
border-radius: 12rpx;
}

View File

@@ -0,0 +1,131 @@
<script setup lang="ts">
import { QUALIFICATION_STATUS_LABEL } from '@/constants/qualification'
import { useQualifications } from '@/hooks/use-qualifications'
import type { QualificationItem } from '@/types/qualification'
import { navigateBackWithFallback } from '@/utils/navigate-back'
const { qualifications } = useQualifications()
function handleBack() {
navigateBackWithFallback('/pages/personal-center/index')
}
function formatValidity(item: QualificationItem) {
return `${item.validFrom}至${item.validTo}`
}
function statusLabel(status: QualificationItem['status']) {
return QUALIFICATION_STATUS_LABEL[status]
}
function handleAdd() {
uni.navigateTo({
url: '/pages/personal-center/qualification-upload/index',
})
}
function handleEdit(item: QualificationItem) {
uni.navigateTo({
url: `/pages/personal-center/qualification-upload/index?id=${encodeURIComponent(item.id)}`,
})
}
function handleDelete(item: QualificationItem) {
uni.showModal({
title: '删除资质',
content: `确认删除「${item.certificateName}」吗?`,
confirmText: '删除',
confirmColor: '#2563EB',
success: (res) => {
if (!res.confirm)
return
qualifications.value = qualifications.value.filter(row => row.id !== item.id)
uni.showToast({ title: '已删除', icon: 'none' })
},
})
}
</script>
<template>
<view class="qualification-list">
<view class="qualification-list__navbar">
<view
class="qualification-list__back"
@tap="handleBack"
>
<image
class="qualification-list__back-icon"
src="/static/icons/icon-back.svg"
mode="aspectFit"
/>
</view>
<text class="qualification-list__navbar-title">
资质列表
</text>
<view class="qualification-list__navbar-placeholder" />
</view>
<scroll-view
class="qualification-list__scroll"
scroll-y
:show-scrollbar="false"
>
<view class="qualification-list__content">
<view
v-for="item in qualifications"
:key="item.id"
class="qualification-list__card"
>
<view class="qualification-list__card-header">
<text class="qualification-list__type">
{{ item.type }}
</text>
<text class="qualification-list__status">
{{ statusLabel(item.status) }}
</text>
</view>
<text class="qualification-list__name">
{{ item.certificateName }}
</text>
<text class="qualification-list__row">
{{ item.certificateNo }}
</text>
<text class="qualification-list__row">
{{ item.issuer }}
</text>
<text class="qualification-list__row">
{{ formatValidity(item) }}
</text>
<view class="qualification-list__actions">
<text
class="qualification-list__action qualification-list__action--edit"
@tap="handleEdit(item)"
>
编辑
</text>
<text
class="qualification-list__action qualification-list__action--delete"
@tap="handleDelete(item)"
>
删除
</text>
</view>
</view>
</view>
</scroll-view>
<view class="qualification-list__footer">
<view
class="qualification-list__add-btn"
@tap="handleAdd"
>
+ 新增资质
</view>
</view>
</view>
</template>
<style scoped lang="scss">
@import './index.scss';
</style>

View File

@@ -0,0 +1,248 @@
@import '@/styles/variables.scss';
.qualification-upload {
display: flex;
flex-direction: column;
min-height: 100vh;
background: $color-bg-section;
}
.qualification-upload__navbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
padding: calc(var(--status-bar-height, 44px) + 60rpx) 24rpx 20rpx;
background: $color-bg-page;
}
.qualification-upload__back {
display: flex;
align-items: center;
justify-content: center;
width: 64rpx;
height: 64rpx;
}
.qualification-upload__back-icon {
width: 40rpx;
height: 40rpx;
}
.qualification-upload__navbar-title {
font-size: 34rpx;
font-weight: 600;
color: $color-text-primary;
}
.qualification-upload__navbar-placeholder {
width: 64rpx;
height: 64rpx;
}
.qualification-upload__scroll {
flex: 1;
height: 0;
padding: 20rpx 24rpx;
box-sizing: border-box;
}
.qualification-upload__card {
padding: 28rpx 24rpx;
background: $color-bg-page;
border-radius: 20rpx;
}
.qualification-upload__field {
margin-bottom: 28rpx;
&:last-child {
margin-bottom: 0;
}
}
.qualification-upload__label {
display: block;
margin-bottom: 16rpx;
font-size: 28rpx;
font-weight: 500;
color: $color-text-primary;
}
.qualification-upload__required {
color: $color-text-primary;
}
.qualification-upload__label-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.qualification-upload__hint {
font-size: 24rpx;
color: $color-text-secondary;
}
.qualification-upload__input,
.qualification-upload__picker {
height: 80rpx;
padding: 0 24rpx;
background: $color-bg-page;
border: 1rpx solid #e8eaef;
border-radius: 12rpx;
box-sizing: border-box;
}
.qualification-upload__input {
width: 100%;
font-size: 28rpx;
color: $color-text-primary;
}
.qualification-upload__picker {
display: flex;
align-items: center;
justify-content: space-between;
}
.qualification-upload__picker-text {
font-size: 28rpx;
color: $color-text-primary;
&--placeholder {
color: $color-text-placeholder;
}
}
.qualification-upload__picker-arrow {
font-size: 24rpx;
color: $color-text-secondary;
}
.qualification-upload__placeholder {
color: $color-text-placeholder;
}
.qualification-upload__date-row {
display: flex;
align-items: center;
gap: 16rpx;
}
.qualification-upload__date {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
height: 80rpx;
background: $color-bg-page;
border: 1rpx solid #e8eaef;
border-radius: 12rpx;
}
.qualification-upload__date-text {
font-size: 28rpx;
color: $color-text-primary;
&--placeholder {
color: $color-text-placeholder;
}
}
.qualification-upload__date-sep {
flex-shrink: 0;
font-size: 28rpx;
color: $color-text-primary;
}
.qualification-upload__attachment-row {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
padding: 10rpx 10rpx 0 0;
}
.qualification-upload__attachment-item {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
width: 160rpx;
padding: 20rpx 16rpx;
background: $color-bg-section;
border-radius: 12rpx;
}
.qualification-upload__attachment-remove {
position: absolute;
top: -10rpx;
right: -10rpx;
width: 32rpx;
height: 32rpx;
}
.qualification-upload__attachment-remove-icon {
width: 32rpx;
height: 32rpx;
}
.qualification-upload__attachment-icon {
width: 64rpx;
height: 64rpx;
}
.qualification-upload__attachment-name {
margin-top: 12rpx;
font-size: 22rpx;
color: $color-text-primary;
text-align: center;
word-break: break-all;
}
.qualification-upload__attachment-add {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 160rpx;
height: 160rpx;
background: #f8f9fb;
border: 1rpx dashed #d8dde6;
border-radius: 12rpx;
}
.qualification-upload__attachment-add-icon {
font-size: 40rpx;
line-height: 1;
color: $color-text-secondary;
}
.qualification-upload__attachment-add-text {
margin-top: 8rpx;
font-size: 20rpx;
color: $color-text-secondary;
}
.qualification-upload__footer {
display: flex;
gap: 16rpx;
flex-shrink: 0;
padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));
background: $color-bg-page;
border-top: 1rpx solid #f0f0f0;
}
.qualification-upload__footer-btn {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
height: 88rpx;
font-size: 28rpx;
font-weight: 600;
color: #ffffff;
background: $color-primary;
border-radius: 12rpx;
}

View File

@@ -0,0 +1,384 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { QUALIFICATION_TYPE_OPTIONS } from '@/constants/qualification'
import { useQualifications } from '@/hooks/use-qualifications'
import type { QualificationItem, QualificationStatus } from '@/types/qualification'
import deleteIcon from '@/static/icons/X.png'
import pdfIcon from '@/static/icons/PDF.png'
import pngIcon from '@/static/icons/PNG.png'
import { navigateBackWithFallback } from '@/utils/navigate-back'
const { qualifications, getQualificationById } = useQualifications()
const editingId = ref<string | null>(null)
const typeOptions = QUALIFICATION_TYPE_OPTIONS
const typeIndex = ref(-1)
const certificateName = ref('')
const certificateNo = ref('')
const issuer = ref('')
const validFrom = ref('')
const validTo = ref('')
const attachments = ref([
{ id: '1', name: '需求文档.pdf', icon: pdfIcon },
{ id: '2', name: '系统....pdf', icon: pdfIcon },
{ id: '3', name: '参考图.png', icon: pngIcon },
])
const selectedType = computed(() => {
if (typeIndex.value < 0)
return ''
return typeOptions[typeIndex.value]
})
onLoad((options) => {
const rawId = options?.id
if (!rawId)
return
const id = decodeURIComponent(String(rawId))
const item = getQualificationById(id)
if (!item)
return
editingId.value = id
const index = typeOptions.indexOf(item.type)
typeIndex.value = index >= 0 ? index : 0
certificateName.value = item.certificateName
certificateNo.value = item.certificateNo
issuer.value = item.issuer
validFrom.value = item.validFrom
validTo.value = item.validTo
})
function handleBack() {
navigateBackWithFallback('/pages/personal-center/qualification-list/index')
}
function handleTypeChange(event: { detail: { value: string } }) {
typeIndex.value = Number(event.detail.value)
}
function handleValidFromChange(event: { detail: { value: string } }) {
validFrom.value = event.detail.value
}
function handleValidToChange(event: { detail: { value: string } }) {
validTo.value = event.detail.value
}
function handleRemoveAttachment(id: string) {
attachments.value = attachments.value.filter(item => item.id !== id)
}
function handleAddAttachment() {
uni.showToast({ title: '附件上传开发中', icon: 'none' })
}
function resetForm() {
if (!editingId.value) {
typeIndex.value = -1
certificateName.value = ''
certificateNo.value = ''
issuer.value = ''
validFrom.value = ''
validTo.value = ''
}
else {
const item = getQualificationById(editingId.value)
if (item) {
const index = typeOptions.indexOf(item.type)
typeIndex.value = index >= 0 ? index : 0
certificateName.value = item.certificateName
certificateNo.value = item.certificateNo
issuer.value = item.issuer
validFrom.value = item.validFrom
validTo.value = item.validTo
}
}
attachments.value = [
{ id: '1', name: '需求文档.pdf', icon: pdfIcon },
{ id: '2', name: '系统....pdf', icon: pdfIcon },
{ id: '3', name: '参考图.png', icon: pngIcon },
]
}
function validateForm() {
if (!selectedType.value) {
uni.showToast({ title: '请选择资质类型', icon: 'none' })
return false
}
if (!certificateName.value.trim()) {
uni.showToast({ title: '请输入证书名称', icon: 'none' })
return false
}
if (!certificateNo.value.trim()) {
uni.showToast({ title: '请输入证书编号', icon: 'none' })
return false
}
if (!issuer.value.trim()) {
uni.showToast({ title: '请输入发证单位', icon: 'none' })
return false
}
if (!validFrom.value || !validTo.value) {
uni.showToast({ title: '请选择有效期', icon: 'none' })
return false
}
return true
}
function buildPayload(status: QualificationStatus): Omit<QualificationItem, 'id'> {
return {
type: selectedType.value,
certificateName: certificateName.value.trim(),
certificateNo: certificateNo.value.trim(),
issuer: issuer.value.trim(),
validFrom: validFrom.value,
validTo: validTo.value,
status,
}
}
function upsertItem(status: QualificationStatus) {
const payload = buildPayload(status)
if (editingId.value) {
qualifications.value = qualifications.value.map(row => (
row.id === editingId.value
? { ...row, ...payload }
: row
))
return
}
qualifications.value.push({
id: `${Date.now()}`,
...payload,
})
}
function handleSaveDraft() {
if (!validateForm())
return
upsertItem('draft')
uni.showToast({ title: '草稿已保存', icon: 'none' })
setTimeout(() => {
uni.navigateBack()
}, 1200)
}
function handleSubmit() {
if (!validateForm())
return
upsertItem('reviewing')
uni.showToast({ title: '提交成功', icon: 'none' })
setTimeout(() => {
uni.navigateBack()
}, 1200)
}
</script>
<template>
<view class="qualification-upload">
<view class="qualification-upload__navbar">
<view
class="qualification-upload__back"
@tap="handleBack"
>
<image
class="qualification-upload__back-icon"
src="/static/icons/icon-back.svg"
mode="aspectFit"
/>
</view>
<text class="qualification-upload__navbar-title">
资质上传
</text>
<view class="qualification-upload__navbar-placeholder" />
</view>
<scroll-view
class="qualification-upload__scroll"
scroll-y
:show-scrollbar="false"
>
<view class="qualification-upload__card">
<view class="qualification-upload__field">
<text class="qualification-upload__label">
资质类型<text class="qualification-upload__required">*</text>
</text>
<picker
mode="selector"
:range="typeOptions"
:value="typeIndex < 0 ? 0 : typeIndex"
@change="handleTypeChange"
>
<view class="qualification-upload__picker">
<text
class="qualification-upload__picker-text"
:class="{ 'qualification-upload__picker-text--placeholder': !selectedType }"
>
{{ selectedType || '请选择资质类型' }}
</text>
<text class="qualification-upload__picker-arrow">
▾
</text>
</view>
</picker>
</view>
<view class="qualification-upload__field">
<text class="qualification-upload__label">
证书名称<text class="qualification-upload__required">*</text>
</text>
<input
v-model="certificateName"
class="qualification-upload__input"
placeholder="请输入证书名称"
placeholder-class="qualification-upload__placeholder"
>
</view>
<view class="qualification-upload__field">
<text class="qualification-upload__label">
证书编号<text class="qualification-upload__required">*</text>
</text>
<input
v-model="certificateNo"
class="qualification-upload__input"
placeholder="请输入证书编号"
placeholder-class="qualification-upload__placeholder"
>
</view>
<view class="qualification-upload__field">
<text class="qualification-upload__label">
发证单位<text class="qualification-upload__required">*</text>
</text>
<input
v-model="issuer"
class="qualification-upload__input"
placeholder="请输入发证单位"
placeholder-class="qualification-upload__placeholder"
>
</view>
<view class="qualification-upload__field">
<text class="qualification-upload__label">
有效期<text class="qualification-upload__required">*</text>
</text>
<view class="qualification-upload__date-row">
<picker
mode="date"
:value="validFrom"
@change="handleValidFromChange"
>
<view class="qualification-upload__date">
<text
class="qualification-upload__date-text"
:class="{ 'qualification-upload__date-text--placeholder': !validFrom }"
>
{{ validFrom || '起始时间' }}
</text>
</view>
</picker>
<text class="qualification-upload__date-sep">
至
</text>
<picker
mode="date"
:value="validTo"
@change="handleValidToChange"
>
<view class="qualification-upload__date">
<text
class="qualification-upload__date-text"
:class="{ 'qualification-upload__date-text--placeholder': !validTo }"
>
{{ validTo || '截止时间' }}
</text>
</view>
</picker>
</view>
</view>
<view class="qualification-upload__field">
<view class="qualification-upload__label-row">
<text class="qualification-upload__label">
附件上传
</text>
<text class="qualification-upload__hint">
支持图片/文档/压缩包
</text>
</view>
<view class="qualification-upload__attachment-row">
<view
v-for="file in attachments"
:key="file.id"
class="qualification-upload__attachment-item"
>
<view
class="qualification-upload__attachment-remove"
@tap="handleRemoveAttachment(file.id)"
>
<image
class="qualification-upload__attachment-remove-icon"
:src="deleteIcon"
mode="aspectFit"
/>
</view>
<image
class="qualification-upload__attachment-icon"
:src="file.icon"
mode="aspectFit"
/>
<text class="qualification-upload__attachment-name">
{{ file.name }}
</text>
</view>
<view
class="qualification-upload__attachment-add"
@tap="handleAddAttachment"
>
<text class="qualification-upload__attachment-add-icon">
+
</text>
<text class="qualification-upload__attachment-add-text">
添加附件
</text>
</view>
</view>
</view>
</view>
</scroll-view>
<view class="qualification-upload__footer">
<view
class="qualification-upload__footer-btn"
@tap="handleSaveDraft"
>
保存草稿
</view>
<view
class="qualification-upload__footer-btn"
@tap="resetForm"
>
重置
</view>
<view
class="qualification-upload__footer-btn"
@tap="handleSubmit"
>
提交认证
</view>
</view>
</view>
</template>
<style scoped lang="scss">
@import './index.scss';
</style>

View File

@@ -0,0 +1,94 @@
@import '@/styles/variables.scss';
.service-hotline {
display: flex;
flex-direction: column;
min-height: 100vh;
background: $color-bg-section;
}
.service-hotline__navbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
padding: calc(var(--status-bar-height, 44px) + 60rpx) 24rpx 20rpx;
background: $color-bg-page;
}
.service-hotline__back {
display: flex;
align-items: center;
justify-content: center;
width: 64rpx;
height: 64rpx;
}
.service-hotline__back-icon {
width: 40rpx;
height: 40rpx;
}
.service-hotline__navbar-title {
font-size: 34rpx;
font-weight: 600;
color: $color-text-primary;
}
.service-hotline__navbar-placeholder {
width: 64rpx;
height: 64rpx;
}
.service-hotline__body {
padding: 24rpx;
}
.service-hotline__card {
display: flex;
align-items: center;
padding: 32rpx 28rpx;
background: $color-bg-page;
border-radius: 20rpx;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.04);
}
.service-hotline__icon {
flex-shrink: 0;
width: 96rpx;
height: 96rpx;
}
.service-hotline__info {
flex: 1;
min-width: 0;
margin-left: 24rpx;
}
.service-hotline__title {
display: block;
font-size: 30rpx;
font-weight: 600;
color: $color-text-primary;
word-break: break-all;
}
.service-hotline__hours {
display: block;
margin-top: 12rpx;
font-size: 24rpx;
color: $color-text-secondary;
}
.service-hotline__dial-btn {
display: flex;
align-items: center;
justify-content: center;
height: 88rpx;
margin-top: 32rpx;
font-size: 30rpx;
font-weight: 600;
color: #ffffff;
background: $color-primary;
border-radius: 12rpx;
}

View File

@@ -0,0 +1,75 @@
<script setup lang="ts">
import {
PERSONAL_CENTER_SERVICE_HOTLINE,
PERSONAL_CENTER_SERVICE_HOTLINE_HOURS,
} from '@/constants/personal-center'
import serviceHotlineIcon from '@/static/icon/service-hotline.svg'
import { navigateBackWithFallback } from '@/utils/navigate-back'
const hotlineDisplay = `专属客服热线-${PERSONAL_CENTER_SERVICE_HOTLINE}`
const workHours = PERSONAL_CENTER_SERVICE_HOTLINE_HOURS
const dialNumber = PERSONAL_CENTER_SERVICE_HOTLINE.replace(/-/g, '')
function handleBack() {
navigateBackWithFallback('/pages/personal-center/index')
}
function handleDial() {
uni.makePhoneCall({
phoneNumber: dialNumber,
fail: () => {
uni.showToast({ title: PERSONAL_CENTER_SERVICE_HOTLINE, icon: 'none' })
},
})
}
</script>
<template>
<view class="service-hotline">
<view class="service-hotline__navbar">
<view
class="service-hotline__back"
@tap="handleBack"
>
<image
class="service-hotline__back-icon"
src="/static/icons/icon-back.svg"
mode="aspectFit"
/>
</view>
<text class="service-hotline__navbar-title">
专属客服热线
</text>
<view class="service-hotline__navbar-placeholder" />
</view>
<view class="service-hotline__body">
<view class="service-hotline__card">
<image
class="service-hotline__icon"
:src="serviceHotlineIcon"
mode="aspectFit"
/>
<view class="service-hotline__info">
<text class="service-hotline__title">
{{ hotlineDisplay }}
</text>
<text class="service-hotline__hours">
{{ workHours }}
</text>
</view>
</view>
<view
class="service-hotline__dial-btn"
@tap="handleDial"
>
点击拨号
</view>
</view>
</view>
</template>
<style scoped lang="scss">
@import './index.scss';
</style>