feat:项目中心和个人中心

This commit is contained in:
李伊帆
2026-09-15 17:51:59 +08:00
parent 2c7a115dd7
commit 56eb59ed5d
43 changed files with 2028 additions and 44 deletions

View File

@@ -1,6 +1,7 @@
@import '@/styles/variables.scss';
.login-page {
position: relative;
display: flex;
flex-direction: column;
min-height: 100vh;
@@ -8,6 +9,15 @@
background: $color-bg-page;
}
.login-page__guest {
position: absolute;
top: calc(var(--status-bar-height, 44px) + 24rpx);
right: 32rpx;
z-index: 10;
font-size: 28rpx;
color: $color-text-link;
}
.login-page__header {
padding-top: 120rpx;
text-align: center;

View File

@@ -47,10 +47,21 @@ function handleUserAgreement() {
url: '/pages/Login/user-agreement/index',
})
}
function handleGuestMode() {
uni.redirectTo({ url: '/pages/home-index/index' })
}
</script>
<template>
<view class="login-page">
<text
class="login-page__guest"
@tap="handleGuestMode"
>
游客模式
</text>
<view class="login-page__header">
<text class="login-page__title">
欢迎回来

View File

@@ -23,7 +23,12 @@ const redirectUrl = computed(() => {
const title = computed(() => pageConfig.value.title)
const subtitle = computed(() => `${countdown.value}秒后自动跳转至${pageConfig.value.subtitleTarget}`)
const subtitle = computed(() => {
if (pageType.value === 'modifySuccess')
return `${countdown.value}秒后自动返回`
return `${countdown.value}秒后自动跳转至${pageConfig.value.subtitleTarget}`
})
onLoad((options) => {
const type = options?.type
@@ -44,7 +49,10 @@ onLoad((options) => {
const id = options?.id
if (id)
projectId.value = decodeURIComponent(String(id))
return
}
if (type === 'modifySuccess')
pageType.value = 'modifySuccess'
})
onMounted(() => {

View File

@@ -47,10 +47,8 @@
}
.password-strength__error-mark {
font-size: 20rpx;
font-weight: 700;
color: #ffffff;
line-height: 1;
width: 28rpx;
height: 28rpx;
}
.password-strength__error-text {

View File

@@ -45,9 +45,11 @@ const showIndicator = computed(() => props.password.length > 0)
class="password-strength__error"
>
<view class="password-strength__error-icon">
<text class="password-strength__error-mark">
<img src="/static/icons/icon-mark.png" alt="error" />
</text>
<image
class="password-strength__error-mark"
src="/static/icons/icon-mark.png"
mode="aspectFit"
/>
</view>
<text class="password-strength__error-text">
密码等级过低,请重新输入

View File

@@ -0,0 +1,107 @@
@import '@/styles/variables.scss';
.account-security {
display: flex;
flex-direction: column;
min-height: 100vh;
background: $color-bg-page;
}
.account-security__navbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
padding: calc(var(--status-bar-height, 44px) + 60rpx) 24rpx 28rpx;
background: $color-bg-page;
}
.account-security__back {
display: flex;
align-items: center;
justify-content: center;
width: 64rpx;
height: 64rpx;
}
.account-security__back-icon {
width: 40rpx;
height: 40rpx;
}
.account-security__navbar-title {
font-size: 34rpx;
font-weight: 600;
color: $color-text-primary;
}
.account-security__navbar-placeholder {
width: 64rpx;
height: 64rpx;
}
.account-security__content {
flex: 1;
padding: 24rpx 32rpx calc(32rpx + env(safe-area-inset-bottom));
}
.account-security__list {
display: flex;
flex-direction: column;
gap: 48rpx;
}
.account-security__item {
display: flex;
align-items: center;
justify-content: space-between;
}
.account-security__item-main {
flex: 1;
min-width: 0;
}
.account-security__item-title {
display: block;
font-size: 32rpx;
color: $color-text-primary;
}
.account-security__item-subtitle {
display: block;
margin-top: 12rpx;
font-size: 26rpx;
color: $color-text-secondary;
}
.account-security__item-side {
display: flex;
flex-shrink: 0;
align-items: center;
margin-left: 24rpx;
}
.account-security__item-status {
font-size: 28rpx;
color: $color-text-secondary;
}
.account-security__item-arrow {
margin-left: 8rpx;
font-size: 36rpx;
line-height: 1;
color: $color-text-placeholder;
}
.account-security__delete-btn {
display: flex;
align-items: center;
justify-content: center;
height: 88rpx;
margin-top: 120rpx;
font-size: 30rpx;
color: #ffffff;
background: $color-primary;
border-radius: 12rpx;
}

View File

@@ -0,0 +1,103 @@
<script setup lang="ts">
import { ACCOUNT_SECURITY_ITEMS } from '@/constants/account-security'
import type { AccountSecurityItem } from '@/types/account-security'
import { navigateBackWithFallback } from '@/utils/navigate-back'
const securityItems = ACCOUNT_SECURITY_ITEMS
function handleBack() {
navigateBackWithFallback('/pages/personal-center/index')
}
function handleItemTap(item: AccountSecurityItem) {
if (item.key === 'password') {
uni.navigateTo({ url: '/pages/personal-center/change-password/index' })
return
}
if (item.key === 'phone') {
uni.navigateTo({ url: '/pages/personal-center/bind-phone/index' })
return
}
if (item.key === 'email') {
uni.navigateTo({ url: '/pages/personal-center/bind-email/index' })
return
}
uni.showToast({ title: `${item.title}开发中`, icon: 'none' })
}
function handleDeleteAccount() {
uni.showModal({
title: '注销账号',
content: '注销后账号数据将无法恢复,确认继续吗?',
confirmText: '确认注销',
confirmColor: '#2563EB',
success: (res) => {
if (res.confirm)
uni.showToast({ title: '注销申请已提交', icon: 'none' })
},
})
}
</script>
<template>
<view class="account-security">
<view class="account-security__navbar">
<view
class="account-security__back"
@tap="handleBack"
>
<image
class="account-security__back-icon"
src="/static/icons/icon-back.svg"
mode="aspectFit"
/>
</view>
<text class="account-security__navbar-title">
账号安全
</text>
<view class="account-security__navbar-placeholder" />
</view>
<view class="account-security__content">
<view class="account-security__list">
<view
v-for="item in securityItems"
:key="item.key"
class="account-security__item"
@tap="handleItemTap(item)"
>
<view class="account-security__item-main">
<text class="account-security__item-title">
{{ item.title }}
</text>
<text class="account-security__item-subtitle">
{{ item.subtitle }}
</text>
</view>
<view class="account-security__item-side">
<text class="account-security__item-status">
{{ item.status }}
</text>
<text class="account-security__item-arrow">
›
</text>
</view>
</view>
</view>
<view
class="account-security__delete-btn"
@tap="handleDeleteAccount"
>
注销账号
</view>
</view>
</view>
</template>
<style scoped lang="scss">
@import './index.scss';
</style>

View File

@@ -0,0 +1,63 @@
@import '@/styles/variables.scss';
.bind-email-change {
display: flex;
flex-direction: column;
min-height: 100vh;
background: $color-bg-page;
}
.bind-email-change__navbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
padding: calc(var(--status-bar-height, 44px) + 60rpx) 24rpx 28rpx;
background: $color-bg-page;
}
.bind-email-change__back {
display: flex;
align-items: center;
justify-content: center;
width: 64rpx;
height: 64rpx;
}
.bind-email-change__back-icon {
width: 40rpx;
height: 40rpx;
}
.bind-email-change__navbar-title {
font-size: 34rpx;
font-weight: 600;
color: $color-text-primary;
}
.bind-email-change__navbar-placeholder {
width: 64rpx;
height: 64rpx;
}
.bind-email-change__content {
padding: 48rpx 32rpx calc(32rpx + env(safe-area-inset-bottom));
}
.bind-email-change__field {
width: 100%;
margin-bottom: 24rpx;
box-sizing: border-box;
}
.bind-email-change__submit {
display: flex;
align-items: center;
justify-content: center;
height: 88rpx;
margin-top: 48rpx;
font-size: 30rpx;
color: #ffffff;
background: $color-primary;
border-radius: 12rpx;
}

View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
import { ref } from 'vue'
import ForgotInput from '@/pages/Login/user-forgot-password/components/ForgotInput.vue'
import CodeInput from '@/pages/Login/user-register/components/CodeInput.vue'
import { navigateBackWithFallback } from '@/utils/navigate-back'
import { navigateToResultPage } from '@/utils/navigate-result'
const email = ref('')
const code = ref('')
function handleBack() {
navigateBackWithFallback('/pages/personal-center/bind-email/index')
}
function isValidEmail(value: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
}
function handleSubmit() {
if (!email.value.trim()) {
uni.showToast({ title: '请输入邮箱', icon: 'none' })
return
}
if (!isValidEmail(email.value.trim())) {
uni.showToast({ title: '请输入正确的邮箱', icon: 'none' })
return
}
if (!code.value.trim()) {
uni.showToast({ title: '请输入验证码', icon: 'none' })
return
}
navigateToResultPage('modifySuccess')
}
</script>
<template>
<view class="bind-email-change">
<view class="bind-email-change__navbar">
<view
class="bind-email-change__back"
@tap="handleBack"
>
<image
class="bind-email-change__back-icon"
src="/static/icons/icon-back.svg"
mode="aspectFit"
/>
</view>
<text class="bind-email-change__navbar-title">
绑定邮箱
</text>
<view class="bind-email-change__navbar-placeholder" />
</view>
<view class="bind-email-change__content">
<view class="bind-email-change__field">
<ForgotInput
v-model="email"
placeholder="请输入邮箱"
/>
</view>
<view class="bind-email-change__field">
<CodeInput v-model="code" />
</view>
<view
class="bind-email-change__submit"
@tap="handleSubmit"
>
验证并绑定邮箱
</view>
</view>
</view>
</template>
<style scoped lang="scss">
@import './index.scss';
</style>

View File

@@ -0,0 +1,96 @@
@import '@/styles/variables.scss';
.bind-email {
display: flex;
flex-direction: column;
min-height: 100vh;
background: $color-bg-page;
}
.bind-email__navbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
padding: calc(var(--status-bar-height, 44px) + 60rpx) 24rpx 28rpx;
background: $color-bg-page;
}
.bind-email__back {
display: flex;
align-items: center;
justify-content: center;
width: 64rpx;
height: 64rpx;
}
.bind-email__back-icon {
width: 40rpx;
height: 40rpx;
}
.bind-email__navbar-title {
font-size: 34rpx;
font-weight: 600;
color: $color-text-primary;
}
.bind-email__navbar-placeholder {
width: 64rpx;
height: 64rpx;
}
.bind-email__content {
padding: 48rpx 32rpx calc(32rpx + env(safe-area-inset-bottom));
}
.bind-email__field {
width: 100%;
box-sizing: border-box;
}
.bind-email__label {
display: block;
margin-bottom: 16rpx;
font-size: 28rpx;
font-weight: 500;
color: $color-text-primary;
}
.bind-email__email {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 96rpx;
padding: 0 32rpx;
box-sizing: border-box;
background: $color-bg-input;
border-radius: 48rpx;
}
.bind-email__email-text {
font-size: 30rpx;
color: $color-text-primary;
}
.bind-email__actions {
display: flex;
gap: 20rpx;
margin-top: 48rpx;
}
.bind-email__btn {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
height: 88rpx;
font-size: 28rpx;
border-radius: 12rpx;
&--primary {
color: #ffffff;
background: $color-primary;
}
}

View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
import { BOUND_EMAIL_MASKED } from '@/constants/bind-email'
import { navigateBackWithFallback } from '@/utils/navigate-back'
const boundEmail = BOUND_EMAIL_MASKED
function handleBack() {
navigateBackWithFallback('/pages/personal-center/account-security/index')
}
function handleDeleteEmail() {
uni.showModal({
title: '删除邮箱',
content: '删除后将无法通过邮箱接收通知,确认删除吗?',
confirmText: '确认删除',
confirmColor: '#2563EB',
success: (res) => {
if (res.confirm)
uni.showToast({ title: '邮箱已删除', icon: 'none' })
},
})
}
function handleChangeEmail() {
uni.navigateTo({ url: '/pages/personal-center/bind-email-change/index' })
}
</script>
<template>
<view class="bind-email">
<view class="bind-email__navbar">
<view
class="bind-email__back"
@tap="handleBack"
>
<image
class="bind-email__back-icon"
src="/static/icons/icon-back.svg"
mode="aspectFit"
/>
</view>
<text class="bind-email__navbar-title">
绑定邮箱
</text>
<view class="bind-email__navbar-placeholder" />
</view>
<view class="bind-email__content">
<view class="bind-email__field">
<text class="bind-email__label">
已绑定邮箱
</text>
<view class="bind-email__email">
<text class="bind-email__email-text">
{{ boundEmail }}
</text>
</view>
</view>
<view class="bind-email__actions">
<view
class="bind-email__btn bind-email__btn--primary"
@tap="handleDeleteEmail"
>
删除邮箱
</view>
<view
class="bind-email__btn bind-email__btn--primary"
@tap="handleChangeEmail"
>
更换绑定邮箱
</view>
</view>
</view>
</view>
</template>
<style scoped lang="scss">
@import './index.scss';
</style>

View File

@@ -0,0 +1,79 @@
@import '@/styles/variables.scss';
.bind-phone-new {
display: flex;
flex-direction: column;
min-height: 100vh;
background: $color-bg-page;
}
.bind-phone-new__navbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
padding: calc(var(--status-bar-height, 44px) + 60rpx) 24rpx 28rpx;
background: $color-bg-page;
}
.bind-phone-new__back {
display: flex;
align-items: center;
justify-content: center;
width: 64rpx;
height: 64rpx;
}
.bind-phone-new__back-icon {
width: 40rpx;
height: 40rpx;
}
.bind-phone-new__navbar-title {
font-size: 34rpx;
font-weight: 600;
color: $color-text-primary;
}
.bind-phone-new__navbar-placeholder {
width: 64rpx;
height: 64rpx;
}
.bind-phone-new__content {
padding: 48rpx 32rpx calc(32rpx + env(safe-area-inset-bottom));
}
.bind-phone-new__field {
width: 100%;
margin-bottom: 32rpx;
box-sizing: border-box;
}
.bind-phone-new__label {
display: block;
margin-bottom: 16rpx;
font-size: 28rpx;
font-weight: 500;
color: $color-text-primary;
}
.bind-phone-new__hint {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
line-height: 1.6;
color: $color-text-secondary;
}
.bind-phone-new__submit {
display: flex;
align-items: center;
justify-content: center;
height: 88rpx;
margin-top: 48rpx;
font-size: 30rpx;
color: #ffffff;
background: $color-primary;
border-radius: 12rpx;
}

View File

@@ -0,0 +1,90 @@
<script setup lang="ts">
import { ref } from 'vue'
import { BIND_PHONE_NEW_HINT } from '@/constants/bind-phone'
import CodeInput from '@/pages/Login/user-register/components/CodeInput.vue'
import RegisterInput from '@/pages/Login/user-register/components/RegisterInput.vue'
import { navigateBackWithFallback } from '@/utils/navigate-back'
import { navigateToResultPage } from '@/utils/navigate-result'
const hint = BIND_PHONE_NEW_HINT
const newPhone = ref('')
const code = ref('')
function handleBack() {
navigateBackWithFallback('/pages/personal-center/bind-phone/index')
}
function handleSubmit() {
if (!newPhone.value.trim()) {
uni.showToast({ title: '请输入新手机号', icon: 'none' })
return
}
if (!/^1\d{10}$/.test(newPhone.value.trim())) {
uni.showToast({ title: '请输入正确的手机号', icon: 'none' })
return
}
if (!code.value.trim()) {
uni.showToast({ title: '请输入验证码', icon: 'none' })
return
}
navigateToResultPage('modifySuccess')
}
</script>
<template>
<view class="bind-phone-new">
<view class="bind-phone-new__navbar">
<view
class="bind-phone-new__back"
@tap="handleBack"
>
<image
class="bind-phone-new__back-icon"
src="/static/icons/icon-back.svg"
mode="aspectFit"
/>
</view>
<text class="bind-phone-new__navbar-title">
绑定手机号
</text>
<view class="bind-phone-new__navbar-placeholder" />
</view>
<view class="bind-phone-new__content">
<view class="bind-phone-new__field">
<text class="bind-phone-new__label">
请输入新手机号
</text>
<RegisterInput
v-model="newPhone"
icon="/static/icons/icon-phone.png"
placeholder="请输入新手机号"
input-type="number"
/>
</view>
<view class="bind-phone-new__field">
<text class="bind-phone-new__label">
验证码
</text>
<CodeInput v-model="code" />
</view>
<text class="bind-phone-new__hint">
{{ hint }}
</text>
<view
class="bind-phone-new__submit"
@tap="handleSubmit"
>
验证并绑定手机号
</view>
</view>
</view>
</template>
<style scoped lang="scss">
@import './index.scss';
</style>

View File

@@ -0,0 +1,87 @@
@import '@/styles/variables.scss';
.bind-phone {
display: flex;
flex-direction: column;
min-height: 100vh;
background: $color-bg-page;
}
.bind-phone__navbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
padding: calc(var(--status-bar-height, 44px) + 60rpx) 24rpx 28rpx;
background: $color-bg-page;
}
.bind-phone__back {
display: flex;
align-items: center;
justify-content: center;
width: 64rpx;
height: 64rpx;
}
.bind-phone__back-icon {
width: 40rpx;
height: 40rpx;
}
.bind-phone__navbar-title {
font-size: 34rpx;
font-weight: 600;
color: $color-text-primary;
}
.bind-phone__navbar-placeholder {
width: 64rpx;
height: 64rpx;
}
.bind-phone__content {
padding: 48rpx 32rpx calc(32rpx + env(safe-area-inset-bottom));
}
.bind-phone__field {
width: 100%;
margin-bottom: 32rpx;
box-sizing: border-box;
}
.bind-phone__label {
display: block;
margin-bottom: 16rpx;
font-size: 28rpx;
font-weight: 500;
color: $color-text-primary;
}
.bind-phone__phone {
display: flex;
align-items: center;
width: 100%;
height: 96rpx;
padding: 0 32rpx;
box-sizing: border-box;
background: $color-bg-input;
border-radius: 48rpx;
}
.bind-phone__phone-text {
font-size: 30rpx;
color: $color-text-primary;
}
.bind-phone__submit {
display: flex;
align-items: center;
justify-content: center;
height: 88rpx;
margin-top: 48rpx;
font-size: 30rpx;
color: #ffffff;
background: $color-primary;
border-radius: 12rpx;
}

