初始化项目,架构,UI,接口

This commit is contained in:
dongzp 2026-07-21 14:47:57 +08:00
parent f620834176
commit 563708400e
54 changed files with 5548 additions and 2 deletions

8
.editorconfig Normal file
View File

@ -0,0 +1,8 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

View File

@ -1,3 +1,34 @@
# ai_trade_assistance
# AI 实时交易辅助系统
基于AI的实时期货交易辅助工具,采用Vue3开发
基于 Vue 3 的实时期货交易辅助工具(Phase 1:框架 + UI 布局,数据为 Mock)。
## 技术栈
- Vue 3 + TypeScript
- Vite 7
- Element Plus
- Pinia
- ECharts / vue-echarts
## 启动
```bash
npm install
npm run dev
```
构建:
```bash
npm run build
```
## 功能(本阶段)
- 行情:报价统计、分时/K 线、五档盘口、成交明细
- 相关新闻列表
- 机构持仓(多空前 20 + 圆环图,浅色)
- 底部 AI 建议抽屉(一键刷新,当前返回 Mock)
- 设置:DeepSeek API Key、额外关键词(本地 localStorage)
数据源与 DeepSeek 真实调用将在后续阶段接入。设计说明见 `docs/superpowers/specs/2026-07-21-ai-trade-assistant-design.md`。

View File

@ -0,0 +1,259 @@
# AI 交易辅助系统 Phase1 UI 框架 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 搭建 Vue3 + Vite7 + Element Plus 单页交易辅助 UI(浅色),含行情/新闻/持仓/AI 抽屉/设置,数据全部 mock。
**Architecture:** `AppLayout` 滚动主区 + 固定底栏 AI 抽屉;业务拆为 header/quote/news/position/ai 组件;composable 读 `src/mocks/*`,对外 `{ data, loading, error, refresh }`;设置用 Pinia + localStorage。
**Tech Stack:** Vue 3、Vite 7、Element Plus、Pinia、ECharts、vue-echarts
**Spec:** `docs/superpowers/specs/2026-07-21-ai-trade-assistant-design.md`
---
## File Structure
```
package.json, vite.config.js, index.html
src/main.js, src/App.vue
src/styles/variables.css, src/styles/global.css
src/layout/AppLayout.vue
src/components/header/AppHeader.vue, SettingsDialog.vue
src/components/quote/QuoteStats.vue, ChartPanel.vue, OrderBook.vue, TradeTape.vue
src/components/news/NewsList.vue, NewsItem.vue
src/components/position/PositionPanel.vue, PositionDonut.vue, PositionTable.vue
src/components/ai/AiAdviceDrawer.vue
src/composables/useQuote.js, useNews.js, usePositions.js, useAiAdvice.js
src/stores/settings.js
src/mocks/quote.js, news.js, positions.js, aiAdvice.js
README.md
```
---
### Task 1: Scaffold Vite Vue 项目并安装依赖
**Files:**
- Create: `package.json`, `vite.config.js`, `index.html`, `src/main.js`(由 create-vite 生成后改)
- Modify: `README.md`
- [ ] **Step 1: 在仓库根目录用 create-vite 初始化 Vue 模板**
在 PowerShell、仓库根目录(已有 README,允许非空):
```powershell
cd e:\Gitea\ai_trade_assistance
npm create vite@latest . -- --template vue
```
若提示目录非空,确认继续。期望生成 `package.json`、`vite.config.js`、`src/` 等。
- [ ] **Step 2: 安装运行时依赖**
```powershell
npm install
npm install element-plus @element-plus/icons-vue pinia echarts vue-echarts
```
确认 `package.json` 中 `vite` 主版本为 7.x(若为 6.x,执行 `npm install vite@7`)。
- [ ] **Step 3: 配置 main.js 接入 Element Plus + Pinia**
`src/main.js`:
```js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import 'element-plus/dist/index.css'
import './styles/variables.css'
import './styles/global.css'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.use(ElementPlus, { locale: zhCn })
app.mount('#app')
```
- [ ] **Step 4: 验证能启动**
```powershell
npm run dev
```
期望:终端出现本地 URL,浏览器可打开默认 Vite 页。
- [ ] **Step 5: Commit(仅当用户明确要求时执行;本仓库默认跳过)**
---
### Task 2: 全局样式与 AppLayout 壳
**Files:**
- Create: `src/styles/variables.css`, `src/styles/global.css`, `src/layout/AppLayout.vue`
- Modify: `src/App.vue`
- [ ] **Step 1: 写入主题变量(浅色、红涨绿跌)**
`src/styles/variables.css`:
```css
:root {
--bg-page: #f5f6f8;
--bg-card: #ffffff;
--border: #e6e8eb;
--text-primary: #1f2329;
--text-secondary: #8a9199;
--up: #e54545;
--down: #12b37b;
--accent: #1677ff;
--header-height: 56px;
--ai-bar-height: 48px;
}
```
`src/styles/global.css`: 重置 body 背景 `--bg-page`、字体、链接色;`.price-up { color: var(--up) }` / `.price-down { color: var(--down) }`。
- [ ] **Step 2: AppLayout — 滚动主区 + 底栏插槽**
`src/layout/AppLayout.vue`:顶部 `#header` 插槽,中间 `.main-scroll` 放默认插槽,底部固定 `.ai-footer` 放 `#ai` 插槽;主区 `padding-bottom` 预留 AI 栏高度。
- [ ] **Step 3: App.vue 挂载壳(占位组件可先用空 section)**
```vue
<template>
<AppLayout>
<template #header><AppHeader /></template>
<!-- quote / news / position sections -->
<template #ai><AiAdviceDrawer /></template>
</AppLayout>
</template>
```
---
### Task 3: Mock 数据与 Settings Store
**Files:**
- Create: `src/mocks/quote.js`, `src/mocks/news.js`, `src/mocks/positions.js`, `src/mocks/aiAdvice.js`, `src/stores/settings.js`
- Create: `src/composables/useQuote.js`, `useNews.js`, `usePositions.js`, `useAiAdvice.js`
- [ ] **Step 1: quote mock** — 合约 `玻璃2609`/`FG609`,最新价 936、涨跌 -13/-1.37%,开高低结算持仓量内外盘;`intraday` 点数数组;`orderBook` 卖5–买1;`trades` 若干条。
- [ ] **Step 2: news / positions / aiAdvice mock** — 新闻 5 条(source/time/title/summary/url);持仓多空各 20 名含 qty/change/percent;AI 建议含 action/confidence/reasons/updatedAt。
- [ ] **Step 3: settings store**
```js
// src/stores/settings.js
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
const KEY = 'ai-trade-settings'
export const useSettingsStore = defineStore('settings', () => {
const saved = JSON.parse(localStorage.getItem(KEY) || '{}')
const apiKey = ref(saved.apiKey || '')
const keywords = ref(saved.keywords || '')
watch([apiKey, keywords], () => {
localStorage.setItem(KEY, JSON.stringify({ apiKey: apiKey.value, keywords: keywords.value }))
})
return { apiKey, keywords }
})
```
- [ ] **Step 4: composables** — 各 `useX` 返回 `data`(ref mock)、`loading`、`error`、`refresh`(模拟 300ms delay 后重置 mock)。
---
### Task 4: Header + SettingsDialog
**Files:**
- Create: `src/components/header/AppHeader.vue`, `src/components/header/SettingsDialog.vue`
- [ ] **Step 1: AppHeader** — 左:名称+代码;中:现价与涨跌(红绿 class);右:设置按钮打开 dialog。
- [ ] **Step 2: SettingsDialog** — `el-dialog`;`el-input` type=password 绑定 apiKey;`el-input` type=textarea 绑定 keywords;保存即关(store 已 watch 持久化)。
---
### Task 5: 行情区(统计、图表、盘口、成交)
**Files:**
- Create: `src/components/quote/QuoteStats.vue`, `ChartPanel.vue`, `OrderBook.vue`, `TradeTape.vue`
- Modify: `src/App.vue` 组装行情两栏
- [ ] **Step 1: QuoteStats** — 多列网格展示开/高/低/昨结/持仓/量/内外盘等。
- [ ] **Step 2: ChartPanel** — `el-tabs`:分时/五日/日K/周K/月K;分时用 ECharts line+area + 成交量 bar;K 线 tab 用 candle + volume(可用简化 mock OHLC)。注册 `CanvasRenderer`、`LineChart`、`BarChart`、`CandlestickChart`。
- [ ] **Step 3: OrderBook** — 买卖力度条(红绿 flex 比例);卖5→卖1、买1→买5。
- [ ] **Step 4: TradeTape** — 时间/价/量+方向(B/S 着色)。
- [ ] **Step 5: 在 App 中布局** — 左 70% 统计+图,右 30% 盘口+成交;卡片白底圆角边框。
---
### Task 6: 新闻区
**Files:**
- Create: `src/components/news/NewsList.vue`, `NewsItem.vue`
- [ ] **Step 1: NewsItem** — 上行 source+time 与「查看原文」;标题;摘要(多行省略)。
- [ ] **Step 2: NewsList** — 标题「相关新闻」+ `v-for` 渲染列表。
---
### Task 7: 机构持仓区(浅色)
**Files:**
- Create: `src/components/position/PositionPanel.vue`, `PositionDonut.vue`, `PositionTable.vue`
- [ ] **Step 1: PositionDonut** — vue-echarts pie(roseType 或标准 doughnut),legend 右侧。
- [ ] **Step 2: PositionTable** — 名次/会员简称/数量/增减(正红负青)。
- [ ] **Step 3: PositionPanel** — `el-tabs` 总持仓/成交量/净持仓;双列「多单前20」「空单前20」;非总持仓 Tab 复用同结构 mock 并在角标提示「占位数据」。
---
### Task 8: AI 抽屉 + README 收尾
**Files:**
- Create: `src/components/ai/AiAdviceDrawer.vue`
- Modify: `README.md`, `src/App.vue`
- [ ] **Step 1: AiAdviceDrawer** — 收起:一行摘要 + 展开/刷新;展开:方向、置信度、理由列表、时间;无 apiKey 时刷新用 `ElMessage.warning` 提示打开设置,仍可展示 mock。
- [ ] **Step 2: 更新 README** — 启动命令、技术栈、Phase1 范围说明。
- [ ] **Step 3: 全量验收**
```powershell
npm run build
npm run dev
```
对照 spec §8 验收清单逐项点检。
---
## Spec coverage (self-review)
| Spec 项 | Task |
|---|---|
| 浅色单页滚动 | 2, 5–7 |
| 行情+盘口+成交 | 3, 5 |
| 新闻 | 6 |
| 持仓双列浅色 | 7 |
| AI 底栏抽屉 | 8 |
| DeepSeek Key + 关键词 | 3, 4 |
| Mock composable | 3 |
| Vite7 + Vue3 + Element Plus | 1 |
无 TBD 占位;提交步骤遵循用户「未要求不 commit」规则。

