feat(Tab设置+UI主题): 迭代09 Tab设置统一管理 + 迭代10 UI适配DSH主题

迭代09 (R-011): Tab 设置统一管理所有会话 tab
- settings.tabs 布尔对象 → 统一有序数组 (kind: builtin|strategy)
- 旧配置 schema union 兼容 + 读取归一化无感迁移
- strategies 收窄为 {id,name},策略行 name join 自动跟随改名
- 策略增删联动 tabs 条目 (add 追加末尾 / remove 联动删除)
- 客户端统一注册读 tabs 数组,内置与策略可任意混排
- 设置页 Tab 设置子 tab:混排表格 + 拖动手柄 + 显隐开关 + 落点立即持久化
- 策略分组瘦身:仅 新增/重命名/删除 + 指引提示
- 回归测试: test-r011-tabs (23) + test-r011-api (12)

迭代10 (R-012): UI 适配 DSH 浅色/深色/跟随系统主题
- 10 个视图文件 141 处硬编码色 → var(--dsw-alias-*, fallback)
- 语义映射: bg-layer/label/border/state/button-contrast 等
- 淡底 color-mix 自适应;遮罩 bg-mask-1;阴影 shadow-lv3
This commit is contained in:
2026-09-02 14:00:56 +08:00
parent 35f2ad1a23
commit 4ac8a29805
15 changed files with 629 additions and 385 deletions
+85
View File
@@ -0,0 +1,85 @@
/**
* R-011 API 层集成测试:handleStrategy tabs 域(联动链路)
* 独立内存 mock settings scope + mock manager,验证:
* 1. tabs 端点:返回归一化后的统一数组
* 2. tabs/update:整表更新(顺序 + 显隐)
* 3. strategies/add:联动追加 tab 条目(末尾)
* 4. strategies/remove:联动删除 tab 条目 + 清份额(manager
* 5. strategies/update:重命名(tabs 行 name join 跟随)
* 纯内存,不触真实数据(技术约束-011)
*/
import { handleStrategy, STRATEGY_METHODS } from '../src/api/strategies.js';
let pass = 0, fail = 0;
function assert(cond, msg) {
if (cond) { pass++; console.log(" ✅ " + msg); }
else { fail++; console.log(" ❌ " + msg); }
}
/** 内存 mock scope */
function makeScope(initial) {
const store = structuredClone(initial ?? {});
return {
get: () => structuredClone(store),
update: async (patch) => { Object.assign(store, structuredClone(patch)); },
};
}
const scope = makeScope({
strategies: [{ id: "grid-supermarket", name: "网格超市" }],
tabs: [
{ id: "tab-all", kind: "builtin", refKey: "allPositions", name: "全部持仓", visible: true, order: 0 },
{ id: "tab-g", kind: "strategy", refId: "grid-supermarket", name: "网格超市", visible: true, order: 1 },
],
});
const cleared = [];
const manager = {
async clearStrategyShares(id) { cleared.push(id); },
};
const runtime = { manager, settings: scope };
// 1. tabs 端点
console.log("\n[1] tabs 端点");
const tabs1 = await handleStrategy("tabs", {}, runtime);
assert(Array.isArray(tabs1) && tabs1.length === 2, "tabs 返回统一数组");
assert(tabs1[0].kind === "builtin" && tabs1[1].kind === "strategy", "内置 + 策略混排");
// 2. tabs/update 整表更新
console.log("\n[2] tabs/update 整表更新");
const afterUpdate = await handleStrategy("tabs/update", { tabs: [
{ id: "tab-g", kind: "strategy", refId: "grid-supermarket", name: "网格超市", visible: false, order: 0 },
{ id: "tab-all", kind: "builtin", refKey: "allPositions", name: "全部持仓", visible: true, order: 1 },
] }, runtime);
assert(afterUpdate[0].id === "tab-g" && afterUpdate[0].visible === false, "策略 tab 排到前面且隐藏");
assert(afterUpdate[1].id === "tab-all", "内置 tab 排后");
// 3. strategies/add 联动追加 tab
console.log("\n[3] strategies/add 联动追加 tab");
const added = await handleStrategy("strategies/add", { name: "长线持有" }, runtime);
const tabsAfterAdd = await handleStrategy("tabs", {}, runtime);
assert(tabsAfterAdd.length === 3, "add 后 tabs 联动新增一条");
assert(tabsAfterAdd[2].kind === "strategy" && tabsAfterAdd[2].refId === added.id, "新策略 tab 追加末尾");
assert(tabsAfterAdd[2].name === "长线持有", "新策略 tab name 正确");
// 4. strategies/remove 联动删除 tab + 清份额
console.log("\n[4] strategies/remove 联动删除 tab");
await handleStrategy("strategies/remove", { strategyId: "grid-supermarket" }, runtime);
const tabsAfterRm = await handleStrategy("tabs", {}, runtime);
assert(!tabsAfterRm.some((t) => t.refId === "grid-supermarket"), "删除策略后对应 tab 条目消失");
assert(tabsAfterRm.some((t) => t.refId === added.id), "其余策略 tab 保留");
assert(cleared.includes("grid-supermarket"), "manager.clearStrategyShares 被调用(份额回未分配)");
// 5. strategies/update 重命名(tabs 行 join 跟随)
console.log("\n[5] strategies/update 重命名跟随");
await handleStrategy("strategies/update", { strategies: [
{ id: added.id, name: "长线持有v2" },
] }, runtime);
const tabsAfterRename = await handleStrategy("tabs", {}, runtime);
assert(tabsAfterRename.find((t) => t.refId === added.id).name === "长线持有v2", "重命名后 tabs 行 name join 跟随(Q4");
// 6. 端点表不含 strategies/move
console.log("\n[6] strategies/move 已废弃");
assert(!STRATEGY_METHODS.has("strategies/move"), "STRATEGY_METHODS 不含 strategies/move");
console.log("\n结果: " + pass + " 通过 / " + fail + " 失败");
if (fail > 0) process.exit(1);
+139
View File
@@ -0,0 +1,139 @@
/**
* R-011 回归测试:tabs 统一管理数据层
* 独立内存 mock scopesettings 读写),验证:
* 1. 归一化迁移:旧布尔对象 tabs → 有序数组(内置保留原显隐,策略接续追加)
* 2. 迁移:旧隐藏策略 → visible=trueQ3
* 3. getTabs:策略行 name join strategiesQ4 自动跟随改名)
* 4. updateTabs:整表更新(顺序 + 显隐)
* 5. addStrategy:联动追加 tab 条目(末尾,Q2)
* 6. removeStrategy:联动删除对应 tab 条目(D3)
* 纯内存,不触真实数据(技术约束-011)
*/
import {
getTabs, updateTabs, normalizeTabs,
addStrategy, removeStrategy, renameStrategy, getStrategies,
} from '../src/settings.js';
let pass = 0, fail = 0;
function assert(cond, msg) {
if (cond) { pass++; console.log(" ✅ " + msg); }
else { fail++; console.log(" ❌ " + msg); }
}
/** 内存 mock scopeget/update 读改写对象 */
function makeScope(initial) {
const store = structuredClone(initial ?? {});
return {
get: () => structuredClone(store),
update: async (patch) => { Object.assign(store, structuredClone(patch)); },
_store: store,
};
}
// ---------- 1. 旧格式归一化(布尔对象 tabs + 策略带 visible/order ----------
console.log("\n[1] 旧格式归一化迁移");
const oldScope = makeScope({
strategies: [
{ id: 'grid-supermarket', name: '网格超市', visible: true, order: 5 },
{ id: 'manual-t', name: '手动做T', visible: false, order: 9 },
],
tabs: { allPositions: true, tradeRecords: false, watchlist: true },
});
const oldTabs = normalizeTabs(oldScope);
assert(oldTabs.length === 5, "归一化后共 5 个条目(3 内置 + 2 策略)");
assert(oldTabs[0].kind === 'builtin' && oldTabs[0].refKey === 'allPositions', '内置 allPositions 在前');
assert(oldTabs[1].refKey === 'tradeRecords' && oldTabs[1].visible === false, '内置 tradeRecords 保留旧显隐=false');
assert(oldTabs[2].refKey === 'watchlist' && oldTabs[2].visible === true, '内置 watchlist 保留旧显隐=true');
const manualTab = oldTabs.find((t) => t.kind === 'strategy' && t.refId === 'manual-t');
assert(manualTab && manualTab.visible === true, "旧隐藏策略 manual-t 迁移后 visible=trueQ3");
assert(manualTab && manualTab.name === '手动做T', '策略行 name 来自 strategies');
assert(oldTabs[3].order === 3 && oldTabs[4].order === 4, '策略按 order 接续(order 连续)');
// ---------- 2. 已归一化数组直接返回 ----------
console.log("\n[2] 已归一化数组直接返回");
const arrScope = makeScope({ tabs: [
{ id: 'tab-a', kind: 'builtin', refKey: 'allPositions', name: '全部持仓', visible: true, order: 1 },
{ id: 'tab-b', kind: 'builtin', refKey: 'watchlist', name: '关注列表', visible: true, order: 0 },
] });
const arrTabs = normalizeTabs(arrScope);
assert(arrTabs[0].id === 'tab-b' && arrTabs[1].id === 'tab-a', '数组按 order 排序');
// ---------- 3. 策略行 name join(改名自动跟随,Q4 ----------
console.log("\n[3] getTabs 策略名 join");
const joinScope = makeScope({
strategies: [{ id: 'manual-t', name: '手动做T新名' }],
tabs: [
{ id: 'tab-all', kind: 'builtin', refKey: 'allPositions', name: '全部持仓', visible: true, order: 0 },
{ id: 'tab-s', kind: 'strategy', refId: 'manual-t', name: '旧名', visible: true, order: 1 },
],
});
const joined = getTabs(joinScope);
assert(joined.find((t) => t.id === 'tab-s').name === '手动做T新名', '策略改名后 tabs 行 name 自动跟随(Q4');
// ---------- 4. updateTabs 整表更新 ----------
console.log("\n[4] updateTabs 整表更新");
const updScope = makeScope({ tabs: [
{ id: 'tab-a', kind: 'builtin', refKey: 'allPositions', name: '全部持仓', visible: true, order: 0 },
{ id: 'tab-b', kind: 'builtin', refKey: 'watchlist', name: '关注列表', visible: true, order: 1 },
] });
await updateTabs(updScope, [
{ id: 'tab-b', kind: 'builtin', refKey: 'watchlist', name: '关注列表', visible: false, order: 0 },
{ id: 'tab-a', kind: 'builtin', refKey: 'allPositions', name: '全部持仓', visible: true, order: 1 },
]);
const updTabs = getTabs(updScope);
assert(updTabs[0].id === 'tab-b' && updTabs[0].visible === false, '整表更新:顺序与显隐生效');
assert(updTabs.length === 2, '整表更新后条目数不变');
// ---------- 5. addStrategy 联动追加 tab(末尾,Q2 ----------
console.log("\n[5] addStrategy 联动追加 tab");
const addScope = makeScope({
strategies: [{ id: 'grid-supermarket', name: '网格超市' }],
tabs: [
{ id: 'tab-all', kind: 'builtin', refKey: 'allPositions', name: '全部持仓', visible: true, order: 0 },
{ id: 'tab-s', kind: 'strategy', refId: 'grid-supermarket', name: '网格超市', visible: true, order: 1 },
],
});
const added = await addStrategy(addScope, '长线持有');
assert(getStrategies(addScope).length === 2, '策略定义表新增');
const addTabs = getTabs(addScope);
assert(addTabs.length === 3, 'tabs 联动新增一条');
assert(addTabs[2].kind === 'strategy' && addTabs[2].refId === added.id, '新策略 tab 追加到末尾(Q2');
assert(addTabs[2].name === '长线持有', '新策略 tab name 正确');
assert(addTabs[2].visible === true, '新策略 tab 默认显示');
assert(addTabs[2].order === 2, 'order 连续');
// ---------- 6. removeStrategy 联动删除 tabD3 ----------
console.log("\n[6] removeStrategy 联动删除 tab");
const rmScope = makeScope({
strategies: [
{ id: 'grid-supermarket', name: '网格超市' },
{ id: 'manual-t', name: '手动做T' },
],
tabs: [
{ id: 'tab-all', kind: 'builtin', refKey: 'allPositions', name: '全部持仓', visible: true, order: 0 },
{ id: 'tab-g', kind: 'strategy', refId: 'grid-supermarket', name: '网格超市', visible: true, order: 1 },
{ id: 'tab-m', kind: 'strategy', refId: 'manual-t', name: '手动做T', visible: true, order: 2 },
],
});
await removeStrategy(rmScope, 'grid-supermarket');
const rmTabs = getTabs(rmScope);
assert(rmTabs.length === 2, '删除策略后 tabs 联动删除对应条目');
assert(!rmTabs.some((t) => t.refId === 'grid-supermarket'), 'grid-supermarket tab 条目已删除');
assert(rmTabs.some((t) => t.refId === 'manual-t'), 'manual-t tab 条目保留');
assert(getStrategies(rmScope).length === 1, '策略定义表删除');
// ---------- 7. renameStrategy 只改名(tabs 行 join 跟随,不影响定义) ----------
console.log("\n[7] renameStrategy");
const rnScope = makeScope({
strategies: [{ id: 'manual-t', name: '手动做T' }],
tabs: [
{ id: 'tab-m', kind: 'strategy', refId: 'manual-t', name: '手动做T', visible: true, order: 0 },
],
});
await renameStrategy(rnScope, 'manual-t', '手动T+');
assert(getStrategies(rnScope)[0].name === '手动T+', '策略定义名更新');
assert(getTabs(rnScope)[0].name === '手动T+', 'tabs 行 name join 跟随(Q4');
// ---------- 汇总 ----------
console.log("\n结果: " + pass + " 通过 / " + fail + " 失败");
if (fail > 0) process.exit(1);
+6 -9
View File
@@ -2,15 +2,15 @@
* 服务端 API:策略 / 份额 / tabs 域(从 api.js 拆分,2026-08-31
*
* 端点:
* strategies / strategies/update / strategies/add / strategies/remove / strategies/move
* strategies / strategies/update / strategies/add / strategies/remove
* strategy-positions / unallocated / summary
* add-shares / move-all-shares / remove-shares
* tabs / tabs/update
*/
import {
getStrategies, updateStrategies, addStrategy, removeStrategy, moveStrategy,
getTabConfig, updateTabConfig,
getStrategies, updateStrategies, addStrategy, removeStrategy,
getTabs, updateTabs,
} from '../settings.js';
/** 策略/份额/tabs 端点方法表 */
@@ -19,7 +19,6 @@ export const STRATEGY_METHODS = new Set([
'strategies/update',
'strategies/add',
'strategies/remove',
'strategies/move',
'strategy-positions',
'unallocated',
'summary',
@@ -50,13 +49,11 @@ export async function handleStrategy(method, args, { manager, settings }) {
await removeStrategy(settings, args.strategyId);
await manager.clearStrategyShares(args.strategyId);
return getStrategies(settings);
case 'strategies/move':
return await moveStrategy(settings, args.strategyId, args.dir);
case 'tabs':
return getTabConfig(settings);
return getTabs(settings);
case 'tabs/update':
await updateTabConfig(settings, args.tabs);
return getTabConfig(settings);
await updateTabs(settings, args.tabs);
return getTabs(settings);
case 'strategy-positions':
return await manager.getStrategyPositions(args.strategyId);
case 'unallocated':
+31 -61
View File
@@ -23,21 +23,8 @@ import { MarketDataProvider } from './market/MarketDataProvider.jsx';
export const inject = ['slots', 'connection'];
async function fetchStrategies() {
try {
const res = await fetch('/odl/api/strategies', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const data = await res.json();
return data && data.ok ? data.value : [];
} catch (e) {
return [];
}
}
async function fetchTabConfig() {
async function fetchTabs() {
try {
const res = await fetch('/odl/api/tabs', {
method: 'POST',
@@ -45,29 +32,21 @@ async function fetchTabConfig() {
body: JSON.stringify({}),
});
const data = await res.json();
if (data && data.ok) {
return {
allPositions: data.value.allPositions !== false,
tradeRecords: data.value.tradeRecords !== false,
watchlist: data.value.watchlist !== false,
};
}
if (data && data.ok && Array.isArray(data.value)) return data.value;
} catch (e) { /* ignore */ }
return { allPositions: true, tradeRecords: true, watchlist: true };
return null;
}
function strategyTabId(strategyId) {
return 'odl-strategy-' + strategyId;
}
const GENERAL_TABS = [
{ key: 'allPositions', id: 'odl-all-positions', label: '全部持仓', order: 10,
render: (withConnection) => withConnection((props) => createElement(AllPositionsTab, props)) },
{ key: 'tradeRecords', id: 'odl-trade-records', label: '交易记录', order: 11,
render: (withConnection) => withConnection((props) => createElement(TradeRecordsTab, props)) },
{ key: 'watchlist', id: 'odl-watchlist', label: '关注列表', order: 12,
render: (withConnection) => withConnection((props) => createElement(PlaceholderTab, { ...props, title: '关注列表' })) },
];
// R-011:内置 tab 渲染映射(refKey → render);order 由 settings.tabs 统一管理
const GENERAL_RENDER = {
allPositions: (withConnection) => withConnection((props) => createElement(AllPositionsTab, props)),
tradeRecords: (withConnection) => withConnection((props) => createElement(TradeRecordsTab, props)),
watchlist: (withConnection) => withConnection((props) => createElement(PlaceholderTab, { ...props, title: '关注列表' })),
};
export function apply(ctx) {
const connection = ctx.get('connection');
@@ -109,40 +88,31 @@ export function apply(ctx) {
tabDisposers = [];
};
const registerGeneralTabs = (tabConfig) => {
for (const tab of GENERAL_TABS) {
if (tabConfig[tab.key] === false) continue;
const dispose = ctx.slots.register({
name: 'conversation.view',
id: tab.id,
order: tab.order,
label: () => tab.label,
}, tab.render(withConnection));
tabDisposers.push(dispose);
}
};
const registerStrategyTabs = (strategies) => {
const visible = strategies.filter((s) => s.visible !== false);
visible.forEach((s, idx) => {
const dispose = ctx.slots.register({
name: 'conversation.view',
id: strategyTabId(s.id),
order: 13 + idx,
label: () => s.name,
}, withConnection((props) => createElement(StrategyTab, { ...props, strategyId: s.id, strategyName: s.name })));
tabDisposers.push(dispose);
});
return visible.length;
};
// R-011:统一注册 —— 读 settings.tabs 有序数组,按 order 排序、过滤 visible
// builtin 走内置 renderGENERAL_RENDER),strategy 走 StrategyTab
const registerAllTabs = async () => {
const [strategies, tabConfig] = await Promise.all([fetchStrategies(), fetchTabConfig()]);
const tabs = await fetchTabs();
if (disposed) return;
disposeAllTabs();
registerGeneralTabs(tabConfig);
const n = registerStrategyTabs(strategies);
ctx.logger?.info?.('[one-divine-lot] 已注册通用 tabs + ' + n + ' 个策略 tab');
if (!tabs) return;
const sorted = tabs.slice().sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
let n = 0;
for (const t of sorted) {
if (t.visible === false) continue;
const render = t.kind === 'builtin'
? GENERAL_RENDER[t.refKey]
: (withConnection) => withConnection((props) => createElement(StrategyTab, { ...props, strategyId: t.refId, strategyName: t.name }));
if (!render) continue;
const dispose = ctx.slots.register({
name: 'conversation.view',
id: t.kind === 'builtin' ? t.id : strategyTabId(t.refId),
order: t.order ?? 0,
label: () => t.name ?? t.id,
}, render(withConnection));
tabDisposers.push(dispose);
n++;
}
ctx.logger?.info?.('[one-divine-lot] 已注册 ' + n + ' 个会话 tab');
};
registerAllTabs();
+4 -4
View File
@@ -44,7 +44,7 @@ export function AllPositionsTab(props) {
<LoadState loading={loading} error={error} onRetry={load} autoRetry={2}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
<tr style={{ textAlign: 'left', borderBottom: '2px solid var(--dsw-alias-border-l2, #ddd)' }}>
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>代码</th>
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>名称</th>
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>现价</th>
@@ -56,14 +56,14 @@ export function AllPositionsTab(props) {
</thead>
<tbody>
{(positions?.positions ?? []).map((p) => (
<tr key={p.code} style={{ borderBottom: '1px solid #eee' }}>
<tr key={p.code} style={{ borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)' }}>
<td style={{ padding: '8px 6px', lineHeight: '24px' }}>{p.code}</td>
<td style={{ padding: '8px 6px', lineHeight: '24px' }}>{p.name}</td>
<PriceCell price={getPrice(p.code)?.lastPrice} lastClose={getPrice(p.code)?.lastClose} />
<td style={{ padding: '8px 6px', lineHeight: '24px' }}>{p.totalVolume}</td>
<td style={{ padding: '8px 6px', lineHeight: '24px' }}>{p.allocated}</td>
<td style={{ padding: '8px 6px', lineHeight: '24px', color: p.unallocated > 0 ? '#e65100' : '#2e7d32' }}>{p.unallocated}</td>
<td style={{ padding: '8px 6px', lineHeight: '24px', fontSize: 12, color: '#555' }}>
<td style={{ padding: '8px 6px', lineHeight: '24px', color: p.unallocated > 0 ? 'var(--dsw-alias-state-warn-primary, #e65100)' : 'var(--dsw-alias-state-success-primary, #2e7d32)' }}>{p.unallocated}</td>
<td style={{ padding: '8px 6px', lineHeight: '24px', fontSize: 12, color: 'var(--dsw-alias-label-secondary, #555)' }}>
{Object.entries(p.shares).map(([sid, n]) => sid + ': ' + n).join(', ') || '—'}
</td>
</tr>
+3 -3
View File
@@ -49,17 +49,17 @@ export function LoadState({ loading, error, onRetry, autoRetry = 2, children })
}, [onRetry, autoRetry]);
if (loading) {
return <div style={{ padding: 16, color: '#666' }}>加载中...</div>;
return <div style={{ padding: 16, color: 'var(--dsw-alias-label-secondary, #666)' }}>加载中...</div>;
}
const errMsg = error || manualError;
if (errMsg) {
return (
<div style={{ padding: 16, color: '#c62828' }}>
<div style={{ padding: 16, color: 'var(--dsw-alias-state-error-primary, #c62828)' }}>
<div style={{ marginBottom: 8 }}>数据加载失败{errMsg}</div>
<button
onClick={handleManualRetry}
style={{ padding: '6px 16px', cursor: 'pointer', border: '1px solid #c62828', borderRadius: 4, background: '#fff', color: '#c62828' }}
style={{ padding: '6px 16px', cursor: 'pointer', border: '1px solid var(--dsw-alias-state-error-primary, #c62828)', borderRadius: 4, background: 'var(--dsw-alias-bg-layer-1, #fff)', color: 'var(--dsw-alias-state-error-primary, #c62828)' }}
>
重试
</button>
+1 -1
View File
@@ -5,7 +5,7 @@
export function PlaceholderTab({ title }) {
return (
<div className="odl-root" style={{ padding: 40, textAlign: 'center', fontFamily: 'system-ui', color: '#999' }}>
<div className="odl-root" style={{ padding: 40, textAlign: 'center', fontFamily: 'system-ui', color: 'var(--dsw-alias-label-tertiary, #999)' }}>
<div style={{ fontSize: 16, marginBottom: 8 }}>{title}</div>
<div style={{ fontSize: 13 }}>功能开发中敬请期待</div>
</div>
+6 -5
View File
@@ -35,11 +35,12 @@ function useFlash(price) {
export function PriceCell({ price, lastClose }) {
const flash = useFlash(price);
const hasPrice = price != null && isFinite(price);
let color = '#999';
let color = 'var(--dsw-alias-label-tertiary, #999)';
if (hasPrice && lastClose != null && isFinite(lastClose)) {
if (price > lastClose) color = '#d32f2f';
else if (price < lastClose) color = '#2e7d32';
else color = '#666';
// A股红涨绿跌;语义色跟随宿主深浅主题自动适配
if (price > lastClose) color = 'var(--dsw-alias-state-error-primary, #d32f2f)';
else if (price < lastClose) color = 'var(--dsw-alias-state-success-primary, #2e7d32)';
else color = 'var(--dsw-alias-label-secondary, #666)';
}
return (
<td
@@ -53,7 +54,7 @@ export function PriceCell({ price, lastClose }) {
}}
>
{formatPrice(price)}
{flash > 0 && <style>{'@keyframes odl-price-flash{0%{background:rgba(255,215,0,.5)}100%{background:transparent}}'}</style>}
{flash > 0 && <style>{'@keyframes odl-price-flash{0%{background:color-mix(in srgb, var(--dsw-alias-state-warn-primary, #f9a825) 40%, transparent)}100%{background:transparent}}'}</style>}
</td>
);
}
+13 -13
View File
@@ -21,18 +21,18 @@ function MenuItem({ item, selected, onSelect }) {
onClick={() => onSelect(item)}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%', textAlign: 'left',
padding: '8px 12px', border: 'none', cursor: 'pointer', background: selected ? '#e8f5e9' : 'transparent',
fontSize: 13, color: '#333',
padding: '8px 12px', border: 'none', cursor: 'pointer', background: selected ? 'color-mix(in srgb, var(--dsw-alias-state-success-primary, #2e7d32) 12%, var(--dsw-alias-bg-layer-1, #fff))' : 'transparent',
fontSize: 13, color: 'var(--dsw-alias-label-primary, #333)',
}}
onMouseEnter={(e) => { if (!selected) e.currentTarget.style.background = '#f5f5f5'; }}
onMouseEnter={(e) => { if (!selected) e.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, #f5f5f5)'; }}
onMouseLeave={(e) => { if (!selected) e.currentTarget.style.background = 'transparent'; }}
>
<span style={{ width: 16, flex: 'none', color: '#2e7d32', fontWeight: 700 }}>{selected ? '✓' : ''}</span>
<span style={{ width: 16, flex: 'none', color: 'var(--dsw-alias-state-success-primary, #2e7d32)', fontWeight: 700 }}>{selected ? '✓' : ''}</span>
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: 'block', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.name}</span>
<span style={{ display: 'block', fontSize: 11, color: '#999', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.baseUrl}</span>
<span style={{ display: 'block', fontSize: 11, color: 'var(--dsw-alias-label-tertiary, #999)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.baseUrl}</span>
</span>
{item.isDefault && <span style={{ flex: 'none', fontSize: 11, color: '#1565c0', border: '1px solid #90caf9', borderRadius: 8, padding: '0 6px' }}>默认</span>}
{item.isDefault && <span style={{ flex: 'none', fontSize: 11, color: 'var(--dsw-alias-state-business-primary, #1565c0)', border: '1px solid var(--dsw-alias-border-l3, #90caf9)', borderRadius: 8, padding: '0 6px' }}>默认</span>}
</button>
);
}
@@ -75,7 +75,7 @@ function QmtHealthIndicator() {
}, [load]);
// 状态推导:null=灰(未知/加载中),healthy=true=绿,false=红
const color = health === null ? '#bbb' : (health.healthy ? '#2e7d32' : '#d32f2f');
const color = health === null ? 'var(--dsw-alias-label-tertiary, #bbb)' : (health.healthy ? 'var(--dsw-alias-state-success-primary, #2e7d32)' : 'var(--dsw-alias-state-error-primary, #d32f2f)');
const title = health === null
? 'QMT 健康状态获取中…(点击立即检测)'
: (health.healthy
@@ -91,7 +91,7 @@ function QmtHealthIndicator() {
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 10, height: 10, borderRadius: '50%', background: color, flex: 'none',
boxShadow: color !== '#bbb' ? '0 0 4px ' + color : 'none',
boxShadow: health === null ? 'none' : '0 0 4px ' + color,
cursor: 'pointer',
opacity: refreshing ? 0.5 : 1,
transition: 'opacity .2s',
@@ -181,13 +181,13 @@ function ChipInner() {
role="menu"
style={{
position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 10001,
minWidth: 220, maxWidth: 300, background: '#fff', border: '1px solid #ddd',
borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.15)', padding: '4px 0',
minWidth: 220, maxWidth: 300, background: 'var(--dsw-alias-bg-layer-1, #fff)', border: '1px solid var(--dsw-alias-border-l2, #ddd)',
borderRadius: 8, boxShadow: 'var(--dsw-shadow-lv3, 0 4px 16px rgba(0,0,0,0.15))', padding: '4px 0',
}}
>
<div style={{ padding: '4px 12px', fontSize: 11, color: '#999' }}>切换 QMT 连接</div>
<div style={{ padding: '4px 12px', fontSize: 11, color: 'var(--dsw-alias-label-tertiary, #999)' }}>切换 QMT 连接</div>
{state.list.length === 0 ? (
<div style={{ padding: '10px 12px', fontSize: 12, color: '#888' }}>
<div style={{ padding: '10px 12px', fontSize: 12, color: 'var(--dsw-alias-label-secondary, #888)' }}>
暂无连接配置请进 设置 神之一手 QMT 连接配置 添加
</div>
) : (
@@ -200,7 +200,7 @@ function ChipInner() {
/>
))
)}
<div style={{ borderTop: '1px solid #eee', marginTop: 4, padding: '6px 12px', fontSize: 11, color: '#999' }}>
<div style={{ borderTop: '1px solid var(--dsw-alias-border-l1, #eee)', marginTop: 4, padding: '6px 12px', fontSize: 11, color: 'var(--dsw-alias-label-tertiary, #999)' }}>
管理配置请进 设置 神之一手 QMT 连接配置
</div>
</div>
+7 -7
View File
@@ -154,7 +154,7 @@ export function RangeSelector({ preset, onPresetChange, range, onChange, onLastD
if (error || !lastDate) {
return (
<div style={{ fontSize: 13, color: '#c62828' }}>
<div style={{ fontSize: 13, color: 'var(--dsw-alias-state-error-primary, #c62828)' }}>
{error || '交易日历加载中...'}
<button onClick={load} style={{ marginLeft: 8, padding: '2px 10px', cursor: 'pointer' }}>重试</button>
</div>
@@ -170,17 +170,17 @@ export function RangeSelector({ preset, onPresetChange, range, onChange, onLastD
padding: '2px 10px',
fontSize: 12,
cursor: 'pointer',
border: active ? '1px solid #2e7d32' : '1px solid #ccc',
border: active ? '1px solid var(--dsw-alias-state-success-primary, #2e7d32)' : '1px solid var(--dsw-alias-border-l2, #ccc)',
borderRadius: 4,
background: active ? '#e8f5e9' : '#fff',
color: active ? '#1b5e20' : '#555',
background: active ? 'color-mix(in srgb, var(--dsw-alias-state-success-primary, #2e7d32) 12%, var(--dsw-alias-bg-layer-1, #fff))' : 'var(--dsw-alias-bg-layer-1, #fff)',
color: active ? 'var(--dsw-alias-state-success-primary, #1b5e20)' : 'var(--dsw-alias-label-secondary, #555)',
whiteSpace: 'nowrap',
});
const inputStyle = {
fontSize: 12,
padding: '2px 4px',
border: '1px solid #ccc',
border: '1px solid var(--dsw-alias-border-l2, #ccc)',
borderRadius: 4,
width: 112,
};
@@ -190,9 +190,9 @@ export function RangeSelector({ preset, onPresetChange, range, onChange, onLastD
<button style={btnStyle(activeToday)} onClick={() => applyPreset('today')} title="最近交易日">今日</button>
<button style={btnStyle(activeThisWeek)} onClick={() => applyPreset('thisWeek')} title="本周一至本周五(不考虑周六日)">本周</button>
<button style={btnStyle(activeThisMonth)} onClick={() => applyPreset('thisMonth')} title="本月1号至本月最后一天(完整自然月)">本月</button>
<span style={{ color: '#999' }}>|</span>
<span style={{ color: 'var(--dsw-alias-label-tertiary, #999)' }}>|</span>
<input type="date" value={range ? fmt(range.start) : ''} onChange={onStartChange} style={inputStyle} title="起始日期" />
<span style={{ color: '#999' }}>~</span>
<span style={{ color: 'var(--dsw-alias-label-tertiary, #999)' }}>~</span>
<input type="date" value={range ? fmt(range.end) : ''} onChange={onEndChange} style={inputStyle} title="结束日期" />
</div>
);
+140 -137
View File
@@ -1,10 +1,12 @@
/**
* 神之一手设置 section(迭代 02:设置 tab 化扩展)
* 神之一手设置 section
*
* 结构:
* - 三个子 tab通用设置 / 策略分组 / QMT 连接配置(迭代 03R-004
* - 通用设置:三个 switch(全部持仓 / 交易记录 / 关注列表),控制对应会话 tab 显隐,切换后提示「刷新页面后生效」
* - 策略分组:策略 CRUD(新增/重命名/删除确认/排序/显隐)+ 就地提示
* 结构R-011 迭代 09
* - 三个子 tabTab 设置 / 策略分组 / QMT 连接配置
* - Tab 设置:统一管理所有会话 tab(内置 + 策略分组混排),拖动排序(落点立即持久化)+ 显示/隐藏开关;
* 任何 tab 均不支持重命名/删除(产品约束-009、UI约束-003)
* - 策略分组:策略 CRUD(新增/重命名/删除确认)+ 提示「顺序与显示请在 Tab 设置中调整」
* - QMT 连接配置:R-004 卡片形式(迭代 03)
*/
import { useState, useEffect, useCallback } from 'react';
@@ -15,17 +17,17 @@ import { ToastProvider, useToast } from './Toast.jsx';
function ConfirmDialog({ title, message, onConfirm, onCancel }) {
return (
<div style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)',
position: 'fixed', inset: 0, background: 'var(--dsw-alias-bg-mask-1, rgba(0,0,0,0.4))',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10000,
}}>
<div style={{ background: '#fff', padding: 24, borderRadius: 8, maxWidth: 420, width: '90%' }}>
<div style={{ background: 'var(--dsw-alias-bg-layer-1, #fff)', padding: 24, borderRadius: 8, maxWidth: 420, width: '90%' }}>
<h3 style={{ margin: '0 0 12px', fontSize: 16 }}>{title}</h3>
<div style={{ fontSize: 14, color: '#555', marginBottom: 20 }}>{message}</div>
<div style={{ fontSize: 14, color: 'var(--dsw-alias-label-secondary, #555)', marginBottom: 20 }}>{message}</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
<button onClick={onCancel} style={{ padding: '6px 16px', cursor: 'pointer', border: '1px solid #ccc', borderRadius: 4, background: '#fff' }}>
<button onClick={onCancel} style={{ padding: '6px 16px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-bg-layer-1, #fff)' }}>
取消
</button>
<button onClick={onConfirm} style={{ padding: '6px 16px', cursor: 'pointer', border: 'none', borderRadius: 4, background: '#c62828', color: '#fff' }}>
<button onClick={onConfirm} style={{ padding: '6px 16px', cursor: 'pointer', border: 'none', borderRadius: 4, background: 'var(--dsw-alias-state-error-primary, #c62828)', color: 'var(--dsw-alias-button-contrast-fill, #fff)' }}>
确认
</button>
</div>
@@ -42,30 +44,32 @@ function Switch({ checked, onChange, disabled }) {
disabled={disabled}
style={{
width: 44, height: 24, borderRadius: 12, border: 'none', cursor: disabled ? 'not-allowed' : 'pointer',
background: checked ? '#2e7d32' : '#9e9e9e', position: 'relative', padding: 0, transition: 'background 0.2s',
background: checked ? 'var(--dsw-alias-state-success-primary, #2e7d32)' : 'var(--dsw-alias-border-l4, #9e9e9e)', position: 'relative', padding: 0, transition: 'background 0.2s',
opacity: disabled ? 0.6 : 1,
}}
>
<span style={{
position: 'absolute', top: 2, left: checked ? 22 : 2, width: 20, height: 20,
borderRadius: '50%', background: '#fff', transition: 'left 0.2s',
borderRadius: '50%', background: 'var(--dsw-alias-button-contrast-fill, #fff)', transition: 'left 0.2s',
}} />
</button>
);
}
/** 通用设置子 tab */
function GeneralSettings({ onChanged }) {
const [tabConfig, setTabConfig] = useState(null);
/** Tab 设置子 tab(R-011:统一管理所有会话 tab —— 内置 + 策略分组混排,拖动排序 + 显隐开关) */
function TabSettings() {
const [tabs, setTabs] = useState(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const [dragId, setDragId] = useState(null);
const [overId, setOverId] = useState(null);
const call = useRpc();
const toast = useToast();
const load = useCallback(async () => {
try {
const res = await call('one-divine-lot/tabs', {});
if (res && res.ok) setTabConfig(res.value);
if (res && res.ok && Array.isArray(res.value)) setTabs(res.value);
else setError((res && res.error && res.error.message) || '加载失败');
} catch (e) {
setError(e.message);
@@ -74,48 +78,97 @@ function GeneralSettings({ onChanged }) {
useEffect(() => { load(); }, [load]);
const toggle = async (key, label) => {
if (!tabConfig) return;
// 整表持久化(拖动落点 / 显隐切换共用;Q1 落点立即持久化)
const persist = async (nextTabs, msg) => {
setSaving(true);
try {
const next = { ...tabConfig, [key]: !tabConfig[key] };
const res = await call('one-divine-lot/tabs/update', { args: { tabs: next } });
const res = await call('one-divine-lot/tabs/update', { args: { tabs: nextTabs } });
if (res && res.ok) {
setTabConfig(next);
toast.success(`${label}」已${next[key] ? '显示' : '隐藏'}`);
onChanged?.(label, next[key]);
setTabs(res.value);
toast.success(msg + '(刷新页面后生效)');
} else {
toast.error((res && res.error && res.error.message) || '保存失败');
await load();
}
} catch (e) {
toast.error(e.message);
await load();
} finally {
setSaving(false);
}
};
const items = [
{ key: 'allPositions', label: '全部持仓', desc: '会话窗中的「全部持仓」标签页' },
{ key: 'tradeRecords', label: '交易记录', desc: '会话窗中的「交易记录」标签页(开发中)' },
{ key: 'watchlist', label: '关注列表', desc: '会话窗中的「关注列表」标签页(开发中)' },
];
const toggleVisible = async (t) => {
if (!tabs) return;
const next = tabs.map((x) => (x.id === t.id ? { ...x, visible: !x.visible } : x));
await persist(next, '「' + t.name + '」已' + (next.find((x) => x.id === t.id).visible ? '显示' : '隐藏'));
};
// 原生 HTML5 拖动:落点重排 + 立即持久化(Q1)
const handleDrop = async (targetId) => {
if (!tabs || !dragId || dragId === targetId) { setDragId(null); setOverId(null); return; }
const from = tabs.findIndex((x) => x.id === dragId);
const to = tabs.findIndex((x) => x.id === targetId);
if (from < 0 || to < 0) { setDragId(null); setOverId(null); return; }
const next = tabs.slice();
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
// 重写 order(整表连续)
const ordered = next.map((x, i) => ({ ...x, order: i }));
setDragId(null);
setOverId(null);
await persist(ordered, '「' + moved.name + '」已移动');
};
const badgeStyle = (kind) => ({
fontSize: 11, borderRadius: 8, padding: '0 6px', marginRight: 6, flex: 'none',
color: kind === 'builtin' ? 'var(--dsw-alias-state-business-primary, #1565c0)' : 'var(--dsw-alias-state-warn-primary, #6a1b9a)',
border: kind === 'builtin' ? '1px solid var(--dsw-alias-border-l3, #90caf9)' : '1px solid var(--dsw-alias-border-l3, #ce93d8)',
});
return (
<div>
{error && <div style={{ color: '#c62828', marginBottom: 12 }}>{error}</div>}
{!tabConfig && !error && <div style={{ color: '#666' }}>加载中...</div>}
{tabConfig && items.map((item) => (
<div key={item.key} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '12px 0', borderBottom: '1px solid #eee',
}}>
<div>
<div style={{ fontSize: 14, fontWeight: 500 }}>{item.label}</div>
<div style={{ fontSize: 12, color: '#888', marginTop: 2 }}>{item.desc}</div>
</div>
<Switch checked={tabConfig[item.key] !== false} onChange={() => toggle(item.key, item.label)} disabled={saving} />
<div style={{ fontSize: 12, color: 'var(--dsw-alias-label-secondary, #888)', marginBottom: 12 }}>
拖动调整所有标签页顺序开关控制显示/隐藏内置与策略标签可任意混排名称不支持在此修改策略名称在策略分组中修改
</div>
{error && <div style={{ color: 'var(--dsw-alias-state-error-primary, #c62828)', marginBottom: 12 }}>{error}</div>}
{!tabs && !error && <div style={{ color: 'var(--dsw-alias-label-secondary, #666)' }}>加载中...</div>}
{tabs && (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '2px solid var(--dsw-alias-border-l2, #ddd)' }}>
<th style={{ width: 44, padding: '8px 6px' }}></th>
<th style={{ padding: '8px 6px' }}>标签页</th>
<th style={{ width: 80, padding: '8px 6px' }}>显示</th>
</tr>
</thead>
<tbody>
{tabs.map((t) => (
<tr
key={t.id}
draggable={!saving}
onDragStart={(e) => { setDragId(t.id); e.dataTransfer.effectAllowed = 'move'; }}
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setOverId(t.id); }}
onDragLeave={() => { if (overId === t.id) setOverId(null); }}
onDrop={(e) => { e.preventDefault(); handleDrop(t.id); }}
style={{
borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)', cursor: saving ? 'default' : 'grab',
background: overId === t.id ? 'color-mix(in srgb, var(--dsw-alias-state-business-primary, #1565c0) 12%, transparent)' : 'transparent',
}}
>
<td style={{ padding: '8px 6px', color: 'var(--dsw-alias-label-tertiary, #999)', textAlign: 'center' }}></td>
<td style={{ padding: '8px 6px' }}>
<span style={badgeStyle(t.kind)}>{t.kind === 'builtin' ? '内置' : '策略'}</span>
{t.name}
</td>
<td style={{ padding: '8px 6px' }}>
<Switch checked={t.visible !== false} onChange={() => toggleVisible(t)} disabled={saving} />
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
@@ -210,53 +263,18 @@ function StrategyGroupSettings() {
}
};
const handleMove = async (id, dir) => {
setSaving(true);
try {
const res = await call('one-divine-lot/strategies/move', { args: { strategyId: id, dir } });
if (res && res.ok) {
await load();
} else {
toast.error((res && res.error && res.error.message) || '排序失败');
}
} catch (e) {
toast.error(e.message);
} finally {
setSaving(false);
}
};
const toggleVisible = async (id) => {
if (!strategies) return;
setSaving(true);
try {
const next = strategies.map((s) => (s.id === id ? { ...s, visible: !s.visible } : s));
const res = await call('one-divine-lot/strategies/update', { args: { strategies: next } });
if (res && res.ok) {
setStrategies(next);
handleChanged('显示设置已更新');
} else {
toast.error((res && res.error && res.error.message) || '保存失败');
}
} catch (e) {
toast.error(e.message);
} finally {
setSaving(false);
}
};
return (
<div>
{changedMsg && (
<div style={{
padding: '10px 14px', marginBottom: 12, borderRadius: 6,
background: '#e3f2fd', color: '#1565c0', fontSize: 13, border: '1px solid #90caf9',
background: 'color-mix(in srgb, var(--dsw-alias-state-business-primary, #1565c0) 10%, var(--dsw-alias-bg-layer-1, #fff))', color: 'var(--dsw-alias-state-business-primary, #1565c0)', fontSize: 13, border: '1px solid var(--dsw-alias-border-l3, #90caf9)',
}}>
{changedMsg} <strong>刷新页面后生效</strong>
</div>
)}
{error && <div style={{ color: '#c62828', marginBottom: 12 }}>{error}</div>}
{error && <div style={{ color: 'var(--dsw-alias-state-error-primary, #c62828)', marginBottom: 12 }}>{error}</div>}
<div style={{ display: 'flex', gap: 8, marginBottom: 16, alignItems: 'center' }}>
<input
@@ -265,44 +283,34 @@ function StrategyGroupSettings() {
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
style={{ flex: 1, padding: '6px 10px', border: '1px solid #ccc', borderRadius: 4 }}
style={{ flex: 1, padding: '6px 10px', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4 }}
/>
<button
onClick={handleAdd}
disabled={saving}
style={{ padding: '6px 16px', cursor: 'pointer', border: 'none', borderRadius: 4, background: '#2e7d32', color: '#fff' }}
style={{ padding: '6px 16px', cursor: 'pointer', border: 'none', borderRadius: 4, background: 'var(--dsw-alias-state-success-primary, #2e7d32)', color: 'var(--dsw-alias-button-contrast-fill, #fff)' }}
>
+ 新增策略
</button>
</div>
{!strategies && !error && <div style={{ color: '#666' }}>加载中...</div>}
<div style={{ fontSize: 12, color: 'var(--dsw-alias-label-secondary, #888)', marginBottom: 12 }}>
顺序与显示请在Tab 设置中调整
</div>
{!strategies && !error && <div style={{ color: 'var(--dsw-alias-label-secondary, #666)' }}>加载中...</div>}
{strategies && (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
<th style={{ padding: '8px 6px' }}>排序</th>
<tr style={{ textAlign: 'left', borderBottom: '2px solid var(--dsw-alias-border-l2, #ddd)' }}>
<th style={{ padding: '8px 6px' }}>策略</th>
<th style={{ padding: '8px 6px' }}>ID</th>
<th style={{ padding: '8px 6px' }}>显示</th>
<th style={{ padding: '8px 6px' }}>操作</th>
</tr>
</thead>
<tbody>
{strategies.map((s, idx) => (
<tr key={s.id} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: '8px 6px', whiteSpace: 'nowrap' }}>
<button
onClick={() => handleMove(s.id, 'up')}
disabled={saving || idx === 0}
style={{ padding: '2px 6px', cursor: idx === 0 ? 'not-allowed' : 'pointer', border: '1px solid #ccc', borderRadius: 3, background: '#fff', marginRight: 4 }}
></button>
<button
onClick={() => handleMove(s.id, 'down')}
disabled={saving || idx === strategies.length - 1}
style={{ padding: '2px 6px', cursor: idx === strategies.length - 1 ? 'not-allowed' : 'pointer', border: '1px solid #ccc', borderRadius: 3, background: '#fff' }}
></button>
</td>
{strategies.map((s) => (
<tr key={s.id} style={{ borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)' }}>
<td style={{ padding: '8px 6px' }}>
{editingId === s.id ? (
<span>
@@ -320,18 +328,15 @@ function StrategyGroupSettings() {
s.name
)}
</td>
<td style={{ padding: '8px 6px', color: '#666', fontSize: 12 }}>{s.id}</td>
<td style={{ padding: '8px 6px' }}>
<Switch checked={s.visible !== false} onChange={() => toggleVisible(s.id)} disabled={saving} />
</td>
<td style={{ padding: '8px 6px', color: 'var(--dsw-alias-label-secondary, #666)', fontSize: 12 }}>{s.id}</td>
<td style={{ padding: '8px 6px', whiteSpace: 'nowrap' }}>
<button
onClick={() => { setEditingId(s.id); setEditingName(s.name); }}
style={{ padding: '2px 8px', cursor: 'pointer', border: '1px solid #ccc', borderRadius: 3, background: '#fff', marginRight: 4 }}
style={{ padding: '2px 8px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 3, background: 'var(--dsw-alias-bg-layer-1, #fff)', marginRight: 4 }}
>重命名</button>
<button
onClick={() => setConfirmDelete({ id: s.id, name: s.name })}
style={{ padding: '2px 8px', cursor: 'pointer', border: '1px solid #c62828', borderRadius: 3, background: '#fff', color: '#c62828' }}
style={{ padding: '2px 8px', cursor: 'pointer', border: '1px solid var(--dsw-alias-state-error-primary, #c62828)', borderRadius: 3, background: 'var(--dsw-alias-bg-layer-1, #fff)', color: 'var(--dsw-alias-state-error-primary, #c62828)' }}
>删除</button>
</td>
</tr>
@@ -354,16 +359,16 @@ function StrategyGroupSettings() {
/** 设置 section 主组件:三个子 tab */
function SettingsSectionInner(props) {
const [activeTab, setActiveTab] = useState('general'); // general | strategies | qmt
const [activeTab, setActiveTab] = useState('tabs'); // tabs | strategies | qmt
return (
<div style={{ padding: 20, fontFamily: 'system-ui' }}>
<h2 style={{ margin: '0 0 16px' }}>神之一手</h2>
{/* 子 tab 导航 */}
<div style={{ display: 'flex', gap: 4, borderBottom: '1px solid #ddd', marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 4, borderBottom: '1px solid var(--dsw-alias-border-l2, #ddd)', marginBottom: 16 }}>
{[
{ key: 'general', label: '通用设置' },
{ key: 'tabs', label: 'Tab 设置' },
{ key: 'strategies', label: '策略分组' },
{ key: 'qmt', label: 'QMT 连接配置' },
].map((tab) => (
@@ -371,8 +376,8 @@ function SettingsSectionInner(props) {
key={tab.key}
onClick={() => setActiveTab(tab.key)}
style={{
padding: '8px 16px', cursor: 'pointer', border: 'none', borderBottom: activeTab === tab.key ? '2px solid #2e7d32' : '2px solid transparent',
background: 'none', fontSize: 14, color: activeTab === tab.key ? '#2e7d32' : '#666', fontWeight: activeTab === tab.key ? 600 : 400,
padding: '8px 16px', cursor: 'pointer', border: 'none', borderBottom: activeTab === tab.key ? '2px solid var(--dsw-alias-state-success-primary, #2e7d32)' : '2px solid transparent',
background: 'none', fontSize: 14, color: activeTab === tab.key ? 'var(--dsw-alias-state-success-primary, #2e7d32)' : 'var(--dsw-alias-label-secondary, #666)', fontWeight: activeTab === tab.key ? 600 : 400,
}}
>
{tab.label}
@@ -380,9 +385,7 @@ function SettingsSectionInner(props) {
))}
</div>
{activeTab === 'general' && <GeneralSettings onChanged={(label, visible) => {
// 通用设置变更 → 就地提示(由 GeneralSettings 内 toast 提示,这里可额外提示)
}} />}
{activeTab === 'tabs' && <TabSettings />}
{activeTab === 'strategies' && <StrategyGroupSettings />}
{activeTab === 'qmt' && <QmtConnectionsSettings />}
</div>
@@ -411,34 +414,34 @@ function EllipsisText({ text, maxWidth = 220, title, style }) {
/** QMT 连接配置卡片(R-004;产品约束-005、UI约束-0022026-08-29 老师反馈改卡片形式) */
function QmtConnectionCard({ conn, isActive, isDefault, busy, testResult, onActivate, onSetDefault, onTest, onEdit, onDelete }) {
const btn = { padding: '3px 10px', cursor: 'pointer', border: '1px solid #ccc', borderRadius: 4, background: '#fff', fontSize: 12 };
const btn = { padding: '3px 10px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-bg-layer-1, #fff)', fontSize: 12 };
return (
<div style={{
border: isActive ? '1px solid #2e7d32' : '1px solid #ddd',
background: isActive ? '#f1f8f2' : '#fff',
border: isActive ? '1px solid var(--dsw-alias-state-success-primary, #2e7d32)' : '1px solid var(--dsw-alias-border-l2, #ddd)',
background: isActive ? 'color-mix(in srgb, var(--dsw-alias-state-success-primary, #2e7d32) 8%, var(--dsw-alias-bg-layer-1, #fff))' : 'var(--dsw-alias-bg-layer-1, #fff)',
borderRadius: 8,
padding: '12px 14px',
display: 'flex', flexDirection: 'column', gap: 8,
minWidth: 0, overflow: 'hidden',
boxShadow: '0 1px 2px rgba(0,0,0,0.04)',
boxShadow: 'var(--dsw-shadow-lv3, 0 1px 2px rgba(0,0,0,0.04))',
}}>
{/* 标题行:名称 + 徽标 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
<EllipsisText text={conn.name} maxWidth={140} style={{ fontWeight: 600, fontSize: 14 }} />
{isActive && <span style={{ flex: 'none', fontSize: 11, color: '#2e7d32', border: '1px solid #2e7d32', borderRadius: 8, padding: '0 6px' }}>生效中</span>}
{isDefault && <span style={{ flex: 'none', fontSize: 11, color: '#1565c0', border: '1px solid #90caf9', borderRadius: 8, padding: '0 6px' }}>默认</span>}
{isActive && <span style={{ flex: 'none', fontSize: 11, color: 'var(--dsw-alias-state-success-primary, #2e7d32)', border: '1px solid var(--dsw-alias-state-success-primary, #2e7d32)', borderRadius: 8, padding: '0 6px' }}>生效中</span>}
{isDefault && <span style={{ flex: 'none', fontSize: 11, color: 'var(--dsw-alias-state-business-primary, #1565c0)', border: '1px solid var(--dsw-alias-border-l3, #90caf9)', borderRadius: 8, padding: '0 6px' }}>默认</span>}
</div>
{/* 地址行:省略+悬停 */}
<div style={{ fontSize: 12, color: '#666', minWidth: 0 }}>
<div style={{ fontSize: 12, color: 'var(--dsw-alias-label-secondary, #666)', minWidth: 0 }}>
<EllipsisText text={conn.baseUrl} maxWidth={240} />
</div>
{/* 测试结果行:可换行但受卡片约束;超长悬停看全文 */}
{testResult && (
<div style={{
fontSize: 12, lineHeight: '16px', color: testResult.ok ? '#2e7d32' : '#c62828',
background: testResult.ok ? '#e8f5e9' : '#fdecea',
fontSize: 12, lineHeight: '16px', color: testResult.ok ? 'var(--dsw-alias-state-success-primary, #2e7d32)' : 'var(--dsw-alias-state-error-primary, #c62828)',
background: testResult.ok ? 'color-mix(in srgb, var(--dsw-alias-state-success-primary, #2e7d32) 10%, var(--dsw-alias-bg-layer-1, #fff))' : 'color-mix(in srgb, var(--dsw-alias-state-error-primary, #c62828) 10%, var(--dsw-alias-bg-layer-1, #fff))',
borderRadius: 4, padding: '4px 8px', minWidth: 0,
}}>
<EllipsisText text={testResult.text} maxWidth={9999} title={testResult.fullText || testResult.text} />
@@ -447,11 +450,11 @@ function QmtConnectionCard({ conn, isActive, isDefault, busy, testResult, onActi
{/* 操作行:按钮自动换行排列,不挤压卡片 */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 2 }}>
{!isActive && <button onClick={onActivate} disabled={busy} style={{ ...btn, color: '#2e7d32', borderColor: '#2e7d32' }}>激活</button>}
{!isActive && <button onClick={onActivate} disabled={busy} style={{ ...btn, color: 'var(--dsw-alias-state-success-primary, #2e7d32)', borderColor: 'var(--dsw-alias-state-success-primary, #2e7d32)' }}>激活</button>}
{!isDefault && <button onClick={onSetDefault} disabled={busy} style={btn}>设为默认</button>}
<button onClick={onTest} disabled={busy}>{busy ? '测试中…' : '测试连接'}</button>
<button onClick={onEdit} disabled={busy} style={btn}>编辑</button>
<button onClick={onDelete} disabled={busy} style={{ ...btn, color: '#c62828', borderColor: '#c62828' }}>删除</button>
<button onClick={onDelete} disabled={busy} style={{ ...btn, color: 'var(--dsw-alias-state-error-primary, #c62828)', borderColor: 'var(--dsw-alias-state-error-primary, #c62828)' }}>删除</button>
</div>
</div>
);
@@ -620,19 +623,19 @@ function QmtConnectionsSettings() {
return (
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<div style={{ fontSize: 13, color: '#666', minWidth: 0 }}>
<div style={{ fontSize: 13, color: 'var(--dsw-alias-label-secondary, #666)', minWidth: 0 }}>
激活配置为当前生效连接切换立即生效默认配置在插件启动时自动激活
</div>
<button onClick={openAdd} disabled={saving} style={{ flex: 'none', padding: '6px 16px', cursor: 'pointer', border: 'none', borderRadius: 4, background: '#2e7d32', color: '#fff' }}>
<button onClick={openAdd} disabled={saving} style={{ flex: 'none', padding: '6px 16px', cursor: 'pointer', border: 'none', borderRadius: 4, background: 'var(--dsw-alias-state-success-primary, #2e7d32)', color: 'var(--dsw-alias-button-contrast-fill, #fff)' }}>
+ 新增连接
</button>
</div>
{error && <div style={{ color: '#c62828', marginBottom: 12 }}><EllipsisText text={error} maxWidth={560} /></div>}
{!state && !error && <div style={{ color: '#666' }}>加载中...</div>}
{error && <div style={{ color: 'var(--dsw-alias-state-error-primary, #c62828)', marginBottom: 12 }}><EllipsisText text={error} maxWidth={560} /></div>}
{!state && !error && <div style={{ color: 'var(--dsw-alias-label-secondary, #666)' }}>加载中...</div>}
{state && state.list.length === 0 && (
<div style={{ padding: '24px 0', color: '#888', fontSize: 13 }}>
<div style={{ padding: '24px 0', color: 'var(--dsw-alias-label-secondary, #888)', fontSize: 13 }}>
暂无连接配置点击新增连接录入第一个 QMT Bridge 地址 http://192.168.3.43:8610)。
</div>
)}
@@ -662,40 +665,40 @@ function QmtConnectionsSettings() {
)}
{formOpen && (
<div style={{ marginTop: 16, padding: 16, border: '1px solid #ddd', borderRadius: 8, background: '#fafafa', maxWidth: 640 }}>
<div style={{ marginTop: 16, padding: 16, border: '1px solid var(--dsw-alias-border-l2, #ddd)', borderRadius: 8, background: 'var(--dsw-alias-bg-layer-2, #fafafa)', maxWidth: 640 }}>
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 12 }}>{editingId === null ? '新增连接配置' : '编辑连接配置'}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 10 }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12, color: '#666' }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12, color: 'var(--dsw-alias-label-secondary, #666)' }}>
连接名称
<input type="text" placeholder="如:本机 QMT" value={formName}
onChange={(e) => setFormName(e.target.value)}
style={{ padding: '6px 10px', border: '1px solid #ccc', borderRadius: 4, fontSize: 13 }} />
style={{ padding: '6px 10px', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, fontSize: 13 }} />
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12, color: '#666' }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12, color: 'var(--dsw-alias-label-secondary, #666)' }}>
HTTP 地址
<input type="text" placeholder="如 http://192.168.3.43:8610" value={formUrl}
onChange={(e) => setFormUrl(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
style={{ padding: '6px 10px', border: '1px solid #ccc', borderRadius: 4, fontSize: 13 }} />
style={{ padding: '6px 10px', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, fontSize: 13 }} />
</label>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
<button onClick={handleSave} disabled={saving}
style={{ padding: '6px 16px', cursor: 'pointer', border: 'none', borderRadius: 4, background: '#2e7d32', color: '#fff' }}>
style={{ padding: '6px 16px', cursor: 'pointer', border: 'none', borderRadius: 4, background: 'var(--dsw-alias-state-success-primary, #2e7d32)', color: 'var(--dsw-alias-button-contrast-fill, #fff)' }}>
保存
</button>
<button onClick={handleTestForm} disabled={testingId !== null}
style={{ padding: '3px 10px', cursor: 'pointer', border: '1px solid #ccc', borderRadius: 4, background: '#fff', fontSize: 12 }}>
style={{ padding: '3px 10px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-bg-layer-1, #fff)', fontSize: 12 }}>
{testingId === '__form__' ? '测试中…' : '测试连接'}
</button>
<button onClick={closeForm} disabled={saving}
style={{ padding: '3px 10px', cursor: 'pointer', border: '1px solid #ccc', borderRadius: 4, background: '#fff', fontSize: 12 }}>
style={{ padding: '3px 10px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-bg-layer-1, #fff)', fontSize: 12 }}>
取消
</button>
{testResult && testResult.key === '__form__' && (
<span style={{
fontSize: 12, color: testResult.ok ? '#2e7d32' : '#c62828',
background: testResult.ok ? '#e8f5e9' : '#fdecea', borderRadius: 4, padding: '3px 8px',
fontSize: 12, color: testResult.ok ? 'var(--dsw-alias-state-success-primary, #2e7d32)' : 'var(--dsw-alias-state-error-primary, #c62828)',
background: testResult.ok ? 'color-mix(in srgb, var(--dsw-alias-state-success-primary, #2e7d32) 10%, var(--dsw-alias-bg-layer-1, #fff))' : 'color-mix(in srgb, var(--dsw-alias-state-error-primary, #c62828) 10%, var(--dsw-alias-bg-layer-1, #fff))', borderRadius: 4, padding: '3px 8px',
minWidth: 0,
}}>
<EllipsisText text={testResult.text} maxWidth={360} title={testResult.fullText || testResult.text} />
+24 -24
View File
@@ -39,10 +39,10 @@ function calcPctChange(snap) {
/** 涨幅着色:涨红跌绿(A股习惯) */
function pctStyle(pct) {
if (pct == null) return { color: '#999' };
if (pct > 0) return { color: '#d32f2f', fontWeight: 'bold' };
if (pct < 0) return { color: '#2e7d32', fontWeight: 'bold' };
return { color: '#999' };
if (pct == null) return { color: 'var(--dsw-alias-label-tertiary, #999)' };
if (pct > 0) return { color: 'var(--dsw-alias-state-error-primary, #d32f2f)', fontWeight: 'bold' };
if (pct < 0) return { color: 'var(--dsw-alias-state-success-primary, #2e7d32)', fontWeight: 'bold' };
return { color: 'var(--dsw-alias-label-tertiary, #999)' };
}
/** 涨幅文本:+x.xx% / -x.xx% / — */
@@ -60,10 +60,10 @@ const ADD_FORM_CSS = `
align-items: center;
gap: 10px;
padding: 12px;
border: 1px solid #ddd;
border: 1px solid var(--dsw-alias-border-l2, #ddd);
border-radius: 6px;
margin-bottom: 12px;
background: #fafafa;
background: var(--dsw-alias-bg-layer-1, #fafafa);
}
.odl-add-form .odl-field {
flex: 1 1 260px;
@@ -94,8 +94,8 @@ const ADD_FORM_CSS = `
margin-left: auto;
padding: 6px 16px;
cursor: pointer;
background: #2e7d32;
color: #fff;
background: var(--dsw-alias-state-success-primary, #2e7d32);
color: var(--dsw-alias-button-contrast-fill, #fff);
border: none;
border-radius: 4px;
}
@@ -288,7 +288,7 @@ function StrategyTabInner({ strategyId, strategyName }) {
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={() => setShowAdd(!showAdd)}
style={{ padding: '6px 12px', cursor: 'pointer', border: '1px solid #ccc', borderRadius: 4, background: '#f5f5f5' }}
style={{ padding: '6px 12px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-interactive-bg-hover, #f5f5f5)' }}
>
{showAdd ? '取消' : '+ 添加持仓'}
</button>
@@ -329,13 +329,13 @@ function StrategyTabInner({ strategyId, strategyName }) {
)}
{positions && positions.length === 0 ? (
<div style={{ padding: 24, textAlign: 'center', color: '#999' }}>
<div style={{ padding: 24, textAlign: 'center', color: 'var(--dsw-alias-label-tertiary, #999)' }}>
暂无持仓点击+ 添加持仓从全部持仓中添加
</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
<tr style={{ textAlign: 'left', borderBottom: '2px solid var(--dsw-alias-border-l2, #ddd)' }}>
<th style={{ padding: '8px 6px', lineHeight: '24px', width: 28 }}> </th>
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>代码</th>
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>名称</th>
@@ -359,9 +359,9 @@ function StrategyTabInner({ strategyId, strategyName }) {
<tr
onClick={() => toggleExpand(p.code, p.holdingId)}
style={{
borderBottom: '1px solid #eee',
borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)',
cursor: hasHolding ? 'pointer' : 'default',
background: isExpanded ? '#f5f5f5' : 'transparent',
background: isExpanded ? 'var(--dsw-alias-interactive-bg-hover, #f5f5f5)' : 'transparent',
}}
>
<td style={{ padding: '8px 6px', textAlign: 'center', width: 28 }}>
@@ -374,7 +374,7 @@ function StrategyTabInner({ strategyId, strategyName }) {
display: 'inline-block',
transition: 'transform .15s ease',
transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
color: '#2e7d32',
color: 'var(--dsw-alias-state-success-primary, #2e7d32)',
verticalAlign: 'middle',
}}
>
@@ -388,7 +388,7 @@ function StrategyTabInner({ strategyId, strategyName }) {
/>
</svg>
) : (
<span style={{ color: '#ccc', fontSize: 10 }}>·</span>
<span style={{ color: 'var(--dsw-alias-border-l3, #ccc)', fontSize: 10 }}>·</span>
)}
</td>
<td style={{ padding: '8px 6px' }}>{p.code}</td>
@@ -421,7 +421,7 @@ function StrategyTabInner({ strategyId, strategyName }) {
<span>
<button
onClick={() => handleMoveAllIn(p.code)}
style={{ padding: '2px 8px', cursor: 'pointer', border: '1px solid #2e7d32', borderRadius: 3, background: '#fff', color: '#2e7d32', marginRight: 4 }}
style={{ padding: '2px 8px', cursor: 'pointer', border: '1px solid var(--dsw-alias-state-success-primary, #2e7d32)', borderRadius: 3, background: 'var(--dsw-alias-bg-layer-1, #fff)', color: 'var(--dsw-alias-state-success-primary, #2e7d32)', marginRight: 4 }}
title="将该票全部未分配份额移入本策略"
>
全部移入
@@ -437,12 +437,12 @@ function StrategyTabInner({ strategyId, strategyName }) {
</td>
</tr>
{isExpanded && (
<tr style={{ borderBottom: '1px solid #eee', background: '#fafafa' }}>
<tr style={{ borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)', background: 'var(--dsw-alias-bg-layer-2, #fafafa)' }}>
<td colSpan={10} style={{ padding: '4px 8px 12px' }}>
{isLoading ? (
<div style={{ padding: 8, color: '#999', fontSize: 12 }}>交易记录加载中...</div>
<div style={{ padding: 8, color: 'var(--dsw-alias-label-tertiary, #999)', fontSize: 12 }}>交易记录加载中...</div>
) : !trades || trades.length === 0 ? (
<div style={{ padding: 8, color: '#999', fontSize: 12 }}>
<div style={{ padding: 8, color: 'var(--dsw-alias-label-tertiary, #999)', fontSize: 12 }}>
{hasHolding ? '该持仓暂无关联交易记录(可在「交易记录」tab 为委托设置归属)' : '该持仓无关联锚点(holding_id'}
</div>
) : (
@@ -486,9 +486,9 @@ function dirTextH(d) {
return '—';
}
function dirColorH(d) {
if (d === 'buy') return { color: '#d32f2f', fontWeight: 'bold' };
if (d === 'sell') return { color: '#2e7d32', fontWeight: 'bold' };
return { color: '#999' };
if (d === 'buy') return { color: 'var(--dsw-alias-state-error-primary, #d32f2f)', fontWeight: 'bold' };
if (d === 'sell') return { color: 'var(--dsw-alias-state-success-primary, #2e7d32)', fontWeight: 'bold' };
return { color: 'var(--dsw-alias-label-tertiary, #999)' };
}
function HoldingTradesTable({ trades }) {
@@ -496,7 +496,7 @@ function HoldingTradesTable({ trades }) {
return (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, marginTop: 4 }}>
<thead>
<tr style={{ borderBottom: '1px solid #ddd', color: '#666' }}>
<tr style={{ borderBottom: '1px solid var(--dsw-alias-border-l2, #ddd)', color: 'var(--dsw-alias-label-secondary, #666)' }}>
<th style={{ ...tdH, textAlign: 'left' }}>交易日</th>
<th style={{ ...tdH, textAlign: 'left' }}>时间</th>
<th style={{ ...tdH, textAlign: 'left' }}>方向</th>
@@ -510,7 +510,7 @@ function HoldingTradesTable({ trades }) {
</thead>
<tbody>
{list.map((o) => (
<tr key={o.orderId} style={{ borderBottom: '1px solid #f0f0f0' }}>
<tr key={o.orderId} style={{ borderBottom: '1px solid var(--dsw-alias-border-l1, #f0f0f0)' }}>
<td style={tdH}>{fmtDateH(o.tradeDate)}</td>
<td style={tdH}>{fmtTimeH(o.insertTime)}</td>
<td style={{ ...tdH, ...dirColorH(o.direction) }}>{dirTextH(o.direction)}</td>
+3 -3
View File
@@ -44,9 +44,9 @@ export function ToastHost() {
style={{
padding: '10px 16px',
borderRadius: 6,
color: '#fff',
background: t.type === 'success' ? '#2e7d32' : '#c62828',
boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
color: 'var(--dsw-alias-button-contrast-fill, #fff)',
background: t.type === 'success' ? 'var(--dsw-alias-state-success-primary, #2e7d32)' : 'var(--dsw-alias-state-error-primary, #c62828)',
boxShadow: 'var(--dsw-shadow-lv3, 0 2px 8px rgba(0,0,0,0.2))',
fontSize: 13,
maxWidth: 360,
}}
+21 -21
View File
@@ -42,9 +42,9 @@ function fmtNum(v, digits = 2) {
/** 方向样式:红买绿卖 */
function directionStyle(direction) {
if (direction === 'buy') return { color: '#d32f2f', fontWeight: 'bold' };
if (direction === 'sell') return { color: '#2e7d32', fontWeight: 'bold' };
return { color: '#999' };
if (direction === 'buy') return { color: 'var(--dsw-alias-state-error-primary, #d32f2f)', fontWeight: 'bold' };
if (direction === 'sell') return { color: 'var(--dsw-alias-state-success-primary, #2e7d32)', fontWeight: 'bold' };
return { color: 'var(--dsw-alias-label-tertiary, #999)' };
}
function directionText(direction) {
if (direction === 'buy') return '买入';
@@ -309,7 +309,7 @@ export function TradeRecordsTab() {
<select
value={strategyFilter}
onChange={(e) => setStrategyFilter(e.target.value)}
style={{ fontSize: 12, padding: '2px 6px', border: '1px solid #ccc', borderRadius: 4, color: '#555' }}
style={{ fontSize: 12, padding: '2px 6px', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, color: 'var(--dsw-alias-label-secondary, #555)' }}
title="按策略过滤"
>
<option value="">全部策略</option>
@@ -322,14 +322,14 @@ export function TradeRecordsTab() {
</div>
</div>
{attributionSaved && (
<div style={{ marginBottom: 8, fontSize: 12, color: attributionSaved.startsWith('保存失败') ? '#c62828' : '#1b5e20', background: attributionSaved.startsWith('保存失败') ? '#fdecea' : '#e8f5e9', padding: '4px 8px', borderRadius: 4 }}>
<div style={{ marginBottom: 8, fontSize: 12, color: attributionSaved.startsWith('保存失败') ? 'var(--dsw-alias-state-error-primary, #c62828)' : 'var(--dsw-alias-state-success-primary, #1b5e20)', background: attributionSaved.startsWith('保存失败') ? 'color-mix(in srgb, var(--dsw-alias-state-error-primary, #c62828) 10%, var(--dsw-alias-bg-layer-1, #fff))' : 'color-mix(in srgb, var(--dsw-alias-state-success-primary, #2e7d32) 10%, var(--dsw-alias-bg-layer-1, #fff))', padding: '4px 8px', borderRadius: 4 }}>
{attributionSaved}
</div>
)}
<LoadState loading={loading && !orders} error={error} onRetry={load} autoRetry={2}>
{!orders?.length ? (
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}>
<div style={{ padding: 32, textAlign: 'center', color: 'var(--dsw-alias-label-tertiary, #999)' }}>
<div style={{ fontSize: 15, marginBottom: 8 }}>
{range ? range.start + ' ~ ' + range.end + (isHistoryRange ? '(本地历史)' : '') : ''}
</div>
@@ -340,9 +340,9 @@ export function TradeRecordsTab() {
) : (
<TradeTable rows={rows} toggleSort={toggleSort} sortKey={sortKey} sortDir={sortDir} arrow={arrow} toggleExpand={toggleExpand} expanded={expanded} attributionCandidates={attributionCandidates} loadCandidates={loadCandidates} setAttribution={setAttribution} />
)}
<div style={{ marginTop: 8, fontSize: 12, color: '#999' }}>
<span style={{ color: '#d32f2f' }}> </span>
<span style={{ marginLeft: 8, color: '#2e7d32' }}> </span>
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--dsw-alias-label-tertiary, #999)' }}>
<span style={{ color: 'var(--dsw-alias-state-error-primary, #d32f2f)' }}> </span>
<span style={{ marginLeft: 8, color: 'var(--dsw-alias-state-success-primary, #2e7d32)' }}> </span>
<span style={{ marginLeft: 12 }}>点击委托行展开成交明细 · 点击表头排序</span>
{isHistoryRange && <span style={{ marginLeft: 12 }}>· 历史数据 = 本地积累</span>}
</div>
@@ -356,7 +356,7 @@ function TradeTable({ rows, toggleSort, sortKey, sortDir, arrow, toggleExpand, e
return (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
<tr style={{ textAlign: 'left', borderBottom: '2px solid var(--dsw-alias-border-l2, #ddd)' }}>
<th style={thStyle}> </th>
<th onClick={() => toggleSort('insertTime')} style={thClickStyle}>时间{arrow('insertTime')}</th>
<th onClick={() => toggleSort('code')} style={thClickStyle}>代码{arrow('code')}</th>
@@ -404,9 +404,9 @@ function OrderRows({ order, expanded, onToggle, attributionCandidates, onLoadCan
<tr
onClick={onToggle}
style={{
borderBottom: '1px solid #eee',
borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)',
cursor: hasTrades ? 'pointer' : 'default',
background: expanded ? '#f5f5f5' : 'transparent',
background: expanded ? 'var(--dsw-alias-interactive-bg-hover, #f5f5f5)' : 'transparent',
}}
>
<td style={{ ...tdStyle, textAlign: 'center', width: 28 }}>
@@ -420,7 +420,7 @@ function OrderRows({ order, expanded, onToggle, attributionCandidates, onLoadCan
transition: 'transform .15s ease',
transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)',
cursor: 'pointer',
color: '#2e7d32',
color: 'var(--dsw-alias-state-success-primary, #2e7d32)',
verticalAlign: 'middle',
}}
>
@@ -434,7 +434,7 @@ function OrderRows({ order, expanded, onToggle, attributionCandidates, onLoadCan
/>
</svg>
) : (
<span style={{ color: '#ccc', fontSize: 10 }}>·</span>
<span style={{ color: 'var(--dsw-alias-border-l3, #ccc)', fontSize: 10 }}>·</span>
)}
</td>
<td style={tdStyle}>{fmtTime(order.insertTime)}</td>
@@ -461,11 +461,11 @@ function OrderRows({ order, expanded, onToggle, attributionCandidates, onLoadCan
</td>
</tr>
{expanded && hasTrades && (
<tr style={{ borderBottom: '1px solid #eee', background: '#fafafa' }}>
<tr style={{ borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)', background: 'var(--dsw-alias-bg-layer-2, #fafafa)' }}>
<td colSpan={16} style={{ padding: '4px 8px 12px' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, marginTop: 4 }}>
<thead>
<tr style={{ borderBottom: '1px solid #ddd', color: '#666' }}>
<tr style={{ borderBottom: '1px solid var(--dsw-alias-border-l2, #ddd)', color: 'var(--dsw-alias-label-secondary, #666)' }}>
<th style={{ ...tdStyle, textAlign: 'left' }}>成交时间</th>
<th style={{ ...tdStyle, textAlign: 'right' }}>成交价</th>
<th style={{ ...tdStyle, textAlign: 'right' }}>成交量</th>
@@ -475,12 +475,12 @@ function OrderRows({ order, expanded, onToggle, attributionCandidates, onLoadCan
</thead>
<tbody>
{order.trades.map((t) => (
<tr key={t.tradeId} style={{ borderBottom: '1px solid #f0f0f0' }}>
<tr key={t.tradeId} style={{ borderBottom: '1px solid var(--dsw-alias-border-l1, #f0f0f0)' }}>
<td style={tdStyle}>{fmtTime(t.tradeTime)}</td>
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(t.price)}</td>
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(t.volume, 0)}</td>
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(t.amount)}</td>
<td style={{ ...tdStyle, textAlign: 'right', color: '#999' }}>{t.tradeId}</td>
<td style={{ ...tdStyle, textAlign: 'right', color: 'var(--dsw-alias-label-tertiary, #999)' }}>{t.tradeId}</td>
</tr>
))}
</tbody>
@@ -517,10 +517,10 @@ function AttributionSelect({ order, candidates, onLoadCandidates, setAttribution
style={{
fontSize: 12,
padding: '2px 4px',
border: '1px solid ' + (current ? '#2e7d32' : '#ccc'),
border: '1px solid ' + (current ? 'var(--dsw-alias-state-success-primary, #2e7d32)' : 'var(--dsw-alias-border-l2, #ccc)'),
borderRadius: 4,
background: current ? '#e8f5e9' : '#fff',
color: current ? '#1b5e20' : '#999',
background: current ? 'color-mix(in srgb, var(--dsw-alias-state-success-primary, #2e7d32) 12%, var(--dsw-alias-bg-layer-1, #fff))' : 'var(--dsw-alias-bg-layer-1, #fff)',
color: current ? 'var(--dsw-alias-state-success-primary, #1b5e20)' : 'var(--dsw-alias-label-tertiary, #999)',
maxWidth: 120,
}}
title="设置该委托的策略归属(可随时修改)"
+142 -93
View File
@@ -5,38 +5,63 @@
* 通过 ctx.settings.register('one-divine-lot', schema) 注册策略配置 namespace。
*
* 数据结构:
* strategies: [{ id, name, visible, order }]
* strategies: [{ id, name }]R-011 收窄:visible/order 归 tabs 统一管理)
* tabs: [{ id, kind: builtin|strategy, refKey|refId, name, visible, order }]
* 预置:网格超市、手动做T(R-003 O3-5:可删除,彻底自由)
*
* 迭代 02 新增(R-003 O3 策略 CRUD):
* - generateStrategyId(name):中文/任意名 → 英文 slug id
* - addStrategy / renameStrategy / moveStrategy / removeStrategy
* R-011 迭代 09(产品约束-009、技术约束-014):
* - settings.tabs 从布尔对象升级为统一有序数组(内置 + 策略混排)
* - 旧格式读取时静默归一化迁移(旧隐藏策略迁移后显示,Q3)
* - addStrategy 联动追加 tab(末尾,Q2);removeStrategy 联动删除对应 tabD3
* - 策略行 name 由 getTabs join strategies(改名自动跟随,Q4
* - removeStrategy 的份额清理由上层(api.js 编排 storage)完成
*/
import z from '@deepseek-ai/schemastery';
/** 内置 tab 元数据(常量,顺序即默认顺序;R-011) */
export const BUILTIN_TABS = [
{ id: 'tab-all-positions', refKey: 'allPositions', name: '全部持仓' },
{ id: 'tab-trade-records', refKey: 'tradeRecords', name: '交易记录' },
{ id: 'tab-watchlist', refKey: 'watchlist', name: '关注列表' },
];
/** 默认 tab 列表(首次注册时的初始值;策略行由 DEFAULT_STRATEGIES 派生) */
export const DEFAULT_TABS = BUILTIN_TABS.map((t, i) => ({
id: t.id, kind: 'builtin', refKey: t.refKey, name: t.name, visible: true, order: i,
}));
/** 默认策略(首次注册时的初始值) */
export const DEFAULT_STRATEGIES = [
{ id: 'grid-supermarket', name: '网格超市' },
{ id: 'manual-t', name: '手动做T' },
];
/** 策略设置 schemaschemastery 定义,settings.register 要求 schema 对象) */
export const strategySchema = z.object({
// 策略定义表(R-011 收窄:仅 id + namevisible/order 归 tabs 统一管理)
strategies: z.array(z.object({
id: z.string().required(),
name: z.string().required(),
})).default(DEFAULT_STRATEGIES),
// 统一 tab 列表(R-011:唯一顺序与显隐来源;内置 + 策略混排)
// union 兼容存量:旧格式为布尔对象(迭代 02),新格式为有序数组;normalizeTabs 读取时归一化
tabs: z.union([
z.object({
allPositions: z.boolean().default(true),
tradeRecords: z.boolean().default(true),
watchlist: z.boolean().default(true),
}),
z.array(z.object({
id: z.string().required(),
kind: z.union([z.const('builtin'), z.const('strategy')]).required(),
refKey: z.string(), // builtin 专用:allPositions/tradeRecords/watchlist(可空)
refId: z.string(), // strategy 专用:策略 id(可空)
name: z.string(), // 展示名(策略行 join 时刷新,Q4 自动跟随改名;可空)
visible: z.boolean().default(true),
order: z.number().default(0),
})).default([
{ id: 'grid-supermarket', name: '网格超市', visible: true, order: 0 },
{ id: 'manual-t', name: '手动做T', visible: true, order: 1 },
]),
// 通用 tab 显隐配置(设置 tab 化扩展,2026-08-28
tabs: z.object({
allPositions: z.boolean().default(true), // 全部持仓
tradeRecords: z.boolean().default(true), // 交易记录(未来 tab,本期空占位)
watchlist: z.boolean().default(true), // 关注列表(未来 tab,本期空占位)
}).default({
allPositions: true,
tradeRecords: true,
watchlist: true,
}),
})),
]).default(DEFAULT_TABS),
// QMT 连接配置(R-004,技术约束-008):list + 激活态 + 默认态
qmtConnections: z.object({
list: z.array(z.object({
@@ -50,18 +75,6 @@ export const strategySchema = z.object({
}).default({ list: [], activeId: '', defaultId: '' }),
});
/** 默认通用 tab 配置 */
export const DEFAULT_TABS = {
allPositions: true,
tradeRecords: true,
watchlist: true,
};
/** 默认策略(首次注册时的初始值) */
export const DEFAULT_STRATEGIES = [
{ id: 'grid-supermarket', name: '网格超市', visible: true, order: 0 },
{ id: 'manual-t', name: '手动做T', visible: true, order: 1 },
];
/** 简易拼音映射表(常用汉字 → 拼音,用于 slug 生成兜底) */
const PINYIN_MAP = {
@@ -182,23 +195,12 @@ export function registerSettings(ctx, config) {
}
/**
* 读取策略列表(含 visible 过滤
* 读取策略定义表(R-011 收窄:仅 id + name,无 visible/order
* @param {ReturnType<typeof registerSettings>} scope
*/
export function getStrategies(scope) {
const value = scope?.get() ?? {};
const list = Array.isArray(value.strategies) ? value.strategies : DEFAULT_STRATEGIES;
// 统一按 order 排序(设置页 / tab 栏 / 逻辑层共用同一顺序)
return list.slice().sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
/**
* 获取可见策略列表(visible === true,按 order 排序)
*/
export function getVisibleStrategies(scope) {
return getStrategies(scope)
.filter((s) => s.visible !== false)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
return Array.isArray(value.strategies) ? value.strategies : DEFAULT_STRATEGIES;
}
/**
@@ -222,10 +224,10 @@ export async function addStrategy(scope, name) {
const strategy = {
id,
name: String(name ?? '').trim(),
visible: true,
order: list.reduce((max, s) => Math.max(max, s.order ?? 0), -1) + 1,
};
await updateStrategies(scope, [...list, strategy]);
// R-011:联动在 tabs 列表末尾追加策略 tab 条目(Q2 追加末尾)
await appendStrategyTab(scope, strategy);
return strategy;
}
@@ -245,33 +247,6 @@ export async function renameStrategy(scope, id, name) {
return true;
}
/**
* 移动策略(排序,上移/下移)
* @param {ReturnType<typeof registerSettings>} scope
* @param {string} id 策略 id
* @param {'up'|'down'} dir 方向
* @returns {Promise<boolean>} 是否成功
*/
export async function moveStrategy(scope, id, dir) {
const list = getStrategies(scope)
.slice()
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const idx = list.findIndex((s) => s.id === id);
if (idx < 0) throw Object.assign(new Error(`策略不存在: ${id}`), { code: 'strategy-not-found' });
const swapIdx = dir === 'up' ? idx - 1 : idx + 1;
if (swapIdx < 0 || swapIdx >= list.length) return false; // 已在边界
const a = list[idx];
const b = list[swapIdx];
const orderA = a.order ?? 0;
const orderB = b.order ?? 0;
const next = list.map((s) => {
if (s.id === a.id) return { ...s, order: orderB };
if (s.id === b.id) return { ...s, order: orderA };
return s;
});
await updateStrategies(scope, next);
return true;
}
/**
* 删除策略(O3;份额清理由上层编排 storage)
@@ -284,40 +259,114 @@ export async function removeStrategy(scope, id) {
const target = list.find((s) => s.id === id);
if (!target) throw Object.assign(new Error(`策略不存在: ${id}`), { code: 'strategy-not-found' });
await updateStrategies(scope, list.filter((s) => s.id !== id));
// R-011:联动删除 tabs 列表中对应策略条目(D3)
await removeStrategyTab(scope, id);
return true;
}
/**
* 读取通用 tab 显隐配置(设置 tab 化扩展
* 归一化 tabs 为有序数组(R-011 迁移:旧布尔对象 → 数组;幂等,不重复迁移
* 旧格式:tabs = { allPositions, tradeRecords, watchlist } + strategies 自带 visible/order
* 新格式:tabs = [{ id, kind, refKey|refId, name, visible, order }]
* 旧隐藏策略迁移后 visible=trueQ3,产品约束-009)。
* @param {ReturnType<typeof registerSettings>} scope
* @returns {{ allPositions: boolean, tradeRecords: boolean, watchlist: boolean }}
* @returns {Array} 归一化后的 tabs 数组(按 order 排序)
*/
export function getTabConfig(scope) {
export function normalizeTabs(scope) {
const value = scope?.get() ?? {};
const tabs = value.tabs ?? {};
return {
allPositions: tabs.allPositions !== false,
tradeRecords: tabs.tradeRecords !== false,
watchlist: tabs.watchlist !== false,
};
const raw = value.tabs;
// 已归一化(数组)
if (Array.isArray(raw)) return raw.slice().sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
// 旧布尔对象或缺失 → 生成内置条目(保留原显隐值)
const legacy = (typeof raw === 'object' && raw !== null) ? raw : {};
const tabs = BUILTIN_TABS.map((t, i) => ({
id: t.id, kind: 'builtin', refKey: t.refKey, name: t.name,
visible: legacy[t.refKey] !== false, order: i,
}));
// 策略条目:按旧 order 接续追加;旧隐藏策略迁移后显示(Q3)
const strategies = Array.isArray(value.strategies) ? value.strategies : DEFAULT_STRATEGIES;
const strategyRows = strategies.map((s, i) => ({
id: 'tab-strategy-' + s.id, kind: 'strategy', refId: s.id, name: s.name,
visible: true, order: tabs.length + i,
}));
return [...tabs, ...strategyRows];
}
/**
* 更新通用 tab 显隐配置(整表更新
* 读取统一 tab 列表(R-011):归一化 + 策略行 name join(Q4 自动跟随改名
* @param {ReturnType<typeof registerSettings>} scope
* @param {object} tabs { allPositions?, tradeRecords?, watchlist? }
* @returns {Array} tabs 数组(按 order 排序,策略行 name 已 join
*/
export function getTabs(scope) {
const tabs = normalizeTabs(scope);
const nameMap = new Map(getStrategies(scope).map((s) => [s.id, s.name]));
return tabs.map((t) => (t.kind === 'strategy'
? { ...t, name: nameMap.get(t.refId) ?? t.name ?? t.refId }
: t));
}
/**
* 更新统一 tab 列表(整表更新:顺序 + 显隐;R-011)
* 策略行 name 由服务端 join 刷新,客户端可只传 id/order/visible 的数组。
* @param {ReturnType<typeof registerSettings>} scope
* @param {Array} tabs 完整 tabs 数组
*/
export async function updateTabs(scope, tabs) {
const current = scope?.get() ?? {};
const normalized = (Array.isArray(tabs) ? tabs : []).map((t, i) => ({
id: t.id,
kind: t.kind === 'strategy' ? 'strategy' : 'builtin',
refKey: t.refKey,
refId: t.refId,
name: t.name,
visible: t.visible !== false,
order: typeof t.order === 'number' ? t.order : i,
}));
await scope.update({ ...current, tabs: normalized });
}
/** 追加策略 tab 条目到末尾(addStrategy 联动;Q2 追加末尾) */
export async function appendStrategyTab(scope, strategy) {
const tabs = getTabs(scope);
const next = [...tabs, {
id: 'tab-strategy-' + strategy.id, kind: 'strategy', refId: strategy.id,
name: strategy.name, visible: true,
order: tabs.reduce((max, t) => Math.max(max, t.order ?? 0), -1) + 1,
}];
await updateTabs(scope, next);
}
/** 删除策略 tab 条目(removeStrategy 联动;D3 */
export async function removeStrategyTab(scope, strategyId) {
const tabs = getTabs(scope);
await updateTabs(scope, tabs.filter((t) => !(t.kind === 'strategy' && t.refId === strategyId)));
}
/**
* 读取通用 tab 显隐配置(R-011 迁移期兼容:返回布尔对象,供旧调用点使用)
* @deprecated 由 getTabs 取代
*/
export function getTabConfig(scope) {
const tabs = getTabs(scope);
const cfg = { allPositions: true, tradeRecords: true, watchlist: true };
for (const t of tabs) {
if (t.kind === 'builtin' && t.refKey in cfg) cfg[t.refKey] = t.visible !== false;
}
return cfg;
}
/**
* 更新通用 tab 显隐配置(R-011 迁移期兼容:写回统一数组)
* @deprecated 由 updateTabs 取代
*/
export async function updateTabConfig(scope, tabs) {
const current = scope?.get() ?? {};
const next = {
...current,
tabs: {
allPositions: tabs.allPositions !== false,
tradeRecords: tabs.tradeRecords !== false,
watchlist: tabs.watchlist !== false,
},
};
await scope.update(next);
const currentTabs = getTabs(scope);
const next = currentTabs.map((t) => (t.kind === 'builtin' && t.refKey in tabs
? { ...t, visible: tabs[t.refKey] !== false }
: t));
await updateTabs(scope, next);
}
/**