up
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
// Python 后端 API 客户端 — 自动适配局域网 IP
|
||||
// 前端 JS 在浏览器中运行时,API 用当前页面的 hostname(端口 8000)
|
||||
const API_BASE = `http://${window.location.hostname}:8000`;
|
||||
|
||||
export function getApiBaseUrl(): string {
|
||||
return API_BASE;
|
||||
}
|
||||
|
||||
// 通用 fetch 封装
|
||||
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const url = `${API_BASE}${path}`;
|
||||
const resp = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers as Record<string, string> || {}),
|
||||
},
|
||||
});
|
||||
if (!resp.ok) {
|
||||
let errorMsg = `请求失败 (${resp.status})`;
|
||||
try {
|
||||
const errData = await resp.json();
|
||||
errorMsg = errData.detail || errData.error || errorMsg;
|
||||
} catch {}
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
interface Collection {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
user_id: string;
|
||||
last_accessed_at: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface CollectionStock {
|
||||
id: number;
|
||||
collection_id: number;
|
||||
stock_code: string;
|
||||
stock_name: string;
|
||||
added_price: number | null;
|
||||
added_at: string;
|
||||
}
|
||||
|
||||
// 集合 API
|
||||
export const collectionsApi = {
|
||||
list(userId: string): Promise<Collection[]> {
|
||||
return apiFetch(`/api/collections?user_id=${encodeURIComponent(userId)}`);
|
||||
},
|
||||
|
||||
create(name: string, userId: string, description?: string): Promise<Collection> {
|
||||
return apiFetch("/api/collections", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, user_id: userId, description: description || null }),
|
||||
});
|
||||
},
|
||||
|
||||
get(id: number): Promise<Collection> {
|
||||
return apiFetch(`/api/collections/${id}`);
|
||||
},
|
||||
|
||||
update(id: number, data: Record<string, unknown>): Promise<Collection> {
|
||||
return apiFetch(`/api/collections/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
delete(id: number): Promise<{ success: boolean }> {
|
||||
return apiFetch(`/api/collections/${id}`, { method: "DELETE" });
|
||||
},
|
||||
|
||||
listStocks(collectionId: number): Promise<CollectionStock[]> {
|
||||
return apiFetch(`/api/collections/${collectionId}/stocks`);
|
||||
},
|
||||
|
||||
findStock(stockCode: string): Promise<CollectionStock | null> {
|
||||
return apiFetch(`/api/collections/stock/${stockCode}`);
|
||||
},
|
||||
|
||||
addStock(collectionId: number, stock: { stock_code: string; stock_name: string; added_price?: number | null }): Promise<CollectionStock> {
|
||||
return apiFetch(`/api/collections/${collectionId}/stocks`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(stock),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// 分享链接 API
|
||||
export const sharesApi = {
|
||||
get(shortCode: string): Promise<Collection & { stocks: CollectionStock[] }> {
|
||||
return apiFetch(`/api/share/${shortCode}`);
|
||||
},
|
||||
|
||||
create(collectionId: number): Promise<{ short_code: string }> {
|
||||
return apiFetch("/api/share", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ collection_id: collectionId }),
|
||||
});
|
||||
},
|
||||
};
|
||||
Executable
+332
@@ -0,0 +1,332 @@
|
||||
// 股票数据获取工具:通过 Python 后端代理调用腾讯财经API
|
||||
import { getApiBaseUrl } from "@/lib/api-client";
|
||||
|
||||
export interface StockQuote {
|
||||
code: string;
|
||||
market: string;
|
||||
name: string;
|
||||
todayOpen: number;
|
||||
yesterdayClose: number;
|
||||
currentPrice: number;
|
||||
high: number;
|
||||
low: number;
|
||||
volume: number;
|
||||
amount: number;
|
||||
outerDisk: number; // 外盘(手)
|
||||
innerDisk: number; // 内盘(手)
|
||||
date: string;
|
||||
time: string;
|
||||
change: number;
|
||||
changePercent: number;
|
||||
}
|
||||
|
||||
export interface KLineData {
|
||||
date: string;
|
||||
open: number;
|
||||
close: number;
|
||||
high: number;
|
||||
low: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export interface StockSearchResult {
|
||||
code: string;
|
||||
name: string;
|
||||
market: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface FundFlowData {
|
||||
date: string;
|
||||
mainNetInflow: number; // 主力净流入
|
||||
superLargeInflow: number; // 超大单流入
|
||||
superLargeOutflow: number; // 超大单流出
|
||||
largeInflow: number; // 大单流入
|
||||
largeOutflow: number; // 大单流出
|
||||
mediumInflow: number; // 中单流入
|
||||
mediumOutflow: number; // 中单流出
|
||||
smallInflow: number; // 小单流入
|
||||
smallOutflow: number; // 小单流出
|
||||
mainNetInflowPercent: number; // 主力净流入占比
|
||||
superLargeInflowPercent: number; // 超大单流入占比
|
||||
superLargeOutflowPercent: number; // 超大单流出占比
|
||||
largeInflowPercent: number; // 大单流入占比
|
||||
largeOutflowPercent: number; // 大单流出占比
|
||||
mediumInflowPercent: number; // 中单流入占比
|
||||
mediumOutflowPercent: number; // 中单流出占比
|
||||
smallInflowPercent: number; // 小单流入占比
|
||||
smallOutflowPercent: number; // 小单流出占比
|
||||
turnover: number; // 成交额
|
||||
// 计算字段
|
||||
mainForceNet: number; // 主力净额(超大单+大单净流入)
|
||||
cumulativeMainNet: number; // 累计主力净流入
|
||||
changePercent: number; // 当日涨幅%
|
||||
closePrice: number; // 收盘价
|
||||
}
|
||||
|
||||
export interface FundFlowSummary {
|
||||
totalMainNet: number; // 期间主力总净流入
|
||||
totalTurnover: number; // 期间总成交额
|
||||
totalLargeInflow?: number; // 期间大单流入资金(≈主买资金)
|
||||
avgDailyMainNet: number; // 日均主力净流入
|
||||
positiveDays: number; // 主力净流入为正的天数
|
||||
negativeDays: number; // 主力净流入为负的天数
|
||||
}
|
||||
|
||||
export type StockBoard = "main" | "cyb" | "kcb" | "bjb";
|
||||
|
||||
export interface BoardInfo {
|
||||
board: StockBoard;
|
||||
label: string;
|
||||
className: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据股票代码判断所属板块
|
||||
* - 688开头:科创板(KCB)
|
||||
* - 300/301开头:创业板(CYB,301为注册制)
|
||||
* - 8/4/920开头:北交所(BJB)
|
||||
* - 其他:主板
|
||||
*/
|
||||
export function getStockBoard(code: string): BoardInfo {
|
||||
if (code.startsWith("688")) {
|
||||
return { board: "kcb", label: "科", className: "bg-red-500/10 text-red-500 border-red-500/30" };
|
||||
}
|
||||
// 300/301 开头均为创业板(301 为创业板注册制)
|
||||
if (code.startsWith("300") || code.startsWith("301")) {
|
||||
return { board: "cyb", label: "创", className: "bg-purple-500/10 text-purple-500 border-purple-500/30" };
|
||||
}
|
||||
if (code.startsWith("8") || code.startsWith("4") || code.startsWith("920")) {
|
||||
return { board: "bjb", label: "北", className: "bg-orange-500/10 text-orange-500 border-orange-500/30" };
|
||||
}
|
||||
return { board: "main", label: "", className: "" };
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索股票(支持名称/代码/拼音模糊匹配)
|
||||
*/
|
||||
export async function fetchStockSearch(keyword: string): Promise<StockSearchResult[]> {
|
||||
const trimmed = keyword.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
const baseUrl = getApiBaseUrl();
|
||||
const url = `${baseUrl}/api/stock/search?keyword=${encodeURIComponent(trimmed)}`;
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, { method: "GET" });
|
||||
if (!resp.ok) {
|
||||
console.error("[stock-api] 搜索失败:", resp.status);
|
||||
return [];
|
||||
}
|
||||
const result = await resp.json();
|
||||
if (!result.data || !Array.isArray(result.data)) return [];
|
||||
return result.data as StockSearchResult[];
|
||||
} catch (err) {
|
||||
console.error("[stock-api] 搜索股票失败:", err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单只股票实时行情
|
||||
*/
|
||||
export async function fetchStockQuote(code: string): Promise<StockQuote | null> {
|
||||
if (!/^\d{6}$/.test(code)) {
|
||||
throw new Error("股票代码格式错误,需为6位数字");
|
||||
}
|
||||
|
||||
const baseUrl = getApiBaseUrl();
|
||||
const url = `${baseUrl}/api/stock/quote?code=${code}`;
|
||||
|
||||
console.log("[stock-api] 请求URL:", url);
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, { method: "GET" });
|
||||
|
||||
console.log("[stock-api] 响应状态:", resp.status);
|
||||
|
||||
if (!resp.ok) {
|
||||
let errorMsg = `请求失败 (${resp.status})`;
|
||||
try {
|
||||
const errData = await resp.json();
|
||||
errorMsg = errData.error || errorMsg;
|
||||
} catch {
|
||||
// 忽略 JSON 解析错误
|
||||
}
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
const result = await resp.json();
|
||||
console.log("[stock-api] 响应数据:", result);
|
||||
|
||||
if (!result.data) {
|
||||
throw new Error(result.error || "未获取到股票数据");
|
||||
}
|
||||
|
||||
return result.data as StockQuote;
|
||||
} catch (err) {
|
||||
console.error("[stock-api] 获取股票数据失败:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取股票历史K线数据(通过Edge Function代理腾讯财经API)
|
||||
* @param code 股票代码(6位)
|
||||
* @param days 天数(默认90天)
|
||||
*/
|
||||
export async function fetchStockHistory(code: string, days: number = 90): Promise<KLineData[]> {
|
||||
if (!/^\d{6}$/.test(code)) {
|
||||
throw new Error("股票代码格式错误,需为6位数字");
|
||||
}
|
||||
|
||||
const baseUrl = getApiBaseUrl();
|
||||
const url = `${baseUrl}/api/stock/history?code=${code}&days=${days}`;
|
||||
|
||||
console.log("[stock-api] 请求历史K线URL:", url);
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, { method: "GET" });
|
||||
|
||||
console.log("[stock-api] 历史K线响应状态:", resp.status);
|
||||
|
||||
if (!resp.ok) {
|
||||
let errorMsg = `请求失败 (${resp.status})`;
|
||||
try {
|
||||
const errData = await resp.json();
|
||||
errorMsg = errData.error || errorMsg;
|
||||
} catch {
|
||||
// 忽略 JSON 解析错误
|
||||
}
|
||||
console.error("[stock-api] 历史K线错误:", errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
const result = await resp.json();
|
||||
console.log("[stock-api] 历史K线数据条数:", result.count);
|
||||
|
||||
if (!result.data || !Array.isArray(result.data)) {
|
||||
throw new Error(result.error || "未获取到历史数据");
|
||||
}
|
||||
|
||||
return result.data as KLineData[];
|
||||
} catch (err) {
|
||||
console.error("[stock-api] 获取历史K线数据失败:", err);
|
||||
// 降级方案:返回空数组,让前端使用模拟数据
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取多只股票行情(并行调用)
|
||||
*/
|
||||
export async function fetchStockQuotes(codes: string[]): Promise<Map<string, StockQuote>> {
|
||||
const map = new Map<string, StockQuote>();
|
||||
const results = await Promise.allSettled(
|
||||
codes.map((code) => fetchStockQuote(code))
|
||||
);
|
||||
|
||||
results.forEach((res, idx) => {
|
||||
if (res.status === "fulfilled" && res.value) {
|
||||
map.set(codes[idx], res.value);
|
||||
}
|
||||
});
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取股票资金流向数据
|
||||
* @param code 股票代码(6位)
|
||||
* @param name 股票名称(可选,用于MX API查询)
|
||||
* @param days 天数(默认21个交易日)
|
||||
*/
|
||||
export async function fetchStockFundFlow(code: string, name?: string, days: number = 21): Promise<{ data: FundFlowData[]; summary: FundFlowSummary | null; count: number }> {
|
||||
if (!/^\d{6}$/.test(code)) {
|
||||
throw new Error("股票代码格式错误,需为6位数字");
|
||||
}
|
||||
|
||||
const baseUrl = getApiBaseUrl();
|
||||
const nameParam = name ? `&name=${encodeURIComponent(name)}` : '';
|
||||
const url = `${baseUrl}/api/stock/fund-flow?code=${code}${nameParam}&days=${days}`;
|
||||
|
||||
console.log("[stock-api] 请求资金流向URL:", url);
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, { method: "GET" });
|
||||
|
||||
console.log("[stock-api] 资金流向响应状态:", resp.status);
|
||||
|
||||
if (!resp.ok) {
|
||||
let errorMsg = `请求失败 (${resp.status})`;
|
||||
try {
|
||||
const errData = await resp.json();
|
||||
errorMsg = errData.error || errorMsg;
|
||||
} catch {
|
||||
// 忽略 JSON 解析错误
|
||||
}
|
||||
console.error("[stock-api] 资金流向错误:", errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
const result = await resp.json();
|
||||
console.log("[stock-api] 资金流向响应:", JSON.stringify(result).substring(0, 500));
|
||||
console.log("[stock-api] 资金流向数据条数:", result.count);
|
||||
|
||||
if (!result.data || !Array.isArray(result.data)) {
|
||||
throw new Error(result.error || "未获取到资金流向数据");
|
||||
}
|
||||
|
||||
return {
|
||||
data: result.data as FundFlowData[],
|
||||
summary: result.summary || null,
|
||||
count: result.count || 0,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("[stock-api] 获取资金流向数据失败:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 板块数据 ----
|
||||
|
||||
export interface SectorItem {
|
||||
code: string;
|
||||
name: string;
|
||||
level: number | null;
|
||||
changePercent: number | null;
|
||||
changeAmount: number | null;
|
||||
mainNetInflow: number;
|
||||
mainNetInflowPercent: number;
|
||||
superLargeInflow: number;
|
||||
superLargeInflowPercent: number;
|
||||
turnover: number;
|
||||
smallNetInflow: number;
|
||||
}
|
||||
|
||||
export type SectorType = "industry" | "concept";
|
||||
|
||||
export interface SectorResponse {
|
||||
data: SectorItem[];
|
||||
count: number;
|
||||
type: SectorType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取东方财富板块列表(按主力净流入排序)
|
||||
* @param type industry=行业板块, concept=概念板块
|
||||
*/
|
||||
export async function fetchSectors(type: SectorType): Promise<SectorItem[]> {
|
||||
const baseUrl = getApiBaseUrl();
|
||||
const url = `${baseUrl}/api/sectors?type=${type}`;
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, { method: "GET" });
|
||||
if (!resp.ok) return [];
|
||||
const result: SectorResponse = await resp.json();
|
||||
return result.data || [];
|
||||
} catch (err) {
|
||||
console.error("[stock-api] 获取板块数据失败:", err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 用户ID管理工具
|
||||
* 为每个匿名用户生成永久userid并存储到localStorage
|
||||
*/
|
||||
|
||||
const USER_ID_KEY = 'a_stock_tracker_user_id';
|
||||
|
||||
/**
|
||||
* 获取或生成用户ID
|
||||
* 如果localStorage中已有则返回,否则生成新的UUID
|
||||
*/
|
||||
export function getUserId(): string {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
let userId = localStorage.getItem(USER_ID_KEY);
|
||||
|
||||
if (!userId) {
|
||||
// 生成UUID v4
|
||||
userId = crypto.randomUUID ? crypto.randomUUID() : generateUUID();
|
||||
localStorage.setItem(USER_ID_KEY, userId);
|
||||
}
|
||||
|
||||
return userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动生成UUID v4(兼容不支持crypto.randomUUID的环境)
|
||||
*/
|
||||
function generateUUID(): string {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = Math.random() * 16 | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新最后访问时间
|
||||
*/
|
||||
export function updateLastAccessed(collectionId: number): void {
|
||||
const key = `last_accessed_${collectionId}`;
|
||||
localStorage.setItem(key, new Date().toISOString());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最后访问时间
|
||||
*/
|
||||
export function getLastAccessed(collectionId: number): string | null {
|
||||
const key = `last_accessed_${collectionId}`;
|
||||
return localStorage.getItem(key);
|
||||
}
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化金额:大等于1亿用"亿"单位,小于1亿用"万"单位
|
||||
* 负数会显示为 "-1.23亿" 格式
|
||||
* @param value 金额(元)
|
||||
* @returns 格式化后的字符串,如 "1.23亿" 或 "-4567.89万"
|
||||
*/
|
||||
export function formatMoney(value: number): string {
|
||||
const isNegative = value < 0;
|
||||
const absValue = Math.abs(value);
|
||||
|
||||
if (absValue >= 100000000) {
|
||||
return (isNegative ? '-' : '') + (absValue / 100000000).toFixed(2) + '亿';
|
||||
} else if (absValue >= 10000) {
|
||||
return (isNegative ? '-' : '') + (absValue / 10000).toFixed(2) + '万';
|
||||
}
|
||||
return (isNegative ? '-' : '') + absValue.toFixed(2);
|
||||
}
|
||||
Reference in New Issue
Block a user