| @@ -0,0 +1,13 @@ | |||||
| node_modules/ | |||||
| unpackage/ | |||||
| dist/ | |||||
| .hbuilderx/ | |||||
| .DS_Store | |||||
| *.log | |||||
| # 证书/密钥等敏感文件严禁提交 | |||||
| *.keystore | |||||
| *.jks | |||||
| *.p12 | |||||
| *.mobileprovision | |||||
| *.key | |||||
| @@ -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> | |||||
| @@ -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) | | |||||
| @@ -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取名删除 | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| @@ -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> | |||||
| @@ -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> | |||||
| @@ -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> | |||||
| @@ -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 } | |||||
| } | |||||
| @@ -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" | |||||
| } | |||||
| @@ -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" | |||||
| } | |||||
| } | |||||
| @@ -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": "个人中心" | |||||
| } | |||||
| ] | |||||
| } | |||||
| } | |||||
| @@ -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> | |||||
| @@ -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> | |||||
| @@ -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> | |||||
| @@ -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> | |||||
| @@ -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> | |||||
| @@ -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> | |||||
| @@ -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> | |||||
| @@ -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> | |||||
| @@ -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> | |||||
| @@ -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> | |||||
| @@ -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> | |||||
| @@ -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> | |||||
| @@ -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> | |||||
| @@ -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> | |||||
| @@ -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> | |||||
| @@ -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> | |||||
| @@ -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> | |||||
| @@ -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> | |||||
| @@ -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> | |||||
| @@ -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> | |||||
| @@ -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 | |||||
| @@ -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> | |||||
| @@ -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() | |||||
| } | |||||
| } | |||||
| }) | |||||
| @@ -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); | |||||
| } | |||||
| @@ -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); | |||||
| @@ -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. | |||||
| @@ -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> | |||||
| [](https://github.com/ijry/uview-plus) | |||||
| [](https://github.com/ijry/uview-plus) | |||||
| [](https://github.com/ijry/uview-plus/issues) | |||||
| [](https://gitee.com/jry/uview-plus/releases) | |||||
| [](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`。 | |||||
| @@ -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> | |||||
| @@ -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' | |||||
| } | |||||
| } | |||||
| @@ -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 | |||||
| }, | |||||
| } | |||||
| }) | |||||
| @@ -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> | |||||
| @@ -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> | |||||
| @@ -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, | |||||
| } | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| } | |||||
| }) | |||||
| @@ -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> | |||||
| @@ -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 | |||||
| } | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| } | |||||
| }) | |||||
| @@ -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> | |||||
| @@ -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 | |||||
| } | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| } | |||||
| }) | |||||
| @@ -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> | |||||
| @@ -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: '' | |||||
| } | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| } | |||||
| }) | |||||
| @@ -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> | |||||
| @@ -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' | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| } | |||||
| }) | |||||
| @@ -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> | |||||
| @@ -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 | |||||
| } | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| } | |||||
| }) | |||||
| @@ -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> | |||||
| @@ -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: '右下' | |||||
| } | |||||
| } | |||||
| @@ -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 | |||||
| }, | |||||
| } | |||||
| }) | |||||
| @@ -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> | |||||
| @@ -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, | |||||
| } | |||||
| } | |||||
| @@ -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; | |||||
| } | |||||
| } | |||||
| @@ -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 | |||||
| }, | |||||
| } | |||||
| }) | |||||
| @@ -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> | |||||