This commit is contained in:
Quincy_J 2025-07-29 20:18:40 +08:00
parent 0daa6f2a7a
commit 29734666a6
136 changed files with 22942 additions and 0 deletions

106
.commitlintrc.cjs Normal file
View File

@ -0,0 +1,106 @@
const fs = require('fs')
const path = require('path')
const { execSync } = require('child_process')
const scopes = fs
.readdirSync(path.resolve(__dirname, 'src'), { withFileTypes: true })
.filter((dirent) => dirent.isDirectory())
.map((dirent) => dirent.name.replace(/s$/, ''))
// precomputed scope
const scopeComplete = execSync('git status --porcelain || true')
.toString()
.trim()
.split('\n')
.find((r) => ~r.indexOf('M src'))
?.replace(/(\/)/g, '%%')
?.match(/src%%((\w|-)*)/)?.[1]
?.replace(/s$/, '')
module.exports = {
ignores: [(commit) => commit.includes('init')],
extends: ['@commitlint/config-conventional'],
rules: {
'body-leading-blank': [2, 'always'],
'footer-leading-blank': [1, 'always'],
'header-max-length': [2, 'always', 108],
'subject-empty': [2, 'never'],
'type-empty': [2, 'never'],
'subject-case': [0],
'type-enum': [
2,
'always',
[
'feat',
'fix',
'perf',
'style',
'docs',
'test',
'refactor',
'build',
'ci',
'chore',
'revert',
'wip',
'workflow',
'types',
'release',
],
],
},
prompt: {
/** @use `pnpm commit :f` */
alias: {
f: 'docs: fix typos',
r: 'docs: update README',
s: 'style: update code format',
b: 'build: bump dependencies',
c: 'chore: update config',
},
customScopesAlign: !scopeComplete ? 'top' : 'bottom',
defaultScope: scopeComplete,
scopes: [...scopes, 'mock'],
allowEmptyIssuePrefixs: false,
allowCustomIssuePrefixs: false,
// English
typesAppend: [
{ value: 'wip', name: 'wip: work in process' },
{ value: 'workflow', name: 'workflow: workflow improvements' },
{ value: 'types', name: 'types: type definition file changes' },
],
// 中英文对照版
// messages: {
// type: '选择你要提交的类型 :',
// scope: '选择一个提交范围 (可选):',
// customScope: '请输入自定义的提交范围 :',
// subject: '填写简短精炼的变更描述 :\n',
// body: '填写更加详细的变更描述 (可选)。使用 "|" 换行 :\n',
// breaking: '列举非兼容性重大的变更 (可选)。使用 "|" 换行 :\n',
// footerPrefixsSelect: '选择关联issue前缀 (可选):',
// customFooterPrefixs: '输入自定义issue前缀 :',
// footer: '列举关联issue (可选) 例如: #31, #I3244 :\n',
// confirmCommit: '是否提交或修改commit ?',
// },
// types: [
// { value: 'feat', name: 'feat: 新增功能' },
// { value: 'fix', name: 'fix: 修复缺陷' },
// { value: 'docs', name: 'docs: 文档变更' },
// { value: 'style', name: 'style: 代码格式' },
// { value: 'refactor', name: 'refactor: 代码重构' },
// { value: 'perf', name: 'perf: 性能优化' },
// { value: 'test', name: 'test: 添加疏漏测试或已有测试改动' },
// { value: 'build', name: 'build: 构建流程、外部依赖变更 (如升级 npm 包、修改打包配置等)' },
// { value: 'ci', name: 'ci: 修改 CI 配置、脚本' },
// { value: 'revert', name: 'revert: 回滚 commit' },
// { value: 'chore', name: 'chore: 对构建过程或辅助工具和库的更改 (不影响源文件、测试用例)' },
// { value: 'wip', name: 'wip: 正在开发中' },
// { value: 'workflow', name: 'workflow: 工作流程改进' },
// { value: 'types', name: 'types: 类型定义文件修改' },
// ],
// emptyScopesAlias: 'empty: 不填写',
// customScopesAlias: 'custom: 自定义',
},
}

68
.cusorrules Normal file
View File

@ -0,0 +1,68 @@
每一次会话请求结束后进行会话总结,
无论生成新文件还是修改已有文件都需要做总结,
并将总结内容Append写入到Readme.md文件中(说明文件中的内容是累积增加的)。
总结内容应包括:
-会话的主要目的
-完成的主要任务
-关键决策和解决方案
-使用的技术栈
-修改了哪些文件
-文件的修改内容
// 项目技术栈和目录结构
项目技术栈:
- 前端框架Vue 3.4.21
- 构建工具Vite 5.2.8
- 包管理器pnpm 9.15.4
- 状态管理Pinia 2.0.36
- UI组件库wot-design-uni 1.4.0
- 网络请求luch-request 3.1.1
- 工具库:
- dayjs 1.11.10
- qs 6.5.3
- @tanstack/vue-query 5.62.16
- 样式处理:
- UnoCSS 0.58.9
- SASS 1.77.8
- PostCSS 8.4.49
- 代码规范:
- ESLint
- Prettier
- StyleLint
- TypeScript 5.7.2
- CommitLint
项目目录结构:
├── src/ # 源代码目录
│ ├── components/ # 公共组件
│ ├── hooks/ # 自定义 hooks
│ ├── interceptors/ # 拦截器
│ ├── layouts/ # 布局组件
│ ├── pages/ # 页面
│ ├── pages-sub/ # 分包页面
│ ├── service/ # API 服务
│ ├── static/ # 静态资源
│ ├── store/ # 状态管理
│ ├── style/ # 全局样式
│ ├── types/ # TypeScript 类型定义
│ ├── uni_modules/ # uni-app 模块
│ ├── utils/ # 工具函数
│ ├── App.vue # 应用入口组件
│ ├── main.ts # 应用入口文件
│ ├── manifest.json # 应用配置文件
│ ├── pages.json # 页面路由配置
│ └── uni.scss # 全局样式变量
├── vite-plugins/ # Vite 插件
├── scripts/ # 脚本文件
├── env/ # 环境配置
├── .github/ # GitHub 配置
├── .husky/ # Git hooks
├── .vscode/ # VS Code 配置
└── screenshots/ # 截图目录

13
.editorconfig Normal file
View File

@ -0,0 +1,13 @@
root = true
[*] # 表示所有文件适用
charset = utf-8 # 设置文件字符集为 utf-8
indent_style = space # 缩进风格tab | space
indent_size = 2 # 缩进大小
end_of_line = lf # 控制换行类型(lf | cr | crlf)
trim_trailing_whitespace = true # 去除行首的任意空白字符
insert_final_newline = true # 始终在文件末尾插入一个新行
[*.md] # 表示仅 md 文件适用以下规则
max_line_length = off # 关闭最大行长度限制
trim_trailing_whitespace = false # 关闭末尾空格修剪

1
.eslintignore Normal file
View File

@ -0,0 +1 @@
src/uni_modules/

101
.eslintrc-auto-import.json Normal file
View File

@ -0,0 +1,101 @@
{
"globals": {
"Component": true,
"ComponentPublicInstance": true,
"ComputedRef": true,
"EffectScope": true,
"ExtractDefaultPropTypes": true,
"ExtractPropTypes": true,
"ExtractPublicPropTypes": true,
"InjectionKey": true,
"PropType": true,
"Ref": true,
"VNode": true,
"WritableComputedRef": true,
"computed": true,
"createApp": true,
"customRef": true,
"defineAsyncComponent": true,
"defineComponent": true,
"effectScope": true,
"getCurrentInstance": true,
"getCurrentScope": true,
"h": true,
"inject": true,
"isProxy": true,
"isReactive": true,
"isReadonly": true,
"isRef": true,
"markRaw": true,
"nextTick": true,
"onActivated": true,
"onAddToFavorites": true,
"onBackPress": true,
"onBeforeMount": true,
"onBeforeUnmount": true,
"onBeforeUpdate": true,
"onDeactivated": true,
"onError": true,
"onErrorCaptured": true,
"onHide": true,
"onLaunch": true,
"onLoad": true,
"onMounted": true,
"onNavigationBarButtonTap": true,
"onNavigationBarSearchInputChanged": true,
"onNavigationBarSearchInputClicked": true,
"onNavigationBarSearchInputConfirmed": true,
"onNavigationBarSearchInputFocusChanged": true,
"onPageNotFound": true,
"onPageScroll": true,
"onPullDownRefresh": true,
"onReachBottom": true,
"onReady": true,
"onRenderTracked": true,
"onRenderTriggered": true,
"onResize": true,
"onScopeDispose": true,
"onServerPrefetch": true,
"onShareAppMessage": true,
"onShareTimeline": true,
"onShow": true,
"onTabItemTap": true,
"onThemeChange": true,
"onUnhandledRejection": true,
"onUnload": true,
"onUnmounted": true,
"onUpdated": true,
"provide": true,
"reactive": true,
"readonly": true,
"ref": true,
"resolveComponent": true,
"shallowReactive": true,
"shallowReadonly": true,
"shallowRef": true,
"toRaw": true,
"toRef": true,
"toRefs": true,
"toValue": true,
"triggerRef": true,
"unref": true,
"useAttrs": true,
"useCssModule": true,
"useCssVars": true,
"useRequest": true,
"useSlots": true,
"useUpload": true,
"useUpload2": true,
"watch": true,
"watchEffect": true,
"watchPostEffect": true,
"watchSyncEffect": true,
"DirectiveBinding": true,
"MaybeRef": true,
"MaybeRefOrGetter": true,
"onWatcherCleanup": true,
"useId": true,
"useModel": true,
"useTemplateRef": true
}
}

97
.eslintrc.cjs Normal file
View File

@ -0,0 +1,97 @@
module.exports = {
env: {
browser: true,
es2021: true,
node: true,
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:vue/vue3-essential',
// eslint-plugin-import 插件, @see https://www.npmjs.com/package/eslint-plugin-import
'plugin:import/recommended',
// eslint-config-airbnb-base 插件 已经改用 eslint-config-standard 插件
'standard',
// 1. 接入 prettier 的规则
'prettier',
'plugin:prettier/recommended',
'./.eslintrc-auto-import.json',
],
overrides: [
{
env: {
node: true,
},
files: ['.eslintrc.{js,cjs}'],
parserOptions: {
sourceType: 'script',
},
},
],
parserOptions: {
ecmaVersion: 'latest',
parser: '@typescript-eslint/parser',
sourceType: 'module',
},
plugins: [
'@typescript-eslint',
'vue',
// 2. 加入 prettier 的 eslint 插件
'prettier',
// eslint-import-resolver-typescript 插件,@see https://www.npmjs.com/package/eslint-import-resolver-typescript
'import',
],
rules: {
// 3. 注意要加上这一句,开启 prettier 自动修复的功能
'prettier/prettier': 'error',
// turn on errors for missing imports
'import/no-unresolved': 'off',
// 对后缀的检测,否则 import 一个ts文件也会报错需要手动添加'.ts', 增加了下面的配置后就不用了
'import/extensions': [
'error',
'ignorePackages',
{ js: 'never', jsx: 'never', ts: 'never', tsx: 'never' },
],
// 只允许1个默认导出关闭否则不能随意export xxx
'import/prefer-default-export': ['off'],
'no-console': ['off'],
// 'no-unused-vars': ['off'],
// '@typescript-eslint/no-unused-vars': ['off'],
// 解决vite.config.ts报错问题
'import/no-extraneous-dependencies': 'off',
'no-plusplus': 'off',
'no-shadow': 'off',
'vue/multi-word-component-names': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'no-underscore-dangle': 'off',
'no-use-before-define': 'off',
'no-undef': 'off',
'no-unused-vars': 'off',
'no-param-reassign': 'off',
'@typescript-eslint/no-unused-vars': 'off',
// 避免 `eslint` 对于 `typescript` 函数重载的误报
'no-redeclare': 'off',
'@typescript-eslint/no-redeclare': 'error',
},
// eslint-import-resolver-typescript 插件,@see https://www.npmjs.com/package/eslint-import-resolver-typescript
settings: {
'import/parsers': {
'@typescript-eslint/parser': ['.ts', '.tsx'],
},
'import/resolver': {
typescript: {},
},
},
globals: {
$t: true,
uni: true,
UniApp: true,
wx: true,
WechatMiniprogram: true,
getCurrentPages: true,
UniHelper: true,
Page: true,
App: true,
NodeJS: true,
},
}

42
.gitignore vendored Normal file
View File

@ -0,0 +1,42 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
*.local
# Editor directories and files
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.hbuilderx
.stylelintcache
.eslintcache
docs/.vitepress/dist
docs/.vitepress/cache
# lock 文件还是不要了,我主要的版本写死就好了
# pnpm-lock.yaml
# package-lock.json
# TIPS如果某些文件已经加入了版本管理现在重新加入 .gitignore 是不生效的,需要执行下面的操作
# `git rm -r --cached .` 然后提交 commit 即可。
# git rm -r --cached file1 file2 ## 针对某些文件
# git rm -r --cached dir1 dir2 ## 针对某些文件夹
# git rm -r --cached . ## 针对所有文件
# 更新 uni-app 官方版本
# npx @dcloudio/uvm@latest

6
.npmrc Normal file
View File

@ -0,0 +1,6 @@
# registry = https://registry.npmjs.org
registry = https://registry.npmmirror.com
strict-peer-dependencies=false
auto-install-peers=true
shamefully-hoist=true

12
.prettierignore Normal file
View File

@ -0,0 +1,12 @@
# unplugin-auto-import 生成的类型文件,每次提交都改变,所以加入这里吧,与 .gitignore 配合使用
auto-import.d.ts
# vite-plugin-uni-pages 生成的类型文件,每次切换分支都一堆不同的,所以直接 .gitignore
uni-pages.d.ts
# 插件生成的文件
src/pages.json
src/manifest.json
# 忽略自动生成文件
src/service/app/**

19
.prettierrc.cjs Normal file
View File

@ -0,0 +1,19 @@
// @see https://prettier.io/docs/en/options
module.exports = {
singleQuote: true,
printWidth: 100,
tabWidth: 2,
useTabs: false,
semi: false,
trailingComma: 'all',
endOfLine: 'auto',
htmlWhitespaceSensitivity: 'ignore',
overrides: [
{
files: '*.json',
options: {
trailingComma: 'none',
},
},
],
}

1
.stylelintignore Normal file
View File

@ -0,0 +1 @@
src/uni_modules/

58
.stylelintrc.cjs Normal file
View File

@ -0,0 +1,58 @@
// .stylelintrc.cjs
module.exports = {
root: true,
extends: [
// stylelint-config-standard 替换成了更宽松的 stylelint-config-recommended
'stylelint-config-recommended',
// stylelint-config-standard-scss 替换成了更宽松的 stylelint-config-recommended-scss
'stylelint-config-recommended-scss',
'stylelint-config-recommended-vue/scss',
'stylelint-config-html/vue',
'stylelint-config-recess-order',
],
plugins: ['stylelint-prettier'],
overrides: [
// 扫描 .vue/html 文件中的<style>标签内的样式
{
files: ['**/*.{vue,html}'],
customSyntax: 'postcss-html',
},
{
files: ['**/*.{css,scss}'],
customSyntax: 'postcss-scss',
},
],
// 自定义规则
rules: {
'prettier/prettier': true,
// 允许 global 、export 、v-deep等伪类
'selector-pseudo-class-no-unknown': [
true,
{
ignorePseudoClasses: ['global', 'export', 'v-deep', 'deep'],
},
],
'unit-no-unknown': [
true,
{
ignoreUnits: ['rpx'],
},
],
// 处理小程序page标签不认识的问题
'selector-type-no-unknown': [
true,
{
ignoreTypes: ['page'],
},
],
'comment-empty-line-before': 'never', // never|always|always-multi-line|never-multi-line
'custom-property-empty-line-before': 'never',
'no-empty-source': null,
'comment-no-empty': null,
'no-duplicate-selectors': null,
'scss/comment-no-empty': null,
'selector-class-pattern': null,
'font-family-no-missing-generic-family-keyword': null,
},
}

18
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,18 @@
{
"recommendations": [
"vue.volar",
"stylelint.vscode-stylelint",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"antfu.unocss",
"antfu.iconify",
"evils.uniapp-vscode",
"uni-helper.uni-helper-vscode",
"uni-helper.uni-app-schemas-vscode",
"uni-helper.uni-highlight-vscode",
"uni-helper.uni-ui-snippets-vscode",
"uni-helper.uni-app-snippets-vscode",
"mrmlnc.vscode-json5",
"streetsidesoftware.code-spell-checker"
]
}

62
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,62 @@
{
// prettier
"editor.defaultFormatter": "esbenp.prettier-vscode",
//
"editor.formatOnSave": true,
//
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.fixAll.eslint": "explicit",
"source.fixAll.stylelint": "explicit"
},
// stylelint
"stylelint.validate": ["css", "scss", "vue", "html"], // package.jsonscripts
"stylelint.enable": true,
"css.validate": false,
"less.validate": false,
"scss.validate": false,
"[shellscript]": {
"editor.defaultFormatter": "foxundermoon.shell-format"
},
"[dotenv]": {
"editor.defaultFormatter": "foxundermoon.shell-format"
},
"[vue]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
//
"files.associations": {
"pages.json": "jsonc", // pages.json
"manifest.json": "jsonc" // manifest.json
},
"cSpell.words": [
"Aplipay",
"climblee",
"commitlint",
"dcloudio",
"iconfont",
"qrcode",
"refresherrefresh",
"scrolltolower",
"tabbar",
"Toutiao",
"unibest",
"uvui",
"Wechat",
"WechatMiniprogram",
"Weixin"
],
"typescript.tsdk": "node_modules\\typescript\\lib",
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.expand": false,
"explorer.fileNesting.patterns": {
"package.json": "pnpm-lock.yaml,pnpm-workspace.yaml,LICENSE,.gitattributes,.gitignore,.gitpod.yml,CNAME,.npmrc,.browserslistrc",
".eslintrc.cjs": ".eslintignore,.prettierignore,.stylelintignore,.commitlintrc.*,.prettierrc.*,.stylelintrc.*,.eslintrc-auto-import.json,.editorconfig,.commitlint.cjs"
}
}

56
.vscode/vue3.code-snippets vendored Normal file
View File

@ -0,0 +1,56 @@
{
// Place your unibest 工作区 snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and
// description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope
// is left empty or omitted, the snippet gets applied to all languages. The prefix is what is
// used to trigger the snippet and the body will be expanded and inserted. Possible variables are:
// $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders.
// Placeholders with the same ids are connected.
// Example:
// "Print to console": {
// "scope": "javascript,typescript",
// "prefix": "log",
// "body": [
// "console.log('$1');",
// "$2"
// ],
// "description": "Log output to console"
// }
"Print unibest Vue3 SFC": {
"scope": "vue",
"prefix": "v3",
"body": [
"<route lang=\"json5\" type=\"page\">",
"{",
" layout: 'default',",
" style: {",
" navigationBarTitleText: '$1',",
" },",
"}",
"</route>\n",
"<template>",
" <view class=\"\">$2</view>",
"</template>\n",
"<script lang=\"ts\" setup>",
"//$3",
"</script>\n",
"<style lang=\"scss\" scoped>",
"//$4",
"</style>\n",
],
},
"Print unibest style": {
"scope": "vue",
"prefix": "st",
"body": ["<style lang=\"scss\" scoped>", "//", "</style>\n"],
},
"Print unibest script": {
"scope": "vue",
"prefix": "sc",
"body": ["<script lang=\"ts\" setup>", "//$3", "</script>\n"],
},
"Print unibest template": {
"scope": "vue",
"prefix": "te",
"body": ["<template>", " <view class=\"\">$1</view>", "</template>\n"],
},
}

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 菲鸽
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

BIN
art-exam-web-mobile.rar Normal file

Binary file not shown.

19
env/.env vendored Normal file
View File

@ -0,0 +1,19 @@
VITE_APP_TITLE = 'unibest'
VITE_APP_PORT = 9000
VITE_UNI_APPID = 'H57F2ACE4'
VITE_WX_APPID = 'wx25fb5794b1c3026f'
# h5部署网站的base配置到 manifest.config.ts 里的 h5.router.base
VITE_APP_PUBLIC_BASE=/
VITE_UPLOAD_BASEURL = 'https://ukw0y1.laf.run/upload'
# 有些同学可能需要在微信小程序里面根据 develop、trial、release 分别设置上传地址,参考代码如下。
# 下面的变量如果没有设置,会默认使用 VITE_SERVER_BASEURL or VITE_UPLOAD_BASEURL
# h5是否需要配置代理
VITE_APP_PROXY=true
VITE_APP_PROXY_PREFIX = '/base'
VITE_SERVER_BASEURL = 'http://192.168.40.1:8888/'

6
env/.env.development vendored Normal file
View File

@ -0,0 +1,6 @@
# 变量必须以 VITE_ 为前缀才能暴露给外部读取
NODE_ENV = 'development'
# 是否去除console 和 debugger
VITE_DELETE_CONSOLE = false
# 是否开启sourcemap
VITE_SHOW_SOURCEMAP = true

6
env/.env.production vendored Normal file
View File

@ -0,0 +1,6 @@
# 变量必须以 VITE_ 为前缀才能暴露给外部读取
NODE_ENV = 'development'
# 是否去除console 和 debugger
VITE_DELETE_CONSOLE = true
# 是否开启sourcemap
VITE_SHOW_SOURCEMAP = false

4
env/.env.test vendored Normal file
View File

@ -0,0 +1,4 @@
# 变量必须以 VITE_ 为前缀才能暴露给外部读取
NODE_ENV = 'development'
# 是否去除console 和 debugger
VITE_DELETE_CONSOLE = false

BIN
favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

26
index.html Normal file
View File

@ -0,0 +1,26 @@
<!doctype html>
<html build-time="%BUILD_TIME%">
<head>
<meta charset="UTF-8" />
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
<script>
var coverSupport =
'CSS' in window &&
typeof CSS.supports === 'function' &&
(CSS.supports('top: env(a)') || CSS.supports('top: constant(a)'))
document.write(
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
(coverSupport ? ', viewport-fit=cover' : '') +
'" />',
)
</script>
<title>unibest</title>
<!--preload-links-->
<!--app-context-->
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

134
manifest.config.ts Normal file
View File

@ -0,0 +1,134 @@
// manifest.config.ts
import { defineManifestConfig } from '@uni-helper/vite-plugin-uni-manifest'
import path from 'node:path'
import { loadEnv } from 'vite'
// 获取环境变量的范例
const env = loadEnv(process.env.NODE_ENV!, path.resolve(process.cwd(), 'env'))
const {
VITE_APP_TITLE,
VITE_UNI_APPID,
VITE_WX_APPID,
VITE_APP_PUBLIC_BASE,
VITE_FALLBACK_LOCALE,
} = env
export default defineManifestConfig({
name: VITE_APP_TITLE,
appid: VITE_UNI_APPID,
description: '',
versionName: '1.0.0',
versionCode: '100',
transformPx: false,
locale: VITE_FALLBACK_LOCALE, // 'zh-Hans'
h5: {
router: {
base: VITE_APP_PUBLIC_BASE,
},
},
/* 5+App特有相关 */
'app-plus': {
usingComponents: true,
nvueStyleCompiler: 'uni-app',
compilerVersion: 3,
compatible: {
ignoreVersion: true,
},
splashscreen: {
alwaysShowBeforeRender: true,
waiting: true,
autoclose: true,
delay: 0,
},
/* 模块配置 */
modules: {},
/* 应用发布信息 */
distribute: {
/* android打包配置 */
android: {
minSdkVersion: 30,
targetSdkVersion: 30,
abiFilters: ['armeabi-v7a', 'arm64-v8a'],
permissions: [
'<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/>',
'<uses-permission android:name="android.permission.MOUNT_UNMOUNT_FILESYSTEMS"/>',
'<uses-permission android:name="android.permission.VIBRATE"/>',
'<uses-permission android:name="android.permission.READ_LOGS"/>',
'<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>',
'<uses-feature android:name="android.hardware.camera.autofocus"/>',
'<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>',
'<uses-permission android:name="android.permission.CAMERA"/>',
'<uses-permission android:name="android.permission.GET_ACCOUNTS"/>',
'<uses-permission android:name="android.permission.READ_PHONE_STATE"/>',
'<uses-permission android:name="android.permission.CHANGE_WIFI_STATE"/>',
'<uses-permission android:name="android.permission.WAKE_LOCK"/>',
'<uses-permission android:name="android.permission.FLASHLIGHT"/>',
'<uses-feature android:name="android.hardware.camera"/>',
'<uses-permission android:name="android.permission.WRITE_SETTINGS"/>',
],
},
/* ios打包配置 */
ios: {},
/* SDK配置 */
sdkConfigs: {},
/* 图标配置 */
icons: {
android: {
hdpi: 'static/app/icons/72x72.png',
xhdpi: 'static/app/icons/96x96.png',
xxhdpi: 'static/app/icons/144x144.png',
xxxhdpi: 'static/app/icons/192x192.png',
},
ios: {
appstore: 'static/app/icons/1024x1024.png',
ipad: {
app: 'static/app/icons/76x76.png',
'app@2x': 'static/app/icons/152x152.png',
notification: 'static/app/icons/20x20.png',
'notification@2x': 'static/app/icons/40x40.png',
'proapp@2x': 'static/app/icons/167x167.png',
settings: 'static/app/icons/29x29.png',
'settings@2x': 'static/app/icons/58x58.png',
spotlight: 'static/app/icons/40x40.png',
'spotlight@2x': 'static/app/icons/80x80.png',
},
iphone: {
'app@2x': 'static/app/icons/120x120.png',
'app@3x': 'static/app/icons/180x180.png',
'notification@2x': 'static/app/icons/40x40.png',
'notification@3x': 'static/app/icons/60x60.png',
'settings@2x': 'static/app/icons/58x58.png',
'settings@3x': 'static/app/icons/87x87.png',
'spotlight@2x': 'static/app/icons/80x80.png',
'spotlight@3x': 'static/app/icons/120x120.png',
},
},
},
},
},
/* 快应用特有相关 */
quickapp: {},
/* 小程序特有相关 */
'mp-weixin': {
appid: VITE_WX_APPID,
setting: {
urlCheck: false,
},
usingComponents: true,
// __usePrivacyCheck__: true,
},
'mp-alipay': {
usingComponents: true,
styleIsolation: 'shared',
},
'mp-baidu': {
usingComponents: true,
},
'mp-toutiao': {
usingComponents: true,
},
uniStatistics: {
enable: false,
},
vueVersion: '3',
})

