diff --git a/.helloagents/CHANGELOG.md b/.helloagents/CHANGELOG.md
index 1be9abd..faafde2 100644
--- a/.helloagents/CHANGELOG.md
+++ b/.helloagents/CHANGELOG.md
@@ -1,5 +1,19 @@
# CHANGELOG
+## [0.5.19] - 2026-04-27
+
+### 新增
+- **[admin-frontend]**: 为工单工作台回复区补齐图片拖拽上传与剪贴板粘贴上传,统一复用现有图片上传接口和 Markdown 图片插入逻辑,并将超大工单工作台组件拆分出上传 composable 与独立 SCSS 样式文件 — by yinjianm
+ - 方案: [202604272310_ticket-chat-image-dnd-paste-upload](archive/2026-04/202604272310_ticket-chat-image-dnd-paste-upload/)
+ - 决策: ticket-chat-image-dnd-paste-upload#D001(统一图片入口到现有 Markdown 上传链路)
+
+## [0.5.18] - 2026-04-27
+
+### 快速修改
+- **[admin-frontend]**: 调整节点管理状态筛选口径,“在线节点”现在同时包含显式在线与待同步节点,顶部在线节点统计同步采用相同口径;“离线节点”仍只匹配显式离线节点 — by yinjianm
+ - 类型: 快速修改(无方案包)
+ - 文件: admin-frontend/src/utils/nodes.ts:12-176
+
## [0.5.17] - 2026-04-25
### 修复
diff --git a/.helloagents/INDEX.md b/.helloagents/INDEX.md
index 792586c..a289d0b 100644
--- a/.helloagents/INDEX.md
+++ b/.helloagents/INDEX.md
@@ -3,7 +3,7 @@
```yaml
kb_version: 2
project: Xboard-new
-updated_at: 2026-04-25
+updated_at: 2026-04-27
active_package: 无
```
@@ -11,7 +11,7 @@ active_package: 无
- 类型: PHP Laravel 主仓 + `admin-frontend` Vue3 管理端前端
- 当前重点模块: `admin-frontend`、`order-payment`、`subscription-protocols`
-- 最新归档: `202604250002_order-payment-snapshot`
+- 最新归档: `202604272310_ticket-chat-image-dnd-paste-upload`
## 活跃模块
diff --git a/.helloagents/archive/2026-04/202604272310_ticket-chat-image-dnd-paste-upload/.status.json b/.helloagents/archive/2026-04/202604272310_ticket-chat-image-dnd-paste-upload/.status.json
new file mode 100644
index 0000000..597fe07
--- /dev/null
+++ b/.helloagents/archive/2026-04/202604272310_ticket-chat-image-dnd-paste-upload/.status.json
@@ -0,0 +1,10 @@
+{
+ "status": "completed",
+ "completed": 4,
+ "failed": 0,
+ "pending": 0,
+ "total": 4,
+ "percent": 100,
+ "current": "工单聊天图片拖拽上传、粘贴上传与前端构建验证已完成",
+ "updated_at": "2026-04-27 23:18:00"
+}
diff --git a/.helloagents/archive/2026-04/202604272310_ticket-chat-image-dnd-paste-upload/proposal.md b/.helloagents/archive/2026-04/202604272310_ticket-chat-image-dnd-paste-upload/proposal.md
new file mode 100644
index 0000000..c40d953
--- /dev/null
+++ b/.helloagents/archive/2026-04/202604272310_ticket-chat-image-dnd-paste-upload/proposal.md
@@ -0,0 +1,165 @@
+# 变更提案: ticket-chat-image-dnd-paste-upload
+
+## 元信息
+```yaml
+类型: 新功能
+方案类型: implementation
+优先级: P2
+状态: 已确认
+创建: 2026-04-27
+```
+
+---
+
+## 1. 需求
+
+### 背景
+`admin-frontend` 工单工作台当前已支持通过按钮选择图片,并将上传后的 URL 以 Markdown 图片语法插入回复框。客服处理截图类问题时,图片常来自本地文件拖入或剪贴板截图,单一按钮选择会降低连续回复效率。
+
+### 目标
+- 在 `admin-frontend/src/views/tickets/TicketWorkspaceDialog.vue` 的工单聊天回复区域支持拖拽图片上传。
+- 在回复框聚焦时支持从剪贴板粘贴图片上传。
+- 复用现有 `uploadImage()`、图片类型校验、10MB 大小限制和 Markdown 图片插入方式。
+- 保留原有点击上传入口,并补齐上传中、拖拽悬停、失败提示等状态反馈。
+- 将超大 SFC 的样式拆分到同目录 SCSS 文件,避免继续扩大 533 行组件文件。
+
+### 约束条件
+```yaml
+时间约束: 无
+性能约束: 不引入新依赖;拖拽/粘贴只处理图片文件,不做额外预览缓存
+兼容性约束: 保持 Vue3 + Element Plus + Vite 现有技术栈;保留原有 /upload/rest/upload 上传接口
+业务约束: 不修改后端工单回复语义,不改变 replyTicket payload,仅插入 Markdown 图片链接
+```
+
+### 验收标准
+- [ ] 点击“上传图片”仍可上传图片并插入 ``。
+- [ ] 拖拽图片到回复区域可上传并插入 Markdown 图片。
+- [ ] 在回复框中粘贴剪贴板图片可上传并插入 Markdown 图片。
+- [ ] 非图片文件、超过 10MB 图片会被拒绝并展示明确提示。
+- [ ] 上传中回复区有可见状态,重复上传不会破坏现有回复内容。
+- [ ] `npm run build` 在 `admin-frontend` 通过。
+
+---
+
+## 2. 方案
+
+### 技术方案
+在 `TicketWorkspaceDialog.vue` 中抽出统一的 `uploadReplyImages(files, source)` 流程:从 Element Plus 上传、拖拽事件、粘贴事件统一收敛到同一校验与上传路径。回复区外层增加 drag/drop 事件和视觉状态,`ElInput` 增加 paste 事件。上传成功后继续以 Markdown 图片语法追加到 `replyMessage`,失败时按具体来源给出错误提示。样式迁移到 `TicketWorkspaceDialog.scss`,并新增拖拽激活态、上传提示行和响应式细节。
+
+### 影响范围
+```yaml
+涉及模块:
+ - admin-frontend: 工单工作台回复区图片上传交互
+预计变更文件: 4
+```
+
+### 风险评估
+| 风险 | 等级 | 应对 |
+|------|------|------|
+| 粘贴文本时误触上传逻辑 | 低 | 仅当 clipboardData 中存在 image File 时拦截默认行为,否则保持原粘贴行为 |
+| 多图拖拽上传时状态混乱 | 中 | 顺序上传并复用同一个上传状态,逐个插入 Markdown,失败时提示具体失败原因 |
+| 样式拆分造成 scoped 样式失效 | 低 | 使用项目已有 `` 模式;组件文件降到 400 行以下。
+ - 验证方式: 文件行数检查 + `npm run build`
+ - depends_on: [1.2]
+
+### 3. 验证与知识库同步
+
+- [√] 3.1 运行前端构建并同步知识库
+ - 预期变更: 执行 `npm run build`;更新 `.helloagents/modules/admin-frontend.md`、方案包任务状态和 CHANGELOG/归档信息。
+ - 完成标准: 构建通过或明确记录阻断原因;知识库反映工单工作台支持点击、拖拽和粘贴图片上传。
+ - 验证方式: `npm run build` + 文件检查
+ - depends_on: [2.1]
+
+---
+
+## 执行日志
+
+| 时间 | 任务 | 状态 | 备注 |
+|------|------|------|------|
+| 2026-04-27 23:10:00 | 方案设计 | in_progress | 已创建方案包并确认唯一实现路径 |
+| 2026-04-27 23:15:00 | 1.1 / 1.2 | completed | 已抽出 `useTicketReplyImages`,统一点击、拖拽、粘贴图片上传链路 |
+| 2026-04-27 23:16:00 | 2.1 | completed | 已拆出 `TicketWorkspaceDialog.scss`,组件文件降至 326 行 |
+| 2026-04-27 23:18:00 | 3.1 | completed | `npm run build` 已通过 |
+
+---
+
+## 执行备注
+
+- 当前工作树已有 `public/assets/admin` 未提交改动,本任务不覆盖该路径。
+- 本地上传端到端依赖 `/upload/rest/upload` 可用;构建验证不能替代真实上传环境人工核对。
+- `npm run build` 会刷新 `public/assets/admin` 构建产物;该路径在执行前已经是未提交状态。
diff --git a/.helloagents/archive/_index.md b/.helloagents/archive/_index.md
index 8bb22ea..0ada825 100644
--- a/.helloagents/archive/_index.md
+++ b/.helloagents/archive/_index.md
@@ -7,6 +7,7 @@
| 时间戳 | 名称 | 类型 | 涉及模块 | 决策 | 结果 |
|--------|------|------|---------|------|------|
+| 202604272310 | ticket-chat-image-dnd-paste-upload | implementation | admin-frontend | ticket-chat-image-dnd-paste-upload#D001 | ✅完成 |
| 202604250018 | admin-frontend-user-activity-status-filter | implementation | admin-frontend,backend | admin-frontend-user-activity-status-filter#D001,#D002,#D003 | ✅完成 |
| 202604250002 | order-payment-snapshot | implementation | admin-frontend,order-payment | order-payment-snapshot#D001,#D002 | ✅完成 |
| 202604242245 | admin-frontend-node-pagination-batch-edit | implementation | admin-frontend | admin-frontend-node-pagination-batch-edit#D001,#D002,#D003 | ✅完成 |
@@ -36,6 +37,7 @@
## 按月归档
### 2026-04
+- [202604272310_ticket-chat-image-dnd-paste-upload](./2026-04/202604272310_ticket-chat-image-dnd-paste-upload/) - 为工单工作台回复区补齐图片拖拽上传与剪贴板粘贴上传,并将上传逻辑与样式从超大 SFC 中拆分
- [202604250018_admin-frontend-user-activity-status-filter](./2026-04/202604250018_admin-frontend-user-activity-status-filter/) - 为用户管理高级筛选新增“活跃状态”条件,并在后端补齐 `activity_status` 复合过滤规则,支持按活跃 / 非活跃筛选用户
- [202604250002_order-payment-snapshot](./2026-04/202604250002_order-payment-snapshot/) - 补齐订单支付成功快照保存链路,并在后台订单详情中集中展示支付渠道、支付方法、平台订单号、商户订单号、实付金额与支付 IP
- [202604242245_admin-frontend-node-pagination-batch-edit](./2026-04/202604242245_admin-frontend-node-pagination-batch-edit/) - 为节点管理工作台补齐本地分页、父/子节点筛选、单节点置顶,以及仅对已勾选节点生效的批量修改
diff --git a/.helloagents/modules/admin-frontend.md b/.helloagents/modules/admin-frontend.md
index 7720a09..27f8ac8 100644
--- a/.helloagents/modules/admin-frontend.md
+++ b/.helloagents/modules/admin-frontend.md
@@ -17,6 +17,7 @@
- 受保护路由在未登录时会自动附加 `redirect` 查询参数
- API 基础路径使用 `/api/v2/{secure_path}`,其中 `secure_path` 来自运行时配置
- 工单工作台现允许对已关闭工单继续回复;管理员发送新消息后会提示“发送并重开”,并通过统一后端语义把工单状态重新开启
+- 工单工作台回复区支持点击选择、拖拽放下和剪贴板粘贴三种图片上传入口,统一复用 `/upload/rest/upload` 图片上传和 Markdown 图片链接插入逻辑;上传期间会禁用发送入口,避免图片链接尚未写入时提前回复
- 仪表盘以真实后端接口返回值为准,不在前端伪造业务统计
- 仪表盘“收入趋势”支持在同一张趋势图中切换“按金额 / 按数量”,数量模式同步切换摘要卡片、Y 轴标签与最近记录
- 仪表盘“作业详情”支持打开失败作业报错弹窗,集中查看 Horizon 失败作业的报错摘要、失败时间与队列信息
@@ -94,6 +95,7 @@
- 依赖 `src/utils/notices.ts` 负责公告表单转换、内容摘要、排序与显示字段归一化
- 依赖 `src/utils/systemConfig.ts` 负责系统配置字段元信息、默认值、回填与保存序列化
- 依赖 `src/utils/routes.ts` 负责路由动作映射、匹配规则序列化、节点引用摘要与搜索过滤
+- 依赖 `src/views/tickets/useTicketReplyImages.ts` 收敛工单回复区图片点击上传、拖拽上传、粘贴上传、文件校验和 Markdown 插入
- 依赖 Laravel 后端 `TicketService::reply()` 提供工单“再次回复自动重开”的统一业务语义
- 依赖 Laravel 注入的 `window.settings`
- 构建输出到 `public/assets/admin`
diff --git a/admin-frontend/src/utils/nodes.ts b/admin-frontend/src/utils/nodes.ts
index b01c848..0ec6929 100644
--- a/admin-frontend/src/utils/nodes.ts
+++ b/admin-frontend/src/utils/nodes.ts
@@ -9,6 +9,8 @@ export interface NodeStatusMeta {
tagType: 'success' | 'warning' | 'danger' | 'info'
}
+type NodeStatusClass = NodeStatusMeta['dotClass']
+
const NODE_TYPE_LABELS: Record = {
shadowsocks: 'Shadowsocks',
trojan: 'Trojan',
@@ -64,6 +66,10 @@ export function getNodeStatusMeta(node: AdminNodeItem): NodeStatusMeta {
}
}
+function isNodeOnlineStatus(status: NodeStatusClass): boolean {
+ return status === 'online' || status === 'pending'
+}
+
export function getNodeIdLabel(node: AdminNodeItem): string {
return node.parent_id ? `${node.id} → ${node.parent_id}` : String(node.id)
}
@@ -146,7 +152,7 @@ export function filterNodes(
}
const nodeStatus = getNodeStatusMeta(node).dotClass
- if (normalizedStatus === 'online' && nodeStatus !== 'online') {
+ if (normalizedStatus === 'online' && !isNodeOnlineStatus(nodeStatus)) {
return false
}
@@ -167,7 +173,7 @@ export function filterNodes(
}
export function countOnlineNodes(nodes: AdminNodeItem[]): number {
- return nodes.filter((node) => getNodeStatusMeta(node).dotClass === 'online').length
+ return nodes.filter((node) => isNodeOnlineStatus(getNodeStatusMeta(node).dotClass)).length
}
export function countVisibleNodes(nodes: AdminNodeItem[]): number {
diff --git a/admin-frontend/src/views/tickets/TicketWorkspaceDialog.scss b/admin-frontend/src/views/tickets/TicketWorkspaceDialog.scss
new file mode 100644
index 0000000..f1e9ac4
--- /dev/null
+++ b/admin-frontend/src/views/tickets/TicketWorkspaceDialog.scss
@@ -0,0 +1,270 @@
+.workspace-header {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.workspace-title p {
+ font-size: 11px;
+ letter-spacing: 0.22em;
+ text-transform: uppercase;
+ color: var(--xboard-text-muted);
+}
+
+.workspace-title h2 {
+ font-size: 34px;
+ line-height: 1.08;
+ color: var(--xboard-text-strong);
+}
+
+.workspace-header__actions {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.ghost-action {
+ color: var(--xboard-link);
+}
+
+.danger-action {
+ color: var(--xboard-danger);
+}
+
+.workspace-shell {
+ display: grid;
+ grid-template-columns: 280px minmax(0, 1fr);
+ min-height: 70vh;
+ border: 1px solid var(--xboard-border);
+ border-radius: 24px;
+ overflow: hidden;
+}
+
+.workspace-sidebar {
+ display: grid;
+ grid-template-rows: auto 1fr;
+ border-right: 1px solid var(--xboard-border);
+ background: #fbfbfd;
+}
+
+.sidebar-header {
+ display: grid;
+ gap: 12px;
+ padding: 20px;
+ border-bottom: 1px solid var(--xboard-border);
+}
+
+.sidebar-list {
+ display: grid;
+ align-content: start;
+ gap: 8px;
+ padding: 16px;
+ overflow-y: auto;
+}
+
+.sidebar-ticket {
+ border: 1px solid transparent;
+ background: #ffffff;
+ border-radius: 18px;
+ padding: 16px;
+ text-align: left;
+ display: grid;
+ gap: 8px;
+ cursor: pointer;
+ transition: 0.2s ease;
+}
+
+.sidebar-ticket.active {
+ border-color: rgba(0, 113, 227, 0.24);
+ background: rgba(0, 113, 227, 0.08);
+}
+
+.sidebar-ticket__row,
+.sidebar-ticket__meta {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.sidebar-ticket span,
+.sidebar-ticket small {
+ color: var(--xboard-text-muted);
+}
+
+.workspace-main {
+ display: grid;
+ grid-template-rows: auto 1fr auto;
+ background: #ffffff;
+ min-height: 0;
+}
+
+.conversation-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 20px 24px;
+ border-bottom: 1px solid var(--xboard-border);
+}
+
+.conversation-header h3 {
+ font-size: 32px;
+ line-height: 1.08;
+ color: var(--xboard-text-strong);
+}
+
+.conversation-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 14px;
+ margin-top: 8px;
+ color: var(--xboard-text-muted);
+}
+
+.conversation-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.message-thread {
+ display: grid;
+ align-content: start;
+ gap: 16px;
+ padding: 24px;
+ overflow-y: auto;
+ background: linear-gradient(180deg, #ffffff 0%, #fbfbfd 100%);
+}
+
+.message-card {
+ display: grid;
+ gap: 10px;
+ max-width: min(720px, 100%);
+ padding: 18px 20px;
+ border-radius: 20px;
+ box-shadow: var(--xboard-shadow);
+}
+
+.from-user {
+ background: #eef3fb;
+}
+
+.from-admin {
+ background: #1d1d1f;
+ color: #ffffff;
+ margin-left: auto;
+}
+
+.message-card__meta {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ font-size: 12px;
+}
+
+.from-user .message-card__meta {
+ color: var(--xboard-text-muted);
+}
+
+.from-admin .message-card__meta {
+ color: rgba(255, 255, 255, 0.72);
+}
+
+.markdown-body :deep(p),
+.markdown-body :deep(h1),
+.markdown-body :deep(h2),
+.markdown-body :deep(h3),
+.markdown-body :deep(ul),
+.markdown-body :deep(ol) {
+ margin: 0 0 10px;
+}
+
+.markdown-body :deep(img) {
+ max-width: min(420px, 100%);
+ border-radius: 14px;
+}
+
+.markdown-body :deep(a) {
+ color: inherit;
+ text-decoration: underline;
+}
+
+.reply-box {
+ display: grid;
+ gap: 12px;
+ padding: 20px 24px;
+ border-top: 1px solid var(--xboard-border);
+ background: rgba(255, 255, 255, 0.92);
+ transition: background 0.18s ease, box-shadow 0.18s ease;
+}
+
+.reply-box.is-drag-active {
+ background: #fbfdff;
+ box-shadow: inset 0 0 0 2px rgba(0, 113, 227, 0.34);
+}
+
+.reply-box.is-uploading {
+ cursor: progress;
+}
+
+.reply-box__hint,
+.reply-box__drop-hint {
+ color: var(--xboard-text-muted);
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+.reply-box__drop-hint {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ min-height: 20px;
+ color: var(--xboard-link);
+ opacity: 0.78;
+ transition: opacity 0.18s ease, color 0.18s ease;
+}
+
+.reply-box.is-drag-active .reply-box__drop-hint {
+ color: var(--xboard-primary);
+ opacity: 1;
+}
+
+.reply-box__actions {
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ gap: 12px;
+}
+
+.workspace-empty {
+ display: grid;
+ place-items: center;
+ color: var(--xboard-text-muted);
+}
+
+@media (max-width: 1023px) {
+ .workspace-shell {
+ grid-template-columns: 1fr;
+ }
+
+ .workspace-sidebar {
+ border-right: 0;
+ border-bottom: 1px solid var(--xboard-border);
+ }
+
+ .workspace-header,
+ .conversation-header {
+ flex-direction: column;
+ align-items: stretch;
+ }
+}
+
+@media (max-width: 640px) {
+ .reply-box__actions {
+ justify-content: stretch;
+ flex-wrap: wrap;
+ }
+}
diff --git a/admin-frontend/src/views/tickets/TicketWorkspaceDialog.vue b/admin-frontend/src/views/tickets/TicketWorkspaceDialog.vue
index f4ad24b..bd69ab4 100644
--- a/admin-frontend/src/views/tickets/TicketWorkspaceDialog.vue
+++ b/admin-frontend/src/views/tickets/TicketWorkspaceDialog.vue
@@ -1,7 +1,6 @@