View File

@ -0,0 +1,160 @@
# AI 实时交易辅助系统 — 框架与 UI 设计
**日期:** 2026-07-21
**阶段:** Phase 1 — 框架搭建 + UI 布局(Mock 数据)
**状态:** 已口头确认,待书面复核
---
## 1. 背景与目标
个人投资者在期货等高频场景中易受情绪影响。本系统提供行情、深度、新闻、机构持仓与 AI 操作建议的统一界面,降低非理性交易。
**本阶段目标:** 用 Vue3 搭好可运行的单页布局与组件骨架,数据全部写死;真实数据源与 DeepSeek 调用后续接入。
---
## 2. 已确认决策
| 项 | 选择 |
|---|---|
| 主题 | 统一浅色 |
| AI 建议入口 | 底部可折叠抽屉(默认收起) |
| 页面结构 | 单页分区滚动:行情 → 新闻 → 持仓 |
| 架构风格 | 布局壳 + 业务模块组件 + mock 数据层 |
| 技术栈 | Vue 3、Vite 7、Element Plus、Pinia、ECharts |
---
## 3. 信息架构与布局
```
┌──────────────────────────────────────────────────────────┐
│ AppHeader:品种名 / 代码 / 现价·涨跌 / 休市状态 / 设置按钮 │
├────────────────────────────────┬─────────────────────────┤
│ QuoteSection │ MarketDepthSidebar │
│ · 报价统计网格 │ · 买卖力度条 │
│ · 分时/五日/日K/周K/月K Tab │ · 五档盘口 │
│ · 主图(分时或 K 线)+ 成交量 │ · 分时成交明细 │
├────────────────────────────────┴─────────────────────────┤
│ NewsSection:相关新闻列表(来源·时间·标题·摘要·原文链接) │
├──────────────────────────────────────────────────────────┤
│ PositionSection:总持仓 | 成交量 | 净持仓 │
│ · 左:多单前 20(圆环图 + 表格) 右:空单前 20(同构) │
├──────────────────────────────────────────────────────────┤
│ AiAdviceDrawer(固定底栏):展开后显示建议/理由/刷新 │
└──────────────────────────────────────────────────────────┘
│ SettingsDialog(弹窗):DeepSeek API Key、额外关键词 │
```
- 中文市场惯例:**红涨绿跌**。
- 持仓页按参考图做浅色改版(多单标题偏红、空单标题偏青,圆环图保留多色段)。
---
## 4. 目录结构
```
src/
main.js
App.vue
styles/
variables.css # 主题色、涨跌色
global.css
layout/
AppLayout.vue # 滚动主区 + 底栏抽屉槽位
components/
header/
AppHeader.vue
SettingsDialog.vue
quote/
QuoteStats.vue
ChartPanel.vue
OrderBook.vue
TradeTape.vue
news/
NewsList.vue
NewsItem.vue
position/
PositionPanel.vue
PositionDonut.vue
PositionTable.vue
ai/
AiAdviceDrawer.vue
composables/
useQuote.js # 先读 mock,日后换 API
useNews.js
usePositions.js
useAiAdvice.js
stores/
settings.js # apiKey、keywords(localStorage)
mocks/
quote.js
news.js
positions.js
aiAdvice.js
```
---
## 5. 模块职责
### 5.1 行情区
- 展示合约名称、最新价、涨跌额/幅、开高低、持仓量、内外盘等(mock)。
- ChartPanel:Element Plus Tabs 切换周期;ECharts 渲染分时面积图或蜡烛图 + 成交量柱。
- 右侧:力度条、卖5–买5、成交明细列表。
### 5.2 新闻区
- 列表项:来源 + 时间、标题、摘要、可选「查看原文」。
- 数据来自 `mocks/news.js`。
### 5.3 机构持仓
- Tab:总持仓 / 成交量 / 净持仓(首版仅总持仓有完整 mock,其余 Tab 显示同结构占位或同一套 mock 并标注)。
- 双列:圆环图 + 前 20 名表格(名次、会员简称、数量、增减)。
### 5.4 AI 建议抽屉
- 底栏条:摘要一行 +「展开 / 一键获取建议」。
- 展开:建议方向(买/卖/观望)、置信度占位、简要理由列表、时间戳。
- 点击刷新时:若未配置 Key,提示打开设置;有 Key 时本阶段仍返回 mock 文案(真实调用后续)。
### 5.5 设置
- DeepSeek API Key(密码输入框,存 Pinia + localStorage)。
- 额外关键词(多行或标签输入,供后续新闻/分析过滤)。
- 不提交到仓库的密钥;仅浏览器本地。
---
## 6. Mock 数据约定
- 默认品种:玻璃 2609 / FG609(与参考图一致,可改)。
- 分时点、五档、成交、新闻、持仓、AI 建议均静态 JSON/JS 模块。
- composable 对外统一返回 `{ data, loading, error, refresh }`,便于日后替换实现。
---
## 7. 非目标(本阶段不做)
- 真实行情 / WebSocket / 交易所接口
- 真实新闻、持仓数据源
- DeepSeek HTTP 调用与流式输出
- 下单、账户、登录
- 深色主题切换、移动端专项适配(桌面优先,基础可用即可)
---
## 8. 验收标准
1. `npm install && npm run dev` 可启动,无控制台致命错误。
2. 单页可滚动看到行情、新闻、持仓三块,视觉为统一浅色。
3. 分时/K 线 Tab 可切换,图表有 mock 曲线/蜡烛。
4. 五档与成交明细、新闻列表、持仓双列图表明示完整。
5. 底部 AI 抽屉可展开/收起,刷新有反馈(mock)。
6. 设置可保存 API Key 与关键词,刷新页面后仍在。
---
## 9. 后续阶段(备忘)
- Phase 2:接入行情与深度数据
- Phase 3:新闻 / 机构持仓 API
- Phase 4:DeepSeek 实调与关键词增强分析

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI 实时交易辅助系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2114
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "ai-trade-assistance",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"typecheck": "vue-tsc -b --pretty false"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.18.1",
"echarts": "^6.1.0",
"element-plus": "^2.14.3",
"pinia": "^4.0.2",
"vue": "^3.5.39",
"vue-echarts": "^8.0.1"
},
"devDependencies": {
"@types/node": "^26.1.1",
"@vitejs/plugin-vue": "^6.0.2",
"typescript": "^5.9.3",
"vite": "^7.3.6",
"vue-tsc": "^3.3.7"
}
}

1
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
public/icons.svg Normal file
View File

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

452
scripts/fix-encoding-1.mjs Normal file
View File

