This commit is contained in:
Sakurasan
2026-07-05 21:29:17 +08:00
commit 013ebdaffe
150 changed files with 15614 additions and 0 deletions
+105
View File
@@ -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 }),
});
},
};
+332
View File
@@ -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开头:创业板(CYB301为注册制)
* - 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 [];
}
}
+53
View File
@@ -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);
}
+24
View File
@@ -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);
}