View File

@ -0,0 +1,13 @@
// import type { GenerateServiceProps } from 'openapi-ts-request'
// export default [
// {
// schemaPath: 'http://petstore.swagger.io/v2/swagger.json',
// serversPath: './src/service/app',
// requestLibPath: `import request from '@/utils/request';\n import { CustomRequestOptions } from '@/interceptors/request';`,
// requestOptionsType: 'CustomRequestOptions',
// isGenReactQuery: true,
// reactQueryMode: 'vue',
// isGenJavaScript: false,
// },
// ] as GenerateServiceProps[]

175
package.json Normal file
View File

@ -0,0 +1,175 @@
{
"name": "wxmp",
"type": "commonjs",
"version": "2.6.2",
"description": "unibest - 最好的 uniapp 开发模板",
"author": {
"name": "feige996",
"zhName": "菲鸽",
"email": "1020103647@qq.com",
"github": "https://github.com/feige996",
"gitee": "https://gitee.com/feige996"
},
"license": "MIT",
"repository": "https://github.com/feige996/unibest",
"repository-gitee": "https://gitee.com/feige996/unibest",
"repository-deprecated": "https://github.com/codercup/unibest",
"bugs": {
"url": "https://github.com/feige996/unibest/issues"
},
"homepage": "https://feige996.github.io/unibest/",
"engines": {
"node": ">=18",
"pnpm": ">=7.30"
},
"scripts": {
"preinstall": "npx only-allow pnpm",
"uvm": "npx @dcloudio/uvm@latest",
"uvm-rm": "node ./scripts/postupgrade.js",
"postuvm": "echo upgrade uni-app success!",
"dev:app": "uni -p app",
"dev:app-android": "uni -p app-android",
"dev:app-ios": "uni -p app-ios",
"dev:custom": "uni -p",
"dev": "uni",
"dev:h5": "uni",
"dev:h5:ssr": "uni --ssr",
"dev:mp": "uni -p mp-weixin",
"dev:mp-alipay": "uni -p mp-alipay",
"dev:mp-baidu": "uni -p mp-baidu",
"dev:mp-jd": "uni -p mp-jd",
"dev:mp-kuaishou": "uni -p mp-kuaishou",
"dev:mp-lark": "uni -p mp-lark",
"dev:mp-qq": "uni -p mp-qq",
"dev:mp-toutiao": "uni -p mp-toutiao",
"dev:mp-weixin": "uni -p mp-weixin",
"dev:mp-xhs": "uni -p mp-xhs",
"dev:quickapp-webview": "uni -p quickapp-webview",
"dev:quickapp-webview-huawei": "uni -p quickapp-webview-huawei",
"dev:quickapp-webview-union": "uni -p quickapp-webview-union",
"build:app": "uni build -p app",
"build:app-android": "uni build -p app-android",
"build:app-ios": "uni build -p app-ios",
"build:custom": "uni build -p",
"build:h5": "uni build",
"build": "uni build",
"build:h5:ssr": "uni build --ssr",
"build:mp-alipay": "uni build -p mp-alipay",
"build:mp": "uni build -p mp-weixin",
"build:mp-baidu": "uni build -p mp-baidu",
"build:mp-jd": "uni build -p mp-jd",
"build:mp-kuaishou": "uni build -p mp-kuaishou",
"build:mp-lark": "uni build -p mp-lark",
"build:mp-qq": "uni build -p mp-qq",
"build:mp-toutiao": "uni build -p mp-toutiao",
"build:mp-weixin": "uni build -p mp-weixin",
"build:mp-xhs": "uni build -p mp-xhs",
"build:quickapp-webview": "uni build -p quickapp-webview",
"build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei",
"build:quickapp-webview-union": "uni build -p quickapp-webview-union",
"prepare": "git init && husky install",
"type-check": "vue-tsc --noEmit",
"cz": "czg",
"openapi-ts-request": "openapi-ts"
},
"lint-staged": {
"**/*.{html,vue,ts,cjs,json,md}": [
"prettier --write"
],
"**/*.{vue,js,ts,jsx,tsx}": [
"eslint --cache --fix"
],
"**/*.{vue,css,scss,html}": [
"stylelint --fix"
]
},
"resolutions": {
"bin-wrapper": "npm:bin-wrapper-china"
},
"dependencies": {
"@dcloudio/uni-app": "3.0.0-4020920240930001",
"@dcloudio/uni-app-harmony": "3.0.0-4020920240930001",
"@dcloudio/uni-app-plus": "3.0.0-4020920240930001",
"@dcloudio/uni-components": "3.0.0-4020920240930001",
"@dcloudio/uni-h5": "3.0.0-4020920240930001",
"@dcloudio/uni-mp-alipay": "3.0.0-4020920240930001",
"@dcloudio/uni-mp-baidu": "3.0.0-4020920240930001",
"@dcloudio/uni-mp-jd": "3.0.0-4020920240930001",
"@dcloudio/uni-mp-kuaishou": "3.0.0-4020920240930001",
"@dcloudio/uni-mp-lark": "3.0.0-4020920240930001",
"@dcloudio/uni-mp-qq": "3.0.0-4020920240930001",
"@dcloudio/uni-mp-toutiao": "3.0.0-4020920240930001",
"@dcloudio/uni-mp-weixin": "3.0.0-4020920240930001",
"@dcloudio/uni-mp-xhs": "3.0.0-4020920240930001",
"@dcloudio/uni-quickapp-webview": "3.0.0-4020920240930001",
"@tanstack/vue-query": "^5.62.16",
"abortcontroller-polyfill": "^1.7.8",
"dayjs": "1.11.10",
"luch-request": "^3.1.1",
"pinia": "2.0.36",
"pinia-plugin-persistedstate": "3.2.1",
"qs": "6.5.3",
"vue": "3.4.21",
"wot-design-uni": "^1.4.0",
"z-paging": "^2.8.4"
},
"devDependencies": {
"@commitlint/cli": "^18.6.1",
"@commitlint/config-conventional": "^18.6.3",
"@dcloudio/types": "^3.4.14",
"@dcloudio/uni-automator": "3.0.0-4020920240930001",
"@dcloudio/uni-cli-shared": "3.0.0-4020920240930001",
"@dcloudio/uni-stacktracey": "3.0.0-4020920240930001",
"@dcloudio/vite-plugin-uni": "3.0.0-4020920240930001",
"@esbuild/darwin-arm64": "0.20.2",
"@esbuild/darwin-x64": "0.20.2",
"@iconify-json/carbon": "^1.2.4",
"@rollup/rollup-darwin-x64": "^4.28.0",
"@types/node": "^20.17.9",
"@types/wechat-miniprogram": "^3.4.8",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@uni-helper/uni-types": "1.0.0-alpha.3",
"@uni-helper/vite-plugin-uni-layouts": "^0.1.10",
"@uni-helper/vite-plugin-uni-manifest": "^0.2.7",
"@uni-helper/vite-plugin-uni-pages": "0.2.20",
"@uni-helper/vite-plugin-uni-platform": "^0.0.4",
"@unocss/preset-legacy-compat": "^0.59.4",
"@vue/runtime-core": "^3.5.13",
"@vue/tsconfig": "^0.1.3",
"autoprefixer": "^10.4.20",
"commitlint": "^18.6.1",
"czg": "^1.9.4",
"eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.0",
"eslint-config-standard": "^17.1.0",
"eslint-import-resolver-typescript": "^3.7.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-vue": "^9.32.0",
"husky": "^8.0.3",
"lint-staged": "^15.2.10",
"openapi-ts-request": "^1.1.2",
"postcss": "^8.4.49",
"postcss-html": "^1.7.0",
"postcss-scss": "^4.0.9",
"rollup-plugin-visualizer": "^5.12.0",
"sass": "1.77.8",
"stylelint": "^16.11.0",
"stylelint-config-html": "^1.1.0",
"stylelint-config-recess-order": "^4.6.0",
"stylelint-config-recommended": "^14.0.1",
"stylelint-config-recommended-scss": "^14.1.0",
"stylelint-config-recommended-vue": "^1.5.0",
"stylelint-prettier": "^5.0.2",
"terser": "^5.36.0",
"typescript": "^5.7.2",
"unocss": "^0.58.9",
"unocss-applet": "^0.7.8",
"unplugin-auto-import": "^0.17.8",
"vite": "5.2.8",
"vite-plugin-restart": "^0.4.2",
"vue-tsc": "^1.8.27"
},
"packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0"
}

43
pages.config.ts Normal file
View File

@ -0,0 +1,43 @@
import { defineUniPages } from '@uni-helper/vite-plugin-uni-pages'
export default defineUniPages({
globalStyle: {
navigationStyle: 'default',
navigationBarTitleText: 'unibest',
navigationBarBackgroundColor: '#f8f8f8',
navigationBarTextStyle: 'black',
backgroundColor: '#FFFFFF',
},
easycom: {
autoscan: true,
custom: {
'^wd-(.*)': 'wot-design-uni/components/wd-$1/wd-$1.vue',
'^(?!z-paging-refresh|z-paging-load-more)z-paging(.*)':
'z-paging/components/z-paging$1/z-paging$1.vue',
},
},
tabBar: {
color: '#222222',
selectedColor: '#000000',
backgroundColor: '#ffffff',
borderStyle: 'black',
height: '50px',
fontSize: '10px',
iconWidth: '20px',
spacing: '3px',
list: [
{
iconPath: 'static/tabbar/edit.svg',
selectedIconPath: 'static/tabbar/editHL.svg',
pagePath: 'pages/index/index',
text: '报名',
},
{
iconPath: 'static/tabbar/document.svg',
selectedIconPath: 'static/tabbar/documentHL.svg',
pagePath: 'pages/fate/fate',
text: '考试',
},
],
},
})

13210
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

25
project.config.json Normal file
View File

@ -0,0 +1,25 @@
{
"setting": {
"es6": true,
"postcss": true,
"minified": true,
"uglifyFileName": false,
"enhance": true,
"packNpmRelationList": [],
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
},
"useCompilerPlugins": false,
"minifyWXML": true
},
"compileType": "miniprogram",
"simulatorPluginLibVersion": {},
"packOptions": {
"ignore": [],
"include": []
},
"appid": "wx4ec68802a3f48c39",
"editorSetting": {}
}

View File

@ -0,0 +1,14 @@
{
"libVersion": "3.8.7",
"projectname": "xiangqingmp",
"setting": {
"urlCheck": true,
"coverView": true,
"lazyloadPlaceholderEnable": false,
"skylineRenderEnable": false,
"preloadBackgroundData": false,
"autoAudits": false,
"showShadowRootInWxmlPanel": true,
"compileHotReLoad": true
}
}

BIN
screenshots/pay-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

BIN
screenshots/pay-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

36
scripts/postupgrade.js Normal file
View File

@ -0,0 +1,36 @@
// # 执行 `pnpm upgrade` 后会升级 `uniapp` 相关依赖
// # 在升级完后,会自动添加很多无用依赖,这需要删除以减小依赖包体积
// # 只需要执行下面的命令即可
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { exec } = require('child_process')
// 定义要执行的命令
const dependencies = [
'@dcloudio/uni-app-harmony',
// TODO: 如果需要某个平台的小程序,请手动删除或注释掉
'@dcloudio/uni-mp-alipay',
'@dcloudio/uni-mp-baidu',
'@dcloudio/uni-mp-jd',
'@dcloudio/uni-mp-kuaishou',
'@dcloudio/uni-mp-lark',
'@dcloudio/uni-mp-qq',
'@dcloudio/uni-mp-toutiao',
'@dcloudio/uni-mp-xhs',
'@dcloudio/uni-quickapp-webview',
// i18n模板要注释掉下面的
'vue-i18n',
]
// 使用exec执行命令
exec(`pnpm un ${dependencies.join(' ')}`, (error, stdout, stderr) => {
if (error) {
// 如果有错误,打印错误信息
console.error(`执行出错: ${error}`)
return
}
// 打印正常输出
console.log(`stdout: ${stdout}`)
// 如果有错误输出,也打印出来
console.error(`stderr: ${stderr}`)
})

67
src/App.vue Normal file
View File

