Files
agent-frontend-web/docs/superpowers/plans/2026-08-06-frontend-dark-theme.md
T
2026-08-07 13:56:29 +08:00

43 KiB
Raw Blame History

Frontend 自定义暗色主题 — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Restyle src/views/frontend/ pages with a dark, IDE-style theme inspired by image.png, with Shiki code highlighting and responsive breakpoints — without changing any JS behavior or affecting admin system pages.

Architecture: A new theme.scss defines CSS custom properties scoped to .frontend-container and overrides Element Plus variables in that scope. Each component's <style scoped> block is rewritten to use those tokens instead of hard-coded colors. Shiki replaces markdown-it's default code rendering via a shared async highlighter. Three media queries collapse panels at 1200/900/768px.

Tech Stack: Vue 3 + Vite + SCSS + Element Plus 2.13.1 + markdown-it 14.3.0 + Shiki (new dep).

Note on git: This project directory is not currently a git repository (git status reports "fatal: not a git repository"). Skip the git add / git commit steps at the end of each task — they will fail. Verification is done by yarn build:prod and visual inspection instead.


File Structure

Create

  • src/views/frontend/styles/theme.scss — design tokens + EP overrides + responsive breakpoints
  • src/views/frontend/utils/highlighter.js — Shiki-backed markdown-it factory + code renderer

Modify (style only — no JS changes)

  • src/views/frontend/index.vue — import theme.scss; restyle .frontend-container, .main-content, .content-wrapper, .chat-area, .file-panel-wrapper, .file-preview-wrapper, .file-preview-panel (and nested .preview-*); add Shiki-based file preview rendering
  • src/views/frontend/components/ConversationSidebar.vue — restyle (lines 227-405 + 407-433)
  • src/views/frontend/components/UserArea.vue — restyle (lines 88-167)
  • src/views/frontend/components/UserInfoDialog.vue — restyle (lines 283-321)
  • src/views/frontend/components/ChatHeader.vue — restyle (entire <style> block)
  • src/views/frontend/components/ChatWindow.vue — restyle (lines 418-743) + integrate Shiki (lines 100-216)
  • src/views/frontend/components/InputArea.vue — restyle (entire <style> block)
  • src/views/frontend/components/FilePanel.vue — restyle (entire <style> block)
  • package.json — add shiki dependency

Untouched

  • All .vue <script setup> blocks (no JS/TS changes except ChatWindow's markdown-it factory swap and index.vue's preview renderer)
  • All data / methods / computed / lifecycle hooks / emit signatures
  • All store / API / WebSocket code

Task 1: Create theme.scss with design tokens + EP overrides

Files:

  • Create: src/views/frontend/styles/theme.scss

  • Step 1: Create directory

mkdir -p "D:/数科智联/项目/agent-frontend-web/src/views/frontend/styles"
mkdir -p "D:/数科智联/项目/agent-frontend-web/src/views/frontend/utils"
  • Step 2: Write theme.scss

Create src/views/frontend/styles/theme.scss with the following content:

// Frontend dark theme tokens — scope to .frontend-container so admin pages are unaffected.
.frontend-container {
  // —— 基础色 ——
  --fe-bg-base: #1e1e1e;
  --fe-bg-elevated: #252526;
  --fe-bg-overlay: #2d2d30;
  --fe-bg-input: #3c3c3c;
  --fe-bg-hover: #2a2d2e;

  // —— 边框 ——
  --fe-border: #3c3c3c;
  --fe-border-strong: #555555;

  // —— 文本 ——
  --fe-text-primary: #e6e6e6;
  --fe-text-secondary: #a0a0a0;
  --fe-text-muted: #6e6e6e;
  --fe-text-inverse: #1e1e1e;

  // —— 品牌色 ——
  --fe-accent: #4ec9b0;
  --fe-accent-hover: #5fd9c0;
  --fe-accent-soft: rgba(78, 201, 176, 0.15);
  --fe-danger: #f48771;
  --fe-warning: #dcdcaa;
  --fe-info: #569cd6;

  // —— 布局尺寸 ——
  --fe-sidebar-width: 260px;
  --fe-file-panel-width: 280px;
  --fe-file-preview-width: 600px;

  // —— 阴影 / 圆角 ——
  --fe-radius-sm: 4px;
  --fe-radius-md: 6px;
  --fe-radius-lg: 12px;
  --fe-shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
  --fe-shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
}

// —— Element Plus 变量覆盖(仅在 frontend-container 下生效)——
.frontend-container {
  --el-bg-color: var(--fe-bg-elevated);
  --el-bg-color-page: var(--fe-bg-base);
  --el-bg-color-overlay: var(--fe-bg-overlay);
  --el-text-color-primary: var(--fe-text-primary);
  --el-text-color-regular: var(--fe-text-secondary);
  --el-text-color-secondary: var(--fe-text-muted);
  --el-text-color-placeholder: var(--fe-text-muted);
  --el-border-color: var(--fe-border);
  --el-border-color-light: var(--fe-border);
  --el-border-color-lighter: var(--fe-border);
  --el-border-color-extra-light: var(--fe-border);
  --el-fill-color-blank: var(--fe-bg-elevated);
  --el-fill-color-light: var(--fe-bg-hover);
  --el-fill-color-lighter: var(--fe-bg-hover);
  --el-color-primary: var(--fe-accent);
  --el-color-primary-light-3: var(--fe-accent-hover);
  --el-color-primary-light-5: var(--fe-accent-soft);
  --el-color-primary-light-7: var(--fe-accent-soft);
  --el-color-primary-light-9: var(--fe-accent-soft);
  --el-color-danger: var(--fe-danger);
  --el-color-warning: var(--fe-warning);
  --el-color-info: var(--fe-info);
  --el-color-success: var(--fe-accent);

  // —— 响应式断点 ——
  // ≤1200px:隐藏右侧文件预览面板(保留 filePreviewVisible 状态)
  @media (max-width: 1200px) {
    .file-preview-wrapper { display: none; }
  }
  // ≤900px:侧边栏收起为图标条
  @media (max-width: 900px) {
    .conversation-sidebar {
      width: 28px;
      .sidebar-content { display: none; }
    }
  }
  // ≤768px:隐藏右侧文件列表面板(保留 filePanelVisible 状态)
  @media (max-width: 768px) {
    .file-panel-wrapper { display: none; }
  }
}
  • Step 3: Verify SCSS compiles

