1. 小程序端
2. 视频优化
This commit is contained in:
161
src/subPackages/admin/components/AdminBarChart.vue
Normal file
161
src/subPackages/admin/components/AdminBarChart.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 后台柱状图:金色柱 + 暗色网格,用于热门文章等排行。
|
||||
* 与折线图相同:首帧宽度可能为 0,需原生宽高 + 重试,否则小程序上空白。
|
||||
* getCurrentInstance 仅在 setup/同步生命周期有效,必须在此处缓存 proxy,
|
||||
* 否则 nextTick/setTimeout 里 .in(null) 会抛 $scope of null。
|
||||
*/
|
||||
import { getCurrentInstance, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
categories?: string[]
|
||||
values?: number[]
|
||||
height?: number
|
||||
}>(), {
|
||||
categories: () => [],
|
||||
values: () => [],
|
||||
height: 220,
|
||||
})
|
||||
|
||||
/** setup 阶段同步缓存,供异步 draw 使用 */
|
||||
const compProxy = getCurrentInstance()?.proxy as never
|
||||
const canvasId = `bar-${Math.random().toString(36).slice(2, 8)}`
|
||||
/** 原生 canvas 缓冲像素宽(含 dpr) */
|
||||
const canvasW = ref(300)
|
||||
const canvasH = ref(220)
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let retryCount = 0
|
||||
const MAX_RETRY = 5
|
||||
|
||||
/** 测量失败时用窗口宽度减去边距估算逻辑宽度;优先 getWindowInfo */
|
||||
function fallbackWidth(): number {
|
||||
try {
|
||||
const win = typeof uni.getWindowInfo === 'function' ? uni.getWindowInfo() : null
|
||||
if (win?.windowWidth) return Math.max(200, win.windowWidth - 48)
|
||||
const sys = uni.getSystemInfoSync()
|
||||
return Math.max(200, (sys.windowWidth || 375) - 48)
|
||||
} catch {
|
||||
return 300
|
||||
}
|
||||
}
|
||||
|
||||
function getDpr(): number {
|
||||
try {
|
||||
const device = typeof uni.getDeviceInfo === 'function' ? uni.getDeviceInfo() : null
|
||||
if (device && 'pixelRatio' in device && Number(device.pixelRatio)) {
|
||||
return Number(device.pixelRatio)
|
||||
}
|
||||
return uni.getSystemInfoSync().pixelRatio || 2
|
||||
} catch {
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建选择器:有组件实例则限定在组件内,否则页面级查询(canvasId 已全局唯一)。
|
||||
* 禁止对 null 调用 .in(),否则 mp-weixin 读 $scope 直接报错。
|
||||
*/
|
||||
function createQuery() {
|
||||
const q = uni.createSelectorQuery()
|
||||
if (compProxy) return q.in(compProxy)
|
||||
return q
|
||||
}
|
||||
|
||||
/** 在逻辑像素坐标下绘制柱状图 */
|
||||
function paint(ctx: UniApp.CanvasContext, w: number, h: number, dpr: number) {
|
||||
const cats = props.categories
|
||||
const vals = props.values
|
||||
ctx.scale(dpr, dpr)
|
||||
const padL = 12
|
||||
const padR = 12
|
||||
const padT = 16
|
||||
const padB = 40
|
||||
const plotW = w - padL - padR
|
||||
const plotH = h - padT - padB
|
||||
const max = Math.max(1, ...vals)
|
||||
const n = cats.length
|
||||
const gap = 8
|
||||
const barW = Math.max(8, (plotW - gap * (n + 1)) / n)
|
||||
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
ctx.setStrokeStyle('rgba(255,255,255,0.08)')
|
||||
for (let i = 0; i <= 3; i++) {
|
||||
const y = padT + (plotH * i) / 3
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(padL, y)
|
||||
ctx.lineTo(w - padR, y)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
vals.forEach((v, i) => {
|
||||
const bh = (v / max) * plotH
|
||||
const x = padL + gap + i * (barW + gap)
|
||||
const y = padT + plotH - bh
|
||||
ctx.setFillStyle('rgba(212,179,131,0.85)')
|
||||
ctx.fillRect(x, y, barW, bh)
|
||||
ctx.setFillStyle('rgba(136,136,136,1)')
|
||||
ctx.setFontSize(9)
|
||||
ctx.fillText(String(cats[i] || '').slice(0, 4), x, h - 12)
|
||||
})
|
||||
ctx.draw()
|
||||
}
|
||||
|
||||
/**
|
||||
* 测量并绘制;宽度为 0 时 fallback + 短延迟重试;
|
||||
* 先写原生宽高再 nextTick 绘制。
|
||||
*/
|
||||
const draw = (isRetry = false) => {
|
||||
const cats = props.categories
|
||||
const vals = props.values
|
||||
if (!cats.length || !vals.length) return
|
||||
if (!isRetry) retryCount = 0
|
||||
|
||||
createQuery()
|
||||
.select(`#${canvasId}`)
|
||||
.boundingClientRect((rect) => {
|
||||
const box = Array.isArray(rect) ? rect[0] : rect
|
||||
let w = box?.width || 0
|
||||
const measuredOk = !!w
|
||||
if (!w) w = fallbackWidth()
|
||||
if (!measuredOk && retryCount < MAX_RETRY) {
|
||||
retryCount += 1
|
||||
if (retryTimer) clearTimeout(retryTimer)
|
||||
retryTimer = setTimeout(() => draw(true), 80)
|
||||
} else if (measuredOk) {
|
||||
retryCount = 0
|
||||
}
|
||||
if (!w) return
|
||||
|
||||
const h = props.height
|
||||
const dpr = getDpr()
|
||||
canvasW.value = Math.floor(w * dpr)
|
||||
canvasH.value = Math.floor(h * dpr)
|
||||
nextTick(() => {
|
||||
const ctx = uni.createCanvasContext(canvasId, compProxy)
|
||||
paint(ctx, w, h, dpr)
|
||||
})
|
||||
})
|
||||
.exec()
|
||||
}
|
||||
|
||||
onMounted(() => nextTick(() => draw()))
|
||||
onUnmounted(() => {
|
||||
if (retryTimer) clearTimeout(retryTimer)
|
||||
})
|
||||
watch(() => [props.categories, props.values], () => nextTick(() => draw()), { deep: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<canvas
|
||||
:id="canvasId"
|
||||
:canvas-id="canvasId"
|
||||
class="chart"
|
||||
:width="canvasW"
|
||||
:height="canvasH"
|
||||
:style="{ width: '100%', height: `${height}px` }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chart { width: 100%; display: block; }
|
||||
</style>
|
||||
111
src/subPackages/admin/components/AdminDetailDrawer.vue
Normal file
111
src/subPackages/admin/components/AdminDetailDrawer.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 基于 page-container 的详情抽屉,防止小程序侧滑误退页面。
|
||||
* 只读展示键值对,底部可插操作按钮(如咨询状态变更)。
|
||||
*/
|
||||
withDefaults(defineProps<{
|
||||
show: boolean
|
||||
title?: string
|
||||
rows?: { label: string, value: string }[]
|
||||
}>(), {
|
||||
title: '详情',
|
||||
rows: () => [],
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:show': [boolean]
|
||||
close: []
|
||||
}>()
|
||||
|
||||
function onLeave() {
|
||||
emit('update:show', false)
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<page-container :show="show" position="bottom" round :overlay="true" @afterleave="onLeave" @clickoverlay="onLeave">
|
||||
<view class="drawer">
|
||||
<view class="head">
|
||||
<text class="title">{{ title }}</text>
|
||||
<wd-icon name="close" size="20px" @click="onLeave" />
|
||||
</view>
|
||||
<scroll-view scroll-y class="body">
|
||||
<view v-for="(row, i) in rows" :key="i" class="row">
|
||||
<text class="label text-art-muted">{{ row.label }}</text>
|
||||
<text class="value">{{ row.value || '—' }}</text>
|
||||
</view>
|
||||
<slot />
|
||||
</scroll-view>
|
||||
<view v-if="$slots.footer" class="footer">
|
||||
<slot name="footer" />
|
||||
</view>
|
||||
</view>
|
||||
</page-container>
|
||||
<!-- #endif -->
|
||||
<!-- #ifndef MP-WEIXIN -->
|
||||
<view v-if="show" class="mask" @click="onLeave">
|
||||
<view class="drawer" @click.stop>
|
||||
<view class="head">
|
||||
<text class="title">{{ title }}</text>
|
||||
<wd-icon name="close" size="20px" @click="onLeave" />
|
||||
</view>
|
||||
<scroll-view scroll-y class="body">
|
||||
<view v-for="(row, i) in rows" :key="i" class="row">
|
||||
<text class="label text-art-muted">{{ row.label }}</text>
|
||||
<text class="value">{{ row.value || '—' }}</text>
|
||||
</view>
|
||||
<slot />
|
||||
</scroll-view>
|
||||
<view v-if="$slots.footer" class="footer">
|
||||
<slot name="footer" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.drawer {
|
||||
background: #1a1a1c;
|
||||
border-radius: 24rpx 24rpx 0 0;
|
||||
max-height: 75vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 28rpx 32rpx;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.title { font-size: 32rpx; color: rgb(var(--art-text)); font-weight: 600; }
|
||||
.body { max-height: 55vh; padding: 16rpx 32rpx 32rpx; box-sizing: border-box; }
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
padding: 18rpx 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.label { font-size: 22rpx; }
|
||||
.value { font-size: 28rpx; color: rgb(var(--art-text)); word-break: break-all; }
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16rpx;
|
||||
padding: 16rpx 32rpx 28rpx;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
</style>
|
||||
111
src/subPackages/admin/components/AdminFilterBar.vue
Normal file
111
src/subPackages/admin/components/AdminFilterBar.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 后台列表筛选条:关键词 + 插槽筛选项 + 搜索/重置。
|
||||
* 简单资源与专用列表页复用,避免每页重写一套筛选 UI。
|
||||
*/
|
||||
import { reactive, watch } from 'vue'
|
||||
import type { SchemaFilter } from '../config/resourceSchemas'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
keyword?: string
|
||||
placeholder?: string
|
||||
filters?: SchemaFilter[]
|
||||
modelValue?: Record<string, unknown>
|
||||
}>(), {
|
||||
keyword: '',
|
||||
placeholder: '搜索关键词',
|
||||
filters: () => [],
|
||||
modelValue: () => ({}),
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:keyword': [string]
|
||||
'update:modelValue': [Record<string, unknown>]
|
||||
search: []
|
||||
reset: []
|
||||
}>()
|
||||
|
||||
const local = reactive<Record<string, unknown>>({ ...props.modelValue })
|
||||
|
||||
watch(() => props.modelValue, (v) => {
|
||||
Object.keys(local).forEach(k => delete local[k])
|
||||
Object.assign(local, v || {})
|
||||
}, { deep: true })
|
||||
|
||||
function onKeyword(e: { value?: string } | string) {
|
||||
const val = typeof e === 'string' ? e : String(e?.value ?? '')
|
||||
emit('update:keyword', val)
|
||||
}
|
||||
|
||||
function onFilterChange() {
|
||||
emit('update:modelValue', { ...local })
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
emit('update:modelValue', { ...local })
|
||||
emit('search')
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
Object.keys(local).forEach(k => { local[k] = '' })
|
||||
emit('update:keyword', '')
|
||||
emit('update:modelValue', {})
|
||||
emit('reset')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="filter-bar admin-card">
|
||||
<wd-input
|
||||
:model-value="keyword"
|
||||
:placeholder="placeholder"
|
||||
clearable
|
||||
@update:model-value="onKeyword"
|
||||
@confirm="onSearch"
|
||||
/>
|
||||
<view v-for="f in filters.filter(i => i.key !== 'keyword')" :key="f.key" class="filter-row">
|
||||
<text class="label text-art-muted">{{ f.label }}</text>
|
||||
<wd-input
|
||||
v-if="f.type === 'text'"
|
||||
:model-value="String(local[f.key] ?? '')"
|
||||
:placeholder="f.label"
|
||||
clearable
|
||||
@update:model-value="(v: string | number) => { local[f.key] = v; onFilterChange() }"
|
||||
/>
|
||||
<wd-picker
|
||||
v-else-if="f.type === 'select' && f.options"
|
||||
:columns="[f.options.map(o => ({ label: o.label, value: o.value }))]"
|
||||
@confirm="({ value }: { value: unknown[] }) => { local[f.key] = (value?.[0] as { value?: unknown })?.value ?? value?.[0] ?? ''; onFilterChange() }"
|
||||
>
|
||||
<view class="picker-val">
|
||||
{{ f.options.find(o => String(o.value) === String(local[f.key] ?? ''))?.label || '请选择' }}
|
||||
</view>
|
||||
</wd-picker>
|
||||
</view>
|
||||
<slot />
|
||||
<view class="actions">
|
||||
<wd-button size="small" type="primary" @click="onSearch">搜索</wd-button>
|
||||
<wd-button size="small" plain @click="onReset">重置</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.filter-bar { padding: 20rpx 24rpx; margin-bottom: 20rpx; }
|
||||
.filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
.label { font-size: 24rpx; min-width: 100rpx; }
|
||||
.picker-val {
|
||||
flex: 1;
|
||||
padding: 16rpx 20rpx;
|
||||
border-radius: 12rpx;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgb(var(--art-text));
|
||||
font-size: 26rpx;
|
||||
}
|
||||
.actions { display: flex; gap: 16rpx; margin-top: 20rpx; }
|
||||
</style>
|
||||
29
src/subPackages/admin/components/AdminFormField.vue
Normal file
29
src/subPackages/admin/components/AdminFormField.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 后台表单字段外壳:wot-ui 2.2 的 wd-input 不渲染 label prop,
|
||||
* 用可见标签行保证暗色下字段含义清晰。
|
||||
*/
|
||||
withDefaults(defineProps<{
|
||||
label?: string
|
||||
required?: boolean
|
||||
}>(), {
|
||||
label: '',
|
||||
required: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="admin-form-field">
|
||||
<text v-if="label" class="admin-form-field__label">
|
||||
<text v-if="required" class="req">*</text>{{ label }}
|
||||
</text>
|
||||
<slot />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.req {
|
||||
color: rgb(var(--art-error));
|
||||
margin-right: 6rpx;
|
||||
}
|
||||
</style>
|
||||
226
src/subPackages/admin/components/AdminLineChart.vue
Normal file
226
src/subPackages/admin/components/AdminLineChart.vue
Normal file
@@ -0,0 +1,226 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 后台折线图:旧版 canvas,金色描边,适配暗色工作台。
|
||||
* 多系列时按同色相不同透明度区分;下方 HTML 图例标明发文/UV,避免「UV 没展示」的误解。
|
||||
* 小程序里 v-if 挂载后首帧 boundingClientRect 常为 0,需重试并设置原生宽高。
|
||||
* getCurrentInstance 仅在 setup/同步生命周期有效,必须在此处缓存 proxy。
|
||||
*/
|
||||
import { computed, getCurrentInstance, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** 横轴标签 */
|
||||
categories?: string[]
|
||||
/** 系列:{ name, data } */
|
||||
series?: { name: string, data: number[] }[]
|
||||
height?: number
|
||||
}>(), {
|
||||
categories: () => [],
|
||||
series: () => [],
|
||||
height: 220,
|
||||
})
|
||||
|
||||
const SERIES_COLORS = ['#d4b383', 'rgba(120,180,255,0.9)', 'rgba(160,220,160,0.9)']
|
||||
|
||||
/** setup 阶段同步缓存,供异步 draw 使用 */
|
||||
const compProxy = getCurrentInstance()?.proxy as never
|
||||
const canvasId = `line-${Math.random().toString(36).slice(2, 8)}`
|
||||
/** 原生 canvas 缓冲像素宽(含 dpr),绑定到 width 属性才能在小程序上正常绘制 */
|
||||
const canvasW = ref(300)
|
||||
const canvasH = ref(220)
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let retryCount = 0
|
||||
const MAX_RETRY = 5
|
||||
|
||||
const legendItems = computed(() =>
|
||||
(props.series || []).map((s, i) => ({
|
||||
name: s.name || `系列${i + 1}`,
|
||||
color: SERIES_COLORS[i % SERIES_COLORS.length],
|
||||
})),
|
||||
)
|
||||
|
||||
/** 优先新 API,降级 getSystemInfoSync,减少弃用警告 */
|
||||
function fallbackWidth(): number {
|
||||
try {
|
||||
const win = typeof uni.getWindowInfo === 'function' ? uni.getWindowInfo() : null
|
||||
if (win?.windowWidth) return Math.max(200, win.windowWidth - 48)
|
||||
const sys = uni.getSystemInfoSync()
|
||||
return Math.max(200, (sys.windowWidth || 375) - 48)
|
||||
} catch {
|
||||
return 300
|
||||
}
|
||||
}
|
||||
|
||||
function getDpr(): number {
|
||||
try {
|
||||
const device = typeof uni.getDeviceInfo === 'function' ? uni.getDeviceInfo() : null
|
||||
if (device && 'pixelRatio' in device && Number(device.pixelRatio)) {
|
||||
return Number(device.pixelRatio)
|
||||
}
|
||||
return uni.getSystemInfoSync().pixelRatio || 2
|
||||
} catch {
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建选择器:有组件实例则限定在组件内,否则页面级查询(canvasId 已全局唯一)。
|
||||
* 禁止对 null 调用 .in(),否则 mp-weixin 读 $scope 直接报错。
|
||||
*/
|
||||
function createQuery() {
|
||||
const q = uni.createSelectorQuery()
|
||||
if (compProxy) return q.in(compProxy)
|
||||
return q
|
||||
}
|
||||
|
||||
/**
|
||||
* 在逻辑像素坐标下绘制折线(调用前需已设好原生宽高并 scale)。
|
||||
*/
|
||||
function paint(ctx: UniApp.CanvasContext, w: number, h: number, dpr: number) {
|
||||
const cats = props.categories
|
||||
const series = props.series
|
||||
ctx.scale(dpr, dpr)
|
||||
const padL = 36
|
||||
const padR = 12
|
||||
const padT = 16
|
||||
const padB = 28
|
||||
const plotW = w - padL - padR
|
||||
const plotH = h - padT - padB
|
||||
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
const all = series.flatMap(s => s.data)
|
||||
const max = Math.max(1, ...all)
|
||||
const min = Math.min(0, ...all)
|
||||
const span = max - min || 1
|
||||
|
||||
ctx.setStrokeStyle('rgba(255,255,255,0.08)')
|
||||
ctx.setLineWidth(1)
|
||||
for (let i = 0; i <= 3; i++) {
|
||||
const y = padT + (plotH * i) / 3
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(padL, y)
|
||||
ctx.lineTo(w - padR, y)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
series.forEach((s, si) => {
|
||||
const color = SERIES_COLORS[si % SERIES_COLORS.length]
|
||||
ctx.setStrokeStyle(color)
|
||||
ctx.setLineWidth(2)
|
||||
ctx.beginPath()
|
||||
s.data.forEach((v, i) => {
|
||||
const x = padL + (cats.length <= 1 ? plotW / 2 : (plotW * i) / (cats.length - 1))
|
||||
const y = padT + plotH - ((v - min) / span) * plotH
|
||||
if (i === 0) ctx.moveTo(x, y)
|
||||
else ctx.lineTo(x, y)
|
||||
})
|
||||
ctx.stroke()
|
||||
s.data.forEach((v, i) => {
|
||||
const x = padL + (cats.length <= 1 ? plotW / 2 : (plotW * i) / (cats.length - 1))
|
||||
const y = padT + plotH - ((v - min) / span) * plotH
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, 3, 0, Math.PI * 2)
|
||||
ctx.setFillStyle(color)
|
||||
ctx.fill()
|
||||
})
|
||||
})
|
||||
|
||||
ctx.setFillStyle('rgba(136,136,136,1)')
|
||||
ctx.setFontSize(10)
|
||||
const step = Math.max(1, Math.ceil(cats.length / 5))
|
||||
cats.forEach((c, i) => {
|
||||
if (i % step !== 0 && i !== cats.length - 1) return
|
||||
const x = padL + (cats.length <= 1 ? plotW / 2 : (plotW * i) / (cats.length - 1))
|
||||
ctx.fillText(String(c).slice(-5), x - 12, h - 8)
|
||||
})
|
||||
ctx.draw()
|
||||
}
|
||||
|
||||
/**
|
||||
* 测量并绘制。宽度为 0 时用系统宽度兜底并短延迟重试;
|
||||
* 先写原生宽高再 nextTick 绘制,避免属性未同步导致空白。
|
||||
*/
|
||||
const draw = (isRetry = false) => {
|
||||
const cats = props.categories
|
||||
const series = props.series
|
||||
if (!cats.length || !series.length) return
|
||||
if (!isRetry) retryCount = 0
|
||||
|
||||
createQuery()
|
||||
.select(`#${canvasId}`)
|
||||
.boundingClientRect((rect) => {
|
||||
const box = Array.isArray(rect) ? rect[0] : rect
|
||||
let w = box?.width || 0
|
||||
const measuredOk = !!w
|
||||
if (!w) w = fallbackWidth()
|
||||
if (!measuredOk && retryCount < MAX_RETRY) {
|
||||
retryCount += 1
|
||||
if (retryTimer) clearTimeout(retryTimer)
|
||||
retryTimer = setTimeout(() => draw(true), 80)
|
||||
} else if (measuredOk) {
|
||||
retryCount = 0
|
||||
}
|
||||
if (!w) return
|
||||
|
||||
const h = props.height
|
||||
const dpr = getDpr()
|
||||
canvasW.value = Math.floor(w * dpr)
|
||||
canvasH.value = Math.floor(h * dpr)
|
||||
nextTick(() => {
|
||||
const ctx = uni.createCanvasContext(canvasId, compProxy)
|
||||
paint(ctx, w, h, dpr)
|
||||
})
|
||||
})
|
||||
.exec()
|
||||
}
|
||||
|
||||
onMounted(() => nextTick(() => draw()))
|
||||
onUnmounted(() => {
|
||||
if (retryTimer) clearTimeout(retryTimer)
|
||||
})
|
||||
watch(() => [props.categories, props.series], () => nextTick(() => draw()), { deep: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="line-wrap">
|
||||
<canvas
|
||||
:id="canvasId"
|
||||
:canvas-id="canvasId"
|
||||
class="chart"
|
||||
:width="canvasW"
|
||||
:height="canvasH"
|
||||
:style="{ width: '100%', height: `${height}px` }"
|
||||
/>
|
||||
<view v-if="legendItems.length" class="legend">
|
||||
<view v-for="(item, i) in legendItems" :key="i" class="legend-item">
|
||||
<view class="dot" :style="{ background: item.color }" />
|
||||
<text class="legend-text">{{ item.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.line-wrap { width: 100%; }
|
||||
.chart { width: 100%; display: block; }
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20rpx;
|
||||
margin-top: 12rpx;
|
||||
padding: 0 8rpx;
|
||||
}
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
.dot {
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
border-radius: 4rpx;
|
||||
}
|
||||
.legend-text {
|
||||
font-size: 22rpx;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
</style>
|
||||
113
src/subPackages/admin/components/AdminMediaPicker.vue
Normal file
113
src/subPackages/admin/components/AdminMediaPicker.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 媒体选择器:从相册选图并上传到 /admin/attachments/upload,回写 URL。
|
||||
* 作品封面、头像、附件库等复用,避免各表单重复实现上传。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { useToast } from '@wot-ui/ui'
|
||||
import { uploadAttachment } from '@/api'
|
||||
import { resolveMediaUrl } from '@/utils/request'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue?: string
|
||||
label?: string
|
||||
mediaType?: 'image' | 'video' | 'all'
|
||||
}>(), {
|
||||
modelValue: '',
|
||||
label: '图片',
|
||||
mediaType: 'image',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [string]
|
||||
}>()
|
||||
|
||||
const toast = useToast()
|
||||
const uploading = ref(false)
|
||||
|
||||
const preview = computed(() => resolveMediaUrl(props.modelValue))
|
||||
|
||||
async function choose() {
|
||||
try {
|
||||
if (props.mediaType === 'video') {
|
||||
const res = await uni.chooseVideo({ sourceType: ['album', 'camera'], compressed: true })
|
||||
await doUpload(res.tempFilePath)
|
||||
return
|
||||
}
|
||||
const res = await uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['album', 'camera'] })
|
||||
const path = res.tempFilePaths?.[0]
|
||||
if (!path) return
|
||||
await doUpload(path)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
// 用户取消不提示
|
||||
if (e && typeof e === 'object' && 'errMsg' in e && String((e as { errMsg: string }).errMsg).includes('cancel')) return
|
||||
toast.error(e instanceof Error ? e.message : '选择失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function doUpload(filePath: string) {
|
||||
uploading.value = true
|
||||
try {
|
||||
const result = await uploadAttachment(filePath)
|
||||
const url = String(result.fileUrl || result.url || '')
|
||||
if (!url) throw new Error('上传成功但未返回地址')
|
||||
emit('update:modelValue', url)
|
||||
toast.success('上传成功')
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '上传失败')
|
||||
}
|
||||
finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
emit('update:modelValue', '')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="media-picker">
|
||||
<text v-if="label" class="label text-art-muted">{{ label }}</text>
|
||||
<view class="box" @click="choose">
|
||||
<image v-if="preview && mediaType !== 'video'" class="preview" :src="preview" mode="aspectFill" />
|
||||
<view v-else-if="preview && mediaType === 'video'" class="video-hint">
|
||||
<text>已选视频</text>
|
||||
<text class="url text-art-muted">{{ modelValue }}</text>
|
||||
</view>
|
||||
<view v-else class="placeholder">
|
||||
<wd-loading v-if="uploading" />
|
||||
<text v-else class="text-art-muted">点击上传{{ label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<wd-button v-if="modelValue" size="small" plain @click.stop="clear">清除</wd-button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.media-picker { display: flex; flex-direction: column; gap: 12rpx; margin: 16rpx 0; }
|
||||
.label { font-size: 24rpx; }
|
||||
.box {
|
||||
width: 240rpx;
|
||||
height: 240rpx;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.preview { width: 100%; height: 100%; }
|
||||
.placeholder, .video-hint {
|
||||
padding: 16rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
.url { font-size: 20rpx; word-break: break-all; max-width: 200rpx; }
|
||||
</style>
|
||||
60
src/subPackages/admin/components/AdminStatusBadge.vue
Normal file
60
src/subPackages/admin/components/AdminStatusBadge.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 状态徽章:展示发布/启用/咨询状态,可点击切换(由父组件处理具体 API)。
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
value?: string | number | boolean | null
|
||||
map?: Record<string, string>
|
||||
clickable?: boolean
|
||||
}>(), {
|
||||
value: '',
|
||||
map: () => ({}),
|
||||
clickable: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: []
|
||||
}>()
|
||||
|
||||
const label = computed(() => {
|
||||
const key = String(props.value ?? '')
|
||||
if (props.map[key]) return props.map[key]
|
||||
if (props.value === true || props.value === 1 || props.value === '1') return '是'
|
||||
if (props.value === false || props.value === 0 || props.value === '0') return '否'
|
||||
return key || '—'
|
||||
})
|
||||
|
||||
const tone = computed(() => {
|
||||
const v = props.value
|
||||
if (v === true || v === 1 || v === '1' || v === 2 || v === '2') return 'on'
|
||||
if (v === false || v === 0 || v === '0') return 'off'
|
||||
return 'muted'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view
|
||||
class="badge"
|
||||
:class="[tone, { clickable }]"
|
||||
@click.stop="clickable && emit('click')"
|
||||
>
|
||||
{{ label }}
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4rpx 14rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.on { background: rgba(212, 179, 131, 0.2); color: rgb(var(--art-accent)); }
|
||||
.off { background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.45); }
|
||||
.muted { background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.55); }
|
||||
.clickable { border: 1px solid rgba(212, 179, 131, 0.35); }
|
||||
</style>
|
||||
20
src/subPackages/admin/composables/useAdminGuard.ts
Normal file
20
src/subPackages/admin/composables/useAdminGuard.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 后台页鉴权守卫:未登录或无员工角色则回我的页。
|
||||
*/
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
|
||||
export function useAdminGuard() {
|
||||
const { isAuthenticated } = useAuth()
|
||||
const { canEnterAdmin } = useAppMode()
|
||||
|
||||
function guardAdmin(): boolean {
|
||||
if (!isAuthenticated() || !canEnterAdmin.value) {
|
||||
uni.reLaunch({ url: '/pages/mine/index' })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return { guardAdmin, isAuthenticated, canEnterAdmin }
|
||||
}
|
||||
380
src/subPackages/admin/config/resourceSchemas.ts
Normal file
380
src/subPackages/admin/config/resourceSchemas.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* 简单资源 Schema 注册表:驱动通用 list/form,消灭默认 4 字段 + JSON 主路径。
|
||||
* 复杂模块(posts/works/videos/attachments/settings/about/inquiries)走专用页,不在此注册。
|
||||
*/
|
||||
|
||||
export type FieldType =
|
||||
| 'text'
|
||||
| 'textarea'
|
||||
| 'number'
|
||||
| 'switch'
|
||||
| 'select'
|
||||
| 'media'
|
||||
| 'rating'
|
||||
|
||||
export interface SchemaField {
|
||||
key: string
|
||||
label: string
|
||||
type: FieldType
|
||||
placeholder?: string
|
||||
/** select 选项 */
|
||||
options?: { label: string, value: string | number }[]
|
||||
/** 只读(如系统模板 slug) */
|
||||
readonly?: boolean
|
||||
/** 编辑态才显示 */
|
||||
editOnly?: boolean
|
||||
}
|
||||
|
||||
export interface SchemaFilter {
|
||||
key: string
|
||||
label: string
|
||||
type: 'text' | 'select'
|
||||
options?: { label: string, value: string | number }[]
|
||||
}
|
||||
|
||||
export interface ResourceSchema {
|
||||
title: string
|
||||
/** 真只读:无新建/删除/编辑,点行开详情抽屉 */
|
||||
readonly?: boolean
|
||||
/** 禁止编辑(可新建删除),如邮箱后缀 */
|
||||
noEdit?: boolean
|
||||
/** 页内新建(邮箱后缀) */
|
||||
inlineCreate?: boolean
|
||||
/** 服务端分页 */
|
||||
serverPaging?: boolean
|
||||
/** 默认 pageSize */
|
||||
pageSize?: number
|
||||
/** 列表主标题字段候选 */
|
||||
titleKeys?: string[]
|
||||
/** 列表副标题字段候选 */
|
||||
subtitleKeys?: string[]
|
||||
/** 封面/头像字段 */
|
||||
coverKey?: string
|
||||
/** 状态字段(用于徽章) */
|
||||
statusKey?: string
|
||||
/** 状态文案映射 */
|
||||
statusMap?: Record<string, string>
|
||||
filters?: SchemaFilter[]
|
||||
fields: SchemaField[]
|
||||
/** 本地关键词过滤字段 */
|
||||
localSearchKeys?: string[]
|
||||
}
|
||||
|
||||
const CODE_TYPE_OPTIONS = [
|
||||
{ label: '前端', value: 0 },
|
||||
{ label: '后端', value: 1 },
|
||||
{ label: '其他', value: 2 },
|
||||
]
|
||||
|
||||
/** 简单资源 Schema;未列出的走兜底 */
|
||||
export const RESOURCE_SCHEMAS: Record<string, ResourceSchema> = {
|
||||
categories: {
|
||||
title: '分类管理',
|
||||
titleKeys: ['name'],
|
||||
subtitleKeys: ['slug', 'sortOrder'],
|
||||
localSearchKeys: ['name', 'slug'],
|
||||
fields: [
|
||||
{ key: 'name', label: '名称', type: 'text' },
|
||||
{ key: 'slug', label: 'Slug', type: 'text' },
|
||||
{ key: 'sortOrder', label: '排序', type: 'number' },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
columns: {
|
||||
title: '专栏管理',
|
||||
titleKeys: ['name', 'title'],
|
||||
subtitleKeys: ['description', 'sortOrder'],
|
||||
coverKey: 'cover',
|
||||
statusKey: 'isActive',
|
||||
statusMap: { '1': '启用', '0': '停用', true: '启用', false: '停用' },
|
||||
filters: [
|
||||
{ key: 'keyword', label: '关键词', type: 'text' },
|
||||
{
|
||||
key: 'isActive',
|
||||
label: '状态',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '停用', value: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
localSearchKeys: ['name', 'title', 'description'],
|
||||
fields: [
|
||||
{ key: 'name', label: '名称', type: 'text' },
|
||||
{ key: 'cover', label: '封面', type: 'media' },
|
||||
{ key: 'isActive', label: '启用', type: 'switch' },
|
||||
{ key: 'sortOrder', label: '排序', type: 'number' },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
'video-categories': {
|
||||
title: '视频分类',
|
||||
titleKeys: ['name'],
|
||||
subtitleKeys: ['slug', 'sortOrder'],
|
||||
localSearchKeys: ['name', 'slug'],
|
||||
fields: [
|
||||
{ key: 'name', label: '名称', type: 'text' },
|
||||
{ key: 'slug', label: 'Slug', type: 'text' },
|
||||
{ key: 'sortOrder', label: '排序', type: 'number' },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
'video-albums': {
|
||||
title: '视频专辑',
|
||||
titleKeys: ['name'],
|
||||
subtitleKeys: ['categoryName', 'videoCount'],
|
||||
coverKey: 'cover',
|
||||
statusKey: 'isActive',
|
||||
statusMap: { '1': '启用', '0': '停用', true: '启用', false: '停用' },
|
||||
localSearchKeys: ['name', 'description'],
|
||||
fields: [
|
||||
{ key: 'name', label: '名称', type: 'text' },
|
||||
{ key: 'categoryId', label: '分类ID', type: 'number' },
|
||||
{ key: 'cover', label: '封面', type: 'media' },
|
||||
{ key: 'sortOrder', label: '排序', type: 'number' },
|
||||
{ key: 'isActive', label: '启用', type: 'switch' },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
snippets: {
|
||||
title: '代码片段',
|
||||
titleKeys: ['title'],
|
||||
subtitleKeys: ['description'],
|
||||
localSearchKeys: ['title', 'description'],
|
||||
fields: [
|
||||
{ key: 'title', label: '标题', type: 'text' },
|
||||
{ key: 'codeTypeId', label: '分类ID', type: 'number' },
|
||||
{ key: 'code', label: '代码', type: 'textarea' },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
'code-types': {
|
||||
title: '代码分类',
|
||||
titleKeys: ['name'],
|
||||
subtitleKeys: ['category'],
|
||||
localSearchKeys: ['name'],
|
||||
fields: [
|
||||
{ key: 'name', label: '名称', type: 'text' },
|
||||
{ key: 'category', label: '类别', type: 'select', options: CODE_TYPE_OPTIONS },
|
||||
],
|
||||
},
|
||||
tags: {
|
||||
title: '标签管理',
|
||||
titleKeys: ['name'],
|
||||
subtitleKeys: ['slug'],
|
||||
localSearchKeys: ['name', 'slug'],
|
||||
fields: [
|
||||
{ key: 'name', label: '名称', type: 'text' },
|
||||
{ key: 'slug', label: 'Slug', type: 'text' },
|
||||
],
|
||||
},
|
||||
testimonials: {
|
||||
title: '客户评价',
|
||||
titleKeys: ['name'],
|
||||
subtitleKeys: ['role', 'rating'],
|
||||
coverKey: 'avatar',
|
||||
filters: [
|
||||
{ key: 'keyword', label: '关键词', type: 'text' },
|
||||
{
|
||||
key: 'rating',
|
||||
label: '评分',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '5星', value: 5 },
|
||||
{ label: '4星', value: 4 },
|
||||
{ label: '3星', value: 3 },
|
||||
],
|
||||
},
|
||||
],
|
||||
localSearchKeys: ['name', 'role', 'content'],
|
||||
fields: [
|
||||
{ key: 'name', label: '姓名', type: 'text' },
|
||||
{ key: 'role', label: '职位', type: 'text' },
|
||||
{ key: 'avatar', label: '头像', type: 'media' },
|
||||
{ key: 'rating', label: '评分', type: 'rating' },
|
||||
{ key: 'content', label: '评价内容', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
partners: {
|
||||
title: '合作伙伴',
|
||||
titleKeys: ['name'],
|
||||
subtitleKeys: ['url', 'description'],
|
||||
coverKey: 'logo',
|
||||
localSearchKeys: ['name', 'description', 'url'],
|
||||
fields: [
|
||||
{ key: 'name', label: '名称', type: 'text' },
|
||||
{ key: 'logo', label: 'Logo', type: 'media' },
|
||||
{ key: 'url', label: '链接', type: 'text' },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
'email-suffixes': {
|
||||
title: '邮箱配置',
|
||||
noEdit: true,
|
||||
inlineCreate: true,
|
||||
titleKeys: ['suffix'],
|
||||
subtitleKeys: ['sortOrder', 'isActive'],
|
||||
statusKey: 'isActive',
|
||||
statusMap: { '1': '启用', '0': '停用', true: '启用', false: '停用' },
|
||||
localSearchKeys: ['suffix'],
|
||||
fields: [
|
||||
{ key: 'suffix', label: '后缀', type: 'text', placeholder: '@example.com' },
|
||||
{ key: 'sortOrder', label: '排序', type: 'number' },
|
||||
{ key: 'isActive', label: '启用', type: 'switch' },
|
||||
],
|
||||
},
|
||||
users: {
|
||||
title: '用户管理',
|
||||
serverPaging: true,
|
||||
pageSize: 10,
|
||||
titleKeys: ['username'],
|
||||
subtitleKeys: ['email', 'role'],
|
||||
coverKey: 'avatar',
|
||||
statusKey: 'isActive',
|
||||
statusMap: { '1': '启用', '0': '停用', true: '启用', false: '停用' },
|
||||
filters: [
|
||||
{ key: 'keyword', label: '关键词', type: 'text' },
|
||||
{
|
||||
key: 'role',
|
||||
label: '角色',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: 'admin', value: 'admin' },
|
||||
{ label: 'editor', value: 'editor' },
|
||||
{ label: 'viewer', value: 'viewer' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'isActive',
|
||||
label: '状态',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '停用', value: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
fields: [
|
||||
{ key: 'username', label: '用户名', type: 'text' },
|
||||
{ key: 'email', label: '邮箱', type: 'text' },
|
||||
{ key: 'avatar', label: '头像', type: 'media' },
|
||||
{
|
||||
key: 'role',
|
||||
label: '角色',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'admin', value: 'admin' },
|
||||
{ label: 'editor', value: 'editor' },
|
||||
{ label: 'viewer', value: 'viewer' },
|
||||
],
|
||||
},
|
||||
{ key: 'isActive', label: '启用', type: 'switch' },
|
||||
{ key: 'bio', label: '简介', type: 'textarea' },
|
||||
{ key: 'phone', label: '手机', type: 'text' },
|
||||
{ key: 'wechat', label: '微信', type: 'text' },
|
||||
{ key: 'wechatQrcode', label: '微信二维码', type: 'media' },
|
||||
],
|
||||
},
|
||||
roles: {
|
||||
title: '角色管理',
|
||||
titleKeys: ['name'],
|
||||
subtitleKeys: ['description'],
|
||||
localSearchKeys: ['name', 'description'],
|
||||
fields: [
|
||||
{ key: 'name', label: '名称', type: 'text' },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
'attachment-categories': {
|
||||
title: '附件分类',
|
||||
titleKeys: ['name'],
|
||||
subtitleKeys: ['sortOrder', 'description'],
|
||||
localSearchKeys: ['name', 'description'],
|
||||
fields: [
|
||||
{ key: 'name', label: '名称', type: 'text' },
|
||||
{ key: 'sortOrder', label: '排序', type: 'number' },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
'ppt-templates': {
|
||||
title: 'PPT 模板',
|
||||
titleKeys: ['name'],
|
||||
subtitleKeys: ['slug', 'sortOrder'],
|
||||
statusKey: 'isActive',
|
||||
statusMap: { '1': '启用', '0': '停用', true: '启用', false: '停用' },
|
||||
localSearchKeys: ['name', 'slug'],
|
||||
fields: [
|
||||
{ key: 'name', label: '名称', type: 'text' },
|
||||
{ key: 'slug', label: 'Slug', type: 'text' },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
{ key: 'isDefault', label: '默认', type: 'switch' },
|
||||
{ key: 'isActive', label: '启用', type: 'switch' },
|
||||
{ key: 'sortOrder', label: '排序', type: 'number' },
|
||||
{ key: 'config', label: '配置 JSON', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
logs: {
|
||||
title: '操作日志',
|
||||
readonly: true,
|
||||
serverPaging: true,
|
||||
pageSize: 20,
|
||||
titleKeys: ['action', 'path'],
|
||||
subtitleKeys: ['username', 'createdAt', 'method', 'status'],
|
||||
filters: [
|
||||
{ key: 'action', label: '操作', type: 'text' },
|
||||
{ key: 'method', label: '方法', type: 'text' },
|
||||
{ key: 'status', label: '状态码', type: 'text' },
|
||||
],
|
||||
fields: [],
|
||||
},
|
||||
'access-logs': {
|
||||
title: '访问日志',
|
||||
readonly: true,
|
||||
serverPaging: true,
|
||||
pageSize: 20,
|
||||
titleKeys: ['path'],
|
||||
subtitleKeys: ['region', 'ip', 'createdAt', 'statusCode'],
|
||||
filters: [
|
||||
{ key: 'path', label: '路径', type: 'text' },
|
||||
{ key: 'region', label: '地区', type: 'text' },
|
||||
],
|
||||
fields: [],
|
||||
},
|
||||
/** 已拆专用页,保留兜底标题 */
|
||||
posts: { title: '文章管理', fields: [], titleKeys: ['title'] },
|
||||
works: { title: '作品管理', fields: [], titleKeys: ['title'] },
|
||||
videos: { title: '视频列表', fields: [], titleKeys: ['title'] },
|
||||
attachments: { title: '附件库', readonly: true, fields: [], titleKeys: ['originalName', 'fileName'] },
|
||||
inquiries: { title: '合作咨询', readonly: true, fields: [], titleKeys: ['name'] },
|
||||
'oss-configs': { title: 'OSS配置', fields: [], titleKeys: ['name'] },
|
||||
about: { title: '关于页面', fields: [] },
|
||||
settings: { title: '全局配置', fields: [] },
|
||||
}
|
||||
|
||||
export function getResourceSchema(resource: string): ResourceSchema {
|
||||
return RESOURCE_SCHEMAS[resource] || {
|
||||
title: resource || '资源管理',
|
||||
titleKeys: ['title', 'name', 'username'],
|
||||
subtitleKeys: ['description', 'createdAt'],
|
||||
fields: [
|
||||
{ key: 'title', label: '标题', type: 'text' },
|
||||
{ key: 'name', label: '名称', type: 'text' },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/** 从记录中按候选 key 取展示文案 */
|
||||
export function pickField(item: Record<string, unknown>, keys?: string[]): string {
|
||||
if (!keys?.length) return ''
|
||||
for (const k of keys) {
|
||||
const v = item[k]
|
||||
if (v != null && v !== '') return String(v)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
62
src/subPackages/admin/config/settingsSchema.ts
Normal file
62
src/subPackages/admin/config/settingsSchema.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 全局配置 Schema(自 Web client settingsSchema 精简移植,供小程序设置页分 Tab 编辑)
|
||||
*/
|
||||
|
||||
export type SettingFieldType = 'text' | 'textarea' | 'number' | 'menu-checkboxes' | 'email' | 'homepage-json'
|
||||
|
||||
export interface SettingSchemaItem {
|
||||
key: string
|
||||
label: string
|
||||
type: SettingFieldType
|
||||
group: 'site' | 'seo' | 'navigation' | 'homepage' | 'pagination' | 'contact'
|
||||
default: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export const SETTING_GROUPS: { id: SettingSchemaItem['group'], label: string }[] = [
|
||||
{ id: 'site', label: '站点信息' },
|
||||
{ id: 'seo', label: 'SEO' },
|
||||
{ id: 'navigation', label: '导航菜单' },
|
||||
{ id: 'homepage', label: '首页内容' },
|
||||
{ id: 'pagination', label: '分页设置' },
|
||||
{ id: 'contact', label: '联系方式' },
|
||||
]
|
||||
|
||||
export const MENU_OPTIONS = [
|
||||
{ key: 'home', label: '首页' },
|
||||
{ key: 'blog', label: '思考' },
|
||||
{ key: 'columns', label: '专栏' },
|
||||
{ key: 'works', label: '作品' },
|
||||
{ key: 'videos', label: '视频' },
|
||||
{ key: 'snippets', label: '代码' },
|
||||
{ key: 'about', label: '关于' },
|
||||
{ key: 'services', label: '合作' },
|
||||
]
|
||||
|
||||
export const SETTINGS_SCHEMA: SettingSchemaItem[] = [
|
||||
{ key: 'site_title', label: '网站标题', type: 'text', group: 'site', default: '年糕崽崽.Dev', description: '站点名称' },
|
||||
{ key: 'site_author', label: '网站作者', type: 'text', group: 'site', default: '', description: '作者名' },
|
||||
{ key: 'site_description', label: '网站描述', type: 'textarea', group: 'seo', default: '', description: 'SEO 描述' },
|
||||
{ key: 'site_keywords', label: '网站关键词', type: 'textarea', group: 'seo', default: '', description: '逗号分隔' },
|
||||
{
|
||||
key: 'visible_menus',
|
||||
label: '前台菜单',
|
||||
type: 'menu-checkboxes',
|
||||
group: 'navigation',
|
||||
default: '["home","blog","columns","works","videos","snippets","about","services"]',
|
||||
description: '控制前台导航显示',
|
||||
},
|
||||
{
|
||||
key: 'homepage_config',
|
||||
label: '首页配置 JSON',
|
||||
type: 'homepage-json',
|
||||
group: 'homepage',
|
||||
default: '{}',
|
||||
description: 'Hero / Bento 等首页结构,小程序端用 JSON 编辑',
|
||||
},
|
||||
{ key: 'posts_per_page', label: '文章每页', type: 'number', group: 'pagination', default: '10', description: '' },
|
||||
{ key: 'works_per_page', label: '作品每页', type: 'number', group: 'pagination', default: '12', description: '' },
|
||||
{ key: 'snippets_per_page', label: '代码每页', type: 'number', group: 'pagination', default: '12', description: '' },
|
||||
{ key: 'footer_icp', label: 'ICP 备案号', type: 'text', group: 'contact', default: '', description: '' },
|
||||
{ key: 'contact_email', label: '联系邮箱', type: 'email', group: 'contact', default: '', description: '' },
|
||||
]
|
||||
185
src/subPackages/admin/pages/about/index.vue
Normal file
185
src/subPackages/admin/pages/about/index.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 关于页面单页编辑:结构化字段 + 经历动态增删。
|
||||
*/
|
||||
import { reactive, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminMediaPicker from '../../components/AdminMediaPicker.vue'
|
||||
import AdminFormField from '../../components/AdminFormField.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import { adminCreate, adminList, adminUpdate, normalizeAdminList, type AboutExperience, type AboutProfile } from '@/api'
|
||||
|
||||
const toast = useToast()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const profileId = ref<number | ''>('')
|
||||
const techInput = ref('')
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
avatar: '',
|
||||
location: '',
|
||||
email: '',
|
||||
wechat: '',
|
||||
bio: '',
|
||||
isPrimary: 1,
|
||||
})
|
||||
const techStack = ref<string[]>([])
|
||||
const experiences = ref<AboutExperience[]>([])
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
const list = normalizeAdminList(await adminList('about')) as AboutProfile[]
|
||||
const first = list[0]
|
||||
if (first) {
|
||||
profileId.value = Number(first.id) || ''
|
||||
form.name = String(first.name || '')
|
||||
form.avatar = String(first.avatar || '')
|
||||
form.location = String(first.location || '')
|
||||
form.email = String(first.email || '')
|
||||
form.wechat = String(first.wechat || '')
|
||||
form.bio = String(first.bio || '')
|
||||
form.isPrimary = first.isPrimary === 0 || first.isPrimary === false ? 0 : 1
|
||||
techStack.value = Array.isArray(first.techStack) ? [...first.techStack] : []
|
||||
experiences.value = Array.isArray(first.experiences) ? [...first.experiences] : []
|
||||
}
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
|
||||
function addTech() {
|
||||
const t = techInput.value.trim()
|
||||
if (!t) return
|
||||
techStack.value.push(t)
|
||||
techInput.value = ''
|
||||
}
|
||||
|
||||
function addExp() {
|
||||
experiences.value.push({ year: '', role: '', company: '' })
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload = {
|
||||
...form,
|
||||
techStack: techStack.value,
|
||||
experiences: experiences.value,
|
||||
}
|
||||
if (profileId.value) await adminUpdate('about', profileId.value, payload)
|
||||
else {
|
||||
const created = await adminCreate('about', payload) as AboutProfile
|
||||
if (created?.id) profileId.value = Number(created.id)
|
||||
}
|
||||
toast.success('已保存')
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar title="关于页面" show-back />
|
||||
<view class="page-pad">
|
||||
<view class="admin-card form-card">
|
||||
<AdminFormField label="姓名" required>
|
||||
<wd-input v-model="form.name" placeholder="姓名" clearable />
|
||||
</AdminFormField>
|
||||
<AdminMediaPicker v-model="form.avatar" label="头像" />
|
||||
<AdminFormField label="地点">
|
||||
<wd-input v-model="form.location" placeholder="地点" clearable />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="邮箱">
|
||||
<wd-input v-model="form.email" placeholder="邮箱" clearable />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="微信">
|
||||
<wd-input v-model="form.wechat" placeholder="微信" clearable />
|
||||
</AdminFormField>
|
||||
<view class="switch-row">
|
||||
<text>主档案</text>
|
||||
<wd-switch :model-value="!!form.isPrimary" @change="(v: boolean) => form.isPrimary = v ? 1 : 0" />
|
||||
</view>
|
||||
<AdminFormField label="简介">
|
||||
<wd-textarea v-model="form.bio" placeholder="简介" />
|
||||
</AdminFormField>
|
||||
<view class="section">
|
||||
<text class="label text-art-muted">技术栈</text>
|
||||
<view class="tags">
|
||||
<view v-for="(t, i) in techStack" :key="i" class="tag" @click="techStack.splice(i, 1)">{{ t }} ×</view>
|
||||
</view>
|
||||
<view class="row-input">
|
||||
<wd-input v-model="techInput" placeholder="添加技术" clearable />
|
||||
<wd-button size="small" @click="addTech">添加</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
<view class="section">
|
||||
<view class="sec-head">
|
||||
<text class="label text-art-muted">工作经历</text>
|
||||
<wd-button size="small" plain @click="addExp">添加</wd-button>
|
||||
</view>
|
||||
<view v-for="(exp, i) in experiences" :key="i" class="exp">
|
||||
<AdminFormField label="年份">
|
||||
<wd-input v-model="exp.year" placeholder="年份" clearable />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="职位">
|
||||
<wd-input v-model="exp.role" placeholder="职位" clearable />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="公司">
|
||||
<wd-input v-model="exp.company" placeholder="公司" clearable />
|
||||
</AdminFormField>
|
||||
<wd-button size="small" type="warning" plain @click="experiences.splice(i, 1)">删除</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
<wd-button
|
||||
block
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
custom-style="margin-top:24rpx;background:#d4b383;border-color:#d4b383;"
|
||||
@click="onSubmit"
|
||||
>
|
||||
保存
|
||||
</wd-button>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 80rpx; }
|
||||
.switch-row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 20rpx 0; color: rgb(var(--art-text));
|
||||
}
|
||||
.section { margin: 20rpx 0; }
|
||||
.label { font-size: 24rpx; }
|
||||
.tags { display: flex; flex-wrap: wrap; gap: 10rpx; margin: 12rpx 0; }
|
||||
.tag {
|
||||
padding: 6rpx 14rpx; border-radius: 8rpx; font-size: 22rpx;
|
||||
background: rgba(212, 179, 131, 0.2); color: rgb(var(--art-accent));
|
||||
}
|
||||
.row-input { display: flex; gap: 12rpx; align-items: center; }
|
||||
.sec-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12rpx; }
|
||||
.exp {
|
||||
padding: 16rpx; margin-bottom: 16rpx; border-radius: 12rpx;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
</style>
|
||||
161
src/subPackages/admin/pages/analytics/index.vue
Normal file
161
src/subPackages/admin/pages/analytics/index.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 数据分析:日期预设 + 真实 stats API + canvas 图表 + 地区 TOP 列表。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppFloatingTabbar from '@/components/layout/AppFloatingTabbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminLineChart from '../../components/AdminLineChart.vue'
|
||||
import AdminBarChart from '../../components/AdminBarChart.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import { getDashboardStats, trendPointValue, type DashboardStats, type TrendPoint } from '@/api'
|
||||
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
const loading = ref(false)
|
||||
const stats = ref<DashboardStats>({})
|
||||
const preset = ref<'today' | '7d' | '30d'>('7d')
|
||||
|
||||
function formatDate(d: Date) {
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
|
||||
function rangeOf(p: typeof preset.value) {
|
||||
const end = new Date()
|
||||
const start = new Date()
|
||||
if (p === 'today') { /* same day */ }
|
||||
else if (p === '7d') start.setDate(end.getDate() - 6)
|
||||
else start.setDate(end.getDate() - 29)
|
||||
return { startDate: formatDate(start), endDate: formatDate(end) }
|
||||
}
|
||||
|
||||
const kpi = computed(() => [
|
||||
{ label: '文章', value: stats.value.posts ?? 0 },
|
||||
{ label: '作品', value: stats.value.works ?? 0 },
|
||||
{ label: '咨询', value: stats.value.inquiryCount ?? 0 },
|
||||
{
|
||||
label: '区间 UV',
|
||||
value: (stats.value.uvTrend || []).reduce((s, i) => s + trendPointValue(i), 0),
|
||||
},
|
||||
])
|
||||
|
||||
const cats = computed(() => {
|
||||
const set = new Set<string>()
|
||||
;(stats.value.postsTrend || []).forEach(p => set.add(p.date))
|
||||
;(stats.value.uvTrend || []).forEach(p => set.add(p.date))
|
||||
return Array.from(set).sort()
|
||||
})
|
||||
|
||||
function mapByDate(list: TrendPoint[] | undefined, categories: string[]) {
|
||||
const map = new Map((list || []).map(i => [i.date, trendPointValue(i)]))
|
||||
return categories.map(d => map.get(d) ?? 0)
|
||||
}
|
||||
|
||||
const lineSeries = computed(() => [
|
||||
{ name: '发文', data: mapByDate(stats.value.postsTrend, cats.value) },
|
||||
{ name: 'UV', data: mapByDate(stats.value.uvTrend, cats.value) },
|
||||
])
|
||||
|
||||
const barCategories = computed(() =>
|
||||
(stats.value.topPosts || []).slice(0, 6).map(p => String(p.title || '').slice(0, 6) || '—'),
|
||||
)
|
||||
const barValues = computed(() =>
|
||||
(stats.value.topPosts || []).slice(0, 6).map(p => Number(p.count ?? p.readCount ?? 0)),
|
||||
)
|
||||
|
||||
const regions = computed(() => {
|
||||
const list = stats.value.userRegions || []
|
||||
return [...list]
|
||||
.map(r => ({
|
||||
name: String(r.region || r.name || '未知'),
|
||||
value: Number(r.count ?? r.value ?? 0),
|
||||
}))
|
||||
.sort((a, b) => b.value - a.value)
|
||||
.slice(0, 8)
|
||||
})
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
stats.value = await getDashboardStats(rangeOf(preset.value))
|
||||
}
|
||||
catch {
|
||||
stats.value = {}
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function setPreset(p: typeof preset.value) {
|
||||
preset.value = p
|
||||
load()
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin with-tab-pad>
|
||||
<AppNavbar title="数据分析" />
|
||||
<view class="page-pad">
|
||||
<view class="presets">
|
||||
<view class="chip" :class="{ on: preset === 'today' }" @click="setPreset('today')">今日</view>
|
||||
<view class="chip" :class="{ on: preset === '7d' }" @click="setPreset('7d')">近7天</view>
|
||||
<view class="chip" :class="{ on: preset === '30d' }" @click="setPreset('30d')">近30天</view>
|
||||
</view>
|
||||
<view class="grid">
|
||||
<view v-for="item in kpi" :key="item.label" class="admin-card card">
|
||||
<text class="font-mono-label text-art-muted">{{ item.label }}</text>
|
||||
<text class="value">{{ item.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="admin-card chart-card">
|
||||
<text class="chart-title">发文 / UV 趋势</text>
|
||||
<AdminLineChart v-if="cats.length" :categories="cats" :series="lineSeries" />
|
||||
<wd-empty v-else description="暂无趋势" />
|
||||
</view>
|
||||
<view class="admin-card chart-card">
|
||||
<text class="chart-title">热门文章</text>
|
||||
<AdminBarChart v-if="barCategories.length" :categories="barCategories" :values="barValues" />
|
||||
<wd-empty v-else description="暂无热门" />
|
||||
</view>
|
||||
<view class="admin-card chart-card">
|
||||
<text class="chart-title">访问地区 TOP</text>
|
||||
<view v-for="(r, i) in regions" :key="i" class="region">
|
||||
<text class="r-name">{{ i + 1 }}. {{ r.name }}</text>
|
||||
<text class="r-val text-art-accent">{{ r.value }}</text>
|
||||
</view>
|
||||
<wd-empty v-if="!regions.length" description="暂无地区数据" />
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
</view>
|
||||
<AppFloatingTabbar />
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 48rpx; }
|
||||
.presets { display: flex; gap: 12rpx; margin-bottom: 20rpx; }
|
||||
.chip {
|
||||
padding: 10rpx 22rpx; border-radius: 8rpx; font-size: 24rpx;
|
||||
background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
.chip.on { background: rgba(212, 179, 131, 0.25); color: rgb(var(--art-accent)); }
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16rpx; margin-bottom: 20rpx; }
|
||||
.card { padding: 24rpx; display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.value { font-size: 40rpx; color: rgb(var(--art-accent)); font-family: monospace; }
|
||||
.chart-card { padding: 24rpx; margin-bottom: 20rpx; }
|
||||
.chart-title { display: block; font-size: 28rpx; margin-bottom: 16rpx; color: rgb(var(--art-text)); }
|
||||
.region {
|
||||
display: flex; justify-content: space-between; padding: 14rpx 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.r-name { color: rgb(var(--art-text)); font-size: 26rpx; }
|
||||
.r-val { font-family: monospace; }
|
||||
</style>
|
||||
361
src/subPackages/admin/pages/attachments/index.vue
Normal file
361
src/subPackages/admin/pages/attachments/index.vue
Normal file
@@ -0,0 +1,361 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 附件库:小红书式双列瀑布流 + 分页上拉 + page-container 详情。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onReachBottom, onShow } from '@dcloudio/uni-app'
|
||||
import { useDialog, useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminFilterBar from '../../components/AdminFilterBar.vue'
|
||||
import AdminDetailDrawer from '../../components/AdminDetailDrawer.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import {
|
||||
adminDelete,
|
||||
adminList,
|
||||
adminUpdate,
|
||||
normalizeAdminList,
|
||||
normalizePagination,
|
||||
uploadAttachment,
|
||||
type Attachment,
|
||||
} from '@/api'
|
||||
import { resolveMediaUrl } from '@/utils/request'
|
||||
|
||||
const toast = useToast()
|
||||
const dialog = useDialog()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
|
||||
const loading = ref(false)
|
||||
const uploading = ref(false)
|
||||
const list = ref<Attachment[]>([])
|
||||
const categories = ref<{ id: number, name: string }[]>([])
|
||||
const keyword = ref('')
|
||||
const filters = ref<Record<string, unknown>>({})
|
||||
const page = ref(1)
|
||||
const finished = ref(false)
|
||||
const detailShow = ref(false)
|
||||
const current = ref<Attachment | null>(null)
|
||||
|
||||
const filterDefs = [
|
||||
{ key: 'keyword', label: '关键词', type: 'text' as const },
|
||||
{
|
||||
key: 'fileType',
|
||||
label: '类型',
|
||||
type: 'select' as const,
|
||||
options: [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '图片', value: 'image' },
|
||||
{ label: '视频', value: 'video' },
|
||||
{ label: '文档', value: 'document' },
|
||||
{ label: '其他', value: 'other' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** 奇偶分列,形成简易瀑布流 */
|
||||
const leftCol = computed(() => list.value.filter((_, i) => i % 2 === 0))
|
||||
const rightCol = computed(() => list.value.filter((_, i) => i % 2 === 1))
|
||||
|
||||
async function load(reset = true) {
|
||||
if (!guardAdmin()) return
|
||||
if (reset) {
|
||||
page.value = 1
|
||||
finished.value = false
|
||||
list.value = []
|
||||
}
|
||||
if (finished.value && !reset) return
|
||||
loading.value = true
|
||||
try {
|
||||
if (reset || !categories.value.length) {
|
||||
categories.value = normalizeAdminList(await adminList('attachment-categories')) as { id: number, name: string }[]
|
||||
}
|
||||
const data = await adminList('attachments', {
|
||||
page: page.value,
|
||||
pageSize: 20,
|
||||
keyword: keyword.value || undefined,
|
||||
...filters.value,
|
||||
})
|
||||
const p = normalizePagination<Attachment>(data, page.value, 20)
|
||||
list.value = reset ? p.list : [...list.value, ...p.list]
|
||||
finished.value = list.value.length >= p.total || p.list.length < 20
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(() => load(true))
|
||||
onReachBottom(() => {
|
||||
if (finished.value || loading.value) return
|
||||
page.value += 1
|
||||
load(false)
|
||||
})
|
||||
|
||||
function fileUrl(item: Attachment) {
|
||||
return resolveMediaUrl(item.fileUrl || item.url || '')
|
||||
}
|
||||
|
||||
function isImage(item: Attachment) {
|
||||
const t = String(item.fileType || item.fileUrl || item.url || '').toLowerCase()
|
||||
return t.includes('image') || /\.(png|jpe?g|gif|webp|svg)$/i.test(t)
|
||||
}
|
||||
|
||||
function isVideo(item: Attachment) {
|
||||
const t = String(item.fileType || item.fileUrl || item.url || '').toLowerCase()
|
||||
return t.includes('video') || /\.(mp4|mov|webm|m4v)$/i.test(t)
|
||||
}
|
||||
|
||||
function openDetail(item: Attachment) {
|
||||
current.value = { ...item }
|
||||
detailShow.value = true
|
||||
}
|
||||
|
||||
async function onUpload() {
|
||||
try {
|
||||
const res = await uni.chooseImage({ count: 9, sizeType: ['compressed'] })
|
||||
uploading.value = true
|
||||
for (const path of res.tempFilePaths || []) {
|
||||
await uploadAttachment(path)
|
||||
}
|
||||
toast.success('上传完成')
|
||||
load(true)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
if (e && typeof e === 'object' && 'errMsg' in e && String((e as { errMsg: string }).errMsg).includes('cancel')) return
|
||||
toast.error(e instanceof Error ? e.message : '上传失败')
|
||||
}
|
||||
finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function copyUrl() {
|
||||
const url = fileUrl(current.value || { id: 0 })
|
||||
if (!url) return
|
||||
uni.setClipboardData({ data: url, success: () => toast.success('已复制') })
|
||||
}
|
||||
|
||||
async function saveCategory(catId: number) {
|
||||
if (!current.value?.id) return
|
||||
try {
|
||||
await adminUpdate('attachments', current.value.id, { categoryId: catId })
|
||||
current.value.categoryId = catId
|
||||
toast.success('已更新分类')
|
||||
load(true)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
if (!current.value?.id) return
|
||||
dialog.confirm({ title: '确认删除', msg: '确定删除该附件吗?' }).then(async () => {
|
||||
try {
|
||||
await adminDelete('attachments', current.value!.id)
|
||||
toast.success('已删除')
|
||||
detailShow.value = false
|
||||
load(true)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const detailRows = () => {
|
||||
const c = current.value
|
||||
if (!c) return []
|
||||
return [
|
||||
{ label: '名称', value: String(c.originalName || c.fileName || c.id) },
|
||||
{ label: '类型', value: String(c.fileType || '') },
|
||||
{ label: '大小', value: c.fileSize ? `${Math.round(Number(c.fileSize) / 1024)} KB` : '' },
|
||||
{ label: 'URL', value: fileUrl(c) },
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar title="附件库" show-back />
|
||||
<view class="page-pad">
|
||||
<AdminFilterBar
|
||||
v-model:keyword="keyword"
|
||||
v-model="filters"
|
||||
:filters="filterDefs"
|
||||
@search="load(true)"
|
||||
@reset="load(true)"
|
||||
/>
|
||||
<!-- 双列瀑布:奇偶分列,避免 CSS grid min-width 撑破 -->
|
||||
<view class="waterfall">
|
||||
<view class="col">
|
||||
<view
|
||||
v-for="item in leftCol"
|
||||
:key="item.id"
|
||||
class="card"
|
||||
@click="openDetail(item)"
|
||||
>
|
||||
<view class="media">
|
||||
<image
|
||||
v-if="isImage(item)"
|
||||
class="thumb"
|
||||
:src="fileUrl(item)"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view v-else class="file-icon">
|
||||
<wd-icon :name="isVideo(item) ? 'video' : 'file'" size="28px" />
|
||||
<text v-if="isVideo(item)" class="badge">视频</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="name">{{ item.originalName || item.fileName || item.id }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="col">
|
||||
<view
|
||||
v-for="item in rightCol"
|
||||
:key="item.id"
|
||||
class="card"
|
||||
@click="openDetail(item)"
|
||||
>
|
||||
<view class="media">
|
||||
<image
|
||||
v-if="isImage(item)"
|
||||
class="thumb"
|
||||
:src="fileUrl(item)"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view v-else class="file-icon">
|
||||
<wd-icon :name="isVideo(item) ? 'video' : 'file'" size="28px" />
|
||||
<text v-if="isVideo(item)" class="badge">视频</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="name">{{ item.originalName || item.fileName || item.id }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<wd-loading v-if="loading || uploading" />
|
||||
<text v-if="!finished && list.length" class="more text-art-muted">上拉加载更多</text>
|
||||
<wd-empty v-if="!loading && !list.length" description="暂无附件" />
|
||||
</view>
|
||||
<view class="fab" @click="onUpload"><wd-icon name="upload" size="24px" color="#fff" /></view>
|
||||
|
||||
<AdminDetailDrawer v-model:show="detailShow" title="附件详情" :rows="detailRows()">
|
||||
<view class="cat-section">
|
||||
<text class="text-art-muted">改分类</text>
|
||||
<view class="tags">
|
||||
<view
|
||||
v-for="c in categories"
|
||||
:key="c.id"
|
||||
class="tag"
|
||||
:class="{ on: current?.categoryId === c.id }"
|
||||
@click="saveCategory(c.id)"
|
||||
>
|
||||
{{ c.name }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<template #footer>
|
||||
<wd-button size="small" @click="copyUrl">复制 URL</wd-button>
|
||||
<wd-button size="small" type="warning" @click="onDelete">删除</wd-button>
|
||||
</template>
|
||||
</AdminDetailDrawer>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad {
|
||||
padding: 24rpx 24rpx 120rpx;
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.waterfall {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16rpx;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.col {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.card {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 16rpx;
|
||||
background: rgb(var(--art-admin-card));
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.media {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.thumb {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-height: 180rpx;
|
||||
max-height: 480rpx;
|
||||
}
|
||||
.file-icon {
|
||||
width: 100%;
|
||||
height: 220rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12rpx;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
.badge {
|
||||
font-size: 20rpx;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 8rpx;
|
||||
background: rgba(212, 179, 131, 0.25);
|
||||
color: rgb(var(--art-accent));
|
||||
}
|
||||
.name {
|
||||
display: block;
|
||||
padding: 16rpx 18rpx 20rpx;
|
||||
font-size: 22rpx;
|
||||
color: rgb(var(--art-text));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.more { display: block; text-align: center; font-size: 22rpx; padding: 20rpx; }
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 40rpx;
|
||||
bottom: calc(40rpx + env(safe-area-inset-bottom));
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 50%;
|
||||
background: rgb(var(--art-accent));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
.cat-section { margin-top: 16rpx; }
|
||||
.tags { display: flex; flex-wrap: wrap; gap: 12rpx; margin-top: 12rpx; }
|
||||
.tag {
|
||||
padding: 8rpx 16rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 22rpx;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
.tag.on { background: rgba(212, 179, 131, 0.25); color: rgb(var(--art-accent)); }
|
||||
</style>
|
||||
195
src/subPackages/admin/pages/columns/form.vue
Normal file
195
src/subPackages/admin/pages/columns/form.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 专栏表单:封面/启用 + 编辑态挂载文章(add/remove)。
|
||||
*/
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import { useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminMediaPicker from '../../components/AdminMediaPicker.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import {
|
||||
addPostToColumn,
|
||||
adminCreate,
|
||||
adminGet,
|
||||
adminList,
|
||||
adminUpdate,
|
||||
getAdminColumnPosts,
|
||||
normalizeAdminList,
|
||||
removePostFromColumn,
|
||||
type Column,
|
||||
type Post,
|
||||
} from '@/api'
|
||||
|
||||
const toast = useToast()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
const mode = ref<'create' | 'edit'>('create')
|
||||
const id = ref('')
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const linkedPosts = ref<Post[]>([])
|
||||
const allPosts = ref<Post[]>([])
|
||||
const postKeyword = ref('')
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
cover: '',
|
||||
description: '',
|
||||
sortOrder: 0,
|
||||
isActive: 1,
|
||||
})
|
||||
|
||||
const pageTitle = computed(() => (mode.value === 'create' ? '新建专栏' : '编辑专栏'))
|
||||
const filteredPosts = computed(() => {
|
||||
const kw = postKeyword.value.trim().toLowerCase()
|
||||
const linkedIds = new Set(linkedPosts.value.map(p => Number(p.id)))
|
||||
return allPosts.value.filter((p) => {
|
||||
if (linkedIds.has(Number(p.id))) return false
|
||||
if (!kw) return true
|
||||
return String(p.title || '').toLowerCase().includes(kw)
|
||||
}).slice(0, 30)
|
||||
})
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
const postData = await adminList('posts', { page: 1, pageSize: 200 })
|
||||
allPosts.value = normalizeAdminList(postData) as Post[]
|
||||
if (mode.value === 'edit' && id.value) {
|
||||
const data = await adminGet('columns', id.value) as Column
|
||||
form.name = String(data.name || data.title || '')
|
||||
form.cover = String(data.cover || '')
|
||||
form.description = String(data.description || '')
|
||||
form.sortOrder = Number(data.sortOrder || 0)
|
||||
form.isActive = data.isActive === 0 || data.isActive === false ? 0 : 1
|
||||
linkedPosts.value = await getAdminColumnPosts(id.value)
|
||||
}
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((q) => {
|
||||
mode.value = q?.mode === 'edit' ? 'edit' : 'create'
|
||||
id.value = String(q?.id || '')
|
||||
})
|
||||
onShow(load)
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.name.trim()) {
|
||||
toast.show('请填写名称')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload = { ...form }
|
||||
if (mode.value === 'edit' && id.value) {
|
||||
await adminUpdate('columns', id.value, payload)
|
||||
toast.success('已保存')
|
||||
}
|
||||
else {
|
||||
const created = await adminCreate('columns', payload) as Column
|
||||
if (created?.id) id.value = String(created.id)
|
||||
mode.value = 'edit'
|
||||
toast.success('已创建,可继续挂载文章')
|
||||
}
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addPost(post: Post) {
|
||||
if (!id.value) {
|
||||
toast.show('请先保存专栏')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await addPostToColumn(id.value, post.id)
|
||||
linkedPosts.value.push(post)
|
||||
toast.success('已添加')
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '添加失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function removePost(post: Post) {
|
||||
if (!id.value) return
|
||||
try {
|
||||
await removePostFromColumn(id.value, post.id)
|
||||
linkedPosts.value = linkedPosts.value.filter(p => Number(p.id) !== Number(post.id))
|
||||
toast.success('已移除')
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '移除失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar :title="pageTitle" show-back />
|
||||
<view class="page-pad">
|
||||
<view class="admin-card form-card">
|
||||
<wd-input v-model="form.name" label="名称" clearable />
|
||||
<AdminMediaPicker v-model="form.cover" label="封面" />
|
||||
<wd-input v-model="form.sortOrder" label="排序" type="number" />
|
||||
<view class="switch-row">
|
||||
<text>启用</text>
|
||||
<wd-switch :model-value="!!form.isActive" @change="(v: boolean | { value: boolean }) => form.isActive = (typeof v === 'boolean' ? v : v.value) ? 1 : 0" />
|
||||
</view>
|
||||
<wd-textarea v-model="form.description" label="描述" />
|
||||
<wd-button
|
||||
block
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
custom-style="margin-top:24rpx;background:#d4b383;border-color:#d4b383;"
|
||||
@click="onSubmit"
|
||||
>
|
||||
保存
|
||||
</wd-button>
|
||||
</view>
|
||||
|
||||
<view v-if="mode === 'edit' && id" class="admin-card form-card" style="margin-top: 20rpx;">
|
||||
<text class="section-title" style="font-size: 30rpx;">专栏文章</text>
|
||||
<view v-for="p in linkedPosts" :key="String(p.id)" class="post-row">
|
||||
<text class="ptitle">{{ p.title }}</text>
|
||||
<wd-button size="small" type="warning" plain @click="removePost(p)">移除</wd-button>
|
||||
</view>
|
||||
<wd-input v-model="postKeyword" label="搜索添加" placeholder="文章标题" clearable />
|
||||
<view v-for="p in filteredPosts" :key="`a-${p.id}`" class="post-row">
|
||||
<text class="ptitle">{{ p.title }}</text>
|
||||
<wd-button size="small" @click="addPost(p)">添加</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 80rpx; }
|
||||
.form-card { padding: 24rpx; }
|
||||
.switch-row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 20rpx 0; color: rgb(var(--art-text));
|
||||
}
|
||||
.post-row {
|
||||
display: flex; align-items: center; gap: 12rpx;
|
||||
padding: 16rpx 0; border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.ptitle {
|
||||
flex: 1; font-size: 26rpx; color: rgb(var(--art-text));
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
228
src/subPackages/admin/pages/dashboard/index.vue
Normal file
228
src/subPackages/admin/pages/dashboard/index.vue
Normal file
@@ -0,0 +1,228 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 后台工作台:KPI 可跳转 + 最近文章 + 最近操作(查看全部进日志页)。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppFloatingTabbar from '@/components/layout/AppFloatingTabbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminLineChart from '../../components/AdminLineChart.vue'
|
||||
import AdminBarChart from '../../components/AdminBarChart.vue'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import {
|
||||
adminList,
|
||||
getDashboardStats,
|
||||
getOperationLogs,
|
||||
normalizeAdminList,
|
||||
trendPointValue,
|
||||
type DashboardStats,
|
||||
type Post,
|
||||
type TrendPoint,
|
||||
} from '@/api'
|
||||
|
||||
const { isAuthenticated } = useAuth()
|
||||
const { canEnterAdmin } = useAppMode()
|
||||
const { apply } = useTheme()
|
||||
|
||||
const loading = ref(false)
|
||||
const stats = ref<DashboardStats>({})
|
||||
const logs = ref<unknown[]>([])
|
||||
const recentPosts = ref<Post[]>([])
|
||||
|
||||
const todayUv = computed(() => {
|
||||
const list = stats.value.uvTrend || []
|
||||
if (!list.length) return 0
|
||||
return trendPointValue(list[list.length - 1])
|
||||
})
|
||||
|
||||
const kpiList = computed(() => [
|
||||
{ key: 'posts', label: '文章', value: stats.value.posts ?? 0, url: '/subPackages/admin/pages/posts/list' },
|
||||
{ key: 'works', label: '作品', value: stats.value.works ?? 0, url: '/subPackages/admin/pages/works/list' },
|
||||
{ key: 'inquiry', label: '咨询', value: stats.value.inquiryCount ?? 0, url: '/subPackages/admin/pages/inquiries/list' },
|
||||
{ key: 'uv', label: '今日 UV', value: todayUv.value, url: '/subPackages/admin/pages/analytics/index' },
|
||||
])
|
||||
|
||||
const trendCategories = computed(() => {
|
||||
const posts = stats.value.postsTrend || []
|
||||
const uv = stats.value.uvTrend || []
|
||||
const set = new Set<string>()
|
||||
posts.forEach(p => set.add(p.date))
|
||||
uv.forEach(p => set.add(p.date))
|
||||
return Array.from(set).sort()
|
||||
})
|
||||
|
||||
function mapByDate(list: TrendPoint[] | undefined, cats: string[]): number[] {
|
||||
const map = new Map((list || []).map(i => [i.date, trendPointValue(i)]))
|
||||
return cats.map(d => map.get(d) ?? 0)
|
||||
}
|
||||
|
||||
const lineSeries = computed(() => {
|
||||
const cats = trendCategories.value
|
||||
return [
|
||||
{ name: '发文', data: mapByDate(stats.value.postsTrend, cats) },
|
||||
{ name: 'UV', data: mapByDate(stats.value.uvTrend, cats) },
|
||||
]
|
||||
})
|
||||
|
||||
const barCategories = computed(() =>
|
||||
(stats.value.topPosts || []).slice(0, 6).map(p => String(p.title || '').slice(0, 6) || '—'),
|
||||
)
|
||||
const barValues = computed(() =>
|
||||
(stats.value.topPosts || []).slice(0, 6).map(p => Number(p.count ?? p.readCount ?? 0)),
|
||||
)
|
||||
|
||||
function guardAdmin() {
|
||||
if (!isAuthenticated() || !canEnterAdmin.value) {
|
||||
uni.reLaunch({ url: '/pages/mine/index' })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function logLabel(item: unknown): string {
|
||||
if (!item || typeof item !== 'object') return String(item)
|
||||
const row = item as Record<string, unknown>
|
||||
return String(row.action || row.description || row.module || row.id || '操作记录')
|
||||
}
|
||||
|
||||
function logTime(item: unknown): string {
|
||||
if (!item || typeof item !== 'object') return ''
|
||||
const row = item as Record<string, unknown>
|
||||
return String(row.createdAt || row.time || '')
|
||||
}
|
||||
|
||||
async function load() {
|
||||
apply()
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
const [s, logRes, postsRes] = await Promise.all([
|
||||
getDashboardStats().catch(() => ({}) as DashboardStats),
|
||||
getOperationLogs(1, 10).catch(() => ({ list: [] })),
|
||||
adminList('posts', { page: 1, pageSize: 5 }).catch(() => []),
|
||||
])
|
||||
stats.value = s || {}
|
||||
const list = Array.isArray(logRes) ? logRes : (logRes as { list?: unknown[] }).list || []
|
||||
logs.value = list
|
||||
recentPosts.value = normalizeAdminList(postsRes) as Post[]
|
||||
}
|
||||
catch (e: unknown) {
|
||||
uni.showToast({ title: e instanceof Error ? e.message : '加载失败', icon: 'none' })
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
|
||||
function go(url: string) {
|
||||
uni.navigateTo({ url })
|
||||
}
|
||||
|
||||
function goPost(item: Post) {
|
||||
uni.navigateTo({ url: `/subPackages/admin/pages/posts/form?mode=edit&id=${item.id}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin with-tab-pad>
|
||||
<AppNavbar title="工作台" />
|
||||
<view class="page-pad">
|
||||
<view class="hero-eyebrow">Dashboard</view>
|
||||
<view class="section-title" style="margin-bottom: 32rpx;">概览</view>
|
||||
|
||||
<view class="stats-grid">
|
||||
<view
|
||||
v-for="item in kpiList"
|
||||
:key="item.key"
|
||||
class="admin-card stat"
|
||||
@click="go(item.url)"
|
||||
>
|
||||
<text class="font-mono-label text-art-muted">{{ item.label }}</text>
|
||||
<text class="num">{{ item.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="admin-card chart-card">
|
||||
<text class="chart-title">发文 / UV 趋势</text>
|
||||
<AdminLineChart
|
||||
v-if="trendCategories.length"
|
||||
:categories="trendCategories"
|
||||
:series="lineSeries"
|
||||
/>
|
||||
<wd-empty v-else description="暂无趋势数据" />
|
||||
</view>
|
||||
|
||||
<view class="admin-card chart-card">
|
||||
<text class="chart-title">热门文章阅读</text>
|
||||
<AdminBarChart
|
||||
v-if="barCategories.length"
|
||||
:categories="barCategories"
|
||||
:values="barValues"
|
||||
/>
|
||||
<wd-empty v-else description="暂无热门数据" />
|
||||
</view>
|
||||
|
||||
<view class="section-head">
|
||||
<text class="section-title" style="font-size: 34rpx;">最近文章</text>
|
||||
<text class="link text-art-accent" @click="go('/subPackages/admin/pages/posts/list')">全部</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="item in recentPosts"
|
||||
:key="String(item.id)"
|
||||
class="admin-card log"
|
||||
@click="goPost(item)"
|
||||
>
|
||||
<text class="log-title">{{ item.title }}</text>
|
||||
<text class="font-mono-label text-art-muted">{{ item.categoryName || '' }} · {{ item.date || '' }}</text>
|
||||
</view>
|
||||
|
||||
<view class="section-head" style="margin-top: 28rpx;">
|
||||
<text class="section-title" style="font-size: 34rpx;">最近操作</text>
|
||||
<text class="link text-art-accent" @click="go('/subPackages/admin/pages/logs/operation')">全部</text>
|
||||
</view>
|
||||
<view v-for="(item, idx) in logs" :key="idx" class="admin-card log">
|
||||
<text class="log-title">{{ logLabel(item) }}</text>
|
||||
<text class="font-mono-label text-art-muted">{{ logTime(item) }}</text>
|
||||
</view>
|
||||
<wd-empty v-if="!loading && !logs.length" description="暂无日志" />
|
||||
<wd-loading v-if="loading" />
|
||||
</view>
|
||||
<AppFloatingTabbar />
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 48rpx; }
|
||||
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20rpx; margin-bottom: 28rpx; }
|
||||
.stat { padding: 28rpx; display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.num { font-size: 44rpx; color: rgb(var(--art-accent)); font-family: monospace; }
|
||||
.chart-card { padding: 24rpx; margin-bottom: 24rpx; }
|
||||
.chart-title {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
color: rgb(var(--art-text));
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
.section-head {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding-bottom: 16rpx;
|
||||
margin-bottom: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.link { font-size: 24rpx; }
|
||||
.log {
|
||||
padding: 24rpx 28rpx;
|
||||
margin-bottom: 16rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
}
|
||||
.log-title { font-size: 28rpx; color: rgb(var(--art-text)); }
|
||||
</style>
|
||||
132
src/subPackages/admin/pages/explore/index.vue
Normal file
132
src/subPackages/admin/pages/explore/index.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 后台功能入口:顶栏快捷 + Tab 分组 + 4 列金刚区。
|
||||
* 不用 wd-collapse/cell 白底列表,暗色走 AppPageShell admin。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppFloatingTabbar from '@/components/layout/AppFloatingTabbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { ADMIN_MENUS, type MenuItem } from '@/config/menus'
|
||||
|
||||
const { isAuthenticated } = useAuth()
|
||||
const { canEnterAdmin } = useAppMode()
|
||||
const { apply } = useTheme()
|
||||
|
||||
/** 0 = 全部,其余对应 groups 下标 +1 */
|
||||
const tabIndex = ref(0)
|
||||
|
||||
const quickLinks = computed(() => ADMIN_MENUS.filter(m => m.path))
|
||||
const groups = computed(() => ADMIN_MENUS.filter(m => m.children?.length))
|
||||
|
||||
const tabLabels = computed(() => ['全部', ...groups.value.map(g => g.title)])
|
||||
|
||||
/** 当前 Tab 下的入口列表(扁平) */
|
||||
const gridItems = computed(() => {
|
||||
if (tabIndex.value === 0) {
|
||||
return groups.value.flatMap(g => g.children || [])
|
||||
}
|
||||
const g = groups.value[tabIndex.value - 1]
|
||||
return g?.children || []
|
||||
})
|
||||
|
||||
function guardAdmin() {
|
||||
if (!isAuthenticated() || !canEnterAdmin.value) {
|
||||
uni.reLaunch({ url: '/pages/mine/index' })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
apply()
|
||||
guardAdmin()
|
||||
})
|
||||
|
||||
const open = (item: MenuItem) => {
|
||||
if (!item.path) return
|
||||
uni.navigateTo({ url: item.path })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin with-tab-pad>
|
||||
<AppNavbar title="功能入口" />
|
||||
<view class="page-pad">
|
||||
<view class="hero-eyebrow">Admin</view>
|
||||
<view class="section-title" style="margin-bottom: 24rpx;">后台菜单</view>
|
||||
|
||||
<view class="quick-row">
|
||||
<view
|
||||
v-for="item in quickLinks"
|
||||
:key="item.key"
|
||||
class="admin-card quick"
|
||||
@click="open(item)"
|
||||
>
|
||||
<wd-icon :name="item.icon || 'apps'" size="22px" color="rgb(212,179,131)" />
|
||||
<text class="quick-title">{{ item.title }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<wd-tabs v-model="tabIndex" slidable="always">
|
||||
<wd-tab v-for="(label, i) in tabLabels" :key="i" :title="label" :name="i" />
|
||||
</wd-tabs>
|
||||
|
||||
<view class="grid">
|
||||
<view
|
||||
v-for="item in gridItems"
|
||||
:key="item.key"
|
||||
class="admin-card cell"
|
||||
@click="open(item)"
|
||||
>
|
||||
<wd-icon :name="item.icon || 'apps'" size="26px" color="rgb(212,179,131)" />
|
||||
<text class="cell-title">{{ item.title }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<wd-empty v-if="!gridItems.length" description="暂无入口" />
|
||||
</view>
|
||||
<AppFloatingTabbar />
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 48rpx; }
|
||||
.quick-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
.quick {
|
||||
padding: 28rpx 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.quick-title { font-size: 28rpx; color: rgb(var(--art-text)); }
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16rpx;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.cell {
|
||||
padding: 24rpx 8rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12rpx;
|
||||
min-height: 160rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.cell-title {
|
||||
font-size: 22rpx;
|
||||
color: rgb(var(--art-text));
|
||||
line-height: 1.3;
|
||||
}
|
||||
</style>
|
||||
150
src/subPackages/admin/pages/inquiries/list.vue
Normal file
150
src/subPackages/admin/pages/inquiries/list.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 合作咨询:只读列表 + page-container 详情 + 状态流转(未读→已读→已联系)。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminFilterBar from '../../components/AdminFilterBar.vue'
|
||||
import AdminStatusBadge from '../../components/AdminStatusBadge.vue'
|
||||
import AdminDetailDrawer from '../../components/AdminDetailDrawer.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import { adminList, normalizeAdminList, updateInquiryStatus, type Inquiry } from '@/api'
|
||||
|
||||
const STATUS_MAP: Record<string, string> = {
|
||||
'0': '未读',
|
||||
'1': '已读',
|
||||
'2': '已联系',
|
||||
}
|
||||
|
||||
const toast = useToast()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
const loading = ref(false)
|
||||
const list = ref<Inquiry[]>([])
|
||||
const keyword = ref('')
|
||||
const filters = ref<Record<string, unknown>>({})
|
||||
const detailShow = ref(false)
|
||||
const current = ref<Inquiry | null>(null)
|
||||
|
||||
const filterDefs = [
|
||||
{ key: 'keyword', label: '关键词', type: 'text' as const },
|
||||
{
|
||||
key: 'status',
|
||||
label: '状态',
|
||||
type: 'select' as const,
|
||||
options: [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '未读', value: 0 },
|
||||
{ label: '已读', value: 1 },
|
||||
{ label: '已联系', value: 2 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
list.value = normalizeAdminList(await adminList('inquiries', {
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
keyword: keyword.value || undefined,
|
||||
...filters.value,
|
||||
})) as Inquiry[]
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
|
||||
async function openDetail(item: Inquiry) {
|
||||
current.value = { ...item }
|
||||
detailShow.value = true
|
||||
// 未读自动标已读
|
||||
if (Number(item.status) === 0) {
|
||||
try {
|
||||
await updateInquiryStatus(item.id, 1)
|
||||
item.status = 1
|
||||
current.value.status = 1
|
||||
}
|
||||
catch { /* 忽略自动标记失败 */ }
|
||||
}
|
||||
}
|
||||
|
||||
async function setStatus(status: number) {
|
||||
if (!current.value?.id) return
|
||||
try {
|
||||
await updateInquiryStatus(current.value.id, status)
|
||||
current.value.status = status
|
||||
const hit = list.value.find(i => i.id === current.value!.id)
|
||||
if (hit) hit.status = status
|
||||
toast.success('状态已更新')
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
function detailRows() {
|
||||
const c = current.value
|
||||
if (!c) return []
|
||||
return [
|
||||
{ label: '姓名', value: String(c.name || '') },
|
||||
{ label: '公司', value: String(c.company || '') },
|
||||
{ label: '联系方式', value: `${c.contactMethod || ''} ${c.contactValue || ''}` },
|
||||
{ label: '预算', value: String(c.budget || '') },
|
||||
{ label: '需求', value: String(c.description || '') },
|
||||
{ label: '状态', value: STATUS_MAP[String(c.status ?? 0)] || '' },
|
||||
{ label: '时间', value: String(c.createdAt || '') },
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar title="合作咨询" show-back />
|
||||
<view class="page-pad">
|
||||
<AdminFilterBar
|
||||
v-model:keyword="keyword"
|
||||
v-model="filters"
|
||||
:filters="filterDefs"
|
||||
@search="load"
|
||||
@reset="load"
|
||||
/>
|
||||
<view v-for="item in list" :key="item.id" class="admin-card row" @click="openDetail(item)">
|
||||
<view class="main">
|
||||
<view class="title-row">
|
||||
<text class="title">{{ item.name || '匿名' }}</text>
|
||||
<AdminStatusBadge :value="item.status" :map="STATUS_MAP" />
|
||||
</view>
|
||||
<text class="sub text-art-muted">{{ item.company || '—' }} · {{ item.createdAt || '' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
<wd-empty v-if="!loading && !list.length" description="暂无咨询" />
|
||||
</view>
|
||||
|
||||
<AdminDetailDrawer v-model:show="detailShow" title="咨询详情" :rows="detailRows()">
|
||||
<template #footer>
|
||||
<wd-button size="small" @click="setStatus(1)">标为已读</wd-button>
|
||||
<wd-button size="small" type="primary" @click="setStatus(2)">标为已联系</wd-button>
|
||||
</template>
|
||||
</AdminDetailDrawer>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 80rpx; }
|
||||
.row { padding: 24rpx 28rpx; margin-bottom: 16rpx; }
|
||||
.main { display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.title-row { display: flex; align-items: center; gap: 12rpx; }
|
||||
.title { flex: 1; font-size: 30rpx; color: rgb(var(--art-text)); }
|
||||
.sub { font-size: 24rpx; }
|
||||
</style>
|
||||
206
src/subPackages/admin/pages/oss-configs/index.vue
Normal file
206
src/subPackages/admin/pages/oss-configs/index.vue
Normal file
@@ -0,0 +1,206 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* OSS 配置:分厂商 Tab,保存 / 测试连接 / 删除。
|
||||
*/
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { useDialog, useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import { adminCreate, adminDelete, adminList, adminUpdate, normalizeAdminList, testOssConfig } from '@/api'
|
||||
|
||||
type StorageType = 'local' | 'aliyun' | 'qcloud' | 'qiniu'
|
||||
|
||||
const TABS: { id: StorageType, label: string }[] = [
|
||||
{ id: 'local', label: '本地' },
|
||||
{ id: 'aliyun', label: '阿里云' },
|
||||
{ id: 'qcloud', label: '腾讯云' },
|
||||
{ id: 'qiniu', label: '七牛' },
|
||||
]
|
||||
|
||||
const toast = useToast()
|
||||
const dialog = useDialog()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const tab = ref(0)
|
||||
const configs = ref<Record<string, unknown>[]>([])
|
||||
|
||||
const form = reactive({
|
||||
id: '' as string | number,
|
||||
name: '',
|
||||
storageType: 'local' as string,
|
||||
isActive: 1 as number,
|
||||
accessKeyId: '',
|
||||
accessKeySecret: '',
|
||||
secretId: '',
|
||||
secretKey: '',
|
||||
accessKey: '',
|
||||
endpoint: '',
|
||||
region: '',
|
||||
bucket: '',
|
||||
domain: '',
|
||||
})
|
||||
|
||||
const currentType = computed(() => TABS[tab.value]?.id || 'local')
|
||||
|
||||
function fillForm(row?: Record<string, unknown>) {
|
||||
const type = currentType.value
|
||||
form.id = ''
|
||||
form.accessKeyId = ''
|
||||
form.accessKeySecret = ''
|
||||
form.secretId = ''
|
||||
form.secretKey = ''
|
||||
form.accessKey = ''
|
||||
form.endpoint = ''
|
||||
form.region = ''
|
||||
form.bucket = ''
|
||||
form.domain = ''
|
||||
form.storageType = type
|
||||
form.isActive = 1
|
||||
form.name = `${TABS[tab.value]?.label || type} 存储`
|
||||
if (!row) return
|
||||
form.id = (row.id as string | number) || ''
|
||||
form.name = String(row.name || form.name)
|
||||
form.storageType = String(row.storageType || type)
|
||||
form.isActive = row.isActive === 0 || row.isActive === false ? 0 : 1
|
||||
form.accessKeyId = String(row.accessKeyId || '')
|
||||
form.accessKeySecret = row.accessKeySecret === '***' ? '' : String(row.accessKeySecret || '')
|
||||
form.secretId = String(row.secretId || '')
|
||||
form.secretKey = row.secretKey === '***' ? '' : String(row.secretKey || '')
|
||||
form.accessKey = String(row.accessKey || '')
|
||||
form.endpoint = String(row.endpoint || '')
|
||||
form.region = String(row.region || '')
|
||||
form.bucket = String(row.bucket || '')
|
||||
form.domain = String(row.domain || '')
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
configs.value = normalizeAdminList(await adminList('oss-configs'))
|
||||
const hit = configs.value.find(c => String(c.storageType) === currentType.value)
|
||||
fillForm(hit)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onTabChange() {
|
||||
const hit = configs.value.find(c => String(c.storageType) === currentType.value)
|
||||
fillForm(hit)
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
|
||||
async function onSave() {
|
||||
submitting.value = true
|
||||
try {
|
||||
const { id, ...rest } = form
|
||||
const payload = { ...rest }
|
||||
if (id) await adminUpdate('oss-configs', id, payload)
|
||||
else await adminCreate('oss-configs', payload)
|
||||
toast.success('已保存')
|
||||
load()
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onTest() {
|
||||
if (!form.id) {
|
||||
toast.show('请先保存配置')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await testOssConfig(form.id as string | number)
|
||||
toast.success('连接成功')
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '测试失败')
|
||||
}
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
if (!form.id) return
|
||||
dialog.confirm({ title: '确认删除', msg: '确定删除该 OSS 配置吗?' }).then(async () => {
|
||||
try {
|
||||
await adminDelete('oss-configs', form.id as string | number)
|
||||
toast.success('已删除')
|
||||
load()
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar title="OSS 配置" show-back />
|
||||
<view class="page-pad">
|
||||
<wd-tabs v-model="tab" @change="onTabChange">
|
||||
<wd-tab v-for="t in TABS" :key="t.id" :title="t.label" />
|
||||
</wd-tabs>
|
||||
<view class="admin-card form-card">
|
||||
<wd-input v-model="form.name" label="名称" clearable />
|
||||
<view class="switch-row">
|
||||
<text>启用</text>
|
||||
<wd-switch :model-value="!!form.isActive" @change="(v: boolean) => form.isActive = v ? 1 : 0" />
|
||||
</view>
|
||||
<template v-if="currentType === 'aliyun'">
|
||||
<wd-input v-model="form.accessKeyId" label="AccessKeyId" clearable />
|
||||
<wd-input v-model="form.accessKeySecret" label="AccessKeySecret" clearable />
|
||||
<wd-input v-model="form.endpoint" label="Endpoint" clearable />
|
||||
<wd-input v-model="form.bucket" label="Bucket" clearable />
|
||||
<wd-input v-model="form.domain" label="Domain" clearable />
|
||||
</template>
|
||||
<template v-else-if="currentType === 'qcloud'">
|
||||
<wd-input v-model="form.secretId" label="SecretId" clearable />
|
||||
<wd-input v-model="form.secretKey" label="SecretKey" clearable />
|
||||
<wd-input v-model="form.region" label="Region" clearable />
|
||||
<wd-input v-model="form.bucket" label="Bucket" clearable />
|
||||
<wd-input v-model="form.domain" label="Domain" clearable />
|
||||
</template>
|
||||
<template v-else-if="currentType === 'qiniu'">
|
||||
<wd-input v-model="form.accessKey" label="AccessKey" clearable />
|
||||
<wd-input v-model="form.secretKey" label="SecretKey" clearable />
|
||||
<wd-input v-model="form.bucket" label="Bucket" clearable />
|
||||
<wd-input v-model="form.region" label="Region" clearable />
|
||||
<wd-input v-model="form.domain" label="Domain" clearable />
|
||||
</template>
|
||||
<template v-else>
|
||||
<text class="hint text-art-muted">本地存储无需额外密钥,仅需启用即可。</text>
|
||||
</template>
|
||||
<view class="actions">
|
||||
<wd-button type="primary" :loading="submitting" @click="onSave">保存</wd-button>
|
||||
<wd-button plain @click="onTest">测试连接</wd-button>
|
||||
<wd-button v-if="form.id" type="warning" plain @click="onDelete">删除</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 80rpx; }
|
||||
.form-card { padding: 24rpx; margin-top: 20rpx; }
|
||||
.switch-row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 20rpx 0; color: rgb(var(--art-text));
|
||||
}
|
||||
.hint { font-size: 24rpx; display: block; margin: 16rpx 0; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 12rpx; margin-top: 24rpx; }
|
||||
</style>
|
||||
354
src/subPackages/admin/pages/posts/form.vue
Normal file
354
src/subPackages/admin/pages/posts/form.vue
Normal file
@@ -0,0 +1,354 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文章编辑表单:结构化字段 + 简易 Markdown 工具条(插图走附件上传)。
|
||||
* 分类/专栏用 wd-picker,空列时塞占位项,避免 wot 内部 map 读 value of undefined。
|
||||
* 版本历史/访问日志共用一个 page-container(小程序同页只能有一个)。
|
||||
*/
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import { useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminMediaPicker from '../../components/AdminMediaPicker.vue'
|
||||
import AdminDetailDrawer from '../../components/AdminDetailDrawer.vue'
|
||||
import AdminFormField from '../../components/AdminFormField.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import {
|
||||
adminCreate,
|
||||
adminGetPost,
|
||||
adminList,
|
||||
adminUpdate,
|
||||
getPostAccessLogs,
|
||||
getPostHistory,
|
||||
normalizeAdminList,
|
||||
restorePostVersion,
|
||||
uploadAttachment,
|
||||
type Post,
|
||||
} from '@/api'
|
||||
|
||||
const toast = useToast()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
|
||||
const mode = ref<'create' | 'edit'>('create')
|
||||
const id = ref('')
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const categories = ref<{ id: number, name: string }[]>([])
|
||||
/** 专栏列表;勿命名 columns,避免与 wd-picker 的 columns 属性混淆 */
|
||||
const columnList = ref<{ id: number, name?: string, title?: string }[]>([])
|
||||
const tags = ref<{ id: number, name: string }[]>([])
|
||||
const selectedTagIds = ref<number[]>([])
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
excerpt: '',
|
||||
content: '',
|
||||
cover: '',
|
||||
categoryId: '' as string | number,
|
||||
columnId: '' as string | number,
|
||||
date: '',
|
||||
isPublished: 1,
|
||||
})
|
||||
|
||||
/** 单一抽屉:history | access,避免双 page-container */
|
||||
const drawerShow = ref(false)
|
||||
const drawerMode = ref<'history' | 'access'>('history')
|
||||
const historyRows = ref<{ label: string, value: string }[]>([])
|
||||
const historyVersions = ref<{ version?: number | string, createdAt?: string }[]>([])
|
||||
const accessRows = ref<{ label: string, value: string }[]>([])
|
||||
|
||||
const pageTitle = computed(() => (mode.value === 'create' ? '新建文章' : '编辑文章'))
|
||||
const drawerTitle = computed(() => (drawerMode.value === 'history' ? '版本历史' : '访问日志'))
|
||||
const drawerRows = computed(() =>
|
||||
drawerMode.value === 'history' ? historyRows.value : accessRows.value,
|
||||
)
|
||||
|
||||
/**
|
||||
* wd-picker 列数据:空数组会触发内部 selectWithValue 崩溃,故至少给一项占位。
|
||||
*/
|
||||
const categoryColumns = computed(() => {
|
||||
const list = categories.value.map(c => ({ label: c.name, value: c.id }))
|
||||
return [list.length ? list : [{ label: '暂无分类', value: '' }]]
|
||||
})
|
||||
const columnColumns = computed(() => {
|
||||
const list = columnList.value.map(c => ({
|
||||
label: c.name || c.title || String(c.id),
|
||||
value: c.id,
|
||||
}))
|
||||
return [list.length ? list : [{ label: '暂无专栏', value: '' }]]
|
||||
})
|
||||
|
||||
function insertMd(prefix: string, suffix = '') {
|
||||
form.content = `${form.content}${prefix}${suffix}`
|
||||
}
|
||||
|
||||
async function insertImage() {
|
||||
try {
|
||||
const res = await uni.chooseImage({ count: 1, sizeType: ['compressed'] })
|
||||
const path = res.tempFilePaths?.[0]
|
||||
if (!path) return
|
||||
const uploaded = await uploadAttachment(path)
|
||||
const url = uploaded.fileUrl || uploaded.url || ''
|
||||
if (url) insertMd(`\n\n`)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
if (e && typeof e === 'object' && 'errMsg' in e && String((e as { errMsg: string }).errMsg).includes('cancel')) return
|
||||
toast.error(e instanceof Error ? e.message : '插图失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMeta() {
|
||||
const [cats, cols, tgs] = await Promise.all([
|
||||
adminList('categories').catch(() => []),
|
||||
adminList('columns').catch(() => []),
|
||||
adminList('tags').catch(() => []),
|
||||
])
|
||||
categories.value = normalizeAdminList(cats) as { id: number, name: string }[]
|
||||
columnList.value = normalizeAdminList(cols) as { id: number, name?: string, title?: string }[]
|
||||
tags.value = normalizeAdminList(tgs) as { id: number, name: string }[]
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
await loadMeta()
|
||||
if (mode.value === 'edit' && id.value) {
|
||||
// 走后台详情接口(含未发布),勿用公开 getPost / 已删除的 adminGet posts
|
||||
const data = await adminGetPost(id.value)
|
||||
form.title = String(data.title || '')
|
||||
form.excerpt = String(data.excerpt || data.summary || '')
|
||||
form.content = String(data.content || '')
|
||||
form.cover = String(data.cover || '')
|
||||
form.categoryId = (data.categoryId as number) || ''
|
||||
form.columnId = (data.columnId as number) || ''
|
||||
form.date = String(data.date || '')
|
||||
form.isPublished = data.isPublished === 0 || data.isPublished === false ? 0 : 1
|
||||
selectedTagIds.value = (data.tags || []).map(t => Number(t.id))
|
||||
}
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((q) => {
|
||||
mode.value = q?.mode === 'edit' ? 'edit' : 'create'
|
||||
id.value = String(q?.id || '')
|
||||
})
|
||||
onShow(load)
|
||||
|
||||
function toggleTag(tid: number) {
|
||||
const i = selectedTagIds.value.indexOf(tid)
|
||||
if (i >= 0) selectedTagIds.value.splice(i, 1)
|
||||
else selectedTagIds.value.push(tid)
|
||||
}
|
||||
|
||||
function onCatConfirm(payload: { value?: unknown[] }) {
|
||||
const v = payload?.value?.[0]
|
||||
const raw = (v && typeof v === 'object' && 'value' in v)
|
||||
? (v as { value: unknown }).value
|
||||
: v
|
||||
form.categoryId = (raw as number) || ''
|
||||
}
|
||||
|
||||
function onColConfirm(payload: { value?: unknown[] }) {
|
||||
const v = payload?.value?.[0]
|
||||
const raw = (v && typeof v === 'object' && 'value' in v)
|
||||
? (v as { value: unknown }).value
|
||||
: v
|
||||
form.columnId = (raw as number) || ''
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.title.trim()) {
|
||||
toast.show('请填写标题')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload = {
|
||||
title: form.title,
|
||||
excerpt: form.excerpt,
|
||||
content: form.content,
|
||||
cover: form.cover,
|
||||
categoryId: form.categoryId ? Number(form.categoryId) : undefined,
|
||||
columnId: form.columnId ? Number(form.columnId) : undefined,
|
||||
date: form.date || undefined,
|
||||
isPublished: form.isPublished,
|
||||
tagIds: selectedTagIds.value,
|
||||
}
|
||||
if (mode.value === 'edit' && id.value) await adminUpdate('posts', id.value, payload)
|
||||
else await adminCreate('posts', payload)
|
||||
toast.success('已保存')
|
||||
setTimeout(() => uni.navigateBack(), 400)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openHistory() {
|
||||
if (!id.value) return
|
||||
try {
|
||||
const list = await getPostHistory(id.value)
|
||||
historyVersions.value = (Array.isArray(list) ? list : []) as typeof historyVersions.value
|
||||
historyRows.value = historyVersions.value.map((v, i) => ({
|
||||
label: `版本 ${v.version ?? i + 1}`,
|
||||
value: String(v.createdAt || ''),
|
||||
}))
|
||||
drawerMode.value = 'history'
|
||||
drawerShow.value = true
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载历史失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function restore(version: string | number) {
|
||||
try {
|
||||
await restorePostVersion(id.value, version)
|
||||
toast.success('已恢复')
|
||||
drawerShow.value = false
|
||||
load()
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '恢复失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function openAccess() {
|
||||
if (!id.value) return
|
||||
try {
|
||||
const data = await getPostAccessLogs(id.value, { page: 1, pageSize: 20 })
|
||||
const list = Array.isArray(data) ? data : data.list || []
|
||||
accessRows.value = list.map((row: unknown, i: number) => {
|
||||
const r = row as Record<string, unknown>
|
||||
return {
|
||||
label: `#${i + 1} ${r.path || r.ip || ''}`,
|
||||
value: String(r.createdAt || r.region || ''),
|
||||
}
|
||||
})
|
||||
drawerMode.value = 'access'
|
||||
drawerShow.value = true
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载访问日志失败')
|
||||
}
|
||||
}
|
||||
|
||||
function catLabel() {
|
||||
return categories.value.find(c => String(c.id) === String(form.categoryId))?.name || '选择分类'
|
||||
}
|
||||
function colLabel() {
|
||||
const c = columnList.value.find(c => String(c.id) === String(form.columnId))
|
||||
return c?.name || c?.title || '选择专栏(可选)'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar :title="pageTitle" show-back />
|
||||
<view class="page-pad">
|
||||
<view class="admin-card form-card">
|
||||
<AdminFormField label="标题" required>
|
||||
<wd-input v-model="form.title" placeholder="请输入标题" clearable />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="摘要">
|
||||
<wd-textarea v-model="form.excerpt" placeholder="文章摘要" />
|
||||
</AdminFormField>
|
||||
<AdminMediaPicker v-model="form.cover" label="封面" />
|
||||
<wd-picker :columns="categoryColumns" @confirm="onCatConfirm">
|
||||
<view class="picker-row"><text>分类</text><text class="val">{{ catLabel() }}</text></view>
|
||||
</wd-picker>
|
||||
<wd-picker :columns="columnColumns" @confirm="onColConfirm">
|
||||
<view class="picker-row"><text>专栏</text><text class="val">{{ colLabel() }}</text></view>
|
||||
</wd-picker>
|
||||
<AdminFormField label="日期">
|
||||
<wd-input v-model="form.date" placeholder="YYYY-MM-DD" clearable />
|
||||
</AdminFormField>
|
||||
<view class="switch-row">
|
||||
<text>发布</text>
|
||||
<wd-switch :model-value="!!form.isPublished" @change="(v: boolean) => form.isPublished = v ? 1 : 0" />
|
||||
</view>
|
||||
<view class="tags">
|
||||
<text class="label text-art-muted">标签</text>
|
||||
<view class="tag-wrap">
|
||||
<view
|
||||
v-for="t in tags"
|
||||
:key="t.id"
|
||||
class="tag"
|
||||
:class="{ on: selectedTagIds.includes(t.id) }"
|
||||
@click="toggleTag(t.id)"
|
||||
>
|
||||
{{ t.name }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="md-tools">
|
||||
<wd-button size="small" plain @click="insertMd('**', '**')">加粗</wd-button>
|
||||
<wd-button size="small" plain @click="insertMd('[链接](', ')')">链接</wd-button>
|
||||
<wd-button size="small" plain @click="insertImage">插图</wd-button>
|
||||
</view>
|
||||
<AdminFormField label="正文 Markdown" required>
|
||||
<wd-textarea v-model="form.content" placeholder="支持 Markdown" :rows="12" />
|
||||
</AdminFormField>
|
||||
<view v-if="mode === 'edit'" class="extra">
|
||||
<wd-button size="small" plain @click="openHistory">版本历史</wd-button>
|
||||
<wd-button size="small" plain @click="openAccess">访问日志</wd-button>
|
||||
</view>
|
||||
<wd-button
|
||||
block
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
custom-style="margin-top:24rpx;background:#d4b383;border-color:#d4b383;"
|
||||
@click="onSubmit"
|
||||
>
|
||||
保存
|
||||
</wd-button>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
</view>
|
||||
|
||||
<AdminDetailDrawer v-model:show="drawerShow" :title="drawerTitle" :rows="drawerRows">
|
||||
<template v-if="drawerMode === 'history'">
|
||||
<view v-for="(v, i) in historyVersions" :key="i" class="hist-row">
|
||||
<wd-button size="small" @click="restore(v.version ?? i + 1)">恢复此版本</wd-button>
|
||||
</view>
|
||||
</template>
|
||||
</AdminDetailDrawer>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 80rpx; }
|
||||
.picker-row, .switch-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20rpx 0;
|
||||
color: rgb(var(--art-text));
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.val { color: rgba(255, 255, 255, 0.6); font-size: 26rpx; }
|
||||
.tags { margin: 16rpx 0; }
|
||||
.label { font-size: 24rpx; }
|
||||
.tag-wrap { display: flex; flex-wrap: wrap; gap: 12rpx; margin-top: 12rpx; }
|
||||
.tag {
|
||||
padding: 8rpx 18rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 24rpx;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
.tag.on { background: rgba(212, 179, 131, 0.25); color: rgb(var(--art-accent)); }
|
||||
.md-tools { display: flex; gap: 12rpx; margin: 16rpx 0; flex-wrap: wrap; }
|
||||
.extra { display: flex; gap: 12rpx; margin-top: 16rpx; }
|
||||
.hist-row { margin: 8rpx 0; }
|
||||
</style>
|
||||
187
src/subPackages/admin/pages/posts/list.vue
Normal file
187
src/subPackages/admin/pages/posts/list.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文章管理列表:服务端分页 + 筛选 + 行内发布切换 + 删除。
|
||||
* 关联/历史/访问日志放在编辑页,降低列表复杂度。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { onReachBottom, onShow } from '@dcloudio/uni-app'
|
||||
import { useDialog, useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminFilterBar from '../../components/AdminFilterBar.vue'
|
||||
import AdminStatusBadge from '../../components/AdminStatusBadge.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import {
|
||||
adminDelete,
|
||||
adminList,
|
||||
normalizePagination,
|
||||
patchPostStatus,
|
||||
type Post,
|
||||
} from '@/api'
|
||||
|
||||
const toast = useToast()
|
||||
const dialog = useDialog()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<Post[]>([])
|
||||
const keyword = ref('')
|
||||
const filters = ref<Record<string, unknown>>({})
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const finished = ref(false)
|
||||
|
||||
const filterDefs = [
|
||||
{ key: 'keyword', label: '关键词', type: 'text' as const },
|
||||
{
|
||||
key: 'isPublished',
|
||||
label: '发布',
|
||||
type: 'select' as const,
|
||||
options: [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '已发布', value: 1 },
|
||||
{ label: '草稿', value: 0 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
async function load(reset = true) {
|
||||
if (!guardAdmin()) return
|
||||
if (reset) {
|
||||
page.value = 1
|
||||
finished.value = false
|
||||
list.value = []
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await adminList('posts', {
|
||||
page: page.value,
|
||||
pageSize: 20,
|
||||
keyword: keyword.value || undefined,
|
||||
...filters.value,
|
||||
})
|
||||
const p = normalizePagination<Post>(data, page.value, 20)
|
||||
list.value = reset ? p.list : [...list.value, ...p.list]
|
||||
total.value = p.total
|
||||
finished.value = list.value.length >= p.total
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(() => load(true))
|
||||
onReachBottom(() => {
|
||||
if (finished.value || loading.value) return
|
||||
page.value += 1
|
||||
load(false)
|
||||
})
|
||||
|
||||
function goCreate() {
|
||||
uni.navigateTo({ url: '/subPackages/admin/pages/posts/form?mode=create' })
|
||||
}
|
||||
|
||||
function goEdit(item: Post) {
|
||||
uni.navigateTo({ url: `/subPackages/admin/pages/posts/form?mode=edit&id=${item.id}` })
|
||||
}
|
||||
|
||||
async function togglePublish(item: Post) {
|
||||
const next = item.isPublished === 1 || item.isPublished === true ? 0 : 1
|
||||
try {
|
||||
await patchPostStatus(item.id, next)
|
||||
item.isPublished = next
|
||||
toast.success(next ? '已发布' : '已设为草稿')
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '状态更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
function onDelete(item: Post) {
|
||||
dialog.confirm({ title: '确认删除', msg: `确定删除「${item.title}」吗?` }).then(async () => {
|
||||
try {
|
||||
await adminDelete('posts', item.id)
|
||||
toast.success('已删除')
|
||||
load(true)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar title="文章管理" show-back />
|
||||
<view class="page-pad">
|
||||
<AdminFilterBar
|
||||
v-model:keyword="keyword"
|
||||
v-model="filters"
|
||||
:filters="filterDefs"
|
||||
@search="load(true)"
|
||||
@reset="load(true)"
|
||||
/>
|
||||
<view v-for="item in list" :key="String(item.id)" class="admin-card row" @click="goEdit(item)">
|
||||
<view class="main">
|
||||
<view class="title-row">
|
||||
<text class="title">{{ item.title }}</text>
|
||||
<AdminStatusBadge
|
||||
:value="item.isPublished"
|
||||
:map="{ '1': '已发布', '0': '草稿', true: '已发布', false: '草稿' }"
|
||||
clickable
|
||||
@click="togglePublish(item)"
|
||||
/>
|
||||
</view>
|
||||
<text class="sub text-art-muted">
|
||||
{{ item.categoryName || '未分类' }} · {{ item.date || '' }}
|
||||
</text>
|
||||
</view>
|
||||
<wd-button size="small" type="warning" plain @click.stop="onDelete(item)">删除</wd-button>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
<wd-empty v-if="!loading && !list.length" description="暂无文章" />
|
||||
</view>
|
||||
<view class="fab" @click="goCreate">
|
||||
<wd-icon name="plus" size="24px" color="#fff" />
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 120rpx; }
|
||||
.row {
|
||||
padding: 24rpx 28rpx;
|
||||
margin-bottom: 16rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.title-row { display: flex; align-items: center; gap: 12rpx; }
|
||||
.title {
|
||||
flex: 1;
|
||||
font-size: 30rpx;
|
||||
color: rgb(var(--art-text));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sub { font-size: 24rpx; }
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 40rpx;
|
||||
bottom: calc(40rpx + env(safe-area-inset-bottom));
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 50%;
|
||||
background: rgb(var(--art-accent));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
</style>
|
||||
199
src/subPackages/admin/pages/profile/index.vue
Normal file
199
src/subPackages/admin/pages/profile/index.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 后台个人中心:资料编辑(头像/联系方式)+ 修改密码。
|
||||
*/
|
||||
import { reactive, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppFloatingTabbar from '@/components/layout/AppFloatingTabbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminMediaPicker from '../../components/AdminMediaPicker.vue'
|
||||
import AdminFormField from '../../components/AdminFormField.vue'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { getCurrentUser, updateMe, updatePassword } from '@/api'
|
||||
import { resolveMediaUrl } from '@/utils/request'
|
||||
|
||||
const toast = useToast()
|
||||
const { isAuthenticated, getUser, setUser } = useAuth()
|
||||
const { canEnterAdmin } = useAppMode()
|
||||
const { apply } = useTheme()
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const pwdSubmitting = ref(false)
|
||||
const user = ref(getUser())
|
||||
const profile = reactive({
|
||||
email: '',
|
||||
bio: '',
|
||||
phone: '',
|
||||
wechat: '',
|
||||
avatar: '',
|
||||
wechatQrcode: '',
|
||||
})
|
||||
const form = reactive({ oldPassword: '', newPassword: '', confirm: '' })
|
||||
|
||||
function guardAdmin() {
|
||||
if (!isAuthenticated() || !canEnterAdmin.value) {
|
||||
uni.reLaunch({ url: '/pages/mine/index' })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function load() {
|
||||
apply()
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
const me = await getCurrentUser()
|
||||
user.value = { ...me } as typeof user.value
|
||||
profile.email = me.email || ''
|
||||
profile.bio = me.bio || ''
|
||||
profile.phone = me.phone || ''
|
||||
profile.wechat = me.wechat || ''
|
||||
profile.avatar = me.avatar || ''
|
||||
profile.wechatQrcode = me.wechatQrcode || ''
|
||||
}
|
||||
catch {
|
||||
user.value = getUser()
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
|
||||
async function saveProfile() {
|
||||
submitting.value = true
|
||||
try {
|
||||
const updated = await updateMe({ ...profile })
|
||||
if (updated) setUser(updated as never)
|
||||
toast.success('资料已保存')
|
||||
load()
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!form.oldPassword || !form.newPassword) {
|
||||
toast.show('请填写原密码与新密码')
|
||||
return
|
||||
}
|
||||
if (form.newPassword !== form.confirm) {
|
||||
toast.show('两次新密码不一致')
|
||||
return
|
||||
}
|
||||
pwdSubmitting.value = true
|
||||
try {
|
||||
await updatePassword(form.oldPassword, form.newPassword)
|
||||
toast.success('密码已更新')
|
||||
form.oldPassword = ''
|
||||
form.newPassword = ''
|
||||
form.confirm = ''
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '更新失败')
|
||||
}
|
||||
finally {
|
||||
pwdSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin with-tab-pad>
|
||||
<AppNavbar title="个人中心" show-back />
|
||||
<view class="page-pad">
|
||||
<view class="avatar-row">
|
||||
<image
|
||||
class="avatar"
|
||||
:src="resolveMediaUrl(profile.avatar || user.avatar) || '/static/logo.svg'"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="meta">
|
||||
<text class="name">{{ user.username }}</text>
|
||||
<text class="role text-art-accent font-mono-label">{{ user.role || 'staff' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="admin-card form-card">
|
||||
<view class="section-title" style="font-size: 34rpx; margin-bottom: 24rpx;">个人资料</view>
|
||||
<AdminMediaPicker v-model="profile.avatar" label="头像" />
|
||||
<AdminFormField label="邮箱">
|
||||
<wd-input v-model="profile.email" placeholder="邮箱" clearable />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="简介">
|
||||
<wd-textarea v-model="profile.bio" placeholder="简介" />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="手机">
|
||||
<wd-input v-model="profile.phone" placeholder="手机" clearable />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="微信">
|
||||
<wd-input v-model="profile.wechat" placeholder="微信" clearable />
|
||||
</AdminFormField>
|
||||
<AdminMediaPicker v-model="profile.wechatQrcode" label="微信二维码" />
|
||||
<wd-button
|
||||
block
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
custom-style="margin-top:24rpx;background:#d4b383;border-color:#d4b383;"
|
||||
@click="saveProfile"
|
||||
>
|
||||
保存资料
|
||||
</wd-button>
|
||||
</view>
|
||||
|
||||
<view class="admin-card form-card" style="margin-top: 24rpx;">
|
||||
<view class="section-title" style="font-size: 34rpx; margin-bottom: 24rpx;">修改密码</view>
|
||||
<AdminFormField label="原密码">
|
||||
<wd-input v-model="form.oldPassword" placeholder="原密码" show-password clearable />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="新密码">
|
||||
<wd-input v-model="form.newPassword" placeholder="新密码" show-password clearable />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="确认新密码">
|
||||
<wd-input v-model="form.confirm" placeholder="确认新密码" show-password clearable />
|
||||
</AdminFormField>
|
||||
<wd-button
|
||||
block
|
||||
type="primary"
|
||||
:loading="pwdSubmitting"
|
||||
custom-style="margin-top:24rpx;background:#d4b383;border-color:#d4b383;"
|
||||
@click="onSubmit"
|
||||
>
|
||||
更新密码
|
||||
</wd-button>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
</view>
|
||||
<AppFloatingTabbar />
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 48rpx; }
|
||||
.avatar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
.avatar {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.meta { display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.name { font-size: 36rpx; color: rgb(var(--art-text)); font-weight: 600; }
|
||||
.role { font-size: 22rpx; }
|
||||
</style>
|
||||
251
src/subPackages/admin/pages/resource/form.vue
Normal file
251
src/subPackages/admin/pages/resource/form.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 通用资源表单:按 resourceSchemas.fields 渲染,支持 media/switch/select。
|
||||
* JSON 模式仅作高级兜底;复杂资源应走专用页。
|
||||
*/
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import { useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminMediaPicker from '../../components/AdminMediaPicker.vue'
|
||||
import AdminFormField from '../../components/AdminFormField.vue'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { adminCreate, adminGet, adminUpdate } from '@/api'
|
||||
import { getResourceSchema, type SchemaField } from '../../config/resourceSchemas'
|
||||
|
||||
const toast = useToast()
|
||||
const { isAuthenticated } = useAuth()
|
||||
const { canEnterAdmin } = useAppMode()
|
||||
|
||||
const resource = ref('')
|
||||
const mode = ref<'create' | 'edit'>('create')
|
||||
const id = ref('')
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const jsonMode = ref(false)
|
||||
const form = reactive<Record<string, unknown>>({})
|
||||
const rawJson = ref('{}')
|
||||
|
||||
const schema = computed(() => getResourceSchema(resource.value))
|
||||
const fields = computed(() => schema.value.fields.filter(f => !f.editOnly || mode.value === 'edit'))
|
||||
const pageTitle = computed(() => {
|
||||
const label = schema.value.title || resource.value
|
||||
return mode.value === 'create' ? `新建${label}` : `编辑${label}`
|
||||
})
|
||||
|
||||
function guardAdmin() {
|
||||
if (!isAuthenticated() || !canEnterAdmin.value) {
|
||||
uni.reLaunch({ url: '/pages/mine/index' })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function initEmpty() {
|
||||
Object.keys(form).forEach(k => delete form[k])
|
||||
fields.value.forEach((f) => {
|
||||
if (f.type === 'switch') form[f.key] = 1
|
||||
else if (f.type === 'number' || f.type === 'rating') form[f.key] = f.type === 'rating' ? 5 : 0
|
||||
else form[f.key] = ''
|
||||
})
|
||||
rawJson.value = '{}'
|
||||
}
|
||||
|
||||
function fillFromRecord(data: Record<string, unknown>) {
|
||||
Object.keys(form).forEach(k => delete form[k])
|
||||
fields.value.forEach((f) => {
|
||||
const v = data[f.key]
|
||||
if (f.type === 'switch') form[f.key] = v === true || v === 1 || v === '1' ? 1 : 0
|
||||
else if (f.type === 'number' || f.type === 'rating') form[f.key] = Number(v ?? 0)
|
||||
else if (f.key === 'config' && typeof v === 'object') form[f.key] = JSON.stringify(v, null, 2)
|
||||
else form[f.key] = v ?? ''
|
||||
})
|
||||
// 保留未声明字段以便 JSON 模式完整编辑
|
||||
rawJson.value = JSON.stringify(data, null, 2)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!resource.value || !guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
if (mode.value === 'edit' && id.value) {
|
||||
const data = await adminGet(resource.value, id.value) as Record<string, unknown>
|
||||
fillFromRecord(data)
|
||||
}
|
||||
else {
|
||||
initEmpty()
|
||||
}
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((q) => {
|
||||
resource.value = String(q?.resource || '')
|
||||
mode.value = (q?.mode === 'edit' ? 'edit' : 'create') as 'create' | 'edit'
|
||||
id.value = String(q?.id || '')
|
||||
// 无字段的资源强制 JSON
|
||||
if (!getResourceSchema(resource.value).fields.length) jsonMode.value = true
|
||||
})
|
||||
|
||||
onShow(load)
|
||||
|
||||
function buildPayload(): Record<string, unknown> {
|
||||
if (jsonMode.value) {
|
||||
try {
|
||||
return JSON.parse(rawJson.value || '{}') as Record<string, unknown>
|
||||
}
|
||||
catch {
|
||||
throw new Error('JSON 格式不正确')
|
||||
}
|
||||
}
|
||||
const payload: Record<string, unknown> = {}
|
||||
fields.value.forEach((f: SchemaField) => {
|
||||
let v = form[f.key]
|
||||
if (f.type === 'number' || f.type === 'rating') v = Number(v ?? 0)
|
||||
if (f.type === 'switch') v = v ? 1 : 0
|
||||
if (f.key === 'config' && typeof v === 'string') {
|
||||
try { v = JSON.parse(v) }
|
||||
catch { /* 保持字符串,后端可自行处理 */ }
|
||||
}
|
||||
payload[f.key] = v
|
||||
})
|
||||
return payload
|
||||
}
|
||||
|
||||
const onSubmit = async () => {
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload = buildPayload()
|
||||
if (mode.value === 'edit' && id.value) await adminUpdate(resource.value, id.value, payload)
|
||||
else await adminCreate(resource.value, payload)
|
||||
toast.success('已保存')
|
||||
setTimeout(() => uni.navigateBack(), 400)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectLabel(f: SchemaField) {
|
||||
const opts = f.options || []
|
||||
const hit = opts.find(o => String(o.value) === String(form[f.key]))
|
||||
return hit?.label || '请选择'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar :title="pageTitle" show-back />
|
||||
<view class="page-pad">
|
||||
<view class="admin-card form-card">
|
||||
<template v-if="!jsonMode && fields.length">
|
||||
<template v-for="f in fields" :key="f.key">
|
||||
<AdminFormField v-if="f.type === 'text' || f.type === 'number'" :label="f.label">
|
||||
<wd-input
|
||||
:model-value="String(form[f.key] ?? '')"
|
||||
:placeholder="f.placeholder || f.label"
|
||||
:disabled="f.readonly"
|
||||
clearable
|
||||
@update:model-value="(v: string | number) => form[f.key] = v"
|
||||
/>
|
||||
</AdminFormField>
|
||||
<AdminFormField v-else-if="f.type === 'textarea'" :label="f.label">
|
||||
<wd-textarea
|
||||
:model-value="String(form[f.key] ?? '')"
|
||||
:placeholder="f.placeholder || f.label"
|
||||
@update:model-value="(v: string) => form[f.key] = v"
|
||||
/>
|
||||
</AdminFormField>
|
||||
<AdminMediaPicker
|
||||
v-else-if="f.type === 'media'"
|
||||
:model-value="String(form[f.key] ?? '')"
|
||||
:label="f.label"
|
||||
@update:model-value="(v: string) => form[f.key] = v"
|
||||
/>
|
||||
<view v-else-if="f.type === 'switch'" class="switch-row">
|
||||
<text>{{ f.label }}</text>
|
||||
<wd-switch
|
||||
:model-value="!!form[f.key]"
|
||||
@change="(v: boolean) => form[f.key] = v ? 1 : 0"
|
||||
/>
|
||||
</view>
|
||||
<view v-else-if="f.type === 'rating'" class="rating-row">
|
||||
<text class="label">{{ f.label }}</text>
|
||||
<view class="stars">
|
||||
<text
|
||||
v-for="n in 5"
|
||||
:key="n"
|
||||
class="star"
|
||||
:class="{ on: Number(form[f.key]) >= n }"
|
||||
@click="form[f.key] = n"
|
||||
>
|
||||
★
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<wd-picker
|
||||
v-else-if="f.type === 'select' && f.options"
|
||||
:columns="[f.options.map(o => ({ label: o.label, value: o.value }))]"
|
||||
@confirm="({ value }: { value: unknown[] }) => { form[f.key] = (value?.[0] as { value?: unknown })?.value ?? value?.[0] }"
|
||||
>
|
||||
<view class="picker-row">
|
||||
<text class="label">{{ f.label }}</text>
|
||||
<text class="val">{{ selectLabel(f) }}</text>
|
||||
</view>
|
||||
</wd-picker>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<AdminFormField label="JSON">
|
||||
<wd-textarea v-model="rawJson" placeholder="{}" />
|
||||
</AdminFormField>
|
||||
</template>
|
||||
|
||||
<view class="actions">
|
||||
<wd-button size="small" plain @click="jsonMode = !jsonMode">
|
||||
{{ jsonMode ? '表单模式' : 'JSON 模式' }}
|
||||
</wd-button>
|
||||
</view>
|
||||
|
||||
<wd-button
|
||||
block
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
custom-style="margin-top:24rpx;background:#d4b383;border-color:#d4b383;"
|
||||
@click="onSubmit"
|
||||
>
|
||||
保存
|
||||
</wd-button>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 80rpx; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 12rpx; margin-top: 16rpx; }
|
||||
.switch-row, .picker-row, .rating-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20rpx 0;
|
||||
color: rgb(var(--art-text));
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.label { font-size: 28rpx; }
|
||||
.val { font-size: 26rpx; color: rgba(255, 255, 255, 0.65); }
|
||||
.stars { display: flex; gap: 8rpx; }
|
||||
.star { font-size: 36rpx; color: rgba(255, 255, 255, 0.25); }
|
||||
.star.on { color: rgb(var(--art-accent)); }
|
||||
</style>
|
||||
404
src/subPackages/admin/pages/resource/list.vue
Normal file
404
src/subPackages/admin/pages/resource/list.vue
Normal file
@@ -0,0 +1,404 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 通用资源列表:按 resourceSchemas 渲染筛选/行信息/真只读。
|
||||
* 复杂模块已拆专用页;本页兜底简单 CRUD 与只读日志类资源。
|
||||
*/
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { onLoad, onReachBottom, onShow } from '@dcloudio/uni-app'
|
||||
import { useDialog, useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminFilterBar from '../../components/AdminFilterBar.vue'
|
||||
import AdminStatusBadge from '../../components/AdminStatusBadge.vue'
|
||||
import AdminDetailDrawer from '../../components/AdminDetailDrawer.vue'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { adminCreate, adminDelete, adminList, normalizeAdminList, normalizePagination } from '@/api'
|
||||
import { resolveMediaUrl } from '@/utils/request'
|
||||
import { getResourceSchema, pickField } from '../../config/resourceSchemas'
|
||||
|
||||
/** 已拆专用页的资源:进入通用 list 时自动跳转 */
|
||||
const DEDICATED: Record<string, string> = {
|
||||
posts: '/subPackages/admin/pages/posts/list',
|
||||
works: '/subPackages/admin/pages/works/list',
|
||||
videos: '/subPackages/admin/pages/videos/list',
|
||||
attachments: '/subPackages/admin/pages/attachments/index',
|
||||
inquiries: '/subPackages/admin/pages/inquiries/list',
|
||||
'oss-configs': '/subPackages/admin/pages/oss-configs/index',
|
||||
about: '/subPackages/admin/pages/about/index',
|
||||
settings: '/subPackages/admin/pages/settings/index',
|
||||
logs: '/subPackages/admin/pages/logs/operation',
|
||||
'access-logs': '/subPackages/admin/pages/logs/access',
|
||||
users: '/subPackages/admin/pages/users/list',
|
||||
}
|
||||
|
||||
const toast = useToast()
|
||||
const dialog = useDialog()
|
||||
const { isAuthenticated } = useAuth()
|
||||
const { canEnterAdmin } = useAppMode()
|
||||
|
||||
const resource = ref('')
|
||||
const loading = ref(false)
|
||||
const list = ref<Record<string, unknown>[]>([])
|
||||
const keyword = ref('')
|
||||
const filters = ref<Record<string, unknown>>({})
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const finished = ref(false)
|
||||
|
||||
const drawerShow = ref(false)
|
||||
const drawerRows = ref<{ label: string, value: string }[]>([])
|
||||
const inlineForm = reactive<Record<string, unknown>>({})
|
||||
const inlineOpen = ref(false)
|
||||
|
||||
const schema = computed(() => getResourceSchema(resource.value))
|
||||
const pageTitle = computed(() => schema.value.title)
|
||||
const showFab = computed(() => {
|
||||
if (!resource.value) return false
|
||||
if (schema.value.readonly || schema.value.noEdit) return false
|
||||
if (schema.value.inlineCreate) return false
|
||||
return true
|
||||
})
|
||||
const canDelete = computed(() => !schema.value.readonly)
|
||||
const canEdit = computed(() => !schema.value.readonly && !schema.value.noEdit)
|
||||
|
||||
function guardAdmin() {
|
||||
if (!isAuthenticated() || !canEnterAdmin.value) {
|
||||
uni.reLaunch({ url: '/pages/mine/index' })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function itemTitle(item: Record<string, unknown>) {
|
||||
return pickField(item, schema.value.titleKeys) || String(item.id || '未命名')
|
||||
}
|
||||
|
||||
function itemSubtitle(item: Record<string, unknown>) {
|
||||
return pickField(item, schema.value.subtitleKeys)
|
||||
}
|
||||
|
||||
function coverUrl(item: Record<string, unknown>) {
|
||||
const key = schema.value.coverKey
|
||||
if (!key) return ''
|
||||
return resolveMediaUrl(String(item[key] || ''))
|
||||
}
|
||||
|
||||
function matchLocal(item: Record<string, unknown>) {
|
||||
const kw = keyword.value.trim().toLowerCase()
|
||||
if (!kw) return true
|
||||
const keys = schema.value.localSearchKeys || schema.value.titleKeys || []
|
||||
return keys.some(k => String(item[k] ?? '').toLowerCase().includes(kw))
|
||||
}
|
||||
|
||||
function matchFilters(item: Record<string, unknown>) {
|
||||
for (const [k, v] of Object.entries(filters.value)) {
|
||||
if (v === '' || v == null) continue
|
||||
if (k === 'keyword') continue
|
||||
if (String(item[k] ?? '') !== String(v)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function load(reset = true) {
|
||||
if (!resource.value || !guardAdmin()) return
|
||||
if (reset) {
|
||||
page.value = 1
|
||||
finished.value = false
|
||||
list.value = []
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, unknown> = { ...filters.value }
|
||||
if (keyword.value) params.keyword = keyword.value
|
||||
if (schema.value.serverPaging) {
|
||||
params.page = page.value
|
||||
params.pageSize = schema.value.pageSize || pageSize.value
|
||||
}
|
||||
const data = await adminList(resource.value, params)
|
||||
if (schema.value.serverPaging) {
|
||||
const p = normalizePagination(data, page.value, schema.value.pageSize || pageSize.value)
|
||||
list.value = reset ? p.list : [...list.value, ...p.list]
|
||||
total.value = p.total
|
||||
finished.value = list.value.length >= p.total
|
||||
}
|
||||
else {
|
||||
let rows = normalizeAdminList(data)
|
||||
rows = rows.filter(matchLocal).filter(matchFilters)
|
||||
list.value = rows
|
||||
total.value = rows.length
|
||||
finished.value = true
|
||||
}
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((q) => {
|
||||
resource.value = String(q?.resource || '')
|
||||
const jump = DEDICATED[resource.value]
|
||||
if (jump) {
|
||||
uni.redirectTo({ url: jump })
|
||||
return
|
||||
}
|
||||
pageSize.value = schema.value.pageSize || 20
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
if (DEDICATED[resource.value]) return
|
||||
load(true)
|
||||
})
|
||||
|
||||
onReachBottom(() => {
|
||||
if (!schema.value.serverPaging || finished.value || loading.value) return
|
||||
page.value += 1
|
||||
load(false)
|
||||
})
|
||||
|
||||
const goCreate = () => {
|
||||
if (schema.value.inlineCreate) {
|
||||
Object.keys(inlineForm).forEach(k => delete inlineForm[k])
|
||||
schema.value.fields.forEach((f) => {
|
||||
inlineForm[f.key] = f.type === 'switch' ? 1 : f.type === 'number' ? 0 : ''
|
||||
})
|
||||
inlineOpen.value = true
|
||||
return
|
||||
}
|
||||
// 专用表单入口
|
||||
if (resource.value === 'video-albums') {
|
||||
uni.navigateTo({ url: '/subPackages/admin/pages/video-albums/form?mode=create' })
|
||||
return
|
||||
}
|
||||
if (resource.value === 'snippets') {
|
||||
uni.navigateTo({ url: '/subPackages/admin/pages/snippets/form?mode=create' })
|
||||
return
|
||||
}
|
||||
if (resource.value === 'columns') {
|
||||
uni.navigateTo({ url: '/subPackages/admin/pages/columns/form?mode=create' })
|
||||
return
|
||||
}
|
||||
uni.navigateTo({
|
||||
url: `/subPackages/admin/pages/resource/form?resource=${resource.value}&mode=create`,
|
||||
})
|
||||
}
|
||||
|
||||
const goEdit = (item: Record<string, unknown>) => {
|
||||
if (schema.value.readonly) {
|
||||
openDetail(item)
|
||||
return
|
||||
}
|
||||
if (!canEdit.value) {
|
||||
openDetail(item)
|
||||
return
|
||||
}
|
||||
// 视频专辑编辑走专用 form(含视频多选)
|
||||
if (resource.value === 'video-albums' && item.id != null) {
|
||||
uni.navigateTo({
|
||||
url: `/subPackages/admin/pages/video-albums/form?mode=edit&id=${item.id}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (resource.value === 'snippets' && item.id != null) {
|
||||
uni.navigateTo({
|
||||
url: `/subPackages/admin/pages/snippets/form?mode=edit&id=${item.id}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (resource.value === 'columns' && item.id != null) {
|
||||
uni.navigateTo({
|
||||
url: `/subPackages/admin/pages/columns/form?mode=edit&id=${item.id}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
const id = item.id
|
||||
if (id == null) return
|
||||
uni.navigateTo({
|
||||
url: `/subPackages/admin/pages/resource/form?resource=${resource.value}&mode=edit&id=${id}`,
|
||||
})
|
||||
}
|
||||
|
||||
function openDetail(item: Record<string, unknown>) {
|
||||
drawerRows.value = Object.entries(item)
|
||||
.filter(([k]) => !['password'].includes(k))
|
||||
.slice(0, 30)
|
||||
.map(([label, value]) => ({
|
||||
label,
|
||||
value: typeof value === 'object' ? JSON.stringify(value) : String(value ?? ''),
|
||||
}))
|
||||
drawerShow.value = true
|
||||
}
|
||||
|
||||
const onDelete = (item: Record<string, unknown>) => {
|
||||
if (!canDelete.value) return
|
||||
// 系统 PPT 模板禁止删除
|
||||
if (resource.value === 'ppt-templates' && item.isSystem) {
|
||||
toast.show('系统模板不可删除')
|
||||
return
|
||||
}
|
||||
const id = item.id
|
||||
if (id == null) return
|
||||
dialog.confirm({
|
||||
title: '确认删除',
|
||||
msg: `确定删除「${itemTitle(item)}」吗?`,
|
||||
}).then(async () => {
|
||||
try {
|
||||
await adminDelete(resource.value, id as string | number)
|
||||
toast.success('已删除')
|
||||
load(true)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
async function submitInline() {
|
||||
try {
|
||||
await adminCreate(resource.value, { ...inlineForm })
|
||||
toast.success('已创建')
|
||||
inlineOpen.value = false
|
||||
load(true)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '创建失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar :title="pageTitle" show-back />
|
||||
<view class="page-pad">
|
||||
<AdminFilterBar
|
||||
v-if="schema.filters?.length || schema.localSearchKeys?.length || schema.serverPaging"
|
||||
v-model:keyword="keyword"
|
||||
v-model="filters"
|
||||
:filters="schema.filters || []"
|
||||
@search="load(true)"
|
||||
@reset="load(true)"
|
||||
/>
|
||||
|
||||
<view v-if="schema.inlineCreate" class="inline-create admin-card">
|
||||
<wd-button size="small" type="primary" @click="goCreate">新增</wd-button>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-for="item in list"
|
||||
:key="String(item.id)"
|
||||
class="admin-card row"
|
||||
@click="goEdit(item)"
|
||||
>
|
||||
<image v-if="coverUrl(item)" class="cover" :src="coverUrl(item)" mode="aspectFill" />
|
||||
<view class="main">
|
||||
<view class="title-row">
|
||||
<text class="title">{{ itemTitle(item) }}</text>
|
||||
<AdminStatusBadge
|
||||
v-if="schema.statusKey"
|
||||
:value="item[schema.statusKey] as string | number | boolean"
|
||||
:map="schema.statusMap"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="itemSubtitle(item)" class="sub text-art-muted">{{ itemSubtitle(item) }}</text>
|
||||
</view>
|
||||
<wd-button
|
||||
v-if="canDelete"
|
||||
size="small"
|
||||
type="warning"
|
||||
plain
|
||||
@click.stop="onDelete(item)"
|
||||
>
|
||||
删除
|
||||
</wd-button>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
<wd-empty v-if="!loading && !list.length" description="暂无数据" />
|
||||
<text v-if="schema.serverPaging && !finished && list.length" class="more text-art-muted">上拉加载更多</text>
|
||||
</view>
|
||||
|
||||
<view v-if="showFab" class="fab" @click="goCreate">
|
||||
<wd-icon name="plus" size="24px" color="#fff" />
|
||||
</view>
|
||||
|
||||
<AdminDetailDrawer v-model:show="drawerShow" :title="pageTitle" :rows="drawerRows" />
|
||||
|
||||
<!-- 页内新建(邮箱后缀等) -->
|
||||
<AdminDetailDrawer v-model:show="inlineOpen" title="新建">
|
||||
<view v-for="f in schema.fields" :key="f.key" class="inline-field">
|
||||
<wd-input
|
||||
v-if="f.type === 'text' || f.type === 'number'"
|
||||
:model-value="String(inlineForm[f.key] ?? '')"
|
||||
:label="f.label"
|
||||
:placeholder="f.placeholder || f.label"
|
||||
clearable
|
||||
@update:model-value="(v: string | number) => inlineForm[f.key] = v"
|
||||
/>
|
||||
<view v-else-if="f.type === 'switch'" class="switch-row">
|
||||
<text>{{ f.label }}</text>
|
||||
<wd-switch :model-value="!!inlineForm[f.key]" @change="(v: boolean) => inlineForm[f.key] = v ? 1 : 0" />
|
||||
</view>
|
||||
</view>
|
||||
<template #footer>
|
||||
<wd-button block type="primary" @click="submitInline">保存</wd-button>
|
||||
</template>
|
||||
</AdminDetailDrawer>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 120rpx; }
|
||||
.inline-create { padding: 16rpx 24rpx; margin-bottom: 16rpx; }
|
||||
.row {
|
||||
padding: 24rpx 28rpx;
|
||||
margin-bottom: 16rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.cover {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
border-radius: 12rpx;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.main { flex: 1; display: flex; flex-direction: column; gap: 8rpx; min-width: 0; }
|
||||
.title-row { display: flex; align-items: center; gap: 12rpx; }
|
||||
.title {
|
||||
font-size: 30rpx;
|
||||
color: rgb(var(--art-text));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
.sub { font-size: 24rpx; }
|
||||
.more { display: block; text-align: center; font-size: 22rpx; padding: 16rpx; }
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 40rpx;
|
||||
bottom: calc(120rpx + env(safe-area-inset-bottom));
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 50%;
|
||||
background: rgb(var(--art-accent));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.35);
|
||||
z-index: 100;
|
||||
}
|
||||
.inline-field { margin-bottom: 12rpx; }
|
||||
.switch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16rpx 0;
|
||||
color: rgb(var(--art-text));
|
||||
}
|
||||
</style>
|
||||
162
src/subPackages/admin/pages/settings/index.vue
Normal file
162
src/subPackages/admin/pages/settings/index.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 全局配置:按 settingsSchema 分 Tab 编辑,分组保存。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import { batchUpdateSettings, getAdminSettings } from '@/api'
|
||||
import { MENU_OPTIONS, SETTING_GROUPS, SETTINGS_SCHEMA } from '../../config/settingsSchema'
|
||||
import AdminFormField from '../../components/AdminFormField.vue'
|
||||
|
||||
const toast = useToast()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const tab = ref(0)
|
||||
const values = ref<Record<string, string>>({})
|
||||
|
||||
const currentGroup = computed(() => SETTING_GROUPS[tab.value]?.id || 'site')
|
||||
const currentFields = computed(() => SETTINGS_SCHEMA.filter(f => f.group === currentGroup.value))
|
||||
|
||||
function parseMenus(raw: string): string[] {
|
||||
try {
|
||||
const arr = JSON.parse(raw || '[]')
|
||||
return Array.isArray(arr) ? arr.map(String) : []
|
||||
}
|
||||
catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMenu(key: string) {
|
||||
const list = parseMenus(values.value.visible_menus || '[]')
|
||||
const i = list.indexOf(key)
|
||||
if (i >= 0) list.splice(i, 1)
|
||||
else list.push(key)
|
||||
values.value.visible_menus = JSON.stringify(list)
|
||||
}
|
||||
|
||||
function normalizeSettings(data: unknown): Record<string, string> {
|
||||
const map: Record<string, string> = {}
|
||||
SETTINGS_SCHEMA.forEach((f) => { map[f.key] = f.default })
|
||||
if (Array.isArray(data)) {
|
||||
data.forEach((row: unknown) => {
|
||||
const r = row as { key?: string, keyName?: string, value?: string }
|
||||
const k = r.key || r.keyName
|
||||
if (k) map[k] = String(r.value ?? '')
|
||||
})
|
||||
}
|
||||
else if (data && typeof data === 'object') {
|
||||
Object.entries(data as Record<string, unknown>).forEach(([k, v]) => {
|
||||
map[k] = typeof v === 'string' ? v : JSON.stringify(v)
|
||||
})
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
values.value = normalizeSettings(await getAdminSettings())
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
|
||||
async function saveGroup() {
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload: Record<string, string> = {}
|
||||
currentFields.value.forEach((f) => {
|
||||
payload[f.key] = values.value[f.key] ?? f.default
|
||||
})
|
||||
await batchUpdateSettings(payload)
|
||||
toast.success('本组已保存')
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar title="全局配置" show-back />
|
||||
<view class="page-pad">
|
||||
<wd-tabs v-model="tab">
|
||||
<wd-tab v-for="g in SETTING_GROUPS" :key="g.id" :title="g.label" />
|
||||
</wd-tabs>
|
||||
<view class="admin-card form-card">
|
||||
<template v-for="f in currentFields" :key="f.key">
|
||||
<AdminFormField
|
||||
v-if="f.type === 'text' || f.type === 'email' || f.type === 'number'"
|
||||
:label="f.label"
|
||||
>
|
||||
<wd-input v-model="values[f.key]" :placeholder="f.label" clearable />
|
||||
</AdminFormField>
|
||||
<AdminFormField
|
||||
v-else-if="f.type === 'textarea' || f.type === 'homepage-json'"
|
||||
:label="f.label"
|
||||
>
|
||||
<wd-textarea
|
||||
v-model="values[f.key]"
|
||||
:placeholder="f.label"
|
||||
:rows="f.type === 'homepage-json' ? 10 : 3"
|
||||
/>
|
||||
</AdminFormField>
|
||||
<view v-else-if="f.type === 'menu-checkboxes'" class="menus">
|
||||
<text class="label text-art-muted">{{ f.label }}</text>
|
||||
<view class="tags">
|
||||
<view
|
||||
v-for="m in MENU_OPTIONS"
|
||||
:key="m.key"
|
||||
class="tag"
|
||||
:class="{ on: parseMenus(values.visible_menus || '').includes(m.key) }"
|
||||
@click="toggleMenu(m.key)"
|
||||
>
|
||||
{{ m.label }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
<wd-button
|
||||
block
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
custom-style="margin-top:24rpx;background:#d4b383;border-color:#d4b383;"
|
||||
@click="saveGroup"
|
||||
>
|
||||
保存本组
|
||||
</wd-button>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 80rpx; }
|
||||
.form-card { margin-top: 20rpx; }
|
||||
.menus { margin: 16rpx 0; }
|
||||
.label { font-size: 24rpx; }
|
||||
.tags { display: flex; flex-wrap: wrap; gap: 12rpx; margin-top: 12rpx; }
|
||||
.tag {
|
||||
padding: 10rpx 18rpx; border-radius: 8rpx; font-size: 24rpx;
|
||||
background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
.tag.on { background: rgba(212, 179, 131, 0.25); color: rgb(var(--art-accent)); }
|
||||
</style>
|
||||
162
src/subPackages/admin/pages/snippets/form.vue
Normal file
162
src/subPackages/admin/pages/snippets/form.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 代码片段表单:代码大文本 + 分类 + 关联文章多选。
|
||||
*/
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import { useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import { adminCreate, adminGet, adminList, adminUpdate, normalizeAdminList, type Post, type Snippet } from '@/api'
|
||||
|
||||
const toast = useToast()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
const mode = ref<'create' | 'edit'>('create')
|
||||
const id = ref('')
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const codeTypes = ref<{ id: number, name: string }[]>([])
|
||||
const posts = ref<Post[]>([])
|
||||
const selectedPosts = ref<number[]>([])
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
codeTypeId: '' as string | number,
|
||||
code: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const pageTitle = computed(() => (mode.value === 'create' ? '新建代码片段' : '编辑代码片段'))
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
codeTypes.value = normalizeAdminList(await adminList('code-types')) as { id: number, name: string }[]
|
||||
const postData = await adminList('posts', { page: 1, pageSize: 100 })
|
||||
posts.value = normalizeAdminList(postData) as Post[]
|
||||
if (mode.value === 'edit' && id.value) {
|
||||
const data = await adminGet('snippets', id.value) as Snippet
|
||||
form.title = String(data.title || '')
|
||||
form.codeTypeId = data.codeTypeId || ''
|
||||
form.code = String(data.code || '')
|
||||
form.description = String(data.description || '')
|
||||
selectedPosts.value = Array.isArray(data.postIds) ? data.postIds.map(Number) : []
|
||||
}
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((q) => {
|
||||
mode.value = q?.mode === 'edit' ? 'edit' : 'create'
|
||||
id.value = String(q?.id || '')
|
||||
})
|
||||
onShow(load)
|
||||
|
||||
function togglePost(pid: string | number) {
|
||||
const n = Number(pid)
|
||||
const i = selectedPosts.value.indexOf(n)
|
||||
if (i >= 0) selectedPosts.value.splice(i, 1)
|
||||
else selectedPosts.value.push(n)
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.title.trim()) {
|
||||
toast.show('请填写标题')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload = {
|
||||
title: form.title,
|
||||
codeTypeId: form.codeTypeId ? Number(form.codeTypeId) : undefined,
|
||||
code: form.code,
|
||||
description: form.description,
|
||||
postIds: selectedPosts.value,
|
||||
}
|
||||
if (mode.value === 'edit' && id.value) await adminUpdate('snippets', id.value, payload)
|
||||
else await adminCreate('snippets', payload)
|
||||
toast.success('已保存')
|
||||
setTimeout(() => uni.navigateBack(), 400)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function typeLabel() {
|
||||
return codeTypes.value.find(c => String(c.id) === String(form.codeTypeId))?.name || '选择分类'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar :title="pageTitle" show-back />
|
||||
<view class="page-pad">
|
||||
<view class="admin-card form-card">
|
||||
<wd-input v-model="form.title" label="标题" clearable />
|
||||
<wd-picker
|
||||
:columns="[codeTypes.map(c => ({ label: c.name, value: c.id }))]"
|
||||
@confirm="({ value }: { value: unknown[] }) => { form.codeTypeId = (value?.[0] as { value?: number })?.value ?? value?.[0] as number }"
|
||||
>
|
||||
<view class="picker-row"><text>分类</text><text class="val">{{ typeLabel() }}</text></view>
|
||||
</wd-picker>
|
||||
<wd-textarea v-model="form.code" label="代码" :rows="10" custom-class="code-area" />
|
||||
<wd-textarea v-model="form.description" label="描述" />
|
||||
<view class="section">
|
||||
<text class="label text-art-muted">关联文章</text>
|
||||
<view class="tag-wrap">
|
||||
<view
|
||||
v-for="p in posts"
|
||||
:key="String(p.id)"
|
||||
class="tag"
|
||||
:class="{ on: selectedPosts.includes(Number(p.id)) }"
|
||||
@click="togglePost(p.id)"
|
||||
>
|
||||
{{ p.title }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<wd-button
|
||||
block
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
custom-style="margin-top:24rpx;background:#d4b383;border-color:#d4b383;"
|
||||
@click="onSubmit"
|
||||
>
|
||||
保存
|
||||
</wd-button>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 80rpx; }
|
||||
.form-card { padding: 24rpx; }
|
||||
.picker-row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 20rpx 0; color: rgb(var(--art-text));
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.val { color: rgba(255, 255, 255, 0.6); }
|
||||
.section { margin-top: 16rpx; }
|
||||
.label { font-size: 24rpx; }
|
||||
.tag-wrap { display: flex; flex-wrap: wrap; gap: 12rpx; margin-top: 12rpx; }
|
||||
.tag {
|
||||
padding: 8rpx 16rpx; border-radius: 8rpx; font-size: 22rpx;
|
||||
background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.65);
|
||||
max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.tag.on { background: rgba(212, 179, 131, 0.25); color: rgb(var(--art-accent)); }
|
||||
</style>
|
||||
144
src/subPackages/admin/pages/users/form.vue
Normal file
144
src/subPackages/admin/pages/users/form.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 用户表单:完整资料字段。
|
||||
*/
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import { useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminMediaPicker from '../../components/AdminMediaPicker.vue'
|
||||
import AdminFormField from '../../components/AdminFormField.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import { adminCreate, adminGet, adminUpdate, type User } from '@/api'
|
||||
|
||||
const toast = useToast()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
const mode = ref<'create' | 'edit'>('create')
|
||||
const id = ref('')
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
avatar: '',
|
||||
role: 'editor',
|
||||
isActive: 1,
|
||||
bio: '',
|
||||
phone: '',
|
||||
wechat: '',
|
||||
wechatQrcode: '',
|
||||
})
|
||||
|
||||
const pageTitle = computed(() => (mode.value === 'create' ? '新建用户' : '编辑用户'))
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
if (mode.value !== 'edit' || !id.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await adminGet('users', id.value) as User
|
||||
Object.assign(form, {
|
||||
username: data.username || '',
|
||||
email: data.email || '',
|
||||
avatar: data.avatar || '',
|
||||
role: data.role || 'editor',
|
||||
isActive: data.isActive === 0 ? 0 : 1,
|
||||
bio: data.bio || '',
|
||||
phone: data.phone || '',
|
||||
wechat: data.wechat || '',
|
||||
wechatQrcode: data.wechatQrcode || '',
|
||||
})
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((q) => {
|
||||
mode.value = q?.mode === 'edit' ? 'edit' : 'create'
|
||||
id.value = String(q?.id || '')
|
||||
})
|
||||
onShow(load)
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.username.trim()) {
|
||||
toast.show('请填写用户名')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
if (mode.value === 'edit' && id.value) await adminUpdate('users', id.value, { ...form })
|
||||
else await adminCreate('users', { ...form })
|
||||
toast.success('已保存')
|
||||
setTimeout(() => uni.navigateBack(), 400)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar :title="pageTitle" show-back />
|
||||
<view class="page-pad">
|
||||
<view class="admin-card form-card">
|
||||
<AdminFormField label="用户名" required>
|
||||
<wd-input v-model="form.username" placeholder="用户名" clearable />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="邮箱">
|
||||
<wd-input v-model="form.email" placeholder="邮箱" clearable />
|
||||
</AdminFormField>
|
||||
<AdminMediaPicker v-model="form.avatar" label="头像" />
|
||||
<wd-picker
|
||||
:columns="[[{ label: 'admin', value: 'admin' }, { label: 'editor', value: 'editor' }, { label: 'viewer', value: 'viewer' }]]"
|
||||
@confirm="({ value }: { value: unknown[] }) => { form.role = String((value?.[0] as { value?: string })?.value ?? value?.[0] ?? 'editor') }"
|
||||
>
|
||||
<view class="picker-row"><text>角色</text><text class="val">{{ form.role }}</text></view>
|
||||
</wd-picker>
|
||||
<view class="switch-row">
|
||||
<text>启用</text>
|
||||
<wd-switch :model-value="!!form.isActive" @change="(v: boolean) => form.isActive = v ? 1 : 0" />
|
||||
</view>
|
||||
<AdminFormField label="简介">
|
||||
<wd-textarea v-model="form.bio" placeholder="简介" />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="手机">
|
||||
<wd-input v-model="form.phone" placeholder="手机" clearable />
|
||||
</AdminFormField>
|
||||
<AdminFormField label="微信">
|
||||
<wd-input v-model="form.wechat" placeholder="微信" clearable />
|
||||
</AdminFormField>
|
||||
<AdminMediaPicker v-model="form.wechatQrcode" label="微信二维码" />
|
||||
<wd-button
|
||||
block
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
custom-style="margin-top:24rpx;background:#d4b383;border-color:#d4b383;"
|
||||
@click="onSubmit"
|
||||
>
|
||||
保存
|
||||
</wd-button>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 80rpx; }
|
||||
.picker-row, .switch-row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 20rpx 0; color: rgb(var(--art-text));
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.val { color: rgba(255, 255, 255, 0.6); }
|
||||
</style>
|
||||
147
src/subPackages/admin/pages/users/list.vue
Normal file
147
src/subPackages/admin/pages/users/list.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 用户列表:服务端分页 + 角色/状态筛选。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { onReachBottom, onShow } from '@dcloudio/uni-app'
|
||||
import { useDialog, useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminFilterBar from '../../components/AdminFilterBar.vue'
|
||||
import AdminStatusBadge from '../../components/AdminStatusBadge.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import { adminDelete, adminList, normalizePagination, type User } from '@/api'
|
||||
import { resolveMediaUrl } from '@/utils/request'
|
||||
|
||||
const toast = useToast()
|
||||
const dialog = useDialog()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
const loading = ref(false)
|
||||
const list = ref<User[]>([])
|
||||
const keyword = ref('')
|
||||
const filters = ref<Record<string, unknown>>({})
|
||||
const page = ref(1)
|
||||
const finished = ref(false)
|
||||
|
||||
const filterDefs = [
|
||||
{ key: 'keyword', label: '关键词', type: 'text' as const },
|
||||
{
|
||||
key: 'role',
|
||||
label: '角色',
|
||||
type: 'select' as const,
|
||||
options: [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: 'admin', value: 'admin' },
|
||||
{ label: 'editor', value: 'editor' },
|
||||
{ label: 'viewer', value: 'viewer' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'isActive',
|
||||
label: '状态',
|
||||
type: 'select' as const,
|
||||
options: [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '停用', value: 0 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
async function load(reset = true) {
|
||||
if (!guardAdmin()) return
|
||||
if (reset) {
|
||||
page.value = 1
|
||||
finished.value = false
|
||||
list.value = []
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await adminList('users', {
|
||||
page: page.value,
|
||||
pageSize: 10,
|
||||
keyword: keyword.value || undefined,
|
||||
...filters.value,
|
||||
})
|
||||
const p = normalizePagination<User>(data, page.value, 10)
|
||||
list.value = reset ? p.list : [...list.value, ...p.list]
|
||||
finished.value = list.value.length >= p.total
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(() => load(true))
|
||||
onReachBottom(() => {
|
||||
if (finished.value || loading.value) return
|
||||
page.value += 1
|
||||
load(false)
|
||||
})
|
||||
|
||||
function goCreate() {
|
||||
uni.navigateTo({ url: '/subPackages/admin/pages/users/form?mode=create' })
|
||||
}
|
||||
function goEdit(u: User) {
|
||||
uni.navigateTo({ url: `/subPackages/admin/pages/users/form?mode=edit&id=${u.id}` })
|
||||
}
|
||||
function onDelete(u: User) {
|
||||
dialog.confirm({ title: '确认删除', msg: `确定删除用户「${u.username}」吗?` }).then(async () => {
|
||||
try {
|
||||
await adminDelete('users', u.id)
|
||||
toast.success('已删除')
|
||||
load(true)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar title="用户管理" show-back />
|
||||
<view class="page-pad">
|
||||
<AdminFilterBar
|
||||
v-model:keyword="keyword"
|
||||
v-model="filters"
|
||||
:filters="filterDefs"
|
||||
@search="load(true)"
|
||||
@reset="load(true)"
|
||||
/>
|
||||
<view v-for="item in list" :key="item.id" class="admin-card row" @click="goEdit(item)">
|
||||
<image class="avatar" :src="resolveMediaUrl(item.avatar) || '/static/logo.svg'" mode="aspectFill" />
|
||||
<view class="main">
|
||||
<view class="title-row">
|
||||
<text class="title">{{ item.username }}</text>
|
||||
<AdminStatusBadge :value="item.isActive" :map="{ '1': '启用', '0': '停用', true: '启用', false: '停用' }" />
|
||||
</view>
|
||||
<text class="sub text-art-muted">{{ item.email }} · {{ item.role }}</text>
|
||||
</view>
|
||||
<wd-button size="small" type="warning" plain @click.stop="onDelete(item)">删除</wd-button>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
<wd-empty v-if="!loading && !list.length" description="暂无用户" />
|
||||
</view>
|
||||
<view class="fab" @click="goCreate"><wd-icon name="plus" size="24px" color="#fff" /></view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 120rpx; }
|
||||
.row { padding: 24rpx 28rpx; margin-bottom: 16rpx; display: flex; align-items: center; gap: 16rpx; }
|
||||
.avatar { width: 80rpx; height: 80rpx; border-radius: 50%; background: rgba(255,255,255,0.06); }
|
||||
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.title-row { display: flex; align-items: center; gap: 12rpx; }
|
||||
.title { flex: 1; font-size: 30rpx; color: rgb(var(--art-text)); }
|
||||
.sub { font-size: 24rpx; }
|
||||
.fab {
|
||||
position: fixed; right: 40rpx; bottom: calc(40rpx + env(safe-area-inset-bottom));
|
||||
width: 96rpx; height: 96rpx; border-radius: 50%;
|
||||
background: rgb(var(--art-accent)); display: flex; align-items: center; justify-content: center; z-index: 100;
|
||||
}
|
||||
</style>
|
||||
187
src/subPackages/admin/pages/video-albums/form.vue
Normal file
187
src/subPackages/admin/pages/video-albums/form.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 视频专辑表单:基础字段 + 编辑态视频多选。
|
||||
*/
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import { useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminMediaPicker from '../../components/AdminMediaPicker.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import {
|
||||
adminCreate,
|
||||
adminGet,
|
||||
adminList,
|
||||
adminUpdate,
|
||||
getAlbumVideoIds,
|
||||
normalizeAdminList,
|
||||
type VideoAlbum,
|
||||
type VideoItem,
|
||||
} from '@/api'
|
||||
|
||||
const toast = useToast()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
const mode = ref<'create' | 'edit'>('create')
|
||||
const id = ref('')
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const categories = ref<{ id: number, name: string }[]>([])
|
||||
const videos = ref<VideoItem[]>([])
|
||||
const selectedIds = ref<number[]>([])
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
categoryId: '' as string | number,
|
||||
cover: '',
|
||||
description: '',
|
||||
sortOrder: 0,
|
||||
isActive: 1,
|
||||
})
|
||||
|
||||
const pageTitle = computed(() => (mode.value === 'create' ? '新建专辑' : '编辑专辑'))
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
categories.value = normalizeAdminList(await adminList('video-categories')) as { id: number, name: string }[]
|
||||
videos.value = normalizeAdminList(await adminList('videos')) as VideoItem[]
|
||||
if (mode.value === 'edit' && id.value) {
|
||||
const data = await adminGet('video-albums', id.value) as VideoAlbum
|
||||
form.name = String(data.name || '')
|
||||
form.categoryId = data.categoryId || ''
|
||||
form.cover = String(data.cover || '')
|
||||
form.description = String(data.description || '')
|
||||
form.sortOrder = Number(data.sortOrder || 0)
|
||||
form.isActive = data.isActive === 0 || data.isActive === false ? 0 : 1
|
||||
try {
|
||||
const ids = await getAlbumVideoIds(id.value)
|
||||
selectedIds.value = Array.isArray(ids) ? ids.map(Number) : []
|
||||
}
|
||||
catch {
|
||||
selectedIds.value = Array.isArray(data.videoIds) ? data.videoIds.map(Number) : []
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((q) => {
|
||||
mode.value = q?.mode === 'edit' ? 'edit' : 'create'
|
||||
id.value = String(q?.id || '')
|
||||
})
|
||||
onShow(load)
|
||||
|
||||
function toggleVideo(vid: string | number) {
|
||||
const n = Number(vid)
|
||||
const i = selectedIds.value.indexOf(n)
|
||||
if (i >= 0) selectedIds.value.splice(i, 1)
|
||||
else selectedIds.value.push(n)
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.name.trim()) {
|
||||
toast.show('请填写名称')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name,
|
||||
categoryId: form.categoryId ? Number(form.categoryId) : undefined,
|
||||
cover: form.cover,
|
||||
description: form.description,
|
||||
sortOrder: Number(form.sortOrder || 0),
|
||||
isActive: form.isActive,
|
||||
videoIds: selectedIds.value,
|
||||
}
|
||||
if (mode.value === 'edit' && id.value) await adminUpdate('video-albums', id.value, payload)
|
||||
else await adminCreate('video-albums', payload)
|
||||
toast.success('已保存')
|
||||
setTimeout(() => uni.navigateBack(), 400)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function catLabel() {
|
||||
return categories.value.find(c => String(c.id) === String(form.categoryId))?.name || '选择分类'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar :title="pageTitle" show-back />
|
||||
<view class="page-pad">
|
||||
<view class="admin-card form-card">
|
||||
<wd-input v-model="form.name" label="名称" clearable />
|
||||
<wd-picker
|
||||
:columns="[categories.map(c => ({ label: c.name, value: c.id }))]"
|
||||
@confirm="({ value }: { value: unknown[] }) => { form.categoryId = (value?.[0] as { value?: number })?.value ?? value?.[0] as number }"
|
||||
>
|
||||
<view class="picker-row"><text>分类</text><text class="val">{{ catLabel() }}</text></view>
|
||||
</wd-picker>
|
||||
<AdminMediaPicker v-model="form.cover" label="封面" />
|
||||
<wd-input v-model="form.sortOrder" label="排序" type="number" />
|
||||
<view class="switch-row">
|
||||
<text>启用</text>
|
||||
<wd-switch :model-value="!!form.isActive" @change="(v: boolean) => form.isActive = v ? 1 : 0" />
|
||||
</view>
|
||||
<wd-textarea v-model="form.description" label="描述" />
|
||||
<view v-if="mode === 'edit'" class="section">
|
||||
<text class="label text-art-muted">关联视频 ({{ selectedIds.length }})</text>
|
||||
<view class="tag-wrap">
|
||||
<view
|
||||
v-for="v in videos"
|
||||
:key="String(v.id)"
|
||||
class="tag"
|
||||
:class="{ on: selectedIds.includes(Number(v.id)) }"
|
||||
@click="toggleVideo(v.id)"
|
||||
>
|
||||
{{ v.title }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<wd-button
|
||||
block
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
custom-style="margin-top:24rpx;background:#d4b383;border-color:#d4b383;"
|
||||
@click="onSubmit"
|
||||
>
|
||||
保存
|
||||
</wd-button>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 80rpx; }
|
||||
.form-card { padding: 24rpx; }
|
||||
.picker-row, .switch-row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 20rpx 0; color: rgb(var(--art-text));
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.val { color: rgba(255, 255, 255, 0.6); }
|
||||
.section { margin-top: 16rpx; }
|
||||
.label { font-size: 24rpx; }
|
||||
.tag-wrap { display: flex; flex-wrap: wrap; gap: 12rpx; margin-top: 12rpx; }
|
||||
.tag {
|
||||
padding: 8rpx 16rpx; border-radius: 8rpx; font-size: 22rpx;
|
||||
background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
.tag.on { background: rgba(212, 179, 131, 0.25); color: rgb(var(--art-accent)); }
|
||||
</style>
|
||||
174
src/subPackages/admin/pages/videos/form.vue
Normal file
174
src/subPackages/admin/pages/videos/form.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 视频表单:分类/专辑多选/封面/播放地址/发布状态。
|
||||
*/
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import { useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminMediaPicker from '../../components/AdminMediaPicker.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import { adminCreate, adminGet, adminList, adminUpdate, normalizeAdminList, type VideoItem } from '@/api'
|
||||
|
||||
const toast = useToast()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
|
||||
const mode = ref<'create' | 'edit'>('create')
|
||||
const id = ref('')
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const categories = ref<{ id: number, name: string }[]>([])
|
||||
const albums = ref<{ id: number, name: string }[]>([])
|
||||
const selectedAlbums = ref<number[]>([])
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
categoryId: '' as string | number,
|
||||
videoUrl: '',
|
||||
cover: '',
|
||||
description: '',
|
||||
isPublished: 1,
|
||||
})
|
||||
|
||||
const pageTitle = computed(() => (mode.value === 'create' ? '新建视频' : '编辑视频'))
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
categories.value = normalizeAdminList(await adminList('video-categories')) as { id: number, name: string }[]
|
||||
albums.value = normalizeAdminList(await adminList('video-albums')) as { id: number, name: string }[]
|
||||
if (mode.value === 'edit' && id.value) {
|
||||
const data = await adminGet('videos', id.value) as VideoItem
|
||||
form.title = String(data.title || '')
|
||||
form.categoryId = data.categoryId || ''
|
||||
form.videoUrl = String(data.videoUrl || data.url || '')
|
||||
form.cover = String(data.cover || data.poster || '')
|
||||
form.description = String(data.description || '')
|
||||
form.isPublished = data.isPublished === 0 || data.isPublished === false ? 0 : 1
|
||||
selectedAlbums.value = Array.isArray(data.albumIds) ? data.albumIds.map(Number) : []
|
||||
}
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((q) => {
|
||||
mode.value = q?.mode === 'edit' ? 'edit' : 'create'
|
||||
id.value = String(q?.id || '')
|
||||
})
|
||||
onShow(load)
|
||||
|
||||
function toggleAlbum(aid: number) {
|
||||
const i = selectedAlbums.value.indexOf(aid)
|
||||
if (i >= 0) selectedAlbums.value.splice(i, 1)
|
||||
else selectedAlbums.value.push(aid)
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.title.trim()) {
|
||||
toast.show('请填写标题')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload = {
|
||||
title: form.title,
|
||||
categoryId: form.categoryId ? Number(form.categoryId) : undefined,
|
||||
videoUrl: form.videoUrl,
|
||||
cover: form.cover,
|
||||
poster: form.cover,
|
||||
description: form.description,
|
||||
isPublished: form.isPublished,
|
||||
albumIds: selectedAlbums.value,
|
||||
}
|
||||
if (mode.value === 'edit' && id.value) await adminUpdate('videos', id.value, payload)
|
||||
else await adminCreate('videos', payload)
|
||||
toast.success('已保存')
|
||||
setTimeout(() => uni.navigateBack(), 400)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function catLabel() {
|
||||
return categories.value.find(c => String(c.id) === String(form.categoryId))?.name || '选择分类'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar :title="pageTitle" show-back />
|
||||
<view class="page-pad">
|
||||
<view class="admin-card form-card">
|
||||
<wd-input v-model="form.title" label="标题" clearable />
|
||||
<wd-picker
|
||||
:columns="[categories.map(c => ({ label: c.name, value: c.id }))]"
|
||||
@confirm="({ value }: { value: unknown[] }) => { form.categoryId = (value?.[0] as { value?: number })?.value ?? value?.[0] as number }"
|
||||
>
|
||||
<view class="picker-row"><text>分类</text><text class="val">{{ catLabel() }}</text></view>
|
||||
</wd-picker>
|
||||
<AdminMediaPicker v-model="form.cover" label="封面" />
|
||||
<AdminMediaPicker v-model="form.videoUrl" label="视频文件" media-type="video" />
|
||||
<wd-input v-model="form.videoUrl" label="或填写视频 URL" clearable />
|
||||
<wd-textarea v-model="form.description" label="描述" />
|
||||
<view class="switch-row">
|
||||
<text>发布</text>
|
||||
<wd-switch :model-value="!!form.isPublished" @change="(v: boolean) => form.isPublished = v ? 1 : 0" />
|
||||
</view>
|
||||
<view class="section">
|
||||
<text class="label text-art-muted">所属专辑</text>
|
||||
<view class="tag-wrap">
|
||||
<view
|
||||
v-for="a in albums"
|
||||
:key="a.id"
|
||||
class="tag"
|
||||
:class="{ on: selectedAlbums.includes(a.id) }"
|
||||
@click="toggleAlbum(a.id)"
|
||||
>
|
||||
{{ a.name }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<wd-button
|
||||
block
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
custom-style="margin-top:24rpx;background:#d4b383;border-color:#d4b383;"
|
||||
@click="onSubmit"
|
||||
>
|
||||
保存
|
||||
</wd-button>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 80rpx; }
|
||||
.form-card { padding: 24rpx; }
|
||||
.picker-row, .switch-row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 20rpx 0; color: rgb(var(--art-text));
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.val { color: rgba(255, 255, 255, 0.6); font-size: 26rpx; }
|
||||
.section { margin: 16rpx 0; }
|
||||
.label { font-size: 24rpx; }
|
||||
.tag-wrap { display: flex; flex-wrap: wrap; gap: 12rpx; margin-top: 12rpx; }
|
||||
.tag {
|
||||
padding: 8rpx 18rpx; border-radius: 8rpx; font-size: 24rpx;
|
||||
background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
.tag.on { background: rgba(212, 179, 131, 0.25); color: rgb(var(--art-accent)); }
|
||||
</style>
|
||||
92
src/subPackages/admin/pages/videos/list.vue
Normal file
92
src/subPackages/admin/pages/videos/list.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 视频列表:标题/分类/发布状态。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { useDialog, useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminStatusBadge from '../../components/AdminStatusBadge.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import { adminDelete, adminList, normalizeAdminList, type VideoItem } from '@/api'
|
||||
|
||||
const toast = useToast()
|
||||
const dialog = useDialog()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
const loading = ref(false)
|
||||
const list = ref<VideoItem[]>([])
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
list.value = normalizeAdminList(await adminList('videos')) as VideoItem[]
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
|
||||
function goCreate() {
|
||||
uni.navigateTo({ url: '/subPackages/admin/pages/videos/form?mode=create' })
|
||||
}
|
||||
function goEdit(item: VideoItem) {
|
||||
uni.navigateTo({ url: `/subPackages/admin/pages/videos/form?mode=edit&id=${item.id}` })
|
||||
}
|
||||
function onDelete(item: VideoItem) {
|
||||
dialog.confirm({ title: '确认删除', msg: `确定删除「${item.title}」吗?` }).then(async () => {
|
||||
try {
|
||||
await adminDelete('videos', item.id)
|
||||
toast.success('已删除')
|
||||
load()
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar title="视频列表" show-back />
|
||||
<view class="page-pad">
|
||||
<view v-for="item in list" :key="String(item.id)" class="admin-card row" @click="goEdit(item)">
|
||||
<view class="main">
|
||||
<view class="title-row">
|
||||
<text class="title">{{ item.title }}</text>
|
||||
<AdminStatusBadge
|
||||
:value="item.isPublished"
|
||||
:map="{ '1': '已发布', '0': '草稿', true: '已发布', false: '草稿' }"
|
||||
/>
|
||||
</view>
|
||||
<text class="sub text-art-muted">{{ item.categoryName || '未分类' }} · {{ item.createdAt || '' }}</text>
|
||||
</view>
|
||||
<wd-button size="small" type="warning" plain @click.stop="onDelete(item)">删除</wd-button>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
<wd-empty v-if="!loading && !list.length" description="暂无视频" />
|
||||
</view>
|
||||
<view class="fab" @click="goCreate"><wd-icon name="plus" size="24px" color="#fff" /></view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 120rpx; }
|
||||
.row { padding: 24rpx 28rpx; margin-bottom: 16rpx; display: flex; align-items: center; gap: 16rpx; }
|
||||
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.title-row { display: flex; align-items: center; gap: 12rpx; }
|
||||
.title { flex: 1; font-size: 30rpx; color: rgb(var(--art-text)); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sub { font-size: 24rpx; }
|
||||
.fab {
|
||||
position: fixed; right: 40rpx; bottom: calc(40rpx + env(safe-area-inset-bottom));
|
||||
width: 96rpx; height: 96rpx; border-radius: 50%;
|
||||
background: rgb(var(--art-accent)); display: flex; align-items: center; justify-content: center; z-index: 100;
|
||||
}
|
||||
</style>
|
||||
211
src/subPackages/admin/pages/works/form.vue
Normal file
211
src/subPackages/admin/pages/works/form.vue
Normal file
@@ -0,0 +1,211 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 作品表单:封面/图集/技术栈/外链/演示视频。
|
||||
*/
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import { useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminMediaPicker from '../../components/AdminMediaPicker.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import { adminCreate, adminGet, adminUpdate, type Work, type WorkTechGroup } from '@/api'
|
||||
import { resolveMediaUrl } from '@/utils/request'
|
||||
import { uploadAttachment } from '@/api'
|
||||
|
||||
const toast = useToast()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
|
||||
const mode = ref<'create' | 'edit'>('create')
|
||||
const id = ref('')
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
category: '',
|
||||
year: '',
|
||||
heroImg: '',
|
||||
heroVideo: '',
|
||||
desc: '',
|
||||
live: '',
|
||||
github: '',
|
||||
demo: '',
|
||||
})
|
||||
const techStack = ref<WorkTechGroup[]>([{ category: 'Frontend', items: [] }])
|
||||
const gallery = ref<string[]>([])
|
||||
const techInput = ref('')
|
||||
|
||||
const pageTitle = computed(() => (mode.value === 'create' ? '新建作品' : '编辑作品'))
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
if (mode.value !== 'edit' || !id.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await adminGet('works', id.value) as Work
|
||||
form.title = String(data.title || '')
|
||||
form.category = String(data.category || '')
|
||||
form.year = String(data.year || '')
|
||||
form.heroImg = String(data.heroImg || '')
|
||||
form.heroVideo = String(data.heroVideo || data.videoUrl || '')
|
||||
form.desc = String(data.desc || data.description || '')
|
||||
const links = typeof data.links === 'string'
|
||||
? (() => { try { return JSON.parse(data.links) } catch { return {} } })()
|
||||
: (data.links || {})
|
||||
form.live = String((links as Record<string, string>).live || '')
|
||||
form.github = String((links as Record<string, string>).github || '')
|
||||
form.demo = String((links as Record<string, string>).demo || '')
|
||||
techStack.value = Array.isArray(data.techStack) && data.techStack.length
|
||||
? data.techStack
|
||||
: [{ category: 'Frontend', items: [] }]
|
||||
gallery.value = Array.isArray(data.gallery) ? [...data.gallery] : []
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((q) => {
|
||||
mode.value = q?.mode === 'edit' ? 'edit' : 'create'
|
||||
id.value = String(q?.id || '')
|
||||
})
|
||||
onShow(load)
|
||||
|
||||
function addTechTag() {
|
||||
const t = techInput.value.trim()
|
||||
if (!t) return
|
||||
if (!techStack.value[0]) techStack.value = [{ category: 'Frontend', items: [] }]
|
||||
techStack.value[0].items = [...(techStack.value[0].items || []), t]
|
||||
techInput.value = ''
|
||||
}
|
||||
|
||||
function removeTech(i: number) {
|
||||
techStack.value[0]?.items?.splice(i, 1)
|
||||
}
|
||||
|
||||
async function addGallery() {
|
||||
if (gallery.value.length >= 20) {
|
||||
toast.show('最多 20 张')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await uni.chooseImage({ count: Math.min(9, 20 - gallery.value.length), sizeType: ['compressed'] })
|
||||
for (const path of res.tempFilePaths || []) {
|
||||
const up = await uploadAttachment(path)
|
||||
const url = up.fileUrl || up.url
|
||||
if (url) gallery.value.push(url)
|
||||
}
|
||||
}
|
||||
catch (e: unknown) {
|
||||
if (e && typeof e === 'object' && 'errMsg' in e && String((e as { errMsg: string }).errMsg).includes('cancel')) return
|
||||
toast.error(e instanceof Error ? e.message : '上传失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.title.trim()) {
|
||||
toast.show('请填写标题')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload = {
|
||||
title: form.title,
|
||||
category: form.category,
|
||||
year: form.year,
|
||||
heroImg: form.heroImg,
|
||||
heroVideo: form.heroVideo,
|
||||
desc: form.desc,
|
||||
techStack: techStack.value,
|
||||
gallery: gallery.value,
|
||||
links: { live: form.live, github: form.github, demo: form.demo },
|
||||
}
|
||||
if (mode.value === 'edit' && id.value) await adminUpdate('works', id.value, payload)
|
||||
else await adminCreate('works', payload)
|
||||
toast.success('已保存')
|
||||
setTimeout(() => uni.navigateBack(), 400)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar :title="pageTitle" show-back />
|
||||
<view class="page-pad">
|
||||
<view class="admin-card form-card">
|
||||
<wd-input v-model="form.title" label="标题" clearable />
|
||||
<wd-input v-model="form.category" label="分类" clearable />
|
||||
<wd-input v-model="form.year" label="年份" clearable />
|
||||
<AdminMediaPicker v-model="form.heroImg" label="封面图" />
|
||||
<AdminMediaPicker v-model="form.heroVideo" label="演示视频" media-type="video" />
|
||||
<wd-textarea v-model="form.desc" label="描述" />
|
||||
<view class="section">
|
||||
<text class="label text-art-muted">技术栈</text>
|
||||
<view class="tag-wrap">
|
||||
<view v-for="(t, i) in techStack[0]?.items || []" :key="i" class="tag" @click="removeTech(i)">{{ t }} ×</view>
|
||||
</view>
|
||||
<view class="row-input">
|
||||
<wd-input v-model="techInput" placeholder="添加技术标签" clearable />
|
||||
<wd-button size="small" @click="addTechTag">添加</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
<view class="section">
|
||||
<text class="label text-art-muted">图集 ({{ gallery.length }}/20)</text>
|
||||
<view class="gallery">
|
||||
<image v-for="(g, i) in gallery" :key="i" class="g-img" :src="resolveMediaUrl(g)" mode="aspectFill" @click="gallery.splice(i, 1)" />
|
||||
<view class="g-add" @click="addGallery">+</view>
|
||||
</view>
|
||||
</view>
|
||||
<wd-input v-model="form.live" label="线上地址" clearable />
|
||||
<wd-input v-model="form.github" label="GitHub" clearable />
|
||||
<wd-input v-model="form.demo" label="Demo" clearable />
|
||||
<wd-button
|
||||
block
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
custom-style="margin-top:24rpx;background:#d4b383;border-color:#d4b383;"
|
||||
@click="onSubmit"
|
||||
>
|
||||
保存
|
||||
</wd-button>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 80rpx; }
|
||||
.form-card { padding: 24rpx; }
|
||||
.section { margin: 20rpx 0; }
|
||||
.label { font-size: 24rpx; }
|
||||
.tag-wrap { display: flex; flex-wrap: wrap; gap: 10rpx; margin: 12rpx 0; }
|
||||
.tag {
|
||||
padding: 6rpx 14rpx;
|
||||
border-radius: 8rpx;
|
||||
background: rgba(212, 179, 131, 0.2);
|
||||
color: rgb(var(--art-accent));
|
||||
font-size: 22rpx;
|
||||
}
|
||||
.row-input { display: flex; gap: 12rpx; align-items: center; }
|
||||
.gallery { display: flex; flex-wrap: wrap; gap: 12rpx; margin-top: 12rpx; }
|
||||
.g-img, .g-add {
|
||||
width: 140rpx; height: 140rpx; border-radius: 12rpx;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.g-add {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 48rpx; color: rgba(255, 255, 255, 0.4);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
</style>
|
||||
126
src/subPackages/admin/pages/works/list.vue
Normal file
126
src/subPackages/admin/pages/works/list.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 作品列表:本地关键词 + 分类/年份筛选。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { useDialog, useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import AdminFilterBar from '../../components/AdminFilterBar.vue'
|
||||
import { useAdminGuard } from '../../composables/useAdminGuard'
|
||||
import { adminDelete, adminList, normalizeAdminList, type Work } from '@/api'
|
||||
|
||||
const toast = useToast()
|
||||
const dialog = useDialog()
|
||||
const { guardAdmin } = useAdminGuard()
|
||||
|
||||
const loading = ref(false)
|
||||
const all = ref<Work[]>([])
|
||||
const keyword = ref('')
|
||||
const category = ref('')
|
||||
const year = ref('')
|
||||
|
||||
const categories = computed(() => {
|
||||
const set = new Set(all.value.map(w => String(w.category || '')).filter(Boolean))
|
||||
return Array.from(set)
|
||||
})
|
||||
const years = computed(() => {
|
||||
const set = new Set(all.value.map(w => String(w.year || '')).filter(Boolean))
|
||||
return Array.from(set).sort().reverse()
|
||||
})
|
||||
|
||||
const list = computed(() => {
|
||||
const kw = keyword.value.trim().toLowerCase()
|
||||
return all.value.filter((w) => {
|
||||
if (kw && !String(w.title || '').toLowerCase().includes(kw)) return false
|
||||
if (category.value && String(w.category) !== category.value) return false
|
||||
if (year.value && String(w.year) !== year.value) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
async function load() {
|
||||
if (!guardAdmin()) return
|
||||
loading.value = true
|
||||
try {
|
||||
all.value = normalizeAdminList(await adminList('works')) as Work[]
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
|
||||
function goCreate() {
|
||||
uni.navigateTo({ url: '/subPackages/admin/pages/works/form?mode=create' })
|
||||
}
|
||||
function goEdit(item: Work) {
|
||||
uni.navigateTo({ url: `/subPackages/admin/pages/works/form?mode=edit&id=${item.id}` })
|
||||
}
|
||||
function onDelete(item: Work) {
|
||||
dialog.confirm({ title: '确认删除', msg: `确定删除「${item.title}」吗?` }).then(async () => {
|
||||
try {
|
||||
await adminDelete('works', item.id)
|
||||
toast.success('已删除')
|
||||
load()
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell admin>
|
||||
<AppNavbar title="作品管理" show-back />
|
||||
<view class="page-pad">
|
||||
<AdminFilterBar v-model:keyword="keyword" placeholder="搜索作品" @search="() => {}" @reset="keyword = ''; category = ''; year = ''" />
|
||||
<view class="chips">
|
||||
<view class="chip" :class="{ on: !category }" @click="category = ''">全部分类</view>
|
||||
<view v-for="c in categories" :key="c" class="chip" :class="{ on: category === c }" @click="category = c">{{ c }}</view>
|
||||
</view>
|
||||
<view class="chips">
|
||||
<view class="chip" :class="{ on: !year }" @click="year = ''">全部年份</view>
|
||||
<view v-for="y in years" :key="y" class="chip" :class="{ on: year === y }" @click="year = y">{{ y }}</view>
|
||||
</view>
|
||||
<view v-for="item in list" :key="String(item.id)" class="admin-card row" @click="goEdit(item)">
|
||||
<view class="main">
|
||||
<text class="title">{{ item.title }}</text>
|
||||
<text class="sub text-art-muted">{{ item.category || '—' }} · {{ item.year || '—' }}</text>
|
||||
</view>
|
||||
<wd-button size="small" type="warning" plain @click.stop="onDelete(item)">删除</wd-button>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
<wd-empty v-if="!loading && !list.length" description="暂无作品" />
|
||||
</view>
|
||||
<view class="fab" @click="goCreate"><wd-icon name="plus" size="24px" color="#fff" /></view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 120rpx; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 12rpx; margin-bottom: 16rpx; }
|
||||
.chip {
|
||||
padding: 8rpx 18rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 22rpx;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
.chip.on { background: rgba(212, 179, 131, 0.25); color: rgb(var(--art-accent)); }
|
||||
.row { padding: 24rpx 28rpx; margin-bottom: 16rpx; display: flex; align-items: center; gap: 16rpx; }
|
||||
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.title { font-size: 30rpx; color: rgb(var(--art-text)); }
|
||||
.sub { font-size: 24rpx; }
|
||||
.fab {
|
||||
position: fixed; right: 40rpx; bottom: calc(40rpx + env(safe-area-inset-bottom));
|
||||
width: 96rpx; height: 96rpx; border-radius: 50%;
|
||||
background: rgb(var(--art-accent)); display: flex; align-items: center; justify-content: center; z-index: 100;
|
||||
}
|
||||
</style>
|
||||
115
src/subPackages/content/components/CodeBlock.vue
Normal file
115
src/subPackages/content/components/CodeBlock.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* C 端代码块:hljs 高亮后用 mp-html 渲染,样式内联适配暗色。
|
||||
*/
|
||||
import { computed, watch } from 'vue'
|
||||
import { highlightCodeBlock, preloadMarkdownRenderer } from '../lib/markdownRenderer'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
code?: string
|
||||
language?: string
|
||||
}>(), {
|
||||
code: '',
|
||||
language: '',
|
||||
})
|
||||
|
||||
preloadMarkdownRenderer()
|
||||
|
||||
const html = computed(() => highlightCodeBlock(props.code || '(empty)', props.language))
|
||||
|
||||
watch(() => props.code, () => {
|
||||
/* 触发 computed 刷新即可 */
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="code-block">
|
||||
<mp-html :content="html" container-style="overflow-x:auto;" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* mp-html 内样式有限,用 :deep 尽量覆盖;主要靠内联 class */
|
||||
.code-block {
|
||||
width: 100%;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
:deep(.ios-code-container) {
|
||||
background: #1e1e1e;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
:deep(.mac-window-header) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16rpx 24rpx;
|
||||
background: #282828;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
:deep(.mac-dots) { display: flex; gap: 10rpx; }
|
||||
:deep(.dot) {
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
:deep(.dot.red) { background: #ff5f56; }
|
||||
:deep(.dot.yellow) { background: #ffbd2e; }
|
||||
:deep(.dot.green) { background: #27c93f; }
|
||||
:deep(.mac-lang) {
|
||||
font-size: 20rpx;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
:deep(.code-pre) {
|
||||
margin: 0;
|
||||
padding: 24rpx;
|
||||
overflow-x: auto;
|
||||
background: #1e1e1e !important;
|
||||
}
|
||||
:deep(.hljs) {
|
||||
background: transparent !important;
|
||||
color: #abb2bf;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.65;
|
||||
white-space: pre;
|
||||
}
|
||||
/* atom-one-dark 精简 token */
|
||||
:deep(.hljs-comment),
|
||||
:deep(.hljs-quote) { color: #5c6370; font-style: italic; }
|
||||
:deep(.hljs-keyword),
|
||||
:deep(.hljs-selector-tag),
|
||||
:deep(.hljs-subst) { color: #c678dd; }
|
||||
:deep(.hljs-number),
|
||||
:deep(.hljs-literal),
|
||||
:deep(.hljs-variable),
|
||||
:deep(.hljs-template-variable),
|
||||
:deep(.hljs-tag .hljs-attr) { color: #d19a66; }
|
||||
:deep(.hljs-string),
|
||||
:deep(.hljs-doctag) { color: #98c379; }
|
||||
:deep(.hljs-title),
|
||||
:deep(.hljs-section),
|
||||
:deep(.hljs-selector-id) { color: #e06c75; }
|
||||
:deep(.hljs-type),
|
||||
:deep(.hljs-class .hljs-title) { color: #e5c07b; }
|
||||
:deep(.hljs-tag),
|
||||
:deep(.hljs-name),
|
||||
:deep(.hljs-attribute) { color: #e06c75; }
|
||||
:deep(.hljs-regexp),
|
||||
:deep(.hljs-link) { color: #56b6c2; }
|
||||
:deep(.hljs-symbol),
|
||||
:deep(.hljs-bullet) { color: #61afef; }
|
||||
:deep(.hljs-built_in),
|
||||
:deep(.hljs-builtin-name) { color: #e6c07b; }
|
||||
:deep(.hljs-meta) { color: #61afef; }
|
||||
:deep(.hljs-deletion) { background: #392b2b; }
|
||||
:deep(.hljs-addition) { background: #2b392f; }
|
||||
:deep(.hljs-emphasis) { font-style: italic; }
|
||||
:deep(.hljs-strong) { font-weight: 700; }
|
||||
</style>
|
||||
34
src/subPackages/content/components/ContentPageHeader.vue
Normal file
34
src/subPackages/content/components/ContentPageHeader.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* content 分包通用页头:eyebrow + 标题 + 可选说明。
|
||||
* 仅供 content 分包页面复用,与主包 ProfileHero 职责分离(微信分包隔离)。
|
||||
*/
|
||||
withDefaults(defineProps<{
|
||||
eyebrow?: string
|
||||
title: string
|
||||
desc?: string
|
||||
}>(), {
|
||||
eyebrow: '',
|
||||
desc: '',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="content-page-header animate-reveal">
|
||||
<view v-if="eyebrow" class="hero-eyebrow">{{ eyebrow }}</view>
|
||||
<view class="section-title">{{ title }}</view>
|
||||
<text v-if="desc" class="desc text-art-muted">{{ desc }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.content-page-header {
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
.desc {
|
||||
display: block;
|
||||
margin-top: 16rpx;
|
||||
font-size: 26rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
88
src/subPackages/content/components/StaffLoginForm.vue
Normal file
88
src/subPackages/content/components/StaffLoginForm.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 管理员账号密码表单(content 分包内复用)。
|
||||
* 提交逻辑由父页处理;此处只做受控输入与 loading 按钮。
|
||||
*/
|
||||
import { reactive, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
loading?: boolean
|
||||
username?: string
|
||||
password?: string
|
||||
/** 提交按钮文案 */
|
||||
submitLabel?: string
|
||||
}>(), {
|
||||
loading: false,
|
||||
username: '',
|
||||
password: '',
|
||||
submitLabel: '登录',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [payload: { username: string, password: string }]
|
||||
'update:username': [v: string]
|
||||
'update:password': [v: string]
|
||||
}>()
|
||||
|
||||
const form = reactive({
|
||||
username: props.username,
|
||||
password: props.password,
|
||||
})
|
||||
|
||||
watch(() => props.username, (v) => { form.username = v })
|
||||
watch(() => props.password, (v) => { form.password = v })
|
||||
|
||||
/** 同步账号到父组件(v-model) */
|
||||
const onUserInput = (v: string) => {
|
||||
form.username = v
|
||||
emit('update:username', v)
|
||||
}
|
||||
|
||||
/** 同步密码到父组件 */
|
||||
const onPassInput = (v: string) => {
|
||||
form.password = v
|
||||
emit('update:password', v)
|
||||
}
|
||||
|
||||
/** 点击登录:把当前表单抛给父页发请求 */
|
||||
const onSubmit = () => {
|
||||
emit('submit', { username: form.username, password: form.password })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="staff-login-form">
|
||||
<wd-form :model="form">
|
||||
<wd-input
|
||||
:model-value="form.username"
|
||||
label="账号"
|
||||
placeholder="用户名"
|
||||
clearable
|
||||
@update:model-value="onUserInput"
|
||||
/>
|
||||
<wd-input
|
||||
:model-value="form.password"
|
||||
label="密码"
|
||||
placeholder="密码"
|
||||
show-password
|
||||
clearable
|
||||
@update:model-value="onPassInput"
|
||||
/>
|
||||
</wd-form>
|
||||
<wd-button
|
||||
block
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
custom-style="margin-top:24px;background:#d4b383;border-color:#d4b383;color:#171717;"
|
||||
@click="onSubmit"
|
||||
>
|
||||
{{ submitLabel }}
|
||||
</wd-button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.staff-login-form {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
24
src/subPackages/content/lib/codeHighlight.ts
Normal file
24
src/subPackages/content/lib/codeHighlight.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 代码语言别名:对齐 PC client/src/utils/codeHighlight.ts
|
||||
* 放在 content/lib,避免分包 utils 与主包 utils 路径冲突。
|
||||
*/
|
||||
const LANGUAGE_ALIASES: Record<string, string> = {
|
||||
js: 'javascript',
|
||||
ts: 'typescript',
|
||||
py: 'python',
|
||||
rb: 'ruby',
|
||||
sh: 'bash',
|
||||
yml: 'yaml',
|
||||
md: 'markdown',
|
||||
xml: 'xml',
|
||||
svg: 'xml',
|
||||
noise: 'xml',
|
||||
}
|
||||
|
||||
/** 把业务侧语言名解析成 highlight.js 注册名 */
|
||||
export function resolveHighlightLanguage(typeName?: string): string {
|
||||
if (!typeName) return 'plaintext'
|
||||
const key = typeName.trim().toLowerCase()
|
||||
if (!key) return 'plaintext'
|
||||
return LANGUAGE_ALIASES[key] || key
|
||||
}
|
||||
120
src/subPackages/content/lib/markdownRenderer.ts
Normal file
120
src/subPackages/content/lib/markdownRenderer.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Markdown 渲染:marked + 树摇 highlight.js。
|
||||
* 放在 content/lib(勿用分包 utils,会与主包 utils 撞路径导致 ENOENT)。
|
||||
* 不做行号(对齐 PC 移动端)。
|
||||
*/
|
||||
import { marked, type Tokens } from 'marked'
|
||||
import type { HLJSApi, LanguageFn } from 'highlight.js'
|
||||
import hljsCore from 'highlight.js/lib/core'
|
||||
import javascript from 'highlight.js/lib/languages/javascript'
|
||||
import typescript from 'highlight.js/lib/languages/typescript'
|
||||
import python from 'highlight.js/lib/languages/python'
|
||||
import go from 'highlight.js/lib/languages/go'
|
||||
import java from 'highlight.js/lib/languages/java'
|
||||
import css from 'highlight.js/lib/languages/css'
|
||||
import xml from 'highlight.js/lib/languages/xml'
|
||||
import json from 'highlight.js/lib/languages/json'
|
||||
import bash from 'highlight.js/lib/languages/bash'
|
||||
import sql from 'highlight.js/lib/languages/sql'
|
||||
import yaml from 'highlight.js/lib/languages/yaml'
|
||||
import markdown from 'highlight.js/lib/languages/markdown'
|
||||
import { resolveHighlightLanguage } from './codeHighlight'
|
||||
import { rewriteHtmlMediaUrls } from '@/utils/request'
|
||||
|
||||
const LANGUAGE_REGISTRY: Array<[string, LanguageFn]> = [
|
||||
['javascript', javascript],
|
||||
['typescript', typescript],
|
||||
['python', python],
|
||||
['go', go],
|
||||
['java', java],
|
||||
['css', css],
|
||||
['xml', xml],
|
||||
['html', xml],
|
||||
['json', json],
|
||||
['bash', bash],
|
||||
['sh', bash],
|
||||
['sql', sql],
|
||||
['yaml', yaml],
|
||||
['yml', yaml],
|
||||
['markdown', markdown],
|
||||
['md', markdown],
|
||||
]
|
||||
|
||||
let hljsInstance: HLJSApi | null = null
|
||||
let markedReady = false
|
||||
|
||||
function getHljs(): HLJSApi {
|
||||
if (!hljsInstance) {
|
||||
hljsInstance = hljsCore
|
||||
for (const [name, register] of LANGUAGE_REGISTRY) {
|
||||
hljsInstance.registerLanguage(name, register)
|
||||
}
|
||||
}
|
||||
return hljsInstance
|
||||
}
|
||||
|
||||
/** 转义 HTML,避免未高亮代码注入 */
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
/**
|
||||
* 单段代码 → 带 Mac 风格头栏的 HTML(供 mp-html)
|
||||
*/
|
||||
export function highlightCodeBlock(code: string, language?: string): string {
|
||||
const hljs = getHljs()
|
||||
const lang = resolveHighlightLanguage(language)
|
||||
let highlighted: string
|
||||
if (lang && lang !== 'plaintext' && hljs.getLanguage(lang)) {
|
||||
try {
|
||||
highlighted = hljs.highlight(code, { language: lang, ignoreIllegals: true }).value
|
||||
}
|
||||
catch {
|
||||
highlighted = escapeHtml(code)
|
||||
}
|
||||
}
|
||||
else {
|
||||
highlighted = escapeHtml(code)
|
||||
}
|
||||
const label = (lang && lang !== 'plaintext' ? lang : 'TEXT').toUpperCase()
|
||||
return `
|
||||
<div class="ios-code-container">
|
||||
<div class="mac-window-header">
|
||||
<div class="mac-dots">
|
||||
<span class="dot red"></span><span class="dot yellow"></span><span class="dot green"></span>
|
||||
</div>
|
||||
<span class="mac-lang">${label}</span>
|
||||
</div>
|
||||
<pre class="hljs code-pre"><code class="hljs">${highlighted}</code></pre>
|
||||
</div>`
|
||||
}
|
||||
|
||||
function ensureMarked() {
|
||||
if (markedReady) return
|
||||
const renderer = new marked.Renderer()
|
||||
renderer.code = ({ text, lang }: Tokens.Code) => highlightCodeBlock(text, lang || '')
|
||||
marked.setOptions({
|
||||
gfm: true,
|
||||
breaks: false,
|
||||
})
|
||||
marked.use({ renderer })
|
||||
markedReady = true
|
||||
}
|
||||
|
||||
/** 渲染 Markdown 为 HTML(含代码高亮块);相对图片路径拼上传域名 */
|
||||
export async function renderMarkdown(content: string): Promise<string> {
|
||||
if (!content) return ''
|
||||
getHljs()
|
||||
ensureMarked()
|
||||
const html = marked.parse(content) as string
|
||||
return rewriteHtmlMediaUrls(html)
|
||||
}
|
||||
|
||||
/** 预加载 highlight(进入详情/弹层前可调用) */
|
||||
export function preloadMarkdownRenderer(): void {
|
||||
getHljs()
|
||||
}
|
||||
214
src/subPackages/content/pages/about/index.vue
Normal file
214
src/subPackages/content/pages/about/index.vue
Normal file
@@ -0,0 +1,214 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 关于页:档案、联系、技术栈、经历时间轴(对齐 PC About 信息架构)。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import ContentPageHeader from '../../components/ContentPageHeader.vue'
|
||||
import { getAbout, type AboutProfile } from '@/api'
|
||||
import { resolveMediaUrl, rewriteHtmlMediaUrls } from '@/utils/request'
|
||||
|
||||
const loading = ref(false)
|
||||
const profile = ref<AboutProfile | null>(null)
|
||||
|
||||
function normalizeProfile(data: AboutProfile | AboutProfile[] | null): AboutProfile | null {
|
||||
if (!data) return null
|
||||
if (Array.isArray(data)) return data[0] || null
|
||||
return data
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await getAbout()
|
||||
profile.value = normalizeProfile(data as AboutProfile | AboutProfile[])
|
||||
}
|
||||
catch (e: unknown) {
|
||||
uni.showToast({ title: e instanceof Error ? e.message : '加载失败', icon: 'none' })
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
|
||||
const avatarUrl = computed(() => resolveMediaUrl(String(profile.value?.avatar || '')))
|
||||
const techList = computed(() => {
|
||||
const t = profile.value?.techStack
|
||||
return Array.isArray(t) ? t.map(String) : []
|
||||
})
|
||||
const experiences = computed(() => profile.value?.experiences || [])
|
||||
const bioHtml = computed(() => rewriteHtmlMediaUrls(String(profile.value?.bio || '')))
|
||||
|
||||
const copyText = (label: string, value?: string) => {
|
||||
const v = String(value || '').trim()
|
||||
if (!v) return
|
||||
uni.setClipboardData({
|
||||
data: v,
|
||||
success: () => uni.showToast({ title: `${label}已复制`, icon: 'none' }),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell>
|
||||
<AppNavbar title="关于" show-back />
|
||||
<view class="page-pad">
|
||||
<view v-if="profile" class="animate-reveal">
|
||||
<ContentPageHeader eyebrow="个人档案" title="关于我" />
|
||||
|
||||
<view class="identity">
|
||||
<image
|
||||
v-if="avatarUrl"
|
||||
class="avatar"
|
||||
:src="avatarUrl"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="identity-meta">
|
||||
<text class="name">{{ profile.name || '关于我' }}</text>
|
||||
<view v-if="profile.location" class="loc text-art-muted">
|
||||
<wd-icon name="location" size="14px" color="rgb(212,179,131)" />
|
||||
<text>{{ profile.location }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<rich-text v-if="bioHtml" class="bio" :nodes="bioHtml" />
|
||||
|
||||
<view v-if="profile.email || profile.wechat" class="art-card contact">
|
||||
<text class="block-title">联系方式</text>
|
||||
<view
|
||||
v-if="profile.email"
|
||||
class="contact-row"
|
||||
@click="copyText('邮箱', profile.email)"
|
||||
>
|
||||
<wd-icon name="email" size="18px" color="rgb(212,179,131)" />
|
||||
<text class="contact-text">{{ profile.email }}</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="profile.wechat"
|
||||
class="contact-row"
|
||||
@click="copyText('微信', profile.wechat)"
|
||||
>
|
||||
<wd-icon name="message" size="18px" color="rgb(212,179,131)" />
|
||||
<text class="contact-text">WeChat: {{ profile.wechat }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="techList.length" class="art-card panel">
|
||||
<text class="block-title">技术栈</text>
|
||||
<view class="pills">
|
||||
<text v-for="(t, i) in techList" :key="i" class="pill">{{ t }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="experiences.length" class="art-card panel">
|
||||
<text class="block-title">经历</text>
|
||||
<view class="timeline">
|
||||
<view
|
||||
v-for="(exp, i) in experiences"
|
||||
:key="i"
|
||||
class="exp"
|
||||
>
|
||||
<view class="dot" :class="{ 'dot--first': i === 0 }" />
|
||||
<view class="exp-body">
|
||||
<text class="exp-year font-mono-label" :class="i === 0 ? 'text-art-accent' : 'text-art-muted'">
|
||||
{{ exp.year }}
|
||||
</text>
|
||||
<text class="exp-role">{{ exp.role }}</text>
|
||||
<text class="exp-company text-art-muted">{{ exp.company }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<wd-loading v-if="loading" />
|
||||
<wd-empty v-if="!loading && !profile" description="暂无资料" />
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 80rpx; }
|
||||
.identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28rpx;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
.avatar {
|
||||
width: 144rpx;
|
||||
height: 144rpx;
|
||||
border-radius: 50%;
|
||||
border: 2rpx solid rgba(212, 179, 131, 0.55);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.identity-meta { display: flex; flex-direction: column; gap: 12rpx; min-width: 0; }
|
||||
.name {
|
||||
font-size: 40rpx;
|
||||
color: rgb(var(--art-text));
|
||||
font-weight: 600;
|
||||
}
|
||||
.loc { display: flex; align-items: center; gap: 8rpx; font-size: 26rpx; }
|
||||
.bio {
|
||||
display: block;
|
||||
font-size: 30rpx;
|
||||
line-height: 1.8;
|
||||
color: rgb(var(--art-muted));
|
||||
margin-bottom: 36rpx;
|
||||
}
|
||||
.contact, .panel {
|
||||
padding: 28rpx 32rpx;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
.block-title {
|
||||
display: block;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--art-text));
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
.contact-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
padding: 14rpx 0;
|
||||
}
|
||||
.contact-text { font-size: 26rpx; color: rgb(var(--art-muted)); }
|
||||
.pills { display: flex; flex-wrap: wrap; gap: 12rpx; }
|
||||
.pill {
|
||||
padding: 10rpx 18rpx;
|
||||
border-radius: 999rpx;
|
||||
border: 1px solid var(--art-card-border);
|
||||
font-size: 22rpx;
|
||||
color: rgb(var(--art-text));
|
||||
background: rgba(212, 179, 131, 0.06);
|
||||
}
|
||||
.timeline {
|
||||
border-left: 2rpx solid var(--art-card-border);
|
||||
margin-left: 10rpx;
|
||||
padding-left: 28rpx;
|
||||
}
|
||||
.exp {
|
||||
position: relative;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
.dot {
|
||||
position: absolute;
|
||||
left: -36rpx;
|
||||
top: 10rpx;
|
||||
width: 14rpx;
|
||||
height: 14rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(127, 127, 127, 0.35);
|
||||
}
|
||||
.dot--first { background: rgb(var(--art-accent)); }
|
||||
.exp-body { display: flex; flex-direction: column; gap: 6rpx; }
|
||||
.exp-year { font-size: 22rpx; }
|
||||
.exp-role { font-size: 28rpx; color: rgb(var(--art-text)); font-weight: 600; }
|
||||
.exp-company { font-size: 24rpx; }
|
||||
</style>
|
||||
368
src/subPackages/content/pages/blog/detail.vue
Normal file
368
src/subPackages/content/pages/blog/detail.vue
Normal file
@@ -0,0 +1,368 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 思考详情:正文 + 分类/专栏/标签导航 + 专栏其它文章 + 相关推荐。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import PostCard from '@/components/c-end/PostCard.vue'
|
||||
import {
|
||||
getColumn,
|
||||
getColumnPosts,
|
||||
getPost,
|
||||
getRecommendedPosts,
|
||||
type Column,
|
||||
type Post,
|
||||
} from '@/api'
|
||||
import { resolveMediaUrl } from '@/utils/request'
|
||||
import { renderMarkdown, preloadMarkdownRenderer } from '../../lib/markdownRenderer'
|
||||
|
||||
const postId = ref('')
|
||||
const post = ref<Post | null>(null)
|
||||
const html = ref('')
|
||||
const loading = ref(false)
|
||||
const recommended = ref<Post[]>([])
|
||||
const columnInfo = ref<Column | null>(null)
|
||||
const columnPosts = ref<Post[]>([])
|
||||
|
||||
const DEFAULT_AVATAR = '/static/default-avatar.svg'
|
||||
|
||||
const avatarSrc = computed(() => {
|
||||
const raw = post.value?.userAvatar
|
||||
if (!raw) return DEFAULT_AVATAR
|
||||
return resolveMediaUrl(String(raw)) || DEFAULT_AVATAR
|
||||
})
|
||||
|
||||
const tagList = computed(() => {
|
||||
const tags = post.value?.tags
|
||||
if (!Array.isArray(tags)) return [] as { id?: number, name: string }[]
|
||||
return tags.map((t) => {
|
||||
if (typeof t === 'string') return { name: t }
|
||||
return { id: t?.id, name: String(t?.name || '') }
|
||||
}).filter(t => t.name)
|
||||
})
|
||||
|
||||
const columnName = computed(() => {
|
||||
if (columnInfo.value?.name || columnInfo.value?.title) {
|
||||
return String(columnInfo.value.name || columnInfo.value.title)
|
||||
}
|
||||
return String(post.value?.columnName || '')
|
||||
})
|
||||
|
||||
const otherColumnPosts = computed(() =>
|
||||
columnPosts.value
|
||||
.filter(p => String(p.id) !== String(postId.value))
|
||||
.slice(0, 8),
|
||||
)
|
||||
|
||||
/** 按 id 拉文章、推荐、专栏列表并渲染 Markdown */
|
||||
async function loadDetail() {
|
||||
if (!postId.value) return
|
||||
loading.value = true
|
||||
preloadMarkdownRenderer()
|
||||
try {
|
||||
const postData = await getPost(postId.value)
|
||||
post.value = postData
|
||||
html.value = await renderMarkdown(String(postData?.content || ''))
|
||||
|
||||
const tasks: Promise<void>[] = [
|
||||
getRecommendedPosts(postId.value, 3)
|
||||
.then((list) => { recommended.value = Array.isArray(list) ? list : [] })
|
||||
.catch(() => { recommended.value = [] }),
|
||||
]
|
||||
|
||||
const colId = postData?.columnId as number | undefined
|
||||
if (colId) {
|
||||
tasks.push(
|
||||
Promise.all([
|
||||
getColumn(colId).catch(() => null),
|
||||
getColumnPosts(colId).catch(() => []),
|
||||
]).then(([col, posts]) => {
|
||||
columnInfo.value = col
|
||||
columnPosts.value = Array.isArray(posts) ? posts : []
|
||||
}),
|
||||
)
|
||||
}
|
||||
else {
|
||||
columnInfo.value = null
|
||||
columnPosts.value = []
|
||||
}
|
||||
|
||||
await Promise.all(tasks)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
uni.showToast({ title: e instanceof Error ? e.message : '加载失败', icon: 'none' })
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((q) => {
|
||||
postId.value = String(q?.id || '')
|
||||
loadDetail()
|
||||
})
|
||||
|
||||
onPullDownRefresh(async () => {
|
||||
try {
|
||||
await loadDetail()
|
||||
}
|
||||
finally {
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
})
|
||||
|
||||
function goCategory() {
|
||||
const id = post.value?.categoryId
|
||||
const name = post.value?.categoryName
|
||||
if (id) {
|
||||
uni.navigateTo({
|
||||
url: `/subPackages/content/pages/blog/index?categoryId=${id}&categoryName=${encodeURIComponent(String(name || ''))}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
uni.navigateTo({ url: '/subPackages/content/pages/blog/index' })
|
||||
}
|
||||
|
||||
function goColumn() {
|
||||
const id = post.value?.columnId
|
||||
if (!id) return
|
||||
uni.navigateTo({ url: `/subPackages/content/pages/columns/detail?id=${id}` })
|
||||
}
|
||||
|
||||
function goTag(tag: { id?: number, name: string }) {
|
||||
const q = tag.id
|
||||
? `tagId=${tag.id}&tagName=${encodeURIComponent(tag.name)}`
|
||||
: `tagName=${encodeURIComponent(tag.name)}`
|
||||
uni.navigateTo({ url: `/subPackages/content/pages/blog/index?${q}` })
|
||||
}
|
||||
|
||||
function goPost(id: string | number) {
|
||||
uni.redirectTo({ url: `/subPackages/content/pages/blog/detail?id=${id}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell>
|
||||
<AppNavbar :title="post?.title || '思考详情'" show-back />
|
||||
<view class="page-pad">
|
||||
<wd-loading v-if="loading" />
|
||||
<template v-else-if="post">
|
||||
<view class="meta-row">
|
||||
<text
|
||||
v-if="post.categoryName"
|
||||
class="chip text-art-accent"
|
||||
@click="goCategory"
|
||||
>
|
||||
{{ post.categoryName }}
|
||||
</text>
|
||||
<text
|
||||
v-if="columnName"
|
||||
class="chip text-art-accent"
|
||||
@click="goColumn"
|
||||
>
|
||||
{{ columnName }}
|
||||
</text>
|
||||
<text v-if="post.date" class="font-mono-label text-art-muted">{{ post.date }}</text>
|
||||
</view>
|
||||
|
||||
<text class="title">{{ post.title }}</text>
|
||||
|
||||
<view v-if="post.userName" class="author">
|
||||
<image class="avatar" :src="avatarSrc" mode="aspectFill" />
|
||||
<text class="name">{{ post.userName }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="tagList.length" class="tags">
|
||||
<view
|
||||
v-for="(t, i) in tagList"
|
||||
:key="`${t.id || t.name}-${i}`"
|
||||
class="tag"
|
||||
@click="goTag(t)"
|
||||
>
|
||||
#{{ t.name }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="article-md body">
|
||||
<mp-html :content="html" container-style="overflow-x:auto;" />
|
||||
</view>
|
||||
|
||||
<view v-if="otherColumnPosts.length" class="section">
|
||||
<view class="section-head">
|
||||
<text class="section-title">专栏其它文章</text>
|
||||
<text v-if="post.columnId" class="more text-art-accent" @click="goColumn">全部 ›</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="item in otherColumnPosts"
|
||||
:key="String(item.id)"
|
||||
class="col-row"
|
||||
@click="goPost(item.id)"
|
||||
>
|
||||
<text class="col-title">{{ item.title }}</text>
|
||||
<text class="col-date text-art-muted">{{ item.date || '' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="recommended.length" class="section">
|
||||
<text class="section-title">相关推荐</text>
|
||||
<PostCard
|
||||
v-for="item in recommended"
|
||||
:key="String(item.id)"
|
||||
:post="item"
|
||||
@click="goPost(item.id)"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
<wd-empty v-else-if="!loading" description="文章不存在" />
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 64rpx; display: flex; flex-direction: column; gap: 20rpx; }
|
||||
.meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.chip {
|
||||
font-size: 24rpx;
|
||||
padding: 6rpx 16rpx;
|
||||
border-radius: 8rpx;
|
||||
background: rgba(212, 179, 131, 0.12);
|
||||
}
|
||||
.title {
|
||||
font-size: 44rpx;
|
||||
line-height: 1.35;
|
||||
color: rgb(var(--art-text));
|
||||
font-family: 'Playfair Display', serif;
|
||||
}
|
||||
.author { display: flex; align-items: center; gap: 12rpx; margin-bottom: 8rpx; }
|
||||
.avatar {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(127, 127, 127, 0.15);
|
||||
}
|
||||
.name { font-size: 26rpx; color: rgb(var(--art-text)); }
|
||||
.tags { display: flex; flex-wrap: wrap; gap: 12rpx; }
|
||||
.tag {
|
||||
font-size: 22rpx;
|
||||
padding: 6rpx 14rpx;
|
||||
border-radius: 8rpx;
|
||||
color: rgb(var(--art-muted));
|
||||
background: rgba(127, 127, 127, 0.1);
|
||||
}
|
||||
.body { margin-top: 12rpx; }
|
||||
.section {
|
||||
margin-top: 40rpx;
|
||||
padding-top: 28rpx;
|
||||
border-top: 1px solid var(--art-border);
|
||||
}
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
.section-title {
|
||||
font-family: 'Playfair Display', 'Noto Serif SC', serif;
|
||||
font-style: italic;
|
||||
font-size: 36rpx;
|
||||
color: rgb(var(--art-text));
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
.section-head .section-title { margin-bottom: 0; }
|
||||
.more { font-size: 24rpx; }
|
||||
.col-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
padding: 20rpx 0;
|
||||
border-bottom: 1px solid var(--art-border);
|
||||
}
|
||||
.col-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 28rpx;
|
||||
color: rgb(var(--art-text));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.col-date { font-size: 22rpx; flex-shrink: 0; }
|
||||
:deep(.ios-code-container) {
|
||||
background: #1e1e1e;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
margin: 24rpx 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
:deep(.mac-window-header) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16rpx 24rpx;
|
||||
background: #282828;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
:deep(.mac-dots) { display: flex; gap: 10rpx; }
|
||||
:deep(.dot) {
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
:deep(.dot.red) { background: #ff5f56; }
|
||||
:deep(.dot.yellow) { background: #ffbd2e; }
|
||||
:deep(.dot.green) { background: #27c93f; }
|
||||
:deep(.mac-lang) {
|
||||
font-size: 20rpx;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
:deep(.code-pre) {
|
||||
margin: 0;
|
||||
padding: 24rpx;
|
||||
overflow-x: auto;
|
||||
background: #1e1e1e !important;
|
||||
}
|
||||
:deep(.hljs) {
|
||||
background: transparent !important;
|
||||
color: #abb2bf;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.65;
|
||||
white-space: pre;
|
||||
}
|
||||
:deep(.hljs-comment),
|
||||
:deep(.hljs-quote) { color: #5c6370; font-style: italic; }
|
||||
:deep(.hljs-keyword),
|
||||
:deep(.hljs-selector-tag),
|
||||
:deep(.hljs-subst) { color: #c678dd; }
|
||||
:deep(.hljs-number),
|
||||
:deep(.hljs-literal),
|
||||
:deep(.hljs-variable),
|
||||
:deep(.hljs-template-variable),
|
||||
:deep(.hljs-tag .hljs-attr) { color: #d19a66; }
|
||||
:deep(.hljs-string),
|
||||
:deep(.hljs-doctag) { color: #98c379; }
|
||||
:deep(.hljs-title),
|
||||
:deep(.hljs-section),
|
||||
:deep(.hljs-selector-id) { color: #e06c75; }
|
||||
:deep(.hljs-type),
|
||||
:deep(.hljs-class .hljs-title) { color: #e5c07b; }
|
||||
:deep(.hljs-tag),
|
||||
:deep(.hljs-name),
|
||||
:deep(.hljs-attribute) { color: #e06c75; }
|
||||
:deep(.hljs-regexp),
|
||||
:deep(.hljs-link) { color: #56b6c2; }
|
||||
:deep(.hljs-symbol),
|
||||
:deep(.hljs-bullet) { color: #61afef; }
|
||||
:deep(.hljs-built_in),
|
||||
:deep(.hljs-builtin-name) { color: #e6c07b; }
|
||||
:deep(.hljs-meta) { color: #61afef; }
|
||||
</style>
|
||||
239
src/subPackages/content/pages/blog/index.vue
Normal file
239
src/subPackages/content/pages/blog/index.vue
Normal file
@@ -0,0 +1,239 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 思考列表:PostCard + 暗色搜索条 + 历史/热搜面板。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { onLoad, onReachBottom } from '@dcloudio/uni-app'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import ContentPageHeader from '../../components/ContentPageHeader.vue'
|
||||
import PostCard from '@/components/c-end/PostCard.vue'
|
||||
import ArtSearchBar from '@/components/c-end/ArtSearchBar.vue'
|
||||
import { getPosts, getHotSearches, type Post } from '@/api'
|
||||
import { useSearchHistory } from '@/composables/useSearchHistory'
|
||||
import { showUnsupported } from '@/utils/unsupported'
|
||||
|
||||
const { getHistory, addHistory, removeHistory, clearHistory } = useSearchHistory()
|
||||
|
||||
const q = ref('')
|
||||
const list = ref<Post[]>([])
|
||||
const page = ref(1)
|
||||
const loading = ref(false)
|
||||
const finished = ref(false)
|
||||
const hot = ref<string[]>([])
|
||||
const history = ref<string[]>([])
|
||||
const showSuggest = ref(false)
|
||||
/** 从详情页跳转带来的筛选 */
|
||||
const filterCategoryId = ref('')
|
||||
const filterTagId = ref('')
|
||||
const filterTagName = ref('')
|
||||
const filterLabel = ref('')
|
||||
|
||||
function refreshHistory() {
|
||||
history.value = getHistory()
|
||||
}
|
||||
|
||||
async function fetchList(reset = false) {
|
||||
if (loading.value) return
|
||||
if (reset) {
|
||||
page.value = 1
|
||||
finished.value = false
|
||||
list.value = []
|
||||
}
|
||||
if (finished.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const keyword = q.value.trim()
|
||||
const res = await getPosts({
|
||||
...(keyword ? { q: keyword } : {}),
|
||||
...(filterCategoryId.value ? { categoryId: filterCategoryId.value } : {}),
|
||||
...(filterTagId.value ? { tagId: filterTagId.value } : {}),
|
||||
...(filterTagName.value && !filterTagId.value ? { tag: filterTagName.value } : {}),
|
||||
page: page.value,
|
||||
pageSize: 10,
|
||||
})
|
||||
const items = Array.isArray(res) ? res : (res as { list?: Post[] }).list || []
|
||||
list.value = reset ? items : [...list.value, ...items]
|
||||
if (items.length < 10) finished.value = true
|
||||
else page.value += 1
|
||||
}
|
||||
catch (e: unknown) {
|
||||
uni.showToast({ title: e instanceof Error ? e.message : '加载失败', icon: 'none' })
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad(async (query) => {
|
||||
filterCategoryId.value = String(query?.categoryId || '')
|
||||
filterTagId.value = String(query?.tagId || '')
|
||||
filterTagName.value = query?.tagName ? decodeURIComponent(String(query.tagName)) : ''
|
||||
const catName = query?.categoryName ? decodeURIComponent(String(query.categoryName)) : ''
|
||||
if (catName) filterLabel.value = `分类:${catName}`
|
||||
else if (filterTagName.value) filterLabel.value = `标签:${filterTagName.value}`
|
||||
else filterLabel.value = ''
|
||||
|
||||
refreshHistory()
|
||||
fetchList(true)
|
||||
try {
|
||||
const h = await getHotSearches(8)
|
||||
hot.value = (h || []).map(i => i.keyword)
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
})
|
||||
|
||||
function clearFilter() {
|
||||
filterCategoryId.value = ''
|
||||
filterTagId.value = ''
|
||||
filterTagName.value = ''
|
||||
filterLabel.value = ''
|
||||
fetchList(true)
|
||||
}
|
||||
|
||||
onReachBottom(() => fetchList())
|
||||
|
||||
/** 提交搜索:写入历史并刷新列表 */
|
||||
const onSearch = () => {
|
||||
const kw = q.value.trim()
|
||||
if (kw) {
|
||||
addHistory(kw)
|
||||
refreshHistory()
|
||||
}
|
||||
showSuggest.value = false
|
||||
fetchList(true)
|
||||
}
|
||||
|
||||
const onClear = () => {
|
||||
q.value = ''
|
||||
showSuggest.value = false
|
||||
fetchList(true)
|
||||
}
|
||||
|
||||
const applyKeyword = (kw: string) => {
|
||||
q.value = kw
|
||||
onSearch()
|
||||
}
|
||||
|
||||
const onRemoveHistory = (kw: string) => {
|
||||
removeHistory(kw)
|
||||
refreshHistory()
|
||||
}
|
||||
|
||||
const onClearHistory = () => {
|
||||
clearHistory()
|
||||
refreshHistory()
|
||||
}
|
||||
|
||||
const open = (id: string | number) =>
|
||||
uni.navigateTo({ url: `/subPackages/content/pages/blog/detail?id=${id}` })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell>
|
||||
<AppNavbar title="思考" show-back />
|
||||
<view class="page-pad">
|
||||
<ContentPageHeader eyebrow="Think" title="思考" desc="记录与沉淀" />
|
||||
<ArtSearchBar
|
||||
v-model="q"
|
||||
placeholder="搜索文章"
|
||||
@search="onSearch"
|
||||
@clear="onClear"
|
||||
@focus="showSuggest = true; refreshHistory()"
|
||||
/>
|
||||
|
||||
<view v-if="filterLabel" class="filter-bar">
|
||||
<text class="filter-text text-art-accent">{{ filterLabel }}</text>
|
||||
<text class="filter-clear text-art-muted" @click="clearFilter">清除</text>
|
||||
</view>
|
||||
|
||||
<view v-if="showSuggest && (history.length || hot.length)" class="suggest art-card">
|
||||
<view v-if="history.length" class="block">
|
||||
<view class="block-head">
|
||||
<text class="font-mono-label text-art-muted">搜索历史</text>
|
||||
<text class="clear-all text-art-muted" @click="onClearHistory">清空</text>
|
||||
</view>
|
||||
<view class="chips">
|
||||
<view v-for="k in history" :key="`h-${k}`" class="chip" @click="applyKeyword(k)">
|
||||
<text class="chip-text">{{ k }}</text>
|
||||
<view class="chip-x" @click.stop="onRemoveHistory(k)">
|
||||
<wd-icon name="close" size="12px" color="rgb(136,136,136)" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="hot.length" class="block">
|
||||
<text class="font-mono-label text-art-muted">热搜</text>
|
||||
<view class="chips">
|
||||
<view v-for="k in hot" :key="`hot-${k}`" class="chip chip--hot" @click="applyKeyword(k)">
|
||||
<text class="chip-text">{{ k }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="hot.length && !q" class="hot-inline">
|
||||
<text class="font-mono-label text-art-muted">热搜</text>
|
||||
<view class="chips">
|
||||
<view v-for="k in hot" :key="k" class="chip chip--hot" @click="applyKeyword(k)">
|
||||
<text class="chip-text">{{ k }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<PostCard
|
||||
v-for="item in list"
|
||||
:key="String(item.id)"
|
||||
:post="item"
|
||||
@click="open(item.id)"
|
||||
/>
|
||||
<wd-loadmore v-if="list.length" :state="finished ? 'finished' : loading ? 'loading' : 'finished'" />
|
||||
<wd-empty v-if="!loading && !list.length" description="暂无文章" />
|
||||
<view class="export-tip text-art-muted" @click="showUnsupported('文章批量导出')">导出功能 ›</view>
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 48rpx; }
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 16rpx 0 8rpx;
|
||||
padding: 14rpx 20rpx;
|
||||
border-radius: 12rpx;
|
||||
background: rgba(212, 179, 131, 0.1);
|
||||
}
|
||||
.filter-text { font-size: 24rpx; }
|
||||
.filter-clear { font-size: 22rpx; }
|
||||
.suggest {
|
||||
margin: 20rpx 0 28rpx;
|
||||
padding: 24rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
}
|
||||
.hot-inline { margin: 20rpx 0 28rpx; }
|
||||
.block { display: flex; flex-direction: column; gap: 14rpx; }
|
||||
.block-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.clear-all { font-size: 22rpx; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 12rpx; margin-top: 8rpx; }
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
padding: 10rpx 18rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(127, 127, 127, 0.1);
|
||||
border: 1px solid var(--art-border);
|
||||
}
|
||||
.chip--hot { border-color: rgba(212, 179, 131, 0.35); }
|
||||
.chip-text { font-size: 24rpx; color: rgb(var(--art-text)); }
|
||||
.chip-x { display: flex; align-items: center; padding: 2rpx; }
|
||||
.export-tip { text-align: center; font-size: 24rpx; margin-top: 24rpx; }
|
||||
</style>
|
||||
60
src/subPackages/content/pages/blog/ppt.vue
Normal file
60
src/subPackages/content/pages/blog/ppt.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { getPost, type Post } from '@/api'
|
||||
import { showUnsupported } from '@/utils/unsupported'
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const rootClass = computed(() => ['page-root', isDark.value ? 'theme-dark' : ''])
|
||||
const postId = ref('')
|
||||
const post = ref<Post | null>(null)
|
||||
|
||||
/** 拉文章用于 PPT 预览入口 */
|
||||
async function loadDetail() {
|
||||
if (!postId.value) return
|
||||
try {
|
||||
post.value = await getPost(postId.value)
|
||||
if (!(post.value as { presentation?: unknown }).presentation) {
|
||||
showUnsupported('PPT 预览')
|
||||
}
|
||||
}
|
||||
catch {
|
||||
showUnsupported('PPT 预览')
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((q) => {
|
||||
postId.value = String(q?.id || '')
|
||||
loadDetail()
|
||||
})
|
||||
|
||||
onPullDownRefresh(async () => {
|
||||
try {
|
||||
await loadDetail()
|
||||
}
|
||||
finally {
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view :class="rootClass">
|
||||
<AppNavbar title="PPT 预览" show-back />
|
||||
<view class="page-pad">
|
||||
<view class="section-title">{{ post?.title || 'PPT' }}</view>
|
||||
<view class="tip text-art-muted">结构化 PPT 演示在小程序端能力受限。可点击下方按钮了解。</view>
|
||||
<wd-button block custom-style="margin-top:24px;background:#d4b383;border:none;" @click="showUnsupported('结构化 PPT / 导出')">
|
||||
打开完整 PPT 能力
|
||||
</wd-button>
|
||||
</view>
|
||||
<wd-toast /><wd-dialog />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-pad { padding: 32rpx; }
|
||||
.tip { margin-top: 24rpx; line-height: 1.7; font-size: 28rpx; }
|
||||
</style>
|
||||
104
src/subPackages/content/pages/columns/detail.vue
Normal file
104
src/subPackages/content/pages/columns/detail.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { getColumn, getColumnPosts, type Column, type Post } from '@/api'
|
||||
import { resolveMediaUrl } from '@/utils/request'
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const rootClass = computed(() => ['page-root', isDark.value ? 'theme-dark' : ''])
|
||||
|
||||
const id = ref('')
|
||||
const column = ref<Column | null>(null)
|
||||
const posts = ref<Post[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
/** 拉专栏信息与文章列表 */
|
||||
async function loadDetail() {
|
||||
if (!id.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const [col, postList] = await Promise.all([
|
||||
getColumn(id.value),
|
||||
getColumnPosts(id.value),
|
||||
])
|
||||
column.value = col
|
||||
posts.value = postList || []
|
||||
}
|
||||
catch (e: unknown) {
|
||||
uni.showToast({ title: e instanceof Error ? e.message : '加载失败', icon: 'none' })
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((q) => {
|
||||
id.value = String(q?.id || '')
|
||||
loadDetail()
|
||||
})
|
||||
|
||||
onPullDownRefresh(async () => {
|
||||
try {
|
||||
await loadDetail()
|
||||
}
|
||||
finally {
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
})
|
||||
|
||||
const openPost = (pid: string | number) => uni.navigateTo({ url: `/subPackages/content/pages/blog/detail?id=${pid}` })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view :class="rootClass">
|
||||
<AppNavbar :title="column?.title || '专栏详情'" show-back />
|
||||
<view v-if="column" class="page-pad">
|
||||
<image
|
||||
v-if="column.cover"
|
||||
class="hero"
|
||||
:src="resolveMediaUrl(String(column.cover))"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="hero-eyebrow">Column</view>
|
||||
<view class="section-title" style="margin-bottom: 16rpx;">{{ column.title }}</view>
|
||||
<text v-if="column.description" class="desc text-art-muted">{{ column.description }}</text>
|
||||
<view class="section-head">
|
||||
<text class="section-title" style="font-size: 34rpx;">文章列表</text>
|
||||
<text class="font-mono-label text-art-muted">{{ posts.length }} 篇</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="item in posts"
|
||||
:key="String(item.id)"
|
||||
class="art-card art-card-press card"
|
||||
@click="openPost(item.id)"
|
||||
>
|
||||
<text class="font-mono-label text-art-muted">{{ item.date || item.categoryName }}</text>
|
||||
<text class="title">{{ item.title }}</text>
|
||||
<text v-if="item.summary" class="summary text-art-muted">{{ item.summary }}</text>
|
||||
</view>
|
||||
<wd-empty v-if="!loading && !posts.length" description="暂无文章" />
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
<wd-toast />
|
||||
<wd-dialog />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 48rpx; }
|
||||
.hero { width: 100%; height: 320rpx; border-radius: 16rpx; margin-bottom: 24rpx; }
|
||||
.desc { font-size: 28rpx; line-height: 1.7; display: block; margin-bottom: 32rpx; }
|
||||
.section-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
border-bottom: 1px solid var(--art-border);
|
||||
padding-bottom: 16rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
.card { padding: 28rpx 32rpx; margin-bottom: 20rpx; display: flex; flex-direction: column; gap: 10rpx; }
|
||||
.title { font-size: 32rpx; color: rgb(var(--art-text)); }
|
||||
.summary { font-size: 26rpx; line-height: 1.6; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
</style>
|
||||
67
src/subPackages/content/pages/columns/index.vue
Normal file
67
src/subPackages/content/pages/columns/index.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import { getColumns, type Column } from '@/api'
|
||||
import { resolveMediaUrl } from '@/utils/request'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<Column[]>([])
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
list.value = await getColumns() || []
|
||||
}
|
||||
catch (e: unknown) {
|
||||
uni.showToast({ title: e instanceof Error ? e.message : '加载失败', icon: 'none' })
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
|
||||
const open = (id: string | number) => uni.navigateTo({ url: `/subPackages/content/pages/columns/detail?id=${id}` })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell>
|
||||
<AppNavbar title="专栏" show-back />
|
||||
<view class="page-pad">
|
||||
<view class="hero-eyebrow">Columns</view>
|
||||
<view class="section-title" style="margin-bottom: 32rpx;">专栏</view>
|
||||
<view
|
||||
v-for="item in list"
|
||||
:key="String(item.id)"
|
||||
class="art-card art-card-press card"
|
||||
@click="open(item.id)"
|
||||
>
|
||||
<image
|
||||
v-if="item.cover"
|
||||
class="cover"
|
||||
:src="resolveMediaUrl(String(item.cover))"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="body">
|
||||
<text class="title">{{ item.title }}</text>
|
||||
<text v-if="item.description" class="desc text-art-muted">{{ item.description }}</text>
|
||||
<text v-if="item.postCount != null" class="font-mono-label text-art-muted">{{ item.postCount }} 篇</text>
|
||||
</view>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
<wd-empty v-if="!loading && !list.length" description="暂无专栏" />
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 48rpx; }
|
||||
.card { margin-bottom: 24rpx; overflow: hidden; }
|
||||
.cover { width: 100%; height: 240rpx; display: block; }
|
||||
.body { padding: 28rpx 32rpx; display: flex; flex-direction: column; gap: 12rpx; }
|
||||
.title { font-size: 36rpx; color: rgb(var(--art-text)); font-family: 'Playfair Display', serif; }
|
||||
.desc { font-size: 26rpx; line-height: 1.6; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
</style>
|
||||
133
src/subPackages/content/pages/login/index.vue
Normal file
133
src/subPackages/content/pages/login/index.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 独立管理员登录 / 合并账号页(content 分包)。
|
||||
* mode=merge:校验管理员密码,绑定微信并注销当前游客。
|
||||
* 默认 mode:普通账号密码登录。
|
||||
*/
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { onLoad } 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 ContentPageHeader from '../../components/ContentPageHeader.vue'
|
||||
import StaffLoginForm from '../../components/StaffLoginForm.vue'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { loginApi, mergeAccountApi } from '@/api'
|
||||
|
||||
const toast = useToast()
|
||||
const { login, isAuthenticated } = useAuth()
|
||||
const { setLoginType } = useAppMode()
|
||||
|
||||
const submitting = ref(false)
|
||||
const form = reactive({ username: '', password: '' })
|
||||
/** merge = 合并账号;login = 普通登录 */
|
||||
const mode = ref<'login' | 'merge'>('login')
|
||||
|
||||
const isMerge = computed(() => mode.value === 'merge')
|
||||
const pageTitle = computed(() => (isMerge.value ? '合并账号' : '登录'))
|
||||
const headerTitle = computed(() => (isMerge.value ? '合并到管理员' : '管理员登录'))
|
||||
const headerDesc = computed(() => (
|
||||
isMerge.value
|
||||
? '验证管理员账号密码后,当前游客微信将绑定到该账号,游客账号逻辑删除。'
|
||||
: '使用账号密码进入员工身份,仅管理员与编辑者可切换后台。'
|
||||
))
|
||||
const headerEyebrow = computed(() => (isMerge.value ? 'Merge Account' : 'Staff Login'))
|
||||
const hintText = computed(() => (
|
||||
isMerge.value
|
||||
? '合并需要已通过微信静默登录的游客身份。'
|
||||
: '账号仅用于站点管理,与微信静默登录相互独立。'
|
||||
))
|
||||
|
||||
onLoad((q) => {
|
||||
const m = String(q?.mode || '').toLowerCase()
|
||||
mode.value = m === 'merge' ? 'merge' : 'login'
|
||||
})
|
||||
|
||||
/** 成功后写 token 并返回「我的」 */
|
||||
const finishAuth = (res: { token: string, user: { role?: string, [k: string]: unknown }, expire: number }, okMsg: string) => {
|
||||
login(res.token, { ...res.user }, res.expire)
|
||||
const role = (res.user.role || '').toLowerCase()
|
||||
const staff = role === 'admin' || role === 'editor' || role.includes('admin') || role.includes('editor')
|
||||
setLoginType(staff ? 'staff' : 'guest')
|
||||
toast.success(okMsg)
|
||||
form.password = ''
|
||||
const pages = getCurrentPages()
|
||||
if (pages.length > 1) {
|
||||
uni.navigateBack()
|
||||
}
|
||||
else {
|
||||
uni.reLaunch({ url: '/pages/mine/index' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交:按 mode 分流普通登录 / 合并账号。
|
||||
*/
|
||||
const onSubmit = async (payload: { username: string, password: string }) => {
|
||||
form.username = payload.username
|
||||
form.password = payload.password
|
||||
if (!form.username || !form.password) {
|
||||
toast.show('请输入账号密码')
|
||||
return
|
||||
}
|
||||
if (isMerge.value && !isAuthenticated()) {
|
||||
toast.error('请先完成微信静默登录后再合并')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isMerge.value) {
|
||||
const res = await mergeAccountApi(form.username, form.password)
|
||||
finishAuth(res, '合并成功')
|
||||
}
|
||||
else {
|
||||
const res = await loginApi(form.username, form.password)
|
||||
finishAuth(res, '登录成功')
|
||||
}
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : (isMerge.value ? '合并失败' : '登录失败'))
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell>
|
||||
<AppNavbar :title="pageTitle" show-back />
|
||||
<view class="page-pad">
|
||||
<ContentPageHeader
|
||||
:eyebrow="headerEyebrow"
|
||||
:title="headerTitle"
|
||||
:desc="headerDesc"
|
||||
/>
|
||||
<view class="login-card art-card animate-reveal">
|
||||
<StaffLoginForm
|
||||
v-model:username="form.username"
|
||||
v-model:password="form.password"
|
||||
:loading="submitting"
|
||||
:submit-label="isMerge ? '确认合并' : '登录'"
|
||||
@submit="onSubmit"
|
||||
/>
|
||||
<view class="hint text-art-muted">{{ hintText }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad {
|
||||
padding: 32rpx 32rpx 80rpx;
|
||||
}
|
||||
.login-card {
|
||||
padding: 40rpx 32rpx;
|
||||
}
|
||||
.hint {
|
||||
margin-top: 28rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
396
src/subPackages/content/pages/services/index.vue
Normal file
396
src/subPackages/content/pages/services/index.vue
Normal file
@@ -0,0 +1,396 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 合作页:hero、能力卡、创作流程、滚动客户原声、CTA+表单、伙伴。
|
||||
* 不做 PC 鼠标 3D tilt;触控用 art-card-press + CSS 滚动。
|
||||
*/
|
||||
import { computed, 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 ContentPageHeader from '../../components/ContentPageHeader.vue'
|
||||
import { getTestimonials, getPartners, submitInquiry } from '@/api'
|
||||
import { resolveMediaUrl } from '@/utils/request'
|
||||
|
||||
interface Testimonial {
|
||||
id?: number
|
||||
name?: string
|
||||
role?: string
|
||||
content?: string
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
interface Partner {
|
||||
id?: number
|
||||
name?: string
|
||||
logo?: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
const toast = useToast()
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const marqueePaused = ref(false)
|
||||
const processOpen = ref(0)
|
||||
const testimonials = ref<Testimonial[]>([])
|
||||
const partners = ref<Partner[]>([])
|
||||
const form = reactive({
|
||||
name: '',
|
||||
email: '',
|
||||
company: '',
|
||||
message: '',
|
||||
})
|
||||
|
||||
const capabilities = [
|
||||
{ icon: 'application', title: '前端架构设计', desc: '为复杂的大型应用提供可扩展的架构方案。', tone: 'blue' },
|
||||
{ icon: 'code-block', title: '创意交互开发', desc: '用动画与细节打造令人过目难忘的体验。', tone: 'gold' },
|
||||
{ icon: 'mobile', title: '跨平台应用', desc: '使用 UniApp 等技术交付高质量移动端。', tone: 'purple' },
|
||||
]
|
||||
|
||||
const processSteps = [
|
||||
{ title: '灵感 & 探索', desc: '深入理解业务需求,进行竞品分析,寻找视觉灵感,确定设计方向。' },
|
||||
{ title: '架构 & 设计', desc: '设计高保真原型,规划技术架构,确定数据流向,将抽象想法具象化。' },
|
||||
{ title: '编码 & 雕琢', desc: '编写干净可维护的代码,加入微交互与动效,让页面活起来。' },
|
||||
{ title: '测试 & 交付', desc: '多端测试、性能优化与上线配置,确保交付物稳定可靠。' },
|
||||
]
|
||||
|
||||
/** 双行弹幕:复制一份实现无缝滚动 */
|
||||
const marqueeList = computed(() => {
|
||||
const list = testimonials.value
|
||||
if (!list.length) return []
|
||||
return [...list, ...list]
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [t, p] = await Promise.all([
|
||||
getTestimonials().catch(() => []),
|
||||
getPartners().catch(() => []),
|
||||
])
|
||||
testimonials.value = (t || []) as Testimonial[]
|
||||
partners.value = (p || []) as Partner[]
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
|
||||
const scrollToForm = () => {
|
||||
uni.pageScrollTo({ selector: '#inquiry-form', duration: 300 })
|
||||
}
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!form.name || !form.email || !form.message) {
|
||||
toast.show('请填写姓名、邮箱与留言')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await submitInquiry({ ...form })
|
||||
toast.success('提交成功')
|
||||
form.name = ''
|
||||
form.email = ''
|
||||
form.company = ''
|
||||
form.message = ''
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : '提交失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell>
|
||||
<AppNavbar title="合作" show-back />
|
||||
<view class="page-pad">
|
||||
<ContentPageHeader
|
||||
eyebrow="Services"
|
||||
title="共创数字未来"
|
||||
desc="用代码构建骨架,用设计触动灵魂。"
|
||||
/>
|
||||
|
||||
<view class="cap-grid">
|
||||
<view
|
||||
v-for="(c, i) in capabilities"
|
||||
:key="i"
|
||||
class="art-card art-card-press cap-card"
|
||||
:class="`cap-card--${c.tone}`"
|
||||
>
|
||||
<view class="cap-icon">
|
||||
<wd-icon :name="c.icon" size="28px" color="rgb(212,179,131)" />
|
||||
</view>
|
||||
<text class="cap-title">{{ c.title }}</text>
|
||||
<text class="cap-desc text-art-muted">{{ c.desc }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="block">
|
||||
<view class="block-title">创作流程</view>
|
||||
<view class="process art-card">
|
||||
<view
|
||||
v-for="(step, i) in processSteps"
|
||||
:key="i"
|
||||
class="process-item"
|
||||
:class="{ 'process-item--open': processOpen === i }"
|
||||
@click="processOpen = processOpen === i ? -1 : i"
|
||||
>
|
||||
<view class="process-head">
|
||||
<text class="process-no font-mono-label text-art-accent">{{ String(i + 1).padStart(2, '0') }}</text>
|
||||
<text class="process-title">{{ step.title }}</text>
|
||||
<wd-icon
|
||||
:name="processOpen === i ? 'arrow-up' : 'arrow-down'"
|
||||
size="14px"
|
||||
color="rgb(136,136,136)"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="processOpen === i" class="step-desc text-art-muted">{{ step.desc }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="marqueeList.length" class="block marquee-block">
|
||||
<view class="block-title">客户原声</view>
|
||||
<view
|
||||
class="marquee-viewport"
|
||||
@touchstart="marqueePaused = true"
|
||||
@touchend="marqueePaused = false"
|
||||
>
|
||||
<view class="marquee-row" :class="{ 'is-paused': marqueePaused }">
|
||||
<view
|
||||
v-for="(item, idx) in marqueeList"
|
||||
:key="'a-' + idx"
|
||||
class="danmaku art-card"
|
||||
>
|
||||
<image
|
||||
v-if="item.avatar"
|
||||
class="avatar"
|
||||
:src="resolveMediaUrl(String(item.avatar))"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view v-else class="avatar avatar--empty" />
|
||||
<text v-if="item.name" class="who text-art-accent">{{ item.name }}</text>
|
||||
<text class="quote">"{{ item.content }}"</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="marquee-row marquee-row--rev" :class="{ 'is-paused': marqueePaused }">
|
||||
<view
|
||||
v-for="(item, idx) in marqueeList"
|
||||
:key="'b-' + idx"
|
||||
class="danmaku art-card"
|
||||
>
|
||||
<image
|
||||
v-if="item.avatar"
|
||||
class="avatar"
|
||||
:src="resolveMediaUrl(String(item.avatar))"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view v-else class="avatar avatar--empty" />
|
||||
<text v-if="item.name" class="who text-art-accent">{{ item.name }}</text>
|
||||
<text class="quote">"{{ item.content }}"</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="cta art-card">
|
||||
<view class="cta-glow" />
|
||||
<text class="cta-title">准备好开始了吗?</text>
|
||||
<text class="cta-desc text-art-muted">无论是一个疯狂的想法,还是一个具体的业务需求,我都乐意倾听。</text>
|
||||
<wd-button
|
||||
type="primary"
|
||||
custom-style="background:#d4b383;border-color:#d4b383;color:#171717;"
|
||||
@click="scrollToForm"
|
||||
>
|
||||
发起合作咨询
|
||||
</wd-button>
|
||||
</view>
|
||||
|
||||
<view id="inquiry-form" class="block">
|
||||
<view class="block-title">提交咨询</view>
|
||||
<view class="art-card form-card">
|
||||
<wd-input v-model="form.name" label="姓名" placeholder="您的姓名" clearable />
|
||||
<wd-input v-model="form.email" label="邮箱" placeholder="联系邮箱" clearable />
|
||||
<wd-input v-model="form.company" label="公司" placeholder="可选" clearable />
|
||||
<wd-textarea v-model="form.message" label="留言" placeholder="项目需求或合作意向" />
|
||||
<wd-button
|
||||
block
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
custom-style="margin-top:24rpx;background:#d4b383;border-color:#d4b383;color:#171717;"
|
||||
@click="onSubmit"
|
||||
>
|
||||
提交
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="partners.length" class="block">
|
||||
<text class="trusted font-mono-label text-art-muted">Trusted By</text>
|
||||
<view class="partner-grid">
|
||||
<view v-for="(p, idx) in partners" :key="p.id || idx" class="art-card partner">
|
||||
<image
|
||||
v-if="p.logo"
|
||||
class="partner-logo"
|
||||
:src="resolveMediaUrl(String(p.logo))"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="partner-name">{{ p.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<wd-loading v-if="loading" />
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 100rpx; }
|
||||
.cap-grid { display: flex; flex-direction: column; gap: 20rpx; margin-bottom: 48rpx; }
|
||||
.cap-card { padding: 36rpx 32rpx; }
|
||||
.cap-icon {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
border-radius: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(212, 179, 131, 0.12);
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
.cap-title {
|
||||
display: block;
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--art-text));
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
.cap-desc { font-size: 26rpx; line-height: 1.6; }
|
||||
.block { margin-bottom: 56rpx; }
|
||||
.block-title {
|
||||
font-size: 36rpx;
|
||||
color: rgb(var(--art-text));
|
||||
margin-bottom: 24rpx;
|
||||
font-family: 'Playfair Display', serif;
|
||||
font-style: italic;
|
||||
}
|
||||
.process { overflow: hidden; }
|
||||
.process-item {
|
||||
padding: 28rpx 32rpx;
|
||||
border-bottom: 1px solid var(--art-card-border);
|
||||
}
|
||||
.process-item:last-child { border-bottom: none; }
|
||||
.process-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.process-no { flex-shrink: 0; }
|
||||
.process-title {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
color: rgb(var(--art-text));
|
||||
font-weight: 600;
|
||||
}
|
||||
.step-desc {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
line-height: 1.65;
|
||||
padding: 16rpx 0 4rpx 56rpx;
|
||||
}
|
||||
.marquee-block { overflow: hidden; }
|
||||
.marquee-viewport { display: flex; flex-direction: column; gap: 20rpx; }
|
||||
.marquee-row {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
width: max-content;
|
||||
animation: marquee 36s linear infinite;
|
||||
}
|
||||
.marquee-row--rev { animation-name: marquee-rev; animation-duration: 42s; }
|
||||
.marquee-row.is-paused { animation-play-state: paused; }
|
||||
@keyframes marquee {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
@keyframes marquee-rev {
|
||||
from { transform: translateX(-50%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
.danmaku {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
padding: 16rpx 22rpx;
|
||||
max-width: 520rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.avatar {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.avatar--empty { background: rgba(212, 179, 131, 0.25); }
|
||||
.who { font-size: 22rpx; flex-shrink: 0; }
|
||||
.quote {
|
||||
font-size: 24rpx;
|
||||
color: rgb(var(--art-text));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 360rpx;
|
||||
}
|
||||
.cta {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 48rpx 36rpx;
|
||||
margin-bottom: 48rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 20rpx;
|
||||
}
|
||||
.cta-glow {
|
||||
position: absolute;
|
||||
top: -40rpx;
|
||||
width: 280rpx;
|
||||
height: 280rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(212, 179, 131, 0.22);
|
||||
filter: blur(40px);
|
||||
pointer-events: none;
|
||||
}
|
||||
.cta-title {
|
||||
position: relative;
|
||||
font-size: 40rpx;
|
||||
font-family: 'Playfair Display', serif;
|
||||
font-style: italic;
|
||||
color: rgb(var(--art-text));
|
||||
}
|
||||
.cta-desc { position: relative; font-size: 26rpx; line-height: 1.6; max-width: 560rpx; }
|
||||
.form-card { padding: 24rpx; }
|
||||
.trusted {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-bottom: 24rpx;
|
||||
letter-spacing: 0.18em;
|
||||
}
|
||||
.partner-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16rpx; }
|
||||
.partner {
|
||||
padding: 28rpx 20rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.partner-logo { width: 96rpx; height: 64rpx; }
|
||||
.partner-name { font-size: 24rpx; color: rgb(var(--art-text)); text-align: center; }
|
||||
</style>
|
||||
94
src/subPackages/content/pages/snippets/index.vue
Normal file
94
src/subPackages/content/pages/snippets/index.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 代码片段列表:弹层内用 CodeBlock 做语法高亮。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import CodeBlock from '../../components/CodeBlock.vue'
|
||||
import { getSnippets, type Snippet } from '@/api'
|
||||
import { preloadMarkdownRenderer } from '../../lib/markdownRenderer'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<Snippet[]>([])
|
||||
const popupVisible = ref(false)
|
||||
const active = ref<Snippet | null>(null)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
list.value = await getSnippets() || []
|
||||
}
|
||||
catch (e: unknown) {
|
||||
uni.showToast({ title: e instanceof Error ? e.message : '加载失败', icon: 'none' })
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
|
||||
const open = (item: Snippet) => {
|
||||
preloadMarkdownRenderer()
|
||||
active.value = item
|
||||
popupVisible.value = true
|
||||
}
|
||||
|
||||
const copyCode = () => {
|
||||
if (!active.value?.code) return
|
||||
uni.setClipboardData({ data: String(active.value.code) })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell>
|
||||
<AppNavbar title="代码" show-back />
|
||||
<view class="page-pad">
|
||||
<view class="hero-eyebrow">Snippets</view>
|
||||
<view class="section-title" style="margin-bottom: 32rpx;">代码片段</view>
|
||||
<view
|
||||
v-for="item in list"
|
||||
:key="String(item.id)"
|
||||
class="art-card art-card-press card"
|
||||
@click="open(item)"
|
||||
>
|
||||
<text class="font-mono-label text-art-accent">{{ item.language || 'code' }}</text>
|
||||
<text class="title">{{ item.title }}</text>
|
||||
<text v-if="item.description" class="desc text-art-muted">{{ item.description }}</text>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
<wd-empty v-if="!loading && !list.length" description="暂无代码片段" />
|
||||
</view>
|
||||
|
||||
<wd-popup
|
||||
v-model="popupVisible"
|
||||
position="bottom"
|
||||
closable
|
||||
safe-area-inset-bottom
|
||||
custom-style="border-radius:24rpx 24rpx 0 0;max-height:80vh;"
|
||||
>
|
||||
<view v-if="active" class="popup-body">
|
||||
<text class="popup-title">{{ active.title }}</text>
|
||||
<text class="font-mono-label text-art-muted">{{ active.language || 'plaintext' }}</text>
|
||||
<scroll-view scroll-y class="code-scroll">
|
||||
<CodeBlock :code="String(active.code || '')" :language="String(active.language || '')" />
|
||||
</scroll-view>
|
||||
<wd-button block type="primary" custom-style="background:#d4b383;border-color:#d4b383;" @click="copyCode">
|
||||
复制代码
|
||||
</wd-button>
|
||||
</view>
|
||||
</wd-popup>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 48rpx; }
|
||||
.card { padding: 28rpx 32rpx; margin-bottom: 20rpx; display: flex; flex-direction: column; gap: 10rpx; }
|
||||
.title { font-size: 32rpx; color: rgb(var(--art-text)); }
|
||||
.desc { font-size: 26rpx; line-height: 1.5; }
|
||||
.popup-body { padding: 32rpx; display: flex; flex-direction: column; gap: 16rpx; }
|
||||
.popup-title { font-size: 36rpx; color: rgb(var(--art-text)); font-family: 'Playfair Display', serif; }
|
||||
.code-scroll { max-height: 50vh; margin: 8rpx 0 24rpx; }
|
||||
</style>
|
||||
70
src/subPackages/content/pages/theme/index.vue
Normal file
70
src/subPackages/content/pages/theme/index.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 主题设置独立页:避免 ActionSheet 被浮动 Tabbar 遮挡。
|
||||
* 三选一写入本地 theme-pref,即时预览当前页背景。
|
||||
*/
|
||||
import { useToast } from '@wot-ui/ui'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import ContentPageHeader from '../../components/ContentPageHeader.vue'
|
||||
import ArtMenuGroup from '@/components/c-end/ArtMenuGroup.vue'
|
||||
import ArtMenuItem from '@/components/c-end/ArtMenuItem.vue'
|
||||
import { useTheme, type ThemePref } from '@/composables/useTheme'
|
||||
|
||||
const toast = useToast()
|
||||
const { themePref, themeLabel, setThemePref } = useTheme()
|
||||
|
||||
const options: { name: string, value: ThemePref, label: string }[] = [
|
||||
{ name: '跟随系统', value: 'system', label: '与设备深浅色保持一致' },
|
||||
{ name: '浅色', value: 'light', label: '始终使用浅色界面' },
|
||||
{ name: '深色', value: 'dark', label: '始终使用深色界面' },
|
||||
]
|
||||
|
||||
/** 当前选中项右侧展示勾选文案 */
|
||||
const selectedMark = (pref: ThemePref) => (themePref.value === pref ? '已选' : '')
|
||||
|
||||
/** 点选主题:写入并 toast,留在页内可立即看到效果 */
|
||||
const onSelect = (pref: ThemePref) => {
|
||||
setThemePref(pref)
|
||||
toast.success(`已切换为${themeLabel.value}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell>
|
||||
<AppNavbar title="主题" show-back />
|
||||
<view class="page-pad animate-reveal">
|
||||
<ContentPageHeader
|
||||
eyebrow="Appearance"
|
||||
title="主题模式"
|
||||
desc="选择后立即生效,可随时回来修改。"
|
||||
/>
|
||||
<ArtMenuGroup eyebrow="Theme" title="外观">
|
||||
<ArtMenuItem
|
||||
v-for="item in options"
|
||||
:key="item.value"
|
||||
:title="item.name"
|
||||
:label="item.label"
|
||||
icon="settings"
|
||||
:value="selectedMark(item.value)"
|
||||
is-link
|
||||
@click="onSelect(item.value)"
|
||||
/>
|
||||
</ArtMenuGroup>
|
||||
<view class="hint text-art-muted">
|
||||
当前:{{ themeLabel }}
|
||||
</view>
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad {
|
||||
padding: 32rpx 32rpx 80rpx;
|
||||
}
|
||||
.hint {
|
||||
margin-top: 8rpx;
|
||||
padding: 0 8rpx;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
</style>
|
||||
112
src/subPackages/content/pages/videos/album.vue
Normal file
112
src/subPackages/content/pages/videos/album.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 专辑详情:展示封面/简介 + 专辑内视频列表。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import ContentPageHeader from '../../components/ContentPageHeader.vue'
|
||||
import { getVideoAlbum, getAlbumVideos, type VideoAlbum, type VideoItem } from '@/api'
|
||||
import { resolveMediaUrl } from '@/utils/request'
|
||||
|
||||
const albumId = ref('')
|
||||
const album = ref<VideoAlbum | null>(null)
|
||||
const list = ref<VideoItem[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
/** 拉专辑信息与视频列表 */
|
||||
async function loadDetail() {
|
||||
if (!albumId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const [a, videos] = await Promise.all([
|
||||
getVideoAlbum(albumId.value).catch(() => null),
|
||||
getAlbumVideos(albumId.value),
|
||||
])
|
||||
album.value = a
|
||||
list.value = videos || []
|
||||
}
|
||||
catch (e: unknown) {
|
||||
uni.showToast({ title: e instanceof Error ? e.message : '加载失败', icon: 'none' })
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((q) => {
|
||||
albumId.value = String(q?.id || '')
|
||||
loadDetail()
|
||||
})
|
||||
|
||||
onPullDownRefresh(async () => {
|
||||
try {
|
||||
await loadDetail()
|
||||
}
|
||||
finally {
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
})
|
||||
|
||||
const open = (id: string) => uni.navigateTo({ url: `/subPackages/content/pages/videos/detail?id=${id}` })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell>
|
||||
<AppNavbar :title="album?.name || '专辑'" show-back />
|
||||
<view class="page-pad">
|
||||
<image
|
||||
v-if="album?.cover"
|
||||
class="hero"
|
||||
:src="resolveMediaUrl(String(album.cover))"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<ContentPageHeader
|
||||
eyebrow="Album"
|
||||
:title="album?.name || '专辑视频'"
|
||||
:desc="album?.description || ''"
|
||||
/>
|
||||
<text v-if="album?.videoCount != null" class="count font-mono-label text-art-muted">
|
||||
{{ album.videoCount }} 集
|
||||
<text v-if="album.categoryName"> · {{ album.categoryName }}</text>
|
||||
</text>
|
||||
|
||||
<view
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
class="art-card art-card-press card"
|
||||
@click="open(item.id)"
|
||||
>
|
||||
<image
|
||||
v-if="item.cover"
|
||||
class="cover"
|
||||
:src="resolveMediaUrl(String(item.cover))"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="body">
|
||||
<text class="title">{{ item.title }}</text>
|
||||
<text v-if="item.description" class="desc text-art-muted">{{ item.description }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
<wd-empty v-if="!loading && !list.length" description="专辑暂无视频" />
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 80rpx; }
|
||||
.hero {
|
||||
width: 100%;
|
||||
height: 320rpx;
|
||||
border-radius: 16rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
.count { display: block; margin: -24rpx 0 28rpx; font-size: 22rpx; }
|
||||
.card { margin-bottom: 24rpx; overflow: hidden; }
|
||||
.cover { width: 100%; height: 240rpx; display: block; }
|
||||
.body { padding: 24rpx 28rpx; display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.title { font-size: 32rpx; color: rgb(var(--art-text)); }
|
||||
.desc { font-size: 24rpx; line-height: 1.5; }
|
||||
</style>
|
||||
176
src/subPackages/content/pages/videos/detail.vue
Normal file
176
src/subPackages/content/pages/videos/detail.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 视频详情:使用 videoUrl 播放;展示元信息与专辑上下集导航。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import { getVideo, type VideoDetailItem, type VideoItem } from '@/api'
|
||||
import { resolveMediaUrl } from '@/utils/request'
|
||||
|
||||
const videoId = ref('')
|
||||
const video = ref<VideoDetailItem | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
/** 播放地址:优先 videoUrl,兼容旧 url */
|
||||
const videoSrc = computed(() => {
|
||||
const v = video.value
|
||||
if (!v) return ''
|
||||
return resolveMediaUrl(String(v.videoUrl || v.url || ''))
|
||||
})
|
||||
|
||||
const posterSrc = computed(() => {
|
||||
const v = video.value
|
||||
if (!v) return ''
|
||||
return resolveMediaUrl(String(v.poster || v.cover || ''))
|
||||
})
|
||||
|
||||
const metaLine = computed(() => {
|
||||
const v = video.value
|
||||
if (!v) return ''
|
||||
return [v.categoryName, v.createdAt].filter(Boolean).join(' · ')
|
||||
})
|
||||
|
||||
/** 拉视频详情 */
|
||||
async function loadDetail() {
|
||||
if (!videoId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
video.value = await getVideo(videoId.value)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
uni.showToast({ title: e instanceof Error ? e.message : '加载失败', icon: 'none' })
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((q) => {
|
||||
videoId.value = String(q?.id || '')
|
||||
loadDetail()
|
||||
})
|
||||
|
||||
onPullDownRefresh(async () => {
|
||||
try {
|
||||
await loadDetail()
|
||||
}
|
||||
finally {
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
})
|
||||
|
||||
const openVideo = (id: string) => {
|
||||
uni.redirectTo({ url: `/subPackages/content/pages/videos/detail?id=${id}` })
|
||||
}
|
||||
|
||||
const openAlbum = (albumId: number) => {
|
||||
uni.navigateTo({ url: `/subPackages/content/pages/videos/album?id=${albumId}` })
|
||||
}
|
||||
|
||||
const thumb = (item: VideoItem) => resolveMediaUrl(String(item.cover || item.poster || ''))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell>
|
||||
<AppNavbar :title="video?.title || '视频详情'" show-back />
|
||||
<view v-if="video" class="page-pad animate-reveal">
|
||||
<video
|
||||
v-if="videoSrc"
|
||||
class="player"
|
||||
:src="videoSrc"
|
||||
:poster="posterSrc"
|
||||
controls
|
||||
object-fit="contain"
|
||||
show-center-play-btn
|
||||
/>
|
||||
<view v-else class="player placeholder text-art-muted">暂无视频地址</view>
|
||||
|
||||
<view class="hero-eyebrow">Video</view>
|
||||
<view class="section-title title">{{ video.title }}</view>
|
||||
<text v-if="metaLine" class="meta text-art-muted">{{ metaLine }}</text>
|
||||
<text v-if="video.description" class="desc text-art-muted">{{ video.description }}</text>
|
||||
|
||||
<view v-if="video.albumContext" class="album-nav art-card">
|
||||
<view class="album-nav-head" @click="openAlbum(video.albumContext.albumId)">
|
||||
<text class="font-mono-label text-art-accent">专辑</text>
|
||||
<text class="album-name">{{ video.albumContext.albumName }}</text>
|
||||
<wd-icon name="arrow-right" size="14px" color="rgb(136,136,136)" />
|
||||
</view>
|
||||
<scroll-view scroll-x class="nav-scroll" :show-scrollbar="false">
|
||||
<view class="nav-row">
|
||||
<view
|
||||
v-for="item in video.albumContext.prevVideos"
|
||||
:key="'p-' + item.id"
|
||||
class="nav-thumb"
|
||||
@click="openVideo(item.id)"
|
||||
>
|
||||
<image v-if="thumb(item)" class="nav-img" :src="thumb(item)" mode="aspectFill" />
|
||||
<text class="nav-title text-art-muted">上一集</text>
|
||||
</view>
|
||||
<view class="nav-thumb nav-thumb--current">
|
||||
<image v-if="posterSrc" class="nav-img" :src="posterSrc" mode="aspectFill" />
|
||||
<text class="nav-title text-art-accent">当前</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="item in video.albumContext.nextVideos"
|
||||
:key="'n-' + item.id"
|
||||
class="nav-thumb"
|
||||
@click="openVideo(item.id)"
|
||||
>
|
||||
<image v-if="thumb(item)" class="nav-img" :src="thumb(item)" mode="aspectFill" />
|
||||
<text class="nav-title text-art-muted">下一集</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 80rpx; }
|
||||
.player {
|
||||
width: 100%;
|
||||
height: 420rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #000;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
.title { margin-bottom: 12rpx; }
|
||||
.meta { display: block; font-size: 24rpx; margin-bottom: 16rpx; }
|
||||
.desc { display: block; font-size: 28rpx; line-height: 1.7; }
|
||||
.album-nav { margin-top: 40rpx; padding: 24rpx; }
|
||||
.album-nav-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
.album-name { flex: 1; font-size: 28rpx; color: rgb(var(--art-text)); }
|
||||
.nav-scroll { white-space: nowrap; }
|
||||
.nav-row { display: inline-flex; gap: 16rpx; }
|
||||
.nav-thumb {
|
||||
width: 200rpx;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
}
|
||||
.nav-thumb--current { opacity: 1; }
|
||||
.nav-img {
|
||||
width: 200rpx;
|
||||
height: 112rpx;
|
||||
border-radius: 12rpx;
|
||||
background: rgba(127, 127, 127, 0.15);
|
||||
}
|
||||
.nav-title { font-size: 22rpx; }
|
||||
</style>
|
||||
217
src/subPackages/content/pages/videos/index.vue
Normal file
217
src/subPackages/content/pages/videos/index.vue
Normal file
@@ -0,0 +1,217 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 视频库:分类 tabs(暗色走 AppPageShell themeVars)+ 专辑封面横滑 + 视频列表。
|
||||
*/
|
||||
import { ref, watch } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import ContentPageHeader from '../../components/ContentPageHeader.vue'
|
||||
import {
|
||||
getVideos,
|
||||
getVideoCategories,
|
||||
getVideoAlbums,
|
||||
type VideoItem,
|
||||
type VideoAlbum,
|
||||
} from '@/api'
|
||||
import { resolveMediaUrl } from '@/utils/request'
|
||||
|
||||
interface VideoCategory {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const categories = ref<VideoCategory[]>([])
|
||||
const albums = ref<VideoAlbum[]>([])
|
||||
const list = ref<VideoItem[]>([])
|
||||
const activeTab = ref('all')
|
||||
|
||||
const tabs = ref<{ name: string, title: string }[]>([{ name: 'all', title: '全部' }])
|
||||
|
||||
async function loadCategories() {
|
||||
try {
|
||||
const cats = await getVideoCategories()
|
||||
categories.value = (cats || []) as VideoCategory[]
|
||||
tabs.value = [
|
||||
{ name: 'all', title: '全部' },
|
||||
...categories.value.map(c => ({ name: String(c.id), title: c.name })),
|
||||
]
|
||||
}
|
||||
catch {
|
||||
categories.value = []
|
||||
tabs.value = [{ name: 'all', title: '全部' }]
|
||||
}
|
||||
}
|
||||
|
||||
/** 专辑随分类过滤(全部时不过滤) */
|
||||
async function loadAlbums() {
|
||||
try {
|
||||
const params: Record<string, unknown> = {}
|
||||
if (activeTab.value !== 'all') params.categoryId = Number(activeTab.value)
|
||||
albums.value = await getVideoAlbums(params) || []
|
||||
}
|
||||
catch {
|
||||
albums.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVideos() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, unknown> = {}
|
||||
if (activeTab.value !== 'all') params.categoryId = Number(activeTab.value)
|
||||
list.value = await getVideos(params) || []
|
||||
}
|
||||
catch (e: unknown) {
|
||||
uni.showToast({ title: e instanceof Error ? e.message : '加载失败', icon: 'none' })
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await loadCategories()
|
||||
await Promise.all([loadAlbums(), loadVideos()])
|
||||
}
|
||||
|
||||
onShow(refresh)
|
||||
|
||||
watch(activeTab, async () => {
|
||||
await Promise.all([loadAlbums(), loadVideos()])
|
||||
})
|
||||
|
||||
const openVideo = (id: string) => uni.navigateTo({ url: `/subPackages/content/pages/videos/detail?id=${id}` })
|
||||
const openAlbum = (id: number) => uni.navigateTo({ url: `/subPackages/content/pages/videos/album?id=${id}` })
|
||||
|
||||
const metaLine = (item: VideoItem) => {
|
||||
const bits = [item.categoryName, item.createdAt].filter(Boolean)
|
||||
return bits.join(' · ')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell>
|
||||
<AppNavbar title="视频" show-back />
|
||||
<view class="page-pad">
|
||||
<ContentPageHeader
|
||||
eyebrow="Videos"
|
||||
title="视频库"
|
||||
desc="专辑与最新成片,按分类浏览。"
|
||||
/>
|
||||
|
||||
<wd-tabs v-if="tabs.length > 1" v-model="activeTab" slidable="always">
|
||||
<wd-tab v-for="tab in tabs" :key="tab.name" :name="tab.name" :title="tab.title" />
|
||||
</wd-tabs>
|
||||
|
||||
<view v-if="albums.length" class="albums animate-reveal">
|
||||
<text class="font-mono-label text-art-muted">专辑</text>
|
||||
<scroll-view scroll-x class="album-scroll" :show-scrollbar="false">
|
||||
<view class="album-row">
|
||||
<view
|
||||
v-for="alb in albums"
|
||||
:key="alb.id"
|
||||
class="album-card art-card art-card-press"
|
||||
@click="openAlbum(alb.id)"
|
||||
>
|
||||
<image
|
||||
v-if="alb.cover"
|
||||
class="album-cover"
|
||||
:src="resolveMediaUrl(String(alb.cover))"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view v-else class="album-cover album-cover--empty" />
|
||||
<view class="album-meta">
|
||||
<text class="album-name">{{ alb.name }}</text>
|
||||
<text v-if="alb.videoCount != null" class="album-count text-art-muted">
|
||||
{{ alb.videoCount }} 集
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<view class="list-label font-mono-label text-art-muted">最新视频</view>
|
||||
<view
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
class="art-card art-card-press card"
|
||||
@click="openVideo(item.id)"
|
||||
>
|
||||
<view class="cover-wrap">
|
||||
<image
|
||||
v-if="item.cover"
|
||||
class="cover"
|
||||
:src="resolveMediaUrl(String(item.cover))"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view v-else class="cover cover--empty" />
|
||||
<view class="play-badge">
|
||||
<wd-icon name="play-circle" size="36px" color="#fff" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="body">
|
||||
<text class="title">{{ item.title }}</text>
|
||||
<text v-if="metaLine(item)" class="meta text-art-muted">{{ metaLine(item) }}</text>
|
||||
<text v-if="item.description" class="desc text-art-muted">{{ item.description }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<wd-loading v-if="loading" />
|
||||
<wd-empty v-if="!loading && !list.length" description="暂无视频" />
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 80rpx; }
|
||||
.albums { margin: 28rpx 0 36rpx; }
|
||||
.album-scroll { margin-top: 16rpx; white-space: nowrap; }
|
||||
.album-row { display: inline-flex; gap: 20rpx; padding-bottom: 8rpx; }
|
||||
.album-card {
|
||||
width: 280rpx;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.album-cover {
|
||||
width: 280rpx;
|
||||
height: 160rpx;
|
||||
display: block;
|
||||
}
|
||||
.album-cover--empty { background: rgba(212, 179, 131, 0.12); }
|
||||
.album-meta { padding: 16rpx 20rpx 20rpx; display: flex; flex-direction: column; gap: 6rpx; }
|
||||
.album-name {
|
||||
font-size: 26rpx;
|
||||
color: rgb(var(--art-text));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.album-count { font-size: 22rpx; }
|
||||
.list-label { display: block; margin-bottom: 16rpx; }
|
||||
.card { margin-bottom: 24rpx; overflow: hidden; }
|
||||
.cover-wrap { position: relative; }
|
||||
.cover { width: 100%; height: 320rpx; display: block; }
|
||||
.cover--empty { background: rgba(212, 179, 131, 0.1); height: 320rpx; }
|
||||
.play-badge {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
.body { padding: 24rpx 28rpx; display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.title { font-size: 32rpx; color: rgb(var(--art-text)); }
|
||||
.meta { font-size: 22rpx; }
|
||||
.desc {
|
||||
font-size: 24rpx;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
225
src/subPackages/content/pages/works/detail.vue
Normal file
225
src/subPackages/content/pages/works/detail.vue
Normal file
@@ -0,0 +1,225 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 作品详情:hero、简介、技术栈、外链、图集、下一个作品。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import { getWork, type Work } from '@/api'
|
||||
import { resolveMediaUrl, rewriteHtmlMediaUrls } from '@/utils/request'
|
||||
import { flattenTechStack, openExternalUrl, parseWorkLinks } from '@/utils/content'
|
||||
|
||||
const workId = ref('')
|
||||
const work = ref<Work | null>(null)
|
||||
const loading = ref(false)
|
||||
const showVideo = ref(false)
|
||||
|
||||
const heroUrl = computed(() => resolveMediaUrl(String(work.value?.heroImg || '')))
|
||||
const videoSrc = computed(() => resolveMediaUrl(String(work.value?.videoUrl || work.value?.heroVideo || '')))
|
||||
const descHtml = computed(() =>
|
||||
rewriteHtmlMediaUrls(String(work.value?.desc || work.value?.description || '')),
|
||||
)
|
||||
const techGroups = computed(() => {
|
||||
const stack = work.value?.techStack
|
||||
if (!Array.isArray(stack) || !stack.length) return []
|
||||
// 若已是分组且含 items,直接用;否则展平为单组
|
||||
if (stack.some(g => Array.isArray(g.items))) return stack
|
||||
const flat = flattenTechStack(stack)
|
||||
return flat.length ? [{ category: '技术栈', items: flat }] : []
|
||||
})
|
||||
const links = computed(() => parseWorkLinks(work.value?.links))
|
||||
const gallery = computed(() => (work.value?.gallery || []).map(u => resolveMediaUrl(String(u))).filter(Boolean))
|
||||
const meta = computed(() => [work.value?.year, work.value?.category].filter(Boolean).join(' · '))
|
||||
|
||||
/** 拉作品详情 */
|
||||
async function loadDetail() {
|
||||
if (!workId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
work.value = await getWork(workId.value)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
uni.showToast({ title: e instanceof Error ? e.message : '加载失败', icon: 'none' })
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((q) => {
|
||||
workId.value = String(q?.id || '')
|
||||
loadDetail()
|
||||
})
|
||||
|
||||
onPullDownRefresh(async () => {
|
||||
try {
|
||||
await loadDetail()
|
||||
}
|
||||
finally {
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
})
|
||||
|
||||
const playHero = () => {
|
||||
if (videoSrc.value) showVideo.value = true
|
||||
}
|
||||
|
||||
const goNext = () => {
|
||||
if (!work.value?.next) return
|
||||
uni.redirectTo({ url: `/subPackages/content/pages/works/detail?id=${work.value.next}` })
|
||||
}
|
||||
|
||||
const previewGallery = (current: string) => {
|
||||
uni.previewImage({ urls: gallery.value, current })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell>
|
||||
<AppNavbar :title="work?.title || '作品详情'" show-back />
|
||||
<view v-if="work" class="page-pad animate-reveal">
|
||||
<view class="hero-wrap" @click="playHero">
|
||||
<image v-if="heroUrl" class="hero" :src="heroUrl" mode="aspectFill" />
|
||||
<view v-else class="hero hero--empty" />
|
||||
<view v-if="videoSrc" class="play-overlay">
|
||||
<wd-icon name="play-circle-fill" size="48px" color="#fff" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<video
|
||||
v-if="showVideo && videoSrc"
|
||||
class="player"
|
||||
:src="videoSrc"
|
||||
:poster="heroUrl"
|
||||
controls
|
||||
autoplay
|
||||
object-fit="contain"
|
||||
/>
|
||||
|
||||
<text v-if="meta" class="font-mono-label text-art-accent">{{ meta }}</text>
|
||||
<view class="section-title title">{{ work.title }}</view>
|
||||
|
||||
<view v-if="descHtml" class="block">
|
||||
<text class="block-label font-mono-label text-art-muted">关于项目</text>
|
||||
<rich-text class="desc" :nodes="descHtml" />
|
||||
</view>
|
||||
|
||||
<view v-if="techGroups.length" class="block">
|
||||
<text class="block-label font-mono-label text-art-muted">技术全景</text>
|
||||
<view v-for="(g, gi) in techGroups" :key="gi" class="tech-group">
|
||||
<text v-if="g.category" class="tech-cat text-art-accent">{{ g.category }}</text>
|
||||
<view class="pills">
|
||||
<text v-for="(t, ti) in (g.items || [])" :key="ti" class="pill">{{ t }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="links.live || links.demo || links.github" class="block">
|
||||
<text class="block-label font-mono-label text-art-muted">相关链接</text>
|
||||
<view class="link-rows">
|
||||
<view v-if="links.live" class="link-row art-card" @click="openExternalUrl(links.live)">
|
||||
<wd-icon name="link" size="18px" color="rgb(212,179,131)" />
|
||||
<text>线上项目</text>
|
||||
<wd-icon name="arrow-right" size="14px" color="rgb(136,136,136)" />
|
||||
</view>
|
||||
<view v-if="links.demo" class="link-row art-card" @click="openExternalUrl(links.demo)">
|
||||
<wd-icon name="play-circle" size="18px" color="rgb(212,179,131)" />
|
||||
<text>演示</text>
|
||||
<wd-icon name="arrow-right" size="14px" color="rgb(136,136,136)" />
|
||||
</view>
|
||||
<view v-if="links.github" class="link-row art-card" @click="openExternalUrl(links.github)">
|
||||
<wd-icon name="code" size="18px" color="rgb(212,179,131)" />
|
||||
<text>源码</text>
|
||||
<wd-icon name="arrow-right" size="14px" color="rgb(136,136,136)" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="gallery.length" class="block">
|
||||
<text class="block-label font-mono-label text-art-muted">图集</text>
|
||||
<image
|
||||
v-for="(img, i) in gallery"
|
||||
:key="i"
|
||||
class="gallery-img"
|
||||
:class="i % 2 === 1 ? 'gallery-img--tall' : ''"
|
||||
:src="img"
|
||||
mode="aspectFill"
|
||||
@click="previewGallery(img)"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view v-if="work.next" class="next art-card art-card-press" @click="goNext">
|
||||
<text class="font-mono-label text-art-muted">下一个作品</text>
|
||||
<text class="next-cta text-art-accent">继续浏览 →</text>
|
||||
</view>
|
||||
</view>
|
||||
<wd-loading v-if="loading" />
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 100rpx; }
|
||||
.hero-wrap { position: relative; margin-bottom: 28rpx; border-radius: 16rpx; overflow: hidden; }
|
||||
.hero { width: 100%; height: 480rpx; display: block; }
|
||||
.hero--empty { height: 480rpx; background: rgba(212, 179, 131, 0.12); }
|
||||
.play-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
.player {
|
||||
width: 100%;
|
||||
height: 420rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #000;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
.title { margin: 12rpx 0 32rpx; }
|
||||
.block { margin-bottom: 40rpx; }
|
||||
.block-label { display: block; margin-bottom: 16rpx; }
|
||||
.desc {
|
||||
font-size: 30rpx;
|
||||
line-height: 1.8;
|
||||
color: rgb(var(--art-text));
|
||||
}
|
||||
.tech-group { margin-bottom: 20rpx; }
|
||||
.tech-cat { display: block; font-size: 24rpx; margin-bottom: 12rpx; }
|
||||
.pills { display: flex; flex-wrap: wrap; gap: 12rpx; }
|
||||
.pill {
|
||||
padding: 10rpx 18rpx;
|
||||
border: 1px solid var(--art-card-border);
|
||||
border-radius: 999rpx;
|
||||
font-size: 22rpx;
|
||||
color: rgb(var(--art-text));
|
||||
}
|
||||
.link-rows { display: flex; flex-direction: column; gap: 12rpx; }
|
||||
.link-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
padding: 24rpx 28rpx;
|
||||
font-size: 28rpx;
|
||||
color: rgb(var(--art-text));
|
||||
}
|
||||
.link-row text { flex: 1; }
|
||||
.gallery-img {
|
||||
width: 100%;
|
||||
height: 360rpx;
|
||||
border-radius: 12rpx;
|
||||
margin-bottom: 20rpx;
|
||||
display: block;
|
||||
}
|
||||
.gallery-img--tall { height: 480rpx; }
|
||||
.next {
|
||||
margin-top: 16rpx;
|
||||
padding: 32rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.next-cta { font-size: 28rpx; }
|
||||
</style>
|
||||
164
src/subPackages/content/pages/works/index.vue
Normal file
164
src/subPackages/content/pages/works/index.vue
Normal file
@@ -0,0 +1,164 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 作品列表:单列大图卡片,展示简介摘要、技术栈与外链入口。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import AppNavbar from '@/components/layout/AppNavbar.vue'
|
||||
import AppPageShell from '@/components/layout/AppPageShell.vue'
|
||||
import ContentPageHeader from '../../components/ContentPageHeader.vue'
|
||||
import { getWorks, type Work } from '@/api'
|
||||
import { resolveMediaUrl } from '@/utils/request'
|
||||
import { flattenTechStack, openExternalUrl, parseWorkLinks, stripHtml } from '@/utils/content'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<Work[]>([])
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
list.value = await getWorks() || []
|
||||
}
|
||||
catch (e: unknown) {
|
||||
uni.showToast({ title: e instanceof Error ? e.message : '加载失败', icon: 'none' })
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
|
||||
const open = (id: string) => uni.navigateTo({ url: `/subPackages/content/pages/works/detail?id=${id}` })
|
||||
|
||||
const workDesc = (w: Work) => stripHtml(String(w.desc || w.description || ''))
|
||||
const workTech = (w: Work) => flattenTechStack(w.techStack).slice(0, 6)
|
||||
const workLinks = (w: Work) => parseWorkLinks(w.links)
|
||||
const meta = (w: Work) => [w.year, w.category].filter(Boolean).join(' · ')
|
||||
|
||||
/** 阻止冒泡打开外链,避免同时进入详情 */
|
||||
const stopOpen = (url?: string) => {
|
||||
openExternalUrl(url)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageShell>
|
||||
<AppNavbar title="作品" show-back />
|
||||
<view class="page-pad">
|
||||
<ContentPageHeader
|
||||
eyebrow="Works"
|
||||
title="精选作品"
|
||||
desc="这里展示了参与设计和开发的核心项目。"
|
||||
/>
|
||||
|
||||
<view
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
class="art-card art-card-press work-card animate-reveal"
|
||||
@click="open(item.id)"
|
||||
>
|
||||
<view class="thumb-wrap">
|
||||
<image
|
||||
v-if="item.heroImg"
|
||||
class="thumb"
|
||||
:src="resolveMediaUrl(String(item.heroImg))"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view v-else class="thumb thumb--empty" />
|
||||
<view class="thumb-wash" />
|
||||
</view>
|
||||
<view class="body">
|
||||
<text v-if="meta(item)" class="font-mono-label text-art-accent">{{ meta(item) }}</text>
|
||||
<text class="title">{{ item.title }}</text>
|
||||
<text v-if="workDesc(item)" class="desc text-art-muted">{{ workDesc(item) }}</text>
|
||||
<view v-if="workTech(item).length" class="pills">
|
||||
<text v-for="(t, i) in workTech(item)" :key="i" class="pill">{{ t }}</text>
|
||||
</view>
|
||||
<view v-if="workLinks(item).live || workLinks(item).demo || workLinks(item).github" class="links">
|
||||
<view
|
||||
v-if="workLinks(item).live"
|
||||
class="link-chip"
|
||||
@click.stop="stopOpen(workLinks(item).live)"
|
||||
>
|
||||
线上
|
||||
</view>
|
||||
<view
|
||||
v-if="workLinks(item).demo"
|
||||
class="link-chip"
|
||||
@click.stop="stopOpen(workLinks(item).demo)"
|
||||
>
|
||||
演示
|
||||
</view>
|
||||
<view
|
||||
v-if="workLinks(item).github"
|
||||
class="link-chip"
|
||||
@click.stop="stopOpen(workLinks(item).github)"
|
||||
>
|
||||
源码
|
||||
</view>
|
||||
</view>
|
||||
<text class="cta text-art-accent">查看项目详情 →</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<wd-loading v-if="loading" />
|
||||
<wd-empty v-if="!loading && !list.length" description="暂无作品" />
|
||||
</view>
|
||||
</AppPageShell>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-pad { padding: 24rpx 32rpx 80rpx; }
|
||||
.work-card {
|
||||
margin-bottom: 40rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
.thumb-wrap { position: relative; }
|
||||
.thumb { width: 100%; height: 360rpx; display: block; }
|
||||
.thumb--empty { height: 360rpx; background: rgba(212, 179, 131, 0.12); }
|
||||
.thumb-wash {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(180deg, transparent 40%, rgba(0, 0, 0, 0.35) 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.body {
|
||||
padding: 28rpx 28rpx 32rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14rpx;
|
||||
}
|
||||
.title {
|
||||
font-size: 40rpx;
|
||||
color: rgb(var(--art-text));
|
||||
font-family: 'Playfair Display', serif;
|
||||
font-style: italic;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.desc {
|
||||
font-size: 26rpx;
|
||||
line-height: 1.65;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pills { display: flex; flex-wrap: wrap; gap: 12rpx; }
|
||||
.pill {
|
||||
padding: 8rpx 16rpx;
|
||||
border: 1px solid var(--art-card-border);
|
||||
border-radius: 999rpx;
|
||||
font-size: 22rpx;
|
||||
color: rgb(var(--art-muted));
|
||||
}
|
||||
.links { display: flex; flex-wrap: wrap; gap: 12rpx; }
|
||||
.link-chip {
|
||||
padding: 10rpx 20rpx;
|
||||
border: 1px solid var(--art-card-border);
|
||||
border-radius: 999rpx;
|
||||
font-size: 22rpx;
|
||||
color: rgb(var(--art-text));
|
||||
}
|
||||
.cta { font-size: 24rpx; margin-top: 4rpx; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user