init pages

This commit is contained in:
halo 2026-05-03 19:36:05 +08:00
parent 0477c632b4
commit f633d59fd2
30 changed files with 7147 additions and 5617 deletions

443
package-lock.json generated
View File

@ -2733,6 +2733,70 @@
}
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz",
"integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz",
"integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz",
"integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz",
"integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.20.2",
"cpu": [
@ -2747,6 +2811,294 @@
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz",
"integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz",
"integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz",
"integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz",
"integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz",
"integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz",
"integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz",
"integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==",
"cpu": [
"loong64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz",
"integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==",
"cpu": [
"mips64el"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz",
"integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz",
"integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz",
"integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==",
"cpu": [
"s390x"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz",
"integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz",
"integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz",
"integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz",
"integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz",
"integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz",
"integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz",
"integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@intlify/core-base": {
"version": "9.1.9",
"license": "MIT",
@ -7284,15 +7636,6 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/growly": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/growly/-/growly-1.3.0.tgz",
"integrity": "sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz",
@ -7599,24 +7942,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-docker": {
"version": "2.2.1",
"resolved": "https://registry.npmmirror.com/is-docker/-/is-docker-2.2.1.tgz",
"integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"bin": {
"is-docker": "cli.js"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz",
@ -7704,21 +8029,6 @@
"license": "MIT",
"peer": true
},
"node_modules/is-wsl": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/is-wsl/-/is-wsl-2.2.0.tgz",
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"is-docker": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/isbinaryfile": {
"version": "5.0.4",
"resolved": "https://registry.npmmirror.com/isbinaryfile/-/isbinaryfile-5.0.4.tgz",
@ -9151,36 +9461,6 @@
"license": "MIT",
"peer": true
},
"node_modules/node-notifier": {
"version": "9.0.1",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"growly": "^1.3.0",
"is-wsl": "^2.2.0",
"semver": "^7.3.2",
"shellwords": "^0.1.1",
"uuid": "^8.3.0",
"which": "^2.0.2"
}
},
"node_modules/node-notifier/node_modules/semver": {
"version": "7.7.1",
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.1.tgz",
"integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
"dev": true,
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-releases": {
"version": "2.0.19",
"resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.19.tgz",
@ -10599,15 +10879,6 @@
"node": ">=8"
}
},
"node_modules/shellwords": {
"version": "0.1.1",
"resolved": "https://registry.npmmirror.com/shellwords/-/shellwords-0.1.1.tgz",
"integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz",
@ -11545,16 +11816,6 @@
"node": ">= 0.4.0"
}
},
"node_modules/uuid": {
"version": "8.3.2",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/v8-to-istanbul": {
"version": "8.1.1",
"dev": true,

View File

@ -0,0 +1,78 @@
<template>
<view class="app-notice-board" v-if="visible">
<view class="app-notice-icon">
<tui-icon name="news-fill" :size="24" color="#f54f46"></tui-icon>
</view>
<view class="app-notice-scroll">
<view class="app-notice-text" :class="{ 'app-notice-animation': animation }">{{ text }}</view>
</view>
</view>
</template>
<script setup lang="ts">
import TuiIcon from "@/components/thorui/tui-icon/tui-icon.vue";
defineProps({
text: {
type: String,
required: true,
},
visible: {
type: Boolean,
default: true,
},
animation: {
type: Boolean,
default: false,
},
});
</script>
<style scoped>
.app-notice-board {
width: 100%;
padding-right: 30rpx;
box-sizing: border-box;
font-size: 28rpx;
height: 60rpx;
background: #fff8d5;
display: flex;
align-items: center;
position: relative;
z-index: 10;
}
.app-notice-icon {
background: #fff8d5;
padding-left: 30rpx;
position: relative;
z-index: 10;
}
.app-notice-scroll {
flex: 1;
line-height: 1;
white-space: nowrap;
overflow: hidden;
color: #f54f46;
}
.app-notice-text {
-webkit-backface-visibility: hidden;
transform: translate3d(100%, 0, 0);
}
.app-notice-animation {
-webkit-animation: app-notice-rolling 12s linear infinite;
animation: app-notice-rolling 12s linear infinite;
}
@keyframes app-notice-rolling {
0% {
transform: translate3d(100%, 0, 0);
}
100% {
transform: translate3d(-170%, 0, 0);
}
}
</style>

View File

@ -0,0 +1,78 @@
<template>
<view class="state-card" :class="type">
<text class="state-text">{{ text }}</text>
<button v-if="retryText" class="retry-button" @tap="$emit('retry')">{{ retryText }}</button>
</view>
</template>
<script setup lang="ts">
defineEmits(["retry"]);
defineProps({
text: {
type: String,
required: true,
},
type: {
type: String,
default: "loading",
},
retryText: {
type: String,
default: "",
},
});
</script>
<style scoped>
.state-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: white;
margin: 15px 0;
padding: 30px;
border-radius: 14px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04);
}
.loading::before {
content: "";
width: 24px;
height: 24px;
border-radius: 50%;
border: 3px solid #f3f3f3;
border-top: 3px solid #4f7cfe;
animation: spin 1s linear infinite;
display: block;
margin-bottom: 12px;
}
.state-text {
color: #999;
font-size: 14px;
}
.retry-button {
margin-top: 12px;
font-size: 14px;
color: #4f7cfe;
background: #f1f5fe;
border-radius: 20px;
padding: 6px 18px;
}
.retry-button::after {
content: none;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View File

@ -0,0 +1,36 @@
<template>
<view class="usage-description">
<text class="usage-text" v-for="(tip, index) in tips" :key="tip">{{ index + 1 }}. {{ tip }}</text>
</view>
</template>
<script setup lang="ts">
defineProps({
tips: {
type: Array as () => string[],
default: () => [],
},
});
</script>
<style scoped>
.usage-description {
background-color: #fff;
border-radius: 20rpx;
padding: 24rpx 30rpx;
margin-top: 30rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.04);
}
.usage-text {
display: block;
font-size: 26rpx;
color: #777;
line-height: 1.65;
margin-bottom: 10rpx;
}
.usage-text:last-child {
margin-bottom: 0;
}
</style>

View File

