forked from kevin/meshtastic_mqtt_server
统一 Admin 管理后台样式:全部页面改为 100% 全宽,表格长文本列自动换行去除横向滚动,LLM 队列/API 配置页重做为全局 panel 风格,前端 v0.3.2
This commit is contained in:
@@ -458,36 +458,36 @@ onMounted(() => {
|
||||
<p v-if="wordError" class="error">{{ wordError }}</p>
|
||||
<p v-if="wordMessage" class="success">{{ wordMessage }}</p>
|
||||
|
||||
<div class="node-table-wrap">
|
||||
<div class="node-table-wrap table-fit-wrap">
|
||||
<table class="node-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th class="cell-nowrap">ID</th>
|
||||
<th>违禁词</th>
|
||||
<th>匹配类型</th>
|
||||
<th>区分大小写</th>
|
||||
<th class="cell-nowrap">匹配类型</th>
|
||||
<th class="cell-nowrap">区分大小写</th>
|
||||
<th>原因</th>
|
||||
<th>启用</th>
|
||||
<th>创建时间</th>
|
||||
<th>更新时间</th>
|
||||
<th>操作</th>
|
||||
<th class="cell-nowrap">启用</th>
|
||||
<th class="cell-nowrap">创建时间</th>
|
||||
<th class="cell-nowrap">更新时间</th>
|
||||
<th class="cell-nowrap">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="rule in wordRules" :key="rule.id">
|
||||
<td>{{ rule.id }}</td>
|
||||
<td class="cell-nowrap">{{ rule.id }}</td>
|
||||
<td><input v-model="wordEdits[rule.id].word" class="admin-table-input" /></td>
|
||||
<td>
|
||||
<select v-model="wordEdits[rule.id].match_type" class="admin-table-input">
|
||||
<option value="contains">包含</option>
|
||||
</select>
|
||||
</td>
|
||||
<td><input v-model="wordEdits[rule.id].case_sensitive" type="checkbox" /></td>
|
||||
<td class="cell-nowrap"><input v-model="wordEdits[rule.id].case_sensitive" type="checkbox" /></td>
|
||||
<td><input v-model="wordEdits[rule.id].reason" class="admin-table-input" /></td>
|
||||
<td><input v-model="wordEdits[rule.id].enabled" type="checkbox" /></td>
|
||||
<td>{{ formatTime(rule.created_at) }}</td>
|
||||
<td>{{ formatTime(rule.updated_at) }}</td>
|
||||
<td>
|
||||
<td class="cell-nowrap"><input v-model="wordEdits[rule.id].enabled" type="checkbox" /></td>
|
||||
<td class="cell-nowrap">{{ formatTime(rule.created_at) }}</td>
|
||||
<td class="cell-nowrap">{{ formatTime(rule.updated_at) }}</td>
|
||||
<td class="cell-nowrap">
|
||||
<button class="admin-button" :disabled="wordLoading" @click="saveWordRule(rule)">保存</button>
|
||||
<button class="admin-button" :disabled="wordLoading" @click="removeWordRule(rule)">删除</button>
|
||||
</td>
|
||||
@@ -533,30 +533,30 @@ onMounted(() => {
|
||||
<p v-if="nodeError" class="error">{{ nodeError }}</p>
|
||||
<p v-if="nodeMessage" class="success">{{ nodeMessage }}</p>
|
||||
|
||||
<div class="node-table-wrap">
|
||||
<div class="node-table-wrap table-fit-wrap">
|
||||
<table class="node-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th class="cell-nowrap">ID</th>
|
||||
<th>节点 ID</th>
|
||||
<th>数字 ID</th>
|
||||
<th class="cell-nowrap">数字 ID</th>
|
||||
<th>原因</th>
|
||||
<th>启用</th>
|
||||
<th>创建时间</th>
|
||||
<th>更新时间</th>
|
||||
<th>操作</th>
|
||||
<th class="cell-nowrap">启用</th>
|
||||
<th class="cell-nowrap">创建时间</th>
|
||||
<th class="cell-nowrap">更新时间</th>
|
||||
<th class="cell-nowrap">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="rule in nodeRules" :key="rule.id">
|
||||
<td>{{ rule.id }}</td>
|
||||
<td class="cell-nowrap">{{ rule.id }}</td>
|
||||
<td><input v-model="nodeEdits[rule.id].node_id" class="admin-table-input" /></td>
|
||||
<td><input v-model="nodeEdits[rule.id].node_num" class="admin-table-input" placeholder="可选" /></td>
|
||||
<td><input v-model="nodeEdits[rule.id].reason" class="admin-table-input" /></td>
|
||||
<td><input v-model="nodeEdits[rule.id].enabled" type="checkbox" /></td>
|
||||
<td>{{ formatTime(rule.created_at) }}</td>
|
||||
<td>{{ formatTime(rule.updated_at) }}</td>
|
||||
<td>
|
||||
<td class="cell-nowrap"><input v-model="nodeEdits[rule.id].enabled" type="checkbox" /></td>
|
||||
<td class="cell-nowrap">{{ formatTime(rule.created_at) }}</td>
|
||||
<td class="cell-nowrap">{{ formatTime(rule.updated_at) }}</td>
|
||||
<td class="cell-nowrap">
|
||||
<button class="admin-button" :disabled="nodeLoading" @click="saveNodeRule(rule)">保存</button>
|
||||
<button class="admin-button" :disabled="nodeLoading" @click="removeNodeRule(rule)">删除</button>
|
||||
</td>
|
||||
@@ -599,28 +599,28 @@ onMounted(() => {
|
||||
<p v-if="ipError" class="error">{{ ipError }}</p>
|
||||
<p v-if="ipMessage" class="success">{{ ipMessage }}</p>
|
||||
|
||||
<div class="node-table-wrap">
|
||||
<div class="node-table-wrap table-fit-wrap">
|
||||
<table class="node-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th class="cell-nowrap">ID</th>
|
||||
<th>IP/CIDR</th>
|
||||
<th>原因</th>
|
||||
<th>启用</th>
|
||||
<th>创建时间</th>
|
||||
<th>更新时间</th>
|
||||
<th>操作</th>
|
||||
<th class="cell-nowrap">启用</th>
|
||||
<th class="cell-nowrap">创建时间</th>
|
||||
<th class="cell-nowrap">更新时间</th>
|
||||
<th class="cell-nowrap">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="rule in ipRules" :key="rule.id">
|
||||
<td>{{ rule.id }}</td>
|
||||
<td class="cell-nowrap">{{ rule.id }}</td>
|
||||
<td><input v-model="ipEdits[rule.id].ip_value" class="admin-table-input" /></td>
|
||||
<td><input v-model="ipEdits[rule.id].reason" class="admin-table-input" /></td>
|
||||
<td><input v-model="ipEdits[rule.id].enabled" type="checkbox" /></td>
|
||||
<td>{{ formatTime(rule.created_at) }}</td>
|
||||
<td>{{ formatTime(rule.updated_at) }}</td>
|
||||
<td>
|
||||
<td class="cell-nowrap"><input v-model="ipEdits[rule.id].enabled" type="checkbox" /></td>
|
||||
<td class="cell-nowrap">{{ formatTime(rule.created_at) }}</td>
|
||||
<td class="cell-nowrap">{{ formatTime(rule.updated_at) }}</td>
|
||||
<td class="cell-nowrap">
|
||||
<button class="admin-button" :disabled="ipLoading" @click="saveIPRule(rule)">保存</button>
|
||||
<button class="admin-button" :disabled="ipLoading" @click="removeIPRule(rule)">删除</button>
|
||||
</td>
|
||||
|
||||
@@ -360,40 +360,43 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel direct-page">
|
||||
<div class="direct-header">
|
||||
<div>
|
||||
<p class="eyebrow">Direct Bot Chat</p>
|
||||
<h2>
|
||||
机器人私聊
|
||||
<span class="pki-badge" title="使用 X25519 + AES-CCM 与目标节点端到端加密">PKI 加密</span>
|
||||
<span v-if="unreadTotal > 0" class="header-unread-badge">{{ unreadTotal > 99 ? '99+' : unreadTotal }} 未读</span>
|
||||
</h2>
|
||||
<section class="admin-dashboard">
|
||||
<div class="panel">
|
||||
<div class="panel-header direct-header">
|
||||
<div>
|
||||
<p class="eyebrow">Direct Bot Chat</p>
|
||||
<h2>
|
||||
机器人私聊
|
||||
<span class="pki-badge" title="使用 X25519 + AES-CCM 与目标节点端到端加密">PKI 加密</span>
|
||||
<span v-if="unreadTotal > 0" class="header-unread-badge">{{ unreadTotal > 99 ? '99+' : unreadTotal }} 未读</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="direct-actions">
|
||||
<a class="admin-button secondary" href="/admin/bot">返回频道聊天</a>
|
||||
<button class="admin-button" @click="refreshLists" :disabled="loading">{{ loading ? '刷新中...' : '刷新列表' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="direct-actions">
|
||||
<a class="admin-button secondary" href="/admin/bot">返回频道聊天</a>
|
||||
<button class="admin-button" @click="refreshLists" :disabled="loading">{{ loading ? '刷新中...' : '刷新列表' }}</button>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="notice" class="success">{{ notice }}</p>
|
||||
|
||||
<div class="direct-bot-picker">
|
||||
<label>机器人
|
||||
<select v-model="selectedBotId">
|
||||
<option :value="null">选择机器人</option>
|
||||
<option v-for="bot in bots" :key="bot.id" :value="bot.id">{{ bot.long_name }} · {{ bot.node_id }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>新建私聊
|
||||
<select :value="''" @change="(event) => { startConversationFromPicker(Number((event.target as HTMLSelectElement).value)); (event.target as HTMLSelectElement).value = '' }">
|
||||
<option value="">选择目标节点开启会话</option>
|
||||
<option v-for="node in peerNodeOptions" :key="node.node_id" :value="node.node_num">{{ node.long_name || node.short_name || node.node_id }} · {{ node.node_id }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="notice" class="success">{{ notice }}</p>
|
||||
|
||||
<div class="direct-bot-picker">
|
||||
<label>机器人
|
||||
<select v-model="selectedBotId">
|
||||
<option :value="null">选择机器人</option>
|
||||
<option v-for="bot in bots" :key="bot.id" :value="bot.id">{{ bot.long_name }} · {{ bot.node_id }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>新建私聊
|
||||
<select :value="''" @change="(event) => { startConversationFromPicker(Number((event.target as HTMLSelectElement).value)); (event.target as HTMLSelectElement).value = '' }">
|
||||
<option value="">选择目标节点开启会话</option>
|
||||
<option v-for="node in peerNodeOptions" :key="node.node_id" :value="node.node_num">{{ node.long_name || node.short_name || node.node_id }} · {{ node.node_id }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="panel direct-chat-panel">
|
||||
<div class="direct-layout">
|
||||
<aside class="conversation-list">
|
||||
<p v-if="conversations.length === 0" class="empty-state">还没有私聊会话。等设备发来消息或在上方“新建私聊”开启。</p>
|
||||
@@ -442,11 +445,13 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.direct-page { display: grid; gap: 12px; padding: 16px; }
|
||||
.direct-chat-panel { padding: 16px; }
|
||||
.direct-bot-picker { padding: 16px; }
|
||||
.direct-header, .direct-actions, .send-actions { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
|
||||
.direct-bot-picker { display: grid; grid-template-columns: repeat(2, minmax(200px, 1fr)); gap: 12px; }
|
||||
.direct-hint { color: #475569; font-size: 12px; margin: 0 0 8px; }
|
||||
|
||||
@@ -253,7 +253,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<span class="badge">{{ status?.clients?.length ?? 0 }}</span>
|
||||
</div>
|
||||
<div class="node-table-wrap">
|
||||
<div class="node-table-wrap table-fit-wrap">
|
||||
<table class="node-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -261,9 +261,9 @@ onBeforeUnmount(() => {
|
||||
<th class="sortable" @click="toggleClientSort('username')">Username <span class="sort-indicator">{{ sortIndicator('username') }}</span></th>
|
||||
<th class="sortable" @click="toggleClientSort('listener')">Listener <span class="sort-indicator">{{ sortIndicator('listener') }}</span></th>
|
||||
<th class="sortable" @click="toggleClientSort('remote_addr')">Remote Addr <span class="sort-indicator">{{ sortIndicator('remote_addr') }}</span></th>
|
||||
<th class="sortable" @click="toggleClientSort('packets_in')">客户端→服务器 <span class="sort-indicator">{{ sortIndicator('packets_in') }}</span></th>
|
||||
<th class="sortable" @click="toggleClientSort('packets_out')">服务器→客户端 <span class="sort-indicator">{{ sortIndicator('packets_out') }}</span></th>
|
||||
<th>操作</th>
|
||||
<th class="sortable cell-nowrap" @click="toggleClientSort('packets_in')">客户端→服务器 <span class="sort-indicator">{{ sortIndicator('packets_in') }}</span></th>
|
||||
<th class="sortable cell-nowrap" @click="toggleClientSort('packets_out')">服务器→客户端 <span class="sort-indicator">{{ sortIndicator('packets_out') }}</span></th>
|
||||
<th class="cell-nowrap">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -272,9 +272,9 @@ onBeforeUnmount(() => {
|
||||
<td>{{ client.username || '-' }}</td>
|
||||
<td>{{ client.listener || '-' }}</td>
|
||||
<td>{{ client.remote_addr || '-' }}</td>
|
||||
<td>{{ client.packets_in ?? 0 }}</td>
|
||||
<td>{{ client.packets_out ?? 0 }}</td>
|
||||
<td>
|
||||
<td class="cell-nowrap">{{ client.packets_in ?? 0 }}</td>
|
||||
<td class="cell-nowrap">{{ client.packets_out ?? 0 }}</td>
|
||||
<td class="cell-nowrap">
|
||||
<button
|
||||
type="button"
|
||||
class="client-danger-action"
|
||||
|
||||
@@ -135,11 +135,11 @@ onMounted(refreshItems)
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<div class="node-table-wrap">
|
||||
<div class="node-table-wrap table-fit-wrap">
|
||||
<table class="node-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 40px;">
|
||||
<th style="width: 40px;" class="cell-nowrap">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="allSelected"
|
||||
@@ -148,10 +148,10 @@ onMounted(refreshItems)
|
||||
@change="toggleAll(($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
</th>
|
||||
<th>时间</th>
|
||||
<th class="cell-nowrap">时间</th>
|
||||
<th>Topic</th>
|
||||
<th>Error</th>
|
||||
<th>Payload Len</th>
|
||||
<th class="cell-nowrap">Payload Len</th>
|
||||
<th>Client ID</th>
|
||||
<th>Username</th>
|
||||
<th>Listener</th>
|
||||
@@ -162,17 +162,17 @@ onMounted(refreshItems)
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in items" :key="item.id">
|
||||
<td>
|
||||
<td class="cell-nowrap">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedIds.has(item.id)"
|
||||
@change="toggleRow(item.id, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ formatTime(item.created_at) }}</td>
|
||||
<td class="cell-nowrap">{{ formatTime(item.created_at) }}</td>
|
||||
<td>{{ item.topic || '-' }}</td>
|
||||
<td>{{ item.error || '-' }}</td>
|
||||
<td>{{ item.payload_len }}</td>
|
||||
<td class="cell-nowrap">{{ item.payload_len }}</td>
|
||||
<td>{{ item.mqtt_client_id || '-' }}</td>
|
||||
<td>{{ item.mqtt_username || '-' }}</td>
|
||||
<td>{{ item.mqtt_listener || '-' }}</td>
|
||||
|
||||
@@ -14,13 +14,6 @@ const offset = ref(0)
|
||||
const selectedBotId = ref<number | ''>('')
|
||||
const includeDeleted = ref(false)
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: 'background: linear-gradient(135deg, #fef3c7 0%, #fde68a33 100%);',
|
||||
processing: 'background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe33 100%);',
|
||||
processed: 'background: linear-gradient(135deg, #dcfce7 0%, #bbf7d033 100%);',
|
||||
error: 'background: linear-gradient(135deg, #fee2e2 0%, #fecaca33 100%);',
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
pending: '待处理',
|
||||
processing: '处理中',
|
||||
@@ -178,12 +171,15 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-llm">
|
||||
<h2>LLM 消息队列</h2>
|
||||
|
||||
<div class="admin-llm-section">
|
||||
<h3>机器人 LLM 设置</h3>
|
||||
<p class="section-desc">每个机器人可以独立启用或禁用 LLM 消息队列。启用后,该机器人收到的私聊消息将被加入队列。</p>
|
||||
<section class="admin-dashboard">
|
||||
<div class="panel admin-status-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="eyebrow">LLM</p>
|
||||
<h2>机器人 LLM 设置</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p class="llm-section-desc">每个机器人可以独立启用或禁用 LLM 消息队列。启用后,该机器人收到的私聊消息将被加入队列。</p>
|
||||
|
||||
<div class="bot-settings-grid">
|
||||
<div v-for="bot in botNodes" :key="bot.id" class="bot-settings-card">
|
||||
@@ -216,608 +212,345 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-llm-toolbar">
|
||||
<div class="admin-llm-filter">
|
||||
<label>选择机器人:</label>
|
||||
<select v-model="selectedBotId" @change="loadMessages(true)">
|
||||
<option :value="''">全部</option>
|
||||
<option v-for="bot in botNodes" :key="bot.id" :value="bot.id">
|
||||
{{ bot.long_name }} ({{ bot.node_id }})
|
||||
</option>
|
||||
</select>
|
||||
<div class="panel admin-status-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="eyebrow">Queue</p>
|
||||
<h2>LLM 消息队列</h2>
|
||||
</div>
|
||||
<div class="admin-actions">
|
||||
<button class="admin-button" @click="() => loadMessages()" :disabled="loading">{{ loading ? '刷新中...' : '刷新' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-llm-filter">
|
||||
<label>
|
||||
<div class="llm-toolbar">
|
||||
<label class="llm-filter">
|
||||
<span>选择机器人</span>
|
||||
<select v-model="selectedBotId" @change="loadMessages(true)">
|
||||
<option :value="''">全部</option>
|
||||
<option v-for="bot in botNodes" :key="bot.id" :value="bot.id">
|
||||
{{ bot.long_name }} ({{ bot.node_id }})
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="llm-filter llm-filter-inline">
|
||||
<input type="checkbox" v-model="includeDeleted" @change="loadMessages(true)" />
|
||||
包含已删除
|
||||
</label>
|
||||
|
||||
<button class="admin-button danger" @click="handleDeleteAllByBot" :disabled="!selectedBotId">
|
||||
删除该机器人所有消息
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button class="admin-button admin-button-danger" @click="handleDeleteAllByBot" :disabled="!selectedBotId">
|
||||
删除该机器人所有消息
|
||||
</button>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
<button class="admin-button" @click="() => loadMessages()">刷新</button>
|
||||
<div class="llm-stats">
|
||||
共 {{ total }} 条消息,当前显示 {{ displayFrom }} - {{ displayTo }}
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="empty">加载中...</div>
|
||||
|
||||
<div v-else class="node-table-wrap table-fit-wrap">
|
||||
<table class="node-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="cell-nowrap">ID</th>
|
||||
<th>机器人</th>
|
||||
<th class="cell-nowrap">类型</th>
|
||||
<th class="cell-nowrap">状态</th>
|
||||
<th>来自节点</th>
|
||||
<th>频道</th>
|
||||
<th>消息内容</th>
|
||||
<th class="cell-nowrap">接收时间</th>
|
||||
<th class="cell-nowrap">处理时间</th>
|
||||
<th class="cell-nowrap">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="msg in messages" :key="msg.id">
|
||||
<td class="cell-nowrap">{{ msg.id }}</td>
|
||||
<td>
|
||||
<div class="bot-info">
|
||||
<div class="bot-long-name">{{ getBotName(msg.bot_id) }}</div>
|
||||
<div class="bot-node-id" v-if="msg.bot_id !== 0">{{ msg.bot_node_id }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="cell-nowrap">
|
||||
<span class="type-badge" :class="getMessageType(msg) === '频道' ? 'channel' : 'direct'">
|
||||
{{ getMessageType(msg) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="cell-nowrap">
|
||||
<span class="status-badge" :class="`status-${msg.status}`">
|
||||
{{ statusLabels[msg.status] || msg.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="node-info">
|
||||
<div class="node-name">{{ msg.long_name || msg.short_name || '-' }}</div>
|
||||
<div class="node-id">{{ msg.from_node_id }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="channel-cell">{{ msg.channel_id || '-' }}</td>
|
||||
<td class="message-text">{{ msg.text }}</td>
|
||||
<td class="cell-nowrap">{{ formatTime(msg.received_at) }}</td>
|
||||
<td class="cell-nowrap">{{ formatTime(msg.processed_at) }}</td>
|
||||
<td class="cell-nowrap">
|
||||
<button
|
||||
v-if="!msg.deleted_at"
|
||||
class="admin-button danger"
|
||||
@click="handleDeleteMessage(msg.id)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
<span v-else class="muted-tag">已删除</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="messages.length === 0" class="empty">暂无消息</div>
|
||||
</div>
|
||||
|
||||
<div class="pagination">
|
||||
<button @click="goPrev" :disabled="!hasPrev">上一页</button>
|
||||
<span>第 {{ Math.floor(offset / limit) + 1 }} 页</span>
|
||||
<button @click="goNext" :disabled="!hasMore">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
<div class="admin-llm-stats">
|
||||
共 {{ total }} 条消息,当前显示 {{ displayFrom }} - {{ displayTo }}
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="admin-loading">
|
||||
<div style="margin-bottom: 1rem;">🔄</div>
|
||||
加载中...
|
||||
</div>
|
||||
|
||||
<div v-else class="admin-llm-table-wrapper">
|
||||
<table class="admin-llm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>机器人</th>
|
||||
<th>类型</th>
|
||||
<th>状态</th>
|
||||
<th>来自节点</th>
|
||||
<th>频道</th>
|
||||
<th>消息内容</th>
|
||||
<th>接收时间</th>
|
||||
<th>处理时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="msg in messages" :key="msg.id" :style="statusColors[msg.status]">
|
||||
<td>{{ msg.id }}</td>
|
||||
<td class="bot-name-cell">
|
||||
<div class="bot-info">
|
||||
<div class="bot-long-name">{{ getBotName(msg.bot_id) }}</div>
|
||||
<div class="bot-node-id" v-if="msg.bot_id !== 0">{{ msg.bot_node_id }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="type-badge" :class="getMessageType(msg) === '频道' ? 'channel' : 'direct'">
|
||||
{{ getMessageType(msg) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-badge" :style="statusColors[msg.status]">
|
||||
{{ statusLabels[msg.status] || msg.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="node-info">
|
||||
<div class="node-name">{{ msg.long_name || msg.short_name || '-' }}</div>
|
||||
<div class="node-id">{{ msg.from_node_id }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="channel-cell">{{ msg.channel_id || '-' }}</td>
|
||||
<td class="message-text">{{ msg.text }}</td>
|
||||
<td>{{ formatTime(msg.received_at) }}</td>
|
||||
<td>{{ formatTime(msg.processed_at) }}</td>
|
||||
<td>
|
||||
<button
|
||||
v-if="!msg.deleted_at"
|
||||
class="admin-button admin-button-small admin-button-danger"
|
||||
@click="handleDeleteMessage(msg.id)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
<span v-else class="muted-tag">已删除</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="messages.length === 0">
|
||||
<td colspan="10" class="empty-state">暂无消息</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="admin-llm-pagination">
|
||||
<button class="admin-button admin-button-small" @click="goPrev" :disabled="!hasPrev">上一页</button>
|
||||
<span class="pagination-info">第 {{ Math.floor(offset / limit) + 1 }} 页</span>
|
||||
<button class="admin-button admin-button-small" @click="goNext" :disabled="!hasMore">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-llm {
|
||||
padding: 1.5rem;
|
||||
max-width: 100%;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.admin-llm h2 {
|
||||
margin: 0 0 2rem;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.admin-llm-section {
|
||||
background: white;
|
||||
padding: 1.75rem;
|
||||
border-radius: 16px;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05), 0 4px 6px rgba(0, 0, 0, 0.03);
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.admin-llm-section h3 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
margin: 0 0 1.5rem;
|
||||
color: #64748b;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
.llm-section-desc {
|
||||
margin: 0;
|
||||
padding: 12px 16px 0;
|
||||
color: var(--color-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.bot-settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1.25rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.bot-settings-card {
|
||||
background: linear-gradient(135deg, #fafbfc 0%, #f8fafc 100%);
|
||||
padding: 1.25rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bot-settings-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px;
|
||||
background: var(--color-surface-soft);
|
||||
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
|
||||
}
|
||||
|
||||
.bot-settings-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08);
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
.bot-settings-card:hover::before {
|
||||
opacity: 1;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.bot-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.bot-header strong {
|
||||
font-size: 1.05rem;
|
||||
color: #1e293b;
|
||||
font-weight: 600;
|
||||
color: var(--color-heading);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.bot-node-id {
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace;
|
||||
background: #f1f5f9;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 8px;
|
||||
color: var(--color-muted);
|
||||
background: var(--color-surface-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.bot-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
gap: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 8px;
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
color: #475569;
|
||||
padding: 0.5rem;
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.15s ease;
|
||||
transition: background-color 0.16s ease;
|
||||
}
|
||||
|
||||
.setting-item:hover {
|
||||
background: #f1f5f9;
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.setting-item input[type='checkbox'] {
|
||||
cursor: pointer;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: #3b82f6;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.saving-indicator {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: #3b82f6;
|
||||
margin-top: 8px;
|
||||
color: var(--color-primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.llm-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.saving-indicator::before {
|
||||
content: '';
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid #3b82f6;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.admin-llm-toolbar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
padding: 1.25rem;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid #e2e8f0;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.admin-llm-filter {
|
||||
.llm-filter {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: var(--color-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.llm-filter select {
|
||||
border: 1px solid var(--color-border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 9px 12px;
|
||||
min-width: 220px;
|
||||
color: var(--color-heading);
|
||||
background: var(--color-surface);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.16s ease, box-shadow 0.16s ease;
|
||||
}
|
||||
|
||||
.llm-filter select:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 20%, transparent);
|
||||
}
|
||||
|
||||
.llm-filter-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-llm-filter label {
|
||||
font-size: 0.9rem;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.admin-llm-filter select {
|
||||
padding: 0.6rem 1rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
min-width: 220px;
|
||||
font-size: 0.9rem;
|
||||
color: #334155;
|
||||
background: white;
|
||||
gap: 8px;
|
||||
padding-bottom: 9px;
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.admin-llm-filter select:hover {
|
||||
border-color: #94a3b8;
|
||||
}
|
||||
|
||||
.admin-llm-filter select:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.admin-llm-filter input[type='checkbox'] {
|
||||
.llm-filter-inline input[type='checkbox'] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: #3b82f6;
|
||||
accent-color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-llm-stats {
|
||||
padding: 1rem 1.25rem;
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 1.25rem;
|
||||
font-size: 0.9rem;
|
||||
color: #1e40af;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
.llm-stats {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
color: var(--color-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.type-badge,
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
|
||||
.admin-loading {
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
display: inline-block;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.type-badge.channel {
|
||||
background: linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 100%);
|
||||
color: #4338ca;
|
||||
border: 1px solid #a5b4fc;
|
||||
color: color-mix(in srgb, var(--color-primary) 78%, var(--color-heading));
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.type-badge.direct {
|
||||
background: linear-gradient(135deg, #fce7f3 0%, #fbcfe8 100%);
|
||||
color: #be185d;
|
||||
border: 1px solid #f9a8d4;
|
||||
color: color-mix(in srgb, var(--color-accent) 78%, var(--color-heading));
|
||||
background: color-mix(in srgb, var(--color-accent) 14%, white);
|
||||
}
|
||||
|
||||
.channel-cell {
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace;
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
background: #f8fafc;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e2e8f0;
|
||||
.status-badge.status-pending {
|
||||
color: color-mix(in srgb, var(--color-warning) 72%, var(--color-heading));
|
||||
background: var(--color-warning-soft);
|
||||
}
|
||||
|
||||
.bot-name-cell {
|
||||
min-width: 160px;
|
||||
.status-badge.status-processing {
|
||||
color: color-mix(in srgb, var(--color-primary) 78%, var(--color-heading));
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.bot-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
.status-badge.status-processed {
|
||||
color: color-mix(in srgb, var(--color-success) 70%, var(--color-heading));
|
||||
background: var(--color-success-soft);
|
||||
}
|
||||
|
||||
.bot-long-name {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
.status-badge.status-error {
|
||||
color: color-mix(in srgb, var(--color-danger) 76%, var(--color-heading));
|
||||
background: var(--color-danger-soft);
|
||||
}
|
||||
|
||||
.bot-info,
|
||||
.node-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.bot-long-name,
|
||||
.node-name {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
color: var(--color-heading);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.node-id {
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace;
|
||||
background: #f1f5f9;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 1px 6px;
|
||||
color: var(--color-muted);
|
||||
background: var(--color-surface-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.admin-llm-table-wrapper {
|
||||
overflow-x: auto;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid #e2e8f0;
|
||||
.channel-cell {
|
||||
color: var(--color-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-llm-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
min-width: 1000px;
|
||||
}
|
||||
|
||||
.admin-llm-table th,
|
||||
.admin-llm-table td {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.admin-llm-table th {
|
||||
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.admin-llm-table tbody tr {
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.admin-llm-table tbody tr:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
border: 1px solid transparent;
|
||||
.message-text {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.muted-tag {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.status-badge[style*='#fff3cd'] {
|
||||
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%) !important;
|
||||
color: #92400e;
|
||||
border-color: #fcd34d;
|
||||
}
|
||||
|
||||
.status-badge[style*='#cfe2ff'] {
|
||||
background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%) !important;
|
||||
color: #1e40af;
|
||||
border-color: #93c5fd;
|
||||
}
|
||||
|
||||
.status-badge[style*='#d1e7dd'] {
|
||||
background: linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%) !important;
|
||||
color: #166534;
|
||||
border-color: #86efac;
|
||||
}
|
||||
|
||||
.status-badge[style*='#f8d7da'] {
|
||||
background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%) !important;
|
||||
color: #991b1b;
|
||||
border-color: #fca5a5;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
max-width: 450px;
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
color: #334155;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem !important;
|
||||
color: #94a3b8;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.empty-state::before {
|
||||
content: '📭';
|
||||
display: block;
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.admin-llm-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
padding: 1.25rem;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
color: #64748b;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
min-width: 80px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #991b1b;
|
||||
padding: 1rem 1.25rem;
|
||||
background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 1.25rem;
|
||||
border: 1px solid #fca5a5;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.error::before {
|
||||
content: '⚠️';
|
||||
}
|
||||
|
||||
.admin-button {
|
||||
padding: 0.6rem 1.25rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
color: white;
|
||||
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.admin-button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.admin-button:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.admin-button-small {
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.admin-button-danger {
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
box-shadow: 0 2px 4px rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.admin-button-danger:hover:not(:disabled) {
|
||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 8px;
|
||||
color: var(--color-muted);
|
||||
background: var(--color-surface-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -49,13 +49,13 @@ onMounted(refreshLogs)
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<div class="node-table-wrap">
|
||||
<div class="node-table-wrap table-fit-wrap">
|
||||
<table class="node-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th class="cell-nowrap">时间</th>
|
||||
<th>用户名</th>
|
||||
<th>结果</th>
|
||||
<th class="cell-nowrap">结果</th>
|
||||
<th>原因</th>
|
||||
<th>Remote Addr</th>
|
||||
<th>Remote Host</th>
|
||||
@@ -64,9 +64,9 @@ onMounted(refreshLogs)
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="log in logs" :key="log.id">
|
||||
<td>{{ formatTime(log.created_at) }}</td>
|
||||
<td class="cell-nowrap">{{ formatTime(log.created_at) }}</td>
|
||||
<td>{{ log.username || '-' }}</td>
|
||||
<td>
|
||||
<td class="cell-nowrap">
|
||||
<span class="log-badge" :class="log.success ? 'log-success' : 'log-failure'">
|
||||
{{ log.success ? '成功' : '失败' }}
|
||||
</span>
|
||||
|
||||
@@ -572,8 +572,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<style scoped>
|
||||
.mqtt-forward-page {
|
||||
width: min(1440px, 100%);
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
@@ -212,41 +212,41 @@ onMounted(() => refreshSigns())
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="message" class="success">{{ message }}</p>
|
||||
|
||||
<div class="node-table-wrap">
|
||||
<div class="node-table-wrap table-fit-wrap">
|
||||
<table class="node-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>节点 ID</th>
|
||||
<th class="cell-nowrap">ID</th>
|
||||
<th class="cell-nowrap">节点 ID</th>
|
||||
<th>Long Name</th>
|
||||
<th>Short Name</th>
|
||||
<th class="cell-nowrap">Short Name</th>
|
||||
<th>签到文本</th>
|
||||
<th>签到时间</th>
|
||||
<th>操作</th>
|
||||
<th class="cell-nowrap">签到时间</th>
|
||||
<th class="cell-nowrap">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="record in records" :key="record.id">
|
||||
<template v-if="edits[record.id]">
|
||||
<td>{{ record.id }}</td>
|
||||
<td class="cell-nowrap">{{ record.id }}</td>
|
||||
<td><input v-model="edits[record.id].node_id" class="admin-table-input" /></td>
|
||||
<td><input v-model="edits[record.id].long_name" class="admin-table-input" /></td>
|
||||
<td><input v-model="edits[record.id].short_name" class="admin-table-input" /></td>
|
||||
<td><input v-model="edits[record.id].sign_text" class="admin-table-input" /></td>
|
||||
<td><input v-model="edits[record.id].sign_time" class="admin-table-input" type="datetime-local" /></td>
|
||||
<td>
|
||||
<td class="cell-nowrap">
|
||||
<button class="admin-button" :disabled="loading" @click="saveSign(record)">保存</button>
|
||||
<button class="admin-button" :disabled="loading" @click="cancelEdit(record.id)">取消</button>
|
||||
</td>
|
||||
</template>
|
||||
<template v-else>
|
||||
<td>{{ record.id }}</td>
|
||||
<td>{{ record.node_id }}</td>
|
||||
<td class="cell-nowrap">{{ record.id }}</td>
|
||||
<td class="cell-nowrap">{{ record.node_id }}</td>
|
||||
<td>{{ record.long_name || '-' }}</td>
|
||||
<td>{{ record.short_name || '-' }}</td>
|
||||
<td class="cell-nowrap">{{ record.short_name || '-' }}</td>
|
||||
<td>{{ record.sign_text }}</td>
|
||||
<td>{{ formatTime(record.sign_time) }}</td>
|
||||
<td>
|
||||
<td class="cell-nowrap">{{ formatTime(record.sign_time) }}</td>
|
||||
<td class="cell-nowrap">
|
||||
<button class="admin-button" :disabled="loading" @click="startEdit(record)">编辑</button>
|
||||
<button class="admin-button danger" :disabled="loading" @click="removeSign(record)">删除</button>
|
||||
</td>
|
||||
|
||||
@@ -119,26 +119,26 @@ onMounted(refreshUsers)
|
||||
<p v-if="userError" class="error">{{ userError }}</p>
|
||||
<p v-if="userMessage" class="success">{{ userMessage }}</p>
|
||||
|
||||
<div class="node-table-wrap">
|
||||
<div class="node-table-wrap table-fit-wrap">
|
||||
<table class="node-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th class="cell-nowrap">ID</th>
|
||||
<th>用户名</th>
|
||||
<th>角色</th>
|
||||
<th>创建时间</th>
|
||||
<th>更新时间</th>
|
||||
<th class="cell-nowrap">角色</th>
|
||||
<th class="cell-nowrap">创建时间</th>
|
||||
<th class="cell-nowrap">更新时间</th>
|
||||
<th>新密码</th>
|
||||
<th>操作</th>
|
||||
<th class="cell-nowrap">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="managedUser in users" :key="managedUser.id">
|
||||
<td>{{ managedUser.id }}</td>
|
||||
<td class="cell-nowrap">{{ managedUser.id }}</td>
|
||||
<td>{{ managedUser.username }} <span v-if="managedUser.username === props.user.username" class="badge">当前</span></td>
|
||||
<td>{{ managedUser.role }}</td>
|
||||
<td>{{ formatTime(managedUser.created_at) }}</td>
|
||||
<td>{{ formatTime(managedUser.updated_at) }}</td>
|
||||
<td class="cell-nowrap">{{ managedUser.role }}</td>
|
||||
<td class="cell-nowrap">{{ formatTime(managedUser.created_at) }}</td>
|
||||
<td class="cell-nowrap">{{ formatTime(managedUser.updated_at) }}</td>
|
||||
<td>
|
||||
<input
|
||||
v-model="passwordEdits[managedUser.id]"
|
||||
@@ -148,7 +148,7 @@ onMounted(refreshUsers)
|
||||
placeholder="输入新密码"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<td class="cell-nowrap">
|
||||
<button class="admin-button" :disabled="passwordSaving[managedUser.id]" @click="updatePassword(managedUser)">
|
||||
{{ passwordSaving[managedUser.id] ? '保存中...' : '修改密码' }}
|
||||
</button>
|
||||
|
||||
@@ -1225,6 +1225,25 @@ h3 {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* === 管理页表格:长文本列换行完整显示,短列保持单行 === */
|
||||
.table-fit-wrap {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.table-fit-wrap .node-table th,
|
||||
.table-fit-wrap .node-table td {
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
overflow-wrap: anywhere;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.table-fit-wrap .node-table th.cell-nowrap,
|
||||
.table-fit-wrap .node-table td.cell-nowrap {
|
||||
white-space: nowrap;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.pubkey-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1259,10 +1278,13 @@ h3 {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.admin-login,
|
||||
.admin-dashboard,
|
||||
.admin-session-card {
|
||||
max-width: 1100px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-login {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1374,6 +1396,12 @@ h3 {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.table-fit-wrap .admin-table-input {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.success {
|
||||
margin: 0 16px 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-success) 34%, white);
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const FRONTEND_VERSION = '0.3.1'
|
||||
export const FRONTEND_VERSION = '0.3.2'
|
||||
Reference in New Issue
Block a user