View File

@@ -0,0 +1,74 @@
<script setup lang="ts">
import { ref } from 'vue'
import { BOUND_PHONE_MASKED } from '@/constants/bind-phone'
import CodeInput from '@/pages/Login/user-register/components/CodeInput.vue'
import { navigateBackWithFallback } from '@/utils/navigate-back'
const boundPhone = BOUND_PHONE_MASKED
const code = ref('')
function handleBack() {
navigateBackWithFallback('/pages/personal-center/account-security/index')
}
function handleSubmit() {
if (!code.value.trim()) {
uni.showToast({ title: '请输入验证码', icon: 'none' })
return
}
uni.navigateTo({ url: '/pages/personal-center/bind-phone-new/index' })
}
</script>
<template>
<view class="bind-phone">
<view class="bind-phone__navbar">
<view
class="bind-phone__back"
@tap="handleBack"
>
<image
class="bind-phone__back-icon"
src="/static/icons/icon-back.svg"
mode="aspectFit"
/>
</view>
<text class="bind-phone__navbar-title">
绑定手机号
</text>
<view class="bind-phone__navbar-placeholder" />
</view>
<view class="bind-phone__content">
<view class="bind-phone__field">
<text class="bind-phone__label">
已绑定手机号
</text>
<view class="bind-phone__phone">
<text class="bind-phone__phone-text">
{{ boundPhone }}
</text>
</view>
</view>
<view class="bind-phone__field">
<text class="bind-phone__label">
验证码
</text>
<CodeInput v-model="code" />
</view>
<view
class="bind-phone__submit"
@tap="handleSubmit"
>
验证并换绑
</view>
</view>
</view>
</template>
<style scoped lang="scss">
@import './index.scss';
</style>

View File

@@ -0,0 +1,71 @@
@import '@/styles/variables.scss';
.change-password {
display: flex;
flex-direction: column;
min-height: 100vh;
background: $color-bg-page;
}
.change-password__navbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
padding: calc(var(--status-bar-height, 44px) + 60rpx) 24rpx 28rpx;
background: $color-bg-page;
}
.change-password__back {
display: flex;
align-items: center;
justify-content: center;
width: 64rpx;
height: 64rpx;
}
.change-password__back-icon {
width: 40rpx;
height: 40rpx;
}
.change-password__navbar-title {
font-size: 34rpx;
font-weight: 600;
color: $color-text-primary;
}
.change-password__navbar-placeholder {
width: 64rpx;
height: 64rpx;
}
.change-password__content {
padding: 48rpx 32rpx calc(32rpx + env(safe-area-inset-bottom));
}
.change-password__field {
width: 100%;
margin-bottom: 24rpx;
box-sizing: border-box;
&--password {
margin-bottom: 32rpx;
}
}
.change-password__strength {
width: 100%;
}
.change-password__submit {
display: flex;
align-items: center;
justify-content: center;
height: 88rpx;
margin-top: 48rpx;
font-size: 30rpx;
color: #ffffff;
background: $color-primary;
border-radius: 12rpx;
}

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import { ref } from 'vue'
import PasswordField from '@/pages/Login/user-register/components/PasswordField.vue'
import PasswordStrength from '@/pages/Login/user-register/components/PasswordStrength.vue'
import { getPasswordStrength } from '@/utils/password-strength'
import { navigateBackWithFallback } from '@/utils/navigate-back'
import { navigateToResultPage } from '@/utils/navigate-result'
const oldPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
function handleBack() {
navigateBackWithFallback('/pages/personal-center/account-security/index')
}
function handleSubmit() {
if (!oldPassword.value || !newPassword.value || !confirmPassword.value) {
uni.showToast({ title: '请填写完整信息', icon: 'none' })
return
}
if (newPassword.value !== confirmPassword.value) {
uni.showToast({ title: '两次密码输入不一致', icon: 'none' })
return
}
if (getPasswordStrength(newPassword.value).isTooWeak) {
uni.showToast({ title: '密码等级过低,请重新输入', icon: 'none' })
return
}
navigateToResultPage('modifySuccess')
}
</script>
<template>
<view class="change-password">
<view class="change-password__navbar">
<view
class="change-password__back"
@tap="handleBack"
>
<image
class="change-password__back-icon"
src="/static/icons/icon-back.svg"
mode="aspectFit"
/>
</view>
<text class="change-password__navbar-title">
修改密码
</text>
<view class="change-password__navbar-placeholder" />
</view>
<view class="change-password__content">
<view class="change-password__field">
<PasswordField
v-model="oldPassword"
placeholder="输入旧密码"
/>
</view>
<view class="change-password__field change-password__field--password">
<PasswordField
v-model="newPassword"
placeholder="设置新密码"
/>
<view class="change-password__strength">
<PasswordStrength :password="newPassword" />
</view>
</view>
<view class="change-password__field">
<PasswordField
v-model="confirmPassword"
placeholder="确认新密码"
/>
</view>
<view
class="change-password__submit"
@tap="handleSubmit"
>
确认修改
</view>
</view>
</view>
</template>
<style scoped lang="scss">
@import './index.scss';
</style>

