@dsh-ft/dsh-fft-settings 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/lib/client.d.ts +8 -1
- package/lib/client.js +342 -33
- package/lib/client.js.map +1 -1
- package/lib/features/api-key/card.js +2 -2
- package/lib/features/api-key/contract.d.ts +13 -0
- package/lib/features/api-key/contract.js +13 -0
- package/lib/features/api-key/probe.d.ts +7 -1
- package/lib/features/api-key/probe.js +14 -8
- package/lib/features/free-key-notice/card.d.ts +53 -0
- package/lib/features/free-key-notice/card.js +137 -0
- package/lib/features/free-key-notice/decision.d.ts +40 -0
- package/lib/features/free-key-notice/decision.js +37 -0
- package/lib/features/free-key-notice/index.d.ts +7 -0
- package/lib/features/free-key-notice/index.js +10 -0
- package/lib/features/free-key-notice/storage.d.ts +34 -0
- package/lib/features/free-key-notice/storage.js +58 -0
- package/lib/features/free-key-notice/styles.d.ts +11 -0
- package/lib/features/free-key-notice/styles.js +27 -0
- package/lib/guidance.d.ts +6 -1
- package/lib/guidance.js +6 -1
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +62 -38
- package/src/client.tsx +10 -1
- package/src/features/api-key/card.tsx +2 -2
- package/src/features/api-key/contract.ts +13 -0
- package/src/features/api-key/probe.ts +17 -8
- package/src/features/free-key-notice/card.tsx +172 -0
- package/src/features/free-key-notice/decision.ts +47 -0
- package/src/features/free-key-notice/index.ts +30 -0
- package/src/features/free-key-notice/storage.ts +62 -0
- package/src/features/free-key-notice/styles.ts +27 -0
- package/src/guidance.ts +6 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useMemo, useState, useSyncExternalStore, type ReactNode } from 'react'
|
|
2
|
-
import { FTSHARE_API_KEY_REF, FTSHARE_CREDENTIAL_EVENTS, type SubscribeChanges, type ApiKeyPublicApi } from './contract.js'
|
|
2
|
+
import { FTSHARE_API_KEY_APPLY_URL, FTSHARE_API_KEY_REF, FTSHARE_CREDENTIAL_EVENTS, type SubscribeChanges, type ApiKeyPublicApi } from './contract.js'
|
|
3
3
|
import { FTSHARE_SETTINGS_CARD_SLOT } from '../../core/contract.js'
|
|
4
4
|
import { FtshareSettingsModel } from './model.js'
|
|
5
5
|
import { createApiKeyClient } from './client-api.js'
|
|
@@ -60,7 +60,7 @@ export function ApiKeyCard({ api, subscribeChanges }: { readonly api: ApiKeyPubl
|
|
|
60
60
|
<button type="button" onClick={() => void model.testConnection()} disabled={busy || !status}>测试当前连接</button>
|
|
61
61
|
<button type="button" onClick={() => void model.clear()} disabled={disabled || !status?.configured}>清除已保存密钥</button>
|
|
62
62
|
<button type="button" onClick={() => void model.refresh()} disabled={busy} title="重新读取已保存的配置和上次检测结果,不会发起联网检测">重新读取状态</button>
|
|
63
|
-
<a href=
|
|
63
|
+
<a href={FTSHARE_API_KEY_APPLY_URL} target="_blank" rel="noreferrer">获取 API Key ↗</a>
|
|
64
64
|
</div>
|
|
65
65
|
{draft && <small>“测试当前连接”使用已生效的密钥,不测试输入框内尚未保存的内容。</small>}
|
|
66
66
|
{busy && <p role="status">正在处理,请稍候…</p>}
|
|
@@ -8,6 +8,19 @@
|
|
|
8
8
|
* every consumer.
|
|
9
9
|
*/
|
|
10
10
|
export const FTSHARE_API_KEY_REF = 'FTSHARE_API_KEY' as const
|
|
11
|
+
/**
|
|
12
|
+
* 申请/购买 API Key 的页面。`utm_source=dsh-fft` 标记来源为本插件,站点据此归因;
|
|
13
|
+
* 卡片与一次性提示共用这一条地址,不要在别处另写一份。
|
|
14
|
+
*/
|
|
15
|
+
export const FTSHARE_API_KEY_APPLY_URL = 'https://ftai.chat/me/profile?tab=api-key&utm_source=dsh-fft' as const
|
|
16
|
+
/**
|
|
17
|
+
* 设备级「已看过免费 Key 提示」标记的 localStorage 键。
|
|
18
|
+
*
|
|
19
|
+
* 带版本号:改一次文案就把版本加一,让所有用户重新看一次,不需要让用户清缓存。
|
|
20
|
+
* 设备级语义(重启、升级都保留,跨 workspace/窗口共享)由桌面版主窗口的
|
|
21
|
+
* `dsh-app://app/` origin 私有存储提供,不写宿主的配置文件。
|
|
22
|
+
*/
|
|
23
|
+
export const FREE_KEY_NOTICE_STORAGE_KEY = 'dsh-fft.free-key-notice.v1' as const
|
|
11
24
|
export const FTSHARE_CREDENTIAL_EVENTS = ['credentials/reference-updated'] as const
|
|
12
25
|
export const API_KEY_METHODS = ['describe', 'set', 'clear', 'configureAndTest', 'testConnection', 'getValidationStatus'] as const
|
|
13
26
|
export type ApiKeyMethod = typeof API_KEY_METHODS[number]
|
|
@@ -47,12 +47,15 @@ async function readBoundedJson(response: Response): Promise<unknown> {
|
|
|
47
47
|
return JSON.parse(new TextDecoder().decode(bytes))
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/** 日志里的分项:远端服务检查与 Key 验证分开记,不因是否携带 Key 而错位。 */
|
|
51
|
+
type ProbeTarget = 'remote' | 'apiKey'
|
|
52
|
+
|
|
50
53
|
/**
|
|
51
54
|
* 一次固定探测。失败(发出过请求且没有通过)写一条 `warn`:
|
|
52
|
-
* 字段固定 `{ target, status, durationMs }
|
|
55
|
+
* 字段固定 `{ target, status, durationMs }`,`target` 由调用点显式给出;
|
|
53
56
|
* 未发请求的 `not_run` 不是探测失败,成功也不记(规格 §6/§7)。
|
|
54
57
|
*/
|
|
55
|
-
async function probe(path: string, params: Record<string, string>, key: string | undefined, parent: AbortSignal, fetcher: typeof fetch, now: number, logger?: FeatureLogger): Promise<ProbeResult> {
|
|
58
|
+
async function probe(target: ProbeTarget, path: string, params: Record<string, string>, key: string | undefined, parent: AbortSignal, fetcher: typeof fetch, now: number, logger?: FeatureLogger): Promise<ProbeResult> {
|
|
56
59
|
const url = new URL(path, ORIGIN)
|
|
57
60
|
url.search = new URLSearchParams(params).toString()
|
|
58
61
|
const timeout = AbortSignal.timeout(PROBE_TIMEOUT_MS)
|
|
@@ -60,7 +63,7 @@ async function probe(path: string, params: Record<string, string>, key: string |
|
|
|
60
63
|
const started = Date.now()
|
|
61
64
|
const finish = (outcome: ProbeResult): ProbeResult => {
|
|
62
65
|
if (outcome.status !== 'available' && outcome.status !== 'not_run') {
|
|
63
|
-
logger?.warn('FTShare 探测未通过', { target
|
|
66
|
+
logger?.warn('FTShare 探测未通过', { target, status: outcome.status, durationMs: Date.now() - started })
|
|
64
67
|
}
|
|
65
68
|
return outcome
|
|
66
69
|
}
|
|
@@ -84,7 +87,7 @@ async function probe(path: string, params: Record<string, string>, key: string |
|
|
|
84
87
|
if (!Array.isArray(payload.data)) return finish(result('invalid_response', '接口未返回 K 线数组'))
|
|
85
88
|
// This checks service/authentication success, not market-data availability.
|
|
86
89
|
// Empty holiday or post-close windows are successful responses too.
|
|
87
|
-
return finish(result('available',
|
|
90
|
+
return finish(result('available', target === 'remote' ? '远端服务正常' : 'API Key 验证通过'))
|
|
88
91
|
} catch {
|
|
89
92
|
if (parent.aborted) return finish(result('not_run', '测试已取消或凭据已变化'))
|
|
90
93
|
if (timeout.aborted) return finish(result('timeout', '连接超时,请稍后重试'))
|
|
@@ -92,12 +95,18 @@ async function probe(path: string, params: Record<string, string>, key: string |
|
|
|
92
95
|
}
|
|
93
96
|
}
|
|
94
97
|
|
|
95
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* Two fixed, bounded requests. No SDK, arbitrary URL, retries or stored rows.
|
|
100
|
+
*
|
|
101
|
+
* 配置了 Key 时两项检查都携带它:匿名额度按出口 IP 计,用尽后未携带 Key 的请求会被
|
|
102
|
+
* 网关以 429 拒绝,若远端检查仍走匿名就会把「Key 已配置且可用」误报成受限。未配置
|
|
103
|
+
* Key 时退回匿名远端检查(401/403 不能用来指控 Key)。
|
|
104
|
+
*/
|
|
96
105
|
export async function probeApiKey(key: string | undefined, signal: AbortSignal, fetcher: typeof fetch = fetch, now: number = Date.now(), logger?: FeatureLogger): Promise<ValidationResult> {
|
|
97
|
-
const remote = await probe(PROBE_CONTRACTS.daily, {
|
|
106
|
+
const remote = await probe('remote', PROBE_CONTRACTS.daily, {
|
|
98
107
|
symbol: '600519.SH', interval_unit: 'day', adjust_kind: 'none',
|
|
99
108
|
since_ts_millis: String(now - 30 * DAY), until_ts_millis: String(now), limit: '2',
|
|
100
|
-
},
|
|
109
|
+
}, key, signal, fetcher, now, logger)
|
|
101
110
|
let apiKey: ProbeResult
|
|
102
111
|
if (!key) {
|
|
103
112
|
apiKey = result('auth_required', '未配置 API Key')
|
|
@@ -106,7 +115,7 @@ export async function probeApiKey(key: string | undefined, signal: AbortSignal,
|
|
|
106
115
|
} else {
|
|
107
116
|
// The server counts inclusive Beijing calendar dates, not elapsed days.
|
|
108
117
|
// A 24-hour window covers at most two dates; empty data still verifies auth.
|
|
109
|
-
apiKey = await probe(PROBE_CONTRACTS.historicalMinute, {
|
|
118
|
+
apiKey = await probe('apiKey', PROBE_CONTRACTS.historicalMinute, {
|
|
110
119
|
symbol: '600519.SH', interval_value: '1', adjust_kind: 'none',
|
|
111
120
|
since_ts_millis: String(now - DAY), until_ts_millis: String(now), limit: '1',
|
|
112
121
|
}, key, signal, fetcher, now, logger)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 一次性提示:还没配置 API Key 的用户,去获取免费的 FTShare Key。
|
|
3
|
+
*
|
|
4
|
+
* 三个设计决定:
|
|
5
|
+
*
|
|
6
|
+
* 1. **左下角、不阻塞**。它是提示,不是必须处理的阻断:不盖遮罩、不抢焦点、
|
|
7
|
+
* 不打断正在发生的事,12 秒后自己淡出(鼠标悬停时暂停计时)。用 `Modal`
|
|
8
|
+
* 会遮住整个界面,代价太大。
|
|
9
|
+
* 2. **只在确认「没配 Key」时出现**。凭据状态异步读来,没读到时一律不出现,
|
|
10
|
+
* 否则配了 Key 的用户每次启动都会被闪一下。
|
|
11
|
+
* 3. **设备级只出现一次**。首次出现就写 `localStorage` 标记(见 `./storage.ts`),
|
|
12
|
+
* 不等用户点按钮——否则自动消失后标记不落盘,下次启动又会打扰。
|
|
13
|
+
*
|
|
14
|
+
* 挂载在宿主框架级的 `shell.overlay` 槽上(`ui-layout` 的 AppFrame 常驻渲染),
|
|
15
|
+
* 不在设置页里:要触达的恰恰是从不打开设置的用户。该图层本身可穿透点击,
|
|
16
|
+
* 条目只在自己的卡片范围内接收指针事件。
|
|
17
|
+
*/
|
|
18
|
+
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from 'react'
|
|
19
|
+
import { FTSHARE_API_KEY_APPLY_URL, FTSHARE_API_KEY_REF, FTSHARE_CREDENTIAL_EVENTS, FREE_KEY_NOTICE_STORAGE_KEY } from '../api-key/contract.js'
|
|
20
|
+
import type { ApiKeyCardContext } from '../api-key/card.js'
|
|
21
|
+
import { createApiKeyClient } from '../api-key/client-api.js'
|
|
22
|
+
import { createSettingsTransport } from '../../core/client-transport.js'
|
|
23
|
+
import { browserNoticeStorage, readNoticeSeen, writeNoticeSeen, type NoticeFlagStorage } from './storage.js'
|
|
24
|
+
import { FREE_KEY_NOTICE_ID, FREE_KEY_NOTICE_SLOT, isViewIdle, shouldShowNotice, type FreeKeyNoticeSessionState } from './decision.js'
|
|
25
|
+
import { FREE_KEY_NOTICE_CSS } from './styles.js'
|
|
26
|
+
|
|
27
|
+
/** 停留时长:足够读完一句话并决定要不要去获取,又不至于一直挂在界面上。 */
|
|
28
|
+
export const FREE_KEY_NOTICE_AUTO_DISMISS_MS = 12_000
|
|
29
|
+
|
|
30
|
+
export { FREE_KEY_NOTICE_ID, FREE_KEY_NOTICE_SLOT }
|
|
31
|
+
|
|
32
|
+
/** 提示组件依赖的外部输入;全部可注入,测试不需要真实网络与浏览器存储。 */
|
|
33
|
+
export interface FreeKeyNoticeDeps {
|
|
34
|
+
/** 读当前 Key 是否已配置;reject 表示读不到。 */
|
|
35
|
+
readConfigured: () => Promise<boolean>
|
|
36
|
+
/** 订阅「Key 可能变了」,配置好后立即收起。 */
|
|
37
|
+
subscribeConfigured: (listener: () => void) => () => void
|
|
38
|
+
storage: NoticeFlagStorage | undefined
|
|
39
|
+
applyUrl: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 把异步的 `configured` 摊成 uSES 快照。只在有订阅者时读一次,订阅清理时停止。
|
|
44
|
+
* @param deps - 读函数与订阅函数。
|
|
45
|
+
* @returns uSES 快照源。
|
|
46
|
+
*/
|
|
47
|
+
function useConfigured(deps: FreeKeyNoticeDeps): boolean | undefined {
|
|
48
|
+
const source = useMemo(() => {
|
|
49
|
+
let value: boolean | undefined
|
|
50
|
+
let stop: (() => void) | undefined
|
|
51
|
+
const listeners = new Set<() => void>()
|
|
52
|
+
const publish = () => { for (const listener of [...listeners]) listener() }
|
|
53
|
+
const load = () => {
|
|
54
|
+
deps.readConfigured().then(next => { value = next; publish() }, () => { /* 读失败保持 undefined */ })
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
getSnapshot: () => value,
|
|
58
|
+
subscribe: (listener: () => void) => {
|
|
59
|
+
listeners.add(listener)
|
|
60
|
+
if (stop === undefined) {
|
|
61
|
+
load()
|
|
62
|
+
const off = deps.subscribeConfigured(load)
|
|
63
|
+
stop = () => { off() }
|
|
64
|
+
}
|
|
65
|
+
return () => {
|
|
66
|
+
listeners.delete(listener)
|
|
67
|
+
if (listeners.size === 0) {
|
|
68
|
+
stop?.()
|
|
69
|
+
stop = undefined
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
}, [deps])
|
|
75
|
+
return useSyncExternalStore(source.subscribe, source.getSnapshot, source.getSnapshot)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 槽位提供的标准份额里,这里只用得到的这一个(由 ui-session 合并进所有槽位)。 */
|
|
79
|
+
type UseSessionsShare = (selector: (state: FreeKeyNoticeSessionState) => boolean) => boolean
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 提示本体。
|
|
83
|
+
* @param props - 依赖注入,以及宿主通过槽位交给每个条目的标准份额。
|
|
84
|
+
* @returns 左下角卡片,或 null。
|
|
85
|
+
*/
|
|
86
|
+
export function FreeKeyNotice({ deps, useSessions }: {
|
|
87
|
+
deps: FreeKeyNoticeDeps
|
|
88
|
+
useSessions: UseSessionsShare
|
|
89
|
+
}): ReactNode {
|
|
90
|
+
const [seen, setSeen] = useState(() => readNoticeSeen(deps.storage, FREE_KEY_NOTICE_STORAGE_KEY))
|
|
91
|
+
const [visible, setVisible] = useState(true)
|
|
92
|
+
const configured = useConfigured(deps)
|
|
93
|
+
const viewIdle = useSessions(isViewIdle)
|
|
94
|
+
const show = visible && shouldShowNotice({ seen, configured, viewIdle })
|
|
95
|
+
|
|
96
|
+
// 出现即记:自动消失或用户没点时也要落盘,不然下次启动又弹。
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (!show || seen) return
|
|
99
|
+
writeNoticeSeen(deps.storage, FREE_KEY_NOTICE_STORAGE_KEY)
|
|
100
|
+
setSeen(true)
|
|
101
|
+
}, [show, seen, deps.storage])
|
|
102
|
+
|
|
103
|
+
// 自动淡出。悬停时暂停,移开后按剩余时间继续,避免用户正要看就被收走。
|
|
104
|
+
const deadline = useRef<number | undefined>(undefined)
|
|
105
|
+
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
106
|
+
const dismiss = useCallback(() => { setVisible(false) }, [])
|
|
107
|
+
const hold = useCallback(() => {
|
|
108
|
+
if (timer.current !== undefined) clearTimeout(timer.current)
|
|
109
|
+
timer.current = undefined
|
|
110
|
+
deadline.current = undefined
|
|
111
|
+
}, [])
|
|
112
|
+
const resume = useCallback(() => {
|
|
113
|
+
if (deadline.current === undefined) return
|
|
114
|
+
const remaining = Math.max(0, deadline.current - Date.now())
|
|
115
|
+
timer.current = setTimeout(dismiss, remaining)
|
|
116
|
+
}, [dismiss])
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (!show) return
|
|
119
|
+
deadline.current = Date.now() + FREE_KEY_NOTICE_AUTO_DISMISS_MS
|
|
120
|
+
timer.current = setTimeout(dismiss, FREE_KEY_NOTICE_AUTO_DISMISS_MS)
|
|
121
|
+
return () => {
|
|
122
|
+
if (timer.current !== undefined) clearTimeout(timer.current)
|
|
123
|
+
timer.current = undefined
|
|
124
|
+
deadline.current = undefined
|
|
125
|
+
}
|
|
126
|
+
}, [show, dismiss])
|
|
127
|
+
|
|
128
|
+
if (!show) return null
|
|
129
|
+
|
|
130
|
+
return <>
|
|
131
|
+
<style>{FREE_KEY_NOTICE_CSS}</style>
|
|
132
|
+
<aside
|
|
133
|
+
className="ftshare-free-key-notice"
|
|
134
|
+
role="status"
|
|
135
|
+
aria-live="polite"
|
|
136
|
+
onMouseEnter={hold}
|
|
137
|
+
onMouseLeave={resume}
|
|
138
|
+
>
|
|
139
|
+
<div className="ftshare-free-key-head">
|
|
140
|
+
<strong>免费获取 Key</strong>
|
|
141
|
+
<button type="button" className="ftshare-free-key-close" aria-label="关闭" onClick={dismiss}>✕</button>
|
|
142
|
+
</div>
|
|
143
|
+
<p>海量金融数据,一次配置长期使用</p>
|
|
144
|
+
<div className="ftshare-free-key-actions">
|
|
145
|
+
<a className="primary" href={deps.applyUrl} target="_blank" rel="noreferrer" onClick={dismiss}>去获取</a>
|
|
146
|
+
<button type="button" onClick={dismiss}>知道了</button>
|
|
147
|
+
</div>
|
|
148
|
+
</aside>
|
|
149
|
+
</>
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 浏览器侧装配:把凭据通道、真实 localStorage 接到组件上。
|
|
154
|
+
* `useSessions` 是宿主交给 `shell.overlay` 条目的标准份额,从 props 透传而不是自造。
|
|
155
|
+
* @param ctx - 卡片那套上下文(slots + 凭据失效事件)。
|
|
156
|
+
* @returns 槽位组件工厂。
|
|
157
|
+
*/
|
|
158
|
+
export function createFreeKeyNotice(ctx: ApiKeyCardContext): (props: { useSessions: UseSessionsShare }) => ReactNode {
|
|
159
|
+
const api = createApiKeyClient(createSettingsTransport())
|
|
160
|
+
const subscribeConfigured = (listener: () => void) => {
|
|
161
|
+
const disposers = FTSHARE_CREDENTIAL_EVENTS.map(event => ctx.remote.$on(event, ref => { if (ref === FTSHARE_API_KEY_REF) listener() }))
|
|
162
|
+
disposers.push(ctx.on('connection/reset', listener))
|
|
163
|
+
return () => { for (const dispose of disposers) dispose() }
|
|
164
|
+
}
|
|
165
|
+
const deps: FreeKeyNoticeDeps = {
|
|
166
|
+
readConfigured: async () => (await api.describe()).configured,
|
|
167
|
+
subscribeConfigured,
|
|
168
|
+
storage: browserNoticeStorage(),
|
|
169
|
+
applyUrl: FTSHARE_API_KEY_APPLY_URL,
|
|
170
|
+
}
|
|
171
|
+
return ({ useSessions }) => <FreeKeyNotice deps={deps} useSessions={useSessions} />
|
|
172
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「这条一次性提示该不该弹」的判定。
|
|
3
|
+
*
|
|
4
|
+
* 与渲染分开:判定是纯函数、零依赖,能在 Node 测试里直接钉住;渲染层只管把
|
|
5
|
+
* 模态框摆出去。三条输入里任何一条没准备好都按「不弹」处理——这是刻意的,
|
|
6
|
+
* 一次没弹只是少一次提醒,弹错一次就是打扰。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** 宿主 `shell.overlay` 槽名;这个 feature 只往这一个座位贡献。 */
|
|
10
|
+
export const FREE_KEY_NOTICE_SLOT = 'shell.overlay' as const
|
|
11
|
+
/** 条目 id:必须自己的,占用宿主的会顶掉别人的浮层。 */
|
|
12
|
+
export const FREE_KEY_NOTICE_ID = 'ftshare.free-key-notice' as const
|
|
13
|
+
|
|
14
|
+
/** 提示出现前需要的会话事实,只取用得到的字段。 */
|
|
15
|
+
export interface FreeKeyNoticeSessionState {
|
|
16
|
+
phase: string
|
|
17
|
+
byId: Record<string, { blank: boolean; retainedBy?: { mainView?: number } }>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 主视图是否停在一个空会话上(没有内容会被弹窗挡住)。
|
|
22
|
+
* @param state - 会话列表快照。
|
|
23
|
+
* @returns 是否可以安全地弹出模态框。
|
|
24
|
+
*/
|
|
25
|
+
export function isViewIdle(state: FreeKeyNoticeSessionState): boolean {
|
|
26
|
+
if (state.phase !== 'ready') return false
|
|
27
|
+
const main = Object.values(state.byId).find(session => (session.retainedBy?.mainView ?? 0) > 0)
|
|
28
|
+
return main === undefined || main.blank
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 是否应当显示提示。
|
|
33
|
+
* - 已看过:不弹(设备级一次);
|
|
34
|
+
* - Key 已配置、或状态还读不到:不弹(读不到时宁可少弹);
|
|
35
|
+
* - 主视图被有内容的会话占着:不弹,等它空下来。
|
|
36
|
+
* @param facts - 三个输入。
|
|
37
|
+
* @returns 是否显示。
|
|
38
|
+
*/
|
|
39
|
+
export function shouldShowNotice(facts: {
|
|
40
|
+
seen: boolean
|
|
41
|
+
configured: boolean | undefined
|
|
42
|
+
viewIdle: boolean
|
|
43
|
+
}): boolean {
|
|
44
|
+
if (facts.seen) return false
|
|
45
|
+
if (facts.configured !== false) return false
|
|
46
|
+
return facts.viewIdle
|
|
47
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 一次性提示的注册入口。
|
|
3
|
+
*
|
|
4
|
+
* 它和设置卡片走同一个 `ctx.slots`,但目标是框架级的 `shell.overlay`——
|
|
5
|
+
* 宿主给「徽标、toast、状态条、对话框」这类全局浮层留的加法槽(`kind: list`,
|
|
6
|
+
* `scope: root`),`ui-layout` 的 AppFrame 常驻渲染它,不依赖设置面板是否打开。
|
|
7
|
+
*
|
|
8
|
+
* 两条注册纪律来自宿主的槽位契约:
|
|
9
|
+
* 1. `ctx.slots.inject(key, ...)` 等到槽位被声明再注册——启动早期 `shell.overlay`
|
|
10
|
+
* 可能还不存在,直接 `register` 会抛。
|
|
11
|
+
* 2. `id` 必须自己的。槽位按 id 去重,复用宿主的 id 会顶掉别人的浮层。
|
|
12
|
+
*/
|
|
13
|
+
import type { ReactNode } from 'react'
|
|
14
|
+
// Type-only: pulls `shell.overlay` 的 SlotMap 声明(含 `useSessions` 这类标准份额)进本程序。
|
|
15
|
+
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
|
16
|
+
import type { ApiKeyCardContext } from '../api-key/card.js'
|
|
17
|
+
import { createFreeKeyNotice, FREE_KEY_NOTICE_ID, FREE_KEY_NOTICE_SLOT } from './card.js'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 注册一次性提示。
|
|
21
|
+
* @param ctx - 客户端上下文,需带 `slots` 服务。
|
|
22
|
+
* @returns 幂等 disposer。
|
|
23
|
+
*/
|
|
24
|
+
export function registerFreeKeyNotice(ctx: ApiKeyCardContext): () => void {
|
|
25
|
+
const component = createFreeKeyNotice(ctx)
|
|
26
|
+
return ctx.slots.inject(FREE_KEY_NOTICE_SLOT, () => ctx.slots.register(
|
|
27
|
+
{ name: FREE_KEY_NOTICE_SLOT, id: FREE_KEY_NOTICE_ID, order: 100 },
|
|
28
|
+
component,
|
|
29
|
+
))
|
|
30
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「看过免费 Key 提示」的设备级标记。
|
|
3
|
+
*
|
|
4
|
+
* 只有一个事实(看过/没看过),所以不建服务、不建 store:读一次、写一次,
|
|
5
|
+
* 两个纯函数加一个最小的存储接口。存储接口只声明用到的那两个方法,测试可以传
|
|
6
|
+
* 一个内存实现,浏览器里传 `window.localStorage`。
|
|
7
|
+
*
|
|
8
|
+
* 读失败一律按「没看过」处理:隐私模式或存储被禁用时 `localStorage` 会抛异常,
|
|
9
|
+
* 这时提示最多多弹一次,比整个设置页崩掉可接受。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** 这一层用到的 `localStorage` 子集;隔离出来是为了让测试不碰真实存储。 */
|
|
13
|
+
export interface NoticeFlagStorage {
|
|
14
|
+
getItem(key: string): string | null
|
|
15
|
+
setItem(key: string, value: string): void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 取浏览器存储;拿不到(没有 window、被禁用)时返回 undefined。
|
|
20
|
+
* @returns 可用存储,或 undefined。
|
|
21
|
+
*/
|
|
22
|
+
export function browserNoticeStorage(): NoticeFlagStorage | undefined {
|
|
23
|
+
try {
|
|
24
|
+
const storage = globalThis.localStorage
|
|
25
|
+
return storage === undefined ? undefined : { getItem: key => storage.getItem(key), setItem: (key, value) => { storage.setItem(key, value) } }
|
|
26
|
+
} catch (_blocked) {
|
|
27
|
+
// 访问 `localStorage` 本身在受限环境会抛 SecurityError。
|
|
28
|
+
return undefined
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 读标记。任何异常与空值都算「没看过」。
|
|
34
|
+
* @param storage - 目标存储;缺失视为没看过。
|
|
35
|
+
* @param key - 标记键。
|
|
36
|
+
* @returns 是否已看过。
|
|
37
|
+
*/
|
|
38
|
+
export function readNoticeSeen(storage: NoticeFlagStorage | undefined, key: string): boolean {
|
|
39
|
+
if (storage === undefined) return false
|
|
40
|
+
try {
|
|
41
|
+
return storage.getItem(key) === '1'
|
|
42
|
+
} catch (_blocked) {
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 写标记。写失败不抛:提示已经显示过了,这次没记下来只是下次会再弹一次。
|
|
49
|
+
* @param storage - 目标存储;缺失时什么都不做。
|
|
50
|
+
* @param key - 标记键。
|
|
51
|
+
* @returns 是否写入成功。
|
|
52
|
+
*/
|
|
53
|
+
export function writeNoticeSeen(storage: NoticeFlagStorage | undefined, key: string): boolean {
|
|
54
|
+
if (storage === undefined) return false
|
|
55
|
+
try {
|
|
56
|
+
storage.setItem(key, '1')
|
|
57
|
+
return true
|
|
58
|
+
} catch (_blocked) {
|
|
59
|
+
// 配额满或被禁用:不阻断用户,下次再弹。
|
|
60
|
+
return false
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 一次性提示的样式:固定在左下角的非阻塞卡片。
|
|
3
|
+
*
|
|
4
|
+
* 不盖遮罩、不抢焦点——这一层(宿主的 `shell.overlay`)本身可穿透点击,
|
|
5
|
+
* 条目只有在这张卡片范围内才接收指针事件,所以卡片以外的界面照常可用。
|
|
6
|
+
* 位置用 `fixed` 而不是依赖外层布局:浮层是 `inset:0` 的绝对定位层,
|
|
7
|
+
* 条目自己决定停在哪里。
|
|
8
|
+
*
|
|
9
|
+
* 颜色全走 `--dsw-alias-*` 与 `--ft-*` 变量,深色/浅色两套调色板共用一份。
|
|
10
|
+
*/
|
|
11
|
+
export const FREE_KEY_NOTICE_CSS = `
|
|
12
|
+
.ftshare-free-key-notice { position:fixed; left:24px; bottom:24px; z-index:20; box-sizing:border-box; width:min(320px, calc(100vw - 48px)); padding:16px; border:0.5px solid var(--dsw-alias-border-l2,rgba(0,0,0,.1)); border-radius:var(--dsw-radius-xl,20px); background:var(--dsw-alias-bg-layer-2,#fff); color:var(--dsw-alias-label-primary,#0f1115); box-shadow:var(--dsw-elevation-prominent,0 3px 8px rgba(0,0,0,.04), 0 0 20px rgba(0,0,0,.05)); animation:ftshareNoticeIn .2s ease; }
|
|
13
|
+
.ftshare-free-key-notice * { box-sizing:border-box; }
|
|
14
|
+
.ftshare-free-key-notice .ftshare-free-key-head { display:flex; align-items:flex-start; justify-content:space-between; gap:8px; }
|
|
15
|
+
.ftshare-free-key-notice strong { font-size:14px; font-weight:500; line-height:22px; }
|
|
16
|
+
.ftshare-free-key-notice .ftshare-free-key-close { flex:none; width:22px; height:22px; display:inline-flex; align-items:center; justify-content:center; border:none; border-radius:var(--dsw-radius-sm,8px); background:transparent; color:var(--dsw-alias-label-secondary,#61666b); font:inherit; font-size:12px; line-height:1; cursor:pointer; }
|
|
17
|
+
.ftshare-free-key-notice .ftshare-free-key-close:hover { background:var(--dsw-alias-bg-layer-3,#f9fafb); }
|
|
18
|
+
.ftshare-free-key-notice p { margin:6px 0 14px; font-size:13px; line-height:1.6; color:var(--dsw-alias-label-secondary,#61666b); }
|
|
19
|
+
.ftshare-free-key-notice .ftshare-free-key-actions { display:flex; gap:8px; }
|
|
20
|
+
.ftshare-free-key-notice .ftshare-free-key-actions :is(button,a) { min-height:30px; display:inline-flex; align-items:center; justify-content:center; border-radius:var(--dsw-radius-md,12px); border:0.5px solid var(--dsw-alias-border-l4,rgba(0,0,0,.16)); padding:0 12px; font:inherit; font-size:13px; background:transparent; color:inherit; text-decoration:none; cursor:pointer; }
|
|
21
|
+
/* 主入口用品牌黄的半透明底:保留 FT 的色,又不至于在左下角变成一块醒目的亮黄。
|
|
22
|
+
文字仍取正文主色(不是压在上面的深黑字),70% 在深浅两套调色板下都能读。 */
|
|
23
|
+
.ftshare-free-key-notice .ftshare-free-key-actions .primary { flex:1; background:color-mix(in srgb,var(--ft-accent,#f0c433) 70%,transparent); border-color:var(--ft-accent,#f0c433); color:var(--dsw-alias-label-primary,#0f1115); font-weight:500; }
|
|
24
|
+
.ftshare-free-key-notice .ftshare-free-key-actions .primary:hover { background:color-mix(in srgb,var(--ft-accent,#f0c433) 82%,transparent); }
|
|
25
|
+
@keyframes ftshareNoticeIn { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:none; } }
|
|
26
|
+
@media (prefers-reduced-motion: reduce) { .ftshare-free-key-notice { animation:none; } }
|
|
27
|
+
`
|
package/src/guidance.ts
CHANGED
|
@@ -45,7 +45,12 @@ export const FT_SECTION_ORDERS = { services: 6, dataPolicy: 7 } as const
|
|
|
45
45
|
/** Order of the credential fact — `getContextOrder` owns only three named positions. */
|
|
46
46
|
export const FT_CREDENTIALS_ORDER = 130
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
/**
|
|
49
|
+
* The capability list. It may only name tools this base actually ships or that
|
|
50
|
+
* the FT preset actually mounts: a promise about a tool that is not installed
|
|
51
|
+
* sends the model hunting for something that does not exist.
|
|
52
|
+
*/
|
|
53
|
+
export const FT_SERVICES_TEXT = '行情、K线与指标用 dsh-kline 工具(代码先用 search_symbols 解析);FTShare 数据用 ftshare_* 工具,可用接口查 ftshare_status;FTShare API Key 在设置页维护。'
|
|
49
54
|
|
|
50
55
|
export const FT_DATA_POLICY_TEXT = '金融数据优先取 FTShare,其次才用联网检索补充;引用必须给出处与时点;严格区分已披露事实、机构判断与待验证信息;不编造数字,也不构成投资建议。'
|
|
51
56
|
|