| @@ -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> | |||