@ -0,0 +1,67 @@
<script setup lang="ts">
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
import 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only'
import { useUserStore } from './store'
const userStore = useUserStore()
onLaunch(() => {
console.log('App Launch')
console.log(userStore.userInfo)
console.log(uni.getStorageSync('x-token'))
if (!uni.getStorageSync('x-token')) {
uni.navigateTo({
url: '/pages/login/index',
})
}
})
onShow(() => {
console.log('App Show')
})
onHide(() => {
console.log('App Hide')
})
</script>
<style lang="scss">
/* stylelint-disable selector-type-no-unknown */
button::after {
border: none;
}
swiper,
scroll-view {
flex: 1;
height: 100%;
overflow: hidden;
}
image {
width: 100%;
height: 100%;
vertical-align: middle;
}
// 使 unocss: text-ellipsis
.ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
//
.ellipsis-2 {
display: -webkit-box;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
//
.ellipsis-3 {
display: -webkit-box;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
</style>

0
src/components/.gitkeep Normal file
View File

31
src/env.d.ts vendored Normal file
View File

@ -0,0 +1,31 @@
/// <reference types="vite/client" />
/// <reference types="vite-svg-loader" />
declare module '*.vue' {
import { DefineComponent } from 'vue'
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
const component: DefineComponent<{}, {}, any>
export default component
}
interface ImportMetaEnv {
/** 网站标题,应用名称 */
readonly VITE_APP_TITLE: string
/** 服务端口号 */
readonly VITE_SERVER_PORT: string
/** 后台接口地址 */
readonly VITE_SERVER_BASEURL: string
/** H5是否需要代理 */
readonly VITE_APP_PROXY: 'true' | 'false'
/** H5是否需要代理需要的话有个前缀 */
readonly VITE_APP_PROXY_PREFIX: string // 一般是/api
/** 上传图片地址 */
readonly VITE_UPLOAD_BASEURL: string
/** 是否清除console */
readonly VITE_DELETE_CONSOLE: string
// 更多环境变量...
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

0
src/hooks/.gitkeep Normal file
View File

44
src/hooks/useRequest.ts Normal file
View File

@ -0,0 +1,44 @@
import { UnwrapRef } from 'vue'
type IUseRequestOptions<T> = {
/** 是否立即执行 */
immediate?: boolean
/** 初始化数据 */
initialData?: T
}
/**
* useRequest是一个定制化的请求钩子
* @param func Promise
* @param options {immediate, initialData}
* @param options.immediate false
* @param options.initialData undefined
* @returns {loading, error, data, run}
*/
export default function useRequest<T>(
func: () => Promise<IResData<T>>,
options: IUseRequestOptions<T> = { immediate: false },
) {
const loading = ref(false)
const error = ref(false)
const data = ref<T>(options.initialData)
const run = async () => {
loading.value = true
return func()
.then((res) => {
data.value = res.data as UnwrapRef<T>
error.value = false
return data.value
})
.catch((err) => {
error.value = err
throw err
})
.finally(() => {
loading.value = false
})
}
options.immediate && run()
return { loading, error, data, run }
}

69
src/hooks/useUpload.ts Normal file
View File

@ -0,0 +1,69 @@
// TODO: 别忘加更改环境变量的 VITE_UPLOAD_BASEURL 地址。
import { getEnvBaseUploadUrl } from '@/utils'
const VITE_UPLOAD_BASEURL = `${getEnvBaseUploadUrl()}`
/**
* useUpload
* @param formData {name: '菲鸽'}
* @returns {loading, error, data, run}
*/
export default function useUpload<T = string>(formData: Record<string, any> = {}) {
const loading = ref(false)
const error = ref(false)
const data = ref<T>()
const run = () => {
// #ifdef MP-WEIXIN
// 微信小程序从基础库 2.21.0 开始, wx.chooseImage 停止维护,请使用 uni.chooseMedia 代替。
// 微信小程序在2023年10月17日之后使用本API需要配置隐私协议
uni.chooseMedia({
count: 1,
mediaType: ['image'],
success: (res) => {
loading.value = true
const tempFilePath = res.tempFiles[0].tempFilePath
uploadFile<T>({ tempFilePath, formData, data, error, loading })
},
fail: (err) => {
console.error('uni.chooseMedia err->', err)
error.value = true
},
})
// #endif
// #ifndef MP-WEIXIN
uni.chooseImage({
count: 1,
success: (res) => {
loading.value = true
const tempFilePath = res.tempFilePaths[0]
uploadFile<T>({ tempFilePath, formData, data, error, loading })
},
fail: (err) => {
console.error('uni.chooseImage err->', err)
error.value = true
},
})
// #endif
}
return { loading, error, data, run }
}
function uploadFile<T>({ tempFilePath, formData, data, error, loading }) {
uni.uploadFile({
url: VITE_UPLOAD_BASEURL,
filePath: tempFilePath,
name: 'file',
formData,
success: (uploadFileRes) => {
data.value = uploadFileRes.data as T
},
fail: (err) => {
console.error('uni.uploadFile err->', err)
error.value = true
},
complete: () => {
loading.value = false
},
})
}

View File

@ -0,0 +1,2 @@
export { routeInterceptor } from './route'
export { prototypeInterceptor } from './prototype'

View File

@ -0,0 +1,13 @@
export const prototypeInterceptor = {
install() {
// 解决低版本手机不识别 array.at() 导致运行报错的问题
if (typeof Array.prototype.at !== 'function') {
// eslint-disable-next-line no-extend-native
Array.prototype.at = function (index: number) {
if (index < 0) return this[this.length + index]
if (index >= this.length) return undefined
return this[index]
}
}
},
}

54
src/interceptors/route.ts Normal file
View File

@ -0,0 +1,54 @@
/**
* by on 2024-03-06
*
*
* 便使
*/
import { useUserStore } from '@/store'
import { needLoginPages as _needLoginPages, getNeedLoginPages } from '@/utils'
// TODO Check
const loginRoute = '/pages/login/index'
const isLogined = () => {
const userStore = useUserStore()
return userStore.isLogined
}
const isDev = import.meta.env.DEV
// 黑名单登录拦截器 - (适用于大部分页面不需要登录,少部分页面需要登录)
const navigateToInterceptor = {
// 注意这里的url是 '/' 开头的,如 '/pages/index/index',跟 'pages.json' 里面的 path 不同
invoke({ url }: { url: string }) {
// console.log(url) // /pages/route-interceptor/index?name=feige&age=30
const path = url.split('?')[0]
let needLoginPages: string[] = []
// 为了防止开发时出现BUG这里每次都获取一下。生产环境可以移到函数外性能更好
if (isDev) {
needLoginPages = getNeedLoginPages()
} else {
needLoginPages = _needLoginPages
}
const isNeedLogin = needLoginPages.includes(path)
if (!isNeedLogin) {
return true
}
const hasLogin = isLogined()
if (hasLogin) {
return true
}
const redirectRoute = `${loginRoute}?redirect=${encodeURIComponent(url)}`
uni.navigateTo({ url: redirectRoute })
return false
},
}
export const routeInterceptor = {
install() {
uni.addInterceptor('navigateTo', navigateToInterceptor)
uni.addInterceptor('reLaunch', navigateToInterceptor)
uni.addInterceptor('redirectTo', navigateToInterceptor)
uni.addInterceptor('switchTab', navigateToInterceptor)
},
}

17
src/layouts/default.vue Normal file
View File

@ -0,0 +1,17 @@
<template>
<wd-config-provider :themeVars="themeVars">
<slot />
<wd-toast />
<wd-message-box />
</wd-config-provider>
</template>
<script lang="ts" setup>
import type { ConfigProviderThemeVars } from 'wot-design-uni'
const themeVars: ConfigProviderThemeVars = {
// colorTheme: 'red',
// buttonPrimaryBgColor: '#07c160',
// buttonPrimaryColor: '#07c160',
}
</script>

17
src/layouts/demo.vue Normal file
View File

@ -0,0 +1,17 @@
<template>
<wd-config-provider :themeVars="themeVars">
<slot />
<wd-toast />
<wd-message-box />
</wd-config-provider>
</template>
<script lang="ts" setup>
import type { ConfigProviderThemeVars } from 'wot-design-uni'
const themeVars: ConfigProviderThemeVars = {
// colorTheme: 'red',
// buttonPrimaryBgColor: '#07c160',
// buttonPrimaryColor: '#07c160',
}
</script>

20
src/main.ts Normal file
View File

@ -0,0 +1,20 @@
import '@/style/index.scss'
import { VueQueryPlugin } from '@tanstack/vue-query'
import 'virtual:uno.css'
import { createSSRApp } from 'vue'
import App from './App.vue'
import { prototypeInterceptor, routeInterceptor } from './interceptors'
import store from './store'
export function createApp() {
const app = createSSRApp(App)
app.use(store)
app.use(routeInterceptor)
app.use(prototypeInterceptor)
app.use(VueQueryPlugin)
return {
app,
}
}

111
src/manifest.json Normal file
View File

@ -0,0 +1,111 @@
{
"name": "unibest",
"appid": "H57F2ACE4",
"description": "",
"versionName": "1.0.0",
"versionCode": "100",
"transformPx": false,
"app-plus": {
"usingComponents": true,
"nvueStyleCompiler": "uni-app",
"compilerVersion": 3,
"splashscreen": {
"alwaysShowBeforeRender": true,
"waiting": true,
"autoclose": true,
"delay": 0
},
"modules": {},
"distribute": {
"android": {
"permissions": [
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
],
"minSdkVersion": 30,
"targetSdkVersion": 30,
"abiFilters": [
"armeabi-v7a",
"arm64-v8a"
]
},
"ios": {},
"sdkConfigs": {},
"icons": {
"android": {
"hdpi": "static/app/icons/72x72.png",
"xhdpi": "static/app/icons/96x96.png",
"xxhdpi": "static/app/icons/144x144.png",
"xxxhdpi": "static/app/icons/192x192.png"
},
"ios": {
"appstore": "static/app/icons/1024x1024.png",
"ipad": {
"app": "static/app/icons/76x76.png",
"app@2x": "static/app/icons/152x152.png",
"notification": "static/app/icons/20x20.png",
"notification@2x": "static/app/icons/40x40.png",
"proapp@2x": "static/app/icons/167x167.png",
"settings": "static/app/icons/29x29.png",
"settings@2x": "static/app/icons/58x58.png",
"spotlight": "static/app/icons/40x40.png",
"spotlight@2x": "static/app/icons/80x80.png"
},
"iphone": {
"app@2x": "static/app/icons/120x120.png",
"app@3x": "static/app/icons/180x180.png",
"notification@2x": "static/app/icons/40x40.png",
"notification@3x": "static/app/icons/60x60.png",
"settings@2x": "static/app/icons/58x58.png",
"settings@3x": "static/app/icons/87x87.png",
"spotlight@2x": "static/app/icons/80x80.png",
"spotlight@3x": "static/app/icons/120x120.png"
}
}
}
},
"compatible": {
"ignoreVersion": true
}
},
"quickapp": {},
"mp-weixin": {
"appid": "wx25fb5794b1c3026f",
"setting": {
"urlCheck": false
},
"usingComponents": true
},
"mp-alipay": {
"usingComponents": true,
"styleIsolation": "shared"
},
"mp-baidu": {
"usingComponents": true
},
"mp-toutiao": {
"usingComponents": true
},
"uniStatistics": {
"enable": false
},
"vueVersion": "3",
"h5": {
"router": {
"base": "/"
}
}
}

View File

@ -0,0 +1,20 @@
<route lang="json5" type="page">
{
style: { navigationBarTitleText: '分包页面 标题' },
}
</route>
<template>
<view class="text-center">
<view class="m-8">http://localhost:9000/#/pages-sub/demo/index</view>
<view class="text-green-500">分包页面demo</view>
</view>
</template>
<script lang="ts" setup>
// code here
</script>
<style lang="scss" scoped>
//
</style>

209
src/pages.json Normal file
View File

@ -0,0 +1,209 @@
{
"globalStyle": {
"navigationStyle": "default",
"navigationBarTitleText": "unibest",
"navigationBarBackgroundColor": "#f8f8f8",
"navigationBarTextStyle": "black",
"backgroundColor": "#FFFFFF"
},
"easycom": {
"autoscan": true,
"custom": {
"^wd-(.*)": "wot-design-uni/components/wd-$1/wd-$1.vue",
"^(?!z-paging-refresh|z-paging-load-more)z-paging(.*)": "z-paging/components/z-paging$1/z-paging$1.vue"
}
},
"tabBar": {
"color": "#222222",
"selectedColor": "#000000",
"backgroundColor": "#ffffff",
"borderStyle": "black",
"height": "50px",
"fontSize": "10px",
"iconWidth": "20px",
"spacing": "3px",
"list": [
{
"iconPath": "static/tabbar/edit.svg",
"selectedIconPath": "static/tabbar/editHL.svg",
"pagePath": "pages/index/index",
"text": "报名"
},
{
"iconPath": "static/tabbar/document.svg",
"selectedIconPath": "static/tabbar/documentHL.svg",
"pagePath": "pages/fate/fate",
"text": "考试"
}
]
},
"pages": [
{
"path": "pages/index/index",
"type": "home",
"style": {
"navigationBarTitleText": ""
}
},
{
"path": "pages/activity/detail",
"type": "page"
},
{
"path": "pages/activity/index",
"type": "page"
},
{
"path": "pages/agreement/privacy",
"type": "page",
"style": {
"navigationBarTitleText": "隐私政策"
}
},
{
"path": "pages/agreement/user",
"type": "page",
"style": {
"navigationBarTitleText": "用户协议"
}
},
{
"path": "pages/detail/index",
"type": "page",
"style": {
"navigationBarTitleText": "用户详情"
}
},
{
"path": "pages/fate/fate",
"type": "page",
"layout": "default",
"style": {
"navigationBarTitleText": "红娘"
}
},
{
"path": "pages/login/index",
"type": "page",
"style": {
"navigationBarTitleText": ""
}
},
{
"path": "pages/my/activities",
"type": "page",
"layout": "default",
"style": {
"navigationBarTitleText": "我的活动"
}
},
{
"path": "pages/my/complaint",
"type": "page",
"layout": "default",
"style": {
"navigationBarTitleText": "发起投诉"
}
},
{
"path": "pages/my/feedback",
"type": "page",
"layout": "default",
"style": {
"navigationBarTitleText": "意见反馈"
}
},
{
"path": "pages/my/invite",
"type": "page",
"layout": "default",
"style": {
"navigationBarTitleText": "邀请好友"
}
},
{
"path": "pages/my/inviter",
"type": "page",
"layout": "default",
"style": {
"navigationBarTitleText": "我的邀请人"
}
},
{
"path": "pages/my/matches",
"type": "page",
"layout": "default",
"style": {
"navigationBarTitleText": "我的配对"
}
},
{
"path": "pages/my/my",
"type": "page",
"layout": "default",
"style": {
"navigationBarTitleText": "我的"
}
},
{
"path": "pages/my/profile",
"type": "page",
"layout": "default",
"style": {
"navigationBarTitleText": "我的资料"
}
},
{
"path": "pages/my/recharge",
"type": "page",
"layout": "default",
"style": {
"navigationBarTitleText": "充值记录"
}
},
{
"path": "pages/my/refund",
"type": "page",
"layout": "default",
"style": {
"navigationBarTitleText": "退款记录"
}
},
{
"path": "pages/my/spend-detail",
"type": "page",
"layout": "default",
"style": {
"navigationBarTitleText": "消费记录"
}
},
{
"path": "pages/my/spend",
"type": "page",
"layout": "default",
"style": {
"navigationBarTitleText": "消费记录"
}
},
{
"path": "pages/my/subordinate",
"type": "page"
},
{
"path": "pages/my/user-info",
"type": "page",
"layout": "default",
"style": {
"navigationBarTitleText": "用户信息"
}
},
{
"path": "pages/recommend/index",
"type": "page",
"style": {
"navigationBarTitleText": "推荐列表"
}
}
],
"subPackages": []
}

View File

@ -0,0 +1,173 @@
<template>
<view class="min-h-screen bg-gray-50">
<!-- 顶部导航 -->
<view class="sticky top-0 z-10 bg-white shadow-sm">
<view class="flex items-center justify-between px-4 py-3">
<view class="flex items-center space-x-2">
<text class="iconfont icon-arrow-left text-xl" @tap="goBack"></text>
<text class="text-lg font-bold">活动详情</text>
</view>
<view class="flex items-center space-x-4">
<text class="iconfont icon-share text-xl"></text>
</view>
</view>
</view>
<!-- 活动详情内容 -->
<scroll-view class="h-screen" scroll-y>
<!-- 活动封面 -->
<view class="relative">
<image
:src="activityDetail.eventAvatar"
mode="aspectFill"
class="w-full h-80 object-cover"
/>
<view class="absolute top-3 right-3 px-3 py-1 bg-white/90 rounded-full backdrop-blur-sm">
<text class="text-sm text-primary font-medium">¥{{ activityDetail.eventPrice }}</text>
</view>
<view
:class="[
'absolute top-3 left-3 px-3 py-1 rounded-full text-sm font-medium',
isOngoing ? 'bg-green-500/90 text-white' : 'bg-gray-500/90 text-white',
]"
>
{{ isOngoing ? '报名中' : '已结束' }}
</view>
</view>
<!-- 活动信息 -->
<view class="p-4 bg-white">
<text class="block text-xl font-bold mb-2">{{ activityDetail.eventTitle }}</text>
<view class="flex items-center text-sm text-gray-500 mb-2">
<text class="iconfont icon-time mr-1"></text>
<text>截止时间: {{ activityDetail.endTime }}</text>
</view>
<view class="flex items-center text-sm text-gray-500 mb-4">
<text class="iconfont icon-location mr-1"></text>
<text>{{ activityDetail.eventPlace }}</text>
</view>
<view class="flex items-center justify-between">
<view class="flex items-center">
<text class="text-sm text-gray-500">
已有 {{ activityDetail.eventNumber }}/{{ activityDetail.eventLimitNumber }} 人报名
</text>
</view>
<button
class="px-8 py-2.5 text-white text-sm rounded-full flex items-center justify-center shadow-md transition-all duration-300"
:class="{
'bg-gradient-to-r from-pink-500 to-purple-500 hover:from-pink-600 hover:to-purple-600':
isOngoing,
'bg-gray-400 cursor-not-allowed': !isOngoing,
}"
:disabled="!isOngoing"
@tap="handleSignUp"
>
<text class="iconfont icon-add mr-1" v-if="isOngoing"></text>
{{ isOngoing ? '立即报名' : '已结束' }}
</button>
</view>
</view>
<!-- 活动详情 -->
<view class="p-4 mt-4 bg-white">
<text class="block text-lg font-bold mb-4">活动详情</text>
<rich-text :nodes="activityDetail.eventContent"></rich-text>
</view>
<!-- 活动信息补充 -->
<view class="p-4 mt-4 bg-white">
<text class="block text-lg font-bold mb-4">活动信息</text>
<view class="space-y-2">
<view class="flex justify-between">
<text class="text-gray-500">发起时间</text>
<text>{{ activityDetail.createTime }}</text>
</view>
<view class="flex justify-between">
<text class="text-gray-500">有效天数</text>
<text>{{ activityDetail.effectiveTime }}</text>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import type { EventItem } from '@/service/event/type'
import { joinEventAPI } from '@/service/event'
onLoad(() => {
//
const instance: any = getCurrentInstance().proxy
const eventChannel = instance.getOpenerEventChannel()
eventChannel.on('item', function (data) {
const { item } = data
activityDetail.value = item
})
})
//
const activityDetail = ref<EventItem>({
id: '',
eventTitle: '',
eventContent: '',
eventAvatar: '',
eventPrice: '',
eventPlace: '',
createTime: '',
endTime: '',
effectiveTime: '',
eventNumber: '',
eventLimitNumber: '',
})
//
const isOngoing = computed(() => {
if (!activityDetail.value.endTime) return false
return new Date(activityDetail.value.endTime) > new Date()
})
//
const isLoading = ref(false)
const hasMore = ref(true)
//
const handleSignUp = async () => {
try {
uni.showModal({
title: '报名确认',
content: '确定要报名参加此活动吗?',
success: async (res) => {
if (res.confirm) {
const result = await joinEventAPI(activityDetail.value.id)
if (result.code === 500) {
uni.showModal({
title: '报名失败',
content: result.message || '报名失败,请稍后重试',
showCancel: false,
})
return
}
uni.showToast({
title: '报名成功',
icon: 'success',
})
}
},
})
} catch (error) {
uni.showModal({
title: '报名失败',
content: '系统异常,请稍后重试',
showCancel: false,
})
}
}
//
const goBack = () => {
uni.navigateBack()
}
</script>

View File

@ -0,0 +1,236 @@
<template>
<view class="min-h-screen bg-gray-50">
<!-- 顶部导航 -->
<view class="sticky top-0 z-10 bg-white shadow-sm">
<view class="flex items-center justify-between px-4 py-3">
<view class="flex items-center space-x-2">
<text class="iconfont icon-arrow-left text-xl" @tap="goBack"></text>
<text class="text-2xl font-bold">相亲活动</text>
</view>
<view class="flex items-center space-x-4">
<text class="iconfont icon-search text-xl"></text>
<text class="iconfont icon-filter text-xl"></text>
</view>
</view>
<!-- 筛选器 -->
<view class="flex px-4 pb-3 space-x-4">
<view
:class="[
'px-4 py-2 rounded-full text-sm transition-all duration-300',
currentFilter === '1' ? 'bg-primary/90 shadow-lg shadow-primary/20' : 'bg-gray-100',
]"
@tap="changeFilter('1')"
>
<text :class="currentFilter === '1' ? 'text-gray-600' : 'text-gray-600'">全部</text>
</view>
<view
:class="[
'px-4 py-2 rounded-full text-sm transition-all duration-300',
currentFilter === '2' ? 'bg-primary/90 shadow-lg shadow-primary/20' : 'bg-gray-100',
]"
@tap="changeFilter('2')"
>
<text :class="currentFilter === '2' ? 'text-gray-600' : 'text-gray-600'">报名中</text>
</view>
<view
:class="[
'px-4 py-2 rounded-full text-sm transition-all duration-300',
currentFilter === '3' ? 'bg-primary/90 shadow-lg shadow-primary/20' : 'bg-gray-100',
]"
@tap="changeFilter('3')"
>
<text :class="currentFilter === '3' ? 'text-gray-600' : 'text-gray-600'">已结束</text>
</view>
</view>
</view>
<!-- 活动列表 -->
<view class="px-4 pt-2 pb-8 overflow-hidden">
<scroll-view
class="space-y-4"
scroll-y
@scrolltolower="loadMore"
refresher-enabled
:refresher-triggered="loading"
@refresherrefresh="onRefresh"
>
<!-- 加载状态 -->
<view v-if="loading" class="flex justify-center items-center py-8">
<text class="text-gray-500">加载中...</text>
</view>
<!-- 错误状态 -->
<view v-else-if="error" class="flex justify-center items-center py-8">
<text class="text-red-500">{{ error }}</text>
</view>
<!-- 空状态 -->
<view v-else-if="!activities.length" class="flex justify-center items-center py-8">
<text class="text-gray-500">暂无活动</text>
</view>
<view v-else class="space-y-4">
<view
class="bg-white rounded-2xl shadow-lg overflow-hidden transform transition-all duration-300 hover:scale-[1.02] active:scale-[0.98]"
v-for="item in activities"
:key="item.id"
@tap="viewActivity(item)"
>
<view class="relative">
<image :src="item.eventAvatar" mode="aspectFill" class="w-full h-40 object-cover" />
<view
class="absolute top-3 right-3 px-3 py-1 bg-white/90 rounded-full backdrop-blur-sm"
>
<text class="text-sm text-primary font-medium">¥{{ item.eventPrice }}</text>
</view>
<view
:class="[
'absolute top-3 left-3 px-3 py-1 rounded-full text-sm font-medium',
isOngoing(item) ? 'bg-green-500/90 text-white' : 'bg-gray-500/90 text-white',
]"
>
{{ isOngoing(item) ? '报名中' : '已结束' }}
</view>
</view>
<view class="p-4">
<text class="block text-lg font-bold mb-2">{{ item.eventTitle }}</text>
<view class="flex items-center text-sm text-gray-500">
<text class="iconfont icon-time mr-1"></text>
<text>截止时间: {{ formatDate(item.endTime) }}</text>
</view>
<view class="mt-3 flex items-center justify-between">
<view class="flex items-center">
<view class="flex -space-x-2">
<image
v-for="(avatar, index) in getRandomAvatars(3)"
:key="index"
:src="avatar"
class="w-8 h-8 rounded-full border-2 border-white"
/>
</view>
<text class="ml-2 text-sm text-gray-500">已有 {{ item.eventNumber }} 人报名</text>
</view>
<view
class="px-4 py-2 bg-primary text-white text-sm rounded-full"
@tap.stop="handleJoin(item)"
>
立即报名
</view>
</view>
</view>
</view>
</view>
<!-- 加载完成提示 -->
<view
v-if="!loading && activities.length > 0 && activities.length >= total"
class="py-4 text-center text-gray-500"
>
<text>没有更多数据了</text>
</view>
</scroll-view>
</view>
</view>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue'
import { joinEventAPI } from '@/service/event'
import type { EventItem } from '@/service/event/type'
import { formatDate } from '@/utils/date'
//
const loading = ref(false)
const error = ref('')
const activities = ref<EventItem[]>([])
const currentFilter = ref<'1' | '2' | '3'>('1') // 1- 2- 3-
const total = ref(0)
//
const fetchActivities = async (isRefresh = false) => {
if (loading.value) return
loading.value = true
error.value = ''
try {
} catch (err) {
error.value = '网络错误,请稍后重试'
console.error('获取活动列表失败:', err)
} finally {
loading.value = false
}
}
//
const isOngoing = (item: EventItem) => {
const now = new Date().getTime()
const endTime = new Date(item.endTime).getTime()
return endTime > now
}
//
const loadMore = () => {
if (activities.value.length >= total.value) return
fetchActivities()
}
//
const onRefresh = () => {
fetchActivities(true)
}
//
const changeFilter = (filter: '1' | '2' | '3') => {
currentFilter.value = filter
fetchActivities(true)
}
//
const viewActivity = (item: EventItem) => {
uni.navigateTo({
url: `/pages/activity/detail?id=${item.id}`,
})
}
//
const handleJoin = async (item: EventItem) => {
try {
const res = await joinEventAPI(item.id)
if (res.code === 200) {
uni.showToast({
title: '报名成功',
icon: 'success',
})
//
fetchActivities(true)
} else {
uni.showToast({
title: res.message || '报名失败',
icon: 'error',
})
}
} catch (err) {
uni.showToast({
title: '网络错误,请稍后重试',
icon: 'error',
})
console.error('报名失败:', err)
}
}
const goBack = () => {
uni.navigateBack()
}
// 使
const getRandomAvatars = (count: number) => {
return Array(count).fill(
'https://bpic.588ku.com/element_origin_min_pic/23/07/11/d32dabe266d10da8b21bd640a2e9b611.jpg',
)
}
//
onMounted(() => {
fetchActivities()
})
</script>

View File

@ -0,0 +1,103 @@
<route lang="json5" type="page">
{
style: {
navigationBarTitleText: '隐私政策',
},
}
</route>
<template>
<view class="min-h-screen bg-white p-4">
<view class="prose prose-sm max-w-none">
<view class="text-center mb-8">
<text class="text-xl font-bold">隐私政策</text>
<text class="block text-sm text-gray-500 mt-2">最后更新日期2024年3月</text>
</view>
<view class="space-y-6">
<view>
<text class="font-bold block mb-2">1. 引言</text>
<text class="text-gray-600 block">
我们非常重视您的隐私保护本隐私政策旨在向您说明我们如何收集使用存储和保护您的个人信息请您在使用我们的服务前仔细阅读并了解本隐私政策的全部内容
</text>
</view>
<view>
<text class="font-bold block mb-2">2. 信息收集</text>
<text class="text-gray-600 block">
2.1 我们收集的信息包括 - 基本信息昵称头像性别年龄等 -
位置信息您的位置信息需要您授权 - 设备信息设备型号操作系统版本设备设置等 -
日志信息使用时间使用时长操作记录等 2.2 我们通过以下方式收集信息 -
您主动提供的信息 - 在您使用服务时产生的信息 - 经您授权从第三方获取的信息
</text>
</view>
<view>
<text class="font-bold block mb-2">3. 信息使用</text>
<text class="text-gray-600 block">
3.1 我们使用收集的信息用于 - 提供维护和改进我们的服务 - 开发新的服务和功能 -
了解用户如何使用我们的服务 - 防止欺诈和滥用 - 向您推送可能感兴趣的内容
</text>
</view>
<view>
<text class="font-bold block mb-2">4. 信息共享</text>
<text class="text-gray-600 block">
4.1 我们不会向第三方出售您的个人信息 4.2 在以下情况下我们可能会共享您的信息 -
获得您的明确同意 - 法律法规要求 - 维护我们的合法权益 - 与我们的关联公司共享
</text>
</view>
<view>
<text class="font-bold block mb-2">5. 信息安全</text>
<text class="text-gray-600 block">
5.1 我们采取各种安全措施保护您的个人信息 - 数据加密存储 - 访问控制机制 - 安全审计机制
- 应急响应机制
</text>
</view>
<view>
<text class="font-bold block mb-2">6. 信息存储</text>
<text class="text-gray-600 block">
6.1 我们会将您的信息存储在中国境内的服务器上 6.2
我们会根据法律法规的要求在必要的时间内保存您的信息
</text>
</view>
<view>
<text class="font-bold block mb-2">7. 您的权利</text>
<text class="text-gray-600 block">
您对您的个人信息享有以下权利 - 访问和查看您的个人信息 - 更正或更新您的个人信息 -
删除您的个人信息 - 撤回您的授权同意 - 注销您的账号
</text>
</view>
<view>
<text class="font-bold block mb-2">8. 未成年人保护</text>
<text class="text-gray-600 block">
8.1 我们建议未成年人在监护人的指导下使用我们的服务 8.2
我们会采取特殊措施保护未成年人的个人信息
</text>
</view>
<view>
<text class="font-bold block mb-2">9. 政策更新</text>
<text class="text-gray-600 block">
我们可能会不时更新本隐私政策当我们更新隐私政策时我们会在小程序内发布更新后的版本并更新"最后更新日期"
</text>
</view>
<view>
<text class="font-bold block mb-2">10. 联系我们</text>
<text class="text-gray-600 block">
如果您对本隐私政策有任何疑问意见或建议请通过小程序内的客服功能与我们联系
</text>
</view>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
//
</script>

View File

@ -0,0 +1,100 @@
<route lang="json5" type="page">
{
style: {
navigationBarTitleText: '用户协议',
},
}
</route>
<template>
<view class="min-h-screen bg-white p-4">
<view class="prose prose-sm max-w-none">
<view class="text-center mb-8">
<text class="text-xl font-bold">用户协议</text>
<text class="block text-sm text-gray-500 mt-2">最后更新日期2024年3月</text>
</view>
<view class="space-y-6">
<view>
<text class="font-bold block mb-2">1. 协议的范围</text>
<text class="text-gray-600 block">
欢迎您使用我们的相亲小程序本协议是您与我们之间关于使用本小程序服务所订立的协议请您仔细阅读本协议的全部内容如果您不同意本协议的任何内容您应立即停止使用本小程序
</text>
</view>
<view>
<text class="font-bold block mb-2">2. 服务内容</text>
<text class="text-gray-600 block">
本小程序是一个相亲交友平台为用户提供信息展示交流互动等服务我们会不断改进服务质量但不对服务的及时性安全性准确性做出保证
</text>
</view>
<view>
<text class="font-bold block mb-2">3. 用户注册与账号</text>
<text class="text-gray-600 block">
3.1
您承诺以真实身份注册成为本小程序的用户并保证所提供的个人资料真实准确完整合法有效
3.2 您应当妥善保管账号和密码对账号下的所有行为负责 3.3
如发现任何未经授权使用您账号的情况应立即通知我们
</text>
</view>
<view>
<text class="font-bold block mb-2">4. 用户行为规范</text>
<text class="text-gray-600 block">
4.1 您在使用本小程序时必须遵守中华人民共和国相关法律法规 4.2
您不得利用本小程序从事违法违规活动包括但不限于 - 发布虚假信息 - 侵犯他人知识产权 -
传播色情暴力等不良信息 - 从事诈骗等违法活动
</text>
</view>
<view>
<text class="font-bold block mb-2">5. 隐私保护</text>
<text class="text-gray-600 block">
我们重视对您个人信息的保护具体隐私政策请参见隐私政策
</text>
</view>
<view>
<text class="font-bold block mb-2">6. 知识产权</text>
<text class="text-gray-600 block">
本小程序的所有内容包括但不限于文字图片音频视频软件程序版面设计等均受著作权法和国际著作权条约以及其他知识产权法律法规的保护
</text>
</view>
<view>
<text class="font-bold block mb-2">7. 免责声明</text>
<text class="text-gray-600 block">
7.1 对于因不可抗力或我们无法控制的原因造成的服务中断或其它缺陷我们不承担任何责任 7.2
您理解并同意我们不对您因使用本小程序而遭受的任何直接或间接损失负责
</text>
</view>
<view>
<text class="font-bold block mb-2">8. 协议修改</text>
<text class="text-gray-600 block">
我们有权在必要时修改本协议条款您可以在本小程序中查阅最新版本的协议条款本协议条款变更后如果您继续使用本小程序即视为您已接受修改后的协议
</text>
</view>
<view>
<text class="font-bold block mb-2">9. 法律适用</text>
<text class="text-gray-600 block">
本协议的订立执行和解释及争议的解决均应适用中华人民共和国法律
</text>
</view>
<view>
<text class="font-bold block mb-2">10. 联系我们</text>
<text class="text-gray-600 block">
如果您对本协议有任何疑问请通过小程序内的客服功能与我们联系
</text>
</view>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
//
</script>

384
src/pages/detail/index.vue Normal file
View File

@ -0,0 +1,384 @@
<!-- 用户详情页面 -->
<route lang="json5">
{
style: {
navigationBarTitleText: '用户详情',
},
}
</route>
<template>
<view class="min-h-screen bg-gray-50">
<!-- 用户基本信息卡片 -->
<view class="bg-white rounded-b-3xl shadow-sm">
<view class="p-6">
<view class="flex items-center">
<image
:src="userInfo.avatar"
mode="aspectFill"
class="w-24 h-24 rounded-xl object-cover"
/>
<view class="ml-4 flex-1">
<view class="flex items-center justify-between">
<view class="flex items-center">
<text class="text-xl font-semibold text-gray-900">{{ userInfo.nickName }}</text>
<text class="ml-2 text-sm text-gray-500">
{{ calculateAge(userInfo.birthday) }}
</text>
</view>
<view
v-if="!isUnlocked"
class="px-4 py-1.5 bg-primary text-sm rounded-full flex items-center"
:class="{ 'opacity-50': isUnlocking }"
@tap="handleUnlock"
>
<text v-if="isUnlocking">解锁中...</text>
<text v-else>解锁</text>
</view>
<view
v-else
class="px-4 py-1.5 bg-red-500 text-sm text-white rounded-full flex items-center"
@tap="handleComplaint"
>
<text>投诉</text>
</view>
</view>
<view class="mt-2 flex items-center space-x-2">
<text class="text-sm text-gray-500">{{ userInfo.height }}cm</text>
<view class="w-px h-3 bg-gray-200"></view>
<text class="text-sm text-gray-500">{{ getEducationText(userInfo.education) }}</text>
<view class="w-px h-3 bg-gray-200"></view>
<text class="text-sm text-gray-500">{{ userInfo.workArea }}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 详细信息列表 -->
<view class="mt-4 bg-white rounded-3xl p-6">
<view class="space-y-6">
<!-- 基本信息 -->
<view class="space-y-4">
<text class="text-lg font-semibold text-gray-900">基本信息</text>
<view class="grid grid-cols-2 gap-4">
<view class="space-y-1">
<text class="text-sm text-gray-500">职业</text>
<view class="relative">
<text class="text-sm text-gray-900">{{ userInfo.profession }}</text>
<view
v-if="!isUnlocked"
class="absolute inset-0 bg-white/80 backdrop-blur-sm flex items-center justify-center"
>
<text class="text-sm text-gray-500">点击解锁查看</text>
</view>
</view>
</view>
<view class="space-y-1">
<text class="text-sm text-gray-500">月收入</text>
<view class="relative">
<text class="text-sm text-gray-900">
{{ getMonthlyIncomeText(userInfo.monthlyIncome) }}
</text>
<view
v-if="!isUnlocked"
class="absolute inset-0 bg-white/80 backdrop-blur-sm flex items-center justify-center"
>
<text class="text-sm text-gray-500">点击解锁查看</text>
</view>
</view>
</view>
<view class="space-y-1">
<text class="text-sm text-gray-500">婚姻状况</text>
<view class="relative">
<text class="text-sm text-gray-900">
{{ getMaritalStatusText(userInfo.maritalStatus) }}
</text>
<view
v-if="!isUnlocked"
class="absolute inset-0 bg-white/80 backdrop-blur-sm flex items-center justify-center"
>
<text class="text-sm text-gray-500">点击解锁查看</text>
</view>
</view>
</view>
<view class="space-y-1">
<text class="text-sm text-gray-500">住房情况</text>
<view class="relative">
<text class="text-sm text-gray-900">
{{ getHousingStatusText(userInfo.housingStatus) }}
</text>
<view
v-if="!isUnlocked"
class="absolute inset-0 bg-white/80 backdrop-blur-sm flex items-center justify-center"
>
<text class="text-sm text-gray-500">点击解锁查看</text>
</view>
</view>
</view>
</view>
</view>
<!-- 联系方式 -->
<view class="space-y-4">
<text class="text-lg font-semibold text-gray-900">联系方式</text>
<view class="grid grid-cols-2 gap-4">
<view class="space-y-1">
<text class="text-sm text-gray-500">微信号</text>
<view class="relative">
<text class="text-sm text-gray-900" @tap="copyText(userInfo.wechat)">
{{ userInfo.wechat }}
</text>
<view
v-if="!isUnlocked"
class="absolute inset-0 bg-white/80 backdrop-blur-sm flex items-center justify-center"
>
<text class="text-sm text-gray-500">点击解锁查看</text>
</view>
</view>
</view>
<view class="space-y-1">
<text class="text-sm text-gray-500">QQ</text>
<view class="relative">
<text class="text-sm text-gray-900" @tap="copyText(userInfo.qq)">
{{ userInfo.qq }}
</text>
<view
v-if="!isUnlocked"
class="absolute inset-0 bg-white/80 backdrop-blur-sm flex items-center justify-center"
>
<text class="text-sm text-gray-500">点击解锁查看</text>
</view>
</view>
</view>
<view class="space-y-1">
<text class="text-sm text-gray-500">邮箱</text>
<view class="relative">
<text class="text-sm text-gray-900" @tap="copyText(userInfo.email)">
{{ userInfo.email }}
</text>
<view
v-if="!isUnlocked"
class="absolute inset-0 bg-white/80 backdrop-blur-sm flex items-center justify-center"
>
<text class="text-sm text-gray-500">点击解锁查看</text>
</view>
</view>
</view>
</view>
</view>
<!-- 其他信息 -->
<view class="space-y-4">
<text class="text-lg font-semibold text-gray-900">其他信息</text>
<view class="space-y-4">
<view class="space-y-1">
<text class="text-sm text-gray-500">个人介绍</text>
<view class="relative">
<text class="text-sm text-gray-900">{{ userInfo.selfIntroduction }}</text>
<view
v-if="!isUnlocked"
class="absolute inset-0 bg-white/80 backdrop-blur-sm flex items-center justify-center"
>
<text class="text-sm text-gray-500">点击解锁查看</text>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue'
import type { LoginResponse } from '@/service/login/type'
import { checkIsLoveAPI, unlockUserAPI } from '@/service/love'
import { onLoad } from '@dcloudio/uni-app'
//
const userInfo = ref<LoginResponse>({} as LoginResponse)
const isUnlocked = ref(false)
const userId = ref('')
const isLoading = ref(false)
const isUnlocking = ref(false)
//
const calculateAge = (birthday: string | undefined) => {
if (!birthday) return '未知'
const birthDate = new Date(birthday)
const today = new Date()
let age = today.getFullYear() - birthDate.getFullYear()
const monthDiff = today.getMonth() - birthDate.getMonth()
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
age--
}
return age
}
//
const getMaritalStatusText = (status: string | undefined) => {
const statusMap = {
'1': '未婚',
'2': '离异',
'3': '丧偶',
}
return status ? statusMap[status as keyof typeof statusMap] || '未知' : '未知'
}
//
const getEducationText = (education: string | undefined) => {
const educationMap = {
'1': '初中',
'2': '高中',
'3': '大专',
'4': '本科',
'5': '硕士',
'6': '博士',
}
return education ? educationMap[education as keyof typeof educationMap] || '未知' : '未知'
}
//
const getMonthlyIncomeText = (income: string | undefined) => {
const incomeMap = {
'1': '3000以下',
'2': '3000~5000',
'3': '5000~8000',
'4': '8000~10000',
'5': '10000~20000',
'6': '20000以上',
}
return income ? incomeMap[income as keyof typeof incomeMap] || '未知' : '未知'
}
//
const getHousingStatusText = (status: string | undefined) => {
const statusMap = {
'1': '已购房(有贷款)',
'2': '已购房(无贷款)',
'3': '有能力购房',
'4': '无房',
'5': '无房希望对方解决',
'6': '无房希望双方解决',
'7': '与父母同住',
'8': '独自租房',
'9': '与人合租',
'10': '住单位房',
}
return status ? statusMap[status as keyof typeof statusMap] || '未知' : '未知'
}
//
const getUserDetail = async () => {
if (isLoading.value) return
try {
isLoading.value = true
} catch (error) {
console.error('获取用户信息失败:', error)
uni.showToast({
title: '获取用户信息失败',
icon: 'none',
})
} finally {
isLoading.value = false
}
}
//
const checkIsUnlocked = async () => {
try {
const res = await checkIsLoveAPI(userId.value)
if (res.code === 200) {
isUnlocked.value = res.data
}
} catch (error) {
console.error('检查解锁状态失败:', error)
uni.showToast({
title: '检查解锁状态失败',
icon: 'none',
})
}
}
//
const handleUnlock = async () => {
if (isUnlocking.value) return
try {
isUnlocking.value = true
const res = await unlockUserAPI(userId.value)
if (res.code === 200) {
isUnlocked.value = true
uni.showToast({
title: '解锁成功',
icon: 'success',
})
//
await getUserDetail()
} else {
uni.showToast({
title: (res.data as any) || '解锁失败',
icon: 'none',
})
}
} catch (error) {
console.error('解锁失败:', error)
uni.showToast({
title: '解锁失败',
icon: 'none',
})
} finally {
isUnlocking.value = false
}
}
//
const copyText = (text: string | undefined) => {
if (!text) return
if (!isUnlocked.value) {
uni.showToast({
title: '请先解锁查看',
icon: 'none',
})
return
}
uni.setClipboardData({
data: text,
success: () => {
uni.showToast({
title: '复制成功',
icon: 'success',
})
},
fail: () => {
uni.showToast({
title: '复制失败',
icon: 'none',
})
},
})
}
//
const handleComplaint = () => {
uni.navigateTo({
url: `/pages/my/complaint?id=${userId.value}`,
})
}
onLoad((options) => {
if (options.id) {
userId.value = options.id
getUserDetail()
checkIsUnlocked()
}
})
</script>
<style lang="scss">
@import '@/style/iconfont.css';
</style>

