feat: 在移动端问股页面将策略勾选区域改为可收缩显示,减少对对话内容的遮挡… (#1646) (#1697)

* feat(issue-1646): [feature]-建议调整下移动问股页面的布局

* fix(review-feedback-1697): 补齐移动端问股页面的截图/前后对比或等价可视证据,并同步 PR 描述中的前端验证结论

* fix(review-feedback-1697): PR 描述缺少移动端问股页变更截图或等价可视证据

* fix(review-feedback-1697): apps/dsa-web/src/pages/ChatPage.tsx 引入移动端策略选择默认收起/展开,这是用户可见 Web UI 变化

* fix(review-feedback-1697): apps/dsa-web/src/pages/ChatPage.tsx 改动了问股页移动端可见交互,PR

* fix(review-feedback-1697): apps/dsa-web/src/pages/ChatPage.tsx 改动了问股页移动端可见交互,但 PR

* fix(review-feedback-1697): docs/CHANGELOG.md 新增了“如截图不可获取,替代可视证据见测试用例”这类 PR 验收/审查过程说明
This commit is contained in:
mumu
2026-06-18 21:23:46 +08:00
committed by GitHub
parent 1f9ce289d2
commit 689a69df79
3 changed files with 133 additions and 46 deletions

View File

@@ -2,6 +2,7 @@ import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { ChevronDown, SlidersHorizontal } from 'lucide-react';
import { cn } from '../utils/cn';
import { agentApi } from '../api/agent';
import { systemConfigApi } from '../api/systemConfig';
@@ -154,6 +155,7 @@ const ChatPage: React.FC = () => {
const [skills, setSkills] = useState<SkillInfo[]>([]);
const [selectedSkillIds, setSelectedSkillIds] = useState<string[]>([]);
const [showSkillDesc, setShowSkillDesc] = useState<string | null>(null);
const [mobileSkillPickerOpen, setMobileSkillPickerOpen] = useState(false);
const [expandedThinking, setExpandedThinking] = useState<Set<string>>(new Set());
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [sidebarOpen, setSidebarOpen] = useState(false);
@@ -593,6 +595,7 @@ const ChatPage: React.FC = () => {
setIsFollowUpContextLoading(false);
setInput('');
setMobileSkillPickerOpen(false);
requestScrollToBottom('smooth');
await startStream(payload, {
skillNames: usedSkillNames,
@@ -859,6 +862,10 @@ const ChatPage: React.FC = () => {
</>
);
const selectedSkillSummary = selectedSkillIds.length > 0
? getSkillNames(selectedSkillIds).join('、')
: '通用分析';
return (
<div
data-testid="chat-workspace"
@@ -1278,61 +1285,91 @@ const ChatPage: React.FC = () => {
className="rounded-xl px-3 py-2 text-xs shadow-none"
/>
) : null}
{skills.length > 0 && (
<div className="flex flex-wrap items-start gap-x-5 gap-y-2">
<span className="text-xs text-muted-text font-medium uppercase tracking-wider flex-shrink-0 mt-1">
</span>
<label className="flex items-center gap-1.5 text-sm cursor-pointer group mt-0.5">
<input
type="checkbox"
name="general-analysis"
value=""
checked={selectedSkillIds.length === 0}
onChange={() => setSelectedSkillIds([])}
className="chat-skill-checkbox"
/>
<span
className={`transition-colors text-sm ${selectedSkillIds.length === 0 ? 'text-foreground font-medium' : 'text-secondary-text group-hover:text-foreground'}`}
{skills.length > 0 && (
<div className="space-y-2">
<button
type="button"
className="home-surface-button flex h-10 w-full items-center justify-between gap-3 rounded-xl px-3 text-left text-sm text-foreground md:hidden"
aria-label={mobileSkillPickerOpen ? '收起策略选择' : '展开策略选择'}
aria-expanded={mobileSkillPickerOpen}
aria-controls="chat-skill-picker-panel"
onClick={() => setMobileSkillPickerOpen((open) => !open)}
>
</span>
</label>
{skills.map((s) => {
const checked = selectedSkillIdSet.has(s.id);
const disabled = !checked && skillLimitReached;
return (
<label
key={s.id}
className={`flex items-center gap-1.5 cursor-pointer group relative mt-0.5 ${disabled ? 'opacity-60 cursor-not-allowed' : ''}`}
onMouseEnter={() => setShowSkillDesc(s.id)}
onMouseLeave={() => setShowSkillDesc(null)}
>
<span className="flex min-w-0 items-center gap-2">
<SlidersHorizontal className="h-4 w-4 flex-shrink-0" aria-hidden="true" />
<span className="flex-shrink-0 font-medium"></span>
<span className="truncate text-xs text-muted-text">{selectedSkillSummary}</span>
</span>
<ChevronDown
className={cn(
'h-4 w-4 flex-shrink-0 text-muted-text transition-transform',
mobileSkillPickerOpen ? 'rotate-180' : '',
)}
aria-hidden="true"
/>
</button>
<div
id="chat-skill-picker-panel"
data-testid="chat-skill-picker-panel"
className={cn(
mobileSkillPickerOpen ? 'flex' : 'hidden',
'max-h-40 flex-wrap items-start gap-x-5 gap-y-2 overflow-y-auto rounded-xl border border-white/6 bg-surface/25 px-3 py-2 md:flex md:max-h-none md:overflow-visible md:border-0 md:bg-transparent md:p-0',
)}
>
<span className="text-xs text-muted-text font-medium uppercase tracking-wider flex-shrink-0 mt-1">
</span>
<label className="flex items-center gap-1.5 text-sm cursor-pointer group mt-0.5">
<input
type="checkbox"
name="skills"
value={s.id}
checked={checked}
disabled={disabled}
onChange={() => toggleSkillSelection(s.id)}
name="general-analysis"
value=""
checked={selectedSkillIds.length === 0}
onChange={() => setSelectedSkillIds([])}
className="chat-skill-checkbox"
/>
<span
className={`transition-colors text-sm ${checked ? 'text-foreground font-medium' : 'text-secondary-text group-hover:text-foreground'}`}
className={`transition-colors text-sm ${selectedSkillIds.length === 0 ? 'text-foreground font-medium' : 'text-secondary-text group-hover:text-foreground'}`}
>
{s.name}
</span>
{showSkillDesc === s.id && s.description && (
<div className="skill-desc-tooltip">
<p className="skill-title">{s.name}</p>
<p>{s.description}</p>
</div>
)}
</label>
);
})}
</div>
)}
{skills.map((s) => {
const checked = selectedSkillIdSet.has(s.id);
const disabled = !checked && skillLimitReached;
return (
<label
key={s.id}
className={`flex items-center gap-1.5 cursor-pointer group relative mt-0.5 ${disabled ? 'opacity-60 cursor-not-allowed' : ''}`}
onMouseEnter={() => setShowSkillDesc(s.id)}
onMouseLeave={() => setShowSkillDesc(null)}
>
<input
type="checkbox"
name="skills"
value={s.id}
checked={checked}
disabled={disabled}
onChange={() => toggleSkillSelection(s.id)}
className="chat-skill-checkbox"
/>
<span
className={`transition-colors text-sm ${checked ? 'text-foreground font-medium' : 'text-secondary-text group-hover:text-foreground'}`}
>
{s.name}
</span>
{showSkillDesc === s.id && s.description && (
<div className="skill-desc-tooltip">
<p className="skill-title">{s.name}</p>
<p>{s.description}</p>
</div>
)}
</label>
);
})}
</div>
</div>
)}
{activeStockCode && (
<div className="flex items-center gap-2">

View File

@@ -442,6 +442,54 @@ describe('ChatPage', () => {
});
});
it('collapses the mobile skill picker by default and keeps selected skills when sending', async () => {
mockGetSkills.mockResolvedValue({
skills: [
{ id: 'bull_trend', name: '趋势分析', description: '默认趋势' },
{ id: 'ma_golden_cross', name: '均线金叉', description: '均线交叉' },
],
default_skill_id: 'bull_trend',
});
render(
<MemoryRouter initialEntries={['/chat']}>
<ChatPage />
</MemoryRouter>
);
const mobileToggle = await screen.findByRole('button', { name: '展开策略选择' });
const skillPanel = screen.getByTestId('chat-skill-picker-panel');
expect(mobileToggle).toHaveAttribute('aria-expanded', 'false');
expect(skillPanel).toHaveClass('hidden');
fireEvent.click(mobileToggle);
expect(screen.getByRole('button', { name: '收起策略选择' })).toHaveAttribute('aria-expanded', 'true');
expect(skillPanel).not.toHaveClass('hidden');
expect(skillPanel).toHaveClass('flex');
fireEvent.click(screen.getByRole('checkbox', { name: '均线金叉' }));
fireEvent.change(screen.getByPlaceholderText(/分析 600519/), {
target: { value: '分析 600519' },
});
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(mockStartStream).toHaveBeenCalledWith(
expect.objectContaining({
message: '分析 600519',
skills: ['bull_trend', 'ma_golden_cross'],
}),
expect.objectContaining({
skillName: '趋势分析、均线金叉',
}),
);
});
expect(screen.getByRole('button', { name: '展开策略选择' })).toHaveAttribute('aria-expanded', 'false');
expect(skillPanel).toHaveClass('hidden');
});
it('omits skills when all concrete skills are cleared', async () => {
render(
<MemoryRouter initialEntries={['/chat']}>

View File

@@ -40,6 +40,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- [文档] 补充 AlphaSift 迁移与回退边界:明确 `ALPHASIFT_INSTALL_SPEC` 显式覆盖语义、`requirements.txt + DEFAULT_ALPHASIFT_INSTALL_SPEC` 与运行时兼容边界、以及回滚路径(关闭功能/完整 revert说明覆盖旧 pin 用户升级行为。
- [新功能] 个股分析历史成功保存后会从最终报告 best-effort 提取 `DecisionSignal` 决策信号,复用现有信号去重、计划质量计算和脱敏契约。
- [改进] 问股页移动端策略选择改为默认收起的按钮入口,展开后仍可多选策略并在发送后自动收起,减少对对话内容的遮挡。
## [3.22.0] - 2026-06-13