Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions apps/dsa-web/src/components/history/HistoryListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Badge } from '../common';
import type { HistoryItem } from '../../types/analysis';
import { getSentimentColor } from '../../types/analysis';
import { formatDateTime } from '../../utils/format';
import { truncateStockName, isStockNameTruncated } from '../../utils/stockName';

interface HistoryListItemProps {
item: HistoryItem;
Expand Down Expand Up @@ -42,6 +43,8 @@ export const HistoryListItem: React.FC<HistoryListItemProps> = ({
onClick,
}) => {
const sentimentColor = item.sentimentScore !== undefined ? getSentimentColor(item.sentimentScore) : null;
const stockName = item.stockName || item.stockCode;
const isTruncated = isStockNameTruncated(stockName);

return (
<div className="flex items-start gap-2 group">
Expand All @@ -61,7 +64,7 @@ export const HistoryListItem: React.FC<HistoryListItemProps> = ({
isViewing ? 'home-history-item-selected' : ''
}`}
>
<div className="flex items-center gap-2.5 relative z-10">
<div className={`flex items-center gap-2.5 relative z-10${isTruncated ? ' group-hover/item:z-20' : ''}`}>
{sentimentColor && (
<div
className="w-1 h-8 rounded-full flex-shrink-0"
Expand All @@ -75,14 +78,19 @@ export const HistoryListItem: React.FC<HistoryListItemProps> = ({
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<span className="truncate text-sm font-semibold text-foreground tracking-tight">
{item.stockName || item.stockCode}
<span className="group-hover/item:hidden">
{truncateStockName(stockName)}
</span>
<span className="hidden group-hover/item:inline">
{stockName}
</span>
</span>
</div>
{sentimentColor && (
<Badge
variant="default"
size="sm"
className="home-history-sentiment-badge shrink-0 shadow-none text-[11px] font-semibold leading-none"
className={`home-history-sentiment-badge shrink-0 shadow-none text-[11px] font-semibold leading-none transition-opacity duration-200${isTruncated ? ' group-hover/item:opacity-80' : ''}`}
style={{
color: sentimentColor,
borderColor: `${sentimentColor}30`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ const items: HistoryItem[] = [
},
];

const longChineseNameItem: HistoryItem = {
id: 2,
queryId: 'q-2',
stockCode: '600519',
stockName: '贵州茅台股票股份有限公司',
sentimentScore: 75,
operationAdvice: '持有',
createdAt: '2026-03-16T08:00:00Z',
};

describe('HistoryList', () => {
it('shows the empty state copy when no history exists', () => {
const { container } = render(<HistoryList {...baseProps} items={[]} />);
Expand Down Expand Up @@ -84,6 +94,22 @@ describe('HistoryList', () => {
expect(screen.getByRole('button', { name: '删除' })).toBeDisabled();
});

it('truncates long stock names with trailing dot', () => {
render(
<HistoryList
{...baseProps}
items={[longChineseNameItem]}
/>,
);

// '贵州茅台股票股份有限公司' (12 Chinese chars) should be truncated to '贵州茅台股票股份.' (8 chars + dot)
// The full name exists in a hidden span, visible on hover
expect(screen.getByText('贵州茅台股票股份.')).toBeInTheDocument();
const fullNameHidden = screen.queryByText('贵州茅台股票股份有限公司');
expect(fullNameHidden).toBeInTheDocument();
expect(fullNameHidden).toHaveClass('hidden');
});

it('generates unique select-all ids across multiple instances', () => {
const { container } = render(
<>
Expand Down
119 changes: 119 additions & 0 deletions apps/dsa-web/src/utils/__tests__/stockName.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import {
truncateStockName,
isStockNameTruncated,
STOCK_NAME_MAX_LENGTH,
} from '../stockName';
import { describe, expect, test } from 'vitest';

describe('truncateStockName', () => {
describe('English strings', () => {
test('returns unchanged when at or below 15 chars', () => {
expect(truncateStockName('Apple')).toBe('Apple');
expect(truncateStockName('AAPL')).toBe('AAPL');
expect(truncateStockName('123456789012345')).toBe('123456789012345');
});

test('truncates to 15 chars with trailing dot', () => {
expect(truncateStockName('Apple Computer Inc.')).toBe('Apple Computer .');
expect(truncateStockName('1234567890123456')).toBe('123456789012345.');
});

test('truncates very long English strings', () => {
expect(truncateStockName('VeryLongStockNameCorporation')).toBe('VeryLongStockNa.');
});
});

describe('Chinese strings', () => {
test('returns unchanged when at or below 8 chars', () => {
expect(truncateStockName('贵州茅台')).toBe('贵州茅台');
expect(truncateStockName('腾讯控股')).toBe('腾讯控股');
});

test('truncates to 8 chars with trailing dot', () => {
// 贵州茅台股票有限公司: 10 Chinese chars -> slice(0,8) + dot = 8 ch + dot
expect(truncateStockName('贵州茅台股票有限公司')).toBe('贵州茅台股票有限.');
// 中华人民共和国ABCD: mixed, 11 chars > 10 → truncate to '中华人民共和国ABC.'
expect(truncateStockName('中华人民共和国ABCD')).toBe('中华人民共和国ABC.');
});
});

describe('Mixed Chinese and English strings', () => {
test('returns unchanged when at or below 10 chars', () => {
expect(truncateStockName('茅台A')).toBe('茅台A');
expect(truncateStockName('腾讯控股HK')).toBe('腾讯控股HK');
});

test('truncates to 10 chars with trailing dot', () => {
// 贵州茅台股票有限公司AB: 10 Chinese + 2 English = 12 mixed -> slice(0,10) + dot
// First 10: 贵 州 茅 台 股 票 有 限 公 司 = 8 ch + 2 en
expect(truncateStockName('贵州茅台股票有限公司AB')).toBe('贵州茅台股票有限公司.');
// 腾讯控股00700H: 4 Chinese + 6 English = 10 mixed -> no truncation (10 <= 10)
expect(truncateStockName('腾讯控股00700H')).toBe('腾讯控股00700H');
});
});

describe('edge cases', () => {
test('returns empty string unchanged', () => {
expect(truncateStockName('')).toBe('');
});

test('handles stock code only (no Chinese)', () => {
expect(truncateStockName('600519.SH')).toBe('600519.SH');
expect(truncateStockName('00700.HK')).toBe('00700.HK');
});

test('handles single character strings', () => {
expect(truncateStockName('A')).toBe('A');
expect(truncateStockName('茅')).toBe('茅');
});

test('handles strings with only numbers and symbols', () => {
expect(truncateStockName('600519')).toBe('600519');
expect(truncateStockName('2026-03-24')).toBe('2026-03-24');
});

test('returns undefined unchanged (but should not happen in practice)', () => {
// The function checks falsy, so empty string is handled, but non-string values
// would behave unexpectedly - this documents current behavior
expect(truncateStockName('' as unknown as string)).toBe('');
});
});

describe('isStockNameTruncated', () => {
test('returns false for empty string', () => {
expect(isStockNameTruncated('')).toBe(false);
});

test('returns false for names at or below max length', () => {
expect(isStockNameTruncated('Apple')).toBe(false);
expect(isStockNameTruncated('贵州茅台')).toBe(false);
expect(isStockNameTruncated('茅台A')).toBe(false);
});

test('returns true for English names exceeding 15 chars', () => {
expect(isStockNameTruncated('Apple Computer Inc.')).toBe(true);
expect(isStockNameTruncated('VeryLongStockNameCorporation')).toBe(true);
});

test('returns true for Chinese names exceeding 8 chars', () => {
expect(isStockNameTruncated('贵州茅台股票股份有限公司')).toBe(true);
});

test('returns true for mixed names exceeding 10 chars', () => {
expect(isStockNameTruncated('贵州茅台股票有限公司AB')).toBe(true);
});

test('returns false for stock codes at boundary', () => {
expect(isStockNameTruncated('600519.SH')).toBe(false);
expect(isStockNameTruncated('00700.HK')).toBe(false);
});
});

describe('STOCK_NAME_MAX_LENGTH constant', () => {
test('has correct values', () => {
expect(STOCK_NAME_MAX_LENGTH.ENGLISH).toBe(15);
expect(STOCK_NAME_MAX_LENGTH.CHINESE).toBe(8);
expect(STOCK_NAME_MAX_LENGTH.MIXED).toBe(10);
});
});
});
46 changes: 46 additions & 0 deletions apps/dsa-web/src/utils/stockName.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* Stock name truncation configuration
* English characters: 15 chars max
* Chinese characters: 8 chars max
* Mixed (Chinese + English): 10 chars max
*/
export const STOCK_NAME_MAX_LENGTH = {
ENGLISH: 15,
CHINESE: 8,
MIXED: 10,
} as const;

/**
* Get max allowed length for a stock name based on character type
* - Pure English: 15 chars
* - Pure Chinese: 8 chars
* - Mixed: 10 chars
*/
function getMaxLength(name: string): number {
const isChinese = /[\u4e00-\u9fa5]/.test(name);
const isMixed = isChinese && /[a-zA-Z]/.test(name);
if (isMixed) return STOCK_NAME_MAX_LENGTH.MIXED;
if (isChinese) return STOCK_NAME_MAX_LENGTH.CHINESE;
return STOCK_NAME_MAX_LENGTH.ENGLISH;
}

/**
* Truncate stock name based on character type
* - Pure English: max 15 characters
* - Pure Chinese: max 8 characters
* - Mixed: max 10 characters
*/
export function truncateStockName(name: string): string {
if (!name) return name;
const maxLen = getMaxLength(name);
if (name.length <= maxLen) return name;
return name.slice(0, maxLen) + '.';
}

/**
* Check if stock name will be truncated
*/
export function isStockNameTruncated(name: string): boolean {
if (!name) return false;
return name.length > getMaxLength(name);
}
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- 🔢 **A 股同码实时行情保留交易所提示**(fixes #852)— `DataFetcherManager` 与 `TushareFetcher` 现在会保留 `SZ000001` / `000001.SZ` 这类显式沪深提示,旧版 Tushare 实时行情降级分支不再把深市 `000001` 误判成 `sh000001` 上证指数。
- 🎯 **多 Agent 次优买点不再盲目复制理想买点**(fixes #851)— 当多智能体结果缺少独立 `secondary_buy` 时,仪表盘现在优先展示 `N/A` 而不是把 fallback 值硬拷贝成与 `ideal_buy` 完全相同,减少误导性的双买点展示。
- 🧩 **Tushare 初始化不再强依赖本地 SDK 包** — `TushareFetcher` 现在直接使用内置 HTTP client 访问 Tushare Pro,不再在启动阶段先 `import tushare` 才能初始化;修复了 Docker、桌面打包或环境重建后因缺少 `tushare` 包而提前报 `No module named 'tushare'` 的问题,并补充对应回归测试。
- 🖥️ **历史列表过长股票名称截断与悬停展示**(fixes #815)— 历史列表中过长的股票名称, 现在会按字符类型自动截断(英文15/中文8/混合10字符),默认显示截断结果,悬停时展示完整名称;解决 1920x1080 分辨率下股票名称与右侧状态标签文字重叠的问题。新增 `stockName.ts` 工具函数并补充对应测试。

## [3.10.1] - 2026-03-24

Expand Down
Loading