1. 小程序端

2. 视频优化
This commit is contained in:
李琦
2026-07-30 14:11:51 +08:00
parent f36c2ae71e
commit fe5a5b6c0f
93 changed files with 12430 additions and 0 deletions

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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 }
}

View 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 ''
}

View 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: '' },
]

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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![](${url})\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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>