View File

@ -0,0 +1,128 @@
<template>
<view class="mb-8">
<view class="flex justify-between items-center mb-4">
<view class="flex items-center">
<text class="text-2xl font-bold text-primary">红娘推荐</text>
<view class="ml-2 px-2 py-1 bg-primary/10 rounded-full">
<text class="text-xs text-primary font-medium">专业认证</text>
</view>
</view>
<view class="flex items-center">
<text class="i-carbon-task-complete text-base text-primary"></text>
</view>
</view>
<view class="relative">
<view
class="p-5 bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow duration-300"
>
<view class="flex items-start">
<view class="relative">
<image
:src="matchmakerInfo.avatar"
mode="aspectFill"
class="w-24 h-24 rounded-full border-2 border-primary/20 shadow-md"
/>
<view
class="absolute -bottom-1 -right-1 w-7 h-7 bg-gradient-to-r from-primary to-primary/80 rounded-full flex items-center justify-center shadow-md"
>
<text class="i-carbon-task-complete text-xs text-white"></text>
</view>
</view>
<view class="ml-5 flex-1">
<view class="flex items-center mb-3">
<text class="text-xl font-bold text-gray-800 mr-3">{{ matchmakerInfo.name }}</text>
<view class="px-3 py-1 bg-gradient-to-r from-primary/10 to-primary/5 rounded-full">
<text class="text-sm text-primary font-medium">金牌红娘</text>
</view>
</view>
<view class="space-y-2.5">
<view class="flex items-center">
<text class="i-carbon-logo-wechat text-lg text-gray-500 mr-2"></text>
<text class="text-sm text-gray-600">微信: {{ matchmakerInfo.wechat }}</text>
</view>
<view class="flex items-center">
<text class="i-carbon-phone text-lg text-gray-500 mr-2"></text>
<text class="text-sm text-gray-600">电话: {{ matchmakerInfo.phone }}</text>
</view>
<view class="flex items-center text-sm">
<text class="i-carbon-star-filled text-lg text-yellow-400 mr-2"></text>
<text class="text-gray-600">
成功率:
<text class="font-medium text-primary">{{ matchmakerInfo.successRate }}%</text>
</text>
<text class="mx-3 text-gray-300">|</text>
<text class="text-gray-600">
服务人数:
<text class="font-medium text-primary">{{ matchmakerInfo.serviceCount }}+</text>
</text>
</view>
</view>
</view>
</view>
<view class="mt-5 pt-4 border-t border-gray-100">
<view class="flex justify-between items-center">
<view class="flex items-center">
<text class="i-carbon-chat text-lg text-gray-500 mr-2"></text>
<text class="text-sm text-gray-600">在线咨询</text>
</view>
<button
class="flex items-center px-5 py-2.5 bg-gradient-to-r from-[#FF6B6B] to-[#FF8E8E] text-white rounded-full text-sm font-medium shadow-md hover:shadow-lg hover:from-[#FF8E8E] hover:to-[#FF6B6B] transition-all duration-300 active:scale-95 active:shadow-inner"
@click="contactMatchmaker"
>
<text class="font-semibold">立即联系</text>
<text class="i-carbon-arrow-right text-xs ml-1"></text>
</button>
</view>
</view>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
interface MatchmakerInfo {
name: string
avatar: string
wechat: string
phone: string
successRate: number
serviceCount: number
}
//
const matchmakerInfo = ref<MatchmakerInfo>({
name: '王红娘',
avatar:
'https://bpic.588ku.com/element_origin_min_pic/23/07/11/d32dabe266d10da8b21bd640a2e9b611.jpg',
wechat: 'matchmaker123',
phone: '13800138000',
successRate: 98,
serviceCount: 1000,
})
//
const contactMatchmaker = () => {
uni.showActionSheet({
itemList: ['复制微信号', '拨打电话'],
success: (res) => {
if (res.tapIndex === 0) {
uni.setClipboardData({
data: matchmakerInfo.value.wechat,
success: () => {
uni.showToast({
title: '微信号已复制',
icon: 'success',
})
},
})
} else if (res.tapIndex === 1) {
uni.makePhoneCall({
phoneNumber: matchmakerInfo.value.phone,
})
}
},
})
}
</script>

23
src/pages/fate/fate.vue Normal file
View File

@ -0,0 +1,23 @@
<route lang="json5" type="page">
{
layout: 'default',
style: {
navigationBarTitleText: '红娘',
},
}
</route>
<template>
<view class="px-4 py-3">
<!-- 红娘推荐组件 -->
<MatchmakerRecommend />
</view>
</template>
<script lang="ts" setup>
import MatchmakerRecommend from './components/MatchmakerRecommend.vue'
</script>
<style lang="scss" scoped>
//
</style>

284
src/pages/index/index.vue Normal file
View File

@ -0,0 +1,284 @@
<!-- 使用 type="home" 属性设置首页其他页面不需要设置默认为page推荐使用json5更强大且允许注释 -->
<route lang="json5" type="home">
{
style: {
navigationBarTitleText: '',
},
}
</route>
<template>
<view class="px-4 py-3" @click="closeUserMenu">
<!-- 顶部栏 -->
<view class="flex items-center justify-between mb-6">
<text class="text-lg font-bold">可报名考试</text>
<view class="flex items-center relative" @click.stop>
<i class="i-carbon-user-filled text-xl mr-2 text-gray-600"></i>
<text
class="text-base font-bold text-gray-700 mr-4 cursor-pointer transition-colors duration-200 hover:text-gray-900"
@click="toggleUserMenu"
>
{{ userName }}
</text>
<!-- 用户菜单 -->
<transition name="menu-fade" mode="out-in">
<view
v-if="showUserMenu"
class="absolute top-full right-0 mt-2 bg-white rounded-lg shadow-lg border border-gray-200 z-50 min-w-32 transform origin-top-right"
>
<view
class="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer border-b border-gray-100 transition-colors duration-150"
@click="handleModifyPassword"
>
修改密码
</view>
<view
class="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer border-b border-gray-100 transition-colors duration-150"
@click="handleModifyProfile"
>
修改个人信息
</view>
<view
class="px-4 py-2 text-sm text-red-600 hover:bg-red-50 cursor-pointer transition-colors duration-150"
@click="handleLogout"
>
退出登录
</view>
</view>
</transition>
</view>
</view>
<!-- 考试列表 -->
<view class="space-y-6">
<view
v-for="exam in examList"
:key="exam.id"
class="bg-white rounded-lg shadow-xl p-4 border-2 border-gray-400 relative transition-all duration-300 hover:shadow-2xl hover:-translate-y-1 hover:border-gray-500 cursor-pointer"
style="
box-shadow:
0 8px 16px -4px rgba(0, 0, 0, 0.15),
0 4px 8px -2px rgba(0, 0, 0, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
"
@mouseenter="handleCardHover(exam.id)"
@mouseleave="handleCardLeave(exam.id)"
>
<!-- 第一行考试名称 -->
<view class="mb-2">
<text class="text-xl font-bold text-gray-800">{{ exam.examName }}</text>
</view>
<!-- 第二行考试时间 -->
<view class="mb-2">
<text class="text-base text-gray-600">
{{ formatExamTime(exam.examDate, exam.startTime, exam.endTime) }}
</text>
</view>
<!-- 第三行考试地点 -->
<view class="mb-2">
<text class="text-base text-gray-600">
{{
formatExamLocation(exam.provinceName, exam.cityName, exam.address, exam.locationName)
}}
</text>
</view>
<!-- 第四五行空白间距 -->
<view class="h-4"></view>
<view class="h-4"></view>
<!-- 第六行报名截止信息 -->
<view class="mb-4">
<text class="text-sm text-gray-500">报名截止{{ exam.registrationDeadline }}</text>
</view>
<!-- 报名按钮右下角 -->
<view class="absolute bottom-4 right-4">
<button
class="bg-black text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-gray-800 transition-colors duration-200"
@click="handleRegisterExam(exam)"
>
报名
</button>
</view>
</view>
</view>
<!-- 加载状态 -->
<view v-if="loading" class="text-center py-8">
<text class="text-gray-500">加载中...</text>
</view>
<!-- 空状态 -->
<view v-if="!loading && examList.length === 0" class="text-center py-8">
<text class="text-gray-500">暂无可报名的考试</text>
</view>
</view>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { getCanBookExamListAPI } from '@/service/exam'
defineOptions({
name: 'Home',
})
const userName = ref('')
const showUserMenu = ref(false)
const examList = ref([])
const loading = ref(false)
//
const getExamList = async () => {
loading.value = true
try {
const res = await getCanBookExamListAPI()
examList.value = res.data.list || []
} catch (error) {
console.error('获取考试列表失败:', error)
uni.showToast({
title: '获取考试列表失败',
icon: 'none',
})
} finally {
loading.value = false
}
}
//
const formatExamTime = (examDate: string, startTime: string, endTime: string) => {
if (!examDate || !startTime || !endTime) return ''
return `${examDate} ${startTime}-${endTime}`
}
//
const formatExamLocation = (
provinceName: string,
cityName: string,
address: string,
locationName: string,
) => {
const parts = [provinceName, cityName, address, locationName].filter(Boolean)
return parts.join(' ')
}
//
const handleRegisterExam = (exam: any) => {
uni.showModal({
title: '确认报名',
content: `确定要报名参加"${exam.examName}"吗?`,
success: (res) => {
if (res.confirm) {
// TODO:
uni.showToast({
title: '报名成功',
icon: 'success',
})
}
},
})
}
// hover
const handleCardHover = (examId: string) => {
// hover
console.log('卡片hover:', examId)
}
const handleCardLeave = (examId: string) => {
//
console.log('卡片离开:', examId)
}
//
const toggleUserMenu = () => {
showUserMenu.value = !showUserMenu.value
}
//
const closeUserMenu = () => {
showUserMenu.value = false
}
//
const handleModifyPassword = () => {
showUserMenu.value = false
uni.navigateTo({
url: '/pages/user/modify-password',
})
}
//
const handleModifyProfile = () => {
showUserMenu.value = false
uni.navigateTo({
url: '/pages/user/profile',
})
}
// 退
const handleLogout = () => {
showUserMenu.value = false
uni.showModal({
title: '提示',
content: '确定要退出登录吗?',
success: (res) => {
if (res.confirm) {
//
uni.removeStorageSync('token')
uni.removeStorageSync('loginData')
//
uni.reLaunch({
url: '/pages/login/index',
})
}
},
})
}
onLoad(() => {
//
try {
const loginData = uni.getStorageSync('loginData')
userName.value = loginData.user.nickName
} catch (e) {
userName.value = ''
}
//
getExamList()
})
//
onPullDownRefresh(() => {
getExamList().then(() => {
uni.stopPullDownRefresh()
})
})
</script>
<style scoped>
/* 菜单淡入淡出动画 */
.menu-fade-enter-active,
.menu-fade-leave-active {
transition: all 0.2s ease-in-out;
}
.menu-fade-enter-from {
opacity: 0;
transform: scale(0.95) translateY(-10px);
}
.menu-fade-leave-to {
opacity: 0;
transform: scale(0.95) translateY(-10px);
}
.menu-fade-enter-to,
.menu-fade-leave-from {
opacity: 1;
transform: scale(1) translateY(0);
}
</style>

86
src/pages/login/index.vue Normal file
View File

@ -0,0 +1,86 @@
<route lang="json5" type="page">
{
style: {
navigationBarTitleText: '',
},
}
</route>
<template>
<view class="min-h-screen flex items-center justify-center bg-gray-200 px-4">
<view
class="w-full max-w-md mx-auto bg-white rounded-2xl shadow-lg p-8 border border-gray-100 box-border"
>
<view class="mb-6 text-center">
<text class="text-2xl font-bold text-gray-800">艺术评测考试平台</text>
</view>
<view class="mb-4">
<input
v-model="account"
type="text"
placeholder="用户名/手机号"
class="w-full h-12 px-4 border-2 border-gray-300 rounded-lg focus:border-pink-400 outline-none bg-gray-50 text-base transition-all duration-200 box-border"
/>
</view>
<view class="mb-6">
<input
v-model="password"
type="password"
placeholder="密码"
class="w-full h-12 px-4 border-2 border-gray-300 rounded-lg focus:border-pink-400 outline-none bg-gray-50 text-base transition-all duration-200 box-border"
/>
</view>
<view class="flex mb-2 mt-4 gap-3">
<button
class="flex-1 h-12 bg-black text-white rounded-l-lg text-base font-semibold shadow hover:shadow-md transition-all duration-200 flex items-center justify-center border border-gray-300"
@click="handleLogin"
>
<span class="w-full text-center">登录</span>
</button>
<button
class="flex-1 h-12 bg-white text-black rounded-r-lg text-base font-semibold hover:bg-gray-50 transition-all duration-200 flex items-center justify-center border border-gray-300"
@click="handleRegister"
>
<span class="w-full text-center">注册</span>
</button>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { loginAPI } from '@/service/login'
import { useUserStore } from '@/store'
const useStore = useUserStore()
const account = ref('')
const password = ref('')
const handleLogin = async () => {
if (!account.value || !password.value) {
uni.showToast({ title: '请输入账号和密码', icon: 'none' })
return
}
try {
const res = await loginAPI({ username: account.value, password: password.value })
uni.setStorageSync('loginData', res.data)
uni.setStorageSync('x-token', res.data.token)
useStore.setUserInfo()
uni.reLaunch({ url: '/pages/index/index' })
} catch (error) {
uni.showToast({ title: '登录失败', icon: 'none' })
}
}
const handleRegister = () => {
//
uni.navigateTo({ url: '/pages/register/index' })
}
</script>
<style>
body {
background: #fafaff;
}
</style>

170
src/pages/my/activities.vue Normal file
View File

@ -0,0 +1,170 @@
<route lang="json5" type="page">
{
layout: 'default',
style: {
navigationBarTitleText: '我的活动',
},
}
</route>
<template>
<view class="min-h-screen bg-gray-50">
<!-- 顶部筛选 -->
<view class="sticky top-0 z-10 bg-white shadow-sm">
<view class="flex items-center justify-around p-4 border-b border-gray-100">
<view
v-for="(item, index) in filterOptions"
:key="index"
class="flex-1 text-center"
@tap="changeFilter(item.value)"
>
<text
:class="[
'text-sm py-2 px-4 rounded-full transition-colors',
currentFilter === item.value
? 'bg-sky-500 text-white'
: 'text-gray-600 hover:bg-gray-100',
]"
>
{{ item.label }}
</text>
</view>
</view>
</view>
<!-- 活动列表 -->
<view class="p-4">
<!-- 加载状态 -->
<view v-if="loading" class="flex justify-center items-center py-8">
<text class="text-gray-500">加载中...</text>
</view>
<!-- 错误状态 -->
<view v-else-if="error" class="flex justify-center items-center py-8">
<text class="text-red-500">{{ error }}</text>
</view>
<!-- 空状态 -->
<view v-else-if="!activities.length" class="flex justify-center items-center py-8">
<text class="text-gray-500">暂无活动</text>
</view>
<!-- 活动列表 -->
<view v-else class="space-y-4">
<view
class="bg-white rounded-2xl shadow-lg overflow-hidden transform transition-all duration-300 hover:scale-[1.02] active:scale-[0.98]"
v-for="item in activities"
:key="item.id"
@tap="viewActivity(item)"
>
<view class="relative">
<image :src="item.eventAvatar" mode="aspectFill" class="w-full h-40 object-cover" />
<view
class="absolute top-3 right-3 px-3 py-1 bg-white/90 rounded-full backdrop-blur-sm"
>
<text class="text-sm text-primary font-medium">¥{{ item.eventPrice }}</text>
</view>
<view
:class="[
'absolute top-3 left-3 px-3 py-1 rounded-full text-sm font-medium',
item.status === '1' ? 'bg-green-500/90 text-white' : 'bg-gray-500/90 text-white',
]"
>
{{ item.status === '1' ? '进行中' : '已结束' }}
</view>
</view>
<view class="p-4">
<text class="block text-lg font-bold mb-2">{{ item.eventTitle }}</text>
<view class="flex items-center text-sm text-gray-500">
<text class="iconfont icon-time mr-1"></text>
<text>截止时间: {{ formatDate(item.eventEndTime) }}</text>
</view>
<view class="mt-3 flex items-center justify-between">
<view class="flex items-center">
<view class="flex -space-x-2">
<image
v-for="(avatar, index) in getRandomAvatars(3)"
:key="index"
:src="avatar"
class="w-8 h-8 rounded-full border-2 border-white"
/>
</view>
<text class="ml-2 text-sm text-gray-500">已有 {{ item.eventNumber }} 人报名</text>
</view>
<view
class="px-4 py-2 bg-primary text-white text-sm rounded-full"
@tap.stop="handleJoin(item)"
>
查看详情
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue'
import { getMyEventListAPI } from '@/service/event'
import type { MyEventItem } from '@/service/event/type'
import { formatDate } from '@/utils/date'
//
const filterOptions = [
{ label: '全部', value: '1' },
{ label: '进行中', value: '2' },
{ label: '已结束', value: '3' },
]
//
const loading = ref(false)
const error = ref('')
const activities = ref<MyEventItem[]>([])
const currentFilter = ref('1')
//
const fetchActivities = async () => {
loading.value = true
error.value = ''
try {
const res = await getMyEventListAPI()
if (res.code === 200) {
activities.value = res.data
} else {
error.value = res.message || '获取活动列表失败'
}
} catch (err) {
error.value = '网络错误,请稍后重试'
console.error('获取活动列表失败:', err)
} finally {
loading.value = false
}
}
//
const changeFilter = (filter: string) => {
currentFilter.value = filter
fetchActivities()
}
//
const viewActivity = (item: MyEventItem) => {
uni.navigateTo({
url: `/pages/activity/detail?id=${item.id}`,
})
}
// 使
const getRandomAvatars = (count: number) => {
return Array(count).fill(
'https://bpic.588ku.com/element_origin_min_pic/23/07/11/d32dabe266d10da8b21bd640a2e9b611.jpg',
)
}
//
onMounted(() => {
fetchActivities()
})
</script>

212
src/pages/my/complaint.vue Normal file
View File

@ -0,0 +1,212 @@
<route lang="json5" type="page">
{
layout: 'default',
style: {
navigationBarTitleText: '发起投诉',
},
}
</route>
<template>
<view class="min-h-screen bg-gray-50 p-4">
<view class="bg-white rounded-2xl shadow-sm p-4">
<!-- 投诉分类 -->
<view class="mb-4">
<text class="text-sm text-gray-600 mb-2 block">投诉分类</text>
<view class="grid grid-cols-2 gap-2">
<view
v-for="item in complaintClasses"
:key="item.value"
class="h-12 px-4 bg-gray-50 rounded-lg flex items-center justify-center"
:class="{
'bg-sky-50 border border-sky-200': selectedClass === item.value,
'border border-gray-100': selectedClass !== item.value,
}"
@click="selectedClass = item.value"
>
<text
:class="{
'text-sky-600': selectedClass === item.value,
'text-gray-600': selectedClass !== item.value,
}"
>
{{ item.label }}
</text>
</view>
</view>
</view>
<!-- 投诉内容 -->
<view class="mb-4">
<text class="text-sm text-gray-600 mb-2 block">投诉详情</text>
<textarea
v-model="complaintContent"
class="w-full h-40 px-4 py-3 bg-gray-50 rounded-lg text-gray-800 placeholder-gray-400"
placeholder="请详细描述您遇到的问题..."
maxlength="500"
/>
<text class="text-xs text-gray-400 text-right block mt-1">
{{ complaintContent.length }}/500
</text>
</view>
<!-- 图片上传 -->
<view class="mb-4">
<text class="text-sm text-gray-600 mb-2 block">上传证据选填</text>
<view class="grid grid-cols-3 gap-2">
<view v-for="(image, index) in imageList" :key="index" class="relative aspect-square">
<image :src="image" class="w-full h-full rounded-lg object-cover" mode="aspectFill" />
<view
class="absolute top-1 right-1 w-6 h-6 bg-black/50 rounded-full flex items-center justify-center"
@click="removeImage(index)"
>
<i class="i-carbon-close text-white text-sm"></i>
</view>
</view>
<view
v-if="imageList.length < 9"
class="aspect-square bg-gray-50 rounded-lg flex items-center justify-center border border-dashed border-gray-200"
@click="chooseImage"
>
<i class="i-carbon-image text-gray-400 text-2xl"></i>
</view>
</view>
<text class="text-xs text-gray-400 mt-1 block">最多上传9张图片每张不超过5MB</text>
</view>
<!-- 提交按钮 -->
<button
class="w-full h-12 bg-gradient-to-r from-sky-400 to-indigo-400 text-white rounded-lg text-base flex items-center justify-center shadow-sm hover:shadow-md transition-shadow"
:disabled="!isValid"
:class="{ 'opacity-50': !isValid }"
@click="submitComplaint"
>
提交投诉
</button>
</view>
</view>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue'
import { addComplaintAPI } from '@/service/feedback'
import { uploadImageAPI } from '@/service/file'
import { onLoad } from '@dcloudio/uni-app'
const id = ref('')
onLoad((options) => {
id.value = options.id
})
//
const complaintClasses = [
{ label: '虚假宣传', value: '虚假宣传' },
{ label: '微商', value: '微商' },
{ label: '诈骗', value: '诈骗' },
{ label: '个人信息不符', value: '个人信息不符' },
]
const selectedClass = ref('')
const complaintContent = ref('')
const imageList = ref<string[]>([])
//
const isValid = computed(() => {
return selectedClass.value && complaintContent.value.trim().length > 0
})
//
const chooseImage = async () => {
try {
const res = await uni.chooseImage({
count: 9 - imageList.value.length,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
})
//
const tempFiles = res.tempFiles as UniApp.ChooseImageSuccessCallbackResultFile[]
const tempFilePaths = res.tempFilePaths as string[]
const validImages = tempFilePaths.filter((path) => {
const file = tempFiles.find((f) => f.path === path)
return file && file.size <= 5 * 1024 * 1024 // 5MB
})
if (validImages.length !== tempFilePaths.length) {
uni.showToast({
title: '部分图片超过5MB限制',
icon: 'none',
})
}
imageList.value.push(...validImages)
} catch (error) {
console.error('选择图片失败:', error)
}
}
//
const removeImage = (index: number) => {
imageList.value.splice(index, 1)
}
//
const uploadImages = async () => {
const uploadedUrls: string[] = []
for (const image of imageList.value) {
try {
const res = await uploadImageAPI(image, 'complaint')
if (res.url) {
uploadedUrls.push(res.url)
}
} catch (error) {
console.error('上传图片失败:', error)
uni.showToast({
title: '图片上传失败',
icon: 'none',
})
}
}
return uploadedUrls
}
//
const submitComplaint = async () => {
if (!isValid.value) return
try {
uni.showLoading({ title: '提交中...' })
//
const imageUrls = await uploadImages()
//
await addComplaintAPI({
userId: id.value, // TODO: ID
complaintClass: selectedClass.value as any,
complaintContent: complaintContent.value.trim(),
complaintImageList: imageUrls,
})
uni.hideLoading()
uni.showToast({
title: '提交成功',
icon: 'success',
})
//
setTimeout(() => {
uni.navigateBack()
}, 1500)
} catch (error) {
uni.hideLoading()
uni.showToast({
title: '提交失败,请重试',
icon: 'none',
})
}
}
</script>

