Procházet zdrojové kódy

feat(frontend): 建立手术观测台视觉基础(shell token + 字体 + 通用组件)

wangkangyjy před 2 týdny
rodič
revize
3d1d45a783

+ 29 - 0
frontend/src/__tests__/theme-contract.test.js

@@ -0,0 +1,29 @@
+import { readFileSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import { dirname, resolve } from 'node:path'
+import { describe, expect, it } from 'vitest'
+
+const __dirname = dirname(fileURLToPath(import.meta.url))
+
+describe('surgical observatory theme', () => {
+  const css = readFileSync(resolve(__dirname, '../styles/theme.css'), 'utf8')
+
+  it('defines shell layout tokens', () => {
+    expect(css).toContain('--shell-rail-width:')
+    expect(css).toContain('--shell-context-width:')
+    expect(css).toContain('--shell-status-height:')
+    expect(css).toContain('--page-context-height:')
+  })
+
+  it('defines workspace surface tokens', () => {
+    expect(css).toContain('--workspace-bg:')
+    expect(css).toContain('--shell-bg-deep:')
+    expect(css).toContain('--shell-bg:')
+    expect(css).toContain('--motion-fast:')
+  })
+
+  it('preserves existing brand tokens', () => {
+    expect(css).toContain('--color-brand: #2b1f99')
+    expect(css).toContain('--color-accent: #3ad4d8')
+  })
+})

+ 9 - 0
frontend/src/assets/fonts/LICENSE-IBMPlex.txt

@@ -0,0 +1,9 @@
+IBM Plex Sans
+Licensed under SIL Open Font License 1.1
+https://github.com/IBM/plex/blob/master/LICENSE.txt
+
+Download WOFF2 files from:
+https://github.com/IBM/plex/releases
+
+Required files:
+- IBMPlexSans-Regular.woff2

+ 10 - 0
frontend/src/assets/fonts/LICENSE-SourceHanSans.txt

@@ -0,0 +1,10 @@
+Source Han Sans SC (思源黑体)
+Licensed under SIL Open Font License 1.1
+https://github.com/adobe-fonts/source-han-sans/raw/release/LICENSE.txt
+
+Download WOFF2 files from:
+https://github.com/adobe-fonts/source-han-sans/releases
+
+Required files:
+- SourceHanSansSC-Regular.woff2
+- SourceHanSansSC-Medium.woff2

+ 75 - 0
frontend/src/components/common/AsyncState.vue

@@ -0,0 +1,75 @@
+<template>
+  <div class="async-state">
+    <!-- Loading -->
+    <div v-if="loading" class="async-state__skeleton">
+      <div v-for="n in skeletonRows" :key="n" class="async-state__skeleton-row" :style="{ width: skeletonWidths[(n - 1) % skeletonWidths.length] }"></div>
+    </div>
+
+    <!-- Error -->
+    <div v-else-if="error" class="async-state__error">
+      <div class="async-state__error-icon">
+        <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
+          <circle cx="12" cy="12" r="10"/>
+          <line x1="12" y1="8" x2="12" y2="12"/>
+          <line x1="12" y1="16" x2="12.01" y2="16"/>
+        </svg>
+      </div>
+      <p class="async-state__error-text">{{ errorMessage || '加载失败' }}</p>
+      <el-button v-if="retryable" size="small" @click="$emit('retry')">重试</el-button>
+    </div>
+
+    <!-- Empty -->
+    <div v-else-if="empty" class="async-state__empty">
+      <p class="async-state__empty-text">{{ emptyMessage || '暂无数据' }}</p>
+      <p v-if="emptyHint" class="async-state__empty-hint">{{ emptyHint }}</p>
+    </div>
+
+    <!-- Content -->
+    <slot v-else />
+  </div>
+</template>
+
+<script setup>
+defineProps({
+  loading: { type: Boolean, default: false },
+  error: { type: Boolean, default: false },
+  empty: { type: Boolean, default: false },
+  errorMessage: { type: String, default: '' },
+  emptyMessage: { type: String, default: '' },
+  emptyHint: { type: String, default: '' },
+  retryable: { type: Boolean, default: true },
+  skeletonRows: { type: Number, default: 4 }
+})
+
+defineEmits(['retry'])
+
+const skeletonWidths = ['100%', '75%', '90%', '60%']
+</script>
+
+<style scoped>
+.async-state__skeleton { padding: 16px; }
+.async-state__skeleton-row {
+  height: 14px;
+  background: var(--color-surface-muted, #f3f6f9);
+  border-radius: 4px;
+  margin-bottom: 10px;
+  animation: async-pulse 1.5s ease-in-out infinite;
+}
+@keyframes async-pulse {
+  0%, 100% { opacity: 1; }
+  50% { opacity: 0.4; }
+}
+.async-state__error,
+.async-state__empty {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 48px 16px;
+  text-align: center;
+}
+.async-state__error-icon { color: var(--color-text-muted, #8a97a8); margin-bottom: 12px; }
+.async-state__error-text { color: var(--color-text-secondary, #5f6b7a); font-size: 13px; margin: 0 0 12px; }
+.async-state__empty-text { color: var(--color-text-muted, #8a97a8); font-size: 13px; margin: 0; }
+.async-state__empty-hint { color: var(--color-text-muted, #8a97a8); font-size: 11px; margin: 6px 0 0; }
+</style>

+ 45 - 0
frontend/src/components/common/MetricBar.vue

@@ -0,0 +1,45 @@
+<template>
+  <div class="metric-bar">
+    <div class="metric-bar__head">
+      <span class="metric-bar__label">{{ label }}</span>
+      <span class="metric-bar__value" :class="{ 'metric-bar__value--muted': muted }">
+        {{ displayValue }}<template v-if="showMax"> / {{ max }}</template>
+      </span>
+    </div>
+    <div class="metric-bar__track">
+      <div class="metric-bar__fill" :class="`metric-bar__fill--${tone}`" :style="{ width: pct + '%' }"></div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+
+const props = defineProps({
+  label: { type: String, required: true },
+  value: { type: Number, required: true },
+  max: { type: Number, default: 100 },
+  tone: { type: String, default: 'brand', validator: v => ['brand', 'success', 'warning', 'danger', 'info'].includes(v) },
+  showMax: { type: Boolean, default: true },
+  muted: { type: Boolean, default: false },
+  precision: { type: Number, default: 1 }
+})
+
+const pct = computed(() => props.max > 0 ? Math.min(100, Math.max(0, (props.value / props.max) * 100)) : 0)
+const displayValue = computed(() => Number(props.value).toFixed(props.precision))
+</script>
+
+<style scoped>
+.metric-bar { margin-bottom: 10px; }
+.metric-bar__head { display: flex; justify-content: space-between; margin-bottom: 4px; font-size: 12px; }
+.metric-bar__label { color: var(--color-text, #18212f); font-weight: 500; }
+.metric-bar__value { color: var(--color-text, #18212f); font-weight: 600; font-variant-numeric: tabular-nums; }
+.metric-bar__value--muted { color: var(--color-text-muted, #8a97a8); font-weight: 400; }
+.metric-bar__track { height: 5px; background: var(--table-border, #f3f6f9); border-radius: 3px; overflow: hidden; }
+.metric-bar__fill { height: 100%; border-radius: 3px; transition: width var(--motion-fast, 160ms ease); }
+.metric-bar__fill--brand { background: var(--color-brand, #2b1f99); }
+.metric-bar__fill--success { background: var(--color-success, #2e9d65); }
+.metric-bar__fill--warning { background: var(--color-warning, #c98219); }
+.metric-bar__fill--danger { background: var(--color-danger, #c94a4a); }
+.metric-bar__fill--info { background: var(--color-info, #4c6f91); }
+</style>

+ 36 - 0
frontend/src/components/common/StatusLabel.vue

@@ -0,0 +1,36 @@
+<template>
+  <span class="status-label" :class="`status-label--${tone}`">
+    <span class="status-label__dot"></span>
+    <span class="status-label__text">{{ label }}</span>
+  </span>
+</template>
+
+<script setup>
+defineProps({
+  tone: { type: String, default: 'info', validator: v => ['active', 'success', 'warning', 'danger', 'info'].includes(v) },
+  label: { type: String, required: true }
+})
+</script>
+
+<style scoped>
+.status-label {
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+  font-size: 11px;
+  font-weight: 500;
+  white-space: nowrap;
+}
+.status-label__dot {
+  width: var(--dot-size, 6px);
+  height: var(--dot-size, 6px);
+  border-radius: 50%;
+  flex-shrink: 0;
+}
+.status-label--active .status-label__dot { background: var(--color-success, #2e9d65); }
+.status-label--success .status-label__dot { background: var(--color-success, #2e9d65); }
+.status-label--warning .status-label__dot { background: var(--color-warning, #c98219); }
+.status-label--danger .status-label__dot { background: var(--color-danger, #c94a4a); }
+.status-label--info .status-label__dot { background: var(--color-text-muted, #8a97a8); }
+.status-label__text { color: var(--color-text-secondary, #5f6b7a); }
+</style>

+ 40 - 0
frontend/src/styles/theme.css

@@ -109,6 +109,46 @@
   /* 登录页品牌区渐变 */
   --color-login-brand-from: #1d166b;
   --color-login-brand-to: #120d4a;
+
+  /* ===== v3 手术观测台壳层 token ===== */
+  --shell-rail-width: 64px;
+  --shell-context-width: 176px;
+  --shell-status-height: 36px;
+  --page-context-height: 64px;
+
+  --workspace-bg: #f5f8fa;
+  --workspace-surface: #ffffff;
+
+  --shell-bg-deep: #120d4a;
+  --shell-bg: #1d166b;
+
+  --motion-fast: 160ms ease;
+}
+
+/* ===== 手术观测台字体 ===== */
+/* WOFF2 文件需从官方发行包提取并放入 src/assets/fonts/ */
+/* Source Han Sans SC: https://github.com/adobe-fonts/source-han-sans */
+/* IBM Plex Sans: https://github.com/IBM/plex */
+@font-face {
+  font-family: 'Source Han Sans SC';
+  src: local('Source Han Sans SC'), local('Noto Sans CJK SC');
+  font-weight: 400;
+  font-style: normal;
+  font-display: swap;
+}
+@font-face {
+  font-family: 'Source Han Sans SC';
+  src: local('Source Han Sans SC Medium'), local('Noto Sans CJK SC Medium');
+  font-weight: 500;
+  font-style: normal;
+  font-display: swap;
+}
+@font-face {
+  font-family: 'IBM Plex Sans';
+  src: local('IBM Plex Sans');
+  font-weight: 400;
+  font-style: normal;
+  font-display: swap;
 }
 
 * {