1. 小程序端
2. 视频优化
This commit is contained in:
161
src/subPackages/admin/components/AdminBarChart.vue
Normal file
161
src/subPackages/admin/components/AdminBarChart.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 后台柱状图:金色柱 + 暗色网格,用于热门文章等排行。
|
||||
* 与折线图相同:首帧宽度可能为 0,需原生宽高 + 重试,否则小程序上空白。
|
||||
* getCurrentInstance 仅在 setup/同步生命周期有效,必须在此处缓存 proxy,
|
||||
* 否则 nextTick/setTimeout 里 .in(null) 会抛 $scope of null。
|
||||
*/
|
||||
import { getCurrentInstance, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
categories?: string[]
|
||||
values?: number[]
|
||||
height?: number
|
||||
}>(), {
|
||||
categories: () => [],
|
||||
values: () => [],
|
||||
height: 220,
|
||||
})
|
||||
|
||||
/** setup 阶段同步缓存,供异步 draw 使用 */
|
||||
const compProxy = getCurrentInstance()?.proxy as never
|
||||
const canvasId = `bar-${Math.random().toString(36).slice(2, 8)}`
|
||||
/** 原生 canvas 缓冲像素宽(含 dpr) */
|
||||
const canvasW = ref(300)
|
||||
const canvasH = ref(220)
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let retryCount = 0
|
||||
const MAX_RETRY = 5
|
||||
|
||||
/** 测量失败时用窗口宽度减去边距估算逻辑宽度;优先 getWindowInfo */
|
||||
function fallbackWidth(): number {
|
||||
try {
|
||||
const win = typeof uni.getWindowInfo === 'function' ? uni.getWindowInfo() : null
|
||||
if (win?.windowWidth) return Math.max(200, win.windowWidth - 48)
|
||||
const sys = uni.getSystemInfoSync()
|
||||
return Math.max(200, (sys.windowWidth || 375) - 48)
|
||||
} catch {
|
||||
return 300
|
||||
}
|
||||
}
|
||||
|
||||
function getDpr(): number {
|
||||
try {
|
||||
const device = typeof uni.getDeviceInfo === 'function' ? uni.getDeviceInfo() : null
|
||||
if (device && 'pixelRatio' in device && Number(device.pixelRatio)) {
|
||||
return Number(device.pixelRatio)
|
||||
}
|
||||
return uni.getSystemInfoSync().pixelRatio || 2
|
||||
} catch {
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建选择器:有组件实例则限定在组件内,否则页面级查询(canvasId 已全局唯一)。
|
||||
* 禁止对 null 调用 .in(),否则 mp-weixin 读 $scope 直接报错。
|
||||
*/
|
||||
function createQuery() {
|
||||
const q = uni.createSelectorQuery()
|
||||
if (compProxy) return q.in(compProxy)
|
||||
return q
|
||||
}
|
||||
|
||||
/** 在逻辑像素坐标下绘制柱状图 */
|
||||
function paint(ctx: UniApp.CanvasContext, w: number, h: number, dpr: number) {
|
||||
const cats = props.categories
|
||||
const vals = props.values
|
||||
ctx.scale(dpr, dpr)
|
||||
const padL = 12
|
||||
const padR = 12
|
||||
const padT = 16
|
||||
const padB = 40
|
||||
const plotW = w - padL - padR
|
||||
const plotH = h - padT - padB
|
||||
const max = Math.max(1, ...vals)
|
||||
const n = cats.length
|
||||
const gap = 8
|
||||
const barW = Math.max(8, (plotW - gap * (n + 1)) / n)
|
||||
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
ctx.setStrokeStyle('rgba(255,255,255,0.08)')
|
||||
for (let i = 0; i <= 3; i++) {
|
||||
const y = padT + (plotH * i) / 3
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(padL, y)
|
||||
ctx.lineTo(w - padR, y)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
vals.forEach((v, i) => {
|
||||
const bh = (v / max) * plotH
|
||||
const x = padL + gap + i * (barW + gap)
|
||||
const y = padT + plotH - bh
|
||||
ctx.setFillStyle('rgba(212,179,131,0.85)')
|
||||
ctx.fillRect(x, y, barW, bh)
|
||||
ctx.setFillStyle('rgba(136,136,136,1)')
|
||||
ctx.setFontSize(9)
|
||||
ctx.fillText(String(cats[i] || '').slice(0, 4), x, h - 12)
|
||||
})
|
||||
ctx.draw()
|
||||
}
|
||||
|
||||
/**
|
||||
* 测量并绘制;宽度为 0 时 fallback + 短延迟重试;
|
||||
* 先写原生宽高再 nextTick 绘制。
|
||||
*/
|
||||
const draw = (isRetry = false) => {
|
||||
const cats = props.categories
|
||||
const vals = props.values
|
||||
if (!cats.length || !vals.length) return
|
||||
if (!isRetry) retryCount = 0
|
||||
|
||||
createQuery()
|
||||
.select(`#${canvasId}`)
|
||||
.boundingClientRect((rect) => {
|
||||
const box = Array.isArray(rect) ? rect[0] : rect
|
||||
let w = box?.width || 0
|
||||
const measuredOk = !!w
|
||||
if (!w) w = fallbackWidth()
|
||||
if (!measuredOk && retryCount < MAX_RETRY) {
|
||||
retryCount += 1
|
||||
if (retryTimer) clearTimeout(retryTimer)
|
||||
retryTimer = setTimeout(() => draw(true), 80)
|
||||
} else if (measuredOk) {
|
||||
retryCount = 0
|
||||
}
|
||||
if (!w) return
|
||||
|
||||
const h = props.height
|
||||
const dpr = getDpr()
|
||||
canvasW.value = Math.floor(w * dpr)
|
||||
canvasH.value = Math.floor(h * dpr)
|
||||
nextTick(() => {
|
||||
const ctx = uni.createCanvasContext(canvasId, compProxy)
|
||||
paint(ctx, w, h, dpr)
|
||||
})
|
||||
})
|
||||
.exec()
|
||||
}
|
||||
|
||||
onMounted(() => nextTick(() => draw()))
|
||||
onUnmounted(() => {
|
||||
if (retryTimer) clearTimeout(retryTimer)
|
||||
})
|
||||
watch(() => [props.categories, props.values], () => nextTick(() => draw()), { deep: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<canvas
|
||||
:id="canvasId"
|
||||
:canvas-id="canvasId"
|
||||
class="chart"
|
||||
:width="canvasW"
|
||||
:height="canvasH"
|
||||
:style="{ width: '100%', height: `${height}px` }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chart { width: 100%; display: block; }
|
||||
</style>
|
||||
111
src/subPackages/admin/components/AdminDetailDrawer.vue
Normal file
111
src/subPackages/admin/components/AdminDetailDrawer.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 基于 page-container 的详情抽屉,防止小程序侧滑误退页面。
|
||||
* 只读展示键值对,底部可插操作按钮(如咨询状态变更)。
|
||||
*/
|
||||
withDefaults(defineProps<{
|
||||
show: boolean
|
||||
title?: string
|
||||
rows?: { label: string, value: string }[]
|
||||
}>(), {
|
||||
title: '详情',
|
||||
rows: () => [],
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:show': [boolean]
|
||||
close: []
|
||||
}>()
|
||||
|
||||
function onLeave() {
|
||||
emit('update:show', false)
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<page-container :show="show" position="bottom" round :overlay="true" @afterleave="onLeave" @clickoverlay="onLeave">
|
||||
<view class="drawer">
|
||||
<view class="head">
|
||||
<text class="title">{{ title }}</text>
|
||||
<wd-icon name="close" size="20px" @click="onLeave" />
|
||||
</view>
|
||||
<scroll-view scroll-y class="body">
|
||||
<view v-for="(row, i) in rows" :key="i" class="row">
|
||||
<text class="label text-art-muted">{{ row.label }}</text>
|
||||
<text class="value">{{ row.value || '—' }}</text>
|
||||
</view>
|
||||
<slot />
|
||||
</scroll-view>
|
||||
<view v-if="$slots.footer" class="footer">
|
||||
<slot name="footer" />
|
||||
</view>
|
||||
</view>
|
||||
</page-container>
|
||||
<!-- #endif -->
|
||||
<!-- #ifndef MP-WEIXIN -->
|
||||
<view v-if="show" class="mask" @click="onLeave">
|
||||
<view class="drawer" @click.stop>
|
||||
<view class="head">
|
||||
<text class="title">{{ title }}</text>
|
||||
<wd-icon name="close" size="20px" @click="onLeave" />
|
||||
</view>
|
||||
<scroll-view scroll-y class="body">
|
||||
<view v-for="(row, i) in rows" :key="i" class="row">
|
||||
<text class="label text-art-muted">{{ row.label }}</text>
|
||||
<text class="value">{{ row.value || '—' }}</text>
|
||||
</view>
|
||||
<slot />
|
||||
</scroll-view>
|
||||
<view v-if="$slots.footer" class="footer">
|
||||
<slot name="footer" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.drawer {
|
||||
background: #1a1a1c;
|
||||
border-radius: 24rpx 24rpx 0 0;
|
||||
max-height: 75vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 28rpx 32rpx;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.title { font-size: 32rpx; color: rgb(var(--art-text)); font-weight: 600; }
|
||||
.body { max-height: 55vh; padding: 16rpx 32rpx 32rpx; box-sizing: border-box; }
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
padding: 18rpx 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.label { font-size: 22rpx; }
|
||||
.value { font-size: 28rpx; color: rgb(var(--art-text)); word-break: break-all; }
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16rpx;
|
||||
padding: 16rpx 32rpx 28rpx;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
</style>
|
||||
111
src/subPackages/admin/components/AdminFilterBar.vue
Normal file
111
src/subPackages/admin/components/AdminFilterBar.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 后台列表筛选条:关键词 + 插槽筛选项 + 搜索/重置。
|
||||
* 简单资源与专用列表页复用,避免每页重写一套筛选 UI。
|
||||
*/
|
||||
import { reactive, watch } from 'vue'
|
||||
import type { SchemaFilter } from '../config/resourceSchemas'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
keyword?: string
|
||||
placeholder?: string
|
||||
filters?: SchemaFilter[]
|
||||
modelValue?: Record<string, unknown>
|
||||
}>(), {
|
||||
keyword: '',
|
||||
placeholder: '搜索关键词',
|
||||
filters: () => [],
|
||||
modelValue: () => ({}),
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:keyword': [string]
|
||||
'update:modelValue': [Record<string, unknown>]
|
||||
search: []
|
||||
reset: []
|
||||
}>()
|
||||
|
||||
const local = reactive<Record<string, unknown>>({ ...props.modelValue })
|
||||
|
||||
watch(() => props.modelValue, (v) => {
|
||||
Object.keys(local).forEach(k => delete local[k])
|
||||
Object.assign(local, v || {})
|
||||
}, { deep: true })
|
||||
|
||||
function onKeyword(e: { value?: string } | string) {
|
||||
const val = typeof e === 'string' ? e : String(e?.value ?? '')
|
||||
emit('update:keyword', val)
|
||||
}
|
||||
|
||||
function onFilterChange() {
|
||||
emit('update:modelValue', { ...local })
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
emit('update:modelValue', { ...local })
|
||||
emit('search')
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
Object.keys(local).forEach(k => { local[k] = '' })
|
||||
emit('update:keyword', '')
|
||||
emit('update:modelValue', {})
|
||||
emit('reset')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="filter-bar admin-card">
|
||||
<wd-input
|
||||
:model-value="keyword"
|
||||
:placeholder="placeholder"
|
||||
clearable
|
||||
@update:model-value="onKeyword"
|
||||
@confirm="onSearch"
|
||||
/>
|
||||
<view v-for="f in filters.filter(i => i.key !== 'keyword')" :key="f.key" class="filter-row">
|
||||
<text class="label text-art-muted">{{ f.label }}</text>
|
||||
<wd-input
|
||||
v-if="f.type === 'text'"
|
||||
:model-value="String(local[f.key] ?? '')"
|
||||
:placeholder="f.label"
|
||||
clearable
|
||||
@update:model-value="(v: string | number) => { local[f.key] = v; onFilterChange() }"
|
||||
/>
|
||||
<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[] }) => { local[f.key] = (value?.[0] as { value?: unknown })?.value ?? value?.[0] ?? ''; onFilterChange() }"
|
||||
>
|
||||
<view class="picker-val">
|
||||
{{ f.options.find(o => String(o.value) === String(local[f.key] ?? ''))?.label || '请选择' }}
|
||||
</view>
|
||||
</wd-picker>
|
||||
</view>
|
||||
<slot />
|
||||
<view class="actions">
|
||||
<wd-button size="small" type="primary" @click="onSearch">搜索</wd-button>
|
||||
<wd-button size="small" plain @click="onReset">重置</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.filter-bar { padding: 20rpx 24rpx; margin-bottom: 20rpx; }
|
||||
.filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
.label { font-size: 24rpx; min-width: 100rpx; }
|
||||
.picker-val {
|
||||
flex: 1;
|
||||
padding: 16rpx 20rpx;
|
||||
border-radius: 12rpx;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgb(var(--art-text));
|
||||
font-size: 26rpx;
|
||||
}
|
||||
.actions { display: flex; gap: 16rpx; margin-top: 20rpx; }
|
||||
</style>
|
||||
29
src/subPackages/admin/components/AdminFormField.vue
Normal file
29
src/subPackages/admin/components/AdminFormField.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 后台表单字段外壳:wot-ui 2.2 的 wd-input 不渲染 label prop,
|
||||
* 用可见标签行保证暗色下字段含义清晰。
|
||||
*/
|
||||
withDefaults(defineProps<{
|
||||
label?: string
|
||||
required?: boolean
|
||||
}>(), {
|
||||
label: '',
|
||||
required: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="admin-form-field">
|
||||
<text v-if="label" class="admin-form-field__label">
|
||||
<text v-if="required" class="req">*</text>{{ label }}
|
||||
</text>
|
||||
<slot />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.req {
|
||||
color: rgb(var(--art-error));
|
||||
margin-right: 6rpx;
|
||||
}
|
||||
</style>
|
||||
226
src/subPackages/admin/components/AdminLineChart.vue
Normal file
226
src/subPackages/admin/components/AdminLineChart.vue
Normal file
@@ -0,0 +1,226 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 后台折线图:旧版 canvas,金色描边,适配暗色工作台。
|
||||
* 多系列时按同色相不同透明度区分;下方 HTML 图例标明发文/UV,避免「UV 没展示」的误解。
|
||||
* 小程序里 v-if 挂载后首帧 boundingClientRect 常为 0,需重试并设置原生宽高。
|
||||
* getCurrentInstance 仅在 setup/同步生命周期有效,必须在此处缓存 proxy。
|
||||
*/
|
||||
import { computed, getCurrentInstance, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** 横轴标签 */
|
||||
categories?: string[]
|
||||
/** 系列:{ name, data } */
|
||||
series?: { name: string, data: number[] }[]
|
||||
height?: number
|
||||
}>(), {
|
||||
categories: () => [],
|
||||
series: () => [],
|
||||
height: 220,
|
||||
})
|
||||
|
||||
const SERIES_COLORS = ['#d4b383', 'rgba(120,180,255,0.9)', 'rgba(160,220,160,0.9)']
|
||||
|
||||
/** setup 阶段同步缓存,供异步 draw 使用 */
|
||||
const compProxy = getCurrentInstance()?.proxy as never
|
||||
const canvasId = `line-${Math.random().toString(36).slice(2, 8)}`
|
||||
/** 原生 canvas 缓冲像素宽(含 dpr),绑定到 width 属性才能在小程序上正常绘制 */
|
||||
const canvasW = ref(300)
|
||||
const canvasH = ref(220)
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let retryCount = 0
|
||||
const MAX_RETRY = 5
|
||||
|
||||
const legendItems = computed(() =>
|
||||
(props.series || []).map((s, i) => ({
|
||||
name: s.name || `系列${i + 1}`,
|
||||
color: SERIES_COLORS[i % SERIES_COLORS.length],
|
||||
})),
|
||||
)
|
||||
|
||||
/** 优先新 API,降级 getSystemInfoSync,减少弃用警告 */
|
||||
function fallbackWidth(): number {
|
||||
try {
|
||||
const win = typeof uni.getWindowInfo === 'function' ? uni.getWindowInfo() : null
|
||||
if (win?.windowWidth) return Math.max(200, win.windowWidth - 48)
|
||||
const sys = uni.getSystemInfoSync()
|
||||
return Math.max(200, (sys.windowWidth || 375) - 48)
|
||||
} catch {
|
||||
return 300
|
||||
}
|
||||
}
|
||||
|
||||
function getDpr(): number {
|
||||
try {
|
||||
const device = typeof uni.getDeviceInfo === 'function' ? uni.getDeviceInfo() : null
|
||||
if (device && 'pixelRatio' in device && Number(device.pixelRatio)) {
|
||||
return Number(device.pixelRatio)
|
||||
}
|
||||
return uni.getSystemInfoSync().pixelRatio || 2
|
||||
} catch {
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建选择器:有组件实例则限定在组件内,否则页面级查询(canvasId 已全局唯一)。
|
||||
* 禁止对 null 调用 .in(),否则 mp-weixin 读 $scope 直接报错。
|
||||
*/
|
||||
function createQuery() {
|
||||
const q = uni.createSelectorQuery()
|
||||
if (compProxy) return q.in(compProxy)
|
||||
return q
|
||||
}
|
||||
|
||||
/**
|
||||
* 在逻辑像素坐标下绘制折线(调用前需已设好原生宽高并 scale)。
|
||||
*/
|
||||
function paint(ctx: UniApp.CanvasContext, w: number, h: number, dpr: number) {
|
||||
const cats = props.categories
|
||||
const series = props.series
|
||||
ctx.scale(dpr, dpr)
|
||||
const padL = 36
|
||||
const padR = 12
|
||||
const padT = 16
|
||||
const padB = 28
|
||||
const plotW = w - padL - padR
|
||||
const plotH = h - padT - padB
|
||||
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
const all = series.flatMap(s => s.data)
|
||||
const max = Math.max(1, ...all)
|
||||
const min = Math.min(0, ...all)
|
||||
const span = max - min || 1
|
||||
|
||||
ctx.setStrokeStyle('rgba(255,255,255,0.08)')
|
||||
ctx.setLineWidth(1)
|
||||
for (let i = 0; i <= 3; i++) {
|
||||
const y = padT + (plotH * i) / 3
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(padL, y)
|
||||
ctx.lineTo(w - padR, y)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
series.forEach((s, si) => {
|
||||
const color = SERIES_COLORS[si % SERIES_COLORS.length]
|
||||
ctx.setStrokeStyle(color)
|
||||
ctx.setLineWidth(2)
|
||||
ctx.beginPath()
|
||||
s.data.forEach((v, i) => {
|
||||
const x = padL + (cats.length <= 1 ? plotW / 2 : (plotW * i) / (cats.length - 1))
|
||||
const y = padT + plotH - ((v - min) / span) * plotH
|
||||
if (i === 0) ctx.moveTo(x, y)
|
||||
else ctx.lineTo(x, y)
|
||||
})
|
||||
ctx.stroke()
|
||||
s.data.forEach((v, i) => {
|
||||
const x = padL + (cats.length <= 1 ? plotW / 2 : (plotW * i) / (cats.length - 1))
|
||||
const y = padT + plotH - ((v - min) / span) * plotH
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, 3, 0, Math.PI * 2)
|
||||
ctx.setFillStyle(color)
|
||||
ctx.fill()
|
||||
})
|
||||
})
|
||||
|
||||
ctx.setFillStyle('rgba(136,136,136,1)')
|
||||
ctx.setFontSize(10)
|
||||
const step = Math.max(1, Math.ceil(cats.length / 5))
|
||||
cats.forEach((c, i) => {
|
||||
if (i % step !== 0 && i !== cats.length - 1) return
|
||||
const x = padL + (cats.length <= 1 ? plotW / 2 : (plotW * i) / (cats.length - 1))
|
||||
ctx.fillText(String(c).slice(-5), x - 12, h - 8)
|
||||
})
|
||||
ctx.draw()
|
||||
}
|
||||
|
||||
/**
|
||||
* 测量并绘制。宽度为 0 时用系统宽度兜底并短延迟重试;
|
||||
* 先写原生宽高再 nextTick 绘制,避免属性未同步导致空白。
|
||||
*/
|
||||
const draw = (isRetry = false) => {
|
||||
const cats = props.categories
|
||||
const series = props.series
|
||||
if (!cats.length || !series.length) return
|
||||
if (!isRetry) retryCount = 0
|
||||
|
||||
createQuery()
|
||||
.select(`#${canvasId}`)
|
||||
.boundingClientRect((rect) => {
|
||||
const box = Array.isArray(rect) ? rect[0] : rect
|
||||
let w = box?.width || 0
|
||||
const measuredOk = !!w
|
||||
if (!w) w = fallbackWidth()
|
||||
if (!measuredOk && retryCount < MAX_RETRY) {
|
||||
retryCount += 1
|
||||
if (retryTimer) clearTimeout(retryTimer)
|
||||
retryTimer = setTimeout(() => draw(true), 80)
|
||||
} else if (measuredOk) {
|
||||
retryCount = 0
|
||||
}
|
||||
if (!w) return
|
||||
|
||||
const h = props.height
|
||||
const dpr = getDpr()
|
||||
canvasW.value = Math.floor(w * dpr)
|
||||
canvasH.value = Math.floor(h * dpr)
|
||||
nextTick(() => {
|
||||
const ctx = uni.createCanvasContext(canvasId, compProxy)
|
||||
paint(ctx, w, h, dpr)
|
||||
})
|
||||
})
|
||||
.exec()
|
||||
}
|
||||
|
||||
onMounted(() => nextTick(() => draw()))
|
||||
onUnmounted(() => {
|
||||
if (retryTimer) clearTimeout(retryTimer)
|
||||
})
|
||||
watch(() => [props.categories, props.series], () => nextTick(() => draw()), { deep: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="line-wrap">
|
||||
<canvas
|
||||
:id="canvasId"
|
||||
:canvas-id="canvasId"
|
||||
class="chart"
|
||||
:width="canvasW"
|
||||
:height="canvasH"
|
||||
:style="{ width: '100%', height: `${height}px` }"
|
||||
/>
|
||||
<view v-if="legendItems.length" class="legend">
|
||||
<view v-for="(item, i) in legendItems" :key="i" class="legend-item">
|
||||
<view class="dot" :style="{ background: item.color }" />
|
||||
<text class="legend-text">{{ item.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.line-wrap { width: 100%; }
|
||||
.chart { width: 100%; display: block; }
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20rpx;
|
||||
margin-top: 12rpx;
|
||||
padding: 0 8rpx;
|
||||
}
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
.dot {
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
border-radius: 4rpx;
|
||||
}
|
||||
.legend-text {
|
||||
font-size: 22rpx;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
</style>
|
||||
113
src/subPackages/admin/components/AdminMediaPicker.vue
Normal file
113
src/subPackages/admin/components/AdminMediaPicker.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 媒体选择器:从相册选图并上传到 /admin/attachments/upload,回写 URL。
|
||||
* 作品封面、头像、附件库等复用,避免各表单重复实现上传。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { useToast } from '@wot-ui/ui'
|
||||
import { uploadAttachment } from '@/api'
|
||||
import { resolveMediaUrl } from '@/utils/request'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue?: string
|
||||
label?: string
|
||||
mediaType?: 'image' | 'video' | 'all'
|
||||
}>(), {
|
||||
modelValue: '',
|
||||
label: '图片',
|
||||
mediaType: 'image',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [string]
|
||||
}>()
|
||||
|
||||
const toast = useToast()
|
||||
const uploading = ref(false)
|
||||
|
||||
const preview = computed(() => resolveMediaUrl(props.modelValue))
|
||||
|
||||
async function choose() {
|
||||
try {
|
||||
if (props.mediaType === 'video') {
|
||||
const res = await uni.chooseVideo({ sourceType: ['album', 'camera'], compressed: true })
|
||||
await doUpload(res.tempFilePath)
|
||||
return
|
||||
}
|
||||
const res = await uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['album', 'camera'] })
|
||||
const path = res.tempFilePaths?.[0]
|
||||
if (!path) return
|
||||
await doUpload(path)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
// 用户取消不提示
|
||||
if (e && typeof e === 'object' && 'errMsg' in e && String((e as { errMsg: string }).errMsg).includes('cancel')) return
|
||||
toast.error(e instanceof Error ? e.message : '选择失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function doUpload(filePath: string) {
|
||||
uploading.value = true
|
||||
try {
|
||||
const result = await uploadAttachment(filePath)
|
||||
const url = String(result.fileUrl || result.url || '')
|
||||
if (!url) throw new Error('上传成功但未返回地址')
|
||||
emit('update:modelValue', url)
|
||||
toast.success('上传成功')
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '上传失败')
|
||||
}
|
||||
finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
emit('update:modelValue', '')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="media-picker">
|
||||
<text v-if="label" class="label text-art-muted">{{ label }}</text>
|
||||
<view class="box" @click="choose">
|
||||
<image v-if="preview && mediaType !== 'video'" class="preview" :src="preview" mode="aspectFill" />
|
||||
<view v-else-if="preview && mediaType === 'video'" class="video-hint">
|
||||
<text>已选视频</text>
|
||||
<text class="url text-art-muted">{{ modelValue }}</text>
|
||||
</view>
|
||||
<view v-else class="placeholder">
|
||||
<wd-loading v-if="uploading" />
|
||||
<text v-else class="text-art-muted">点击上传{{ label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<wd-button v-if="modelValue" size="small" plain @click.stop="clear">清除</wd-button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.media-picker { display: flex; flex-direction: column; gap: 12rpx; margin: 16rpx 0; }
|
||||
.label { font-size: 24rpx; }
|
||||
.box {
|
||||
width: 240rpx;
|
||||
height: 240rpx;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.preview { width: 100%; height: 100%; }
|
||||
.placeholder, .video-hint {
|
||||
padding: 16rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
.url { font-size: 20rpx; word-break: break-all; max-width: 200rpx; }
|
||||
</style>
|
||||
60
src/subPackages/admin/components/AdminStatusBadge.vue
Normal file
60
src/subPackages/admin/components/AdminStatusBadge.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 状态徽章:展示发布/启用/咨询状态,可点击切换(由父组件处理具体 API)。
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
value?: string | number | boolean | null
|
||||
map?: Record<string, string>
|
||||
clickable?: boolean
|
||||
}>(), {
|
||||
value: '',
|
||||
map: () => ({}),
|
||||
clickable: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: []
|
||||
}>()
|
||||
|
||||
const label = computed(() => {
|
||||
const key = String(props.value ?? '')
|
||||
if (props.map[key]) return props.map[key]
|
||||
if (props.value === true || props.value === 1 || props.value === '1') return '是'
|
||||
if (props.value === false || props.value === 0 || props.value === '0') return '否'
|
||||
return key || '—'
|
||||
})
|
||||
|
||||
const tone = computed(() => {
|
||||
const v = props.value
|
||||
if (v === true || v === 1 || v === '1' || v === 2 || v === '2') return 'on'
|
||||
if (v === false || v === 0 || v === '0') return 'off'
|
||||
return 'muted'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view
|
||||
class="badge"
|
||||
:class="[tone, { clickable }]"
|
||||
@click.stop="clickable && emit('click')"
|
||||
>
|
||||
{{ label }}
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4rpx 14rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.on { background: rgba(212, 179, 131, 0.2); color: rgb(var(--art-accent)); }
|
||||
.off { background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.45); }
|
||||
.muted { background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.55); }
|
||||
.clickable { border: 1px solid rgba(212, 179, 131, 0.35); }
|
||||
</style>
|
||||
20
src/subPackages/admin/composables/useAdminGuard.ts
Normal file
20
src/subPackages/admin/composables/useAdminGuard.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 后台页鉴权守卫:未登录或无员工角色则回我的页。
|
||||
*/
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
|
||||
export function useAdminGuard() {
|
||||
const { isAuthenticated } = useAuth()
|
||||
const { canEnterAdmin } = useAppMode()
|
||||
|
||||
function guardAdmin(): boolean {
|
||||
if (!isAuthenticated() || !canEnterAdmin.value) {
|
||||
uni.reLaunch({ url: '/pages/mine/index' })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return { guardAdmin, isAuthenticated, canEnterAdmin }
|
||||
}
|
||||
380
src/subPackages/admin/config/resourceSchemas.ts
Normal file
380
src/subPackages/admin/config/resourceSchemas.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* 简单资源 Schema 注册表:驱动通用 list/form,消灭默认 4 字段 + JSON 主路径。
|
||||
* 复杂模块(posts/works/videos/attachments/settings/about/inquiries)走专用页,不在此注册。
|
||||
*/
|
||||
|
||||
export type FieldType =
|
||||
| 'text'
|
||||
| 'textarea'
|
||||
| 'number'
|
||||
| 'switch'
|
||||
| 'select'
|
||||
| 'media'
|
||||
| 'rating'
|
||||
|
||||
export interface SchemaField {
|
||||
key: string
|
||||
label: string
|
||||
type: FieldType
|
||||
placeholder?: string
|
||||
/** select 选项 */
|
||||
options?: { label: string, value: string | number }[]
|
||||
/** 只读(如系统模板 slug) */
|
||||
readonly?: boolean
|
||||
/** 编辑态才显示 */
|
||||
editOnly?: boolean
|
||||
}
|
||||
|
||||
export interface SchemaFilter {
|
||||
key: string
|
||||
label: string
|
||||
type: 'text' | 'select'
|
||||
options?: { label: string, value: string | number }[]
|
||||
}
|
||||
|
||||
export interface ResourceSchema {
|
||||
title: string
|
||||
/** 真只读:无新建/删除/编辑,点行开详情抽屉 */
|
||||
readonly?: boolean
|
||||
/** 禁止编辑(可新建删除),如邮箱后缀 */
|
||||
noEdit?: boolean
|
||||
/** 页内新建(邮箱后缀) */
|
||||
inlineCreate?: boolean
|
||||
/** 服务端分页 */
|
||||
serverPaging?: boolean
|
||||
/** 默认 pageSize */
|
||||
pageSize?: number
|
||||
/** 列表主标题字段候选 */
|
||||
titleKeys?: string[]
|
||||
/** 列表副标题字段候选 */
|
||||
subtitleKeys?: string[]
|
||||
/** 封面/头像字段 */
|
||||
coverKey?: string
|
||||
/** 状态字段(用于徽章) */
|
||||
statusKey?: string
|
||||
/** 状态文案映射 */
|
||||
statusMap?: Record<string, string>
|
||||
filters?: SchemaFilter[]
|
||||
fields: SchemaField[]
|
||||
/** 本地关键词过滤字段 */
|
||||
localSearchKeys?: string[]
|
||||
}
|
||||
|
||||
const CODE_TYPE_OPTIONS = [
|
||||
{ label: '前端', value: 0 },
|
||||
{ label: '后端', value: 1 },
|
||||
{ label: '其他', value: 2 },
|
||||
]
|
||||
|
||||
/** 简单资源 Schema;未列出的走兜底 */
|
||||
export const RESOURCE_SCHEMAS: Record<string, ResourceSchema> = {
|
||||
categories: {
|
||||
title: '分类管理',
|
||||
titleKeys: ['name'],
|
||||
subtitleKeys: ['slug', 'sortOrder'],
|
||||
localSearchKeys: ['name', 'slug'],
|
||||
fields: [
|
||||
{ key: 'name', label: '名称', type: 'text' },
|
||||
{ key: 'slug', label: 'Slug', type: 'text' },
|
||||
{ key: 'sortOrder', label: '排序', type: 'number' },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
columns: {
|
||||
title: '专栏管理',
|
||||
titleKeys: ['name', 'title'],
|
||||
subtitleKeys: ['description', 'sortOrder'],
|
||||
coverKey: 'cover',
|
||||
statusKey: 'isActive',
|
||||
statusMap: { '1': '启用', '0': '停用', true: '启用', false: '停用' },
|
||||
filters: [
|
||||
{ key: 'keyword', label: '关键词', type: 'text' },
|
||||
{
|
||||
key: 'isActive',
|
||||
label: '状态',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '停用', value: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
localSearchKeys: ['name', 'title', 'description'],
|
||||
fields: [
|
||||
{ key: 'name', label: '名称', type: 'text' },
|
||||
{ key: 'cover', label: '封面', type: 'media' },
|
||||
{ key: 'isActive', label: '启用', type: 'switch' },
|
||||
{ key: 'sortOrder', label: '排序', type: 'number' },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
'video-categories': {
|
||||
title: '视频分类',
|
||||
titleKeys: ['name'],
|
||||
subtitleKeys: ['slug', 'sortOrder'],
|
||||
localSearchKeys: ['name', 'slug'],
|
||||
fields: [
|
||||
{ key: 'name', label: '名称', type: 'text' },
|
||||
{ key: 'slug', label: 'Slug', type: 'text' },
|
||||
{ key: 'sortOrder', label: '排序', type: 'number' },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
'video-albums': {
|
||||
title: '视频专辑',
|
||||
titleKeys: ['name'],
|
||||
subtitleKeys: ['categoryName', 'videoCount'],
|
||||
coverKey: 'cover',
|
||||
statusKey: 'isActive',
|
||||
statusMap: { '1': '启用', '0': '停用', true: '启用', false: '停用' },
|
||||
localSearchKeys: ['name', 'description'],
|
||||
fields: [
|
||||
{ key: 'name', label: '名称', type: 'text' },
|
||||
{ key: 'categoryId', label: '分类ID', type: 'number' },
|
||||
{ key: 'cover', label: '封面', type: 'media' },
|
||||
{ key: 'sortOrder', label: '排序', type: 'number' },
|
||||
{ key: 'isActive', label: '启用', type: 'switch' },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
snippets: {
|
||||
title: '代码片段',
|
||||
titleKeys: ['title'],
|
||||
subtitleKeys: ['description'],
|
||||
localSearchKeys: ['title', 'description'],
|
||||
fields: [
|
||||
{ key: 'title', label: '标题', type: 'text' },
|
||||
{ key: 'codeTypeId', label: '分类ID', type: 'number' },
|
||||
{ key: 'code', label: '代码', type: 'textarea' },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
'code-types': {
|
||||
title: '代码分类',
|
||||
titleKeys: ['name'],
|
||||
subtitleKeys: ['category'],
|
||||
localSearchKeys: ['name'],
|
||||
fields: [
|
||||
{ key: 'name', label: '名称', type: 'text' },
|
||||
{ key: 'category', label: '类别', type: 'select', options: CODE_TYPE_OPTIONS },
|
||||
],
|
||||
},
|
||||
tags: {
|
||||
title: '标签管理',
|
||||
titleKeys: ['name'],
|
||||
subtitleKeys: ['slug'],
|
||||
localSearchKeys: ['name', 'slug'],
|
||||
fields: [
|
||||
{ key: 'name', label: '名称', type: 'text' },
|
||||
{ key: 'slug', label: 'Slug', type: 'text' },
|
||||
],
|
||||
},
|
||||
testimonials: {
|
||||
title: '客户评价',
|
||||
titleKeys: ['name'],
|
||||
subtitleKeys: ['role', 'rating'],
|
||||
coverKey: 'avatar',
|
||||
filters: [
|
||||
{ key: 'keyword', label: '关键词', type: 'text' },
|
||||
{
|
||||
key: 'rating',
|
||||
label: '评分',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '5星', value: 5 },
|
||||
{ label: '4星', value: 4 },
|
||||
{ label: '3星', value: 3 },
|
||||
],
|
||||
},
|
||||
],
|
||||
localSearchKeys: ['name', 'role', 'content'],
|
||||
fields: [
|
||||
{ key: 'name', label: '姓名', type: 'text' },
|
||||
{ key: 'role', label: '职位', type: 'text' },
|
||||
{ key: 'avatar', label: '头像', type: 'media' },
|
||||
{ key: 'rating', label: '评分', type: 'rating' },
|
||||
{ key: 'content', label: '评价内容', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
partners: {
|
||||
title: '合作伙伴',
|
||||
titleKeys: ['name'],
|
||||
subtitleKeys: ['url', 'description'],
|
||||
coverKey: 'logo',
|
||||
localSearchKeys: ['name', 'description', 'url'],
|
||||
fields: [
|
||||
{ key: 'name', label: '名称', type: 'text' },
|
||||
{ key: 'logo', label: 'Logo', type: 'media' },
|
||||
{ key: 'url', label: '链接', type: 'text' },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
'email-suffixes': {
|
||||
title: '邮箱配置',
|
||||
noEdit: true,
|
||||
inlineCreate: true,
|
||||
titleKeys: ['suffix'],
|
||||
subtitleKeys: ['sortOrder', 'isActive'],
|
||||
statusKey: 'isActive',
|
||||
statusMap: { '1': '启用', '0': '停用', true: '启用', false: '停用' },
|
||||
localSearchKeys: ['suffix'],
|
||||
fields: [
|
||||
{ key: 'suffix', label: '后缀', type: 'text', placeholder: '@example.com' },
|
||||
{ key: 'sortOrder', label: '排序', type: 'number' },
|
||||
{ key: 'isActive', label: '启用', type: 'switch' },
|
||||
],
|
||||
},
|
||||
users: {
|
||||
title: '用户管理',
|
||||
serverPaging: true,
|
||||
pageSize: 10,
|
||||
titleKeys: ['username'],
|
||||
subtitleKeys: ['email', 'role'],
|
||||
coverKey: 'avatar',
|
||||
statusKey: 'isActive',
|
||||
statusMap: { '1': '启用', '0': '停用', true: '启用', false: '停用' },
|
||||
filters: [
|
||||
{ key: 'keyword', label: '关键词', type: 'text' },
|
||||
{
|
||||
key: 'role',
|
||||
label: '角色',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: 'admin', value: 'admin' },
|
||||
{ label: 'editor', value: 'editor' },
|
||||
{ label: 'viewer', value: 'viewer' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'isActive',
|
||||
label: '状态',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '停用', value: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
fields: [
|
||||
{ key: 'username', label: '用户名', type: 'text' },
|
||||
{ key: 'email', label: '邮箱', type: 'text' },
|
||||
{ key: 'avatar', label: '头像', type: 'media' },
|
||||
{
|
||||
key: 'role',
|
||||
label: '角色',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'admin', value: 'admin' },
|
||||
{ label: 'editor', value: 'editor' },
|
||||
{ label: 'viewer', value: 'viewer' },
|
||||
],
|
||||
},
|
||||
{ key: 'isActive', label: '启用', type: 'switch' },
|
||||
{ key: 'bio', label: '简介', type: 'textarea' },
|
||||
{ key: 'phone', label: '手机', type: 'text' },
|
||||
{ key: 'wechat', label: '微信', type: 'text' },
|
||||
{ key: 'wechatQrcode', label: '微信二维码', type: 'media' },
|
||||
],
|
||||
},
|
||||
roles: {
|
||||
title: '角色管理',
|
||||
titleKeys: ['name'],
|
||||
subtitleKeys: ['description'],
|
||||
localSearchKeys: ['name', 'description'],
|
||||
fields: [
|
||||
{ key: 'name', label: '名称', type: 'text' },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
'attachment-categories': {
|
||||
title: '附件分类',
|
||||
titleKeys: ['name'],
|
||||
subtitleKeys: ['sortOrder', 'description'],
|
||||
localSearchKeys: ['name', 'description'],
|
||||
fields: [
|
||||
{ key: 'name', label: '名称', type: 'text' },
|
||||
{ key: 'sortOrder', label: '排序', type: 'number' },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
'ppt-templates': {
|
||||
title: 'PPT 模板',
|
||||
titleKeys: ['name'],
|
||||
subtitleKeys: ['slug', 'sortOrder'],
|
||||
statusKey: 'isActive',
|
||||
statusMap: { '1': '启用', '0': '停用', true: '启用', false: '停用' },
|
||||
localSearchKeys: ['name', 'slug'],
|
||||
fields: [
|
||||
{ key: 'name', label: '名称', type: 'text' },
|
||||
{ key: 'slug', label: 'Slug', type: 'text' },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
{ key: 'isDefault', label: '默认', type: 'switch' },
|
||||
{ key: 'isActive', label: '启用', type: 'switch' },
|
||||
{ key: 'sortOrder', label: '排序', type: 'number' },
|
||||
{ key: 'config', label: '配置 JSON', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
logs: {
|
||||
title: '操作日志',
|
||||
readonly: true,
|
||||
serverPaging: true,
|
||||
pageSize: 20,
|
||||
titleKeys: ['action', 'path'],
|
||||
subtitleKeys: ['username', 'createdAt', 'method', 'status'],
|
||||
filters: [
|
||||
{ key: 'action', label: '操作', type: 'text' },
|
||||
{ key: 'method', label: '方法', type: 'text' },
|
||||
{ key: 'status', label: '状态码', type: 'text' },
|
||||
],
|
||||
fields: [],
|
||||
},
|
||||
'access-logs': {
|
||||
title: '访问日志',
|
||||
readonly: true,
|
||||
serverPaging: true,
|
||||
pageSize: 20,
|
||||
titleKeys: ['path'],
|
||||
subtitleKeys: ['region', 'ip', 'createdAt', 'statusCode'],
|
||||
filters: [
|
||||
{ key: 'path', label: '路径', type: 'text' },
|
||||
{ key: 'region', label: '地区', type: 'text' },
|
||||
],
|
||||
fields: [],
|
||||
},
|
||||
/** 已拆专用页,保留兜底标题 */
|
||||
posts: { title: '文章管理', fields: [], titleKeys: ['title'] },
|
||||
works: { title: '作品管理', fields: [], titleKeys: ['title'] },
|
||||
videos: { title: '视频列表', fields: [], titleKeys: ['title'] },
|
||||
attachments: { title: '附件库', readonly: true, fields: [], titleKeys: ['originalName', 'fileName'] },
|
||||
inquiries: { title: '合作咨询', readonly: true, fields: [], titleKeys: ['name'] },
|
||||
'oss-configs': { title: 'OSS配置', fields: [], titleKeys: ['name'] },
|
||||
about: { title: '关于页面', fields: [] },
|
||||
settings: { title: '全局配置', fields: [] },
|
||||
}
|
||||
|
||||
export function getResourceSchema(resource: string): ResourceSchema {
|
||||
return RESOURCE_SCHEMAS[resource] || {
|
||||
title: resource || '资源管理',
|
||||
titleKeys: ['title', 'name', 'username'],
|
||||
subtitleKeys: ['description', 'createdAt'],
|
||||
fields: [
|
||||
{ key: 'title', label: '标题', type: 'text' },
|
||||
{ key: 'name', label: '名称', type: 'text' },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/** 从记录中按候选 key 取展示文案 */
|
||||
export function pickField(item: Record<string, unknown>, keys?: string[]): string {
|
||||
if (!keys?.length) return ''
|
||||
for (const k of keys) {
|
||||
const v = item[k]
|
||||
if (v != null && v !== '') return String(v)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
62
src/subPackages/admin/config/settingsSchema.ts
Normal file
62
src/subPackages/admin/config/settingsSchema.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 全局配置 Schema(自 Web client settingsSchema 精简移植,供小程序设置页分 Tab 编辑)
|
||||
*/
|
||||
|
||||
export type SettingFieldType = 'text' | 'textarea' | 'number' | 'menu-checkboxes' | 'email' | 'homepage-json'
|
||||
|
||||
export interface SettingSchemaItem {
|
||||
key: string
|
||||
label: string
|
||||
type: SettingFieldType
|
||||
group: 'site' | 'seo' | 'navigation' | 'homepage' | 'pagination' | 'contact'
|
||||
default: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export const SETTING_GROUPS: { id: SettingSchemaItem['group'], label: string }[] = [
|
||||
{ id: 'site', label: '站点信息' },
|
||||
{ id: 'seo', label: 'SEO' },
|
||||
{ id: 'navigation', label: '导航菜单' },
|
||||
{ id: 'homepage', label: '首页内容' },
|
||||
{ id: 'pagination', label: '分页设置' },
|
||||
{ id: 'contact', label: '联系方式' },
|
||||
]
|
||||
|
||||
export const MENU_OPTIONS = [
|
||||
{ key: 'home', label: '首页' },
|
||||
{ key: 'blog', label: '思考' },
|
||||
{ key: 'columns', label: '专栏' },
|
||||
{ key: 'works', label: '作品' },
|
||||
{ key: 'videos', label: '视频' },
|
||||
{ key: 'snippets', label: '代码' },
|
||||
{ key: 'about', label: '关于' },
|
||||
{ key: 'services', label: '合作' },
|
||||
]
|
||||
|
||||
export const SETTINGS_SCHEMA: SettingSchemaItem[] = [
|
||||
{ key: 'site_title', label: '网站标题', type: 'text', group: 'site', default: '年糕崽崽.Dev', description: '站点名称' },
|
||||
{ key: 'site_author', label: '网站作者', type: 'text', group: 'site', default: '', description: '作者名' },
|
||||
{ key: 'site_description', label: '网站描述', type: 'textarea', group: 'seo', default: '', description: 'SEO 描述' },
|
||||
{ key: 'site_keywords', label: '网站关键词', type: 'textarea', group: 'seo', default: '', description: '逗号分隔' },
|
||||
{
|
||||
key: 'visible_menus',
|
||||
label: '前台菜单',
|
||||
type: 'menu-checkboxes',
|
||||
group: 'navigation',
|
||||
default: '["home","blog","columns","works","videos","snippets","about","services"]',
|
||||
description: '控制前台导航显示',
|
||||
},
|
||||
{
|
||||
key: 'homepage_config',
|
||||
label: '首页配置 JSON',
|
||||
type: 'homepage-json',
|
||||
group: 'homepage',
|
||||
default: '{}',
|
||||
description: 'Hero / Bento 等首页结构,小程序端用 JSON 编辑',
|
||||
},
|
||||
{ key: 'posts_per_page', label: '文章每页', type: 'number', group: 'pagination', default: '10', description: '' },
|
||||
{ key: 'works_per_page', label: '作品每页', type: 'number', group: 'pagination', default: '12', description: '' },
|
||||
{ key: 'snippets_per_page', label: '代码每页', type: 'number', group: 'pagination', default: '12', description: '' },
|
||||
{ key: 'footer_icp', label: 'ICP 备案号', type: 'text', group: 'contact', default: '', description: '' },
|
||||
{ key: 'contact_email', label: '联系邮箱', type: 'email', group: 'contact', default: '', description: '' },
|
||||
]
|
||||
185
src/subPackages/admin/pages/about/index.vue
Normal file
185
src/subPackages/admin/pages/about/index.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 关于页面单页编辑:结构化字段 + 经历动态增删。
|
||||
*/
|
||||
import { reactive, ref } from 'vue'
|
||||
import { 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 { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import { adminCreate, adminList, adminUpdate, normalizeAdminList, type AboutExperience, type AboutProfile } from '@/api'
|
||||
|
||||
const toast = useToast()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const profileId = ref<number | ''>('')
|
||||
const techInput = ref('')
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
avatar: '',
|
||||
location: '',
|
||||
email: '',
|
||||
wechat: '',
|
||||
bio: '',
|
||||
isPrimary: 1,
|
||||
})
|
||||
const techStack = ref<string[]>([])
|
||||
const experiences = ref<AboutExperience[]>([])
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
const list = normalizeAdminList(await adminList('about')) as AboutProfile[]
|
||||
const first = list[0]
|
||||
if (first) {
|
||||
profileId.value = Number(first.id) || ''
|
||||
form.name = String(first.name || '')
|
||||
form.avatar = String(first.avatar || '')
|
||||
form.location = String(first.location || '')
|
||||
form.email = String(first.email || '')
|
||||
form.wechat = String(first.wechat || '')
|
||||
form.bio = String(first.bio || '')
|
||||
form.isPrimary = first.isPrimary === 0 || first.isPrimary === false ? 0 : 1
|
||||
techStack.value = Array.isArray(first.techStack) ? [...first.techStack] : []
|
||||
experiences.value = Array.isArray(first.experiences) ? [...first.experiences] : []
|
||||
}
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
|
||||
function addTech() {
|
||||
const t = techInput.value.trim()
|
||||
if (!t) return
|
||||
techStack.value.push(t)
|
||||
techInput.value = ''
|
||||
}
|
||||
|
||||
function addExp() {
|
||||
experiences.value.push({ year: '', role: '', company: '' })
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload = {
|
||||
...form,
|
||||
techStack: techStack.value,
|
||||
experiences: experiences.value,
|
||||
}
|
||||
if (profileId.value) await adminUpdate('about', profileId.value, payload)
|
||||
else {
|
||||
const created = await adminCreate('about', payload) as AboutProfile
|
||||
if (created?.id) profileId.value = Number(created.id)
|
||||
}
|
||||
toast.success('已保存')
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar title="关于页面" show-back />
|
||||
<view class="page-pad">
|
||||
<view class="admin-card form-card">
|
||||
<AdminFormField label="姓名" required>
|
||||
<wd-input v-model="form.name" placeholder="姓名" clearable />
|
||||
</AdminFormField>
|
||||
<AdminMediaPicker v-model="form.avatar" label="头像" />
|
||||
<AdminFormField label="地点">
|
||||
<wd-input v-model="form.location" placeholder="地点" clearable />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="邮箱">
|
||||
<wd-input v-model="form.email" placeholder="邮箱" clearable />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="微信">
|
||||
<wd-input v-model="form.wechat" placeholder="微信" clearable />
|
||||
</AdminFormField>
|
||||
<view class="switch-row">
|
||||
<text>主档案</text>
|
||||
<wd-switch :model-value="!!form.isPrimary" @change="(v: boolean) => form.isPrimary = v ? 1 : 0" />
|
||||
</view>
|
||||
<AdminFormField label="简介">
|
||||
<wd-textarea v-model="form.bio" placeholder="简介" />
|
||||
</AdminFormField>
|
||||
<view class="section">
|
||||
<text class="label text-art-muted">技术栈</text>
|
||||
<view class="tags">
|
||||
<view v-for="(t, i) in techStack" :key="i" class="tag" @click="techStack.splice(i, 1)">{{ t }} ×</view>
|
||||
</view>
|
||||
<view class="row-input">
|
||||
<wd-input v-model="techInput" placeholder="添加技术" clearable />
|
||||
<wd-button size="small" @click="addTech">添加</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
<view class="section">
|
||||
<view class="sec-head">
|
||||
<text class="label text-art-muted">工作经历</text>
|
||||
<wd-button size="small" plain @click="addExp">添加</wd-button>
|
||||
</view>
|
||||
<view v-for="(exp, i) in experiences" :key="i" class="exp">
|
||||
<AdminFormField label="年份">
|
||||
<wd-input v-model="exp.year" placeholder="年份" clearable />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="职位">
|
||||
<wd-input v-model="exp.role" placeholder="职位" clearable />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="公司">
|
||||
<wd-input v-model="exp.company" placeholder="公司" clearable />
|
||||
</AdminFormField>
|
||||
<wd-button size="small" type="warning" plain @click="experiences.splice(i, 1)">删除</wd-button>
|
||||
</view>
|
||||
</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; }
|
||||
.switch-row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 20rpx 0; color: rgb(var(--art-text));
|
||||
}
|
||||
.section { margin: 20rpx 0; }
|
||||
.label { font-size: 24rpx; }
|
||||
.tags { display: flex; flex-wrap: wrap; gap: 10rpx; margin: 12rpx 0; }
|
||||
.tag {
|
||||
padding: 6rpx 14rpx; border-radius: 8rpx; font-size: 22rpx;
|
||||
background: rgba(212, 179, 131, 0.2); color: rgb(var(--art-accent));
|
||||
}
|
||||
.row-input { display: flex; gap: 12rpx; align-items: center; }
|
||||
.sec-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12rpx; }
|
||||
.exp {
|
||||
padding: 16rpx; margin-bottom: 16rpx; border-radius: 12rpx;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
</style>
|
||||
161
src/subPackages/admin/pages/analytics/index.vue
Normal file
161
src/subPackages/admin/pages/analytics/index.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 数据分析:日期预设 + 真实 stats API + canvas 图表 + 地区 TOP 列表。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppFloatingTabbar from '@/components/layout/AppFloatingTabbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminLineChart from '../../components/AdminLineChart.vue'
|
||||
import AdminBarChart from '../../components/AdminBarChart.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import { getDashboardStats, trendPointValue, type DashboardStats, type TrendPoint } from '@/api'
|
||||
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
const loading = ref(false)
|
||||
const stats = ref<DashboardStats>({})
|
||||
const preset = ref<'today' | '7d' | '30d'>('7d')
|
||||
|
||||
function formatDate(d: Date) {
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
|
||||
function rangeOf(p: typeof preset.value) {
|
||||
const end = new Date()
|
||||
const start = new Date()
|
||||
if (p === 'today') { /* same day */ }
|
||||
else if (p === '7d') start.setDate(end.getDate() - 6)
|
||||
else start.setDate(end.getDate() - 29)
|
||||
return { startDate: formatDate(start), endDate: formatDate(end) }
|
||||
}
|
||||
|
||||
const kpi = computed(() => [
|
||||
{ label: '文章', value: stats.value.posts ?? 0 },
|
||||
{ label: '作品', value: stats.value.works ?? 0 },
|
||||
{ label: '咨询', value: stats.value.inquiryCount ?? 0 },
|
||||
{
|
||||
label: '区间 UV',
|
||||
value: (stats.value.uvTrend || []).reduce((s, i) => s + trendPointValue(i), 0),
|
||||
},
|
||||
])
|
||||
|
||||
const cats = computed(() => {
|
||||
const set = new Set<string>()
|
||||
;(stats.value.postsTrend || []).forEach(p => set.add(p.date))
|
||||
;(stats.value.uvTrend || []).forEach(p => set.add(p.date))
|
||||
return Array.from(set).sort()
|
||||
})
|
||||
|
||||
function mapByDate(list: TrendPoint[] | undefined, categories: string[]) {
|
||||
const map = new Map((list || []).map(i => [i.date, trendPointValue(i)]))
|
||||
return categories.map(d => map.get(d) ?? 0)
|
||||
}
|
||||
|
||||
const lineSeries = computed(() => [
|
||||
{ name: '发文', data: mapByDate(stats.value.postsTrend, cats.value) },
|
||||
{ name: 'UV', data: mapByDate(stats.value.uvTrend, cats.value) },
|
||||
])
|
||||
|
||||
const barCategories = computed(() =>
|
||||
(stats.value.topPosts || []).slice(0, 6).map(p => String(p.title || '').slice(0, 6) || '—'),
|
||||
)
|
||||
const barValues = computed(() =>
|
||||
(stats.value.topPosts || []).slice(0, 6).map(p => Number(p.count ?? p.readCount ?? 0)),
|
||||
)
|
||||
|
||||
const regions = computed(() => {
|
||||
const list = stats.value.userRegions || []
|
||||
return [...list]
|
||||
.map(r => ({
|
||||
name: String(r.region || r.name || '未知'),
|
||||
value: Number(r.count ?? r.value ?? 0),
|
||||
}))
|
||||
.sort((a, b) => b.value - a.value)
|
||||
.slice(0, 8)
|
||||
})
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
stats.value = await getDashboardStats(rangeOf(preset.value))
|
||||
}
|
||||
catch {
|
||||
stats.value = {}
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function setPreset(p: typeof preset.value) {
|
||||
preset.value = p
|
||||
load()
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin with-tab-pad>
|
||||
<AppNavbar title="数据分析" />
|
||||
<view class="page-pad">
|
||||
<view class="presets">
|
||||
<view class="chip" :class="{ on: preset === 'today' }" @click="setPreset('today')">今日</view>
|
||||
<view class="chip" :class="{ on: preset === '7d' }" @click="setPreset('7d')">近7天</view>
|
||||
<view class="chip" :class="{ on: preset === '30d' }" @click="setPreset('30d')">近30天</view>
|
||||
</view>
|
||||
<view class="grid">
|
||||
<view v-for="item in kpi" :key="item.label" class="admin-card card">
|
||||
<text class="font-mono-label text-art-muted">{{ item.label }}</text>
|
||||
<text class="value">{{ item.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="admin-card chart-card">
|
||||
<text class="chart-title">发文 / UV 趋势</text>
|
||||
<AdminLineChart v-if="cats.length" :categories="cats" :series="lineSeries" />
|
||||
<wd-empty v-else description="暂无趋势" />
|
||||
</view>
|
||||
<view class="admin-card chart-card">
|
||||
<text class="chart-title">热门文章</text>
|
||||
<AdminBarChart v-if="barCategories.length" :categories="barCategories" :values="barValues" />
|
||||
<wd-empty v-else description="暂无热门" />
|
||||
</view>
|
||||
<view class="admin-card chart-card">
|
||||
<text class="chart-title">访问地区 TOP</text>
|
||||
<view v-for="(r, i) in regions" :key="i" class="region">
|
||||
<text class="r-name">{{ i + 1 }}. {{ r.name }}</text>
|
||||
<text class="r-val text-art-accent">{{ r.value }}</text>
|
||||
</view>
|
||||
<wd-empty v-if="!regions.length" description="暂无地区数据" />
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
</view>
|
||||
<AppFloatingTabbar />
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 48rpx; }
|
||||
.presets { display: flex; gap: 12rpx; margin-bottom: 20rpx; }
|
||||
.chip {
|
||||
padding: 10rpx 22rpx; border-radius: 8rpx; font-size: 24rpx;
|
||||
background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
.chip.on { background: rgba(212, 179, 131, 0.25); color: rgb(var(--art-accent)); }
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16rpx; margin-bottom: 20rpx; }
|
||||
.card { padding: 24rpx; display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.value { font-size: 40rpx; color: rgb(var(--art-accent)); font-family: monospace; }
|
||||
.chart-card { padding: 24rpx; margin-bottom: 20rpx; }
|
||||
.chart-title { display: block; font-size: 28rpx; margin-bottom: 16rpx; color: rgb(var(--art-text)); }
|
||||
.region {
|
||||
display: flex; justify-content: space-between; padding: 14rpx 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.r-name { color: rgb(var(--art-text)); font-size: 26rpx; }
|
||||
.r-val { font-family: monospace; }
|
||||
</style>
|
||||
361
src/subPackages/admin/pages/attachments/index.vue
Normal file
361
src/subPackages/admin/pages/attachments/index.vue
Normal file
@@ -0,0 +1,361 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 附件库:小红书式双列瀑布流 + 分页上拉 + page-container 详情。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onReachBottom, onShow } from '@dcloudio/uni-app'
|
||||
import { useDialog, useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminFilterBar from '../../components/AdminFilterBar.vue'
|
||||
import AdminDetailDrawer from '../../components/AdminDetailDrawer.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import {
|
||||
adminDelete,
|
||||
adminList,
|
||||
adminUpdate,
|
||||
normalizeAdminList,
|
||||
normalizePagination,
|
||||
uploadAttachment,
|
||||
type Attachment,
|
||||
} from '@/api'
|
||||
import { resolveMediaUrl } from '@/utils/request'
|
||||
|
||||
const toast = useToast()
|
||||
const dialog = useDialog()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
|
||||
const loading = ref(false)
|
||||
const uploading = ref(false)
|
||||
const list = ref<Attachment[]>([])
|
||||
const categories = ref<{ id: number, name: string }[]>([])
|
||||
const keyword = ref('')
|
||||
const filters = ref<Record<string, unknown>>({})
|
||||
const page = ref(1)
|
||||
const finished = ref(false)
|
||||
const detailShow = ref(false)
|
||||
const current = ref<Attachment | null>(null)
|
||||
|
||||
const filterDefs = [
|
||||
{ key: 'keyword', label: '关键词', type: 'text' as const },
|
||||
{
|
||||
key: 'fileType',
|
||||
label: '类型',
|
||||
type: 'select' as const,
|
||||
options: [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '图片', value: 'image' },
|
||||
{ label: '视频', value: 'video' },
|
||||
{ label: '文档', value: 'document' },
|
||||
{ label: '其他', value: 'other' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** 奇偶分列,形成简易瀑布流 */
|
||||
const leftCol = computed(() => list.value.filter((_, i) => i % 2 === 0))
|
||||
const rightCol = computed(() => list.value.filter((_, i) => i % 2 === 1))
|
||||
|
||||
async function load(reset = true) {
|
||||
if (!guardAdmin()) return
|
||||
if (reset) {
|
||||
page.value = 1
|
||||
finished.value = false
|
||||
list.value = []
|
||||
}
|
||||
if (finished.value && !reset) return
|
||||
loading.value = true
|
||||
try {
|
||||
if (reset || !categories.value.length) {
|
||||
categories.value = normalizeAdminList(await adminList('attachment-categories')) as { id: number, name: string }[]
|
||||
}
|
||||
const data = await adminList('attachments', {
|
||||
page: page.value,
|
||||
pageSize: 20,
|
||||
keyword: keyword.value || undefined,
|
||||
...filters.value,
|
||||
})
|
||||
const p = normalizePagination<Attachment>(data, page.value, 20)
|
||||
list.value = reset ? p.list : [...list.value, ...p.list]
|
||||
finished.value = list.value.length >= p.total || p.list.length < 20
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(() => load(true))
|
||||
onReachBottom(() => {
|
||||
if (finished.value || loading.value) return
|
||||
page.value += 1
|
||||
load(false)
|
||||
})
|
||||
|
||||
function fileUrl(item: Attachment) {
|
||||
return resolveMediaUrl(item.fileUrl || item.url || '')
|
||||
}
|
||||
|
||||
function isImage(item: Attachment) {
|
||||
const t = String(item.fileType || item.fileUrl || item.url || '').toLowerCase()
|
||||
return t.includes('image') || /\.(png|jpe?g|gif|webp|svg)$/i.test(t)
|
||||
}
|
||||
|
||||
function isVideo(item: Attachment) {
|
||||
const t = String(item.fileType || item.fileUrl || item.url || '').toLowerCase()
|
||||
return t.includes('video') || /\.(mp4|mov|webm|m4v)$/i.test(t)
|
||||
}
|
||||
|
||||
function openDetail(item: Attachment) {
|
||||
current.value = { ...item }
|
||||
detailShow.value = true
|
||||
}
|
||||
|
||||
async function onUpload() {
|
||||
try {
|
||||
const res = await uni.chooseImage({ count: 9, sizeType: ['compressed'] })
|
||||
uploading.value = true
|
||||
for (const path of res.tempFilePaths || []) {
|
||||
await uploadAttachment(path)
|
||||
}
|
||||
toast.success('上传完成')
|
||||
load(true)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
if (e && typeof e === 'object' && 'errMsg' in e && String((e as { errMsg: string }).errMsg).includes('cancel')) return
|
||||
toast.error(e instanceof Error ? e.message : '上传失败')
|
||||
}
|
||||
finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function copyUrl() {
|
||||
const url = fileUrl(current.value || { id: 0 })
|
||||
if (!url) return
|
||||
uni.setClipboardData({ data: url, success: () => toast.success('已复制') })
|
||||
}
|
||||
|
||||
async function saveCategory(catId: number) {
|
||||
if (!current.value?.id) return
|
||||
try {
|
||||
await adminUpdate('attachments', current.value.id, { categoryId: catId })
|
||||
current.value.categoryId = catId
|
||||
toast.success('已更新分类')
|
||||
load(true)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
if (!current.value?.id) return
|
||||
dialog.confirm({ title: '确认删除', msg: '确定删除该附件吗?' }).then(async () => {
|
||||
try {
|
||||
await adminDelete('attachments', current.value!.id)
|
||||
toast.success('已删除')
|
||||
detailShow.value = false
|
||||
load(true)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const detailRows = () => {
|
||||
const c = current.value
|
||||
if (!c) return []
|
||||
return [
|
||||
{ label: '名称', value: String(c.originalName || c.fileName || c.id) },
|
||||
{ label: '类型', value: String(c.fileType || '') },
|
||||
{ label: '大小', value: c.fileSize ? `${Math.round(Number(c.fileSize) / 1024)} KB` : '' },
|
||||
{ label: 'URL', value: fileUrl(c) },
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar title="附件库" show-back />
|
||||
<view class="page-pad">
|
||||
<AdminFilterBar
|
||||
v-model:keyword="keyword"
|
||||
v-model="filters"
|
||||
:filters="filterDefs"
|
||||
@search="load(true)"
|
||||
@reset="load(true)"
|
||||
/>
|
||||
<!-- 双列瀑布:奇偶分列,避免 CSS grid min-width 撑破 -->
|
||||
<view class="waterfall">
|
||||
<view class="col">
|
||||
<view
|
||||
v-for="item in leftCol"
|
||||
:key="item.id"
|
||||
class="card"
|
||||
@click="openDetail(item)"
|
||||
>
|
||||
<view class="media">
|
||||
<image
|
||||
v-if="isImage(item)"
|
||||
class="thumb"
|
||||
:src="fileUrl(item)"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view v-else class="file-icon">
|
||||
<wd-icon :name="isVideo(item) ? 'video' : 'file'" size="28px" />
|
||||
<text v-if="isVideo(item)" class="badge">视频</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="name">{{ item.originalName || item.fileName || item.id }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="col">
|
||||
<view
|
||||
v-for="item in rightCol"
|
||||
:key="item.id"
|
||||
class="card"
|
||||
@click="openDetail(item)"
|
||||
>
|
||||
<view class="media">
|
||||
<image
|
||||
v-if="isImage(item)"
|
||||
class="thumb"
|
||||
:src="fileUrl(item)"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view v-else class="file-icon">
|
||||
<wd-icon :name="isVideo(item) ? 'video' : 'file'" size="28px" />
|
||||
<text v-if="isVideo(item)" class="badge">视频</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="name">{{ item.originalName || item.fileName || item.id }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<wd-loading v-if="loading || uploading" />
|
||||
<text v-if="!finished && list.length" class="more text-art-muted">上拉加载更多</text>
|
||||
<wd-empty v-if="!loading && !list.length" description="暂无附件" />
|
||||
</view>
|
||||
<view class="fab" @click="onUpload"><wd-icon name="upload" size="24px" color="#fff" /></view>
|
||||
|
||||
<AdminDetailDrawer v-model:show="detailShow" title="附件详情" :rows="detailRows()">
|
||||
<view class="cat-section">
|
||||
<text class="text-art-muted">改分类</text>
|
||||
<view class="tags">
|
||||
<view
|
||||
v-for="c in categories"
|
||||
:key="c.id"
|
||||
class="tag"
|
||||
:class="{ on: current?.categoryId === c.id }"
|
||||
@click="saveCategory(c.id)"
|
||||
>
|
||||
{{ c.name }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<template #footer>
|
||||
<wd-button size="small" @click="copyUrl">复制 URL</wd-button>
|
||||
<wd-button size="small" type="warning" @click="onDelete">删除</wd-button>
|
||||
</template>
|
||||
</AdminDetailDrawer>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad {
|
||||
padding: 24rpx 24rpx 120rpx;
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.waterfall {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16rpx;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.col {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.card {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 16rpx;
|
||||
background: rgb(var(--art-admin-card));
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.media {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.thumb {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-height: 180rpx;
|
||||
max-height: 480rpx;
|
||||
}
|
||||
.file-icon {
|
||||
width: 100%;
|
||||
height: 220rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12rpx;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
.badge {
|
||||
font-size: 20rpx;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 8rpx;
|
||||
background: rgba(212, 179, 131, 0.25);
|
||||
color: rgb(var(--art-accent));
|
||||
}
|
||||
.name {
|
||||
display: block;
|
||||
padding: 16rpx 18rpx 20rpx;
|
||||
font-size: 22rpx;
|
||||
color: rgb(var(--art-text));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.more { display: block; text-align: center; font-size: 22rpx; padding: 20rpx; }
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 40rpx;
|
||||
bottom: calc(40rpx + env(safe-area-inset-bottom));
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 50%;
|
||||
background: rgb(var(--art-accent));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
.cat-section { margin-top: 16rpx; }
|
||||
.tags { display: flex; flex-wrap: wrap; gap: 12rpx; margin-top: 12rpx; }
|
||||
.tag {
|
||||
padding: 8rpx 16rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 22rpx;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
.tag.on { background: rgba(212, 179, 131, 0.25); color: rgb(var(--art-accent)); }
|
||||
</style>
|
||||
195
src/subPackages/admin/pages/columns/form.vue
Normal file
195
src/subPackages/admin/pages/columns/form.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 专栏表单:封面/启用 + 编辑态挂载文章(add/remove)。
|
||||
*/
|
||||
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 { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import {
|
||||
addPostToColumn,
|
||||
adminCreate,
|
||||
adminGet,
|
||||
adminList,
|
||||
adminUpdate,
|
||||
getAdminColumnPosts,
|
||||
normalizeAdminList,
|
||||
removePostFromColumn,
|
||||
type Column,
|
||||
type Post,
|
||||
} from '@/api'
|
||||
|
||||
const toast = useToast()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
const mode = ref<'create' | 'edit'>('create')
|
||||
const id = ref('')
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const linkedPosts = ref<Post[]>([])
|
||||
const allPosts = ref<Post[]>([])
|
||||
const postKeyword = ref('')
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
cover: '',
|
||||
description: '',
|
||||
sortOrder: 0,
|
||||
isActive: 1,
|
||||
})
|
||||
|
||||
const pageTitle = computed(() => (mode.value === 'create' ? '新建专栏' : '编辑专栏'))
|
||||
const filteredPosts = computed(() => {
|
||||
const kw = postKeyword.value.trim().toLowerCase()
|
||||
const linkedIds = new Set(linkedPosts.value.map(p => Number(p.id)))
|
||||
return allPosts.value.filter((p) => {
|
||||
if (linkedIds.has(Number(p.id))) return false
|
||||
if (!kw) return true
|
||||
return String(p.title || '').toLowerCase().includes(kw)
|
||||
}).slice(0, 30)
|
||||
})
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
const postData = await adminList('posts', { page: 1, pageSize: 200 })
|
||||
allPosts.value = normalizeAdminList(postData) as Post[]
|
||||
if (mode.value === 'edit' && id.value) {
|
||||
const data = await adminGet('columns', id.value) as Column
|
||||
form.name = String(data.name || data.title || '')
|
||||
form.cover = String(data.cover || '')
|
||||
form.description = String(data.description || '')
|
||||
form.sortOrder = Number(data.sortOrder || 0)
|
||||
form.isActive = data.isActive === 0 || data.isActive === false ? 0 : 1
|
||||
linkedPosts.value = await getAdminColumnPosts(id.value)
|
||||
}
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((q) => {
|
||||
mode.value = q?.mode === 'edit' ? 'edit' : 'create'
|
||||
id.value = String(q?.id || '')
|
||||
})
|
||||
onShow(load)
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.name.trim()) {
|
||||
toast.show('请填写名称')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload = { ...form }
|
||||
if (mode.value === 'edit' && id.value) {
|
||||
await adminUpdate('columns', id.value, payload)
|
||||
toast.success('已保存')
|
||||
}
|
||||
else {
|
||||
const created = await adminCreate('columns', payload) as Column
|
||||
if (created?.id) id.value = String(created.id)
|
||||
mode.value = 'edit'
|
||||
toast.success('已创建,可继续挂载文章')
|
||||
}
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addPost(post: Post) {
|
||||
if (!id.value) {
|
||||
toast.show('请先保存专栏')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await addPostToColumn(id.value, post.id)
|
||||
linkedPosts.value.push(post)
|
||||
toast.success('已添加')
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '添加失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function removePost(post: Post) {
|
||||
if (!id.value) return
|
||||
try {
|
||||
await removePostFromColumn(id.value, post.id)
|
||||
linkedPosts.value = linkedPosts.value.filter(p => Number(p.id) !== Number(post.id))
|
||||
toast.success('已移除')
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '移除失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar :title="pageTitle" show-back />
|
||||
<view class="page-pad">
|
||||
<view class="admin-card form-card">
|
||||
<wd-input v-model="form.name" label="名称" clearable />
|
||||
<AdminMediaPicker v-model="form.cover" label="封面" />
|
||||
<wd-input v-model="form.sortOrder" label="排序" type="number" />
|
||||
<view class="switch-row">
|
||||
<text>启用</text>
|
||||
<wd-switch :model-value="!!form.isActive" @change="(v: boolean | { value: boolean }) => form.isActive = (typeof v === 'boolean' ? v : v.value) ? 1 : 0" />
|
||||
</view>
|
||||
<wd-textarea v-model="form.description" label="描述" />
|
||||
<wd-button
|
||||
block
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
custom-style="margin-top:24rpx;background:#d4b383;border-color:#d4b383;"
|
||||
@click="onSubmit"
|
||||
>
|
||||
保存
|
||||
</wd-button>
|
||||
</view>
|
||||
|
||||
<view v-if="mode === 'edit' && id" class="admin-card form-card" style="margin-top: 20rpx;">
|
||||
<text class="section-title" style="font-size: 30rpx;">专栏文章</text>
|
||||
<view v-for="p in linkedPosts" :key="String(p.id)" class="post-row">
|
||||
<text class="ptitle">{{ p.title }}</text>
|
||||
<wd-button size="small" type="warning" plain @click="removePost(p)">移除</wd-button>
|
||||
</view>
|
||||
<wd-input v-model="postKeyword" label="搜索添加" placeholder="文章标题" clearable />
|
||||
<view v-for="p in filteredPosts" :key="`a-${p.id}`" class="post-row">
|
||||
<text class="ptitle">{{ p.title }}</text>
|
||||
<wd-button size="small" @click="addPost(p)">添加</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 80rpx; }
|
||||
.form-card { padding: 24rpx; }
|
||||
.switch-row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 20rpx 0; color: rgb(var(--art-text));
|
||||
}
|
||||
.post-row {
|
||||
display: flex; align-items: center; gap: 12rpx;
|
||||
padding: 16rpx 0; border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.ptitle {
|
||||
flex: 1; font-size: 26rpx; color: rgb(var(--art-text));
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
228
src/subPackages/admin/pages/dashboard/index.vue
Normal file
228
src/subPackages/admin/pages/dashboard/index.vue
Normal file
@@ -0,0 +1,228 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 后台工作台:KPI 可跳转 + 最近文章 + 最近操作(查看全部进日志页)。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppFloatingTabbar from '@/components/layout/AppFloatingTabbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminLineChart from '../../components/AdminLineChart.vue'
|
||||
import AdminBarChart from '../../components/AdminBarChart.vue'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import {
|
||||
adminList,
|
||||
getDashboardStats,
|
||||
getOperationLogs,
|
||||
normalizeAdminList,
|
||||
trendPointValue,
|
||||
type DashboardStats,
|
||||
type Post,
|
||||
type TrendPoint,
|
||||
} from '@/api'
|
||||
|
||||
const { isAuthenticated } = useAuth()
|
||||
const { canEnterAdmin } = useAppMode()
|
||||
const { apply } = useTheme()
|
||||
|
||||
const loading = ref(false)
|
||||
const stats = ref<DashboardStats>({})
|
||||
const logs = ref<unknown[]>([])
|
||||
const recentPosts = ref<Post[]>([])
|
||||
|
||||
const todayUv = computed(() => {
|
||||
const list = stats.value.uvTrend || []
|
||||
if (!list.length) return 0
|
||||
return trendPointValue(list[list.length - 1])
|
||||
})
|
||||
|
||||
const kpiList = computed(() => [
|
||||
{ key: 'posts', label: '文章', value: stats.value.posts ?? 0, url: '/subPackages/admin/pages/posts/list' },
|
||||
{ key: 'works', label: '作品', value: stats.value.works ?? 0, url: '/subPackages/admin/pages/works/list' },
|
||||
{ key: 'inquiry', label: '咨询', value: stats.value.inquiryCount ?? 0, url: '/subPackages/admin/pages/inquiries/list' },
|
||||
{ key: 'uv', label: '今日 UV', value: todayUv.value, url: '/subPackages/admin/pages/analytics/index' },
|
||||
])
|
||||
|
||||
const trendCategories = computed(() => {
|
||||
const posts = stats.value.postsTrend || []
|
||||
const uv = stats.value.uvTrend || []
|
||||
const set = new Set<string>()
|
||||
posts.forEach(p => set.add(p.date))
|
||||
uv.forEach(p => set.add(p.date))
|
||||
return Array.from(set).sort()
|
||||
})
|
||||
|
||||
function mapByDate(list: TrendPoint[] | undefined, cats: string[]): number[] {
|
||||
const map = new Map((list || []).map(i => [i.date, trendPointValue(i)]))
|
||||
return cats.map(d => map.get(d) ?? 0)
|
||||
}
|
||||
|
||||
const lineSeries = computed(() => {
|
||||
const cats = trendCategories.value
|
||||
return [
|
||||
{ name: '发文', data: mapByDate(stats.value.postsTrend, cats) },
|
||||
{ name: 'UV', data: mapByDate(stats.value.uvTrend, cats) },
|
||||
]
|
||||
})
|
||||
|
||||
const barCategories = computed(() =>
|
||||
(stats.value.topPosts || []).slice(0, 6).map(p => String(p.title || '').slice(0, 6) || '—'),
|
||||
)
|
||||
const barValues = computed(() =>
|
||||
(stats.value.topPosts || []).slice(0, 6).map(p => Number(p.count ?? p.readCount ?? 0)),
|
||||
)
|
||||
|
||||
function guardAdmin() {
|
||||
if (!isAuthenticated() || !canEnterAdmin.value) {
|
||||
uni.reLaunch({ url: '/pages/mine/index' })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function logLabel(item: unknown): string {
|
||||
if (!item || typeof item !== 'object') return String(item)
|
||||
const row = item as Record<string, unknown>
|
||||
return String(row.action || row.description || row.module || row.id || '操作记录')
|
||||
}
|
||||
|
||||
function logTime(item: unknown): string {
|
||||
if (!item || typeof item !== 'object') return ''
|
||||
const row = item as Record<string, unknown>
|
||||
return String(row.createdAt || row.time || '')
|
||||
}
|
||||
|
||||
async function load() {
|
||||
apply()
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
const [s, logRes, postsRes] = await Promise.all([
|
||||
getDashboardStats().catch(() => ({}) as DashboardStats),
|
||||
getOperationLogs(1, 10).catch(() => ({ list: [] })),
|
||||
adminList('posts', { page: 1, pageSize: 5 }).catch(() => []),
|
||||
])
|
||||
stats.value = s || {}
|
||||
const list = Array.isArray(logRes) ? logRes : (logRes as { list?: unknown[] }).list || []
|
||||
logs.value = list
|
||||
recentPosts.value = normalizeAdminList(postsRes) as Post[]
|
||||
}
|
||||
catch (e: unknown) {
|
||||
uni.showToast({ title: e instanceof Error ? e.message : '加载失败', icon: 'none' })
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
|
||||
function go(url: string) {
|
||||
uni.navigateTo({ url })
|
||||
}
|
||||
|
||||
function goPost(item: Post) {
|
||||
uni.navigateTo({ url: `/subPackages/admin/pages/posts/form?mode=edit&id=${item.id}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin with-tab-pad>
|
||||
<AppNavbar title="工作台" />
|
||||
<view class="page-pad">
|
||||
<view class="hero-eyebrow">Dashboard</view>
|
||||
<view class="section-title" style="margin-bottom: 32rpx;">概览</view>
|
||||
|
||||
<view class="stats-grid">
|
||||
<view
|
||||
v-for="item in kpiList"
|
||||
:key="item.key"
|
||||
class="admin-card stat"
|
||||
@click="go(item.url)"
|
||||
>
|
||||
<text class="font-mono-label text-art-muted">{{ item.label }}</text>
|
||||
<text class="num">{{ item.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="admin-card chart-card">
|
||||
<text class="chart-title">发文 / UV 趋势</text>
|
||||
<AdminLineChart
|
||||
v-if="trendCategories.length"
|
||||
:categories="trendCategories"
|
||||
:series="lineSeries"
|
||||
/>
|
||||
<wd-empty v-else description="暂无趋势数据" />
|
||||
</view>
|
||||
|
||||
<view class="admin-card chart-card">
|
||||
<text class="chart-title">热门文章阅读</text>
|
||||
<AdminBarChart
|
||||
v-if="barCategories.length"
|
||||
:categories="barCategories"
|
||||
:values="barValues"
|
||||
/>
|
||||
<wd-empty v-else description="暂无热门数据" />
|
||||
</view>
|
||||
|
||||
<view class="section-head">
|
||||
<text class="section-title" style="font-size: 34rpx;">最近文章</text>
|
||||
<text class="link text-art-accent" @click="go('/subPackages/admin/pages/posts/list')">全部</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="item in recentPosts"
|
||||
:key="String(item.id)"
|
||||
class="admin-card log"
|
||||
@click="goPost(item)"
|
||||
>
|
||||
<text class="log-title">{{ item.title }}</text>
|
||||
<text class="font-mono-label text-art-muted">{{ item.categoryName || '' }} · {{ item.date || '' }}</text>
|
||||
</view>
|
||||
|
||||
<view class="section-head" style="margin-top: 28rpx;">
|
||||
<text class="section-title" style="font-size: 34rpx;">最近操作</text>
|
||||
<text class="link text-art-accent" @click="go('/subPackages/admin/pages/logs/operation')">全部</text>
|
||||
</view>
|
||||
<view v-for="(item, idx) in logs" :key="idx" class="admin-card log">
|
||||
<text class="log-title">{{ logLabel(item) }}</text>
|
||||
<text class="font-mono-label text-art-muted">{{ logTime(item) }}</text>
|
||||
</view>
|
||||
<wd-empty v-if="!loading && !logs.length" description="暂无日志" />
|
||||
<wd-loading v-if="loading" />
|
||||
</view>
|
||||
<AppFloatingTabbar />
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 48rpx; }
|
||||
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20rpx; margin-bottom: 28rpx; }
|
||||
.stat { padding: 28rpx; display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.num { font-size: 44rpx; color: rgb(var(--art-accent)); font-family: monospace; }
|
||||
.chart-card { padding: 24rpx; margin-bottom: 24rpx; }
|
||||
.chart-title {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
color: rgb(var(--art-text));
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
.section-head {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding-bottom: 16rpx;
|
||||
margin-bottom: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.link { font-size: 24rpx; }
|
||||
.log {
|
||||
padding: 24rpx 28rpx;
|
||||
margin-bottom: 16rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
}
|
||||
.log-title { font-size: 28rpx; color: rgb(var(--art-text)); }
|
||||
</style>
|
||||
132
src/subPackages/admin/pages/explore/index.vue
Normal file
132
src/subPackages/admin/pages/explore/index.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 后台功能入口:顶栏快捷 + Tab 分组 + 4 列金刚区。
|
||||
* 不用 wd-collapse/cell 白底列表,暗色走 AppPageShell admin。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppFloatingTabbar from '@/components/layout/AppFloatingTabbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { ADMIN_MENUS, type MenuItem } from '@/config/menus'
|
||||
|
||||
const { isAuthenticated } = useAuth()
|
||||
const { canEnterAdmin } = useAppMode()
|
||||
const { apply } = useTheme()
|
||||
|
||||
/** 0 = 全部,其余对应 groups 下标 +1 */
|
||||
const tabIndex = ref(0)
|
||||
|
||||
const quickLinks = computed(() => ADMIN_MENUS.filter(m => m.path))
|
||||
const groups = computed(() => ADMIN_MENUS.filter(m => m.children?.length))
|
||||
|
||||
const tabLabels = computed(() => ['全部', ...groups.value.map(g => g.title)])
|
||||
|
||||
/** 当前 Tab 下的入口列表(扁平) */
|
||||
const gridItems = computed(() => {
|
||||
if (tabIndex.value === 0) {
|
||||
return groups.value.flatMap(g => g.children || [])
|
||||
}
|
||||
const g = groups.value[tabIndex.value - 1]
|
||||
return g?.children || []
|
||||
})
|
||||
|
||||
function guardAdmin() {
|
||||
if (!isAuthenticated() || !canEnterAdmin.value) {
|
||||
uni.reLaunch({ url: '/pages/mine/index' })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
apply()
|
||||
guardAdmin()
|
||||
})
|
||||
|
||||
const open = (item: MenuItem) => {
|
||||
if (!item.path) return
|
||||
uni.navigateTo({ url: item.path })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin with-tab-pad>
|
||||
<AppNavbar title="功能入口" />
|
||||
<view class="page-pad">
|
||||
<view class="hero-eyebrow">Admin</view>
|
||||
<view class="section-title" style="margin-bottom: 24rpx;">后台菜单</view>
|
||||
|
||||
<view class="quick-row">
|
||||
<view
|
||||
v-for="item in quickLinks"
|
||||
:key="item.key"
|
||||
class="admin-card quick"
|
||||
@click="open(item)"
|
||||
>
|
||||
<wd-icon :name="item.icon || 'apps'" size="22px" color="rgb(212,179,131)" />
|
||||
<text class="quick-title">{{ item.title }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<wd-tabs v-model="tabIndex" slidable="always">
|
||||
<wd-tab v-for="(label, i) in tabLabels" :key="i" :title="label" :name="i" />
|
||||
</wd-tabs>
|
||||
|
||||
<view class="grid">
|
||||
<view
|
||||
v-for="item in gridItems"
|
||||
:key="item.key"
|
||||
class="admin-card cell"
|
||||
@click="open(item)"
|
||||
>
|
||||
<wd-icon :name="item.icon || 'apps'" size="26px" color="rgb(212,179,131)" />
|
||||
<text class="cell-title">{{ item.title }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<wd-empty v-if="!gridItems.length" description="暂无入口" />
|
||||
</view>
|
||||
<AppFloatingTabbar />
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 48rpx; }
|
||||
.quick-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
.quick {
|
||||
padding: 28rpx 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.quick-title { font-size: 28rpx; color: rgb(var(--art-text)); }
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16rpx;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.cell {
|
||||
padding: 24rpx 8rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12rpx;
|
||||
min-height: 160rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.cell-title {
|
||||
font-size: 22rpx;
|
||||
color: rgb(var(--art-text));
|
||||
line-height: 1.3;
|
||||
}
|
||||
</style>
|
||||
150
src/subPackages/admin/pages/inquiries/list.vue
Normal file
150
src/subPackages/admin/pages/inquiries/list.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 合作咨询:只读列表 + page-container 详情 + 状态流转(未读→已读→已联系)。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { 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 AdminFilterBar from '../../components/AdminFilterBar.vue'
|
||||
import AdminStatusBadge from '../../components/AdminStatusBadge.vue'
|
||||
import AdminDetailDrawer from '../../components/AdminDetailDrawer.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import { adminList, normalizeAdminList, updateInquiryStatus, type Inquiry } from '@/api'
|
||||
|
||||
const STATUS_MAP: Record<string, string> = {
|
||||
'0': '未读',
|
||||
'1': '已读',
|
||||
'2': '已联系',
|
||||
}
|
||||
|
||||
const toast = useToast()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
const loading = ref(false)
|
||||
const list = ref<Inquiry[]>([])
|
||||
const keyword = ref('')
|
||||
const filters = ref<Record<string, unknown>>({})
|
||||
const detailShow = ref(false)
|
||||
const current = ref<Inquiry | null>(null)
|
||||
|
||||
const filterDefs = [
|
||||
{ key: 'keyword', label: '关键词', type: 'text' as const },
|
||||
{
|
||||
key: 'status',
|
||||
label: '状态',
|
||||
type: 'select' as const,
|
||||
options: [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '未读', value: 0 },
|
||||
{ label: '已读', value: 1 },
|
||||
{ label: '已联系', value: 2 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
list.value = normalizeAdminList(await adminList('inquiries', {
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
keyword: keyword.value || undefined,
|
||||
...filters.value,
|
||||
})) as Inquiry[]
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
|
||||
async function openDetail(item: Inquiry) {
|
||||
current.value = { ...item }
|
||||
detailShow.value = true
|
||||
// 未读自动标已读
|
||||
if (Number(item.status) === 0) {
|
||||
try {
|
||||
await updateInquiryStatus(item.id, 1)
|
||||
item.status = 1
|
||||
current.value.status = 1
|
||||
}
|
||||
catch { /* 忽略自动标记失败 */ }
|
||||
}
|
||||
}
|
||||
|
||||
async function setStatus(status: number) {
|
||||
if (!current.value?.id) return
|
||||
try {
|
||||
await updateInquiryStatus(current.value.id, status)
|
||||
current.value.status = status
|
||||
const hit = list.value.find(i => i.id === current.value!.id)
|
||||
if (hit) hit.status = status
|
||||
toast.success('状态已更新')
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
function detailRows() {
|
||||
const c = current.value
|
||||
if (!c) return []
|
||||
return [
|
||||
{ label: '姓名', value: String(c.name || '') },
|
||||
{ label: '公司', value: String(c.company || '') },
|
||||
{ label: '联系方式', value: `${c.contactMethod || ''} ${c.contactValue || ''}` },
|
||||
{ label: '预算', value: String(c.budget || '') },
|
||||
{ label: '需求', value: String(c.description || '') },
|
||||
{ label: '状态', value: STATUS_MAP[String(c.status ?? 0)] || '' },
|
||||
{ label: '时间', value: String(c.createdAt || '') },
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar title="合作咨询" show-back />
|
||||
<view class="page-pad">
|
||||
<AdminFilterBar
|
||||
v-model:keyword="keyword"
|
||||
v-model="filters"
|
||||
:filters="filterDefs"
|
||||
@search="load"
|
||||
@reset="load"
|
||||
/>
|
||||
<view v-for="item in list" :key="item.id" class="admin-card row" @click="openDetail(item)">
|
||||
<view class="main">
|
||||
<view class="title-row">
|
||||
<text class="title">{{ item.name || '匿名' }}</text>
|
||||
<AdminStatusBadge :value="item.status" :map="STATUS_MAP" />
|
||||
</view>
|
||||
<text class="sub text-art-muted">{{ item.company || '—' }} · {{ item.createdAt || '' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
<wd-empty v-if="!loading && !list.length" description="暂无咨询" />
|
||||
</view>
|
||||
|
||||
<AdminDetailDrawer v-model:show="detailShow" title="咨询详情" :rows="detailRows()">
|
||||
<template #footer>
|
||||
<wd-button size="small" @click="setStatus(1)">标为已读</wd-button>
|
||||
<wd-button size="small" type="primary" @click="setStatus(2)">标为已联系</wd-button>
|
||||
</template>
|
||||
</AdminDetailDrawer>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 80rpx; }
|
||||
.row { padding: 24rpx 28rpx; margin-bottom: 16rpx; }
|
||||
.main { display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.title-row { display: flex; align-items: center; gap: 12rpx; }
|
||||
.title { flex: 1; font-size: 30rpx; color: rgb(var(--art-text)); }
|
||||
.sub { font-size: 24rpx; }
|
||||
</style>
|
||||
206
src/subPackages/admin/pages/oss-configs/index.vue
Normal file
206
src/subPackages/admin/pages/oss-configs/index.vue
Normal file
@@ -0,0 +1,206 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* OSS 配置:分厂商 Tab,保存 / 测试连接 / 删除。
|
||||
*/
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { useDialog, useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import { adminCreate, adminDelete, adminList, adminUpdate, normalizeAdminList, testOssConfig } from '@/api'
|
||||
|
||||
type StorageType = 'local' | 'aliyun' | 'qcloud' | 'qiniu'
|
||||
|
||||
const TABS: { id: StorageType, label: string }[] = [
|
||||
{ id: 'local', label: '本地' },
|
||||
{ id: 'aliyun', label: '阿里云' },
|
||||
{ id: 'qcloud', label: '腾讯云' },
|
||||
{ id: 'qiniu', label: '七牛' },
|
||||
]
|
||||
|
||||
const toast = useToast()
|
||||
const dialog = useDialog()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const tab = ref(0)
|
||||
const configs = ref<Record<string, unknown>[]>([])
|
||||
|
||||
const form = reactive({
|
||||
id: '' as string | number,
|
||||
name: '',
|
||||
storageType: 'local' as string,
|
||||
isActive: 1 as number,
|
||||
accessKeyId: '',
|
||||
accessKeySecret: '',
|
||||
secretId: '',
|
||||
secretKey: '',
|
||||
accessKey: '',
|
||||
endpoint: '',
|
||||
region: '',
|
||||
bucket: '',
|
||||
domain: '',
|
||||
})
|
||||
|
||||
const currentType = computed(() => TABS[tab.value]?.id || 'local')
|
||||
|
||||
function fillForm(row?: Record<string, unknown>) {
|
||||
const type = currentType.value
|
||||
form.id = ''
|
||||
form.accessKeyId = ''
|
||||
form.accessKeySecret = ''
|
||||
form.secretId = ''
|
||||
form.secretKey = ''
|
||||
form.accessKey = ''
|
||||
form.endpoint = ''
|
||||
form.region = ''
|
||||
form.bucket = ''
|
||||
form.domain = ''
|
||||
form.storageType = type
|
||||
form.isActive = 1
|
||||
form.name = `${TABS[tab.value]?.label || type} 存储`
|
||||
if (!row) return
|
||||
form.id = (row.id as string | number) || ''
|
||||
form.name = String(row.name || form.name)
|
||||
form.storageType = String(row.storageType || type)
|
||||
form.isActive = row.isActive === 0 || row.isActive === false ? 0 : 1
|
||||
form.accessKeyId = String(row.accessKeyId || '')
|
||||
form.accessKeySecret = row.accessKeySecret === '***' ? '' : String(row.accessKeySecret || '')
|
||||
form.secretId = String(row.secretId || '')
|
||||
form.secretKey = row.secretKey === '***' ? '' : String(row.secretKey || '')
|
||||
form.accessKey = String(row.accessKey || '')
|
||||
form.endpoint = String(row.endpoint || '')
|
||||
form.region = String(row.region || '')
|
||||
form.bucket = String(row.bucket || '')
|
||||
form.domain = String(row.domain || '')
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
configs.value = normalizeAdminList(await adminList('oss-configs'))
|
||||
const hit = configs.value.find(c => String(c.storageType) === currentType.value)
|
||||
fillForm(hit)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onTabChange() {
|
||||
const hit = configs.value.find(c => String(c.storageType) === currentType.value)
|
||||
fillForm(hit)
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
|
||||
async function onSave() {
|
||||
submitting.value = true
|
||||
try {
|
||||
const { id, ...rest } = form
|
||||
const payload = { ...rest }
|
||||
if (id) await adminUpdate('oss-configs', id, payload)
|
||||
else await adminCreate('oss-configs', payload)
|
||||
toast.success('已保存')
|
||||
load()
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onTest() {
|
||||
if (!form.id) {
|
||||
toast.show('请先保存配置')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await testOssConfig(form.id as string | number)
|
||||
toast.success('连接成功')
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '测试失败')
|
||||
}
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
if (!form.id) return
|
||||
dialog.confirm({ title: '确认删除', msg: '确定删除该 OSS 配置吗?' }).then(async () => {
|
||||
try {
|
||||
await adminDelete('oss-configs', form.id as string | number)
|
||||
toast.success('已删除')
|
||||
load()
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar title="OSS 配置" show-back />
|
||||
<view class="page-pad">
|
||||
<wd-tabs v-model="tab" @change="onTabChange">
|
||||
<wd-tab v-for="t in TABS" :key="t.id" :title="t.label" />
|
||||
</wd-tabs>
|
||||
<view class="admin-card form-card">
|
||||
<wd-input v-model="form.name" label="名称" clearable />
|
||||
<view class="switch-row">
|
||||
<text>启用</text>
|
||||
<wd-switch :model-value="!!form.isActive" @change="(v: boolean) => form.isActive = v ? 1 : 0" />
|
||||
</view>
|
||||
<template v-if="currentType === 'aliyun'">
|
||||
<wd-input v-model="form.accessKeyId" label="AccessKeyId" clearable />
|
||||
<wd-input v-model="form.accessKeySecret" label="AccessKeySecret" clearable />
|
||||
<wd-input v-model="form.endpoint" label="Endpoint" clearable />
|
||||
<wd-input v-model="form.bucket" label="Bucket" clearable />
|
||||
<wd-input v-model="form.domain" label="Domain" clearable />
|
||||
</template>
|
||||
<template v-else-if="currentType === 'qcloud'">
|
||||
<wd-input v-model="form.secretId" label="SecretId" clearable />
|
||||
<wd-input v-model="form.secretKey" label="SecretKey" clearable />
|
||||
<wd-input v-model="form.region" label="Region" clearable />
|
||||
<wd-input v-model="form.bucket" label="Bucket" clearable />
|
||||
<wd-input v-model="form.domain" label="Domain" clearable />
|
||||
</template>
|
||||
<template v-else-if="currentType === 'qiniu'">
|
||||
<wd-input v-model="form.accessKey" label="AccessKey" clearable />
|
||||
<wd-input v-model="form.secretKey" label="SecretKey" clearable />
|
||||
<wd-input v-model="form.bucket" label="Bucket" clearable />
|
||||
<wd-input v-model="form.region" label="Region" clearable />
|
||||
<wd-input v-model="form.domain" label="Domain" clearable />
|
||||
</template>
|
||||
<template v-else>
|
||||
<text class="hint text-art-muted">本地存储无需额外密钥,仅需启用即可。</text>
|
||||
</template>
|
||||
<view class="actions">
|
||||
<wd-button type="primary" :loading="submitting" @click="onSave">保存</wd-button>
|
||||
<wd-button plain @click="onTest">测试连接</wd-button>
|
||||
<wd-button v-if="form.id" type="warning" plain @click="onDelete">删除</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 80rpx; }
|
||||
.form-card { padding: 24rpx; margin-top: 20rpx; }
|
||||
.switch-row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 20rpx 0; color: rgb(var(--art-text));
|
||||
}
|
||||
.hint { font-size: 24rpx; display: block; margin: 16rpx 0; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 12rpx; margin-top: 24rpx; }
|
||||
</style>
|
||||
354
src/subPackages/admin/pages/posts/form.vue
Normal file
354
src/subPackages/admin/pages/posts/form.vue
Normal file
@@ -0,0 +1,354 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文章编辑表单:结构化字段 + 简易 Markdown 工具条(插图走附件上传)。
|
||||
* 分类/专栏用 wd-picker,空列时塞占位项,避免 wot 内部 map 读 value of undefined。
|
||||
* 版本历史/访问日志共用一个 page-container(小程序同页只能有一个)。
|
||||
*/
|
||||
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 AdminDetailDrawer from '../../components/AdminDetailDrawer.vue'
|
||||
import AdminFormField from '../../components/AdminFormField.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import {
|
||||
adminCreate,
|
||||
adminGetPost,
|
||||
adminList,
|
||||
adminUpdate,
|
||||
getPostAccessLogs,
|
||||
getPostHistory,
|
||||
normalizeAdminList,
|
||||
restorePostVersion,
|
||||
uploadAttachment,
|
||||
type Post,
|
||||
} from '@/api'
|
||||
|
||||
const toast = useToast()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
|
||||
const mode = ref<'create' | 'edit'>('create')
|
||||
const id = ref('')
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const categories = ref<{ id: number, name: string }[]>([])
|
||||
/** 专栏列表;勿命名 columns,避免与 wd-picker 的 columns 属性混淆 */
|
||||
const columnList = ref<{ id: number, name?: string, title?: string }[]>([])
|
||||
const tags = ref<{ id: number, name: string }[]>([])
|
||||
const selectedTagIds = ref<number[]>([])
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
excerpt: '',
|
||||
content: '',
|
||||
cover: '',
|
||||
categoryId: '' as string | number,
|
||||
columnId: '' as string | number,
|
||||
date: '',
|
||||
isPublished: 1,
|
||||
})
|
||||
|
||||
/** 单一抽屉:history | access,避免双 page-container */
|
||||
const drawerShow = ref(false)
|
||||
const drawerMode = ref<'history' | 'access'>('history')
|
||||
const historyRows = ref<{ label: string, value: string }[]>([])
|
||||
const historyVersions = ref<{ version?: number | string, createdAt?: string }[]>([])
|
||||
const accessRows = ref<{ label: string, value: string }[]>([])
|
||||
|
||||
const pageTitle = computed(() => (mode.value === 'create' ? '新建文章' : '编辑文章'))
|
||||
const drawerTitle = computed(() => (drawerMode.value === 'history' ? '版本历史' : '访问日志'))
|
||||
const drawerRows = computed(() =>
|
||||
drawerMode.value === 'history' ? historyRows.value : accessRows.value,
|
||||
)
|
||||
|
||||
/**
|
||||
* wd-picker 列数据:空数组会触发内部 selectWithValue 崩溃,故至少给一项占位。
|
||||
*/
|
||||
const categoryColumns = computed(() => {
|
||||
const list = categories.value.map(c => ({ label: c.name, value: c.id }))
|
||||
return [list.length ? list : [{ label: '暂无分类', value: '' }]]
|
||||
})
|
||||
const columnColumns = computed(() => {
|
||||
const list = columnList.value.map(c => ({
|
||||
label: c.name || c.title || String(c.id),
|
||||
value: c.id,
|
||||
}))
|
||||
return [list.length ? list : [{ label: '暂无专栏', value: '' }]]
|
||||
})
|
||||
|
||||
function insertMd(prefix: string, suffix = '') {
|
||||
form.content = `${form.content}${prefix}${suffix}`
|
||||
}
|
||||
|
||||
async function insertImage() {
|
||||
try {
|
||||
const res = await uni.chooseImage({ count: 1, sizeType: ['compressed'] })
|
||||
const path = res.tempFilePaths?.[0]
|
||||
if (!path) return
|
||||
const uploaded = await uploadAttachment(path)
|
||||
const url = uploaded.fileUrl || uploaded.url || ''
|
||||
if (url) insertMd(`\n\n`)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
if (e && typeof e === 'object' && 'errMsg' in e && String((e as { errMsg: string }).errMsg).includes('cancel')) return
|
||||
toast.error(e instanceof Error ? e.message : '插图失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMeta() {
|
||||
const [cats, cols, tgs] = await Promise.all([
|
||||
adminList('categories').catch(() => []),
|
||||
adminList('columns').catch(() => []),
|
||||
adminList('tags').catch(() => []),
|
||||
])
|
||||
categories.value = normalizeAdminList(cats) as { id: number, name: string }[]
|
||||
columnList.value = normalizeAdminList(cols) as { id: number, name?: string, title?: string }[]
|
||||
tags.value = normalizeAdminList(tgs) as { id: number, name: string }[]
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
await loadMeta()
|
||||
if (mode.value === 'edit' && id.value) {
|
||||
// 走后台详情接口(含未发布),勿用公开 getPost / 已删除的 adminGet posts
|
||||
const data = await adminGetPost(id.value)
|
||||
form.title = String(data.title || '')
|
||||
form.excerpt = String(data.excerpt || data.summary || '')
|
||||
form.content = String(data.content || '')
|
||||
form.cover = String(data.cover || '')
|
||||
form.categoryId = (data.categoryId as number) || ''
|
||||
form.columnId = (data.columnId as number) || ''
|
||||
form.date = String(data.date || '')
|
||||
form.isPublished = data.isPublished === 0 || data.isPublished === false ? 0 : 1
|
||||
selectedTagIds.value = (data.tags || []).map(t => Number(t.id))
|
||||
}
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((q) => {
|
||||
mode.value = q?.mode === 'edit' ? 'edit' : 'create'
|
||||
id.value = String(q?.id || '')
|
||||
})
|
||||
onShow(load)
|
||||
|
||||
function toggleTag(tid: number) {
|
||||
const i = selectedTagIds.value.indexOf(tid)
|
||||
if (i >= 0) selectedTagIds.value.splice(i, 1)
|
||||
else selectedTagIds.value.push(tid)
|
||||
}
|
||||
|
||||
function onCatConfirm(payload: { value?: unknown[] }) {
|
||||
const v = payload?.value?.[0]
|
||||
const raw = (v && typeof v === 'object' && 'value' in v)
|
||||
? (v as { value: unknown }).value
|
||||
: v
|
||||
form.categoryId = (raw as number) || ''
|
||||
}
|
||||
|
||||
function onColConfirm(payload: { value?: unknown[] }) {
|
||||
const v = payload?.value?.[0]
|
||||
const raw = (v && typeof v === 'object' && 'value' in v)
|
||||
? (v as { value: unknown }).value
|
||||
: v
|
||||
form.columnId = (raw as number) || ''
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.title.trim()) {
|
||||
toast.show('请填写标题')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload = {
|
||||
title: form.title,
|
||||
excerpt: form.excerpt,
|
||||
content: form.content,
|
||||
cover: form.cover,
|
||||
categoryId: form.categoryId ? Number(form.categoryId) : undefined,
|
||||
columnId: form.columnId ? Number(form.columnId) : undefined,
|
||||
date: form.date || undefined,
|
||||
isPublished: form.isPublished,
|
||||
tagIds: selectedTagIds.value,
|
||||
}
|
||||
if (mode.value === 'edit' && id.value) await adminUpdate('posts', id.value, payload)
|
||||
else await adminCreate('posts', payload)
|
||||
toast.success('已保存')
|
||||
setTimeout(() => uni.navigateBack(), 400)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openHistory() {
|
||||
if (!id.value) return
|
||||
try {
|
||||
const list = await getPostHistory(id.value)
|
||||
historyVersions.value = (Array.isArray(list) ? list : []) as typeof historyVersions.value
|
||||
historyRows.value = historyVersions.value.map((v, i) => ({
|
||||
label: `版本 ${v.version ?? i + 1}`,
|
||||
value: String(v.createdAt || ''),
|
||||
}))
|
||||
drawerMode.value = 'history'
|
||||
drawerShow.value = true
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载历史失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function restore(version: string | number) {
|
||||
try {
|
||||
await restorePostVersion(id.value, version)
|
||||
toast.success('已恢复')
|
||||
drawerShow.value = false
|
||||
load()
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '恢复失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function openAccess() {
|
||||
if (!id.value) return
|
||||
try {
|
||||
const data = await getPostAccessLogs(id.value, { page: 1, pageSize: 20 })
|
||||
const list = Array.isArray(data) ? data : data.list || []
|
||||
accessRows.value = list.map((row: unknown, i: number) => {
|
||||
const r = row as Record<string, unknown>
|
||||
return {
|
||||
label: `#${i + 1} ${r.path || r.ip || ''}`,
|
||||
value: String(r.createdAt || r.region || ''),
|
||||
}
|
||||
})
|
||||
drawerMode.value = 'access'
|
||||
drawerShow.value = true
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载访问日志失败')
|
||||
}
|
||||
}
|
||||
|
||||
function catLabel() {
|
||||
return categories.value.find(c => String(c.id) === String(form.categoryId))?.name || '选择分类'
|
||||
}
|
||||
function colLabel() {
|
||||
const c = columnList.value.find(c => String(c.id) === String(form.columnId))
|
||||
return c?.name || c?.title || '选择专栏(可选)'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar :title="pageTitle" show-back />
|
||||
<view class="page-pad">
|
||||
<view class="admin-card form-card">
|
||||
<AdminFormField label="标题" required>
|
||||
<wd-input v-model="form.title" placeholder="请输入标题" clearable />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="摘要">
|
||||
<wd-textarea v-model="form.excerpt" placeholder="文章摘要" />
|
||||
</AdminFormField>
|
||||
<AdminMediaPicker v-model="form.cover" label="封面" />
|
||||
<wd-picker :columns="categoryColumns" @confirm="onCatConfirm">
|
||||
<view class="picker-row"><text>分类</text><text class="val">{{ catLabel() }}</text></view>
|
||||
</wd-picker>
|
||||
<wd-picker :columns="columnColumns" @confirm="onColConfirm">
|
||||
<view class="picker-row"><text>专栏</text><text class="val">{{ colLabel() }}</text></view>
|
||||
</wd-picker>
|
||||
<AdminFormField label="日期">
|
||||
<wd-input v-model="form.date" placeholder="YYYY-MM-DD" clearable />
|
||||
</AdminFormField>
|
||||
<view class="switch-row">
|
||||
<text>发布</text>
|
||||
<wd-switch :model-value="!!form.isPublished" @change="(v: boolean) => form.isPublished = v ? 1 : 0" />
|
||||
</view>
|
||||
<view class="tags">
|
||||
<text class="label text-art-muted">标签</text>
|
||||
<view class="tag-wrap">
|
||||
<view
|
||||
v-for="t in tags"
|
||||
:key="t.id"
|
||||
class="tag"
|
||||
:class="{ on: selectedTagIds.includes(t.id) }"
|
||||
@click="toggleTag(t.id)"
|
||||
>
|
||||
{{ t.name }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="md-tools">
|
||||
<wd-button size="small" plain @click="insertMd('**', '**')">加粗</wd-button>
|
||||
<wd-button size="small" plain @click="insertMd('[链接](', ')')">链接</wd-button>
|
||||
<wd-button size="small" plain @click="insertImage">插图</wd-button>
|
||||
</view>
|
||||
<AdminFormField label="正文 Markdown" required>
|
||||
<wd-textarea v-model="form.content" placeholder="支持 Markdown" :rows="12" />
|
||||
</AdminFormField>
|
||||
<view v-if="mode === 'edit'" class="extra">
|
||||
<wd-button size="small" plain @click="openHistory">版本历史</wd-button>
|
||||
<wd-button size="small" plain @click="openAccess">访问日志</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>
|
||||
|
||||
<AdminDetailDrawer v-model:show="drawerShow" :title="drawerTitle" :rows="drawerRows">
|
||||
<template v-if="drawerMode === 'history'">
|
||||
<view v-for="(v, i) in historyVersions" :key="i" class="hist-row">
|
||||
<wd-button size="small" @click="restore(v.version ?? i + 1)">恢复此版本</wd-button>
|
||||
</view>
|
||||
</template>
|
||||
</AdminDetailDrawer>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 80rpx; }
|
||||
.picker-row, .switch-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20rpx 0;
|
||||
color: rgb(var(--art-text));
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.val { color: rgba(255, 255, 255, 0.6); font-size: 26rpx; }
|
||||
.tags { margin: 16rpx 0; }
|
||||
.label { font-size: 24rpx; }
|
||||
.tag-wrap { display: flex; flex-wrap: wrap; gap: 12rpx; margin-top: 12rpx; }
|
||||
.tag {
|
||||
padding: 8rpx 18rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 24rpx;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
.tag.on { background: rgba(212, 179, 131, 0.25); color: rgb(var(--art-accent)); }
|
||||
.md-tools { display: flex; gap: 12rpx; margin: 16rpx 0; flex-wrap: wrap; }
|
||||
.extra { display: flex; gap: 12rpx; margin-top: 16rpx; }
|
||||
.hist-row { margin: 8rpx 0; }
|
||||
</style>
|
||||
187
src/subPackages/admin/pages/posts/list.vue
Normal file
187
src/subPackages/admin/pages/posts/list.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文章管理列表:服务端分页 + 筛选 + 行内发布切换 + 删除。
|
||||
* 关联/历史/访问日志放在编辑页,降低列表复杂度。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { onReachBottom, onShow } from '@dcloudio/uni-app'
|
||||
import { useDialog, useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminFilterBar from '../../components/AdminFilterBar.vue'
|
||||
import AdminStatusBadge from '../../components/AdminStatusBadge.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import {
|
||||
adminDelete,
|
||||
adminList,
|
||||
normalizePagination,
|
||||
patchPostStatus,
|
||||
type Post,
|
||||
} from '@/api'
|
||||
|
||||
const toast = useToast()
|
||||
const dialog = useDialog()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<Post[]>([])
|
||||
const keyword = ref('')
|
||||
const filters = ref<Record<string, unknown>>({})
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const finished = ref(false)
|
||||
|
||||
const filterDefs = [
|
||||
{ key: 'keyword', label: '关键词', type: 'text' as const },
|
||||
{
|
||||
key: 'isPublished',
|
||||
label: '发布',
|
||||
type: 'select' as const,
|
||||
options: [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '已发布', value: 1 },
|
||||
{ label: '草稿', value: 0 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
async function load(reset = true) {
|
||||
if (!guardAdmin()) return
|
||||
if (reset) {
|
||||
page.value = 1
|
||||
finished.value = false
|
||||
list.value = []
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await adminList('posts', {
|
||||
page: page.value,
|
||||
pageSize: 20,
|
||||
keyword: keyword.value || undefined,
|
||||
...filters.value,
|
||||
})
|
||||
const p = normalizePagination<Post>(data, page.value, 20)
|
||||
list.value = reset ? p.list : [...list.value, ...p.list]
|
||||
total.value = p.total
|
||||
finished.value = list.value.length >= p.total
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(() => load(true))
|
||||
onReachBottom(() => {
|
||||
if (finished.value || loading.value) return
|
||||
page.value += 1
|
||||
load(false)
|
||||
})
|
||||
|
||||
function goCreate() {
|
||||
uni.navigateTo({ url: '/subPackages/admin/pages/posts/form?mode=create' })
|
||||
}
|
||||
|
||||
function goEdit(item: Post) {
|
||||
uni.navigateTo({ url: `/subPackages/admin/pages/posts/form?mode=edit&id=${item.id}` })
|
||||
}
|
||||
|
||||
async function togglePublish(item: Post) {
|
||||
const next = item.isPublished === 1 || item.isPublished === true ? 0 : 1
|
||||
try {
|
||||
await patchPostStatus(item.id, next)
|
||||
item.isPublished = next
|
||||
toast.success(next ? '已发布' : '已设为草稿')
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '状态更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
function onDelete(item: Post) {
|
||||
dialog.confirm({ title: '确认删除', msg: `确定删除「${item.title}」吗?` }).then(async () => {
|
||||
try {
|
||||
await adminDelete('posts', item.id)
|
||||
toast.success('已删除')
|
||||
load(true)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar title="文章管理" show-back />
|
||||
<view class="page-pad">
|
||||
<AdminFilterBar
|
||||
v-model:keyword="keyword"
|
||||
v-model="filters"
|
||||
:filters="filterDefs"
|
||||
@search="load(true)"
|
||||
@reset="load(true)"
|
||||
/>
|
||||
<view v-for="item in list" :key="String(item.id)" class="admin-card row" @click="goEdit(item)">
|
||||
<view class="main">
|
||||
<view class="title-row">
|
||||
<text class="title">{{ item.title }}</text>
|
||||
<AdminStatusBadge
|
||||
:value="item.isPublished"
|
||||
:map="{ '1': '已发布', '0': '草稿', true: '已发布', false: '草稿' }"
|
||||
clickable
|
||||
@click="togglePublish(item)"
|
||||
/>
|
||||
</view>
|
||||
<text class="sub text-art-muted">
|
||||
{{ item.categoryName || '未分类' }} · {{ item.date || '' }}
|
||||
</text>
|
||||
</view>
|
||||
<wd-button size="small" type="warning" plain @click.stop="onDelete(item)">删除</wd-button>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
<wd-empty v-if="!loading && !list.length" description="暂无文章" />
|
||||
</view>
|
||||
<view class="fab" @click="goCreate">
|
||||
<wd-icon name="plus" size="24px" color="#fff" />
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 120rpx; }
|
||||
.row {
|
||||
padding: 24rpx 28rpx;
|
||||
margin-bottom: 16rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.title-row { display: flex; align-items: center; gap: 12rpx; }
|
||||
.title {
|
||||
flex: 1;
|
||||
font-size: 30rpx;
|
||||
color: rgb(var(--art-text));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sub { font-size: 24rpx; }
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 40rpx;
|
||||
bottom: calc(40rpx + env(safe-area-inset-bottom));
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 50%;
|
||||
background: rgb(var(--art-accent));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
</style>
|
||||
199
src/subPackages/admin/pages/profile/index.vue
Normal file
199
src/subPackages/admin/pages/profile/index.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 后台个人中心:资料编辑(头像/联系方式)+ 修改密码。
|
||||
*/
|
||||
import { reactive, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppFloatingTabbar from '@/components/layout/AppFloatingTabbar.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 { useTheme } from '@/composables/useTheme'
|
||||
import { getCurrentUser, updateMe, updatePassword } from '@/api'
|
||||
import { resolveMediaUrl } from '@/utils/request'
|
||||
|
||||
const toast = useToast()
|
||||
const { isAuthenticated, getUser, setUser } = useAuth()
|
||||
const { canEnterAdmin } = useAppMode()
|
||||
const { apply } = useTheme()
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const pwdSubmitting = ref(false)
|
||||
const user = ref(getUser())
|
||||
const profile = reactive({
|
||||
email: '',
|
||||
bio: '',
|
||||
phone: '',
|
||||
wechat: '',
|
||||
avatar: '',
|
||||
wechatQrcode: '',
|
||||
})
|
||||
const form = reactive({ oldPassword: '', newPassword: '', confirm: '' })
|
||||
|
||||
function guardAdmin() {
|
||||
if (!isAuthenticated() || !canEnterAdmin.value) {
|
||||
uni.reLaunch({ url: '/pages/mine/index' })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function load() {
|
||||
apply()
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
const me = await getCurrentUser()
|
||||
user.value = { ...me } as typeof user.value
|
||||
profile.email = me.email || ''
|
||||
profile.bio = me.bio || ''
|
||||
profile.phone = me.phone || ''
|
||||
profile.wechat = me.wechat || ''
|
||||
profile.avatar = me.avatar || ''
|
||||
profile.wechatQrcode = me.wechatQrcode || ''
|
||||
}
|
||||
catch {
|
||||
user.value = getUser()
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
|
||||
async function saveProfile() {
|
||||
submitting.value = true
|
||||
try {
|
||||
const updated = await updateMe({ ...profile })
|
||||
if (updated) setUser(updated as never)
|
||||
toast.success('资料已保存')
|
||||
load()
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!form.oldPassword || !form.newPassword) {
|
||||
toast.show('请填写原密码与新密码')
|
||||
return
|
||||
}
|
||||
if (form.newPassword !== form.confirm) {
|
||||
toast.show('两次新密码不一致')
|
||||
return
|
||||
}
|
||||
pwdSubmitting.value = true
|
||||
try {
|
||||
await updatePassword(form.oldPassword, form.newPassword)
|
||||
toast.success('密码已更新')
|
||||
form.oldPassword = ''
|
||||
form.newPassword = ''
|
||||
form.confirm = ''
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '更新失败')
|
||||
}
|
||||
finally {
|
||||
pwdSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin with-tab-pad>
|
||||
<AppNavbar title="个人中心" show-back />
|
||||
<view class="page-pad">
|
||||
<view class="avatar-row">
|
||||
<image
|
||||
class="avatar"
|
||||
:src="resolveMediaUrl(profile.avatar || user.avatar) || '/static/logo.svg'"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="meta">
|
||||
<text class="name">{{ user.username }}</text>
|
||||
<text class="role text-art-accent font-mono-label">{{ user.role || 'staff' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="admin-card form-card">
|
||||
<view class="section-title" style="font-size: 34rpx; margin-bottom: 24rpx;">个人资料</view>
|
||||
<AdminMediaPicker v-model="profile.avatar" label="头像" />
|
||||
<AdminFormField label="邮箱">
|
||||
<wd-input v-model="profile.email" placeholder="邮箱" clearable />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="简介">
|
||||
<wd-textarea v-model="profile.bio" placeholder="简介" />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="手机">
|
||||
<wd-input v-model="profile.phone" placeholder="手机" clearable />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="微信">
|
||||
<wd-input v-model="profile.wechat" placeholder="微信" clearable />
|
||||
</AdminFormField>
|
||||
<AdminMediaPicker v-model="profile.wechatQrcode" label="微信二维码" />
|
||||
<wd-button
|
||||
block
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
custom-style="margin-top:24rpx;background:#d4b383;border-color:#d4b383;"
|
||||
@click="saveProfile"
|
||||
>
|
||||
保存资料
|
||||
</wd-button>
|
||||
</view>
|
||||
|
||||
<view class="admin-card form-card" style="margin-top: 24rpx;">
|
||||
<view class="section-title" style="font-size: 34rpx; margin-bottom: 24rpx;">修改密码</view>
|
||||
<AdminFormField label="原密码">
|
||||
<wd-input v-model="form.oldPassword" placeholder="原密码" show-password clearable />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="新密码">
|
||||
<wd-input v-model="form.newPassword" placeholder="新密码" show-password clearable />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="确认新密码">
|
||||
<wd-input v-model="form.confirm" placeholder="确认新密码" show-password clearable />
|
||||
</AdminFormField>
|
||||
<wd-button
|
||||
block
|
||||
type="primary"
|
||||
:loading="pwdSubmitting"
|
||||
custom-style="margin-top:24rpx;background:#d4b383;border-color:#d4b383;"
|
||||
@click="onSubmit"
|
||||
>
|
||||
更新密码
|
||||
</wd-button>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
</view>
|
||||
<AppFloatingTabbar />
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 48rpx; }
|
||||
.avatar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
.avatar {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.meta { display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.name { font-size: 36rpx; color: rgb(var(--art-text)); font-weight: 600; }
|
||||
.role { font-size: 22rpx; }
|
||||
</style>
|
||||
251
src/subPackages/admin/pages/resource/form.vue
Normal file
251
src/subPackages/admin/pages/resource/form.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<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>
|
||||
404
src/subPackages/admin/pages/resource/list.vue
Normal file
404
src/subPackages/admin/pages/resource/list.vue
Normal file
@@ -0,0 +1,404 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 通用资源列表:按 resourceSchemas 渲染筛选/行信息/真只读。
|
||||
* 复杂模块已拆专用页;本页兜底简单 CRUD 与只读日志类资源。
|
||||
*/
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { onLoad, onReachBottom, onShow } from '@dcloudio/uni-app'
|
||||
import { useDialog, useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminFilterBar from '../../components/AdminFilterBar.vue'
|
||||
import AdminStatusBadge from '../../components/AdminStatusBadge.vue'
|
||||
import AdminDetailDrawer from '../../components/AdminDetailDrawer.vue'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { adminCreate, adminDelete, adminList, normalizeAdminList, normalizePagination } from '@/api'
|
||||
import { resolveMediaUrl } from '@/utils/request'
|
||||
import { getResourceSchema, pickField } from '../../config/resourceSchemas'
|
||||
|
||||
/** 已拆专用页的资源:进入通用 list 时自动跳转 */
|
||||
const DEDICATED: Record<string, string> = {
|
||||
posts: '/subPackages/admin/pages/posts/list',
|
||||
works: '/subPackages/admin/pages/works/list',
|
||||
videos: '/subPackages/admin/pages/videos/list',
|
||||
attachments: '/subPackages/admin/pages/attachments/index',
|
||||
inquiries: '/subPackages/admin/pages/inquiries/list',
|
||||
'oss-configs': '/subPackages/admin/pages/oss-configs/index',
|
||||
about: '/subPackages/admin/pages/about/index',
|
||||
settings: '/subPackages/admin/pages/settings/index',
|
||||
logs: '/subPackages/admin/pages/logs/operation',
|
||||
'access-logs': '/subPackages/admin/pages/logs/access',
|
||||
users: '/subPackages/admin/pages/users/list',
|
||||
}
|
||||
|
||||
const toast = useToast()
|
||||
const dialog = useDialog()
|
||||
const { isAuthenticated } = useAuth()
|
||||
const { canEnterAdmin } = useAppMode()
|
||||
|
||||
const resource = ref('')
|
||||
const loading = ref(false)
|
||||
const list = ref<Record<string, unknown>[]>([])
|
||||
const keyword = ref('')
|
||||
const filters = ref<Record<string, unknown>>({})
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const finished = ref(false)
|
||||
|
||||
const drawerShow = ref(false)
|
||||
const drawerRows = ref<{ label: string, value: string }[]>([])
|
||||
const inlineForm = reactive<Record<string, unknown>>({})
|
||||
const inlineOpen = ref(false)
|
||||
|
||||
const schema = computed(() => getResourceSchema(resource.value))
|
||||
const pageTitle = computed(() => schema.value.title)
|
||||
const showFab = computed(() => {
|
||||
if (!resource.value) return false
|
||||
if (schema.value.readonly || schema.value.noEdit) return false
|
||||
if (schema.value.inlineCreate) return false
|
||||
return true
|
||||
})
|
||||
const canDelete = computed(() => !schema.value.readonly)
|
||||
const canEdit = computed(() => !schema.value.readonly && !schema.value.noEdit)
|
||||
|
||||
function guardAdmin() {
|
||||
if (!isAuthenticated() || !canEnterAdmin.value) {
|
||||
uni.reLaunch({ url: '/pages/mine/index' })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function itemTitle(item: Record<string, unknown>) {
|
||||
return pickField(item, schema.value.titleKeys) || String(item.id || '未命名')
|
||||
}
|
||||
|
||||
function itemSubtitle(item: Record<string, unknown>) {
|
||||
return pickField(item, schema.value.subtitleKeys)
|
||||
}
|
||||
|
||||
function coverUrl(item: Record<string, unknown>) {
|
||||
const key = schema.value.coverKey
|
||||
if (!key) return ''
|
||||
return resolveMediaUrl(String(item[key] || ''))
|
||||
}
|
||||
|
||||
function matchLocal(item: Record<string, unknown>) {
|
||||
const kw = keyword.value.trim().toLowerCase()
|
||||
if (!kw) return true
|
||||
const keys = schema.value.localSearchKeys || schema.value.titleKeys || []
|
||||
return keys.some(k => String(item[k] ?? '').toLowerCase().includes(kw))
|
||||
}
|
||||
|
||||
function matchFilters(item: Record<string, unknown>) {
|
||||
for (const [k, v] of Object.entries(filters.value)) {
|
||||
if (v === '' || v == null) continue
|
||||
if (k === 'keyword') continue
|
||||
if (String(item[k] ?? '') !== String(v)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function load(reset = true) {
|
||||
if (!resource.value || !guardAdmin()) return
|
||||
if (reset) {
|
||||
page.value = 1
|
||||
finished.value = false
|
||||
list.value = []
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, unknown> = { ...filters.value }
|
||||
if (keyword.value) params.keyword = keyword.value
|
||||
if (schema.value.serverPaging) {
|
||||
params.page = page.value
|
||||
params.pageSize = schema.value.pageSize || pageSize.value
|
||||
}
|
||||
const data = await adminList(resource.value, params)
|
||||
if (schema.value.serverPaging) {
|
||||
const p = normalizePagination(data, page.value, schema.value.pageSize || pageSize.value)
|
||||
list.value = reset ? p.list : [...list.value, ...p.list]
|
||||
total.value = p.total
|
||||
finished.value = list.value.length >= p.total
|
||||
}
|
||||
else {
|
||||
let rows = normalizeAdminList(data)
|
||||
rows = rows.filter(matchLocal).filter(matchFilters)
|
||||
list.value = rows
|
||||
total.value = rows.length
|
||||
finished.value = true
|
||||
}
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((q) => {
|
||||
resource.value = String(q?.resource || '')
|
||||
const jump = DEDICATED[resource.value]
|
||||
if (jump) {
|
||||
uni.redirectTo({ url: jump })
|
||||
return
|
||||
}
|
||||
pageSize.value = schema.value.pageSize || 20
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
if (DEDICATED[resource.value]) return
|
||||
load(true)
|
||||
})
|
||||
|
||||
onReachBottom(() => {
|
||||
if (!schema.value.serverPaging || finished.value || loading.value) return
|
||||
page.value += 1
|
||||
load(false)
|
||||
})
|
||||
|
||||
const goCreate = () => {
|
||||
if (schema.value.inlineCreate) {
|
||||
Object.keys(inlineForm).forEach(k => delete inlineForm[k])
|
||||
schema.value.fields.forEach((f) => {
|
||||
inlineForm[f.key] = f.type === 'switch' ? 1 : f.type === 'number' ? 0 : ''
|
||||
})
|
||||
inlineOpen.value = true
|
||||
return
|
||||
}
|
||||
// 专用表单入口
|
||||
if (resource.value === 'video-albums') {
|
||||
uni.navigateTo({ url: '/subPackages/admin/pages/video-albums/form?mode=create' })
|
||||
return
|
||||
}
|
||||
if (resource.value === 'snippets') {
|
||||
uni.navigateTo({ url: '/subPackages/admin/pages/snippets/form?mode=create' })
|
||||
return
|
||||
}
|
||||
if (resource.value === 'columns') {
|
||||
uni.navigateTo({ url: '/subPackages/admin/pages/columns/form?mode=create' })
|
||||
return
|
||||
}
|
||||
uni.navigateTo({
|
||||
url: `/subPackages/admin/pages/resource/form?resource=${resource.value}&mode=create`,
|
||||
})
|
||||
}
|
||||
|
||||
const goEdit = (item: Record<string, unknown>) => {
|
||||
if (schema.value.readonly) {
|
||||
openDetail(item)
|
||||
return
|
||||
}
|
||||
if (!canEdit.value) {
|
||||
openDetail(item)
|
||||
return
|
||||
}
|
||||
// 视频专辑编辑走专用 form(含视频多选)
|
||||
if (resource.value === 'video-albums' && item.id != null) {
|
||||
uni.navigateTo({
|
||||
url: `/subPackages/admin/pages/video-albums/form?mode=edit&id=${item.id}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (resource.value === 'snippets' && item.id != null) {
|
||||
uni.navigateTo({
|
||||
url: `/subPackages/admin/pages/snippets/form?mode=edit&id=${item.id}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (resource.value === 'columns' && item.id != null) {
|
||||
uni.navigateTo({
|
||||
url: `/subPackages/admin/pages/columns/form?mode=edit&id=${item.id}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
const id = item.id
|
||||
if (id == null) return
|
||||
uni.navigateTo({
|
||||
url: `/subPackages/admin/pages/resource/form?resource=${resource.value}&mode=edit&id=${id}`,
|
||||
})
|
||||
}
|
||||
|
||||
function openDetail(item: Record<string, unknown>) {
|
||||
drawerRows.value = Object.entries(item)
|
||||
.filter(([k]) => !['password'].includes(k))
|
||||
.slice(0, 30)
|
||||
.map(([label, value]) => ({
|
||||
label,
|
||||
value: typeof value === 'object' ? JSON.stringify(value) : String(value ?? ''),
|
||||
}))
|
||||
drawerShow.value = true
|
||||
}
|
||||
|
||||
const onDelete = (item: Record<string, unknown>) => {
|
||||
if (!canDelete.value) return
|
||||
// 系统 PPT 模板禁止删除
|
||||
if (resource.value === 'ppt-templates' && item.isSystem) {
|
||||
toast.show('系统模板不可删除')
|
||||
return
|
||||
}
|
||||
const id = item.id
|
||||
if (id == null) return
|
||||
dialog.confirm({
|
||||
title: '确认删除',
|
||||
msg: `确定删除「${itemTitle(item)}」吗?`,
|
||||
}).then(async () => {
|
||||
try {
|
||||
await adminDelete(resource.value, id as string | number)
|
||||
toast.success('已删除')
|
||||
load(true)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
async function submitInline() {
|
||||
try {
|
||||
await adminCreate(resource.value, { ...inlineForm })
|
||||
toast.success('已创建')
|
||||
inlineOpen.value = false
|
||||
load(true)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '创建失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar :title="pageTitle" show-back />
|
||||
<view class="page-pad">
|
||||
<AdminFilterBar
|
||||
v-if="schema.filters?.length || schema.localSearchKeys?.length || schema.serverPaging"
|
||||
v-model:keyword="keyword"
|
||||
v-model="filters"
|
||||
:filters="schema.filters || []"
|
||||
@search="load(true)"
|
||||
@reset="load(true)"
|
||||
/>
|
||||
|
||||
<view v-if="schema.inlineCreate" class="inline-create admin-card">
|
||||
<wd-button size="small" type="primary" @click="goCreate">新增</wd-button>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-for="item in list"
|
||||
:key="String(item.id)"
|
||||
class="admin-card row"
|
||||
@click="goEdit(item)"
|
||||
>
|
||||
<image v-if="coverUrl(item)" class="cover" :src="coverUrl(item)" mode="aspectFill" />
|
||||
<view class="main">
|
||||
<view class="title-row">
|
||||
<text class="title">{{ itemTitle(item) }}</text>
|
||||
<AdminStatusBadge
|
||||
v-if="schema.statusKey"
|
||||
:value="item[schema.statusKey] as string | number | boolean"
|
||||
:map="schema.statusMap"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="itemSubtitle(item)" class="sub text-art-muted">{{ itemSubtitle(item) }}</text>
|
||||
</view>
|
||||
<wd-button
|
||||
v-if="canDelete"
|
||||
size="small"
|
||||
type="warning"
|
||||
plain
|
||||
@click.stop="onDelete(item)"
|
||||
>
|
||||
删除
|
||||
</wd-button>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
<wd-empty v-if="!loading && !list.length" description="暂无数据" />
|
||||
<text v-if="schema.serverPaging && !finished && list.length" class="more text-art-muted">上拉加载更多</text>
|
||||
</view>
|
||||
|
||||
<view v-if="showFab" class="fab" @click="goCreate">
|
||||
<wd-icon name="plus" size="24px" color="#fff" />
|
||||
</view>
|
||||
|
||||
<AdminDetailDrawer v-model:show="drawerShow" :title="pageTitle" :rows="drawerRows" />
|
||||
|
||||
<!-- 页内新建(邮箱后缀等) -->
|
||||
<AdminDetailDrawer v-model:show="inlineOpen" title="新建">
|
||||
<view v-for="f in schema.fields" :key="f.key" class="inline-field">
|
||||
<wd-input
|
||||
v-if="f.type === 'text' || f.type === 'number'"
|
||||
:model-value="String(inlineForm[f.key] ?? '')"
|
||||
:label="f.label"
|
||||
:placeholder="f.placeholder || f.label"
|
||||
clearable
|
||||
@update:model-value="(v: string | number) => inlineForm[f.key] = v"
|
||||
/>
|
||||
<view v-else-if="f.type === 'switch'" class="switch-row">
|
||||
<text>{{ f.label }}</text>
|
||||
<wd-switch :model-value="!!inlineForm[f.key]" @change="(v: boolean) => inlineForm[f.key] = v ? 1 : 0" />
|
||||
</view>
|
||||
</view>
|
||||
<template #footer>
|
||||
<wd-button block type="primary" @click="submitInline">保存</wd-button>
|
||||
</template>
|
||||
</AdminDetailDrawer>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 120rpx; }
|
||||
.inline-create { padding: 16rpx 24rpx; margin-bottom: 16rpx; }
|
||||
.row {
|
||||
padding: 24rpx 28rpx;
|
||||
margin-bottom: 16rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.cover {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
border-radius: 12rpx;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.main { flex: 1; display: flex; flex-direction: column; gap: 8rpx; min-width: 0; }
|
||||
.title-row { display: flex; align-items: center; gap: 12rpx; }
|
||||
.title {
|
||||
font-size: 30rpx;
|
||||
color: rgb(var(--art-text));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
.sub { font-size: 24rpx; }
|
||||
.more { display: block; text-align: center; font-size: 22rpx; padding: 16rpx; }
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 40rpx;
|
||||
bottom: calc(120rpx + env(safe-area-inset-bottom));
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 50%;
|
||||
background: rgb(var(--art-accent));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.35);
|
||||
z-index: 100;
|
||||
}
|
||||
.inline-field { margin-bottom: 12rpx; }
|
||||
.switch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16rpx 0;
|
||||
color: rgb(var(--art-text));
|
||||
}
|
||||
</style>
|
||||
162
src/subPackages/admin/pages/settings/index.vue
Normal file
162
src/subPackages/admin/pages/settings/index.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 全局配置:按 settingsSchema 分 Tab 编辑,分组保存。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { 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 { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import { batchUpdateSettings, getAdminSettings } from '@/api'
|
||||
import { MENU_OPTIONS, SETTING_GROUPS, SETTINGS_SCHEMA } from '../../config/settingsSchema'
|
||||
import AdminFormField from '../../components/AdminFormField.vue'
|
||||
|
||||
const toast = useToast()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const tab = ref(0)
|
||||
const values = ref<Record<string, string>>({})
|
||||
|
||||
const currentGroup = computed(() => SETTING_GROUPS[tab.value]?.id || 'site')
|
||||
const currentFields = computed(() => SETTINGS_SCHEMA.filter(f => f.group === currentGroup.value))
|
||||
|
||||
function parseMenus(raw: string): string[] {
|
||||
try {
|
||||
const arr = JSON.parse(raw || '[]')
|
||||
return Array.isArray(arr) ? arr.map(String) : []
|
||||
}
|
||||
catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMenu(key: string) {
|
||||
const list = parseMenus(values.value.visible_menus || '[]')
|
||||
const i = list.indexOf(key)
|
||||
if (i >= 0) list.splice(i, 1)
|
||||
else list.push(key)
|
||||
values.value.visible_menus = JSON.stringify(list)
|
||||
}
|
||||
|
||||
function normalizeSettings(data: unknown): Record<string, string> {
|
||||
const map: Record<string, string> = {}
|
||||
SETTINGS_SCHEMA.forEach((f) => { map[f.key] = f.default })
|
||||
if (Array.isArray(data)) {
|
||||
data.forEach((row: unknown) => {
|
||||
const r = row as { key?: string, keyName?: string, value?: string }
|
||||
const k = r.key || r.keyName
|
||||
if (k) map[k] = String(r.value ?? '')
|
||||
})
|
||||
}
|
||||
else if (data && typeof data === 'object') {
|
||||
Object.entries(data as Record<string, unknown>).forEach(([k, v]) => {
|
||||
map[k] = typeof v === 'string' ? v : JSON.stringify(v)
|
||||
})
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
values.value = normalizeSettings(await getAdminSettings())
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
|
||||
async function saveGroup() {
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload: Record<string, string> = {}
|
||||
currentFields.value.forEach((f) => {
|
||||
payload[f.key] = values.value[f.key] ?? f.default
|
||||
})
|
||||
await batchUpdateSettings(payload)
|
||||
toast.success('本组已保存')
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar title="全局配置" show-back />
|
||||
<view class="page-pad">
|
||||
<wd-tabs v-model="tab">
|
||||
<wd-tab v-for="g in SETTING_GROUPS" :key="g.id" :title="g.label" />
|
||||
</wd-tabs>
|
||||
<view class="admin-card form-card">
|
||||
<template v-for="f in currentFields" :key="f.key">
|
||||
<AdminFormField
|
||||
v-if="f.type === 'text' || f.type === 'email' || f.type === 'number'"
|
||||
:label="f.label"
|
||||
>
|
||||
<wd-input v-model="values[f.key]" :placeholder="f.label" clearable />
|
||||
</AdminFormField>
|
||||
<AdminFormField
|
||||
v-else-if="f.type === 'textarea' || f.type === 'homepage-json'"
|
||||
:label="f.label"
|
||||
>
|
||||
<wd-textarea
|
||||
v-model="values[f.key]"
|
||||
:placeholder="f.label"
|
||||
:rows="f.type === 'homepage-json' ? 10 : 3"
|
||||
/>
|
||||
</AdminFormField>
|
||||
<view v-else-if="f.type === 'menu-checkboxes'" class="menus">
|
||||
<text class="label text-art-muted">{{ f.label }}</text>
|
||||
<view class="tags">
|
||||
<view
|
||||
v-for="m in MENU_OPTIONS"
|
||||
:key="m.key"
|
||||
class="tag"
|
||||
:class="{ on: parseMenus(values.visible_menus || '').includes(m.key) }"
|
||||
@click="toggleMenu(m.key)"
|
||||
>
|
||||
{{ m.label }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
<wd-button
|
||||
block
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
custom-style="margin-top:24rpx;background:#d4b383;border-color:#d4b383;"
|
||||
@click="saveGroup"
|
||||
>
|
||||
保存本组
|
||||
</wd-button>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 80rpx; }
|
||||
.form-card { margin-top: 20rpx; }
|
||||
.menus { margin: 16rpx 0; }
|
||||
.label { font-size: 24rpx; }
|
||||
.tags { display: flex; flex-wrap: wrap; gap: 12rpx; margin-top: 12rpx; }
|
||||
.tag {
|
||||
padding: 10rpx 18rpx; border-radius: 8rpx; font-size: 24rpx;
|
||||
background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
.tag.on { background: rgba(212, 179, 131, 0.25); color: rgb(var(--art-accent)); }
|
||||
</style>
|
||||
162
src/subPackages/admin/pages/snippets/form.vue
Normal file
162
src/subPackages/admin/pages/snippets/form.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 代码片段表单:代码大文本 + 分类 + 关联文章多选。
|
||||
*/
|
||||
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 { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import { adminCreate, adminGet, adminList, adminUpdate, normalizeAdminList, type Post, type Snippet } from '@/api'
|
||||
|
||||
const toast = useToast()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
const mode = ref<'create' | 'edit'>('create')
|
||||
const id = ref('')
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const codeTypes = ref<{ id: number, name: string }[]>([])
|
||||
const posts = ref<Post[]>([])
|
||||
const selectedPosts = ref<number[]>([])
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
codeTypeId: '' as string | number,
|
||||
code: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const pageTitle = computed(() => (mode.value === 'create' ? '新建代码片段' : '编辑代码片段'))
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
codeTypes.value = normalizeAdminList(await adminList('code-types')) as { id: number, name: string }[]
|
||||
const postData = await adminList('posts', { page: 1, pageSize: 100 })
|
||||
posts.value = normalizeAdminList(postData) as Post[]
|
||||
if (mode.value === 'edit' && id.value) {
|
||||
const data = await adminGet('snippets', id.value) as Snippet
|
||||
form.title = String(data.title || '')
|
||||
form.codeTypeId = data.codeTypeId || ''
|
||||
form.code = String(data.code || '')
|
||||
form.description = String(data.description || '')
|
||||
selectedPosts.value = Array.isArray(data.postIds) ? data.postIds.map(Number) : []
|
||||
}
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((q) => {
|
||||
mode.value = q?.mode === 'edit' ? 'edit' : 'create'
|
||||
id.value = String(q?.id || '')
|
||||
})
|
||||
onShow(load)
|
||||
|
||||
function togglePost(pid: string | number) {
|
||||
const n = Number(pid)
|
||||
const i = selectedPosts.value.indexOf(n)
|
||||
if (i >= 0) selectedPosts.value.splice(i, 1)
|
||||
else selectedPosts.value.push(n)
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.title.trim()) {
|
||||
toast.show('请填写标题')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload = {
|
||||
title: form.title,
|
||||
codeTypeId: form.codeTypeId ? Number(form.codeTypeId) : undefined,
|
||||
code: form.code,
|
||||
description: form.description,
|
||||
postIds: selectedPosts.value,
|
||||
}
|
||||
if (mode.value === 'edit' && id.value) await adminUpdate('snippets', id.value, payload)
|
||||
else await adminCreate('snippets', payload)
|
||||
toast.success('已保存')
|
||||
setTimeout(() => uni.navigateBack(), 400)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function typeLabel() {
|
||||
return codeTypes.value.find(c => String(c.id) === String(form.codeTypeId))?.name || '选择分类'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar :title="pageTitle" show-back />
|
||||
<view class="page-pad">
|
||||
<view class="admin-card form-card">
|
||||
<wd-input v-model="form.title" label="标题" clearable />
|
||||
<wd-picker
|
||||
:columns="[codeTypes.map(c => ({ label: c.name, value: c.id }))]"
|
||||
@confirm="({ value }: { value: unknown[] }) => { form.codeTypeId = (value?.[0] as { value?: number })?.value ?? value?.[0] as number }"
|
||||
>
|
||||
<view class="picker-row"><text>分类</text><text class="val">{{ typeLabel() }}</text></view>
|
||||
</wd-picker>
|
||||
<wd-textarea v-model="form.code" label="代码" :rows="10" custom-class="code-area" />
|
||||
<wd-textarea v-model="form.description" label="描述" />
|
||||
<view class="section">
|
||||
<text class="label text-art-muted">关联文章</text>
|
||||
<view class="tag-wrap">
|
||||
<view
|
||||
v-for="p in posts"
|
||||
:key="String(p.id)"
|
||||
class="tag"
|
||||
:class="{ on: selectedPosts.includes(Number(p.id)) }"
|
||||
@click="togglePost(p.id)"
|
||||
>
|
||||
{{ p.title }}
|
||||
</view>
|
||||
</view>
|
||||
</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; }
|
||||
.form-card { padding: 24rpx; }
|
||||
.picker-row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 20rpx 0; color: rgb(var(--art-text));
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.val { color: rgba(255, 255, 255, 0.6); }
|
||||
.section { margin-top: 16rpx; }
|
||||
.label { font-size: 24rpx; }
|
||||
.tag-wrap { display: flex; flex-wrap: wrap; gap: 12rpx; margin-top: 12rpx; }
|
||||
.tag {
|
||||
padding: 8rpx 16rpx; border-radius: 8rpx; font-size: 22rpx;
|
||||
background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.65);
|
||||
max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.tag.on { background: rgba(212, 179, 131, 0.25); color: rgb(var(--art-accent)); }
|
||||
</style>
|
||||
144
src/subPackages/admin/pages/users/form.vue
Normal file
144
src/subPackages/admin/pages/users/form.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 用户表单:完整资料字段。
|
||||
*/
|
||||
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 { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import { adminCreate, adminGet, adminUpdate, type User } from '@/api'
|
||||
|
||||
const toast = useToast()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
const mode = ref<'create' | 'edit'>('create')
|
||||
const id = ref('')
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
avatar: '',
|
||||
role: 'editor',
|
||||
isActive: 1,
|
||||
bio: '',
|
||||
phone: '',
|
||||
wechat: '',
|
||||
wechatQrcode: '',
|
||||
})
|
||||
|
||||
const pageTitle = computed(() => (mode.value === 'create' ? '新建用户' : '编辑用户'))
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
if (mode.value !== 'edit' || !id.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await adminGet('users', id.value) as User
|
||||
Object.assign(form, {
|
||||
username: data.username || '',
|
||||
email: data.email || '',
|
||||
avatar: data.avatar || '',
|
||||
role: data.role || 'editor',
|
||||
isActive: data.isActive === 0 ? 0 : 1,
|
||||
bio: data.bio || '',
|
||||
phone: data.phone || '',
|
||||
wechat: data.wechat || '',
|
||||
wechatQrcode: data.wechatQrcode || '',
|
||||
})
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((q) => {
|
||||
mode.value = q?.mode === 'edit' ? 'edit' : 'create'
|
||||
id.value = String(q?.id || '')
|
||||
})
|
||||
onShow(load)
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.username.trim()) {
|
||||
toast.show('请填写用户名')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
if (mode.value === 'edit' && id.value) await adminUpdate('users', id.value, { ...form })
|
||||
else await adminCreate('users', { ...form })
|
||||
toast.success('已保存')
|
||||
setTimeout(() => uni.navigateBack(), 400)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar :title="pageTitle" show-back />
|
||||
<view class="page-pad">
|
||||
<view class="admin-card form-card">
|
||||
<AdminFormField label="用户名" required>
|
||||
<wd-input v-model="form.username" placeholder="用户名" clearable />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="邮箱">
|
||||
<wd-input v-model="form.email" placeholder="邮箱" clearable />
|
||||
</AdminFormField>
|
||||
<AdminMediaPicker v-model="form.avatar" label="头像" />
|
||||
<wd-picker
|
||||
:columns="[[{ label: 'admin', value: 'admin' }, { label: 'editor', value: 'editor' }, { label: 'viewer', value: 'viewer' }]]"
|
||||
@confirm="({ value }: { value: unknown[] }) => { form.role = String((value?.[0] as { value?: string })?.value ?? value?.[0] ?? 'editor') }"
|
||||
>
|
||||
<view class="picker-row"><text>角色</text><text class="val">{{ form.role }}</text></view>
|
||||
</wd-picker>
|
||||
<view class="switch-row">
|
||||
<text>启用</text>
|
||||
<wd-switch :model-value="!!form.isActive" @change="(v: boolean) => form.isActive = v ? 1 : 0" />
|
||||
</view>
|
||||
<AdminFormField label="简介">
|
||||
<wd-textarea v-model="form.bio" placeholder="简介" />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="手机">
|
||||
<wd-input v-model="form.phone" placeholder="手机" clearable />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="微信">
|
||||
<wd-input v-model="form.wechat" placeholder="微信" clearable />
|
||||
</AdminFormField>
|
||||
<AdminMediaPicker v-model="form.wechatQrcode" label="微信二维码" />
|
||||
<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; }
|
||||
.picker-row, .switch-row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 20rpx 0; color: rgb(var(--art-text));
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.val { color: rgba(255, 255, 255, 0.6); }
|
||||
</style>
|
||||
147
src/subPackages/admin/pages/users/list.vue
Normal file
147
src/subPackages/admin/pages/users/list.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 用户列表:服务端分页 + 角色/状态筛选。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { onReachBottom, onShow } from '@dcloudio/uni-app'
|
||||
import { useDialog, useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminFilterBar from '../../components/AdminFilterBar.vue'
|
||||
import AdminStatusBadge from '../../components/AdminStatusBadge.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import { adminDelete, adminList, normalizePagination, type User } from '@/api'
|
||||
import { resolveMediaUrl } from '@/utils/request'
|
||||
|
||||
const toast = useToast()
|
||||
const dialog = useDialog()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
const loading = ref(false)
|
||||
const list = ref<User[]>([])
|
||||
const keyword = ref('')
|
||||
const filters = ref<Record<string, unknown>>({})
|
||||
const page = ref(1)
|
||||
const finished = ref(false)
|
||||
|
||||
const filterDefs = [
|
||||
{ key: 'keyword', label: '关键词', type: 'text' as const },
|
||||
{
|
||||
key: 'role',
|
||||
label: '角色',
|
||||
type: 'select' as const,
|
||||
options: [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: 'admin', value: 'admin' },
|
||||
{ label: 'editor', value: 'editor' },
|
||||
{ label: 'viewer', value: 'viewer' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'isActive',
|
||||
label: '状态',
|
||||
type: 'select' as const,
|
||||
options: [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '停用', value: 0 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
async function load(reset = true) {
|
||||
if (!guardAdmin()) return
|
||||
if (reset) {
|
||||
page.value = 1
|
||||
finished.value = false
|
||||
list.value = []
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await adminList('users', {
|
||||
page: page.value,
|
||||
pageSize: 10,
|
||||
keyword: keyword.value || undefined,
|
||||
...filters.value,
|
||||
})
|
||||
const p = normalizePagination<User>(data, page.value, 10)
|
||||
list.value = reset ? p.list : [...list.value, ...p.list]
|
||||
finished.value = list.value.length >= p.total
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(() => load(true))
|
||||
onReachBottom(() => {
|
||||
if (finished.value || loading.value) return
|
||||
page.value += 1
|
||||
load(false)
|
||||
})
|
||||
|
||||
function goCreate() {
|
||||
uni.navigateTo({ url: '/subPackages/admin/pages/users/form?mode=create' })
|
||||
}
|
||||
function goEdit(u: User) {
|
||||
uni.navigateTo({ url: `/subPackages/admin/pages/users/form?mode=edit&id=${u.id}` })
|
||||
}
|
||||
function onDelete(u: User) {
|
||||
dialog.confirm({ title: '确认删除', msg: `确定删除用户「${u.username}」吗?` }).then(async () => {
|
||||
try {
|
||||
await adminDelete('users', u.id)
|
||||
toast.success('已删除')
|
||||
load(true)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar title="用户管理" show-back />
|
||||
<view class="page-pad">
|
||||
<AdminFilterBar
|
||||
v-model:keyword="keyword"
|
||||
v-model="filters"
|
||||
:filters="filterDefs"
|
||||
@search="load(true)"
|
||||
@reset="load(true)"
|
||||
/>
|
||||
<view v-for="item in list" :key="item.id" class="admin-card row" @click="goEdit(item)">
|
||||
<image class="avatar" :src="resolveMediaUrl(item.avatar) || '/static/logo.svg'" mode="aspectFill" />
|
||||
<view class="main">
|
||||
<view class="title-row">
|
||||
<text class="title">{{ item.username }}</text>
|
||||
<AdminStatusBadge :value="item.isActive" :map="{ '1': '启用', '0': '停用', true: '启用', false: '停用' }" />
|
||||
</view>
|
||||
<text class="sub text-art-muted">{{ item.email }} · {{ item.role }}</text>
|
||||
</view>
|
||||
<wd-button size="small" type="warning" plain @click.stop="onDelete(item)">删除</wd-button>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
<wd-empty v-if="!loading && !list.length" description="暂无用户" />
|
||||
</view>
|
||||
<view class="fab" @click="goCreate"><wd-icon name="plus" size="24px" color="#fff" /></view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 120rpx; }
|
||||
.row { padding: 24rpx 28rpx; margin-bottom: 16rpx; display: flex; align-items: center; gap: 16rpx; }
|
||||
.avatar { width: 80rpx; height: 80rpx; border-radius: 50%; background: rgba(255,255,255,0.06); }
|
||||
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.title-row { display: flex; align-items: center; gap: 12rpx; }
|
||||
.title { flex: 1; font-size: 30rpx; color: rgb(var(--art-text)); }
|
||||
.sub { font-size: 24rpx; }
|
||||
.fab {
|
||||
position: fixed; right: 40rpx; bottom: calc(40rpx + env(safe-area-inset-bottom));
|
||||
width: 96rpx; height: 96rpx; border-radius: 50%;
|
||||
background: rgb(var(--art-accent)); display: flex; align-items: center; justify-content: center; z-index: 100;
|
||||
}
|
||||
</style>
|
||||
187
src/subPackages/admin/pages/video-albums/form.vue
Normal file
187
src/subPackages/admin/pages/video-albums/form.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 视频专辑表单:基础字段 + 编辑态视频多选。
|
||||
*/
|
||||
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 { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import {
|
||||
adminCreate,
|
||||
adminGet,
|
||||
adminList,
|
||||
adminUpdate,
|
||||
getAlbumVideoIds,
|
||||
normalizeAdminList,
|
||||
type VideoAlbum,
|
||||
type VideoItem,
|
||||
} from '@/api'
|
||||
|
||||
const toast = useToast()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
const mode = ref<'create' | 'edit'>('create')
|
||||
const id = ref('')
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const categories = ref<{ id: number, name: string }[]>([])
|
||||
const videos = ref<VideoItem[]>([])
|
||||
const selectedIds = ref<number[]>([])
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
categoryId: '' as string | number,
|
||||
cover: '',
|
||||
description: '',
|
||||
sortOrder: 0,
|
||||
isActive: 1,
|
||||
})
|
||||
|
||||
const pageTitle = computed(() => (mode.value === 'create' ? '新建专辑' : '编辑专辑'))
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
categories.value = normalizeAdminList(await adminList('video-categories')) as { id: number, name: string }[]
|
||||
videos.value = normalizeAdminList(await adminList('videos')) as VideoItem[]
|
||||
if (mode.value === 'edit' && id.value) {
|
||||
const data = await adminGet('video-albums', id.value) as VideoAlbum
|
||||
form.name = String(data.name || '')
|
||||
form.categoryId = data.categoryId || ''
|
||||
form.cover = String(data.cover || '')
|
||||
form.description = String(data.description || '')
|
||||
form.sortOrder = Number(data.sortOrder || 0)
|
||||
form.isActive = data.isActive === 0 || data.isActive === false ? 0 : 1
|
||||
try {
|
||||
const ids = await getAlbumVideoIds(id.value)
|
||||
selectedIds.value = Array.isArray(ids) ? ids.map(Number) : []
|
||||
}
|
||||
catch {
|
||||
selectedIds.value = Array.isArray(data.videoIds) ? data.videoIds.map(Number) : []
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((q) => {
|
||||
mode.value = q?.mode === 'edit' ? 'edit' : 'create'
|
||||
id.value = String(q?.id || '')
|
||||
})
|
||||
onShow(load)
|
||||
|
||||
function toggleVideo(vid: string | number) {
|
||||
const n = Number(vid)
|
||||
const i = selectedIds.value.indexOf(n)
|
||||
if (i >= 0) selectedIds.value.splice(i, 1)
|
||||
else selectedIds.value.push(n)
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.name.trim()) {
|
||||
toast.show('请填写名称')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name,
|
||||
categoryId: form.categoryId ? Number(form.categoryId) : undefined,
|
||||
cover: form.cover,
|
||||
description: form.description,
|
||||
sortOrder: Number(form.sortOrder || 0),
|
||||
isActive: form.isActive,
|
||||
videoIds: selectedIds.value,
|
||||
}
|
||||
if (mode.value === 'edit' && id.value) await adminUpdate('video-albums', id.value, payload)
|
||||
else await adminCreate('video-albums', payload)
|
||||
toast.success('已保存')
|
||||
setTimeout(() => uni.navigateBack(), 400)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function catLabel() {
|
||||
return categories.value.find(c => String(c.id) === String(form.categoryId))?.name || '选择分类'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar :title="pageTitle" show-back />
|
||||
<view class="page-pad">
|
||||
<view class="admin-card form-card">
|
||||
<wd-input v-model="form.name" label="名称" clearable />
|
||||
<wd-picker
|
||||
:columns="[categories.map(c => ({ label: c.name, value: c.id }))]"
|
||||
@confirm="({ value }: { value: unknown[] }) => { form.categoryId = (value?.[0] as { value?: number })?.value ?? value?.[0] as number }"
|
||||
>
|
||||
<view class="picker-row"><text>分类</text><text class="val">{{ catLabel() }}</text></view>
|
||||
</wd-picker>
|
||||
<AdminMediaPicker v-model="form.cover" label="封面" />
|
||||
<wd-input v-model="form.sortOrder" label="排序" type="number" />
|
||||
<view class="switch-row">
|
||||
<text>启用</text>
|
||||
<wd-switch :model-value="!!form.isActive" @change="(v: boolean) => form.isActive = v ? 1 : 0" />
|
||||
</view>
|
||||
<wd-textarea v-model="form.description" label="描述" />
|
||||
<view v-if="mode === 'edit'" class="section">
|
||||
<text class="label text-art-muted">关联视频 ({{ selectedIds.length }})</text>
|
||||
<view class="tag-wrap">
|
||||
<view
|
||||
v-for="v in videos"
|
||||
:key="String(v.id)"
|
||||
class="tag"
|
||||
:class="{ on: selectedIds.includes(Number(v.id)) }"
|
||||
@click="toggleVideo(v.id)"
|
||||
>
|
||||
{{ v.title }}
|
||||
</view>
|
||||
</view>
|
||||
</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; }
|
||||
.form-card { padding: 24rpx; }
|
||||
.picker-row, .switch-row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 20rpx 0; color: rgb(var(--art-text));
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.val { color: rgba(255, 255, 255, 0.6); }
|
||||
.section { margin-top: 16rpx; }
|
||||
.label { font-size: 24rpx; }
|
||||
.tag-wrap { display: flex; flex-wrap: wrap; gap: 12rpx; margin-top: 12rpx; }
|
||||
.tag {
|
||||
padding: 8rpx 16rpx; border-radius: 8rpx; font-size: 22rpx;
|
||||
background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
.tag.on { background: rgba(212, 179, 131, 0.25); color: rgb(var(--art-accent)); }
|
||||
</style>
|
||||
174
src/subPackages/admin/pages/videos/form.vue
Normal file
174
src/subPackages/admin/pages/videos/form.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 视频表单:分类/专辑多选/封面/播放地址/发布状态。
|
||||
*/
|
||||
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 { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import { adminCreate, adminGet, adminList, adminUpdate, normalizeAdminList, type VideoItem } from '@/api'
|
||||
|
||||
const toast = useToast()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
|
||||
const mode = ref<'create' | 'edit'>('create')
|
||||
const id = ref('')
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const categories = ref<{ id: number, name: string }[]>([])
|
||||
const albums = ref<{ id: number, name: string }[]>([])
|
||||
const selectedAlbums = ref<number[]>([])
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
categoryId: '' as string | number,
|
||||
videoUrl: '',
|
||||
cover: '',
|
||||
description: '',
|
||||
isPublished: 1,
|
||||
})
|
||||
|
||||
const pageTitle = computed(() => (mode.value === 'create' ? '新建视频' : '编辑视频'))
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
categories.value = normalizeAdminList(await adminList('video-categories')) as { id: number, name: string }[]
|
||||
albums.value = normalizeAdminList(await adminList('video-albums')) as { id: number, name: string }[]
|
||||
if (mode.value === 'edit' && id.value) {
|
||||
const data = await adminGet('videos', id.value) as VideoItem
|
||||
form.title = String(data.title || '')
|
||||
form.categoryId = data.categoryId || ''
|
||||
form.videoUrl = String(data.videoUrl || data.url || '')
|
||||
form.cover = String(data.cover || data.poster || '')
|
||||
form.description = String(data.description || '')
|
||||
form.isPublished = data.isPublished === 0 || data.isPublished === false ? 0 : 1
|
||||
selectedAlbums.value = Array.isArray(data.albumIds) ? data.albumIds.map(Number) : []
|
||||
}
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((q) => {
|
||||
mode.value = q?.mode === 'edit' ? 'edit' : 'create'
|
||||
id.value = String(q?.id || '')
|
||||
})
|
||||
onShow(load)
|
||||
|
||||
function toggleAlbum(aid: number) {
|
||||
const i = selectedAlbums.value.indexOf(aid)
|
||||
if (i >= 0) selectedAlbums.value.splice(i, 1)
|
||||
else selectedAlbums.value.push(aid)
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.title.trim()) {
|
||||
toast.show('请填写标题')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload = {
|
||||
title: form.title,
|
||||
categoryId: form.categoryId ? Number(form.categoryId) : undefined,
|
||||
videoUrl: form.videoUrl,
|
||||
cover: form.cover,
|
||||
poster: form.cover,
|
||||
description: form.description,
|
||||
isPublished: form.isPublished,
|
||||
albumIds: selectedAlbums.value,
|
||||
}
|
||||
if (mode.value === 'edit' && id.value) await adminUpdate('videos', id.value, payload)
|
||||
else await adminCreate('videos', payload)
|
||||
toast.success('已保存')
|
||||
setTimeout(() => uni.navigateBack(), 400)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function catLabel() {
|
||||
return categories.value.find(c => String(c.id) === String(form.categoryId))?.name || '选择分类'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar :title="pageTitle" show-back />
|
||||
<view class="page-pad">
|
||||
<view class="admin-card form-card">
|
||||
<wd-input v-model="form.title" label="标题" clearable />
|
||||
<wd-picker
|
||||
:columns="[categories.map(c => ({ label: c.name, value: c.id }))]"
|
||||
@confirm="({ value }: { value: unknown[] }) => { form.categoryId = (value?.[0] as { value?: number })?.value ?? value?.[0] as number }"
|
||||
>
|
||||
<view class="picker-row"><text>分类</text><text class="val">{{ catLabel() }}</text></view>
|
||||
</wd-picker>
|
||||
<AdminMediaPicker v-model="form.cover" label="封面" />
|
||||
<AdminMediaPicker v-model="form.videoUrl" label="视频文件" media-type="video" />
|
||||
<wd-input v-model="form.videoUrl" label="或填写视频 URL" clearable />
|
||||
<wd-textarea v-model="form.description" label="描述" />
|
||||
<view class="switch-row">
|
||||
<text>发布</text>
|
||||
<wd-switch :model-value="!!form.isPublished" @change="(v: boolean) => form.isPublished = v ? 1 : 0" />
|
||||
</view>
|
||||
<view class="section">
|
||||
<text class="label text-art-muted">所属专辑</text>
|
||||
<view class="tag-wrap">
|
||||
<view
|
||||
v-for="a in albums"
|
||||
:key="a.id"
|
||||
class="tag"
|
||||
:class="{ on: selectedAlbums.includes(a.id) }"
|
||||
@click="toggleAlbum(a.id)"
|
||||
>
|
||||
{{ a.name }}
|
||||
</view>
|
||||
</view>
|
||||
</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; }
|
||||
.form-card { padding: 24rpx; }
|
||||
.picker-row, .switch-row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 20rpx 0; color: rgb(var(--art-text));
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.val { color: rgba(255, 255, 255, 0.6); font-size: 26rpx; }
|
||||
.section { margin: 16rpx 0; }
|
||||
.label { font-size: 24rpx; }
|
||||
.tag-wrap { display: flex; flex-wrap: wrap; gap: 12rpx; margin-top: 12rpx; }
|
||||
.tag {
|
||||
padding: 8rpx 18rpx; border-radius: 8rpx; font-size: 24rpx;
|
||||
background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
.tag.on { background: rgba(212, 179, 131, 0.25); color: rgb(var(--art-accent)); }
|
||||
</style>
|
||||
92
src/subPackages/admin/pages/videos/list.vue
Normal file
92
src/subPackages/admin/pages/videos/list.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 视频列表:标题/分类/发布状态。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { useDialog, useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminStatusBadge from '../../components/AdminStatusBadge.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import { adminDelete, adminList, normalizeAdminList, type VideoItem } from '@/api'
|
||||
|
||||
const toast = useToast()
|
||||
const dialog = useDialog()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
const loading = ref(false)
|
||||
const list = ref<VideoItem[]>([])
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
list.value = normalizeAdminList(await adminList('videos')) as VideoItem[]
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
|
||||
function goCreate() {
|
||||
uni.navigateTo({ url: '/subPackages/admin/pages/videos/form?mode=create' })
|
||||
}
|
||||
function goEdit(item: VideoItem) {
|
||||
uni.navigateTo({ url: `/subPackages/admin/pages/videos/form?mode=edit&id=${item.id}` })
|
||||
}
|
||||
function onDelete(item: VideoItem) {
|
||||
dialog.confirm({ title: '确认删除', msg: `确定删除「${item.title}」吗?` }).then(async () => {
|
||||
try {
|
||||
await adminDelete('videos', item.id)
|
||||
toast.success('已删除')
|
||||
load()
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar title="视频列表" show-back />
|
||||
<view class="page-pad">
|
||||
<view v-for="item in list" :key="String(item.id)" class="admin-card row" @click="goEdit(item)">
|
||||
<view class="main">
|
||||
<view class="title-row">
|
||||
<text class="title">{{ item.title }}</text>
|
||||
<AdminStatusBadge
|
||||
:value="item.isPublished"
|
||||
:map="{ '1': '已发布', '0': '草稿', true: '已发布', false: '草稿' }"
|
||||
/>
|
||||
</view>
|
||||
<text class="sub text-art-muted">{{ item.categoryName || '未分类' }} · {{ item.createdAt || '' }}</text>
|
||||
</view>
|
||||
<wd-button size="small" type="warning" plain @click.stop="onDelete(item)">删除</wd-button>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
<wd-empty v-if="!loading && !list.length" description="暂无视频" />
|
||||
</view>
|
||||
<view class="fab" @click="goCreate"><wd-icon name="plus" size="24px" color="#fff" /></view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 120rpx; }
|
||||
.row { padding: 24rpx 28rpx; margin-bottom: 16rpx; display: flex; align-items: center; gap: 16rpx; }
|
||||
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.title-row { display: flex; align-items: center; gap: 12rpx; }
|
||||
.title { flex: 1; font-size: 30rpx; color: rgb(var(--art-text)); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sub { font-size: 24rpx; }
|
||||
.fab {
|
||||
position: fixed; right: 40rpx; bottom: calc(40rpx + env(safe-area-inset-bottom));
|
||||
width: 96rpx; height: 96rpx; border-radius: 50%;
|
||||
background: rgb(var(--art-accent)); display: flex; align-items: center; justify-content: center; z-index: 100;
|
||||
}
|
||||
</style>
|
||||
211
src/subPackages/admin/pages/works/form.vue
Normal file
211
src/subPackages/admin/pages/works/form.vue
Normal file
@@ -0,0 +1,211 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 作品表单:封面/图集/技术栈/外链/演示视频。
|
||||
*/
|
||||
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 { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import { adminCreate, adminGet, adminUpdate, type Work, type WorkTechGroup } from '@/api'
|
||||
import { resolveMediaUrl } from '@/utils/request'
|
||||
import { uploadAttachment } from '@/api'
|
||||
|
||||
const toast = useToast()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
|
||||
const mode = ref<'create' | 'edit'>('create')
|
||||
const id = ref('')
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
category: '',
|
||||
year: '',
|
||||
heroImg: '',
|
||||
heroVideo: '',
|
||||
desc: '',
|
||||
live: '',
|
||||
github: '',
|
||||
demo: '',
|
||||
})
|
||||
const techStack = ref<WorkTechGroup[]>([{ category: 'Frontend', items: [] }])
|
||||
const gallery = ref<string[]>([])
|
||||
const techInput = ref('')
|
||||
|
||||
const pageTitle = computed(() => (mode.value === 'create' ? '新建作品' : '编辑作品'))
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
if (mode.value !== 'edit' || !id.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await adminGet('works', id.value) as Work
|
||||
form.title = String(data.title || '')
|
||||
form.category = String(data.category || '')
|
||||
form.year = String(data.year || '')
|
||||
form.heroImg = String(data.heroImg || '')
|
||||
form.heroVideo = String(data.heroVideo || data.videoUrl || '')
|
||||
form.desc = String(data.desc || data.description || '')
|
||||
const links = typeof data.links === 'string'
|
||||
? (() => { try { return JSON.parse(data.links) } catch { return {} } })()
|
||||
: (data.links || {})
|
||||
form.live = String((links as Record<string, string>).live || '')
|
||||
form.github = String((links as Record<string, string>).github || '')
|
||||
form.demo = String((links as Record<string, string>).demo || '')
|
||||
techStack.value = Array.isArray(data.techStack) && data.techStack.length
|
||||
? data.techStack
|
||||
: [{ category: 'Frontend', items: [] }]
|
||||
gallery.value = Array.isArray(data.gallery) ? [...data.gallery] : []
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((q) => {
|
||||
mode.value = q?.mode === 'edit' ? 'edit' : 'create'
|
||||
id.value = String(q?.id || '')
|
||||
})
|
||||
onShow(load)
|
||||
|
||||
function addTechTag() {
|
||||
const t = techInput.value.trim()
|
||||
if (!t) return
|
||||
if (!techStack.value[0]) techStack.value = [{ category: 'Frontend', items: [] }]
|
||||
techStack.value[0].items = [...(techStack.value[0].items || []), t]
|
||||
techInput.value = ''
|
||||
}
|
||||
|
||||
function removeTech(i: number) {
|
||||
techStack.value[0]?.items?.splice(i, 1)
|
||||
}
|
||||
|
||||
async function addGallery() {
|
||||
if (gallery.value.length >= 20) {
|
||||
toast.show('最多 20 张')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await uni.chooseImage({ count: Math.min(9, 20 - gallery.value.length), sizeType: ['compressed'] })
|
||||
for (const path of res.tempFilePaths || []) {
|
||||
const up = await uploadAttachment(path)
|
||||
const url = up.fileUrl || up.url
|
||||
if (url) gallery.value.push(url)
|
||||
}
|
||||
}
|
||||
catch (e: unknown) {
|
||||
if (e && typeof e === 'object' && 'errMsg' in e && String((e as { errMsg: string }).errMsg).includes('cancel')) return
|
||||
toast.error(e instanceof Error ? e.message : '上传失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.title.trim()) {
|
||||
toast.show('请填写标题')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload = {
|
||||
title: form.title,
|
||||
category: form.category,
|
||||
year: form.year,
|
||||
heroImg: form.heroImg,
|
||||
heroVideo: form.heroVideo,
|
||||
desc: form.desc,
|
||||
techStack: techStack.value,
|
||||
gallery: gallery.value,
|
||||
links: { live: form.live, github: form.github, demo: form.demo },
|
||||
}
|
||||
if (mode.value === 'edit' && id.value) await adminUpdate('works', id.value, payload)
|
||||
else await adminCreate('works', payload)
|
||||
toast.success('已保存')
|
||||
setTimeout(() => uni.navigateBack(), 400)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar :title="pageTitle" show-back />
|
||||
<view class="page-pad">
|
||||
<view class="admin-card form-card">
|
||||
<wd-input v-model="form.title" label="标题" clearable />
|
||||
<wd-input v-model="form.category" label="分类" clearable />
|
||||
<wd-input v-model="form.year" label="年份" clearable />
|
||||
<AdminMediaPicker v-model="form.heroImg" label="封面图" />
|
||||
<AdminMediaPicker v-model="form.heroVideo" label="演示视频" media-type="video" />
|
||||
<wd-textarea v-model="form.desc" label="描述" />
|
||||
<view class="section">
|
||||
<text class="label text-art-muted">技术栈</text>
|
||||
<view class="tag-wrap">
|
||||
<view v-for="(t, i) in techStack[0]?.items || []" :key="i" class="tag" @click="removeTech(i)">{{ t }} ×</view>
|
||||
</view>
|
||||
<view class="row-input">
|
||||
<wd-input v-model="techInput" placeholder="添加技术标签" clearable />
|
||||
<wd-button size="small" @click="addTechTag">添加</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
<view class="section">
|
||||
<text class="label text-art-muted">图集 ({{ gallery.length }}/20)</text>
|
||||
<view class="gallery">
|
||||
<image v-for="(g, i) in gallery" :key="i" class="g-img" :src="resolveMediaUrl(g)" mode="aspectFill" @click="gallery.splice(i, 1)" />
|
||||
<view class="g-add" @click="addGallery">+</view>
|
||||
</view>
|
||||
</view>
|
||||
<wd-input v-model="form.live" label="线上地址" clearable />
|
||||
<wd-input v-model="form.github" label="GitHub" clearable />
|
||||
<wd-input v-model="form.demo" label="Demo" clearable />
|
||||
<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; }
|
||||
.form-card { padding: 24rpx; }
|
||||
.section { margin: 20rpx 0; }
|
||||
.label { font-size: 24rpx; }
|
||||
.tag-wrap { display: flex; flex-wrap: wrap; gap: 10rpx; margin: 12rpx 0; }
|
||||
.tag {
|
||||
padding: 6rpx 14rpx;
|
||||
border-radius: 8rpx;
|
||||
background: rgba(212, 179, 131, 0.2);
|
||||
color: rgb(var(--art-accent));
|
||||
font-size: 22rpx;
|
||||
}
|
||||
.row-input { display: flex; gap: 12rpx; align-items: center; }
|
||||
.gallery { display: flex; flex-wrap: wrap; gap: 12rpx; margin-top: 12rpx; }
|
||||
.g-img, .g-add {
|
||||
width: 140rpx; height: 140rpx; border-radius: 12rpx;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.g-add {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 48rpx; color: rgba(255, 255, 255, 0.4);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
</style>
|
||||
126
src/subPackages/admin/pages/works/list.vue
Normal file
126
src/subPackages/admin/pages/works/list.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 作品列表:本地关键词 + 分类/年份筛选。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { useDialog, useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminFilterBar from '../../components/AdminFilterBar.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import { adminDelete, adminList, normalizeAdminList, type Work } from '@/api'
|
||||
|
||||
const toast = useToast()
|
||||
const dialog = useDialog()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
|
||||
const loading = ref(false)
|
||||
const all = ref<Work[]>([])
|
||||
const keyword = ref('')
|
||||
const category = ref('')
|
||||
const year = ref('')
|
||||
|
||||
const categories = computed(() => {
|
||||
const set = new Set(all.value.map(w => String(w.category || '')).filter(Boolean))
|
||||
return Array.from(set)
|
||||
})
|
||||
const years = computed(() => {
|
||||
const set = new Set(all.value.map(w => String(w.year || '')).filter(Boolean))
|
||||
return Array.from(set).sort().reverse()
|
||||
})
|
||||
|
||||
const list = computed(() => {
|
||||
const kw = keyword.value.trim().toLowerCase()
|
||||
return all.value.filter((w) => {
|
||||
if (kw && !String(w.title || '').toLowerCase().includes(kw)) return false
|
||||
if (category.value && String(w.category) !== category.value) return false
|
||||
if (year.value && String(w.year) !== year.value) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
all.value = normalizeAdminList(await adminList('works')) as Work[]
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
|
||||
function goCreate() {
|
||||
uni.navigateTo({ url: '/subPackages/admin/pages/works/form?mode=create' })
|
||||
}
|
||||
function goEdit(item: Work) {
|
||||
uni.navigateTo({ url: `/subPackages/admin/pages/works/form?mode=edit&id=${item.id}` })
|
||||
}
|
||||
function onDelete(item: Work) {
|
||||
dialog.confirm({ title: '确认删除', msg: `确定删除「${item.title}」吗?` }).then(async () => {
|
||||
try {
|
||||
await adminDelete('works', item.id)
|
||||
toast.success('已删除')
|
||||
load()
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar title="作品管理" show-back />
|
||||
<view class="page-pad">
|
||||
<AdminFilterBar v-model:keyword="keyword" placeholder="搜索作品" @search="() => {}" @reset="keyword = ''; category = ''; year = ''" />
|
||||
<view class="chips">
|
||||
<view class="chip" :class="{ on: !category }" @click="category = ''">全部分类</view>
|
||||
<view v-for="c in categories" :key="c" class="chip" :class="{ on: category === c }" @click="category = c">{{ c }}</view>
|
||||
</view>
|
||||
<view class="chips">
|
||||
<view class="chip" :class="{ on: !year }" @click="year = ''">全部年份</view>
|
||||
<view v-for="y in years" :key="y" class="chip" :class="{ on: year === y }" @click="year = y">{{ y }}</view>
|
||||
</view>
|
||||
<view v-for="item in list" :key="String(item.id)" class="admin-card row" @click="goEdit(item)">
|
||||
<view class="main">
|
||||
<text class="title">{{ item.title }}</text>
|
||||
<text class="sub text-art-muted">{{ item.category || '—' }} · {{ item.year || '—' }}</text>
|
||||
</view>
|
||||
<wd-button size="small" type="warning" plain @click.stop="onDelete(item)">删除</wd-button>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
<wd-empty v-if="!loading && !list.length" description="暂无作品" />
|
||||
</view>
|
||||
<view class="fab" @click="goCreate"><wd-icon name="plus" size="24px" color="#fff" /></view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 120rpx; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 12rpx; margin-bottom: 16rpx; }
|
||||
.chip {
|
||||
padding: 8rpx 18rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 22rpx;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
.chip.on { background: rgba(212, 179, 131, 0.25); color: rgb(var(--art-accent)); }
|
||||
.row { padding: 24rpx 28rpx; margin-bottom: 16rpx; display: flex; align-items: center; gap: 16rpx; }
|
||||
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.title { font-size: 30rpx; color: rgb(var(--art-text)); }
|
||||
.sub { font-size: 24rpx; }
|
||||
.fab {
|
||||
position: fixed; right: 40rpx; bottom: calc(40rpx + env(safe-area-inset-bottom));
|
||||
width: 96rpx; height: 96rpx; border-radius: 50%;
|
||||
background: rgb(var(--art-accent)); display: flex; align-items: center; justify-content: center; z-index: 100;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user