@ -0,0 +1,194 @@
<template>
<view class="function-card">
<view class="function-left">
<view class="icon-wrapper" :class="iconClass">
<image class="function-icon" :src="iconPath" mode="aspectFit"></image>
</view>
<view class="function-text">
<view class="title-row">
<text class="function-title">{{ title }}</text>
<text class="feature-tag" v-if="tag">{{ tag }}</text>
</view>
<text class="function-description">{{ description }}</text>
</view>
</view>
<view class="function-right">
<button class="function-button" :class="buttonClass" hover-class="button-hover" @tap="$emit('action')">
<view class="button-content">
<text>{{ buttonText }}</text>
<text class="arrow"></text>
</view>
</button>
</view>
</view>
</template>
<script setup lang="ts">
defineEmits(["action"]);
defineProps({
title: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
iconPath: {
type: String,
required: true,
},
buttonText: {
type: String,
required: true,
},
iconClass: {
type: String,
default: "",
},
buttonClass: {
type: String,
default: "",
},
tag: {
type: String,
default: "",
},
});
</script>
<style scoped>
.function-card {
display: flex;
justify-content: space-between;
align-items: center;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.92), rgba(255, 250, 253, 0.86)),
linear-gradient(90deg, rgba(255, 204, 220, 0.2), rgba(207, 237, 255, 0.16));
margin: 0 0 20rpx;
padding: 28rpx 26rpx;
border-radius: 24rpx;
box-shadow: 0 12rpx 32rpx rgba(83, 96, 130, 0.08);
border: 1rpx solid rgba(255, 255, 255, 0.86);
backdrop-filter: blur(12rpx);
}
.function-card:active {
transform: translateY(2px);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.03);
}
.function-left {
display: flex;
align-items: center;
min-width: 0;
flex: 1;
}
.function-icon {
width: 48rpx;
height: 48rpx;
border-radius: 18rpx;
margin-right: 22rpx;
background: linear-gradient(145deg, #fff, #f2f7ff);
padding: 14rpx;
box-shadow: 0 10rpx 22rpx rgba(112, 126, 157, 0.1);
}
.function-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.title-row {
display: flex;
align-items: center;
min-width: 0;
margin-bottom: 8rpx;
}
.function-title {
font-size: 31rpx;
font-weight: 600;
color: #1f2937;
display: block;
line-height: 1.3;
}
.feature-tag {
margin-left: 12rpx;
padding: 5rpx 12rpx;
border-radius: 999rpx;
background: rgba(255, 232, 240, 0.86);
color: #d94f75;
font-size: 20rpx;
line-height: 1.2;
flex-shrink: 0;
border: 1rpx solid rgba(255, 255, 255, 0.72);
}
.function-description {
font-size: 24rpx;
color: #7b8493;
display: block;
line-height: 1.45;
}
.function-button {
background: transparent;
padding: 0;
margin: 0;
line-height: 1;
border: none;
outline: none;
display: flex;
justify-content: center;
align-items: center;
flex-shrink: 0;
}
.function-button::after {
content: none;
}
.button-content {
background-color: #f1f5fe;
color: #4f7cfe;
padding: 13rpx 20rpx;
border-radius: 999rpx;
font-size: 24rpx;
display: flex;
align-items: center;
line-height: 1.2;
}
.arrow {
margin-left: 6rpx;
font-size: 34rpx;
font-weight: 300;
line-height: 1;
}
.color-button .button-content {
background: linear-gradient(135deg, #ff6f96 0%, #77b9ff 100%);
color: white;
box-shadow: 0 10rpx 20rpx rgba(255, 111, 150, 0.18);
}
.green-button .button-content {
background: rgba(236, 249, 244, 0.9);
color: #179b70;
}
.purple-button .button-content {
background: rgba(244, 240, 255, 0.9);
color: #7861d4;
}
.red-button .button-content {
background: rgba(255, 241, 245, 0.92);
color: #d94f75;
}
</style>

View File

@ -0,0 +1,240 @@
<template>
<view class="hero">
<view class="hero-copy">
<text class="hero-eyebrow">{{ eyebrow }}</text>
<text class="hero-title">{{ title }}</text>
<text class="hero-subtitle">{{ subtitle }}</text>
<view class="hero-tags">
<text class="hero-tag" v-for="item in highlights" :key="item">{{ item }}</text>
</view>
</view>
<view class="hero-visual">
<image class="hero-image" :src="image" mode="aspectFit"></image>
</view>
<view class="hero-actions">
<button class="primary-button" @tap="$emit('action')">{{ actionText }}</button>
<button class="secondary-button" @tap="$emit('secondary')">{{ secondaryText }}</button>
</view>
<view class="sparkle sparkle-one"></view>
<view class="sparkle sparkle-two"></view>
<view class="sparkle sparkle-three"></view>
</view>
</template>
<script setup lang="ts">
defineEmits(["action", "secondary"]);
defineProps({
eyebrow: {
type: String,
required: true,
},
title: {
type: String,
required: true,
},
subtitle: {
type: String,
required: true,
},
highlights: {
type: Array as () => string[],
required: true,
},
image: {
type: String,
required: true,
},
actionText: {
type: String,
required: true,
},
secondaryText: {
type: String,
required: true,
},
});
</script>
<style scoped>
.hero {
position: relative;
overflow: hidden;
border-radius: 34rpx;
padding: 38rpx 32rpx 30rpx;
background:
linear-gradient(140deg, rgba(255, 247, 252, 0.96) 0%, rgba(255, 255, 255, 0.9) 38%, rgba(232, 247, 255, 0.92) 100%),
linear-gradient(45deg, rgba(255, 180, 206, 0.36), rgba(153, 217, 255, 0.2));
border: 1rpx solid rgba(255, 255, 255, 0.82);
box-shadow: 0 22rpx 52rpx rgba(89, 101, 132, 0.12);
}
.hero::before {
content: "";
position: absolute;
left: 28rpx;
right: 28rpx;
top: 24rpx;
height: 1rpx;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.96), transparent);
}
.hero::after {
content: "";
position: absolute;
right: -80rpx;
bottom: -120rpx;
width: 360rpx;
height: 320rpx;
background: linear-gradient(150deg, rgba(255, 216, 228, 0.46), rgba(198, 235, 255, 0.28));
transform: rotate(-16deg);
border-radius: 42% 58% 48% 52%;
}
.hero-copy {
position: relative;
z-index: 2;
width: 64%;
}
.hero-eyebrow {
display: block;
color: #d94f75;
font-size: 24rpx;
font-weight: 600;
line-height: 1.3;
}
.hero-title {
display: block;
margin-top: 12rpx;
color: #20273a;
font-size: 46rpx;
font-weight: 700;
line-height: 1.18;
}
.hero-subtitle {
display: block;
margin-top: 18rpx;
color: #626b7b;
font-size: 27rpx;
line-height: 1.55;
}
.hero-tags {
display: flex;
flex-wrap: wrap;
margin-top: 22rpx;
}
.hero-tag {
margin-right: 12rpx;
margin-bottom: 12rpx;
padding: 9rpx 16rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.72);
color: #6c7280;
font-size: 22rpx;
line-height: 1.2;
border: 1rpx solid rgba(255, 255, 255, 0.92);
box-shadow: 0 8rpx 20rpx rgba(124, 143, 171, 0.08);
}
.hero-visual {
position: absolute;
right: 10rpx;
top: 34rpx;
width: 238rpx;
height: 226rpx;
z-index: 1;
}
.hero-image {
width: 100%;
height: 100%;
filter: drop-shadow(0 16rpx 26rpx rgba(230, 95, 131, 0.12));
}
.hero-actions {
position: relative;
z-index: 2;
display: flex;
align-items: center;
margin-top: 36rpx;
}
.primary-button,
.secondary-button {
height: 80rpx;
line-height: 80rpx;
border-radius: 999rpx;
font-size: 28rpx;
margin: 0;
}
.primary-button::after,
.secondary-button::after {
content: none;
}
.primary-button {
flex: 1;
color: #fff;
background: linear-gradient(135deg, #ff6f96 0%, #ff9a7a 100%);
box-shadow: 0 14rpx 28rpx rgba(255, 111, 150, 0.28);
}
.secondary-button {
width: 190rpx;
margin-left: 18rpx;
color: #d94f75;
background: rgba(255, 255, 255, 0.76);
border: 1rpx solid rgba(255, 255, 255, 0.94);
box-shadow: 0 10rpx 24rpx rgba(124, 143, 171, 0.08);
}
.sparkle {
position: absolute;
z-index: 1;
width: 10rpx;
height: 10rpx;
background: #fff;
transform: rotate(45deg);
box-shadow: 0 0 18rpx rgba(255, 255, 255, 0.9);
}
.sparkle-one {
top: 34rpx;
left: 58%;
}
.sparkle-two {
top: 164rpx;
right: 42rpx;
width: 8rpx;
height: 8rpx;
}
.sparkle-three {
bottom: 108rpx;
left: 40rpx;
width: 7rpx;
height: 7rpx;
}
@media screen and (max-width: 360px) {
.hero-copy {
width: 68%;
}
.hero-visual {
width: 190rpx;
height: 190rpx;
}
}
</style>

View File

@ -0,0 +1,232 @@
<template>
<view
class="mail-card"
:class="[statusMeta.className, { 'new-card': mail.isNew }]"
hover-class="card-hover"
@tap="$emit('detail', mail)"
>
<view class="card-glow"></view>
<view class="card-header tui-skeleton-rect">
<view class="status-badge" :class="statusMeta.className">
<text class="status-dot"></text>
<text>{{ statusMeta.text }}</text>
</view>
<text class="order-number">#{{ mail.orderNumber }}</text>
</view>
<view class="card-content">
<view class="recipient-row">
<text class="label-text">联系人</text>
<text class="recipient-value tui-skeleton-rect">{{ maskedRecipient }}</text>
</view>
<view class="message-bubble tui-skeleton-rect">
<text class="content-value">{{ mail.content }}</text>
</view>
<view class="time-row tui-skeleton-rect">
<text class="time-label">{{ mail.status === sentStatus ? "发送" : "提交" }}</text>
<text class="time-value">{{ displayTime }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { MailStatus, MAIL_STATUS_META, formatMailTime, maskRecipient, type Mail } from "@/config/mail";
const props = defineProps({
mail: {
type: Object as () => Mail,
required: true,
},
});
defineEmits(["detail"]);
const sentStatus = MailStatus.SENT;
const statusMeta = computed(() => MAIL_STATUS_META[props.mail.status]);
const maskedRecipient = computed(() => maskRecipient(props.mail.recipient));
const displayTime = computed(() => {
const time = props.mail.status === MailStatus.SENT ? props.mail.sendTime : props.mail.submitTime;
return formatMailTime(time);
});
</script>
<style scoped lang="scss">
.mail-card {
position: relative;
overflow: hidden;
margin-bottom: 22rpx;
padding: 22rpx;
border-radius: 26rpx;
border: 1rpx solid rgba(255, 255, 255, 0.88);
background:
linear-gradient(145deg, rgba(255, 255, 255, 0.92) 0%, rgba(255, 248, 252, 0.84) 54%, rgba(241, 249, 255, 0.88) 100%),
rgba(255, 255, 255, 0.8);
box-shadow: 0 18rpx 42rpx rgba(93, 107, 139, 0.09);
transition: transform 0.2s ease, box-shadow 0.2s ease;
&.card-hover {
transform: scale(0.98);
box-shadow: 0 12rpx 26rpx rgba(93, 107, 139, 0.06);
}
&.new-card {
animation: fadeSlideIn 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
}
.card-glow {
position: absolute;
top: -88rpx;
right: -70rpx;
width: 210rpx;
height: 210rpx;
border-radius: 50%;
background: rgba(255, 192, 216, 0.26);
pointer-events: none;
}
@keyframes fadeSlideIn {
0% {
opacity: 0;
transform: translateY(30rpx);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.card-header {
position: relative;
z-index: 1;
display: flex;
justify-content: space-between;
align-items: center;
gap: 18rpx;
}
.status-badge {
display: flex;
align-items: center;
height: 46rpx;
padding: 0 18rpx;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 700;
color: #fff;
&.status-pending {
background: linear-gradient(135deg, #ffb75e 0%, #f59e0b 100%);
box-shadow: 0 8rpx 18rpx rgba(245, 158, 11, 0.18);
}
&.status-sent {
background: linear-gradient(135deg, #7ee0a3 0%, #34b878 100%);
box-shadow: 0 8rpx 18rpx rgba(52, 184, 120, 0.18);
}
&.status-failed {
background: linear-gradient(135deg, #ff8daf 0%, #e85b7f 100%);
box-shadow: 0 8rpx 18rpx rgba(232, 91, 127, 0.18);
}
}
.status-dot {
width: 10rpx;
height: 10rpx;
border-radius: 50%;
margin-right: 10rpx;
background: rgba(255, 255, 255, 0.9);
}
.order-number {
flex: 1;
color: #9aa3b2;
font-size: 22rpx;
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-content {
position: relative;
z-index: 1;
padding-top: 22rpx;
display: flex;
flex-direction: column;
}
.recipient-row {
display: flex;
align-items: center;
margin-bottom: 16rpx;
}
.label-text {
flex-shrink: 0;
height: 40rpx;
line-height: 40rpx;
padding: 0 14rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.72);
color: #8c96a8;
font-size: 21rpx;
margin-right: 14rpx;
border: 1rpx solid rgba(232, 238, 248, 0.9);
}
.recipient-value {
flex: 1;
color: #263044;
font-size: 30rpx;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.message-bubble {
padding: 20rpx 22rpx;
border-radius: 22rpx;
background: rgba(255, 255, 255, 0.62);
border: 1rpx solid rgba(242, 231, 239, 0.9);
}
.content-value {
display: block;
color: #586174;
font-size: 27rpx;
line-height: 1.65;
max-height: 90rpx;
overflow: hidden;
}
.time-row {
display: flex;
align-items: center;
justify-content: flex-end;
margin-top: 18rpx;
}
.time-label {
color: #a0a9b8;
font-size: 22rpx;
margin-right: 6rpx;
flex-shrink: 0;
}
.time-value {
color: #737d90;
font-size: 22rpx;
}
</style>

View File

@ -0,0 +1,159 @@
<template>
<view v-if="loading" class="loading-state">
<view class="loading-dot"></view>
<view class="loading-dot"></view>
<view class="loading-dot"></view>
</view>
<view v-else-if="empty" class="empty-state">
<view class="empty-card">
<image class="empty-image tui-skeleton-rect" :src="emptyImage" mode="aspectFit"></image>
<text class="empty-title tui-skeleton-rect">还没有消息</text>
<text class="empty-text tui-skeleton-rect">{{ emptyText }}</text>
</view>
</view>
<view v-else-if="noMore" class="no-more">
<view class="no-more-line"></view>
<text class="no-more-text">{{ noMoreText }}</text>
<view class="no-more-line"></view>
</view>
</template>
<script setup lang="ts">
defineProps({
loading: {
type: Boolean,
default: false,
},
empty: {
type: Boolean,
default: false,
},
noMore: {
type: Boolean,
default: false,
},
emptyImage: {
type: String,
required: true,
},
emptyText: {
type: String,
default: "暂无短信",
},
noMoreText: {
type: String,
default: "已经到底了",
},
});
</script>
<style scoped lang="scss">
.loading-state {
display: flex;
justify-content: center;
align-items: center;
padding: 42rpx 0;
height: 42rpx;
}
.loading-dot {
width: 14rpx;
height: 14rpx;
border-radius: 50%;
background: linear-gradient(135deg, #ff92b6, #8bcfff);
margin: 0 7rpx;
animation: loadingDot 1.4s infinite ease-in-out both;
&:nth-child(1) {
animation-delay: -0.32s;
}
&:nth-child(2) {
animation-delay: -0.16s;
}
}
@keyframes loadingDot {
0%,
80%,
100% {
transform: scale(0);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 70rpx 24rpx;
min-height: 58vh;
}
.empty-card {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
box-sizing: border-box;
padding: 58rpx 34rpx 62rpx;
border-radius: 28rpx;
background:
linear-gradient(145deg, rgba(255, 255, 255, 0.86), rgba(247, 252, 255, 0.74)),
rgba(255, 255, 255, 0.72);
border: 1rpx solid rgba(255, 255, 255, 0.9);
box-shadow: 0 18rpx 42rpx rgba(93, 107, 139, 0.08);
}
.empty-image {
width: 330rpx;
height: 230rpx;
margin-bottom: 24rpx;
opacity: 0.86;
}
.empty-title {
color: #263044;
font-size: 32rpx;
font-weight: 700;
line-height: 1.4;
}
.empty-text {
margin-top: 8rpx;
font-size: 24rpx;
color: #8c96a8;
text-align: center;
line-height: 1.5;
}
.no-more {
display: flex;
align-items: center;
justify-content: center;
padding: 24rpx 0 34rpx;
}
.no-more-line {
height: 1rpx;
width: 120rpx;
background: linear-gradient(90deg, transparent, rgba(153, 170, 190, 0.35));
&:last-child {
background: linear-gradient(90deg, rgba(153, 170, 190, 0.35), transparent);
}
}
.no-more-text {
color: #9aa3b2;
font-size: 22rpx;
margin: 0 18rpx;
}
</style>

View File

@ -0,0 +1,187 @@
<template>
<view class="plan-card" hover-class="card-hover">
<view class="card-glow"></view>
<view class="card-top">
<view class="cycle-pill">
<text>{{ plan.cycleName }}</text>
</view>
<view class="status-dot" :class="statusMeta.className"></view>
</view>
<view class="account-row">
<text class="account-label">{{ plan.socialTypeName }}</text>
<text class="account-value">{{ maskedAccount }}</text>
</view>
<view class="content-box">
<text class="content-text">{{ plan.content }}</text>
</view>
<view class="meta-row">
<text class="meta-label">下次传送</text>
<text class="meta-value">{{ nextRunTime }}</text>
</view>
</view>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { formatMailTime } from "@/config/mail";
import { PLAN_STATUS_META, maskSocialAccount, type PlanItem } from "@/config/planning";
const props = defineProps({
plan: {
type: Object as () => PlanItem,
required: true,
},
});
const statusMeta = computed(() => PLAN_STATUS_META[props.plan.status]);
const maskedAccount = computed(() => maskSocialAccount(props.plan.socialAccount));
const nextRunTime = computed(() => formatMailTime(props.plan.nextRunTime));
</script>
<style scoped lang="scss">
.plan-card {
position: relative;
overflow: hidden;
margin-bottom: 16rpx;
padding: 18rpx 20rpx;
border-radius: 22rpx;
border: 1rpx solid rgba(255, 255, 255, 0.72);
background:
linear-gradient(145deg, rgba(255, 255, 255, 0.76), rgba(247, 252, 255, 0.54)),
rgba(255, 255, 255, 0.52);
box-shadow: 0 12rpx 28rpx rgba(93, 107, 139, 0.06);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.card-hover {
transform: scale(0.98);
box-shadow: 0 12rpx 26rpx rgba(93, 107, 139, 0.06);
}
.card-glow {
position: absolute;
top: -72rpx;
right: -66rpx;
width: 170rpx;
height: 170rpx;
border-radius: 50%;
background: rgba(255, 192, 216, 0.18);
pointer-events: none;
}
.card-top,
.account-row,
.content-box,
.meta-row {
position: relative;
z-index: 1;
}
.card-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
}
.cycle-pill {
display: flex;
align-items: center;
height: 40rpx;
padding: 0 16rpx;
border-radius: 999rpx;
font-size: 21rpx;
font-weight: 700;
}
.cycle-pill {
color: #d94f75;
background: rgba(255, 232, 241, 0.86);
}
.status-running {
background: #35b779;
}
.status-done {
background: #9aa3b2;
}
.status-paused {
background: #f59e0b;
}
.status-dot {
width: 18rpx;
height: 18rpx;
border-radius: 50%;
box-shadow: 0 0 0 8rpx rgba(154, 163, 178, 0.12);
}
.account-row {
display: flex;
align-items: center;
margin-top: 16rpx;
}
.account-label {
height: 36rpx;
line-height: 36rpx;
padding: 0 12rpx;
border-radius: 999rpx;
color: #8c96a8;
font-size: 20rpx;
background: rgba(255, 255, 255, 0.72);
border: 1rpx solid rgba(232, 238, 248, 0.9);
}
.account-value {
margin-left: 12rpx;
color: #263044;
font-size: 27rpx;
font-weight: 700;
}
.content-box {
margin-top: 14rpx;
padding: 0;
border-radius: 0;
background: transparent;
border: 0;
}
.content-text {
display: block;
color: #586174;
font-size: 25rpx;
line-height: 1.5;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.meta-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
margin-top: 14rpx;
}
.meta-label {
flex-shrink: 0;
color: #a0a9b8;
font-size: 21rpx;
}
.meta-value {
color: #737d90;
font-size: 21rpx;
text-align: right;
}
</style>

View File

@ -0,0 +1,107 @@
<template>
<view class="header">
<view class="profile-info">
<view class="profile-pic-container">
<image :src="avatar" mode="aspectFill" class="profile-pic"></image>
</view>
<view class="user-details">
<text class="username">{{ username }}</text>
<view class="id-copy-wrapper">
<text class="user-id">ID: {{ userId }}</text>
<text class="copy-id" @tap="$emit('copy-id')">复制ID</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
defineEmits(["copy-id"]);
defineProps({
avatar: {
type: String,
required: true,
},
username: {
type: String,
required: true,
},
userId: {
type: String,
required: true,
},
});
</script>
<style scoped>
.header {
width: 100%;
padding: 30rpx;
background:
linear-gradient(145deg, rgba(255, 255, 255, 0.86), rgba(247, 252, 255, 0.66)),
rgba(255, 255, 255, 0.72);
margin-bottom: 22rpx;
box-shadow: 0 18rpx 42rpx rgba(93, 107, 139, 0.08);
flex-shrink: 0;
border-radius: 28rpx;
border: 1rpx solid rgba(255, 255, 255, 0.9);
box-sizing: border-box;
}
.profile-info {
display: flex;
align-items: center;
width: 100%;
}
.profile-pic-container {
width: 132rpx;
height: 132rpx;
border-radius: 50%;
padding: 6rpx;
background: linear-gradient(135deg, rgba(255, 120, 162, 0.18), rgba(131, 198, 255, 0.18));
box-shadow: 0 12rpx 30rpx rgba(93, 107, 139, 0.1);
margin-right: 24rpx;
flex-shrink: 0;
}
.profile-pic {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
border: 4rpx solid #fff;
}
.user-details {
display: flex;
flex-direction: column;
}
.username {
font-size: 36rpx;
color: #20273a;
font-weight: 700;
margin-bottom: 10rpx;
}
.id-copy-wrapper {
display: flex;
align-items: center;
}
.user-id {
font-size: 24rpx;
color: #7a8496;
}
.copy-id {
color: #d94f75;
font-size: 23rpx;
margin-left: 12rpx;
padding: 5rpx 12rpx;
background: rgba(255, 232, 241, 0.72);
border-radius: 999rpx;
}
</style>

View File

@ -0,0 +1,82 @@
<template>
<view class="menu-section">
<view
class="menu-item"
hover-class="menu-item-hover"
v-for="item in items"
:key="item.label"
@tap="$emit('select', item)"
>
<text class="item-text" :class="{ highlight: item.highlight }">{{ item.label }}</text>
<text class="arrow"></text>
</view>
</view>
</template>
<script setup lang="ts">
export interface ProfileMenuItem {
label: string;
route?: string;
action?: string;
highlight?: boolean;
}
defineEmits(["select"]);
defineProps({
items: {
type: Array as () => ProfileMenuItem[],
required: true,
},
});
</script>
<style scoped>
.menu-section {
width: 100%;
margin-bottom: 22rpx;
background:
linear-gradient(145deg, rgba(255, 255, 255, 0.84), rgba(247, 252, 255, 0.62)),
rgba(255, 255, 255, 0.7);
overflow: hidden;
flex-shrink: 0;
border-radius: 26rpx;
border: 1rpx solid rgba(255, 255, 255, 0.9);
box-shadow: 0 16rpx 36rpx rgba(93, 107, 139, 0.07);
}
.menu-item {
display: flex;
justify-content: space-between;
align-items: center;
min-height: 92rpx;
padding: 0 26rpx;
border-bottom: 1rpx solid rgba(230, 236, 246, 0.72);
transition: background-color 0.2s;
}
.menu-item:last-child {
border-bottom: none;
}
.menu-item-hover {
background-color: rgba(255, 255, 255, 0.54);
}
.item-text {
font-size: 28rpx;
color: #263044;
font-weight: 600;
}
.highlight {
color: #d94f75;
font-weight: 700;
}
.arrow {
color: #a7b0bf;
font-size: 42rpx;
font-weight: 300;
}
</style>

View File

@ -0,0 +1,203 @@
<template>
<view class="reply-card" :class="{ unread: reply.unread }" hover-class="card-hover" @tap="$emit('open', reply)">
<view class="card-glow"></view>
<view class="card-top">
<view class="source-pill" :class="sourceMeta.className">
<text class="source-dot"></text>
<text>{{ sourceMeta.text }}</text>
</view>
<text class="reply-time">{{ displayTime }}</text>
</view>
<view class="contact-row">
<text class="contact-label">来自</text>
<text class="contact-phone">{{ maskedPhone }}</text>
<text v-if="reply.unread" class="unread-dot"></text>
</view>
<view class="reply-bubble">
<text class="reply-content">{{ reply.replyMessage }}</text>
</view>
<view class="card-footer">
<text class="original-label">原短信</text>
<text class="original-text">{{ reply.originalMessage }}</text>
</view>
</view>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { formatMailTime, maskRecipient } from "@/config/mail";
import { REPLY_SOURCE_META, type ReplyItem } from "@/config/reply";
const props = defineProps({
reply: {
type: Object as () => ReplyItem,
required: true,
},
});
defineEmits(["open"]);
const sourceMeta = computed(() => REPLY_SOURCE_META[props.reply.source]);
const maskedPhone = computed(() => maskRecipient(props.reply.contactPhone));
const displayTime = computed(() => formatMailTime(props.reply.replyTime));
</script>
<style scoped lang="scss">
.reply-card {
position: relative;
overflow: hidden;
margin-bottom: 22rpx;
padding: 24rpx;
border-radius: 28rpx;
border: 1rpx solid rgba(255, 255, 255, 0.88);
background:
linear-gradient(145deg, rgba(255, 255, 255, 0.92) 0%, rgba(255, 248, 252, 0.84) 54%, rgba(241, 249, 255, 0.88) 100%),
rgba(255, 255, 255, 0.8);
box-shadow: 0 18rpx 42rpx rgba(93, 107, 139, 0.09);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.card-hover {
transform: scale(0.98);
box-shadow: 0 12rpx 26rpx rgba(93, 107, 139, 0.06);
}
.card-glow {
position: absolute;
top: -82rpx;
right: -72rpx;
width: 210rpx;
height: 210rpx;
border-radius: 50%;
background: rgba(255, 192, 216, 0.25);
pointer-events: none;
}
.card-top,
.contact-row,
.reply-bubble,
.card-footer {
position: relative;
z-index: 1;
}
.card-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
}
.source-pill {
display: flex;
align-items: center;
height: 46rpx;
padding: 0 18rpx;
border-radius: 999rpx;
color: #fff;
font-size: 22rpx;
font-weight: 700;
}
.source-sms {
background: linear-gradient(135deg, #ff78a2 0%, #ed6f9b 100%);
box-shadow: 0 8rpx 18rpx rgba(237, 111, 155, 0.18);
}
.source-manual {
background: linear-gradient(135deg, #8bcfff 0%, #6caef5 100%);
box-shadow: 0 8rpx 18rpx rgba(108, 174, 245, 0.18);
}
.source-dot {
width: 10rpx;
height: 10rpx;
margin-right: 10rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
}
.reply-time {
flex: 1;
color: #9aa3b2;
font-size: 22rpx;
text-align: right;
}
.contact-row {
display: flex;
align-items: center;
margin-top: 20rpx;
}
.contact-label {
height: 40rpx;
line-height: 40rpx;
padding: 0 14rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.72);
color: #8c96a8;
font-size: 21rpx;
border: 1rpx solid rgba(232, 238, 248, 0.9);
}
.contact-phone {
margin-left: 14rpx;
color: #263044;
font-size: 30rpx;
font-weight: 700;
}
.unread-dot {
width: 14rpx;
height: 14rpx;
margin-left: 12rpx;
border-radius: 50%;
background: #ff5d8d;
box-shadow: 0 0 0 8rpx rgba(255, 93, 141, 0.12);
}
.reply-bubble {
margin-top: 18rpx;
padding: 22rpx 24rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.66);
border: 1rpx solid rgba(242, 231, 239, 0.9);
}
.reply-content {
display: block;
color: #333b4f;
font-size: 29rpx;
line-height: 1.62;
max-height: 94rpx;
overflow: hidden;
}
.card-footer {
display: flex;
align-items: center;
gap: 12rpx;
margin-top: 18rpx;
}
.original-label {
flex-shrink: 0;
color: #a0a9b8;
font-size: 22rpx;
}
.original-text {
flex: 1;
color: #7a8496;
font-size: 23rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

58
src/config/form.ts Normal file
View File

@ -0,0 +1,58 @@
export const APP_NOTICE = {
text: "B站10分日本动漫已消失9.9分仅剩12部这一部动漫包揽三席",
};
export const MESSAGE_USAGE_TIPS = [
"填写对方手机号码,系统将向该号码发送短信",
"可选择未来的发送时间,不选则立即发送",
"使用AI文案美化功能可自动生成不同类型的短信内容",
"短信按20字一条计费发送前可预览效果",
];
export const SOCIAL_TYPE_OPTIONS = [
{ text: "手机短信", value: 1, inputType: "number" },
{ text: "微信", value: 2, inputType: "text" },
{ text: "QQ", value: 3, inputType: "number" },
{ text: "钉钉", value: 4, inputType: "text" },
{ text: "企业微信", value: 5, inputType: "text" },
{ text: "其他", value: 6, inputType: "text" },
];
export const MESSAGE_TEMPLATES = [
{
typeId: 1,
type: "表白",
desc: "表达喜欢和心意,语气真诚不过度施压",
content: "有些话我想认真告诉你:遇见你以后,我开始期待每一次聊天和见面。我喜欢你,也尊重你的想法,只希望这份心意能被你看见。",
},
{
typeId: 2,
type: "道歉",
desc: "承认问题、表达歉意,减少争辩感",
content: "对不起,之前是我没有处理好自己的情绪,也没有站在你的角度考虑。我知道一句道歉不能立刻弥补什么,但我真心希望你能看到我的歉意。",
},
{
typeId: 3,
type: "祝福",
desc: "适合生日、纪念日和特殊日子的温柔问候",
content: "今天想把祝福送给你。愿你接下来的日子顺利、开心,也愿所有美好的事情都慢慢靠近你。",
},
{
typeId: 4,
type: "问候",
desc: "轻量关心,不打扰也不冒犯",
content: "只是想问候你一下,希望你最近一切都好。天气变化记得照顾好自己,也希望你每天都能轻松一点。",
},
{
typeId: 5,
type: "晚安",
desc: "适合夜晚发送,柔和表达惦记",
content: "晚安。希望你今晚能睡个好觉,把今天的疲惫都放下。明天醒来,也能拥有一点新的开心。",
},
{
typeId: 6,
type: "思念",
desc: "表达想念,但保留体面和边界",
content: "有些时刻还是会想起你,想起我们曾经一起经历过的片段。我没有想打扰你,只是想让你知道,我依然珍惜那些回忆。",
},
];

113
src/config/home.ts Normal file
View File

@ -0,0 +1,113 @@
import callingIcon from "@/assets/1.svg";
import manualIcon from "@/assets/2.svg";
import { APP_ROUTES } from "./routes";
export interface HomeFeatureCardConfig {
id: number;
title: string;
description: string;
iconPath: string;
buttonText: string;
route?: string;
buttonClass?: string;
iconClass?: string;
disabledTip?: string;
tag?: string;
}
export const HOME_HERO = {
eyebrow: "匿名短信 · 匿名电话",
title: "有些话,换一种方式认真说",
subtitle: "被拉黑、想道歉、想告白、想送祝福时,用更克制的方式把心意送达。",
highlights: ["匿名代发", "发送前预览", "定时送达"],
image: "/static/images/love.png",
actionText: "写一条匿名短信",
actionRoute: APP_ROUTES.sending,
secondaryText: "人工传话",
secondaryRoute: APP_ROUTES.manual,
};
export const HOME_PRIMARY_FEATURES: HomeFeatureCardConfig[] = [
{
id: 1,
title: "人工传话",
description: "真人协助传达,更适合认真道歉和重要告白",
iconPath: manualIcon,
buttonText: "去传话",
route: APP_ROUTES.manual,
iconClass: "icon-wrapper-orange",
buttonClass: "color-button",
tag: "更有温度",
},
{
id: 2,
title: "匿名电话",
description: "适合紧急和解、重要解释,先确认意愿再沟通",
iconPath: callingIcon,
buttonText: "去拨打",
route: APP_ROUTES.calling,
iconClass: "icon-wrapper-blue",
buttonClass: "color-button",
disabledTip: "此通道正在维护中,暂时禁用!",
tag: "即将开放",
},
];
export const HOME_ADDITIONAL_FEATURES: HomeFeatureCardConfig[] = [
{
id: 3,
title: "定时短信",
description: "生日、纪念日、晚安问候可提前设置",
iconPath: "/static/images/cat.png",
buttonText: "去设置",
route: APP_ROUTES.planning,
tag: "不怕错过",
},
{
id: 4,
title: "AI文案美化",
description: "把冲动的话改得更真诚、更体面",
iconPath: "/static/images/cat.png",
buttonText: "去优化",
route: APP_ROUTES.sending,
tag: "降低冒犯",
},
{
id: 5,
title: "收到回复",
description: "回复统一收进信箱,避免错过对方回应",
iconPath: "/static/images/cat.png",
buttonText: "去查看",
route: APP_ROUTES.reply,
tag: "及时查看",
},
];
export const HOME_FEATURE_STYLE_CLASSES = {
icon: ["icon-wrapper-green", "icon-wrapper-purple", "icon-wrapper-red"],
button: ["green-button", "purple-button", "red-button"],
};
export const HOME_SCENARIOS = [
{
title: "被拉黑后道歉",
description: "把重点放在承担责任,不反复打扰。",
},
{
title: "鼓起勇气告白",
description: "先表达心意,给对方留出选择空间。",
},
{
title: "生日和纪念日祝福",
description: "定时发送,适合不方便直接联系的时刻。",
},
];
export const HOME_STEPS = [
"选择短信、人工传话或电话",
"填写联系人和想说的话",
"预览确认后再发送",
];
export const HOME_GUARDRAILS = ["尊重对方意愿", "不鼓励频繁打扰", "内容发送前可检查"];

104
src/config/mail.ts Normal file
View File

@ -0,0 +1,104 @@
import emptyImage from "@/assets/nodata.svg";
export enum MailStatus {
PENDING = 0,
SENT = 1,
FAILED = 2,
}
export interface Mail {
id: number;
orderNumber: string;
recipient: string;
content: string;
sendTime: string;
submitTime: string;
status: MailStatus;
price: number;
isNew: boolean;
}
export const MAIL_DETAIL_PAGE = "/pages/mailbox/detail";
export const MAIL_TABS = [
{ name: "全部" },
{ name: "待发送" },
{ name: "已发送" },
{ name: "发送失败" },
];
export const MAIL_EMPTY_IMAGE = emptyImage;
export const MAIL_STATUS_META: Record<MailStatus, { text: string; className: string }> = {
[MailStatus.PENDING]: {
text: "待发送",
className: "status-pending",
},
[MailStatus.SENT]: {
text: "已发送",
className: "status-sent",
},
[MailStatus.FAILED]: {
text: "发送失败",
className: "status-failed",
},
};
export const MOCK_MAIL_CONTENTS = [
"有些话想认真说一次,不打扰你,只希望你知道我的歉意。",
"之前没有照顾好你的感受,对不起。愿你之后都能轻松一点。",
"如果还有机会,我想把没说好的话认真补上。",
"这条短信只是想把心意说清楚,不给你压力。",
];
export const MOCK_PHONE_PREFIXES = ["135", "136", "138", "151", "166", "171", "188", "199"];
export const maskRecipient = (recipient: string): string => {
if (!recipient) return "";
const value = String(recipient).trim();
if (value.length <= 7) {
return value.replace(/^(.{2}).*(.{1})$/, "$1****$2");
}
return `${value.slice(0, 3)}****${value.slice(-4)}`;
};
export const formatMailTime = (timeString: string): string => {
if (!timeString || typeof timeString !== "string") {
return String(timeString || "");
}
const date = new Date(timeString.replace(" ", "T"));
if (Number.isNaN(date.getTime())) {
return timeString;
}
const now = new Date();
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
if (year === now.getFullYear()) {
return `${month}${day}${hours}:${minutes}`;
}
return `${year}${month}${day}${hours}:${minutes}`;
};
export const serializeMail = (mail: Mail): string => encodeURIComponent(JSON.stringify(mail));
export const deserializeMail = (payload?: string): Mail | null => {
if (!payload) return null;
try {
return JSON.parse(decodeURIComponent(payload)) as Mail;
} catch (error) {
console.error("解析短信详情失败", error);
return null;
}
};

143
src/config/planning.ts Normal file
View File

@ -0,0 +1,143 @@
import { SOCIAL_TYPE_OPTIONS } from "@/config/form";
import { maskRecipient } from "@/config/mail";
export type PlanCycleType = "daily" | "weekly" | "monthly";
export type PlanStatus = "inProgress" | "completed" | "paused";
export interface PlanCycleOption {
type: PlanCycleType;
name: string;
desc: string;
}
export interface PlanSubmitPayload {
cycleType: PlanCycleType;
cycleName: string;
socialType: number;
socialTypeName: string;
socialAccount: string;
content: string;
}
export interface PlanItem {
id: string;
cycleType: PlanCycleType;
cycleName: string;
socialTypeName: string;
socialAccount: string;
content: string;
nextRunTime: string;
createdAt: string;
status: PlanStatus;
}
export const PLAN_CYCLE_OPTIONS: PlanCycleOption[] = [
{
type: "daily",
name: "每天",
desc: "每天定时传达一次",
},
{
type: "weekly",
name: "每周",
desc: "每周固定传达一次",
},
{
type: "monthly",
name: "每月",
desc: "每月固定传达一次",
},
];
export const PLAN_STATUS_TABS = [
{ name: "全部", value: "all" },
{ name: "进行中", value: "inProgress" },
{ name: "已完成", value: "completed" },
] as const;
export const PLAN_STATUS_META: Record<PlanStatus, { text: string; className: string }> = {
inProgress: {
text: "进行中",
className: "status-running",
},
completed: {
text: "已完成",
className: "status-done",
},
paused: {
text: "已暂停",
className: "status-paused",
},
};
export const PLAN_SOCIAL_OPTIONS = SOCIAL_TYPE_OPTIONS.filter((item) => item.value !== 6);
export const MOCK_PLAN_LIST: PlanItem[] = [
{
id: "PL250503001",
cycleType: "daily",
cycleName: "每天",
socialTypeName: "手机短信",
socialAccount: "19878384602",
content: "每天都想轻轻问候你一下,希望你今天也能顺利一点。",
nextRunTime: "2026-05-04 20:00:00",
createdAt: "2026-05-03 18:30:00",
status: "inProgress",
},
{
id: "PL250502002",
cycleType: "weekly",
cycleName: "每周",
socialTypeName: "微信",
socialAccount: "moonlight_520",
content: "每周给你留一句温柔的话,不催促,只表达我的在意。",
nextRunTime: "2026-05-09 21:00:00",
createdAt: "2026-05-02 21:12:00",
status: "inProgress",
},
{
id: "PL250501003",
cycleType: "monthly",
cycleName: "每月",
socialTypeName: "QQ",
socialAccount: "1289045216",
content: "这个月也想认真祝你平安顺利,愿你越来越轻松。",
nextRunTime: "2026-06-01 09:30:00",
createdAt: "2026-05-01 09:18:00",
status: "inProgress",
},
{
id: "PL250430004",
cycleType: "daily",
cycleName: "每天",
socialTypeName: "手机短信",
socialAccount: "13677268104",
content: "晚安,愿你今晚能睡得安心一点。",
nextRunTime: "2026-04-30 23:00:00",
createdAt: "2026-04-28 22:10:00",
status: "completed",
},
{
id: "PL250428005",
cycleType: "weekly",
cycleName: "每周",
socialTypeName: "企业微信",
socialAccount: "yanxi_2026",
content: "想把没说好的话慢慢说清楚,也尊重你的节奏。",
nextRunTime: "2026-05-05 19:30:00",
createdAt: "2026-04-28 16:02:00",
status: "paused",
},
];
export const maskSocialAccount = (account: string): string => {
if (/^1\d{10}$/.test(account)) {
return maskRecipient(account);
}
if (account.length <= 6) {
return account;
}
return `${account.slice(0, 3)}***${account.slice(-3)}`;
};

77
src/config/profile.ts Normal file
View File

@ -0,0 +1,77 @@
import { APP_ROUTES } from "./routes";
export const PROFILE_INFO = {
avatar: "/static/images/cat.png",
username: "GT-呆河马",
userId: "10086",
};
export const PROFILE_STATS = [
{
label: "可用余额",
value: "¥26.80",
},
{
label: "收到回复",
value: "2",
route: APP_ROUTES.reply,
},
{
label: "执行计划",
value: "3",
route: APP_ROUTES.planned,
},
] as const;
export const PROFILE_QUICK_ACTIONS = [
{
title: "发匿名短信",
desc: "快速写一条短信",
icon: "信",
route: APP_ROUTES.sending,
},
{
title: "人工传话",
desc: "更柔和地表达",
icon: "传",
route: APP_ROUTES.manual,
},
{
title: "创建计划",
desc: "定期传达心意",
icon: "计",
route: APP_ROUTES.planning,
},
{
title: "我的信箱",
desc: "查看发送记录",
icon: "箱",
route: APP_ROUTES.mailbox,
},
] as const;
export const PROFILE_MENU_GROUPS = [
[
{
label: "隐私与安全",
action: "privacy",
},
{
label: "使用须知",
action: "notice",
},
{
label: "联系我们",
action: "copyWechat",
},
],
] as const;
export const CONTACT_CONFIG = {
wechatId: "I18888",
copySuccessText: "微信号已复制",
copyFailText: "复制失败,请重试",
};
export const PROFILE_NOTICE_TEXT = "请真诚表达,避免频繁打扰对方。";
export const PROFILE_PRIVACY_TEXT = "平台会保护你的提交信息,仅用于完成传话服务。";

101
src/config/reply.ts Normal file
View File

@ -0,0 +1,101 @@
export interface ReplyItem {
id: string;
contactPhone: string;
originalMessage: string;
replyMessage: string;
replyTime: string;
source: "sms" | "manual";
unread: boolean;
}
export const REPLY_PAGE_SIZE = 6;
export const REPLY_SOURCE_META: Record<ReplyItem["source"], { text: string; className: string }> = {
sms: {
text: "短信回复",
className: "source-sms",
},
manual: {
text: "人工传话",
className: "source-manual",
},
};
export const MOCK_REPLY_LIST: ReplyItem[] = [
{
id: "RP250503001",
contactPhone: "19878384602",
originalMessage: "有些话想认真说一次,不打扰你,只希望你知道我的歉意。",
replyMessage: "我收到了。谢谢你愿意认真说这些,我需要一点时间想想。",
replyTime: "2026-05-03 20:12:00",
source: "sms",
unread: true,
},
{
id: "RP250503002",
contactPhone: "13993621665",
originalMessage: "之前没有照顾好你的感受,对不起。愿你之后都能轻松一点。",
replyMessage: "我已经看到了,也希望你之后能真的慢慢变好。",
replyTime: "2026-05-03 18:35:00",
source: "manual",
unread: true,
},
{
id: "RP250502003",
contactPhone: "18826830915",
originalMessage: "如果还有机会,我想把没说好的话认真补上。",
replyMessage: "先不用急着见面,等彼此都平静一点再说吧。",
replyTime: "2026-05-02 21:08:00",
source: "sms",
unread: false,
},
{
id: "RP250501004",
contactPhone: "15188340219",
originalMessage: "这条短信只是想把心意说清楚,不给你压力。",
replyMessage: "谢谢你的祝福,我也希望我们都能好好生活。",
replyTime: "2026-05-01 09:46:00",
source: "sms",
unread: false,
},
{
id: "RP250430005",
contactPhone: "13677268104",
originalMessage: "对不起,之前的争吵我也有很多没有处理好的地方。",
replyMessage: "我知道了。过去的事情先放一放吧,别再互相消耗了。",
replyTime: "2026-04-30 22:20:00",
source: "manual",
unread: false,
},
{
id: "RP250429006",
contactPhone: "19945201866",
originalMessage: "想把晚安认真补给你,也想把歉意说清楚。",
replyMessage: "晚安。也祝你以后能遇到更好的自己。",
replyTime: "2026-04-29 23:17:00",
source: "sms",
unread: false,
},
{
id: "RP250428007",
contactPhone: "16690438152",
originalMessage: "我不会再频繁打扰,只是想最后认真表达一次。",
replyMessage: "收到。谢谢你尊重我的节奏。",
replyTime: "2026-04-28 16:02:00",
source: "manual",
unread: false,
},
];
export const serializeReply = (reply: ReplyItem): string => encodeURIComponent(JSON.stringify(reply));
export const deserializeReply = (payload?: string): ReplyItem | null => {
if (!payload) return null;
try {
return JSON.parse(decodeURIComponent(payload)) as ReplyItem;
} catch (error) {
console.error("解析回复详情失败", error);
return null;
}
};

32
src/config/routes.ts Normal file
View File

@ -0,0 +1,32 @@
export const APP_ROUTES = {
home: "/pages/index/index",
mailbox: "/pages/mailbox/index",
mine: "/pages/mine/index",
sending: "/pages/sending/index",
manual: "/pages/manual/index",
calling: "/pages/calling/index",
planning: "/pages/planning/index",
planned: "/pages/planning/planned",
reply: "/pages/reply/index",
} as const;
export const TAB_BAR_ITEMS = [
{
pagePath: "pages/index/index",
text: "写信",
iconPath: "static/images/send.png",
selectedIconPath: "static/images/send-active.png",
},
{
pagePath: "pages/mailbox/index",
text: "信箱",
iconPath: "static/images/mailbox.png",
selectedIconPath: "static/images/mailbox-active.png",
},
{
pagePath: "pages/mine/index",
text: "我的",
iconPath: "static/images/mine.png",
selectedIconPath: "static/images/mine-active.png",
},
] as const;

View File

@ -35,6 +35,19 @@
}
}
},
{
"path": "pages/mailbox/detail",
"style": {
// #ifdef H5
"titleNView": false,
// #endif
"navigationBarTitleText": "短信详情",
"mp-alipay": {
"titleBarColor": "#FFFFFF",
"titleColor": "#000000"
}
}
},
{
"path": "pages/mine/index",
"style": {
@ -113,6 +126,19 @@
}
}
},
{
"path": "pages/reply/detail",
"style": {
// #ifdef H5
"titleNView": false,
// #endif
"navigationBarTitleText": "回复详情",
"mp-alipay": {
"titleBarColor": "#FFFFFF",
"titleColor": "#000000"
}
}
},
{
"path": "pages/planning/planned",
"style": {
@ -134,10 +160,14 @@
"backgroundColor": "#F8F8F8"
},
"tabBar": {
"color": "#999",
"selectedColor": "#00c853",
"color": "#7a7f87",
"selectedColor": "#18b566",
"backgroundColor": "#ffffff",
"borderStyle": "white",
"borderStyle": "black",
"height": "56px",
"fontSize": "12px",
"iconWidth": "24px",
"spacing": "4px",
"list": [
{
"pagePath": "pages/index/index",

View File

@ -1,334 +1,343 @@
<template>
<view class="container">
<home-hero-card
:eyebrow="HOME_HERO.eyebrow"
:title="HOME_HERO.title"
:subtitle="HOME_HERO.subtitle"
:highlights="HOME_HERO.highlights"
:image="HOME_HERO.image"
:action-text="HOME_HERO.actionText"
:secondary-text="HOME_HERO.secondaryText"
@action="navigateTo(HOME_HERO.actionRoute)"
@secondary="navigateTo(HOME_HERO.secondaryRoute)"
/>
<!-- 页面标题 -->
<view class="page-title">
<text class="title-text">很多遗憾&源于当初没有勇敢</text>
</view>
<!-- 消息卡片 -->
<view class="message-card">
<view class="message-content">
<view class="message-types">
<text class="message-type-text">分手挽回, 生日祝福</text>
<text class="message-type-text">道歉告白时空信也</text>
<text class="message-type-text">可以发送哦</text>
<view class="action-button">
<button class="write-button" @tap="navigateToSending">开始写信</button>
</view>
</view>
<view class="illustration">
<!-- 注意需要替换成实际的图片路径 -->
<image class="illustration-image" src="../../assets/love.svg" mode="aspectFit"></image>
</view>
</view>
</view>
<view class="card-group">
<!-- 人工传话卡片 -->
<view class="function-card">
<view class="function-left">
<view class="icon-wrapper icon-wrapper-orange">
<image class="function-icon" src="../../assets/2.svg" mode="aspectFit"></image>
</view>
<view class="function-text">
<text class="function-title">人工传话</text>
<text class="function-description">成功率更高更有仪式感</text>
</view>
</view>
<view class="function-right">
<!-- "去传话"按钮 -->
<button class="function-button color-button" hover-class="button-hover" @tap="navigateToManual">
<view class="button-content">
<text>去传话</text>
<text class="arrow"></text>
</view>
</button>
</view>
</view>
<!-- 和解电话卡片第一版本暂时不开发 -->
<view class="function-card">
<view class="function-left">
<view class="icon-wrapper icon-wrapper-blue">
<image class="function-icon" src="../../assets/1.svg" mode="aspectFit"></image>
</view>
<view class="function-text">
<text class="function-title">和解电话</text>
<text class="function-description">被拉黑也能打通的电话</text>
</view>
</view>
<view class="function-right">
<!-- "去拨打"按钮 -->
<button class="function-button color-button" hover-class="button-hover" @tap="navigateToCalling">
<view class="button-content">
<text>去拨打</text>
<text class="arrow"></text>
</view>
</button>
</view>
<view class="section-block">
<view class="section-header">
<text class="section-title">选择表达方式</text>
<text class="section-desc">按场景选择先把话说清楚</text>
</view>
<home-feature-card
v-for="feature in HOME_PRIMARY_FEATURES"
:key="feature.id"
:title="feature.title"
:description="feature.description"
:icon-path="feature.iconPath"
:button-text="feature.buttonText"
:icon-class="feature.iconClass"
:button-class="feature.buttonClass"
:tag="feature.tag"
@action="handleFeatureTap(feature)"
/>
</view>
<!--居中消息提示组件移到卡片外部-->
<tui-tips ref="tuiTips" position="center" backgroundColor="rgba(0, 0, 0, 0.8)" color="#fff" :size="30"></tui-tips>
<view class="card-group additional-cards">
<!-- 使用v-for循环渲染动态卡片 -->
<view class="function-card" v-for="(card, index) in additionalCards" :key="index">
<view class="function-left">
<view class="icon-wrapper" :class="getIconClass(index)">
<image class="function-icon" :src="card.iconPath" mode="aspectFit"></image>
</view>
<view class="function-text">
<text class="function-title">{{ card.title }}</text>
<text class="function-description">{{ card.description }}</text>
</view>
</view>
<view class="function-right">
<button class="function-button" :class="getButtonClass(index)" hover-class="button-hover" @tap="navigateToPlanning">
<view class="button-content">
<text>{{ card.buttonText }}</text>
<text class="arrow"></text>
</view>
</button>
</view>
<view class="scenario-section">
<view class="section-header">
<text class="section-title">适合这些时刻</text>
<text class="section-desc">告白道歉祝福都需要分寸感</text>
</view>
<!-- 加载状态显示 -->
<view class="loading-container" v-if="isLoading">
<text class="loading-text">加载中...</text>
</view>
<!--加载失败时显示-->
<view class="error-container" v-if="loadError">
<text class="error-text">{{ loadError }}</text>
<button class="retry-button" @tap="fetchAdditionalCards">重试</button>
<view class="scenario-list">
<view class="scenario-card" v-for="scenario in HOME_SCENARIOS" :key="scenario.title">
<view class="scenario-dot"></view>
<view class="scenario-copy">
<text class="scenario-title">{{ scenario.title }}</text>
<text class="scenario-desc">{{ scenario.description }}</text>
</view>
</view>
</view>
</view>
</view>
<view class="step-section">
<view class="section-header">
<text class="section-title">三步送达</text>
<text class="section-desc">发送前还能再次预览确认</text>
</view>
<view class="step-list">
<view class="step-item" v-for="(step, index) in HOME_STEPS" :key="step">
<text class="step-index">{{ index + 1 }}</text>
<text class="step-text">{{ step }}</text>
</view>
</view>
</view>
<view class="section-block additional-cards">
<view class="section-header">
<text class="section-title">更多工具</text>
<text class="section-desc">让表达更准时更稳妥</text>
</view>
<home-feature-card
v-for="(feature, index) in additionalCards"
:key="feature.id"
:title="feature.title"
:description="feature.description"
:icon-path="feature.iconPath"
:button-text="feature.buttonText"
:icon-class="getIconClass(index)"
:button-class="getButtonClass(index)"
:tag="feature.tag"
@action="handleFeatureTap(feature)"
/>
<inline-state v-if="isLoading" text="加载中..." type="loading" />
<inline-state v-if="loadError" :text="loadError" type="error" retry-text="重试" @retry="fetchAdditionalCards" />
</view>
<view class="guardrail-bar">
<text class="guardrail-item" v-for="item in HOME_GUARDRAILS" :key="item">{{ item }}</text>
</view>
</view>
</template>
<script lang="ts" setup>
import { onPullDownRefresh } from "@dcloudio/uni-app";
import { onPullDownRefresh } from '@dcloudio/uni-app';
import {useSwitchTab} from "@/stores/switchTab";
import InlineState from "@/components/app/InlineState.vue";
import HomeFeatureCard from "@/components/home/HomeFeatureCard.vue";
import HomeHeroCard from "@/components/home/HomeHeroCard.vue";
import TuiTips from "@/components/thorui/tui-tips/tui-tips.vue";
import {
HOME_ADDITIONAL_FEATURES,
HOME_FEATURE_STYLE_CLASSES,
HOME_GUARDRAILS,
HOME_HERO,
HOME_PRIMARY_FEATURES,
HOME_SCENARIOS,
HOME_STEPS,
type HomeFeatureCardConfig,
} from "@/config/home";
import { useSwitchTab } from "@/stores/switchTab";
interface Card {
id: number;
title: string;
description: string;
iconPath: string;
buttonText: string;
pageUrl?: string; //
}
//
interface IndexConfig {
title: string;
}
//
const additionalCards = ref<Card[]>([]);
//
const additionalCards = ref<HomeFeatureCardConfig[]>([]);
const isLoading = ref<boolean>(false);
const loadError = ref<string>('');
// tui-tips
const loadError = ref<string>("");
const tuiTips = ref<any>(null);
//
const getIconClass = (index: number): string => {
const classes = ['icon-wrapper-green', 'icon-wrapper-purple', 'icon-wrapper-red'];
const classes = HOME_FEATURE_STYLE_CLASSES.icon;
return classes[index % classes.length];
};
//
const getButtonClass = (index: number): string => {
const classes = ['green-button', 'purple-button', 'red-button'];
const classes = HOME_FEATURE_STYLE_CLASSES.button;
return classes[index % classes.length];
};
//
const showTip = (message: string, duration: number = 2000): void => {
if (tuiTips.value) {
tuiTips.value.showTips({
msg: message,
duration: duration
});
const showTip = (message: string, duration = 2000): void => {
tuiTips.value?.showTips({
msg: message,
duration,
});
};
const navigateTo = (route?: string): void => {
if (!route) return;
uni.navigateTo({
url: route,
});
};
const handleFeatureTap = (feature: HomeFeatureCardConfig): void => {
if (feature.disabledTip) {
showTip(feature.disabledTip);
return;
}
navigateTo(feature.route);
};
//
const navigateToCalling = (): void => {
console.log("触发跳转其他页面");
//
showTip('🚧 此通道正在维护中,暂时禁用!', 2000);
//
console.log("用户尝试访问正在开发中的功能: 和解电话");
//
// setTimeout(() => {
// uni.navigateTo({
// url: '/pages/calling/index',
// });
// }, 3000);
};
//
const navigateToManual = (): void => {
console.log("触发跳转其他页面")
uni.navigateTo({
url: '/pages/manual/index',
});
};
//
const navigateToSending = (): void => {
console.log("触发跳转其他页面")
uni.navigateTo({
url: '/pages/sending/index',
});
};
//
const navigateToPlanning = (): void => {
console.log("触发跳转其他页面")
uni.navigateTo({
url: '/pages/planning/index',
});
};
//
const navigateToReply = (): void => {
console.log("触发跳转到回复页面");
uni.navigateTo({
url: '/pages/reply/index',
});
};
//
const fetchAdditionalCards = (): void => {
isLoading.value = true;
loadError.value = '';
loadError.value = "";
// API
setTimeout(() => {
// 使
additionalCards.value = [
{
id: 1,
title: '记忆时光机',
description: '回忆美好时光,重温幸福',
iconPath: '/static/images/cat.png',
buttonText: '去回忆'
},
{
id: 2,
title: '情感咨询',
description: '专业导师一对一指导',
iconPath: '/static/images/cat.png',
buttonText: '去咨询'
},
{
id: 3,
title: '关系修复',
description: '重建信任,恢复亲密',
iconPath: '/static/images/cat.png',
buttonText: '去修复'
}
];
additionalCards.value = HOME_ADDITIONAL_FEATURES;
isLoading.value = false;
}, 1000); // 1
}, 1000);
};
// tab
const initLastPage = (): void => {
const switchTab = useSwitchTab();
const currentPages = getCurrentPages();
const lastPage = currentPages[currentPages.length - 1];
const page = lastPage ? String(lastPage.route) : '';
const page = lastPage ? String(lastPage.route) : "";
switchTab.setLastPage(page);
}
};
//
onMounted(() => {
console.log("内容已载入")
// tab
initLastPage();
//
fetchAdditionalCards();
});
//
onPullDownRefresh(() => {
console.log("下拉刷新触发事件")
fetchAdditionalCards();
uni.stopPullDownRefresh();
console.log("下拉刷新触发完成")
})
});
</script>
<style>
@import '../../theme/index/index.css';
.custom-write-button {
background: linear-gradient(135deg, #537edc, #84a9ff);
border: none;
padding: 0;
border-radius: 30rpx;
overflow: hidden;
box-shadow: 0 6rpx 12rpx rgba(83, 126, 220, 0.2);
transition: all 0.3s ease;
position: relative;
z-index: 1;
max-width: 200rpx;
}
.custom-write-button::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, #4568b2, #7090e7);
border-radius: 30rpx;
z-index: -1;
opacity: 0;
transition: opacity 0.3s ease;
}
.button-inner {
.container {
display: flex;
align-items: center;
justify-content: center;
padding: 10rpx 24rpx;
flex-direction: column;
min-height: 100vh;
background:
linear-gradient(180deg, rgba(255, 244, 250, 0.98) 0%, rgba(239, 248, 255, 0.94) 46%, #f8f8fb 100%),
linear-gradient(120deg, rgba(255, 210, 228, 0.26), rgba(196, 231, 255, 0.22));
position: relative;
overflow: hidden;
padding: 24rpx 24rpx 44rpx;
box-sizing: border-box;
}
.button-text {
color: #fff;
.section-block,
.scenario-section,
.step-section {
margin-top: 30rpx;
}
.section-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 18rpx;
padding: 0 6rpx;
}
.section-title {
color: #20273a;
font-size: 32rpx;
font-weight: 700;
line-height: 1.3;
}
.section-desc {
color: #8b94a3;
font-size: 23rpx;
line-height: 1.4;
margin-left: 16rpx;
text-align: right;
}
.scenario-list {
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.92), rgba(250, 253, 255, 0.82)),
linear-gradient(90deg, rgba(255, 205, 223, 0.18), rgba(202, 235, 255, 0.18));
border-radius: 26rpx;
padding: 8rpx 24rpx;
box-shadow: 0 12rpx 32rpx rgba(83, 96, 130, 0.07);
border: 1rpx solid rgba(255, 255, 255, 0.84);
}
.scenario-card {
display: flex;
align-items: flex-start;
padding: 24rpx 0;
border-bottom: 1rpx solid #f0f2f5;
}
.scenario-card:last-child {
border-bottom: none;
}
.scenario-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: linear-gradient(135deg, #ff7fa0, #7fc8ff);
margin-top: 13rpx;
margin-right: 18rpx;
box-shadow: 0 0 0 8rpx rgba(255, 127, 160, 0.12);
flex-shrink: 0;
}
.scenario-copy {
display: flex;
flex-direction: column;
}
.scenario-title {
color: #253044;
font-size: 29rpx;
font-weight: 600;
line-height: 1.35;
}
.scenario-desc {
color: #7b8493;
font-size: 24rpx;
font-weight: 500;
letter-spacing: 1rpx;
line-height: 1.5;
margin-top: 8rpx;
}
.button-icon {
margin-left: 6rpx;
.step-list {
display: flex;
justify-content: space-between;
}
.step-item {
flex: 1;
min-height: 156rpx;
margin-right: 16rpx;
padding: 22rpx 18rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.84);
box-shadow: 0 12rpx 30rpx rgba(83, 96, 130, 0.07);
border: 1rpx solid rgba(255, 255, 255, 0.84);
box-sizing: border-box;
}
.step-item:last-child {
margin-right: 0;
}
.step-index {
display: flex;
align-items: center;
justify-content: center;
animation: bounceRight 1.5s infinite;
width: 38rpx;
height: 38rpx;
border-radius: 50%;
color: #fff;
background: linear-gradient(135deg, #ff82a2, #73bfff);
font-size: 22rpx;
font-weight: 700;
line-height: 38rpx;
box-shadow: 0 8rpx 16rpx rgba(115, 191, 255, 0.18);
}
</style>
.step-text {
display: block;
margin-top: 18rpx;
color: #3a4354;
font-size: 24rpx;
line-height: 1.45;
}
.additional-cards {
margin-top: 32rpx;
}
.guardrail-bar {
display: flex;
flex-wrap: wrap;
margin-top: 24rpx;
padding: 18rpx 20rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.66);
border: 1rpx solid rgba(255, 255, 255, 0.82);
box-shadow: 0 8rpx 22rpx rgba(83, 96, 130, 0.05);
}
.guardrail-item {
color: #7b8493;
font-size: 22rpx;
line-height: 1.3;
margin-right: 20rpx;
margin-bottom: 6rpx;
}
</style>

View File

@ -0,0 +1,475 @@
<template>
<view class="detail-page">
<view class="dream-bg dream-bg-one"></view>
<view class="dream-bg dream-bg-two"></view>
<view class="detail-header">
<view class="back-button" hover-class="button-hover" @tap="goBack"></view>
<view>
<text class="detail-title">短信详情</text>
<text class="detail-subtitle">查看这条匿名短信的发送进度</text>
</view>
</view>
<view class="status-card" :class="statusMeta.className">
<view class="status-top">
<view class="status-pill" :class="statusMeta.className">
<text class="status-dot"></text>
<text>{{ statusMeta.text }}</text>
</view>
<text class="order-text">#{{ currentMail.orderNumber }}</text>
</view>
<text class="status-title">{{ statusTitle }}</text>
<text class="status-desc">{{ statusDesc }}</text>
</view>
<view class="phone-preview">
<view class="preview-header">
<view class="avatar"></view>
<view>
<text class="preview-name">匿名短信</text>
<text class="preview-recipient">发给 {{ maskedRecipient }}</text>
</view>
</view>
<view class="imessage-area">
<view class="sms-bubble">
<text class="sms-content">{{ currentMail.content }}</text>
</view>
</view>
</view>
<view class="info-panel">
<view class="info-row">
<text class="info-label">联系人</text>
<text class="info-value">{{ maskedRecipient }}</text>
</view>
<view class="info-row">
<text class="info-label">提交时间</text>
<text class="info-value">{{ submitTime }}</text>
</view>
<view class="info-row">
<text class="info-label">发送时间</text>
<text class="info-value">{{ sendTime }}</text>
</view>
<view class="info-row">
<text class="info-label">费用</text>
<text class="info-value">¥{{ currentMail.price.toFixed(2) }}</text>
</view>
</view>
<view class="timeline-panel">
<view class="timeline-item done">
<view class="timeline-dot"></view>
<view class="timeline-content">
<text class="timeline-title">已提交</text>
<text class="timeline-time">{{ submitTime }}</text>
</view>
</view>
<view class="timeline-item" :class="{ done: currentMail.status === sentStatus, failed: currentMail.status === failedStatus }">
<view class="timeline-dot"></view>
<view class="timeline-content">
<text class="timeline-title">{{ currentMail.status === failedStatus ? "发送失败" : "等待发送" }}</text>
<text class="timeline-time">{{ sendTime }}</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import {
MAIL_STATUS_META,
MailStatus,
deserializeMail,
formatMailTime,
maskRecipient,
type Mail,
} from "@/config/mail";
const fallbackMail: Mail = {
id: 0,
orderNumber: "2503000000000000",
recipient: "13800138000",
content: "这是一条匿名短信记录,稍后会展示真实发送内容。",
sendTime: "2025-03-18 20:00:00",
submitTime: "2025-03-18 19:45:00",
status: MailStatus.PENDING,
price: 0,
isNew: false,
};
const mail = ref<Mail>(fallbackMail);
const sentStatus = MailStatus.SENT;
const failedStatus = MailStatus.FAILED;
onLoad((query) => {
const detail = deserializeMail(query?.mail as string | undefined);
if (detail) {
mail.value = detail;
}
});
const currentMail = computed(() => mail.value);
const statusMeta = computed(() => MAIL_STATUS_META[currentMail.value.status]);
const maskedRecipient = computed(() => maskRecipient(currentMail.value.recipient));
const submitTime = computed(() => formatMailTime(currentMail.value.submitTime));
const sendTime = computed(() => formatMailTime(currentMail.value.sendTime));
const statusTitle = computed(() => {
if (currentMail.value.status === MailStatus.SENT) return "短信已经送达";
if (currentMail.value.status === MailStatus.FAILED) return "发送没有成功";
return "短信正在等待发送";
});
const statusDesc = computed(() => {
if (currentMail.value.status === MailStatus.SENT) return "对方收到后,如果有回复会在信箱中展示。";
if (currentMail.value.status === MailStatus.FAILED) return "可以稍后重新提交,或检查联系人号码是否正确。";
return "系统会按预约时间发送,请留意后续状态变化。";
});
const goBack = () => {
uni.navigateBack({
delta: 1,
fail: () => {
uni.switchTab({
url: "/pages/mailbox/index",
});
},
});
};
</script>
<style scoped lang="scss">
.detail-page {
min-height: 100vh;
box-sizing: border-box;
padding: 28rpx 26rpx 48rpx;
background:
linear-gradient(180deg, rgba(255, 244, 250, 0.98) 0%, rgba(238, 248, 255, 0.96) 48%, #f7f8fb 100%),
linear-gradient(135deg, rgba(255, 204, 225, 0.22), rgba(199, 233, 255, 0.24));
position: relative;
overflow: hidden;
}
.dream-bg {
position: fixed;
border-radius: 50%;
pointer-events: none;
z-index: 0;
}
.dream-bg-one {
width: 360rpx;
height: 360rpx;
right: -160rpx;
top: 110rpx;
background: rgba(255, 188, 214, 0.24);
}
.dream-bg-two {
width: 320rpx;
height: 320rpx;
left: -140rpx;
top: 620rpx;
background: rgba(167, 218, 255, 0.22);
}
.detail-header,
.status-card,
.phone-preview,
.info-panel,
.timeline-panel {
position: relative;
z-index: 1;
}
.detail-header {
display: flex;
align-items: center;
gap: 18rpx;
margin-bottom: 24rpx;
}
.back-button {
width: 68rpx;
height: 68rpx;
line-height: 62rpx;
text-align: center;
border-radius: 50%;
color: #293247;
font-size: 54rpx;
background: rgba(255, 255, 255, 0.78);
border: 1rpx solid rgba(255, 255, 255, 0.9);
box-shadow: 0 10rpx 24rpx rgba(83, 96, 130, 0.07);
}
.button-hover {
opacity: 0.72;
}
.detail-title,
.detail-subtitle,
.status-title,
.status-desc,
.preview-name,
.preview-recipient,
.sms-content,
.info-label,
.info-value,
.timeline-title,
.timeline-time {
display: block;
}
.detail-title {
color: #20273a;
font-size: 42rpx;
font-weight: 700;
line-height: 1.2;
}
.detail-subtitle {
margin-top: 8rpx;
color: #687286;
font-size: 24rpx;
line-height: 1.4;
}
.status-card {
overflow: hidden;
padding: 26rpx;
border-radius: 28rpx;
background:
linear-gradient(145deg, rgba(255, 255, 255, 0.9) 0%, rgba(255, 248, 252, 0.82) 55%, rgba(240, 249, 255, 0.86) 100%),
rgba(255, 255, 255, 0.78);
border: 1rpx solid rgba(255, 255, 255, 0.88);
box-shadow: 0 18rpx 42rpx rgba(93, 107, 139, 0.09);
}
.status-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
margin-bottom: 26rpx;
}
.status-pill {
display: flex;
align-items: center;
height: 48rpx;
padding: 0 18rpx;
border-radius: 999rpx;
color: #fff;
font-size: 22rpx;
font-weight: 700;
}
.status-pill.status-pending {
background: linear-gradient(135deg, #ffb75e 0%, #f59e0b 100%);
}
.status-pill.status-sent {
background: linear-gradient(135deg, #7ee0a3 0%, #34b878 100%);
}
.status-pill.status-failed {
background: linear-gradient(135deg, #ff8daf 0%, #e85b7f 100%);
}
.status-dot {
width: 10rpx;
height: 10rpx;
margin-right: 10rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
}
.order-text {
flex: 1;
color: #9aa3b2;
font-size: 22rpx;
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-title {
color: #263044;
font-size: 36rpx;
font-weight: 700;
line-height: 1.35;
}
.status-desc {
margin-top: 12rpx;
color: #687286;
font-size: 25rpx;
line-height: 1.55;
}
.phone-preview,
.info-panel,
.timeline-panel {
margin-top: 22rpx;
border-radius: 28rpx;
background: rgba(255, 255, 255, 0.78);
border: 1rpx solid rgba(255, 255, 255, 0.9);
box-shadow: 0 18rpx 42rpx rgba(93, 107, 139, 0.08);
}
.phone-preview {
overflow: hidden;
}
.preview-header {
display: flex;
align-items: center;
padding: 26rpx 28rpx 18rpx;
border-bottom: 1rpx solid rgba(230, 236, 246, 0.78);
}
.avatar {
width: 74rpx;
height: 74rpx;
line-height: 74rpx;
margin-right: 16rpx;
text-align: center;
border-radius: 50%;
color: #fff;
font-size: 28rpx;
font-weight: 700;
background: linear-gradient(135deg, #ff78a2 0%, #83c6ff 100%);
}
.preview-name {
color: #263044;
font-size: 30rpx;
font-weight: 700;
}
.preview-recipient {
margin-top: 6rpx;
color: #8c96a8;
font-size: 23rpx;
}
.imessage-area {
padding: 28rpx;
background: linear-gradient(180deg, rgba(247, 250, 255, 0.76), rgba(255, 248, 252, 0.72));
}
.sms-bubble {
max-width: 590rpx;
padding: 22rpx 24rpx;
border-radius: 28rpx 28rpx 28rpx 8rpx;
background: #fff;
border: 1rpx solid rgba(232, 238, 248, 0.88);
box-shadow: 0 10rpx 24rpx rgba(93, 107, 139, 0.06);
}
.sms-content {
color: #333b4f;
font-size: 29rpx;
line-height: 1.7;
}
.info-panel {
padding: 8rpx 26rpx;
}
.info-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24rpx;
min-height: 82rpx;
border-bottom: 1rpx solid rgba(230, 236, 246, 0.72);
}
.info-row:last-child {
border-bottom: 0;
}
.info-label {
color: #8c96a8;
font-size: 24rpx;
flex-shrink: 0;
}
.info-value {
color: #263044;
font-size: 25rpx;
font-weight: 600;
text-align: right;
}
.timeline-panel {
padding: 26rpx 28rpx;
}
.timeline-item {
position: relative;
display: flex;
padding-bottom: 30rpx;
}
.timeline-item:last-child {
padding-bottom: 0;
}
.timeline-item::after {
content: "";
position: absolute;
left: 11rpx;
top: 28rpx;
bottom: 0;
width: 2rpx;
background: rgba(200, 211, 226, 0.7);
}
.timeline-item:last-child::after {
display: none;
}
.timeline-dot {
width: 24rpx;
height: 24rpx;
margin-top: 4rpx;
margin-right: 20rpx;
border-radius: 50%;
background: #d5dde9;
flex-shrink: 0;
}
.timeline-item.done .timeline-dot {
background: linear-gradient(135deg, #7ee0a3, #34b878);
}
.timeline-item.failed .timeline-dot {
background: linear-gradient(135deg, #ff8daf, #e85b7f);
}
.timeline-content {
flex: 1;
}
.timeline-title {
color: #263044;
font-size: 27rpx;
font-weight: 700;
line-height: 1.35;
}
.timeline-time {
margin-top: 8rpx;
color: #8c96a8;
font-size: 23rpx;
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,248 +1,322 @@
<template>
<view class="container">
<!-- Header Section: Profile Picture and Name -->
<view class="content-wrapper">
<view class="header">
<view class="profile-info">
<view class="profile-pic-container">
<image src="@/static/images/cat.png" mode="aspectFill" class="profile-pic"></image>
</view>
<view class="user-details">
<text class="username">GT-呆河马</text>
<view class="id-copy-wrapper">
<text class="user-id">ID: 10086</text>
<text class="copy-id">复制ID</text>
</view>
<view class="dream-bg dream-bg-one"></view>
<view class="dream-bg dream-bg-two"></view>
<scroll-view class="content-wrapper" scroll-y>
<profile-header
:avatar="PROFILE_INFO.avatar"
:username="PROFILE_INFO.username"
:user-id="PROFILE_INFO.userId"
@copy-id="copyUserId"
/>
<view class="stats-card">
<view
v-for="item in PROFILE_STATS"
:key="item.label"
class="stat-item"
hover-class="button-hover"
@tap="navigateTo(item.route)"
>
<text class="stat-value">{{ item.value }}</text>
<text class="stat-label">{{ item.label }}</text>
</view>
</view>
<view class="section-block">
<view class="section-header">
<text class="section-title">常用操作</text>
<text class="section-subtitle">更快开始一次表达</text>
</view>
<view class="quick-grid">
<view
v-for="item in PROFILE_QUICK_ACTIONS"
:key="item.title"
class="quick-card"
hover-class="card-hover"
@tap="navigateTo(item.route)"
>
<view class="quick-icon">{{ item.icon }}</view>
<text class="quick-title">{{ item.title }}</text>
<text class="quick-desc">{{ item.desc }}</text>
</view>
</view>
</view>
<!-- Second Section: Interactions -->
<view class="menu-section">
<!-- <view class="menu-item" hover-class="menu-item-hover" @tap="navigateToMailbox">-->
<!-- <text class="item-text">已发短信</text>-->
<!-- <text class="arrow"></text>-->
<!-- </view>-->
<view class="menu-item" hover-class="menu-item-hover" @tap="navigateToReply">
<text class="item-text highlight">收到回复</text>
<text class="arrow"></text>
</view>
<view class="menu-item" hover-class="menu-item-hover" @tap="navigateToPlanned">
<text class="item-text">已建计划</text>
<text class="arrow"></text>
</view>
</view>
<profile-menu-section
v-for="(menuGroup, index) in profileMenuGroups"
:key="index"
:items="menuGroup"
@select="handleMenuSelect"
/>
<!-- Additional Section -->
<view class="menu-section">
<!-- <view class="menu-item" hover-class="menu-item-hover">
<text class="item-text highlight">通话记录</text>
<text class="arrow"></text>
</view> -->
<view class="menu-item" hover-class="menu-item-hover" @tap="copyWechatContact">
<text class="item-text">联系我们</text>
<text class="arrow"></text>
</view>
</view>
<!-- Logout Button Section -->
<view class="button-container">
<tui-button btnSize="medium" shape="circle" size="28">退出登陆</tui-button>
<button class="logout-button" hover-class="button-hover">退出登录</button>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
// Script logic here
import TuiButton from "@/components/thorui/tui-button/tui-button.vue";
import ProfileHeader from "@/components/profile/ProfileHeader.vue";
import ProfileMenuSection, { type ProfileMenuItem } from "@/components/profile/ProfileMenuSection.vue";
import { APP_ROUTES } from "@/config/routes";
import {
CONTACT_CONFIG,
PROFILE_INFO,
PROFILE_MENU_GROUPS,
PROFILE_NOTICE_TEXT,
PROFILE_PRIVACY_TEXT,
PROFILE_QUICK_ACTIONS,
PROFILE_STATS,
} from "@/config/profile";
const profileMenuGroups = PROFILE_MENU_GROUPS.map((group) => [...group]) as ProfileMenuItem[][];
const navigateTo = (route?: string): void => {
if (!route) return;
if (route === APP_ROUTES.home || route === APP_ROUTES.mailbox || route === APP_ROUTES.mine) {
uni.switchTab({
url: route,
});
return;
}
//
const navigateToMailbox = (): void => {
console.log("触发跳转其他页面")
uni.navigateTo({
url: '/pages/mailbox/index',
url: route,
});
};
//
const navigateToReply = (): void => {
console.log("触发跳转其他页面")
uni.navigateTo({
url: '/pages/reply/index',
});
const handleMenuSelect = (item: ProfileMenuItem): void => {
if (item.action === "copyWechat") {
copyWechatContact();
return;
}
if (item.action === "privacy") {
showInfo(PROFILE_PRIVACY_TEXT);
return;
}
if (item.action === "notice") {
showInfo(PROFILE_NOTICE_TEXT);
return;
}
navigateTo(item.route);
};
//
const navigateToPlanned = (): void => {
console.log("触发跳转其他页面")
uni.navigateTo({
url: '/pages/planning/planned',
});
};
//
const copyWechatContact = (): void => {
const wechatID = "I18888"; //
const copyUserId = (): void => {
uni.setClipboardData({
data: wechatID,
data: PROFILE_INFO.userId,
success: () => {
//
uni.showToast({
title: '微信号已复制',
icon: 'success',
duration: 2000
title: "ID已复制",
icon: "success",
duration: 2000,
});
},
});
};
const copyWechatContact = (): void => {
uni.setClipboardData({
data: CONTACT_CONFIG.wechatId,
success: () => {
uni.showToast({
title: CONTACT_CONFIG.copySuccessText,
icon: "success",
duration: 2000,
});
},
fail: () => {
uni.showToast({
title: '复制失败,请重试',
icon: 'none',
duration: 2000
title: CONTACT_CONFIG.copyFailText,
icon: "none",
duration: 2000,
});
}
},
});
};
const showInfo = (content: string): void => {
uni.showModal({
title: "提示",
content,
showCancel: false,
confirmText: "知道了",
});
};
</script>
<style scoped lang="scss">
/* Global page styles to prevent scrolling */
page {
overflow: hidden;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans",
"Helvetica Neue", sans-serif;
}
.container {
display: flex;
flex-direction: column;
padding: 0;
background-color: #f5f7fa;
background:
linear-gradient(180deg, rgba(255, 244, 250, 0.98) 0%, rgba(239, 248, 255, 0.96) 48%, #f7f8fb 100%),
linear-gradient(135deg, rgba(255, 204, 225, 0.22), rgba(199, 233, 255, 0.24));
height: 100vh;
overflow: hidden;
box-sizing: border-box;
position: relative;
}
.content-wrapper {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
padding: 12px;
.dream-bg {
position: fixed;
border-radius: 50%;
pointer-events: none;
z-index: 0;
}
.header {
width: 100%;
padding: 20px;
background-color: #fff;
margin-bottom: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
flex-shrink: 0;
border-radius: 12px;
.dream-bg-one {
width: 360rpx;
height: 360rpx;
right: -150rpx;
top: 90rpx;
background: rgba(255, 193, 215, 0.23);
}
.dream-bg-two {
width: 330rpx;
height: 330rpx;
left: -140rpx;
top: 620rpx;
background: rgba(171, 218, 255, 0.21);
}
.content-wrapper {
position: relative;
z-index: 1;
flex: 1;
height: 0;
padding: 24rpx;
box-sizing: border-box;
}
.profile-info {
.stats-card {
display: flex;
align-items: center;
width: 100%;
padding: 0;
margin-bottom: 22rpx;
padding: 20rpx 10rpx;
border-radius: 26rpx;
background:
linear-gradient(145deg, rgba(255, 255, 255, 0.84), rgba(247, 252, 255, 0.62)),
rgba(255, 255, 255, 0.7);
border: 1rpx solid rgba(255, 255, 255, 0.9);
box-shadow: 0 16rpx 36rpx rgba(93, 107, 139, 0.07);
}
.profile-pic-container {
width: 80px;
height: 80px;
border-radius: 50%;
padding: 4px;
background-color: #fff;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
margin-right: 16px;
flex-shrink: 0;
.stat-item {
flex: 1;
text-align: center;
border-right: 1rpx solid rgba(230, 236, 246, 0.82);
}
.profile-pic {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
border: 2px solid #fff;
.stat-item:last-child {
border-right: 0;
}
.user-details {
display: flex;
flex-direction: column;
}
.username {
font-size: 20px;
color: #333;
font-weight: 600;
margin-bottom: 4px;
}
.id-copy-wrapper {
display: flex;
align-items: center;
}
.user-id {
font-size: 14px;
color: #666;
}
.copy-id {
color: #4285f4;
font-size: 14px;
margin-left: 8px;
padding: 2px 6px;
background-color: rgba(66, 133, 244, 0.1);
border-radius: 4px;
}
.menu-section {
width: 100%;
margin-bottom: 12px;
background-color: #fff;
overflow: hidden;
flex-shrink: 0;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
}
.menu-item {
.stat-value,
.stat-label,
.section-title,
.section-subtitle,
.quick-title,
.quick-desc {
display: block;
}
.stat-value {
color: #20273a;
font-size: 34rpx;
font-weight: 800;
line-height: 1.2;
}
.stat-label {
margin-top: 8rpx;
color: #8c96a8;
font-size: 22rpx;
}
.section-block {
margin-bottom: 22rpx;
}
.section-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
transition: background-color 0.2s;
&:last-child {
border-bottom: none;
}
padding: 0 4rpx 14rpx;
}
.menu-item-hover {
background-color: #f9f9f9;
.section-title {
color: #20273a;
font-size: 31rpx;
font-weight: 800;
}
.item-text {
font-size: 16px;
color: #333;
.section-subtitle {
color: #8c96a8;
font-size: 22rpx;
}
.highlight {
color: #4CAF50;
font-weight: 500;
.quick-grid {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.arrow {
color: #ccc;
font-size: 22px;
font-weight: 300;
.quick-card {
width: calc(50% - 8rpx);
min-height: 150rpx;
box-sizing: border-box;
padding: 22rpx;
border-radius: 24rpx;
background:
linear-gradient(145deg, rgba(255, 255, 255, 0.82), rgba(255, 248, 252, 0.62)),
rgba(255, 255, 255, 0.64);
border: 1rpx solid rgba(255, 255, 255, 0.88);
box-shadow: 0 14rpx 32rpx rgba(93, 107, 139, 0.07);
}
.card-hover {
transform: scale(0.98);
}
.quick-icon {
width: 56rpx;
height: 56rpx;
line-height: 56rpx;
margin-bottom: 16rpx;
border-radius: 18rpx;
text-align: center;
color: #fff;
font-size: 24rpx;
font-weight: 800;
background: linear-gradient(135deg, #ff78a2 0%, #83c6ff 100%);
}
.quick-title {
color: #263044;
font-size: 27rpx;
font-weight: 800;
}
.quick-desc {
margin-top: 8rpx;
color: #8c96a8;
font-size: 21rpx;
line-height: 1.35;
}
.button-container {
@ -250,30 +324,27 @@ page {
justify-content: center;
align-items: center;
width: 100%;
padding: 10px 0;
}
.logout-section {
margin-top: auto;
padding: 0;
overflow: hidden;
padding: 18rpx 0 40rpx;
}
.logout-button {
width: 100%;
background-color: #fff;
color: #ff3b30;
font-size: 16px;
font-weight: 500;
padding: 16px 0;
border-radius: 0;
text-align: center;
border: none;
transition: all 0.2s;
line-height: 1;
height: 86rpx;
line-height: 86rpx;
border-radius: 999rpx;
color: #d94f75;
font-size: 28rpx;
font-weight: 700;
background: rgba(255, 255, 255, 0.74);
border: 1rpx solid rgba(255, 255, 255, 0.9);
box-shadow: 0 14rpx 30rpx rgba(83, 96, 130, 0.07);
}
.logout-button-hover {
background-color: #fff0f0;
.logout-button::after {
border: 0;
}
</style>
.button-hover {
opacity: 0.76;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -1,446 +1,324 @@
<script setup lang="ts">
import TuiTabs from "@/components/thorui/tui-tabs/tui-tabs.vue";
import { ref, computed, onMounted } from "vue";
import { computed, onMounted, ref } from "vue";
//
const tabs = ref([
{ name: "全部" },
{ name: "进行中" },
{ name: "已完成" }
]);
import PlanCard from "@/components/planning/PlanCard.vue";
import { MOCK_PLAN_LIST, PLAN_STATUS_TABS, type PlanItem } from "@/config/planning";
//
const currentTab = ref(0);
//
interface PlanData {
id: string;
phoneNumber: string;
planType: string;
planCycle: string;
startTime: string;
status: 'inProgress' | 'completed'; //
}
// ()
const mockAllPlanData: PlanData[] = [
{
id: 'P001',
phoneNumber: '1386****123',
planType: '健身计划',
planCycle: '7天',
startTime: '2023-06-01',
status: 'inProgress'
},
{
id: 'P002',
phoneNumber: '1590****456',
planType: '学习计划',
planCycle: '1个月',
startTime: '2023-05-15',
status: 'inProgress'
},
{
id: 'P003',
phoneNumber: '1377****789',
planType: '阅读计划',
planCycle: '半个月',
startTime: '2023-05-20',
status: 'completed'
},
{
id: 'P004',
phoneNumber: '1865****234',
planType: '工作计划',
planCycle: '1天',
startTime: '2023-06-05',
status: 'inProgress'
},
{
id: 'P005',
phoneNumber: '1398****567',
planType: '旅行计划',
planCycle: '7天',
startTime: '2023-04-10',
status: 'completed'
},
{
id: 'P006',
phoneNumber: '1592****890',
planType: '饮食计划',
planCycle: '1个月',
startTime: '2023-05-01',
status: 'completed'
},
{
id: 'P007',
phoneNumber: '1356****345',
planType: '写作计划',
planCycle: '半个月',
startTime: '2023-06-10',
status: 'inProgress'
},
{
id: 'P008',
phoneNumber: '1867****678',
planType: '省钱计划',
planCycle: '1个月',
startTime: '2023-05-25',
status: 'inProgress'
},
//
{
id: 'P009',
phoneNumber: '1377****111',
planType: '学英语计划',
planCycle: '1个月',
startTime: '2023-06-15',
status: 'inProgress'
},
{
id: 'P010',
phoneNumber: '1399****222',
planType: '早起计划',
planCycle: '7天',
startTime: '2023-06-20',
status: 'inProgress'
},
{
id: 'P011',
phoneNumber: '1566****333',
planType: '跑步计划',
planCycle: '半个月',
startTime: '2023-05-30',
status: 'inProgress'
},
{
id: 'P012',
phoneNumber: '1866****444',
planType: '节食计划',
planCycle: '1个月',
startTime: '2023-05-01',
status: 'completed'
},
{
id: 'P013',
phoneNumber: '1388****555',
planType: '学习编程计划',
planCycle: '1个月',
startTime: '2023-04-15',
status: 'completed'
},
{
id: 'P014',
phoneNumber: '1599****666',
planType: '看电影计划',
planCycle: '7天',
startTime: '2023-06-25',
status: 'inProgress'
},
{
id: 'P015',
phoneNumber: '1366****777',
planType: '睡眠计划',
planCycle: '半个月',
startTime: '2023-06-01',
status: 'inProgress'
},
{
id: 'P016',
phoneNumber: '1877****888',
planType: '减肥计划',
planCycle: '1个月',
startTime: '2023-05-10',
status: 'completed'
},
{
id: 'P017',
phoneNumber: '1399****999',
planType: '存钱计划',
planCycle: '1个月',
startTime: '2023-06-01',
status: 'inProgress'
},
{
id: 'P018',
phoneNumber: '1590****000',
planType: '阅读计划',
planCycle: '半个月',
startTime: '2023-05-15',
status: 'completed'
}
];
//
const pageSize = 10; // 10
const currentPage = ref(1);
const planList = ref<PlanItem[]>([]);
const isLoading = ref(false);
const hasMore = ref(true);
const currentPage = ref(1);
const pageSize = 4;
//
const allPlanData = ref<PlanData[]>([]);
const currentTabValue = computed(() => PLAN_STATUS_TABS[currentTab.value].value);
const filteredSource = computed(() => {
if (currentTabValue.value === "all") return MOCK_PLAN_LIST;
return MOCK_PLAN_LIST.filter((item) => item.status === currentTabValue.value);
});
const loadData = (page = 1, reset = false) => {
if (isLoading.value) return;
//
const loadData = (page: number, tabIndex: number, refresh: boolean = false) => {
isLoading.value = true;
//
let filteredSource;
if (tabIndex === 0) {
//
filteredSource = mockAllPlanData;
} else if (tabIndex === 1) {
//
filteredSource = mockAllPlanData.filter(item => item.status === 'inProgress');
} else {
//
filteredSource = mockAllPlanData.filter(item => item.status === 'completed');
}
//
setTimeout(() => {
const start = (page - 1) * pageSize;
const end = start + pageSize;
const newData = filteredSource.slice(start, end);
//
if (refresh) {
allPlanData.value = newData;
} else {
allPlanData.value = [...allPlanData.value, ...newData];
}
//
const nextList = filteredSource.value.slice(start, end);
planList.value = reset ? nextList : [...planList.value, ...nextList];
currentPage.value = page;
hasMore.value = end < filteredSource.length;
hasMore.value = end < filteredSource.value.length;
isLoading.value = false;
}, 500);
}, 400);
};
//
const changeTab = (e: { index: number }) => {
currentTab.value = e.index;
//
const changeTab = (index: number) => {
currentTab.value = index;
currentPage.value = 1;
loadData(1, e.index, true);
hasMore.value = true;
loadData(1, true);
};
//
const onLoadMore = () => {
if (!isLoading.value && hasMore.value) {
loadData(currentPage.value + 1, currentTab.value);
loadData(currentPage.value + 1);
}
};
//
const filteredPlanData = computed(() => {
return allPlanData.value;
});
//
onMounted(() => {
//
loadData(1, currentTab.value, true);
loadData(1, true);
});
</script>
<template>
<view class="container">
<!-- 标签页 -->
<tui-tabs :tabs="tabs" :isFixed="false" :currentTab="currentTab" itemWidth="33.3%" @change="changeTab"></tui-tabs>
<!-- 计划列表 -->
<scroll-view
class="plan-list-scroll"
scroll-y
@scrolltolower="onLoadMore"
v-if="filteredPlanData.length > 0 || isLoading"
>
<view class="plan-list">
<view
v-for="(item, index) in filteredPlanData"
:key="index"
class="plan-card"
:class="{'plan-card-in-progress': item.status === 'inProgress', 'plan-card-completed': item.status === 'completed'}"
>
<view class="plan-header">
<text class="plan-type">{{ item.planType }}</text>
<text class="plan-id">ID: {{ item.id }}</text>
</view>
<view class="plan-divider"></view>
<view class="plan-info">
<view class="info-row">
<text class="info-label">手机号</text>
<text class="info-value">{{ item.phoneNumber }}</text>
</view>
<view class="info-row">
<text class="info-label">计划周期</text>
<text class="info-value">{{ item.planCycle }}</text>
</view>
<view class="info-row">
<text class="info-label">开始时间</text>
<text class="info-value">{{ item.startTime }}</text>
</view>
</view>
<view class="planned-page">
<view class="dream-bg dream-bg-one"></view>
<view class="dream-bg dream-bg-two"></view>
<view class="page-header">
<view>
<text class="page-title">计划列表</text>
<text class="page-subtitle">查看正在执行的周期传话计划</text>
</view>
</view>
<view class="plan-tabs">
<view
v-for="(tab, index) in PLAN_STATUS_TABS"
:key="tab.value"
class="plan-tab"
:class="{ active: currentTab === index }"
@tap="changeTab(index)"
>
<text>{{ tab.name }}</text>
</view>
</view>
<scroll-view class="plan-scroll" scroll-y @scrolltolower="onLoadMore">
<view class="plan-list" v-if="planList.length > 0">
<plan-card v-for="plan in planList" :key="plan.id" :plan="plan" />
<view v-if="isLoading" class="loading-state">
<view class="loading-dot"></view>
<view class="loading-dot"></view>
<view class="loading-dot"></view>
</view>
<!-- 加载状态提示 -->
<view class="loading-more" v-if="isLoading">
<view class="loading-icon"></view>
<text class="loading-text">加载中...</text>
</view>
<view class="no-more" v-if="!hasMore && !isLoading && filteredPlanData.length > 0">
<text class="no-more-text">--- 已经到底了 ---</text>
<view v-if="!hasMore && !isLoading" class="no-more">
<view class="no-more-line"></view>
<text class="no-more-text">已经到底了</text>
<view class="no-more-line"></view>
</view>
</view>
<view class="empty-state" v-else-if="!isLoading">
<view class="empty-card">
<view class="empty-icon"></view>
<text class="empty-title">暂无计划</text>
<text class="empty-desc">创建后会在这里看到计划执行状态</text>
</view>
</view>
<view v-else class="loading-state first-loading">
<view class="loading-dot"></view>
<view class="loading-dot"></view>
<view class="loading-dot"></view>
</view>
</scroll-view>
<!-- 空数据状态 -->
<view class="empty-state" v-if="filteredPlanData.length === 0 && !isLoading">
<text class="empty-text">暂无相关计划数据</text>
</view>
</view>
</template>
<style scoped>
.container {
<style scoped lang="scss">
.planned-page {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #f5f5f5;
box-sizing: border-box;
background:
linear-gradient(180deg, rgba(255, 244, 250, 0.98) 0%, rgba(239, 248, 255, 0.96) 48%, #f7f8fb 100%),
linear-gradient(135deg, rgba(255, 204, 225, 0.22), rgba(199, 233, 255, 0.24));
position: relative;
overflow: hidden;
}
.plan-list-scroll {
.dream-bg {
position: fixed;
border-radius: 50%;
pointer-events: none;
z-index: 0;
}
.dream-bg-one {
width: 360rpx;
height: 360rpx;
right: -150rpx;
top: 110rpx;
background: rgba(255, 193, 215, 0.23);
}
.dream-bg-two {
width: 330rpx;
height: 330rpx;
left: -140rpx;
top: 650rpx;
background: rgba(171, 218, 255, 0.21);
}
.page-header,
.plan-tabs,
.plan-scroll {
position: relative;
z-index: 1;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx 28rpx 18rpx;
}
.page-title,
.page-subtitle,
.empty-title,
.empty-desc {
display: block;
}
.page-title {
color: #20273a;
font-size: 44rpx;
font-weight: 700;
line-height: 1.2;
}
.page-subtitle {
margin-top: 10rpx;
color: #687286;
font-size: 25rpx;
line-height: 1.45;
}
.plan-tabs {
display: flex;
margin: 0 24rpx 18rpx;
padding: 8rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.62);
border: 1rpx solid rgba(255, 255, 255, 0.86);
box-shadow: 0 10rpx 26rpx rgba(83, 96, 130, 0.05);
}
.plan-tab {
flex: 1;
height: calc(100vh - 100rpx);
height: 62rpx;
line-height: 62rpx;
text-align: center;
border-radius: 999rpx;
color: #7b8493;
font-size: 24rpx;
font-weight: 600;
}
.plan-tab.active {
color: #fff;
background: linear-gradient(135deg, #ff78a2 0%, #83c6ff 100%);
box-shadow: 0 10rpx 20rpx rgba(255, 120, 162, 0.18);
}
.plan-scroll {
flex: 1;
height: 0;
}
.plan-list {
padding: 0 24rpx 32rpx;
}
.loading-state {
display: flex;
flex-direction: column;
padding: 20rpx;
gap: 20rpx;
}
.plan-card {
border-radius: 12rpx;
padding: 24rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.05);
background-color: #fff;
}
.plan-card-in-progress {
background-color: #e6f7ff; /* 浅蓝色背景表示进行中 */
}
.plan-card-completed {
background-color: #f6ffed; /* 浅绿色背景表示已完成 */
}
.plan-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
}
.plan-type {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.plan-id {
font-size: 24rpx;
color: #999;
}
.plan-divider {
height: 1px;
background-color: rgba(0, 0, 0, 0.06);
margin: 12rpx 0 16rpx;
}
.plan-info {
display: flex;
flex-direction: column;
gap: 10rpx;
}
.info-row {
display: flex;
line-height: 1.6;
}
.info-label {
font-size: 28rpx;
color: #666;
min-width: 160rpx;
}
.info-value {
font-size: 28rpx;
color: #333;
flex: 1;
}
/* 加载更多样式 */
.loading-more {
display: flex;
justify-content: center;
align-items: center;
padding: 30rpx 0;
padding: 38rpx 0;
}
.loading-icon {
width: 40rpx;
height: 40rpx;
border: 3rpx solid #f0f0f0;
border-top: 3rpx solid #07c160;
.first-loading {
min-height: 50vh;
}
.loading-dot {
width: 14rpx;
height: 14rpx;
margin: 0 7rpx;
border-radius: 50%;
margin-right: 10rpx;
animation: spin 1s linear infinite;
background: linear-gradient(135deg, #ff92b6, #8bcfff);
animation: loadingDot 1.4s infinite ease-in-out both;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
.loading-dot:nth-child(1) {
animation-delay: -0.32s;
}
.loading-text {
font-size: 26rpx;
color: #999;
.loading-dot:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes loadingDot {
0%,
80%,
100% {
transform: scale(0);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
.no-more {
text-align: center;
padding: 30rpx 0;
display: flex;
align-items: center;
justify-content: center;
padding: 24rpx 0 34rpx;
}
.no-more-line {
width: 120rpx;
height: 1rpx;
background: linear-gradient(90deg, transparent, rgba(153, 170, 190, 0.35));
}
.no-more-line:last-child {
background: linear-gradient(90deg, rgba(153, 170, 190, 0.35), transparent);
}
.no-more-text {
font-size: 26rpx;
color: #999;
margin: 0 18rpx;
color: #9aa3b2;
font-size: 22rpx;
}
.empty-state {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 70rpx 24rpx;
}
.empty-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
text-align: center;
padding: 68rpx 34rpx;
border-radius: 28rpx;
background: rgba(255, 255, 255, 0.74);
border: 1rpx solid rgba(255, 255, 255, 0.9);
box-shadow: 0 18rpx 42rpx rgba(93, 107, 139, 0.08);
}
.empty-text {
font-size: 28rpx;
color: #999;
.empty-icon {
width: 96rpx;
height: 96rpx;
line-height: 96rpx;
margin-bottom: 24rpx;
border-radius: 50%;
text-align: center;
color: #fff;
font-size: 34rpx;
font-weight: 700;
background: linear-gradient(135deg, #ff78a2 0%, #83c6ff 100%);
}
</style>
.empty-title {
color: #263044;
font-size: 32rpx;
font-weight: 700;
}
.empty-desc {
margin-top: 10rpx;
color: #8c96a8;
font-size: 24rpx;
}
</style>

306
src/pages/reply/detail.vue Normal file
View File

@ -0,0 +1,306 @@
<template>
<view class="detail-page">
<view class="dream-bg dream-bg-one"></view>
<view class="dream-bg dream-bg-two"></view>
<view class="detail-header">
<view class="back-button" hover-class="button-hover" @tap="goBack"></view>
<view>
<text class="detail-title">回复详情</text>
<text class="detail-subtitle">{{ maskedContact }} · {{ replyTime }}</text>
</view>
</view>
<view class="chat-panel">
<view class="chat-time">{{ replyTime }}</view>
<view class="message-row sent-row">
<view class="message-meta">
<text>发送内容</text>
</view>
<view class="chat-bubble sent-bubble">
<text>{{ currentReply.originalMessage }}</text>
</view>
</view>
<view class="message-row reply-row">
<view class="reply-avatar"></view>
<view>
<view class="message-meta reply-meta">
<text>回复内容</text>
</view>
<view class="chat-bubble reply-bubble">
<text>{{ currentReply.replyMessage }}</text>
</view>
</view>
</view>
</view>
<view class="info-panel">
<view class="info-row">
<text class="info-label">联系人</text>
<text class="info-value">{{ maskedContact }}</text>
</view>
<view class="info-row">
<text class="info-label">回复时间</text>
<text class="info-value">{{ replyTime }}</text>
</view>
<view class="info-row">
<text class="info-label">记录编号</text>
<text class="info-value">#{{ currentReply.id }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { formatMailTime, maskRecipient } from "@/config/mail";
import { deserializeReply, type ReplyItem } from "@/config/reply";
const fallbackReply: ReplyItem = {
id: "RP000000000",
contactPhone: "13800138000",
originalMessage: "这是一条原短信内容。",
replyMessage: "这是一条回复内容。",
replyTime: "2026-05-03 20:12:00",
source: "sms",
unread: false,
};
const reply = ref<ReplyItem>(fallbackReply);
onLoad((query) => {
const detail = deserializeReply(query?.reply as string | undefined);
if (detail) {
reply.value = detail;
}
});
const currentReply = computed(() => reply.value);
const maskedContact = computed(() => maskRecipient(currentReply.value.contactPhone));
const replyTime = computed(() => formatMailTime(currentReply.value.replyTime));
const goBack = () => {
uni.navigateBack({
delta: 1,
fail: () => {
uni.navigateTo({
url: "/pages/reply/index",
});
},
});
};
</script>
<style scoped lang="scss">
.detail-page {
min-height: 100vh;
box-sizing: border-box;
padding: 28rpx 26rpx 48rpx;
background:
linear-gradient(180deg, rgba(255, 244, 250, 0.98) 0%, rgba(239, 248, 255, 0.96) 48%, #f7f8fb 100%),
linear-gradient(135deg, rgba(255, 204, 225, 0.22), rgba(199, 233, 255, 0.24));
position: relative;
overflow: hidden;
}
.dream-bg {
position: fixed;
border-radius: 50%;
pointer-events: none;
z-index: 0;
}
.dream-bg-one {
width: 360rpx;
height: 360rpx;
right: -160rpx;
top: 110rpx;
background: rgba(255, 188, 214, 0.24);
}
.dream-bg-two {
width: 320rpx;
height: 320rpx;
left: -140rpx;
top: 620rpx;
background: rgba(167, 218, 255, 0.22);
}
.detail-header,
.chat-panel,
.info-panel {
position: relative;
z-index: 1;
}
.detail-header {
display: flex;
align-items: center;
gap: 18rpx;
margin-bottom: 24rpx;
}
.back-button {
width: 68rpx;
height: 68rpx;
line-height: 62rpx;
text-align: center;
border-radius: 50%;
color: #293247;
font-size: 54rpx;
background: rgba(255, 255, 255, 0.78);
border: 1rpx solid rgba(255, 255, 255, 0.9);
box-shadow: 0 10rpx 24rpx rgba(83, 96, 130, 0.07);
}
.button-hover {
opacity: 0.72;
}
.detail-title,
.detail-subtitle,
.info-label,
.info-value {
display: block;
}
.detail-title {
color: #20273a;
font-size: 42rpx;
font-weight: 700;
line-height: 1.2;
}
.detail-subtitle {
margin-top: 8rpx;
color: #687286;
font-size: 24rpx;
line-height: 1.4;
}
.chat-panel {
min-height: 520rpx;
box-sizing: border-box;
padding: 26rpx 20rpx 30rpx;
border-radius: 30rpx;
background:
linear-gradient(180deg, rgba(247, 251, 255, 0.8), rgba(255, 248, 252, 0.72)),
rgba(255, 255, 255, 0.64);
border: 1rpx solid rgba(255, 255, 255, 0.9);
box-shadow: 0 18rpx 42rpx rgba(93, 107, 139, 0.08);
}
.chat-time {
width: 310rpx;
margin: 0 auto 30rpx;
padding: 8rpx 16rpx;
border-radius: 999rpx;
color: #9aa3b2;
font-size: 22rpx;
text-align: center;
background: rgba(238, 243, 250, 0.88);
}
.message-row {
display: flex;
margin-bottom: 30rpx;
}
.sent-row {
flex-direction: column;
align-items: flex-end;
}
.reply-row {
align-items: flex-start;
}
.message-meta {
margin-bottom: 10rpx;
color: #9aa3b2;
font-size: 22rpx;
}
.reply-meta {
margin-left: 2rpx;
}
.chat-bubble {
max-width: 560rpx;
box-sizing: border-box;
padding: 22rpx 24rpx;
border-radius: 28rpx;
font-size: 29rpx;
line-height: 1.68;
word-break: break-all;
}
.sent-bubble {
color: #fff;
border-bottom-right-radius: 8rpx;
background: linear-gradient(135deg, #ff78a2 0%, #83c6ff 100%);
box-shadow: 0 12rpx 28rpx rgba(255, 120, 162, 0.16);
}
.reply-avatar {
width: 58rpx;
height: 58rpx;
line-height: 58rpx;
margin-top: 34rpx;
margin-right: 12rpx;
border-radius: 50%;
color: #fff;
font-size: 22rpx;
font-weight: 700;
text-align: center;
background: linear-gradient(135deg, #ff78a2 0%, #83c6ff 100%);
}
.reply-bubble {
color: #333b4f;
border-bottom-left-radius: 8rpx;
background: #fff;
border: 1rpx solid rgba(232, 238, 248, 0.88);
box-shadow: 0 10rpx 24rpx rgba(93, 107, 139, 0.06);
}
.info-panel {
margin-top: 24rpx;
padding: 8rpx 26rpx;
border-radius: 28rpx;
background: rgba(255, 255, 255, 0.74);
border: 1rpx solid rgba(255, 255, 255, 0.9);
box-shadow: 0 18rpx 42rpx rgba(93, 107, 139, 0.08);
}
.info-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24rpx;
min-height: 82rpx;
border-bottom: 1rpx solid rgba(230, 236, 246, 0.72);
}
.info-row:last-child {
border-bottom: 0;
}
.info-label {
flex-shrink: 0;
color: #8c96a8;
font-size: 24rpx;
}
.info-value {
color: #263044;
font-size: 25rpx;
font-weight: 600;
text-align: right;
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff