feat(阶段1): 搬入 ojnext 为 apps/web,未改业务代码
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
createAchievement,
|
||||
getMetricOptions,
|
||||
updateAchievement,
|
||||
type AdminAchievement,
|
||||
type MetricOption,
|
||||
} from "admin/api"
|
||||
import AchievementIcon from "shared/components/AchievementIcon.vue"
|
||||
import { RARITY_LABEL } from "utils/constants"
|
||||
|
||||
const rarityOptions = Object.entries(RARITY_LABEL).map(([value, label]) => ({
|
||||
label,
|
||||
value,
|
||||
}))
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
editing: AdminAchievement | null
|
||||
}>()
|
||||
const emit = defineEmits<{ "update:show": [boolean]; saved: [] }>()
|
||||
|
||||
const message = useMessage()
|
||||
const metrics = ref<MetricOption[]>([])
|
||||
const saving = ref(false)
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
name: "",
|
||||
description: "",
|
||||
icon: "noto:trophy",
|
||||
rarity: "bronze",
|
||||
hidden: false,
|
||||
metric: "",
|
||||
operator: "gte" as "gte" | "lte",
|
||||
threshold: 1,
|
||||
visible: true,
|
||||
order: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const form = ref(emptyForm())
|
||||
|
||||
const metricOptions = computed(() =>
|
||||
metrics.value.map((m) => ({ label: `${m.name}(${m.key})`, value: m.key })),
|
||||
)
|
||||
|
||||
const metricHelp = computed(
|
||||
() => metrics.value.find((m) => m.key === form.value.metric)?.help_text ?? "",
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
async (show) => {
|
||||
if (!show) return
|
||||
if (!metrics.value.length) {
|
||||
const res = await getMetricOptions()
|
||||
metrics.value = res.data
|
||||
}
|
||||
if (props.editing) {
|
||||
form.value = { ...emptyForm(), ...props.editing }
|
||||
} else {
|
||||
form.value = { ...emptyForm(), metric: metrics.value[0]?.key ?? "" }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function save() {
|
||||
if (!form.value.name || !form.value.metric) {
|
||||
message.error("名称和指标不能为空")
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (props.editing) {
|
||||
await updateAchievement({ ...form.value, id: props.editing.id })
|
||||
} else {
|
||||
await createAchievement(form.value)
|
||||
}
|
||||
message.success("保存成功")
|
||||
emit("update:show", false)
|
||||
emit("saved")
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
:show="show"
|
||||
preset="card"
|
||||
style="width: 560px"
|
||||
:title="editing ? '编辑成就' : '新建成就'"
|
||||
@update:show="emit('update:show', $event)"
|
||||
>
|
||||
<n-form label-placement="left" :label-width="80">
|
||||
<n-form-item label="名称" required>
|
||||
<n-input v-model:value="form.name" placeholder="成就名称" />
|
||||
</n-form-item>
|
||||
<n-form-item label="描述" required>
|
||||
<n-input
|
||||
v-model:value="form.description"
|
||||
type="textarea"
|
||||
placeholder="达成条件的描述,展示给学生看"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="图标">
|
||||
<n-flex vertical :size="4" style="flex: 1">
|
||||
<n-flex align="center" :size="10" :wrap="false">
|
||||
<div class="icon-preview">
|
||||
<AchievementIcon :icon="form.icon" :size="28" />
|
||||
</div>
|
||||
<n-input
|
||||
v-model:value="form.icon"
|
||||
placeholder="iconify 图标名,例如 noto:owl"
|
||||
/>
|
||||
</n-flex>
|
||||
<n-text depth="3" style="font-size: 12px">
|
||||
填 iconify 图标名(推荐 noto: 开头的彩色 emoji 图标),左侧是实时
|
||||
预览;预览不出来说明名字写错了。图标名可在 icon-sets.iconify.design
|
||||
搜索。
|
||||
</n-text>
|
||||
</n-flex>
|
||||
</n-form-item>
|
||||
<n-form-item label="稀有度">
|
||||
<n-select v-model:value="form.rarity" :options="rarityOptions" />
|
||||
</n-form-item>
|
||||
<n-form-item label="指标" required>
|
||||
<n-select
|
||||
v-model:value="form.metric"
|
||||
:options="metricOptions"
|
||||
filterable
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item v-if="metricHelp" label=" ">
|
||||
<n-text depth="3">{{ metricHelp }}</n-text>
|
||||
</n-form-item>
|
||||
<n-form-item label="条件">
|
||||
<n-flex align="center">
|
||||
<n-select
|
||||
v-model:value="form.operator"
|
||||
style="width: 130px"
|
||||
:options="[
|
||||
{ label: '大于等于', value: 'gte' },
|
||||
{ label: '小于等于', value: 'lte' },
|
||||
]"
|
||||
/>
|
||||
<n-input-number v-model:value="form.threshold" :min="0" />
|
||||
</n-flex>
|
||||
</n-form-item>
|
||||
<n-form-item label="隐藏成就">
|
||||
<n-switch v-model:value="form.hidden" />
|
||||
<n-text depth="3" style="margin-left: 12px">
|
||||
未解锁时学生只能看到 ???
|
||||
</n-text>
|
||||
</n-form-item>
|
||||
<n-form-item label="上架">
|
||||
<n-switch v-model:value="form.visible" />
|
||||
</n-form-item>
|
||||
<n-form-item label="排序">
|
||||
<n-input-number v-model:value="form.order" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="emit('update:show', false)">取消</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="save">
|
||||
保存
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.icon-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 34px;
|
||||
flex: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NFlex } from "naive-ui"
|
||||
import {
|
||||
deleteAchievement,
|
||||
getAdminAchievements,
|
||||
type AdminAchievement,
|
||||
} from "admin/api"
|
||||
import AchievementIcon from "shared/components/AchievementIcon.vue"
|
||||
import AchievementModal from "./components/AchievementModal.vue"
|
||||
import { RARITY_LABEL } from "utils/constants"
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
||||
const list = ref<AdminAchievement[]>([])
|
||||
const loading = ref(false)
|
||||
const showModal = ref(false)
|
||||
const editing = ref<AdminAchievement | null>(null)
|
||||
|
||||
// 下架的只在有的时候才提,全部上架时标题不啰嗦
|
||||
const title = computed(() => {
|
||||
if (!list.value.length) return "成就管理"
|
||||
const offline = list.value.filter((a) => !a.visible).length
|
||||
return offline
|
||||
? `成就管理(${list.value.length} 条,${offline} 条下架)`
|
||||
: `成就管理(${list.value.length} 条)`
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getAdminAchievements()
|
||||
list.value = res.data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function create() {
|
||||
editing.value = null
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function edit(row: AdminAchievement) {
|
||||
editing.value = row
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function remove(row: AdminAchievement) {
|
||||
dialog.warning({
|
||||
title: "删除成就",
|
||||
content: `确定删除「${row.name}」?已解锁记录会一并删除。`,
|
||||
positiveText: "删除",
|
||||
negativeText: "取消",
|
||||
onPositiveClick: async () => {
|
||||
await deleteAchievement(row.id)
|
||||
message.success("已删除")
|
||||
load()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const columns: DataTableColumn<AdminAchievement>[] = [
|
||||
{
|
||||
title: "图标",
|
||||
key: "icon",
|
||||
width: 60,
|
||||
render: (row) => h(AchievementIcon, { icon: row.icon, size: 24 }),
|
||||
},
|
||||
{ title: "名称", key: "name" },
|
||||
{
|
||||
title: "稀有度",
|
||||
key: "rarity",
|
||||
width: 90,
|
||||
render: (row) => RARITY_LABEL[row.rarity] ?? row.rarity,
|
||||
},
|
||||
{ title: "指标", key: "metric_name" },
|
||||
{
|
||||
title: "条件",
|
||||
key: "threshold",
|
||||
width: 110,
|
||||
render: (row) => `${row.operator === "gte" ? "≥" : "≤"} ${row.threshold}`,
|
||||
},
|
||||
{
|
||||
title: "隐藏",
|
||||
key: "hidden",
|
||||
width: 70,
|
||||
render: (row) => (row.hidden ? "是" : "—"),
|
||||
},
|
||||
{
|
||||
title: "上架",
|
||||
key: "visible",
|
||||
width: 70,
|
||||
render: (row) => (row.visible ? "是" : "否"),
|
||||
},
|
||||
{ title: "已解锁人数", key: "unlock_count", width: 110 },
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 130,
|
||||
render: (row) =>
|
||||
h(NFlex, { size: 8 }, () => [
|
||||
h(
|
||||
NButton,
|
||||
{ text: true, type: "primary", onClick: () => edit(row) },
|
||||
() => "编辑",
|
||||
),
|
||||
h(
|
||||
NButton,
|
||||
{ text: true, type: "error", onClick: () => remove(row) },
|
||||
() => "删除",
|
||||
),
|
||||
]),
|
||||
},
|
||||
]
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-card :title="title">
|
||||
<template #header-extra>
|
||||
<n-button type="primary" @click="create">新建成就</n-button>
|
||||
</template>
|
||||
|
||||
<n-alert type="info" style="margin-bottom: 12px">
|
||||
「已解锁人数」是唯一的仪表盘:配置一周后仍为
|
||||
0,多半是阈值配错了而不是太难。
|
||||
</n-alert>
|
||||
|
||||
<n-data-table
|
||||
:loading="loading"
|
||||
:data="list"
|
||||
:columns="columns"
|
||||
:row-key="(row: AdminAchievement) => row.id"
|
||||
/>
|
||||
|
||||
<AchievementModal
|
||||
v-model:show="showModal"
|
||||
:editing="editing"
|
||||
@saved="load"
|
||||
/>
|
||||
</n-card>
|
||||
</template>
|
||||
Reference in New Issue
Block a user