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,46 @@
@import '@/styles/variables.scss';
.bottom-sheet {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 300;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.bottom-sheet__mask {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: rgba(0, 0, 0, 0.45);
opacity: 0;
transition: opacity 0.3s ease;
}
.bottom-sheet__mask--active {
opacity: 1;
}
.bottom-sheet__panel {
position: relative;
z-index: 1;
padding-bottom: env(safe-area-inset-bottom);
background: $color-bg-page;
border-radius: 24rpx 24rpx 0 0;
transform: translateY(100%);
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.bottom-sheet__panel--active {
transform: translateY(0);
}
.bottom-sheet__body {
padding: 32rpx;
}

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
const props = defineProps<{
visible: boolean
}>()
const emit = defineEmits<{
close: []
}>()
const showSheet = ref(false)
const sheetActive = ref(false)
let closeTimer: ReturnType<typeof setTimeout> | null = null
watch(
() => props.visible,
(visible) => {
if (closeTimer) {
clearTimeout(closeTimer)
closeTimer = null
}
if (visible) {
showSheet.value = true
nextTick(() => {
sheetActive.value = true
})
return
}
sheetActive.value = false
closeTimer = setTimeout(() => {
showSheet.value = false
}, 300)
},
{ immediate: true },
)
</script>
<template>
<view
v-if="showSheet"
class="bottom-sheet"
>
<view
class="bottom-sheet__mask"
:class="{ 'bottom-sheet__mask--active': sheetActive }"
@tap="emit('close')"
/>
<view
class="bottom-sheet__panel"
:class="{ 'bottom-sheet__panel--active': sheetActive }"
@tap.stop
>
<view class="bottom-sheet__body">
<slot />
</view>
</view>
</view>
</template>
<style scoped lang="scss">
@import './BottomSheet.scss';
</style>

20
src/constants/address.ts Normal file
View File

@@ -0,0 +1,20 @@
import type { AddressItem } from '@/types/address'
export const ADDRESS_LIST: AddressItem[] = [
{
id: '1',
name: '张三',
phone: '15588888000',
region: '山东省日照市东港区',
detail: '科技园南路99号',
isDefault: true,
},
{
id: '2',
name: '张三',
phone: '15588888000',
region: '山东省日照市东港区',
detail: '科技园南路99号',
isDefault: false,
},
]

53
src/constants/contract.ts Normal file
View File

@@ -0,0 +1,53 @@
import type { ContractItem, ContractTab } from '@/types/contract'
export const CONTRACT_TABS: Array<{ value: ContractTab, label: string }> = [
{ value: 'all', label: '全部' },
{ value: 'pending', label: '待签署' },
{ value: 'signed', label: '已签署' },
]
export const CONTRACT_LIST: ContractItem[] = [
{
id: '1',
contractNo: 'C8K2M202408001',
title: '社区康养智慧系统',
type: '软件开发',
relatedLabel: '关联合同',
relatedValue: '20260814ZZZ151245',
signStatus: 'signed',
badges: [
{ type: 'signed', label: '已签署' },
{ type: 'in_progress', label: '履行中' },
],
showDownload: true,
showSign: false,
},
{
id: '2',
contractNo: 'C8K2M202408001',
title: '社区康养智慧系统',
type: '软件开发',
relatedLabel: '关联项目',
relatedValue: '社区康养智慧系统',
signStatus: 'pending',
badges: [
{ type: 'pending_sign', label: '待签署' },
],
showDownload: false,
showSign: true,
},
{
id: '3',
contractNo: 'C8K2M202408001',
title: '社区康养智慧系统',
type: '软件开发',
relatedLabel: '关联项目',
relatedValue: '社区康养智慧系统',
signStatus: 'signed',
badges: [
{ type: 'signed', label: '已签署' },
],
showDownload: false,
showSign: false,
},
]

View File

@@ -0,0 +1,31 @@
import type { InvoiceHeaderItem } from '@/types/invoice-list'
export const INVOICE_HEADER_LIST: InvoiceHeaderItem[] = [
{
id: '1',
title: '山东日照有限公司',
type: 'enterprise',
taxNo: '344683434513543543545535',
isDefault: true,
},
{
id: '2',
title: '山东日照有限公司',
type: 'personal',
taxNo: '344683434513543543545535',
isDefault: false,
},
]
export const INVOICE_HEADER_TYPE_LABEL: Record<InvoiceHeaderItem['type'], string> = {
enterprise: '企业',
personal: '个人',
}
export const INVOICE_HEADER_FORM_TYPE_OPTIONS: Array<{
value: InvoiceHeaderItem['type']
label: string
}> = [
{ value: 'enterprise', label: '企业单位' },
{ value: 'personal', label: '非企业单位' },
]

47
src/constants/invoice.ts Normal file
View File

@@ -0,0 +1,47 @@
import type { InvoiceRecordItem, InvoiceRecordStatus } from '@/types/invoice'
export const INVOICE_RECORD_TABS: Array<{ value: InvoiceRecordStatus, label: string }> = [
{ value: 'all', label: '全部' },
{ value: 'invoiced', label: '已开票' },
{ value: 'not_invoiced', label: '未开票' },
{ value: 'invoicing', label: '开票中' },
]
export const INVOICE_RECORD_STATUS_LABEL: Record<InvoiceRecordItem['status'], string> = {
invoiced: '已开票',
not_invoiced: '未开票',
invoicing: '开票中',
}
export const INVOICE_RECORD_LIST: InvoiceRecordItem[] = [
{
id: '1',
title: '第一期·预付款',
ratio: '比例33%',
amount: 20100,
transactionNo: 'T20260819153035678K2M001',
relatedProject: '社区康养智慧系统',
invoiceTime: '2026-08-14 15:00',
status: 'invoiced',
},
{
id: '2',
title: '第一期·预付款',
ratio: '比例33%',
amount: 20100,
transactionNo: 'T20260819153035678K2M002',
relatedProject: '社区康养智慧系统',
invoiceTime: '2026-08-14 15:00',
status: 'invoiced',
},
{
id: '3',
title: '第一期·预付款',
ratio: '比例33%',
amount: 20100,
transactionNo: 'T20260819153035678K2M003',
relatedProject: '社区康养智慧系统',
invoiceTime: '2026-08-14 15:00',
status: 'not_invoiced',
},
]

View File

@@ -11,7 +11,7 @@ import iconInvoice from '@/static/icon/invoice.png'
import iconMessagePush from '@/static/icon/message-push.png' import iconMessagePush from '@/static/icon/message-push.png'
import iconQualification from '@/static/icon/qualification.png' import iconQualification from '@/static/icon/qualification.png'
import iconSecurity from '@/static/icon/security.png' import iconSecurity from '@/static/icon/security.png'
import iconServiceHotline from '@/static/icon/service-hotline.png' import iconServiceHotline from '@/static/icon/service-hotline.svg'
export const PERSONAL_CENTER_PROFILE: PersonalCenterProfile = { export const PERSONAL_CENTER_PROFILE: PersonalCenterProfile = {
nickname: '这里是昵称', nickname: '这里是昵称',
@@ -23,15 +23,17 @@ export const PERSONAL_CENTER_MENU: PersonalCenterMenuItem[] = [
{ key: 'security', label: '账号安全', icon: iconSecurity, path: '/pages/personal-center/account-security/index' }, { key: 'security', label: '账号安全', icon: iconSecurity, path: '/pages/personal-center/account-security/index' },
{ key: 'message-push', label: '消息推送', icon: iconMessagePush }, { key: 'message-push', label: '消息推送', icon: iconMessagePush },
{ key: 'feedback', label: '意见反馈', icon: iconFeedback, path: '/pages/personal-center/feedback/index' }, { key: 'feedback', label: '意见反馈', icon: iconFeedback, path: '/pages/personal-center/feedback/index' },
{ key: 'contract-list', label: '合同列表', icon: iconContractList }, { key: 'contract-list', label: '合同列表', icon: iconContractList, path: '/pages/contract-center/index' },
{ key: 'finance', label: '财务中心', icon: iconFinance, path: '/pages/personal-center/finance/index' }, { key: 'finance', label: '财务中心', icon: iconFinance, path: '/pages/personal-center/finance/index' },
{ key: 'invoice', label: '发票列表', icon: iconInvoice }, { key: 'invoice', label: '发票列表', icon: iconInvoice, path: '/pages/personal-center/invoice/index' },
{ key: 'address', label: '地址管理', icon: iconAddress }, { key: 'address', label: '地址管理', icon: iconAddress, path: '/pages/personal-center/address/index' },
{ key: 'favorites', label: '收藏列表', icon: iconFavorites, path: '/pages/case-library/favorites/index' }, { key: 'favorites', label: '收藏列表', icon: iconFavorites, path: '/pages/case-library/favorites/index' },
{ key: 'qualification', label: '资质列表', icon: iconQualification }, { key: 'qualification', label: '资质列表', icon: iconQualification, path: '/pages/personal-center/qualification-list/index' },
{ key: 'help', label: '帮助中心', icon: iconHelp }, { key: 'help', label: '帮助中心', icon: iconHelp },
{ key: 'about', label: '关于我们', icon: iconAbout }, { key: 'about', label: '关于我们', icon: iconAbout },
{ key: 'service-hotline', label: '专属客服热线', icon: iconServiceHotline }, { key: 'service-hotline', label: '专属客服热线', icon: iconServiceHotline, path: '/pages/personal-center/service-hotline/index' },
] ]
export const PERSONAL_CENTER_SERVICE_HOTLINE = '400-000-0000' export const PERSONAL_CENTER_SERVICE_HOTLINE = '400-000-0000'
export const PERSONAL_CENTER_SERVICE_HOTLINE_HOURS = '工作日 8:00-12:00 14:00-18:00'

View File

@@ -0,0 +1,38 @@
import type { QualificationItem, QualificationStatus } from '@/types/qualification'
export const QUALIFICATION_TYPE_OPTIONS = [
'营业执照',
'高新技术企业证书',
'ISO 质量管理体系认证',
'软件企业认定证书',
]
export const QUALIFICATION_STATUS_LABEL: Record<QualificationStatus, string> = {
reviewing: '审核中',
approved: '已通过',
rejected: '已驳回',
draft: '草稿',
}
export const QUALIFICATION_LIST: QualificationItem[] = [
{
id: '1',
type: '营业执照',
certificateName: '山东日照有限公司',
certificateNo: '344683434513543543545535',
issuer: '山东省政务局',
validFrom: '2024-10-10',
validTo: '2027-10-10',
status: 'reviewing',
},
{
id: '2',
type: '营业执照',
certificateName: '山东日照有限公司',
certificateNo: '344683434513543543545535',
issuer: '山东省政务局',
validFrom: '2024-10-10',
validTo: '2027-10-10',
status: 'reviewing',
},
]

View File

@@ -0,0 +1,16 @@
import { computed, ref } from 'vue'
import { INVOICE_HEADER_LIST } from '@/constants/invoice-list'
import type { InvoiceHeaderItem } from '@/types/invoice-list'
const invoiceHeaders = ref<InvoiceHeaderItem[]>(INVOICE_HEADER_LIST.map(item => ({ ...item })))
export function useInvoiceHeaders() {
const defaultHeader = computed(() =>
invoiceHeaders.value.find(item => item.isDefault) || invoiceHeaders.value[0] || null,
)
return {
invoiceHeaders,
defaultHeader,
}
}

View File

@@ -0,0 +1,16 @@
import { ref } from 'vue'
import { QUALIFICATION_LIST } from '@/constants/qualification'
import type { QualificationItem } from '@/types/qualification'
const qualifications = ref<QualificationItem[]>(QUALIFICATION_LIST.map(item => ({ ...item })))
export function useQualifications() {
function getQualificationById(id: string) {
return qualifications.value.find(item => item.id === id) || null
}
return {
qualifications,
getQualificationById,
}
}

View File

@@ -198,6 +198,48 @@
"style": { "style": {
"navigationStyle": "custom" "navigationStyle": "custom"
} }
},
{
"path": "pages/personal-center/invoice/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/personal-center/invoice-list/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/personal-center/address/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/contract-center/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/personal-center/service-hotline/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/personal-center/qualification-list/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/personal-center/qualification-upload/index",
"style": {
"navigationStyle": "custom"
}
} }
], ],
"globalStyle": { "globalStyle": {

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 AppTabBar from '@/components/AppTabBar.vue'
import { useGreeting } from '@/hooks/use-greeting' import { useGreeting } from '@/hooks/use-greeting'
import { useQuickEntries } from '@/hooks/use-quick-entries' 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 headerBg from '@/static/icons/1.png'
import softwareIllus from '@/static/icons/software.png' import softwareIllus from '@/static/icons/software.png'
import hardwareIllus from '@/static/icons/hardware.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[] = [ const projects: ProjectItem[] = [
{ {
id: '1', id: '1',
@@ -163,6 +168,7 @@ const projects: ProjectItem[] = [
v-for="entry in pinnedEntries" v-for="entry in pinnedEntries"
:key="entry.id" :key="entry.id"
class="home__quick-item" class="home__quick-item"
@tap="handleQuickEntryTap(entry)"
> >
<image <image
class="home__quick-icon" 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 { import {
PERSONAL_CENTER_MENU, PERSONAL_CENTER_MENU,
PERSONAL_CENTER_PROFILE, PERSONAL_CENTER_PROFILE,
PERSONAL_CENTER_SERVICE_HOTLINE,
} from '@/constants/personal-center' } from '@/constants/personal-center'
import type { PersonalCenterMenuItem } from '@/types/personal-center' import type { PersonalCenterMenuItem } from '@/types/personal-center'
import editIcon from '@/static/icon/edit.svg' import editIcon from '@/static/icon/edit.svg'
@@ -21,16 +20,6 @@ function handleMenuTap(item: PersonalCenterMenuItem) {
return 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' }) uni.showToast({ title: '功能开发中', icon: 'none' })
} }
</script> </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>

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1789542083912" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="13339" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M517.423 770.417c4.385 0 8.843-0.268 13.336-0.8 43.066-5.171 79.639-35.276 93.136-76.762 1.019-3.102-0.449-6.423-3.42-7.789-2.936-1.366-6.492-0.185-8.155 2.6-0.156 0.263-17.267 27.901-83.883 36.901-9.975 1.336-19.677 2.039-28.876 2.039-46.671-0.03-67.469-17.624-67.659-17.784-2.351-2.088-5.936-2.141-8.341-0.107-2.434 2.034-2.994 5.57-1.336 8.278 20.319 32.936 56.776 53.424 95.198 53.424z m358.196-412.861c-0.586 0-1.123 0.107-1.678 0.107-39.037-168.986-188.18-295.043-367-295.043-183.735 0-336.303 133.018-370.121 308.999-32.876 5.916-57.904 34.817-57.904 69.767v139.879c0 39.203 31.438 70.992 70.255 70.992 21.896 0 41.226-10.342 54.079-26.218 31.067 82.165 94.313 148.411 174.346 183.365 0.196-0.415 1.708-3.36 3.533-5.712 1.257-1.625 2.672-2.961 3.882-2.961 1.254 0 2.41 0.458 3.395 1.151-18.526-13.789-85.434-84.683-99.932-183.609-6.356-43.544 26.258-86.288 64.241-93.361 60.982-11.356 121.642-24.291 182.619-35.427 38.764-7.073 65.255-28.335 81.453-63.743 3.795-8.273 9.278-25.008 11.795-49.143 0.663-3.585 3.609-6.341 7.375-6.341 2.516 0 4.653 1.282 6.073 3.131l1.683-1.043c24.033 34.895 71.694 112.159 78.542 193.628 7.858 93.141 3.473 156.938-67.82 222.197a3.076 3.076 0 0 1-0.293 0.273 5.834 5.834 0 0 0-1.58 3.991c0 2.004 1.068 3.721 2.619 4.765 0.586 0.239 1.176 0.561 1.766 0.804 0.478 0.107 0.937 0.263 1.415 0.263 0.488 0 0.913-0.156 1.341-0.263 1.015-0.536 1.975-1.15 2.966-1.687 72.099-39.72 127.409-106.053 152.967-185.833 10.347 15.369 26.545 26.433 45.363 30.29-30.183 137.276-152.615 223.193-304.3 236.231-9.092-22.55-31.466-38.559-57.748-38.559-34.32 0-62.152 27.179-62.152 60.685 0 33.5 27.828 60.655 62.152 60.655 27.671 0 50.846-17.779 58.894-42.208 175.58-14.271 316.333-118.638 345.717-280.794 25.961-10.853 44.188-36.32 44.188-66.046V429.201c-0.004-39.559-32.3-71.645-72.131-71.645z m-64.773 40.656c-45.792-124.051-164.464-212.666-304.061-212.666-139.017 0-257.258 87.868-303.481 211.115-2.298-2.815-4.995-5.223-7.643-7.658 26.169-150.191 155.426-264.38 311.28-264.38 155.104 0 283.966 113.067 310.988 262.214-2.668 3.585-5.132 7.332-7.083 11.375z m-433.24 411.197h-0.005c-0.028 0.063-0.028 0.073 0.005 0z m0 0" fill="#2681FF" p-id="13340"></path></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

8
src/types/address.ts Normal file
View File

@@ -0,0 +1,8 @@
export interface AddressItem {
id: string
name: string
phone: string
region: string
detail: string
isDefault: boolean
}

23
src/types/contract.ts Normal file
View File

@@ -0,0 +1,23 @@
export type ContractTab = 'all' | 'pending' | 'signed'
export type ContractSignStatus = 'signed' | 'pending'
export type ContractBadgeType = 'signed' | 'in_progress' | 'pending_sign'
export interface ContractBadge {
type: ContractBadgeType
label: string
}
export interface ContractItem {
id: string
contractNo: string
title: string
type: string
relatedLabel: string
relatedValue: string
signStatus: ContractSignStatus
badges: ContractBadge[]
showDownload: boolean
showSign: boolean
}

View File

@@ -0,0 +1,9 @@
export type InvoiceHeaderType = 'enterprise' | 'personal'
export interface InvoiceHeaderItem {
id: string
title: string
type: InvoiceHeaderType
taxNo: string
isDefault: boolean
}

14
src/types/invoice.ts Normal file
View File

@@ -0,0 +1,14 @@
export type InvoiceRecordStatus = 'all' | 'invoiced' | 'not_invoiced' | 'invoicing'
export type InvoiceRecordItemStatus = Exclude<InvoiceRecordStatus, 'all'>
export interface InvoiceRecordItem {
id: string
title: string
ratio: string
amount: number
transactionNo: string
relatedProject: string
invoiceTime: string
status: InvoiceRecordItemStatus
}

View File

@@ -0,0 +1,12 @@
export type QualificationStatus = 'reviewing' | 'approved' | 'rejected' | 'draft'
export interface QualificationItem {
id: string
type: string
certificateName: string
certificateNo: string
issuer: string
validFrom: string
validTo: string
status: QualificationStatus
}

View File

@@ -0,0 +1,39 @@
const TAB_BAR_PATHS = new Set([
'/pages/home-index/index',
'/pages/case-library/index',
'/pages/message-center/index',
'/pages/project-center/index',
'/pages/personal-center/index',
])
const QUICK_ENTRY_ROUTES: Record<string, string> = {
project: '/pages/project-center/index',
report: '/pages/home-index/requirement-submit/index',
service: '/pages/message-center/customer-service/index',
contract: '/pages/contract-center/index',
finance: '/pages/personal-center/finance/index',
personal: '/pages/personal-center/index',
message: '/pages/message-center/index',
security: '/pages/personal-center/account-security/index',
feedback: '/pages/personal-center/feedback/index',
'contract-list': '/pages/contract-center/index',
invoice: '/pages/personal-center/invoice/index',
address: '/pages/personal-center/address/index',
favorites: '/pages/case-library/favorites/index',
qualification: '/pages/personal-center/qualification-list/index',
}
export function navigateQuickEntry(id: string, label: string) {
const path = QUICK_ENTRY_ROUTES[id]
if (!path) {
uni.showToast({ title: `${label}开发中`, icon: 'none' })
return
}
if (TAB_BAR_PATHS.has(path)) {
uni.redirectTo({ url: path })
return
}
uni.navigateTo({ url: path })
}