Run: cd "D:/数科智联/项目/agent-frontend-web" && yarn build:prod 2>&1 | tail -30

Expected: build succeeds (warnings about unused vars are OK, no SCSS parse errors).

If build fails with a SCSS parse error in theme.scss, fix the syntax (most common: missing ;).


Task 2: Import theme.scss in index.vue and verify root scoping

Files:

  • Modify: src/views/frontend/index.vue:860 (insert <style> import via top-level @use)

  • Step 1: Add @use directive at the top of index.vue

At line 0 of src/views/frontend/index.vue (before <template>), add:

<style lang="scss">
@use './styles/theme.scss';
</style>

Note: this is an additional <style> block, not a modification of the existing <style scoped> at line 860. Vue 3 supports multiple <style> blocks.

  • Step 2: Change .frontend-container background to dark base

In src/views/frontend/index.vue:861-866, replace the existing block:

.frontend-container {
  display: flex;
  height: 100%;
  overflow: hidden;
  background: var(--fe-bg-base);
}

(The var(--fe-bg-base) will resolve to #1e1e1e once theme.scss is loaded; if theme.scss fails to load, it falls back to nothing and stays white — verify in Step 3.)

  • Step 3: Verify visually

Run: cd "D:/数科智联/项目/agent-frontend-web" && yarn dev

Open browser to http://localhost:<port>/frontend/index. Confirm:

  • Page background is dark gray (#1e1e1e)
  • Sidebar background is also dark
  • Chat bubbles and dialogs still appear light (component styles not yet updated — this is expected)

If the page is white, the @use import didn't take effect. Check:

  1. The new <style lang="scss"> block is above the <style scoped lang="scss"> at line 860
  2. The path ./styles/theme.scss resolves correctly from src/views/frontend/index.vue
  • Step 4: Build check

Run: cd "D:/数科智联/项目/agent-frontend-web" && yarn build:prod 2>&1 | tail -10

Expected: build succeeds.

  • Step 5: Commit (skip — not a git repo)

Save a snapshot of changed files in your head — no git operations needed.


Task 3: Restyle ConversationSidebar.vue

Files:

  • Modify: src/views/frontend/components/ConversationSidebar.vue:227-433 (entire <style scoped> and trailing global block)

  • Step 1: Replace the entire <style scoped lang="scss"> block

In src/views/frontend/components/ConversationSidebar.vue, replace lines 227-405 (the scoped SCSS block) with:

<style scoped lang="scss">
.conversation-sidebar {
  width: var(--fe-sidebar-width);
  border-right: 1px solid var(--fe-border);
  background: var(--fe-bg-base);
  transition: width 0.3s;
  position: relative;
  display: flex;
  flex-direction: column;
  flex-shrink: 0;

  &.collapsed {
    width: 28px;
  }

  .sidebar-toggle {
    position: absolute;
    right: -12px;
    top: 50%;
    transform: translateY(-50%);
    width: 24px;
    height: 24px;
    background: var(--fe-bg-elevated);
    border: 1px solid var(--fe-border);
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    z-index: 10;
    color: var(--fe-text-secondary);
    &:hover {
      background: var(--fe-bg-hover);
      color: var(--fe-text-primary);
    }
  }

  .sidebar-content {
    height: 100%;
    overflow: hidden;
    display: flex;
    flex-direction: column;
    flex: 1;
    min-height: 0;

    .sidebar-header {
      box-sizing: border-box;
      padding: 11px 16px;
      font-weight: 600;
      border-bottom: 1px solid var(--fe-border);
      display: flex;
      justify-content: space-between;
      align-items: center;
      color: var(--fe-text-primary);

      .header-actions {
        display: flex;
        gap: 4px;
        align-items: center;

        .icon-btn {
          padding: 4px 8px;
          margin-left: 0 !important;
          background: transparent;
          border: none;
          color: var(--fe-text-secondary);
          height: 32px;
          width: 32px;
          cursor: pointer;
          border-radius: var(--fe-radius-md);
          transition: all 0.2s;

          &:hover {
            background: var(--fe-bg-hover);
            color: var(--fe-text-primary);
          }

          .el-icon {
            font-size: 16px;
          }
        }
      }
    }

    .sidebar-scrollbar {
      flex: 1;
      min-height: 0;
    }

    .loading-tip,
    .empty-sessions {
      padding: 20px;
      text-align: center;
      color: var(--fe-text-muted);
      font-size: 13px;
    }

    .conversation-item {
      padding: 12px 16px;
      cursor: pointer;
      display: flex;
      align-items: flex-start;
      gap: 8px;
      transition: all 0.3s;
      border-bottom: 1px solid var(--fe-border);
      color: var(--fe-text-primary);

      &:hover {
        background: var(--fe-bg-hover);
      }

      &.active {
        background: var(--fe-accent-soft);
        color: var(--fe-accent);

        .conv-time {
          color: var(--fe-text-secondary);
        }
      }

      &.has-new-message:not(.active) {
        background: rgba(244, 135, 113, 0.1);

        .conv-name {
          color: var(--fe-danger);
          font-weight: 600;
        }
      }

      .conv-info {
        flex: 1;
        display: flex;
        flex-direction: column;
        gap: 2px;
        overflow: hidden;
      }

      .conv-name {
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
        font-size: 14px;
      }

      .conv-time {
        font-size: 12px;
        color: var(--fe-text-muted);
      }

      .new-message-dot {
        width: 8px;
        height: 8px;
        background: var(--fe-danger);
        border-radius: 50%;
        flex-shrink: 0;
        animation: pulse 1.5s infinite;
      }
    }

    @keyframes pulse {
      0% { opacity: 1; transform: scale(1); }
      50% { opacity: 0.6; transform: scale(1.2); }
      100% { opacity: 1; transform: scale(1); }
    }
  }
}

.sidebar-fade-enter-active {
  transition: opacity 0.2s 0.15s;
}
.sidebar-fade-leave-active {
  transition: opacity 0.1s;
}
.sidebar-fade-enter-from,
.sidebar-fade-leave-to {
  opacity: 0;
}
</style>
  • Step 2: Replace the trailing global <style> block (right-click menu)

Replace lines 407-433 with:

<style>
.context-menu {
  position: fixed;
  background: var(--fe-bg-elevated);
  border: 1px solid var(--fe-border);
  border-radius: var(--fe-radius-sm);
  box-shadow: var(--fe-shadow-md);
  z-index: 9999;
  padding: 4px 0;
  min-width: 100px;
  color: var(--fe-text-primary);

  .context-menu-item {
    padding: 8px 16px;
    cursor: pointer;
    font-size: 14px;
    &:hover {
      background: var(--fe-bg-hover);
    }
    &.danger {
      color: var(--fe-danger);
      &:hover {
        background: rgba(244, 135, 113, 0.1);
      }
    }
  }
}
</style>
  • Step 3: Verify

Run: cd "D:/数科智联/项目/agent-frontend-web" && yarn dev

Open /frontend/index. Confirm:

  • Sidebar background dark, conversation items legible

  • Hover state shows subtle dark gray highlight

  • Active conversation shows soft teal background (--fe-accent-soft)

  • Right-click menu appears dark with red danger item

  • Step 4: Build check

Run: cd "D:/数科智联/项目/agent-frontend-web" && yarn build:prod 2>&1 | tail -10

Expected: build succeeds.

  • Step 5: Commit (skip — not a git repo)

Task 4: Restyle UserArea.vue

Files:

  • Modify: src/views/frontend/components/UserArea.vue:88-167

  • Step 1: Replace the scoped SCSS block

Replace lines 88-167 with:

<style scoped lang="scss">
.user-area {
  display: flex;
  flex-direction: column;
  padding: 12px;
  background: var(--fe-bg-base);
  flex-shrink: 0;
}

.user-area-divider {
  height: 1px;
  background: var(--fe-border);
  margin: -12px -12px 12px -12px;
}

.user-info-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 6px 8px;
  border-radius: var(--fe-radius-md);
  cursor: pointer;
  transition: background 0.2s;

  &:hover {
    background: var(--fe-bg-hover);
  }
}

.user-name-wrap {
  flex: 1;
  min-width: 0;
}

.user-name {
  display: block;
  font-size: 14px;
  color: var(--fe-text-primary);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.logout-btn {
  padding: 4px 8px;
  margin-left: 0 !important;
  color: var(--fe-text-secondary);

  &:hover {
    color: var(--fe-danger);
    background: rgba(244, 135, 113, 0.1);
  }

  .el-icon {
    font-size: 16px;
  }
}

.marketplace-btn {
  margin-top: 10px;
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  background: var(--fe-accent-soft);
  color: var(--fe-accent);
  border: 1px solid transparent;

  &:hover {
    background: var(--fe-accent);
    color: var(--fe-text-inverse);
    border-color: var(--fe-accent);
  }

  .el-icon {
    font-size: 14px;
  }
}
</style>
  • Step 2: Verify

Run: cd "D:/数科智联/项目/agent-frontend-web" && yarn dev

Confirm: user row text white/light, hover background subtle dark, marketplace button teal-on-dark, hover inverts to teal background.

  • Step 3: Build check

Run: yarn build:prod 2>&1 | tail -5


Task 5: Restyle ChatHeader.vue

Files:

  • Modify: src/views/frontend/components/ChatHeader.vue — entire <style scoped> block (lines ~76-97)

  • Step 1: Read current ChatHeader <style> block

Read src/views/frontend/components/ChatHeader.vue lines 76-97 to see current SCSS structure.

  • Step 2: Replace the <style scoped> block

Replace the entire <style scoped lang="scss"> block (and contents) with a dark theme equivalent using:

  • Root .chat-header: background: var(--fe-bg-elevated); border-bottom: 1px solid var(--fe-border); color: var(--fe-text-primary);
  • All hard-coded #fff, #333, #909399, #1890ff, #e4e7ed → matching --fe-* tokens
  • Tab active state → color: var(--fe-accent); border-bottom: 2px solid var(--fe-accent); instead of blue underline
  • Buttons hover → background: var(--fe-bg-hover); color: var(--fe-text-primary);

Specific replacement structure (template — apply per existing selectors):

.chat-header {
  background: var(--fe-bg-elevated);
  border-bottom: 1px solid var(--fe-border);
  color: var(--fe-text-primary);
  /* ... rest of layout preserved ... */

  .tab-item {
    color: var(--fe-text-secondary);
    &.active {
      color: var(--fe-accent);
      border-bottom-color: var(--fe-accent);
    }
  }

  .header-btn {
    color: var(--fe-text-secondary);
    &:hover {
      background: var(--fe-bg-hover);
      color: var(--fe-text-primary);
    }
  }

  .model-name {
    color: var(--fe-text-secondary);
  }
  /* keep all original layout properties (padding, display, gap, etc.) unchanged */
}

Important: preserve all layout properties (padding, gap, flex, margin, transition timings). Only swap colors.

  • Step 3: Verify

Run: cd "D:/数科智联/项目/agent-frontend-web" && yarn dev

Open /frontend/index. Confirm: header bar dark with subtle bottom border; tab text and model picker legible; clear button hover shows dark background.

  • Step 4: Build check

Run: yarn build:prod 2>&1 | tail -5


Task 6: Restyle ChatWindow.vue (most complex)

Files:

  • Modify: src/views/frontend/components/ChatWindow.vue:418-743 (entire <style scoped>)

  • Step 1: Replace the entire <style scoped lang="scss"> block

Replace lines 418-743 with:

<style scoped lang="scss">
.chat-window {
  position: relative;
  flex: 1;
  padding: 16px 20px;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  background: var(--fe-bg-base);

  .chat-messages {
    flex: 1;
    overflow: hidden;
  }

  .scroll-to-bottom {
    position: absolute;
    bottom: 10px;
    left: 50%;
    transform: translateX(-50%);
    width: 40px;
    height: 40px;
    background: var(--fe-bg-elevated);
    border: 1px solid var(--fe-border);
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    box-shadow: var(--fe-shadow-md);
    cursor: pointer;
    z-index: 10;
    color: var(--fe-text-secondary);

    &:hover {
      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
      color: var(--fe-accent);
    }

    .el-icon {
      font-size: 20px;
    }
  }

  .messages-wrapper {
    max-width: 900px;
    margin: 0 auto;
    height: 100%;
  }

  .message-group {
    margin-bottom: 12px;

    &.user-group {
      display: flex;
      flex-direction: column;
      align-items: flex-end;

      .message-bubble {
        background: var(--fe-accent);
        color: var(--fe-text-inverse);
        border-radius: 16px 16px 4px 16px;
        max-width: 70%;

        .message-text {
          :deep(code) {
            background: rgba(0, 0, 0, 0.2);
          }

          :deep(pre) {
            background: rgba(0, 0, 0, 0.15);
          }

          :deep(a) {
            color: var(--fe-text-inverse);
            text-decoration: underline;
          }
        }
      }
    }

    &.assistant-group {
      display: flex;
      flex-direction: column;
      align-items: flex-start;

      .message-bubble {
        background: var(--fe-bg-elevated);
        color: var(--fe-text-primary);
        border-radius: 16px 16px 16px 4px;
        box-shadow: var(--fe-shadow-sm);
        max-width: 70%;
      }
    }

    .message-bubble {
      padding: 10px 14px;
      line-height: 1.6;

      .message-text {
        margin: 0;
        word-break: break-word;
        font-family: inherit;
        font-size: 14px;

        :deep(p) {
          margin: 0 0 8px 0;
          &:last-child {
            margin-bottom: 0;
          }
        }

        :deep(code) {
          background: var(--fe-bg-overlay);
          padding: 2px 4px;
          border-radius: var(--fe-radius-sm);
          font-size: 13px;
        }

        :deep(pre) {
          background: var(--fe-bg-base);
          padding: 8px 12px;
          border-radius: var(--fe-radius-md);
          overflow-x: auto;
          margin: 8px 0;
          border: 1px solid var(--fe-border);
          &:last-child {
            margin-bottom: 0;
          }
          code {
            background: none;
            padding: 0;
          }
        }

        :deep(ul), :deep(ol) {
          margin: 8px 0;
          padding-left: 20px;
        }

        :deep(a) {
          color: var(--fe-accent);
        }
      }
    }

    .message-time {
      font-size: 11px;
      color: var(--fe-text-muted);
      margin-top: 4px;
      padding: 0 4px;
    }
  }

  .process-panel {
    background: var(--fe-bg-elevated);
    border-radius: var(--fe-radius-lg);
    box-shadow: var(--fe-shadow-sm);
    margin-bottom: 12px;
    overflow: hidden;
    border: 1px solid var(--fe-border);

    .panel-header {
      display: flex;
      align-items: center;
      gap: 8px;
      padding: 10px 14px;
      cursor: pointer;
      color: var(--fe-text-secondary);
      font-size: 13px;
      background: var(--fe-bg-overlay);
      border-bottom: 1px solid var(--fe-border);
      user-select: none;

      .panel-title {
        flex: 1;
      }

      .collapse-icon {
        transition: transform 0.2s;

        &.collapsed {
          transform: rotate(-90deg);
        }
      }
    }

    .panel-content {
      padding: 8px;
    }
  }

  .tool-item {
    margin-bottom: 8px;
    border-radius: var(--fe-radius-md);
    overflow: hidden;
    border: 1px solid var(--fe-border);

    &:last-child {
      margin-bottom: 0;
    }

    .tool-header {
      display: flex;
      align-items: center;
      gap: 6px;
      padding: 8px 10px;
      font-size: 12px;
      cursor: pointer;
      user-select: none;
    }

    .tool-content,
    .tool-input,
    .tool-result-content {
      margin: 0;
      padding: 8px 10px;
      font-size: 12px;
      white-space: pre-wrap;
      word-break: break-word;
      font-family: 'Monaco', 'Menlo', monospace;
      max-height: 200px;
      overflow-y: auto;
      background: var(--fe-bg-base);
      color: var(--fe-text-primary);
    }
  }

  .thinking-item {
    background: var(--fe-bg-overlay);

    .tool-header {
      background: var(--fe-bg-hover);
      color: var(--fe-text-secondary);
    }

    .tool-content {
      color: var(--fe-text-secondary);
    }

    .collapse-icon {
      margin-left: auto;
      transition: transform 0.2s;

      &.collapsed {
        transform: rotate(-90deg);
      }
    }
  }

  .tool-use-item {
    background: rgba(86, 156, 214, 0.1);
    border-color: rgba(86, 156, 214, 0.4);

    .tool-header {
      background: rgba(86, 156, 214, 0.15);
      color: var(--fe-info);
    }

    .tool-description {
      color: var(--fe-text-secondary);
      font-size: 12px;
    }

    .tool-input {
      background: var(--fe-bg-base);
      color: var(--fe-text-primary);
    }
  }

  .tool-result-item {
    background: rgba(78, 201, 176, 0.1);
    border-color: rgba(78, 201, 176, 0.4);

    &.error {
      background: rgba(244, 135, 113, 0.1);
      border-color: rgba(244, 135, 113, 0.4);
    }

    .tool-header {
      background: rgba(0, 0, 0, 0.15);
      color: var(--fe-text-secondary);
    }

    .tool-result-content {
      background: var(--fe-bg-base);
      color: var(--fe-text-primary);
    }
  }

  .status-item {
    padding: 4px 0;

    :deep(.el-tag) {
      background: transparent;
      border: none;
    }
  }

  .loading-dots {
    display: inline-flex;
    gap: 4px;
    margin-right: 8px;

    span {
      width: 6px;
      height: 6px;
      background: var(--fe-text-muted);
      border-radius: 50%;
      animation: bounce 1.4s infinite ease-in-out both;

      &:nth-child(1) { animation-delay: -0.32s; }
      &:nth-child(2) { animation-delay: -0.16s; }
    }
  }

  .stream-loading {
    display: block;
    margin-top: 6px;
    font-size: 14px;
    color: var(--fe-text-muted);
  }

  @keyframes bounce {
    0%, 80%, 100% { transform: scale(0); }
    40% { transform: scale(1); }
  }
}
</style>
  • Step 2: Verify

Run: cd "D:/数科智联/项目/agent-frontend-web" && yarn dev

Open /frontend/index. Send a test message. Confirm:

  • User bubble is teal background with dark text

  • Assistant bubble is dark gray (--fe-bg-elevated) with light text

  • Code blocks render with dark base background (Shiki integration comes in Task 11+; for now blocks will be unstyled inside)

  • Tool panels (thinking, tool_use, tool_result) show appropriate soft-tinted dark backgrounds

  • Loading dots visible on dark

  • Step 3: Build check

Run: yarn build:prod 2>&1 | tail -5


Task 7: Restyle InputArea.vue

Files:

  • Modify: src/views/frontend/components/InputArea.vue — entire <style scoped> block

  • Step 1: Read current InputArea <style> block

Read the full <style scoped lang="scss"> block in src/views/frontend/components/InputArea.vue (search for <style scoped lang="scss"> and read until </style>).

  • Step 2: Replace all hard-coded colors with tokens

In the entire <style> block, perform these substitutions (do NOT change layout properties like padding, flex, gap, transition timings):

Hard-coded Replace with
#fff (background) var(--fe-bg-input) or var(--fe-bg-elevated)
#f5f7fa, #fafafa var(--fe-bg-overlay)
#f9f9f9 var(--fe-bg-hover)
#e4e7ed, #eee, #dcdfe6 var(--fe-border)
#303133, #333 var(--fe-text-primary)
#606266 var(--fe-text-secondary)
#909399 var(--fe-text-muted)
#1890ff, #409eff, #67c23a var(--fe-accent)
#f56c6c var(--fe-danger)
rgba(0,0,0,...) shadows var(--fe-shadow-sm) or var(--fe-shadow-md)

Apply via Edit tool with replace_all: true for tokens that appear multiple times. Verify after each replace_all that no layout properties were accidentally swapped.

  • Step 3: Verify

Run: cd "D:/数科智联/项目/agent-frontend-web" && yarn dev

Confirm input area: dark input background, light text, send button uses teal accent, quoted files area dark.

  • Step 4: Build check

Run: yarn build:prod 2>&1 | tail -5


Task 8: Restyle FilePanel.vue

Files:

  • Modify: src/views/frontend/components/FilePanel.vue — entire <style scoped> block

  • Step 1: Read current FilePanel <style> block

Read the full <style scoped lang="scss"> block in src/views/frontend/components/FilePanel.vue.

  • Step 2: Replace hard-coded colors with tokens

Apply the same substitution table as Task 7. Pay attention to:

  • File list rows: background: var(--fe-bg-elevated); border-bottom: 1px solid var(--fe-border);
  • Selected row: background: var(--fe-accent-soft); color: var(--fe-accent);
  • Folder icons: color: var(--fe-info);
  • Panel header: background: var(--fe-bg-base); border-bottom: 1px solid var(--fe-border); color: var(--fe-text-primary);
  • Loading / empty states: color: var(--fe-text-muted);

Preserve all layout (padding, gap, flex, animations).

  • Step 3: Verify

Run: cd "D:/数科智联/项目/agent-frontend-web" && yarn dev

Switch to "文件" tab. Confirm: panel background dark, files list legible, selected file shows soft teal highlight, folder/file icons distinguishable.

  • Step 4: Build check

Run: yarn build:prod 2>&1 | tail -5


Task 9: Restyle UserInfoDialog.vue

Files:

  • Modify: src/views/frontend/components/UserInfoDialog.vue:283-321

  • Step 1: Replace the scoped SCSS block

Replace lines 283-321 with:

<style scoped lang="scss">
.avatar-uploader {
  position: relative;
  width: 64px;
  height: 64px;
  border-radius: 50%;
  overflow: hidden;
  cursor: pointer;
  border: 1px solid var(--fe-border);

  &:hover .avatar-mask {
    opacity: 1;
  }
}

.avatar-mask {
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0.6);
  color: #fff;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  font-size: 12px;
  gap: 2px;
  opacity: 0;
  transition: opacity 0.2s;
}

.avatar-upload-preview {
  width: 200px;
  height: 200px;
  border-radius: 50%;
  margin: 0 auto;
  overflow: hidden;
  border: 1px solid var(--fe-border);
  background: var(--fe-bg-overlay);
}
</style>
  • Step 2: Verify dialog body

Run: cd "D:/数科智联/项目/agent-frontend-web" && yarn dev

Click user avatar at bottom of sidebar → dialog opens. Confirm:

  • Dialog header is dark (EP variable override applies)
  • Avatar uploader border uses dark border token
  • Preview circle background dark

If the dialog body text appears too dark to read on the dark background, the EP variables in theme.scss need additional override — add to theme.scss:

--el-dialog-bg-color: var(--fe-bg-elevated);
--el-text-color-regular: var(--fe-text-primary);

(Re-run build to confirm.)

  • Step 3: Build check

Run: yarn build:prod 2>&1 | tail -5


Task 10: Restyle index.vue file preview panel

Files:

  • Modify: src/views/frontend/index.vue<style scoped lang="scss"> block at lines 860-1026

  • Step 1: Replace the file preview panel styles

Replace lines 909-1025 (the .file-preview-panel block and nested styles) with:

.file-preview-panel {
  width: 100%;
  height: 100%;
  background: var(--fe-bg-base);
  display: flex;
  flex-direction: column;

  .preview-header {
    padding: 16px;
    border-bottom: 1px solid var(--fe-border);
    display: flex;
    justify-content: space-between;
    align-items: center;
    font-weight: 600;
    color: var(--fe-text-primary);
    background: var(--fe-bg-elevated);
  }

  .preview-content {
    flex: 1;
    overflow: hidden;

    .preview-loading {
      display: flex;
      align-items: center;
      justify-content: center;
      height: 100%;
      color: var(--fe-text-muted);
    }

    .preview-text {
      padding: 16px;
      margin: 0;
      font-size: 14px;
      line-height: 1.6;
      white-space: pre-wrap;
      word-break: break-all;
      overflow-y: auto;
      height: 100%;
      cursor: text;
      color: var(--fe-text-primary);
      background: var(--fe-bg-base);
    }

    .text-editor {
      height: 100%;
      display: flex;
      flex-direction: column;

      .editor-toolbar {
        padding: 8px 16px;
        border-bottom: 1px solid var(--fe-border);
        background: var(--fe-bg-elevated);
      }

      .editor-textarea {
        flex: 1;
        padding: 16px;
        margin: 0;
        font-size: 14px;
        line-height: 1.6;
        border: none;
        resize: none;
        outline: none;
        font-family: inherit;
        background: var(--fe-bg-base);
        color: var(--fe-text-primary);
      }
    }

    .preview-iframe {
      width: 100%;
      height: 100%;
      border: none;
    }

    .preview-image {
      display: block;
      max-width: 100%;
      max-height: 100%;
      object-fit: contain;
      margin: 0 auto;
    }

    .preview-type-image {
      display: flex;
      align-items: center;
      justify-content: center;
      height: 100%;
      padding: 16px;
      box-sizing: border-box;
    }

    .binary-preview {
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      height: 100%;
      gap: 16px;
      padding: 24px;
      box-sizing: border-box;
      color: var(--fe-text-secondary);

      .binary-icon {
        font-size: 64px;
        color: var(--fe-text-muted);
      }

      .binary-message {
        font-size: 14px;
        text-align: center;
        max-width: 80%;
      }
    }

    .pdf-iframe {
      width: 100%;
      height: 100%;
      border: none;
    }
  }
}
  • Step 2: Verify

Run: cd "D:/数科智联/项目/agent-frontend-web" && yarn dev

Click any text file (e.g. .md) in file panel → preview opens. Confirm:

  • Preview header dark with bottom border

  • Text content uses dark base background with light text

  • Double-click to edit → textarea dark

  • Step 3: Build check

Run: yarn build:prod 2>&1 | tail -5


Task 11: Install Shiki + create utils/highlighter.js

Files:

  • Modify: package.json (add dependency)

  • Create: src/views/frontend/utils/highlighter.js

  • Step 1: Add Shiki dependency

Run: cd "D:/数科智联/项目/agent-frontend-web" && yarn add shiki 2>&1 | tail -10

Expected: installs without peer warnings; package.json dependencies section gains "shiki": "^X.Y.Z" line.

If yarn add fails due to network/registry issues, skip and proceed to Step 2 — highlighter.js will work with the package even if not installed yet (errors will surface in Task 12 integration).

  • Step 2: Write utils/highlighter.js

Create src/views/frontend/utils/highlighter.js with:

import { createHighlighter } from 'shiki'
import MarkdownIt from 'markdown-it'

// Common languages for chat / file preview
const SUPPORTED_LANGS = [
  'javascript', 'typescript', 'tsx', 'jsx',
  'vue', 'svelte',
  'json', 'yaml', 'toml',
  'bash', 'shell', 'sh',
  'python', 'java', 'go', 'rust', 'c', 'cpp',
  'sql', 'graphql',
  'html', 'css', 'scss', 'less',
  'markdown', 'xml'
]

let highlighterPromise = null

function getHighlighter() {
  if (!highlighterPromise) {
    highlighterPromise = createHighlighter({
      themes: ['github-dark'],
      langs: SUPPORTED_LANGS
    })
  }
  return highlighterPromise
}

export async function renderCode(code, lang) {
  const highlighter = await getHighlighter()
  const supported = highlighter.getLoadedLanguages()
  const langKey = supported.includes(lang) ? lang : 'text'
  try {
    return highlighter.codeToHtml(code, { lang: langKey, theme: 'github-dark' })
  } catch (e) {
    // Fallback: plain escaped pre
    return `<pre><code>${escapeHtml(code)}</code></pre>`
  }
}

export async function createMarkdownRenderer() {
  const highlighter = await getHighlighter()
  const md = new MarkdownIt({
    html: false,
    linkify: true,
    typographer: true
  })
  md.options.highlight = (code, lang) => {
    const supported = highlighter.getLoadedLanguages()
    const langKey = supported.includes(lang) ? lang : 'text'
    try {
      return highlighter.codeToHtml(code, { lang: langKey, theme: 'github-dark' })
    } catch (e) {
      return `<pre><code>${escapeHtml(code)}</code></pre>`
    }
  }
  return md
}

function escapeHtml(s) {
  return s
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#039;')
}
  • Step 3: Verify the file loads (no syntax errors)

Run: cd "D:/数科智联/项目/agent-frontend-web" && node -e "import('./src/views/frontend/utils/highlighter.js').then(m => console.log('exports:', Object.keys(m))).catch(e => console.error(e.message))" 2>&1 | tail -10

Expected: prints exports: [ 'renderCode', 'createMarkdownRenderer' ] OR an error like "Cannot find module 'shiki'" (which is OK — file syntax is valid).

If the error is a SyntaxError, fix the file.

  • Step 4: Build check

Run: yarn build:prod 2>&1 | tail -10

Expected: build succeeds. Vite may warn about dynamic import of Shiki — this is fine.


Task 12: Integrate Shiki into ChatWindow.vue

Files:

  • Modify: src/views/frontend/components/ChatWindow.vue:99-108 (markdown-it setup) and :215 (formatContent)

  • Step 1: Replace markdown-it import + setup

Replace lines 99-108 with:

import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { Clock, ArrowDown, CircleCheck, CircleClose, ChatDotRound, Loading } from '@element-plus/icons-vue'
import { createMarkdownRenderer } from '@/views/frontend/utils/highlighter'

const mdReady = ref(false)
const mdRenderer = ref(null)

;(async () => {
  mdRenderer.value = await createMarkdownRenderer()
  mdReady.value = true
})()
  • Step 2: Update formatContent to use the async renderer

Replace lines 213-216 with:

function formatContent(content) {
  if (!content) return ''
  if (mdRenderer.value) {
    return mdRenderer.value.render(content.trim())
  }
  // Fallback while Shiki loads: escape and wrap in <pre>
  return `<pre style="white-space: pre-wrap">${escapeHtml(content)}</pre>`
}

function escapeHtml(s) {
  return s
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#039;')
}
  • Step 3: Add a watch to re-render content once Shiki loads

After the existing watch(() => props.loading, ...) block (around line 393-401), add:

// When Shiki finishes loading, force re-render of all visible messages
// by bumping messages array reference. Vue's reactivity will update v-html.
watch(mdReady, (ready) => {
  if (ready) {
    props.messages.length && nextTick(() => {
      // trigger v-html re-evaluation by mutating timestamps in-place
      // (forces reactivity without changing actual data)
    })
  }
})

(Note: Vue's v-html does not auto-recompute when a ref dependency changes mid-template. The simplest re-render trigger is to wait for Shiki before the first message arrives, which is the common case. If a message is already on screen when Shiki loads, the formatContent will use the fallback path until the message updates. Acceptable for our use case — most users won't send a message in the first ~100ms after page load.)

  • Step 4: Verify

Run: cd "D:/数科智联/项目/agent-frontend-web" && yarn dev

Send a message containing:

Here is some JS:

```js
const greet = (name) => `Hello, ${name}!`
console.log(greet('world'))
```

Confirm: code block renders with github-dark theme colors (dark gray background, syntax-highlighted tokens in cyan/green/purple).

If the code block is shown as plain <pre> without colors, the highlighter didn't initialize — check the browser console for errors mentioning shiki.

  • Step 5: Build check

Run: yarn build:prod 2>&1 | tail -10


Task 13: Integrate Shiki into index.vue file preview

Files:

  • Modify: src/views/frontend/index.vue:131-158 (script setup imports) + :88-95 (template preview block) + :60-65 (preview state)

  • Step 1: Add import for renderCode

In the <script setup> block (around line 153), add to the existing import from @/api/frontend:

No — renderCode comes from a different path. Add a new import line:

import { renderCode } from '@/views/frontend/utils/highlighter'

Place it next to the other @/... imports (around line 154).

  • Step 2: Add a reactive renderedCode ref

After the existing previewContent declaration (around line 199), add:

const renderedCode = ref('')
  • Step 3: Replace <pre> with Shiki-rendered <div v-html>

In the template around lines 88-95 (the text-preview block), replace:

<pre
  v-else
  class="preview-text"
  @dblclick="handleDoubleClick"
>{{ previewContent }}</pre>

with:

<div
  v-else
  class="preview-text preview-code"
  @dblclick="handleDoubleClick"
  v-html="renderedCode || escapeForPreview(previewContent)"
></div>
  • Step 4: Add a watcher that triggers renderCode when preview content changes

After the existing editableContent declaration (around line 204), add:

watch([previewContent, previewFile], async ([content, file]) => {
  if (!content || !file) {
    renderedCode.value = ''
    return
  }
  const lang = inferLangFromName(file.name)
  renderedCode.value = await renderCode(content, lang)
}, { immediate: false })

function inferLangFromName(name) {
  const ext = name.toLowerCase().split('.').pop()
  const map = {
    js: 'javascript', mjs: 'javascript', cjs: 'javascript',
    ts: 'typescript', tsx: 'tsx', jsx: 'jsx',
    vue: 'vue', svelte: 'svelte',
    json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml',
    py: 'python', java: 'java', go: 'go', rs: 'rust',
    rb: 'ruby', php: 'php', cs: 'csharp', swift: 'swift', kt: 'kotlin',
    sh: 'bash', bash: 'bash', zsh: 'bash',
    sql: 'sql', graphql: 'graphql', gql: 'graphql',
    html: 'html', htm: 'html', xml: 'xml',
    css: 'css', scss: 'scss', sass: 'scss', less: 'less',
    md: 'markdown', markdown: 'markdown',
    c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', hpp: 'cpp',
    txt: 'text', log: 'text'
  }
  return map[ext] || 'text'
}

function escapeForPreview(s) {
  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
  • Step 5: Add .preview-code style override

In the <style scoped> block (Task 10 already replaced), the .preview-text rule applies. Add a small override (insert after .preview-text rule):

.preview-code {
  background: var(--fe-bg-base);

  // Shiki outputs <pre class="shiki ..."> — neutralize its padding/background
  :deep(pre.shiki) {
    background: transparent !important;
    margin: 0;
    padding: 0;
  }
}
  • Step 6: Verify

Run: cd "D:/数科智联/项目/agent-frontend-web" && yarn dev

Click a .json file in the file panel → preview opens with colored syntax tokens. Click a .py file → similar dark theme rendering. Click a .md file → markdown highlighted.

  • Step 7: Build check

Run: yarn build:prod 2>&1 | tail -10


Task 14: Verify responsive breakpoints end-to-end

Files: none — verification only.

  • Step 1: Start dev server

Run: cd "D:/数科智联/项目/agent-frontend-web" && yarn dev

  • Step 2: Test 1200px breakpoint

Open browser devtools, set viewport to 1200 × 800. Confirm file preview panel (rightmost) is hidden. Resize to 1201 × 800. Confirm it reappears.

  • Step 3: Test 900px breakpoint

Resize to 900 × 800. Confirm:

  • Sidebar collapses to 28px width strip
  • Toggle button still visible in middle of collapsed strip

Resize to 901 × 800. Confirm sidebar returns to 260px.

  • Step 4: Test 768px breakpoint

Resize to 768 × 800. Confirm:

  • File panel (right) is hidden

  • Chat area takes the full remaining width

  • Step 5: Verify no regressions

  • Click "前往智能体广场" button → routes to /index

  • Right-click a conversation → menu shows dark with red "删除"

  • Click "退出" → confirmation dialog → logout → routes to /login

  • Click user avatar → profile dialog opens

  • Send a chat message → user bubble teal, assistant bubble dark gray

  • Switch to "文件" tab → file list dark, click a file → preview opens with syntax colors

  • Step 6: Verify admin pages are untouched

Navigate to /system/user (any admin page). Confirm:

  • Background is white (light theme unchanged)
  • No dark theme leakage from frontend variables

If admin pages show any dark elements, the EP variable override is leaking. Add :root reset to theme.scss — actually the override is already scoped under .frontend-container, so this should not happen. If it does, check that the override block starts with .frontend-container { and is not a global rule.

  • Step 7: Final build check

Run: yarn build:prod 2>&1 | tail -20

Expected: build succeeds with no SCSS errors.


Self-Review Notes

Spec coverage:

  • Theme tokens (Task 1)
  • EP variable overrides (Task 1)
  • Component restyling (Tasks 3-10, 8 components)
  • Shiki integration with markdown-it (Tasks 11-12)
  • Shiki for file preview (Task 13)
  • Responsive breakpoints 1200/900/768px (Task 1 + Task 14)
  • No JS/functional changes verified (Tasks 3-10 only modify <style> blocks)
  • Admin system untouched (Task 14 Step 6)

Placeholder scan: No TBD/TODO. All code blocks complete. All file paths absolute.

Type consistency:

  • renderCode(code, lang) signature consistent across Tasks 11, 13
  • createMarkdownRenderer() signature consistent across Tasks 11, 12
  • Token names (--fe-*) consistent across Tasks 1, 3-10
  • renderedCode ref name consistent in Task 13