feat: virtual company module, team projects, PWA dishes app, and startup scripts overhaul

- Add company module (3-tier org, CEO planning, parallel departments)
- Add company orchestrator, knowledge extractor, presets, scheduler
- Add company API endpoints, models, and frontend views
- Add 今天吃啥 PWA app (69 dishes, real images, offline support)
- Add team_projects output directory structure
- Add unified manage.ps1 for service lifecycle
- Add Windows startup guide v1.0
- Add TTS troubleshooting doc
- Update frontend (AgentChat UX overhaul, new views)
- Update backend (voice engine fix, multi-tenant, RBAC)
- Remove deprecated startup scripts and old docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 22:37:56 +08:00
parent 3483c6b3be
commit f2e65a8fbb
259 changed files with 39239 additions and 3148 deletions

View File

@@ -0,0 +1,277 @@
import api from '@/api'
export interface Company {
id: string
name: string
description: string | null
industry: string | null
department_count: number
ceo_agent_id: string | null
status: string
created_at: string | null
}
export interface Department {
id: string
name: string
description: string | null
department_type: string | null
members: DepartmentMember[] | null
}
export interface DepartmentMember {
id: string
agent_id: string
role: string
is_lead: boolean
agent?: any
}
export interface CompanyProject {
id: string
company_id: string
name: string
description: string | null
status: string
ceo_plan: any
created_at: string | null
}
export interface CompanyDetail extends Company {
departments: Department[]
}
export async function listCompanies(): Promise<Company[]> {
const resp = await api.get('/api/v1/companies')
return resp.data.companies || []
}
export async function getCompany(id: string): Promise<CompanyDetail> {
const resp = await api.get(`/api/v1/companies/${id}`)
return resp.data
}
export async function createCompanyFromPreset(presetType: string, companyName?: string): Promise<any> {
const params = companyName ? { company_name: companyName } : {}
const resp = await api.post(`/api/v1/companies/template/${presetType}`, null, { params })
return resp.data
}
export async function createCompany(data: { name: string; description?: string; industry?: string }): Promise<any> {
const resp = await api.post('/api/v1/companies', data)
return resp.data
}
export async function updateCompany(id: string, data: { name?: string; description?: string; industry?: string }): Promise<any> {
const resp = await api.put(`/api/v1/companies/${id}`, data)
return resp.data
}
export async function deleteCompany(id: string): Promise<void> {
await api.delete(`/api/v1/companies/${id}`)
}
export async function executeCompany(companyId: string, projectDescription: string): Promise<any> {
const resp = await api.post(`/api/v1/companies/${companyId}/execute`, { project_description: projectDescription })
return resp.data
}
export async function addDepartment(companyId: string, teamId: string): Promise<any> {
const resp = await api.post(`/api/v1/companies/${companyId}/departments`, { team_id: teamId })
return resp.data
}
export async function removeDepartment(companyId: string, departmentId: string): Promise<void> {
await api.delete(`/api/v1/companies/${companyId}/departments/${departmentId}`)
}
export async function listProjects(companyId: string): Promise<CompanyProject[]> {
const resp = await api.get(`/api/v1/companies/${companyId}/projects`)
return resp.data.projects || []
}
export async function exportCompany(companyId: string): Promise<void> {
const token = localStorage.getItem('access_token') || ''
const resp = await fetch(`/api/v1/companies/${companyId}/export`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!resp.ok) throw new Error('Export failed')
const blob = await resp.blob()
const disposition = resp.headers.get('Content-Disposition') || ''
const match = disposition.match(/filename="?(.+)"?/)
const filename = match ? match[1] : 'company_export.md'
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
export async function getCompanyStats(companyId: string): Promise<{
total_projects: number
completed_projects: number
success_rate: number
last_active_at: string | null
}> {
const resp = await api.get(`/api/v1/companies/${companyId}/stats`)
return resp.data
}
// ─── Schedules ───
export interface CompanySchedule {
id: string
company_id: string
name: string
project_goal: string
cron_expression: string
interval_minutes: number | null
enabled: boolean
last_run_at: string | null
next_run_at: string | null
}
export async function listSchedules(companyId: string): Promise<CompanySchedule[]> {
const resp = await api.get(`/api/v1/companies/${companyId}/schedules`)
return resp.data.schedules || []
}
export async function createSchedule(companyId: string, data: {
name: string; project_goal: string; cron_expression: string; interval_minutes?: number
}): Promise<any> {
const payload: any = { ...data }
if (payload.interval_minutes !== undefined) payload.interval_minutes = String(payload.interval_minutes)
const resp = await api.post(`/api/v1/companies/${companyId}/schedules`, payload)
return resp.data
}
export async function updateSchedule(scheduleId: string, data: {
name?: string; project_goal?: string; cron_expression?: string; interval_minutes?: number; enabled?: boolean
}): Promise<any> {
const payload: any = { ...data }
if (payload.interval_minutes !== undefined) payload.interval_minutes = String(payload.interval_minutes)
const resp = await api.put(`/api/v1/companies/schedules/${scheduleId}`, payload)
return resp.data
}
export async function deleteSchedule(scheduleId: string): Promise<void> {
await api.delete(`/api/v1/companies/schedules/${scheduleId}`)
}
export async function triggerSchedule(scheduleId: string): Promise<any> {
const resp = await api.post(`/api/v1/companies/schedules/${scheduleId}/trigger`)
return resp.data
}
// ─── Knowledge ───
export interface CompanyKnowledge {
id: string
company_id: string
project_id: string | null
title: string
content: string
category: string
tags: string[]
source_dept: string
created_at: string
}
export async function listKnowledge(companyId: string, params?: { search?: string; category?: string; limit?: number }): Promise<CompanyKnowledge[]> {
const resp = await api.get(`/api/v1/companies/${companyId}/knowledge`, { params })
return resp.data.knowledge || []
}
export async function generateKnowledge(companyId: string, projectId: string): Promise<any> {
const resp = await api.post(`/api/v1/companies/${companyId}/knowledge/generate`, { project_id: projectId })
return resp.data
}
export async function deleteKnowledge(companyId: string, knowledgeId: string): Promise<void> {
await api.delete(`/api/v1/companies/${companyId}/knowledge/${knowledgeId}`)
}
// ─── Insights ───
export interface CompanyInsights {
total_projects: number
completed_projects: number
failed_projects: number
success_rate: number
dept_stats: { name: string; execution_count: number; success_count: number; avg_score: number; success_rate: number }[]
collaboration_pairs: { pair: string; count: number }[]
avg_rounds: number
avg_cost: number
failure_reasons: { project: string; department: string; score: number; feedback: string }[]
recommendations: string[]
}
export async function getCompanyInsights(companyId: string): Promise<CompanyInsights> {
const resp = await api.get(`/api/v1/companies/${companyId}/insights`)
return resp.data
}
// ─── Template Marketplace ───
export interface ProjectTemplate {
id: string
company_id: string
name: string
description: string | null
ceo_plan: any
is_public: boolean
}
export async function listTemplates(params?: { search?: string }): Promise<ProjectTemplate[]> {
const resp = await api.get('/api/v1/companies/templates/list', { params })
return resp.data.templates || []
}
export async function publishProject(companyId: string, projectId: string): Promise<any> {
const resp = await api.post(`/api/v1/companies/${companyId}/projects/${projectId}/publish`)
return resp.data
}
export async function unpublishProject(companyId: string, projectId: string): Promise<any> {
const resp = await api.post(`/api/v1/companies/${companyId}/projects/${projectId}/unpublish`)
return resp.data
}
// ─── Project Supervisor / 项目监管 ───
export interface ZombieProject {
project_id: string
name: string
company_id: string
status: string
idle_minutes: number
last_updated: string | null
created_at: string | null
}
export interface InProgressProject {
project_id: string
name: string
company_id: string
status: string
idle_minutes: number
is_at_risk: boolean
will_become_zombie_in_minutes: number
last_updated: string | null
}
export async function getZombieProjects(): Promise<{ zombie_count: number; projects: ZombieProject[]; scanned_at: string | null }> {
const resp = await api.get('/api/v1/companies/supervisor/zombies')
return resp.data
}
export async function getInProgressProjects(): Promise<{ projects: InProgressProject[] }> {
const resp = await api.get('/api/v1/companies/supervisor/in-progress')
return resp.data
}
export async function triggerSupervisorScan(): Promise<any> {
const resp = await api.post('/api/v1/companies/supervisor/scan')
return resp.data
}
export async function recoverProject(companyId: string, projectId: string): Promise<any> {
const resp = await api.post(`/api/v1/companies/${companyId}/projects/${projectId}/recover`)
return resp.data
}

View File

@@ -62,11 +62,14 @@ api.interceptors.request.use(
)
// 响应拦截器
// 单例 refresh promise避免并发 401 触发多次刷新
let refreshPromise: Promise<string | null> | null = null
api.interceptors.response.use(
(response) => {
return response
},
(error) => {
async (error) => {
const skip = Boolean(
(error.config as { skipErrorHandler?: boolean } | undefined)?.skipErrorHandler
)
@@ -77,14 +80,40 @@ api.interceptors.response.use(
if (skip) {
return Promise.reject(error)
}
// 处理401未授权
// 处理401未授权:先尝试用 refresh_token 静默刷新并重试原请求
if (status === 401) {
// 延迟导入避免循环依赖:通过 Pinia store 统一清理认证状态
const originalConfig = error.config as
| (import('axios').AxiosRequestConfig & { _retry?: boolean })
| undefined
const hasRefresh = Boolean(localStorage.getItem('refresh_token'))
if (originalConfig && !originalConfig._retry && hasRefresh) {
originalConfig._retry = true
try {
if (!refreshPromise) {
refreshPromise = import('@/stores/user')
.then(({ useUserStore }) => useUserStore().refreshAccessToken())
.finally(() => {
refreshPromise = null
})
}
const newToken = await refreshPromise
if (newToken) {
// 重试原请求(请求拦截器会自动注入最新 token
return api(originalConfig)
}
} catch {
// 刷新失败,走下面的登出流程
}
}
// 刷新不可用或失败 → 清理认证状态并跳转登录
import('@/stores/user').then(({ useUserStore }) => {
useUserStore().logout()
}).catch(() => {
localStorage.removeItem('token')
localStorage.removeItem('refresh_token')
})
router.push('/login')
ElMessage.error(data?.message || '登录已过期,请重新登录')

View File

@@ -133,7 +133,7 @@ import {
Grid, Document, User, ChatLineSquare, Share, UserFilled,
Clock, Monitor, List, Connection, Setting, Star, Shop,
Tools, Bell, Tickets, OfficeBuilding, Lock, ArrowDown, DataAnalysis,
Sunny, Moon, Iphone, Fold, Expand, SwitchButton
Sunny, Moon, Iphone, Fold, Expand, SwitchButton, MagicStick
} from '@element-plus/icons-vue'
import GlobalSearch from '@/components/GlobalSearch.vue'
import BreadcrumbNav from '@/components/BreadcrumbNav.vue'
@@ -184,6 +184,8 @@ const menuGroups = computed<MenuGroup[]>(() => {
children: [
{ index: 'agent-chat', label: 'Agent对话', description: '与智能体对话交互', path: '/agent-chat', pathPattern: '/agent-chat', icon: ChatLineSquare },
{ index: 'teams', label: '虚拟团队', description: '团队管理与数字员工', path: '/teams', pathPattern: '/goals', icon: UserFilled },
{ index: 'company-presets', label: 'AI数字员工', description: '一键创建全套公司AI员工', path: '/company-presets', icon: MagicStick },
{ index: 'companies', label: '虚拟公司', description: '多层组织架构与多部门协作', path: '/companies', icon: OfficeBuilding },
{ index: 'agent-schedules', label: '定时任务', description: 'Agent定时执行', path: '/agent-schedules', icon: Clock },
{ index: 'agent-test', label: '快速测试', description: '快速测试Agent', path: '/agent-test', icon: Monitor },
],
@@ -194,7 +196,7 @@ const menuGroups = computed<MenuGroup[]>(() => {
icon: Shop,
children: [
{ index: 'template-market', label: '模板市场', description: '浏览安装模板', path: '/template-market', icon: Star },
{ index: 'agent-market', label: 'Agent技能商店', description: '浏览安装技能', path: '/agent-market', icon: Shop },
{ index: 'agent-market', label: 'Agent 市场', description: '浏览安装 Agent', path: '/agent-market', icon: Shop },
{ index: 'tools', label: '工具市场', description: '内置与自定义工具', path: '/tools', icon: Tools },
{ index: 'node-templates', label: '节点模板', description: '工作流节点模板', path: '/node-templates', icon: Document },
],
@@ -221,6 +223,7 @@ const menuGroups = computed<MenuGroup[]>(() => {
{ index: 'permissions', label: '权限管理', description: '用户权限管理', path: '/permissions', icon: Lock, adminOnly: true },
{ index: 'app-versions', label: 'App版本管理', description: '发布App新版本', path: '/app-versions', icon: Iphone, adminOnly: true },
{ index: 'users', label: '用户管理', description: '用户列表与权限管理', path: '/users', icon: User, adminOnly: true },
{ index: 'api-key-management', label: 'API密钥管理', description: '管理所有API密钥', path: '/admin/api-keys', icon: Tickets, adminOnly: true },
],
},
]

View File

@@ -15,7 +15,6 @@ const SpeechRecognition =
export function useSpeechRecognition() {
let recognition: any = null;
// 初始化
if (SpeechRecognition) {
supported.value = true;
}
@@ -26,8 +25,14 @@ export function useSpeechRecognition() {
return;
}
// Stop any previous recognition instance
if (recognition) {
try { recognition.stop(); } catch { /* ignore */ }
}
error.value = '';
transcript.value = '';
isListening.value = true;
recognition = new SpeechRecognition();
recognition.lang = lang;
@@ -35,33 +40,55 @@ export function useSpeechRecognition() {
recognition.continuous = false;
recognition.maxAlternatives = 1;
let finalTranscript = '';
recognition.onresult = (event: any) => {
let final = '';
let interim = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
const result = event.results[i];
if (result.isFinal) final += result[0].transcript;
else transcript.value += result[0].transcript;
if (result.isFinal) {
finalTranscript += result[0].transcript;
} else {
interim += result[0].transcript;
}
}
if (final) transcript.value = final;
transcript.value = finalTranscript + interim;
};
recognition.onerror = (e: any) => {
error.value = e.error === 'no-speech' ? '未检测到语音' : `识别错误: ${e.error}`;
console.error('SpeechRecognition error:', e.error, e.message);
if (e.error === 'not-allowed') {
error.value = '麦克风权限未授权,请点击地址栏锁图标 → 允许麦克风访问';
} else if (e.error === 'no-speech') {
error.value = '未检测到语音';
} else {
error.value = `识别错误: ${e.error}`;
}
isListening.value = false;
};
recognition.onend = () => {
isListening.value = false;
// If still flagged as listening, recognition ended unexpectedly — auto restart
if (isListening.value) {
try { recognition.start(); } catch { isListening.value = false; }
} else {
isListening.value = false;
}
};
isListening.value = true;
recognition.start();
try {
recognition.start();
} catch (e: any) {
console.error('SpeechRecognition start failed:', e);
error.value = `无法启动语音识别: ${e.message || e}`;
isListening.value = false;
}
}
function stop() {
isListening.value = false;
if (recognition) {
recognition.stop();
isListening.value = false;
try { recognition.stop(); } catch { /* ignore */ }
}
}

View File

@@ -208,6 +208,18 @@ const router = createRouter({
component: () => import('@/views/TeamBuilder.vue'),
meta: { requiresAuth: true }
},
{
path: '/company-presets',
name: 'company-presets',
component: () => import('@/views/CompanyPresets.vue'),
meta: { requiresAuth: true }
},
{
path: '/companies',
name: 'companies',
component: () => import('@/views/CompanyBuilder.vue'),
meta: { requiresAuth: true }
},
{
path: '/app-versions',
name: 'app-versions',
@@ -219,6 +231,18 @@ const router = createRouter({
name: 'users',
component: () => import('@/views/Users.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: '/profile',
name: 'profile',
component: () => import('@/views/Profile.vue'),
meta: { requiresAuth: true }
},
{
path: '/admin/api-keys',
name: 'api-key-management',
component: () => import('@/views/ApiKeyManagement.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
}
]
})

View File

@@ -9,6 +9,7 @@ import type { User, Workspace } from '@/types'
export const useUserStore = defineStore('user', () => {
const user = ref<User | null>(null)
const token = ref<string | null>(localStorage.getItem('token'))
const refreshToken = ref<string | null>(localStorage.getItem('refresh_token'))
const workspaces = ref<Workspace[]>([])
const currentWorkspaceId = ref<string | null>(null)
@@ -38,33 +39,74 @@ export const useUserStore = defineStore('user', () => {
api.defaults.headers.common['Authorization'] = `Bearer ${newToken}`
}
// 设置 refresh token
const setRefreshToken = (newToken: string | null) => {
refreshToken.value = newToken
if (newToken) {
localStorage.setItem('refresh_token', newToken)
} else {
localStorage.removeItem('refresh_token')
}
}
// 清除token
const clearToken = () => {
token.value = null
localStorage.removeItem('token')
setRefreshToken(null)
delete api.defaults.headers.common['Authorization']
}
// 登录
const login = async (username: string, password: string) => {
// 登录opts 支持图形验证码与「记住我」)
const login = async (
username: string,
password: string,
opts: { captchaId?: string; captcha?: string; rememberMe?: boolean } = {}
) => {
const params = new URLSearchParams()
params.append('username', username)
params.append('password', password)
if (opts.rememberMe) params.append('remember_me', 'true')
if (opts.captchaId) params.append('captcha_id', opts.captchaId)
if (opts.captcha) params.append('captcha', opts.captcha)
const response = await api.post('/api/v1/auth/login', params, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
})
},
// 登录失败由登录页自行处理,避免全局拦截器把 401 当成「登录过期」触发跳转
skipErrorHandler: true,
} as any)
if (response.data.access_token) {
setToken(response.data.access_token)
if (response.data.refresh_token) {
setRefreshToken(response.data.refresh_token)
}
await fetchUser()
}
return response.data
}
// 用 refresh_token 静默换取新的 access_token供响应拦截器调用
const refreshAccessToken = async (): Promise<string | null> => {
if (!refreshToken.value) return null
const response = await api.post(
'/api/v1/auth/refresh',
{ refresh_token: refreshToken.value },
{ skipErrorHandler: true } as any
)
if (response.data?.access_token) {
setToken(response.data.access_token)
if (response.data.refresh_token) {
setRefreshToken(response.data.refresh_token)
}
return response.data.access_token
}
return null
}
// 注册
const register = async (username: string, email: string, password: string) => {
const response = await api.post('/api/v1/auth/register', {
@@ -146,6 +188,7 @@ export const useUserStore = defineStore('user', () => {
return {
user,
token,
refreshToken,
workspaces,
currentWorkspaceId,
currentWorkspace,
@@ -155,6 +198,7 @@ export const useUserStore = defineStore('user', () => {
register,
logout,
fetchUser,
refreshAccessToken,
switchWorkspace,
}
})

File diff suppressed because it is too large Load Diff

View File

@@ -284,7 +284,7 @@ const myRating = ref(0)
const newComment = ref('')
const submittingComment = ref(false)
const viewTitle = ref('Agent 技能市场')
const viewTitle = ref('Agent 市场')
// 加载 Agent 列表
const loadAgents = async (url?: string) => {
@@ -318,7 +318,7 @@ const loadAgents = async (url?: string) => {
const handleSearch = () => {
currentPage.value = 1
viewMode.value = 'market'
viewTitle.value = 'Agent 技能市场'
viewTitle.value = 'Agent 市场'
loadAgents()
}
@@ -345,7 +345,7 @@ const switchView = (mode: string) => {
viewMode.value = mode
currentPage.value = 1
if (mode === 'market') {
viewTitle.value = 'Agent 技能市场'
viewTitle.value = 'Agent 市场'
loadAgents()
} else if (mode === 'favorites') {
viewTitle.value = '我的收藏'

View File

@@ -0,0 +1,155 @@
<template>
<MainLayout>
<div class="apikey-management-page">
<h3>API 密钥管理</h3>
<el-card class="section-card">
<template #header>
<div class="card-header">
<span>全部密钥管理员视图</span>
<el-input
v-model="searchText"
placeholder="搜索用户名或密钥名称"
clearable
style="width: 260px"
/>
</div>
</template>
<el-table :data="filteredKeys" style="width:100%" v-loading="loading">
<el-table-column prop="name" label="密钥名称" min-width="140" />
<el-table-column label="所属用户" min-width="120">
<template #default="{ row }">
{{ row.owner?.username || '-' }}
</template>
</el-table-column>
<el-table-column label="邮箱" min-width="160">
<template #default="{ row }">
{{ row.owner?.email || '-' }}
</template>
</el-table-column>
<el-table-column prop="key_prefix" label="前缀" width="100">
<template #default="{ row }">
<el-tag size="small">{{ row.key_prefix }}</el-tag>
</template>
</el-table-column>
<el-table-column label="权限" min-width="100">
<template #default="{ row }">
<el-tag v-if="!row.permissions" type="success" size="small">全部权限</el-tag>
<template v-else>
<el-tag v-for="p in row.permissions" :key="p" size="small" style="margin-right:4px">{{ p }}</el-tag>
</template>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'info'" size="small">
{{ row.status === 'active' ? '生效' : '已撤销' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="过期时间" width="120">
<template #default="{ row }">
{{ row.expires_at ? row.expires_at.split('T')[0] : '永不过期' }}
</template>
</el-table-column>
<el-table-column label="最后使用" width="120">
<template #default="{ row }">
{{ row.last_used_at ? formatTime(row.last_used_at) : '未使用' }}
</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button
v-if="row.status === 'active'"
type="danger" link size="small"
@click="revokeKey(row.id)"
>
撤销
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</MainLayout>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import MainLayout from '@/components/MainLayout.vue'
interface Owner {
id: string; username: string; email: string
}
interface ApiKeyRow {
id: string; name: string; key_prefix: string; permissions: string[] | null
last_used_at: string | null; expires_at: string | null; status: string
created_at: string; owner: Owner | null
}
const loading = ref(false)
const apiKeys = ref<ApiKeyRow[]>([])
const searchText = ref('')
const filteredKeys = computed(() => {
if (!searchText.value) return apiKeys.value
const q = searchText.value.toLowerCase()
return apiKeys.value.filter(k =>
k.name.toLowerCase().includes(q) ||
(k.owner?.username || '').toLowerCase().includes(q)
)
})
const baseUrl = import.meta.env.DEV ? '' : `${window.location.protocol}//${window.location.hostname}:8037`
const token = () => localStorage.getItem('token') || ''
async function fetchAllKeys() {
loading.value = true
try {
const resp = await fetch(`${baseUrl}/api/v1/api-keys/admin/all`, {
headers: { Authorization: `Bearer ${token()}` }
})
if (resp.ok) {
apiKeys.value = await resp.json()
} else {
ElMessage.error('获取密钥列表失败')
}
} catch { /* ignore */ }
finally { loading.value = false }
}
async function revokeKey(id: string) {
try {
await ElMessageBox.confirm('撤销后该密钥立即失效,确认撤销?', '确认撤销', { type: 'warning' })
} catch { return }
try {
const resp = await fetch(`${baseUrl}/api/v1/api-keys/admin/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token()}` }
})
if (resp.ok) {
ElMessage.success('已撤销')
fetchAllKeys()
} else {
const err = await resp.json()
ElMessage.error(err.detail || '撤销失败')
}
} catch { ElMessage.error('请求失败') }
}
function formatTime(iso: string) {
const d = new Date(iso)
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
}
onMounted(() => fetchAllKeys())
</script>
<style scoped>
.apikey-management-page { max-width: 1100px; margin: 0 auto; padding: 20px; }
.apikey-management-page h3 { margin-bottom: 20px; }
.section-card { margin-bottom: 20px; }
.card-header { display: flex; justify-content: space-between; align-items: center; }
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,445 @@
<template>
<MainLayout>
<div class="company-presets-page">
<div class="page-header">
<div>
<h2>AI 数字员工工厂</h2>
<p class="subtitle">选择行业一键创建覆盖公司全职能的 AI 组织班底</p>
</div>
</div>
<!-- 步骤 1选择行业 -->
<div class="step-section" v-if="!selectedPreset">
<div class="step-title">选择您的行业类型</div>
<div class="preset-grid">
<el-card
v-for="preset in presets"
:key="preset.id"
class="preset-card"
:shadow="'hover'"
@click="selectPreset(preset)"
>
<div class="preset-icon">{{ preset.icon }}</div>
<h3>{{ preset.name }}</h3>
<p class="preset-industry">{{ preset.industry }}</p>
<p class="preset-desc">{{ preset.description }}</p>
<el-tag size="small" type="info">{{ preset.role_count }} 个岗位角色</el-tag>
</el-card>
</div>
</div>
<!-- 步骤 2选择角色 -->
<div class="step-section" v-if="selectedPreset && !created">
<div class="step-nav">
<el-button text @click="selectedPreset = null">
<el-icon><ArrowLeft /></el-icon>
返回行业选择
</el-button>
</div>
<div class="step-title">
{{ selectedPreset.icon }} {{ selectedPreset.name }} 组织班底配置
</div>
<p class="preset-industry">{{ selectedPreset.industry }}</p>
<!-- 按分组展示角色 -->
<div v-for="group in roleGroups" :key="group.name" class="role-group">
<div class="group-header">
<span class="group-name">{{ group.name }}</span>
<el-button size="small" text type="primary" @click="toggleGroup(group.name)">
{{ group.allSelected ? '取消全选' : '全选' }}
</el-button>
</div>
<div class="role-grid">
<el-card
v-for="role in group.roles"
:key="role.role"
:class="['role-card', { selected: selectedRoles.has(role.role) }]"
:shadow="selectedRoles.has(role.role) ? 'always' : 'hover'"
@click="toggleRole(role.role)"
>
<div class="role-check">
<el-checkbox :model-value="selectedRoles.has(role.role)" />
</div>
<h4>{{ role.name }}</h4>
<p class="role-desc">{{ role.description }}</p>
<div class="role-meta">
<el-tag size="small" effect="plain">{{ role.model }}</el-tag>
<el-tag size="small" effect="plain" type="info">{{ role.tools.length }} 工具</el-tag>
<el-tag size="small" effect="plain" type="warning">max {{ role.max_iterations }}</el-tag>
</div>
</el-card>
</div>
</div>
<div class="create-bar">
<div class="selected-count">
已选 <strong>{{ selectedRoles.size }}</strong> / {{ selectedPreset.role_count }} 个角色
</div>
<el-button
type="primary"
size="large"
:loading="creating"
:disabled="selectedRoles.size === 0"
@click="handleCreate"
>
<el-icon><MagicStick /></el-icon>
一键创建 {{ selectedRoles.size }} AI 数字员工
</el-button>
</div>
</div>
<!-- 步骤 3创建完成 -->
<div class="step-section" v-if="created">
<div class="result-header">
<el-result icon="success" title="AI 数字员工创建成功!">
<template #sub-title>
共创建 <strong>{{ createdResult.created }}</strong> 个新 Agent
<span v-if="createdResult.reused > 0">复用 <strong>{{ createdResult.reused }}</strong> 个已有 Agent</span>
</template>
</el-result>
</div>
<div v-for="group in createdGroups" :key="group.name" class="created-group">
<h4 class="created-group-name">{{ group.name }}</h4>
<el-table :data="group.agents" size="small" stripe>
<el-table-column prop="name" label="Agent 名称" width="200" />
<el-table-column prop="role" label="角色标识" width="160" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.reused ? 'info' : 'success'" size="small">
{{ row.reused ? '复用' : '新建' }}
</el-tag>
</template>
</el-table-column>
</el-table>
</div>
<div class="result-actions">
<el-button type="primary" @click="router.push('/agents')">
查看全部 Agent
</el-button>
<el-button @click="resetAll">
创建更多
</el-button>
</div>
</div>
</div>
</MainLayout>
</template>
<script setup lang="ts">
import MainLayout from '@/components/MainLayout.vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeft, MagicStick } from '@element-plus/icons-vue'
import api from '@/api'
interface RoleInfo {
role: string
name: string
group: string
description: string
tools: string[]
temperature: number
model: string
max_iterations: number
}
interface Preset {
id: string
name: string
icon: string
description: string
industry: string
role_count: number
roles: RoleInfo[]
}
interface CreatedAgent {
id: string
name: string
role: string
group: string
reused: boolean
}
interface CreatedResult {
created: number
reused: number
total: number
agents: CreatedAgent[]
}
const router = useRouter()
const presets = ref<Preset[]>([])
const selectedPreset = ref<Preset | null>(null)
const selectedRoles = ref<Set<string>>(new Set())
const creating = ref(false)
const created = ref(false)
const createdResult = ref<CreatedResult>({ created: 0, reused: 0, total: 0, agents: [] })
const createdAgents = ref<CreatedAgent[]>([])
onMounted(async () => {
try {
const resp = await api.get('/api/v1/company-presets')
presets.value = resp.data.presets || []
} catch (e: any) {
ElMessage.error('加载预设包失败: ' + (e?.message || '未知错误'))
}
})
function selectPreset(preset: Preset) {
selectedPreset.value = preset
// 默认全选
selectedRoles.value = new Set(preset.roles.map(r => r.role))
}
const roleGroups = computed(() => {
if (!selectedPreset.value) return []
const groups: Record<string, { name: string; roles: RoleInfo[]; allSelected: boolean }> = {}
for (const role of selectedPreset.value.roles) {
if (!groups[role.group]) {
groups[role.group] = { name: role.group, roles: [], allSelected: true }
}
groups[role.group].roles.push(role)
}
for (const g of Object.values(groups)) {
g.allSelected = g.roles.every(r => selectedRoles.value.has(r.role))
}
return Object.values(groups)
})
function toggleRole(roleKey: string) {
const next = new Set(selectedRoles.value)
if (next.has(roleKey)) {
next.delete(roleKey)
} else {
next.add(roleKey)
}
selectedRoles.value = next
}
function toggleGroup(groupName: string) {
const group = roleGroups.value.find(g => g.name === groupName)
if (!group) return
const next = new Set(selectedRoles.value)
if (group.allSelected) {
for (const r of group.roles) next.delete(r.role)
} else {
for (const r of group.roles) next.add(r.role)
}
selectedRoles.value = next
}
async function handleCreate() {
if (!selectedPreset.value || selectedRoles.value.size === 0) return
creating.value = true
try {
const resp = await api.post(`/api/v1/company-presets/${selectedPreset.value.id}`, {
selected_roles: [...selectedRoles.value],
})
createdResult.value = resp.data
createdAgents.value = resp.data.agents || []
created.value = true
ElMessage.success(`成功创建 ${resp.data.created} 位 AI 数字员工!`)
} catch (e: any) {
const msg = e?.response?.data?.detail || e?.message || '创建失败'
ElMessage.error(msg)
} finally {
creating.value = false
}
}
const createdGroups = computed(() => {
if (!selectedPreset.value) return []
const groupOrder = [...new Set(selectedPreset.value.roles.map(r => r.group))]
const groups: { name: string; agents: CreatedAgent[] }[] = []
for (const gn of groupOrder) {
const agents = createdAgents.value.filter(a => a.group === gn)
if (agents.length > 0) groups.push({ name: gn, agents })
}
return groups
})
function resetAll() {
selectedPreset.value = null
selectedRoles.value = new Set()
created.value = false
createdResult.value = { created: 0, reused: 0, total: 0, agents: [] }
createdAgents.value = []
}
</script>
<style scoped>
.company-presets-page {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
}
.page-header {
margin-bottom: 32px;
}
.page-header h2 {
margin: 0 0 8px;
font-size: 24px;
color: var(--el-text-color-primary);
}
.subtitle {
margin: 0;
color: var(--el-text-color-secondary);
font-size: 14px;
}
.step-section {
margin-bottom: 40px;
}
.step-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
color: var(--el-text-color-primary);
}
.step-nav {
margin-bottom: 16px;
}
.preset-industry {
color: var(--el-text-color-secondary);
font-size: 13px;
margin: 0 0 24px;
}
/* 行业卡片网格 */
.preset-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 16px;
}
.preset-card {
cursor: pointer;
transition: transform 0.2s, border-color 0.2s;
border: 2px solid transparent;
}
.preset-card:hover {
transform: translateY(-2px);
border-color: var(--el-color-primary);
}
.preset-icon {
font-size: 40px;
margin-bottom: 8px;
}
.preset-card h3 {
margin: 0 0 4px;
font-size: 16px;
}
.preset-desc {
font-size: 13px;
color: var(--el-text-color-secondary);
margin: 8px 0;
line-height: 1.5;
}
/* 角色分组 */
.role-group {
margin-bottom: 24px;
}
.group-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.group-name {
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
position: relative;
padding-left: 12px;
}
.group-name::before {
content: '';
position: absolute;
left: 0;
top: 2px;
bottom: 2px;
width: 3px;
background: var(--el-color-primary);
border-radius: 2px;
}
.role-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 12px;
}
.role-card {
cursor: pointer;
border: 2px solid transparent;
transition: all 0.2s;
}
.role-card:hover {
border-color: var(--el-color-primary-light-5);
}
.role-card.selected {
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
.role-check {
position: absolute;
top: 8px;
right: 8px;
}
.role-card h4 {
margin: 0 0 6px;
font-size: 14px;
}
.role-desc {
font-size: 12px;
color: var(--el-text-color-secondary);
margin: 0 0 10px;
line-height: 1.4;
}
.role-meta {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
/* 创建栏 */
.create-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 32px;
padding: 20px;
background: var(--el-bg-color-page);
border-radius: 8px;
position: sticky;
bottom: 16px;
}
.selected-count {
font-size: 15px;
color: var(--el-text-color-secondary);
}
/* 结果 */
.result-header {
margin-bottom: 32px;
}
.created-group {
margin-bottom: 20px;
}
.created-group-name {
font-size: 15px;
font-weight: 600;
margin: 0 0 8px;
color: var(--el-text-color-primary);
}
.result-actions {
display: flex;
gap: 12px;
justify-content: center;
margin-top: 32px;
}
</style>

View File

@@ -76,15 +76,32 @@
<transition name="el-zoom-in-top">
<el-form-item v-if="showCaptcha" prop="captcha">
<el-input
v-model="loginForm.captcha"
placeholder="验证码"
:prefix-icon="KeyIcon"
:maxlength="6"
clearable
autocomplete="off"
tabindex="3"
/>
<div style="display:flex; gap:8px; align-items:center; width:100%;">
<el-input
v-model="loginForm.captcha"
placeholder="验证码"
:prefix-icon="KeyIcon"
:maxlength="6"
clearable
autocomplete="off"
tabindex="3"
style="flex:1;"
/>
<img
v-if="captchaImg"
:src="captchaImg"
title="看不清?点击刷新"
style="width:110px; height:40px; border-radius:6px; cursor:pointer; border:1px solid var(--el-border-color); flex-shrink:0;"
@click="fetchCaptcha"
/>
<div
v-else
style="width:110px; height:40px; border-radius:6px; cursor:pointer; border:1px dashed var(--el-border-color); display:flex; align-items:center; justify-content:center; font-size:13px; color:var(--el-text-color-secondary); flex-shrink:0;"
@click="fetchCaptcha"
>
{{ captchaLoading ? '加载中' : '点击获取' }}
</div>
</div>
</el-form-item>
</transition>
@@ -277,12 +294,13 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onBeforeUnmount, nextTick } from 'vue'
import { ref, reactive, computed, onBeforeUnmount, nextTick, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElNotification } from 'element-plus'
import { User as UserIcon, Lock as LockIcon, Key as KeyIcon, Message as MessageIcon } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import { useUserStore } from '@/stores/user'
import api from '@/api'
// ── types ──────────────────────────────────────────
type LoginState = 'idle' | 'validating' | 'loading' | 'success' | 'error' | 'disabled'
@@ -303,6 +321,12 @@ const failedCount = ref(0)
const errorMessage = ref('')
const countdown = ref(60)
// ── captcha state ──────────────────────────────────
const captchaRequired = ref(false) // 后端标记需要验证码
const captchaId = ref('')
const captchaImg = ref('')
const captchaLoading = ref(false)
// ── forgot password state ──────────────────────────
const forgotPwdVisible = ref(false)
const forgotLoading = ref(false)
@@ -376,10 +400,30 @@ const registerRules: FormRules = {
}
// ── computed ───────────────────────────────────────
const showCaptcha = computed(() => failedCount.value >= 3)
const showCaptcha = computed(() => captchaRequired.value || failedCount.value >= 3)
const isSubmitDisabled = computed(() => loginState.value === 'disabled')
const isFormDisabled = computed(() => loginState.value === 'loading' || loginState.value === 'disabled')
// ── captcha ────────────────────────────────────────
async function fetchCaptcha() {
captchaLoading.value = true
try {
const { data } = await api.get('/api/v1/auth/captcha', { skipErrorHandler: true } as any)
captchaId.value = data.captcha_id
captchaImg.value = data.image
loginForm.captcha = ''
} catch {
// 获取失败静默忽略,用户可点击图片重试
} finally {
captchaLoading.value = false
}
}
// 验证码从隐藏变为显示时,自动拉取一张
watch(showCaptcha, (visible) => {
if (visible && !captchaImg.value) fetchCaptcha()
})
const submitText = computed(() => {
if (loginState.value === 'disabled') return `${countdown.value} 秒后重试`
if (loginState.value === 'loading') return activeTab.value === 'login' ? '登录中...' : '注册中...'
@@ -432,8 +476,15 @@ async function handleLogin() {
errorMessage.value = ''
try {
await userStore.login(loginForm.username.trim(), loginForm.password)
await userStore.login(loginForm.username.trim(), loginForm.password, {
captchaId: captchaId.value,
captcha: loginForm.captcha,
rememberMe: loginForm.rememberMe,
})
failedCount.value = 0
captchaRequired.value = false
captchaId.value = ''
captchaImg.value = ''
loginForm.captcha = ''
setState('success')
ElMessage.success('登录成功')
@@ -443,7 +494,18 @@ async function handleLogin() {
}, 600)
} catch (error: any) {
failedCount.value++
if (showCaptcha.value) loginForm.captcha = ''
// 后端 detail 可能是字符串或 { message, captcha_required }
const raw = error?.response?.data?.detail
const isObj = raw && typeof raw === 'object'
const detail = (isObj ? raw.message : raw) || error?.message || '用户名或密码错误'
if (isObj && raw.captcha_required) captchaRequired.value = true
// 需要验证码时刷新图片,清空旧输入
if (showCaptcha.value) {
loginForm.captcha = ''
await fetchCaptcha()
}
// ── 渐进式错误提示 ──
if (failedCount.value >= 5) {
@@ -453,12 +515,10 @@ async function handleLogin() {
startCooldown(15) // 15秒而非60秒
} else if (failedCount.value >= 3) {
// 第3-4次显示忘记密码提示
const detail = error?.response?.data?.detail || error?.message || '用户名或密码错误'
errorMessage.value = `${detail}。忘记密码?点击下方"忘记密码"进行重置。`
setState('error')
} else {
// 第1-2次通用错误提示
const detail = error?.response?.data?.detail || error?.message || '用户名或密码错误'
errorMessage.value = detail
setState('error')
}

View File

@@ -21,10 +21,31 @@
clearable
@change="handleSearch"
>
<el-option label="OpenAI" value="openai" />
<el-option label="DeepSeek" value="deepseek" />
<el-option label="OpenAI" value="openai" />
<el-option label="Anthropic" value="anthropic" />
<el-option label="本地模型" value="local" />
<el-option label="阿里通义千问" value="qwen" />
<el-option label="智谱 GLM" value="zhipu" />
<el-option label="百度文心一言" value="baidu" />
<el-option label="月之暗面 Kimi" value="moonshot" />
<el-option label="字节豆包" value="bytedance" />
<el-option label="MiniMax" value="minimax" />
<el-option label="硅基流动" value="siliconflow" />
<el-option label="讯飞星火" value="xunfei" />
<el-option label="腾讯混元" value="hunyuan" />
<el-option label="零一万物" value="yi" />
<el-option label="百川" value="baichuan" />
<el-option label="OpenRouter" value="openrouter" />
<el-option label="Cohere" value="cohere" />
<el-option label="xAI" value="xai" />
<el-option label="Together" value="together" />
<el-option label="Fireworks" value="fireworks" />
<el-option label="Perplexity" value="perplexity" />
<el-option label="DeepInfra" value="deepinfra" />
<el-option label="Groq" value="groq" />
<el-option label="Google Gemini" value="google" />
<el-option label="Mistral AI" value="mistral" />
<el-option label="本地/其他" value="local" />
</el-select>
<el-button type="primary" @click="handleSearch" style="margin-left: 10px">
<el-icon><Search /></el-icon>
@@ -46,7 +67,7 @@
<el-table-column prop="name" label="名称" width="200" />
<el-table-column prop="provider" label="提供商" width="120">
<template #default="{ row }">
<el-tag>{{ row.provider.toUpperCase() }}</el-tag>
<el-tag>{{ providerDisplay(row.provider) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="model_name" label="模型名称" width="200" />
@@ -92,70 +113,131 @@
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="600px"
width="720px"
@close="handleDialogClose"
>
<el-form :model="form" label-width="120px" :rules="rules" ref="formRef">
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" placeholder="请输入配置名称" />
</el-form-item>
<el-form-item label="提供商" prop="provider">
<el-select v-model="form.provider" placeholder="选择提供商">
<el-option label="OpenAI" value="openai" />
<el-option label="DeepSeek" value="deepseek" />
<el-option label="Anthropic" value="anthropic" />
<el-option label="本地模型" value="local" />
</el-select>
</el-form-item>
<el-form-item label="模型名称" prop="model_name">
<el-select
v-model="form.model_name"
placeholder="选择或输入模型名称"
filterable
allow-create
default-first-option
style="width: 100%"
>
<el-option
v-for="name in providerModelOptions"
:key="name"
:label="name"
:value="name"
/>
</el-select>
</el-form-item>
<el-form-item label="API密钥" prop="api_key">
<el-input
v-model="form.api_key"
type="password"
show-password
placeholder="请输入API密钥"
/>
<el-alert
type="info"
:closable="false"
show-icon
style="margin-top: 5px;"
>
<template #title>
<div style="font-size: 12px;">
API密钥将加密存储不会在列表中显示
</div>
</template>
</el-alert>
</el-form-item>
<el-form-item label="API地址" prop="base_url">
<el-input
v-model="form.base_url"
placeholder="可选,例如: https://api.openai.com/v1"
/>
</el-form-item>
</el-form>
<!-- 编辑模式单一表单 -->
<template v-if="currentConfigId">
<el-form :model="form" label-width="120px" :rules="rules" ref="formRef">
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" placeholder="可选,区分个人/公司用时填写" />
</el-form-item>
<el-form-item label="提供商" prop="provider">
<el-select v-model="form.provider" placeholder="选择提供商">
<el-option label="DeepSeek" value="deepseek" />
<el-option label="OpenAI" value="openai" />
<el-option label="Anthropic" value="anthropic" />
<el-option label="阿里通义千问" value="qwen" />
<el-option label="智谱 GLM" value="zhipu" />
<el-option label="百度文心一言" value="baidu" />
<el-option label="月之暗面 Kimi" value="moonshot" />
<el-option label="字节豆包" value="bytedance" />
<el-option label="MiniMax" value="minimax" />
<el-option label="硅基流动" value="siliconflow" />
<el-option label="讯飞星火" value="xunfei" />
<el-option label="腾讯混元" value="hunyuan" />
<el-option label="零一万物" value="yi" />
<el-option label="百川" value="baichuan" />
<el-option label="OpenRouter" value="openrouter" />
<el-option label="Cohere" value="cohere" />
<el-option label="xAI" value="xai" />
<el-option label="Together" value="together" />
<el-option label="Fireworks" value="fireworks" />
<el-option label="Perplexity" value="perplexity" />
<el-option label="DeepInfra" value="deepinfra" />
<el-option label="Groq" value="groq" />
<el-option label="Google Gemini" value="google" />
<el-option label="Mistral AI" value="mistral" />
<el-option label="本地/其他" value="local" />
</el-select>
</el-form-item>
<el-form-item label="模型名称" prop="model_name">
<el-input v-model="form.model_name" placeholder="模型名称" />
</el-form-item>
<el-form-item label="API密钥">
<el-input v-model="form.api_key" type="password" show-password placeholder="留空则不修改" />
</el-form-item>
<el-form-item label="API地址">
<el-input v-model="form.base_url" placeholder="可选" />
</el-form-item>
</el-form>
</template>
<!-- 创建模式全量模型选择面板 -->
<template v-else>
<div class="create-panel">
<div class="create-hint">
<el-icon><InfoFilled /></el-icon>
勾选需要的模型填写对应提供商的 API 密钥名称自动生成可选填前缀以区分
</div>
<el-form label-width="80px" class="name-prefix-row">
<el-form-item label="名称前缀">
<el-input v-model="customNamePrefix" placeholder="可选,如:公司、我的" style="width: 200px" />
<span class="prefix-hint">留空则自动命名为 "DeepSeek deepseek-chat" </span>
</el-form-item>
</el-form>
<!-- 按提供商分组 -->
<div v-for="group in modelGroups" :key="group.provider" class="provider-group">
<div class="provider-header">
<span class="provider-name">{{ group.label }}</span>
<span class="provider-count">{{ group.models.filter(m => m.checked).length }}/{{ group.models.length }} 个模型</span>
</div>
<!-- 模型选择网格 -->
<div class="model-check-grid">
<el-checkbox
v-for="model in group.models"
:key="model.name"
v-model="model.checked"
:label="model.name"
class="model-check-item"
>
<span class="model-name">{{ model.name }}</span>
<span class="model-desc" v-if="model.desc">{{ model.desc }}</span>
<span class="model-price" v-if="model.price">{{ model.price }}</span>
</el-checkbox>
</div>
<!-- 该提供商的 API 配置 -->
<div v-if="group.models.filter(m => m.checked).length > 0" class="provider-config">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="API 密钥" :label-width="80" size="small">
<el-input v-model="group.apiKey" type="password" show-password placeholder="sk-..." size="small" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="地址" :label-width="50" size="small">
<el-input v-model="group.baseUrl" placeholder="默认地址" size="small" />
</el-form-item>
</el-col>
</el-row>
</div>
</div>
<!-- 统计 -->
<div class="create-summary" v-if="totalSelected > 0">
<el-icon><Select /></el-icon>
共选择 <b>{{ totalSelected }}</b> 个模型将为它们创建配置
</div>
<el-empty v-else description="未选择任何模型" :image-size="48" />
</div>
</template>
<!-- 统一 Footer -->
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">
确定
</el-button>
<template v-if="currentConfigId">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">确定</el-button>
</template>
<template v-else>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleBatchCreate" :loading="submitting" :disabled="totalSelected === 0">
创建 {{ totalSelected }} 个配置
</el-button>
</template>
</template>
</el-dialog>
@@ -188,7 +270,9 @@ import {
Edit,
Delete,
Connection,
Loading
Loading,
InfoFilled,
Select
} from '@element-plus/icons-vue'
import { useModelConfigStore } from '@/stores/modelConfig'
import type { ModelConfig } from '@/stores/modelConfig'
@@ -214,6 +298,299 @@ const form = ref({
base_url: ''
})
const currentConfigId = ref<string | null>(null)
const customNamePrefix = ref('')
// 全量模型列表,按提供商分组
interface ModelItem { name: string; desc?: string; price?: string; checked: boolean }
interface ProviderGroup { provider: string; label: string; defaultBaseUrl: string; models: ModelItem[]; apiKey: string; baseUrl: string }
function createModelGroups(): ProviderGroup[] {
return [
{
provider: 'deepseek', label: 'DeepSeek', defaultBaseUrl: 'https://api.deepseek.com/v1',
models: [
{ name: 'deepseek-chat', desc: '通用对话', price: '¥1/2', checked: false },
{ name: 'deepseek-coder', desc: '代码专用', price: '¥1/2', checked: false },
{ name: 'deepseek-reasoner', desc: '深度推理', price: '¥1/4', checked: false },
{ name: 'deepseek-v4-pro', desc: '最新旗舰', price: '¥1/4', checked: false },
{ name: 'deepseek-v4-flash', desc: '极速响应', price: '¥0.5/1', checked: false },
],
apiKey: '', baseUrl: ''
},
{
provider: 'openai', label: 'OpenAI', defaultBaseUrl: 'https://api.openai.com/v1',
models: [
{ name: 'gpt-4o', desc: '最新多模态', price: '$2.5/10', checked: false },
{ name: 'gpt-4o-mini', desc: '轻量快速', price: '$0.15/0.6', checked: false },
{ name: 'gpt-4-turbo', desc: 'Turbo 版', price: '$10/30', checked: false },
{ name: 'o1', desc: '深度推理', price: '$15/60', checked: false },
{ name: 'o1-mini', desc: '轻量推理', price: '$1.1/4.4', checked: false },
{ name: 'o3-mini', desc: '最新推理', price: '$0.55/2.2', checked: false },
],
apiKey: '', baseUrl: ''
},
{
provider: 'anthropic', label: 'Anthropic', defaultBaseUrl: 'https://api.anthropic.com',
models: [
{ name: 'claude-sonnet-4-6', desc: '最新 Sonnet', price: '$3/15', checked: false },
{ name: 'claude-opus-4-6', desc: '最新 Opus', price: '$15/75', checked: false },
{ name: 'claude-haiku-4-5-20251001', desc: '最快速度', price: '$0.8/4', checked: false },
{ name: 'claude-3-5-sonnet-latest', desc: '稳定版 Sonnet', price: '$3/15', checked: false },
{ name: 'claude-3-opus-latest', desc: '稳定版 Opus', price: '$15/75', checked: false },
],
apiKey: '', baseUrl: ''
},
{
provider: 'qwen', label: '阿里通义千问', defaultBaseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
models: [
{ name: 'qwen-turbo', desc: '通用轻量', price: '¥0.8/0.8', checked: false },
{ name: 'qwen-plus', desc: '均衡性能', price: '¥2/2', checked: false },
{ name: 'qwen-max', desc: '最强旗舰', price: '¥20/20', checked: false },
{ name: 'qwen-long', desc: '超长上下文', price: '¥4/4', checked: false },
{ name: 'qwen-coder-plus', desc: '代码专用', price: '¥4/4', checked: false },
{ name: 'qwq-plus', desc: '深度推理', price: '¥4/4', checked: false },
],
apiKey: '', baseUrl: ''
},
{
provider: 'zhipu', label: '智谱 GLM', defaultBaseUrl: 'https://open.bigmodel.cn/api/paas/v4',
models: [
{ name: 'glm-4-flash', desc: '极速免费', price: '免费', checked: false },
{ name: 'glm-4-air', desc: '轻量高性价比', price: '¥0.1/0.1', checked: false },
{ name: 'glm-4-plus', desc: '最强旗舰', price: '¥5/5', checked: false },
{ name: 'glm-4-long', desc: '超长上下文', price: '¥5/5', checked: false },
{ name: 'glm-4.5', desc: '最新升级', price: '¥1/1', checked: false },
],
apiKey: '', baseUrl: ''
},
{
provider: 'baidu', label: '百度文心一言', defaultBaseUrl: 'https://qianfan.baidubce.com/v2',
models: [
{ name: 'ernie-speed-128k', desc: '极速大容量', price: '免费', checked: false },
{ name: 'ernie-3.5-128k', desc: '均衡通用', price: '¥0.8/0.8', checked: false },
{ name: 'ernie-4.0-turbo-128k', desc: '旗舰推理', price: '¥30/30', checked: false },
{ name: 'ernie-4.5', desc: '最新升级', price: '¥4/4', checked: false },
],
apiKey: '', baseUrl: ''
},
{
provider: 'moonshot', label: '月之暗面 Kimi', defaultBaseUrl: 'https://api.moonshot.cn/v1',
models: [
{ name: 'moonshot-v1-8k', desc: '标准长度', price: '¥0.15/0.15', checked: false },
{ name: 'moonshot-v1-32k', desc: '中长文本', price: '¥0.3/0.3', checked: false },
{ name: 'moonshot-v1-128k', desc: '超长文本', price: '¥0.6/0.6', checked: false },
],
apiKey: '', baseUrl: ''
},
{
provider: 'bytedance', label: '字节豆包', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
models: [
{ name: 'doubao-lite-128k', desc: '轻量快速', price: '¥0.3/0.3', checked: false },
{ name: 'doubao-pro-256k', desc: '主力旗舰', price: '¥0.8/0.8', checked: false },
{ name: 'doubao-pro-32k', desc: '高并发版', price: '¥0.8/0.8', checked: false },
],
apiKey: '', baseUrl: ''
},
{
provider: 'minimax', label: 'MiniMax', defaultBaseUrl: 'https://api.minimax.chat/v1',
models: [
{ name: 'abab6.5s-chat', desc: '轻量标准', price: '¥0.3/0.3', checked: false },
{ name: 'abab6.5t-chat', desc: '深度推理', price: '¥1/1', checked: false },
{ name: 'abab7-chat-preview', desc: '最新旗舰', price: '¥1/1', checked: false },
],
apiKey: '', baseUrl: ''
},
{
provider: 'xunfei', label: '讯飞星火', defaultBaseUrl: 'https://spark-api-open.xf-yun.com/v1',
models: [
{ name: '4.0Ultra', desc: '最强旗舰', price: '¥15/15', checked: false },
{ name: '3.5Max', desc: '高性价比', price: '¥3/3', checked: false },
{ name: 'lite', desc: '极速轻量', price: '免费', checked: false },
{ name: 'pro-128k', desc: '超长上下文', price: '¥3/3', checked: false },
],
apiKey: '', baseUrl: ''
},
{
provider: 'hunyuan', label: '腾讯混元', defaultBaseUrl: 'https://api.hunyuan.cloud.tencent.com/v1',
models: [
{ name: 'hunyuan-turbo', desc: '通用Turbo', price: '¥0.8/0.8', checked: false },
{ name: 'hunyuan-pro', desc: '旗舰Pro', price: '¥2/2', checked: false },
{ name: 'hunyuan-lite', desc: '免费轻量', price: '免费', checked: false },
{ name: 'hunyuan-code', desc: '代码专用', price: '¥2/2', checked: false },
],
apiKey: '', baseUrl: ''
},
{
provider: 'yi', label: '零一万物 Yi', defaultBaseUrl: 'https://api.lingyiwanwu.com/v1',
models: [
{ name: 'yi-large', desc: '旗舰大模型', price: '¥20/20', checked: false },
{ name: 'yi-medium', desc: '均衡中杯', price: '¥4/4', checked: false },
{ name: 'yi-lightning', desc: '极速闪电', price: '¥0.8/0.8', checked: false },
],
apiKey: '', baseUrl: ''
},
{
provider: 'baichuan', label: '百川智能', defaultBaseUrl: 'https://api.baichuan-ai.com/v1',
models: [
{ name: 'baichuan4', desc: '最新旗舰', price: '¥10/10', checked: false },
{ name: 'baichuan3-turbo', desc: '极速Turbo', price: '¥1/1', checked: false },
],
apiKey: '', baseUrl: ''
},
{
provider: 'cohere', label: 'Cohere', defaultBaseUrl: 'https://api.cohere.ai/v1',
models: [
{ name: 'command-r-plus', desc: '最强旗舰', price: '$2.5/10', checked: false },
{ name: 'command-r', desc: '高性价比', price: '$0.5/1.5', checked: false },
{ name: 'command-a', desc: '最新多模态', price: '$1/4', checked: false },
],
apiKey: '', baseUrl: ''
},
{
provider: 'xai', label: 'xAI (Grok)', defaultBaseUrl: 'https://api.x.ai/v1',
models: [
{ name: 'grok-3', desc: '最新旗舰', price: '$3/15', checked: false },
{ name: 'grok-3-mini', desc: '极速轻量', price: '$0.3/1.5', checked: false },
],
apiKey: '', baseUrl: ''
},
{
provider: 'together', label: 'Together AI', defaultBaseUrl: 'https://api.together.xyz/v1',
models: [
{ name: 'meta-llama/Llama-3.3-70B-Instruct-Turbo', desc: 'Llama 3.3 70B', price: '$0.88/0.88', checked: false },
{ name: 'deepseek-ai/DeepSeek-V3', desc: 'DeepSeek V3', price: '$1.25/1.25', checked: false },
{ name: 'Qwen/Qwen2.5-72B-Instruct-Turbo', desc: '千问2.5 72B', price: '$0.88/0.88', checked: false },
{ name: 'mistralai/Mixtral-8x22B-Instruct-v0.1', desc: 'Mixtral 8x22B', price: '$1.2/1.2', checked: false },
],
apiKey: '', baseUrl: ''
},
{
provider: 'fireworks', label: 'Fireworks AI', defaultBaseUrl: 'https://api.fireworks.ai/inference/v1',
models: [
{ name: 'accounts/fireworks/models/llama-v3p3-70b-instruct', desc: 'Llama 3.3 70B', price: '$0.9/0.9', checked: false },
{ name: 'accounts/fireworks/models/deepseek-v3', desc: 'DeepSeek V3', price: '$0.9/0.9', checked: false },
{ name: 'accounts/fireworks/models/qwen2p5-72b-instruct', desc: '千问2.5 72B', price: '$0.9/0.9', checked: false },
],
apiKey: '', baseUrl: ''
},
{
provider: 'perplexity', label: 'Perplexity', defaultBaseUrl: 'https://api.perplexity.ai',
models: [
{ name: 'sonar-reasoning-pro', desc: '深度推理', price: '$5/15', checked: false },
{ name: 'sonar-pro', desc: '专业搜索', price: '$3/15', checked: false },
{ name: 'sonar', desc: '轻量搜索', price: '$1/1', checked: false },
],
apiKey: '', baseUrl: ''
},
{
provider: 'deepinfra', label: 'DeepInfra', defaultBaseUrl: 'https://api.deepinfra.com/v1/openai',
models: [
{ name: 'meta-llama/Llama-3.3-70B-Instruct', desc: 'Llama 3.3 70B', price: '$0.35/0.35', checked: false },
{ name: 'deepseek-ai/DeepSeek-V3', desc: 'DeepSeek V3', price: '$0.89/0.89', checked: false },
{ name: 'Qwen/Qwen2.5-72B-Instruct', desc: '千问2.5 72B', price: '$0.35/0.35', checked: false },
{ name: 'microsoft/WizardLM-2-8x22B', desc: 'WizardLM 8x22B', price: '$0.5/0.5', checked: false },
],
apiKey: '', baseUrl: ''
},
{
provider: 'groq', label: 'Groq (极速推理)', defaultBaseUrl: 'https://api.groq.com/openai/v1',
models: [
{ name: 'llama-3.3-70b-versatile', desc: 'Llama 3.3 70B', price: '$0.59/0.79', checked: false },
{ name: 'llama-3.1-8b-instant', desc: 'Llama 3.1 8B', price: '$0.05/0.08', checked: false },
{ name: 'mixtral-8x7b-32768', desc: 'Mixtral 8x7B', price: '$0.24/0.24', checked: false },
{ name: 'gemma2-9b-it', desc: 'Gemma 2 9B', price: '$0.2/0.2', checked: false },
],
apiKey: '', baseUrl: ''
},
{
provider: 'google', label: 'Google Gemini', defaultBaseUrl: 'https://generativelanguage.googleapis.com/v1beta',
models: [
{ name: 'gemini-2.5-flash', desc: '极速响应', price: '$0.15/0.6', checked: false },
{ name: 'gemini-2.5-pro', desc: '最强旗舰', price: '$1.25/5', checked: false },
],
apiKey: '', baseUrl: ''
},
{
provider: 'openrouter', label: 'OpenRouter (代理)', defaultBaseUrl: 'https://openrouter.ai/api/v1',
models: [
// Claude 系列
{ name: 'anthropic/claude-sonnet-4-6', desc: 'Claude Sonnet 4.6', price: '$3/15', checked: false },
{ name: 'anthropic/claude-opus-4-6', desc: 'Claude Opus 4.6', price: '$15/75', checked: false },
{ name: 'anthropic/claude-3.5-sonnet', desc: 'Claude 3.5 Sonnet', price: '$3/15', checked: false },
// GPT 系列
{ name: 'openai/gpt-4o', desc: 'GPT-4o 多模态', price: '$2.5/10', checked: false },
{ name: 'openai/gpt-4o-mini', desc: 'GPT-4o Mini', price: '$0.15/0.6', checked: false },
{ name: 'openai/o3-mini', desc: 'o3-mini 推理', price: '$0.55/2.2', checked: false },
{ name: 'openai/o1', desc: 'o1 深度推理', price: '$15/60', checked: false },
// Gemini 系列
{ name: 'google/gemini-2.5-pro', desc: 'Gemini 2.5 Pro', price: '$1.25/5', checked: false },
{ name: 'google/gemini-2.5-flash', desc: 'Gemini 2.5 Flash', price: '$0.15/0.6', checked: false },
// 开源旗舰
{ name: 'meta-llama/llama-4-maverick', desc: 'Llama 4 Maverick', price: '$0.2/0.6', checked: false },
{ name: 'deepseek/deepseek-r1', desc: 'DeepSeek R1', price: '$0.55/2.19', checked: false },
{ name: 'deepseek/deepseek-chat', desc: 'DeepSeek V3', price: '$0.89/0.89', checked: false },
{ name: 'qwen/qwen3-235b-a22b', desc: '千问3 235B', price: '$0.3/0.4', checked: false },
{ name: 'mistralai/mistral-large', desc: 'Mistral Large', price: '$2/6', checked: false },
{ name: 'cohere/command-r-plus', desc: 'Command R+', price: '$2.5/10', checked: false },
],
apiKey: '', baseUrl: ''
},
{
provider: 'siliconflow', label: '硅基流动', defaultBaseUrl: 'https://api.siliconflow.cn/v1',
models: [
// 国产精选
{ name: 'deepseek-ai/DeepSeek-V3.2', desc: 'DeepSeek 最新旗舰', price: '¥1.33/1.33', checked: false },
{ name: 'deepseek-ai/DeepSeek-R1', desc: 'DeepSeek R1 推理', price: '¥4/16', checked: false },
{ name: 'Pro/zai-org/GLM-4.7', desc: 'GLM-4.7 最新', price: '¥1.33/1.33', checked: false },
{ name: 'Qwen/Qwen3-235B-A22B-Thinking', desc: '千问3 超大推理', price: '¥2.8/2.8', checked: false },
{ name: 'Qwen/Qwen2.5-72B-Instruct', desc: '千问2.5 72B', price: '¥1.33/1.33', checked: false },
{ name: 'Qwen/Qwen2.5-Coder-32B-Instruct', desc: '千问代码 32B', price: '¥1.33/1.33', checked: false },
// 国外开源旗舰
{ name: 'meta-llama/Meta-Llama-3.1-405B-Instruct', desc: 'Llama 3.1 405B 最强开源', price: '¥4/4', checked: false },
{ name: 'meta-llama/Llama-3.3-70B-Instruct', desc: 'Llama 3.3 70B', price: '¥1.33/1.33', checked: false },
{ name: 'meta-llama/Llama-3.2-90B-Vision-Instruct', desc: 'Llama 3.2 90B 多模态', price: '¥1.33/1.33', checked: false },
{ name: 'mistralai/Mixtral-8x22B-Instruct-v0.1', desc: 'Mixtral 8x22B MoE', price: '¥1.33/1.33', checked: false },
{ name: 'mistralai/Mixtral-8x7B-Instruct-v0.1', desc: 'Mixtral 8x7B MoE', price: '¥1.33/1.33', checked: false },
{ name: 'google/gemma-2-27b-it', desc: 'Gemma 2 27B Google', price: '¥1.33/1.33', checked: false },
{ name: 'google/gemma-2-9b-it', desc: 'Gemma 2 9B 免费', price: '免费', checked: false },
{ name: 'meta-llama/Meta-Llama-3.1-70B-Instruct', desc: 'Llama 3.1 70B', price: '¥1.33/1.33', checked: false },
{ name: 'meta-llama/Meta-Llama-3.1-8B-Instruct', desc: 'Llama 3.1 8B 轻量', price: '¥1.33/1.33', checked: false },
],
apiKey: '', baseUrl: ''
},
{
provider: 'mistral', label: 'Mistral AI', defaultBaseUrl: 'https://api.mistral.ai/v1',
models: [
{ name: 'mistral-small', desc: '轻量快速', price: '$0.2/0.6', checked: false },
{ name: 'mistral-large', desc: '旗舰模型', price: '$2/6', checked: false },
{ name: 'codestral', desc: '代码专用', price: '$0.3/0.9', checked: false },
],
apiKey: '', baseUrl: ''
},
]
}
const modelGroups = ref<ProviderGroup[]>(createModelGroups())
const totalSelected = computed(() => {
let n = 0
for (const g of modelGroups.value) n += g.models.filter(m => m.checked).length
return n
})
const providerDisplayMap: Record<string, string> = {
deepseek: 'DeepSeek', openai: 'OpenAI', anthropic: 'Anthropic',
qwen: '通义千问', zhipu: '智谱 GLM', baidu: '文心一言',
moonshot: 'Kimi', bytedance: '豆包', minimax: 'MiniMax',
openrouter: 'OpenRouter', siliconflow: '硅基流动', xunfei: '讯飞星火', hunyuan: '腾讯混元',
yi: '零一万物', baichuan: '百川',
cohere: 'Cohere', xai: 'xAI', together: 'Together', fireworks: 'Fireworks',
perplexity: 'Perplexity', deepinfra: 'DeepInfra', groq: 'Groq',
google: 'Gemini', mistral: 'Mistral', local: '本地',
}
function providerDisplay(p: string) {
return providerDisplayMap[p] || p.toUpperCase()
}
// 测试
const testDialogVisible = ref(false)
@@ -221,33 +598,14 @@ const testing = ref(false)
const testResult = ref<any>(null)
const currentTestConfig = ref<ModelConfig | null>(null)
const providerModelOptions = computed(() => {
const provider = form.value.provider
if (provider === 'openai') {
return ['gpt-4o', 'gpt-4-turbo-preview', 'gpt-4', 'gpt-3.5-turbo']
}
if (provider === 'deepseek') {
return ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner', 'deepseek-coder']
}
if (provider === 'anthropic') {
return ['claude-3-5-sonnet-latest', 'claude-3-opus-20240229']
}
return []
})
// 表单验证规则
// 表单验证规则(仅编辑模式使用)
const rules = {
name: [
{ required: true, message: '请输入配置名称', trigger: 'blur' }
],
provider: [
{ required: true, message: '请选择提供商', trigger: 'change' }
],
model_name: [
{ required: true, message: '请输入模型名称', trigger: 'blur' }
],
api_key: [
{ required: true, message: '请输入API密钥', trigger: 'blur' }
]
}
@@ -289,13 +647,8 @@ const loadModelConfigs = async () => {
const handleCreate = () => {
dialogTitle.value = '创建模型配置'
currentConfigId.value = null
form.value = {
name: '',
provider: 'openai',
model_name: '',
api_key: '',
base_url: ''
}
customNamePrefix.value = ''
modelGroups.value = createModelGroups()
dialogVisible.value = true
}
@@ -359,33 +712,68 @@ const handleDelete = async (config: ModelConfig) => {
}
}
// 提交表单
// 批量创建
const handleBatchCreate = async () => {
if (totalSelected.value === 0) {
ElMessage.warning('请至少选择一个模型')
return
}
submitting.value = true
let created = 0
try {
const prefix = customNamePrefix.value.trim()
for (const group of modelGroups.value) {
const selected = group.models.filter(m => m.checked)
if (selected.length === 0) continue
const apiKey = group.apiKey.trim()
if (!apiKey) {
ElMessage.warning(`请填写 ${group.label} 的 API 密钥`)
submitting.value = false
return
}
const baseUrl = group.baseUrl.trim() || group.defaultBaseUrl
for (const model of selected) {
const name = prefix ? `${prefix} ${group.label} ${model.name}` : `${group.label} ${model.name}`
await modelConfigStore.createModelConfig({
name,
provider: group.provider,
model_name: model.name,
api_key: apiKey,
base_url: baseUrl,
})
created++
}
}
ElMessage.success(`成功创建 ${created} 个模型配置`)
dialogVisible.value = false
await loadModelConfigs()
} catch (error: any) {
ElMessage.error(error.response?.data?.detail || '批量创建失败')
} finally {
submitting.value = false
}
}
// 提交表单(编辑模式)
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid: boolean) => {
if (!valid) return
submitting.value = true
try {
if (currentConfigId.value) {
// 更新如果api_key为空则不更新
const updateData: any = {
name: form.value.name,
provider: form.value.provider,
model_name: form.value.model_name,
base_url: form.value.base_url || null
}
if (form.value.api_key) {
updateData.api_key = form.value.api_key
}
await modelConfigStore.updateModelConfig(currentConfigId.value, updateData)
ElMessage.success('更新成功')
} else {
// 创建
await modelConfigStore.createModelConfig(form.value)
ElMessage.success('创建成功')
const updateData: any = {
name: form.value.name,
provider: form.value.provider,
model_name: form.value.model_name,
base_url: form.value.base_url || null
}
if (form.value.api_key) {
updateData.api_key = form.value.api_key
}
await modelConfigStore.updateModelConfig(currentConfigId.value!, updateData)
ElMessage.success('更新成功')
dialogVisible.value = false
await loadModelConfigs()
} catch (error: any) {
@@ -439,4 +827,103 @@ onMounted(() => {
display: flex;
justify-content: flex-end;
}
/* ── 创建面板 ── */
.create-panel {
max-height: 60vh;
overflow-y: auto;
}
.create-hint {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 14px;
background: #f0f7ff;
border-radius: 8px;
font-size: 13px;
color: #409eff;
margin-bottom: 16px;
}
.name-prefix-row {
margin-bottom: 8px;
}
.prefix-hint {
margin-left: 12px;
font-size: 12px;
color: #909399;
}
.provider-group {
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.provider-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.provider-name {
font-size: 16px;
font-weight: 700;
color: #303133;
}
.provider-count {
font-size: 13px;
color: #909399;
}
.model-check-grid {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.model-check-item {
margin-right: 0 !important;
padding: 6px 12px;
border: 1px solid #e4e7ed;
border-radius: 6px;
background: #fafafa;
transition: all 0.2s;
}
.model-check-item:hover {
border-color: #409eff;
background: #f0f7ff;
}
.model-check-item.is-checked {
border-color: #409eff;
background: #ecf5ff;
}
.model-name {
font-weight: 500;
font-size: 13px;
font-family: monospace;
}
.model-desc {
margin-left: 6px;
font-size: 12px;
color: #909399;
}
.model-price {
margin-left: 6px;
font-size: 11px;
color: #e6a23c;
font-weight: 500;
white-space: nowrap;
}
.provider-config {
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed #dcdfe6;
}
.create-summary {
display: flex;
align-items: center;
gap: 6px;
padding: 12px 16px;
background: #ecf5ff;
border-radius: 8px;
font-size: 14px;
color: #409eff;
}
</style>

View File

@@ -0,0 +1,204 @@
<template>
<MainLayout>
<div class="profile-page">
<h3>个人设置</h3>
<!-- 基本信息 -->
<el-card class="section-card">
<template #header><span>基本信息</span></template>
<el-descriptions :column="2" border>
<el-descriptions-item label="用户名">{{ user?.username }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ user?.email }}</el-descriptions-item>
<el-descriptions-item label="角色">{{ user?.role === 'admin' ? '管理员' : '用户' }}</el-descriptions-item>
<el-descriptions-item label="注册时间">{{ user?.created_at?.split('T')[0] || '-' }}</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- API Key 管理 -->
<el-card class="section-card">
<template #header>
<div class="card-header">
<span>API 密钥</span>
<el-button type="primary" size="small" @click="showCreateDialog">+ 创建密钥</el-button>
</div>
</template>
<div v-if="apiKeys.length === 0" class="empty-hint">暂无 API 密钥点击上方按钮创建</div>
<el-table v-else :data="apiKeys" style="width:100%">
<el-table-column prop="name" label="名称" min-width="140" />
<el-table-column prop="key_prefix" label="前缀" width="100">
<template #default="{ row }">
<el-tag size="small">{{ row.key_prefix }}</el-tag>
</template>
</el-table-column>
<el-table-column label="权限" min-width="120">
<template #default="{ row }">
<el-tag v-if="!row.permissions" type="success" size="small">全部权限</el-tag>
<template v-else>
<el-tag v-for="p in row.permissions" :key="p" size="small" style="margin-right:4px">{{ p }}</el-tag>
</template>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'info'" size="small">
{{ row.status === 'active' ? '生效' : '已撤销' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="过期时间" width="120">
<template #default="{ row }">
{{ row.expires_at ? row.expires_at.split('T')[0] : '永不过期' }}
</template>
</el-table-column>
<el-table-column label="最后使用" width="120">
<template #default="{ row }">
{{ row.last_used_at ? formatTime(row.last_used_at) : '未使用' }}
</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button v-if="row.status === 'active'" type="danger" link size="small" @click="revokeKey(row.id)">
撤销
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
<!-- 创建密钥对话框 -->
<el-dialog v-model="createVisible" title="创建 API 密钥" width="500px" @closed="resetCreateForm">
<el-form :model="createForm" label-width="80px">
<el-form-item label="名称" required>
<el-input v-model="createForm.name" placeholder="如:生产环境、测试脚本" maxlength="100" />
</el-form-item>
<el-form-item label="过期时间">
<el-select v-model="createForm.expires_in_days" placeholder="永不过期" clearable style="width:100%">
<el-option label="30 天" :value="30" />
<el-option label="90 天" :value="90" />
<el-option label="1 年" :value="365" />
<el-option label="永不过期" :value="null" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :disabled="!createForm.name.trim()" @click="createKey">创建</el-button>
</template>
</el-dialog>
<!-- 创建成功对话框 展示明文密钥 -->
<el-dialog v-model="createdVisible" title="密钥创建成功" width="520px" :close-on-click-modal="false">
<el-alert type="warning" :closable="false" show-icon style="margin-bottom:16px">
<template #title>请立即复制并保存此密钥关闭后无法再次查看</template>
</el-alert>
<el-input v-model="createdKey" readonly style="font-family:monospace;font-size:13px">
<template #append>
<el-button @click="copyKey">复制</el-button>
</template>
</el-input>
<template #footer>
<el-button type="primary" @click="createdVisible = false">我已保存关闭</el-button>
</template>
</el-dialog>
</MainLayout>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import MainLayout from '@/components/MainLayout.vue'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const user = ref(userStore.user)
interface ApiKey {
id: string; name: string; key_prefix: string; permissions: string[] | null
last_used_at: string | null; expires_at: string | null; status: string; created_at: string
}
const apiKeys = ref<ApiKey[]>([])
const createVisible = ref(false)
const createdVisible = ref(false)
const createdKey = ref('')
const createForm = ref({ name: '', expires_in_days: null as number | null })
const baseUrl = import.meta.env.DEV ? '' : `${window.location.protocol}//${window.location.hostname}:8037`
const token = () => localStorage.getItem('token') || ''
async function fetchKeys() {
try {
const resp = await fetch(`${baseUrl}/api/v1/api-keys`, {
headers: { Authorization: `Bearer ${token()}` }
})
if (resp.ok) apiKeys.value = await resp.json()
} catch { /* ignore */ }
}
async function createKey() {
if (!createForm.value.name.trim()) return
try {
const resp = await fetch(`${baseUrl}/api/v1/api-keys`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token()}` },
body: JSON.stringify({
name: createForm.value.name.trim(),
expires_in_days: createForm.value.expires_in_days || null
})
})
if (!resp.ok) {
const err = await resp.json()
ElMessage.error(err.detail || '创建失败')
return
}
const data = await resp.json()
createdKey.value = data.api_key
createVisible.value = false
createdVisible.value = true
fetchKeys()
} catch { ElMessage.error('请求失败') }
}
async function revokeKey(id: string) {
try {
await ElMessageBox.confirm('撤销后该密钥立即失效,确认撤销?', '确认撤销', { type: 'warning' })
} catch { return }
try {
const resp = await fetch(`${baseUrl}/api/v1/api-keys/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token()}` }
})
if (resp.ok) {
ElMessage.success('已撤销')
fetchKeys()
} else {
const err = await resp.json()
ElMessage.error(err.detail || '撤销失败')
}
} catch { ElMessage.error('请求失败') }
}
function copyKey() {
navigator.clipboard.writeText(createdKey.value).then(() => ElMessage.success('已复制'))
}
function resetCreateForm() {
createForm.value = { name: '', expires_in_days: null }
}
function formatTime(iso: string) {
const d = new Date(iso)
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
}
onMounted(() => fetchKeys())
</script>
<style scoped>
.profile-page { max-width: 800px; margin: 0 auto; padding: 20px; }
.profile-page h3 { margin-bottom: 20px; }
.section-card { margin-bottom: 20px; }
.card-header { display: flex; justify-content: space-between; align-items: center; }
.empty-hint { color: #999; text-align: center; padding: 40px 0; }
</style>