Compare commits
7 Commits
6cde1ab561
...
f1dfbf7d80
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1dfbf7d80 | ||
|
|
afdc5edcca | ||
|
|
0c2127c087 | ||
|
|
9a8b69c15a | ||
|
|
7b076aa4f2 | ||
|
|
effa221907 | ||
|
|
88b7a97e3f |
6493
design/pencil-new.pen
Normal file
6493
design/pencil-new.pen
Normal file
File diff suppressed because it is too large
Load Diff
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## 功能用途
|
## 功能用途
|
||||||
|
|
||||||
HTTP 请求基础封装,提供 `get`、`post`、`buildQuery` 方法,以及 WebSocket URL 生成。所有 API 模块均通过此文件发起请求。
|
HTTP 请求基础封装,提供 `get`、`post`、`put`、`buildQuery` 方法,以及 WebSocket URL 生成。所有 API 模块均通过此文件发起请求。
|
||||||
|
|
||||||
## 核心能力
|
## 核心能力
|
||||||
|
|
||||||
@ -14,13 +14,14 @@ HTTP 请求基础封装,提供 `get`、`post`、`buildQuery` 方法,以及 W
|
|||||||
- User WebSocket URL:`getUserWsUrl()` 返回 `ws(s)://host/clob/ws/user`(订单/持仓/余额推送)
|
- User WebSocket URL:`getUserWsUrl()` 返回 `ws(s)://host/clob/ws/user`(订单/持仓/余额推送)
|
||||||
- GET 请求:支持 query 参数,自动序列化
|
- GET 请求:支持 query 参数,自动序列化
|
||||||
- POST 请求:支持 JSON body
|
- POST 请求:支持 JSON body
|
||||||
|
- PUT 请求:支持 JSON body
|
||||||
- 自定义 headers:通过 `RequestConfig.headers` 传入
|
- 自定义 headers:通过 `RequestConfig.headers` 传入
|
||||||
- **Accept-Language**:所有 GET/POST 请求自动附带当前 vue-i18n 的 `locale`
|
- **Accept-Language**:所有 GET/POST/PUT 请求自动附带当前 vue-i18n 的 `locale`
|
||||||
|
|
||||||
## 使用方式
|
## 使用方式
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { get, post, buildQuery, getClobWsUrl, getUserWsUrl } from '@/api/request'
|
import { get, post, put, buildQuery, getClobWsUrl, getUserWsUrl } from '@/api/request'
|
||||||
|
|
||||||
// 构建 query(自动过滤空值)
|
// 构建 query(自动过滤空值)
|
||||||
const query = buildQuery({ page: 1, pageSize: 10, keyword, tagIds })
|
const query = buildQuery({ page: 1, pageSize: 10, keyword, tagIds })
|
||||||
@ -39,11 +40,16 @@ const data = await get<MyResponse>('/path', undefined, {
|
|||||||
const res = await post<MyResponse>('/path', { key: 'value' }, {
|
const res = await post<MyResponse>('/path', { key: 'value' }, {
|
||||||
headers: { 'x-token': token },
|
headers: { 'x-token': token },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// PUT 请求
|
||||||
|
const putRes = await put<MyResponse>('/path', { key: 'value' }, {
|
||||||
|
headers: { 'x-token': token },
|
||||||
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
## 扩展方式
|
## 扩展方式
|
||||||
|
|
||||||
1. **添加 PUT/DELETE**:仿照 `get`/`post` 实现 `put`、`del` 函数
|
1. **添加 DELETE**:仿照 `get`/`post`/`put` 实现 `del` 函数
|
||||||
2. **统一错误处理**:在 `get`/`post` 内对 `!res.ok` 做统一 toast 或错误上报
|
2. **统一错误处理**:在 `get`/`post` 内对 `!res.ok` 做统一 toast 或错误上报
|
||||||
3. **请求/响应拦截**:在 fetch 前后加入拦截逻辑(如 loading、日志)
|
3. **请求/响应拦截**:在 fetch 前后加入拦截逻辑(如 loading、日志)
|
||||||
4. **超时控制**:使用 `AbortController` 实现超时取消
|
4. **超时控制**:使用 `AbortController` 实现超时取消
|
||||||
|
|||||||
@ -4,12 +4,13 @@
|
|||||||
|
|
||||||
## 功能用途
|
## 功能用途
|
||||||
|
|
||||||
用户相关接口:获取用户信息、USDC 余额。对接 `/user/getUserInfo`、`/user/getUsdcBalance`,均需鉴权。
|
用户相关接口:获取用户信息、USDC 余额、修改自身用户名。对接 `/user/getUserInfo`、`/user/getUsdcBalance`、`/user/setSelfUsername`,均需鉴权。
|
||||||
|
|
||||||
## 核心能力
|
## 核心能力
|
||||||
|
|
||||||
- `getUserInfo`:获取当前用户信息(头像、昵称等)
|
- `getUserInfo`:获取当前用户信息(头像、昵称等)
|
||||||
- `getUsdcBalance`:查询 USDC 余额(amount、available、locked 需除以 1_000_000)
|
- `getUsdcBalance`:查询 USDC 余额(amount、available、locked 需除以 1_000_000)
|
||||||
|
- `setSelfUsername`:修改自身用户名(PUT `/user/setSelfUsername`)
|
||||||
- `formatUsdcBalance`:将原始数值转为显示用字符串(如 "0.00")
|
- `formatUsdcBalance`:将原始数值转为显示用字符串(如 "0.00")
|
||||||
|
|
||||||
## 类型说明
|
## 类型说明
|
||||||
@ -22,7 +23,7 @@
|
|||||||
## 使用方式
|
## 使用方式
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { getUserInfo, getUsdcBalance, formatUsdcBalance } from '@/api/user'
|
import { getUserInfo, getUsdcBalance, setSelfUsername, formatUsdcBalance } from '@/api/user'
|
||||||
|
|
||||||
const headers = { 'x-token': token, 'x-user-id': userId }
|
const headers = { 'x-token': token, 'x-user-id': userId }
|
||||||
|
|
||||||
@ -32,6 +33,12 @@ const balanceRes = await getUsdcBalance(headers)
|
|||||||
if (balanceRes.data) {
|
if (balanceRes.data) {
|
||||||
const display = formatUsdcBalance(balanceRes.data.available)
|
const display = formatUsdcBalance(balanceRes.data.available)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 修改自身用户名
|
||||||
|
const setRes = await setSelfUsername(headers, { username: 'new_username' })
|
||||||
|
if (setRes.code === 0 || setRes.code === 200) {
|
||||||
|
// 建议:成功后调用 fetchUserInfo() 刷新页面展示
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 扩展方式
|
## 扩展方式
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
- Asks、Bids 列表,带 `HorizontalProgressBar` 深度条(买卖两边共用同一最大值 `maxOrderBookTotal`,取两边累计总量中的最大值,便于对比深度)
|
- Asks、Bids 列表,带 `HorizontalProgressBar` 深度条(买卖两边共用同一最大值 `maxOrderBookTotal`,取两边累计总量中的最大值,便于对比深度)
|
||||||
- Last price、Spread 展示
|
- Last price、Spread 展示
|
||||||
- Live / 连接中 状态展示(均通过 i18n 国际化)
|
- Live / 连接中 状态展示(均通过 i18n 国际化)
|
||||||
|
- Header 右侧已移除折叠箭头图标(`mdi-chevron-up`),仅保留状态文案与成交量文案
|
||||||
|
|
||||||
## Props
|
## Props
|
||||||
|
|
||||||
|
|||||||
@ -4,20 +4,27 @@
|
|||||||
|
|
||||||
## 功能用途
|
## 功能用途
|
||||||
|
|
||||||
根组件,包含全局 AppBar、主内容区(router-view)。AppBar 含返回按钮、标题、登录/余额/头像菜单,主内容区使用 keep-alive 缓存 Home。
|
根组件,包含全局 AppBar、主内容区(router-view)。AppBar 含返回按钮、标题、登录/余额/头像入口,主内容区使用 keep-alive 缓存 Home。
|
||||||
|
|
||||||
## 核心能力
|
## 核心能力
|
||||||
|
|
||||||
- 顶部导航栏:返回、TestMarket 标题、Login 或余额+用户名+头像菜单
|
- 顶部导航栏:返回、TestMarket 标题、Login 或余额+头像入口
|
||||||
- 多语言入口:右侧地球图标(`mdi-earth`)+ 当前语言文案,点击打开语言选择菜单
|
- 头像入口:登录态点击头像直接跳转 `/profile`
|
||||||
- 移动端底部导航:Home / Search / Mine(三个 tab 等分屏宽,与路由联动;Mine 未登录跳转 Login;选中态仅加粗、无底色;未选中项图标与文字偏灰;底部导航上方有淡投影)
|
- 移动端底部导航:Home / Search / Mine(三个 tab 等分屏宽,与路由联动;Mine 点击跳转 `/profile`;选中态仅加粗、无底色;未选中项图标与文字偏灰;底部导航上方有淡投影);仅在 `/`、`/search`、`/profile` 三个主页面显示,其他页面隐藏
|
||||||
|
- 全局滚动稳定:通过 `scrollbar-gutter: stable` 保留滚动条占位,避免页面从搜索态切到结果态时出现顶部/底部导航轻微横向抖动
|
||||||
- 登录态:`userStore.isLoggedIn` 控制展示
|
- 登录态:`userStore.isLoggedIn` 控制展示
|
||||||
- 用户名:`nickName` 或 `userName` 显示在头像左侧(有值时)
|
- 用户名:`nickName` 或 `userName` 显示在头像左侧(有值时)
|
||||||
- 挂载时与 `isLoggedIn` 变为 true 时:拉取用户信息与余额(`router.isReady()` + `nextTick` 后执行),确保钱包登录、刷新页面后头像和用户名正确显示
|
- 挂载时与 `isLoggedIn` 变为 true 时:拉取用户信息与余额(`router.isReady()` + `nextTick` 后执行),确保钱包登录、刷新页面后头像和用户名正确显示
|
||||||
- keep-alive:`include="['Home']"` 缓存首页
|
- keep-alive:`include="['Home']"` 缓存首页
|
||||||
|
|
||||||
|
## 使用方式
|
||||||
|
|
||||||
|
- 启动应用后由 `App.vue` 承载顶栏、主内容与底部导航
|
||||||
|
- 路由切换时由 `bottomNavValue` 自动同步 Home / Search / Mine 高亮状态
|
||||||
|
- 页面内容通过 `<router-view>` 渲染,Home 页面使用 keep-alive 缓存
|
||||||
|
|
||||||
## 扩展方式
|
## 扩展方式
|
||||||
|
|
||||||
1. **多级导航**:根据路由深度调整返回逻辑
|
1. **多级导航**:根据路由深度调整返回逻辑
|
||||||
2. **主题切换**:增加亮/暗模式切换
|
2. **主题切换**:增加亮/暗模式切换
|
||||||
3. **多语言**:接入 i18n 插件
|
3. **个人入口扩展**:可在个人中心页继续扩展设置项与账户操作
|
||||||
|
|||||||
@ -17,6 +17,8 @@ Vue Router 配置,定义路由表与滚动行为。
|
|||||||
| /trade-detail/:id | trade-detail | TradeDetail |
|
| /trade-detail/:id | trade-detail | TradeDetail |
|
||||||
| /event/:id/markets | event-markets | EventMarkets |
|
| /event/:id/markets | event-markets | EventMarkets |
|
||||||
| /wallet | wallet | Wallet |
|
| /wallet | wallet | Wallet |
|
||||||
|
| /profile | profile | Profile |
|
||||||
|
| /api-key | api-key | ApiKey |
|
||||||
|
|
||||||
## 滚动行为
|
## 滚动行为
|
||||||
|
|
||||||
|
|||||||
22
docs/views/ApiKey.md
Normal file
22
docs/views/ApiKey.md
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# 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 脱敏展示与二次确认删除弹窗。
|
||||||
42
docs/views/Profile.md
Normal file
42
docs/views/Profile.md
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
# 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`(由固定值改为可收缩),减少页面因内容过少而产生滚动。
|
||||||
33
docs/views/Search.md
Normal file
33
docs/views/Search.md
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# 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,6 +11,7 @@
|
|||||||
|
|
||||||
- 分时图: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 分时走势图**
|
- 分时图: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` 转为展示值
|
- 订单簿:`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`(持仓数据)
|
- 交易:`TradeComponent`,传入 `market`、`initialOption`、`positions`(持仓数据)
|
||||||
- 持仓列表:通过 `getPositionList` 获取当前市场持仓,传递给 `TradeComponent` 用于计算可合并份额
|
- 持仓列表:通过 `getPositionList` 获取当前市场持仓,传递给 `TradeComponent` 用于计算可合并份额
|
||||||
- 限价订单:通过 `getOrderList` 获取当前市场未成交限价单,支持撤单
|
- 限价订单:通过 `getOrderList` 获取当前市场未成交限价单,支持撤单
|
||||||
|
|||||||
@ -5,15 +5,37 @@
|
|||||||
|
|
||||||
## 功能用途
|
## 功能用途
|
||||||
|
|
||||||
钱包页,展示 Portfolio、Profit/Loss、Positions、Open orders、History。支持 Deposit/Withdraw 弹窗、搜索、筛选(如 Close Losses)。
|
钱包页,展示 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 卡片:余额、Deposit/Withdraw 按钮
|
- Portfolio 卡片:余额、Deposit/Withdraw 按钮
|
||||||
- Profit/Loss 卡片:时间范围切换(1D/1W/1M/ALL)、Lightweight Charts 资产变化面积图;数据格式为 `[timestamp_ms, pnl][]`,**暂无接口时全部显示为 0**(真实时间轴 + 数值 0),有接口后在此处对接
|
- 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(提现记录)
|
- 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 判断
|
- **可结算/领取**:未结算项(unsettledItems)由持仓中有 `marketID`、`tokenID` 且 **所属 market.closed=true** 的项组成,用于「领取结算」按钮;不再使用 needClaim 判断
|
||||||
- Withdrawals:分页列表,状态筛选(全部/审核中/提现成功/审核不通过/提现失败),对接 GET /pmset/getPmSettlementRequestsListClient
|
- Withdrawals:对接 GET /pmset/getPmSettlementRequestsListClient,保留状态映射展示
|
||||||
- DepositDialog、WithdrawDialog 组件
|
- DepositDialog、WithdrawDialog 组件
|
||||||
- **401 权限错误**:取消订单等接口失败时,通过 `useAuthError().formatAuthError` 统一提示「请先登录」或「权限不足」
|
- **401 权限错误**:取消订单等接口失败时,通过 `useAuthError().formatAuthError` 统一提示「请先登录」或「权限不足」
|
||||||
|
|
||||||
@ -24,6 +46,6 @@
|
|||||||
|
|
||||||
## 扩展方式
|
## 扩展方式
|
||||||
|
|
||||||
1. **真实数据**:Positions、Orders、History 对接接口
|
1. **真实字段补全**:若后端补充订单价值、手续费等字段,可替换当前前端组合文案(如 `price × total`)。
|
||||||
2. **导出**:History 支持导出 CSV
|
2. **卡片交互增强**:可在不改变结构前提下添加点击展开、跳转详情、快捷撤单等行为。
|
||||||
3. **筛选**:按市场、时间、盈亏等筛选
|
3. **视觉主题扩展**:保持当前卡片信息架构,按主题变量调整色彩与密度以适配暗色模式。
|
||||||
|
|||||||
79
src/App.vue
79
src/App.vue
@ -4,36 +4,30 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import { useUserStore } from './stores/user'
|
import { useUserStore } from './stores/user'
|
||||||
import { useLocaleStore } from './stores/locale'
|
|
||||||
import Toast from './components/Toast.vue'
|
import Toast from './components/Toast.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const localeStore = useLocaleStore()
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const display = useDisplay()
|
const display = useDisplay()
|
||||||
|
|
||||||
const currentRoute = computed(() => route.path)
|
const currentRoute = computed(() => route.path)
|
||||||
const currentLocaleLabel = computed(() => {
|
|
||||||
return (
|
|
||||||
localeStore.localeOptions.find((o) => o.value === localeStore.currentLocale)?.label ??
|
|
||||||
String(localeStore.currentLocale)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const showBottomNav = computed(() => display.smAndDown.value)
|
const showBottomNav = computed(() => {
|
||||||
const mineTargetPath = computed(() => (userStore.isLoggedIn ? '/wallet' : '/login'))
|
if (!display.smAndDown.value) return false
|
||||||
|
return currentRoute.value === '/' || currentRoute.value === '/search' || currentRoute.value === '/profile'
|
||||||
|
})
|
||||||
|
const mineTargetPath = computed(() => '/profile')
|
||||||
const bottomNavValue = computed({
|
const bottomNavValue = computed({
|
||||||
get() {
|
get() {
|
||||||
const p = currentRoute.value
|
const p = currentRoute.value
|
||||||
if (p.startsWith('/wallet')) return '/wallet'
|
if (p.startsWith('/profile')) return '/profile'
|
||||||
if (p.startsWith('/login')) return '/wallet' // Mine 入口在登录页也保持高亮
|
|
||||||
if (p.startsWith('/search')) return '/search'
|
if (p.startsWith('/search')) return '/search'
|
||||||
return '/'
|
return '/'
|
||||||
},
|
},
|
||||||
set(v: string) {
|
set(v: string) {
|
||||||
if (v === '/wallet') router.push(mineTargetPath.value)
|
if (v === '/profile') router.push(mineTargetPath.value)
|
||||||
else router.push(v)
|
else router.push(v)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -74,29 +68,6 @@ watch(
|
|||||||
</v-btn>
|
</v-btn>
|
||||||
<v-app-bar-title v-if="currentRoute === '/'">TestMarket</v-app-bar-title>
|
<v-app-bar-title v-if="currentRoute === '/'">TestMarket</v-app-bar-title>
|
||||||
<v-spacer></v-spacer>
|
<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-btn
|
||||||
v-if="!userStore.isLoggedIn"
|
v-if="!userStore.isLoggedIn"
|
||||||
text
|
text
|
||||||
@ -115,24 +86,19 @@ watch(
|
|||||||
>
|
>
|
||||||
<span class="balance-text">${{ userStore.balance }}</span>
|
<span class="balance-text">${{ userStore.balance }}</span>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-menu location="bottom" :close-on-content-click="false">
|
<v-btn
|
||||||
<template #activator="{ props }">
|
icon
|
||||||
<v-btn v-bind="props" icon variant="text" class="avatar-btn">
|
variant="text"
|
||||||
|
class="avatar-btn"
|
||||||
|
:aria-label="t('common.user')"
|
||||||
|
@click="$router.push('/profile')"
|
||||||
|
>
|
||||||
<v-avatar size="36" color="primary">
|
<v-avatar size="36" color="primary">
|
||||||
<v-img v-if="userStore.avatarUrl" :src="userStore.avatarUrl" cover alt="avatar" />
|
<v-img v-if="userStore.avatarUrl" :src="userStore.avatarUrl" cover alt="avatar" />
|
||||||
<v-icon v-else>mdi-account</v-icon>
|
<v-icon v-else>mdi-account</v-icon>
|
||||||
</v-avatar>
|
</v-avatar>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</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>
|
</div>
|
||||||
</v-app-bar>
|
</v-app-bar>
|
||||||
<v-main>
|
<v-main>
|
||||||
@ -160,7 +126,7 @@ watch(
|
|||||||
<v-icon size="24">mdi-magnify</v-icon>
|
<v-icon size="24">mdi-magnify</v-icon>
|
||||||
<span>Search</span>
|
<span>Search</span>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn value="/wallet" :ripple="false">
|
<v-btn value="/profile" :ripple="false">
|
||||||
<v-icon size="24">mdi-account-outline</v-icon>
|
<v-icon size="24">mdi-account-outline</v-icon>
|
||||||
<span>Mine</span>
|
<span>Mine</span>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
@ -206,20 +172,6 @@ watch(
|
|||||||
color: rgba(0, 0, 0, 0.87);
|
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) {
|
:deep(.v-bottom-navigation) {
|
||||||
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.06);
|
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.06);
|
||||||
@ -260,6 +212,7 @@ watch(
|
|||||||
:global(html),
|
:global(html),
|
||||||
:global(body) {
|
:global(body) {
|
||||||
background: rgb(252, 252, 252);
|
background: rgb(252, 252, 252);
|
||||||
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
:global(.v-application) {
|
:global(.v-application) {
|
||||||
background: rgb(252, 252, 252);
|
background: rgb(252, 252, 252);
|
||||||
|
|||||||
@ -107,3 +107,28 @@ export async function post<T = unknown>(
|
|||||||
}
|
}
|
||||||
return res.json() as Promise<T>
|
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,4 +1,5 @@
|
|||||||
import { get } from './request'
|
import { get, put } from './request'
|
||||||
|
import type { ApiResponse } from './types'
|
||||||
|
|
||||||
const USDC_DECIMALS = 1_000_000
|
const USDC_DECIMALS = 1_000_000
|
||||||
|
|
||||||
@ -123,3 +124,22 @@ export async function getUsdcBalance(
|
|||||||
})
|
})
|
||||||
return res
|
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,7 +10,6 @@
|
|||||||
</span>
|
</span>
|
||||||
<span v-else-if="loading" class="loading-badge">{{ t('trade.orderBookConnecting') }}</span>
|
<span v-else-if="loading" class="loading-badge">{{ t('trade.orderBookConnecting') }}</span>
|
||||||
<span v-else class="vol-text">$4.4k Vol.</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -334,10 +333,6 @@ const maxOrderBookTotal = computed(() => {
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.order-book-icon {
|
|
||||||
color: #666666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-badge {
|
.live-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -183,7 +183,60 @@
|
|||||||
"withdrawChain": "Chain",
|
"withdrawChain": "Chain",
|
||||||
"withdrawTime": "Time",
|
"withdrawTime": "Time",
|
||||||
"withdrawStatus": "Status",
|
"withdrawStatus": "Status",
|
||||||
"noWithdrawalsFound": "No withdrawals found"
|
"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"
|
||||||
},
|
},
|
||||||
"deposit": {
|
"deposit": {
|
||||||
"title": "Deposit",
|
"title": "Deposit",
|
||||||
|
|||||||
@ -183,7 +183,60 @@
|
|||||||
"withdrawChain": "チェーン",
|
"withdrawChain": "チェーン",
|
||||||
"withdrawTime": "時間",
|
"withdrawTime": "時間",
|
||||||
"withdrawStatus": "状態",
|
"withdrawStatus": "状態",
|
||||||
"noWithdrawalsFound": "出金履歴がありません"
|
"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": "ユーザー名を更新しました"
|
||||||
},
|
},
|
||||||
"deposit": {
|
"deposit": {
|
||||||
"title": "入金",
|
"title": "入金",
|
||||||
|
|||||||
@ -183,7 +183,60 @@
|
|||||||
"withdrawChain": "체인",
|
"withdrawChain": "체인",
|
||||||
"withdrawTime": "시간",
|
"withdrawTime": "시간",
|
||||||
"withdrawStatus": "상태",
|
"withdrawStatus": "상태",
|
||||||
"noWithdrawalsFound": "출금 내역이 없습니다"
|
"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": "사용자 이름이 업데이트되었습니다"
|
||||||
},
|
},
|
||||||
"deposit": {
|
"deposit": {
|
||||||
"title": "입금",
|
"title": "입금",
|
||||||
|
|||||||
@ -183,7 +183,60 @@
|
|||||||
"withdrawTime": "时间",
|
"withdrawTime": "时间",
|
||||||
"withdrawStatus": "状态",
|
"withdrawStatus": "状态",
|
||||||
"noWithdrawalsFound": "暂无提现记录",
|
"noWithdrawalsFound": "暂无提现记录",
|
||||||
"withdrawAddress": "提现地址"
|
"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": "用户名已更新"
|
||||||
},
|
},
|
||||||
"deposit": {
|
"deposit": {
|
||||||
"title": "入金",
|
"title": "入金",
|
||||||
|
|||||||
@ -183,7 +183,60 @@
|
|||||||
"withdrawChain": "鏈",
|
"withdrawChain": "鏈",
|
||||||
"withdrawTime": "時間",
|
"withdrawTime": "時間",
|
||||||
"withdrawStatus": "狀態",
|
"withdrawStatus": "狀態",
|
||||||
"noWithdrawalsFound": "暫無提現記錄"
|
"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": "使用者名稱已更新"
|
||||||
},
|
},
|
||||||
"deposit": {
|
"deposit": {
|
||||||
"title": "入金",
|
"title": "入金",
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import TradeDetail from '../views/TradeDetail.vue'
|
|||||||
import EventMarkets from '../views/EventMarkets.vue'
|
import EventMarkets from '../views/EventMarkets.vue'
|
||||||
import Wallet from '../views/Wallet.vue'
|
import Wallet from '../views/Wallet.vue'
|
||||||
import Search from '../views/Search.vue'
|
import Search from '../views/Search.vue'
|
||||||
|
import Profile from '../views/Profile.vue'
|
||||||
|
import ApiKey from '../views/ApiKey.vue'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
@ -45,6 +47,16 @@ const router = createRouter({
|
|||||||
name: 'wallet',
|
name: 'wallet',
|
||||||
component: Wallet,
|
component: Wallet,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/profile',
|
||||||
|
name: 'profile',
|
||||||
|
component: Profile,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/api-key',
|
||||||
|
name: 'api-key',
|
||||||
|
component: ApiKey,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
scrollBehavior(to, from, savedPosition) {
|
scrollBehavior(to, from, savedPosition) {
|
||||||
if (savedPosition && from?.name) return savedPosition
|
if (savedPosition && from?.name) return savedPosition
|
||||||
|
|||||||
150
src/views/ApiKey.vue
Normal file
150
src/views/ApiKey.vue
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
<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>
|
||||||
699
src/views/Profile.vue
Normal file
699
src/views/Profile.vue
Normal file
@ -0,0 +1,699 @@
|
|||||||
|
<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,8 +1,421 @@
|
|||||||
<template>
|
<template>
|
||||||
<Home :initial-search-expanded="true" />
|
<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>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Home from './Home.vue'
|
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.
|
||||||
|
}
|
||||||
</script>
|
</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;
|
margin-top: 16px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
border: 1px solid #e7e7e7;
|
border: none;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1493
src/views/Wallet.vue
1493
src/views/Wallet.vue
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user