Add landing page, referral system, usage quotas, search API management, and yearly pricing
- Separate workspace landing from login for better UX - Referral system rewards both parties with Pro days - Quota enforcement prevents abuse without breaking endpoints - 7-day free trial with auto-downgrade on expiry - Admin-managed search provider config (SearXNG, Bing) - 15% discount on annual subscriptions - MCP search server wrapping opencode search - Fix discovery module field name mismatch causing 422
This commit is contained in:
@@ -2,8 +2,8 @@
|
||||
<el-container class="layout-container">
|
||||
<el-aside :width="collapsed ? '64px' : '220px'" class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<span v-show="!collapsed" class="logo-text">TradeMate</span>
|
||||
<span v-show="collapsed" class="logo-text logo-sm">TM</span>
|
||||
<router-link v-show="!collapsed" to="/" class="logo-text">TradeMate</router-link>
|
||||
<router-link v-show="collapsed" to="/" class="logo-text logo-sm">TM</router-link>
|
||||
</div>
|
||||
<el-menu
|
||||
:default-active="route.path"
|
||||
@@ -42,6 +42,16 @@
|
||||
<el-icon><List /></el-icon>
|
||||
<span>发票管理</span>
|
||||
</el-menu-item>
|
||||
<el-sub-menu index="/system">
|
||||
<template #title>
|
||||
<el-icon><Tools /></el-icon>
|
||||
<span>系统管理</span>
|
||||
</template>
|
||||
<el-menu-item index="/system/search-config">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>搜索配置</span>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
@@ -99,7 +109,7 @@ const collapsed = ref(false)
|
||||
.layout-container { height: 100vh; }
|
||||
.sidebar { background: #fff; border-right: 1px solid #e8e8e8; transition: width 0.3s; overflow: hidden; }
|
||||
.sidebar-header { height: 60px; display: flex; align-items: center; justify-content: center; border-bottom: 1px solid #f0f0f0; }
|
||||
.logo-text { color: #1890ff; font-size: 18px; font-weight: 700; white-space: nowrap; }
|
||||
.logo-text { color: #1890ff; font-size: 18px; font-weight: 700; white-space: nowrap; text-decoration: none; }
|
||||
.logo-sm { font-size: 16px; }
|
||||
.sidebar :deep(.el-menu) { border-right: none; }
|
||||
.sidebar :deep(.el-menu-item) { margin: 2px 8px; border-radius: 8px; }
|
||||
|
||||
@@ -68,6 +68,14 @@ const routes = [
|
||||
{ path: '', name: 'Invoices', component: () => import('@/views/Invoices.vue'), meta: { title: '发票管理' } },
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/system/search-config',
|
||||
component: AdminLayout,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{ path: '', name: 'SearchConfig', component: () => import('@/views/SearchConfig.vue'), meta: { title: '搜索配置' } },
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({ history: createWebHistory('/admin/'), routes })
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="landing-page">
|
||||
<header class="landing-header">
|
||||
<div class="header-inner">
|
||||
<span class="logo">Trade<span>Mate</span></span>
|
||||
<router-link to="/" class="logo">Trade<span>Mate</span></router-link>
|
||||
<span class="subtitle">管理后台</span>
|
||||
<div class="header-right">
|
||||
<el-button v-if="isLoggedIn" @click="goDashboard">进入后台</el-button>
|
||||
@@ -111,7 +111,7 @@ function goDashboard() { router.push('/dashboard') }
|
||||
.landing-page { min-height: 100vh; display: flex; flex-direction: column; background: #f5f5f5; }
|
||||
.landing-header { background: #fff; border-bottom: 1px solid #eee; padding: 0 40px; height: 60px; display: flex; align-items: center; position: sticky; top: 0; z-index: 100; }
|
||||
.header-inner { width: 100%; max-width: 1200px; margin: 0 auto; display: flex; align-items: center; gap: 16px; }
|
||||
.logo { font-size: 20px; font-weight: 700; color: #1890ff; }
|
||||
.logo { font-size: 20px; font-weight: 700; color: #1890ff; text-decoration: none; }
|
||||
.logo span { color: #333; }
|
||||
.subtitle { font-size: 13px; color: #999; flex: 1; }
|
||||
.header-right { flex-shrink: 0; }
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
<template>
|
||||
<div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||
<h3 style="margin:0">搜索 API 配置</h3>
|
||||
<el-button type="primary" @click="showAdd">添加搜索源</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="list" v-loading="loading" border stripe>
|
||||
<el-table-column prop="name" label="名称" width="160" />
|
||||
<el-table-column prop="provider_type" label="类型" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="typeColor(row.provider_type)">{{ typeLabel(row.provider_type) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="api_endpoint" label="接口地址" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.api_endpoint" style="font-size:12px;color:#999">{{ row.api_endpoint }}</span>
|
||||
<span v-else style="color:#ccc">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="api_key" label="API Key" width="180">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.api_key" style="font-family:monospace;font-size:12px">{{ row.api_key }}</span>
|
||||
<span v-else style="color:#ccc">无密钥</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="priority" label="优先级" width="80" align="center" />
|
||||
<el-table-column prop="enabled" label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.enabled ? 'success' : 'info'" size="small">{{ row.enabled ? '启用' : '禁用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="testProvider(row)">测试</el-button>
|
||||
<el-button size="small" @click="editProvider(row)">编辑</el-button>
|
||||
<el-popconfirm title="确认删除?" @confirm="deleteProvider(row)">
|
||||
<template #reference>
|
||||
<el-button size="small" type="danger" plain>删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dialog.visible" :title="dialog.isEdit ? '编辑搜索源' : '添加搜索源'" width="520px">
|
||||
<el-form :model="form" label-width="100px" label-position="top">
|
||||
<el-form-item label="名称" required>
|
||||
<el-input v-model="form.name" placeholder="例:SearXNG 主搜索" />
|
||||
</el-form-item>
|
||||
<el-form-item label="类型" required>
|
||||
<el-select v-model="form.provider_type" style="width:100%">
|
||||
<el-option value="searxng" label="SearXNG (需境外服务器自建)" />
|
||||
<el-option value="bing" label="Bing Search API (国内可用)" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.provider_type === 'searxng'" label="接口地址" required>
|
||||
<el-input v-model="form.api_endpoint" placeholder="https://your-searxng.com" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.provider_type === 'bing'" label="API Key" required>
|
||||
<el-input v-model="form.api_key" placeholder="Azure Bing Search API Key" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.provider_type === 'google_cse'" label="API Key" required>
|
||||
<el-input v-model="form.api_key" placeholder="Google API Key" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.provider_type === 'google_cse'" label="Search Engine ID" required>
|
||||
<el-input v-model="form.cx" placeholder="cx=xxxx" />
|
||||
</el-form-item>
|
||||
<el-form-item label="优先级(越小越优先)">
|
||||
<el-input-number v-model="form.priority" :min="0" :max="99" />
|
||||
<span style="font-size:12px;color:#999;margin-left:8px">0=最高</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="启用">
|
||||
<el-switch v-model="form.enabled" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialog.visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="saveProvider">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="testDialog.visible" title="测试结果" width="520px">
|
||||
<div v-if="testDialog.loading" style="text-align:center;padding:24px">
|
||||
<el-icon class="is-loading" :size="24"><Loading /></el-icon>
|
||||
<p>正在测试...</p>
|
||||
</div>
|
||||
<div v-else-if="testDialog.error" style="color:#f56c6c">{{ testDialog.error }}</div>
|
||||
<div v-else>
|
||||
<el-alert type="success" :closable="false" style="margin-bottom:12px">搜索成功,返回 {{ testDialog.results?.length }} 条结果</el-alert>
|
||||
<div v-for="(r, i) in testDialog.results" :key="i" style="margin-bottom:8px;padding:8px;background:#fafafa;border-radius:4px">
|
||||
<div style="font-weight:600;font-size:13px">{{ r.title }}</div>
|
||||
<div style="font-size:11px;color:#999">{{ r.url }}</div>
|
||||
<div style="font-size:12px;color:#666;margin-top:4px">{{ r.snippet }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
import http from '@/api'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const list = ref([])
|
||||
|
||||
const dialog = reactive({ visible: false, isEdit: false, id: null })
|
||||
const form = reactive({
|
||||
name: '',
|
||||
provider_type: 'searxng',
|
||||
api_key: '',
|
||||
api_endpoint: '',
|
||||
cx: '',
|
||||
priority: 0,
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
const testDialog = reactive({ visible: false, loading: false, results: [], error: '' })
|
||||
|
||||
function typeLabel(t) {
|
||||
const m = { searxng: 'SearXNG', bing: 'Bing' }
|
||||
return m[t] || t
|
||||
}
|
||||
function typeColor(t) {
|
||||
const m = { searxng: '', bing: 'primary' }
|
||||
return m[t] || ''
|
||||
}
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await http.get('/admin/search-providers')
|
||||
list.value = res.items || []
|
||||
} catch (e) { ElMessage.error('加载失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function showAdd() {
|
||||
dialog.isEdit = false
|
||||
dialog.id = null
|
||||
form.name = ''
|
||||
form.provider_type = 'searxng'
|
||||
form.api_key = ''
|
||||
form.api_endpoint = ''
|
||||
form.cx = ''
|
||||
form.priority = 0
|
||||
form.enabled = true
|
||||
dialog.visible = true
|
||||
}
|
||||
|
||||
function editProvider(p) {
|
||||
dialog.isEdit = true
|
||||
dialog.id = p.id
|
||||
form.name = p.name
|
||||
form.provider_type = p.provider_type
|
||||
form.api_key = p.api_key || ''
|
||||
form.api_endpoint = p.api_endpoint || ''
|
||||
form.cx = (p.extra_config?.cx) || ''
|
||||
form.priority = p.priority
|
||||
form.enabled = p.enabled
|
||||
dialog.visible = true
|
||||
}
|
||||
|
||||
async function saveProvider() {
|
||||
if (!form.name || !form.provider_type) { ElMessage.warning('请填写名称和类型'); return }
|
||||
saving.value = true
|
||||
try {
|
||||
const data = {
|
||||
name: form.name,
|
||||
provider_type: form.provider_type,
|
||||
api_key: form.api_key || null,
|
||||
api_endpoint: form.api_endpoint || null,
|
||||
extra_config: form.cx ? { cx: form.cx } : {},
|
||||
priority: form.priority,
|
||||
enabled: form.enabled,
|
||||
}
|
||||
if (dialog.isEdit) {
|
||||
await http.put(`/admin/search-providers/${dialog.id}`, data)
|
||||
ElMessage.success('已更新')
|
||||
} else {
|
||||
await http.post('/admin/search-providers', data)
|
||||
ElMessage.success('已添加')
|
||||
}
|
||||
dialog.visible = false
|
||||
await fetchList()
|
||||
} catch (e) { ElMessage.error('保存失败') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
async function deleteProvider(p) {
|
||||
try {
|
||||
await http.delete(`/admin/search-providers/${p.id}`)
|
||||
ElMessage.success('已删除')
|
||||
await fetchList()
|
||||
} catch (e) { ElMessage.error('删除失败') }
|
||||
}
|
||||
|
||||
async function testProvider(p) {
|
||||
testDialog.visible = true
|
||||
testDialog.loading = true
|
||||
testDialog.results = []
|
||||
testDialog.error = ''
|
||||
try {
|
||||
const res = await http.post(`/admin/search-providers/${p.id}/test`)
|
||||
testDialog.results = res.results || []
|
||||
if (!res.success) testDialog.error = res.error || '测试失败'
|
||||
} catch (e) { testDialog.error = e?.detail || '请求失败' }
|
||||
finally { testDialog.loading = false }
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
Reference in New Issue
Block a user