92
src/pages/my/feedback.vue Normal file
View File

@ -0,0 +1,92 @@
<route lang="json5" type="page">
{
layout: 'default',
style: {
navigationBarTitleText: '意见反馈',
},
}
</route>
<template>
<view class="min-h-screen bg-gray-50 p-4">
<view class="bg-white rounded-2xl shadow-sm p-4">
<!-- 标题输入 -->
<view class="mb-4">
<text class="text-sm text-gray-600 mb-2 block">反馈标题</text>
<input
v-model="feedbackTitle"
class="w-full h-12 px-4 bg-gray-50 rounded-lg text-gray-800 placeholder-gray-400"
placeholder="请输入反馈标题"
maxlength="50"
/>
</view>
<!-- 内容输入 -->
<view class="mb-4">
<text class="text-sm text-gray-600 mb-2 block">反馈内容</text>
<textarea
v-model="feedbackContent"
class="w-full h-40 px-4 py-3 bg-gray-50 rounded-lg text-gray-800 placeholder-gray-400"
placeholder="请详细描述您遇到的问题或建议..."
maxlength="500"
/>
<text class="text-xs text-gray-400 text-right block mt-1">
{{ feedbackContent.length }}/500
</text>
</view>
<!-- 提交按钮 -->
<button
class="w-full h-12 bg-gradient-to-r from-sky-400 to-indigo-400 text-white rounded-lg text-base flex items-center justify-center shadow-sm hover:shadow-md transition-shadow"
:disabled="!isValid"
:class="{ 'opacity-50': !isValid }"
@click="submitFeedback"
>
提交反馈
</button>
</view>
</view>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue'
import { addFeedbackAPI } from '@/service/feedback'
const feedbackTitle = ref('')
const feedbackContent = ref('')
//
const isValid = computed(() => {
return feedbackTitle.value.trim().length > 0 && feedbackContent.value.trim().length > 0
})
//
const submitFeedback = async () => {
if (!isValid.value) return
try {
uni.showLoading({ title: '提交中...' })
await addFeedbackAPI({
feedbackTitle: feedbackTitle.value.trim(),
feedbackContent: feedbackContent.value.trim(),
})
uni.hideLoading()
uni.showToast({
title: '提交成功',
icon: 'success',
})
//
setTimeout(() => {
uni.navigateBack()
}, 1500)
} catch (error) {
uni.hideLoading()
uni.showToast({
title: '提交失败,请重试',
icon: 'none',
})
}
}
</script>

274
src/pages/my/invite.vue Normal file
View File

@ -0,0 +1,274 @@
<route lang="json5" type="page">
{
layout: 'default',
style: {
navigationBarTitleText: '邀请好友',
},
}
</route>
<template>
<view class="min-h-screen bg-gray-50 pb-20">
<!-- 邀请码卡片 -->
<view class="mx-4 mt-4">
<view class="bg-white rounded-2xl shadow-sm p-6">
<view class="text-center">
<text class="text-lg font-bold text-gray-800 mb-2 block">我的邀请码</text>
<view class="flex items-center justify-center space-x-2 mb-4">
<text class="text-3xl font-bold text-sky-500">{{ inviteCode }}</text>
<button
class="px-3 py-1 bg-sky-50 text-sky-500 rounded-full text-sm"
@click="copyInviteCode"
>
复制
</button>
</view>
<text class="text-sm text-gray-500">邀请好友注册时填写此邀请码双方均可获得奖励</text>
</view>
</view>
</view>
<!-- 邀请记录 -->
<view class="mx-4 mt-4">
<view class="bg-white rounded-2xl shadow-sm overflow-hidden">
<view class="p-4 border-b border-gray-100">
<text class="text-base font-bold text-gray-800">邀请记录</text>
</view>
<view v-if="inviteList.length === 0" class="p-8 text-center">
<text class="text-gray-500">暂无邀请记录</text>
</view>
<view v-else class="divide-y divide-gray-100">
<view v-for="(item, index) in inviteList" :key="index" class="p-4">
<!-- 一级用户 -->
<view class="flex items-center justify-between">
<view class="flex-1">
<text class="text-gray-800 block">{{ item.nickname }}</text>
<text class="text-sm text-gray-500">{{ item.createTime }}</text>
</view>
<view class="flex items-center space-x-2">
<button
class="px-2 py-1 bg-gradient-to-r from-sky-400 to-indigo-400 text-white rounded-lg text-xs flex items-center shadow-sm hover:shadow transition-all duration-300"
@click.stop="
navigateTo('/pages/my/spend-detail', {
nickname: item.nickname,
id: item.id,
points: item.points,
})
"
>
<i class="i-carbon-money text-xs mr-1"></i>
消费明细
</button>
<view class="w-8 h-8 flex items-center justify-center" @click="toggleExpand(index)">
<i
:class="[
expandedItems[index] ? 'i-carbon-chevron-down' : 'i-carbon-chevron-right',
'text-gray-400',
]"
></i>
</view>
</view>
</view>
<!-- 子用户列表 -->
<view
v-if="item.child && item.child.length > 0 && expandedItems[index]"
class="mt-3 ml-4 pl-4 border-l-2 border-gray-100"
>
<view v-for="(child, childIndex) in item.child" :key="childIndex" class="py-3">
<!-- 子用户信息 -->
<view class="flex items-center justify-between">
<view class="flex-1">
<text class="text-gray-800 block">{{ child.nickname }}</text>
<text class="text-sm text-gray-500">{{ child.createTime }}</text>
</view>
<view class="flex items-center space-x-2">
<button
class="px-2 py-1 bg-gradient-to-r from-sky-400 to-indigo-400 text-white rounded-lg text-xs flex items-center shadow-sm hover:shadow transition-all duration-300"
@click.stop="
navigateTo('/pages/my/spend-detail', {
nickname: child.nickname,
id: child.id,
points: child.points,
})
"
>
<i class="i-carbon-money text-xs mr-1"></i>
消费明细
</button>
<view
class="w-8 h-8 flex items-center justify-center"
@click="toggleChildExpand(index, childIndex)"
>
<i
:class="[
expandedChildItems[`${index}-${childIndex}`]
? 'i-carbon-chevron-down'
: 'i-carbon-chevron-right',
'text-gray-400',
]"
></i>
</view>
</view>
</view>
<!-- 孙用户列表 -->
<view
v-if="
child.child &&
child.child.length > 0 &&
expandedChildItems[`${index}-${childIndex}`]
"
class="mt-3 ml-4 pl-4 border-l-2 border-gray-100"
>
<view
v-for="(grandChild, grandChildIndex) in child.child"
:key="grandChildIndex"
class="py-3 flex items-center justify-between"
>
<view class="flex-1">
<text class="text-gray-800 block">{{ grandChild.nickname }}</text>
<text class="text-sm text-gray-500">{{ grandChild.createTime }}</text>
</view>
<view class="flex items-center space-x-2">
<button
class="px-2 py-1 bg-gradient-to-r from-sky-400 to-indigo-400 text-white rounded-lg text-xs flex items-center shadow-sm hover:shadow transition-all duration-300"
@click.stop="
navigateTo('/pages/my/spend-detail', {
nickname: grandChild.nickname,
id: grandChild.id,
points: grandChild.points,
})
"
>
<i class="i-carbon-money text-xs mr-1"></i>
消费明细
</button>
<view class="w-8 h-8 flex items-center justify-center">
<i class="i-carbon-chevron-right text-gray-300"></i>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 邀请说明 -->
<view class="mx-4 mt-4">
<view class="bg-white rounded-2xl shadow-sm p-4">
<text class="text-base font-bold text-gray-800 mb-2 block">邀请说明</text>
<view class="space-y-2 text-sm text-gray-600">
<text class="block">1. 邀请好友填写您的邀请码</text>
<text class="block">2. 好友注册成功后双方均可获得奖励</text>
<text class="block">3. 邀请记录将在好友注册成功后显示</text>
<text class="block">4. 如有疑问请联系客服</text>
</view>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue'
import { getInviteCodeAPI, getMyInviteListAPI } from '@/service/login'
interface InviteUserItem {
nickname: string
id: string | number
createTime?: string
child?: InviteUserItem[]
points?: number
}
const inviteCode = ref('')
const inviteList = ref<InviteUserItem[]>([])
const expandedItems = ref<{ [key: number]: boolean }>({})
const expandedChildItems = ref<{ [key: string]: boolean }>({})
// /
const toggleExpand = (index: number) => {
expandedItems.value[index] = !expandedItems.value[index]
}
// /
const toggleChildExpand = (parentIndex: number, childIndex: number) => {
const key = `${parentIndex}-${childIndex}`
expandedChildItems.value[key] = !expandedChildItems.value[key]
}
//
const getInviteCode = async () => {
try {
const res = await getInviteCodeAPI()
if (res.code === 200) {
inviteCode.value = res.data
console.log('邀请码:', res.data) //
}
} catch (error) {
console.error('获取邀请码失败:', error) //
uni.showToast({
title: '获取邀请码失败',
icon: 'none',
})
}
}
//
const getInviteList = async () => {
try {
const res = await getMyInviteListAPI()
if (res.code === 200 && Array.isArray(res.data)) {
inviteList.value = res.data
console.log('邀请记录数据:', res.data) //
}
} catch (error) {
console.error('获取邀请记录失败:', error) //
uni.showToast({
title: '获取邀请记录失败',
icon: 'none',
})
}
}
//
const copyInviteCode = () => {
if (!inviteCode.value) {
uni.showToast({
title: '邀请码获取失败',
icon: 'none',
})
return
}
uni.setClipboardData({
data: inviteCode.value,
success: () => {
uni.showToast({
title: '复制成功',
icon: 'success',
})
},
})
}
const navigateTo = (url: string, params?: Record<string, any>) => {
if (params) {
console.log('传递的参数:', params) //
const query = Object.entries(params)
.map(([key, value]) => `${key}=${encodeURIComponent(String(value))}`)
.join('&')
url = `${url}?${query}`
console.log('最终URL:', url) //
}
uni.navigateTo({ url })
}
onMounted(() => {
getInviteCode()
getInviteList()
})
</script>

122
src/pages/my/inviter.vue Normal file
View File

@ -0,0 +1,122 @@
<route lang="json5" type="page">
{
layout: 'default',
style: {
navigationBarTitleText: '我的邀请人',
},
}
</route>
<template>
<view class="min-h-screen bg-gray-50 pb-20">
<view class="mx-4 mt-4">
<view class="bg-white rounded-2xl shadow-sm p-6">
<view class="text-center">
<text class="text-lg font-bold text-gray-800 mb-4 block">邀请人信息</text>
<!-- 有邀请人时显示 -->
<template v-if="userInfo.inviterCode">
<view class="bg-gradient-to-r from-pink-50 to-rose-50 rounded-xl p-4 mb-4">
<text class="text-sm text-gray-500 mb-2 block">我的邀请人代码</text>
<text class="text-2xl font-bold text-pink-500 tracking-wider">
{{ userInfo.inviterCode }}
</text>
</view>
<text class="text-sm text-gray-500">感谢您使用我们的服务</text>
</template>
<!-- 无邀请人时显示 -->
<template v-else>
<view class="bg-gray-50 rounded-xl p-6 mb-4">
<text class="text-gray-500 mb-4 block">您还没有绑定邀请人</text>
<button
class="w-full bg-gradient-to-r from-pink-400 to-rose-400 text-white py-3 rounded-xl shadow-sm hover:shadow transition-all duration-300"
@click="showBindInviter"
>
绑定邀请人
</button>
</view>
</template>
</view>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue'
import { useUserStore } from '@/store'
import { bindInviterAPI } from '@/service/login'
const userStore = useUserStore()
const userInfo = ref<any>({})
const inviterCode = ref('')
//
const getUserInfo = async () => {
try {
await userStore.setUserInfo()
userInfo.value = userStore.userInfo
} catch (error) {
uni.showToast({
title: '获取用户信息失败',
icon: 'none',
})
}
}
//
const showBindInviter = () => {
uni.showModal({
title: '绑定邀请人',
editable: true,
placeholderText: '请输入邀请人代码',
success: async (res) => {
if (res.confirm && res.content) {
inviterCode.value = res.content
await bindInviter()
}
},
})
}
//
const bindInviter = async () => {
if (!inviterCode.value) {
uni.showToast({
title: '请输入邀请人代码',
icon: 'none',
})
return
}
try {
const res = await bindInviterAPI(inviterCode.value)
if (res.code === 200) {
uni.showToast({
title: '绑定成功',
icon: 'success',
})
getUserInfo()
} else {
throw new Error(res.message || '绑定失败')
}
} catch (error) {
uni.showToast({
title: error.message || '绑定失败',
icon: 'none',
})
}
}
onMounted(() => {
getUserInfo()
})
</script>
<style>
/* 确保弹窗样式正确加载 */
:deep(.uni-popup) {
z-index: 999;
}
</style>

211
src/pages/my/matches.vue Normal file
View File

@ -0,0 +1,211 @@
<route lang="json5" type="page">
{
layout: 'default',
style: {
navigationBarTitleText: '我的配对',
},
}
</route>
<template>
<view class="min-h-screen bg-gray-50">
<!-- 列表内容 -->
<scroll-view
scroll-y
class="h-screen"
refresher-enabled
:refresher-triggered="isRefreshing"
@refresherrefresh="onRefresh"
@scrolltolower="loadMore"
>
<!-- 配对列表 -->
<view class="p-4 space-y-4">
<view
v-for="item in matchList"
:key="item.id"
class="bg-white rounded-2xl shadow-sm overflow-hidden"
>
<view class="p-4">
<view class="flex items-start">
<!-- 头像 -->
<image
class="w-20 h-20 rounded-xl"
:src="item.avatar || defaultAvatar"
mode="aspectFill"
/>
<!-- 基本信息 -->
<view class="flex-1 ml-4">
<view class="flex items-center mb-1">
<text class="text-lg font-bold text-gray-800">{{ item.nickname }}</text>
<text class="ml-2 text-sm text-gray-500">{{ item.age }}</text>
</view>
<view class="flex items-center space-x-2 mb-2">
<text class="text-sm text-gray-600">{{ item.height }}cm</text>
<text class="text-sm text-gray-600">|</text>
<text class="text-sm text-gray-600">
{{ getEducationText(item.educationLevel) }}
</text>
</view>
<view class="text-sm text-gray-600">
{{ item.workArea }} · {{ item.profession }}
</view>
</view>
</view>
<!-- 操作按钮 -->
<view class="mt-4 flex space-x-3">
<button
class="flex-1 bg-gradient-to-r from-sky-400 to-indigo-400 text-white text-sm py-2 rounded-lg shadow-sm hover:shadow-md transition-shadow"
@tap="handleViewDetail(item)"
>
查看详情
</button>
<button
class="flex-1 bg-white border border-sky-200 text-sky-600 text-sm py-2 rounded-lg hover:bg-sky-50 transition-colors"
@tap="handleViewDetail(item)"
>
联系TA
</button>
</view>
</view>
</view>
</view>
<!-- 加载状态 -->
<view v-if="isLoading" class="flex justify-center py-6">
<view class="flex items-center space-x-2">
<view class="w-2 h-2 bg-sky-500 rounded-full animate-bounce"></view>
<view
class="w-2 h-2 bg-sky-500 rounded-full animate-bounce"
style="animation-delay: 0.2s"
></view>
<view
class="w-2 h-2 bg-sky-500 rounded-full animate-bounce"
style="animation-delay: 0.4s"
></view>
</view>
</view>
<view v-else-if="!hasMore" class="flex justify-center py-6">
<text class="text-sm text-gray-400">没有更多了</text>
</view>
</scroll-view>
</view>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue'
import { getMyLoveListAPI } from '@/service/love'
import type { LoveUserItem } from '@/service/love/type'
const defaultAvatar = '/static/images/default-avatar.png'
//
const matchList = ref<LoveUserItem[]>([])
const isLoading = ref(false)
const isRefreshing = ref(false)
const hasMore = ref(true)
//
const getEducationText = (level: string) => {
const educationMap: Record<string, string> = {
'1': '初中',
'2': '高中',
'3': '大专',
'4': '本科',
'5': '硕士',
'6': '博士',
}
return educationMap[level] || '未知'
}
//
const getMatchList = async () => {
if (isLoading.value) return
try {
isLoading.value = true
const res = await getMyLoveListAPI()
if (res.code === 200) {
matchList.value = res.data
hasMore.value = false // API
} else {
uni.showToast({
title: res.message || '获取配对列表失败',
icon: 'none',
})
}
} catch (error) {
console.error('获取配对列表失败:', error)
uni.showToast({
title: '获取配对列表失败',
icon: 'none',
})
} finally {
isLoading.value = false
isRefreshing.value = false
}
}
//
const onRefresh = async () => {
isRefreshing.value = true
await getMatchList()
}
//
const loadMore = () => {
if (!hasMore.value || isLoading.value) return
getMatchList()
}
//
const handleViewDetail = (item: LoveUserItem) => {
uni.navigateTo({
url: `/pages/detail/index?id=${item.id}`,
})
}
// TA
const handleContact = (item: LoveUserItem) => {
if (!item.isUnlocked) {
uni.showToast({
title: '请先解锁查看联系方式',
icon: 'none',
})
return
}
if (!item.wechatId) {
uni.showToast({
title: '对方暂未设置联系方式',
icon: 'none',
})
return
}
uni.showModal({
title: '联系方式',
content: `微信号:${item.wechatId}`,
showCancel: false,
})
}
onMounted(() => {
getMatchList()
})
</script>
<style lang="scss" scoped>
@keyframes bounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-6px);
}
}
.animate-bounce {
animation: bounce 1s infinite;
}
</style>

364
src/pages/my/my.vue Normal file
View File

@ -0,0 +1,364 @@
<route lang="json5" type="page">
{
layout: 'default',
style: {
navigationBarTitleText: '我的',
},
}
</route>
<template>
<view class="min-h-screen bg-gray-50">
<!-- 顶部背景 -->
<view
class="h-56 bg-gradient-to-br from-pink-50 via-rose-50 to-purple-50 relative overflow-hidden"
>
<view class="absolute top-0 left-0 right-0 bottom-0">
<view
class="absolute top-0 right-0 w-72 h-72 bg-gradient-to-br from-pink-200/40 to-transparent rounded-full blur-3xl"
></view>
<view
class="absolute bottom-0 left-0 w-72 h-72 bg-gradient-to-tr from-rose-200/40 to-transparent rounded-full blur-3xl"
></view>
</view>
</view>
<!-- 个人信息卡片 -->
<view class="mx-4 -mt-24 relative">
<view class="bg-white/95 backdrop-blur-md rounded-3xl shadow-xl p-6 border border-pink-100">
<view class="flex items-center">
<view class="relative">
<image
class="w-24 h-24 rounded-2xl border-4 border-white shadow-xl"
:src="userInfo.avatar || defaultAvatar"
mode="aspectFill"
@click="handleUploadAvatar"
/>
<view
v-if="userInfo.vipLevel"
class="absolute -bottom-2 -right-2 bg-gradient-to-r from-pink-400 to-rose-400 text-white px-3 py-1 rounded-full text-xs flex items-center shadow-lg"
>
<i class="i-carbon-crown text-sm mr-1"></i>
<text>VIP{{ userInfo.points }}</text>
</view>
</view>
<view class="flex-1 ml-6">
<view class="flex flex-wrap items-center gap-2 mb-3">
<text class="text-xl font-bold text-gray-800 truncate max-w-[140px]">
{{ userInfo.nickName }}
</text>
<view
class="px-3 py-1 bg-pink-50 text-pink-600 rounded-full text-xs border border-pink-100 whitespace-nowrap"
>
ID: {{ userInfo.id }}
</view>
<view
class="px-3 py-1 bg-rose-50 text-rose-600 rounded-full text-xs border border-rose-100 whitespace-nowrap"
>
<i class="i-carbon-money text-xs mr-1"></i>
余额: {{ userInfo.points || 0 }}
</view>
</view>
<view class="flex space-x-3">
<button
class="flex-1 bg-gradient-to-r from-pink-400 to-rose-400 text-white text-sm py-2 rounded-xl shadow-md hover:shadow-lg transition-all duration-300 flex items-center justify-center"
@click="navigateTo('/pages/my/profile')"
>
<i class="i-carbon-user-profile text-base mr-2"></i>
<text class="truncate">我的资料</text>
</button>
</view>
</view>
</view>
</view>
</view>
<!-- 数据统计 -->
<!-- <view class="mx-4 mt-4">
<view class="bg-white/80 backdrop-blur-sm rounded-2xl shadow-lg p-4 border border-gray-100">
<view class="grid grid-cols-3 gap-4">
<view class="text-center">
<view
class="w-12 h-12 bg-sky-50 rounded-full flex items-center justify-center mx-auto mb-2"
>
<i class="i-carbon-calendar text-2xl text-sky-500"></i>
</view>
<text class="text-2xl font-bold text-sky-500 block">
{{ userInfo.activityCount || 0 }}
</text>
<text class="text-sm text-gray-600">活动</text>
</view>
<view class="text-center">
<view
class="w-12 h-12 bg-indigo-50 rounded-full flex items-center justify-center mx-auto mb-2"
>
<i class="i-carbon-favorite text-2xl text-indigo-500"></i>
</view>
<text class="text-2xl font-bold text-indigo-500 block">
{{ userInfo.matchCount || 0 }}
</text>
<text class="text-sm text-gray-600">配对</text>
</view>
<view class="text-center">
<view
class="w-12 h-12 bg-purple-50 rounded-full flex items-center justify-center mx-auto mb-2"
>
<i class="i-carbon-group text-2xl text-purple-500"></i>
</view>
<text class="text-2xl font-bold text-purple-500 block">
{{ userInfo.groupCount || 0 }}
</text>
<text class="text-sm text-gray-600">群组</text>
</view>
</view>
</view>
</view> -->
<!-- 功能菜单 -->
<view class="mx-4 mt-6">
<view
class="bg-white/95 backdrop-blur-md rounded-3xl shadow-xl overflow-hidden border border-pink-100"
>
<view
class="flex items-center justify-between px-6 py-4 border-b border-pink-50 active:bg-pink-50/50 transition-colors"
v-for="(item, index) in menuItems"
:key="index"
@click="navigateTo(item.path)"
>
<view class="flex items-center">
<view
class="w-12 h-12 rounded-2xl bg-gradient-to-br from-pink-50 to-rose-50 flex items-center justify-center mr-4 shadow-sm"
>
<i :class="item.icon" class="text-xl text-pink-500"></i>
</view>
<view>
<text class="text-base font-medium text-gray-800 block">{{ item.title }}</text>
<text class="text-xs text-gray-500">{{ item.desc }}</text>
</view>
</view>
<i class="i-carbon-chevron-right text-pink-300"></i>
</view>
</view>
</view>
<!-- 退出登录按钮 -->
<view class="mx-4 my-6 mb-20">
<button
class="w-full h-14 bg-white text-rose-500 rounded-xl text-base font-medium flex items-center justify-center shadow-md hover:shadow-lg transition-all duration-300 border border-rose-100"
@click="handleLogout"
>
退出登录
</button>
</view>
<!-- 在线客服 -->
<!-- <view class="fixed bottom-6 left-0 right-0 px-4">
<button
class="w-full h-12 bg-gradient-to-r from-sky-400 to-indigo-400 text-white rounded-full text-base flex items-center justify-center shadow-lg hover:shadow-xl transition-shadow"
open-type="contact"
>
<i class="i-carbon-chat text-lg mr-2"></i>
在线客服
</button>
</view> -->
</view>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue'
import { logoutAPI, updateUserInfoAPI } from '@/service/login'
import { uploadImageAPI } from '@/service/file'
import type { UserInfo as ApiUserInfo } from '@/service/login/type'
import { onShow } from '@dcloudio/uni-app'
import { useUserStore } from '@/store'
const userStore = useUserStore()
const defaultAvatar = '/static/images/default-avatar.png'
interface UserInfo {
avatar: string
name: string
vipLevel: number
userId: string
profileCompletion: number
activityCount: number
matchCount: number
groupCount: number
points: number
}
const userInfo = ref<UserInfo>({
avatar: '',
name: '未设置',
vipLevel: 0,
userId: '---',
profileCompletion: 0,
activityCount: 0,
matchCount: 0,
groupCount: 0,
points: 0,
})
const menuItems = [
{
title: '我的活动',
desc: '查看已参与的活动',
path: '/pages/my/activities',
icon: 'i-carbon-calendar',
},
{
title: '我的配对',
desc: '查看配对信息',
path: '/pages/my/matches',
icon: 'i-carbon-favorite',
},
{
title: '邀请好友',
desc: '邀请好友加入',
path: '/pages/my/invite',
icon: 'i-carbon-user-multiple',
},
{
title: '消费记录',
desc: '查看消费明细',
path: '/pages/my/spend',
icon: 'i-carbon-money',
},
{
title: '充值记录',
desc: '查看充值',
path: '/pages/my/recharge',
icon: 'i-carbon-wallet',
},
{
title: '退款记录',
desc: '查看退款',
path: '/pages/my/refund',
icon: 'i-carbon-money',
},
// {
// title: '',
// desc: '',
// path: '/pages/my/complaint',
// icon: 'i-carbon-warning',
// },
{
title: '意见反馈',
desc: '帮助我们提供更好的服务',
path: '/pages/my/feedback',
icon: 'i-carbon-chat-bot',
},
{
title: '我的邀请人',
desc: '查看或绑定邀请人',
path: '/pages/my/inviter',
icon: 'i-carbon-user-follow',
},
{
title: '我的下级',
desc: '查看我的下级',
path: '/pages/my/subordinate',
icon: 'i-carbon-user-multiple',
},
]
const navigateTo = (url: string) => {
uni.navigateTo({ url })
}
// 退
const handleLogout = async () => {
try {
const res = await logoutAPI()
if (res.code === 200) {
uni.showToast({
title: '退出成功',
icon: 'success',
})
//
uni.removeStorageSync('x-token')
uni.removeStorageSync('userInfo')
//
setTimeout(() => {
uni.reLaunch({
url: '/pages/login/index',
})
}, 1500)
}
} catch (error) {
uni.showToast({
title: '退出失败',
icon: 'none',
})
}
}
const handleUploadAvatar = () => {
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: async (res) => {
const tempFilePath = res.tempFilePaths[0]
try {
//
uni.showLoading({
title: '上传中...',
mask: true,
})
//
const uploadRes = await uploadImageAPI(tempFilePath, 'avatar')
if (uploadRes.url) {
//
const updateRes = await userStore.updateUserInfo({
avatar: uploadRes.url,
})
if (updateRes.code === 200) {
//
userInfo.value.avatar = uploadRes.url
uni.showToast({
title: '头像更新成功',
icon: 'success',
})
} else {
throw new Error(updateRes.message)
}
} else {
throw new Error('上传失败')
}
} catch (error) {
uni.showToast({
title: error.message || '上传失败',
icon: 'none',
})
} finally {
uni.hideLoading()
}
},
})
}
onShow(() => {
//
getUserInfo()
})
const getUserInfo = async () => {
try {
await userStore.setUserInfo()
userInfo.value = userStore.userInfo as any
} catch (error) {
uni.showToast({
title: '获取用户信息失败',
icon: 'none',
})
}
}
</script>
<style lang="scss" scoped>
// iconfont 使 Carbon
</style>

