dsh-dock 0.9.1 → 0.9.3

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.
@@ -37,6 +37,21 @@ const XIANGQI_LINES = (() => {
37
37
  return parts;
38
38
  })();
39
39
 
40
+ // 传统棋谱着法(如"炮二平五"/"馬8进7"):红方用中文数字、黑方用阿拉伯数字,各自从己方右侧数起。
41
+ const CN_NUMS = ["一", "二", "三", "四", "五", "六", "七", "八", "九"];
42
+ function moveNotation(board, fr, fc, tr, tc) {
43
+ const p = board[fr][fc];
44
+ if (!p) return "";
45
+ const red = p.c === "r";
46
+ const num = (n) => (red ? CN_NUMS[n - 1] : String(n));
47
+ const file = (c) => (red ? 9 - c : c + 1);
48
+ const forward = red ? tr < fr : tr > fr;
49
+ const verb = fr === tr ? "平" : (forward ? "进" : "退");
50
+ // 直行棋子进/退记步数;斜行棋子(马/象/士)进/退记目标线位。
51
+ const to = fr === tr || p.t === "h" || p.t === "e" || p.t === "a" ? file(tc) : Math.abs(tr - fr);
52
+ return pieceChar(p) + num(file(fc)) + verb + num(to);
53
+ }
54
+
40
55
  export function XiangqiGame(props) {
41
56
  const [board, setBoard] = useState(initialBoard);
42
57
  const [turn, setTurn] = useState("r");
@@ -45,6 +60,11 @@ export function XiangqiGame(props) {
45
60
  const [over, setOver] = useState(null); // 'r' | 'b' | 'draw'
46
61
  const [check, setCheck] = useState(false);
47
62
  const [last, setLast] = useState(null);
63
+ // 上一手的起点/终点/棋谱文本:起点画虚线圈、终点画红点,棋谱行让玩家看清对方走了什么。
64
+ const [lastMove, setLastMove] = useState(null); // { fr, fc, tr, tc, text }
65
+ const [moveLog, setMoveLog] = useState([]); // [{ side, text }] 双方着法流水
66
+ // 跳跃动画:moving = { fr, fc, tr, tc, piece, capture },动画结束后落定为普通棋子。
67
+ const [moving, setMoving] = useState(null);
48
68
  const [thinking, setThinking] = useState(false);
49
69
  const [history, setHistory] = useState([]);
50
70
  // 已出现过的局面指纹:AI 搜索时排除重复局面,杜绝"反复将军无限循环"。
@@ -57,44 +77,78 @@ export function XiangqiGame(props) {
57
77
  const overRef = useRef(over); overRef.current = over;
58
78
  const thinkingRef = useRef(thinking); thinkingRef.current = thinking;
59
79
  const historyRef = useRef(history); historyRef.current = history;
80
+ // 动画期间锁输入与 AI 回合,结束后调用 settle 落定真正的棋盘状态。
81
+ const movingRef = useRef(false);
82
+ const pendingRef = useRef(null);
83
+
84
+ // 起跳动画:先在起点渲染飞行棋子,260ms 后落地并把棋盘推进到新局面。
85
+ const animTimerRef = useRef(null);
86
+ const animateMove = useCallback((b, fr, fc, tr, tc, after) => {
87
+ // 被移动/被吃的棋子须从旧盘(boardRef)读取:b 已是落子后的新盘。
88
+ const piece = boardRef.current[fr][fc];
89
+ const capture = !!boardRef.current[tr][tc];
90
+ pendingRef.current = { board: b, after };
91
+ movingRef.current = true;
92
+ setSel(null); setTargets([]);
93
+ setMoving({ fr, fc, tr, tc, piece, capture });
94
+ playSfx(capture ? "capture" : "step");
95
+ if (animTimerRef.current) clearTimeout(animTimerRef.current);
96
+ animTimerRef.current = setTimeout(() => {
97
+ animTimerRef.current = null;
98
+ const pend = pendingRef.current;
99
+ pendingRef.current = null;
100
+ movingRef.current = false;
101
+ setMoving(null);
102
+ if (pend) {
103
+ const text = moveNotation(boardRef.current, fr, fc, tr, tc);
104
+ boardRef.current = pend.board;
105
+ setBoard(pend.board);
106
+ setLastMove({ fr, fc, tr, tc, text });
107
+ setMoveLog((log) => log.concat({ side: piece.c, text }));
108
+ setLast([tr, tc]);
109
+ if (pend.after) pend.after();
110
+ }
111
+ }, 260);
112
+ }, []);
60
113
 
61
114
  const restart = useCallback(() => {
115
+ if (animTimerRef.current) { clearTimeout(animTimerRef.current); animTimerRef.current = null; }
116
+ pendingRef.current = null; movingRef.current = false;
62
117
  const b = initialBoard();
63
118
  boardRef.current = b;
64
119
  setBoard(b); setTurn("r"); setSel(null); setTargets([]); setOver(null); setCheck(false); setLast(null); setThinking(false); setHistory([]);
65
- setRepKeys([posKey(b)]);
120
+ setLastMove(null); setMoveLog([]); setMoving(null); setRepKeys([posKey(b)]);
66
121
  }, []);
67
122
 
68
123
  const undo = useCallback(() => {
69
- if (thinkingRef.current || overRef.current || turnRef.current !== "r") return;
124
+ if (thinkingRef.current || overRef.current || turnRef.current !== "r" || movingRef.current) return;
70
125
  const h = historyRef.current;
71
126
  if (h.length < 2) return;
72
127
  const hist = h.slice(0, -2);
73
128
  const b = hist.length ? cloneBoard(hist[hist.length - 1]) : initialBoard();
74
129
  boardRef.current = b;
75
- setBoard(b); setHistory(hist); setSel(null); setTargets([]); setLast(null); setOver(null);
130
+ setBoard(b); setHistory(hist); setSel(null); setTargets([]); setLast(null); setLastMove(null); setMoveLog((prevLog) => prevLog.slice(0, -2)); setOver(null);
76
131
  setCheck(inCheck(b, "r"));
77
132
  // 重建局面指纹(历史各局面 + 当前)。
78
133
  setRepKeys(hist.map(posKey).concat(posKey(b)));
79
134
  }, []);
80
135
 
81
136
  const moveRed = useCallback((tr, tc) => {
82
- if (turnRef.current !== "r" || overRef.current || !sel) return;
137
+ if (turnRef.current !== "r" || overRef.current || !sel || movingRef.current) return;
83
138
  const fr = sel[0], fc = sel[1];
84
- if (boardRef.current[tr][tc]) playSfx("capture"); else playSfx("step");
85
139
  const nb = applyMove(boardRef.current, fr, fc, tr, tc);
86
140
  setHistory((h) => [...h, boardRef.current]);
87
- boardRef.current = nb;
88
141
  pushKey(nb);
89
- setBoard(nb);
90
- setSel(null); setTargets([]); setLast([tr, tc]);
91
- setTurn("b");
92
- if (!hasLegalMoves(nb, "b")) { setOver("r"); setCheck(false); }
93
- else setCheck(inCheck(nb, "b"));
94
- }, [sel, pushKey]);
142
+ animateMove(nb, fr, fc, tr, tc, () => {
143
+ // 动画落地后再移交回合:让 AI 的 useEffect 依赖到最新 turn 才启动搜索。
144
+ setTurn("b");
145
+ if (!hasLegalMoves(nb, "b")) { setOver("r"); setCheck(false); }
146
+ else setCheck(inCheck(nb, "b"));
147
+ });
148
+ }, [sel, pushKey, animateMove]);
95
149
 
96
150
  const select = useCallback((r, c) => {
97
- if (overRef.current || turnRef.current !== "r" || thinkingRef.current) return;
151
+ if (overRef.current || turnRef.current !== "r" || thinkingRef.current || movingRef.current) return;
98
152
  const p = boardRef.current[r][c];
99
153
  if (p && p.c === "r") {
100
154
  setSel([r, c]);
@@ -106,9 +160,9 @@ export function XiangqiGame(props) {
106
160
  setSel(null); setTargets([]);
107
161
  }, [sel, targets, moveRed]);
108
162
 
109
- // AI 回合(执黑):搜索时排除已出现过的局面,禁止长将循环。
163
+ // AI 回合(执黑):先想棋(thinking 期间玩家能看到"AI 思考中"),落子走同款跳跃动画。
110
164
  useEffect(() => {
111
- if (turn !== "b" || over || props.paused) return;
165
+ if (turn !== "b" || over || props.paused || movingRef.current) return;
112
166
  const t = setTimeout(() => {
113
167
  // 局面出现次数表:AI 优先走新局面;全部重复时也倾向去往出现最少的局面。
114
168
  const seenCounts = new Map();
@@ -116,20 +170,20 @@ export function XiangqiGame(props) {
116
170
  const mv = bestMove(boardRef.current, "b", 3, seenCounts);
117
171
  if (mv) {
118
172
  const [fr, fc, tr, tc] = mv;
119
- if (boardRef.current[tr][tc]) playSfx("capture"); else playSfx("step");
120
173
  const nb = applyMove(boardRef.current, fr, fc, tr, tc);
121
174
  setHistory((h) => [...h, boardRef.current]);
122
- boardRef.current = nb;
123
175
  pushKey(nb);
124
- setBoard(nb); setLast([tr, tc]); setTurn("r");
125
- if (!hasLegalMoves(nb, "r")) { setOver("b"); setCheck(false); }
126
- else setCheck(inCheck(nb, "r"));
176
+ animateMove(nb, fr, fc, tr, tc, () => {
177
+ setTurn("r");
178
+ if (!hasLegalMoves(nb, "r")) { setOver("b"); setCheck(false); }
179
+ else setCheck(inCheck(nb, "r"));
180
+ });
127
181
  } else setOver("r");
128
182
  setThinking(false);
129
- }, 90);
183
+ }, 420);
130
184
  setThinking(true);
131
185
  return () => clearTimeout(t);
132
- }, [turn, over, props.paused, repKeys, pushKey]);
186
+ }, [turn, over, props.paused, repKeys, pushKey, animateMove]);
133
187
 
