252 lines
8.3 KiB
Vue
252 lines
8.3 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* 通用资源表单:按 resourceSchemas.fields 渲染,支持 media/switch/select。
|
|
* JSON 模式仅作高级兜底;复杂资源应走专用页。
|
|
*/
|
|
import { computed, reactive, ref } from 'vue'
|
|
import { onLoad, onShow } from '@dcloudio/uni-app'
|
|
import { useToast } from '@wot-ui/ui'
|
|
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
|
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
|
import AdminMediaPicker from '../../components/AdminMediaPicker.vue'
|
|
import AdminFormField from '../../components/AdminFormField.vue'
|
|
import { useAuth } from '@/composables/useAuth'
|
|
import { useAppMode } from '@/composables/useAppMode'
|
|
import { adminCreate, adminGet, adminUpdate } from '@/api'
|
|
import { getResourceSchema, type SchemaField } from '../../config/resourceSchemas'
|
|
|
|
const toast = useToast()
|
|
const { isAuthenticated } = useAuth()
|
|
const { canEnterAdmin } = useAppMode()
|
|
|
|
const resource = ref('')
|
|
const mode = ref<'create' | 'edit'>('create')
|
|
const id = ref('')
|
|
const loading = ref(false)
|
|
const submitting = ref(false)
|
|
const jsonMode = ref(false)
|
|
const form = reactive<Record<string, unknown>>({})
|
|
const rawJson = ref('{}')
|
|
|
|
const schema = computed(() => getResourceSchema(resource.value))
|
|
const fields = computed(() => schema.value.fields.filter(f => !f.editOnly || mode.value === 'edit'))
|
|
const pageTitle = computed(() => {
|
|
const label = schema.value.title || resource.value
|
|
return mode.value === 'create' ? `新建${label}` : `编辑${label}`
|
|
})
|
|
|
|
function guardAdmin() {
|
|
if (!isAuthenticated() || !canEnterAdmin.value) {
|
|
uni.reLaunch({ url: '/pages/mine/index' })
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
function initEmpty() {
|
|
Object.keys(form).forEach(k => delete form[k])
|
|
fields.value.forEach((f) => {
|
|
if (f.type === 'switch') form[f.key] = 1
|
|
else if (f.type === 'number' || f.type === 'rating') form[f.key] = f.type === 'rating' ? 5 : 0
|
|
else form[f.key] = ''
|
|
})
|
|
rawJson.value = '{}'
|
|
}
|
|
|
|
function fillFromRecord(data: Record<string, unknown>) {
|
|
Object.keys(form).forEach(k => delete form[k])
|
|
fields.value.forEach((f) => {
|
|
const v = data[f.key]
|
|
if (f.type === 'switch') form[f.key] = v === true || v === 1 || v === '1' ? 1 : 0
|
|
else if (f.type === 'number' || f.type === 'rating') form[f.key] = Number(v ?? 0)
|
|
else if (f.key === 'config' && typeof v === 'object') form[f.key] = JSON.stringify(v, null, 2)
|
|
else form[f.key] = v ?? ''
|
|
})
|
|
// 保留未声明字段以便 JSON 模式完整编辑
|
|
rawJson.value = JSON.stringify(data, null, 2)
|
|
}
|
|
|
|
async function load() {
|
|
if (!resource.value || !guardAdmin()) return
|
|
loading.value = true
|
|
try {
|
|
if (mode.value === 'edit' && id.value) {
|
|
const data = await adminGet(resource.value, id.value) as Record<string, unknown>
|
|
fillFromRecord(data)
|
|
}
|
|
else {
|
|
initEmpty()
|
|
}
|
|
}
|
|
catch (e: unknown) {
|
|
toast.error(e instanceof Error ? e.message : '加载失败')
|
|
}
|
|
finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
onLoad((q) => {
|
|
resource.value = String(q?.resource || '')
|
|
mode.value = (q?.mode === 'edit' ? 'edit' : 'create') as 'create' | 'edit'
|
|
id.value = String(q?.id || '')
|
|
// 无字段的资源强制 JSON
|
|
if (!getResourceSchema(resource.value).fields.length) jsonMode.value = true
|
|
})
|
|
|
|
onShow(load)
|
|
|
|
function buildPayload(): Record<string, unknown> {
|
|
if (jsonMode.value) {
|
|
try {
|
|
return JSON.parse(rawJson.value || '{}') as Record<string, unknown>
|
|
}
|
|
catch {
|
|
throw new Error('JSON 格式不正确')
|
|
}
|
|
}
|
|
const payload: Record<string, unknown> = {}
|
|
fields.value.forEach((f: SchemaField) => {
|
|
let v = form[f.key]
|
|
if (f.type === 'number' || f.type === 'rating') v = Number(v ?? 0)
|
|
if (f.type === 'switch') v = v ? 1 : 0
|
|
if (f.key === 'config' && typeof v === 'string') {
|
|
try { v = JSON.parse(v) }
|
|
catch { /* 保持字符串,后端可自行处理 */ }
|
|
}
|
|
payload[f.key] = v
|
|
})
|
|
return payload
|
|
}
|
|
|
|
const onSubmit = async () => {
|
|
submitting.value = true
|
|
try {
|
|
const payload = buildPayload()
|
|
if (mode.value === 'edit' && id.value) await adminUpdate(resource.value, id.value, payload)
|
|
else await adminCreate(resource.value, payload)
|
|
toast.success('已保存')
|
|
setTimeout(() => uni.navigateBack(), 400)
|
|
}
|
|
catch (e: unknown) {
|
|
toast.error(e instanceof Error ? e.message : '保存失败')
|
|
}
|
|
finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
|
|
function selectLabel(f: SchemaField) {
|
|
const opts = f.options || []
|
|
const hit = opts.find(o => String(o.value) === String(form[f.key]))
|
|
return hit?.label || '请选择'
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<AppPageShell admin>
|
|
<AppNavbar :title="pageTitle" show-back />
|
|
<view class="page-pad">
|
|
<view class="admin-card form-card">
|
|
<template v-if="!jsonMode && fields.length">
|
|
<template v-for="f in fields" :key="f.key">
|
|
<AdminFormField v-if="f.type === 'text' || f.type === 'number'" :label="f.label">
|
|
<wd-input
|
|
:model-value="String(form[f.key] ?? '')"
|
|
:placeholder="f.placeholder || f.label"
|
|
:disabled="f.readonly"
|
|
clearable
|
|
@update:model-value="(v: string | number) => form[f.key] = v"
|
|
/>
|
|
</AdminFormField>
|
|
<AdminFormField v-else-if="f.type === 'textarea'" :label="f.label">
|
|
<wd-textarea
|
|
:model-value="String(form[f.key] ?? '')"
|
|
:placeholder="f.placeholder || f.label"
|
|
@update:model-value="(v: string) => form[f.key] = v"
|
|
/>
|
|
</AdminFormField>
|
|
<AdminMediaPicker
|
|
v-else-if="f.type === 'media'"
|
|
:model-value="String(form[f.key] ?? '')"
|
|
:label="f.label"
|
|
@update:model-value="(v: string) => form[f.key] = v"
|
|
/>
|
|
<view v-else-if="f.type === 'switch'" class="switch-row">
|
|
<text>{{ f.label }}</text>
|
|
<wd-switch
|
|
:model-value="!!form[f.key]"
|
|
@change="(v: boolean) => form[f.key] = v ? 1 : 0"
|
|
/>
|
|
</view>
|
|
<view v-else-if="f.type === 'rating'" class="rating-row">
|
|
<text class="label">{{ f.label }}</text>
|
|
<view class="stars">
|
|
<text
|
|
v-for="n in 5"
|
|
:key="n"
|
|
class="star"
|
|
:class="{ on: Number(form[f.key]) >= n }"
|
|
@click="form[f.key] = n"
|
|
>
|
|
★
|
|
</text>
|
|
</view>
|
|
</view>
|
|
<wd-picker
|
|
v-else-if="f.type === 'select' && f.options"
|
|
:columns="[f.options.map(o => ({ label: o.label, value: o.value }))]"
|
|
@confirm="({ value }: { value: unknown[] }) => { form[f.key] = (value?.[0] as { value?: unknown })?.value ?? value?.[0] }"
|
|
>
|
|
<view class="picker-row">
|
|
<text class="label">{{ f.label }}</text>
|
|
<text class="val">{{ selectLabel(f) }}</text>
|
|
</view>
|
|
</wd-picker>
|
|
</template>
|
|
</template>
|
|
<template v-else>
|
|
<AdminFormField label="JSON">
|
|
<wd-textarea v-model="rawJson" placeholder="{}" />
|
|
</AdminFormField>
|
|
</template>
|
|
|
|
<view class="actions">
|
|
<wd-button size="small" plain @click="jsonMode = !jsonMode">
|
|
{{ jsonMode ? '表单模式' : 'JSON 模式' }}
|
|
</wd-button>
|
|
</view>
|
|
|
|
<wd-button
|
|
block
|
|
type="primary"
|
|
:loading="submitting"
|
|
custom-style="margin-top:24rpx;background:#d4b383;border-color:#d4b383;"
|
|
@click="onSubmit"
|
|
>
|
|
保存
|
|
</wd-button>
|
|
</view>
|
|
<wd-loading v-if="loading" />
|
|
</view>
|
|
</AppPageShell>
|
|
</template>
|
|
|
|
<style scoped lang="scss">
|
|
.page-pad { padding: 24rpx 32rpx 80rpx; }
|
|
.actions { display: flex; flex-wrap: wrap; gap: 12rpx; margin-top: 16rpx; }
|
|
.switch-row, .picker-row, .rating-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 20rpx 0;
|
|
color: rgb(var(--art-text));
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
}
|
|
.label { font-size: 28rpx; }
|
|
.val { font-size: 26rpx; color: rgba(255, 255, 255, 0.65); }
|
|
.stars { display: flex; gap: 8rpx; }
|
|
.star { font-size: 36rpx; color: rgba(255, 255, 255, 0.25); }
|
|
.star.on { color: rgb(var(--art-accent)); }
|
|
</style>
|