初始化项目,架构,UI,接口
This commit is contained in:
parent
f620834176
commit
563708400e
8
.editorconfig
Normal file
8
.editorconfig
Normal 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
24
.gitignore
vendored
Normal 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
3
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
35
README.md
35
README.md
@ -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`。
|
||||||
|
|||||||
259
docs/superpowers/plans/2026-07-21-ai-trade-assistant-ui.md
Normal file
259
docs/superpowers/plans/2026-07-21-ai-trade-assistant-ui.md
Normal 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」规则。
|
||||||
160
docs/superpowers/specs/2026-07-21-ai-trade-assistant-design.md
Normal file
160
docs/superpowers/specs/2026-07-21-ai-trade-assistant-design.md
Normal 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
13
index.html
Normal 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
2114
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal 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
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
24
public/icons.svg
Normal 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
452
scripts/fix-encoding-1.mjs
Normal 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
204
scripts/fix-encoding-2.mjs
Normal 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')
|
||||||
51
scripts/verify-chinese.mjs
Normal file
51
scripts/verify-chinese.mjs
Normal 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
66
src/App.vue
Normal 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
126
src/api/baidu/mapQuote.ts
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/api/baidu/quotation.ts
Normal file
54
src/api/baidu/quotation.ts
Normal 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
103
src/api/baidu/types.ts
Normal 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
7
src/api/http.ts
Normal 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
BIN
src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
src/assets/vite.svg
Normal file
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
1
src/assets/vue.svg
Normal 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 |
139
src/components/ai/AiAdviceDrawer.vue
Normal file
139
src/components/ai/AiAdviceDrawer.vue
Normal 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>
|
||||||
101
src/components/header/AppHeader.vue
Normal file
101
src/components/header/AppHeader.vue
Normal 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>
|
||||||
70
src/components/header/SettingsDialog.vue
Normal file
70
src/components/header/SettingsDialog.vue
Normal 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>
|
||||||
55
src/components/news/NewsItem.vue
Normal file
55
src/components/news/NewsItem.vue
Normal 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">查看原文 ></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>
|
||||||
22
src/components/news/NewsList.vue
Normal file
22
src/components/news/NewsList.vue
Normal 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>
|
||||||
91
src/components/position/PositionDonut.vue
Normal file
91
src/components/position/PositionDonut.vue
Normal 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>
|
||||||
72
src/components/position/PositionPanel.vue
Normal file
72
src/components/position/PositionPanel.vue
Normal 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>
|
||||||
40
src/components/position/PositionTable.vue
Normal file
40
src/components/position/PositionTable.vue
Normal 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>
|
||||||
250
src/components/quote/ChartPanel.vue
Normal file
250
src/components/quote/ChartPanel.vue
Normal 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>
|
||||||
88
src/components/quote/OrderBook.vue
Normal file
88
src/components/quote/OrderBook.vue
Normal 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>
|
||||||
92
src/components/quote/QuoteStats.vue
Normal file
92
src/components/quote/QuoteStats.vue
Normal 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>
|
||||||
64
src/components/quote/TradeTape.vue
Normal file
64
src/components/quote/TradeTape.vue
Normal 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>
|
||||||
31
src/composables/useAiAdvice.ts
Normal file
31
src/composables/useAiAdvice.ts
Normal 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 }
|
||||||
|
}
|
||||||
28
src/composables/useNews.ts
Normal file
28
src/composables/useNews.ts
Normal 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 }
|
||||||
|
}
|
||||||
28
src/composables/usePositions.ts
Normal file
28
src/composables/usePositions.ts
Normal 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 }
|
||||||
|
}
|
||||||
33
src/composables/useQuote.ts
Normal file
33
src/composables/useQuote.ts
Normal 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
9
src/config/contract.ts
Normal 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
7
src/env.d.ts
vendored
Normal 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
49
src/layout/AppLayout.vue
Normal 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
13
src/main.ts
Normal 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
15
src/mocks/aiAdvice.ts
Normal 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
49
src/mocks/news.ts
Normal 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
75
src/mocks/positions.ts
Normal 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
112
src/mocks/quote.ts
Normal 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
29
src/stores/settings.ts
Normal 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
49
src/styles/global.css
Normal 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
16
src/styles/variables.css
Normal 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
104
src/types/index.ts
Normal 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
26
tsconfig.app.json
Normal 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
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
21
tsconfig.node.json
Normal file
21
tsconfig.node.json
Normal 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
31
vite.config.ts
Normal 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/, ''),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user