Compare commits

..

No commits in common. "f1dfbf7d8083d7e35227ccb1edbd44237f3e6f26" and "6cde1ab561cce989799e6cf29068adafb4fa7884" have entirely different histories.

26 changed files with 753 additions and 9334 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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` 实现超时取消

View File

@ -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() 刷新页面展示
}
```
## 扩展方式

View File

@ -12,7 +12,6 @@
- Asks、Bids 列表,带 `HorizontalProgressBar` 深度条(买卖两边共用同一最大值 `maxOrderBookTotal`,取两边累计总量中的最大值,便于对比深度)
- Last price、Spread 展示
- Live / 连接中 状态展示(均通过 i18n 国际化)
- Header 右侧已移除折叠箭头图标(`mdi-chevron-up`),仅保留状态文案与成交量文案
## Props

View File

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

View File

@ -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 |
## 滚动行为

View File

@ -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 脱敏展示与二次确认删除弹窗。

View File

@ -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`(由固定值改为可收缩),减少页面因内容过少而产生滚动。

View File

@ -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 视觉层级。

View File

@ -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`01转成 `[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` 获取当前市场未成交限价单,支持撤单

View File

@ -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有接口后在此处对接
- TabPositions、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. **筛选**:按市场、时间、盈亏等筛选

View File

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

View File

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

View File

@ -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,
})
}

View File

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

View File

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

View File

@ -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": "入金",

View File

@ -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": "입금",

View File

@ -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": "入金",

View File

@ -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": "入金",

View File

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

View File

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

View File

@ -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') }} &gt;</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">&gt;</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>

View File

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

View File

@ -1830,7 +1830,7 @@ onUnmounted(() => {
margin-top: 16px;
padding: 0;
background-color: #ffffff;
border: none;
border: 1px solid #e7e7e7;
box-shadow: none;
}

File diff suppressed because it is too large Load Diff