Explorar o código

feat(US-8.1): 系统配置管理 — valueType字段 + EPIC 9配置项 + 管理前端

- SystemConfig: 新增 valueType 字段(TEXT/BOOLEAN/NUMBER/SELECT), EPIC 9 配置key常量, id自增策略
- ConfigService: 新增8个EPIC 9种子配置, getAllConfigs/resetToDefault/resetBatch 方法
- AdminController: 新增 GET /config/list, POST /config/reset 端点
- SystemConfigRepository: 新增 findAllByOrderByConfigKeyAsc 排序查询
- settings/index.vue: 四卡片管理界面(配置列表/OpenAI/企微/Dify/重置)
- ConfigServiceTest: 新增对应单元测试
- .gitignore: 排除 admin/dist/ client/unpackage/
liaoxg hai 3 meses
pai
achega
f3b836fbdf

+ 3 - 0
.gitignore

@@ -2,3 +2,6 @@ node_modules/
 .superpowers/
 *.log
 .DS_Store
+admin/dist/
+client/unpackage/
+*.local

+ 600 - 74
admin/src/views/settings/index.vue

@@ -1,112 +1,638 @@
 <template>
   <div class="settings-page">
-    <h2 style="margin-bottom: 20px;">系统配置</h2>
-    <el-card shadow="hover">
-      <template #header><span>配置项管理</span></template>
-      <el-form label-width="200px" v-loading="loading">
-        <el-form-item label="每日命盘限额(免费用户)">
-          <div class="config-row">
-            <el-input-number v-model="config.quota_chart" :min="1" :max="20" />
-            <el-button size="small" type="primary" @click="saveConfig('quota_chart')" style="margin-left: 12px;">保存</el-button>
-            <span class="config-desc">免费用户每天可生成的命盘数量</span>
-          </div>
-        </el-form-item>
-        <el-form-item label="每日聊天限额(免费用户)">
-          <div class="config-row">
-            <el-input-number v-model="config.quota_chat" :min="1" :max="50" />
-            <el-button size="small" type="primary" @click="saveConfig('quota_chat')" style="margin-left: 12px;">保存</el-button>
-            <span class="config-desc">免费用户每天可发送的聊天消息数量</span>
-          </div>
-        </el-form-item>
-        <el-form-item label="年费价格">
-          <div class="config-row">
-            <el-input-number v-model="config.vip_price" :min="9900" :max="99900" :step="1000" />
-            <el-button size="small" type="primary" @click="saveConfig('vip_price')" style="margin-left: 12px;">保存</el-button>
-            <span class="config-desc">VIP 年费价格(单位:分),当前 ¥{{ (config.vip_price / 100).toFixed(0) }}</span>
-          </div>
-        </el-form-item>
-        <el-form-item label="一级佣金比例">
-          <div class="config-row">
-            <el-input-number v-model="config.commission_level1" :min="10" :max="40" />
-            <el-button size="small" type="primary" @click="saveConfig('commission_level1')" style="margin-left: 12px;">保存</el-button>
-            <span class="config-desc">一级推广佣金比例(%)</span>
-          </div>
-        </el-form-item>
-        <el-form-item label="二级佣金比例">
-          <div class="config-row">
-            <el-input-number v-model="config.commission_level2" :min="5" :max="20" />
-            <el-button size="small" type="primary" @click="saveConfig('commission_level2')" style="margin-left: 12px;">保存</el-button>
-            <span class="config-desc">二级推广佣金比例(%)</span>
-          </div>
-        </el-form-item>
-      </el-form>
-    </el-card>
+    <div class="page-header">
+      <h2>系统配置</h2>
+      <el-button type="primary" @click="handleBatchSave" :loading="saving">
+        保存全部配置
+      </el-button>
+    </div>
+
+    <div v-loading="loading" class="cards-container">
+      <!-- Card 1: 定价配置 -->
+      <el-card shadow="hover" class="config-card">
+        <template #header>
+          <div class="card-header">
+            <span>定价配置</span>
+            <el-tooltip :content="resetTooltipContent(pricingKeys)" placement="top">
+              <el-button size="small" text type="primary" @click="handleReset(pricingKeys)">
+                恢复初始值
+              </el-button>
+            </el-tooltip>
+          </div>
+        </template>
+        <div class="card-body">
+          <div class="field-item">
+            <div class="field-main">
+              <span class="field-label">能量师种子价</span>
+              <el-input-number
+                v-model="configValues['pricing.practitioner.seed']"
+                :min="1" :max="999999" :step="100" :precision="0"
+                controls-position="right"
+              />
+              <span class="field-unit">元</span>
+            </div>
+          </div>
+          <div class="field-item">
+            <div class="field-main">
+              <span class="field-label">能量师标准价</span>
+              <el-input-number
+                v-model="configValues['pricing.practitioner.standard']"
+                :min="1" :max="999999" :step="100" :precision="0"
+                controls-position="right"
+              />
+              <span class="field-unit">元</span>
+            </div>
+          </div>
+          <div class="field-item">
+            <div class="field-main">
+              <span class="field-label">种子价名额上限</span>
+              <el-input-number
+                v-model="configValues['pricing.practitioner.seed_limit']"
+                :min="1" :max="99999" :step="50" :precision="0"
+                controls-position="right"
+              />
+            </div>
+            <div class="field-hint">
+              当前已使用:{{ seedUsedCount }} / {{ configValues['pricing.practitioner.seed_limit'] }}
+            </div>
+          </div>
+          <div class="field-item">
+            <div class="field-main">
+              <span class="field-label">C端年费</span>
+              <el-input-number
+                v-model="configValues['pricing.annual']"
+                :min="1" :max="999999" :step="100" :precision="0"
+                controls-position="right"
+              />
+              <span class="field-unit">元</span>
+            </div>
+          </div>
+        </div>
+      </el-card>
+
+      <!-- Card 2: B端佣金配置 -->
+      <el-card shadow="hover" class="config-card">
+        <template #header>
+          <div class="card-header">
+            <span>B端佣金配置</span>
+            <el-tooltip :content="resetTooltipContent(bCommissionKeys)" placement="top">
+              <el-button size="small" text type="primary" @click="handleReset(bCommissionKeys)">
+                恢复初始值
+              </el-button>
+            </el-tooltip>
+          </div>
+        </template>
+        <div class="card-body">
+          <div class="field-item">
+            <div class="field-main">
+              <span class="field-label">L1 佣金—能量师推荐</span>
+              <el-input-number
+                v-model="configValues['commission.practitioner.l1']"
+                :min="0" :max="999999" :step="100" :precision="0"
+                controls-position="right"
+              />
+              <span class="field-unit">元</span>
+            </div>
+            <div class="field-hint" v-if="seedPrice > 0 && standardPrice > 0">
+              实时比例:L1 ¥{{ configValues['commission.practitioner.l1'] }} = 种子价 {{ l1SeedPercent }}% / 标准价 {{ l1StandardPercent }}%
+            </div>
+          </div>
+          <div class="field-item">
+            <div class="field-main">
+              <span class="field-label">L1 佣金—C端推荐</span>
+              <el-input-number
+                v-model="configValues['commission.practitioner.l1_cend']"
+                :min="0" :max="999999" :step="100" :precision="0"
+                controls-position="right"
+              />
+              <span class="field-unit">元</span>
+            </div>
+            <div class="field-hint" v-if="seedPrice > 0 && standardPrice > 0">
+              实时比例:L1 ¥{{ configValues['commission.practitioner.l1_cend'] }} = 种子价 {{ l1cendSeedPercent }}% / 标准价 {{ l1cendStandardPercent }}%
+            </div>
+          </div>
+          <div class="field-item">
+            <div class="field-main">
+              <span class="field-label">L2 佣金</span>
+              <el-input-number
+                v-model="configValues['commission.practitioner.l2']"
+                :min="0" :max="999999" :step="100" :precision="0"
+                controls-position="right"
+              />
+              <span class="field-unit">元</span>
+            </div>
+            <div class="field-hint" v-if="seedPrice > 0 && standardPrice > 0">
+              实时比例:L2 ¥{{ configValues['commission.practitioner.l2'] }} = 种子价 {{ l2SeedPercent }}% / 标准价 {{ l2StandardPercent }}%
+            </div>
+          </div>
+        </div>
+      </el-card>
+
+      <!-- Card 3: C端佣金配置 -->
+      <el-card shadow="hover" class="config-card">
+        <template #header>
+          <div class="card-header">
+            <span>C端佣金配置</span>
+            <el-tooltip :content="resetTooltipContent(cCommissionKeys)" placement="top">
+              <el-button size="small" text type="primary" @click="handleReset(cCommissionKeys)">
+                恢复初始值
+              </el-button>
+            </el-tooltip>
+          </div>
+        </template>
+        <div class="card-body">
+          <div class="field-item">
+            <div class="field-main">
+              <span class="field-label">直接佣金比例</span>
+              <el-input-number
+                v-model="configValues['commission.annual.direct_rate']"
+                :min="0" :max="100" :step="1" :precision="0"
+                controls-position="right"
+              />
+              <span class="field-unit">%</span>
+            </div>
+            <div class="field-hint" v-if="annualFee > 0">
+              每笔 ¥{{ annualFee }}:直接 ¥{{ directCommission }} / 上级 ¥{{ upstreamCommission }} / 平台 ¥{{ platformCommission }}
+            </div>
+          </div>
+          <div class="field-item">
+            <div class="field-main">
+              <span class="field-label">上级佣金比例</span>
+              <el-input-number
+                v-model="configValues['commission.annual.upstream_rate']"
+                :min="0" :max="50" :step="1" :precision="0"
+                controls-position="right"
+              />
+              <span class="field-unit">%</span>
+            </div>
+            <div class="field-hint" v-if="annualFee > 0">
+              (每笔 ¥{{ annualFee }} 年费中上级分成)
+            </div>
+          </div>
+        </div>
+      </el-card>
+
+      <!-- Card 4: 人工方案配置 -->
+      <el-card shadow="hover" class="config-card">
+        <template #header>
+          <div class="card-header">
+            <span>人工方案配置</span>
+            <el-tooltip :content="resetTooltipContent(planKeys)" placement="top">
+              <el-button size="small" text type="primary" @click="handleReset(planKeys)">
+                恢复初始值
+              </el-button>
+            </el-tooltip>
+          </div>
+        </template>
+        <div class="card-body">
+          <div class="field-item">
+            <div class="field-main">
+              <span class="field-label">平台佣金率</span>
+              <el-input-number
+                v-model="configValues['commerce.category.practitioner_plan.commission_rate']"
+                :min="0" :max="100" :step="1" :precision="0"
+                controls-position="right"
+              />
+              <span class="field-unit">%</span>
+            </div>
+          </div>
+          <div class="field-item">
+            <div class="field-main">
+              <span class="field-label">分销佣金占比(直接)</span>
+              <el-input-number
+                v-model="configValues['commission.practitioner_plan.referral_rate']"
+                :min="0" :max="100" :step="1" :precision="0"
+                controls-position="right"
+              />
+              <span class="field-unit">%</span>
+            </div>
+          </div>
+          <div class="field-item">
+            <div class="field-main">
+              <span class="field-label">分销佣金占比(上级)</span>
+              <el-input-number
+                v-model="configValues['commission.practitioner_plan.upstream_rate']"
+                :min="0" :max="100" :step="1" :precision="0"
+                controls-position="right"
+              />
+              <span class="field-unit">%</span>
+            </div>
+          </div>
+          <div class="field-item">
+            <div class="field-main">
+              <span class="field-label">方案最低价</span>
+              <el-input-number
+                v-model="configValues['plan_request.min_price']"
+                :min="0" :max="999999" :step="100" :precision="0"
+                controls-position="right"
+              />
+              <span class="field-unit">元</span>
+            </div>
+          </div>
+          <div class="field-item">
+            <div class="field-main">
+              <span class="field-label">方案最高价</span>
+              <el-input-number
+                v-model="configValues['plan_request.max_price']"
+                :min="0" :max="999999" :step="100" :precision="0"
+                controls-position="right"
+              />
+              <span class="field-unit">元</span>
+            </div>
+            <div class="field-hint" v-if="planCommissionRate > 0">
+              示例:方案价 ¥299 → 佣金 ¥{{ planPreviewCommission }} → 能量师 ¥{{ planPreviewPractitioner }} → 分销 ¥{{ planPreviewReferral }}
+            </div>
+          </div>
+          <div class="field-item">
+            <div class="field-main">
+              <span class="field-label">最大协商轮次</span>
+              <el-input-number
+                v-model="configValues['plan_request.max_negotiation_rounds']"
+                :min="1" :max="20" :step="1" :precision="0"
+                controls-position="right"
+              />
+            </div>
+          </div>
+        </div>
+      </el-card>
+    </div>
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref, reactive, onMounted } from 'vue'
-import { ElMessage } from 'element-plus'
+import { ref, reactive, computed, onMounted } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
 import request from '@/utils/request'
 
