Files
geo/apps/admin-web/src/components/GenerateTaskDrawer.vue
T
root 2436f50c1f refactor(admin-web): tighten schedule cover picker layout
Replace the labeled cover button + meta column with a compact preview
tile and an overlaid remove button, dropping the now-unused cover-meta
typography rules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:04:28 +08:00

976 lines
27 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { MinusCircleFilled, PlusOutlined } from "@ant-design/icons-vue";
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
import { message } from "ant-design-vue";
import type { PromptRuleSimple, ScheduleTask } from "@geo/shared-types";
import { computed, reactive, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import CoverPickerModal from "@/components/CoverPickerModal.vue";
import PromptRuleModal from "@/components/PromptRuleModal.vue";
import { generateApi, imagesApi, mediaApi, promptRulesApi, resolveApiURL, schedulesApi, tenantAccountsApi } from "@/lib/api";
import { coverUploadRequired, deriveCoverFileName } from "@/lib/cover-requirements";
import { formatError } from "@/lib/errors";
import {
buildPublishAccountCards,
buildPublishPlatformMap,
publishStatusShortLabel,
type PublishAccountCard,
} from "@/lib/publish-account-cards";
const props = defineProps<{
open: boolean;
mode: "instant" | "schedule";
task?: ScheduleTask | null;
}>();
const emit = defineEmits<{
"update:open": [value: boolean];
}>();
const queryClient = useQueryClient();
const { t } = useI18n();
const promptDrawerOpen = ref(false);
const coverPickerOpen = ref(false);
const form = reactive({
name: "",
promptRuleId: undefined as number | undefined,
autoPublish: false,
publishAccountIds: [] as string[],
coverEnabled: false,
coverAssetUrl: "",
coverFileName: "",
coverImageAssetId: null as number | null,
enableWebSearch: false,
generateCount: 1,
scheduleTime: "08:00:00",
});
const isSchedule = computed(() => props.mode === "schedule");
const rulesQuery = useQuery({
queryKey: ["promptRules", "simple"],
queryFn: () => promptRulesApi.listSimple(),
});
const schedulePublishQueryEnabled = computed(() => props.open && isSchedule.value);
const platformsQuery = useQuery({
queryKey: ["media", "platforms", "schedule-task-drawer"],
enabled: schedulePublishQueryEnabled,
queryFn: () => mediaApi.platforms(),
});
const accountsQuery = useQuery({
queryKey: ["tenant", "desktop-accounts", "schedule-task-drawer"],
enabled: schedulePublishQueryEnabled,
queryFn: () => tenantAccountsApi.list(),
});
const promptOptions = computed(() =>
(rulesQuery.data.value ?? []).map((rule: PromptRuleSimple) => ({
label: rule.name,
value: rule.id,
})),
);
const platformMap = computed(() => buildPublishPlatformMap(platformsQuery.data.value ?? []));
const schedulePublishAccounts = computed<PublishAccountCard[]>(() =>
buildPublishAccountCards(accountsQuery.data.value ?? [], platformMap.value),
);
const schedulePublishLoading = computed(
() => platformsQuery.isPending.value || accountsQuery.isPending.value,
);
const selectedSchedulePublishPlatformIds = computed(() => {
const selected = new Set(form.publishAccountIds);
return Array.from(
new Set(
schedulePublishAccounts.value
.filter((account) => selected.has(account.id))
.map((account) => account.platformId),
),
);
});
const scheduleCoverRequired = computed(() => coverUploadRequired(selectedSchedulePublishPlatformIds.value));
const effectiveScheduleCoverEnabled = computed(() => scheduleCoverRequired.value || form.coverEnabled);
watch(
scheduleCoverRequired,
(required) => {
if (required) {
form.coverEnabled = true;
}
},
{ immediate: true },
);
const drawerTitle = computed(() => {
if (isSchedule.value) {
return props.task?.id ? t("custom.schedule.editTitle") : t("custom.schedule.createTitle");
}
return t("custom.instant.createTitle");
});
const submitText = computed(() => {
if (isSchedule.value) {
return props.task?.id ? t("common.save") : t("custom.task.submit");
}
return t("custom.task.submit");
});
watch(
() => props.open,
(open) => {
if (open) {
hydrateForm();
}
},
);
const createScheduleMutation = useMutation({
mutationFn: () =>
schedulesApi.create(buildSchedulePayload()),
onSuccess: async () => {
message.success(t("custom.messages.scheduleCreated"));
emit("update:open", false);
await queryClient.invalidateQueries({ queryKey: ["schedules"] });
},
onError: (error) => message.error(formatError(error)),
});
const updateScheduleMutation = useMutation({
mutationFn: () =>
schedulesApi.update(props.task!.id, buildSchedulePayload()),
onSuccess: async () => {
message.success(t("custom.messages.scheduleUpdated"));
emit("update:open", false);
await queryClient.invalidateQueries({ queryKey: ["schedules"] });
},
onError: (error) => message.error(formatError(error)),
});
const instantGenerateMutation = useMutation({
mutationFn: () =>
generateApi.fromRule(buildInstantPayload()),
onSuccess: async () => {
message.success(t("custom.messages.generateSubmitted"));
emit("update:open", false);
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["articles"] }),
queryClient.invalidateQueries({ queryKey: ["instantTasks"] }),
]);
},
onError: (error) => message.error(formatError(error)),
});
const submitLoading = computed(
() =>
createScheduleMutation.isPending.value ||
updateScheduleMutation.isPending.value ||
instantGenerateMutation.isPending.value,
);
function hydrateForm(): void {
form.name = props.task?.name ?? "";
form.promptRuleId = props.task?.prompt_rule_id ?? undefined;
form.autoPublish = isSchedule.value ? Boolean(props.task?.auto_publish) : false;
form.publishAccountIds = isSchedule.value ? [...(props.task?.publish_account_ids ?? [])] : [];
form.coverAssetUrl = isSchedule.value ? resolveApiURL(props.task?.cover_asset_url) : "";
form.coverFileName = deriveCoverFileName(form.coverAssetUrl);
form.coverImageAssetId = isSchedule.value ? props.task?.cover_image_asset_id ?? null : null;
form.coverEnabled = Boolean(form.coverAssetUrl);
form.enableWebSearch = props.task?.enable_web_search ?? false;
form.generateCount = props.task?.generate_count ?? 1;
form.scheduleTime = parseCronToDailyTime(props.task?.cron_expr);
}
function buildSchedulePayload() {
const coverEnabled = form.autoPublish && effectiveScheduleCoverEnabled.value;
const coverAssetUrl = coverEnabled ? form.coverAssetUrl.trim() : "";
return {
name: form.name.trim(),
prompt_rule_id: form.promptRuleId!,
cron_expr: buildDailyCron(form.scheduleTime),
auto_publish: form.autoPublish,
publish_account_ids: form.autoPublish ? form.publishAccountIds : [],
cover_asset_url: coverAssetUrl || null,
cover_image_asset_id: coverAssetUrl ? form.coverImageAssetId : null,
enable_web_search: form.enableWebSearch,
generate_count: form.generateCount,
};
}
function buildInstantPayload() {
return {
prompt_rule_id: form.promptRuleId!,
extra_params: {
task_name: form.name.trim(),
generation_mode: "instant",
enable_web_search: form.enableWebSearch,
generate_count: form.generateCount,
},
};
}
function handlePromptSaved(ruleId: number): void {
form.promptRuleId = ruleId;
promptDrawerOpen.value = false;
void queryClient.invalidateQueries({ queryKey: ["promptRules", "simple"] });
}
function toggleSchedulePublishAccount(account: PublishAccountCard): void {
if (!account.selectable) {
return;
}
if (form.publishAccountIds.includes(account.id)) {
form.publishAccountIds = form.publishAccountIds.filter((id) => id !== account.id);
return;
}
form.publishAccountIds = [...form.publishAccountIds, account.id];
}
function isSchedulePublishAccountSelected(accountId: string): boolean {
return form.publishAccountIds.includes(accountId);
}
async function uploadScheduleCover(file: File): Promise<{ url: string; fileName: string; assetId?: number | null }> {
const uploaded = await imagesApi.upload(file);
return {
url: uploaded.url,
fileName: uploaded.name,
assetId: uploaded.id,
};
}
function handleScheduleCoverPicked(payload: { url: string; fileName: string; assetId?: number | null }): void {
form.coverAssetUrl = payload.url;
form.coverFileName = payload.fileName;
form.coverImageAssetId = payload.assetId ?? null;
form.coverEnabled = true;
}
function handleRemoveScheduleCover(): void {
form.coverAssetUrl = "";
form.coverFileName = "";
form.coverImageAssetId = null;
}
function handleScheduleCoverToggle(checked: boolean): void {
if (scheduleCoverRequired.value) {
form.coverEnabled = true;
return;
}
form.coverEnabled = checked;
}
function validateForm(): boolean {
if (!form.name.trim()) {
message.warning(t("custom.messages.missingTaskName"));
return false;
}
if (!form.promptRuleId) {
message.warning(t("custom.messages.missingPromptRule"));
return false;
}
if (form.generateCount < 1) {
message.warning(t("custom.messages.invalidGenerateCount"));
return false;
}
if (isSchedule.value && !isValidTime(form.scheduleTime)) {
message.warning(t("custom.messages.invalidScheduleTime"));
return false;
}
if (isSchedule.value && form.autoPublish) {
if (form.publishAccountIds.length === 0) {
message.warning(t("custom.messages.missingPublishAccounts"));
return false;
}
if (scheduleCoverRequired.value && !form.coverAssetUrl.trim()) {
message.warning(t("custom.messages.missingScheduleCover"));
return false;
}
}
return true;
}
async function handleSubmit(): Promise<void> {
if (!validateForm()) {
return;
}
if (isSchedule.value) {
if (props.task?.id) {
await updateScheduleMutation.mutateAsync();
} else {
await createScheduleMutation.mutateAsync();
}
return;
}
await instantGenerateMutation.mutateAsync();
}
function parseCronToDailyTime(cronExpr?: string | null): string {
if (!cronExpr) {
return "08:00:00";
}
const fiveFieldMatch = cronExpr.match(/^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+\*$/);
if (fiveFieldMatch) {
const [, minute, hour] = fiveFieldMatch;
return `${padTime(hour)}:${padTime(minute)}:00`;
}
const sixFieldMatch = cronExpr.match(/^(\d{1,2})\s+(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+\*$/);
if (sixFieldMatch) {
const [, second, minute, hour] = sixFieldMatch;
return `${padTime(hour)}:${padTime(minute)}:${padTime(second)}`;
}
return "08:00:00";
}
function buildDailyCron(timeValue: string): string {
const [hourRaw, minuteRaw] = timeValue.split(":");
const hour = Number(hourRaw || 0);
const minute = Number(minuteRaw || 0);
return `${minute} ${hour} * * *`;
}
function isValidTime(value: string): boolean {
return /^\d{2}:\d{2}(:\d{2})?$/.test(value);
}
function padTime(value: string): string {
return value.padStart(2, "0");
}
</script>
<template>
<a-drawer
:open="open"
:title="drawerTitle"
placement="right"
width="1120"
:mask-closable="false"
class="generate-task-drawer"
@close="emit('update:open', false)"
>
<div class="generate-task-drawer__body">
<section class="generate-task-drawer__panel">
<div class="generate-task-drawer__section-head">
<div class="generate-task-drawer__section-marker"></div>
<h3>{{ t("custom.task.basicSection") }}</h3>
</div>
<div class="generate-task-drawer__field">
<label class="generate-task-drawer__label generate-task-drawer__label--required">
{{ t("custom.task.name") }}
</label>
<a-input
v-model:value="form.name"
:maxlength="100"
size="large"
:placeholder="t('custom.task.namePlaceholder')"
/>
</div>
<div class="generate-task-drawer__field">
<label class="generate-task-drawer__label generate-task-drawer__label--required">
{{ t("custom.task.prompt") }}
</label>
<div class="generate-task-drawer__prompt-row">
<a-select
v-model:value="form.promptRuleId"
size="large"
:placeholder="t('custom.task.promptPlaceholder')"
:options="promptOptions"
:loading="rulesQuery.isPending.value"
allow-clear
class="generate-task-drawer__prompt-select"
/>
<a-button class="generate-task-drawer__prompt-create" size="large" @click="promptDrawerOpen = true">
<template #icon><PlusOutlined /></template>
{{ t("custom.task.createPrompt") }}
</a-button>
</div>
</div>
</section>
<section v-if="isSchedule" class="generate-task-drawer__panel generate-task-drawer__panel--bordered">
<div class="generate-task-drawer__section-head">
<div class="generate-task-drawer__section-marker"></div>
<h3>{{ t("custom.task.publishSection") }}</h3>
</div>
<div class="generate-task-drawer__field generate-task-drawer__field--inline">
<div>
<label class="generate-task-drawer__label">{{ t("custom.task.autoPublish") }}</label>
<p class="generate-task-drawer__hint generate-task-drawer__hint--compact">
{{ t("custom.task.autoPublishHint") }}
</p>
</div>
<a-switch v-model:checked="form.autoPublish" />
</div>
<template v-if="form.autoPublish">
<div class="generate-task-drawer__field">
<label class="generate-task-drawer__label generate-task-drawer__label--required">
{{ t("custom.task.publishAccounts") }}
</label>
<p class="generate-task-drawer__hint">{{ t("custom.task.publishAccountsHint") }}</p>
<a-skeleton
v-if="schedulePublishLoading"
active
:title="false"
:paragraph="{ rows: 4 }"
/>
<div v-else-if="schedulePublishAccounts.length" class="generate-task-drawer__account-grid">
<button
v-for="account in schedulePublishAccounts"
:key="account.id"
type="button"
class="generate-task-drawer__account"
:class="{
'generate-task-drawer__account--active': isSchedulePublishAccountSelected(account.id),
'generate-task-drawer__account--disabled': !account.selectable,
}"
@click="toggleSchedulePublishAccount(account)"
>
<span class="generate-task-drawer__account-check">
<span v-if="isSchedulePublishAccountSelected(account.id)"></span>
</span>
<span class="generate-task-drawer__account-logo" :style="{ '--account-accent': account.platformAccent }">
<img
v-if="account.platformLogoUrl"
:src="account.platformLogoUrl"
:alt="account.platformName"
referrerpolicy="no-referrer"
/>
<span v-else>{{ account.platformShortName }}</span>
</span>
<span class="generate-task-drawer__account-copy">
<strong>{{ account.displayName }}</strong>
<small>{{ account.platformName }} · {{ account.platformUid }}</small>
</span>
<a-tooltip :title="account.statusText" placement="top">
<span
class="generate-task-drawer__account-status"
:class="`generate-task-drawer__account-status--${account.publishState}`"
>
{{ publishStatusShortLabel(account.publishState) }}
</span>
</a-tooltip>
</button>
</div>
<div v-else class="generate-task-drawer__empty">
<strong>{{ t("custom.task.publishAccountEmptyTitle") }}</strong>
<p>{{ t("custom.task.publishAccountEmptyHint") }}</p>
</div>
</div>
<div class="generate-task-drawer__field">
<div class="generate-task-drawer__cover-head">
<label
class="generate-task-drawer__label"
:class="{ 'generate-task-drawer__label--required': scheduleCoverRequired }"
>
{{ t("custom.task.cover") }}
</label>
<a-switch
:checked="effectiveScheduleCoverEnabled"
:disabled="scheduleCoverRequired"
@change="handleScheduleCoverToggle"
/>
</div>
<p class="generate-task-drawer__hint">
{{ scheduleCoverRequired ? t("custom.task.scheduleCoverRequiredHint") : t("custom.task.scheduleCoverHint") }}
</p>
<div v-if="effectiveScheduleCoverEnabled" class="generate-task-drawer__cover-body">
<div class="generate-task-drawer__cover-preview-wrap">
<button type="button" class="generate-task-drawer__cover-preview" @click="coverPickerOpen = true">
<template v-if="form.coverAssetUrl">
<img :src="form.coverAssetUrl" alt="cover preview" />
</template>
<template v-else>
<span class="generate-task-drawer__cover-plus">+</span>
<span>{{ t("custom.task.coverUpload") }}</span>
</template>
</button>
<button
v-if="form.coverAssetUrl"
type="button"
class="generate-task-drawer__cover-remove"
:aria-label="t('article.editor.coverRemove')"
:title="t('article.editor.coverRemove')"
@click.stop="handleRemoveScheduleCover"
>
<MinusCircleFilled />
</button>
</div>
</div>
</div>
</template>
</section>
<section class="generate-task-drawer__panel generate-task-drawer__panel--bordered">
<div class="generate-task-drawer__section-head">
<div class="generate-task-drawer__section-marker"></div>
<h3>{{ t("custom.task.advancedSection") }}</h3>
</div>
<div v-if="isSchedule" class="generate-task-drawer__field">
<label class="generate-task-drawer__label">{{ t("custom.task.scheduleTime") }}</label>
<div class="generate-task-drawer__time-wrapper">
<span>{{ t("custom.task.scheduleEveryDay") }}</span>
<a-time-picker
v-model:value="form.scheduleTime"
value-format="HH:mm:ss"
size="large"
:allow-clear="false"
style="width: 160px"
/>
</div>
</div>
<div class="generate-task-drawer__field generate-task-drawer__field--inline">
<label class="generate-task-drawer__label">{{ t("custom.task.enableWebSearch") }}</label>
<a-switch v-model:checked="form.enableWebSearch" />
</div>
<div class="generate-task-drawer__field generate-task-drawer__field--narrow">
<label class="generate-task-drawer__label">{{ t("custom.task.generateCount") }}</label>
<a-input-number
v-model:value="form.generateCount"
:min="1"
:max="20"
:precision="0"
size="large"
/>
</div>
</section>
</div>
<template #footer>
<div class="generate-task-drawer__footer">
<a-button size="large" @click="emit('update:open', false)">
{{ t("common.cancel") }}
</a-button>
<a-button type="primary" size="large" :loading="submitLoading" @click="handleSubmit">
{{ submitText }}
</a-button>
</div>
</template>
<PromptRuleModal v-model:open="promptDrawerOpen" @saved="handlePromptSaved" />
<CoverPickerModal
v-model:open="coverPickerOpen"
:article-id="null"
:platform-ids="selectedSchedulePublishPlatformIds"
:current-url="form.coverAssetUrl"
:current-file-name="form.coverFileName"
:current-asset-id="form.coverImageAssetId"
:upload-handler="uploadScheduleCover"
@confirmed="handleScheduleCoverPicked"
/>
</a-drawer>
</template>
<style scoped>
.generate-task-drawer :deep(.ant-drawer-header) {
padding: 30px 28px;
border-bottom: 1px solid #edf2f7;
}
.generate-task-drawer :deep(.ant-drawer-title) {
color: #111827;
font-size: 18px;
font-weight: 800;
}
.generate-task-drawer :deep(.ant-drawer-body) {
padding: 30px 32px 32px;
background: #fff;
}
.generate-task-drawer :deep(.ant-drawer-footer) {
padding: 18px 24px;
border-top: 1px solid #edf2f7;
background: #fff;
}
.generate-task-drawer__body {
padding: 0 20px;
display: flex;
flex-direction: column;
}
.generate-task-drawer__panel {
padding: 16px 0 32px;
}
.generate-task-drawer__panel--bordered {
padding-top: 32px;
border-top: 1px solid #edf2f7;
}
.generate-task-drawer__section-head {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 20px;
border-bottom: 1px solid #fafafa;
}
.generate-task-drawer__section-marker {
width: 4px;
height: 16px;
border-radius: 2px;
background: #111827;
}
.generate-task-drawer__section-head h3 {
margin: 0;
color: #111827;
font-size: 16px;
font-weight: 800;
}
.generate-task-drawer__field {
margin-top: 24px;
}
.generate-task-drawer__field--inline {
display: flex;
align-items: center;
justify-content: space-between;
}
.generate-task-drawer__field--narrow {
max-width: 220px;
}
.generate-task-drawer__label {
display: inline-flex;
align-items: center;
margin-bottom: 12px;
color: #1f2937;
font-size: 15px;
font-weight: 700;
}
.generate-task-drawer__label--required::before {
content: "*";
margin-right: 4px;
color: #ff4d4f;
}
.generate-task-drawer__hint {
margin: -2px 0 14px;
color: #667085;
font-size: 13px;
line-height: 1.7;
}
.generate-task-drawer__hint--compact {
margin-top: 6px;
}
.generate-task-drawer__prompt-row {
display: flex;
gap: 12px;
align-items: center;
}
.generate-task-drawer__prompt-select {
flex: 0 1 320px;
}
.generate-task-drawer__prompt-create {
border-style: dashed;
border-color: #9db7ff;
color: #355dff;
}
.generate-task-drawer__account-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 12px;
}
.generate-task-drawer__account {
display: grid;
grid-template-columns: 20px 36px minmax(0, 1fr);
gap: 12px;
align-items: center;
min-height: 76px;
padding: 12px 14px;
border: 1px solid #e1e8f2;
border-radius: 8px;
background: #fff;
text-align: left;
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
}
.generate-task-drawer__account:hover {
border-color: #b7c7ec;
background: #fbfdff;
}
.generate-task-drawer__account--active {
border-color: #355dff;
background: #f4f7ff;
box-shadow: 0 4px 12px rgba(53, 93, 255, 0.08);
}
.generate-task-drawer__account--disabled {
cursor: not-allowed;
opacity: 0.55;
}
.generate-task-drawer__account-check {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: 1px solid #d0d7e5;
border-radius: 4px;
background: #fff;
}
.generate-task-drawer__account--active .generate-task-drawer__account-check {
border-color: #355dff;
background: #355dff;
}
.generate-task-drawer__account-check span {
width: 6px;
height: 6px;
border-radius: 1px;
background: #fff;
}
.generate-task-drawer__account-logo {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
border: 1px solid #e4e9f2;
background: #fff;
color: var(--account-accent);
font-size: 12px;
font-weight: 800;
overflow: hidden;
}
.generate-task-drawer__account-logo img {
width: 24px;
height: 24px;
object-fit: contain;
}
.generate-task-drawer__account-logo span {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
background: color-mix(in srgb, var(--account-accent) 10%, #ffffff);
}
.generate-task-drawer__account-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
}
.generate-task-drawer__account-copy strong {
overflow: hidden;
color: #1f2937;
font-size: 14px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.generate-task-drawer__account-copy small,
.generate-task-drawer__account-status {
overflow: hidden;
color: #667085;
font-size: 12px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.generate-task-drawer__account-status {
grid-column: 3;
color: #355dff;
}
.generate-task-drawer__account-status--immediate {
color: #039855;
}
.generate-task-drawer__account-status--queued {
color: #dc6803;
}
.generate-task-drawer__account-status--unavailable {
color: #d92d20;
}
.generate-task-drawer__cover-head {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
}
.generate-task-drawer__cover-head .generate-task-drawer__label {
margin-bottom: 0;
}
.generate-task-drawer__empty {
padding: 18px 20px;
border: 1px dashed #cfd9e8;
border-radius: 8px;
background: #fbfcff;
}
.generate-task-drawer__empty strong {
display: block;
color: #1f2937;
font-size: 15px;
font-weight: 700;
}
.generate-task-drawer__empty p {
margin: 6px 0 0;
color: #667085;
font-size: 13px;
line-height: 1.7;
}
.generate-task-drawer__cover-body {
display: flex;
margin-top: 12px;
}
.generate-task-drawer__cover-preview-wrap {
position: relative;
width: 176px;
flex: 0 0 176px;
}
.generate-task-drawer__cover-preview {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
min-height: 124px;
padding: 0;
border: 1px dashed #d1d5db;
border-radius: 8px;
background: #f9fafb;
color: #6b7280;
font-size: 13px;
cursor: pointer;
overflow: hidden;
transition: all 0.2s ease;
}
.generate-task-drawer__cover-preview:hover {
border-color: #6366f1;
background: #f5f3ff;
color: #6366f1;
}
.generate-task-drawer__cover-preview img {
display: block;
width: 100%;
height: 124px;
object-fit: cover;
}
.generate-task-drawer__cover-plus {
font-size: 20px;
line-height: 1;
}
.generate-task-drawer__cover-remove {
position: absolute;
top: 8px;
right: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 999px;
background: rgba(17, 24, 39, 0.7);
color: #fff;
cursor: pointer;
transition: background 0.2s ease;
}
.generate-task-drawer__cover-remove:hover {
background: rgba(17, 24, 39, 0.9);
}
.generate-task-drawer__time-wrapper {
display: flex;
align-items: center;
gap: 12px;
}
.generate-task-drawer__time-wrapper span {
color: #344054;
font-size: 15px;
font-weight: 600;
}
.generate-task-drawer__footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
@media (max-width: 1024px) {
.generate-task-drawer__prompt-row {
flex-direction: column;
align-items: stretch;
}
.generate-task-drawer__prompt-select {
flex-basis: auto;
}
}
</style>