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:
TradeMate Dev
2026-05-26 11:40:13 +08:00
parent 52dba37f22
commit bed5c7abef
39 changed files with 1988 additions and 152 deletions
+13 -3
View File
@@ -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; }
+8
View File
@@ -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 -2
View File
@@ -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; }
+216
View File
@@ -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>