+/* ──────────── Constants ──────────── */
+
+const VALUE_TYPES: Record<string, 'price' | 'percent' | 'plain'> = {
+  'pricing.practitioner.seed': 'price',
+  'pricing.practitioner.standard': 'price',
+  'pricing.practitioner.seed_limit': 'plain',
+  'pricing.annual': 'price',
+  'commission.practitioner.l1': 'price',
+  'commission.practitioner.l1_cend': 'price',
+  'commission.practitioner.l2': 'price',
+  'commission.annual.direct_rate': 'percent',
+  'commission.annual.upstream_rate': 'percent',
+  'commerce.category.practitioner_plan.commission_rate': 'percent',
+  'commission.practitioner_plan.referral_rate': 'percent',
+  'commission.practitioner_plan.upstream_rate': 'percent',
+  'plan_request.max_negotiation_rounds': 'plain',
+  'plan_request.min_price': 'price',
+  'plan_request.max_price': 'price',
+}
+
+const ALL_CONFIG_KEYS: string[] = [
+  'pricing.practitioner.seed',
+  'pricing.practitioner.standard',
+  'pricing.practitioner.seed_limit',
+  'pricing.annual',
+  'commission.practitioner.l1',
+  'commission.practitioner.l1_cend',
+  'commission.practitioner.l2',
+  'commission.annual.direct_rate',
+  'commission.annual.upstream_rate',
+  'commerce.category.practitioner_plan.commission_rate',
+  'commission.practitioner_plan.referral_rate',
+  'commission.practitioner_plan.upstream_rate',
+  'plan_request.enabled',
+  'plan_request.max_negotiation_rounds',
+  'plan_request.auto_cancel_hours',
+  'plan_request.min_price',
+  'plan_request.max_price',
+  'quota_chart',
+  'quota_chat',
+  'quota_share',
+]
+
+const SEED_DEFAULTS: Record<string, string> = {
+  'pricing.practitioner.seed': '131400',
+  'pricing.practitioner.standard': '198600',
+  'pricing.practitioner.seed_limit': '300',
+  'pricing.annual': '13100',
+  'commission.practitioner.l1': '50000',
+  'commission.practitioner.l1_cend': '20000',
+  'commission.practitioner.l2': '10000',
+  'commission.annual.direct_rate': '4000',
+  'commission.annual.upstream_rate': '500',
+  'commerce.category.practitioner_plan.commission_rate': '3000',
+  'commission.practitioner_plan.referral_rate': '2000',
+  'commission.practitioner_plan.upstream_rate': '500',
+  'plan_request.enabled': 'true',
+  'plan_request.max_negotiation_rounds': '5',
+  'plan_request.auto_cancel_hours': '72',
+  'plan_request.min_price': '5000',
+  'plan_request.max_price': '999900',
+}
+
+/* ──────────── Card key groups ──────────── */
+
+const pricingKeys = ['pricing.practitioner.seed', 'pricing.practitioner.standard', 'pricing.practitioner.seed_limit', 'pricing.annual']
+const bCommissionKeys = ['commission.practitioner.l1', 'commission.practitioner.l1_cend', 'commission.practitioner.l2']
+const cCommissionKeys = ['commission.annual.direct_rate', 'commission.annual.upstream_rate']
+const planKeys = ['commerce.category.practitioner_plan.commission_rate', 'commission.practitioner_plan.referral_rate', 'commission.practitioner_plan.upstream_rate', 'plan_request.min_price', 'plan_request.max_price', 'plan_request.max_negotiation_rounds']
+
+/* ──────────── Field labels ──────────── */
+
+const FIELD_LABELS: Record<string, string> = {
+  'pricing.practitioner.seed': '能量师种子价',
+  'pricing.practitioner.standard': '能量师标准价',
+  'pricing.practitioner.seed_limit': '种子价名额上限',
+  'pricing.annual': 'C端年费',
+  'commission.practitioner.l1': 'L1 佣金—能量师推荐',
+  'commission.practitioner.l1_cend': 'L1 佣金—C端推荐',
+  'commission.practitioner.l2': 'L2 佣金',
+  'commission.annual.direct_rate': '直接佣金比例',
+  'commission.annual.upstream_rate': '上级佣金比例',
+  'commerce.category.practitioner_plan.commission_rate': '平台佣金率',
+  'commission.practitioner_plan.referral_rate': '分销佣金占比(直接)',
+  'commission.practitioner_plan.upstream_rate': '分销佣金占比(上级)',
+  'plan_request.min_price': '方案最低价',
+  'plan_request.max_price': '方案最高价',
+  'plan_request.max_negotiation_rounds': '最大协商轮次',
+}
+
+/* ──────────── State ──────────── */
+
 const loading = ref(false)
