feat: add timezone support for schedule and user runtime (#282)

This commit is contained in:
Yiming Qi
2026-03-26 01:32:02 +08:00
committed by GitHub
parent 3a7f5200ed
commit 03ba13e7e5
51 changed files with 793 additions and 100 deletions
+10
View File
@@ -75,6 +75,8 @@
"userID": "User ID",
"displayName": "Display Name",
"avatarUrl": "Avatar URL",
"timezone": "Timezone",
"timezonePlaceholder": "e.g. America/New_York",
"saveProfile": "Save Profile",
"profileUpdated": "Profile updated",
"profileUpdateFailed": "Failed to update profile",
@@ -511,10 +513,18 @@
"displayNamePlaceholder": "Give your bot a name",
"avatarUrl": "Avatar URL",
"avatarUrlPlaceholder": "Enter avatar image URL",
"timezone": "Timezone",
"timezonePlaceholder": "e.g. America/New_York",
"timezoneInherited": "Inherited from user/system",
"timezoneInheritedHint": "Leave empty to inherit the user's timezone, or fall back to the system timezone.",
"editAvatar": "Edit Avatar",
"editAvatarDescription": "Set the image URL for the bot avatar.",
"editTimezone": "Edit Timezone",
"editTimezoneDescription": "Set a timezone override for this bot.",
"avatarUpdateSuccess": "Avatar updated",
"avatarUpdateFailed": "Failed to update avatar",
"timezoneUpdateSuccess": "Timezone updated",
"timezoneUpdateFailed": "Failed to update timezone",
"typePlaceholder": "Select bot type",
"types": {
"personal": "Personal",
+11 -1
View File
@@ -75,6 +75,8 @@
"userID": "用户 ID",
"displayName": "显示名称",
"avatarUrl": "头像链接",
"timezone": "时区",
"timezonePlaceholder": "例如 Asia/Tokyo",
"saveProfile": "保存资料",
"profileUpdated": "用户资料已更新",
"profileUpdateFailed": "用户资料更新失败",
@@ -400,7 +402,7 @@
"locale": "语言区域",
"localePlaceholder": "例如 zh-CN",
"timezoneId": "时区",
"timezonePlaceholder": "例如 Asia/Shanghai",
"timezonePlaceholder": "例如 Asia/Tokyo",
"ignoreHTTPSErrors": "忽略 HTTPS 错误",
"core": "浏览器内核",
"chromium": "Chromium",
@@ -507,10 +509,18 @@
"displayNamePlaceholder": "给你的 Bot 起个名字",
"avatarUrl": "头像链接",
"avatarUrlPlaceholder": "输入头像图片地址",
"timezone": "时区",
"timezonePlaceholder": "例如 Asia/Shanghai",
"timezoneInherited": "继承用户或系统时区",
"timezoneInheritedHint": "留空则继承用户时区,若用户未设置则回退到系统时区。",
"editAvatar": "编辑头像",
"editAvatarDescription": "设置 Bot 头像图片的链接地址",
"editTimezone": "编辑时区",
"editTimezoneDescription": "为这个 Bot 设置独立时区。",
"avatarUpdateSuccess": "头像已更新",
"avatarUpdateFailed": "更新头像失败",
"timezoneUpdateSuccess": "时区已更新",
"timezoneUpdateFailed": "更新时区失败",
"typePlaceholder": "选择 Bot 类型",
"types": {
"personal": "个人",
@@ -57,6 +57,42 @@
</FormControl>
</FormItem>
</FormField>
<FormField
v-slot="{ componentField }"
name="timezone"
>
<FormItem>
<Label class="mb-2">
{{ $t('bots.timezone') }}
<span class="text-muted-foreground text-xs ml-1">({{ $t('common.optional') }})</span>
</Label>
<FormControl>
<Select
:model-value="componentField.modelValue || emptyTimezoneValue"
@update:model-value="(value) => componentField['onUpdate:modelValue'](value === emptyTimezoneValue ? '' : value)"
>
<SelectTrigger class="w-full">
<SelectValue :placeholder="$t('bots.timezonePlaceholder')" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem :value="emptyTimezoneValue">
{{ $t('bots.timezoneInherited') }}
</SelectItem>
<SelectItem
v-for="timezoneOption in timezones"
:key="timezoneOption"
:value="timezoneOption"
>
{{ timezoneOption }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
</FormItem>
</FormField>
<div class="rounded-md border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
{{ $t('bots.createBotWaitHint') }}
</div>
@@ -98,6 +134,12 @@ import {
FormItem,
Separator,
Label,
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
Spinner,
} from '@memohai/ui'
import { useForm } from 'vee-validate'
@@ -108,6 +150,7 @@ import { useMutation, useQueryCache } from '@pinia/colada'
import { postBotsMutation, getBotsQueryKey } from '@memohai/sdk/colada'
import { useI18n } from 'vue-i18n'
import { useDialogMutation } from '@/composables/useDialogMutation'
import { emptyTimezoneValue, timezones } from '@/utils/timezones'
const open = defineModel<boolean>('open', { default: false })
const { t } = useI18n()
@@ -116,6 +159,7 @@ const { run } = useDialogMutation()
const formSchema = toTypedSchema(z.object({
display_name: z.string().min(1),
avatar_url: z.string().optional(),
timezone: z.string().optional(),
}))
const form = useForm({
@@ -123,6 +167,7 @@ const form = useForm({
initialValues: {
display_name: '',
avatar_url: '',
timezone: '',
},
})
@@ -138,6 +183,7 @@ watch(open, (val) => {
values: {
display_name: '',
avatar_url: '',
timezone: '',
},
})
} else {
@@ -151,6 +197,7 @@ const handleSubmit = form.handleSubmit(async (values) => {
body: {
display_name: values.display_name,
avatar_url: values.avatar_url || undefined,
timezone: values.timezone || undefined,
is_active: true,
},
}),
+113 -1
View File
@@ -93,6 +93,22 @@
</Badge>
<span v-if="bot?.type">{{ botTypeLabel }}</span>
</div>
<div
v-if="bot"
class="mt-1 flex items-center gap-2 text-sm text-muted-foreground"
>
<span>{{ $t('bots.timezone') }}: {{ bot.timezone || $t('bots.timezoneInherited') }}</span>
<Button
type="button"
variant="ghost"
size="sm"
class="h-7 px-2"
:disabled="botLifecyclePending"
@click="handleEditTimezone"
>
{{ $t('common.edit') }}
</Button>
</div>
</div>
</div>
<Separator />
@@ -193,6 +209,67 @@
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="timezoneDialogOpen">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>{{ $t('bots.editTimezone') }}</DialogTitle>
<DialogDescription>
{{ $t('bots.editTimezoneDescription') }}
</DialogDescription>
</DialogHeader>
<div class="mt-4 flex flex-col gap-2">
<Select
:model-value="timezoneDraft || emptyTimezoneValue"
@update:model-value="(value) => timezoneDraft = value === emptyTimezoneValue ? '' : String(value)"
>
<SelectTrigger
class="w-full"
:disabled="timezoneSaving"
>
<SelectValue :placeholder="$t('bots.timezonePlaceholder')" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem :value="emptyTimezoneValue">
{{ $t('bots.timezoneInherited') }}
</SelectItem>
<SelectItem
v-for="timezoneOption in timezones"
:key="timezoneOption"
:value="timezoneOption"
>
{{ timezoneOption }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<p class="text-sm text-muted-foreground">
{{ $t('bots.timezoneInheritedHint') }}
</p>
</div>
<DialogFooter class="mt-6">
<DialogClose as-child>
<Button
variant="outline"
:disabled="timezoneSaving"
>
{{ $t('common.cancel') }}
</Button>
</DialogClose>
<Button
:disabled="timezoneSaving || !canConfirmTimezone"
@click="handleConfirmTimezone"
>
<Spinner
v-if="timezoneSaving"
class="mr-1.5"
/>
{{ $t('common.confirm') }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
</template>
@@ -211,6 +288,12 @@ import {
DialogHeader,
DialogTitle,
Input,
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
Separator,
Spinner,
ScrollArea,
@@ -255,6 +338,7 @@ import { useAvatarInitials } from '@/composables/useAvatarInitials'
import { useSyncedQueryParam } from '@/composables/useSyncedQueryParam'
import { useBotStatusMeta } from '@/composables/useBotStatusMeta'
import MasterDetailSidebarLayout from '@/components/master-detail-sidebar-layout/index.vue'
import { emptyTimezoneValue, timezones } from '@/utils/timezones'
type BotCheck = BotsBotCheck
type BotContainerInfo = HandlersGetContainerResponse
type BotContainerSnapshot = HandlersListSnapshotsResponse extends { snapshots?: (infer T)[] } ? T : never
@@ -344,9 +428,12 @@ watch(bot, (val) => {
const activeTab = useSyncedQueryParam('tab', 'overview')
const avatarDialogOpen = ref(false)
const avatarUrlDraft = ref('')
const timezoneDialogOpen = ref(false)
const timezoneDraft = ref('')
const avatarFallback = useAvatarInitials(() => bot.value?.display_name || botId.value || '')
const isSavingBotName = computed(() => updateBotLoading.value)
const avatarSaving = computed(() => updateBotLoading.value)
const timezoneSaving = computed(() => updateBotLoading.value)
const canConfirmAvatar = computed(() => {
if (!bot.value) return false
const next = avatarUrlDraft.value.trim()
@@ -359,6 +446,10 @@ const canConfirmBotName = computed(() => {
if (!nextName) return false
return nextName !== (bot.value.display_name || '').trim()
})
const canConfirmTimezone = computed(() => {
if (!bot.value) return false
return timezoneDraft.value.trim() !== (bot.value.timezone || '').trim()
})
const {
hasIssue,
isPending: botLifecyclePending,
@@ -412,6 +503,12 @@ function handleEditAvatar() {
avatarDialogOpen.value = true
}
function handleEditTimezone() {
if (!bot.value || botLifecyclePending.value) return
timezoneDraft.value = bot.value.timezone || ''
timezoneDialogOpen.value = true
}
async function handleConfirmAvatar() {
if (!bot.value || !canConfirmAvatar.value || avatarSaving.value) return
const nextUrl = avatarUrlDraft.value.trim()
@@ -427,6 +524,21 @@ async function handleConfirmAvatar() {
}
}
async function handleConfirmTimezone() {
if (!bot.value || !canConfirmTimezone.value || timezoneSaving.value) return
const nextTimezone = timezoneDraft.value.trim()
try {
await updateBot({
id: bot.value.id as string,
timezone: nextTimezone,
})
timezoneDialogOpen.value = false
toast.success(t('bots.timezoneUpdateSuccess'))
} catch (error) {
toast.error(resolveErrorMessage(error, t('bots.timezoneUpdateFailed')))
}
}
function handleStartEditBotName() {
if (!bot.value) return
isEditingBotName.value = true
@@ -517,4 +629,4 @@ async function loadSnapshots() {
}
}
</script>
</script>
@@ -155,11 +155,28 @@
<FormItem>
<Label>{{ $t('browserContext.timezoneId') }}</Label>
<FormControl>
<Input
type="text"
:placeholder="$t('browserContext.timezonePlaceholder')"
v-bind="componentField"
/>
<Select
:model-value="componentField.modelValue || emptyTimezoneValue"
@update:model-value="(value) => componentField['onUpdate:modelValue'](value === emptyTimezoneValue ? '' : value)"
>
<SelectTrigger class="w-full">
<SelectValue :placeholder="$t('browserContext.timezonePlaceholder')" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem :value="emptyTimezoneValue">
{{ $t('common.optional') }}
</SelectItem>
<SelectItem
v-for="timezoneOption in timezones"
:key="timezoneOption"
:value="timezoneOption"
>
{{ timezoneOption }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
</FormItem>
</FormField>
@@ -232,6 +249,12 @@ import {
Label,
Separator,
Button,
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
Switch,
} from '@memohai/ui'
import { toTypedSchema } from '@vee-validate/zod'
@@ -247,6 +270,7 @@ import { toast } from 'vue-sonner'
import { useDialogMutation } from '@/composables/useDialogMutation'
import ConfirmPopover from '@/components/confirm-popover/index.vue'
import { resolveApiErrorMessage } from '@/utils/api-error'
import { emptyTimezoneValue, timezones } from '@/utils/timezones'
const { t } = useI18n()
const { run } = useDialogMutation()
+1
View File
@@ -187,6 +187,7 @@ const login = form.handleSubmit(async (values) => {
displayName: data.display_name ?? '',
role: data.role ?? '',
avatarUrl: data.avatar_url ?? '',
timezone: data.timezone ?? 'UTC',
}, data.access_token)
} else {
throw new Error(t('auth.loginFailed'))
@@ -46,6 +46,32 @@
@update:model-value="onAvatarUrlChange"
/>
</div>
<div class="space-y-2">
<Label for="settings-timezone">{{ $t('settings.timezone') }}</Label>
<Select
:model-value="timezone"
@update:model-value="onTimezoneChange"
>
<SelectTrigger
id="settings-timezone"
class="w-full"
:aria-label="$t('settings.timezone')"
>
<SelectValue :placeholder="$t('settings.timezonePlaceholder')" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem
v-for="timezoneOption in timezones"
:key="timezoneOption"
:value="timezoneOption"
>
{{ timezoneOption }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div class="flex justify-end">
<Button
:disabled="saving || loading"
@@ -60,13 +86,27 @@
</template>
<script setup lang="ts">
import { Button, Input, Label, Separator, Spinner } from '@memohai/ui'
import {
Button,
Input,
Label,
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
Separator,
Spinner,
} from '@memohai/ui'
import { timezones } from '@/utils/timezones'
defineProps<{
displayUserId: string
displayUsername: string
displayName: string
avatarUrl: string
timezone: string
saving: boolean
loading: boolean
}>()
@@ -74,6 +114,7 @@ defineProps<{
const emit = defineEmits<{
'update:displayName': [value: string]
'update:avatarUrl': [value: string]
'update:timezone': [value: string]
save: []
}>()
@@ -84,4 +125,8 @@ function onDisplayNameChange(value: string | number) {
function onAvatarUrlChange(value: string | number) {
emit('update:avatarUrl', String(value))
}
function onTimezoneChange(value: string | number | undefined) {
emit('update:timezone', String(value || 'UTC'))
}
</script>
+8
View File
@@ -106,10 +106,12 @@
:display-username="displayUsername"
:display-name="profileForm.display_name"
:avatar-url="profileForm.avatar_url"
:timezone="profileForm.timezone"
:saving="savingProfile"
:loading="loadingInitial"
@update:display-name="profileForm.display_name = $event"
@update:avatar-url="profileForm.avatar_url = $event"
@update:timezone="profileForm.timezone = $event"
@save="onSaveProfile"
/>
@@ -263,6 +265,7 @@ const generatingBindCode = ref(false)
const profileForm = reactive({
display_name: '',
avatar_url: '',
timezone: '',
})
const passwordForm = reactive({
@@ -322,12 +325,14 @@ async function loadMyAccount() {
account.value = data
profileForm.display_name = data.display_name || ''
profileForm.avatar_url = data.avatar_url || ''
profileForm.timezone = data.timezone || 'UTC'
patchUserInfo({
id: data.id,
username: data.username,
role: data.role,
displayName: data.display_name || '',
avatarUrl: data.avatar_url || '',
timezone: data.timezone || 'UTC',
})
}
@@ -347,14 +352,17 @@ async function onSaveProfile() {
const body: AccountsUpdateProfileRequest = {
display_name: profileForm.display_name.trim(),
avatar_url: profileForm.avatar_url.trim(),
timezone: profileForm.timezone.trim(),
}
const { data } = await putUsersMe({ body, throwOnError: true })
account.value = data
profileForm.display_name = data.display_name || ''
profileForm.avatar_url = data.avatar_url || ''
profileForm.timezone = data.timezone || 'UTC'
patchUserInfo({
displayName: data.display_name || '',
avatarUrl: data.avatar_url || '',
timezone: data.timezone || 'UTC',
})
toast.success(t('settings.profileUpdated'))
} catch (error) {
+3 -1
View File
@@ -9,6 +9,7 @@ export interface UserInfo {
role: string;
displayName: string;
avatarUrl: string;
timezone: string;
}
export const useUserStore = defineStore(
@@ -20,6 +21,7 @@ export const useUserStore = defineStore(
role: '',
displayName: '',
avatarUrl: '',
timezone: 'UTC',
})
const localToken = useLocalStorage('token', '')
@@ -43,7 +45,7 @@ export const useUserStore = defineStore(
const exitLogin = () => {
localToken.value = ''
for (const key of Object.keys(userInfo) as (keyof UserInfo)[]) {
userInfo[key as keyof UserInfo] = ''
userInfo[key as keyof UserInfo] = key === 'timezone' ? 'UTC' : ''
}
}
const router = useRouter()
+7
View File
@@ -0,0 +1,7 @@
const fallbackTimezones = ['UTC']
export const timezones = typeof Intl.supportedValuesOf === 'function'
? Intl.supportedValuesOf('timeZone')
: fallbackTimezones
export const emptyTimezoneValue = '__empty_timezone__'