@dsh-ft/dsh-fft-settings 0.5.1 → 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/lib/client.d.ts +8 -1
- package/lib/client.js +327 -2
- 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/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/package.json +3 -1
- 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/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
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* 一次性提示:还没配置 API Key 的用户,去获取免费的 FTShare Key。
|
|
4
|
+
*
|
|
5
|
+
* 三个设计决定:
|
|
6
|
+
*
|
|
7
|
+
* 1. **左下角、不阻塞**。它是提示,不是必须处理的阻断:不盖遮罩、不抢焦点、
|
|
8
|
+
* 不打断正在发生的事,12 秒后自己淡出(鼠标悬停时暂停计时)。用 `Modal`
|
|
9
|
+
* 会遮住整个界面,代价太大。
|
|
10
|
+
* 2. **只在确认「没配 Key」时出现**。凭据状态异步读来,没读到时一律不出现,
|
|
11
|
+
* 否则配了 Key 的用户每次启动都会被闪一下。
|
|
12
|
+
* 3. **设备级只出现一次**。首次出现就写 `localStorage` 标记(见 `./storage.ts`),
|
|
13
|
+
* 不等用户点按钮——否则自动消失后标记不落盘,下次启动又会打扰。
|
|
14
|
+
*
|
|
15
|
+
* 挂载在宿主框架级的 `shell.overlay` 槽上(`ui-layout` 的 AppFrame 常驻渲染),
|
|
16
|
+
* 不在设置页里:要触达的恰恰是从不打开设置的用户。该图层本身可穿透点击,
|
|
17
|
+
* 条目只在自己的卡片范围内接收指针事件。
|
|
18
|
+
*/
|
|
19
|
+
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
20
|
+
import { FTSHARE_API_KEY_APPLY_URL, FTSHARE_API_KEY_REF, FTSHARE_CREDENTIAL_EVENTS, FREE_KEY_NOTICE_STORAGE_KEY } from '../api-key/contract.js';
|
|
21
|
+
import { createApiKeyClient } from '../api-key/client-api.js';
|
|
22
|
+
import { createSettingsTransport } from '../../core/client-transport.js';
|
|
23
|
+
import { browserNoticeStorage, readNoticeSeen, writeNoticeSeen } from './storage.js';
|
|
24
|
+
import { FREE_KEY_NOTICE_ID, FREE_KEY_NOTICE_SLOT, isViewIdle, shouldShowNotice } from './decision.js';
|
|
25
|
+
import { FREE_KEY_NOTICE_CSS } from './styles.js';
|
|
26
|
+
/** 停留时长:足够读完一句话并决定要不要去获取,又不至于一直挂在界面上。 */
|
|
27
|
+
export const FREE_KEY_NOTICE_AUTO_DISMISS_MS = 12_000;
|
|
28
|
+
export { FREE_KEY_NOTICE_ID, FREE_KEY_NOTICE_SLOT };
|
|
29
|
+
/**
|
|
30
|
+
* 把异步的 `configured` 摊成 uSES 快照。只在有订阅者时读一次,订阅清理时停止。
|
|
31
|
+
* @param deps - 读函数与订阅函数。
|
|
32
|
+
* @returns uSES 快照源。
|
|
33
|
+
*/
|
|
34
|
+
function useConfigured(deps) {
|
|
35
|
+
const source = useMemo(() => {
|
|
36
|
+
let value;
|
|
37
|
+
let stop;
|
|
38
|
+
const listeners = new Set();
|
|
39
|
+
const publish = () => { for (const listener of [...listeners])
|
|
40
|
+
listener(); };
|
|
41
|
+
const load = () => {
|
|
42
|
+
deps.readConfigured().then(next => { value = next; publish(); }, () => { });
|
|
43
|
+
};
|
|
44
|
+
return {
|
|
45
|
+
getSnapshot: () => value,
|
|
46
|
+
subscribe: (listener) => {
|
|
47
|
+
listeners.add(listener);
|
|
48
|
+
if (stop === undefined) {
|
|
49
|
+
load();
|
|
50
|
+
const off = deps.subscribeConfigured(load);
|
|
51
|
+
stop = () => { off(); };
|
|
52
|
+
}
|
|
53
|
+
return () => {
|
|
54
|
+
listeners.delete(listener);
|
|
55
|
+
if (listeners.size === 0) {
|
|
56
|
+
stop?.();
|
|
57
|
+
stop = undefined;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}, [deps]);
|
|
63
|
+
return useSyncExternalStore(source.subscribe, source.getSnapshot, source.getSnapshot);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* 提示本体。
|
|
67
|
+
* @param props - 依赖注入,以及宿主通过槽位交给每个条目的标准份额。
|
|
68
|
+
* @returns 左下角卡片,或 null。
|
|
69
|
+
*/
|
|
70
|
+
export function FreeKeyNotice({ deps, useSessions }) {
|
|
71
|
+
const [seen, setSeen] = useState(() => readNoticeSeen(deps.storage, FREE_KEY_NOTICE_STORAGE_KEY));
|
|
72
|
+
const [visible, setVisible] = useState(true);
|
|
73
|
+
const configured = useConfigured(deps);
|
|
74
|
+
const viewIdle = useSessions(isViewIdle);
|
|
75
|
+
const show = visible && shouldShowNotice({ seen, configured, viewIdle });
|
|
76
|
+
// 出现即记:自动消失或用户没点时也要落盘,不然下次启动又弹。
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (!show || seen)
|
|
79
|
+
return;
|
|
80
|
+
writeNoticeSeen(deps.storage, FREE_KEY_NOTICE_STORAGE_KEY);
|
|
81
|
+
setSeen(true);
|
|
82
|
+
}, [show, seen, deps.storage]);
|
|
83
|
+
// 自动淡出。悬停时暂停,移开后按剩余时间继续,避免用户正要看就被收走。
|
|
84
|
+
const deadline = useRef(undefined);
|
|
85
|
+
const timer = useRef(undefined);
|
|
86
|
+
const dismiss = useCallback(() => { setVisible(false); }, []);
|
|
87
|
+
const hold = useCallback(() => {
|
|
88
|
+
if (timer.current !== undefined)
|
|
89
|
+
clearTimeout(timer.current);
|
|
90
|
+
timer.current = undefined;
|
|
91
|
+
deadline.current = undefined;
|
|
92
|
+
}, []);
|
|
93
|
+
const resume = useCallback(() => {
|
|
94
|
+
if (deadline.current === undefined)
|
|
95
|
+
return;
|
|
96
|
+
const remaining = Math.max(0, deadline.current - Date.now());
|
|
97
|
+
timer.current = setTimeout(dismiss, remaining);
|
|
98
|
+
}, [dismiss]);
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (!show)
|
|
101
|
+
return;
|
|
102
|
+
deadline.current = Date.now() + FREE_KEY_NOTICE_AUTO_DISMISS_MS;
|
|
103
|
+
timer.current = setTimeout(dismiss, FREE_KEY_NOTICE_AUTO_DISMISS_MS);
|
|
104
|
+
return () => {
|
|
105
|
+
if (timer.current !== undefined)
|
|
106
|
+
clearTimeout(timer.current);
|
|
107
|
+
timer.current = undefined;
|
|
108
|
+
deadline.current = undefined;
|
|
109
|
+
};
|
|
110
|
+
}, [show, dismiss]);
|
|
111
|
+
if (!show)
|
|
112
|
+
return null;
|
|
113
|
+
return _jsxs(_Fragment, { children: [_jsx("style", { children: FREE_KEY_NOTICE_CSS }), _jsxs("aside", { className: "ftshare-free-key-notice", role: "status", "aria-live": "polite", onMouseEnter: hold, onMouseLeave: resume, children: [_jsxs("div", { className: "ftshare-free-key-head", children: [_jsx("strong", { children: "\u514D\u8D39\u83B7\u53D6 Key" }), _jsx("button", { type: "button", className: "ftshare-free-key-close", "aria-label": "\u5173\u95ED", onClick: dismiss, children: "\u2715" })] }), _jsx("p", { children: "\u6D77\u91CF\u91D1\u878D\u6570\u636E\uFF0C\u4E00\u6B21\u914D\u7F6E\u957F\u671F\u4F7F\u7528" }), _jsxs("div", { className: "ftshare-free-key-actions", children: [_jsx("a", { className: "primary", href: deps.applyUrl, target: "_blank", rel: "noreferrer", onClick: dismiss, children: "\u53BB\u83B7\u53D6" }), _jsx("button", { type: "button", onClick: dismiss, children: "\u77E5\u9053\u4E86" })] })] })] });
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* 浏览器侧装配:把凭据通道、真实 localStorage 接到组件上。
|
|
117
|
+
* `useSessions` 是宿主交给 `shell.overlay` 条目的标准份额,从 props 透传而不是自造。
|
|
118
|
+
* @param ctx - 卡片那套上下文(slots + 凭据失效事件)。
|
|
119
|
+
* @returns 槽位组件工厂。
|
|
120
|
+
*/
|
|
121
|
+
export function createFreeKeyNotice(ctx) {
|
|
122
|
+
const api = createApiKeyClient(createSettingsTransport());
|
|
123
|
+
const subscribeConfigured = (listener) => {
|
|
124
|
+
const disposers = FTSHARE_CREDENTIAL_EVENTS.map(event => ctx.remote.$on(event, ref => { if (ref === FTSHARE_API_KEY_REF)
|
|
125
|
+
listener(); }));
|
|
126
|
+
disposers.push(ctx.on('connection/reset', listener));
|
|
127
|
+
return () => { for (const dispose of disposers)
|
|
128
|
+
dispose(); };
|
|
129
|
+
};
|
|
130
|
+
const deps = {
|
|
131
|
+
readConfigured: async () => (await api.describe()).configured,
|
|
132
|
+
subscribeConfigured,
|
|
133
|
+
storage: browserNoticeStorage(),
|
|
134
|
+
applyUrl: FTSHARE_API_KEY_APPLY_URL,
|
|
135
|
+
};
|
|
136
|
+
return ({ useSessions }) => _jsx(FreeKeyNotice, { deps: deps, useSessions: useSessions });
|
|
137
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「这条一次性提示该不该弹」的判定。
|
|
3
|
+
*
|
|
4
|
+
* 与渲染分开:判定是纯函数、零依赖,能在 Node 测试里直接钉住;渲染层只管把
|
|
5
|
+
* 模态框摆出去。三条输入里任何一条没准备好都按「不弹」处理——这是刻意的,
|
|
6
|
+
* 一次没弹只是少一次提醒,弹错一次就是打扰。
|
|
7
|
+
*/
|
|
8
|
+
/** 宿主 `shell.overlay` 槽名;这个 feature 只往这一个座位贡献。 */
|
|
9
|
+
export declare const FREE_KEY_NOTICE_SLOT: "shell.overlay";
|
|
10
|
+
/** 条目 id:必须自己的,占用宿主的会顶掉别人的浮层。 */
|
|
11
|
+
export declare const FREE_KEY_NOTICE_ID: "ftshare.free-key-notice";
|
|
12
|
+
/** 提示出现前需要的会话事实,只取用得到的字段。 */
|
|
13
|
+
export interface FreeKeyNoticeSessionState {
|
|
14
|
+
phase: string;
|
|
15
|
+
byId: Record<string, {
|
|
16
|
+
blank: boolean;
|
|
17
|
+
retainedBy?: {
|
|
18
|
+
mainView?: number;
|
|
19
|
+
};
|
|
20
|
+
}>;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 主视图是否停在一个空会话上(没有内容会被弹窗挡住)。
|
|
24
|
+
* @param state - 会话列表快照。
|
|
25
|
+
* @returns 是否可以安全地弹出模态框。
|
|
26
|
+
*/
|
|
27
|
+
export declare function isViewIdle(state: FreeKeyNoticeSessionState): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* 是否应当显示提示。
|
|
30
|
+
* - 已看过:不弹(设备级一次);
|
|
31
|
+
* - Key 已配置、或状态还读不到:不弹(读不到时宁可少弹);
|
|
32
|
+
* - 主视图被有内容的会话占着:不弹,等它空下来。
|
|
33
|
+
* @param facts - 三个输入。
|
|
34
|
+
* @returns 是否显示。
|
|
35
|
+
*/
|
|
36
|
+
export declare function shouldShowNotice(facts: {
|
|
37
|
+
seen: boolean;
|
|
38
|
+
configured: boolean | undefined;
|
|
39
|
+
viewIdle: boolean;
|
|
40
|
+
}): boolean;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「这条一次性提示该不该弹」的判定。
|
|
3
|
+
*
|
|
4
|
+
* 与渲染分开:判定是纯函数、零依赖,能在 Node 测试里直接钉住;渲染层只管把
|
|
5
|
+
* 模态框摆出去。三条输入里任何一条没准备好都按「不弹」处理——这是刻意的,
|
|
6
|
+
* 一次没弹只是少一次提醒,弹错一次就是打扰。
|
|
7
|
+
*/
|
|
8
|
+
/** 宿主 `shell.overlay` 槽名;这个 feature 只往这一个座位贡献。 */
|
|
9
|
+
export const FREE_KEY_NOTICE_SLOT = 'shell.overlay';
|
|
10
|
+
/** 条目 id:必须自己的,占用宿主的会顶掉别人的浮层。 */
|
|
11
|
+
export const FREE_KEY_NOTICE_ID = 'ftshare.free-key-notice';
|
|
12
|
+
/**
|
|
13
|
+
* 主视图是否停在一个空会话上(没有内容会被弹窗挡住)。
|
|
14
|
+
* @param state - 会话列表快照。
|
|
15
|
+
* @returns 是否可以安全地弹出模态框。
|
|
16
|
+
*/
|
|
17
|
+
export function isViewIdle(state) {
|
|
18
|
+
if (state.phase !== 'ready')
|
|
19
|
+
return false;
|
|
20
|
+
const main = Object.values(state.byId).find(session => (session.retainedBy?.mainView ?? 0) > 0);
|
|
21
|
+
return main === undefined || main.blank;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* 是否应当显示提示。
|
|
25
|
+
* - 已看过:不弹(设备级一次);
|
|
26
|
+
* - Key 已配置、或状态还读不到:不弹(读不到时宁可少弹);
|
|
27
|
+
* - 主视图被有内容的会话占着:不弹,等它空下来。
|
|
28
|
+
* @param facts - 三个输入。
|
|
29
|
+
* @returns 是否显示。
|
|
30
|
+
*/
|
|
31
|
+
export function shouldShowNotice(facts) {
|
|
32
|
+
if (facts.seen)
|
|
33
|
+
return false;
|
|
34
|
+
if (facts.configured !== false)
|
|
35
|
+
return false;
|
|
36
|
+
return facts.viewIdle;
|
|
37
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createFreeKeyNotice, FREE_KEY_NOTICE_ID, FREE_KEY_NOTICE_SLOT } from './card.js';
|
|
2
|
+
/**
|
|
3
|
+
* 注册一次性提示。
|
|
4
|
+
* @param ctx - 客户端上下文,需带 `slots` 服务。
|
|
5
|
+
* @returns 幂等 disposer。
|
|
6
|
+
*/
|
|
7
|
+
export function registerFreeKeyNotice(ctx) {
|
|
8
|
+
const component = createFreeKeyNotice(ctx);
|
|
9
|
+
return ctx.slots.inject(FREE_KEY_NOTICE_SLOT, () => ctx.slots.register({ name: FREE_KEY_NOTICE_SLOT, id: FREE_KEY_NOTICE_ID, order: 100 }, component));
|
|
10
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「看过免费 Key 提示」的设备级标记。
|
|
3
|
+
*
|
|
4
|
+
* 只有一个事实(看过/没看过),所以不建服务、不建 store:读一次、写一次,
|
|
5
|
+
* 两个纯函数加一个最小的存储接口。存储接口只声明用到的那两个方法,测试可以传
|
|
6
|
+
* 一个内存实现,浏览器里传 `window.localStorage`。
|
|
7
|
+
*
|
|
8
|
+
* 读失败一律按「没看过」处理:隐私模式或存储被禁用时 `localStorage` 会抛异常,
|
|
9
|
+
* 这时提示最多多弹一次,比整个设置页崩掉可接受。
|
|
10
|
+
*/
|
|
11
|
+
/** 这一层用到的 `localStorage` 子集;隔离出来是为了让测试不碰真实存储。 */
|
|
12
|
+
export interface NoticeFlagStorage {
|
|
13
|
+
getItem(key: string): string | null;
|
|
14
|
+
setItem(key: string, value: string): void;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* 取浏览器存储;拿不到(没有 window、被禁用)时返回 undefined。
|
|
18
|
+
* @returns 可用存储,或 undefined。
|
|
19
|
+
*/
|
|
20
|
+
export declare function browserNoticeStorage(): NoticeFlagStorage | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* 读标记。任何异常与空值都算「没看过」。
|
|
23
|
+
* @param storage - 目标存储;缺失视为没看过。
|
|
24
|
+
* @param key - 标记键。
|
|
25
|
+
* @returns 是否已看过。
|
|
26
|
+
*/
|
|
27
|
+
export declare function readNoticeSeen(storage: NoticeFlagStorage | undefined, key: string): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* 写标记。写失败不抛:提示已经显示过了,这次没记下来只是下次会再弹一次。
|
|
30
|
+
* @param storage - 目标存储;缺失时什么都不做。
|
|
31
|
+
* @param key - 标记键。
|
|
32
|
+
* @returns 是否写入成功。
|
|
33
|
+
*/
|
|
34
|
+
export declare function writeNoticeSeen(storage: NoticeFlagStorage | undefined, key: string): boolean;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「看过免费 Key 提示」的设备级标记。
|
|
3
|
+
*
|
|
4
|
+
* 只有一个事实(看过/没看过),所以不建服务、不建 store:读一次、写一次,
|
|
5
|
+
* 两个纯函数加一个最小的存储接口。存储接口只声明用到的那两个方法,测试可以传
|
|
6
|
+
* 一个内存实现,浏览器里传 `window.localStorage`。
|
|
7
|
+
*
|
|
8
|
+
* 读失败一律按「没看过」处理:隐私模式或存储被禁用时 `localStorage` 会抛异常,
|
|
9
|
+
* 这时提示最多多弹一次,比整个设置页崩掉可接受。
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* 取浏览器存储;拿不到(没有 window、被禁用)时返回 undefined。
|
|
13
|
+
* @returns 可用存储,或 undefined。
|
|
14
|
+
*/
|
|
15
|
+
export function browserNoticeStorage() {
|
|
16
|
+
try {
|
|
17
|
+
const storage = globalThis.localStorage;
|
|
18
|
+
return storage === undefined ? undefined : { getItem: key => storage.getItem(key), setItem: (key, value) => { storage.setItem(key, value); } };
|
|
19
|
+
}
|
|
20
|
+
catch (_blocked) {
|
|
21
|
+
// 访问 `localStorage` 本身在受限环境会抛 SecurityError。
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* 读标记。任何异常与空值都算「没看过」。
|
|
27
|
+
* @param storage - 目标存储;缺失视为没看过。
|
|
28
|
+
* @param key - 标记键。
|
|
29
|
+
* @returns 是否已看过。
|
|
30
|
+
*/
|
|
31
|
+
export function readNoticeSeen(storage, key) {
|
|
32
|
+
if (storage === undefined)
|
|
33
|
+
return false;
|
|
34
|
+
try {
|
|
35
|
+
return storage.getItem(key) === '1';
|
|
36
|
+
}
|
|
37
|
+
catch (_blocked) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* 写标记。写失败不抛:提示已经显示过了,这次没记下来只是下次会再弹一次。
|
|
43
|
+
* @param storage - 目标存储;缺失时什么都不做。
|
|
44
|
+
* @param key - 标记键。
|
|
45
|
+
* @returns 是否写入成功。
|
|
46
|
+
*/
|
|
47
|
+
export function writeNoticeSeen(storage, key) {
|
|
48
|
+
if (storage === undefined)
|
|
49
|
+
return false;
|
|
50
|
+
try {
|
|
51
|
+
storage.setItem(key, '1');
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
catch (_blocked) {
|
|
55
|
+
// 配额满或被禁用:不阻断用户,下次再弹。
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 一次性提示的样式:固定在左下角的非阻塞卡片。
|
|
3
|
+
*
|
|
4
|
+
* 不盖遮罩、不抢焦点——这一层(宿主的 `shell.overlay`)本身可穿透点击,
|
|
5
|
+
* 条目只有在这张卡片范围内才接收指针事件,所以卡片以外的界面照常可用。
|
|
6
|
+
* 位置用 `fixed` 而不是依赖外层布局:浮层是 `inset:0` 的绝对定位层,
|
|
7
|
+
* 条目自己决定停在哪里。
|
|
8
|
+
*
|
|
9
|
+
* 颜色全走 `--dsw-alias-*` 与 `--ft-*` 变量,深色/浅色两套调色板共用一份。
|
|
10
|
+
*/
|
|
11
|
+
export declare const FREE_KEY_NOTICE_CSS = "\n.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; }\n.ftshare-free-key-notice * { box-sizing:border-box; }\n.ftshare-free-key-notice .ftshare-free-key-head { display:flex; align-items:flex-start; justify-content:space-between; gap:8px; }\n.ftshare-free-key-notice strong { font-size:14px; font-weight:500; line-height:22px; }\n.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; }\n.ftshare-free-key-notice .ftshare-free-key-close:hover { background:var(--dsw-alias-bg-layer-3,#f9fafb); }\n.ftshare-free-key-notice p { margin:6px 0 14px; font-size:13px; line-height:1.6; color:var(--dsw-alias-label-secondary,#61666b); }\n.ftshare-free-key-notice .ftshare-free-key-actions { display:flex; gap:8px; }\n.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; }\n/* \u4E3B\u5165\u53E3\u7528\u54C1\u724C\u9EC4\u7684\u534A\u900F\u660E\u5E95\uFF1A\u4FDD\u7559 FT \u7684\u8272\uFF0C\u53C8\u4E0D\u81F3\u4E8E\u5728\u5DE6\u4E0B\u89D2\u53D8\u6210\u4E00\u5757\u9192\u76EE\u7684\u4EAE\u9EC4\u3002\n \u6587\u5B57\u4ECD\u53D6\u6B63\u6587\u4E3B\u8272\uFF08\u4E0D\u662F\u538B\u5728\u4E0A\u9762\u7684\u6DF1\u9ED1\u5B57\uFF09\uFF0C70% \u5728\u6DF1\u6D45\u4E24\u5957\u8C03\u8272\u677F\u4E0B\u90FD\u80FD\u8BFB\u3002 */\n.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; }\n.ftshare-free-key-notice .ftshare-free-key-actions .primary:hover { background:color-mix(in srgb,var(--ft-accent,#f0c433) 82%,transparent); }\n@keyframes ftshareNoticeIn { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:none; } }\n@media (prefers-reduced-motion: reduce) { .ftshare-free-key-notice { animation:none; } }\n";
|
|
@@ -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/lib/guidance.d.ts
CHANGED
|
@@ -31,7 +31,12 @@ export declare const FT_SECTION_ORDERS: {
|
|
|
31
31
|
};
|
|
32
32
|
/** Order of the credential fact — `getContextOrder` owns only three named positions. */
|
|
33
33
|
export declare const FT_CREDENTIALS_ORDER = 130;
|
|
34
|
-
|
|
34
|
+
/**
|
|
35
|
+
* The capability list. It may only name tools this base actually ships or that
|
|
36
|
+
* the FT preset actually mounts: a promise about a tool that is not installed
|
|
37
|
+
* sends the model hunting for something that does not exist.
|
|
38
|
+
*/
|
|
39
|
+
export declare const FT_SERVICES_TEXT = "\u884C\u60C5\u3001K\u7EBF\u4E0E\u6307\u6807\u7528 dsh-kline \u5DE5\u5177\uFF08\u4EE3\u7801\u5148\u7528 search_symbols \u89E3\u6790\uFF09\uFF1BFTShare \u6570\u636E\u7528 ftshare_* \u5DE5\u5177\uFF0C\u53EF\u7528\u63A5\u53E3\u67E5 ftshare_status\uFF1BFTShare API Key \u5728\u8BBE\u7F6E\u9875\u7EF4\u62A4\u3002";
|
|
35
40
|
export declare const FT_DATA_POLICY_TEXT = "\u91D1\u878D\u6570\u636E\u4F18\u5148\u53D6 FTShare\uFF0C\u5176\u6B21\u624D\u7528\u8054\u7F51\u68C0\u7D22\u8865\u5145\uFF1B\u5F15\u7528\u5FC5\u987B\u7ED9\u51FA\u5904\u4E0E\u65F6\u70B9\uFF1B\u4E25\u683C\u533A\u5206\u5DF2\u62AB\u9732\u4E8B\u5B9E\u3001\u673A\u6784\u5224\u65AD\u4E0E\u5F85\u9A8C\u8BC1\u4FE1\u606F\uFF1B\u4E0D\u7F16\u9020\u6570\u5B57\uFF0C\u4E5F\u4E0D\u6784\u6210\u6295\u8D44\u5EFA\u8BAE\u3002";
|
|
36
41
|
export declare const FT_CREDENTIALS_MISSING_TEXT = "FTShare \u51ED\u636E\u672A\u914D\u7F6E\uFF1A\u884C\u60C5\u53EF\u80FD\u9000\u5316\u4E3A\u514D\u8D39\u515C\u5E95\u6E90\uFF0C\u8986\u76D6\u6709\u9650\uFF0C\u5F15\u7528\u65F6\u5FC5\u987B\u6807\u660E\u6765\u6E90\uFF1B\u9700\u8981\u5B8C\u6574\u6570\u636E\u65F6\u5F15\u5BFC\u7528\u6237\u5728\u8BBE\u7F6E\u9875\u586B\u5199 API Key\u3002";
|
|
37
42
|
/**
|
package/lib/guidance.js
CHANGED
|
@@ -27,7 +27,12 @@ export const FT_CREDENTIALS_CONTEXT = 'ft:credentials';
|
|
|
27
27
|
export const FT_SECTION_ORDERS = { services: 6, dataPolicy: 7 };
|
|
28
28
|
/** Order of the credential fact — `getContextOrder` owns only three named positions. */
|
|
29
29
|
export const FT_CREDENTIALS_ORDER = 130;
|
|
30
|
-
|
|
30
|
+
/**
|
|
31
|
+
* The capability list. It may only name tools this base actually ships or that
|
|
32
|
+
* the FT preset actually mounts: a promise about a tool that is not installed
|
|
33
|
+
* sends the model hunting for something that does not exist.
|
|
34
|
+
*/
|
|
35
|
+
export const FT_SERVICES_TEXT = '行情、K线与指标用 dsh-kline 工具(代码先用 search_symbols 解析);FTShare 数据用 ftshare_* 工具,可用接口查 ftshare_status;FTShare API Key 在设置页维护。';
|
|
31
36
|
export const FT_DATA_POLICY_TEXT = '金融数据优先取 FTShare,其次才用联网检索补充;引用必须给出处与时点;严格区分已披露事实、机构判断与待验证信息;不编造数字,也不构成投资建议。';
|
|
32
37
|
export const FT_CREDENTIALS_MISSING_TEXT = 'FTShare 凭据未配置:行情可能退化为免费兜底源,覆盖有限,引用时必须标明来源;需要完整数据时引导用户在设置页填写 API Key。';
|
|
33
38
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dsh-ft/dsh-fft-settings",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "FT 设置与凭据底座:FTShare API Key、设置页卡片、运行日志与品牌主题层,供 FT 系列插件共享。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/gigaflw/dsh-fft-base/tree/dev/packages/dsh-fft-settings#readme",
|
|
@@ -86,6 +86,7 @@
|
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"@deepseek-ai/cordis": "^4.0.2",
|
|
88
88
|
"@deepseek-ai/dsh-api-remotes": ">=0.2.0-rc.2",
|
|
89
|
+
"@deepseek-ai/dsh-client-ui-layout": ">=0.2.0-rc.2",
|
|
89
90
|
"@deepseek-ai/dsh-client-ui-settings": ">=0.2.0-rc.2",
|
|
90
91
|
"@deepseek-ai/dsh-client-ui-slots": ">=0.2.0-rc.2",
|
|
91
92
|
"react": "^18.2.0",
|
|
@@ -99,6 +100,7 @@
|
|
|
99
100
|
"tsdown": "^0.22.2",
|
|
100
101
|
"typescript": "^5.8.3",
|
|
101
102
|
"@deepseek-ai/cordis": "catalog:",
|
|
103
|
+
"@deepseek-ai/dsh-client-ui-layout": "catalog:",
|
|
102
104
|
"@deepseek-ai/dsh-client-ui-settings": "catalog:",
|
|
103
105
|
"@deepseek-ai/dsh-client-ui-slots": "catalog:",
|
|
104
106
|
"@deepseek-ai/dsh-credentials": "catalog:",
|
package/src/client.tsx
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { registerApiKeyCard, type ApiKeyCardContext } from './features/api-key/card.js'
|
|
2
|
+
import { registerFreeKeyNotice } from './features/free-key-notice/index.js'
|
|
2
3
|
import { registerLogsCard } from './features/logs/card.js'
|
|
3
4
|
import { registerSettingsSection } from './core/shell.js'
|
|
4
5
|
import { applyBrandTokens, type BrandThemeContext } from './brand.js'
|
|
5
6
|
export * from './core/shell.js'
|
|
7
|
+
export { FREE_KEY_NOTICE_ID, FREE_KEY_NOTICE_SLOT, isViewIdle, shouldShowNotice, type FreeKeyNoticeSessionState } from './features/free-key-notice/decision.js'
|
|
8
|
+
export { readNoticeSeen, writeNoticeSeen, type NoticeFlagStorage } from './features/free-key-notice/storage.js'
|
|
6
9
|
/** Browser context this plugin's own client half requires. */
|
|
7
10
|
export type FtshareSettingsClientContext = ApiKeyCardContext
|
|
8
11
|
export const inject = ['slots', 'remote']
|
|
@@ -13,9 +16,15 @@ export function apply(ctx: FtshareSettingsClientContext): void {
|
|
|
13
16
|
// plugin still loads on a host without the theme service.
|
|
14
17
|
applyBrandTokens(ctx as unknown as BrandThemeContext)
|
|
15
18
|
}
|
|
16
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Contribute the settings section, its cards, and the frame-wide notice.
|
|
21
|
+
*
|
|
22
|
+
* The notice deliberately does not live in the settings page: it has to reach
|
|
23
|
+
* users who never open settings, so it takes a seat in `shell.overlay`.
|
|
24
|
+
*/
|
|
17
25
|
export function registerFtshareSettings(ctx: FtshareSettingsClientContext): void {
|
|
18
26
|
registerSettingsSection(ctx)
|
|
19
27
|
registerApiKeyCard(ctx)
|
|
20
28
|
registerLogsCard(ctx)
|
|
29
|
+
registerFreeKeyNotice(ctx)
|
|
21
30
|
}
|
|
@@ -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]
|