@ -0,0 +1,452 @@
import { writeFileSync } from 'node:fs'
import { join } from 'node:path'
const root = 'e:/Gitea/ai_trade_assistance'
function w(rel, content) {
const path = join(root, rel)
writeFileSync(path, content, 'utf8')
console.log('ok', rel)
}
// Use unicode escapes so this repair script itself stays ASCII-safe on Windows shells.
const S = {
mai: '\u4e70',
mai2: '\u5356',
shezhi: '\u8bbe\u7f6e',
quxiao: '\u53d6\u6d88',
baocun: '\u4fdd\u5b58',
eci: '\u989d\u5916\u5173\u952e\u8bcd',
eciPh: '\u7528\u4e8e\u65b0\u95fb\u8fc7\u6ee4\u4e0e\u5206\u6790\u589e\u5f3a\uff0c\u591a\u4e2a\u8bcd\u7528\u9017\u53f7\u6216\u6362\u884c\u5206\u9694',
saved: '\u8bbe\u7f6e\u5df2\u4fdd\u5b58\u5230\u672c\u5730',
zongcc: '\u603b\u6301\u4ed3',
cjl: '\u6210\u4ea4\u91cf',
jingcc: '\u51c0\u6301\u4ed3',
placeholder: '\u5f53\u524d\u4e3a\u5360\u4f4d\u6570\u636e\uff08\u7ed3\u6784\u4e0e\u603b\u6301\u4ed3\u4e00\u81f4\uff09',
long20: '\u591a\u5355\u6301\u4ed3\u524d20\u540d',
short20: '\u7a7a\u5355\u6301\u4ed3\u524d20\u540d',
duodan: '\u591a\u5355',
kongdan: '\u7a7a\u5355',
mingci: '\u540d\u6b21',
hyjc: '\u4f1a\u5458\u7b80\u79f0',
zengjian: '\u589e\u51cf',
shuliang: '\u6570\u91cf',
kaipan: '\u5f00\u76d8',
zhangdie: '\u6da8\u8dcc',
junjia: '\u5747\u4ef7',
zuigao: '\u6700\u9ad8',
jiesuan: '\u7ed3\u7b97',
cje: '\u6210\u4ea4\u989d',
yi: '\u4ebf',
ccl: '\u6301\u4ed3\u91cf',
zhenfu: '\u632f\u5e45',
waipan: '\u5916\u76d8',
zuoshou: '\u6628\u6536',
neipan: '\u5185\u76d8',
zuidi: '\u6700\u4f4e',
zuojie: '\u6628\u7ed3',
fenshi: '\u5206\u65f6',
wuri: '\u4e94\u65e5',
riK: '\u65e5K',
zhouK: '\u5468K',
yueK: '\u6708K',
junjia2: '\u5747\u4ef7',
xindu: '\u7f6e\u4fe1\u5ea6',
yijian: '\u4e00\u952e\u83b7\u53d6\u5efa\u8bae',
shouqi: '\u6536\u8d77',
zhankai: '\u5c55\u5f00',
hint: '\u57fa\u4e8e\u884c\u60c5\u3001\u76d8\u53e3\u3001\u65b0\u95fb\u4e0e\u6301\u4ed3\u7684\u7efc\u5408\u5206\u6790\uff08\u5f53\u524d\u4e3a Mock\uff09',
warn: '\u8bf7\u5148\u5728\u8bbe\u7f6e\u4e2d\u914d\u7f6e DeepSeek API Key\uff08\u672c\u9636\u6bb5\u4ecd\u8fd4\u56de Mock \u5efa\u8bae\uff09',
qita: '\u5176\u4ed6',
shuliang2: '\u6570\u91cf',
zhanbi: '\u5360\u6bd4',
}
w(
'src/components/quote/OrderBook.vue',
`<template>
<div class="order-book card">
<div class="sentiment">
<div class="buy" :style="{ flex: quote.buyRatio }">${S.mai} {{ quote.buyRatio }}%</div>
<div class="sell" :style="{ flex: quote.sellRatio }">${S.mai2} {{ quote.sellRatio }}%</div>
</div>
<div class="levels">
<div
v-for="ask in quote.orderBook.asks"
:key="'a' + ask.level"
class="level ask"
>
<span class="side">${S.mai2}{{ ask.level }}</span>
<span class="price price-down">{{ ask.price.toFixed(2) }}</span>
<span class="vol">{{ ask.volume }}</span>
</div>
<div class="divider" />
<div v-for="bid in quote.orderBook.bids" :key="'b' + bid.level" class="level bid">
<span class="side">${S.mai}{{ bid.level }}</span>
<span class="price price-up">{{ bid.price.toFixed(2) }}</span>
<span class="vol">{{ bid.volume }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { QuoteData } from '../../types'
defineProps<{
quote: QuoteData
}>()
</script>
<style scoped>
.order-book {
padding: 12px;
}
.sentiment {
display: flex;
height: 22px;
border-radius: 4px;
overflow: hidden;
font-size: 12px;
margin-bottom: 10px;
}
.buy,
.sell {
display: flex;
align-items: center;
justify-content: center;
color: #fff;
min-width: 48px;
}
.buy {
background: var(--up);
}
.sell {
background: var(--down);
}
.level {
display: grid;
grid-template-columns: 40px 1fr 48px;
font-size: 13px;
line-height: 1.9;
}
.side {
color: var(--text-secondary);
}
.price,
.vol {
font-variant-numeric: tabular-nums;
text-align: right;
}
.divider {
height: 1px;
background: var(--border);
margin: 4px 0;
}
</style>
`,
)
w(
'src/components/header/SettingsDialog.vue',
`<template>
<el-dialog
:model-value="modelValue"
title="AI ${S.shezhi}"
width="480px"
destroy-on-close
@update:model-value="emit('update:modelValue', $event)"
>
<el-form label-position="top">
<el-form-item label="DeepSeek API Key">
<el-input
v-model="localKey"
type="password"
show-password
placeholder="sk-..."
autocomplete="off"
/>
</el-form-item>
<el-form-item label="${S.eci}">
<el-input
v-model="localKeywords"
type="textarea"
:rows="3"
placeholder="${S.eciPh}"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="emit('update:modelValue', false)">${S.quxiao}</el-button>
<el-button type="primary" @click="save">${S.baocun}</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useSettingsStore } from '../../stores/settings'
const props = withDefaults(
defineProps<{
modelValue?: boolean
}>(),
{ modelValue: false },
)
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const settings = useSettingsStore()
const localKey = ref('')
const localKeywords = ref('')
watch(
() => props.modelValue,
(open) => {
if (open) {
localKey.value = settings.apiKey
localKeywords.value = settings.keywords
}
},
)
function save() {
settings.apiKey = localKey.value.trim()
settings.keywords = localKeywords.value.trim()
ElMessage.success('${S.saved}')
emit('update:modelValue', false)
}
</script>
`,
)
w(
'src/components/position/PositionPanel.vue',
`<template>
<section class="position-panel card">
<el-tabs v-model="tab">
<el-tab-pane label="${S.zongcc}" name="total" />
<el-tab-pane label="${S.cjl}" name="volume" />
<el-tab-pane label="${S.jingcc}" name="net" />
</el-tabs>
<div v-if="tab !== 'total'" class="placeholder-tip">${S.placeholder}</div>
<div class="cols">
<div class="col">
<h3 class="long-title">${S.long20}</h3>
<PositionDonut :list="data.long" />
<PositionTable :list="data.long" qty-label="${S.duodan}" />
</div>
<div class="col">
<h3 class="short-title">${S.short20}</h3>
<PositionDonut :list="data.short" />
<PositionTable :list="data.short" qty-label="${S.kongdan}" />
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { PositionsData } from '../../types'
import PositionDonut from './PositionDonut.vue'
import PositionTable from './PositionTable.vue'
defineProps<{
data: PositionsData
}>()
const tab = ref('total')
</script>
<style scoped>
.position-panel {
padding: 12px 16px 16px;
margin-top: 16px;
}
.placeholder-tip {
font-size: 12px;
color: var(--text-secondary);
margin: -4px 0 8px;
}
.cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.long-title {
margin: 0 0 8px;
color: var(--long);
font-size: 14px;
}
.short-title {
margin: 0 0 8px;
color: var(--short);
font-size: 14px;
}
@media (max-width: 960px) {
.cols {
grid-template-columns: 1fr;
}
}
</style>
`,
)
w(
'src/components/position/PositionTable.vue',
`<template>
<el-table :data="list" size="small" stripe class="pos-table" max-height="320">
<el-table-column prop="rank" label="${S.mingci}" width="56" />
<el-table-column prop="name" label="${S.hyjc}" min-width="110" />
<el-table-column :prop="String(qtyKey)" :label="qtyLabel" min-width="90" />
<el-table-column label="${S.zengjian}" min-width="80">
<template #default="{ row }">
<span :class="row.change >= 0 ? 'price-up' : 'chg-down'">
{{ row.change > 0 ? '+' : '' }}{{ row.change }}
</span>
</template>
</el-table-column>
</el-table>
</template>
<script setup lang="ts">
import type { PositionRow } from '../../types'
withDefaults(
defineProps<{
list: PositionRow[]
qtyKey?: keyof PositionRow
qtyLabel?: string
}>(),
{
qtyKey: 'qty',
qtyLabel: '${S.shuliang}',
},
)
</script>
<style scoped>
.pos-table {
width: 100%;
}
.chg-down {
color: var(--short);
}
</style>
`,
)
w(
'src/components/quote/QuoteStats.vue',
`<template>
<div class="quote-stats">
<div class="col" v-for="(col, i) in columns" :key="i">
<div class="row" v-for="item in col" :key="item.label">
<span class="label">{{ item.label }}</span>
<span class="value" :class="item.className">{{ item.value }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { QuoteData } from '../../types'
interface StatCell {
label: string
value: string
className?: string
}
const props = defineProps<{
quote: QuoteData
}>()
const columns = computed((): StatCell[][] => {
const q = props.quote
const chgClass = q.change >= 0 ? 'price-up' : 'price-down'
return [
[
{ label: '${S.kaipan}', value: q.open.toFixed(2) },
{ label: '${S.zhangdie}', value: \`\${q.change.toFixed(2)}\`, className: chgClass },
{ label: '${S.junjia}', value: q.avg.toFixed(2) },
],
[
{ label: '${S.zuigao}', value: q.high.toFixed(2), className: 'price-up' },
{ label: '${S.jiesuan}', value: q.settlement.toFixed(2) },
{ label: '${S.cje}', value: \`\${q.amount}${S.yi}\` },
],
[
{ label: '${S.ccl}', value: q.openInterest.toLocaleString() },
{ label: '${S.zhenfu}', value: \`\${q.amplitude}%\` },
{ label: '${S.waipan}', value: q.outerVol.toLocaleString(), className: 'price-up' },
],
[
{ label: '${S.zuoshou}', value: q.prevClose.toFixed(2) },
{ label: '${S.cjl}', value: q.volume.toLocaleString() },
{ label: '${S.neipan}', value: q.innerVol.toLocaleString(), className: 'price-down' },
],
[
{ label: '${S.zuidi}', value: q.low.toFixed(2), className: 'price-down' },
{ label: '${S.zuojie}', value: q.prevSettlement.toFixed(2) },
],
]
})
</script>
<style scoped>
.quote-stats {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px 16px;
padding: 12px 16px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 12px;
}
.row {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 13px;
line-height: 1.8;
}
.label {
color: var(--text-secondary);
}
.value {
font-variant-numeric: tabular-nums;
font-weight: 500;
}
@media (max-width: 900px) {
.quote-stats {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
`,
)
console.log('batch1 done')

204
scripts/fix-encoding-2.mjs Normal file
View File