View File

@@ -0,0 +1,224 @@
@import '@/styles/variables.scss';
.feedback {
display: flex;
flex-direction: column;
min-height: 100vh;
background: $color-bg-section;
}
.feedback__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;
}
.feedback__back {
display: flex;
align-items: center;
justify-content: center;
width: 64rpx;
height: 64rpx;
}
.feedback__back-icon {
width: 40rpx;
height: 40rpx;
}
.feedback__navbar-title {
font-size: 34rpx;
font-weight: 600;
color: $color-text-primary;
}
.feedback__navbar-placeholder {
width: 64rpx;
height: 64rpx;
}
.feedback__scroll {
flex: 1;
height: 0;
padding: 20rpx 24rpx;
box-sizing: border-box;
}
.feedback__card {
padding: 28rpx 24rpx;
background: $color-bg-page;
border-radius: 20rpx;
}
.feedback__input {
width: 100%;
font-size: 28rpx;
color: $color-text-primary;
box-sizing: border-box;
&--title {
height: 80rpx;
padding: 0 24rpx;
background: $color-bg-page;
border: 1rpx solid #e8eaef;
border-radius: 12rpx;
}
}
.feedback__placeholder {
color: $color-text-placeholder;
}
.feedback__textarea-wrap {
position: relative;
margin-top: 24rpx;
padding: 20rpx 24rpx 48rpx;
background: $color-bg-page;
border: 1rpx solid #e8eaef;
border-radius: 12rpx;
}
.feedback__textarea {
width: 100%;
min-height: 280rpx;
font-size: 28rpx;
line-height: 1.6;
color: $color-text-primary;
}
.feedback__counter {
position: absolute;
right: 24rpx;
bottom: 16rpx;
font-size: 22rpx;
color: $color-text-secondary;
}
.feedback__field {
margin-top: 28rpx;
}
.feedback__label-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.feedback__label {
font-size: 28rpx;
font-weight: 500;
color: $color-text-primary;
&--inline {
display: inline;
}
}
.feedback__hint {
font-size: 24rpx;
color: $color-text-secondary;
}
.feedback__attachment-row {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
padding: 10rpx 10rpx 0 0;
}
.feedback__attachment-item {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
width: 160rpx;
padding: 20rpx 16rpx;
background: $color-bg-section;
border-radius: 12rpx;
}
.feedback__attachment-remove {
position: absolute;
top: -10rpx;
right: -10rpx;
width: 32rpx;
height: 32rpx;
}
.feedback__attachment-remove-icon {
width: 32rpx;
height: 32rpx;
}
.feedback__attachment-icon {
width: 64rpx;
height: 64rpx;
}
.feedback__attachment-name {
margin-top: 12rpx;
font-size: 22rpx;
color: $color-text-primary;
text-align: center;
word-break: break-all;
}
.feedback__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;
}
.feedback__attachment-add-icon {
font-size: 40rpx;
line-height: 1;
color: $color-text-secondary;
}
.feedback__attachment-add-text {
margin-top: 8rpx;
font-size: 20rpx;
color: $color-text-secondary;
}
.feedback__footer {
display: flex;
gap: 20rpx;
flex-shrink: 0;
padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));
background: $color-bg-page;
border-top: 1rpx solid #f0f0f0;
}
.feedback__footer-btn {
display: flex;
align-items: center;
justify-content: center;
height: 88rpx;
font-size: 30rpx;
border-radius: 12rpx;
&--outline {
flex: 0.8;
color: $color-primary;
background: $color-bg-page;
border: 1rpx solid $color-primary;
}
&--primary {
flex: 1.2;
font-weight: 600;
color: #ffffff;
background: $color-primary;
}
}

