Преглед изворни кода

init: ai_uniapp_v2 initial commit

main
leiyun пре 1 месец
комит
635772c47b
100 измењених фајлова са 18844 додато и 0 уклоњено
  1. +13
    -0
      .gitignore
  2. +161
    -0
      App.vue
  3. +65
    -0
      README.md
  4. BIN
     
  5. BIN
     
  6. +80
    -0
      api/config.js
  7. +118
    -0
      api/index.js
  8. +36
    -0
      components/ai-empty/ai-empty.vue
  9. +91
    -0
      components/ai-nav-title/ai-nav-title.vue
  10. +24
    -0
      index.html
  11. +35
    -0
      main.js
  12. +71
    -0
      manifest.json
  13. +22
    -0
      package.json
  14. +185
    -0
      pages.json
  15. +45
    -0
      pages/agreement/agreement.vue
  16. +672
    -0
      pages/bill/bill.vue
  17. +474
    -0
      pages/invite/invite.vue
  18. +215
    -0
      pages/invite/landing.vue
  19. +465
    -0
      pages/invite/list.vue
  20. +417
    -0
      pages/login/login.vue
  21. +155
    -0
      pages/login/phone.vue
  22. +337
    -0
      pages/profile/profile.vue
  23. +411
    -0
      pages/recharge/recharge.vue
  24. +1295
    -0
      pages/tool/ai-name.vue
  25. +1114
    -0
      pages/tool/anime-avatar.vue
  26. +497
    -0
      pages/tool/anime-history.vue
  27. +110
    -0
      pages/tool/detail.vue
  28. +797
    -0
      pages/tool/id-photo.vue
  29. +348
    -0
      pages/tool/name-history.vue
  30. +609
    -0
      pages/tool/name-result.vue
  31. +422
    -0
      pages/tool/old-photo.vue
  32. +612
    -0
      pages/tool/portrait-photo.vue
  33. +311
    -0
      pages/tool/work-detail.vue
  34. +342
    -0
      pages/toolbox/toolbox.vue
  35. +15
    -0
      pages/webview/webview.vue
  36. +595
    -0
      pages/works/works.vue
  37. +498
    -0
      pnpm-lock.yaml
  38. +3
    -0
      static/icons/bolt.svg
  39. BIN
     
  40. BIN
     
  41. BIN
     
  42. BIN
     
  43. BIN
     
  44. BIN
     
  45. BIN
     
  46. BIN
     
  47. BIN
     
  48. BIN
     
  49. BIN
     
  50. BIN
     
  51. BIN
     
  52. BIN
     
  53. BIN
     
  54. BIN
     
  55. BIN
     
  56. BIN
     
  57. BIN
     
  58. BIN
     
  59. BIN
     
  60. BIN
     
  61. BIN
     
  62. BIN
     
  63. BIN
     
  64. +75
    -0
      store/user.js
  65. +64
    -0
      styles/common.scss
  66. +34
    -0
      uni.scss
  67. +21
    -0
      uni_modules/uview-plus/LICENSE
  68. +90
    -0
      uni_modules/uview-plus/README.md
  69. +2368
    -0
      uni_modules/uview-plus/changelog.md
  70. +109
    -0
      uni_modules/uview-plus/components/u-action-sheet-data/u-action-sheet-data.vue
  71. +28
    -0
      uni_modules/uview-plus/components/u-action-sheet/actionSheet.js
  72. +82
    -0
      uni_modules/uview-plus/components/u-action-sheet/props.js
  73. +341
    -0
      uni_modules/uview-plus/components/u-action-sheet/u-action-sheet.vue
  74. +77
    -0
      uni_modules/uview-plus/components/u-agreement/u-agreement.vue
  75. +28
    -0
      uni_modules/uview-plus/components/u-album/album.js
  76. +97
    -0
      uni_modules/uview-plus/components/u-album/props.js
  77. +344
    -0
      uni_modules/uview-plus/components/u-album/u-album.vue
  78. +26
    -0
      uni_modules/uview-plus/components/u-alert/alert.js
  79. +77
    -0
      uni_modules/uview-plus/components/u-alert/props.js
  80. +291
    -0
      uni_modules/uview-plus/components/u-alert/u-alert.vue
  81. +23
    -0
      uni_modules/uview-plus/components/u-avatar-group/avatarGroup.js
  82. +57
    -0
      uni_modules/uview-plus/components/u-avatar-group/props.js
  83. +109
    -0
      uni_modules/uview-plus/components/u-avatar-group/u-avatar-group.vue
  84. +28
    -0
      uni_modules/uview-plus/components/u-avatar/avatar.js
  85. +84
    -0
      uni_modules/uview-plus/components/u-avatar/props.js
  86. +179
    -0
      uni_modules/uview-plus/components/u-avatar/u-avatar.vue
  87. +27
    -0
      uni_modules/uview-plus/components/u-back-top/backtop.js
  88. +59
    -0
      uni_modules/uview-plus/components/u-back-top/props.js
  89. +132
    -0
      uni_modules/uview-plus/components/u-back-top/u-back-top.vue
  90. +27
    -0
      uni_modules/uview-plus/components/u-badge/badge.js
  91. +82
    -0
      uni_modules/uview-plus/components/u-badge/props.js
  92. +176
    -0
      uni_modules/uview-plus/components/u-badge/u-badge.vue
  93. +1031
    -0
      uni_modules/uview-plus/components/u-barcode/u-barcode.vue
  94. +24
    -0
      uni_modules/uview-plus/components/u-box/box.js
  95. +60
    -0
      uni_modules/uview-plus/components/u-box/props.js
  96. +106
    -0
      uni_modules/uview-plus/components/u-box/u-box.vue
  97. +43
    -0
      uni_modules/uview-plus/components/u-button/button.js
  98. +46
    -0
      uni_modules/uview-plus/components/u-button/nvue.scss
  99. +162
    -0
      uni_modules/uview-plus/components/u-button/props.js
  100. +577
    -0
      uni_modules/uview-plus/components/u-button/u-button.vue

+ 13
- 0
.gitignore Прегледај датотеку

@@ -0,0 +1,13 @@
node_modules/
unpackage/
dist/
.hbuilderx/
.DS_Store
*.log

# 证书/密钥等敏感文件严禁提交
*.keystore
*.jks
*.p12
*.mobileprovision
*.key

+ 161
- 0
App.vue Прегледај датотеку

@@ -0,0 +1,161 @@
<script setup>
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
import { useUserStore } from '@/store/user.js'

const userStore = useUserStore()

function parseInviteScene(scene) {
if (!scene) return ''
let text = ''
try {
text = decodeURIComponent(scene)
} catch (e) {
text = scene
}
const match = text.match(/(?:^|[&?])i=([^&]+)/) || text.match(/(?:^|[&?])i_([^&]+)/)
if (match) return match[1]
if (text.indexOf('i_') === 0) return text.slice(2)
return ''
}

function saveInviteParams(options = {}) {
const query = options.query || {}
const inviteCode = query.invite_code || query.invite_open_id || parseInviteScene(query.scene || '')
if (inviteCode) uni.setStorageSync('invite_code', inviteCode)
if (query.source) uni.setStorageSync('source', query.source)
}

function applyLightTheme() {
if (typeof uni === 'undefined') return
try {
uni.setStorageSync('u-theme-mode', 'light')
} catch (e) {}
if (uni.$u && typeof uni.$u.setThemePreference === 'function') {
uni.$u.setThemePreference('light')
}
// #ifdef MP-WEIXIN
if (typeof uni.setNavigationBarColor === 'function') {
try {
uni.setNavigationBarColor({
frontColor: '#000000',
backgroundColor: '#ffffff',
animation: {
duration: 0,
timingFunc: 'linear'
}
})
} catch (e) {}
}
if (typeof uni.setBackgroundColor === 'function') {
try {
uni.setBackgroundColor({
backgroundColor: '#ffffff',
backgroundColorTop: '#ffffff',
backgroundColorBottom: '#ffffff'
})
} catch (e) {}
}
// #endif
}

onLaunch((options = {}) => {
console.log('App Launch')
saveInviteParams(options)
// 恢复本地登录态
applyLightTheme()
userStore.restore()
})

onShow((options = {}) => {
console.log('App Show')
saveInviteParams(options)
applyLightTheme()
})

onHide(() => {
console.log('App Hide')
})
</script>

<style lang="scss">
@import '@/uni_modules/uview-plus/index.scss';
/* 全局公共样式 */
@import '@/styles/common.scss';

:root,
page,
body,
[data-up-theme='light'],
[data-up-theme='dark'] {
color-scheme: light;
--up-main-color: var(--up-light-main-color, #303133);
--up-content-color: var(--up-light-content-color, #606266);
--up-tips-color: var(--up-light-tips-color, #909193);
--up-light-color: var(--up-light-light-color, #c0c4cc);
--up-border-color: var(--up-light-border-color, #dadbde);
--up-bg-color: var(--up-light-bg-color, #f3f4f6);
--up-hover-bg-color: #e7ebf0;
--up-page-bg-color: #f3f4f6;
--up-card-bg-color: #ffffff;
--up-navbar-bg-color: #ffffff;
--up-disabled-color: var(--up-light-disabled-color, #c8c9cc);
--up-primary: var(--up-light-primary, #3c9cff);
--up-primary-dark: var(--up-light-primary-dark, #398ade);
--up-primary-disabled: var(--up-light-primary-disabled, #9acafc);
--up-primary-light: var(--up-light-primary-light, #ecf5ff);
--up-warning: var(--up-light-warning, #f9ae3d);
--up-warning-dark: var(--up-light-warning-dark, #f1a532);
--up-warning-disabled: var(--up-light-warning-disabled, #f9d39b);
--up-warning-light: var(--up-light-warning-light, #fdf6ec);
--up-success: var(--up-light-success, #5ac725);
--up-success-dark: var(--up-light-success-dark, #53c21d);
--up-success-disabled: var(--up-light-success-disabled, #a9e08f);
--up-success-light: var(--up-light-success-light, #f5fff0);
--up-error: var(--up-light-error, #f56c6c);
--up-error-dark: var(--up-light-error-dark, #e45656);
--up-error-disabled: var(--up-light-error-disabled, #f7b2b2);
--up-error-light: var(--up-light-error-light, #fef0f0);
--up-info: var(--up-light-info, #909399);
--up-info-dark: var(--up-light-info-dark, #767a82);
--up-info-disabled: var(--up-light-info-disabled, #c4c6c9);
--up-info-light: var(--up-light-info-light, #f4f4f5);
--up-table2-header-bg-color: #f5f7fa;
--up-table2-zebra-bg-color: #fafafa;
--up-table2-highlight-bg-color: #f5f7fa;
--up-gap-bg-color: #f3f4f6;
--up-skeleton-bg-color: #f1f2f4;
--up-skeleton-shimmer-color: #e6e6e6;
--up-swipe-action-button-bg-color: #c7c6cd;
--up-index-list-indicator-bg-color: #c9c9c9;
--up-calendar-month-mark-color: rgba(231, 232, 234, 0.83);

--u-main-color: var(--up-main-color);
--u-content-color: var(--up-content-color);
--u-tips-color: var(--up-tips-color);
--u-light-color: var(--up-light-color);
--u-border-color: var(--up-border-color);
--u-bg-color: var(--up-bg-color);
--u-hover-bg-color: var(--up-hover-bg-color);
--u-disabled-color: var(--up-disabled-color);
--u-primary: var(--up-primary);
--u-primary-dark: var(--up-primary-dark);
--u-primary-disabled: var(--up-primary-disabled);
--u-primary-light: var(--up-primary-light);
--u-warning: var(--up-warning);
--u-warning-dark: var(--up-warning-dark);
--u-warning-disabled: var(--up-warning-disabled);
--u-warning-light: var(--up-warning-light);
--u-success: var(--up-success);
--u-success-dark: var(--up-success-dark);
--u-success-disabled: var(--up-success-disabled);
--u-success-light: var(--up-success-light);
--u-error: var(--up-error);
--u-error-dark: var(--up-error-dark);
--u-error-disabled: var(--up-error-disabled);
--u-error-light: var(--up-error-light);
--u-info: var(--up-info);
--u-info-dark: var(--up-info-dark);
--u-info-disabled: var(--up-info-disabled);
--u-info-light: var(--up-info-light);
}
</style>

+ 65
- 0
README.md Прегледај датотеку

@@ -0,0 +1,65 @@
# ai_uniapp_v2

AIonline(奇想宇宙)多端应用 —— **uni-app + Vue 3** 重构版,替代 `ai_uniapp`(Vue 2)。

## 技术栈

- uni-app(HBuilderX 工作流,`manifest.json` 中 `vueVersion: "3"`)
- Vue 3 组合式 API(`<script setup>`)
- Pinia 状态管理(替代旧版 Vuex)
- SCSS(全局变量见 `uni.scss`,取自新版原型配色)

## 目录结构

```
ai_uniapp_v2/
├── api/ # 接口层
│ ├── config.js # 接口地址常量(INTERFACE_<动作>_<资源>)
│ └── index.js # 按业务模块聚合的请求方法
├── components/ # 通用组件(easycom:ai-xxx 自动注册)
├── pages/
│ ├── toolbox/ # 工具箱(首页 tab)
│ ├── works/ # 作品(tab)
│ ├── profile/ # 个人中心(tab)
│ ├── login/ # 登录(微信手机号 / 验证码)
│ ├── tool/detail # 通用工具详情页(按 type 渲染)
│ └── webview/ # 内嵌 H5
├── static/ # 静态资源
├── store/ # Pinia
│ └── user.js
├── styles/common.scss # 全局公共样式
├── utils/
│ ├── config.js # 环境与接口基址
│ ├── request.js # 统一请求(沿用旧版鉴权:token/mac/client/nonce)
│ ├── auth.js # 登录态存取
│ ├── util.js # 通用工具
│ ├── useApp.js # 交互组合式函数(toast/loading/返回)
│ └── filter.js # 格式化工具
├── App.vue / main.js / pages.json / manifest.json / uni.scss
```

## 与后端对接

接口路径与旧项目及 `ai_api` / `ai_server` 保持一致,后端零改动。约定:

- 请求体 `application/x-www-form-urlencoded`,自动附加 `token` / `mac` / `client` / `source` / `version` 等公共参数。
- 响应结构 `{ code, data, list, msg }`:`code===0` 成功;`code===1009` 登录失效(自动清态并跳登录页)。

## 运行

推荐用 **HBuilderX** 打开本目录,选择运行到浏览器 / 小程序 / App。

H5 本地开发通过 `manifest.json` 的 `h5.devServer.proxy` 将 `/api` 代理到测试域名,规避跨域。

> appid 已沿用旧项目:uni 应用 `__UNI__CD107E4`、微信小程序 `wx6d948e86da551d48`、App 端微信 oauth `wx34fc0db30ace39bc`。
> 注意:`utils/config.js` 中的正式域名仍为占位,接入前请替换为真实值,切勿硬编码任何密钥。

## 迁移说明(相对 ai_uniapp)

| 项 | 旧(ai_uniapp) | 新(ai_uniapp_v2) |
| --- | --- | --- |
| Vue | Vue 2 | Vue 3 `<script setup>` |
| 状态管理 | Vuex | Pinia |
| 全局能力 | `Vue.mixin(base)` / `Vue.prototype.$xxx` | 组合式函数 `useApp()` |
| 过滤器 | `Vue.filter` | `utils/filter.js`(Vue3 已移除 filter) |
| 请求 | `utils/request.js`(回调拦截器) | `utils/request.js`(Promise + Pinia) |



+ 80
- 0
api/config.js Прегледај датотеку

@@ -0,0 +1,80 @@
/**
* 接口地址常量集合(沿用旧版命名:INTERFACE_<动作>_<资源>)
* 对接后端 ai_api / ai_server,接口路径保持一致,后端零改动。
*/
export default {
// 公共
INTERFACE_FILE_UPLOAD: '/common/uploadpic', // 图片上传
INTERFACE_AUDIO_UPLOAD: '/common/uploadaudio', // 音频上传
INTERFACE_COMMON_GET_CONFIG: '/common/getconfig', // 配置项
INTERFACE_COMMON_GET_HTML: '/common/gethtml', // 富文本
INTERFACE_COMMON_GET_WX_SIGN: '/common/getwxsign', // 微信签名
INTERFACE_COMMON_POST_FEEDBACK: '/feedback/add', // 意见反馈
INTERFACE_SEND_CODE: '/common/sendcode', // 获取验证码
INTERFACE_PVCV_ADD: '/common/pvcv', // PV/CV 上报
INTERFACE_HOME_GET_CONFIG: '/home/config', // 小程序首页配置
INTERFACE_WORK_GET_LIST: '/work/getlist', // 我的作品聚合列表

// 用户
INTERFACE_LOGIN_BY_CODE: '/user/loginbycode', // 微信登录
INTERFACE_COMMON_PHONE_LOGIN_MP: '/user/login_phone_mini', // 小程序手机号登录
INTERFACE_LOGIN_BY_PHONE: '/user/loginbyphone', // 手机号+密码登录
INTERFACE_PHONE_LOGIN: '/user/login_phone', // 手机号+验证码登录
INTERFACE_SIGN_WITH_APPLE: '/user/loginbyapple', // 苹果登录
INTERFACE_POST_BIND_PHONE_MP: '/user/bindphonemini', // 绑定手机号-小程序
INTERFACE_POST_BIND_PHONE: '/user/bindphone', // 绑定手机号
INTERFACE_USER_GET_INFO: '/user/getuserinfo', // 用户信息
INTERFACE_USER_UPDATE_INFO: '/user/edit', // 编辑用户信息
INTERFACE_USER_GET_BALANCE: '/user/getbalance', // 点数余额
INTERFACE_USER_GET_INVITE_LIST: '/user/getinvitelist', // 邀请列表
INTERFACE_USER_GET_MINI_SCHEME: '/user/getminischeme', // 小程序深度链接(H5 唤起小程序登录)
INTERFACE_INVITE_GET_CONFIG: '/invite/config', // 邀请配置/落地页信息
INTERFACE_INVITE_GET_MY: '/invite/my', // 我的邀请信息
INTERFACE_INVITE_GET_LIST: '/invite/list', // 我的邀请奖励记录
INTERFACE_INVITE_GET_QRCODE: '/invite/qrcode', // 邀请小程序码

// 会员/充值
INTERFACE_VIP_GET_PRODUCT_LIST: '/vip/getproductlist', // 产品列表
INTERFACE_VIP_POST_CREATE_ORDER: '/vip/createorder', // 创建订单
INTERFACE_VIP_POST_PAY_SUCCESS: '/vip/paysuccess', // 支付结果查询
INTERFACE_VIP_GET_MY_LIST: '/vip/getmylist', // 我的订阅

// 点数充值(新业务,小程序虚拟支付)
INTERFACE_POINT_GET_PACKAGE_LIST: '/point/packagelist', // 点数套餐列表
INTERFACE_POINT_GET_BALANCE: '/point/balance', // 点数余额
INTERFACE_POINT_GET_LOG_LIST: '/point/loglist', // 点数账单流水
INTERFACE_POINT_POST_CREATE_ORDER: '/point/createorder', // 创建充值订单
INTERFACE_POINT_GET_ORDER_STATUS: '/point/orderstatus', // 订单状态轮询
INTERFACE_BILLING_GET_CONFIG: '/billing/config', // 产品计费配置
INTERFACE_BILLING_POST_QUOTE: '/billing/quote', // 产品计价

// AI 图像应用(工具箱)
INTERFACE_APP_GET_LIST: '/app/getlist', // 应用列表
INTERFACE_APP_GET_DETAIL: '/app/getdetail', // 应用详情
INTERFACE_ART_GET_LIST: '/art/getlist', // 作品列表
INTERFACE_ART_GET_DETAIL: '/art/getdetail', // 作品详情
INTERFACE_ART_POST_LIKE: '/art/like', // 作品点赞
INTERFACE_MJ_POST_SEND: '/mj/send', // 提交生成任务
INTERFACE_MJ_GET_LIST: '/mj/getlist', // 生成记录
INTERFACE_MJ_POST_ACTION: '/mj/action', // 二次操作
INTERFACE_WXYG_GET_STYLE_LIST: '/mj/getstylelist', // 图生图/文心风格
INTERFACE_ANIME_POST_SEND: '/anime/send', // 动漫头像提交
INTERFACE_ANIME_GET_LIST: '/anime/getlist', // 动漫头像记录
INTERFACE_ANIME_GET_DETAIL: '/anime/getdetail', // 动漫头像详情
INTERFACE_ANIME_POST_DEL: '/anime/del', // 动漫头像删除
INTERFACE_ANIME_GET_STYLE_LIST: '/anime/getstylelist', // 动漫头像风格

// 机器人/文本生成(旧项目 AI 写作/取名接口)
INTERFACE_BOT_POST_EDIT: '/robot/edit', // 创建/编辑机器人
INTERFACE_BOT_GET_DETAIL_BY_TYPE: '/robot/getdetailbytype', // 按类型获取机器人
INTERFACE_CHAT_POST_SEND: '/chat/send', // 文本生成
INTERFACE_CHAT_GET_MSG_LIST: '/chat/getlist', // 文本生成记录
INTERFACE_CHAT_GET_MSG_DETAIL: '/chat/getdetail', // 文本生成详情
INTERFACE_CHAT_POST_DEL: '/chat/del', // 删除文本/图片记录
INTERFACE_AINAME_POST_SEND: '/ainame/send', // AI取名新链路
INTERFACE_AINAME_POST_CHECK: '/ainame/check', // AI取名额度预检
INTERFACE_AINAME_GET_ROBOT: '/ainame/getrobot', // AI取名机器人
INTERFACE_AINAME_GET_LIST: '/ainame/getlist', // AI取名记录
INTERFACE_AINAME_GET_DETAIL: '/ainame/getdetail', // AI取名详情
INTERFACE_AINAME_POST_DEL: '/ainame/del' // AI取名删除
}

+ 118
- 0
api/index.js Прегледај датотеку

@@ -0,0 +1,118 @@
import request from '@/utils/request.js'
import API from './config.js'
import { getUA } from '@/utils/util.js'

/**
* 按业务模块聚合的接口方法。
* 组件中:import api from '@/api/index.js' -> api.user.getInfo()
*/

// 登录类接口统一补充 invite_open_id(邀请码)与 user_agent(沿用旧项目 comApi 逻辑)
function withLoginExtra(params = {}) {
const inviteCode = uni.getStorageSync('invite_code')
if (inviteCode) params.invite_open_id = inviteCode
params.user_agent = getUA()
return params
}

export const userApi = {
// 微信登录(code 换取登录态)
loginByCode: (params) => request({ url: API.INTERFACE_LOGIN_BY_CODE, method: 'POST', params: withLoginExtra(params) }),
// 小程序手机号一键登录
loginByPhoneMp: (params) => request({ url: API.INTERFACE_COMMON_PHONE_LOGIN_MP, method: 'POST', params: withLoginExtra(params) }),
// 手机号+密码登录
loginByPhone: (params) => request({ url: API.INTERFACE_LOGIN_BY_PHONE, method: 'POST', params }),
// 手机号+验证码登录(App / H5)
loginPhone: (params) => request({ url: API.INTERFACE_PHONE_LOGIN, method: 'POST', params: withLoginExtra(params) }),
// 苹果 / 游客登录
signWithApple: (params) => request({ url: API.INTERFACE_SIGN_WITH_APPLE, method: 'POST', params: withLoginExtra(params) }),
// H5 唤起小程序登录的深度链接
getMiniScheme: (params) => request({ url: API.INTERFACE_USER_GET_MINI_SCHEME, method: 'POST', params }),
sendCode: (params) => request({ url: API.INTERFACE_SEND_CODE, method: 'POST', params }),
getInfo: (params) => request({ url: API.INTERFACE_USER_GET_INFO, method: 'POST', params }),
updateInfo: (params) => request({ url: API.INTERFACE_USER_UPDATE_INFO, method: 'POST', params }),
getBalance: (params) => request({ url: API.INTERFACE_USER_GET_BALANCE, method: 'POST', params }),
getInviteList: (params) => request({ url: API.INTERFACE_USER_GET_INVITE_LIST, method: 'POST', params })
}

export const inviteApi = {
getConfig: (params) => request({ url: API.INTERFACE_INVITE_GET_CONFIG, method: 'POST', params }),
getMy: (params) => request({ url: API.INTERFACE_INVITE_GET_MY, method: 'POST', params }),
getList: (params) => request({ url: API.INTERFACE_INVITE_GET_LIST, method: 'POST', params }),
getQrcode: (params) => request({ url: API.INTERFACE_INVITE_GET_QRCODE, method: 'POST', params })
}

export const commonApi = {
getConfig: (params) => request({ url: API.INTERFACE_COMMON_GET_CONFIG, method: 'POST', params }),
getHomeConfig: (params) => request({ url: API.INTERFACE_HOME_GET_CONFIG, method: 'POST', params }),
getHtml: (params) => request({ url: API.INTERFACE_COMMON_GET_HTML, method: 'POST', params }),
feedback: (params) => request({ url: API.INTERFACE_COMMON_POST_FEEDBACK, method: 'POST', params })
}

export const vipApi = {
getProductList: (params) => request({ url: API.INTERFACE_VIP_GET_PRODUCT_LIST, method: 'POST', params }),
createOrder: (params) => request({ url: API.INTERFACE_VIP_POST_CREATE_ORDER, method: 'POST', params }),
paySuccess: (params) => request({ url: API.INTERFACE_VIP_POST_PAY_SUCCESS, method: 'POST', params }),
getMyList: (params) => request({ url: API.INTERFACE_VIP_GET_MY_LIST, method: 'POST', params })
}

export const pointApi = {
// 点数套餐列表
getPackageList: (params) => request({ url: API.INTERFACE_POINT_GET_PACKAGE_LIST, method: 'POST', params }),
// 点数余额
getBalance: (params) => request({ url: API.INTERFACE_POINT_GET_BALANCE, method: 'POST', params }),
// 点数账单流水
getLogList: (params) => request({ url: API.INTERFACE_POINT_GET_LOG_LIST, method: 'POST', params }),
// 创建充值订单(小程序返回虚拟支付参数)
createOrder: (params) => request({ url: API.INTERFACE_POINT_POST_CREATE_ORDER, method: 'POST', params }),
// 订单状态轮询
getOrderStatus: (params) => request({ url: API.INTERFACE_POINT_GET_ORDER_STATUS, method: 'POST', params })
}

export const billingApi = {
getConfig: (params) => request({ url: API.INTERFACE_BILLING_GET_CONFIG, method: 'POST', params }),
quote: (params) => request({ url: API.INTERFACE_BILLING_POST_QUOTE, method: 'POST', params })
}

export const toolApi = {
getMyWorkList: (params) => request({ url: API.INTERFACE_WORK_GET_LIST, method: 'POST', params }),
getAppList: (params) => request({ url: API.INTERFACE_APP_GET_LIST, method: 'POST', params }),
getAppDetail: (params) => request({ url: API.INTERFACE_APP_GET_DETAIL, method: 'POST', params }),
getWorkList: (params) => request({ url: API.INTERFACE_ART_GET_LIST, method: 'POST', params }),
getWorkDetail: (params) => request({ url: API.INTERFACE_ART_GET_DETAIL, method: 'POST', params }),
likeWork: (params) => request({ url: API.INTERFACE_ART_POST_LIKE, method: 'POST', params }),
submitTask: (params) => request({ url: API.INTERFACE_MJ_POST_SEND, method: 'POST', params }),
getTaskList: (params) => request({ url: API.INTERFACE_MJ_GET_LIST, method: 'POST', params }),
getStyleList: (params) => request({ url: API.INTERFACE_WXYG_GET_STYLE_LIST, method: 'POST', params }),
submitAnime: (params) => request({ url: API.INTERFACE_ANIME_POST_SEND, method: 'POST', params }),
getAnimeList: (params) => request({ url: API.INTERFACE_ANIME_GET_LIST, method: 'POST', params }),
getAnimeDetail: (params) => request({ url: API.INTERFACE_ANIME_GET_DETAIL, method: 'POST', params }),
deleteAnime: (params) => request({ url: API.INTERFACE_ANIME_POST_DEL, method: 'POST', params }),
getAnimeStyleList: (params) => request({ url: API.INTERFACE_ANIME_GET_STYLE_LIST, method: 'POST', params }),
getRobotDetailByType: (params) => request({ url: API.INTERFACE_BOT_GET_DETAIL_BY_TYPE, method: 'POST', params }),
editRobot: (params) => request({ url: API.INTERFACE_BOT_POST_EDIT, method: 'POST', params }),
sendChat: (params) => request({ url: API.INTERFACE_CHAT_POST_SEND, method: 'POST', params }),
submitWriting: async (params) => {
await request({ url: API.INTERFACE_BOT_POST_EDIT, method: 'POST', params })
return request({ url: API.INTERFACE_CHAT_POST_SEND, method: 'POST', params })
},
checkName: (params) => request({ url: API.INTERFACE_AINAME_POST_CHECK, method: 'POST', params }),
submitName: (params) => request({ url: API.INTERFACE_AINAME_POST_SEND, method: 'POST', params }),
getNameRobot: (params) => request({ url: API.INTERFACE_AINAME_GET_ROBOT, method: 'POST', params }),
getNameList: (params) => request({ url: API.INTERFACE_AINAME_GET_LIST, method: 'POST', params }),
getNameDetail: (params) => request({ url: API.INTERFACE_AINAME_GET_DETAIL, method: 'POST', params }),
deleteName: (params) => request({ url: API.INTERFACE_AINAME_POST_DEL, method: 'POST', params }),
getChatList: (params) => request({ url: API.INTERFACE_CHAT_GET_MSG_LIST, method: 'POST', params }),
getChatDetail: (params) => request({ url: API.INTERFACE_CHAT_GET_MSG_DETAIL, method: 'POST', params }),
deleteChat: (params) => request({ url: API.INTERFACE_CHAT_POST_DEL, method: 'POST', params })
}

export default {
user: userApi,
invite: inviteApi,
common: commonApi,
vip: vipApi,
point: pointApi,
billing: billingApi,
tool: toolApi
}

+ 36
- 0
components/ai-empty/ai-empty.vue Прегледај датотеку

@@ -0,0 +1,36 @@
<template>
<view class="ai-empty">
<text class="ai-empty__title">{{ title }}</text>
<text v-if="desc" class="ai-empty__desc">{{ desc }}</text>
<slot />
</view>
</template>

<script setup>
/**
* 通用空状态组件(easycom 自动注册,模板中直接写 <ai-empty />)
*/
defineProps({
title: { type: String, default: '暂无数据' },
desc: { type: String, default: '' }
})
</script>

<style lang="scss" scoped>
.ai-empty {
padding: 160rpx 40rpx 0;
display: flex;
flex-direction: column;
align-items: center;

&__title {
font-size: 32rpx;
font-weight: 700;
}
&__desc {
margin-top: 12rpx;
font-size: 26rpx;
color: $ai-muted;
}
}
</style>

+ 91
- 0
components/ai-nav-title/ai-nav-title.vue Прегледај датотеку

@@ -0,0 +1,91 @@
<template>
<view class="ai-nav-title">
<view class="nav-fixed" :style="{ height: totalHeight + 'px' }">
<text
class="nav-title"
:style="{
top: titleTop + 'px',
lineHeight: titleLineHeight + 'px',
fontSize: titleFontSize + 'px'
}"
>
{{ title }}
</text>
</view>
<view class="nav-placeholder" :style="{ height: totalHeight + 'px' }"></view>
</view>
</template>

<script setup>
import { onMounted, ref } from 'vue'

defineProps({
title: {
type: String,
default: ''
}
})

const totalHeight = ref(88)
const titleTop = ref(54)
const titleLineHeight = ref(24)
const titleFontSize = ref(18)

function setNavMetrics() {
const systemInfo = uni.getSystemInfoSync()
const statusBarHeight = systemInfo.statusBarHeight || 0
let menuButton = null

// #ifdef MP-WEIXIN
menuButton = uni.getMenuButtonBoundingClientRect()
// #endif

if (menuButton && menuButton.top && menuButton.height) {
const navPadding = menuButton.top - statusBarHeight
totalHeight.value = menuButton.bottom + navPadding
titleLineHeight.value = Math.min(28, menuButton.height)
titleFontSize.value = 18
titleTop.value = menuButton.top + (menuButton.height - titleLineHeight.value) / 2
return
}

totalHeight.value = statusBarHeight + 44
titleLineHeight.value = 24
titleFontSize.value = 18
titleTop.value = statusBarHeight + 10
}

onMounted(() => {
setNavMetrics()
})
</script>

<style lang="scss" scoped>
.ai-nav-title {
position: relative;
z-index: 90;
}

.nav-fixed {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 90;
background: #ffffff;
box-sizing: border-box;
}

.nav-title {
position: absolute;
left: 30rpx;
color: #333333;
font-weight: 700;
white-space: nowrap;
}

.nav-placeholder {
width: 100%;
background: #ffffff;
}
</style>

+ 24
- 0
index.html Прегледај датотеку

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<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,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no' +
(coverSupport ? ',viewport-fit=cover' : '') +
'" />'
)
</script>
<title>AI在线</title>
<!--preload-links-->
<!--app-context-->
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/main.js"></script>
</body>
</html>

+ 35
- 0
main.js Прегледај датотеку

@@ -0,0 +1,35 @@
import { createSSRApp } from 'vue'
import App from './App.vue'
import { createPinia } from 'pinia'
import uviewPlus from '@/uni_modules/uview-plus'
import * as filters from '@/utils/filter.js'

function lockLightTheme() {
if (typeof uni === 'undefined') return
try {
uni.setStorageSync('u-theme-mode', 'light')
} catch (e) {}
if (uni.$u && typeof uni.$u.setThemePreference === 'function') {
uni.$u.setThemePreference('light')
}
}

// uni-app Vue3 入口:必须导出 createApp
export function createApp() {
const app = createSSRApp(App)

// 状态管理:Pinia(替代旧版 Vuex)
app.use(createPinia())
lockLightTheme()
app.use(uviewPlus)
lockLightTheme()

// 全局过滤器/工具(Vue3 已移除 filter,这里挂到 globalProperties 供模板使用)
app.config.globalProperties.$f = filters

// #ifdef H5-DEV || H5-TEST
// 开发/测试环境可按需引入 vconsole
// #endif

return { app }
}

+ 71
- 0
manifest.json Прегледај датотеку

@@ -0,0 +1,71 @@
{
"name" : "AI在线",
"appid" : "__UNI__CD107E4",
"description" : "AIonline 多端 AI 应用(Vue3 重构版)",
"versionName" : "2.0.0",
"versionCode" : 200,
"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.INTERNET\"/>"
]
},
"ios" : {},
"sdkConfigs" : {
"oauth" : {
"apple" : {},
"weixin" : {
"appid" : "wx34fc0db30ace39bc",
"UniversalLinks" : "https://aionline.cc/app/"
}
}
}
}
},
"quickapp" : {},
"mp-weixin" : {
"appid" : "wx6d948e86da551d48",
"darkmode" : false,
"setting" : {
"urlCheck" : false,
"es6" : true,
"minified" : true
},
"usingComponents" : true
},
"mp-alipay" : {
"usingComponents" : true
},
"h5" : {
"router" : {
"mode" : "history",
"base" : "/"
},
"devServer" : {
"https" : false,
"proxy" : {
"/api" : {
"target" : "https://api.jiefuku.com",
"changeOrigin" : true,
"secure" : false,
"pathRewrite" : {
"^/api" : ""
}
}
}
}
},
"vueVersion" : "3"
}

+ 22
- 0
package.json Прегледај датотеку

@@ -0,0 +1,22 @@
{
"name": "ai_uniapp_v2",
"version": "2.0.0",
"description": "AIonline 小程序/App/H5 多端应用(uni-app + Vue3 重构版)",
"main": "main.js",
"scripts": {
"dev:h5": "uni",
"dev:mp-weixin": "uni -p mp-weixin",
"build:h5": "uni build",
"build:mp-weixin": "uni build -p mp-weixin"
},
"author": "AIonline",
"license": "ISC",
"dependencies": {
"dayjs": "^1.11.10",
"pinia": "^2.1.7",
"vue": "^3.4.21"
},
"devDependencies": {
"sass": "^1.72.0"
}
}

+ 185
- 0
pages.json Прегледај датотеку

@@ -0,0 +1,185 @@
{
"easycom": {
"autoscan": true,
"custom": {
"^up-(.*)": "@/uni_modules/uview-plus/components/u-$1/u-$1.vue",
"^u-(.*)": "@/uni_modules/uview-plus/components/u-$1/u-$1.vue",
"^ai-(.*)": "@/components/ai-$1/ai-$1.vue"
}
},
"pages": [
{
"path": "pages/toolbox/toolbox",
"style": {
"navigationBarTitleText": "AI在线",
"navigationStyle": "custom"
}
},
{
"path": "pages/works/works",
"style": {
"navigationBarTitleText": "作品",
"navigationStyle": "custom"
}
},
{
"path": "pages/profile/profile",
"style": {
"navigationBarTitleText": "个人中心",
"navigationStyle": "custom"
}
},
{
"path": "pages/login/login",
"style": {
"navigationBarTitleText": "登录"
}
},
{
"path": "pages/login/phone",
"style": {
"navigationBarTitleText": "手机号登录"
}
},
{
"path": "pages/agreement/agreement",
"style": {
"navigationBarTitleText": "协议"
}
},
{
"path": "pages/tool/detail",
"style": {
"navigationBarTitleText": "AI工具"
}
},
{
"path": "pages/tool/anime-avatar",
"style": {
"navigationBarTitleText": "动漫头像"
}
},
{
"path": "pages/tool/anime-history",
"style": {
"navigationBarTitleText": "历史记录"
}
},
{
"path": "pages/tool/id-photo",
"style": {
"navigationBarTitleText": "证件照"
}
},
{
"path": "pages/tool/portrait-photo",
"style": {
"navigationBarTitleText": "形象照/职业照/写真"
}
},
{
"path": "pages/tool/old-photo",
"style": {
"navigationBarTitleText": "老照片修复/上色"
}
},
{
"path": "pages/tool/work-detail",
"style": {
"navigationBarTitleText": "作品详情"
}
},
{
"path": "pages/tool/ai-name",
"style": {
"navigationBarTitleText": "AI取名"
}
},
{
"path": "pages/tool/name-result",
"style": {
"navigationBarTitleText": "生成结果"
}
},
{
"path": "pages/tool/name-history",
"style": {
"navigationBarTitleText": "历史记录"
}
},
{
"path": "pages/recharge/recharge",
"style": {
"navigationBarTitleText": "充值"
}
},
{
"path": "pages/bill/bill",
"style": {
"navigationBarTitleText": "账单",
"enablePullDownRefresh": true,
"backgroundTextStyle": "dark",
"backgroundColor": "#f3f7fb"
}
},
{
"path": "pages/invite/invite",
"style": {
"navigationBarTitleText": "邀请好友"
}
},
{
"path": "pages/invite/landing",
"style": {
"navigationBarTitleText": "好友邀请"
}
},
{
"path": "pages/invite/list",
"style": {
"navigationBarTitleText": "邀请记录",
"enablePullDownRefresh": true,
"backgroundTextStyle": "dark",
"backgroundColor": "#f3f7fb"
}
},
{
"path": "pages/webview/webview",
"style": {
"navigationBarTitleText": ""
}
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "AI在线",
"navigationBarBackgroundColor": "#ffffff",
"backgroundColor": "#f4f7fb"
},
"tabBar": {
"color": "#7f8896",
"selectedColor": "#0b8cff",
"backgroundColor": "#ffffff",
"borderStyle": "black",
"list": [
{
"pagePath": "pages/toolbox/toolbox",
"iconPath": "static/tabbar/tab_toolbox.png",
"selectedIconPath": "static/tabbar/tab_toolbox_active.png",
"text": "工具箱"
},
{
"pagePath": "pages/works/works",
"iconPath": "static/tabbar/tab_works.png",
"selectedIconPath": "static/tabbar/tab_works_active.png",
"text": "作品"
},
{
"pagePath": "pages/profile/profile",
"iconPath": "static/tabbar/tab_profile.png",
"selectedIconPath": "static/tabbar/tab_profile_active.png",
"text": "个人中心"
}
]
}
}

+ 45
- 0
pages/agreement/agreement.vue Прегледај датотеку

@@ -0,0 +1,45 @@
<template>
<view class="agreement-page">
<!-- #ifdef H5 -->
<view class="txt" v-html="detail.content"></view>
<!-- #endif -->
<!-- #ifndef H5 -->
<rich-text class="txt" :nodes="detail.content || ''"></rich-text>
<!-- #endif -->
</view>
</template>

<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { commonApi } from '@/api/index.js'

const detail = ref({})

onLoad((options) => {
getHtmlInfo(options.key)
})

async function getHtmlInfo(key) {
try {
const res = await commonApi.getHtml({ key })
detail.value = res.data || {}
if (res.data && res.data.title) {
uni.setNavigationBarTitle({ title: res.data.title })
}
} catch (e) {
// 忽略
}
}
</script>

<style lang="scss" scoped>
.agreement-page {
padding: 30rpx;
.txt {
line-height: 1.6;
font-size: 28rpx;
color: $ai-text;
}
}
</style>

+ 672
- 0
pages/bill/bill.vue Прегледај датотеку

@@ -0,0 +1,672 @@
<template>
<view class="page">
<view class="wrap">
<view class="wallet-card">
<view class="wallet-copy">
<text class="wallet-kicker">我的点数</text>
<view class="wallet-main">
<view class="balance-line">
<text class="balance-number">{{ balanceText }}</text>
<text v-if="balanceUnit" class="balance-unit">{{ balanceUnit }}</text>
</view>
<view class="recharge-action" @click="goRecharge">充值</view>
</view>
</view>

<view class="wallet-stats">
<view v-for="item in summaryStats" :key="item.key" class="stat-item">
<text class="stat-value" :class="item.key">{{ item.value }}</text>
<text class="stat-label">{{ item.label }}</text>
</view>
</view>
</view>

<view class="filter-panel">
<view
v-for="item in tabs"
:key="item.key"
class="filter-item"
:class="{ active: activeTab === item.key }"
@click="changeTab(item.key)"
>
<text class="filter-name">{{ item.name }}</text>
<text class="filter-count">{{ tabCounts[item.key] || 0 }}条</text>
</view>
</view>

<view class="list-head">
<text class="list-title">流水明细</text>
<text class="list-sub">{{ activeTabName }}</text>
</view>

<view v-if="list.length" class="ledger-panel">
<view v-for="item in list" :key="item.id" class="ledger-row">
<view class="type-mark" :class="item.type">
<text>{{ getTypeMark(item.type) }}</text>
</view>
<view class="ledger-main">
<view class="title-row">
<text class="ledger-title">{{ item.title }}</text>
<text class="ledger-status">已完成</text>
</view>
<text class="ledger-desc">{{ item.desc }}</text>
<view class="ledger-meta">
<text class="ledger-type" :class="item.type">{{ item.type_text }}</text>
<text>{{ item.time }}</text>
</view>
</view>
<text class="ledger-amount" :class="item.amount > 0 ? 'plus' : 'minus'">
{{ formatAmount(item.amount) }}
</text>
</view>
</view>

<view v-if="!list.length && !loading" class="empty-state">
<view class="empty-icon">账</view>
<text class="empty-title">暂无账单记录</text>
<text class="empty-desc">{{ activeTab === 'all' ? '充值或创作后,这里会展示点数变化。' : '当前分类还没有记录。' }}</text>
</view>

<view v-if="loading" class="loading">{{ list.length ? '加载更多...' : '加载中...' }}</view>
<view v-if="list.length && list.length >= total && !loading" class="end-text">没有更多了</view>
</view>
</view>
</template>

<script setup>
import { computed, ref } from 'vue'
import { onLoad, onShow, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import { pointApi } from '@/api/index.js'
import { useUserStore } from '@/store/user.js'
import { useApp } from '@/utils/useApp.js'

const userStore = useUserStore()
const { toast } = useApp()
const activeTab = ref('all')
const list = ref([])
const page = ref(1)
const pageSize = 10
const total = ref(0)
const loading = ref(false)
const refreshing = ref(false)
const balance = ref({ points: 0, total_recharge: 0, total_consume: 0 })
const summary = ref({
recharge: 0,
consume: 0,
reward: 0,
refund: 0,
adjust: 0,
counts: {
all: 0,
recharge: 0,
consume: 0,
reward: 0,
other: 0
}
})
let firstShow = true

const tabs = [
{ key: 'all', name: '全部' },
{ key: 'recharge', name: '充值' },
{ key: 'consume', name: '消费' },
{ key: 'reward', name: '奖励' },
{ key: 'other', name: '其他' }
]

const typeMark = {
all: '',
recharge: '+',
consume: '-',
reward: '奖',
refund: '退',
adjust: '调'
}

const balanceText = computed(() => {
const points = balance.value.points !== undefined ? balance.value.points : userStore.balance
return Number(points) < 0 ? '不限' : String(Number(points) || 0)
})

const balanceUnit = computed(() => {
return ['不限', '无限'].includes(String(balanceText.value)) ? '' : '点'
})

const tabCounts = computed(() => {
return summary.value.counts || {}
})

const activeTabName = computed(() => {
const item = tabs.find((tab) => tab.key === activeTab.value)
return item ? `${item.name}记录` : '全部记录'
})

const summaryStats = computed(() => {
return [
{ key: 'recharge', label: '累计充值', value: formatSigned(summary.value.recharge, true) },
{ key: 'consume', label: '累计消费', value: summary.value.consume ? `-${summary.value.consume}` : '0' },
{ key: 'reward', label: '奖励获得', value: formatSigned(summary.value.reward, true) }
]
})

onLoad((options = {}) => {
if (options.type && tabs.some((item) => item.key === options.type)) {
activeTab.value = options.type
}
refreshSilent()
})

onShow(() => {
if (firstShow) {
firstShow = false
return
}
refreshSilent()
})

onPullDownRefresh(() => {
refreshing.value = true
refreshSilent()
})

onReachBottom(() => loadMore())

async function fetchList(reset = false) {
if (loading.value) {
if (refreshing.value) uni.stopPullDownRefresh()
return
}
loading.value = true
if (reset) page.value = 1
try {
const res = await pointApi.getLogList({
type: activeTab.value,
page_no: page.value,
page_size: pageSize
})
const rows = res.list || []
const data = res.data || {}
list.value = reset ? rows : list.value.concat(rows)
total.value = Number(data.total || rows.length)
setBalance(data.balance || {})
summary.value = normalizeSummary(data.summary || {})
} catch (e) {
toast(e.msg || '账单加载失败')
} finally {
loading.value = false
refreshing.value = false
uni.stopPullDownRefresh()
}
}

function refreshSilent() {
fetchList(true)
}

function loadMore() {
if (loading.value || list.value.length >= total.value) return
page.value += 1
fetchList()
}

function changeTab(key) {
if (activeTab.value === key) return
activeTab.value = key
refreshSilent()
}

function normalizeSummary(data = {}) {
const counts = Object.assign({
all: 0,
recharge: 0,
consume: 0,
reward: 0,
other: 0
}, data.counts || {})
Object.keys(counts).forEach((key) => {
counts[key] = Number(counts[key] || 0)
})
return {
recharge: Number(data.recharge || 0),
consume: Number(data.consume || 0),
reward: Number(data.reward || 0),
refund: Number(data.refund || 0),
adjust: Number(data.adjust || 0),
counts
}
}

function setBalance(data = {}) {
balance.value = data
if (data.points !== undefined || data.point_balance !== undefined) {
userStore.balance = Number(data.points !== undefined ? data.points : data.point_balance) || 0
userStore.balanceDetail = data
}
}

function formatSigned(value, plus = false) {
const num = Number(value || 0)
if (num > 0 && plus) return `+${num}`
return String(num)
}

function formatAmount(value) {
const num = Number(value || 0)
return num > 0 ? `+${num}` : String(num)
}

function getTypeMark(type) {
return typeMark[type] || '记'
}

function goRecharge() {
uni.navigateTo({ url: '/pages/recharge/recharge' })
}
</script>

<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #f3f7fb;
}

.wrap {
padding: 30rpx 30rpx calc(44rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}

.wallet-card {
position: relative;
min-height: 308rpx;
padding: 34rpx 32rpx 26rpx;
border-radius: 30rpx;
color: #ffffff;
background: linear-gradient(135deg, #1167e8 0%, #0b8cff 58%, #41c7ff 100%);
box-shadow: 0 18rpx 40rpx rgba(11, 140, 255, 0.22);
overflow: hidden;
box-sizing: border-box;
}

.wallet-card::before {
content: '';
position: absolute;
right: -96rpx;
top: -130rpx;
width: 330rpx;
height: 330rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.16);
}

.wallet-card::after {
content: '';
position: absolute;
left: 42rpx;
right: 42rpx;
bottom: 118rpx;
height: 1rpx;
background: rgba(255, 255, 255, 0.2);
}

.wallet-copy,
.wallet-stats {
position: relative;
z-index: 1;
}

.wallet-kicker {
display: block;
color: rgba(255, 255, 255, 0.74);
font-size: 24rpx;
font-weight: 800;
line-height: 1;
}

.wallet-main {
margin-top: 20rpx;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24rpx;
}

.balance-line {
min-width: 0;
display: flex;
align-items: flex-end;
}

.balance-number {
color: #ffffff;
font-size: 70rpx;
font-weight: 900;
line-height: 0.95;
}

.balance-unit {
margin-left: 10rpx;
margin-bottom: 8rpx;
color: rgba(255, 255, 255, 0.78);
font-size: 26rpx;
font-weight: 800;
}

.recharge-action {
height: 68rpx;
min-width: 136rpx;
padding: 0 30rpx;
border-radius: 999rpx;
color: #0b74ea;
background: #ffffff;
font-size: 27rpx;
font-weight: 900;
line-height: 68rpx;
text-align: center;
box-sizing: border-box;
flex-shrink: 0;
}

.wallet-stats {
margin-top: 46rpx;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12rpx;
}

.stat-item {
min-width: 0;
}

.stat-value {
display: block;
color: #ffffff;
font-size: 28rpx;
font-weight: 900;
line-height: 1;
}

.stat-value.consume {
color: #ffe2c7;
}

.stat-label {
display: block;
margin-top: 10rpx;
color: rgba(255, 255, 255, 0.68);
font-size: 21rpx;
line-height: 1;
}

.filter-panel {
margin-top: 24rpx;
padding: 8rpx;
border-radius: 24rpx;
background: #ffffff;
box-shadow: 0 8rpx 28rpx rgba(34, 68, 112, 0.05);
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8rpx;
}

.filter-item {
height: 88rpx;
border-radius: 20rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #7f8896;
}

.filter-item.active {
color: #0b8cff;
background: #eef7ff;
}

.filter-name {
font-size: 27rpx;
font-weight: 900;
line-height: 1;
}

.filter-count {
margin-top: 8rpx;
font-size: 20rpx;
font-weight: 700;
line-height: 1;
opacity: 0.72;
}

.list-head {
margin-top: 30rpx;
margin-bottom: 16rpx;
display: flex;
align-items: center;
justify-content: space-between;
}

.list-title {
color: #172033;
font-size: 32rpx;
font-weight: 900;
}

.list-sub {
color: #8a95a6;
font-size: 24rpx;
}

.ledger-panel {
border-radius: 26rpx;
background: #ffffff;
box-shadow: 0 8rpx 28rpx rgba(34, 68, 112, 0.05);
overflow: hidden;
}

.ledger-row {
position: relative;
min-height: 156rpx;
padding: 26rpx 28rpx;
display: flex;
align-items: center;
gap: 22rpx;
box-sizing: border-box;
}

.ledger-row::after {
content: '';
position: absolute;
left: 112rpx;
right: 28rpx;
bottom: 0;
height: 1rpx;
background: #edf2f7;
}

.ledger-row:last-child::after {
display: none;
}

.type-mark {
width: 62rpx;
height: 62rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 29rpx;
font-weight: 900;
flex-shrink: 0;
}

.type-mark.recharge {
color: #0b8cff;
background: #eef7ff;
}

.type-mark.consume {
color: #ff681f;
background: #fff4ec;
}

.type-mark.reward {
color: #17a65a;
background: #edfdf4;
}

.type-mark.refund,
.type-mark.adjust {
color: #7a5af8;
background: #f3f0ff;
}

.ledger-main {
min-width: 0;
flex: 1;
}

.title-row {
display: flex;
align-items: center;
gap: 14rpx;
}

.ledger-title {
min-width: 0;
color: #172033;
font-size: 29rpx;
font-weight: 900;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.ledger-status {
height: 32rpx;
padding: 0 12rpx;
border-radius: 999rpx;
color: #17a65a;
background: #edfdf4;
font-size: 19rpx;
font-weight: 800;
line-height: 32rpx;
flex-shrink: 0;
}

.ledger-desc {
display: block;
margin-top: 10rpx;
color: #7f8896;
font-size: 24rpx;
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.ledger-meta {
margin-top: 14rpx;
display: flex;
align-items: center;
gap: 14rpx;
color: #9aa4b2;
font-size: 22rpx;
}

.ledger-type {
height: 34rpx;
padding: 0 14rpx;
border-radius: 999rpx;
font-size: 20rpx;
font-weight: 900;
line-height: 34rpx;
}

.ledger-type.recharge {
color: #0b8cff;
background: #eef7ff;
}

.ledger-type.consume {
color: #ff681f;
background: #fff4ec;
}

.ledger-type.reward {
color: #17a65a;
background: #edfdf4;
}

.ledger-type.refund,
.ledger-type.adjust {
color: #7a5af8;
background: #f3f0ff;
}

.ledger-amount {
color: #172033;
font-size: 34rpx;
font-weight: 900;
line-height: 1;
flex-shrink: 0;
}

.ledger-amount.plus {
color: #17a65a;
}

.ledger-amount.minus {
color: #ff681f;
}

.empty-state {
min-height: 430rpx;
margin-top: 0;
border-radius: 26rpx;
background: #ffffff;
box-shadow: 0 8rpx 28rpx rgba(34, 68, 112, 0.05);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}

.empty-icon {
width: 92rpx;
height: 92rpx;
border-radius: 28rpx;
color: #0b8cff;
background: #eef7ff;
font-size: 34rpx;
font-weight: 900;
line-height: 92rpx;
}

.empty-title {
margin-top: 24rpx;
color: #172033;
font-size: 30rpx;
font-weight: 900;
line-height: 1;
}

.empty-desc {
width: 500rpx;
max-width: 80%;
margin-top: 14rpx;
color: #8a95a6;
font-size: 24rpx;
line-height: 1.45;
}

.loading,
.end-text {
padding: 28rpx 0 12rpx;
color: #9aa4b2;
font-size: 24rpx;
line-height: 1;
text-align: center;
}
</style>

+ 474
- 0
pages/invite/invite.vue Прегледај датотеку

@@ -0,0 +1,474 @@
<template>
<view class="invite-page">
<view class="hero">
<view class="hero-copy">
<text class="eyebrow">INVITE FRIENDS</text>
<text class="hero-title">{{ config.share_title || '邀请好友一起用 AI ONLINE' }}</text>
<text class="hero-desc">{{ config.share_desc || '好友注册成功后,你和好友都能获得点数奖励。' }}</text>
</view>
<view class="reward-box">
<view class="reward-item">
<text class="reward-num">{{ config.inviter_points || 0 }}</text>
<text class="reward-label">你获得</text>
</view>
<view class="reward-divider"></view>
<view class="reward-item">
<text class="reward-num">{{ config.invitee_points || 0 }}</text>
<text class="reward-label">好友获得</text>
</view>
</view>
</view>

<view class="stats-row">
<view class="stat-card">
<text class="stat-num">{{ stats.success || 0 }}</text>
<text class="stat-label">成功邀请</text>
</view>
<view class="stat-card">
<text class="stat-num">{{ stats.inviter_points || 0 }}</text>
<text class="stat-label">累计奖励</text>
</view>
</view>

<view class="action-panel">
<button class="primary-action" open-type="share">
<up-icon class="action-icon" name="share" size="28rpx" color="#ffffff"></up-icon>
<text>分享小程序</text>
</button>
<view class="secondary-actions">
<view class="secondary-action" @click="openQr">
<up-icon class="action-icon" name="scan" size="28rpx" color="#0b8cff"></up-icon>
<text>二维码分享</text>
</view>
<view class="secondary-action" @click="goList">
<up-icon class="action-icon" name="order" size="28rpx" color="#0b8cff"></up-icon>
<text>邀请记录</text>
</view>
</view>
</view>

<view class="rule-card">
<view class="rule-title">活动规则</view>
<text class="rule-text">{{ config.rule_text || '好友通过你的邀请登录注册成功后,你和好友都可获得点数奖励。' }}</text>
<view class="rule-steps">
<view class="step">
<text class="step-no">01</text>
<text>分享给好友</text>
</view>
<view class="step">
<text class="step-no">02</text>
<text>好友登录注册</text>
</view>
<view class="step">
<text class="step-no">03</text>
<text>点数自动到账</text>
</view>
</view>
</view>

<view v-if="qrVisible" class="qr-mask" @click="closeQr">
<view class="qr-sheet" @click.stop>
<view class="sheet-head">
<text class="sheet-title">二维码分享</text>
<text class="sheet-close" @click="closeQr">×</text>
</view>
<view class="qr-card">
<image v-if="qrcodeUrl" class="qr-img" :src="qrcodeUrl" mode="aspectFit" />
<view v-else class="qr-loading">{{ qrLoading ? '生成中...' : '暂无二维码' }}</view>
<text class="qr-tip">好友微信扫码即可进入邀请页</text>
</view>
<view class="sheet-actions">
<view class="ghost-btn" @click="copyPath">复制路径</view>
<view class="save-btn" @click="saveQr">保存图片</view>
</view>
</view>
</view>
</view>
</template>

<script setup>
import { computed, ref } from 'vue'
import { onShareAppMessage, onShow } from '@dcloudio/uni-app'
import { inviteApi } from '@/api/index.js'
import { useUserStore } from '@/store/user.js'
import { useApp } from '@/utils/useApp.js'

const userStore = useUserStore()
const { toast } = useApp()
const config = ref({})
const stats = ref({})
const share = ref({})
const qrcodeUrl = ref('')
const qrVisible = ref(false)
const qrLoading = ref(false)

const sharePath = computed(() => (share.value && share.value.path) || `/pages/invite/landing?invite_code=${userStore.userInfo.open_id || ''}`)

onShow(() => {
userStore.restore()
if (!userStore.isLogin) {
uni.navigateTo({ url: '/pages/login/login' })
return
}
loadData()
})

onShareAppMessage(() => ({
title: (share.value && share.value.title) || config.value.share_title || '邀请你体验 AI ONLINE',
path: sharePath.value,
imageUrl: (share.value && share.value.image) || config.value.share_image || ''
}))

async function loadData() {
try {
const res = await inviteApi.getMy()
config.value = res.data.config || {}
stats.value = res.data.stats || {}
share.value = res.data.share || {}
} catch (e) {
toast(e.msg || '邀请信息加载失败')
}
}

async function openQr() {
qrVisible.value = true
if (qrcodeUrl.value || qrLoading.value) return
qrLoading.value = true
try {
const res = await inviteApi.getQrcode()
qrcodeUrl.value = res.data.qrcode_url || ''
} catch (e) {
toast(e.msg || '二维码生成失败')
} finally {
qrLoading.value = false
}
}

function closeQr() {
qrVisible.value = false
}

function copyPath() {
uni.setClipboardData({ data: sharePath.value })
}

function saveQr() {
if (!qrcodeUrl.value) return toast('二维码还没有生成')
uni.downloadFile({
url: qrcodeUrl.value,
success: (res) => {
if (res.statusCode !== 200) return toast('图片下载失败')
uni.saveImageToPhotosAlbum({
filePath: res.tempFilePath,
success: () => toast('已保存到相册'),
fail: () => toast('保存失败,请检查相册权限')
})
},
fail: () => toast('图片下载失败')
})
}

function goList() {
uni.navigateTo({ url: '/pages/invite/list' })
}
</script>

<style lang="scss" scoped>
.invite-page {
min-height: 100vh;
padding: 24rpx 24rpx calc(46rpx + env(safe-area-inset-bottom));
background: #f4f7fb;
box-sizing: border-box;
}

.hero {
border-radius: 30rpx;
padding: 34rpx 30rpx 30rpx;
color: #ffffff;
background: linear-gradient(135deg, #0b8cff 0%, #18c2ff 100%);
overflow: hidden;
}

.hero-copy {
display: flex;
flex-direction: column;
}

.eyebrow {
opacity: 0.72;
font-size: 22rpx;
font-weight: 800;
line-height: 1;
}

.hero-title {
margin-top: 18rpx;
font-size: 42rpx;
font-weight: 900;
line-height: 1.22;
}

.hero-desc {
width: 92%;
margin-top: 16rpx;
opacity: 0.86;
font-size: 26rpx;
line-height: 1.45;
}

.reward-box {
margin-top: 30rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.16);
display: flex;
align-items: center;
padding: 24rpx 0;
}

.reward-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}

.reward-num {
font-size: 50rpx;
font-weight: 900;
line-height: 1;
}

.reward-label {
margin-top: 10rpx;
font-size: 23rpx;
opacity: 0.82;
}

.reward-divider {
width: 1rpx;
height: 58rpx;
background: rgba(255, 255, 255, 0.35);
}

.stats-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18rpx;
margin-top: 20rpx;
}

.stat-card,
.action-panel,
.rule-card {
border-radius: 24rpx;
background: #ffffff;
box-shadow: 0 12rpx 30rpx rgba(30, 66, 120, 0.06);
}

.stat-card {
padding: 24rpx;
}

.stat-num {
color: #172033;
font-size: 38rpx;
font-weight: 900;
line-height: 1;
}

.stat-label {
display: block;
margin-top: 12rpx;
color: #7f8896;
font-size: 24rpx;
}

.action-panel {
margin-top: 20rpx;
padding: 24rpx;
}

button {
margin: 0;
padding: 0;
border: 0;
background: transparent;
line-height: normal;
}

button::after {
border: 0;
}

.primary-action {
height: 88rpx;
border-radius: 999rpx;
color: #ffffff;
background: #0b8cff;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
font-size: 29rpx;
font-weight: 900;
}

.secondary-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16rpx;
margin-top: 16rpx;
}

.secondary-action {
height: 78rpx;
border-radius: 999rpx;
color: #0b8cff;
background: #edf7ff;
display: flex;
align-items: center;
justify-content: center;
gap: 10rpx;
font-size: 26rpx;
font-weight: 800;
}

.action-icon {
flex-shrink: 0;
}

.rule-card {
margin-top: 20rpx;
padding: 28rpx;
}

.rule-title {
color: #172033;
font-size: 30rpx;
font-weight: 900;
}

.rule-text {
display: block;
margin-top: 14rpx;
color: #687386;
font-size: 25rpx;
line-height: 1.5;
}

.rule-steps {
margin-top: 22rpx;
display: flex;
flex-direction: column;
gap: 14rpx;
}

.step {
height: 58rpx;
border-radius: 16rpx;
background: #f5f9ff;
display: flex;
align-items: center;
padding: 0 18rpx;
color: #39445a;
font-size: 25rpx;
font-weight: 700;
}

.step-no {
margin-right: 16rpx;
color: #0b8cff;
font-size: 21rpx;
font-weight: 900;
}

.qr-mask {
position: fixed;
inset: 0;
z-index: 99;
background: rgba(9, 16, 28, 0.45);
display: flex;
align-items: flex-end;
}

.qr-sheet {
width: 100%;
padding: 28rpx 28rpx calc(30rpx + env(safe-area-inset-bottom));
border-radius: 34rpx 34rpx 0 0;
background: #ffffff;
box-sizing: border-box;
}

.sheet-head,
.sheet-actions {
display: flex;
align-items: center;
justify-content: space-between;
}

.sheet-title {
color: #172033;
font-size: 32rpx;
font-weight: 900;
}

.sheet-close {
width: 58rpx;
height: 58rpx;
border-radius: 50%;
color: #7f8896;
background: #f4f7fb;
font-size: 38rpx;
line-height: 54rpx;
text-align: center;
}

.qr-card {
margin: 26rpx auto 24rpx;
width: 430rpx;
min-height: 500rpx;
border-radius: 28rpx;
background: #f6f9fd;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}

.qr-img {
width: 360rpx;
height: 360rpx;
}

.qr-loading,
.qr-tip {
color: #7f8896;
font-size: 24rpx;
}

.qr-tip {
margin-top: 18rpx;
}

.ghost-btn,
.save-btn {
flex: 1;
height: 82rpx;
border-radius: 999rpx;
font-size: 27rpx;
font-weight: 900;
line-height: 82rpx;
text-align: center;
}

.ghost-btn {
margin-right: 18rpx;
color: #0b8cff;
background: #edf7ff;
}

.save-btn {
color: #ffffff;
background: #0b8cff;
}
</style>

+ 215
- 0
pages/invite/landing.vue Прегледај датотеку

@@ -0,0 +1,215 @@
<template>
<view class="landing-page">
<view class="invite-card">
<view class="avatar-wrap">
<image v-if="inviter.avatar" class="avatar" :src="inviter.avatar" mode="aspectFill" />
<text v-else class="avatar-text">AI</text>
</view>
<text class="from-text">{{ inviter.user_name || '你的好友' }} 邀请你</text>
<text class="title">{{ config.landing_title || '一起体验 AI ONLINE' }}</text>
<text class="desc">{{ config.landing_desc || '登录后领取点数奖励,AI取名、动漫头像、证件照等功能都能使用。' }}</text>

<view class="gift-row">
<view class="gift-item">
<text class="gift-num">{{ config.invitee_points || 0 }}</text>
<text class="gift-label">你可领取点数</text>
</view>
<view class="gift-item">
<text class="gift-num">{{ config.inviter_points || 0 }}</text>
<text class="gift-label">好友获得点数</text>
</view>
</view>

<view class="claim-btn" @click="claim">{{ userStore.isLogin ? '进入 AI ONLINE' : '登录并领取' }}</view>
<text class="rule-text">{{ config.rule_text || '好友通过你的邀请登录注册成功后,你和好友都可获得点数奖励。' }}</text>
</view>
</view>
</template>

<script setup>
import { ref } from 'vue'
import { onLoad, onShareAppMessage } from '@dcloudio/uni-app'
import { inviteApi } from '@/api/index.js'
import { useUserStore } from '@/store/user.js'
import { useApp } from '@/utils/useApp.js'

const userStore = useUserStore()
const { toast } = useApp()
const config = ref({})
const inviter = ref({})
const inviteCode = ref('')

onLoad((options = {}) => {
userStore.restore()
inviteCode.value = options.invite_code || options.invite_open_id || parseInviteScene(options.scene || '')
if (inviteCode.value) uni.setStorageSync('invite_code', inviteCode.value)
if (options.source) uni.setStorageSync('source', options.source)
loadConfig(options.scene || '')
})

onShareAppMessage(() => ({
title: config.value.share_title || '邀请你体验 AI ONLINE',
path: `/pages/invite/landing?invite_code=${inviteCode.value}`,
imageUrl: config.value.share_image || ''
}))

function parseInviteScene(scene) {
if (!scene) return ''
let text = ''
try {
text = decodeURIComponent(scene)
} catch (e) {
text = scene
}
const match = text.match(/(?:^|[&?])i=([^&]+)/) || text.match(/(?:^|[&?])i_([^&]+)/)
if (match) return match[1]
if (text.indexOf('i_') === 0) return text.slice(2)
return text
}

async function loadConfig(scene) {
try {
const res = await inviteApi.getConfig({
invite_code: inviteCode.value,
scene
})
config.value = res.data.config || {}
inviter.value = res.data.inviter || {}
if (res.data.invite_code) {
inviteCode.value = res.data.invite_code
uni.setStorageSync('invite_code', inviteCode.value)
}
} catch (e) {
toast(e.msg || '邀请信息加载失败')
}
}

function claim() {
if (userStore.isLogin) {
uni.switchTab({ url: '/pages/toolbox/toolbox' })
return
}
uni.navigateTo({ url: '/pages/login/login' })
}
</script>

<style lang="scss" scoped>
.landing-page {
min-height: 100vh;
padding: 44rpx 24rpx;
background: linear-gradient(180deg, #eef7ff 0%, #f7f9fc 42%, #ffffff 100%);
box-sizing: border-box;
}

.invite-card {
min-height: calc(100vh - 88rpx);
padding: 52rpx 34rpx 38rpx;
border-radius: 34rpx;
background: #ffffff;
box-shadow: 0 20rpx 50rpx rgba(30, 66, 120, 0.08);
display: flex;
flex-direction: column;
align-items: center;
box-sizing: border-box;
}

.avatar-wrap {
width: 124rpx;
height: 124rpx;
border-radius: 50%;
background: #eaf5ff;
box-shadow: 0 10rpx 24rpx rgba(11, 140, 255, 0.18);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}

.avatar {
width: 100%;
height: 100%;
}

.avatar-text {
color: #0b8cff;
font-size: 34rpx;
font-weight: 900;
}

.from-text {
margin-top: 24rpx;
color: #7f8896;
font-size: 25rpx;
line-height: 1;
}

.title {
margin-top: 28rpx;
color: #172033;
font-size: 46rpx;
font-weight: 900;
line-height: 1.2;
text-align: center;
}

.desc {
margin-top: 20rpx;
color: #687386;
font-size: 28rpx;
line-height: 1.55;
text-align: center;
}

.gift-row {
width: 100%;
margin-top: 42rpx;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18rpx;
}

.gift-item {
height: 176rpx;
border-radius: 26rpx;
background: #f3f9ff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}

.gift-num {
color: #0b8cff;
font-size: 54rpx;
font-weight: 900;
line-height: 1;
}

.gift-label {
margin-top: 14rpx;
color: #687386;
font-size: 23rpx;
}

.claim-btn {
width: 100%;
height: 92rpx;
margin-top: auto;
border-radius: 999rpx;
color: #ffffff;
background: #0b8cff;
font-size: 30rpx;
font-weight: 900;
line-height: 92rpx;
text-align: center;
box-shadow: 0 14rpx 28rpx rgba(11, 140, 255, 0.22);
}

.rule-text {
margin-top: 22rpx;
color: #9aa4b2;
font-size: 23rpx;
line-height: 1.45;
text-align: center;
}
</style>

+ 465
- 0
pages/invite/list.vue Прегледај датотеку

@@ -0,0 +1,465 @@
<template>
<view class="invite-list-page">
<view class="summary-card">
<view>
<text class="eyebrow">INVITE RECORDS</text>
<text class="summary-title">我的邀请</text>
<text class="summary-desc">好友完成注册后,奖励点数会自动入账。</text>
</view>
<view class="summary-count">
<text class="count-num">{{ total || list.length }}</text>
<text class="count-label">记录</text>
</view>
</view>

<view class="stats-row">
<view class="stat-card">
<text class="stat-num">{{ successCount }}</text>
<text class="stat-label">已奖励</text>
</view>
<view class="stat-card">
<text class="stat-num">{{ rewardPoints }}</text>
<text class="stat-label">累计点数</text>
</view>
<view class="stat-card">
<text class="stat-num">{{ pendingCount }}</text>
<text class="stat-label">待处理</text>
</view>
</view>

<view v-if="list.length" class="record-list">
<view v-for="item in list" :key="item.id" class="record-card">
<view class="avatar-wrap">
<image v-if="item.avatar" class="avatar" :src="item.avatar" mode="aspectFill" />
<text v-else class="avatar-text">{{ getInitial(item.user_name) }}</text>
</view>
<view class="record-main">
<view class="record-head">
<text class="name">{{ item.user_name || '好友' }}</text>
<text class="status" :class="statusClass(item.status)">{{ item.status_text || statusText(item.status) }}</text>
</view>
<view class="reward-line">
<text v-if="Number(item.inviter_points) > 0" class="reward-chip">+{{ item.inviter_points }} 点</text>
<text class="reward-desc">{{ getRecordDesc(item) }}</text>
</view>
<text class="time">{{ formatTime(item.reward_time || item.add_time) }}</text>
</view>
</view>
</view>

<view v-if="!list.length && !loading" class="empty-state">
<view class="empty-mark">邀</view>
<text class="empty-title">还没有邀请记录</text>
<text class="empty-desc">分享给好友,好友注册成功后这里会展示奖励明细。</text>
<view class="empty-action" @click="goInvite">去邀请好友</view>
</view>

<view v-if="loading" class="loading">{{ list.length ? '加载更多...' : '加载中...' }}</view>
<view v-if="list.length && list.length >= total && !loading" class="end-text">没有更多记录了</view>
</view>
</template>

<script setup>
import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import dayjs from 'dayjs'
import { inviteApi } from '@/api/index.js'
import { useUserStore } from '@/store/user.js'
import { useApp } from '@/utils/useApp.js'

const userStore = useUserStore()
const { toast } = useApp()
const list = ref([])
const stats = ref({})
const page = ref(1)
const pageSize = 10
const total = ref(0)
const loading = ref(false)

const successCount = computed(() => Number(stats.value.success !== undefined ? stats.value.success : list.value.filter((item) => Number(item.status) === 1).length))
const pendingCount = computed(() => list.value.filter((item) => Number(item.status) === 0).length)
const rewardPoints = computed(() => {
if (stats.value.inviter_points !== undefined) return Number(stats.value.inviter_points || 0)
return list.value.reduce((sum, item) => {
if (Number(item.status) !== 1) return sum
return sum + Number(item.inviter_points || 0)
}, 0)
})

onLoad(() => {
userStore.restore()
if (!userStore.isLogin) {
uni.navigateTo({ url: '/pages/login/login' })
return
}
refreshSilent()
})

onPullDownRefresh(() => refreshSilent())
onReachBottom(() => loadMore())

async function fetchList(reset = false) {
if (loading.value) {
uni.stopPullDownRefresh()
return
}
loading.value = true
if (reset) page.value = 1
try {
const res = await inviteApi.getList({
page_no: page.value,
page_size: pageSize
})
const rows = res.list || []
const data = res.data || {}
list.value = reset ? rows : list.value.concat(rows)
total.value = Number(data.total || rows.length)
stats.value = data.stats || {}
} catch (e) {
toast(e.msg || '邀请记录加载失败')
} finally {
loading.value = false
uni.stopPullDownRefresh()
}
}

function refreshSilent() {
fetchList(true)
}

function loadMore() {
if (loading.value || list.value.length >= total.value) return
page.value += 1
fetchList()
}

function formatTime(value) {
if (!value) return ''
return dayjs(Number(value) * 1000).format('YYYY-MM-DD HH:mm')
}

function getInitial(name = '') {
return String(name || '友').slice(0, 1)
}

function statusClass(status) {
status = Number(status)
if (status === 1) return 'success'
if (status === 0) return 'pending'
return 'muted'
}

function statusText(status) {
status = Number(status)
if (status === 1) return '已奖励'
if (status === 2) return '已记录'
if (status < 0) return '失败'
return '待发放'
}

function getRecordDesc(item) {
const status = Number(item.status)
if (status === 1) return '好友注册成功,奖励已到账'
if (status === 2) return '已记录,本次未触发奖励'
if (status < 0) return item.remark || '奖励发放失败'
return '等待奖励发放'
}

function goInvite() {
const pages = getCurrentPages()
const index = pages.findIndex((page) => page.route === 'pages/invite/invite')
if (index >= 0 && pages.length - 1 - index > 0) {
uni.navigateBack({ delta: pages.length - 1 - index })
return
}
uni.navigateTo({ url: '/pages/invite/invite' })
}
</script>

<style lang="scss" scoped>
.invite-list-page {
min-height: 100vh;
padding: 24rpx 24rpx calc(52rpx + env(safe-area-inset-bottom));
background: #f4f8fc;
box-sizing: border-box;
}

.summary-card {
padding: 28rpx 28rpx;
border-radius: 28rpx;
color: #ffffff;
background: linear-gradient(135deg, #0ea5e9 0%, #1f8cff 100%);
display: flex;
align-items: center;
justify-content: space-between;
gap: 24rpx;
box-shadow: 0 16rpx 34rpx rgba(14, 165, 233, 0.18);
}

.eyebrow,
.summary-title,
.summary-desc,
.count-num,
.count-label,
.stat-num,
.stat-label,
.name,
.reward-desc,
.time,
.empty-title,
.empty-desc {
display: block;
}

.eyebrow {
opacity: 0.72;
font-size: 20rpx;
font-weight: 900;
line-height: 1;
}

.summary-title {
margin-top: 14rpx;
font-size: 40rpx;
font-weight: 900;
line-height: 1.18;
}

.summary-desc {
margin-top: 12rpx;
opacity: 0.86;
font-size: 24rpx;
line-height: 1.45;
}

.summary-count {
width: 126rpx;
height: 126rpx;
border-radius: 28rpx;
background: rgba(255, 255, 255, 0.16);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-shrink: 0;
}

.count-num {
font-size: 42rpx;
font-weight: 900;
line-height: 1;
}

.count-label {
margin-top: 10rpx;
opacity: 0.82;
font-size: 22rpx;
}

.stats-row {
margin-top: 18rpx;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14rpx;
}

.stat-card {
padding: 20rpx 8rpx;
border-radius: 22rpx;
background: #ffffff;
text-align: center;
box-shadow: 0 8rpx 22rpx rgba(31, 52, 86, 0.05);
}

.stat-num {
color: #172033;
font-size: 34rpx;
font-weight: 900;
line-height: 1;
}

.stat-label {
margin-top: 10rpx;
color: #7c8a9b;
font-size: 22rpx;
}

.record-list {
margin-top: 22rpx;
}

.record-card {
margin-bottom: 18rpx;
padding: 24rpx;
border-radius: 24rpx;
background: #ffffff;
display: flex;
gap: 20rpx;
box-shadow: 0 8rpx 22rpx rgba(31, 52, 86, 0.045);
}

.avatar-wrap {
width: 86rpx;
height: 86rpx;
border-radius: 50%;
background: #ecf7ff;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
}

.avatar {
width: 100%;
height: 100%;
}

.avatar-text {
color: #0b8cff;
font-size: 30rpx;
font-weight: 900;
}

.record-main {
flex: 1;
min-width: 0;
}

.record-head {
display: flex;
align-items: center;
gap: 16rpx;
}

.name {
flex: 1;
min-width: 0;
color: #172033;
font-size: 30rpx;
font-weight: 900;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.status {
height: 40rpx;
padding: 0 16rpx;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 800;
line-height: 40rpx;
flex-shrink: 0;

&.success {
color: #13a86b;
background: #e9fff4;
}

&.pending {
color: #f97316;
background: #fff4e8;
}

&.muted {
color: #7f8896;
background: #f2f5f9;
}
}

.reward-line {
margin-top: 14rpx;
display: flex;
align-items: center;
gap: 12rpx;
}

.reward-chip {
height: 38rpx;
padding: 0 14rpx;
border-radius: 999rpx;
color: #f97316;
background: #fff3e8;
font-size: 22rpx;
font-weight: 900;
line-height: 38rpx;
flex-shrink: 0;
}

.reward-desc {
min-width: 0;
color: #64748b;
font-size: 24rpx;
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.time {
margin-top: 12rpx;
color: #9aa4b2;
font-size: 22rpx;
}

.empty-state {
margin-top: 72rpx;
padding: 48rpx 34rpx;
border-radius: 28rpx;
background: #ffffff;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
box-shadow: 0 8rpx 22rpx rgba(31, 52, 86, 0.045);
}

.empty-mark {
width: 96rpx;
height: 96rpx;
border-radius: 30rpx;
color: #0b8cff;
background: #eef8ff;
font-size: 40rpx;
font-weight: 900;
line-height: 96rpx;
}

.empty-title {
margin-top: 26rpx;
color: #172033;
font-size: 32rpx;
font-weight: 900;
line-height: 1;
}

.empty-desc {
margin-top: 16rpx;
color: #7f8896;
font-size: 25rpx;
line-height: 1.45;
}

.empty-action {
width: 230rpx;
height: 72rpx;
margin-top: 28rpx;
border-radius: 999rpx;
color: #ffffff;
background: #0b8cff;
font-size: 26rpx;
font-weight: 900;
line-height: 72rpx;
box-shadow: 0 12rpx 24rpx rgba(11, 140, 255, 0.2);
}

.loading,
.end-text {
padding: 26rpx 0 0;
color: #9aa4b2;
font-size: 24rpx;
text-align: center;
}
</style>

+ 417
- 0
pages/login/login.vue Прегледај датотеку

@@ -0,0 +1,417 @@
<template>
<view class="login">
<view class="logo-section">
<image
class="logo-img"
src="https://cdn.aionline.cc/logo/logo_aionline.svg"
mode="widthFix"
></image>
<text class="login-tip">登录后同步作品、点数和创作记录</text>
</view>

<view class="login-actions">
<button class="login-btn wechat" hover-class="login-btn-hover" :loading="postLoading" @click="onLoginClick">
<up-icon class="btn-icon" name="weixin-fill" size="24" color="#ffffff"></up-icon>
<text>微信登录</text>
</button>

<!-- #ifdef MP-WEIXIN -->
<button
class="login-btn phone"
hover-class="login-btn-hover"
:open-type="agree ? 'getPhoneNumber' : ''"
@getphonenumber="getPhoneNumber"
@click="onPhoneLoginClick"
>
<up-icon class="btn-icon" name="phone-fill" size="24" color="#46bb36"></up-icon>
<text>手机号登录</text>
</button>
<!-- #endif -->

<!-- #ifndef MP-WEIXIN -->
<button class="login-btn phone" hover-class="login-btn-hover" @click="onPhoneLoginClick">
<up-icon class="btn-icon" name="phone-fill" size="24" color="#46bb36"></up-icon>
<text>手机号登录</text>
</button>
<!-- #endif -->

<!-- #ifdef APP-PLUS -->
<view v-if="isIOS" class="more-actions">
<button class="mini-login-btn apple" hover-class="mini-login-btn-hover" :loading="postLoading" @click="onAppleLoginClick">
<up-icon name="apple-fill" size="20" color="#172033"></up-icon>
<text>Apple 登录</text>
</button>
<button class="mini-login-btn visitor" hover-class="mini-login-btn-hover" :loading="postLoading" @click="onVisitorLoginClick">
<up-icon name="account-fill" size="20" color="#172033"></up-icon>
<text>游客登录</text>
</button>
</view>
<!-- #endif -->
</view>

<view class="agreement" @click="agree = !agree">
<view class="checkbox" :class="{ checked: agree }">
<up-icon v-if="agree" name="checkbox-mark" size="14" color="#ffffff"></up-icon>
</view>
<view class="agreement-text">
<text>请阅读并同意</text>
<text class="link" @click.stop="onAgreementClick('service')">《服务条款》</text>
<text class="link" @click.stop="onAgreementClick('privacy')">《隐私政策》</text>
</view>
</view>
</view>
</template>

<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { userApi } from '@/api/index.js'
import { useUserStore } from '@/store/user.js'
import { useApp } from '@/utils/useApp.js'
import { isInWechat } from '@/utils/util.js'
import CONFIG from '@/utils/config.js'

const userStore = useUserStore()
const { toast, showLoading, hideLoading } = useApp()

const postLoading = ref(false)
const agree = ref(false)
const type = ref('1')
const source = ref('')

// 平台标识
let isIOS = false
// #ifdef APP-PLUS
isIOS = uni.getSystemInfoSync().platform === 'ios'
// #endif

onLoad((options = {}) => {
type.value = options.type || '1'
source.value = options.source || ''
const redirect = options.redirect || options.url || ''

// #ifdef H5
if (redirect) uni.setStorageSync('login_redirect', decodeURIComponent(redirect))
if (options.code) postLogin(options.code) // 公众号授权回跳
// #endif

// #ifdef MP-WEIXIN
if (options.source === 'scheme') {
// 深度链接进入,自动登录
if (userStore.isLogin) {
uni.switchTab({ url: '/pages/toolbox/toolbox' })
} else {
agree.value = true
onLoginClick()
}
}
// #endif
})

function checkAgree() {
if (!agree.value) {
toast('请阅读并同意服务政策')
return false
}
return true
}

function onAgreementClick(key) {
uni.navigateTo({ url: `/pages/agreement/agreement?key=${key}` })
}

/* ============ 微信登录 ============ */
function onLoginClick() {
// #ifndef H5
if (!checkAgree()) return
// #endif

// #ifdef APP-PLUS
wechatLogin()
// #endif

// #ifdef MP-WEIXIN
uni.login({
success: (res) => postLogin(res.code)
})
// #endif

// #ifdef H5
if (isInWechat()) {
location.replace(getH5AuthUrl())
} else {
getMiniScheme()
}
// #endif
}

function wechatLogin() {
uni.login({
provider: 'weixin',
onlyAuthorize: true,
success: (event) => postLogin(event.code)
})
}

function getH5AuthUrl() {
const mUrl = CONFIG.M_URL
const appid = CONFIG.WX_MP_APPID
return `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${appid}&redirect_uri=${mUrl}/pages/login/login&response_type=code&scope=snsapi_userinfo&state=23_4_${appid}#wechat_redirect`
}

async function getMiniScheme() {
try {
showLoading('加载中...')
const res = await userApi.getMiniScheme({})
hideLoading()
location.href = res.data.openlink
} catch (e) {
hideLoading()
toast(e.msg || '登录失败,请重试')
}
}

/* ============ 手机号登录 ============ */
function onPhoneLoginClick() {
if (!checkAgree()) return
// #ifndef MP-WEIXIN
// 非小程序端跳验证码登录页
uni.navigateTo({ url: '/pages/login/phone' })
// #endif
// 小程序端由 button open-type=getPhoneNumber 触发 getPhoneNumber
}

function getPhoneNumber(event) {
if (event.detail.errMsg !== 'getPhoneNumber:ok') {
toast('已取消')
return
}
loginByPhoneCode(event.detail.code)
}

async function loginByPhoneCode(code) {
try {
postLoading.value = true
const res = await userApi.loginByPhoneMp({ code })
afterLogin(res)
} catch (e) {
toast(e.msg || '登录失败,请重试!')
} finally {
postLoading.value = false
}
}

/* ============ Apple / 游客登录 ============ */
function onAppleLoginClick() {
if (!checkAgree()) return
uni.login({
provider: 'apple',
success: (res) => {
const open_id = res.appleInfo.user
let user_name = ''
if (res.appleInfo.fullName) {
user_name = `${res.appleInfo.fullName.familyName || ''}${res.appleInfo.fullName.givenName || ''}`
}
signWithApple({ open_id, user_name })
}
})
}

function onVisitorLoginClick() {
if (!checkAgree()) return
const open_id = uni.getSystemInfoSync().deviceId
signWithApple({ open_id, user_name: '', is_visitor: 1 })
}

async function signWithApple(params) {
try {
postLoading.value = true
const res = await userApi.signWithApple(params)
afterLogin(res)
} catch (e) {
toast(e.msg || '登录失败,请重试!')
} finally {
postLoading.value = false
}
}

/* ============ 通用登录处理 ============ */
async function postLogin(code) {
try {
postLoading.value = true
const res = await userApi.loginByCode({ code })
afterLogin(res)
} catch (e) {
toast(e.msg || '登录失败,请重试!')
} finally {
postLoading.value = false
}
}

function afterLogin(res) {
const inviteCode = uni.getStorageSync('invite_code')
userStore.setLogin({ token: res.data.token, userInfo: res.data })

if (res.data.is_register) {
toast(inviteCode ? '登录并领取成功!' : '登录成功!')
} else {
toast('登录成功!')
}

if (inviteCode) uni.removeStorageSync('invite_code')

// 统一跳回首页
setTimeout(() => {
uni.switchTab({ url: '/pages/toolbox/toolbox' })
}, 1200)
}
</script>

<style lang="scss" scoped>
.login {
min-height: 100vh;
padding: 0 48rpx calc(46rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
background: #ffffff;
text-align: center;
}

.logo-section {
padding-top: 12vh;
display: flex;
flex-direction: column;
align-items: center;
}

.logo-img {
width: 300rpx;
height: auto;
display: block;
}

.login-tip {
margin-top: 24rpx;
color: #7f8896;
font-size: 26rpx;
line-height: 1.4;
}

.login-actions {
margin-top: 92rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 30rpx;
}

.login-btn {
width: 100%;
max-width: 560rpx;
height: 92rpx;
margin: 0;
padding: 0 28rpx;
border-radius: 48rpx;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
font-size: 30rpx;
font-weight: 600;
line-height: 1;

&::after {
border: none;
}

.btn-icon {
margin-right: 14rpx;
}

&.wechat {
color: #fff;
background: #46bb36;
box-shadow: 0 14rpx 28rpx rgba(70, 187, 54, 0.18);
}

&.phone {
color: #46bb36;
background: #ffffff;
border: 2rpx solid rgba(70, 187, 54, 0.55);
}
}

.login-btn-hover,
.mini-login-btn-hover {
opacity: 0.86;
}

.more-actions {
width: 100%;
max-width: 560rpx;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16rpx;
}

.mini-login-btn {
height: 82rpx;
margin: 0;
padding: 0;
border-radius: 42rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 10rpx;
color: #172033;
font-size: 26rpx;
font-weight: 700;
line-height: 1;
background: #ffffff;
border: 1rpx solid rgba(89, 105, 128, 0.12);

&::after {
border: none;
}
}

.agreement {
margin-top: 60rpx;
padding: 0 6rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
line-height: 1.45;
color: #7f8896;

.checkbox {
width: 32rpx;
height: 32rpx;
margin-right: 12rpx;
border-radius: 50%;
border: 2rpx solid #b8c2d1;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;

&.checked {
background: #46bb36;
border-color: #46bb36;
}
}

.agreement-text {
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
}

.link {
color: #0b8cff;
font-weight: 600;
}
}
</style>

+ 155
- 0
pages/login/phone.vue Прегледај датотеку

@@ -0,0 +1,155 @@
<template>
<view class="phone-login">
<view class="title">手机号登录</view>

<view class="field">
<input v-model="phone" class="input" type="number" placeholder="请输入手机号" maxlength="11" />
</view>
<view class="field code">
<input v-model="code" class="input" type="number" placeholder="请输入验证码" maxlength="6" />
<view class="code-btn" :class="{ disabled: counting }" @click="sendCode">
{{ counting ? `${count}s` : '获取验证码' }}
</view>
</view>

<button class="btn-primary submit" :loading="postLoading" @click="onLogin">登录</button>

<view class="agreement" @click="agree = !agree">
<view class="checkbox" :class="{ checked: agree }">{{ agree ? '✓' : '' }}</view>
<text>登录即代表同意《服务条款》与《隐私政策》</text>
</view>
</view>
</template>

<script setup>
import { ref } from 'vue'
import { userApi } from '@/api/index.js'
import { useUserStore } from '@/store/user.js'
import { useApp } from '@/utils/useApp.js'

const userStore = useUserStore()
const { toast } = useApp()

const phone = ref('')
const code = ref('')
const agree = ref(false)
const postLoading = ref(false)
const counting = ref(false)
const count = ref(60)

function sendCode() {
if (counting.value) return
if (!/^1\d{10}$/.test(phone.value)) return toast('请输入正确的手机号')
userApi
.sendCode({ phone: phone.value })
.then(() => {
toast('验证码已发送')
counting.value = true
count.value = 60
const timer = setInterval(() => {
count.value--
if (count.value <= 0) {
clearInterval(timer)
counting.value = false
}
}, 1000)
})
.catch((e) => toast(e.msg || '发送失败'))
}

async function onLogin() {
if (!/^1\d{10}$/.test(phone.value)) return toast('请输入正确的手机号')
if (!code.value) return toast('请输入验证码')
if (!agree.value) return toast('请阅读并同意服务政策')
try {
postLoading.value = true
const res = await userApi.loginPhone({ phone: phone.value, code: code.value })
userStore.setLogin({ token: res.data.token, userInfo: res.data })
toast('登录成功!')

// #ifdef H5
const redirect = uni.getStorageSync('login_redirect') || ''
if (redirect) {
uni.removeStorageSync('login_redirect')
setTimeout(() => uni.reLaunch({ url: redirect }), 800)
return
}
// #endif

setTimeout(() => uni.switchTab({ url: '/pages/toolbox/toolbox' }), 800)
} catch (e) {
toast(e.msg || '登录失败,请重试!')
} finally {
postLoading.value = false
}
}
</script>

<style lang="scss" scoped>
.phone-login {
padding: 60rpx;
}
.title {
font-size: 48rpx;
font-weight: 800;
margin-bottom: 60rpx;
}
.field {
margin-bottom: 28rpx;
&.code {
display: flex;
align-items: center;
gap: 20rpx;
}
.input {
flex: 1;
height: 92rpx;
padding: 0 28rpx;
border-radius: 16rpx;
background: $ai-panel;
font-size: 30rpx;
box-shadow: $ai-shadow;
}
.code-btn {
flex-shrink: 0;
height: 92rpx;
line-height: 92rpx;
padding: 0 28rpx;
border-radius: 16rpx;
font-size: 26rpx;
color: $ai-accent;
background: rgba(11, 140, 255, 0.1);
&.disabled {
color: $ai-muted;
}
}
}
.submit {
margin-top: 20rpx;
}
.agreement {
margin-top: 40rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
color: $ai-muted;

.checkbox {
width: 34rpx;
height: 34rpx;
margin-right: 12rpx;
border-radius: 50%;
border: 2rpx solid $ai-muted;
color: #fff;
font-size: 22rpx;
display: flex;
align-items: center;
justify-content: center;
&.checked {
background: $ai-accent;
border-color: $ai-accent;
}
}
}
</style>

+ 337
- 0
pages/profile/profile.vue Прегледај датотеку

@@ -0,0 +1,337 @@
<template>
<view class="profile">
<ai-nav-title title="AI在线-解放你的生产力" />

<scroll-view class="page-scroll" scroll-y>
<view class="profile-content">
<view class="balance-card">
<image class="balance-texture" src="/static/profile/balance_texture.png" mode="aspectFit" />
<view class="balance-copy">
<text class="hello">{{ userStore.isLogin ? `Hi,${userStore.nickname}!` : 'Hi,手机微信昵称!' }}</text>
<text class="balance-label">点数余额</text>
<view class="balance-bottom">
<text class="balance-value">{{ userStore.isLogin ? userStore.pointsText : '123456' }}</text>
<view class="balance-actions">
<view class="outline-btn" @click="go('bill')">账单</view>
<view class="solid-btn" @click="go('recharge')">充值</view>
</view>
</view>
</view>
</view>

<view class="menu-panel">
<view v-for="item in menus" :key="item.key" class="menu-row" @click="onMenu(item)">
<image class="menu-icon" :src="item.icon" mode="aspectFit" />
<text class="menu-name">{{ item.name }}</text>
<text v-if="item.hint" class="menu-hint">{{ item.hint }}</text>
<image class="arrow" src="/static/profile/icon_arrow.png" mode="aspectFit" />
</view>
</view>

<view class="logout" @click="logout">退出登录</view>
</view>
</scroll-view>
</view>
</template>

<script setup>
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { inviteApi } from '@/api/index.js'
import { useUserStore } from '@/store/user.js'
import { useApp } from '@/utils/useApp.js'

const userStore = useUserStore()
const { toast } = useApp()

const inviteMenuConfig = {
key: 'invite',
name: '邀请好友',
hint: '得100点分享奖励',
icon: '/static/profile/icon_invite.png',
auth: true
}

const menus = ref([
{ key: 'account', name: '我的账号', hint: '提示文字#999', icon: '/static/profile/icon_account.png', auth: true },
{ ...inviteMenuConfig },
{ key: 'news', name: '前沿资讯', hint: '提示文字#999', icon: '/static/profile/icon_news.png', soon: true },
{ key: 'tutorial', name: '使用教程', icon: '/static/profile/icon_tutorial.png', soon: true },
{ key: 'official', name: '关注公众号', icon: '/static/profile/icon_official.png', soon: true },
{ key: 'contact', name: '联系我们', icon: '/static/profile/icon_contact.png' },
{ key: 'feedback', name: '意见反馈', hint: '得10点奖励', icon: '/static/profile/icon_feedback.png', soon: true },
{ key: 'terms', name: '服务条款', icon: '/static/profile/icon_terms.png' },
{ key: 'version', name: '当前版本', icon: '/static/profile/icon_version.png', soon: true }
])

const MENU_ROUTE = {
invite: '/pages/invite/invite',
contact: '/pages/agreement/agreement?key=contact',
terms: '/pages/agreement/agreement?key=terms'
}

function requireLogin() {
if (userStore.isLogin) return true
uni.navigateTo({ url: '/pages/login/login' })
return false
}

function onMenu(item) {
if (item.auth && !requireLogin()) return
if (item.soon) return toast(`${item.name} 即将上线`)
const route = MENU_ROUTE[item.key]
if (route) return uni.navigateTo({ url: route })
toast(`${item.name}(待开发)`)
}

function go(page) {
if (!requireLogin()) return
if (page === 'bill') return uni.navigateTo({ url: '/pages/bill/bill?type=recharge' })
if (page === 'recharge') return uni.navigateTo({ url: '/pages/recharge/recharge' })
toast(`${page}(待开发)`)
}

function syncInviteMenu(config = {}) {
const currentIndex = menus.value.findIndex((item) => item.key === 'invite')
if (Number(config.status) !== 1) {
if (currentIndex > -1) menus.value.splice(currentIndex, 1)
return
}

const points = Number(config.inviter_points || 0)
const item = {
...inviteMenuConfig,
hint: points > 0 ? `得${points}点分享奖励` : '分享给好友'
}
if (currentIndex > -1) {
menus.value.splice(currentIndex, 1, item)
return
}

const accountIndex = menus.value.findIndex((menu) => menu.key === 'account')
menus.value.splice(accountIndex > -1 ? accountIndex + 1 : 0, 0, item)
}

async function loadProfile() {
if (!userStore.isLogin) return
try {
await Promise.all([userStore.fetchUserInfo(), userStore.fetchBalance()])
} catch (e) {
// 登录失效由 request 统一处理
}
}

async function loadInviteConfig() {
try {
const res = await inviteApi.getConfig()
const config = (res.data && res.data.config) || {}
syncInviteMenu(config)
} catch (e) {}
}

function logout() {
if (!userStore.isLogin) return requireLogin()
uni.showModal({
title: '提示',
content: '确定退出登录?',
success: (r) => {
if (r.confirm) {
userStore.clear()
toast('已退出登录')
}
}
})
}

onShow(() => {
loadInviteConfig()
loadProfile()
})
</script>

<style lang="scss" scoped>
.profile {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
background: #f3f3f3;
}

.page-scroll {
flex: 1;
height: 0;
}

.profile-content {
min-height: 100vh;
padding: 30rpx 30rpx 120rpx;
box-sizing: border-box;
background: #f3f3f3;
}

.balance-card {
position: relative;
height: 266rpx;
border-radius: 24rpx;
overflow: hidden;
background: linear-gradient(135deg, #3f94ff 0%, #2384f8 100%);
box-sizing: border-box;

}

.balance-texture {
position: absolute;
right: 13rpx;
top: -44rpx;
width: 269rpx;
height: 288rpx;
}

.balance-copy {
position: relative;
z-index: 1;
height: 100%;
padding: 44rpx 62rpx;
display: flex;
flex-direction: column;
box-sizing: border-box;
}

.hello {
max-width: 430rpx;
color: #ffffff;
font-size: 27rpx;
line-height: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.balance-label {
margin-top: auto;
color: rgba(255, 255, 255, 0.65);
font-size: 25rpx;
line-height: 1;
}

.balance-value {
color: #ffffff;
font-size: 40rpx;
font-weight: 700;
line-height: 1;
}

.balance-bottom {
margin-top: 16rpx;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24rpx;
}

.balance-actions {
display: flex;
gap: 22rpx;
flex-shrink: 0;
}

.outline-btn,
.solid-btn {
width: 127rpx;
height: 58rpx;
border-radius: 29rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 27rpx;
box-sizing: border-box;
}

.outline-btn {
color: #ffffff;
border: 1rpx solid rgba(255, 255, 255, 0.75);
background: rgba(255, 255, 255, 0.08);
}

.solid-btn {
color: #ffffff;
border: 1rpx solid rgba(255, 255, 255, 0.75);
background: rgba(255, 255, 255, 0.08);
}

.menu-panel {
margin-top: 28rpx;
padding-top: 18rpx;
padding-bottom: 14rpx;
border-radius: 21rpx;
background: #ffffff;
overflow: hidden;
box-sizing: border-box;
}

.menu-row {
position: relative;
height: 100rpx;
padding: 0 27rpx 0 27rpx;
display: flex;
align-items: center;
box-sizing: border-box;

&::after {
content: '';
position: absolute;
left: 25rpx;
right: 24rpx;
bottom: 0;
height: 1rpx;
background: #eeeeee;
}

&:last-child::after {
display: none;
}
}

.menu-icon {
width: 34rpx;
height: 34rpx;
flex-shrink: 0;
}

.menu-name {
margin-left: 25rpx;
color: #333333;
font-size: 28rpx;
line-height: 1;
flex: 1;
min-width: 0;
}

.menu-hint {
margin-left: 20rpx;
max-width: 300rpx;
color: #999999;
font-size: 26rpx;
line-height: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.arrow {
width: 14rpx;
height: 22rpx;
margin-left: 20rpx;
flex-shrink: 0;
}

.logout {
height: 104rpx;
color: #333333;
font-size: 27rpx;
display: flex;
align-items: center;
justify-content: center;
}
</style>

+ 411
- 0
pages/recharge/recharge.vue Прегледај датотеку

@@ -0,0 +1,411 @@
<template>
<view class="recharge">
<!-- 余额卡 -->
<view class="head">
<view class="head-left">
<text class="kicker">AI在线点数包</text>
<text class="title">剩余 {{ balance }} 点</text>
<text class="user">用户:{{ userStore.nickname }}</text>
</view>
<view class="avatar">
<image v-if="userStore.avatar" class="avatar-img" :src="userStore.avatar" mode="aspectFill" />
<text v-else>AI</text>
</view>
</view>

<!-- 套餐 -->
<view class="section-title">选择充值套餐</view>
<view class="grid">
<view
v-for="item in packages"
:key="item.id"
class="product"
:class="{ active: current && current.id === item.id }"
@click="current = item"
>
<text v-if="item.badge" class="badge">{{ item.badge }}</text>
<text class="points">{{ item.points }}点</text>
<text class="price">¥{{ item.price_yuan }}</text>
</view>
</view>

<view class="buy-btn" :class="{ disabled: !current || paying }" @click="handleBuy">
{{ buyText }}
</view>

<!-- 权益 -->
<view class="perks">
<view class="perk">
<text class="perk-title">点数长期有效</text>
<text class="perk-desc">购买后可用于所有 AI 图像工具</text>
</view>
<view class="perk">
<text class="perk-title">多端通用</text>
<text class="perk-desc">小程序、H5、PC 端共享点数</text>
</view>
</view>

<!-- 购买须知 -->
<view class="notice">
<text class="notice-title">购买须知:</text>
<text class="notice-line">1、购买后长期有效,不支持退款;</text>
<text class="notice-line">2、同一账号点数可在小程序、H5、PC端使用;</text>
<text class="notice-line">3、点数适用于 AI 图片生成、高清修复、证件照、AI绘画等功能。</text>
</view>
</view>
</template>

<script setup>
import { ref, computed } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { useUserStore } from '@/store/user.js'
import { useApp } from '@/utils/useApp.js'
import { pointApi } from '@/api/index.js'

const userStore = useUserStore()
const { toast } = useApp()

const packages = ref([])
const current = ref(null)
const balance = ref(0)
const paying = ref(false)

const buyText = computed(() => {
if (!current.value) return '请选择套餐'
return `确认充值(${current.value.price_yuan}元)`
})

function ensureLogin() {
if (!userStore.isLogin) {
uni.navigateTo({ url: '/pages/login/login' })
return false
}
return true
}

async function loadPackages() {
try {
const res = await pointApi.getPackageList()
packages.value = res.list || []
current.value = packages.value.find((p) => p.badge === '推荐') || packages.value[0] || null
} catch (e) {
toast(e.msg || '套餐加载失败')
}
}

async function loadBalance() {
if (!userStore.isLogin) return
try {
const res = await pointApi.getBalance()
balance.value = (res.data && res.data.points) || 0
} catch (e) {
// 登录失效由 request 统一处理
}
}

function getWxLoginCode() {
return new Promise((resolve, reject) => {
// #ifdef MP-WEIXIN
uni.login({
provider: 'weixin',
success: (r) => (r.code ? resolve(r.code) : reject(new Error('获取登录凭证失败'))),
fail: () => reject(new Error('获取登录凭证失败'))
})
// #endif
// #ifndef MP-WEIXIN
reject(new Error('当前端暂不支持点数充值'))
// #endif
})
}

function requestVirtualPayment(order) {
return new Promise((resolve, reject) => {
// #ifdef MP-WEIXIN
if (!wx.canIUse || !wx.canIUse('requestVirtualPayment')) {
reject(new Error('当前微信版本不支持虚拟支付,请升级微信'))
return
}
// 调起虚拟支付的完整参数(供审核/排查用)
const payParams = {
signData: order.signData,
paySig: order.paySig,
signature: order.signature,
mode: order.mode
}
console.log('[虚拟支付] wx.requestVirtualPayment 参数:\n' + JSON.stringify(payParams, null, 2))
wx.requestVirtualPayment({
...payParams,
success: (res) => {
console.log('[虚拟支付] success:', JSON.stringify(res))
resolve(res)
},
fail: (err) => {
console.log('[虚拟支付] fail:', JSON.stringify(err))
reject(err)
}
})
// #endif
// #ifndef MP-WEIXIN
reject(new Error('当前端暂不支持点数充值'))
// #endif
})
}

async function pollOrder(orderNo, times = 5) {
for (let i = 0; i < times; i++) {
try {
const res = await pointApi.getOrderStatus({ order_no: orderNo })
if (res.data && res.data.status === 20) {
balance.value = res.data.balance
return true
}
} catch (e) {
// 忽略单次失败,继续轮询
}
await new Promise((r) => setTimeout(r, 1500))
}
return false
}

async function handleBuy() {
if (!ensureLogin()) return
if (!current.value || paying.value) return

paying.value = true
uni.showLoading({ mask: true, title: '发起支付...' })
try {
const code = await getWxLoginCode()
const res = await pointApi.createOrder({ package_id: current.value.id, code })
const order = res.data
uni.hideLoading()

// 打印后端返回的下单结果(含 env,1=沙箱不扣费 / 0=现网真实扣费)
console.log('[虚拟支付] createorder 返回:', JSON.stringify(order))
// 解析 signData 里的 env,直观确认环境
try {
const sd = JSON.parse(order.signData || '{}')
console.log('[虚拟支付] signData.env =', sd.env, sd.env === 1 ? '(沙箱-不扣费)' : '(现网-真实扣费!)')
} catch (e) {}

await requestVirtualPayment(order)

uni.showLoading({ mask: true, title: '确认到账...' })
const ok = await pollOrder(order.order_no)
uni.hideLoading()
if (ok) {
toast('充值成功')
} else {
toast('支付成功,点数到账处理中')
loadBalance()
}
} catch (e) {
uni.hideLoading()
const errCode = e && e.errCode
if (errCode === -2) {
// 用户取消支付,不提示
} else {
toast((e && (e.msg || e.errMsg || e.message)) || '支付失败')
}
} finally {
paying.value = false
}
}

onShow(() => {
loadPackages()
loadBalance()
})
</script>

<style lang="scss" scoped>
$accent: #0b8cff;
$muted: #8b95a3;

.recharge {
padding: 36rpx 36rpx calc(48rpx + env(safe-area-inset-bottom));
}

/* 余额卡 */
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 32rpx;
min-height: 264rpx;
padding: 44rpx 40rpx;
border-radius: 36rpx;
color: #fff;
box-sizing: border-box;
background:
radial-gradient(circle at 82% 18%, rgba(255, 255, 255, 0.28), transparent 28%),
linear-gradient(135deg, #0b8cff 0%, #20a7ff 48%, #20d3a2 100%);
box-shadow: 0 28rpx 56rpx rgba(11, 140, 255, 0.18);

.head-left {
display: flex;
flex-direction: column;
}
.kicker {
font-size: 26rpx;
font-weight: 800;
color: rgba(255, 255, 255, 0.82);
}
.title {
margin-top: 14rpx;
font-size: 56rpx;
line-height: 1.35;
font-weight: 900;
}
.user {
margin-top: 12rpx;
font-size: 26rpx;
color: rgba(255, 255, 255, 0.78);
}
.avatar {
flex: 0 0 104rpx;
width: 104rpx;
height: 104rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
font-weight: 900;
color: #fff;
background: rgba(15, 23, 42, 0.24);
border: 4rpx solid rgba(255, 255, 255, 0.55);
overflow: hidden;
}
.avatar-img {
width: 100%;
height: 100%;
}
}

.section-title {
margin: 48rpx 0 24rpx;
font-size: 34rpx;
font-weight: 900;
color: #1f2733;
}

/* 套餐宫格 */
.grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 20rpx;
}

.product {
position: relative;
min-height: 212rpx;
padding: 28rpx 16rpx 24rpx;
border: 4rpx solid transparent;
border-radius: 28rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16rpx;
color: #1f2733;
background: #fff;
box-shadow: 0 20rpx 44rpx rgba(15, 23, 42, 0.06);

&.active {
border-color: $accent;
box-shadow: 0 0 0 8rpx rgba(11, 140, 255, 0.1);
}
.badge {
position: absolute;
top: 16rpx;
left: 50%;
transform: translateX(-50%);
max-width: calc(100% - 24rpx);
padding: 6rpx 14rpx;
border-radius: 999rpx;
font-size: 20rpx;
font-weight: 900;
white-space: nowrap;
color: $accent;
background: rgba(11, 140, 255, 0.1);
}
.points {
margin-top: 36rpx;
font-size: 40rpx;
font-weight: 900;
}
.price {
font-size: 26rpx;
font-weight: 800;
color: $accent;
}
}

/* 确认充值 */
.buy-btn {
display: flex;
align-items: center;
justify-content: center;
height: 96rpx;
margin-top: 36rpx;
border-radius: 24rpx;
font-size: 30rpx;
font-weight: 900;
color: #fff;
background: linear-gradient(135deg, #0b8cff, #20a7ff);
box-shadow: 0 24rpx 48rpx rgba(11, 140, 255, 0.22);

&.disabled {
opacity: 0.6;
}
}

/* 权益 */
.perks {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20rpx;
margin-top: 36rpx;

.perk {
padding: 28rpx 24rpx;
border-radius: 28rpx;
background: rgba(11, 140, 255, 0.08);
}
.perk-title {
font-size: 28rpx;
font-weight: 900;
color: $accent;
}
.perk-desc {
display: block;
margin-top: 14rpx;
font-size: 24rpx;
line-height: 1.45;
color: $muted;
}
}

/* 购买须知 */
.notice {
margin-top: 44rpx;
padding: 28rpx;
border-radius: 28rpx;
background: #fff;
box-shadow: 0 20rpx 44rpx rgba(15, 23, 42, 0.04);

.notice-title {
display: block;
margin-bottom: 8rpx;
font-size: 26rpx;
font-weight: 800;
color: #8f98a6;
}
.notice-line {
display: block;
font-size: 26rpx;
line-height: 1.8;
color: $muted;
}
}
</style>

+ 1295
- 0
pages/tool/ai-name.vue
Разлика између датотеке није приказан због своје велике величине
Прегледај датотеку


+ 1114
- 0
pages/tool/anime-avatar.vue
Разлика између датотеке није приказан због своје велике величине
Прегледај датотеку


+ 497
- 0
pages/tool/anime-history.vue Прегледај датотеку

@@ -0,0 +1,497 @@
<template>
<view class="page">
<scroll-view
class="scroll"
scroll-y
refresher-enabled
:refresher-triggered="refreshing"
@refresherrefresh="refresh"
@scrolltolower="loadMore"
>
<view class="wrap">
<view class="studio-head">
<view>
<text class="eyebrow">AVATAR GALLERY</text>
<text class="page-title">我的动漫头像</text>
<text class="page-desc">保存每一次生成结果,完成后可查看大图、下载或继续创作。</text>
</view>
<view class="create-btn" @click="goCreate">生成</view>
</view>

<view class="stats-row">
<view class="stat-card">
<text class="stat-num">{{ total || list.length }}</text>
<text class="stat-label">全部作品</text>
</view>
<view class="stat-card">
<text class="stat-num">{{ pendingCount }}</text>
<text class="stat-label">生成中</text>
</view>
<view class="stat-card">
<text class="stat-num">{{ successCount }}</text>
<text class="stat-label">已完成</text>
</view>
</view>

<view v-if="list.length" class="gallery">
<view v-for="item in list" :key="item.chat_open_id" class="work-card">
<view class="poster" @click="openItem(item)">
<image v-if="item.content && Number(item.is_suc) > -1" class="poster-img" :src="item.content" mode="aspectFill" />
<view v-else class="poster-empty"></view>

<view class="status-chip" :class="statusClass(item)">
{{ statusText(item) }}
</view>

<view v-if="Number(item.is_suc) === 0" class="progress-mask">
<view class="spinner"></view>
<text>正在生成</text>
</view>
<view v-if="Number(item.is_suc) < 0" class="failed-mask">
<text>{{ item.content || '生成失败' }}</text>
</view>
</view>

<view class="card-info">
<view class="time-line">
<text>{{ formatTime(item.add_time) }}</text>
<view class="delete-btn" @click.stop="deleteItem(item)">删除</view>
</view>
</view>
</view>
</view>

<view v-if="!list.length && !loading" class="empty-state">
<view class="empty-preview">
<view class="empty-face"></view>
</view>
<text class="empty-title">还没有动漫头像</text>
<text class="empty-desc">上传一张清晰自拍,生成第一张专属头像。</text>
<view class="empty-action" @click="goCreate">去生成</view>
</view>

<view v-if="loading" class="loading">{{ list.length ? '加载更多...' : '加载中...' }}</view>
<view v-if="list.length && list.length >= total && !loading" class="end-text">已经到底了</view>
</view>
</scroll-view>
</view>
</template>

<script setup>
import { computed, ref } from 'vue'
import { onLoad, onUnload } from '@dcloudio/uni-app'
import dayjs from 'dayjs'
import { toolApi } from '@/api/index.js'
import { useApp } from '@/utils/useApp.js'

const { toast } = useApp()
const list = ref([])
const page = ref(1)
const total = ref(0)
const loading = ref(false)
const refreshing = ref(false)
let timer = 0

const pendingCount = computed(() => list.value.filter((item) => Number(item.is_suc) === 0).length)
const successCount = computed(() => list.value.filter((item) => Number(item.is_suc) > 0).length)

onLoad(() => refresh())
onUnload(() => clearTimeout(timer))

function formatTime(value) {
if (!value) return ''
return dayjs(Number(value) * 1000).format('YYYY-MM-DD HH:mm')
}

async function fetchList(reset = false) {
if (loading.value) return
loading.value = true
try {
const res = await toolApi.getAnimeList({
page_no: page.value,
page_size: 10,
role: 2
})
const rows = res.list || []
total.value = Number((res.data && res.data.total) || rows.length)
list.value = reset ? rows : list.value.concat(rows)
scheduleRefresh()
} catch (e) {
toast(e.msg || '历史记录加载失败')
} finally {
loading.value = false
refreshing.value = false
}
}

function scheduleRefresh() {
clearTimeout(timer)
if (list.value.some((item) => Number(item.is_suc) === 0)) {
timer = setTimeout(() => refreshSilent(), 2500)
}
}

function refresh() {
refreshing.value = true
refreshSilent()
}

function refreshSilent() {
page.value = 1
fetchList(true)
}

function loadMore() {
if (loading.value) return
if (list.value.length >= total.value) return
page.value += 1
fetchList()
}

function openItem(item) {
if (!item.content || Number(item.is_suc) < 0) return
uni.navigateTo({ url: `/pages/tool/work-detail?id=${item.chat_open_id}_0&from=anime` })
}

function statusText(item) {
if (Number(item.is_suc) === 0) return '生成中'
if (Number(item.is_suc) < 0) return '失败'
return '完成'
}

function statusClass(item) {
if (Number(item.is_suc) === 0) return 'pending'
if (Number(item.is_suc) < 0) return 'failed'
return 'done'
}

function goCreate() {
const delta = getPageStackDelta('pages/tool/anime-avatar')
if (delta > 0) {
uni.navigateBack({ delta })
return
}
uni.navigateTo({ url: '/pages/tool/anime-avatar' })
}

function getPageStackDelta(routeName) {
const pages = getCurrentPages()
const index = pages.findIndex((page) => page.route === routeName)
return index >= 0 ? pages.length - 1 - index : -1
}

function deleteItem(item) {
uni.showModal({
title: '温馨提示',
content: '是否确认删除该内容?',
confirmText: '确认删除',
success: async (res) => {
if (!res.confirm) return
try {
await toolApi.deleteAnime({ chat_open_id: item.chat_open_id })
toast('删除成功')
refresh()
} catch (e) {
toast(e.msg || '删除失败')
}
}
})
}
</script>

<style lang="scss" scoped>
.page,
.scroll {
height: 100vh;
background: #f6f8fc;
}

.wrap {
padding: 24rpx 28rpx 70rpx;
}

.studio-head {
padding: 30rpx;
border-radius: 26rpx;
background: #ffffff;
display: flex;
align-items: center;
gap: 24rpx;
box-shadow: 0 10rpx 28rpx rgba(31, 52, 86, 0.05);
}

.studio-head > view:first-child {
flex: 1;
min-width: 0;
}

.eyebrow {
display: block;
color: #1f8cff;
font-size: 20rpx;
font-weight: 800;
letter-spacing: 0;
}

.page-title {
display: block;
margin-top: 10rpx;
color: #172033;
font-size: 40rpx;
line-height: 1.2;
font-weight: 900;
}

.page-desc {
display: block;
margin-top: 12rpx;
color: #64748b;
font-size: 24rpx;
line-height: 1.5;
}

.create-btn {
width: 112rpx;
height: 68rpx;
border-radius: 999rpx;
color: #ffffff;
background: #1f8cff;
font-size: 26rpx;
line-height: 68rpx;
text-align: center;
font-weight: 800;
box-shadow: 0 12rpx 24rpx rgba(31, 140, 255, 0.22);
flex-shrink: 0;
}

.stats-row {
margin-top: 18rpx;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14rpx;
}

.stat-card {
padding: 20rpx 10rpx;
border-radius: 20rpx;
background: #ffffff;
text-align: center;
box-shadow: 0 8rpx 20rpx rgba(31, 52, 86, 0.04);
}

.stat-num {
display: block;
color: #172033;
font-size: 34rpx;
line-height: 1;
font-weight: 900;
}

.stat-label {
display: block;
margin-top: 10rpx;
color: #7c8a9b;
font-size: 22rpx;
}

.gallery {
margin-top: 24rpx;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 22rpx;
}

.work-card {
border-radius: 24rpx;
background: #ffffff;
overflow: hidden;
box-shadow: 0 10rpx 28rpx rgba(31, 52, 86, 0.06);
}

.poster {
position: relative;
width: 100%;
height: 440rpx;
background: #dfe7f2;
overflow: hidden;
}

.poster-img,
.poster-empty {
width: 100%;
height: 100%;
}

.poster-empty {
background: linear-gradient(135deg, #eef4fb, #dbe6f4);
}

.status-chip {
position: absolute;
left: 14rpx;
top: 14rpx;
height: 42rpx;
padding: 0 16rpx;
border-radius: 999rpx;
color: #ffffff;
font-size: 22rpx;
line-height: 42rpx;
font-weight: 800;
background: rgba(15, 23, 42, 0.48);
backdrop-filter: blur(8rpx);
}

.status-chip.done {
background: rgba(31, 140, 255, 0.9);
}

.status-chip.pending {
background: rgba(249, 115, 22, 0.92);
}

.status-chip.failed {
background: rgba(239, 68, 68, 0.9);
}

.progress-mask,
.failed-mask {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24rpx;
color: #ffffff;
font-size: 26rpx;
text-align: center;
background: rgba(15, 23, 42, 0.52);
}

.failed-mask {
background: rgba(127, 29, 29, 0.72);
}

.spinner {
width: 48rpx;
height: 48rpx;
margin-bottom: 18rpx;
border: 6rpx solid rgba(255, 255, 255, 0.45);
border-top-color: #ffffff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}

.card-info {
padding: 18rpx;
}

.time-line {
display: flex;
align-items: center;
gap: 12rpx;
color: #64748b;
font-size: 22rpx;
}

.time-line text {
flex: 1;
min-width: 0;
}

.delete-btn {
width: 74rpx;
height: 44rpx;
border-radius: 999rpx;
color: #ef4444;
background: #fff1f2;
font-size: 22rpx;
line-height: 44rpx;
text-align: center;
flex-shrink: 0;
}

.empty-state {
margin-top: 86rpx;
padding: 56rpx 34rpx;
border-radius: 28rpx;
background: #ffffff;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
box-shadow: 0 10rpx 28rpx rgba(31, 52, 86, 0.05);
}

.empty-preview {
width: 150rpx;
height: 200rpx;
border-radius: 32rpx;
background: linear-gradient(135deg, #edf6ff, #fff4f0);
display: flex;
align-items: center;
justify-content: center;
}

.empty-face {
width: 76rpx;
height: 76rpx;
border-radius: 50%;
border: 6rpx solid #1f8cff;
position: relative;
}

.empty-face::after {
content: '';
position: absolute;
left: 16rpx;
right: 16rpx;
bottom: 18rpx;
height: 6rpx;
border-radius: 999rpx;
background: #1f8cff;
}

.empty-title {
margin-top: 28rpx;
color: #172033;
font-size: 32rpx;
font-weight: 900;
}

.empty-desc {
margin-top: 12rpx;
color: #64748b;
font-size: 25rpx;
line-height: 1.5;
}

.empty-action {
margin-top: 28rpx;
width: 220rpx;
height: 72rpx;
border-radius: 999rpx;
color: #ffffff;
background: #1f8cff;
font-size: 28rpx;
line-height: 72rpx;
font-weight: 800;
}

.loading,
.end-text {
padding: 46rpx 0 20rpx;
color: #7c8a9b;
font-size: 24rpx;
text-align: center;
}

@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

+ 110
- 0
pages/tool/detail.vue Прегледај датотеку

@@ -0,0 +1,110 @@
<template>
<view class="tool">
<view class="tip card">
<text class="tip-title">{{ title || 'AI工具' }}</text>
<text class="tip-desc">工具类型:{{ type || '未指定' }}</text>
<text class="tip-note">这是通用工具详情页骨架。后续按 type 渲染不同的上传/参数表单,并调用 toolApi.submitTask 提交生成任务、轮询结果。</text>
</view>

<!-- 示例:图片上传占位 -->
<view class="upload card" @click="chooseImage">
<view v-if="!image" class="upload-empty">
<text class="upload-plus">+</text>
<text class="upload-text">上传图片</text>
</view>
<image v-else class="upload-img" :src="image" mode="aspectFit" />
</view>

<view class="btn-primary submit" @click="onSubmit">开始生成</view>
</view>
</template>

<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useUserStore } from '@/store/user.js'
import { useApp } from '@/utils/useApp.js'

const { toast } = useApp()
const userStore = useUserStore()

const type = ref('')
const title = ref('')
const image = ref('')

onLoad((options) => {
type.value = options.type || ''
title.value = options.title ? decodeURIComponent(options.title) : ''
if (title.value) uni.setNavigationBarTitle({ title: title.value })
})

function chooseImage() {
uni.chooseImage({
count: 1,
success: (res) => {
image.value = res.tempFilePaths[0]
}
})
}

function onSubmit() {
if (!userStore.isLogin) return uni.navigateTo({ url: '/pages/login/login' })
if (!image.value) return toast('请先上传图片')
// TODO: 上传图片 -> toolApi.submitTask -> 轮询结果 -> 跳转结果页
toast('生成逻辑待接入')
}
</script>

<style lang="scss" scoped>
.tool {
padding: 24rpx 0;
}
.tip {
margin-top: 24rpx;
padding: 32rpx;
display: flex;
flex-direction: column;
gap: 12rpx;

.tip-title {
font-size: 34rpx;
font-weight: 700;
}
.tip-desc {
font-size: 26rpx;
color: $ai-accent;
}
.tip-note {
font-size: 24rpx;
color: $ai-muted;
line-height: 1.6;
}
}
.upload {
margin-top: 24rpx;
height: 400rpx;
display: flex;
align-items: center;
justify-content: center;

.upload-empty {
display: flex;
flex-direction: column;
align-items: center;
color: $ai-muted;
}
.upload-plus {
font-size: 60rpx;
}
.upload-text {
font-size: 26rpx;
}
.upload-img {
width: 100%;
height: 100%;
}
}
.submit {
margin: 48rpx $ai-gutter 0;
}
</style>

+ 797
- 0
pages/tool/id-photo.vue Прегледај датотеку

@@ -0,0 +1,797 @@
<template>
<view class="page">
<scroll-view class="scroll" scroll-y>
<view class="wrap">
<view class="hero-card id-hero">
<view class="hero-copy">
<text class="eyebrow">ID PHOTO STUDIO</text>
<text class="hero-title">快速制作标准证件照</text>
<text class="hero-desc">选择尺寸、背景和服装,生成适合报名、签证、证件使用的照片。</text>
</view>
<view class="hero-preview">
<view class="id-photo-sample bg-blue">
<view class="person-head"></view>
<view class="person-body"></view>
</view>
<view class="id-photo-sample bg-white">
<view class="person-head"></view>
<view class="person-body"></view>
</view>
</view>
</view>

<view class="upload-card">
<view class="card-head">
<view>
<text class="card-title"><text class="required">*</text>上传图片</text>
<text class="card-desc">上传正面半身照,自动裁切为证件照</text>
</view>
<text class="card-tip">0/1</text>
</view>
<view class="upload-strip" @click="showTodo('请选择证件照图片')">
<view class="upload-thumb">
<text class="upload-plus">+</text>
</view>
<view class="upload-copy">
<text class="upload-title">选择一张清晰照片</text>
<text class="upload-sub">建议五官无遮挡、背景简单、光线均匀</text>
</view>
<view class="upload-action">上传</view>
</view>
</view>

<view class="section">
<view class="section-head">
<text class="tool-section-title">选尺寸</text>
<text class="section-tip">常用规格</text>
</view>
<view class="search-row">
<input class="search-input" placeholder="输入证件照名称" />
<view class="search-btn" @click="showTodo('尺寸搜索')">搜索</view>
</view>
<scroll-view class="tab-scroll" scroll-x>
<view class="tab-list">
<view
v-for="tab in sizeTabs"
:key="tab.key"
class="tab-item"
:class="{ active: activeSizeTab === tab.key }"
@click="selectSizeTab(tab.key)"
>
{{ tab.name }}
</view>
</view>
</scroll-view>
<scroll-view class="size-scroll" scroll-x>
<view class="size-list">
<view
v-for="item in currentSizes"
:key="item.name"
class="size-card"
:class="{ active: activeSize === item.name }"
@click="activeSize = item.name"
>
<text class="size-name">{{ item.name }}</text>
<text class="size-meta">{{ item.pixel }}</text>
<text class="size-desc">{{ item.print }}</text>
</view>
</view>
</scroll-view>
</view>

<view class="section">
<view class="section-head">
<text class="tool-section-title">选背景</text>
<text class="section-tip">生成后可替换</text>
</view>
<view class="bg-row">
<view
v-for="item in bgOptions"
:key="item.name"
class="bg-item"
:class="{ active: activeBg === item.name }"
@click="activeBg = item.name"
>
<view class="bg-dot" :class="item.className"></view>
<text>{{ item.name }}</text>
</view>
</view>
</view>

<view class="section compact">
<view class="section-head">
<text class="tool-section-title">性别</text>
<text class="section-tip">用于服装推荐</text>
</view>
<view class="chip-row">
<view
v-for="item in genderOptions"
:key="item"
class="chip"
:class="{ active: activeGender === item }"
@click="activeGender = item"
>
{{ item }}
</view>
</view>
</view>

<view class="section compact">
<view class="section-head">
<text class="tool-section-title">选服装</text>
<text class="section-tip">可选</text>
</view>
<view class="chip-row">
<view
v-for="item in outfitOptions"
:key="item"
class="chip"
:class="{ active: activeOutfit === item }"
@click="activeOutfit = item"
>
{{ item }}
</view>
</view>
</view>

<view class="section">
<view class="section-head">
<text class="tool-section-title">描述词</text>
<text class="section-tip">可补充要求</text>
</view>
<textarea class="textarea" placeholder="如自然美颜、肤色更均匀、保留本人特征等"></textarea>
<view class="chip-row keyword-row">
<view v-for="item in keywordOptions" :key="item" class="keyword-chip" @click="showTodo(`添加 ${item}`)">
{{ item }}
</view>
</view>
</view>
</view>
</scroll-view>

<view class="bottom-bar">
<view class="balance">
<text class="balance-copy">{{ balanceText }}</text>
<view v-if="isBalanceInsufficient" class="recharge-link" @click="goRecharge">去充值</view>
</view>
<view class="buttons">
<view class="history-btn" @click="showTodo('历史记录')">历史记录</view>
<view class="submit-btn" @click="showTodo('证件照制作')">
<image class="submit-icon" src="/static/icons/bolt.svg" mode="aspectFit" />
<text>{{ submitText }}</text>
</view>
</view>
</view>
</view>
</template>

<script setup>
import { computed, ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { billingApi, userApi } from '@/api/index.js'
import { useUserStore } from '@/store/user.js'
import { useApp } from '@/utils/useApp.js'

const userStore = useUserStore()
const { toast } = useApp()

const activeSizeTab = ref('common')
const activeSize = ref('小二寸')
const activeBg = ref('蓝色')
const activeGender = ref('先生')
const activeOutfit = ref('不换装')
const balance = ref({ points: 0 })
const quote = ref({ points: 0 })
const appKey = 'id-photo'

const sizeTabs = [
{ key: 'common', name: '常用尺寸' },
{ key: 'exam', name: '考试证照' },
{ key: 'passport', name: '护照签证' },
{ key: 'personal', name: '个人证件' }
]

const sizeGroups = {
common: [
{ name: '自定义尺寸', pixel: '满足各类需求', print: '按需设置' },
{ name: '小二寸', pixel: '413x531px', print: '35x45mm' },
{ name: '一寸', pixel: '295x413px', print: '25x35mm' },
{ name: '小一寸', pixel: '260x378px', print: '22x32mm' },
{ name: '大二寸', pixel: '413x626px', print: '35x53mm' },
{ name: '二寸', pixel: '413x579px', print: '35x49mm' }
],
exam: [
{ name: '教师资格证', pixel: '295x413px', print: '25x35mm' },
{ name: '英语四六级', pixel: '144x192px', print: '33x48mm' },
{ name: '计算机考试', pixel: '144x192px', print: '33x48mm' },
{ name: '普通话测试', pixel: '390x567px', print: '33x48mm' }
],
passport: [
{ name: '护照', pixel: '390x567px', print: '33x48mm' },
{ name: '美国签证', pixel: '600x600px', print: '51x51mm' },
{ name: '日本签证', pixel: '413x531px', print: '35x45mm' },
{ name: '韩国签证', pixel: '413x531px', print: '35x45mm' }
],
personal: [
{ name: '标准证件照', pixel: '462x612px', print: '35x48mm' },
{ name: '入学照片', pixel: '295x413px', print: '25x35mm' },
{ name: '离婚证照片', pixel: '413x531px', print: '35x45mm' },
{ name: '微信头像', pixel: '1000x1000px', print: '85x85mm' }
]
}

const currentSizes = computed(() => sizeGroups[activeSizeTab.value] || [])
const currentPoints = computed(() => Number(balance.value.points || balance.value.point_balance || 0))
const currentCost = computed(() => Number(quote.value.points || 0))
const balanceText = computed(() => {
const cost = currentCost.value
if (!userStore.isLogin) return cost ? `证件照 · ${cost}点/次` : '证件照'
return cost ? `余额${currentPoints.value}点 · 预计消耗${cost}点` : `余额${currentPoints.value}点`
})
const isBalanceInsufficient = computed(() => userStore.isLogin && currentCost.value > 0 && currentPoints.value < currentCost.value)
const submitText = computed(() => currentCost.value ? `立即制作 · ${currentCost.value}点` : '立即制作')

const bgOptions = [
{ name: '蓝色', className: 'blue' },
{ name: '白色', className: 'white' },
{ name: '红色', className: 'red' },
{ name: '深蓝', className: 'navy' },
{ name: '浅蓝渐变', className: 'gradient-blue' },
{ name: '深色渐变', className: 'gradient-dark' }
]
const genderOptions = ['先生', '女士', '小男孩', '小女孩']
const outfitOptions = ['不换装', '白衬衣', '浅蓝色衬衣', '西装', '休闲装']
const keywordOptions = ['磨皮', '去皱', '瘦脸']

onLoad(() => {
loadBillingConfig()
})

onShow(() => {
if (userStore.isLogin) loadBalance()
loadBillingConfig()
})

async function loadBalance() {
try {
const res = await userApi.getBalance()
balance.value = res.data || {}
} catch (e) {}
}

async function loadBillingConfig() {
try {
const res = await billingApi.getConfig({ app_key: appKey, image_count: 1 })
const data = res.data || {}
quote.value = data.quote || {}
if (data.balance) balance.value = data.balance
} catch (e) {}
}

function goRecharge() {
uni.navigateTo({ url: '/pages/recharge/recharge' })
}

function showTodo(name) {
toast(`${name}逻辑待接入`)
}

function selectSizeTab(key) {
activeSizeTab.value = key
const list = sizeGroups[key] || []
if (list.length) activeSize.value = list[0].name
}
</script>

<style lang="scss" scoped>
.page {
height: 100vh;
background: #f3f7fb;
display: flex;
flex-direction: column;
overflow: hidden;
}

.scroll {
flex: 1;
height: 0;
}

.wrap {
padding: 24rpx 28rpx 300rpx;
}

.hero-card {
position: relative;
min-height: 260rpx;
padding: 30rpx;
border-radius: 28rpx;
color: #fff;
background: linear-gradient(135deg, #0b8cff 0%, #36b6ff 58%, #79e0df 100%);
overflow: hidden;
box-sizing: border-box;
}

.hero-card::before {
content: '';
position: absolute;
right: -92rpx;
top: -110rpx;
width: 300rpx;
height: 300rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.18);
}

.hero-copy,
.hero-preview {
position: relative;
z-index: 1;
}

.eyebrow {
display: block;
color: rgba(255, 255, 255, 0.72);
font-size: 21rpx;
font-weight: 800;
}

.hero-title {
display: block;
margin-top: 16rpx;
width: 430rpx;
color: #fff;
font-size: 40rpx;
font-weight: 900;
line-height: 1.24;
}

.hero-desc {
display: block;
margin-top: 14rpx;
width: 420rpx;
color: rgba(255, 255, 255, 0.86);
font-size: 24rpx;
line-height: 1.5;
}

.hero-preview {
position: absolute;
right: 30rpx;
bottom: 28rpx;
width: 176rpx;
height: 126rpx;
}

.id-photo-sample {
position: absolute;
width: 90rpx;
height: 118rpx;
border-radius: 14rpx;
background: #fff;
box-shadow: 0 16rpx 28rpx rgba(0, 62, 130, 0.2);
overflow: hidden;
}

.id-photo-sample.bg-blue {
left: 0;
top: 0;
background: #5ba7f8;
}

.id-photo-sample.bg-white {
right: 0;
bottom: 0;
background: #f7fbff;
}

.person-head {
width: 36rpx;
height: 36rpx;
margin: 24rpx auto 0;
border-radius: 50%;
background: #ffffff;
}

.bg-white .person-head {
background: #7db7f7;
}

.person-body {
width: 64rpx;
height: 36rpx;
margin: 8rpx auto 0;
border-radius: 28rpx 28rpx 12rpx 12rpx;
background: #ffffff;
}

.bg-white .person-body {
background: #7db7f7;
}

.upload-card,
.section {
margin-top: 22rpx;
padding: 28rpx;
border-radius: 24rpx;
background: #fff;
box-shadow: 0 8rpx 28rpx rgba(34, 68, 112, 0.05);
}

.upload-card:first-child {
margin-top: 0;
}

.section.compact {
padding-bottom: 24rpx;
}

.card-head,
.section-head {
display: flex;
align-items: baseline;
}

.card-title,
.tool-section-title {
display: block;
color: #172033;
font-size: 31rpx;
font-weight: 800;
}

.required {
margin-right: 4rpx;
color: #ef4444;
}

.card-desc {
display: block;
margin-top: 8rpx;
color: #8a95a6;
font-size: 23rpx;
line-height: 1.35;
}

.card-tip,
.section-tip {
margin-left: auto;
color: #8a95a6;
font-size: 23rpx;
}

.upload-strip {
margin-top: 22rpx;
min-height: 142rpx;
padding: 18rpx;
border: 2rpx dashed rgba(11, 140, 255, 0.28);
border-radius: 20rpx;
background: #f8fbff;
display: flex;
align-items: center;
box-sizing: border-box;
}

.upload-thumb {
width: 104rpx;
height: 104rpx;
border-radius: 18rpx;
background: #eef6ff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}

.upload-plus {
color: #0b8cff;
font-size: 48rpx;
line-height: 1;
}

.upload-copy {
min-width: 0;
flex: 1;
margin-left: 20rpx;
}

.upload-title {
display: block;
color: #172033;
font-size: 29rpx;
font-weight: 800;
}

.upload-sub {
display: block;
margin-top: 8rpx;
color: #7f8896;
font-size: 23rpx;
line-height: 1.4;
}

.upload-action {
margin-left: 18rpx;
color: #0b8cff;
font-size: 25rpx;
font-weight: 800;
}

.search-row {
margin-top: 22rpx;
height: 72rpx;
border: 2rpx solid rgba(11, 140, 255, 0.22);
border-radius: 999rpx;
display: flex;
overflow: hidden;
background: #fff;
}

.search-input {
flex: 1;
height: 72rpx;
padding: 0 24rpx;
color: #172033;
font-size: 25rpx;
}

.search-btn {
width: 132rpx;
color: #fff;
background: #0b8cff;
font-size: 26rpx;
font-weight: 800;
line-height: 72rpx;
text-align: center;
}

.tab-scroll,
.size-scroll {
margin-left: -28rpx;
margin-right: -28rpx;
width: auto;
white-space: nowrap;
}

.tab-list {
padding: 20rpx 28rpx 14rpx;
display: flex;
gap: 34rpx;
}

.tab-item {
position: relative;
flex-shrink: 0;
height: 48rpx;
color: #7f8896;
font-size: 25rpx;
line-height: 48rpx;
}

.tab-item.active {
color: #172033;
font-weight: 800;
}

.tab-item.active::after {
content: '';
position: absolute;
left: 12rpx;
right: 12rpx;
bottom: 0;
height: 5rpx;
border-radius: 999rpx;
background: #0b8cff;
}

.size-list {
padding: 4rpx 28rpx 4rpx;
display: flex;
gap: 18rpx;
}

.size-card {
width: 196rpx;
min-height: 156rpx;
padding: 20rpx 14rpx;
border-radius: 20rpx;
border: 2rpx solid transparent;
background: #f7f9fd;
box-sizing: border-box;
text-align: center;
flex-shrink: 0;
}

.size-card.active {
color: #fff;
background: #4358ff;
box-shadow: 0 12rpx 26rpx rgba(67, 88, 255, 0.2);
}

.size-name,
.size-meta,
.size-desc {
display: block;
}

.size-name {
color: #172033;
font-size: 28rpx;
font-weight: 800;
line-height: 1.2;
}

.size-meta {
margin-top: 16rpx;
color: #8a95a6;
font-size: 23rpx;
font-weight: 700;
}

.size-desc {
margin-top: 8rpx;
color: #9aa4b2;
font-size: 21rpx;
}

.size-card.active .size-name,
.size-card.active .size-meta,
.size-card.active .size-desc {
color: rgba(255, 255, 255, 0.9);
}

.bg-row {
margin-top: 22rpx;
display: flex;
gap: 20rpx;
overflow-x: auto;
}

.bg-item {
width: 100rpx;
color: #596579;
font-size: 21rpx;
font-weight: 700;
text-align: center;
flex-shrink: 0;
}

.bg-item.active {
color: #0b8cff;
}

.bg-dot {
width: 76rpx;
height: 76rpx;
margin: 0 auto 10rpx;
border-radius: 50%;
border: 4rpx solid transparent;
box-shadow: 0 8rpx 18rpx rgba(15, 23, 42, 0.1);
}

.bg-item.active .bg-dot {
border-color: #0b8cff;
box-shadow: 0 0 0 6rpx rgba(11, 140, 255, 0.1), 0 10rpx 22rpx rgba(15, 23, 42, 0.12);
}

.blue { background: #4194f4; }
.white { background: #fff; border-color: #e5e7eb; }
.red { background: #ef3028; }
.navy { background: #326da4; }
.gradient-blue { background: linear-gradient(180deg, #64a9f7 0%, #d8ecff 100%); }
.gradient-dark { background: linear-gradient(180deg, #263345 0%, #4f6f84 100%); }

.chip-row {
margin-top: 18rpx;
display: flex;
flex-wrap: wrap;
gap: 14rpx;
}

.chip,
.keyword-chip {
height: 58rpx;
padding: 0 24rpx;
border-radius: 30rpx;
color: #596579;
font-size: 25rpx;
font-weight: 700;
line-height: 58rpx;
background: #f6f9fe;
border: 2rpx solid transparent;
box-sizing: border-box;
}

.chip.active {
color: #0b8cff;
background: #eff8ff;
border-color: #0b8cff;
}

.textarea {
margin-top: 20rpx;
width: 100%;
min-height: 144rpx;
padding: 22rpx;
border-radius: 20rpx;
color: #172033;
font-size: 25rpx;
line-height: 1.5;
background: #f7f9fd;
box-sizing: border-box;
}

.keyword-chip {
color: #0b8cff;
background: #eef7ff;
}

.bottom-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 80;
padding: 18rpx 28rpx calc(18rpx + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 -10rpx 30rpx rgba(32, 56, 92, 0.1);
}

.balance {
margin-bottom: 14rpx;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
color: #7b8797;
font-size: 24rpx;
line-height: 1.35;
}

.balance-copy {
min-width: 0;
flex: 1;
}

.recharge-link {
color: #0b8cff;
font-size: 24rpx;
font-weight: 800;
flex-shrink: 0;
}

.buttons {
display: flex;
gap: 22rpx;
}

.history-btn,
.submit-btn {
height: 86rpx;
border-radius: 999rpx;
font-size: 30rpx;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
}

.history-btn {
width: 210rpx;
color: #1f8cff;
background: #eef7ff;
}

.submit-btn {
flex: 1;
color: #fff;
background: linear-gradient(135deg, #1f8cff, #41b9ff);
box-shadow: 0 12rpx 24rpx rgba(31, 140, 255, 0.28);
}

.submit-icon {
width: 24rpx;
height: 24rpx;
margin-right: 8rpx;
}
</style>

+ 348
- 0
pages/tool/name-history.vue Прегледај датотеку

@@ -0,0 +1,348 @@
<template>
<view class="page">
<scroll-view
class="scroll"
scroll-y
refresher-enabled
:refresher-triggered="refreshing"
@refresherrefresh="refresh"
@scrolltolower="loadMore"
>
<view class="list">
<view v-for="item in list" :key="item.chat_open_id" class="item" @click="openItem(item)">
<view class="item-head">
<view class="title">{{ item.question_content || 'AI取名' }}</view>
<text class="status" :class="{ pending: Number(item.is_suc) === 0, failed: Number(item.is_suc) < 0 }">
{{ getStatusText(item) }}
</text>
</view>

<view v-if="getNames(item).length" class="name-row">
<text v-for="name in getNames(item)" :key="name" class="name-chip">{{ name }}</text>
</view>

<view class="desc">{{ getSummary(item) }}</view>

<view class="meta">
<text>{{ formatTime(item.add_time) }}</text>
<text class="del" @click.stop="deleteItem(item)">删除</text>
</view>
</view>
</view>

<view v-if="!list.length && !loading" class="empty">暂无历史记录</view>
<view v-if="loading" class="loading">加载中...</view>
</scroll-view>
</view>
</template>

<script setup>
import { ref } from 'vue'
import { onLoad, onUnload } from '@dcloudio/uni-app'
import dayjs from 'dayjs'
import { toolApi } from '@/api/index.js'
import { useApp } from '@/utils/useApp.js'

const { toast } = useApp()
const robotId = ref('')
const list = ref([])
const page = ref(1)
const total = ref(0)
const loading = ref(false)
const refreshing = ref(false)
let timer = 0

onLoad((options) => {
robotId.value = options.robot_id || ''
refresh()
})

onUnload(() => clearTimeout(timer))

function formatTime(value) {
if (!value) return ''
return dayjs(Number(value) * 1000).format('YYYY-MM-DD HH:mm')
}

async function fetchList(reset = false) {
if (loading.value) return
loading.value = true
try {
const res = await toolApi.getNameList({
page_no: page.value,
page_size: 10,
robot_open_id: robotId.value,
role: 2
})
const rows = res.list || []
total.value = Number((res.data && res.data.total) || rows.length)
list.value = reset ? rows : list.value.concat(rows)
scheduleRefresh()
} catch (e) {
toast(e.msg || '历史记录加载失败')
} finally {
loading.value = false
refreshing.value = false
}
}

function scheduleRefresh() {
clearTimeout(timer)
if (list.value.some((item) => Number(item.is_suc) === 0)) {
timer = setTimeout(() => refreshSilent(), 2500)
}
}

function refresh() {
refreshing.value = true
refreshSilent()
}

function refreshSilent() {
page.value = 1
fetchList(true)
}

function loadMore() {
if (loading.value || list.value.length >= total.value) return
page.value += 1
fetchList()
}

function openItem(item) {
uni.navigateTo({
url: `/pages/tool/name-result?id=${item.chat_open_id}&robot_id=${robotId.value}&key=naming`
})
}

function getStatusText(item) {
if (Number(item.is_suc) === 0) return '生成中'
if (Number(item.is_suc) < 0) return '失败'
return '完成'
}

function getParsed(item) {
if (item._parsed) return item._parsed
item._parsed = parseResult(item.content)
return item._parsed
}

function getNames(item) {
const parsed = getParsed(item)
return (parsed.names || []).map((name) => name.name).filter(Boolean).slice(0, 3)
}

function getSummary(item) {
if (Number(item.is_suc) === 0) return '正在生成取名方案,请稍后查看'
if (Number(item.is_suc) < 0) return item.content || '生成失败'

const parsed = getParsed(item)
if (parsed.summary) return parsed.summary

const first = parsed.names && parsed.names[0]
if (first) return [first.meaning, first.reason].filter(Boolean).join(' ')
return '点击查看取名结果'
}

function parseResult(content) {
if (!content) return { summary: '', names: [] }
let text = String(content).trim()
text = text.replace(/^```json\s*/i, '').replace(/^```\s*/i, '').replace(/```$/i, '').trim()

const lineResult = parseJsonLines(text)
if (lineResult.names.length) return lineResult

const markedResult = parseMarkedText(text)
if (markedResult.names.length) return markedResult

const start = text.indexOf('{')
const end = text.lastIndexOf('}')
if (start >= 0 && end > start) text = text.substring(start, end + 1)

try {
const data = JSON.parse(text)
return {
summary: data.summary || '',
names: Array.isArray(data.names) ? data.names : []
}
} catch (e) {
return { summary: '', names: [] }
}
}

function parseMarkedText(text) {
const data = { summary: '', names: [] }
const value = String(text || '')
const summaryMatch = value.match(/整体建议[::]([\s\S]*?)(?=\n\s*【|$)/)
if (summaryMatch) data.summary = summaryMatch[1].trim()

const reg = /【([^】]+)】([\s\S]*?)(?=\n\s*【|$)/g
let match
while ((match = reg.exec(value))) {
const body = match[2] || ''
data.names.push({
name: match[1].trim(),
pinyin: matchField(body, '拼音'),
score: parseInt(matchField(body, '评分')) || 0,
source: matchField(body, '出处'),
meaning: matchField(body, '寓意'),
tone: matchField(body, '音律'),
reason: matchField(body, '推荐')
})
}
data.names = data.names.filter((item) => item.name).slice(0, 5)
return data
}

function matchField(body, label) {
const reg = new RegExp(`${label}[::]([^\\n\\r]*)`)
const match = String(body || '').match(reg)
return match ? match[1].trim() : ''
}

function parseJsonLines(text) {
const data = { summary: '', names: [] }
String(text || '').split(/\r?\n/).forEach((line) => {
line = line.trim()
if (!line) return
try {
const item = JSON.parse(line)
if (item.type === 'summary') data.summary = item.content || ''
if (item.type === 'name' && item.data) data.names.push(item.data)
} catch (e) {}
})
return data
}

function deleteItem(item) {
uni.showModal({
title: '温馨提示',
content: '是否确认删除该内容?',
confirmText: '确认删除',
success: async (res) => {
if (!res.confirm) return
try {
await toolApi.deleteName({ chat_open_id: item.chat_open_id })
toast('删除成功')
refresh()
} catch (e) {
toast(e.msg || '删除失败')
}
}
})
}
</script>

<style lang="scss" scoped>
.page,
.scroll {
height: 100vh;
background: #f4f7fb;
}

.list {
padding: 26rpx;
}

.item {
padding: 28rpx 30rpx;
border-radius: 20rpx;
background: #fff;
margin-bottom: 22rpx;
box-shadow: 0 8rpx 24rpx rgba(35, 55, 90, 0.05);
}

.item-head {
display: flex;
align-items: center;
min-width: 0;
}

.title {
flex: 1;
min-width: 0;
color: #172033;
font-size: 30rpx;
font-weight: 700;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

.status {
margin-left: 18rpx;
height: 40rpx;
padding: 0 16rpx;
border-radius: 22rpx;
color: #0b8cff;
font-size: 22rpx;
line-height: 40rpx;
background: #eef6ff;

&.pending {
color: #ff8a00;
background: #fff5e9;
}

&.failed {
color: #f04438;
background: #fff1f1;
}
}

.name-row {
margin-top: 18rpx;
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}

.name-chip {
max-width: 190rpx;
height: 46rpx;
padding: 0 18rpx;
border-radius: 24rpx;
color: #0b8cff;
font-size: 25rpx;
font-weight: 700;
line-height: 46rpx;
background: #f0f8ff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.desc {
margin-top: 16rpx;
color: #7f8896;
font-size: 26rpx;
line-height: 1.5;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}

.meta {
margin-top: 20rpx;
display: flex;
align-items: center;
color: #a0aabb;
font-size: 23rpx;
}

.del {
margin-left: auto;
color: #f04438;
padding-left: 28rpx;
}

.empty,
.loading {
padding: 80rpx 0;
color: #7f8896;
font-size: 26rpx;
text-align: center;
}
</style>

+ 609
- 0
pages/tool/name-result.vue Прегледај датотеку

@@ -0,0 +1,609 @@
<template>
<view class="page">
<scroll-view class="scroll" scroll-y>
<view class="wrap">
<view class="content-card">
<view v-if="questionTitle" class="question">{{ questionTitle }}</view>

<view v-if="streaming" class="stream-box">
<view class="loading">生成中...</view>
<view v-if="!result.names.length" class="progress-card">
<view class="dot-list">
<text></text>
<text></text>
<text></text>
</view>
<text class="progress-title">正在整理名字方案</text>
<text class="progress-desc">AI 正在分析音律、寓意和出处,完成后会自动展示为名字卡片。</text>
</view>
<text v-if="streamText && !result.names.length" class="stream-text" user-select>{{ streamText }}</text>
</view>

<view v-if="Number(msg.is_suc) < 0" class="error">{{ msg.content || '生成失败' }}</view>

<view v-if="result.names && result.names.length" class="result">
<text v-if="result.summary" class="summary">{{ result.summary }}</text>
<view v-for="item in result.names" :key="item.name" class="name-card">
<view class="name-head">
<text class="name">{{ item.name }}</text>
<text v-if="item.score" class="score">{{ item.score }}分</text>
</view>
<text v-if="item.pinyin" class="pinyin">{{ item.pinyin }}</text>
<view v-if="item.source" class="row">
<text class="row-label">出处</text>
<text class="row-text">{{ item.source }}</text>
</view>
<view v-if="item.wuxing" class="row">
<text class="row-label">五行</text>
<text class="row-text">{{ item.wuxing }}</text>
</view>
<view v-if="item.meaning" class="row">
<text class="row-label">寓意</text>
<text class="row-text">{{ item.meaning }}</text>
</view>
<view v-if="item.tone" class="row">
<text class="row-label">音律</text>
<text class="row-text">{{ item.tone }}</text>
</view>
<view v-if="item.reason" class="row">
<text class="row-label">推荐</text>
<text class="row-text">{{ item.reason }}</text>
</view>
</view>
</view>

<view v-if="!streaming && Number(msg.is_suc) >= 0 && (!result.names || !result.names.length)" class="empty-card">
<text class="empty-title">本次没有可用名字</text>
<text class="empty-desc">{{ result.summary || 'AI 返回的候选名未通过姓名可用性筛选,请调整条件或重新生成。' }}</text>
</view>
</view>
</view>
</scroll-view>

<view class="bottom-bar">
<button class="bar-btn" :loading="regenerating" @click="regenerate">重新生成</button>
<button class="bar-btn" @click="copy">复制</button>
<button class="main-btn" @click="continueName">继续AI取名</button>
</view>
</view>
</template>

<script setup>
import { computed, ref } from 'vue'
import { onLoad, onUnload } from '@dcloudio/uni-app'
import dayjs from 'dayjs'
import { toolApi } from '@/api/index.js'
import { requestNameStream } from '@/utils/nameStream.js'
import { useApp } from '@/utils/useApp.js'

const { toast } = useApp()
const id = ref('')
const robotId = ref('')
const msg = ref({})
const result = ref({ summary: '', names: [] })
const streamText = ref('')
const streaming = ref(false)
const regenerating = ref(false)
const streamParams = ref({})
let timer = 0
let streamTask = null

const questionTitle = computed(() => {
return msg.value.question_content || (streaming.value ? 'AI取名' : '')
})

onLoad((options) => {
robotId.value = options.robot_id || ''
if (options.create == 1) {
const params = uni.getStorageSync('name_stream_params')
if (params && params.surname) {
startStream(params)
} else {
toast('缺少取名参数')
}
return
}
id.value = options.id || ''
loadDetail()
})

onUnload(() => {
clearTimeout(timer)
if (streamTask && streamTask.abort) streamTask.abort()
})

async function loadDetail() {
if (!id.value) return
try {
const res = await toolApi.getNameDetail({ chat_open_id: id.value })
msg.value = res.data || {}
streamParams.value = msg.value.detail || {}
result.value = normalizeResult(parseResult(msg.value.content))
if (Number(msg.value.is_suc) === 0 || !msg.value.content) {
timer = setTimeout(loadDetail, 2000)
}
} catch (e) {
toast(e.msg || '结果加载失败')
}
}

function startStream(params) {
streamParams.value = params || {}
streaming.value = true
streamText.value = ''
result.value = { summary: '', names: [] }
msg.value = {
question_content: `${params.surname}${params.sex == 2 ? '女孩' : '男孩'}好名字`
}

streamTask = requestNameStream(params, {
onStart: (data) => {
id.value = data.answer_chat_open_id || ''
},
onMessage: (data) => {
if (data.type === 'summary') {
result.value.summary = data.content || ''
return
}
if (data.type === 'name' && data.data) {
if (!isValidName(data.data.name)) return
const exists = result.value.names.some((item) => item.name === data.data.name)
if (!exists) result.value.names.push(data.data)
return
}
if (data.content) {
streamText.value += data.content
const parsed = parseResult(streamText.value)
if (parsed.summary) result.value.summary = parsed.summary
parsed.names = normalizeNames(parsed.names)
if (parsed.names.length) result.value.names = parsed.names
}
},
onDone: (data) => {
streaming.value = false
streamText.value = data.content || streamText.value
result.value = normalizeResult(data.result || parseResult(streamText.value))
msg.value = {
...msg.value,
is_suc: 1,
content: JSON.stringify(result.value)
}
uni.removeStorageSync('name_stream_params')
},
onError: (data) => {
streaming.value = false
msg.value = { ...msg.value, is_suc: -1, content: data.msg || '生成失败' }
toast(data.msg || '生成失败')
}
})
}

function parseResult(content) {
if (!content) return { summary: '', names: [] }
let text = String(content).trim()
text = text.replace(/^```json\s*/i, '').replace(/^```\s*/i, '').replace(/```$/i, '').trim()

const lineResult = parseJsonLines(text)
if (lineResult.names.length) return lineResult

const markedResult = parseMarkedText(text)
if (markedResult.names.length) return markedResult

const start = text.indexOf('{')
const end = text.lastIndexOf('}')
if (start >= 0 && end > start) text = text.substring(start, end + 1)

try {
const data = JSON.parse(text)
return {
summary: data.summary || '',
names: Array.isArray(data.names) ? data.names : []
}
} catch (e) {
return { summary: text, names: [] }
}
}

function parseMarkedText(text) {
const data = { summary: '', names: [] }
const value = String(text || '')
const summaryMatch = value.match(/整体建议[::]([\s\S]*?)(?=\n\s*【|$)/)
if (summaryMatch) data.summary = summaryMatch[1].trim()

const reg = /【([^】]+)】([\s\S]*?)(?=\n\s*【|$)/g
let match
while ((match = reg.exec(value))) {
const body = match[2] || ''
const name = resolveNameBlockTitle(match[1], body)
data.names.push({
name,
pinyin: matchField(body, '拼音'),
score: parseInt(matchField(body, '评分')) || 90,
source: matchField(body, '出处'),
wuxing: matchField(body, '五行'),
meaning: matchField(body, '寓意'),
tone: matchField(body, '音律'),
reason: matchField(body, '推荐')
})
}
data.names = data.names.filter((item) => item.name).slice(0, 5)
return data
}

function resolveNameBlockTitle(title, body) {
const name = String(title || '').trim()
if (!['姓名', '名字', '候选名'].includes(name)) return name

const fieldReg = /^(拼音|评分|出处|五行|寓意|音律|推荐)[::]/
const line = String(body || '').split(/\r?\n/).map((item) => item.trim()).find((item) => item && !fieldReg.test(item))
return line ? line.replace(/^姓名[::]/, '').trim() : name
}

function matchField(body, label) {
const reg = new RegExp(`${label}[::]([^\\n\\r]*)`)
const match = String(body || '').match(reg)
return match ? match[1].trim() : ''
}

function parseJsonLines(text) {
const data = { summary: '', names: [] }
String(text || '').split(/\r?\n/).forEach((line) => {
line = line.trim()
if (!line) return
try {
const item = JSON.parse(line)
if (item.type === 'summary') data.summary = item.content || ''
if (item.type === 'name' && item.data) data.names.push(item.data)
} catch (e) {}
})
return data
}

function normalizeResult(data) {
data = data || { summary: '', names: [] }
return {
...data,
names: normalizeNames(data.names)
}
}

function normalizeNames(names) {
return (Array.isArray(names) ? names : []).filter((item) => isValidName(item.name)).slice(0, 5)
}

function isValidName(name) {
const params = streamParams.value || {}
const value = String(name || '').replace(/\s/g, '')
const surname = String(params.surname || '').trim()
const specialWord = String(params.special_word || '').trim()
const wordsNum = Number(params.words_num || 0)

if (!value) return false
if (!surname && !specialWord && wordsNum <= 0) return true
if (surname && !value.startsWith(surname)) return false
if (specialWord && !value.includes(specialWord)) return false
if (wordsNum > 0 && Array.from(value).length !== wordsNum) return false
if (!isHumanLikeName(value, surname)) return false
return true
}

function isHumanLikeName(name, surname) {
const value = String(name || '').replace(/\s/g, '')
const familyName = String(surname || '').trim()
if (!value || !familyName || !value.startsWith(familyName)) return false

const givenName = Array.from(value).slice(Array.from(familyName).length).join('')
if (!givenName || givenName.length > 3) return false

const bannedFullNames = ['雷厉风行', '雷打不动', '雷腾云奔', '雷令风行']
if (bannedFullNames.includes(value)) return false

const phrasePatterns = ['厉风行', '打不动', '腾云奔', '令风行', '风行', '不动', '云奔', '令行']
if (phrasePatterns.some((item) => givenName.includes(item))) return false

const unnaturalChars = ['打', '不']
if (unnaturalChars.some((item) => givenName.includes(item))) return false

const unnaturalEndChars = ['动', '奔', '行']
if (unnaturalEndChars.includes(givenName[givenName.length - 1])) return false

return true
}

function formatCopyText() {
if (result.value.names && result.value.names.length) {
const lines = []
if (result.value.summary) lines.push(result.value.summary)
result.value.names.forEach((item) => {
lines.push(`${item.name}${item.score ? `(${item.score}分)` : ''}`)
if (item.pinyin) lines.push(`拼音:${item.pinyin}`)
if (item.source) lines.push(`出处:${item.source}`)
if (item.wuxing) lines.push(`五行:${item.wuxing}`)
if (item.meaning) lines.push(`寓意:${item.meaning}`)
if (item.tone) lines.push(`音律:${item.tone}`)
if (item.reason) lines.push(`推荐:${item.reason}`)
lines.push('')
})
return lines.join('\n')
}
return streamText.value || msg.value.content || ''
}

function copy() {
const data = formatCopyText()
if (!data) return toast('暂无可复制内容')
uni.setClipboardData({
data,
success: () => toast('复制成功')
})
}

function regenerate() {
const cache = uni.getStorageSync('writing_form')
if (!cache || !cache.surname) {
continueName()
return
}
regenerating.value = true
const params = {
...cache,
model: 4,
robot_open_id: robotId.value
}
if (params.birth_time) params.birth_time = dayjs(params.birth_time).unix()
startStream(params)
regenerating.value = false
}

function continueName() {
const pages = getCurrentPages()
const targetIndex = pages.findIndex((page) => page.route === 'pages/tool/ai-name')
if (targetIndex >= 0) {
const delta = pages.length - 1 - targetIndex
if (delta > 0) {
uni.navigateBack({ delta })
return
}
}
uni.redirectTo({ url: '/pages/tool/ai-name' })
}
</script>

<style lang="scss" scoped>
.page {
height: 100vh;
background: #f4f7fb;
display: flex;
flex-direction: column;
overflow: hidden;
}

.scroll {
flex: 1;
height: 0;
}

.wrap {
padding: 28rpx 28rpx 220rpx;
}

.content-card {
min-height: 70vh;
padding: 34rpx;
border-radius: 18rpx;
background: #fff;
box-sizing: border-box;
}

.question {
color: #172033;
font-size: 32rpx;
font-weight: 700;
line-height: 1.5;
margin-bottom: 28rpx;
}

.loading,
.empty {
color: #7f8896;
font-size: 28rpx;
}

.progress-card {
margin-top: 20rpx;
padding: 34rpx 28rpx;
border-radius: 16rpx;
background: #f7f9fd;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}

.dot-list {
display: flex;
gap: 10rpx;
margin-bottom: 20rpx;

text {
width: 14rpx;
height: 14rpx;
border-radius: 50%;
background: #0b8cff;
animation: pulse 1.1s ease-in-out infinite;

&:nth-child(2) {
animation-delay: 0.15s;
}

&:nth-child(3) {
animation-delay: 0.3s;
}
}
}

.progress-title {
color: #172033;
font-size: 30rpx;
font-weight: 700;
}

.progress-desc {
margin-top: 14rpx;
color: #7f8896;
font-size: 25rpx;
line-height: 1.6;
}

.stream-text {
display: block;
margin-top: 22rpx;
padding: 24rpx;
border-radius: 14rpx;
color: #172033;
font-size: 26rpx;
line-height: 1.7;
white-space: pre-wrap;
background: #f7f9fd;
}

.error {
color: #f04438;
font-size: 28rpx;
}

.empty-card {
padding: 34rpx 30rpx;
border-radius: 16rpx;
background: #f7f9fd;
}

.empty-title {
display: block;
color: #172033;
font-size: 30rpx;
font-weight: 700;
}

.empty-desc {
display: block;
margin-top: 16rpx;
color: #7f8896;
font-size: 26rpx;
line-height: 1.6;
}

.summary {
display: block;
color: #596579;
font-size: 27rpx;
line-height: 1.6;
margin-bottom: 22rpx;
}

.name-card {
padding: 26rpx;
border-radius: 16rpx;
background: #f7f9fd;
margin-top: 20rpx;
}

.name-head {
display: flex;
align-items: center;
}

.name {
color: #0b8cff;
font-size: 42rpx;
font-weight: 700;
}

.score {
margin-left: auto;
color: #ff8a00;
font-size: 28rpx;
font-weight: 700;
}

.pinyin {
display: block;
margin-top: 6rpx;
color: #7f8896;
font-size: 24rpx;
}

.row {
margin-top: 16rpx;
display: flex;
align-items: flex-start;
}

.row-label {
width: 78rpx;
height: 38rpx;
border-radius: 20rpx;
color: #0b8cff;
font-size: 22rpx;
line-height: 38rpx;
text-align: center;
background: #eef6ff;
flex-shrink: 0;
}

.row-text {
flex: 1;
min-width: 0;
margin-left: 14rpx;
color: #172033;
font-size: 26rpx;
line-height: 1.55;
}

.bottom-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 50;
padding: 18rpx 28rpx calc(18rpx + env(safe-area-inset-bottom));
background: #fff;
display: flex;
gap: 18rpx;
box-shadow: 0 -8rpx 24rpx rgba(35, 55, 90, 0.08);
}

@keyframes pulse {
0%,
100% {
opacity: 0.35;
transform: scale(0.82);
}

50% {
opacity: 1;
transform: scale(1);
}
}

.bar-btn,
.main-btn {
height: 82rpx;
border-radius: 42rpx;
line-height: 82rpx;
font-size: 29rpx;
margin: 0;
}

.bar-btn {
width: 170rpx;
color: #0b8cff;
background: #eef6ff;
}

.main-btn {
flex: 1;
color: #fff;
background: #0b8cff;
}
</style>

+ 422
- 0
pages/tool/old-photo.vue Прегледај датотеку

@@ -0,0 +1,422 @@
<template>
<view class="page">
<scroll-view class="scroll" scroll-y>
<view class="wrap">
<view class="hero-card">
<view class="hero-copy">
<text class="eyebrow">OLD PHOTO RESTORE</text>
<text class="hero-title">老照片修复 / 上色</text>
<text class="hero-desc">适合黑白、泛黄、模糊、划痕或破损照片,先搭建 UI,生成逻辑后续接入。</text>
</view>
<view class="hero-preview">
<view class="photo before"></view>
<view class="photo after"></view>
</view>
</view>

<view class="upload-card">
<view class="card-head">
<view>
<text class="card-title"><text class="required">*</text>上传照片</text>
<text class="card-desc">支持黑白、泛黄、模糊、划痕或破损照片</text>
</view>
<text class="card-tip">0/1</text>
</view>
<view class="upload-strip" @click="showTodo('选择老照片')">
<view class="upload-thumb"><text class="upload-plus">+</text></view>
<view class="upload-copy">
<text class="upload-title">上传需要修复的老照片</text>
<text class="upload-sub">建议选择主体清晰、人物不被严重遮挡的照片</text>
</view>
<view class="upload-action">上传</view>
</view>
</view>

<view class="section">
<view class="section-head">
<text class="tool-section-title">照片风格</text>
<text class="section-tip">输出方式</text>
</view>
<view class="style-grid">
<view
v-for="item in styleOptions"
:key="item"
class="style-item"
:class="{ active: activeStyle === item }"
@click="activeStyle = item"
>
{{ item }}
</view>
</view>
</view>

<view class="section">
<view class="section-head">
<text class="tool-section-title">描述关键词</text>
<text class="section-tip">点击添加</text>
</view>
<textarea class="textarea" placeholder="补充照片修复要求,例如保留原始五官、增强清晰度、自然上色"></textarea>
<view class="keyword-row">
<view
v-for="item in keywords"
:key="item"
class="keyword-chip"
:class="{ active: selectedKeywords.includes(item) }"
@click="toggleKeyword(item)"
>
{{ item }}
</view>
</view>
</view>
</view>
</scroll-view>

<view class="bottom-bar">
<view class="balance">
<text class="balance-copy">{{ balanceText }}</text>
<view v-if="isBalanceInsufficient" class="recharge-link" @click="goRecharge">去充值</view>
</view>
<view class="buttons">
<view class="history-btn" @click="showTodo('历史记录')">历史记录</view>
<view class="submit-btn" @click="showTodo('老照片修复')">
<image class="submit-icon" src="/static/icons/bolt.svg" mode="aspectFit" />
<text>{{ submitText }}</text>
</view>
</view>
</view>
</view>
</template>

<script setup>
import { computed, ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { billingApi, userApi } from '@/api/index.js'
import { useUserStore } from '@/store/user.js'
import { useApp } from '@/utils/useApp.js'

const userStore = useUserStore()
const { toast } = useApp()
const activeStyle = ref('彩色')
const selectedKeywords = ref(['4K', '自然上色'])
const balance = ref({ points: 0 })
const quote = ref({ points: 0 })
const appKey = 'old-photo'

const styleOptions = ['彩色', '黑白']
const keywords = [
'4K',
'自然上色',
'去掉划痕',
'去褶皱',
'去噪点',
'去除斑驳破损',
'衣服修复',
'面部还原真实人像',
'还原人脸五官细节',
'眼神神态自然',
'修补缺失破损区域',
'画质超清增强',
'锐化细节',
'保留年代质感',
'人物容貌不变',
'不扭曲五官'
]
const currentPoints = computed(() => Number(balance.value.points || balance.value.point_balance || 0))
const currentCost = computed(() => Number(quote.value.points || 0))
const balanceText = computed(() => {
const cost = currentCost.value
if (!userStore.isLogin) return cost ? `老照片修复 · ${cost}点/次` : '老照片修复'
return cost ? `余额${currentPoints.value}点 · 预计消耗${cost}点` : `余额${currentPoints.value}点`
})
const isBalanceInsufficient = computed(() => userStore.isLogin && currentCost.value > 0 && currentPoints.value < currentCost.value)
const submitText = computed(() => currentCost.value ? `立即修复 · ${currentCost.value}点` : '立即修复')

onLoad(() => {
loadBillingConfig()
})

onShow(() => {
if (userStore.isLogin) loadBalance()
loadBillingConfig()
})

async function loadBalance() {
try {
const res = await userApi.getBalance()
balance.value = res.data || {}
} catch (e) {}
}

async function loadBillingConfig() {
try {
const res = await billingApi.getConfig({ app_key: appKey, image_count: 1 })
const data = res.data || {}
quote.value = data.quote || {}
if (data.balance) balance.value = data.balance
} catch (e) {}
}

function goRecharge() {
uni.navigateTo({ url: '/pages/recharge/recharge' })
}

function toggleKeyword(item) {
const index = selectedKeywords.value.indexOf(item)
if (index >= 0) {
selectedKeywords.value.splice(index, 1)
} else {
selectedKeywords.value.push(item)
}
}

function showTodo(name) {
toast(`${name}逻辑待接入`)
}
</script>

<style lang="scss" scoped>
.page {
height: 100vh;
background: #f3f7fb;
display: flex;
flex-direction: column;
overflow: hidden;
}
.scroll { flex: 1; height: 0; }
.wrap { padding: 24rpx 28rpx 300rpx; }

.hero-card {
position: relative;
min-height: 260rpx;
padding: 30rpx;
border-radius: 28rpx;
color: #fff;
background: linear-gradient(135deg, #172033 0%, #2d5d7d 56%, #5cb7c4 100%);
overflow: hidden;
box-sizing: border-box;
}
.hero-card::before {
content: '';
position: absolute;
right: -90rpx;
top: -90rpx;
width: 280rpx;
height: 280rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
}
.hero-copy,
.hero-preview {
position: relative;
z-index: 1;
}
.eyebrow {
display: block;
color: rgba(255, 255, 255, 0.68);
font-size: 21rpx;
font-weight: 800;
}
.hero-title {
display: block;
margin-top: 16rpx;
color: #fff;
font-size: 40rpx;
font-weight: 900;
}
.hero-desc {
display: block;
margin-top: 14rpx;
width: 430rpx;
color: rgba(255, 255, 255, 0.82);
font-size: 24rpx;
line-height: 1.5;
}
.hero-preview {
position: absolute;
right: 30rpx;
bottom: 28rpx;
width: 170rpx;
height: 116rpx;
}
.photo {
position: absolute;
width: 96rpx;
height: 116rpx;
border-radius: 16rpx;
box-shadow: 0 16rpx 28rpx rgba(0, 0, 0, 0.18);
}
.photo.before {
left: 0;
top: 0;
background: linear-gradient(135deg, #d7d0c6, #6c6259);
}
.photo.after {
right: 0;
bottom: 0;
background: linear-gradient(135deg, #f4d4a0, #5db2d9 50%, #254f6d);
}

.upload-card,
.section {
margin-top: 22rpx;
padding: 28rpx;
border-radius: 24rpx;
background: #fff;
box-shadow: 0 8rpx 28rpx rgba(34, 68, 112, 0.05);
}
.card-head,
.section-head {
display: flex;
align-items: baseline;
}
.card-title,
.tool-section-title {
display: block;
color: #172033;
font-size: 31rpx;
font-weight: 800;
}
.required { margin-right: 4rpx; color: #ef4444; }
.card-desc {
display: block;
margin-top: 8rpx;
color: #8a95a6;
font-size: 23rpx;
line-height: 1.35;
}
.card-tip,
.section-tip {
margin-left: auto;
color: #8a95a6;
font-size: 23rpx;
}
.upload-strip {
margin-top: 22rpx;
min-height: 142rpx;
padding: 18rpx;
border: 2rpx dashed rgba(11, 140, 255, 0.28);
border-radius: 20rpx;
background: #f8fbff;
display: flex;
align-items: center;
box-sizing: border-box;
}
.upload-thumb {
width: 104rpx;
height: 104rpx;
border-radius: 18rpx;
background: #eef6ff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.upload-plus { color: #0b8cff; font-size: 48rpx; line-height: 1; }
.upload-copy { min-width: 0; flex: 1; margin-left: 20rpx; }
.upload-title { display: block; color: #172033; font-size: 29rpx; font-weight: 800; }
.upload-sub { display: block; margin-top: 8rpx; color: #7f8896; font-size: 23rpx; line-height: 1.4; }
.upload-action { margin-left: 18rpx; color: #0b8cff; font-size: 25rpx; font-weight: 800; }

.style-grid {
margin-top: 20rpx;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14rpx;
}
.style-item {
height: 76rpx;
border-radius: 18rpx;
color: #596579;
font-size: 26rpx;
font-weight: 800;
line-height: 76rpx;
text-align: center;
background: #f7f9fd;
border: 2rpx solid transparent;
}
.style-item.active {
color: #0b8cff;
background: #eff8ff;
border-color: #0b8cff;
}
.textarea {
margin-top: 20rpx;
width: 100%;
min-height: 180rpx;
padding: 22rpx;
border-radius: 20rpx;
color: #172033;
font-size: 25rpx;
line-height: 1.5;
background: #f7f9fd;
box-sizing: border-box;
}
.keyword-row {
margin-top: 18rpx;
display: flex;
flex-wrap: wrap;
gap: 14rpx;
}
.keyword-chip {
min-height: 58rpx;
padding: 0 22rpx;
border-radius: 30rpx;
color: #0b8cff;
font-size: 24rpx;
font-weight: 700;
line-height: 58rpx;
background: #eef7ff;
border: 2rpx solid transparent;
box-sizing: border-box;
}
.keyword-chip.active {
color: #fff;
background: #0b8cff;
}
.bottom-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 80;
padding: 18rpx 28rpx calc(18rpx + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 -10rpx 30rpx rgba(32, 56, 92, 0.1);
}
.balance {
margin-bottom: 14rpx;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
color: #7b8797;
font-size: 24rpx;
line-height: 1.35;
}
.balance-copy { min-width: 0; flex: 1; }
.recharge-link { color: #0b8cff; font-size: 24rpx; font-weight: 800; flex-shrink: 0; }
.buttons { display: flex; gap: 22rpx; }
.history-btn,
.submit-btn {
height: 86rpx;
border-radius: 999rpx;
font-size: 30rpx;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
}
.history-btn { width: 210rpx; color: #1f8cff; background: #eef7ff; }
.submit-btn {
flex: 1;
color: #fff;
background: linear-gradient(135deg, #1f8cff, #41b9ff);
box-shadow: 0 12rpx 24rpx rgba(31, 140, 255, 0.28);
}

.submit-icon {
width: 24rpx;
height: 24rpx;
margin-right: 8rpx;
}
</style>

+ 612
- 0
pages/tool/portrait-photo.vue Прегледај датотеку

@@ -0,0 +1,612 @@
<template>
<view class="page">
<scroll-view class="scroll" scroll-y>
<view class="wrap">
<view class="hero-card portrait-hero">
<view class="hero-copy">
<text class="eyebrow">PORTRAIT PHOTO STUDIO</text>
<text class="hero-title">生成你的职业形象照</text>
<text class="hero-desc">从商务形象、普通职业到艺术写真,快速得到适合头像和简历的照片。</text>
</view>
<view class="hero-preview">
<view class="portrait-card-mini main">
<view class="portrait-face"></view>
<view class="portrait-suit"></view>
</view>
<view class="portrait-card-mini sub">
<view class="portrait-face"></view>
<view class="portrait-suit"></view>
</view>
</view>
</view>

<view class="upload-card">
<view class="card-head">
<view>
<text class="card-title"><text class="required">*</text>上传照片</text>
<text class="card-desc">建议选择五官无遮挡、光线均匀的半身照</text>
</view>
<text class="card-tip">0/1</text>
</view>
<view class="upload-strip" @click="showTodo('选择人像照片')">
<view class="upload-thumb"><text class="upload-plus">+</text></view>
<view class="upload-copy">
<text class="upload-title">上传正面清晰人像照片</text>
<text class="upload-sub">支持自拍、半身照,生成商务照、职业照或写真</text>
</view>
<view class="upload-action">上传</view>
</view>
</view>

<view class="section">
<view class="section-head">
<text class="tool-section-title">照片风格</text>
<text class="section-tip">选择生成方向</text>
</view>
<view class="segmented">
<view
v-for="item in styleTabs"
:key="item.key"
class="segment"
:class="{ active: activeStyle === item.key }"
@click="activeStyle = item.key"
>
{{ item.name }}
</view>
</view>

<view v-if="activeStyle === 'business'" class="config-panel">
<view class="sub-block">
<text class="sub-title">纯色背景</text>
<view class="option-grid">
<view v-for="item in businessBg" :key="item.name" class="option" :class="{ 'active-first': item.name === '白色' }">
<view class="color-dot" :class="item.className"></view>{{ item.name }}
</view>
</view>
</view>
<view class="sub-block">
<text class="sub-title">服装</text>
<view class="option-grid two">
<view class="option active-first">正装</view>
<view class="option">衬衫</view>
</view>
</view>
<view class="sub-block">
<text class="sub-title">取景范围</text>
<view class="option-grid three">
<view class="option active-first">半身</view>
<view class="option">全身</view>
<view class="option">胸像</view>
</view>
</view>
</view>

<view v-if="activeStyle === 'career'" class="config-panel">
<view class="sub-block">
<text class="sub-title">背景</text>
<view class="option-grid three">
<view v-for="item in careerBg" :key="item" class="option" :class="{ 'active-first': item === '浅灰' }">{{ item }}</view>
</view>
</view>
<view class="sub-block">
<text class="sub-title">服装</text>
<view class="option-grid">
<view v-for="item in careerOutfit" :key="item" class="option" :class="{ 'active-first': item === '针织衫' }">{{ item }}</view>
</view>
</view>
</view>

<view v-if="activeStyle === 'art'" class="config-panel">
<view class="option-grid three">
<view v-for="item in artStyles" :key="item" class="option" :class="{ 'active-first': item === '古风' }">{{ item }}</view>
</view>
</view>
</view>

<view class="section">
<view class="section-head">
<text class="tool-section-title">图片比例</text>
<text class="section-tip">构图尺寸</text>
</view>
<view class="ratio-grid">
<view
v-for="item in ratios"
:key="item"
class="ratio"
:class="{ active: activeRatio === item }"
@click="activeRatio = item"
>
{{ item }}
</view>
</view>
</view>

<view class="section">
<view class="section-head">
<text class="tool-section-title">生成数量</text>
<text class="price">20点/张,共{{ quantity * 20 }}点</text>
</view>
<view class="quantity">
<view class="quantity-btn" @click="decreaseQuantity">-</view>
<text class="quantity-value">{{ quantity }}</text>
<view class="quantity-btn" @click="increaseQuantity">+</view>
</view>
<text class="note">按单张收费,每次最多生成10张</text>
</view>

<view class="section">
<view class="section-head">
<text class="tool-section-title">补充描述</text>
<text class="section-tip">可选</text>
</view>
<textarea class="textarea" placeholder="可补充职业、服装、背景、妆发或照片气质要求"></textarea>
</view>
</view>
</scroll-view>

<view class="bottom-bar">
<view class="balance">
<text class="balance-copy">{{ balanceText }}</text>
<view v-if="isBalanceInsufficient" class="recharge-link" @click="goRecharge">去充值</view>
</view>
<view class="buttons">
<view class="history-btn" @click="showTodo('历史记录')">历史记录</view>
<view class="submit-btn" @click="showTodo('形象照生成')">
<image class="submit-icon" src="/static/icons/bolt.svg" mode="aspectFit" />
<text>{{ submitText }}</text>
</view>
</view>
</view>
</view>
</template>

<script setup>
import { computed, ref, watch } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { billingApi, userApi } from '@/api/index.js'
import { useUserStore } from '@/store/user.js'
import { useApp } from '@/utils/useApp.js'

const userStore = useUserStore()
const { toast } = useApp()
const activeStyle = ref('business')
const activeRatio = ref('1:1')
const quantity = ref(1)
const balance = ref({ points: 0 })
const quote = ref({ points: 0 })
const appKey = 'portrait-photo'

const styleTabs = [
{ key: 'business', name: '商务形象照' },
{ key: 'career', name: '普通职业照' },
{ key: 'art', name: '艺术写真' }
]
const businessBg = [
{ name: '白色', className: 'white' },
{ name: '浅灰', className: 'gray' },
{ name: '商务蓝', className: 'blue' },
{ name: '深蓝', className: 'deep-blue' }
]
const careerBg = ['浅灰', '米色', '办公室', '书架', '咖啡角', '落地窗']
const careerOutfit = ['针织衫', 'T恤配牛仔裤', '西装外套配T恤']
const artStyles = ['古风', '藏族', '苗族', '都市青年', '潮人', '敦煌飞仙', '港风复古', '新中式', '法式油画']
const ratios = ['1:1', '3:4', '4:3', '9:16', '16:9']
const portraitModel = computed(() => activeStyle.value === 'art' ? 'premium' : 'standard')
const currentPoints = computed(() => Number(balance.value.points || balance.value.point_balance || 0))
const currentCost = computed(() => Number(quote.value.points || 0))
const balanceText = computed(() => {
const cost = currentCost.value
if (!userStore.isLogin) return cost ? `形象照 · ${cost}点/次` : '形象照'
return cost ? `余额${currentPoints.value}点 · 预计消耗${cost}点` : `余额${currentPoints.value}点`
})
const isBalanceInsufficient = computed(() => userStore.isLogin && currentCost.value > 0 && currentPoints.value < currentCost.value)
const submitText = computed(() => currentCost.value ? `立即生成 · ${currentCost.value}点` : '立即生成')

onLoad(() => {
loadBillingConfig()
})

onShow(() => {
if (userStore.isLogin) loadBalance()
loadBillingConfig()
})

watch([activeStyle, quantity], () => {
loadBillingConfig()
})

async function loadBalance() {
try {
const res = await userApi.getBalance()
balance.value = res.data || {}
} catch (e) {}
}

async function loadBillingConfig() {
try {
const res = await billingApi.getConfig({
app_key: appKey,
model: portraitModel.value,
count: quantity.value
})
const data = res.data || {}
quote.value = data.quote || {}
if (data.balance) balance.value = data.balance
} catch (e) {}
}

function goRecharge() {
uni.navigateTo({ url: '/pages/recharge/recharge' })
}

function showTodo(name) {
toast(`${name}逻辑待接入`)
}

function decreaseQuantity() {
quantity.value = Math.max(1, quantity.value - 1)
}

function increaseQuantity() {
quantity.value = Math.min(10, quantity.value + 1)
}
</script>

<style lang="scss" scoped>
.page {
height: 100vh;
background: #f3f7fb;
display: flex;
flex-direction: column;
overflow: hidden;
}
.scroll { flex: 1; height: 0; }
.wrap { padding: 24rpx 28rpx 300rpx; }

.hero-card {
position: relative;
min-height: 260rpx;
padding: 30rpx;
border-radius: 28rpx;
color: #fff;
background: linear-gradient(135deg, #2447d8 0%, #0b8cff 56%, #55d6c6 100%);
overflow: hidden;
box-sizing: border-box;
}
.hero-card::before {
content: '';
position: absolute;
right: -90rpx;
top: -110rpx;
width: 300rpx;
height: 300rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.16);
}
.hero-copy,
.hero-preview {
position: relative;
z-index: 1;
}
.eyebrow {
display: block;
color: rgba(255, 255, 255, 0.72);
font-size: 21rpx;
font-weight: 800;
}
.hero-title {
display: block;
margin-top: 16rpx;
width: 430rpx;
color: #fff;
font-size: 40rpx;
font-weight: 900;
line-height: 1.24;
}
.hero-desc {
display: block;
margin-top: 14rpx;
width: 420rpx;
color: rgba(255, 255, 255, 0.86);
font-size: 24rpx;
line-height: 1.5;
}
.hero-preview {
position: absolute;
right: 30rpx;
bottom: 28rpx;
width: 178rpx;
height: 126rpx;
}
.portrait-card-mini {
position: absolute;
width: 92rpx;
height: 120rpx;
border-radius: 18rpx;
overflow: hidden;
box-shadow: 0 16rpx 30rpx rgba(0, 54, 125, 0.22);
}
.portrait-card-mini.main {
left: 0;
top: 0;
background: linear-gradient(180deg, #e8f4ff 0%, #9bd0ff 100%);
}
.portrait-card-mini.sub {
right: 0;
bottom: 0;
background: linear-gradient(180deg, #fff4e7 0%, #ebb875 100%);
}
.portrait-face {
width: 38rpx;
height: 42rpx;
margin: 22rpx auto 0;
border-radius: 50% 50% 45% 45%;
background: #ffe0c4;
}
.portrait-suit {
width: 70rpx;
height: 48rpx;
margin: 4rpx auto 0;
border-radius: 30rpx 30rpx 12rpx 12rpx;
background: #1f3a5f;
}

.upload-card,
.section {
margin-top: 22rpx;
padding: 28rpx;
border-radius: 24rpx;
background: #fff;
box-shadow: 0 8rpx 28rpx rgba(34, 68, 112, 0.05);
}
.upload-card:first-child { margin-top: 0; }

.card-head,
.section-head {
display: flex;
align-items: baseline;
}
.card-title,
.tool-section-title {
display: block;
color: #172033;
font-size: 31rpx;
font-weight: 800;
}
.required { margin-right: 4rpx; color: #ef4444; }
.card-desc {
display: block;
margin-top: 8rpx;
color: #8a95a6;
font-size: 23rpx;
line-height: 1.35;
}
.card-tip,
.section-tip {
margin-left: auto;
color: #8a95a6;
font-size: 23rpx;
}
.upload-strip {
margin-top: 22rpx;
min-height: 142rpx;
padding: 18rpx;
border: 2rpx dashed rgba(11, 140, 255, 0.28);
border-radius: 20rpx;
background: #f8fbff;
display: flex;
align-items: center;
box-sizing: border-box;
}
.upload-thumb {
width: 104rpx;
height: 104rpx;
border-radius: 18rpx;
background: #eef6ff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.upload-plus { color: #0b8cff; font-size: 48rpx; line-height: 1; }
.upload-copy { min-width: 0; flex: 1; margin-left: 20rpx; }
.upload-title { display: block; color: #172033; font-size: 29rpx; font-weight: 800; }
.upload-sub { display: block; margin-top: 8rpx; color: #7f8896; font-size: 23rpx; line-height: 1.4; }
.upload-action { margin-left: 18rpx; color: #0b8cff; font-size: 25rpx; font-weight: 800; }

.segmented {
margin-top: 22rpx;
padding: 6rpx;
border-radius: 18rpx;
background: #f7f9fd;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6rpx;
}
.segment {
height: 66rpx;
border-radius: 16rpx;
color: #596579;
font-size: 24rpx;
font-weight: 800;
line-height: 66rpx;
text-align: center;
}
.segment.active {
color: #0b8cff;
background: #fff;
box-shadow: 0 8rpx 18rpx rgba(11, 140, 255, 0.12);
}
.config-panel {
margin-top: 24rpx;
padding-top: 22rpx;
border-top: 1rpx solid #edf2f7;
}
.sub-block + .sub-block { margin-top: 24rpx; }
.sub-title {
display: block;
margin-bottom: 14rpx;
color: #7f8896;
font-size: 24rpx;
font-weight: 800;
}
.option-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14rpx;
}
.option-grid.three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.option-grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.option {
min-height: 66rpx;
padding: 0 12rpx;
border-radius: 16rpx;
color: #596579;
font-size: 24rpx;
font-weight: 800;
background: #f7f9fd;
border: 2rpx solid transparent;
display: flex;
align-items: center;
justify-content: center;
gap: 10rpx;
text-align: center;
box-sizing: border-box;
}
.option.active-first {
color: #0b8cff;
background: #eff8ff;
border-color: #0b8cff;
}
.color-dot {
width: 28rpx;
height: 28rpx;
border-radius: 50%;
border: 1rpx solid rgba(15, 23, 42, 0.16);
flex-shrink: 0;
}
.white { background: #fff; }
.gray { background: #d9dde3; }
.blue { background: #6aa7e8; }
.deep-blue { background: #245a9c; }

.ratio-grid {
margin-top: 20rpx;
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 10rpx;
}
.ratio {
height: 64rpx;
border-radius: 16rpx;
color: #596579;
font-size: 24rpx;
font-weight: 800;
line-height: 64rpx;
text-align: center;
background: #f7f9fd;
border: 2rpx solid transparent;
}
.ratio.active {
color: #0b8cff;
background: #eff8ff;
border-color: #0b8cff;
}
.price {
margin-left: auto;
color: #ff681f;
font-size: 24rpx;
font-weight: 800;
}
.quantity {
margin-top: 22rpx;
display: grid;
grid-template-columns: 80rpx minmax(0, 1fr) 80rpx;
gap: 18rpx;
align-items: center;
}
.quantity-btn {
height: 72rpx;
border-radius: 16rpx;
color: #fff;
background: #0b8cff;
font-size: 40rpx;
font-weight: 800;
line-height: 72rpx;
text-align: center;
}
.quantity-value {
height: 72rpx;
border-radius: 16rpx;
color: #172033;
background: #f7f9fd;
font-size: 32rpx;
font-weight: 800;
line-height: 72rpx;
text-align: center;
}
.note {
display: block;
margin-top: 14rpx;
color: #8a95a6;
font-size: 23rpx;
}
.textarea {
margin-top: 20rpx;
width: 100%;
min-height: 144rpx;
padding: 22rpx;
border-radius: 20rpx;
color: #172033;
font-size: 25rpx;
line-height: 1.5;
background: #f7f9fd;
box-sizing: border-box;
}
.bottom-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 80;
padding: 18rpx 28rpx calc(18rpx + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 -10rpx 30rpx rgba(32, 56, 92, 0.1);
}
.balance {
margin-bottom: 14rpx;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
color: #7b8797;
font-size: 24rpx;
line-height: 1.35;
}
.balance-copy { min-width: 0; flex: 1; }
.recharge-link { color: #0b8cff; font-size: 24rpx; font-weight: 800; flex-shrink: 0; }
.buttons { display: flex; gap: 22rpx; }
.history-btn,
.submit-btn {
height: 86rpx;
border-radius: 999rpx;
font-size: 30rpx;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
}
.history-btn { width: 210rpx; color: #1f8cff; background: #eef7ff; }
.submit-btn {
flex: 1;
color: #fff;
background: linear-gradient(135deg, #1f8cff, #41b9ff);
box-shadow: 0 12rpx 24rpx rgba(31, 140, 255, 0.28);
}

.submit-icon {
width: 24rpx;
height: 24rpx;
margin-right: 8rpx;
}
</style>

+ 311
- 0
pages/tool/work-detail.vue Прегледај датотеку

@@ -0,0 +1,311 @@
<template>
<view class="page">
<scroll-view class="scroll" scroll-y>
<view class="wrap">
<view v-if="isLoadingState" class="state-card">
<view v-if="detail.is_suc === 0" class="spinner"></view>
<text class="state-title">{{ detail.is_suc === 0 ? '创作中,请耐心等待' : '生成失败' }}</text>
<text v-if="detail.is_suc === 0 && countDown > 0" class="count">{{ countDown }} S</text>
<text v-if="detail.is_suc < 0" class="error">{{ detail.content }}</text>
</view>

<view v-else class="image-card" @click="preview">
<image class="work-img" :src="detail.picUrl" mode="widthFix" />
<view v-if="detail.picList && detail.picList.length > 1" class="num">{{ detail.picList.length }}</view>
</view>

<view class="info-card">
<view class="title">作品信息</view>
<view v-if="detail.prompts" class="cell">
<text class="cell-label">画面描述:</text>
<text class="cell-content" user-select>{{ detail.prompts }}</text>
</view>
<view class="cell">
<text class="cell-label">作者:</text>
<text class="cell-content">{{ detail.author_name || '-' }}</text>
</view>
<view class="cell">
<text class="cell-label">作品来源:</text>
<text class="link" @click="createAgain">{{ detail.ai_type_name || aiTypeName }}</text>
</view>
</view>
</view>
</scroll-view>

<view v-if="!isLoadingState" class="bottom-bar">
<view class="bar-btn" @click="download">下载</view>
<button class="bar-btn" open-type="share">分享</button>
<view class="main-btn" @click="createAgain">再画一张</view>
</view>
</view>
</template>

<script setup>
import { computed, ref } from 'vue'
import { onLoad, onShareAppMessage, onUnload } from '@dcloudio/uni-app'
import { toolApi } from '@/api/index.js'
import { useApp } from '@/utils/useApp.js'

const { toast } = useApp()
const id = ref('')
const imgIndex = ref(0)
const from = ref('')
const detail = ref({ picList: [], is_suc: 0 })
const countDown = ref(0)
let timer = 0
let countTimer = 0

const aiTypeName = computed(() => {
const map = { 3: '动漫头像', 7: '动漫头像', 8: '动漫头像', 2: 'MJ绘画', 6: 'AI绘画' }
return map[detail.value.ai_type] || 'AI绘画'
})

const isLoadingState = computed(() => [0, -1, -2].includes(Number(detail.value.is_suc)))

onLoad((options) => {
const parts = String(options.id || '').split('_')
id.value = parts[0] || ''
imgIndex.value = Number(parts[1] || 0)
from.value = options.from || ''
loadDetail()
})

onUnload(() => {
clearTimeout(timer)
clearInterval(countTimer)
})

onShareAppMessage(() => ({
title: '推荐给你看一个 AI 绘画作品',
path: `/pages/tool/work-detail?id=${id.value}_0`,
imageUrl: detail.value.picUrl || ''
}))

async function loadDetail() {
if (!id.value) return
try {
const api = from.value === 'anime' ? toolApi.getAnimeDetail : toolApi.getWorkDetail
const res = await api({ chat_open_id: id.value })
const data = res.data || {}
const pics = buildPicList(data)
detail.value = {
...data,
picList: pics,
picUrl: pics[imgIndex.value] || pics[0] || data.content || '',
ai_type_name: data.ai_type_name || aiTypeName.value
}
if (Number(detail.value.is_suc) === 0) waitNext()
} catch (e) {
toast(e.msg || '作品详情加载失败')
}
}

function buildPicList(data) {
if (Array.isArray(data.picList) && data.picList.length) return data.picList
if (data.imgs) return String(data.imgs).split(',').filter(Boolean)
if (data.content && String(data.content).includes(',')) return String(data.content).split(',').filter(Boolean)
return data.content ? [data.content] : []
}

function waitNext() {
clearTimeout(timer)
clearInterval(countTimer)
countDown.value = 3
countTimer = setInterval(() => {
countDown.value -= 1
if (countDown.value <= 0) clearInterval(countTimer)
}, 1000)
timer = setTimeout(loadDetail, 3000)
}

function preview() {
const urls = detail.value.picList || []
if (!urls.length) return
uni.previewImage({ urls, current: detail.value.picUrl })
}

function download() {
if (!detail.value.picUrl) return
uni.previewImage({ urls: [detail.value.picUrl] })
}

function createAgain() {
const delta = getPageStackDelta('pages/tool/anime-avatar')
if (delta > 0) {
if (id.value) uni.setStorageSync('anime_copy_id', id.value)
uni.navigateBack({ delta })
return
}
uni.navigateTo({ url: `/pages/tool/anime-avatar?copy_id=${id.value}` })
}

function getPageStackDelta(routeName) {
const pages = getCurrentPages()
const index = pages.findIndex((page) => page.route === routeName)
return index >= 0 ? pages.length - 1 - index : -1
}
</script>

<style lang="scss" scoped>
.page {
height: 100vh;
background: #f4f7fb;
display: flex;
flex-direction: column;
overflow: hidden;
}

.scroll {
flex: 1;
height: 0;
}

.wrap {
padding: 28rpx 28rpx 150rpx;
}

.state-card,
.image-card,
.info-card {
background: #fff;
border-radius: 18rpx;
overflow: hidden;
}

.state-card {
min-height: 540rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #172033;
font-size: 30rpx;
}

.spinner {
width: 72rpx;
height: 72rpx;
border: 8rpx solid #e9edf5;
border-top-color: #0b8cff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 28rpx;
}

.count,
.error {
margin-top: 18rpx;
color: #7f8896;
font-size: 26rpx;
}

.error {
color: #f04438;
padding: 0 40rpx;
text-align: center;
}

.work-img {
width: 100%;
display: block;
}

.image-card {
position: relative;
}

.num {
position: absolute;
right: 18rpx;
bottom: 18rpx;
min-width: 44rpx;
height: 44rpx;
border-radius: 22rpx;
color: #fff;
font-size: 24rpx;
line-height: 44rpx;
text-align: center;
background: rgba(0, 0, 0, 0.45);
}

.info-card {
margin-top: 24rpx;
padding: 28rpx;
}

.title {
color: #172033;
font-size: 32rpx;
font-weight: 700;
margin-bottom: 16rpx;
}

.cell {
display: flex;
color: #7f8896;
font-size: 27rpx;
line-height: 1.6;
padding: 10rpx 0;
}

.cell-label {
flex-shrink: 0;
}

.cell-content {
flex: 1;
min-width: 0;
color: #172033;
}

.link {
color: #0b8cff;
}

.bottom-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 18rpx 28rpx calc(18rpx + env(safe-area-inset-bottom));
background: #fff;
display: flex;
gap: 18rpx;
box-shadow: 0 -8rpx 24rpx rgba(35, 55, 90, 0.08);
}

.bar-btn,
.main-btn {
height: 82rpx;
border-radius: 42rpx;
font-size: 29rpx;
margin: 0;
padding: 0;
border: 0;
display: flex;
align-items: center;
justify-content: center;
}

.bar-btn::after {
border: 0;
}

.bar-btn {
width: 150rpx;
color: #0b8cff;
background: #eef6ff;
}

.main-btn {
flex: 1;
color: #fff;
background: #0b8cff;
}

@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

+ 342
- 0
pages/toolbox/toolbox.vue Прегледај датотеку

@@ -0,0 +1,342 @@
<template>
<view class="toolbox">
<ai-nav-title title="AI在线-解放你的生产力" />

<scroll-view
class="page-scroll"
scroll-y
refresher-enabled
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
>
<view class="toolbox-content">
<swiper class="hero" circular autoplay :interval="4000" @change="onBannerChange">
<swiper-item v-for="(banner, i) in banners" :key="i" @click="onTool(banner)">
<image class="hero-img" :src="banner.image" mode="aspectFill" />
</swiper-item>
</swiper>
<view v-if="banners.length" class="hero-indicator">
<view
v-for="(banner, i) in banners"
:key="banner.id || i"
class="indicator-bar"
:class="{ active: i === bannerIndex }"
></view>
</view>

<view v-if="recommends.length" class="recommend">
<view v-for="item in recommends" :key="item.id || item.type" class="recommend-card" @click="onTool(item)">
<image class="recommend-img" :src="item.image" mode="aspectFill" />
</view>
</view>

<view class="tool-list">
<view v-for="item in apps" :key="item.id || item.type || item.title" class="tool-row" @click="onTool(item)">
<image class="tool-icon" :src="item.icon" mode="aspectFit" />
<view class="tool-main">
<text class="tool-name">{{ item.name }}</text>
<text class="tool-desc">{{ item.desc }}</text>
</view>
<text class="chevron">›</text>
</view>
</view>
</view>
</scroll-view>
</view>
</template>

<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { commonApi } from '@/api/index.js'
import { useApp } from '@/utils/useApp.js'

const { toast } = useApp()
const refreshing = ref(false)
const bannerIndex = ref(0)

const defaultBanners = [
{
image: '/static/toolbox/home_banner_note17.png',
title: 'Note 17 系列',
link_type: 'none',
type: ''
}
]

const defaultRecommends = [
{
name: 'AI动漫头像',
title: 'AI动漫头像',
image: '/static/toolbox/home_card_avatar.png',
link_type: 'app',
type: 'anime-avatar'
},
{
name: 'AI取名',
title: 'AI取名',
image: '/static/toolbox/home_card_name.png',
link_type: 'app',
type: 'ai-name'
}
]

const defaultApps = [
{
icon: '/static/toolbox/tool_id_photo.png',
name: 'AI证件照/签证照',
title: 'AI证件照/签证照',
desc: '各类规格证件照、免冠照、换底色换正装',
subtitle: '各类规格证件照、免冠照、换底色换正装',
link_type: 'app',
type: 'id-photo'
},
{
icon: '/static/toolbox/tool_portrait.png',
name: 'AI形象照/职业照/写真',
title: 'AI形象照/职业照/写真',
desc: '上传自拍生成职业照、艺术写真、古风',
subtitle: '上传自拍生成职业照、艺术写真、古风',
link_type: 'app',
type: 'portrait-photo'
},
{
icon: '/static/toolbox/tool_id_photo.png',
name: '老照片上色',
title: '老照片上色',
desc: '模糊破损老照片修复、黑白照片自然上色',
subtitle: '模糊破损老照片修复、黑白照片自然上色',
link_type: 'app',
type: 'old-photo'
}
]

const banners = ref(defaultBanners)
const recommends = ref(defaultRecommends)
const apps = ref(defaultApps)

onLoad(() => {
loadHomeConfig()
})

async function loadHomeConfig() {
try {
const res = await commonApi.getHomeConfig()
const data = res.data || {}
const nextBanners = normalizeBanners(data.banners)
const nextRecommends = normalizeApps(data.recommends)
const nextApps = normalizeApps(data.apps)
if (nextBanners.length) banners.value = nextBanners
if (nextRecommends.length) recommends.value = nextRecommends
if (nextApps.length) apps.value = nextApps
bannerIndex.value = 0
} catch (e) {}
}

function normalizeBanners(list) {
if (!Array.isArray(list)) return []
return list
.filter(item => item && item.image)
.map(item => ({
...item,
name: item.title,
type: item.type || item.app_key || (item.link_type === 'app' ? item.link_url : '')
}))
}

function normalizeApps(list) {
if (!Array.isArray(list)) return []
return list
.filter(item => item && (item.image || item.icon))
.map(item => ({
...item,
name: item.name || item.title,
desc: item.desc || item.subtitle,
type: item.type || item.app_key || (item.link_type === 'app' ? item.link_url : '')
}))
}

function onTool(item) {
if (!item || item.link_type === 'none') return
if (item.soon) {
if (item && (item.name || item.title)) toast(`${item.name || item.title} 即将上线`)
return
}

if (item.link_type === 'page' && item.link_url) {
uni.navigateTo({ url: item.link_url })
return
}

if (item.link_type === 'web' && item.link_url) {
uni.navigateTo({ url: `/pages/webview/webview?url=${encodeURIComponent(item.link_url)}&title=${encodeURIComponent(item.name || item.title || '')}` })
return
}

if (!item.type) {
toast(`${item.name || item.title} 即将上线`)
return
}
const routeMap = {
'anime-avatar': '/pages/tool/anime-avatar',
'ai-name': '/pages/tool/ai-name',
'id-photo': '/pages/tool/id-photo',
'portrait-photo': '/pages/tool/portrait-photo',
'old-photo': '/pages/tool/old-photo'
}
if (routeMap[item.type]) {
uni.navigateTo({ url: routeMap[item.type] })
return
}
uni.navigateTo({ url: `/pages/tool/detail?type=${item.type}&title=${encodeURIComponent(item.name || item.title)}` })
}

function onBannerChange(e) {
bannerIndex.value = e.detail.current || 0
}

function onRefresh() {
refreshing.value = true
loadHomeConfig().finally(() => {
refreshing.value = false
})
}
</script>

<style lang="scss" scoped>
.toolbox {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
background: #f3f3f3;
}

.page-scroll {
flex: 1;
height: 0;
}

.toolbox-content {
min-height: 100vh;
padding: 30rpx 30rpx 96rpx;
box-sizing: border-box;
background: #f3f3f3;
}

.hero {
width: 100%;
height: 341rpx;
border-radius: 21rpx;
overflow: hidden;
}

.hero-img {
display: block;
width: 100%;
height: 100%;
}

.hero-indicator {
height: 8rpx;
margin-top: -30rpx;
margin-bottom: 22rpx;
position: relative;
z-index: 2;
display: flex;
justify-content: center;
gap: 8rpx;
pointer-events: none;
}

.indicator-bar {
width: 22rpx;
height: 6rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.5);
transition: width 0.2s ease, background 0.2s ease;
}

.indicator-bar.active {
width: 50rpx;
background: #ffffff;
}

.recommend {
display: flex;
gap: 16rpx;
margin-top: 22rpx;
}

.recommend-card {
flex: 1;
min-width: 0;
height: 195rpx;
border-radius: 19rpx;
overflow: visible;
}

.recommend-img {
width: 100%;
height: 195rpx;
border-radius: 19rpx;
display: block;
}

.tool-list {
margin-top: 24rpx;
}

.tool-row {
height: 169rpx;
padding: 0 40rpx 0 44rpx;
border-radius: 21rpx;
background: #ffffff;
display: flex;
align-items: center;
box-sizing: border-box;

& + .tool-row {
margin-top: 19rpx;
}
}

.tool-icon {
width: 91rpx;
height: 91rpx;
flex-shrink: 0;
}

.tool-main {
min-width: 0;
flex: 1;
margin-left: 35rpx;
display: flex;
flex-direction: column;
}

.tool-name {
color: #333333;
font-size: 32rpx;
font-weight: 400;
line-height: 1.2;
}

.tool-desc {
margin-top: 18rpx;
color: #999999;
font-size: 25rpx;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

.chevron {
margin-left: 16rpx;
color: #c9c9c9;
font-size: 54rpx;
font-weight: 200;
line-height: 1;
}
</style>

+ 15
- 0
pages/webview/webview.vue Прегледај датотеку

@@ -0,0 +1,15 @@
<template>
<web-view v-if="url" :src="url" />
</template>

<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'

const url = ref('')

onLoad((options) => {
if (options.url) url.value = decodeURIComponent(options.url)
if (options.title) uni.setNavigationBarTitle({ title: decodeURIComponent(options.title) })
})
</script>

+ 595
- 0
pages/works/works.vue Прегледај датотеку

@@ -0,0 +1,595 @@
<template>
<view class="works">
<ai-nav-title title="我的作品" />

<scroll-view
class="page-scroll"
scroll-y
refresher-enabled
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
@scrolltolower="loadMore"
>
<view class="works-content">
<view class="tabs">
<view
v-for="item in tabs"
:key="item.key"
class="tab"
:class="{ active: activeTab === item.key }"
@click="changeTab(item.key)"
>
<text class="tab-name">{{ item.name }}</text>
<text class="tab-count">{{ counts[item.key] || 0 }}</text>
</view>
</view>

<view v-if="list.length" class="works-grid">
<view
v-for="item in list"
:key="item.id"
class="work-card"
:class="{ 'name-work': item.work_type === 'ainame' }"
@click="openWork(item)"
>
<view v-if="item.work_type === 'ainame'" class="name-preview">
<view class="card-top">
<text class="type-chip name">AI取名</text>
<text class="status-chip" :class="statusClass(item)">{{ item.status_text }}</text>
</view>
<view class="name-list">
<text v-for="name in item.names" :key="name" class="name-chip">{{ name }}</text>
<text v-if="!item.names || !item.names.length" class="name-placeholder">名字整理中</text>
</view>
<text class="name-summary">{{ item.summary || item.subtitle }}</text>
</view>

<view v-else class="image-preview">
<image v-if="item.cover" class="cover" :src="item.cover" mode="aspectFill" />
<view v-else class="cover-placeholder">
<view v-if="Number(item.status) === 0" class="spinner"></view>
<text>{{ Number(item.status) === 0 ? '生成中' : '暂无封面' }}</text>
</view>
<view class="image-mask"></view>
<view class="card-top overlay">
<text class="type-chip">{{ typeText(item.work_type) }}</text>
<text class="status-chip" :class="statusClass(item)">{{ item.status_text }}</text>
</view>
<view v-if="Number(item.status) === 0" class="pending-mask">
<view class="spinner light"></view>
<text>正在创作</text>
</view>
</view>

<view class="card-body">
<text class="work-time">{{ formatTime(item.add_time) }}</text>
</view>
</view>
</view>

<view v-if="!list.length && !loading" class="empty-state">
<view class="empty-icon">
<view></view>
<view></view>
<view></view>
<view></view>
</view>
<text class="empty-title">{{ userStore.isLogin ? '还没有作品' : '登录后查看作品' }}</text>
<text class="empty-desc">
{{ userStore.isLogin ? '去工具箱生成第一张头像、照片或名字方案。' : '登录后会同步你的作品、点数和创作记录。' }}
</text>
<view class="empty-action" @click="handleEmptyAction">
{{ userStore.isLogin ? '去创作' : '去登录' }}
</view>
</view>

<view v-if="loading" class="loading">{{ list.length ? '加载更多...' : '加载中...' }}</view>
<view v-if="list.length && list.length >= total && !loading" class="end-text">没有更多了</view>
</view>
</scroll-view>
</view>
</template>

<script setup>
import { computed, ref } from 'vue'
import { onHide, onShow, onUnload } from '@dcloudio/uni-app'
import dayjs from 'dayjs'
import { toolApi } from '@/api/index.js'
import { useUserStore } from '@/store/user.js'
import { useApp } from '@/utils/useApp.js'

const userStore = useUserStore()
const { toast } = useApp()
const activeTab = ref('all')
const list = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = 10
const loading = ref(false)
const refreshing = ref(false)
const counts = ref({ all: 0, ainame: 0, anime: 0, image: 0, photo: 0 })
let timer = 0

const tabs = computed(() => [
{ key: 'all', name: '全部' },
{ key: 'ainame', name: 'AI取名' },
{ key: 'anime', name: '动漫头像' },
{ key: 'photo', name: '证件照' }
])

onShow(() => {
refreshSilent()
})

onHide(() => clearTimer())
onUnload(() => clearTimer())

function formatTime(value) {
if (!value) return ''
return dayjs(Number(value) * 1000).format('YYYY-MM-DD HH:mm')
}

async function fetchList(reset = false, silent = false) {
if (!userStore.isLogin) {
list.value = []
total.value = 0
refreshing.value = false
uni.stopPullDownRefresh()
return
}
if (loading.value) {
if (refreshing.value) uni.stopPullDownRefresh()
return
}
loading.value = true
if (reset) page.value = 1
try {
const res = await toolApi.getMyWorkList({
type: activeTab.value,
page_no: page.value,
page_size: pageSize
})
const rows = res.list || []
const data = res.data || {}
list.value = reset ? rows : list.value.concat(rows)
total.value = Number(data.total || rows.length)
counts.value = normalizeCounts(data.counts || {})
scheduleRefresh()
} catch (e) {
if (!silent) toast(e.msg || '作品加载失败')
} finally {
loading.value = false
refreshing.value = false
uni.stopPullDownRefresh()
}
}

function normalizeCounts(data = {}) {
return {
all: Number(data.all || 0),
ainame: Number(data.ainame || 0),
anime: Number(data.anime || 0),
image: Number(data.image || 0),
photo: Number(data.photo || 0)
}
}

function refreshSilent(silent = false) {
fetchList(true, silent)
}

function onRefresh() {
refreshing.value = true
refreshSilent()
}

function loadMore() {
if (loading.value || list.value.length >= total.value) return
page.value += 1
fetchList()
}

function changeTab(key) {
if (activeTab.value === key) return
activeTab.value = key
refreshSilent()
}

function scheduleRefresh() {
clearTimer()
if (list.value.some((item) => Number(item.status) === 0)) {
timer = setTimeout(() => refreshSilent(true), 3000)
}
}

function clearTimer() {
clearTimeout(timer)
timer = 0
}

function openWork(item) {
if (!item.detail_url) return
uni.navigateTo({ url: item.detail_url })
}

function statusClass(item) {
const status = Number(item.status)
if (status === 0) return 'pending'
if (status < 0) return 'failed'
return 'done'
}

function typeText(type) {
const map = {
ainame: 'AI取名',
anime: '动漫头像',
image: 'AI作品',
photo: '证件照'
}
return map[type] || 'AI作品'
}

function goCreate() {
uni.switchTab({ url: '/pages/toolbox/toolbox' })
}

function goLogin() {
uni.navigateTo({ url: '/pages/login/login' })
}

function handleEmptyAction() {
if (userStore.isLogin) {
goCreate()
return
}
goLogin()
}
</script>

<style lang="scss" scoped>
.works {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
background: #f6f8fb;
}

.page-scroll {
flex: 1;
height: 0;
}

.works-content {
min-height: 100%;
padding: 0 24rpx calc(132rpx + env(safe-area-inset-bottom));
background: #f6f8fb;
box-sizing: border-box;
}

.tabs {
position: sticky;
top: 0;
z-index: 3;
height: 88rpx;
margin: 0 -24rpx;
padding: 0 24rpx;
border-top: 1rpx solid #eef1f6;
background: rgba(255, 255, 255, 0.96);
display: flex;
align-items: center;
gap: 14rpx;
box-sizing: border-box;
}

.tab {
height: 56rpx;
padding: 0 20rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
gap: 8rpx;
color: #687386;
background: #f4f7fb;
box-sizing: border-box;
flex-shrink: 0;

&.active {
color: #0b8cff;
background: #eaf5ff;
}
}

.tab-name {
font-size: 25rpx;
font-weight: 800;
line-height: 1;
}

.tab-count {
min-width: 30rpx;
height: 30rpx;
padding: 0 8rpx;
border-radius: 999rpx;
color: #8a95a8;
background: #ffffff;
font-size: 19rpx;
font-weight: 800;
line-height: 30rpx;
text-align: center;
box-sizing: border-box;
}

.tab.active .tab-count {
color: #0b8cff;
}

.works-grid {
padding-top: 22rpx;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18rpx;
}

.work-card {
min-width: 0;
border-radius: 22rpx;
background: #ffffff;
overflow: hidden;
box-shadow: 0 10rpx 28rpx rgba(34, 68, 112, 0.06);
}

.image-preview {
position: relative;
width: 100%;
aspect-ratio: 1 / 1;
background: #eef3f8;
overflow: hidden;
}

.cover {
width: 100%;
height: 100%;
display: block;
}

.cover-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #8a95a8;
font-size: 24rpx;
font-weight: 800;
}

.image-mask {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 110rpx;
background: linear-gradient(180deg, rgba(9, 16, 28, 0.36), rgba(9, 16, 28, 0));
z-index: 1;
}

.card-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
}

.card-top.overlay {
position: absolute;
left: 12rpx;
right: 12rpx;
top: 12rpx;
z-index: 3;
}

.type-chip,
.status-chip {
height: 36rpx;
padding: 0 12rpx;
border-radius: 999rpx;
font-size: 19rpx;
font-weight: 900;
line-height: 36rpx;
flex-shrink: 0;
}

.type-chip {
color: #172033;
background: rgba(255, 255, 255, 0.9);
}

.type-chip.name {
color: #0b8cff;
background: #eaf5ff;
}

.status-chip.done {
color: #16a15d;
background: #ecfbf3;
}

.status-chip.pending {
color: #ff8a00;
background: #fff5e8;
}

.status-chip.failed {
color: #f04438;
background: #fff0ef;
}

.pending-mask {
position: absolute;
inset: 0;
z-index: 2;
background: rgba(9, 16, 28, 0.42);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 24rpx;
font-weight: 800;
}

.spinner {
width: 44rpx;
height: 44rpx;
margin-bottom: 14rpx;
border: 6rpx solid rgba(11, 140, 255, 0.18);
border-top-color: #0b8cff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}

.spinner.light {
border-color: rgba(255, 255, 255, 0.35);
border-top-color: #ffffff;
}

.name-preview {
min-height: 278rpx;
padding: 18rpx 18rpx 10rpx;
background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%);
box-sizing: border-box;
}

.name-list {
min-height: 128rpx;
margin-top: 22rpx;
display: flex;
flex-wrap: wrap;
gap: 12rpx 10rpx;
align-content: flex-start;
}

.name-chip {
max-width: 100%;
height: 48rpx;
padding: 0 14rpx;
border-radius: 999rpx;
color: #0b8cff;
background: #eaf5ff;
font-size: 26rpx;
font-weight: 900;
line-height: 48rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
box-sizing: border-box;
}

.name-placeholder {
color: #8a95a8;
font-size: 24rpx;
line-height: 44rpx;
}

.name-summary {
display: -webkit-box;
margin-top: 4rpx;
color: #687386;
font-size: 23rpx;
line-height: 1.45;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}

.card-body {
padding: 12rpx 16rpx 16rpx;
}

.work-time {
display: block;
color: #a0a8b8;
font-size: 20rpx;
line-height: 1;
}

.empty-state {
min-height: 760rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}

.empty-icon {
width: 112rpx;
height: 112rpx;
padding: 18rpx;
border-radius: 34rpx;
background: #ffffff;
box-shadow: 0 10rpx 28rpx rgba(34, 68, 112, 0.06);
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10rpx;
box-sizing: border-box;

view {
border-radius: 12rpx;
background: #dceeff;
}

view:nth-child(2),
view:nth-child(3) {
background: #b9ddff;
}
}

.empty-title {
margin-top: 28rpx;
color: #172033;
font-size: 32rpx;
font-weight: 900;
line-height: 1;
}

.empty-desc {
width: 520rpx;
max-width: 82%;
margin-top: 16rpx;
color: #7f8896;
font-size: 25rpx;
line-height: 1.5;
}

.empty-action {
height: 72rpx;
min-width: 180rpx;
margin-top: 34rpx;
padding: 0 36rpx;
border-radius: 999rpx;
color: #ffffff;
background: #0b8cff;
font-size: 27rpx;
font-weight: 900;
line-height: 72rpx;
box-sizing: border-box;
}

.loading,
.end-text {
padding: 30rpx 0 8rpx;
color: #9aa4b2;
font-size: 24rpx;
text-align: center;
}

@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

+ 498
- 0
pnpm-lock.yaml Прегледај датотеку

@@ -0,0 +1,498 @@
lockfileVersion: '9.0'

settings:
autoInstallPeers: true
excludeLinksFromLockfile: false

importers:

.:
dependencies:
dayjs:
specifier: ^1.11.10
version: 1.11.21
md5:
specifier: ^2.3.0
version: 2.3.0
pinia:
specifier: ^2.1.7
version: 2.3.1(vue@3.5.39)
vue:
specifier: ^3.4.21
version: 3.5.39
devDependencies:
sass:
specifier: ^1.72.0
version: 1.101.0

packages:

'@babel/helper-string-parser@7.29.7':
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
engines: {node: '>=6.9.0'}

'@babel/helper-validator-identifier@7.29.7':
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
engines: {node: '>=6.9.0'}

'@babel/parser@7.29.7':
resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==}
engines: {node: '>=6.0.0'}
hasBin: true

'@babel/types@7.29.7':
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
engines: {node: '>=6.9.0'}

'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}

'@parcel/watcher-android-arm64@2.5.6':
resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [android]

'@parcel/watcher-darwin-arm64@2.5.6':
resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [darwin]

'@parcel/watcher-darwin-x64@2.5.6':
resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [darwin]

'@parcel/watcher-freebsd-x64@2.5.6':
resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [freebsd]

'@parcel/watcher-linux-arm-glibc@2.5.6':
resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [glibc]

'@parcel/watcher-linux-arm-musl@2.5.6':
resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [musl]

'@parcel/watcher-linux-arm64-glibc@2.5.6':
resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]

'@parcel/watcher-linux-arm64-musl@2.5.6':
resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]

'@parcel/watcher-linux-x64-glibc@2.5.6':
resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]

'@parcel/watcher-linux-x64-musl@2.5.6':
resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]

'@parcel/watcher-win32-arm64@2.5.6':
resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [win32]

'@parcel/watcher-win32-ia32@2.5.6':
resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==}
engines: {node: '>= 10.0.0'}
cpu: [ia32]
os: [win32]

'@parcel/watcher-win32-x64@2.5.6':
resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [win32]

'@parcel/watcher@2.5.6':
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
engines: {node: '>= 10.0.0'}

'@vue/compiler-core@3.5.39':
resolution: {integrity: sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==}

'@vue/compiler-dom@3.5.39':
resolution: {integrity: sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==}

'@vue/compiler-sfc@3.5.39':
resolution: {integrity: sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==}

'@vue/compiler-ssr@3.5.39':
resolution: {integrity: sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==}

'@vue/devtools-api@6.6.4':
resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}

'@vue/reactivity@3.5.39':
resolution: {integrity: sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==}

'@vue/runtime-core@3.5.39':
resolution: {integrity: sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==}

'@vue/runtime-dom@3.5.39':
resolution: {integrity: sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==}

'@vue/server-renderer@3.5.39':
resolution: {integrity: sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==}
peerDependencies:
vue: 3.5.39

'@vue/shared@3.5.39':
resolution: {integrity: sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==}

charenc@0.0.2:
resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==}

chokidar@5.0.0:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'}

crypt@0.0.2:
resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==}

csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}

dayjs@1.11.21:
resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==}

detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}

entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}

estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}

immutable@5.1.9:
resolution: {integrity: sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==}

is-buffer@1.1.6:
resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==}

is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}

is-glob@4.0.3:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}

magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}

md5@2.3.0:
resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==}

nanoid@3.3.16:
resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true

node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}

picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}

picomatch@4.0.5:
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
engines: {node: '>=12'}

pinia@2.3.1:
resolution: {integrity: sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==}
peerDependencies:
typescript: '>=4.4.4'
vue: ^2.7.0 || ^3.5.11
peerDependenciesMeta:
typescript:
optional: true

postcss@8.5.17:
resolution: {integrity: sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==}
engines: {node: ^10 || ^12 || >=14}

readdirp@5.0.0:
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
engines: {node: '>= 20.19.0'}

sass@1.101.0:
resolution: {integrity: sha512-OL3GoQyoUdDt843DpVmDO6y2k1sc5IhUDSpu8XucEI+35neq5QivZ1iuegnpraEVTJXlQGK1gl27zKcTLEPbQw==}
engines: {node: '>=20.19.0'}
hasBin: true

source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}

vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
engines: {node: '>=12'}
hasBin: true
peerDependencies:
'@vue/composition-api': ^1.0.0-rc.1
vue: ^3.0.0-0 || ^2.6.0
peerDependenciesMeta:
'@vue/composition-api':
optional: true

vue@3.5.39:
resolution: {integrity: sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true

snapshots:

'@babel/helper-string-parser@7.29.7': {}

'@babel/helper-validator-identifier@7.29.7': {}

'@babel/parser@7.29.7':
dependencies:
'@babel/types': 7.29.7

'@babel/types@7.29.7':
dependencies:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7

'@jridgewell/sourcemap-codec@1.5.5': {}

'@parcel/watcher-android-arm64@2.5.6':
optional: true

'@parcel/watcher-darwin-arm64@2.5.6':
optional: true

'@parcel/watcher-darwin-x64@2.5.6':
optional: true

'@parcel/watcher-freebsd-x64@2.5.6':
optional: true

'@parcel/watcher-linux-arm-glibc@2.5.6':
optional: true

'@parcel/watcher-linux-arm-musl@2.5.6':
optional: true

'@parcel/watcher-linux-arm64-glibc@2.5.6':
optional: true

'@parcel/watcher-linux-arm64-musl@2.5.6':
optional: true

'@parcel/watcher-linux-x64-glibc@2.5.6':
optional: true

'@parcel/watcher-linux-x64-musl@2.5.6':
optional: true

'@parcel/watcher-win32-arm64@2.5.6':
optional: true

'@parcel/watcher-win32-ia32@2.5.6':
optional: true

'@parcel/watcher-win32-x64@2.5.6':
optional: true

'@parcel/watcher@2.5.6':
dependencies:
detect-libc: 2.1.2
is-glob: 4.0.3
node-addon-api: 7.1.1
picomatch: 4.0.5
optionalDependencies:
'@parcel/watcher-android-arm64': 2.5.6
'@parcel/watcher-darwin-arm64': 2.5.6
'@parcel/watcher-darwin-x64': 2.5.6
'@parcel/watcher-freebsd-x64': 2.5.6
'@parcel/watcher-linux-arm-glibc': 2.5.6
'@parcel/watcher-linux-arm-musl': 2.5.6
'@parcel/watcher-linux-arm64-glibc': 2.5.6
'@parcel/watcher-linux-arm64-musl': 2.5.6
'@parcel/watcher-linux-x64-glibc': 2.5.6
'@parcel/watcher-linux-x64-musl': 2.5.6
'@parcel/watcher-win32-arm64': 2.5.6
'@parcel/watcher-win32-ia32': 2.5.6
'@parcel/watcher-win32-x64': 2.5.6
optional: true

'@vue/compiler-core@3.5.39':
dependencies:
'@babel/parser': 7.29.7
'@vue/shared': 3.5.39
entities: 7.0.1
estree-walker: 2.0.2
source-map-js: 1.2.1

'@vue/compiler-dom@3.5.39':
dependencies:
'@vue/compiler-core': 3.5.39
'@vue/shared': 3.5.39

'@vue/compiler-sfc@3.5.39':
dependencies:
'@babel/parser': 7.29.7
'@vue/compiler-core': 3.5.39
'@vue/compiler-dom': 3.5.39
'@vue/compiler-ssr': 3.5.39
'@vue/shared': 3.5.39
estree-walker: 2.0.2
magic-string: 0.30.21
postcss: 8.5.17
source-map-js: 1.2.1

'@vue/compiler-ssr@3.5.39':
dependencies:
'@vue/compiler-dom': 3.5.39
'@vue/shared': 3.5.39

'@vue/devtools-api@6.6.4': {}

'@vue/reactivity@3.5.39':
dependencies:
'@vue/shared': 3.5.39

'@vue/runtime-core@3.5.39':
dependencies:
'@vue/reactivity': 3.5.39
'@vue/shared': 3.5.39

'@vue/runtime-dom@3.5.39':
dependencies:
'@vue/reactivity': 3.5.39
'@vue/runtime-core': 3.5.39
'@vue/shared': 3.5.39
csstype: 3.2.3

'@vue/server-renderer@3.5.39(vue@3.5.39)':
dependencies:
'@vue/compiler-ssr': 3.5.39
'@vue/shared': 3.5.39
vue: 3.5.39

'@vue/shared@3.5.39': {}

charenc@0.0.2: {}

chokidar@5.0.0:
dependencies:
readdirp: 5.0.0

crypt@0.0.2: {}

csstype@3.2.3: {}

dayjs@1.11.21: {}

detect-libc@2.1.2:
optional: true

entities@7.0.1: {}

estree-walker@2.0.2: {}

immutable@5.1.9: {}

is-buffer@1.1.6: {}

is-extglob@2.1.1:
optional: true

is-glob@4.0.3:
dependencies:
is-extglob: 2.1.1
optional: true

magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5

md5@2.3.0:
dependencies:
charenc: 0.0.2
crypt: 0.0.2
is-buffer: 1.1.6

nanoid@3.3.16: {}

node-addon-api@7.1.1:
optional: true

picocolors@1.1.1: {}

picomatch@4.0.5:
optional: true

pinia@2.3.1(vue@3.5.39):
dependencies:
'@vue/devtools-api': 6.6.4
vue: 3.5.39
vue-demi: 0.14.10(vue@3.5.39)
transitivePeerDependencies:
- '@vue/composition-api'

postcss@8.5.17:
dependencies:
nanoid: 3.3.16
picocolors: 1.1.1
source-map-js: 1.2.1

readdirp@5.0.0: {}

sass@1.101.0:
dependencies:
chokidar: 5.0.0
immutable: 5.1.9
source-map-js: 1.2.1
optionalDependencies:
'@parcel/watcher': 2.5.6

source-map-js@1.2.1: {}

vue-demi@0.14.10(vue@3.5.39):
dependencies:
vue: 3.5.39

vue@3.5.39:
dependencies:
'@vue/compiler-dom': 3.5.39
'@vue/compiler-sfc': 3.5.39
'@vue/runtime-dom': 3.5.39
'@vue/server-renderer': 3.5.39(vue@3.5.39)
'@vue/shared': 3.5.39

+ 3
- 0
static/icons/bolt.svg Прегледај датотеку

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="none" viewBox="0 0 12 12">
<path fill="#fff" d="M1.652 6.074 7.342.197c.377-.39 1.032-.063.947.473L7.68 4.484a.556.556 0 0 0 .618.639l1.573-.196a.556.556 0 0 1 .466.939L4.61 11.745c-.38.39-1.035.058-.946-.479l.633-3.806a.556.556 0 0 0-.616-.642l-1.561.194a.556.556 0 0 1-.468-.938"/>
</svg>


























+ 75
- 0
store/user.js Прегледај датотеку

@@ -0,0 +1,75 @@
import { defineStore } from 'pinia'
import { getToken, setToken, getUser, setUser, clearAuth } from '@/utils/auth.js'
import { userApi } from '@/api/index.js'

export const useUserStore = defineStore('user', {
state: () => ({
token: '',
userInfo: {},
balance: 0, // 统一可用点数
balanceDetail: {} // 后端原始余额明细 { points, gpt3_balance, gpt4_balance, mj_balance }
}),

getters: {
isLogin: (state) => !!state.token,
// 兼容后端多种字段命名
nickname: (state) => state.userInfo.nick_name || state.userInfo.nickname || state.userInfo.user_name || 'AI在线用户',
avatar: (state) => state.userInfo.head_url || state.userInfo.avatar || '',
// 点数展示文案
pointsText: (state) => (state.balance < 0 ? '不限' : String(state.balance))
},

actions: {
/** 从本地存储恢复登录态(App 启动时调用) */
restore() {
this.token = getToken()
this.userInfo = getUser()
},

/** 登录成功后写入 */
setLogin({ token, userInfo }) {
this.token = token || ''
this.userInfo = userInfo || {}
setToken(this.token)
setUser(this.userInfo)
},

/** 拉取最新用户信息 */
async fetchUserInfo() {
const res = await userApi.getInfo()
this.userInfo = res.data || {}
setUser(this.userInfo)
return this.userInfo
},

/**
* 拉取点数余额。
* 后端返回新版点数 points,同时兼容旧资源包 { gpt3_balance, gpt4_balance, mj_balance }。
* 个人中心优先展示新版点数;旧接口未合并时才回退到旧资源包合计。
*/
async fetchBalance() {
const res = await userApi.getBalance()
const detail = res.data || {}
this.balanceDetail = detail
if (detail.points !== undefined || detail.point_balance !== undefined) {
this.balance = Number(detail.points !== undefined ? detail.points : detail.point_balance) || 0
return this.balance
}
const { gpt3_balance = 0, gpt4_balance = 0, mj_balance = 0 } = detail
if ([gpt3_balance, gpt4_balance, mj_balance].some((v) => v <= -9999)) {
this.balance = -1 // 不限
} else {
this.balance = gpt3_balance + gpt4_balance + mj_balance
}
return this.balance
},

/** 退出登录/登录失效,清空状态 */
clear() {
this.token = ''
this.userInfo = {}
this.balance = 0
clearAuth()
}
}
})

+ 64
- 0
styles/common.scss Прегледај датотеку

@@ -0,0 +1,64 @@
/* 全局公共样式,App.vue 中引入 */

page {
min-height: 100%;
color: $ai-text;
background: $ai-page;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}

/* 卡片 */
.card {
margin: 0 $ai-gutter;
border-radius: $ai-radius;
background: $ai-panel;
box-shadow: $ai-shadow;
box-sizing: border-box;
}

/* 区块标题 */
.section-title {
display: flex;
align-items: center;
justify-content: space-between;
margin: 34rpx $ai-gutter 20rpx;
font-size: 34rpx;
font-weight: 700;

.section-action {
font-size: 26rpx;
font-weight: 400;
color: $ai-muted;
}
}

.muted {
color: $ai-muted;
}

.chevron {
color: #8f98a6;
font-size: 44rpx;
line-height: 1;
}

/* 主色按钮 */
.btn-primary {
display: flex;
align-items: center;
justify-content: center;
height: 88rpx;
border-radius: 44rpx;
color: #fff;
font-size: 32rpx;
background: $ai-accent;

&:active {
opacity: 0.9;
}
}

/* 安全区 */
.safe-bottom {
padding-bottom: env(safe-area-inset-bottom);
}

+ 34
- 0
uni.scss Прегледај датотеку

@@ -0,0 +1,34 @@
/**
* uni.scss —— 全局 SCSS 变量
* 变量取自新版原型(assets/preview.css)
* 在任意 vue 组件的 <style lang="scss"> 中可直接使用这些变量
*/

/* 主题色 */
$ai-accent: #0b8cff; // 主色
$ai-orange: #ff681f; // 强调/付费
$ai-green: #22c55e; // 成功

/* 背景与文字 */
$ai-page: #f4f7fb; // 页面底色
$ai-panel: #ffffff; // 卡片/面板
$ai-panel-2: #f7f9fd; // 次级面板
$ai-text: #172033; // 主文字
$ai-muted: #7f8896; // 次要文字
$ai-line: rgba(89, 105, 128, 0.14); // 分割线

/* uview-plus 主题变量 */
$u-primary: $ai-accent;
$u-primary-dark: #0874d8;
$u-primary-disabled: #9fd0ff;
$u-primary-light: #eef7ff;

@import '@/uni_modules/uview-plus/theme.scss';

/* 圆角与间距 */
$ai-radius: 18rpx;
$ai-radius-lg: 28rpx;
$ai-gutter: 32rpx;

/* 阴影 */
$ai-shadow: 0 10rpx 30rpx rgba(35, 55, 90, 0.08);

+ 21
- 0
uni_modules/uview-plus/LICENSE Прегледај датотеку

@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2024 https://uiadmin.net/uview-plus

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.

+ 90
- 0
uni_modules/uview-plus/README.md Прегледај датотеку

@@ -0,0 +1,90 @@
<p align="center">
<img alt="logo" src="https://uiadmin.net/uview-plus/common/logo.png" width="120" height="120" style="margin-bottom: 10px;">
</p>
<h3 align="center" style="margin: 30px 0 30px;font-weight: bold;font-size:40px;">uview-plus 3.0</h3>
<h3 align="center">多平台快速开发的UI框架</h3>

[![stars](https://img.shields.io/github/stars/ijry/uview-plus?style=flat-square&logo=GitHub)](https://github.com/ijry/uview-plus)
[![forks](https://img.shields.io/github/forks/ijry/uview-plus?style=flat-square&logo=GitHub)](https://github.com/ijry/uview-plus)
[![issues](https://img.shields.io/github/issues/ijry/uview-plus?style=flat-square&logo=GitHub)](https://github.com/ijry/uview-plus/issues)
[![release](https://img.shields.io/github/v/release/ijry/uview-plus?style=flat-square)](https://gitee.com/jry/uview-plus/releases)
[![license](https://img.shields.io/github/license/ijry/uview-plus?style=flat-square)](https://en.wikipedia.org/wiki/MIT_License)

## 说明

【文档有广告,不喜勿入谢谢】

uview-plus,是uni-app全面兼容vue3/nvue/鸿蒙/uni-app-x(已经发布https://ext.dcloud.net.cn/plugin?name=uview-ultra)的uni-app生态框架,全面的组件和便捷的工具会让您信手拈来,如鱼得水。uview-plus是基于uView2.x移植的支持vue3的版本,感谢uView。

## 版本

### uview-plus v3 (uni-app版本)
1. 保持选项式API,尽可能兼容原有uview2各种API,比如mixins,方便大家升级更容易;
2. 新手友好,保持原汁原味js,二次开发方便,未使用typescript重写,但带有独立的类型申明包;

### uview-ultra v4 (uni-app-x版本)
1. 尽可能保持原有组件的API一致,方便大家升级;
2. 使用uts + 组合式API架构级全新重构,但仍然保持源码的简易,不封装过于复杂,便于修改。

## 可视化设计

uview-plus现已推出免费可视化设计,可以方便的进行页面可视化设计,导出源码即可使用。极大提高前端页面开发效率;如产品经理设计师直接使用更可作为高保真高可用原型制作工具,让设计稿即代码,无需传统的设计稿开发还原步骤。

<img src="https://s3.bmp.ovh/imgs/2024/11/24/fd58d00071e6e5df.png" width="900" height="auto" >
<img src="https://s3.bmp.ovh/imgs/2024/11/24/8e85a519fe627fb1.png" width="900" height="auto" >


## 文档
[官方文档:https://uview-plus.jiangruyi.com](https://uview-plus.jiangruyi.com)
[备用文档:https://uiadmin.net/uview-plus](https://uiadmin.net/uview-plus)


## 预览

您可以通过**微信**扫码,查看最佳的演示效果。
<br>
<br>

| H5 | 微信小程序 | 安卓 | 鸿蒙Next(已上架) |
| --- | --- | --- | --- |
| <img src="https://uview-plus.jiangruyi.com/common/h5_qrcode.png" width="160" height="160" /> | <img src="https://uview-plus.jiangruyi.com/common/weixin_mini_qrcode.jpg" width="160" height="160" /> | <img src="https://uview-plus.jiangruyi.com/common/android_qrcode.png" width="160" height="160" /> | <img src="https://uview-plus.jiangruyi.com/common/hos_qrcode.jpg" width="160" height="160" /> |
<br>
<img src="https://uview-plus.jiangruyi.com/common/hos.png">


## 链接

- [官方文档](https://uview-plus.jiangruyi.com)
- [更新日志](https://uview-plus.jiangruyi.com/components/changelog.html)
- [升级指南](https://uview-plus.jiangruyi.com/components/changeGuide.html)
- [关于我们](https://uview-plus.jiangruyi.com/cooperation/about.html)


## 关于PR

> 我们非常乐意接受各位的优质PR,但在此之前我希望您了解uview-plus是一个需要兼容多个平台的(小程序、h5、ios app、android app)包括nvue页面、vue页面。
> 所以希望在您修复bug并提交之前尽可能的去这些平台测试一下兼容性。最好能携带测试截图以方便审核。非常感谢!

## 安装

#### **uni-app插件市场链接** —— [https://ext.dcloud.net.cn/plugin?name=uview-plus](https://ext.dcloud.net.cn/plugin?name=uview-plus)

请通过[官网安装文档](https://uview-plus.jiangruyi.com/components/install.html)了解更详细的内容

## 快速上手

请通过[快速上手](https://uview-plus.jiangruyi.com/components/quickstart.html)了解更详细的内容

## 使用方法
配置easycom规则后,自动按需引入,无需`import`组件,直接引用即可。

```html
<template>
<u-button text="按钮"></u-button>
</template>
```

## 版权信息
uview-plus遵循[MIT](https://en.wikipedia.org/wiki/MIT_License)开源协议,意味着您无需支付任何费用,也无需授权,即可将uview-plus应用到您的产品中。

Root 组件基于上游项目 `uni-ku/root` 二次开发,已按 MIT 要求保留其原始授权文件:`libs/root/LICENSE`。

+ 2368
- 0
uni_modules/uview-plus/changelog.md
Разлика између датотеке није приказан због своје велике величине
Прегледај датотеку


+ 109
- 0
uni_modules/uview-plus/components/u-action-sheet-data/u-action-sheet-data.vue Прегледај датотеку

@@ -0,0 +1,109 @@
<template>
<view class="u-action-sheet-data">
<view class="u-action-sheet-data__trigger">
<slot name="trigger"></slot>
<up-input
v-if="!$slots['trigger']"
:modelValue="current"
disabled
disabledColor="#ffffff"
:placeholder="title"
border="none"
></up-input>
<view @click="show = true"
class="u-action-sheet-data__trigger__cover"></view>
</view>
<up-action-sheet
:show="show"
:actions="options"
:title="title"
safeAreaInsetBottom
:description="description"
@close="show = false"
@select="select"
>
</up-action-sheet>
</view>
</template>

<script>
export default {
props: {
modelValue: {
type: [String, Number],
default: ''
},
title: {
type: String,
default: ''
},
description: {
type: String,
default: ''
},
options: {
type: Array,
default: () => {
return []
}
},
valueKey: {
type: String,
default: 'value'
},
labelKey: {
type: String,
default: 'name'
}
},
data() {
return {
show: false,
current: '',
}
},
created() {
if (this.modelValue) {
this.options.forEach((ele) => {
if (ele[this.valueKey] == this.modelValue) {
this.current = ele[this.labelKey]
}
})
}
},
emits: ['update:modelValue'],
watch: {
modelValue() {
this.options.forEach((ele) => {
if (ele[this.valueKey] == this.modelValue) {
this.current = ele[this.labelKey]
}
})
}
},
methods: {
hideKeyboard() {
uni.hideKeyboard()
},
select(e) {
this.$emit('update:modelValue', e[this.valueKey])
this.current = e[this.labelKey]
},
}
}
</script>

<style lang="scss" scoped>
.u-action-sheet-data {
&__trigger {
position: relative;
&__cover {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
}
}
</style>

+ 28
- 0
uni_modules/uview-plus/components/u-action-sheet/actionSheet.js Прегледај датотеку

@@ -0,0 +1,28 @@
/*
* @Author : LQ
* @Description :
* @version : 3.0
* @Date : 2021-08-20 16:44:21
* @LastAuthor : jry
* @lastTime : 2025-08-16 10:52:35
* @FilePath : /uview-plus/libs/config/props/actionSheet.js
*/
export default {
// action-sheet组件
actionSheet: {
show: false,
title: '',
description: '',
actions: [],
nameKey: 'name',
subnameKey: 'subnameKey',
index: '',
cancelText: '',
closeOnClickAction: true,
safeAreaInsetBottom: true,
openType: '',
closeOnClickOverlay: true,
round: 0,
wrapMaxHeight: '600px'
}
}

+ 82
- 0
uni_modules/uview-plus/components/u-action-sheet/props.js Прегледај датотеку

@@ -0,0 +1,82 @@
/*
* @Author : LQ
* @Description :
* @version : 3.0
* @LastAuthor : jry
* @lastTime : 2025-08-16 10:52:35
* @FilePath : /uview-plus/libs/config/props/props.js
*/
import { defineMixin } from '../../libs/vue'
import ActionSheetDefaultProps from './actionSheet'
import { registerComponentProps } from '../../libs/config/props.js'

const defProps = registerComponentProps(ActionSheetDefaultProps)
export const props = defineMixin({
props: {
// 操作菜单是否展示 (默认false)
show: {
type: Boolean,
default: () => defProps.actionSheet.show
},
// 标题
title: {
type: String,
default: () => defProps.actionSheet.title
},
// 选项上方的描述信息
description: {
type: String,
default: () => defProps.actionSheet.description
},
// 数据
actions: {
type: Array,
default: () => defProps.actionSheet.actions
},
// 选项名称key
nameKey: {
type: String,
default: () => defProps.actionSheet.nameKey
},
// 选项子名称key
subnameKey: {
type: String,
default: () => defProps.actionSheet.subnameKey
},
// 取消按钮的文字,不为空时显示按钮
cancelText: {
type: String,
default: () => defProps.actionSheet.cancelText
},
// 点击某个菜单项时是否关闭弹窗
closeOnClickAction: {
type: Boolean,
default: () => defProps.actionSheet.closeOnClickAction
},
// 处理底部安全区(默认true)
safeAreaInsetBottom: {
type: Boolean,
default: () => defProps.actionSheet.safeAreaInsetBottom
},
// 小程序的打开方式
openType: {
type: String,
default: () => defProps.actionSheet.openType
},
// 点击遮罩是否允许关闭 (默认true)
closeOnClickOverlay: {
type: Boolean,
default: () => defProps.actionSheet.closeOnClickOverlay
},
// 圆角值
round: {
type: [Boolean, String, Number],
default: () => defProps.actionSheet.round
},
// 选项区域最大高度
wrapMaxHeight: {
type: [String],
default: () => defProps.actionSheet.wrapMaxHeight
},
}
})

+ 341
- 0
uni_modules/uview-plus/components/u-action-sheet/u-action-sheet.vue Прегледај датотеку

@@ -0,0 +1,341 @@
<template>
<u-popup
:show="show"
mode="bottom"
@close="closeHandler"
:safeAreaInsetBottom="safeAreaInsetBottom"
:round="round"
>
<view class="u-action-sheet">
<!-- 顶部标题区域 -->
<view
class="u-action-sheet__header"
v-if="title"
>
<text
class="u-action-sheet__header__title u-line-1"
:style="titleDynamicStyle"
>{{title}}</text>
<view
class="u-action-sheet__header__icon-wrap"
@tap.stop="cancel"
>
<up-icon
name="close"
size="17"
:color="closeIconColor"
bold
></up-icon>
</view>
</view>
<!-- 描述信息 -->
<text
class="u-action-sheet__description"
:style="[{
marginTop: `${title && description ? 0 : '18px'}`
}, descriptionDynamicStyle]"
v-if="description"
>{{description}}</text>
<slot>
<!-- 分割线 -->
<u-line v-if="description" :color="dividerColor"></u-line>
<!-- 操作项列表 -->
<scroll-view scroll-y class="u-action-sheet__item-wrap" :style="{maxHeight: wrapMaxHeight}">
<view :key="index" v-for="(item, index) in actions">
<!-- #ifdef MP -->
<button
class="u-reset-button"
:openType="item.openType"
@getuserinfo="onGetUserInfo"
@contact="onContact"
@getphonenumber="onGetPhoneNumber"
@error="onError"
@launchapp="onLaunchApp"
@opensetting="onOpenSetting"
:lang="lang"
:session-from="sessionFrom"
:send-message-title="sendMessageTitle"
:send-message-path="sendMessagePath"
:send-message-img="sendMessageImg"
:show-message-card="showMessageCard"
:app-parameter="appParameter"
@tap="selectHandler(index)"
:hover-class="!item.disabled && !item.loading ? 'u-action-sheet--hover' : ''"
>
<!-- #endif -->
<view
class="u-action-sheet__item-wrap__item"
@tap.stop="selectHandler(index)"
:hover-class="!item.disabled && !item.loading ? 'u-action-sheet--hover' : ''"
:hover-stay-time="150"
:style="getItemHoverStyle(index)"
>
<template v-if="!item.loading">
<text
class="u-action-sheet__item-wrap__item__name"
:style="[itemStyle(index)]"
>{{ item[nameKey] }}</text>
<text
v-if="item[subnameKey]"
class="u-action-sheet__item-wrap__item__subname"
:style="[subnameStyle(index)]"
>{{ item[subnameKey] }}</text>
</template>
<!-- 加载状态图标 -->
<u-loading-icon
v-else
custom-class="van-action-sheet__loading"
size="18"
mode="circle"
/>
</view>
<!-- #ifdef MP -->
</button>
<!-- #endif -->
<!-- 选项间分割线 -->
<u-line v-if="index !== actions.length - 1" :color="dividerColor"></u-line>
</view>
</scroll-view>
</slot>
<!-- 取消按钮前的分割区域 -->
<u-gap
:bgColor="cancelGapColor"
height="6"
v-if="cancelText"
></u-gap>
<!-- 取消按钮 -->
<view class="u-action-sheet__item-wrap__item u-action-sheet__cancel"
hover-class="u-action-sheet--hover" @tap="cancel" v-if="cancelText">
<text
@touchmove.stop.prevent
:hover-stay-time="150"
class="u-action-sheet__cancel-text"
:style="cancelTextDynamicStyle"
>{{cancelText}}</text>
</view>
</view>
</u-popup>
</template>

<script>
import { openType } from '../../libs/mixin/openType'
import { buttonMixin } from '../../libs/mixin/button'
import { props } from './props';
import { mpMixin } from '../../libs/mixin/mpMixin';
import { mixin } from '../../libs/mixin/mixin';
import { addUnit } from '../../libs/function/index';
/**
* ActionSheet 操作菜单
* @description 本组件用于从底部弹出一个操作菜单,供用户选择并返回结果。本组件功能类似于uni的uni.showActionSheetAPI,配置更加灵活,所有平台都表现一致。
* @tutorial https://uview-plus.jiangruyi.com/components/actionSheet.html
*
* @property {Boolean} show 操作菜单是否展示 (默认 false )
* @property {String} title 操作菜单标题
* @property {String} description 选项上方的描述信息
* @property {Array<Object>} actions 按钮的文字数组,见官方文档示例
* @property {String} cancelText 取消按钮的提示文字,不为空时显示按钮
* @property {Boolean} closeOnClickAction 点击某个菜单项时是否关闭弹窗 (默认 true )
* @property {Boolean} safeAreaInsetBottom 处理底部安全区 (默认 true )
* @property {String} openType 小程序的打开方式 (contact | launchApp | getUserInfo | openSetting |getPhoneNumber |error )
* @property {Boolean} closeOnClickOverlay 点击遮罩是否允许关闭 (默认 true )
* @property {Number|String} round 圆角值,默认无圆角 (默认 0 )
* @property {String} lang 指定返回用户信息的语言,zh_CN 简体中文,zh_TW 繁体中文,en 英文
* @property {String} sessionFrom 会话来源,openType="contact"时有效
* @property {String} sendMessageTitle 会话内消息卡片标题,openType="contact"时有效
* @property {String} sendMessagePath 会话内消息卡片点击跳转小程序路径,openType="contact"时有效
* @property {String} sendMessageImg 会话内消息卡片图片,openType="contact"时有效
* @property {Boolean} showMessageCard 是否显示会话内消息卡片,设置此参数为 true,用户进入客服会话会在右下角显示"可能要发送的小程序"提示,用户点击后可以快速发送小程序消息,openType="contact"时有效 (默认 false )
* @property {String} appParameter 打开 APP 时,向 APP 传递的参数,openType=launchApp 时有效
*
* @event {Function} select 点击ActionSheet列表项时触发
* @event {Function} close 点击取消按钮时触发
* @event {Function} getuserinfo 用户点击该按钮时,会返回获取到的用户信息,回调的 detail 数据与 wx.getUserInfo 返回的一致,openType="getUserInfo"时有效
* @event {Function} contact 客服消息回调,openType="contact"时有效
* @event {Function} getphonenumber 获取用户手机号回调,openType="getPhoneNumber"时有效
* @event {Function} error 当使用开放能力时,发生错误的回调,openType="error"时有效
* @event {Function} launchapp 打开 APP 成功的回调,openType="launchApp"时有效
* @event {Function} opensetting 在打开授权设置页后回调,openType="openSetting"时有效
* @example <u-action-sheet :actions="list" :title="title" :show="show"></u-action-sheet>
*/
export default {
name: "u-action-sheet",
// 一些props参数和methods方法,通过mixin混入,因为其他文件也会用到
mixins: [openType, buttonMixin, mixin, props],
data() {
return {

}
},
computed: {
titleDynamicStyle() {
return {
color: this.upThemeVar('--up-main-color', '#303133')
}
},
descriptionDynamicStyle() {
return {
color: this.upThemeVar('--up-tips-color', '#909193')
}
},
closeIconColor() {
return this.upThemeVar('--up-content-color', '#606266')
},
dividerColor() {
return this.upThemeVar('--up-border-color', this.upThemeIsDark ? '#3a3a3c' : '#dadbde')
},
cancelGapColor() {
return this.upThemeVar('--up-gap-bg-color', this.upThemeIsDark ? '#111111' : '#eaeaec')
},
cancelTextDynamicStyle() {
return {
color: this.upThemeVar('--up-main-color', '#303133')
}
},
// 操作项目的样式
itemStyle() {
return (index) => {
const style = {
color: this.upThemeVar('--up-main-color', '#303133')
};
if (this.actions[index].color) style.color = this.actions[index].color
if (this.actions[index].fontSize) style.fontSize = addUnit(this.actions[index].fontSize)
// 选项被禁用的样式
if (this.actions[index].disabled) style.color = this.upThemeVar('--up-light-color', '#c0c4cc')
return style;
}
},
subnameStyle() {
return (index) => ({
color: this.actions[index].disabled
? this.upThemeVar('--up-light-color', '#c0c4cc')
: this.upThemeVar('--up-tips-color', '#909193')
})
}
},
emits: ["close", "select", "update:show"],
methods: {
// 关闭操作菜单事件处理
closeHandler() {
// 允许点击遮罩关闭时,才发出close事件
if(this.closeOnClickOverlay) {
this.$emit('update:show', false)
this.$emit('close')
}
},
// 点击取消按钮
cancel() {
this.$emit('update:show', false)
this.$emit('close')
},
// 选择操作项处理
selectHandler(index) {
const item = this.actions[index]
if (item && !item.disabled && !item.loading) {
this.$emit('select', item)
if (this.closeOnClickAction) {
this.$emit('update:show', false)
this.$emit('close')
}
}
},
// 动态处理Hover时候第一个item的圆角
getItemHoverStyle(index) {
if (index === 0 && this.round && !this.title && !this.description) {
return {
borderTopLeftRadius: `${this.round}px`,
borderTopRightRadius: `${this.round}px`,
}
}
return {}
},
}
}
</script>

<style lang="scss" scoped>
$u-action-sheet-reset-button-width:100% !default;
$u-action-sheet-title-font-size: 16px !default;
$u-action-sheet-title-padding: 12px 30px !default;
$u-action-sheet-title-color: var(--up-main-color, #303133) !default;
$u-action-sheet-header-icon-wrap-right:15px !default;
$u-action-sheet-header-icon-wrap-top:15px !default;
$u-action-sheet-description-font-size:13px !default;
$u-action-sheet-description-color: var(--up-tips-color, #909193) !default;
$u-action-sheet-description-margin: 18px 15px !default;
$u-action-sheet-item-wrap-item-padding:17px !default;
$u-action-sheet-item-wrap-name-font-size:16px !default;
$u-action-sheet-item-wrap-subname-font-size:13px !default;
$u-action-sheet-item-wrap-subname-color: var(--up-tips-color, #909193) !default;
$u-action-sheet-item-wrap-subname-margin-top:10px !default;
$u-action-sheet-cancel-text-font-size:16px !default;
$u-action-sheet-cancel-text-color: var(--up-main-color, #303133) !default;
$u-action-sheet-cancel-text-font-size:15px !default;
$u-action-sheet-cancel-text-hover-background-color: var(--up-hover-bg-color, rgb(242, 243, 245)) !default;

.u-reset-button {
width: $u-action-sheet-reset-button-width;
}

.u-action-sheet {
text-align: center;
&__header {
position: relative;
padding: $u-action-sheet-title-padding;
&__title {
font-size: $u-action-sheet-title-font-size;
color: $u-action-sheet-title-color;
font-weight: bold;
text-align: center;
}

&__icon-wrap {
position: absolute;
right: $u-action-sheet-header-icon-wrap-right;
top: $u-action-sheet-header-icon-wrap-top;
}
}

&__description {
display: block;
font-size: $u-action-sheet-description-font-size;
color: $u-action-sheet-description-color;
margin: $u-action-sheet-description-margin;
text-align: center;
}

&__item-wrap {

&__item {
padding: $u-action-sheet-item-wrap-item-padding;
@include flex;
align-items: center;
justify-content: center;
flex-direction: column;

&__name {
font-size: $u-action-sheet-item-wrap-name-font-size;
color: var(--up-main-color, #303133);
text-align: center;
}

&__subname {
font-size: $u-action-sheet-item-wrap-subname-font-size;
color: $u-action-sheet-item-wrap-subname-color;
margin-top: $u-action-sheet-item-wrap-subname-margin-top;
text-align: center;
}
}
}

&__cancel-text {
font-size: $u-action-sheet-cancel-text-font-size;
color: $u-action-sheet-cancel-text-color;
text-align: center;
// padding: $u-action-sheet-cancel-text-font-size;
}

&--hover {
background-color: $u-action-sheet-cancel-text-hover-background-color;
}
}
</style>

+ 77
- 0
uni_modules/uview-plus/components/u-agreement/u-agreement.vue Прегледај датотеку

@@ -0,0 +1,77 @@
<style scoped lang="scss">
.agreement-content {
width: 100%;;
display: inline-block;
flex-direction: column;
color: var(--up-main-color, #303133);
.agreement-url {
display: inline-block;
color: var(--up-primary, #2979ff);
// #ifdef H5
cursor: pointer;
// #endif
}
}
</style>

<template>
<view class="up-agreement">
<up-modal v-model:show="show" showCancelButton @confirm="confirm" @cancel="close" confirmText="阅读并同意">
<view class="agreement-content">
<slot>
我们非常重视您的个人信息和隐私保护。为了更好地保障您的个人权益,在您使用我们的产品前,
请务必审慎阅读《<text class="agreement-url" @click="urlClick('urlProtocol')">用户协议</text>》
和《<text class="agreement-url" @click="urlClick('urlPrivacy')">隐私政策</text>》内的所有条款,
尤其是:1.我们对您的个人信息的收集/保存/使用/对外提供/保护等规则条款,以及您的用户权利等条款;2. 约定我们的限制责任、免责
条款;3.其他以颜色或加粗进行标识的重要条款。如您对以上协议有任何疑问,请先不要同意,您点击“同意并继续”的行为即表示您已阅读
完毕并同意以上协议的全部内容。
</slot>
</view>
</up-modal>
</view>
</template>

<script>
export default {
name: 'up-agreement',
props: {
urlProtocol: {
type: String,
default: '/pages/user_agreement/agreement/info?title=用户协议'
},
urlPrivacy: {
type: String,
default: '/pages/user_agreement/agreement/info?title=隐私政策'
},
},
emits: ['confirm'],
data() {
return {
show: false
}
},
methods: {
close() {
// #ifdef H5
window.opener = null;
window.close();
// #endif
// #ifdef APP-PLUS
plus.runtime.quit();
// #endif
},
confirm() {
this.show = false;
this.$emit('confirm', 1);
},
showModal() {
this.show = true;
},
urlClick(type) {
uni.navigateTo({
url: this[type]
});
}
}
}
</script>

+ 28
- 0
uni_modules/uview-plus/components/u-album/album.js Прегледај датотеку

@@ -0,0 +1,28 @@
/*
* @Author : LQ
* @Description :
* @version : 3.0
* @Date : 2021-08-20 16:44:21
* @LastAuthor : jry
* @lastTime : 2025-08-16 16:32:24
* @FilePath : /uview-plus/libs/config/props/album.js
*/
export default {
// album 组件
album: {
urls: [],
keyName: '',
singleSize: 180,
multipleSize: 70,
space: 6,
singleMode: 'scaleToFill',
multipleMode: 'aspectFill',
maxCount: 9,
previewFullImage: true,
rowCount: 3,
showMore: true,
autoWrap: false,
unit: 'px',
stop: true,
}
}

+ 97
- 0
uni_modules/uview-plus/components/u-album/props.js Прегледај датотеку

@@ -0,0 +1,97 @@
/*
* @Author : jry
* @Description :
* @version : 3.0
* @LastAuthor : jry
* @lastTime : 2025-08-16 16:35:24
* @FilePath : /uview-plus/components/u-album/props.js
*/
import { defineMixin } from '../../libs/vue'
import AlbumDefaultProps from './album'
import { registerComponentProps } from '../../libs/config/props.js'

const defProps = registerComponentProps(AlbumDefaultProps)
export const props = defineMixin({
props: {
// 图片地址,Array<String>|Array<Object>形式
urls: {
type: Array,
default: () => defProps.album.urls
},
// 指定从数组的对象元素中读取哪个属性作为图片地址
keyName: {
type: String,
default: () => defProps.album.keyName
},
// 单图时,图片长边的长度
singleSize: {
type: [String, Number],
default: () => defProps.album.singleSize
},
// 多图时,图片边长
multipleSize: {
type: [String, Number],
default: () => defProps.album.multipleSize
},
// 多图时,图片水平和垂直之间的间隔
space: {
type: [String, Number],
default: () => defProps.album.space
},
// 单图时,图片缩放裁剪的模式
singleMode: {
type: String,
default: () => defProps.album.singleMode
},
// 多图时,图片缩放裁剪的模式
multipleMode: {
type: String,
default: () => defProps.album.multipleMode
},
// 最多展示的图片数量,超出时最后一个位置将会显示剩余图片数量
maxCount: {
type: [String, Number],
default: () => defProps.album.maxCount
},
// 是否可以预览图片
previewFullImage: {
type: Boolean,
default: () => defProps.album.previewFullImage
},
// 每行展示图片数量,如设置,singleSize和multipleSize将会无效
rowCount: {
type: [String, Number],
default: () => defProps.album.rowCount
},
// 超出maxCount时是否显示查看更多的提示
showMore: {
type: Boolean,
default: () => defProps.album.showMore
},
// 图片形状,circle-圆形,square-方形
shape: {
type: String,
default: () => defProps.image.shape
},
// 圆角,单位任意
radius: {
type: [String, Number],
default: () => defProps.image.radius
},
// 自适应换行
autoWrap: {
type: Boolean,
default: () => defProps.album.autoWrap
},
// 单位
unit: {
type: [String],
default: () => defProps.album.unit
},
// 阻止点击冒泡
stop: {
type: Boolean,
default: () => defProps.album.stop
}
}
})

+ 344
- 0
uni_modules/uview-plus/components/u-album/u-album.vue Прегледај датотеку

@@ -0,0 +1,344 @@
<template>
<view class="u-album">
<!-- 相册行容器,每行显示 rowCount 个图片 -->
<view
class="u-album__row"
ref="u-album__row"
v-for="(arr, index) in showUrls"
:forComputedUse="albumWidth"
:key="index"
:style="{flexWrap: autoWrap ? 'wrap' : 'nowrap'}"
>
<!-- 图片包装容器 -->
<view
class="u-album__row__wrapper"
v-for="(item, index1) in arr"
:key="index1"
:style="[imageStyle(index + 1, index1 + 1)]"
@tap="onPreviewTap($event, getSrc(item))"
>
<!-- 图片显示 -->
<image
:src="getSrc(item)"
:mode="
urls.length === 1
? imageHeight > 0
? singleMode
: 'widthFix'
: multipleMode
"
:style="[
{
width: imageWidth,
height: imageHeight,
borderRadius: shape == 'circle' ? '10000px' : addUnit(radius)
}
]"
></image>
<!-- 超出最大显示数量时的更多提示 -->
<view
v-if="
showMore &&
urls.length > rowCount * showUrls.length &&
index === showUrls.length - 1 &&
index1 === showUrls[showUrls.length - 1].length - 1
"
class="u-album__row__wrapper__text"
:style="{
borderRadius: shape == 'circle' ? '50%' : addUnit(radius),
}"
>
<up-text
:text="`+${urls.length - maxCount}`"
color="#fff"
:size="multipleSize * 0.3"
align="center"
customStyle="justify-content: center"
></up-text>
</view>
</view>
</view>
</view>
</template>

<script>
import { props } from './props';
import { mpMixin } from '../../libs/mixin/mpMixin';
import { mixin } from '../../libs/mixin/mixin';
import { addUnit, sleep } from '../../libs/function/index';
import test from '../../libs/function/test';
// #ifdef APP-NVUE
// 不支持百分比单位,这里需要通过dom查询组件的宽度
const dom = uni.requireNativePlugin('dom')
// #endif

/**
* Album 相册
* @description 本组件提供一个类似相册的功能,让开发者开发起来更加得心应手。减少重复的模板代码
* @tutorial https://uview-plus.jiangruyi.com/components/album.html
*
* @property {Array} urls 图片地址列表 Array<String>|Array<Object>形式
* @property {String} keyName 指定从数组的对象元素中读取哪个属性作为图片地址
* @property {String | Number} singleSize 单图时,图片长边的长度 (默认 180 )
* @property {String | Number} multipleSize 多图时,图片边长 (默认 70 )
* @property {String | Number} space 多图时,图片水平和垂直之间的间隔 (默认 6 )
* @property {String} singleMode 单图时,图片缩放裁剪的模式 (默认 'scaleToFill' )
* @property {String} multipleMode 多图时,图片缩放裁剪的模式 (默认 'aspectFill' )
* @property {String | Number} maxCount 取消按钮的提示文字 (默认 9 )
* @property {Boolean} previewFullImage 是否可以预览图片 (默认 true )
* @property {String | Number} rowCount 每行展示图片数量,如设置,singleSize和multipleSize将会无效 (默认 3 )
* @property {Boolean} showMore 超出maxCount时是否显示查看更多的提示 (默认 true )
* @property {String} shape 图片形状,circle-圆形,square-方形 (默认 'square' )
* @property {String | Number} radius 圆角值,单位任意,如果为数值,则为px单位 (默认 0 )
* @property {Boolean} autoWrap 自适应换行模式,不受rowCount限制,图片会自动换行 (默认 false )
* @property {String} unit 图片单位 (默认 px )
* @event {Function} albumWidth 某些特殊的情况下,需要让文字与相册的宽度相等,这里事件的形式对外发送 (回调参数 width )
* @example <u-album :urls="urls2" @albumWidth="width => albumWidth = width" multipleSize="68" ></u-album>
*/
export default {
name: 'u-album',
mixins: [mpMixin, mixin, props],
data() {
return {
// 单图的宽度
singleWidth: 0,
// 单图的高度
singleHeight: 0,
// 单图时,如果无法获取图片的尺寸信息,让图片宽度默认为容器的一定百分比
singlePercent: 0.6
}
},
watch: {
urls: {
immediate: true,
handler(newVal) {
// 当只有一张图片时,获取图片尺寸信息
if (newVal.length === 1) {
this.getImageRect()
}
}
}
},
computed: {
/**
* 计算图片样式
* @param {Number} index1 - 行索引
* @param {Number} index2 - 列索引
* @returns {Object} 图片样式对象
*/
imageStyle() {
return (index1, index2) => {
const { space, rowCount, multipleSize, urls } = this,
rowLen = this.showUrls.length,
allLen = this.urls.length
const style = {
marginRight: addUnit(space),
marginBottom: addUnit(space)
}
// 如果为最后一行,则每个图片都无需下边框
if (index1 === rowLen && !this.autoWrap) style.marginBottom = 0
// 每行的最右边一张和总长度的最后一张无需右边框
if (!this.autoWrap) {
if (
index2 === rowCount ||
(index1 === rowLen &&
index2 === this.showUrls[index1 - 1].length)
)
style.marginRight = 0
}
return style
}
},
/**
* 将图片地址数组划分为二维数组,用于按行显示
* @returns {Array} 二维数组,每个子数组代表一行图片
*/
showUrls() {
if (this.autoWrap) {
// 自动换行模式下,所有图片放在一行中显示
return [ this.urls.slice(0, this.maxCount) ];
} else {
// 固定行数模式下,按 rowCount 分割图片
const arr = []
this.urls.map((item, index) => {
// 限制最大展示数量
if (index + 1 <= this.maxCount) {
// 计算该元素为第几个素组内
const itemIndex = Math.floor(index / this.rowCount)
// 判断对应的索引是否存在
if (!arr[itemIndex]) {
arr[itemIndex] = []
}
arr[itemIndex].push(item)
}
})
return arr
}
},
/**
* 计算图片宽度
* @returns {String} 图片宽度样式值
*/
imageWidth() {
return addUnit(
this.urls.length === 1 ? this.singleWidth : this.multipleSize, this.unit
)
},
/**
* 计算图片高度
* @returns {String} 图片高度样式值
*/
imageHeight() {
return addUnit(
this.urls.length === 1 ? this.singleHeight : this.multipleSize, this.unit
)
},
/**
* 计算相册总宽度,用于外部组件对齐
* 此变量无实际用途,仅仅是为了利用computed特性,让其在urls长度等变化时,重新计算图片的宽度
* @returns {Number} 相册宽度
*/
albumWidth() {
let width = 0
if (this.urls.length === 1) {
width = this.singleWidth
} else {
width =
this.showUrls[0].length * this.multipleSize +
this.space * (this.showUrls[0].length - 1)
}
this.$emit('albumWidth', width)
return width
}
},
emits: ['preview', 'albumWidth'],
methods: {
addUnit,
/**
* 点击图片预览
* @param {Event} e - 点击事件对象
* @param {String} url - 当前点击图片的地址
*/
onPreviewTap(e, url) {
// 获取所有图片地址
const urls = this.urls.map((item) => {
return this.getSrc(item)
})
if (this.previewFullImage) {
// 使用系统默认预览图片功能
uni.previewImage({
current: url,
urls
})
// 是否阻止事件传播
this.stop && this.preventEvent(e)
} else {
// 发送自定义预览事件
this.$emit('preview', {
urls,
currentIndex: urls.indexOf(url)
})
}
},
/**
* 获取图片地址
* @param {String|Object} item - 图片项,可以是字符串或对象
* @returns {String} 图片地址
*/
getSrc(item) {
return test.object(item)
? (this.keyName && item[this.keyName]) || item.src
: item
},
/**
* 单图时,获取图片的尺寸
* 在小程序中,需要将网络图片的的域名添加到小程序的download域名才可能获取尺寸
* 在没有添加的情况下,让单图宽度默认为盒子的一定宽度(singlePercent)
*/
getImageRect() {
const src = this.getSrc(this.urls[0])
uni.getImageInfo({
src,
success: (res) => {
let singleSize = this.singleSize;
// 单位
let unit = '';
if (Number.isNaN(Number(this.singleSize))) {
// 大小中有字符 则记录字符
unit = this.singleSize.replace(/\d+/g, ''); // 单位
singleSize = Number(this.singleSize.replace(/\D+/g, ''), 10); // 具体值
}

// 判断图片横向还是竖向展示方式
const isHorizotal = res.width >= res.height
this.singleWidth = isHorizotal
? singleSize
: (res.width / res.height) * singleSize
this.singleHeight = !isHorizotal
? singleSize
: (res.height / res.width) * this.singleWidth

// 如果有单位统一设置单位
if(unit != null && unit !== ''){
this.singleWidth = this.singleWidth + unit
this.singleHeight = this.singleHeight + unit
}
},
fail: () => {
// 获取图片信息失败时,通过组件宽度计算
this.getComponentWidth()
}
})
},
/**
* 获取组件的宽度,用于计算单图显示尺寸
*/
async getComponentWidth() {
// 延时一定时间,以获取dom尺寸
await sleep(30)
// #ifndef APP-NVUE
// H5、小程序等平台通过 $uGetRect 获取组件宽度
this.$uGetRect('.u-album__row').then((size) => {
this.singleWidth = size.width * this.singlePercent
})
// #endif

// #ifdef APP-NVUE
// NVUE 平台通过 dom 插件获取组件宽度
// 这里ref="u-album__row"所在的标签为通过for循环出来,导致this.$refs['u-album__row']是一个数组
const ref = this.$refs['u-album__row'][0]
ref &&
dom.getComponentRect(ref, (res) => {
this.singleWidth = res.size.width * this.singlePercent
})
// #endif
}
}
}
</script>

<style lang="scss" scoped>
.u-album {
@include flex(column);

&__row {
@include flex(row);

&__wrapper {
position: relative;

&__text {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.3);
@include flex(row);
justify-content: center;
align-items: center;
}
}
}
}
</style>

+ 26
- 0
uni_modules/uview-plus/components/u-alert/alert.js Прегледај датотеку

@@ -0,0 +1,26 @@
/*
* @Author : LQ
* @Description :
* @version : 3.0
* @Date : 2021-08-20 16:44:21
* @LastAuthor : jry
* @lastTime : 2025-08-17 17:23:53
* @FilePath : /uview-plus/libs/config/props/alert.js
*/
export default {
// alert警告组件
alert: {
title: '',
type: 'warning',
description: '',
closable: false,
showIcon: false,
effect: 'light',
center: false,
fontSize: 14,
transitionMode: 'fade',
duration: 0,
icon: '',
value: true
}
}

+ 77
- 0
uni_modules/uview-plus/components/u-alert/props.js Прегледај датотеку

@@ -0,0 +1,77 @@
/*
* @Author : jry
* @Description :
* @version : 3.0
* @LastAuthor : jry
* @lastTime : 2025-08-17 17:23:53
* @FilePath : /uview-plus/libs/config/props/props.js
*/
import { defineMixin } from '../../libs/vue'
import AlertDefaultProps from './alert'
import { registerComponentProps } from '../../libs/config/props.js'

const defProps = registerComponentProps(AlertDefaultProps)
export const props = defineMixin({
props: {
// 显示文字
title: {
type: String,
default: () => defProps.alert.title
},
// 主题,success/warning/info/error
type: {
type: String,
default: () => defProps.alert.type
},
// 辅助性文字
description: {
type: String,
default: () => defProps.alert.description
},
// 是否可关闭
closable: {
type: Boolean,
default: () => defProps.alert.closable
},
// 是否显示图标
showIcon: {
type: Boolean,
default: () => defProps.alert.showIcon
},
// 浅或深色调,light-浅色,dark-深色
effect: {
type: String,
default: () => defProps.alert.effect
},
// 文字是否居中
center: {
type: Boolean,
default: () => defProps.alert.center
},
// 字体大小
fontSize: {
type: [String, Number],
default: () => defProps.alert.fontSize
},
// 动画类型
transitionMode: {
type: [String],
default: () => defProps.alert.transitionMode
},
// 自动定时关闭毫秒
duration: {
type: [Number],
default: () => defProps.alert.duration
},
// 自定义图标
icon: {
type: [String],
default: () => defProps.alert.icon
},
// 是否显示
modelValue: {
type: [Boolean],
default: () => defProps.alert.value
}
}
})

+ 291
- 0
uni_modules/uview-plus/components/u-alert/u-alert.vue Прегледај датотеку

@@ -0,0 +1,291 @@
<template>
<up-transition
:mode="transitionMode"
:show="show"
>
<view
class="u-alert"
:class="[`u-alert--${type}--${effect}`]"
@tap.stop="clickHandler"
:style="[addStyle(customStyle)]"
>
<!-- 左侧图标 -->
<view
class="u-alert__icon"
v-if="showIcon"
>
<up-icon
:name="iconName"
size="18"
:color="iconColor"
></up-icon>
</view>
<!-- 内容区域 -->
<view
class="u-alert__content"
:style="[{
paddingRight: closable ? '20px' : 0
}]"
>
<!-- 标题 -->
<text
class="u-alert__content__title"
v-if="title"
:style="[{
fontSize: addUnit(fontSize),
textAlign: center ? 'center' : 'left'
}]"
:class="[effect === 'dark' ? 'u-alert__text--dark' : `u-alert__text--${type}--light`]"
>{{ title }}</text>
<!-- 描述信息 -->
<text
class="u-alert__content__desc"
v-if="description"
:style="[{
fontSize: addUnit(fontSize),
textAlign: center ? 'center' : 'left'
}]"
:class="[effect === 'dark' ? 'u-alert__text--dark' : `u-alert__text--${type}--light`]"
>{{ description }}</text>
</view>
<!-- 关闭按钮 -->
<view
class="u-alert__close"
v-if="closable"
@tap.stop="closeHandler"
>
<slot name="close">
<up-icon
name="close"
:color="iconColor"
size="15"
></up-icon>
</slot>
</view>
</view>
</up-transition>
</template>

<script>
import { props } from './props';
import { mpMixin } from '../../libs/mixin/mpMixin';
import { mixin } from '../../libs/mixin/mixin';
import { addUnit, addStyle } from '../../libs/function/index';
/**
* Alert 警告提示
* @description 警告提示,展现需要关注的信息。
* @tutorial https://uview-plus.jiangruyi.com/components/alertTips.html
*
* @property {String} title 显示的文字
* @property {String} type 使用预设的颜色 (默认 'warning' )
* @property {String} description 辅助性文字,颜色比title浅一点,字号也小一点,可选
* @property {Boolean} closable 关闭按钮(默认为叉号icon图标) (默认 false )
* @property {Boolean} showIcon 是否显示左边的辅助图标 ( 默认 false )
* @property {String} effect 多图时,图片缩放裁剪的模式 (默认 'light' )
* @property {Boolean} center 文字是否居中 (默认 false )
* @property {String | Number} fontSize 字体大小 (默认 14 )
* @property {Object} customStyle 定义需要用到的外部样式
* @property {String} transitionMode 过渡动画模式 (默认 'fade' )
* @property {String | Number} duration 自动关闭延时(毫秒),设置为0或负数则不自动关闭 (默认 0 )
* @property {String} icon 自定义图标名称,优先级高于type默认图标
* @property {Boolean} modelValue/v-model 绑定值,控制是否显示 (默认 true )
* @event {Function} click 点击组件时触发
* @event {Function} close 点击关闭按钮时触发
* @event {Function} closed 关闭动画结束时触发
* @example <up-alert :title="title" type = "warning" :closable="closable" :description = "description"></up-alert>
*/
export default {
name: 'u-alert',
mixins: [mpMixin, mixin, props],
data() {
return {
// 控制组件显示隐藏
show: true
}
},
computed: {
// 根据不同的主题类型返回对应的图标颜色
iconColor() {
return this.effect === 'light' ? this.type : '#fff'
},
// 不同主题对应不同的图标
iconName() {
// 如果用户自定义了图标,则优先使用自定义图标
if (this.icon) return this.icon;
switch (this.type) {
case 'success':
return 'checkmark-circle-fill';
break;
case 'error':
return 'close-circle-fill';
break;
case 'warning':
return 'error-circle-fill';
break;
case 'info':
return 'info-circle-fill';
break;
case 'primary':
return 'more-circle-fill';
break;
default:
return 'error-circle-fill';
}
}
},
emits: ["click","close", "closed", "update:modelValue"],
watch: {
modelValue: {
handler(newVal) {
this.show = newVal;
},
immediate: true
},
show: {
handler(newVal) {
this.$emit('update:modelValue', newVal);
// 如果是从显示到隐藏,且启用了自动关闭功能
if (!newVal && this.duration > 0) {
this.$emit('closed');
}
}
}
},
mounted() {
// 如果设置了自动关闭时间,则在指定时间后自动关闭
if (this.duration > 0) {
setTimeout(() => {
this.closeHandler();
}, this.duration);
}
},
methods: {
addUnit,
addStyle,
// 点击内容区域触发click事件
clickHandler() {
this.$emit('click')
},
// 点击关闭按钮触发close事件并隐藏组件
closeHandler() {
this.show = false
this.$emit('close');
}
}
}
</script>

<style lang="scss" scoped>

.u-alert {
position: relative;
background-color: $u-primary;
padding: 8px 10px;
@include flex(row);
align-items: center;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;

&--primary--dark {
background-color: $u-primary;
}

&--primary--light {
background-color: var(--up-primary-light, #ecf5ff);
}

&--error--dark {
background-color: $u-error;
}

&--error--light {
background-color: var(--up-error-light, #FEF0F0);
}

&--success--dark {
background-color: $u-success;
}

&--success--light {
background-color: var(--up-success-light, #f5fff0);
}

&--warning--dark {
background-color: $u-warning;
}

&--warning--light {
background-color: var(--up-warning-light, #FDF6EC);
}

&--info--dark {
background-color: $u-info;
}

&--info--light {
background-color: var(--up-info-light, #f4f4f5);
}

&__icon {
margin-right: 5px;
}

&__content {
@include flex(column);
flex: 1;

&__title {
color: var(--up-main-color, $u-main-color);
font-size: 14px;
font-weight: bold;
margin-bottom: 2px;
}

&__desc {
color: var(--up-main-color, $u-main-color);
font-size: 14px;
flex-wrap: wrap;
}
}

&__title--dark,
&__desc--dark {
color: var(--up-white, #FFFFFF);
}

&__text--primary--light,
&__text--primary--light {
color: $u-primary;
}

&__text--success--light,
&__text--success--light {
color: $u-success;
}

&__text--warning--light,
&__text--warning--light {
color: $u-warning;
}

&__text--error--light,
&__text--error--light {
color: $u-error;
}

&__text--info--light,
&__text--info--light {
color: $u-info;
}

&__close {
position: absolute;
top: 11px;
right: 10px;
}
}
</style>

+ 23
- 0
uni_modules/uview-plus/components/u-avatar-group/avatarGroup.js Прегледај датотеку

@@ -0,0 +1,23 @@
/*
* @Author : LQ
* @Description :
* @version : 3.0
* @Date : 2021-08-20 16:44:21
* @LastAuthor : jry
* @lastTime : 2025-12-19 08:55:21
* @FilePath : /uview-plus/libs/config/props/avatarGroup.js
*/
export default {
// avatarGroup 组件
avatarGroup: {
urls: [],
maxCount: 5,
shape: 'circle',
mode: 'scaleToFill',
showMore: true,
size: 40,
keyName: '',
gap: 0.5,
extraValue: 0
}
}

+ 57
- 0
uni_modules/uview-plus/components/u-avatar-group/props.js Прегледај датотеку

@@ -0,0 +1,57 @@
import { defineMixin } from '../../libs/vue'
import AvatarGroupDefaultProps from './avatarGroup'
import { registerComponentProps } from '../../libs/config/props.js'

const defProps = registerComponentProps(AvatarGroupDefaultProps)
export const props = defineMixin({
props: {
// 头像图片组
urls: {
type: Array,
default: () => defProps.avatarGroup.urls
},
// 最多展示的头像数量
maxCount: {
type: [String, Number],
default: () => defProps.avatarGroup.maxCount
},
// 头像形状
shape: {
type: String,
default: () => defProps.avatarGroup.shape
},
// 图片裁剪模式
mode: {
type: String,
default: () => defProps.avatarGroup.mode
},
// 超出maxCount时是否显示查看更多的提示
showMore: {
type: Boolean,
default: () => defProps.avatarGroup.showMore
},
// 头像大小
size: {
type: [String, Number],
default: () => defProps.avatarGroup.size
},
// 指定从数组的对象元素中读取哪个属性作为图片地址
keyName: {
type: String,
default: () => defProps.avatarGroup.keyName
},
// 头像之间的遮挡比例
gap: {
type: [String, Number],
validator(value) {
return value >= 0 && value <= 1
},
default: () => defProps.avatarGroup.gap
},
// 需额外显示的值
extraValue: {
type: [Number, String],
default: () => defProps.avatarGroup.extraValue
}
}
})

+ 109
- 0
uni_modules/uview-plus/components/u-avatar-group/u-avatar-group.vue Прегледај датотеку

@@ -0,0 +1,109 @@
<template>
<view class="u-avatar-group">
<view
class="u-avatar-group__item"
v-for="(item, index) in showUrl"
:key="index"
:style="{
marginLeft: index === 0 ? 0 : addUnit(-size * gap)
}"
>
<u-avatar
:size="size"
:shape="shape"
:mode="mode"
:src="testObject(item) ? keyName && item[keyName] || item.url : item"
></u-avatar>
<view
class="u-avatar-group__item__show-more"
v-if="showMore && index === showUrl.length - 1 && (urls.length > maxCount || extraValue > 0)"
@tap="clickHandler"
>
<up-text
color="#ffffff"
:size="size * 0.4"
:text="`+${extraValue || urls.length - showUrl.length}`"
align="center"
customStyle="justify-content: center"
></up-text>
</view>
</view>
</view>
</template>

<script>
import { props } from './props';
import { mpMixin } from '../../libs/mixin/mpMixin';
import { mixin } from '../../libs/mixin/mixin';
import { addUnit } from '../../libs/function/index';
import test from '../../libs/function/test';
/**
* AvatarGroup 头像组
* @description 本组件一般用于展示头像的地方,如个人中心,或者评论列表页的用户头像展示等场所。
* @tutorial https://uview-plus.jiangruyi.com/components/avatar.html
*
* @property {Array} urls 头像图片组 (默认 [] )
* @property {String | Number} maxCount 最多展示的头像数量 ( 默认 5 )
* @property {String} shape 头像形状( 'circle' (默认) | 'square' )
* @property {String} mode 图片裁剪模式(默认 'scaleToFill' )
* @property {Boolean} showMore 超出maxCount时是否显示查看更多的提示 (默认 true )
* @property {String | Number} size 头像大小 (默认 40 )
* @property {String} keyName 指定从数组的对象元素中读取哪个属性作为图片地址
* @property {String | Number} gap 头像之间的遮挡比例(0.4代表遮挡40%) (默认 0.5 )
* @property {String | Number} extraValue 需额外显示的值
* @event {Function} showMore 头像组更多点击
* @example <u-avatar-group:urls="urls" size="35" gap="0.4" ></u-avatar-group:urls=>
*/
export default {
name: 'u-avatar-group',
mixins: [mpMixin, mixin, props],
data() {
return {

}
},
computed: {
showUrl() {
return this.urls.slice(0, this.maxCount)
}
},
emits: ["showMore"],
methods: {
addUnit,
testObject: test.object,
clickHandler() {
this.$emit('showMore')
}
},
}
</script>

<style lang="scss" scoped>

.u-avatar-group {
@include flex;

&__item {
margin-left: -10px;
position: relative;

&--no-indent {
// 如果你想质疑作者不会使用:first-child,说明你太年轻,因为nvue不支持
margin-left: 0;
}

&__show-more {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background-color: rgba(0, 0, 0, 0.3);
@include flex;
align-items: center;
justify-content: center;
border-radius: 100px;
}
}
}
</style>

+ 28
- 0
uni_modules/uview-plus/components/u-avatar/avatar.js Прегледај датотеку

@@ -0,0 +1,28 @@
/*
* @Author : LQ
* @Description :
* @version : 3.0
* @Date : 2021-08-20 16:44:21
* @LastAuthor : jry
* @lastTime : 2025-12-19 08:55:21
* @FilePath : /uview-plus/libs/config/props/avatar.js
*/
export default {
// avatar 组件
avatar: {
src: '',
shape: 'circle',
size: 40,
mode: 'scaleToFill',
text: '',
bgColor: '#c0c4cc',
color: '#ffffff',
fontSize: 18,
icon: '',
mpAvatar: false,
randomBgColor: false,
defaultUrl: '',
colorIndex: '',
name: ''
}
}

+ 84
- 0
uni_modules/uview-plus/components/u-avatar/props.js Прегледај датотеку

@@ -0,0 +1,84 @@
import { defineMixin } from '../../libs/vue'
import AvatarDefaultProps from './avatar'
import { registerComponentProps } from '../../libs/config/props.js'

const defProps = registerComponentProps(AvatarDefaultProps)
import test from '../../libs/function/test';
export const props = defineMixin({
props: {
// 头像图片路径(不能为相对路径)
src: {
type: String,
default: () => defProps.avatar.src
},
// 头像形状,circle-圆形,square-方形
shape: {
type: String,
default: () => defProps.avatar.shape
},
// 头像尺寸
size: {
type: [String, Number],
default: () => defProps.avatar.size
},
// 裁剪模式
mode: {
type: String,
default: () => defProps.avatar.mode
},
// 显示的文字
text: {
type: String,
default: () => defProps.avatar.text
},
// 背景色
bgColor: {
type: String,
default: () => defProps.avatar.bgColor
},
// 文字颜色
color: {
type: String,
default: () => defProps.avatar.color
},
// 文字大小
fontSize: {
type: [String, Number],
default: () => defProps.avatar.fontSize
},
// 显示的图标
icon: {
type: String,
default: () => defProps.avatar.icon
},
// 显示小程序头像,只对百度,微信,QQ小程序有效
mpAvatar: {
type: Boolean,
default: () => defProps.avatar.mpAvatar
},
// 是否使用随机背景色
randomBgColor: {
type: Boolean,
default: () => defProps.avatar.randomBgColor
},
// 加载失败的默认头像(组件有内置默认图片)
defaultUrl: {
type: String,
default: () => defProps.avatar.defaultUrl
},
// 如果配置了randomBgColor为true,且配置了此值,则从默认的背景色数组中取出对应索引的颜色值,取值0-19之间
colorIndex: {
type: [String, Number],
// 校验参数规则,索引在0-19之间
validator(n) {
return test.range(n, [0, 19]) || n === ''
},
default: () => defProps.avatar.colorIndex
},
// 组件标识符
name: {
type: String,
default: () => defProps.avatar.name
}
}
})

+ 179
- 0
uni_modules/uview-plus/components/u-avatar/u-avatar.vue Прегледај датотеку

@@ -0,0 +1,179 @@
<template>
<view
class="u-avatar"
:class="[`u-avatar--${shape}`]"
:style="[{
backgroundColor: (text || icon) ? (randomBgColor ? colors[colorIndex !== '' ? colorIndex : random(0, 19)] : bgColor) : 'transparent',
width: addUnit(size),
height: addUnit(size),
}, addStyle(customStyle)]"
@tap="clickHandler"
>
<slot>
<!-- #ifdef MP-WEIXIN || MP-QQ || MP-BAIDU -->
<open-data
v-if="mpAvatar && allowMp"
type="userAvatarUrl"
:style="[{
width: addUnit(size),
height: addUnit(size)
}]"
/>
<!-- #endif -->
<!-- #ifndef MP-WEIXIN && MP-QQ && MP-BAIDU -->
<template v-if="mpAvatar && allowMp"></template>
<!-- #endif -->
<up-icon
v-else-if="icon"
:name="icon"
:size="fontSize"
:color="color"
></up-icon>
<up-text
v-else-if="text"
:text="text"
:size="fontSize"
:color="color"
align="center"
customStyle="justify-content: center"
></up-text>
<image
class="u-avatar__image"
v-else
:class="[`u-avatar__image--${shape}`]"
:src="avatarUrl || defaultUrl"
:mode="mode"
@error="errorHandler"
:style="[{
width: addUnit(size),
height: addUnit(size)
}]"
></image>
</slot>
</view>
</template>

<script>
import { props } from './props';
import { mpMixin } from '../../libs/mixin/mpMixin';
import { mixin } from '../../libs/mixin/mixin';
import { addStyle, addUnit, random } from '../../libs/function/index';
const base64Avatar =
"data:image/jpg;base64,/9j/4QAYRXhpZgAASUkqAAgAAAAAAAAAAAAAAP/sABFEdWNreQABAAQAAAA8AAD/4QMraHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLwA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/PiA8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJBZG9iZSBYTVAgQ29yZSA1LjMtYzAxMSA2Ni4xNDU2NjEsIDIwMTIvMDIvMDYtMTQ6NTY6MjcgICAgICAgICI+IDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+IDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bXA6Q3JlYXRvclRvb2w9IkFkb2JlIFBob3Rvc2hvcCBDUzYgKFdpbmRvd3MpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjREMEQwRkY0RjgwNDExRUE5OTY2RDgxODY3NkJFODMxIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOjREMEQwRkY1RjgwNDExRUE5OTY2RDgxODY3NkJFODMxIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6NEQwRDBGRjJGODA0MTFFQTk5NjZEODE4Njc2QkU4MzEiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6NEQwRDBGRjNGODA0MTFFQTk5NjZEODE4Njc2QkU4MzEiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz7/7gAOQWRvYmUAZMAAAAAB/9sAhAAGBAQEBQQGBQUGCQYFBgkLCAYGCAsMCgoLCgoMEAwMDAwMDBAMDg8QDw4MExMUFBMTHBsbGxwfHx8fHx8fHx8fAQcHBw0MDRgQEBgaFREVGh8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx//wAARCADIAMgDAREAAhEBAxEB/8QAcQABAQEAAwEBAAAAAAAAAAAAAAUEAQMGAgcBAQAAAAAAAAAAAAAAAAAAAAAQAAIBAwICBgkDBQAAAAAAAAABAhEDBCEFMVFBYXGREiKBscHRMkJSEyOh4XLxYjNDFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8A/fAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHbHFyZ/Dam+yLA+Z2L0Pjtyj2poD4AAAAAAAAAAAAAAAAAAAAAAAAKWFs9y6lcvvwQeqj8z9wFaziY1n/HbUX9XF97A7QAGXI23EvJ1goyfzR0YEfN269jeZ+a03pNe0DIAAAAAAAAAAAAAAAAAAAACvtO3RcVkXlWutuL9YFYAAAAAOJRjKLjJVi9GmB5/csH/mu1h/in8PU+QGMAAAAAAAAAAAAAAAAAAaMDG/6MmMH8C80+xAelSSVFolwQAAAAAAAHVlWI37ErUulaPk+hgeYnCUJuElSUXRrrQHAAAAAAAAAAAAAAAAABa2Oz4bM7r4zdF2ICmAAAAAAAAAg7zZ8GX41wuJP0rRgYAAAAAAAAAAAAAAAAAD0m2R8ODaXU33tsDSAAAAAAAAAlb9HyWZcnJd9PcBHAAAAAAAAAAAAAAAAAPS7e64Vn+KA0AAAAAAAAAJm+v8Ftf3ewCKAAAAAAAAAAAAAAAAAX9muqeGo9NttP06+0DcAAAAAAAAAjb7dTu2ra+VOT9P8AQCWAAAAAAAAAAAAAAAAAUNmyPt5Ltv4bui/kuAF0AAAAAAADiUlGLlJ0SVW+oDzOXfd/Ind6JPRdS0QHSAAAAAAAAAAAAAAAAAE2nVaNcGB6Lbs6OTao9LsF51z60BrAAAAAABJ3jOVHjW3r/sa9QEgAAAAAAAAAAAAAAAAAAAPu1duWriuW34ZR4MC9hbnZyEoy8l36XwfYBsAAADaSq9EuLAlZ+7xSdrGdW9Hc5dgEdtt1erfFgAAAAAAAAAAAAAAAAADVjbblX6NR8MH80tEBRs7HYivyzlN8lovaBPzduvY0m6eK10TXtAyAarO55lpJK54orolr+4GqO/Xaea1FvqbXvA+Z77kNeW3GPbV+4DJfzcm/pcm3H6Vou5AdAFLC2ed2Pjv1txa8sV8T6wOL+yZEKu1JXFy4MDBOE4ScZxcZLinoB8gAAAAAAAAAAAB242LeyJ+C3GvN9C7QLmJtePYpKS+5c+p8F2IDYAANJqj1T4oCfk7Nj3G5Wn9qXJax7gJ93Z82D8sVNc4v30A6Xg5i42Z+iLfqARwcyT0sz9MWvWBps7LlTf5Grce9/oBTxdtxseklHxT+uWr9AGoAB138ezfj4bsFJdD6V2MCPm7RdtJzs1uW1xXzL3gTgAAAAAAAAADRhYc8q74I6RWs5ckB6GxYtWLat21SK731sDsAAAAAAAAAAAAAAAASt021NO/YjrxuQXT1oCOAAAAAAABzGLlJRSq26JAelwsWONYjbXxcZvmwO8AAAAAAAAAAAAAAAAAAef3TEWPkVivx3NY9T6UBiAAAAAABo2+VmGXblddIJ8eivRUD0oAAAAAAAAAAAAAAAAAAAYt4tKeFKVNYNSXfRgefAAAAAAAAr7VuSSWPedKaW5v1MCsAAAAAAAAAAAAAAAAAAIe6bj96Ts2n+JPzSXzP3ATgAAAAAAAAFbbt1UUrOQ9FpC4/UwK6aaqtU+DAAAAAAAAAAAAAAA4lKMIuUmoxWrb4ARNx3R3q2rLpa4Sl0y/YCcAAAAAAAAAAANmFud7G8r89r6X0dgFvGzLGRGtuWvTF6NAdwAAAAAAAAAAAy5W442PVN+K59EePp5ARMvOv5MvO6QXCC4AZwAAAAAAAAAAAAAcxlKLUotprg1owN+PvORborq+7Hnwl3gUbO74VzRydt8pKn68ANcJwmqwkpLmnUDkAAAAfNy9atqtyagut0AxXt5xIV8Fbj6lRd7Am5G65V6qUvtwfyx94GMAAAAAAAAAAAAAAAAAAAOU2nVOj5gdsc3LiqRvTpyqwOxbnnrhdfpSfrQB7pnv/AGvuS9gHXPMy5/Fem1yq0v0A6W29XqwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf//Z";
/**
* Avatar 头像
* @description 本组件一般用于展示头像的地方,如个人中心,或者评论列表页的用户头像展示等场所。
* @tutorial https://uview-plus.jiangruyi.com/components/avatar.html
*
* @property {String} src 头像路径,如加载失败,将会显示默认头像(不能为相对路径)
* @property {String} shape 头像形状 ( circle (默认) | square)
* @property {String | Number} size 头像尺寸,可以为指定字符串(large, default, mini),或者数值 (默认 40 )
* @property {String} mode 头像图片的裁剪类型,与uni的image组件的mode参数一致,如效果达不到需求,可尝试传widthFix值 (默认 'scaleToFill' )
* @property {String} text 用文字替代图片,级别优先于src
* @property {String} bgColor 背景颜色,一般显示文字时用 (默认 '#c0c4cc' )
* @property {String} color 文字颜色 (默认 '#ffffff' )
* @property {String | Number} fontSize 文字大小 (默认 18 )
* @property {String} icon 显示的图标
* @property {Boolean} mpAvatar 显示小程序头像,只对百度,微信,QQ小程序有效 (默认 false )
* @property {Boolean} randomBgColor 是否使用随机背景色 (默认 false )
* @property {String} defaultUrl 加载失败的默认头像(组件有内置默认图片)
* @property {String | Number} colorIndex 如果配置了randomBgColor为true,且配置了此值,则从默认的背景色数组中取出对应索引的颜色值,取值0-19之间
* @property {String} name 组件标识符 (默认 'level' )
* @property {Object} customStyle 定义需要用到的外部样式
*
* @event {Function} click 点击组件时触发 index: 用户传递的标识符
* @example <u-avatar :src="src" mode="square"></u-avatar>
*/
export default {
name: 'u-avatar',
mixins: [mpMixin, mixin, props],
data() {
return {
// 如果配置randomBgColor参数为true,在图标或者文字的模式下,会随机从中取出一个颜色值当做背景色
colors: ['#ffb34b', '#f2bba9', '#f7a196', '#f18080', '#88a867', '#bfbf39', '#89c152', '#94d554', '#f19ec2',
'#afaae4', '#e1b0df', '#c38cc1', '#72dcdc', '#9acdcb', '#77b1cc', '#448aca', '#86cefa', '#98d1ee',
'#73d1f1',
'#80a7dc'
],
avatarUrl: this.src,
allowMp: false
}
},
watch: {
// 监听头像src的变化,赋值给内部的avatarUrl变量,因为图片加载失败时,需要修改图片的src为默认值
// 而组件内部不能直接修改props的值,所以需要一个中间变量
src: {
immediate: true,
handler(newVal) {
this.avatarUrl = newVal
// 如果没有传src,则主动触发error事件,用于显示默认的头像,否则src为''空字符等的时候,会无内容展示
if(!newVal) {
this.errorHandler()
}
}
}
},
computed: {
imageStyle() {
const style = {}
return style
}
},
created() {
this.init()
},
emits: ["click"],
methods: {
addStyle,
addUnit,
random,
init() {
// 目前只有这几个小程序平台具有open-data标签
// 其他平台可以通过uni.getUserInfo类似接口获取信息,但是需要弹窗授权(首次),不合符组件逻辑
// 故目前自动获取小程序头像只支持这几个平台
// #ifdef MP-WEIXIN || MP-QQ || MP-BAIDU
this.allowMp = true
// #endif
},
// 判断传入的name属性,是否图片路径,只要带有"/"均认为是图片形式
isImg() {
return this.src.indexOf('/') !== -1
},
// 图片加载时失败时触发
errorHandler() {
this.avatarUrl = this.defaultUrl || base64Avatar
},
clickHandler(e) {
this.$emit('click', this.name, e)
}
}
}
</script>

<style lang="scss" scoped>

.u-avatar {
@include flex;
align-items: center;
justify-content: center;

&--circle {
border-radius: 100px;
}

&--square {
border-radius: 4px;
}

&__image {
&--circle {
border-radius: 100px;
overflow: hidden;
}

&--square {
border-radius: 4px;
}
}
}
</style>

+ 27
- 0
uni_modules/uview-plus/components/u-back-top/backtop.js Прегледај датотеку

@@ -0,0 +1,27 @@
/*
* @Author : LQ
* @Description :
* @version : 3.0
* @Date : 2021-08-20 16:44:21
* @LastAuthor : jry
* @lastTime : 2025-12-19 08:55:21
* @FilePath : /uview-plus/libs/config/props/backtop.js
*/
export default {
// backtop组件
backtop: {
mode: 'circle',
icon: 'arrow-upward',
text: '',
duration: 100,
scrollTop: 0,
top: 400,
bottom: 100,
right: 20,
zIndex: 9,
iconStyle: {
color: '#909399',
fontSize: '19px'
}
}
}

+ 59
- 0
uni_modules/uview-plus/components/u-back-top/props.js Прегледај датотеку

@@ -0,0 +1,59 @@
import { defineMixin } from '../../libs/vue'
import BackTopDefaultProps from './backtop'
import { registerComponentProps } from '../../libs/config/props.js'

const defProps = registerComponentProps(BackTopDefaultProps)
export const props = defineMixin({
props: {
// 返回顶部的形状,circle-圆形,square-方形
mode: {
type: String,
default: () => defProps.backtop.mode
},
// 自定义图标
icon: {
type: String,
default: () => defProps.backtop.icon
},
// 提示文字
text: {
type: String,
default: () => defProps.backtop.text
},
// 返回顶部滚动时间
duration: {
type: [String, Number],
default: () => defProps.backtop.duration
},
// 滚动距离
scrollTop: {
type: [String, Number],
default: () => defProps.backtop.scrollTop
},
// 距离顶部多少距离显示,单位px
top: {
type: [String, Number],
default: () => defProps.backtop.top
},
// 返回顶部按钮到底部的距离,单位px
bottom: {
type: [String, Number],
default: () => defProps.backtop.bottom
},
// 返回顶部按钮到右边的距离,单位px
right: {
type: [String, Number],
default: () => defProps.backtop.right
},
// 层级
zIndex: {
type: [String, Number],
default: () => defProps.backtop.zIndex
},
// 图标的样式,对象形式
iconStyle: {
type: Object,
default: () => defProps.backtop.iconStyle
}
}
})

+ 132
- 0
uni_modules/uview-plus/components/u-back-top/u-back-top.vue Прегледај датотеку

@@ -0,0 +1,132 @@
<template>
<u-transition
mode="fade"
:customStyle="backTopStyle"
:show="show"
>
<view
class="u-back-top"
:style="[contentStyle]"
v-if="!$slots.default && !$slots.$default"
@click="backToTop"
>
<up-icon
:name="icon"
:custom-style="iconStyle"
></up-icon>
<text
v-if="text"
class="u-back-top__text"
>{{text}}</text>
</view>
<slot v-else />
</u-transition>
</template>

<script>
import { props } from './props';
import { mpMixin } from '../../libs/mixin/mpMixin';
import { mixin } from '../../libs/mixin/mixin';
import { addUnit, addStyle, getPx, deepMerge, error } from '../../libs/function/index';
// #ifdef APP-NVUE
const dom = weex.requireModule('dom')
// #endif
/**
* backTop 返回顶部
* @description 本组件一个用于长页面,滑动一定距离后,出现返回顶部按钮,方便快速返回顶部的场景。
* @tutorial https://uview-plus.jiangruyi.com/components/backTop.html
*
* @property {String} mode 返回顶部的形状,circle-圆形,square-方形 (默认 'circle' )
* @property {String} icon 自定义图标 (默认 'arrow-upward' ) 见官方文档示例
* @property {String} text 提示文字
* @property {String | Number} duration 返回顶部滚动时间 (默认 100)
* @property {String | Number} scrollTop 滚动距离 (默认 0 )
* @property {String | Number} top 距离顶部多少距离显示,单位px (默认 400 )
* @property {String | Number} bottom 返回顶部按钮到底部的距离,单位px (默认 100 )
* @property {String | Number} right 返回顶部按钮到右边的距离,单位px (默认 20 )
* @property {String | Number} zIndex 层级 (默认 9 )
* @property {Object<Object>} iconStyle 图标的样式,对象形式 (默认 {color: '#909399',fontSize: '19px'})
* @property {Object} customStyle 定义需要用到的外部样式
*
* @example <u-back-top :scrollTop="scrollTop"></u-back-top>
*/
export default {
name: 'u-back-top',
mixins: [mpMixin, mixin, props],
computed: {
backTopStyle() {
// 动画组件样式
const style = {
bottom: addUnit(this.bottom),
right: addUnit(this.right),
width: '40px',
height: '40px',
position: 'fixed',
zIndex: 10,
}
return style
},
show() {
return getPx(this.scrollTop) > getPx(this.top)
},
contentStyle() {
const style = {}
let radius = 0
// 是否圆形
if(this.mode === 'circle') {
radius = '100px'
} else {
radius = '4px'
}
// 为了兼容安卓nvue,只能这么分开写
style.borderTopLeftRadius = radius
style.borderTopRightRadius = radius
style.borderBottomLeftRadius = radius
style.borderBottomRightRadius = radius
return deepMerge(style, addStyle(this.customStyle))
}
},
emits: ["click"],
methods: {
backToTop() {
// #ifdef APP-NVUE
if (!this.$parent.$refs['u-back-top']) {
error(`nvue页面需要给页面最外层元素设置"ref='u-back-top'`)
}
dom.scrollToElement(this.$parent.$refs['u-back-top'], {
offset: 0
})
// #endif
// #ifndef APP-NVUE
uni.pageScrollTo({
scrollTop: 0,
duration: this.duration
});
// #endif
this.$emit('click')
}
}
}
</script>

<style lang="scss" scoped>
$u-back-top-flex:1 !default;
$u-back-top-height:100% !default;
$u-back-top-background-color:#E1E1E1 !default;
$u-back-top-tips-font-size:12px !default;
.u-back-top {
@include flex;
flex-direction: column;
align-items: center;
flex:$u-back-top-flex;
height: $u-back-top-height;
justify-content: center;
background-color: $u-back-top-background-color;

&__tips {
font-size:$u-back-top-tips-font-size;
transform: scale(0.8);
}
}
</style>

+ 27
- 0
uni_modules/uview-plus/components/u-badge/badge.js Прегледај датотеку

@@ -0,0 +1,27 @@
/*
* @Author : LQ
* @Description :
* @version : 3.0
* @Date : 2021-08-20 16:44:21
* @LastAuthor : jry
* @lastTime : 2025-12-19 08:55:21
* @FilePath : /uview-plus/libs/config/props/badge.js
*/
export default {
// 徽标数组件
badge: {
isDot: false,
value: '',
show: true,
max: 999,
type: 'error',
showZero: false,
bgColor: null,
color: null,
shape: 'circle',
numberType: 'overflow',
offset: [],
inverted: false,
absolute: false
}
}

+ 82
- 0
uni_modules/uview-plus/components/u-badge/props.js Прегледај датотеку

@@ -0,0 +1,82 @@
import { defineMixin } from '../../libs/vue'
import BadgeDefaultProps from './badge'
import { registerComponentProps } from '../../libs/config/props.js'

const defProps = registerComponentProps(BadgeDefaultProps)
export const props = defineMixin({
props: {
// 是否显示圆点
isDot: {
type: Boolean,
default: () => defProps.badge.isDot
},
// 显示的内容
value: {
type: [Number, String],
default: () => defProps.badge.value
},
// 显示的内容
modelValue: {
type: [Number, String],
default: () => defProps.badge.modelValue
},
// 是否显示
show: {
type: Boolean,
default: () => defProps.badge.show
},
// 最大值,超过最大值会显示 '{max}+'
max: {
type: [Number, String],
default: () => defProps.badge.max
},
// 主题类型,error|warning|success|primary
type: {
type: String,
default: () => defProps.badge.type
},
// 当数值为 0 时,是否展示 Badge
showZero: {
type: Boolean,
default: () => defProps.badge.showZero
},
// 背景颜色,优先级比type高,如设置,type参数会失效
bgColor: {
type: [String, null],
default: () => defProps.badge.bgColor
},
// 字体颜色
color: {
type: [String, null],
default: () => defProps.badge.color
},
// 徽标形状,circle-四角均为圆角,horn-左下角为直角
shape: {
type: String,
default: () => defProps.badge.shape
},
// 设置数字的显示方式,overflow|ellipsis|limit
// overflow会根据max字段判断,超出显示`${max}+`
// ellipsis会根据max判断,超出显示`${max}...`
// limit会依据1000作为判断条件,超出1000,显示`${value/1000}K`,比如2.2k、3.34w,最多保留2位小数
numberType: {
type: String,
default: () => defProps.badge.numberType
},
// 设置badge的位置偏移,格式为 [x, y],也即设置的为top和right的值,absolute为true时有效
offset: {
type: Array,
default: () => defProps.badge.offset
},
// 是否反转背景和字体颜色
inverted: {
type: Boolean,
default: () => defProps.badge.inverted
},
// 是否绝对定位
absolute: {
type: Boolean,
default: () => defProps.badge.absolute
}
}
})

+ 176
- 0
uni_modules/uview-plus/components/u-badge/u-badge.vue Прегледај датотеку

@@ -0,0 +1,176 @@
<template>
<text
v-if="show && ((Number(value) === 0 ? showZero : true) || isDot)"
:class="[isDot ? 'u-badge--dot' : 'u-badge--not-dot', inverted && 'u-badge--inverted', shape === 'horn' && 'u-badge--horn', `u-badge--${type}${inverted ? '--inverted' : ''}`]"
:style="[addStyle(customStyle), badgeStyle]"
class="u-badge"
>{{ isDot ? '' :showValue }}</text>
</template>

<script>
import { props } from './props';
import { mpMixin } from '../../libs/mixin/mpMixin';
import { mixin } from '../../libs/mixin/mixin';
import { addStyle, addUnit } from '../../libs/function/index';
/**
* badge 徽标数
* @description 该组件一般用于图标右上角显示未读的消息数量,提示用户点击,有圆点和圆包含文字两种形式。
* @tutorial https://uview-plus.jiangruyi.com/components/badge.html
*
* @property {Boolean} isDot 是否显示圆点 (默认 false )
* @property {String | Number} value 显示的内容
* @property {Boolean} show 是否显示 (默认 true )
* @property {String | Number} max 最大值,超过最大值会显示 '{max}+' (默认999)
* @property {String} type 主题类型,error|warning|success|primary (默认 'error' )
* @property {Boolean} showZero 当数值为 0 时,是否展示 Badge (默认 false )
* @property {String} bgColor 背景颜色,优先级比type高,如设置,type参数会失效
* @property {String} color 字体颜色 (默认 '#ffffff' )
* @property {String} shape 徽标形状,circle-四角均为圆角,horn-左下角为直角 (默认 'circle' )
* @property {String} numberType 设置数字的显示方式,overflow|ellipsis|limit (默认 'overflow' )
* @property {Array}} offset 设置badge的位置偏移,格式为 [x, y],也即设置的为top和right的值,absolute为true时有效
* @property {Boolean} inverted 是否反转背景和字体颜色(默认 false )
* @property {Boolean} absolute 是否绝对定位(默认 false )
* @property {Object} customStyle 定义需要用到的外部样式
* @example <u-badge :type="type" :count="count"></u-badge>
*/
export default {
name: 'u-badge',
mixins: [mpMixin, props, mixin],
computed: {
// 是否将badge中心与父组件右上角重合
boxStyle() {
let style = {};
return style;
},
// 整个组件的样式
badgeStyle() {
const style = {}
if(this.color) {
style.color = this.color
}
if (this.bgColor && !this.inverted) {
style.backgroundColor = this.bgColor
}
if (this.absolute) {
style.position = 'absolute'
// 如果有设置offset参数
if(this.offset.length) {
// top和right分为为offset的第一个和第二个值,如果没有第二个值,则right等于top
const top = this.offset[0]
const right = this.offset[1] || top
style.top = addUnit(top)
style.right = addUnit(right)
}
}
return style
},
showValue() {
switch (this.numberType) {
case "overflow":
return Number(this.value) > Number(this.max) ? this.max + "+" : this.value
break;
case "ellipsis":
return Number(this.value) > Number(this.max) ? "..." : this.value
break;
case "limit":
return Number(this.value) > 999 ? Number(this.value) >= 9999 ?
Math.floor(this.value / 1e4 * 100) / 100 + "w" : Math.floor(this.value /
1e3 * 100) / 100 + "k" : this.value
break;
default:
return Number(this.value)
}
},
},
methods: {
addStyle
}
}
</script>

<style lang="scss" scoped>

$u-badge-primary: $u-primary !default;
$u-badge-error: $u-error !default;
$u-badge-success: $u-success !default;
$u-badge-info: $u-info !default;
$u-badge-warning: $u-warning !default;
$u-badge-dot-radius: 100px !default;
$u-badge-dot-size: 8px !default;
$u-badge-dot-right: 4px !default;
$u-badge-dot-top: 0 !default;
$u-badge-text-font-size: 11px !default;
$u-badge-text-right: 10px !default;
$u-badge-text-padding: 2px 5px !default;
$u-badge-text-align: center !default;
$u-badge-text-color: #FFFFFF !default;

.u-badge {
border-top-right-radius: $u-badge-dot-radius;
border-top-left-radius: $u-badge-dot-radius;
border-bottom-left-radius: $u-badge-dot-radius;
border-bottom-right-radius: $u-badge-dot-radius;
@include flex;
line-height: $u-badge-text-font-size;
text-align: $u-badge-text-align;
font-size: $u-badge-text-font-size;
color: $u-badge-text-color;

&--dot {
height: $u-badge-dot-size;
width: $u-badge-dot-size;
}
&--inverted {
font-size: 13px;
}
&--not-dot {
padding: $u-badge-text-padding;
}

&--horn {
border-bottom-left-radius: 0;
}

&--primary {
background-color: $u-badge-primary;
}
&--primary--inverted {
color: $u-badge-primary;
}

&--error {
background-color: $u-badge-error;
}
&--error--inverted {
color: $u-badge-error;
}

&--success {
background-color: $u-badge-success;
}
&--success--inverted {
color: $u-badge-success;
}

&--info {
background-color: $u-badge-info;
}
&--info--inverted {
color: $u-badge-info;
}

&--warning {
background-color: $u-badge-warning;
}
&--warning--inverted {
color: $u-badge-warning;
}
}
</style>

+ 1031
- 0
uni_modules/uview-plus/components/u-barcode/u-barcode.vue
Разлика између датотеке није приказан због своје велике величине
Прегледај датотеку


+ 24
- 0
uni_modules/uview-plus/components/u-box/box.js Прегледај датотеку

@@ -0,0 +1,24 @@
/*
* @Author : ly
* @Description :
* @version : 4.0
* @Date : 2026-02-02 23:54:58
* @LastAuthor : ly
* @lastTime : 2026-02-02 23:54:58
* @FilePath : /uview-ultra/components/up-box/box.js
*/
export default {
// box 组件
box: {
bgColors: ['#EEFCFF', '#FCF8FF', '#FDF8F2'],
height: '160px',
borderRadius: '6px',
gap: '15px',
leftIcon: '',
leftTitle: '左',
rightTopIcon: '',
rightTopTitle: '右上',
rightBottomIcon: '',
rightBottomTitle: '右下'
}
}

+ 60
- 0
uni_modules/uview-plus/components/u-box/props.js Прегледај датотеку

@@ -0,0 +1,60 @@
import { defineMixin } from '../../libs/vue.js'
import BoxDefaultProps from './box.js'
import { registerComponentProps } from '../../libs/config/props.js'

const defProps = registerComponentProps(BoxDefaultProps)

export const propsBox = defineMixin({
props: {
// 背景色
bgColors: {
type: [Array],
default: () => defProps.box.bgColors
},
// 高度
height: {
type: [String],
default: () => defProps.box.height
},
// 圆角
borderRadius: {
type: [String],
default: () => defProps.box.borderRadius
},
// 间隔
gap: {
type: [String],
default: () => defProps.box.gap
},
// 左侧图标
leftIcon: {
type: [String],
default: () => defProps.box.leftIcon
},
// 左侧文案
leftTitle: {
type: [String],
default: () => defProps.box.leftTitle
},
// 右上图标
rightTopIcon: {
type: [String],
default: () => defProps.box.rightTopIcon
},
// 右上文案
rightTopTitle: {
type: [String],
default: () => defProps.box.rightTopTitle
},
// 右下图标
rightBottomIcon: {
type: [String],
default: () => defProps.box.rightBottomIcon
},
// 右下文案
rightBottomTitle: {
type: [String],
default: () => defProps.box.rightBottomTitle
},
}
})

+ 106
- 0
uni_modules/uview-plus/components/u-box/u-box.vue Прегледај датотеку

@@ -0,0 +1,106 @@
<template>
<view class="u-box" :style="[{height: height}, addStyle(customStyle)]">
<view class="u-box__left" :style="{borderRadius: borderRadius, backgroundColor: bgColors[0]}">
<slot name="left">
<view class="flex flex-row items-center justify-center">
<u-icon size="36" :name="leftIcon"></u-icon>
<text class="ml-2 text-16px">{{leftTitle}}</text>
</view>
</slot>
</view>
<view class="u-box__gap" :style="{width: gap, height: height}"></view>
<view class="u-box__right">
<view class="u-box__right-top" :style="{borderRadius: borderRadius, backgroundColor: bgColors[1]}">
<slot name="rightTop">
<view class="flex flex-row items-center justify-center">
<u-icon size="36" :name="rightTopIcon"></u-icon>
<text class="ml-2 text-15px">{{rightTopTitle}}</text>
</view>
</slot>
</view>
<view class="u-box__right-gap" :style="{height: gap}"></view>
<view class="u-box__right-bottom" :style="{borderRadius: borderRadius, backgroundColor: bgColors[2]}">
<slot name="rightBottom">
<view class="flex flex-row items-center justify-center">
<u-icon size="36" :name="rightBottomIcon"></u-icon>
<text class="ml-2 text-15px">{{rightBottomTitle}}</text>
</view>
</slot>
</view>
</view>
</view>
</template>

<script>
import { propsBox } from './props';
import { mpMixin } from '../../libs/mixin/mpMixin';
import { mixin } from '../../libs/mixin/mixin';
import { addStyle } from '../../libs/function/index';
import test from '../../libs/function/test';
/**
* box 盒子
* @description box盒子一般为左边一个盒子,右侧两个等高的半盒组成,常用于App首页座位重点突出。
* @tutorial https://uview-plus.jiangruyi.com/components/box.html
* @property {Array} bgColors 背景色
* @property {String} height 高度
* @property {String} borderRadius 圆角
* @property {Object} customStyle 定义需要用到的外部样式
*
* @event {Function} click 点击cell列表时触发
* @example <up-box colors=['blue', 'red', 'yellow'] height="200px"></up-box>
*/
export default {
name: 'up-box',
data() {
return {
}
},
mixins: [mpMixin, mixin, propsBox],
computed: {
},
emits: [],
methods: {
addStyle,
}
}
</script>

<style lang="scss" scoped>

.u-box {
/* #ifndef APP-NVUE */
/* #endif */
@include flex();
flex: 1;

&__left {
@include flex();
justify-content: center;
align-items: center;
flex: 1;
}
&__gap {
@include flex();
flex-direction: column;
}
&__right {
@include flex();
flex-direction: column;
flex: 1;
}

&__right-top {
@include flex();
flex: 1;
justify-content: center;
align-items: center;
}

&__right-bottom {
@include flex();
flex: 1;
justify-content: center;
align-items: center;
}
}
</style>

+ 43
- 0
uni_modules/uview-plus/components/u-button/button.js Прегледај датотеку

@@ -0,0 +1,43 @@
/*
* @Author : LQ
* @Description :
* @version : 3.0
* @Date : 2021-08-20 16:44:21
* @LastAuthor : jry
* @lastTime : 2025-12-19 08:55:21
* @FilePath : /uview-plus/libs/config/props/button.js
*/
export default {
// button组件
button: {
hairline: false,
type: 'info',
size: 'normal',
shape: 'square',
plain: false,
disabled: false,
loading: false,
loadingText: '',
loadingMode: 'spinner',
loadingSize: 15,
openType: '',
formType: '',
appParameter: '',
hoverStopPropagation: true,
lang: 'en',
sessionFrom: '',
sendMessageTitle: '',
sendMessagePath: '',
sendMessageImg: '',
showMessageCard: false,
dataName: '',
throttleTime: 0,
hoverStartTime: 0,
hoverStayTime: 200,
text: '',
icon: '',
iconColor: '',
color: '',
stop: true,
}
}

+ 46
- 0
uni_modules/uview-plus/components/u-button/nvue.scss Прегледај датотеку

@@ -0,0 +1,46 @@
$u-button-active-opacity:0.75 !default;
$u-button-loading-text-margin-left:4px !default;
$u-button-text-color: #FFFFFF !default;
$u-button-text-plain-error-color:$u-error !default;
$u-button-text-plain-warning-color:$u-warning !default;
$u-button-text-plain-success-color:$u-success !default;
$u-button-text-plain-info-color:$u-info !default;
$u-button-text-plain-primary-color:$u-primary !default;
.u-button {
&--active {
opacity: $u-button-active-opacity;
}
&--active--plain {
background-color: rgb(217, 217, 217);
}
&__loading-text {
margin-left:$u-button-loading-text-margin-left;
}
&__text,
&__loading-text {
color:$u-button-text-color;
}
&__text--plain--error {
color:$u-button-text-plain-error-color;
}
&__text--plain--warning {
color:$u-button-text-plain-warning-color;
}
&__text--plain--success{
color:$u-button-text-plain-success-color;
}
&__text--plain--info {
color:$u-button-text-plain-info-color;
}
&__text--plain--primary {
color:$u-button-text-plain-primary-color;
}
}

+ 162
- 0
uni_modules/uview-plus/components/u-button/props.js Прегледај датотеку

@@ -0,0 +1,162 @@
import { defineMixin } from '../../libs/vue'
import ButtonDefaultProps from './button'
import { registerComponentProps } from '../../libs/config/props.js'

const defProps = registerComponentProps(ButtonDefaultProps)
export const props = defineMixin({
props: {
// 是否细边框
hairline: {
type: Boolean,
default: () => defProps.button.hairline
},
// 按钮的预置样式,info,primary,error,warning,success
type: {
type: String,
default: () => defProps.button.type
},
// 按钮尺寸,large,normal,small,mini
size: {
type: String,
default: () => defProps.button.size
},
// 按钮形状,circle(两边为半圆),square(带圆角)
shape: {
type: String,
default: () => defProps.button.shape
},
// 按钮是否镂空
plain: {
type: Boolean,
default: () => defProps.button.plain
},
// 是否禁止状态
disabled: {
type: Boolean,
default: () => defProps.button.disabled
},
// 是否加载中
loading: {
type: Boolean,
default: () => defProps.button.loading
},
// 加载中提示文字
loadingText: {
type: [String, Number],
default: () => defProps.button.loadingText
},
// 加载状态图标类型
loadingMode: {
type: String,
default: () => defProps.button.loadingMode
},
// 加载图标大小
loadingSize: {
type: [String, Number],
default: () => defProps.button.loadingSize
},
// 开放能力,具体请看uniapp稳定关于button组件部分说明
// https://uniapp.dcloud.io/component/button
openType: {
type: String,
default: () => defProps.button.openType
},
// 用于 <form> 组件,点击分别会触发 <form> 组件的 submit/reset 事件
// 取值为submit(提交表单),reset(重置表单)
formType: {
type: String,
default: () => defProps.button.formType
},
// 打开 APP 时,向 APP 传递的参数,open-type=launchApp时有效
// 只微信小程序、QQ小程序有效
appParameter: {
type: String,
default: () => defProps.button.appParameter
},
// 指定是否阻止本节点的祖先节点出现点击态,微信小程序有效
hoverStopPropagation: {
type: Boolean,
default: () => defProps.button.hoverStopPropagation
},
// 指定返回用户信息的语言,zh_CN 简体中文,zh_TW 繁体中文,en 英文。只微信小程序有效
lang: {
type: String,
default: () => defProps.button.lang
},
// 会话来源,open-type="contact"时有效。只微信小程序有效
sessionFrom: {
type: String,
default: () => defProps.button.sessionFrom
},
// 会话内消息卡片标题,open-type="contact"时有效
// 默认当前标题,只微信小程序有效
sendMessageTitle: {
type: String,
default: () => defProps.button.sendMessageTitle
},
// 会话内消息卡片点击跳转小程序路径,open-type="contact"时有效
// 默认当前分享路径,只微信小程序有效
sendMessagePath: {
type: String,
default: () => defProps.button.sendMessagePath
},
// 会话内消息卡片图片,open-type="contact"时有效
// 默认当前页面截图,只微信小程序有效
sendMessageImg: {
type: String,
default: () => defProps.button.sendMessageImg
},
// 是否显示会话内消息卡片,设置此参数为 true,用户进入客服会话会在右下角显示"可能要发送的小程序"提示,
// 用户点击后可以快速发送小程序消息,open-type="contact"时有效
showMessageCard: {
type: Boolean,
default: () => defProps.button.showMessageCard
},
// 额外传参参数,用于小程序的data-xxx属性,通过target.dataset.name获取
dataName: {
type: String,
default: () => defProps.button.dataName
},
// 节流,一定时间内只能触发一次
throttleTime: {
type: [String, Number],
default: () => defProps.button.throttleTime
},
// 按住后多久出现点击态,单位毫秒
hoverStartTime: {
type: [String, Number],
default: () => defProps.button.hoverStartTime
},
// 手指松开后点击态保留时间,单位毫秒
hoverStayTime: {
type: [String, Number],
default: () => defProps.button.hoverStayTime
},
// 按钮文字,之所以通过props传入,是因为slot传入的话
// nvue中无法控制文字的样式
text: {
type: [String, Number],
default: () => defProps.button.text
},
// 按钮图标
icon: {
type: String,
default: () => defProps.button.icon
},
// 按钮图标
iconColor: {
type: String,
default: () => defProps.button.icon
},
// 按钮颜色,支持传入linear-gradient渐变色
color: {
type: String,
default: () => defProps.button.color
},
// 停止冒泡
stop: {
type: Boolean,
default: () => defProps.button.stop
},
}
})

+ 577
- 0
uni_modules/uview-plus/components/u-button/u-button.vue Прегледај датотеку

@@ -0,0 +1,577 @@
<template>
<!-- #ifndef APP-NVUE -->
<button
:hover-start-time="Number(hoverStartTime)"
:hover-stay-time="Number(hoverStayTime)"
:form-type="formType"
:open-type="openType"
:app-parameter="appParameter"
:hover-stop-propagation="hoverStopPropagation"
:send-message-title="sendMessageTitle"
:send-message-path="sendMessagePath"
:lang="lang"
:data-name="dataName"
:session-from="sessionFrom"
:send-message-img="sendMessageImg"
:show-message-card="showMessageCard"
@getphonenumber="getphonenumber"
@getuserinfo="getuserinfo"
@error="error"
@opensetting="opensetting"
@launchapp="launchapp"
@agreeprivacyauthorization="agreeprivacyauthorization"
:hover-class="!disabled && !loading ? 'u-button--active' : ''"
class="u-button u-reset-button"
:style="[baseColor, addStyle(customStyle)]"
@tap="clickHandler"
:class="bemClass"
>
<template v-if="loading">
<u-loading-icon
:mode="loadingMode"
:size="loadingSize * 1.15"
:color="loadingColor"
></u-loading-icon>
<text
class="u-button__loading-text"
:style="[{ fontSize: textSize + 'px' }]"
>{{ loadingText || text }}</text
>
</template>
<template v-else>
<up-icon
v-if="icon"
:name="icon"
:color="iconColorCom"
:size="textSize * 1.35"
:customStyle="{ marginRight: '2px' }"
></up-icon>
<slot>
<text
class="u-button__text"
:style="[{ fontSize: textSize + 'px' }]"
>{{ text }}</text
>
</slot>
</template>
</button>
<!-- #endif -->

<!-- #ifdef APP-NVUE -->
<view
:hover-start-time="Number(hoverStartTime)"
:hover-stay-time="Number(hoverStayTime)"
class="u-button"
:hover-class="
!disabled && !loading && !color && (plain || type === 'info')
? 'u-button--active--plain'
: !disabled && !loading && !plain
? 'u-button--active'
: ''
"
@tap="clickHandler"
:class="bemClass"
:style="[baseColor, addStyle(customStyle)]"
>
<template v-if="loading">
<u-loading-icon
:mode="loadingMode"
:size="loadingSize * 1.15"
:color="loadingColor"
></u-loading-icon>
<text
class="u-button__loading-text"
:style="[nvueTextStyle]"
:class="[plain && `u-button__text--plain--${type}`]"
>{{ loadingText || text }}</text
>
</template>
<template v-else>
<up-icon
v-if="icon"
:name="icon"
:color="iconColorCom"
:size="textSize * 1.35"
></up-icon>
<text
class="u-button__text"
:style="[
{
marginLeft: icon ? '2px' : 0,
},
nvueTextStyle,
]"
:class="[plain && `u-button__text--plain--${type}`]"
>{{ text }}</text
>
</template>
</view>
<!-- #endif -->
</template>

<script lang="ts">
import { buttonMixin } from "../../libs/mixin/button";
import { openType } from "../../libs/mixin/openType";
import { mpMixin } from '../../libs/mixin/mpMixin';
import { mixin } from '../../libs/mixin/mixin';
import { props } from "./props";
import { addStyle } from '../../libs/function/index';
import { throttle } from '../../libs/function/throttle';
import { getThemeVar } from '../../libs/theme/runtime.js';
/**
* button 按钮
* @description Button 按钮
* @tutorial https://uview-plus.jiangruyi.com/components/button.html
*
* @property {Boolean} hairline 是否显示按钮的细边框 (默认 true )
* @property {String} type 按钮的预置样式,info,primary,error,warning,success (默认 'info' )
* @property {String} size 按钮尺寸,large,normal,mini (默认 normal)
* @property {String} shape 按钮形状,circle(两边为半圆),square(带圆角) (默认 'square' )
* @property {Boolean} plain 按钮是否镂空,背景色透明 (默认 false)
* @property {Boolean} disabled 是否禁用 (默认 false)
* @property {Boolean} loading 按钮名称前是否带 loading 图标(App-nvue 平台,在 ios 上为雪花,Android上为圆圈) (默认 false)
* @property {String | Number} loadingText 加载中提示文字
* @property {String} loadingMode 加载状态图标类型 (默认 'spinner' )
* @property {String | Number} loadingSize 加载图标大小 (默认 15 )
* @property {String} openType 开放能力,具体请看uniapp稳定关于button组件部分说明
* @property {String} formType 用于 <form> 组件,点击分别会触发 <form> 组件的 submit/reset 事件
* @property {String} appParameter 打开 APP 时,向 APP 传递的参数,open-type=launchApp时有效 (注:只微信小程序、QQ小程序有效)
* @property {Boolean} hoverStopPropagation 指定是否阻止本节点的祖先节点出现点击态,微信小程序有效(默认 true )
* @property {String} lang 指定返回用户信息的语言,zh_CN 简体中文,zh_TW 繁体中文,en 英文(默认 en )
* @property {String} sessionFrom 会话来源,openType="contact"时有效
* @property {String} sendMessageTitle 会话内消息卡片标题,openType="contact"时有效
* @property {String} sendMessagePath 会话内消息卡片点击跳转小程序路径,openType="contact"时有效
* @property {String} sendMessageImg 会话内消息卡片图片,openType="contact"时有效
* @property {Boolean} showMessageCard 是否显示会话内消息卡片,设置此参数为 true,用户进入客服会话会在右下角显示"可能要发送的小程序"提示,用户点击后可以快速发送小程序消息,openType="contact"时有效(默认false)
* @property {String} dataName 额外传参参数,用于小程序的data-xxx属性,通过target.dataset.name获取
* @property {String | Number} throttleTime 节流,一定时间内只能触发一次 (默认 0 )
* @property {String | Number} hoverStartTime 按住后多久出现点击态,单位毫秒 (默认 0 )
* @property {String | Number} hoverStayTime 手指松开后点击态保留时间,单位毫秒 (默认 200 )
* @property {String | Number} text 按钮文字,之所以通过props传入,是因为slot传入的话(注:nvue中无法控制文字的样式)
* @property {String} icon 按钮图标
* @property {String} iconColor 按钮图标颜色
* @property {String} color 按钮颜色,支持传入linear-gradient渐变色
* @property {Object} customStyle 定义需要用到的外部样式
*
* @event {Function} click 非禁止并且非加载中,才能点击
* @event {Function} getphonenumber open-type="getPhoneNumber"时有效
* @event {Function} getuserinfo 用户点击该按钮时,会返回获取到的用户信息,从返回参数的detail中获取到的值同uni.getUserInfo
* @event {Function} error 当使用开放能力时,发生错误的回调
* @event {Function} opensetting 在打开授权设置页并关闭后回调
* @event {Function} launchapp 打开 APP 成功的回调
* @event {Function} agreeprivacyauthorization 用户同意隐私协议事件回调
* @example <u-button>月落</u-button>
*/
export default {
name: "u-button",
// #ifdef MP
mixins: [mpMixin, mixin, buttonMixin, openType, props],
// #endif
// #ifndef MP
mixins: [mpMixin, mixin, props],
// #endif
data() {
return {};
},
computed: {
// 生成bem风格的类名
bemClass() {
// this.bem为一个computed变量,在mixin中
if (!this.color) {
return this.bem(
"button",
["type", "shape", "size"],
["disabled", "plain", "hairline"]
);
} else {
// 由于nvue的原因,在有color参数时,不需要传入type,否则会生成type相关的类型,影响最终的样式
return this.bem(
"button",
["shape", "size"],
["disabled", "plain", "hairline"]
);
}
},
isDarkTheme() {
return this.$u.theme && this.$u.theme.mode === 'dark';
},
nvueMainColor() {
return this.resolveNvueColor(this.$u.color.mainColor, this.isDarkTheme ? '#f5f5f5' : '#303133');
},
nvueBorderColor() {
return this.resolveNvueColor(this.$u.color.borderColor, this.isDarkTheme ? '#3a3a3c' : '#dadbde');
},
themeTypeColor() {
const fallbackMap = {
primary: '#3c9cff',
success: '#5ac725',
warning: '#f9ae3d',
error: '#f56c6c',
info: '#909399',
};
const type = fallbackMap[this.type] ? this.type : 'info';
return this.resolveNvueColor(
this.upThemeVar(`--up-${type}`, fallbackMap[type]),
fallbackMap[type]
);
},
nvueInfoBackgroundColor() {
return this.upThemeVar(
'--up-button-info-background-color',
this.upThemeVar(
'--up-card-bg-color',
this.isDarkTheme ? '#1c1c1e' : '#ffffff'
)
);
},
nvuePlainBackgroundColor() {
return this.upThemeVar(
'--up-button-plain-background-color',
this.upThemeVar(
'--up-card-bg-color',
this.isDarkTheme ? '#1c1c1e' : '#ffffff'
)
);
},
loadingColor() {
if (this.plain) {
// 如果有设置color值,则用color值,否则使用type主题颜色
return this.color
? this.color
: this.themeTypeColor;
}
if (this.type === "info") {
return this.isDarkTheme ? "#9ca3af" : "#c9c9c9";
}
return "rgb(200, 200, 200)";
},
iconColorCom() {
// 如果是镂空状态,设置了color就用color值,否则使用主题颜色,
// up-icon的color能接受一个主题颜色的值
if (this.iconColor) return this.iconColor;
if (this.plain) {
return this.color ? this.color : this.type;
} else {
return this.type === "info" ? this.nvueMainColor : "#ffffff";
}
},
baseColor() {
let style = {};
if (this.color) {
// 针对自定义了color颜色的情况,镂空状态下,就是用自定义的颜色
style.color = this.plain ? this.color : "white";
if (!this.plain) {
// 非镂空,背景色使用自定义的颜色
style["background-color"] = this.color;
}
if (this.color.indexOf("gradient") !== -1) {
// 如果自定义的颜色为渐变色,不显示边框,以及通过backgroundImage设置渐变色
// weex文档说明可以写borderWidth的形式,为什么这里需要分开写?
// 因为weex是阿里巴巴为了部门业绩考核而做的你懂的东西,所以需要这么写才有效
style.borderTopWidth = 0;
style.borderRightWidth = 0;
style.borderBottomWidth = 0;
style.borderLeftWidth = 0;
if (!this.plain) {
style.backgroundImage = this.color;
}
} else {
// 非渐变色,则设置边框相关的属性
style.borderColor = this.color;
style.borderWidth = "1px";
style.borderStyle = "solid";
}
return style;
}
// #ifdef APP-NVUE
const typeColor = this.themeTypeColor;
if (this.plain) {
style.color = this.type === 'info' ? this.nvueMainColor : typeColor;
style.backgroundColor = this.nvuePlainBackgroundColor;
style.borderColor = this.type === 'info' ? this.nvueBorderColor : typeColor;
style.borderWidth = this.hairline ? '0.5px' : '1px';
style.borderStyle = 'solid';
} else {
style.borderWidth = this.hairline ? '0.5px' : '1px';
style.borderStyle = 'solid';
if (this.type === 'info') {
style.color = this.nvueMainColor;
style.backgroundColor = this.nvueInfoBackgroundColor;
style.borderColor = this.nvueBorderColor;
} else {
style.color = '#ffffff';
style.backgroundColor = typeColor;
style.borderColor = typeColor;
}
}
// #endif
return style;
},
// nvue版本按钮的字体不会继承父组件的颜色,需要对每一个text组件进行单独的设置
nvueTextStyle() {
let style = {};
// 针对自定义了color颜色的情况,镂空状态下,就是用自定义的颜色
if (this.type === "info") {
style.color = this.nvueMainColor;
}
if (this.color) {
style.color = this.plain ? this.color : "white";
} else if (this.plain && this.type !== 'info') {
style.color = this.themeTypeColor;
}
style.fontSize = this.textSize + "px";
return style;
},
// 字体大小
textSize() {
let fontSize = 14,
{ size } = this;
if (size === "large") fontSize = 16;
if (size === "normal") fontSize = 14;
if (size === "small") fontSize = 12;
if (size === "mini") fontSize = 10;
return fontSize;
},
},
emits: ['click', 'getphonenumber', 'getuserinfo',
'error', 'opensetting', 'launchapp', 'agreeprivacyauthorization'],
methods: {
addStyle,
resolveNvueColor(colorValue, fallbackColor) {
if (typeof colorValue !== 'string') return fallbackColor;
if (colorValue.indexOf('var(') > -1) return fallbackColor;
return colorValue;
},
upThemeVar(varName, fallbackColor) {
return getThemeVar(varName, fallbackColor, this.$u);
},
clickHandler(e: any) {
// 非禁止并且非加载中,才能点击
if (!this.disabled && !this.loading) {
// 进行节流控制,每this.throttle毫秒内,只在开始处执行
throttle(() => {
this.$emit("click", e);
}, this.throttleTime);
}
// 是否阻止事件传播
this.stop && this.preventEvent(e)
},
// 下面为对接uniapp官方按钮开放能力事件回调的对接
getphonenumber(res: any) {
this.$emit("getphonenumber", res);
},
getuserinfo(res: any) {
this.$emit("getuserinfo", res);
},
error(res: any) {
this.$emit("error", res);
},
opensetting(res: any) {
this.$emit("opensetting", res);
},
launchapp(res: any) {
this.$emit("launchapp", res);
},
agreeprivacyauthorization(res) {
this.$emit("agreeprivacyauthorization", res);
},
},
};
</script>

<style lang="scss" scoped>
/* #ifndef APP-NVUE */
@import "./vue.scss";
/* #endif */

/* #ifdef APP-NVUE */
@import "./nvue.scss";
/* #endif */

$u-button-u-button-height: 40px !default;
$u-button-text-font-size: 15px !default;
$u-button-loading-text-font-size: 15px !default;
$u-button-loading-text-margin-left: 4px !default;
$u-button-large-width: 100% !default;
$u-button-large-height: 50px !default;
$u-button-normal-padding: 0 12px !default;
$u-button-large-padding: 0 15px !default;
$u-button-normal-font-size: 14px !default;
$u-button-small-min-width: 60px !default;
$u-button-small-height: 30px !default;
$u-button-small-padding: 0px 8px !default;
$u-button-mini-padding: 0px 8px !default;
$u-button-small-font-size: 12px !default;
$u-button-mini-height: 22px !default;
$u-button-mini-font-size: 10px !default;
$u-button-mini-min-width: 50px !default;
$u-button-disabled-opacity: 0.5 !default;
$u-button-info-color: $u-main-color !default;
$u-button-info-background-color: var(--up-button-info-background-color, var(--up-card-bg-color, #fff)) !default;
$u-button-info-border-color: $u-border-color !default;
$u-button-info-border-width: 1px !default;
$u-button-info-border-style: solid !default;
$u-button-success-color: #fff !default;
$u-button-success-background-color: $u-success !default;
$u-button-success-border-color: $u-button-success-background-color !default;
$u-button-success-border-width: 1px !default;
$u-button-success-border-style: solid !default;
$u-button-primary-color: #fff !default;
$u-button-primary-background-color: $u-primary !default;
$u-button-primary-border-color: $u-button-primary-background-color !default;
$u-button-primary-border-width: 1px !default;
$u-button-primary-border-style: solid !default;
$u-button-error-color: #fff !default;
$u-button-error-background-color: $u-error !default;
$u-button-error-border-color: $u-button-error-background-color !default;
$u-button-error-border-width: 1px !default;
$u-button-error-border-style: solid !default;
$u-button-warning-color: #fff !default;
$u-button-warning-background-color: $u-warning !default;
$u-button-warning-border-color: $u-button-warning-background-color !default;
$u-button-warning-border-width: 1px !default;
$u-button-warning-border-style: solid !default;
$u-button-block-width: 100% !default;
$u-button-circle-border-top-right-radius: 100px !default;
$u-button-circle-border-top-left-radius: 100px !default;
$u-button-circle-border-bottom-left-radius: 100px !default;
$u-button-circle-border-bottom-right-radius: 100px !default;
$u-button-square-border-top-right-radius: 3px !default;
$u-button-square-border-top-left-radius: 3px !default;
$u-button-square-border-bottom-left-radius: 3px !default;
$u-button-square-border-bottom-right-radius: 3px !default;
$u-button-icon-min-width: 1em !default;
$u-button-plain-background-color: var(--up-button-plain-background-color, var(--up-card-bg-color, #fff)) !default;
$u-button-hairline-border-width: 0.5px !default;

.u-button {
height: $u-button-u-button-height;
position: relative;
align-items: center;
justify-content: center;
@include flex;
/* #ifndef APP-NVUE */
box-sizing: border-box;
/* #endif */
flex-direction: row;

&__text {
font-size: $u-button-text-font-size;
}

&__loading-text {
font-size: $u-button-loading-text-font-size;
margin-left: $u-button-loading-text-margin-left;
}

&--large {
/* #ifndef APP-NVUE */
width: $u-button-large-width;
/* #endif */
height: $u-button-large-height;
padding: $u-button-large-padding;
}

&--normal {
padding: $u-button-normal-padding;
font-size: $u-button-normal-font-size;
}

&--small {
/* #ifndef APP-NVUE */
min-width: $u-button-small-min-width;
/* #endif */
height: $u-button-small-height;
padding: $u-button-small-padding;
font-size: $u-button-small-font-size;
}

&--mini {
height: $u-button-mini-height;
font-size: $u-button-mini-font-size;
/* #ifndef APP-NVUE */
min-width: $u-button-mini-min-width;
/* #endif */
padding: $u-button-mini-padding;
}

&--disabled {
opacity: $u-button-disabled-opacity;
}

&--info {
color: $u-button-info-color;
background-color: $u-button-info-background-color;
border-color: $u-button-info-border-color;
border-width: $u-button-info-border-width;
border-style: $u-button-info-border-style;
}

&--success {
color: $u-button-success-color;
background-color: $u-button-success-background-color;
border-color: $u-button-success-border-color;
border-width: $u-button-success-border-width;
border-style: $u-button-success-border-style;
}

&--primary {
color: $u-button-primary-color;
background-color: $u-button-primary-background-color;
border-color: $u-button-primary-border-color;
border-width: $u-button-primary-border-width;
border-style: $u-button-primary-border-style;
}

&--error {
color: $u-button-error-color;
background-color: $u-button-error-background-color;
border-color: $u-button-error-border-color;
border-width: $u-button-error-border-width;
border-style: $u-button-error-border-style;
}

&--warning {
color: $u-button-warning-color;
background-color: $u-button-warning-background-color;
border-color: $u-button-warning-border-color;
border-width: $u-button-warning-border-width;
border-style: $u-button-warning-border-style;
}

&--block {
@include flex;
width: $u-button-block-width;
}

&--circle {
border-top-right-radius: $u-button-circle-border-top-right-radius;
border-top-left-radius: $u-button-circle-border-top-left-radius;
border-bottom-left-radius: $u-button-circle-border-bottom-left-radius;
border-bottom-right-radius: $u-button-circle-border-bottom-right-radius;
}

&--square {
border-bottom-left-radius: $u-button-square-border-top-right-radius;
border-bottom-right-radius: $u-button-square-border-top-left-radius;
border-top-left-radius: $u-button-square-border-bottom-left-radius;
border-top-right-radius: $u-button-square-border-bottom-right-radius;
}

&__icon {
/* #ifndef APP-NVUE */
min-width: $u-button-icon-min-width;
line-height: inherit !important;
vertical-align: top;
/* #endif */
}

&--plain {
background-color: $u-button-plain-background-color;
}

&--hairline {
border-width: $u-button-hairline-border-width !important;
}
}
</style>

Неке датотеке нису приказане због велике количине промена

Loading…
Откажи
Сачувај