feat(kline): add configurable external stock link

设置页新增「个股详情外链」: 可填 URL 模板(占位符 {code}/{market}/{symbol}),
信息条第一行右侧显示外链图标(新标签打开)。留空关闭。

- 新建 lib/stock-external-link.ts: load/save/build + scheme 白名单(http/https) + symbol 形状守卫
- storage.ts 新增 stockExternalTemplate kv

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
richard
2026-09-02 13:12:42 +08:00
co-authored by Claude
parent 0f7c1dfe39
commit 3258325daa
4 changed files with 82 additions and 3 deletions
+16 -2
View File
@@ -1,10 +1,11 @@
import { useState, type ReactNode } from 'react'
import { Settings2, RadioTower, Star } from 'lucide-react'
import { Settings2, RadioTower, Star, ExternalLink } from 'lucide-react'
import type { KlineRow, FinancialMetricRecord } from '@/lib/api'
import { fmtPrice, fmtBigNum, fmtVolume } from '@/lib/format'
import { ListColumnCustomizer } from '@/components/ListColumnCustomizer'
import { WatchlistAddMenu } from '@/components/WatchlistAddMenu'
import { INFO_GROUPS, type ColumnConfig } from '@/lib/stock-info-fields'
import { buildStockExternalUrl, loadStockExternalTemplate } from '@/lib/stock-external-link'
const BULL = '#C74040'
const BEAR = '#2D9B65'
@@ -235,6 +236,8 @@ export function StockInfoBar({
)
}
const extUrl = buildStockExternalUrl(loadStockExternalTemplate(), symbol)
return (
<div className="px-2 pb-3 font-mono text-[12px] select-none space-y-1">
{/* Row 1: code, name, price, change, change% */}
@@ -250,8 +253,19 @@ export function StockInfoBar({
<span style={{ color: clr }} className="tabular-nums">
{isUp ? '+' : ''}{fmtPrice(chgPct)}%
</span>
{/* 右侧操作按钮:加自选 + 加监控 + 信息条配置 */}
{/* 右侧操作按钮:外链 + 加自选 + 加监控 + 信息条配置 */}
<div className="ml-auto self-center flex items-center gap-1">
{extUrl && (
<a
href={extUrl}
target="_blank"
rel="noopener noreferrer"
title={extUrl}
className="p-1 rounded-btn text-muted hover:text-foreground hover:bg-elevated transition-colors"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
)}
{inWatchlist && onRemoveFromWatchlist ? (
<button
type="button"
+36
View File
@@ -0,0 +1,36 @@
/**
* 个股详情外链 — 可配置 URL 模板 + 证券代码。
*
* 占位符保持独立, 用户自行排列 (市场前缀/后缀因站而异):
* {code} 纯 6 位代码, 如 000001
* {market} 市场前缀小写, 如 sz / sh / bj
* {symbol} 完整 symbol (含交易所后缀), 如 000001.SZ
* 模板留空 = 关闭外链。
*/
import { storage } from '@/lib/storage'
/** 形状守卫: 只放行 6位数字 + 沪深北后缀 的 symbol (信息条场景下即股票) */
const STOCK_SYMBOL_RE = /^(\d{6})\.(SH|SZ|BJ)$/
/** 未设置或留空 → 返回 '' 即关闭外链 */
export function loadStockExternalTemplate(): string {
return storage.stockExternalTemplate.get('')
}
export function saveStockExternalTemplate(tpl: string): void {
storage.stockExternalTemplate.set(tpl.trim())
}
export function buildStockExternalUrl(template: string, symbol: string): string | null {
if (!template) return null
// scheme 白名单: 只放行 http/https, 挡掉 javascript:/data: 等危险 scheme
if (!/^https?:\/\//i.test(template.trim())) return null
const m = symbol.match(STOCK_SYMBOL_RE)
if (!m) return null
const code = m[1]
const market = m[2].toLowerCase()
return template
.replaceAll('{code}', code)
.replaceAll('{market}', market)
.replaceAll('{symbol}', symbol)
}
+3
View File
@@ -44,6 +44,9 @@ export const storage = {
/** 个股详情多日分时周期 */
stockPreviewIntradayDays: kv<number>('stock_preview_intraday_days'),
/** 个股详情外链 URL 模板 (支持 {code}/{market}/{symbol}; 留空关闭) */
stockExternalTemplate: kv<string>('stock_external_template'),
/** 策略结果列表列配置 */
screenerResultColumns: kv<unknown[]>('screener_result_columns'),
+27 -1
View File
@@ -5,7 +5,7 @@
*/
import { useState, useCallback, useEffect } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { Settings2, Trash2, RefreshCw, Bell, Volume2, Info } from 'lucide-react'
import { Settings2, Trash2, RefreshCw, Bell, Volume2, Info, ExternalLink } from 'lucide-react'
import { usePreferences, useVersion } from '@/lib/useSharedQueries'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
@@ -15,6 +15,7 @@ import { SOUND_OPTIONS, previewSound } from '@/lib/notificationSound'
import {
listZhVoices, previewVoice, activateVoice, getCurrentVoiceURI,
} from '@/lib/voiceBroadcast'
import { loadStockExternalTemplate, saveStockExternalTemplate } from '@/lib/stock-external-link'
export function SettingsSystemPanel() {
const qc = useQueryClient()
@@ -23,6 +24,7 @@ export function SettingsSystemPanel() {
const [saving, setSaving] = useState(false)
const screenerAutoRun = prefs?.screener_auto_run ?? true
const [extTpl, setExtTpl] = useState(() => loadStockExternalTemplate())
const [clearing, setClearing] = useState(false)
const [toastEnabled, setToastEnabled] = useState(() => {
try { return localStorage.getItem('alert_toast_enabled') !== '0' } catch { return true }
@@ -288,6 +290,30 @@ export function SettingsSystemPanel() {
</div>
</section>
<section className="rounded-card border border-border bg-surface p-5 mt-6">
<div className="flex items-center gap-2 mb-4">
<ExternalLink className="h-4 w-4 text-accent" />
<h3 className="text-sm font-medium text-foreground"></h3>
</div>
<div className="flex items-center justify-between gap-4 py-2">
<div className="min-w-0">
<div className="text-sm text-foreground"> URL </div>
<div className="text-[11px] text-muted truncate">{"支持 {code} {market} {symbol} · 留空关闭外链"}</div>
</div>
<input
value={extTpl}
onChange={(e) => {
setExtTpl(e.target.value)
saveStockExternalTemplate(e.target.value)
}}
placeholder="https://..."
spellCheck={false}
className="w-[26rem] max-w-[60%] h-8 px-2.5 rounded-btn border border-border bg-base text-xs font-mono text-foreground focus:border-accent/50 focus:outline-none"
/>
</div>
</section>
<section className="rounded-card border border-border bg-surface p-5 mt-6">
<div className="flex items-center gap-2 mb-4">
<Trash2 className="h-4 w-4 text-accent" />