View File

@@ -0,0 +1,173 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { FEEDBACK_DESCRIPTION_MAX_LENGTH } from '@/constants/feedback'
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 title = ref('')
const description = ref('')
const attachments = ref([
{ id: '1', name: '需求文档.pdf', icon: pdfIcon },
{ id: '2', name: '系统....pdf', icon: pdfIcon },
{ id: '3', name: '参考图.png', icon: pngIcon },
])
const descLength = computed(() => description.value.length)
function handleBack() {
navigateBackWithFallback('/pages/personal-center/index')
}
function handleRemoveAttachment(id: string) {
attachments.value = attachments.value.filter(item => item.id !== id)
}
function handleAddAttachment() {
uni.showToast({ title: '附件上传开发中', icon: 'none' })
}
function handleReset() {
title.value = ''
description.value = ''
attachments.value = [
{ id: '1', name: '需求文档.pdf', icon: pdfIcon },
{ id: '2', name: '系统....pdf', icon: pdfIcon },
{ id: '3', name: '参考图.png', icon: pngIcon },
]
}
function handleSubmit() {
if (!title.value.trim()) {
uni.showToast({ title: '请输入反馈标题', icon: 'none' })
return
}
if (!description.value.trim()) {
uni.showToast({ title: '请详细描述您的问题', icon: 'none' })
return
}
uni.showToast({ title: '提交成功', icon: 'none' })
setTimeout(() => {
uni.navigateBack()
}, 1500)
}
</script>
<template>
<view class="feedback">
<view class="feedback__navbar">
<view
class="feedback__back"
@tap="handleBack"
>
<image
class="feedback__back-icon"
src="/static/icons/icon-back.svg"
mode="aspectFit"
/>
</view>
<text class="feedback__navbar-title">
意见反馈
</text>
<view class="feedback__navbar-placeholder" />
</view>
<scroll-view
class="feedback__scroll"
scroll-y
:show-scrollbar="false"
>
<view class="feedback__card">
<input
v-model="title"
class="feedback__input feedback__input--title"
placeholder="请输入反馈标题"
placeholder-class="feedback__placeholder"
>
<view class="feedback__textarea-wrap">
<textarea
v-model="description"
class="feedback__textarea"
placeholder="请详细描述您的问题..."
placeholder-class="feedback__placeholder"
:maxlength="FEEDBACK_DESCRIPTION_MAX_LENGTH"
/>
<text class="feedback__counter">
{{ descLength }}/{{ FEEDBACK_DESCRIPTION_MAX_LENGTH }}字
</text>
</view>
<view class="feedback__field">
<view class="feedback__label-row">
<text class="feedback__label feedback__label--inline">
附件上传
</text>
<text class="feedback__hint">
支持图片/文档/压缩包
</text>
</view>
<view class="feedback__attachment-row">
<view
v-for="file in attachments"
:key="file.id"
class="feedback__attachment-item"
>
<view
class="feedback__attachment-remove"
@tap="handleRemoveAttachment(file.id)"
>
<image
class="feedback__attachment-remove-icon"
:src="deleteIcon"
mode="aspectFit"
/>
</view>
<image
class="feedback__attachment-icon"
:src="file.icon"
mode="aspectFit"
/>
<text class="feedback__attachment-name">
{{ file.name }}
</text>
</view>
<view
class="feedback__attachment-add"
@tap="handleAddAttachment"
>
<text class="feedback__attachment-add-icon">
+
</text>
<text class="feedback__attachment-add-text">
添加附件
</text>
</view>
</view>
</view>
</view>
</scroll-view>
<view class="feedback__footer">
<view
class="feedback__footer-btn feedback__footer-btn--primary"
@tap="handleReset"
>
重置
</view>
<view
class="feedback__footer-btn feedback__footer-btn--primary"
@tap="handleSubmit"
>
提交反馈
</view>
</view>
</view>
</template>
<style scoped lang="scss">
@import './index.scss';
</style>