134
188
  const onKeyDown = useCallback((event) => {
135
189
  if (event.key.toLowerCase() === "r") { event.preventDefault(); restart(); }
@@ -137,7 +191,7 @@ export function XiangqiGame(props) {
137
191
  }, [restart, undo]);
138
192
  useGameControls(stageRef, props.paused, onKeyDown);
139
193
 
140
- const status = over === "r" ? "红方胜 · 恭喜" : over === "b" ? "黑方胜 · AI 赢" : (thinking ? "AI 思考中…" : (check ? "将军!" : (turn === "r" ? "轮到你(红方)" : "轮到 AI(黑方)")));
194
+ const status = over === "r" ? "红方胜 · 恭喜" : over === "b" ? "黑方胜 · AI 赢" : (moving ? (moving.piece.c === "r" ? "红方走子…" : "AI 走子…") : (thinking ? "AI 思考中…" : (check ? "将军!" : (turn === "r" ? "轮到你(红方)" : "轮到 AI(黑方)"))));
141
195
  const prevCheck = useRef(false);
142
196
  useEffect(() => {
143
197
  if (over === "r") playSfx("win");
@@ -145,6 +199,8 @@ export function XiangqiGame(props) {
145
199
  else if (!thinking && check && !prevCheck.current) playSfx("check");
146
200
  prevCheck.current = !thinking && check;
147
201
  }, [check, thinking, over]);
202
+ // 组件卸载时清掉未落地的动画定时器,避免对已卸载组件 setState。
203
+ useEffect(() => () => { if (animTimerRef.current) clearTimeout(animTimerRef.current); }, []);
148
204
 
149
205
  return <section className="dgame-game" aria-label="象棋">
150
206
  <div className="dgame-game-head"><div><h3>中国象棋</h3><p>你执红先行,AI 执黑;吃掉对方将/帅即胜。点击选中红子,再点高亮落点走棋。</p></div><div className="dgame-score"><span>当前</span><strong className="dg-gomoku-status">{status}</strong><button type="button" onClick={undo}>撤销</button><button type="button" onClick={restart}>重开</button></div></div>
@@ -155,17 +211,33 @@ export function XiangqiGame(props) {
155
211
  <text className="dgame-xiangqi-rivertext" x={670} y={505}>漢 界</text>
156
212
  </svg>
157
213
  {board.map((row, r) => row.map((p, c) => {
214
+ const mv = moving;
215
+ // 动画期间:飞行棋子由独立层渲染(起点格不再画它);被吃棋子原地淡出。
216
+ const isFlyFrom = mv && mv.fr === r && mv.fc === c;
217
+ const isDying = mv && mv.capture && mv.tr === r && mv.tc === c;
158
218
  const isSel = sel && sel[0] === r && sel[1] === c;
159
219
  const isTargetMark = targets.some(([tr, tc]) => tr === r && tc === c);
220
+ const isLastMoveFrom = lastMove && lastMove.fr === r && lastMove.fc === c;
221
+ const isLand = !mv && lastMove && lastMove.tr === r && lastMove.tc === c;
160
222
  const isLast = last && last[0] === r && last[1] === c;
161
223
  return <button type="button" key={r + "-" + c} role="gridcell"
162
- className={"dgame-xiangqi-cell" + (isSel ? " sel" : "") + (isTargetMark ? " target" : "") + (isLast ? " last" : "")}
224
+ className={"dgame-xiangqi-cell" + (isSel ? " sel" : "") + (isTargetMark ? " target" : "") + (isLastMoveFrom ? " from" : "") + (isLand ? " land" : "") + (isLast ? " last" : "")}
163
225
  aria-label={"第" + (r + 1) + "行第" + (c + 1) + "列" + (p ? pieceChar(p) : " 空")}
164
226
  onClick={() => { focusStage(stageRef); select(r, c); }}>
165
- {p ? <span className={"dgame-xiangqi-pc " + p.c}>{pieceChar(p)}</span> : (isTargetMark ? <span className="dgame-xiangqi-dot" aria-hidden="true" /> : null)}
227
+ {p && !isFlyFrom ? <span className={"dgame-xiangqi-pc " + p.c + (isDying ? " dying" : "")}>{pieceChar(p)}</span> : (!p && isTargetMark ? <span className="dgame-xiangqi-dot" aria-hidden="true" /> : null)}
166
228
  </button>;
167
229
  }))}
230
+ {moving ? <span key={moving.fr + "-" + moving.fc + "-" + moving.tr + "-" + moving.tc + "-" + moving.piece.c}
231
+ className={"dgame-xiangqi-pc dgame-xiangqi-fly " + moving.piece.c}
232
+ style={{
233
+ "--fx0": (moving.fc + 0.5) / 9, "--fy0": (moving.fr + 0.5) / 10,
234
+ "--fx1": (moving.tc + 0.5) / 9, "--fy1": (moving.tr + 0.5) / 10,
235
+ }}
236
+ aria-hidden="true">{pieceChar(moving.piece)}</span> : null}
168
237
  </div>
238
+ {moveLog.length ? <div className="dgame-xiangqi-log" role="status" aria-live="polite">
239
+ {moveLog.slice(-4).map((m, i) => <span key={moveLog.length - 4 + i} className={m.side}>{m.text}</span>)}
240
+ </div> : null}
169
241
  <div className="dgame-controls"><button type="button" onClick={undo}>撤销</button><span>{status} · R 重开 · U 撤销</span><button type="button" onClick={restart}>重开</button></div>
170
242
  </section>;
171
243
  }
@@ -201,5 +273,5 @@ export function XiangqiPreview() {
201
273
  }
202
274
 
203
275
  export const xiangqiGame = { Game: XiangqiGame, Preview: XiangqiPreview, css: `
204
- .dgame-xiangqi-board{position:relative;display:grid;grid-template-columns:repeat(9,1fr);grid-template-rows:repeat(10,1fr);align-self:center;width:min(100%,min(90vw,340px));aspect-ratio:9/10;padding:13px;border-radius:12px;border:1px solid color-mix(in srgb,#7c4f16 65%,transparent);box-shadow:0 12px 32px rgb(0 0 0/.5),inset 0 1px 0 rgba(255,236,200,.55),inset 0 -3px 9px rgba(90,50,10,.35),inset 3px 0 8px rgba(255,224,170,.28),inset -3px 0 8px rgba(120,70,20,.25);outline:none;background:repeating-linear-gradient(94deg,rgba(124,79,22,.05) 0 2px,transparent 2px 11px),repeating-linear-gradient(87deg,rgba(255,238,196,.05) 0 2px,transparent 2px 14px),radial-gradient(130% 105% at 26% 8%,#f0cd86,#ddb067 48%,#cb9a51 78%,#bb8844)}.dgame-xiangqi-lines{position:absolute;inset:13px;pointer-events:none}.dgame-xiangqi-lines line,.dgame-xiangqi-lines path{stroke:#66431a;stroke-width:3.4;fill:none;stroke-linecap:square}.dgame-xiangqi-lines .dgame-xiangqi-mark{stroke-width:2.6}.dgame-xiangqi-rivertext{fill:rgba(102,67,26,.7);font-family:KaiTi,"Kaiti SC","STKaiti","FangSong",serif;font-size:46px;letter-spacing:7px;text-anchor:middle;dominant-baseline:central}.dgame-xiangqi-board:focus-visible{box-shadow:0 0 0 3px rgba(0,0,0,.25)}.dgame-xiangqi-cell{position:relative;z-index:1;background:transparent;border:none;cursor:pointer;padding:0;margin:0;outline:none;display:grid;place-items:center;min-width:0;min-height:0}.dgame-xiangqi-cell.target .dgame-xiangqi-dot{width:12px;height:12px;border-radius:50%;background:rgba(34,211,238,.6);box-shadow:0 0 10px rgba(34,211,238,.65),0 0 0 2px rgba(255,255,255,.28)}.dgame-xiangqi-cell.target .dgame-xiangqi-pc{box-shadow:0 0 0 2px rgba(248,113,113,.85),0 0 10px rgba(248,113,113,.5),0 2px 4px rgb(0 0 0/.4),inset 0 0 0 1.5px rgba(96,62,18,.5),inset 0 1px 2px rgba(255,255,255,.95),inset 0 -2px 3px rgba(120,80,20,.4)}.dgame-xiangqi-cell.sel .dgame-xiangqi-pc::before{content:'';position:absolute;inset:-3px;border-radius:50%;border:2.5px solid rgba(251,191,36,.95);box-shadow:0 0 12px rgba(251,191,36,.6)}.dgame-xiangqi-cell.last .dgame-xiangqi-pc::after{content:'';position:absolute;right:5%;bottom:5%;width:5px;height:5px;border-radius:50%;background:#ef4444;box-shadow:0 0 4px rgba(239,68,68,.8)}.dgame-xiangqi-pc{position:relative;display:grid;place-items:center;z-index:1;width:min(80%,30px);aspect-ratio:1/1;border-radius:50%;box-sizing:border-box;font-family:KaiTi,"Kaiti SC","STKaiti","FangSong","SimSun",serif;font-size:clamp(12px,1.4vw,15px);font-weight:700;line-height:1;background:radial-gradient(circle at 34% 28%,#fff3d4,#eedaa6 46%,#d8b675 72%,#bd9650);box-shadow:0 2px 4px rgb(0 0 0/.45),inset 0 0 0 1.5px rgba(96,62,18,.5),inset 0 1px 2px rgba(255,255,255,.95),inset 0 -2px 3px rgba(120,80,20,.4)}.dgame-xiangqi-pc.r{color:#b91c1c;text-shadow:0 1px 0 rgba(255,255,255,.5)}.dgame-xiangqi-pc.b{color:#1f2937;text-shadow:0 1px 0 rgba(255,255,255,.35)}
276
+ .dgame-xiangqi-board{position:relative;display:grid;grid-template-columns:repeat(9,1fr);grid-template-rows:repeat(10,1fr);align-self:center;width:min(100%,min(90vw,340px));aspect-ratio:9/10;padding:13px;border-radius:12px;border:1px solid color-mix(in srgb,#7c4f16 65%,transparent);box-shadow:0 12px 32px rgb(0 0 0/.5),inset 0 1px 0 rgba(255,236,200,.55),inset 0 -3px 9px rgba(90,50,10,.35),inset 3px 0 8px rgba(255,224,170,.28),inset -3px 0 8px rgba(120,70,20,.25);outline:none;background:repeating-linear-gradient(94deg,rgba(124,79,22,.05) 0 2px,transparent 2px 11px),repeating-linear-gradient(87deg,rgba(255,238,196,.05) 0 2px,transparent 2px 14px),radial-gradient(130% 105% at 26% 8%,#f0cd86,#ddb067 48%,#cb9a51 78%,#bb8844)}.dgame-xiangqi-lines{position:absolute;inset:13px;pointer-events:none}.dgame-xiangqi-lines line,.dgame-xiangqi-lines path,.dgame-xiangqi-lines rect{stroke:#66431a;stroke-width:3.4;fill:none;stroke-linecap:square}.dgame-xiangqi-lines rect{stroke-width:7}.dgame-xiangqi-lines .dgame-xiangqi-mark{stroke-width:2.6}.dgame-xiangqi-rivertext{fill:rgba(102,67,26,.7);font-family:KaiTi,"Kaiti SC","STKaiti","FangSong",serif;font-size:46px;letter-spacing:7px;text-anchor:middle;dominant-baseline:central}.dgame-xiangqi-board:focus-visible{box-shadow:0 0 0 3px rgba(0,0,0,.25)}.dgame-xiangqi-cell{position:relative;z-index:1;background:transparent;border:none;cursor:pointer;padding:0;margin:0;outline:none;display:grid;place-items:center;min-width:0;min-height:0}.dgame-xiangqi-cell.target .dgame-xiangqi-dot{width:12px;height:12px;border-radius:50%;background:rgba(34,211,238,.6);box-shadow:0 0 10px rgba(34,211,238,.65),0 0 0 2px rgba(255,255,255,.28)}.dgame-xiangqi-cell.target .dgame-xiangqi-pc{box-shadow:0 0 0 2px rgba(248,113,113,.85),0 0 10px rgba(248,113,113,.5),0 2px 4px rgb(0 0 0/.4),inset 0 0 0 1.5px rgba(96,62,18,.5),inset 0 1px 2px rgba(255,255,255,.95),inset 0 -2px 3px rgba(120,80,20,.4)}.dgame-xiangqi-cell.sel .dgame-xiangqi-pc::before{content:'';position:absolute;inset:-3px;border-radius:50%;border:2.5px solid rgba(251,191,36,.95);box-shadow:0 0 12px rgba(251,191,36,.6)}.dgame-xiangqi-cell.from::before{content:'';position:absolute;inset:50% auto auto 50%;width:34%;height:34%;transform:translate(-50%,-50%);border-radius:50%;border:2.5px dashed rgba(102,67,26,.75);pointer-events:none}.dgame-xiangqi-cell.land .dgame-xiangqi-pc{animation:dgame-xq-land .28s cubic-bezier(.2,.85,.3,1.25)}.dgame-xiangqi-cell.last .dgame-xiangqi-pc::after{content:'';position:absolute;right:5%;bottom:5%;width:5px;height:5px;border-radius:50%;background:#ef4444;box-shadow:0 0 4px rgba(239,68,68,.8)}.dgame-xiangqi-pc{position:relative;display:grid;place-items:center;z-index:1;width:min(80%,30px);aspect-ratio:1/1;border-radius:50%;box-sizing:border-box;font-family:KaiTi,"Kaiti SC","STKaiti","FangSong","SimSun",serif;font-size:clamp(12px,1.4vw,15px);font-weight:700;line-height:1;background:radial-gradient(circle at 34% 28%,#fff3d4,#eedaa6 46%,#d8b675 72%,#bd9650);box-shadow:0 2px 4px rgb(0 0 0/.45),inset 0 0 0 1.5px rgba(96,62,18,.5),inset 0 1px 2px rgba(255,255,255,.95),inset 0 -2px 3px rgba(120,80,20,.4)}.dgame-xiangqi-pc.dying{animation:dgame-xq-die .26s ease forwards}.dgame-xiangqi-fly{position:absolute;z-index:5;width:calc((100% - 26px)*.0889);aspect-ratio:1/1;border-radius:50%;display:grid;place-items:center;box-sizing:border-box;font-family:KaiTi,"Kaiti SC","STKaiti","FangSong","SimSun",serif;font-size:clamp(12px,1.4vw,15px);font-weight:700;line-height:1;pointer-events:none;background:radial-gradient(circle at 34% 28%,#fff3d4,#eedaa6 46%,#d8b675 72%,#bd9650);box-shadow:0 6px 14px rgb(0 0 0/.5),0 0 0 1.5px rgba(96,62,18,.35),inset 0 0 0 1.5px rgba(96,62,18,.5),inset 0 1px 2px rgba(255,255,255,.95),inset 0 -2px 3px rgba(120,80,20,.4);animation:dgame-xq-hop .26s cubic-bezier(.3,.7,.4,1) forwards}.dgame-xiangqi-fly.r{color:#b91c1c;text-shadow:0 1px 0 rgba(255,255,255,.5)}.dgame-xiangqi-fly.b{color:#1f2937;text-shadow:0 1px 0 rgba(255,255,255,.35)}@keyframes dgame-xq-hop{0%{left:calc(13px + var(--fx0)*(100% - 26px));top:calc(13px + var(--fy0)*(100% - 26px));transform:translate(-50%,-50%) scale(.9);opacity:.85}55%{transform:translate(-50%,-64%) scale(1.16);opacity:1}100%{left:calc(13px + var(--fx1)*(100% - 26px));top:calc(13px + var(--fy1)*(100% - 26px));transform:translate(-50%,-50%) scale(1)}}@keyframes dgame-xq-land{0%{transform:scale(1.18)}100%{transform:scale(1)}}@keyframes dgame-xq-die{to{opacity:0;transform:scale(.55)}}.dgame-xiangqi-log{display:flex;flex-wrap:wrap;justify-content:center;gap:5px 12px;align-self:center;min-height:22px;color:var(--dsw-alias-label-secondary);font-family:KaiTi,"Kaiti SC","STKaiti","FangSong",serif;font-size:13px}.dgame-xiangqi-log span{opacity:.65}.dgame-xiangqi-log span:last-child{opacity:1;font-weight:700}.dgame-xiangqi-log .r{color:#c03434}.dgame-xiangqi-log .b{color:var(--dsw-alias-label-primary)}
205
277
  ` };
@@ -210,7 +210,7 @@ const GAMES = [
210
210
  {
211
211
  id: "breakout", name: "打砖块", cat: "classic", accent: "#38bdf8",
212
212
  desc: "移动挡板反弹小球,击碎全部砖块;球掉到底部丢一条命。",
213
- tip: "←/→ 或鼠标移动 · 空格发球",
213
+ tip: "←/→ 或鼠标移动 · 空格/点击发球",
214
214
  Game: breakoutGame.Game, Preview: breakoutGame.Preview,
215
215
  },
216
216
  {
@@ -16,6 +16,7 @@
16
16
  // 配对状态、在线设备与接力备注只存内存;会话数据始终只有 ~/.dsh 这一份。
17
17
  import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
18
18
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
19
+ import { stat } from 'node:fs/promises'
19
20
  import { homedir } from 'node:os'
20
21
  import { dirname, join } from 'node:path'
21
22
  import yaml from 'js-yaml'
@@ -193,6 +194,58 @@ export const feature = {
193
194
 
194
195
  disposers.push(ctx.inject(['settings'], (sctx) => { settingsCtx = sctx }))
195
196
 
197
+ // ── 浏览模式目录选择器的 Windows 跨盘补全 ──
198
+ // 官方 browse 后端从主目录起步,只能列出当前盘内的子目录,面包屑在主目录之上
199
+ // 全部折叠——Windows 开着远程访问时,选工作区根本到不了其他磁盘。
200
+ // 这里在运行时包装 browse 能力的 list():主目录层级注入全部盘符入口,盘根目录
201
+ // 注入其他盘符入口,远程浏览即可像资源管理器一样跨盘选工作区。非 Windows 或
202
+ // 非浏览模式(原生对话框本身可跨盘)零改动。
203
+ const DRIVE_SCAN_TTL = 5000
204
+ let driveScan = { at: 0, letters: null }
205
+ function sameWinPath(a, b) {
206
+ return a.replace(/[\\/]+/g, '\\').toLowerCase() === b.replace(/[\\/]+/g, '\\').toLowerCase()
207
+ }
208
+ function winDriveLetterOf(p) {
209
+ return /^[A-Za-z]:[\\/]?$/.test(p) ? p.slice(0, 1).toUpperCase() : null
210
+ }
211
+ async function windowsDrivePaths() {
212
+ if (driveScan.letters && Date.now() - driveScan.at < DRIVE_SCAN_TTL) return driveScan.letters
213
+ const probes = []
214
+ for (const letter of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') {
215
+ probes.push(stat(letter + ':\\').then(() => letter, () => null))
216
+ }
217
+ const letters = (await Promise.all(probes)).filter(Boolean).sort()
218
+ driveScan = { at: Date.now(), letters }
219
+ return letters
220
+ }
221
+ disposers.push(ctx.inject(['directoryPicker'], (dpCtx) => {
222
+ if (process.platform !== 'win32') return
223
+ const picker = dpCtx.directoryPicker
224
+ if (!picker || typeof picker.capability !== 'function') return
225
+ let cap
226
+ try { cap = picker.capability() } catch { return }
227
+ if (!cap || cap.kind !== 'browse' || typeof cap.list !== 'function' || cap.__dshDockDrives) return
228
+ cap.__dshDockDrives = true
229
+ const origList = cap.list
230
+ cap.list = async (path, signal) => {
231
+ const listing = await origList(path, signal)
232
+ try {
233
+ const target = String((listing && listing.path) || '')
234
+ const atHome = sameWinPath(target, String(listing.home || '\u0000'))
235
+ const driveRoot = winDriveLetterOf(target)
236
+ // 只在主目录与盘根两级注入:这两级是跨盘的必经之地,其余层级保持官方原样
237
+ if (atHome || driveRoot) {
238
+ const letters = await windowsDrivePaths()
239
+ const rows = letters
240
+ .filter((letter) => !driveRoot || letter !== driveRoot)
241
+ .map((letter) => ({ name: letter + ':\\', path: letter + ':\\', hidden: false }))
242
+ if (rows.length) listing.entries = atHome ? rows.concat(listing.entries) : listing.entries.concat(rows)
243
+ }
244
+ } catch { /* 盘符探测失败保持原列表,不让补全拖垮选目录 */ }
245
+ return listing
246
+ }
247
+ }))
248
+
196
249
  // ── 远程访问账号(settings 持久化;密码只存加盐哈希) ──
197
250
  function hashPassword(salt, password) {
198
251
  return createHash('sha256').update(String(salt) + '\u0000' + String(password), 'utf8').digest('hex')
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-dock",
3
- "version": "0.9.1",
4
- "description": "DeepSeek Harness 功能坞插件(dsh-dock):模块化功能中枢——每个功能一个 features/ 模块(可单独提取成包发布,又可经 dockBridge 装回面板)。含用量记录(LLM 调用记账/费用统计)、模型设置、图片理解代理、模型余额、任务动画(8 种氛围动效 + 桌面伙伴 3D 场景、完成通知与提示音、钉钉/飞书推送,开关独立、配置持久化);侧栏入口 + 可拖拽弹窗(最大化/最小化/缩放)。",
3
+ "version": "0.9.3",
4
+ "description": "DeepSeek Harness 功能坞插件(dsh-dock):模块化功能中枢——每个功能一个 features/ 模块(可单独提取成包发布,又可经 dockBridge 装回面板)。含用量记录(LLM 调用记账/费用统计)、模型设置、图片理解代理、模型余额、任务动画(19 种运行动画 + 桌面伙伴 3D 场景、完成/需确认通知与提示音、钉钉/飞书推送,开关独立、配置持久化);侧栏入口 + 可拖拽弹窗(最大化/最小化/缩放)。",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "files": [
package/src/client.jsx CHANGED
@@ -28,7 +28,7 @@ import { feature as fGames } from "../features/games/view.jsx";
28
28
  import { feature as fMobileRelay } from "../features/mobile-relay/view.jsx";
29
29
 
30
30
  const name = "dsh-dock";
31
- const DOCK_VERSION = "0.9.1";
31
+ const DOCK_VERSION = "0.9.3";
32
32
 
33
33
  // ---- 内置功能注册表:新功能 = features/<id>/ 加模块 + 这里 import 一行 ----
34
34
  const BUILTIN_FEATURES = [fTokenlog, fModelconfig, fHeartbeat, fTheme, fBalance, fAnimation, fGames, fMobileRelay];
package/src/host-core.js CHANGED
@@ -27,6 +27,8 @@ export const DockConfig = z.object({
27
27
  notifyEnabled: z.boolean().default(true),
28
28
  notifyOnComplete: z.boolean().default(true),
29
29
  notifyOnError: z.boolean().default(true),
30
+ // 工具等待用户确认/批准时提醒(dsh 会话流 approval/asked → decided)
31
+ notifyOnConfirm: z.boolean().default(true),
30
32
  // 通知停留毫秒数(0 = 常驻直到手动关闭)
31
33
  notifyStayMs: z.number().default(8000),
32
34
  // 浏览器系统通知(页面后台时推送)