统一 Admin 管理后台样式:全部页面改为 100% 全宽,表格长文本列自动换行去除横向滚动,LLM 队列/API 配置页重做为全局 panel 风格,前端 v0.3.2

This commit is contained in:
2026-08-12 17:49:21 +08:00
parent 92e766dfa8
commit fbd105bd6c
12 changed files with 802 additions and 1166 deletions
@@ -458,36 +458,36 @@ onMounted(() => {
<p v-if="wordError" class="error">{{ wordError }}</p> <p v-if="wordError" class="error">{{ wordError }}</p>
<p v-if="wordMessage" class="success">{{ wordMessage }}</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"> <table class="node-table">
<thead> <thead>
<tr> <tr>
<th>ID</th> <th class="cell-nowrap">ID</th>
<th>违禁词</th> <th>违禁词</th>
<th>匹配类型</th> <th class="cell-nowrap">匹配类型</th>
<th>区分大小写</th> <th class="cell-nowrap">区分大小写</th>
<th>原因</th> <th>原因</th>
<th>启用</th> <th class="cell-nowrap">启用</th>
<th>创建时间</th> <th class="cell-nowrap">创建时间</th>
<th>更新时间</th> <th class="cell-nowrap">更新时间</th>
<th>操作</th> <th class="cell-nowrap">操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="rule in wordRules" :key="rule.id"> <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><input v-model="wordEdits[rule.id].word" class="admin-table-input" /></td>
<td> <td>
<select v-model="wordEdits[rule.id].match_type" class="admin-table-input"> <select v-model="wordEdits[rule.id].match_type" class="admin-table-input">
<option value="contains">包含</option> <option value="contains">包含</option>
</select> </select>
</td> </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].reason" class="admin-table-input" /></td>
<td><input v-model="wordEdits[rule.id].enabled" type="checkbox" /></td> <td class="cell-nowrap"><input v-model="wordEdits[rule.id].enabled" type="checkbox" /></td>
<td>{{ formatTime(rule.created_at) }}</td> <td class="cell-nowrap">{{ formatTime(rule.created_at) }}</td>
<td>{{ formatTime(rule.updated_at) }}</td> <td class="cell-nowrap">{{ formatTime(rule.updated_at) }}</td>
<td> <td class="cell-nowrap">
<button class="admin-button" :disabled="wordLoading" @click="saveWordRule(rule)">保存</button> <button class="admin-button" :disabled="wordLoading" @click="saveWordRule(rule)">保存</button>
<button class="admin-button" :disabled="wordLoading" @click="removeWordRule(rule)">删除</button> <button class="admin-button" :disabled="wordLoading" @click="removeWordRule(rule)">删除</button>
</td> </td>
@@ -533,30 +533,30 @@ onMounted(() => {
<p v-if="nodeError" class="error">{{ nodeError }}</p> <p v-if="nodeError" class="error">{{ nodeError }}</p>
<p v-if="nodeMessage" class="success">{{ nodeMessage }}</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"> <table class="node-table">
<thead> <thead>
<tr> <tr>
<th>ID</th> <th class="cell-nowrap">ID</th>
<th>节点 ID</th> <th>节点 ID</th>
<th>数字 ID</th> <th class="cell-nowrap">数字 ID</th>
<th>原因</th> <th>原因</th>
<th>启用</th> <th class="cell-nowrap">启用</th>
<th>创建时间</th> <th class="cell-nowrap">创建时间</th>
<th>更新时间</th> <th class="cell-nowrap">更新时间</th>
<th>操作</th> <th class="cell-nowrap">操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="rule in nodeRules" :key="rule.id"> <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_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].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].reason" class="admin-table-input" /></td>
<td><input v-model="nodeEdits[rule.id].enabled" type="checkbox" /></td> <td class="cell-nowrap"><input v-model="nodeEdits[rule.id].enabled" type="checkbox" /></td>
<td>{{ formatTime(rule.created_at) }}</td> <td class="cell-nowrap">{{ formatTime(rule.created_at) }}</td>
<td>{{ formatTime(rule.updated_at) }}</td> <td class="cell-nowrap">{{ formatTime(rule.updated_at) }}</td>
<td> <td class="cell-nowrap">
<button class="admin-button" :disabled="nodeLoading" @click="saveNodeRule(rule)">保存</button> <button class="admin-button" :disabled="nodeLoading" @click="saveNodeRule(rule)">保存</button>
<button class="admin-button" :disabled="nodeLoading" @click="removeNodeRule(rule)">删除</button> <button class="admin-button" :disabled="nodeLoading" @click="removeNodeRule(rule)">删除</button>
</td> </td>
@@ -599,28 +599,28 @@ onMounted(() => {
<p v-if="ipError" class="error">{{ ipError }}</p> <p v-if="ipError" class="error">{{ ipError }}</p>
<p v-if="ipMessage" class="success">{{ ipMessage }}</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"> <table class="node-table">
<thead> <thead>
<tr> <tr>
<th>ID</th> <th class="cell-nowrap">ID</th>
<th>IP/CIDR</th> <th>IP/CIDR</th>
<th>原因</th> <th>原因</th>
<th>启用</th> <th class="cell-nowrap">启用</th>
<th>创建时间</th> <th class="cell-nowrap">创建时间</th>
<th>更新时间</th> <th class="cell-nowrap">更新时间</th>
<th>操作</th> <th class="cell-nowrap">操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="rule in ipRules" :key="rule.id"> <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].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].reason" class="admin-table-input" /></td>
<td><input v-model="ipEdits[rule.id].enabled" type="checkbox" /></td> <td class="cell-nowrap"><input v-model="ipEdits[rule.id].enabled" type="checkbox" /></td>
<td>{{ formatTime(rule.created_at) }}</td> <td class="cell-nowrap">{{ formatTime(rule.created_at) }}</td>
<td>{{ formatTime(rule.updated_at) }}</td> <td class="cell-nowrap">{{ formatTime(rule.updated_at) }}</td>
<td> <td class="cell-nowrap">
<button class="admin-button" :disabled="ipLoading" @click="saveIPRule(rule)">保存</button> <button class="admin-button" :disabled="ipLoading" @click="saveIPRule(rule)">保存</button>
<button class="admin-button" :disabled="ipLoading" @click="removeIPRule(rule)">删除</button> <button class="admin-button" :disabled="ipLoading" @click="removeIPRule(rule)">删除</button>
</td> </td>
@@ -360,40 +360,43 @@ onBeforeUnmount(() => {
</script> </script>
<template> <template>
<section class="panel direct-page"> <section class="admin-dashboard">
<div class="direct-header"> <div class="panel">
<div> <div class="panel-header direct-header">
<p class="eyebrow">Direct Bot Chat</p> <div>
<h2> <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> <span class="pki-badge" title="使用 X25519 + AES-CCM 与目标节点端到端加密">PKI 加密</span>
</h2> <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>
<div class="direct-actions">
<a class="admin-button secondary" href="/admin/bot">返回频道聊天</a> <p v-if="error" class="error">{{ error }}</p>
<button class="admin-button" @click="refreshLists" :disabled="loading">{{ loading ? '刷新中...' : '刷新列表' }}</button> <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>
</div> </div>
<p v-if="error" class="error">{{ error }}</p> <div class="panel direct-chat-panel">
<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="direct-layout"> <div class="direct-layout">
<aside class="conversation-list"> <aside class="conversation-list">
<p v-if="conversations.length === 0" class="empty-state">还没有私聊会话等设备发来消息或在上方新建私聊开启</p> <p v-if="conversations.length === 0" class="empty-state">还没有私聊会话等设备发来消息或在上方新建私聊开启</p>
@@ -442,11 +445,13 @@ onBeforeUnmount(() => {
</div> </div>
</div> </div>
</div> </div>
</div>
</section> </section>
</template> </template>
<style scoped> <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-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-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; } .direct-hint { color: #475569; font-size: 12px; margin: 0 0 8px; }
@@ -253,7 +253,7 @@ onBeforeUnmount(() => {
</div> </div>
<span class="badge">{{ status?.clients?.length ?? 0 }}</span> <span class="badge">{{ status?.clients?.length ?? 0 }}</span>
</div> </div>
<div class="node-table-wrap"> <div class="node-table-wrap table-fit-wrap">
<table class="node-table"> <table class="node-table">
<thead> <thead>
<tr> <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('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('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('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 cell-nowrap" @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 class="sortable cell-nowrap" @click="toggleClientSort('packets_out')">服务器客户端 <span class="sort-indicator">{{ sortIndicator('packets_out') }}</span></th>
<th>操作</th> <th class="cell-nowrap">操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -272,9 +272,9 @@ onBeforeUnmount(() => {
<td>{{ client.username || '-' }}</td> <td>{{ client.username || '-' }}</td>
<td>{{ client.listener || '-' }}</td> <td>{{ client.listener || '-' }}</td>
<td>{{ client.remote_addr || '-' }}</td> <td>{{ client.remote_addr || '-' }}</td>
<td>{{ client.packets_in ?? 0 }}</td> <td class="cell-nowrap">{{ client.packets_in ?? 0 }}</td>
<td>{{ client.packets_out ?? 0 }}</td> <td class="cell-nowrap">{{ client.packets_out ?? 0 }}</td>
<td> <td class="cell-nowrap">
<button <button
type="button" type="button"
class="client-danger-action" class="client-danger-action"
@@ -135,11 +135,11 @@ onMounted(refreshItems)
</div> </div>
<p v-if="error" class="error">{{ error }}</p> <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"> <table class="node-table">
<thead> <thead>
<tr> <tr>
<th style="width: 40px;"> <th style="width: 40px;" class="cell-nowrap">
<input <input
type="checkbox" type="checkbox"
:checked="allSelected" :checked="allSelected"
@@ -148,10 +148,10 @@ onMounted(refreshItems)
@change="toggleAll(($event.target as HTMLInputElement).checked)" @change="toggleAll(($event.target as HTMLInputElement).checked)"
/> />
</th> </th>
<th>时间</th> <th class="cell-nowrap">时间</th>
<th>Topic</th> <th>Topic</th>
<th>Error</th> <th>Error</th>
<th>Payload Len</th> <th class="cell-nowrap">Payload Len</th>
<th>Client ID</th> <th>Client ID</th>
<th>Username</th> <th>Username</th>
<th>Listener</th> <th>Listener</th>
@@ -162,17 +162,17 @@ onMounted(refreshItems)
</thead> </thead>
<tbody> <tbody>
<tr v-for="item in items" :key="item.id"> <tr v-for="item in items" :key="item.id">
<td> <td class="cell-nowrap">
<input <input
type="checkbox" type="checkbox"
:checked="selectedIds.has(item.id)" :checked="selectedIds.has(item.id)"
@change="toggleRow(item.id, ($event.target as HTMLInputElement).checked)" @change="toggleRow(item.id, ($event.target as HTMLInputElement).checked)"
/> />
</td> </td>
<td>{{ formatTime(item.created_at) }}</td> <td class="cell-nowrap">{{ formatTime(item.created_at) }}</td>
<td>{{ item.topic || '-' }}</td> <td>{{ item.topic || '-' }}</td>
<td>{{ item.error || '-' }}</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_client_id || '-' }}</td>
<td>{{ item.mqtt_username || '-' }}</td> <td>{{ item.mqtt_username || '-' }}</td>
<td>{{ item.mqtt_listener || '-' }}</td> <td>{{ item.mqtt_listener || '-' }}</td>
+243 -510
View File
@@ -14,13 +14,6 @@ const offset = ref(0)
const selectedBotId = ref<number | ''>('') const selectedBotId = ref<number | ''>('')
const includeDeleted = ref(false) 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> = { const statusLabels: Record<string, string> = {
pending: '待处理', pending: '待处理',
processing: '处理中', processing: '处理中',
@@ -178,12 +171,15 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="admin-llm"> <section class="admin-dashboard">
<h2>LLM 消息队列</h2> <div class="panel admin-status-panel">
<div class="panel-header">
<div class="admin-llm-section"> <div>
<h3>机器人 LLM 设置</h3> <p class="eyebrow">LLM</p>
<p class="section-desc">每个机器人可以独立启用或禁用 LLM 消息队列启用后该机器人收到的私聊消息将被加入队列</p> <h2>机器人 LLM 设置</h2>
</div>
</div>
<p class="llm-section-desc">每个机器人可以独立启用或禁用 LLM 消息队列启用后该机器人收到的私聊消息将被加入队列</p>
<div class="bot-settings-grid"> <div class="bot-settings-grid">
<div v-for="bot in botNodes" :key="bot.id" class="bot-settings-card"> <div v-for="bot in botNodes" :key="bot.id" class="bot-settings-card">
@@ -216,608 +212,345 @@ onMounted(() => {
</div> </div>
</div> </div>
<div class="admin-llm-toolbar"> <div class="panel admin-status-panel">
<div class="admin-llm-filter"> <div class="panel-header">
<label>选择机器人</label> <div>
<select v-model="selectedBotId" @change="loadMessages(true)"> <p class="eyebrow">Queue</p>
<option :value="''">全部</option> <h2>LLM 消息队列</h2>
<option v-for="bot in botNodes" :key="bot.id" :value="bot.id"> </div>
{{ bot.long_name }} ({{ bot.node_id }}) <div class="admin-actions">
</option> <button class="admin-button" @click="() => loadMessages()" :disabled="loading">{{ loading ? '刷新中...' : '刷新' }}</button>
</select> </div>
</div> </div>
<div class="admin-llm-filter"> <div class="llm-toolbar">
<label> <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)" /> <input type="checkbox" v-model="includeDeleted" @change="loadMessages(true)" />
包含已删除 包含已删除
</label> </label>
<button class="admin-button danger" @click="handleDeleteAllByBot" :disabled="!selectedBotId">
删除该机器人所有消息
</button>
</div> </div>
<button class="admin-button admin-button-danger" @click="handleDeleteAllByBot" :disabled="!selectedBotId"> <p v-if="error" class="error">{{ error }}</p>
删除该机器人所有消息
</button>
<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> </div>
</section>
<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>
</template> </template>
<style scoped> <style scoped>
.admin-llm { .llm-section-desc {
padding: 1.5rem; margin: 0;
max-width: 100%; padding: 12px 16px 0;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); color: var(--color-muted);
min-height: 100vh; font-size: 13px;
} line-height: 1.6;
.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;
} }
.bot-settings-grid { .bot-settings-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.25rem; gap: 12px;
padding: 16px;
} }
.bot-settings-card { .bot-settings-card {
background: linear-gradient(135deg, #fafbfc 0%, #f8fafc 100%); border: 1px solid var(--color-border);
padding: 1.25rem; border-radius: var(--radius-md);
border-radius: 12px; padding: 14px;
border: 1px solid #e2e8f0; background: var(--color-surface-soft);
transition: all 0.2s ease; transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s 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;
} }
.bot-settings-card:hover { .bot-settings-card:hover {
transform: translateY(-2px); border-color: var(--color-primary);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08); box-shadow: var(--shadow-sm);
border-color: #cbd5e1; transform: translateY(-1px);
}
.bot-settings-card:hover::before {
opacity: 1;
} }
.bot-header { .bot-header {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.35rem; gap: 6px;
margin-bottom: 1rem; margin-bottom: 10px;
padding-bottom: 1rem; padding-bottom: 10px;
border-bottom: 1px solid #e2e8f0; border-bottom: 1px solid var(--color-border);
} }
.bot-header strong { .bot-header strong {
font-size: 1.05rem; color: var(--color-heading);
color: #1e293b; font-size: 15px;
font-weight: 600;
} }
.bot-node-id { .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; display: inline-block;
width: fit-content; 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 { .bot-settings {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 8px;
} }
.setting-item { .setting-item {
display: flex; display: flex;
align-items: center; 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; cursor: pointer;
font-size: 0.9rem; transition: background-color 0.16s ease;
color: #475569;
padding: 0.5rem;
border-radius: 8px;
transition: background-color 0.15s ease;
} }
.setting-item:hover { .setting-item:hover {
background: #f1f5f9; background: var(--color-primary-soft);
} }
.setting-item input[type='checkbox'] { .setting-item input[type='checkbox'] {
cursor: pointer; cursor: pointer;
width: 18px; width: 16px;
height: 18px; height: 16px;
accent-color: #3b82f6; accent-color: var(--color-primary);
} }
.saving-indicator { .saving-indicator {
margin-top: 0.75rem; margin-top: 8px;
font-size: 0.8rem; color: var(--color-primary);
color: #3b82f6; font-size: 12px;
}
.llm-toolbar {
display: flex; display: flex;
align-items: center; align-items: flex-end;
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;
flex-wrap: wrap; flex-wrap: wrap;
padding: 1.25rem; gap: 12px;
background: white; padding: 14px 16px;
border-radius: 12px; border-bottom: 1px solid var(--color-border);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
border: 1px solid #e2e8f0;
} }
.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; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 8px;
} padding-bottom: 9px;
color: var(--color-text);
.admin-llm-filter label { font-size: 13px;
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;
cursor: pointer; cursor: pointer;
transition: all 0.15s ease;
} }
.admin-llm-filter select:hover { .llm-filter-inline input[type='checkbox'] {
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'] {
width: 16px; width: 16px;
height: 16px; height: 16px;
accent-color: #3b82f6; accent-color: var(--color-primary);
cursor: pointer; cursor: pointer;
} }
.admin-llm-stats { .llm-stats {
padding: 1rem 1.25rem; padding: 12px 16px;
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%); border-bottom: 1px solid var(--color-border);
border-radius: 10px; color: var(--color-muted);
margin-bottom: 1.25rem; font-size: 13px;
font-size: 0.9rem; }
color: #1e40af;
font-weight: 500; .type-badge,
display: flex; .status-badge {
display: inline-flex;
align-items: center; align-items: center;
gap: 0.5rem; border-radius: 999px;
border: 1px solid #bfdbfe; padding: 4px 10px;
} font-size: 12px;
font-weight: 700;
.admin-loading { white-space: nowrap;
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;
} }
.type-badge.channel { .type-badge.channel {
background: linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 100%); color: color-mix(in srgb, var(--color-primary) 78%, var(--color-heading));
color: #4338ca; background: var(--color-primary-soft);
border: 1px solid #a5b4fc;
} }
.type-badge.direct { .type-badge.direct {
background: linear-gradient(135deg, #fce7f3 0%, #fbcfe8 100%); color: color-mix(in srgb, var(--color-accent) 78%, var(--color-heading));
color: #be185d; background: color-mix(in srgb, var(--color-accent) 14%, white);
border: 1px solid #f9a8d4;
} }
.channel-cell { .status-badge.status-pending {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace; color: color-mix(in srgb, var(--color-warning) 72%, var(--color-heading));
font-size: 0.8rem; background: var(--color-warning-soft);
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;
} }
.bot-name-cell { .status-badge.status-processing {
min-width: 160px; color: color-mix(in srgb, var(--color-primary) 78%, var(--color-heading));
background: var(--color-primary-soft);
} }
.bot-info { .status-badge.status-processed {
display: flex; color: color-mix(in srgb, var(--color-success) 70%, var(--color-heading));
flex-direction: column; background: var(--color-success-soft);
gap: 0.35rem;
} }
.bot-long-name { .status-badge.status-error {
font-weight: 600; color: color-mix(in srgb, var(--color-danger) 76%, var(--color-heading));
color: #1e293b; background: var(--color-danger-soft);
} }
.bot-info,
.node-info { .node-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.35rem; gap: 4px;
} }
.bot-long-name,
.node-name { .node-name {
font-weight: 600; color: var(--color-heading);
color: #1e293b; font-weight: 700;
} }
.node-id { .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; display: inline-block;
width: fit-content; 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 { .channel-cell {
overflow-x: auto; color: var(--color-muted);
background: white; font-family: var(--font-mono);
border-radius: 12px; font-size: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
border: 1px solid #e2e8f0;
} }
.admin-llm-table { .message-text {
width: 100%; line-height: 1.5;
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;
} }
.muted-tag { .muted-tag {
display: inline-block; display: inline-block;
padding: 0.25rem 0.6rem; border-radius: var(--radius-sm);
border-radius: 6px; padding: 2px 8px;
font-size: 0.75rem; color: var(--color-muted);
color: #94a3b8; background: var(--color-surface-muted);
background: #f1f5f9; font-size: 12px;
}
.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;
} }
</style> </style>
File diff suppressed because it is too large Load Diff
@@ -49,13 +49,13 @@ onMounted(refreshLogs)
</div> </div>
<p v-if="error" class="error">{{ error }}</p> <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"> <table class="node-table">
<thead> <thead>
<tr> <tr>
<th>时间</th> <th class="cell-nowrap">时间</th>
<th>用户名</th> <th>用户名</th>
<th>结果</th> <th class="cell-nowrap">结果</th>
<th>原因</th> <th>原因</th>
<th>Remote Addr</th> <th>Remote Addr</th>
<th>Remote Host</th> <th>Remote Host</th>
@@ -64,9 +64,9 @@ onMounted(refreshLogs)
</thead> </thead>
<tbody> <tbody>
<tr v-for="log in logs" :key="log.id"> <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>{{ log.username || '-' }}</td>
<td> <td class="cell-nowrap">
<span class="log-badge" :class="log.success ? 'log-success' : 'log-failure'"> <span class="log-badge" :class="log.success ? 'log-success' : 'log-failure'">
{{ log.success ? '成功' : '失败' }} {{ log.success ? '成功' : '失败' }}
</span> </span>
@@ -572,8 +572,7 @@ onBeforeUnmount(() => {
<style scoped> <style scoped>
.mqtt-forward-page { .mqtt-forward-page {
width: min(1440px, 100%); width: 100%;
margin: 0 auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
@@ -212,41 +212,41 @@ onMounted(() => refreshSigns())
<p v-if="error" class="error">{{ error }}</p> <p v-if="error" class="error">{{ error }}</p>
<p v-if="message" class="success">{{ message }}</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"> <table class="node-table">
<thead> <thead>
<tr> <tr>
<th>ID</th> <th class="cell-nowrap">ID</th>
<th>节点 ID</th> <th class="cell-nowrap">节点 ID</th>
<th>Long Name</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>操作</th> <th class="cell-nowrap">操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="record in records" :key="record.id"> <tr v-for="record in records" :key="record.id">
<template v-if="edits[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].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].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].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_text" class="admin-table-input" /></td>
<td><input v-model="edits[record.id].sign_time" class="admin-table-input" type="datetime-local" /></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="saveSign(record)">保存</button>
<button class="admin-button" :disabled="loading" @click="cancelEdit(record.id)">取消</button> <button class="admin-button" :disabled="loading" @click="cancelEdit(record.id)">取消</button>
</td> </td>
</template> </template>
<template v-else> <template v-else>
<td>{{ record.id }}</td> <td class="cell-nowrap">{{ record.id }}</td>
<td>{{ record.node_id }}</td> <td class="cell-nowrap">{{ record.node_id }}</td>
<td>{{ record.long_name || '-' }}</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>{{ record.sign_text }}</td>
<td>{{ formatTime(record.sign_time) }}</td> <td class="cell-nowrap">{{ formatTime(record.sign_time) }}</td>
<td> <td class="cell-nowrap">
<button class="admin-button" :disabled="loading" @click="startEdit(record)">编辑</button> <button class="admin-button" :disabled="loading" @click="startEdit(record)">编辑</button>
<button class="admin-button danger" :disabled="loading" @click="removeSign(record)">删除</button> <button class="admin-button danger" :disabled="loading" @click="removeSign(record)">删除</button>
</td> </td>
+11 -11
View File
@@ -119,26 +119,26 @@ onMounted(refreshUsers)
<p v-if="userError" class="error">{{ userError }}</p> <p v-if="userError" class="error">{{ userError }}</p>
<p v-if="userMessage" class="success">{{ userMessage }}</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"> <table class="node-table">
<thead> <thead>
<tr> <tr>
<th>ID</th> <th class="cell-nowrap">ID</th>
<th>用户名</th> <th>用户名</th>
<th>角色</th> <th class="cell-nowrap">角色</th>
<th>创建时间</th> <th class="cell-nowrap">创建时间</th>
<th>更新时间</th> <th class="cell-nowrap">更新时间</th>
<th>新密码</th> <th>新密码</th>
<th>操作</th> <th class="cell-nowrap">操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="managedUser in users" :key="managedUser.id"> <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.username }} <span v-if="managedUser.username === props.user.username" class="badge">当前</span></td>
<td>{{ managedUser.role }}</td> <td class="cell-nowrap">{{ managedUser.role }}</td>
<td>{{ formatTime(managedUser.created_at) }}</td> <td class="cell-nowrap">{{ formatTime(managedUser.created_at) }}</td>
<td>{{ formatTime(managedUser.updated_at) }}</td> <td class="cell-nowrap">{{ formatTime(managedUser.updated_at) }}</td>
<td> <td>
<input <input
v-model="passwordEdits[managedUser.id]" v-model="passwordEdits[managedUser.id]"
@@ -148,7 +148,7 @@ onMounted(refreshUsers)
placeholder="输入新密码" placeholder="输入新密码"
/> />
</td> </td>
<td> <td class="cell-nowrap">
<button class="admin-button" :disabled="passwordSaving[managedUser.id]" @click="updatePassword(managedUser)"> <button class="admin-button" :disabled="passwordSaving[managedUser.id]" @click="updatePassword(managedUser)">
{{ passwordSaving[managedUser.id] ? '保存中...' : '修改密码' }} {{ passwordSaving[managedUser.id] ? '保存中...' : '修改密码' }}
</button> </button>
+30 -2
View File
@@ -1225,6 +1225,25 @@ h3 {
vertical-align: middle; 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 { .pubkey-cell {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -1259,10 +1278,13 @@ h3 {
color: var(--color-muted); color: var(--color-muted);
} }
.admin-login,
.admin-dashboard, .admin-dashboard,
.admin-session-card { .admin-session-card {
max-width: 1100px; width: 100%;
}
.admin-login {
max-width: 480px;
margin: 0 auto; margin: 0 auto;
width: 100%; width: 100%;
} }
@@ -1374,6 +1396,12 @@ h3 {
padding: 8px 10px; padding: 8px 10px;
} }
.table-fit-wrap .admin-table-input {
min-width: 0;
width: 100%;
box-sizing: border-box;
}
.success { .success {
margin: 0 16px 12px; margin: 0 16px 12px;
border: 1px solid color-mix(in srgb, var(--color-success) 34%, white); border: 1px solid color-mix(in srgb, var(--color-success) 34%, white);
+1 -1
View File
@@ -1 +1 @@
export const FRONTEND_VERSION = '0.3.1' export const FRONTEND_VERSION = '0.3.2'