View File

@@ -0,0 +1,154 @@
@import '@/styles/variables.scss';
.finance-center {
display: flex;
flex-direction: column;
min-height: 100vh;
background: $color-bg-section;
}
.finance-center__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;
}
.finance-center__back {
display: flex;
align-items: center;
justify-content: center;
width: 64rpx;
height: 64rpx;
}
.finance-center__back-icon {
width: 40rpx;
height: 40rpx;
}
.finance-center__navbar-title {
font-size: 34rpx;
font-weight: 600;
color: $color-text-primary;
}
.finance-center__navbar-placeholder {
width: 64rpx;
height: 64rpx;
}
.finance-center__scroll {
flex: 1;
height: 0;
padding: 20rpx 24rpx calc(32rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.finance-center__summary {
padding: 32rpx 28rpx 40rpx;
background: linear-gradient(135deg, #3b7cff 0%, $color-home-header 100%);
border-radius: 20rpx;
}
.finance-center__summary-top {
display: flex;
align-items: center;
justify-content: space-between;
}
.finance-center__summary-label {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.9);
}
.finance-center__month {
display: flex;
align-items: center;
height: 52rpx;
padding: 0 20rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 26rpx;
}
.finance-center__month-text {
font-size: 24rpx;
color: #ffffff;
}
.finance-center__month-arrow {
margin-left: 8rpx;
font-size: 20rpx;
color: #ffffff;
}
.finance-center__amount {
display: block;
margin-top: 28rpx;
font-size: 56rpx;
font-weight: 700;
color: #ffffff;
letter-spacing: 1rpx;
}
.finance-center__quick-card {
margin-top: 20rpx;
padding: 28rpx 16rpx 32rpx;
background: $color-bg-page;
border-radius: 20rpx;
}
.finance-center__quick-grid {
display: flex;
justify-content: space-between;
}
.finance-center__quick-item {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
}
.finance-center__quick-icon-wrap {
display: flex;
align-items: center;
justify-content: center;
width: 96rpx;
height: 96rpx;
background: $color-bg-section;
border-radius: 50%;
}
.finance-center__quick-icon {
width: 48rpx;
height: 48rpx;
}
.finance-center__quick-label {
margin-top: 16rpx;
font-size: 24rpx;
color: $color-text-primary;
}
.finance-center__chart-card {
margin-top: 20rpx;
padding: 28rpx 24rpx 32rpx;
background: $color-bg-page;
border-radius: 20rpx;
}
.finance-center__chart-title {
font-size: 30rpx;
font-weight: 600;
color: $color-text-primary;
}
.finance-center__chart-placeholder {
min-height: 360rpx;
margin-top: 24rpx;
background: $color-bg-section;
border-radius: 16rpx;
}

View File

@@ -0,0 +1,122 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import {
FINANCE_CENTER_TOTAL,
FINANCE_MONTH_OPTIONS,
FINANCE_QUICK_ACTIONS,
} from '@/constants/finance-center'
import type { FinanceQuickAction } from '@/types/finance-center'
import { navigateBackWithFallback } from '@/utils/navigate-back'
const monthOptions = FINANCE_MONTH_OPTIONS
const quickActions = FINANCE_QUICK_ACTIONS
const selectedMonthIndex = ref(0)
const selectedMonth = computed(() => monthOptions[selectedMonthIndex.value])
const totalAmountText = computed(() => `¥ ${FINANCE_CENTER_TOTAL}`)
function handleBack() {
navigateBackWithFallback('/pages/personal-center/index')
}
function handleSelectMonth() {
uni.showActionSheet({
itemList: monthOptions,
success: (res) => {
selectedMonthIndex.value = res.tapIndex
},
})
}
function handleQuickAction(item: FinanceQuickAction) {
if (item.path) {
uni.navigateTo({ url: item.path })
return
}
uni.showToast({ title: `${item.label}开发中`, icon: 'none' })
}
</script>
<template>
<view class="finance-center">
<view class="finance-center__navbar">
<view
class="finance-center__back"
@tap="handleBack"
>
<image
class="finance-center__back-icon"
src="/static/icons/icon-back.svg"
mode="aspectFit"
/>
</view>
<text class="finance-center__navbar-title">
财务管理
</text>
<view class="finance-center__navbar-placeholder" />
</view>
<scroll-view
class="finance-center__scroll"
scroll-y
:show-scrollbar="false"
>
<view class="finance-center__summary">
<view class="finance-center__summary-top">
<text class="finance-center__summary-label">
累计消费总额
</text>
<view
class="finance-center__month"
@tap="handleSelectMonth"
>
<text class="finance-center__month-text">
{{ selectedMonth }}
</text>
<text class="finance-center__month-arrow">
▾
</text>
</view>
</view>
<text class="finance-center__amount">
{{ totalAmountText }}
</text>
</view>
<view class="finance-center__quick-card">
<view class="finance-center__quick-grid">
<view
v-for="item in quickActions"
:key="item.key"
class="finance-center__quick-item"
@tap="handleQuickAction(item)"
>
<view class="finance-center__quick-icon-wrap">
<image
class="finance-center__quick-icon"
:src="item.icon"
mode="aspectFit"
/>
</view>
<text class="finance-center__quick-label">
{{ item.label }}
</text>
</view>
</view>
</view>
<view class="finance-center__chart-card">
<text class="finance-center__chart-title">
业务类型分布
</text>
<view class="finance-center__chart-placeholder" />
</view>
</scroll-view>
</view>
</template>
<style scoped lang="scss">
@import './index.scss';
</style>

View File

@@ -12,7 +12,7 @@
align-items: center;
justify-content: space-between;
flex-shrink: 0;
padding: calc(var(--status-bar-height, 44px) + 12rpx) 24rpx 20rpx;
padding: calc(var(--status-bar-height, 44px) + 60rpx) 24rpx 20rpx;
background: $color-bg-page;
}
@@ -46,14 +46,21 @@
}
.bill-detail__content {
padding: 24rpx 24rpx calc(32rpx + env(safe-area-inset-bottom));
padding: 20rpx 24rpx calc(32rpx + env(safe-area-inset-bottom));
}
.bill-detail__hero {
.bill-detail--with-footer .bill-detail__content {
padding-bottom: calc(140rpx + env(safe-area-inset-bottom));
}
.bill-detail__hero-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 24rpx 0 40rpx;
padding: 40rpx 24rpx 36rpx;
margin-bottom: 20rpx;
background: $color-bg-page;
border-radius: 20rpx;
}
.bill-detail__icon {
@@ -68,15 +75,26 @@
color: $color-text-primary;
}
.bill-detail__amount {
margin-top: 16rpx;
.bill-detail__amount-row {
display: flex;
align-items: baseline;
margin-top: 20rpx;
}
.bill-detail__amount-label {
font-size: 26rpx;
color: $color-text-secondary;
}
.bill-detail__amount-value {
font-size: 32rpx;
font-weight: 700;
color: $color-text-primary;
}
.bill-detail__card {
padding: 28rpx 24rpx;
margin-bottom: 24rpx;
margin-bottom: 20rpx;
background: $color-bg-page;
border-radius: 20rpx;
@@ -146,3 +164,22 @@
font-size: 22rpx;
color: $color-text-secondary;
}
.bill-detail__footer {
flex-shrink: 0;
padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));
background: $color-bg-page;
border-top: 1rpx solid #f0f0f0;
}
.bill-detail__pay-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