944
src/pages/my/profile.vue Normal file
View File

@ -0,0 +1,944 @@
<route lang="json5" type="page">
{
layout: 'default',
style: {
navigationBarTitleText: '我的资料',
},
}
</route>
<template>
<view class="min-h-screen bg-gray-50 pb-20">
<!-- 头像区域 -->
<view class="mx-4 mt-4">
<view class="avatar-section">
<image class="avatar" :src="userInfo.avatar || defaultAvatar" mode="aspectFill" />
<view class="info">
<view class="name">{{ userInfo.nickName || '未设置昵称' }}</view>
<view class="id">ID: {{ userInfo.id }}</view>
<view class="location">{{ userInfo.workArea || '未设置工作地区' }}</view>
</view>
</view>
</view>
<!-- 资料编辑表单 -->
<view class="mx-4">
<!-- 基本信息 -->
<view class="card">
<text class="card-title">基本信息</text>
<view class="form-item">
<text class="label">昵称</text>
<input v-model="userInfo.nickName" class="value" placeholder="请输入昵称" />
</view>
<view class="form-item">
<text class="label">生日</text>
<picker mode="date" :value="userInfo.birthday" @change="onBirthdayChange" class="value">
<text>{{ userInfo.birthday || '请选择生日' }}</text>
</picker>
</view>
<view class="form-item">
<text class="label">身高(cm)</text>
<input v-model="userInfo.height" type="number" class="value" placeholder="请输入身高" />
</view>
<view class="form-item">
<text class="label">体重(kg)</text>
<input v-model="userInfo.weight" type="number" class="value" placeholder="请输入体重" />
</view>
<view class="form-item">
<text class="label">婚姻状况</text>
<picker
:range="maritalStatusOptions"
:value="maritalStatusIndex"
@change="onMaritalStatusChange"
class="value"
>
<text>{{ maritalStatusOptions[maritalStatusIndex] || '请选择婚姻状况' }}</text>
</picker>
</view>
<view class="form-item">
<text class="label">血型</text>
<input v-model="userInfo.bloodType" class="value" placeholder="请输入血型" />
</view>
<view class="form-item">
<text class="label">民族</text>
<input v-model="userInfo.nation" class="value" placeholder="请输入民族" />
</view>
</view>
<!-- 工作信息 -->
<view class="card">
<text class="card-title">工作信息</text>
<view class="form-item">
<text class="label">工作省份</text>
<picker
:range="provinces"
:value="provinceIndex"
@change="onProvinceChange"
class="value"
>
<text>{{ userInfo.workProvinceName || '请选择省份' }}</text>
</picker>
</view>
<view class="form-item">
<text class="label">工作城市</text>
<picker
:range="getCurrentCities()"
:value="cityIndex"
@change="onCityChange"
class="value"
>
<text>{{ userInfo.workCityName || '请选择城市' }}</text>
</picker>
</view>
<view class="form-item">
<text class="label">工作区县</text>
<picker
:range="getCurrentDistricts()"
:value="districtIndex"
@change="onDistrictChange"
class="value"
>
<text>{{ userInfo.workCountryName || '请选择区县' }}</text>
</picker>
</view>
<view class="form-item">
<text class="label">工作地址</text>
<input v-model="userInfo.workArea" class="value" placeholder="请输入工作地址" />
</view>
<view class="form-item">
<text class="label">工作单位</text>
<input v-model="userInfo.workUnit" class="value" placeholder="请输入工作单位" />
</view>
<view class="form-item">
<text class="label">单位类型</text>
<picker
:range="companyTypeOptions"
:value="companyTypeIndex"
@change="onCompanyTypeChange"
class="value"
>
<text>{{ companyTypeOptions[companyTypeIndex] || '请选择单位类型' }}</text>
</picker>
</view>
<view class="form-item">
<text class="label">工作行业</text>
<input v-model="userInfo.workIndustry" class="value" placeholder="请输入工作行业" />
</view>
<view class="form-item">
<text class="label">职业</text>
<input v-model="userInfo.profession" class="value" placeholder="请输入职业" />
</view>
<view class="form-item">
<text class="label">月收入</text>
<picker
:range="incomeOptions"
:value="incomeIndex"
@change="onIncomeChange"
class="value"
>
<text>{{ incomeOptions[incomeIndex] || '请选择月收入' }}</text>
</picker>
</view>
<view class="form-item">
<text class="label">学历</text>
<picker
:range="educationOptions"
:value="educationIndex"
@change="onEducationChange"
class="value"
>
<text>{{ educationOptions[educationIndex] || '请选择学历' }}</text>
</picker>
</view>
<view class="form-item">
<text class="label">毕业院校</text>
<input v-model="userInfo.graduationSchool" class="value" placeholder="请输入毕业院校" />
</view>
</view>
<!-- 住房资产 -->
<view class="card">
<text class="card-title">住房资产</text>
<view class="form-item">
<text class="label">住房状况</text>
<picker
:range="housingStatusOptions"
:value="housingStatusIndex"
@change="onHousingStatusChange"
class="value"
>
<text>{{ housingStatusOptions[housingStatusIndex] || '请选择住房状况' }}</text>
</picker>
</view>
<view class="form-item">
<text class="label">购车情况</text>
<picker
:range="carOwnershipOptions"
:value="carOwnershipIndex"
@change="onCarOwnershipChange"
class="value"
>
<text>{{ carOwnershipOptions[carOwnershipIndex] || '请选择购车情况' }}</text>
</picker>
</view>
</view>
<!-- 家庭背景 -->
<view class="card">
<text class="card-title">家庭背景</text>
<view class="form-item">
<text class="label">父母状况</text>
<picker
:range="parentsStatusOptions"
:value="parentsStatusIndex"
@change="onParentsStatusChange"
class="value"
>
<text>{{ parentsStatusOptions[parentsStatusIndex] || '请选择父母状况' }}</text>
</picker>
</view>
<view class="form-item">
<text class="label">兄弟姐妹</text>
<picker
:range="siblingsOptions"
:value="siblingsIndex"
@change="onSiblingsChange"
class="value"
>
<text>{{ siblingsOptions[siblingsIndex] || '请选择兄弟姐妹' }}</text>
</picker>
</view>
<view class="form-item">
<text class="label">与父母同住</text>
<picker
:range="liveWithParentsOptions"
:value="liveWithParentsIndex"
@change="onLiveWithParentsChange"
class="value"
>
<text>
{{ liveWithParentsOptions[liveWithParentsIndex] || '请选择是否与父母同住' }}
</text>
</picker>
</view>
<view class="form-item">
<text class="label">婚娶形式</text>
<picker
:range="marriageFormOptions"
:value="marriageFormIndex"
@change="onMarriageFormChange"
class="value"
>
<text>{{ marriageFormOptions[marriageFormIndex] || '请选择婚娶形式' }}</text>
</picker>
</view>
<view class="form-item">
<text class="label">子女情况</text>
<picker
:range="childrenStatusOptions"
:value="childrenStatusIndex"
@change="onChildrenStatusChange"
class="value"
>
<text>{{ childrenStatusOptions[childrenStatusIndex] || '请选择子女情况' }}</text>
</picker>
</view>
</view>
<!-- 生活习惯 -->
<view class="card">
<text class="card-title">生活习惯</text>
<view class="form-item">
<text class="label">作息习惯</text>
<picker
:range="sleepHabitsOptions"
:value="sleepHabitsIndex"
@change="onSleepHabitsChange"
class="value"
>
<text>{{ sleepHabitsOptions[sleepHabitsIndex] || '请选择作息习惯' }}</text>
</picker>
</view>
<view class="form-item">
<text class="label">锻炼习惯</text>
<picker
:range="exerciseHabitsOptions"
:value="exerciseHabitsIndex"
@change="onExerciseHabitsChange"
class="value"
>
<text>{{ exerciseHabitsOptions[exerciseHabitsIndex] || '请选择锻炼习惯' }}</text>
</picker>
</view>
<view class="form-item">
<text class="label">是否吸烟</text>
<switch
:checked="userInfo.smoke === 1"
@change="(e) => (userInfo.smoke = e.detail.value ? 1 : 0)"
/>
</view>
<view class="form-item">
<text class="label">是否饮酒</text>
<switch
:checked="userInfo.drink === 1"
@change="(e) => (userInfo.drink = e.detail.value ? 1 : 0)"
/>
</view>
<view class="form-item">
<text class="label">兴趣爱好</text>
<input v-model="userInfo.hobbies" class="value" placeholder="请输入兴趣爱好" />
</view>
</view>
<!-- 择偶要求 -->
<view class="card">
<text class="card-title">择偶要求</text>
<view class="form-item">
<text class="label">期望结婚时间</text>
<picker
:range="expectedMarriageTimeOptions"
:value="expectedMarriageTimeIndex"
@change="onExpectedMarriageTimeChange"
class="value"
>
<text>
{{ expectedMarriageTimeOptions[expectedMarriageTimeIndex] || '请选择期望结婚时间' }}
</text>
</picker>
</view>
<view class="form-item">
<text class="label">婚况要求</text>
<checkbox-group @change="onPartnerMaritalStatusChange">
<label v-for="(item, index) in partnerMaritalStatusOptions" :key="index">
<checkbox
:value="item.value"
:checked="selectedPartnerMaritalStatus.includes(item.value)"
/>
<text>{{ item.label }}</text>
</label>
</checkbox-group>
</view>
<view class="form-item">
<text class="label">年龄范围</text>
<input v-model="userInfo.ageRange" class="value" placeholder="请输入年龄范围" />
</view>
<view class="form-item">
<text class="label">身高范围</text>
<input v-model="userInfo.heightRange" class="value" placeholder="请输入身高范围" />
</view>
<view class="form-item">
<text class="label">最低学历</text>
<picker
:range="educationOptions"
:value="Number(userInfo.minEducation) - 1"
@change="(e) => (userInfo.minEducation = String(Number(e.detail.value) + 1))"
class="value"
>
<text>
{{ educationOptions[Number(userInfo.minEducation) - 1] || '请选择最低学历' }}
</text>
</picker>
</view>
<view class="form-item">
<text class="label">最低收入</text>
<picker
:range="incomeOptions"
:value="Number(userInfo.minIncome) - 1"
@change="(e) => (userInfo.minIncome = String(Number(e.detail.value) + 1))"
class="value"
>
<text>{{ incomeOptions[Number(userInfo.minIncome) - 1] || '请选择最低收入' }}</text>
</picker>
</view>
<view class="form-item">
<text class="label">工作地区要求</text>
<input
v-model="userInfo.partnerWorkArea"
class="value"
placeholder="请输入工作地区要求"
/>
</view>
<view class="form-item">
<text class="label">其他要求</text>
<input v-model="userInfo.otherRequirements" class="value" placeholder="请输入其他要求" />
</view>
</view>
<!-- 联系方式 -->
<view class="card">
<text class="card-title">联系方式</text>
<view class="form-item">
<text class="label">手机号</text>
<input v-model="userInfo.phone" type="number" class="value" placeholder="请输入手机号" />
</view>
<view class="form-item">
<text class="label">微信号</text>
<input v-model="userInfo.wechat" class="value" placeholder="请输入微信号" />
</view>
<view class="form-item">
<text class="label">QQ</text>
<input v-model="userInfo.qq" type="number" class="value" placeholder="请输入QQ号" />
</view>
<view class="form-item">
<text class="label">邮箱</text>
<input v-model="userInfo.email" class="value" placeholder="请输入邮箱" />
</view>
</view>
<!-- 自我介绍 -->
<view class="card">
<text class="card-title">自我介绍</text>
<textarea v-model="userInfo.selfIntroduction" placeholder="请输入自我介绍" />
</view>
</view>
<!-- 保存按钮 -->
<button class="save-button" @click="saveUserInfo">保存修改</button>
</view>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue'
import type { UserInfo } from '@/service/login/type'
import { useUserStore } from '@/store'
import { provinces, cities, districts } from '@/utils/area-data'
const userStore = useUserStore()
const defaultAvatar = '/static/images/default-avatar.png'
const userInfo = ref<UserInfo>({} as UserInfo)
//
const maritalStatusOptions = ['未婚', '离异', '丧偶']
const maritalStatusIndex = ref(0)
//
const incomeOptions = [
'3000以下',
'3000~5000',
'5000~8000',
'8000~10000',
'10000~20000',
'20000以上',
]
const incomeIndex = ref(0)
//
const educationOptions = ['初中', '高中', '大专', '本科', '硕士', '博士']
const educationIndex = ref(0)
//
const housingStatusOptions = [
'已购房(有贷款)',
'已购房(无贷款)',
'有能力购房',
'无房',
'无房希望对方解决',
'无房希望双方解决',
'与父母同住',
'独自租房',
'与人合租',
'住单位房',
]
const housingStatusIndex = ref(0)
//
const carOwnershipOptions = [
'无车',
'已购车(经济型)',
'已购车(中档型)',
'已购车(豪华型)',
'单位用车',
'需要时购置',
]
const carOwnershipIndex = ref(0)
//
const expectedMarriageTimeOptions = ['随时', '半年内', '一年内', '两年内', '三年内']
const expectedMarriageTimeIndex = ref(0)
//
const parentsStatusOptions = ['父母均建在', '只有母亲建在', '只有父亲建在', '父母均以离世']
const parentsStatusIndex = ref(0)
//
const siblingsOptions = ['独生子女', '2个', '3个', '4个', '5个']
const siblingsIndex = ref(0)
//
const liveWithParentsOptions = ['愿意', '不愿意', '视具体情况而定', '尊重伴侣意见']
const liveWithParentsIndex = ref(0)
//
const marriageFormOptions = ['嫁娶', '两顾', '上门']
const marriageFormIndex = ref(0)
//
const childrenStatusOptions = ['未育', '子女归自己', '子女归对方']
const childrenStatusIndex = ref(0)
//
const partnerMaritalStatusOptions = [
{ label: '未婚', value: '1' },
{ label: '离异', value: '2' },
{ label: '丧偶', value: '3' },
]
const selectedPartnerMaritalStatus = ref<string[]>([])
//
const sleepHabitsOptions = [
'早睡早起很规律',
'经常夜猫子',
'总是早起鸟',
'偶尔懒散一下',
'没有规律',
]
const sleepHabitsIndex = ref(0)
//
const exerciseHabitsOptions = [
'每天锻炼',
'每周至少一次',
'每月几次',
'没时间锻炼',
'集中时间锻炼',
'不喜欢锻炼',
]
const exerciseHabitsIndex = ref(0)
//
const companyTypeOptions = [
'政府机关',
'事业单位',
'外资企业',
'合资企业',
'国营企业',
'私营企业',
'自有公司',
'其他',
]
const companyTypeIndex = ref(0)
//
const provinceIndex = ref(0)
const cityIndex = ref(0)
const districtIndex = ref(0)
//
const currentProvince = ref('')
const currentCity = ref('')
const currentDistrict = ref('')
//
const getCurrentCities = () => {
if (!currentProvince.value) return []
return cities[currentProvince.value] || []
}
//
const getCurrentDistricts = () => {
if (!currentProvince.value || !currentCity.value) return []
return districts[`${currentProvince.value}-${currentCity.value}`] || []
}
//
const onProvinceChange = (e: any) => {
const index = e.detail.value
provinceIndex.value = index
currentProvince.value = provinces[index]
cityIndex.value = 0
districtIndex.value = 0
currentCity.value = getCurrentCities()[0] || ''
currentDistrict.value = getCurrentDistricts()[0] || ''
//
userInfo.value.workProvinceName = currentProvince.value
userInfo.value.workCityName = currentCity.value
userInfo.value.workCountryName = currentDistrict.value
}
//
const onCityChange = (e: any) => {
const index = e.detail.value
cityIndex.value = index
currentCity.value = getCurrentCities()[index]
districtIndex.value = 0
currentDistrict.value = getCurrentDistricts()[0] || ''
//
userInfo.value.workCityName = currentCity.value
userInfo.value.workCountryName = currentDistrict.value
}
//
const onDistrictChange = (e: any) => {
const index = e.detail.value
districtIndex.value = index
currentDistrict.value = getCurrentDistricts()[index]
//
userInfo.value.workCountryName = currentDistrict.value
}
//
const setAreaIndexes = () => {
//
const provinceIdx = provinces.findIndex((p) => p === userInfo.value.workProvinceName)
if (provinceIdx !== -1) {
provinceIndex.value = provinceIdx
currentProvince.value = provinces[provinceIdx]
//
const cityList = getCurrentCities()
const cityIdx = cityList.findIndex((c) => c === userInfo.value.workCityName)
if (cityIdx !== -1) {
cityIndex.value = cityIdx
currentCity.value = cityList[cityIdx]
//
const districtList = getCurrentDistricts()
const districtIdx = districtList.findIndex((d) => d === userInfo.value.workCountryName)
if (districtIdx !== -1) {
districtIndex.value = districtIdx
currentDistrict.value = districtList[districtIdx]
}
}
}
}
//
const getUserInfo = async () => {
try {
const res = await userStore.setUserInfo()
if (res.code === 200) {
userInfo.value = res.data
// nickName
if (!userInfo.value.nickName && userInfo.value.nickname) {
userInfo.value.nickName = userInfo.value.nickname
}
//
maritalStatusIndex.value = Number(userInfo.value.maritalStatus) - 1
incomeIndex.value = Number(userInfo.value.monthlyIncome) - 1
educationIndex.value = Number(userInfo.value.education) - 1
housingStatusIndex.value = Number(userInfo.value.housingStatus) - 1
carOwnershipIndex.value = Number(userInfo.value.carOwnership) - 1
expectedMarriageTimeIndex.value = Number(userInfo.value.expectedMarriageTime) - 1
parentsStatusIndex.value = Number(userInfo.value.parentsStatus) - 1
siblingsIndex.value = Number(userInfo.value.siblings) - 1
liveWithParentsIndex.value = Number(userInfo.value.liveWithParents) - 1
marriageFormIndex.value = Number(userInfo.value.marriageForm) - 1
childrenStatusIndex.value = Number(userInfo.value.childrenStatus) - 1
sleepHabitsIndex.value = Number(userInfo.value.sleepHabits) - 1
exerciseHabitsIndex.value = Number(userInfo.value.exerciseHabits) - 1
companyTypeIndex.value = Number(userInfo.value.companyType) - 1
//
setAreaIndexes()
// minEducationminIncome
if (typeof userInfo.value.minEducation !== 'string') {
userInfo.value.minEducation = String(userInfo.value.minEducation || '1')
}
if (typeof userInfo.value.minIncome !== 'string') {
userInfo.value.minIncome = String(userInfo.value.minIncome || '1')
}
// smokedrink
if (typeof userInfo.value.smoke !== 'number') {
userInfo.value.smoke = Number(userInfo.value.smoke || 0)
}
if (typeof userInfo.value.drink !== 'number') {
userInfo.value.drink = Number(userInfo.value.drink || 0)
}
//
if (typeof userInfo.value.partnerMaritalStatus === 'string') {
selectedPartnerMaritalStatus.value = userInfo.value.partnerMaritalStatus
? userInfo.value.partnerMaritalStatus.split(',')
: []
}
}
} catch (error) {
uni.showToast({
title: '获取用户信息失败',
icon: 'none',
})
}
}
//
const saveUserInfo = async () => {
try {
//
userInfo.value.partnerMaritalStatus = selectedPartnerMaritalStatus.value.join(',')
const res = await userStore.updateUserInfo(userInfo.value)
if (res.code === 200) {
uni.showToast({
title: '保存成功',
icon: 'success',
})
//
setTimeout(() => {
uni.navigateBack()
}, 1500)
}
} catch (error) {
console.error('保存用户信息失败:', error)
uni.showToast({
title: '保存失败',
icon: 'none',
})
}
}
// change
const onMaritalStatusChange = (e: any) => {
maritalStatusIndex.value = e.detail.value
userInfo.value.maritalStatus = String(Number(e.detail.value) + 1)
}
const onHousingStatusChange = (e: any) => {
housingStatusIndex.value = e.detail.value
userInfo.value.housingStatus = String(Number(e.detail.value) + 1)
}
const onCarOwnershipChange = (e: any) => {
carOwnershipIndex.value = e.detail.value
userInfo.value.carOwnership = String(Number(e.detail.value) + 1)
}
const onExpectedMarriageTimeChange = (e: any) => {
expectedMarriageTimeIndex.value = e.detail.value
userInfo.value.expectedMarriageTime = String(Number(e.detail.value) + 1)
}
const onParentsStatusChange = (e: any) => {
parentsStatusIndex.value = e.detail.value
userInfo.value.parentsStatus = String(Number(e.detail.value) + 1)
}
const onSiblingsChange = (e: any) => {
siblingsIndex.value = e.detail.value
userInfo.value.siblings = String(Number(e.detail.value) + 1)
}
const onLiveWithParentsChange = (e: any) => {
liveWithParentsIndex.value = e.detail.value
userInfo.value.liveWithParents = String(Number(e.detail.value) + 1)
}
const onMarriageFormChange = (e: any) => {
marriageFormIndex.value = e.detail.value
userInfo.value.marriageForm = String(Number(e.detail.value) + 1)
}
const onChildrenStatusChange = (e: any) => {
childrenStatusIndex.value = e.detail.value
userInfo.value.childrenStatus = String(Number(e.detail.value) + 1)
}
const onSleepHabitsChange = (e: any) => {
sleepHabitsIndex.value = e.detail.value
userInfo.value.sleepHabits = String(Number(e.detail.value) + 1)
}
const onExerciseHabitsChange = (e: any) => {
exerciseHabitsIndex.value = e.detail.value
userInfo.value.exerciseHabits = String(Number(e.detail.value) + 1)
}
const onCompanyTypeChange = (e: any) => {
companyTypeIndex.value = e.detail.value
userInfo.value.companyType = String(Number(e.detail.value) + 1)
}
//
const onPartnerMaritalStatusChange = (e: any) => {
selectedPartnerMaritalStatus.value = e.detail.value
}
//
const onBirthdayChange = (e: any) => {
userInfo.value.birthday = e.detail.value
}
//
const onIncomeChange = (e: any) => {
incomeIndex.value = e.detail.value
userInfo.value.monthlyIncome = String(Number(e.detail.value) + 1)
}
//
const onEducationChange = (e: any) => {
educationIndex.value = e.detail.value
userInfo.value.education = String(Number(e.detail.value) + 1)
}
onMounted(() => {
getUserInfo()
})
</script>
<style lang="scss" scoped>
//
input {
min-width: 200rpx;
text-align: right;
padding: 4rpx 0;
}
//
picker {
min-width: 200rpx;
text-align: right;
padding: 4rpx 0;
}
//
checkbox-group {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
padding: 16rpx 0;
label {
display: flex;
align-items: center;
background-color: #f5f5f5;
padding: 8rpx 16rpx;
border-radius: 8rpx;
checkbox {
transform: scale(0.8);
margin-right: 4rpx;
}
text {
font-size: 24rpx;
color: #666;
}
}
}
//
switch {
transform: scale(0.8);
}
//
.form-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 0;
border-bottom: 1px solid #f5f5f5;
&:last-child {
border-bottom: none;
}
.label {
color: #666;
font-size: 28rpx;
}
.value {
color: #333;
font-size: 28rpx;
text-align: right;
flex: 1;
margin-left: 24rpx;
}
}
//
textarea {
width: 100%;
height: 200rpx;
background-color: #f5f5f5;
border-radius: 12rpx;
padding: 24rpx;
font-size: 28rpx;
color: #333;
margin-top: 16rpx;
}
//
.save-button {
position: fixed;
bottom: 48rpx;
left: 48rpx;
right: 48rpx;
height: 88rpx;
background: linear-gradient(to right, #3b82f6, #6366f1);
color: white;
border-radius: 44rpx;
font-size: 32rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 12rpx rgba(59, 130, 246, 0.3);
&:active {
transform: scale(0.98);
}
}
//
.card {
background-color: white;
border-radius: 24rpx;
padding: 32rpx;
margin-bottom: 24rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
.card-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
margin-bottom: 32rpx;
}
}
//
.avatar-section {
display: flex;
align-items: center;
padding: 32rpx;
background-color: white;
border-radius: 24rpx;
margin-bottom: 24rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
.avatar {
width: 120rpx;
height: 120rpx;
border-radius: 60rpx;
border: 4rpx solid #f5f5f5;
}
.info {
margin-left: 24rpx;
flex: 1;
.name {
font-size: 32rpx;
font-weight: bold;
color: #333;
margin-bottom: 8rpx;
}
.id {
font-size: 24rpx;
color: #999;
}
.location {
font-size: 26rpx;
color: #666;
margin-top: 8rpx;
}
}
}
</style>

