Compare commits

...

7 Commits

Author SHA1 Message Date
ivan
f1dfbf7d80 新增:文档更新 2026-03-20 17:05:49 +08:00
ivan
afdc5edcca 新增:用户名修改接口 2026-03-20 17:05:30 +08:00
ivan
0c2127c087 新增:新页面路由 2026-03-20 17:05:06 +08:00
ivan
9a8b69c15a 优化:详情页面UI优化 2026-03-20 17:04:38 +08:00
ivan
7b076aa4f2 优化:钱包页面UI优化 2026-03-20 17:04:05 +08:00
ivan
effa221907 新增:Api Key,个人中心,搜索页面 2026-03-20 17:02:36 +08:00
ivan
88b7a97e3f 新增:国际化配置 2026-03-20 17:01:26 +08:00
26 changed files with 9331 additions and 750 deletions

6493
design/pencil-new.pen Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -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. **个人入口扩展**:可在个人中心页继续扩展设置项与账户操作

View File

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

View File

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

View File

@ -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有接口后在此处对接
- TabPositions、Open orders、**History**(历史记录来自 **GET /hr/getHistoryRecordListClient**`src/api/historyRecord.ts`需鉴权、按当前用户分页、Withdrawals提现记录 - 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 判断 - **可结算/领取**未结算项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. **视觉主题扩展**:保持当前卡片信息架构,按主题变量调整色彩与密度以适配暗色模式。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff