Skip to content

Commit 000d4fc

Browse files
authored
fix: history stock name truncate [Issue: #815] (#894)
* feat(web): truncate long stock names in history list Display truncated stock names with a trailing dot (max 15 English / 8 Chinese / 10 mixed chars). Show full name on hover. Extract truncation logic to utils/stockName with full test coverage. (cherry picked from commit 76cf05db841dc8ee39436e0ba9cd86be2690252c) * fix(web): remove redundant title attribute in HistoryListItem The title attribute is no longer needed since truncateStockName already handles long stock names properly. * refactor(web): consolidate stockName variable usage in HistoryListItem Unified duplicate item.stockName || item.stockCode expressions to use the pre-computed stockName variable. Also added CHANGELOG entry
1 parent b59e33a commit 000d4fc

5 files changed

Lines changed: 203 additions & 3 deletions

File tree

apps/dsa-web/src/components/history/HistoryListItem.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Badge } from '../common';
33
import type { HistoryItem } from '../../types/analysis';
44
import { getSentimentColor } from '../../types/analysis';
55
import { formatDateTime } from '../../utils/format';
6+
import { truncateStockName, isStockNameTruncated } from '../../utils/stockName';
67

78
interface HistoryListItemProps {
89
item: HistoryItem;
@@ -42,6 +43,8 @@ export const HistoryListItem: React.FC<HistoryListItemProps> = ({
4243
onClick,
4344
}) => {
4445
const sentimentColor = item.sentimentScore !== undefined ? getSentimentColor(item.sentimentScore) : null;
46+
const stockName = item.stockName || item.stockCode;
47+
const isTruncated = isStockNameTruncated(stockName);
4548

4649
return (
4750
<div className="flex items-start gap-2 group">
@@ -61,7 +64,7 @@ export const HistoryListItem: React.FC<HistoryListItemProps> = ({
6164
isViewing ? 'home-history-item-selected' : ''
6265
}`}
6366
>
64-
<div className="flex items-center gap-2.5 relative z-10">
67+
<div className={`flex items-center gap-2.5 relative z-10${isTruncated ? ' group-hover/item:z-20' : ''}`}>
6568
{sentimentColor && (
6669
<div
6770
className="w-1 h-8 rounded-full flex-shrink-0"
@@ -75,14 +78,19 @@ export const HistoryListItem: React.FC<HistoryListItemProps> = ({
7578
<div className="flex items-start justify-between gap-2">
7679
<div className="min-w-0 flex-1">
7780
<span className="truncate text-sm font-semibold text-foreground tracking-tight">
78-
{item.stockName || item.stockCode}
81+
<span className="group-hover/item:hidden">
82+
{truncateStockName(stockName)}
83+
</span>
84+
<span className="hidden group-hover/item:inline">
85+
{stockName}
86+
</span>
7987
</span>
8088
</div>
8189
{sentimentColor && (
8290
<Badge
8391
variant="default"
8492
size="sm"
85-
className="home-history-sentiment-badge shrink-0 shadow-none text-[11px] font-semibold leading-none"
93+
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' : ''}`}
8694
style={{
8795
color: sentimentColor,
8896
borderColor: `${sentimentColor}30`,

apps/dsa-web/src/components/history/__tests__/HistoryList.test.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,16 @@ const items: HistoryItem[] = [
2727
},
2828
];
2929

30+
const longChineseNameItem: HistoryItem = {
31+
id: 2,
32+
queryId: 'q-2',
33+
stockCode: '600519',
34+
stockName: '贵州茅台股票股份有限公司',
35+
sentimentScore: 75,
36+
operationAdvice: '持有',
37+
createdAt: '2026-03-16T08:00:00Z',
38+
};
39+
3040
describe('HistoryList', () => {
3141
it('shows the empty state copy when no history exists', () => {
3242
const { container } = render(<HistoryList {...baseProps} items={[]} />);
@@ -84,6 +94,22 @@ describe('HistoryList', () => {
8494
expect(screen.getByRole('button', { name: '删除' })).toBeDisabled();
8595
});
8696

97+
it('truncates long stock names with trailing dot', () => {
98+
render(
99+
<HistoryList
100+
{...baseProps}
101+
items={[longChineseNameItem]}
102+
/>,
103+
);
104+
105+
// '贵州茅台股票股份有限公司' (12 Chinese chars) should be truncated to '贵州茅台股票股份.' (8 chars + dot)
106+
// The full name exists in a hidden span, visible on hover
107+
expect(screen.getByText('贵州茅台股票股份.')).toBeInTheDocument();
108+
const fullNameHidden = screen.queryByText('贵州茅台股票股份有限公司');
109+
expect(fullNameHidden).toBeInTheDocument();
110+
expect(fullNameHidden).toHaveClass('hidden');
111+
});
112+
87113
it('generates unique select-all ids across multiple instances', () => {
88114
const { container } = render(
89115
<>
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import {
2+
truncateStockName,
3+
isStockNameTruncated,
4+
STOCK_NAME_MAX_LENGTH,
5+
} from '../stockName';
6+
import { describe, expect, test } from 'vitest';
7+
8+
describe('truncateStockName', () => {
9+
describe('English strings', () => {
10+
test('returns unchanged when at or below 15 chars', () => {
11+
expect(truncateStockName('Apple')).toBe('Apple');
12+
expect(truncateStockName('AAPL')).toBe('AAPL');
13+
expect(truncateStockName('123456789012345')).toBe('123456789012345');
14+
});
15+
16+
test('truncates to 15 chars with trailing dot', () => {
17+
expect(truncateStockName('Apple Computer Inc.')).toBe('Apple Computer .');
18+
expect(truncateStockName('1234567890123456')).toBe('123456789012345.');
19+
});
20+
21+
test('truncates very long English strings', () => {
22+
expect(truncateStockName('VeryLongStockNameCorporation')).toBe('VeryLongStockNa.');
23+
});
24+
});
25+
26+
describe('Chinese strings', () => {
27+
test('returns unchanged when at or below 8 chars', () => {
28+
expect(truncateStockName('贵州茅台')).toBe('贵州茅台');
29+
expect(truncateStockName('腾讯控股')).toBe('腾讯控股');
30+
});
31+
32+
test('truncates to 8 chars with trailing dot', () => {
33+
// 贵州茅台股票有限公司: 10 Chinese chars -> slice(0,8) + dot = 8 ch + dot
34+
expect(truncateStockName('贵州茅台股票有限公司')).toBe('贵州茅台股票有限.');
35+
// 中华人民共和国ABCD: mixed, 11 chars > 10 → truncate to '中华人民共和国ABC.'
36+
expect(truncateStockName('中华人民共和国ABCD')).toBe('中华人民共和国ABC.');
37+
});
38+
});
39+
40+
describe('Mixed Chinese and English strings', () => {
41+
test('returns unchanged when at or below 10 chars', () => {
42+
expect(truncateStockName('茅台A')).toBe('茅台A');
43+
expect(truncateStockName('腾讯控股HK')).toBe('腾讯控股HK');
44+
});
45+
46+
test('truncates to 10 chars with trailing dot', () => {
47+
// 贵州茅台股票有限公司AB: 10 Chinese + 2 English = 12 mixed -> slice(0,10) + dot
48+
// First 10: 贵 州 茅 台 股 票 有 限 公 司 = 8 ch + 2 en
49+
expect(truncateStockName('贵州茅台股票有限公司AB')).toBe('贵州茅台股票有限公司.');
50+
// 腾讯控股00700H: 4 Chinese + 6 English = 10 mixed -> no truncation (10 <= 10)
51+
expect(truncateStockName('腾讯控股00700H')).toBe('腾讯控股00700H');
52+
});
53+
});
54+
55+
describe('edge cases', () => {
56+
test('returns empty string unchanged', () => {
57+
expect(truncateStockName('')).toBe('');
58+
});
59+
60+
test('handles stock code only (no Chinese)', () => {
61+
expect(truncateStockName('600519.SH')).toBe('600519.SH');
62+
expect(truncateStockName('00700.HK')).toBe('00700.HK');
63+
});
64+
65+
test('handles single character strings', () => {
66+
expect(truncateStockName('A')).toBe('A');
67+
expect(truncateStockName('茅')).toBe('茅');
68+
});
69+
70+
test('handles strings with only numbers and symbols', () => {
71+
expect(truncateStockName('600519')).toBe('600519');
72+
expect(truncateStockName('2026-03-24')).toBe('2026-03-24');
73+
});
74+
75+
test('returns undefined unchanged (but should not happen in practice)', () => {
76+
// The function checks falsy, so empty string is handled, but non-string values
77+
// would behave unexpectedly - this documents current behavior
78+
expect(truncateStockName('' as unknown as string)).toBe('');
79+
});
80+
});
81+
82+
describe('isStockNameTruncated', () => {
83+
test('returns false for empty string', () => {
84+
expect(isStockNameTruncated('')).toBe(false);
85+
});
86+
87+
test('returns false for names at or below max length', () => {
88+
expect(isStockNameTruncated('Apple')).toBe(false);
89+
expect(isStockNameTruncated('贵州茅台')).toBe(false);
90+
expect(isStockNameTruncated('茅台A')).toBe(false);
91+
});
92+
93+
test('returns true for English names exceeding 15 chars', () => {
94+
expect(isStockNameTruncated('Apple Computer Inc.')).toBe(true);
95+
expect(isStockNameTruncated('VeryLongStockNameCorporation')).toBe(true);
96+
});
97+
98+
test('returns true for Chinese names exceeding 8 chars', () => {
99+
expect(isStockNameTruncated('贵州茅台股票股份有限公司')).toBe(true);
100+
});
101+
102+
test('returns true for mixed names exceeding 10 chars', () => {
103+
expect(isStockNameTruncated('贵州茅台股票有限公司AB')).toBe(true);
104+
});
105+
106+
test('returns false for stock codes at boundary', () => {
107+
expect(isStockNameTruncated('600519.SH')).toBe(false);
108+
expect(isStockNameTruncated('00700.HK')).toBe(false);
109+
});
110+
});
111+
112+
describe('STOCK_NAME_MAX_LENGTH constant', () => {
113+
test('has correct values', () => {
114+
expect(STOCK_NAME_MAX_LENGTH.ENGLISH).toBe(15);
115+
expect(STOCK_NAME_MAX_LENGTH.CHINESE).toBe(8);
116+
expect(STOCK_NAME_MAX_LENGTH.MIXED).toBe(10);
117+
});
118+
});
119+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* Stock name truncation configuration
3+
* English characters: 15 chars max
4+
* Chinese characters: 8 chars max
5+
* Mixed (Chinese + English): 10 chars max
6+
*/
7+
export const STOCK_NAME_MAX_LENGTH = {
8+
ENGLISH: 15,
9+
CHINESE: 8,
10+
MIXED: 10,
11+
} as const;
12+
13+
/**
14+
* Get max allowed length for a stock name based on character type
15+
* - Pure English: 15 chars
16+
* - Pure Chinese: 8 chars
17+
* - Mixed: 10 chars
18+
*/
19+
function getMaxLength(name: string): number {
20+
const isChinese = /[\u4e00-\u9fa5]/.test(name);
21+
const isMixed = isChinese && /[a-zA-Z]/.test(name);
22+
if (isMixed) return STOCK_NAME_MAX_LENGTH.MIXED;
23+
if (isChinese) return STOCK_NAME_MAX_LENGTH.CHINESE;
24+
return STOCK_NAME_MAX_LENGTH.ENGLISH;
25+
}
26+
27+
/**
28+
* Truncate stock name based on character type
29+
* - Pure English: max 15 characters
30+
* - Pure Chinese: max 8 characters
31+
* - Mixed: max 10 characters
32+
*/
33+
export function truncateStockName(name: string): string {
34+
if (!name) return name;
35+
const maxLen = getMaxLength(name);
36+
if (name.length <= maxLen) return name;
37+
return name.slice(0, maxLen) + '.';
38+
}
39+
40+
/**
41+
* Check if stock name will be truncated
42+
*/
43+
export function isStockNameTruncated(name: string): boolean {
44+
if (!name) return false;
45+
return name.length > getMaxLength(name);
46+
}

docs/CHANGELOG.md

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

3637
## [3.10.1] - 2026-03-24
3738

0 commit comments

Comments
 (0)