mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-31 22:57:14 +00:00
feat: add endpoint for retrieving user space credits and implement caching mechanism in UserService
This commit is contained in:
@@ -158,6 +158,12 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@self.route('/space-credits', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def _(user_email: str) -> str:
|
||||||
|
"""Get Space credits balance for current user"""
|
||||||
|
credits = await self.ap.user_service.get_space_credits(user_email)
|
||||||
|
return self.success(data={'credits': credits})
|
||||||
|
|
||||||
@self.route('/account-info', methods=['GET'], auth_type=group.AuthType.NONE)
|
@self.route('/account-info', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||||
async def _() -> str:
|
async def _() -> str:
|
||||||
"""Get account info for login page (account type and has_password)"""
|
"""Get account info for login page (account type and has_password)"""
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ from ....utils import constants
|
|||||||
class UserService:
|
class UserService:
|
||||||
ap: app.Application
|
ap: app.Application
|
||||||
_create_user_lock: asyncio.Lock
|
_create_user_lock: asyncio.Lock
|
||||||
|
_space_credits_cache: typing.Dict[str, typing.Tuple[int, float]] # {user_email: (credits, timestamp)}
|
||||||
|
|
||||||
def __init__(self, ap: app.Application) -> None:
|
def __init__(self, ap: app.Application) -> None:
|
||||||
self.ap = ap
|
self.ap = ap
|
||||||
self._create_user_lock = asyncio.Lock()
|
self._create_user_lock = asyncio.Lock()
|
||||||
|
self._space_credits_cache = {}
|
||||||
|
|
||||||
def _get_space_config(self) -> typing.Dict[str, str]:
|
def _get_space_config(self) -> typing.Dict[str, str]:
|
||||||
"""Get Space configuration from config file"""
|
"""Get Space configuration from config file"""
|
||||||
@@ -178,6 +180,30 @@ class UserService:
|
|||||||
raise ValueError(f'Failed to get user info: {data.get("msg")}')
|
raise ValueError(f'Failed to get user info: {data.get("msg")}')
|
||||||
return data.get('data', {})
|
return data.get('data', {})
|
||||||
|
|
||||||
|
async def get_space_credits(self, user_email: str, force_refresh: bool = False) -> int | None:
|
||||||
|
"""Get Space credits for user with caching (60s TTL)"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
cache_ttl = 60
|
||||||
|
|
||||||
|
if not force_refresh and user_email in self._space_credits_cache:
|
||||||
|
credits, ts = self._space_credits_cache[user_email]
|
||||||
|
if time.time() - ts < cache_ttl:
|
||||||
|
return credits
|
||||||
|
|
||||||
|
user_obj = await self.get_user_by_email(user_email)
|
||||||
|
if not user_obj or user_obj.account_type != 'space' or not user_obj.space_access_token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
info = await self.get_space_user_info(user_obj.space_access_token)
|
||||||
|
credits = info.get('credits')
|
||||||
|
if credits is not None:
|
||||||
|
self._space_credits_cache[user_email] = (credits, time.time())
|
||||||
|
return credits
|
||||||
|
except Exception:
|
||||||
|
return self._space_credits_cache.get(user_email, (None, 0))[0]
|
||||||
|
|
||||||
async def refresh_space_token(self, refresh_token: str) -> typing.Dict:
|
async def refresh_space_token(self, refresh_token: str) -> typing.Dict:
|
||||||
"""Refresh Space access token"""
|
"""Refresh Space access token"""
|
||||||
space_config = self._get_space_config()
|
space_config = self._get_space_config()
|
||||||
|
|||||||
@@ -9,12 +9,11 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
Trash2,
|
Trash2,
|
||||||
Settings,
|
Settings,
|
||||||
Sparkles,
|
|
||||||
LogIn,
|
LogIn,
|
||||||
Eye,
|
Eye,
|
||||||
Wrench,
|
Wrench,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
|
||||||
import {
|
import {
|
||||||
LLMModel,
|
LLMModel,
|
||||||
EmbeddingModel,
|
EmbeddingModel,
|
||||||
@@ -46,6 +45,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import LLMForm from './component/llm-form/LLMForm';
|
import LLMForm from './component/llm-form/LLMForm';
|
||||||
import EmbeddingForm from './component/embedding-form/EmbeddingForm';
|
import EmbeddingForm from './component/embedding-form/EmbeddingForm';
|
||||||
import ProviderForm from './component/provider-form/ProviderForm';
|
import ProviderForm from './component/provider-form/ProviderForm';
|
||||||
|
import langbotIcon from '@/app/assets/langbot-logo.webp';
|
||||||
|
|
||||||
interface ModelsDialogProps {
|
interface ModelsDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -62,7 +62,7 @@ export default function ModelsDialog({
|
|||||||
|
|
||||||
const [providers, setProviders] = useState<ModelProvider[]>([]);
|
const [providers, setProviders] = useState<ModelProvider[]>([]);
|
||||||
const [accountType, setAccountType] = useState<'local' | 'space'>('local');
|
const [accountType, setAccountType] = useState<'local' | 'space'>('local');
|
||||||
const [spaceBalance] = useState<number | null>(null);
|
const [spaceCredits, setSpaceCredits] = useState<number | null>(null);
|
||||||
|
|
||||||
// Expanded providers and their models
|
// Expanded providers and their models
|
||||||
const [expandedProviders, setExpandedProviders] = useState<Set<string>>(
|
const [expandedProviders, setExpandedProviders] = useState<Set<string>>(
|
||||||
@@ -103,6 +103,10 @@ export default function ModelsDialog({
|
|||||||
try {
|
try {
|
||||||
const userInfo = await httpClient.getUserInfo();
|
const userInfo = await httpClient.getUserInfo();
|
||||||
setAccountType(userInfo.account_type);
|
setAccountType(userInfo.account_type);
|
||||||
|
if (userInfo.account_type === 'space') {
|
||||||
|
const creditsInfo = await httpClient.getSpaceCredits();
|
||||||
|
setSpaceCredits(creditsInfo.credits);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setAccountType('local');
|
setAccountType('local');
|
||||||
}
|
}
|
||||||
@@ -279,8 +283,12 @@ export default function ModelsDialog({
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2 flex-1">
|
<div className="flex items-center gap-2 flex-1">
|
||||||
{isLangBotModels ? (
|
{isLangBotModels ? (
|
||||||
<div className="p-2 bg-gradient-to-br from-purple-500 to-blue-500 rounded-lg">
|
<div className="w-9 h-9 rounded-lg overflow-hidden flex-shrink-0">
|
||||||
<Sparkles className="h-5 w-5 text-white" />
|
<img
|
||||||
|
src={langbotIcon.src}
|
||||||
|
alt="LangBot"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<img
|
<img
|
||||||
@@ -332,11 +340,29 @@ export default function ModelsDialog({
|
|||||||
{t('models.loginWithSpace')}
|
{t('models.loginWithSpace')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{isLangBotModels && accountType === 'space' && (
|
{isLangBotModels &&
|
||||||
<Badge variant="secondary">
|
accountType === 'space' &&
|
||||||
{t('models.balance')}: {spaceBalance ?? '--'}
|
spaceCredits !== null && (
|
||||||
</Badge>
|
<div className="flex items-center gap-1 border rounded-md px-2 h-8 text-sm mr-2">
|
||||||
)}
|
<span>
|
||||||
|
{(spaceCredits / 5000).toFixed(2)} {t('models.credits')}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
window.open(
|
||||||
|
`${systemInfo.cloud_service_url}/billing`,
|
||||||
|
'_blank',
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{!isLangBotModels && (
|
{!isLangBotModels && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
@@ -480,8 +506,12 @@ export default function ModelsDialog({
|
|||||||
<CardHeader className="p-3">
|
<CardHeader className="p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="p-2 bg-gradient-to-br from-purple-500 to-blue-500 rounded-lg">
|
<div className="w-9 h-9 rounded-lg overflow-hidden flex-shrink-0">
|
||||||
<Sparkles className="h-5 w-5 text-white" />
|
<img
|
||||||
|
src={langbotIcon.src}
|
||||||
|
alt="LangBot"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-base">
|
<CardTitle className="text-base">
|
||||||
@@ -492,11 +522,32 @@ export default function ModelsDialog({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{accountType !== 'space' && (
|
{accountType !== 'space' ? (
|
||||||
<Button variant="outline" size="sm" onClick={handleSpaceLogin}>
|
<Button variant="outline" size="sm" onClick={handleSpaceLogin}>
|
||||||
<LogIn className="h-4 w-4 mr-1" />
|
<LogIn className="h-4 w-4 mr-1" />
|
||||||
{t('models.loginWithSpace')}
|
{t('models.loginWithSpace')}
|
||||||
</Button>
|
</Button>
|
||||||
|
) : (
|
||||||
|
spaceCredits !== null && (
|
||||||
|
<div className="flex items-center gap-1 border rounded-md px-2 h-8 text-sm">
|
||||||
|
<span>
|
||||||
|
{(spaceCredits / 5000).toFixed(2)} {t('models.credits')}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5"
|
||||||
|
onClick={() =>
|
||||||
|
window.open(
|
||||||
|
`${systemInfo.cloud_service_url}/billing`,
|
||||||
|
'_blank',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -531,12 +582,15 @@ export default function ModelsDialog({
|
|||||||
<DialogTitle>{t('models.title')}</DialogTitle>
|
<DialogTitle>{t('models.title')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col overflow-hidden px-6 pb-6 mt-4">
|
<div className="flex-1 flex flex-col overflow-hidden px-6 pb-6 mt-0">
|
||||||
{/* Fixed LangBot Models Card */}
|
{/* Fixed LangBot Models Card */}
|
||||||
<div className="flex-shrink-0">{renderLangBotModelsCard()}</div>
|
<div className="flex-shrink-0">{renderLangBotModelsCard()}</div>
|
||||||
|
|
||||||
{/* Add Model Button */}
|
{/* Add Model Button */}
|
||||||
<div className="flex-shrink-0 mb-3 flex justify-end">
|
<div className="flex-shrink-0 mb-3 flex justify-between items-center">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{t('models.providerCount', { count: otherProviders.length })}
|
||||||
|
</span>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button size="sm" variant="outline">
|
<Button size="sm" variant="outline">
|
||||||
|
|||||||
@@ -731,6 +731,10 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
return this.get('/api/v1/user/info');
|
return this.get('/api/v1/user/info');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getSpaceCredits(): Promise<{ credits: number | null }> {
|
||||||
|
return this.get('/api/v1/user/space-credits');
|
||||||
|
}
|
||||||
|
|
||||||
public getAccountInfo(): Promise<{
|
public getAccountInfo(): Promise<{
|
||||||
initialized: boolean;
|
initialized: boolean;
|
||||||
account_type?: 'local' | 'space';
|
account_type?: 'local' | 'space';
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ const enUS = {
|
|||||||
spaceModelReadOnly: 'Space models are read-only',
|
spaceModelReadOnly: 'Space models are read-only',
|
||||||
noSpaceModels: 'No Space models. Click Sync to fetch models from Space.',
|
noSpaceModels: 'No Space models. Click Sync to fetch models from Space.',
|
||||||
noLocalModels: 'No local models. Click Create to add a model.',
|
noLocalModels: 'No local models. Click Create to add a model.',
|
||||||
|
providerCount: '{{count}} providers',
|
||||||
// New keys for provider-based structure
|
// New keys for provider-based structure
|
||||||
addModel: 'Add Model',
|
addModel: 'Add Model',
|
||||||
addLLMModel: 'Add LLM Model',
|
addLLMModel: 'Add LLM Model',
|
||||||
@@ -197,17 +198,17 @@ const enUS = {
|
|||||||
existingProvider: 'Existing Provider',
|
existingProvider: 'Existing Provider',
|
||||||
newProvider: 'New Provider',
|
newProvider: 'New Provider',
|
||||||
selectProvider: 'Select Provider',
|
selectProvider: 'Select Provider',
|
||||||
requester: 'Requester',
|
requester: 'Provider Type',
|
||||||
selectRequester: 'Select Requester',
|
selectRequester: 'Select Provider Type',
|
||||||
langbotModelsDescription: 'Cloud models powered by LangBot Space',
|
langbotModelsDescription: 'Cloud models powered by LangBot Space',
|
||||||
balance: 'Balance',
|
credits: 'Credits',
|
||||||
loginWithSpace: 'Login with Space',
|
loginWithSpace: 'Login with Space',
|
||||||
loginToUseModels: 'Login with Space to use cloud models',
|
loginToUseModels: 'Login with Space to use cloud models',
|
||||||
noModels: 'No models configured',
|
noModels: 'No models configured',
|
||||||
editProvider: 'Edit Provider',
|
editProvider: 'Edit Provider',
|
||||||
providerName: 'Provider Name',
|
providerName: 'Provider Name',
|
||||||
providerNameRequired: 'Provider name is required',
|
providerNameRequired: 'Provider name is required',
|
||||||
requesterRequired: 'Requester is required',
|
requesterRequired: 'Provider type is required',
|
||||||
providerSaved: 'Provider saved',
|
providerSaved: 'Provider saved',
|
||||||
providerCreated: 'Provider created',
|
providerCreated: 'Provider created',
|
||||||
providerSaveError: 'Failed to save provider: ',
|
providerSaveError: 'Failed to save provider: ',
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ const jaJP = {
|
|||||||
'Space モデルがありません。同期ボタンをクリックして Space からモデルを取得してください。',
|
'Space モデルがありません。同期ボタンをクリックして Space からモデルを取得してください。',
|
||||||
noLocalModels:
|
noLocalModels:
|
||||||
'ローカルモデルがありません。作成ボタンをクリックしてモデルを追加してください。',
|
'ローカルモデルがありません。作成ボタンをクリックしてモデルを追加してください。',
|
||||||
|
providerCount: '{{count}} 件のプロバイダー',
|
||||||
addModel: 'モデルを追加',
|
addModel: 'モデルを追加',
|
||||||
addLLMModel: 'LLMモデルを追加',
|
addLLMModel: 'LLMモデルを追加',
|
||||||
addEmbeddingModel: '埋め込みモデルを追加',
|
addEmbeddingModel: '埋め込みモデルを追加',
|
||||||
@@ -202,17 +203,17 @@ const jaJP = {
|
|||||||
existingProvider: '既存のプロバイダー',
|
existingProvider: '既存のプロバイダー',
|
||||||
newProvider: '新規プロバイダー',
|
newProvider: '新規プロバイダー',
|
||||||
selectProvider: 'プロバイダーを選択',
|
selectProvider: 'プロバイダーを選択',
|
||||||
requester: 'リクエスター',
|
requester: 'プロバイダータイプ',
|
||||||
selectRequester: 'リクエスターを選択',
|
selectRequester: 'プロバイダータイプを選択',
|
||||||
langbotModelsDescription: 'LangBot Space が提供するクラウドモデル',
|
langbotModelsDescription: 'LangBot Space が提供するクラウドモデル',
|
||||||
balance: '残高',
|
credits: 'クレジット',
|
||||||
loginWithSpace: 'Space でログイン',
|
loginWithSpace: 'Space でログイン',
|
||||||
loginToUseModels: 'Space でログインしてクラウドモデルを使用',
|
loginToUseModels: 'Space でログインしてクラウドモデルを使用',
|
||||||
noModels: 'モデルがありません',
|
noModels: 'モデルがありません',
|
||||||
editProvider: 'プロバイダーを編集',
|
editProvider: 'プロバイダーを編集',
|
||||||
providerName: 'プロバイダー名',
|
providerName: 'プロバイダー名',
|
||||||
providerNameRequired: 'プロバイダー名は必須です',
|
providerNameRequired: 'プロバイダー名は必須です',
|
||||||
requesterRequired: 'リクエスターは必須です',
|
requesterRequired: 'プロバイダータイプは必須です',
|
||||||
providerSaved: 'プロバイダーを保存しました',
|
providerSaved: 'プロバイダーを保存しました',
|
||||||
providerCreated: 'プロバイダーを作成しました',
|
providerCreated: 'プロバイダーを作成しました',
|
||||||
providerSaveError: 'プロバイダーの保存に失敗しました:',
|
providerSaveError: 'プロバイダーの保存に失敗しました:',
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ const zhHans = {
|
|||||||
spaceModelReadOnly: 'Space 模型为只读',
|
spaceModelReadOnly: 'Space 模型为只读',
|
||||||
noSpaceModels: '暂无 Space 模型。点击同步按钮从 Space 获取模型。',
|
noSpaceModels: '暂无 Space 模型。点击同步按钮从 Space 获取模型。',
|
||||||
noLocalModels: '暂无本地模型。点击创建按钮添加模型。',
|
noLocalModels: '暂无本地模型。点击创建按钮添加模型。',
|
||||||
|
providerCount: '共 {{count}} 个供应商',
|
||||||
// 供应商结构新增键
|
// 供应商结构新增键
|
||||||
addModel: '添加模型',
|
addModel: '添加模型',
|
||||||
addLLMModel: '添加对话模型',
|
addLLMModel: '添加对话模型',
|
||||||
@@ -190,17 +191,17 @@ const zhHans = {
|
|||||||
existingProvider: '已有供应商',
|
existingProvider: '已有供应商',
|
||||||
newProvider: '新建供应商',
|
newProvider: '新建供应商',
|
||||||
selectProvider: '选择供应商',
|
selectProvider: '选择供应商',
|
||||||
requester: '请求器',
|
requester: '供应商类型',
|
||||||
selectRequester: '选择请求器',
|
selectRequester: '选择供应商类型',
|
||||||
langbotModelsDescription: 'LangBot Space 提供的云端模型',
|
langbotModelsDescription: 'LangBot Space 提供的云端模型',
|
||||||
balance: '余额',
|
credits: '积分',
|
||||||
loginWithSpace: '通过 Space 登录',
|
loginWithSpace: '通过 Space 登录',
|
||||||
loginToUseModels: '通过 Space 登录以使用云端模型',
|
loginToUseModels: '通过 Space 登录以使用云端模型',
|
||||||
noModels: '暂无模型',
|
noModels: '暂无模型',
|
||||||
editProvider: '编辑供应商',
|
editProvider: '编辑供应商',
|
||||||
providerName: '供应商名称',
|
providerName: '供应商名称',
|
||||||
providerNameRequired: '供应商名称不能为空',
|
providerNameRequired: '供应商名称不能为空',
|
||||||
requesterRequired: '请求器不能为空',
|
requesterRequired: '供应商类型不能为空',
|
||||||
providerSaved: '供应商已保存',
|
providerSaved: '供应商已保存',
|
||||||
providerCreated: '供应商已创建',
|
providerCreated: '供应商已创建',
|
||||||
providerSaveError: '保存供应商失败:',
|
providerSaveError: '保存供应商失败:',
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ const zhHant = {
|
|||||||
spaceModelReadOnly: 'Space 模型為唯讀',
|
spaceModelReadOnly: 'Space 模型為唯讀',
|
||||||
noSpaceModels: '暫無 Space 模型。點擊同步按鈕從 Space 取得模型。',
|
noSpaceModels: '暫無 Space 模型。點擊同步按鈕從 Space 取得模型。',
|
||||||
noLocalModels: '暫無本地模型。點擊建立按鈕新增模型。',
|
noLocalModels: '暫無本地模型。點擊建立按鈕新增模型。',
|
||||||
|
providerCount: '共 {{count}} 個供應商',
|
||||||
addModel: '新增模型',
|
addModel: '新增模型',
|
||||||
addLLMModel: '新增對話模型',
|
addLLMModel: '新增對話模型',
|
||||||
addEmbeddingModel: '新增嵌入模型',
|
addEmbeddingModel: '新增嵌入模型',
|
||||||
@@ -189,17 +190,17 @@ const zhHant = {
|
|||||||
existingProvider: '現有供應商',
|
existingProvider: '現有供應商',
|
||||||
newProvider: '新供應商',
|
newProvider: '新供應商',
|
||||||
selectProvider: '選擇供應商',
|
selectProvider: '選擇供應商',
|
||||||
requester: '請求器',
|
requester: '供應商類型',
|
||||||
selectRequester: '選擇請求器',
|
selectRequester: '選擇供應商類型',
|
||||||
langbotModelsDescription: '由 LangBot Space 提供的雲端模型',
|
langbotModelsDescription: '由 LangBot Space 提供的雲端模型',
|
||||||
balance: '餘額',
|
credits: '積分',
|
||||||
loginWithSpace: '使用 Space 登入',
|
loginWithSpace: '使用 Space 登入',
|
||||||
loginToUseModels: '使用 Space 登入以使用雲端模型',
|
loginToUseModels: '使用 Space 登入以使用雲端模型',
|
||||||
noModels: '暫無模型',
|
noModels: '暫無模型',
|
||||||
editProvider: '編輯供應商',
|
editProvider: '編輯供應商',
|
||||||
providerName: '供應商名稱',
|
providerName: '供應商名稱',
|
||||||
providerNameRequired: '供應商名稱不能為空',
|
providerNameRequired: '供應商名稱不能為空',
|
||||||
requesterRequired: '請求器不能為空',
|
requesterRequired: '供應商類型不能為空',
|
||||||
providerSaved: '供應商已儲存',
|
providerSaved: '供應商已儲存',
|
||||||
providerCreated: '供應商已建立',
|
providerCreated: '供應商已建立',
|
||||||
providerSaveError: '儲存供應商失敗:',
|
providerSaveError: '儲存供應商失敗:',
|
||||||
|
|||||||
Reference in New Issue
Block a user