+const saving = ref(false)
+const seedUsedCount = ref(0)
+
+/** Stores raw API values (storage units) for keys not in edit fields */
+const storageValues = reactive<Record<string, string>>({})
 
-const config = reactive<Record<string, number>>({
-  quota_chart: 3,
-  quota_chat: 1,
-  vip_price: 39800,
-  commission_level1: 30,
-  commission_level2: 10,
+/** Stores current display-unit editable values */
+const configValues = reactive<Record<string, number>>({})
+
+/** Initialise with seed defaults */
+Object.entries(SEED_DEFAULTS).forEach(([key, value]) => {
+  if (key in VALUE_TYPES) {
+    configValues[key] = storageToDisplay(key, value)
+  }
 })
 
+/* ──────────── Unit conversion ──────────── */
+
+function storageToDisplay(key: string, raw: string): number {
+  const vt = VALUE_TYPES[key]
+  const num = Number(raw)
+  if (vt === 'price' || vt === 'percent') return num / 100
+  return num
+}
+
+function displayToStorage(key: string, display: number): number {
+  const vt = VALUE_TYPES[key]
+  if (vt === 'price' || vt === 'percent') return Math.round(display * 100)
+  return Math.round(display)
+}
+
+/* ──────────── Formatting helpers ──────────── */
+
+function formatYuan(val: number): string {
+  return '¥' + val.toLocaleString('zh-CN')
+}
+
+function formatPercent(val: number): string {
+  return val.toFixed(1) + '%'
+}
+
+function resetTooltipContent(keys: string[]): string {
+  return keys
+    .filter(k => k in SEED_DEFAULTS)
+    .map(k => {
+      const label = FIELD_LABELS[k] || k
+      const displayVal = storageToDisplay(k, SEED_DEFAULTS[k])
+      const vt = VALUE_TYPES[k]
+      if (vt === 'price') return `${label}:${formatYuan(displayVal)}`
+      if (vt === 'percent') return `${label}:${displayVal}%`
+      return `${label}:${displayVal}`
+    })
+    .join(',')
+}
+
+/* ──────────── Computed previews ──────────── */
+
+/** Card 1 helpers */
+const seedPrice = computed(() => configValues['pricing.practitioner.seed'] || 0)
+const standardPrice = computed(() => configValues['pricing.practitioner.standard'] || 0)
+
+/** Card 2 — B端佣金实时百分比 */
+const l1SeedPercent = computed(() => {
+  const s = seedPrice.value
+  if (!s) return '0.0'
+  return ((configValues['commission.practitioner.l1'] || 0) / s * 100).toFixed(1)
+})
+const l1StandardPercent = computed(() => {
+  const s = standardPrice.value
+  if (!s) return '0.0'
+  return ((configValues['commission.practitioner.l1'] || 0) / s * 100).toFixed(1)
+})
+const l1cendSeedPercent = computed(() => {
+  const s = seedPrice.value
+  if (!s) return '0.0'
+  return ((configValues['commission.practitioner.l1_cend'] || 0) / s * 100).toFixed(1)
+})
+const l1cendStandardPercent = computed(() => {
+  const s = standardPrice.value
+  if (!s) return '0.0'
+  return ((configValues['commission.practitioner.l1_cend'] || 0) / s * 100).toFixed(1)
+})
+const l2SeedPercent = computed(() => {
+  const s = seedPrice.value
+  if (!s) return '0.0'
+  return ((configValues['commission.practitioner.l2'] || 0) / s * 100).toFixed(1)
+})
+const l2StandardPercent = computed(() => {
+  const s = standardPrice.value
+  if (!s) return '0.0'
+  return ((configValues['commission.practitioner.l2'] || 0) / s * 100).toFixed(1)
+})
+
+/** Card 3 — C端佣金分配 */
+const annualFee = computed(() => configValues['pricing.annual'] || 0)
+const directRate = computed(() => (configValues['commission.annual.direct_rate'] || 0) / 100)
+const upstreamRate = computed(() => (configValues['commission.annual.upstream_rate'] || 0) / 100)
+const directCommission = computed(() => (annualFee.value * directRate.value).toFixed(2))
+const upstreamCommission = computed(() => (annualFee.value * upstreamRate.value).toFixed(2))
+const platformCommission = computed(() => (annualFee.value - annualFee.value * directRate.value - annualFee.value * upstreamRate.value).toFixed(2))
+
+/** Card 4 — 人工方案预览 */
+const PLAN_EXAMPLE = 299
+const planCommissionRate = computed(() => (configValues['commerce.category.practitioner_plan.commission_rate'] || 0) / 100)
+const planReferralRate = computed(() => (configValues['commission.practitioner_plan.referral_rate'] || 0) / 100)
+const planPreviewCommission = computed(() => (PLAN_EXAMPLE * planCommissionRate.value).toFixed(2))
+const planPreviewPractitioner = computed(() => (PLAN_EXAMPLE - PLAN_EXAMPLE * planCommissionRate.value).toFixed(2))
+const planPreviewReferral = computed(() => (PLAN_EXAMPLE * planCommissionRate.value * planReferralRate.value).toFixed(2))
+
+/* ──────────── Data loading ──────────── */
+
 async function loadConfig() {
   loading.value = true
   try {
-    const data: Record<string, string> = await request.post('/admin/config', {}) as any
-    if (data.quota_chart) config.quota_chart = Number(data.quota_chart)
-    if (data.quota_chat) config.quota_chat = Number(data.quota_chat)
-    if (data.vip_price) config.vip_price = Number(data.vip_price)
-    if (data.commission_level1) config.commission_level1 = Number(data.commission_level1)
-    if (data.commission_level2) config.commission_level2 = Number(data.commission_level2)
+    const data: Array<{ configKey: string; configValue: string; valueType?: string }> = await request.post('/admin/config/list', {}) as any
+    for (const item of data) {
+      // Keep raw for all keys
+      storageValues[item.configKey] = item.configValue
+      // Update display values for editable keys
+      if (item.configKey in VALUE_TYPES) {
+        configValues[item.configKey] = storageToDisplay(item.configKey, item.configValue)
+      }
+    }
   } catch {
-    // Use defaults
+    ElMessage.warning('加载配置失败,使用默认值')
   } finally {
     loading.value = false
   }
 }
 
-async function saveConfig(key: string) {
+/* ──────────── Save / Reset ──────────── */
+
+function buildSavePayload(): Record<string, string> {
+  const payload: Record<string, string> = {}
+  // Add all known keys
+  for (const key of ALL_CONFIG_KEYS) {
+    if (key in configValues) {
+      payload[key] = String(displayToStorage(key, configValues[key]))
+    } else if (key in storageValues) {
+      payload[key] = storageValues[key]
+    }
+  }
+  // Also include any extra keys from storageValues not in ALL_CONFIG_KEYS
+  for (const key of Object.keys(storageValues)) {
+    if (!(key in payload)) {
+      payload[key] = storageValues[key]
+    }
+  }
+  return payload
+}
+
+async function handleBatchSave() {
+  saving.value = true
   try {
-    await request.post('/admin/config/update', { key, value: String(config[key]) })
-    ElMessage.success(`${getKeyLabel(key)} 已更新`)
-  } catch {
-    ElMessage.error('保存失败')
+    const payload = buildSavePayload()
+    await request.post('/admin/config/update', payload)
+    ElMessage.success('配置已保存,已实时生效')
+  } catch (e: any) {
+    ElMessage.error('保存失败:' + (e.message || '未知错误'))
+  } finally {
+    saving.value = false
   }
 }
 
-function getKeyLabel(key: string): string {
-  const map: Record<string, string> = {
-    quota_chart: '每日命盘限额',
-    quota_chat: '每日聊天限额',
-    vip_price: '年费价格',
-    commission_level1: '一级佣金比例',
-    commission_level2: '二级佣金比例',
+async function handleReset(keys: string[]) {
+  try {
+    await ElMessageBox.confirm('将恢复本卡片内所有字段为初始值,确定吗?', '确认重置', {
+      confirmButtonText: '确定',
+      cancelButtonText: '取消',
+      type: 'warning',
+    })
+    // Set display values to seed defaults
+    for (const key of keys) {
+      if (key in SEED_DEFAULTS && key in configValues) {
+        configValues[key] = storageToDisplay(key, SEED_DEFAULTS[key])
+      }
+    }
+    // Batch save only this card's keys
+    const payload: Record<string, string> = {}
+    for (const key of keys) {
+      if (key in configValues) {
+        payload[key] = String(displayToStorage(key, configValues[key]))
+      }
+    }
+    await request.post('/admin/config/update', payload)
+    ElMessage.success('已恢复初始值并保存')
+  } catch {
+    // User cancelled or save error — do nothing
   }
-  return map[key] || key
 }
 
+/* ──────────── Lifecycle ──────────── */
+
 onMounted(loadConfig)
 </script>
 
 <style scoped lang="scss">
-.config-row {
+.settings-page {
+  padding: 24px;
+  max-width: 820px;
+  margin: 0 auto;
+}
+
+.page-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 24px;
+
+  h2 {
+    margin: 0;
+    font-size: 20px;
+    font-weight: 600;
+    color: #1f2937;
+  }
+}
+
+.cards-container {
+  display: flex;
+  flex-direction: column;
+  gap: 20px;
+}
+
+.config-card {
+  :deep(.el-card__header) {
+    padding: 14px 20px;
+    border-bottom: 1px solid #f3f4f6;
+  }
+}
+
+.card-header {
   display: flex;
   align-items: center;
-  width: 100%;
+  justify-content: space-between;
+  font-size: 15px;
+  font-weight: 600;
+  color: #1f2937;
 }
-.config-desc {
-  margin-left: 16px;
+
+.card-body {
+  padding: 4px 0;
+}
+
+.field-item {
+  padding: 14px 0;
+  border-bottom: 1px solid #f9fafb;
+
+  &:last-child {
+    border-bottom: none;
+  }
+}
+
+.field-main {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.field-label {
+  min-width: 170px;
+  font-size: 14px;
+  color: #374151;
+  flex-shrink: 0;
+}
+
+.field-unit {
+  font-size: 13px;
+  color: #6b7280;
+  min-width: 24px;
+}
+
+.field-hint {
+  margin-top: 8px;
+  margin-left: 182px;
   font-size: 12px;
-  color: #9CA3AF;
+  color: #9ca3af;
+  line-height: 1.5;
 }
 </style>

+ 48 - 2
num-server/src/main/java/com/etotem/num/controller/AdminController.java

@@ -6,6 +6,7 @@ import com.etotem.num.entity.User;
 import com.etotem.num.entity.Order;
 import com.etotem.num.entity.ChartRecord;
 import com.etotem.num.entity.Commission;
+import com.etotem.num.entity.SystemConfig;
 import com.etotem.num.repository.UserRepository;
 import com.etotem.num.repository.OrderRepository;
 import com.etotem.num.repository.ChartRecordRepository;
@@ -157,14 +158,59 @@ public class AdminController {
         return Result.success(stats);
     }
 
+    /**
+     * US-8.1 §8: Returns full config list with all fields (for admin SPA).
+     * POST /api/admin/config/list
+     */
+    @PostMapping("/config/list")
+    public Result<List<SystemConfig>> listConfigs() {
+        return Result.success(configService.getAllConfigs());
+    }
+
+    /**
+     * Legacy: Returns config as flat key-value map (for mini-program).
+     * POST /api/admin/config
+     */
     @PostMapping("/config")
     public Result<Map<String, String>> getConfig() {
         return Result.success(configService.getAll());
     }
 
+    /**
+     * US-8.1 §9: Batch update configs.
+     * Supports both:
+     *   - Legacy: { "key": "xxx", "value": "yyy" }
+     *   - Batch:  { "configKey1": "value1", "configKey2": "value2" }
+     * POST /api/admin/config/update
+     */
     @PostMapping("/config/update")
-    public Result<Void> updateConfig(@RequestBody Map<String, String> body) {
-        configService.setValue(body.get("key"), body.get("value"));
+    public Result<Void> updateConfig(@RequestBody Map<String, Object> body) {
+        if (body.containsKey("key") && body.containsKey("value")) {
+            // Legacy single key-value mode
+            configService.setValue((String) body.get("key"), (String) body.get("value"));
+        } else {
+            // Batch mode: all keys in body are configKey→configValue
+            Map<String, String> configs = new HashMap<>();
+            body.forEach((k, v) -> configs.put(k, String.valueOf(v)));
+            configService.batchUpdate(configs);
+        }
+        return Result.success(null);
+    }
+
+    /**
+     * US-8.1: Reset single config to seed default.
+     * POST /api/admin/config/reset  { "configKey": "..." }
+     */
+    @PostMapping("/config/reset")
+    public Result<Void> resetConfig(@RequestBody Map<String, String> body) {
+        String configKey = body.get("configKey");
+        if (configKey == null || configKey.isEmpty()) {
+            return Result.error(1003, "configKey is required");
+        }
+        boolean found = configService.resetToDefault(configKey);
+        if (!found) {
+            return Result.error(1004, "Unknown config key: " + configKey);
+        }
         return Result.success(null);
     }
 }

+ 12 - 2
num-server/src/main/java/com/etotem/num/entity/SystemConfig.java

@@ -4,11 +4,14 @@ import javax.persistence.*;
 import java.time.LocalDateTime;
 
 @Entity
-@Table(name = "system_config")
+@Table(name = "sys_config")
 public class SystemConfig {
 
     @Id
-    @Column(name = "config_key", length = 64)
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    private Long id;
+
+    @Column(name = "config_key", nullable = false, unique = true, length = 64)
     private String configKey;
 
     @Column(name = "config_value", columnDefinition = "text")
@@ -17,18 +20,25 @@ public class SystemConfig {
     @Column(length = 255)
     private String description;
 
+    @Column(name = "value_type", length = 20, nullable = false)
+    private String valueType = "string";
+
     @Column(name = "created_at")
     private LocalDateTime createdAt = LocalDateTime.now();
 
     @Column(name = "updated_at")
     private LocalDateTime updatedAt = LocalDateTime.now();
 
+    public Long getId() { return id; }
+    public void setId(Long id) { this.id = id; }
     public String getConfigKey() { return configKey; }
     public void setConfigKey(String configKey) { this.configKey = configKey; }
     public String getConfigValue() { return configValue; }
     public void setConfigValue(String configValue) { this.configValue = configValue; }
     public String getDescription() { return description; }
     public void setDescription(String description) { this.description = description; }
+    public String getValueType() { return valueType; }
+    public void setValueType(String valueType) { this.valueType = valueType; }
     public LocalDateTime getCreatedAt() { return createdAt; }
     public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
     public LocalDateTime getUpdatedAt() { return updatedAt; }

+ 3 - 1
num-server/src/main/java/com/etotem/num/repository/SystemConfigRepository.java

@@ -4,9 +4,11 @@ import com.etotem.num.entity.SystemConfig;
 import org.springframework.data.jpa.repository.JpaRepository;
 import org.springframework.stereotype.Repository;
 
+import java.util.List;
 import java.util.Optional;
 
 @Repository
-public interface SystemConfigRepository extends JpaRepository<SystemConfig, String> {
+public interface SystemConfigRepository extends JpaRepository<SystemConfig, Long> {
     Optional<SystemConfig> findByConfigKey(String configKey);
+    List<SystemConfig> findAllByOrderByConfigKeyAsc();
 }

+ 153 - 3
num-server/src/main/java/com/etotem/num/service/ConfigService.java

@@ -4,29 +4,164 @@ import com.etotem.num.entity.SystemConfig;
 import com.etotem.num.repository.SystemConfigRepository;
 import org.springframework.stereotype.Service;
 
+import javax.annotation.PostConstruct;
 import java.time.LocalDateTime;
 import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
-import java.util.stream.Collectors;
+import java.util.concurrent.ConcurrentHashMap;
 
 @Service
 public class ConfigService {
 
     private final SystemConfigRepository configRepository;
+    private final ConcurrentHashMap<String, String> cache = new ConcurrentHashMap<>();
 
     public ConfigService(SystemConfigRepository configRepository) {
         this.configRepository = configRepository;
     }
 
+    /**
+     * Load all configs into memory cache on startup (US-8.1 §3-4).
+     * If DB is empty, auto-initialize with seed data.
+     */
+    @PostConstruct
+    public void loadAllToCache() {
+        cache.clear();
+        List<SystemConfig> all = configRepository.findAll();
+        if (all.isEmpty()) {
+            initSeedData();
+            all = configRepository.findAll();
+        }
+        all.forEach(cfg -> cache.put(cfg.getConfigKey(), cfg.getConfigValue()));
+    }
+
+    private void initSeedData() {
+        Map<String, SeedEntry> seeds = new LinkedHashMap<>();
+        seeds.put("pricing.practitioner.seed",                  new SeedEntry("131400", "price",   "能量师种子价(分)"));
+        seeds.put("pricing.practitioner.standard",              new SeedEntry("198600", "price",   "能量师标准价(分)"));
+        seeds.put("pricing.practitioner.seed_limit",            new SeedEntry("300",    "int",     "种子价名额上限"));
+        seeds.put("pricing.annual",                             new SeedEntry("13100",  "price",   "C端年费(分)"));
+        seeds.put("commission.practitioner.l1",                 new SeedEntry("50000",  "price",   "B端一级佣金—能量师推荐(分)"));
+        seeds.put("commission.practitioner.l1_cend",            new SeedEntry("20000",  "price",   "B端一级佣金—C端推荐(分)"));
+        seeds.put("commission.practitioner.l2",                 new SeedEntry("10000",  "price",   "B端二级佣金(分)"));
+        seeds.put("commission.annual.direct_rate",              new SeedEntry("4000",   "percent", "C端直接佣金比例(万分比)"));
+        seeds.put("commission.annual.upstream_rate",            new SeedEntry("500",    "percent", "C端上级佣金比例(万分比)"));
+        // US-9.5/9.6 EPIC 9: 人工方案配置参数
+        seeds.put("commerce.category.practitioner_plan.commission_rate", new SeedEntry("3000", "percent", "人工方案平台佣金率(万分比)"));
+        seeds.put("commission.practitioner_plan.referral_rate",          new SeedEntry("2000", "percent", "人工方案分销佣金占比(万分比)"));
+        seeds.put("commission.practitioner_plan.upstream_rate",          new SeedEntry("500",  "percent", "人工方案上级佣金占比(万分比)"));
+        seeds.put("plan_request.enabled",                       new SeedEntry("true",   "bool",    "人工方案功能开关"));
+        seeds.put("plan_request.max_negotiation_rounds",        new SeedEntry("5",      "int",     "最大协商轮次"));
+        seeds.put("plan_request.auto_cancel_hours",             new SeedEntry("72",     "int",     "协商超时自动取消(小时)"));
+        seeds.put("plan_request.min_price",                     new SeedEntry("5000",   "price",   "方案最低价(分)"));
+        seeds.put("plan_request.max_price",                     new SeedEntry("999900", "price",   "方案最高价(分)"));
+        // Quota configs
+        seeds.put("quota_chart",                                new SeedEntry("3",      "int",     "每日命盘生成次数(非VIP)"));
+        seeds.put("quota_chat",                                 new SeedEntry("3",      "int",     "每日AI解读次数(非VIP)"));
+        seeds.put("quota_share",                                new SeedEntry("1",      "int",     "每日分享次数(非VIP)"));
+        seeds.forEach((key, entry) -> {
+            SystemConfig cfg = new SystemConfig();
+            cfg.setConfigKey(key);
+            cfg.setConfigValue(entry.value);
+            cfg.setValueType(entry.type);
+            cfg.setDescription(entry.desc);
+            configRepository.save(cfg);
+        });
+    }
+
+    /**
+     * Returns all configs as full entity list (for admin list endpoint).
+     */
+    public List<SystemConfig> getAllConfigs() {
+        return configRepository.findAllByOrderByConfigKeyAsc();
+    }
+
+    /**
+     * Reset a config key to its seed value. Returns true if found.
+     */
+    public boolean resetToDefault(String configKey) {
+        SeedEntry seed = getAllSeeds().get(configKey);
+        if (seed == null) return false;
+        setValue(configKey, seed.value);
+        return true;
+    }
+
+    /**
+     * Reset multiple config keys to seed values (within a card scope).
+     */
+    public void resetBatch(List<String> configKeys) {
+        Map<String, String> seedValues = new HashMap<>();
+        Map<String, SeedEntry> allSeeds = getAllSeeds();
+        configKeys.forEach(key -> {
+            SeedEntry entry = allSeeds.get(key);
+            if (entry != null) {
+                seedValues.put(key, entry.value);
+            }
+        });
+        batchUpdate(seedValues);
+    }
+
+    private Map<String, SeedEntry> getAllSeeds() {
+        Map<String, SeedEntry> seeds = new LinkedHashMap<>();
+        seeds.put("pricing.practitioner.seed",                  new SeedEntry("131400", "price",   "能量师种子价(分)"));
+        seeds.put("pricing.practitioner.standard",              new SeedEntry("198600", "price",   "能量师标准价(分)"));
+        seeds.put("pricing.practitioner.seed_limit",            new SeedEntry("300",    "int",     "种子价名额上限"));
+        seeds.put("pricing.annual",                             new SeedEntry("13100",  "price",   "C端年费(分)"));
+        seeds.put("commission.practitioner.l1",                 new SeedEntry("50000",  "price",   "B端一级佣金—能量师推荐(分)"));
+        seeds.put("commission.practitioner.l1_cend",            new SeedEntry("20000",  "price",   "B端一级佣金—C端推荐(分)"));
+        seeds.put("commission.practitioner.l2",                 new SeedEntry("10000",  "price",   "B端二级佣金(分)"));
+        seeds.put("commission.annual.direct_rate",              new SeedEntry("4000",   "percent", "C端直接佣金比例(万分比)"));
+        seeds.put("commission.annual.upstream_rate",            new SeedEntry("500",    "percent", "C端上级佣金比例(万分比)"));
+        seeds.put("commerce.category.practitioner_plan.commission_rate", new SeedEntry("3000", "percent", "人工方案平台佣金率(万分比)"));
+        seeds.put("commission.practitioner_plan.referral_rate",          new SeedEntry("2000", "percent", "人工方案分销佣金占比(万分比)"));
+        seeds.put("commission.practitioner_plan.upstream_rate",          new SeedEntry("500",  "percent", "人工方案上级佣金占比(万分比)"));
+        seeds.put("plan_request.enabled",                       new SeedEntry("true",   "bool",    "人工方案功能开关"));
+        seeds.put("plan_request.max_negotiation_rounds",        new SeedEntry("5",      "int",     "最大协商轮次"));
+        seeds.put("plan_request.auto_cancel_hours",             new SeedEntry("72",     "int",     "协商超时自动取消(小时)"));
+        seeds.put("plan_request.min_price",                     new SeedEntry("5000",   "price",   "方案最低价(分)"));
+        seeds.put("plan_request.max_price",                     new SeedEntry("999900", "price",   "方案最高价(分)"));
+        seeds.put("quota_chart",                                new SeedEntry("3",      "int",     "每日命盘生成次数(非VIP)"));
+        seeds.put("quota_chat",                                 new SeedEntry("3",      "int",     "每日AI解读次数(非VIP)"));
+        seeds.put("quota_share",                                new SeedEntry("1",      "int",     "每日分享次数(非VIP)"));
+        return seeds;
+    }
+
+    private static class SeedEntry {
+        final String value;
+        final String type;
+        final String desc;
+        SeedEntry(String value, String type, String desc) {
+            this.value = value;
+            this.type = type;
+            this.desc = desc;
+        }
+    }
+
     /**
      * Get config value by key, returning default if not found.
+     * Reads from memory cache (O(1)), falls back to DB.
      */
     public String getValue(String key, String defaultValue) {
+        String val = cache.get(key);
+        if (val != null) return val;
+        // Cache miss → try DB
         return configRepository.findByConfigKey(key)
-                .map(SystemConfig::getConfigValue)
+                .map(cfg -> {
+                    cache.put(key, cfg.getConfigValue());
+                    return cfg.getConfigValue();
+                })
                 .orElse(defaultValue);
     }
 
+    /**
+     * Same as getValue - alias for API clarity (US-8.1 §4 naming).
+     */
+    public String getString(String key, String defaultValue) {
+        return getValue(key, defaultValue);
+    }
+
     /**
      * Get config value as integer.
      */
@@ -41,7 +176,7 @@ public class ConfigService {
     }
 
     /**
-     * Set config value (upsert).
+     * Set config value (upsert) and update cache immediately (US-8.1 §6).
      */
     public void setValue(String key, String value) {
         SystemConfig config = configRepository.findByConfigKey(key).orElse(new SystemConfig());
@@ -49,6 +184,14 @@ public class ConfigService {
         config.setConfigValue(value);
         config.setUpdatedAt(LocalDateTime.now());
         configRepository.save(config);
+        cache.put(key, value);
+    }
+
+    /**
+     * Batch update multiple configs in one transaction (US-8.1 §7).
+     */
+    public void batchUpdate(Map<String, String> configs) {
+        configs.forEach(this::setValue);
     }
 
     /**
@@ -62,6 +205,13 @@ public class ConfigService {
         return result;
     }
 
+    /**
+     * Get all config as a map from cache (faster).
+     */
+    public Map<String, String> getAllCached() {
+        return new HashMap<>(cache);
+    }
+
     /**
      * Get daily quota for a specific action type.
      */

+ 53 - 23
num-server/src/test/java/com/etotem/num/service/ConfigServiceTest.java

@@ -2,10 +2,12 @@ package com.etotem.num.service;
 
 import com.etotem.num.entity.SystemConfig;
 import com.etotem.num.repository.SystemConfigRepository;
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.boot.test.mock.mockito.MockBean;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
 
 import java.util.Arrays;
 import java.util.List;
@@ -16,24 +18,52 @@ import static org.junit.jupiter.api.Assertions.*;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.*;
 
-@SpringBootTest
+@ExtendWith(MockitoExtension.class)
 class ConfigServiceTest {
 
-    @Autowired
+    @Mock
+    private SystemConfigRepository configRepository;
+
+    @InjectMocks
     private ConfigService configService;
 
-    @MockBean
-    private SystemConfigRepository configRepository;
+    @BeforeEach
+    void setUp() {
+        lenient().when(configRepository.save(any(SystemConfig.class))).thenAnswer(i -> i.getArgument(0));
+    }
 
-    @Test
-    void testGetValue_existingKey() {
-        SystemConfig config = new SystemConfig();
-        config.setConfigKey("quota_chart");
-        config.setConfigValue("5");
-        when(configRepository.findByConfigKey("quota_chart")).thenReturn(Optional.of(config));
+    /**
+     * Preload cache with seed data (simulates @PostConstruct behavior).
+     */
+    private void loadSeedCache() {
+        String[] keys = {
+            "pricing.practitioner.seed", "pricing.practitioner.standard",
+            "pricing.practitioner.seed_limit", "pricing.annual",
+            "commission.practitioner.l1", "commission.practitioner.l1_cend",
+            "commission.practitioner.l2", "commission.annual.direct_rate",
+            "commission.annual.upstream_rate", "quota_chart", "quota_chat", "quota_share"
+        };
+        String[] values = {
+            "131400", "198600", "300", "13100",
+            "50000", "20000", "10000", "4000",
+            "500", "3", "3", "1"
+        };
+        List<SystemConfig> seedList = new java.util.ArrayList<>();
+        for (int i = 0; i < keys.length; i++) {
+            SystemConfig cfg = new SystemConfig();
+            cfg.setConfigKey(keys[i]);
+            cfg.setConfigValue(values[i]);
+            seedList.add(cfg);
+        }
+        when(configRepository.findAll()).thenReturn(seedList);
+        configService.loadAllToCache();
+    }
 
-        String value = configService.getValue("quota_chart", "3");
-        assertEquals("5", value);
+    @Test
+    void testGetValue_existingKey_cacheHit() {
+        loadSeedCache();
+        // Cache has quota_chart="3" (seed)
+        assertEquals("3", configService.getValue("quota_chart", "default"));
     }
 
     @Test
@@ -51,6 +81,7 @@ class ConfigServiceTest {
         configService.setValue("new_key", "new_value");
 
         verify(configRepository).save(any(SystemConfig.class));
+        assertEquals("new_value", configService.getValue("new_key", null));
     }
 
     @Test
@@ -62,7 +93,8 @@ class ConfigServiceTest {
 
         configService.setValue("existing_key", "new_value");
 
-        verify(configRepository).save(any(SystemConfig.class));
+        verify(configRepository).save(argThat(c ->
+                "existing_key".equals(c.getConfigKey()) && "new_value".equals(c.getConfigValue())));
         assertEquals("new_value", existing.getConfigValue());
     }
 
@@ -106,10 +138,9 @@ class ConfigServiceTest {
 
     @Test
     void testGetQuota_chart() {
-        SystemConfig config = new SystemConfig();
-        config.setConfigKey("quota_chart");
-        config.setConfigValue("10");
-        when(configRepository.findByConfigKey("quota_chart")).thenReturn(Optional.of(config));
+        loadSeedCache();
+        // Override by writing to cache
+        configService.setValue("quota_chart", "10");
 
         int quota = configService.getQuota("chart");
         assertEquals(10, quota);
@@ -117,9 +148,8 @@ class ConfigServiceTest {
 
     @Test
     void testGetQuota_chat_default() {
-        when(configRepository.findByConfigKey("quota_chat")).thenReturn(Optional.empty());
-
+        // Empty cache, no DB stub → returns default
         int quota = configService.getQuota("chat");
         assertEquals(3, quota);
     }
-}
+}