@ -0,0 +1,204 @@
import { writeFileSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
const root = 'e:/Gitea/ai_trade_assistance'
function w(rel, content) {
writeFileSync(join(root, rel), content, 'utf8')
console.log('ok', rel)
}
const S = {
fenshi: '\u5206\u65f6',
wuri: '\u4e94\u65e5',
riK: '\u65e5K',
zhouK: '\u5468K',
yueK: '\u6708K',
junjia: '\u5747\u4ef7',
cjl: '\u6210\u4ea4\u91cf',
xindu: '\u7f6e\u4fe1\u5ea6',
yijian: '\u4e00\u952e\u83b7\u53d6\u5efa\u8bae',
shouqi: '\u6536\u8d77',
zhankai: '\u5c55\u5f00',
hint: '\u57fa\u4e8e\u884c\u60c5\u3001\u76d8\u53e3\u3001\u65b0\u95fb\u4e0e\u6301\u4ed3\u7684\u7efc\u5408\u5206\u6790\uff08\u5f53\u524d\u4e3a Mock\uff09',
warn: '\u8bf7\u5148\u5728\u8bbe\u7f6e\u4e2d\u914d\u7f6e DeepSeek API Key\uff08\u672c\u9636\u6bb5\u4ecd\u8fd4\u56de Mock \u5efa\u8bae\uff09',
qita: '\u5176\u4ed6',
shuliang: '\u6570\u91cf',
zengjian: '\u589e\u51cf',
zhanbi: '\u5360\u6bd4',
}
// Patch ChartPanel: replace garbled labels in existing file structure by rewriting full file from template pieces
const chartPath = join(root, 'src/components/quote/ChartPanel.vue')
let chart = readFileSync(chartPath, 'utf8')
chart = chart
.replace(/label="\?+"/g, (m, offset, str) => m) // noop marker
.replace(/<el-tab-pane label="[^"]*" name="intraday"/, `<el-tab-pane label="${S.fenshi}" name="intraday"`)
.replace(/<el-tab-pane label="[^"]*" name="five"/, `<el-tab-pane label="${S.wuri}" name="five"`)
.replace(/<el-tab-pane label="[^"]*" name="day"/, `<el-tab-pane label="${S.riK}" name="day"`)
.replace(/<el-tab-pane label="[^"]*" name="week"/, `<el-tab-pane label="${S.zhouK}" name="week"`)
.replace(/<el-tab-pane label="[^"]*" name="month"/, `<el-tab-pane label="${S.yueK}" name="month"`)
.replace(/data: \['[^\]]*'\]/, `data: ['${S.fenshi}', '${S.junjia}']`)
.replace(/name: '[^']*',\s*\n\s*type: 'line',\s*\n\s*data: prices/, `name: '${S.fenshi}',\n type: 'line',\n data: prices`)
.replace(/name: '[^']*',\s*\n\s*type: 'line',\s*\n\s*data: avgs/, `name: '${S.junjia}',\n type: 'line',\n data: avgs`)
.replace(/name: '[^']*',\s*\n\s*type: 'bar',\s*\n\s*data: vols/, `name: '${S.cjl}',\n type: 'bar',\n data: vols`)
writeFileSync(chartPath, chart, 'utf8')
console.log('ok ChartPanel.vue')
w(
'src/components/ai/AiAdviceDrawer.vue',
`<template>
<div class="ai-drawer" :class="{ open: expanded }">
<div class="bar">
<div class="summary">
<span class="badge" :class="data.direction">AI</span>
<strong>{{ data.action }}</strong>
<span class="text">{{ data.summary }}</span>
<span class="meta">${S.xindu} {{ data.confidence }}% \u00b7 {{ data.updatedAt }}</span>
</div>
<div class="btns">
<el-button size="small" :loading="loading" @click="onRefresh">${S.yijian}</el-button>
<el-button size="small" text type="primary" @click="expanded = !expanded">
{{ expanded ? '${S.shouqi}' : '${S.zhankai}' }}
</el-button>
</div>
</div>
<div v-show="expanded" class="body">
<div class="hint">${S.hint}</div>
<ul>
<li v-for="(r, i) in data.reasons" :key="i">{{ r }}</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import type { AiAdvice } from '../../types'
import { useSettingsStore } from '../../stores/settings'
withDefaults(
defineProps<{
data: AiAdvice
loading?: boolean
}>(),
{ loading: false },
)
const emit = defineEmits<{
refresh: []
}>()
const settings = useSettingsStore()
const expanded = ref(false)
function onRefresh() {
if (!settings.apiKey) {
ElMessage.warning('${S.warn}')
}
emit('refresh')
}
</script>
<style scoped>
.ai-drawer {
max-width: 1440px;
margin: 0 auto;
}
.bar {
min-height: var(--ai-bar-height);
padding: 8px 16px;
display: flex;
align-items: center;
gap: 12px;
}
.summary {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
font-size: 13px;
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
background: linear-gradient(135deg, #1677ff, #69b1ff);
color: #fff;
font-size: 11px;
font-weight: 700;
flex-shrink: 0;
}
.text {
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta {
color: var(--text-secondary);
flex-shrink: 0;
font-size: 12px;
}
.btns {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.body {
padding: 0 16px 14px 54px;
border-top: 1px dashed var(--border);
}
.hint {
font-size: 12px;
color: var(--text-secondary);
margin: 10px 0 6px;
}
ul {
margin: 0;
padding-left: 18px;
font-size: 13px;
line-height: 1.7;
color: #3d4450;
}
@media (max-width: 768px) {
.bar {
flex-direction: column;
align-items: stretch;
}
.meta {
display: none;
}
}
</style>
`,
)
// Fix PositionDonut Chinese in tooltip/legend if garbled
const donutPath = join(root, 'src/components/position/PositionDonut.vue')
let donut = readFileSync(donutPath, 'utf8')
donut = donut
.replace(/data\.push\(\{ name: '[^']*', value: otherQty \}\)/, `data.push({ name: '${S.qita}', value: otherQty })`)
.replace(
/return `\$\{param\.name\}<br\/>[^`]+`/,
`return \`\${param.name}<br/>${S.shuliang}: \${param.value}<br/>${S.zengjian}: \${sign}\${change}<br/>${S.zhanbi}: \${param.percent}%\``,
)
writeFileSync(donutPath, donut, 'utf8')
console.log('ok PositionDonut.vue')
console.log('batch2 done')

View File

@ -0,0 +1,51 @@
import { readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
function walk(d, acc = []) {
for (const e of readdirSync(d, { withFileTypes: true })) {
const p = join(d, e.name)
if (e.isDirectory()) walk(p, acc)
else if (/\.(vue|ts|md)$/.test(e.name)) acc.push(p)
}
return acc
}
const checks = [
['src/components/quote/ChartPanel.vue', ['分时', '五日', '日K', '周K', '月K', '成交量', '均价']],
['src/components/ai/AiAdviceDrawer.vue', ['置信度', '一键获取建议', '收起', '展开', 'DeepSeek']],
['src/components/position/PositionPanel.vue', ['总持仓', '成交量', '净持仓', '多单持仓前20名', '空单持仓前20名']],
['src/components/header/SettingsDialog.vue', ['设置', '额外关键词', '保存', '取消']],
['src/components/quote/OrderBook.vue', ['买', '卖']],
['src/components/quote/QuoteStats.vue', ['开盘', '涨跌', '持仓量', '外盘', '内盘']],
['src/components/position/PositionTable.vue', ['名次', '会员简称', '增减', '数量']],
['src/components/position/PositionDonut.vue', ['其他', '数量', '增减', '占比']],
['src/components/quote/TradeTape.vue', ['分时成交', '时间', '价格', '现手']],
['src/components/news/NewsList.vue', ['相关新闻']],
['src/components/news/NewsItem.vue', ['查看原文']],
['src/components/header/AppHeader.vue', ['更新', '设置']],
['src/mocks/quote.ts', ['玻璃', '郑商所', '休市']],
['src/mocks/aiAdvice.ts', ['观望']],
]
let failed = 0
for (const [f, words] of checks) {
const t = readFileSync(f, 'utf8')
const miss = words.filter((w) => !t.includes(w))
if (miss.length) {
failed++
console.log('FAIL', f, miss.join(','))
} else {
console.log('OK ', f)
}
}
// detect literal ??? garbled (3+ question marks in a row outside of ??)
for (const f of walk('src')) {
const t = readFileSync(f, 'utf8')
if (/\?{3,}/.test(t)) {
console.log('GARBLED?', f)
failed++
}
}
process.exit(failed ? 1 : 0)

66
src/App.vue Normal file
View File

@ -0,0 +1,66 @@
<template>
<AppLayout>
<template #header>
<AppHeader v-if="quote" :quote="quote" />
</template>
<section class="quote-section">
<QuoteStats v-if="quote" :quote="quote" />
<div class="quote-grid">
<ChartPanel v-if="quote" :quote="quote" />
<aside class="side">
<OrderBook v-if="quote" :quote="quote" />
<TradeTape v-if="quote" :quote="quote" />
</aside>
</div>
</section>
<NewsList v-if="news" :items="news" />
<PositionPanel v-if="positions" :data="positions" />
<template #ai>
<AiAdviceDrawer
v-if="advice"
:data="advice"
:loading="adviceLoading"
@refresh="refreshAdvice"
/>
</template>
</AppLayout>
</template>
<script setup lang="ts">
import AppLayout from './layout/AppLayout.vue'
import AppHeader from './components/header/AppHeader.vue'
import QuoteStats from './components/quote/QuoteStats.vue'
import ChartPanel from './components/quote/ChartPanel.vue'
import OrderBook from './components/quote/OrderBook.vue'
import TradeTape from './components/quote/TradeTape.vue'
import NewsList from './components/news/NewsList.vue'
import PositionPanel from './components/position/PositionPanel.vue'
import AiAdviceDrawer from './components/ai/AiAdviceDrawer.vue'
import { useQuote } from './composables/useQuote'
import { useNews } from './composables/useNews'
import { usePositions } from './composables/usePositions'
import { useAiAdvice } from './composables/useAiAdvice'
const { data: quote } = useQuote()
const { data: news } = useNews()
const { data: positions } = usePositions()
const { data: advice, loading: adviceLoading, refresh: refreshAdvice } = useAiAdvice()
</script>
<style scoped>
.quote-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
gap: 12px;
align-items: start;
}
@media (max-width: 960px) {
.quote-grid {
grid-template-columns: 1fr;
}
}
</style>

126
src/api/baidu/mapQuote.ts Normal file
View File

@ -0,0 +1,126 @@
import type { IntradayPoint, QuoteData, TradeTick } from '../../types'
import { contractConfig } from '../../config/contract'
import type { BaiduQuotationResult } from './types'
function toNum(value: string | number | undefined | null, fallback = 0): number {
if (value == null || value === '' || value === '--') return fallback
if (typeof value === 'number') return Number.isFinite(value) ? value : fallback
const cleaned = value.replace(/[+,%]/g, '').trim()
const n = Number(cleaned)
return Number.isFinite(n) ? n : fallback
}
/** 将「235.43亿」「77.72万」等转为数值;亿保持为亿元单位 */
function parseAmountYi(value: string | undefined, rawAmount?: string): number {
if (rawAmount) {
const raw = toNum(rawAmount)
if (raw > 0) return Number((raw / 1e8).toFixed(2))
}
if (!value || value === '--') return 0
if (value.includes('亿')) return toNum(value.replace('亿', ''))
if (value.includes('万')) return Number((toNum(value.replace('万', '')) / 1e4).toFixed(4))
return toNum(value)
}
function parseIntraday(result: BaiduQuotationResult): IntradayPoint[] {
const days = result.newMarketData?.marketData ?? []
const points: IntradayPoint[] = []
for (const day of days) {
if (!day.p) continue
const chunks = day.p.split(';').filter(Boolean)
for (const chunk of chunks) {
const parts = chunk.split(',')
// timestamp,time,price,avgPrice,range,ratio,volume,amount
if (parts.length < 7) continue
const timeLabel = parts[1] ?? ''
const hhmm = timeLabel.includes(' ')
? timeLabel.split(' ')[1]!.slice(0, 5)
: timeLabel.slice(0, 5)
points.push({
time: hhmm,
price: toNum(parts[2]),
avg: toNum(parts[3]),
volume: toNum(parts[6]),
})
}
}
return points
}
function parseTrades(result: BaiduQuotationResult): TradeTick[] {
return (result.detailinfos ?? []).map((t) => ({
time: t.formatTime,
price: toNum(t.price),
volume: toNum(t.volume),
side: t.bsFlag === 'B' ? 'B' : 'S',
}))
}
export function mapBaiduQuotationToQuote(
result: BaiduQuotationResult,
fallback?: QuoteData,
): QuoteData {
const op = result.pankouinfos?.origin_pankou
const cur = result.cur
const basic = result.basicinfos
const update = result.update
const outside = toNum(op?.outside)
const inside = toNum(op?.inside)
const total = outside + inside
const buyRatio = total > 0 ? Math.round((outside / total) * 100) : 50
const sellRatio = 100 - buyRatio
const asksRaw = result.askinfos ?? []
const bidsRaw = result.buyinfos ?? []
// 盘口:接口卖档为卖5→卖1,买档为买1→买5,与 UI 一致
const asks = asksRaw.map((a, i) => ({
level: asksRaw.length - i,
price: toNum(a.askprice),
volume: toNum(a.askvolume),
}))
const bids = bidsRaw.map((b, i) => ({
level: i + 1,
price: toNum(b.bidprice),
volume: toNum(b.bidvolume),
}))
const amountYi = parseAmountYi(
result.pankouinfos?.list?.find((i) => i.ename === 'amount')?.value,
op?.amount,
)
return {
name: basic?.name || contractConfig.name,
code: basic?.code || contractConfig.code,
exchange: basic?.exchange || contractConfig.exchange,
status: update?.stockStatus || '未知',
last: toNum(cur?.price ?? op?.currentPrice),
change: toNum(cur?.increase),
changePercent: toNum(cur?.ratio),
open: toNum(op?.open),
high: toNum(op?.high),
low: toNum(op?.low),
prevClose: toNum(op?.preClose),
settlement: toNum(op?.settlement),
prevSettlement: toNum(op?.prevSettlement),
avg: toNum(cur?.avgPrice ?? op?.currentPrice),
volume: toNum(op?.volume),
amount: amountYi,
openInterest: toNum(op?.holdingAmount),
amplitude: toNum(op?.amplitudeRatio),
outerVol: outside,
innerVol: inside,
updatedAt: update?.text || cur?.datetime || '',
buyRatio,
sellRatio,
intraday: parseIntraday(result),
candles: fallback?.candles ?? { day: [], week: [], month: [] },
orderBook: { asks, bids },
trades: parseTrades(result),
}
}

View File

@ -0,0 +1,54 @@
import { baiduHttp } from '../http'
import type { BaiduQuotationResponse, BaiduQuotationResult } from './types'
export interface GetStockQuotationParams {
code: string
}
/**
* 百度财经 — 期货盘口 / 分时 / 行情快照
* 仅页面打开时拉取一次;实时更新后续对接 WebSocket。
*/
export async function getStockQuotation(
params: GetStockQuotationParams,
): Promise<BaiduQuotationResult> {
const { data } = await baiduHttp.get<BaiduQuotationResponse>(
'/selfselect/getstockquotation',
{
params: {
all: 1,
isIndex: false,
isBk: false,
isBlock: false,
isStock: false,
isEtf: false,
isFutures: true,
isForeign: false,
code: params.code,
stockType: 'ab',
newFormat: 1,
market_type: 'ab',
group: 'quotation_futures_minute',
finClientType: 'pc',
},
},
)
if (data.ResultCode !== '0') {
throw new Error(`行情接口失败: ResultCode=${data.ResultCode}`)
}
const result = data.Result
if (Array.isArray(result)) {
if (!result.length) {
throw new Error('行情接口返回空 Result')
}
return result[0]
}
if (!result || typeof result !== 'object') {
throw new Error('行情接口返回空 Result')
}
return result
}

103
src/api/baidu/types.ts Normal file
View File

@ -0,0 +1,103 @@
export interface BaiduPricePoint {
time: string
price: string
ratio: string
increase: string
volume: string
avgPrice: string
amount: string
totalVolume: string
totalAmount: string
timeKey: string
datetime: string
oriAmount: string
show: string
unit?: string
}
export interface BaiduPankouItem {
ename: string
name: string
value: string
status?: string
}
export interface BaiduOriginPankou {
open: string
preClose: string
volume: string
high: string
low: string
inside: string
outside: string
amount: string
amplitudeRatio: string
holdingAmount: string
prevSettlement: string
settlement: string
amountDelta: string
currentPrice: string
[key: string]: string
}
export interface BaiduAskInfo {
askprice: string
askvolume: string
}
export interface BaiduBuyInfo {
bidprice: string
bidvolume: string
}
export interface BaiduDetailInfo {
time: string
volume: string
price: string
type: string
bsFlag: 'B' | 'S' | string
formatTime: string
}
export interface BaiduMarketDataDay {
date: string
p: string
}
export interface BaiduQuotationResult {
priceinfo: BaiduPricePoint[]
pankouinfos: {
list: BaiduPankouItem[]
origin_pankou: BaiduOriginPankou
}
basicinfos: {
exchange: string
code: string
name: string
stockStatus: string
stock_market_code: string
}
askinfos: BaiduAskInfo[]
buyinfos: BaiduBuyInfo[]
detailinfos: BaiduDetailInfo[]
update: {
text: string
time: string
stockStatus: string
}
newMarketData: {
headers: string[]
maxPoints: string
cx: string[]
keys: string[]
marketData: BaiduMarketDataDay[]
}
cur: BaiduPricePoint
provider?: string
}
export interface BaiduQuotationResponse {
QueryID: string
ResultCode: string
Result: BaiduQuotationResult | BaiduQuotationResult[]
}

7
src/api/http.ts Normal file
View File

@ -0,0 +1,7 @@
import axios from 'axios'
/** 开发环境走 Vite 代理 /baidu → https://finance.pae.baidu.com */
export const baiduHttp = axios.create({
baseURL: '/baidu',
timeout: 15000,
})

BIN
src/assets/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

1
src/assets/vite.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

1
src/assets/vue.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@ -0,0 +1,139 @@
<template>
<div class="ai-drawer" :class="{ open: expanded }">
<div class="bar">
<div class="summary">
<span class="badge" :class="data.direction">AI</span>
<strong>{{ data.action }}</strong>
<span class="text">{{ data.summary }}</span>
<span class="meta">置信度 {{ data.confidence }}% · {{ data.updatedAt }}</span>
</div>
<div class="btns">
<el-button size="small" :loading="loading" @click="onRefresh">一键获取建议</el-button>
<el-button size="small" text type="primary" @click="expanded = !expanded">
{{ expanded ? '收起' : '展开' }}
</el-button>
</div>
</div>
<div v-show="expanded" class="body">
<div class="hint">基于行情、盘口、新闻与持仓的综合分析(当前为 Mock)</div>
<ul>
<li v-for="(r, i) in data.reasons" :key="i">{{ r }}</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import type { AiAdvice } from '../../types'
import { useSettingsStore } from '../../stores/settings'
withDefaults(
defineProps<{
data: AiAdvice
loading?: boolean
}>(),
{ loading: false },
)
const emit = defineEmits<{
refresh: []
}>()
const settings = useSettingsStore()
const expanded = ref(false)
function onRefresh() {
if (!settings.apiKey) {
ElMessage.warning('请先在设置中配置 DeepSeek API Key(本阶段仍返回 Mock 建议)')
}
emit('refresh')
}
</script>
<style scoped>
.ai-drawer {
max-width: 1440px;
margin: 0 auto;
}
.bar {
min-height: var(--ai-bar-height);
padding: 8px 16px;
display: flex;
align-items: center;
gap: 12px;
}
.summary {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
font-size: 13px;
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
background: linear-gradient(135deg, #1677ff, #69b1ff);
color: #fff;
font-size: 11px;
font-weight: 700;
flex-shrink: 0;
}
.text {
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta {
color: var(--text-secondary);
flex-shrink: 0;
font-size: 12px;
}
.btns {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.body {
padding: 0 16px 14px 54px;
border-top: 1px dashed var(--border);
}
.hint {
font-size: 12px;
color: var(--text-secondary);
margin: 10px 0 6px;
}
ul {
margin: 0;
padding-left: 18px;
font-size: 13px;
line-height: 1.7;
color: #3d4450;
}
@media (max-width: 768px) {
.bar {
flex-direction: column;
align-items: stretch;
}
.meta {
display: none;
}
}
</style>

View File

@ -0,0 +1,101 @@
<template>
<div class="header-inner">
<div class="symbol">
<span class="name">{{ quote.name }}</span>
<span class="code">{{ quote.code }}</span>
<span class="exchange">{{ quote.exchange }}</span>
<el-tag size="small" type="info" effect="plain">{{ quote.status }}</el-tag>
</div>
<div class="price-block">
<span class="last" :class="priceClass">{{ quote.last.toFixed(2) }}</span>
<span class="chg" :class="priceClass">
{{ formatSigned(quote.change) }}
({{ formatSigned(quote.changePercent) }}%)
</span>
<span class="time">更新 {{ quote.updatedAt }}</span>
</div>
<div class="actions">
<el-button :icon="Setting" circle title="设置" @click="settingsVisible = true" />
</div>
<SettingsDialog v-model="settingsVisible" />
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Setting } from '@element-plus/icons-vue'
import type { QuoteData } from '../../types'
import SettingsDialog from './SettingsDialog.vue'
const props = defineProps<{
quote: QuoteData
}>()
const settingsVisible = ref(false)
const priceClass = computed(() =>
props.quote.change >= 0 ? 'price-up' : 'price-down',
)
function formatSigned(n: number) {
if (n > 0) return `+${Number(n).toFixed(2)}`
return Number(n).toFixed(2)
}
</script>
<style scoped>
.header-inner {
height: 100%;
max-width: 1440px;
margin: 0 auto;
padding: 0 16px;
display: flex;
align-items: center;
gap: 24px;
}
.symbol {
display: flex;
align-items: center;
gap: 8px;
min-width: 220px;
}
.name {
font-size: 18px;
font-weight: 700;
}
.code,
.exchange {
color: var(--text-secondary);
font-size: 13px;
}
.price-block {
display: flex;
align-items: baseline;
gap: 12px;
flex: 1;
}
.last {
font-size: 28px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.chg {
font-size: 14px;
font-weight: 600;
}
.time {
color: var(--text-secondary);
font-size: 12px;
}
.actions {
margin-left: auto;
}
</style>

View File

@ -0,0 +1,70 @@
<template>
<el-dialog
:model-value="modelValue"
title="AI 设置"
width="480px"
destroy-on-close
@update:model-value="emit('update:modelValue', $event)"
>
<el-form label-position="top">
<el-form-item label="DeepSeek API Key">
<el-input
v-model="localKey"
type="password"
show-password
placeholder="sk-..."
autocomplete="off"
/>
</el-form-item>
<el-form-item label="额外关键词">
<el-input
v-model="localKeywords"
type="textarea"
:rows="3"
placeholder="用于新闻过滤与分析增强,多个词用逗号或换行分隔"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" @click="save">保存</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useSettingsStore } from '../../stores/settings'
const props = withDefaults(
defineProps<{
modelValue?: boolean
}>(),
{ modelValue: false },
)
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const settings = useSettingsStore()
const localKey = ref('')
const localKeywords = ref('')
watch(
() => props.modelValue,
(open) => {
if (open) {
localKey.value = settings.apiKey
localKeywords.value = settings.keywords
}
},
)
function save() {
settings.apiKey = localKey.value.trim()
settings.keywords = localKeywords.value.trim()
ElMessage.success('设置已保存到本地')
emit('update:modelValue', false)
}
</script>

View File

@ -0,0 +1,55 @@
<template>
<article class="news-item">
<div class="meta">
<span>{{ item.source }} {{ item.time }}</span>
<a v-if="item.url" :href="item.url" target="_blank" rel="noopener">查看原文 &gt;</a>
</div>
<h3 class="title">{{ item.title }}</h3>
<p class="summary">{{ item.summary }}</p>
</article>
</template>
<script setup lang="ts">
import type { NewsItem } from '../../types'
defineProps<{
item: NewsItem
}>()
</script>
<style scoped>
.news-item {
padding: 14px 0;
border-bottom: 1px solid var(--border);
}
.news-item:last-child {
border-bottom: none;
}
.meta {
display: flex;
justify-content: space-between;
color: var(--text-secondary);
font-size: 12px;
margin-bottom: 6px;
}
.title {
margin: 0 0 6px;
font-size: 15px;
font-weight: 600;
line-height: 1.4;
}
.summary {
margin: 0;
color: #5c6570;
font-size: 13px;
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@ -0,0 +1,22 @@
<template>
<section class="news-list card">
<h2 class="section-title">相关新闻</h2>
<NewsItem v-for="item in items" :key="item.id" :item="item" />
</section>
</template>
<script setup lang="ts">
import type { NewsItem as NewsItemType } from '../../types'
import NewsItem from './NewsItem.vue'
defineProps<{
items: NewsItemType[]
}>()
</script>
<style scoped>
.news-list {
padding: 16px 20px;
margin-top: 16px;
}
</style>

View File

@ -0,0 +1,91 @@
<template>
<div class="donut-wrap">
<v-chart class="chart" :option="option" autoresize />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { PieChart } from 'echarts/charts'
import { TooltipComponent, LegendComponent } from 'echarts/components'
import type { EChartsOption } from 'echarts'
import VChart from 'vue-echarts'
import type { PositionRow } from '../../types'
use([CanvasRenderer, PieChart, TooltipComponent, LegendComponent])
const props = withDefaults(
defineProps<{
list: PositionRow[]
valueKey?: keyof PositionRow
}>(),
{ valueKey: 'qty' },
)
const colors = ['#7c3aed', '#f59e0b', '#eab308', '#14b8a6', '#3b82f6', '#94a3b8']
interface TooltipParam {
name?: string
value?: string | number
percent?: number
}
const option = computed((): EChartsOption => {
const key = props.valueKey
const top = props.list.slice(0, 5)
const rest = props.list.slice(5)
const otherQty = rest.reduce((s, x) => s + Number(x[key]), 0)
const data = top.map((x) => ({ name: x.name, value: Number(x[key]) }))
if (otherQty > 0) data.push({ name: '其他', value: otherQty })
return {
color: colors,
tooltip: {
trigger: 'item',
formatter: (p) => {
const param = (Array.isArray(p) ? p[0] : p) as TooltipParam
const row = props.list.find((x) => x.name === param.name)
const change = row ? row.change : 0
const sign = change > 0 ? '+' : ''
return `${param.name}<br/>数量: ${param.value}<br/>增减: ${sign}${change}<br/>占比: ${param.percent}%`
},
},
legend: {
orient: 'vertical',
right: 0,
top: 'middle',
textStyle: { fontSize: 11 },
formatter: (name: string) => {
const item = data.find((d) => d.name === name)
if (!item) return name
const total = data.reduce((s, d) => s + d.value, 0)
const pct = ((item.value / total) * 100).toFixed(2)
return `${name} ${pct}%`
},
},
series: [
{
type: 'pie',
radius: ['48%', '72%'],
center: ['32%', '50%'],
avoidLabelOverlap: true,
label: { show: false },
data,
},
],
}
})
</script>
<style scoped>
.donut-wrap {
height: 220px;
}
.chart {
height: 100%;
width: 100%;
}
</style>

View File

@ -0,0 +1,72 @@
<template>
<section class="position-panel card">
<el-tabs v-model="tab">
<el-tab-pane label="总持仓" name="total" />
<el-tab-pane label="成交量" name="volume" />
<el-tab-pane label="净持仓" name="net" />
</el-tabs>
<div v-if="tab !== 'total'" class="placeholder-tip">当前为占位数据(结构与总持仓一致)</div>
<div class="cols">
<div class="col">
<h3 class="long-title">多单持仓前20名</h3>
<PositionDonut :list="data.long" />
<PositionTable :list="data.long" qty-label="多单" />
</div>
<div class="col">
<h3 class="short-title">空单持仓前20名</h3>
<PositionDonut :list="data.short" />
<PositionTable :list="data.short" qty-label="空单" />
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { PositionsData } from '../../types'
import PositionDonut from './PositionDonut.vue'
import PositionTable from './PositionTable.vue'
defineProps<{
data: PositionsData
}>()
const tab = ref('total')
</script>
<style scoped>
.position-panel {
padding: 12px 16px 16px;
margin-top: 16px;
}
.placeholder-tip {
font-size: 12px;
color: var(--text-secondary);
margin: -4px 0 8px;
}
.cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.long-title {
margin: 0 0 8px;
color: var(--long);
font-size: 14px;
}
.short-title {
margin: 0 0 8px;
color: var(--short);
font-size: 14px;
}
@media (max-width: 960px) {
.cols {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -0,0 +1,40 @@
<template>
<el-table :data="list" size="small" stripe class="pos-table" max-height="320">
<el-table-column prop="rank" label="名次" width="56" />
<el-table-column prop="name" label="会员简称" min-width="110" />
<el-table-column :prop="String(qtyKey)" :label="qtyLabel" min-width="90" />
<el-table-column label="增减" min-width="80">
<template #default="{ row }">
<span :class="row.change >= 0 ? 'price-up' : 'chg-down'">
{{ row.change > 0 ? '+' : '' }}{{ row.change }}
</span>
</template>
</el-table-column>
</el-table>
</template>
<script setup lang="ts">
import type { PositionRow } from '../../types'
withDefaults(
defineProps<{
list: PositionRow[]
qtyKey?: keyof PositionRow
qtyLabel?: string
}>(),
{
qtyKey: 'qty',
qtyLabel: '数量',
},
)
</script>
<style scoped>
.pos-table {
width: 100%;
}
.chg-down {
color: var(--short);
}
</style>

View File

@ -0,0 +1,250 @@
<template>
<div class="chart-panel card">
<el-tabs v-model="period" class="tabs">
<el-tab-pane label="分时" name="intraday" />
<el-tab-pane label="五日" name="five" />
<el-tab-pane label="日K" name="day" />
<el-tab-pane label="周K" name="week" />
<el-tab-pane label="月K" name="month" />
</el-tabs>
<v-chart class="chart" :option="option" autoresize />
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart, BarChart, CandlestickChart } from 'echarts/charts'
import {
GridComponent,
TooltipComponent,
DataZoomComponent,
LegendComponent,
} from 'echarts/components'
import type { EChartsOption } from 'echarts'
import VChart from 'vue-echarts'
import type { Candle, QuoteData } from '../../types'
use([
CanvasRenderer,
LineChart,
BarChart,
CandlestickChart,
GridComponent,
TooltipComponent,
DataZoomComponent,
LegendComponent,
])
type ChartPeriod = 'intraday' | 'five' | 'day' | 'week' | 'month'
interface ColorParam {
dataIndex?: number
}
const props = defineProps<{
quote: QuoteData
}>()
const period = ref<ChartPeriod>('intraday')
const option = computed((): EChartsOption => {
if (period.value === 'intraday' || period.value === 'five') {
return buildIntradayOption(props.quote, period.value === 'five')
}
const key = period.value === 'day' ? 'day' : period.value === 'week' ? 'week' : 'month'
return buildCandleOption(props.quote.candles[key])
})
function buildIntradayOption(quote: QuoteData, fiveDay: boolean): EChartsOption {
const points = fiveDay
? [...quote.intraday, ...quote.intraday.map((p) => ({ ...p, price: p.price + 1.5 }))]
: quote.intraday
const times = points.map((p) => p.time)
const prices = points.map((p) => p.price)
const avgs = points.map((p) => p.avg)
const vols = points.map((p) => p.volume)
const base = quote.prevSettlement
return {
animation: false,
legend: { data: ['分时', '均价'], top: 0, textStyle: { fontSize: 11 } },
tooltip: { trigger: 'axis' },
axisPointer: { link: [{ xAxisIndex: 'all' }] },
grid: [
{ left: 48, right: 48, top: 28, height: '58%' },
{ left: 48, right: 48, top: '78%', height: '14%' },
],
xAxis: [
{
type: 'category',
data: times,
boundaryGap: false,
axisLabel: { fontSize: 10 },
gridIndex: 0,
},
{
type: 'category',
data: times,
boundaryGap: false,
axisLabel: { show: false },
gridIndex: 1,
},
],
yAxis: [
{
type: 'value',
scale: true,
axisLabel: { fontSize: 10 },
splitLine: { lineStyle: { type: 'dashed', color: '#eee' } },
gridIndex: 0,
},
{
type: 'value',
scale: true,
position: 'right',
axisLabel: {
fontSize: 10,
formatter: (v: number) => `${(((v - base) / base) * 100).toFixed(2)}%`,
},
splitLine: { show: false },
gridIndex: 0,
},
{
type: 'value',
axisLabel: { show: false },
splitLine: { show: false },
gridIndex: 1,
},
],
dataZoom: [{ type: 'inside', xAxisIndex: [0, 1] }],
series: [
{
name: '分时',
type: 'line',
data: prices,
showSymbol: false,
lineStyle: { width: 1.5, color: '#1677ff' },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(22,119,255,0.25)' },
{ offset: 1, color: 'rgba(22,119,255,0.02)' },
],
},
},
xAxisIndex: 0,
yAxisIndex: 0,
},
{
name: '均价',
type: 'line',
data: avgs,
showSymbol: false,
lineStyle: { width: 1, color: '#fa8c16' },
xAxisIndex: 0,
yAxisIndex: 0,
},
{
name: '成交量',
type: 'bar',
data: vols,
itemStyle: {
color: (p: ColorParam) => {
const idx = p.dataIndex ?? 0
return prices[idx] >= (prices[idx - 1] ?? prices[idx]) ? '#e54545' : '#12b37b'
},
},
xAxisIndex: 1,
yAxisIndex: 2,
},
],
}
}
function buildCandleOption(candles: Candle[]): EChartsOption {
const dates = candles.map((c) => c.date)
const ohlc = candles.map((c) => [c.open, c.close, c.low, c.high])
const vols = candles.map((c) => c.volume)
return {
animation: false,
tooltip: { trigger: 'axis' },
grid: [
{ left: 48, right: 16, top: 16, height: '58%' },
{ left: 48, right: 16, top: '78%', height: '14%' },
],
xAxis: [
{ type: 'category', data: dates, axisLabel: { fontSize: 10 }, gridIndex: 0 },
{
type: 'category',
data: dates,
axisLabel: { show: false },
gridIndex: 1,
},
],
yAxis: [
{
scale: true,
axisLabel: { fontSize: 10 },
splitLine: { lineStyle: { type: 'dashed', color: '#eee' } },
gridIndex: 0,
},
{
scale: true,
axisLabel: { show: false },
splitLine: { show: false },
gridIndex: 1,
},
],
dataZoom: [{ type: 'inside', xAxisIndex: [0, 1] }],
series: [
{
type: 'candlestick',
data: ohlc,
itemStyle: {
color: '#e54545',
color0: '#12b37b',
borderColor: '#e54545',
borderColor0: '#12b37b',
},
xAxisIndex: 0,
yAxisIndex: 0,
},
{
type: 'bar',
data: vols,
itemStyle: {
color: (p: ColorParam) => {
const d = ohlc[p.dataIndex ?? 0]
return d[1] >= d[0] ? '#e54545' : '#12b37b'
},
},
xAxisIndex: 1,
yAxisIndex: 1,
},
],
}
}
</script>
<style scoped>
.chart-panel {
padding: 8px 12px 12px;
}
.tabs :deep(.el-tabs__header) {
margin-bottom: 4px;
}
.chart {
height: 420px;
width: 100%;
}
</style>

View File

@ -0,0 +1,88 @@
<template>
<div class="order-book card">
<div class="sentiment">
<div class="buy" :style="{ flex: quote.buyRatio }">买 {{ quote.buyRatio }}%</div>
<div class="sell" :style="{ flex: quote.sellRatio }">卖 {{ quote.sellRatio }}%</div>
</div>
<div class="levels">
<div
v-for="ask in quote.orderBook.asks"
:key="'a' + ask.level"
class="level ask"
>
<span class="side">卖{{ ask.level }}</span>
<span class="price price-down">{{ ask.price.toFixed(2) }}</span>
<span class="vol">{{ ask.volume }}</span>
</div>
<div class="divider" />
<div v-for="bid in quote.orderBook.bids" :key="'b' + bid.level" class="level bid">
<span class="side">买{{ bid.level }}</span>
<span class="price price-up">{{ bid.price.toFixed(2) }}</span>
<span class="vol">{{ bid.volume }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { QuoteData } from '../../types'
defineProps<{
quote: QuoteData
}>()
</script>
<style scoped>
.order-book {
padding: 12px;
}
.sentiment {
display: flex;
height: 22px;
border-radius: 4px;
overflow: hidden;
font-size: 12px;
margin-bottom: 10px;
}
.buy,
.sell {
display: flex;
align-items: center;
justify-content: center;
color: #fff;
min-width: 48px;
}
.buy {
background: var(--up);
}
.sell {
background: var(--down);
}
.level {
display: grid;
grid-template-columns: 40px 1fr 48px;
font-size: 13px;
line-height: 1.9;
}
.side {
color: var(--text-secondary);
}
.price,
.vol {
font-variant-numeric: tabular-nums;
text-align: right;
}
.divider {
height: 1px;
background: var(--border);
margin: 4px 0;
}
</style>

View File

@ -0,0 +1,92 @@
<template>
<div class="quote-stats">
<div class="col" v-for="(col, i) in columns" :key="i">
<div class="row" v-for="item in col" :key="item.label">
<span class="label">{{ item.label }}</span>
<span class="value" :class="item.className">{{ item.value }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { QuoteData } from '../../types'
interface StatCell {
label: string
value: string
className?: string
}
const props = defineProps<{
quote: QuoteData
}>()
const columns = computed((): StatCell[][] => {
const q = props.quote
const chgClass = q.change >= 0 ? 'price-up' : 'price-down'
return [
[
{ label: '开盘', value: q.open.toFixed(2) },
{ label: '涨跌', value: `${q.change.toFixed(2)}`, className: chgClass },
{ label: '均价', value: q.avg.toFixed(2) },
],
[
{ label: '最高', value: q.high.toFixed(2), className: 'price-up' },
{ label: '结算', value: q.settlement.toFixed(2) },
{ label: '成交额', value: `${q.amount}亿` },
],
[
{ label: '持仓量', value: q.openInterest.toLocaleString() },
{ label: '振幅', value: `${q.amplitude}%` },
{ label: '外盘', value: q.outerVol.toLocaleString(), className: 'price-up' },
],
[
{ label: '昨收', value: q.prevClose.toFixed(2) },
{ label: '成交量', value: q.volume.toLocaleString() },
{ label: '内盘', value: q.innerVol.toLocaleString(), className: 'price-down' },
],
[
{ label: '最低', value: q.low.toFixed(2), className: 'price-down' },
{ label: '昨结', value: q.prevSettlement.toFixed(2) },
],
]
})
</script>
<style scoped>
.quote-stats {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px 16px;
padding: 12px 16px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 12px;
}
.row {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 13px;
line-height: 1.8;
}
.label {
color: var(--text-secondary);
}
.value {
font-variant-numeric: tabular-nums;
font-weight: 500;
}
@media (max-width: 900px) {
.quote-stats {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

View File

@ -0,0 +1,64 @@
<template>
<div class="trade-tape card">
<div class="title">分时成交</div>
<div class="head">
<span>时间</span>
<span>价格</span>
<span>现手</span>
</div>
<div class="list">
<div v-for="(t, i) in quote.trades" :key="i" class="row">
<span>{{ t.time }}</span>
<span :class="t.side === 'B' ? 'price-up' : 'price-down'">{{ t.price.toFixed(2) }}</span>
<span :class="t.side === 'B' ? 'price-up' : 'price-down'">{{ t.volume }}{{ t.side }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { QuoteData } from '../../types'
defineProps<{
quote: QuoteData
}>()
</script>
<style scoped>
.trade-tape {
padding: 12px;
margin-top: 12px;
max-height: 280px;
display: flex;
flex-direction: column;
}
.title {
font-size: 13px;
font-weight: 600;
margin-bottom: 8px;
}
.head,
.row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
font-size: 12px;
line-height: 1.8;
}
.head {
color: var(--text-secondary);
border-bottom: 1px solid var(--border);
margin-bottom: 4px;
}
.list {
overflow: auto;
flex: 1;
}
.row span {
font-variant-numeric: tabular-nums;
}
</style>

View File

@ -0,0 +1,31 @@
import { ref } from 'vue'
import type { AiAdvice } from '../types'
import { aiAdviceMock } from '../mocks/aiAdvice'
function delay(ms = 400): Promise<void> {
return new Promise((r) => setTimeout(r, ms))
}
export function useAiAdvice() {
const data = ref<AiAdvice>(structuredClone(aiAdviceMock))
const loading = ref(false)
const error = ref<unknown>(null)
async function refresh() {
loading.value = true
error.value = null
try {
await delay()
data.value = {
...structuredClone(aiAdviceMock),
updatedAt: new Date().toLocaleString('zh-CN', { hour12: false }),
}
} catch (e) {
error.value = e
} finally {
loading.value = false
}
}
return { data, loading, error, refresh }
}

View File

@ -0,0 +1,28 @@
import { ref } from 'vue'
import type { NewsItem } from '../types'
import { newsMock } from '../mocks/news'
function delay(ms = 300): Promise<void> {
return new Promise((r) => setTimeout(r, ms))
}
export function useNews() {
const data = ref<NewsItem[]>(structuredClone(newsMock))
const loading = ref(false)
const error = ref<unknown>(null)
async function refresh() {
loading.value = true
error.value = null
try {
await delay()
data.value = structuredClone(newsMock)
} catch (e) {
error.value = e
} finally {
loading.value = false
}
}
return { data, loading, error, refresh }
}

View File

@ -0,0 +1,28 @@
import { ref } from 'vue'
import type { PositionsData } from '../types'
import { positionsMock } from '../mocks/positions'
function delay(ms = 300): Promise<void> {
return new Promise((r) => setTimeout(r, ms))
}
export function usePositions() {
const data = ref<PositionsData>(structuredClone(positionsMock))
const loading = ref(false)
const error = ref<unknown>(null)
async function refresh() {
loading.value = true
error.value = null
try {
await delay()
data.value = structuredClone(positionsMock)
} catch (e) {
error.value = e
} finally {
loading.value = false
}
}
return { data, loading, error, refresh }
}

View File

@ -0,0 +1,33 @@
import { onMounted, ref } from 'vue'
import type { QuoteData } from '../types'
import { quoteMock } from '../mocks/quote'
import { contractConfig } from '../config/contract'
import { getStockQuotation } from '../api/baidu/quotation'
import { mapBaiduQuotationToQuote } from '../api/baidu/mapQuote'
export function useQuote() {
const data = ref<QuoteData>(structuredClone(quoteMock))
const loading = ref(false)
const error = ref<unknown>(null)
async function refresh() {
loading.value = true
error.value = null
try {
const result = await getStockQuotation({ code: contractConfig.code })
data.value = mapBaiduQuotationToQuote(result, data.value)
} catch (e) {
error.value = e
console.error('[useQuote] 拉取行情失败', e)
} finally {
loading.value = false
}
}
// 页面打开时请求一次;实时数据后续对接 WebSocket
onMounted(() => {
void refresh()
})
return { data, loading, error, refresh }
}

9
src/config/contract.ts Normal file
View File

@ -0,0 +1,9 @@
/** 当前关注的期货合约(后续可改为可切换) */
export const contractConfig = {
/** 合约代码,对应百度行情接口 code 参数 */
code: 'FG609',
/** 展示名称;接口未返回 name 时使用 */
name: '玻璃2609',
/** 交易所展示名 */
exchange: '郑商所',
} as const

7
src/env.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}

49
src/layout/AppLayout.vue Normal file
View File

@ -0,0 +1,49 @@
<template>
<div class="app-layout">
<header class="app-header">
<slot name="header" />
</header>
<main class="main-scroll">
<slot />
</main>
<footer class="ai-footer">
<slot name="ai" />
</footer>
</div>
</template>
<style scoped>
.app-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.app-header {
position: sticky;
top: 0;
z-index: 20;
height: var(--header-height);
background: var(--bg-card);
border-bottom: 1px solid var(--border);
}
.main-scroll {
flex: 1;
padding: 16px 16px calc(var(--ai-bar-height) + 24px);
max-width: 1440px;
width: 100%;
margin: 0 auto;
}
.ai-footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 30;
background: var(--bg-card);
border-top: 1px solid var(--border);
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.06);
}
</style>

13
src/main.ts Normal file
View File

@ -0,0 +1,13 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import 'element-plus/dist/index.css'
import './styles/variables.css'
import './styles/global.css'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.use(ElementPlus, { locale: zhCn })
app.mount('#app')

15
src/mocks/aiAdvice.ts Normal file
View File

@ -0,0 +1,15 @@
import type { AiAdvice } from '../types'
export const aiAdviceMock: AiAdvice = {
action: '观望',
direction: 'neutral',
confidence: 62,
summary: '短线偏弱但未破关键支撑,建议观望等待方向确认。',
reasons: [
'分时均线上方承压,反弹动能不足',
'五档卖压略大于买盘,内外盘接近均衡',
'机构多空头部持仓变动分化,缺乏单边合力',
'相关新闻偏中性,未见明确驱动',
],
updatedAt: '2026-07-21 11:30:12',
}

49
src/mocks/news.ts Normal file
View File

@ -0,0 +1,49 @@
import type { NewsItem } from '../types'
export const newsMock: NewsItem[] = [
{
id: 1,
source: 'LSEG',
time: '07-16 04:07',
title: 'Precipitate Gold appoints Pelayo Troncoso and John Wenger to the Board',
summary:
'Precipitate Gold Corp announced the appointment of Pelayo Troncoso and John Wenger as independent directors, strengthening governance ahead of exploration milestones.',
url: 'https://example.com/news/1',
},
{
id: 2,
source: '路透',
time: '07-16 03:42',
title: '原油短线承压,美油回落关注库存数据',
summary:
'隔夜油价震荡回落,市场等待本周库存与炼厂开工率数据,短线交易者对风险偏好趋于谨慎。',
url: 'https://example.com/news/2',
},
{
id: 3,
source: '财联社',
time: '07-15 21:18',
title: '玻璃期货日内走弱,期现基差小幅收敛',
summary:
'盘面跟随黑色系情绪回落,现货报价相对坚挺,基差有所收窄;机构提示关注沙河地区库存变化。',
url: 'https://example.com/news/3',
},
{
id: 4,
source: 'Bloomberg',
time: '07-15 18:05',
title: 'Gold holds near highs as markets await PPI print',
summary:
'Bullion stayed firm as traders positioned for U.S. producer price data, with real yields and dollar moves still the key drivers.',
url: 'https://example.com/news/4',
},
{
id: 5,
source: '新华财经',
time: '07-15 15:30',
title: '碳酸锂价格波动加剧,产业链观望情绪升温',
summary:
'下游备货节奏放缓,部分贸易商报价分歧加大,锂盐市场短期或以区间震荡为主。',
url: 'https://example.com/news/5',
},
]

75
src/mocks/positions.ts Normal file
View File

@ -0,0 +1,75 @@
import type { PositionRow, PositionsData } from '../types'
function makeList(names: string[], base: number): PositionRow[] {
return names.map((name, i) => {
const qty = Math.floor(base * (0.22 - i * 0.012) + Math.random() * 2000)
const change = Math.floor((Math.random() - 0.45) * 800)
return {
rank: i + 1,
name,
qty,
change,
percent: 0,
}
})
}
function withPercent(list: PositionRow[]): PositionRow[] {
const total = list.reduce((s, x) => s + x.qty, 0)
return list.map((x) => ({
...x,
percent: Number(((x.qty / total) * 100).toFixed(2)),
}))
}
const longNames = [
'中财期货',
'东证期货',
'恒力期货',
'物产中大期货',
'中信期货',
'国泰君安期货',
'海通期货',
'永安期货',
'银河期货',
'华泰期货',
'南华期货',
'光大期货',
'方正中期',
'申银万国',
'中粮期货',
'浙商期货',
'广发期货',
'兴证期货',
'瑞达期货',
'其他',
]
const shortNames = [
'国泰君安期货',
'中信期货',
'永安期货',
'海通期货',
'华泰期货',
'银河期货',
'中财期货',
'东证期货',
'南华期货',
'光大期货',
'恒力期货',
'物产中大期货',
'方正中期',
'申银万国',
'中粮期货',
'浙商期货',
'广发期货',
'兴证期货',
'瑞达期货',
'其他',
]
export const positionsMock: PositionsData = {
long: withPercent(makeList(longNames, 650000)),
short: withPercent(makeList(shortNames, 640000)),
updatedAt: '2026-07-15',
}

112
src/mocks/quote.ts Normal file
View File

@ -0,0 +1,112 @@
import type { Candle, IntradayPoint, QuoteData } from '../types'
function buildIntraday(): IntradayPoint[] {
const points: IntradayPoint[] = []
let price = 948
const slots = [
{ start: '21:00', count: 30 },
{ start: '09:00', count: 40 },
{ start: '10:30', count: 20 },
{ start: '13:30', count: 35 },
]
for (const slot of slots) {
const [h, m] = slot.start.split(':').map(Number)
for (let i = 0; i < slot.count; i++) {
const mm = m + i
const hh = h + Math.floor(mm / 60)
const min = mm % 60
const t = `${String(hh).padStart(2, '0')}:${String(min).padStart(2, '0')}`
price += (Math.random() - 0.55) * 1.2
points.push({
time: t,
price: Number(price.toFixed(2)),
avg: Number((price + 0.4).toFixed(2)),
volume: Math.floor(Math.random() * 800 + 50),
})
}
}
return points
}
function buildCandles(count = 40): Candle[] {
const candles: Candle[] = []
let close = 950
for (let i = 0; i < count; i++) {
const open = close
const change = (Math.random() - 0.5) * 12
close = Number((open + change).toFixed(2))
const high = Number((Math.max(open, close) + Math.random() * 4).toFixed(2))
const low = Number((Math.min(open, close) - Math.random() * 4).toFixed(2))
candles.push({
date: `05-${String((i % 28) + 1).padStart(2, '0')}`,
open,
close,
low,
high,
volume: Math.floor(Math.random() * 20000 + 3000),
})
}
return candles
}
export const quoteMock: QuoteData = {
name: '玻璃2609',
code: 'FG609',
exchange: '郑商所',
status: '休市',
last: 936.0,
change: -13.0,
changePercent: -1.37,
open: 948.0,
high: 949.0,
low: 934.0,
prevClose: 949.0,
settlement: 949.0,
prevSettlement: 949.0,
avg: 940.0,
volume: 825643,
amount: 77.72,
openInterest: 673892,
amplitude: 1.58,
outerVol: 412331,
innerVol: 413312,
updatedAt: '11:30:00',
buyRatio: 48,
sellRatio: 52,
intraday: buildIntraday(),
candles: {
day: buildCandles(60),
week: buildCandles(40),
month: buildCandles(24),
},
orderBook: {
asks: [
{ level: 5, price: 938.0, volume: 312 },
{ level: 4, price: 937.0, volume: 198 },
{ level: 3, price: 936.0, volume: 456 },
{ level: 2, price: 935.0, volume: 221 },
{ level: 1, price: 934.0, volume: 178 },
],
bids: [
{ level: 1, price: 933.0, volume: 265 },
{ level: 2, price: 932.0, volume: 340 },
{ level: 3, price: 931.0, volume: 189 },
{ level: 4, price: 930.0, volume: 412 },
{ level: 5, price: 929.0, volume: 297 },
],
},
trades: [
{ time: '11:30', price: 936.0, volume: 1, side: 'B' },
{ time: '11:30', price: 936.0, volume: 23, side: 'B' },
{ time: '11:29', price: 935.0, volume: 2, side: 'S' },
{ time: '11:29', price: 935.0, volume: 8, side: 'B' },
{ time: '11:28', price: 936.0, volume: 15, side: 'S' },
{ time: '11:28', price: 936.0, volume: 4, side: 'B' },
{ time: '11:27', price: 937.0, volume: 11, side: 'S' },
{ time: '11:27', price: 936.0, volume: 6, side: 'B' },
{ time: '11:26', price: 935.0, volume: 19, side: 'S' },
{ time: '11:26', price: 934.0, volume: 3, side: 'B' },
{ time: '11:25', price: 935.0, volume: 27, side: 'S' },
{ time: '11:25', price: 936.0, volume: 9, side: 'B' },
],
}

29
src/stores/settings.ts Normal file
View File

@ -0,0 +1,29 @@
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import type { AppSettings } from '../types'
const STORAGE_KEY = 'ai-trade-settings'
function load(): Partial<AppSettings> {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') as Partial<AppSettings>
} catch {
return {}
}
}
export const useSettingsStore = defineStore('settings', () => {
const saved = load()
const apiKey = ref(saved.apiKey || '')
const keywords = ref(saved.keywords || '')
watch([apiKey, keywords], () => {
const payload: AppSettings = {
apiKey: apiKey.value,
keywords: keywords.value,
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload))
})
return { apiKey, keywords }
})

49
src/styles/global.css Normal file
View File

@ -0,0 +1,49 @@
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body,
#app {
margin: 0;
min-height: 100%;
}
body {
font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: var(--bg-page);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.price-up {
color: var(--up);
}
.price-down {
color: var(--down);
}
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.section-title {
margin: 0 0 12px;
font-size: 16px;
font-weight: 600;
}

16
src/styles/variables.css Normal file
View File

@ -0,0 +1,16 @@
:root {
--bg-page: #f5f6f8;
--bg-card: #ffffff;
--border: #e6e8eb;
--text-primary: #1f2329;
--text-secondary: #8a9199;
--up: #e54545;
--down: #12b37b;
--accent: #1677ff;
--long: #e54545;
--short: #0ea5a0;
--header-height: 56px;
--ai-bar-height: 48px;
--radius: 8px;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}

104
src/types/index.ts Normal file
View File

@ -0,0 +1,104 @@
export interface IntradayPoint {
time: string
price: number
avg: number
volume: number
}
export interface Candle {
date: string
open: number
close: number
low: number
high: number
volume: number
}
export interface OrderLevel {
level: number
price: number
volume: number
}
export interface TradeTick {
time: string
price: number
volume: number
side: 'B' | 'S'
}
export interface QuoteData {
name: string
code: string
exchange: string
status: string
last: number
change: number
changePercent: number
open: number
high: number
low: number
prevClose: number
settlement: number
prevSettlement: number
avg: number
volume: number
amount: number
openInterest: number
amplitude: number
outerVol: number
innerVol: number
updatedAt: string
buyRatio: number
sellRatio: number
intraday: IntradayPoint[]
candles: {
day: Candle[]
week: Candle[]
month: Candle[]
}
orderBook: {
asks: OrderLevel[]
bids: OrderLevel[]
}
trades: TradeTick[]
}
export interface NewsItem {
id: number
source: string
time: string
title: string
summary: string
url?: string
}
export interface PositionRow {
rank: number
name: string
qty: number
change: number
percent: number
}
export interface PositionsData {
long: PositionRow[]
short: PositionRow[]
updatedAt: string
}
export type AdviceDirection = 'long' | 'short' | 'neutral'
export interface AiAdvice {
action: string
direction: AdviceDirection
confidence: number
summary: string
reasons: string[]
updatedAt: string
}
export interface AppSettings {
apiKey: string
keywords: string
}

26
tsconfig.app.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

7
tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

21
tsconfig.node.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

31
vite.config.ts Normal file
View File

@ -0,0 +1,31 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
proxy: {
'/baidu': {
target: 'https://finance.pae.baidu.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/baidu/, ''),
headers: {
Referer: 'https://gushitong.baidu.com/',
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
},
},
'/jqka': {
target: 'https://fupage.10jqka.com.cn',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/jqka/, ''),
},
},
},
})