Compare commits
No commits in common. "f1dfbf7d8083d7e35227ccb1edbd44237f3e6f26" and "6cde1ab561cce989799e6cf29068adafb4fa7884" have entirely different histories.
f1dfbf7d80
...
6cde1ab561
File diff suppressed because it is too large
Load Diff
@ -4,7 +4,7 @@
|
||||
|
||||
## 功能用途
|
||||
|
||||
HTTP 请求基础封装,提供 `get`、`post`、`put`、`buildQuery` 方法,以及 WebSocket URL 生成。所有 API 模块均通过此文件发起请求。
|
||||
HTTP 请求基础封装,提供 `get`、`post`、`buildQuery` 方法,以及 WebSocket URL 生成。所有 API 模块均通过此文件发起请求。
|
||||
|
||||
## 核心能力
|
||||
|
||||
@ -14,14 +14,13 @@ HTTP 请求基础封装,提供 `get`、`post`、`put`、`buildQuery` 方法,
|
||||
- User WebSocket URL:`getUserWsUrl()` 返回 `ws(s)://host/clob/ws/user`(订单/持仓/余额推送)
|
||||
- GET 请求:支持 query 参数,自动序列化
|
||||
- POST 请求:支持 JSON body
|
||||
- PUT 请求:支持 JSON body
|
||||
- 自定义 headers:通过 `RequestConfig.headers` 传入
|
||||
- **Accept-Language**:所有 GET/POST/PUT 请求自动附带当前 vue-i18n 的 `locale`
|
||||
- **Accept-Language**:所有 GET/POST 请求自动附带当前 vue-i18n 的 `locale`
|
||||
|
||||
## 使用方式
|
||||
|
||||
```typescript
|
||||
import { get, post, put, buildQuery, getClobWsUrl, getUserWsUrl } from '@/api/request'
|
||||
import { get, post, buildQuery, getClobWsUrl, getUserWsUrl } from '@/api/request'
|
||||
|
||||
// 构建 query(自动过滤空值)
|
||||
const query = buildQuery({ page: 1, pageSize: 10, keyword, tagIds })
|
||||
@ -40,16 +39,11 @@ const data = await get<MyResponse>('/path', undefined, {
|
||||
const res = await post<MyResponse>('/path', { key: 'value' }, {
|
||||
headers: { 'x-token': token },
|
||||
})
|
||||
|
||||
// PUT 请求
|
||||
const putRes = await put<MyResponse>('/path', { key: 'value' }, {
|
||||
headers: { 'x-token': token },
|
||||
})
|
||||
```
|
||||
|
||||
## 扩展方式
|
||||
|
||||
1. **添加 DELETE**:仿照 `get`/`post`/`put` 实现 `del` 函数
|
||||
1. **添加 PUT/DELETE**:仿照 `get`/`post` 实现 `put`、`del` 函数
|
||||
2. **统一错误处理**:在 `get`/`post` 内对 `!res.ok` 做统一 toast 或错误上报
|
||||
3. **请求/响应拦截**:在 fetch 前后加入拦截逻辑(如 loading、日志)
|
||||
4. **超时控制**:使用 `AbortController` 实现超时取消
|
||||
|
||||
@ -4,13 +4,12 @@
|
||||
|
||||
## 功能用途
|
||||
|
||||
用户相关接口:获取用户信息、USDC 余额、修改自身用户名。对接 `/user/getUserInfo`、`/user/getUsdcBalance`、`/user/setSelfUsername`,均需鉴权。
|
||||
用户相关接口:获取用户信息、USDC 余额。对接 `/user/getUserInfo`、`/user/getUsdcBalance`,均需鉴权。
|
||||
|
||||
## 核心能力
|
||||
|
||||
- `getUserInfo`:获取当前用户信息(头像、昵称等)
|
||||
- `getUsdcBalance`:查询 USDC 余额(amount、available、locked 需除以 1_000_000)
|
||||
- `setSelfUsername`:修改自身用户名(PUT `/user/setSelfUsername`)
|
||||
- `formatUsdcBalance`:将原始数值转为显示用字符串(如 "0.00")
|
||||
|
||||
## 类型说明
|
||||
@ -23,7 +22,7 @@
|
||||
## 使用方式
|
||||
|
||||
```typescript
|
||||
import { getUserInfo, getUsdcBalance, setSelfUsername, formatUsdcBalance } from '@/api/user'
|
||||
import { getUserInfo, getUsdcBalance, formatUsdcBalance } from '@/api/user'
|
||||
|
||||
const headers = { 'x-token': token, 'x-user-id': userId }
|
||||
|
||||
@ -33,12 +32,6 @@ const balanceRes = await getUsdcBalance(headers)
|
||||
if (balanceRes.data) {
|
||||
const display = formatUsdcBalance(balanceRes.data.available)
|
||||
}
|
||||
|
||||
// 修改自身用户名
|
||||
const setRes = await setSelfUsername(headers, { username: 'new_username' })
|
||||
if (setRes.code === 0 || setRes.code === 200) {
|
||||
// 建议:成功后调用 fetchUserInfo() 刷新页面展示
|
||||
}
|
||||
```
|
||||
|
||||
## 扩展方式
|
||||
|
||||
@ -12,7 +12,6 @@
|
||||
- Asks、Bids 列表,带 `HorizontalProgressBar` 深度条(买卖两边共用同一最大值 `maxOrderBookTotal`,取两边累计总量中的最大值,便于对比深度)
|
||||
- Last price、Spread 展示
|
||||
- Live / 连接中 状态展示(均通过 i18n 国际化)
|
||||
- Header 右侧已移除折叠箭头图标(`mdi-chevron-up`),仅保留状态文案与成交量文案
|
||||
|
||||
## Props
|
||||
|
||||
|
||||
@ -4,27 +4,20 @@
|
||||
|
||||
## 功能用途
|
||||
|
||||
根组件,包含全局 AppBar、主内容区(router-view)。AppBar 含返回按钮、标题、登录/余额/头像入口,主内容区使用 keep-alive 缓存 Home。
|
||||
根组件,包含全局 AppBar、主内容区(router-view)。AppBar 含返回按钮、标题、登录/余额/头像菜单,主内容区使用 keep-alive 缓存 Home。
|
||||
|
||||
## 核心能力
|
||||
|
||||
- 顶部导航栏:返回、TestMarket 标题、Login 或余额+头像入口
|
||||
- 头像入口:登录态点击头像直接跳转 `/profile`
|
||||
- 移动端底部导航:Home / Search / Mine(三个 tab 等分屏宽,与路由联动;Mine 点击跳转 `/profile`;选中态仅加粗、无底色;未选中项图标与文字偏灰;底部导航上方有淡投影);仅在 `/`、`/search`、`/profile` 三个主页面显示,其他页面隐藏
|
||||
- 全局滚动稳定:通过 `scrollbar-gutter: stable` 保留滚动条占位,避免页面从搜索态切到结果态时出现顶部/底部导航轻微横向抖动
|
||||
- 顶部导航栏:返回、TestMarket 标题、Login 或余额+用户名+头像菜单
|
||||
- 多语言入口:右侧地球图标(`mdi-earth`)+ 当前语言文案,点击打开语言选择菜单
|
||||
- 移动端底部导航:Home / Search / Mine(三个 tab 等分屏宽,与路由联动;Mine 未登录跳转 Login;选中态仅加粗、无底色;未选中项图标与文字偏灰;底部导航上方有淡投影)
|
||||
- 登录态:`userStore.isLoggedIn` 控制展示
|
||||
- 用户名:`nickName` 或 `userName` 显示在头像左侧(有值时)
|
||||
- 挂载时与 `isLoggedIn` 变为 true 时:拉取用户信息与余额(`router.isReady()` + `nextTick` 后执行),确保钱包登录、刷新页面后头像和用户名正确显示
|
||||
- keep-alive:`include="['Home']"` 缓存首页
|
||||
|
||||
## 使用方式
|
||||
|
||||
- 启动应用后由 `App.vue` 承载顶栏、主内容与底部导航
|
||||
- 路由切换时由 `bottomNavValue` 自动同步 Home / Search / Mine 高亮状态
|
||||
- 页面内容通过 `<router-view>` 渲染,Home 页面使用 keep-alive 缓存
|
||||
|
||||
## 扩展方式
|
||||
|
||||
1. **多级导航**:根据路由深度调整返回逻辑
|
||||
2. **主题切换**:增加亮/暗模式切换
|
||||
3. **个人入口扩展**:可在个人中心页继续扩展设置项与账户操作
|
||||
3. **多语言**:接入 i18n 插件
|
||||
|
||||
@ -17,8 +17,6 @@ Vue Router 配置,定义路由表与滚动行为。
|
||||
| /trade-detail/:id | trade-detail | TradeDetail |
|
||||
| /event/:id/markets | event-markets | EventMarkets |
|
||||
| /wallet | wallet | Wallet |
|
||||
| /profile | profile | Profile |
|
||||
| /api-key | api-key | ApiKey |
|
||||
|
||||
## 滚动行为
|
||||
|
||||
|
||||
@ -1,22 +0,0 @@
|
||||
# ApiKey.vue
|
||||
|
||||
**路径**:`src/views/ApiKey.vue`
|
||||
**路由**:`/api-key`,name: `api-key`
|
||||
|
||||
## 功能用途
|
||||
|
||||
API Key 管理页面,按 Pencil 设计稿 `WFa0K` 节点 1:1 还原。页面包含标题区(`API Key 管理` + `创建 Key` 按钮)和 API Key 卡片列表(Key 名称、Key 值、复制/删除按钮)。
|
||||
|
||||
## 使用方式
|
||||
|
||||
- 访问路由 `/api-key`
|
||||
- 页面展示 3 条示例 Key 数据,卡片结构如下:
|
||||
- 顶部:`Key #n`
|
||||
- 中部:完整 Key 字符串
|
||||
- 底部:右对齐操作按钮 `复制`、`删除`
|
||||
|
||||
## 扩展方式
|
||||
|
||||
1. **接入真实列表**:将本地 `apiKeys` 常量替换为后端 API 数据。
|
||||
2. **接入操作事件**:为 `创建 Key`、`复制`、`删除` 按钮绑定真实业务逻辑。
|
||||
3. **安全策略**:可增加 Key 脱敏展示与二次确认删除弹窗。
|
||||
@ -1,42 +0,0 @@
|
||||
# Profile.vue
|
||||
|
||||
**路径**:`src/views/Profile.vue`
|
||||
**路由**:`/profile`,name: `profile`
|
||||
|
||||
## 功能用途
|
||||
|
||||
个人中心页面,按照 Pencil 设计稿(`design/pencil-new.pen` 的 `UNTdC` 节点)还原移动端 Profile Screen,并接入用户态与国际化:
|
||||
|
||||
- 从 `useUserStore` 读取昵称、UID、头像、余额、钱包地址等数据
|
||||
- 页面加载后自动触发 `fetchUserInfo()` 与 `fetchUsdcBalance()`,刷新展示数据
|
||||
- 支持语言切换、复制钱包地址、登出等交互逻辑
|
||||
- 全量文案改为 `vue-i18n`(`profile.*` 与 `common.logout` 等)
|
||||
|
||||
## 使用方式
|
||||
|
||||
- 访问路由 `/profile`
|
||||
- 页面会读取 `userStore.user` 作为展示数据源(昵称、UID、头像、VIP 标签、钱包地址)
|
||||
- 点击设置项中的 `钱包管理` 会弹出钱包地址框,显示当前钱包地址(无地址时显示 i18n 兜底文案)
|
||||
- 点击设置项中的 `API KEY 管理` 会跳转到 `/api-key`
|
||||
- 点击设置项中的 `语言` 会弹出语言选择框,点选后即时切换并关闭弹窗,右侧显示当前语言名称
|
||||
- 点击钱包卡片的 `钱包详情` 会跳转到 `/wallet`
|
||||
- 页面展示的用户名与编辑初始值统一取 `userName`(调用 `PUT /user/setSelfUsername` 后刷新);输入会提示允许格式与校验错误(至少 2 位,且仅允许 `a-z / 0-9 / _`)
|
||||
- 点击 `复制地址` 会写入剪贴板,并通过 toast 提示成功/失败
|
||||
- 点击底部 `退出登录` 会执行异步登出(带 loading 防重复)并跳转到 `/login`
|
||||
|
||||
主要结构:
|
||||
|
||||
- Profile 卡片:头像、昵称、UID、VIP 标签、编辑按钮
|
||||
- Wallet 卡片:总览标题、余额、明细文案、充币/提币按钮
|
||||
- 设置卡片:钱包管理、API KEY 管理、语言
|
||||
- 退出按钮:底部高亮按钮
|
||||
|
||||
## 扩展方式
|
||||
|
||||
1. **更新用户名错误分层**:当前错误优先展示输入校验与接口返回的 `msg`;后续可按错误码映射到更细粒度的 i18n 文案与字段提示。
|
||||
2. **余额细分字段接入**:目前可用/冻结余额已支持多字段兜底,后续可统一对接后端标准字段。
|
||||
3. **头像兜底增强**:当前无头像时展示昵称首字母,可扩展为默认头像资源或主题色方案。
|
||||
4. **多语言持续补齐**:新增字段时同步更新 `src/locales/*.json` 下 `profile` 命名空间。
|
||||
5. **长昵称适配**:`.name-text` 已启用 `ellipsis` 截断,避免与右侧 `编辑` 按钮发生重叠。
|
||||
6. **底部留白优化**:通过调整 `.profile-page` 的 flex 对齐方式,避免容器被拉伸导致底部空白过多。
|
||||
7. **高度收缩优化**:移除了 `.profile-screen` 过高的 `min-height`(由固定值改为可收缩),减少页面因内容过少而产生滚动。
|
||||
@ -1,33 +0,0 @@
|
||||
# Search.vue
|
||||
|
||||
**路径**:`src/views/Search.vue`
|
||||
**路由**:`/search`,name: `search`
|
||||
|
||||
## 功能用途
|
||||
|
||||
搜索页独立实现,按 Pencil 设计稿同文件实现双态页面:
|
||||
- `p4Kcp`:搜索页(搜索框、搜索记录、推荐标签)
|
||||
- `mN9t2`:搜索结果页(顶部搜索框 + 结果卡片列表)
|
||||
- 页面容器改为移动端自适应宽度(`width: 100%`),不再固定 `402px`
|
||||
|
||||
## 使用方式
|
||||
|
||||
- 访问路由 `/search`
|
||||
- 在搜索输入框中输入关键词后,手机键盘回车键会显示为“搜索”(`enterkeyhint="search"`)
|
||||
- 点击键盘“搜索”会请求 `GET /PmEvent/getPmEventPublic`(`keyword` 参数),并将关键词写入搜索记录顶部
|
||||
- 搜索前展示 `p4Kcp` 结构:
|
||||
- 顶部标题:`搜索`
|
||||
- 搜索框:真实输入框,占位文案 `搜索市场、话题、地址`
|
||||
- 搜索记录卡:3 条示例记录,每行右侧带关闭图标
|
||||
- 推荐标签卡:`ETH`、`科技股`、`总统大选`
|
||||
- 搜索后切换到 `mN9t2` 结构:
|
||||
- 顶部同样保留标题与搜索框
|
||||
- 下方展示结果卡片列表(图标、标题、百分比、时间)
|
||||
- 切换结果态时不会因滚动条变化导致顶部/底部导航轻微位移
|
||||
|
||||
## 扩展方式
|
||||
|
||||
1. **接入真实搜索记录**:将 `searchRecords` 替换为用户历史接口数据。
|
||||
2. **接入搜索行为**:将搜索框改为可输入组件并绑定查询 API。
|
||||
3. **标签联动搜索**:点击标签触发关键词搜索并跳转到结果页。
|
||||
4. **响应式优化**:可按断点进一步细分字号与间距,但保持 1:1 视觉层级。
|
||||
@ -11,7 +11,6 @@
|
||||
|
||||
- 分时图:TradingView Lightweight Charts 渲染,支持 Past、时间粒度切换(1H/6H/1D/1W/1M/ALL);**Yes/No 模式**数据来自 **GET /pmPriceHistory/getPmPriceHistoryPublic**(market 传 clobTokenIds[0]),接口返回 `time`(Unix 秒)、`price`(0–1)转成 `[timestamp_ms, value_0_100][]` 后缓存在 `rawChartData`,**分时**为前端按当前选中范围过滤:1H=最近 1 小时、6H=6 小时、1D=1 天、1W=7 天、1M=30 天、ALL=全部,切换时间范围不重复请求;**加密货币事件**可切换 YES/NO 分时图与加密货币价格走势图(CoinGecko 实时数据),**加密货币模式默认显示 30S 分时走势图**
|
||||
- 订单簿:`OrderBook` 组件,通过 **ClobSdk** 对接 CLOB WebSocket 实时数据(全量快照、增量更新、成交推送);份额接口按 6 位小数传(1_000_000 = 1 share),`priceSizeToRows` 与 `mergeDelta` 会将 raw 值除以 `ORDER_BOOK_SIZE_SCALE` 转为展示值
|
||||
- 订单簿外层容器(`order-book-card`)已去除重复外边框,仅保留 `OrderBook` 组件单层描边,避免视觉上出现双层边线
|
||||
- 交易:`TradeComponent`,传入 `market`、`initialOption`、`positions`(持仓数据)
|
||||
- 持仓列表:通过 `getPositionList` 获取当前市场持仓,传递给 `TradeComponent` 用于计算可合并份额
|
||||
- 限价订单:通过 `getOrderList` 获取当前市场未成交限价单,支持撤单
|
||||
|
||||
@ -5,37 +5,15 @@
|
||||
|
||||
## 功能用途
|
||||
|
||||
钱包页,展示 Portfolio、Profit/Loss 与四类交易数据(Positions / Open orders / History / Withdrawals)。当前已按 `design/pencil-new.pen` 的 `tuLlv`、`KRKZv`、`aRC6m`、`tZDYO` 节点同步为卡片化移动端布局:不展示搜索栏和筛选工具栏,tab 选中后直接显示列表内容。
|
||||
|
||||
其中 `tuLlv`(Wallet - Positions)已按设计稿做 1:1 样式对齐:
|
||||
- 页面容器使用移动端自适应宽度(`width: 100%`),统一 `16px` 间距与内边距
|
||||
- 顶部为「钱包」标题
|
||||
- Portfolio 卡片使用主色背景(`$--primary`)与白色文本
|
||||
- 快捷操作区为两个等宽按钮(`t('wallet.deposit')` / `t('wallet.withdraw')`)
|
||||
- Settlement 卡片为紧凑样式,右侧胶囊 Claim 按钮
|
||||
- Wallet Section 为圆角描边卡片 + 胶囊 tabs + Positions 卡片列表
|
||||
- Positions 标题区采用“右侧价格优先”的自适应布局:价格变长时标题区域自动收缩,标题单行并使用跑马灯展示超出内容,避免与价格重叠
|
||||
钱包页,展示 Portfolio、Profit/Loss、Positions、Open orders、History。支持 Deposit/Withdraw 弹窗、搜索、筛选(如 Close Losses)。
|
||||
|
||||
## 核心能力
|
||||
|
||||
- Portfolio 卡片:余额、Deposit/Withdraw 按钮
|
||||
- Profit/Loss 卡片:时间范围切换(1D/1W/1M/ALL)、Lightweight Charts 资产变化面积图;数据格式为 `[timestamp_ms, pnl][]`,**暂无接口时全部显示为 0**(真实时间轴 + 数值 0),有接口后在此处对接
|
||||
- Tab:Positions、Open orders、**History**(历史记录来自 **GET /hr/getHistoryRecordListClient**,`src/api/historyRecord.ts`,需鉴权、按当前用户分页)、Withdrawals(提现记录)
|
||||
- Positions:卡片展示市场图标、标题、YES/NO outcome、价值、盈亏、`t('wallet.sharesLabel')`、`t('wallet.avgPriceLabel')`、`t('wallet.currentPriceLabel')`
|
||||
- Open orders:卡片展示图标、标题、BUY/SELL 标签、YES/NO 标签、`t('wallet.openOrderPriceLabel')`、`t('wallet.filledTotalLabel')`、`t('wallet.orderValueLabel')`、取消按钮
|
||||
- Open orders 标题区与 Positions 一致采用单行跑马灯;标题容器可自适应收缩,优先保证右侧取消按钮与数值不重叠
|
||||
- Open orders 样式参数已按设计值收敛:图标 `44x44`、取消按钮 `32x32` 圆形描边、BUY/SELL 为描边矩形标签(`22px` 高、`6px` 圆角)、YES/NO 为胶囊标签(`20px` 高、`999px` 圆角)
|
||||
- Open orders 顶部结构按设计对齐:左侧图标置于左上,图标右侧为垂直两行(标题 + BUY/YES/NO 同行标签),右侧关闭按钮与图标处于同一顶行;下半部分独立展示价格相关三列信息
|
||||
- 当接口返回的 `order.market` 为空时,标题会自动回退为 `Market · <outcome>`,避免顶部标题为空导致布局塌陷
|
||||
- Open orders 卡片主容器使用纵向布局(上半结构 + 下半价格区),避免价格区被挤到右侧并出现竖排换行
|
||||
- History:支持两种卡片形态(交易卡 + 充值/提现卡)
|
||||
- History(`ny6M5`)已按设计做 1:1 对齐:交易卡为“图标+标题日期+右侧金额”上行、下行“BUY/SELL 标签 + `t('wallet.priceLabel')`/`t('wallet.sharesLabel')`”;资金卡为“左侧图标与标题日期 + 右侧金额”的单行结构
|
||||
- History 标题采用与 Positions/Open orders 一致的单行跑马灯方案,超出宽度时自动循环滚动展示
|
||||
- Withdrawals:紧凑提现卡(日期、链路标签、状态标签、金额/手续费、地址)
|
||||
- Withdrawals(`UjOKn`)按 1:1 结构实现:顶部日期 + 链路/状态标签、中部金额与手续费双列、底部地址;并在无真实记录时提供 3 条预览数据用于视觉验收
|
||||
- Withdrawals 头部布局已调整为“日期在上、chain 在日期下方、状态标签在右侧”,并收紧卡片内边距与列表间距以贴合设计稿密度
|
||||
- **可结算/领取**:未结算项(unsettledItems)由持仓中有 `marketID`、`tokenID` 且 **所属 market.closed=true** 的项组成,用于「领取结算」按钮;不再使用 needClaim 判断
|
||||
- Withdrawals:对接 GET /pmset/getPmSettlementRequestsListClient,保留状态映射展示
|
||||
- Withdrawals:分页列表,状态筛选(全部/审核中/提现成功/审核不通过/提现失败),对接 GET /pmset/getPmSettlementRequestsListClient
|
||||
- DepositDialog、WithdrawDialog 组件
|
||||
- **401 权限错误**:取消订单等接口失败时,通过 `useAuthError().formatAuthError` 统一提示「请先登录」或「权限不足」
|
||||
|
||||
@ -46,6 +24,6 @@
|
||||
|
||||
## 扩展方式
|
||||
|
||||
1. **真实字段补全**:若后端补充订单价值、手续费等字段,可替换当前前端组合文案(如 `price × total`)。
|
||||
2. **卡片交互增强**:可在不改变结构前提下添加点击展开、跳转详情、快捷撤单等行为。
|
||||
3. **视觉主题扩展**:保持当前卡片信息架构,按主题变量调整色彩与密度以适配暗色模式。
|
||||
1. **真实数据**:Positions、Orders、History 对接接口
|
||||
2. **导出**:History 支持导出 CSV
|
||||
3. **筛选**:按市场、时间、盈亏等筛选
|
||||
|
||||
89
src/App.vue
89
src/App.vue
@ -4,30 +4,36 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useUserStore } from './stores/user'
|
||||
import { useLocaleStore } from './stores/locale'
|
||||
import Toast from './components/Toast.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const localeStore = useLocaleStore()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const display = useDisplay()
|
||||
|
||||
const currentRoute = computed(() => route.path)
|
||||
|
||||
const showBottomNav = computed(() => {
|
||||
if (!display.smAndDown.value) return false
|
||||
return currentRoute.value === '/' || currentRoute.value === '/search' || currentRoute.value === '/profile'
|
||||
const currentLocaleLabel = computed(() => {
|
||||
return (
|
||||
localeStore.localeOptions.find((o) => o.value === localeStore.currentLocale)?.label ??
|
||||
String(localeStore.currentLocale)
|
||||
)
|
||||
})
|
||||
const mineTargetPath = computed(() => '/profile')
|
||||
|
||||
const showBottomNav = computed(() => display.smAndDown.value)
|
||||
const mineTargetPath = computed(() => (userStore.isLoggedIn ? '/wallet' : '/login'))
|
||||
const bottomNavValue = computed({
|
||||
get() {
|
||||
const p = currentRoute.value
|
||||
if (p.startsWith('/profile')) return '/profile'
|
||||
if (p.startsWith('/wallet')) return '/wallet'
|
||||
if (p.startsWith('/login')) return '/wallet' // Mine 入口在登录页也保持高亮
|
||||
if (p.startsWith('/search')) return '/search'
|
||||
return '/'
|
||||
},
|
||||
set(v: string) {
|
||||
if (v === '/profile') router.push(mineTargetPath.value)
|
||||
if (v === '/wallet') router.push(mineTargetPath.value)
|
||||
else router.push(v)
|
||||
},
|
||||
})
|
||||
@ -68,6 +74,29 @@ watch(
|
||||
</v-btn>
|
||||
<v-app-bar-title v-if="currentRoute === '/'">TestMarket</v-app-bar-title>
|
||||
<v-spacer></v-spacer>
|
||||
<v-menu location="bottom" :close-on-content-click="true" class="locale-menu">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
variant="text"
|
||||
size="small"
|
||||
class="locale-btn"
|
||||
:aria-label="`${t('common.more')} (${currentLocaleLabel})`"
|
||||
>
|
||||
<v-icon size="24">mdi-earth</v-icon>
|
||||
<span class="locale-label">{{ currentLocaleLabel }}</span>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
v-for="opt in localeStore.localeOptions"
|
||||
:key="opt.value"
|
||||
:title="opt.label"
|
||||
:active="localeStore.currentLocale === opt.value"
|
||||
@click="localeStore.setLocale(opt.value)"
|
||||
/>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<v-btn
|
||||
v-if="!userStore.isLoggedIn"
|
||||
text
|
||||
@ -86,18 +115,23 @@ watch(
|
||||
>
|
||||
<span class="balance-text">${{ userStore.balance }}</span>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
class="avatar-btn"
|
||||
:aria-label="t('common.user')"
|
||||
@click="$router.push('/profile')"
|
||||
>
|
||||
<v-avatar size="36" color="primary">
|
||||
<v-img v-if="userStore.avatarUrl" :src="userStore.avatarUrl" cover alt="avatar" />
|
||||
<v-icon v-else>mdi-account</v-icon>
|
||||
</v-avatar>
|
||||
</v-btn>
|
||||
<v-menu location="bottom" :close-on-content-click="false">
|
||||
<template #activator="{ props }">
|
||||
<v-btn v-bind="props" icon variant="text" class="avatar-btn">
|
||||
<v-avatar size="36" color="primary">
|
||||
<v-img v-if="userStore.avatarUrl" :src="userStore.avatarUrl" cover alt="avatar" />
|
||||
<v-icon v-else>mdi-account</v-icon>
|
||||
</v-avatar>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
:title="userStore.user?.nickName || userStore.user?.userName || t('common.user')"
|
||||
disabled
|
||||
/>
|
||||
<v-list-item :title="t('common.logout')" @click="userStore.logout()" />
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</template>
|
||||
</div>
|
||||
</v-app-bar>
|
||||
@ -126,7 +160,7 @@ watch(
|
||||
<v-icon size="24">mdi-magnify</v-icon>
|
||||
<span>Search</span>
|
||||
</v-btn>
|
||||
<v-btn value="/profile" :ripple="false">
|
||||
<v-btn value="/wallet" :ripple="false">
|
||||
<v-icon size="24">mdi-account-outline</v-icon>
|
||||
<span>Mine</span>
|
||||
</v-btn>
|
||||
@ -172,6 +206,20 @@ watch(
|
||||
color: rgba(0, 0, 0, 0.87);
|
||||
}
|
||||
|
||||
.locale-btn {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.locale-label {
|
||||
margin-left: 6px;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.72);
|
||||
max-width: 88px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 底部导航:整条栏上方淡投影 */
|
||||
:deep(.v-bottom-navigation) {
|
||||
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.06);
|
||||
@ -212,7 +260,6 @@ watch(
|
||||
:global(html),
|
||||
:global(body) {
|
||||
background: rgb(252, 252, 252);
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
:global(.v-application) {
|
||||
background: rgb(252, 252, 252);
|
||||
|
||||
@ -107,28 +107,3 @@ export async function post<T = unknown>(
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* 带 x-token 等自定义头的 PUT 请求
|
||||
*/
|
||||
export async function put<T = unknown>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
config?: RequestConfig,
|
||||
): Promise<T> {
|
||||
const url = new URL(path, BASE_URL || window.location.origin)
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept-Language': i18n.global.locale.value as string,
|
||||
...config?.headers,
|
||||
}
|
||||
const res = await fetch(url.toString(), {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { get, put } from './request'
|
||||
import type { ApiResponse } from './types'
|
||||
import { get } from './request'
|
||||
|
||||
const USDC_DECIMALS = 1_000_000
|
||||
|
||||
@ -124,22 +123,3 @@ export async function getUsdcBalance(
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /user/setSelfUsername
|
||||
* 修改自身用户名
|
||||
* Body: { username: string }
|
||||
* 需鉴权(ApiKeyAuth)
|
||||
*/
|
||||
export interface ChangeSelfUsernameReq {
|
||||
username: string
|
||||
}
|
||||
|
||||
export async function setSelfUsername(
|
||||
authHeaders: Record<string, string>,
|
||||
req: ChangeSelfUsernameReq,
|
||||
): Promise<ApiResponse> {
|
||||
return put<ApiResponse>('/user/setSelfUsername', req, {
|
||||
headers: authHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
</span>
|
||||
<span v-else-if="loading" class="loading-badge">{{ t('trade.orderBookConnecting') }}</span>
|
||||
<span v-else class="vol-text">$4.4k Vol.</span>
|
||||
<v-icon size="14" class="order-book-icon">mdi-chevron-up</v-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -333,6 +334,10 @@ const maxOrderBookTotal = computed(() => {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.order-book-icon {
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.live-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@ -183,60 +183,7 @@
|
||||
"withdrawChain": "Chain",
|
||||
"withdrawTime": "Time",
|
||||
"withdrawStatus": "Status",
|
||||
"noWithdrawalsFound": "No withdrawals found",
|
||||
"walletTitle": "Wallet",
|
||||
"sharesLabel": "Shares",
|
||||
"avgPriceLabel": "Avg. Price",
|
||||
"currentPriceLabel": "Current Price",
|
||||
"openOrderPriceLabel": "Open Order Price",
|
||||
"filledTotalLabel": "Filled / Total",
|
||||
"orderValueLabel": "Order Value",
|
||||
"withdrawAmountLabel": "Withdraw Amount",
|
||||
"feeLabel": "Fee",
|
||||
"withdrawAddressLabel": "Withdraw Address",
|
||||
"priceLabel": "Price",
|
||||
"sellDialogTitle": "Sell {outcome}",
|
||||
"position": "Position",
|
||||
"sellDialogReceive": "Receive",
|
||||
"sellDialogRedeem": "Redeem",
|
||||
"sellDialogEditOrder": "Edit order",
|
||||
"btcWithdrawHistoryLabel": "BTC Withdraw",
|
||||
"usdtDepositHistoryLabel": "USDT Deposit",
|
||||
"withdrawIconText": "W",
|
||||
"depositIconText": "D",
|
||||
"cancelFailed": "Cancel failed"
|
||||
},
|
||||
"profile": {
|
||||
"uidLabel": "UID {uid}",
|
||||
"edit": "Edit",
|
||||
"walletOverview": "Wallet Overview",
|
||||
"walletDetail": "Wallet details",
|
||||
"walletSub": "Available ${available} Frozen ${frozen}",
|
||||
"accountSettings": "Account & Settings",
|
||||
"walletManage": "Wallet Management",
|
||||
"apiKeyManage": "API KEY Management",
|
||||
"language": "Language",
|
||||
"selectLanguage": "Select Language",
|
||||
"currentWalletAddress": "Current Wallet Address",
|
||||
"copyAddress": "Copy address",
|
||||
"copySuccess": "Address copied",
|
||||
"copyFailed": "Copy failed, try again later",
|
||||
"walletAddressUnavailable": "Wallet address not available",
|
||||
"unbound": "Unbound",
|
||||
"defaultName": "User",
|
||||
"vipTrader": "VIP Trader",
|
||||
"trader": "Trader",
|
||||
"editComingSoon": "Edit profile is coming soon",
|
||||
"editNameTitle": "Edit Username",
|
||||
"newUserName": "Username",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"nameRequired": "Username is required",
|
||||
"nameTooLong": "Username must be at most {max} characters",
|
||||
"nameInvalidFormat": "Username may only contain letters, numbers, and underscores",
|
||||
"nameTooShort": "Username must be at least {min} characters",
|
||||
"nameFormatHint": "Only letters, numbers, and underscores are allowed (a-z / 0-9 / _)",
|
||||
"nameSaved": "Username updated"
|
||||
"noWithdrawalsFound": "No withdrawals found"
|
||||
},
|
||||
"deposit": {
|
||||
"title": "Deposit",
|
||||
|
||||
@ -183,60 +183,7 @@
|
||||
"withdrawChain": "チェーン",
|
||||
"withdrawTime": "時間",
|
||||
"withdrawStatus": "状態",
|
||||
"noWithdrawalsFound": "出金履歴がありません",
|
||||
"walletTitle": "ウォレット",
|
||||
"sharesLabel": "シェア数",
|
||||
"avgPriceLabel": "平均価格",
|
||||
"currentPriceLabel": "現在価格",
|
||||
"openOrderPriceLabel": "指値価格",
|
||||
"filledTotalLabel": "約定 / 合計",
|
||||
"orderValueLabel": "注文価値",
|
||||
"withdrawAmountLabel": "出金額",
|
||||
"feeLabel": "手数料",
|
||||
"withdrawAddressLabel": "出金先アドレス",
|
||||
"priceLabel": "価格",
|
||||
"sellDialogTitle": "売却 {outcome}",
|
||||
"position": "ポジション",
|
||||
"sellDialogReceive": "受け取る",
|
||||
"sellDialogRedeem": "償還",
|
||||
"sellDialogEditOrder": "注文を編集",
|
||||
"btcWithdrawHistoryLabel": "BTC 出金",
|
||||
"usdtDepositHistoryLabel": "USDT 入金",
|
||||
"withdrawIconText": "出",
|
||||
"depositIconText": "入",
|
||||
"cancelFailed": "キャンセルに失敗しました"
|
||||
},
|
||||
"profile": {
|
||||
"uidLabel": "UID {uid}",
|
||||
"edit": "編集",
|
||||
"walletOverview": "ウォレット概要",
|
||||
"walletDetail": "ウォレット詳細",
|
||||
"walletSub": "利用可能残高 ${available} 凍結 ${frozen}",
|
||||
"accountSettings": "アカウントと設定",
|
||||
"walletManage": "ウォレット管理",
|
||||
"apiKeyManage": "API KEY 管理",
|
||||
"language": "言語",
|
||||
"selectLanguage": "言語を選択",
|
||||
"currentWalletAddress": "現在のウォレットアドレス",
|
||||
"copyAddress": "アドレスをコピー",
|
||||
"copySuccess": "アドレスをコピーしました",
|
||||
"copyFailed": "コピーに失敗しました。後でもう一度お試しください",
|
||||
"walletAddressUnavailable": "ウォレットアドレスを取得できません",
|
||||
"unbound": "未連携",
|
||||
"defaultName": "ユーザー",
|
||||
"vipTrader": "VIP Trader",
|
||||
"trader": "Trader",
|
||||
"editComingSoon": "編集機能は近日公開です",
|
||||
"editNameTitle": "ユーザー名を編集",
|
||||
"newUserName": "新しいユーザー名",
|
||||
"cancel": "キャンセル",
|
||||
"save": "保存",
|
||||
"nameRequired": "ユーザー名は必須です",
|
||||
"nameTooLong": "ユーザー名は {max} 文字以内にしてください",
|
||||
"nameInvalidFormat": "ユーザー名は半角英字、数字、アンダースコア(_)のみで入力してください",
|
||||
"nameTooShort": "ユーザー名は {min} 文字以上にしてください",
|
||||
"nameFormatHint": "半角英字、数字、アンダースコア(_)のみ使用できます(a-z / 0-9 / _)",
|
||||
"nameSaved": "ユーザー名を更新しました"
|
||||
"noWithdrawalsFound": "出金履歴がありません"
|
||||
},
|
||||
"deposit": {
|
||||
"title": "入金",
|
||||
|
||||
@ -183,60 +183,7 @@
|
||||
"withdrawChain": "체인",
|
||||
"withdrawTime": "시간",
|
||||
"withdrawStatus": "상태",
|
||||
"noWithdrawalsFound": "출금 내역이 없습니다",
|
||||
"walletTitle": "지갑",
|
||||
"sharesLabel": "수량",
|
||||
"avgPriceLabel": "평균 가격",
|
||||
"currentPriceLabel": "현재 가격",
|
||||
"openOrderPriceLabel": "지정가 가격",
|
||||
"filledTotalLabel": "체결 / 전체",
|
||||
"orderValueLabel": "주문 가치",
|
||||
"withdrawAmountLabel": "출금 금액",
|
||||
"feeLabel": "수수료",
|
||||
"withdrawAddressLabel": "출금 주소",
|
||||
"priceLabel": "가격",
|
||||
"sellDialogTitle": "매도 {outcome}",
|
||||
"position": "포지션",
|
||||
"sellDialogReceive": "받게 됨",
|
||||
"sellDialogRedeem": "환급",
|
||||
"sellDialogEditOrder": "주문 수정",
|
||||
"btcWithdrawHistoryLabel": "BTC 출금",
|
||||
"usdtDepositHistoryLabel": "USDT 입금",
|
||||
"withdrawIconText": "출",
|
||||
"depositIconText": "입",
|
||||
"cancelFailed": "취소에 실패했습니다"
|
||||
},
|
||||
"profile": {
|
||||
"uidLabel": "UID {uid}",
|
||||
"edit": "편집",
|
||||
"walletOverview": "지갑 개요",
|
||||
"walletDetail": "지갑 상세",
|
||||
"walletSub": "사용 가능 잔액 ${available} 동결 ${frozen}",
|
||||
"accountSettings": "계정 및 설정",
|
||||
"walletManage": "지갑 관리",
|
||||
"apiKeyManage": "API KEY 관리",
|
||||
"language": "언어",
|
||||
"selectLanguage": "언어 선택",
|
||||
"currentWalletAddress": "현재 지갑 주소",
|
||||
"copyAddress": "주소 복사",
|
||||
"copySuccess": "주소가 복사되었습니다",
|
||||
"copyFailed": "복사에 실패했습니다. 잠시 후 다시 시도해 주세요",
|
||||
"walletAddressUnavailable": "지갑 주소를 불러오지 못했습니다",
|
||||
"unbound": "미연결",
|
||||
"defaultName": "사용자",
|
||||
"vipTrader": "VIP Trader",
|
||||
"trader": "Trader",
|
||||
"editComingSoon": "편집 기능은 곧 제공됩니다",
|
||||
"editNameTitle": "사용자 이름 수정",
|
||||
"newUserName": "새 사용자 이름",
|
||||
"cancel": "취소",
|
||||
"save": "저장",
|
||||
"nameRequired": "사용자 이름은 필수입니다",
|
||||
"nameTooLong": "사용자 이름은 {max}자 이하여야 합니다",
|
||||
"nameInvalidFormat": "사용자 이름은 영문자, 숫자, 밑줄(_)만 사용할 수 있습니다",
|
||||
"nameTooShort": "사용자 이름은 최소 {min}자 이상이어야 합니다",
|
||||
"nameFormatHint": "영문자, 숫자, 밑줄(_)만 사용할 수 있습니다 (a-z / 0-9 / _)",
|
||||
"nameSaved": "사용자 이름이 업데이트되었습니다"
|
||||
"noWithdrawalsFound": "출금 내역이 없습니다"
|
||||
},
|
||||
"deposit": {
|
||||
"title": "입금",
|
||||
|
||||
@ -183,60 +183,7 @@
|
||||
"withdrawTime": "时间",
|
||||
"withdrawStatus": "状态",
|
||||
"noWithdrawalsFound": "暂无提现记录",
|
||||
"withdrawAddress": "提现地址",
|
||||
"walletTitle": "钱包",
|
||||
"sharesLabel": "份额",
|
||||
"avgPriceLabel": "买入均价",
|
||||
"currentPriceLabel": "当前价格",
|
||||
"openOrderPriceLabel": "挂单价格",
|
||||
"filledTotalLabel": "成单/挂单",
|
||||
"orderValueLabel": "订单价值",
|
||||
"withdrawAmountLabel": "提现金额",
|
||||
"feeLabel": "手续费",
|
||||
"withdrawAddressLabel": "提现地址",
|
||||
"priceLabel": "价格",
|
||||
"sellDialogTitle": "卖出 {outcome}",
|
||||
"position": "仓位",
|
||||
"sellDialogReceive": "接收",
|
||||
"sellDialogRedeem": "赎回",
|
||||
"sellDialogEditOrder": "编辑订单",
|
||||
"btcWithdrawHistoryLabel": "BTC 提现",
|
||||
"usdtDepositHistoryLabel": "USDT 充值",
|
||||
"withdrawIconText": "提",
|
||||
"depositIconText": "充",
|
||||
"cancelFailed": "取消失败"
|
||||
},
|
||||
"profile": {
|
||||
"uidLabel": "UID {uid}",
|
||||
"edit": "编辑",
|
||||
"walletOverview": "钱包总览",
|
||||
"walletDetail": "钱包详情",
|
||||
"walletSub": "可用余额 ${available} 冻结 ${frozen}",
|
||||
"accountSettings": "账户与设置",
|
||||
"walletManage": "钱包管理",
|
||||
"apiKeyManage": "API KEY 管理",
|
||||
"language": "语言",
|
||||
"selectLanguage": "选择语言",
|
||||
"currentWalletAddress": "当前钱包地址",
|
||||
"copyAddress": "复制地址",
|
||||
"copySuccess": "地址复制成功",
|
||||
"copyFailed": "复制失败,请稍后重试",
|
||||
"walletAddressUnavailable": "未获取到钱包地址",
|
||||
"unbound": "未绑定",
|
||||
"defaultName": "用户",
|
||||
"vipTrader": "VIP Trader",
|
||||
"trader": "Trader",
|
||||
"editComingSoon": "编辑功能即将上线",
|
||||
"editNameTitle": "修改用户名",
|
||||
"newUserName": "用户名",
|
||||
"cancel": "取消",
|
||||
"save": "保存",
|
||||
"nameRequired": "用户名不能为空",
|
||||
"nameTooLong": "用户名不能超过 {max} 个字符",
|
||||
"nameInvalidFormat": "用户名只能包含字母、数字与下划线(a-z / 0-9 / _)",
|
||||
"nameTooShort": "用户名长度至少 {min} 位",
|
||||
"nameFormatHint": "只能包含字母、数字与下划线(a-z / 0-9 / _)",
|
||||
"nameSaved": "用户名已更新"
|
||||
"withdrawAddress": "提现地址"
|
||||
},
|
||||
"deposit": {
|
||||
"title": "入金",
|
||||
|
||||
@ -183,60 +183,7 @@
|
||||
"withdrawChain": "鏈",
|
||||
"withdrawTime": "時間",
|
||||
"withdrawStatus": "狀態",
|
||||
"noWithdrawalsFound": "暫無提現記錄",
|
||||
"walletTitle": "錢包",
|
||||
"sharesLabel": "份額",
|
||||
"avgPriceLabel": "買入均價",
|
||||
"currentPriceLabel": "當前價格",
|
||||
"openOrderPriceLabel": "掛單價格",
|
||||
"filledTotalLabel": "成交/掛單",
|
||||
"orderValueLabel": "訂單價值",
|
||||
"withdrawAmountLabel": "提現金額",
|
||||
"feeLabel": "手續費",
|
||||
"withdrawAddressLabel": "提現地址",
|
||||
"priceLabel": "價格",
|
||||
"sellDialogTitle": "賣出 {outcome}",
|
||||
"position": "倉位",
|
||||
"sellDialogReceive": "接收",
|
||||
"sellDialogRedeem": "贖回",
|
||||
"sellDialogEditOrder": "編輯訂單",
|
||||
"btcWithdrawHistoryLabel": "BTC 提現",
|
||||
"usdtDepositHistoryLabel": "USDT 充值",
|
||||
"withdrawIconText": "提",
|
||||
"depositIconText": "充",
|
||||
"cancelFailed": "取消失敗"
|
||||
},
|
||||
"profile": {
|
||||
"uidLabel": "UID {uid}",
|
||||
"edit": "編輯",
|
||||
"walletOverview": "錢包總覽",
|
||||
"walletDetail": "錢包詳情",
|
||||
"walletSub": "可用餘額 ${available} 凍結 ${frozen}",
|
||||
"accountSettings": "帳戶與設定",
|
||||
"walletManage": "錢包管理",
|
||||
"apiKeyManage": "API KEY 管理",
|
||||
"language": "語言",
|
||||
"selectLanguage": "選擇語言",
|
||||
"currentWalletAddress": "目前錢包地址",
|
||||
"copyAddress": "複製地址",
|
||||
"copySuccess": "地址已複製",
|
||||
"copyFailed": "複製失敗,請稍後再試",
|
||||
"walletAddressUnavailable": "未取得錢包地址",
|
||||
"unbound": "未綁定",
|
||||
"defaultName": "使用者",
|
||||
"vipTrader": "VIP Trader",
|
||||
"trader": "Trader",
|
||||
"editComingSoon": "編輯功能即將上線",
|
||||
"editNameTitle": "修改使用者名稱",
|
||||
"newUserName": "使用者名稱",
|
||||
"cancel": "取消",
|
||||
"save": "儲存",
|
||||
"nameRequired": "使用者名稱不能为空",
|
||||
"nameTooLong": "使用者名稱不能超過 {max} 個字元",
|
||||
"nameInvalidFormat": "使用者名稱只能包含字母、數字與底線(_)",
|
||||
"nameTooShort": "使用者名稱長度至少 {min} 個字元",
|
||||
"nameFormatHint": "只能包含字母、數字與底線(a-z / 0-9 / _)",
|
||||
"nameSaved": "使用者名稱已更新"
|
||||
"noWithdrawalsFound": "暫無提現記錄"
|
||||
},
|
||||
"deposit": {
|
||||
"title": "入金",
|
||||
|
||||
@ -6,8 +6,6 @@ import TradeDetail from '../views/TradeDetail.vue'
|
||||
import EventMarkets from '../views/EventMarkets.vue'
|
||||
import Wallet from '../views/Wallet.vue'
|
||||
import Search from '../views/Search.vue'
|
||||
import Profile from '../views/Profile.vue'
|
||||
import ApiKey from '../views/ApiKey.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
@ -47,16 +45,6 @@ const router = createRouter({
|
||||
name: 'wallet',
|
||||
component: Wallet,
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'profile',
|
||||
component: Profile,
|
||||
},
|
||||
{
|
||||
path: '/api-key',
|
||||
name: 'api-key',
|
||||
component: ApiKey,
|
||||
},
|
||||
],
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
if (savedPosition && from?.name) return savedPosition
|
||||
|
||||
@ -1,150 +0,0 @@
|
||||
<template>
|
||||
<div class="api-key-page">
|
||||
<div class="api-key-screen">
|
||||
<header class="api-key-header">
|
||||
<h1 class="api-key-title">API Key 管理</h1>
|
||||
<button class="create-btn" type="button">创建 Key</button>
|
||||
</header>
|
||||
|
||||
<section class="api-key-list">
|
||||
<article v-for="item in apiKeys" :key="item.id" class="api-key-item">
|
||||
<p class="item-name">{{ item.name }}</p>
|
||||
<p class="item-value">{{ item.key }}</p>
|
||||
<div class="item-actions">
|
||||
<button class="action-btn action-copy" type="button">复制</button>
|
||||
<button class="action-btn action-delete" type="button">删除</button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface ApiKeyItem {
|
||||
id: string
|
||||
name: string
|
||||
key: string
|
||||
}
|
||||
|
||||
const apiKeys: ApiKeyItem[] = [
|
||||
{ id: '1', name: 'Key #1', key: 'pk_live_8f2a9d1c5e7b44a8b1c2d3e4f5a6b7c8' },
|
||||
{ id: '2', name: 'Key #2', key: 'pk_test_4d8f8a0c2e49f6h0j2k4m8n6p0r2i4v6' },
|
||||
{ id: '3', name: 'Key #3', key: 'pk_live_1a3c5e7g8i1k3m5o7q9s1u8w5y7z9b1d' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.api-key-page {
|
||||
min-height: 100vh;
|
||||
background: #fcfcfc;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.api-key-screen {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-height: 980px;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
font-family: Inter, sans-serif;
|
||||
}
|
||||
|
||||
.api-key-header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.api-key-title {
|
||||
margin: 0;
|
||||
color: #111827;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
background: #5b5bd6;
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.api-key-list {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.api-key-item {
|
||||
width: 100%;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #ffffff;
|
||||
padding: 14px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.item-value {
|
||||
margin: 0;
|
||||
color: #111827;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
padding: 0 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action-copy {
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #fcfcfc;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.action-delete {
|
||||
border: 0;
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
</style>
|
||||
@ -1,699 +0,0 @@
|
||||
<template>
|
||||
<div class="profile-page">
|
||||
<div class="profile-screen">
|
||||
<section class="card profile-card">
|
||||
<div class="top-row">
|
||||
<div class="avatar">
|
||||
<img v-if="avatarImage" :src="avatarImage" :alt="displayName" class="avatar-img" />
|
||||
<span v-else>{{ avatarText }}</span>
|
||||
</div>
|
||||
<div class="info-col">
|
||||
<div class="name-text">{{ displayName }}</div>
|
||||
<div class="acc-text">{{ t('profile.uidLabel', { uid: userIdText }) }}</div>
|
||||
<div class="tag-text">{{ userTag }}</div>
|
||||
</div>
|
||||
<button class="edit-btn" type="button" @click="onEditProfile">{{ t('profile.edit') }}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card wallet-card">
|
||||
<div class="wallet-head">
|
||||
<span class="wallet-title">{{ t('profile.walletOverview') }}</span>
|
||||
<button class="wallet-link" type="button" @click="goWallet">{{ t('profile.walletDetail') }} ></button>
|
||||
</div>
|
||||
<div class="wallet-balance">${{ totalBalance }}</div>
|
||||
<div class="wallet-sub">
|
||||
{{ t('profile.walletSub', { available: availableBalance, frozen: frozenBalance }) }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card menu-card">
|
||||
<div class="menu-title">{{ t('profile.accountSettings') }}</div>
|
||||
<button v-for="item in settingItems" :key="item.label" class="menu-item" type="button" @click="goSetting(item)">
|
||||
<span>{{ item.label }}</span>
|
||||
<span v-if="item.action === 'locale'" class="menu-locale">{{ currentLocaleLabel }}</span>
|
||||
<span v-else-if="item.action === 'wallet'" class="menu-locale">{{ walletAddressShort }}</span>
|
||||
<span v-else class="menu-arrow">></span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<button class="logout-btn" type="button" :disabled="logoutLoading" @click="logout">
|
||||
{{ t('common.logout') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<v-dialog v-model="localeDialogOpen" max-width="360">
|
||||
<v-card class="locale-dialog-card" rounded="xl" elevation="0">
|
||||
<div class="locale-dialog-title">{{ t('profile.selectLanguage') }}</div>
|
||||
<button
|
||||
v-for="opt in localeStore.localeOptions"
|
||||
:key="opt.value"
|
||||
class="locale-option"
|
||||
type="button"
|
||||
@click="chooseLocale(opt.value)"
|
||||
>
|
||||
<span>{{ opt.label }}</span>
|
||||
<span v-if="opt.value === localeStore.currentLocale" class="locale-selected">✓</span>
|
||||
</button>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog v-model="walletDialogOpen" max-width="420">
|
||||
<v-card class="wallet-dialog-card" rounded="xl" elevation="0">
|
||||
<div class="wallet-dialog-title">{{ t('profile.currentWalletAddress') }}</div>
|
||||
<div class="wallet-dialog-address">{{ walletAddressText }}</div>
|
||||
<div class="wallet-dialog-actions">
|
||||
<button class="wallet-copy-btn" type="button" :disabled="!walletAddress" @click="copyWalletAddress">
|
||||
{{ t('profile.copyAddress') }}
|
||||
</button>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog v-model="editNameDialogOpen" max-width="420">
|
||||
<v-card class="name-dialog-card" rounded="xl" elevation="0">
|
||||
<div class="name-dialog-title">{{ t('profile.editNameTitle') }}</div>
|
||||
<v-text-field
|
||||
v-model="editingName"
|
||||
:label="t('profile.newUserName')"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
:error-messages="nameError ? [nameError] : []"
|
||||
:hint="t('profile.nameFormatHint')"
|
||||
persistent-hint
|
||||
/>
|
||||
<div class="name-dialog-actions">
|
||||
<button class="name-dialog-cancel-btn" type="button" :disabled="isSaving" @click="closeEditNameDialog">
|
||||
{{ t('profile.cancel') }}
|
||||
</button>
|
||||
<button class="name-dialog-save-btn" type="button" :disabled="isSaving" @click="saveName">
|
||||
{{ t('profile.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useLocaleStore } from '../stores/locale'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
import { useUserStore, type UserInfo } from '../stores/user'
|
||||
import { setSelfUsername } from '@/api/user'
|
||||
import type { LocaleCode } from '@/plugins/i18n'
|
||||
|
||||
interface SettingItem {
|
||||
label: string
|
||||
route?: string
|
||||
action?: 'locale' | 'wallet'
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const localeStore = useLocaleStore()
|
||||
const userStore = useUserStore()
|
||||
const toastStore = useToastStore()
|
||||
const localeDialogOpen = ref(false)
|
||||
const walletDialogOpen = ref(false)
|
||||
const logoutLoading = ref(false)
|
||||
const editNameDialogOpen = ref(false)
|
||||
const editingName = ref('')
|
||||
const nameError = ref<string | null>(null)
|
||||
const isSaving = ref(false)
|
||||
|
||||
const currentLocaleLabel = computed(() => {
|
||||
return (
|
||||
localeStore.localeOptions.find((opt) => opt.value === localeStore.currentLocale)?.label ??
|
||||
String(localeStore.currentLocale)
|
||||
)
|
||||
})
|
||||
|
||||
function readStringFromUser(keys: string[]): string {
|
||||
const user = userStore.user as Record<string, unknown> | null
|
||||
if (!user) return ''
|
||||
for (const key of keys) {
|
||||
const value = user[key]
|
||||
if (typeof value === 'string' && value.trim()) return value.trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const walletAddress = computed(() => {
|
||||
const user = userStore.user as Record<string, unknown> | null
|
||||
if (!user) return ''
|
||||
const candidateKeys = ['walletAddress', 'address', 'wallet', 'walletAddr', 'ethAddress']
|
||||
for (const key of candidateKeys) {
|
||||
const value = user[key]
|
||||
if (typeof value === 'string' && value.trim()) return value.trim()
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const walletAddressText = computed(() => walletAddress.value || t('profile.walletAddressUnavailable'))
|
||||
const walletAddressShort = computed(() => {
|
||||
const value = walletAddress.value
|
||||
if (!value) return t('profile.unbound')
|
||||
if (value.length <= 14) return value
|
||||
return `${value.slice(0, 6)}...${value.slice(-4)}`
|
||||
})
|
||||
|
||||
const rawUser = computed(() => (userStore.user ?? {}) as Record<string, unknown>)
|
||||
const userNameRaw = computed(() => {
|
||||
const v = rawUser.value.userName
|
||||
return typeof v === 'string' && v.trim() ? v.trim() : ''
|
||||
})
|
||||
const displayName = computed(() => userNameRaw.value || t('profile.defaultName'))
|
||||
const userIdText = computed(() => {
|
||||
const uid = rawUser.value.id ?? rawUser.value.ID
|
||||
if (uid == null || uid === '') return '--'
|
||||
return String(uid)
|
||||
})
|
||||
const avatarImage = computed(() => userStore.avatarUrl || '')
|
||||
const avatarText = computed(() => {
|
||||
const first = displayName.value.trim().charAt(0)
|
||||
return first ? first.toUpperCase() : 'U'
|
||||
})
|
||||
const hasVip = computed(() => {
|
||||
const user = rawUser.value
|
||||
const candidates = [user.isVip, user.vip, user.vipLevel, user.memberLevel]
|
||||
return candidates.some((v) => {
|
||||
if (typeof v === 'boolean') return v
|
||||
if (typeof v === 'number') return v > 0
|
||||
if (typeof v === 'string') return v.trim() === '1' || v.trim().toLowerCase() === 'vip'
|
||||
return false
|
||||
})
|
||||
})
|
||||
const userTag = computed(() => (hasVip.value ? t('profile.vipTrader') : t('profile.trader')))
|
||||
const totalBalance = computed(() => userStore.balance || '0.00')
|
||||
const availableBalance = computed(() => {
|
||||
const val = readStringFromUser(['availableBalance', 'available', 'walletAvailable'])
|
||||
return val || totalBalance.value
|
||||
})
|
||||
const frozenBalance = computed(() => {
|
||||
return readStringFromUser(['frozenBalance', 'frozen', 'walletFrozen']) || '0.00'
|
||||
})
|
||||
const settingItems = computed<SettingItem[]>(() => [
|
||||
{ label: t('profile.walletManage'), action: 'wallet' },
|
||||
{ label: t('profile.apiKeyManage'), route: '/api-key' },
|
||||
{ label: t('profile.language'), action: 'locale' },
|
||||
])
|
||||
|
||||
function goSetting(item: SettingItem) {
|
||||
if (item.action === 'wallet') {
|
||||
walletDialogOpen.value = true
|
||||
return
|
||||
}
|
||||
if (item.action === 'locale') {
|
||||
localeDialogOpen.value = true
|
||||
return
|
||||
}
|
||||
if (!item.route) return
|
||||
router.push(item.route)
|
||||
}
|
||||
|
||||
function chooseLocale(locale: LocaleCode) {
|
||||
localeStore.setLocale(locale)
|
||||
localeDialogOpen.value = false
|
||||
}
|
||||
|
||||
async function copyWalletAddress() {
|
||||
if (!walletAddress.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(walletAddress.value)
|
||||
walletDialogOpen.value = false
|
||||
toastStore.show(t('profile.copySuccess'))
|
||||
} catch {
|
||||
toastStore.show(t('profile.copyFailed'), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function onEditProfile() {
|
||||
editNameDialogOpen.value = true
|
||||
editingName.value = userNameRaw.value
|
||||
nameError.value = null
|
||||
}
|
||||
|
||||
function goWallet() {
|
||||
router.push('/wallet')
|
||||
}
|
||||
|
||||
function validateUserName(name: string): string | null {
|
||||
const v = name.trim()
|
||||
if (!v) return t('profile.nameRequired')
|
||||
if (v.length < MIN_USER_NAME_LEN) return t('profile.nameTooShort', { min: MIN_USER_NAME_LEN })
|
||||
if (v.length > MAX_USER_NAME_LEN) return t('profile.nameTooLong', { max: MAX_USER_NAME_LEN })
|
||||
const allowedRe = /^[a-z0-9_]+$/i
|
||||
if (!allowedRe.test(v)) return t('profile.nameInvalidFormat')
|
||||
return null
|
||||
}
|
||||
|
||||
function closeEditNameDialog() {
|
||||
if (isSaving.value) return
|
||||
editNameDialogOpen.value = false
|
||||
nameError.value = null
|
||||
}
|
||||
|
||||
async function saveName() {
|
||||
if (isSaving.value) return
|
||||
const err = validateUserName(editingName.value)
|
||||
if (err) {
|
||||
nameError.value = err
|
||||
return
|
||||
}
|
||||
|
||||
const trimmed = editingName.value.trim()
|
||||
const token = userStore.token
|
||||
const u = userStore.user as UserInfo | null
|
||||
if (!token || !u) {
|
||||
toastStore.show(t('error.pleaseLogin'), 'error')
|
||||
return
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
const authHeaders = userStore.getAuthHeaders()
|
||||
if (!authHeaders) {
|
||||
toastStore.show(t('error.pleaseLogin'), 'error')
|
||||
return
|
||||
}
|
||||
|
||||
const res = await setSelfUsername(authHeaders, { username: trimmed })
|
||||
if (res.code !== 0 && res.code !== 200) {
|
||||
nameError.value = res.msg || t('error.requestFailed')
|
||||
return
|
||||
}
|
||||
|
||||
// 接口成功后刷新用户信息,确保 userName/headerImg 等字段一致
|
||||
await userStore.fetchUserInfo()
|
||||
toastStore.show(t('profile.nameSaved'), 'success')
|
||||
editNameDialogOpen.value = false
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
nameError.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const MIN_USER_NAME_LEN = 2
|
||||
const MAX_USER_NAME_LEN = 20
|
||||
|
||||
async function logout() {
|
||||
if (logoutLoading.value) return
|
||||
logoutLoading.value = true
|
||||
try {
|
||||
await userStore.logout()
|
||||
router.push('/login')
|
||||
} catch {
|
||||
toastStore.show(t('error.requestFailed'), 'error')
|
||||
} finally {
|
||||
logoutLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!userStore.isLoggedIn) return
|
||||
userStore.fetchUserInfo()
|
||||
userStore.fetchUsdcBalance()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.profile-page {
|
||||
min-height: 100vh;
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.profile-screen {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-height: 0;
|
||||
background: #fcfcfc;
|
||||
font-family: Inter, sans-serif;
|
||||
padding: 16px 16px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 100%;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.top-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 999px;
|
||||
background: #5b5bd6;
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.info-col {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.name-text {
|
||||
color: #111827;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.acc-text {
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.tag-text {
|
||||
color: #5b5bd6;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #fcfcfc;
|
||||
color: #111827;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wallet-card {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.wallet-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.wallet-title {
|
||||
color: #111827;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.wallet-link {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #5b5bd6;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wallet-balance {
|
||||
color: #111827;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.wallet-sub {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.wallet-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action-primary {
|
||||
border: 0;
|
||||
background: #5b5bd6;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.action-secondary {
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #fcfcfc;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.menu-card {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.menu-title {
|
||||
color: #111827;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
height: 44px;
|
||||
padding: 0 4px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: #111827;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menu-arrow {
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.menu-locale {
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.logout-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.locale-dialog-card {
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #ffffff;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.locale-dialog-title {
|
||||
padding: 10px 10px 6px;
|
||||
color: #111827;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.locale-option {
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: #111827;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.locale-option:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.locale-selected {
|
||||
color: #5b5bd6;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.wallet-dialog-card {
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #ffffff;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.wallet-dialog-title {
|
||||
color: #111827;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.wallet-dialog-address {
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #fcfcfc;
|
||||
padding: 12px;
|
||||
color: #111827;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.wallet-dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.wallet-copy-btn {
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
border: 0;
|
||||
padding: 0 14px;
|
||||
background: #5b5bd6;
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wallet-copy-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.name-dialog-card {
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #ffffff;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.name-dialog-title {
|
||||
color: #111827;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
padding: 2px 2px 0;
|
||||
}
|
||||
|
||||
.name-dialog-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.name-dialog-cancel-btn {
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #fcfcfc;
|
||||
color: #111827;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.name-dialog-cancel-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.name-dialog-save-btn {
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
border: 0;
|
||||
background: #5b5bd6;
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.name-dialog-save-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@ -1,421 +1,8 @@
|
||||
<template>
|
||||
<div class="search-page">
|
||||
<div class="search-screen">
|
||||
<h1 class="search-header">搜索</h1>
|
||||
|
||||
<form class="search-box" @submit.prevent="onSearch">
|
||||
<input
|
||||
v-model.trim="searchKeyword"
|
||||
class="search-input"
|
||||
type="search"
|
||||
enterkeyhint="search"
|
||||
inputmode="search"
|
||||
autocomplete="off"
|
||||
placeholder="搜索市场、话题、地址"
|
||||
@keydown.enter.prevent="onSearch"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<template v-if="showResults">
|
||||
<section class="result-list">
|
||||
<article
|
||||
v-for="item in resultItems"
|
||||
:key="item.id"
|
||||
class="result-item"
|
||||
@click="openResult(item)"
|
||||
>
|
||||
<div class="result-left">
|
||||
<div class="result-icon" :class="item.iconClass">{{ item.iconText }}</div>
|
||||
<div class="result-title">{{ item.title }}</div>
|
||||
</div>
|
||||
<div class="result-right">
|
||||
<div class="result-pct" :class="item.pctClass">{{ item.percent }}</div>
|
||||
<div class="result-time">{{ item.timeAgo }}</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
<template v-else>
|
||||
<section class="card records-card">
|
||||
<h2 class="card-title">搜索记录</h2>
|
||||
<button
|
||||
v-for="record in searchRecords"
|
||||
:key="record"
|
||||
type="button"
|
||||
class="record-row"
|
||||
@click="useRecord(record)"
|
||||
>
|
||||
<span class="record-text">{{ record }}</span>
|
||||
<v-icon size="16" class="record-close" @click.stop="removeRecord(record)">mdi-close</v-icon>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="card tags-card">
|
||||
<h2 class="card-title">推荐标签</h2>
|
||||
<div class="tag-row">
|
||||
<button v-for="tag in recommendTags" :key="tag" type="button" class="tag-chip" @click="useTag(tag)">
|
||||
{{ tag }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<div v-if="searching" class="search-loading">搜索中...</div>
|
||||
<div v-if="searchError" class="search-error">{{ searchError }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Home :initial-search-expanded="true" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { getPmEventPublic, type PmEventListItem } from '../api/event'
|
||||
|
||||
interface SearchResultItem {
|
||||
id: string
|
||||
title: string
|
||||
iconText: string
|
||||
iconClass: string
|
||||
percent: string
|
||||
pctClass: string
|
||||
timeAgo: string
|
||||
raw: PmEventListItem
|
||||
}
|
||||
|
||||
const searchRecords = ref(['BTC ETF approval odds', 'US election winner', 'ETH above $4k'])
|
||||
const recommendTags = ref(['ETH', '科技股', '总统大选'])
|
||||
const searchKeyword = ref('')
|
||||
const searching = ref(false)
|
||||
const searchError = ref('')
|
||||
const hasSearched = ref(false)
|
||||
const resultItems = ref<SearchResultItem[]>([])
|
||||
|
||||
const showResults = computed(() => hasSearched.value)
|
||||
|
||||
function removeRecord(record: string) {
|
||||
searchRecords.value = searchRecords.value.filter((item) => item !== record)
|
||||
}
|
||||
|
||||
function upsertRecord(keyword: string) {
|
||||
searchRecords.value = [keyword, ...searchRecords.value.filter((item) => item !== keyword)].slice(0, 10)
|
||||
}
|
||||
|
||||
function getTimeAgoLabel(event: PmEventListItem): string {
|
||||
const source = event.updatedAt || event.createdAt || event.startDate
|
||||
if (!source) return '1d ago'
|
||||
const time = new Date(source).getTime()
|
||||
if (!Number.isFinite(time)) return '1d ago'
|
||||
const diff = Date.now() - time
|
||||
if (diff < 60 * 60 * 1000) return `${Math.max(1, Math.floor(diff / (60 * 1000)))}m ago`
|
||||
if (diff < 24 * 60 * 60 * 1000) return `${Math.max(1, Math.floor(diff / (60 * 60 * 1000)))}h ago`
|
||||
return `${Math.max(1, Math.floor(diff / (24 * 60 * 60 * 1000)))}d ago`
|
||||
}
|
||||
|
||||
function mapEventToResultItem(event: PmEventListItem, idx: number): SearchResultItem {
|
||||
const title = event.title || event.slug || 'Untitled Event'
|
||||
const iconText = title.trim().charAt(0).toUpperCase() || 'M'
|
||||
const iconClass = idx === 1 ? 'icon-red' : idx === 2 ? 'icon-primary' : 'icon-dark'
|
||||
const chance = Number(event.markets?.[0]?.outcomePrices?.[0] ?? 0.5)
|
||||
const percentNum = Number.isFinite(chance) ? Math.round(chance * 100) : 50
|
||||
return {
|
||||
id: String(event.ID ?? `${title}-${idx}`),
|
||||
title,
|
||||
iconText,
|
||||
iconClass,
|
||||
percent: `${percentNum}%`,
|
||||
pctClass: idx === 0 ? 'pct-primary' : 'pct-default',
|
||||
timeAgo: getTimeAgoLabel(event),
|
||||
raw: event,
|
||||
}
|
||||
}
|
||||
|
||||
function buildFallbackResults(): SearchResultItem[] {
|
||||
return [
|
||||
{
|
||||
id: 'btc',
|
||||
title: 'BTC > $85k this year',
|
||||
iconText: 'B',
|
||||
iconClass: 'icon-dark',
|
||||
percent: '72%',
|
||||
pctClass: 'pct-primary',
|
||||
timeAgo: '2h ago',
|
||||
raw: { ID: 1, title: 'BTC > $85k this year' } as PmEventListItem,
|
||||
},
|
||||
{
|
||||
id: 'eth',
|
||||
title: 'ETH above $4k by Q4',
|
||||
iconText: 'E',
|
||||
iconClass: 'icon-red',
|
||||
percent: '61%',
|
||||
pctClass: 'pct-default',
|
||||
timeAgo: '5h ago',
|
||||
raw: { ID: 2, title: 'ETH above $4k by Q4' } as PmEventListItem,
|
||||
},
|
||||
{
|
||||
id: 'us',
|
||||
title: 'US Election Winner',
|
||||
iconText: 'U',
|
||||
iconClass: 'icon-primary',
|
||||
percent: '54%',
|
||||
pctClass: 'pct-default',
|
||||
timeAgo: '1d ago',
|
||||
raw: { ID: 3, title: 'US Election Winner' } as PmEventListItem,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
async function onSearch() {
|
||||
const keyword = searchKeyword.value.trim()
|
||||
if (!keyword || searching.value) return
|
||||
|
||||
searching.value = true
|
||||
searchError.value = ''
|
||||
try {
|
||||
const res = await getPmEventPublic({ page: 1, pageSize: 10, keyword })
|
||||
const list = res.data?.list ?? []
|
||||
resultItems.value = (list.length > 0 ? list : []).slice(0, 12).map(mapEventToResultItem)
|
||||
if (resultItems.value.length === 0) {
|
||||
resultItems.value = buildFallbackResults()
|
||||
}
|
||||
upsertRecord(keyword)
|
||||
hasSearched.value = true
|
||||
} catch {
|
||||
resultItems.value = buildFallbackResults()
|
||||
hasSearched.value = true
|
||||
searchError.value = '搜索失败,已展示示例结果'
|
||||
} finally {
|
||||
searching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function useRecord(record: string) {
|
||||
searchKeyword.value = record
|
||||
onSearch()
|
||||
}
|
||||
|
||||
function useTag(tag: string) {
|
||||
searchKeyword.value = tag
|
||||
onSearch()
|
||||
}
|
||||
|
||||
function openResult(_item: SearchResultItem) {
|
||||
// Placeholder: can route to detail page later.
|
||||
}
|
||||
import Home from './Home.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-page {
|
||||
min-height: 100vh;
|
||||
background: #fcfcfc;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.search-screen {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-height: 980px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
font-family: Inter, sans-serif;
|
||||
}
|
||||
|
||||
.search-header {
|
||||
margin: 0;
|
||||
color: #111827;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
height: 42px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #fff;
|
||||
padding: 0 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: #111827;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
font-family: Inter, sans-serif;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 100%;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #fff;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0;
|
||||
color: #111827;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.record-row {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.record-text {
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.record-close {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.tag-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
height: 30px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #fcfcfc;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.result-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
width: 100%;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #fff;
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.result-left {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.result-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.icon-dark {
|
||||
background: #111827;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.icon-red {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.icon-primary {
|
||||
background: #e0e7ff;
|
||||
color: #5b5bd6;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
color: #111827;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.result-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.result-pct {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pct-primary {
|
||||
color: #5b5bd6;
|
||||
}
|
||||
|
||||
.pct-default {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.result-time {
|
||||
color: #9ca3af;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.search-loading,
|
||||
.search-error {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.search-error {
|
||||
color: #dc2626;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1830,7 +1830,7 @@ onUnmounted(() => {
|
||||
margin-top: 16px;
|
||||
padding: 0;
|
||||
background-color: #ffffff;
|
||||
border: none;
|
||||
border: 1px solid #e7e7e7;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
|
||||
1723
src/views/Wallet.vue
1723
src/views/Wallet.vue
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user