157
src/pages/my/recharge.vue Normal file
View File

@ -0,0 +1,157 @@
<route lang="json5" type="page">
{
layout: 'default',
style: {
navigationBarTitleText: '充值记录',
},
}
</route>
<template>
<view class="min-h-screen bg-gray-50 pb-20">
<!-- 消费统计 -->
<view class="mx-4 mt-4">
<view class="bg-white rounded-2xl shadow-sm p-6">
<view class="text-center">
<text class="text-lg font-bold text-gray-800 mb-2 block">充值统计</text>
<view class="grid grid-cols-2 gap-4">
<view class="text-center">
<text class="text-2xl font-bold text-sky-500 block">¥{{ totalSpend }}</text>
<text class="text-sm text-gray-500">总充值数</text>
</view>
<view class="text-center">
<text class="text-2xl font-bold text-indigo-500 block">{{ spendCount }}</text>
<text class="text-sm text-gray-500">充值次数</text>
</view>
</view>
</view>
</view>
</view>
<!-- 消费记录列表 -->
<view class="mx-4 mt-4">
<view class="bg-white rounded-2xl shadow-sm overflow-hidden">
<view class="p-4 border-b border-gray-100 flex justify-between items-center">
<text class="text-base font-bold text-gray-800">充值明细</text>
<text class="text-sm text-sky-500" @tap="refreshData">刷新</text>
</view>
<view v-if="loading" class="p-8 text-center">
<text class="text-gray-500">加载中...</text>
</view>
<view v-else-if="error" class="p-8 text-center">
<text class="text-red-500">{{ error }}</text>
</view>
<view v-else-if="spendList.length === 0" class="p-8 text-center">
<text class="text-gray-500">暂无充值记录</text>
</view>
<view v-else class="divide-y divide-gray-100">
<view
v-for="(item, index) in spendList"
:key="index"
class="p-4 flex items-center justify-between"
>
<view>
<view class="flex items-center mb-1">
<view
class="bg-pink-50 text-pink-600 px-2 py-0.5 rounded-full text-xs border border-pink-100 mr-2"
>
订单号
</view>
<text class="text-gray-800 font-mono">{{ item.orderNo }}</text>
</view>
<text class="text-sm text-gray-500">{{ formatDate(item.createTime) }}</text>
</view>
<text class="text-lg font-bold text-green-500">
+¥{{ formatPrice(item.payPoints) }}
</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue'
import { getUserPayInfoAPI } from '@/service/login'
import type { PayInfoList } from '@/service/login/type'
const spendList = ref<PayInfoList[]>([])
const loading = ref(false)
const error = ref('')
//
const totalSpend = computed(() => {
return spendList.value.reduce((sum, item) => sum + Number(item.payPoints), 0).toFixed(2)
})
//
const spendCount = computed(() => spendList.value.length)
//
const getSpendTypeText = (type: number) => {
const typeMap: Record<number, string> = {
1: '解锁用户',
2: '充值',
3: '其他',
}
return typeMap[type] || '未知类型'
}
//
const formatDate = (dateStr: string) => {
try {
const date = new Date(dateStr)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
} catch (e) {
return dateStr
}
}
//
const formatPrice = (price: string) => {
try {
return Number(price).toFixed(2)
} catch (e) {
return '0.00'
}
}
//
const getSpendList = async () => {
loading.value = true
error.value = ''
try {
const res = await getUserPayInfoAPI()
if (res.code === 200) {
spendList.value = res.data || []
} else {
error.value = res.message || '获取充值记录失败'
uni.showToast({
title: error.value,
icon: 'none',
duration: 2000,
})
}
} catch (e) {
error.value = '网络请求失败,请稍后重试'
uni.showToast({
title: error.value,
icon: 'none',
duration: 2000,
})
} finally {
loading.value = false
}
}
//
const refreshData = () => {
getSpendList()
}
onMounted(() => {
getSpendList()
})
</script>

240
src/pages/my/refund.vue Normal file
View File

@ -0,0 +1,240 @@
<route lang="json5" type="page">
{
layout: 'default',
style: {
navigationBarTitleText: '退款记录',
},
}
</route>
<template>
<view class="min-h-screen bg-gray-50 pb-20">
<!-- 消费统计 -->
<view class="mx-4 mt-4">
<view class="bg-white rounded-2xl shadow-sm p-6">
<view class="text-center">
<text class="text-lg font-bold text-gray-800 mb-2 block">退款统计</text>
<view class="grid grid-cols-2 gap-4">
<view class="text-center">
<text class="text-2xl font-bold text-sky-500 block">¥{{ totalSpend }}</text>
<text class="text-sm text-gray-500">总退款数</text>
</view>
<view class="text-center">
<text class="text-2xl font-bold text-indigo-500 block">{{ spendCount }}</text>
<text class="text-sm text-gray-500">退款次数</text>
</view>
</view>
</view>
</view>
</view>
<!-- 消费记录列表 -->
<view class="mx-4 mt-4">
<view class="bg-white rounded-2xl shadow-sm overflow-hidden">
<view class="p-4 border-b border-gray-100 flex justify-between items-center">
<text class="text-base font-bold text-gray-800">退款明细</text>
<text class="text-sm text-sky-500" @tap="refreshData">刷新</text>
</view>
<view v-if="loading" class="p-8 text-center">
<text class="text-gray-500">加载中...</text>
</view>
<view v-else-if="error" class="p-8 text-center">
<text class="text-red-500">{{ error }}</text>
</view>
<view v-else-if="spendList.length === 0" class="p-8 text-center">
<text class="text-gray-500">暂无退款记录</text>
</view>
<view v-else class="divide-y divide-gray-100">
<view v-for="(item, index) in spendList" :key="index" class="p-4">
<!-- 顶部退款金额和状态 -->
<view class="flex items-center justify-between mb-3">
<text class="text-lg font-bold text-rose-500">
-¥{{ formatPrice(item.refundPoints) }}
</text>
<view
:class="[
'px-3 py-1 rounded-full text-xs border',
item.refundStatus === '0'
? 'bg-yellow-50 text-yellow-600 border-yellow-100'
: item.refundStatus === '1'
? 'bg-green-50 text-green-600 border-green-100'
: 'bg-red-50 text-red-600 border-red-100',
]"
>
{{ getRefundStatusText(item.refundStatus) }}
</view>
</view>
<!-- 中间退款原因 -->
<view class="mb-3">
<view class="flex items-center mb-1">
<view
class="bg-sky-50 text-sky-600 px-2 py-0.5 rounded-full text-xs border border-sky-100 mr-2"
>
退款原因
</view>
<text class="text-gray-600 text-sm flex-1">{{ item.refundReason }}</text>
</view>
</view>
<!-- 底部时间和操作按钮 -->
<view class="flex items-center justify-between">
<view class="flex flex-col space-y-1">
<text class="text-xs text-gray-500">
申请时间{{ formatDate(item.createTime) }}
</text>
<text v-if="item.auditTime" class="text-xs text-gray-500">
审核时间{{ formatDate(item.auditTime) }}
</text>
</view>
<button
v-if="item.refundStatus === '0'"
class="flex items-center justify-center bg-white border border-green-200 text-green-500 px-4 py-1.5 rounded-lg text-xs hover:bg-green-50 active:bg-green-100 transition-colors mr-0"
@tap="handleCancelRefund(item.id)"
>
<i class="i-carbon-close text-sm mr-1"></i>
撤销
</button>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue'
import { getUserRefundInfoAPI, cancelRefundAPI } from '@/service/login'
import type { RefundInfoList } from '@/service/login/type'
const spendList = ref<RefundInfoList[]>([])
const loading = ref(false)
const error = ref('')
//
const totalSpend = computed(() => {
return spendList.value.reduce((sum, item) => sum + Number(item.refundPoints), 0).toFixed(2)
})
//
const spendCount = computed(() => spendList.value.length)
//
const getSpendTypeText = (type: number) => {
const typeMap: Record<number, string> = {
1: '解锁用户',
2: '充值',
3: '其他',
}
return typeMap[type] || '未知类型'
}
//
const formatDate = (dateStr: string) => {
try {
const date = new Date(dateStr)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
} catch (e) {
return dateStr
}
}
//
const formatPrice = (price: string) => {
try {
return Number(price).toFixed(2)
} catch (e) {
return '0.00'
}
}
//
const getSpendList = async () => {
loading.value = true
error.value = ''
try {
const res = await getUserRefundInfoAPI()
if (res.code === 200) {
spendList.value = res.data || []
} else {
error.value = res.message || '获取充值记录失败'
uni.showToast({
title: error.value,
icon: 'none',
duration: 2000,
})
}
} catch (e) {
error.value = '网络请求失败,请稍后重试'
uni.showToast({
title: error.value,
icon: 'none',
duration: 2000,
})
} finally {
loading.value = false
}
}
//
const refreshData = () => {
getSpendList()
}
// 退
const getRefundStatusText = (status: string) => {
const statusMap: Record<string, string> = {
'0': '审核中',
'1': '已通过',
'2': '已拒绝',
}
return statusMap[status] || '未知状态'
}
// 退
const handleCancelRefund = async (id: string) => {
try {
uni.showModal({
title: '提示',
content: '确定要撤销该退款申请吗?',
success: async (res) => {
if (res.confirm) {
uni.showLoading({
title: '处理中...',
mask: true,
})
const result = await cancelRefundAPI(id)
if (result.code === 200) {
uni.showToast({
title: '撤销成功',
icon: 'success',
})
//
getSpendList()
} else {
uni.showToast({
title: result.message || '撤销失败',
icon: 'none',
})
}
}
},
})
} catch (error) {
uni.showToast({
title: '操作失败,请稍后重试',
icon: 'none',
})
} finally {
uni.hideLoading()
}
}
onMounted(() => {
getSpendList()
})
</script>

View File

