feat: 支持API导入

This commit is contained in:
digua
2026-03-26 20:38:47 +08:00
committed by digua
parent 6d5e6f6e7a
commit 7ce50eab7c
21 changed files with 2100 additions and 97 deletions
+21
View File
@@ -433,6 +433,27 @@
"noToken": "No token generated yet. Please enable the service first",
"regenerate": "Regenerate"
},
"dataSources": {
"title": "Data Sources",
"desc": "Configure external data source URLs. ChatLab will automatically pull and import chat data at the specified interval.",
"empty": "No data sources",
"disabled": "Paused",
"every": "Every",
"minutes": "min",
"lastSync": "Last sync",
"addBtn": "Add Data Source",
"form": {
"name": "Name",
"namePlaceholder": "e.g., My Chat Server",
"url": "Data Source URL",
"token": "Access Token (optional)",
"tokenPlaceholder": "Bearer Token for the remote API",
"interval": "Pull Interval (minutes)",
"targetSession": "Target Session ID (optional)",
"targetSessionPlaceholder": "Leave empty to create new session each time",
"add": "Add"
}
},
"usage": {
"title": "Usage Guide",
"desc": "After enabling the service, use the following endpoints to query data. All requests require Bearer Token authentication.",
+21
View File
@@ -433,6 +433,27 @@
"noToken": "トークンが未生成です。まずサービスを有効にしてください",
"regenerate": "再生成"
},
"dataSources": {
"title": "データソース",
"desc": "外部データソースの URL を設定すると、ChatLab が指定間隔で自動的にチャットデータを取得・インポートします。",
"empty": "データソースなし",
"disabled": "一時停止中",
"every": "",
"minutes": "分ごと",
"lastSync": "最終同期",
"addBtn": "データソースを追加",
"form": {
"name": "名前",
"namePlaceholder": "例:マイチャットサーバー",
"url": "データソース URL",
"token": "アクセストークン(任意)",
"tokenPlaceholder": "リモート API の Bearer Token",
"interval": "取得間隔(分)",
"targetSession": "ターゲットセッション ID(任意)",
"targetSessionPlaceholder": "空欄の場合、毎回新規セッションを作成",
"add": "追加"
}
},
"usage": {
"title": "使用ガイド",
"desc": "サービスを有効にした後、以下のエンドポイントでデータを照会できます。すべてのリクエストに Bearer Token 認証が必要です。",
+21
View File
@@ -433,6 +433,27 @@
"noToken": "尚未生成 Token,请先启用服务",
"regenerate": "重新生成"
},
"dataSources": {
"title": "数据源",
"desc": "配置外部数据源 URL,ChatLab 将按设定间隔自动拉取并导入聊天数据。",
"empty": "暂无数据源",
"disabled": "已暂停",
"every": "每",
"minutes": "分钟",
"lastSync": "上次同步",
"addBtn": "添加数据源",
"form": {
"name": "名称",
"namePlaceholder": "例如:我的聊天服务器",
"url": "数据源 URL",
"token": "访问令牌(可选)",
"tokenPlaceholder": "远程 API 的 Bearer Token",
"interval": "拉取间隔(分钟)",
"targetSession": "目标会话 ID(可选)",
"targetSessionPlaceholder": "留空则每次新建会话",
"add": "添加"
}
},
"usage": {
"title": "使用说明",
"desc": "启用服务后,可使用以下端点查询数据。所有请求需携带 Bearer Token 认证。",
+21
View File
@@ -433,6 +433,27 @@
"noToken": "尚未產生 Token,請先啟用服務",
"regenerate": "重新產生"
},
"dataSources": {
"title": "資料來源",
"desc": "設定外部資料來源 URL,ChatLab 將按設定間隔自動拉取並匯入聊天資料。",
"empty": "暫無資料來源",
"disabled": "已暫停",
"every": "每",
"minutes": "分鐘",
"lastSync": "上次同步",
"addBtn": "新增資料來源",
"form": {
"name": "名稱",
"namePlaceholder": "例如:我的聊天伺服器",
"url": "資料來源 URL",
"token": "存取權杖(可選)",
"tokenPlaceholder": "遠端 API 的 Bearer Token",
"interval": "拉取間隔(分鐘)",
"targetSession": "目標會話 ID(可選)",
"targetSessionPlaceholder": "留空則每次新建會話",
"add": "新增"
}
},
"usage": {
"title": "使用說明",
"desc": "啟用服務後,可使用以下端點查詢數據。所有請求需攜帶 Bearer Token 認證。",
+183 -57
View File
@@ -1,28 +1,41 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { ref, reactive, onMounted, onUnmounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useApiServerStore } from '@/stores/apiServer'
import { useApiServerStore, type DataSource } from '@/stores/apiServer'
import { storeToRefs } from 'pinia'
const { t } = useI18n()
const store = useApiServerStore()
const { config, status, loading, isRunning, hasError, isPortInUse } = storeToRefs(store)
const { config, status, loading, isRunning, hasError, isPortInUse, dataSources, pullingId } = storeToRefs(store)
const tokenVisible = ref(false)
const editingPort = ref(false)
const portInput = ref(5200)
const copied = ref(false)
const showAddForm = ref(false)
const newSource = reactive({
name: '',
url: '',
token: '',
intervalMinutes: 60,
enabled: true,
targetSessionId: '',
})
let unlistenStartupError: (() => void) | null = null
let unlistenPullResult: (() => void) | null = null
onMounted(async () => {
await store.refresh()
portInput.value = config.value.port
unlistenStartupError = store.listenStartupError()
unlistenPullResult = store.listenPullResult()
})
onUnmounted(() => {
unlistenStartupError?.()
unlistenPullResult?.()
})
const maskedToken = computed(() => {
@@ -71,14 +84,47 @@ async function copyToken() {
await navigator.clipboard.writeText(config.value.token)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
} catch {
// fallback
}
} catch { /* fallback */ }
}
async function handleRegenerateToken() {
await store.regenerateToken()
}
// ==================== 数据源管理 ====================
function resetNewSource() {
newSource.name = ''
newSource.url = ''
newSource.token = ''
newSource.intervalMinutes = 60
newSource.enabled = true
newSource.targetSessionId = ''
showAddForm.value = false
}
async function addSource() {
if (!newSource.name || !newSource.url) return
await store.addDataSource({ ...newSource })
resetNewSource()
}
async function toggleSourceEnabled(ds: DataSource) {
await store.updateDataSource(ds.id, { enabled: !ds.enabled })
}
async function removeSource(ds: DataSource) {
await store.deleteDataSource(ds.id)
}
async function pullNow(ds: DataSource) {
await store.triggerPull(ds.id)
}
function formatTime(ts: number): string {
if (!ts) return '-'
return new Date(ts * 1000).toLocaleString()
}
</script>
<template>
@@ -101,8 +147,6 @@ async function handleRegenerateToken() {
</div>
<USwitch :model-value="config.enabled" :loading="loading" @update:model-value="toggleEnabled" />
</div>
<!-- 运行状态 -->
<div v-if="config.enabled" class="mt-3 flex items-center gap-2 border-t border-gray-200 pt-3 dark:border-gray-700">
<span class="inline-block h-2 w-2 rounded-full" :class="isRunning ? 'bg-green-500' : hasError ? 'bg-red-500' : 'bg-gray-400'"></span>
<span class="text-xs" :class="statusColor">{{ statusText }}</span>
@@ -110,8 +154,6 @@ async function handleRegenerateToken() {
{{ apiBaseUrl }}
</span>
</div>
<!-- 端口占用提示 -->
<div v-if="isPortInUse" class="mt-2 rounded-md bg-red-50 p-2 text-xs text-red-600 dark:bg-red-900/20 dark:text-red-400">
{{ t('settings.api.service.portInUseHint') }}
</div>
@@ -127,27 +169,17 @@ async function handleRegenerateToken() {
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
<div class="flex items-center justify-between">
<div class="flex-1 pr-4">
<p class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('settings.api.port.label') }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('settings.api.port.desc') }}
</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('settings.api.port.label') }}</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('settings.api.port.desc') }}</p>
</div>
<div v-if="editingPort" class="flex items-center gap-2">
<UInput v-model.number="portInput" type="number" :min="1024" :max="65535" size="sm" class="w-24" />
<UButton size="xs" color="primary" :loading="loading" @click="savePort">
{{ t('settings.api.port.save') }}
</UButton>
<UButton size="xs" variant="ghost" @click="cancelPortEdit">
{{ t('settings.api.port.cancel') }}
</UButton>
<UButton size="xs" color="primary" :loading="loading" @click="savePort">{{ t('settings.api.port.save') }}</UButton>
<UButton size="xs" variant="ghost" @click="cancelPortEdit">{{ t('settings.api.port.cancel') }}</UButton>
</div>
<div v-else class="flex items-center gap-2">
<span class="text-sm font-mono text-gray-700 dark:text-gray-300">{{ config.port }}</span>
<UButton size="xs" variant="ghost" @click="editingPort = true; portInput = config.port">
{{ t('settings.api.port.edit') }}
</UButton>
<UButton size="xs" variant="ghost" @click="editingPort = true; portInput = config.port">{{ t('settings.api.port.edit') }}</UButton>
</div>
</div>
</div>
@@ -160,34 +192,132 @@ async function handleRegenerateToken() {
{{ t('settings.api.token.title') }}
</h3>
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
<div>
<p class="mb-2 text-sm font-medium text-gray-900 dark:text-white">
{{ t('settings.api.token.label') }}
</p>
<p class="mb-3 text-xs text-gray-500 dark:text-gray-400">
{{ t('settings.api.token.desc') }}
</p>
<div v-if="config.token" class="flex items-center gap-2">
<code class="flex-1 rounded bg-gray-100 px-3 py-2 font-mono text-xs text-gray-700 dark:bg-gray-800 dark:text-gray-300">
{{ tokenVisible ? config.token : maskedToken }}
</code>
<UButton size="xs" variant="ghost" @click="tokenVisible = !tokenVisible">
<UIcon :name="tokenVisible ? 'i-heroicons-eye-slash' : 'i-heroicons-eye'" class="h-4 w-4" />
</UButton>
<UButton size="xs" variant="ghost" @click="copyToken">
<UIcon :name="copied ? 'i-heroicons-check' : 'i-heroicons-clipboard'" class="h-4 w-4" />
</UButton>
</div>
<div v-else class="text-xs text-gray-400">
{{ t('settings.api.token.noToken') }}
</div>
<div class="mt-3">
<UButton size="xs" variant="soft" color="warning" @click="handleRegenerateToken">
<UIcon name="i-heroicons-arrow-path" class="mr-1 h-3 w-3" />
{{ t('settings.api.token.regenerate') }}
</UButton>
<p class="mb-2 text-sm font-medium text-gray-900 dark:text-white">{{ t('settings.api.token.label') }}</p>
<p class="mb-3 text-xs text-gray-500 dark:text-gray-400">{{ t('settings.api.token.desc') }}</p>
<div v-if="config.token" class="flex items-center gap-2">
<code class="flex-1 rounded bg-gray-100 px-3 py-2 font-mono text-xs text-gray-700 dark:bg-gray-800 dark:text-gray-300">
{{ tokenVisible ? config.token : maskedToken }}
</code>
<UButton size="xs" variant="ghost" @click="tokenVisible = !tokenVisible">
<UIcon :name="tokenVisible ? 'i-heroicons-eye-slash' : 'i-heroicons-eye'" class="h-4 w-4" />
</UButton>
<UButton size="xs" variant="ghost" @click="copyToken">
<UIcon :name="copied ? 'i-heroicons-check' : 'i-heroicons-clipboard'" class="h-4 w-4" />
</UButton>
</div>
<div v-else class="text-xs text-gray-400">{{ t('settings.api.token.noToken') }}</div>
<div class="mt-3">
<UButton size="xs" variant="soft" color="warning" @click="handleRegenerateToken">
<UIcon name="i-heroicons-arrow-path" class="mr-1 h-3 w-3" />
{{ t('settings.api.token.regenerate') }}
</UButton>
</div>
</div>
</div>
<!-- 数据源管理 -->
<div>
<h3 class="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<UIcon name="i-heroicons-cloud-arrow-down" class="h-4 w-4 text-indigo-500" />
{{ t('settings.api.dataSources.title') }}
</h3>
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
<p class="mb-3 text-xs text-gray-500 dark:text-gray-400">
{{ t('settings.api.dataSources.desc') }}
</p>
<!-- 数据源列表 -->
<div v-if="dataSources.length > 0" class="mb-4 space-y-3">
<div
v-for="ds in dataSources"
:key="ds.id"
class="rounded-lg border border-gray-200 bg-white p-3 dark:border-gray-600 dark:bg-gray-800"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span
class="inline-block h-2 w-2 rounded-full"
:class="ds.lastStatus === 'success' ? 'bg-green-500' : ds.lastStatus === 'error' ? 'bg-red-500' : 'bg-gray-400'"
></span>
<span class="text-sm font-medium text-gray-900 dark:text-white">{{ ds.name }}</span>
<span v-if="!ds.enabled" class="text-xs text-gray-400">({{ t('settings.api.dataSources.disabled') }})</span>
</div>
<div class="flex items-center gap-1">
<UButton
size="xs"
variant="ghost"
:loading="pullingId === ds.id"
@click="pullNow(ds)"
>
<UIcon name="i-heroicons-arrow-path" class="h-3.5 w-3.5" />
</UButton>
<UButton size="xs" variant="ghost" @click="toggleSourceEnabled(ds)">
<UIcon :name="ds.enabled ? 'i-heroicons-pause' : 'i-heroicons-play'" class="h-3.5 w-3.5" />
</UButton>
<UButton size="xs" variant="ghost" color="error" @click="removeSource(ds)">
<UIcon name="i-heroicons-trash" class="h-3.5 w-3.5" />
</UButton>
</div>
</div>
<div class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
<span class="font-mono">{{ ds.url }}</span>
<span class="mx-2">·</span>
<span>{{ t('settings.api.dataSources.every') }} {{ ds.intervalMinutes }} {{ t('settings.api.dataSources.minutes') }}</span>
</div>
<div v-if="ds.lastPullAt" class="mt-1 text-xs text-gray-400">
{{ t('settings.api.dataSources.lastSync') }}: {{ formatTime(ds.lastPullAt) }}
<span v-if="ds.lastStatus === 'success'" class="text-green-500">
(+{{ ds.lastNewMessages }})
</span>
<span v-if="ds.lastStatus === 'error'" class="text-red-500">
{{ ds.lastError }}
</span>
</div>
</div>
</div>
<div v-else class="mb-4 text-center text-xs text-gray-400 py-4">
{{ t('settings.api.dataSources.empty') }}
</div>
<!-- 添加表单 -->
<div v-if="showAddForm" class="rounded-lg border border-blue-200 bg-blue-50/50 p-3 dark:border-blue-800 dark:bg-blue-900/20">
<div class="space-y-3">
<div>
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">{{ t('settings.api.dataSources.form.name') }}</label>
<UInput v-model="newSource.name" size="sm" :placeholder="t('settings.api.dataSources.form.namePlaceholder')" />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">{{ t('settings.api.dataSources.form.url') }}</label>
<UInput v-model="newSource.url" size="sm" placeholder="https://example.com/api/export" />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">{{ t('settings.api.dataSources.form.token') }}</label>
<UInput v-model="newSource.token" size="sm" :placeholder="t('settings.api.dataSources.form.tokenPlaceholder')" />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">{{ t('settings.api.dataSources.form.interval') }}</label>
<UInput v-model.number="newSource.intervalMinutes" type="number" :min="1" size="sm" class="w-32" />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">{{ t('settings.api.dataSources.form.targetSession') }}</label>
<UInput v-model="newSource.targetSessionId" size="sm" :placeholder="t('settings.api.dataSources.form.targetSessionPlaceholder')" />
</div>
<div class="flex items-center gap-2 pt-1">
<UButton size="xs" color="primary" :disabled="!newSource.name || !newSource.url" @click="addSource">
{{ t('settings.api.dataSources.form.add') }}
</UButton>
<UButton size="xs" variant="ghost" @click="resetNewSource">
{{ t('settings.api.port.cancel') }}
</UButton>
</div>
</div>
</div>
<UButton v-if="!showAddForm" size="xs" variant="soft" @click="showAddForm = true">
<UIcon name="i-heroicons-plus" class="mr-1 h-3 w-3" />
{{ t('settings.api.dataSources.addBtn') }}
</UButton>
</div>
</div>
@@ -198,9 +328,7 @@ async function handleRegenerateToken() {
{{ t('settings.api.usage.title') }}
</h3>
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
<p class="mb-3 text-xs text-gray-600 dark:text-gray-400">
{{ t('settings.api.usage.desc') }}
</p>
<p class="mb-3 text-xs text-gray-600 dark:text-gray-400">{{ t('settings.api.usage.desc') }}</p>
<div class="space-y-2">
<div class="rounded bg-gray-100 p-2 font-mono text-xs text-gray-700 dark:bg-gray-800 dark:text-gray-300">
<span class="text-green-600 dark:text-green-400">GET</span> {{ apiBaseUrl }}/status
@@ -215,9 +343,7 @@ async function handleRegenerateToken() {
<span class="text-blue-600 dark:text-blue-400">POST</span> {{ apiBaseUrl }}/sessions/:id/sql
</div>
</div>
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">
{{ t('settings.api.usage.authHint') }}
</p>
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">{{ t('settings.api.usage.authHint') }}</p>
<div class="mt-1 rounded bg-gray-100 p-2 font-mono text-xs text-gray-700 dark:bg-gray-800 dark:text-gray-300">
Authorization: Bearer {{ config.token ? maskedToken : 'clb_...' }}
</div>
+94 -1
View File
@@ -19,6 +19,21 @@ export interface ApiServerStatus {
error: string | null
}
export interface DataSource {
id: string
name: string
url: string
token: string
intervalMinutes: number
enabled: boolean
targetSessionId: string
lastPullAt: number
lastStatus: 'idle' | 'success' | 'error'
lastError: string
lastNewMessages: number
createdAt: number
}
export const useApiServerStore = defineStore('apiServer', () => {
const config = ref<ApiServerConfig>({
enabled: false,
@@ -35,6 +50,8 @@ export const useApiServerStore = defineStore('apiServer', () => {
})
const loading = ref(false)
const dataSources = ref<DataSource[]>([])
const pullingId = ref<string | null>(null)
const isRunning = computed(() => status.value.running)
const hasError = computed(() => !!status.value.error)
@@ -57,7 +74,7 @@ export const useApiServerStore = defineStore('apiServer', () => {
}
async function refresh() {
await Promise.all([fetchConfig(), fetchStatus()])
await Promise.all([fetchConfig(), fetchStatus(), fetchDataSources()])
}
async function setEnabled(enabled: boolean) {
@@ -99,10 +116,80 @@ export const useApiServerStore = defineStore('apiServer', () => {
})
}
// ==================== 数据源管理 ====================
async function fetchDataSources() {
try {
dataSources.value = await window.apiServerApi.getDataSources()
} catch (err) {
console.error('[ApiServerStore] Failed to fetch data sources:', err)
}
}
async function addDataSource(partial: Omit<DataSource, 'id' | 'createdAt' | 'lastPullAt' | 'lastStatus' | 'lastError' | 'lastNewMessages'>) {
try {
const ds = await window.apiServerApi.addDataSource(partial)
dataSources.value.push(ds)
return ds
} catch (err) {
console.error('[ApiServerStore] Failed to add data source:', err)
return null
}
}
async function updateDataSource(id: string, updates: Partial<DataSource>) {
try {
const ds = await window.apiServerApi.updateDataSource(id, updates)
if (ds) {
const idx = dataSources.value.findIndex((s) => s.id === id)
if (idx !== -1) dataSources.value[idx] = ds
}
return ds
} catch (err) {
console.error('[ApiServerStore] Failed to update data source:', err)
return null
}
}
async function deleteDataSource(id: string) {
try {
const ok = await window.apiServerApi.deleteDataSource(id)
if (ok) {
dataSources.value = dataSources.value.filter((s) => s.id !== id)
}
return ok
} catch (err) {
console.error('[ApiServerStore] Failed to delete data source:', err)
return false
}
}
async function triggerPull(id: string) {
pullingId.value = id
try {
const result = await window.apiServerApi.triggerPull(id)
await fetchDataSources()
return result
} catch (err) {
console.error('[ApiServerStore] Failed to trigger pull:', err)
return { success: false, error: String(err) }
} finally {
pullingId.value = null
}
}
function listenPullResult() {
return window.apiServerApi.onPullResult(() => {
fetchDataSources()
})
}
return {
config,
status,
loading,
dataSources,
pullingId,
isRunning,
hasError,
isPortInUse,
@@ -113,5 +200,11 @@ export const useApiServerStore = defineStore('apiServer', () => {
setPort,
regenerateToken,
listenStartupError,
fetchDataSources,
addDataSource,
updateDataSource,
deleteDataSource,
triggerPull,
listenPullResult,
}
})