| <!DOCTYPE html> |
| <html lang="zh-CN"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>OpenWebUI 模型头像URL修正工具 | 精致美化版</title> |
| <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script> |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> |
| <style> |
| |
| :root { |
| --primary-color: #4A6CF7; |
| --primary-hover: #3a5af5; |
| --secondary-color: #6c757d; |
| --success-color: #10b981; |
| --warning-color: #f59e0b; |
| --error-color: #ef4444; |
| --background-color: #f8fafc; |
| --card-bg: #ffffff; |
| --text-color: #1e293b; |
| --light-text: #64748b; |
| --border-color: #e2e8f0; |
| --gap: 20px; |
| --card-padding: 25px; |
| --button-font-size: 14px; |
| --border-radius: 12px; |
| --box-shadow: 0 10px 25px -5px rgba(0,0,0,0.05), 0 10px 10px -5px rgba(0,0,0,0.02); |
| --transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); |
| --switch-bg: #e2e8f0; |
| --switch-active: var(--primary-color); |
| } |
| |
| |
| * { |
| margin: 0; |
| padding: 0; |
| box-sizing: border-box; |
| } |
| |
| body { |
| font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif; |
| line-height: 1.6; |
| padding: 20px; |
| max-width: 1200px; |
| margin: 20px auto; |
| background-color: var(--background-color); |
| background-image: linear-gradient(135deg, #f0f4ff 0%, #f8fafc 100%); |
| color: var(--text-color); |
| } |
| |
| |
| .header { |
| text-align: center; |
| margin-bottom: 30px; |
| padding: 20px; |
| } |
| |
| .header h1 { |
| font-size: 2.2rem; |
| font-weight: 700; |
| margin-bottom: 8px; |
| background: linear-gradient(90deg, var(--primary-color), #8b5cf6); |
| -webkit-background-clip: text; |
| -webkit-text-fill-color: transparent; |
| position: relative; |
| display: inline-block; |
| } |
| |
| .header h1::after { |
| content: ''; |
| position: absolute; |
| bottom: -10px; |
| left: 50%; |
| transform: translateX(-50%); |
| width: 80px; |
| height: 4px; |
| background: linear-gradient(90deg, var(--primary-color), #8b5cf6); |
| border-radius: 2px; |
| } |
| |
| .header p { |
| font-size: 1.1rem; |
| color: var(--light-text); |
| max-width: 700px; |
| margin: 0 auto; |
| line-height: 1.7; |
| } |
| |
| |
| .main-tabs { |
| display: flex; |
| gap: 8px; |
| margin-bottom: var(--gap); |
| background: var(--card-bg); |
| border-radius: var(--border-radius); |
| padding: 6px; |
| box-shadow: var(--box-shadow); |
| } |
| |
| .main-tab-button { |
| flex: 1; |
| border: none; |
| background: none; |
| padding: 14px 20px; |
| font-size: 16px; |
| font-weight: 600; |
| color: var(--light-text); |
| cursor: pointer; |
| border-radius: 8px; |
| transition: var(--transition); |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| gap: 8px; |
| } |
| |
| .main-tab-button:hover { |
| background: #f1f5f9; |
| color: var(--primary-color); |
| } |
| |
| .main-tab-button.active { |
| background: var(--primary-color); |
| color: white; |
| box-shadow: 0 4px 6px -1px rgba(74, 108, 247, 0.2), 0 2px 4px -1px rgba(74, 108, 247, 0.06); |
| } |
| |
| |
| .tab-content { |
| background-color: var(--card-bg); |
| border-radius: var(--border-radius); |
| box-shadow: var(--box-shadow); |
| padding: var(--card-padding); |
| margin-bottom: var(--gap); |
| border: 1px solid var(--border-color); |
| transition: var(--transition); |
| } |
| |
| .tab-content:hover { |
| box-shadow: 0 20px 25px -5px rgba(0,0,0,0.08), 0 10px 10px -5px rgba(0,0,0,0.03); |
| } |
| |
| .section-title { |
| font-size: 1.3rem; |
| font-weight: 600; |
| margin-bottom: 20px; |
| padding-bottom: 12px; |
| border-bottom: 1px solid var(--border-color); |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| color: var(--primary-color); |
| } |
| |
| .section-title i { |
| font-size: 1.2rem; |
| } |
| |
| |
| .upload-section .input-group { |
| margin-bottom: 20px; |
| } |
| |
| .upload-section label { |
| font-size: 15px; |
| font-weight: 500; |
| color: var(--text-color); |
| margin-bottom: 10px; |
| display: block; |
| } |
| |
| |
| .file-upload-container { |
| position: relative; |
| display: flex; |
| flex-direction: column; |
| gap: 15px; |
| } |
| |
| .file-upload-btn { |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| gap: 10px; |
| padding: 16px 24px; |
| background: var(--primary-color); |
| color: white; |
| border-radius: var(--border-radius); |
| cursor: pointer; |
| transition: var(--transition); |
| border: none; |
| font-size: 16px; |
| font-weight: 600; |
| box-shadow: 0 4px 6px -1px rgba(74, 108, 247, 0.2), 0 2px 4px -1px rgba(74, 108, 247, 0.06); |
| } |
| |
| .file-upload-btn:hover { |
| background: var(--primary-hover); |
| transform: translateY(-2px); |
| box-shadow: 0 6px 10px -1px rgba(74, 108, 247, 0.3), 0 4px 6px -1px rgba(74, 108, 247, 0.1); |
| } |
| |
| .file-upload-btn i { |
| font-size: 18px; |
| } |
| |
| .file-name { |
| font-size: 14px; |
| padding: 12px 15px; |
| background: #f8fafc; |
| border-radius: var(--border-radius); |
| border: 1px dashed var(--border-color); |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| } |
| |
| .file-name i { |
| color: var(--success-color); |
| } |
| |
| .file-name.empty { |
| color: var(--light-text); |
| } |
| |
| .file-name.empty i { |
| color: var(--light-text); |
| } |
| |
| |
| .options-container { |
| display: flex; |
| flex-direction: column; |
| gap: 15px; |
| margin-bottom: 20px; |
| } |
| |
| .option-item { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| padding: 15px; |
| background: #f8fafc; |
| border-radius: var(--border-radius); |
| border: 1px solid var(--border-color); |
| transition: var(--transition); |
| } |
| |
| .option-item:hover { |
| border-color: var(--primary-color); |
| background: #eef2ff; |
| } |
| |
| .option-label { |
| font-size: 15px; |
| font-weight: 500; |
| color: var(--text-color); |
| display: flex; |
| align-items: center; |
| gap: 12px; |
| } |
| |
| .option-label i { |
| color: var(--primary-color); |
| font-size: 18px; |
| } |
| |
| .switch { |
| position: relative; |
| display: inline-block; |
| width: 50px; |
| height: 26px; |
| } |
| |
| .switch input { |
| opacity: 0; |
| width: 0; |
| height: 0; |
| } |
| |
| .slider { |
| position: absolute; |
| cursor: pointer; |
| top: 0; |
| left: 0; |
| right: 0; |
| bottom: 0; |
| background-color: var(--switch-bg); |
| transition: var(--transition); |
| border-radius: 34px; |
| } |
| |
| .slider:before { |
| position: absolute; |
| content: ""; |
| height: 20px; |
| width: 20px; |
| left: 3px; |
| bottom: 3px; |
| background-color: white; |
| transition: var(--transition); |
| border-radius: 50%; |
| } |
| |
| input:checked + .slider { |
| background-color: var(--switch-active); |
| } |
| |
| input:checked + .slider:before { |
| transform: translateX(24px); |
| } |
| |
| |
| .action-buttons { |
| display: flex; |
| gap: var(--gap); |
| margin-bottom: 20px; |
| flex-wrap: wrap; |
| } |
| |
| .button { |
| border: none; |
| border-radius: var(--border-radius); |
| padding: 14px 24px; |
| font-size: 15px; |
| cursor: pointer; |
| transition: var(--transition); |
| display: inline-flex; |
| align-items: center; |
| justify-content: center; |
| gap: 8px; |
| font-weight: 600; |
| } |
| |
| .button:hover { |
| transform: translateY(-2px); |
| box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -1px rgba(0,0,0,0.05); |
| } |
| |
| .button-primary { |
| background-color: var(--primary-color); |
| color: white; |
| } |
| |
| .button-primary:hover { |
| background-color: var(--primary-hover); |
| } |
| |
| .button-success { |
| background-color: var(--success-color); |
| color: white; |
| } |
| |
| .button-success:hover { |
| background-color: #059669; |
| } |
| |
| .button-secondary { |
| background-color: #e2e8f0; |
| color: var(--text-color); |
| } |
| |
| .button-secondary:hover { |
| background-color: #cbd5e1; |
| } |
| |
| .button-danger { |
| background-color: var(--error-color); |
| color: white; |
| } |
| |
| .button-danger:hover { |
| background-color: #dc2626; |
| } |
| |
| |
| .result-subtabs { |
| display: flex; |
| gap: 8px; |
| margin-bottom: 20px; |
| background: #f8fafc; |
| border-radius: var(--border-radius); |
| padding: 6px; |
| } |
| |
| .result-subtab-button { |
| flex: 1; |
| border: none; |
| background: none; |
| padding: 12px 20px; |
| font-size: 14px; |
| font-weight: 500; |
| color: var(--light-text); |
| cursor: pointer; |
| border-radius: 8px; |
| transition: var(--transition); |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| gap: 8px; |
| } |
| |
| .result-subtab-button:hover { |
| background: #eef2ff; |
| color: var(--primary-color); |
| } |
| |
| .result-subtab-button.active { |
| background: var(--primary-color); |
| color: white; |
| box-shadow: 0 4px 6px -1px rgba(74, 108, 247, 0.2), 0 2px 4px -1px rgba(74, 108, 247, 0.06); |
| } |
| |
| .log-container { |
| max-height: 300px; |
| overflow-y: auto; |
| padding: 15px; |
| border: 1px solid var(--border-color); |
| border-radius: var(--border-radius); |
| margin-bottom: 15px; |
| background: #f8fafc; |
| } |
| |
| .log-summary { |
| font-size: 15px; |
| font-weight: 600; |
| margin-bottom: 15px; |
| padding: 12px 15px; |
| border-radius: var(--border-radius); |
| background: #e0e7ff; |
| color: var(--primary-color); |
| display: flex; |
| justify-content: space-between; |
| flex-wrap: wrap; |
| gap: 10px; |
| } |
| |
| .log-summary span { |
| background: white; |
| padding: 5px 12px; |
| border-radius: 20px; |
| font-weight: 600; |
| } |
| |
| .log-item { |
| font-size: 14px; |
| padding: 12px 15px; |
| margin-bottom: 10px; |
| border-radius: 8px; |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| transition: var(--transition); |
| } |
| |
| .log-item:hover { |
| transform: translateY(-2px); |
| box-shadow: 0 4px 6px -1px rgba(0,0,0,0.05), 0 2px 4px -1px rgba(0,0,0,0.02); |
| } |
| |
| .log-item i { |
| font-size: 16px; |
| } |
| |
| .log-update { |
| background-color: #d1fae5; |
| color: var(--success-color); |
| border-left: 4px solid var(--success-color); |
| } |
| |
| .log-skip { |
| background-color: #fffbeb; |
| color: var(--warning-color); |
| border-left: 4px solid var(--warning-color); |
| } |
| |
| .log-notfound { |
| background-color: #fee2e2; |
| color: var(--error-color); |
| border-left: 4px solid var(--error-color); |
| } |
| |
| .output-container pre { |
| font-size: 14px; |
| padding: 15px; |
| border: 1px solid var(--border-color); |
| border-radius: var(--border-radius); |
| background-color: #f8fafc; |
| max-height: 300px; |
| overflow-y: auto; |
| margin-top: 10px; |
| line-height: 1.5; |
| font-family: 'Fira Code', 'Consolas', monospace; |
| } |
| |
| |
| .rules-section .rules-subtabs { |
| display: flex; |
| gap: 8px; |
| margin-bottom: 20px; |
| background: #f8fafc; |
| border-radius: var(--border-radius); |
| padding: 6px; |
| } |
| |
| .rules-subtab-button { |
| flex: 1; |
| border: none; |
| background: none; |
| padding: 12px 20px; |
| font-size: 14px; |
| font-weight: 500; |
| color: var(--light-text); |
| cursor: pointer; |
| border-radius: 8px; |
| transition: var(--transition); |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| gap: 8px; |
| } |
| |
| .rules-subtab-button:hover { |
| background: #eef2ff; |
| color: var(--primary-color); |
| } |
| |
| .rules-subtab-button.active { |
| background: var(--primary-color); |
| color: white; |
| box-shadow: 0 4px 6px -1px rgba(74, 108, 247, 0.2), 0 2px 4px -1px rgba(74, 108, 247, 0.06); |
| } |
| |
| |
| .rules-list { |
| max-height: 300px; |
| overflow-y: auto; |
| padding: 15px; |
| border: 1px solid var(--border-color); |
| border-radius: var(--border-radius); |
| margin-bottom: 20px; |
| background: #f8fafc; |
| } |
| |
| .rules-list-header { |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| margin-bottom: 15px; |
| padding-bottom: 10px; |
| border-bottom: 1px solid var(--border-color); |
| } |
| |
| .rules-list-header h4 { |
| font-size: 16px; |
| font-weight: 600; |
| color: var(--text-color); |
| } |
| |
| .rules-count { |
| background: var(--primary-color); |
| color: white; |
| padding: 3px 10px; |
| border-radius: 20px; |
| font-size: 13px; |
| font-weight: 500; |
| } |
| |
| .rules-list-item { |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| padding: 15px; |
| margin-bottom: 12px; |
| border: 1px solid var(--border-color); |
| border-radius: var(--border-radius); |
| background-color: white; |
| box-shadow: 0 2px 4px rgba(0,0,0,0.03); |
| transition: var(--transition); |
| } |
| |
| .rules-list-item:hover { |
| transform: translateX(3px); |
| border-left: 3px solid var(--primary-color); |
| } |
| |
| .rules-list-item .keyword { |
| font-weight: 600; |
| font-size: 15px; |
| color: var(--text-color); |
| background: #e0e7ff; |
| padding: 3px 10px; |
| border-radius: 20px; |
| display: inline-block; |
| } |
| |
| .rules-list-item .url { |
| font-size: 14px; |
| color: var(--secondary-color); |
| text-decoration: none; |
| white-space: nowrap; |
| overflow: hidden; |
| text-overflow: ellipsis; |
| display: block; |
| margin-top: 10px; |
| padding-left: 5px; |
| } |
| |
| .rules-list-item .delete-btn { |
| padding: 6px 12px; |
| font-size: 13px; |
| background-color: var(--error-color); |
| color: white; |
| border: none; |
| border-radius: 6px; |
| cursor: pointer; |
| transition: var(--transition); |
| display: flex; |
| align-items: center; |
| gap: 5px; |
| } |
| |
| .rules-list-item .delete-btn:hover { |
| background-color: #dc2626; |
| transform: translateY(-2px); |
| } |
| |
| |
| .add-rule-form { |
| padding: 20px; |
| border: 1px solid var(--border-color); |
| border-radius: var(--border-radius); |
| margin-bottom: 20px; |
| background: white; |
| box-shadow: var(--box-shadow); |
| } |
| |
| .add-rule-form h4 { |
| font-size: 16px; |
| margin-bottom: 15px; |
| color: var(--text-color); |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| } |
| |
| .add-rule-form label { |
| font-size: 14px; |
| font-weight: 500; |
| color: var(--text-color); |
| margin-bottom: 8px; |
| display: block; |
| } |
| |
| .add-rule-form input[type="text"] { |
| width: 100%; |
| padding: 12px 15px; |
| border: 1px solid var(--border-color); |
| border-radius: var(--border-radius); |
| font-size: 14px; |
| margin-bottom: 15px; |
| transition: var(--transition); |
| } |
| |
| .add-rule-form input[type="text"]:focus { |
| outline: none; |
| border-color: var(--primary-color); |
| box-shadow: 0 0 0 3px rgba(74, 108, 247, 0.2); |
| } |
| |
| |
| .raw-editor textarea { |
| width: 100%; |
| height: 200px; |
| padding: 15px; |
| border: 1px solid var(--border-color); |
| border-radius: var(--border-radius); |
| font-size: 14px; |
| resize: vertical; |
| margin-bottom: 20px; |
| font-family: 'Fira Code', 'Consolas', monospace; |
| line-height: 1.5; |
| transition: var(--transition); |
| } |
| |
| .raw-editor textarea:focus { |
| outline: none; |
| border-color: var(--primary-color); |
| box-shadow: 0 0 0 3px rgba(74, 108, 247, 0.2); |
| } |
| |
| .rules-action-buttons { |
| display: flex; |
| gap: 12px; |
| flex-wrap: wrap; |
| } |
| |
| |
| .status { |
| padding: 15px 20px; |
| border-radius: var(--border-radius); |
| font-size: 15px; |
| margin-bottom: var(--gap); |
| display: flex; |
| align-items: center; |
| gap: 12px; |
| box-shadow: var(--box-shadow); |
| position: relative; |
| overflow: hidden; |
| } |
| |
| .status::before { |
| content: ''; |
| position: absolute; |
| left: 0; |
| top: 0; |
| height: 100%; |
| width: 4px; |
| } |
| |
| .status.success { |
| background-color: #d1fae5; |
| color: var(--success-color); |
| } |
| |
| .status.success::before { |
| background-color: var(--success-color); |
| } |
| |
| .status.error { |
| background-color: #fee2e2; |
| color: var(--error-color); |
| } |
| |
| .status.error::before { |
| background-color: var(--error-color); |
| } |
| |
| .status i { |
| font-size: 18px; |
| } |
| |
| |
| .footer { |
| text-align: center; |
| margin-top: 40px; |
| padding: 20px; |
| color: var(--light-text); |
| font-size: 14px; |
| } |
| |
| .footer a { |
| color: var(--primary-color); |
| text-decoration: none; |
| } |
| |
| .footer a:hover { |
| text-decoration: underline; |
| } |
| |
| |
| @media (max-width: 768px) { |
| .main-tabs { |
| flex-direction: column; |
| gap: 5px; |
| } |
| |
| .result-subtabs { |
| flex-direction: column; |
| gap: 5px; |
| } |
| |
| .rules-subtabs { |
| flex-direction: column; |
| gap: 5px; |
| } |
| |
| .header h1 { |
| font-size: 1.8rem; |
| } |
| |
| .header p { |
| font-size: 1rem; |
| } |
| |
| .action-buttons, .rules-action-buttons { |
| flex-direction: column; |
| } |
| |
| .button { |
| width: 100%; |
| } |
| } |
| </style> |
| </head> |
| <body> |
| <div id="app"> |
| |
| <div class="header"> |
| <h1><i class="fas fa-cogs"></i> OpenWebUI 模型头像URL修正工具</h1> |
| <p>自动检测并修正模型配置文件中的头像URL,支持自定义规则管理,提升OpenWebUI界面美观度</p> |
| </div> |
|
|
| |
| <div v-if="statusMessage.text" :class="['status', statusMessage.type]"> |
| <i :class="statusMessage.icon"></i> |
| <span>{{ statusMessage.text }}</span> |
| </div> |
|
|
| |
| <div class="main-tabs"> |
| <button @click="activeMainTab='upload'" :class="['main-tab-button', { active: activeMainTab==='upload' }]"> |
| <i class="fas fa-upload"></i> 上传与结果 |
| </button> |
| <button @click="activeMainTab='rules'" :class="['main-tab-button', { active: activeMainTab==='rules' }]"> |
| <i class="fas fa-list"></i> 规则管理 |
| </button> |
| </div> |
|
|
| |
| <div class="tab-content"> |
| |
| <div v-if="activeMainTab==='upload'"> |
| <div class="upload-section"> |
| <h3 class="section-title"><i class="fas fa-file-import"></i> 模型文件处理</h3> |
| |
| |
| <div class="input-group"> |
| <label>选择模型JSON文件:</label> |
| <div class="file-upload-container"> |
| <input type="file" id="jsonFile" @change="handleFileChange" accept=".json" ref="fileInput" style="display: none;"> |
| <button class="file-upload-btn" @click="triggerFileInput"> |
| <i class="fas fa-folder-open"></i> 选择JSON文件 |
| </button> |
| <div :class="['file-name', fileContent ? '' : 'empty']"> |
| <i :class="fileContent ? 'fas fa-check-circle' : 'fas fa-info-circle'"></i> |
| {{ fileContent ? originalFileName : '未选择文件' }} |
| </div> |
| </div> |
| </div> |
| |
| |
| <div class="options-container"> |
| <div class="option-item"> |
| <div class="option-label"> |
| <i class="fas fa-binoculars"></i> |
| <span>仅预览模式(不修改文件)</span> |
| </div> |
| <label class="switch"> |
| <input type="checkbox" v-model="isDryRun"> |
| <span class="slider"></span> |
| </label> |
| </div> |
| <div class="option-item"> |
| <div class="option-label"> |
| <i class="fas fa-sync-alt"></i> |
| <span>覆盖已有非默认URL</span> |
| </div> |
| <label class="switch"> |
| <input type="checkbox" v-model="overwriteExisting"> |
| <span class="slider"></span> |
| </label> |
| </div> |
| </div> |
| |
| |
| <div class="action-buttons"> |
| <button @click="processFile" class="button button-primary" :disabled="!fileContent"> |
| <i class="fas fa-bolt"></i> {{ isDryRun ? '开始预览' : '开始处理' }} |
| </button> |
| <button @click="downloadJson" class="button button-success" :disabled="!outputJson"> |
| <i class="fas fa-download"></i> 下载修正文件 |
| </button> |
| <button @click="resetFile" class="button button-secondary"> |
| <i class="fas fa-redo"></i> 重置 |
| </button> |
| </div> |
| </div> |
|
|
| |
| <div v-if="logs.length > 0 || outputJson"> |
| <h3 class="section-title"><i class="fas fa-chart-bar"></i> 处理结果</h3> |
| |
| <div class="result-subtabs"> |
| <button @click="activeResultSubtab='logs'" :class="['result-subtab-button', { active: activeResultSubtab==='logs' }]"> |
| <i class="fas fa-list"></i> 处理日志 |
| </button> |
| <button @click="activeResultSubtab='output'" :class="['result-subtab-button', { active: activeResultSubtab==='output' }]"> |
| <i class="fas fa-code"></i> 输出JSON |
| </button> |
| </div> |
|
|
| |
| <div v-if="activeResultSubtab==='logs'" class="log-container"> |
| <div class="log-summary"> |
| 处理结果:共{{ totalModels }}条模型 |
| <span>更新: {{ updatedCount }}</span> |
| <span>跳过: {{ skippedCount }}</span> |
| <span>未匹配: {{ notFoundCount }}</span> |
| </div> |
| <div v-for="(log, index) in logs" :key="index" :class="['log-item', `log-${log.type}`]"> |
| <i :class="log.icon"></i> |
| <span>{{ log.message }}</span> |
| </div> |
| </div> |
|
|
| |
| <div v-if="activeResultSubtab==='output' && !isDryRun" class="output-container"> |
| <pre>{{ outputJson }}</pre> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div v-if="activeMainTab==='rules'"> |
| <div class="rules-section"> |
| <h3 class="section-title"><i class="fas fa-sliders-h"></i> 规则管理</h3> |
| |
| |
| <div class="rules-subtabs"> |
| <button @click="activeRulesSubtab='list'" :class="['rules-subtab-button', { active: activeRulesSubtab==='list' }]"> |
| <i class="fas fa-th-list"></i> 现有规则 |
| </button> |
| <button @click="activeRulesSubtab='raw'" :class="['rules-subtab-button', { active: activeRulesSubtab==='raw' }]"> |
| <i class="fas fa-code"></i> 原始JSON |
| </button> |
| </div> |
|
|
| |
| <div v-if="activeRulesSubtab==='list'"> |
| <div class="rules-list"> |
| <div class="rules-list-header"> |
| <h4>当前规则列表</h4> |
| <div class="rules-count">{{ providerMap.length }} 条规则</div> |
| </div> |
| <div v-if="providerMap.length === 0" class="empty-rules"> |
| <p style="font-size:14px; color:var(--light-text); text-align:center; padding:20px;"> |
| <i class="fas fa-inbox" style="font-size:24px; margin-bottom:10px;"></i><br> |
| 暂无规则,请添加新规则 |
| </p> |
| </div> |
| <div v-for="(rule, index) in providerMap" :key="index" class="rules-list-item"> |
| <div> |
| <span class="keyword">{{ rule[0] }}</span> |
| <a :href="rule[1]" target="_blank" class="url">{{ rule[1] }}</a> |
| </div> |
| <button @click="deleteRule(index)" class="delete-btn"> |
| <i class="fas fa-trash-alt"></i> 删除 |
| </button> |
| </div> |
| </div> |
|
|
| |
| <div class="add-rule-form"> |
| <h4><i class="fas fa-plus-circle"></i> 添加新规则</h4> |
| <label> |
| 匹配关键词(如"gpt"): |
| <input type="text" v-model="newKeyword" placeholder="请输入关键词" required> |
| </label> |
| <label> |
| 头像URL(完整链接): |
| <input type="text" v-model="newUrl" placeholder="请输入图片URL" required> |
| </label> |
| <button @click="addRule" class="button button-primary" :disabled="!newKeyword || !newUrl"> |
| <i class="fas fa-save"></i> 添加规则 |
| </button> |
| </div> |
| </div> |
|
|
| |
| <div v-if="activeRulesSubtab==='raw'"> |
| <div class="raw-editor"> |
| <textarea v-model="rulesText" placeholder="规则格式:[['关键词1','URL1'],['关键词2','URL2']]"></textarea> |
| </div> |
| <div class="rules-action-buttons"> |
| <button @click="saveRules" class="button button-primary"> |
| <i class="fas fa-save"></i> 保存规则 |
| </button> |
| <button @click="importRules" class="button button-secondary"> |
| <i class="fas fa-file-import"></i> 导入规则 |
| </button> |
| <button @click="exportRules" class="button button-secondary"> |
| <i class="fas fa-file-export"></i> 导出规则 |
| </button> |
| <button @click="resetRules" class="button button-danger"> |
| <i class="fas fa-undo"></i> 重置为默认 |
| </button> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div class="footer"> |
| <p>图像 CDN 服务由 lobechat 提供 再次感谢 lobechat 提供的图像 CDN 服务</p> |
| </div> |
| </div> |
|
|
| <script> |
| const { createApp, ref, reactive, watch, onMounted } = Vue; |
| |
| |
| const DEFAULT_RULES = [ |
| ["openrouter/", "https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/openrouter.svg"], |
| ["aliyun/", "https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/aliyun.svg"], |
| ["gemini_pipe_new.", "https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/gemini.svg"], |
| ["gpt", "https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/openai.svg"], |
| ["openai", "https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/openai.svg"], |
| ["o1-", "https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/openai.svg"], |
| ["o3-", "https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/openai.svg"], |
| ["whisper", "https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/openai.svg"], |
| ["text-embedding-", "https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/dalle.svg"], |
| ["tts-", "https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/dalle.svg"], |
| ["dall-e", "https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/dalle.svg"], |
| ["claude", "https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/claude.svg"], |
| ["gemini", "https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/gemini.svg"], |
| ["ernie", "https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/wenxin.svg"], |
| ["baidu", "https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/wenxin.svg"], |
| ["command", "https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/cohere.svg"], |
| ["cohere", "https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/cohere.svg"], |
| ["deepseek", "https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/deepseek.svg"], |
| ["grok", "https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/grok.svg"], |
| ["llama", "https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/meta.svg"], |
| ["meta", "https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/meta.svg"], |
| ["groq", "https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/groq.svg"], |
| ["qwq", "https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/qwen.svg"], |
| ["qvq", "https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/qwen.svg"], |
| ["qwen", "https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/qwen.svg"], |
| ["abab", "https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/minimax.svg"], |
| ["minimax", "https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/minimax.svg"], |
| ["mistral", "https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/mistral.svg"], |
| ["kimi", "https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/kimi.svg"], |
| ["moonshot", "https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/kimi.svg"], |
| ["ollama", "https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/ollama.svg"], |
| ["hunyuan", "https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/hunyuan.svg"], |
| ["tencent", "https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/hunyuan.svg"], |
| ["yi", "https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/yi.svg"], |
| ["glm", "https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/qingyan.svg"], |
| ["zhipu", "https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/qingyan.svg"], |
| ["open-mixtral","https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/mistral.svg"], |
| ["ministral","https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/mistral.svg"], |
| ["codestral","https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/mistral.svg"], |
| ["pixtral","https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/mistral.svg"], |
| ["doubao","https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/doubao.svg"] |
| ]; |
| |
| |
| const STORAGE_KEY = "openwebui_avatar_rules"; |
| |
| createApp({ |
| setup() { |
| |
| const activeMainTab = ref("upload"); |
| const activeResultSubtab = ref("logs"); |
| const activeRulesSubtab = ref("list"); |
| |
| |
| const fileContent = ref(null); |
| const originalFileName = ref(""); |
| const isDryRun = ref(false); |
| const overwriteExisting = ref(false); |
| const providerMap = ref([]); |
| const rulesText = ref(""); |
| const newKeyword = ref(""); |
| const newUrl = ref(""); |
| const logs = ref([]); |
| const outputJson = ref(""); |
| const error = ref(""); |
| const statusMessage = reactive({ text: "", type: "success", icon: "" }); |
| const fileInput = ref(null); |
| |
| |
| const totalModels = ref(0); |
| const updatedCount = ref(0); |
| const skippedCount = ref(0); |
| const notFoundCount = ref(0); |
| |
| |
| onMounted(() => { |
| loadRules(); |
| }); |
| |
| |
| watch(providerMap, (newRules) => { |
| rulesText.value = JSON.stringify(newRules, null, 2); |
| }, { deep: true }); |
| |
| |
| const loadRules = () => { |
| try { |
| const stored = localStorage.getItem(STORAGE_KEY); |
| providerMap.value = stored ? JSON.parse(stored) : [...DEFAULT_RULES]; |
| showStatus("规则加载成功", "success", "fas fa-check-circle"); |
| } catch (e) { |
| console.error("加载规则失败:", e); |
| providerMap.value = [...DEFAULT_RULES]; |
| showStatus("加载规则失败,使用默认规则", "error", "fas fa-exclamation-circle"); |
| } |
| }; |
| |
| const saveRules = () => { |
| try { |
| if (activeRulesSubtab.value === "raw") { |
| const parsed = JSON.parse(rulesText.value); |
| if (!Array.isArray(parsed) || !parsed.every(item => Array.isArray(item) && item.length === 2)) { |
| throw new Error("规则格式错误,请检查JSON结构"); |
| } |
| providerMap.value = parsed; |
| } |
| localStorage.setItem(STORAGE_KEY, JSON.stringify(providerMap.value)); |
| showStatus("规则保存成功", "success", "fas fa-check-circle"); |
| } catch (e) { |
| console.error("保存规则失败:", e); |
| showStatus(`保存失败:${e.message}`, "error", "fas fa-exclamation-circle"); |
| } |
| }; |
| |
| const importRules = () => { |
| const input = document.createElement("input"); |
| input.type = "file"; |
| input.accept = ".json"; |
| input.onchange = (e) => { |
| const file = e.target.files[0]; |
| if (!file) return; |
| const reader = new FileReader(); |
| reader.onload = (event) => { |
| try { |
| const rules = JSON.parse(event.target.result); |
| if (Array.isArray(rules) && rules.every(item => Array.isArray(item) && item.length === 2)) { |
| providerMap.value = rules; |
| saveRules(); |
| showStatus("规则导入成功", "success", "fas fa-check-circle"); |
| } else { |
| throw new Error("导入文件格式错误,需为规则JSON数组"); |
| } |
| } catch (e) { |
| console.error("导入失败:", e); |
| showStatus(`导入失败:${e.message}`, "error", "fas fa-exclamation-circle"); |
| } |
| }; |
| reader.readAsText(file); |
| }; |
| input.click(); |
| }; |
| |
| const exportRules = () => { |
| const blob = new Blob([JSON.stringify(providerMap.value, null, 2)], { type: "application/json" }); |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement("a"); |
| a.href = url; |
| a.download = "openwebui_avatar_rules.json"; |
| a.click(); |
| URL.revokeObjectURL(url); |
| showStatus("规则导出成功", "success", "fas fa-check-circle"); |
| }; |
| |
| const resetRules = () => { |
| if (confirm("确定要重置为默认规则吗?当前规则将丢失")) { |
| providerMap.value = [...DEFAULT_RULES]; |
| localStorage.removeItem(STORAGE_KEY); |
| showStatus("规则已重置为默认", "success", "fas fa-check-circle"); |
| } |
| }; |
| |
| const addRule = () => { |
| if (!newKeyword.value || !newUrl.value) { |
| showStatus("关键词和URL不能为空", "error", "fas fa-exclamation-circle"); |
| return; |
| } |
| if (!newUrl.value.startsWith('http')) { |
| showStatus("URL格式无效,必须以http/https开头", "error", "fas fa-exclamation-circle"); |
| return; |
| } |
| const exists = providerMap.value.some(rule => rule[0].toLowerCase() === newKeyword.value.toLowerCase()); |
| if (exists) { |
| showStatus("该关键词已存在,请修改后重试", "error", "fas fa-exclamation-circle"); |
| return; |
| } |
| providerMap.value.push([newKeyword.value, newUrl.value]); |
| newKeyword.value = ""; |
| newUrl.value = ""; |
| saveRules(); |
| showStatus("规则添加成功", "success", "fas fa-check-circle"); |
| }; |
| |
| const deleteRule = (index) => { |
| if (confirm("确定要删除该规则吗?")) { |
| providerMap.value.splice(index, 1); |
| saveRules(); |
| showStatus("规则删除成功", "success", "fas fa-check-circle"); |
| } |
| }; |
| |
| |
| const triggerFileInput = () => { |
| fileInput.value.click(); |
| }; |
| |
| const handleFileChange = (e) => { |
| const file = e.target.files[0]; |
| if (!file) return; |
| originalFileName.value = file.name; |
| const reader = new FileReader(); |
| reader.onload = (event) => { |
| fileContent.value = event.target.result; |
| error.value = ""; |
| showStatus(`文件 "${file.name}" 加载成功`, "success", "fas fa-check-circle"); |
| }; |
| reader.onerror = () => { |
| showStatus("文件读取失败,请检查文件完整性", "error", "fas fa-exclamation-circle"); |
| }; |
| reader.readAsText(file); |
| }; |
| |
| const resetFile = () => { |
| fileContent.value = null; |
| originalFileName.value = ""; |
| logs.value = []; |
| outputJson.value = ""; |
| fileInput.value.value = ""; |
| showStatus("文件已重置", "success", "fas fa-redo"); |
| }; |
| |
| const processFile = () => { |
| if (!fileContent.value) { |
| showStatus("请先选择模型JSON文件", "error", "fas fa-exclamation-circle"); |
| return; |
| } |
| if (providerMap.value.length === 0) { |
| showStatus("请先添加或导入规则", "error", "fas fa-exclamation-circle"); |
| return; |
| } |
| |
| try { |
| const data = JSON.parse(fileContent.value); |
| if (!Array.isArray(data)) { |
| throw new Error("JSON文件格式错误,顶层应为模型数组"); |
| } |
| |
| |
| totalModels.value = data.length; |
| updatedCount.value = 0; |
| skippedCount.value = 0; |
| notFoundCount.value = 0; |
| logs.value = []; |
| |
| |
| const processed = data.map(model => { |
| const clone = { ...model }; |
| const modelId = clone.id || ""; |
| const currentUrl = clone.meta?.profile_image_url || ""; |
| |
| |
| if (clone.base_model_id === undefined) { |
| clone.base_model_id = null; |
| } |
| |
| |
| let newUrl = null; |
| for (const [keyword, url] of providerMap.value) { |
| if (modelId.toLowerCase().includes(keyword.toLowerCase())) { |
| newUrl = url; |
| break; |
| } |
| } |
| |
| |
| if (newUrl) { |
| if (currentUrl === newUrl) { |
| skippedCount.value++; |
| logs.value.push({ |
| type: "skip", |
| message: `[跳过] ${modelId}:已使用正确URL`, |
| icon: "fas fa-redo" |
| }); |
| } else if (currentUrl === "" || currentUrl === "/static/favicon.png" || overwriteExisting.value) { |
| if (!isDryRun.value) { |
| clone.meta = clone.meta || {}; |
| clone.meta.profile_image_url = newUrl; |
| } |
| updatedCount.value++; |
| logs.value.push({ |
| type: "update", |
| message: `[更新] ${modelId}:旧URL→${currentUrl || "空"},新URL→${newUrl}`, |
| icon: "fas fa-sync-alt" |
| }); |
| } else { |
| skippedCount.value++; |
| logs.value.push({ |
| type: "skip", |
| message: `[跳过] ${modelId}:已有非默认URL(未勾选覆盖)`, |
| icon: "fas fa-ban" |
| }); |
| } |
| } else { |
| notFoundCount.value++; |
| logs.value.push({ |
| type: "notfound", |
| message: `[未匹配] ${modelId}:无对应规则`, |
| icon: "fas fa-search" |
| }); |
| } |
| |
| return clone; |
| }); |
| |
| |
| if (!isDryRun.value) { |
| outputJson.value = JSON.stringify(processed, null, 2); |
| } else { |
| outputJson.value = ""; |
| } |
| |
| showStatus(`处理完成:共${totalModels.value}条模型`, "success", "fas fa-check-circle"); |
| } catch (e) { |
| console.error("处理失败:", e); |
| showStatus(`处理失败:${e.message}`, "error", "fas fa-exclamation-circle"); |
| } |
| }; |
| |
| const downloadJson = () => { |
| if (!outputJson.value) return; |
| const blob = new Blob([outputJson.value], { type: "application/json" }); |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement("a"); |
| a.href = url; |
| a.download = `${originalFileName.value.replace(".json", "")}_modified.json`; |
| a.click(); |
| URL.revokeObjectURL(url); |
| showStatus("文件下载成功", "success", "fas fa-download"); |
| }; |
| |
| |
| const showStatus = (message, type = "success", icon = "fas fa-info-circle") => { |
| statusMessage.text = message; |
| statusMessage.type = type; |
| statusMessage.icon = icon; |
| setTimeout(() => { |
| statusMessage.text = ""; |
| }, 4000); |
| }; |
| |
| |
| return { |
| activeMainTab, |
| activeResultSubtab, |
| activeRulesSubtab, |
| fileContent, |
| originalFileName, |
| isDryRun, |
| overwriteExisting, |
| providerMap, |
| rulesText, |
| newKeyword, |
| newUrl, |
| logs, |
| outputJson, |
| error, |
| statusMessage, |
| fileInput, |
| totalModels, |
| updatedCount, |
| skippedCount, |
| notFoundCount, |
| loadRules, |
| saveRules, |
| importRules, |
| exportRules, |
| resetRules, |
| addRule, |
| deleteRule, |
| triggerFileInput, |
| handleFileChange, |
| resetFile, |
| processFile, |
| downloadJson |
| }; |
| } |
| }).mount("#app"); |
| </script> |
| </body> |
| </html> |