Browse Source

feat: add payment flow and config management UI

Pchen. 2 weeks ago
parent
commit
fbaa42ead0

+ 9 - 0
src/api/order.js

@@ -2,6 +2,7 @@ import request from '../utils/request'
 
 
 const api = {
 const api = {
   Create: '/Order/CreateOrder',
   Create: '/Order/CreateOrder',
+  Pay: '/Order/Pay',
   Cancel: '/Order/CancelOrder',
   Cancel: '/Order/CancelOrder',
   Refund: '/Order/RefundOrder',
   Refund: '/Order/RefundOrder',
   Detail: '/Order/Detail',
   Detail: '/Order/Detail',
@@ -19,6 +20,14 @@ export function createOrder(parameter) {
   })
   })
 }
 }
 
 
+export function payOrder(parameter) {
+  return request({
+    url: api.Pay,
+    method: 'post',
+    data: parameter
+  })
+}
+
 export function orderDeatil(parameter) {
 export function orderDeatil(parameter) {
   return request({
   return request({
     url: api.Detail,
     url: api.Detail,

+ 19 - 0
src/api/runtimeConfig.js

@@ -0,0 +1,19 @@
+import request from '../utils/request'
+
+const api = {
+  List: '/Admin/System/RuntimeConfig',
+  Save: '/Admin/System/RuntimeConfig',
+  Select: '/Admin/System/RuntimeConfig/Select'
+}
+
+export function getRuntimeConfigs() {
+  return request({ url: api.List, method: 'get' })
+}
+
+export function saveRuntimeConfig(config) {
+  return request({ url: api.Save, method: 'put', data: { config } })
+}
+
+export function selectRuntimeConfig(key, name) {
+  return request({ url: api.Select, method: 'put', data: { key, name } })
+}

+ 37 - 7
src/components/store/PayMethodPicker.vue

@@ -1,10 +1,10 @@
 <template>
 <template>
-  <div class="pay-methods">
+  <div class="pay-methods" :class="{ 'pay-methods--grid': layout === 'grid' }">
     <div
     <div
       v-for="item in methods"
       v-for="item in methods"
       :key="item.value"
       :key="item.value"
       class="pay-method"
       class="pay-method"
-      :class="{ active: modelValue === item.value }"
+      :class="[{ active: modelValue === item.value }, `pay-method--${item.value}`]"
       @click="$emit('update:modelValue', item.value)"
       @click="$emit('update:modelValue', item.value)"
     >
     >
       <component :is="item.icon" :size="28" />
       <component :is="item.icon" :size="28" />
@@ -15,22 +15,34 @@
 </template>
 </template>
 
 
 <script setup>
 <script setup>
+import { computed } from 'vue'
 import {
 import {
   IconAlipayCircle,
   IconAlipayCircle,
   IconWechatpay,
   IconWechatpay,
   IconCheckCircleFill
   IconCheckCircleFill
 } from '@arco-design/web-vue/es/icon'
 } from '@arco-design/web-vue/es/icon'
 
 
-defineProps({
-  modelValue: { type: String, default: '' }
-})
-
 defineEmits(['update:modelValue'])
 defineEmits(['update:modelValue'])
 
 
-const methods = [
+const defaultMethods = [
   { value: 'alipay', label: '支付宝', icon: IconAlipayCircle },
   { value: 'alipay', label: '支付宝', icon: IconAlipayCircle },
   { value: 'wxpay', label: '微信支付', icon: IconWechatpay }
   { value: 'wxpay', label: '微信支付', icon: IconWechatpay }
 ]
 ]
+
+const props = defineProps({
+  modelValue: { type: String, default: '' },
+  methods: { type: Array, default: () => [] },
+  layout: { type: String, default: 'stack' }
+})
+
+const methods = computed(() => {
+  const source = props.methods.length ? props.methods : defaultMethods
+  return source.map(item => ({
+    value: item.value || item.type,
+    label: item.label || item.name || item.type,
+    icon: (item.value || item.type) === 'wxpay' ? IconWechatpay : IconAlipayCircle
+  })).filter(item => item.value)
+})
 </script>
 </script>
 
 
 <style scoped lang="less">
 <style scoped lang="less">
@@ -42,6 +54,15 @@ const methods = [
   gap: 12px;
   gap: 12px;
 }
 }
 
 
+.pay-methods--grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+
+  @media (max-width: 480px) {
+    grid-template-columns: 1fr;
+  }
+}
+
 .pay-method {
 .pay-method {
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
@@ -53,6 +74,7 @@ const methods = [
   transition: border-color 0.2s, background 0.2s, box-shadow 0.2s;
   transition: border-color 0.2s, background 0.2s, box-shadow 0.2s;
   position: relative;
   position: relative;
   color: @store-primary;
   color: @store-primary;
+  min-height: 58px;
 
 
   &:hover {
   &:hover {
     border-color: fade(@store-accent, 50%);
     border-color: fade(@store-accent, 50%);
@@ -65,6 +87,14 @@ const methods = [
     box-shadow: 0 0 0 1px fade(@store-accent, 20%);
     box-shadow: 0 0 0 1px fade(@store-accent, 20%);
   }
   }
 
 
+  &--alipay {
+    :deep(svg) { color: #1677ff; }
+  }
+
+  &--wxpay {
+    :deep(svg) { color: #07c160; }
+  }
+
   .check {
   .check {
     margin-left: auto;
     margin-left: auto;
     color: @store-accent;
     color: @store-accent;

+ 616 - 0
src/pages/admin/systemConfig/index.vue

@@ -0,0 +1,616 @@
+<template>
+  <div class="admin-config-page">
+    <Breadcrumb />
+
+    <a-card title="基础配置" class="config-card">
+      <template #extra>
+        <a-button type="primary" @click="openCreate">
+          <template #icon><icon-plus /></template>
+          新增方案
+        </a-button>
+      </template>
+
+      <a-alert type="info" class="hint">
+        配置保存或切换当前方案后会清理缓存并即时生效。支付方式、邮件尝试顺序等均在表单内维护。
+      </a-alert>
+
+      <a-tabs v-model:active-key="activeKey" class="config-tabs">
+        <a-tab-pane v-for="item in configTypes" :key="item.key" :title="item.label" />
+      </a-tabs>
+
+      <a-table :data="currentList" :loading="loading" :pagination="false" row-key="config_name">
+        <template #columns>
+          <a-table-column title="方案名称" data-index="config_name" :width="180" />
+          <a-table-column title="配置摘要">
+            <template #cell="{ record }">
+              <span class="summary">{{ formatSummary(record) }}</span>
+            </template>
+          </a-table-column>
+          <a-table-column title="状态" :width="160">
+            <template #cell="{ record }">
+              <a-space>
+                <a-tag :color="record.enabled ? 'green' : 'gray'">
+                  {{ record.enabled ? '启用' : '停用' }}
+                </a-tag>
+                <a-tag v-if="record.is_default" color="arcoblue">当前</a-tag>
+              </a-space>
+            </template>
+          </a-table-column>
+          <a-table-column title="操作" :width="210" fixed="right">
+            <template #cell="{ record }">
+              <a-space wrap>
+                <a-button size="mini" @click="openEdit(record)">编辑</a-button>
+                <a-button
+                  size="mini"
+                  type="primary"
+                  :disabled="record.is_default || !record.enabled"
+                  @click="setCurrent(record)"
+                >
+                  设为当前
+                </a-button>
+              </a-space>
+            </template>
+          </a-table-column>
+        </template>
+      </a-table>
+    </a-card>
+
+    <a-modal
+      v-model:visible="visible"
+      :title="editing ? '编辑配置方案' : '新增配置方案'"
+      width="920px"
+      :modal-style="{ maxWidth: 'calc(100vw - 32px)' }"
+      @before-ok="save"
+    >
+      <a-form :model="form" layout="vertical">
+        <a-grid :cols="{ xs: 1, sm: 2 }" :col-gap="16">
+          <a-grid-item>
+            <a-form-item label="配置类型" required>
+              <a-select v-model="form.key" :disabled="!!editing" @change="resetConfigValue">
+                <a-option v-for="item in configTypes" :key="item.key" :value="item.key">
+                  {{ item.label }}
+                </a-option>
+              </a-select>
+            </a-form-item>
+          </a-grid-item>
+          <a-grid-item>
+            <a-form-item label="方案名称" required>
+              <a-input v-model="form.name" placeholder="例如:默认配置、备用通道" :disabled="!!editing" />
+            </a-form-item>
+          </a-grid-item>
+        </a-grid>
+
+        <a-space class="switch-row" wrap>
+          <span class="switch-item"><a-switch v-model="form.enabled" /> 启用方案</span>
+          <span class="switch-item"><a-switch v-model="form.isDefault" /> 保存后设为当前方案</span>
+        </a-space>
+
+        <div v-if="form.key === 'pay'" class="form-section">
+          <div class="section-title">支付平台</div>
+          <a-grid :cols="{ xs: 1, sm: 2 }" :col-gap="16">
+            <a-grid-item>
+              <a-form-item label="易支付网关地址" required>
+                <a-input v-model="form.pay.url" placeholder="https://pay.example.com" />
+              </a-form-item>
+            </a-grid-item>
+            <a-grid-item>
+              <a-form-item label="商户 PID" required>
+                <a-input v-model="form.pay.pid" placeholder="请输入易支付商户 ID" />
+              </a-form-item>
+            </a-grid-item>
+            <a-grid-item>
+              <a-form-item label="商户密钥 Key" required>
+                <a-input-password v-model="form.pay.key" placeholder="请输入易支付通信密钥" />
+              </a-form-item>
+            </a-grid-item>
+            <a-grid-item>
+              <a-form-item label="网页支付返回地址" required>
+                <a-input v-model="form.pay.return_url" placeholder="https://站点/store/orders/" />
+              </a-form-item>
+            </a-grid-item>
+            <a-grid-item>
+              <a-form-item label="App 支付返回地址">
+                <a-input v-model="form.pay.uni_return_url" placeholder="runforge://store/orders/" />
+              </a-form-item>
+            </a-grid-item>
+          </a-grid>
+
+          <div class="subhead">
+            <span>可用付款方式</span>
+            <a-button size="small" @click="addPayMethod">
+              <template #icon><icon-plus /></template>
+              添加
+            </a-button>
+          </div>
+          <div class="editable-list">
+            <div v-for="(method, index) in form.pay.methods" :key="index" class="editable-row method-row">
+              <a-switch v-model="method.enabled" />
+              <a-select v-model="method.type" placeholder="付款类型" allow-create>
+                <a-option value="alipay">alipay</a-option>
+                <a-option value="wxpay">wxpay</a-option>
+                <a-option value="qqpay">qqpay</a-option>
+                <a-option value="bank">bank</a-option>
+              </a-select>
+              <a-input v-model="method.name" placeholder="前台显示名称" />
+              <a-button size="mini" status="danger" @click="removePayMethod(index)">删除</a-button>
+            </div>
+          </div>
+        </div>
+
+        <div v-if="form.key === 'email'" class="form-section">
+          <div class="subhead">
+            <span>邮件发送账号</span>
+            <a-button size="small" @click="addEmailAccount">
+              <template #icon><icon-plus /></template>
+              添加账号
+            </a-button>
+          </div>
+          <div class="editable-list">
+            <div v-for="(account, index) in form.emails" :key="index" class="email-card">
+              <div class="email-card-head">
+                <a-space wrap>
+                  <a-switch v-model="account.enabled" />
+                  <span>账号 {{ index + 1 }}</span>
+                </a-space>
+                <a-button size="mini" status="danger" @click="removeEmailAccount(index)">删除</a-button>
+              </div>
+              <a-grid :cols="{ xs: 1, sm: 2 }" :col-gap="16">
+                <a-grid-item>
+                  <a-form-item label="SMTP Host" required>
+                    <a-input v-model="account.host" placeholder="smtp.example.com" />
+                  </a-form-item>
+                </a-grid-item>
+                <a-grid-item>
+                  <a-form-item label="SMTP 端口" required>
+                    <a-input-number v-model="account.port" :min="1" :max="65535" mode="button" />
+                  </a-form-item>
+                </a-grid-item>
+                <a-grid-item>
+                  <a-form-item label="邮箱账号" required>
+                    <a-input v-model="account.user" placeholder="service@example.com" />
+                  </a-form-item>
+                </a-grid-item>
+                <a-grid-item>
+                  <a-form-item label="邮箱密码/授权码" required>
+                    <a-input-password v-model="account.password" placeholder="请输入 SMTP 授权码" />
+                  </a-form-item>
+                </a-grid-item>
+                <a-grid-item>
+                  <a-form-item label="发件人 From" required>
+                    <a-input v-model="account.from" placeholder="RunForge <service@example.com>" />
+                  </a-form-item>
+                </a-grid-item>
+                <a-grid-item>
+                  <a-form-item label="尝试顺序">
+                    <a-input-number v-model="account.priority" :min="0" mode="button" />
+                  </a-form-item>
+                </a-grid-item>
+              </a-grid>
+            </div>
+          </div>
+        </div>
+
+        <div v-if="form.key === 'unilogin'" class="form-section">
+          <div class="section-title">聚合登录</div>
+          <a-grid :cols="{ xs: 1, sm: 2 }" :col-gap="16">
+            <a-grid-item>
+              <a-form-item label="接口地址" required>
+                <a-input v-model="form.unilogin.url" placeholder="https://login.example.com" />
+              </a-form-item>
+            </a-grid-item>
+            <a-grid-item>
+              <a-form-item label="AppID" required>
+                <a-input v-model="form.unilogin.appid" placeholder="请输入 AppID" />
+              </a-form-item>
+            </a-grid-item>
+            <a-grid-item>
+              <a-form-item label="AppKey" required>
+                <a-input-password v-model="form.unilogin.appkey" placeholder="请输入 AppKey" />
+              </a-form-item>
+            </a-grid-item>
+            <a-grid-item>
+              <a-form-item label="启用登录类型">
+                <a-checkbox-group v-model="form.unilogin.enabledTypes">
+                  <a-checkbox value="qq">QQ</a-checkbox>
+                  <a-checkbox value="wx">微信</a-checkbox>
+                </a-checkbox-group>
+              </a-form-item>
+            </a-grid-item>
+          </a-grid>
+        </div>
+
+        <div v-if="form.key === 'proxyForwardServer'" class="form-section">
+          <div class="section-title">代理转发服务</div>
+          <a-grid :cols="{ xs: 1, sm: 2 }" :col-gap="16">
+            <a-grid-item>
+              <a-form-item label="代理服务开关">
+                <a-switch v-model="form.proxy.enabled" />
+              </a-form-item>
+            </a-grid-item>
+            <a-grid-item>
+              <a-form-item label="默认超时(毫秒)">
+                <a-input-number v-model="form.proxy.timeout" :min="1000" :step="1000" mode="button" />
+              </a-form-item>
+            </a-grid-item>
+            <a-grid-item :span="2">
+              <a-form-item label="代理服务地址">
+                <a-input v-model="form.proxy.url" placeholder="http://127.0.0.1:9000" />
+              </a-form-item>
+            </a-grid-item>
+          </a-grid>
+        </div>
+      </a-form>
+    </a-modal>
+  </div>
+</template>
+
+<script setup>
+import { computed, onMounted, reactive, ref } from 'vue'
+import { Message } from '@arco-design/web-vue'
+import { getRuntimeConfigs, saveRuntimeConfig, selectRuntimeConfig } from '@/api/runtimeConfig'
+
+const configTypes = [
+  { key: 'pay', label: '支付配置' },
+  { key: 'email', label: '邮件配置' },
+  { key: 'unilogin', label: '聚合登录' },
+  { key: 'proxyForwardServer', label: '代理配置' }
+]
+
+const activeKey = ref('pay')
+const allConfigs = ref([])
+const loading = ref(false)
+const visible = ref(false)
+const editing = ref(null)
+const originalValue = ref({})
+
+const form = reactive({
+  key: 'pay',
+  name: '',
+  enabled: true,
+  isDefault: false,
+  pay: emptyPayConfig(),
+  emails: [],
+  unilogin: emptyUniLoginConfig(),
+  proxy: emptyProxyConfig()
+})
+
+const currentList = computed(() => allConfigs.value.filter(item => item.config_key === activeKey.value))
+
+function emptyPayConfig() {
+  return {
+    url: '',
+    pid: '',
+    key: '',
+    return_url: '',
+    uni_return_url: '',
+    methods: [
+      { type: 'alipay', name: '支付宝', enabled: true },
+      { type: 'wxpay', name: '微信支付', enabled: true }
+    ]
+  }
+}
+
+function emptyEmailConfig() {
+  return {
+    host: '',
+    port: 465,
+    user: '',
+    password: '',
+    from: '',
+    priority: 0,
+    enabled: true
+  }
+}
+
+function emptyUniLoginConfig() {
+  return {
+    url: '',
+    appid: '',
+    appkey: '',
+    enabledTypes: ['qq', 'wx']
+  }
+}
+
+function emptyProxyConfig() {
+  return {
+    enabled: false,
+    url: '',
+    timeout: 120000
+  }
+}
+
+function resetConfigValue() {
+  form.pay = emptyPayConfig()
+  form.emails = [emptyEmailConfig()]
+  form.unilogin = emptyUniLoginConfig()
+  form.proxy = emptyProxyConfig()
+  originalValue.value = {}
+}
+
+async function load() {
+  loading.value = true
+  try {
+    const res = await getRuntimeConfigs()
+    if (res?.code === 0) {
+      allConfigs.value = res.data || []
+    } else {
+      Message.error(res?.msg || '获取配置失败')
+    }
+  } finally {
+    loading.value = false
+  }
+}
+
+function openCreate() {
+  editing.value = null
+  Object.assign(form, {
+    key: activeKey.value,
+    name: '',
+    enabled: true,
+    isDefault: false
+  })
+  resetConfigValue()
+  form.key = activeKey.value
+  visible.value = true
+}
+
+function openEdit(record) {
+  editing.value = record
+  originalValue.value = cloneValue(record.config_value)
+  Object.assign(form, {
+    key: record.config_key,
+    name: record.config_name,
+    enabled: !!record.enabled,
+    isDefault: !!record.is_default
+  })
+  fillFormValue(record.config_key, record.config_value)
+  visible.value = true
+}
+
+function fillFormValue(key, value) {
+  const data = cloneValue(value)
+  if (key === 'pay') {
+    form.pay = {
+      ...emptyPayConfig(),
+      ...data,
+      methods: Array.isArray(data.methods) && data.methods.length
+        ? data.methods.map(item => ({ enabled: item.enabled !== false, ...item }))
+        : emptyPayConfig().methods
+    }
+  } else if (key === 'email') {
+    form.emails = Array.isArray(data) && data.length
+      ? data.map((item, index) => ({ ...emptyEmailConfig(), priority: index, enabled: item.enabled !== false, ...item }))
+      : [emptyEmailConfig()]
+  } else if (key === 'unilogin') {
+    form.unilogin = { ...emptyUniLoginConfig(), ...data }
+  } else if (key === 'proxyForwardServer') {
+    form.proxy = { ...emptyProxyConfig(), ...data }
+  }
+}
+
+function cloneValue(value) {
+  if (value == null) return {}
+  return JSON.parse(JSON.stringify(value))
+}
+
+function addPayMethod() {
+  form.pay.methods.push({ type: '', name: '', enabled: true })
+}
+
+function removePayMethod(index) {
+  form.pay.methods.splice(index, 1)
+}
+
+function addEmailAccount() {
+  form.emails.push({ ...emptyEmailConfig(), priority: form.emails.length })
+}
+
+function removeEmailAccount(index) {
+  form.emails.splice(index, 1)
+}
+
+function buildValue() {
+  if (form.key === 'pay') {
+    return {
+      ...(editing.value && typeof originalValue.value === 'object' ? originalValue.value : {}),
+      url: String(form.pay.url || '').trim(),
+      pid: String(form.pay.pid || '').trim(),
+      key: String(form.pay.key || '').trim(),
+      return_url: String(form.pay.return_url || '').trim(),
+      uni_return_url: String(form.pay.uni_return_url || '').trim(),
+      methods: form.pay.methods
+        .filter(item => item.enabled !== false && String(item.type || '').trim())
+        .map(item => ({
+          type: String(item.type).trim(),
+          name: String(item.name || item.type).trim()
+        }))
+    }
+  }
+
+  if (form.key === 'email') {
+    return form.emails
+      .filter(item => item.enabled !== false && item.host && item.user)
+      .map((item, index) => ({
+        host: String(item.host || '').trim(),
+        port: Number(item.port) || 465,
+        user: String(item.user || '').trim(),
+        password: String(item.password || '').trim(),
+        from: String(item.from || item.user || '').trim(),
+        priority: Number(item.priority ?? index)
+      }))
+      .sort((a, b) => a.priority - b.priority)
+  }
+
+  if (form.key === 'unilogin') {
+    return {
+      ...(editing.value && typeof originalValue.value === 'object' ? originalValue.value : {}),
+      url: String(form.unilogin.url || '').trim(),
+      appid: String(form.unilogin.appid || '').trim(),
+      appkey: String(form.unilogin.appkey || '').trim(),
+      enabledTypes: form.unilogin.enabledTypes || []
+    }
+  }
+
+  return {
+    ...(editing.value && typeof originalValue.value === 'object' ? originalValue.value : {}),
+    enabled: !!form.proxy.enabled,
+    url: String(form.proxy.url || '').trim(),
+    timeout: Number(form.proxy.timeout) || 120000
+  }
+}
+
+function validateValue(value) {
+  if (!form.name.trim()) return '请输入方案名称'
+  if (form.key === 'pay') {
+    if (!value.url || !value.pid || !value.key || !value.return_url) return '请完整填写支付平台配置'
+    if (!value.methods.length) return '请至少启用一种付款方式'
+  }
+  if (form.key === 'email' && !value.length) return '请至少配置一个可用邮箱账号'
+  if (form.key === 'unilogin' && (!value.url || !value.appid || !value.appkey)) return '请完整填写聚合登录配置'
+  if (form.key === 'proxyForwardServer' && value.enabled && !value.url) return '启用代理时必须填写代理服务地址'
+  return ''
+}
+
+async function save() {
+  const value = buildValue()
+  const error = validateValue(value)
+  if (error) {
+    Message.error(error)
+    return false
+  }
+
+  const res = await saveRuntimeConfig({
+    key: form.key,
+    name: form.name.trim(),
+    value,
+    enabled: form.enabled,
+    isDefault: form.isDefault
+  })
+  if (res?.code !== 0) {
+    Message.error(res?.msg || '保存失败')
+    return false
+  }
+  Message.success('保存成功,已即时生效')
+  await load()
+  return true
+}
+
+async function setCurrent(record) {
+  const res = await selectRuntimeConfig(record.config_key, record.config_name)
+  if (res?.code === 0) {
+    Message.success('切换成功')
+    await load()
+  } else {
+    Message.error(res?.msg || '切换失败')
+  }
+}
+
+function formatSummary(record) {
+  const value = record.config_value
+  if (record.config_key === 'pay') {
+    const count = Array.isArray(value?.methods) ? value.methods.length : 0
+    return `${value?.url || '未填写网关'},${count} 种付款方式`
+  }
+  if (record.config_key === 'email') {
+    return `${Array.isArray(value) ? value.length : 0} 个 SMTP 账号`
+  }
+  if (record.config_key === 'unilogin') {
+    return `${value?.url || '未填写接口'},${(value?.enabledTypes || ['qq', 'wx']).join(' / ')}`
+  }
+  if (record.config_key === 'proxyForwardServer') {
+    return `${value?.enabled === false ? '停用' : '启用'},${value?.url || '未填写地址'}`
+  }
+  return '-'
+}
+
+onMounted(load)
+</script>
+
+<style scoped>
+.admin-config-page {
+  padding-bottom: 24px;
+}
+
+.config-card {
+  border-radius: 8px;
+}
+
+.hint {
+  margin-bottom: 16px;
+}
+
+.config-tabs {
+  margin-bottom: 12px;
+}
+
+.summary {
+  color: var(--color-text-2);
+  word-break: break-all;
+}
+
+.switch-row {
+  margin-bottom: 18px;
+}
+
+.switch-item {
+  color: var(--color-text-2);
+}
+
+.form-section {
+  padding: 16px;
+  border: 1px solid var(--color-border-2);
+  border-radius: 8px;
+  background: var(--color-fill-1);
+}
+
+.section-title,
+.subhead {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 14px;
+  font-size: 15px;
+  font-weight: 600;
+  color: var(--color-text-1);
+}
+
+.editable-list {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.editable-row {
+  display: grid;
+  grid-template-columns: auto minmax(150px, 1fr) minmax(160px, 1fr) auto;
+  gap: 10px;
+  align-items: center;
+}
+
+.email-card {
+  padding: 14px;
+  border: 1px solid var(--color-border-2);
+  border-radius: 8px;
+  background: var(--color-bg-2);
+}
+
+.email-card-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 12px;
+  font-weight: 600;
+}
+
+@media (max-width: 640px) {
+  .editable-row {
+    grid-template-columns: 1fr;
+  }
+
+  .email-card-head {
+    align-items: flex-start;
+    gap: 8px;
+    flex-direction: column;
+  }
+}
+</style>

+ 10 - 2
src/pages/store/goodsDetail/index.vue

@@ -140,6 +140,7 @@ import PayMethodPicker from '@/components/store/PayMethodPicker.vue'
 import GoodsEmoji from '@/components/store/GoodsEmoji.vue'
 import GoodsEmoji from '@/components/store/GoodsEmoji.vue'
 import StoreDetailSkeleton from '@/components/skeleton/StoreDetailSkeleton.vue'
 import StoreDetailSkeleton from '@/components/skeleton/StoreDetailSkeleton.vue'
 import { stockLabel, parseFeatures, decodeGoodsContent } from '@/utils/storeFormat'
 import { stockLabel, parseFeatures, decodeGoodsContent } from '@/utils/storeFormat'
+import { submitPayment } from '@/utils/payment'
 
 
 const route = useRoute()
 const route = useRoute()
 const router = useRouter()
 const router = useRouter()
@@ -179,6 +180,7 @@ const applyCoupon = async () => {
     couponError.value = ''
     couponError.value = ''
     return
     return
   }
   }
+  const paymentWindow = window.open('', '_blank')
   try {
   try {
     couponLoading.value = true
     couponLoading.value = true
     couponError.value = ''
     couponError.value = ''
@@ -217,9 +219,15 @@ const submitOrder = async () => {
       return
       return
     }
     }
     checkoutVisible.value = false
     checkoutVisible.value = false
-    Message.success('订单已创建,请完成支付')
-    router.push(`/store/orderDetail/${res.id}`)
+    // 订单详情留在当前标签页,支付网关在新标签页完成,便于用户返回查看支付状态。
+    await router.push(`/store/orderDetail/${res.id}`)
+    if (submitPayment(res.pay, { targetWindow: paymentWindow })) return
+    if (paymentWindow && !paymentWindow.closed) paymentWindow.close()
+
+    // 兼容旧版本后端:支付参数暂不可用时仍可进入订单详情继续付款。
+    Message.success('订单已创建,请在订单详情完成支付')
   } catch (error) {
   } catch (error) {
+    if (paymentWindow && !paymentWindow.closed) paymentWindow.close()
     Notification.error({
     Notification.error({
       title: '创建订单失败',
       title: '创建订单失败',
       content: error.message || '请稍后再试'
       content: error.message || '请稍后再试'

+ 84 - 34
src/pages/store/orders/orderDetail/index.vue

@@ -8,7 +8,7 @@
         <template #content>
         <template #content>
           <div class="detail-stack">
           <div class="detail-stack">
         <!-- 待支付横幅 -->
         <!-- 待支付横幅 -->
-        <div v-if="data?.state === 0 && hasPay" class="pay-banner detail-stack__section">
+        <div v-if="data?.state === 0" class="pay-banner detail-stack__section">
           <div class="pay-banner__info">
           <div class="pay-banner__info">
             <icon-clock-circle class="pay-banner__icon" />
             <icon-clock-circle class="pay-banner__icon" />
             <div>
             <div>
@@ -17,8 +17,8 @@
             </div>
             </div>
           </div>
           </div>
           <div class="pay-banner__actions">
           <div class="pay-banner__actions">
-            <a-button type="primary" size="large" class="pay-banner__btn" @click="pay">
-              立即支付 ¥{{ data?.price }}
+            <a-button type="primary" size="large" class="pay-banner__btn" :loading="payLoading" @click="pay">
+              继续支付 ¥{{ data?.price }}
             </a-button>
             </a-button>
             <a-button
             <a-button
               size="large"
               size="large"
@@ -31,6 +31,27 @@
           </div>
           </div>
         </div>
         </div>
 
 
+        <a-card
+          v-if="data?.state === 0 && data?.canChangePayType !== false"
+          :bordered="false"
+          class="payment-method-card detail-stack__section"
+          title="选择支付方式"
+        >
+          <!-- <p class="payment-method-card__hint">可以随时切换,确认后将直接跳转至对应支付页面。</p> -->
+          <PayMethodPicker v-model="selectedPayType" :methods="availablePaymentMethods" layout="grid" />
+          <a-button
+            type="primary"
+            long
+            size="large"
+            class="payment-method-card__submit"
+            :disabled="!selectedPayType"
+            :loading="payLoading"
+            @click="pay"
+          >
+            确认并支付
+          </a-button>
+        </a-card>
+
         <a-card :bordered="false" class="status-card detail-stack__section">
         <a-card :bordered="false" class="status-card detail-stack__section">
           <OrderProgressSteps
           <OrderProgressSteps
             class="steps"
             class="steps"
@@ -118,19 +139,20 @@
 
 
 <script setup>
 <script setup>
 import { ref, computed, onUnmounted, onMounted } from 'vue'
 import { ref, computed, onUnmounted, onMounted } from 'vue'
-import { orderDeatil, cancelOrder, refundOrder } from '@/api/order'
+import { orderDeatil, cancelOrder, refundOrder, payOrder } from '@/api/order'
 import { useRoute } from 'vue-router'
 import { useRoute } from 'vue-router'
 import { Notification, Message, Modal } from '@arco-design/web-vue'
 import { Notification, Message, Modal } from '@arco-design/web-vue'
 import OrderStateTag from '@/components/store/OrderStateTag.vue'
 import OrderStateTag from '@/components/store/OrderStateTag.vue'
 import OrderProgressSteps from '@/components/store/OrderProgressSteps.vue'
 import OrderProgressSteps from '@/components/store/OrderProgressSteps.vue'
 import StoreDetailSkeleton from '@/components/skeleton/StoreDetailSkeleton.vue'
 import StoreDetailSkeleton from '@/components/skeleton/StoreDetailSkeleton.vue'
 import OrderRefundPanel from '@/components/store/OrderRefundPanel.vue'
 import OrderRefundPanel from '@/components/store/OrderRefundPanel.vue'
+import PayMethodPicker from '@/components/store/PayMethodPicker.vue'
 import {
 import {
   formatStoreTimeFull,
   formatStoreTimeFull,
   getPayTypeLabel,
   getPayTypeLabel,
-  decodeGoodsContent,
-  hasPayData
+  decodeGoodsContent
 } from '@/utils/storeFormat'
 } from '@/utils/storeFormat'
+import { submitPayment } from '@/utils/payment'
 
 
 const route = useRoute()
 const route = useRoute()
 const { id } = route.params
 const { id } = route.params
@@ -139,12 +161,13 @@ const loading = ref(true)
 const cancelLoading = ref(false)
 const cancelLoading = ref(false)
 const refundLoading = ref(false)
 const refundLoading = ref(false)
 const data = ref({})
 const data = ref({})
-const payData = ref({})
 const content = ref('')
 const content = ref('')
 const paymentCountdownText = ref('')
 const paymentCountdownText = ref('')
 const isMobile = ref(false)
 const isMobile = ref(false)
+const selectedPayType = ref('')
+const payLoading = ref(false)
 
 
-const hasPay = computed(() => hasPayData(payData.value))
+const availablePaymentMethods = computed(() => data.value?.paymentMethods || [])
 
 
 const stepCurrent = ref(1)
 const stepCurrent = ref(1)
 const stepStatus = ref('process')
 const stepStatus = ref('process')
@@ -218,6 +241,7 @@ const updatePaymentCountdown = () => {
 }
 }
 
 
 const getOrderDetail = async () => {
 const getOrderDetail = async () => {
+  const paymentWindow = window.open('', '_blank')
   try {
   try {
     const res = await orderDeatil({ orderId: id })
     const res = await orderDeatil({ orderId: id })
     if (!res || res.code !== 0) {
     if (!res || res.code !== 0) {
@@ -228,7 +252,9 @@ const getOrderDetail = async () => {
     }
     }
     data.value = res.data ?? {}
     data.value = res.data ?? {}
     content.value = decodeGoodsContent(res.data?.content)
     content.value = decodeGoodsContent(res.data?.content)
-    payData.value = res.payData && hasPayData(res.payData) ? res.payData : {}
+    if (data.value?.state === 0 && !selectedPayType.value) {
+      selectedPayType.value = data.value.pay_type || availablePaymentMethods.value[0]?.type || 'alipay'
+    }
     updateStepState()
     updateStepState()
   } catch (error) {
   } catch (error) {
     Notification.error({
     Notification.error({
@@ -297,13 +323,30 @@ onUnmounted(() => {
   if (resizeHandler) window.removeEventListener('resize', resizeHandler)
   if (resizeHandler) window.removeEventListener('resize', resizeHandler)
 })
 })
 
 
-const pay = () => {
-  if (!hasPay.value) {
-    Message.warning('暂无支付信息,请刷新页面重试')
+const pay = async () => {
+  if (!selectedPayType.value) {
+    Message.warning('请选择支付方式')
     return
     return
   }
   }
-  Message.success('正在跳转支付页面,请在新页面完成支付')
-  openPaymentWindow(payData.value.payUrl, payData.value.payData)
+  try {
+    payLoading.value = true
+    const res = await payOrder({ orderId: id, pay_type: selectedPayType.value })
+    if (!res || res.code !== 0 || !submitPayment(res.pay, { targetWindow: paymentWindow })) {
+      if (paymentWindow && !paymentWindow.closed) paymentWindow.close()
+      Notification.error({
+        title: '发起支付失败',
+        content: res?.msg || '支付信息无效,请稍后重试'
+      })
+    }
+  } catch (error) {
+    if (paymentWindow && !paymentWindow.closed) paymentWindow.close()
+    Notification.error({
+      title: '发起支付失败',
+      content: error.message || '请稍后重试'
+    })
+  } finally {
+    payLoading.value = false
+  }
 }
 }
 
 
 const handleCancelOrder = () => {
 const handleCancelOrder = () => {
@@ -325,7 +368,6 @@ const handleCancelOrder = () => {
           return
           return
         }
         }
         Message.success(res.msg || '订单已取消')
         Message.success(res.msg || '订单已取消')
-        payData.value = {}
         stopPendingOrderTimers()
         stopPendingOrderTimers()
         await getOrderDetail()
         await getOrderDetail()
       } catch (error) {
       } catch (error) {
@@ -374,24 +416,6 @@ const handleRefundOrder = () => {
   })
   })
 }
 }
 
 
-function openPaymentWindow(payUrl, formData) {
-  const form = document.createElement('form')
-  form.method = 'POST'
-  form.action = payUrl
-  form.style.display = 'none'
-  for (const key in formData) {
-    if (Object.prototype.hasOwnProperty.call(formData, key)) {
-      const input = document.createElement('input')
-      input.type = 'hidden'
-      input.name = key
-      input.value = formData[key]
-      form.appendChild(input)
-    }
-  }
-  document.body.appendChild(form)
-  form.submit()
-  document.body.removeChild(form)
-}
 </script>
 </script>
 
 
 <style lang="less" scoped>
 <style lang="less" scoped>
@@ -460,12 +484,38 @@ function openPaymentWindow(payUrl, formData) {
 }
 }
 
 
 .status-card,
 .status-card,
-.info-card {
+.info-card,
+.payment-method-card {
   border-radius: @store-radius;
   border-radius: @store-radius;
   border: 1px solid @store-card-border;
   border: 1px solid @store-card-border;
   box-shadow: @store-shadow;
   box-shadow: @store-shadow;
 }
 }
 
 
+.payment-method-card {
+  overflow: hidden;
+  background:
+    radial-gradient(circle at 100% 0, fade(@store-accent, 12%) 0, transparent 33%),
+    @store-card-bg;
+
+  &__hint {
+    margin: 0 0 18px;
+    color: @store-text-muted;
+    font-size: 0.9rem;
+    line-height: 1.6;
+  }
+
+  &__submit {
+    margin-top: 20px;
+    height: 46px;
+    border-radius: 999px;
+    font-weight: 600;
+    letter-spacing: 0.04em;
+    background: linear-gradient(110deg, @store-primary, lighten(@store-primary, 13%)) !important;
+    border-color: transparent !important;
+    box-shadow: 0 8px 18px fade(@store-primary, 24%);
+  }
+}
+
 .status-badge-wrap {
 .status-badge-wrap {
   text-align: center;
   text-align: center;
 }
 }

+ 9 - 0
src/router/index.js

@@ -384,6 +384,15 @@ const routes = [
             ]
             ]
         },
         },
         children: [
         children: [
+            {
+                path: 'systemConfig',
+                name: 'admin.systemConfig',
+                component: () => import('../pages/admin/systemConfig/index.vue'),
+                meta: {
+                    title: '基础配置',
+                    permission: ['admin']
+                }
+            },
             {
             {
                 path: 'userList',
                 path: 'userList',
                 name: 'admin.userList',
                 name: 'admin.userList',

+ 35 - 0
src/utils/payment.js

@@ -0,0 +1,35 @@
+/**
+ * 支付网关要求以 POST 表单提交签名参数,不能用普通的路由跳转替代。
+ */
+function resolveTarget(target, targetWindow) {
+  if (!targetWindow) return target
+  if (!targetWindow.name) {
+    targetWindow.name = `runforge_pay_${Date.now()}_${Math.random().toString(36).slice(2)}`
+  }
+  return targetWindow.name
+}
+
+export function submitPayment(pay, { target = '_blank', targetWindow = null } = {}) {
+  const payUrl = pay?.payUrl
+  const payData = pay?.payData
+  if (!payUrl || !payData || typeof payData !== 'object') return false
+
+  const form = document.createElement('form')
+  form.method = 'POST'
+  form.action = payUrl
+  form.target = resolveTarget(target, targetWindow)
+  form.style.display = 'none'
+
+  Object.entries(payData).forEach(([key, value]) => {
+    const input = document.createElement('input')
+    input.type = 'hidden'
+    input.name = key
+    input.value = String(value)
+    form.appendChild(input)
+  })
+
+  document.body.appendChild(form)
+  form.submit()
+  document.body.removeChild(form)
+  return true
+}