@@ -2,6 +2,9 @@
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getBill } from '@/constants/bill-list'
import { PAYMENT_PLAN_STATUS_LABEL } from '@/constants/project-detail'
import billPendingIcon from '@/static/icon/bill-pending.png'
import notDueIcon from '@/static/icon/not-due.png'
import successIcon from '@/static/icons/icon-register-success.png'
import type { BillItem } from '@/types/bill'
import { navigateBackWithFallback } from '@/utils/navigate-back'
@@ -11,11 +14,57 @@ type PaymentMethod = 'wechat' | 'alipay'
const bill = ref<BillItem | null>(null)
const paymentMethod = ref<PaymentMethod>('wechat')
const paymentMethodLabel = computed(() =>
paymentMethod.value === 'wechat' ? '微信支付' : '支付宝',
const statusLabel = computed(() => {
if (!bill.value)
return ''
return PAYMENT_PLAN_STATUS_LABEL[bill.value.status] || ''
})
const heroIcon = computed(() => {
if (bill.value?.status === 'paid')
return successIcon
if (bill.value?.status === 'not_due')
return notDueIcon
if (bill.value?.status === 'pending')
return billPendingIcon
return successIcon
})
const paymentMethodLabel = computed(() => {
if (!bill.value)
return ''
if (bill.value.status === 'paid')
return paymentMethod.value === 'wechat' ? '微信支付' : '支付宝'
if (bill.value.status === 'pending' || bill.value.status === 'not_due')
return '微信支付'
return '--'
})
const paymentTime = computed(() => {
if (!bill.value)
return ''
if (bill.value.paidAt)
return bill.value.paidAt
if (bill.value.status === 'pending' || bill.value.status === 'not_due')
return '2026-01-21 15:00:00'
return '--'
})
const orderCardTitle = computed(() => {
if (bill.value?.status === 'paid' || bill.value?.status === 'pending')
return '已支付订单'
if (bill.value?.status === 'not_due')
return '账单订单'
return '账单订单'
})
const showPaymentInfo = computed(() =>
bill.value?.status === 'paid'
|| bill.value?.status === 'not_due'
|| bill.value?.status === 'pending',
)
const paymentTime = computed(() => bill.value?.paidAt || '2026-01-21 15:00:00')
const showPayFooter = computed(() => bill.value?.status === 'pending')
onLoad((options) => {
const projectId = options?.projectId ? decodeURIComponent(String(options.projectId)) : '1'
@@ -37,12 +86,22 @@ function handleBack() {
function formatAmount(amount: number) {
return `¥ ${amount}`
}
function handlePayNow() {
if (!bill.value)
return
uni.navigateTo({
url: `/pages/project-center/payment/index?projectId=${encodeURIComponent(bill.value.projectId)}&planId=${encodeURIComponent(bill.value.id)}`,
})
}
</script>
<template>
<view
v-if="bill"
class="bill-detail"
:class="{ 'bill-detail--with-footer': showPayFooter }"
>
<view class="bill-detail__navbar">
<view
@@ -67,21 +126,31 @@ function formatAmount(amount: number) {
:show-scrollbar="false"
>
<view class="bill-detail__content">
<view class="bill-detail__hero">
<view class="bill-detail__hero-card">
<image
class="bill-detail__icon"
:src="successIcon"
:src="heroIcon"
mode="aspectFit"
/>
<text class="bill-detail__status">
已支付
</text>
<text class="bill-detail__amount">
付款金额:{{ formatAmount(bill.amount) }}
{{ statusLabel }}
</text>
<view class="bill-detail__amount-row">
<text class="bill-detail__amount-label">
付款金额:
</text>
<text class="bill-detail__amount-value">
{{ formatAmount(bill.amount) }}
</text>
</view>
</view>
<view class="bill-detail__card">
<view
v-if="showPaymentInfo"
class="bill-detail__card"
>
<text class="bill-detail__card-title">
支付信息
</text>
@@ -113,7 +182,7 @@ function formatAmount(amount: number) {
<view class="bill-detail__card">
<text class="bill-detail__card-title">
已支付订单
{{ orderCardTitle }}
</text>
<view class="bill-detail__order-row">
<text class="bill-detail__order-title">
@@ -132,6 +201,18 @@ function formatAmount(amount: number) {
</view>
</view>
</scroll-view>
<view
v-if="showPayFooter"
class="bill-detail__footer"
>
<view
class="bill-detail__pay-btn"
@tap="handlePayNow"
>
立即支付
</view>
</view>
</view>
</template>

View File

@@ -132,16 +132,19 @@
&--paid {
color: #ffffff;
background: $color-primary;
border-radius: 40rpx;
}
&--pending {
color: #ffffff;
background: #ff9a3d;
border-radius: 40rpx;
}
&--not_due {
color: $color-primary;
background: $color-primary-light;
color: #ffffff;
background: #25eb9f;
border-radius: 40rpx;
}
}

View File

@@ -35,16 +35,12 @@ function formatAmount(amount: number) {
}
function handleBillTap(item: BillItem) {
if (item.status === 'pending') {
if (item.status === 'pending' || item.status === 'paid' || item.status === 'not_due') {
const methodQuery = item.status === 'paid'
? `&method=${encodeURIComponent(item.paidMethod || 'wechat')}`
: ''
uni.navigateTo({
url: `/pages/project-center/payment/index?projectId=${encodeURIComponent(item.projectId)}&planId=${encodeURIComponent(item.id)}`,
})
return
}
if (item.status === 'paid') {
uni.navigateTo({
url: `/pages/project-center/bill-detail/index?projectId=${encodeURIComponent(item.projectId)}&planId=${encodeURIComponent(item.id)}&method=${encodeURIComponent(item.paidMethod || 'wechat')}`,
url: `/pages/project-center/bill-detail/index?projectId=${encodeURIComponent(item.projectId)}&planId=${encodeURIComponent(item.id)}${methodQuery}`,
})
}
}