feat: Add model icon support across backend and UI; prefer model icon over vendor; add icon column in Models table

Backend:
- Model: Add `icon` field to `model.Model` (gorm: varchar(128)); auto-migrated via GORM.
- Pricing API: Extend `model.Pricing` with `icon` and populate from model meta in `GetPricing()`.

Frontend:
- EditModelModal: Add `icon` input (with @lobehub/icons helper link); wire into init/load/submit flows.
- ModelHeader / PricingCardView: Prefer rendering `model.icon`; fallback to `vendor_icon`; final fallback to initials avatar.
- Models table: Add leading “Icon” column, rendering `model.icon` or `vendor` icon via `getLobeHubIcon`.

Notes:
- Backward-compatible. Existing data without `icon` remain unaffected.
- No manual SQL needed; column is added by AutoMigrate.

Affected files:
- model/model_meta.go
- model/pricing.go
- web/src/components/table/models/modals/EditModelModal.jsx
- web/src/components/table/model-pricing/modal/components/ModelHeader.jsx
- web/src/components/table/model-pricing/view/card/PricingCardView.jsx
- web/src/components/table/models/ModelsColumnDefs.js
This commit is contained in:
t0ng7u
2025-08-10 01:38:59 +08:00
parent 9572e16dcb
commit cb75e25a1a
7 changed files with 75 additions and 4 deletions

View File

