linguaielts-api / fronted /src /views /admin /AdminWritingContent.vue
phuc1st
feat(cms): implement soft-archive (hide) and restore (unhide) features for tests
076c536
Raw
History Blame Contribute Delete
17.7 kB
<template>
<div class="admin-page mx-auto max-w-7xl space-y-5" :style="pageStyle">
<AdminPageHeader
module="writing"
title="Writing CMS"
subtitle="Nhập nội dung form — JSON tự sinh bên dưới. Lưu khi đã xong."
/>
<AdminCrudBar
module="writing"
:can-archive="!!selectedId"
:is-archived="isArchived"
@create="newTopic"
@save="saveForm"
@archive="archiveTopic"
@refresh="loadList"
/>
<div class="rounded-lg border border-[var(--border)] bg-white p-3">
<div class="grid gap-3 md:grid-cols-[1fr_160px_160px_120px]">
<input v-model="filters.q" class="ct-input" placeholder="Search title" @keyup.enter="loadList" />
<select v-model="filters.task_type" class="ct-input">
<option value="">All tasks</option>
<option value="1">Task 1</option>
<option value="2">Task 2</option>
</select>
<select v-model="filters.status" class="ct-input">
<option value="">All status</option>
<option value="published">published</option>
<option value="archived">archived</option>
</select>
<button class="ct-btn" @click="loadList">Filter</button>
</div>
</div>
<div class="grid gap-4 lg:grid-cols-[420px_1fr]">
<section class="rounded-lg border border-[var(--border)] bg-white">
<div class="max-h-[760px] overflow-y-auto">
<button
v-for="item in items"
:key="item.id"
class="block w-full border-b border-[var(--border)] px-4 py-3 text-left hover:bg-[var(--bg)]"
:class="selectedId === item.id ? 'admin-list-active' : ''"
@click="selectTopic(item.id)"
>
<div class="line-clamp-2 text-sm font-semibold text-[var(--ink)]">{{ item.title }}<span v-if="item.status === 'archived'" class="ml-1.5 inline-block rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-bold text-amber-700">Đã ẩn</span></div>
<div class="mt-1 text-xs text-[var(--ink3)]">#{{ item.id }} · Task {{ item.writing_task_type || '?' }} · {{ item.status || 'published' }}</div>
</button>
<div v-if="!items.length" class="p-6 text-center text-sm text-[var(--ink3)]">No writing topics.</div>
</div>
</section>
<section class="space-y-4">
<div v-if="error" class="rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">{{ error }}</div>
<div v-if="savedMessage" class="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-700">{{ savedMessage }}</div>
<div class="rounded-lg border border-[var(--border)] bg-white p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 class="text-sm font-bold text-[var(--ink)]">Writing topic editor</h2>
<p class="mt-1 text-xs text-[var(--ink3)]">Fields map to the public Writing practice page.</p>
</div>
<span class="rounded-md bg-[var(--bg)] px-2 py-1 text-xs font-semibold text-[var(--ink2)]">{{ taskHelper }}</span>
</div>
<div class="mt-4 grid gap-4">
<section class="grid gap-3 md:grid-cols-2">
<label class="text-xs font-semibold text-[var(--ink3)]">Title<input v-model="form.title" class="ct-input mt-1 w-full" /></label>
<label class="text-xs font-semibold text-[var(--ink3)]">Task type<select v-model.number="form.writing_task_type" class="ct-input mt-1 w-full"><option :value="1">Task 1</option><option :value="2">Task 2</option></select></label>
<label class="text-xs font-semibold text-[var(--ink3)]">Status<input v-model="form.status" class="ct-input mt-1 w-full" /></label>
</section>
<section class="grid gap-3 md:grid-cols-[180px_1fr]">
<div class="aspect-video overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--bg)]">
<img v-if="graphPreviewUrl" :src="graphPreviewUrl" class="h-full w-full object-contain" alt="" />
<div v-else class="flex h-full items-center justify-center text-center text-xs text-[var(--ink3)]">No graph image</div>
</div>
<div class="space-y-2">
<div class="text-xs font-semibold text-[var(--ink3)]">Upload graph / illustration</div>
<input type="file" accept="image/png,image/jpeg,image/webp" class="block w-full text-sm" @change="uploadGraphImage" />
<div class="flex flex-wrap gap-2">
<button class="ct-btn btn-sm" :disabled="uploadingImage" @click="runSync">{{ uploadingImage ? 'Uploading...' : 'Cập nhật JSON' }}</button>
<button class="ct-btn btn-sm" :disabled="!form.graph_image" @click="clearGraphImage">Clear image</button>
</div>
<p class="text-xs text-[var(--ink3)]">Saved to <code>questions[0].writing_graph_image</code>. Topic thumbnail is left unchanged.</p>
</div>
</section>
<section class="grid gap-3">
<label class="text-xs font-semibold text-[var(--ink3)]">Prompt text<textarea v-model="form.prompt_text" class="ct-input mt-1 min-h-24 w-full"></textarea></label>
<label class="text-xs font-semibold text-[var(--ink3)]">Prompt HTML<textarea v-model="form.prompt_html" class="ct-input mt-1 min-h-32 w-full font-mono text-xs"></textarea></label>
<label class="text-xs font-semibold text-[var(--ink3)]">Instruction / guidance HTML<textarea v-model="form.instruction" class="ct-input mt-1 min-h-36 w-full font-mono text-xs"></textarea></label>
</section>
<section class="rounded-lg border border-[var(--border)]">
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-[var(--border)] px-4 py-3">
<div>
<h3 class="text-sm font-bold text-[var(--ink)]">Logical frames</h3>
<p class="mt-1 text-xs text-[var(--ink3)]">Reference sections used by the writing practice helper.</p>
</div>
<button class="ct-btn btn-sm" @click="addFrame">Add frame</button>
</div>
<div class="divide-y divide-[var(--border)]">
<div v-for="(frame, idx) in form.logical_frames" :key="frame.local_id" class="grid gap-3 p-4 lg:grid-cols-[80px_1fr_120px]">
<label class="text-xs font-semibold text-[var(--ink3)]">Sort<input v-model.number="frame.sort" type="number" min="1" class="ct-input mt-1 w-full" /></label>
<label class="text-xs font-semibold text-[var(--ink3)]">Name<input v-model="frame.name" class="ct-input mt-1 w-full" /></label>
<label class="flex items-end gap-2 text-xs font-semibold text-[var(--ink3)]"><input v-model="frame.is_toggle_on" type="checkbox" class="mb-3" /> Enabled</label>
<label class="lg:col-span-3 text-xs font-semibold text-[var(--ink3)]">HTML<textarea v-model="frame.value" class="ct-input mt-1 min-h-24 w-full font-mono text-xs"></textarea></label>
<div class="lg:col-span-3 flex justify-end"><button class="ct-btn btn-sm" @click="removeFrame(idx)">Remove</button></div>
</div>
<div v-if="!form.logical_frames.length" class="p-6 text-center text-sm text-[var(--ink3)]">No logical frames.</div>
</div>
</section>
</div>
<div class="mt-4 flex flex-wrap gap-2">
<button class="ct-btn ct-btn-accent" @click="saveForm">Save form</button>
<button class="ct-btn" @click="saveRaw">Save raw JSON</button>
<button v-if="selectedId" class="ct-btn" @click="archiveTopic">{{ isArchived ? 'Hiện đề' : 'Ẩn đề' }}</button>
</div>
</div>
<div class="rounded-lg border border-[var(--border)] bg-white p-4">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-bold text-[var(--ink)]">Practice preview</h2>
<span class="text-xs text-[var(--ink3)]">Prompt + graph + guidance</span>
</div>
<div class="rounded-xl border border-[var(--border)] p-5">
<div v-if="form.prompt_html" class="prose prose-sm max-w-none text-sm leading-relaxed text-[var(--ink)]" v-html="form.prompt_html"></div>
<p v-else class="text-sm text-[var(--ink3)]">{{ form.prompt_text || 'No prompt yet.' }}</p>
<div v-if="graphPreviewUrl" class="mt-4 overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--bg)]">
<img :src="graphPreviewUrl" class="max-h-[420px] w-full object-contain" alt="" />
</div>
<details v-if="form.instruction" class="mt-4 rounded-lg border border-[var(--border)] bg-[var(--bg)]">
<summary class="cursor-pointer px-4 py-3 text-xs font-bold uppercase tracking-wider text-[#34d399]">Guidance</summary>
<div class="px-4 pb-4 pt-2 text-sm leading-relaxed text-[var(--ink)]" v-html="form.instruction"></div>
</details>
</div>
</div>
<AdminJsonPanel
v-model="rawText"
module="writing"
:live="jsonLive"
:syncing="jsonSyncing"
hint="JSON tự cập nhật khi bạn sửa form · Lưu sẽ validate JSON.parse"
/>
</section>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { adminService } from '@/services/adminService.js'
import AdminCrudBar from '@/components/admin/AdminCrudBar.vue'
import AdminPageHeader from '@/components/admin/AdminPageHeader.vue'
import AdminJsonPanel from '@/components/admin/AdminJsonPanel.vue'
import { moduleStyle } from '@/components/admin/adminModules.js'
import { useAutoJsonSync } from '@/composables/useAutoJsonSync.js'
import { imageUrl } from '@/utils/mediaUrl.js'
const pageStyle = moduleStyle('writing')
const items = ref([])
const selectedId = ref(null)
const rawText = ref('')
const error = ref('')
const savedMessage = ref('')
const uploadingImage = ref(false)
const filters = reactive({ q: '', task_type: '', status: '' })
const isArchived = computed(() => {
if (!selectedId.value) return false
const item = items.value.find(x => x.id === selectedId.value)
return item && item.status === 'archived'
})
const form = reactive({
title: '',
writing_task_type: 1,
status: 'published',
graph_image: '',
prompt_text: '',
prompt_html: '',
instruction: '',
logical_frames: [],
})
const { jsonLive, jsonSyncing, pause, resume, runSync } = useAutoJsonSync(form, () => {
setRaw(applyForm(parseRaw()))
})
const graphPreviewUrl = computed(() => imageUrl(form.graph_image))
const taskHelper = computed(() => Number(form.writing_task_type) === 2 ? 'Task 2 - 40 min - 250+ words' : 'Task 1 - 20 min - 150+ words')
function setRaw(raw) {
rawText.value = JSON.stringify(raw, null, 2)
}
function parseRaw() {
try {
return JSON.parse(rawText.value)
} catch {
throw new Error('Raw JSON invalid.')
}
}
function fillForm(item) {
const question = (item.questions || [])[0] || {}
Object.assign(form, {
title: item.title || '',
writing_task_type: Number(item.writing_task_type || 1),
status: item.status || 'published',
graph_image: question.writing_graph_image || item.writing_graph_image || '',
prompt_text: question.title || item.prompt_text || '',
prompt_html: question.content_writing || item.prompt_html || '',
instruction: question.instruction || '',
logical_frames: normalizeFrames(question.writing_logical_frame || []),
})
}
function applyForm(raw) {
const wrapper = raw.data ? raw : { code: 0, message: '', data: raw }
const item = wrapper.data
item.title = form.title
item.writing_task_type = Number(form.writing_task_type || 1)
item.status = form.status || 'published'
item.is_public = item.status !== 'archived'
item.questions = Array.isArray(item.questions) && item.questions.length ? item.questions : [emptyQuestion(item.id)]
const question = item.questions[0]
question.quiz_id = item.id || question.quiz_id
question.type = question.type || 'writing'
question.status = question.status || 'published'
question.order = question.order || 1
question.title = form.prompt_text
question.content_writing = form.prompt_html
question.instruction = form.instruction || ''
question.writing_graph_image = form.graph_image || ''
question.writing_logical_frame = form.logical_frames
.slice()
.sort((a, b) => Number(a.sort || 0) - Number(b.sort || 0))
.map((frame, idx) => ({
id: frame.id || Date.now() + idx,
sort: Number(frame.sort || idx + 1),
name: frame.name || `Frame ${idx + 1}`,
value: frame.value || '',
question_id: question.id,
is_toggle_on: Boolean(frame.is_toggle_on),
}))
wrapper.data = item
return wrapper
}
function emptyQuestion(topicId) {
return {
id: Date.now(),
quiz_id: topicId,
type: 'writing',
title: '',
status: 'published',
content_writing: '',
order: 1,
instruction: '',
writing_graph_image: '',
writing_logical_frame: [],
}
}
function normalizeFrames(frames) {
return (Array.isArray(frames) ? frames : [])
.slice()
.sort((a, b) => Number(a?.sort || 0) - Number(b?.sort || 0))
.map((frame, idx) => ({
local_id: `${frame?.id || 'new'}_${idx}_${Date.now()}`,
id: frame?.id || null,
sort: Number(frame?.sort || idx + 1),
name: frame?.name || `Frame ${idx + 1}`,
value: frame?.value || '',
is_toggle_on: frame?.is_toggle_on !== false,
}))
}
function addFrame() {
const nextSort = form.logical_frames.length + 1
form.logical_frames.push({
local_id: `new_${Date.now()}_${nextSort}`,
id: null,
sort: nextSort,
name: nextSort === 1 ? 'Introduction' : `Frame ${nextSort}`,
value: '',
is_toggle_on: true,
})
}
function removeFrame(idx) {
form.logical_frames.splice(idx, 1)
}
async function uploadGraphImage(event) {
const file = event.target.files?.[0]
if (!file) return
error.value = ''
savedMessage.value = ''
uploadingImage.value = true
try {
const result = await adminService.uploadAdminImage(file)
form.graph_image = result.id
runSync()
savedMessage.value = 'Graph image uploaded.'
} catch (err) {
error.value = err.response?.data?.detail || err.message || 'Cannot upload graph image.'
} finally {
uploadingImage.value = false
event.target.value = ''
}
}
function clearGraphImage() {
form.graph_image = ''
runSync()
}
async function loadList() {
const params = {
q: filters.q || undefined,
task_type: filters.task_type || undefined,
status: filters.status || undefined,
}
const data = await adminService.listWritingTopics(params)
items.value = data.items
}
async function selectTopic(id) {
pause()
error.value = ''
savedMessage.value = ''
const data = await adminService.getWritingTopic(id)
selectedId.value = id
setRaw(data.raw_json)
fillForm(data.item)
resume()
runSync()
}
function newTopic() {
pause()
selectedId.value = null
const raw = { code: 0, message: '', data: { title: 'New writing topic', status: 'published', writing_task_type: 1, questions: [emptyQuestion(null)], is_public: true } }
setRaw(raw)
fillForm(raw.data)
resume()
runSync()
}
async function saveForm() {
try {
const raw = applyForm(parseRaw())
const result = selectedId.value
? await adminService.updateWritingTopic(selectedId.value, raw)
: await adminService.createWritingTopic(raw)
selectedId.value = result.item.id
setRaw(result.raw_json)
fillForm(result.item)
savedMessage.value = `Saved. Backup: ${result.backup_path || 'none'}`
await loadList()
} catch (err) {
error.value = err.message || err.response?.data?.detail || 'Cannot save writing topic.'
}
}
async function saveRaw() {
try {
const raw = parseRaw()
const id = selectedId.value || raw.data?.id || raw.id
const result = id ? await adminService.updateWritingTopic(id, raw) : await adminService.createWritingTopic(raw)
selectedId.value = result.item.id
setRaw(result.raw_json)
fillForm(result.item)
savedMessage.value = `Saved raw JSON. Backup: ${result.backup_path || 'none'}`
await loadList()
} catch (err) {
error.value = err.message || err.response?.data?.detail || 'Cannot save raw JSON.'
}
}
async function archiveTopic() {
if (!selectedId.value) return
if (isArchived.value) {
if (!window.confirm('Hiện lại đề này? Đề sẽ hiển thị lại cho người dùng.')) return
try {
const result = await adminService.restoreWritingTopic(selectedId.value)
setRaw(result.raw_json)
fillForm(result.item)
savedMessage.value = 'Đã hiện lại đề.'
await loadList()
} catch (err) {
error.value = err.message || err.response?.data?.detail || 'Không hiện lại được.'
}
} else {
if (!window.confirm('Ẩn đề này? Đề sẽ không hiển thị cho người dùng.')) return
try {
const result = await adminService.archiveWritingTopic(selectedId.value)
setRaw(result.raw_json)
fillForm(result.item)
savedMessage.value = 'Đã ẩn đề.'
await loadList()
} catch (err) {
error.value = err.message || err.response?.data?.detail || 'Không ẩn được.'
}
}
}
onMounted(async () => {
await loadList()
if (items.value.length) await selectTopic(items.value[0].id)
})
</script>