@ -0,0 +1,181 @@
<route lang="json5" type="page">
{
layout: 'default',
style: {
navigationBarTitleText: '消费记录',
},
}
</route>
<template>
<view class="min-h-screen bg-gray-50 pb-20">
<!-- 消费统计 -->
<view class="mx-4 mt-4">
<view class="bg-white rounded-2xl shadow-sm p-6">
<view class="text-center">
<text class="text-lg font-bold text-gray-800 mb-2 block">消费统计</text>
<view class="grid grid-cols-2 gap-4">
<view class="text-center">
<text class="text-2xl font-bold text-sky-500 block">¥{{ totalSpend }}</text>
<text class="text-sm text-gray-500">总消费</text>
</view>
<view class="text-center">
<text class="text-2xl font-bold text-indigo-500 block">{{ spendCount }}</text>
<text class="text-sm text-gray-500">消费次数</text>
</view>
</view>
</view>
</view>
</view>
<!-- 消费记录列表 -->
<view class="mx-4 mt-4">
<view class="bg-white rounded-2xl shadow-sm overflow-hidden">
<view class="p-4 border-b border-gray-100 flex justify-between items-center">
<text class="text-base font-bold text-gray-800">消费明细</text>
<text class="text-sm text-sky-500" @tap="refreshData">刷新</text>
</view>
<view v-if="loading" class="p-8 text-center">
<text class="text-gray-500">加载中...</text>
</view>
<view v-else-if="error" class="p-8 text-center">
<text class="text-red-500">{{ error }}</text>
</view>
<view v-else-if="spendList.length === 0" class="p-8 text-center">
<text class="text-gray-500">暂无消费记录</text>
</view>
<view v-else class="divide-y divide-gray-100">
<view
v-for="(item, index) in spendList"
:key="index"
class="p-4 flex items-center justify-between"
>
<view>
<text
:class="[
'block text-sm',
item.spendType === '1'
? 'text-yellow-300 font-semibold border-l-2 border-purple-500 pl-2'
: item.spendType === '2'
? 'text-emerald-300 font-semibold border-l-2 border-emerald-500 pl-2'
: 'text-gray-600 border-l-2 border-gray-300 pl-2',
]"
>
{{ getSpendTypeText(item.spendType) }}
</text>
<text class="text-sm text-gray-500">{{ formatDate(item.createTime) }}</text>
</view>
<text class="text-lg font-bold text-red-500">-¥{{ formatPrice(item.spendPrice) }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue'
import { getUserSpendHistoryAPI } from '@/service/login'
import type { SpendHistoryItem } from '@/service/login/type'
const spendList = ref<SpendHistoryItem[]>([])
const userId = ref('')
const loading = ref(false)
const error = ref('')
//
const getSpendList = async () => {
if (!userId.value) {
uni.showToast({
title: error.value,
icon: 'none',
duration: 2000,
})
return
}
loading.value = true
error.value = ''
try {
const res = await getUserSpendHistoryAPI(userId.value)
if (res.code === 200) {
spendList.value = res.data || []
} else {
error.value = res.message || '获取消费记录失败'
uni.showToast({
title: error.value,
icon: 'none',
duration: 2000,
})
}
} catch (e) {
error.value = '网络请求失败,请稍后重试'
uni.showToast({
title: error.value,
icon: 'none',
duration: 2000,
})
} finally {
loading.value = false
}
}
//
const refreshData = () => {
getSpendList()
}
//
const totalSpend = computed(() => {
return spendList.value.reduce((sum, item) => sum + Number(item.spendPrice), 0).toFixed(2)
})
//
const spendCount = computed(() => spendList.value.length)
//
const getSpendTypeText = (type: number) => {
const typeMap: Record<number, string> = {
1: '解锁用户',
2: '参加活动',
3: '其他',
}
return typeMap[type] || '未知类型'
}
//
const formatDate = (dateStr: string) => {
try {
const date = new Date(dateStr)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
} catch (e) {
return dateStr
}
}
//
const formatPrice = (price: string) => {
try {
return Number(price).toFixed(2)
} catch (e) {
return '0.00'
}
}
//
onLoad((options: Record<string, string>) => {
console.log('onLoad参数:', options)
if (options.id) {
userId.value = options.id
console.log('设置的用户ID:', userId.value)
getSpendList()
} else {
error.value = '未获取到用户信息'
}
})
onMounted(() => {
getSpendList()
})
</script>

185
src/pages/my/spend.vue Normal file
View File

@ -0,0 +1,185 @@
<route lang="json5" type="page">
{
layout: 'default',
style: {
navigationBarTitleText: '消费记录',
},
}
</route>
<template>
<view class="min-h-screen bg-gray-50 pb-20">
<!-- 消费统计 -->
<view class="mx-4 mt-4">
<view class="bg-white rounded-2xl shadow-sm p-6">
<view class="text-center">
<text class="text-lg font-bold text-gray-800 mb-2 block">消费统计</text>
<view class="grid grid-cols-2 gap-4">
<view class="text-center">
<text class="text-2xl font-bold text-sky-500 block">¥{{ totalSpend }}</text>
<text class="text-sm text-gray-500">总消费</text>
</view>
<view class="text-center">
<text class="text-2xl font-bold text-indigo-500 block">{{ spendCount }}</text>
<text class="text-sm text-gray-500">消费次数</text>
</view>
</view>
</view>
</view>
</view>
<!-- 消费记录列表 -->
<view class="mx-4 mt-4">
<view class="bg-white rounded-2xl shadow-sm overflow-hidden">
<view class="p-4 border-b border-gray-100 flex justify-between items-center">
<text class="text-base font-bold text-gray-800">消费明细</text>
<text class="text-sm text-sky-500" @tap="refreshData">刷新</text>
</view>
<view v-if="loading" class="p-8 text-center">
<text class="text-gray-500">加载中...</text>
</view>
<view v-else-if="error" class="p-8 text-center">
<text class="text-red-500">{{ error }}</text>
</view>
<view v-else-if="spendList.length === 0" class="p-8 text-center">
<text class="text-gray-500">暂无消费记录</text>
</view>
<view v-else class="divide-y divide-gray-100">
<view
v-for="(item, index) in spendList"
:key="index"
class="p-4 flex items-center justify-between"
>
<view>
<text
:class="[
'block text-sm',
item.spendType === '1'
? 'text-yellow-300 font-semibold border-l-2 border-purple-500 pl-2'
: item.spendType === '2'
? 'text-emerald-300 font-semibold border-l-2 border-emerald-500 pl-2'
: 'text-gray-600 border-l-2 border-gray-300 pl-2',
]"
>
{{ getSpendTypeText(item.spendType) }}
</text>
<text class="text-sm text-gray-500">{{ formatDate(item.createTime) }}</text>
</view>
<text class="text-lg font-bold text-red-500">-¥{{ formatPrice(item.spendPrice) }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue'
import { getUserSpendHistoryAPI } from '@/service/login'
import type { SpendHistoryItem } from '@/service/login/type'
const spendList = ref<SpendHistoryItem[]>([])
const userId = ref('')
const loading = ref(false)
const error = ref('')
// ID
const getUserInfo = () => {
try {
const userInfo = uni.getStorageSync('loginData')
if (!userInfo || !userInfo.id) {
error.value = '未获取到用户信息,请重新登录'
return false
}
userId.value = userInfo.id
return true
} catch (e) {
error.value = '获取用户信息失败'
return false
}
}
//
const totalSpend = computed(() => {
return spendList.value.reduce((sum, item) => sum + Number(item.spendPrice), 0).toFixed(2)
})
//
const spendCount = computed(() => spendList.value.length)
//
const getSpendTypeText = (type: number) => {
const typeMap: Record<number, string> = {
1: '解锁用户',
2: '参加活动',
3: '其他',
}
return typeMap[type] || '未知类型'
}
//
const formatDate = (dateStr: string) => {
try {
const date = new Date(dateStr)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
} catch (e) {
return dateStr
}
}
//
const formatPrice = (price: string) => {
try {
return Number(price).toFixed(2)
} catch (e) {
return '0.00'
}
}
//
const getSpendList = async () => {
if (!getUserInfo()) {
uni.showToast({
title: error.value,
icon: 'none',
duration: 2000,
})
return
}
loading.value = true
error.value = ''
try {
const res = await getUserSpendHistoryAPI(userId.value)
if (res.code === 200) {
spendList.value = res.data || []
} else {
error.value = res.message || '获取消费记录失败'
uni.showToast({
title: error.value,
icon: 'none',
duration: 2000,
})
}
} catch (e) {
error.value = '网络请求失败,请稍后重试'
uni.showToast({
title: error.value,
icon: 'none',
duration: 2000,
})
} finally {
loading.value = false
}
}
//
const refreshData = () => {
getSpendList()
}
onMounted(() => {
getSpendList()
})
</script>

View File

@ -0,0 +1,240 @@
<template>
<view class="subordinate-container">
<view class="header">
<text class="title">我的下级</text>
</view>
<view v-if="loading" class="loading-state">
<text class="loading-text">加载中...</text>
</view>
<view v-else-if="subordinates.length === 0" class="empty-state">
<text class="empty-text">暂无下级用户</text>
</view>
<view v-else class="subordinate-grid">
<view class="subordinate-card" v-for="item in subordinates" :key="item.id">
<view class="card-content">
<view class="user-avatar">
<text class="avatar-text">
{{ item?.nickname?.charAt?.(0) ?? '' }}
</text>
</view>
<view class="user-info">
<view class="user-header">
<text class="user-name">{{ item.nickname }}</text>
<text class="user-id">ID: {{ item.id }}</text>
</view>
<view v-if="item.child && item.child.length > 0" class="child-list">
<text class="child-count">直接下级: {{ item.child.length }}</text>
<view class="child-container">
<view v-for="child in item.child" :key="child.id" class="child-item">
<view class="child-content">
<view class="child-avatar">
<text class="child-avatar-text">
{{ child?.nickname?.charAt?.(0) ?? '' }}
</text>
</view>
<view class="child-info">
<text class="child-name">{{ child.nickname }}</text>
<text class="child-id">ID: {{ child.id }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue'
import { getMyInviteListAPI } from '@/service/login'
import type { InviteUserItem } from '@/service/login/type'
defineOptions({
name: 'SubordinateList',
})
//
const loading = ref(true)
//
const subordinates = ref<InviteUserItem[]>([])
//
const fetchSubordinates = async () => {
try {
loading.value = true
const res = await getMyInviteListAPI()
if (res.code === 200 && res.data) {
console.log(666, res)
subordinates.value = res.data
loading.value = false
}
} catch (error) {
console.error('获取下级用户列表失败:', error)
} finally {
loading.value = false
}
}
onMounted(() => {
fetchSubordinates()
})
</script>
<style lang="scss" scoped>
.subordinate-container {
min-height: 100vh;
padding: 24rpx;
background-color: #f5f7fa;
.header {
margin-bottom: 32rpx;
.title {
font-size: 36rpx;
font-weight: 600;
color: #333;
}
}
.loading-state,
.empty-state {
display: flex;
align-items: center;
justify-content: center;
padding: 48rpx 0;
.loading-text,
.empty-text {
font-size: 28rpx;
color: #909399;
}
}
.subordinate-grid {
display: grid;
gap: 24rpx;
}
.subordinate-card {
background: #ffffff;
border-radius: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
&:active {
transform: scale(0.98);
}
.card-content {
display: flex;
align-items: flex-start;
padding: 24rpx;
}
.user-avatar {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 88rpx;
height: 88rpx;
margin-right: 24rpx;
background: linear-gradient(135deg, #4a90e2, #357abd);
border-radius: 50%;
.avatar-text {
font-size: 36rpx;
font-weight: 600;
color: #ffffff;
}
}
.user-info {
flex: 1;
.user-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
.user-name {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.user-id {
font-size: 24rpx;
color: #909399;
}
}
}
.child-list {
padding-top: 16rpx;
margin-top: 16rpx;
border-top: 2rpx solid #f0f2f5;
.child-count {
display: block;
margin-bottom: 16rpx;
font-size: 26rpx;
color: #606266;
}
.child-container {
padding-left: 24rpx;
border-left: 4rpx solid #e8eaf6;
}
.child-item {
margin-bottom: 16rpx;
&:last-child {
margin-bottom: 0;
}
.child-content {
display: flex;
align-items: center;
}
.child-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 64rpx;
height: 64rpx;
margin-right: 16rpx;
background: linear-gradient(135deg, #e8eaf6, #c5cae9);
border-radius: 50%;
.child-avatar-text {
font-size: 28rpx;
font-weight: 600;
color: #4a90e2;
}
}
.child-info {
.child-name {
margin-right: 12rpx;
font-size: 28rpx;
color: #333;
}
.child-id {
font-size: 24rpx;
color: #909399;
}
}
}
}
}
}
</style>

169
src/pages/my/user-info.vue Normal file
View File

@ -0,0 +1,169 @@
<route lang="json5" type="page">
{
layout: 'default',
style: {
navigationBarTitleText: '用户信息',
},
}
</route>
<template>
<view class="min-h-screen bg-gray-50 pb-20">
<!-- 用户信息卡片 -->
<view class="mx-4 mt-4">
<view class="bg-white rounded-2xl shadow-sm p-4">
<view class="flex items-center mb-4">
<image
class="w-20 h-20 rounded-full border-2 border-gray-100"
:src="userInfo.avatar || defaultAvatar"
mode="aspectFill"
/>
<view class="ml-4 flex-1">
<view class="flex items-center mb-2">
<text class="text-lg font-bold text-gray-800">
{{ userInfo.nickname || '未设置昵称' }}
</text>
<text class="ml-2 text-sm text-gray-500">ID: {{ userInfo.id }}</text>
</view>
<view class="text-sm text-gray-600">
<text>{{ userInfo.workArea || '未设置工作地区' }}</text>
</view>
</view>
</view>
<!-- 基本信息列表 -->
<view class="mt-4 space-y-4">
<view class="flex justify-between items-center">
<text class="text-gray-600">手机号</text>
<text class="text-gray-800">{{ userInfo.phone || '未设置' }}</text>
</view>
<view class="flex justify-between items-center">
<text class="text-gray-600">生日</text>
<text class="text-gray-800">{{ userInfo.birthday || '未设置' }}</text>
</view>
<view class="flex justify-between items-center">
<text class="text-gray-600">身高</text>
<text class="text-gray-800">
{{ userInfo.height ? `${userInfo.height}cm` : '未设置' }}
</text>
</view>
<view class="flex justify-between items-center">
<text class="text-gray-600">体重</text>
<text class="text-gray-800">
{{ userInfo.weight ? `${userInfo.weight}kg` : '未设置' }}
</text>
</view>
<view class="flex justify-between items-center">
<text class="text-gray-600">学历</text>
<text class="text-gray-800">{{ getEducationText(userInfo.education) }}</text>
</view>
<view class="flex justify-between items-center">
<text class="text-gray-600">月收入</text>
<text class="text-gray-800">{{ getIncomeText(userInfo.monthlyIncome) }}</text>
</view>
<view class="flex justify-between items-center">
<text class="text-gray-600">职业</text>
<text class="text-gray-800">{{ userInfo.profession || '未设置' }}</text>
</view>
<view class="flex justify-between items-center">
<text class="text-gray-600">住房情况</text>
<text class="text-gray-800">{{ getHousingStatusText(userInfo.housingStatus) }}</text>
</view>
<view class="flex justify-between items-center">
<text class="text-gray-600">购车情况</text>
<text class="text-gray-800">{{ getCarOwnershipText(userInfo.carOwnership) }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue'
import type { UserInfo } from '@/service/login/type'
import { useUserStore } from '@/store'
const userStore = useUserStore()
const defaultAvatar = '/static/images/default-avatar.png'
const userInfo = ref<UserInfo>({} as UserInfo)
//
const getUserInfo = async () => {
try {
const res = await userStore.setUserInfo()
// ID
const userInfoStr = userStore.userInfo
if (!userInfoStr) {
uni.showToast({
title: '请先登录',
icon: 'none',
})
setTimeout(() => {
uni.navigateTo({
url: '/pages/login/index',
})
}, 1500)
return
}
if (res.code === 200) {
userInfo.value = res.data
}
} catch (error) {
uni.showToast({
title: '获取用户信息失败',
icon: 'none',
})
}
}
//
const getEducationText = (education: number) => {
const educationMap = {
1: '初中',
2: '高中',
3: '大专',
4: '本科',
5: '硕士',
6: '博士',
}
return educationMap[education] || '未设置'
}
//
const getIncomeText = (income: number) => {
const incomeMap = {
1: '3000以下',
2: '3000~5000',
3: '5000~8000',
4: '8000~10000',
5: '10000~20000',
6: '20000以上',
}
return incomeMap[income] || '未设置'
}
//
const getHousingStatusText = (status: number) => {
const statusMap = {
1: '租房',
2: '有房',
3: '与父母同住',
}
return statusMap[status] || '未设置'
}
//
const getCarOwnershipText = (status: number) => {
const statusMap = {
1: '无车',
2: '有车',
}
return statusMap[status] || '未设置'
}
onMounted(() => {
getUserInfo()
})
</script>

View File

@ -0,0 +1,176 @@
<!-- 推荐列表页面 -->
<route lang="json5">
{
style: {
navigationBarTitleText: '推荐列表',
},
}
</route>
<template>
<view class="min-h-screen bg-gray-50">
<!-- 顶部导航栏 -->
<view class="sticky top-0 z-10 bg-white/80 backdrop-blur-sm border-b border-gray-100">
<view class="flex items-center justify-between px-4 py-3">
<view class="flex items-center">
<text class="text-xl font-bold text-gray-900">推荐列表</text>
</view>
</view>
</view>
<!-- 推荐列表 -->
<scroll-view
scroll-y
class="h-[calc(100vh-120rpx)]"
refresher-enabled
:refresher-triggered="isRefreshing"
@refresherrefresh="onRefresh"
@scrolltolower="loadMore"
>
<view v-if="recommendList.length > 0" class="p-4 space-y-4">
<view
v-for="(item, index) in recommendList"
:key="index"
class="bg-white rounded-2xl shadow-sm overflow-hidden transition-all duration-300 hover:shadow-md active:scale-[0.98]"
@tap="viewDetail(item)"
>
<view class="flex p-4">
<view class="relative">
<image
:src="item.avatarUrl || item.avatar"
mode="aspectFill"
class="w-24 h-24 rounded-xl object-cover"
/>
</view>
<view class="flex-1 ml-4">
<view class="flex items-center justify-between mb-2">
<view class="flex items-center">
<text class="text-lg font-semibold text-gray-900 mr-2">{{ item.nickname }}</text>
<text class="text-sm text-gray-500">{{ item.age }}</text>
</view>
</view>
<view class="flex items-center space-x-2 mb-3">
<view class="flex items-center">
<text class="text-xs text-gray-500">身高 {{ item.height }}cm</text>
</view>
<view class="w-px h-3 bg-gray-200"></view>
<view class="flex items-center">
<text class="text-xs text-gray-500">{{ item.education }}</text>
</view>
<view class="w-px h-3 bg-gray-200"></view>
<view class="flex items-center">
<text class="text-xs text-gray-500">{{ item.workArea }}</text>
</view>
</view>
<view class="flex items-center">
<text class="text-sm text-gray-600">{{ item.profession }}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 空状态 -->
<view v-else-if="!isLoading" class="flex flex-col items-center justify-center py-12">
<image src="/static/empty.png" class="w-32 h-32 mb-4" />
<text class="text-gray-400">暂无推荐数据</text>
</view>
<!-- 加载状态 -->
<view v-if="isLoading" class="flex justify-center py-6">
<view class="flex items-center space-x-2">
<view class="w-2 h-2 bg-primary rounded-full animate-bounce"></view>
<view
class="w-2 h-2 bg-primary rounded-full animate-bounce"
style="animation-delay: 0.2s"
></view>
<view
class="w-2 h-2 bg-primary rounded-full animate-bounce"
style="animation-delay: 0.4s"
></view>
</view>
</view>
<view v-else-if="!hasMore && recommendList.length > 0" class="flex justify-center py-6">
<text class="text-sm text-gray-400">没有更多了</text>
</view>
</scroll-view>
</view>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue'
import type { LoveUserItem, LoveListParams } from '@/service/love/type'
import { onLoad } from '@dcloudio/uni-app'
//
const recommendList = ref<LoveUserItem[]>([])
const page = ref(1)
const pageSize = ref(10)
const hasMore = ref(true)
const isLoading = ref(false)
const isRefreshing = ref(false)
//
const getRecommendList = async () => {
if (isLoading.value) return
try {
isLoading.value = true
const params: LoveListParams = {
pageIndex: page.value,
}
} catch (error) {
console.error('获取推荐列表失败:', error)
uni.showToast({
title: '获取推荐列表失败',
icon: 'none',
})
} finally {
isLoading.value = false
isRefreshing.value = false
}
}
//
const onRefresh = async () => {
isRefreshing.value = true
page.value = 1
await getRecommendList()
}
//
const loadMore = () => {
if (!hasMore.value || isLoading.value) return
page.value++
getRecommendList()
}
//
const viewDetail = (item: LoveUserItem) => {
uni.navigateTo({
url: `/pages/detail/index?id=${item.id}`,
})
}
onLoad(() => {
getRecommendList()
})
</script>
<style lang="scss">
@import '@/style/iconfont.css';
@keyframes bounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-6px);
}
}
.animate-bounce {
animation: bounce 1s infinite;
}
</style>

View File

@ -0,0 +1,19 @@
import { request } from '@/utils/request'
import type {
EventItem,
EventListParams,
MyEventItem,
JoinEventResponse,
ApiResponse,
} from './type'
/** 获取活动列表 */
export const getEventListAPI = (params: EventListParams) =>
request.post<ApiResponse<EventItem[]>>('/event/list', params)
/** 参加活动 */
export const joinEventAPI = (id: string) =>
request.get<ApiResponse<JoinEventResponse>>(`/event/join/${id}`)
/** 获取我参加的活动列表 */
export const getMyEventListAPI = () => request.get<ApiResponse<MyEventItem[]>>('/event/myEvent')

63
src/service/event/type.ts Normal file
View File

@ -0,0 +1,63 @@
/** 活动列表项 */
export interface EventItem {
/** 活动ID */
id: string
/** 活动标题 */
eventTitle: string
/** 活动内容 markdown格式 */
eventContent: string
/** 活动封面图 */
eventAvatar: string
/** 活动价格 */
eventPrice: string
/** 活动地点 */
eventPlace: string
/** 发起时间 */
createTime: string
/** 到期时间 */
endTime: string
/** 有效天数 */
effectiveTime: string
/** 已参与人数 */
eventNumber: string
/** 限制人数 */
eventLimitNumber: string
}
/** 我参加的活动列表项 */
export interface MyEventItem extends EventItem {
/** 我的活动表id非活动id */
id: string
/** 活动发起时间 */
enevtCreateTime: string
/** 到期时间 */
eventEndTime: string
/** 活动状态 1-启用 0-禁用 */
status: '1' | '0'
/** 参加状态 1-已参加 0或者为空就是未参加 */
joinStatus?: '1' | '0'
/** 参加活动时间 */
createTime: string
}
/** 活动列表查询参数 */
export interface EventListParams {
/** 活动状态1-全部 2-报名中 3-已结束 */
status: '1' | '2' | '3'
}
/** 参加活动响应 */
export interface JoinEventResponse {
/** 订单编号 */
trackingNo: string
}
/** 通用响应格式 */
export interface ApiResponse<T> {
/** 响应码 */
code: number
/** 响应消息 */
message: string
/** 响应数据 */
data: T
}

20
src/service/exam/index.ts Normal file
View File

@ -0,0 +1,20 @@
import { request } from '@/utils/request'
import type { BaseResponse } from '../login/type'
/** 考试信息类型 */
export interface ExamItem {
id: string
examName: string
examDate: string
startTime: string
endTime: string
provinceName: string
cityName: string
address: string
locationName: string
estimatedAttendees: string
}
/** 获取可报名考试列表 */
export const getCanBookExamListAPI = () =>
request.get<BaseResponse<ExamItem[]>>('/exam/getCanBookExamList')

View File

@ -0,0 +1,10 @@
import { request } from '@/utils/request'
import type { FeedbackParams, ComplaintParams, ApiResponse } from './type'
/** 提交反馈 */
export const addFeedbackAPI = (data: FeedbackParams) =>
request.post<ApiResponse>('/feedback/add', data)
/** 提交投诉 */
export const addComplaintAPI = (data: ComplaintParams) =>
request.post<ApiResponse>('/complaint/add', data)

View File

@ -0,0 +1,29 @@
/** 反馈参数 */
export interface FeedbackParams {
/** 反馈意见标题 */
feedbackTitle: string
/** 反馈意见内容 */
feedbackContent: string
}
/** 投诉参数 */
export interface ComplaintParams {
/** 被投诉人id */
userId: string
/** 投诉分类 */
complaintClass: '虚假宣传' | '微商' | '诈骗' | '个人信息不符'
/** 投诉详情 */
complaintContent: string
/** 附件图片url列表 */
complaintImageList?: string[]
}
/** API响应基础类型 */
export interface ApiResponse<T = any> {
/** 状态码 */
code: '200' | '500'
/** 消息 */
message: string
/** 数据 */
data?: T
}

30
src/service/file/index.ts Normal file
View File

@ -0,0 +1,30 @@
import { request } from '@/utils/request'
import { getEnvBaseUrl } from '@/utils'
/** 上传图片 */
export const uploadImageAPI = (filePath: string, imageClassId: string) => {
return new Promise<{ url: string }>((resolve, reject) => {
uni.uploadFile({
url: getEnvBaseUrl() + 'attachment/uploadImageForUser',
filePath,
name: 'img',
formData: {
image_class_id: imageClassId,
},
header: {
'x-token': uni.getStorageSync('x-token') || '',
},
success: (res) => {
const data = JSON.parse(res.data)
if (data.code === 200) {
resolve(data.data)
} else {
reject(new Error(data.message))
}
},
fail: (err) => {
reject(err)
},
})
})
}

11
src/service/index/foo.ts Normal file
View File

@ -0,0 +1,11 @@
import { request } from '@/utils/request'
export interface IFooItem {
id: string
name: string
}
/** GET 请求 */
export const getFooAPI = (name: string) => request.get<IFooItem>('/foo', { name })
/** POST 请求 */
export const postFooAPI = (name: string) => request.post<IFooItem>('/foo', { name }, { name })

View File

@ -0,0 +1,64 @@
import { request } from '@/utils/request'
import type {
BaseResponse,
GetOpenIdResponse,
LoginRequest,
LoginResponse,
UserInfo,
UpdateUserInfoRequest,
InviteUserItem,
SpendHistoryItem,
PayInfoList,
GetInviteCodeResponse,
RefundInfoList,
} from './type'
/** 获取用户openId */
export const getOpenIdAPI = (code: string) =>
request.post<BaseResponse<GetOpenIdResponse>>('/sys/getOpenId', { code })
/** 用户登录 */
export const loginAPI = (data: LoginRequest) =>
request.post<BaseResponse<LoginResponse>>('/base/mobileLogin', data)
/** 获取用户信息 */
export const getUserInfoAPI = (id: string) =>
request.get<BaseResponse<UserInfo>>(`/user/info/${id}`)
/** 更新用户信息 */
export const updateUserInfoAPI = (data: UpdateUserInfoRequest) =>
request.post<BaseResponse>('/user/update', data)
/**
* banner图片
*/
export const getBannerImage = () => request.get<BaseResponse>('/attachment/getBannerImage')
/** 获取我邀请的用户列表 */
export const getMyInviteListAPI = () =>
request.get<BaseResponse<InviteUserItem[]>>('/user/myInvite')
/** 获取用户消费明细 */
export const getUserSpendHistoryAPI = (id: string) =>
request.get<BaseResponse<SpendHistoryItem[]>>(`/user/spendHistory/${id}`)
export const getUserPayInfoAPI = () => request.get<BaseResponse<PayInfoList[]>>(`/pay/payInfo`)
/** 获取用户退款记录 */
export const getUserRefundInfoAPI = () =>
request.get<BaseResponse<RefundInfoList[]>>('/pay/refundInfo')
/** 撤销退款申请 */
export const cancelRefundAPI = (id: string) => request.get<BaseResponse>(`/pay/cancelRefund/${id}`)
/** 退出登录 */
export const logoutAPI = () => request.get<BaseResponse>('/sys/logout')
/** 获取邀请码 */
export const getInviteCodeAPI = () =>
request.get<BaseResponse<GetInviteCodeResponse>>('/sys/getInviteCode')
/** 绑定邀请人 */
export const bindInviterAPI = (code: string) => {
return request.get(`/sys/bindInviteUser/${code}`)
}

176
src/service/login/type.ts Normal file
View File

@ -0,0 +1,176 @@
/** 基础响应类型 */
export interface BaseResponse<T = any> {
code: number
message: string
data: T
}
/** 获取openId响应类型 */
export type GetOpenIdResponse = string
/** 登录请求参数类型 */
export interface LoginRequest {
openId: string
avatarUrl: string
userName: string
}
/** 登录响应类型 */
export interface LoginResponse {
/** 用户ID */
id?: number
/** 用户token */
'x-token'?: string
/** 账户余额,展示在个人信息页面 */
points?: string
/** 退款状态 */
refundStatus?: string
/** 手机号 */
phone?: string
/** 头像URL */
avatar?: string
/** 昵称 */
nickName?: string
/** 生日日期Date类型 */
birthday?: string
/** 工作地址 */
workArea?: string
/** 工作省份名称 */
workProvinceName?: string
/** 工作城市名称 */
workCityName?: string
/** 工作区县名称 */
workCountryName?: string
/** 户籍地址 */
householdArea?: string
/** 婚姻状况 (1-未婚, 2-离异, 3-丧偶) */
maritalStatus?: string
/** 身高,单位:米 */
height?: number
/** 体重,单位:千克 */
weight?: number
/** 学历 (1-初中, 2-高中, 3-大专, 4-本科, 5-硕士, 6-博士) */
education?: string
/** 月收入 1-3000以下 2-3000~5000 3-5000~8000 4-8000~10000 5-10000~20000 6-20000以上 */
monthlyIncome?: string
/** 职业 */
profession?: string
/** 住房情况1-已购房(有贷款) 2-已购房(无贷款) 3-有能力购房 4-无房 5-无房希望对方解决 6-无房希望双方解决 7-与父母同住 8-独自租房 9-与人合租 10-住单位房) */
housingStatus?: string
/** 购车情况1-无车 2-已购车(经济型) 3-已购车(中档型) 4-已购车(豪华型) 5-单位用车 6-需要时购置) */
carOwnership?: string
/** 期望结婚时间1-随时 2-半年内 3-一年内 4-两年内 5-三年内) */
expectedMarriageTime?: string
/** 自我介绍 */
selfIntroduction?: string
/** 微信 */
wechat?: string
/** 微信二维码url */
wechatQrcode?: string
/** QQ号 */
qq?: string
/** 邮箱 */
email?: string
/** 父母状况1-父母均建在 2-只有母亲建在 3-只有父亲建在 4-父母均以离世) */
parentsStatus?: string
/** 兄弟姐妹 (1-独生子女 2-2 3-3 4-4 5-5 */
siblings?: string
/** 与Ta父母住1-愿意 2-不愿意 3-视具体情况而定 4-尊重伴侣意见) */
liveWithParents?: string
/** 是否吸烟1-是 0-否) */
smoke?: number
/** 婚娶形式1-嫁娶 2-两顾 3-上门) */
marriageForm?: string
/** 是否饮酒1-是 0-否) */
drink?: number
/** 子女情况1-未育 2-子女归自己 3-子女归对方) */
childrenStatus?: string
/** 养宠物情况(多选)(猫,狗,鸟,鱼,兔,鼠,乌龟,蛇,爬行动物,另类动物,不喜欢养,可能会养,过敏) */
pets?: string
/** 兴趣爱好 */
hobbies?: string
/** 血型A B AB O 其他) */
bloodType?: string
/** 作息习惯(早睡早起很规律,经常夜猫子,总是早起鸟,偶尔懒散一下,没有规律) */
sleepHabits?: string
/** 民族 */
nation?: string
/** 毕业院校 */
graduationSchool?: string
/** 锻炼习惯(每天锻炼,每周至少一次,每月几次,没时间锻炼,集中时间锻炼,不喜欢锻炼) */
exerciseHabits?: string
/** 工作行业 */
workIndustry?: string
/** 单位类型(政府机关,事业单位,外资企业,合资企业,国营企业,私营企业,自有公司,其他) */
companyType?: string
/** 工作单位 */
workUnit?: string
/** 我的标签(多选)(孝顺,酷,责任心,经济适用,憨直,感性,事业,睿智,猥琐,幽默,旅行,宅男,体贴,有魄力,仗义,稳重) */
tags?: string
/** 房产位置(多选)(市区 城区有房 老家有房) */
propertyLocation?: string
/** 期望婚况多选1-未婚 2-离异 3-丧偶) */
partnerMaritalStatus?: string
/** 期望年龄范围 20-23 */
ageRange?: string
/** 期望身高范围 160-180 */
heightRange?: string
/** 期望最低学历1-初中 2-高中 3-大专 4-本科 5-硕士 6-博士) */
minEducation?: string
/** 期望月收入 1-3000以下 2-3000~5000 3-5000~8000 4-8000~10000 5-10000~20000 6-20000以上 */
minIncome?: string
/** 期望工作地区,选省份代码 */
partnerWorkArea?: string
/** 期望其他要求 */
otherRequirements?: string
/** 创建时间 */
createTime?: string
/** 更新时间 */
updateTime?: string
/** 邀请人id */
inviterId?: string
}
/** 用户信息类型 */
export type UserInfo = LoginResponse
/** 更新用户信息请求参数类型 */
export type UpdateUserInfoRequest = Partial<UserInfo>
/** 邀请用户列表项类型 */
export interface InviteUserItem {
id: string
nickname: string
child: {
id: string
nickname: string
}[]
}
/** 用户消费明细类型 */
export interface SpendHistoryItem {
id: string
spendPrice: string
spendType: number
createTime: string
}
export interface PayInfoList {
id: string
payPoints: string
createTime: string
orderNo: string
}
/** 获取邀请码响应类型 */
export type GetInviteCodeResponse = string
/** 退款记录类型 */
export interface RefundInfoList {
id: string
refundPoints: string
refundReason: string
refundStatus: string
createTime: string
auditTime: string
}

21
src/service/love/index.ts Normal file
View File

@ -0,0 +1,21 @@
import { request } from '@/utils/request'
import type { LoveUserItem, LoveListParams, ApiResponse, UnlockUserResult } from './type'
/** 获取我解锁的相亲对象列表 */
export const getMyLoveListAPI = () => request.get<ApiResponse<LoveUserItem[]>>('/love/myList')
/** 获取推荐相亲对象列表 */
export const getLoveListAPI = (params: LoveListParams) =>
request.post<ApiResponse<LoveUserItem[]>>('/love/list', params)
/** 检查用户是否已解锁 */
export const checkIsLoveAPI = (id: string) =>
request.get<ApiResponse<boolean>>(`/user/isLove/${id}`)
/** 解锁用户 */
export const unlockUserAPI = (id: string) =>
request.get<ApiResponse<UnlockUserResult[]>>(`/user/love/${id}`)
/** 获取用户详细信息 */
export const getUserDetailAPI = (id: string) =>
request.get<ApiResponse<LoveUserItem>>(`/user/detail/${id}`)

91
src/service/love/type.ts Normal file
View File

@ -0,0 +1,91 @@
/** 相亲对象列表项 */
export interface LoveUserItem {
/** 用户ID */
id: string | number
/** 用户昵称 */
nickname: string
/** 用户头像 */
avatar: string
/** 年龄 */
age: number
/** 身高(cm) */
height: number
/** 学历 */
education: string
/** 职业 */
occupation: string
/** 收入 */
income: string
/** 所在地 */
location: string
/** 是否已解锁 */
isUnlocked: boolean
/** 微信ID */
wechatId?: string
/** 头像URL */
avatarUrl: string
/** 工作地区 */
workArea: string
/** 婚姻状况 (1-未婚, 2-离异, 3-丧偶) */
maritalStatus: '1' | '2' | '3'
/** 体重,单位:千克 */
weight: string
/** 学历 (1-初中, 2-高中, 3-大专, 4-本科, 5-硕士, 6-博士) */
educationLevel: '1' | '2' | '3' | '4' | '5' | '6'
/** 月收入 1-3000以下 2-3000~5000 3-5000~8000 4-8000~10000 5-10000~20000 6-20000以上 */
monthlyIncome: '1' | '2' | '3' | '4' | '5' | '6'
/** 职业 */
profession: string
/** 兴趣爱好 */
hobbies: string
/** 我的标签(多选) */
tags: string
/** 房产位置(多选) */
propertyLocation: string
/** 住房情况 */
housingStatus: string
/** QQ号 */
qq: string
/** 邮箱 */
email: string
/** 个人介绍 */
selfIntroduction: string
}
/** 相亲对象列表查询参数 */
export interface LoveListParams {
/** 页码 */
pageIndex: number | string
/** 年龄范围 示例20-30 */
ageRange?: string
/** 体重范围 示例20-30 */
weightRange?: string
/** 身高范围 示例20-30 */
heightRange?: string
/** 工作地区 市6位代码 示例211000 */
workCityCode?: string
/** 户籍地区 市6位代码 示例211000 */
householdCityCode?: string
/** 民族 直接传中文即可 */
nation?: string
/** 婚姻状况 (1-未婚, 2-离异, 3-丧偶) */
maritalStatus?: '1' | '2' | '3'
/** 住房情况 1-10 */
housingStatus?: string
/** 是否吸烟1-是 0-否) */
smoke?: '1' | '0'
/** 子女情况1-未育 2-子女归自己 3-子女归对方) */
childrenStatus?: '1' | '2' | '3'
}
/** 解锁用户返回项 */
export interface UnlockUserResult {
trackingNo: string
}
/** 通用API响应体 */
export interface ApiResponse<T = any> {
code: number
message: string
data: T
}

21
src/service/pay/index.ts Normal file
View File

@ -0,0 +1,21 @@
import { request } from '@/utils/request'
import type { PayParams, RefundParams, PayInfo, RefundInfo } from './type'
/** 发起支付 */
export const payAPI = (data: PayParams) => request.post<{ outTradeNo: string }>('/pay/pay', data)
/** 查询支付结果 */
export const queryPayResultAPI = (outTradeNo: string) =>
request.get<boolean>(`/pay/queryPayResult/${outTradeNo}`)
/** 申请退款 */
export const refundAPI = (data: RefundParams) => request.post('/pay/refund', data)
/** 获取退款详情 */
export const getRefundInfoAPI = () => request.get<RefundInfo[]>('/pay/refundInfo')
/** 撤销退款 */
export const cancelRefundAPI = (id: string) => request.get(`/pay/cancelRefund/${id}`)
/** 获取充值记录 */
export const getPayInfoAPI = () => request.get<PayInfo[]>('/pay/payInfo')

43
src/service/pay/type.ts Normal file
View File

@ -0,0 +1,43 @@
/** 支付参数 */
export interface PayParams {
/** 支付金额 */
amount: number
}
/** 退款参数 */
export interface RefundParams {
/** 退款金额 */
amount: number
/** 退款原因 */
reason?: string
}
/** 支付记录 */
export interface PayInfo {
/** 支付记录ID */
id: string
/** 支付金额 */
amount: number
/** 支付状态pending-待支付success-支付成功failed-支付失败 */
status: 'pending' | 'success' | 'failed'
/** 创建时间 */
createTime: string
/** 商户订单号 */
outTradeNo: string
}
/** 退款记录 */
export interface RefundInfo {
/** 退款记录ID */
id: string
/** 退款金额 */
amount: number
/** 退款状态pending-待审核approved-已通过rejected-已拒绝cancelled-已取消 */
status: 'pending' | 'approved' | 'rejected' | 'cancelled'
/** 退款原因 */
reason?: string
/** 创建时间 */
createTime: string
/** 更新时间 */
updateTime: string
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 780 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 985 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Some files were not shown because too many files have changed in this diff Show More