@@ -38,6 +38,7 @@ type Model struct {
Id int `json:"id"` Id int `json:"id"`
ModelName string `json:"model_name" gorm:"size:128;not null;uniqueIndex:uk_model_name,priority:1"` ModelName string `json:"model_name" gorm:"size:128;not null;uniqueIndex:uk_model_name,priority:1"`
Description string `json:"description,omitempty" gorm:"type:text"` Description string `json:"description,omitempty" gorm:"type:text"`
Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"`
Tags string `json:"tags,omitempty" gorm:"type:varchar(255)"` Tags string `json:"tags,omitempty" gorm:"type:varchar(255)"`
VendorID int `json:"vendor_id,omitempty" gorm:"index"` VendorID int `json:"vendor_id,omitempty" gorm:"index"`
Endpoints string `json:"endpoints,omitempty" gorm:"type:text"` Endpoints string `json:"endpoints,omitempty" gorm:"type:text"`

View File

@@ -16,6 +16,7 @@ import (
type Pricing struct { type Pricing struct {
ModelName string `json:"model_name"` ModelName string `json:"model_name"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
Icon string `json:"icon,omitempty"`
Tags string `json:"tags,omitempty"` Tags string `json:"tags,omitempty"`
VendorID int `json:"vendor_id,omitempty"` VendorID int `json:"vendor_id,omitempty"`
QuotaType int `json:"quota_type"` QuotaType int `json:"quota_type"`
@@ -272,6 +273,7 @@ func updatePricing() {
continue continue
} }
pricing.Description = meta.Description pricing.Description = meta.Description
pricing.Icon = meta.Icon
pricing.Tags = meta.Tags pricing.Tags = meta.Tags
pricing.VendorID = meta.VendorID pricing.VendorID = meta.VendorID
} }

View File

@@ -29,9 +29,19 @@ const CARD_STYLES = {
}; };
const ModelHeader = ({ modelData, vendorsMap = {}, t }) => { const ModelHeader = ({ modelData, vendorsMap = {}, t }) => {
// 获取模型图标(使用供应商图标) // 获取模型图标(优先模型图标,其次供应商图标)
const getModelIcon = () => { const getModelIcon = () => {
// 优先使用供应商图标 // 1) 优先使用模型自定义图标
if (modelData?.icon) {
return (
<div className={CARD_STYLES.container}>
<div className={CARD_STYLES.icon}>
{getLobeHubIcon(modelData.icon, 32)}
</div>
</div>
);
}
// 2) 退化为供应商图标
if (modelData?.vendor_icon) { if (modelData?.vendor_icon) {
return ( return (
<div className={CARD_STYLES.container}> <div className={CARD_STYLES.container}>

View File

@@ -81,7 +81,17 @@ const PricingCardView = ({
</div> </div>
); );
} }
// 优先使用供应商图标 // 1) 优先使用模型自定义图标
if (model.icon) {
return (
<div className={CARD_STYLES.container}>
<div className={CARD_STYLES.icon}>
{getLobeHubIcon(model.icon, 32)}
</div>
</div>
);
}
// 2) 退化为供应商图标
if (model.vendor_icon) { if (model.vendor_icon) {
return ( return (
<div className={CARD_STYLES.container}> <div className={CARD_STYLES.container}>

View File

@@ -33,6 +33,17 @@ function renderTimestamp(timestamp) {
return <>{timestamp2string(timestamp)}</>; return <>{timestamp2string(timestamp)}</>;
} }
// Render model icon column: prefer model.icon, then fallback to vendor icon
const renderModelIconCol = (record, vendorMap) => {
const iconKey = record?.icon || vendorMap[record?.vendor_id]?.icon;
if (!iconKey) return '-';
return (
<div className="flex items-center justify-center">
{getLobeHubIcon(iconKey, 20)}
</div>
);
};
// Render vendor column with icon // Render vendor column with icon
const renderVendorTag = (vendorId, vendorMap, t) => { const renderVendorTag = (vendorId, vendorMap, t) => {
if (!vendorId || !vendorMap[vendorId]) return '-'; if (!vendorId || !vendorMap[vendorId]) return '-';
@@ -222,6 +233,13 @@ export const getModelsColumns = ({
vendorMap, vendorMap,
}) => { }) => {
return [ return [
{
title: t('图标'),
dataIndex: 'icon',
width: 70,
align: 'center',
render: (text, record) => renderModelIconCol(record, vendorMap),
},
{ {
title: t('模型名称'), title: t('模型名称'),
dataIndex: 'model_name', dataIndex: 'model_name',

View File

@@ -33,6 +33,7 @@ import {
Row, Row,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import { Save, X, FileText } from 'lucide-react'; import { Save, X, FileText } from 'lucide-react';
import { IconLink } from '@douyinfe/semi-icons';
import { API, showError, showSuccess } from '../../../../helpers'; import { API, showError, showSuccess } from '../../../../helpers';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useIsMobile } from '../../../../hooks/common/useIsMobile'; import { useIsMobile } from '../../../../hooks/common/useIsMobile';
@@ -112,6 +113,7 @@ const EditModelModal = (props) => {
const getInitValues = () => ({ const getInitValues = () => ({
model_name: props.editingModel?.model_name || '', model_name: props.editingModel?.model_name || '',
description: '', description: '',
icon: '',
tags: [], tags: [],
vendor_id: undefined, vendor_id: undefined,
vendor: '', vendor: '',
@@ -314,6 +316,27 @@ const EditModelModal = (props) => {
/> />
</Col> </Col>
<Col span={24}>
<Form.Input
field='icon'
label={t('模型图标')}
placeholder={t('请输入图标名称')}
extraText={
<span>
{t('图标使用@lobehub/icons库OpenAI、Claude.Color支持链式参数OpenAI.Avatar.type={\'platform\'}、OpenRouter.Avatar.shape={\'square\'},查询所有可用图标请 ')}
<Typography.Text
link={{ href: 'https://icons.lobehub.com/components/lobe-hub', target: '_blank' }}
icon={<IconLink />}
underline
>
{t('请点击我')}
</Typography.Text>
</span>
}
showClear
/>
</Col>
<Col span={24}> <Col span={24}>
<Form.TextArea <Form.TextArea
field='description' field='description'

View File

@@ -1907,5 +1907,12 @@
"确定要禁用所有的密钥吗?": "Are you sure you want to disable all keys?", "确定要禁用所有的密钥吗?": "Are you sure you want to disable all keys?",
"确定要删除所有已自动禁用的密钥吗?": "Are you sure you want to delete all automatically disabled keys?", "确定要删除所有已自动禁用的密钥吗?": "Are you sure you want to delete all automatically disabled keys?",
"此操作不可撤销,将永久删除已自动禁用的密钥": "This operation cannot be undone, and all automatically disabled keys will be permanently deleted.", "此操作不可撤销,将永久删除已自动禁用的密钥": "This operation cannot be undone, and all automatically disabled keys will be permanently deleted.",
"删除自动禁用密钥": "Delete auto disabled keys" "删除自动禁用密钥": "Delete auto disabled keys",
"图标": "Icon",
"模型图标": "Model icon",
"请输入图标名称": "Please enter the icon name",
"精确名称匹配": "Exact name matching",
"前缀名称匹配": "Prefix name matching",
"后缀名称匹配": "Suffix name matching",
"包含名称匹配": "Contains name matching"
} }